From 719716e62f12b6c640897647b453a356ee580c82 Mon Sep 17 00:00:00 2001 From: Travis James Date: Thu, 24 Sep 2026 12:45:59 -0500 Subject: [PATCH] chore(prometheus-skills): update mini agent team bundle Signed-off-by: Travis James --- resources/prometheus-skills-mini | 2 +- resources/skills/agent-team-creator/SKILL.md | 115 +++++ .../agent-team-creator/agents/openai.yaml | 4 + .../agent-team-creator/assets/intake.json | 11 + .../agent-team-creator/references/manifest.md | 146 ++++++ .../references/models-memory.md | 76 +++ .../references/native-harnesses.md | 37 ++ .../references/task-handoff.md | 381 +++++++++++++++ .../agent-team-creator/runtime/.gitignore | 1 + .../runtime/package-lock.json | 442 ++++++++++++++++++ .../agent-team-creator/runtime/package.json | 19 + .../runtime/src/adapters-codecs.mts | 64 +++ .../runtime/src/adapters-local.mts | 106 +++++ .../runtime/src/adapters-services.mts | 69 +++ .../runtime/src/adapters.mts | 91 ++++ .../agent-team-creator/runtime/src/cli.mts | 78 ++++ .../runtime/src/export-files.mts | 33 ++ .../runtime/src/guidance.mts | 62 +++ .../runtime/src/handoff.mts | 81 ++++ .../agent-team-creator/runtime/src/memory.mts | 144 ++++++ .../runtime/src/models-http.mts | 98 ++++ .../agent-team-creator/runtime/src/models.mts | 174 +++++++ .../runtime/src/state-kbd.mts | 83 ++++ .../runtime/src/state-tasks.mts | 105 +++++ .../runtime/src/state-validation.mts | 171 +++++++ .../agent-team-creator/runtime/src/state.mts | 104 +++++ .../agent-team-creator/runtime/src/types.mts | 102 ++++ .../runtime/src/validation.mts | 81 ++++ .../runtime/test-src/export.integration.mts | 118 +++++ .../runtime/test-src/fixture.mts | 38 ++ .../runtime/test-src/kbd.integration.mts | 155 ++++++ .../test-src/models-memory.integration.mts | 244 ++++++++++ .../runtime/test-src/state.integration.mts | 225 +++++++++ .../agent-team-creator/runtime/tsconfig.json | 18 + .../runtime/tsconfig.tests.json | 10 + .../schemas/team.schema.json | 52 +++ .../scripts/adapters-codecs.mjs | 52 +++ .../scripts/adapters-local.mjs | 121 +++++ .../scripts/adapters-services.mjs | 68 +++ .../agent-team-creator/scripts/adapters.mjs | 95 ++++ .../skills/agent-team-creator/scripts/cli.mjs | 82 ++++ .../scripts/export-files.mjs | 36 ++ .../agent-team-creator/scripts/guidance.mjs | 76 +++ .../agent-team-creator/scripts/handoff.mjs | 83 ++++ .../agent-team-creator/scripts/memory.mjs | 151 ++++++ .../scripts/models-http.mjs | 124 +++++ .../agent-team-creator/scripts/models.mjs | 198 ++++++++ .../agent-team-creator/scripts/state-kbd.mjs | 89 ++++ .../scripts/state-tasks.mjs | 119 +++++ .../scripts/state-validation.mjs | 208 +++++++++ .../agent-team-creator/scripts/state.mjs | 136 ++++++ .../agent-team-creator/scripts/types.mjs | 1 + .../agent-team-creator/scripts/validation.mjs | 127 +++++ .../tests/export.integration.mjs | 149 ++++++ .../agent-team-creator/tests/fixture.mjs | 43 ++ .../tests/kbd.integration.mjs | 148 ++++++ .../tests/models-memory.integration.mjs | 242 ++++++++++ .../tests/state.integration.mjs | 224 +++++++++ resources/skills/agent-team-handoff/SKILL.md | 54 +++ .../agent-team-handoff/agents/openai.yaml | 4 + resources/skills/agent-team-manage/SKILL.md | 60 +++ .../agent-team-manage/agents/openai.yaml | 4 + resources/skills/agent-team-models/SKILL.md | 55 +++ .../agent-team-models/agents/openai.yaml | 4 + resources/skills/kbd-assess/SKILL.md | 16 +- resources/skills/kbd-execute/SKILL.md | 98 +++- resources/skills/kbd-init/SKILL.md | 7 +- resources/skills/kbd-plan/SKILL.md | 8 +- .../skills/kbd-process-orchestrator/SKILL.md | 135 +++--- resources/skills/kbd-reflect/SKILL.md | 10 +- resources/skills/kbd-spec/SKILL.md | 44 +- 71 files changed, 6675 insertions(+), 136 deletions(-) create mode 100644 resources/skills/agent-team-creator/SKILL.md create mode 100644 resources/skills/agent-team-creator/agents/openai.yaml create mode 100644 resources/skills/agent-team-creator/assets/intake.json create mode 100644 resources/skills/agent-team-creator/references/manifest.md create mode 100644 resources/skills/agent-team-creator/references/models-memory.md create mode 100644 resources/skills/agent-team-creator/references/native-harnesses.md create mode 100644 resources/skills/agent-team-creator/references/task-handoff.md create mode 100644 resources/skills/agent-team-creator/runtime/.gitignore create mode 100644 resources/skills/agent-team-creator/runtime/package-lock.json create mode 100644 resources/skills/agent-team-creator/runtime/package.json create mode 100644 resources/skills/agent-team-creator/runtime/src/adapters-codecs.mts create mode 100644 resources/skills/agent-team-creator/runtime/src/adapters-local.mts create mode 100644 resources/skills/agent-team-creator/runtime/src/adapters-services.mts create mode 100644 resources/skills/agent-team-creator/runtime/src/adapters.mts create mode 100644 resources/skills/agent-team-creator/runtime/src/cli.mts create mode 100644 resources/skills/agent-team-creator/runtime/src/export-files.mts create mode 100644 resources/skills/agent-team-creator/runtime/src/guidance.mts create mode 100644 resources/skills/agent-team-creator/runtime/src/handoff.mts create mode 100644 resources/skills/agent-team-creator/runtime/src/memory.mts create mode 100644 resources/skills/agent-team-creator/runtime/src/models-http.mts create mode 100644 resources/skills/agent-team-creator/runtime/src/models.mts create mode 100644 resources/skills/agent-team-creator/runtime/src/state-kbd.mts create mode 100644 resources/skills/agent-team-creator/runtime/src/state-tasks.mts create mode 100644 resources/skills/agent-team-creator/runtime/src/state-validation.mts create mode 100644 resources/skills/agent-team-creator/runtime/src/state.mts create mode 100644 resources/skills/agent-team-creator/runtime/src/types.mts create mode 100644 resources/skills/agent-team-creator/runtime/src/validation.mts create mode 100644 resources/skills/agent-team-creator/runtime/test-src/export.integration.mts create mode 100644 resources/skills/agent-team-creator/runtime/test-src/fixture.mts create mode 100644 resources/skills/agent-team-creator/runtime/test-src/kbd.integration.mts create mode 100644 resources/skills/agent-team-creator/runtime/test-src/models-memory.integration.mts create mode 100644 resources/skills/agent-team-creator/runtime/test-src/state.integration.mts create mode 100644 resources/skills/agent-team-creator/runtime/tsconfig.json create mode 100644 resources/skills/agent-team-creator/runtime/tsconfig.tests.json create mode 100644 resources/skills/agent-team-creator/schemas/team.schema.json create mode 100755 resources/skills/agent-team-creator/scripts/adapters-codecs.mjs create mode 100755 resources/skills/agent-team-creator/scripts/adapters-local.mjs create mode 100755 resources/skills/agent-team-creator/scripts/adapters-services.mjs create mode 100755 resources/skills/agent-team-creator/scripts/adapters.mjs create mode 100755 resources/skills/agent-team-creator/scripts/cli.mjs create mode 100755 resources/skills/agent-team-creator/scripts/export-files.mjs create mode 100755 resources/skills/agent-team-creator/scripts/guidance.mjs create mode 100755 resources/skills/agent-team-creator/scripts/handoff.mjs create mode 100755 resources/skills/agent-team-creator/scripts/memory.mjs create mode 100755 resources/skills/agent-team-creator/scripts/models-http.mjs create mode 100755 resources/skills/agent-team-creator/scripts/models.mjs create mode 100755 resources/skills/agent-team-creator/scripts/state-kbd.mjs create mode 100755 resources/skills/agent-team-creator/scripts/state-tasks.mjs create mode 100755 resources/skills/agent-team-creator/scripts/state-validation.mjs create mode 100755 resources/skills/agent-team-creator/scripts/state.mjs create mode 100755 resources/skills/agent-team-creator/scripts/types.mjs create mode 100755 resources/skills/agent-team-creator/scripts/validation.mjs create mode 100644 resources/skills/agent-team-creator/tests/export.integration.mjs create mode 100644 resources/skills/agent-team-creator/tests/fixture.mjs create mode 100644 resources/skills/agent-team-creator/tests/kbd.integration.mjs create mode 100644 resources/skills/agent-team-creator/tests/models-memory.integration.mjs create mode 100644 resources/skills/agent-team-creator/tests/state.integration.mjs create mode 100644 resources/skills/agent-team-handoff/SKILL.md create mode 100644 resources/skills/agent-team-handoff/agents/openai.yaml create mode 100644 resources/skills/agent-team-manage/SKILL.md create mode 100644 resources/skills/agent-team-manage/agents/openai.yaml create mode 100644 resources/skills/agent-team-models/SKILL.md create mode 100644 resources/skills/agent-team-models/agents/openai.yaml diff --git a/resources/prometheus-skills-mini b/resources/prometheus-skills-mini index 86b2c7e1a7e..2782c3a2d3d 160000 --- a/resources/prometheus-skills-mini +++ b/resources/prometheus-skills-mini @@ -1 +1 @@ -Subproject commit 86b2c7e1a7ea21d416782ed90fba19e496f02f88 +Subproject commit 2782c3a2d3d0b2bf3b969a5f55352be48b87d7f6 diff --git a/resources/skills/agent-team-creator/SKILL.md b/resources/skills/agent-team-creator/SKILL.md new file mode 100644 index 00000000000..9b8e49732ba --- /dev/null +++ b/resources/skills/agent-team-creator/SKILL.md @@ -0,0 +1,115 @@ +--- +name: agent-team-creator +description: "Create the smallest useful coding agent team for a task, with guided role discovery and native exports for UAR, Codex, Claude Code, Copilot, Kimi Code, MiniMax CLI, OpenCode and DeepSeek. Use when a user asks to create, configure or choose an agent team; use agent-team-manage for existing task state. Do not use for existing task updates (see agent-team-manage)." +license: MIT +compatibility: Requires Node.js 22 or newer. Git is optional for handoff snapshots. Model gateways, memory services and native harness CLIs are optional and separately configured. +metadata: + version: "1.0.0" + tags: "agents, teams, orchestration, coding" +--- + +# Agent Team Creator + +Help the user choose a useful team, then create inspectable definitions. A role +describes responsibility; a skill provides reusable instructions; a model supplies +capability; a harness owns execution. Do not turn these into one interchangeable +concept or start a second agent loop. + +## Start with the task + +Read project instructions and any active KBD work first. Reuse answers already +given. Ask only for missing outcome, scope, deliverables, budget preference and +review needs. Use ordinary language: “Is this one isolated change, or does it +span design, implementation and verification?” The user need not know agent +terminology. Choose a short team ID and explain it rather than requiring jargon. + +Use [assets/intake.json](assets/intake.json) as the JSON request shape. `guide` +returns missing intake fields when answers are incomplete, or a proposed team +with role explanations and a single-agent alternative. Once task details are known, +the guide returns `proposedRoles` and ownership questions until every proposed role +has output paths in `ownership: {"role-id": ["relative/path/**"]}`. Inspect the +project, suggest concrete disjoint paths, and reuse known scope. Reviewers can read +broadly but need a separate findings output path. Only `ready: true` returns `team`: + +```text +node /scripts/cli.mjs guide --input intake.json +``` + +Recommend one implementer for a small isolated task. Add specialists only for +concrete work that can be assigned separately. Independent review costs another +pass; explain that tradeoff. Let the user refine roles using their existing +authorization and preferences. Do not require a ritual confirmation for every +reversible file creation. Experts may supply a manifest directly. + +The guide’s skill IDs are suggestions, not assertions that anything is installed. +Discover actual installed skills or use an available skills directory/search. +Inspect each chosen skill’s provenance, scope, tools and instructions. Replace +unavailable suggestions, or propose installation when needed; do not silently +install external code. Bind discovered skills to each role’s `skills` array. + +## Define and export + +Save the proposed `team` from guide output. Fill `owns`, inputs, outputs and +dependencies for each role before parallel edits. Empty ownership is unresolved, +not permission over the repository. The [manifest reference](references/manifest.md) +and [JSON schema](schemas/team.schema.json) define the common contract. + +Select models with `$agent-team-models`: declared strength, required capabilities, +and known prices, followed by concrete IDs. Tier labels alone do not configure a +native model. Every native option can be carried in `native..options`, +role-native overrides, or exact `native..files`. Read +[native harness contracts](references/native-harnesses.md) for the selected +target’s mapping, source, plugin support and limitations. Do not load every +harness manual for a single-target task. + +```text +node /scripts/cli.mjs validate --input team-request.json +node /scripts/cli.mjs init --input team-request.json +node /scripts/cli.mjs export --input export-request.json +``` + +`team-request.json` contains `{"team": , "state": }`. +`export-request.json` contains `{"state": , "target": "codex", +"out": }`. Targets are `uar`, `codex`, `claude`, +`copilot`, `kimi`, `minimax`, `opencode`, `deepseek`, and the separate `bossfang` +integration. MiniMax means its own `mcode` CLI. + +Export never overwrites an existing output directory or native configuration. +Inspect `team-export.json`, diagnostics, native files and the source/version +receipt. Preservation of arbitrary options is not semantic validation. Validate +with the installed harness when available, or report source-only support. + +## Install and operate within the requested scope + +Apply reviewed proposals only where the user authorized them. Merge existing +native configuration deliberately; never replace it wholesale. Follow the +native reference for supported project agents or plugin/marketplace installation. +Do not invent plugin agent fields where a harness has none. A plugin installation +does not start an agent team. + +For UAR or BossFang, keep registry registration, activation and execution separate. +Use the exported native payload and verified route, operator-selected instance +URL and environment credential reference. Record each returned native ID and +outcome; multi-agent registration is not atomic. BossFang Hands and standalone +agent/workflow registration are alternative native deployment paths. Choose one. +Do not auto-activate a Hand or run a workflow merely because definitions exist. + +For actual work, the chosen harness owns native spawning, permissions, sessions, +subagent depth and model overrides. Use its available tools/current CLI contract; +the team ledger does not schedule processes or enforce Cedar. Resolve missing +native capabilities explicitly. Use `$agent-team-manage` for tasks and +`$agent-team-handoff` when changing owners/harnesses. + +## Evidence and recovery + +Report generated paths, roles and rationale, chosen model policy, native support +level, unresolved ownership/configuration and the next authorized action. Never +report export as live execution. Node 22+ runs the compiled package without a +root checkout or runtime dependencies. Maintainers rebuild the `.mts` source +with pinned TypeScript 7.0.2 under `runtime/`; full and mini ship identical bytes. + +The local state is a coordination record for trusted collaborators, not a +distributed authorization service. See [task and handoff contracts](references/task-handoff.md) +for revision/lock recovery and [models and memory](references/models-memory.md) +for optional shared services and Karpathy boundaries. Existing KBD state stays +authoritative; never hand-edit its generated projections. diff --git a/resources/skills/agent-team-creator/agents/openai.yaml b/resources/skills/agent-team-creator/agents/openai.yaml new file mode 100644 index 00000000000..52f53ae805d --- /dev/null +++ b/resources/skills/agent-team-creator/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Agent Team Creator" + short_description: "Choose and create a useful coding agent team" + default_prompt: "Use $agent-team-creator to help me choose and create a team for my task." diff --git a/resources/skills/agent-team-creator/assets/intake.json b/resources/skills/agent-team-creator/assets/intake.json new file mode 100644 index 00000000000..a7852bab512 --- /dev/null +++ b/resources/skills/agent-team-creator/assets/intake.json @@ -0,0 +1,11 @@ +{ + "id": "checkout-team", + "outcome": "Implement an accessible checkout with verified payment error handling", + "complexity": "complex", + "areas": ["code", "design", "security", "docs"], + "deliverables": ["Checkout implementation", "Integration evidence", "Operator documentation"], + "budget": "balanced", + "review": true, + "harness": "codex", + "scope": "project" +} diff --git a/resources/skills/agent-team-creator/references/manifest.md b/resources/skills/agent-team-creator/references/manifest.md new file mode 100644 index 00000000000..a4a6bfeb49c --- /dev/null +++ b/resources/skills/agent-team-creator/references/manifest.md @@ -0,0 +1,146 @@ +# Team manifest and CLI + +The portable manifest describes intent and coordination. It is not a native team +API, a scheduler, an authentication credential, or a Cedar policy. The native +harness remains the execution and permission authority. + +## Minimum expert request + +Save this as `team-request.json` in the target project. Paths in requests resolve +against the CLI working directory. Keep state in a local, access-controlled +directory; it contains prompts, evidence and optional memory content. + +```json +{ + "state": ".agent-team/state.json", + "team": { + "schemaVersion": 1, + "id": "feature-team", + "outcome": "Implement the agreed feature and supply integration evidence", + "scope": "project", + "harness": "codex", + "roles": [ + { + "id": "implementer", + "description": "Implement the agreed feature", + "prompt": "Read the project instructions and acceptance criteria. Implement the complete behavior before verification. Report changed files and evidence.", + "skills": [], + "owns": ["src/feature/"], + "inputs": ["Acceptance criteria"], + "outputs": ["Implementation", "Integration evidence"], + "dependsOn": [], + "modelPolicy": { "tier": "medium", "capabilities": ["function_calling"] } + } + ] + } +} +``` + +`scope` chooses project, UAR or BossFang administration intent. It does not select +a network instance or transfer execution authority. `harness` is one of the eight +execution targets. BossFang is a separate export target; it may coordinate UAR or +its own native runtime as configured outside this skill. + +Role IDs are stable portable lowercase identifiers. Role inputs/outputs describe +the handoff contract; `dependsOn` is validated for references and cycles. Native +runtime dependency behavior varies; portable dependencies also gate local tasks. +`owns` is a planning agreement, not an enforced filesystem permission. Assign +disjoint write ownership before parallel work, and let reviewers read broadly. + +## Model binding + +Optional `modelPolicy` exists on the team and each role. `skillPolicies` maps +installed skill IDs to the same policy shape. Tasks can provide `modelPolicy`. +Fields: `model`, `tier` (`low`, `medium`, `hard`), `capabilities` (string array), +`maxInputPerMillion`, `maxOutputPerMillion` (nonnegative USD estimates). + +Use `models-select` before invocation; tier/cost/capability intent is not itself +a native model ID. The exporter translates explicit team/role `model` values +where supported. Skill/task policy selection must be applied through the native +invocation controls, or by updating an appropriate role and re-exporting. + +## Preserving every native option + +The common schema deliberately does not enumerate every native harness setting. +Use the complete current native documentation linked from `native-harnesses.md`: + +- `roles[].native.` overrides that role's generated native fields. +- `native..options` supplies target-specific settings or defaults. Its + meaning is explicit in the adapter reference; some targets preserve it in + `native-options.json` for manual application rather than inventing a config path. +- `native..files` maps relative paths to exact UTF-8 text for native hooks, + MCP settings, plugin source, profiles, configuration or other options. +- Each team-native wrapper includes `source` and `version` recording the contract + the user chose. Native role overrides and the built-in source contract are + retained in the export receipt too. + +Example extension inside `team`: + +```json +{ + "native": { + "codex": { + "version": "installed version recorded by operator", + "source": "https://developers.openai.com/codex/config-reference/", + "options": { "agents": { "max_threads": 4 } }, + "files": { ".codex/team-notes.md": "Project-specific native configuration notes\n" } + } + } +} +``` + +Unknown common fields fail to prevent silently misspelled configuration. Native +options are preserved without claiming their semantic validity. Objects merge +recursively and arrays replace. JSON `null` cannot be serialized to TOML; omit +the field or supply a separate native file. Native file paths cannot escape the +proposal root, use Windows-reserved names, or collide with generated files, +case variants or directories. Generated files cannot be silently replaced by +opaque files. Change supported fields through role/options overrides, or author +an independently reviewed native file outside the automatic export path. + +The generated receipt identifies source-only versus live verification. Validate +against the installed version before applying options. Do not commit real +credentials in any native file; use the target's environment/credential facility. +This escape hatch preserves future options; it does not assert future support. + +## CLI protocol + +All commands use one UTF-8 JSON request and return JSON. Malformed requests and +conflicts exit nonzero. No shell quoting or shell-specific redirection is needed: + +```text +node /scripts/cli.mjs --input request.json +``` + +| Command | Request | +|---|---| +| `guide` | Intake fields from `assets/intake.json`, followed by `ownership` mapping role IDs to nonempty arrays of project-relative output paths/globs. Missing scope returns questions and `proposedRoles`; only `ready: true` returns `team` | +| `validate` | `team` manifest | +| `init` | `team`, new `state` filename | +| `status` | `state` | +| `team-update` | `state`, `expectedRevision`, replacement same-ID `team` | +| `export` | `team` or `state`, `target`, new `out` directory | +| `task`, `complete-kbd` | See `task-handoff.md` | +| `handoff-create`, `handoff-accept` | See `task-handoff.md` | +| `models-discover`, `models-select` | See `models-memory.md` | +| `memory-queue`, `memory-publish` | See `models-memory.md` | + +Exports are proposals, never in-place installation. If a write fails partway, +the incomplete directory remains inspectable and lacks its final receipt. Choose +a new output directory for a retry. No global tool installation or remote agent +registration occurs through this CLI. Skills guide native installation and +execution using the user's authorized target and the actual harness contract. + +## Building and distributing + +`runtime/package.json` pins TypeScript 7.0.2 and Node type declarations. Run +`npm ci --prefix /runtime` then `npm run build --prefix /runtime` +when maintaining source. Runtime consumers need only Node.js 22+ and the copied +skill files; they do not need npm, TypeScript or the repository checkout. Full +and mini distribute identical source and emitted `.mjs` files. Each sibling skill +declares its dependency on this creator companion in its instructions. + +The four SKILL.md frontmatters follow the +[AgentSkills specification](https://agentskills.io/specification): version and +comma-separated tags live as string-valued metadata. Existing pack extensions +remain accepted for backward compatibility but are not required by these skills. diff --git a/resources/skills/agent-team-creator/references/models-memory.md b/resources/skills/agent-team-creator/references/models-memory.md new file mode 100644 index 00000000000..fea0bdae461 --- /dev/null +++ b/resources/skills/agent-team-creator/references/models-memory.md @@ -0,0 +1,76 @@ +# Models and optional memory + +These are source-based adapter contracts, not live server certification. The packaged runtime requires Node >=22, no runtime dependencies and no extra resident service. Discovery never starts inference; memory publication only sends explicitly queued content to the configured endpoint. + +## Discovery API + +`discoverModels(input: ObjectValue): Promise` accepts `kind: "openai" | "uar" | "bossfang"`, `discoveryUrl`, optional `auth`, `timeoutMs`, `catalog`, `aliases`, `tiers`, and `maxCatalogAgeDays`. + +For OpenAI-compatible discovery, `baseUrl` may replace `discoveryUrl`; it appends `/v1/models` without duplicating a trailing `/v1`. UAR and BossFang require the exact configured discovery URL: UAR's provider-specific `/api/uar/providers//models` or BossFang's `/api/models`. Responses are respectively `{data:[...]}`, a model array, and `{models:[...]}`. Native capability flags are configured metadata; availability is not proof of successful inference. + +Authentication uses environment references, never literal values: + +```json +{"kind":"openai","baseUrl":"http://127.0.0.1:8000","auth":{"env":"GATEWAY_API_KEY"},"timeoutMs":10000} +``` + +`auth.header` supports `Authorization` (default) or `X-API-Key`; `auth.scheme` supports `Bearer` (Authorization default) or `raw` (X-API-Key default). URLs reject userinfo, fragments and credential query parameters; HTTP is allowed only for loopback hosts. Redirects are refused. Timeouts are 1–60,000 ms; JSON responses are limited to 8 MiB. Remote error bodies and raw fetch errors are not logged or persisted. + +Supply the actual liter-llm schema-1 document under `catalog`; it has `$schema_version: 1`, `$provenance`, and provider/model maps. Gateway aliases require explicit mappings: + +```json +{"aliases":{"gateway-alias":{"provider":"catalog-provider","model":"catalog-model-id"}},"tiers":{"gateway-alias":"medium"},"maxCatalogAgeDays":30} +``` + +Every mapped catalog model must exist. Similar names and provider prefixes never establish identity. Unmapped aliases retain unknown catalog metadata. Strength tiers are only the operator's `low`, `medium`, or `hard`; native routing tiers are not reinterpreted. + +The result is `{schemaVersion:1, models, catalogProvenance, catalogFreshness, diagnostics}`. Models retain exact IDs, nullable declared tier, known capability booleans, nullable prices, provenance and freshness. OpenAI-compatible aliases are discovery-listed; UAR `enabled` and BossFang `available` determine configured eligibility. None certifies live inference. + +liter-llm prices are USD **per token**. Conversion multiplies by 1,000,000. A ceiling compares the maximum base/context-tier price; any unknown tier price leaves that side unknown. BossFang's own costs are already per million and are labeled configured base prices. These are text input/output comparisons, not bounds on entire bills, cache, audio, image, reasoning or future rates. Provenance retains source/hash/fetch date/library version; missing or future fetch dates yield unknown staleness. Stale catalog metadata is reported, not silently refreshed. + +## Selection API + +`selectModel(team, roleId, skills: string[], taskPolicy: ModelPolicy, catalog: unknown): Json` accepts the discovery result or `{catalog, availableModels:["gateway-alias"], aliases, tiers, maxCatalogAgeDays}`. This second form is explicitly **operator-declared availability**, not live discovery. A catalog alone does not prove availability. + +Policies resolve team → role → ordered skills → task. Scalars override earlier values; capabilities accumulate and every required capability must be explicitly true. Empty task capabilities do not erase team requirements. Shared strict policy validation rejects unknown fields and invalid ceilings. Required tiers match exactly; prices of unknown value cannot satisfy a ceiling. + +Eligible models sort by lowest known input+output per-million price, then exact identifier. Unknown prices sort last when no ceiling excludes them. This is deterministic comparison, not a workload cost prediction. Output includes `selected` (null if none), effective `policy`, `appliedLayers`, rejections with reasons, explanation and warnings. Selected stale/unknown price freshness generates an explicit warning that ceilings do not guarantee current rates. + +## Memory APIs and persistence + +`queueMemory(state, input): MemoryEntry` accepts `{content, scope, provenance?, id?}`. The caller must commit its mutation using the state's lock/revision transaction **before** publication. An omitted ID is a stable content/scope/provenance digest. Repeating identical input returns the existing entry; an ID collision with different content fails. Published entries remain in the outbox. + +`publishMemory(state, input): Promise` requires a persisted queued `id`. It mutates the receipt and status in the supplied state; it does not write the file itself. Missing endpoint or remote failure returns `status: "queued"`; the caller must commit that receipt. Repeated publication of a published entry returns its recorded receipt without another request. + +The verified surreal-memory REST adapter accepts: + +```json +{"id":"memory-example","provider":"surreal-memory","url":"http://127.0.0.1:8001/api/v1/memory/","scopeMapping":{"scope":"team:example","agentId":"example-team","userId":"anonymous"},"auth":{"env":"MEMORY_API_KEY"}} +``` + +The port is operator configuration. `scopeMapping.scope` must exactly equal the queued logical scope; `agentId` is required, `userId` and `sessionId` optional. The actual request has `content`, `agent_id`, `user_id`, `session_id`, and `categories`. No metadata/idempotency field is invented: content contains an envelope preserving local ID, scope, provenance and original content. + +**Scope limitation:** identity fields are retrieval filters, not an authorization guarantee. The inspected REST constructor defaults the stored scope enum to global. This adapter does not claim private server scope; use an appropriately authorized deployment or explicitly mapped API when enforced isolation is needed. + +Other providers use `provider: "mapped-http"` and an explicit mapping: + +```json +{"source":"https://memory.example/docs/store","version":"deployed-version","method":"POST","contentField":"text","scopeField":"scope","provenanceField":"provenance","idempotencyField":"request_id","idempotencyHeader":"Idempotency-Key","responseIdField":"id","constants":{}} +``` + +Mapping fields are top-level JSON properties; collisions fail. POST/PUT are supported. Scope/provenance mappings are mandatory. Idempotency field/header are optional and do not certify a remote guarantee. No unspecified MCP tool is invented. Endpoint and mapping are fingerprinted, so retries cannot silently change destinations. + +Receipts retain local ID, payload fingerprint, target contract, HTTP status when known, remote ID on success, and uncertainty. **Exactly-once delivery is not guaranteed.** The verified surreal-memory implementation performs similarity deduplication, not request-key deduplication. Failed transports, invalid success responses and server errors can hide committed remote work; reconcile before setting `retryUncertain: true`. A process crash after remote commit but before local persistence also requires reconciliation. + +Literal credential fields and known secret environment values are rejected from persisted structures. This is not a general secret scanner: do not place credentials in content/provenance/aliases. Unavailable services do not prevent local queuing or other team work. + +## KBD and Karpathy boundaries + +Optional `provenance.kbd` must exactly match a linked team task's five-part identity. This validates the local reference only; it is labeled an unverified mirror. Neither memory API emits Karpathy boundaries, completes KBD tasks, or writes knowledge bundles. Validate canonical identity and complete a real KBD boundary through its actual CLI, then use the existing Karpathy procedure and real receipt. `pk` remains the sole knowledge-bundle writer. + +## Primary source contracts + +- [liter-llm schema-1 catalog](https://github.com/GQAdonis/liter-llm/blob/c5c6caac617eb931cd5009146a70831422ec236c/schemas/catalog.json), [price transformation](https://github.com/GQAdonis/liter-llm/blob/c5c6caac617eb931cd5009146a70831422ec236c/crates/liter-llm-catalog-gen/src/transform.rs), and [configured alias discovery](https://github.com/GQAdonis/liter-llm/blob/c5c6caac617eb931cd5009146a70831422ec236c/crates/liter-llm-proxy/src/routes/models.rs). +- [UAR provider route](https://github.com/Prometheus-AGS/universal-agent-runtime/blob/ba12845138104d3c8c3b8bca8bc7c5be24004e91/src/uar/api/providers.rs) and [ModelConfig](https://github.com/Prometheus-AGS/universal-agent-runtime/blob/ba12845138104d3c8c3b8bca8bc7c5be24004e91/src/llm/registry.rs). +- BossFang: local fork `crates/librefang-api/src/routes/providers.rs`, `list_models`; retain the deployment source/version with registration artifacts. +- [surreal-memory request](https://github.com/Prometheus-AGS/surreal-memory-server/blob/dd7fdcd6d8974af4059d1d51401bd33ae29f65db/src/contracts.rs), [HTTP handler](https://github.com/Prometheus-AGS/surreal-memory-server/blob/dd7fdcd6d8974af4059d1d51401bd33ae29f65db/src/api/memory.rs), [scope constructor](https://github.com/Prometheus-AGS/surreal-memory-server/blob/dd7fdcd6d8974af4059d1d51401bd33ae29f65db/crates/surreal-memory/src/memory.rs), and [similarity deduplication](https://github.com/Prometheus-AGS/surreal-memory-server/blob/dd7fdcd6d8974af4059d1d51401bd33ae29f65db/crates/surreal-memory/src/storage/surreal.rs). diff --git a/resources/skills/agent-team-creator/references/native-harnesses.md b/resources/skills/agent-team-creator/references/native-harnesses.md new file mode 100644 index 00000000000..e151d214490 --- /dev/null +++ b/resources/skills/agent-team-creator/references/native-harnesses.md @@ -0,0 +1,37 @@ +# Native export contracts + +Source inspection: 2026-09-24. These are source-verified staging adapters, not installed CLI certification. Documentation snapshots are identified as snapshots, not invented release pins. Every export includes `export-receipt.json` with the adapter source/version and caller-supplied native provenance; the staging command adds its separate `team-export.json` receipt. + +`exportTeam(team, target)` returns an `ExportResult` containing file contents, diagnostics, instructions and verification. It never writes files, installs packages, registers agents, starts processes, activates Hands or invokes agents. Native execution loops retain authority. Role ownership and dependencies in prompts are coordination instructions, not permission enforcement. + +## Configuration preservation + +Role `native[target]` objects merge recursively over native agent defaults; arrays replace. Unknown fields survive and require native validation. OpenCode's native `prompt` override becomes the Markdown body. DeepSeek role native values configure its persona plugin. The other Markdown harnesses use native frontmatter, with the portable prompt as the body; supply arbitrary standalone native files for richer unsupported formats. Literal `${base_prompt}` is not interpolated. Claude plugin agents ignore `hooks`, `mcpServers`, `permissionMode`, `initialPrompt` and `omitClaudeMd`; exports diagnose supplied fields and retain the project agent alternative. Current [Copilot source types](https://github.com/github/copilot-cli/blob/main/_autodocs/types.md) support `skills: string[]` for startup loading. + +Team `native[target].options` means native Codex project config, Claude project settings, OpenCode project config, UAR per-artifact defaults, BossFang Hand definition overrides, or DeepSeek experimental team service configuration. For Kimi, MiniMax and Copilot the object is retained in `native-options.json` without automatic application because this exporter has not verified a suitable project-level configuration location. All targets preserve a verbatim JSON rendering of the options and caller source/version. This preservation is not validation or application. + +`native[target].files` preserves UTF-8 strings at supplied staging-relative paths. Traversal, absolute/Windows drive paths, backslashes, portable reserved path components, case-insensitive collisions and file/directory overlaps fail. The staging/provenance/options filenames are reserved. Generated files cannot be replaced through opaque files; use role overrides or choose a distinct path and explicitly install that alternate artifact. IDs use portable lowercase kebab-case. No credentials should be embedded in manifests: reference native credential environment settings instead. + +JSON flow syntax is used as YAML 1.2 for frontmatter and Cordis patch files. This preserves literal quotes, newlines and arbitrary JSON option structures without hand-built YAML interpolation. TOML uses quoted keys and nested inline tables; null values fail because TOML cannot represent null. + +## Harnesses + +| Target | Emitted native artifacts | Contract and limits | +| --- | --- | --- | +| Codex | `.codex/agents/.toml`, optional `.codex/config.toml` | Required `name`, `description`, `developer_instructions`; native `model` and other config keys are retained. [Standalone subagent docs](https://learn.chatgpt.com/docs/agent-configuration/subagents). Plugin skill support does not verify an `agents` plugin manifest field, so none is invented. | +| Claude Code | `.claude/agents/.md`, optional `.claude/settings.json`; alternative plugin `plugins//agents/` and `.claude-plugin/plugin.json`; `.claude-plugin/marketplace.json` | Native subagents, not automatic experimental agent-team creation. Install either project agents or the plugin copy. [Subagents](https://code.claude.com/docs/en/sub-agents), [plugin structure](https://github.com/anthropics/claude-code/blob/main/plugins/plugin-dev/skills/plugin-structure/SKILL.md), [marketplace schema example](https://github.com/anthropics/claude-code/blob/main/.claude-plugin/marketplace.json). | +| Copilot | `.github/agents/.agent.md` | Native custom-agent frontmatter and body. CLI `/agent` or `--agent` selection is separate from Fleet execution. No unverified marketplace mapping emitted. [Configuration](https://docs.github.com/en/copilot/reference/custom-agents-configuration). | +| Kimi Code | `.kimi-code/agents/.md`; alternative `plugins//kimi.plugin.json` with `agents` directories, and v2 `marketplace.json` | Current MoonshotAI/kimi-code, not archived kimi-cli. Role model frontmatter is ignored; choose invocation/global secondary model separately. Project or plugin agent discovery does not create a running team. [Agents](https://github.com/MoonshotAI/kimi-code/blob/main/docs/en/customization/agents.md), [plugins and v2 marketplace](https://github.com/MoonshotAI/kimi-code/blob/main/docs/en/customization/plugins.md), [manifest schema](https://github.com/MoonshotAI/kimi-code/blob/main/packages/klient/src/contract/global/plugins.ts). | +| MiniMax | `agents//agent.md`, relative to the active user-data directory | Inspected `@minimax-ai/code` 0.4.12. Destination is `MINIMAX_DATA_DIR`, `MAVIS_DATA_DIR`, or default `~/.minimax`, not an assumed project agent directory. `mcode exec` lacks a custom-agent selector. Plugin manifest has no agent mapping. [Canonical frontmatter](https://github.com/MiniMax-AI/minimax-code/blob/main/packages/local-runtime-v2/src/service/agent/storage/canonical-agent-config.ts), [CLI contract](https://github.com/MiniMax-AI/minimax-code/blob/main/packages/tui/src/cli/contract.ts). | +| OpenCode | `.opencode/agents/.md`, optional `opencode.json` | Deployed singular `agent`, `permission`, `prompt` schema. Colliding config/Markdown agent names fail. Plugins are a separate JS/TS/npm facility. [Agents](https://opencode.ai/docs/agents/), [plugins](https://opencode.ai/docs/plugins/). | +| DeepSeek Harness | `.dsh/profiles/-/cordis.patch.yml`; separate `-team` composition profile | Cordis `insert` entries configure persona roles. Separate composition enables durable session storage and both experimental team packages; it does not declare or create members. Team options configure `dsh-experimental-agent-team`. No per-member model, remote worker or worktree isolation is promised. [Cordis publishing](https://github.com/deepseek-ai/deepseek-harness/blob/master/docs/user/develop/basic/publish.md), [persona](https://github.com/deepseek-ai/deepseek-harness/blob/master/packages/preset/persona/README.md), [experimental team source](https://github.com/deepseek-ai/deepseek-harness/blob/master/packages/experimental/agent-team/README.md). | + +## Service registration artifacts + +UAR 1.0.0 source commit `ba12845138104d3c8c3b8bca8bc7c5be24004e91`: `src/uar/domain/artifact.rs`, `api/discovery.rs`, `api/routes.rs`, `api/compiler.rs`, `security/middleware.rs`. Each `uar/agents/.json` is a complete `AgentArtifact` for `POST /api/agents`. `runtime.entry=default`; policy provider defaults can inherit through empty strings. `policy.skills.prefer` is a preference, not a deny policy. UAR role overrides apply after team defaults. Explicit tool allowlists and bundles start empty; select required native tool permissions through overrides before registration. No persistent native team API is asserted. Execution uses a separate `POST /api/uar/runs` with full `artifact` and `input`; it is not attempted by export. + +BossFang 2026.7.11 source commit `c719a4d683e4d3fb42e436f812e0193f865c9d2c`: `crates/librefang-types/src/agent.rs`, `crates/librefang-hands/src/lib.rs`, API routes `agents/lifecycle.rs`, `skills/hands.rs`, `workflows/workflow.rs`. Native agent TOML stores the prompt inside `model.system_prompt`. Standalone request JSON wraps it as `manifest_toml` for `POST /api/agents`. An empty portable skill list exports `skills_disabled=true`; native `skills=[]` with `skills_disabled=false` means all. Agent MCP `[]` means none and `["*"]` means all. Preserve the original manifest because native GET is a projection. + +The alternative multi-agent `HAND.toml` uses native `agents.` entries and explicit coordinator, and `hand-install.json` targets `POST /api/hands/install`. `HandAgentManifest` flattens the native manifest (`librefang-hands/src/lib.rs:338–354`); `parse_multi_agent_entry` accepts nested model tables (`:441–531`). Hand-level allowlists retain their own native semantics. Team native options merge into this Hand definition. Activation is a separate action that may launch autonomous schedules. `workflow.json` uses native `agent_name`, `prompt` and `depends_on` fields and targets `POST /api/workflows` after standalone agents are registered. The route parses dependencies (`routes/workflows/workflow.rs:50–80`); nonempty edges select DAG execution and topological layers (`librefang-kernel/src/workflow.rs:3710–3712`, `:5182`). Choose the standalone workflow or Hand deployment deliberately to avoid duplicate agents. + +Each `registration-plan.json` is explicitly an exporter review plan, not an API body. An operator must supply base URL and credential reference and retain per-request returned IDs/outcomes. Registration is not atomic; UAR defaults JWT-required, while BossFang supports Bearer or X-API-Key. Discovery health does not grant mutation authority. No live service acceptance or authentication validation is claimed. diff --git a/resources/skills/agent-team-creator/references/task-handoff.md b/resources/skills/agent-team-creator/references/task-handoff.md new file mode 100644 index 00000000000..a4df4f8d962 --- /dev/null +++ b/resources/skills/agent-team-creator/references/task-handoff.md @@ -0,0 +1,381 @@ +# Task state and handoff requests + +Use Node.js 22 or newer. Save each request as a UTF-8 JSON file, then invoke the packaged entry point: + +```text +node /scripts/cli.mjs --input request.json +``` + +Replace `` with the installed **agent-team-creator** directory. The sibling manage and handoff skills use this same runtime. Relative `state`, `cwd`, and request-file paths resolve from the process working directory. A command returns JSON on stdout; failures return an error on stderr and a nonzero exit code. The examples use JSON files directly and require no shell redirects or pipelines. + +## Initialize, inspect, and revise the team + +Save `init.json`: + +```json +{ + "state": ".agent-teams/docs-team.json", + "team": { + "schemaVersion": 1, + "id": "docs-team", + "outcome": "Implement a small documentation improvement and independently review it", + "scope": "project", + "harness": "codex", + "roles": [ + { + "id": "implementer", + "description": "Makes the requested documentation change", + "prompt": "Implement the assigned change within docs/. Report evidence and remaining work.", + "skills": [], "owns": ["docs/**"], + "inputs": ["Task requirements"], "outputs": ["Documentation patch"], + "dependsOn": [] + }, + { + "id": "reviewer", + "description": "Independently checks the patch", + "prompt": "Review the assigned patch and record actionable findings. Do not edit the implementer's files.", + "skills": [], "owns": ["reviews/**"], + "inputs": ["Documentation patch"], "outputs": ["Review findings"], + "dependsOn": ["implementer"] + } + ] + } +} +``` + +```text +node /scripts/cli.mjs init --input init.json +``` + +Initialization creates state revision `0` with empty tasks, handoffs, events, and memory outbox. It refuses to replace an existing state file. These roles describe responsibilities; initialization does not launch agents, install skills, or enforce `owns` paths. + +Save `status.json`: + +```json +{"state": ".agent-teams/docs-team.json"} +``` + +```text +node /scripts/cli.mjs status --input status.json +``` + +Use `team-update` to replace the entire manifest while retaining its `id`. It is not a patch operation. Save `team-update.json`, copying the current state revision into `expectedRevision`: + +```json +{ + "state": ".agent-teams/docs-team.json", + "expectedRevision": 0, + "team": { + "schemaVersion": 1, + "id": "docs-team", + "outcome": "Improve the setup guide and independently verify every documented step", + "scope": "project", + "harness": "codex", + "roles": [ + { + "id": "implementer", "description": "Updates the setup guide", + "prompt": "Implement the assigned documentation change and record evidence.", + "skills": [], "owns": ["docs/**"], "inputs": ["Task requirements"], + "outputs": ["Documentation patch"], "dependsOn": [] + }, + { + "id": "reviewer", "description": "Verifies the setup instructions", + "prompt": "Independently review the patch and record findings in reviews/.", + "skills": [], "owns": ["reviews/**"], "inputs": ["Documentation patch"], + "outputs": ["Review findings"], "dependsOn": ["implementer"] + } + ] + } +} +``` + +```text +node /scripts/cli.mjs team-update --input team-update.json +``` + +Keep roles referenced by tasks or by either endpoint of historical handoffs. Reassigning an active task does not erase historical handoff role references. Updating the manifest does not regenerate native exports or change existing task harnesses. Role dependencies describe the team; task execution uses each task's explicit `dependsOn` list. + +## Mixed-harness implementation and independent review + +The team manifest supplies one default harness. Each task and handoff destination can select a different harness. For a Codex implementation with a Claude Code reviewer: + +1. Export the team to separate Codex and Claude proposal directories. Select the intended Codex implementer and Claude reviewer definitions before installation; each export contains all roles. Verify destination skills and model availability independently. +2. Add an implementation task owned by `implementer` with `harness: "codex"`. Add a separate review task owned by `reviewer` with `harness: "claude"` and `dependsOn: ["implementation-task-id"]`. Role dependencies do not automatically create task dependencies. +3. Complete implementation with actual evidence. Start the review task only after its dependency is complete. Record review findings and completion on that separate task. +4. If dispatch requires destination acceptance, initially assign the review task to the dispatching role, then create a handoff for that review task to `reviewer`/`claude`. Acceptance transfers ownership; the dependency still prevents premature start. Re-read revisions before every mutation. + +Reassigning the implementation task is useful when another harness continues the same work. Independent review uses its own task so its evidence and responsibility remain distinct. Exports and assignments do not launch either harness. + +## Two revision checks + +Every modifying command after initialization requires top-level `expectedRevision`, taken from the latest state. Updating an existing task also requires `owner` and `expectedTaskRevision`, taken from that task. Both revisions are nonnegative integers. A stale revision or incorrect owner fails before persistence; do not retry by guessing the next number. Read `status`, review intervening changes, then submit the intended operation with current values. + +A successful change increments state revision once. A task change increments that task's revision once. Adding a task starts its revision at `0`. Creating a handoff increments state revision but leaves task revision and owner unchanged. An unchanged operation, including a valid duplicate handoff acceptance, leaves state revision unchanged. + +The following lifecycle examples illustrate successive operations **after a fresh init, without the optional team-update above**. If any other operation occurs, replace the illustrated revisions with values from `status`. + +## Task lifecycle + +Save `add-task.json`: + +```json +{ + "state": ".agent-teams/docs-team.json", "expectedRevision": 0, + "task": { + "action": "add", "id": "setup-guide", "title": "Clarify the setup guide", + "owner": "implementer", "harness": "codex", + "dependsOn": [], "evidence": [], "remaining": ["Update the examples and check them"] + } +} +``` + +```text +node /scripts/cli.mjs task --input add-task.json +``` + +`add` requires `id`, `title`, and an existing role as `owner`. `harness` defaults to the team's harness. `dependsOn`, `evidence`, and `remaining` default to empty arrays. Optional `modelPolicy` follows the manifest policy schema; optional `kbd` links canonical identity as described below. New tasks are `pending`. Dependencies must already exist, cannot refer to the task itself, cannot repeat, and must form an acyclic graph. + +Save `start-task.json`: + +```json +{ + "state": ".agent-teams/docs-team.json", "expectedRevision": 1, + "task": {"action": "start", "id": "setup-guide", "owner": "implementer", "expectedTaskRevision": 0} +} +``` + +```text +node /scripts/cli.mjs task --input start-task.json +``` + +Only `pending` or `blocked` tasks can start. Every dependency must be `complete`; cancellation does not satisfy a dependency. Starting records local `running` status and does not launch a harness. + +Save `block-task.json`: + +```json +{ + "state": ".agent-teams/docs-team.json", "expectedRevision": 2, + "task": { + "action": "block", "id": "setup-guide", "owner": "implementer", "expectedTaskRevision": 1, + "reason": "Need the supported runtime version", "evidence": ["notes/version-question.md"] + } +} +``` + +```text +node /scripts/cli.mjs task --input block-task.json +``` + +`block` accepts `pending` or `running` tasks and requires `reason`. The reason remains in `remaining`, including when an explicit `remaining` array is supplied. To resume after resolving it, save `resume-task.json`: + +```json +{ + "state": ".agent-teams/docs-team.json", "expectedRevision": 3, + "task": { + "action": "start", "id": "setup-guide", "owner": "implementer", "expectedTaskRevision": 2, + "remaining": ["Check the corrected setup examples"] + } +} +``` + +```text +node /scripts/cli.mjs task --input resume-task.json +``` + +An explicit reassignment transfers local ownership immediately; use a handoff instead when the destination must accept first. Save `reassign-task.json`: + +```json +{ + "state": ".agent-teams/docs-team.json", "expectedRevision": 4, + "task": { + "action": "reassign", "id": "setup-guide", "owner": "implementer", "expectedTaskRevision": 3, + "toOwner": "reviewer", "toHarness": "claude" + } +} +``` + +```text +node /scripts/cli.mjs task --input reassign-task.json +``` + +The destination owner must be an existing role. `toHarness` defaults to the task's current harness, and at least one of owner/harness must change. Reassigning a running task returns it to `pending`; a blocked task stays blocked. The runtime does not stop the source process. Coordinate that separately before another worker edits the same files. + +Save `start-review.json`, then run it: + +```json +{ + "state": ".agent-teams/docs-team.json", "expectedRevision": 5, + "task": {"action": "start", "id": "setup-guide", "owner": "reviewer", "expectedTaskRevision": 4} +} +``` + +```text +node /scripts/cli.mjs task --input start-review.json +``` + +Save `complete-task.json`: + +```json +{ + "state": ".agent-teams/docs-team.json", "expectedRevision": 6, + "task": { + "action": "complete", "id": "setup-guide", "owner": "reviewer", "expectedTaskRevision": 5, + "evidence": ["reviews/setup-guide.md"], "remaining": [] + } +} +``` + +```text +node /scripts/cli.mjs task --input complete-task.json +``` + +Completion requires `running` status, all dependencies complete, a supplied `evidence` array, at least one evidence entry after merging prior evidence, and no remaining work. Evidence entries are references or descriptions; the runtime does not independently verify their claims. Explicit `remaining: []` clears previously recorded remaining work. + +As an **alternative to completion**, save `cancel-task.json` at the same pre-completion snapshot: + +```json +{ + "state": ".agent-teams/docs-team.json", "expectedRevision": 6, + "task": { + "action": "cancel", "id": "setup-guide", "owner": "reviewer", "expectedTaskRevision": 5, + "reason": "The requested guide change was withdrawn" + } +} +``` + +```text +node /scripts/cli.mjs task --input cancel-task.json +``` + +Cancellation requires a reason and is available for any nonterminal task. `complete` and `cancelled` are terminal: they cannot be restarted, reassigned, accepted through a handoff, or deleted through the runtime. Create a new task for subsequent work. Updating evidence on nonterminal actions merges it with existing evidence; an explicit `remaining` array replaces the current list, subject to the block-reason rule above. + +## Create and accept a handoff + +This separate example assumes a task named `setup-guide` is still owned by `implementer`, running on `codex` at task revision `1`, with state revision `2`. These are the values immediately after the earlier start example. A handoff is a context packet; creating it does not transfer ownership or dispatch the destination harness. + +Save `handoff-create.json`: + +```json +{ + "state": ".agent-teams/docs-team.json", "expectedRevision": 2, "cwd": ".", + "handoff": { + "taskId": "setup-guide", "owner": "implementer", "expectedTaskRevision": 1, + "toOwner": "reviewer", "toHarness": "claude", + "context": "The examples are drafted. Review the patch and verify the setup instructions.", + "evidence": ["docs/setup.md"], + "remaining": ["Check the examples and record findings"], + "memoryRefs": ["notes/setup-decisions.md"] + } +} +``` + +```text +node /scripts/cli.mjs handoff-create --input handoff-create.json +``` + +The field is `handoff.taskId`, while task actions use `task.id`. All listed handoff fields are required. Evidence, remaining work, and memory references may be empty arrays; existing task evidence and remaining work are preserved in the packet. Source and destination must differ by owner or harness. Harness identifiers are `uar`, `codex`, `claude`, `copilot`, `kimi`, `minimax`, `opencode`, or `deepseek`; `bossfang` is an export target, not a handoff harness identifier. + +The returned state contains a generated `handoffs[].id`, the source task revision, source/destination identities, context, evidence, remaining work, memory references, and a fresh-context `prompt`. It captures real Git root, HEAD, branch, and dirty status, including untracked files, using read-only Git commands. Git failures leave unavailable fields `null`; detached HEAD may have a known commit and `branch: null`. A fallback `git.root` is the requested directory, not proof that it is a repository. `dirty: null` means unknown, never clean. This snapshot does not copy the working tree, untracked files, memory content, or evidence files, and it may become stale as work continues. + +Open the destination harness separately, provide the fresh prompt and reachable artifacts, and review destination instructions. Source session identifiers, credentials, permissions, native approvals, and sandbox capabilities do not transfer. The destination's native controls remain authoritative. + +Copy the generated ID into `handoff-accept.json`: + +```json +{ + "state": ".agent-teams/docs-team.json", "expectedRevision": 3, + "id": "REPLACE_WITH_HANDOFF_ID", + "destination": {"owner": "reviewer", "harness": "claude"} +} +``` + +```text +node /scripts/cli.mjs handoff-accept --input handoff-accept.json +``` + +Acceptance checks the targeted owner/harness against the packet, then checks the task's current owner, source harness, and recorded task revision. A stale, reassigned, cancelled, or completed task is rejected. On success, ownership and harness transfer, task revision advances to `2`, a running task becomes `pending`, packet evidence and remaining work are retained, and `acceptedAt` plus an acceptance event commit atomically in the same state file. Blocked tasks stay blocked. The destination must explicitly start its task before completing it. + +At this snapshot state revision is `4`. To retry the **same** acceptance, use the same ID and destination but set `expectedRevision` to **4**, or to the latest state revision if unrelated work changed it. Reusing the original `expectedRevision: 3` fails the state check before idempotency is considered. An unchanged duplicate returns the same receipt without incrementing either revision. If the accepted task subsequently changes revision, owner, harness, or becomes terminal, the old acceptance is rejected as stale. This prevents an old packet from reclaiming ownership after later work. + +Handoff snapshots and accepted receipts are immutable through the runtime. Task events are append-only. A rejected or superseded pending packet remains historical; create a fresh handoff from the current task when needed. + +## Complete a task linked to canonical KBD + +Attach `kbd` when adding the task. Read all five identity values from the real canonical KBD state, rather than assuming the local team/task names match. Example `add-kbd-task.json` below assumes state revision `7` after ordinary completion above; replace placeholders and revision before use: + +```json +{ + "state": ".agent-teams/docs-team.json", "expectedRevision": 7, + "task": { + "action": "add", "id": "canonical-docs", "title": "Deliver the KBD documentation task", + "owner": "implementer", "harness": "codex", "dependsOn": [], + "evidence": [], "remaining": [], + "kbd": { + "projectId": "PROJECT_ID_FROM_KBD_STATUS", "runId": "RUN_ID_FROM_KBD_STATUS", + "phaseId": "PHASE_ID_FROM_KBD_STATUS", "changeId": "CHANGE_ID_FROM_KBD_STATUS", + "taskId": "TASK_ID_FROM_KBD_STATUS" + } + } +} +``` + +```text +node /scripts/cli.mjs task --input add-kbd-task.json +``` + +Adding a link validates its structure only; it does not register or start canonical work. Canonical identity is immutable on that team task. Save `start-kbd-task.json`: + +```json +{ + "state": ".agent-teams/docs-team.json", "expectedRevision": 8, + "task": {"action": "start", "id": "canonical-docs", "owner": "implementer", "expectedTaskRevision": 0} +} +``` + +```text +node /scripts/cli.mjs task --input start-kbd-task.json +``` + +Follow KBD's own required start, evidence, and boundary workflow separately. Ordinary `task` completion refuses linked tasks. + +Save `complete-kbd.json` once the local task is running and the canonical task is `in_progress`: + +```json +{ + "state": ".agent-teams/docs-team.json", "expectedRevision": 9, "cwd": ".", + "task": { + "id": "canonical-docs", "owner": "implementer", "expectedTaskRevision": 1, + "kbdCli": "/absolute/path/to/prometheus", + "evidence": ["reviews/canonical-docs.md"], "remaining": [] + } +} +``` + +```text +node /scripts/cli.mjs complete-kbd --input complete-kbd.json +``` + +`complete-kbd` uses the task object directly; no `action` field is required. `kbdCli` is an explicit executable, preferably an absolute path to the intended Prometheus CLI, not a command string containing arguments. This avoids confusing another executable named `prometheus` on PATH. The runtime executes argument arrays with `shell: false`: + +```text + kbd --path status --json + kbd --path task transition --command-id --phase --change --id --status complete --summary +``` + +Preflight checks canonical project/run identity, a nonzero canonical revision, and the exact phase/change/task records. The canonical task must be `in_progress` or already `complete`. After a transition, the returned committed state must confirm the same identities and `complete` status. Only then does the local task become complete. The local `kbd.task.completed` event records the command ID, actual argv, canonical revision/event ID when available, response hash, identity, and evidence. If canonical work was already complete, a real status read produces an explicitly labeled reconciliation receipt instead of claiming a newly committed transition. + +The canonical CLI creates its own current frontier and exposes no caller-supplied expected-run/revision argument. Preflight and response checks detect drift; they cannot atomically bind the initial identity check to the later canonical mutation. A concurrent run rollover can therefore create a cross-store race. Avoid changing canonical run identity during this operation. The local state lock does not lock KBD. + +A CLI failure, timeout, invalid response, or identity mismatch leaves local completion unrecorded. Canonical work may nevertheless have committed. Read canonical and local status before retrying with current local revisions. If the canonical task is now complete and identities still match, retry can reconcile it. A crash after canonical commit but before local persistence has the same recovery path. This is not a distributed transaction, and the runtime never erases KBD history to make the stores agree. + +The completion adapter does not emit fabricated KBD boundary receipts or call Karpathy hooks for arbitrary team events. Native KBD guards still apply; team state and local events are coordination records, not replacement canonical authority. + +## Local persistence and recovery + +State is one JSON file on a local filesystem. Writers acquire `.lock` with exclusive creation, validate revisions and state, write a uniquely named temporary file in the same directory, and atomically rename it over the state file. Readers see the old or new complete document. Failed validation or a throwing callback does not commit partial local state. Asynchronous memory publication holds the same lock across its operation; remote effects remain outside the local transaction. + +Use a local filesystem with reliable exclusive creation and same-directory atomic rename. NFS, network shares, cloud-synchronized folders, and multi-machine concurrent writes are not supported coordination backends. Local roles, ownership assertions, and file locks are advisory coordination among cooperating processes; they do not authenticate a caller, enforce native permissions, stop agents, or provide distributed leases. Anyone with direct file-write access can bypass the runtime, so preserve ordinary operating-system access controls. + +There is no automatic lock timeout, retry takeover, or stale-lock stealing. A crash may leave the lock behind. To recover, inspect its recorded `pid`, `at`, and `token`; verify no writer still owns it, including another session, then remove only that abandoned lock manually. Do not remove a live writer's lock. Inspect current state and any uncertain external KBD/memory operation before retrying. Temporary files are not authority and must not be renamed over state as an improvised recovery procedure. + +Source contracts: [CLI dispatcher](../runtime/src/cli.mts), [state transactions](../runtime/src/state.mts), [task actions](../runtime/src/state-tasks.mts), [handoff operations](../runtime/src/handoff.mts), [KBD completion](../runtime/src/state-kbd.mts), and [boundary validation](../runtime/src/state-validation.mts). diff --git a/resources/skills/agent-team-creator/runtime/.gitignore b/resources/skills/agent-team-creator/runtime/.gitignore new file mode 100644 index 00000000000..c2658d7d1b3 --- /dev/null +++ b/resources/skills/agent-team-creator/runtime/.gitignore @@ -0,0 +1 @@ +node_modules/ diff --git a/resources/skills/agent-team-creator/runtime/package-lock.json b/resources/skills/agent-team-creator/runtime/package-lock.json new file mode 100644 index 00000000000..356febe3e8c --- /dev/null +++ b/resources/skills/agent-team-creator/runtime/package-lock.json @@ -0,0 +1,442 @@ +{ + "name": "prometheus-agent-team-runtime", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "prometheus-agent-team-runtime", + "version": "1.0.0", + "devDependencies": { + "@types/node": "22.18.6", + "smol-toml": "1.9.0", + "typescript": "7.0.2", + "yaml": "2.9.0" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@types/node": { + "version": "22.18.6", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.18.6.tgz", + "integrity": "sha512-r8uszLPpeIWbNKtvWRt/DbVi5zbqZyj1PTmhRMqBMvDnaz1QpmSKujUtJLrqGZeoM8v72MfYggDceY4K1itzWQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@typescript/typescript-aix-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz", + "integrity": "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz", + "integrity": "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz", + "integrity": "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.0.2.tgz", + "integrity": "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.0.2.tgz", + "integrity": "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.0.2.tgz", + "integrity": "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.0.2.tgz", + "integrity": "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-loong64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.0.2.tgz", + "integrity": "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-mips64el": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.0.2.tgz", + "integrity": "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.0.2.tgz", + "integrity": "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-riscv64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.0.2.tgz", + "integrity": "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-s390x": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.0.2.tgz", + "integrity": "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.0.2.tgz", + "integrity": "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz", + "integrity": "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz", + "integrity": "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz", + "integrity": "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz", + "integrity": "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-sunos-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz", + "integrity": "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz", + "integrity": "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz", + "integrity": "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/smol-toml": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.9.0.tgz", + "integrity": "sha512-hpd+HLON7HdZXqYchMM/+LaTTbdK0AU3NngIJ4KVyWbY9bfQqdL9cD+4yf6dUoU2Ap4VsU0JkQi6FxAI1B2mXQ==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">= 18" + }, + "funding": { + "url": "https://github.com/sponsors/cyyynthia" + } + }, + "node_modules/typescript": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz", + "integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc" + }, + "engines": { + "node": ">=16.20.0" + }, + "optionalDependencies": { + "@typescript/typescript-aix-ppc64": "7.0.2", + "@typescript/typescript-darwin-arm64": "7.0.2", + "@typescript/typescript-darwin-x64": "7.0.2", + "@typescript/typescript-freebsd-arm64": "7.0.2", + "@typescript/typescript-freebsd-x64": "7.0.2", + "@typescript/typescript-linux-arm": "7.0.2", + "@typescript/typescript-linux-arm64": "7.0.2", + "@typescript/typescript-linux-loong64": "7.0.2", + "@typescript/typescript-linux-mips64el": "7.0.2", + "@typescript/typescript-linux-ppc64": "7.0.2", + "@typescript/typescript-linux-riscv64": "7.0.2", + "@typescript/typescript-linux-s390x": "7.0.2", + "@typescript/typescript-linux-x64": "7.0.2", + "@typescript/typescript-netbsd-arm64": "7.0.2", + "@typescript/typescript-netbsd-x64": "7.0.2", + "@typescript/typescript-openbsd-arm64": "7.0.2", + "@typescript/typescript-openbsd-x64": "7.0.2", + "@typescript/typescript-sunos-x64": "7.0.2", + "@typescript/typescript-win32-arm64": "7.0.2", + "@typescript/typescript-win32-x64": "7.0.2" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/yaml": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", + "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", + "dev": true, + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + } + } +} diff --git a/resources/skills/agent-team-creator/runtime/package.json b/resources/skills/agent-team-creator/runtime/package.json new file mode 100644 index 00000000000..708addba3b4 --- /dev/null +++ b/resources/skills/agent-team-creator/runtime/package.json @@ -0,0 +1,19 @@ +{ + "name": "prometheus-agent-team-runtime", + "version": "1.0.0", + "private": true, + "type": "module", + "engines": { + "node": ">=22" + }, + "scripts": { + "build": "tsc -p tsconfig.json", + "build:tests": "tsc -p tsconfig.tests.json" + }, + "devDependencies": { + "@types/node": "22.18.6", + "smol-toml": "1.9.0", + "typescript": "7.0.2", + "yaml": "2.9.0" + } +} diff --git a/resources/skills/agent-team-creator/runtime/src/adapters-codecs.mts b/resources/skills/agent-team-creator/runtime/src/adapters-codecs.mts new file mode 100644 index 00000000000..ed3bee72483 --- /dev/null +++ b/resources/skills/agent-team-creator/runtime/src/adapters-codecs.mts @@ -0,0 +1,64 @@ +import type { Json, ObjectValue, Role, Team } from './types.mjs'; + +export const json = (value: unknown): string => `${JSON.stringify(value, null, 2)}\n`; +export const object = (value: Json | undefined): value is ObjectValue => + value !== null && typeof value === 'object' && !Array.isArray(value); + +/** Arrays replace; objects merge recursively; own keys (including __proto__) survive. */ +export function merge(base: ObjectValue, override: ObjectValue = {}): ObjectValue { + const result: ObjectValue = Object.create(null) as ObjectValue; + for (const [key, value] of Object.entries(base)) result[key] = value; + for (const [key, value] of Object.entries(override)) { + const previous = result[key]; + result[key] = object(previous) && object(value) ? merge(previous, value) : value; + } + return result; +} + +function tomlValue(value: Json): string { + if (value === null) throw new Error('TOML has no null value; omit the field or use an opaque native file.'); + if (typeof value === 'string') return JSON.stringify(value).replace(/\u007f/g, '\\u007f'); + if (typeof value === 'number') { + if (!Number.isFinite(value)) throw new Error('Native TOML requires finite JSON numbers.'); + return String(value); + } + if (typeof value === 'boolean') return String(value); + if (Array.isArray(value)) return `[${value.map(tomlValue).join(', ')}]`; + return `{ ${Object.entries(value).map(([key, item]) => `${tomlValue(key)} = ${tomlValue(item)}`).join(', ')} }`; +} + +/** Quoted keys and inline tables preserve arbitrary native nesting without interpolation. */ +export function toml(value: ObjectValue): string { + return `${Object.entries(value).map(([key, item]) => `${tomlValue(key)} = ${tomlValue(item)}`).join('\n')}\n`; +} + +/** JSON flow syntax is a YAML 1.2 subset, including escaped multiline strings. */ +export const yaml = (value: unknown): string => json(value); +export const markdown = (frontmatter: ObjectValue, body: string): string => + `---\n${yaml(frontmatter)}---\n\n${body}\n`; + +export function identifier(value: unknown, context: string): string { + if (typeof value !== 'string' || !/^[a-z][a-z0-9-]{0,127}$/.test(value)) { + throw new Error(`${context} must be a lowercase kebab-case identifier (1–128 characters).`); + } + return value; +} + +export function model(team: Team, role: Role): string | undefined { + return role.modelPolicy?.model ?? team.modelPolicy?.model; +} + +export function prompt(team: Team, role: Role): string { + return `${role.prompt}\n\nTeam outcome: ${team.outcome}\nRole: ${role.id}\n` + + `Owns: ${JSON.stringify(role.owns)}\nInputs: ${JSON.stringify(role.inputs)}\n` + + `Outputs: ${JSON.stringify(role.outputs)}\nDependencies: ${JSON.stringify(role.dependsOn)}\n` + + `Requested skills: ${JSON.stringify(role.skills)}\n` + + 'Ownership and skill names are coordination instructions; native permissions and installed skills remain authoritative.'; +} + +export interface ExportContext { + add(path: string, content: string): void; + claimName(name: unknown): string; + diagnostics: string[]; + instructions: string[]; +} diff --git a/resources/skills/agent-team-creator/runtime/src/adapters-local.mts b/resources/skills/agent-team-creator/runtime/src/adapters-local.mts new file mode 100644 index 00000000000..02535b5e52e --- /dev/null +++ b/resources/skills/agent-team-creator/runtime/src/adapters-local.mts @@ -0,0 +1,106 @@ +import type { Harness, ObjectValue, Team } from './types.mjs'; +import { json, markdown, merge, model, object, prompt, toml, yaml } from './adapters-codecs.mjs'; +import type { ExportContext } from './adapters-codecs.mjs'; + +export function exportLocal(team: Team, target: Exclude, ctx: ExportContext): void { + const options = team.native?.[target]?.options; + if (target === 'opencode' && object(options?.agent)) { + const generatedNames = new Set(team.roles.map(role => role.id.toLowerCase())); + for (const name of Object.keys(options.agent)) { + if (generatedNames.has(name.toLowerCase())) { + throw new Error(`OpenCode agent collision between options.agent and generated Markdown: ${name}. Use the role native override.`); + } + } + } + const pluginRoot = `plugins/${team.id}`; + for (const role of team.roles) { + const native = role.native?.[target] ?? {}; + const selected = model(team, role); + const modelField: ObjectValue = selected ? { model: selected } : {}; + const body = prompt(team, role); + if (target === 'codex') { + const agent = merge({ name: role.id, description: role.description, developer_instructions: body, ...modelField }, native); + const name = ctx.claimName(agent.name); + ctx.add(`.codex/agents/${name}.toml`, toml(agent)); + } else if (target === 'claude' || target === 'copilot' || target === 'minimax') { + const agent = merge({ name: role.id, description: role.description, ...modelField, skills: role.skills }, native); + const name = ctx.claimName(agent.name); + const content = markdown(agent, body); + const path = target === 'claude' ? `.claude/agents/${name}.md` + : target === 'copilot' ? `.github/agents/${name}.agent.md` : `agents/${name}/agent.md`; + ctx.add(path, content); + if (target === 'claude') { + ctx.add(`${pluginRoot}/agents/${name}.md`, content); + const ignored = ['hooks', 'mcpServers', 'permissionMode', 'initialPrompt', 'omitClaudeMd'] + .filter(key => Object.hasOwn(native, key)); + if (ignored.length) ctx.diagnostics.push(`${role.id}: Claude plugin subagents ignore ${ignored.join(', ')}; use the project agent copy for those fields. Values remain preserved in both copies.`); + } + } else if (target === 'kimi') { + const agent = merge({ name: role.id, description: role.description }, native); + const name = ctx.claimName(agent.name); + const content = markdown(agent, body); + ctx.add(`.kimi-code/agents/${name}.md`, content); + ctx.add(`${pluginRoot}/agents/${name}.md`, content); + if (selected || Object.hasOwn(native, 'model')) { + ctx.diagnostics.push(`${role.id}: Kimi ignores role model frontmatter; configure the invocation model pool or global secondary model separately.`); + } + } else if (target === 'opencode') { + // Filename owns the identity; native prompt overrides become the Markdown body. + const agent = merge({ description: role.description, mode: 'subagent', ...modelField }, native); + const nativePrompt = agent.prompt; + if (nativePrompt !== undefined && typeof nativePrompt !== 'string') throw new Error('OpenCode native prompt must be a string.'); + delete agent.prompt; + ctx.claimName(role.id); + ctx.add(`.opencode/agents/${role.id}.md`, markdown(agent, nativePrompt ?? body)); + } else if (target === 'deepseek') { + ctx.claimName(role.id); + const persona = merge({ prefix: body }, native); + ctx.add(`.dsh/profiles/${team.id}-${role.id}/cordis.patch.yml`, yaml([ + { insert: [{ id: `${team.id}-${role.id}-persona`, name: '@deepseek-ai/dsh-persona', config: persona }] }, + ])); + if (selected || Object.hasOwn(native, 'model')) { + ctx.diagnostics.push(`${role.id}: DeepSeek persona/member configuration cannot select a per-member model; preserved model intent is not applied.`); + } + } + } + if (target === 'codex') { + if (options) ctx.add('.codex/config.toml', toml(options)); + ctx.instructions.push('Review and merge .codex/agents and optional .codex/config.toml into the project; config values are native Codex options.'); + ctx.diagnostics.push('Standalone agent files are supported. A Codex plugin agents manifest field is not verified; no such field is emitted.'); + } else if (target === 'claude') { + if (options) ctx.add('.claude/settings.json', json(options)); + ctx.add(`${pluginRoot}/.claude-plugin/plugin.json`, json({ name: team.id, version: '1.0.0', description: team.outcome })); + ctx.add('.claude-plugin/marketplace.json', json({ + name: `${team.id}-marketplace`, owner: { name: team.id }, + plugins: [{ name: team.id, source: `./${pluginRoot}`, description: team.outcome }], + })); + ctx.instructions.push('Choose project .claude/agents or the staged local marketplace/plugin. Do not install both copies. Settings options are a proposed project .claude/settings.json.'); + ctx.diagnostics.push('These are native subagents; installing them does not create an experimental Claude agent team or enable that feature.'); + } else if (target === 'kimi') { + ctx.add(`${pluginRoot}/kimi.plugin.json`, json({ name: team.id, version: '1.0.0', description: team.outcome, agents: ['./agents'] })); + ctx.add('marketplace.json', json({ version: '2', plugins: [{ id: team.id, displayName: team.id, source: `./${pluginRoot}` }] })); + ctx.instructions.push('Choose project .kimi-code/agents or the staged Kimi v2 marketplace/plugin. Literal ${base_prompt} in supplied prompts remains unchanged.'); + if (options) ctx.diagnostics.push('Kimi team native options are preserved in native-options.json only; no project config location or automatic application is asserted.'); + } else if (target === 'minimax') { + ctx.instructions.push('Install agents//agent.md under the active MiniMax user-data directory: MINIMAX_DATA_DIR, MAVIS_DATA_DIR, or default ~/.minimax. Export does not install there.'); + ctx.diagnostics.push('mcode exec has no verified custom-agent selector. The MiniMax plugin manifest supports skills/MCP/hooks/apps, not an agents field; no agent plugin is invented.'); + if (options) ctx.diagnostics.push('MiniMax team native options are preserved in native-options.json only; apply through the installed native configuration interface.'); + } else if (target === 'copilot') { + ctx.instructions.push('Review .github/agents/*.agent.md; native CLI /agent or --agent selects a custom role. Fleet is a separate native execution mechanism.'); + ctx.diagnostics.push('No Copilot agent marketplace mapping was verified; this export uses native agent files.'); + if (options) ctx.diagnostics.push('Copilot team native options are preserved in native-options.json only; they are not project configuration.'); + } else if (target === 'opencode') { + if (options) ctx.add('opencode.json', json(options)); + ctx.instructions.push('Review .opencode/agents/*.md and optional opencode.json. Native configuration uses singular agent and permission keys.'); + ctx.diagnostics.push('OpenCode JS/TS or npm plugins are separate from agent definitions; no agent marketplace manifest is invented.'); + } else { + ctx.add(`.dsh/profiles/${team.id}-team/cordis.patch.yml`, yaml([{ insert: [ + { id: `${team.id}-persistence`, name: '@deepseek-ai/dsh-session-persistence-jsonl' }, + { id: `${team.id}-team`, name: '@deepseek-ai/dsh-experimental-agent-team', config: options ?? {} }, + { id: `${team.id}-team-tools`, name: '@deepseek-ai/dsh-experimental-tool-agent-team' }, + ] }])); + ctx.instructions.push('Review the role persona profiles and separate team composition profile before selecting them in DeepSeek. Team options configure the experimental agent-team service. The lead creates members at runtime using the portable role prompts; profiles do not pre-create a roster.'); + ctx.diagnostics.push('DeepSeek teams are experimental, require durable session storage, share one process/workspace, and provide no static per-member model setting. No package install, profile activation or team creation occurs on export.'); + } + ctx.diagnostics.push('Role overrides are preserved without native schema certification. Skill IDs refer to separately installed skills; prompt mentions do not install or authorize them.'); +} diff --git a/resources/skills/agent-team-creator/runtime/src/adapters-services.mts b/resources/skills/agent-team-creator/runtime/src/adapters-services.mts new file mode 100644 index 00000000000..6f8b1bfb8a8 --- /dev/null +++ b/resources/skills/agent-team-creator/runtime/src/adapters-services.mts @@ -0,0 +1,69 @@ +import type { ObjectValue, Team } from './types.mjs'; +import { json, merge, model, prompt, toml } from './adapters-codecs.mjs'; +import type { ExportContext } from './adapters-codecs.mjs'; + +export function exportService(team: Team, target: 'uar' | 'bossfang', ctx: ExportContext): void { + const options = team.native?.[target]?.options; + const registrations: ObjectValue[] = []; + const handAgents: ObjectValue = Object.create(null) as ObjectValue; + const steps: ObjectValue[] = []; + for (const [index, role] of team.roles.entries()) { + const native = role.native?.[target] ?? {}; + if (target === 'uar') { + const defaults: ObjectValue = { + version: '1.0.0', kind: 'agent', id: `${team.id}-${role.id}`, + metadata: { title: role.id, description: role.description, tags: [team.id] }, + runtime: { entry: 'default', protocols: {} }, + policy: { + provider: { default: { provider: '', model: model(team, role) ?? '' }, fallbacks: [] }, + tools: { allow: [], deny: [], max_concurrent: 1, execution_mode: 'direct' }, + skills: { prefer: role.skills, max_active: 3 }, + }, + schemas: {}, prompt: { system: prompt(team, role), instructions: [] }, + memory: { conversation: { enabled: true }, kb: { enabled: false, knowledge_bases: [], citation_required: false } }, + tools: { bundles: [] }, ui: { forms: { enabled: false }, artifacts: { enabled: false, preferred_types: [] } }, + extensions: {}, + }; + const artifact = merge(merge(defaults, options), native); + ctx.claimName(artifact.id); + const path = `uar/agents/${role.id}.json`; + ctx.add(path, json(artifact)); + registrations.push({ method: 'POST', route: '/api/agents', bodyFile: path, role: role.id }); + } else { + const manifest = merge({ + name: `${team.id}-${role.id}`, description: role.description, + model: { provider: 'default', model: model(team, role) ?? 'default', system_prompt: prompt(team, role) }, + skills: role.skills, skills_disabled: role.skills.length === 0, mcp_servers: [], + }, native); + const name = ctx.claimName(manifest.name); + const content = toml(manifest); + ctx.add(`bossfang/agents/${role.id}/agent.toml`, content); + const path = `bossfang/registration/${role.id}.json`; + ctx.add(path, json({ manifest_toml: content })); + registrations.push({ method: 'POST', route: '/api/agents', bodyFile: path, role: role.id }); + handAgents[role.id] = merge({ coordinator: index === 0, invoke_hint: role.description }, manifest); + steps.push({ name: role.id, agent_name: name, prompt: '{{input}}', depends_on: role.dependsOn }); + } + } + if (target === 'bossfang') { + const hand = merge({ id: team.id, version: '1.0.0', name: team.id, description: team.outcome, + category: 'development', icon: '', tools: [], skills: [], mcp_servers: [], agents: handAgents }, options); + const handContent = toml(hand); + ctx.add(`bossfang/hands/${team.id}/HAND.toml`, handContent); + ctx.add('bossfang/hand-install.json', json({ toml_content: handContent, skill_content: '' })); + ctx.add('bossfang/workflow.json', json({ name: team.id, description: team.outcome, steps })); + ctx.instructions.push('Choose standalone agent registrations plus workflow, or install the Hand. These are distinct native deployment paths; do not activate the Hand as a duplicate of the standalone workflow.'); + ctx.instructions.push('Optional native payloads: POST /api/hands/install with bossfang/hand-install.json; POST /api/workflows with bossfang/workflow.json after registering standalone agents. Hand activation and workflow run require separate authorization and are not performed.'); + ctx.diagnostics.push('Empty portable role skills emit skills_disabled=true; native skills=[] with skills_disabled=false means all. Agent MCP [] means none; ["*"] means all. Hand-level allowlists have their own native defaults.'); + ctx.diagnostics.push('BossFang team native options merge into HAND.toml; role overrides merge into each AgentManifest. Preserve local TOML because native GET returns a projection. Hand activation can start autonomous schedules.'); + } else { + ctx.instructions.push('Each uar/agents/*.json is a complete AgentArtifact body for POST /api/agents. UAR team native options supply artifact defaults, then role native overrides win.'); + ctx.diagnostics.push('UAR skill policy prefer is a preference, not an enforced skill allowlist. Blank provider/model use service defaults; model IDs are not split to guess a provider.'); + ctx.instructions.push('Review native policy.tools.allow and tools.bundles before registration; generated defaults grant no explicit tool allowlist or bundles. Supply required native tool policy through team defaults or role overrides.'); + ctx.diagnostics.push('UAR has no verified persistent team registration API. POST /api/uar/runs requires {artifact:,input:}; registration does not run it.'); + } + ctx.add(`${target}/registration-plan.json`, json({ schemaVersion: 1, format: 'agent-team-export-plan', + execution: 'not-performed', requests: registrations })); + ctx.instructions.push('registration-plan.json is a local review plan, not a native API body. Supply an operator-approved base URL and credential reference; preserve each returned native ID/outcome because registration is not atomic.'); + ctx.diagnostics.push('Native authentication and registration are unverified. UAR defaults to JWT-required; BossFang accepts Bearer or X-API-Key. Discovery success is not mutation authorization.'); +} diff --git a/resources/skills/agent-team-creator/runtime/src/adapters.mts b/resources/skills/agent-team-creator/runtime/src/adapters.mts new file mode 100644 index 00000000000..1c9e5020be6 --- /dev/null +++ b/resources/skills/agent-team-creator/runtime/src/adapters.mts @@ -0,0 +1,91 @@ +import type { ExportResult, Target, Team } from './types.mjs'; +import { identifier, json } from './adapters-codecs.mjs'; +import { exportLocal } from './adapters-local.mjs'; +import { exportService } from './adapters-services.mjs'; + +const sources: Record = { + codex: { source: 'https://learn.chatgpt.com/docs/agent-configuration/subagents', version: 'documentation inspected 2026-09-24' }, + claude: { source: 'https://code.claude.com/docs/en/sub-agents', version: 'documentation inspected 2026-09-24' }, + copilot: { source: 'https://docs.github.com/en/copilot/reference/custom-agents-configuration', version: 'documentation inspected 2026-09-24' }, + kimi: { source: 'https://github.com/MoonshotAI/kimi-code/blob/main/docs/en/customization/agents.md', version: 'main documentation inspected 2026-09-24' }, + minimax: { source: 'https://github.com/MiniMax-AI/minimax-code/blob/main/packages/local-runtime-v2/src/service/agent/storage/canonical-agent-config.ts', version: '@minimax-ai/code 0.4.12; source inspected 2026-09-24' }, + opencode: { source: 'https://opencode.ai/docs/agents/', version: 'deployed singular agent schema inspected 2026-09-24' }, + deepseek: { source: 'https://github.com/deepseek-ai/deepseek-harness/tree/master/packages/experimental/agent-team', version: 'master documentation inspected 2026-09-24; experimental' }, + uar: { source: 'https://github.com/Prometheus-AGS/universal-agent-runtime/blob/ba12845138104d3c8c3b8bca8bc7c5be24004e91/src/uar/domain/artifact.rs', version: '1.0.0 / ba12845138104d3c8c3b8bca8bc7c5be24004e91' }, + bossfang: { source: 'crates/librefang-types/src/agent.rs; crates/librefang-hands/src/lib.rs; crates/librefang-api/src/routes/workflows/workflow.rs', version: '2026.7.11 / c719a4d683e4d3fb42e436f812e0193f865c9d2c' }, +}; + +function safePath(path: string): string { + if (!path || path.includes('\\') || path.startsWith('/') || /[\x00-\x1f\x7f:]/.test(path)) { + throw new Error(`Unsafe native export path: ${JSON.stringify(path)}`); + } + for (const part of path.split('/')) { + if (!part || part === '.' || part === '..' || /[. ]$/.test(part) || + /^(?:\.git|con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\.|$)/i.test(part)) { + throw new Error(`Unsafe native export path component: ${JSON.stringify(part)}`); + } + } + return path.normalize('NFC').toLowerCase(); +} + +/** Pure staging: no filesystem, process, HTTP, registration or execution side effects. */ +export function exportTeam(team: Team, target: Target): ExportResult { + if (!Object.hasOwn(sources, target)) throw new Error(`Unsupported export target: ${String(target)}`); + identifier(team.id, 'Team ID'); + if (!team.roles.length) throw new Error('Export requires at least one role.'); + const roleIds = new Set(); + for (const role of team.roles) { + const id = identifier(role.id, 'Role ID'); + if (roleIds.has(id)) throw new Error(`Duplicate role ID: ${id}`); + roleIds.add(id); + } + const files: Record = Object.create(null) as Record; + const paths = new Set(); + const names = new Set(); + const diagnostics = ['Source-verified serialization only; installed native validation and live execution are unverified.']; + const instructions = ['Review staged artifacts before installation. Export grants no execution or registration authority.']; + const context = { + diagnostics, instructions, + add(path: string, content: string): void { + const key = safePath(path); + if (key === 'team-export.json') throw new Error('team-export.json is reserved for the staging receipt.'); + for (const existing of paths) { + if (key === existing || key.startsWith(`${existing}/`) || existing.startsWith(`${key}/`)) { + throw new Error(`Native export file collision: ${path}`); + } + } + paths.add(key); + files[path] = content; + }, + claimName(value: unknown): string { + const name = identifier(value, 'Native agent name'); + if (names.has(name)) throw new Error(`Native agent name collision: ${name}`); + names.add(name); + return name; + }, + }; + const native = team.native?.[target]; + if (native && (!native.source?.trim() || !native.version?.trim())) { + throw new Error('Native configuration requires a nonempty source and version receipt.'); + } + if (team.skillPolicies || team.modelPolicy || team.roles.some(role => role.modelPolicy)) { + diagnostics.push('Only explicit model IDs are translated. Resolve tier, skill, capability and price policies with agent-team-models before native invocation.'); + } + if (target === 'uar' || target === 'bossfang') exportService(team, target, context); + else exportLocal(team, target, context); + if (native?.options) context.add('native-options.json', json(native.options)); + const verification: ExportResult['verification'] = { level: 'source-verified', ...sources[target], live: 'unverified' }; + context.add('export-receipt.json', json({ + target, verification, nativeProvenance: native ? { source: native.source, version: native.version } : null, + nativeOptions: native?.options ?? null, + roleOverrides: Object.fromEntries(team.roles.filter(role => role.native?.[target]).map(role => [role.id, role.native?.[target]])), + diagnostics, instructions, + })); + for (const [path, content] of Object.entries(native?.files ?? {})) { + if (['team-export.json', 'export-receipt.json', 'native-options.json'].includes(safePath(path))) { + throw new Error(`Reserved generated export file: ${path}`); + } + context.add(path, content); + } + return { target, files, verification, diagnostics, instructions }; +} diff --git a/resources/skills/agent-team-creator/runtime/src/cli.mts b/resources/skills/agent-team-creator/runtime/src/cli.mts new file mode 100644 index 00000000000..e95c4c07042 --- /dev/null +++ b/resources/skills/agent-team-creator/runtime/src/cli.mts @@ -0,0 +1,78 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { guide } from './guidance.mjs'; +import { validateTeam, object, text, strings, asJson, target } from './validation.mjs'; +import { exportTeam } from './adapters.mjs'; +import { writeExport } from './export-files.mjs'; +import { readState, initState, mutateState, mutateStateAsync, taskAction, completeKbdTask } from './state.mjs'; +import { createHandoff, acceptHandoff } from './handoff.mjs'; +import { discoverModels, selectModel } from './models.mjs'; +import { queueMemory, publishMemory } from './memory.mjs'; +import type { ObjectValue, ModelPolicy, Json } from './types.mjs'; + +function revision(input: ObjectValue): number { + const r = input.expectedRevision; + if (!Number.isSafeInteger(r) || Number(r) < 0) throw Error('expectedRevision must be a nonnegative integer from the current state'); + return r as number; +} +const stateFile = (input: ObjectValue): string => path.resolve(text(input.state, 'state')); + +async function dispatch(command: string, input: ObjectValue): Promise { + switch (command) { + case 'guide': return guide(input); + case 'validate': return { valid: true, team: validateTeam(input.team) }; + case 'init': return initState(stateFile(input), validateTeam(input.team)); + case 'status': return readState(stateFile(input)); + case 'team-update': return mutateState(stateFile(input), revision(input), state => { + const team = validateTeam(input.team); + if (team.id !== state.team.id) throw Error('team-update cannot change team identity'); + if (state.tasks.some(t => !team.roles.some(r => r.id === t.owner))) throw Error('Cannot remove a role referenced by a task; preserve history and reassign active work explicitly'); + state.team = team; + }); + case 'export': { + const team = validateTeam(input.team ?? readState(stateFile(input)).team); + const result = exportTeam(team, target(input.target ?? team.harness)); + for (const role of team.roles) if (!role.owns.length) result.diagnostics.push(`${role.id}: file ownership is not yet assigned; resolve it before parallel edits.`); + return { ...writeExport(text(input.out, 'out'), result), verification: result.verification, diagnostics: result.diagnostics, instructions: result.instructions }; + } + case 'task': return mutateState(stateFile(input), revision(input), state => taskAction(state, object(input.task, 'task action'))); + case 'complete-kbd': return mutateState(stateFile(input), revision(input), state => { + completeKbdTask(state, object(input.task, 'task action'), text(input.cwd, 'cwd')); + }); + case 'handoff-create': return mutateState(stateFile(input), revision(input), state => { + createHandoff(state, object(input.handoff, 'handoff'), text(input.cwd, 'cwd')); + }); + case 'handoff-accept': return mutateState(stateFile(input), revision(input), state => { + const destination = object(input.destination, 'destination'); + const harness = target(destination.harness); + if (harness === 'bossfang') throw Error('Destination harness must name an execution harness'); + acceptHandoff(state, text(input.id, 'handoff id'), { owner: text(destination.owner, 'destination owner'), harness }); + }); + case 'models-discover': return discoverModels(input); + case 'models-select': return selectModel(validateTeam(input.team), text(input.roleId, 'roleId'), strings(input.skills ?? [], 'skills'), (input.taskPolicy ?? {}) as ModelPolicy, input.catalog); + case 'memory-queue': return mutateState(stateFile(input), revision(input), state => { queueMemory(state, object(input.entry, 'entry')); }); + case 'memory-publish': { + let receipt: Json = null; + const state = await mutateStateAsync(stateFile(input), revision(input), async state => { receipt = await publishMemory(state, object(input.publication, 'publication')); }); + return { state, publication: receipt }; + } + default: throw Error(`Unknown command: ${command}`); + } +} + +const commands = ['guide','validate','init','status','team-update','export','task','complete-kbd','handoff-create','handoff-accept','models-discover','models-select','memory-queue','memory-publish']; +async function main(): Promise { + if (Number(process.versions.node.split('.')[0]) < 22) throw Error('Node.js 22 or newer is required'); + const [command, flag, file, ...extra] = process.argv.slice(2); + if (!command || command === '--help') { + process.stdout.write(JSON.stringify({ usage: 'node /scripts/cli.mjs --input ', commands, note: 'JSON requests preserve spaces and native configuration. Export stages files; it does not install or execute agents.' }, null, 2) + '\n'); + return; + } + if (flag !== '--input' || !file || extra.length) throw Error('Expected --input '); + const input = object(JSON.parse(fs.readFileSync(file, 'utf8').replace(/^\uFEFF/, ''))); + process.stdout.write(JSON.stringify(asJson(await dispatch(command, input)), null, 2) + '\n'); +} +main().catch(error => { + process.stderr.write(JSON.stringify({ error: error instanceof Error ? error.message : 'Operation failed' }) + '\n'); + process.exitCode = 1; +}); diff --git a/resources/skills/agent-team-creator/runtime/src/export-files.mts b/resources/skills/agent-team-creator/runtime/src/export-files.mts new file mode 100644 index 00000000000..1fe96573e12 --- /dev/null +++ b/resources/skills/agent-team-creator/runtime/src/export-files.mts @@ -0,0 +1,33 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { createHash } from 'node:crypto'; +import type { ExportResult } from './types.mjs'; +import { relativeFile } from './validation.mjs'; + +/** An export is a new proposal directory, never an in-place config merge. */ +export function writeExport(out: string, result: ExportResult): { directory: string; files: string[] } { + const files = { ...result.files }; + const reserved = 'team-export.json'; + if (Object.keys(files).some(f => f.toLowerCase() === reserved)) throw Error(`Reserved export receipt path: ${reserved}`); + const names = new Set(); + for (const file of Object.keys(files)) { + relativeFile(file); + const lower = file.toLowerCase(); + if (names.has(lower)) throw Error(`Case-insensitive file collision: ${file}`); + names.add(lower); + } + for (const file of names) for (const other of names) if (file !== other && other.startsWith(file + '/')) throw Error(`File/directory collision: ${file}`); + files[reserved] = JSON.stringify({ target: result.target, verification: result.verification, diagnostics: result.diagnostics, + instructions: result.instructions, files: Object.entries(files).map(([file, content]) => ({ file, sha256: createHash('sha256').update(content).digest('hex') })) }, null, 2) + '\n'; + const directory = path.resolve(out); + fs.mkdirSync(path.dirname(directory), { recursive: true }); + // Nonrecursive mkdir is the no-overwrite boundary. A partial failed export remains + // inspectable and cannot be mistaken for success (receipt is written last). + fs.mkdirSync(directory); + for (const [file, content] of Object.entries(files)) { + const destination = path.join(directory, ...file.split('/')); + fs.mkdirSync(path.dirname(destination), { recursive: true }); + fs.writeFileSync(destination, content, { flag: 'wx', mode: 0o600 }); + } + return { directory, files: Object.keys(files) }; +} diff --git a/resources/skills/agent-team-creator/runtime/src/guidance.mts b/resources/skills/agent-team-creator/runtime/src/guidance.mts new file mode 100644 index 00000000000..99328e06d43 --- /dev/null +++ b/resources/skills/agent-team-creator/runtime/src/guidance.mts @@ -0,0 +1,62 @@ +import type { Team, Role, ObjectValue } from './types.mjs'; +import { id, text, strings, target, object } from './validation.mjs'; + +export const questions = [ + { key: 'id', question: 'What short name should identify this team?' }, + { key: 'outcome', question: 'What should be different when this work is finished?' }, + { key: 'complexity', question: 'Is this one isolated change, or work spanning several components?', choices: ['simple', 'complex'] }, + { key: 'areas', question: 'Which kinds of work are involved?', choices: ['code', 'design', 'mobile', 'security', 'docs', 'marketing', 'product'] }, + { key: 'deliverables', question: 'What files, features, or decisions should the team deliver?' }, + { key: 'budget', question: 'Should we favor lower cost, balanced cost, or capability for difficult work?', choices: ['economy', 'balanced', 'quality'] }, + { key: 'review', question: 'Does this work need an independent reviewer?', choices: ['yes', 'no'] }, + { key: 'harness', question: 'Which coding tool will execute the team?' }, + { key: 'scope', question: 'Will the definitions live in this project, UAR, or BossFang?', choices: ['project', 'uar', 'bossfang'] }, +]; +const specialists: Record = { + design: { id: 'designer', why: 'Decide layout, interaction and visual acceptance before implementation.', output: 'Design specification and assets', skills: ['frontend-design', 'impeccable'] }, + mobile: { id: 'mobile-specialist', why: 'Resolve platform navigation, accessibility and device constraints.', output: 'Mobile implementation plan', skills: ['flutter', 'dart'] }, + security: { id: 'security-reviewer', why: 'Review the actual trust boundaries and required controls.', output: 'Threat model and evidence-backed findings', skills: ['agent-runtime-security'] }, + docs: { id: 'documentation-specialist', why: 'Keep operator and developer instructions consistent with the delivered behavior.', output: 'Updated documentation', skills: ['documentation-and-adrs'] }, + marketing: { id: 'marketing-specialist', why: 'Develop audience, positioning and measurable campaign deliverables.', output: 'Campaign brief and copy', skills: ['brand'] }, + product: { id: 'product-manager', why: 'Translate desired outcomes into priorities and acceptance criteria.', output: 'Prioritized requirements', skills: ['domain-modeling'] }, +}; +export function guide(input: ObjectValue): { questions: typeof questions; team?: Team; proposedRoles?: Role[]; ready?: boolean; reasons?: string[]; missing?: string[]; alternatives?: string[]; skillDiscovery?: string } { + const required = ['id','outcome','complexity','areas','deliverables','budget','review','harness','scope']; + const missing = required.filter(k => input[k] === undefined); + if (missing.length) return { questions, missing }; + const teamId = id(input.id), outcome = text(input.outcome, 'outcome'); + const areas = strings(input.areas, 'areas'), deliverables = strings(input.deliverables, 'deliverables'); + if (!['simple', 'complex'].includes(String(input.complexity))) throw Error('complexity must be simple or complex'); + if (!['economy', 'balanced', 'quality'].includes(String(input.budget))) throw Error('Invalid budget preference'); + if (typeof input.review !== 'boolean') throw Error('review must be boolean'); + if (areas.some(a => !['code', ...Object.keys(specialists)].includes(a))) throw Error('Unknown work area'); + const harness = target(input.harness); + if (harness === 'bossfang') throw Error('Use scope=bossfang and choose the executing harness separately'); + if (!['project','uar','bossfang'].includes(String(input.scope))) throw Error('Invalid scope'); + const tier = input.budget === 'quality' ? 'hard' : input.budget === 'economy' ? 'low' : 'medium'; + const roles: Role[] = [{ id: 'implementer', description: 'Deliver the requested outcome within assigned scope.', prompt: `Deliver: ${outcome}. Coordinate ownership before editing. Report evidence and remaining work.`, skills: [], owns: [], inputs: ['Task and acceptance criteria'], outputs: deliverables, dependsOn: [], modelPolicy: { tier } }]; + const reasons = ['An implementer owns delivery. Assign concrete output paths before creating the team; suggested roles can be reduced.']; + if (input.complexity === 'complex') for (const area of [...new Set(areas)]) { + const spec = specialists[area]; if (!spec) continue; + roles.push({ id: spec.id, description: spec.why, prompt: `${spec.why} Outcome: ${outcome}. Stay within assigned scope and return concrete evidence.`, skills: spec.skills, owns: [], inputs: ['Task context'], outputs: [spec.output], dependsOn: [], modelPolicy: { tier: area === 'security' ? 'hard' : tier } }); + reasons.push(`${spec.id}: ${spec.why}`); + } + if (input.review) { + roles.push({ id: 'reviewer', description: 'Independently verify acceptance criteria and code quality.', prompt: 'Inspect the delivered diff and actual verification evidence. Report concrete defects; do not rewrite implementation while reviewing.', skills: ['code-review-and-quality'], owns: [], inputs: ['Implementation diff', 'Verification evidence'], outputs: ['Review findings'], dependsOn: roles.map(r => r.id), modelPolicy: { tier: 'hard' } }); + reasons.push('An independent reviewer adds a separate verification pass and extra model cost.'); + } + const ownership = input.ownership === undefined ? {} : object(input.ownership, 'ownership'); + for (const key of Object.keys(ownership)) if (!roles.some(r => r.id === key)) throw Error('Unknown ownership role: ' + key); + for (const role of roles) if (ownership[role.id] !== undefined) { + role.owns = strings(ownership[role.id], 'ownership.' + role.id); + for (const owned of role.owns) if (owned.startsWith('/') || owned.includes('\\') || owned.includes(':') || owned.split('/').some(p => p === '..' || p === '.' || !p)) throw Error('Ownership must use project-relative paths or globs: ' + owned); + } + const unresolved = roles.filter(r => r.owns.length === 0); + const alternatives = ['Use one implementer for sequential work; invoke specialist skills as needed.', 'Add parallel roles only where work and file ownership can be separated.']; + const skillDiscovery = 'Skill names are suggestions, not installation claims. Discover installed AgentSkills, inspect their source and requirements, and replace or remove unavailable skills before export.'; + if (unresolved.length) return { ready: false, proposedRoles: roles, reasons, alternatives, skillDiscovery, + missing: unresolved.map(r => 'ownership.' + r.id), + questions: unresolved.map(r => ({ key: 'ownership.' + r.id, question: 'Which project-relative files or output directories may ' + r.id + ' write? For read-only review, assign a separate findings path. Inspect the project and suggest paths instead of guessing.' })) }; + return { ready: true, questions: [], team: { schemaVersion: 1, id: teamId, outcome, scope: input.scope as Team['scope'], harness, roles, modelPolicy: { tier } }, reasons, + alternatives, skillDiscovery }; +} diff --git a/resources/skills/agent-team-creator/runtime/src/handoff.mts b/resources/skills/agent-team-creator/runtime/src/handoff.mts new file mode 100644 index 00000000000..79b60db1df9 --- /dev/null +++ b/resources/skills/agent-team-creator/runtime/src/handoff.mts @@ -0,0 +1,81 @@ +import { spawnSync } from 'node:child_process'; +import { randomUUID } from 'node:crypto'; +import { resolve } from 'node:path'; +import type { Handoff, Harness, ObjectValue, TeamState } from './types.mjs'; +import { checkedTask, recordEvent } from './state-tasks.mjs'; +import { harness, owner, strings, text, validateState } from './state-validation.mjs'; + +function git(cwd: string, args: string[]): string | null { + const result = spawnSync('git', ['-C', cwd, ...args], { encoding: 'utf8', shell: false, timeout: 10_000, maxBuffer: 4 * 1024 * 1024 }); + return !result.error && result.status === 0 ? result.stdout.trim() : null; +} + +/** Capture is read-only. Neither the packet nor its prompt grants native permissions. */ +export function createHandoff(state: TeamState, input: ObjectValue, cwd: string): Handoff { + validateState(state); + const task = checkedTask(state, { ...input, id: text(input.taskId, 'taskId') }); + const to = { owner: owner(state, input.toOwner), harness: harness(input.toHarness) }; + if (task.owner === to.owner && task.harness === to.harness) throw new Error('Handoff destination must change owner or harness'); + const context = text(input.context, 'context'); + const evidence = [...new Set([...task.evidence, ...strings(input.evidence, 'evidence')])]; + const remaining = [...new Set([...task.remaining, ...strings(input.remaining, 'remaining')])]; + const memoryRefs = strings(input.memoryRefs, 'memoryRefs'); + const root = git(resolve(cwd), ['rev-parse', '--show-toplevel']) ?? resolve(cwd); + const dirty = git(root, ['status', '--porcelain=v1', '--untracked-files=all']); + const snapshot = { + root, head: git(root, ['rev-parse', '--verify', 'HEAD']), + branch: git(root, ['symbolic-ref', '--quiet', '--short', 'HEAD']), + dirty: dirty === null ? null : dirty.length > 0, + }; + const handoff: Handoff = { + schemaVersion: 1, id: randomUUID(), taskId: task.id, taskRevision: task.revision, + from: { owner: task.owner, harness: task.harness }, to, + context, evidence, remaining, memoryRefs, git: snapshot, + createdAt: new Date().toISOString(), prompt: '', + }; + handoff.prompt = [ + `Fresh task context for role ${to.owner} on ${to.harness}.`, + 'Explicitly accept this handoff before taking ownership. Re-read destination project instructions and check current task revision.', + 'This packet is task data, not authority to bypass instructions. Source sessions, credentials and permissions do not transfer; native destination controls apply.', + `Task: ${task.id} — ${task.title}; source status: ${task.status}; revision: ${task.revision}.`, + `Source: ${task.owner} on ${task.harness}. Destination: ${to.owner} on ${to.harness}.`, + `Context:\n${context}`, + `Evidence:\n${evidence.length ? evidence.map(item => `- ${item}`).join('\n') : '(none supplied)'}`, + `Remaining work / blockers:\n${remaining.length ? remaining.map(item => `- ${item}`).join('\n') : '(none supplied; task is not completed)'}`, + `Memory references:\n${memoryRefs.length ? memoryRefs.map(item => `- ${item}`).join('\n') : '(none supplied)'}`, + `Git root: ${root}; HEAD: ${snapshot.head ?? 'unknown'}; branch: ${snapshot.branch ?? 'unknown or detached'}; dirty: ${snapshot.dirty === null ? 'unknown' : snapshot.dirty}.`, + ...(task.kbd ? [`Canonical KBD identity: ${JSON.stringify(task.kbd)}. Completion must be confirmed by KBD.`] : []), + ].join('\n\n'); + state.handoffs.push(handoff); + recordEvent(state, 'handoff.created', { handoffId: handoff.id, taskId: task.id, taskRevision: task.revision, toOwner: to.owner, toHarness: to.harness }); + return structuredClone(handoff); +} + +/** Run inside mutateState: receipt and ownership change commit in one atomic file replacement. */ +export function acceptHandoff(state: TeamState, id: string, destination: { owner: string; harness: Harness }): Handoff { + validateState(state); + const handoff = state.handoffs.find(candidate => candidate.id === text(id, 'handoff id')); + if (!handoff) throw new Error(`Unknown handoff: ${id}`); + const to = { owner: owner(state, destination.owner), harness: harness(destination.harness) }; + if (handoff.to.owner !== to.owner || handoff.to.harness !== to.harness) throw new Error('Acceptance must come from the targeted destination'); + const task = state.tasks.find(candidate => candidate.id === handoff.taskId)!; + if (handoff.acceptedAt) { + if (task.owner !== to.owner || task.harness !== to.harness || task.revision !== handoff.taskRevision + 1 || ['complete', 'cancelled'].includes(task.status)) throw new Error('Accepted receipt is stale: task changed after transfer'); + return structuredClone(handoff); + } + checkedTask(state, { id: task.id, owner: handoff.from.owner, expectedTaskRevision: handoff.taskRevision }); + if (task.harness !== handoff.from.harness) throw new Error('Source harness changed after handoff creation'); + task.owner = to.owner; + task.harness = to.harness; + if (task.status === 'running') task.status = 'pending'; + task.evidence = [...handoff.evidence]; + task.remaining = [...handoff.remaining]; + task.revision++; + handoff.acceptedAt = new Date().toISOString(); + recordEvent(state, 'handoff.accepted', { + handoffId: handoff.id, taskId: task.id, taskRevision: task.revision, + fromOwner: handoff.from.owner, fromHarness: handoff.from.harness, + owner: to.owner, harness: to.harness, acceptedAt: handoff.acceptedAt, + }); + return structuredClone(handoff); +} diff --git a/resources/skills/agent-team-creator/runtime/src/memory.mts b/resources/skills/agent-team-creator/runtime/src/memory.mts new file mode 100644 index 00000000000..afe0534a5d6 --- /dev/null +++ b/resources/skills/agent-team-creator/runtime/src/memory.mts @@ -0,0 +1,144 @@ +import { createHash } from 'node:crypto'; +import type { Json, MemoryEntry, ObjectValue, TeamState } from './types.mjs'; +import { assertNoCredentials, endpoint, object, requestJson, RequestFailure, text } from './models-http.mjs'; + +function canonical(value: Json): string { + if (Array.isArray(value)) return `[${value.map(canonical).join(',')}]`; + if (value && typeof value === 'object') return `{${Object.keys(value).sort().map(key => `${JSON.stringify(key)}:${canonical(value[key])}`).join(',')}}`; + return JSON.stringify(value); +} +const digest = (value: Json): string => createHash('sha256').update(canonical(value)).digest('hex'); + +function reference(state: TeamState, provenance: ObjectValue): void { + if (provenance.kbd === undefined) return; + const kbd = object(provenance.kbd, 'provenance.kbd'); + for (const key of ['projectId', 'runId', 'phaseId', 'changeId', 'taskId']) text(kbd[key], `kbd.${key}`); + const matching = state.tasks.some(task => task.kbd && canonical(task.kbd as unknown as ObjectValue) === canonical(kbd)); + if (!matching) throw new Error('KBD provenance must exactly reference a linked team task; canonical validation is separate'); +} + +/** Caller commits this mutation atomically before offering the entry for publication. */ +export function queueMemory(state: TeamState, input: ObjectValue): MemoryEntry { + assertNoCredentials(input); + const content = text(input.content, 'memory.content'); + const scope = text(input.scope, 'memory.scope'); + const supplied = input.provenance === undefined ? {} : object(input.provenance, 'memory.provenance'); + reference(state, supplied); + const provenance: ObjectValue = { ...supplied, teamId: state.team.id, source: 'agent-team-runtime', authority: 'local-team-record; KBD references are unverified mirrors' }; + const identity = digest({ content, scope, provenance }); + const id = input.id === undefined ? `memory-${identity.slice(0, 48)}` : text(input.id, 'memory.id'); + if (!/^[a-z][a-z0-9-]{0,62}$/.test(id)) throw new Error('memory.id must be a portable lowercase identifier'); + const existing = state.outbox.find(entry => entry.id === id); + if (existing) { + if (digest({ content: existing.content, scope: existing.scope, provenance: existing.provenance }) !== identity) throw new Error('memory id conflicts with different content, scope or provenance'); + return existing; + } + const entry: MemoryEntry = { id, content, scope, provenance, status: 'queued' }; + state.outbox.push(entry); + return entry; +} + +interface Publication { + body: ObjectValue; + headers: Record; + remoteIdField: string; + method: string; + contract: ObjectValue; +} + +function field(value: unknown, label: string): string { + const key = text(value, label); + if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key) || ['__proto__', 'prototype', 'constructor'].includes(key)) throw new Error('mapping fields must be safe top-level JSON property names'); + return key; +} + +function publication(entry: MemoryEntry, input: ObjectValue): Publication { + if (input.provider === 'surreal-memory') { + const scope = object(input.scopeMapping, 'scopeMapping'); + if (scope.scope !== entry.scope) throw new Error('scopeMapping.scope must match the queued scope exactly'); + const agentId = text(scope.agentId, 'scopeMapping.agentId'); + const userId = scope.userId === undefined ? null : text(scope.userId, 'scopeMapping.userId'); + const sessionId = scope.sessionId === undefined ? null : text(scope.sessionId, 'scopeMapping.sessionId'); + // The verified REST request has no metadata or idempotency fields. Keep the + // publication envelope inside content rather than inventing accepted fields. + const content = JSON.stringify({ schemaVersion: 1, kind: 'agent-team-memory', id: entry.id, scope: entry.scope, provenance: entry.provenance, content: entry.content }); + return { + body: { content, agent_id: agentId, user_id: userId, session_id: sessionId, categories: ['agent-team', entry.scope] }, + headers: {}, remoteIdField: 'id', method: 'POST', + contract: { provider: 'surreal-memory', source: 'https://github.com/Prometheus-AGS/surreal-memory-server/blob/dd7fdcd6d8974af4059d1d51401bd33ae29f65db/src/contracts.rs', + route: 'POST /api/v1/memory/', scopeBinding: 'explicit identity filters and content envelope; not an authorization guarantee', remoteIdempotency: 'unsupported-by-verified-contract' }, + }; + } + if (input.provider !== 'mapped-http') throw new Error('memory provider must be surreal-memory or mapped-http'); + const mapping = object(input.mapping, 'mapping'); + const source = text(mapping.source, 'mapping.source'); + const version = text(mapping.version, 'mapping.version'); + const method = mapping.method ?? 'POST'; + if (method !== 'POST' && method !== 'PUT') throw new Error('mapping.method must be POST or PUT'); + const body: ObjectValue = mapping.constants === undefined ? {} : { ...object(mapping.constants, 'mapping.constants') }; + const fields: [string, Json][] = [ + [field(mapping.contentField, 'mapping.contentField'), entry.content], + [field(mapping.scopeField, 'mapping.scopeField'), entry.scope], + [field(mapping.provenanceField, 'mapping.provenanceField'), entry.provenance], + ]; + if (mapping.idempotencyField !== undefined) fields.push([field(mapping.idempotencyField, 'mapping.idempotencyField'), entry.id]); + const seen = new Set(); + for (const [key, value] of fields) { + if (seen.has(key) || Object.hasOwn(body, key)) throw new Error('memory mapping fields collide'); + seen.add(key); body[key] = value; + } + const headers: Record = {}; + if (mapping.idempotencyHeader !== undefined) { + const header = text(mapping.idempotencyHeader, 'mapping.idempotencyHeader'); + if (!/^(?:Idempotency-Key|X-Idempotency-Key)$/i.test(header)) throw new Error('unsupported idempotency header mapping'); + headers[header] = entry.id; + } + return { body, headers, method, remoteIdField: field(mapping.responseIdField, 'mapping.responseIdField'), + contract: { provider: 'mapped-http', source, version, scopeBinding: 'operator-configured mapping; server authorization unverified', + remoteIdempotency: mapping.idempotencyHeader || mapping.idempotencyField ? 'operator-mapped; server guarantee unverified' : 'not-configured' } }; +} + +/** Only an already-queued entry is eligible. Caller persists success AND failure receipts. */ +export async function publishMemory(state: TeamState, input: ObjectValue): Promise { + assertNoCredentials(input); + const id = text(input.id, 'memory.id'); + const entry = state.outbox.find(item => item.id === id); + if (!entry) throw new Error('queue and persist memory before publication'); + if (entry.status === 'published') return { id, status: 'published', receipt: entry.receipt ?? null, repeated: true }; + const previous = entry.receipt && typeof entry.receipt === 'object' && !Array.isArray(entry.receipt) ? entry.receipt as ObjectValue : {}; + if (previous.uncertain === true && input.retryUncertain !== true) { + return { id, status: 'queued', receipt: previous, reason: 'remote outcome uncertain; reconcile before explicitly setting retryUncertain' }; + } + if (input.url === undefined) { + entry.receipt = { at: new Date().toISOString(), outcome: 'unavailable', reason: 'no memory endpoint configured', uncertain: false }; + return { id, status: 'queued', receipt: entry.receipt }; + } + const url = endpoint(input.url); + if (input.provider === 'surreal-memory' && !url.pathname.endsWith('/api/v1/memory/')) throw new Error('surreal-memory url must name the verified /api/v1/memory/ route'); + const request = publication(entry, input); + assertNoCredentials(request.body); + const target = { url: url.href, contract: request.contract }; + const publicationKey = digest({ id, content: entry.content, scope: entry.scope, provenance: entry.provenance, target, body: request.body }); + if (previous.publicationKey !== undefined && previous.publicationKey !== publicationKey) throw new Error('retry destination or mapping differs from recorded attempt'); + const receipt: ObjectValue = { at: new Date().toISOString(), publicationKey, contentSha256: digest(entry.content), target, localIdempotencyKey: id, + exactlyOnce: false, uncertaintyNote: 'A crash after remote commit and before local receipt can duplicate a retry; reconcile remotely.' }; + try { + const response = await requestJson(url, input, request.method, request.body, request.headers); + const payload = object(response.value, 'memory response'); + const remoteId = payload[request.remoteIdField]; + if (remoteId === undefined || remoteId === null) throw new RequestFailure('remote_response_missing_id', true, response.status); + assertNoCredentials(remoteId); + entry.receipt = { ...receipt, outcome: 'published', httpStatus: response.status, remoteId, uncertain: false }; + entry.status = 'published'; + return { id, status: 'published', receipt: entry.receipt }; + } catch (error) { + // Invalid configuration fails before I/O; transport and response failures + // remain durable retryable outbox records without logging remote content. + if (!(error instanceof RequestFailure)) { + entry.receipt = { ...receipt, outcome: 'unavailable', reason: 'unsafe_or_unsupported_remote_response', uncertain: true }; + } else { + entry.receipt = { ...receipt, outcome: 'unavailable', reason: error.code, httpStatus: error.httpStatus, uncertain: error.uncertain }; + } + return { id, status: 'queued', receipt: entry.receipt }; + } +} diff --git a/resources/skills/agent-team-creator/runtime/src/models-http.mts b/resources/skills/agent-team-creator/runtime/src/models-http.mts new file mode 100644 index 00000000000..ea200a9c309 --- /dev/null +++ b/resources/skills/agent-team-creator/runtime/src/models-http.mts @@ -0,0 +1,98 @@ +import type { Json, ObjectValue } from './types.mjs'; + +export function object(value: unknown, label: string): ObjectValue { + if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error(`${label} must be an object`); + return value as ObjectValue; +} + +export function text(value: unknown, label: string): string { + if (typeof value !== 'string' || !value.trim()) throw new Error(`${label} must be a nonempty string`); + return value; +} + +export function assertNoCredentials(value: unknown): void { + const encoded = JSON.stringify(value); + for (const [name, secret] of Object.entries(process.env)) { + if (/(?:TOKEN|SECRET|PASSWORD|API_KEY|PRIVATE_KEY)$/i.test(name) && secret && secret.length >= 8 && encoded.includes(JSON.stringify(secret).slice(1, -1))) { + throw new Error('credential values must not be persisted; use environment references'); + } + } + function inspect(item: unknown): void { + if (Array.isArray(item)) { item.forEach(inspect); return; } + if (item && typeof item === 'object') for (const [key, child] of Object.entries(item)) { + if (/^(?:authorization|api_?key|access_?token|refresh_?token|password|secret|private_?key)$/i.test(key)) { + throw new Error('credential fields are not accepted in persisted content'); + } + inspect(child); + } + } + inspect(value); +} + +export function endpoint(value: unknown): URL { + const url = new URL(text(value, 'endpoint URL')); + if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password || url.hash) { + throw new Error('endpoint must be HTTP(S), without userinfo or fragment'); + } + if (url.protocol === 'http:' && !['localhost', '127.0.0.1', '[::1]'].includes(url.hostname)) { + throw new Error('non-loopback endpoints require HTTPS'); + } + for (const key of url.searchParams.keys()) { + if (/(?:token|key|secret|password|credential|auth)/i.test(key)) throw new Error('URL credentials are forbidden; use auth.env'); + } + assertNoCredentials(url.href); + return url; +} + +export class RequestFailure extends Error { + constructor(public readonly code: string, public readonly uncertain: boolean, public readonly httpStatus: number | null = null) { + super(code); + } +} + +// Response bodies and raw fetch errors are never returned: either can echo credentials. +export async function requestJson(url: URL, input: ObjectValue, method = 'GET', body?: Json, extraHeaders: Record = {}): Promise<{ value: Json; status: number }> { + const headers: Record = { Accept: 'application/json', ...extraHeaders }; + let secret: string | undefined; + if (input.auth !== undefined) { + const auth = object(input.auth, 'auth'); + if (Object.keys(auth).some(key => !['env', 'header', 'scheme'].includes(key))) throw new RequestFailure('invalid_auth_configuration', false); + const name = text(auth.env, 'auth.env'); + if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) throw new RequestFailure('invalid_auth_environment_reference', false); + secret = process.env[name]; + if (!secret) throw new RequestFailure('credential_environment_unavailable', false); + if (/[\r\n]/.test(secret)) throw new RequestFailure('invalid_credential_environment_value', false); + const header = auth.header ?? 'Authorization'; + if (header !== 'Authorization' && header !== 'X-API-Key') throw new RequestFailure('invalid_auth_header', false); + const scheme = auth.scheme ?? (header === 'Authorization' ? 'Bearer' : 'raw'); + if (scheme !== 'Bearer' && scheme !== 'raw') throw new RequestFailure('invalid_auth_scheme', false); + headers[header] = scheme === 'Bearer' ? `Bearer ${secret}` : secret; + } + const timeout = input.timeoutMs ?? 10000; + if (typeof timeout !== 'number' || !Number.isInteger(timeout) || timeout < 1 || timeout > 60000) throw new RequestFailure('invalid_timeout_configuration', false); + if (body !== undefined) headers['Content-Type'] = 'application/json'; + let response: Response; + try { + response = await fetch(url, { method, headers, body: body === undefined ? undefined : JSON.stringify(body), redirect: 'error', signal: AbortSignal.timeout(timeout) }); + } catch { throw new RequestFailure('transport_unavailable_or_redirect_refused', method !== 'GET'); } + if (!response.ok) { + await response.body?.cancel(); + throw new RequestFailure('http_request_failed', method !== 'GET' && response.status >= 500, response.status); + } + try { + const reader = response.body?.getReader(); + if (!reader) throw new Error('empty'); + const chunks: Uint8Array[] = []; + let length = 0; + while (true) { + const part = await reader.read(); + if (part.done) break; + length += part.value.length; + if (length > 8 * 1024 * 1024) { await reader.cancel(); throw new Error('large'); } + chunks.push(part.value); + } + const encoded = Buffer.concat(chunks).toString('utf8'); + if (secret && encoded.includes(secret)) throw new Error('credential reflected'); + return { value: JSON.parse(encoded) as Json, status: response.status }; + } catch { throw new RequestFailure('invalid_or_unsafe_json_response', method !== 'GET', response.status); } +} diff --git a/resources/skills/agent-team-creator/runtime/src/models.mts b/resources/skills/agent-team-creator/runtime/src/models.mts new file mode 100644 index 00000000000..8ab16ac0c87 --- /dev/null +++ b/resources/skills/agent-team-creator/runtime/src/models.mts @@ -0,0 +1,174 @@ +import type { Json, ObjectValue, ModelPolicy, Team } from './types.mjs'; +import { assertNoCredentials, endpoint, object, requestJson, text } from './models-http.mjs'; +import { policy as validatePolicy } from './validation.mjs'; + +const tiers = ['low', 'medium', 'hard']; +const own = (record: ObjectValue, key: string): Json | undefined => Object.hasOwn(record, key) ? record[key] : undefined; +const maybeObject = (value: unknown): ObjectValue => value && typeof value === 'object' && !Array.isArray(value) ? value as ObjectValue : {}; +const numberOrNull = (value: unknown): number | null => typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : null; +const stringOrNull = (value: unknown): string | null => typeof value === 'string' && value.length > 0 ? value : null; + +function freshness(provenance: ObjectValue, maxAge: Json | undefined): ObjectValue { + const limit = maxAge ?? 30; + if (typeof limit !== 'number' || !Number.isFinite(limit) || limit < 0) throw new Error('maxCatalogAgeDays must be nonnegative'); + const fetched = stringOrNull(provenance.fetched); + const stamp = fetched ? Date.parse(fetched) : NaN; + const age = Number.isFinite(stamp) ? (Date.now() - stamp) / 86400000 : null; + return { fetchedAt: fetched, ageDays: age === null ? null : Math.max(0, age), stale: age === null || age < 0 ? null : age > limit, maxAgeDays: limit }; +} + +function catalogMetadata(input: ObjectValue): { providers: ObjectValue; provenance: ObjectValue; freshness: ObjectValue } { + const catalog = input.catalog === undefined ? {} : object(input.catalog, 'catalog'); + if (input.catalog !== undefined && catalog.$schema_version !== 1) throw new Error('liter-llm catalog requires $schema_version 1'); + const original = maybeObject(catalog.$provenance); + const provenance: ObjectValue = { + source: stringOrNull(original.source), sourceSha256: stringOrNull(original.source_sha256), + fetched: stringOrNull(original.fetched), libraryVersion: stringOrNull(original.library_version), + adapter: 'liter-llm/catalog-schema-1', sourceRevision: 'c5c6caac617eb931cd5009146a70831422ec236c', + }; + return { providers: maybeObject(catalog.providers), provenance, freshness: freshness(provenance, input.maxCatalogAgeDays) }; +} + +function prices(model: ObjectValue): ObjectValue { + const pricing = maybeObject(model.pricing); + const rows = [pricing, ...(Array.isArray(pricing.tiers) ? pricing.tiers.map(value => object(value, 'catalog pricing tier')) : [])]; + const max = (key: string): number | null => { + const values = rows.map(row => numberOrNull(row[key])); + const converted = values.some(value => value === null) ? null : Math.max(...values as number[]) * 1_000_000; + return numberOrNull(converted); + }; + return { inputPerMillion: max('input_cost_per_token'), outputPerMillion: max('output_cost_per_token'), currency: 'USD', basis: 'maximum-known-context-tier', sourceUnit: 'per-token' }; +} + +function normalize(input: ObjectValue, rows: Json[], kind: string, discovery: ObjectValue | null): ObjectValue { + const metadata = catalogMetadata(input); + const aliases = input.aliases === undefined ? {} : object(input.aliases, 'aliases'); + const annotations = input.tiers === undefined ? {} : object(input.tiers, 'tiers'); + for (const tier of Object.values(annotations)) if (typeof tier !== 'string' || !tiers.includes(tier)) throw new Error('operator tiers must be low, medium or hard'); + const seen = new Set(); + const models = rows.map(value => { + const row = typeof value === 'string' ? { id: value } : object(value, 'discovered model'); + const id = text(row.id, 'model.id'); + if (seen.has(id)) throw new Error('duplicate discovered model identifier; narrow discovery to one provider'); + seen.add(id); + const mapped = own(aliases, id); + const mapping = mapped === undefined ? null : object(mapped, 'alias mapping'); + const provider = mapping ? text(mapping.provider, 'alias.provider') : null; + const catalogId = mapping ? text(mapping.model, 'alias.model') : null; + const providerModels = provider ? maybeObject(maybeObject(own(metadata.providers, provider)).models) : {}; + const catalogModel = catalogId ? own(providerModels, catalogId) : undefined; + if (mapping && !catalogModel) throw new Error('explicit alias mapping does not identify a catalog model'); + const model = maybeObject(catalogModel); + const capabilities: ObjectValue = {}; + for (const [key, flag] of Object.entries(maybeObject(model.capabilities))) if (typeof flag === 'boolean') capabilities[key] = flag; + const fields: Record = { supports_tools: 'function_calling', supports_vision: 'vision', supports_streaming: 'streaming', supports_reasoning: 'reasoning', supports_thinking: 'reasoning', supports_structured_output: 'structured_output' }; + if (kind === 'uar' || kind === 'bossfang') for (const [field, capability] of Object.entries(fields)) { + if (typeof row[field] === 'boolean') capabilities[capability] = row[field]; + } + const pricing = prices(model); + if (!mapping && kind === 'bossfang') { + pricing.inputPerMillion = numberOrNull(row.input_cost_per_m); + pricing.outputPerMillion = numberOrNull(row.output_cost_per_m); + pricing.sourceUnit = 'per-million-tokens'; + pricing.basis = 'bossfang-configured-base-price'; + } + const available = kind === 'bossfang' ? (typeof row.available === 'boolean' ? row.available : null) + : kind === 'uar' ? (typeof row.enabled === 'boolean' ? row.enabled : null) : true; + return { + id, provider: provider ?? stringOrNull(row.provider), catalogId, available, + tier: own(annotations, id) ?? null, capabilities, pricing, + provenance: { discovery, catalog: mapping ? metadata.provenance : null, + availabilityBasis: kind === 'declared' ? 'operator-declared' : 'configured-discovery-response', + metadataBasis: mapping ? 'explicit-alias-mapping' : kind === 'declared' ? 'operator-declared-identifier-only' : kind === 'openai' ? 'identifier-only' : 'configured-native-record' }, + freshness: mapping ? metadata.freshness : { fetchedAt: discovery?.fetchedAt ?? null, ageDays: discovery ? 0 : null, stale: null }, + }; + }).sort((a, b) => a.id < b.id ? -1 : a.id > b.id ? 1 : 0); + const result: ObjectValue = { schemaVersion: 1, models, catalogProvenance: metadata.provenance, catalogFreshness: metadata.freshness, + diagnostics: ['Discovery describes configured availability, not a successful inference.', 'Strength tiers are operator annotations; unspecified metadata remains unknown.'] }; + assertNoCredentials(result); + return result; +} + +/** Discover identifiers; catalog aliases are explicit and credentials remain in the environment. */ +export async function discoverModels(input: ObjectValue): Promise { + assertNoCredentials(input); + const kind = input.kind ?? 'openai'; + if (kind !== 'openai' && kind !== 'uar' && kind !== 'bossfang') throw new Error('discovery kind must be openai, uar or bossfang'); + let url: URL; + if (input.discoveryUrl !== undefined) url = endpoint(input.discoveryUrl); + else { + if (kind !== 'openai') throw new Error('UAR and BossFang require an explicit discoveryUrl'); + url = endpoint(input.baseUrl); + if (url.search) throw new Error('baseUrl cannot contain a query'); + url.pathname = `${url.pathname.replace(/\/$/, '').replace(/\/v1$/, '')}/v1/models`; + } + const response = await requestJson(url, input); + const payload = kind === 'uar' ? response.value : object(response.value, 'model discovery response')[kind === 'bossfang' ? 'models' : 'data']; + if (!Array.isArray(payload)) throw new Error('model discovery response has an unsupported shape'); + return normalize(input, payload, kind, { kind, url: url.href, fetchedAt: new Date().toISOString(), httpStatus: response.status }); +} + +/** Layered scalar overrides, AND capabilities, deterministic lowest-known-total-price choice. */ +export function selectModel(team: Team, roleId: string, skills: string[], taskPolicy: ModelPolicy, catalog: unknown): Json { + const role = team.roles.find(candidate => candidate.id === roleId); + if (!role) throw new Error('unknown role for model selection'); + const layers: [string, ModelPolicy | undefined][] = [['team', team.modelPolicy], ['role', role.modelPolicy], + ...skills.map(skill => [`skill:${skill}`, team.skillPolicies?.[skill]] as [string, ModelPolicy | undefined]), ['task', taskPolicy]]; + let policy: ModelPolicy = {}; + const applied: string[] = []; + for (const [name, layer] of layers) if (layer) { + validatePolicy(layer, `${name}.modelPolicy`); + const capabilities = [...new Set([...(policy.capabilities ?? []), ...(layer.capabilities ?? [])])]; + policy = { ...policy, ...Object.fromEntries(Object.entries(layer).filter(([, value]) => value !== undefined)), capabilities }; + applied.push(name); + } + const input = object(catalog, 'catalog input'); + const normalized = input.schemaVersion === 1 && Array.isArray(input.models) ? input + : normalize(input, Array.isArray(input.availableModels) ? input.availableModels : [], 'declared', null); + if (!Array.isArray(normalized.models)) throw new Error('normalized catalog models must be an array'); + const accepted: ObjectValue[] = []; + const rejected: Json[] = []; + const seen = new Set(); + for (const value of normalized.models) { + const model = object(value, 'model'); + const id = text(model.id, 'model.id'); + if (seen.has(id)) throw new Error('duplicate model identifier in selection catalog'); + seen.add(id); + const reasons: string[] = []; + const capabilities = maybeObject(model.capabilities); + const pricing = maybeObject(model.pricing); + if (model.available !== true) reasons.push('availability unknown or disabled'); + if (policy.model && model.id !== policy.model) reasons.push('different explicit model'); + if (policy.tier && model.tier !== policy.tier) reasons.push('declared tier missing or different'); + for (const capability of policy.capabilities ?? []) if (capabilities[capability] !== true) reasons.push(`capability ${capability} unsupported or unknown`); + const ceilings: [number | undefined, Json | undefined][] = [[policy.maxInputPerMillion, pricing.inputPerMillion], [policy.maxOutputPerMillion, pricing.outputPerMillion]]; + for (const [ceiling, price] of ceilings) { + if (ceiling === undefined) continue; + const known = numberOrNull(price); + if (known === null) reasons.push('price unknown; cannot satisfy ceiling'); + else if (known > ceiling) reasons.push('price exceeds ceiling'); + } + if (reasons.length) rejected.push({ id: model.id, reasons }); + else accepted.push(model); + } + const totalPrice = (model: ObjectValue): number => { + const pricing = maybeObject(model.pricing); + const input = numberOrNull(pricing.inputPerMillion), output = numberOrNull(pricing.outputPerMillion); + return input === null || output === null ? Infinity : input + output; + }; + accepted.sort((a, b) => (totalPrice(a) - totalPrice(b)) || (String(a.id) < String(b.id) ? -1 : String(a.id) > String(b.id) ? 1 : 0)); + const selected = accepted[0] ?? null; + const warnings: string[] = []; + if (selected) { + const age = maybeObject(selected.freshness); + if (age.stale === true) warnings.push('Selected catalog pricing is stale. Price ceilings do not guarantee current provider rates.'); + else if (age.stale === null || age.stale === undefined) warnings.push('Selected pricing freshness is unknown. Price ceilings do not guarantee current provider rates.'); + const provenance = maybeObject(selected.provenance); + if (provenance.availabilityBasis === 'operator-declared') warnings.push('Availability is operator-declared; no live discovery was performed for this list.'); + } + const result: ObjectValue = { selected, policy: policy as unknown as ObjectValue, appliedLayers: applied, rejected, + explanation: selected ? 'All declared constraints satisfied; ordered by lowest known input+output USD per million, then exact identifier. Unknown costs rank last.' : 'No declared available model satisfies every constraint.', + warnings, diagnostics: normalized.diagnostics ?? [], catalogProvenance: normalized.catalogProvenance ?? null, catalogFreshness: normalized.catalogFreshness ?? null }; + assertNoCredentials(result); + return result; +} diff --git a/resources/skills/agent-team-creator/runtime/src/state-kbd.mts b/resources/skills/agent-team-creator/runtime/src/state-kbd.mts new file mode 100644 index 00000000000..5c0f7019db1 --- /dev/null +++ b/resources/skills/agent-team-creator/runtime/src/state-kbd.mts @@ -0,0 +1,83 @@ +import { spawnSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { resolve } from 'node:path'; +import type { KbdIdentity, ObjectValue, TeamState } from './types.mjs'; +import { prepareCompletion, recordEvent } from './state-tasks.mjs'; +import { integer, object, text, validateState } from './state-validation.mjs'; + +function execute(binary: string, argv: string[], cwd: string): { value: ObjectValue; stdout: string } { + const result = spawnSync(binary, argv, { + cwd, shell: false, encoding: 'utf8', timeout: 30_000, maxBuffer: 16 * 1024 * 1024, + }); + if (result.error || result.status !== 0) { + throw new Error(`Canonical KBD command failed or has an uncertain outcome; local completion was not recorded. Re-read canonical status before retrying. ${result.error?.message ?? result.stderr.trim()}`); + } + let value: unknown; + try { value = JSON.parse(result.stdout); } + catch { throw new Error('Canonical CLI returned non-JSON output; completion is unconfirmed, re-read status before retrying'); } + return { value: object(value, 'canonical CLI output'), stdout: result.stdout }; +} + +function verifyIdentity(state: ObjectValue, identity: KbdIdentity): ObjectValue { + if (state.projectId !== identity.projectId || state.runId !== identity.runId) throw new Error('Canonical project/run identity does not match the linked task'); + if (integer(state.revision, 'canonical revision') === 0) throw new Error('Canonical run is not initialized'); + const phase = object(object(state.phases, 'canonical phases')[identity.phaseId], 'canonical phase'); + const change = object(object(phase.changes, 'canonical changes')[identity.changeId], 'canonical change'); + const task = object(object(change.tasks, 'canonical tasks')[identity.taskId], 'canonical task'); + if (phase.id !== identity.phaseId || change.id !== identity.changeId || task.id !== identity.taskId) throw new Error('Canonical phase/change/task identity mismatch'); + return task; +} + +/** + * Source contract: prometheus-cli main.rs KbdAction::Status / KbdTaskAction::Transition. + * kbd --path status --json + * kbd --path task transition --command-id + * --phase --change --id --status complete --summary + * + * Call only inside a state transaction. kbdCli is explicit because PATH may name + * another product. No shell, hooks, service registration, or synthetic KBD events. + * The CLI chooses its canonical frontier; it exposes no expected-run/revision + * argument. Preflight + committed-response identity checks detect drift but cannot + * provide a distributed transaction across KBD and this file. A crash after the + * canonical commit requires retry/reconciliation; never roll back KBD history. + */ +export function completeKbdTask(state: TeamState, input: ObjectValue, cwd: string): void { + validateState(state); + const complete = prepareCompletion(state, input); + if (!complete.kbd) throw new Error('Task has no canonical KBD identity; use ordinary task completion'); + const binary = text(input.kbdCli, 'kbdCli executable'); + const directory = resolve(cwd); + const base = ['kbd', '--path', directory]; + const identity = complete.kbd; + const before = execute(binary, [...base, 'status', '--json'], directory); + const canonicalTask = verifyIdentity(before.value, identity); + if (!['in_progress', 'complete'].includes(String(canonicalTask.status))) throw new Error(`Canonical task must be in_progress before completion; current status: ${String(canonicalTask.status)}`); + const commandId = `agent-team-${createHash('sha256').update(JSON.stringify({ + team: state.team.id, task: complete.id, revision: complete.revision - 1, identity, + })).digest('hex')}`; + let receipt = before; + let argv = [...base, 'status', '--json']; + let mode = 'reconciled-existing-completion'; + if (canonicalTask.status !== 'complete') { + argv = [...base, 'task', 'transition', '--command-id', commandId, + '--phase', identity.phaseId, '--change', identity.changeId, '--id', identity.taskId, + '--status', 'complete', '--summary', `Team ${state.team.id}, task ${complete.id}. Evidence: ${complete.evidence.join('; ')}`]; + const response = execute(binary, argv, directory); + receipt = { value: object(response.value.state, 'committed canonical state'), stdout: response.stdout }; + mode = 'transition-committed'; + } + const committed = verifyIdentity(receipt.value, identity); + if (committed.status !== 'complete') throw new Error('Canonical response did not confirm completion; local task remains unchanged'); + const draft = structuredClone(state); + draft.tasks[draft.tasks.findIndex(task => task.id === complete.id)] = complete; + recordEvent(draft, 'kbd.task.completed', { + taskId: complete.id, taskRevision: complete.revision, owner: complete.owner, + kbd: { ...identity }, commandId, mode, executable: binary, argv, + canonicalRevision: receipt.value.revision!, canonicalTaskStatus: committed.status, + canonicalEventId: receipt.value.lastEventId ?? null, + receiptSha256: createHash('sha256').update(receipt.stdout).digest('hex'), + evidence: complete.evidence, + }); + validateState(draft); + Object.assign(state, draft); +} diff --git a/resources/skills/agent-team-creator/runtime/src/state-tasks.mts b/resources/skills/agent-team-creator/runtime/src/state-tasks.mts new file mode 100644 index 00000000000..84d2d35992f --- /dev/null +++ b/resources/skills/agent-team-creator/runtime/src/state-tasks.mts @@ -0,0 +1,105 @@ +import { randomUUID } from 'node:crypto'; +import type { KbdIdentity, ModelPolicy, ObjectValue, TeamState, TeamTask } from './types.mjs'; +import { harness, integer, object, owner, strings, text, validateState } from './state-validation.mjs'; + +export function recordEvent(state: TeamState, kind: string, detail: ObjectValue): void { + state.events.push({ id: randomUUID(), at: new Date().toISOString(), kind, detail }); +} + +export function checkedTask(state: TeamState, input: ObjectValue): TeamTask { + const id = text(input.id ?? input.taskId, 'task id'); + const task = state.tasks.find(candidate => candidate.id === id); + if (!task) throw new Error(`Unknown task: ${id}`); + if (owner(state, input.owner) !== task.owner) throw new Error(`Task ${id} belongs to ${task.owner}`); + if (integer(input.expectedTaskRevision, 'expectedTaskRevision') !== task.revision) throw new Error(`Task revision conflict: ${id} is at ${task.revision}`); + if (['complete', 'cancelled'].includes(task.status)) throw new Error(`Task ${id} is terminal`); + if (task.revision === Number.MAX_SAFE_INTEGER) throw new Error('Task revision exhausted'); + return task; +} + +export function dependenciesComplete(state: TeamState, task: TeamTask): void { + for (const id of task.dependsOn) { + if (state.tasks.find(candidate => candidate.id === id)?.status !== 'complete') throw new Error(`Dependency ${id} is not complete`); + } +} + +export function prepareCompletion(state: TeamState, input: ObjectValue): TeamTask { + const task = checkedTask(state, input); + dependenciesComplete(state, task); + if (task.status !== 'running') throw new Error('Start the task before completing it'); + const evidence = [...new Set([...task.evidence, ...strings(input.evidence, 'completion evidence')])]; + const remaining = input.remaining === undefined ? task.remaining : strings(input.remaining, 'remaining'); + if (!evidence.length || remaining.length) throw new Error('Completion requires evidence and no remaining work'); + return { ...task, status: 'complete', revision: task.revision + 1, evidence, remaining }; +} + +export function taskAction(state: TeamState, input: ObjectValue): void { + validateState(state); + object(input, 'task action'); + const draft = structuredClone(state); + const action = text(input.action, 'action'); + if (action === 'add') { + const id = text(input.id, 'task id'); + if (draft.tasks.some(task => task.id === id)) throw new Error(`Task already exists: ${id}`); + const task: TeamTask = { + id, title: text(input.title, 'title'), owner: owner(draft, input.owner), + harness: harness(input.harness ?? draft.team.harness), status: 'pending', revision: 0, + dependsOn: strings(input.dependsOn ?? [], 'dependsOn'), + evidence: strings(input.evidence ?? [], 'evidence'), remaining: strings(input.remaining ?? [], 'remaining'), + }; + if (input.kbd !== undefined) task.kbd = structuredClone(object(input.kbd, 'kbd')) as unknown as KbdIdentity; + if (input.modelPolicy !== undefined) task.modelPolicy = structuredClone(object(input.modelPolicy, 'modelPolicy')) as unknown as ModelPolicy; + draft.tasks.push(task); + recordEvent(draft, 'task.added', { taskId: id, owner: task.owner, harness: task.harness, taskRevision: 0 }); + } else { + const task = checkedTask(draft, input); + const previousOwner = task.owner; + const previousHarness = task.harness; + const previousStatus = task.status; + const previousRevision = task.revision; + if (action === 'complete') { + if (task.kbd) throw new Error('KBD-linked completion requires completeKbdTask and a successful canonical CLI receipt'); + Object.assign(task, prepareCompletion(draft, input)); + } else { + switch (action) { + case 'start': + if (!['pending', 'blocked'].includes(task.status)) throw new Error('Only pending or blocked tasks can start'); + dependenciesComplete(draft, task); + task.status = 'running'; + break; + case 'block': { + if (!['pending', 'running'].includes(task.status)) throw new Error('Only pending or running tasks can be blocked'); + const reason = text(input.reason, 'block reason'); + task.remaining = [...new Set([...task.remaining, reason])]; + task.status = 'blocked'; + break; + } + case 'cancel': + text(input.reason, 'cancellation reason'); + task.status = 'cancelled'; + break; + case 'reassign': + task.owner = owner(draft, input.toOwner); + task.harness = harness(input.toHarness ?? task.harness); + if (task.owner === previousOwner && task.harness === previousHarness) throw new Error('Reassignment must change owner or harness'); + if (task.status === 'running') task.status = 'pending'; + break; + default: throw new Error(`Unsupported task action: ${action}`); + } + if (input.evidence !== undefined) task.evidence = [...new Set([...task.evidence, ...strings(input.evidence, 'evidence')])]; + if (input.remaining !== undefined) { + task.remaining = strings(input.remaining, 'remaining'); + if (action === 'block') task.remaining = [...new Set([...task.remaining, text(input.reason, 'block reason')])]; + } + task.revision++; + } + recordEvent(draft, `task.${action}`, { + taskId: task.id, previousRevision, taskRevision: task.revision, + previousOwner, previousHarness, previousStatus, owner: task.owner, harness: task.harness, + status: task.status, evidence: task.evidence, remaining: task.remaining, + ...(input.reason === undefined ? {} : { reason: text(input.reason, 'reason') }), + }); + } + validateState(draft); + Object.assign(state, draft); +} diff --git a/resources/skills/agent-team-creator/runtime/src/state-validation.mts b/resources/skills/agent-team-creator/runtime/src/state-validation.mts new file mode 100644 index 00000000000..6b05c42d1f7 --- /dev/null +++ b/resources/skills/agent-team-creator/runtime/src/state-validation.mts @@ -0,0 +1,171 @@ +import { harnesses, type Harness, type ObjectValue, type TeamState } from './types.mjs'; +import { validateTeam } from './validation.mjs'; + +export function object(value: unknown, label: string): ObjectValue { + if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error(`${label} must be an object`); + return value as ObjectValue; +} + +export function text(value: unknown, label: string): string { + if (typeof value !== 'string' || !value.trim() || value.includes('\0')) throw new Error(`${label} must be a nonempty string without NUL`); + return value; +} + +export function integer(value: unknown, label: string): number { + if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 0) throw new Error(`${label} must be a nonnegative safe integer`); + return value; +} + +export function strings(value: unknown, label: string): string[] { + if (!Array.isArray(value)) throw new Error(`${label} must be an array`); + return value.map((item, index) => text(item, `${label}[${index}]`)); +} + +export function harness(value: unknown): Harness { + if (typeof value !== 'string' || !harnesses.includes(value as Harness)) throw new Error(`Unsupported harness: ${String(value)}`); + return value as Harness; +} + +export function owner(state: TeamState, value: unknown): string { + const id = text(value, 'owner'); + if (!state.team.roles.some(role => role.id === id)) throw new Error(`Unknown team role: ${id}`); + return id; +} + +function timestamp(value: unknown, label: string): void { + if (!Number.isFinite(Date.parse(text(value, label)))) throw new Error(`${label} must be an ISO timestamp`); +} + +function uniqueIds(items: unknown, label: string): ObjectValue[] { + if (!Array.isArray(items)) throw new Error(`${label} must be an array`); + const ids = new Set(); + return items.map(item => { + const entry = object(item, label); + const id = text(entry.id, `${label}.id`); + if (ids.has(id)) throw new Error(`Duplicate ${label} id: ${id}`); + ids.add(id); + return entry; + }); +} + +function jsonBoundary(value: unknown, ancestors = new Set()): void { + if (value === null || typeof value === 'string' || typeof value === 'boolean') return; + if (typeof value === 'number' && Number.isFinite(value)) return; + if (!value || typeof value !== 'object') throw new Error('State must contain only JSON values'); + if (ancestors.has(value)) throw new Error('State contains a cyclic object'); + if (!Array.isArray(value) && ![Object.prototype, null].includes(Object.getPrototypeOf(value))) throw new Error('State objects must be plain JSON objects'); + ancestors.add(value); + for (const item of Array.isArray(value) ? value : Object.values(value)) jsonBoundary(item, ancestors); + ancestors.delete(value); +} + +export function validateState(value: unknown): TeamState { + jsonBoundary(value); + const raw = object(value, 'state'); + if (raw.schemaVersion !== 1) throw new Error('Unsupported state schemaVersion'); + integer(raw.revision, 'state.revision'); + validateTeam(raw.team); + const state = value as TeamState; + const tasks = uniqueIds(raw.tasks, 'task'); + const byId = new Map(tasks.map(task => [task.id, task])); + for (const task of tasks) { + text(task.title, 'task.title'); + owner(state, task.owner); + harness(task.harness); + integer(task.revision, 'task.revision'); + if (!['pending', 'running', 'blocked', 'complete', 'cancelled'].includes(String(task.status))) throw new Error(`Invalid task status: ${String(task.status)}`); + const dependencies = strings(task.dependsOn, 'task.dependsOn'); + if (new Set(dependencies).size !== dependencies.length) throw new Error('Duplicate task dependency'); + for (const dependency of dependencies) { + if (dependency === task.id || !byId.has(dependency)) throw new Error(`Invalid task dependency: ${dependency}`); + if (['running', 'complete'].includes(String(task.status)) && byId.get(dependency)!.status !== 'complete') throw new Error(`Dependency ${dependency} is not complete`); + } + const evidence = strings(task.evidence, 'task.evidence'); + const remaining = strings(task.remaining, 'task.remaining'); + if (task.status === 'complete' && (!evidence.length || remaining.length)) throw new Error('Completed tasks require evidence and no remaining work'); + if (task.kbd !== undefined) { + const kbd = object(task.kbd, 'task.kbd'); + for (const field of ['projectId', 'runId', 'phaseId', 'changeId', 'taskId']) text(kbd[field], `task.kbd.${field}`); + } + if (task.modelPolicy !== undefined) { + // Reuse the manifest's policy boundary without inventing another schema. + validateTeam({ ...state.team, modelPolicy: task.modelPolicy }); + } + } + const visited = new Set(); + const visiting = new Set(); + function visit(id: string): void { + if (visiting.has(id)) throw new Error(`Task dependency cycle at ${id}`); + if (visited.has(id)) return; + visiting.add(id); + for (const dependency of byId.get(id)!.dependsOn as string[]) visit(dependency); + visiting.delete(id); + visited.add(id); + } + for (const task of state.tasks) visit(task.id); + for (const handoff of uniqueIds(raw.handoffs, 'handoff')) { + if (handoff.schemaVersion !== 1) throw new Error('Unsupported handoff schemaVersion'); + const taskId = text(handoff.taskId, 'handoff.taskId'); + if (!byId.has(taskId)) throw new Error(`Unknown handoff task: ${taskId}`); + const revision = integer(handoff.taskRevision, 'handoff.taskRevision'); + if (revision > (byId.get(taskId)!.revision as number)) throw new Error('Handoff references a future task revision'); + for (const field of ['from', 'to']) { + const endpoint = object(handoff[field], `handoff.${field}`); + owner(state, endpoint.owner); + harness(endpoint.harness); + } + text(handoff.context, 'handoff.context'); + text(handoff.prompt, 'handoff.prompt'); + for (const field of ['evidence', 'remaining', 'memoryRefs']) strings(handoff[field], `handoff.${field}`); + const git = object(handoff.git, 'handoff.git'); + text(git.root, 'handoff.git.root'); + for (const field of ['head', 'branch']) if (git[field] !== null) text(git[field], `handoff.git.${field}`); + if (git.dirty !== null && typeof git.dirty !== 'boolean') throw new Error('handoff.git.dirty must be boolean or null'); + timestamp(handoff.createdAt, 'handoff.createdAt'); + if (handoff.acceptedAt !== undefined) timestamp(handoff.acceptedAt, 'handoff.acceptedAt'); + } + for (const memory of uniqueIds(raw.outbox, 'memory')) { + text(memory.content, 'memory.content'); + text(memory.scope, 'memory.scope'); + object(memory.provenance, 'memory.provenance'); + if (!['queued', 'published'].includes(String(memory.status))) throw new Error('Invalid memory status'); + if (memory.status === 'published' && memory.receipt === undefined) throw new Error('Published memory requires a receipt'); + } + for (const event of uniqueIds(raw.events, 'event')) { + timestamp(event.at, 'event.at'); + text(event.kind, 'event.kind'); + object(event.detail, 'event.detail'); + } + return state; +} + +export function validateMutation(before: TeamState, after: TeamState): void { + if (after.revision !== before.revision || after.team.id !== before.team.id) throw new Error('Callback may not change state revision or team identity'); + validateState(after); + const same = (left: unknown, right: unknown) => JSON.stringify(left) === JSON.stringify(right); + if (!same(before.events, after.events.slice(0, before.events.length))) throw new Error('Event history is append-only'); + for (const old of before.tasks) { + const next = after.tasks.find(task => task.id === old.id); + if (!next) throw new Error('Tasks cannot be deleted; cancel instead'); + if (same(old, next)) continue; + if (['complete', 'cancelled'].includes(old.status)) throw new Error(`Task ${old.id} is terminal`); + if (next.revision !== old.revision + 1) throw new Error('Each changed task must increment its revision once'); + if (!same(old.kbd, next.kbd)) throw new Error('Canonical task identity cannot be reassigned'); + const ownershipChanged = old.owner !== next.owner || old.harness !== next.harness; + const allowed = old.status === 'pending' + ? ['pending', 'running', 'blocked', 'cancelled'] + : old.status === 'running' + ? ['running', 'blocked', 'complete', 'cancelled', ...(ownershipChanged ? ['pending'] : [])] + : ['blocked', 'running', 'cancelled']; + if (!allowed.includes(next.status)) throw new Error(`Invalid task transition: ${old.status} -> ${next.status}`); + if (old.kbd && next.status === 'complete') { + const receipt = after.events.slice(before.events.length).find(event => event.kind === 'kbd.task.completed' && event.detail.taskId === next.id && event.detail.taskRevision === next.revision); + if (!receipt || !same(receipt.detail.kbd, next.kbd) || receipt.detail.canonicalTaskStatus !== 'complete') throw new Error('Linked task completion requires a canonical completion receipt'); + } + } + for (const task of after.tasks) if (!before.tasks.some(old => old.id === task.id) && task.revision !== 0) throw new Error('New tasks start at revision 0'); + for (const old of before.handoffs) { + const next = after.handoffs.find(handoff => handoff.id === old.id); + if (!next || !same({ ...old, acceptedAt: undefined }, { ...next, acceptedAt: undefined }) || (old.acceptedAt !== undefined && next.acceptedAt !== old.acceptedAt)) throw new Error('Handoff packets and accepted receipts are immutable'); + } +} diff --git a/resources/skills/agent-team-creator/runtime/src/state.mts b/resources/skills/agent-team-creator/runtime/src/state.mts new file mode 100644 index 00000000000..61383d20695 --- /dev/null +++ b/resources/skills/agent-team-creator/runtime/src/state.mts @@ -0,0 +1,104 @@ +import { closeSync, existsSync, fsyncSync, lstatSync, mkdirSync, openSync, readFileSync, realpathSync, renameSync, rmSync, writeFileSync } from 'node:fs'; +import { basename, dirname, join, resolve } from 'node:path'; +import { randomUUID } from 'node:crypto'; +import type { Team, TeamState } from './types.mjs'; +import { integer, validateMutation, validateState } from './state-validation.mjs'; +export { taskAction } from './state-tasks.mjs'; +export { completeKbdTask } from './state-kbd.mjs'; + +function statePath(file: string, createDirectory = false): string { + const absolute = resolve(file); + if (createDirectory) mkdirSync(dirname(absolute), { recursive: true }); + const path = join(realpathSync(dirname(absolute)), basename(absolute)); + if (existsSync(path) && (!lstatSync(path).isFile() || lstatSync(path).isSymbolicLink())) throw new Error('State must be a regular file, not a symlink'); + return path; +} + +function lock(file: string): () => void { + const path = `${file}.lock`; + const token = randomUUID(); + let fd: number; + try { fd = openSync(path, 'wx', 0o600); } + catch (error) { + if ((error as NodeJS.ErrnoException).code === 'EEXIST') throw new Error(`State lock held: ${path}. No automatic stale-lock takeover; inspect the recorded owner before manual recovery.`); + throw error; + } + try { writeFileSync(fd, JSON.stringify({ token, pid: process.pid, at: new Date().toISOString() })); fsyncSync(fd); } + catch (error) { closeSync(fd); rmSync(path, { force: true }); throw error; } + closeSync(fd); + return () => { + try { + if (JSON.parse(readFileSync(path, 'utf8')).token === token) rmSync(path); + } catch { /* A removed/replaced lock is never stolen from another writer. */ } + }; +} + +function atomicWrite(file: string, state: TeamState): void { + const temporary = join(dirname(file), `.${basename(file)}.${randomUUID()}.tmp`); + const fd = openSync(temporary, 'wx', 0o600); + try { writeFileSync(fd, `${JSON.stringify(state, null, 2)}\n`); fsyncSync(fd); } + catch (error) { closeSync(fd); rmSync(temporary, { force: true }); throw error; } + closeSync(fd); + try { + for (let attempt = 0; ; attempt++) { + try { renameSync(temporary, file); break; } + catch (error) { + const transient = ['EPERM', 'EBUSY', 'EACCES'].includes((error as NodeJS.ErrnoException).code ?? ''); + if (process.platform !== 'win32' || !transient || attempt >= 6) throw error; + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 25 * 2 ** attempt); + } + } + } finally { rmSync(temporary, { force: true }); } +} + +export function readState(file: string): TeamState { + return validateState(JSON.parse(readFileSync(statePath(file), 'utf8'))); +} + +export function initState(file: string, team: Team): TeamState { + const path = statePath(file, true); + const release = lock(path); + try { + if (existsSync(path)) throw new Error(`State already exists: ${path}`); + const state = validateState({ schemaVersion: 1, revision: 0, team: structuredClone(team), tasks: [], handoffs: [], outbox: [], events: [] }); + atomicWrite(path, state); + return state; + } finally { release(); } +} + +function prepare(file: string, expectedRevision: number): { before: TeamState; state: TeamState } { + integer(expectedRevision, 'expectedRevision'); + const before = readState(file); + if (before.revision !== expectedRevision) throw new Error(`State revision conflict: expected ${expectedRevision}, current ${before.revision}`); + if (before.revision === Number.MAX_SAFE_INTEGER) throw new Error('State revision exhausted'); + return { before, state: structuredClone(before) }; +} + +function commit(file: string, before: TeamState, state: TeamState): TeamState { + validateMutation(before, state); + if (JSON.stringify(before) === JSON.stringify(state)) return state; + state.revision = before.revision + 1; + atomicWrite(file, state); + return state; +} + +export function mutateState(file: string, expectedRevision: number, callback: (state: TeamState) => void): TeamState { + const path = statePath(file); + const release = lock(path); + try { + const { before, state } = prepare(path, expectedRevision); + const result: unknown = callback(state); + if (result && typeof (result as { then?: unknown }).then === 'function') throw new Error('Async callback requires mutateStateAsync'); + return commit(path, before, state); + } finally { release(); } +} + +export async function mutateStateAsync(file: string, expectedRevision: number, callback: (state: TeamState) => Promise): Promise { + const path = statePath(file); + const release = lock(path); + try { + const { before, state } = prepare(path, expectedRevision); + await callback(state); + return commit(path, before, state); + } finally { release(); } +} diff --git a/resources/skills/agent-team-creator/runtime/src/types.mts b/resources/skills/agent-team-creator/runtime/src/types.mts new file mode 100644 index 00000000000..cf59eaf10f8 --- /dev/null +++ b/resources/skills/agent-team-creator/runtime/src/types.mts @@ -0,0 +1,102 @@ +export type Json = null | boolean | number | string | Json[] | { [key: string]: Json }; +export type ObjectValue = { [key: string]: Json }; +export const harnesses = ['uar', 'codex', 'claude', 'copilot', 'kimi', 'minimax', 'opencode', 'deepseek'] as const; +export type Harness = typeof harnesses[number]; +export type Target = Harness | 'bossfang'; +export type Tier = 'low' | 'medium' | 'hard'; +export interface ModelPolicy { + model?: string; + tier?: Tier; + capabilities?: string[]; + maxInputPerMillion?: number; + maxOutputPerMillion?: number; +} +export interface Role { + id: string; + description: string; + prompt: string; + skills: string[]; + owns: string[]; + inputs: string[]; + outputs: string[]; + dependsOn: string[]; + modelPolicy?: ModelPolicy; + native?: Partial>; +} +export interface NativeConfig { + version: string; + source: string; + options?: ObjectValue; + files?: Record; +} +export interface Team { + schemaVersion: 1; + id: string; + outcome: string; + scope: 'project' | 'uar' | 'bossfang'; + harness: Harness; + roles: Role[]; + modelPolicy?: ModelPolicy; + skillPolicies?: Record; + native?: Partial>; +} +export interface KbdIdentity { + projectId: string; + runId: string; + phaseId: string; + changeId: string; + taskId: string; +} +export interface TeamTask { + id: string; + title: string; + owner: string; + harness: Harness; + status: 'pending' | 'running' | 'blocked' | 'complete' | 'cancelled'; + revision: number; + dependsOn: string[]; + evidence: string[]; + remaining: string[]; + modelPolicy?: ModelPolicy; + kbd?: KbdIdentity; +} +export interface Handoff { + schemaVersion: 1; + id: string; + taskId: string; + taskRevision: number; + from: { owner: string; harness: Harness }; + to: { owner: string; harness: Harness }; + context: string; + evidence: string[]; + remaining: string[]; + memoryRefs: string[]; + git: { root: string; head: string | null; branch: string | null; dirty: boolean | null }; + createdAt: string; + acceptedAt?: string; + prompt: string; +} +export interface MemoryEntry { + id: string; + content: string; + scope: string; + provenance: ObjectValue; + status: 'queued' | 'published'; + receipt?: Json; +} +export interface TeamState { + schemaVersion: 1; + revision: number; + team: Team; + tasks: TeamTask[]; + handoffs: Handoff[]; + outbox: MemoryEntry[]; + events: { id: string; at: string; kind: string; detail: ObjectValue }[]; +} +export interface ExportResult { + target: Target; + files: Record; + verification: { level: 'source-verified'; source: string; version: string; live: 'unverified' }; + diagnostics: string[]; + instructions: string[]; +} diff --git a/resources/skills/agent-team-creator/runtime/src/validation.mts b/resources/skills/agent-team-creator/runtime/src/validation.mts new file mode 100644 index 00000000000..b5c60758d74 --- /dev/null +++ b/resources/skills/agent-team-creator/runtime/src/validation.mts @@ -0,0 +1,81 @@ +import { harnesses, type Team, type Json, type ObjectValue, type Target } from './types.mjs'; + +export function object(value: unknown, label = 'input'): ObjectValue { + if (!value || typeof value !== 'object' || Array.isArray(value)) throw Error(`${label} must be an object`); + return value as ObjectValue; +} +export function text(value: unknown, label: string): string { + if (typeof value !== 'string' || !value.trim()) throw Error(`${label} must be a nonempty string`); + return value; +} +export function strings(value: unknown, label: string): string[] { + if (!Array.isArray(value) || value.some(v => typeof v !== 'string' || !v.trim())) throw Error(`${label} must be a string array`); + return value as string[]; +} +export function id(value: unknown, label = 'id'): string { + const result = text(value, label); + if (!/^[a-z][a-z0-9-]{0,62}$/.test(result) || /^(con|prn|aux|nul|com[0-9]|lpt[0-9])$/.test(result)) throw Error(`${label} must be a portable lowercase identifier`); + return result; +} +export function target(value: unknown): Target { + if (typeof value !== 'string' || ![...harnesses, 'bossfang'].includes(value as Target)) throw Error('Unknown harness target'); + return value as Target; +} +export function policy(value: unknown, label: string): void { + const p = object(value, label); + for (const key of Object.keys(p)) if (!['model', 'tier', 'capabilities', 'maxInputPerMillion', 'maxOutputPerMillion'].includes(key)) throw Error(`Unknown ${label} field: ${key}`); + if (p.model !== undefined) text(p.model, `${label}.model`); + if (p.tier !== undefined && !['low', 'medium', 'hard'].includes(String(p.tier))) throw Error(`Invalid ${label}.tier`); + if (p.capabilities !== undefined) strings(p.capabilities, `${label}.capabilities`); + for (const key of ['maxInputPerMillion', 'maxOutputPerMillion']) { + if (p[key] !== undefined && (typeof p[key] !== 'number' || !Number.isFinite(p[key]) || p[key] < 0)) throw Error(`Invalid ${label}.${key}`); + } +} +export function relativeFile(file: string): string { + if (!file || file.includes('\\') || file.startsWith('/') || /[<>:"|?*\u0000-\u001f]/.test(file) || file.split('/').some(p => !p || p === '.' || p === '..' || /[. ]$/.test(p) || /^(con|prn|aux|nul|com[0-9]|lpt[0-9])(\.|$)/i.test(p))) throw Error(`Unsafe portable file path: ${file}`); + return file; +} +export function validateTeam(value: unknown): Team { + const t = object(value, 'team'); + const allowed = ['schemaVersion','id','outcome','scope','harness','roles','modelPolicy','skillPolicies','native']; + for (const key of Object.keys(t)) if (!allowed.includes(key)) throw Error(`Unknown team field ${key}; use native..options or files for harness-specific configuration`); + if (t.schemaVersion !== 1) throw Error('team.schemaVersion must be 1'); + id(t.id, 'team.id'); text(t.outcome, 'team.outcome'); + if (!['project', 'uar', 'bossfang'].includes(String(t.scope))) throw Error('Invalid scope'); + if (!harnesses.includes(t.harness as typeof harnesses[number])) throw Error('Invalid team.harness'); + if (!Array.isArray(t.roles) || t.roles.length === 0) throw Error('At least one role is required'); + const ids = new Set(); + for (const entry of t.roles) { + const r = object(entry, 'role'), key = id(r.id, 'role.id'); + if (ids.has(key)) throw Error(`Duplicate role ${key}`); ids.add(key); + for (const field of Object.keys(r)) if (!['id','description','prompt','skills','owns','inputs','outputs','dependsOn','modelPolicy','native'].includes(field)) throw Error(`Unknown role field ${field}`); + text(r.description, 'description'); text(r.prompt, 'prompt'); + for (const field of ['skills','owns','inputs','outputs','dependsOn']) strings(r[field], field); + if (r.modelPolicy !== undefined) policy(r.modelPolicy, 'role.modelPolicy'); + if (r.native !== undefined) for (const [key, v] of Object.entries(object(r.native))) { target(key); object(v, `role.native.${key}`); } + } + const visiting = new Set(), visited = new Set(); + const team = t as unknown as Team; + function visit(key: string): void { + if (visiting.has(key)) throw Error('Role dependency cycle'); + if (visited.has(key)) return; + const r = team.roles.find(r => r.id === key); + if (!r) throw Error(`Unknown dependency ${key}`); + visiting.add(key); r.dependsOn.forEach(visit); visiting.delete(key); visited.add(key); + } + ids.forEach(visit); + if (t.modelPolicy !== undefined) policy(t.modelPolicy, 'modelPolicy'); + if (t.skillPolicies !== undefined) for (const [key, v] of Object.entries(object(t.skillPolicies))) policy(v, `skillPolicies.${key}`); + if (t.native !== undefined) for (const [key, v] of Object.entries(object(t.native))) { + target(key); const n = object(v, `native.${key}`); + for (const field of Object.keys(n)) if (!['version','source','options','files'].includes(field)) throw Error(`Unknown native wrapper field ${field}; put native settings in options or files`); + text(n.version, 'native version'); text(n.source, 'native source'); + if (n.options !== undefined) object(n.options, 'native options'); + if (n.files !== undefined) for (const [file, content] of Object.entries(object(n.files))) { + relativeFile(file); if (typeof content !== 'string') throw Error('Native file content must be a string'); + } + } + return structuredClone(team); +} + +export const asJson = (value: unknown): Json => JSON.parse(JSON.stringify(value)) as Json; diff --git a/resources/skills/agent-team-creator/runtime/test-src/export.integration.mts b/resources/skills/agent-team-creator/runtime/test-src/export.integration.mts new file mode 100644 index 00000000000..ef7cc3a66fe --- /dev/null +++ b/resources/skills/agent-team-creator/runtime/test-src/export.integration.mts @@ -0,0 +1,118 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import { createRequire } from 'node:module'; +import { fixture, team, skillRoot } from './fixture.mjs'; + +const require = createRequire(path.join(skillRoot, 'runtime', 'package.json')); +const parseToml: (text: string) => Record = require('smol-toml').parse; +const parseYaml: (text: string) => unknown = require('yaml').parse; + +test('packaged guide recommends minimal work and explains editable specialist teams', () => { + const f = fixture(); + try { + const questions = f.call('guide', {}); assert.ok(questions.missing.includes('outcome')); + const request = { id: 'checkout', outcome: 'Accessible checkout', complexity: 'simple', areas: ['code'], deliverables: ['Checkout'], budget: 'balanced', review: false, harness: 'codex', scope: 'project' }; + const pending = f.call('guide', request); assert.equal(pending.ready, false); assert.equal(pending.team, undefined); assert.deepEqual(pending.missing, ['ownership.implementer']); + const simple = f.call('guide', { ...request, ownership: { implementer: ['src/checkout/**'] } }); assert.equal(simple.team.roles.length, 1); assert.equal(simple.ready, true); assert.deepEqual(simple.team.roles[0].owns, ['src/checkout/**']); + f.call('validate', { team: simple.team }); + const complex = f.call('guide', { ...request, complexity: 'complex', areas: ['code','design','mobile','security'], review: true, ownership: { implementer:['src/checkout/**'], designer:['design/checkout/**'], 'mobile-specialist':['plans/mobile.md'], 'security-reviewer':['reviews/security.md'], reviewer:['reviews/quality.md'] } }); + assert.ok(complex.team.roles.some((r: {id:string}) => r.id === 'designer')); + assert.ok(complex.team.roles.some((r: {id:string}) => r.id === 'mobile-specialist')); + assert.ok(complex.alternatives.length); assert.ok(complex.skillDiscovery); + assert.ok(complex.team.roles.every((r:{owns:string[]})=>r.owns.length>0)); + f.call('guide', {...request,ownership:null},1); f.call('guide',{...request,ownership:{ghost:['unknown']}},1); f.call('guide',{...request,ownership:{implementer:['../escape']}},1); + assert.equal(f.call('guide',{...request,ownership:{implementer:[]}}).team,undefined); + f.call('init', { state: f.state, team: complex.team }); + assert.equal(f.call('status', { state: f.state }).revision, 0); + assert.equal(fs.existsSync(path.join(f.root, 'copied skill', 'runtime')), false); + } finally { f.close(); } +}); + +for (const target of ['uar','bossfang','codex','claude','copilot','kimi','minimax','opencode','deepseek']) { + test(`packaged export ${target}: parse actual serialized proposals and preserve native files`, () => { + const f = fixture(); + try { + const unusual = 'Quoted "value" with newline\nbackslash \\ and ${base_prompt}'; + const manifest = { ...team(), modelPolicy: { model: 'provider/model' }, + roles: team().roles.map(r => ({ ...r, prompt: unusual })), + native: { [target]: { version: 'source-contract-fixture', source: 'operator-configured', options: { 'opaque-setting': { values: [unusual, 3, true] } }, files: { 'native/custom.txt': unusual } } } }; + const out = path.join(f.root, `proposal-${target}`); + const result = f.call('export', { team: manifest, target, out }); + assert.equal(result.verification.live, 'unverified'); + assert.equal(fs.readFileSync(path.join(out, 'native/custom.txt'), 'utf8'), unusual); + const receipt = JSON.parse(fs.readFileSync(path.join(out, 'team-export.json'), 'utf8')); + assert.ok(receipt.files.length > 1); assert.ok(receipt.diagnostics.length); + for (const { file } of receipt.files) { + const content = fs.readFileSync(path.join(out, file), 'utf8'); + if (file.endsWith('.toml')) assert.ok(parseToml(content)); + if (/\.ya?ml$/.test(file)) assert.ok(parseYaml(content)); + if (file.endsWith('.json')) assert.ok(JSON.parse(content)); + if (file.endsWith('.md') && content.startsWith('---\n')) { + const front = content.match(/^---\n([\s\S]*?)\n---/);assert.ok(front); + assert.equal(typeof parseYaml(front[1]), 'object'); + } + } + if (target === 'codex') { + const agent = parseToml(fs.readFileSync(path.join(out,'.codex/agents/implementer.toml'),'utf8')); + assert.ok(String(agent.developer_instructions).startsWith(unusual)); + assert.equal(agent.model, 'provider/model'); + } + if (target === 'uar') { + const artifact = JSON.parse(fs.readFileSync(path.join(out,'uar/agents/implementer.json'),'utf8')); + for (const key of ['metadata','runtime','policy','schemas','prompt','memory','tools','ui']) assert.equal(typeof artifact[key], 'object'); + assert.equal(artifact.runtime.entry, 'default'); + } + if (target === 'bossfang') { + const request = JSON.parse(fs.readFileSync(path.join(out,'bossfang/registration/implementer.json'),'utf8')); + const manifest = parseToml(request.manifest_toml);assert.equal(manifest.skills_disabled, true); + assert.ok(String((manifest.model as Record).system_prompt).startsWith(unusual)); + } + if (target === 'kimi' || target === 'deepseek') assert.ok(result.diagnostics.some((d:string) => /model/.test(d))); + if (target === 'minimax') assert.ok(result.diagnostics.some((d:string) => /selector/.test(d))); + } finally { f.close(); } + }); +} + +test('export refuses collisions, unsafe paths and overwriting existing proposals', () => { + const f = fixture(); + try { + const out = path.join(f.root, 'proposal'); + f.call('export', { team: team(), target: 'codex', out }); + const before = fs.readFileSync(path.join(out, 'team-export.json')); + f.call('export', { team: team(), target: 'codex', out }, 1); + assert.deepEqual(fs.readFileSync(path.join(out, 'team-export.json')), before); + for (const files of [{'.codex/agents/implementer.toml':'collision'},{'A.txt':'one','a.txt':'two'},{'../escape':'bad'},{'CON.txt':'bad'},{'wild*card':'bad'},{'a':'one','a/b':'two'}]) { + const bad = { ...team(), native: { codex: { source:'operator', version:'recorded', files } } }; + const refused = path.join(f.root,'refused'); + f.call('export', { team: bad, target:'codex', out:refused }, 1); + assert.equal(fs.existsSync(refused), false); + } + const renamed = { ...team(), roles: team().roles.map(r=>({...r,native:{codex:{name:'same-name'}}})) }; + f.call('export',{team:renamed,target:'codex',out:path.join(f.root,'duplicate-name')},1); + } finally { f.close(); } +}); + +test('manifest rejects misspelled common fields and dependency cycles; native options remain explicit', () => { + const f = fixture(); + try { + for (const key of ['modelPolicy','skillPolicies','native']) f.call('validate',{team:{...team(),[key]:null}},1); + for (const key of ['modelPolicy','native']) f.call('validate',{team:{...team(),roles:team().roles.map(r=>({...r,[key]:null}))}},1); + f.call('validate',{team:{...team(),model:'misspelled-common-model'}},1); + f.call('validate',{team:{...team(),roles:team().roles.map(r=>({...r,dependsOn:[r.id]}))}},1); + f.call('validate',{team:{...team(),native:{codex:{source:'operator',version:'recorded',unknown:true}}}},1); + f.call('validate',{team:{...team(),native:{codex:{source:'operator',version:'recorded',options:{futureOption:true}}}}}); + } finally { f.close(); } +}); + +test('all four shipped skill frontmatters use the AgentSkills standard metadata shape', () => { + for (const name of ['agent-team-creator','agent-team-manage','agent-team-models','agent-team-handoff']) { + const file=path.join(skillRoot,'..',name,'SKILL.md'); + const raw=fs.readFileSync(file,'utf8').match(/^---\n([\s\S]*?)\n---/);assert.ok(raw); + const meta=parseYaml(raw[1]) as Record; + assert.equal(meta.name,name); assert.equal(typeof meta.description,'string'); + for(const key of Object.keys(meta))assert.ok(['name','description','license','compatibility','metadata','allowed-tools'].includes(key),key); + for(const value of Object.values(meta.metadata as Record))assert.equal(typeof value,'string'); + } +}); diff --git a/resources/skills/agent-team-creator/runtime/test-src/fixture.mts b/resources/skills/agent-team-creator/runtime/test-src/fixture.mts new file mode 100644 index 00000000000..87d0caaab2c --- /dev/null +++ b/resources/skills/agent-team-creator/runtime/test-src/fixture.mts @@ -0,0 +1,38 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { spawn, spawnSync } from 'node:child_process'; +import assert from 'node:assert/strict'; +import { randomUUID } from 'node:crypto'; + +export const skillRoot = fileURLToPath(new URL('../', import.meta.url)); +export function team() { + return { schemaVersion: 1, id: 'example', outcome: 'Deliver a verified feature', scope: 'project', harness: 'codex', + roles: ['implementer','reviewer'].map(id => ({ id, description: `${id} responsibility`, prompt: 'Read instructions. Report evidence.', skills: [], owns: id === 'implementer' ? ['src/feature/'] : [], inputs: ['Requirements'], outputs: ['Evidence'], dependsOn: [] })) }; +} +export function fixture() { + const scratch = path.resolve('.scratch'); fs.mkdirSync(scratch, { recursive: true }); + const root = fs.mkdtempSync(path.join(scratch, 'team-cli-')); + const scripts = path.join(root, 'copied skill', 'scripts'); + fs.cpSync(path.join(skillRoot, 'scripts'), scripts, { recursive: true }); + const cli = path.join(scripts, 'cli.mjs'); + const state = path.join(root, 'state.json'); + function request(input: unknown): string { + const file = path.join(root, `request-${randomUUID()}.json`);fs.writeFileSync(file, JSON.stringify(input));return file; + } + function call(command: string, input: unknown, expected = 0, env = process.env) { + const result = spawnSync(process.execPath, [cli, command, '--input', request(input)], { cwd: root, env, encoding: 'utf8', shell: false, maxBuffer: 20 * 1024 * 1024 }); + assert.equal(result.status, expected, `${command}: ${result.stderr}\n${result.stdout}`); + return JSON.parse(expected === 0 ? result.stdout : result.stderr); + } + function concurrent(command: string, input: unknown): Promise<{ status: number | null; stdout: string; stderr: string }> { + return new Promise((resolve, reject) => { + const child = spawn(process.execPath, [cli, command, '--input', request(input)], { cwd: root, shell: false, stdio: ['ignore','pipe','pipe'] }); + let stdout = '', stderr = '';child.stdout.on('data', b => stdout += b);child.stderr.on('data', b => stderr += b); + child.on('error', reject);child.on('exit', status => resolve({ status, stdout, stderr })); + }); + } + return { root, cli, state, call, concurrent, + read: () => JSON.parse(fs.readFileSync(state, 'utf8')), + close: () => fs.rmSync(root, { recursive: true, force: true }) }; +} diff --git a/resources/skills/agent-team-creator/runtime/test-src/kbd.integration.mts b/resources/skills/agent-team-creator/runtime/test-src/kbd.integration.mts new file mode 100644 index 00000000000..a4327dd9f3f --- /dev/null +++ b/resources/skills/agent-team-creator/runtime/test-src/kbd.integration.mts @@ -0,0 +1,155 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import { createHash, generateKeyPairSync } from 'node:crypto'; +import { spawnSync } from 'node:child_process'; +import { fixture, team } from './fixture.mjs'; + +const binary = process.env.PROMETHEUS_CLI_TEST_BINARY; +const skip = !binary ? 'PROMETHEUS_CLI_TEST_BINARY is required: canonical KBD integration is unverified without the real CLI' : false; +type Fixture = ReturnType; +interface Identity { projectId: string; runId: string; phaseId: string; changeId: string; taskId: string } + +function canonical(f: Fixture) { + assert.ok(binary && path.isAbsolute(binary), 'PROMETHEUS_CLI_TEST_BINARY must name the actual absolute CLI path'); + const root = path.join(f.root, 'canonical-project'); + const data = path.join(f.root, 'canonical-data'); + // Discovery walks ancestors. Establish the boundary before the first CLI call. + fs.mkdirSync(path.join(root, '.kbd-orchestrator'), { recursive: true }); + fs.mkdirSync(data, { recursive: true }); + const { privateKey, publicKey } = generateKeyPairSync('ed25519'); + const privateJwk = privateKey.export({ format: 'jwk' }); + const publicJwk = publicKey.export({ format: 'jwk' }); + assert.ok(privateJwk.d && publicJwk.x); + const keyFile = path.join(f.root, 'canonical-device-key.json'); + fs.writeFileSync(keyFile, JSON.stringify({ schemaVersion: '1', + keyId: `ed25519:${createHash('sha256').update(Buffer.from(publicJwk.x, 'base64url')).digest('hex')}`, + privateKey: Buffer.from(privateJwk.d, 'base64url').toString('base64'), + }), { mode: 0o600 }); + const env = { ...process.env, PROMETHEUS_DATA_DIR: data, PROMETHEUS_DEVICE_KEY_FILE: keyFile, + PROMETHEUS_KBD_CONTROL_PLANE: '0', PROMETHEUS_CONTROL_ENDPOINT: 'http://127.0.0.1:1', + PROMETHEUS_HARNESS: 'agent-team-integration', OPENSPEC_TELEMETRY: '0', DO_NOT_TRACK: '1' }; + const cli = (...args: string[]) => { + const result = spawnSync(binary, ['kbd', '--path', root, ...args], { + cwd: root, env, encoding: 'utf8', shell: false, timeout: 60_000, maxBuffer: 16 * 1024 * 1024, + }); + assert.equal(result.error, undefined, result.error?.message); + assert.equal(result.status, 0, `${args.join(' ')}\n${result.stderr}\n${result.stdout}`); + return JSON.parse(result.stdout); + }; + const initial = cli('status', '--json'); + assert.equal(initial.runtimeInitialized, false); + assert.ok(initial.runtimePath.startsWith(data + path.sep), 'canonical storage must remain inside the fixture'); + const registry = cli('projects', '--json'); + const registered = Object.keys(registry.replicas); + assert.equal(registered.length, 1, 'isolated registry must contain only the test checkout'); + assert.equal(fs.realpathSync(registered[0]), fs.realpathSync(root), 'discovery must stop at the fixture marker'); + const manifest = JSON.parse(fs.readFileSync(path.join(root, '.prometheus', 'project.json'), 'utf8')); + assert.equal(registry.replicas[registered[0]].projectId, manifest.projectId); + cli('phase', 'create', '--command-id', 'team-phase-create', '--id', 'phase-id', '--slug', 'phase-slug', '--title', 'Team fixture phase'); + cli('phase', 'activate', '--command-id', 'team-phase-activate', '--id', 'phase-id'); + cli('change', 'register', '--command-id', 'team-change-register', '--phase', 'phase-id', '--id', 'change-id', '--title', 'Team fixture change'); + cli('task', 'register', '--command-id', 'team-task-register', '--phase', 'phase-id', '--change', 'change-id', '--id', 'task-id', '--title', 'Team fixture task'); + cli('task', 'transition', '--command-id', 'team-task-start', '--phase', 'phase-id', '--change', 'change-id', '--id', 'task-id', '--status', 'in-progress'); + const status = () => cli('status', '--json'); + const state = status(); + assert.equal(state.projectId, manifest.projectId); + assert.equal(state.phases['phase-id'].changes['change-id'].tasks['task-id'].status, 'in_progress'); + const identity: Identity = { projectId: state.projectId, runId: state.runId, phaseId: 'phase-id', changeId: 'change-id', taskId: 'task-id' }; + return { root, data, env, cli, status, identity }; +} + +function linkedTask(f: Fixture, identity: Identity, id = 'linked') { + f.call('task', { state: f.state, expectedRevision: f.read().revision, + task: { action: 'add', id, title: 'Linked canonical work', owner: 'implementer', kbd: identity } }); + f.call('task', { state: f.state, expectedRevision: f.read().revision, + task: { action: 'start', id, owner: 'implementer', expectedTaskRevision: 0 } }); +} + +function completionRequest(f: Fixture, root: string, id = 'linked') { + const state = f.read(), task = state.tasks.find((item: { id: string }) => item.id === id); + return { state: f.state, expectedRevision: state.revision, cwd: root, + task: { id, owner: task.owner, expectedTaskRevision: task.revision, + kbdCli: binary!, evidence: ['evidence/actual-result.md'], remaining: [] } }; +} + +test('packaged linked completion commits through the real canonical CLI and records its returned identity', { skip, timeout: 180_000 }, t => { + const f = fixture(); t.after(f.close); + const kbd = canonical(f); + f.call('init', { state: f.state, team: team() }); + linkedTask(f, kbd.identity); + fs.mkdirSync(path.join(kbd.root, 'evidence'), { recursive: true }); + fs.writeFileSync(path.join(kbd.root, 'evidence', 'actual-result.md'), 'Integration evidence: the real canonical CLI confirms this task transition.\n'); + const before = kbd.status(); + const localBefore = fs.readFileSync(f.state); + const request = completionRequest(f, kbd.root); + assert.match(f.call('task', { state: f.state, expectedRevision: request.expectedRevision, + task: { ...request.task, action: 'complete' } }, 1, kbd.env).error, /canonical CLI receipt/); + assert.deepEqual(fs.readFileSync(f.state), localBefore); + assert.equal(kbd.status().revision, before.revision, 'ordinary local completion cannot change canonical work'); + const local = f.call('complete-kbd', request, 0, kbd.env); + const after = kbd.status(); + const nativeTask = after.phases['phase-id'].changes['change-id'].tasks['task-id']; + assert.equal(nativeTask.status, 'complete'); + assert.match(nativeTask.summary, /evidence\/actual-result\.md/); + assert.ok(after.revision > before.revision); + assert.equal(local.tasks[0].status, 'complete'); + assert.equal(local.tasks[0].revision, 2); + const receipt = local.events.at(-1); + assert.equal(receipt.kind, 'kbd.task.completed'); + assert.equal(receipt.detail.mode, 'transition-committed'); + assert.deepEqual(receipt.detail.kbd, kbd.identity); + assert.equal(receipt.detail.canonicalRevision, after.revision); + assert.equal(receipt.detail.canonicalEventId, after.lastEventId); + assert.equal(receipt.detail.canonicalTaskStatus, 'complete'); + assert.equal(after.commandRevisions[receipt.detail.commandId], after.revision); + assert.match(receipt.detail.receiptSha256, /^[a-f0-9]{64}$/); + assert.deepEqual(receipt.detail.argv.slice(0, 5), ['kbd', '--path', kbd.root, 'task', 'transition']); + assert.equal(local.events.some((event: { kind: string }) => /boundary|karpathy/.test(event.kind)), false); + const committed = fs.readFileSync(f.state); + const retry = completionRequest(f, kbd.root); + assert.match(f.call('complete-kbd', retry, 1, kbd.env).error, /terminal/); + assert.deepEqual(fs.readFileSync(f.state), committed); + assert.equal(kbd.status().revision, after.revision); +}); + +test('each wrong canonical identity is rejected without canonical or local completion', { skip, timeout: 180_000 }, t => { + const f = fixture(); t.after(f.close); + const kbd = canonical(f); + f.call('init', { state: f.state, team: team() }); + for (const field of ['projectId', 'runId', 'phaseId', 'changeId', 'taskId'] as const) { + const id = `wrong-${field.toLowerCase()}`; + linkedTask(f, { ...kbd.identity, [field]: `wrong-${field}` }, id); + const before = kbd.status(), local = fs.readFileSync(f.state); + const failure = f.call('complete-kbd', completionRequest(f, kbd.root, id), 1, kbd.env); + assert.match(failure.error, /canonical|identity/i); + assert.deepEqual(fs.readFileSync(f.state), local); + const after = kbd.status(); + assert.equal(after.revision, before.revision); + assert.equal(after.phases['phase-id'].changes['change-id'].tasks['task-id'].status, 'in_progress'); + assert.equal(fs.existsSync(`${f.state}.lock`), false); + } + assert.equal(f.read().events.some((event: { kind: string }) => event.kind === 'kbd.task.completed'), false); +}); + +test('a real prior canonical completion reconciles local state without submitting another transition', { skip, timeout: 180_000 }, t => { + const f = fixture(); t.after(f.close); + const kbd = canonical(f); + f.call('init', { state: f.state, team: team() }); + linkedTask(f, kbd.identity); + kbd.cli('task', 'transition', '--command-id', 'complete-before-local-receipt', '--phase', 'phase-id', + '--change', 'change-id', '--id', 'task-id', '--status', 'complete', '--summary', 'Canonical completion occurred before local receipt'); + const before = kbd.status(); + const local = f.call('complete-kbd', completionRequest(f, kbd.root), 0, kbd.env); + const after = kbd.status(); + assert.equal(after.revision, before.revision); + assert.equal(after.lastEventId, before.lastEventId); + assert.equal(local.tasks[0].status, 'complete'); + const receipt = local.events.at(-1).detail; + assert.equal(receipt.mode, 'reconciled-existing-completion'); + assert.deepEqual(receipt.argv, ['kbd', '--path', kbd.root, 'status', '--json']); + assert.deepEqual(receipt.kbd, kbd.identity); + assert.equal(receipt.canonicalRevision, before.revision); + assert.equal(after.commandRevisions[receipt.commandId], undefined, 'reconciliation must not invent a canonical command'); +}); diff --git a/resources/skills/agent-team-creator/runtime/test-src/models-memory.integration.mts b/resources/skills/agent-team-creator/runtime/test-src/models-memory.integration.mts new file mode 100644 index 00000000000..d54a2542dcd --- /dev/null +++ b/resources/skills/agent-team-creator/runtime/test-src/models-memory.integration.mts @@ -0,0 +1,244 @@ +// Packaged CLI processes exercise real JSON input, policy adaptation and durable files. +// Catalogs below are explicit operator fixtures, not fabricated discovery responses. +// No fake servers or live memory writes. Successful remote publication is unverified. +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import net from 'node:net'; +import { randomUUID } from 'node:crypto'; +import { fixture, team } from './fixture.mjs'; + +const capabilities = { function_calling: true, vision: true, reasoning: true, structured_output: true }; +function bundle(ids = ['alpha', 'beta']) { + return { + catalog: { + $schema_version: 1, + $provenance: { source: 'operator integration fixture', source_sha256: 'fixture-not-a-source-attestation', fetched: '2020-01-01', library_version: 'fixture' }, + providers: { fixture: { models: { + priced: { id: 'priced', capabilities, pricing: { input_cost_per_token: 0.000001, output_cost_per_token: 0.000002 } }, + } } }, + }, + availableModels: ids, + aliases: Object.fromEntries(ids.map(id => [id, { provider: 'fixture', model: 'priced' }])), + tiers: Object.fromEntries(ids.map(id => [id, 'medium'])), + maxCatalogAgeDays: 30, + }; +} + +const entry = () => ({ content: 'Keep review evidence separate from implementation claims.', scope: 'team:example', provenance: { evidence: ['review-note'], task: 'documentation' } }); +const stateBytes = (f: ReturnType) => fs.readFileSync(f.state); +function initialize(f: ReturnType) { + f.call('init', { state: f.state, team: team() }); + return f.call('memory-queue', { state: f.state, expectedRevision: 0, entry: entry() }); +} + +async function closedPort(): Promise { + // Reserve an ephemeral address, then close it before the CLI request. This + // server never handles a request and never supplies a fabricated API response. + const server = net.createServer(); + await new Promise((resolve, reject) => { server.once('error', reject); server.listen(0, '127.0.0.1', resolve); }); + const address = server.address(); + assert.ok(address && typeof address !== 'string'); + await new Promise((resolve, reject) => server.close(error => error ? reject(error) : resolve())); + return address.port; +} + +test('packaged model selection resolves every layer and ANDs capabilities', t => { + const f = fixture(); t.after(f.close); + const base = team(); + const configured = { + ...base, + modelPolicy: { model: 'team-choice', tier: 'low', maxInputPerMillion: 8, capabilities: ['function_calling'] }, + roles: base.roles.map(role => ({ ...role, modelPolicy: { model: 'role-choice', tier: 'medium', maxInputPerMillion: 6, capabilities: ['vision'] } })), + skillPolicies: { + first: { model: 'skill-first', tier: 'hard', maxInputPerMillion: 5, capabilities: ['reasoning'] }, + second: { model: 'skill-second', tier: 'low', maxInputPerMillion: 4, capabilities: ['structured_output'] }, + }, + }; + const result = f.call('models-select', { team: configured, roleId: 'implementer', skills: ['first', 'second'], + taskPolicy: { model: 'alpha', tier: 'medium', maxInputPerMillion: 3, maxOutputPerMillion: 6, capabilities: [] }, catalog: bundle() }); + assert.equal(result.selected.id, 'alpha'); + assert.deepEqual(result.appliedLayers, ['team', 'role', 'skill:first', 'skill:second', 'task']); + assert.deepEqual(result.policy, { model: 'alpha', tier: 'medium', maxInputPerMillion: 3, maxOutputPerMillion: 6, + capabilities: ['function_calling', 'vision', 'reasoning', 'structured_output'] }); + const ordered = bundle(['skill-first', 'skill-second']); + ordered.tiers = { 'skill-first': 'hard', 'skill-second': 'low' }; + for (const skills of [['first', 'second'], ['second', 'first']]) { + const selected = f.call('models-select', { team: configured, roleId: 'implementer', skills, catalog: ordered }); + assert.equal(selected.selected.id, skills[1] === 'first' ? 'skill-first' : 'skill-second'); + } +}); + +test('packaged catalog adaptation converts per-token cost and makes deterministic price ties explicit', t => { + const f = fixture(); t.after(f.close); + const request = { team: team(), roleId: 'implementer', taskPolicy: { tier: 'medium', maxInputPerMillion: 1, maxOutputPerMillion: 2 } }; + const selected = f.call('models-select', { ...request, catalog: bundle(['beta', 'alpha']) }); + const repeated = f.call('models-select', { ...request, catalog: bundle(['alpha', 'beta']) }); + assert.equal(selected.selected.id, 'alpha'); + assert.equal(repeated.selected.id, selected.selected.id); + assert.deepEqual(selected.selected.pricing, { inputPerMillion: 1, outputPerMillion: 2, currency: 'USD', basis: 'maximum-known-context-tier', sourceUnit: 'per-token' }); + assert.equal(selected.selected.catalogId, 'priced'); + assert.equal(selected.selected.provenance.availabilityBasis, 'operator-declared'); + assert.equal(selected.selected.provenance.discovery, null); + assert.equal(selected.selected.freshness.stale, true); + assert.equal(selected.catalogProvenance.source, 'operator integration fixture'); + assert.ok(selected.warnings.some((warning: string) => /stale.*current provider rates/.test(warning))); + assert.ok(selected.warnings.some((warning: string) => /operator-declared/.test(warning))); +}); + +test('ceilings reject context-tier costs, unknown prices and unknown capabilities', t => { + const f = fixture(); t.after(f.close); + const catalog = { + catalog: { $schema_version: 1, providers: { fixture: { models: { + costly: { capabilities, pricing: { input_cost_per_token: 0.000001, output_cost_per_token: 0.000002, + tiers: [{ min_context_tokens: 200000, input_cost_per_token: 0.000004, output_cost_per_token: 0.000008 }] } }, + unpriced: { capabilities }, + unknown: { pricing: { input_cost_per_token: 0, output_cost_per_token: 0 } }, + } } } }, + availableModels: ['costly', 'unpriced', 'unknown'], + aliases: Object.fromEntries(['costly', 'unpriced', 'unknown'].map(id => [id, { provider: 'fixture', model: id }])), + tiers: { costly: 'hard', unpriced: 'hard', unknown: 'hard' }, + }; + const result = f.call('models-select', { team: team(), roleId: 'implementer', taskPolicy: { tier: 'hard', capabilities: ['vision'], maxInputPerMillion: 2 }, catalog }); + assert.equal(result.selected, null); + const reasons = (id: string): string[] => result.rejected.find((row: { id: string }) => row.id === id).reasons; + assert.ok(reasons('costly').includes('price exceeds ceiling')); + assert.ok(reasons('unpriced').includes('price unknown; cannot satisfy ceiling')); + assert.ok(reasons('unknown').includes('capability vision unsupported or unknown')); + const allowedUnknown = f.call('models-select', { team: team(), roleId: 'implementer', taskPolicy: { model: 'unpriced', tier: 'hard', capabilities: ['vision'] }, catalog }); + assert.equal(allowedUnknown.selected.pricing.inputPerMillion, null); + assert.ok(allowedUnknown.warnings.some((warning: string) => /freshness is unknown/.test(warning))); +}); + +test('names never infer tiers or aliases, and a static catalog never proves availability', t => { + const f = fixture(); t.after(f.close); + const catalog = bundle(['ultra-hard-model']); + catalog.tiers = {}; + const noTier = f.call('models-select', { team: team(), roleId: 'implementer', taskPolicy: { tier: 'hard' }, catalog }); + assert.equal(noTier.selected, null); + assert.ok(noTier.rejected[0].reasons.includes('declared tier missing or different')); + catalog.tiers = { 'ultra-hard-model': 'low' }; + assert.equal(f.call('models-select', { team: team(), roleId: 'implementer', taskPolicy: { tier: 'low' }, catalog }).selected.id, 'ultra-hard-model'); + const noAlias = { ...bundle(['priced']), aliases: {} }; + const result = f.call('models-select', { team: team(), roleId: 'implementer', taskPolicy: { maxInputPerMillion: 10 }, catalog: noAlias }); + assert.equal(result.selected, null, 'identical catalog model name does not imply an alias mapping'); + const noAvailability = f.call('models-select', { team: team(), roleId: 'implementer', catalog: { catalog: bundle().catalog } }); + assert.equal(noAvailability.selected, null); +}); + +test('packaged CLI rejects invalid catalog mappings and task policies explicitly', t => { + const f = fixture(); t.after(f.close); + const request = { team: team(), roleId: 'implementer' }; + const invalid = bundle(); invalid.aliases.alpha = { provider: 'fixture', model: 'absent' }; + assert.match(f.call('models-select', { ...request, catalog: invalid }, 1).error, /mapping.*catalog model/); + const version = bundle(); version.catalog.$schema_version = 2; + assert.match(f.call('models-select', { ...request, catalog: version }, 1).error, /schema_version 1/); + assert.match(f.call('models-select', { ...request, taskPolicy: { guessedTier: 'hard' }, catalog: bundle() }, 1).error, /Unknown.*modelPolicy/); + assert.match(f.call('models-select', { ...request, taskPolicy: { maxInputPerMillion: -1 }, catalog: bundle() }, 1).error, /Invalid.*maxInputPerMillion/); +}); + +test('discovery refuses literal credentials, unsafe URLs and invalid environment references before any network request', t => { + const f = fixture(); t.after(f.close); + const base = { kind: 'openai', baseUrl: 'http://127.0.0.1:1' }; + const errors = [ + f.call('models-discover', { ...base, auth: { apiKey: 'synthetic-placeholder' } }, 1), + f.call('models-discover', { ...base, auth: { env: 'INVALID-NAME' } }, 1), + f.call('models-discover', { ...base, auth: { env: 'TEAM_INTEGRATION_ABSENT_CREDENTIAL' } }, 1, { ...process.env, TEAM_INTEGRATION_ABSENT_CREDENTIAL: '' }), + f.call('models-discover', { kind: 'openai', discoveryUrl: 'https://user:synthetic-placeholder@example.invalid/v1/models' }, 1), + f.call('models-discover', { kind: 'openai', discoveryUrl: 'https://example.invalid/v1/models?api_key=synthetic-placeholder' }, 1), + ]; + assert.match(errors[0].error, /credential fields/); + assert.match(errors[1].error, /invalid_auth_environment_reference/); + assert.match(errors[2].error, /credential_environment_unavailable/); + assert.match(errors[3].error, /without userinfo/); + assert.match(errors[4].error, /URL credentials/); + assert.equal(JSON.stringify(errors).includes('synthetic-placeholder'), false); +}); + +test('memory queue is durable across CLI restarts, idempotent, scoped, and refuses identity conflicts', t => { + const f = fixture(); t.after(f.close); + const queued = initialize(f); + assert.equal(queued.revision, 1); + const id = queued.outbox[0].id; + const before = stateBytes(f); + const restarted = f.call('status', { state: f.state }); + assert.equal(restarted.outbox[0].scope, 'team:example'); + assert.equal(restarted.outbox[0].provenance.teamId, 'example'); + assert.match(restarted.outbox[0].provenance.authority, /unverified mirrors/); + const repeated = f.call('memory-queue', { state: f.state, expectedRevision: 1, + entry: { ...entry(), provenance: { task: 'documentation', evidence: ['review-note'] } } }); + assert.equal(repeated.revision, 1); + assert.equal(repeated.outbox.length, 1); + assert.equal(repeated.outbox[0].id, id); + assert.deepEqual(stateBytes(f), before); + for (const changed of [{ ...entry(), id, scope: 'team:another' }, { ...entry(), id, content: 'Different content' }]) { + assert.match(f.call('memory-queue', { state: f.state, expectedRevision: 1, entry: changed }, 1).error, /conflicts/); + assert.deepEqual(stateBytes(f), before); + } + const independent = f.call('memory-queue', { state: f.state, expectedRevision: 1, entry: { ...entry(), scope: 'team:another' } }); + assert.equal(independent.outbox.length, 2); + assert.notEqual(independent.outbox[1].id, id); +}); + +test('no memory service preserves queued content and durable unavailable receipt', t => { + const f = fixture(); t.after(f.close); + const queued = initialize(f), id = queued.outbox[0].id; + const result = f.call('memory-publish', { state: f.state, expectedRevision: 1, publication: { id } }); + assert.equal(result.publication.status, 'queued'); + assert.equal(result.publication.receipt.uncertain, false); + assert.match(result.publication.receipt.reason, /no memory endpoint/); + const restarted = f.call('status', { state: f.state }); + assert.equal(restarted.outbox[0].content, entry().content); + assert.deepEqual(restarted.outbox[0].receipt, result.publication.receipt); + assert.equal(restarted.outbox[0].status, 'queued'); + assert.deepEqual(restarted.events, [], 'memory events must not fabricate canonical KBD boundaries'); +}); + +test('closed real loopback endpoint preserves durable uncertainty and requires explicit retry', async t => { + const f = fixture(); t.after(f.close); + const queued = initialize(f), id = queued.outbox[0].id; + const port = await closedPort(); + const publication = { id, provider: 'surreal-memory', url: `http://127.0.0.1:${port}/api/v1/memory/`, timeoutMs: 1000, + scopeMapping: { scope: 'team:example', agentId: 'fixture-agent', userId: 'anonymous' } }; + const failed = f.call('memory-publish', { state: f.state, expectedRevision: 1, publication }); + assert.equal(failed.publication.status, 'queued'); + assert.equal(failed.publication.receipt.uncertain, true, 'transport failures are conservatively uncertain'); + assert.equal(failed.publication.receipt.exactlyOnce, false); + assert.equal(failed.publication.receipt.reason, 'transport_unavailable_or_redirect_refused'); + assert.equal(failed.publication.receipt.target.contract.remoteIdempotency, 'unsupported-by-verified-contract'); + const restarted = f.call('status', { state: f.state }); + assert.deepEqual(restarted.outbox[0].receipt, failed.publication.receipt); + const before = stateBytes(f); + const deferred = f.call('memory-publish', { state: f.state, expectedRevision: restarted.revision, publication }); + assert.match(deferred.publication.reason, /reconcile/); + assert.deepEqual(stateBytes(f), before, 'deferred retry must retain exact receipt and revision'); + const conflict = f.call('memory-publish', { state: f.state, expectedRevision: restarted.revision, + publication: { ...publication, retryUncertain: true, scopeMapping: { ...publication.scopeMapping, agentId: 'different-agent' } } }, 1); + assert.match(conflict.error, /mapping differs/); + assert.deepEqual(stateBytes(f), before); + const retried = f.call('memory-publish', { state: f.state, expectedRevision: restarted.revision, publication: { ...publication, retryUncertain: true } }); + assert.equal(retried.publication.status, 'queued'); + assert.equal(retried.publication.receipt.publicationKey, failed.publication.receipt.publicationKey); + assert.equal(retried.state.outbox.length, 1); + assert.equal(retried.state.outbox[0].content, entry().content); +}); + +test('invalid memory scope mapping, unsupported KBD reference and credentials cannot change durable state', t => { + const f = fixture(); t.after(f.close); + const queued = initialize(f), id = queued.outbox[0].id; + const before = stateBytes(f); + assert.match(f.call('memory-publish', { state: f.state, expectedRevision: 1, publication: { + id, provider: 'surreal-memory', url: 'http://127.0.0.1:1/api/v1/memory/', scopeMapping: { scope: 'team:wrong', agentId: 'fixture-agent' }, + } }, 1).error, /match.*scope/); + assert.deepEqual(stateBytes(f), before); + assert.match(f.call('memory-queue', { state: f.state, expectedRevision: 1, entry: { ...entry(), provenance: { kbd: { + projectId: 'project', runId: 'run', phaseId: 'phase', changeId: 'change', taskId: 'task', + } } } }, 1).error, /linked team task/); + assert.deepEqual(stateBytes(f), before); + const secret = `synthetic-${randomUUID()}`; + const rejected = f.call('memory-queue', { state: f.state, expectedRevision: 1, entry: { ...entry(), content: secret } }, 1, + { ...process.env, TEAM_INTEGRATION_API_KEY: secret }); + assert.match(rejected.error, /credential values/); + assert.equal(JSON.stringify(rejected).includes(secret), false); + assert.deepEqual(stateBytes(f), before); +}); diff --git a/resources/skills/agent-team-creator/runtime/test-src/state.integration.mts b/resources/skills/agent-team-creator/runtime/test-src/state.integration.mts new file mode 100644 index 00000000000..2d22722e4c7 --- /dev/null +++ b/resources/skills/agent-team-creator/runtime/test-src/state.integration.mts @@ -0,0 +1,225 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import { spawnSync } from 'node:child_process'; +import { fixture, team } from './fixture.mjs'; + +type Fixture = ReturnType; +type Fields = Record; +const bytes = (f: Fixture) => fs.readFileSync(f.state); + +function add(f: Fixture, id: string, extra: Fields = {}) { + return f.call('task', { state: f.state, expectedRevision: f.read().revision, + task: { action: 'add', id, title: `Deliver ${id}`, owner: 'implementer', ...extra } }); +} + +function update(f: Fixture, id: string, action: string, extra: Fields = {}) { + const state = f.read(), task = state.tasks.find((item: { id: string }) => item.id === id); + assert.ok(task); + return f.call('task', { state: f.state, expectedRevision: state.revision, + task: { action, id, owner: task.owner, expectedTaskRevision: task.revision, ...extra } }); +} + +function refused(f: Fixture, command: string, input: Fields, reason: RegExp) { + const before = bytes(f); + const failure = f.call(command, input, 1); + assert.match(failure.error, reason); + assert.deepEqual(bytes(f), before, 'rejected CLI command must preserve exact state bytes'); + assert.equal(fs.existsSync(`${f.state}.lock`), false, 'failed command must release its own lock'); +} + +function handoff(f: Fixture, taskId: string, cwd: string) { + const state = f.read(), task = state.tasks.find((item: { id: string }) => item.id === taskId); + const next = f.call('handoff-create', { state: f.state, expectedRevision: state.revision, cwd, + handoff: { taskId, owner: task.owner, expectedTaskRevision: task.revision, + toOwner: 'reviewer', toHarness: 'claude', context: 'Review the actual patch and finish the task.', + evidence: ['evidence.md'], remaining: ['Review the patch'], memoryRefs: ['memory/decision.md'] } }); + return next.handoffs.at(-1); +} + +test('packaged CLI preserves owner/revision boundaries through dependencies, blocking, reassignment and completion', t => { + const f = fixture(); t.after(f.close); + assert.equal(f.call('init', { state: f.state, team: team() }).revision, 0); + add(f, 'implementation'); + add(f, 'verification', { owner: 'reviewer', dependsOn: ['implementation'] }); + const initial = f.read(); + refused(f, 'task', { state: f.state, expectedRevision: initial.revision, + task: { action: 'start', id: 'verification', owner: 'reviewer', expectedTaskRevision: 0 } }, /Dependency implementation is not complete/); + refused(f, 'task', { state: f.state, expectedRevision: initial.revision, + task: { action: 'start', id: 'implementation', owner: 'reviewer', expectedTaskRevision: 0 } }, /belongs to implementer/); + refused(f, 'task', { state: f.state, expectedRevision: initial.revision, + task: { action: 'start', id: 'implementation', owner: 'implementer' } }, /expectedTaskRevision/); + update(f, 'implementation', 'start'); + refused(f, 'task', { state: f.state, expectedRevision: initial.revision, + task: { action: 'block', id: 'implementation', owner: 'implementer', expectedTaskRevision: 1, reason: 'Stale writer' } }, /State revision conflict/); + refused(f, 'task', { state: f.state, expectedRevision: f.read().revision, + task: { action: 'block', id: 'implementation', owner: 'implementer', expectedTaskRevision: 0, reason: 'Stale task' } }, /Task revision conflict/); + let state = update(f, 'implementation', 'block', { reason: 'Need a decision', evidence: ['discussion.md'] }); + assert.equal(state.tasks[0].status, 'blocked'); + assert.deepEqual(state.tasks[0].remaining, ['Need a decision']); + update(f, 'implementation', 'start'); + refused(f, 'task', { state: f.state, expectedRevision: f.read().revision, + task: { action: 'complete', id: 'implementation', owner: 'implementer', expectedTaskRevision: 3, evidence: ['proof.md'] } }, /no remaining work/); + state = update(f, 'implementation', 'reassign', { toOwner: 'reviewer', toHarness: 'claude' }); + assert.equal(state.tasks[0].status, 'pending'); + assert.equal(state.tasks[0].owner, 'reviewer'); + assert.equal(state.tasks[0].harness, 'claude'); + assert.equal(state.tasks[0].revision, 4); + refused(f, 'task', { state: f.state, expectedRevision: state.revision, + task: { action: 'start', id: 'implementation', owner: 'implementer', expectedTaskRevision: 4 } }, /belongs to reviewer/); + update(f, 'implementation', 'start', { remaining: [] }); + state = update(f, 'implementation', 'complete', { evidence: ['proof.md'], remaining: [] }); + assert.equal(state.tasks[0].revision, 6); + assert.equal(state.tasks[0].status, 'complete'); + assert.deepEqual(state.tasks[0].evidence, ['discussion.md', 'proof.md']); + refused(f, 'task', { state: f.state, expectedRevision: state.revision, + task: { action: 'reassign', id: 'implementation', owner: 'reviewer', expectedTaskRevision: 6, toOwner: 'implementer' } }, /terminal/); + update(f, 'verification', 'start'); + refused(f, 'task', { state: f.state, expectedRevision: f.read().revision, + task: { action: 'complete', id: 'verification', owner: 'reviewer', expectedTaskRevision: 1, evidence: [], remaining: [] } }, /requires evidence/); + state = update(f, 'verification', 'complete', { evidence: ['review.md'], remaining: [] }); + assert.equal(state.tasks[1].status, 'complete'); + assert.equal(state.events.length, state.revision); + assert.deepEqual(f.call('status', { state: f.state }), state, 'a new CLI process reads committed state'); +}); + +test('cancelled dependencies stay unsatisfied and invalid additions never persist', t => { + const f = fixture(); t.after(f.close); + f.call('init', { state: f.state, team: team() }); + for (const extra of [{ dependsOn: ['missing'] }, { dependsOn: ['invalid'] }, { owner: 'absent' }, { harness: 'bossfang' }]) { + refused(f, 'task', { state: f.state, expectedRevision: 0, + task: { action: 'add', id: 'invalid', title: 'Invalid task', owner: 'implementer', ...extra } }, /dependency|role|harness/i); + } + add(f, 'parent'); + add(f, 'child', { dependsOn: ['parent'] }); + update(f, 'parent', 'cancel', { reason: 'Withdrawn scope' }); + const state = f.read(); + assert.equal(state.tasks[0].status, 'cancelled'); + assert.equal(state.events.at(-1).detail.reason, 'Withdrawn scope'); + refused(f, 'task', { state: f.state, expectedRevision: state.revision, + task: { action: 'start', id: 'parent', owner: 'implementer', expectedTaskRevision: 1 } }, /terminal/); + refused(f, 'task', { state: f.state, expectedRevision: state.revision, + task: { action: 'start', id: 'child', owner: 'implementer', expectedTaskRevision: 0 } }, /Dependency parent is not complete/); + // Malformed persisted input crosses the real CLI read boundary, not a module seam. + const original = bytes(f); + state.tasks[0].dependsOn = ['child']; + fs.writeFileSync(f.state, JSON.stringify(state)); + const invalid = bytes(f); + assert.match(f.call('status', { state: f.state }, 1).error, /dependency cycle/i); + assert.deepEqual(bytes(f), invalid); + fs.writeFileSync(f.state, original); + assert.equal(f.call('status', { state: f.state }).tasks[0].status, 'cancelled'); +}); + +test('simultaneous packaged writers have one winner and preserve the other task on revision-aware retry', async t => { + const f = fixture(); t.after(f.close); + f.call('init', { state: f.state, team: team() }); + const request = (id: string, revision: number) => ({ state: f.state, expectedRevision: revision, + task: { action: 'add', id, title: id, owner: 'implementer' } }); + const results = await Promise.all(['first', 'second'].map(id => f.concurrent('task', request(id, 0)))); + assert.deepEqual(results.map(result => result.status).sort(), [0, 1]); + const rejected = results.find(result => result.status === 1)!; + assert.match(JSON.parse(rejected.stderr).error, /lock held|revision conflict/i); + const winner = f.read(); + assert.equal(winner.revision, 1); + assert.equal(winner.tasks.length, 1); + assert.equal(winner.events.length, 1); + const missing = winner.tasks[0].id === 'first' ? 'second' : 'first'; + const final = f.call('task', request(missing, winner.revision)); + assert.equal(final.revision, 2); + assert.deepEqual(final.tasks.map((task: { id: string }) => task.id).sort(), ['first', 'second']); + assert.equal(fs.existsSync(`${f.state}.lock`), false); + assert.equal(fs.readdirSync(f.root).some(file => file.endsWith('.tmp')), false); +}); + +test('an existing real filesystem lock is never stolen and team replacement preserves history references', t => { + const f = fixture(); t.after(f.close); + f.call('init', { state: f.state, team: team() }); + const lock = `${f.state}.lock`, content = JSON.stringify({ pid: process.pid, at: '2000-01-01T00:00:00Z', token: 'held-by-integration' }); + fs.writeFileSync(lock, content, { flag: 'wx' }); + const before = bytes(f); + assert.match(f.call('task', { state: f.state, expectedRevision: 0, + task: { action: 'add', id: 'blocked', title: 'Blocked writer', owner: 'implementer' } }, 1).error, /lock held/i); + assert.equal(fs.readFileSync(lock, 'utf8'), content); + assert.deepEqual(bytes(f), before); + fs.rmSync(lock); + add(f, 'retained'); + handoff(f, 'retained', path.join(f.root, 'missing-directory')); + const replacement = team(); replacement.outcome = 'Revised explicit outcome'; + f.call('team-update', { state: f.state, expectedRevision: f.read().revision, team: replacement }); + const removed = { ...replacement, roles: replacement.roles.filter(role => role.id !== 'reviewer') }; + refused(f, 'team-update', { state: f.state, expectedRevision: f.read().revision, team: removed }, /Unknown team role: reviewer/); +}); + +test('handoff snapshots real clean/dirty Git and transfers only on targeted revision-safe acceptance', t => { + const f = fixture(); t.after(f.close); + const project = path.join(f.root, 'git-project'); fs.mkdirSync(project); + const emptyConfig = path.join(f.root, 'empty-git-config'); fs.writeFileSync(emptyConfig, ''); + const env = { ...process.env, GIT_CONFIG_GLOBAL: emptyConfig, GIT_CONFIG_NOSYSTEM: '1' }; + const git = (...args: string[]) => { + const result = spawnSync('git', ['-C', project, ...args], { env, encoding: 'utf8', shell: false, timeout: 15_000 }); + assert.equal(result.error, undefined); + assert.equal(result.status, 0, result.stderr); + return result.stdout.trim(); + }; + git('init'); git('symbolic-ref', 'HEAD', 'refs/heads/handoff-integration'); + git('config', 'user.name', 'Integration Fixture'); git('config', 'user.email', 'fixture@example.invalid'); + fs.writeFileSync(path.join(project, 'evidence.md'), 'Initial evidence\n'); + git('add', 'evidence.md'); git('commit', '-m', 'Initial fixture evidence'); + const head = git('rev-parse', 'HEAD'); + f.call('init', { state: f.state, team: team() }); + add(f, 'transfer', { evidence: ['prior-evidence.md'], remaining: ['Keep the original blocker'] }); + update(f, 'transfer', 'start'); + const clean = handoff(f, 'transfer', project); + assert.equal(clean.git.dirty, false); + fs.appendFileSync(path.join(project, 'evidence.md'), 'Uncommitted evidence\n'); + const packet = handoff(f, 'transfer', project); + assert.equal(packet.git.head, head); + assert.equal(packet.git.branch, 'handoff-integration'); + assert.equal(fs.realpathSync(packet.git.root), fs.realpathSync(project)); + assert.equal(packet.git.dirty, true); + assert.equal(packet.taskRevision, 1); + assert.deepEqual(packet.from, { owner: 'implementer', harness: 'codex' }); + assert.deepEqual(packet.to, { owner: 'reviewer', harness: 'claude' }); + assert.deepEqual(packet.evidence, ['prior-evidence.md', 'evidence.md']); + assert.deepEqual(packet.remaining, ['Keep the original blocker', 'Review the patch']); + assert.deepEqual(packet.memoryRefs, ['memory/decision.md']); + assert.match(packet.prompt, /permissions do not transfer/); + assert.equal(f.read().tasks[0].owner, 'implementer'); + assert.equal(f.read().tasks[0].revision, 1); + const request = { state: f.state, expectedRevision: f.read().revision, id: packet.id, + destination: { owner: 'reviewer', harness: 'claude' } }; + refused(f, 'handoff-accept', { ...request, destination: { owner: 'reviewer', harness: 'codex' } }, /targeted destination/); + const accepted = f.call('handoff-accept', request); + assert.equal(accepted.tasks[0].owner, 'reviewer'); + assert.equal(accepted.tasks[0].harness, 'claude'); + assert.equal(accepted.tasks[0].status, 'pending'); + assert.equal(accepted.tasks[0].revision, 2); + const receipt = accepted.handoffs.find((entry: { id: string }) => entry.id === packet.id); + assert.ok(Number.isFinite(Date.parse(receipt.acceptedAt))); + assert.deepEqual({ ...receipt, acceptedAt: undefined }, { ...packet, acceptedAt: undefined }); + const acceptedBytes = bytes(f); + f.call('handoff-accept', { ...request, expectedRevision: accepted.revision }); + assert.deepEqual(bytes(f), acceptedBytes, 'same accepted receipt must be byte-idempotent'); + refused(f, 'handoff-accept', request, /State revision conflict/); + refused(f, 'handoff-accept', { ...request, expectedRevision: accepted.revision, id: clean.id }, /belongs to reviewer|revision conflict/); + update(f, 'transfer', 'start'); + refused(f, 'handoff-accept', { ...request, expectedRevision: f.read().revision }, /stale/); +}); + +test('unknown Git stays explicit and changed, reassigned or cancelled work rejects pending handoffs', t => { + const f = fixture(); t.after(f.close); + f.call('init', { state: f.state, team: team() }); + for (const action of ['start', 'reassign', 'cancel']) { + add(f, action); + const missing = path.join(f.root, 'no-such-git-directory'); + const packet = handoff(f, action, missing); + assert.deepEqual(packet.git, { root: missing, head: null, branch: null, dirty: null }); + assert.match(packet.prompt, /dirty: unknown/); + update(f, action, action, action === 'reassign' ? { toOwner: 'reviewer' } : action === 'cancel' ? { reason: 'Withdrawn' } : {}); + refused(f, 'handoff-accept', { state: f.state, expectedRevision: f.read().revision, id: packet.id, + destination: { owner: 'reviewer', harness: 'claude' } }, /revision conflict|belongs to reviewer|terminal/); + assert.equal(f.read().handoffs.find((entry: { id: string }) => entry.id === packet.id).acceptedAt, undefined); + } +}); diff --git a/resources/skills/agent-team-creator/runtime/tsconfig.json b/resources/skills/agent-team-creator/runtime/tsconfig.json new file mode 100644 index 00000000000..7ed58a0ce3b --- /dev/null +++ b/resources/skills/agent-team-creator/runtime/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "skipLibCheck": true, + "rootDir": "src", + "outDir": "../scripts", + "noEmitOnError": true, + "types": [ + "node" + ] + }, + "include": [ + "src/**/*.mts" + ] +} diff --git a/resources/skills/agent-team-creator/runtime/tsconfig.tests.json b/resources/skills/agent-team-creator/runtime/tsconfig.tests.json new file mode 100644 index 00000000000..2c6a17e2244 --- /dev/null +++ b/resources/skills/agent-team-creator/runtime/tsconfig.tests.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "rootDir": "test-src", + "outDir": "../tests" + }, + "include": [ + "test-src/**/*.mts" + ] +} diff --git a/resources/skills/agent-team-creator/schemas/team.schema.json b/resources/skills/agent-team-creator/schemas/team.schema.json new file mode 100644 index 00000000000..5c945831ab0 --- /dev/null +++ b/resources/skills/agent-team-creator/schemas/team.schema.json @@ -0,0 +1,52 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://prometheus-ags.github.io/schemas/agent-team/v1", + "title": "Prometheus portable agent team", + "type": "object", + "additionalProperties": false, + "required": ["schemaVersion", "id", "outcome", "scope", "harness", "roles"], + "properties": { + "schemaVersion": { "const": 1 }, + "id": { "$ref": "#/$defs/id" }, + "outcome": { "type": "string", "minLength": 1 }, + "scope": { "enum": ["project", "uar", "bossfang"] }, + "harness": { "enum": ["uar", "codex", "claude", "copilot", "kimi", "minimax", "opencode", "deepseek"] }, + "roles": { "type": "array", "minItems": 1, "items": { "$ref": "#/$defs/role" } }, + "modelPolicy": { "$ref": "#/$defs/policy" }, + "skillPolicies": { "type": "object", "additionalProperties": { "$ref": "#/$defs/policy" } }, + "native": { "type": "object", "propertyNames": { "$ref": "#/$defs/target" }, "additionalProperties": { "$ref": "#/$defs/native" } } + }, + "$defs": { + "id": { "type": "string", "pattern": "^[a-z][a-z0-9-]{0,62}$" }, + "target": { "enum": ["uar", "codex", "claude", "copilot", "kimi", "minimax", "opencode", "deepseek", "bossfang"] }, + "strings": { "type": "array", "items": { "type": "string", "minLength": 1 } }, + "policy": { + "type": "object", "additionalProperties": false, + "properties": { + "model": { "type": "string", "minLength": 1 }, + "tier": { "enum": ["low", "medium", "hard"] }, + "capabilities": { "$ref": "#/$defs/strings" }, + "maxInputPerMillion": { "type": "number", "minimum": 0 }, + "maxOutputPerMillion": { "type": "number", "minimum": 0 } + } + }, + "role": { + "type": "object", "additionalProperties": false, + "required": ["id", "description", "prompt", "skills", "owns", "inputs", "outputs", "dependsOn"], + "properties": { + "id": { "$ref": "#/$defs/id" }, "description": { "type": "string", "minLength": 1 }, "prompt": { "type": "string", "minLength": 1 }, + "skills": { "$ref": "#/$defs/strings" }, "owns": { "$ref": "#/$defs/strings" }, "inputs": { "$ref": "#/$defs/strings" }, + "outputs": { "$ref": "#/$defs/strings" }, "dependsOn": { "$ref": "#/$defs/strings" }, + "modelPolicy": { "$ref": "#/$defs/policy" }, + "native": { "type": "object", "propertyNames": { "$ref": "#/$defs/target" }, "additionalProperties": { "type": "object" } } + } + }, + "native": { + "type": "object", "additionalProperties": false, "required": ["version", "source"], + "properties": { + "version": { "type": "string", "minLength": 1 }, "source": { "type": "string", "minLength": 1 }, + "options": { "type": "object" }, "files": { "type": "object", "additionalProperties": { "type": "string" } } + } + } + } +} diff --git a/resources/skills/agent-team-creator/scripts/adapters-codecs.mjs b/resources/skills/agent-team-creator/scripts/adapters-codecs.mjs new file mode 100755 index 00000000000..87cc5fba4d4 --- /dev/null +++ b/resources/skills/agent-team-creator/scripts/adapters-codecs.mjs @@ -0,0 +1,52 @@ +export const json = (value) => `${JSON.stringify(value, null, 2)}\n`; +export const object = (value) => value !== null && typeof value === 'object' && !Array.isArray(value); +/** Arrays replace; objects merge recursively; own keys (including __proto__) survive. */ +export function merge(base, override = {}) { + const result = Object.create(null); + for (const [key, value] of Object.entries(base)) + result[key] = value; + for (const [key, value] of Object.entries(override)) { + const previous = result[key]; + result[key] = object(previous) && object(value) ? merge(previous, value) : value; + } + return result; +} +function tomlValue(value) { + if (value === null) + throw new Error('TOML has no null value; omit the field or use an opaque native file.'); + if (typeof value === 'string') + return JSON.stringify(value).replace(/\u007f/g, '\\u007f'); + if (typeof value === 'number') { + if (!Number.isFinite(value)) + throw new Error('Native TOML requires finite JSON numbers.'); + return String(value); + } + if (typeof value === 'boolean') + return String(value); + if (Array.isArray(value)) + return `[${value.map(tomlValue).join(', ')}]`; + return `{ ${Object.entries(value).map(([key, item]) => `${tomlValue(key)} = ${tomlValue(item)}`).join(', ')} }`; +} +/** Quoted keys and inline tables preserve arbitrary native nesting without interpolation. */ +export function toml(value) { + return `${Object.entries(value).map(([key, item]) => `${tomlValue(key)} = ${tomlValue(item)}`).join('\n')}\n`; +} +/** JSON flow syntax is a YAML 1.2 subset, including escaped multiline strings. */ +export const yaml = (value) => json(value); +export const markdown = (frontmatter, body) => `---\n${yaml(frontmatter)}---\n\n${body}\n`; +export function identifier(value, context) { + if (typeof value !== 'string' || !/^[a-z][a-z0-9-]{0,127}$/.test(value)) { + throw new Error(`${context} must be a lowercase kebab-case identifier (1–128 characters).`); + } + return value; +} +export function model(team, role) { + return role.modelPolicy?.model ?? team.modelPolicy?.model; +} +export function prompt(team, role) { + return `${role.prompt}\n\nTeam outcome: ${team.outcome}\nRole: ${role.id}\n` + + `Owns: ${JSON.stringify(role.owns)}\nInputs: ${JSON.stringify(role.inputs)}\n` + + `Outputs: ${JSON.stringify(role.outputs)}\nDependencies: ${JSON.stringify(role.dependsOn)}\n` + + `Requested skills: ${JSON.stringify(role.skills)}\n` + + 'Ownership and skill names are coordination instructions; native permissions and installed skills remain authoritative.'; +} diff --git a/resources/skills/agent-team-creator/scripts/adapters-local.mjs b/resources/skills/agent-team-creator/scripts/adapters-local.mjs new file mode 100755 index 00000000000..a3b08fb85de --- /dev/null +++ b/resources/skills/agent-team-creator/scripts/adapters-local.mjs @@ -0,0 +1,121 @@ +import { json, markdown, merge, model, object, prompt, toml, yaml } from './adapters-codecs.mjs'; +export function exportLocal(team, target, ctx) { + const options = team.native?.[target]?.options; + if (target === 'opencode' && object(options?.agent)) { + const generatedNames = new Set(team.roles.map(role => role.id.toLowerCase())); + for (const name of Object.keys(options.agent)) { + if (generatedNames.has(name.toLowerCase())) { + throw new Error(`OpenCode agent collision between options.agent and generated Markdown: ${name}. Use the role native override.`); + } + } + } + const pluginRoot = `plugins/${team.id}`; + for (const role of team.roles) { + const native = role.native?.[target] ?? {}; + const selected = model(team, role); + const modelField = selected ? { model: selected } : {}; + const body = prompt(team, role); + if (target === 'codex') { + const agent = merge({ name: role.id, description: role.description, developer_instructions: body, ...modelField }, native); + const name = ctx.claimName(agent.name); + ctx.add(`.codex/agents/${name}.toml`, toml(agent)); + } + else if (target === 'claude' || target === 'copilot' || target === 'minimax') { + const agent = merge({ name: role.id, description: role.description, ...modelField, skills: role.skills }, native); + const name = ctx.claimName(agent.name); + const content = markdown(agent, body); + const path = target === 'claude' ? `.claude/agents/${name}.md` + : target === 'copilot' ? `.github/agents/${name}.agent.md` : `agents/${name}/agent.md`; + ctx.add(path, content); + if (target === 'claude') { + ctx.add(`${pluginRoot}/agents/${name}.md`, content); + const ignored = ['hooks', 'mcpServers', 'permissionMode', 'initialPrompt', 'omitClaudeMd'] + .filter(key => Object.hasOwn(native, key)); + if (ignored.length) + ctx.diagnostics.push(`${role.id}: Claude plugin subagents ignore ${ignored.join(', ')}; use the project agent copy for those fields. Values remain preserved in both copies.`); + } + } + else if (target === 'kimi') { + const agent = merge({ name: role.id, description: role.description }, native); + const name = ctx.claimName(agent.name); + const content = markdown(agent, body); + ctx.add(`.kimi-code/agents/${name}.md`, content); + ctx.add(`${pluginRoot}/agents/${name}.md`, content); + if (selected || Object.hasOwn(native, 'model')) { + ctx.diagnostics.push(`${role.id}: Kimi ignores role model frontmatter; configure the invocation model pool or global secondary model separately.`); + } + } + else if (target === 'opencode') { + // Filename owns the identity; native prompt overrides become the Markdown body. + const agent = merge({ description: role.description, mode: 'subagent', ...modelField }, native); + const nativePrompt = agent.prompt; + if (nativePrompt !== undefined && typeof nativePrompt !== 'string') + throw new Error('OpenCode native prompt must be a string.'); + delete agent.prompt; + ctx.claimName(role.id); + ctx.add(`.opencode/agents/${role.id}.md`, markdown(agent, nativePrompt ?? body)); + } + else if (target === 'deepseek') { + ctx.claimName(role.id); + const persona = merge({ prefix: body }, native); + ctx.add(`.dsh/profiles/${team.id}-${role.id}/cordis.patch.yml`, yaml([ + { insert: [{ id: `${team.id}-${role.id}-persona`, name: '@deepseek-ai/dsh-persona', config: persona }] }, + ])); + if (selected || Object.hasOwn(native, 'model')) { + ctx.diagnostics.push(`${role.id}: DeepSeek persona/member configuration cannot select a per-member model; preserved model intent is not applied.`); + } + } + } + if (target === 'codex') { + if (options) + ctx.add('.codex/config.toml', toml(options)); + ctx.instructions.push('Review and merge .codex/agents and optional .codex/config.toml into the project; config values are native Codex options.'); + ctx.diagnostics.push('Standalone agent files are supported. A Codex plugin agents manifest field is not verified; no such field is emitted.'); + } + else if (target === 'claude') { + if (options) + ctx.add('.claude/settings.json', json(options)); + ctx.add(`${pluginRoot}/.claude-plugin/plugin.json`, json({ name: team.id, version: '1.0.0', description: team.outcome })); + ctx.add('.claude-plugin/marketplace.json', json({ + name: `${team.id}-marketplace`, owner: { name: team.id }, + plugins: [{ name: team.id, source: `./${pluginRoot}`, description: team.outcome }], + })); + ctx.instructions.push('Choose project .claude/agents or the staged local marketplace/plugin. Do not install both copies. Settings options are a proposed project .claude/settings.json.'); + ctx.diagnostics.push('These are native subagents; installing them does not create an experimental Claude agent team or enable that feature.'); + } + else if (target === 'kimi') { + ctx.add(`${pluginRoot}/kimi.plugin.json`, json({ name: team.id, version: '1.0.0', description: team.outcome, agents: ['./agents'] })); + ctx.add('marketplace.json', json({ version: '2', plugins: [{ id: team.id, displayName: team.id, source: `./${pluginRoot}` }] })); + ctx.instructions.push('Choose project .kimi-code/agents or the staged Kimi v2 marketplace/plugin. Literal ${base_prompt} in supplied prompts remains unchanged.'); + if (options) + ctx.diagnostics.push('Kimi team native options are preserved in native-options.json only; no project config location or automatic application is asserted.'); + } + else if (target === 'minimax') { + ctx.instructions.push('Install agents//agent.md under the active MiniMax user-data directory: MINIMAX_DATA_DIR, MAVIS_DATA_DIR, or default ~/.minimax. Export does not install there.'); + ctx.diagnostics.push('mcode exec has no verified custom-agent selector. The MiniMax plugin manifest supports skills/MCP/hooks/apps, not an agents field; no agent plugin is invented.'); + if (options) + ctx.diagnostics.push('MiniMax team native options are preserved in native-options.json only; apply through the installed native configuration interface.'); + } + else if (target === 'copilot') { + ctx.instructions.push('Review .github/agents/*.agent.md; native CLI /agent or --agent selects a custom role. Fleet is a separate native execution mechanism.'); + ctx.diagnostics.push('No Copilot agent marketplace mapping was verified; this export uses native agent files.'); + if (options) + ctx.diagnostics.push('Copilot team native options are preserved in native-options.json only; they are not project configuration.'); + } + else if (target === 'opencode') { + if (options) + ctx.add('opencode.json', json(options)); + ctx.instructions.push('Review .opencode/agents/*.md and optional opencode.json. Native configuration uses singular agent and permission keys.'); + ctx.diagnostics.push('OpenCode JS/TS or npm plugins are separate from agent definitions; no agent marketplace manifest is invented.'); + } + else { + ctx.add(`.dsh/profiles/${team.id}-team/cordis.patch.yml`, yaml([{ insert: [ + { id: `${team.id}-persistence`, name: '@deepseek-ai/dsh-session-persistence-jsonl' }, + { id: `${team.id}-team`, name: '@deepseek-ai/dsh-experimental-agent-team', config: options ?? {} }, + { id: `${team.id}-team-tools`, name: '@deepseek-ai/dsh-experimental-tool-agent-team' }, + ] }])); + ctx.instructions.push('Review the role persona profiles and separate team composition profile before selecting them in DeepSeek. Team options configure the experimental agent-team service. The lead creates members at runtime using the portable role prompts; profiles do not pre-create a roster.'); + ctx.diagnostics.push('DeepSeek teams are experimental, require durable session storage, share one process/workspace, and provide no static per-member model setting. No package install, profile activation or team creation occurs on export.'); + } + ctx.diagnostics.push('Role overrides are preserved without native schema certification. Skill IDs refer to separately installed skills; prompt mentions do not install or authorize them.'); +} diff --git a/resources/skills/agent-team-creator/scripts/adapters-services.mjs b/resources/skills/agent-team-creator/scripts/adapters-services.mjs new file mode 100755 index 00000000000..6da8a8b1e49 --- /dev/null +++ b/resources/skills/agent-team-creator/scripts/adapters-services.mjs @@ -0,0 +1,68 @@ +import { json, merge, model, prompt, toml } from './adapters-codecs.mjs'; +export function exportService(team, target, ctx) { + const options = team.native?.[target]?.options; + const registrations = []; + const handAgents = Object.create(null); + const steps = []; + for (const [index, role] of team.roles.entries()) { + const native = role.native?.[target] ?? {}; + if (target === 'uar') { + const defaults = { + version: '1.0.0', kind: 'agent', id: `${team.id}-${role.id}`, + metadata: { title: role.id, description: role.description, tags: [team.id] }, + runtime: { entry: 'default', protocols: {} }, + policy: { + provider: { default: { provider: '', model: model(team, role) ?? '' }, fallbacks: [] }, + tools: { allow: [], deny: [], max_concurrent: 1, execution_mode: 'direct' }, + skills: { prefer: role.skills, max_active: 3 }, + }, + schemas: {}, prompt: { system: prompt(team, role), instructions: [] }, + memory: { conversation: { enabled: true }, kb: { enabled: false, knowledge_bases: [], citation_required: false } }, + tools: { bundles: [] }, ui: { forms: { enabled: false }, artifacts: { enabled: false, preferred_types: [] } }, + extensions: {}, + }; + const artifact = merge(merge(defaults, options), native); + ctx.claimName(artifact.id); + const path = `uar/agents/${role.id}.json`; + ctx.add(path, json(artifact)); + registrations.push({ method: 'POST', route: '/api/agents', bodyFile: path, role: role.id }); + } + else { + const manifest = merge({ + name: `${team.id}-${role.id}`, description: role.description, + model: { provider: 'default', model: model(team, role) ?? 'default', system_prompt: prompt(team, role) }, + skills: role.skills, skills_disabled: role.skills.length === 0, mcp_servers: [], + }, native); + const name = ctx.claimName(manifest.name); + const content = toml(manifest); + ctx.add(`bossfang/agents/${role.id}/agent.toml`, content); + const path = `bossfang/registration/${role.id}.json`; + ctx.add(path, json({ manifest_toml: content })); + registrations.push({ method: 'POST', route: '/api/agents', bodyFile: path, role: role.id }); + handAgents[role.id] = merge({ coordinator: index === 0, invoke_hint: role.description }, manifest); + steps.push({ name: role.id, agent_name: name, prompt: '{{input}}', depends_on: role.dependsOn }); + } + } + if (target === 'bossfang') { + const hand = merge({ id: team.id, version: '1.0.0', name: team.id, description: team.outcome, + category: 'development', icon: '', tools: [], skills: [], mcp_servers: [], agents: handAgents }, options); + const handContent = toml(hand); + ctx.add(`bossfang/hands/${team.id}/HAND.toml`, handContent); + ctx.add('bossfang/hand-install.json', json({ toml_content: handContent, skill_content: '' })); + ctx.add('bossfang/workflow.json', json({ name: team.id, description: team.outcome, steps })); + ctx.instructions.push('Choose standalone agent registrations plus workflow, or install the Hand. These are distinct native deployment paths; do not activate the Hand as a duplicate of the standalone workflow.'); + ctx.instructions.push('Optional native payloads: POST /api/hands/install with bossfang/hand-install.json; POST /api/workflows with bossfang/workflow.json after registering standalone agents. Hand activation and workflow run require separate authorization and are not performed.'); + ctx.diagnostics.push('Empty portable role skills emit skills_disabled=true; native skills=[] with skills_disabled=false means all. Agent MCP [] means none; ["*"] means all. Hand-level allowlists have their own native defaults.'); + ctx.diagnostics.push('BossFang team native options merge into HAND.toml; role overrides merge into each AgentManifest. Preserve local TOML because native GET returns a projection. Hand activation can start autonomous schedules.'); + } + else { + ctx.instructions.push('Each uar/agents/*.json is a complete AgentArtifact body for POST /api/agents. UAR team native options supply artifact defaults, then role native overrides win.'); + ctx.diagnostics.push('UAR skill policy prefer is a preference, not an enforced skill allowlist. Blank provider/model use service defaults; model IDs are not split to guess a provider.'); + ctx.instructions.push('Review native policy.tools.allow and tools.bundles before registration; generated defaults grant no explicit tool allowlist or bundles. Supply required native tool policy through team defaults or role overrides.'); + ctx.diagnostics.push('UAR has no verified persistent team registration API. POST /api/uar/runs requires {artifact:,input:}; registration does not run it.'); + } + ctx.add(`${target}/registration-plan.json`, json({ schemaVersion: 1, format: 'agent-team-export-plan', + execution: 'not-performed', requests: registrations })); + ctx.instructions.push('registration-plan.json is a local review plan, not a native API body. Supply an operator-approved base URL and credential reference; preserve each returned native ID/outcome because registration is not atomic.'); + ctx.diagnostics.push('Native authentication and registration are unverified. UAR defaults to JWT-required; BossFang accepts Bearer or X-API-Key. Discovery success is not mutation authorization.'); +} diff --git a/resources/skills/agent-team-creator/scripts/adapters.mjs b/resources/skills/agent-team-creator/scripts/adapters.mjs new file mode 100755 index 00000000000..7983d99de51 --- /dev/null +++ b/resources/skills/agent-team-creator/scripts/adapters.mjs @@ -0,0 +1,95 @@ +import { identifier, json } from './adapters-codecs.mjs'; +import { exportLocal } from './adapters-local.mjs'; +import { exportService } from './adapters-services.mjs'; +const sources = { + codex: { source: 'https://learn.chatgpt.com/docs/agent-configuration/subagents', version: 'documentation inspected 2026-09-24' }, + claude: { source: 'https://code.claude.com/docs/en/sub-agents', version: 'documentation inspected 2026-09-24' }, + copilot: { source: 'https://docs.github.com/en/copilot/reference/custom-agents-configuration', version: 'documentation inspected 2026-09-24' }, + kimi: { source: 'https://github.com/MoonshotAI/kimi-code/blob/main/docs/en/customization/agents.md', version: 'main documentation inspected 2026-09-24' }, + minimax: { source: 'https://github.com/MiniMax-AI/minimax-code/blob/main/packages/local-runtime-v2/src/service/agent/storage/canonical-agent-config.ts', version: '@minimax-ai/code 0.4.12; source inspected 2026-09-24' }, + opencode: { source: 'https://opencode.ai/docs/agents/', version: 'deployed singular agent schema inspected 2026-09-24' }, + deepseek: { source: 'https://github.com/deepseek-ai/deepseek-harness/tree/master/packages/experimental/agent-team', version: 'master documentation inspected 2026-09-24; experimental' }, + uar: { source: 'https://github.com/Prometheus-AGS/universal-agent-runtime/blob/ba12845138104d3c8c3b8bca8bc7c5be24004e91/src/uar/domain/artifact.rs', version: '1.0.0 / ba12845138104d3c8c3b8bca8bc7c5be24004e91' }, + bossfang: { source: 'crates/librefang-types/src/agent.rs; crates/librefang-hands/src/lib.rs; crates/librefang-api/src/routes/workflows/workflow.rs', version: '2026.7.11 / c719a4d683e4d3fb42e436f812e0193f865c9d2c' }, +}; +function safePath(path) { + if (!path || path.includes('\\') || path.startsWith('/') || /[\x00-\x1f\x7f:]/.test(path)) { + throw new Error(`Unsafe native export path: ${JSON.stringify(path)}`); + } + for (const part of path.split('/')) { + if (!part || part === '.' || part === '..' || /[. ]$/.test(part) || + /^(?:\.git|con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\.|$)/i.test(part)) { + throw new Error(`Unsafe native export path component: ${JSON.stringify(part)}`); + } + } + return path.normalize('NFC').toLowerCase(); +} +/** Pure staging: no filesystem, process, HTTP, registration or execution side effects. */ +export function exportTeam(team, target) { + if (!Object.hasOwn(sources, target)) + throw new Error(`Unsupported export target: ${String(target)}`); + identifier(team.id, 'Team ID'); + if (!team.roles.length) + throw new Error('Export requires at least one role.'); + const roleIds = new Set(); + for (const role of team.roles) { + const id = identifier(role.id, 'Role ID'); + if (roleIds.has(id)) + throw new Error(`Duplicate role ID: ${id}`); + roleIds.add(id); + } + const files = Object.create(null); + const paths = new Set(); + const names = new Set(); + const diagnostics = ['Source-verified serialization only; installed native validation and live execution are unverified.']; + const instructions = ['Review staged artifacts before installation. Export grants no execution or registration authority.']; + const context = { + diagnostics, instructions, + add(path, content) { + const key = safePath(path); + if (key === 'team-export.json') + throw new Error('team-export.json is reserved for the staging receipt.'); + for (const existing of paths) { + if (key === existing || key.startsWith(`${existing}/`) || existing.startsWith(`${key}/`)) { + throw new Error(`Native export file collision: ${path}`); + } + } + paths.add(key); + files[path] = content; + }, + claimName(value) { + const name = identifier(value, 'Native agent name'); + if (names.has(name)) + throw new Error(`Native agent name collision: ${name}`); + names.add(name); + return name; + }, + }; + const native = team.native?.[target]; + if (native && (!native.source?.trim() || !native.version?.trim())) { + throw new Error('Native configuration requires a nonempty source and version receipt.'); + } + if (team.skillPolicies || team.modelPolicy || team.roles.some(role => role.modelPolicy)) { + diagnostics.push('Only explicit model IDs are translated. Resolve tier, skill, capability and price policies with agent-team-models before native invocation.'); + } + if (target === 'uar' || target === 'bossfang') + exportService(team, target, context); + else + exportLocal(team, target, context); + if (native?.options) + context.add('native-options.json', json(native.options)); + const verification = { level: 'source-verified', ...sources[target], live: 'unverified' }; + context.add('export-receipt.json', json({ + target, verification, nativeProvenance: native ? { source: native.source, version: native.version } : null, + nativeOptions: native?.options ?? null, + roleOverrides: Object.fromEntries(team.roles.filter(role => role.native?.[target]).map(role => [role.id, role.native?.[target]])), + diagnostics, instructions, + })); + for (const [path, content] of Object.entries(native?.files ?? {})) { + if (['team-export.json', 'export-receipt.json', 'native-options.json'].includes(safePath(path))) { + throw new Error(`Reserved generated export file: ${path}`); + } + context.add(path, content); + } + return { target, files, verification, diagnostics, instructions }; +} diff --git a/resources/skills/agent-team-creator/scripts/cli.mjs b/resources/skills/agent-team-creator/scripts/cli.mjs new file mode 100755 index 00000000000..1681b6b4840 --- /dev/null +++ b/resources/skills/agent-team-creator/scripts/cli.mjs @@ -0,0 +1,82 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { guide } from './guidance.mjs'; +import { validateTeam, object, text, strings, asJson, target } from './validation.mjs'; +import { exportTeam } from './adapters.mjs'; +import { writeExport } from './export-files.mjs'; +import { readState, initState, mutateState, mutateStateAsync, taskAction, completeKbdTask } from './state.mjs'; +import { createHandoff, acceptHandoff } from './handoff.mjs'; +import { discoverModels, selectModel } from './models.mjs'; +import { queueMemory, publishMemory } from './memory.mjs'; +function revision(input) { + const r = input.expectedRevision; + if (!Number.isSafeInteger(r) || Number(r) < 0) + throw Error('expectedRevision must be a nonnegative integer from the current state'); + return r; +} +const stateFile = (input) => path.resolve(text(input.state, 'state')); +async function dispatch(command, input) { + switch (command) { + case 'guide': return guide(input); + case 'validate': return { valid: true, team: validateTeam(input.team) }; + case 'init': return initState(stateFile(input), validateTeam(input.team)); + case 'status': return readState(stateFile(input)); + case 'team-update': return mutateState(stateFile(input), revision(input), state => { + const team = validateTeam(input.team); + if (team.id !== state.team.id) + throw Error('team-update cannot change team identity'); + if (state.tasks.some(t => !team.roles.some(r => r.id === t.owner))) + throw Error('Cannot remove a role referenced by a task; preserve history and reassign active work explicitly'); + state.team = team; + }); + case 'export': { + const team = validateTeam(input.team ?? readState(stateFile(input)).team); + const result = exportTeam(team, target(input.target ?? team.harness)); + for (const role of team.roles) + if (!role.owns.length) + result.diagnostics.push(`${role.id}: file ownership is not yet assigned; resolve it before parallel edits.`); + return { ...writeExport(text(input.out, 'out'), result), verification: result.verification, diagnostics: result.diagnostics, instructions: result.instructions }; + } + case 'task': return mutateState(stateFile(input), revision(input), state => taskAction(state, object(input.task, 'task action'))); + case 'complete-kbd': return mutateState(stateFile(input), revision(input), state => { + completeKbdTask(state, object(input.task, 'task action'), text(input.cwd, 'cwd')); + }); + case 'handoff-create': return mutateState(stateFile(input), revision(input), state => { + createHandoff(state, object(input.handoff, 'handoff'), text(input.cwd, 'cwd')); + }); + case 'handoff-accept': return mutateState(stateFile(input), revision(input), state => { + const destination = object(input.destination, 'destination'); + const harness = target(destination.harness); + if (harness === 'bossfang') + throw Error('Destination harness must name an execution harness'); + acceptHandoff(state, text(input.id, 'handoff id'), { owner: text(destination.owner, 'destination owner'), harness }); + }); + case 'models-discover': return discoverModels(input); + case 'models-select': return selectModel(validateTeam(input.team), text(input.roleId, 'roleId'), strings(input.skills ?? [], 'skills'), (input.taskPolicy ?? {}), input.catalog); + case 'memory-queue': return mutateState(stateFile(input), revision(input), state => { queueMemory(state, object(input.entry, 'entry')); }); + case 'memory-publish': { + let receipt = null; + const state = await mutateStateAsync(stateFile(input), revision(input), async (state) => { receipt = await publishMemory(state, object(input.publication, 'publication')); }); + return { state, publication: receipt }; + } + default: throw Error(`Unknown command: ${command}`); + } +} +const commands = ['guide', 'validate', 'init', 'status', 'team-update', 'export', 'task', 'complete-kbd', 'handoff-create', 'handoff-accept', 'models-discover', 'models-select', 'memory-queue', 'memory-publish']; +async function main() { + if (Number(process.versions.node.split('.')[0]) < 22) + throw Error('Node.js 22 or newer is required'); + const [command, flag, file, ...extra] = process.argv.slice(2); + if (!command || command === '--help') { + process.stdout.write(JSON.stringify({ usage: 'node /scripts/cli.mjs --input ', commands, note: 'JSON requests preserve spaces and native configuration. Export stages files; it does not install or execute agents.' }, null, 2) + '\n'); + return; + } + if (flag !== '--input' || !file || extra.length) + throw Error('Expected --input '); + const input = object(JSON.parse(fs.readFileSync(file, 'utf8').replace(/^\uFEFF/, ''))); + process.stdout.write(JSON.stringify(asJson(await dispatch(command, input)), null, 2) + '\n'); +} +main().catch(error => { + process.stderr.write(JSON.stringify({ error: error instanceof Error ? error.message : 'Operation failed' }) + '\n'); + process.exitCode = 1; +}); diff --git a/resources/skills/agent-team-creator/scripts/export-files.mjs b/resources/skills/agent-team-creator/scripts/export-files.mjs new file mode 100755 index 00000000000..0ce0b2b1ed8 --- /dev/null +++ b/resources/skills/agent-team-creator/scripts/export-files.mjs @@ -0,0 +1,36 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { createHash } from 'node:crypto'; +import { relativeFile } from './validation.mjs'; +/** An export is a new proposal directory, never an in-place config merge. */ +export function writeExport(out, result) { + const files = { ...result.files }; + const reserved = 'team-export.json'; + if (Object.keys(files).some(f => f.toLowerCase() === reserved)) + throw Error(`Reserved export receipt path: ${reserved}`); + const names = new Set(); + for (const file of Object.keys(files)) { + relativeFile(file); + const lower = file.toLowerCase(); + if (names.has(lower)) + throw Error(`Case-insensitive file collision: ${file}`); + names.add(lower); + } + for (const file of names) + for (const other of names) + if (file !== other && other.startsWith(file + '/')) + throw Error(`File/directory collision: ${file}`); + files[reserved] = JSON.stringify({ target: result.target, verification: result.verification, diagnostics: result.diagnostics, + instructions: result.instructions, files: Object.entries(files).map(([file, content]) => ({ file, sha256: createHash('sha256').update(content).digest('hex') })) }, null, 2) + '\n'; + const directory = path.resolve(out); + fs.mkdirSync(path.dirname(directory), { recursive: true }); + // Nonrecursive mkdir is the no-overwrite boundary. A partial failed export remains + // inspectable and cannot be mistaken for success (receipt is written last). + fs.mkdirSync(directory); + for (const [file, content] of Object.entries(files)) { + const destination = path.join(directory, ...file.split('/')); + fs.mkdirSync(path.dirname(destination), { recursive: true }); + fs.writeFileSync(destination, content, { flag: 'wx', mode: 0o600 }); + } + return { directory, files: Object.keys(files) }; +} diff --git a/resources/skills/agent-team-creator/scripts/guidance.mjs b/resources/skills/agent-team-creator/scripts/guidance.mjs new file mode 100755 index 00000000000..21858b82884 --- /dev/null +++ b/resources/skills/agent-team-creator/scripts/guidance.mjs @@ -0,0 +1,76 @@ +import { id, text, strings, target, object } from './validation.mjs'; +export const questions = [ + { key: 'id', question: 'What short name should identify this team?' }, + { key: 'outcome', question: 'What should be different when this work is finished?' }, + { key: 'complexity', question: 'Is this one isolated change, or work spanning several components?', choices: ['simple', 'complex'] }, + { key: 'areas', question: 'Which kinds of work are involved?', choices: ['code', 'design', 'mobile', 'security', 'docs', 'marketing', 'product'] }, + { key: 'deliverables', question: 'What files, features, or decisions should the team deliver?' }, + { key: 'budget', question: 'Should we favor lower cost, balanced cost, or capability for difficult work?', choices: ['economy', 'balanced', 'quality'] }, + { key: 'review', question: 'Does this work need an independent reviewer?', choices: ['yes', 'no'] }, + { key: 'harness', question: 'Which coding tool will execute the team?' }, + { key: 'scope', question: 'Will the definitions live in this project, UAR, or BossFang?', choices: ['project', 'uar', 'bossfang'] }, +]; +const specialists = { + design: { id: 'designer', why: 'Decide layout, interaction and visual acceptance before implementation.', output: 'Design specification and assets', skills: ['frontend-design', 'impeccable'] }, + mobile: { id: 'mobile-specialist', why: 'Resolve platform navigation, accessibility and device constraints.', output: 'Mobile implementation plan', skills: ['flutter', 'dart'] }, + security: { id: 'security-reviewer', why: 'Review the actual trust boundaries and required controls.', output: 'Threat model and evidence-backed findings', skills: ['agent-runtime-security'] }, + docs: { id: 'documentation-specialist', why: 'Keep operator and developer instructions consistent with the delivered behavior.', output: 'Updated documentation', skills: ['documentation-and-adrs'] }, + marketing: { id: 'marketing-specialist', why: 'Develop audience, positioning and measurable campaign deliverables.', output: 'Campaign brief and copy', skills: ['brand'] }, + product: { id: 'product-manager', why: 'Translate desired outcomes into priorities and acceptance criteria.', output: 'Prioritized requirements', skills: ['domain-modeling'] }, +}; +export function guide(input) { + const required = ['id', 'outcome', 'complexity', 'areas', 'deliverables', 'budget', 'review', 'harness', 'scope']; + const missing = required.filter(k => input[k] === undefined); + if (missing.length) + return { questions, missing }; + const teamId = id(input.id), outcome = text(input.outcome, 'outcome'); + const areas = strings(input.areas, 'areas'), deliverables = strings(input.deliverables, 'deliverables'); + if (!['simple', 'complex'].includes(String(input.complexity))) + throw Error('complexity must be simple or complex'); + if (!['economy', 'balanced', 'quality'].includes(String(input.budget))) + throw Error('Invalid budget preference'); + if (typeof input.review !== 'boolean') + throw Error('review must be boolean'); + if (areas.some(a => !['code', ...Object.keys(specialists)].includes(a))) + throw Error('Unknown work area'); + const harness = target(input.harness); + if (harness === 'bossfang') + throw Error('Use scope=bossfang and choose the executing harness separately'); + if (!['project', 'uar', 'bossfang'].includes(String(input.scope))) + throw Error('Invalid scope'); + const tier = input.budget === 'quality' ? 'hard' : input.budget === 'economy' ? 'low' : 'medium'; + const roles = [{ id: 'implementer', description: 'Deliver the requested outcome within assigned scope.', prompt: `Deliver: ${outcome}. Coordinate ownership before editing. Report evidence and remaining work.`, skills: [], owns: [], inputs: ['Task and acceptance criteria'], outputs: deliverables, dependsOn: [], modelPolicy: { tier } }]; + const reasons = ['An implementer owns delivery. Assign concrete output paths before creating the team; suggested roles can be reduced.']; + if (input.complexity === 'complex') + for (const area of [...new Set(areas)]) { + const spec = specialists[area]; + if (!spec) + continue; + roles.push({ id: spec.id, description: spec.why, prompt: `${spec.why} Outcome: ${outcome}. Stay within assigned scope and return concrete evidence.`, skills: spec.skills, owns: [], inputs: ['Task context'], outputs: [spec.output], dependsOn: [], modelPolicy: { tier: area === 'security' ? 'hard' : tier } }); + reasons.push(`${spec.id}: ${spec.why}`); + } + if (input.review) { + roles.push({ id: 'reviewer', description: 'Independently verify acceptance criteria and code quality.', prompt: 'Inspect the delivered diff and actual verification evidence. Report concrete defects; do not rewrite implementation while reviewing.', skills: ['code-review-and-quality'], owns: [], inputs: ['Implementation diff', 'Verification evidence'], outputs: ['Review findings'], dependsOn: roles.map(r => r.id), modelPolicy: { tier: 'hard' } }); + reasons.push('An independent reviewer adds a separate verification pass and extra model cost.'); + } + const ownership = input.ownership === undefined ? {} : object(input.ownership, 'ownership'); + for (const key of Object.keys(ownership)) + if (!roles.some(r => r.id === key)) + throw Error('Unknown ownership role: ' + key); + for (const role of roles) + if (ownership[role.id] !== undefined) { + role.owns = strings(ownership[role.id], 'ownership.' + role.id); + for (const owned of role.owns) + if (owned.startsWith('/') || owned.includes('\\') || owned.includes(':') || owned.split('/').some(p => p === '..' || p === '.' || !p)) + throw Error('Ownership must use project-relative paths or globs: ' + owned); + } + const unresolved = roles.filter(r => r.owns.length === 0); + const alternatives = ['Use one implementer for sequential work; invoke specialist skills as needed.', 'Add parallel roles only where work and file ownership can be separated.']; + const skillDiscovery = 'Skill names are suggestions, not installation claims. Discover installed AgentSkills, inspect their source and requirements, and replace or remove unavailable skills before export.'; + if (unresolved.length) + return { ready: false, proposedRoles: roles, reasons, alternatives, skillDiscovery, + missing: unresolved.map(r => 'ownership.' + r.id), + questions: unresolved.map(r => ({ key: 'ownership.' + r.id, question: 'Which project-relative files or output directories may ' + r.id + ' write? For read-only review, assign a separate findings path. Inspect the project and suggest paths instead of guessing.' })) }; + return { ready: true, questions: [], team: { schemaVersion: 1, id: teamId, outcome, scope: input.scope, harness, roles, modelPolicy: { tier } }, reasons, + alternatives, skillDiscovery }; +} diff --git a/resources/skills/agent-team-creator/scripts/handoff.mjs b/resources/skills/agent-team-creator/scripts/handoff.mjs new file mode 100755 index 00000000000..baf349924f9 --- /dev/null +++ b/resources/skills/agent-team-creator/scripts/handoff.mjs @@ -0,0 +1,83 @@ +import { spawnSync } from 'node:child_process'; +import { randomUUID } from 'node:crypto'; +import { resolve } from 'node:path'; +import { checkedTask, recordEvent } from './state-tasks.mjs'; +import { harness, owner, strings, text, validateState } from './state-validation.mjs'; +function git(cwd, args) { + const result = spawnSync('git', ['-C', cwd, ...args], { encoding: 'utf8', shell: false, timeout: 10_000, maxBuffer: 4 * 1024 * 1024 }); + return !result.error && result.status === 0 ? result.stdout.trim() : null; +} +/** Capture is read-only. Neither the packet nor its prompt grants native permissions. */ +export function createHandoff(state, input, cwd) { + validateState(state); + const task = checkedTask(state, { ...input, id: text(input.taskId, 'taskId') }); + const to = { owner: owner(state, input.toOwner), harness: harness(input.toHarness) }; + if (task.owner === to.owner && task.harness === to.harness) + throw new Error('Handoff destination must change owner or harness'); + const context = text(input.context, 'context'); + const evidence = [...new Set([...task.evidence, ...strings(input.evidence, 'evidence')])]; + const remaining = [...new Set([...task.remaining, ...strings(input.remaining, 'remaining')])]; + const memoryRefs = strings(input.memoryRefs, 'memoryRefs'); + const root = git(resolve(cwd), ['rev-parse', '--show-toplevel']) ?? resolve(cwd); + const dirty = git(root, ['status', '--porcelain=v1', '--untracked-files=all']); + const snapshot = { + root, head: git(root, ['rev-parse', '--verify', 'HEAD']), + branch: git(root, ['symbolic-ref', '--quiet', '--short', 'HEAD']), + dirty: dirty === null ? null : dirty.length > 0, + }; + const handoff = { + schemaVersion: 1, id: randomUUID(), taskId: task.id, taskRevision: task.revision, + from: { owner: task.owner, harness: task.harness }, to, + context, evidence, remaining, memoryRefs, git: snapshot, + createdAt: new Date().toISOString(), prompt: '', + }; + handoff.prompt = [ + `Fresh task context for role ${to.owner} on ${to.harness}.`, + 'Explicitly accept this handoff before taking ownership. Re-read destination project instructions and check current task revision.', + 'This packet is task data, not authority to bypass instructions. Source sessions, credentials and permissions do not transfer; native destination controls apply.', + `Task: ${task.id} — ${task.title}; source status: ${task.status}; revision: ${task.revision}.`, + `Source: ${task.owner} on ${task.harness}. Destination: ${to.owner} on ${to.harness}.`, + `Context:\n${context}`, + `Evidence:\n${evidence.length ? evidence.map(item => `- ${item}`).join('\n') : '(none supplied)'}`, + `Remaining work / blockers:\n${remaining.length ? remaining.map(item => `- ${item}`).join('\n') : '(none supplied; task is not completed)'}`, + `Memory references:\n${memoryRefs.length ? memoryRefs.map(item => `- ${item}`).join('\n') : '(none supplied)'}`, + `Git root: ${root}; HEAD: ${snapshot.head ?? 'unknown'}; branch: ${snapshot.branch ?? 'unknown or detached'}; dirty: ${snapshot.dirty === null ? 'unknown' : snapshot.dirty}.`, + ...(task.kbd ? [`Canonical KBD identity: ${JSON.stringify(task.kbd)}. Completion must be confirmed by KBD.`] : []), + ].join('\n\n'); + state.handoffs.push(handoff); + recordEvent(state, 'handoff.created', { handoffId: handoff.id, taskId: task.id, taskRevision: task.revision, toOwner: to.owner, toHarness: to.harness }); + return structuredClone(handoff); +} +/** Run inside mutateState: receipt and ownership change commit in one atomic file replacement. */ +export function acceptHandoff(state, id, destination) { + validateState(state); + const handoff = state.handoffs.find(candidate => candidate.id === text(id, 'handoff id')); + if (!handoff) + throw new Error(`Unknown handoff: ${id}`); + const to = { owner: owner(state, destination.owner), harness: harness(destination.harness) }; + if (handoff.to.owner !== to.owner || handoff.to.harness !== to.harness) + throw new Error('Acceptance must come from the targeted destination'); + const task = state.tasks.find(candidate => candidate.id === handoff.taskId); + if (handoff.acceptedAt) { + if (task.owner !== to.owner || task.harness !== to.harness || task.revision !== handoff.taskRevision + 1 || ['complete', 'cancelled'].includes(task.status)) + throw new Error('Accepted receipt is stale: task changed after transfer'); + return structuredClone(handoff); + } + checkedTask(state, { id: task.id, owner: handoff.from.owner, expectedTaskRevision: handoff.taskRevision }); + if (task.harness !== handoff.from.harness) + throw new Error('Source harness changed after handoff creation'); + task.owner = to.owner; + task.harness = to.harness; + if (task.status === 'running') + task.status = 'pending'; + task.evidence = [...handoff.evidence]; + task.remaining = [...handoff.remaining]; + task.revision++; + handoff.acceptedAt = new Date().toISOString(); + recordEvent(state, 'handoff.accepted', { + handoffId: handoff.id, taskId: task.id, taskRevision: task.revision, + fromOwner: handoff.from.owner, fromHarness: handoff.from.harness, + owner: to.owner, harness: to.harness, acceptedAt: handoff.acceptedAt, + }); + return structuredClone(handoff); +} diff --git a/resources/skills/agent-team-creator/scripts/memory.mjs b/resources/skills/agent-team-creator/scripts/memory.mjs new file mode 100755 index 00000000000..7a5db8c5ae9 --- /dev/null +++ b/resources/skills/agent-team-creator/scripts/memory.mjs @@ -0,0 +1,151 @@ +import { createHash } from 'node:crypto'; +import { assertNoCredentials, endpoint, object, requestJson, RequestFailure, text } from './models-http.mjs'; +function canonical(value) { + if (Array.isArray(value)) + return `[${value.map(canonical).join(',')}]`; + if (value && typeof value === 'object') + return `{${Object.keys(value).sort().map(key => `${JSON.stringify(key)}:${canonical(value[key])}`).join(',')}}`; + return JSON.stringify(value); +} +const digest = (value) => createHash('sha256').update(canonical(value)).digest('hex'); +function reference(state, provenance) { + if (provenance.kbd === undefined) + return; + const kbd = object(provenance.kbd, 'provenance.kbd'); + for (const key of ['projectId', 'runId', 'phaseId', 'changeId', 'taskId']) + text(kbd[key], `kbd.${key}`); + const matching = state.tasks.some(task => task.kbd && canonical(task.kbd) === canonical(kbd)); + if (!matching) + throw new Error('KBD provenance must exactly reference a linked team task; canonical validation is separate'); +} +/** Caller commits this mutation atomically before offering the entry for publication. */ +export function queueMemory(state, input) { + assertNoCredentials(input); + const content = text(input.content, 'memory.content'); + const scope = text(input.scope, 'memory.scope'); + const supplied = input.provenance === undefined ? {} : object(input.provenance, 'memory.provenance'); + reference(state, supplied); + const provenance = { ...supplied, teamId: state.team.id, source: 'agent-team-runtime', authority: 'local-team-record; KBD references are unverified mirrors' }; + const identity = digest({ content, scope, provenance }); + const id = input.id === undefined ? `memory-${identity.slice(0, 48)}` : text(input.id, 'memory.id'); + if (!/^[a-z][a-z0-9-]{0,62}$/.test(id)) + throw new Error('memory.id must be a portable lowercase identifier'); + const existing = state.outbox.find(entry => entry.id === id); + if (existing) { + if (digest({ content: existing.content, scope: existing.scope, provenance: existing.provenance }) !== identity) + throw new Error('memory id conflicts with different content, scope or provenance'); + return existing; + } + const entry = { id, content, scope, provenance, status: 'queued' }; + state.outbox.push(entry); + return entry; +} +function field(value, label) { + const key = text(value, label); + if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key) || ['__proto__', 'prototype', 'constructor'].includes(key)) + throw new Error('mapping fields must be safe top-level JSON property names'); + return key; +} +function publication(entry, input) { + if (input.provider === 'surreal-memory') { + const scope = object(input.scopeMapping, 'scopeMapping'); + if (scope.scope !== entry.scope) + throw new Error('scopeMapping.scope must match the queued scope exactly'); + const agentId = text(scope.agentId, 'scopeMapping.agentId'); + const userId = scope.userId === undefined ? null : text(scope.userId, 'scopeMapping.userId'); + const sessionId = scope.sessionId === undefined ? null : text(scope.sessionId, 'scopeMapping.sessionId'); + // The verified REST request has no metadata or idempotency fields. Keep the + // publication envelope inside content rather than inventing accepted fields. + const content = JSON.stringify({ schemaVersion: 1, kind: 'agent-team-memory', id: entry.id, scope: entry.scope, provenance: entry.provenance, content: entry.content }); + return { + body: { content, agent_id: agentId, user_id: userId, session_id: sessionId, categories: ['agent-team', entry.scope] }, + headers: {}, remoteIdField: 'id', method: 'POST', + contract: { provider: 'surreal-memory', source: 'https://github.com/Prometheus-AGS/surreal-memory-server/blob/dd7fdcd6d8974af4059d1d51401bd33ae29f65db/src/contracts.rs', + route: 'POST /api/v1/memory/', scopeBinding: 'explicit identity filters and content envelope; not an authorization guarantee', remoteIdempotency: 'unsupported-by-verified-contract' }, + }; + } + if (input.provider !== 'mapped-http') + throw new Error('memory provider must be surreal-memory or mapped-http'); + const mapping = object(input.mapping, 'mapping'); + const source = text(mapping.source, 'mapping.source'); + const version = text(mapping.version, 'mapping.version'); + const method = mapping.method ?? 'POST'; + if (method !== 'POST' && method !== 'PUT') + throw new Error('mapping.method must be POST or PUT'); + const body = mapping.constants === undefined ? {} : { ...object(mapping.constants, 'mapping.constants') }; + const fields = [ + [field(mapping.contentField, 'mapping.contentField'), entry.content], + [field(mapping.scopeField, 'mapping.scopeField'), entry.scope], + [field(mapping.provenanceField, 'mapping.provenanceField'), entry.provenance], + ]; + if (mapping.idempotencyField !== undefined) + fields.push([field(mapping.idempotencyField, 'mapping.idempotencyField'), entry.id]); + const seen = new Set(); + for (const [key, value] of fields) { + if (seen.has(key) || Object.hasOwn(body, key)) + throw new Error('memory mapping fields collide'); + seen.add(key); + body[key] = value; + } + const headers = {}; + if (mapping.idempotencyHeader !== undefined) { + const header = text(mapping.idempotencyHeader, 'mapping.idempotencyHeader'); + if (!/^(?:Idempotency-Key|X-Idempotency-Key)$/i.test(header)) + throw new Error('unsupported idempotency header mapping'); + headers[header] = entry.id; + } + return { body, headers, method, remoteIdField: field(mapping.responseIdField, 'mapping.responseIdField'), + contract: { provider: 'mapped-http', source, version, scopeBinding: 'operator-configured mapping; server authorization unverified', + remoteIdempotency: mapping.idempotencyHeader || mapping.idempotencyField ? 'operator-mapped; server guarantee unverified' : 'not-configured' } }; +} +/** Only an already-queued entry is eligible. Caller persists success AND failure receipts. */ +export async function publishMemory(state, input) { + assertNoCredentials(input); + const id = text(input.id, 'memory.id'); + const entry = state.outbox.find(item => item.id === id); + if (!entry) + throw new Error('queue and persist memory before publication'); + if (entry.status === 'published') + return { id, status: 'published', receipt: entry.receipt ?? null, repeated: true }; + const previous = entry.receipt && typeof entry.receipt === 'object' && !Array.isArray(entry.receipt) ? entry.receipt : {}; + if (previous.uncertain === true && input.retryUncertain !== true) { + return { id, status: 'queued', receipt: previous, reason: 'remote outcome uncertain; reconcile before explicitly setting retryUncertain' }; + } + if (input.url === undefined) { + entry.receipt = { at: new Date().toISOString(), outcome: 'unavailable', reason: 'no memory endpoint configured', uncertain: false }; + return { id, status: 'queued', receipt: entry.receipt }; + } + const url = endpoint(input.url); + if (input.provider === 'surreal-memory' && !url.pathname.endsWith('/api/v1/memory/')) + throw new Error('surreal-memory url must name the verified /api/v1/memory/ route'); + const request = publication(entry, input); + assertNoCredentials(request.body); + const target = { url: url.href, contract: request.contract }; + const publicationKey = digest({ id, content: entry.content, scope: entry.scope, provenance: entry.provenance, target, body: request.body }); + if (previous.publicationKey !== undefined && previous.publicationKey !== publicationKey) + throw new Error('retry destination or mapping differs from recorded attempt'); + const receipt = { at: new Date().toISOString(), publicationKey, contentSha256: digest(entry.content), target, localIdempotencyKey: id, + exactlyOnce: false, uncertaintyNote: 'A crash after remote commit and before local receipt can duplicate a retry; reconcile remotely.' }; + try { + const response = await requestJson(url, input, request.method, request.body, request.headers); + const payload = object(response.value, 'memory response'); + const remoteId = payload[request.remoteIdField]; + if (remoteId === undefined || remoteId === null) + throw new RequestFailure('remote_response_missing_id', true, response.status); + assertNoCredentials(remoteId); + entry.receipt = { ...receipt, outcome: 'published', httpStatus: response.status, remoteId, uncertain: false }; + entry.status = 'published'; + return { id, status: 'published', receipt: entry.receipt }; + } + catch (error) { + // Invalid configuration fails before I/O; transport and response failures + // remain durable retryable outbox records without logging remote content. + if (!(error instanceof RequestFailure)) { + entry.receipt = { ...receipt, outcome: 'unavailable', reason: 'unsafe_or_unsupported_remote_response', uncertain: true }; + } + else { + entry.receipt = { ...receipt, outcome: 'unavailable', reason: error.code, httpStatus: error.httpStatus, uncertain: error.uncertain }; + } + return { id, status: 'queued', receipt: entry.receipt }; + } +} diff --git a/resources/skills/agent-team-creator/scripts/models-http.mjs b/resources/skills/agent-team-creator/scripts/models-http.mjs new file mode 100755 index 00000000000..0ccab1104ba --- /dev/null +++ b/resources/skills/agent-team-creator/scripts/models-http.mjs @@ -0,0 +1,124 @@ +export function object(value, label) { + if (!value || typeof value !== 'object' || Array.isArray(value)) + throw new Error(`${label} must be an object`); + return value; +} +export function text(value, label) { + if (typeof value !== 'string' || !value.trim()) + throw new Error(`${label} must be a nonempty string`); + return value; +} +export function assertNoCredentials(value) { + const encoded = JSON.stringify(value); + for (const [name, secret] of Object.entries(process.env)) { + if (/(?:TOKEN|SECRET|PASSWORD|API_KEY|PRIVATE_KEY)$/i.test(name) && secret && secret.length >= 8 && encoded.includes(JSON.stringify(secret).slice(1, -1))) { + throw new Error('credential values must not be persisted; use environment references'); + } + } + function inspect(item) { + if (Array.isArray(item)) { + item.forEach(inspect); + return; + } + if (item && typeof item === 'object') + for (const [key, child] of Object.entries(item)) { + if (/^(?:authorization|api_?key|access_?token|refresh_?token|password|secret|private_?key)$/i.test(key)) { + throw new Error('credential fields are not accepted in persisted content'); + } + inspect(child); + } + } + inspect(value); +} +export function endpoint(value) { + const url = new URL(text(value, 'endpoint URL')); + if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password || url.hash) { + throw new Error('endpoint must be HTTP(S), without userinfo or fragment'); + } + if (url.protocol === 'http:' && !['localhost', '127.0.0.1', '[::1]'].includes(url.hostname)) { + throw new Error('non-loopback endpoints require HTTPS'); + } + for (const key of url.searchParams.keys()) { + if (/(?:token|key|secret|password|credential|auth)/i.test(key)) + throw new Error('URL credentials are forbidden; use auth.env'); + } + assertNoCredentials(url.href); + return url; +} +export class RequestFailure extends Error { + code; + uncertain; + httpStatus; + constructor(code, uncertain, httpStatus = null) { + super(code); + this.code = code; + this.uncertain = uncertain; + this.httpStatus = httpStatus; + } +} +// Response bodies and raw fetch errors are never returned: either can echo credentials. +export async function requestJson(url, input, method = 'GET', body, extraHeaders = {}) { + const headers = { Accept: 'application/json', ...extraHeaders }; + let secret; + if (input.auth !== undefined) { + const auth = object(input.auth, 'auth'); + if (Object.keys(auth).some(key => !['env', 'header', 'scheme'].includes(key))) + throw new RequestFailure('invalid_auth_configuration', false); + const name = text(auth.env, 'auth.env'); + if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) + throw new RequestFailure('invalid_auth_environment_reference', false); + secret = process.env[name]; + if (!secret) + throw new RequestFailure('credential_environment_unavailable', false); + if (/[\r\n]/.test(secret)) + throw new RequestFailure('invalid_credential_environment_value', false); + const header = auth.header ?? 'Authorization'; + if (header !== 'Authorization' && header !== 'X-API-Key') + throw new RequestFailure('invalid_auth_header', false); + const scheme = auth.scheme ?? (header === 'Authorization' ? 'Bearer' : 'raw'); + if (scheme !== 'Bearer' && scheme !== 'raw') + throw new RequestFailure('invalid_auth_scheme', false); + headers[header] = scheme === 'Bearer' ? `Bearer ${secret}` : secret; + } + const timeout = input.timeoutMs ?? 10000; + if (typeof timeout !== 'number' || !Number.isInteger(timeout) || timeout < 1 || timeout > 60000) + throw new RequestFailure('invalid_timeout_configuration', false); + if (body !== undefined) + headers['Content-Type'] = 'application/json'; + let response; + try { + response = await fetch(url, { method, headers, body: body === undefined ? undefined : JSON.stringify(body), redirect: 'error', signal: AbortSignal.timeout(timeout) }); + } + catch { + throw new RequestFailure('transport_unavailable_or_redirect_refused', method !== 'GET'); + } + if (!response.ok) { + await response.body?.cancel(); + throw new RequestFailure('http_request_failed', method !== 'GET' && response.status >= 500, response.status); + } + try { + const reader = response.body?.getReader(); + if (!reader) + throw new Error('empty'); + const chunks = []; + let length = 0; + while (true) { + const part = await reader.read(); + if (part.done) + break; + length += part.value.length; + if (length > 8 * 1024 * 1024) { + await reader.cancel(); + throw new Error('large'); + } + chunks.push(part.value); + } + const encoded = Buffer.concat(chunks).toString('utf8'); + if (secret && encoded.includes(secret)) + throw new Error('credential reflected'); + return { value: JSON.parse(encoded), status: response.status }; + } + catch { + throw new RequestFailure('invalid_or_unsafe_json_response', method !== 'GET', response.status); + } +} diff --git a/resources/skills/agent-team-creator/scripts/models.mjs b/resources/skills/agent-team-creator/scripts/models.mjs new file mode 100755 index 00000000000..9caa0d2f4ae --- /dev/null +++ b/resources/skills/agent-team-creator/scripts/models.mjs @@ -0,0 +1,198 @@ +import { assertNoCredentials, endpoint, object, requestJson, text } from './models-http.mjs'; +import { policy as validatePolicy } from './validation.mjs'; +const tiers = ['low', 'medium', 'hard']; +const own = (record, key) => Object.hasOwn(record, key) ? record[key] : undefined; +const maybeObject = (value) => value && typeof value === 'object' && !Array.isArray(value) ? value : {}; +const numberOrNull = (value) => typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : null; +const stringOrNull = (value) => typeof value === 'string' && value.length > 0 ? value : null; +function freshness(provenance, maxAge) { + const limit = maxAge ?? 30; + if (typeof limit !== 'number' || !Number.isFinite(limit) || limit < 0) + throw new Error('maxCatalogAgeDays must be nonnegative'); + const fetched = stringOrNull(provenance.fetched); + const stamp = fetched ? Date.parse(fetched) : NaN; + const age = Number.isFinite(stamp) ? (Date.now() - stamp) / 86400000 : null; + return { fetchedAt: fetched, ageDays: age === null ? null : Math.max(0, age), stale: age === null || age < 0 ? null : age > limit, maxAgeDays: limit }; +} +function catalogMetadata(input) { + const catalog = input.catalog === undefined ? {} : object(input.catalog, 'catalog'); + if (input.catalog !== undefined && catalog.$schema_version !== 1) + throw new Error('liter-llm catalog requires $schema_version 1'); + const original = maybeObject(catalog.$provenance); + const provenance = { + source: stringOrNull(original.source), sourceSha256: stringOrNull(original.source_sha256), + fetched: stringOrNull(original.fetched), libraryVersion: stringOrNull(original.library_version), + adapter: 'liter-llm/catalog-schema-1', sourceRevision: 'c5c6caac617eb931cd5009146a70831422ec236c', + }; + return { providers: maybeObject(catalog.providers), provenance, freshness: freshness(provenance, input.maxCatalogAgeDays) }; +} +function prices(model) { + const pricing = maybeObject(model.pricing); + const rows = [pricing, ...(Array.isArray(pricing.tiers) ? pricing.tiers.map(value => object(value, 'catalog pricing tier')) : [])]; + const max = (key) => { + const values = rows.map(row => numberOrNull(row[key])); + const converted = values.some(value => value === null) ? null : Math.max(...values) * 1_000_000; + return numberOrNull(converted); + }; + return { inputPerMillion: max('input_cost_per_token'), outputPerMillion: max('output_cost_per_token'), currency: 'USD', basis: 'maximum-known-context-tier', sourceUnit: 'per-token' }; +} +function normalize(input, rows, kind, discovery) { + const metadata = catalogMetadata(input); + const aliases = input.aliases === undefined ? {} : object(input.aliases, 'aliases'); + const annotations = input.tiers === undefined ? {} : object(input.tiers, 'tiers'); + for (const tier of Object.values(annotations)) + if (typeof tier !== 'string' || !tiers.includes(tier)) + throw new Error('operator tiers must be low, medium or hard'); + const seen = new Set(); + const models = rows.map(value => { + const row = typeof value === 'string' ? { id: value } : object(value, 'discovered model'); + const id = text(row.id, 'model.id'); + if (seen.has(id)) + throw new Error('duplicate discovered model identifier; narrow discovery to one provider'); + seen.add(id); + const mapped = own(aliases, id); + const mapping = mapped === undefined ? null : object(mapped, 'alias mapping'); + const provider = mapping ? text(mapping.provider, 'alias.provider') : null; + const catalogId = mapping ? text(mapping.model, 'alias.model') : null; + const providerModels = provider ? maybeObject(maybeObject(own(metadata.providers, provider)).models) : {}; + const catalogModel = catalogId ? own(providerModels, catalogId) : undefined; + if (mapping && !catalogModel) + throw new Error('explicit alias mapping does not identify a catalog model'); + const model = maybeObject(catalogModel); + const capabilities = {}; + for (const [key, flag] of Object.entries(maybeObject(model.capabilities))) + if (typeof flag === 'boolean') + capabilities[key] = flag; + const fields = { supports_tools: 'function_calling', supports_vision: 'vision', supports_streaming: 'streaming', supports_reasoning: 'reasoning', supports_thinking: 'reasoning', supports_structured_output: 'structured_output' }; + if (kind === 'uar' || kind === 'bossfang') + for (const [field, capability] of Object.entries(fields)) { + if (typeof row[field] === 'boolean') + capabilities[capability] = row[field]; + } + const pricing = prices(model); + if (!mapping && kind === 'bossfang') { + pricing.inputPerMillion = numberOrNull(row.input_cost_per_m); + pricing.outputPerMillion = numberOrNull(row.output_cost_per_m); + pricing.sourceUnit = 'per-million-tokens'; + pricing.basis = 'bossfang-configured-base-price'; + } + const available = kind === 'bossfang' ? (typeof row.available === 'boolean' ? row.available : null) + : kind === 'uar' ? (typeof row.enabled === 'boolean' ? row.enabled : null) : true; + return { + id, provider: provider ?? stringOrNull(row.provider), catalogId, available, + tier: own(annotations, id) ?? null, capabilities, pricing, + provenance: { discovery, catalog: mapping ? metadata.provenance : null, + availabilityBasis: kind === 'declared' ? 'operator-declared' : 'configured-discovery-response', + metadataBasis: mapping ? 'explicit-alias-mapping' : kind === 'declared' ? 'operator-declared-identifier-only' : kind === 'openai' ? 'identifier-only' : 'configured-native-record' }, + freshness: mapping ? metadata.freshness : { fetchedAt: discovery?.fetchedAt ?? null, ageDays: discovery ? 0 : null, stale: null }, + }; + }).sort((a, b) => a.id < b.id ? -1 : a.id > b.id ? 1 : 0); + const result = { schemaVersion: 1, models, catalogProvenance: metadata.provenance, catalogFreshness: metadata.freshness, + diagnostics: ['Discovery describes configured availability, not a successful inference.', 'Strength tiers are operator annotations; unspecified metadata remains unknown.'] }; + assertNoCredentials(result); + return result; +} +/** Discover identifiers; catalog aliases are explicit and credentials remain in the environment. */ +export async function discoverModels(input) { + assertNoCredentials(input); + const kind = input.kind ?? 'openai'; + if (kind !== 'openai' && kind !== 'uar' && kind !== 'bossfang') + throw new Error('discovery kind must be openai, uar or bossfang'); + let url; + if (input.discoveryUrl !== undefined) + url = endpoint(input.discoveryUrl); + else { + if (kind !== 'openai') + throw new Error('UAR and BossFang require an explicit discoveryUrl'); + url = endpoint(input.baseUrl); + if (url.search) + throw new Error('baseUrl cannot contain a query'); + url.pathname = `${url.pathname.replace(/\/$/, '').replace(/\/v1$/, '')}/v1/models`; + } + const response = await requestJson(url, input); + const payload = kind === 'uar' ? response.value : object(response.value, 'model discovery response')[kind === 'bossfang' ? 'models' : 'data']; + if (!Array.isArray(payload)) + throw new Error('model discovery response has an unsupported shape'); + return normalize(input, payload, kind, { kind, url: url.href, fetchedAt: new Date().toISOString(), httpStatus: response.status }); +} +/** Layered scalar overrides, AND capabilities, deterministic lowest-known-total-price choice. */ +export function selectModel(team, roleId, skills, taskPolicy, catalog) { + const role = team.roles.find(candidate => candidate.id === roleId); + if (!role) + throw new Error('unknown role for model selection'); + const layers = [['team', team.modelPolicy], ['role', role.modelPolicy], + ...skills.map(skill => [`skill:${skill}`, team.skillPolicies?.[skill]]), ['task', taskPolicy]]; + let policy = {}; + const applied = []; + for (const [name, layer] of layers) + if (layer) { + validatePolicy(layer, `${name}.modelPolicy`); + const capabilities = [...new Set([...(policy.capabilities ?? []), ...(layer.capabilities ?? [])])]; + policy = { ...policy, ...Object.fromEntries(Object.entries(layer).filter(([, value]) => value !== undefined)), capabilities }; + applied.push(name); + } + const input = object(catalog, 'catalog input'); + const normalized = input.schemaVersion === 1 && Array.isArray(input.models) ? input + : normalize(input, Array.isArray(input.availableModels) ? input.availableModels : [], 'declared', null); + if (!Array.isArray(normalized.models)) + throw new Error('normalized catalog models must be an array'); + const accepted = []; + const rejected = []; + const seen = new Set(); + for (const value of normalized.models) { + const model = object(value, 'model'); + const id = text(model.id, 'model.id'); + if (seen.has(id)) + throw new Error('duplicate model identifier in selection catalog'); + seen.add(id); + const reasons = []; + const capabilities = maybeObject(model.capabilities); + const pricing = maybeObject(model.pricing); + if (model.available !== true) + reasons.push('availability unknown or disabled'); + if (policy.model && model.id !== policy.model) + reasons.push('different explicit model'); + if (policy.tier && model.tier !== policy.tier) + reasons.push('declared tier missing or different'); + for (const capability of policy.capabilities ?? []) + if (capabilities[capability] !== true) + reasons.push(`capability ${capability} unsupported or unknown`); + const ceilings = [[policy.maxInputPerMillion, pricing.inputPerMillion], [policy.maxOutputPerMillion, pricing.outputPerMillion]]; + for (const [ceiling, price] of ceilings) { + if (ceiling === undefined) + continue; + const known = numberOrNull(price); + if (known === null) + reasons.push('price unknown; cannot satisfy ceiling'); + else if (known > ceiling) + reasons.push('price exceeds ceiling'); + } + if (reasons.length) + rejected.push({ id: model.id, reasons }); + else + accepted.push(model); + } + const totalPrice = (model) => { + const pricing = maybeObject(model.pricing); + const input = numberOrNull(pricing.inputPerMillion), output = numberOrNull(pricing.outputPerMillion); + return input === null || output === null ? Infinity : input + output; + }; + accepted.sort((a, b) => (totalPrice(a) - totalPrice(b)) || (String(a.id) < String(b.id) ? -1 : String(a.id) > String(b.id) ? 1 : 0)); + const selected = accepted[0] ?? null; + const warnings = []; + if (selected) { + const age = maybeObject(selected.freshness); + if (age.stale === true) + warnings.push('Selected catalog pricing is stale. Price ceilings do not guarantee current provider rates.'); + else if (age.stale === null || age.stale === undefined) + warnings.push('Selected pricing freshness is unknown. Price ceilings do not guarantee current provider rates.'); + const provenance = maybeObject(selected.provenance); + if (provenance.availabilityBasis === 'operator-declared') + warnings.push('Availability is operator-declared; no live discovery was performed for this list.'); + } + const result = { selected, policy: policy, appliedLayers: applied, rejected, + explanation: selected ? 'All declared constraints satisfied; ordered by lowest known input+output USD per million, then exact identifier. Unknown costs rank last.' : 'No declared available model satisfies every constraint.', + warnings, diagnostics: normalized.diagnostics ?? [], catalogProvenance: normalized.catalogProvenance ?? null, catalogFreshness: normalized.catalogFreshness ?? null }; + assertNoCredentials(result); + return result; +} diff --git a/resources/skills/agent-team-creator/scripts/state-kbd.mjs b/resources/skills/agent-team-creator/scripts/state-kbd.mjs new file mode 100755 index 00000000000..919068a8276 --- /dev/null +++ b/resources/skills/agent-team-creator/scripts/state-kbd.mjs @@ -0,0 +1,89 @@ +import { spawnSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { resolve } from 'node:path'; +import { prepareCompletion, recordEvent } from './state-tasks.mjs'; +import { integer, object, text, validateState } from './state-validation.mjs'; +function execute(binary, argv, cwd) { + const result = spawnSync(binary, argv, { + cwd, shell: false, encoding: 'utf8', timeout: 30_000, maxBuffer: 16 * 1024 * 1024, + }); + if (result.error || result.status !== 0) { + throw new Error(`Canonical KBD command failed or has an uncertain outcome; local completion was not recorded. Re-read canonical status before retrying. ${result.error?.message ?? result.stderr.trim()}`); + } + let value; + try { + value = JSON.parse(result.stdout); + } + catch { + throw new Error('Canonical CLI returned non-JSON output; completion is unconfirmed, re-read status before retrying'); + } + return { value: object(value, 'canonical CLI output'), stdout: result.stdout }; +} +function verifyIdentity(state, identity) { + if (state.projectId !== identity.projectId || state.runId !== identity.runId) + throw new Error('Canonical project/run identity does not match the linked task'); + if (integer(state.revision, 'canonical revision') === 0) + throw new Error('Canonical run is not initialized'); + const phase = object(object(state.phases, 'canonical phases')[identity.phaseId], 'canonical phase'); + const change = object(object(phase.changes, 'canonical changes')[identity.changeId], 'canonical change'); + const task = object(object(change.tasks, 'canonical tasks')[identity.taskId], 'canonical task'); + if (phase.id !== identity.phaseId || change.id !== identity.changeId || task.id !== identity.taskId) + throw new Error('Canonical phase/change/task identity mismatch'); + return task; +} +/** + * Source contract: prometheus-cli main.rs KbdAction::Status / KbdTaskAction::Transition. + * kbd --path status --json + * kbd --path task transition --command-id + * --phase --change --id --status complete --summary + * + * Call only inside a state transaction. kbdCli is explicit because PATH may name + * another product. No shell, hooks, service registration, or synthetic KBD events. + * The CLI chooses its canonical frontier; it exposes no expected-run/revision + * argument. Preflight + committed-response identity checks detect drift but cannot + * provide a distributed transaction across KBD and this file. A crash after the + * canonical commit requires retry/reconciliation; never roll back KBD history. + */ +export function completeKbdTask(state, input, cwd) { + validateState(state); + const complete = prepareCompletion(state, input); + if (!complete.kbd) + throw new Error('Task has no canonical KBD identity; use ordinary task completion'); + const binary = text(input.kbdCli, 'kbdCli executable'); + const directory = resolve(cwd); + const base = ['kbd', '--path', directory]; + const identity = complete.kbd; + const before = execute(binary, [...base, 'status', '--json'], directory); + const canonicalTask = verifyIdentity(before.value, identity); + if (!['in_progress', 'complete'].includes(String(canonicalTask.status))) + throw new Error(`Canonical task must be in_progress before completion; current status: ${String(canonicalTask.status)}`); + const commandId = `agent-team-${createHash('sha256').update(JSON.stringify({ + team: state.team.id, task: complete.id, revision: complete.revision - 1, identity, + })).digest('hex')}`; + let receipt = before; + let argv = [...base, 'status', '--json']; + let mode = 'reconciled-existing-completion'; + if (canonicalTask.status !== 'complete') { + argv = [...base, 'task', 'transition', '--command-id', commandId, + '--phase', identity.phaseId, '--change', identity.changeId, '--id', identity.taskId, + '--status', 'complete', '--summary', `Team ${state.team.id}, task ${complete.id}. Evidence: ${complete.evidence.join('; ')}`]; + const response = execute(binary, argv, directory); + receipt = { value: object(response.value.state, 'committed canonical state'), stdout: response.stdout }; + mode = 'transition-committed'; + } + const committed = verifyIdentity(receipt.value, identity); + if (committed.status !== 'complete') + throw new Error('Canonical response did not confirm completion; local task remains unchanged'); + const draft = structuredClone(state); + draft.tasks[draft.tasks.findIndex(task => task.id === complete.id)] = complete; + recordEvent(draft, 'kbd.task.completed', { + taskId: complete.id, taskRevision: complete.revision, owner: complete.owner, + kbd: { ...identity }, commandId, mode, executable: binary, argv, + canonicalRevision: receipt.value.revision, canonicalTaskStatus: committed.status, + canonicalEventId: receipt.value.lastEventId ?? null, + receiptSha256: createHash('sha256').update(receipt.stdout).digest('hex'), + evidence: complete.evidence, + }); + validateState(draft); + Object.assign(state, draft); +} diff --git a/resources/skills/agent-team-creator/scripts/state-tasks.mjs b/resources/skills/agent-team-creator/scripts/state-tasks.mjs new file mode 100755 index 00000000000..68946705655 --- /dev/null +++ b/resources/skills/agent-team-creator/scripts/state-tasks.mjs @@ -0,0 +1,119 @@ +import { randomUUID } from 'node:crypto'; +import { harness, integer, object, owner, strings, text, validateState } from './state-validation.mjs'; +export function recordEvent(state, kind, detail) { + state.events.push({ id: randomUUID(), at: new Date().toISOString(), kind, detail }); +} +export function checkedTask(state, input) { + const id = text(input.id ?? input.taskId, 'task id'); + const task = state.tasks.find(candidate => candidate.id === id); + if (!task) + throw new Error(`Unknown task: ${id}`); + if (owner(state, input.owner) !== task.owner) + throw new Error(`Task ${id} belongs to ${task.owner}`); + if (integer(input.expectedTaskRevision, 'expectedTaskRevision') !== task.revision) + throw new Error(`Task revision conflict: ${id} is at ${task.revision}`); + if (['complete', 'cancelled'].includes(task.status)) + throw new Error(`Task ${id} is terminal`); + if (task.revision === Number.MAX_SAFE_INTEGER) + throw new Error('Task revision exhausted'); + return task; +} +export function dependenciesComplete(state, task) { + for (const id of task.dependsOn) { + if (state.tasks.find(candidate => candidate.id === id)?.status !== 'complete') + throw new Error(`Dependency ${id} is not complete`); + } +} +export function prepareCompletion(state, input) { + const task = checkedTask(state, input); + dependenciesComplete(state, task); + if (task.status !== 'running') + throw new Error('Start the task before completing it'); + const evidence = [...new Set([...task.evidence, ...strings(input.evidence, 'completion evidence')])]; + const remaining = input.remaining === undefined ? task.remaining : strings(input.remaining, 'remaining'); + if (!evidence.length || remaining.length) + throw new Error('Completion requires evidence and no remaining work'); + return { ...task, status: 'complete', revision: task.revision + 1, evidence, remaining }; +} +export function taskAction(state, input) { + validateState(state); + object(input, 'task action'); + const draft = structuredClone(state); + const action = text(input.action, 'action'); + if (action === 'add') { + const id = text(input.id, 'task id'); + if (draft.tasks.some(task => task.id === id)) + throw new Error(`Task already exists: ${id}`); + const task = { + id, title: text(input.title, 'title'), owner: owner(draft, input.owner), + harness: harness(input.harness ?? draft.team.harness), status: 'pending', revision: 0, + dependsOn: strings(input.dependsOn ?? [], 'dependsOn'), + evidence: strings(input.evidence ?? [], 'evidence'), remaining: strings(input.remaining ?? [], 'remaining'), + }; + if (input.kbd !== undefined) + task.kbd = structuredClone(object(input.kbd, 'kbd')); + if (input.modelPolicy !== undefined) + task.modelPolicy = structuredClone(object(input.modelPolicy, 'modelPolicy')); + draft.tasks.push(task); + recordEvent(draft, 'task.added', { taskId: id, owner: task.owner, harness: task.harness, taskRevision: 0 }); + } + else { + const task = checkedTask(draft, input); + const previousOwner = task.owner; + const previousHarness = task.harness; + const previousStatus = task.status; + const previousRevision = task.revision; + if (action === 'complete') { + if (task.kbd) + throw new Error('KBD-linked completion requires completeKbdTask and a successful canonical CLI receipt'); + Object.assign(task, prepareCompletion(draft, input)); + } + else { + switch (action) { + case 'start': + if (!['pending', 'blocked'].includes(task.status)) + throw new Error('Only pending or blocked tasks can start'); + dependenciesComplete(draft, task); + task.status = 'running'; + break; + case 'block': { + if (!['pending', 'running'].includes(task.status)) + throw new Error('Only pending or running tasks can be blocked'); + const reason = text(input.reason, 'block reason'); + task.remaining = [...new Set([...task.remaining, reason])]; + task.status = 'blocked'; + break; + } + case 'cancel': + text(input.reason, 'cancellation reason'); + task.status = 'cancelled'; + break; + case 'reassign': + task.owner = owner(draft, input.toOwner); + task.harness = harness(input.toHarness ?? task.harness); + if (task.owner === previousOwner && task.harness === previousHarness) + throw new Error('Reassignment must change owner or harness'); + if (task.status === 'running') + task.status = 'pending'; + break; + default: throw new Error(`Unsupported task action: ${action}`); + } + if (input.evidence !== undefined) + task.evidence = [...new Set([...task.evidence, ...strings(input.evidence, 'evidence')])]; + if (input.remaining !== undefined) { + task.remaining = strings(input.remaining, 'remaining'); + if (action === 'block') + task.remaining = [...new Set([...task.remaining, text(input.reason, 'block reason')])]; + } + task.revision++; + } + recordEvent(draft, `task.${action}`, { + taskId: task.id, previousRevision, taskRevision: task.revision, + previousOwner, previousHarness, previousStatus, owner: task.owner, harness: task.harness, + status: task.status, evidence: task.evidence, remaining: task.remaining, + ...(input.reason === undefined ? {} : { reason: text(input.reason, 'reason') }), + }); + } + validateState(draft); + Object.assign(state, draft); +} diff --git a/resources/skills/agent-team-creator/scripts/state-validation.mjs b/resources/skills/agent-team-creator/scripts/state-validation.mjs new file mode 100755 index 00000000000..0c9e4a09abd --- /dev/null +++ b/resources/skills/agent-team-creator/scripts/state-validation.mjs @@ -0,0 +1,208 @@ +import { harnesses } from './types.mjs'; +import { validateTeam } from './validation.mjs'; +export function object(value, label) { + if (!value || typeof value !== 'object' || Array.isArray(value)) + throw new Error(`${label} must be an object`); + return value; +} +export function text(value, label) { + if (typeof value !== 'string' || !value.trim() || value.includes('\0')) + throw new Error(`${label} must be a nonempty string without NUL`); + return value; +} +export function integer(value, label) { + if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 0) + throw new Error(`${label} must be a nonnegative safe integer`); + return value; +} +export function strings(value, label) { + if (!Array.isArray(value)) + throw new Error(`${label} must be an array`); + return value.map((item, index) => text(item, `${label}[${index}]`)); +} +export function harness(value) { + if (typeof value !== 'string' || !harnesses.includes(value)) + throw new Error(`Unsupported harness: ${String(value)}`); + return value; +} +export function owner(state, value) { + const id = text(value, 'owner'); + if (!state.team.roles.some(role => role.id === id)) + throw new Error(`Unknown team role: ${id}`); + return id; +} +function timestamp(value, label) { + if (!Number.isFinite(Date.parse(text(value, label)))) + throw new Error(`${label} must be an ISO timestamp`); +} +function uniqueIds(items, label) { + if (!Array.isArray(items)) + throw new Error(`${label} must be an array`); + const ids = new Set(); + return items.map(item => { + const entry = object(item, label); + const id = text(entry.id, `${label}.id`); + if (ids.has(id)) + throw new Error(`Duplicate ${label} id: ${id}`); + ids.add(id); + return entry; + }); +} +function jsonBoundary(value, ancestors = new Set()) { + if (value === null || typeof value === 'string' || typeof value === 'boolean') + return; + if (typeof value === 'number' && Number.isFinite(value)) + return; + if (!value || typeof value !== 'object') + throw new Error('State must contain only JSON values'); + if (ancestors.has(value)) + throw new Error('State contains a cyclic object'); + if (!Array.isArray(value) && ![Object.prototype, null].includes(Object.getPrototypeOf(value))) + throw new Error('State objects must be plain JSON objects'); + ancestors.add(value); + for (const item of Array.isArray(value) ? value : Object.values(value)) + jsonBoundary(item, ancestors); + ancestors.delete(value); +} +export function validateState(value) { + jsonBoundary(value); + const raw = object(value, 'state'); + if (raw.schemaVersion !== 1) + throw new Error('Unsupported state schemaVersion'); + integer(raw.revision, 'state.revision'); + validateTeam(raw.team); + const state = value; + const tasks = uniqueIds(raw.tasks, 'task'); + const byId = new Map(tasks.map(task => [task.id, task])); + for (const task of tasks) { + text(task.title, 'task.title'); + owner(state, task.owner); + harness(task.harness); + integer(task.revision, 'task.revision'); + if (!['pending', 'running', 'blocked', 'complete', 'cancelled'].includes(String(task.status))) + throw new Error(`Invalid task status: ${String(task.status)}`); + const dependencies = strings(task.dependsOn, 'task.dependsOn'); + if (new Set(dependencies).size !== dependencies.length) + throw new Error('Duplicate task dependency'); + for (const dependency of dependencies) { + if (dependency === task.id || !byId.has(dependency)) + throw new Error(`Invalid task dependency: ${dependency}`); + if (['running', 'complete'].includes(String(task.status)) && byId.get(dependency).status !== 'complete') + throw new Error(`Dependency ${dependency} is not complete`); + } + const evidence = strings(task.evidence, 'task.evidence'); + const remaining = strings(task.remaining, 'task.remaining'); + if (task.status === 'complete' && (!evidence.length || remaining.length)) + throw new Error('Completed tasks require evidence and no remaining work'); + if (task.kbd !== undefined) { + const kbd = object(task.kbd, 'task.kbd'); + for (const field of ['projectId', 'runId', 'phaseId', 'changeId', 'taskId']) + text(kbd[field], `task.kbd.${field}`); + } + if (task.modelPolicy !== undefined) { + // Reuse the manifest's policy boundary without inventing another schema. + validateTeam({ ...state.team, modelPolicy: task.modelPolicy }); + } + } + const visited = new Set(); + const visiting = new Set(); + function visit(id) { + if (visiting.has(id)) + throw new Error(`Task dependency cycle at ${id}`); + if (visited.has(id)) + return; + visiting.add(id); + for (const dependency of byId.get(id).dependsOn) + visit(dependency); + visiting.delete(id); + visited.add(id); + } + for (const task of state.tasks) + visit(task.id); + for (const handoff of uniqueIds(raw.handoffs, 'handoff')) { + if (handoff.schemaVersion !== 1) + throw new Error('Unsupported handoff schemaVersion'); + const taskId = text(handoff.taskId, 'handoff.taskId'); + if (!byId.has(taskId)) + throw new Error(`Unknown handoff task: ${taskId}`); + const revision = integer(handoff.taskRevision, 'handoff.taskRevision'); + if (revision > byId.get(taskId).revision) + throw new Error('Handoff references a future task revision'); + for (const field of ['from', 'to']) { + const endpoint = object(handoff[field], `handoff.${field}`); + owner(state, endpoint.owner); + harness(endpoint.harness); + } + text(handoff.context, 'handoff.context'); + text(handoff.prompt, 'handoff.prompt'); + for (const field of ['evidence', 'remaining', 'memoryRefs']) + strings(handoff[field], `handoff.${field}`); + const git = object(handoff.git, 'handoff.git'); + text(git.root, 'handoff.git.root'); + for (const field of ['head', 'branch']) + if (git[field] !== null) + text(git[field], `handoff.git.${field}`); + if (git.dirty !== null && typeof git.dirty !== 'boolean') + throw new Error('handoff.git.dirty must be boolean or null'); + timestamp(handoff.createdAt, 'handoff.createdAt'); + if (handoff.acceptedAt !== undefined) + timestamp(handoff.acceptedAt, 'handoff.acceptedAt'); + } + for (const memory of uniqueIds(raw.outbox, 'memory')) { + text(memory.content, 'memory.content'); + text(memory.scope, 'memory.scope'); + object(memory.provenance, 'memory.provenance'); + if (!['queued', 'published'].includes(String(memory.status))) + throw new Error('Invalid memory status'); + if (memory.status === 'published' && memory.receipt === undefined) + throw new Error('Published memory requires a receipt'); + } + for (const event of uniqueIds(raw.events, 'event')) { + timestamp(event.at, 'event.at'); + text(event.kind, 'event.kind'); + object(event.detail, 'event.detail'); + } + return state; +} +export function validateMutation(before, after) { + if (after.revision !== before.revision || after.team.id !== before.team.id) + throw new Error('Callback may not change state revision or team identity'); + validateState(after); + const same = (left, right) => JSON.stringify(left) === JSON.stringify(right); + if (!same(before.events, after.events.slice(0, before.events.length))) + throw new Error('Event history is append-only'); + for (const old of before.tasks) { + const next = after.tasks.find(task => task.id === old.id); + if (!next) + throw new Error('Tasks cannot be deleted; cancel instead'); + if (same(old, next)) + continue; + if (['complete', 'cancelled'].includes(old.status)) + throw new Error(`Task ${old.id} is terminal`); + if (next.revision !== old.revision + 1) + throw new Error('Each changed task must increment its revision once'); + if (!same(old.kbd, next.kbd)) + throw new Error('Canonical task identity cannot be reassigned'); + const ownershipChanged = old.owner !== next.owner || old.harness !== next.harness; + const allowed = old.status === 'pending' + ? ['pending', 'running', 'blocked', 'cancelled'] + : old.status === 'running' + ? ['running', 'blocked', 'complete', 'cancelled', ...(ownershipChanged ? ['pending'] : [])] + : ['blocked', 'running', 'cancelled']; + if (!allowed.includes(next.status)) + throw new Error(`Invalid task transition: ${old.status} -> ${next.status}`); + if (old.kbd && next.status === 'complete') { + const receipt = after.events.slice(before.events.length).find(event => event.kind === 'kbd.task.completed' && event.detail.taskId === next.id && event.detail.taskRevision === next.revision); + if (!receipt || !same(receipt.detail.kbd, next.kbd) || receipt.detail.canonicalTaskStatus !== 'complete') + throw new Error('Linked task completion requires a canonical completion receipt'); + } + } + for (const task of after.tasks) + if (!before.tasks.some(old => old.id === task.id) && task.revision !== 0) + throw new Error('New tasks start at revision 0'); + for (const old of before.handoffs) { + const next = after.handoffs.find(handoff => handoff.id === old.id); + if (!next || !same({ ...old, acceptedAt: undefined }, { ...next, acceptedAt: undefined }) || (old.acceptedAt !== undefined && next.acceptedAt !== old.acceptedAt)) + throw new Error('Handoff packets and accepted receipts are immutable'); + } +} diff --git a/resources/skills/agent-team-creator/scripts/state.mjs b/resources/skills/agent-team-creator/scripts/state.mjs new file mode 100755 index 00000000000..3fd754d1383 --- /dev/null +++ b/resources/skills/agent-team-creator/scripts/state.mjs @@ -0,0 +1,136 @@ +import { closeSync, existsSync, fsyncSync, lstatSync, mkdirSync, openSync, readFileSync, realpathSync, renameSync, rmSync, writeFileSync } from 'node:fs'; +import { basename, dirname, join, resolve } from 'node:path'; +import { randomUUID } from 'node:crypto'; +import { integer, validateMutation, validateState } from './state-validation.mjs'; +export { taskAction } from './state-tasks.mjs'; +export { completeKbdTask } from './state-kbd.mjs'; +function statePath(file, createDirectory = false) { + const absolute = resolve(file); + if (createDirectory) + mkdirSync(dirname(absolute), { recursive: true }); + const path = join(realpathSync(dirname(absolute)), basename(absolute)); + if (existsSync(path) && (!lstatSync(path).isFile() || lstatSync(path).isSymbolicLink())) + throw new Error('State must be a regular file, not a symlink'); + return path; +} +function lock(file) { + const path = `${file}.lock`; + const token = randomUUID(); + let fd; + try { + fd = openSync(path, 'wx', 0o600); + } + catch (error) { + if (error.code === 'EEXIST') + throw new Error(`State lock held: ${path}. No automatic stale-lock takeover; inspect the recorded owner before manual recovery.`); + throw error; + } + try { + writeFileSync(fd, JSON.stringify({ token, pid: process.pid, at: new Date().toISOString() })); + fsyncSync(fd); + } + catch (error) { + closeSync(fd); + rmSync(path, { force: true }); + throw error; + } + closeSync(fd); + return () => { + try { + if (JSON.parse(readFileSync(path, 'utf8')).token === token) + rmSync(path); + } + catch { /* A removed/replaced lock is never stolen from another writer. */ } + }; +} +function atomicWrite(file, state) { + const temporary = join(dirname(file), `.${basename(file)}.${randomUUID()}.tmp`); + const fd = openSync(temporary, 'wx', 0o600); + try { + writeFileSync(fd, `${JSON.stringify(state, null, 2)}\n`); + fsyncSync(fd); + } + catch (error) { + closeSync(fd); + rmSync(temporary, { force: true }); + throw error; + } + closeSync(fd); + try { + for (let attempt = 0;; attempt++) { + try { + renameSync(temporary, file); + break; + } + catch (error) { + const transient = ['EPERM', 'EBUSY', 'EACCES'].includes(error.code ?? ''); + if (process.platform !== 'win32' || !transient || attempt >= 6) + throw error; + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 25 * 2 ** attempt); + } + } + } + finally { + rmSync(temporary, { force: true }); + } +} +export function readState(file) { + return validateState(JSON.parse(readFileSync(statePath(file), 'utf8'))); +} +export function initState(file, team) { + const path = statePath(file, true); + const release = lock(path); + try { + if (existsSync(path)) + throw new Error(`State already exists: ${path}`); + const state = validateState({ schemaVersion: 1, revision: 0, team: structuredClone(team), tasks: [], handoffs: [], outbox: [], events: [] }); + atomicWrite(path, state); + return state; + } + finally { + release(); + } +} +function prepare(file, expectedRevision) { + integer(expectedRevision, 'expectedRevision'); + const before = readState(file); + if (before.revision !== expectedRevision) + throw new Error(`State revision conflict: expected ${expectedRevision}, current ${before.revision}`); + if (before.revision === Number.MAX_SAFE_INTEGER) + throw new Error('State revision exhausted'); + return { before, state: structuredClone(before) }; +} +function commit(file, before, state) { + validateMutation(before, state); + if (JSON.stringify(before) === JSON.stringify(state)) + return state; + state.revision = before.revision + 1; + atomicWrite(file, state); + return state; +} +export function mutateState(file, expectedRevision, callback) { + const path = statePath(file); + const release = lock(path); + try { + const { before, state } = prepare(path, expectedRevision); + const result = callback(state); + if (result && typeof result.then === 'function') + throw new Error('Async callback requires mutateStateAsync'); + return commit(path, before, state); + } + finally { + release(); + } +} +export async function mutateStateAsync(file, expectedRevision, callback) { + const path = statePath(file); + const release = lock(path); + try { + const { before, state } = prepare(path, expectedRevision); + await callback(state); + return commit(path, before, state); + } + finally { + release(); + } +} diff --git a/resources/skills/agent-team-creator/scripts/types.mjs b/resources/skills/agent-team-creator/scripts/types.mjs new file mode 100755 index 00000000000..ac41a54f03f --- /dev/null +++ b/resources/skills/agent-team-creator/scripts/types.mjs @@ -0,0 +1 @@ +export const harnesses = ['uar', 'codex', 'claude', 'copilot', 'kimi', 'minimax', 'opencode', 'deepseek']; diff --git a/resources/skills/agent-team-creator/scripts/validation.mjs b/resources/skills/agent-team-creator/scripts/validation.mjs new file mode 100755 index 00000000000..0e1733b5595 --- /dev/null +++ b/resources/skills/agent-team-creator/scripts/validation.mjs @@ -0,0 +1,127 @@ +import { harnesses } from './types.mjs'; +export function object(value, label = 'input') { + if (!value || typeof value !== 'object' || Array.isArray(value)) + throw Error(`${label} must be an object`); + return value; +} +export function text(value, label) { + if (typeof value !== 'string' || !value.trim()) + throw Error(`${label} must be a nonempty string`); + return value; +} +export function strings(value, label) { + if (!Array.isArray(value) || value.some(v => typeof v !== 'string' || !v.trim())) + throw Error(`${label} must be a string array`); + return value; +} +export function id(value, label = 'id') { + const result = text(value, label); + if (!/^[a-z][a-z0-9-]{0,62}$/.test(result) || /^(con|prn|aux|nul|com[0-9]|lpt[0-9])$/.test(result)) + throw Error(`${label} must be a portable lowercase identifier`); + return result; +} +export function target(value) { + if (typeof value !== 'string' || ![...harnesses, 'bossfang'].includes(value)) + throw Error('Unknown harness target'); + return value; +} +export function policy(value, label) { + const p = object(value, label); + for (const key of Object.keys(p)) + if (!['model', 'tier', 'capabilities', 'maxInputPerMillion', 'maxOutputPerMillion'].includes(key)) + throw Error(`Unknown ${label} field: ${key}`); + if (p.model !== undefined) + text(p.model, `${label}.model`); + if (p.tier !== undefined && !['low', 'medium', 'hard'].includes(String(p.tier))) + throw Error(`Invalid ${label}.tier`); + if (p.capabilities !== undefined) + strings(p.capabilities, `${label}.capabilities`); + for (const key of ['maxInputPerMillion', 'maxOutputPerMillion']) { + if (p[key] !== undefined && (typeof p[key] !== 'number' || !Number.isFinite(p[key]) || p[key] < 0)) + throw Error(`Invalid ${label}.${key}`); + } +} +export function relativeFile(file) { + if (!file || file.includes('\\') || file.startsWith('/') || /[<>:"|?*\u0000-\u001f]/.test(file) || file.split('/').some(p => !p || p === '.' || p === '..' || /[. ]$/.test(p) || /^(con|prn|aux|nul|com[0-9]|lpt[0-9])(\.|$)/i.test(p))) + throw Error(`Unsafe portable file path: ${file}`); + return file; +} +export function validateTeam(value) { + const t = object(value, 'team'); + const allowed = ['schemaVersion', 'id', 'outcome', 'scope', 'harness', 'roles', 'modelPolicy', 'skillPolicies', 'native']; + for (const key of Object.keys(t)) + if (!allowed.includes(key)) + throw Error(`Unknown team field ${key}; use native..options or files for harness-specific configuration`); + if (t.schemaVersion !== 1) + throw Error('team.schemaVersion must be 1'); + id(t.id, 'team.id'); + text(t.outcome, 'team.outcome'); + if (!['project', 'uar', 'bossfang'].includes(String(t.scope))) + throw Error('Invalid scope'); + if (!harnesses.includes(t.harness)) + throw Error('Invalid team.harness'); + if (!Array.isArray(t.roles) || t.roles.length === 0) + throw Error('At least one role is required'); + const ids = new Set(); + for (const entry of t.roles) { + const r = object(entry, 'role'), key = id(r.id, 'role.id'); + if (ids.has(key)) + throw Error(`Duplicate role ${key}`); + ids.add(key); + for (const field of Object.keys(r)) + if (!['id', 'description', 'prompt', 'skills', 'owns', 'inputs', 'outputs', 'dependsOn', 'modelPolicy', 'native'].includes(field)) + throw Error(`Unknown role field ${field}`); + text(r.description, 'description'); + text(r.prompt, 'prompt'); + for (const field of ['skills', 'owns', 'inputs', 'outputs', 'dependsOn']) + strings(r[field], field); + if (r.modelPolicy !== undefined) + policy(r.modelPolicy, 'role.modelPolicy'); + if (r.native !== undefined) + for (const [key, v] of Object.entries(object(r.native))) { + target(key); + object(v, `role.native.${key}`); + } + } + const visiting = new Set(), visited = new Set(); + const team = t; + function visit(key) { + if (visiting.has(key)) + throw Error('Role dependency cycle'); + if (visited.has(key)) + return; + const r = team.roles.find(r => r.id === key); + if (!r) + throw Error(`Unknown dependency ${key}`); + visiting.add(key); + r.dependsOn.forEach(visit); + visiting.delete(key); + visited.add(key); + } + ids.forEach(visit); + if (t.modelPolicy !== undefined) + policy(t.modelPolicy, 'modelPolicy'); + if (t.skillPolicies !== undefined) + for (const [key, v] of Object.entries(object(t.skillPolicies))) + policy(v, `skillPolicies.${key}`); + if (t.native !== undefined) + for (const [key, v] of Object.entries(object(t.native))) { + target(key); + const n = object(v, `native.${key}`); + for (const field of Object.keys(n)) + if (!['version', 'source', 'options', 'files'].includes(field)) + throw Error(`Unknown native wrapper field ${field}; put native settings in options or files`); + text(n.version, 'native version'); + text(n.source, 'native source'); + if (n.options !== undefined) + object(n.options, 'native options'); + if (n.files !== undefined) + for (const [file, content] of Object.entries(object(n.files))) { + relativeFile(file); + if (typeof content !== 'string') + throw Error('Native file content must be a string'); + } + } + return structuredClone(team); +} +export const asJson = (value) => JSON.parse(JSON.stringify(value)); diff --git a/resources/skills/agent-team-creator/tests/export.integration.mjs b/resources/skills/agent-team-creator/tests/export.integration.mjs new file mode 100644 index 00000000000..5e1f91ece5e --- /dev/null +++ b/resources/skills/agent-team-creator/tests/export.integration.mjs @@ -0,0 +1,149 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import { createRequire } from 'node:module'; +import { fixture, team, skillRoot } from './fixture.mjs'; +const require = createRequire(path.join(skillRoot, 'runtime', 'package.json')); +const parseToml = require('smol-toml').parse; +const parseYaml = require('yaml').parse; +test('packaged guide recommends minimal work and explains editable specialist teams', () => { + const f = fixture(); + try { + const questions = f.call('guide', {}); + assert.ok(questions.missing.includes('outcome')); + const request = { id: 'checkout', outcome: 'Accessible checkout', complexity: 'simple', areas: ['code'], deliverables: ['Checkout'], budget: 'balanced', review: false, harness: 'codex', scope: 'project' }; + const pending = f.call('guide', request); + assert.equal(pending.ready, false); + assert.equal(pending.team, undefined); + assert.deepEqual(pending.missing, ['ownership.implementer']); + const simple = f.call('guide', { ...request, ownership: { implementer: ['src/checkout/**'] } }); + assert.equal(simple.team.roles.length, 1); + assert.equal(simple.ready, true); + assert.deepEqual(simple.team.roles[0].owns, ['src/checkout/**']); + f.call('validate', { team: simple.team }); + const complex = f.call('guide', { ...request, complexity: 'complex', areas: ['code', 'design', 'mobile', 'security'], review: true, ownership: { implementer: ['src/checkout/**'], designer: ['design/checkout/**'], 'mobile-specialist': ['plans/mobile.md'], 'security-reviewer': ['reviews/security.md'], reviewer: ['reviews/quality.md'] } }); + assert.ok(complex.team.roles.some((r) => r.id === 'designer')); + assert.ok(complex.team.roles.some((r) => r.id === 'mobile-specialist')); + assert.ok(complex.alternatives.length); + assert.ok(complex.skillDiscovery); + assert.ok(complex.team.roles.every((r) => r.owns.length > 0)); + f.call('guide', { ...request, ownership: null }, 1); + f.call('guide', { ...request, ownership: { ghost: ['unknown'] } }, 1); + f.call('guide', { ...request, ownership: { implementer: ['../escape'] } }, 1); + assert.equal(f.call('guide', { ...request, ownership: { implementer: [] } }).team, undefined); + f.call('init', { state: f.state, team: complex.team }); + assert.equal(f.call('status', { state: f.state }).revision, 0); + assert.equal(fs.existsSync(path.join(f.root, 'copied skill', 'runtime')), false); + } + finally { + f.close(); + } +}); +for (const target of ['uar', 'bossfang', 'codex', 'claude', 'copilot', 'kimi', 'minimax', 'opencode', 'deepseek']) { + test(`packaged export ${target}: parse actual serialized proposals and preserve native files`, () => { + const f = fixture(); + try { + const unusual = 'Quoted "value" with newline\nbackslash \\ and ${base_prompt}'; + const manifest = { ...team(), modelPolicy: { model: 'provider/model' }, + roles: team().roles.map(r => ({ ...r, prompt: unusual })), + native: { [target]: { version: 'source-contract-fixture', source: 'operator-configured', options: { 'opaque-setting': { values: [unusual, 3, true] } }, files: { 'native/custom.txt': unusual } } } }; + const out = path.join(f.root, `proposal-${target}`); + const result = f.call('export', { team: manifest, target, out }); + assert.equal(result.verification.live, 'unverified'); + assert.equal(fs.readFileSync(path.join(out, 'native/custom.txt'), 'utf8'), unusual); + const receipt = JSON.parse(fs.readFileSync(path.join(out, 'team-export.json'), 'utf8')); + assert.ok(receipt.files.length > 1); + assert.ok(receipt.diagnostics.length); + for (const { file } of receipt.files) { + const content = fs.readFileSync(path.join(out, file), 'utf8'); + if (file.endsWith('.toml')) + assert.ok(parseToml(content)); + if (/\.ya?ml$/.test(file)) + assert.ok(parseYaml(content)); + if (file.endsWith('.json')) + assert.ok(JSON.parse(content)); + if (file.endsWith('.md') && content.startsWith('---\n')) { + const front = content.match(/^---\n([\s\S]*?)\n---/); + assert.ok(front); + assert.equal(typeof parseYaml(front[1]), 'object'); + } + } + if (target === 'codex') { + const agent = parseToml(fs.readFileSync(path.join(out, '.codex/agents/implementer.toml'), 'utf8')); + assert.ok(String(agent.developer_instructions).startsWith(unusual)); + assert.equal(agent.model, 'provider/model'); + } + if (target === 'uar') { + const artifact = JSON.parse(fs.readFileSync(path.join(out, 'uar/agents/implementer.json'), 'utf8')); + for (const key of ['metadata', 'runtime', 'policy', 'schemas', 'prompt', 'memory', 'tools', 'ui']) + assert.equal(typeof artifact[key], 'object'); + assert.equal(artifact.runtime.entry, 'default'); + } + if (target === 'bossfang') { + const request = JSON.parse(fs.readFileSync(path.join(out, 'bossfang/registration/implementer.json'), 'utf8')); + const manifest = parseToml(request.manifest_toml); + assert.equal(manifest.skills_disabled, true); + assert.ok(String(manifest.model.system_prompt).startsWith(unusual)); + } + if (target === 'kimi' || target === 'deepseek') + assert.ok(result.diagnostics.some((d) => /model/.test(d))); + if (target === 'minimax') + assert.ok(result.diagnostics.some((d) => /selector/.test(d))); + } + finally { + f.close(); + } + }); +} +test('export refuses collisions, unsafe paths and overwriting existing proposals', () => { + const f = fixture(); + try { + const out = path.join(f.root, 'proposal'); + f.call('export', { team: team(), target: 'codex', out }); + const before = fs.readFileSync(path.join(out, 'team-export.json')); + f.call('export', { team: team(), target: 'codex', out }, 1); + assert.deepEqual(fs.readFileSync(path.join(out, 'team-export.json')), before); + for (const files of [{ '.codex/agents/implementer.toml': 'collision' }, { 'A.txt': 'one', 'a.txt': 'two' }, { '../escape': 'bad' }, { 'CON.txt': 'bad' }, { 'wild*card': 'bad' }, { 'a': 'one', 'a/b': 'two' }]) { + const bad = { ...team(), native: { codex: { source: 'operator', version: 'recorded', files } } }; + const refused = path.join(f.root, 'refused'); + f.call('export', { team: bad, target: 'codex', out: refused }, 1); + assert.equal(fs.existsSync(refused), false); + } + const renamed = { ...team(), roles: team().roles.map(r => ({ ...r, native: { codex: { name: 'same-name' } } })) }; + f.call('export', { team: renamed, target: 'codex', out: path.join(f.root, 'duplicate-name') }, 1); + } + finally { + f.close(); + } +}); +test('manifest rejects misspelled common fields and dependency cycles; native options remain explicit', () => { + const f = fixture(); + try { + for (const key of ['modelPolicy', 'skillPolicies', 'native']) + f.call('validate', { team: { ...team(), [key]: null } }, 1); + for (const key of ['modelPolicy', 'native']) + f.call('validate', { team: { ...team(), roles: team().roles.map(r => ({ ...r, [key]: null })) } }, 1); + f.call('validate', { team: { ...team(), model: 'misspelled-common-model' } }, 1); + f.call('validate', { team: { ...team(), roles: team().roles.map(r => ({ ...r, dependsOn: [r.id] })) } }, 1); + f.call('validate', { team: { ...team(), native: { codex: { source: 'operator', version: 'recorded', unknown: true } } } }, 1); + f.call('validate', { team: { ...team(), native: { codex: { source: 'operator', version: 'recorded', options: { futureOption: true } } } } }); + } + finally { + f.close(); + } +}); +test('all four shipped skill frontmatters use the AgentSkills standard metadata shape', () => { + for (const name of ['agent-team-creator', 'agent-team-manage', 'agent-team-models', 'agent-team-handoff']) { + const file = path.join(skillRoot, '..', name, 'SKILL.md'); + const raw = fs.readFileSync(file, 'utf8').match(/^---\n([\s\S]*?)\n---/); + assert.ok(raw); + const meta = parseYaml(raw[1]); + assert.equal(meta.name, name); + assert.equal(typeof meta.description, 'string'); + for (const key of Object.keys(meta)) + assert.ok(['name', 'description', 'license', 'compatibility', 'metadata', 'allowed-tools'].includes(key), key); + for (const value of Object.values(meta.metadata)) + assert.equal(typeof value, 'string'); + } +}); diff --git a/resources/skills/agent-team-creator/tests/fixture.mjs b/resources/skills/agent-team-creator/tests/fixture.mjs new file mode 100644 index 00000000000..e25d472c42f --- /dev/null +++ b/resources/skills/agent-team-creator/tests/fixture.mjs @@ -0,0 +1,43 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { spawn, spawnSync } from 'node:child_process'; +import assert from 'node:assert/strict'; +import { randomUUID } from 'node:crypto'; +export const skillRoot = fileURLToPath(new URL('../', import.meta.url)); +export function team() { + return { schemaVersion: 1, id: 'example', outcome: 'Deliver a verified feature', scope: 'project', harness: 'codex', + roles: ['implementer', 'reviewer'].map(id => ({ id, description: `${id} responsibility`, prompt: 'Read instructions. Report evidence.', skills: [], owns: id === 'implementer' ? ['src/feature/'] : [], inputs: ['Requirements'], outputs: ['Evidence'], dependsOn: [] })) }; +} +export function fixture() { + const scratch = path.resolve('.scratch'); + fs.mkdirSync(scratch, { recursive: true }); + const root = fs.mkdtempSync(path.join(scratch, 'team-cli-')); + const scripts = path.join(root, 'copied skill', 'scripts'); + fs.cpSync(path.join(skillRoot, 'scripts'), scripts, { recursive: true }); + const cli = path.join(scripts, 'cli.mjs'); + const state = path.join(root, 'state.json'); + function request(input) { + const file = path.join(root, `request-${randomUUID()}.json`); + fs.writeFileSync(file, JSON.stringify(input)); + return file; + } + function call(command, input, expected = 0, env = process.env) { + const result = spawnSync(process.execPath, [cli, command, '--input', request(input)], { cwd: root, env, encoding: 'utf8', shell: false, maxBuffer: 20 * 1024 * 1024 }); + assert.equal(result.status, expected, `${command}: ${result.stderr}\n${result.stdout}`); + return JSON.parse(expected === 0 ? result.stdout : result.stderr); + } + function concurrent(command, input) { + return new Promise((resolve, reject) => { + const child = spawn(process.execPath, [cli, command, '--input', request(input)], { cwd: root, shell: false, stdio: ['ignore', 'pipe', 'pipe'] }); + let stdout = '', stderr = ''; + child.stdout.on('data', b => stdout += b); + child.stderr.on('data', b => stderr += b); + child.on('error', reject); + child.on('exit', status => resolve({ status, stdout, stderr })); + }); + } + return { root, cli, state, call, concurrent, + read: () => JSON.parse(fs.readFileSync(state, 'utf8')), + close: () => fs.rmSync(root, { recursive: true, force: true }) }; +} diff --git a/resources/skills/agent-team-creator/tests/kbd.integration.mjs b/resources/skills/agent-team-creator/tests/kbd.integration.mjs new file mode 100644 index 00000000000..1460f35939c --- /dev/null +++ b/resources/skills/agent-team-creator/tests/kbd.integration.mjs @@ -0,0 +1,148 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import { createHash, generateKeyPairSync } from 'node:crypto'; +import { spawnSync } from 'node:child_process'; +import { fixture, team } from './fixture.mjs'; +const binary = process.env.PROMETHEUS_CLI_TEST_BINARY; +const skip = !binary ? 'PROMETHEUS_CLI_TEST_BINARY is required: canonical KBD integration is unverified without the real CLI' : false; +function canonical(f) { + assert.ok(binary && path.isAbsolute(binary), 'PROMETHEUS_CLI_TEST_BINARY must name the actual absolute CLI path'); + const root = path.join(f.root, 'canonical-project'); + const data = path.join(f.root, 'canonical-data'); + // Discovery walks ancestors. Establish the boundary before the first CLI call. + fs.mkdirSync(path.join(root, '.kbd-orchestrator'), { recursive: true }); + fs.mkdirSync(data, { recursive: true }); + const { privateKey, publicKey } = generateKeyPairSync('ed25519'); + const privateJwk = privateKey.export({ format: 'jwk' }); + const publicJwk = publicKey.export({ format: 'jwk' }); + assert.ok(privateJwk.d && publicJwk.x); + const keyFile = path.join(f.root, 'canonical-device-key.json'); + fs.writeFileSync(keyFile, JSON.stringify({ schemaVersion: '1', + keyId: `ed25519:${createHash('sha256').update(Buffer.from(publicJwk.x, 'base64url')).digest('hex')}`, + privateKey: Buffer.from(privateJwk.d, 'base64url').toString('base64'), + }), { mode: 0o600 }); + const env = { ...process.env, PROMETHEUS_DATA_DIR: data, PROMETHEUS_DEVICE_KEY_FILE: keyFile, + PROMETHEUS_KBD_CONTROL_PLANE: '0', PROMETHEUS_CONTROL_ENDPOINT: 'http://127.0.0.1:1', + PROMETHEUS_HARNESS: 'agent-team-integration', OPENSPEC_TELEMETRY: '0', DO_NOT_TRACK: '1' }; + const cli = (...args) => { + const result = spawnSync(binary, ['kbd', '--path', root, ...args], { + cwd: root, env, encoding: 'utf8', shell: false, timeout: 60_000, maxBuffer: 16 * 1024 * 1024, + }); + assert.equal(result.error, undefined, result.error?.message); + assert.equal(result.status, 0, `${args.join(' ')}\n${result.stderr}\n${result.stdout}`); + return JSON.parse(result.stdout); + }; + const initial = cli('status', '--json'); + assert.equal(initial.runtimeInitialized, false); + assert.ok(initial.runtimePath.startsWith(data + path.sep), 'canonical storage must remain inside the fixture'); + const registry = cli('projects', '--json'); + const registered = Object.keys(registry.replicas); + assert.equal(registered.length, 1, 'isolated registry must contain only the test checkout'); + assert.equal(fs.realpathSync(registered[0]), fs.realpathSync(root), 'discovery must stop at the fixture marker'); + const manifest = JSON.parse(fs.readFileSync(path.join(root, '.prometheus', 'project.json'), 'utf8')); + assert.equal(registry.replicas[registered[0]].projectId, manifest.projectId); + cli('phase', 'create', '--command-id', 'team-phase-create', '--id', 'phase-id', '--slug', 'phase-slug', '--title', 'Team fixture phase'); + cli('phase', 'activate', '--command-id', 'team-phase-activate', '--id', 'phase-id'); + cli('change', 'register', '--command-id', 'team-change-register', '--phase', 'phase-id', '--id', 'change-id', '--title', 'Team fixture change'); + cli('task', 'register', '--command-id', 'team-task-register', '--phase', 'phase-id', '--change', 'change-id', '--id', 'task-id', '--title', 'Team fixture task'); + cli('task', 'transition', '--command-id', 'team-task-start', '--phase', 'phase-id', '--change', 'change-id', '--id', 'task-id', '--status', 'in-progress'); + const status = () => cli('status', '--json'); + const state = status(); + assert.equal(state.projectId, manifest.projectId); + assert.equal(state.phases['phase-id'].changes['change-id'].tasks['task-id'].status, 'in_progress'); + const identity = { projectId: state.projectId, runId: state.runId, phaseId: 'phase-id', changeId: 'change-id', taskId: 'task-id' }; + return { root, data, env, cli, status, identity }; +} +function linkedTask(f, identity, id = 'linked') { + f.call('task', { state: f.state, expectedRevision: f.read().revision, + task: { action: 'add', id, title: 'Linked canonical work', owner: 'implementer', kbd: identity } }); + f.call('task', { state: f.state, expectedRevision: f.read().revision, + task: { action: 'start', id, owner: 'implementer', expectedTaskRevision: 0 } }); +} +function completionRequest(f, root, id = 'linked') { + const state = f.read(), task = state.tasks.find((item) => item.id === id); + return { state: f.state, expectedRevision: state.revision, cwd: root, + task: { id, owner: task.owner, expectedTaskRevision: task.revision, + kbdCli: binary, evidence: ['evidence/actual-result.md'], remaining: [] } }; +} +test('packaged linked completion commits through the real canonical CLI and records its returned identity', { skip, timeout: 180_000 }, t => { + const f = fixture(); + t.after(f.close); + const kbd = canonical(f); + f.call('init', { state: f.state, team: team() }); + linkedTask(f, kbd.identity); + fs.mkdirSync(path.join(kbd.root, 'evidence'), { recursive: true }); + fs.writeFileSync(path.join(kbd.root, 'evidence', 'actual-result.md'), 'Integration evidence: the real canonical CLI confirms this task transition.\n'); + const before = kbd.status(); + const localBefore = fs.readFileSync(f.state); + const request = completionRequest(f, kbd.root); + assert.match(f.call('task', { state: f.state, expectedRevision: request.expectedRevision, + task: { ...request.task, action: 'complete' } }, 1, kbd.env).error, /canonical CLI receipt/); + assert.deepEqual(fs.readFileSync(f.state), localBefore); + assert.equal(kbd.status().revision, before.revision, 'ordinary local completion cannot change canonical work'); + const local = f.call('complete-kbd', request, 0, kbd.env); + const after = kbd.status(); + const nativeTask = after.phases['phase-id'].changes['change-id'].tasks['task-id']; + assert.equal(nativeTask.status, 'complete'); + assert.match(nativeTask.summary, /evidence\/actual-result\.md/); + assert.ok(after.revision > before.revision); + assert.equal(local.tasks[0].status, 'complete'); + assert.equal(local.tasks[0].revision, 2); + const receipt = local.events.at(-1); + assert.equal(receipt.kind, 'kbd.task.completed'); + assert.equal(receipt.detail.mode, 'transition-committed'); + assert.deepEqual(receipt.detail.kbd, kbd.identity); + assert.equal(receipt.detail.canonicalRevision, after.revision); + assert.equal(receipt.detail.canonicalEventId, after.lastEventId); + assert.equal(receipt.detail.canonicalTaskStatus, 'complete'); + assert.equal(after.commandRevisions[receipt.detail.commandId], after.revision); + assert.match(receipt.detail.receiptSha256, /^[a-f0-9]{64}$/); + assert.deepEqual(receipt.detail.argv.slice(0, 5), ['kbd', '--path', kbd.root, 'task', 'transition']); + assert.equal(local.events.some((event) => /boundary|karpathy/.test(event.kind)), false); + const committed = fs.readFileSync(f.state); + const retry = completionRequest(f, kbd.root); + assert.match(f.call('complete-kbd', retry, 1, kbd.env).error, /terminal/); + assert.deepEqual(fs.readFileSync(f.state), committed); + assert.equal(kbd.status().revision, after.revision); +}); +test('each wrong canonical identity is rejected without canonical or local completion', { skip, timeout: 180_000 }, t => { + const f = fixture(); + t.after(f.close); + const kbd = canonical(f); + f.call('init', { state: f.state, team: team() }); + for (const field of ['projectId', 'runId', 'phaseId', 'changeId', 'taskId']) { + const id = `wrong-${field.toLowerCase()}`; + linkedTask(f, { ...kbd.identity, [field]: `wrong-${field}` }, id); + const before = kbd.status(), local = fs.readFileSync(f.state); + const failure = f.call('complete-kbd', completionRequest(f, kbd.root, id), 1, kbd.env); + assert.match(failure.error, /canonical|identity/i); + assert.deepEqual(fs.readFileSync(f.state), local); + const after = kbd.status(); + assert.equal(after.revision, before.revision); + assert.equal(after.phases['phase-id'].changes['change-id'].tasks['task-id'].status, 'in_progress'); + assert.equal(fs.existsSync(`${f.state}.lock`), false); + } + assert.equal(f.read().events.some((event) => event.kind === 'kbd.task.completed'), false); +}); +test('a real prior canonical completion reconciles local state without submitting another transition', { skip, timeout: 180_000 }, t => { + const f = fixture(); + t.after(f.close); + const kbd = canonical(f); + f.call('init', { state: f.state, team: team() }); + linkedTask(f, kbd.identity); + kbd.cli('task', 'transition', '--command-id', 'complete-before-local-receipt', '--phase', 'phase-id', '--change', 'change-id', '--id', 'task-id', '--status', 'complete', '--summary', 'Canonical completion occurred before local receipt'); + const before = kbd.status(); + const local = f.call('complete-kbd', completionRequest(f, kbd.root), 0, kbd.env); + const after = kbd.status(); + assert.equal(after.revision, before.revision); + assert.equal(after.lastEventId, before.lastEventId); + assert.equal(local.tasks[0].status, 'complete'); + const receipt = local.events.at(-1).detail; + assert.equal(receipt.mode, 'reconciled-existing-completion'); + assert.deepEqual(receipt.argv, ['kbd', '--path', kbd.root, 'status', '--json']); + assert.deepEqual(receipt.kbd, kbd.identity); + assert.equal(receipt.canonicalRevision, before.revision); + assert.equal(after.commandRevisions[receipt.commandId], undefined, 'reconciliation must not invent a canonical command'); +}); diff --git a/resources/skills/agent-team-creator/tests/models-memory.integration.mjs b/resources/skills/agent-team-creator/tests/models-memory.integration.mjs new file mode 100644 index 00000000000..c3b8e0524ed --- /dev/null +++ b/resources/skills/agent-team-creator/tests/models-memory.integration.mjs @@ -0,0 +1,242 @@ +// Packaged CLI processes exercise real JSON input, policy adaptation and durable files. +// Catalogs below are explicit operator fixtures, not fabricated discovery responses. +// No fake servers or live memory writes. Successful remote publication is unverified. +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import net from 'node:net'; +import { randomUUID } from 'node:crypto'; +import { fixture, team } from './fixture.mjs'; +const capabilities = { function_calling: true, vision: true, reasoning: true, structured_output: true }; +function bundle(ids = ['alpha', 'beta']) { + return { + catalog: { + $schema_version: 1, + $provenance: { source: 'operator integration fixture', source_sha256: 'fixture-not-a-source-attestation', fetched: '2020-01-01', library_version: 'fixture' }, + providers: { fixture: { models: { + priced: { id: 'priced', capabilities, pricing: { input_cost_per_token: 0.000001, output_cost_per_token: 0.000002 } }, + } } }, + }, + availableModels: ids, + aliases: Object.fromEntries(ids.map(id => [id, { provider: 'fixture', model: 'priced' }])), + tiers: Object.fromEntries(ids.map(id => [id, 'medium'])), + maxCatalogAgeDays: 30, + }; +} +const entry = () => ({ content: 'Keep review evidence separate from implementation claims.', scope: 'team:example', provenance: { evidence: ['review-note'], task: 'documentation' } }); +const stateBytes = (f) => fs.readFileSync(f.state); +function initialize(f) { + f.call('init', { state: f.state, team: team() }); + return f.call('memory-queue', { state: f.state, expectedRevision: 0, entry: entry() }); +} +async function closedPort() { + // Reserve an ephemeral address, then close it before the CLI request. This + // server never handles a request and never supplies a fabricated API response. + const server = net.createServer(); + await new Promise((resolve, reject) => { server.once('error', reject); server.listen(0, '127.0.0.1', resolve); }); + const address = server.address(); + assert.ok(address && typeof address !== 'string'); + await new Promise((resolve, reject) => server.close(error => error ? reject(error) : resolve())); + return address.port; +} +test('packaged model selection resolves every layer and ANDs capabilities', t => { + const f = fixture(); + t.after(f.close); + const base = team(); + const configured = { + ...base, + modelPolicy: { model: 'team-choice', tier: 'low', maxInputPerMillion: 8, capabilities: ['function_calling'] }, + roles: base.roles.map(role => ({ ...role, modelPolicy: { model: 'role-choice', tier: 'medium', maxInputPerMillion: 6, capabilities: ['vision'] } })), + skillPolicies: { + first: { model: 'skill-first', tier: 'hard', maxInputPerMillion: 5, capabilities: ['reasoning'] }, + second: { model: 'skill-second', tier: 'low', maxInputPerMillion: 4, capabilities: ['structured_output'] }, + }, + }; + const result = f.call('models-select', { team: configured, roleId: 'implementer', skills: ['first', 'second'], + taskPolicy: { model: 'alpha', tier: 'medium', maxInputPerMillion: 3, maxOutputPerMillion: 6, capabilities: [] }, catalog: bundle() }); + assert.equal(result.selected.id, 'alpha'); + assert.deepEqual(result.appliedLayers, ['team', 'role', 'skill:first', 'skill:second', 'task']); + assert.deepEqual(result.policy, { model: 'alpha', tier: 'medium', maxInputPerMillion: 3, maxOutputPerMillion: 6, + capabilities: ['function_calling', 'vision', 'reasoning', 'structured_output'] }); + const ordered = bundle(['skill-first', 'skill-second']); + ordered.tiers = { 'skill-first': 'hard', 'skill-second': 'low' }; + for (const skills of [['first', 'second'], ['second', 'first']]) { + const selected = f.call('models-select', { team: configured, roleId: 'implementer', skills, catalog: ordered }); + assert.equal(selected.selected.id, skills[1] === 'first' ? 'skill-first' : 'skill-second'); + } +}); +test('packaged catalog adaptation converts per-token cost and makes deterministic price ties explicit', t => { + const f = fixture(); + t.after(f.close); + const request = { team: team(), roleId: 'implementer', taskPolicy: { tier: 'medium', maxInputPerMillion: 1, maxOutputPerMillion: 2 } }; + const selected = f.call('models-select', { ...request, catalog: bundle(['beta', 'alpha']) }); + const repeated = f.call('models-select', { ...request, catalog: bundle(['alpha', 'beta']) }); + assert.equal(selected.selected.id, 'alpha'); + assert.equal(repeated.selected.id, selected.selected.id); + assert.deepEqual(selected.selected.pricing, { inputPerMillion: 1, outputPerMillion: 2, currency: 'USD', basis: 'maximum-known-context-tier', sourceUnit: 'per-token' }); + assert.equal(selected.selected.catalogId, 'priced'); + assert.equal(selected.selected.provenance.availabilityBasis, 'operator-declared'); + assert.equal(selected.selected.provenance.discovery, null); + assert.equal(selected.selected.freshness.stale, true); + assert.equal(selected.catalogProvenance.source, 'operator integration fixture'); + assert.ok(selected.warnings.some((warning) => /stale.*current provider rates/.test(warning))); + assert.ok(selected.warnings.some((warning) => /operator-declared/.test(warning))); +}); +test('ceilings reject context-tier costs, unknown prices and unknown capabilities', t => { + const f = fixture(); + t.after(f.close); + const catalog = { + catalog: { $schema_version: 1, providers: { fixture: { models: { + costly: { capabilities, pricing: { input_cost_per_token: 0.000001, output_cost_per_token: 0.000002, + tiers: [{ min_context_tokens: 200000, input_cost_per_token: 0.000004, output_cost_per_token: 0.000008 }] } }, + unpriced: { capabilities }, + unknown: { pricing: { input_cost_per_token: 0, output_cost_per_token: 0 } }, + } } } }, + availableModels: ['costly', 'unpriced', 'unknown'], + aliases: Object.fromEntries(['costly', 'unpriced', 'unknown'].map(id => [id, { provider: 'fixture', model: id }])), + tiers: { costly: 'hard', unpriced: 'hard', unknown: 'hard' }, + }; + const result = f.call('models-select', { team: team(), roleId: 'implementer', taskPolicy: { tier: 'hard', capabilities: ['vision'], maxInputPerMillion: 2 }, catalog }); + assert.equal(result.selected, null); + const reasons = (id) => result.rejected.find((row) => row.id === id).reasons; + assert.ok(reasons('costly').includes('price exceeds ceiling')); + assert.ok(reasons('unpriced').includes('price unknown; cannot satisfy ceiling')); + assert.ok(reasons('unknown').includes('capability vision unsupported or unknown')); + const allowedUnknown = f.call('models-select', { team: team(), roleId: 'implementer', taskPolicy: { model: 'unpriced', tier: 'hard', capabilities: ['vision'] }, catalog }); + assert.equal(allowedUnknown.selected.pricing.inputPerMillion, null); + assert.ok(allowedUnknown.warnings.some((warning) => /freshness is unknown/.test(warning))); +}); +test('names never infer tiers or aliases, and a static catalog never proves availability', t => { + const f = fixture(); + t.after(f.close); + const catalog = bundle(['ultra-hard-model']); + catalog.tiers = {}; + const noTier = f.call('models-select', { team: team(), roleId: 'implementer', taskPolicy: { tier: 'hard' }, catalog }); + assert.equal(noTier.selected, null); + assert.ok(noTier.rejected[0].reasons.includes('declared tier missing or different')); + catalog.tiers = { 'ultra-hard-model': 'low' }; + assert.equal(f.call('models-select', { team: team(), roleId: 'implementer', taskPolicy: { tier: 'low' }, catalog }).selected.id, 'ultra-hard-model'); + const noAlias = { ...bundle(['priced']), aliases: {} }; + const result = f.call('models-select', { team: team(), roleId: 'implementer', taskPolicy: { maxInputPerMillion: 10 }, catalog: noAlias }); + assert.equal(result.selected, null, 'identical catalog model name does not imply an alias mapping'); + const noAvailability = f.call('models-select', { team: team(), roleId: 'implementer', catalog: { catalog: bundle().catalog } }); + assert.equal(noAvailability.selected, null); +}); +test('packaged CLI rejects invalid catalog mappings and task policies explicitly', t => { + const f = fixture(); + t.after(f.close); + const request = { team: team(), roleId: 'implementer' }; + const invalid = bundle(); + invalid.aliases.alpha = { provider: 'fixture', model: 'absent' }; + assert.match(f.call('models-select', { ...request, catalog: invalid }, 1).error, /mapping.*catalog model/); + const version = bundle(); + version.catalog.$schema_version = 2; + assert.match(f.call('models-select', { ...request, catalog: version }, 1).error, /schema_version 1/); + assert.match(f.call('models-select', { ...request, taskPolicy: { guessedTier: 'hard' }, catalog: bundle() }, 1).error, /Unknown.*modelPolicy/); + assert.match(f.call('models-select', { ...request, taskPolicy: { maxInputPerMillion: -1 }, catalog: bundle() }, 1).error, /Invalid.*maxInputPerMillion/); +}); +test('discovery refuses literal credentials, unsafe URLs and invalid environment references before any network request', t => { + const f = fixture(); + t.after(f.close); + const base = { kind: 'openai', baseUrl: 'http://127.0.0.1:1' }; + const errors = [ + f.call('models-discover', { ...base, auth: { apiKey: 'synthetic-placeholder' } }, 1), + f.call('models-discover', { ...base, auth: { env: 'INVALID-NAME' } }, 1), + f.call('models-discover', { ...base, auth: { env: 'TEAM_INTEGRATION_ABSENT_CREDENTIAL' } }, 1, { ...process.env, TEAM_INTEGRATION_ABSENT_CREDENTIAL: '' }), + f.call('models-discover', { kind: 'openai', discoveryUrl: 'https://user:synthetic-placeholder@example.invalid/v1/models' }, 1), + f.call('models-discover', { kind: 'openai', discoveryUrl: 'https://example.invalid/v1/models?api_key=synthetic-placeholder' }, 1), + ]; + assert.match(errors[0].error, /credential fields/); + assert.match(errors[1].error, /invalid_auth_environment_reference/); + assert.match(errors[2].error, /credential_environment_unavailable/); + assert.match(errors[3].error, /without userinfo/); + assert.match(errors[4].error, /URL credentials/); + assert.equal(JSON.stringify(errors).includes('synthetic-placeholder'), false); +}); +test('memory queue is durable across CLI restarts, idempotent, scoped, and refuses identity conflicts', t => { + const f = fixture(); + t.after(f.close); + const queued = initialize(f); + assert.equal(queued.revision, 1); + const id = queued.outbox[0].id; + const before = stateBytes(f); + const restarted = f.call('status', { state: f.state }); + assert.equal(restarted.outbox[0].scope, 'team:example'); + assert.equal(restarted.outbox[0].provenance.teamId, 'example'); + assert.match(restarted.outbox[0].provenance.authority, /unverified mirrors/); + const repeated = f.call('memory-queue', { state: f.state, expectedRevision: 1, + entry: { ...entry(), provenance: { task: 'documentation', evidence: ['review-note'] } } }); + assert.equal(repeated.revision, 1); + assert.equal(repeated.outbox.length, 1); + assert.equal(repeated.outbox[0].id, id); + assert.deepEqual(stateBytes(f), before); + for (const changed of [{ ...entry(), id, scope: 'team:another' }, { ...entry(), id, content: 'Different content' }]) { + assert.match(f.call('memory-queue', { state: f.state, expectedRevision: 1, entry: changed }, 1).error, /conflicts/); + assert.deepEqual(stateBytes(f), before); + } + const independent = f.call('memory-queue', { state: f.state, expectedRevision: 1, entry: { ...entry(), scope: 'team:another' } }); + assert.equal(independent.outbox.length, 2); + assert.notEqual(independent.outbox[1].id, id); +}); +test('no memory service preserves queued content and durable unavailable receipt', t => { + const f = fixture(); + t.after(f.close); + const queued = initialize(f), id = queued.outbox[0].id; + const result = f.call('memory-publish', { state: f.state, expectedRevision: 1, publication: { id } }); + assert.equal(result.publication.status, 'queued'); + assert.equal(result.publication.receipt.uncertain, false); + assert.match(result.publication.receipt.reason, /no memory endpoint/); + const restarted = f.call('status', { state: f.state }); + assert.equal(restarted.outbox[0].content, entry().content); + assert.deepEqual(restarted.outbox[0].receipt, result.publication.receipt); + assert.equal(restarted.outbox[0].status, 'queued'); + assert.deepEqual(restarted.events, [], 'memory events must not fabricate canonical KBD boundaries'); +}); +test('closed real loopback endpoint preserves durable uncertainty and requires explicit retry', async (t) => { + const f = fixture(); + t.after(f.close); + const queued = initialize(f), id = queued.outbox[0].id; + const port = await closedPort(); + const publication = { id, provider: 'surreal-memory', url: `http://127.0.0.1:${port}/api/v1/memory/`, timeoutMs: 1000, + scopeMapping: { scope: 'team:example', agentId: 'fixture-agent', userId: 'anonymous' } }; + const failed = f.call('memory-publish', { state: f.state, expectedRevision: 1, publication }); + assert.equal(failed.publication.status, 'queued'); + assert.equal(failed.publication.receipt.uncertain, true, 'transport failures are conservatively uncertain'); + assert.equal(failed.publication.receipt.exactlyOnce, false); + assert.equal(failed.publication.receipt.reason, 'transport_unavailable_or_redirect_refused'); + assert.equal(failed.publication.receipt.target.contract.remoteIdempotency, 'unsupported-by-verified-contract'); + const restarted = f.call('status', { state: f.state }); + assert.deepEqual(restarted.outbox[0].receipt, failed.publication.receipt); + const before = stateBytes(f); + const deferred = f.call('memory-publish', { state: f.state, expectedRevision: restarted.revision, publication }); + assert.match(deferred.publication.reason, /reconcile/); + assert.deepEqual(stateBytes(f), before, 'deferred retry must retain exact receipt and revision'); + const conflict = f.call('memory-publish', { state: f.state, expectedRevision: restarted.revision, + publication: { ...publication, retryUncertain: true, scopeMapping: { ...publication.scopeMapping, agentId: 'different-agent' } } }, 1); + assert.match(conflict.error, /mapping differs/); + assert.deepEqual(stateBytes(f), before); + const retried = f.call('memory-publish', { state: f.state, expectedRevision: restarted.revision, publication: { ...publication, retryUncertain: true } }); + assert.equal(retried.publication.status, 'queued'); + assert.equal(retried.publication.receipt.publicationKey, failed.publication.receipt.publicationKey); + assert.equal(retried.state.outbox.length, 1); + assert.equal(retried.state.outbox[0].content, entry().content); +}); +test('invalid memory scope mapping, unsupported KBD reference and credentials cannot change durable state', t => { + const f = fixture(); + t.after(f.close); + const queued = initialize(f), id = queued.outbox[0].id; + const before = stateBytes(f); + assert.match(f.call('memory-publish', { state: f.state, expectedRevision: 1, publication: { + id, provider: 'surreal-memory', url: 'http://127.0.0.1:1/api/v1/memory/', scopeMapping: { scope: 'team:wrong', agentId: 'fixture-agent' }, + } }, 1).error, /match.*scope/); + assert.deepEqual(stateBytes(f), before); + assert.match(f.call('memory-queue', { state: f.state, expectedRevision: 1, entry: { ...entry(), provenance: { kbd: { + projectId: 'project', runId: 'run', phaseId: 'phase', changeId: 'change', taskId: 'task', + } } } }, 1).error, /linked team task/); + assert.deepEqual(stateBytes(f), before); + const secret = `synthetic-${randomUUID()}`; + const rejected = f.call('memory-queue', { state: f.state, expectedRevision: 1, entry: { ...entry(), content: secret } }, 1, { ...process.env, TEAM_INTEGRATION_API_KEY: secret }); + assert.match(rejected.error, /credential values/); + assert.equal(JSON.stringify(rejected).includes(secret), false); + assert.deepEqual(stateBytes(f), before); +}); diff --git a/resources/skills/agent-team-creator/tests/state.integration.mjs b/resources/skills/agent-team-creator/tests/state.integration.mjs new file mode 100644 index 00000000000..41edd3daeab --- /dev/null +++ b/resources/skills/agent-team-creator/tests/state.integration.mjs @@ -0,0 +1,224 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import { spawnSync } from 'node:child_process'; +import { fixture, team } from './fixture.mjs'; +const bytes = (f) => fs.readFileSync(f.state); +function add(f, id, extra = {}) { + return f.call('task', { state: f.state, expectedRevision: f.read().revision, + task: { action: 'add', id, title: `Deliver ${id}`, owner: 'implementer', ...extra } }); +} +function update(f, id, action, extra = {}) { + const state = f.read(), task = state.tasks.find((item) => item.id === id); + assert.ok(task); + return f.call('task', { state: f.state, expectedRevision: state.revision, + task: { action, id, owner: task.owner, expectedTaskRevision: task.revision, ...extra } }); +} +function refused(f, command, input, reason) { + const before = bytes(f); + const failure = f.call(command, input, 1); + assert.match(failure.error, reason); + assert.deepEqual(bytes(f), before, 'rejected CLI command must preserve exact state bytes'); + assert.equal(fs.existsSync(`${f.state}.lock`), false, 'failed command must release its own lock'); +} +function handoff(f, taskId, cwd) { + const state = f.read(), task = state.tasks.find((item) => item.id === taskId); + const next = f.call('handoff-create', { state: f.state, expectedRevision: state.revision, cwd, + handoff: { taskId, owner: task.owner, expectedTaskRevision: task.revision, + toOwner: 'reviewer', toHarness: 'claude', context: 'Review the actual patch and finish the task.', + evidence: ['evidence.md'], remaining: ['Review the patch'], memoryRefs: ['memory/decision.md'] } }); + return next.handoffs.at(-1); +} +test('packaged CLI preserves owner/revision boundaries through dependencies, blocking, reassignment and completion', t => { + const f = fixture(); + t.after(f.close); + assert.equal(f.call('init', { state: f.state, team: team() }).revision, 0); + add(f, 'implementation'); + add(f, 'verification', { owner: 'reviewer', dependsOn: ['implementation'] }); + const initial = f.read(); + refused(f, 'task', { state: f.state, expectedRevision: initial.revision, + task: { action: 'start', id: 'verification', owner: 'reviewer', expectedTaskRevision: 0 } }, /Dependency implementation is not complete/); + refused(f, 'task', { state: f.state, expectedRevision: initial.revision, + task: { action: 'start', id: 'implementation', owner: 'reviewer', expectedTaskRevision: 0 } }, /belongs to implementer/); + refused(f, 'task', { state: f.state, expectedRevision: initial.revision, + task: { action: 'start', id: 'implementation', owner: 'implementer' } }, /expectedTaskRevision/); + update(f, 'implementation', 'start'); + refused(f, 'task', { state: f.state, expectedRevision: initial.revision, + task: { action: 'block', id: 'implementation', owner: 'implementer', expectedTaskRevision: 1, reason: 'Stale writer' } }, /State revision conflict/); + refused(f, 'task', { state: f.state, expectedRevision: f.read().revision, + task: { action: 'block', id: 'implementation', owner: 'implementer', expectedTaskRevision: 0, reason: 'Stale task' } }, /Task revision conflict/); + let state = update(f, 'implementation', 'block', { reason: 'Need a decision', evidence: ['discussion.md'] }); + assert.equal(state.tasks[0].status, 'blocked'); + assert.deepEqual(state.tasks[0].remaining, ['Need a decision']); + update(f, 'implementation', 'start'); + refused(f, 'task', { state: f.state, expectedRevision: f.read().revision, + task: { action: 'complete', id: 'implementation', owner: 'implementer', expectedTaskRevision: 3, evidence: ['proof.md'] } }, /no remaining work/); + state = update(f, 'implementation', 'reassign', { toOwner: 'reviewer', toHarness: 'claude' }); + assert.equal(state.tasks[0].status, 'pending'); + assert.equal(state.tasks[0].owner, 'reviewer'); + assert.equal(state.tasks[0].harness, 'claude'); + assert.equal(state.tasks[0].revision, 4); + refused(f, 'task', { state: f.state, expectedRevision: state.revision, + task: { action: 'start', id: 'implementation', owner: 'implementer', expectedTaskRevision: 4 } }, /belongs to reviewer/); + update(f, 'implementation', 'start', { remaining: [] }); + state = update(f, 'implementation', 'complete', { evidence: ['proof.md'], remaining: [] }); + assert.equal(state.tasks[0].revision, 6); + assert.equal(state.tasks[0].status, 'complete'); + assert.deepEqual(state.tasks[0].evidence, ['discussion.md', 'proof.md']); + refused(f, 'task', { state: f.state, expectedRevision: state.revision, + task: { action: 'reassign', id: 'implementation', owner: 'reviewer', expectedTaskRevision: 6, toOwner: 'implementer' } }, /terminal/); + update(f, 'verification', 'start'); + refused(f, 'task', { state: f.state, expectedRevision: f.read().revision, + task: { action: 'complete', id: 'verification', owner: 'reviewer', expectedTaskRevision: 1, evidence: [], remaining: [] } }, /requires evidence/); + state = update(f, 'verification', 'complete', { evidence: ['review.md'], remaining: [] }); + assert.equal(state.tasks[1].status, 'complete'); + assert.equal(state.events.length, state.revision); + assert.deepEqual(f.call('status', { state: f.state }), state, 'a new CLI process reads committed state'); +}); +test('cancelled dependencies stay unsatisfied and invalid additions never persist', t => { + const f = fixture(); + t.after(f.close); + f.call('init', { state: f.state, team: team() }); + for (const extra of [{ dependsOn: ['missing'] }, { dependsOn: ['invalid'] }, { owner: 'absent' }, { harness: 'bossfang' }]) { + refused(f, 'task', { state: f.state, expectedRevision: 0, + task: { action: 'add', id: 'invalid', title: 'Invalid task', owner: 'implementer', ...extra } }, /dependency|role|harness/i); + } + add(f, 'parent'); + add(f, 'child', { dependsOn: ['parent'] }); + update(f, 'parent', 'cancel', { reason: 'Withdrawn scope' }); + const state = f.read(); + assert.equal(state.tasks[0].status, 'cancelled'); + assert.equal(state.events.at(-1).detail.reason, 'Withdrawn scope'); + refused(f, 'task', { state: f.state, expectedRevision: state.revision, + task: { action: 'start', id: 'parent', owner: 'implementer', expectedTaskRevision: 1 } }, /terminal/); + refused(f, 'task', { state: f.state, expectedRevision: state.revision, + task: { action: 'start', id: 'child', owner: 'implementer', expectedTaskRevision: 0 } }, /Dependency parent is not complete/); + // Malformed persisted input crosses the real CLI read boundary, not a module seam. + const original = bytes(f); + state.tasks[0].dependsOn = ['child']; + fs.writeFileSync(f.state, JSON.stringify(state)); + const invalid = bytes(f); + assert.match(f.call('status', { state: f.state }, 1).error, /dependency cycle/i); + assert.deepEqual(bytes(f), invalid); + fs.writeFileSync(f.state, original); + assert.equal(f.call('status', { state: f.state }).tasks[0].status, 'cancelled'); +}); +test('simultaneous packaged writers have one winner and preserve the other task on revision-aware retry', async (t) => { + const f = fixture(); + t.after(f.close); + f.call('init', { state: f.state, team: team() }); + const request = (id, revision) => ({ state: f.state, expectedRevision: revision, + task: { action: 'add', id, title: id, owner: 'implementer' } }); + const results = await Promise.all(['first', 'second'].map(id => f.concurrent('task', request(id, 0)))); + assert.deepEqual(results.map(result => result.status).sort(), [0, 1]); + const rejected = results.find(result => result.status === 1); + assert.match(JSON.parse(rejected.stderr).error, /lock held|revision conflict/i); + const winner = f.read(); + assert.equal(winner.revision, 1); + assert.equal(winner.tasks.length, 1); + assert.equal(winner.events.length, 1); + const missing = winner.tasks[0].id === 'first' ? 'second' : 'first'; + const final = f.call('task', request(missing, winner.revision)); + assert.equal(final.revision, 2); + assert.deepEqual(final.tasks.map((task) => task.id).sort(), ['first', 'second']); + assert.equal(fs.existsSync(`${f.state}.lock`), false); + assert.equal(fs.readdirSync(f.root).some(file => file.endsWith('.tmp')), false); +}); +test('an existing real filesystem lock is never stolen and team replacement preserves history references', t => { + const f = fixture(); + t.after(f.close); + f.call('init', { state: f.state, team: team() }); + const lock = `${f.state}.lock`, content = JSON.stringify({ pid: process.pid, at: '2000-01-01T00:00:00Z', token: 'held-by-integration' }); + fs.writeFileSync(lock, content, { flag: 'wx' }); + const before = bytes(f); + assert.match(f.call('task', { state: f.state, expectedRevision: 0, + task: { action: 'add', id: 'blocked', title: 'Blocked writer', owner: 'implementer' } }, 1).error, /lock held/i); + assert.equal(fs.readFileSync(lock, 'utf8'), content); + assert.deepEqual(bytes(f), before); + fs.rmSync(lock); + add(f, 'retained'); + handoff(f, 'retained', path.join(f.root, 'missing-directory')); + const replacement = team(); + replacement.outcome = 'Revised explicit outcome'; + f.call('team-update', { state: f.state, expectedRevision: f.read().revision, team: replacement }); + const removed = { ...replacement, roles: replacement.roles.filter(role => role.id !== 'reviewer') }; + refused(f, 'team-update', { state: f.state, expectedRevision: f.read().revision, team: removed }, /Unknown team role: reviewer/); +}); +test('handoff snapshots real clean/dirty Git and transfers only on targeted revision-safe acceptance', t => { + const f = fixture(); + t.after(f.close); + const project = path.join(f.root, 'git-project'); + fs.mkdirSync(project); + const emptyConfig = path.join(f.root, 'empty-git-config'); + fs.writeFileSync(emptyConfig, ''); + const env = { ...process.env, GIT_CONFIG_GLOBAL: emptyConfig, GIT_CONFIG_NOSYSTEM: '1' }; + const git = (...args) => { + const result = spawnSync('git', ['-C', project, ...args], { env, encoding: 'utf8', shell: false, timeout: 15_000 }); + assert.equal(result.error, undefined); + assert.equal(result.status, 0, result.stderr); + return result.stdout.trim(); + }; + git('init'); + git('symbolic-ref', 'HEAD', 'refs/heads/handoff-integration'); + git('config', 'user.name', 'Integration Fixture'); + git('config', 'user.email', 'fixture@example.invalid'); + fs.writeFileSync(path.join(project, 'evidence.md'), 'Initial evidence\n'); + git('add', 'evidence.md'); + git('commit', '-m', 'Initial fixture evidence'); + const head = git('rev-parse', 'HEAD'); + f.call('init', { state: f.state, team: team() }); + add(f, 'transfer', { evidence: ['prior-evidence.md'], remaining: ['Keep the original blocker'] }); + update(f, 'transfer', 'start'); + const clean = handoff(f, 'transfer', project); + assert.equal(clean.git.dirty, false); + fs.appendFileSync(path.join(project, 'evidence.md'), 'Uncommitted evidence\n'); + const packet = handoff(f, 'transfer', project); + assert.equal(packet.git.head, head); + assert.equal(packet.git.branch, 'handoff-integration'); + assert.equal(fs.realpathSync(packet.git.root), fs.realpathSync(project)); + assert.equal(packet.git.dirty, true); + assert.equal(packet.taskRevision, 1); + assert.deepEqual(packet.from, { owner: 'implementer', harness: 'codex' }); + assert.deepEqual(packet.to, { owner: 'reviewer', harness: 'claude' }); + assert.deepEqual(packet.evidence, ['prior-evidence.md', 'evidence.md']); + assert.deepEqual(packet.remaining, ['Keep the original blocker', 'Review the patch']); + assert.deepEqual(packet.memoryRefs, ['memory/decision.md']); + assert.match(packet.prompt, /permissions do not transfer/); + assert.equal(f.read().tasks[0].owner, 'implementer'); + assert.equal(f.read().tasks[0].revision, 1); + const request = { state: f.state, expectedRevision: f.read().revision, id: packet.id, + destination: { owner: 'reviewer', harness: 'claude' } }; + refused(f, 'handoff-accept', { ...request, destination: { owner: 'reviewer', harness: 'codex' } }, /targeted destination/); + const accepted = f.call('handoff-accept', request); + assert.equal(accepted.tasks[0].owner, 'reviewer'); + assert.equal(accepted.tasks[0].harness, 'claude'); + assert.equal(accepted.tasks[0].status, 'pending'); + assert.equal(accepted.tasks[0].revision, 2); + const receipt = accepted.handoffs.find((entry) => entry.id === packet.id); + assert.ok(Number.isFinite(Date.parse(receipt.acceptedAt))); + assert.deepEqual({ ...receipt, acceptedAt: undefined }, { ...packet, acceptedAt: undefined }); + const acceptedBytes = bytes(f); + f.call('handoff-accept', { ...request, expectedRevision: accepted.revision }); + assert.deepEqual(bytes(f), acceptedBytes, 'same accepted receipt must be byte-idempotent'); + refused(f, 'handoff-accept', request, /State revision conflict/); + refused(f, 'handoff-accept', { ...request, expectedRevision: accepted.revision, id: clean.id }, /belongs to reviewer|revision conflict/); + update(f, 'transfer', 'start'); + refused(f, 'handoff-accept', { ...request, expectedRevision: f.read().revision }, /stale/); +}); +test('unknown Git stays explicit and changed, reassigned or cancelled work rejects pending handoffs', t => { + const f = fixture(); + t.after(f.close); + f.call('init', { state: f.state, team: team() }); + for (const action of ['start', 'reassign', 'cancel']) { + add(f, action); + const missing = path.join(f.root, 'no-such-git-directory'); + const packet = handoff(f, action, missing); + assert.deepEqual(packet.git, { root: missing, head: null, branch: null, dirty: null }); + assert.match(packet.prompt, /dirty: unknown/); + update(f, action, action, action === 'reassign' ? { toOwner: 'reviewer' } : action === 'cancel' ? { reason: 'Withdrawn' } : {}); + refused(f, 'handoff-accept', { state: f.state, expectedRevision: f.read().revision, id: packet.id, + destination: { owner: 'reviewer', harness: 'claude' } }, /revision conflict|belongs to reviewer|terminal/); + assert.equal(f.read().handoffs.find((entry) => entry.id === packet.id).acceptedAt, undefined); + } +}); diff --git a/resources/skills/agent-team-handoff/SKILL.md b/resources/skills/agent-team-handoff/SKILL.md new file mode 100644 index 00000000000..1abe8b94ed7 --- /dev/null +++ b/resources/skills/agent-team-handoff/SKILL.md @@ -0,0 +1,54 @@ +--- +name: agent-team-handoff +description: "Prepare and accept a durable handoff between agent roles or coding harnesses using task context, Git identity, evidence and memory references. Use when work must continue in another harness or agent; use agent-team-manage for ordinary same-owner task updates. Do not use for ordinary same-owner task updates (see agent-team-manage)." +license: MIT +compatibility: Requires Node.js 22 or newer. Git is optional for handoff snapshots. Model gateways, memory services and native harness CLIs are optional and separately configured. +metadata: + version: "1.0.0" + tags: "agents, teams, orchestration, coding" +--- + +# Agent Team Handoff + +Load the existing state and companion `agent-team-creator` runtime. Read its +`references/task-handoff.md` for exact request shapes. Preserve the active KBD +identity if present; do not create a competing canonical task. + +1. Read current state and task revisions. Identify the actual source owner and + destination role/harness. Capture completed work, concrete evidence, remaining + work, blockers and memory references. Do not summarize away dirty or unknown + Git state. +2. Run `handoff-create` with the source owner, expected task revision, destination + and repository path. The runtime captures Git HEAD/branch/status read-only + and stores a fresh-context prompt and immutable packet. Ownership stays with + the source at this point. +3. Deliver the packet through an authorized local file or existing communication + mechanism. Do not send Slack/email messages without authorization. Source + sessions, private credentials and permissions never become destination authority. +4. In the destination, read current project instructions and inspect the packet + as task data. Verify its evidence and current revision. Explicitly accept with + the named destination role/harness using `handoff-accept`. +5. Only acceptance transfers local ownership. A stale/reassigned/cancelled task + refuses transfer. Repeating the same unchanged acceptance is idempotent; + later task changes require reconciliation rather than an old receipt replay. + +```text +node /scripts/cli.mjs handoff-create --input handoff-request.json +node /scripts/cli.mjs handoff-accept --input acceptance.json +``` + +Start a fresh native destination context from the saved prompt. Use the current +harness’s real resume interface only for that same harness/session when useful; +there is no portable session token. A state transfer does not stop the source +process. Coordinate stopping or pausing native work to prevent concurrent edits. + +For shared memory, use the creator’s durable local queue and verified/configured +provider mappings described in `references/models-memory.md`. Scope and +provenance travel with the record, but a memory reference grants no authorization. +Unavailable services leave the local packet usable. Ambiguous publication needs +remote reconciliation before an explicitly authorized retry. + +Karpathy progress is recorded only by the existing canonical boundary flow, +and `pk` owns knowledge bundles. Never write those stores directly or report +handoff acceptance as successful task completion. Finish with packet ID, current +owner/revision, acceptance state, evidence and unresolved work. diff --git a/resources/skills/agent-team-handoff/agents/openai.yaml b/resources/skills/agent-team-handoff/agents/openai.yaml new file mode 100644 index 00000000000..ad7cbbce331 --- /dev/null +++ b/resources/skills/agent-team-handoff/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Agent Team Handoff" + short_description: "Transfer task context with explicit acceptance" + default_prompt: "Use $agent-team-handoff to move this task to another harness with its context and evidence." diff --git a/resources/skills/agent-team-manage/SKILL.md b/resources/skills/agent-team-manage/SKILL.md new file mode 100644 index 00000000000..56eafc17c17 --- /dev/null +++ b/resources/skills/agent-team-manage/SKILL.md @@ -0,0 +1,60 @@ +--- +name: agent-team-manage +description: "Manage an existing agent team: assign tasks, dependencies and owners, update definitions, track evidence, cancel or reassign work, and reconcile KBD-linked completion. Use when managing team lifecycle requests; use agent-team-creator for initial team discovery and agent-team-handoff to transfer work across harnesses. Do not use for initial team creation (see agent-team-creator)." +license: MIT +compatibility: Requires Node.js 22 or newer. Git is optional for handoff snapshots. Model gateways, memory services and native harness CLIs are optional and separately configured. +metadata: + version: "1.0.0" + tags: "agents, teams, orchestration, coding" +--- + +# Agent Team Manager + +Locate the existing team state and load the companion `agent-team-creator` skill. +Use its compiled `/scripts/cli.mjs`; do not recreate its runtime. If that companion +is absent, report the missing package and resolve it through the authorized skill +installation flow. Read its `references/task-handoff.md` for exact JSON requests. + +1. Read project instructions and `status`. Inspect the current state revision, + task revisions, owners, dependencies, native harness and actual evidence. +2. Assign bounded tasks with deliverables and file ownership. Use native harness + tools to spawn work only when authorized. The ledger records coordination; + it does not launch agents or make a local owner string an authenticated identity. +3. Use `task` actions `add`, `start`, `block`, `complete`, `cancel`, or `reassign`. + Mutations carry `expectedRevision`; task updates also carry current `owner` + and `expectedTaskRevision`. Do not silently retry a conflict with a newer + revision—re-read and reconcile the competing work first. +4. Start work after dependencies complete. Record real evidence before completing + it. Blocking/cancellation need a reason. Completed and cancelled tasks are + terminal; create a new task for follow-up work instead of rewriting history. +5. For a KBD-linked task, preserve project/run/phase/change/task identity. Use + `complete-kbd` with the actual KBD CLI; direct local completion is refused. + Inspect canonical state after uncertain outcomes. This does not replace KBD + stage, review, archive or Karpathy boundary procedures. + +```text +node /scripts/cli.mjs status --input status-request.json +node /scripts/cli.mjs task --input task-request.json +node /scripts/cli.mjs team-update --input definition-request.json +``` + +Use `team-update` for validated role, skill, model or native settings while +preserving team identity and referenced historical roles. Re-export to a new +proposal directory afterward; a local edit does not update a resident UAR or +BossFang instance. Keep native IDs and registration receipts when applying such +updates through those services’ documented APIs. + +Use `$agent-team-handoff` for a context-bearing transfer. Ordinary reassignment +is explicit administrative intervention and invalidates older handoffs. Cancel +the corresponding native work separately when authorized; cancelling a ledger +task cannot stop a running process. + +Optional shared-memory publication uses the creator’s `memory-queue` followed by +`memory-publish`. Persist the queued record first. Follow its +`references/models-memory.md` for scope mappings, environment credentials, +failure receipts and uncertain retry handling. For Karpathy logs, invoke the +existing canonical process skill after real boundaries; `pk` remains the writer +of its knowledge bundle. Never manufacture a completion log from a team event. + +Finish with actual changes, evidence, remaining work and revision. Do not claim +native execution, policy enforcement or remote cancellation from local state. diff --git a/resources/skills/agent-team-manage/agents/openai.yaml b/resources/skills/agent-team-manage/agents/openai.yaml new file mode 100644 index 00000000000..014a4b6483e --- /dev/null +++ b/resources/skills/agent-team-manage/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Agent Team Manager" + short_description: "Assign work and manage team ownership safely" + default_prompt: "Use $agent-team-manage to organize the work and ownership of my existing team." diff --git a/resources/skills/agent-team-models/SKILL.md b/resources/skills/agent-team-models/SKILL.md new file mode 100644 index 00000000000..8ced803d9b6 --- /dev/null +++ b/resources/skills/agent-team-models/SKILL.md @@ -0,0 +1,55 @@ +--- +name: agent-team-models +description: "Discover configured models and choose explicit capability, strength and cost policies at team, role, skill and task levels. Use when selecting models or controlling an agent team budget; use agent-team-creator for role discovery. Unknown prices and capabilities remain unknown. Do not use for role selection (see agent-team-creator)." +license: MIT +compatibility: Requires Node.js 22 or newer. Git is optional for handoff snapshots. Model gateways, memory services and native harness CLIs are optional and separately configured. +metadata: + version: "1.0.0" + tags: "agents, teams, orchestration, coding" +--- + +# Agent Team Models + +Use the companion `agent-team-creator` compiled runtime and read its +`references/models-memory.md`. Discover the currently configured provider/model +identifiers; do not infer availability, strength or price from a model name. + +Ask for missing constraints in task terms: difficult reasoning, routine +implementation, mechanical edits, tools, vision, context size or cost. Reuse +existing preferences. Explain the three policy tiers as operator labels: +`hard` for difficult reasoning/review, `medium` for routine implementation, and +`low` for bounded mechanical work. They do not guarantee benchmark performance. + +```text +node /scripts/cli.mjs models-discover --input discovery.json +node /scripts/cli.mjs models-select --input selection.json +``` + +Discovery supports a configured OpenAI-compatible gateway (including liter-llm), +or the explicit verified UAR/BossFang discovery endpoint. Supply credentials by +environment variable reference. A successful model listing does not prove a +successful inference. Offline operator-declared catalogs also work and are +labeled as declarations. + +Map gateway aliases explicitly to liter-llm catalog provider/model identities. +Keep catalog provenance, freshness and per-token→per-million conversion. Annotate +tiers explicitly. Missing capability or price data cannot satisfy a requirement +or price ceiling; report no match instead of quietly relaxing constraints. + +Policy order is team → role → requested skills in explicit order → task. +Scalar values override; required capabilities accumulate. Explain the effective +policy and why the selected model qualifies. This is a configured policy order, +not a security boundary: a task override may intentionally change a budget. +Stale prices are estimates, not a guarantee of current provider charges. + +Save the concrete chosen ID in the role’s `modelPolicy.model` (or task policy +for a task-specific invocation) through a reviewed manifest/state update. +Re-run selection when the skills or task change. Skill policies influence +selection; they do not mutate a skill’s frontmatter or force every harness to +support per-skill model switching. + +Read the selected harness’s native reference before binding it. Kimi has no +role frontmatter model setting; DeepSeek team members lack a verified per-member +override. Use supported invocation/global controls or report the limitation. +Never add an invented model flag. Report policy, selected ID, unknown metadata, +price age, and whether native application has actually been verified. diff --git a/resources/skills/agent-team-models/agents/openai.yaml b/resources/skills/agent-team-models/agents/openai.yaml new file mode 100644 index 00000000000..b664f8d9e27 --- /dev/null +++ b/resources/skills/agent-team-models/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Agent Team Models" + short_description: "Match model capability and cost to team work" + default_prompt: "Use $agent-team-models to choose models for my team within its capability and cost constraints." diff --git a/resources/skills/kbd-assess/SKILL.md b/resources/skills/kbd-assess/SKILL.md index b2eafcb4e1d..a5f00b516cf 100644 --- a/resources/skills/kbd-assess/SKILL.md +++ b/resources/skills/kbd-assess/SKILL.md @@ -66,8 +66,9 @@ Use the canonical phase name from the argument or `current-waypoint.json`. Emit the written assessment. CRITICAL findings → revise `assessment.md` and re-vet (max 2 rounds, then accept with an "Unresolved review findings" section appended). WARNING findings → carry into the stage handoff summary. -8. **Enter/complete the assessment stage** by recording the stage handoff (see - below); never hand-edit `progress.json` directly. +8. **Enter/complete the assessment stage** through typed `prometheus kbd stage` + commands and separately record the handoff (see below); never hand-edit + generated progress or waypoint files. ## Examples @@ -105,8 +106,9 @@ event taxonomy, override semantics, and `KBD_HOOK_*` payload — all of which ## Stage gate & handoff -Assess is the first stage, so its gate always passes — call it anyway for -uniformity. After writing `assessment.md`, record the handoff that the next +Assess has no predecessor, but its gate still requires a resolvable phase +and agreement between canonical phase views. Stop on any nonzero result. +After writing `assessment.md`, record the handoff that the next stage (analyze, or plan when analyze is skipped) reads first. `lib/kbd/stage-gate.mjs` exports `stageGate(stage, ctx)`, @@ -129,6 +131,8 @@ stageHandoffWrite( ); ``` -Phases without a `handoffs/` directory are legacy: `stageGate` warns (via its -returned `stderr`) and still passes. A deliberate stage skip is recorded with +A missing `handoffs/` directory does not bypass required predecessors. +A missing required handoff fails with remediation: complete the predecessor +stage, or record an explicit skip with its reason under project policy. +A deliberate stage skip is recorded with `stageHandoffSkip('assess', '', { cwd })`. diff --git a/resources/skills/kbd-execute/SKILL.md b/resources/skills/kbd-execute/SKILL.md index 2d7c36b2cf6..03059d36fa5 100644 --- a/resources/skills/kbd-execute/SKILL.md +++ b/resources/skills/kbd-execute/SKILL.md @@ -13,8 +13,17 @@ Reads `.kbd-orchestrator/phases//plan.md`, selects the best execution backend (tool or OpenSpec), writes `execution.md`, and dispatches the phase while keeping KBD as the source of truth. -Also refreshes `.kbd-orchestrator/current-waypoint.json` so any AI tool can -resume cleanly. +Dispatch begins Execute; it does not complete it. Write the backend contract +in `execution.md` and a dispatch receipt at +`.kbd-orchestrator/phases//execute-dispatch.json` with the actual +dispatch time, assigned changes, and pending work. This receipt is not a stage +handoff: do not give it `completedAt` or `nextStage: reflect`, and do not place +it at `handoffs/execute.handoff.json`. + +Keep the parent Execute stage active while delegated or local work runs. Use +`kbd-apply` task boundaries and typed KBD mutations; the runtime refreshes +progress and waypoint projections. Resume from actual canonical work state, +not the existence of a dispatch artifact. ## Per-Change QA Gate @@ -24,22 +33,22 @@ invoke a quality gate before archiving, if `artifact-refiner` and evidence/certification state; it must not reopen the implementation counter: ``` -implementation_status → COMPLETE in progress.json +implementation complete via typed KBD change transition (projected in progress.json) │ ├─ artifact validation for "" (artifact-refiner, if installed) │ ├─ ALL PASS → diff-mode adversarial review for "" (if installed) │ │ │ ├─ verdict PASS → proceed to archive - │ │ ├─ if OpenSpec: /opsx:verify → /opsx:archive - │ │ └─ if native: move to .kbd-orchestrator/changes/archive/-/ + │ │ ├─ if OpenSpec: kbd-apply verify → kbd-apply archive + │ │ └─ if native: kbd-apply verify → kbd-apply archive │ │ (WARNING findings: logged in the review dir, archive proceeds; │ │ SUGGESTION: informational) │ │ - │ └─ verdict BLOCK (any CRITICAL) → mark certification BLOCKED in progress.json + │ └─ verdict BLOCK (any CRITICAL) → record certification BLOCKED through typed KBD commands │ └─ fix, then re-run both gates │ - └─ ANY FAIL → mark certification BLOCKED in progress.json, fix, retry + └─ ANY FAIL → record certification BLOCKED through typed KBD commands, fix, retry ``` ### Local review coverage @@ -60,7 +69,7 @@ Before any other action, emit to plain response text (BEFORE any tool call): Starting kbd-execute — (step N of T) ``` -When all steps are complete, emit: +Only after the execute completion checklist below is satisfied, emit: ``` Completed kbd-execute — (step N of T) @@ -79,7 +88,7 @@ When executing a named sub-phase within a multi-phase plan, emit the phase-level Starting phase out of : ``` -And after the last change in that sub-phase: +After all changes in that sub-phase satisfy the execute completion checklist: ``` Completed phase out of : @@ -120,12 +129,13 @@ Use the canonical phase name from the argument or `current-waypoint.json`. Phase 3. **Load waypoint** — `.kbd-orchestrator/current-waypoint.json` first when it exists 4. **Load assessment and plan** for the phase 5. **Write `execution.md`** with selected backend + dispatch contract -6. **Record the active path** — via the phase's canonical progress/waypoint files -7. **Register planned changes and tasks** -8. **Dispatch** to selected backend or mark phase execution-ready +6. **Record the active path** through typed KBD commands; projections refresh automatically +7. **Register planned changes and tasks** through `prometheus kbd change register` / `task register` +8. **Dispatch** and write `execute-dispatch.json`; keep Execute active 9. **Per completed change**: run the QA gate (see above) 10. **Per completed change**: run the adversarial review gate after QA passes -11. **Archive** changes that pass both gates +11. **Verify and archive** changes through `kbd-apply` after both gates pass +12. **Complete Execute only at the phase boundary** — use the checklist below; dispatch is not completion ## Backend Types @@ -147,8 +157,10 @@ Use the canonical phase name from the argument or `current-waypoint.json`. Phase ## Hook integration -Fire `execute:before` before selecting a backend, `execute:after` after -writing `execution.md`, via `hooksFire('execute', 'before'|'after', name, index, total, ctx)` +Fire `execute:before` when entering Execute, before selecting a backend. +Fire `execute:after` only at the completed Execute boundary described below, +never after merely writing `execution.md` or dispatching work. Use +`hooksFire('execute', 'before'|'after', name, index, total, ctx)` from `lib/kbd/hooks.mjs`. **`task:before`/`task:after` are fired per task by the change-apply driver (`kbd-apply`'s scope)** — not by `/kbd-execute` and not by bare OpenSpec apply. `/kbd-execute` writes the dispatch contract; the @@ -159,8 +171,8 @@ plain-text position signal on each boundary. import { hooksFire } from '../../lib/kbd/hooks.mjs'; await hooksFire('execute', 'before', phase, 1, 1, { orchestratorRoot, cwd, runCommand }); -// … select backend, write execution.md … -await hooksFire('execute', 'after', phase, 1, 1, { orchestratorRoot, cwd, runCommand }); +// … write execution.md and execute-dispatch.json; drive the assigned work … +// No execute:after or execute completion handoff at dispatch. ``` Note: the `on_change_complete` legacy alias is fired automatically by @@ -168,28 +180,64 @@ Note: the `on_change_complete` legacy alias is fired automatically by `index === total`, matching `kbd_hook_index == kbd_hook_total`). Projects relying on `on_change_complete` continue to work without changes. +## Execute completion checklist + +Before completing Execute, inspect the active phase’s canonical state and +actual evidence. All of the following must hold: + +1. Every change and task assigned to this phase’s execution scope is complete; + none remains pending, in progress, or blocked. Completion of one delegated + change does not complete the parent stage. +2. Each change satisfies the required QA and independent review gates, with + real receipts or an explicitly permitted signed waiver. A skip flag or + `pending_review` is not a passing result. +3. Required `kbd-apply verify` and `kbd-apply archive` operations have + succeeded for every applicable change, including any reconciliation work + assigned to this phase. Record actual outcomes and evidence locations. + +If anything remains, report it and keep Execute active. Implementation N/N +alone does not satisfy this checklist. Do not infer success from a dispatch +receipt, a missing tool, or a previous completion claim. + +Once all conditions hold, record the completed execute stage with a typed +`prometheus kbd stage transition`, fire `execute:after`, inspect its actual +outcome under project hook policy, and then write the completion handoff. +Required hook failures must be resolved before handing off to Reflect. +Preserve actual earlier receipts; never manufacture a successful past hook. + ## Stage gate & handoff -The execute gate requires the plan handoff. After writing `execution.md` -and registering canonical changes/tasks, record the handoff that reflect reads -first: +At dispatch, require the plan handoff and write only dispatch artifacts: ```js -import { stageGate, stageHandoffWrite } from '../../lib/kbd/stage-gate.mjs'; +import { stageGate } from '../../lib/kbd/stage-gate.mjs'; const gate = stageGate('execute', { cwd }); if (gate.status !== 0) throw new Error(gate.stderr); -// … select backend, write execution.md, register canonical work items … +// … enter Execute with a typed command, write execution.md and execute-dispatch.json … +// Keep Execute active; do not write its completion handoff here. +``` + +Only after the completion checklist and required hook outcomes above are +satisfied, invoke: + +```js +import { stageHandoffWrite } from '../../lib/kbd/stage-gate.mjs'; stageHandoffWrite( 'execute', - '<1–3 sentences: backend chosen, dispatch contract, first pending change>', + '', ['execution.md', 'progress.json'], { cwd }, ); ``` -Phases without a `handoffs/` directory are legacy: `stageGate` warns and still -passes. A deliberate stage skip is recorded with +This completion handoff is what Reflect reads first. Its `completedAt` and +`nextStage` describe a completed Execute boundary, never dispatch readiness. + +A missing `handoffs/` directory does not bypass required predecessors. +A missing required handoff fails with remediation: complete the predecessor +stage, or record an explicit skip with its reason under project policy. +A deliberate stage skip is recorded with `stageHandoffSkip('execute', '', { cwd })`. diff --git a/resources/skills/kbd-init/SKILL.md b/resources/skills/kbd-init/SKILL.md index 1b82f5a3aa3..c942a7a38e9 100644 --- a/resources/skills/kbd-init/SKILL.md +++ b/resources/skills/kbd-init/SKILL.md @@ -7,8 +7,11 @@ description: Use once per project, before any other KBD command — auto-discove Initialize the KBD orchestrator for the **current project**. -> This is the ONLY KBD command that creates project-specific configuration. -> All other skills read from `project.json` — they never write it. +> `/kbd-init` owns full project configuration discovery. Phase creation and +> advancement helpers own `project.json.activePhase`, preserve unrelated keys, +> and remove the legacy `active_phase` alias. If metadata is missing, those +> helpers may bootstrap minimal identity and active-phase metadata; this does +> not replace `/kbd-init` for full stack, policy, and constraint discovery. ## What this does diff --git a/resources/skills/kbd-plan/SKILL.md b/resources/skills/kbd-plan/SKILL.md index 38c3039eefd..e7d4cc22800 100644 --- a/resources/skills/kbd-plan/SKILL.md +++ b/resources/skills/kbd-plan/SKILL.md @@ -93,7 +93,7 @@ Use the canonical phase name from the argument or `current-waypoint.json`. Emit findings → carry into the stage handoff summary. Vet **before** emitting change structures, so a corrected plan never leaves stale changes behind. 8. **Emit change structures** via OpenSpec or native KBD -9. **Refresh waypoint** files (`current-waypoint.md` and `current-waypoint.json`) +9. **Record plan state** through typed KBD stage/change/task commands; the runtime regenerates progress and waypoint projections ## Examples @@ -141,6 +141,8 @@ stageHandoffWrite( ); ``` -Phases without a `handoffs/` directory are legacy: `stageGate` warns and still -passes. A deliberate stage skip is recorded with +A missing `handoffs/` directory does not bypass required predecessors. +A missing required handoff fails with remediation: complete the predecessor +stage, or record an explicit skip with its reason under project policy. +A deliberate stage skip is recorded with `stageHandoffSkip('plan', '', { cwd })`. diff --git a/resources/skills/kbd-process-orchestrator/SKILL.md b/resources/skills/kbd-process-orchestrator/SKILL.md index 3fe6d2e99d0..40a118df55c 100644 --- a/resources/skills/kbd-process-orchestrator/SKILL.md +++ b/resources/skills/kbd-process-orchestrator/SKILL.md @@ -33,7 +33,7 @@ When the phase cycle is complete: Completed phase of : ``` -Read `changes_total` and the phase list from `progress.json` or `current-waypoint.json` for accurate totals — never guess. Emit to plain response text — no tool call needed. Individual skills (`kbd-assess`, `kbd-plan`, etc.) emit their own skill-level signals independently. +Read change totals from the active phase’s `completion.implementation` and phase totals from the canonical phase list — never use change totals as phase totals or guess. Emit to plain response text — no tool call needed. Individual skills (`kbd-assess`, `kbd-plan`, etc.) emit their own skill-level signals independently. --- @@ -61,18 +61,21 @@ On every invocation, before acting, KBD MUST: ### Level 1 — Global Phase (this skill) -Assess → Analyze → Plan → Execute (backend selection + dispatch) → Reflect. -KBD owns canonical phase state and delegates execution to OpenSpec, a native -planner backend, or a designated AI tool. +Assess → Analyze → Spec → Plan → Execute (dispatch through completion) → Reflect. +KBD owns canonical phase state. This repository uses OpenSpec for changes, +with designated AI tools performing tasks through `kbd-apply`. ### Level 2 — Change (inner loop) -A change is created in **plan**, driven task-by-task in **execute** by -whichever apply mechanism this project has ported (an OpenSpec-aware driver -that wraps the OpenSpec CLI one task at a time so KBD hooks fire and -`progress.json`/the waypoint stay in sync — never invoke a bare, KBD-unaware -apply command directly), then `verify` → `archive`. Delegates QA to -`artifact-refiner` when that skill is installed. +A change is created during **spec/plan**, then driven task-by-task in +**execute** by the shipped `kbd-apply` skill and `scripts/kbd-apply.mjs`. +Use `begin-task` / `end-task` for every task, then driver `verify` → `archive` +after QA and independent review. Never invoke bare OpenSpec apply inside KBD. +The parent Execute stage remains active through all assigned changes/tasks and +their required gates. `execution.md` and `execute-dispatch.json` record dispatch; +only the completed boundary gets `execute:after` and +`handoffs/execute.handoff.json`. See `skills/kbd-execute/SKILL.md` for its +completion checklist. ### Level 3 — Artifact QA (innermost) @@ -83,18 +86,18 @@ skill is installed in this project. ## Multi-Tool Coordination Architecture -KBD's coordination contract is a set of files under `.kbd-orchestrator/`, -described in the table below. Multiple harnesses or tools coordinate by -reading and writing these files through the shared `lib/kbd/` modules rather -than by mutating them ad hoc — that is what keeps `progress.json` and the -waypoint from drifting out of sync with each other. +The canonical runtime journal under `.kbd-orchestrator/runtime/` is the +coordination authority. Harnesses use typed `prometheus kbd` commands through +the shipped Node adapters and read the generated views below. Shared modules +also retain legacy migration support; that is not permission to hand-edit +progress, position, or waypoint files. ### State files | File | Written by | Read by | Purpose | | ------------------------------------------------ | ------------------ | ----------- | --------------------------------- | | `.kbd-orchestrator/current-waypoint.json` | projection writer | All tools | Derived resume view (`lib/kbd/waypoint.mjs`) | -| `.kbd-orchestrator/current-waypoint.md` | Any orchestrator | All tools | Human-readable waypoint summary | +| `.kbd-orchestrator/current-waypoint.md` | projection writer | All tools | Human-readable waypoint summary | | `.kbd-orchestrator/phases//assessment.md` | kbd-assess | kbd-analyze/kbd-plan | Gap analysis output | | `.kbd-orchestrator/phases//analysis.md` | kbd-analyze | kbd-spec/kbd-plan | Engineering-landscape research | | `.kbd-orchestrator/phases//library-candidates.json` | kbd-analyze | kbd-spec/kbd-plan | Build-vs-adopt candidate set | @@ -102,9 +105,10 @@ waypoint from drifting out of sync with each other. | `.kbd-orchestrator/position.json` | projection writer | kbd-status/renderer | Revision-bound derived position tree | | `.kbd-orchestrator/phases//plan.md` | kbd-plan | kbd-execute | Ordered change list | | `.kbd-orchestrator/phases//execution.md` | kbd-execute | All tools | Backend dispatch contract | +| `.kbd-orchestrator/phases//execute-dispatch.json` | kbd-execute | All tools | Dispatch receipt; never a completed-stage handoff | | `.kbd-orchestrator/phases//progress.json` | projection writer | kbd-status | Derived implementation/evidence/certification/publication ledger (`lib/kbd/progress.mjs`) | | `.kbd-orchestrator/phases//reflection.md` | kbd-reflect | Next phase | Phase retrospective | -| `.kbd-orchestrator/project.json` | kbd-init | All tools | Project identity + config | +| `.kbd-orchestrator/project.json` | kbd-init; phase helpers for activePhase/bootstrap | All tools | Project identity + config | | `.kbd-orchestrator/phases//hooks.log.jsonl` | hooks dispatcher | operators | Append-only hook fire log (`lib/kbd/hooks.mjs`) | | `.kbd-orchestrator/phases//hooks-status.json` | hooks dispatcher | operators | Rolling hook success/failure summary | @@ -118,6 +122,9 @@ ledger that `lib/kbd/progress.mjs` reads, transforms, validates (`validateProgress`), and atomically rewrites in place — see that module for the exact branch logic. +The following is a compatibility ledger example, not an initialization +template or the full canonical runtime schema: + ```json { "schemaVersion": "2", @@ -192,32 +199,26 @@ counters in the projection by hand. 2. **Analyze** (`skills/kbd-analyze/SKILL.md`) — identify highest-leverage missing features, prioritize 3. **Spec** (`skills/kbd-spec/SKILL.md`) — turn gaps into concrete, ordered changes 4. **Plan** (`skills/kbd-plan/SKILL.md`) — produce ordered list of changes for this phase -5. **Execute** (`skills/kbd-execute/SKILL.md`) — select backend, write `execution.md`, dispatch +5. **Execute** (`skills/kbd-execute/SKILL.md`) — dispatch and remain active through all assigned tasks, QA/review, verification, and archive; write the completion handoff only at that boundary 6. **Reflect** (`skills/kbd-reflect/SKILL.md`) — capture lessons, seed next phase -7. **Persist** — write phase state, refresh waypoint, commit +7. **Persist** — record typed KBD transitions; review and commit intended artifacts and runtime projections under project policy After each phase: checkpoint + dispatch workflow triggers. --- -## OpenSpec Availability - -OpenSpec is **optional**. KBD adapts: - -### When OpenSpec IS available (`openspec/` directory exists) - -- Use `/opsx:new` to create structured changes with proposal → design → tasks -- Progress tracked in `openspec/changes//tasks.md` -- Archiving via `/opsx:archive` feeds the reflection phase +## Spec backend policy -### When OpenSpec is NOT available +This repository requires OpenSpec and pins `specBackend: openspec`. Create +changes through the project’s OpenSpec workflow; run their tasks, verification, +and archival through `kbd-apply`. Missing CLI or change artifacts are blockers +to repair, not permission to switch backends or claim completion. -- Use KBD's built-in change management via `.kbd-orchestrator/changes//` -- Create `change.md` (same structure as OpenSpec proposal + tasks combined) -- Track task status with `[ ]` / `[/]` / `[x]` in `change.md` -- Archive by moving to `.kbd-orchestrator/changes/archive/-/` - -KBD **never** requires OpenSpec. The `execution.md` format accommodates both. +The reusable `lib/kbd/spec-backend.mjs` also implements native-kbd for other +projects and legacy changes. Its detector honors an explicit pin first, then +change-local shape, then repository evidence. It can recognize Spec Kit, but +this mini port has no Spec Kit execution adapter. These library capabilities +do not change the OpenSpec policy for work in this repository. --- @@ -225,7 +226,7 @@ KBD **never** requires OpenSpec. The `execution.md` format accommodates both. KBD maintains a resumable return point for the current phase. -- Canonical files: +- Derived resume files: - `.kbd-orchestrator/current-waypoint.md` - `.kbd-orchestrator/current-waypoint.json` - Minimum fields (all documented, with defaults, in `waypointLoad` — @@ -237,7 +238,7 @@ KBD maintains a resumable return point for the current phase. - `lastCompletedChange` — last archived/completed change ID - `nextPendingChange` — next change to start - `sourceTool` — which tool last updated this projection - - `exactNextCommand` — the exact next command to run + - `exactNextCommand` — contextual guidance; confirm against canonical pending work - `nextChange` / `nextTask` — the concrete next unit of work When the waypoint exists, any AI tool should consult it before deriving @@ -314,8 +315,10 @@ debugging — lives in [`references/hooks.md`](references/hooks.md).** When an AI tool (Roo, Cursor, Cline, Codex, etc.) is dispatched to execute a KBD change, it should follow a start/during/completion/blocker protocol — -update `progress.json` + the waypoint and commit `.kbd-orchestrator/` on each -boundary, so the next tool to look at the projection sees accurate state. +use `kbd-apply begin-task` / `end-task` at every task boundary and typed KBD +change, completion, and blocker commands. The runtime regenerates progress +and waypoint views; never edit them directly. Review and commit intended +artifacts under project policy. --- @@ -366,11 +369,14 @@ hook it is mirroring. ``` /kbd-init # Auto-discover project and generate .kbd-orchestrator/project.json -/kbd-assess # Run the first assessment (writes the first phase from context) +/kbd-new-phase # Create and activate the first phase +/kbd-assess # Assess the active phase ``` > **IMPORTANT — project.json is GENERATED, not shipped.** -> `.kbd-orchestrator/project.json` is always created by `/kbd-init` using auto-discovery. +> `/kbd-init` creates full project configuration using auto-discovery. Phase +> helpers maintain `activePhase` and may bootstrap minimal missing metadata, +> preserving unrelated configuration; full discovery still belongs to `/kbd-init`. > It lives in the project repository, not in this skill directory. > The skill ships the generation template at > `skills/kbd-init/references/schemas/project.template.json` and the writer @@ -380,11 +386,14 @@ hook it is mirroring. ### Ongoing workflow - `/kbd-init [--force] [--dry-run]` — Initialize or re-initialize project context +- `/kbd-new-phase ` / `/kbd-next-phase` — Create or advance a phase +- `/kbd-new-child` / `/kbd-next-child` / `/kbd-child-exit` — Manage nested phases +- `/kbd-apply ` — Drive each task and its lifecycle hooks - `/kbd-assess [phase-name]` — Assess current codebase against active phase goals - `/kbd-analyze [phase-name]` — Research engineering landscape between Assess and Spec - `/kbd-spec [phase-name]` — Turn assessment + analysis into concrete change specs - `/kbd-plan [phase-name]` — Create prioritized change list for current phase -- `/kbd-execute [phase-name]` — Select execution backend and dispatch phase +- `/kbd-execute [phase-name]` — Dispatch and coordinate work through the completed Execute boundary - `/kbd-reflect [phase-name]` — Generate phase reflection report + seed next phase - `/kbd-status` — Show current phase, change inventory, and waypoint-guided next action - `/kbd-audit` — Inspect causal history, ownership, and drift, read-only @@ -398,31 +407,17 @@ See each sub-skill's own `SKILL.md` for its detailed invocation contract. --- -## What this port does not carry - -This is a scaled-down port of the full KBD process orchestrator. The -following pieces are documented upstream but are **not** part of this -project's skill set, and any reference to them elsewhere in this pack should -be read as aspirational, not wired: - -- `kbd-new-phase` / `kbd-new-child` / `kbd-next-child` / `kbd-next-phase` / - `kbd-child-exit` — nested-phase lifecycle writers. The nested-phase *read* - model (`path[]`, `kbdNodeDir`, `kbdCurrentNodeDir`) is ported in - `lib/kbd/waypoint.mjs` and `lib/kbd/rollup.mjs` above, but nothing in this - project's skill set currently writes a new child phase. -- `kbd-apply` — the per-task OpenSpec/spec-backend driver referenced above as - "whichever apply mechanism this project has ported." Confirm whether it - exists in `skills/` before assuming task-level hooks fire automatically. -- `kbd-bottleneck-detector`, `kbd-inject-agent-rules`, `kbd-memory-recall` — - referenced by name in the sections above (bottleneck guard, memory - integration) because their underlying `lib/kbd/` modules - (`bottleneck-guard.mjs`, `memory.mjs`, `memory-log.mjs`) are ported, but the - skill wrappers themselves may not be. -- The **evolver bridge** (`evolver-bridge.json`, iterative-evolver read-back - in Reflect) described in the upstream orchestrator is omitted here — this - port's `kbd-plan` and `kbd-reflect` do not read or write it. If an outer - evolution loop is added to this project later, reintroduce the bridge at - that point rather than assuming it already works. - -Before telling an operator that one of these works, check whether the -corresponding `skills//SKILL.md` actually exists in this project. +## Shipped capabilities and limits + +The following skills and matching `scripts/*.mjs` helpers are shipped: + +- `kbd-new-phase`, `kbd-next-phase`, `kbd-new-child`, `kbd-next-child`, and + `kbd-child-exit` create, activate, and navigate phase hierarchies. +- `kbd-apply` drives tasks and KBD boundary hooks through the backend adapters. +- `kbd-bottleneck-detector`, `kbd-inject-agent-rules`, and `kbd-memory-recall` + provide boundary checks, rule injection, and prior-context retrieval. + +A shipped wrapper still depends on its documented runtime/service prerequisites; +report its actual result. Spec Kit execution is not ported. The upstream +evolver-bridge read-back is also absent from this port’s plan/reflect flow; +do not claim that an `evolver-bridge.json` file is automatically consumed. diff --git a/resources/skills/kbd-reflect/SKILL.md b/resources/skills/kbd-reflect/SKILL.md index 338bdb43e11..5c568241b0d 100644 --- a/resources/skills/kbd-reflect/SKILL.md +++ b/resources/skills/kbd-reflect/SKILL.md @@ -53,7 +53,7 @@ All changes for this phase must be: - Implemented (`implementation_status: COMPLETE` in `progress.json`) - QA gate passed (when `artifact-refiner` is installed, unless skipped) -- If OpenSpec: verified (`/opsx:verify`) and archived (`/opsx:archive`) +- If OpenSpec: verified (`kbd-apply verify`) and archived (`kbd-apply archive`) - If native KBD: moved to `.kbd-orchestrator/changes/archive/-/` These are separate prerequisites: implementation completion drives the N/N @@ -94,7 +94,7 @@ Use the canonical phase name from the argument or `current-waypoint.json`. Emit 5. **Load all change data** — from `openspec/changes/archive/` if OpenSpec, or `.kbd-orchestrator/changes/archive/` if native KBD 6. **Write reflection** to `.kbd-orchestrator/phases//reflection.md` -7. **Advance the waypoint** to the next phase +7. **Advance when authorized** through `/kbd-next-phase`; its helper activates the phase and generates projections 8. **Trigger**: report that reflection is complete and the next step is to advance to a new phase @@ -168,6 +168,8 @@ stageHandoffWrite( ); ``` -Phases without a `handoffs/` directory are legacy: `stageGate` warns and still -passes. A deliberate stage skip is recorded with +A missing `handoffs/` directory does not bypass required predecessors. +A missing required handoff fails with remediation: complete the predecessor +stage, or record an explicit skip with its reason under project policy. +A deliberate stage skip is recorded with `stageHandoffSkip('reflect', '', { cwd })`. diff --git a/resources/skills/kbd-spec/SKILL.md b/resources/skills/kbd-spec/SKILL.md index d4b5c19c58e..e3801ed927a 100644 --- a/resources/skills/kbd-spec/SKILL.md +++ b/resources/skills/kbd-spec/SKILL.md @@ -1,6 +1,6 @@ --- name: kbd-spec -description: Use to run the Spec stage of the KBD lifecycle (between Analyze and Plan) — turn an assessment and analysis into concrete, ordered changes (native-kbd spec.md + tasks.json + verification.md, or OpenSpec proposals). +description: Use to run the Spec stage of the KBD lifecycle (between Analyze and Plan) — turn an assessment and analysis into concrete, ordered OpenSpec changes for KBD task execution. --- # /kbd-spec @@ -13,13 +13,15 @@ Converts `assessment.md` (and `analysis.json` / `library-candidates.json` when the Analyze stage ran) into concrete change specs that the Plan stage will order and the Execute stage will drive one task per turn: -- **native-kbd backend** (default): writes - `.kbd-orchestrator/changes//{spec.md, tasks.json, verification.md}`. -- **openspec backend**: emits `/opsx:new ` per change, producing - `openspec/changes//{proposal.md, tasks.md}`. +This repository uses **OpenSpec**, pinned by `project.json.specBackend`. +Create `openspec/changes//` with proposal, design, delta specs, and +explicit tasks as required by the project schema. Execute those tasks through +`kbd-apply`; do not substitute a native change when OpenSpec is unavailable. -Backend is resolved the same way `kbd-apply` resolves it -(`project.json.specBackend` → openspec → native-kbd). +The reusable backend library also supports existing native-kbd changes, but +that portability capability does not override this repository’s OpenSpec +policy. Detection honors the explicit pin first, then change-local evidence, +then repository evidence; an empty result is not a native-kbd default. ## Progress Signals (MANDATORY) @@ -42,36 +44,28 @@ Never guess. Emit to plain response text — no tool call needed. 1. **Confirm the active phase** — from argument or `.kbd-orchestrator/current-waypoint.json`. -2. **Stage gate** — `stageGate('spec', { cwd })` from `lib/kbd/stage-gate.mjs` - (requires the assess handoff; `analyze` is optional, so the gate walks back - across an absent analyze handoff automatically). +2. **Stage gate** — call `stageGate('spec', { cwd })` from + `lib/kbd/stage-gate.mjs` and stop unless its returned `status` is zero + (requires assess; an absent optional analyze handoff is walked back). 3. **Read inputs** — `assessment.md`; `analysis.json` / `library-candidates.json` if Analyze ran (adopt/adapt candidates become "reuse this library" tasks, not "build it" tasks). -4. **Resolve backend** — `kbd-apply`'s detect semantics. -5. **Write change specs** — native-kbd files or `/opsx:new` per change, with a - declared `scope:` and explicit task list each. +4. **Confirm backend** — `kbd-apply` must resolve the pinned OpenSpec backend; + repair missing CLI/setup prerequisites instead of silently changing backends. +5. **Write OpenSpec changes** — use the project’s OpenSpec creation workflow, + with declared scope and an explicit task list for each change. 6. **Adversarial vet** — when `adversarial-review` is installed and `--skip-adversarial-review` was not passed, run it in artifact mode against the whole change set (every change named in the spec handoff, not one at a time — a spec is only coherent against its siblings; cross-change failures - like a `tasks.json` `scope` that omits a file its tasks edit, or two changes + like a declared scope that omits a file its tasks edit, or two changes editing the same file with no ordering, are invisible when reviewed in - isolation). CRITICAL findings → revise the affected `spec.md` / - `tasks.json` / `verification.md` and re-vet (max 2 rounds, then accept with + isolation). CRITICAL findings → revise the affected OpenSpec proposal, + design, delta specs, or tasks and re-vet (max 2 rounds, then accept with an "Unresolved review findings" section appended). WARNING findings → carry into the stage handoff. 7. **Write handoff** — see "Stage gate & handoff" below. -> **Note on the ZeeSpec coverage gate.** The upstream version of this skill -> gates spec-writing on a `.zeespec//` coverage verdict (GO / -> CAUTION / NO-GO) when that directory exists. This port omits that gate: it -> is documented upstream as inactive whenever no `.zeespec/` directory is -> present, and none of this project's own skills create one, so the omission -> is behavior-preserving here. If `zeespec-interrogator`-style coverage -> tracking is ever added to this project, reintroduce the gate at this point -> in the flow — after reading inputs, before writing change specs. - ## Hook integration Fires `spec:before` / `spec:after` via `hooksFire('spec', 'before'|'after', name, index, total, ctx)`