diff --git a/.changeset/activation-reconciliation.md b/.changeset/activation-reconciliation.md new file mode 100644 index 000000000..bfe3d6255 --- /dev/null +++ b/.changeset/activation-reconciliation.md @@ -0,0 +1,32 @@ +--- +"@bradygaster/squad-sdk": patch +--- + +Validate real numeric task and epic issue references and add an explicit activation +reader for independently verified current-state-authorized reconciliations. +Preserve valid legacy activation records while rejecting ambiguous, malformed, +or unverified replacements without rewriting historical acceptance. +Require exactly one matching verifier evidence record and bind any selected +phase scope to its canonical, independently verified phase IDs. +Require activation-source phases to exactly match verifier evidence, rejecting +internally inconsistent caller-supplied evidence without changing verifier trust. +Reject malformed legacy phase scopes with non-positive, unsafe, nonnumeric, or +duplicate phase IDs consistently with the packaged activation checker. +Allow a verifier-supplied full frozen-plan assignment context to validate complete +epic identity sets across phase subsets; retain strict exact-row validation when +that independent context is absent. +Normalize verifier and producer display names consistently with trim/lowercase +while retaining exact stable agent identity checks. +Reject task issues that collide with the origin or any epic issue, and prevent +non-origin epics from reusing the origin issue. +Support explicit verifier-authorized pending-source reconciliation with matching +capture metadata, frozen plan and registry digests, and phase scope. Pending +reservations and their prior references never authorize activation on their own. +Select complete transitive phase histories only when every reconciliation has an +independent verifier proof and exact source links form one fully consumed chain +with consistent frozen snapshots, expanding phases, and preserved prior mappings. +Ignore unrelated discussion in scoped pairs and complete histories while retaining +rejection of extra activation or pending candidates and ambiguous chains. +Require a nonempty, strictly shorter positional phase prefix across ancestors; +reject whole-plan phase expansion and explicitly selected phases absent from the +frozen plan without requiring consecutive phase IDs. diff --git a/.github/workflows/squad-agent-binding-check.yml b/.github/workflows/squad-agent-binding-check.yml index 6373c5653..260894df9 100644 --- a/.github/workflows/squad-agent-binding-check.yml +++ b/.github/workflows/squad-agent-binding-check.yml @@ -1,6 +1,7 @@ name: Squad Agent Binding Check on: + workflow_dispatch: workflow_run: # "Squad" is the installed gh-aw workflow; "Squad CI" keeps this source # repository's checker wired to a workflow that exists here. @@ -13,37 +14,28 @@ permissions: jobs: check: + name: Historical bindings (not current-run completion) runs-on: ubuntu-latest steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 #v7 - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 #v6 with: node-version: 22 - - name: Collect activation evidence from the completed run + - name: Collect historical activation records (not run outputs) uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 #v9 with: script: | const fs = require('node:fs'); - const started = new Date(context.payload.workflow_run.run_started_at); - const completed = new Date(context.payload.workflow_run.updated_at); - const comments = await github.paginate(github.rest.issues.listCommentsForRepo, { - ...context.repo, - since: started.toISOString(), - per_page: 100, - }); - const activationComments = comments - .filter(comment => new Date(comment.created_at) <= completed) - .filter(comment => /Structured data:/i.test(comment.body ?? '') - && /"squad_artifact"\s*:\s*"?(?:phases-activated|activated|phases-accepted|plan-accepted)/i.test(comment.body ?? '')) - .map(comment => ({ - body: comment.body, - issue: Number(comment.issue_url.split('/').at(-1)), - })); + const { pathToFileURL } = require('node:url'); + const { join } = require('node:path'); + const { collectHistoricalActivationRecords } = await import(pathToFileURL( + join(process.env.GITHUB_WORKSPACE, 'workflows/shared/check-agent-binding.mjs')).href); + const audit = await collectHistoricalActivationRecords({ github, context }); fs.writeFileSync( `${process.env.RUNNER_TEMP}/activation-comments.json`, - JSON.stringify(activationComments), + JSON.stringify(audit), ); - - name: Check agent bindings + - name: Audit historical bindings without asserting current-run completion env: GITHUB_TOKEN: ${{ github.token }} run: >- diff --git a/.github/workflows/squad-ci.yml b/.github/workflows/squad-ci.yml index 80a603917..915940a37 100644 --- a/.github/workflows/squad-ci.yml +++ b/.github/workflows/squad-ci.yml @@ -788,7 +788,8 @@ jobs: --source-revision "$SQUAD_SOURCE_REVISION" node .github/workflows/shared/squad-install-verifier.mjs --materialize-runtime test "$(find .github/workflows -maxdepth 1 -name 'squad*.md' | wc -l | tr -d ' ')" = 8 - test "$(find .github/workflows/shared -type f ! -name 'squad-bootstrap-trigger-probe.json' | wc -l | tr -d ' ')" = 17 + test "$(find .github/workflows/shared -type f ! -name 'squad-bootstrap-trigger-probe.json' | wc -l | tr -d ' ')" = 19 + test -f .github/workflows/squad-agent-binding-check.yml test "$(find .github/skills/gh-aw-enlistment -type f | wc -l | tr -d ' ')" = 1 node --input-type=module -e ' import { compileWithPinnedActions } from "./.github/workflows/shared/squad-install-verifier.mjs"; diff --git a/docs/src/content/docs/guide/gh-aw.md b/docs/src/content/docs/guide/gh-aw.md index a30e45ab4..7ba720a78 100644 --- a/docs/src/content/docs/guide/gh-aw.md +++ b/docs/src/content/docs/guide/gh-aw.md @@ -556,7 +556,7 @@ rm -f .github/skills/agentic-workflows/SKILL.md The nested `workflows/aw.yml` is the canonical package registration. Its package boundary prevents unrelated repository skills or agents from being -auto-discovered. It installs exactly eight workflows, seventeen runtime +auto-discovered. It installs exactly eight agentic workflows, twenty runtime resources, one integrity manifest, and one enlistment skill from the same resolved commit. The installed top-level workflow set is: @@ -569,11 +569,14 @@ resolved commit. The installed top-level workflow set is: - `squad-bootstrap.md` and `squad-bootstrap.lock.yml` - `squad-command-router.md` and `squad-command-router.lock.yml` -The install must also contain these seventeen executable, documentation, and +The install must also contain these twenty executable, documentation, and schema runtime resources (manifest-backed — see `workflows/squad-workflows.manifest.json`'s `shared_runtime` list for the authoritative source of truth): +- `squad-agent-binding-check.yml` +- `shared/squad-activation.mjs` +- `shared/check-agent-binding.mjs` - `shared/squad-cast-validator.mjs` - `shared/squad-bootstrap-validator.mjs` - `shared/squad-improvement-gate.mjs` @@ -594,9 +597,14 @@ authoritative source of truth): The package owns every runtime resource. Because native gh-aw package resources allow JavaScript guards under `.github/workflows/shared/` but not Markdown or -JSON at that destination, the package stores those remaining exact bytes under -`.github/aw/squad/runtime/`. The verifier materializes them to their runtime +JSON at that destination, nor native workflow YAML destinations, the package +stores those remaining exact bytes under `.github/aw/squad/runtime/`. The verifier materializes them to their runtime paths and rejects any digest mismatch before the bootstrap PR can be opened. +Nineteen resources materialize under `.github/workflows/shared/`; the native +binding checker materializes at `.github/workflows/squad-agent-binding-check.yml`. +All twenty are verified against the same contract. +A differing pre-existing native checker is preserved and blocks materialization; +resolve that file conflict before opening the bootstrap PR. `squad-improvement-worker` is part of this standard install, not a separate add-on — it stays dormant until a maintainer approves a governance-scoped @@ -693,7 +701,7 @@ absent. Do not suppress warnings or use `--approve` to bypass this gate. Verify the complete source/lock surface. Prefer the manifest-backed verifier over a hand-maintained file list, since it checks every one of the eight -workflows, seventeen runtime resources, the integrity manifest, and the +workflows, twenty runtime resources, the integrity manifest, and the enlistment skill against `workflows/squad-workflows.manifest.json`: ```bash @@ -840,8 +848,8 @@ Use this checklist for the initial bootstrap and after any workflow update: | Research backlog | Follow the proposal issue through research, triage, plan, and activate | The journey ends with assignable implementation issues; `/squad implement` is used only on those generated tasks | | Cast checks | Open the linked Cast PR and inspect its checks; if application CI is `action_required`, approve that workflow run and wait for it to finish | Copilot review and the repository's normal build, test, lint, and security checks complete before merge | | Handoffs | Run `/squad implement` on a ready issue, then review its PR | The dispatcher starts the appropriate isolated worker; automatic PR review supplies the independent current-head verdict; `/squad review` explains how to rerun that exact automatic check | -| Rerun | Repeat the same command after a cancellation or uncertain result | Existing Cast and implementation PRs are detected instead of duplicated; an unchanged reviewed head is not reviewed twice | -| Recovery | Fix the named failing activation step, then use **Re-run failed jobs**; for an interrupted command, rerun the identical `/squad` command | Activation uploads no partial state artifact, and command-specific idempotency resumes from GitHub's committed PR, issue, comment, and review state | +| Rerun | Repeat Cast, implementation, or review commands after checking their existing durable outputs; do not rerun an ambiguous activation to fill missing issues | Existing Cast and implementation PRs are detected instead of duplicated; an unchanged reviewed head is not reviewed twice | +| Recovery | Inspect activation receipts, the durable pending comment, and current issue state; use [explicit current-state reconciliation](#deterministic-activation-and-no-create-recovery) for interrupted or ambiguous creation | Recovery validates explicitly mapped existing issues without creating replacements or matching titles; a pending reservation blocks another create batch | If a work command auto-opens a Cast PR because no committed team exists, merge that PR and rerun the original command. Do not start a second Cast command. @@ -1377,6 +1385,278 @@ To activate one phase at a time, use `/squad activate phase {N}`. The existing `/squad plan accept` and `/squad plan accept phase {N}` commands remain supported as legacy aliases with identical behavior. +### Deterministic activation and no-create recovery + +The model submits only the exact plan-comment ID. A trusted job freezes the API +comment and committed casting registry, validates the plan, and records a durable +pending reservation **before any issue creation**. It receives actual issue IDs +from the pinned synchronous runtime API. Only its finalizer can publish acceptance +and Activated lifecycle output, after all operations, IDs, dependencies, owners +and live labels validate. A green run or pending marker is not acceptance. +Retry-safe label setup and exact authenticated phase-approval reuse precede +reservation; admission is rechecked afterward. Rendered issue-body limits are +validated before reservation. The durable pending marker and started-operation +journal still precede every issue create. Ambiguous create/crash outcomes remain +no-create: empty receipts or an error never release a started reservation. +Finalization and downstream verification also check current API parent links. +A per-operation body-reference fallback is allowed only when recorded after the +native parent write returned 404/422 and the parent read remains unavailable +with 404/422. An available, contradictory parent always rejects the binding. + +Current-run completion is checked **inside the trusted writer job**: the exact +synchronous comment-publication response must identify the expected repository, +origin, workflow bot and body. Its exact API readback, live bindings and sole +history head are checked before lifecycle publication and successful completion. +Reconciliation predecessors are fetched through their explicit ID/hash links as +dependencies, never counted as this run's output. Missing or ambiguous publication +responses fail even if another run published valid output at the same time. +Run IDs asserted in comment bodies, time windows, artifact names and logs grant +no completion authority. Existing validated acceptance/reconciliation retries +remain explicit unchanged/no-create operations, not new publication claims. +The native **Squad Agent Binding Check** is a separate historical current-state +audit, including old predecessors. After each Squad completion (also Squad CI in +this repository), automatic discovery reads comments updated since 24 hours before +the triggering run's completion timestamp. This timestamp and comment contents +are candidate-selection hints only, never current-run attribution or coverage +proof. Discovery permits at most five pages of 100 comments and five candidate +origins. Each selected origin is fetched without a time filter, up to ten pages of +100 comments, and its complete required chain is verified by the canonical checks. +Automatic collection plus verification share a hard budget of 250 API reads, +including ancestry, permissions, issue labels and hierarchy reads. Pagination or +candidate overflow, exhausted budgets, malformed discovery, and missing required +evidence fail the audit explicitly as incomplete; they are not reported as no +candidates or successful partial coverage. A complete discovery with no candidates +is distinct and reports no historical artifact in this limited scope. + +Use **Actions → Squad Agent Binding Check → Run workflow** for the manual +full-history audit: it has no discovery time filter or automatic page/origin/API +caps and may require substantial API quota. API failures still fail the audit. +Automatic success is not a repository-wide historical coverage claim. Its success (including +an empty history) does **not** prove that the triggering run produced output. +These are point-in-time API checks, not a transaction or a guarantee against +later edits; changed historical state can fail a subsequent audit. +Authorized dispatch and the existing `github.workflow_sha` trusted-code boundary +are unchanged; no default-branch-only restriction or additional permission is imposed. + +Supported tables have unique stable `#`, `Title`, `Owner`, `Agent ID`, and +`Depends On` columns. Granular implementation tables use `Agent` instead of +`Owner` and additionally require `Epic`. Use comma-separated stable task IDs for +dependencies, or `None`/`-`; use `### Phase N` headings for phases. Missing columns, +unknown identities, duplicate IDs and cycles are rejected, not inferred. +The applicable `max_issues` policy applies per invocation, with a hard ceiling +of 50 including new epics. Issue identity never comes from a title match. + +If a runner fails after reserving or starting an operation, do not rerun creation +to fill missing receipts. The API write might have succeeded. Partial output +remains explicitly non-consumable. Run/attempt-qualified capture and receipt +artifacts retain diagnostics, but their IDs/digests alone are **not historical +producer attestation**. Missing authenticated historical receipts or frozen +snapshots cannot be reconstructed from model logs. + +An eligible operator with write, maintain or admin permission may instead approve +a **new current-state reconciliation**, under their existing repair authority. +Post one request comment with this shape, then invoke +`/squad reconcile activation `: + +````markdown +Activation reconciliation request: +```json +{ + "schema": "squad-activation-reconciliation-request/v1", + "repository": "owner/repo", + "origin_issue": 42, + "original": {"comment_id": 200, "sha256": ""}, + "plan": {"comment_id": 100, "sha256": ""}, + "registry": {"revision": 1, "sha256": ""}, + "tasks": [ + {"task": "1", "issue": 101, "temporary_id": "aw_wi1"}, + {"task": "2", "issue": 102, "temporary_id": "aw_wi2"} + ], + "epics": [] +} +``` +```` + +All hashes are lowercase SHA-256: exact raw comment text for comments, and +`JSON.stringify(registry)` for the registry. Supply every task and non-origin +epic; use `temporary_id: null` when no token correction is needed. The model may +not supply or widen these mappings. The writer verifies current API identities, +hierarchy, labels, dependencies and the freshly frozen plan/registry. + +The only permitted issue-body correction replaces explicitly mapped backtick +tokens in the single standalone `**Depends On:**` field. For example, +``Task 1 (`aw_wi1`) — Prepare example inputs`` changes only its token to `#101`; +the task label and trailing prose remain unchanged. Titles, scope, owners, +labels, hierarchy and the original acceptance remain untouched. Before/after +body hashes, fresh reads and post-write checks detect conflicts and make retries +idempotent. **GitHub offers no atomic body compare-and-swap**: workflow concurrency +serializes this origin's runs, but operators must avoid simultaneous external +edits. An intervening edit or partial failure withholds reconciliation. + +Success creates immutable `activation-reconciled` output with +`basis: current-state-authorized` and the original comment ID/hash. It makes no +historical creation claim. The packaged checker and SDK selector require +independently verified request evidence; actor flags or embedded hashes alone +do not authorize selection. Malformed originals remain rejected by legacy readers. +New readers skip one only after validating its explicit supplement, never by +selecting the newest comment. + +The SDK accepts an explicitly supplied reconciliation chain only when every +supplement has matching independent verifier evidence. It rejects forks, +missing predecessors, cycles and changed prior mappings. A native continuation +without its own reconciliation is not authenticated merely by passing it to the +SDK: the packaged checker separately verifies its current API record, prior +request evidence and unique history head. Standalone legacy parsing is not +historical attestation. +Legacy records remain readable and unchanged, but promotion requires independently +provable correspondence. Missing proof requires explicit current-state reconciliation. + +An interrupted run that created issues but never published acceptance may use +`"source_kind": "pending"` in this same request. `original` must then identify the +exact workflow-bot pending comment and body hash. Its repository, origin, frozen +plan/registry hashes and complete phase coverage must match the newly authorized +request. The reservation is only the source being superseded: it proves neither +authority nor successful creation. Supply every existing numeric task/epic mapping; +missing or partial maps cannot become full acceptance or authorize creation of +missing work. The supplement records `supersedes_kind: "pending"`. A stale or +forged reservation fails closed, as does incomplete current API evidence. + +For safe phase continuation, include ascending `"phases": [1, 2]` in a request +covering **all previously activated phases** and their complete mappings. This +must match the exact source artifact's phase coverage. Omitting `phases` is +equivalent to `"phases": []`, and is valid only for a whole-plan source with +empty phase coverage; neither form widens a phase-scoped source. After reconciliation, +`/squad activate phase 3` creates only Phase 3 work, reusing validated prior +issues and shared epics. Missing, stale or competing mappings fail closed for +that invocation. Shared-epic labels use the full frozen plan's owner set, not +just the currently selected phase. + +`/squad plan accept implementation phase N` keeps its combined acceptance and +activation behavior through this same guarded writer, not a second create path. +It requires the current scope acceptance and a frozen validation comment containing +exactly one `RESULT: PASS`, the exact `**Implementation plan:**` comment URL and +`**Implementation SHA-256:**` body hash, all sixteen passing rows in a +`Check | Status | Details` table, and complete `Task | Size | Traces to` coverage +with sizes XS/S/M/L. These inputs are rechecked during execution. Whole-plan +implementation acceptance remains acceptance-only. Missing prior-phase mappings +still require the explicit bridge above. + +Activation updates the existing lifecycle tracker only after validated acceptance. +Intermediate phases show remaining work rather than terminal success. If acceptance +was published but the lifecycle write failed, an identical activation rerun validates +the existing record and repairs only the lifecycle; it does not duplicate issues +or acceptance. Reconciliation similarly retries its lifecycle update idempotently. + +### Reading activation records with the SDK + +The public `@bradygaster/squad-sdk/casting` subpath exports +`selectActivationRecord(records, registry, context)`. It is a synchronous reader, +not a GitHub API verifier. Supply `ActivationRecord` entries with the exact comment +`id` and `body`, the independently frozen `AgentProvenanceRegistry`, and an +`ActivationSelectionContext` containing `repository`, `originIssue`, +`reconciliations`, and the full frozen plan's `epicAssignments` (`EpicAssignment[]`). +Include the exact source and predecessor records needed by the reconciliation +pair or complete chain, not just the newest comment. + +**Establish trust before calling the selector.** A trusted API verifier must +authenticate the reconciliation request actor and their current authorization, +freeze the plan and committed registry, and check the current issue mappings. +Supply its independently obtained `ReconciliationEvidence` for every supplement. +Copying hashes or actor IDs from a comment, constructing matching evidence fields, +or asserting an `authorized` flag is not proof. The selector does not authenticate +comment authors, fetch current GitHub state, or attest that a historical run +created issues. Native continuations still need the packaged checker's separate +current-API and history-head verification described above. + +This consumer-side fragment shows the public import and result handling. Its +arguments must come from that trusted integration; it does not implement a verifier: + +```typescript +import { + AgentProvenanceError, + selectActivationRecord, + type ActivationRecord, + type ActivationSelectionContext, + type AgentProvenanceRegistry, + type SelectedActivationRecord, +} from '@bradygaster/squad-sdk/casting'; + +function inspectActivation( + records: readonly ActivationRecord[], + registry: AgentProvenanceRegistry, + context: ActivationSelectionContext, +): SelectedActivationRecord | undefined { + try { + const selected = selectActivationRecord(records, registry, context); + if (selected === undefined) { + console.info('No activation selected; do not dispatch work.'); + return undefined; + } + console.info(selected.id, selected.artifact, selected.bindings); + return selected; + } catch (error) { + if (error instanceof AgentProvenanceError) { + console.error('Activation rejected:', error.message, error.diagnostics); + } + throw error; + } +} +``` + +A selected result contains the comment `id`, `artifact` kind and validated +`bindings`, not a new authorization grant. `undefined` means no activation +candidate was selected; an empty or pending-only history is not acceptance. +Malformed, conflicting or incompletely evidenced histories throw rather than +returning `undefined`. Fail closed on errors: do not fall back to the newest +comment, ignore a missing proof, or dispatch partial bindings. + +#### Run the existing executable example + +The repository's +[activation finalization integration fixture](https://github.com/bradygaster/squad/blob/dev/test/gh-aw-activation-finalization.test.ts) +is the executable producer/consumer example. In +`continues after a later-phase pending-source recovery and validates the complete transitive reader chain`, +it obtains proofs through the real workflow `verifyReconciliationRequest`, +projects them with `selectionEvidence`, supplies `frozenAssignments`, and calls +both the SDK selector and canonical checker. These helpers belong to +`workflows/shared/squad-activation.mjs`; they are **not** public `/casting` verifier +exports. The `trusted synchronous publication completion (source and strict-compiled jobs)` +group also executes emitted job scripts and the pinned issue-creation API adapter. +The companion [selector contract tests](https://github.com/bradygaster/squad/blob/dev/test/agent-provenance.test.ts) +cover selection, no-record results and rejection cases; their hand-built evidence +is test data, not production authorization. + +Run from a Squad repository checkout with Node.js **22.5.0 or newer**, npm, Git, +GitHub CLI, and the repository's npm dependencies already installed. Install the +exact compiler once with `gh extension install --pin v0.91.5 github/gh-aw`. +If a different gh-aw version is installed, remove that extension with +`gh extension remove gh-aw` before the pinned installation; v0.89.22 is unsupported +for this fixture. Before **either compiler path below**, build the current +checkout's SDK so public `/casting` imports use freshly generated `dist` output: + +```bash +npm run build -w packages/squad-sdk +``` + +With the pinned extension available, run: + +```bash +test "$(gh aw --version | awk 'END {print $NF}')" = "v0.91.5" && +npm test -- test/gh-aw-activation-finalization.test.ts test/agent-provenance.test.ts +``` + +Alternatively, set `SQUAD_GH_AW_BIN` to an **absolute path** to an already obtained +v0.91.5 executable when running that same `npm test` command. The fixture checks +the exact compiler version, strict-compiles an isolated package installation, +and validates immutable action pins. Outbound HTTPS access to +`raw.githubusercontent.com` is required to fetch the runtime closure at +`github/gh-aw-actions` commit `16b430146d5d5646eccef5a1ceb72152333b18b9`. +GitHub issue APIs are synthetic and writes stay local: this example neither +creates live issues nor demonstrates hosted credentials or permissions. +A missing compiler, mismatched pin, failed runtime download or failed assertion +is a failed run, not permission to skip coverage. A passing local run demonstrates +the integration contract, not production trust for caller-supplied evidence. + ### Granular lifecycle ``` @@ -1574,108 +1854,53 @@ After this, the plan is ready to activate. Creates GitHub issues from the accepted plan with Squad labels (`squad`, `squad:{agent-name}`), acceptance criteria, dependency references, and phase assignments. Flat plans create one child issue per planned task directly under -the originating issue; they do not add a duplicate epic. Dependencies use native -`blockedBy` edges when the installed safe-output tool supports them, otherwise -they remain explicit `Depends On` references in each issue body. +the originating issue; they do not add a duplicate epic. Dependencies are explicit +numeric `Depends On` references in each issue body. This activation writer makes +no native dependency-edge claim. Native parent links are requested; supported +404/422 failures are recorded as body-reference fallbacks. #### Labels are created automatically -You do not need to pre-create any label. Activation applies labels through the -`add-labels` safe output, which is configured with `allowed: [squad, "squad:*"]` -and `create-if-missing: true`, so `squad` and each `squad:{agent}` label is -created the first time a run needs it. A fresh repository with zero Squad labels -requires no manual label setup. +You do not need to pre-create labels. The trusted writer looks up each permitted +`squad`/roster-derived `squad:{agent}` label and creates missing names before +issuing any issue create. It supplies the exact per-operation labels to the +synchronous create API, then reads each actual issue's labels before accepting. +Existing label descriptions and colors are untouched. Newly provisioned labels +use color `9B8FCC` and an empty description. -> **Two names, one operation.** `add-labels` (hyphenated) is the *configuration -> key* in the workflow's `safe-outputs` block — that is the spelling to search for -> in `squad.md` and the one the caps below apply to. `add_labels` (underscored) is -> the *tool call* the agent makes at run time, and it is the spelling that appears -> in activation summaries and incomplete reports. They refer to the same -> operation; this page uses whichever form matches the surface being described. - -A label created this way receives gh-aw's deterministic color and an **empty -description**. That is expected on a fresh repository, not a failure. Labels that -already exist are left as they are, and re-applying a label on a rerun is a no-op. - -#### "Accepted" in an activation summary does not mean "applied" - -Activation summaries report **accepted operations**, and that word is exact. -Issue creation and labeling are gh-aw *safe outputs*: during the agent's turn the -run can only queue an operation against a target and observe that it was -accepted. gh-aw applies the queued operations afterward, in a separate post-agent -job. Nothing in the run reads labels back from GitHub. - -So a correct summary says a label operation was **accepted**. It must not claim a -label was applied, landed, verified, confirmed, or checked on the issue. - -This vocabulary exists for a concrete reason. Labels do not ride along with issue -creation: a label reaches an activated issue through exactly one route, an -accepted `add_labels` operation targeting that issue. `create-issue`'s own -`labels:` field never lands a label the activation run can claim, so it is never -evidence that a label arrived. Labels therefore travel as separate `add_labels` -calls, and "accepted" is the strongest thing the run can truthfully say about one. - -Practical consequence: an accepted summary is strong evidence, not proof. To -confirm what actually landed, read the issues themselves: - -```bash -gh issue list --label squad --json number,title,labels -``` +The model's native `create_issue`/`add_labels` path is not used for activation. +Queued calls or a successful issue create alone are not proof that labels landed: +the finalizer checks their actual per-issue correspondence. Other command modes +retain their native safe-output configuration. #### Activation is capped, and shortfalls are reported -A single activation run is bounded. **The user-facing ceiling is 50 issues per -activation run.** Plan for that number. - -The two underlying safe-output caps are set above that ceiling on purpose, so -they are headroom rather than the limit you should size against: - -| Safe output | Cap | Derivation | -|-------------|-----|------------| -| `create-issue` | 75 | 50 worst-case issues plus 25 bounded margin | -| `add-labels` | 110 | Covers both readings of the worst case — 50 calls (one per issue) and 100 label names (two each) | - -`max` counts safe-output **items (tool calls)**, not label names, and it is -enforced at invocation and collection — neither enforcement fails the run. +A single activation invocation must fit its frozen planning-policy limit: +default 20, lean 10, enterprise 50, or spike 5, subject to explicit supported +overrides and a hard ceiling of 50. New epics count; validated reused epics do +not. Oversized batches fail before creation, rather than silently truncating. +Before reserving a batch, the writer also checks every sanitized issue body with +the pinned runtime's attribution formatter and its 65,536 UTF-16-code-unit bound, +including supplementary Unicode and worst-case numeric reference widths. -Two different shortfalls are checked separately, and they are not -interchangeable — the trigger, the wording, and the remedy differ: - -| Shortfall | Trigger | What the report says | -|-----------|---------|----------------------| -| Issues not created | Created count is below the plan's declared total | `N of M issues created so far — rerun the identical activation command to continue.` | -| Labels not applied | An activated issue had no `add_labels` call accepted | `{labeled} of {activated} activated issues had a label operation accepted` | - -Either one calls `report_incomplete`. With pinned gh-aw v0.91.5, **this fails the -conclusion step** and records the reason in the Actions logs. The dispatcher -does not create or update a separate tracking issue. Inspect the run and the -guarded activation summary on the originating issue: +If any create outcome is ambiguous, a label check fails, or a receipt is missing, +the job fails and acceptance is withheld. The durable pending comment blocks +recreation. Inspect the failure and use the explicitly authorized +[no-create recovery contract](#deterministic-activation-and-no-create-recovery): ```bash gh run view --log-failed ``` -If a cap was actually reached, the report names which cap and lists the work -items that did not fit, and recommends `/squad plan activate phase {N}` to -continue in smaller batches. A cap is named only when it was *observed* — the -workflow forbids offering a cap as a guessed explanation, so an incomplete report -will not always attribute the shortfall to one. - -Work items created in the same run are identified in that report by the temporary -IDs the run minted — `#aw_epic{K}` and `#aw_task{N}` on the hierarchical path, -`#aw_ph{N}` for a fast-path phase issue and `#aw_wi{N}` for a fast-path work item -— rather than by issue number, because at that point creation is still deferred -and no real number exists yet. - -Do not read a green run as proof of complete activation: an omitted -`report_incomplete` signal cannot be detected by that handler. Check the -activation summary and verify the resulting issues and labels. +Do not retry an interrupted batch to fill missing issues, infer identity from +titles, or manually repair acceptance. A green run is not consumer authority; +use the validated binding record and current issue state. #### Verifying an activation: the bindings block Every phase and full activation artifact includes an `Activation bindings:` -fenced JSON block — one entry per created or recognized task, built only from -accepted operations. This is the most directly checkable surface Squad emits: a +fenced JSON block — one entry per created or explicitly mapped task, built from +actual IDs and validated frozen assignments. This is the most directly checkable surface Squad emits: a deterministic post-activation checker compares those bindings against the labels actually present on the issues. @@ -1697,11 +1922,13 @@ gh issue list --label squad --json number,title,labels A binding whose `label` does not match the labels actually on that issue is the discrepancy the checker exists to catch. -Where an owner did not become a `squad:{agent}` label — a multi-owner epic, or an -owner matching no roster name — the summary carries a required `Non-roster agent -values` heading naming the value and the issue, and the matching binding records -an omission reason. An omission is always reported explicitly, never left for you -to infer. +Multi-owner epics carry base `squad` plus explicit `multi-owner` omission metadata +instead of a fabricated sole-owner label. The retained `Non-roster agent values` +section names each affected epic and its owners, explaining that omission. +Non-roster plan owners fail before +creation; the supported external `@copilot` owner carries explicit identity +omission metadata and `squad:copilot`. Missing expected labels cannot be converted +into an omission to obtain acceptance. ### Incremental phase acceptance @@ -1713,14 +1940,15 @@ phase at a time: ``` This accepts Phase 1 **and automatically creates its GitHub issues** in a single -step. After completing Phase 1 work, continue with: +step. Before continuing, reconcile the complete prior-phase mapping against the +same frozen plan and registry as described above. Then continue with: ``` /squad plan accept implementation phase 2 ``` The accept command automatically activates the phase (creates issues) when all -prior phases are already activated. This means you don't need a separate +prior phases have a validated explicit mapping bridge. This means you don't need a separate `/squad plan activate phase N` command in the common case — accept does it all. If you need to activate a phase separately (e.g., you accepted it earlier but @@ -1735,12 +1963,13 @@ skipped auto-activation), use: - Phases must be activated in order (Phase 2 requires Phase 1 to be activated first) - Accept automatically activates when prior phases are ready (no separate activate needed) - Each acceptance/activation posts a summary showing created issues and remaining phases -- Dependencies in later phases automatically reference issue numbers from earlier phases +- Dependencies in later phases use the explicit validated prior-phase numeric mappings - `/squad plan accept implementation` with no phase arg still accepts everything (backward compatible) - `/squad plan activate` with no phase arg still activates everything (backward compatible) - The same pattern works for the legacy fast path: `/squad plan accept phase {N}` -This is useful for large projects where you want to review and iterate between phases — ship Phase 1, learn from it, then decide whether to adjust Phase 2's plan before accepting it. +This supports bounded sequential delivery. A changed plan or registry invalidates +the existing bridge; do not silently reuse its old mapping against new inputs. ### Plan revise @@ -2367,14 +2596,14 @@ other refs. | `/squad` command is ignored | Lock file not committed or workflow not compiled | Run `gh aw compile --strict`, commit the lock file, and push | | Universe is full on cast-member | All character names in the universe are allocated | Retire an unused member first, or re-cast with `/squad cast` | | "No plan found" on plan accept | No `/squad plan` comment exists yet | Run `/squad plan` first to generate a plan for review | -| Plan activation creates fewer issues than the accepted plan declares | The run ended early, or it reached the `create-issue` (75) or `add-labels` (110) safe-output cap | Inspect the Actions logs and originating issue's activation summary for the shortfall and any cap reached. Re-run the identical activation command (title matching resumes without duplicating existing issues), or activate one phase at a time with `/squad plan activate phase {N}` | -| Activation run is green but some issues are missing or unlabeled | The agent may have omitted `report_incomplete`, which would fail the conclusion step if emitted | A green run is not proof of a complete activation. Check the activation summary and Actions logs, then verify with `gh issue list --label squad`; automatic tracking-issue reporting is disabled | +| Plan activation creates fewer issues than the frozen plan declares | Creation was interrupted or an actual create result could not be verified; oversized batches fail before creation under the planning-policy limit (default 20, lean 10, enterprise 50, spike 5; hard ceiling 50, counting new epics) | Inspect Actions logs, receipts, the pending reservation, and current issues. Use [explicit current-state reconciliation](#deterministic-activation-and-no-create-recovery) with exact existing issue mappings and source phase coverage; never resume by title or rerun to create missing issues. Plan smaller phases before starting a fresh activation, not as a bypass for a pending batch | +| Activation run is green but some issues are missing or unlabeled | Workflow status alone is not consumer authority; current state may differ from the validated publication | Inspect the trusted publication receipt/readback, validated bindings, current labels, and hierarchy. Missing receipts or failed validation withhold acceptance; reconcile ambiguous history explicitly without creating issues. Do not rely on agent-emitted `report_incomplete` or a title/label search as proof | | A Squad label has no description and an unexpected color | It was auto-created on a fresh repo by `create-if-missing` | Expected, not a failure. Edit the label if you want a description or a specific color | | `/squad implement` cannot create a PR | Expected under Profile A (recommended); Actions is not allowed to create pull requests and there is no automatic fallback for this workflow | Push the worker's branch and open the PR manually, or switch to [Profile B](#profile-b-opt-in-automatic-pr-creation) if you accept its repository-wide self-approval tradeoff | | Epic implementation dispatches no workers | Every child is blocked or already has an open implementation PR | Merge dependency PRs, then run `/squad implement` on the epic again | | Standalone activation fails before init | Latest stable release lookup, bundle download, or mandatory checksum verification failed | Inspect installer diagnostics and restore complete, valid release assets, then use **Re-run failed jobs** | | Squad health fails | Initialization or committed team state is incomplete | Inspect the `Run Squad health check` JSON, correct the reported state, and rerun; no `squad-state` artifact is uploaded on failure | -| A command run was cancelled or its result is uncertain | The run stopped before a durable output was confirmed | Rerun the identical `/squad` command; Cast, implementation, activation, and review paths check existing GitHub state before creating output | +| A command run was cancelled or its result is uncertain | The run stopped before a durable output was confirmed | Check existing durable outputs before repeating Cast, implementation, or review commands. For activation, inspect the pending reservation and receipts and use explicit no-create current-state reconciliation; do not retry an interrupted batch to fill missing issues | --- diff --git a/packages/squad-sdk/src/casting/agent-provenance.ts b/packages/squad-sdk/src/casting/agent-provenance.ts index c7cb84763..f8818fec8 100644 --- a/packages/squad-sdk/src/casting/agent-provenance.ts +++ b/packages/squad-sdk/src/casting/agent-provenance.ts @@ -1,3 +1,5 @@ +import { createHash } from 'node:crypto'; + export const AGENT_PROVENANCE_SCHEMA = 'squad-agent-provenance/v1' as const; export const AGENT_PROVENANCE_VERSION = 1 as const; export const WORK_AGENT_BINDING_SCHEMA = 'squad-work-agent-binding/v1' as const; @@ -64,7 +66,7 @@ export interface WorkAgentBinding { producer: 'squad'; repository: string; origin_issue: number; - artifact: 'activated' | 'phases-activated' | 'plan-accepted' | 'phases-accepted'; + artifact: 'activated' | 'phases-activated' | 'plan-accepted' | 'phases-accepted' | 'activation-reconciled'; registry_schema: typeof AGENT_PROVENANCE_SCHEMA; registry_revision: number; task: string; @@ -81,6 +83,73 @@ export interface WorkAgentBindingContext { repository: string; originIssue: number; artifact: WorkAgentBinding['artifact']; + /** Full frozen-plan assignments supplied independently by the trusted verifier. */ + epicAssignments?: readonly EpicAssignment[]; +} + +/** + * One task's epic and agent assignment from the full frozen plan. + * Supply independently verified assignments when validating epic owner sets. + */ +export interface EpicAssignment { + task: string; + epic: string; + agent: string; + agentId: string | null; +} + +/** + * A comment ID and exact body supplied to activation selection, including any + * required source or predecessor. These fields alone do not authenticate its author. + */ +export interface ActivationRecord { + id: number; + body: string; +} + +/** + * Evidence supplied independently by a trusted API verifier, never derived from + * artifact claims or an `authorized` boolean. The verifier authenticates the + * request actor and authorization and freezes the current plan and registry. + * This reader does not authenticate authors or attest historical correctness. + */ +export interface ReconciliationEvidence { + requestCommentId: number; + requestSha256: string; + actorId: number; + sourceCommentId: number; + sourceSha256: string; + planSha256: string; + registrySha256: string; + bindingsSha256: string; + /** Ascending, unique positive safe phase IDs; omitted means whole-plan ([]). */ + phases?: readonly number[]; + /** Independently verified source kind; omission preserves legacy activation sources. */ + sourceKind?: 'activation' | 'pending'; +} + +/** + * Repository and origin scope plus independently verified reconciliation evidence. + * Supply one proof per reconciliation in a complete chain; caller-populated + * fields and frozen-plan assignments are not themselves authorization. + */ +export interface ActivationSelectionContext { + repository: string; + originIssue: number; + reconciliations: readonly ReconciliationEvidence[]; + /** Full frozen-plan assignments, never authorization claimed by an artifact. */ + epicAssignments?: readonly EpicAssignment[]; +} + +/** + * The selected comment's ID, artifact kind and validated work-agent bindings. + * Selection checks consistency against supplied inputs, not API authorship or + * historical publication; callers must establish those trust boundaries separately. + */ +export interface SelectedActivationRecord { + id: number; + artifact: WorkAgentBinding['artifact']; + bindings: WorkAgentBinding[]; } export class AgentProvenanceError extends Error { @@ -97,6 +166,27 @@ function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value); } +function isPositiveSafeInteger(value: unknown): value is number { + return typeof value === 'number' && Number.isSafeInteger(value) && value > 0; +} + +function numericIssueReference( + value: unknown, + path: string, + diagnostics: AgentProvenanceDiagnostic[], +): string { + if (typeof value !== 'string' || !/^#?[1-9]\d*$/.test(value) + || !isPositiveSafeInteger(Number(value.replace(/^#/, '')))) { + diagnostics.push({ path, message: 'must be a positive safe integer issue reference' }); + return ''; + } + return value; +} + +function issueReferenceKey(value: string): string { + return value.replace(/^#/, ''); +} + function isTimestamp(value: unknown): value is string { return typeof value === 'string' && value.length > 0 && !Number.isNaN(Date.parse(value)); } @@ -235,6 +325,43 @@ export function parseWorkAgentBindings( throw new AgentProvenanceError('Work-agent bindings must be a non-empty array'); } const diagnostics: AgentProvenanceDiagnostic[] = []; + const assignmentsByTask = new Map(); + const assignmentsByEpic = new Map(); + if (context.epicAssignments !== undefined) { + if (!Array.isArray(context.epicAssignments) || context.epicAssignments.length === 0) { + throw new AgentProvenanceError('Verifier epic assignments must be a non-empty array'); + } + for (const assignment of context.epicAssignments) { + if (!isRecord(assignment) + || typeof assignment.task !== 'string' || !assignment.task || /\s/.test(assignment.task) + || typeof assignment.epic !== 'string' || !assignment.epic || /\s/.test(assignment.epic) + || typeof assignment.agent !== 'string' || !assignment.agent.trim() + || assignmentsByTask.has(assignment.task)) { + throw new AgentProvenanceError('Verifier epic assignments require unique stable task IDs and complete identities'); + } + const agentName = normalizedDisplayName(assignment.agent); + if (assignment.agentId === null) { + if (Object.values(registry.agents).some((agent) => + normalizedDisplayName(agent.display_name) === normalizedDisplayName(agentName))) { + throw new AgentProvenanceError('Verifier epic assignment omits a registered agent identity'); + } + } else if (typeof assignment.agentId !== 'string' || !validateId(assignment.agentId) + || !registry.agents[assignment.agentId] + || normalizedDisplayName(registry.agents[assignment.agentId]!.display_name) !== agentName) { + throw new AgentProvenanceError('Verifier epic assignment agent and display identity do not match the registry'); + } + const validatedAssignment: EpicAssignment = { + task: assignment.task, + epic: assignment.epic, + agent: agentName, + agentId: assignment.agentId as string | null, + }; + assignmentsByTask.set(assignment.task, validatedAssignment); + const epicAssignments = assignmentsByEpic.get(assignment.epic) ?? []; + epicAssignments.push(validatedAssignment); + assignmentsByEpic.set(assignment.epic, epicAssignments); + } + } const bindings: WorkAgentBinding[] = []; const issues = new Set(); const tasks = new Set(); @@ -253,9 +380,9 @@ export function parseWorkAgentBindings( const registrySchema = rawBinding.registry_schema; const registryRevision = rawBinding.registry_revision; const task = requiredString(rawBinding.task, `${path}.task`, diagnostics); - const issue = requiredString(rawBinding.issue, `${path}.issue`, diagnostics); + const issue = numericIssueReference(rawBinding.issue, `${path}.issue`, diagnostics); const epic = requiredString(rawBinding.epic, `${path}.epic`, diagnostics); - const epicIssue = requiredString(rawBinding.epic_issue, `${path}.epic_issue`, diagnostics); + const epicIssue = numericIssueReference(rawBinding.epic_issue, `${path}.epic_issue`, diagnostics); const agentId = rawBinding.agent_id === null ? null : requiredString(rawBinding.agent_id, `${path}.agent_id`, diagnostics); @@ -266,6 +393,31 @@ export function parseWorkAgentBindings( requiredString(value, `${path}.epic_agent_ids.${agentIndex}`, diagnostics)) : []; + if (context.epicAssignments !== undefined) { + const assignment = assignmentsByTask.get(task); + if (!assignment || rawBinding.task !== assignment.task || rawBinding.epic !== assignment.epic + || rawBinding.agent_id !== assignment.agentId + || (rawBinding.agent !== undefined && (typeof rawBinding.agent !== 'string' + || normalizedDisplayName(rawBinding.agent) !== assignment.agent))) { + diagnostics.push({ + path, message: 'must exactly match a task, epic, and agent in the verifier frozen-plan assignments', + }); + } + if (rawBinding.epic_agents !== undefined) { + const expectedNames = [...new Set( + (assignmentsByEpic.get(epic) ?? []).map((entry) => entry.agent), + )].sort(); + if (!Array.isArray(rawBinding.epic_agents) + || rawBinding.epic_agents.some((name) => typeof name !== 'string') + || JSON.stringify(rawBinding.epic_agents.map(normalizedDisplayName).sort()) !== JSON.stringify(expectedNames)) { + diagnostics.push({ + path: `${path}.epic_agents`, + message: 'must equal the complete agent name set in the verifier frozen-plan assignments', + }); + } + } + } + if (bindingSchema !== WORK_AGENT_BINDING_SCHEMA) { diagnostics.push({ path: `${path}.binding_schema`, message: `must be ${WORK_AGENT_BINDING_SCHEMA}` }); } @@ -278,7 +430,7 @@ export function parseWorkAgentBindings( if (repository !== context.repository) { diagnostics.push({ path: `${path}.repository`, message: 'does not match the artifact repository' }); } - if (!Number.isInteger(originIssue) || originIssue !== context.originIssue) { + if (!isPositiveSafeInteger(originIssue) || originIssue !== context.originIssue) { diagnostics.push({ path: `${path}.origin_issue`, message: 'does not match the artifact origin issue' }); } if (artifact !== context.artifact) { @@ -339,10 +491,10 @@ export function parseWorkAgentBindings( }); } } - if (issue && issues.has(issue)) { + if (issue && issues.has(issueReferenceKey(issue))) { diagnostics.push({ path: `${path}.issue`, message: 'duplicates another work binding' }); } - if (issue) issues.add(issue); + if (issue) issues.add(issueReferenceKey(issue)); if (task && tasks.has(task)) { diagnostics.push({ path: `${path}.task`, message: 'duplicates another work binding' }); } @@ -379,6 +531,23 @@ export function parseWorkAgentBindings( throw new AgentProvenanceError('Work-agent bindings are malformed or partial', diagnostics); } + const epicIssueKeys = new Set(bindings.map((binding) => issueReferenceKey(binding.epic_issue))); + for (const [index, binding] of bindings.entries()) { + const taskIssueKey = issueReferenceKey(binding.issue); + if (taskIssueKey === String(context.originIssue) || epicIssueKeys.has(taskIssueKey)) { + diagnostics.push({ + path: `bindings.${index}.issue`, + message: 'task issue must not be the origin issue or any epic issue', + }); + } + if (binding.epic !== 'origin' && issueReferenceKey(binding.epic_issue) === String(context.originIssue)) { + diagnostics.push({ + path: `bindings.${index}.epic_issue`, + message: 'only the origin epic may reference the origin issue', + }); + } + } + const expectedRegistryRevision = bindings[0]!.registry_revision; const epicIssueByIdentifier = new Map(); const epicIdentifierByIssue = new Map(); @@ -393,38 +562,40 @@ export function parseWorkAgentBindings( }); } + const epicIssueKey = issueReferenceKey(binding.epic_issue); const priorEpicIssue = epicIssueByIdentifier.get(binding.epic); - if (priorEpicIssue !== undefined && priorEpicIssue !== binding.epic_issue) { + if (priorEpicIssue !== undefined && priorEpicIssue !== epicIssueKey) { diagnostics.push({ path: `${path}.epic_issue`, message: `epic ${binding.epic} maps to multiple epic issues`, }); } else { - epicIssueByIdentifier.set(binding.epic, binding.epic_issue); + epicIssueByIdentifier.set(binding.epic, epicIssueKey); } - const priorEpicIdentifier = epicIdentifierByIssue.get(binding.epic_issue); + const priorEpicIdentifier = epicIdentifierByIssue.get(epicIssueKey); if (priorEpicIdentifier !== undefined && priorEpicIdentifier !== binding.epic) { diagnostics.push({ path: `${path}.epic`, message: `epic issue ${binding.epic_issue} maps to multiple epic identifiers`, }); } else { - epicIdentifierByIssue.set(binding.epic_issue, binding.epic); + epicIdentifierByIssue.set(epicIssueKey, binding.epic); } - const epicBindings = bindingsByEpicIssue.get(binding.epic_issue) ?? []; + const epicBindings = bindingsByEpicIssue.get(epicIssueKey) ?? []; epicBindings.push({ binding, index }); - bindingsByEpicIssue.set(binding.epic_issue, epicBindings); + bindingsByEpicIssue.set(epicIssueKey, epicBindings); } for (const epicBindings of bindingsByEpicIssue.values()) { + const completeAgentIds = context.epicAssignments === undefined + ? epicBindings.map(({ binding }) => binding.agent_id) + : assignmentsByEpic.get(epicBindings[0]!.binding.epic)!.map((assignment) => assignment.agentId); const expectedAgentIds = [...new Set( - epicBindings - .map(({ binding }) => binding.agent_id) - .filter((agentId): agentId is string => agentId !== null), + completeAgentIds.filter((agentId): agentId is string => agentId !== null), )].sort(); - const hasIdentityOmission = epicBindings.some(({ binding }) => binding.agent_id === null); + const hasIdentityOmission = completeAgentIds.some((agentId) => agentId === null); for (const { binding, index } of epicBindings) { const path = `bindings.${index}`; @@ -455,6 +626,391 @@ export function parseWorkAgentBindings( return bindings; } +const ACTIVATION_ARTIFACTS: readonly WorkAgentBinding['artifact'][] = [ + 'activated', 'phases-activated', 'plan-accepted', 'phases-accepted', 'activation-reconciled', +]; + +function sha256(value: string): string { + return createHash('sha256').update(value, 'utf8').digest('hex'); +} + +function readActivationSection(body: string, label: string): unknown { + const labels = body.match(new RegExp(`^${label}:[ \\t]*\\r?$`, 'gm')) ?? []; + const blocks = [...body.matchAll(new RegExp( + `^${label}:[ \\t]*\\r?\\n\\s*\`\`\`json[ \\t]*\\r?\\n([\\s\\S]*?)^\`\`\`[ \\t]*\\r?$`, + 'gm', + ))]; + if (labels.length !== 1 || blocks.length !== 1) { + throw new AgentProvenanceError(`Activation record requires exactly one ${label} JSON section`); + } + try { + return JSON.parse(blocks[0]![1]!); + } catch { + throw new AgentProvenanceError(`Malformed ${label} JSON section`); + } +} + +function activationEnvelope(value: unknown, originIssue: number): WorkAgentBinding['artifact'] { + if (!isRecord(value) + || !ACTIVATION_ARTIFACTS.includes(value.squad_artifact as WorkAgentBinding['artifact']) + || value.schema_version !== '1' + || !isPositiveSafeInteger(value.origin_issue) + || value.origin_issue !== originIssue + || !Array.isArray(value.phases)) { + throw new AgentProvenanceError('Unknown, malformed, or cross-origin activation envelope'); + } + if (!value.phases.every(isPositiveSafeInteger) || new Set(value.phases).size !== value.phases.length) { + throw new AgentProvenanceError('Malformed activation phase scope: expected unique positive safe integers'); + } + return value.squad_artifact as WorkAgentBinding['artifact']; +} + +function isCanonicalPhases(value: unknown): value is number[] { + return Array.isArray(value) && Array.from(value).every((phase, index, phases) => + isPositiveSafeInteger(phase) && (index === 0 || phase > phases[index - 1])); +} + +function readPendingSourceBody(body: string): unknown { + const fences = body.match(/^```[^\r\n]*\r?$/gm) ?? []; + const blocks = [...body.matchAll(/^```json[ \t]*\r?\n([\s\S]*?)^```[ \t]*\r?$/gm)]; + if (/Structured data:|Activation bindings:|Activation reconciliation:/.test(body) + || fences.length !== 2 || blocks.length !== 1) { + throw new AgentProvenanceError('Pending source requires exactly one bare JSON fence'); + } + try { + return JSON.parse(blocks[0]![1]!); + } catch { + throw new AgentProvenanceError('Malformed pending source JSON'); + } +} + +function classifyActivationHistory( + record: ActivationRecord, + originIssue: number, +): WorkAgentBinding['artifact'] | 'activation-pending' | undefined { + // Pending reservations belong to history but are never acceptance candidates. + try { + const pending = readPendingSourceBody(record.body); + if (isRecord(pending) && pending.schema === 'squad-activation-pending/v1') return 'activation-pending'; + } catch (error) { + if (record.body.includes('squad-activation-pending/v1')) throw error; + } + const reconciliationMarker = /Activation reconciliation:|activation-reconciled/.test(record.body); + const activationMarker = reconciliationMarker || /Activation bindings:/.test(record.body); + if (!/Structured data:/.test(record.body) && !activationMarker) return undefined; + let envelope: unknown; + try { + envelope = readActivationSection(record.body, 'Structured data'); + } catch (error) { + if (activationMarker || /activated|plan-accepted|phases-accepted/.test(record.body)) throw error; + return undefined; + } + const knownArtifact = isRecord(envelope) + && ACTIVATION_ARTIFACTS.includes(envelope.squad_artifact as WorkAgentBinding['artifact']); + if (!knownArtifact && !activationMarker) return undefined; + if (!reconciliationMarker && isRecord(envelope) && knownArtifact + && envelope.squad_artifact !== 'activation-reconciled' + && isPositiveSafeInteger(envelope.origin_issue) && envelope.origin_issue !== originIssue) { + return undefined; + } + const artifact = activationEnvelope(envelope, originIssue); + if (reconciliationMarker && artifact !== 'activation-reconciled') { + throw new AgentProvenanceError('Reconciliation requires activation-reconciled artifact'); + } + return artifact; +} + +/** + * Select a unique activation using independently verified current-state evidence. + * Digests use SHA-256 over UTF-8: source bodies are hashed verbatim, registry is + * JSON.stringify(registry), and bindings are JSON.stringify(the full parsed JSON + * array), before binding validation normalizes or drops any fields. + * Historical invalid bindings are tolerated only in an explicitly superseded + * old record; malformed reconciliation never falls back to older activation. + */ +function selectActivationPair( + records: readonly ActivationRecord[], + registry: AgentProvenanceRegistry, + context: ActivationSelectionContext, +): SelectedActivationRecord | undefined { + if (!isPositiveSafeInteger(context.originIssue) || !context.repository + || !Array.isArray(context.reconciliations)) { + throw new AgentProvenanceError('Invalid activation selection context'); + } + const evidenceFields = { + request_comment_id: 'requestCommentId', + request_sha256: 'requestSha256', + actor_id: 'actorId', + supersedes_comment_id: 'sourceCommentId', + supersedes_sha256: 'sourceSha256', + plan_sha256: 'planSha256', + registry_sha256: 'registrySha256', + bindings_sha256: 'bindingsSha256', + } as const; + const validEvidenceValue = (field: string, value: unknown): boolean => + field.endsWith('_id') ? isPositiveSafeInteger(value) + : typeof value === 'string' && /^[0-9a-f]{64}$/.test(value); + for (const evidence of context.reconciliations) { + if (!isRecord(evidence) || Object.entries(evidenceFields).some(([field, key]) => + !validEvidenceValue(field, evidence[key]))) { + throw new AgentProvenanceError('Malformed verifier reconciliation evidence'); + } + if (evidence.phases !== undefined && !isCanonicalPhases(evidence.phases)) { + throw new AgentProvenanceError('Malformed verifier reconciliation phase scope'); + } + if (evidence.sourceKind !== undefined + && evidence.sourceKind !== 'activation' && evidence.sourceKind !== 'pending') { + throw new AgentProvenanceError('Malformed verifier reconciliation source kind'); + } + } + const byId = new Map(); + const candidates: Array<{ record: ActivationRecord; artifact: WorkAgentBinding['artifact'] }> = []; + for (const record of records) { + if (!isPositiveSafeInteger(record.id) || typeof record.body !== 'string' || byId.has(record.id)) { + throw new AgentProvenanceError('Invalid or duplicate activation record id'); + } + byId.set(record.id, record); + const artifact = classifyActivationHistory(record, context.originIssue); + if (artifact === undefined || artifact === 'activation-pending') continue; + candidates.push({ record, artifact }); + } + const reconciled = candidates.filter(({ artifact }) => artifact === 'activation-reconciled'); + if (reconciled.length > 1) { + throw new AgentProvenanceError('Competing activation reconciliations'); + } + const reconciliation = reconciled[0]; + if (reconciliation) { + const { record, artifact } = reconciliation; + const metadata = readActivationSection(record.body, 'Activation reconciliation'); + if (!isRecord(metadata) + || metadata.schema !== 'squad-activation-reconciliation/v1' + || metadata.basis !== 'current-state-authorized' + || (metadata.supersedes_kind !== undefined && metadata.supersedes_kind !== 'pending') + || Object.keys(metadata).length !== Object.keys(evidenceFields).length + + (metadata.supersedes_kind === 'pending' ? 3 : 2) + || Object.keys(evidenceFields).some((field) => !validEvidenceValue(field, metadata[field]))) { + throw new AgentProvenanceError('Malformed activation reconciliation metadata'); + } + const matchingEvidence = context.reconciliations.filter((entry) => + (entry.sourceKind ?? 'activation') === (metadata.supersedes_kind ?? 'activation') + && Object.entries(evidenceFields).every(([field, key]) => metadata[field] === entry[key])); + if (matchingEvidence.length !== 1) { + throw new AgentProvenanceError('Activation reconciliation requires exactly one matching verifier evidence record'); + } + const evidence = matchingEvidence[0]!; + const envelope = readActivationSection(record.body, 'Structured data') as Record; + if (JSON.stringify(envelope.phases) !== JSON.stringify(evidence.phases ?? [])) { + throw new AgentProvenanceError('Activation reconciliation phase scope does not match verifier evidence'); + } + const source = byId.get(evidence.sourceCommentId); + if (!source || sha256(source.body) !== evidence.sourceSha256) { + throw new AgentProvenanceError('Activation reconciliation source body digest mismatch'); + } + if (evidence.sourceKind === 'pending') { + const pending = readPendingSourceBody(source.body); + if (!isRecord(pending) || pending.schema !== 'squad-activation-pending/v1' + || pending.repository !== context.repository + || !isPositiveSafeInteger(pending.origin_issue) || pending.origin_issue !== context.originIssue + || ['run_id', 'run_attempt', 'capture_artifact_id', 'plan_comment_id'] + .some((field) => !isPositiveSafeInteger(pending[field])) + || ['capture_sha256', 'plan_sha256', 'registry_sha256'] + .some((field) => typeof pending[field] !== 'string' || !/^[0-9a-f]{64}$/.test(pending[field])) + || !isCanonicalPhases(pending.phases) + || pending.plan_sha256 !== evidence.planSha256 + || pending.registry_sha256 !== evidence.registrySha256 + || JSON.stringify(pending.phases) !== JSON.stringify(evidence.phases ?? [])) { + throw new AgentProvenanceError('Malformed pending source or mismatched verifier plan, registry, or phase scope'); + } + } else { + const sourceEnvelope = readActivationSection(source.body, 'Structured data'); + const sourceArtifact = activationEnvelope(sourceEnvelope, context.originIssue); + if (JSON.stringify((sourceEnvelope as Record).phases) + !== JSON.stringify(evidence.phases ?? [])) { + throw new AgentProvenanceError('Activation reconciliation source phase scope does not match verifier evidence'); + } + if (sourceArtifact === 'activation-reconciled' || /Activation reconciliation:/.test(source.body)) { + throw new AgentProvenanceError('Activation reconciliation cannot supersede a reconciliation'); + } + } + if (candidates.some((candidate) => candidate.record.id !== record.id + && candidate.record.id !== source.id)) { + throw new AgentProvenanceError('Ambiguous unsuperseded activation records'); + } + const rawBindings = readActivationSection(record.body, 'Activation bindings'); + if (sha256(JSON.stringify(registry)) !== evidence.registrySha256 + || sha256(JSON.stringify(rawBindings)) !== evidence.bindingsSha256) { + throw new AgentProvenanceError('Activation reconciliation registry or bindings digest mismatch'); + } + return { + id: record.id, + artifact, + bindings: parseWorkAgentBindings(rawBindings, parseAgentProvenanceRegistry(registry), { + ...context, artifact, + }), + }; + } + if (context.reconciliations.length > 0) { + throw new AgentProvenanceError('Verifier reconciliation evidence has no matching activation record'); + } + if (candidates.length > 1) { + throw new AgentProvenanceError('Ambiguous activation records'); + } + const candidate = candidates[0]; + if (!candidate) return undefined; + return { + id: candidate.record.id, + artifact: candidate.artifact, + bindings: parseWorkAgentBindings( + readActivationSection(candidate.record.body, 'Activation bindings'), + parseAgentProvenanceRegistry(registry), + { ...context, artifact: candidate.artifact }, + ), + }; +} + +/** + * Select one scoped pair or one complete, independently authorized phase chain. + * Full histories require one verifier proof per reconciliation; links in source + * bodies provide only hash-bound ancestry data, never authorization or recency. + */ +export function selectActivationRecord( + records: readonly ActivationRecord[], + registry: AgentProvenanceRegistry, + context: ActivationSelectionContext, +): SelectedActivationRecord | undefined { + const nodes = records.flatMap((record) => { + try { + const envelope = readActivationSection(record.body, 'Structured data'); + return isRecord(envelope) && envelope.squad_artifact === 'activation-reconciled' + ? [{ record, envelope }] : []; + } catch { + return []; + } + }); + if (nodes.length <= 1 && context.reconciliations.length <= 1) { + return selectActivationPair(records, registry, context); + } + const fail = (message: string): never => { + throw new AgentProvenanceError(`Activation chain: ${message}`); + }; + if (nodes.length < 2 || nodes.length !== context.reconciliations.length) { + fail('missing or extra verifier proofs'); + } + const byId = new Map(); + for (const record of records) { + if (!isPositiveSafeInteger(record.id) || typeof record.body !== 'string' || byId.has(record.id)) { + fail('duplicate or malformed record identity'); + } + byId.set(record.id, record); + } + const usedRecords = new Set(); + const usedProofs = new Set(); + const usedRequests = new Set(); + const validated = new Map(); + for (const { record } of nodes) { + const metadata = readActivationSection(record.body, 'Activation reconciliation') as Record; + const proofs = context.reconciliations.filter((proof) => + proof.requestCommentId === metadata.request_comment_id && proof.requestSha256 === metadata.request_sha256 + && proof.actorId === metadata.actor_id && proof.sourceCommentId === metadata.supersedes_comment_id + && proof.sourceSha256 === metadata.supersedes_sha256 && proof.planSha256 === metadata.plan_sha256 + && proof.registrySha256 === metadata.registry_sha256 && proof.bindingsSha256 === metadata.bindings_sha256 + && (proof.sourceKind ?? 'activation') === (metadata.supersedes_kind ?? 'activation')); + if (proofs.length !== 1) fail('missing or duplicate matching verifier proof'); + const proof = proofs[0]!; + const source = byId.get(proof.sourceCommentId) ?? fail('missing source'); + if (usedProofs.has(proof) || usedRequests.has(proof.requestCommentId) + || usedRecords.has(source.id) || usedRecords.has(record.id)) { + fail('missing source or competing reconciliation'); + } + const selected = selectActivationPair([source, record], registry, { + ...context, reconciliations: [proof], + }); + if (!selected || selected.id !== record.id) fail('invalid reconciliation node'); + const first = validated.values().next().value; + if (first && (proof.planSha256 !== first.proof.planSha256 + || proof.registrySha256 !== first.proof.registrySha256)) fail('inconsistent frozen plan or registry'); + let carrier: Record | undefined; + if (proof.sourceKind === 'pending') { + carrier = readPendingSourceBody(source.body) as Record; + } else if (source.body.includes('Activation evidence:')) { + const evidence = readActivationSection(source.body, 'Activation evidence'); + if (!isRecord(evidence) || evidence.schema !== 'squad-activation-finalization/v1' + || evidence.basis !== 'current-trusted-execution' + || evidence.plan_sha256 !== proof.planSha256 || evidence.registry_sha256 !== proof.registrySha256) { + fail('invalid finalization source link carrier'); + } + carrier = evidence as Record; + } + usedRecords.add(source.id); + usedRecords.add(record.id); + usedProofs.add(proof); + usedRequests.add(proof.requestCommentId); + validated.set(record.id, { selected: selected!, source, proof, prior: carrier?.prior }); + } + if (records.some(record => !usedRecords.has(record.id) + && classifyActivationHistory(record, context.originIssue) !== undefined) + || usedProofs.size !== context.reconciliations.length) { + fail('unconsumed history records or verifier proofs'); + } + const parents = new Map(); + const children = new Set(); + for (const [id, node] of validated) { + if (node.prior === undefined) continue; + const link = node.prior; + const keys = ['source_comment_id', 'source_sha256', 'reconciliation_comment_id', 'reconciliation_sha256']; + if (!isRecord(link) || Object.keys(link).sort().join() !== [...keys].sort().join() + || !isPositiveSafeInteger(link.source_comment_id) || !isPositiveSafeInteger(link.reconciliation_comment_id) + || ![link.source_sha256, link.reconciliation_sha256].every((hash) => + typeof hash === 'string' && /^[a-f0-9]{64}$/.test(hash))) fail('malformed prior link'); + const reference = link as Record; + const parentId = reference.reconciliation_comment_id as number; + const parent = validated.get(parentId); + if (!parent || parent.source.id !== reference.source_comment_id + || sha256(parent.source.body) !== reference.source_sha256 + || sha256(byId.get(parentId)!.body) !== reference.reconciliation_sha256) { + fail('unknown, missing, or changed ancestor'); + } + if (children.has(parentId)) fail('forked phase history'); + const before = parent!.proof.phases ?? []; + const after = node.proof.phases ?? []; + if (before.length === 0 || before.length >= after.length || !before.every((phase, index) => after[index] === phase)) { + fail('phase coverage must strictly expand'); + } + const current = new Map(node.selected.bindings.map((binding) => [binding.task, binding])); + for (const priorBinding of parent!.selected.bindings) { + const next = current.get(priorBinding.task); + if (!next || issueReferenceKey(next.issue) !== issueReferenceKey(priorBinding.issue) + || next.epic !== priorBinding.epic + || issueReferenceKey(next.epic_issue) !== issueReferenceKey(priorBinding.epic_issue) + || next.agent_id !== priorBinding.agent_id + || next.registry_revision !== priorBinding.registry_revision + || [...next.epic_agent_ids].sort().join() !== [...priorBinding.epic_agent_ids].sort().join() + || next.identity_omission_reason !== priorBinding.identity_omission_reason + || next.epic_identity_omission_reason !== priorBinding.epic_identity_omission_reason) { + fail('prior task or epic bindings changed'); + } + } + parents.set(id, parentId); + children.add(parentId); + } + const heads = [...validated.keys()].filter((id) => !children.has(id)); + if (heads.length !== 1) fail('competing heads or cyclic history'); + const visited = new Set(); + let id: number | undefined = heads[0]; + while (id !== undefined) { + if (visited.has(id)) fail('cyclic history'); + visited.add(id); + id = parents.get(id); + } + if (visited.size !== validated.size) fail('disconnected or cyclic history'); + return validated.get(heads[0]!)!.selected; +} + /** * Parse a producer-owned registry. Invalid individual records are excluded and * reported as partial; invalid or unsupported roots fail closed. diff --git a/packages/squad-sdk/src/casting/index.ts b/packages/squad-sdk/src/casting/index.ts index f0067b577..031eb0e18 100644 --- a/packages/squad-sdk/src/casting/index.ts +++ b/packages/squad-sdk/src/casting/index.ts @@ -35,6 +35,10 @@ export { parseAgentProvenanceRegistry, parseWorkAgentBindings, reconcileAgentProvenanceRegistry, + selectActivationRecord, + type ActivationRecord, + type ActivationSelectionContext, + type EpicAssignment, type AgentAvatarReference, type AgentProvenanceCandidate, type AgentProvenanceDiagnostic, @@ -43,6 +47,8 @@ export { type AgentProvenanceRegistry, type AgentProvenanceStatus, type ReconcileAgentProvenanceOptions, + type ReconciliationEvidence, + type SelectedActivationRecord, type WorkAgentBinding, type WorkAgentBindingContext, } from './agent-provenance.js'; diff --git a/scripts/check-agent-binding.mjs b/scripts/check-agent-binding.mjs index 219ea0cdc..46dabbf4b 100644 --- a/scripts/check-agent-binding.mjs +++ b/scripts/check-agent-binding.mjs @@ -1,764 +1,7 @@ -import { readFile } from 'node:fs/promises'; -import { createHash } from 'node:crypto'; -import { dirname, join } from 'node:path'; import { pathToFileURL } from 'node:url'; +import { main } from '../workflows/shared/check-agent-binding.mjs'; -// `activated` / `phases-activated` come from the granular `/squad plan activate` path -// (`squad-plan-activate`); `plan-accepted` / `phases-accepted` come from the recommended -// `/squad activate` fast path (`squad-plan-accept`). Both paths create or recognize issues -// and apply labels the same way, so both carry the same mandatory `Activation bindings:` -// contract — see workflows/shared/squad-planning-ontology.md and the E4 preflight package A -// fast-path artifact-integrity fix. -const ACTIVATION_ARTIFACTS = new Set(['activated', 'phases-activated', 'plan-accepted', 'phases-accepted']); -const ACTIVATION_ARTIFACT_PATTERN = new RegExp( - `"squad_artifact"\\s*:\\s*"?(?:${[...ACTIVATION_ARTIFACTS].join('|')})`, - 'i', -); -const VALID_OMISSIONS = new Set(['multi-owner', 'non-roster']); -const TEMPORARY_ID = /^#?aw_[A-Za-z0-9_]{3,12}$/i; -const RESOLVED_REFERENCE = /^#?(\d+)$/; -const AGENT_PROVENANCE_SCHEMA = 'squad-agent-provenance/v1'; -const WORK_AGENT_BINDING_SCHEMA = 'squad-work-agent-binding/v1'; -const CASTING_HISTORY_FIELDS = new Set([ - 'assignment_cast_snapshots', - 'universe_usage_history', - 'transaction_id', - 'registry_revision', -]); - -function parseCastingObject(raw, label) { - let value; - try { - value = JSON.parse(raw); - } catch (error) { - throw new Error(`${label} is invalid JSON: ${error.message}`); - } - if (!value || typeof value !== 'object' || Array.isArray(value)) { - throw new Error(`${label} root is malformed`); - } - return value; -} - -function validateCastingHistory(history, registry) { - const unknownField = Object.keys(history).find(key => !CASTING_HISTORY_FIELDS.has(key)); - if (unknownField !== undefined) { - throw new Error(`casting history contains unknown top-level field "${unknownField}"`); - } - if (!history.assignment_cast_snapshots - || typeof history.assignment_cast_snapshots !== 'object' - || Array.isArray(history.assignment_cast_snapshots) - || !Array.isArray(history.universe_usage_history)) { - throw new Error('casting history shape is malformed'); - } - for (const [key, snapshot] of Object.entries(history.assignment_cast_snapshots)) { - if (!key || !snapshot || typeof snapshot !== 'object' || Array.isArray(snapshot) - || typeof snapshot.created_at !== 'string' - || !Number.isFinite(Date.parse(snapshot.created_at)) - || !Array.isArray(snapshot.agents) - || snapshot.agents.some(agent => typeof agent !== 'string' || !(agent in registry.agents)) - || typeof snapshot.universe !== 'string' || !snapshot.universe) { - throw new Error('casting history snapshot is malformed or references unknown agents'); - } - } - for (const usage of history.universe_usage_history) { - if (!usage || typeof usage !== 'object' || Array.isArray(usage) - || typeof usage.universe !== 'string' || !usage.universe - || typeof usage.used_at !== 'string' - || !Number.isFinite(Date.parse(usage.used_at))) { - throw new Error('casting universe usage history is malformed'); - } - } -} - -function isCanonicalLegacyGenesis(registry, history) { - const agents = registry.agents; - const agentIds = Object.keys(agents); - const snapshots = Object.entries(history.assignment_cast_snapshots); - const usage = history.universe_usage_history; - if (agentIds.length === 0 || snapshots.length !== 1 || usage.length !== 1) { - return false; - } - - const snapshotEntry = snapshots[0]; - if (!snapshotEntry) return false; - const [snapshotKey, rawSnapshot] = snapshotEntry; - const rawUsage = usage[0]; - if (hasRevisionToken(snapshotKey) - || !rawSnapshot - || typeof rawSnapshot !== 'object' - || Array.isArray(rawSnapshot) - || !rawUsage - || typeof rawUsage !== 'object' - || Array.isArray(rawUsage)) { - return false; - } - const snapshot = rawSnapshot; - const usageRecord = rawUsage; - const snapshotAgents = snapshot.agents; - const snapshotCreatedAt = Date.parse(snapshot.created_at); - const generatedAt = Date.parse(registry.generated_at); - if (!Array.isArray(snapshotAgents) - || snapshotAgents.some(agentId => typeof agentId !== 'string') - || new Set(snapshotAgents).size !== snapshotAgents.length - || snapshotAgents.length !== agentIds.length - || snapshotAgents.some(agentId => !Object.hasOwn(agents, agentId)) - || typeof snapshot.universe !== 'string' - || snapshot.universe.length === 0 - || !Number.isFinite(snapshotCreatedAt) - || snapshotCreatedAt > generatedAt - || usageRecord.universe !== snapshot.universe - || Date.parse(usageRecord.used_at) !== snapshotCreatedAt) { - return false; - } - - return Object.values(agents).every(agent => - agent.status === 'active' - && agent.universe === snapshot.universe - && Date.parse(agent.created_at) === snapshotCreatedAt); -} - -const REVISION_TOKEN_PATTERN = /(?:^|[-_])(?:revision[-_]*\d+|r\d+)(?=[-_]|$)/i; - -function hasRevisionToken(snapshotKey) { - return REVISION_TOKEN_PATTERN.test(snapshotKey); -} - -function validateLegacyCastingGeneration(registry, history) { - if (registry.transaction_id !== undefined - || history.transaction_id !== undefined - || history.registry_revision !== undefined) { - throw new Error('casting generation metadata exists without a commit manifest'); - } - const snapshots = Object.entries(history.assignment_cast_snapshots); - if (registry.revision === 1 && Object.keys(registry.agents).length === 0 - && snapshots.length === 0 && history.universe_usage_history.length === 0) { - return; - } - if (registry.revision === 1 && isCanonicalLegacyGenesis(registry, history)) { - return; - } - const revisions = new Set(); - let currentGeneration = false; - const generatedAt = Date.parse(registry.generated_at); - const snapshotEvidence = new Map(); - for (const [key, snapshot] of snapshots) { - const match = key.match(/(?:^|[-_])(?:revision-|r)(\d+)(?:[-_]|$)/i); - if (!match) throw new Error('casting history snapshot lacks generation evidence'); - const revision = Number(match[1]); - if (!Number.isSafeInteger(revision) || revision < 1 || revision > registry.revision - || revisions.has(revision) || Date.parse(snapshot.created_at) > generatedAt) { - throw new Error('casting registry/history pair is mixed-generation'); - } - revisions.add(revision); - if (revision === registry.revision && Date.parse(snapshot.created_at) === generatedAt) { - currentGeneration = true; - } - const evidence = `${snapshot.universe}\u0000${snapshot.created_at}`; - snapshotEvidence.set(evidence, (snapshotEvidence.get(evidence) ?? 0) + 1); - } - const usageEvidence = new Map(); - for (const usage of history.universe_usage_history) { - if (Date.parse(usage.used_at) > generatedAt) { - throw new Error('casting registry/history pair is mixed-generation'); - } - const evidence = `${usage.universe}\u0000${usage.used_at}`; - usageEvidence.set(evidence, (usageEvidence.get(evidence) ?? 0) + 1); - } - const complete = Array.from({ length: registry.revision }, (_, index) => index + 1); - const implicitGenesis = registry.revision === 1 ? complete : complete.slice(1); - const revisionEvidence = [complete, implicitGenesis].some(expected => - expected.length === revisions.size && expected.every(revision => revisions.has(revision))); - const matchingEvidence = snapshotEvidence.size === usageEvidence.size - && [...snapshotEvidence].every(([key, count]) => usageEvidence.get(key) === count); - if (!revisionEvidence || !currentGeneration || !matchingEvidence) { - throw new Error('casting registry/history pair is mixed-generation'); - } -} - -async function readOptionalFile(path) { - try { - return await readFile(path, 'utf8'); - } catch (error) { - if (error?.code === 'ENOENT') return undefined; - throw error; - } -} - -export async function validateCastingPairFiles(registryFile) { - const castingDir = dirname(registryFile); - const [registryRaw, historyRaw, journalRaw, manifestRaw] = await Promise.all([ - readOptionalFile(registryFile), - readOptionalFile(join(castingDir, 'history.json')), - readOptionalFile(join(castingDir, 'registry-history.transaction.json')), - readOptionalFile(join(castingDir, 'registry-history.commit.json')), - ]); - if (registryRaw === undefined || historyRaw === undefined) { - throw new Error('complete casting registry/history pair is required'); - } - if (journalRaw !== undefined) { - throw new Error('casting transaction is awaiting roll-forward'); - } - const registry = parseCastingObject(registryRaw, 'casting registry'); - const history = parseCastingObject(historyRaw, 'casting history'); - parseAgentRegistry(registry); - validateCastingHistory(history, registry); - if (manifestRaw === undefined) { - validateLegacyCastingGeneration(registry, history); - return registry; - } - const manifest = parseCastingObject(manifestRaw, 'casting commit manifest'); - const digest = raw => createHash('sha256').update(raw).digest('hex'); - if (typeof manifest.transaction_id !== 'string' - || !Number.isSafeInteger(manifest.registry_revision) - || manifest.registry_revision < 1 - || registry.transaction_id !== manifest.transaction_id - || history.transaction_id !== manifest.transaction_id - || history.registry_revision !== manifest.registry_revision - || registry.revision !== manifest.registry_revision - || digest(registryRaw) !== manifest.registry_sha256 - || digest(historyRaw) !== manifest.history_sha256) { - throw new Error('casting registry/history pair does not match the stable commit manifest'); - } - return registry; -} - -// Standalone certainty claims a label-operation report may never make: safe outputs like -// `add_labels` are applied in a post-agent job, so an activation/acceptance run only ever -// knows a call was *accepted for a specific target* — never that GitHub applied it. Word -// boundaries keep this from matching substrings ("unverified", "prechecked"). -const FORBIDDEN_LABEL_CLAIM_WORDS = ['applied', 'received', 'landed', 'verified', 'confirmed', 'checked']; -// A clause is treated as a compliant, honest non-claim (never flagged) when it explicitly -// negates the outcome — e.g. "not verified", "never confirmed" — since that is the accurate, -// honest statement the contract requires, not an over-claim. -const NEGATION_NEARBY = /\b(never|not|no|n't|without|cannot|can't|won't|isn't|wasn't|doesn't|didn't|nor)\b/i; -// Scope the forbidden-word scan to clauses that are actually reporting a label operation — -// mentioning "label"/"labels" or a `squad:*` token — rather than scanning the whole comment. -// A blanket scan would false-positive on an unrelated quoted task title like "Verified Email -// Addresses" sitting in the created-issues table. -const LABEL_OPERATION_CONTEXT = /\blabels?\b|\bsquad:[a-z0-9_.-]+\b/i; -// Protects a `squad:{agent}` token's colon from being mistaken for a clause boundary while -// splitting a line into clauses. Requires each embedded `.` to be followed by another name -// character (e.g. `squad:kint.jones`), so a sentence-ending period right after an agent name -// (`squad:kint.`) is left alone as a real delimiter, not swallowed into the token. -const SQUAD_TOKEN = /\bsquad:([a-z0-9_-]+(?:\.[a-z0-9_-]+)*)/gi; -// Placeholder standing in for a protected token's colon while clauses are split; chosen to -// never collide with real comment text. -const SQUAD_COLON_PLACEHOLDER = '\u0000'; - -function normalize(value) { - return typeof value === 'string' ? value.trim().toLowerCase() : ''; -} - -function slugify(value) { - return normalize(value).replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, ''); -} - -export function parseAgentRegistry(value) { - if (!value || typeof value !== 'object' || Array.isArray(value)) { - throw new Error('agent provenance registry root is malformed'); - } - if (value.schema !== AGENT_PROVENANCE_SCHEMA || value.schema_version !== 1) { - throw new Error(`agent provenance registry must use ${AGENT_PROVENANCE_SCHEMA}`); - } - if (!Number.isInteger(value.revision) || value.revision < 1) { - throw new Error('agent provenance registry revision must be a positive integer'); - } - if (!value.agents || typeof value.agents !== 'object' || Array.isArray(value.agents)) { - throw new Error('agent provenance registry agents map is malformed'); - } - const agents = new Map(); - const displayNames = new Set(); - for (const [id, record] of Object.entries(value.agents)) { - if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(id) || !record || typeof record !== 'object') { - throw new Error(`agent provenance registry entry "${id}" is malformed`); - } - if (!['active', 'inactive', 'retired'].includes(record.status)) { - throw new Error(`agent provenance registry entry "${id}" has invalid status`); - } - const displayName = typeof record.display_name === 'string' ? record.display_name.trim() : ''; - if (!displayName || record.persistent_name !== displayName || - typeof record.role !== 'string' || !record.role.trim() || - typeof record.universe !== 'string' || !record.universe.trim() || - !Number.isFinite(Date.parse(record.created_at)) || - !Number.isFinite(Date.parse(record.updated_at)) || - (record.status === 'retired' && !Number.isFinite(Date.parse(record.retired_at)))) { - throw new Error(`agent provenance registry entry "${id}" is incomplete`); - } - const normalizedName = normalize(displayName); - if (displayNames.has(normalizedName)) { - throw new Error(`agent provenance registry display name "${displayName}" is duplicated`); - } - displayNames.add(normalizedName); - if (record.avatar !== undefined) { - const expectedPrefix = `.squad/agents/${id}/`; - const avatarPath = record.avatar?.path; - if (record.avatar?.kind !== 'repository-path' - || typeof avatarPath !== 'string' - || !avatarPath.startsWith(expectedPrefix) - || avatarPath.length === expectedPrefix.length - || avatarPath.includes('\\') - || avatarPath.split('/').some(segment => segment === '.' || segment === '..')) { - throw new Error(`agent provenance registry entry "${id}" has invalid avatar path`); - } - } - agents.set(id, record); - } - return { revision: value.revision, agents }; -} - -/** - * Resolve a binding issue reference to a real issue number. - * - * Activation writes these as quoted `#`-prefixed strings so gh-aw's temporary-ID - * substitution — a plain text replacement that does not skip fenced code blocks and keeps - * the `#` — yields valid JSON. A reference the runtime resolved arrives as `"#42"`; one it - * could not resolve arrives verbatim as `"#aw_task3"`, which means that `create-issue` - * never landed. Fail closed on the latter rather than skipping the binding. Bare integers - * stay accepted so artifacts written before this contract still validate. - */ -function resolveIssueReference(value, description, invalidMessage) { - if (Number.isInteger(value)) { - if (value < 1) throw new Error(invalidMessage); - return value; - } - if (typeof value === 'string') { - const trimmed = value.trim(); - if (TEMPORARY_ID.test(trimmed)) { - throw new Error( - `${description} "${trimmed}" is an unresolved temporary ID — the referenced issue was never created`, - ); - } - const resolved = RESOLVED_REFERENCE.exec(trimmed); - if (resolved && Number(resolved[1]) >= 1) return Number(resolved[1]); - } - throw new Error(invalidMessage); -} - -/** - * Best-effort issue number extraction for label prefetching. Unlike - * `resolveIssueReference()`, this never throws: an unresolved/malformed - * reference is simply skipped here so `validateTaskBinding()` can report the - * real error (e.g. an unresolved temporary ID) instead of a misleading - * "labels could not be resolved" failure. - */ -export function extractIssueNumber(value) { - if (Number.isInteger(value)) return value; - if (typeof value === 'string') { - const resolved = RESOLVED_REFERENCE.exec(value.trim()); - if (resolved) return Number(resolved[1]); - } - return undefined; -} - -export function parseStructuredData(comment) { - const blocks = [...comment.matchAll(/Structured data:\s*```json\s*([\s\S]*?)```/gi)]; - if (blocks.length === 0) { - if (/Structured data:/i.test(comment) && ACTIVATION_ARTIFACT_PATTERN.test(comment)) { - throw new Error('activation structured data block could not be parsed'); - } - return null; - } - - let parsed; - try { - parsed = JSON.parse(blocks.at(-1)[1]); - } catch (error) { - throw new Error(`activation structured data is invalid JSON: ${error.message}`); - } - if (ACTIVATION_ARTIFACTS.has(parsed.squad_artifact)) { - const bindingBlock = /Activation bindings:\s*```json\s*([\s\S]*?)```/i.exec(comment); - if (!bindingBlock) return parsed; - try { - parsed.bindings = JSON.parse(bindingBlock[1]); - } catch (error) { - throw new Error(`activation bindings are invalid JSON: ${error.message}`); - } - } - return parsed; -} - -/** - * Strip content a forbidden-word scan must never see as a claim: fenced/inline code and - * quoted strings. A quoted work-item title ("Verified Email Addresses") or a JSON fragment - * inside backticks is data being described, not a certainty claim about a label operation. - */ -function stripQuotedAndCode(line) { - return line - .replace(/`[^`]*`/g, ' ') - .replace(/"[^"]*"/g, ' ') - .replace(/'[^']*'/g, ' '); -} - -/** - * Split a line into clauses on clause/sentence-ending punctuation (`.`, `,`, `;`, `:`) so a - * negation and a certainty claim are only ever treated as connected when they actually share - * the same clause. Without this, "No issues were skipped: Label squad:kint was verified for - * #42." would let the unrelated negation on "skipped" blanket-suppress the real, separate - * claim that follows the colon — and a claim about a *different* subject sharing a line with - * a label mention (e.g. "Reported squad:kint for issue #123, its verified real number") would - * wrongly inherit label-operation context from a clause it isn't part of. - * - * `squad:{agent}` tokens are protected first so the colon inside them is never itself treated - * as a clause boundary. - */ -function splitClauses(line) { - const guarded = line.replace(SQUAD_TOKEN, (_match, agent) => `squad${SQUAD_COLON_PLACEHOLDER}${agent}`); - return guarded - .split(/[.,;:]+/) - .map(clause => clause.replaceAll(SQUAD_COLON_PLACEHOLDER, ':')) - .filter(clause => clause.trim().length > 0); -} - -/** - * Reject standalone certainty claims ("applied", "received", "landed", "verified", - * "confirmed", "checked") in an activation/acceptance comment's label-operation reporting. - * `add_labels` is a safe output applied in a post-agent job — the run only ever knows a call - * was *accepted* for a target, never that GitHub actually applied it. A summary claiming more - * than that is over-claiming an outcome nothing here observed. - * - * Deliberately scoped, not a blanket whole-comment or whole-line scan: - * - Markdown table rows (`| ... |`) are skipped — that's where quoted work-item titles live. - * - Fenced code blocks, inline code, and quoted strings are stripped before matching, so a - * quoted title or JSON fragment containing a forbidden word never counts. - * - Each line is split into clauses (see `splitClauses`), and label-operation context - * (`label`/`labels`/`squad:*`), the forbidden word, and any negation must all be found - * within the *same* clause. This is what lets a genuinely unrelated negation elsewhere on - * the line ("No issues were skipped: ... was verified ...") fail to suppress a real claim, - * while a forbidden word describing an unrelated subject in a different clause on the same - * line as a label mention ("Reported squad:kint for issue #123, its verified real number") - * is correctly out of scope — its clause carries no label-operation context at all. - * - A clause that explicitly negates the claim ("was not verified", "never confirmed") is - * the honest, compliant statement the contract requires, so it is never flagged. - * - * No-op for artifact types outside the activation contract (fast-path `plan-accepted` / - * `phases-accepted` and granular `activated` / `phases-activated`). - */ -export function assertAcceptedOnlyLabelWording(commentBody, artifactType) { - if (!ACTIVATION_ARTIFACTS.has(artifactType)) return { skipped: true }; - const body = typeof commentBody === 'string' ? commentBody : ''; - const withoutFences = body.replace(/```[\s\S]*?```/g, ' '); - for (const rawLine of withoutFences.split(/\r?\n/)) { - const trimmed = rawLine.trim(); - if (!trimmed || trimmed.startsWith('|')) continue; - const scrubbed = stripQuotedAndCode(trimmed); - for (const clause of splitClauses(scrubbed)) { - if (!LABEL_OPERATION_CONTEXT.test(clause)) continue; - if (NEGATION_NEARBY.test(clause)) continue; - for (const word of FORBIDDEN_LABEL_CLAIM_WORDS) { - if (new RegExp(`\\b${word}\\b`, 'i').test(clause)) { - throw new Error( - `label-operation report uses forbidden certainty claim "${word}" — report label ` + - `operations as accepted only, never applied/received/landed/verified/confirmed/` + - `checked: "${trimmed}"`, - ); - } - } - } - } - return { skipped: false }; -} - -export function parseRoster(teamMarkdown) { - const lines = teamMarkdown.replace(/\r/g, '').split('\n'); - const section = lines.findIndex(line => /^##\s+Members\s*$/.test(line.trim())); - if (section < 0) throw new Error('roster has no ## Members section'); - - const nextSection = lines.findIndex((line, index) => index > section && /^##\s/.test(line)); - const table = lines.slice(section + 1, nextSection < 0 ? undefined : nextSection) - .filter(line => line.trim().startsWith('|')); - if (table.length < 3) throw new Error('roster Members table is missing or empty'); - - const cells = line => line.split('|').slice(1, -1).map(cell => cell.trim()); - const header = cells(table[0]); - const nameIndex = header.findIndex(cell => normalize(cell) === 'name'); - if (nameIndex < 0) throw new Error('roster Members table has no Name column'); - - const names = table.slice(2) - .map(line => normalize(cells(line)[nameIndex])) - .filter(Boolean); - if (names.length === 0) throw new Error('roster Members table has no members'); - - const namesBySlug = new Map(); - for (const name of names) { - const slug = slugify(name); - const existing = namesBySlug.get(slug); - if (existing) { - throw new Error(`roster member label slug "${slug}" is ambiguous: "${existing}" and "${name}"`); - } - namesBySlug.set(slug, name); - } - return new Set(names); -} - -function expectedLabel(agent, roster) { - if (agent === '@copilot') return { label: 'squad:copilot', omission: null }; - if (roster.has(agent)) return { label: `squad:${slugify(agent)}`, omission: null }; - return { label: null, omission: 'non-roster' }; -} - -function validateReportedOutcome(binding, prefix, expected) { - const labelKey = prefix ? `${prefix}_label` : 'label'; - const omissionKey = prefix ? `${prefix}_omission_reason` : 'omission_reason'; - if (binding[labelKey] !== expected.label && !(expected.label === null && binding[labelKey] === undefined)) { - throw new Error( - `issue #${binding.issue}: reported ${labelKey} ${binding[labelKey] ?? '(none)'} does not match ${expected.label ?? 'bare squad'}`, - ); - } - if (binding[omissionKey] !== expected.omission && !(expected.omission === null && binding[omissionKey] === undefined)) { - throw new Error( - `issue #${binding.issue}: ${omissionKey} must be ${expected.omission ?? 'absent'}`, - ); - } - if (binding[omissionKey] && !VALID_OMISSIONS.has(binding[omissionKey])) { - throw new Error(`issue #${binding.issue}: invalid ${omissionKey}`); - } -} - -function validateBindingAuthority(binding, issue, artifact, authority) { - if (!authority) return { epicAgentIds: [] }; - if (binding.binding_schema !== WORK_AGENT_BINDING_SCHEMA || binding.binding_version !== 1) { - throw new Error(`issue #${issue}: binding must use ${WORK_AGENT_BINDING_SCHEMA}`); - } - if (binding.producer !== 'squad') { - throw new Error(`issue #${issue}: binding producer must be squad`); - } - if (binding.repository !== authority.repository) { - throw new Error(`issue #${issue}: binding repository does not match ${authority.repository}`); - } - if (binding.origin_issue !== artifact.origin_issue || binding.artifact !== artifact.squad_artifact) { - throw new Error(`issue #${issue}: binding origin or artifact identity does not match its comment`); - } - if (binding.registry_schema !== AGENT_PROVENANCE_SCHEMA) { - throw new Error(`issue #${issue}: binding registry schema is unsupported`); - } - if (!Number.isInteger(binding.registry_revision) || binding.registry_revision < 1) { - throw new Error(`issue #${issue}: binding registry revision is invalid`); - } - if (binding.registry_revision > authority.registry.revision) { - throw new Error(`issue #${issue}: binding registry revision is newer than the available registry`); - } - if (binding.agent_id === null) { - if (!['external-agent', 'non-roster', 'legacy-plan-missing-id'].includes(binding.identity_omission_reason)) { - throw new Error(`issue #${issue}: null agent_id requires an explicit identity omission reason`); - } - } else { - if (binding.identity_omission_reason !== undefined) { - throw new Error(`issue #${issue}: identity omission reason conflicts with agent_id`); - } - if (typeof binding.agent_id !== 'string' || !authority.registry.agents.has(binding.agent_id)) { - throw new Error(`issue #${issue}: agent_id is absent from the producer registry`); - } - } - if (!Array.isArray(binding.epic_agent_ids)) { - throw new Error(`issue #${issue}: epic_agent_ids must be an array`); - } - const epicAgentIds = [...new Set(binding.epic_agent_ids)]; - if ( - epicAgentIds.length !== binding.epic_agent_ids.length || - (binding.agent_id !== null && !epicAgentIds.includes(binding.agent_id)) - ) { - throw new Error(`issue #${issue}: epic_agent_ids are duplicated or exclude agent_id`); - } - for (const agentId of epicAgentIds) { - if (typeof agentId !== 'string' || !authority.registry.agents.has(agentId)) { - throw new Error(`issue #${issue}: epic_agent_ids references an unknown producer id`); - } - } - if (binding.epic_identity_omission_reason !== undefined && - binding.epic_identity_omission_reason !== 'partial') { - throw new Error(`issue #${issue}: invalid epic identity omission reason`); - } - return { epicAgentIds: epicAgentIds.sort() }; -} - -function validateTaskBinding(binding, roster, artifact, authority) { - if (!binding || typeof binding !== 'object') { - throw new Error('binding has no valid issue number'); - } - const issue = resolveIssueReference(binding.issue, 'binding issue', 'binding has no valid issue number'); - const epicIssue = resolveIssueReference( - binding.epic_issue, - `issue #${issue}: epic_issue`, - `issue #${issue}: missing epic issue linkage`, - ); - if (!normalize(binding.task)) throw new Error(`issue #${issue}: binding has no plan task number`); - if (!normalize(binding.epic)) throw new Error(`issue #${issue}: missing epic linkage`); - const agent = normalize(binding.agent); - if (!agent) throw new Error(`issue #${issue}: binding has no agent`); - if (!Array.isArray(binding.epic_agents) || binding.epic_agents.length === 0) { - throw new Error(`issue #${issue}: epic_agents must be a non-empty array`); - } - const epicAgents = [...new Set(binding.epic_agents.map(normalize).filter(Boolean))].sort(); - if (epicAgents.length !== binding.epic_agents.length || !epicAgents.includes(agent)) { - throw new Error(`issue #${issue}: epic_agents are empty, duplicated, or exclude the task agent`); - } - const { epicAgentIds } = validateBindingAuthority(binding, issue, artifact, authority); - const expected = expectedLabel(agent, roster); - validateReportedOutcome({ ...binding, issue }, '', expected); - return { issue, epicIssue, epicAgents, epicAgentIds, expected }; -} - -function validateActualLabels(issue, labels, expected) { - if (!labels) throw new Error(`issue #${issue}: labels could not be resolved`); - if (!labels.has('squad')) throw new Error(`issue #${issue}: missing squad label`); - const actualAgentLabels = [...labels].filter(label => label.startsWith('squad:')); - const expectedAgentLabels = expected.label ? [expected.label] : []; - if (actualAgentLabels.length !== expectedAgentLabels.length || actualAgentLabels[0] !== expectedAgentLabels[0]) { - throw new Error( - `issue #${issue}: expected ${expected.label ?? 'bare squad'}, found ${actualAgentLabels.join(', ') || 'bare squad'}`, - ); - } -} - -export function validateBindings(artifact, roster, labelsByIssue) { - return validateActivation(artifact, roster, labelsByIssue); -} - -export function validateActivation(artifact, roster, labelsByIssue, expectedOrigin, authority) { - if (!artifact || !ACTIVATION_ARTIFACTS.has(artifact.squad_artifact)) return { skipped: true }; - if (artifact.schema_version !== '1') throw new Error('activation artifact schema_version must be 1'); - if (!Number.isInteger(artifact.origin_issue) || artifact.origin_issue < 1) { - throw new Error('activation artifact origin_issue is invalid'); - } - if (expectedOrigin !== undefined && artifact.origin_issue !== expectedOrigin) { - throw new Error(`activation artifact origin_issue ${artifact.origin_issue} does not match comment issue ${expectedOrigin}`); - } - if (!Array.isArray(artifact.phases) || artifact.phases.some(phase => !Number.isInteger(phase) || phase < 1)) { - throw new Error('activation artifact phases are invalid'); - } - if (!Array.isArray(artifact.bindings) || artifact.bindings.length === 0) { - throw new Error('activation artifact bindings are missing or empty'); - } - - const seen = new Set(); - const seenTasks = new Set(); - const epics = new Map(); - const epicIssuesByIdentifier = new Map(); - let expectedRegistryRevision; - for (const rawBinding of artifact.bindings) { - const { issue, epicIssue, epicAgents, epicAgentIds, expected } = - validateTaskBinding(rawBinding, roster, artifact, authority); - const binding = { ...rawBinding, issue, epic_issue: epicIssue }; - if (seen.has(issue)) throw new Error(`issue #${issue}: duplicate binding`); - seen.add(issue); - if (authority) { - if (seenTasks.has(binding.task)) throw new Error(`task ${binding.task}: duplicate binding`); - seenTasks.add(binding.task); - expectedRegistryRevision ??= binding.registry_revision; - if (binding.registry_revision !== expectedRegistryRevision) { - throw new Error(`issue #${issue}: registry_revision conflicts with other bindings`); - } - } - validateActualLabels(issue, labelsByIssue.get(issue), expected); - - const epicIdentifier = normalize(binding.epic); - const priorEpicIssue = epicIssuesByIdentifier.get(epicIdentifier); - if (priorEpicIssue !== undefined && priorEpicIssue !== epicIssue) { - throw new Error(`epic ${epicIdentifier}: maps to multiple epic issue numbers`); - } - epicIssuesByIdentifier.set(epicIdentifier, epicIssue); - - const epic = epics.get(epicIssue) ?? { - epic: epicIdentifier, - agents: epicAgents, - agentIds: epicAgentIds, - bindings: [], - }; - if (epic.epic !== epicIdentifier) { - throw new Error(`epic issue #${epicIssue}: conflicting epic identifiers`); - } - if (epic.agents.join('\0') !== epicAgents.join('\0')) { - throw new Error(`epic issue #${epicIssue}: inconsistent epic_agents sets`); - } - if (authority && epic.agentIds.join('\0') !== epicAgentIds.join('\0')) { - throw new Error(`epic issue #${epicIssue}: inconsistent epic_agent_ids sets`); - } - epic.bindings.push(binding); - epics.set(epicIssue, epic); - } - - for (const [epicIssue, epic] of epics) { - if (authority) { - const expectedAgentIds = [...new Set( - epic.bindings.map(binding => binding.agent_id).filter(agentId => agentId !== null), - )].sort(); - const hasIdentityOmission = epic.bindings.some(binding => binding.agent_id === null); - for (const binding of epic.bindings) { - const actualAgentIds = [...binding.epic_agent_ids].sort(); - if (actualAgentIds.join('\0') !== expectedAgentIds.join('\0')) { - throw new Error(`epic issue #${epicIssue}: epic_agent_ids do not match the complete epic task-agent set`); - } - if (hasIdentityOmission && binding.epic_identity_omission_reason !== 'partial') { - throw new Error(`epic issue #${epicIssue}: every binding requires partial omission when an epic task omits agent_id`); - } - if (!hasIdentityOmission && binding.epic_identity_omission_reason !== undefined) { - throw new Error(`epic issue #${epicIssue}: partial omission conflicts with a complete epic agent set`); - } - } - } - const expected = epic.agents.length > 1 - ? { label: null, omission: 'multi-owner' } - : expectedLabel(epic.agents[0], roster); - for (const binding of epic.bindings) validateReportedOutcome(binding, 'epic', expected); - validateActualLabels(epicIssue, labelsByIssue.get(epicIssue), expected); - } - return { skipped: false, checked: seen.size, epics: epics.size }; -} - -async function fetchLabels(repo, issues, token) { - const labels = new Map(); - for (const issue of issues) { - const response = await fetch(`https://api.github.com/repos/${repo}/issues/${issue}`, { - headers: { - Accept: 'application/vnd.github+json', - Authorization: `Bearer ${token}`, - 'X-GitHub-Api-Version': '2022-11-28', - }, - }); - if (!response.ok) throw new Error(`issue #${issue}: GitHub API returned ${response.status}`); - const body = await response.json(); - labels.set(issue, new Set(body.labels.map(label => normalize(typeof label === 'string' ? label : label.name)))); - } - return labels; -} - -async function main() { - const args = Object.fromEntries(process.argv.slice(2).map(arg => { - const [key, ...value] = arg.replace(/^--/, '').split('='); - return [key, value.join('=')]; - })); - if ((!args['comment-file'] && !args['comments-file']) || !args['team-file'] || !args.repo) { - throw new Error('usage: check-agent-binding.mjs --comment-file=PATH|--comments-file=PATH --team-file=PATH --repo=OWNER/REPO'); - } - - const roster = parseRoster(await readFile(args['team-file'], 'utf8')); - const registryFile = args['registry-file'] ?? join(dirname(args['team-file']), 'casting', 'registry.json'); - const registry = parseAgentRegistry(await validateCastingPairFiles(registryFile)); - const token = process.env.GITHUB_TOKEN; - if (!token) throw new Error('GITHUB_TOKEN is required for activation binding checks'); - - const comments = args['comments-file'] - ? JSON.parse(await readFile(args['comments-file'], 'utf8')) - : [{ body: await readFile(args['comment-file'], 'utf8') }]; - if (!Array.isArray(comments)) throw new Error('comments file must contain an array'); - - let checked = 0; - for (const comment of comments) { - const artifact = parseStructuredData(comment.body ?? ''); - if (!artifact || !ACTIVATION_ARTIFACTS.has(artifact.squad_artifact)) continue; - assertAcceptedOnlyLabelWording(comment.body ?? '', artifact.squad_artifact); - const issues = Array.isArray(artifact.bindings) - ? artifact.bindings.flatMap(binding => [extractIssueNumber(binding?.issue), extractIssueNumber(binding?.epic_issue)]).filter(Number.isInteger) - : []; - const labels = await fetchLabels(args.repo, [...new Set(issues)], token); - const result = validateActivation(artifact, roster, labels, comment.issue, { - repository: args.repo, - registry, - }); - checked += result.checked; - } - console.log(checked === 0 ? 'No activation artifact found; skipping.' : `Validated ${checked} activation bindings.`); -} +export * from '../workflows/shared/check-agent-binding.mjs'; if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { main().catch(error => { diff --git a/scripts/gh-aw-hosted-e2e.mjs b/scripts/gh-aw-hosted-e2e.mjs index d271006ba..36b9b18e5 100644 --- a/scripts/gh-aw-hosted-e2e.mjs +++ b/scripts/gh-aw-hosted-e2e.mjs @@ -320,8 +320,8 @@ function matchingPriorRunArtifacts(branches, pullRequests, issues, target) { } export function assertPristineTarget(target, defaultBranch, contract, github = githubAdapter, now = Date.now) { - if (contract.workflows.length !== 8 || contract.runtime.length !== 17 || contract.skills.length !== 1) { - throw new Error('Trusted package topology must be exactly 8 workflows, 17 runtime resources, and 1 skill.'); + if (contract.workflows.length !== 8 || contract.runtime.length !== 20 || contract.skills.length !== 1) { + throw new Error('Trusted package topology must be exactly 8 workflows, 20 runtime resources, and 1 skill.'); } const defaultBranchSha = github.getDefaultBranchSha(target, defaultBranch); assertSha(defaultBranchSha, 'Target default-branch SHA'); @@ -968,8 +968,8 @@ function hosted(args, repositoryRoot) { const sourceSha = requireArg(args, 'source_sha'); const evidence = resolve(requireArg(args, 'evidence')); const contract = loadBundleContract(repositoryRoot); - if (contract.workflows.length !== 8 || contract.runtime.length !== 17 || contract.skills.length !== 1) { - throw new Error('Trusted package topology must be exactly 8 workflows, 17 runtime resources, and 1 skill.'); + if (contract.workflows.length !== 8 || contract.runtime.length !== 20 || contract.skills.length !== 1) { + throw new Error('Trusted package topology must be exactly 8 workflows, 20 runtime resources, and 1 skill.'); } const targetState = authorizeTarget(args, sourceInfo, contract); mkdirSync(evidence, { recursive: true }); diff --git a/test/agent-provenance.test.ts b/test/agent-provenance.test.ts index 8552e79f6..7009e726e 100644 --- a/test/agent-provenance.test.ts +++ b/test/agent-provenance.test.ts @@ -1,12 +1,25 @@ +import { createHash } from 'node:crypto'; import { readFileSync } from 'node:fs'; import { join } from 'node:path'; import { describe, expect, it } from 'vitest'; +import { + buildActivationBindings, + digest as activationDigest, + frozenAssignments, +} from '../workflows/shared/squad-activation.mjs'; +import { selectActivationRecord as selectChecker } from '../workflows/shared/check-agent-binding.mjs'; +import { selectActivationRecord as selectBuiltSdk } from '../packages/squad-sdk/dist/casting/index.js'; import { AgentProvenanceError, parseAgentProvenanceRegistry, parseWorkAgentBindings, reconcileAgentProvenanceRegistry, -} from '@bradygaster/squad-sdk/casting'; + selectActivationRecord, + type ActivationRecord, + type EpicAssignment, + type ReconciliationEvidence, + type WorkAgentBinding, +} from '../packages/squad-sdk/src/casting/index.js'; function fixture( name: @@ -38,6 +51,979 @@ describe('agent identity provenance contract', () => { }); }); + describe('verified activation selection', () => { + const digest = (value: string): string => createHash('sha256').update(value, 'utf8').digest('hex'); + const section = (label: string, value: unknown): string => + `${label}:\n\`\`\`json\n${JSON.stringify(value)}\n\`\`\``; + const registry = () => parseAgentProvenanceRegistry(fixture('valid')).registry; + const bindings = (artifact: WorkAgentBinding['artifact'] = 'activated'): WorkAgentBinding[] => + (fixture('binding_valid') as WorkAgentBinding[]).map((binding) => ({ + ...binding, repository: 'owner/repo', artifact, + })); + const body = ( + artifact: WorkAgentBinding['artifact'] = 'activated', + rows: unknown = bindings(artifact), + originIssue = 45, + phases: readonly number[] = [1], + ): string => [ + section('Structured data', { + squad_artifact: artifact, schema_version: '1', origin_issue: originIssue, phases, + }), + section('Activation bindings', rows), + ].join('\n\n'); + const context = (reconciliations: readonly ReconciliationEvidence[] = []) => ({ + repository: 'owner/repo', originIssue: 45, reconciliations, + }); + function reconciled(sourceBody = body('activated', [{ issue: 'temporary-task' }], 45, [])) { + const source = { id: 10, body: sourceBody }; + const rows = bindings('activation-reconciled'); + const currentRegistry = registry(); + const evidence: ReconciliationEvidence = { + requestCommentId: 20, + requestSha256: digest('verified request'), + actorId: 30, + sourceCommentId: source.id, + sourceSha256: digest(source.body), + planSha256: digest('frozen current plan'), + registrySha256: digest(JSON.stringify(currentRegistry)), + bindingsSha256: digest(JSON.stringify(rows)), + }; + const metadata = { + schema: 'squad-activation-reconciliation/v1', + basis: 'current-state-authorized', + request_comment_id: evidence.requestCommentId, + request_sha256: evidence.requestSha256, + actor_id: evidence.actorId, + supersedes_comment_id: evidence.sourceCommentId, + supersedes_sha256: evidence.sourceSha256, + plan_sha256: evidence.planSha256, + registry_sha256: evidence.registrySha256, + bindings_sha256: evidence.bindingsSha256, + }; + const replacement = (changes: Record = {}): ActivationRecord => ({ + id: 40, + body: `${body('activation-reconciled', rows, 45, [])}\n\n${section('Activation reconciliation', { + ...metadata, ...changes, + })}`, + }); + return { source, rows, currentRegistry, evidence, metadata, replacement }; + } + + function pendingReconciled(changes: Record = {}) { + const pending = { + schema: 'squad-activation-pending/v1', + repository: 'owner/repo', + origin_issue: 45, + run_id: 100, + run_attempt: 1, + capture_sha256: digest('frozen capture'), + capture_artifact_id: 200, + plan_comment_id: 300, + plan_sha256: digest('frozen current plan'), + registry_sha256: digest(JSON.stringify(registry())), + phases: [], + ...changes, + }; + const test = reconciled(`Pending reservation\n\n\`\`\`json\n${JSON.stringify(pending)}\n\`\`\``); + const evidence: ReconciliationEvidence = { ...test.evidence, sourceKind: 'pending' }; + return { + ...test, evidence, pending, + record: test.replacement({ supersedes_kind: 'pending' }), + }; + } + + it('selects explicit current-state reconciliation of a verified pending reservation', () => { + const test = pendingReconciled(); + const original = test.source.body; + expect(selectActivationRecord( + [test.source, test.record], test.currentRegistry, context([test.evidence]), + )).toEqual({ id: 40, artifact: 'activation-reconciled', bindings: test.rows }); + expect(test.source.body).toBe(original); + expect(selectActivationRecord([test.source], test.currentRegistry, context())).toBeUndefined(); + }); + + it('accepts pending phase scope only when independently verified for both records', () => { + const test = pendingReconciled({ phases: [1, 3] }); + const record = { ...test.record, body: test.record.body.replace('"phases":[]', '"phases":[1,3]') }; + expect(selectActivationRecord( + [test.source, record], test.currentRegistry, context([{ ...test.evidence, phases: [1, 3] }]), + )?.id).toBe(40); + expect(() => selectActivationRecord( + [test.source, test.record], test.currentRegistry, context([test.evidence]), + )).toThrow(/pending source/); + }); + + it('requires independent pending authorization and the explicit metadata source kind', () => { + const test = pendingReconciled(); + const { sourceKind: _kind, ...activationEvidence } = test.evidence; + for (const [record, evidence] of [ + [test.record, activationEvidence], + [test.replacement(), test.evidence], + [test.replacement(), activationEvidence], + [test.replacement({ supersedes_kind: 'activation' }), activationEvidence], + [test.record, { ...test.evidence, sourceKind: 'unknown' }], + ] as Array<[ActivationRecord, ReconciliationEvidence]>) { + expect(() => selectActivationRecord( + [test.source, record], test.currentRegistry, context([evidence]), + )).toThrow(AgentProvenanceError); + } + expect(() => selectActivationRecord( + [test.source, test.record], test.currentRegistry, context(), + )).toThrow(/matching verifier evidence/); + }); + + it.each([ + ['schema', 'unknown'], + ['repository', 'other/repo'], + ['origin_issue', 99], + ['run_id', 0], + ['run_attempt', 1.5], + ['capture_artifact_id', Number.MAX_SAFE_INTEGER + 1], + ['plan_comment_id', '300'], + ['capture_sha256', 'A'.repeat(64)], + ['plan_sha256', 'a'.repeat(64)], + ['registry_sha256', 'a'.repeat(64)], + ['phases', [2, 1]], + ['phases', [1, 1]], + ['phases', [0]], + ['phases', null], + ])('rejects a pending source with invalid %s even when its body digest matches', (field, value) => { + const test = pendingReconciled({ [field]: value }); + expect(() => selectActivationRecord( + [test.source, test.record], test.currentRegistry, context([test.evidence]), + )).toThrow(/pending source/); + }); + + it('rejects tampered pending bodies and ambiguous or malformed bare JSON fences', () => { + const test = pendingReconciled(); + expect(() => selectActivationRecord( + [{ ...test.source, body: `${test.source.body}\n` }, test.record], + test.currentRegistry, context([test.evidence]), + )).toThrow(/source body digest/); + for (const sourceBody of [ + `${test.source.body}\n\n\`\`\`json\n{}\n\`\`\``, + test.source.body.replace('```json', '```text'), + test.source.body.replace(JSON.stringify(test.pending), '{invalid-json'), + 'No JSON reservation', + body(), + ]) { + const invalid = reconciled(sourceBody); + expect(() => selectActivationRecord( + [invalid.source, invalid.replacement({ supersedes_kind: 'pending' })], + invalid.currentRegistry, context([{ ...invalid.evidence, sourceKind: 'pending' }]), + )).toThrow(AgentProvenanceError); + } + }); + + it('does not let pending prior references select or supersede other acceptances', () => { + const test = pendingReconciled({ + prior: { comment_id: 11, artifact: 'activation-reconciled', authorized: true }, + }); + expect(selectActivationRecord([test.source], test.currentRegistry, context())).toBeUndefined(); + expect(selectActivationRecord( + [test.source, test.record], test.currentRegistry, context([test.evidence]), + )?.id).toBe(40); + const prior = { id: 11, body: body() }; + expect(() => selectActivationRecord( + [test.source, test.record, prior], test.currentRegistry, context([test.evidence]), + )).toThrow(/Ambiguous unsuperseded/); + expect(selectActivationRecord( + [test.source, prior], test.currentRegistry, context(), + )?.id).toBe(11); + }); + + it('still rejects incomplete bindings when a pending source is explicitly verified', () => { + const test = pendingReconciled(); + const rows = [{ ...test.rows[0]!, issue: 'temporary-task' }]; + const hash = digest(JSON.stringify(rows)); + const record = test.replacement({ supersedes_kind: 'pending', bindings_sha256: hash }); + record.body = record.body.replace(JSON.stringify(test.rows), JSON.stringify(rows)); + expect(() => selectActivationRecord( + [test.source, record], test.currentRegistry, + context([{ ...test.evidence, bindingsSha256: hash }]), + )).toThrow(/Work-agent bindings are malformed or partial/); + }); + + it('preserves explicit activation source evidence without changing legacy metadata', () => { + const test = reconciled(); + expect(selectActivationRecord( + [test.source, test.replacement()], test.currentRegistry, + context([{ ...test.evidence, sourceKind: 'activation' }]), + )?.id).toBe(40); + }); + + it.each(['activated', 'phases-activated', 'plan-accepted', 'phases-accepted'] as const)( + 'selects one valid legacy %s record unchanged', + (artifact) => { + expect(selectActivationRecord([{ id: 10, body: body(artifact) }], registry(), context())) + .toEqual({ id: 10, artifact, bindings: bindings(artifact) }); + }, + ); + + it.each([['1'], [0], [-1], [1.5], [Number.MAX_SAFE_INTEGER + 1], [1, 1]])( + 'rejects malformed legacy phase scope %j in both readers', + (...phases) => { + for (const artifact of ['activated', 'phases-activated', 'plan-accepted', 'phases-accepted'] as const) { + const rows = bindings(artifact).map(binding => ({ + ...binding, agent: 'Kepler', epic_agents: ['kepler'], + label: 'squad:kepler', epic_label: 'squad:kepler', depends_on: [], + })); + const valid = { id: 10, body: body(artifact, rows) }; + const record = { ...valid, body: valid.body.replace('"phases":[1]', `"phases":${JSON.stringify(phases)}`) }; + for (const select of [selectActivationRecord, selectChecker]) { + expect(select([valid], registry(), context())?.id).toBe(10); + expect(() => select([record], registry(), context())).toThrow(/phase/); + } + } + }, + ); + + it('preserves valid legacy phase scopes with SDK/checker parity', () => { + for (const phases of [[], [1], [3, 1], [Number.MAX_SAFE_INTEGER]]) { + const rows = bindings().map(binding => ({ + ...binding, agent: 'Kepler', epic_agents: ['kepler'], + label: 'squad:kepler', epic_label: 'squad:kepler', depends_on: [], + })); + const record = { id: 10, body: body('activated', rows, 45, phases) }; + expect(selectChecker([record], registry(), context())) + .toMatchObject(selectActivationRecord([record], registry(), context())!); + } + }); + + it('returns undefined for no relevant activation and ignores unrelated comments', () => { + expect(selectActivationRecord([ + { id: 1, body: 'Discussion only' }, + { id: 2, body: section('Structured data', { squad_artifact: 'plan' }) }, + { id: 3, body: body('activated', bindings(), 99) }, + ], registry(), context())).toBeUndefined(); + }); + + it('selects verified current-state bindings without modifying invalid historical acceptance', () => { + const test = reconciled(); + const original = test.source.body; + const selected = selectActivationRecord( + [test.replacement(), test.source], test.currentRegistry, context([test.evidence]), + ); + expect(selected).toEqual({ + id: 40, artifact: 'activation-reconciled', bindings: test.rows, + }); + expect(test.source.body).toBe(original); + expect(selected!.bindings.every((binding) => + Number.isSafeInteger(Number(binding.issue.replace(/^#/, ''))) + && Number.isSafeInteger(Number(binding.epic_issue.replace(/^#/, ''))))).toBe(true); + expect(selected!.bindings[0]!.agent_id).toBe('runtime-engineer'); + expect(() => selectActivationRecord([test.source], test.currentRegistry, context())) + .toThrow(AgentProvenanceError); + }); + + it('accepts explicit supersession of malformed original binding JSON', () => { + const test = reconciled(body('activated', bindings(), 45, []).replace(JSON.stringify(bindings()), '{not JSON')); + expect(selectActivationRecord( + [test.source, test.replacement()], test.currentRegistry, context([test.evidence]), + )?.id).toBe(40); + }); + + it('does not trust artifact metadata or authorized booleans without verifier evidence', () => { + const test = reconciled(body()); + for (const replacement of [test.replacement(), test.replacement({ authorized: true })]) { + expect(() => selectActivationRecord( + [test.source, replacement], test.currentRegistry, context(), + )).toThrow(AgentProvenanceError); + } + expect(() => selectActivationRecord( + [test.source], test.currentRegistry, context([test.evidence]), + )).toThrow(/no matching activation record/); + }); + + it.each([ + ['request_comment_id', 21], + ['actor_id', 31], + ['supersedes_comment_id', 11], + ['request_sha256', 'a'.repeat(64)], + ['supersedes_sha256', 'a'.repeat(64)], + ['plan_sha256', 'a'.repeat(64)], + ['registry_sha256', 'a'.repeat(64)], + ['bindings_sha256', 'a'.repeat(64)], + ])('requires independently matching evidence for %s', (field, value) => { + const test = reconciled(body()); + expect(() => selectActivationRecord( + [test.source, test.replacement({ [field]: value })], + test.currentRegistry, context([test.evidence]), + )).toThrow(/matching verifier evidence/); + }); + + it.each([ + ['request_comment_id', 0], ['actor_id', 1.5], ['supersedes_comment_id', Number.MAX_SAFE_INTEGER + 1], + ['actor_id', '30'], ['request_sha256', 'A'.repeat(64)], + ['plan_sha256', 'a'.repeat(63)], ['registry_sha256', null], + ['schema', 'unknown'], ['basis', 'historical-attestation'], + ])('rejects malformed reconciliation %s', (field, value) => { + const test = reconciled(body()); + expect(() => selectActivationRecord( + [test.source, test.replacement({ [field]: value })], + test.currentRegistry, context([test.evidence]), + )).toThrow(/Malformed activation reconciliation/); + }); + + it('rejects duplicate matching verifier evidence without legacy fallback', () => { + const test = reconciled(body()); + expect(() => selectActivationRecord( + [test.source, test.replacement()], test.currentRegistry, + context([test.evidence, structuredClone(test.evidence)]), + )).toThrow(/missing or extra verifier proofs/); + }); + + it('treats omitted verifier phases as whole-plan and accepts explicit empty scope', () => { + const test = reconciled(); + for (const evidence of [test.evidence, { ...test.evidence, phases: [] }]) { + expect(selectActivationRecord( + [test.source, test.replacement()], test.currentRegistry, context([evidence]), + )?.id).toBe(40); + } + }); + + it('accepts only the explicitly verified canonical phase scope', () => { + const test = reconciled(body('activated', [{ issue: 'temporary-task' }], 45, [1, 3])); + const replacement = test.replacement(); + replacement.body = replacement.body.replace('"phases":[]', '"phases":[1,3]'); + const evidence = { ...test.evidence, phases: [1, 3] }; + expect(selectActivationRecord( + [test.source, replacement], test.currentRegistry, context([evidence]), + )?.id).toBe(40); + for (const phases of [[], [1], [1, 2, 3], [3, 1], [1, 1, 3], ['1', 3], [0, 3]]) { + const tampered = { + ...replacement, + body: replacement.body.replace('"phases":[1,3]', `"phases":${JSON.stringify(phases)}`), + }; + expect(() => selectActivationRecord( + [test.source, tampered], test.currentRegistry, context([evidence]), + )).toThrow(/phase scope/); + } + expect(() => selectActivationRecord( + [test.source, replacement], test.currentRegistry, context([test.evidence]), + )).toThrow(/phase scope/); + }); + + it('rejects noncanonical or malformed verifier phase scope', () => { + const test = reconciled(); + for (const phases of [ + [0], [-1], [1.5], [Number.MAX_SAFE_INTEGER + 1], [1, 1], [2, 1], + ['1'], null, '1', new Array(1), + ]) { + const evidence = { ...test.evidence, phases } as ReconciliationEvidence; + expect(() => selectActivationRecord( + [test.source, test.replacement()], test.currentRegistry, context([evidence]), + )).toThrow(/verifier reconciliation phase scope/); + } + }); + + function frozenPhase() { + const test = reconciled(body('activated', [{ issue: 'temporary-task' }], 45, [1])); + test.currentRegistry.agents['quality-engineer'] = { + ...test.currentRegistry.agents['runtime-engineer']!, + display_name: 'Orbit', + persistent_name: 'Orbit', + role: 'Quality Engineer', + avatar: { kind: 'repository-path', path: '.squad/agents/quality-engineer/avatar.png' }, + }; + const rows = test.rows.map((row) => ({ + ...row, epic_agent_ids: ['quality-engineer', 'runtime-engineer'], + })); + const assignments: EpicAssignment[] = [ + { task: rows[0]!.task, epic: rows[0]!.epic, agent: 'Kepler', agentId: 'runtime-engineer' }, + { task: 'future-task', epic: rows[0]!.epic, agent: 'Orbit', agentId: 'quality-engineer' }, + ]; + const evidence = { + ...test.evidence, + registrySha256: digest(JSON.stringify(test.currentRegistry)), + bindingsSha256: digest(JSON.stringify(rows)), + phases: [1], + }; + const replacement = test.replacement({ + registry_sha256: evidence.registrySha256, + bindings_sha256: evidence.bindingsSha256, + }); + replacement.body = replacement.body + .replace(JSON.stringify(test.rows), JSON.stringify(rows)) + .replace('"phases":[]', '"phases":[1]'); + return { ...test, rows, assignments, evidence, record: replacement }; + } + + it('validates a phase subset against the complete independently supplied epic assignments', () => { + const test = frozenPhase(); + const selected = selectActivationRecord( + [test.source, test.record], test.currentRegistry, + { ...context([test.evidence]), epicAssignments: test.assignments }, + ); + expect(selected?.bindings).toEqual(test.rows); + expect(selected?.bindings).toHaveLength(1); + expect(selected?.bindings[0]!.epic_agent_ids).toEqual(['quality-engineer', 'runtime-engineer']); + expect(() => selectActivationRecord( + [test.source, test.record], test.currentRegistry, context([test.evidence]), + )).toThrow(/malformed or partial/); + }); + + it('rejects missing, duplicate, and conflicting full-plan assignments', () => { + const test = frozenPhase(); + for (const assignments of [ + [], + [test.assignments[1]!], + [test.assignments[0]!], + [...test.assignments, { ...test.assignments[0]! }], + [{ ...test.assignments[0]!, epic: 'wrong-epic' }, test.assignments[1]!], + [{ ...test.assignments[0]!, task: ' missing ' }, test.assignments[1]!], + [{ ...test.assignments[0]!, agent: 'Orbit' }, test.assignments[1]!], + [{ ...test.assignments[0]!, agentId: null }, test.assignments[1]!], + [{ ...test.assignments[0]!, agent: 'Orbit', agentId: 'quality-engineer' }, test.assignments[1]!], + [test.assignments[0]!, { ...test.assignments[1]!, agentId: 'unknown-agent' }], + [test.assignments[0]!, { ...test.assignments[1]!, agent: 'Impostor' }], + ]) { + expect(() => selectActivationRecord( + [test.source, test.record], test.currentRegistry, + { ...context([test.evidence]), epicAssignments: assignments }, + )).toThrow(AgentProvenanceError); + } + }); + + it('rejects artifact-supplied epic assignments as a substitute for verifier context', () => { + const test = frozenPhase(); + const selfAuthorized = { + ...test.record, + body: test.record.body.replace( + '"phases":[1]', `"phases":[1],"epicAssignments":${JSON.stringify(test.assignments)}`, + ), + }; + expect(() => selectActivationRecord( + [test.source, selfAuthorized], test.currentRegistry, context([test.evidence]), + )).toThrow(/malformed or partial/); + }); + + it.each(['Builder', ' builder ', 'BUILDER'])( + 'roundtrips actual producer bindings and full frozen assignments for %s', + (displayName) => { + const currentRegistry = registry(); + const template = currentRegistry.agents['runtime-engineer']!; + currentRegistry.agents = { + builder: { + ...template, display_name: 'Builder', persistent_name: 'Builder', + avatar: { kind: 'repository-path', path: '.squad/agents/builder/avatar.png' }, + }, + reviewer: { + ...template, display_name: 'Reviewer', persistent_name: 'Reviewer', + avatar: { kind: 'repository-path', path: '.squad/agents/reviewer/avatar.png' }, + }, + }; + const task = { + id: '1', epic: 'origin', agent: displayName, agent_id: 'builder', + label: 'squad:builder', dependencies: [], phase: 1, + }; + const fullPlan = { + kind: 'plan', + tasks: [ + task, + { ...task, id: '2', agent: 'Reviewer', agent_id: 'reviewer', label: 'squad:reviewer', phase: 2 }, + ], + }; + const capture = { + repository: 'owner/repo', origin_issue: 45, run_id: 100, run_attempt: 1, + phase: 1, registry: currentRegistry, fullPlan, + plan: { ...fullPlan, tasks: [task] }, operations: [{ id: 'task:1' }], + }; + const journal = { + schema: 'squad-activation-receipts/v1', + capture_sha256: activationDigest(capture), + repository: capture.repository, run_id: capture.run_id, run_attempt: capture.run_attempt, + started: ['task:1'], + parent_fallbacks: [], + receipts: [{ + operation_id: 'task:1', repository: capture.repository, number: 101, id: 1001, + node_id: 'I_example_101', url: 'https://github.com/owner/repo/issues/101', + }], + }; + const produced = buildActivationBindings(capture, journal); + const assignments = frozenAssignments(fullPlan); + expect(produced[0].epic_agents).toEqual(['builder', 'reviewer']); + expect(assignments[0].agent).toBe(displayName); + const record = { id: 500, body: body('phases-accepted', produced, 45, [1]) }; + const selected = selectActivationRecord([record], currentRegistry, { + ...context(), epicAssignments: assignments, + }); + expect(selected).toMatchObject({ + id: 500, artifact: 'phases-accepted', + bindings: [{ + task: '1', issue: '#101', agent_id: 'builder', + epic_agent_ids: ['builder', 'reviewer'], + }], + }); + expect(() => selectActivationRecord([record], currentRegistry, { + ...context(), + epicAssignments: [{ ...assignments[0], agentId: 'Builder' }, assignments[1]], + })).toThrow(/identity/); + }, + ); + + it('checks supplied display metadata and exact task identity against frozen assignments', () => { + const test = frozenPhase(); + const bindingContext = { + repository: 'owner/repo', originIssue: 45, artifact: 'activation-reconciled' as const, + epicAssignments: test.assignments, + }; + const parsedRegistry = parseAgentProvenanceRegistry(test.currentRegistry); + const row = { ...test.rows[0]!, agent: 'Kepler', epic_agents: ['Kepler', 'Orbit'] }; + expect(parseWorkAgentBindings([row], parsedRegistry, bindingContext)).toEqual(test.rows); + for (const modified of [ + { ...row, agent: 'Orbit' }, + { ...row, epic_agents: ['Kepler'] }, + { ...row, epic_agents: ['Kepler', 'Orbit', 'Orbit'] }, + { ...row, agent_id: ' runtime-engineer ' }, + { ...row, task: ` ${row.task}` }, + { ...row, epic: `${row.epic} ` }, + ]) { + expect(() => parseWorkAgentBindings([modified], parsedRegistry, bindingContext)) + .toThrow(/malformed or partial/); + } + }); + + it('derives partial omission from a future external owner, not only selected tasks', () => { + const test = frozenPhase(); + const assignments = [ + test.assignments[0]!, + { ...test.assignments[1]!, agent: 'External contributor', agentId: null }, + ]; + const row = { + ...test.rows[0]!, epic_agent_ids: ['runtime-engineer'], + epic_identity_omission_reason: 'partial' as const, + }; + const parsedRegistry = parseAgentProvenanceRegistry(test.currentRegistry); + const bindingContext = { + repository: 'owner/repo', originIssue: 45, artifact: 'activation-reconciled' as const, + epicAssignments: assignments, + }; + expect(parseWorkAgentBindings([row], parsedRegistry, bindingContext)[0]) + .toMatchObject({ epic_identity_omission_reason: 'partial' }); + expect(() => parseWorkAgentBindings([ + { ...row, epic_identity_omission_reason: undefined }, + ], parsedRegistry, bindingContext)).toThrow(/malformed or partial/); + const { epicAssignments: _assignments, ...strictContext } = bindingContext; + expect(() => parseWorkAgentBindings([row], parsedRegistry, strictContext)) + .toThrow(/malformed or partial/); + }); + + it('rejects malformed verifier evidence', () => { + const test = reconciled(); + for (const evidence of [ + { ...test.evidence, actorId: -1 }, + { ...test.evidence, bindingsSha256: 'not-a-digest' }, + ]) { + expect(() => selectActivationRecord( + [test.source, test.replacement()], test.currentRegistry, context([evidence]), + )).toThrow(/Malformed verifier/); + } + }); + + it('verifies the exact source body, source existence, registry, and full raw bindings', () => { + const test = reconciled(); + expect(() => selectActivationRecord( + [{ ...test.source, body: `${test.source.body}\n` }, test.replacement()], + test.currentRegistry, context([test.evidence]), + )).toThrow(/source body digest/); + expect(() => selectActivationRecord( + [test.replacement()], test.currentRegistry, context([test.evidence]), + )).toThrow(/source body digest/); + expect(() => selectActivationRecord( + [test.source, test.replacement()], { ...test.currentRegistry, revision: 3 }, + context([test.evidence]), + )).toThrow(/registry or bindings digest/); + const modified = test.replacement(); + modified.body = modified.body.replace( + JSON.stringify(test.rows), JSON.stringify(test.rows.map((row) => ({ ...row, extra: true }))), + ); + expect(() => selectActivationRecord( + [test.source, modified], test.currentRegistry, context([test.evidence]), + )).toThrow(/registry or bindings digest/); + const extraRows = test.rows.map((row) => ({ ...row, extra: true })); + const newHash = digest(JSON.stringify(extraRows)); + modified.body = modified.body.replace(test.evidence.bindingsSha256, newHash); + expect(selectActivationRecord( + [test.source, modified], test.currentRegistry, + context([{ ...test.evidence, bindingsSha256: newHash }]), + )?.bindings).toEqual(test.rows); + }); + + it('validates reconciliation bindings even when their digest is verified', () => { + const test = reconciled(); + const wrongRows = bindings('activated'); + const newHash = digest(JSON.stringify(wrongRows)); + const replacement = test.replacement({ bindings_sha256: newHash }); + replacement.body = replacement.body.replace(JSON.stringify(test.rows), JSON.stringify(wrongRows)); + expect(() => selectActivationRecord( + [test.source, replacement], test.currentRegistry, + context([{ ...test.evidence, bindingsSha256: newHash }]), + )).toThrow(/Work-agent bindings/); + }); + + it('rejects duplicates, competing reconciliations, and unsuperseded old acceptances', () => { + const test = reconciled(body('activated', bindings(), 45, [])); + for (const records of [ + [test.source, test.source], + [test.source, { ...test.source, id: 11 }], + [test.source, { id: 11, body: body('activated', null) }], + ]) { + expect(() => selectActivationRecord(records, test.currentRegistry, context())) + .toThrow(/duplicate|Ambiguous/); + } + for (const extra of [ + { ...test.replacement(), id: 41 }, + { ...test.source, id: 11 }, + test.replacement(), + ]) { + expect(() => selectActivationRecord( + [test.source, test.replacement(), extra], test.currentRegistry, context([test.evidence]), + )).toThrow(/Competing|Ambiguous|duplicate|missing or extra verifier proofs/); + } + }); + + it('rejects chains, self-supersession, and cross-origin source or replacement', () => { + const test = reconciled(body()); + const replacement = test.replacement(); + const selfEvidence = { + ...test.evidence, sourceCommentId: replacement.id, sourceSha256: digest(replacement.body), + }; + expect(() => selectActivationRecord( + [test.source, test.replacement({ + supersedes_comment_id: selfEvidence.sourceCommentId, + supersedes_sha256: selfEvidence.sourceSha256, + })], test.currentRegistry, context([selfEvidence]), + )).toThrow(AgentProvenanceError); + for (const source of [ + body('activation-reconciled'), + body('activated', null, 99), + body().replace('"schema_version":"1"', '"schema_version":"2"'), + ]) { + const invalid = reconciled(source); + expect(() => selectActivationRecord( + [invalid.source, invalid.replacement()], invalid.currentRegistry, context([invalid.evidence]), + )).toThrow(AgentProvenanceError); + } + replacement.body = replacement.body.replace('"origin_issue":45', '"origin_issue":99'); + expect(() => selectActivationRecord( + [test.source, replacement], test.currentRegistry, context([test.evidence]), + )).toThrow(/cross-origin/); + }); + + it.each(['Structured data', 'Activation bindings', 'Activation reconciliation'])( + 'rejects missing, duplicate, or malformed %s sections without legacy fallback', + (label) => { + const test = reconciled(body()); + const original = test.replacement(); + for (const modified of [ + original.body.replace(`${label}:`, 'Removed:'), + `${original.body}\n\n${section(label, {})}`, + original.body.replace(`${label}:\n\`\`\`json\n`, `${label}:\n\`\`\`json\nnot-json`), + ]) { + expect(() => selectActivationRecord( + [test.source, { ...original, body: modified }], test.currentRegistry, context([test.evidence]), + )).toThrow(AgentProvenanceError); + } + }, + ); + + it('rejects unknown reconciliation kinds and malformed envelopes', () => { + const test = reconciled(body()); + for (const modified of [ + test.replacement().body.replace('"squad_artifact":"activation-reconciled"', '"squad_artifact":"unknown"'), + test.replacement().body.replace('"phases":[]', '"phases":null'), + test.replacement().body.replace('"schema_version":"1"', '"schema_version":1'), + ]) { + expect(() => selectActivationRecord( + [test.source, { id: 40, body: modified }], test.currentRegistry, context([test.evidence]), + )).toThrow(/envelope/); + } + }); + + it.each(['issue', 'epic_issue'] as const)('rejects temporary or malformed %s references', (field) => { + for (const value of [ + 'temporary-42', '#temp', '0', '#0', '-1', '1.5', '1e3', 'Infinity', + '9007199254740992', '#9007199254740992', ' 42', '42 ', '##42', '#01', 42, + ]) { + const rows = bindings().map((row) => ({ ...row, [field]: value })); + expect(() => parseWorkAgentBindings(rows, parseAgentProvenanceRegistry(registry()), { + repository: 'owner/repo', originIssue: 45, artifact: 'activated', + })).toThrow(/malformed or partial/); + } + }); + + it.each(['45', '#45'])('rejects task issue %s equal to the origin issue', (issue) => { + const rows = bindings().map((row) => ({ ...row, issue })); + expect(() => selectActivationRecord( + [{ id: 10, body: body('activated', rows) }], registry(), context(), + )).toThrow(/malformed or partial/); + }); + + it.each([ + ['#42', '42'], + ['42', '#42'], + ])('rejects task %s reused as epic issue %s in the same or another row', (issue, epicIssue) => { + const first = { ...bindings()[0]!, issue, epic_issue: '#50' }; + for (const rows of [ + [{ ...first, epic_issue: epicIssue }], + [first, { ...first, task: '2', issue: '#43', epic: 'second-epic', epic_issue: epicIssue }], + ]) { + expect(() => selectActivationRecord( + [{ id: 10, body: body('activated', rows) }], registry(), context(), + )).toThrow(/malformed or partial/); + } + }); + + it.each(['45', '#45'])('reserves epic issue %s for the explicit origin epic', (epicIssue) => { + const row = { ...bindings()[0]!, issue: '#42', epic_issue: epicIssue }; + expect(() => selectActivationRecord( + [{ id: 10, body: body('activated', [row]) }], registry(), context(), + )).toThrow(/malformed or partial/); + expect(selectActivationRecord( + [{ id: 10, body: body('activated', [{ ...row, epic: 'origin' }]) }], + registry(), context(), + )?.bindings[0]).toMatchObject({ issue: '#42', epic: 'origin', epic_issue: epicIssue }); + }); + + function chainNode( + index: number, + phases: number[], + parent?: { source: ActivationRecord; record: ActivationRecord }, + options: { + prior?: unknown; + sourceKind?: 'activation' | 'pending'; + rows?: Array>; + planSha256?: string; + } = {}, + ) { + const rows = options.rows ?? phases.map((phase) => ({ + ...bindings('activation-reconciled')[0]!, + task: String(phase), issue: `#${2065 + phase}`, + agent: 'Kepler', epic_agents: ['kepler'], label: 'squad:kepler', epic_label: 'squad:kepler', + depends_on: [], + })); + const sourceKind = options.sourceKind ?? 'pending'; + const planSha256 = options.planSha256 ?? digest('frozen chain plan'); + const registrySha256 = digest(JSON.stringify(registry())); + const prior = options.prior ?? (parent ? { + source_comment_id: parent.source.id, source_sha256: digest(parent.source.body), + reconciliation_comment_id: parent.record.id, reconciliation_sha256: digest(parent.record.body), + } : undefined); + const frozen = { + run_id: 100, run_attempt: index, capture_sha256: digest(`capture ${index}`), + capture_artifact_id: 200 + index, plan_comment_id: 300, + plan_sha256: planSha256, registry_sha256: registrySha256, + ...(prior === undefined ? {} : { prior }), + }; + const source = { + id: index * 10, + body: sourceKind === 'pending' + ? `\`\`\`json\n${JSON.stringify({ + schema: 'squad-activation-pending/v1', repository: 'owner/repo', origin_issue: 45, + ...frozen, phases, + })}\n\`\`\`` + : `${body('phases-activated', rows.map(row => ({ ...row, artifact: 'phases-activated' })), 45, phases)} + +${section('Activation evidence', { + schema: 'squad-activation-finalization/v1', basis: 'current-trusted-execution', + ...frozen, workflow_sha: 'a'.repeat(40), receipts_sha256: digest(`receipts ${index}`), + receipts_artifact_id: 400 + index, pending_comment_id: 500 + index, +})}`, + }; + const evidence: ReconciliationEvidence = { + requestCommentId: 1000 + index, requestSha256: digest(`request ${index}`), actorId: 30, + sourceCommentId: source.id, sourceSha256: digest(source.body), + planSha256, registrySha256, bindingsSha256: digest(JSON.stringify(rows)), phases, sourceKind, + }; + const record = { + id: source.id + 1, + body: `${body('activation-reconciled', rows, 45, phases)}\n\n${section('Activation reconciliation', { + schema: 'squad-activation-reconciliation/v1', basis: 'current-state-authorized', + request_comment_id: evidence.requestCommentId, request_sha256: evidence.requestSha256, + actor_id: evidence.actorId, supersedes_comment_id: source.id, supersedes_sha256: evidence.sourceSha256, + plan_sha256: planSha256, registry_sha256: registrySha256, bindings_sha256: evidence.bindingsSha256, + ...(sourceKind === 'pending' ? { supersedes_kind: 'pending' } : {}), + })}`, + }; + return { source, record, evidence, rows }; + } + + function rejectChain(records: ActivationRecord[], proofs: ReconciliationEvidence[]) { + for (const selector of [selectActivationRecord, selectBuiltSdk, selectChecker]) { + expect(() => selector(records, registry(), context(proofs))).toThrow(); + } + } + + it.each(['pending', 'activation'] as const)( + 'selects a complete independently verified chain through a %s source', + (sourceKind) => { + const first = chainNode(1, [1]); + const second = chainNode(2, [1, 2], first, { sourceKind }); + const third = chainNode(3, [1, 2, 3], second); + for (const nodes of [[first, second], [first, second, third]]) { + const head = nodes.at(-1)!; + const records = nodes.flatMap(node => [node.record, node.source]).reverse(); + const selectionContext = context(nodes.map(node => node.evidence).reverse()); + const sdk = selectActivationRecord(records, registry(), selectionContext)!; + const canonical = selectChecker(records, registry(), selectionContext); + expect(sdk.id).toBe(head.record.id); + expect(sdk.bindings).toHaveLength(nodes.length); + expect({ + ...canonical, + bindings: parseWorkAgentBindings(canonical.bindings, parseAgentProvenanceRegistry(registry()), { + repository: 'owner/repo', originIssue: 45, artifact: 'activation-reconciled', + }), + }).toEqual(sdk); + expect(selectActivationRecord([head.source, head.record], registry(), context([head.evidence]))?.id) + .toBe(head.record.id); + expect(selectChecker([head.source, head.record], registry(), context([head.evidence]))?.id) + .toBe(head.record.id); + } + }, + ); + + it('rejects missing, duplicate, extra, and mismatched full-history proofs', () => { + const first = chainNode(1, [1]); + const second = chainNode(2, [1, 2], first); + const records = [first.source, first.record, second.source, second.record]; + for (const proofs of [ + [second.evidence], [first.evidence], + [first.evidence, first.evidence], + [first.evidence, second.evidence, structuredClone(second.evidence)], + [first.evidence, { ...second.evidence, actorId: 99 }], + [first.evidence, { ...second.evidence, bindingsSha256: 'b'.repeat(64) }], + [first.evidence, { ...second.evidence, registrySha256: 'b'.repeat(64) }], + ]) rejectChain(records, proofs); + rejectChain(records.filter(record => record.id !== first.source.id), [first.evidence, second.evidence]); + rejectChain(records.filter(record => record.id !== first.record.id), [first.evidence, second.evidence]); + rejectChain([...records, first.source], [first.evidence, second.evidence]); + rejectChain([...records, { id: 999, body: body() }], [first.evidence, second.evidence]); + }); + + it.each([ + ['source SDK', selectActivationRecord], ['built SDK', selectBuiltSdk], ['checker', selectChecker], + ] as const)( + '%s ignores ordinary discussion in verified chains and scoped pairs', + (_reader, selector) => { + for (const sourceKind of ['pending', 'activation'] as const) { + const first = chainNode(1, [1], undefined, { sourceKind }); + const second = chainNode(2, [1, 2], first, { sourceKind }); + const discussion = { id: 999, body: 'Thanks, the task breakdown looks good.' }; + expect(selector([discussion], registry(), context())).toBeUndefined(); + expect(() => selector([discussion], registry(), context([second.evidence]))).toThrow(); + for (const nodes of [[second], [first, second]]) { + expect(selector( + [discussion, { id: 998, body: section('Structured data', { + squad_artifact: 'plan', schema_version: '1', origin_issue: 45, phases: [], + }) }, ...nodes.flatMap(node => [node.source, node.record])], + registry(), context(nodes.map(node => node.evidence)), + )?.id).toBe(second.record.id); + } + } + }, + ); + + it('rejects extra pending, activation, and malformed candidates in a mixed chain', () => { + const first = chainNode(1, [1]); + const second = chainNode(2, [1, 2], first); + const records = [first.source, first.record, second.source, second.record, + { id: 998, body: 'Ordinary discussion.' }]; + for (const extra of [ + chainNode(3, [1, 2, 3], second).source, + { id: 999, body: body() }, + { id: 999, body: 'Activation bindings:\n```json\n{broken\n```' }, + { id: 999, body: first.source.body.replace('"phases":[1]', '"phases":null') }, + { id: 999, body: second.record.body }, + ]) rejectChain([...records, extra], [first.evidence, second.evidence]); + }); + + it('rejects altered ancestor bodies, malformed links, unknown parents, and self-links', () => { + const first = chainNode(1, [1]); + const second = chainNode(2, [1, 2], first); + rejectChain([ + first.source, { ...first.record, body: `${first.record.body}\n` }, second.source, second.record, + ], [first.evidence, second.evidence]); + const link = { + source_comment_id: first.source.id, source_sha256: digest(first.source.body), + reconciliation_comment_id: first.record.id, reconciliation_sha256: digest(first.record.body), + }; + for (const prior of [ + { ...link, source_sha256: 'b'.repeat(64) }, + { ...link, reconciliation_sha256: 'b'.repeat(64) }, + { ...link, reconciliation_comment_id: 999 }, + { ...link, source_comment_id: 20, reconciliation_comment_id: 21 }, + { ...link, authorized: true }, + { source_comment_id: first.source.id }, + ]) { + const child = chainNode(2, [1, 2], first, { prior }); + rejectChain([first.source, first.record, child.source, child.record], [first.evidence, child.evidence]); + } + const cyclicFirst = chainNode(1, [1], second); + rejectChain([cyclicFirst.source, cyclicFirst.record, second.source, second.record], + [cyclicFirst.evidence, second.evidence]); + }); + + it('rejects forks, disconnected heads, non-expanding coverage, and inconsistent frozen plans', () => { + const first = chainNode(1, [1]); + const second = chainNode(2, [1, 2], first); + const branch = chainNode(3, [1, 3], first); + rejectChain( + [first.source, first.record, second.source, second.record, branch.source, branch.record], + [first.evidence, second.evidence, branch.evidence], + ); + for (const child of [ + chainNode(2, [1], first), + chainNode(2, [2, 3], first), + chainNode(2, [1, 2]), + chainNode(2, [1, 2], first, { planSha256: digest('different frozen plan') }), + ]) rejectChain([first.source, first.record, child.source, child.record], [first.evidence, child.evidence]); + }); + + it('preserves prior numeric task/epic mappings even with freshly matching child binding proofs', () => { + const first = chainNode(1, [1]); + const second = chainNode(2, [1, 2], first); + for (const rows of [ + second.rows.map((row, index) => index === 0 ? { ...row, issue: '#999' } : row), + second.rows.map(row => ({ ...row, epic_issue: '#999' })), + second.rows.filter(row => row.task !== '1'), + ]) { + const child = chainNode(2, [1, 2], first, { rows }); + rejectChain([first.source, first.record, child.source, child.record], [first.evidence, child.evidence]); + } + }); + + it.each([ + [[2], [1, 2]], + [[1, 3], [1, 2, 3]], + ])('rejects nonprefix insertion from %j to %j despite matching proofs', (before, after) => { + for (const sourceKind of ['activation', 'pending'] as const) { + const first = chainNode(1, before, undefined, { sourceKind }); + const second = chainNode(2, after, first, { sourceKind }); + rejectChain([first.source, first.record, second.source, second.record], + [first.evidence, second.evidence]); + } + }); + + it('preserves numeric references but canonicalizes task and epic collision checks', () => { + const parsedRegistry = parseAgentProvenanceRegistry(registry()); + const bindingContext = { repository: 'owner/repo', originIssue: 45, artifact: 'activated' as const }; + const first = { ...bindings()[0]!, issue: '#42', epic_issue: '#50' }; + expect(parseWorkAgentBindings([ + { ...first, issue: String(Number.MAX_SAFE_INTEGER) }, + ], parsedRegistry, bindingContext)[0]!.issue).toBe(String(Number.MAX_SAFE_INTEGER)); + expect(() => parseWorkAgentBindings([ + first, { ...first, issue: '42', task: '2' }, + ], parsedRegistry, bindingContext)).toThrow(/malformed or partial/); + expect(() => parseWorkAgentBindings([ + first, { ...first, issue: '43', task: '2', epic: 'other', epic_issue: '50' }, + ], parsedRegistry, bindingContext)).toThrow(/malformed or partial/); + expect(parseWorkAgentBindings([ + first, { ...first, issue: '43', task: '2', epic_issue: '50' }, + ], parsedRegistry, bindingContext)).toHaveLength(2); + }); + }); + it('parses a complete v1 registry including an explicit avatar reference', () => { const result = parseAgentProvenanceRegistry(fixture('valid')); diff --git a/test/gh-aw-activate-fast-path-label-provisioning.test.ts b/test/gh-aw-activate-fast-path-label-provisioning.test.ts index a06fbd4a5..b01448691 100644 --- a/test/gh-aw-activate-fast-path-label-provisioning.test.ts +++ b/test/gh-aw-activate-fast-path-label-provisioning.test.ts @@ -1,421 +1,296 @@ /** - * Fresh-repo label provisioning for the recommended `/squad activate` fast path (#1959). - * - * Parent: #1957. Depends on #1962 (`gh-aw-activation-temporary-ids.test.ts`), which - * established the temporary-ID targeting pattern for the full `/squad plan activate` - * path and enabled workflow-global `require-temporary-id: true`. - * - * `/squad activate` routes to the `squad-plan-accept` skill. When only a flat `plan` - * artifact exists (no `program`/`implementation`), it takes its own fast path with its - * own `create-issue` calls. #1955 fixed provisioning in `squad-plan-activate` only, and - * #1962 added just enough temporary-ID compliance here to survive the new global flag — - * so on a fresh repository the fast path still leaned on `create-issue`'s `labels:` - * field, which GitHub silently drops for label names that do not already exist. - * - * This suite locks in the fast path's own copy of the verified pattern: - * - every fast-path `create-issue` still carries a unique temporary ID (#1962's - * compliance edit is preserved, not regressed) - * - `add_labels` is called in the same turn, targeting that call's temporary ID - * - `item_number` is mandatory, with the silent triggering-issue fallback named - * - reused/pre-existing issues are targeted by verified real numbers instead - * - base `squad`, single certified owner, `@copilot`, multi-owner, and non-roster - * behavior stays at parity with `squad-plan-activate` — including the `@copilot` - * mapping stated in the *primary* Step 2 label computation, not only in the - * provisioning prose, and omit-and-record (not a hard stop) for an uncertified owner - * - the origin intent issue is never an `add_labels` target - * - reruns stay idempotent - * - * Out of scope: activation-result reporting redesign (#1963), operation-capacity policy - * (#1961), the broad behavioral suite (#1960), checker distribution, and E4 (#1958). + * Fast-path activation uses one trusted request, frozen row identities and actual + * receipts. Native temporary-ID label tools remain available only for other commands. */ - -import { afterAll, describe, it, expect } from 'vitest'; -import { cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'; +import { afterAll, describe, it, expect, vi } from 'vitest'; +import { cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { execFileSync, spawnSync } from 'node:child_process'; import { join } from 'node:path'; +import { + ACTIVATION_RECEIPT_SCHEMA, buildActivationBindings, digest, parseActivationPlan, + validateCreateReceipt, validateReceiptSet, +} from '../workflows/shared/squad-activation.mjs'; +import { enforceSquadCommandContract } from '../workflows/shared/squad-command-contract.mjs'; const WORKFLOWS_DIR = join(process.cwd(), 'workflows'); -const SQUAD_WORKFLOW = join(WORKFLOWS_DIR, 'squad.md'); const TEST_WORKSPACES_DIR = join(process.cwd(), '.test-workspaces-fast-path-labels'); - -afterAll(() => { - rmSync(TEST_WORKSPACES_DIR, { recursive: true, force: true }); -}); - -/** Read a text file with line endings normalized to LF (Windows checkouts materialize CRLF). */ -function readText(filePath: string): string { - return readFileSync(filePath, 'utf8').replace(/\r\n/g, '\n'); +const workflow = readFileSync(join(WORKFLOWS_DIR, 'squad.md'), 'utf8').replace(/\r\n/g, '\n'); +const acceptStart = workflow.indexOf('## skill: `squad-plan-accept`'); +const acceptEnd = workflow.indexOf('## skill: `squad-plan-revise`'); +const acceptSkill = workflow.slice(acceptStart, acceptEnd); +const timestamp = '2026-01-01T00:00:00Z'; +const team = '## Members\n| Name | Role |\n|---|---|\n| Builder | Engineer |\n| Reviewer | Tester |'; +const registry = { + schema: 'squad-agent-provenance/v1', schema_version: 1, revision: 1, + agents: Object.fromEntries(['Builder', 'Reviewer'].map(name => [name.toLowerCase(), { + status: 'active', display_name: name, persistent_name: name, role: 'Engineer', + universe: 'synthetic', created_at: timestamp, updated_at: timestamp, + }])), +}; +const table = (rows: string[]) => [ + '| # | Title | Owner | Agent ID | Depends On |', '|---|---|---|---|---|', ...rows, +].join('\n'); +const rows = [ + '| build | Same title | Builder | builder | None |', + '| review | Same title | Reviewer | reviewer | build |', + '| assist | Assistant task | @copilot | - | review |', +]; +const parse = (body = table(rows), roster = team, identities = registry) => + parseActivationPlan(body, 'plan', identities, roster); + +afterAll(() => rmSync(TEST_WORKSPACES_DIR, { recursive: true, force: true })); + +function workspace(prefix: string) { + mkdirSync(TEST_WORKSPACES_DIR, { recursive: true }); + return mkdtempSync(join(TEST_WORKSPACES_DIR, prefix)); } -const workflow = readText(SQUAD_WORKFLOW); - -/** - * Isolate the `squad-plan-accept` skill body — the fast path `/squad activate` routes to. - * It has no `## end skill:` marker of its own, so its body runs to the next `## skill:` - * heading (`squad-plan-revise`). - */ -const ACCEPT_START = workflow.indexOf('## skill: `squad-plan-accept`'); -const ACCEPT_END = workflow.indexOf('## skill: `squad-plan-revise`'); -expect(ACCEPT_START, '"## skill: `squad-plan-accept`" is missing from workflows/squad.md').toBeGreaterThan(-1); -expect(ACCEPT_END, '"## skill: `squad-plan-revise`" is missing from workflows/squad.md').toBeGreaterThan(ACCEPT_START); -const acceptSkill = workflow.slice(ACCEPT_START, ACCEPT_END); - -/** Whitespace-collapsed view for prose assertions that may span wrapped lines. */ -const acceptProse = acceptSkill.replace(/\s+/g, ' '); - -/** The `squad-plan-activate` body, used only for path-parity comparisons. */ -const ACTIVATE_START = workflow.indexOf('## skill: `squad-plan-activate`'); -const ACTIVATE_END = workflow.indexOf('## end skill: `squad-plan-activate`'); -expect(ACTIVATE_START, '"## skill: `squad-plan-activate`" is missing from workflows/squad.md').toBeGreaterThan(-1); -expect(ACTIVATE_END, '"## end skill: `squad-plan-activate`" is missing').toBeGreaterThan(ACTIVATE_START); -const activateProse = workflow.slice(ACTIVATE_START, ACTIVATE_END).replace(/\s+/g, ' '); +function captured(phased = false) { + const plan = parse(`${phased ? '### Phase 1\n' : ''}${table(rows)}`); + const operations = [ + ...plan.epics.map((epic: string) => ({ id: `epic:${epic}`, epic })), + ...plan.tasks.map((task: any) => ({ id: `task:${task.id}`, task: task.id, epic: task.epic })), + ]; + const capture = { + repository: 'owner/repo', origin_issue: 42, run_id: 900, run_attempt: 1, + plan, fullPlan: plan, operations, registry, phase: null, + }; + const journal = { + schema: ACTIVATION_RECEIPT_SCHEMA, repository: capture.repository, + capture_sha256: digest(capture), run_id: 900, run_attempt: 1, + started: operations.map(operation => operation.id), + parent_fallbacks: [], + receipts: operations.map((operation, index) => ({ + operation_id: operation.id, repository: capture.repository, + number: 101 + index, id: 1001 + index, node_id: `I_${index}`, + url: `https://github.com/owner/repo/issues/${101 + index}`, + })), + }; + return { capture, journal }; +} -describe('gh-aw: /squad activate fast path provisions labels on a fresh repo (#1959)', () => { - it('routes `/squad activate` to squad-plan-accept, so this skill is the fast path', () => { - // If routing ever moves, every assertion below is inspecting the wrong skill. +describe('gh-aw: fast-path request boundary', () => { + it('routes activate to the shared trusted writer, not paired native tools', () => { + expect(acceptStart).toBeGreaterThan(-1); + expect(acceptEnd).toBeGreaterThan(acceptStart); expect(workflow).toContain('| `activate` | `squad-plan-accept` |'); - expect(workflow).toContain('| `/squad activate` | Activate (recommended fast-path) |'); + expect(acceptSkill).toContain('Call `activate_plan` exactly once'); + expect(acceptSkill).toContain('Do not call `create_issue`, `add_labels`, `add_comment`'); + expect(workflow.slice(workflow.indexOf('## skill: `squad-plan-activate`'))) + .toContain('Use the same `activate_plan` procedure'); }); - it('names the root cause: create-issue labels: cannot create a missing label', () => { - expect( - /GitHub silently drops label names that do not already exist/i.test(acceptProse), - "The fast path must state why create-issue's own labels: field is insufficient — " + - 'GitHub drops unknown label names on POST /issues rather than creating them. ' + - 'Without this the model has no reason to make the extra add_labels call.', - ).toBe(true); - expect(acceptProse).toMatch(/`create-issue`'s (own )?`labels:` field (alone )?cannot/i); - }); + async function enforce(command: string, items: unknown[]) { + const outputPath = join(workspace('dispatch-'), 'output.json'); + writeFileSync(outputPath, JSON.stringify({ items })); + const createComment = vi.fn(); + const user = { id: 700, login: 'operator', type: 'User' }; + const operation = enforceSquadCommandContract({ + payload: { + action: 'created', sender: user, issue: { number: 42, user }, + comment: { id: 9, body: `/squad ${command}`, user }, + }, + eventName: 'issue_comment', actor: 'operator', + resolvePermission: async () => 'admin', createComment, agentOutputPath: outputPath, + }); + return { operation, createComment }; + } - it('declares add_labels with create-if-missing as the provisioning mechanism', () => { - expect(acceptSkill).toContain('add_labels'); - expect(acceptSkill).toContain('create-if-missing'); - expect(acceptProse).toMatch(/`add-labels` safe output \(`allowed: \[squad, "squad:\*"\]`, `create-if-missing: true`\)/); - expect( - /fresh repository/i.test(acceptProse), - 'The fresh-repository case is the entire point of #1959 and must be named explicitly.', - ).toBe(true); + it.each(['activate', 'plan accept', 'plan activate'])('accepts only the plan reference for %s', async command => { + const { operation, createComment } = await enforce(command, [{ type: 'activate_plan', plan_comment_id: '10' }]); + await expect(operation).resolves.toMatchObject({ status: 'accepted', mode: command }); + expect(createComment).not.toHaveBeenCalled(); }); - it('calls add_labels in the same turn as each create-issue call', () => { - expect(acceptProse).toMatch(/In the same turn as each `create-issue` call in Step 2, call `add_labels`/i); - // The per-item bullet must carry the instruction too, not just the section prose, - // so a model reading only the create-issue recipe still emits the label call. - const labelApplication = acceptSkill.match(/^- Label application:.*$/m)?.[0] ?? ''; - expect(labelApplication, 'Step 2 needs a per-item "Label application:" bullet').not.toBe(''); - expect(labelApplication).toContain('add_labels'); - expect(labelApplication).toContain('item_number'); + const unsafeItems = [ + ['native create with temporary ID', [{ type: 'create_issue', temporary_id: 'aw_wi1', title: 'Task' }]], + ['paired native create and label', [ + { type: 'create_issue', temporary_id: 'aw_wi1', title: 'Task' }, + { type: 'add_labels', item_number: 'aw_wi1', labels: ['squad', 'squad:builder'] }, + ]], + ['implicit origin label fallback', [{ type: 'add_labels', labels: ['squad:builder'] }]], + ['borrowed or predicted issue number', [{ type: 'add_labels', item_number: 101, labels: ['squad'] }]], + ['mixed trusted and native outputs', [ + { type: 'activate_plan', plan_comment_id: '10' }, { type: 'add_labels', item_number: 'aw_other', labels: ['squad'] }, + ]], + ['model-supplied labels', [{ type: 'activate_plan', plan_comment_id: '10', labels: ['squad:visitor'] }]], + ['model-supplied bindings', [{ type: 'add_comment', body: 'Activation bindings:\n[]' }]], + ['model acceptance', [{ type: 'add_comment', data: { squad_artifact: 'plan-accepted' }, body: 'Accepted' }]], + ['model lifecycle', [{ type: 'upsert_lifecycle_state', body: '**Current state:** Activated' }]], + ['duplicate requests', [{ type: 'activate_plan', plan_comment_id: '10' }, { type: 'activate_plan', plan_comment_id: '10' }]], + ] as const; + it.each(unsafeItems)('rejects %s before writes in all activation modes', async (_name, items) => { + for (const command of ['activate', 'plan accept', 'plan activate']) { + const { operation, createComment } = await enforce(command, [...items]); + await expect(operation).rejects.toThrow(/trusted writer|native issue creation/); + expect(createComment).not.toHaveBeenCalled(); + } }); }); -describe('#1959: fast-path add_labels targets an explicit, verified item', () => { - it('targets the minting create-issue call\'s own temporary ID', () => { - expect(acceptProse).toMatch( - /`item_number` — that same `create-issue` call's `temporary_id` \(`#aw_ph\{N\}` for a phase issue, `#aw_wi\{N\}` for a work item\)/i, - ); - const labelApplication = acceptSkill.match(/^- Label application:.*$/m)?.[0] ?? ''; - expect(labelApplication).toMatch(/`item_number` set to this item's own temporary ID/i); +describe('gh-aw: frozen fast-path owner and actual-ID correspondence', () => { + it.each([ + ['Azure Frontend', 'azure-frontend'], + ['Dr. Example', 'dr-example'], + ['--Release_Lead--', 'release-lead'], + ])('derives the actual task and epic label from certified display name %s', (name, slug) => { + const identities = structuredClone(registry); + identities.agents.builder.display_name = name; + identities.agents.builder.persistent_name = name; + const plan = parse(`### Phase 1\n${table([`| build | Example | ${name} | builder | None |`])}`, + team.replace('Builder', name), identities); + const operations = [{ id: 'epic:phase-1', epic: 'phase-1' }, { id: 'task:build', task: 'build', epic: 'phase-1' }]; + const capture = { + repository: 'owner/repo', origin_issue: 42, run_id: 900, run_attempt: 1, + plan, fullPlan: plan, operations, registry: identities, phase: 1, + }; + const journal = { + schema: ACTIVATION_RECEIPT_SCHEMA, repository: capture.repository, + capture_sha256: digest(capture), run_id: 900, run_attempt: 1, parent_fallbacks: [], + started: operations.map(operation => operation.id), + receipts: operations.map((operation, index) => ({ + operation_id: operation.id, repository: capture.repository, number: 101 + index, + id: 1001 + index, node_id: `I_${index}`, url: `https://github.com/owner/repo/issues/${101 + index}`, + })), + }; + expect(buildActivationBindings(capture, journal)).toMatchObject([ + { label: `squad:${slug}`, epic_label: `squad:${slug}`, agent_id: 'builder' }, + ]); }); - it('preserves #1962\'s minimal temp-ID compliance on every fast-path create-issue', () => { - // Regression guard: #1962 enabled workflow-global require-temporary-id: true, which - // rejects any create-issue call lacking one. #1959 must not weaken that. - const tempIdBullet = acceptSkill.match(/^- Temporary ID:.*$/m)?.[0] ?? ''; - expect(tempIdBullet, 'Step 2 must keep its "Temporary ID:" bullet').not.toBe(''); - expect(tempIdBullet).toContain('require-temporary-id: true'); - expect(tempIdBullet).toContain('#aw_ph{N}'); - expect(tempIdBullet).toContain('#aw_wi{N}'); - expect(tempIdBullet).toContain('^#?aw_[A-Za-z0-9_]{3,12}$'); - // Uniqueness invariant. #1962's follow-up (528ba9b0) byte-tightened the wording from - // "must not repeat within this run" to "be unique in this run" — the same contract, - // fewer bytes. Accept either phrasing, but keep requiring BOTH the uniqueness mandate - // and the silent-duplicate hazard that motivates it, so the invariant cannot be - // dropped by quietly rewording it away. - expect( - /(must not repeat within|be unique in) this run/i.test(tempIdBullet), - 'The "Temporary ID:" bullet must state the per-run uniqueness requirement.', - ).toBe(true); - expect( - /silently lets (the|a duplicate's) last writer own the mapping/i.test(tempIdBullet), - 'The bullet must keep the reason uniqueness matters: gh-aw does not reject a ' + - 'duplicate temporary_id, it silently lets the last writer own the mapping.', - ).toBe(true); - // The phase-issue parent linkage from #1962 must still resolve via temporary ID. - expect(acceptProse).toMatch(/pass its `#aw_ph\{N\}` temporary ID/i); + it('rejects ambiguous certified display-name slugs before producing a plan', () => { + const identities = structuredClone(registry); + identities.agents.builder.display_name = 'Foo Bar'; + identities.agents.builder.persistent_name = 'Foo Bar'; + identities.agents.reviewer.display_name = 'Foo-Bar'; + identities.agents.reviewer.persistent_name = 'Foo-Bar'; + expect(() => parse(table(['| build | Example | Foo Bar | builder | None |']), + team.replace('Builder', 'Foo Bar').replace('Reviewer', 'Foo-Bar'), identities)) + .toThrow(/slug.*ambiguous/); }); - it('makes item_number mandatory and names the silent triggering-issue fallback', () => { - expect(acceptProse).toMatch(/Every `add_labels` call MUST pass `item_number`/); - expect( - /Omitting it does not fail — it silently applies the labels to the \*\*triggering intent issue\*\*/i.test( - acceptProse, - ), - 'The hazard must be stated as a silent mislabel of the user\'s own issue, not a ' + - 'generic "always pass item_number" — the model needs the consequence to weigh it.', - ).toBe(true); - // Parity: squad-plan-activate states the identical hazard. - expect(activateProse).toMatch(/silently labels the \*\*triggering intent issue\*\*/i); + it('uses each row owner and explicitly maps @copilot without inheriting the prior owner', () => { + expect(parse().tasks.map((task: any) => [task.id, task.agent_id, task.label])).toEqual([ + ['build', 'builder', 'squad:builder'], ['review', 'reviewer', 'squad:reviewer'], + ['assist', null, 'squad:copilot'], + ]); + expect(parse().tasks[2].label).not.toBe('squad:@copilot'); }); - it('forbids borrowing another item\'s temporary ID or labeling an uncreated item', () => { - expect(acceptProse).toMatch(/Never reuse another item's temporary ID/i); - expect(acceptProse).toMatch( - /never emit `add_labels` for an item whose `create-issue` call was not made in this run/i, - ); + it('keeps distinct stable tasks with the same title instead of title-deduplicating them', () => { + expect(parse().tasks.slice(0, 2).map((task: any) => task.id)).toEqual(['build', 'review']); }); - it('uses verified real numbers for reused or pre-existing issues', () => { - expect(acceptProse).toMatch( - /For an issue this run did \*\*not\*\* create — one recognized by Step 1a's idempotency check or matched by title — pass its verified real number instead/i, - ); - expect(acceptProse).toMatch(/a temporary ID maps only issues this run created/i); + it('rejects duplicate stable IDs rather than last-writer-wins mappings', () => { + expect(() => parse(table([...rows, rows[0]]))).toThrow(/stable task IDs.*duplicated/); }); - it('forbids predicting a returned issue number or waiting for one', () => { - expect(acceptProse).toMatch( - /`create-issue` returns no real issue number during this run, so never predict or infer one and never pause between the two calls waiting for one/i, - ); - expect(acceptProse).toMatch(/`create-issue` first and `add_labels` immediately after is the supported order/i); - }); -}); - -describe('#1959: fast-path label sets stay at parity with squad-plan-activate', () => { - it('applies the base squad label to every activated item', () => { - expect(acceptProse).toMatch(/Work item: `squad`, plus `squad:\{owner\}`/); - expect(acceptProse).toMatch(/Phase issue: `squad`, plus `squad:\{owner\}`/); + it.each([ + ['borrowed identity', rows[0].replace('| builder |', '| reviewer |'), /Agent ID/], + ['invented identity', rows[0].replace('| builder |', '| invented |'), /Agent ID/], + ['non-roster asserted identity', rows[0].replace('| Builder |', '| Visitor |'), /unknown non-roster owner/], + ] as const)('rejects %s before producing a label binding', (_name, row, reason) => { + expect(() => parse(table([row]))).toThrow(reason); }); - it('derives the member label from that row\'s own frozen certified Owner', () => { - expect(acceptProse).toMatch( - /derived from that row's own frozen certified `Owner`, lowercased/i, - ); - expect(acceptProse).toMatch( - /never inherit the phase issue's owner or carry the previous row's value forward/i, - ); - // The Step 2 computation bullet must still name its certified binding source. - const labelRule = acceptSkill.match(/^- Labels:.*$/m)?.[0] ?? ''; - expect(labelRule).toMatch(/frozen row `Owner` lowercased/i); - expect(labelRule).toMatch(/only from that task's certified binding/i); + it('refuses an unreadable roster rather than treating it as an empty certified set', () => { + expect(() => parse(table(rows), 'not a roster')).toThrow(); }); - it('maps @copilot to squad:copilot in the primary label computation itself', () => { - // The bug this guards: the Step 2 `- Labels:` bullet is the *primary* computation — - // it says "the frozen row `Owner` lowercased", which turns `@copilot` into the - // invalid `squad:@copilot`. Stating the mapping only in the provisioning section - // below leaves the primary rule wrong, and a model following the bullet literally - // still emits the bad label. Assert against the extracted bullet, not the whole - // skill, so a correct-elsewhere/wrong-here workflow cannot pass. - const labelRule = acceptSkill.match(/^- Labels:.*$/m)?.[0] ?? ''; - expect(labelRule, 'Step 2 must have a "- Labels:" computation bullet').not.toBe(''); - expect( - /Map `@copilot` to `squad:copilot`/.test(labelRule), - 'The `- Labels:` bullet must carry the @copilot mapping inline.', - ).toBe(true); - expect( - /never `squad:@copilot`/.test(labelRule), - 'The `- Labels:` bullet must forbid the lowercased-verbatim form explicitly.', - ).toBe(true); - - // Still stated in the provisioning section, and identical to the full path. - expect(acceptProse).toMatch(/`@copilot` maps to the existing `squad:copilot` routing label — never `squad:@copilot`/); - expect(activateProse).toMatch(/`@copilot` maps to the existing `squad:copilot` routing label — never `squad:@copilot`/); + it('does not certify an inactive frozen owner', () => { + const identities = structuredClone(registry); + identities.agents.builder.status = 'inactive'; + expect(() => parse(table(rows), team, identities)).toThrow(/active frozen roster owner/); }); - it('gives a multi-owner phase issue only squad, and records the omission', () => { - expect(acceptProse).toMatch( - /only when every accepted row in that phase names one and the same owner/i, - ); - expect(acceptProse).toMatch( - /Two or more distinct owners is a multi-owner phase: apply only `squad`, choose none of them/i, - ); - expect(acceptProse).toMatch(/record it under a `Non-roster agent values` heading/i); - // Parity with the epic rule in squad-plan-activate. - expect(activateProse).toMatch(/multi-owner epic: apply only `squad`/i); + it('uses only actual receipt numbers for task, parent and dependency bindings', () => { + const { capture, journal } = captured(); + const bindings = buildActivationBindings(capture, journal); + expect(bindings.map((binding: any) => [binding.task, binding.issue, binding.epic_issue, binding.depends_on])) + .toEqual([['build', '#101', '#42', []], ['review', '#102', '#42', ['#101']], ['assist', '#103', '#42', ['#102']]]); + expect(bindings.every((binding: any) => binding.epic_label === null && binding.epic_omission_reason === 'origin')).toBe(true); }); - it('omits and records a non-roster owner instead of stopping, matching full activation', () => { - // #1959 requires parity with squad-plan-activate. The fast path previously stopped - // before any mutation on an uncertified Owner; that is now aligned to omit-and-record - // so both activation paths behave identically for the same plan. - const labelRule = acceptSkill.match(/^- Labels:.*$/m)?.[0] ?? ''; - expect( - /An `Owner` certified by neither route gets `squad` alone: omit the owner label, continue, and record the value under `Non-roster agent values`/.test( - labelRule, - ), - 'The primary label computation must carry the omit-and-record rule inline, not ' + - 'defer it to prose elsewhere in the skill.', - ).toBe(true); - - expect(acceptProse).toMatch( - /An individual `Owner` matching no certified active roster name and not `@copilot` does \*\*not\*\* stop the run/i, - ); - expect(acceptProse).toMatch( - /create that issue with the base `squad` label only, omit the owner label, continue, and record the value under `Non-roster agent values`/i, - ); - expect(acceptProse).toMatch( - /an uncertified value can reach `add_labels` only as the base `squad` label/i, - ); - - // The old stop must be gone — a lingering `/squad plan revise` hard stop would - // contradict the rule above and reintroduce the divergence. - expect( - acceptProse, - 'The pre-mutation stop on an uncertified Owner must be removed, not merely ' + - 'supplemented — otherwise the fast path still diverges from squad-plan-activate.', - ).not.toMatch(/stop before mutation and require `\/squad plan revise`/i); - - // Parity: squad-plan-activate's own omit-and-record rule. - expect(activateProse).toMatch( - /MUST NOT become a\s+`squad:\{agent\}` label: apply only `squad` for that issue/i, - ); + it('records multi-owner parent omission without choosing any task owner for the epic', () => { + const { capture, journal } = captured(true); + const bindings = buildActivationBindings(capture, journal); + expect(bindings.every((binding: any) => binding.epic_issue === '#101' && + binding.epic_label === null && binding.epic_omission_reason === 'multi-owner')).toBe(true); + expect(bindings[0].epic_agents).toEqual(['@copilot', 'builder', 'reviewer']); + expect(bindings.map((binding: any) => binding.label)).toEqual(['squad:builder', 'squad:reviewer', 'squad:copilot']); }); - it('still stops before mutation when the roster itself is unreadable', () => { - // ROSTER_UNREADABLE is a different failure: no certified set exists at all, so - // omit-and-record has nothing to certify against. squad-plan-activate stops here - // too — this is parity, not a leftover of the removed per-owner stop. - expect(acceptProse).toMatch( - /If TG-2 emitted a `ROSTER_UNREADABLE:` line, stop before mutation and report that named reason/i, - ); - expect(activateProse).toMatch(/If TG-2 emitted a `ROSTER_UNREADABLE:` line, STOP:/); + it.each(['aw_wi1', '#aw_wi1', 0, 1.5])('rejects non-actual receipt number %s', number => { + const { capture, journal } = captured(); + (journal.receipts[0] as any).number = number; + expect(() => validateReceiptSet(capture, journal)).toThrow(/identities.*malformed/); }); - it('requires the Non-roster agent values heading the omissions feed', () => { - // The omit-and-record rule is only coherent if the heading it records into is - // actually mandated. Without this, "record it" points at nothing. - expect(acceptProse).toMatch( - /Whenever an accepted `Owner` did not become a `squad:\{owner\}` label — a multi-owner phase issue, or a value certified by neither the roster nor `@copilot` — a `Non-roster agent values` heading is \*\*required\*\* in this summary, naming the value and the issue it applied to/i, - ); - expect(acceptProse).toMatch(/Omitting the label while omitting the heading reports a clean run that did not happen/i); + it('rejects staged output instead of waiting for a native queued result', () => { + const { capture } = captured(); + expect(() => validateCreateReceipt({ staged: true, temporary_id: 'aw_wi1' }, capture.operations[0], capture)) + .toThrow(/did not return an actual issue/); }); - it('never targets the triggering intent issue with an owner label', () => { - expect(acceptProse).toMatch( - /The triggering intent issue is never an `add_labels` target/i, - ); - expect(acceptProse).toMatch( - /It is the flat-plan parent, not an activated item, and receives no owner label from this run/i, - ); - }); - - it('keeps reruns idempotent', () => { - expect(acceptProse).toMatch( - /Re-applying an already-present label on a rerun is a no-op under add-only merge semantics/i, - ); - expect(acceptProse).toMatch(/safe under Step 1a's idempotency path/i); - // The pre-existing whole-plan idempotency contract must survive untouched. - expect(acceptProse).toMatch(/\*\*Whole-plan idempotency:\*\* when `requested_phase` is null/i); - }); - - it('treats an auto-provisioned label\'s default color as expected, not a failure', () => { - expect(acceptProse).toMatch( - /auto-provisioned by `create-if-missing` on a fresh repository instead receives gh-aw's deterministic color and an empty description/i, - ); - expect(acceptProse).toMatch(/that is expected, not a failure, and must not be reported as one/i); - }); - - it('reports labels actually applied, never merely intended', () => { - // #1963 tightened this sentence: a run cannot know a label was "actually applied" — - // safe outputs land after the agent turn — so the claim is now an *accepted* - // `add_labels` call for that same issue, with create-issue explicitly ruled out as - // evidence. #1959's original guarantee (never skipped/deferred/merely intended) is - // preserved verbatim inside the tightened sentence. - expect(acceptProse).toMatch( - /Report only the labels an accepted `add_labels` call carried for that same issue; never a label that was skipped, deferred, or merely intended, and never one attributed to `create-issue`/i, - ); + it('cannot borrow a receipt, change repositories, or report an uncreated operation', () => { + for (const mutate of [ + (journal: any) => { journal.receipts[1] = { ...journal.receipts[0], operation_id: 'task:review' }; }, + (journal: any) => { journal.receipts[0].repository = 'other/repo'; }, + (journal: any) => { journal.receipts.pop(); }, + (journal: any) => { journal.started.reverse(); }, + ]) { + const { capture, journal } = captured(); + mutate(journal); + expect(() => validateReceiptSet(capture, journal)).toThrow(); + } }); }); -// --------------------------------------------------------------------------- -// Compiled-artifact verification -// --------------------------------------------------------------------------- -// -// The assertions above lock the prose contract. This section proves the fast path's -// calls are actually executable: `gh aw compile --strict` must expose add_labels and -// create_issue as tools, wire create_if_missing into the handler config, and keep -// #1962's require_temporary_id enforcement on. Fails closed (never skips) per this -// repo's #1833/#1834 convention: an unmeasured contract is indistinguishable from a -// violated one. - -const GH_AW_INSTALL_HINT = - '`gh aw` is required to compile the workflow this gate inspects. Install it with ' + - '`gh extension install github/gh-aw` (matches .github/workflows/squad-ci.yml). This ' + - 'gate fails closed rather than skipping: an unmeasured contract is indistinguishable ' + - 'from a violated one (#1834).'; - -describe('gh-aw: the fast path\'s label calls compile and stay enforceable (#1959)', () => { +describe('gh-aw: native compiled policy remains bounded outside activation', () => { let compiledLock: string | null = null; - - function lockText(): string { + function lockText() { if (compiledLock !== null) return compiledLock; - - const versionProbe = spawnSync('gh', ['aw', '--version'], { encoding: 'utf8' }); - if (versionProbe.status !== 0) { - throw new Error(`gh aw --version failed. ${GH_AW_INSTALL_HINT}`); - } - - mkdirSync(TEST_WORKSPACES_DIR, { recursive: true }); - const workspace = mkdtempSync(join(TEST_WORKSPACES_DIR, 'fast-path-labels-')); - execFileSync('git', ['init', '--quiet'], { cwd: workspace }); - // gh-aw's dispatch-workflow validation resolves cross-workflow references against a - // `.github/workflows/` directory relative to the compiled file. This repo ships the - // gh-aw *source* from a top-level `workflows/` dir; consumers install it into - // `.github/workflows/` via `gh aw add`. Mirror that real deployment layout. - cpSync(WORKFLOWS_DIR, join(workspace, '.github', 'workflows'), { recursive: true }); - execFileSync( - 'gh', - ['aw', 'compile', '.github/workflows/squad.md', '--strict', '--approve', '--no-check-update'], - { cwd: workspace, encoding: 'utf8', stdio: 'pipe' }, - ); - - const lockPath = join(workspace, '.github', 'workflows', 'squad.lock.yml'); - if (!existsSync(lockPath)) { - throw new Error( - 'gh aw compile produced no squad.lock.yml — the compiled artifact this gate ' + - `inspects is absent, so the contract is unmeasured. ${GH_AW_INSTALL_HINT}`, - ); - } - compiledLock = readText(lockPath); + const compiler = process.env.SQUAD_GH_AW_BIN ?? 'gh'; + const prefix = process.env.SQUAD_GH_AW_BIN ? [] : ['aw']; + const version = spawnSync(compiler, [...prefix, '--version'], { encoding: 'utf8' }); + expect(version.status, 'Supported gh-aw is required; this contract never skips').toBe(0); + expect(`${version.stdout}\n${version.stderr}`).toContain('v0.91.5'); + const root = workspace('compile-'); + execFileSync('git', ['init', '--quiet'], { cwd: root }); + cpSync(WORKFLOWS_DIR, join(root, '.github/workflows'), { recursive: true }); + execFileSync(compiler, [...prefix, 'compile', '.github/workflows/squad.md', '--strict', '--no-check-update'], + { cwd: root, encoding: 'utf8', stdio: 'pipe' }); + const lockPath = join(root, '.github/workflows/squad.lock.yml'); + expect(existsSync(lockPath)).toBe(true); + compiledLock = readFileSync(lockPath, 'utf8').replace(/\r\n/g, '\n'); return compiledLock; } - it('strict-compiles the workflow carrying the fast-path label calls', () => { - expect(lockText().length).toBeGreaterThan(0); + it('strict-compiles the workflow containing the trusted activation request', () => { + expect(lockText()).toContain('activate_plan'); }); - - it('keeps add_labels and create_issue callable together, so the fast path can pair them', () => { - const compiled = lockText(); - expect(compiled).toMatch(/"tools":\[[^\]]*"add_labels"[^\]]*\]/); - expect(compiled).toMatch(/"tools":\[[^\]]*"create_issue"[^\]]*\]/); + it('retains native add_labels and create_issue tools for unrelated commands', () => { + expect(lockText()).toMatch(/"tools":\[[^\]]*"add_labels"[^\]]*\]/); + expect(lockText()).toMatch(/"tools":\[[^\]]*"create_issue"[^\]]*\]/); }); - - it('wires create_if_missing into the handler config the fast path relies on', () => { - // gh-aw embeds this JSON config inside a double-quoted YAML env value with every - // `"` backslash-escaped. De-escape before matching so this holds across versions. + it('retains bounded native label provisioning, not arbitrary label creation', () => { const compiled = lockText().replace(/\\"/g, '"'); expect(compiled).toMatch(/"add_labels":\{[^}]*"create_if_missing":true/); expect(compiled).toMatch(/"add_labels":\{[^}]*"allowed":\["squad","squad:\*"\]/); }); - - it('keeps #1962\'s require_temporary_id enforcement on for the fast path\'s create_issue calls', () => { - // require-temporary-id is workflow-global, enforced at the MCP tool layer from the - // single create_issue config block — it covers squad-plan-accept's calls too. + it('retains native temporary-ID enforcement without using it as activation authority', () => { const compiled = lockText().replace(/\\"/g, '"'); expect(compiled).toMatch(/"create_issue":\{[^}]*"require_temporary_id":true/); expect(compiled).toMatch(/"required_field_additions":\s*\{\s*"create_issue":\s*\[\s*"temporary_id"\s*\]/); }); - - it('leaves label writes in the safe-output job, not the agent job', () => { + it('keeps issue writes outside the model agent job', () => { const compiled = lockText(); - const agentJobAt = compiled.indexOf('\n agent:\n'); - const safeOutputsJobAt = compiled.indexOf('\n safe_outputs:\n'); - expect(agentJobAt, '"agent:" job is missing from the compiled lock file').toBeGreaterThan(-1); - expect(safeOutputsJobAt, '"safe_outputs:" job is missing from the compiled lock file').toBeGreaterThan(agentJobAt); - - const permsMatch = compiled.slice(agentJobAt, safeOutputsJobAt).match(/permissions:\n((?:\s{6}\S.*\n)+)/); - expect(permsMatch, 'agent job permissions block not found').not.toBeNull(); - const permsBlock = permsMatch ? permsMatch[1] : ''; - expect(permsBlock).toContain('issues: read'); - expect(permsBlock).not.toContain('issues: write'); + const start = compiled.indexOf('\n agent:\n'); + const end = compiled.indexOf('\n safe_outputs:\n'); + expect(start).toBeGreaterThan(-1); + expect(end).toBeGreaterThan(start); + const permissions = compiled.slice(start, end).match(/permissions:\n((?:\s{6}\S.*\n)+)/)?.[1]; + expect(permissions).toContain('issues: read'); + expect(permissions).not.toContain('issues: write'); }); }); diff --git a/test/gh-aw-activation-artifact-integrity.test.ts b/test/gh-aw-activation-artifact-integrity.test.ts index dd4dc11cd..8086633e2 100644 --- a/test/gh-aw-activation-artifact-integrity.test.ts +++ b/test/gh-aw-activation-artifact-integrity.test.ts @@ -1,32 +1,6 @@ /** - * E4 preflight package A — fast-path `/squad activate` artifact integrity. - * - * Before this change, only the granular `/squad plan activate` path - * (`squad-plan-activate`, artifacts `activated` / `phases-activated`) was required to emit - * a non-empty `Activation bindings:` block, and only that path's checker/collector wiring - * covered it. The recommended fast path (`squad-plan-accept`, artifacts `plan-accepted` / - * `phases-accepted`) creates and labels issues identically but had no such requirement, no - * checker coverage, and the CI collector (`.github/workflows/squad-agent-binding-check.yml`) - * filtered on a literal `/activated/i` substring that a `plan-accepted`/`phases-accepted` - * comment never contains — so a fast-path activation with omitted or empty bindings was - * invisible to the deterministic post-activation check. - * - * This suite locks four things: - * 1. Both activation paths use the exact same `Label operations accepted` heading text. - * 2. The fast path's output template requires a non-empty `Activation bindings:` block, - * with the same shape/quoting/omission-reason contract as the granular path. - * 3. The deterministic checker (`scripts/check-agent-binding.mjs`) validates `plan-accepted` - * / `phases-accepted` bindings exactly like `activated` / `phases-activated`, and rejects - * standalone certainty claims ("applied", "received", "landed", "verified", "confirmed", - * "checked") in label-operation reporting — scoped per-clause (not per-line or a blanket - * whole-comment scan), so quoted titles, unrelated sections, substrings, a forbidden word - * describing an unrelated subject sharing a line with a label token, and negations never - * false-positive, while a negation elsewhere on the line never blanket-suppresses a real, - * separate claim. - * 4. The CI collector actually passes fast-path artifact comments to that checker. - * - * Out of scope (per the E4 preflight package A brief): workflow discriminators, docs - * sequencing, draft PR UX, and every other E4 finding. + * Both activation routes delegate metadata and bindings to the trusted writer. + * Checker, wording-scanner and collector regressions remain independently covered. */ import { describe, expect, it } from 'vitest'; @@ -40,6 +14,9 @@ import { validateActivation, validateBindings, } from '../scripts/check-agent-binding.mjs'; +import { + ACTIVATION_RECEIPT_SCHEMA, buildActivationBindings, digest, +} from '../workflows/shared/squad-activation.mjs'; /** Read a text file with line endings normalized to LF (Windows checkouts materialize CRLF). */ function readText(filePath: string): string { @@ -54,22 +31,17 @@ const COLLECTOR_YML = join(process.cwd(), '.github', 'workflows', 'squad-agent-b const workflow = readText(SQUAD_WORKFLOW); const ontology = readText(ONTOLOGY); -/** `squad-plan-activate` — the `/squad plan activate` granular path. Has an explicit end marker. */ -const ACTIVATE_START = workflow.indexOf('## skill: `squad-plan-activate`'); -const ACTIVATE_END = workflow.indexOf('## end skill: `squad-plan-activate`'); -expect(ACTIVATE_START, '"## skill: `squad-plan-activate`" is missing from workflows/squad.md').toBeGreaterThan(-1); -expect(ACTIVATE_END, '"## end skill: `squad-plan-activate`" is missing').toBeGreaterThan(ACTIVATE_START); -const activateSkill = workflow.slice(ACTIVATE_START, ACTIVATE_END); - -/** - * `squad-plan-accept` — the recommended `/squad activate` fast path. It has no - * `## end skill:` marker, so its body runs to the next `## skill:` heading. - */ -const ACCEPT_START = workflow.indexOf('## skill: `squad-plan-accept`'); -const ACCEPT_END = workflow.indexOf('## skill: `squad-plan-revise`'); -expect(ACCEPT_START, '"## skill: `squad-plan-accept`" is missing from workflows/squad.md').toBeGreaterThan(-1); -expect(ACCEPT_END, '"## skill: `squad-plan-revise`" is missing from workflows/squad.md').toBeGreaterThan(ACCEPT_START); -const acceptSkill = workflow.slice(ACCEPT_START, ACCEPT_END); +/** Skill bodies end at the next level-two heading or EOF, not a legacy end marker. */ +function skillBody(name: string): string { + const heading = `## skill: \`${name}\``; + const start = workflow.indexOf(heading); + expect(start, `${heading} is missing`).toBeGreaterThan(-1); + const rest = workflow.slice(start + heading.length); + const end = rest.search(/\n## /); + return end === -1 ? rest : rest.slice(0, end); +} +const activateSkill = skillBody('squad-plan-activate'); +const acceptSkill = skillBody('squad-plan-accept'); /** Both activation paths, so parity assertions cannot pass by covering only one. */ const BOTH_PATHS: ReadonlyArray = [ @@ -77,147 +49,173 @@ const BOTH_PATHS: ReadonlyArray = [ ['/squad activate fast path (squad-plan-accept)', acceptSkill], ]; -// --------------------------------------------------------------------------- -// 1. Heading parity — both paths use the exact same literal heading. -// --------------------------------------------------------------------------- +describe('gh-aw: both model paths delegate activation publication to the trusted writer', () => { + it.each(BOTH_PATHS)('%s: routes to activate_plan rather than authoring bindings', (_path, text) => { + expect(text).toContain('`activate_plan`'); + expect(text).toContain('trusted'); + expect(text).not.toMatch(/MUST include an `Activation bindings:|Label operations accepted/); + }); -describe('gh-aw: both activation paths use the exact "Label operations accepted" heading', () => { - it.each(BOTH_PATHS)('%s: renders the heading verbatim', (_path, skillText) => { - expect(skillText).toMatch(/^#{1,6} Label operations accepted\s*$/m); + it('makes the granular route reuse the exact fast-path procedure and recovery rules', () => { + expect(activateSkill.replace(/\s+/g, ' ')).toContain( + 'same `activate_plan` procedure and no-create recovery rules as `squad-plan-accept`', + ); + expect(activateSkill).toContain('one `activate_plan` request only'); }); - it('never reintroduces the old, non-identical heading text in either path', () => { - expect(workflow).not.toContain('Label reporting — accepted operations only'); - expect(workflow).not.toContain('Label reporting'); + it('requires exactly one comment-ID request and forbids model-owned metadata and native writes', () => { + const prose = acceptSkill.replace(/\s+/g, ' '); + expect(prose).toMatch(/Call `activate_plan` exactly once with `plan_comment_id`/); + expect(prose).toContain("comment's numeric ID as a string. Emit no other safe-output operation."); + expect(prose).toContain('Do not call `create_issue`, `add_labels`, `add_comment` or `upsert_lifecycle_state`'); + expect(prose).toContain('Do not author Activation bindings, acceptance metadata, issue-number maps'); + expect(prose).toContain('synchronous createIssue API'); + expect(prose).toContain('Never deduplicate by title, guess issue numbers'); }); - it('the two headings are byte-identical, not merely similar', () => { - const activateHeading = activateSkill.match(/^#{1,6} Label operations accepted\s*$/m)?.[0].replace(/^#+\s*/, '').trim(); - const acceptHeading = acceptSkill.match(/^#{1,6} Label operations accepted\s*$/m)?.[0].replace(/^#+\s*/, '').trim(); - expect(activateHeading).toBe('Label operations accepted'); - expect(acceptHeading).toBe('Label operations accepted'); - expect(activateHeading).toBe(acceptHeading); + it('retains the shared ontology requirement for all four acceptance types', () => { + const prose = ontology.replace(/\s+/g, ' '); + expect(prose).toMatch( + /mandatory for\s*`phases-activated`, `activated`, `phases-accepted`, and `plan-accepted` artifacts/, + ); + expect(prose).toMatch(/neither path may omit it or ship an\s*empty array/); }); }); -// --------------------------------------------------------------------------- -// 2. Fast-path output template requires non-empty Activation bindings. -// --------------------------------------------------------------------------- - -/** - * Shared assertion used both as a direct positive check against the real committed skill - * text, and — mutated — as a mutation-test kill switch. A mutation that survives this - * function despite deleting or hollowing out the contract would mean the test suite cannot - * actually detect the regression it claims to guard. - */ -function assertBindingsContractPresent(skillText: string): void { - const prose = skillText.replace(/\s+/g, ' '); - expect(prose).toMatch( - /MUST include an `Activation bindings:` fenced JSON block containing a non-empty array/, - ); - expect(prose).toMatch(/never emit an empty array/); - expect(prose).toMatch(/#{1,6} Label operations accepted/); +const trustedTeam = '## Members\n| Name | Role |\n|---|---|\n| Builder | Engineer |'; +const trustedRegistry = { + schema: 'squad-agent-provenance/v1', schema_version: 1, revision: 3, + agents: { + builder: { + status: 'active', display_name: 'Builder', persistent_name: 'Builder', role: 'Engineer', + universe: 'descriptive', created_at: '2026-01-01T00:00:00.000Z', updated_at: '2026-01-01T00:00:00.000Z', + }, + }, +}; + +function producerFixture(kind = 'implementation', phase: number | null = null) { + const capture = { + repository: 'example/project', origin_issue: 7, registry: trustedRegistry, + run_id: 100, run_attempt: 1, phase, + plan: { + kind, tasks: [ + { id: '1', epic: 'epic1', agent: 'Builder', agent_id: 'builder', label: 'squad:builder', dependencies: [] as string[] }, + { id: '2', epic: 'epic1', agent: 'Builder', agent_id: 'builder', label: 'squad:builder', dependencies: ['1'] }, + ], + }, + operations: [{ id: 'epic:epic1' }, { id: 'task:1' }, { id: 'task:2' }], + }; + const journal = { + schema: ACTIVATION_RECEIPT_SCHEMA, capture_sha256: digest(capture), + repository: capture.repository, run_id: capture.run_id, run_attempt: capture.run_attempt, + started: capture.operations.map(operation => operation.id), + parent_fallbacks: [], + receipts: capture.operations.map((operation, index) => ({ + operation_id: operation.id, repository: capture.repository, + number: 40 + index, id: 1040 + index, node_id: `I_example_${40 + index}`, + url: `https://github.com/example/project/issues/${40 + index}`, + })), + }; + return { capture, journal }; } -describe('gh-aw: fast-path (`squad-plan-accept`) requires non-empty Activation bindings (#1958 E4 preflight A)', () => { - it('passes against the real committed fast-path skill body', () => { - expect(() => assertBindingsContractPresent(acceptSkill)).not.toThrow(); - }); - - it('passes against the real committed granular skill body (parity baseline)', () => { - expect(() => assertBindingsContractPresent(activateSkill)).not.toThrow(); +function checkProduced(capture: ReturnType['capture'], bindings: unknown) { + const artifact = capture.plan.kind === 'plan' + ? (capture.phase === null ? 'plan-accepted' : 'phases-accepted') + : (capture.phase === null ? 'activated' : 'phases-activated'); + return validateActivation({ + squad_artifact: artifact, schema_version: '1', origin_issue: capture.origin_issue, + phases: capture.phase === null ? [] : [capture.phase], bindings, + }, parseRoster(trustedTeam), new Map([40, 41, 42].map(number => + [number, new Set(['squad', 'squad:builder'])])), capture.origin_issue, { + repository: capture.repository, registry: parseAgentRegistry(trustedRegistry), }); +} - it('states the identical versioned provenance/quoting/omission contract as the granular path', () => { - const prose = acceptSkill.replace(/\s+/g, ' '); - expect(prose).toMatch( - /identical versioned provenance, binding shape, quoting, and omission semantics as `squad-plan-activate` Step 4/, - ); - expect(activateSkill).toContain('squad-work-agent-binding/v1'); - expect(activateSkill).toContain('squad-agent-provenance/v1'); - expect(activateSkill).toContain('legacy-plan-missing-id'); - expect(prose).toMatch(/never bare numbers/); - expect(prose).toMatch(/a surviving `#aw_…` reference means `create-issue` never\s*landed/); +describe.each([ + ['plan', null, 'plan-accepted'], ['plan', 1, 'phases-accepted'], + ['implementation', null, 'activated'], ['implementation', 1, 'phases-activated'], +] as const)('trusted producer: %s phase %s emits %s', (kind, phase, artifact) => { + it('builds complete versioned metadata and real-number bindings for the compatibility checker', () => { + const { capture, journal } = producerFixture(kind, phase); + const bindings = buildActivationBindings(capture, journal); + expect(bindings).toHaveLength(2); + expect(bindings[0]).toEqual({ + binding_schema: 'squad-work-agent-binding/v1', binding_version: 1, producer: 'squad', + repository: 'example/project', origin_issue: 7, artifact, + registry_schema: 'squad-agent-provenance/v1', registry_revision: 3, + task: '1', issue: '#41', epic: 'epic1', epic_issue: '#40', + agent: 'Builder', agent_id: 'builder', epic_agents: ['builder'], epic_agent_ids: ['builder'], + label: 'squad:builder', epic_label: 'squad:builder', depends_on: [], + }); + expect(bindings[1]).toMatchObject({ task: '2', issue: '#42', depends_on: ['#41'] }); + expect(checkProduced(capture, bindings)).toMatchObject({ checked: 2 }); }); +}); - it('the checker treats a missing/empty fast-path bindings block as a failure, stated explicitly', () => { - const prose = acceptSkill.replace(/\s+/g, ' '); - expect(prose).toMatch( - /missing, empty, malformed, or unresolved bindings block on a `plan-accepted` or\s*`phases-accepted` artifact as a failure exactly as it does for `activated`\s*and `phases-activated`/, - ); +describe('trusted producer: missing, empty and unresolved binding evidence fails closed', () => { + it.each([0, 1, 2])('does not build bindings from only %i of three create receipts', count => { + const { capture, journal } = producerFixture(); + journal.receipts = journal.receipts.slice(0, count); + expect(() => buildActivationBindings(capture, journal)).toThrow(/recreation is forbidden/); }); - it('both activation paths revalidate stable IDs before mutation', () => { - expect(acceptSkill).toMatch(/Agent ID` MUST exactly match its `AGENT_IDENTITY:` record/); - expect(activateSkill).toMatch(/plan `Agent ID` to exactly match the\s+same `AGENT_IDENTITY:` record/); - expect(activateSkill).toContain('never reconstruct one from its name or label'); + it.each(['aw_task1', '#aw_task1', undefined])('rejects unresolved or missing actual issue number %j', number => { + const { capture, journal } = producerFixture(); + Object.assign(journal.receipts[1], { number }); + expect(() => buildActivationBindings(capture, journal)).toThrow(/actual issue identities/); }); - it('both artifact-data lines for the fast path carry the Activation bindings JSON array', () => { - expect(acceptSkill).toContain( - 'Phase-specific: `data: {"squad_artifact":"phases-accepted","schema_version":"1","origin_issue":{issue_number},"phases":[{accumulated}]}` → Phase accepted table + remaining phases table + the `Activation bindings:` JSON array.', - ); - expect(acceptSkill).toContain( - 'Full (no phases): `data: {"squad_artifact":"plan-accepted","schema_version":"1","origin_issue":{issue_number},"phases":[]}` → All issues table + the `Activation bindings:` JSON array.', - ); + it('cannot publish an empty binding array even with an empty self-consistent journal', () => { + const { capture, journal } = producerFixture(); + capture.plan.tasks = []; + capture.operations = []; + journal.started = []; + journal.receipts = []; + journal.capture_sha256 = digest(capture); + expect(() => checkProduced(capture, buildActivationBindings(capture, journal))) + .toThrow(/bindings are missing or empty/); }); - // --- Mutation tests: prove the assertion above actually fails when the committed source - // --- is hollowed out, rather than only ever passing by construction. - it.each(BOTH_PATHS)( - '%s: mutation kill — deleting the Activation bindings requirement sentence fails the contract check', - (_path, skillText) => { - const mutated = skillText.replace( - /(?:\*\*Every phase and full acceptance artifact body|Every phase and full activation artifact body) MUST include an `Activation[\s\S]*?non-empty array[^.]*\./, - 'The summary should mention the created issues.', - ); - expect(mutated).not.toBe(skillText); - expect(() => assertBindingsContractPresent(mutated)).toThrow(); - }, - ); - - it.each(BOTH_PATHS)( - '%s: mutation kill — weakening "never emit an empty array" to permit an empty array fails the contract check', - (_path, skillText) => { - const mutated = skillText.replace(/never\s+emit an empty array/, 'an empty array is acceptable'); - expect(mutated).not.toBe(skillText); - expect(() => assertBindingsContractPresent(mutated)).toThrow(); - }, - ); - - it.each(BOTH_PATHS)( - '%s: mutation kill — renaming the Label operations accepted heading fails the contract check', - (_path, skillText) => { - const mutated = skillText.replace(/#{1,6} Label operations accepted/, '###### Label Notes'); - expect(mutated).not.toBe(skillText); - expect(() => assertBindingsContractPresent(mutated)).toThrow(); - }, - ); - - it('documents the fast-path mandatory-bindings contract in the shared ontology, not only the skill body', () => { - const prose = ontology.replace(/\s+/g, ' '); - expect(prose).toMatch( - /mandatory for\s*`phases-activated`, `activated`, `phases-accepted`, and `plan-accepted` artifacts/, - ); - expect(prose).toMatch(/neither path may omit it or ship an\s*empty array/); + it('cannot resolve dependencies absent from the actual receipt map', () => { + const { capture, journal } = producerFixture(); + capture.plan.tasks[1].dependencies = ['missing']; + journal.capture_sha256 = digest(capture); + expect(() => buildActivationBindings(capture, journal)).toThrow(); }); -}); -// --------------------------------------------------------------------------- -// Known-trap wording regression: "the label set it should have received". -// --------------------------------------------------------------------------- - -describe('gh-aw: the known "should have received" trap phrase is reworded (#1958 E4 preflight A)', () => { - it('never uses the exact trap phrase describing a label a report_incomplete item was targeting', () => { - // Narrowly scoped to the exact known-trap phrase, not a blanket ban on "received" — the - // word legitimately appears elsewhere in this file for unrelated meanings (e.g. "the - // right issue received the right value", "a rejection you never received"), and a - // blanket scan would be exactly the false-positive-prone pattern this change avoids. - expect(workflow).not.toMatch(/label set it should have received/); + it('cannot resolve a missing epic receipt even when all declared operations finished', () => { + const { capture, journal } = producerFixture(); + capture.operations.shift(); + journal.started.shift(); + journal.receipts.shift(); + journal.capture_sha256 = digest(capture); + expect(() => buildActivationBindings(capture, journal)).toThrow(); }); - it('replaces it with wording that names the same fact without a forbidden certainty word', () => { - expect(workflow).toContain('the label set that missing operation targeted'); + it.each([ + { binding_schema: undefined }, { producer: 'model' }, { registry_revision: 4 }, + { repository: 'example/other' }, { origin_issue: 8 }, { issue: 'aw_task1' }, + { epic_issue: '#aw_epic1' }, + ])('checker rejects corrupted producer metadata or unresolved mappings: %j', mutation => { + const { capture, journal } = producerFixture(); + const bindings = buildActivationBindings(capture, journal); + Object.assign(bindings[0], mutation); + expect(() => checkProduced(capture, bindings)).toThrow(); + }); + + it('never labels the existing origin as an epic for a flat plan', () => { + const { capture, journal } = producerFixture('plan'); + capture.plan.tasks.forEach(task => { task.epic = 'origin'; }); + capture.operations.shift(); + journal.started.shift(); + journal.receipts.shift(); + journal.capture_sha256 = digest(capture); + const bindings = buildActivationBindings(capture, journal); + for (const binding of bindings) { + expect(binding).toMatchObject({ epic: 'origin', epic_issue: '#7', epic_label: null, epic_omission_reason: 'origin' }); + } + expect(checkProduced(capture, bindings)).toMatchObject({ checked: 2 }); }); }); @@ -637,18 +635,20 @@ Label squad:kint: add_labels accepted for #42. // --------------------------------------------------------------------------- const COLLECTOR_ARTIFACT_REGEX_SOURCE = - '"squad_artifact"\\s*:\\s*"?(?:phases-activated|activated|phases-accepted|plan-accepted)'; + '"squad_artifact"\\s*:\\s*"?(?:phases-activated|activated|phases-accepted|plan-accepted|activation-reconciled)'; describe('gh-aw: squad-agent-binding-check.yml collector passes fast-path artifacts to the checker (E4 preflight A)', () => { const ymlContent = readText(COLLECTOR_YML); - it('embeds the exact artifact-matching regex used to filter collected comments', () => { - expect(ymlContent).toContain(COLLECTOR_ARTIFACT_REGEX_SOURCE); + it('delegates discovery to the canonical collector with the exact artifact-matching regex', () => { + expect(ymlContent).toContain('await collectHistoricalActivationRecords({ github, context })'); + expect(readText(join(WORKFLOWS_DIR, 'shared', 'check-agent-binding.mjs'))) + .toContain(COLLECTOR_ARTIFACT_REGEX_SOURCE); }); - it('the embedded regex matches every activation artifact type (fast-path and granular)', () => { + it('the shared discovery regex matches every activation artifact type (fast-path and granular)', () => { const collectorRegex = new RegExp(COLLECTOR_ARTIFACT_REGEX_SOURCE, 'i'); - for (const artifactType of ['activated', 'phases-activated', 'plan-accepted', 'phases-accepted']) { + for (const artifactType of ['activated', 'phases-activated', 'plan-accepted', 'phases-accepted', 'activation-reconciled']) { expect( collectorRegex.test(`Structured data:\n\`\`\`json\n{"squad_artifact":"${artifactType}","schema_version":"1"}\n\`\`\``), `expected collector regex to match ${artifactType}`, @@ -656,7 +656,7 @@ describe('gh-aw: squad-agent-binding-check.yml collector passes fast-path artifa } }); - it('the embedded regex does not indiscriminately match unrelated non-activation artifacts', () => { + it('the shared discovery regex does not indiscriminately match unrelated non-activation artifacts', () => { const collectorRegex = new RegExp(COLLECTOR_ARTIFACT_REGEX_SOURCE, 'i'); for (const artifactType of ['research', 'triage', 'scope-accepted', 'impl-accepted', 'impl-phases-accepted', 'validation']) { expect( diff --git a/test/gh-aw-activation-capacity.test.ts b/test/gh-aw-activation-capacity.test.ts index dbd8c4c17..148ba9362 100644 --- a/test/gh-aw-activation-capacity.test.ts +++ b/test/gh-aw-activation-capacity.test.ts @@ -1,135 +1,36 @@ /** - * Activation label-operation capacity and silent-truncation safeguards (#1961, parent #1957). - * - * ## The defect - * - * `workflows/squad.md` promises a `squad` (and where certified, `squad:{agent}`) label on - * every issue an activation creates. Those labels are applied by the `add-labels` safe - * output, which is governed by a `max`. Two problems let a large activation lose label - * operations *while the run still reported success*: - * - * 1. **`max` was ambiguous in gh-aw's own surface area.** The older compiler injected the tool - * constraint as "Maximum {N} label(s) can be added", which reads as a budget of label - * *names*. The runtime counts operations instead. At the previous `max: 80`, an agent - * that believed the label reading would conclude a 50-issue activation (up to 100 label - * names) had overrun its budget, and could stop labeling early or batch issues together. - * This is agent self-truncation, and it was reachable at 80. - * 2. **Neither cap-enforcement layer fails the run by itself.** Even when a cap is - * genuinely hit, truncation needs an explicit incomplete report to surface. See below. - * - * Note what is *not* claimed: at `max: 80`, 50 `add_labels` calls did **not** overflow the - * operation cap. Runtime truncation was not reachable at the documented maximum. The cap - * stays at 110 even though v0.91.5 now explicitly describes calls, retaining a bounded margin — - * not to fix a proven 80-item overflow. - * - * ## Runtime semantics this suite is written against (gh-aw v0.91.5, the CI pin) - * - * **Cap enforcement is dual (Safe Outputs Specification MCE4) and neither half is fatal.** - * Invocation time — `safe_outputs_handlers.cjs`, `enforcePerTypeMax` via - * `appendSafeOutputCounted`, applied whenever `max` is explicitly configured (it is here): - * - * ```js - * if (current >= maxAllowed) { - * throw { code: -32602, message: `E002: ${type} limit reached — ${current} of ${maxAllowed} already used this run`, ... }; - * } - * ``` - * - * That is a JSON-RPC error the agent **does** observe. Collection time — - * `collect_ndjson_output.cjs` — is the second half: a surplus item is dropped with - * `continue` and reported via `core.warning`. Neither path calls `core.setFailed`, so the - * run can conclude successfully with label operations missing unless incompletion is reported. - * - * `max` therefore caps **operations of that type**, not label names inside one call. One - * `add_labels` call carrying two labels costs one unit of budget, not two. - * - * **`report_incomplete` fails the conclusion step.** In v0.91.5, - * `report_incomplete_handler.cjs` logs a warning, then `handle_agent_failure.cjs` calls - * `core.setFailed` even when the agent succeeded. #2185 disables that conclusion handler's - * independent tracking-issue writes, not its failure status or logged evidence. The guarded - * activation summary must also report the shortfall on the originating issue. The real pinned - * handler and zero-write boundary are exercised by gh-aw-command-authorization.test.ts. - * - * ## The capacity calculation this suite locks in - * - * Largest supported activation = **50 issues** — `enterprise`'s `max_issues: 50`, the - * highest documented profile limit in `workflows/shared/squad-planning-policy.md`, and the - * same threshold `squad-plan-activate`'s Output Budget Awareness uses to force phased - * activation. At that size, worst case: - * - * | Safe output | Worst case | Configured | - * |--------------------------------|-----------:|-----------:| - * | `create-issue` items | 50 | 75 | - * | `add_labels` items (calls) | 50 | 110 | - * | labels within one call | 2 | n/a | - * | label names across the run | 100 | 110 | - * - * `add-labels: max` is sized to satisfy the worst case under **both** readings (50 calls - * and 100 label names) so that no interpretation of the cap can justify dropping a label - * operation. - * - * ## Out of scope (sibling issues — do not broaden these assertions here) - * - * - #1962 / PR #1965 temporary-ID linkage between `create_issue` and `add_labels`. This - * branch is stacked on PR #1965, so that mechanism is present — but this suite still - * asserts nothing about *how* an `add_labels` call targets its item; PR #1965's own - * suite (`gh-aw-activation-temporary-ids.test.ts`) owns that contract. The one place - * the two meet is Step 2e's incomplete report, which must name the `temporary_id` - * minted under #1965's Temporary-ID Contract for items created this run, and a real - * number only where #1965 says one is verified (dedup-by-title or idempotent rerun). - * Demanding a number that does not yet exist would reintroduce the assumption #1962 - * removes, so that single assertion is in scope here. - * - #1959 `/squad activate` fast-path parity. - * - #1963 label-result reporting beyond the explicit overflow/incomplete signal. - * - #1960 broad safe-output contract coverage; #1958 E4 evidence. + * Trusted activation capacity is enforced before creation and reconciled against + * actual receipts. Native safe-output caps remain bounded for other commands. */ - import { afterAll, describe, expect, it } from 'vitest'; import { cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'; import { execFileSync, spawnSync } from 'node:child_process'; import { join } from 'node:path'; import { extractSafeOutputsConfigJson } from './helpers/gh-aw-lock.js'; +import { + ACTIVATION_RECEIPT_SCHEMA, digest, parseActivationPlan, validateReceiptSet, +} from '../workflows/shared/squad-activation.mjs'; const WORKFLOWS_DIR = join(process.cwd(), 'workflows'); -const SQUAD_WORKFLOW = join(WORKFLOWS_DIR, 'squad.md'); -const PLANNING_POLICY = join(WORKFLOWS_DIR, 'shared', 'squad-planning-policy.md'); const TEST_WORKSPACES_DIR = join(process.cwd(), '.test-workspaces-activation-capacity'); afterAll(() => { rmSync(TEST_WORKSPACES_DIR, { recursive: true, force: true }); }); -/** Read a text file with line endings normalized to LF (Windows checkouts materialize CRLF). */ function readText(filePath: string): string { return readFileSync(filePath, 'utf8').replace(/\r\n/g, '\n'); } -const workflow = readText(SQUAD_WORKFLOW); -const policy = readText(PLANNING_POLICY); - -const ACTIVATE_START = workflow.indexOf('## skill: `squad-plan-activate`'); -const ACTIVATE_END = workflow.indexOf('## end skill: `squad-plan-activate`'); -expect(ACTIVATE_START, '"## skill: `squad-plan-activate`" is missing').toBeGreaterThan(-1); -expect(ACTIVATE_END, '"## end skill: `squad-plan-activate`" is missing').toBeGreaterThan(ACTIVATE_START); -const activateSkill = workflow.slice(ACTIVATE_START, ACTIVATE_END); -const activateProse = activateSkill.replace(/\s+/g, ' '); +const workflow = readText(join(WORKFLOWS_DIR, 'squad.md')); +const policy = readText(join(WORKFLOWS_DIR, 'shared', 'squad-planning-policy.md')); -// --------------------------------------------------------------------------- -// Derived capacity model — every input is read from a source of truth, so drift -// in the policy file or the frontmatter fails here instead of silently changing -// the safe number. -// --------------------------------------------------------------------------- - -/** - * The largest documented activation, derived from the planning policy rather than - * hardcoded: the maximum `max_issues` across all documented profiles. - */ function documentedMaxIssues(): number { const values = [...policy.matchAll(/max_issues[`|:\s]+(\d+)/g)].map(m => Number(m[1])); expect(values.length, 'squad-planning-policy.md must document max_issues values').toBeGreaterThan(0); return Math.max(...values); } -/** Read `max` for a safe output straight out of the workflow frontmatter block. */ function frontmatterMax(safeOutput: string): number { const frontmatter = workflow.match(/^---\n([\s\S]*?)\n---/)?.[1] ?? ''; expect(frontmatter, 'workflows/squad.md must have frontmatter').not.toBe(''); @@ -140,329 +41,218 @@ function frontmatterMax(safeOutput: string): number { return Number(max); } -/** Worst-case labels applied to a single created issue: `squad` + one `squad:{agent}`. */ const MAX_LABELS_PER_ISSUE = 2; +const team = '## Members\n| Name | Role |\n|---|---|\n| Builder | Engineer |'; +const registry = { + schema: 'squad-agent-provenance/v1', schema_version: 1, revision: 1, + agents: { + builder: { + status: 'active', display_name: 'Builder', persistent_name: 'Builder', role: 'Engineer', + universe: 'descriptive', created_at: '2026-01-01T00:00:00.000Z', updated_at: '2026-01-01T00:00:00.000Z', + }, + }, +}; + +function taskTable(count: number, kind = 'plan', start = 1, epic = 'epic1'): string { + const implementation = kind === 'implementation'; + return [ + `| # | Title | ${implementation ? 'Agent' : 'Owner'} | Agent ID | Depends On |${implementation ? ' Epic |' : ''}`, + `|---|---|---|---|---|${implementation ? '---|' : ''}`, + ...Array.from({ length: count }, (_, i) => + `| ${start + i} | Task ${start + i} | Builder | builder | None |${implementation ? ` ${epic} |` : ''}`), + ].join('\n'); +} -describe('gh-aw: activation capacity is calculated, recorded, and bounded (#1961)', () => { - it('derives the documented maximum activation size from the planning policy', () => { - // enterprise is the highest documented profile; if a profile ever raises - // max_issues above it, this recomputes and the capacity assertions below - // re-evaluate against the new number rather than silently going stale. +describe('gh-aw: executable activation plan capacity', () => { + it('derives the documented maximum from all planning profiles', () => { expect(documentedMaxIssues()).toBe(50); }); - it('the activation skill enforces the same threshold it is sized for', () => { - // The capacity math is only meaningful if the workflow actually refuses to - // attempt a larger single run. > 50 must route to phased activation. - expect( - /total > 50/.test(activateSkill) && /phased activation/i.test(activateSkill), - 'Output Budget Awareness must force phased activation above the documented maximum.', - ).toBe(true); - }); - - it('records the capacity calculation as workflow rationale, not folklore', () => { - expect( - /Activation capacity budget/i.test(activateSkill), - 'The activation skill must record the capacity calculation so the configured caps ' + - 'are explainable and cannot be lowered by accident.', - ).toBe(true); - // The rationale must name the worst-case operand counts, not just the caps. - expect(activateProse).toMatch(/one per epic\/task/i); - expect(activateProse).toMatch(/one per created issue/i); + it('accepts exactly 50 flat tasks without creating an extra origin epic', () => { + const plan = parseActivationPlan(taskTable(50), 'plan', registry, team); + expect(plan.tasks).toHaveLength(documentedMaxIssues()); + expect(plan.epics).toEqual([]); + expect(plan.tasks.every(task => task.epic === 'origin')).toBe(true); }); - it('distinguishes limits on output items/calls from labels per call', () => { - // This is the specific ambiguity #1961 exists to close. The prose must state - // that `max` counts items/tool calls, and that labels inside one call are not - // individually charged against it. - expect( - /limits safe-output items \(tool calls\), not label names inside a call/i.test(activateProse), - 'The workflow must state explicitly that `max` counts safe-output items (tool ' + - 'calls) rather than label names within a call.', - ).toBe(true); - expect( - /consumes \*\*one\*\* unit\s*of budget, not two/i.test(activateProse), - 'The workflow must show that a two-label call costs one unit of budget, so the ' + - 'agent cannot conclude it must ration labels.', - ).toBe(true); + it('rejects 51 flat tasks before any write', () => { + expect(() => parseActivationPlan(taskTable(51), 'plan', registry, team)) + .toThrow(/exceeds 50 issues/); }); - it('describes cap enforcement as dual and non-fatal, without overstating either half', () => { - // Pinned-runtime semantics, both halves: - // invocation time — safe_outputs_handlers.cjs enforcePerTypeMax/appendSafeOutputCounted - // throw JSON-RPC -32602 "E002: {type} limit reached", which the - // agent DOES observe; - // collection time — collect_ndjson_output.cjs drops the surplus item via core.warning. - // Neither calls core.setFailed, so the run still concludes successfully. - expect( - /enforced twice/i.test(activateProse), - 'The workflow must describe both halves of cap enforcement rather than only the ' + - 'collector drop.', - ).toBe(true); - expect( - /E002/.test(activateSkill), - 'The invocation-time rejection must be named by its actual error, since the agent ' + - 'can observe it.', - ).toBe(true); - expect( - /neither layer fails the run/i.test(activateProse), - 'The workflow must state that neither enforcement layer fails the run — that is ' + - 'why self-validation is mandatory.', - ).toBe(true); - // Guard against the inverse overclaim: the agent is NOT blind to cap rejections. - expect( - /never appears as an error to the agent/i.test(activateProse), - 'The workflow must not claim the agent never sees a cap error — invocation-time ' + - 'enforcement surfaces E002 to the agent.', - ).toBe(false); - expect(activateProse).toMatch(/finish green|still succeeds/i); + it('counts a phase parent toward the exact 50-issue boundary', () => { + const plan = parseActivationPlan(`### Phase 1\n${taskTable(49)}`, 'plan', registry, team); + expect(plan.tasks).toHaveLength(49); + expect(plan.epics).toEqual(['phase-1']); + expect(() => parseActivationPlan(`### Phase 1\n${taskTable(50)}`, 'plan', registry, team)) + .toThrow(/exceeds 50 issues/); }); -}); - -describe('gh-aw: configured caps cover the worst case under both readings of max (#1961)', () => { - const maxIssues = documentedMaxIssues(); - const createIssueMax = frontmatterMax('create-issue'); - const addLabelsMax = frontmatterMax('add-labels'); - it('create-issue covers one item per activated issue with margin', () => { - expect(createIssueMax).toBeGreaterThanOrEqual(maxIssues); - // Bounded, not unbounded: an accidental extra zero should fail review here. - expect(createIssueMax).toBeLessThanOrEqual(maxIssues * 3); + it('counts every distinct phase epic, not only tasks', () => { + const body = `### Phase 1\n${taskTable(24)}\n### Phase 2\n${taskTable(24, 'plan', 25)}`; + const plan = parseActivationPlan(body, 'plan', registry, team); + expect(plan.tasks).toHaveLength(48); + expect(plan.epics).toEqual(['phase-1', 'phase-2']); + expect(() => parseActivationPlan(body + + '\n| 49 | Task 49 | Builder | builder | None |', 'plan', registry, team)) + .toThrow(/exceeds 50 issues/); }); - it('add-labels covers one CALL per activated issue (the runtime reading)', () => { - // Runtime semantics: max caps add_labels operations, not label names. - expect(addLabelsMax).toBeGreaterThanOrEqual(maxIssues); + it('counts each granular epic once even when several tasks share it', () => { + const plan = parseActivationPlan(taskTable(49, 'implementation'), 'implementation', registry, team); + expect(plan.tasks).toHaveLength(49); + expect(plan.epics).toEqual(['epic1']); + expect(() => parseActivationPlan(taskTable(50, 'implementation'), 'implementation', registry, team)) + .toThrow(/exceeds 50 issues/); }); - it('add-labels also covers every LABEL NAME in the run (the prompt-prose reading)', () => { - // Older gh-aw injected "Maximum {max} label(s) can be added". Even if the agent takes - // that literally, a full 50-issue activation applying squad + squad:{agent} to - // every issue (100 names) must still fit, or the agent can rationalize dropping - // label operations. This is the assertion that would have failed at max: 80. - const worstCaseLabelNames = maxIssues * MAX_LABELS_PER_ISSUE; - expect(worstCaseLabelNames).toBe(100); - expect( - addLabelsMax, - `add-labels max (${addLabelsMax}) must cover the worst-case ${worstCaseLabelNames} ` + - 'label names so the older "Maximum N label(s)" constraint cannot be read ' + - 'as a reason to stop labeling early.', - ).toBeGreaterThanOrEqual(worstCaseLabelNames); + it('bounds the parser label contribution to one certified owner plus the base squad label', () => { + const body = taskTable(1) + '\n| 2 | External task | @copilot | - | None |'; + const plan = parseActivationPlan(body, 'plan', registry, team); + expect(plan.tasks.map(task => task.label)).toEqual(['squad:builder', 'squad:copilot']); + for (const task of plan.tasks) { + expect(['squad', ...(task.label ? [task.label] : [])].length).toBeLessThanOrEqual(MAX_LABELS_PER_ISSUE); + } + expect(() => parseActivationPlan(body.replace('| Builder | builder |', '| Builder, Visitor | builder |'), + 'plan', registry, team)).toThrow(/unknown non-roster owner/); + expect(() => parseActivationPlan(`${body}\n| 3 | Unassigned task | Visitor | - | None |`, + 'plan', registry, team)).toThrow(/unknown non-roster owner/); }); - it('keeps the margin explicit and bounded, not open-ended', () => { - const worstCase = Math.max(maxIssues, maxIssues * MAX_LABELS_PER_ISSUE); - expect(addLabelsMax).toBeGreaterThan(worstCase); - expect( - addLabelsMax, - 'The margin above the worst case must stay bounded — a very large cap trades a ' + - 'silent-truncation bug for an unbounded-write one.', - ).toBeLessThanOrEqual(worstCase * 2); + it.each(['1', '', '#aw_task1', 'task 2'])('rejects duplicate or malformed stable task ID %j', id => { + const body = taskTable(1) + `\n| ${id} | Another task | Builder | builder | None |`; + expect(() => parseActivationPlan(body, 'plan', registry, team)) + .toThrow(/stable task IDs are missing, malformed or duplicated/); }); - it('never lets add-labels fall below create-issue (one call per created issue)', () => { - expect(addLabelsMax).toBeGreaterThanOrEqual(createIssueMax); + it('rejects stable-ID collisions across phase tables', () => { + expect(() => parseActivationPlan(`### Phase 1\n${taskTable(1)}\n### Phase 2\n${taskTable(1)}`, + 'plan', registry, team)).toThrow(/duplicated/); }); -}); - -// --------------------------------------------------------------------------- -// Boundary behaviour: exactly at the maximum, and one over it. -// --------------------------------------------------------------------------- -describe('gh-aw: activation size boundaries (#1961)', () => { - const maxIssues = documentedMaxIssues(); - const createIssueMax = frontmatterMax('create-issue'); - const addLabelsMax = frontmatterMax('add-labels'); - - it('AT the maximum supported size, every operation fits without truncation', () => { - const issues = maxIssues; // 50 - expect(issues, 'create-issue items at max size must fit').toBeLessThanOrEqual(createIssueMax); - expect(issues, 'add_labels calls at max size must fit').toBeLessThanOrEqual(addLabelsMax); - expect( - issues * MAX_LABELS_PER_ISSUE, - 'label names at max size must fit even under the prose reading of max', - ).toBeLessThanOrEqual(addLabelsMax); + it('topologically sorts a DAG without requiring table order to match dependency order', () => { + const body = taskTable(3).replace('| builder | None |', '| builder | 3 |'); + const plan = parseActivationPlan(body, 'plan', registry, team); + expect(plan.tasks.map(task => task.id)).toEqual(['3', '1', '2']); + expect(plan.tasks.find(task => task.id === '1')?.dependencies).toEqual(['3']); }); - it('ONE OVER the maximum supported size is routed to phased activation, not attempted whole', () => { - const issues = maxIssues + 1; // 51 - expect(issues).toBeGreaterThan(maxIssues); - // The workflow must not silently attempt an oversized run. The guard is a - // documented threshold on the plan total, evaluated before any create-issue call. - expect( - /Count expected issues before starting/i.test(activateProse), - 'The over-limit guard must run before issue creation begins, not after.', - ).toBe(true); - expect( - /If total > 50: recommend phased activation/i.test(activateProse), - 'One over the documented maximum must recommend phased activation and proceed ' + - 'with the current phase only.', - ).toBe(true); + it.each([ + ['1', /cyclic dependency/], ['99', /references missing task 99/], + ['2, 2', /unique comma-separated/], ['#aw_task2', /unique comma-separated/], + ])('rejects invalid dependency %s', (dependency, error) => { + expect(() => parseActivationPlan(taskTable(2).replace('| builder | None |', `| builder | ${dependency} |`), + 'plan', registry, team)).toThrow(error); }); - it('ONE OVER a safe-output cap must surface as an explicit incomplete result', () => { - // The cap itself cannot report this (dropped item + warning only), so the - // workflow must name cap exhaustion as a reportable cause with affected items. - expect( - /Cap exhaustion is a reportable/i.test(activateProse), - 'Reaching a safe-output cap must be an explicitly reportable cause of an ' + - 'incomplete activation.', - ).toBe(true); - expect( - /list the work items that did not fit/i.test(activateProse), - 'An overflow report must identify the affected work items, not just state that ' + - 'overflow happened.', - ).toBe(true); + it('rejects a multi-task cycle', () => { + const body = taskTable(2).replace('| builder | None |', '| builder | 2 |') + .replace('| builder | None |', '| builder | 1 |'); + expect(() => parseActivationPlan(body, 'plan', registry, team)).toThrow(/cyclic dependency/); }); }); -// --------------------------------------------------------------------------- -// Self-validation: expected activated items vs targeted label operations. -// --------------------------------------------------------------------------- - -describe('gh-aw: self-validation reconciles activated items with label operations (#1961)', () => { - it('declares a label-operation reconciliation step', () => { - expect( - /\*\*2e\. Label-Operation Reconciliation/i.test(activateSkill), - 'Step 2e must exist: counting created issues alone cannot detect a lost label ' + - 'operation.', - ).toBe(true); - }); +describe('gh-aw: receipt sets account for every actual create operation', () => { + const capture = { + repository: 'example/project', run_id: 100, run_attempt: 1, + operations: [{ id: 'epic:phase-1' }, { id: 'task:1' }, { id: 'task:2' }], + }; + function journal() { + return { + schema: ACTIVATION_RECEIPT_SCHEMA, capture_sha256: digest(capture), + repository: capture.repository, run_id: capture.run_id, run_attempt: capture.run_attempt, + started: capture.operations.map(operation => operation.id), + parent_fallbacks: [], + receipts: capture.operations.map((operation, i) => ({ + operation_id: operation.id, repository: capture.repository, + number: 42 + i, id: 1042 + i, node_id: `I_example_${42 + i}`, + url: `https://github.com/example/project/issues/${42 + i}`, + })), + }; + } - it('compares expected activated items against accepted label operations', () => { - // Anchored to the exact definitions rather than `.*`-joined fragments: activateProse is - // the whole flattened skill, so a loose pattern can satisfy itself with wording from a - // different section (verified -- a distant "was accepted" masked a reverted definition). - expect(activateProse).toMatch(/`activated` \(issues created or recognized this run\)/i); - expect(activateProse).toMatch(/`labeled` \(issues whose `add_labels` call was accepted\)/i); - expect( - /labeled < activated/.test(activateSkill), - 'The reconciliation must compare the two counts explicitly.', - ).toBe(true); + it('returns exact operation-to-issue mappings only for a complete journal', () => { + expect([...validateReceiptSet(capture, journal())]).toEqual( + journal().receipts.map(receipt => [receipt.operation_id, receipt]), + ); }); - it('states the counts track accepted operations, not labels present on GitHub', () => { - // #1963 settled the vocabulary: an accepted safe output is queued, not applied. Step 2e - // counts `add_labels` calls, so its reason string and count definition must not claim - // the labels reached the issue -- nothing in this run reads labels back. Guarding the - // negative too, because the over-claim reappears as an innocuous-looking verb. - expect(activateProse).toMatch(/never state or imply that a counted label was applied/i); - expect(activateProse).not.toMatch(/activated issues received their labels/i); - expect(activateProse).not.toMatch(/proof that every label landed/i); + it.each([[], ['epic:phase-1'], ['epic:phase-1', 'task:1', 'task:1'], + ['task:1', 'epic:phase-1', 'task:2'], ['epic:phase-1', 'task:1', 'task:2', 'task:3']] + .map(started => ({ started })))( + 'rejects absent, partial, duplicated, reordered or surplus operations: $started', ({ started }) => { + expect(() => validateReceiptSet(capture, { ...journal(), started })) + .toThrow(/operations are incomplete, duplicated or out of order/); + }, + ); + + it.each([0, 1, 2])('rejects %i receipts after all three writes were started without authorizing recreation', count => { + const value = journal(); + value.receipts = value.receipts.slice(0, count); + expect(() => validateReceiptSet(capture, value)).toThrow(/recreation is forbidden/); }); - it('counts a missing, rejected, or errored label call as unlabeled', () => { - // Absence-of-success, not presence-of-error, must be the failing condition. The agent - // does see an E002 rejection at invocation time, but a call can also simply never be - // made, and a surplus item can be dropped downstream with only a warning. Reconciling - // on counts covers all three; reconciling on observed errors does not. - expect( - /never made, was rejected, or returned an error counts as \*\*unlabeled\*\*/i.test(activateProse), - 'A label call that was never made must count as unlabeled, otherwise a lost ' + - 'operation is invisible to the reconciliation.', - ).toBe(true); + it('rejects more receipts than operations', () => { + const value = journal(); + value.receipts.push(value.receipts[0]); + expect(() => validateReceiptSet(capture, value)).toThrow(/actual create receipts are missing/); }); - it('escalates a shortfall to report_incomplete identifying every affected work item', () => { - const reconciliation = activateSkill.slice(activateSkill.indexOf('**2e. Label-Operation Reconciliation')); - const reconciliationProse = reconciliation.replace(/\s+/g, ' '); - expect(reconciliation).toContain('report_incomplete'); - // Wording was tightened by the E4 preflight package A fast-path artifact-integrity fix: - // "the label set it should have received" used the forbidden certainty word "received" - // to describe a label operation that never happened, even though it was worded as - // something the item merely lacked rather than a claim that it landed. The reworded - // phrase states the identical fact — which label set the missing operation targeted — - // without a word the runtime label-operation reporting contract now bans outright. - expect( - /the identifier you used to target its `add_labels` call, its title, and the label set that missing operation targeted/i.test( - reconciliationProse, - ), - 'The incomplete report must identify affected work items specifically enough to act on.', - ).toBe(true); + it.each([ + { schema: 'unknown' }, { capture_sha256: 'claimed' }, { repository: 'example/other' }, + { run_id: 101 }, { run_attempt: 2 }, + ])('rejects journal provenance mismatch: %j', mutation => { + expect(() => validateReceiptSet(capture, { ...journal(), ...mutation })) + .toThrow(/does not match the frozen capture/); }); - it('does not demand a real issue number for items created during the run', () => { - // gh-aw defers issue creation to the safe-output job, so no real number exists during - // the agent turn. Requiring one here would reintroduce exactly the invalid-number - // assumption #1962 (PR #1965) removes. Now that this branch is stacked on #1965, the - // identifier is named concretely as that PR's `temporary_id` rather than abstractly. - const reconciliation = activateSkill.slice(activateSkill.indexOf('**2e. Label-Operation Reconciliation')); - const reconciliationProse = reconciliation.replace(/\s+/g, ' '); - expect( - /`temporary_id` you minted under the Temporary-ID Contract/i.test(reconciliationProse), - "For an item created this run the report must name the temporary_id minted under " + - "#1965's Temporary-ID Contract, because no issue number exists yet.", - ).toBe(true); - expect( - /not a GitHub issue number/i.test(reconciliationProse), - 'The report must say explicitly that the identifier is not an issue number.', - ).toBe(true); - expect( - /matched by dedup-by-title, or an issue recognized by Step 1's idempotent-rerun path/i.test(reconciliationProse), - 'A real number may only be quoted where independently verified — a dedup-by-title ' + - 'match or an idempotent-rerun recognition, matching #1965 reused-issue rules.', - ).toBe(true); - expect( - /Never predict, infer, or invent a number/i.test(reconciliationProse), - 'The report must forbid inventing an issue number to fill the identifier field.', - ).toBe(true); + it.each([{ started: undefined }, { receipts: undefined }, { receipts: {} }])( + 'rejects malformed operation evidence: %j', mutation => { + expect(() => validateReceiptSet(capture, { ...journal(), ...mutation })).toThrow(/journal is malformed/); + }, + ); + + it.each([ + { operation_id: 'task:99' }, { repository: 'example/other' }, + ])('rejects a receipt assigned to the wrong operation or repository: %j', mutation => { + const value = journal(); + Object.assign(value.receipts[1], mutation); + expect(() => validateReceiptSet(capture, value)).toThrow(/operation identity or repository mismatch/); }); - it('describes pinned report_incomplete failure status without tracking-issue writes', () => { - // v0.91.5 fails conclusion for report_incomplete. #2185 disables its independent - // issue-writing path; the real-handler regression lives in command-authorization. - expect( - /`report_incomplete` records the reason in Actions logs and fails the conclusion step/i.test(activateProse), - 'The workflow must describe the failure status and logged evidence from the pinned runtime.', - ).toBe(true); - expect( - /automatic tracking-issue reporting is disabled/i.test(activateProse), - 'The workflow must not promise an issue from the disabled conclusion reporting path.', - ).toBe(true); - expect( - /Include the shortfall in the guarded activation summary as well, so the originating issue retains an actionable signal/i.test(activateProse), - 'The authorized originating-issue summary must still identify incomplete activation.', - ).toBe(true); - expect( - /does \*\*not\*\* change the run's conclusion/i.test(activateProse), - 'The obsolete v0.87.10 success-conclusion claim must not return.', - ).toBe(false); + it('rejects colliding actual issue identities despite a complete count', () => { + const value = journal(); + value.receipts[1] = { ...value.receipts[0], operation_id: 'task:1' }; + expect(() => validateReceiptSet(capture, value)).toThrow(/duplicate operation or actual issue identity/); }); +}); - it('forbids emitting a clean activation artifact when labels are missing', () => { - expect( - /false success report/i.test(activateProse), - 'Emitting an activated/phases-activated artifact while labeled < activated must be ' + - 'named as a false success report.', - ).toBe(true); - // Anchored to the whole sentence. The previous form ended in an `|activated.*artifact` - // alternative, and since alternation binds loosest the entire pattern collapsed to that - // branch -- it matched any mention of the artifact and would have passed with the - // `labeled < activated` condition deleted. Verified by deleting it. - expect(activateProse).toMatch( - /An `activated` or `phases-activated` artifact listing every item as activated while `labeled < activated`/i, - ); - }); +describe('gh-aw: configured native caps cover both readings of max (#1961)', () => { + const maxIssues = documentedMaxIssues(); + const createIssueMax = frontmatterMax('create-issue'); + const addLabelsMax = frontmatterMax('add-labels'); - it('still forbids blaming a cap that was never actually reached', () => { - // Guard against over-correction: #1683's rule (do not excuse a partial run by - // pointing at a cap) must survive, now narrowed to *guessed* causes. - expect( - /Never surface the `create-issue` or `add-comment` safe-output caps as a guessed reason/i.test( - activateProse, - ), - 'A cap may be named only when observed (Step 2e), never as a guess.', - ).toBe(true); + it('preserves established caps without broadening the write budget', () => { + expect(createIssueMax).toBe(75); + expect(addLabelsMax).toBe(110); + }); + it('create-issue covers one item per issue with bounded margin', () => { + expect(createIssueMax).toBeGreaterThanOrEqual(maxIssues); + expect(createIssueMax).toBeLessThanOrEqual(maxIssues * 3); + }); + it('add-labels covers one call per issue and every label name', () => { + expect(addLabelsMax).toBeGreaterThanOrEqual(maxIssues); + expect(maxIssues * MAX_LABELS_PER_ISSUE).toBe(100); + expect(addLabelsMax).toBeGreaterThanOrEqual(maxIssues * MAX_LABELS_PER_ISSUE); + }); + it('retains bounded margin and never falls below the create-issue cap', () => { + const worstCase = Math.max(maxIssues, maxIssues * MAX_LABELS_PER_ISSUE); + expect(addLabelsMax).toBeGreaterThan(worstCase); + expect(addLabelsMax).toBeLessThanOrEqual(worstCase * 2); + expect(addLabelsMax).toBeGreaterThanOrEqual(createIssueMax); }); }); -// --------------------------------------------------------------------------- -// Compiled-artifact verification — runtime contract evidence, not prose. -// -// Fails closed (never skips) per this repo's #1833/#1834 convention: an -// unmeasured contract is indistinguishable from a violated one. -// --------------------------------------------------------------------------- - const GH_AW_INSTALL_HINT = '`gh aw` is required to compile the workflow this gate inspects. Install it with ' + '`gh extension install --pin v0.91.5 github/gh-aw` (matches .github/workflows/squad-ci.yml). ' + @@ -471,95 +261,64 @@ const GH_AW_INSTALL_HINT = describe('gh-aw: compiled runtime proves the capacity contract (#1961)', () => { let compiledLock: string | null = null; - function lockText(): string { if (compiledLock !== null) return compiledLock; - - const versionProbe = spawnSync('gh', ['aw', '--version'], { encoding: 'utf8' }); - if (versionProbe.status !== 0) { - throw new Error(`gh aw --version failed. ${GH_AW_INSTALL_HINT}`); - } - + const compiler = process.env.SQUAD_GH_AW_BIN ?? 'gh'; + const prefix = process.env.SQUAD_GH_AW_BIN ? [] : ['aw']; + const versionProbe = spawnSync(compiler, [...prefix, '--version'], { encoding: 'utf8' }); + if (versionProbe.status !== 0) throw new Error(`gh aw --version failed. ${GH_AW_INSTALL_HINT}`); + expect(`${versionProbe.stdout}\n${versionProbe.stderr}`).toContain('v0.91.5'); mkdirSync(TEST_WORKSPACES_DIR, { recursive: true }); const workspace = mkdtempSync(join(TEST_WORKSPACES_DIR, 'capacity-')); execFileSync('git', ['init', '--quiet'], { cwd: workspace }); - // Mirror the real deployment layout: this repo ships gh-aw sources from a - // top-level `workflows/` dir; consumers install them into `.github/workflows/`. cpSync(WORKFLOWS_DIR, join(workspace, '.github', 'workflows'), { recursive: true }); - execFileSync('gh', ['aw', 'compile', '.github/workflows/squad.md', '--strict', '--approve', '--no-check-update'], { - cwd: workspace, - encoding: 'utf8', - stdio: 'pipe', + execFileSync(compiler, [...prefix, 'compile', '.github/workflows/squad.md', '--strict', '--no-check-update'], { + cwd: workspace, encoding: 'utf8', stdio: 'pipe', }); - const lockPath = join(workspace, '.github', 'workflows', 'squad.lock.yml'); if (!existsSync(lockPath)) { - throw new Error( - `gh aw compile produced no squad.lock.yml — the contract is unmeasured. ${GH_AW_INSTALL_HINT}`, - ); + throw new Error(`gh aw compile produced no squad.lock.yml — the contract is unmeasured. ${GH_AW_INSTALL_HINT}`); } compiledLock = readText(lockPath); return compiledLock; } - function safeOutputsConfig(): Record> { const json = extractSafeOutputsConfigJson(lockText()); expect(json, 'compiled lock must carry the safe-outputs config').toBeDefined(); return JSON.parse(json as string); } - it('strict-compiles with the capacity configuration in place', () => { expect(lockText().length).toBeGreaterThan(0); }); - - it('compiles the declared add_labels max into the runtime config (not just frontmatter prose)', () => { + it('compiles the declared add_labels max into runtime config', () => { const config = safeOutputsConfig(); expect(config['add_labels']).toBeDefined(); expect(config['add_labels']['max']).toBe(frontmatterMax('add-labels')); - // The number that actually reaches the runtime must cover the worst case. expect(config['add_labels']['max'] as number).toBeGreaterThanOrEqual( documentedMaxIssues() * MAX_LABELS_PER_ISSUE, ); }); - it('keeps create_issue capacity unchanged at its established value', () => { - const config = safeOutputsConfig(); - expect(config['create_issue']['max']).toBe(75); + expect(safeOutputsConfig()['create_issue']['max']).toBe(75); }); - - it("injects a tool constraint whose number matches the configured cap", () => { + it('injects a tool constraint whose number matches the configured cap', () => { const compiled = lockText().replace(/\\"/g, '"'); - const declared = frontmatterMax('add-labels'); - expect(compiled).toContain(`CONSTRAINTS: Maximum ${declared} add_labels call(s) allowed.`); + expect(compiled).toContain(`CONSTRAINTS: Maximum ${frontmatterMax('add-labels')} add_labels call(s) allowed.`); expect(compiled).toContain('Maximum 10 label(s) per call.'); }); - - it('enables report_incomplete so the overflow signal is actually callable', () => { - // Step 2e is unenforceable if this safe output is not wired in: gh-aw builds the - // agent's tool list by intersecting the config keys with its known tools, so the - // key's presence here is what makes report_incomplete available at runtime. - const config = safeOutputsConfig(); - expect( - Object.prototype.hasOwnProperty.call(config, 'report_incomplete'), - 'report_incomplete must be present in the compiled safe-outputs config — Step 2e ' + - 'depends on it to convert a truncated activation into a failure signal.', - ).toBe(true); + it('enables report_incomplete as a callable failure signal for non-activation commands', () => { + expect(Object.prototype.hasOwnProperty.call(safeOutputsConfig(), 'report_incomplete')).toBe(true); }); - - it('exposes add_labels and create_issue as tools the agent can call', () => { - const compiled = lockText(); - expect(compiled).toMatch(/"tools":\[[^\]]*"add_labels"[^\]]*\]/); - expect(compiled).toMatch(/"tools":\[[^\]]*"create_issue"[^\]]*\]/); + it('exposes add_labels and create_issue for non-activation commands', () => { + expect(lockText()).toMatch(/"tools":\[[^\]]*"add_labels"[^\]]*\]/); + expect(lockText()).toMatch(/"tools":\[[^\]]*"create_issue"[^\]]*\]/); }); - - it('does not broaden the agent job permissions — writes stay in the safe-output job', () => { - // Raising a capacity cap must not become a permissions change. + it('keeps write permissions out of the agent job', () => { const compiled = lockText(); const agentJobAt = compiled.indexOf('\n agent:\n'); const safeOutputsJobAt = compiled.indexOf('\n safe_outputs:\n'); expect(agentJobAt, '"agent:" job missing from compiled lock').toBeGreaterThan(-1); expect(safeOutputsJobAt, '"safe_outputs:" job missing from compiled lock').toBeGreaterThan(agentJobAt); - const permsBlock = compiled.slice(agentJobAt, safeOutputsJobAt).match(/permissions:\n((?:\s{6}\S.*\n)+)/)?.[1] ?? ''; expect(permsBlock).toContain('issues: read'); expect(permsBlock).not.toContain('issues: write'); diff --git a/test/gh-aw-activation-finalization.test.ts b/test/gh-aw-activation-finalization.test.ts new file mode 100644 index 000000000..1b3af1ae5 --- /dev/null +++ b/test/gh-aw-activation-finalization.test.ts @@ -0,0 +1,2456 @@ +import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'; +import { randomUUID } from 'node:crypto'; +import { cpSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { createRequire } from 'node:module'; +import { compileFunction, constants } from 'node:vm'; +import { pathToFileURL } from 'node:url'; +import { parse } from 'yaml'; +import { createFirstInstallFixture } from './helpers/gh-aw-install-fixture.js'; +import { diagnosticRuntime, fetchDiagnosticRuntime } from './helpers/gh-aw-diagnostic-runtime.js'; +import { + buildActivationBindings, + correctDependencyReferences, + createActivation, + digest, + finalizeActivation, + frozenAssignments, + prepareActivation, + reconcileActivation, + selectionEvidence, + validateReceiptSet, + verifyReconciliationRequest, + verifyPublishedActivation, +} from '../workflows/shared/squad-activation.mjs'; +import { + parseStructuredData, + collectHistoricalActivationRecords, + selectActivationRecord as selectChecker, + validateCastingPairFiles, + main as checkBindings, +} from '../workflows/shared/check-agent-binding.mjs'; +import { enforceSquadCommandContract } from '../workflows/shared/squad-command-contract.mjs'; +import { selectActivationRecord as selectSdk } from '../packages/squad-sdk/src/casting/index.js'; +import { selectActivationRecord as selectBuiltSdk } from '../packages/squad-sdk/dist/casting/index.js'; + +const repository = 'owner/repo'; +const timestamp = '2026-01-01T00:00:00Z'; +const bot = { id: 41898282, login: 'github-actions[bot]', type: 'Bot' }; +const operator = { id: 700, login: 'operator', type: 'User' }; +const directories: string[] = []; +const clone = (value: T): T => structuredClone(value); +const block = (heading: string, value: unknown) => `${heading}:\n\`\`\`json\n${JSON.stringify(value)}\n\`\`\``; +const jsonBlock = (body: string, heading: string) => + JSON.parse(body.split(`${heading}:\n\`\`\`json\n`)[1].split('\n```')[0]); +const envelope = (kind: string) => ({ + squad_artifact: kind, schema_version: '1', origin_issue: 42, phases: [], +}); + +afterEach(() => { + for (const directory of directories.splice(0)) rmSync(directory, { recursive: true, force: true }); +}); + +// API responses are copied: a later store mutation must not silently mutate a captured snapshot. +function fixture({ count = 2, epic = false } = {}) { + const root = join(process.cwd(), `.activation-finalization-${randomUUID()}`); + directories.push(root); + mkdirSync(join(root, '.squad/casting'), { recursive: true }); + const registry = { + schema: 'squad-agent-provenance/v1' as const, schema_version: 1 as const, + revision: 1, generated_at: timestamp, transaction_id: 'fixture-generation', + agents: { + builder: { + agent_id: 'builder', display_name: 'Builder', persistent_name: 'Builder', + role: 'Engineer', universe: 'synthetic', status: 'active' as const, + created_at: timestamp, updated_at: timestamp, + }, + }, + }; + const history = { + transaction_id: registry.transaction_id, registry_revision: registry.revision, + assignment_cast_snapshots: { + genesis: { created_at: timestamp, agents: ['builder'], universe: 'synthetic' }, + }, + universe_usage_history: [{ universe: 'synthetic', used_at: timestamp }], + }; + const registryRaw = `${JSON.stringify(registry)}\n`; + const historyRaw = `${JSON.stringify(history)}\n`; + writeFileSync(join(root, '.squad/casting/registry.json'), registryRaw); + writeFileSync(join(root, '.squad/casting/history.json'), historyRaw); + writeFileSync(join(root, '.squad/casting/registry-history.commit.json'), JSON.stringify({ + transaction_id: registry.transaction_id, registry_revision: 1, + registry_sha256: digest(registryRaw), history_sha256: digest(historyRaw), + })); + writeFileSync(join(root, '.squad/team.md'), [ + '# Synthetic team', '', '## Members', '| Name | Role |', '|------|------|', + '| Builder | Engineer |', '', + ].join('\n')); + const outputPath = join(root, 'output.json'); + const capturePath = join(root, 'capture.json'); + const journalPath = join(root, 'receipts.json'); + writeFileSync(outputPath, JSON.stringify({ items: [{ type: 'activate_plan', plan_comment_id: '10' }] })); + const context = { + repo: { owner: 'owner', repo: 'repo' }, actor: operator.login, + payload: { + action: 'created', sender: clone(operator), issue: { number: 42, user: clone(operator) }, + comment: { id: 9, body: '/squad activate', user: clone(operator) }, + }, + }; + const env = { + GITHUB_EVENT_NAME: 'issue_comment', GITHUB_RUN_ID: '900', GITHUB_RUN_ATTEMPT: '1', + SQUAD_WORKFLOW_SHA: 'a'.repeat(40), + }; + const planBody = [ + '# Example plan', + epic ? '| # | Title | Agent | Agent ID | Depends On | Epic |' : '| # | Title | Owner | Agent ID | Depends On |', + epic ? '|---|---|---|---|---|---|' : '|---|---|---|---|---|', + ...Array.from({ length: count }, (_, i) => + `| ${i + 1} | ${i === 0 ? 'Prepare example inputs' : `Example task ${i + 1}`} | Builder | builder | ${i === 0 ? '—' : '1'} |${epic ? ' example |' : ''}`), + '', block('Structured data', envelope(epic ? 'implementation' : 'plan')), + ].join('\n'); + function comment(id: number, body: string, user = bot) { + return { + id, body, user: clone(user), created_at: timestamp, updated_at: timestamp, + issue_url: `https://api.github.com/repos/${repository}/issues/42`, + html_url: `https://github.com/${repository}/issues/42#issuecomment-${id}`, + }; + } + const comments = new Map>([[10, comment(10, planBody)]]); + if (epic) { + comments.set(7, comment(7, block('Structured data', envelope('scope-accepted')))); + comments.set(8, comment(8, block('Structured data', envelope('impl-accepted')))); + } + function issue(number: number, values: Record = {}) { + return { + number, id: number + 1000, node_id: `I_example_${number}`, title: `Issue ${number}`, + body: '', labels: [] as string[], assignees: [{ id: operator.id }], + updated_at: timestamp, html_url: `https://github.com/${repository}/issues/${number}`, + url: `https://api.github.com/repos/${repository}/issues/${number}`, ...values, + }; + } + const issues = new Map>([[42, issue(42)]]); + const parents = new Map(); + const labels = new Set(); + const permissions = new Map([[operator.login, 'admin']]); + const writes: Array<{ method: string; params: any }> = []; + const hooks: { + result?: (result: any, index: number) => any; + get?: (number: number, reads: number) => void; + comment?: (body: string) => void; + update?: (params: any) => void; + pendingIdMissing?: boolean; + parentStatus?: number; + publicationResponse?: (value: any) => any; + } = {}; + const reads = new Map(); + let nextComment = 1000; + let nextIssue = 101; + const rest = { + repos: { + getCollaboratorPermissionLevel: vi.fn(async ({ username }: any) => ({ + data: { permission: permissions.get(username) ?? 'read' }, + })), + }, + issues: { + get: vi.fn(async ({ issue_number }: any) => { + const seen = (reads.get(issue_number) ?? 0) + 1; + reads.set(issue_number, seen); + hooks.get?.(issue_number, seen); + if (!issues.has(issue_number)) throw Object.assign(new Error('Not found'), { status: 404 }); + return { data: clone(issues.get(issue_number)!) }; + }), + getComment: vi.fn(async ({ comment_id }: any) => ({ data: clone(comments.get(comment_id)!) })), + listComments: vi.fn(async () => ({ data: clone([...comments.values()]) })), + getParent: vi.fn(async ({ issue_number }: any) => { + if (!parents.has(issue_number)) throw Object.assign(new Error('Parent unavailable'), { status: 404 }); + return { data: clone(issues.get(parents.get(issue_number)!)!) }; + }), + getLabel: vi.fn(async ({ name }: any) => { + if (!labels.has(name)) throw Object.assign(new Error('Missing label'), { status: 404 }); + return { data: { name } }; + }), + createLabel: vi.fn(async (params: any) => { + writes.push({ method: 'createLabel', params: clone(params) }); + labels.add(params.name); + return { data: { name: params.name } }; + }), + createComment: vi.fn(async (params: any) => { + writes.push({ method: 'createComment', params: clone(params) }); + hooks.comment?.(params.body); + const created = comment(nextComment++, params.body); + comments.set(created.id, created); + if (params.body.includes('Activation bindings:') && hooks.publicationResponse) { + return { data: hooks.publicationResponse(clone(created)) }; + } + return { data: hooks.pendingIdMissing && params.body.includes('squad-activation-pending/v1') + ? { ...clone(created), id: undefined } : clone(created) }; + }), + updateComment: vi.fn(async (params: any) => { + writes.push({ method: 'updateComment', params: clone(params) }); + hooks.comment?.(params.body); + const current = comments.get(params.comment_id)!; + current.body = params.body; + return { data: clone(current) }; + }), + update: vi.fn(async (params: any) => { + writes.push({ method: 'update', params: clone(params) }); + hooks.update?.(params); + Object.assign(issues.get(params.issue_number)!, { body: params.body, updated_at: '2026-01-02T00:00:00Z' }); + return { data: clone(issues.get(params.issue_number)!) }; + }), + addSubIssue: vi.fn(async (params: any) => { + writes.push({ method: 'addSubIssue', params: clone(params) }); + if (hooks.parentStatus) throw Object.assign(new Error('Parent link failed'), { status: hooks.parentStatus }); + const child = [...issues.values()].find(item => item.id === params.sub_issue_id)!; + parents.set(child.number, params.issue_number); + return { data: clone(child) }; + }), + }, + }; + const github = { rest, paginate: vi.fn(async (method: any, params: any) => (await method(params)).data) }; + const createIssue = vi.fn(async (params: any) => { + const saved = JSON.parse(readFileSync(journalPath, 'utf8')); + const pending = comments.get(saved.pending_comment_id); + expect(pending?.body, 'every issue create must follow a durable reservation').toContain('squad-activation-pending/v1'); + expect(saved.pending_comment_id).toBe(pending!.id); + expect(saved.started).toHaveLength(saved.receipts.length + 1); + expect(saved.started.at(-1)).toMatch(/^(task|epic):/); + writes.push({ method: 'createIssue', params: clone(params) }); + const created = issue(nextIssue++, params); + issues.set(created.number, created); + const result = { staged: false, issue: clone(created) }; + return hooks.result ? hooks.result(result, createIssue.mock.calls.length) : result; + }); + const prepare = () => prepareActivation({ github, context, env, root, outputPath, capturePath }); + const create = (capture: any, captureArtifactId: any = 800) => createActivation({ + github, context, env, root, capture, captureArtifactId, journalPath, createIssue, + sanitizeContent: (value: string) => value, formatIssueBody: (value: string) => value, + }); + const finalize = (capture: any, journal: any, receiptArtifactId: any = 801) => + finalizeActivation({ github, context, env, root, capture, journal, receiptArtifactId }); + const accepted = () => [...comments.values()].filter(item => + ['plan-accepted', 'activated', 'activation-reconciled'].includes(parseStructuredData(item.body)?.squad_artifact)); + return { + root, registry, outputPath, capturePath, journalPath, context, env, comments, issues, parents, + permissions, writes, hooks, github, createIssue, comment, issue, prepare, create, finalize, accepted, + }; +} + +async function legacyFixture(epic = false) { + const f = fixture({ epic }); + const capture = await f.prepare(); + const journal = await f.create(capture); + const bindings = buildActivationBindings(capture, journal); + const original = f.comment(99, [ + block('Activation bindings', bindings), + block('Structured data', envelope(epic ? 'activated' : 'plan-accepted')), + ].join('\n')); + f.comments.delete(journal.pending_comment_id); + f.comments.set(original.id, original); + f.writes.length = 0; + f.createIssue.mockClear(); + return { ...f, original, bindings, capture }; +} + +describe('authenticated legacy activation promotion', () => { + it.each([false, true])('promotes a legacy record only with independently matching issue sources (epic=%s)', async epic => { + const f = await legacyFixture(epic); + const original = clone(f.original); + expect(f.original.body).not.toContain('Activation evidence:'); + expect(await f.prepare()).toEqual({ existingAcceptanceId: 99 }); + expect(f.writes).toHaveLength(1); + expect(f.writes[0].params.body).toContain('**State:** Activated'); + expect(f.comments.get(99)).toEqual(original); + expect(f.createIssue).not.toHaveBeenCalled(); + }); + + it.each([false, true])('keeps demonstrably bound legacy phases partial (shared epic=%s)', async sharedEpic => { + const f = phasedFixture(sharedEpic); + const capture = await f.prepare(); + await f.finalize(capture, await f.create(capture)); + const original = f.latest(); + original.body = original.body.replace(/Activation evidence:\s*```json\s*[\s\S]*?```/, ''); + const preserved = clone(original); + f.writes.length = 0; + f.createIssue.mockClear(); + expect(await f.prepare()).toEqual({ existingAcceptanceId: original.id }); + expect(f.comments.get(original.id)).toEqual(preserved); + expect(f.writes).toEqual([]); + expect(f.createIssue).not.toHaveBeenCalled(); + f.context.payload.comment.body = sharedEpic ? '/squad plan activate' : '/squad activate'; + await expect(f.prepare()).rejects.toThrow(/only prior phases are accepted/); + expect(f.writes).toEqual([]); + }); + + it('rejects the read-only outsider single-binding forgery for an unrelated labelled issue', async () => { + const f = await legacyFixture(); + const binding = { ...f.bindings[0], issue: '#201' }; + f.issues.set(201, f.issue(201, { labels: ['squad', 'squad:builder'] })); + f.original.user = { id: 701, login: 'outsider', type: 'User' }; + f.original.body = [block('Activation bindings', [binding]), block('Structured data', envelope('plan-accepted'))].join('\n'); + const original = clone(f.original); + await expect(f.prepare()).rejects.toThrow(/workflow-bot.*reconciliation/); + expect(f.permissions.get('outsider')).toBeUndefined(); + expect(f.writes).toEqual([]); + expect(f.createIssue).not.toHaveBeenCalled(); + expect(f.comments.get(99)).toEqual(original); + }); + + it.each([ + 'missing-stable-id', 'wrong-stable-id', 'missing-source-hash', 'wrong-owner', 'wrong-dependencies', + 'wrong-parent', 'wrong-api-parent', 'partial-task-set', 'wrong-binding-dependencies', 'unknown-phase', + 'wrong-artifact', 'wrong-plan-author', 'wrong-plan-origin', 'wrong-record-origin', 'wrong-bot-login', + 'wrong-bot-type', 'unrelated-labelled-issues', + ])('requires reconciliation without writes for legacy %s', async mutation => { + const f = await legacyFixture(); + const issue = f.issues.get(102)!; + if (mutation === 'missing-stable-id') issue.body = issue.body.replace('**Stable work ID:** task:2\n', ''); + if (mutation === 'wrong-stable-id') issue.body = issue.body.replace('task:2', 'task:unrelated'); + if (mutation === 'missing-source-hash') issue.body = issue.body.replace(/^Plan SHA-256:.*\n/m, ''); + if (mutation === 'wrong-owner') issue.body = issue.body.replace('**Owner:** Builder', '**Owner:** Outsider'); + if (mutation === 'wrong-dependencies') issue.body = issue.body.replace('**Depends On:** #101', '**Depends On:** None'); + if (mutation === 'wrong-parent') issue.body = issue.body.replace('**Parent:** #42', '**Parent:** #43'); + if (mutation === 'wrong-api-parent') { f.parents.set(102, 43); f.issues.set(43, f.issue(43)); } + if (mutation === 'partial-task-set') f.original.body = f.original.body.replace(JSON.stringify(f.bindings), JSON.stringify(f.bindings.slice(0, 1))); + if (mutation === 'wrong-binding-dependencies') f.original.body = f.original.body.replace('"depends_on":["#101"]', '"depends_on":[]'); + if (mutation === 'unknown-phase') f.original.body = f.original.body.replace('"phases":[]', '"phases":[99]'); + if (mutation === 'wrong-artifact') f.original.body = f.original.body.replaceAll('plan-accepted', 'activated'); + if (mutation === 'wrong-plan-author') f.comments.get(10)!.user = clone(operator); + if (mutation === 'wrong-plan-origin') f.comments.get(10)!.issue_url = 'https://api.github.com/repos/owner/repo/issues/43'; + if (mutation === 'wrong-record-origin') f.original.issue_url = 'https://api.github.com/repos/owner/repo/issues/43'; + if (mutation === 'wrong-bot-login') f.original.user.login = 'different[bot]'; + if (mutation === 'wrong-bot-type') f.original.user.type = 'User'; + if (mutation === 'unrelated-labelled-issues') for (const issue of f.issues.values()) issue.body = ''; + const original = clone(f.original); + await expect(f.prepare()).rejects.toThrow(/reconciliation|hierarchy/); + expect(f.writes).toEqual([]); + expect(f.createIssue).not.toHaveBeenCalled(); + expect(f.comments.get(99)).toEqual(original); + }); +}); + +describe('retry-safe activation preflight', () => { + it('requires the pinned formatter instead of falling back to an unattributed body bound', async () => { + const f = fixture(); + const capture = await f.prepare(); + await expect(createActivation({ + github: f.github, context: f.context, env: f.env, root: f.root, capture, + captureArtifactId: 800, journalPath: f.journalPath, createIssue: f.createIssue, + sanitizeContent: (body: string) => body, + })).rejects.toThrow(/formatter is required before reservation/); + expect(f.writes).toEqual([]); + expect(f.createIssue).not.toHaveBeenCalled(); + }); + + it('rejects short-line expansion before any durable reservation or issue create', async () => { + const f = fixture(); + f.comments.get(10)!.body += '\n'.repeat(33000); + expect(Buffer.byteLength(f.comments.get(10)!.body)).toBeLessThan(35000); + const capture = await f.prepare(); + await expect(f.create(capture)).rejects.toThrow(/rendered issue body exceeds/); + expect(f.writes).toEqual([]); + expect(f.createIssue).not.toHaveBeenCalled(); + }); + + it('includes sanitizer expansion and worst-case dependency reference widths in preflight', async () => { + const f = fixture({ epic: true }); + const capture = await f.prepare(); + const seen: string[] = []; + await expect(createActivation({ + github: f.github, context: f.context, env: f.env, root: f.root, capture, + captureArtifactId: 800, journalPath: f.journalPath, createIssue: f.createIssue, + formatIssueBody: (body: string) => body, + sanitizeContent: (body: string) => { + seen.push(body); + return body.includes('**Depends On:** #') ? body + 'x'.repeat(65536) : body; + }, + })).rejects.toThrow(/rendered issue body exceeds/); + expect(seen.some(body => body.includes(`**Parent:** #${Number.MAX_SAFE_INTEGER}`))).toBe(true); + expect(seen.some(body => body.includes(`**Depends On:** #${Number.MAX_SAFE_INTEGER}`))).toBe(true); + expect(f.writes).toEqual([]); + expect(f.createIssue).not.toHaveBeenCalled(); + }); + + it.each([65535, 65536, 65537])('bounds the exact rendered %i-code-unit body including Unicode', async size => { + const baseline = fixture({ count: 1 }); + await baseline.create(await baseline.prepare()); + const baselineBody = baseline.writes.find(write => write.method === 'createIssue')!.params.body; + const extra = size - baselineBody.length; + const f = fixture({ count: 1 }); + f.comments.get(10)!.body += '\n'.repeat(Math.floor((extra - 2) / 3)) + '😀' + 'x'.repeat((extra - 2) % 3); + expect(Buffer.byteLength(f.comments.get(10)!.body)).toBeLessThan(35000); + const capture = await f.prepare(); + if (size > 65536) { + await expect(f.create(capture)).rejects.toThrow(/rendered issue body exceeds/); + expect(f.writes).toEqual([]); + expect(f.createIssue).not.toHaveBeenCalled(); + } else { + const journal = await f.create(capture); + expect(f.writes.find(write => write.method === 'createIssue')!.params.body).toHaveLength(size); + await f.finalize(capture, journal); + expect(f.createIssue).toHaveBeenCalledTimes(1); + } + }); + + it.each([ + ['getLabel', 403], ['getLabel', 500], ['createLabel', 403], ['createLabel', 500], + ] as const)('permits a clean retry after prerequisite %s fails with %i', async (method, status) => { + const f = fixture(); + const capture = await f.prepare(); + f.github.rest.issues[method].mockRejectedValueOnce(Object.assign(new Error('pre-create failure'), { status })); + await expect(f.create(capture)).rejects.toThrow('pre-create failure'); + expect(f.createIssue).not.toHaveBeenCalled(); + expect([...f.comments.values()].some(comment => comment.body.includes('squad-activation-pending/v1'))).toBe(false); + const retry = await f.prepare(); + const journal = await f.create(retry); + await f.finalize(retry, journal); + expect(f.createIssue).toHaveBeenCalledTimes(2); + expect(f.accepted()).toHaveLength(1); + }); + + it('reuses a provisioned label after its response is lost without reserving early', async () => { + const f = fixture(); + const provision = f.github.rest.issues.createLabel.getMockImplementation()!; + f.github.rest.issues.createLabel.mockImplementationOnce(async params => { + await provision(params); + throw Object.assign(new Error('label response lost'), { status: 500 }); + }); + await expect(f.create(await f.prepare())).rejects.toThrow('label response lost'); + expect(f.createIssue).not.toHaveBeenCalled(); + expect([...f.comments.values()].some(comment => comment.body.includes('squad-activation-pending/v1'))).toBe(false); + const capture = await f.prepare(); + await f.finalize(capture, await f.create(capture)); + expect(f.writes.filter(write => write.method === 'createLabel' && write.params.name === 'squad')).toHaveLength(1); + expect(f.createIssue).toHaveBeenCalledTimes(2); + }); + + it('rechecks reservation admission after retry-safe prerequisites', async () => { + const f = fixture(); + const capture = await f.prepare(); + const provision = f.github.rest.issues.createLabel.getMockImplementation()!; + f.github.rest.issues.createLabel.mockImplementationOnce(async params => { + const result = await provision(params); + f.comments.set(98, f.comment(98, 'squad-activation-pending/v1')); + return result; + }); + await expect(f.create(capture)).rejects.toThrow(/reserved or accepted during preflight/); + expect(f.createIssue).not.toHaveBeenCalled(); + expect(f.writes.every(write => write.method === 'createLabel')).toBe(true); + }); + + it('retains the no-create guard when an issue is stored but its create response fails', async () => { + const f = fixture(); + const capture = await f.prepare(); + f.hooks.result = () => { throw Object.assign(new Error('response lost'), { status: 500 }); }; + await expect(f.create(capture)).rejects.toThrow('response lost'); + expect(f.issues.has(101)).toBe(true); + expect(f.createIssue).toHaveBeenCalledTimes(1); + await expect(f.prepare()).rejects.toThrow(/pending activation/); + await expect(f.create(capture)).rejects.toThrow(/reserved/); + expect(f.createIssue).toHaveBeenCalledTimes(1); + }); +}); + +function recovery(options: { count?: number; epic?: boolean } = {}) { + const f = fixture(options); + const original = f.comment(20, [ + '## Original invalid activation — preserve verbatim', + block('Activation bindings', [{ task: '1', issue: 'aw_wi1', agent: 'Builder', epic_issue: '#42' }]), + block('Structured data', envelope(options.epic ? 'activated' : 'plan-accepted')), + ].join('\n')); + f.comments.set(original.id, original); + const count = options.count ?? 2; + for (let i = 1; i <= count; i++) { + f.issues.set(100 + i, f.issue(100 + i, { + title: i === 1 ? 'Prepare example inputs' : `Example task ${i}`, + labels: ['squad', 'squad:builder'], body: [ + `# Example task ${i}`, '**Owner:** Builder', + `**Parent:** #${options.epic ? 201 : 42}`, + i === 1 ? '**Depends On:** — (none)' : '**Depends On:** Task 1 (`aw_wi1`) — Prepare example inputs', + '', 'Keep this prose and `aw_wi1` example untouched.', '', + ].join('\n'), + })); + f.parents.set(100 + i, options.epic ? 201 : 42); + } + if (options.epic) { + f.issues.set(201, f.issue(201, { title: '[Epic] example', body: 'Keep epic bytes.', labels: ['squad', 'squad:builder'] })); + f.parents.set(201, 42); + } + const request = { + schema: 'squad-activation-reconciliation-request/v1', repository, origin_issue: 42, + original: { comment_id: 20, sha256: digest(original.body) }, + plan: { comment_id: 10, sha256: digest(f.comments.get(10)!.body) }, + registry: { revision: f.registry.revision, sha256: digest(f.registry) }, + tasks: Array.from({ length: count }, (_, i) => ({ task: String(i + 1), issue: 101 + i, temporary_id: `aw_wi${i + 1}` })), + epics: options.epic ? [{ epic: 'example', issue: 201 }] : [], + }; + function approve() { + const body = block('Activation reconciliation request', request); + f.comments.set(30, f.comment(30, body, operator)); + f.context.payload.comment.body = `/squad reconcile activation 30 ${digest(body)}`; + } + approve(); + const verify = () => verifyReconciliationRequest({ + github: f.github, context: f.context, root: f.root, + requestCommentId: 30, requestSha256: f.context.payload.comment.body.split(' ').at(-1), + }); + const reconcile = () => reconcileActivation({ github: f.github, context: f.context, env: f.env, root: f.root }); + return { ...f, original, request, approve, verify, reconcile }; +} + +const commonIdentity = (selected: any) => selected && ({ + id: selected.id, artifact: selected.artifact, + bindings: selected.bindings.map((binding: any) => Object.fromEntries([ + 'binding_schema', 'binding_version', 'producer', 'repository', 'origin_issue', 'artifact', + 'registry_schema', 'registry_revision', 'task', 'issue', 'epic', 'epic_issue', 'agent_id', 'epic_agent_ids', + ].map(key => [key, binding[key]]))), +}); + +describe('trusted whole-plan activation finalization', () => { + it.each([false, true])('captures actual IDs and immutable evidence (separate epic: %s)', async epic => { + const f = fixture({ epic }); + expect(await validateCastingPairFiles(join(f.root, '.squad/casting/registry.json'))).toEqual(f.registry); + const originalComments = clone([...f.comments.entries()]); + const originalRoot = clone(f.issues.get(42)); + const capture = await f.prepare(); + const capturedBytes = readFileSync(f.capturePath, 'utf8'); + expect(JSON.parse(capturedBytes)).toEqual(capture); + expect(capture).toMatchObject({ + schema: 'squad-activation-capture/v1', repository, origin_issue: 42, + run_id: 900, run_attempt: 1, phase: null, registry_sha256: digest(f.registry), + }); + const journal = await f.create(capture); + const journalBytes = readFileSync(f.journalPath, 'utf8'); + const evidence = await f.finalize(capture, journal); + const taskStart = epic ? 102 : 101; + const expectedBindings = ['1', '2'].map((task, index) => ({ + binding_schema: 'squad-work-agent-binding/v1', binding_version: 1, producer: 'squad', + repository, origin_issue: 42, artifact: epic ? 'activated' : 'plan-accepted', + registry_schema: 'squad-agent-provenance/v1', registry_revision: 1, + task, issue: `#${taskStart + index}`, epic: epic ? 'example' : 'origin', + epic_issue: epic ? '#101' : '#42', agent: 'Builder', agent_id: 'builder', + epic_agents: ['builder'], epic_agent_ids: ['builder'], label: 'squad:builder', + epic_label: epic ? 'squad:builder' : null, + ...(!epic ? { epic_omission_reason: 'origin' } : {}), + depends_on: index === 0 ? [] : [`#${taskStart}`], + })); + const acceptance = f.accepted(); + expect(acceptance).toHaveLength(1); + expect(jsonBlock(acceptance[0].body, 'Activation bindings')).toEqual(expectedBindings); + expect(jsonBlock(acceptance[0].body, 'Structured data')).toEqual(envelope(epic ? 'activated' : 'plan-accepted')); + expect(buildActivationBindings(capture, journal)).toEqual(expectedBindings); + expect(evidence).toEqual({ + schema: 'squad-activation-finalization/v1', basis: 'current-trusted-execution', + run_id: 900, run_attempt: 1, workflow_sha: 'a'.repeat(40), + capture_sha256: digest(capture), receipts_sha256: digest(journal), + capture_artifact_id: 800, receipts_artifact_id: 801, pending_comment_id: journal.pending_comment_id, + parent_fallbacks: [], + plan_comment_id: 10, plan_sha256: digest(f.comments.get(10)!.body), registry_sha256: digest(f.registry), + }); + expect(jsonBlock(acceptance[0].body, 'Activation evidence')).toEqual(evidence); + expect(f.issues.get(taskStart + 1)!.body).toContain(`**Depends On:** #${taskStart}\n`); + expect(f.issues.get(taskStart)!.body).toContain('**Depends On:** None\n'); + expect(f.parents.get(taskStart + 1)).toBe(epic ? 101 : 42); + expect(journal.receipts.map((receipt: any) => [receipt.number, receipt.id, receipt.node_id])) + .toEqual(Array.from({ length: epic ? 3 : 2 }, (_, i) => [101 + i, 1101 + i, `I_example_${101 + i}`])); + const reservation = f.writes.findIndex(write => write.params.body?.includes('squad-activation-pending/v1')); + expect(reservation).toBeGreaterThanOrEqual(0); + expect(reservation).toBeLessThan(f.writes.findIndex(write => write.method === 'createIssue')); + expect(f.issues.get(42)).toEqual(originalRoot); + for (const [id, comment] of originalComments) expect(f.comments.get(id)).toEqual(comment); + expect(readFileSync(f.capturePath, 'utf8')).toBe(capturedBytes); + expect(readFileSync(f.journalPath, 'utf8')).toBe(journalBytes); + const selectionContext = { repository, originIssue: 42, reconciliations: [] }; + expect(commonIdentity(selectSdk(acceptance, f.registry, selectionContext))) + .toEqual(commonIdentity(selectChecker(acceptance, f.registry, selectionContext))); + expect(await f.prepare()).toEqual({ existingAcceptanceId: acceptance[0].id }); + expect(f.createIssue).toHaveBeenCalledTimes(epic ? 3 : 2); + }); + + it.each([ + ['missing number', (r: any) => { delete r.issue.number; }], + ['missing database ID', (r: any) => { delete r.issue.id; }], + ['missing node ID', (r: any) => { delete r.issue.node_id; }], + ['mismatched URL', (r: any) => { r.issue.html_url = `https://github.com/${repository}/issues/999`; }], + ['cross-repository URL', (r: any) => { r.issue.url = 'https://api.github.com/repos/other/repo/issues/101'; }], + ['staged result', (r: any) => { r.staged = true; }], + ['pull request result', (r: any) => { r.issue.pull_request = {}; }], + ])('withholds acceptance after %s and refuses to recreate', async (_name, mutate) => { + const f = fixture(); + const capture = await f.prepare(); + f.hooks.result = result => { mutate(result); return result; }; + await expect(f.create(capture)).rejects.toThrow(/actual issue|identit|repository|URL/); + expect(f.createIssue).toHaveBeenCalledTimes(1); + expect(f.issues.has(101)).toBe(true); + expect(f.accepted()).toEqual([]); + const journal = JSON.parse(readFileSync(f.journalPath, 'utf8')); + expect(journal.started).toEqual(['task:1']); + expect(journal.receipts).toEqual([]); + await expect(f.finalize(capture, journal)).rejects.toThrow(); + await expect(f.create(capture)).rejects.toThrow(/reserved|recreated/); + await expect(f.prepare()).rejects.toThrow(/pending|No-create/); + expect(f.createIssue).toHaveBeenCalledTimes(1); + }); + + it.each(['number', 'id', 'node_id'])('rejects duplicate real %s on the second create', async field => { + const f = fixture(); + const capture = await f.prepare(); + f.hooks.result = (result, index) => { + if (index === 2) { + result.issue[field] = f.issues.get(101)![field as keyof ReturnType]; + if (field === 'number') { + result.issue.html_url = f.issues.get(101)!.html_url; + result.issue.url = f.issues.get(101)!.url; + } + } + return result; + }; + await expect(f.create(capture)).rejects.toThrow(/duplicate/); + const journal = JSON.parse(readFileSync(f.journalPath, 'utf8')); + expect(journal.started).toEqual(['task:1', 'task:2']); + expect(journal.receipts).toHaveLength(1); + await expect(f.finalize(capture, journal)).rejects.toThrow(/receipts are missing/); + await expect(f.create(capture)).rejects.toThrow(/reserved/); + expect(f.createIssue).toHaveBeenCalledTimes(2); + expect(f.accepted()).toEqual([]); + }); + + it('keeps a durable partial journal when creation succeeds remotely but its response is lost', async () => { + const f = fixture(); + const capture = await f.prepare(); + f.hooks.result = (result, index) => { + if (index === 2) throw new Error('connection lost after remote create'); + return result; + }; + await expect(f.create(capture)).rejects.toThrow('connection lost after remote create'); + expect(f.issues.has(102)).toBe(true); + const journal = JSON.parse(readFileSync(f.journalPath, 'utf8')); + expect(journal.receipts).toHaveLength(1); + expect(journal.started).toHaveLength(2); + await expect(f.finalize(capture, journal)).rejects.toThrow(/receipts are missing/); + await expect(f.prepare()).rejects.toThrow(/pending/); + await expect(f.create(capture)).rejects.toThrow(/reserved/); + expect(f.createIssue).toHaveBeenCalledTimes(2); + expect(f.accepted()).toEqual([]); + }); + + it.each([undefined, 0, -1, '800'])('never writes without a durable numeric capture artifact ID: %s', async id => { + const f = fixture(); + const capture = await f.prepare(); + await expect(createActivation({ + github: f.github, context: f.context, capture, captureArtifactId: id, + journalPath: f.journalPath, createIssue: f.createIssue, sanitizeContent: (text: string) => text, + formatIssueBody: (body: string) => body, + })).rejects.toThrow(/durable capture artifact/); + expect(f.writes).toEqual([]); + expect(f.createIssue).not.toHaveBeenCalled(); + }); + + it('refuses an unacknowledged pending comment without creating any issues', async () => { + const f = fixture(); + const capture = await f.prepare(); + f.hooks.pendingIdMissing = true; + await expect(f.create(capture)).rejects.toThrow(/reservation identity/); + await expect(f.prepare()).rejects.toThrow(/pending/); + await expect(f.create(capture)).rejects.toThrow(/reserved/); + expect(f.createIssue).not.toHaveBeenCalled(); + expect(f.accepted()).toEqual([]); + }); + + it.each([ + ['missing receipt upload', (f: any, _c: any, _j: any) => {}, 0, /receipt artifact upload/], + ['changed dependencies', (f: any) => { f.issues.get(102).body = f.issues.get(102).body.replace('**Depends On:** #101', '**Depends On:** #999'); }, 801, /references differ/], + ['changed labels', (f: any) => { f.issues.get(102).labels = ['squad', 'squad:other']; }, 801, /expected squad:builder, found squad:other/], + ['changed actual identity', (f: any) => { f.issues.get(102).id++; }, 801, /identity differs/], + ['changed API hierarchy', (f: any) => { f.parents.set(102, 101); }, 801, /API hierarchy/], + ['changed plan', (f: any) => { f.comments.get(10).body += '\nChanged after create'; }, 801, /plan changed/], + ['changed reservation', (f: any, _c: any, j: any) => { f.comments.get(j.pending_comment_id).body += '\nChanged'; }, 801, /reservation changed/], + ['unresolved reference', (f: any) => { f.issues.get(102).body += '\n**Depends On:** `aw_wi1`'; }, 801, /references differ/], + ])('withholds finalization for %s; retry cannot create', async (_name, mutate, receiptId, reason) => { + const f = fixture(); + const capture = await f.prepare(); + const journal = await f.create(capture); + (mutate as Function)(f, capture, journal); + await expect(f.finalize(capture, journal, receiptId)).rejects.toThrow(reason as RegExp); + expect(f.accepted()).toEqual([]); + await expect(f.create(capture)).rejects.toThrow(/reserved/); + await expect(f.prepare()).rejects.toThrow(/pending/); + expect(f.createIssue).toHaveBeenCalledTimes(2); + }); + + it('checks plan changes before creating a reservation or issue', async () => { + const f = fixture(); + const capture = await f.prepare(); + f.comments.get(10)!.body += '\nChanged before create'; + await expect(f.create(capture)).rejects.toThrow(/plan changed after capture/); + expect(f.writes).toEqual([]); + expect(f.createIssue).not.toHaveBeenCalled(); + }); + + it.each([404, 422])('records parent body fallback for native API status %s', async status => { + const f = fixture(); + f.hooks.parentStatus = status; + const capture = await f.prepare(); + const journal = await f.create(capture); + expect(journal.parent_fallbacks).toEqual(['task:1', 'task:2']); + await f.finalize(capture, journal); + expect(f.accepted()[0].body).toContain('parent body references are the recorded fallback'); + expect(f.issues.get(102)!.body).toContain('**Parent:** #42'); + }); +}); + +describe('trusted lifecycle recovery and pre-write validation', () => { + it('keeps reference-only temporary tokens and identity-looking plan prose out of operational fields', async () => { + const f = fixture(); + f.comments.get(10)!.body += '\n\nExample of a broken dependency: `aw_wi1`\n**Depends On:** example only'; + const capture = await f.prepare(); + const journal = await f.create(capture); + await f.finalize(capture, journal); + expect(f.issues.get(102)!.body).toContain('> **Depends On:** example only'); + expect(f.issues.get(102)!.body).toContain('**Depends On:** #101'); + expect(f.accepted()).toHaveLength(1); + await expect(verifyPublishedActivation({ + github: f.github, context: f.context, root: f.root, comment: f.accepted()[0], + })).resolves.toBeDefined(); + }); + + it.each(['', ' '.repeat(3), 'x'.repeat(257)])('rejects unusable sanitized titles before any write: %j', async title => { + const f = fixture(); + const capture = await f.prepare(); + await expect(createActivation({ + github: f.github, context: f.context, env: f.env, root: f.root, capture, + captureArtifactId: 800, journalPath: f.journalPath, createIssue: f.createIssue, + sanitizeContent: () => title, + formatIssueBody: (body: string) => body, + })).rejects.toThrow(/sanitized title/); + expect(f.writes).toEqual([]); + expect(f.createIssue).not.toHaveBeenCalled(); + }); + + it('repairs a post-acceptance lifecycle interruption without duplicate acceptance or issues', async () => { + const f = fixture(); + const tracker = f.comment(19, `## Squad lifecycle\n**State:** Planned\n${block('Structured data', envelope('lifecycle-state'))}`); + f.comments.set(tracker.id, tracker); + const capture = await f.prepare(); + const journal = await f.create(capture); + f.hooks.comment = body => { if (body.startsWith('## Squad lifecycle')) throw new Error('lifecycle write interrupted'); }; + await expect(f.finalize(capture, journal)).rejects.toThrow('lifecycle write interrupted'); + expect(f.accepted()).toHaveLength(1); + const acceptedId = f.accepted()[0].id; + f.hooks.comment = undefined; + expect(await f.prepare()).toEqual({ existingAcceptanceId: acceptedId }); + expect(f.comments.get(tracker.id)!.body).toContain('**Activation:** ✅ Done'); + expect(f.comments.get(tracker.id)!.body).toContain('**Last command:** `/squad activate`\n'); + expect(f.accepted()).toHaveLength(1); + expect(f.createIssue).toHaveBeenCalledTimes(2); + const writes = f.writes.length; + await f.prepare(); + expect(f.writes).toHaveLength(writes); + }); + + it('rechecks current dependencies before repairing the lifecycle on an idempotent rerun', async () => { + const f = fixture(); + const capture = await f.prepare(); + const journal = await f.create(capture); + await f.finalize(capture, journal); + f.issues.get(102)!.body += '\n**Depends On:** #999'; + const writes = f.writes.length; + await expect(f.prepare()).rejects.toThrow(/current issue content/); + expect(f.writes).toHaveLength(writes); + expect(f.createIssue).toHaveBeenCalledTimes(2); + }); + + it.each([7, 8])('rechecks frozen granular approval %s before creation', async id => { + const f = fixture({ epic: true }); + const capture = await f.prepare(); + f.comments.get(id)!.body += '\nChanged approval'; + await expect(f.create(capture)).rejects.toThrow(/acceptance.*changed/); + expect(f.writes).toEqual([]); + }); + + it.each(['request', 'plan', 'permission'])('stops a correction when %s changes during its durable intent write', async target => { + const f = recovery(); + f.hooks.comment = body => { + if (!body.startsWith('Dependency correction attempt')) return; + if (target === 'permission') f.permissions.set(operator.login, 'read'); + else f.comments.get(target === 'request' ? 30 : 10)!.body += '\nChanged'; + }; + await expect(f.reconcile()).rejects.toThrow(/changed before|lost correction permission/); + expect(f.github.rest.issues.update).not.toHaveBeenCalled(); + expect(f.createIssue).not.toHaveBeenCalled(); + expect(f.comments.get(20)).toEqual(f.original); + }); +}); + +describe('actual receipt set integrity', () => { + it.each([ + ['wrong schema', (j: any) => { j.schema = 'other/v1'; }], + ['wrong capture hash', (j: any) => { j.capture_sha256 = '0'.repeat(64); }], + ['wrong repository', (j: any) => { j.repository = 'other/repo'; }], + ['wrong run', (j: any) => { j.run_id++; }], + ['wrong attempt', (j: any) => { j.run_attempt++; }], + ['duplicate started operation', (j: any) => { j.started[1] = j.started[0]; }], + ['out-of-order operations', (j: any) => { j.started.reverse(); }], + ['partial operation set', (j: any) => { j.started.pop(); }], + ['partial receipts', (j: any) => { j.receipts.pop(); }], + ['duplicate receipt operation', (j: any) => { j.receipts[1].operation_id = j.receipts[0].operation_id; }], + ['receipt repository mismatch', (j: any) => { j.receipts[1].repository = 'other/repo'; }], + ['issue-number collision', (j: any) => { j.receipts[1].number = j.receipts[0].number; j.receipts[1].url = j.receipts[0].url; }], + ['database-ID collision', (j: any) => { j.receipts[1].id = j.receipts[0].id; }], + ['node-ID collision', (j: any) => { j.receipts[1].node_id = j.receipts[0].node_id; }], + ['missing database ID', (j: any) => { delete j.receipts[1].id; }], + ])('rejects %s without publishing', async (_name, mutate) => { + const f = fixture(); + const capture = await f.prepare(); + const journal = await f.create(capture); + expect([...validateReceiptSet(capture, journal).keys()]).toEqual(['task:1', 'task:2']); + const bad = clone(journal); + mutate(bad); + expect(() => validateReceiptSet(capture, bad)).toThrow(); + await expect(f.finalize(capture, bad)).rejects.toThrow(); + expect(f.accepted()).toEqual([]); + }); +}); + +describe('operator-authorized current-state reconciliation', () => { + it.each(['write', 'maintain', 'admin'])('repairs only authorized dependency tokens for %s and is idempotent', async permission => { + const f = recovery({ epic: true }); + f.permissions.set(operator.login, permission); + const beforeIssues = clone([...f.issues.entries()]); + const beforeComments = clone([...f.comments.entries()]); + const beforeParents = clone([...f.parents.entries()]); + const verified = await f.verify(); + expect(f.writes).toEqual([]); + const result = await f.reconcile(); + const record = jsonBlock(result.body, 'Activation reconciliation'); + expect(record).toEqual({ + schema: 'squad-activation-reconciliation/v1', basis: 'current-state-authorized', + request_comment_id: 30, request_sha256: digest(f.comments.get(30)!.body), actor_id: operator.id, + supersedes_comment_id: 20, supersedes_sha256: digest(f.original.body), + plan_sha256: digest(f.comments.get(10)!.body), registry_sha256: digest(f.registry), + bindings_sha256: digest(verified.bindings), + }); + expect(jsonBlock(result.body, 'Structured data')).toEqual(envelope('activation-reconciled')); + expect(jsonBlock(result.body, 'Activation bindings')).toEqual(verified.bindings); + for (const [number, before] of beforeIssues) { + const after = f.issues.get(number)!; + const expectedBody = number === 102 + ? before.body.replace('**Depends On:** Task 1 (`aw_wi1`)', '**Depends On:** Task 1 (`#101`)') + : before.body; + expect(after).toEqual({ ...before, body: expectedBody, updated_at: number === 102 ? '2026-01-02T00:00:00Z' : before.updated_at }); + } + for (const [id, before] of beforeComments) expect(f.comments.get(id)).toEqual(before); + expect([...f.parents.entries()]).toEqual(beforeParents); + expect(f.github.rest.issues.update).toHaveBeenCalledExactlyOnceWith({ + owner: 'owner', repo: 'repo', issue_number: 102, body: f.issues.get(102)!.body, + }); + expect(f.writes.map(write => write.method)).toEqual(['createComment', 'update', 'createComment', 'createComment']); + expect(f.createIssue).not.toHaveBeenCalled(); + const records = [f.original, result]; + const selectionContext = { repository, originIssue: 42, reconciliations: [selectionEvidence(await f.verify())] }; + const sdk = selectSdk(records, f.registry, selectionContext); + const checker = selectChecker(records, f.registry, selectionContext); + expect(commonIdentity(sdk)).toEqual(commonIdentity(checker)); + expect(sdk?.id).toBe(result.id); + expect(sdk?.bindings.map(binding => [binding.task, binding.issue, binding.epic_issue])) + .toEqual([['1', '#101', '#201'], ['2', '#102', '#201']]); + const writeCount = f.writes.length; + expect(await f.reconcile()).toEqual({ id: result.id, unchanged: true }); + expect(f.writes).toHaveLength(writeCount); + f.context.payload.comment.body = '/squad activate'; + expect(await f.prepare()).toEqual({ existingAcceptanceId: result.id }); + expect(f.createIssue).not.toHaveBeenCalled(); + }); + + it.each([ + ['changed request digest', (f: any) => { f.comments.get(30).body += ' '; }, /digest changed/], + ['bot request author', (f: any) => { f.comments.get(30).user = bot; }, /operator account/], + ['request author lacks permission', (f: any) => { f.comments.get(30).user = { id: 701, login: 'reader', type: 'User' }; }, /author is not authorized/], + ['command actor lacks permission', (f: any) => { f.permissions.set(operator.login, 'read'); }, /require write|refused mutating/], + ['stale original', (f: any) => { f.comments.get(20).body += '\nchanged'; }, /content changed/], + ['stale plan', (f: any) => { f.comments.get(10).body += '\nchanged'; }, /content changed/], + ['newer plan', (f: any) => { f.comments.set(11, f.comment(11, f.comments.get(10).body)); }, /no longer the current plan/], + ['stale registry hash', (f: any) => { f.request.registry.sha256 = '0'.repeat(64); f.approve(); }, /registry differs/], + ['stale registry revision', (f: any) => { f.request.registry.revision++; f.approve(); }, /registry differs/], + ['wrong repository', (f: any) => { f.request.repository = 'other/repo'; f.approve(); }, /repository\/origin/], + ['wrong origin', (f: any) => { f.request.origin_issue = 43; f.approve(); }, /repository\/origin/], + ['duplicate task map', (f: any) => { f.request.tasks[1].task = '1'; f.approve(); }, /mapping is unknown|duplicated/], + ['duplicate issue map', (f: any) => { f.request.tasks[1].issue = 101; f.approve(); }, /mapping is unknown|colliding/], + ['origin issue collision', (f: any) => { f.request.tasks[1].issue = 42; f.approve(); }, /colliding/], + ['duplicate temporary token', (f: any) => { f.request.tasks[1].temporary_id = 'aw_wi1'; f.approve(); }, /token mapping/], + ['missing explicit mapping', (f: any) => { f.request.tasks.pop(); f.approve(); }, /complete frozen plan/], + ['unapproved dependency token', (f: any) => { f.request.tasks[0].temporary_id = null; f.approve(); }, /explicitly approved/], + ['unknown task', (f: any) => { f.request.tasks[1].task = 'unknown'; f.approve(); }, /mapping is unknown/], + ['invented mapping field', (f: any) => { f.request.tasks[1].authorized = true; f.approve(); }, /task mappings require/], + ['wrong parent', (f: any) => { f.parents.set(102, 101); }, /hierarchy/], + ['cross-repository issue', (f: any) => { f.issues.get(102).url = 'https://api.github.com/repos/other/repo/issues/102'; }, /another repository/], + ['pull request', (f: any) => { f.issues.get(102).pull_request = {}; }, /current mapped issue identity/], + ['duplicate database identity', (f: any) => { f.issues.get(102).id = f.issues.get(101).id; }, /identity/], + ['duplicate node identity', (f: any) => { f.issues.get(102).node_id = f.issues.get(101).node_id; }, /identity/], + ['wrong owner label', (f: any) => { f.issues.get(102).labels = ['squad', 'squad:other']; }, /expected squad:builder, found squad:other/], + ['mutated dependency', (f: any) => { f.issues.get(102).body = '**Depends On:** #999'; }, /dependency references/], + ])('rejects %s before writes', async (_name, mutate, reason) => { + const f = recovery(); + (mutate as Function)(f); + await expect(f.reconcile()).rejects.toThrow(reason as RegExp); + expect(f.writes).toEqual([]); + expect(f.createIssue).not.toHaveBeenCalled(); + expect(f.accepted()).toEqual([f.original]); + }); + + it.each([ + ['duplicate epic', (f: any) => { f.request.epics.push(clone(f.request.epics[0])); }], + ['epic/task collision', (f: any) => { f.request.epics[0].issue = 101; }], + ['missing epic', (f: any) => { f.request.epics = []; }], + ['wrong epic parent', (f: any) => { f.parents.set(201, 101); }], + ])('rejects %s without hierarchy repair', async (_name, mutate) => { + const f = recovery({ epic: true }); + mutate(f); + f.approve(); + await expect(f.reconcile()).rejects.toThrow(/mapping|complete frozen plan|hierarchy/); + expect(f.writes).toEqual([]); + expect(f.createIssue).not.toHaveBeenCalled(); + }); + + it.each(['before correction', 'while recording correction'])('does not overwrite a body changed %s', async when => { + const f = recovery(); + const changed = `${f.issues.get(102)!.body}\nOperator edit`; + if (when === 'before correction') { + f.hooks.get = (number, reads) => { if (number === 102 && reads === 2) f.issues.get(102)!.body = changed; }; + } else { + f.hooks.comment = body => { if (body.startsWith('Dependency correction attempt')) f.issues.get(102)!.body = changed; }; + } + await expect(f.reconcile()).rejects.toThrow(/changed before correction|changed while recording/); + expect(f.issues.get(102)!.body).toBe(changed); + expect(f.github.rest.issues.update).not.toHaveBeenCalled(); + expect(f.accepted()).toEqual([f.original]); + expect(f.createIssue).not.toHaveBeenCalled(); + }); + + it('resumes partial corrections without repeating repaired writes or issuing creates', async () => { + const f = recovery({ count: 3 }); + const original = clone(f.original); + f.hooks.update = params => { + if (params.issue_number === 103) throw new Error('second correction API unavailable'); + }; + await expect(f.reconcile()).rejects.toThrow('second correction API unavailable'); + expect(f.issues.get(102)!.body).toContain('**Depends On:** Task 1 (`#101`) — Prepare example inputs'); + expect(f.issues.get(103)!.body).toContain('**Depends On:** Task 1 (`aw_wi1`) — Prepare example inputs'); + expect(f.accepted()).toEqual([original]); + const writesBeforeRetry = f.writes.length; + f.hooks.update = undefined; + const result = await f.reconcile(); + expect(f.writes.slice(writesBeforeRetry).filter(write => write.method === 'update').map(write => write.params.issue_number)) + .toEqual([103]); + expect(f.comments.get(20)).toEqual(original); + expect(f.accepted()).toHaveLength(2); + const writesAfterSuccess = f.writes.length; + expect(await f.reconcile()).toEqual({ id: result.id, unchanged: true }); + expect(f.writes).toHaveLength(writesAfterSuccess); + expect(f.createIssue).not.toHaveBeenCalled(); + }); + + it('refuses to treat a malformed old acceptance as successful activation or recreate its issues', async () => { + const f = recovery(); + const before = clone([...f.comments.entries()]); + f.context.payload.comment.body = '/squad activate'; + await expect(f.prepare()).rejects.toThrow(); + expect([...f.comments.entries()]).toEqual(before); + expect(f.writes).toEqual([]); + expect(f.createIssue).not.toHaveBeenCalled(); + }); +}); + +describe('dependency correction field grammar', () => { + it('changes only the recognized backtick token and preserves surrounding bytes', () => { + const body = [ + '# Exact title', '**Owner:** Builder', + '**Depends On:** Task 1 (`aw_wi1`) — Prepare example inputs', + 'Unrelated prose: `aw_wi1`, #999', '', + ].join('\n'); + expect(correctDependencyReferences(body, [101], { aw_wi1: 101 })) + .toBe(body.replace('(`aw_wi1`)', '(`#101`)')); + expect(correctDependencyReferences('**Depends On:** — (none)', [], {})).toBe('**Depends On:** — (none)'); + }); + + it.each([ + '**Depends On:** aw_wi1', + '**Depends On:** #aw_wi1', + '**Depends On:** `aw_missing`', + '**Depends On:** #101, #101', + '**Depends On:** #999', + '**Depends On:** #101\n**Depends On:** #101', + 'Prose mentions `aw_wi1` but no recognized dependency field.', + ])('rejects unsupported or ambiguous dependency field: %s', body => { + expect(() => correctDependencyReferences(body, [101], { aw_wi1: 101 })).toThrow(); + }); +}); + +describe('SDK/canonical checker reconciliation selection parity', () => { + it('preserves genuine whole-plan evidence with explicit or omitted phases', async () => { + const f = recovery(); + const result = await f.reconcile(); + const proof = selectionEvidence(await f.verify()); + expect(proof.phases).toEqual([]); + const omitted = { ...proof }; + delete omitted.phases; + for (const evidence of [proof, omitted]) { + const context = { repository, originIssue: 42, reconciliations: [evidence] }; + const selections = [selectSdk, selectBuiltSdk, selectChecker] + .map(select => select([f.original, result], f.registry, context)); + for (const selected of selections) { + expect(selected?.id).toBe(result.id); + expect(commonIdentity(selected)).toEqual(commonIdentity(selections[0])); + } + } + }); + + it.each([{ phases: [1, 2] }, { phases: [] }, { phases: [2] }])( + 'rejects internally inconsistent activation-source evidence for replacement phases $phases', + async ({ phases: replacementPhases }) => { + const f = phasedFixture(); + const capture = await f.prepare(); + await f.finalize(capture, await f.create(capture)); + const source = f.latest(); + const bridge = await f.bridge(source); + const verify = () => verifyReconciliationRequest({ + github: f.github, context: f.context, root: f.root, + requestCommentId: bridge.comment.id, requestSha256: digest(bridge.comment.body), + }); + const proof = selectionEvidence(await verify()); + const context = { repository, originIssue: 42, reconciliations: [proof], + epicAssignments: frozenAssignments(capture.fullPlan) }; + expect(parseStructuredData(source.body).phases).toEqual([1]); + expect(proof.phases).toEqual([1]); + const replacement = { + ...bridge.result, + body: bridge.result.body.replace('"phases":[1]', `"phases":${JSON.stringify(replacementPhases)}`), + }; + expect(parseStructuredData(replacement.body).phases).toEqual(replacementPhases); + const forgedContext = { + ...context, reconciliations: [{ ...proof, phases: replacementPhases }], + }; + const writes = f.writes.length; + const creates = f.createIssue.mock.calls.length; + for (const select of [selectSdk, selectBuiltSdk, selectChecker]) { + const selected = select([source, bridge.result], f.registry, context); + expect(selected?.id).toBe(bridge.result.id); + expect(commonIdentity(selected)) + .toEqual(commonIdentity(selectChecker([source, bridge.result], f.registry, context))); + // Authentic evidence already rejects tampering; forged evidence must remain internally consistent. + expect(() => select([source, replacement], f.registry, context)).toThrow(); + expect(() => select([source, replacement], f.registry, forgedContext)).toThrow(/phase/); + } + bridge.request.phases = replacementPhases; + bridge.comment.body = block('Activation reconciliation request', bridge.request); + f.context.payload.comment.body = `/squad reconcile activation ${bridge.comment.id} ${digest(bridge.comment.body)}`; + await expect(verify()).rejects.toThrow(/phase/); + await expect(reconcileActivation({ + github: f.github, context: f.context, env: f.env, root: f.root, + })).rejects.toThrow(/phase/); + expect(f.writes).toHaveLength(writes); + expect(f.createIssue).toHaveBeenCalledTimes(creates); + }, + ); + + it.each([ + ['no verifier evidence', (s: any) => { s.context.reconciliations = []; }], + ['duplicate verifier evidence', (s: any) => { s.context.reconciliations.push(clone(s.context.reconciliations[0])); }], + ['wrong verifier actor', (s: any) => { s.context.reconciliations[0].actorId++; }], + ['wrong verifier request', (s: any) => { s.context.reconciliations[0].requestSha256 = '0'.repeat(64); }], + ['changed source record', (s: any) => { s.records[0].body += '\nchanged'; }], + ['changed binding', (s: any) => { s.records[1].body = s.records[1].body.replace('"issue":"#101"', '"issue":"#999"'); }], + ['changed registry', (s: any) => { s.registry.revision++; }], + ['missing original', (s: any) => { s.records.shift(); }], + ['duplicate comment ID', (s: any) => { s.records.push(clone(s.records[0])); }], + ['competing reconciliations', (s: any) => { s.records.push({ ...s.records[1], id: 9999 }); }], + ['unsuperseded original', (s: any) => { s.records.push({ ...s.records[0], id: 9999 }); }], + ['wrong origin context', (s: any) => { s.context.originIssue++; }], + ['wrong repository context', (s: any) => { s.context.repository = 'other/repo'; }], + ])('both selectors reject %s without fallback', async (_name, mutate) => { + const f = recovery(); + const result = await f.reconcile(); + const state = { + records: clone([f.original, result]), registry: clone(f.registry), + context: { repository, originIssue: 42, reconciliations: [selectionEvidence(await f.verify())] }, + }; + expect(selectSdk(state.records, state.registry, state.context)?.id).toBe(result.id); + expect(selectChecker(state.records, state.registry, state.context)?.id).toBe(result.id); + mutate(state); + expect(() => selectChecker(state.records, state.registry, state.context)).toThrow(); + expect(() => selectSdk(state.records, state.registry, state.context)).toThrow(); + }); +}); + +function phasedFixture(sharedEpic = false) { + const f = fixture({ epic: sharedEpic }); + f.comments.get(10)!.body = [ + '# Phased example', + ...[1, 2, 3].flatMap(phase => [ + `### Phase ${phase}`, + sharedEpic ? '| # | Title | Agent | Agent ID | Depends On | Epic |' : '| # | Title | Owner | Agent ID | Depends On |', + sharedEpic ? '|---|---|---|---|---|---|' : '|---|---|---|---|---|', + `| ${phase} | Task ${phase} | ${sharedEpic && phase === 2 ? '@copilot | —' : 'Builder | builder'} | ${phase === 1 ? '—' : phase - 1} |${sharedEpic ? ' shared |' : ''}`, + ]), + block('Structured data', envelope(sharedEpic ? 'implementation' : 'plan')), + ].join('\n'); + const activate = (phase: number) => { + f.context.payload.comment.body = `/squad ${sharedEpic ? 'plan activate' : 'activate'} phase ${phase}`; + f.env.GITHUB_RUN_ID = String(900 + phase); + }; + const bridge = async (source: ReturnType, pending?: { bindings: any[]; phases: number[] }) => { + const artifact = pending ?? parseStructuredData(source.body); + const request = { + schema: 'squad-activation-reconciliation-request/v1', repository, origin_issue: 42, + ...(pending ? { source_kind: 'pending' } : {}), + original: { comment_id: source.id, sha256: digest(source.body) }, + plan: { comment_id: 10, sha256: digest(f.comments.get(10)!.body) }, + registry: { revision: 1, sha256: digest(f.registry) }, + tasks: artifact.bindings.map((binding: any) => ({ + task: binding.task, issue: Number(binding.issue.slice(1)), temporary_id: null, + })), + epics: [...new Map(artifact.bindings.filter((binding: any) => binding.epic !== 'origin') + .map((binding: any) => [binding.epic, Number(binding.epic_issue.slice(1))])).entries()] + .map(([epic, issue]) => ({ epic, issue })), + phases: artifact.phases, + }; + const comment = f.comment(600 + artifact.phases.length, block('Activation reconciliation request', request), operator); + f.comments.set(comment.id, comment); + f.context.payload.comment.body = `/squad reconcile activation ${comment.id} ${digest(comment.body)}`; + const result = await reconcileActivation({ github: f.github, context: f.context, env: f.env, root: f.root }); + return { result, comment, request }; + }; + const latest = () => [...f.comments.values()].filter(comment => + ['phases-accepted', 'phases-activated'].includes(parseStructuredData(comment.body)?.squad_artifact)).at(-1)!; + activate(1); + return { ...f, activate, bridge, latest }; +} + +describe('trusted synchronous publication completion (source and strict-compiled jobs)', () => { + let lock: any; + let runtimeSources: Map; + beforeAll(async () => { + const installed = createFirstInstallFixture('a'.repeat(40), 'package'); + lock = parse(installed.consumerFiles.get('.github/workflows/squad.lock.yml')!.toString()); + runtimeSources = await fetchDiagnosticRuntime(['index.cjs', 'sanitize_content.cjs']); + }, 180_000); + + function compiled(f: ReturnType) { + const trusted = join(f.root, '.squad-activation-trusted-base'); + mkdirSync(join(trusted, '.github/workflows'), { recursive: true }); + cpSync(join(process.cwd(), 'workflows/shared'), join(trusted, '.github/workflows/shared'), { recursive: true }); + const outputs: Record = {}; + const logs: string[] = []; + const creationJob: any = Object.values(lock.jobs).find((job: any) => + job.steps.some((step: any) => step.name === 'Setup pinned issue creation API')); + const runtime = diagnosticRuntime(runtimeSources, f.root, [], { + ...Object.fromEntries(Object.entries(creationJob.env ?? {}).filter(([, value]) => !String(value).includes('${{'))), + GITHUB_REPOSITORY: repository, ...f.env, + }); + const api = runtime.load('js/index.cjs'); + const github = { ...f.github, hook: { before: () => {} }, rest: { + ...f.github.rest, issues: { ...f.github.rest.issues, + create: async (params: any) => { + cpSync(join(f.root, 'squad-activation-receipts.json'), f.journalPath); + return { data: (await f.createIssue(params)).issue }; + }, + }, + } }; + const run = async (name: string) => { + cpSync(join(f.root, '.squad'), join(trusted, '.squad'), { recursive: true }); + const job: any = Object.values(lock.jobs).find((job: any) => job.steps.some((step: any) => step.name === name)); + expect(job.if).toContain("needs.safe_outputs.result == 'success'"); + const step = job.steps.find((step: any) => step.name === name); + const core = { + info: (message: string) => logs.push(message), debug: vi.fn(), warning: vi.fn(), error: vi.fn(), + setOutput: (key: string, value: string) => { outputs[key] = value; }, + setFailed: (message: string) => { throw new Error(message); }, + }; + await compileFunction(`return (async () => { ${step.with.script} })();`, + ['github', 'context', 'core', 'process', 'require', 'exec', 'io', 'getOctokit'], + { importModuleDynamically: constants.USE_MAIN_CONTEXT_DEFAULT_LOADER })( + github, f.context, core, { env: { + ...f.env, GITHUB_WORKSPACE: f.root, RUNNER_TEMP: f.root, GH_AW_AGENT_OUTPUT: f.outputPath, + CAPTURE_ARTIFACT_ID: '800', RECEIPT_ARTIFACT_ID: '801', + } }, (name: string) => { + if (name === join(f.root, 'gh-aw/actions/index.cjs')) return api; + if (name === join(f.root, 'gh-aw/actions/sanitize_content.cjs')) return runtime.load('js/sanitize_content.cjs'); + if (name === join(f.root, 'gh-aw/actions/issue_body.cjs')) return runtime.load('js/issue_body.cjs'); + return createRequire(import.meta.url)(name); + }, {}, {}, () => github, + ); + }; + return { + logs, outputs, trusted, + prepare: async () => { + await run('Freeze and validate activation inputs'); + return outputs.fresh === 'false' ? { existingAcceptanceId: true } + : JSON.parse(readFileSync(join(f.root, 'squad-activation-capture.json'), 'utf8')); + }, + create: async (_capture: any) => { + await run('Reserve activation and record actual create results'); + return JSON.parse(readFileSync(join(f.root, 'squad-activation-receipts.json'), 'utf8')); + }, + finalize: async (_capture: any, _journal: any) => run('Validate actual bindings before publishing acceptance'), + reconcile: async () => { + writeFileSync(f.outputPath, JSON.stringify({ items: [{ type: 'reconcile_activation', action: 'reconcile' }] })); + await run('Validate authorization and reconcile existing dependency references'); + }, + }; + } + + it.each([ + [65535, false], [65536, false], [65537, false], + [65535, true], [65536, true], [65537, true], + ] as const)( + 'compiled: admits only the pinned runtime attributed-body bound of %i code units (supplementary Unicode=%s) before reservation', async (size, unicode) => { + const baseline = fixture({ count: 1 }); + const baselineRun = compiled(baseline); + await baselineRun.create(await baselineRun.prepare()); + const attributedBody = baseline.writes.find(write => write.method === 'createIssue')!.params.body; + const f = fixture({ count: 1 }); + const extra = size - attributedBody.length; + const newlines = Math.floor((extra - (unicode ? 3 : 1)) / 3); + f.comments.get(10)!.body += '\n'.repeat(newlines) + (unicode ? '😀' : '') + + 'x'.repeat(extra - newlines * 3 - (unicode ? 2 : 0)); + const run = compiled(f); + const capture = await run.prepare(); + if (size > 65536) { + await expect(run.create(capture)).rejects.toThrow(/body exceeds/); + expect(f.writes).toEqual([]); + expect(f.createIssue).not.toHaveBeenCalled(); + } else { + await run.finalize(capture, await run.create(capture)); + expect(f.writes.find(write => write.method === 'createIssue')!.params.body.length).toBe(size); + if (unicode) expect(f.writes.find(write => write.method === 'createIssue')!.params.body).toContain('😀'); + expect(f.createIssue).toHaveBeenCalledTimes(1); + } + }, + ); + + for (const implementation of ['source', 'compiled']) { + const driver = (f: ReturnType) => implementation === 'compiled' ? compiled(f) : { + ...f, reconcile: () => reconcileActivation({ github: f.github, context: f.context, env: f.env, root: f.root }), + }; + + it.each(['tilde', 'long-backtick', 'long-tilde', 'indented-fence', 'spaces', 'tab', 'space-tab'])( + `${implementation}: excludes twice-sanitized %s code examples from the actual activation task set`, async style => { + const f = fixture({ count: 1 }); + const example = [ + '### Phase 99', + '| # | Title | Owner | Agent ID | Depends On |', + '|---|---|---|---|---|', + '| example | Must never create | Builder | builder | — |', + ]; + const fenced = style === 'tilde' ? ['~~~markdown', ...example, '~~~'] + : style === 'long-backtick' ? ['````markdown', '```', ...example, '```', '````'] + : style === 'long-tilde' ? ['~~~~markdown', '~~~', ...example, '~~~', '~~~~'] + : style === 'indented-fence' ? [' ~~~markdown', ...example, ' ~~~~'] + : example.map(line => `${style === 'spaces' ? ' ' : style === 'space-tab' ? ' \t' : '\t'}${line}`); + const sanitizer = diagnosticRuntime(runtimeSources, f.root, [], { + GITHUB_REPOSITORY: repository, + }).load('js/sanitize_content.cjs').sanitizeContent; + const body = `# Frozen plan\n\n${fenced.join('\n')}\n\n${f.comments.get(10)!.body}`; + f.comments.get(10)!.body = sanitizer(sanitizer(body)); + expect(f.comments.get(10)!.body).toContain('Must never create'); + const run = driver(f); + const capture = await run.prepare(); + expect(capture.fullPlan.phased).toBe(false); + expect(capture.fullPlan.tasks.map((task: any) => task.id)).toEqual(['1']); + await run.finalize(capture, await run.create(capture)); + expect(f.createIssue).toHaveBeenCalledTimes(1); + expect(f.createIssue.mock.calls[0][0].title).not.toContain('Must never create'); + }, + ); + + it.each([ + [[], 'omitted', true], [[], 'empty', true], [[1], 'omitted', false], + [[1], 'empty', false], [[1], 'matched', true], [[1], 'mismatched', false], + [[], 'mismatched', false], + ])(`${implementation}: binds source scope %j to %s request scope`, async (sourcePhases, scope, valid) => { + const f = recovery(); + // A phase-scoped source over the same mapped issues proves omission cannot widen recovery. + f.original.body = f.original.body.replace('"phases":[]', `"phases":${JSON.stringify(sourcePhases)}`); + if (sourcePhases.length) f.comments.get(10)!.body = `### Phase 1\n${f.comments.get(10)!.body}`; + f.request.original.sha256 = digest(f.original.body); + f.request.plan.sha256 = digest(f.comments.get(10)!.body); + if (scope !== 'omitted') Object.assign(f.request, { + phases: scope === 'empty' ? [] : scope === 'matched' ? sourcePhases : [2], + }); + if (sourcePhases.length) { + f.issues.set(201, f.issue(201, { labels: ['squad', 'squad:builder'] })); + f.parents.set(201, 42); + for (const number of [101, 102]) { + f.parents.set(number, 201); + f.issues.get(number)!.body = f.issues.get(number)!.body.replace('**Parent:** #42', '**Parent:** #201'); + } + f.request.epics.push({ epic: 'phase-1', issue: 201 }); + } + f.approve(); + const run = driver(f); + if (valid) { + await run.reconcile(); + expect(f.accepted()).toHaveLength(2); + expect(parseStructuredData(f.accepted().at(-1)!.body).phases).toEqual(sourcePhases); + } else { + await expect(run.reconcile()).rejects.toThrow(/phase/); + expect(f.writes).toEqual([]); + } + expect(f.createIssue).not.toHaveBeenCalled(); + }); + + it(`${implementation}: omission cannot bind an unactivated later phase through full-plan mappings`, async () => { + const f = recovery({ epic: true }); + f.original.body = f.original.body.replace('"phases":[]', '"phases":[1]'); + f.comments.get(10)!.body = [ + ...[1, 2].flatMap(phase => [ + `### Phase ${phase}`, + '| # | Title | Agent | Agent ID | Depends On | Epic |', + '|---|---|---|---|---|---|', + `| ${phase} | Task ${phase} | Builder | builder | ${phase === 1 ? '—' : '1'} | example |`, + ]), + block('Structured data', envelope('implementation')), + ].join('\n'); + f.request.original.sha256 = digest(f.original.body); + f.request.plan.sha256 = digest(f.comments.get(10)!.body); + f.approve(); + await expect(driver(f).reconcile()).rejects.toThrow(/phase.*exactly match/); + expect(f.writes).toEqual([]); + expect(f.createIssue).not.toHaveBeenCalled(); + }); + + it(`${implementation}: production readers ignore discussion but reject extra history candidates`, async () => { + const f = phasedFixture(); + const run = driver(f); + const records: any[] = [f.comment(9000, 'Thanks, this breakdown looks good.', operator)]; + const proofs: any[] = []; + let head: any; + let capture: any; + for (const phase of [1, 2]) { + f.activate(phase); + capture = await run.prepare(); + await run.finalize(capture, await run.create(capture)); + const source = f.latest(); + const bridge = await f.bridge(source); + head = bridge.result; + records.push(source, head); + proofs.push(selectionEvidence(await verifyReconciliationRequest({ + github: f.github, context: f.context, root: f.root, + requestCommentId: bridge.comment.id, requestSha256: digest(bridge.comment.body), + }))); + } + const checker = 'trusted' in run + ? (await import(pathToFileURL(join(run.trusted, '.github/workflows/shared/check-agent-binding.mjs')).href)).selectActivationRecord + : selectChecker; + const selectionContext = { repository, originIssue: 42, reconciliations: proofs, + epicAssignments: frozenAssignments(capture.fullPlan) }; + const pending = [...f.comments.values()].find(comment => comment.body.includes('squad-activation-pending/v1'))!; + expect(pending).toBeDefined(); + for (const select of [selectSdk, selectBuiltSdk, checker]) { + expect(select(records, f.registry, selectionContext)?.id).toBe(head.id); + for (const extra of [pending, records[1], head]) { + expect(() => select([...records, { ...extra, id: 9001 }], f.registry, selectionContext)).toThrow(); + } + } + }); + + it(`${implementation}: completes the actual publication and retries without claiming a new output`, async () => { + const f = fixture(); + const run = driver(f); + const capture = await run.prepare(); + await run.finalize(capture, await run.create(capture)); + const accepted = f.accepted()[0]; + expect(accepted).toBeDefined(); + expect(f.github.rest.issues.getComment).toHaveBeenCalledWith({ + ...f.context.repo, comment_id: accepted.id, + }); + f.env.GITHUB_RUN_ATTEMPT = '2'; + const writes = f.writes.length; + expect(await run.prepare()).toHaveProperty('existingAcceptanceId'); + expect(f.writes).toHaveLength(writes); + expect(f.createIssue).toHaveBeenCalledTimes(2); + }); + + it.each([ + ['missing response', () => undefined], + ['empty response', () => ({})], + ['wrong repository', (receipt: any) => ({ ...receipt, issue_url: receipt.issue_url.replace('owner/repo', 'other/repo') })], + ['wrong origin', (receipt: any) => ({ ...receipt, issue_url: receipt.issue_url.replace('/42', '/43') })], + ['wrong bot ID', (receipt: any) => ({ ...receipt, user: { ...bot, id: 42 } })], + ['wrong bot login', (receipt: any) => ({ ...receipt, user: { ...bot, login: 'other[bot]' } })], + ['wrong bot type', (receipt: any) => ({ ...receipt, user: { ...bot, type: 'User' } })], + ['self-claimed run', (receipt: any) => ({ ...receipt, body: receipt.body.replace('"run_id":900', '"run_id":901') })], + ['another attempt', (receipt: any) => ({ ...receipt, body: receipt.body.replace('"run_attempt":1', '"run_attempt":2') })], + ['old comment ID', (receipt: any) => ({ ...receipt, id: 10 })], + ])(`${implementation}: rejects %s rather than borrowing a valid concurrent result`, async (_name, mutate) => { + const f = fixture(); + const run = driver(f); + const capture = await run.prepare(); + const journal = await run.create(capture); + // A real, valid record remains visible in the API at the same origin/time, + // but this invocation has no usable write response. + f.hooks.publicationResponse = mutate as (value: any) => any; + await expect(run.finalize(capture, journal)).rejects.toThrow(/publication receipt/); + expect(f.accepted()).toHaveLength(1); + expect([...f.comments.values()].some(comment => parseStructuredData(comment.body)?.squad_artifact === 'lifecycle-state')) + .toBe(false); + }); + + it(`${implementation}: rejects same-origin/time concurrent publication and a different-attempt receipt`, async () => { + const f = fixture(); + const run = driver(f); + const capture = await run.prepare(); + const journal = await run.create(capture); + f.hooks.comment = body => { + if (!body.includes('Activation bindings:')) return; + const other = f.comment(5000, body.replace('"run_id":900', '"run_id":901')); + f.comments.set(other.id, other); + }; + await expect(run.finalize(capture, journal)).rejects.toThrow(/competing/); + expect(f.accepted()).toHaveLength(2); + f.hooks.comment = undefined; + f.env.GITHUB_RUN_ATTEMPT = '2'; + const before = f.writes.length; + await expect(run.finalize(capture, journal)).rejects.toThrow(/command\/run\/attempt/); + expect(f.writes).toHaveLength(before); + }); + + it(`${implementation}: unrelated valid history cannot replace missing current output`, async () => { + const f = fixture(); + const run = driver(f); + const capture = await run.prepare(); + const journal = await run.create(capture); + f.hooks.publicationResponse = receipt => { + f.comments.delete(receipt.id); + f.comments.set(5000, f.comment(5000, receipt.body.replace('"run_id":900', '"run_id":899'))); + return undefined; + }; + await expect(run.finalize(capture, journal)).rejects.toThrow(/publication receipt/); + // Independent historical validity is deliberately not positive completion. + expect(await runCollectedChecker(f, [f.comments.get(5000)!])).toContain('Validated 2 activation bindings.'); + expect([...f.comments.values()].some(comment => parseStructuredData(comment.body)?.squad_artifact === 'lifecycle-state')) + .toBe(false); + }); + + it.each(['body', 'identity', 'bot', 'repository'])( + `${implementation}: checks exact publication readback after a valid response (%s)`, async mutation => { + const f = fixture(); + const run = driver(f); + const capture = await run.prepare(); + const journal = await run.create(capture); + f.hooks.publicationResponse = receipt => { + const current = f.comments.get(receipt.id)!; + if (mutation === 'body') current.body += '\nConcurrent edit'; + if (mutation === 'identity') current.id = 5000; + if (mutation === 'bot') current.user = clone(operator); + if (mutation === 'repository') current.issue_url = current.issue_url.replace(repository, 'other/repo'); + return receipt; + }; + await expect(run.finalize(capture, journal)).rejects.toThrow(/publication receipt/); + expect([...f.comments.values()].some(comment => parseStructuredData(comment.body)?.squad_artifact === 'lifecycle-state')) + .toBe(false); + }, + ); + + it(`${implementation}: accepts authorized dispatch without a default-only revision restriction`, async () => { + const f = fixture(); + f.env.GITHUB_EVENT_NAME = 'workflow_dispatch'; + f.env.SQUAD_WORKFLOW_SHA = 'b'.repeat(40); + Object.assign(f.context.payload, { inputs: { command: 'activate', issue_number: '42' } }); + const run = driver(f); + const capture = await run.prepare(); + await run.finalize(capture, await run.create(capture)); + expect(f.accepted()).toHaveLength(1); + }); + + it(`${implementation}: reconciliation verifies its own response, not its predecessor as output`, async () => { + const f = recovery(); + const run = driver(f); + f.hooks.publicationResponse = () => clone(f.original); + await expect(run.reconcile()).rejects.toThrow(/publication receipt/); + expect(f.accepted()).toHaveLength(2); + expect(f.createIssue).not.toHaveBeenCalled(); + }); + + it(`${implementation}: accepts reconciliation and explicit transitive phase dependencies`, async () => { + const f = phasedFixture(); + const run = driver(f); + for (const phase of [1, 2, 3]) { + f.activate(phase); + const capture = await run.prepare(); + await run.finalize(capture, await run.create(capture)); + expect(parseStructuredData(f.latest().body).phases).toEqual(Array.from({ length: phase }, (_, i) => i + 1)); + if (phase < 3) await f.bridge(f.latest()); + } + expect(f.createIssue).toHaveBeenCalledTimes(6); + const r = recovery(); + await driver(r).reconcile(); + expect(r.accepted()).toHaveLength(2); + expect(r.createIssue).not.toHaveBeenCalled(); + }); + + it.each(['missing', 'changed', 'wrong-bot', 'wrong-repository'])( + `${implementation}: rejects %s explicit predecessor without treating it as output`, async mutation => { + const f = phasedFixture(); + const run = driver(f); + const first = await run.prepare(); + await run.finalize(first, await run.create(first)); + const { result: bridge } = await f.bridge(f.latest()); + f.activate(2); + const second = await run.prepare(); + const journal = await run.create(second); + const predecessor = f.comments.get(bridge.id)!; + if (mutation === 'missing') f.comments.delete(bridge.id); + if (mutation === 'changed') predecessor.body += '\nChanged'; + if (mutation === 'wrong-bot') predecessor.user = clone(operator); + if (mutation === 'wrong-repository') predecessor.issue_url = predecessor.issue_url.replace(repository, 'other/repo'); + const writes = f.writes.length; + await expect(run.finalize(second, journal)).rejects.toThrow(/prior-phase|workflow-bot|ancestry/); + // Failed prepublication or postpublication checks never publish lifecycle completion. + expect(f.writes.slice(writes).some(write => write.params.body?.includes('"squad_artifact":"lifecycle-state"'))).toBe(false); + }, + ); + } + + it('mutation: removing the in-job publication check admits the very same missing response', async () => { + const f = fixture(); + const run = compiled(f); + const path = join(run.trusted, '.github/workflows/shared/squad-activation.mjs'); + const source = readFileSync(path, 'utf8'); + expect(source).toContain('return checkActivationPublication({ github, context, root, receipt, expectedBody: body });'); + writeFileSync(path, source.replace( + 'return checkActivationPublication({ github, context, root, receipt, expectedBody: body });', 'return receipt;', + )); + const capture = await run.prepare(); + f.hooks.publicationResponse = () => undefined; + await expect(run.finalize(capture, await run.create(capture))).resolves.toBeUndefined(); + }); +}); + +function addValidation(f: ReturnType) { + const plan = f.comments.get(10)!; + f.comments.set(11, f.comment(11, [ + '## Plan validation', 'RESULT: PASS', + `**Implementation plan:** ${plan.html_url}`, `**Implementation SHA-256:** ${digest(plan.body)}`, + '| Check | Status | Details |', '|---|---|---|', + ...Array.from({ length: 16 }, (_, index) => `| ${index + 1} | PASS | Checked synthetic condition ${index + 1}. |`), + '', '### Sizing Summary', + '| Task | Size | Traces to |', '|---|---|---|', + '| 1 | S | program-1 |', '| 2 | M | program-1 |', '| 3 | S | program-2 |', + block('Structured data', envelope('validation')), + ].join('\n'))); +} + +describe('combined implementation-phase acceptance and activation', () => { + it.each([false, true])('retries phase-approval failure before reservation (stored=%s)', async stored => { + const f = phasedFixture(true); + addValidation(f); + f.context.payload.comment.body = '/squad plan accept implementation phase 1'; + const post = f.github.rest.issues.createComment.getMockImplementation()!; + f.github.rest.issues.createComment.mockImplementationOnce(async params => { + expect(params.body).toContain('impl-phases-accepted'); + if (stored) await post(params); + throw Object.assign(new Error('approval response failed'), { status: 500 }); + }); + await expect(f.create(await f.prepare())).rejects.toThrow('approval response failed'); + expect(f.createIssue).not.toHaveBeenCalled(); + expect([...f.comments.values()].some(comment => comment.body.includes('squad-activation-pending/v1'))).toBe(false); + const capture = await f.prepare(); + await f.finalize(capture, await f.create(capture)); + expect([...f.comments.values()].filter(comment => + parseStructuredData(comment.body)?.squad_artifact === 'impl-phases-accepted')).toHaveLength(1); + expect(f.createIssue).toHaveBeenCalledTimes(2); + }); + + it('uses the same writer and bridge, then retries without duplicate creates', async () => { + const f = phasedFixture(true); + addValidation(f); + for (const phase of [1, 2]) { + f.activate(phase); + f.context.payload.comment.body = `/squad plan accept implementation phase ${phase}`; + const capture = await f.prepare(); + expect(capture.approvals.validation.id).toBe(11); + await f.finalize(capture, await f.create(capture)); + const source = f.latest(); + expect([...f.comments.values()].some(comment => + parseStructuredData(comment.body)?.squad_artifact === 'impl-phases-accepted')).toBe(true); + expect(await f.prepare()).toMatchObject({ existingAcceptanceId: source.id }); + if (phase === 1) await f.bridge(source); + } + expect(f.createIssue).toHaveBeenCalledTimes(3); + }); + + it.each(['missing', 'stale-plan', 'failed-check', 'oversized', 'traceability'])('rejects %s validation before writes', async mutation => { + const f = phasedFixture(true); + addValidation(f); + f.context.payload.comment.body = '/squad plan accept implementation phase 1'; + if (mutation === 'missing') f.comments.delete(11); + if (mutation === 'stale-plan') f.comments.get(10)!.body += '\nRevision.'; + if (mutation === 'failed-check') f.comments.get(11)!.body = f.comments.get(11)!.body.replace('| 16 | PASS |', '| 16 | FAIL |'); + if (mutation === 'oversized') f.comments.get(11)!.body = f.comments.get(11)!.body.replace('| 2 | M |', '| 2 | XL |'); + if (mutation === 'traceability') f.comments.get(11)!.body = f.comments.get(11)!.body.replace('| 3 | S | program-2 |', '| 3 | S | — |'); + await expect(f.prepare()).rejects.toThrow(/validation|sixteen/); + expect(f.writes).toEqual([]); + }); + + it.each(['phase', 'validation'])('revalidates captured %s immediately before execution', async mutation => { + const f = phasedFixture(true); + addValidation(f); + f.context.payload.comment.body = '/squad plan accept implementation phase 1'; + const capture = await f.prepare(); + if (mutation === 'phase') f.context.payload.comment.body = '/squad plan accept implementation phase 2'; + else f.comments.get(11)!.body += '\nChanged.'; + await expect(f.create(capture)).rejects.toThrow(); + expect(f.writes).toEqual([]); + }); +}); + +describe('explicit pending-source current-state reconciliation', () => { + async function setup() { + const f = fixture(); + const capture = await f.prepare(); + const journal = await f.create(capture); + const original = f.comments.get(journal.pending_comment_id)!; + const bindings = buildActivationBindings(capture, journal); + const request = { + schema: 'squad-activation-reconciliation-request/v1', source_kind: 'pending', + repository, origin_issue: 42, + original: { comment_id: original.id, sha256: digest(original.body) }, + plan: { comment_id: 10, sha256: digest(f.comments.get(10)!.body) }, + registry: { revision: 1, sha256: digest(f.registry) }, + tasks: bindings.map((binding: any) => ({ task: binding.task, issue: Number(binding.issue.slice(1)), temporary_id: null })), + epics: [], + }; + const approve = () => { + const comment = f.comment(600, block('Activation reconciliation request', request), operator); + f.comments.set(comment.id, comment); + f.context.payload.comment.body = `/squad reconcile activation 600 ${digest(comment.body)}`; + }; + approve(); + const reconcile = () => reconcileActivation({ github: f.github, context: f.context, env: f.env, root: f.root }); + return { ...f, original, request, approve, reconcile }; + } + + it('recovers post-create/pre-acceptance interruption without historical attestation or new issues', async () => { + const f = await setup(); + const originalBody = f.original.body; + const result = await f.reconcile(); + const verified = await verifyReconciliationRequest({ + github: f.github, context: f.context, root: f.root, + requestCommentId: 600, requestSha256: digest(f.comments.get(600)!.body), + }); + const context = { repository, originIssue: 42, reconciliations: [selectionEvidence(verified)], + epicAssignments: frozenAssignments(verified.fullPlan) }; + expect(selectChecker([f.original, result], f.registry, context)?.id).toBe(result.id); + expect(selectSdk([f.original, result], f.registry, context)?.id).toBe(result.id); + expect(selectBuiltSdk([f.original, result], f.registry, context)?.id).toBe(result.id); + expect(result.body).toContain('"supersedes_kind":"pending"'); + expect(await f.reconcile()).toMatchObject({ id: result.id, unchanged: true }); + expect(f.original.body).toBe(originalBody); + expect(f.createIssue).toHaveBeenCalledTimes(2); + f.context.payload.comment.body = '/squad activate'; + expect(await f.prepare()).toMatchObject({ existingAcceptanceId: result.id }); + }); + + it.each(['missing-map', 'forged-author', 'stale-reservation', 'stale-plan', 'missing-source-kind'])('rejects %s without accepting or creating', async mutation => { + const f = await setup(); + if (mutation === 'missing-map') f.request.tasks.pop(); + if (mutation === 'forged-author') f.original.user = clone(operator); + if (mutation === 'stale-reservation') f.original.body += '\nChanged.'; + if (mutation === 'stale-plan') f.comments.get(10)!.body += '\nChanged.'; + if (mutation === 'missing-source-kind') f.request.source_kind = 'activation'; + f.approve(); + const before = f.writes.length; + await expect(f.reconcile()).rejects.toThrow(); + expect(f.writes).toHaveLength(before); + expect(f.createIssue).toHaveBeenCalledTimes(2); + }); +}); + +async function runCollectedChecker( + f: ReturnType, comments: Array<{ id: number; body: string }>, + options: { audit?: Record; onFetch?: (path: string) => void; checker?: typeof checkBindings } = {}, +) { + const input = join(f.root, 'comments.json'); + const records = comments.map(({ id, body }) => ({ id, body, issue: 42 })); + writeFileSync(input, JSON.stringify(options.audit ? { ...options.audit, comments: records } : records)); + const argv = process.argv; + const token = process.env.GITHUB_TOKEN; + const log = vi.spyOn(console, 'log').mockImplementation(() => {}); + vi.stubGlobal('fetch', vi.fn(async (input: string) => { + const url = new URL(input); + expect(url.origin).toBe('https://api.github.com'); + const path = url.pathname.replace('/repos/owner/repo/', ''); + options.onFetch?.(path); + let data; + const headers: Record = {}; + if (path === 'issues/42/comments') { + const page = Number(url.searchParams.get('page') ?? 1); + data = [...f.comments.values()].slice((page - 1) * 100, page * 100); + if (f.comments.size > page * 100) headers.link = `; rel="next"`; + } + else if (/^issues\/comments\/\d+$/.test(path)) data = f.comments.get(Number(path.split('/').at(-1))); + else if (/^issues\/\d+\/parent$/.test(path)) { + const parent = f.parents.get(Number(path.split('/')[1])); + if (parent === undefined) return new Response('{}', { status: 404 }); + data = f.issues.get(parent); + } + else if (/^issues\/\d+$/.test(path)) data = f.issues.get(Number(path.split('/')[1])); + else if (path === 'collaborators/operator/permission') data = { permission: f.permissions.get(operator.login) }; + else throw new Error(`Unexpected checker request ${path}`); + return new Response(JSON.stringify(data), { status: 200, headers }); + })); + try { + process.argv = ['node', 'checker', `--comments-file=${input}`, `--team-file=${f.root}/.squad/team.md`, '--repo=owner/repo']; + process.env.GITHUB_TOKEN = 'synthetic-test-token'; + await (options.checker ?? checkBindings)(); + return log.mock.calls.map(args => args[0]); + } finally { + process.argv = argv; + if (token === undefined) delete process.env.GITHUB_TOKEN; + else process.env.GITHUB_TOKEN = token; + vi.unstubAllGlobals(); + log.mockRestore(); + } +} + +describe('live downstream verification', () => { + const automaticContext = () => ({ + eventName: 'workflow_run', repo: { owner: 'owner', repo: 'repo' }, + payload: { workflow_run: { updated_at: '2026-10-12T16:00:00Z' } }, + }); + const automaticAudit = (requestsUsed = 0) => ({ + schema: 'squad-historical-audit/v1', scope: 'recent-candidates', + since: '2026-10-11T16:00:00.000Z', origins: [42], requestsUsed, + }); + + it('bounds automatic discovery pagination without returning a silently truncated success', async () => { + const listCommentsForRepo = vi.fn(async ({ page, since }: any) => { + expect(since).toBe('2026-10-11T16:00:00.000Z'); + return { data: Array.from({ length: 100 }, (_, i) => ({ id: page * 100 + i, body: 'Ordinary discussion' })) }; + }); + const listComments = vi.fn(); + await expect(collectHistoricalActivationRecords({ + github: { rest: { issues: { listCommentsForRepo, listComments } } }, context: automaticContext(), + })).rejects.toThrow(/incomplete.*pagination/); + expect(listCommentsForRepo).toHaveBeenCalledTimes(5); + expect(listComments).not.toHaveBeenCalled(); + }); + + it('accepts a complete automatic discovery at the exact page and origin limits using API Link termination', async () => { + const f = fixture(); + const listCommentsForRepo = vi.fn(async ({ page }: any) => ({ + data: Array.from({ length: 100 }, (_, i) => ({ + ...f.comment(page * 100 + i, block('Structured data', envelope('activated'))), + issue_url: `https://api.github.com/repos/owner/repo/issues/${page}`, + })), + headers: page < 5 ? { link: '; rel="next"' } : {}, + })); + const listComments = vi.fn(async ({ issue_number }: any) => ({ data: [{ + ...f.comment(issue_number, block('Structured data', { ...envelope('activated'), origin_issue: issue_number })), + issue_url: `https://api.github.com/repos/owner/repo/issues/${issue_number}`, + }], headers: {} })); + const audit = await collectHistoricalActivationRecords({ + github: { rest: { issues: { listCommentsForRepo, listComments } } }, context: automaticContext(), + }); + expect(audit).toMatchObject({ scope: 'recent-candidates', origins: [1, 2, 3, 4, 5], requestsUsed: 10 }); + expect(audit.comments).toHaveLength(5); + expect(listCommentsForRepo).toHaveBeenCalledTimes(5); + }); + + it('distinguishes complete no-candidate discovery from API failures and missing trigger evidence', async () => { + const listCommentsForRepo = vi.fn(async () => ({ data: [] })); + const github = { rest: { issues: { listCommentsForRepo, listComments: vi.fn() } } }; + expect(await collectHistoricalActivationRecords({ github, context: automaticContext() })) + .toMatchObject({ scope: 'recent-candidates', origins: [], comments: [], requestsUsed: 1 }); + listCommentsForRepo.mockRejectedValueOnce(new Error('rate limited')); + await expect(collectHistoricalActivationRecords({ github, context: automaticContext() })).rejects.toThrow('rate limited'); + await expect(collectHistoricalActivationRecords({ + github, context: { ...automaticContext(), payload: {} }, + })).rejects.toThrow(/timestamp is missing/); + expect(listCommentsForRepo).toHaveBeenCalledTimes(2); + }); + + it('follows explicit next links and rejects malformed or exhausted candidate discovery', async () => { + const f = fixture(); + const candidate = f.comment(12, block('Structured data', envelope('activated'))); + const listCommentsForRepo = vi.fn(async ({ page }: any) => page === 1 + ? { data: [{ body: 'Discussion' }], headers: { link: '; rel="next"' } } + : { data: [candidate] }); + const listComments = vi.fn(async () => ({ data: [candidate] })); + const github = { rest: { issues: { listCommentsForRepo, listComments } } }; + expect(await collectHistoricalActivationRecords({ github, context: automaticContext() })) + .toMatchObject({ origins: [42], requestsUsed: 3 }); + listCommentsForRepo.mockImplementation(async () => ({ data: [null] as any })); + await expect(collectHistoricalActivationRecords({ github, context: automaticContext() })).rejects.toThrow(); + listCommentsForRepo.mockImplementation(async () => ({ data: Array.from({ length: 6 }, (_, i) => ({ + ...candidate, issue_url: `https://api.github.com/repos/owner/repo/issues/${i + 1}`, + })) })); + listComments.mockClear(); + await expect(collectHistoricalActivationRecords({ github, context: automaticContext() })).rejects.toThrow(/origin limit/); + expect(listComments).not.toHaveBeenCalled(); + }); + + it('bounds complete origin pages and fails when a discovered origin has missing evidence', async () => { + const f = fixture(); + const candidate = f.comment(12, block('Structured data', envelope('activated'))); + const listCommentsForRepo = vi.fn(async () => ({ data: [candidate] })); + const listComments = vi.fn(async () => ({ data: Array.from({ length: 100 }, () => candidate) })); + const github = { rest: { issues: { listCommentsForRepo, listComments } } }; + await expect(collectHistoricalActivationRecords({ github, context: automaticContext() })).rejects.toThrow(/pagination limit/); + expect(listComments).toHaveBeenCalledTimes(10); + listComments.mockImplementation(async () => ({ data: [] })); + await expect(collectHistoricalActivationRecords({ github, context: automaticContext() })).rejects.toThrow(/no activation evidence/); + const pending = f.comment(13, '```json\n{"schema":"squad-activation-pending/v1"}\n```'); + listCommentsForRepo.mockImplementation(async () => ({ data: [pending] })); + listComments.mockImplementation(async () => ({ data: [pending] })); + await expect(collectHistoricalActivationRecords({ github, context: automaticContext() })).rejects.toThrow(/no activation evidence/); + }); + + it('manual full-history audit passes automatic discovery, origin and pagination limits without a time filter', async () => { + const f = fixture(); + const candidate = f.comment(12, block('Structured data', envelope('activated'))); + const listCommentsForRepo = vi.fn(async ({ page, ...params }: any) => { + expect(params).not.toHaveProperty('since'); + return { data: page <= 6 ? Array.from({ length: 100 }, (_, i) => ({ + ...candidate, id: page * 100 + i, + issue_url: `https://api.github.com/repos/owner/repo/issues/${page}`, + })) : [] }; + }); + const listComments = vi.fn(async ({ page, issue_number }: any) => ({ data: page <= 11 + ? Array.from({ length: 100 }, (_, i) => ({ ...candidate, id: page * 100 + i, + issue_url: `https://api.github.com/repos/owner/repo/issues/${issue_number}` })) : [] })); + const audit = await collectHistoricalActivationRecords({ + github: { rest: { issues: { listCommentsForRepo, listComments } } }, + context: { ...automaticContext(), eventName: 'workflow_dispatch' }, + }); + expect(audit).toMatchObject({ scope: 'full-history', origins: [1, 2, 3, 4, 5, 6], requestsUsed: 79 }); + expect(audit).not.toHaveProperty('since'); + expect(audit.comments).toHaveLength(6600); + }); + + it('charges all canonical chain and label reads against the remaining automatic budget', async () => { + const f = recovery(); + const head = await f.reconcile(); + const calls: string[] = []; + await expect(runCollectedChecker(f, [f.original, head], { + audit: automaticAudit(249), onFetch: path => calls.push(path), + })).rejects.toThrow(/budget exhausted.*complete-chain/); + expect(calls).toHaveLength(1); + calls.length = 0; + const audit = automaticAudit(250); + const legacy = f.comment(99, [ + block('Structured data', envelope('plan-accepted')), + block('Activation bindings', jsonBlock(head.body, 'Activation bindings') + .map((binding: any) => ({ ...binding, artifact: 'plan-accepted' }))), + ].join('\n')); + await expect(runCollectedChecker(f, [legacy], { audit, onFetch: path => calls.push(path) })) + .rejects.toThrow(/budget exhausted/); + expect(calls).toEqual([]); + const result = await runCollectedChecker(f, [f.original, head], { + audit: { ...audit, scope: 'full-history', requestsUsed: 1000 }, + }); + expect(result).toContain('Validated 2 activation bindings.'); + expect(result.some(line => line.includes('scope: full-history'))).toBe(true); + }); + + it('fails automatic verification on truncated origin evidence and does not call it no candidates', async () => { + const f = fixture(); + await expect(runCollectedChecker(f, [], { audit: automaticAudit() })).rejects.toThrow(/candidate evidence is missing/); + await expect(runCollectedChecker(f, [f.comment(1, 'Example: {"squad_artifact":"activated"}')], { + audit: automaticAudit(), + })).rejects.toThrow(/no validated activation evidence/); + const noCandidates = await runCollectedChecker(f, [], { audit: { ...automaticAudit(1), origins: [] } }); + expect(noCandidates.some(line => line.includes('scope: recent-candidates'))).toBe(true); + expect(noCandidates).toContain('No historical activation artifact found; no current-run completion is asserted.'); + }); + + it('stops canonical evidence pagination at the shared API budget instead of validating partial history', async () => { + const f = recovery(); + const head = await f.reconcile(); + for (let index = 0; index < 250; index++) f.comments.set(10000 + index, f.comment(10000 + index, 'Discussion')); + const calls: string[] = []; + await expect(runCollectedChecker(f, [f.original, head], { + audit: automaticAudit(246), onFetch: path => calls.push(path), + })).rejects.toThrow(/budget exhausted.*complete-chain/); + expect(calls).toHaveLength(4); + expect(calls.filter(path => path === 'issues/42/comments')).toHaveLength(2); + expect(await runCollectedChecker(f, [f.original, head], { + audit: { ...automaticAudit(1000), scope: 'full-history' }, + })).toContain('Validated 2 activation bindings.'); + }); + + it('mutation: removing the actual request-budget guard admits the same over-budget canonical audit', async () => { + const f = recovery(); + const head = await f.reconcile(); + const audit = automaticAudit(249); + await expect(runCollectedChecker(f, [f.original, head], { audit })).rejects.toThrow(/budget exhausted/); + const directory = join(f.root, 'mutant'); + cpSync(join(process.cwd(), 'workflows/shared'), directory, { recursive: true }); + const path = join(directory, 'check-agent-binding.mjs'); + const source = readFileSync(path, 'utf8'); + const guard = "if (requests >= requestBudget) incompleteAudit('API request budget exhausted during complete-chain verification');"; + expect(source).toContain(guard); + writeFileSync(path, source.replace(guard, '')); + const mutant = await import(pathToFileURL(path).href); + const calls: string[] = []; + expect(await runCollectedChecker(f, [f.original, head], { + audit, checker: mutant.main, onFetch: path => calls.push(path), + })).toContain('Validated 2 activation bindings.'); + expect(calls.length).toBeGreaterThan(1); + }); + + it.each(['.github/workflows/squad-agent-binding-check.yml', 'workflows/shared/squad-agent-binding-check.yml'] + .flatMap(path => ['workflow_run', 'workflow_dispatch'].map(eventName => [path, eventName])))( + '%s executes %s historical audit, retaining old explicit predecessors without run attestation', async (path, eventName) => { + const f = phasedFixture(); + const first = await f.prepare(); + await f.finalize(first, await f.create(first)); + const original = f.latest(); + await f.bridge(original); + f.activate(2); + const second = await f.prepare(); + await f.finalize(second, await f.create(second)); + const workflow = parse(readFileSync(join(process.cwd(), path), 'utf8')); + expect(workflow.jobs.check.name).toContain('not current-run completion'); + const collector = workflow.jobs.check.steps.find((step: any) => step.with?.script); + const files = new Map(); + const listCommentsForRepo = vi.fn(async (params: any) => { + if (eventName === 'workflow_run') expect(params.since).toBe('2026-09-30T00:01:00.000Z'); + else expect(params).not.toHaveProperty('since'); + // Only a new candidate is discovered; old predecessors still must be fetched. + return { data: eventName === 'workflow_run' ? [clone(f.latest())] : clone([...f.comments.values()]) }; + }); + const listComments = vi.fn(async (params: any) => { + expect(params.issue_number).toBe(42); + expect(params).not.toHaveProperty('since'); + return { data: clone([...f.comments.values()]) }; + }); + const workspace = path.startsWith('.github/') ? process.cwd() : f.root; + if (!path.startsWith('.github/')) { + mkdirSync(join(f.root, '.github/workflows'), { recursive: true }); + cpSync(join(process.cwd(), 'workflows/shared'), join(f.root, '.github/workflows/shared'), { recursive: true }); + } + await compileFunction(`return (async () => { ${collector.with.script} })();`, + ['github', 'context', 'process', 'require'], + { importModuleDynamically: constants.USE_MAIN_CONTEXT_DEFAULT_LOADER })( + { rest: { issues: { listCommentsForRepo, listComments } } }, + { eventName, repo: f.context.repo, payload: { workflow_run: { + id: 999, run_attempt: 2, run_started_at: '2026-10-01T00:00:00Z', updated_at: '2026-10-01T00:01:00Z', + } } }, + { env: { RUNNER_TEMP: f.root, GITHUB_WORKSPACE: workspace } }, + (name: string) => { + if (name === 'node:fs') return { writeFileSync: (path: string, body: string) => files.set(path, body) }; + return createRequire(import.meta.url)(name); + }, + ); + expect(workflow.on).toHaveProperty('workflow_dispatch'); + const audit = JSON.parse(files.get(join(f.root, 'activation-comments.json'))!); + expect(audit.scope).toBe(eventName === 'workflow_run' ? 'recent-candidates' : 'full-history'); + const records = audit.comments; + expect(records.map((record: any) => record.id)).toContain(original.id); + expect(records.map((record: any) => record.id)).toContain(f.latest().id); + expect(await runCollectedChecker(f, records, { audit })).toContain('Validated 3 activation bindings.'); + expect(await runCollectedChecker(f, [])).toContain( + 'No historical activation artifact found; no current-run completion is asserted.', + ); + }, + ); + + it.each([null, ['task:unknown'], ['task:1', 'task:1']])('rejects malformed parent fallback evidence: %j', async fallbacks => { + const f = fixture(); + const capture = await f.prepare(); + const journal = await f.create(capture); + expect(() => validateReceiptSet(capture, { ...journal, parent_fallbacks: fallbacks })) + .toThrow(/parent fallback metadata/); + await f.finalize(capture, journal); + const head = f.accepted()[0]; + const evidence = jsonBlock(head.body, 'Activation evidence'); + head.body = head.body.replace(JSON.stringify(evidence), + JSON.stringify({ ...evidence, parent_fallbacks: fallbacks })); + await expect(runCollectedChecker(f, [head])).rejects.toThrow(/parent fallback metadata/); + }); + + it('does not invent a parent fallback after a previously successful native link disappears', async () => { + const f = fixture(); + const capture = await f.prepare(); + await f.finalize(capture, await f.create(capture)); + f.parents.delete(102); + await expect(runCollectedChecker(f, [f.accepted()[0]])).rejects.toThrow(/API returned 404/); + }); + + it.each([404, 422])('honors recorded parent fallback %s only while the API does not contradict it', async status => { + const f = fixture(); + f.hooks.parentStatus = status; + const capture = await f.prepare(); + await f.finalize(capture, await f.create(capture)); + const head = f.accepted()[0]; + expect(await runCollectedChecker(f, [head])).toContain('Validated 2 activation bindings.'); + f.parents.set(102, 101); + await expect(runCollectedChecker(f, [head])).rejects.toThrow(/API hierarchy/); + }); + + it('rejects independently valid but disconnected competing source/supplement pairs', async () => { + const f = recovery(); + const first = await f.reconcile(); + const otherSource = f.comment(21, f.original.body); + f.comments.set(otherSource.id, otherSource); + const otherRequest = { ...clone(f.request), original: { comment_id: 21, sha256: digest(otherSource.body) } }; + const requestComment = f.comment(31, block('Activation reconciliation request', otherRequest), operator); + f.comments.set(requestComment.id, requestComment); + const metadata = jsonBlock(first.body, 'Activation reconciliation'); + const otherMetadata = { ...metadata, request_comment_id: 31, request_sha256: digest(requestComment.body), + supersedes_comment_id: 21, supersedes_sha256: digest(otherSource.body) }; + const other = f.comment(1500, first.body.replace(JSON.stringify(metadata), JSON.stringify(otherMetadata))); + f.comments.set(other.id, other); + await expect(runCollectedChecker(f, [f.original, first, otherSource, other])).rejects.toThrow(/competing/); + }); + + it('verifies the real collector projection for a continuation over preserved legacy issues', async () => { + const f = phasedFixture(); + const first = await f.prepare(); + await f.finalize(first, await f.create(first)); + const prior = parseStructuredData(f.latest().body).bindings[0]; + const issue = f.issues.get(Number(prior.issue.slice(1)))!; + issue.body = issue.body.replace(/^\*\*Stable work ID:\*\*.*\n/m, '') + .replace('**Depends On:** None', '**Depends On:** — (none)'); + await f.bridge(f.latest()); + f.activate(2); + const second = await f.prepare(); + await f.finalize(second, await f.create(second)); + const head = f.latest(); + expect(await runCollectedChecker(f, [head])).toContain('Validated 2 activation bindings.'); + const leaf = parseStructuredData(head.body).bindings.find((binding: any) => binding.task === '2'); + const taskNumber = Number(leaf.issue.slice(1)); + const epicNumber = Number(leaf.epic_issue.slice(1)); + f.parents.set(taskNumber, 42); + await expect(runCollectedChecker(f, [head])).rejects.toThrow(/API hierarchy/); + f.parents.set(taskNumber, epicNumber); + const priorEpic = Number(prior.epic_issue.slice(1)); + f.parents.set(epicNumber, priorEpic); + await expect(runCollectedChecker(f, [head])).rejects.toThrow(/API hierarchy/); + f.parents.set(epicNumber, 42); + await expect(runCollectedChecker(f, [{ ...head, body: `${head.body}\nTampered projection` }])) + .rejects.toThrow(/differs from its current API record/); + const changed = f.comments.get(head.id)!; + changed.user = clone(operator); + await expect(runCollectedChecker(f, [changed])).rejects.toThrow(/workflow-bot record/); + }); + + it('rechecks published producer bindings against frozen plan and current issue references', async () => { + const f = fixture(); + const capture = await f.prepare(); + await f.finalize(capture, await f.create(capture)); + const comment = f.accepted()[0]; + expect(await verifyPublishedActivation({ github: f.github, context: f.context, root: f.root, comment })) + .toMatchObject({ assignments: frozenAssignments(capture.fullPlan) }); + f.issues.get(102)!.body = f.issues.get(102)!.body.replace('**Depends On:** #101', '**Depends On:** #999'); + await expect(verifyPublishedActivation({ github: f.github, context: f.context, root: f.root, comment })) + .rejects.toThrow(/dependencies/); + }); + + it('the packaged checker admits a validated supplement and skips only its exact malformed original', async () => { + const f = recovery(); + f.original.body = f.original.body.replace('"issue":"aw_wi1"', '"issue":aw_wi1'); + f.request.original.sha256 = digest(f.original.body); + f.approve(); + const result = await f.reconcile(); + expect(await runCollectedChecker(f, [f.original, result])).toContain('Validated 2 activation bindings.'); + }); +}); + +describe('actual producer and verifier phase ancestry matrix', () => { + let installed: ReturnType; + beforeAll(() => { + installed = createFirstInstallFixture('a'.repeat(40), 'package'); + }, 180_000); + + const cases: Array<{ + name: string; plan: number[]; scopes: Array; + invalid?: boolean; requestScope?: 'omitted' | number[]; dependency?: boolean; + guardMutation?: 'prefix' | 'whole-plan' | 'existence'; + }> = [ + { name: 'whole plan with omitted request scope', plan: [1, 2], scopes: [undefined] }, + { name: 'whole plan with explicit empty scope', plan: [1, 2], scopes: [[]] }, + { name: 'standalone scoped pair', plan: [1, 2], scopes: [[1]] }, + { name: 'ordered three-node growth', plan: [1, 2, 3], scopes: [[1], [1, 2], [1, 2, 3]], dependency: true }, + { name: 'sparse ordered growth', plan: [1, 3, 9], scopes: [[1], [1, 3], [1, 3, 9]], dependency: true }, + { name: 'reordered child', plan: [1, 2], scopes: [[1], [2, 1]], invalid: true }, + { name: 'nonprefix insertion at start', plan: [1, 2], scopes: [[2], [1, 2]], invalid: true, guardMutation: 'prefix' }, + { name: 'nonprefix insertion in middle', plan: [1, 2, 3], scopes: [[1, 3], [1, 2, 3]], invalid: true, guardMutation: 'prefix' }, + { name: 'duplicate child phase', plan: [1, 2], scopes: [[1], [1, 1, 2]], invalid: true }, + { name: 'non-growing child', plan: [1, 2], scopes: [[1], [1]], invalid: true }, + { name: 'omitted scoped request', plan: [1, 2], scopes: [[1]], requestScope: 'omitted', invalid: true }, + { name: 'widened scoped request', plan: [1, 2], scopes: [[1]], requestScope: [1, 2], invalid: true }, + { name: 'omitted whole-plan predecessor expansion', plan: [1], scopes: [undefined, [1]], invalid: true }, + { name: 'explicit whole-plan predecessor expansion', plan: [1], scopes: [[], [1]], invalid: true, guardMutation: 'whole-plan' }, + { name: 'nonexistent child phase with unchanged maps', plan: [1], scopes: [[1], [1, 999]], invalid: true, guardMutation: 'existence' }, + { name: 'nonexistent phase mixed with an existing phase', plan: [1], scopes: [[1, 999]], invalid: true, guardMutation: 'existence' }, + { name: 'scope omitting a dependency', plan: [1, 3], scopes: [[3]], dependency: true, invalid: true }, + ]; + + it.each(cases.flatMap(testCase => ['activation', 'pending'].map(sourceKind => ({ ...testCase, sourceKind }))))( + '$sourceKind: $name', async ({ plan: phaseIds, scopes, sourceKind, invalid, requestScope, dependency, guardMutation }) => { + const f = fixture({ epic: true }); + f.comments.get(10)!.body = [ + '# Frozen phased implementation', + ...phaseIds.flatMap((phase, index) => [ + `### Phase ${phase}`, + '| # | Title | Agent | Agent ID | Depends On | Epic |', + '|---|---|---|---|---|---|', + `| ${phase} | Task ${phase} | Builder | builder | ${dependency && index ? phaseIds[index - 1] : '—'} | example |`, + ]), + block('Structured data', envelope('implementation')), + ].join('\n'); + let capture = await f.prepare(); + let baseline: { emitted: any; reservation: any; bindings: any[] } | undefined; + if (invalid) { + const journal = await f.create(capture); + await f.finalize(capture, journal); + const emitted = f.accepted()[0]; + const reservation = f.comments.get(journal.pending_comment_id)!; + baseline = { emitted, reservation, bindings: parseStructuredData(emitted.body).bindings }; + f.comments.delete(emitted.id); + f.comments.delete(reservation.id); + } + + const packagedRoot = join(f.root, 'packaged'); + mkdirSync(packagedRoot, { recursive: true }); + for (const [path, bytes] of installed.consumerFiles) { + if (path.startsWith('.github/workflows/shared/')) { + writeFileSync(join(packagedRoot, path.slice('.github/workflows/shared/'.length)), bytes); + } + } + const packaged = await import(pathToFileURL(join(packagedRoot, 'check-agent-binding.mjs')).href); + const packagedRuntime = await import(pathToFileURL(join(packagedRoot, 'squad-activation.mjs')).href); + const records: any[] = [f.comment(9000, 'Ordinary discussion, not activation evidence.', operator)]; + f.comments.set(records[0].id, records[0]); + const proofs: any[] = []; + let previous: { source: any; result: any } | undefined; + let validatedBindings = 0; + for (const [index, scope] of scopes.entries()) { + const phases = scope ?? []; + let source: any; + let rows: any[]; + if (baseline) { + const { emitted, reservation, bindings } = baseline; + rows = bindings.filter(row => !phases.length || phases.includes(Number(row.task))); + const prior = previous ? { + source_comment_id: previous.source.id, source_sha256: digest(previous.source.body), + reconciliation_comment_id: previous.result.id, reconciliation_sha256: digest(previous.result.body), + } : undefined; + // Invalid historical inputs mutate actual producer output, never fabricate verifier proofs. + let body = emitted.body + .replace(JSON.stringify(jsonBlock(emitted.body, 'Structured data')), + JSON.stringify({ ...jsonBlock(emitted.body, 'Structured data'), phases })) + .replace(JSON.stringify(bindings), JSON.stringify(rows)) + .replace(JSON.stringify(jsonBlock(emitted.body, 'Activation evidence')), + JSON.stringify({ ...jsonBlock(emitted.body, 'Activation evidence'), ...(prior ? { prior } : {}) })); + if (sourceKind === 'pending') { + const pendingData = JSON.parse(reservation.body.split('```json\n')[1].split('\n```')[0]); + body = reservation.body.replace(JSON.stringify(pendingData), + JSON.stringify({ ...pendingData, phases, ...(prior ? { prior } : {}) })); + } + source = f.comment(2000 + index, body); + f.comments.set(source.id, source); + } else { + f.context.payload.comment.body = `/squad plan activate${phases.length ? ` phase ${phases.at(-1)}` : ''}`; + f.env.GITHUB_RUN_ID = String(910 + index); + capture = await f.prepare(); + const journal = await f.create(capture); + rows = buildActivationBindings(capture, journal); + if (sourceKind === 'activation') { + await f.finalize(capture, journal); + source = [...f.comments.values()].filter(comment => comment.body.includes('Activation evidence:')).at(-1)!; + } else { + source = f.comments.get(journal.pending_comment_id)!; + } + } + const creates = f.createIssue.mock.calls.length; + const issueSnapshot = clone([...f.issues.entries()]); + const request: any = { + schema: 'squad-activation-reconciliation-request/v1', repository, origin_issue: 42, + ...(sourceKind === 'pending' ? { source_kind: 'pending' } : {}), + original: { comment_id: source.id, sha256: digest(source.body) }, + plan: { comment_id: 10, sha256: digest(f.comments.get(10)!.body) }, + registry: { revision: 1, sha256: digest(f.registry) }, + tasks: rows.map((row: any) => ({ task: row.task, issue: Number(row.issue.slice(1)), temporary_id: null })), + epics: [{ epic: 'example', issue: Number(rows[0].epic_issue.slice(1)) }], + ...(scope === undefined ? {} : { phases }), + }; + if (requestScope === 'omitted') delete request.phases; + else if (requestScope) request.phases = requestScope; + const requestComment = f.comment(3000 + index, block('Activation reconciliation request', request), operator); + f.comments.set(requestComment.id, requestComment); + f.context.payload.comment.body = + `/squad reconcile activation ${requestComment.id} ${digest(requestComment.body)}`; + const verifyOptions = { + github: f.github, context: f.context, root: f.root, + requestCommentId: requestComment.id, requestSha256: digest(requestComment.body), + }; + const verify = () => verifyReconciliationRequest(verifyOptions); + const reconcileOptions = { github: f.github, context: f.context, env: f.env, root: f.root }; + const selectionContext = { repository, originIssue: 42, reconciliations: proofs, + epicAssignments: frozenAssignments(capture.fullPlan) }; + const writes = f.writes.length; + if (invalid && index === scopes.length - 1) { + await expect(verify()).rejects.toThrow(/phase|coverage|dependency|metadata/); + await expect(packagedRuntime.verifyReconciliationRequest(verifyOptions)) + .rejects.toThrow(/phase|coverage|dependency|metadata/); + await expect(reconcileActivation(reconcileOptions)).rejects.toThrow(/phase|coverage|dependency|metadata/); + expect(f.writes).toHaveLength(writes); + expect(proofs.some(proof => proof.sourceCommentId === source.id)).toBe(false); + if (guardMutation) { + const mutantRoot = join(f.root, 'mutant'); + cpSync(packagedRoot, mutantRoot, { recursive: true }); + const path = join(mutantRoot, 'squad-activation.mjs'); + const runtime = readFileSync(path, 'utf8'); + const [guard, replacement] = { + prefix: [ + 'previous.request.phases.every((phase, index) => request.phases[index] === phase)', + 'previous.request.phases.every(phase => request.phases.includes(phase))', + ], + 'whole-plan': [' previous.request.phases.length > 0 &&\n', ''], + existence: [ + " requireThat(phases.every(phase => fullPlan.tasks.some(task => task.phase === phase)),\n" + + " 'selected prior phase does not exist in the frozen plan');\n", + '', + ], + }[guardMutation]; + expect(runtime).toContain(guard); + writeFileSync(path, runtime.replace(guard, replacement)); + const mutant = await import(pathToFileURL(path).href); + // Exercise the actual producer with one verifier guard removed, including its real proof export. + const result = await mutant.reconcileActivation(reconcileOptions); + const proof = mutant.selectionEvidence(await mutant.verifyReconciliationRequest(verifyOptions)); + const mutatedRecords = [...records, source, result]; + const mutatedContext = { ...selectionContext, reconciliations: [...proofs, proof] }; + for (const select of [selectBuiltSdk, packaged.selectActivationRecord]) { + if (guardMutation === 'existence') { + // Readers trust supplied frozen-plan verification; only the verifier can check phase existence. + expect(select(mutatedRecords, f.registry, mutatedContext)?.id).toBe(result.id); + } else { + expect(() => select(mutatedRecords, f.registry, mutatedContext)).toThrow(/phase coverage/); + } + } + await expect(runCollectedChecker(f, mutatedRecords, { checker: packaged.main })) + .rejects.toThrow(/phase|coverage/); + } + expect(f.createIssue).toHaveBeenCalledTimes(creates); + expect([...f.issues.entries()]).toEqual(issueSnapshot); + break; + } + const verified = await verify(); + const result = await reconcileActivation({ + github: f.github, context: f.context, env: f.env, root: f.root, + }); + proofs.push(selectionEvidence(await verify())); + records.push(source, result); + previous = { source, result }; + validatedBindings += verified.bindings.length; + for (const select of [selectBuiltSdk, packaged.selectActivationRecord]) { + expect(select(records, f.registry, selectionContext)?.id).toBe(result.id); + expect(select([records[0], source, result], f.registry, { + ...selectionContext, reconciliations: [proofs.at(-1)], + })?.id).toBe(result.id); + } + expect(await runCollectedChecker(f, records, { checker: packaged.main })) + .toContain(`Validated ${validatedBindings} activation bindings.`); + expect(f.createIssue).toHaveBeenCalledTimes(creates); + expect([...f.issues.entries()]).toEqual(issueSnapshot); + } + }, + ); +}); + +describe('explicit current-state phase continuation', () => { + it('continues after a later-phase pending-source recovery and validates the complete transitive reader chain', async () => { + const f = phasedFixture(); + const first = await f.prepare(); + await f.finalize(first, await f.create(first)); + const original = f.latest(); + const firstBridge = await f.bridge(original); + f.activate(2); + const second = await f.prepare(); + const journal = await f.create(second); + const pending = f.comments.get(journal.pending_comment_id)!; + const secondBridge = await f.bridge(pending, { + bindings: buildActivationBindings(second, journal), phases: [1, 2], + }); + const proofs = await Promise.all([firstBridge.comment, secondBridge.comment].map(comment => + verifyReconciliationRequest({ github: f.github, context: f.context, root: f.root, + requestCommentId: comment.id, requestSha256: digest(comment.body) }))); + const selectionContext = { repository, originIssue: 42, + reconciliations: proofs.map(selectionEvidence), epicAssignments: frozenAssignments(second.fullPlan) }; + const records = [original, firstBridge.result, pending, secondBridge.result]; + expect(selectSdk(records, f.registry, selectionContext)?.id).toBe(secondBridge.result.id); + expect(selectChecker(records, f.registry, selectionContext)?.id).toBe(secondBridge.result.id); + expect(await runCollectedChecker(f, [original, firstBridge.result, secondBridge.result])) + .toContain('Validated 3 activation bindings.'); + f.activate(2); + await expect(f.prepare()).resolves.toMatchObject({ existingAcceptanceId: secondBridge.result.id }); + f.activate(3); + const third = await f.prepare(); + await f.finalize(third, await f.create(third)); + const head = f.latest(); + expect(await runCollectedChecker(f, [head])).toContain('Validated 3 activation bindings.'); + await expect(f.prepare()).resolves.toMatchObject({ existingAcceptanceId: head.id }); + expect(f.createIssue).toHaveBeenCalledTimes(6); + }); + + it('does not roll the terminal lifecycle backward when an old phase bridge is replayed', async () => { + const f = phasedFixture(); + let firstCommand = ''; + for (const phase of [1, 2, 3]) { + f.activate(phase); + const capture = await f.prepare(); + await f.finalize(capture, await f.create(capture)); + if (phase < 3) { + await f.bridge(f.latest()); + if (phase === 1) firstCommand = f.context.payload.comment.body; + } + } + const before = clone([...f.comments.values()]); + const writes = f.writes.length; + f.context.payload.comment.body = firstCommand; + await expect(reconcileActivation({ github: f.github, context: f.context, env: f.env, root: f.root })) + .resolves.toMatchObject({ unchanged: true }); + expect([...f.comments.values()]).toEqual(before); + expect(f.writes).toHaveLength(writes); + expect(f.createIssue).toHaveBeenCalledTimes(6); + }); + + it('consumes a continuation over explicitly reconciled legacy-format prior issues without rewriting them', async () => { + const f = phasedFixture(); + const first = await f.prepare(); + await f.finalize(first, await f.create(first)); + const prior = parseStructuredData(f.latest().body).bindings[0]; + const issue = f.issues.get(Number(prior.issue.slice(1)))!; + issue.body = issue.body.replace(/^\*\*Stable work ID:\*\*.*\n/m, '') + .replace('**Depends On:** None', '**Depends On:** — (none)'); + const legacyBody = issue.body; + await f.bridge(f.latest()); + f.activate(2); + const second = await f.prepare(); + await f.finalize(second, await f.create(second)); + const current = f.latest(); + await expect(f.prepare()).resolves.toMatchObject({ existingAcceptanceId: current.id }); + await expect(verifyPublishedActivation({ github: f.github, context: f.context, root: f.root, comment: current })) + .resolves.toBeDefined(); + expect(issue.body).toBe(legacyBody); + issue.body = issue.body.replace('**Depends On:** — (none)', '**Depends On:** #999'); + await expect(f.prepare()).rejects.toThrow(/dependency references/); + expect(f.createIssue).toHaveBeenCalledTimes(4); + }); + + it.each([false, true])('continues three phases and reuses prior IDs (shared epic=%s)', async sharedEpic => { + const f = phasedFixture(sharedEpic); + for (const phase of [1, 2, 3]) { + f.activate(phase); + const capture = await f.prepare(); + expect(capture.operations.filter((operation: any) => operation.task).map((operation: any) => operation.task)).toEqual([String(phase)]); + if (sharedEpic && phase > 1) expect(capture.operations).toHaveLength(1); + const journal = await f.create(capture); + await f.finalize(capture, journal); + const source = f.latest(); + const artifact = parseStructuredData(source.body); + expect(artifact.phases).toEqual(Array.from({ length: phase }, (_, index) => index + 1)); + expect(artifact.bindings).toHaveLength(phase); + const trackers = [...f.comments.values()].filter(comment => + parseStructuredData(comment.body)?.squad_artifact === 'lifecycle-state'); + expect(trackers).toHaveLength(1); + expect(trackers[0].body).toContain(phase === 3 ? '**State:** Activated' : '**State:** Partially activated'); + if (phase < 3) expect(trackers[0].body).toContain(`phase ${phase + 1}\``); + expect(new Set(artifact.bindings.map((binding: any) => binding.issue)).size).toBe(phase); + if (sharedEpic) expect(f.issues.get(Number(artifact.bindings[0].epic_issue.slice(1)))?.labels).toEqual(['squad']); + const before = f.createIssue.mock.calls.length; + expect(await f.prepare()).toMatchObject({ existingAcceptanceId: source.id }); + if (phase < 3) await f.bridge(source); + expect(f.createIssue).toHaveBeenCalledTimes(before); + } + expect(f.createIssue).toHaveBeenCalledTimes(sharedEpic ? 4 : 6); + }); + + it('does not create the next phase without an explicit bridge', async () => { + const f = phasedFixture(); + const capture = await f.prepare(); + await f.finalize(capture, await f.create(capture)); + f.activate(2); + await expect(f.prepare()).rejects.toThrow(/prior-phase mapping bridge/); + expect(f.createIssue).toHaveBeenCalledTimes(2); + }); + + it.each(['request', 'plan', 'permission', 'competing'])('rejects a %s bridge before any next-phase creates', async mutation => { + const f = phasedFixture(); + const capture = await f.prepare(); + await f.finalize(capture, await f.create(capture)); + const { result, comment } = await f.bridge(f.latest()); + if (mutation === 'request') comment.body += '\nChanged.'; + if (mutation === 'plan') f.comments.get(10)!.body += '\nChanged.'; + if (mutation === 'permission') f.permissions.set(operator.login, 'read'); + if (mutation === 'competing') f.comments.set(1500, f.comment(1500, result.body)); + f.activate(2); + await expect(f.prepare()).rejects.toThrow(); + expect(f.createIssue).toHaveBeenCalledTimes(2); + }); + + it('blocks all recreation after a later-phase ambiguous API success', async () => { + const f = phasedFixture(); + const capture = await f.prepare(); + await f.finalize(capture, await f.create(capture)); + await f.bridge(f.latest()); + f.activate(2); + const next = await f.prepare(); + f.hooks.result = () => { throw new Error('connection lost after create'); }; + await expect(f.create(next)).rejects.toThrow(/connection lost/); + f.env.GITHUB_RUN_ATTEMPT = '2'; + await expect(f.prepare()).rejects.toThrow(/pending activation/); + expect(f.createIssue).toHaveBeenCalledTimes(3); + }); +}); + +describe('executing activation authority', () => { + it.each(['permission', 'attempt', 'run'])('revalidates %s before creating', async mutation => { + const f = fixture(); + const capture = await f.prepare(); + if (mutation === 'permission') f.permissions.set(operator.login, 'read'); + if (mutation === 'attempt') f.env.GITHUB_RUN_ATTEMPT = '2'; + if (mutation === 'run') f.env.GITHUB_RUN_ID = '901'; + await expect(f.create(capture)).rejects.toThrow(); + expect(f.writes).toEqual([]); + }); +}); + +describe('authorized command output guard regression', () => { + it.each(['admin', 'read'])('rejects the malformed old activation batch for %s independently of writer callbacks', async permission => { + const f = fixture(); + const createComment = vi.fn(); + writeFileSync(f.outputPath, JSON.stringify({ items: [ + { type: 'create_issue', temporary_id: 'aw_wi1', title: 'Prepare example inputs', body: '**Owner:** Builder', labels: ['squad', 'squad:builder'] }, + { type: 'create_issue', temporary_id: 'aw_wi2', title: 'Example task 2', body: '**Depends On:** Task 1 (`aw_wi1`) — Prepare example inputs', labels: ['squad', 'squad:builder'] }, + { type: 'add_comment', issue_number: 42, body: [ + block('Activation bindings', [{ task: '1', issue: 'aw_wi1', agent: 'Builder', epic: 'origin', epic_issue: '#42' }]), + block('Structured data', envelope('plan-accepted')), + ].join('\n') }, + ] })); + await expect(enforceSquadCommandContract({ + payload: f.context.payload, eventName: f.env.GITHUB_EVENT_NAME, actor: operator.login, + resolvePermission: async () => permission, createComment, agentOutputPath: f.outputPath, + })).rejects.toThrow(permission === 'admin' ? /trusted writer|activate_plan/ : /refused mutating|require write/); + expect(createComment).not.toHaveBeenCalled(); + expect(f.createIssue).not.toHaveBeenCalled(); + expect(f.writes).toEqual([]); + }); + + it('allows the exact activate_plan request without running any writer', async () => { + const f = fixture(); + const createComment = vi.fn(); + await expect(enforceSquadCommandContract({ + payload: f.context.payload, eventName: f.env.GITHUB_EVENT_NAME, actor: operator.login, + resolvePermission: async () => 'admin', createComment, agentOutputPath: f.outputPath, + })).resolves.toMatchObject({ status: 'accepted', mode: 'activate' }); + expect(createComment).not.toHaveBeenCalled(); + expect(f.createIssue).not.toHaveBeenCalled(); + expect(f.writes).toEqual([]); + }); +}); diff --git a/test/gh-aw-activation-label-provisioning.test.ts b/test/gh-aw-activation-label-provisioning.test.ts index 553eaca11..cb8885d57 100644 --- a/test/gh-aw-activation-label-provisioning.test.ts +++ b/test/gh-aw-activation-label-provisioning.test.ts @@ -1,309 +1,390 @@ /** - * Fresh-repo `squad`/`squad:{agent}` label provisioning for `/squad plan activate` (#1955). - * - * `workflows/squad.md` promised `squad` and `squad:{agent}` labels on every issue the - * activation flow creates, but the workflow only declared `issues: read` and a - * `create-issue` safe-output — it never declared a way to create a label that does not - * already exist. GitHub's REST API silently drops label names that are not already - * present in the target repository when they're passed to `POST /issues`, so a - * consumer's very first activation on a brand-new repo (zero pre-existing labels) - * created every issue with none of the labels the docs promised. The old prose named - * this honestly as a "prerequisite gap" instead of fixing it. - * - * The fix is gh-aw's `add-labels` safe output with `create-if-missing: true`: it creates - * any label in its `allowed` list that the target repository is missing, then applies it - * via an add-only GraphQL mutation. This suite locks in: - * - the frontmatter no longer describes label creation as an unconfigured gap - * - Steps 2b/2c call `add_labels` immediately after each verified `create-issue` result - * - the roster-binding/correspondence/non-roster rules `gh-aw-agent-binding- - * correspondence.test.ts` already locks in are untouched by this change - * - the compiled workflow actually wires `create_if_missing: true` into the add_labels - * safe-output handler config, so this isn't just unenforced prose - * - * Out of scope (per #1955's delegated task boundaries): running the E4 live end-to-end - * test, and the `squad-plan-accept` fast-path's own separate create-issue/label logic - * (only reachable when a flat `plan` artifact exists with no `program`/`implementation` - * artifacts — see workflows/squad.md's `squad-plan-accept` skill). #1959 subsequently - * brought that fast path onto the same pattern; its own contract is locked in - * `gh-aw-activate-fast-path-label-provisioning.test.ts`, and the boundary test below now - * asserts the parity instead of the former gap. + * Fresh repositories are provisioned by the trusted writer before synchronous + * createIssue calls. No native add_labels fallback can mutate the origin issue. */ - -import { afterAll, describe, it, expect } from 'vitest'; -import { cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'; +import { afterAll, describe, it, expect, vi } from 'vitest'; +import { cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { execFileSync, spawnSync } from 'node:child_process'; import { join } from 'node:path'; +import { + buildActivationBindings, createActivation, digest, finalizeActivation, prepareActivation, +} from '../workflows/shared/squad-activation.mjs'; const WORKFLOWS_DIR = join(process.cwd(), 'workflows'); -const SQUAD_WORKFLOW = join(WORKFLOWS_DIR, 'squad.md'); const TEST_WORKSPACES_DIR = join(process.cwd(), '.test-workspaces-label-provisioning'); - -afterAll(() => { - rmSync(TEST_WORKSPACES_DIR, { recursive: true, force: true }); -}); - -/** Read a text file with line endings normalized to LF (Windows checkouts materialize CRLF). */ -function readText(filePath: string): string { - return readFileSync(filePath, 'utf8').replace(/\r\n/g, '\n'); +const workflow = readFileSync(join(WORKFLOWS_DIR, 'squad.md'), 'utf8').replace(/\r\n/g, '\n'); +const timestamp = '2026-01-01T00:00:00Z'; +const bot = { id: 41898282, login: 'github-actions[bot]', type: 'Bot' }; +const operator = { id: 700, login: 'operator', type: 'User' }; +const clone = (value: T): T => structuredClone(value); +const block = (kind: string) => `Structured data:\n\`\`\`json\n${JSON.stringify({ + squad_artifact: kind, schema_version: '1', origin_issue: 42, phases: [], +})}\n\`\`\``; +afterAll(() => rmSync(TEST_WORKSPACES_DIR, { recursive: true, force: true })); + +function workspace(prefix: string) { + mkdirSync(TEST_WORKSPACES_DIR, { recursive: true }); + return mkdtempSync(join(TEST_WORKSPACES_DIR, prefix)); } -const workflow = readText(SQUAD_WORKFLOW); - -/** Whitespace-collapsed view for prose assertions that may span wrapped lines. */ -const prose = workflow.replace(/\s+/g, ' '); - -/** Isolate the `squad-plan-activate` skill body — the only in-scope command for #1955. */ -const ACTIVATE_START = workflow.indexOf("## skill: `squad-plan-activate`"); -const ACTIVATE_END = workflow.indexOf("## end skill: `squad-plan-activate`"); -expect(ACTIVATE_START, '"## skill: `squad-plan-activate`" is missing from workflows/squad.md').toBeGreaterThan(-1); -expect(ACTIVATE_END, '"## end skill: `squad-plan-activate`" is missing from workflows/squad.md').toBeGreaterThan(ACTIVATE_START); -const activateSkill = workflow.slice(ACTIVATE_START, ACTIVATE_END); -const activateProse = activateSkill.replace(/\s+/g, ' '); - -describe('gh-aw: fresh-repo label provisioning in squad-plan-activate (#1955)', () => { - it('declares add-labels with create-if-missing in frontmatter, not a create-label safe-output', () => { - // gh-aw has no `create-label` safe-output primitive; `add-labels` with - // `create-if-missing: true` is the only supported mechanism. A regression here - // (e.g. someone inventing `create-label`) would fail this assertion first. - expect(workflow).toContain('add-labels:'); - expect(workflow).toContain('create-if-missing: true'); - expect(workflow).not.toContain('create-label:'); +function fixture({ owners = ['Builder', 'Reviewer', '@copilot'], implementation = false, phase = false } = {}) { + const root = workspace('api-'); + mkdirSync(join(root, '.squad/casting'), { recursive: true }); + const registry = { + schema: 'squad-agent-provenance/v1', schema_version: 1, revision: 1, + transaction_id: 'labels-fixture', generated_at: timestamp, + agents: Object.fromEntries(['Builder', 'Reviewer'].map(name => [name.toLowerCase(), { + agent_id: name.toLowerCase(), display_name: name, persistent_name: name, role: 'Engineer', + universe: 'synthetic', status: 'active', created_at: timestamp, updated_at: timestamp, + }])), + }; + const history = { + transaction_id: registry.transaction_id, registry_revision: registry.revision, + assignment_cast_snapshots: { genesis: { created_at: timestamp, agents: ['builder', 'reviewer'], universe: 'synthetic' } }, + universe_usage_history: [{ universe: 'synthetic', used_at: timestamp }], + }; + const registryRaw = JSON.stringify(registry); + const historyRaw = JSON.stringify(history); + writeFileSync(join(root, '.squad/casting/registry.json'), registryRaw); + writeFileSync(join(root, '.squad/casting/history.json'), historyRaw); + writeFileSync(join(root, '.squad/casting/registry-history.commit.json'), JSON.stringify({ + transaction_id: registry.transaction_id, registry_revision: registry.revision, + registry_sha256: digest(registryRaw), history_sha256: digest(historyRaw), + })); + writeFileSync(join(root, '.squad/team.md'), + '## Members\n| Name | Role |\n|---|---|\n| Builder | Engineer |\n| Reviewer | Engineer |\n'); + const outputPath = join(root, 'output.json'); + const capturePath = join(root, 'capture.json'); + const journalPath = join(root, 'receipts.json'); + writeFileSync(outputPath, JSON.stringify({ items: [{ type: 'activate_plan', plan_comment_id: '10' }] })); + const context = { + repo: { owner: 'owner', repo: 'repo' }, actor: operator.login, + payload: { + action: 'created', sender: clone(operator), issue: { number: 42, user: clone(operator) }, + comment: { id: 9, body: implementation ? '/squad plan activate' : '/squad activate', user: clone(operator) }, + }, + }; + const env = { + GITHUB_EVENT_NAME: 'issue_comment', GITHUB_RUN_ID: '900', GITHUB_RUN_ATTEMPT: '1', + SQUAD_WORKFLOW_SHA: 'a'.repeat(40), + }; + const planBody = [ + '# Label provisioning plan', ...(phase ? ['### Phase 1'] : []), + `| # | Title | ${implementation ? 'Agent' : 'Owner'} | Agent ID | Depends On |${implementation ? ' Epic |' : ''}`, + `|---|---|---|---|---|${implementation ? '---|' : ''}`, + ...owners.map((owner, i) => + `| ${i + 1} | Same title | ${owner} | ${['Builder', 'Reviewer'].includes(owner) ? owner.toLowerCase() : '-'} | ${i ? i : 'None'} |${implementation ? ' delivery |' : ''}`), + '', block(implementation ? 'implementation' : 'plan'), + ].join('\n'); + const comment = (id: number, body: string) => ({ + id, body, user: clone(bot), created_at: timestamp, updated_at: timestamp, + issue_url: 'https://api.github.com/repos/owner/repo/issues/42', + html_url: `https://github.com/owner/repo/issues/42#issuecomment-${id}`, }); - - it('reframes the old "prerequisite gap" as solved, not still open', () => { - // The phrase survives as a deliberate callback ("... is never a prerequisite gap"), - // but the old admission that it was an unconfigured limitation must be gone. - expect( - /never a prerequisite gap/i.test(activateProse), - 'The prose must state that a fresh repository with missing labels is no longer a ' + - 'prerequisite gap now that add-labels/create-if-missing is declared.', - ).toBe(true); - - expect( - /not configured in this workflow/i.test(activateProse), - 'The old "not configured in this workflow" admission must be removed along with ' + - 'the gap it described.', - ).toBe(false); + const comments = new Map([[10, comment(10, planBody)]]); + if (implementation) { + comments.set(7, comment(7, block('scope-accepted'))); + comments.set(8, comment(8, block('impl-accepted'))); + } + const issue = (number: number, params: any = {}) => ({ + number, id: number + 1000, node_id: `I_labels_${number}`, + title: 'Same title', body: '', labels: [], updated_at: timestamp, + html_url: `https://github.com/owner/repo/issues/${number}`, + url: `https://api.github.com/repos/owner/repo/issues/${number}`, ...params, }); + const issues = new Map([[42, issue(42, { labels: ['user-owned'] })]]); + const parents = new Map(); + const labels = new Set(); + const calls: Array<{ method: string; params: any }> = []; + const record = (method: string, params: any) => calls.push({ method, params: clone(params) }); + let nextComment = 1000; + let nextIssue = 101; + const api = { + get: vi.fn(async ({ issue_number }: any) => ({ data: clone(issues.get(issue_number)) })), + getComment: vi.fn(async ({ comment_id }: any) => ({ data: clone(comments.get(comment_id)) })), + listComments: vi.fn(async () => ({ data: clone([...comments.values()]) })), + getParent: vi.fn(async ({ issue_number }: any) => ({ data: clone(issues.get(parents.get(issue_number)!)) })), + getLabel: vi.fn(async (params: any) => { + record('getLabel', params); + if (!labels.has(params.name)) throw Object.assign(new Error('Label missing'), { status: 404 }); + return { data: { name: params.name } }; + }), + createLabel: vi.fn(async (params: any) => { + record('createLabel', params); + labels.add(params.name); + return { data: { name: params.name } }; + }), + createComment: vi.fn(async (params: any) => { + record('createComment', params); + const created = comment(nextComment++, params.body); + comments.set(created.id, created); + return { data: clone(created) }; + }), + updateComment: vi.fn(async (params: any) => { + record('updateComment', params); + const current = comments.get(params.comment_id)!; + current.body = params.body; + return { data: clone(current) }; + }), + addSubIssue: vi.fn(async (params: any) => { + record('addSubIssue', params); + const child = [...issues.values()].find(value => value.id === params.sub_issue_id); + parents.set(child.number, params.issue_number); + return { data: clone(child) }; + }), + addLabels: vi.fn(async (params: any) => { record('addLabels', params); }), + }; + const github = { + rest: { issues: api, repos: { getCollaboratorPermissionLevel: vi.fn(async () => ({ data: { permission: 'admin' } })) } }, + paginate: vi.fn(async (method: any, params: any) => (await method(params)).data), + }; + const createIssue = vi.fn(async (params: any) => { + const journal = JSON.parse(readFileSync(journalPath, 'utf8')); + expect(comments.get(journal.pending_comment_id)?.body).toContain('squad-activation-pending/v1'); + expect(journal.started).toHaveLength(journal.receipts.length + 1); + expect(params.labels.every((label: string) => labels.has(label)), 'labels must exist before POST /issues').toBe(true); + record('createIssue', params); + const created = issue(nextIssue++, params); + issues.set(created.number, created); + return { staged: false, issue: clone(created) }; + }); + const prepare = () => prepareActivation({ github, context, env, root, outputPath, capturePath }); + const create = (capture: any) => createActivation({ + github, context, env, root, capture, captureArtifactId: 800, journalPath, createIssue, + sanitizeContent: (value: string) => value, + formatIssueBody: (value: string) => value, + }); + const finalize = (capture: any, journal: any) => finalizeActivation({ + github, context, env, root, capture, journal, receiptArtifactId: 801, + }); + return { root, outputPath, journalPath, context, comments, issues, labels, calls, api, createIssue, prepare, create, finalize }; +} - it('documents that add-labels auto-provisions squad labels on a fresh repository', () => { - expect( - /`add-labels`.*`create-if-missing`.*auto-creates/is.test(activateSkill) || - /create-if-missing.*creates any label/is.test(activateSkill), - 'The Label Pre-flight section must explain that add-labels/create-if-missing ' + - 'auto-provisions squad/squad:{agent} labels, replacing the removed gap language.', - ).toBe(true); - - expect( - /fresh repository/i.test(activateProse), - 'The provisioning explanation should name the fresh-repository case #1955 reported.', - ).toBe(true); +describe('gh-aw: trusted fresh-repository label provisioning', () => { + it.each([false, true])('provisions every distinct label before any flat/granular create (implementation=%s)', async implementation => { + const f = fixture({ implementation }); + const capture = await f.prepare(); + const journal = await f.create(capture); + expect(f.api.getLabel.mock.calls.map(([params]) => params.name)).toEqual(['squad', 'squad:builder', 'squad:reviewer', 'squad:copilot']); + expect(f.api.createLabel.mock.calls.map(([params]) => params)).toEqual( + ['squad', 'squad:builder', 'squad:reviewer', 'squad:copilot'].map(name => ({ + owner: 'owner', repo: 'repo', name, color: '9B8FCC', + })), + ); + const firstCreate = f.calls.findIndex(call => call.method === 'createIssue'); + expect(f.calls.filter(call => call.method === 'getLabel' || call.method === 'createLabel') + .every(call => f.calls.indexOf(call) < firstCreate)).toBe(true); + expect(f.createIssue.mock.calls.map(([params]) => params.labels)).toEqual([ + ...(implementation ? [['squad']] : []), + ['squad', 'squad:builder'], ['squad', 'squad:reviewer'], ['squad', 'squad:copilot'], + ]); + expect(journal.receipts).toHaveLength(implementation ? 4 : 3); + expect(f.api.addLabels).not.toHaveBeenCalled(); + expect(f.issues.get(42).labels).toEqual(['user-owned']); + expect(f.createIssue.mock.calls.every(([params]) => params.temporary_id === undefined && params.item_number === undefined)).toBe(true); }); - it("explains why create-issue's own labels field cannot provision missing labels", () => { - expect( - /silently drops? label/i.test(activateProse), - "The prose must state that create-issue's labels field cannot create missing " + - 'labels — GitHub silently drops names that do not already exist — so a reader ' + - "does not reintroduce reliance on create-issue's labels field alone.", - ).toBe(true); + it.each([false, true])('a single-owner phase/epic receives exactly its certified owner label (implementation=%s)', async implementation => { + const f = fixture({ owners: ['Builder', 'Builder'], phase: !implementation, implementation }); + const capture = await f.prepare(); + await f.create(capture); + expect(capture.operations).toHaveLength(3); + expect(f.createIssue.mock.calls.map(([params]) => params.labels)).toEqual([ + ['squad', 'squad:builder'], ['squad', 'squad:builder'], ['squad', 'squad:builder'], + ]); + expect(f.api.createLabel).toHaveBeenCalledTimes(2); }); - it('calls add_labels immediately after create-issue for epics (2b)', () => { - const epicAt = activateSkill.indexOf('**2b. Create Epic Issues:**'); - const taskAt = activateSkill.indexOf('**2c. Create Task Issues:**'); - expect(epicAt, '"**2b. Create Epic Issues:**" is missing').toBeGreaterThan(-1); - expect(taskAt, '"**2c. Create Task Issues:**" is missing').toBeGreaterThan(epicAt); + it('multi-owner phase retains each row label but assigns the parent base squad only', async () => { + const f = fixture({ phase: true }); + const capture = await f.prepare(); + const journal = await f.create(capture); + expect(capture.operations[0].labels).toEqual(['squad']); + expect(buildActivationBindings(capture, journal).every((binding: any) => + binding.epic_omission_reason === 'multi-owner' && binding.epic_label === null)).toBe(true); + }); - const epicSection = activateSkill.slice(epicAt, taskAt); - expect( - /add_labels/i.test(epicSection), - 'Step 2b must call add_labels on the epic issue after create-issue returns.', - ).toBe(true); - expect( - /verified/i.test(epicSection) && /add_labels/i.test(epicSection), - 'The add_labels call must target the verified (real, returned) issue number, not a predicted one.', - ).toBe(true); + it('uses existing labels without recoloring, recreating, or reapplying them', async () => { + const f = fixture({ owners: ['Builder', 'Builder'] }); + f.labels.add('squad'); + f.labels.add('squad:builder'); + await f.create(await f.prepare()); + expect(f.api.getLabel).toHaveBeenCalledTimes(2); + expect(f.api.createLabel).not.toHaveBeenCalled(); + expect(f.api.addLabels).not.toHaveBeenCalled(); + expect(f.createIssue).toHaveBeenCalledTimes(2); }); - it('calls add_labels immediately after create-issue for tasks (2c), inside the atomic contract', () => { - const taskAt = activateSkill.indexOf('**2c. Create Task Issues:**'); - const selfValidationAt = activateSkill.indexOf('**2d. Self-Validation:**'); - expect(taskAt, '"**2c. Create Task Issues:**" is missing').toBeGreaterThan(-1); - expect(selfValidationAt, '"**2d. Self-Validation:**" is missing').toBeGreaterThan(taskAt); + it.each([401, 403, 429, 500])('does not mistake getLabel HTTP %s for a missing label', async status => { + const f = fixture(); + f.api.getLabel.mockRejectedValueOnce(Object.assign(new Error(`HTTP ${status}`), { status })); + await expect(f.create(await f.prepare())).rejects.toThrow(`HTTP ${status}`); + expect(f.api.createLabel).not.toHaveBeenCalled(); + expect(f.createIssue).not.toHaveBeenCalled(); + }); - const taskSection = activateSkill.slice(taskAt, selfValidationAt); - expect( - /add_labels/i.test(taskSection), - 'Step 2c must call add_labels on the task issue after create-issue returns.', - ).toBe(true); + it.each([403, 500])('does not reserve or create issues when label provisioning fails with HTTP %s, then retries safely', async status => { + const f = fixture(); + const originalComments = clone([...f.comments.entries()]); + const originalOrigin = clone(f.issues.get(42)); + f.api.createLabel.mockRejectedValueOnce(Object.assign(new Error('Label provisioning failed'), { status })); + await expect(f.create(await f.prepare())).rejects.toThrow('Label provisioning failed'); + expect(f.createIssue).not.toHaveBeenCalled(); + expect(f.api.createComment).not.toHaveBeenCalled(); + expect(f.api.addSubIssue).not.toHaveBeenCalled(); + expect(f.api.addLabels).not.toHaveBeenCalled(); + expect(existsSync(f.journalPath)).toBe(false); + expect([...f.comments.entries()]).toEqual(originalComments); + expect([...f.issues.keys()]).toEqual([42]); + + const capture = await f.prepare(); + const journal = await f.create(capture); + expect(journal.started).toEqual(capture.operations.map((operation: any) => operation.id)); + expect(journal.receipts).toHaveLength(capture.operations.length); + const reservation = f.calls.findIndex(call => call.params.body?.includes('squad-activation-pending/v1')); + expect(reservation).toBeGreaterThanOrEqual(0); + expect(reservation).toBeLessThan(f.calls.findIndex(call => call.method === 'createIssue')); + expect(f.comments.get(journal.pending_comment_id)?.body).toContain('squad-activation-pending/v1'); + await f.finalize(capture, journal); + expect(await f.prepare()).toHaveProperty('existingAcceptanceId'); + expect(f.createIssue).toHaveBeenCalledTimes(capture.operations.length); + expect(new Set(journal.receipts.map((receipt: any) => receipt.number)).size).toBe(capture.operations.length); + expect(f.issues.size).toBe(capture.operations.length + 1); + expect(f.issues.get(42)).toEqual(originalOrigin); + }); - const atomicAt = taskSection.indexOf('ATOMIC CONTRACT'); - expect(atomicAt, '"ATOMIC CONTRACT" block is missing from Step 2c').toBeGreaterThan(-1); - const atomicLine = taskSection.slice(atomicAt, atomicAt + 400); - expect( - /add_labels/i.test(atomicLine), - 'The ATOMIC CONTRACT (one compose → one create-issue call → one verify) must be ' + - 'extended to include the add_labels call, so the model cannot buffer multiple ' + - 'tasks before labeling them.', - ).toBe(true); + it.each([false, true])('rejects non-roster creation before reservation or label writes (implementation=%s)', async implementation => { + const f = fixture({ owners: ['Builder', 'Visitor'], implementation }); + await expect((async () => f.create(await f.prepare()))()).rejects.toThrow(/non-roster|uncertified|owner/i); + expect(f.api.createComment).not.toHaveBeenCalled(); + expect(f.api.getLabel).not.toHaveBeenCalled(); + expect(f.api.createLabel).not.toHaveBeenCalled(); + expect(f.createIssue).not.toHaveBeenCalled(); }); - it('forbids the invalid "wait for a returned real issue number" contract (#1962)', () => { - // The original version of this rule told the model to wait until create-issue - // "returned and been verified" before calling add_labels. gh-aw never returns a real - // issue number to the agent — creation is deferred to the safe-output job — so that - // instruction was unsatisfiable and is exactly the defect #1962 corrects. The prose - // must now say the opposite: do not wait, because no number arrives. - expect( - /never call `add_labels` before/i.test(activateProse), - 'The old "never call add_labels before create-issue has returned a real issue ' + - 'number" rule must be gone — it encoded the invalid assumption #1962 fixes.', - ).toBe(false); + it('rejects duplicate stable tasks before writes despite different titles', async () => { + const f = fixture(); + f.comments.get(10)!.body = f.comments.get(10)!.body.replace('| 2 | Same title |', '| 1 | Different title |'); + await expect(f.prepare()).rejects.toThrow(/stable task IDs.*duplicated/); + expect(f.calls).toEqual([]); + }); - expect( - /do not wait for a returned issue number/i.test(activateProse), - 'The prose must explicitly tell the model NOT to wait for a returned issue ' + - 'number, since gh-aw never provides one during the run.', - ).toBe(true); + it('refuses an unreadable frozen roster before writes', async () => { + const f = fixture(); + writeFileSync(join(f.root, '.squad/team.md'), 'No roster table'); + await expect(f.prepare()).rejects.toThrow(); + expect(f.calls).toEqual([]); + }); - expect( - /does \*\*not\*\* return a real GitHub issue number/i.test(activateProse), - 'The Hallucination Guard must state plainly that create-issue does not return a ' + - 'real issue number during the run.', - ).toBe(true); + it('revalidates the frozen roster before provisioning labels', async () => { + const f = fixture(); + const capture = await f.prepare(); + writeFileSync(join(f.root, '.squad/team.md'), '## Members\n| Name | Role |\n|---|---|\n| Visitor | Engineer |'); + await expect(f.create(capture)).rejects.toThrow(/frozen registry\/roster changed/); + expect(f.calls).toEqual([]); }); - it('preserves the roster-binding correspondence rules unchanged (#1859, #1860)', () => { - // This is a narrow smoke check; gh-aw-agent-binding-correspondence.test.ts is the - // authoritative lock for these rules. This test only guards against this change - // having silently damaged them while rewriting the surrounding label prose. - expect(activateProse).toMatch(/own\s+`Agent`\s+cell/i); - expect(activateProse).toMatch(/never inherit the parent epic/i); - expect(activateProse).toMatch(/carry the previous task/i); - expect(activateProse).toMatch(/two or more/i); - expect(activateProse).toMatch(/never\s+choose\s+one\s+of\s+several/i); - expect(activateProse).toMatch(/simultaneously certified and wrong/i); - expect(activateProse).toMatch(/Non-roster agent values` heading is \*\*required\*\*/i); + it('does not title-deduplicate distinct stable work or substitute a pre-existing issue', async () => { + const f = fixture({ owners: ['Builder', 'Builder'] }); + f.issues.set(88, { ...clone(f.issues.get(42)), number: 88, labels: ['unrelated'] }); + const capture = await f.prepare(); + const journal = await f.create(capture); + expect(f.createIssue).toHaveBeenCalledTimes(2); + expect(journal.receipts.map((receipt: any) => receipt.number)).toEqual([101, 102]); + expect(f.issues.get(88).labels).toEqual(['unrelated']); + expect(f.createIssue.mock.calls[1][0].body).toContain('**Depends On:** #101'); }); - it('keeps rerun idempotency: reapplying labels on a rerun is a documented no-op', () => { - expect( - /re-applying an already-present label on a rerun is a no-op/i.test(activateProse), - 'Rerun idempotency must be explicit: title-match dedup already skips recreating ' + - 'issues, and add_labels must be safe to call again for issues that already carry ' + - 'the label (add-only GraphQL merge semantics).', - ).toBe(true); - // Pre-existing idempotent-rerun contract (Step 2d) must remain intact. - expect(activateProse).toMatch(/Re-runs are idempotent via title match/i); + it('never retries an ambiguous create and blocks rerun label writes with the durable reservation', async () => { + const f = fixture(); + const capture = await f.prepare(); + f.createIssue.mockRejectedValueOnce(new Error('Connection lost after POST')); + await expect(f.create(capture)).rejects.toThrow('Connection lost after POST'); + const journal = JSON.parse(readFileSync(f.journalPath, 'utf8')); + expect(journal.started).toEqual(['task:1']); + expect(journal.receipts).toEqual([]); + expect(f.createIssue).toHaveBeenCalledTimes(1); + const callCount = f.calls.length; + await expect(f.create(capture)).rejects.toThrow(/reserved or accepted/); + await expect(f.prepare()).rejects.toThrow(/pending activation/); + expect(f.calls).toHaveLength(callCount); + expect(f.createIssue).toHaveBeenCalledTimes(1); }); - it('extends the same provisioning pattern to the squad-plan-accept fast path (#1959)', () => { - const acceptStart = workflow.indexOf('## skill: `squad-plan-accept`'); - expect(acceptStart, '"## skill: `squad-plan-accept`" is missing from workflows/squad.md').toBeGreaterThan(-1); - // squad-plan-accept has no "## end skill" marker of its own; its body runs until the - // next "## skill:" heading (squad-plan-revise). - const acceptEnd = workflow.indexOf('## skill: `squad-plan-revise`'); - expect(acceptEnd, '"## skill: `squad-plan-revise`" is missing from workflows/squad.md').toBeGreaterThan(acceptStart); + it('requires live labels, not merely the labels intended in a create call, before acceptance', async () => { + const f = fixture(); + const capture = await f.prepare(); + const journal = await f.create(capture); + f.issues.get(journal.receipts[0].number).labels = ['squad']; + const priorComments = f.api.createComment.mock.calls.length; + await expect(f.finalize(capture, journal)).rejects.toThrow(/expected squad:builder, found bare squad/); + expect(f.api.createComment).toHaveBeenCalledTimes(priorComments); + expect(f.api.updateComment).not.toHaveBeenCalled(); + }); - const acceptSkill = workflow.slice(acceptStart, acceptEnd); - // #1955 landed provisioning in squad-plan-activate only. #1959 applied the same - // add_labels + create-if-missing pattern to the fast path, so the two activation - // paths now provision labels identically. Assert the parity rather than the old gap. - expect(acceptSkill).toContain('add_labels'); - expect(acceptSkill).toContain('create-if-missing'); + it('publishes actual labels only after live verification and does not relabel on a completed rerun', async () => { + const f = fixture({ implementation: true }); + const capture = await f.prepare(); + const journal = await f.create(capture); + await f.finalize(capture, journal); + const acceptance = [...f.comments.values()].find(comment => comment.body.includes('Activation bindings:'))!; + const bindings = JSON.parse(acceptance.body.split('Activation bindings:\n```json\n')[1].split('\n```')[0]); + expect(bindings.map((binding: any) => binding.label)).toEqual(['squad:builder', 'squad:reviewer', 'squad:copilot']); + expect(bindings.every((binding: any) => binding.epic_omission_reason === 'multi-owner')).toBe(true); + const callCount = f.calls.length; + await expect(f.prepare()).resolves.toEqual({ existingAcceptanceId: acceptance.id }); + expect(f.calls).toHaveLength(callCount); + expect(f.api.addLabels).not.toHaveBeenCalled(); }); }); -// --------------------------------------------------------------------------- -// Compiled-artifact verification -// --------------------------------------------------------------------------- -// -// The assertions above lock the prose contract. This section proves the prose is -// backed by a real, compilable safe-output: `gh aw compile --strict` must wire -// `create_if_missing: true` into the add_labels handler config, and the add_labels -// tool must be exposed to the agent. Fails closed (never skips) per this repo's -// #1833/#1834 convention: an unmeasured contract is indistinguishable from a -// violated one. - -const GH_AW_INSTALL_HINT = - '`gh aw` is required to compile the workflow this gate inspects. Install it with ' + - '`gh extension install github/gh-aw` (matches .github/workflows/squad-ci.yml). This ' + - 'gate fails closed rather than skipping: an unmeasured contract is indistinguishable ' + - 'from a violated one (#1834).'; - -describe('gh-aw: add-labels compiles into the real safe-output handler config (#1955)', () => { +describe('gh-aw: retained native add-labels compiled contract', () => { let compiledLock: string | null = null; - - function lockText(): string { - if (compiledLock !== null) return compiledLock; - - const versionProbe = spawnSync('gh', ['aw', '--version'], { encoding: 'utf8' }); - if (versionProbe.status !== 0) { - throw new Error(`gh aw --version failed. ${GH_AW_INSTALL_HINT}`); - } - - mkdirSync(TEST_WORKSPACES_DIR, { recursive: true }); - const workspace = mkdtempSync(join(TEST_WORKSPACES_DIR, 'add-labels-')); - execFileSync('git', ['init', '--quiet'], { cwd: workspace }); - // gh-aw's dispatch-workflow validation resolves cross-workflow references against - // a `.github/workflows/` directory located relative to the compiled file. This repo - // ships the gh-aw *source* from a top-level `workflows/` dir; downstream consumers - // install it into `.github/workflows/` via `gh aw add`. Mirror that real deployment - // layout, matching .github/workflows/squad-ci.yml's own compile gate. - cpSync(WORKFLOWS_DIR, join(workspace, '.github', 'workflows'), { recursive: true }); - execFileSync('gh', ['aw', 'compile', '.github/workflows/squad.md', '--strict', '--approve', '--no-check-update'], { - cwd: workspace, - encoding: 'utf8', - stdio: 'pipe', - }); - - const lockPath = join(workspace, '.github', 'workflows', 'squad.lock.yml'); - if (!existsSync(lockPath)) { - throw new Error( - `gh aw compile produced no squad.lock.yml — the compiled artifact this gate ` + - `inspects is absent, so the contract is unmeasured. ${GH_AW_INSTALL_HINT}`, - ); - } - compiledLock = readText(lockPath); - return compiledLock; + function compile(source = workflow): string { + const compiler = process.env.SQUAD_GH_AW_BIN ?? 'gh'; + const prefix = process.env.SQUAD_GH_AW_BIN ? [] : ['aw']; + const version = spawnSync(compiler, [...prefix, '--version'], { encoding: 'utf8' }); + expect(version.status, 'Supported gh-aw is required; never skip this gate').toBe(0); + expect(`${version.stdout}\n${version.stderr}`).toContain('v0.91.5'); + const root = workspace('compile-'); + execFileSync('git', ['init', '--quiet'], { cwd: root }); + cpSync(WORKFLOWS_DIR, join(root, '.github/workflows'), { recursive: true }); + writeFileSync(join(root, '.github/workflows/squad.md'), source); + execFileSync(compiler, [...prefix, 'compile', '.github/workflows/squad.md', '--strict', '--no-check-update'], + { cwd: root, encoding: 'utf8', stdio: 'pipe' }); + const lockPath = join(root, '.github/workflows/squad.lock.yml'); + expect(existsSync(lockPath)).toBe(true); + return readFileSync(lockPath, 'utf8').replace(/\r\n/g, '\n'); } - - it('strict-compiles with add-labels declared', () => { - expect(lockText().length).toBeGreaterThan(0); + const lockText = () => compiledLock ??= compile(); + const provisioningContract = (lock: string) => { + const decoded = lock.replace(/\\"/g, '"'); + expect(decoded).toMatch(/"add_labels":\{[^}]*"create_if_missing":true/); + expect(decoded).toMatch(/"add_labels":\{[^}]*"allowed":\["squad","squad:\*"\]/); + }; + + it('strict-compiles retained native label creation without inventing a create-label safe output', () => { + expect(workflow).not.toContain('create-label:'); + provisioningContract(lockText()); }); - - it('wires create_if_missing: true and the allowed squad label patterns into the handler config', () => { - // gh-aw v0.87.x (this fix's required pin bump) emits this JSON config only - // embedded inside a double-quoted YAML env var value, with every `"` - // backslash-escaped. De-escape before matching so this holds regardless of a - // given compiler version's quoting choice. - const compiled = lockText().replace(/\\"/g, '"'); - expect(compiled).toContain('"add_labels"'); - expect(compiled).toMatch(/"add_labels":\{[^}]*"create_if_missing":true/); - expect(compiled).toMatch(/"add_labels":\{[^}]*"allowed":\["squad","squad:\*"\]/); + it('still exposes native add_labels and create_issue for non-activation commands', () => { + expect(lockText()).toMatch(/"tools":\[[^\]]*"add_labels"[^\]]*\]/); + expect(lockText()).toMatch(/"tools":\[[^\]]*"create_issue"[^\]]*\]/); }); - - it('exposes add_labels as a tool the agent can call, alongside create_issue', () => { + it('keeps the agent job read-only even though the trusted write job can provision labels', () => { const compiled = lockText(); - expect(compiled).toMatch(/"tools":\[[^\]]*"add_labels"[^\]]*\]/); - expect(compiled).toMatch(/"tools":\[[^\]]*"create_issue"[^\]]*\]/); + const start = compiled.indexOf('\n agent:\n'); + const end = compiled.indexOf('\n safe_outputs:\n'); + expect(start).toBeGreaterThan(-1); + expect(end).toBeGreaterThan(start); + const permissions = compiled.slice(start, end).match(/permissions:\n((?:\s{6}\S.*\n)+)/)?.[1]; + expect(permissions).toContain('issues: read'); + expect(permissions).not.toContain('issues: write'); }); - - it("does not broaden the main agent job's permissions — writes stay in the safe-output job", () => { - const compiled = lockText(); - const agentJobAt = compiled.indexOf('\n agent:\n'); - const safeOutputsJobAt = compiled.indexOf('\n safe_outputs:\n'); - expect(agentJobAt, '"agent:" job is missing from the compiled lock file').toBeGreaterThan(-1); - expect(safeOutputsJobAt, '"safe_outputs:" job is missing from the compiled lock file').toBeGreaterThan(agentJobAt); - - const agentJob = compiled.slice(agentJobAt, safeOutputsJobAt); - // The agent job's own permissions block (the first "permissions:" after the job - // name) must remain issues: read — write access lives only in the safe_outputs job. - const permsMatch = agentJob.match(/permissions:\n((?:\s{6}\S.*\n)+)/); - expect(permsMatch, 'agent job permissions block not found').not.toBeNull(); - const permsBlock = permsMatch ? permsMatch[1] : ''; - expect(permsBlock).toContain('issues: read'); - expect(permsBlock).not.toContain('issues: write'); + it('detects a realistic mutation disabling native missing-label provisioning', () => { + const mutated = workflow.replace('create-if-missing: true', 'create-if-missing: false'); + expect(mutated).not.toBe(workflow); + const mutatedLock = compile(mutated); + expect(() => provisioningContract(mutatedLock)).toThrow(); }); }); diff --git a/test/gh-aw-activation-summary-outcomes.test.ts b/test/gh-aw-activation-summary-outcomes.test.ts index db6cb958c..d9d504fa1 100644 --- a/test/gh-aw-activation-summary-outcomes.test.ts +++ b/test/gh-aw-activation-summary-outcomes.test.ts @@ -1,416 +1,439 @@ /** - * Activation summaries report actual accepted label-operation outcomes (#1963). - * - * Parent: #1957. Stacks on #1962 (`gh-aw-activation-temporary-ids.test.ts`, temporary-ID - * targeting for `/squad plan activate`) and #1959 - * (`gh-aw-activate-fast-path-label-provisioning.test.ts`, the `/squad activate` fast path's - * own `add_labels` provisioning). Those two made the label *operations* correct. They did - * not make the *summary* correct. - * - * Two defects remained, one per activation path, plus a latent data-contract bug: - * - * 1. Over-claim by attribution. Both paths permitted naming a `squad:{agent}` label once - * `create-issue` "returned successfully carrying it". `create-issue`'s `labels:` field - * cannot land a label GitHub does not already have — the exact failure #1959 fixed — - * so that sentence licensed reporting labels that were never applied. A label is now - * reportable only when an accepted `add_labels` call carried it for that same issue. - * - * 2. Invalid `Activation bindings:` JSON. The block required bare `{created task issue - * number}` / `{created epic issue number}`. The agent cannot know a created issue's - * real number during its turn, and gh-aw's temporary-ID substitution is a plain text - * replacement over the whole comment body that does *not* skip fenced code blocks and - * *keeps* the `#`. A bare `"issue":#aw_task1` therefore becomes `"issue":#42` — - * invalid JSON that fails the entire block. Verified empirically against the pinned - * runtime (`github/gh-aw-actions@v0.87.2`, `setup/js/temporary_id.cjs`). Quoting is - * the narrowest correct fix: `"issue":"#aw_task1"` → `"issue":"#42"`, which parses. - * - * 3. Unresolved references were undefined behavior. An `#aw_…` surviving substitution - * means that `create-issue` never landed. The checker now fails closed on it instead - * of coercing, skipping, or repairing it. - * - * The runtime limitation this suite deliberately encodes: safe outputs are applied in a - * post-agent job, so an activation run has evidence only that a call was *accepted for a - * specific target* — never the GitHub API result. "Accepted" is the strongest honest claim. - * These tests fail on any summary language that claims verification the runtime cannot - * provide, in either direction (over-claim or silent under-claim). - * - * Out of scope: operation-capacity policy (#1961) beyond compatibility, the broad - * behavioral contract suite (#1960), checker distribution, post-activation implementation, - * and E4 (#1958). + * #1963: summaries describe actual per-issue outcomes. The model cannot observe + * queued native writes; the trusted synchronous writer can, and must validate + * live labels plus actual receipts before publishing acceptance. */ - -import { afterAll, describe, it, expect } from 'vitest'; -import { cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'; -import { execFileSync, spawnSync } from 'node:child_process'; +import { afterAll, describe, it, expect, vi } from 'vitest'; +import { cpSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { execFileSync } from 'node:child_process'; import { join } from 'node:path'; -import { parseRoster, validateBindings } from '../scripts/check-agent-binding.mjs'; +import { parse as parseYaml } from 'yaml'; +import { + activationRequest, createActivation, digest, finalizeActivation, prepareActivation, + validateCreateReceipt, +} from '../workflows/shared/squad-activation.mjs'; +import { parseRoster, parseStructuredData, validateBindings } from '../scripts/check-agent-binding.mjs'; const WORKFLOWS_DIR = join(process.cwd(), 'workflows'); -const SQUAD_WORKFLOW = join(WORKFLOWS_DIR, 'squad.md'); -const ONTOLOGY = join(WORKFLOWS_DIR, 'shared', 'squad-planning-ontology.md'); const TEST_WORKSPACES_DIR = join(process.cwd(), '.test-workspaces-activation-summary'); +afterAll(() => rmSync(TEST_WORKSPACES_DIR, { recursive: true, force: true })); -afterAll(() => { - rmSync(TEST_WORKSPACES_DIR, { recursive: true, force: true }); -}); - -/** Read a text file with line endings normalized to LF (Windows checkouts materialize CRLF). */ -function readText(filePath: string): string { - return readFileSync(filePath, 'utf8').replace(/\r\n/g, '\n'); +function workspace(prefix: string) { + mkdirSync(TEST_WORKSPACES_DIR, { recursive: true }); + return mkdtempSync(join(TEST_WORKSPACES_DIR, prefix)); +} +const readText = (path: string) => readFileSync(path, 'utf8').replace(/\r\n/g, '\n'); +const workflow = readText(join(WORKFLOWS_DIR, 'squad.md')); +const ontology = readText(join(WORKFLOWS_DIR, 'shared', 'squad-planning-ontology.md')).replace(/\s+/g, ' '); +const block = (heading: string, value: unknown) => `${heading}:\n\`\`\`json\n${JSON.stringify(value)}\n\`\`\``; +const timestamp = '2026-01-01T00:00:00Z'; +const bot = { id: 41898282, login: 'github-actions[bot]', type: 'Bot' }; +const operator = { id: 700, login: 'operator', type: 'User' }; +const paths = ['activate', 'plan accept', 'plan activate'] as const; + +function fixture(command: string = 'activate', secondOwner = 'Kint') { + const root = workspace('runtime-'); + mkdirSync(join(root, '.squad/casting'), { recursive: true }); + const registry = { + schema: 'squad-agent-provenance/v1', schema_version: 1, revision: 1, + generated_at: timestamp, transaction_id: 'summary-fixture', + agents: Object.fromEntries(['Kint', 'McManus'].map(name => [name.toLowerCase(), { + display_name: name, persistent_name: name, role: 'Engineer', universe: 'synthetic', + status: 'active', created_at: timestamp, updated_at: timestamp, + }])), + }; + const history = { + transaction_id: registry.transaction_id, registry_revision: 1, + assignment_cast_snapshots: { genesis: { created_at: timestamp, agents: ['kint', 'mcmanus'], universe: 'synthetic' } }, + universe_usage_history: [{ universe: 'synthetic', used_at: timestamp }], + }; + const registryRaw = JSON.stringify(registry); + const historyRaw = JSON.stringify(history); + writeFileSync(join(root, '.squad/casting/registry.json'), registryRaw); + writeFileSync(join(root, '.squad/casting/history.json'), historyRaw); + writeFileSync(join(root, '.squad/casting/registry-history.commit.json'), JSON.stringify({ + transaction_id: registry.transaction_id, registry_revision: 1, + registry_sha256: digest(registryRaw), history_sha256: digest(historyRaw), + })); + writeFileSync(join(root, '.squad/team.md'), + '## Members\n| Name | Role |\n|---|---|\n| Kint | Lead |\n| McManus | Engineer |\n'); + const outputPath = join(root, 'request.json'); + writeFileSync(outputPath, JSON.stringify({ items: [{ type: 'activate_plan', plan_comment_id: '10' }] })); + const context = { + repo: { owner: 'owner', repo: 'repo' }, actor: operator.login, + payload: { action: 'created', sender: operator, issue: { number: 42, user: operator }, + comment: { id: 9, body: `/squad ${command}`, user: operator } }, + }; + const env = { GITHUB_EVENT_NAME: 'issue_comment', GITHUB_RUN_ID: '900', GITHUB_RUN_ATTEMPT: '1', + SQUAD_WORKFLOW_SHA: 'a'.repeat(40) }; + const granular = command === 'plan activate'; + const envelope = (kind: string) => ({ squad_artifact: kind, schema_version: '1', origin_issue: 42, phases: [] }); + const comment = (id: number, body: string) => ({ + id, body, user: bot, created_at: timestamp, updated_at: timestamp, + issue_url: 'https://api.github.com/repos/owner/repo/issues/42', + html_url: `https://github.com/owner/repo/issues/42#issuecomment-${id}`, + }); + const planBody = [ + `| # | Title | ${granular ? 'Agent' : 'Owner'} | Agent ID | Depends On |${granular ? ' Epic |' : ''}`, + `|---|---|---|---|---|${granular ? '---|' : ''}`, + `| 1 | Same title | Kint | kint | None |${granular ? ' epic1 |' : ''}`, + `| 2 | Same title | ${secondOwner} | ${secondOwner === '@copilot' ? '-' : secondOwner.toLowerCase()} | 1 |${granular ? ' epic1 |' : ''}`, + block('Structured data', envelope(granular ? 'implementation' : 'plan')), + ].join('\n'); + const comments = new Map([[10, comment(10, planBody)]]); + if (granular) { + comments.set(7, comment(7, block('Structured data', envelope('scope-accepted')))); + comments.set(8, comment(8, block('Structured data', envelope('impl-accepted')))); + } + const issue = (number: number, values: Record = {}) => ({ + number, id: number + 1000, node_id: `I_${number}`, body: '', labels: [], + url: `https://api.github.com/repos/owner/repo/issues/${number}`, + html_url: `https://github.com/owner/repo/issues/${number}`, ...values, + }); + const issues = new Map([[42, issue(42)]]); + const parents = new Map(); + const events: string[] = []; + const rest = { + repos: { getCollaboratorPermissionLevel: vi.fn(async () => ({ data: { permission: 'admin' } })) }, + issues: { + get: vi.fn(async ({ issue_number }: any) => { + events.push(`read:${issue_number}`); + return { data: structuredClone(issues.get(issue_number)) }; + }), + getComment: vi.fn(async ({ comment_id }: any) => ({ data: structuredClone(comments.get(comment_id)) })), + listComments: vi.fn(async () => ({ data: structuredClone([...comments.values()]) })), + getLabel: vi.fn(async () => { throw Object.assign(new Error('Missing'), { status: 404 }); }), + createLabel: vi.fn(async () => ({ data: {} })), + addSubIssue: vi.fn(async ({ issue_number, sub_issue_id }: any) => { + const child = [...issues.values()].find(issue => issue.id === sub_issue_id); + if (!child) throw new Error('Unknown child issue'); + parents.set(child.number, issue_number); + return { data: {} }; + }), + getParent: vi.fn(async ({ issue_number }: any) => { + const parent = parents.get(issue_number); + if (parent === undefined) throw Object.assign(new Error('Parent unavailable'), { status: 404 }); + return { data: structuredClone(issues.get(parent)) }; + }), + createComment: vi.fn(async ({ body }: any) => { + const value = comment(1000 + comments.size, body); + events.push(`publish:${parseStructuredData(body)?.squad_artifact ?? 'pending'}`); + comments.set(value.id, value); + return { data: structuredClone(value) }; + }), + updateComment: vi.fn(async ({ comment_id, body }: any) => { + comments.get(comment_id)!.body = body; + return { data: structuredClone(comments.get(comment_id)) }; + }), + }, + }; + const github = { rest, paginate: vi.fn(async (method: any, params: any) => (await method(params)).data) }; + const createIssue = vi.fn(async (params: any) => { + const value = issue(100 + issues.size, params); + events.push(`create:${value.number}`); + issues.set(value.number, structuredClone(value)); + return { staged: false, issue: value }; + }); + const prepare = () => prepareActivation({ github, context, env, root, outputPath, capturePath: join(root, 'capture.json') }); + const create = (capture: any) => createActivation({ + github, context, env, root, capture, captureArtifactId: 800, createIssue, + journalPath: join(root, 'journal.json'), sanitizeContent: (value: string) => value, + formatIssueBody: (value: string) => value, + }); + const finalize = (capture: any, journal: any, receiptArtifactId = 801) => + finalizeActivation({ github, context, env, root, capture, journal, receiptArtifactId }); + const accepted = () => [...comments.values()].find(item => + ['plan-accepted', 'activated'].includes(parseStructuredData(item.body)?.squad_artifact)); + return { prepare, create, finalize, accepted, comments, issues, events, github, createIssue }; } -const workflow = readText(SQUAD_WORKFLOW); -const ontologyProse = readText(ONTOLOGY).replace(/\s+/g, ' '); - -/** `squad-plan-activate` — the `/squad plan activate` path. Has an explicit end marker. */ -const ACTIVATE_START = workflow.indexOf('## skill: `squad-plan-activate`'); -const ACTIVATE_END = workflow.indexOf('## end skill: `squad-plan-activate`'); -expect(ACTIVATE_START, '"## skill: `squad-plan-activate`" is missing from workflows/squad.md').toBeGreaterThan(-1); -expect(ACTIVATE_END, '"## end skill: `squad-plan-activate`" is missing').toBeGreaterThan(ACTIVATE_START); -const activateSkill = workflow.slice(ACTIVATE_START, ACTIVATE_END); -const activateProse = activateSkill.replace(/\s+/g, ' '); +describe('gh-aw: trusted summaries report live outcomes, not intent (#1963)', () => { + it('routes both public activation skills and the compatibility alias to one exact request', () => { + expect(workflow).toContain('| `activate` | `squad-plan-accept` |'); + expect(workflow).toContain('| `plan activate` | `squad-plan-activate` |'); + expect(activationRequest([{ type: 'activate_plan', plan_comment_id: '10' }])).toBe(10); + for (const type of ['create_issue', 'add_labels', 'add_comment', 'upsert_lifecycle_state']) { + expect(() => activationRequest([ + { type: 'activate_plan', plan_comment_id: '10' }, { type }, + ])).toThrow(/exactly one activate_plan/); + } + }); -/** - * `squad-plan-accept` — the recommended `/squad activate` fast path. It has no - * `## end skill:` marker, so its body runs to the next `## skill:` heading. - */ -const ACCEPT_START = workflow.indexOf('## skill: `squad-plan-accept`'); -const ACCEPT_END = workflow.indexOf('## skill: `squad-plan-revise`'); -expect(ACCEPT_START, '"## skill: `squad-plan-accept`" is missing from workflows/squad.md').toBeGreaterThan(-1); -expect(ACCEPT_END, '"## skill: `squad-plan-revise`" is missing from workflows/squad.md').toBeGreaterThan(ACCEPT_START); -const acceptSkill = workflow.slice(ACCEPT_START, ACCEPT_END); -const acceptProse = acceptSkill.replace(/\s+/g, ' '); + it.each(paths)('%s: a successful create is not evidence that its requested labels landed', async command => { + const f = fixture(command); + const capture = await f.prepare(); + const journal = await f.create(capture); + f.issues.get(journal.receipts.at(-1).number).labels = ['squad']; + await expect(f.finalize(capture, journal)).rejects.toThrow(/expected squad:kint/); + expect(f.accepted()).toBeUndefined(); + }); -/** Both activation paths, so parity assertions cannot pass by covering only one. */ -const BOTH_PATHS: ReadonlyArray = [ - ['/squad plan activate (squad-plan-activate)', activateProse], - ['/squad activate fast path (squad-plan-accept)', acceptProse], -]; + it.each(paths)('%s: checks the same issue, not another operation with the right label', async command => { + const f = fixture(command, 'McManus'); + const capture = await f.prepare(); + const journal = await f.create(capture); + const tasks = journal.receipts.filter((receipt: any) => receipt.operation_id.startsWith('task:')); + f.issues.get(tasks[0].number).labels = ['squad', 'squad:mcmanus']; + f.issues.get(tasks[1].number).labels = ['squad', 'squad:kint']; + await expect(f.finalize(capture, journal)).rejects.toThrow(/expected squad:kint/); + expect(f.accepted()).toBeUndefined(); + }); -describe('gh-aw: activation summaries report accepted outcomes, not intent (#1963)', () => { - it('routes both activation commands to the two skills this suite inspects', () => { - // If routing moves, every parity assertion below is inspecting the wrong prose. - expect(workflow).toContain('| `activate` | `squad-plan-accept` |'); - expect(workflow).toContain('| `plan activate` | `squad-plan-activate` |'); + it('never accepts the old create-returned-successfully attribution without live readback', async () => { + const f = fixture(); + const capture = await f.prepare(); + const journal = await f.create(capture); + f.github.rest.issues.get.mockRejectedValueOnce(new Error('API read unavailable')); + await expect(f.finalize(capture, journal)).rejects.toThrow('API read unavailable'); + expect(f.accepted()).toBeUndefined(); }); - it.each(BOTH_PATHS)( - '%s: refuses to treat a successful create-issue as label evidence', - (_path, prose) => { - expect( - prose, - 'A summary that credits create-issue for a label reports work that may never have ' + - 'happened: create-issue silently drops label names the repository lacks (#1959). ' + - 'Each path must state that create-issue is not evidence.', - ).toMatch(/A successful `create-issue` is \*\*not\*\* evidence/); - expect(prose).toMatch(/`labels:` field cannot land a label on a fresh repository/); - }, - ); + it.each(paths)('%s: publishes only after every live label is read back', async command => { + const f = fixture(command); + const capture = await f.prepare(); + const journal = await f.create(capture); + f.events.length = 0; + const evidence = await f.finalize(capture, journal); + expect(evidence.basis).toBe('current-trusted-execution'); + const publication = f.events.findIndex(event => /^publish:(?:activated|plan-accepted)$/.test(event)); + expect(publication).toBeGreaterThan(-1); + for (const receipt of journal.receipts) { + expect(f.events.indexOf(`read:${receipt.number}`)).toBeGreaterThan(-1); + expect(f.events.indexOf(`read:${receipt.number}`)).toBeLessThan(publication); + } + }); - it.each(BOTH_PATHS)( - '%s: conditions every reported label on an accepted add_labels call for that same issue', - (_path, prose) => { - expect( - prose, - 'The reportable condition must be an accepted add_labels operation targeting the ' + - 'same issue — not a computed value, not another item\'s call.', - ).toMatch(/`add_labels` call (?:that )?carr(?:ied|ying) that label(?: and targeting| and targeted)? that same issue|`add_labels` call carrying that label was accepted for that same issue/); - expect( - prose, - 'Targeting must name both forms: temporary ID for a created issue, verified real ' + - 'number for a reused one.', - ).toMatch(/by its own `temporary_id`, or by its verified real number for a reused issue/); - }, - ); + it.each(paths)('%s: does not under-report a successfully observed owner label', async command => { + const f = fixture(command); + const capture = await f.prepare(); + const journal = await f.create(capture); + await f.finalize(capture, journal); + const artifact = parseStructuredData(f.accepted()!.body); + expect(artifact.bindings).toHaveLength(2); + for (const binding of artifact.bindings) { + expect(binding.label).toBe('squad:kint'); + expect(binding.omission_reason).toBeUndefined(); + } + }); - it('removes the create-issue attribution that Label Pre-flight Step 8 used to license', () => { - // The exact pre-#1963 sentence. Its survival anywhere means the over-claim is still - // reachable, regardless of what was added elsewhere. - expect( - activateProse, - 'Label Pre-flight Step 8 previously allowed naming a label once "that issue\'s ' + - 'create-issue call returned successfully carrying it". That is the root over-claim.', - ).not.toMatch(/`create-issue` call returned\s*successfully carrying it/); - expect(activateProse).not.toMatch(/create-issue` call returned successfully carrying it/); + it.each(paths)('%s: reports external identity omissions against the exact task', async command => { + const f = fixture(command, '@copilot'); + const capture = await f.prepare(); + const journal = await f.create(capture); + await f.finalize(capture, journal); + const bindings = parseStructuredData(f.accepted()!.body).bindings; + expect(bindings[1]).toMatchObject({ + task: '2', agent: '@copilot', agent_id: null, + identity_omission_reason: 'external-agent', label: 'squad:copilot', + }); + expect(bindings[0].identity_omission_reason).toBeUndefined(); + if (command === 'plan activate') { + expect(bindings[1]).toMatchObject({ epic_label: null, epic_omission_reason: 'multi-owner' }); + } }); - it.each(BOTH_PATHS)( - '%s: claims acceptance only, never verification the runtime cannot provide', - (_path, prose) => { - expect( - prose, - 'Safe outputs are applied after the agent turn. A run that says it verified or ' + - 'confirmed a label on the issue is claiming observability gh-aw does not offer.', - ).toMatch(/never write that a label was (?:"verified", "confirmed on the issue", or\s*"checked"|verified, confirmed, or checked)/); - expect(prose).toMatch(/nothing here reads labels back/); - }, - ); + it.each(paths)('%s: rejects unknown owners before mutation rather than silently omitting their labels', async command => { + const f = fixture(command, 'Visitor'); + await expect(f.prepare()).rejects.toThrow(/unknown non-roster owner/); + expect(f.createIssue).not.toHaveBeenCalled(); + expect(f.github.rest.issues.createComment).not.toHaveBeenCalled(); + }); - it.each(BOTH_PATHS)('%s: rejects under-claiming as well as over-claiming', (_path, prose) => { - expect( - prose, - 'Reporting an omission for an item whose add_labels call was accepted manufactures a ' + - 'defect that did not occur — the mirror image of the over-claim, equally wrong.', - ).toMatch(/never (?:report an accepted operation as an omission|emit the heading for an owner that\s*\*did\* become an accepted label)/); + it('makes an intentionally omitted multi-owner epic label visible in the human summary', async () => { + const f = fixture('plan activate', 'McManus'); + const capture = await f.prepare(); + const journal = await f.create(capture); + await f.finalize(capture, journal); + const body = f.accepted()!.body; + const omissionSection = body.match(/^#{2,6} Non-roster agent values\s*\n([\s\S]*?)(?=^#{2,6} |\nActivation bindings:|$)/m)?.[1]; + expect(omissionSection, '#1860: machine omission metadata must not replace the required human-visible explanation') + .toBeDefined(); + expect(omissionSection).toContain('#101'); + expect(omissionSection?.toLowerCase()).toContain('kint'); + expect(omissionSection?.toLowerCase()).toContain('mcmanus'); }); - it.each(BOTH_PATHS)( - '%s: still requires the Non-roster agent values heading when an owner got no label', - (_path, prose) => { - // Preserved from the base stack. #1963 must not weaken it while tightening the - // positive direction. - expect(prose).toMatch(/`Non-roster agent values` heading is \*\*required\*\*/); - expect(prose).toMatch(/naming the value and\s*the issue it applied to|naming the value\s*and the issue it applied to/); - expect(prose).toMatch(/reports a\s*clean run that did not happen/); - }, - ); + it('withholds acceptance when durable receipts have not been uploaded', async () => { + const f = fixture(); + const capture = await f.prepare(); + const journal = await f.create(capture); + await expect(f.finalize(capture, journal, 0)).rejects.toThrow(/durable receipt artifact upload is missing/); + expect(f.accepted()).toBeUndefined(); + }); }); -describe('gh-aw: Activation bindings carry resolvable issue references (#1963)', () => { - it('replaces the bare created-issue-number placeholders that produced invalid JSON', () => { - expect( - workflow, - 'A bare number placeholder cannot be satisfied: the agent never learns a created ' + - "issue's real number during its turn.", - ).not.toContain('"issue":{created task issue number}'); +describe('gh-aw: Activation bindings contain actual API issue references (#1963)', () => { + it('rejects model-provided maps and bare-number placeholders at the request boundary', () => { + for (const issue of ['#aw_task1', 'aw_task1', 101]) { + expect(() => activationRequest([{ type: 'activate_plan', plan_comment_id: '10', issue }])) + .toThrow(/exact numeric plan_comment_id/); + } + expect(workflow).not.toContain('"issue":{created task issue number}'); expect(workflow).not.toContain('"epic_issue":{created epic issue number}'); }); - it('specifies both binding references as quoted JSON strings', () => { - expect(activateSkill).toContain('"issue":"{task issue reference}"'); - expect(activateSkill).toContain('"epic_issue":"{epic issue reference}"'); - expect(activateProse).toMatch(/`issue` and `epic_issue` are \*\*JSON strings\*\*, never bare numbers/); + it('serializes both task and epic actual references as valid quoted JSON', async () => { + const f = fixture('plan activate'); + const capture = await f.prepare(); + const journal = await f.create(capture); + await f.finalize(capture, journal); + const body = f.accepted()!.body; + const bindings = parseStructuredData(body).bindings; + expect(body).toContain('"issue":"#102"'); + expect(body).toContain('"epic_issue":"#101"'); + expect(bindings.map((binding: any) => binding.issue)).toEqual(['#102', '#103']); + expect(body).not.toMatch(/#?aw_/); }); - it('names invalid JSON as the reason quoting is mandatory, not style', () => { - expect( - activateProse, - 'Without the reason stated, a future edit "simplifies" the quotes away and silently ' + - 'breaks every bindings block.', - ).toMatch(/\*\*The quoting is load-bearing\.\*\*/); - expect(activateProse).toMatch(/it does not skip fenced code blocks/); - expect(activateProse).toMatch(/`"issue":#aw_task1` becomes `"issue":#42`, which is invalid JSON/); - expect(activateProse).toMatch(/`"issue":"#aw_task1"` becomes `"issue":"#42"`, which parses/); + it('does not rely on text substitution, which can produce invalid JSON for bare tokens', () => { + expect(() => JSON.parse('{"issue":#42}')).toThrow(); + expect(JSON.parse('{"issue":"#42"}')).toEqual({ issue: '#42' }); + expect(() => validateCreateReceipt({ staged: true, issue: { number: '#aw_task1' } }, + { id: 'task:1' }, { repository: 'owner/repo' })).toThrow(/actual issue/); }); - it('uses temporary IDs for created items and verified real numbers for reused ones', () => { - expect(activateProse).toMatch(/\*\*Created this run:\*\* that item's own `temporary_id`, quoted/); - expect(activateProse).toMatch(/\*\*Reused or pre-existing\*\*[^.]*its verified real\s*number in the same quoted form/); - expect( - activateProse, - 'Inventing a number for a created issue is the failure mode this whole design avoids.', - ).toMatch(/never a real number\s*for an issue this run created/); - expect(activateProse).toMatch(/never infer an issue number/); + it('preserves actual IDs on reruns rather than creating or guessing replacements by title', async () => { + const f = fixture(); + const capture = await f.prepare(); + const journal = await f.create(capture); + await f.finalize(capture, journal); + const original = f.accepted()!; + const bytes = original.body; + expect(await f.prepare()).toEqual({ existingAcceptanceId: original.id }); + expect(f.createIssue).toHaveBeenCalledTimes(2); + expect(f.accepted()!.body).toBe(bytes); }); - it('treats a surviving temporary ID as a failure rather than repairing it', () => { - expect(activateProse).toMatch(/was never resolved — that `create-issue` did\s*not land/); - expect(activateProse).toMatch(/Leave it rather than repairing it by hand/); + it('withholds acceptance for unresolved task or epic IDs in actual receipt journals', async () => { + for (const index of [0, 1]) { + const f = fixture('plan activate'); + const capture = await f.prepare(); + const journal = await f.create(capture); + journal.receipts[index].number = '#aw_unresolved'; + await expect(f.finalize(capture, journal)).rejects.toThrow(/identities are missing or malformed/); + expect(f.accepted()).toBeUndefined(); + } }); - it('documents the same reference and label contract in the shared ontology', () => { - // The ontology is the artifact contract the deterministic checker consumes; a rule - // stated only in the skill body is invisible to that consumer's spec. - expect(ontologyProse).toMatch(/\*\*Issue references are quoted strings, never bare numbers\.\*\*/); - expect(ontologyProse).toMatch(/it does not skip fenced code\s*blocks/); - expect(ontologyProse).toMatch(/\*\*Reported labels mean accepted label operations\.\*\*/); - expect(ontologyProse).toMatch(/It does not assert the label was\s*observed on the issue/); - expect(ontologyProse).toMatch(/consumers MUST treat it as a failure rather than skipping or repairing it/); + it('documents current live-label evidence separately from legacy queued-operation records', () => { + expect(ontology).toContain('Issue references are quoted, actual numeric references.'); + expect(ontology).toContain('Older records\' labels mean accepted operations, not proof that those operations landed.'); + expect(ontology).toContain('New finalization also checks the live result.'); + expect(ontology).toContain('Neither bare nor `#`-prefixed `aw_` tokens are valid consumable references.'); }); - it('never lets the ontology describe bindings as recording labels that were applied', () => { - // Regression guard for a real drift caught in review on this PR: the `Activation - // bindings:` paragraph said bindings record "epic labels reported as applied" while - // the paragraph directly below it correctly defined reported labels as *accepted* - // operations. The ontology is the file most likely to be read standalone, so the - // strong claim there silently re-introduced the exact defect this change removes — - // one consistently-wrong place became two inconsistently-wrong ones. - // - // This is deliberately a narrow, shape-targeted negative rather than a ban on the - // word "applied". The same file legitimately says safe outputs "are applied after - // the agent turn" — that sentence is the *justification* for the weaker claim, so a - // blanket match would forbid the correct prose along with the incorrect prose. - expect( - ontologyProse, - 'The ontology must not say bindings record labels "reported as applied". Reported ' + - 'labels assert an accepted add_labels operation, never observed application.', - ).not.toMatch(/(?:reported|recorded|listed)\s+as\s+applied/i); - expect( - ontologyProse, - 'No binding field may be described as a label already applied to, or present on, the issue.', - ).not.toMatch(/labels?\s+(?:already\s+)?(?:applied\s+to|present\s+on)\s+the\s+issue/i); - - // Positive half: the binding paragraph must state the accepted-operation semantics - // and point a standalone reader at the definition, so the fix cannot be reverted to - // a vaguer wording that merely dodges the negatives above. - expect(ontologyProse).toMatch( - /epic labels reported as accepted label operations \(defined below\)/, - ); + it('never upgrades an operator-authorized current-state supplement into historical attestation', () => { + expect(ontology).toContain('basis: current-state-authorized'); + expect(ontology).toContain('supplement never claims historical attestation'); + expect(ontology).toContain('Only independently verified request evidence may authorize reader selection'); }); }); describe('post-activation checker resolves binding references and fails closed (#1963)', () => { - const roster = parseRoster(` -## Members -| Name | Role | -|------|------| -| Kint | Lead | -`); - - function binding(issue: unknown, epicIssue: unknown) { - return { - task: '1', - issue, - epic: '2.1', - epic_issue: epicIssue, - agent: 'Kint', - epic_agents: ['kint'], - label: 'squad:kint', - epic_label: 'squad:kint', - }; - } - - function activated(bindings: object[]) { - return { - squad_artifact: 'activated', - schema_version: '1', - origin_issue: 1, - phases: [], - bindings, - }; - } - - const presentLabels = new Map([ - [42, new Set(['squad', 'squad:kint'])], - [43, new Set(['squad', 'squad:kint'])], - ]); - - it('accepts the resolved quoted form gh-aw substitution produces', () => { - // '"issue":"#aw_task1"' becomes '"issue":"#42"' after substitution — the shape the - // checker actually receives in production. - const result = validateBindings(activated([binding('#42', '#43')]), roster, presentLabels); - expect(result).toMatchObject({ skipped: false, checked: 1, epics: 1 }); + const roster = parseRoster('## Members\n| Name | Role |\n|---|---|\n| Kint | Lead |\n'); + const binding = (issue: unknown, epicIssue: unknown) => ({ + task: '1', issue, epic: '2.1', epic_issue: epicIssue, agent: 'Kint', + epic_agents: ['kint'], label: 'squad:kint', epic_label: 'squad:kint', }); - - it('still accepts bare integers, so artifacts written before this contract validate', () => { - const result = validateBindings(activated([binding(42, 43)]), roster, presentLabels); - expect(result).toMatchObject({ skipped: false, checked: 1 }); + const activated = (bindings: object[]) => ({ + squad_artifact: 'activated', schema_version: '1', origin_issue: 1, phases: [], bindings, }); + const labels = new Map([[42, new Set(['squad', 'squad:kint'])], [43, new Set(['squad', 'squad:kint'])]]); - it('fails closed on an unresolved temporary ID instead of skipping or coercing it', () => { - expect( - () => validateBindings(activated([binding('#aw_task1', '#43')]), roster, presentLabels), - 'A surviving temporary ID means that create-issue never landed. Silently skipping it ' + - 'would report a clean activation for an issue that does not exist.', - ).toThrow('unresolved temporary ID'); + it('accepts resolved quoted references, including legacy substituted records', () => { + expect(validateBindings(activated([binding('#42', '#43')]), roster, labels)) + .toMatchObject({ skipped: false, checked: 1, epics: 1 }); + }); + it('continues to accept bare integers in pre-contract artifacts', () => { + expect(validateBindings(activated([binding(42, 43)]), roster, labels)) + .toMatchObject({ skipped: false, checked: 1 }); + }); + it.each(['#aw_task1', 'aw_task1'])('fails closed on unresolved task reference %s', reference => { + expect(() => validateBindings(activated([binding(reference, '#43')]), roster, labels)) + .toThrow(/unresolved temporary ID/); }); - it('fails closed on an unresolved epic reference too', () => { - expect(() => validateBindings(activated([binding('#42', '#aw_epic1')]), roster, presentLabels)) + expect(() => validateBindings(activated([binding('#42', '#aw_epic1')]), roster, labels)) .toThrow('unresolved temporary ID'); }); - - it('rejects a reference that is neither a number nor a resolvable reference', () => { - // Cross-repo substitution yields `owner/repo#42`; Squad activates same-repo only, so - // that form means something went wrong upstream. Reject rather than parse loosely. - expect(() => validateBindings(activated([binding('other/repo#42', '#43')]), roster, presentLabels)) - .toThrow('binding has no valid issue number'); - expect(() => validateBindings(activated([binding('', '#43')]), roster, presentLabels)) + it.each(['other/repo#42', ''])('rejects invalid or cross-repository reference %j', reference => { + expect(() => validateBindings(activated([binding(reference, '#43')]), roster, labels)) .toThrow('binding has no valid issue number'); }); - it('keeps per-item correspondence across resolved references', () => { - // Two tasks in one epic, distinct issues: a checker that resolved references sloppily - // could collapse them and pass a duplicate. - const two = [ - { ...binding('#42', '#43'), task: '1' }, - { ...binding('#42', '#43'), task: '2' }, - ]; - expect(() => validateBindings(activated(two), roster, presentLabels)) - .toThrow('duplicate binding'); + expect(() => validateBindings(activated([ + binding('#42', '#43'), { ...binding('#42', '#43'), task: '2' }, + ]), roster, labels)).toThrow('duplicate binding'); }); }); -// --------------------------------------------------------------------------- -// Compiled-artifact verification -// --------------------------------------------------------------------------- -// -// The prose assertions above lock the reporting contract. This section proves the -// workflow carrying it still strict-compiles and that the safe outputs the contract -// depends on stay wired. Fails closed (never skips) per this repo's #1833/#1834 -// convention: an unmeasured contract is indistinguishable from a violated one. - -const GH_AW_INSTALL_HINT = - '`gh aw` is required to compile the workflow this gate inspects. Install it with ' + - '`gh extension install --pin v0.87.10 github/gh-aw` (the same pin ' + - '.github/workflows/squad-ci.yml uses, so local strict-compile and lock output match ' + - 'CI). This gate fails closed rather than skipping: an unmeasured contract is ' + - 'indistinguishable from a violated one (#1834).'; - -describe('gh-aw: the activation summary contract compiles in strict mode (#1963)', () => { - let compiledLock: string | null = null; - - function lockText(): string { - if (compiledLock !== null) return compiledLock; - - const versionProbe = spawnSync('gh', ['aw', '--version'], { encoding: 'utf8' }); - if (versionProbe.status !== 0) { - throw new Error(`gh aw --version failed. ${GH_AW_INSTALL_HINT}`); - } - - mkdirSync(TEST_WORKSPACES_DIR, { recursive: true }); - const workspace = mkdtempSync(join(TEST_WORKSPACES_DIR, 'activation-summary-')); - execFileSync('git', ['init', '--quiet'], { cwd: workspace }); - // gh-aw resolves cross-workflow references against a `.github/workflows/` directory - // relative to the compiled file. This repo ships gh-aw *source* from a top-level - // `workflows/` dir; consumers install it via `gh aw add`. Mirror that layout. - cpSync(WORKFLOWS_DIR, join(workspace, '.github', 'workflows'), { recursive: true }); - execFileSync( - 'gh', - ['aw', 'compile', '.github/workflows/squad.md', '--strict', '--approve', '--no-check-update'], - { cwd: workspace, encoding: 'utf8', stdio: 'pipe' }, - ); - - const lockPath = join(workspace, '.github', 'workflows', 'squad.lock.yml'); - if (!existsSync(lockPath)) { - throw new Error( - 'gh aw compile produced no squad.lock.yml — the compiled artifact this gate ' + - `inspects is absent, so the contract is unmeasured. ${GH_AW_INSTALL_HINT}`, - ); - } - compiledLock = readText(lockPath); - return compiledLock; +describe('gh-aw: activation summary contract strict-compiles at the trusted boundary (#1963)', () => { + let compiled: any; + function compile(source = workflow) { + const root = workspace('compile-'); + execFileSync('git', ['init', '--quiet'], { cwd: root }); + cpSync(WORKFLOWS_DIR, join(root, '.github/workflows'), { recursive: true }); + writeFileSync(join(root, '.github/workflows/squad.md'), source); + const binary = process.env.SQUAD_GH_AW_BIN ?? 'gh'; + const prefix = process.env.SQUAD_GH_AW_BIN ? [] : ['aw']; + execFileSync(binary, [...prefix, 'compile', '.github/workflows/squad.md', '--strict', '--approve', '--no-check-update'], + { cwd: root, encoding: 'utf8', stdio: 'pipe' }); + return parseYaml(readText(join(root, '.github/workflows/squad.lock.yml'))); } - - it('strict-compiles the workflow carrying the reporting contract', () => { - expect(lockText().length).toBeGreaterThan(0); + function lock() { + compiled ??= compile(); + return compiled; + } + function writer(document = lock()) { + const jobs = Object.values(document.jobs) as any[]; + const matches = jobs.filter(job => job.steps?.some((step: any) => + step.with?.script?.includes('activation.finalizeActivation('))); + expect(matches, 'compiled workflow must have exactly one trusted finalizer').toHaveLength(1); + return matches[0]; + } + function requireFinalizer(document: any) { + const job = writer(document); + const scripts = job.steps.map((step: any) => step.with?.script ?? '').join('\n'); + expect(scripts).toContain('createIssue, sanitizeContent,'); + expect(scripts).toContain('await activation.createActivation('); + expect(scripts).toContain('await activation.finalizeActivation('); + expect(scripts.indexOf('await activation.createActivation(')) + .toBeLessThan(scripts.indexOf('await activation.finalizeActivation(')); + } + it('strict-compiles the workflow carrying the summary contract without skipping unavailable tools', () => { + expect(lock().jobs.agent).toBeDefined(); + requireFinalizer(lock()); }); - - it('keeps add_labels callable — the only operation a reported label may cite', () => { - expect(lockText()).toMatch(/"tools":\[[^\]]*"add_labels"[^\]]*\]/); + it('exposes one trusted activate_plan request rather than a label-acceptance summary producer', () => { + const job = writer(); + expect(job.permissions.issues).toBe('write'); + expect(job.needs).toContain('safe_outputs'); + expect(job.if).toContain("contains(needs.agent.outputs.output_types, 'activate_plan')"); + const prepare = job.steps.find((step: any) => step.with?.script?.includes('activation.prepareActivation(')); + expect(prepare.with.script).toContain('outputPath: process.env.GH_AW_AGENT_OUTPUT'); + expect(prepare.env.GH_AW_AGENT_OUTPUT).toContain('agent_output.json'); }); - - it('keeps temporary-ID substitution reachable for the quoted binding references', () => { - // Quoted `#aw_…` references only resolve because create_issue requires a temporary ID - // and gh-aw rewrites references to it. Without this, every binding stays unresolved. - const compiled = lockText().replace(/\\"/g, '"'); - expect(compiled).toMatch(/"create_issue":\{[^}]*"require_temporary_id":true/); + it('persists actual capture and receipts before the trusted finalizer publishes bindings', () => { + const steps = writer().steps; + const uploads = steps.map((step: any, index: number) => ({ step, index })) + .filter(({ step }: any) => step.uses?.startsWith('actions/upload-artifact@')); + expect(uploads).toHaveLength(2); + const finalizer = steps.findIndex((step: any) => step.with?.script?.includes('activation.finalizeActivation(')); + expect(uploads.every(({ index }: any) => index < finalizer)).toBe(true); + expect(uploads[1].step.if).toContain('always()'); + expect(steps[finalizer].env.RECEIPT_ARTIFACT_ID).toContain('artifact-id'); }); - - it('leaves label writes in the safe-output job, which is why "accepted" is the honest claim', () => { - // The agent job holds only `issues: read`; a separate post-agent job performs the - // write. That job boundary is precisely why no summary may claim a verified GitHub - // result — the agent turn ends before any label is applied. - const compiled = lockText(); - const agentJobAt = compiled.indexOf('\n agent:\n'); - const safeOutputsJobAt = compiled.indexOf('\n safe_outputs:\n'); - expect(agentJobAt, '"agent:" job is missing from the compiled lock file').toBeGreaterThan(-1); - expect(safeOutputsJobAt, '"safe_outputs:" job is missing from the compiled lock file').toBeGreaterThan(agentJobAt); - - const permsMatch = compiled.slice(agentJobAt, safeOutputsJobAt).match(/permissions:\n((?:\s{6}\S.*\n)+)/); - expect(permsMatch, 'agent job permissions block not found').not.toBeNull(); - const permsBlock = permsMatch ? permsMatch[1] : ''; - expect(permsBlock).toContain('issues: read'); - expect(permsBlock).not.toContain('issues: write'); + it('retains read-only model permissions while granting writes only to the trusted job', () => { + expect(lock().jobs.agent.permissions.issues).toBe('read'); + expect(writer().permissions.issues).toBe('write'); + }); + it('strict-compiles a real source mutation and detects the missing finalizer despite unchanged summary prose', () => { + const source = workflow.replace( + / - name: Validate actual bindings before publishing acceptance\n[\s\S]*?(?= cast-failure:)/, + '', + ); + expect(source).not.toBe(workflow); + const mutant = compile(source); + expect(() => requireFinalizer(mutant)).toThrow(/exactly one trusted finalizer/); }); }); diff --git a/test/gh-aw-activation-temporary-ids.test.ts b/test/gh-aw-activation-temporary-ids.test.ts index fb7dae491..9aaac2404 100644 --- a/test/gh-aw-activation-temporary-ids.test.ts +++ b/test/gh-aw-activation-temporary-ids.test.ts @@ -1,292 +1,143 @@ /** - * gh-aw temporary-ID targeting for `/squad plan activate` (#1962, parent #1957). - * - * The activation prose previously told the model to read a real GitHub issue number back - * from each `create-issue` call and then target `add_labels` at it. gh-aw never does that: - * issue creation is deferred to the post-agent safe-output job, so the agent only ever - * receives a success acknowledgement. Every downstream operation that depended on a - * "returned" number was therefore either blocked forever or satisfied by a hallucinated - * number — and an `add_labels` call with no resolvable `item_number` silently falls back to - * labelling the *triggering intent issue*. - * - * gh-aw's supported linkage is `temporary_id`: `create_issue` mints one, and - * `add_labels.item_number`, `create_issue.parent`, and `create_issue.blocked_by` accept it. - * The safe-output job resolves temporary IDs to real issue numbers after the agent exits. - * - * Verified against the pinned gh-aw v0.87.10 (.github/workflows/squad-ci.yml): - * - `create_issue.temporary_id` — optional string, pattern `^#?aw_[A-Za-z0-9_]{3,12}$` - * - `add_labels.item_number` — number | string, `^(\d+|#?aw_[A-Za-z0-9_]{3,12})$` - * - `require-temporary-id: true` under `safe-outputs.create-issue` compiles to - * `"require_temporary_id":true` plus `required_field_additions.create_issue` - * - * This suite locks the corrected contract. It is deliberately narrow: the broad activation - * behavioral suite is #1960, the `/squad activate` fast path is #1959, and the activation - * reporting redesign is #1963. + * Activation identity comes from synchronous createIssue API returns, never model + * mappings. Native safe outputs retain temporary-ID enforcement for other commands. */ - import { afterAll, describe, it, expect } from 'vitest'; import { cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'; import { execFileSync, spawnSync } from 'node:child_process'; import { join } from 'node:path'; +import { activationRequest, validateCreateReceipt } from '../workflows/shared/squad-activation.mjs'; const WORKFLOWS_DIR = join(process.cwd(), 'workflows'); -const SQUAD_WORKFLOW = join(WORKFLOWS_DIR, 'squad.md'); const TEST_WORKSPACES_DIR = join(process.cwd(), '.test-workspaces-temporary-ids'); afterAll(() => { rmSync(TEST_WORKSPACES_DIR, { recursive: true, force: true }); }); -/** Read a text file with line endings normalized to LF (Windows checkouts materialize CRLF). */ function readText(filePath: string): string { return readFileSync(filePath, 'utf8').replace(/\r\n/g, '\n'); } -const workflow = readText(SQUAD_WORKFLOW); - -const ACTIVATE_START = workflow.indexOf('## skill: `squad-plan-activate`'); -const ACTIVATE_END = workflow.indexOf('## end skill: `squad-plan-activate`'); -expect(ACTIVATE_START, '"## skill: `squad-plan-activate`" is missing from workflows/squad.md').toBeGreaterThan(-1); -expect(ACTIVATE_END, '"## end skill: `squad-plan-activate`" is missing from workflows/squad.md').toBeGreaterThan( - ACTIVATE_START, -); -const activateSkill = workflow.slice(ACTIVATE_START, ACTIVATE_END); - -/** Whitespace-collapsed view for prose assertions that may span wrapped lines. */ -const activateProse = activateSkill.replace(/\s+/g, ' '); - -/** Isolate a `**{marker}**` step body, running to the next bold step heading. */ -function stepBody(marker: string): string { - const start = activateSkill.indexOf(marker); - expect(start, `"${marker}" is missing from the squad-plan-activate skill`).toBeGreaterThan(-1); - const rest = activateSkill.slice(start + marker.length); - const nextStep = rest.search(/\n\*\*\d[a-z]?\./); - return (nextStep === -1 ? rest : rest.slice(0, nextStep)).replace(/\s+/g, ' '); -} - -describe('gh-aw: temporary-ID targeting in squad-plan-activate (#1962)', () => { - it('requires a temporary_id on every create-issue via frontmatter', () => { - // Without this, a create_issue call that forgets its temporary_id still succeeds and - // the matching add_labels has nothing resolvable to target. The flag makes the - // omission a hard rejection instead of a silent mis-label. - const frontmatterEnd = workflow.indexOf('\n---', 4); - expect(frontmatterEnd, 'workflows/squad.md frontmatter is unterminated').toBeGreaterThan(0); - const frontmatter = workflow.slice(0, frontmatterEnd); - - const createIssueAt = frontmatter.indexOf('create-issue:'); - expect(createIssueAt, '`create-issue:` is missing from the safe-outputs frontmatter').toBeGreaterThan(-1); - - expect( - frontmatter.slice(createIssueAt), - '`require-temporary-id: true` must be declared under safe-outputs.create-issue.', - ).toMatch(/create-issue:[\s\S]*?require-temporary-id: true/); - }); - - it('documents the exact gh-aw temporary-ID format, not an invented one', () => { - // A value outside gh-aw's pattern is rejected at the MCP tool layer, so the prose has - // to carry the real pattern rather than a plausible-looking approximation. - expect( - activateProse, - 'The prose must cite gh-aw\'s actual temporary_id pattern.', - ).toContain('^#?aw_[A-Za-z0-9_]{3,12}$'); - }); - - it('mints deterministic temporary IDs derived from the plan, never invented ones', () => { - expect(activateProse).toMatch(/#aw_epic\{K\}/); - expect(activateProse).toMatch(/#aw_task\{N\}/); - expect( - /derive, never invent/i.test(activateProse), - 'The minting scheme must be derived from the plan\'s own identifiers so a rerun ' + - 'produces the same IDs and two items can never collide.', - ).toBe(true); - }); - - it('makes temporary-ID uniqueness the prompt\'s responsibility', () => { - // Verified in gh-aw v0.87.10: a duplicate temporary_id is NOT rejected. The - // temporaryIdMap set() silently overwrites, so the last create_issue using a value - // owns it and every earlier reference resolves to the wrong issue. gh-aw emits no - // warning for this on create_issue, so the prompt must enforce it. - expect( - /uniqueness is your responsibility/i.test(activateProse), - 'The prose must state that gh-aw does not enforce temporary-ID uniqueness.', - ).toBe(true); - expect( - /does not reject a duplicate `temporary_id`/i.test(activateProse), - 'The prose must name the concrete failure mode: duplicates are silently accepted.', - ).toBe(true); - }); - - it('mandates an explicit add_labels item_number and names the silent-fallback hazard', () => { - // This is the actual production defect: an add_labels with no item_number does not - // error — it labels the triggering intent issue, branding the user's own request. - expect( - /every `add_labels` call MUST pass `item_number`/i.test(activateProse), - 'Explicit item_number targeting must be mandatory, not implied.', - ).toBe(true); - expect( - /triggering intent issue/i.test(activateProse), - 'The prose must name what an omitted item_number silently hits.', - ).toBe(true); - }); - - it('targets add_labels at the create-issue call\'s temporary_id', () => { - expect( - /`item_number` set to that call's `temporary_id`/i.test(activateProse), - 'add_labels must be wired to the minting create-issue call\'s temporary_id.', - ).toBe(true); - }); - - it('never waits for, predicts, or reads back an issue number for issues it created', () => { - expect( - /does \*\*not\*\* return a real GitHub issue number/i.test(activateProse), - 'The Hallucination Guard must state plainly that create-issue returns no number.', - ).toBe(true); - expect( - /never predict, infer, or "read back" a number/i.test(activateProse), - 'Predicting or inferring a created issue\'s number must be explicitly forbidden.', - ).toBe(true); - expect( - /do not wait for a returned issue number/i.test(activateProse), - 'The prose must tell the model not to block on a number that never arrives.', - ).toBe(true); - }); - - it('still uses real, verified numbers for pre-existing and reused issues', () => { - // Temporary IDs only map issues THIS run created. An idempotent rerun or a - // dedup-by-title match has a genuine number and must use it. - expect( - /has a real, verified number: target it by that number, not a temporary ID/i.test(activateProse), - 'Reused/pre-existing issues must be targeted by their verified real number.', - ).toBe(true); - }); - - it('declares a temporary ID in both the epic (2b) and task (2c) creation steps', () => { - for (const marker of ['**2b. Create Epic Issues:**', '**2c. Create Task Issues:**']) { - expect(stepBody(marker), `${marker} must declare a Temporary ID field`).toMatch(/- Temporary ID:/); - } +describe('gh-aw: activation accepts only one exact plan-comment request', () => { + it('accepts a numeric comment ID encoded as a string', () => { + expect(activationRequest([{ type: 'activate_plan', plan_comment_id: '123' }])).toBe(123); }); - it('passes the epic\'s temporary ID as each task\'s parent', () => { - // The epic does not exist yet when the task create-issue is emitted, so `parent` - // cannot be a real number. gh-aw resolves a temporary ID here. - expect(stepBody('**2c. Create Task Issues:**')).toMatch(/#aw_epic\{K\}/); + it.each([ + undefined, null, {}, [], [{ plan_comment_id: '123' }], + [{ type: 'activate_plan', plan_comment_id: '123' }, { type: 'activate_plan', plan_comment_id: '123' }], + [{ type: 'create_issue', temporary_id: 'aw_task1', title: 'Task' }], + [{ type: 'add_labels', item_number: '#aw_task1', labels: ['squad'] }], + [{ type: 'add_comment', body: 'Accepted', data: { squad_artifact: 'activated' } }], + [{ type: 'upsert_lifecycle_state', body: 'Activated' }], + ].map(items => ({ items })))('rejects missing, duplicate and native activation outputs: $items', ({ items }) => { + expect(() => activationRequest(items)).toThrow(/exactly one activate_plan request/); }); - it('uses a verified real number when 2b reused a deduped prior-phase epic', () => { - // 2b dedups epics by title against prior phases. A reused epic was never minted in - // this run, so it has no entry in gh-aw's temporary-ID map — passing `#aw_epic{K}` - // for it would be an unresolvable parent reference. The prose must carve this out - // instead of applying the temporary ID unconditionally. - const parentLine = stepBody('**2c. Create Task Issues:**'); - - expect( - /minted this epic in this run/i.test(parentLine), - 'Task parent targeting must condition the temporary ID on the epic having been ' + - 'minted in this run.', - ).toBe(true); - - expect( - /matched a pre-existing epic by title[\s\S]*?verified real number/i.test(parentLine), - 'A dedup-by-title/prior-phase epic match must be targeted by its verified real ' + - 'number, not a temporary ID this run never minted.', - ).toBe(true); - - expect( - /never pass a temporary ID that was not minted this run/i.test(parentLine), - 'The prose must forbid passing a temporary ID that was not minted this run.', - ).toBe(true); - }); - - it('never leaves an unresolved temporary ID in the sub-issue fallback body reference', () => { - // gh-aw leaves an `#aw_…` reference it cannot resolve in the body VERBATIM, so a - // reused/pre-existing parent written as a temporary ID would ship a literal - // "#aw_epic3" string to the user. The fallback must branch on provenance. - const start = workflow.indexOf('##### Sub-issue Fallback'); - expect(start, '"##### Sub-issue Fallback" is missing').toBeGreaterThan(-1); - const rest = workflow.slice(start); - const nextHeading = rest.slice(1).search(/\n#{1,5} /); - const fallback = (nextHeading === -1 ? rest : rest.slice(0, nextHeading + 1)).replace(/\s+/g, ' '); - - expect( - /if that parent was minted this run/i.test(fallback), - 'The fallback must use a temporary ID only for a parent minted in this run.', - ).toBe(true); - - expect( - /pre-existing or was matched by dedup[\s\S]*?verified real number/i.test(fallback), - 'A pre-existing or deduped parent must be written as its verified real number.', - ).toBe(true); + it.each(['create_issue', 'add_labels', 'add_comment', 'upsert_lifecycle_state'])( + 'rejects a valid request mixed with %s', + type => { + expect(() => activationRequest([ + { type: 'activate_plan', plan_comment_id: '123' }, { type }, + ])).toThrow(/native creates, acceptance and lifecycle outputs are forbidden/); + }, + ); - expect( - /leaves an unresolved `#aw_…` reference in the body verbatim/i.test(fallback), - 'The prose must state WHY: an unresolved temporary ID is not stripped, so it ' + - 'would ship to the user as a meaningless literal.', - ).toBe(true); + it.each([undefined, null, 123, '', '0', '-1', '01', '1.0', '1e2', ' 123', '123 ', '#123', + 'aw_task1', '#aw_task1', '9007199254740992'])('rejects malformed comment identity %j', id => { + expect(() => activationRequest([{ type: 'activate_plan', plan_comment_id: id }])) + .toThrow(/exact numeric plan_comment_id/); }); - it('uses the triggering issue\'s own real number as the epic parent', () => { - // The one place a real number IS correct: the intent issue that triggered the run - // already exists and its number is independently known. - expect(stepBody('**2b. Create Epic Issues:**')).toMatch(/triggering issue/i); + it.each([ + { issue: 42 }, { bindings: [{ task: '1', issue: 42 }] }, + { repository: 'example/project' }, { accepted: true }, + ])('rejects model-provided authority alongside the comment ID: %j', extra => { + expect(() => activationRequest([{ type: 'activate_plan', plan_comment_id: '123', ...extra }])) + .toThrow(/exact numeric plan_comment_id/); }); +}); - it('expresses native dependency edges with temporary IDs on create-issue', () => { - // blocked_by must be declared on the create_issue call itself (gh-aw's topological - // sort only inspects create_issue.blocked_by), not patched in afterwards with a - // number the agent does not have. - const start = activateSkill.indexOf('##### Step 3: Native Dependency Edges'); - expect(start, '"##### Step 3: Native Dependency Edges" is missing').toBeGreaterThan(-1); - const rest = activateSkill.slice(start); - const nextHeading = rest.slice(1).search(/\n#{1,5} /); - const stepThree = (nextHeading === -1 ? rest : rest.slice(0, nextHeading + 1)).replace(/\s+/g, ' '); - - expect(stepThree).toMatch(/blocked_by/); - expect(stepThree).toMatch(/#aw_task\{N\}/); - expect( - /on the `create-issue` call itself/i.test(stepThree), - 'blocked_by must be declared on the create-issue call, not applied afterwards.', - ).toBe(true); +describe('gh-aw: create receipts require actual API-return identities', () => { + const capture = { repository: 'example/project' }; + const operation = { id: 'task:1' }; + const issue = () => ({ + number: 42, id: 1042, node_id: 'I_example_42', + html_url: 'https://github.com/example/project/issues/42', + url: 'https://api.github.com/repos/example/project/issues/42', + }); + const result = () => ({ staged: false, issue: issue() }); + + it('preserves the exact number, database ID, node ID and repository from a synchronous result', () => { + expect(validateCreateReceipt(result(), operation, capture)).toEqual({ + operation_id: 'task:1', repository: 'example/project', + number: 42, id: 1042, node_id: 'I_example_42', + url: 'https://github.com/example/project/issues/42', + }); }); - it('names the 2d self-validation count with one consistent term', () => { - // The reviewer flagged a mixed-terminology hazard: comparing a "requested/recognized" - // count but then reporting it as `created={N}` left {N} undefined. One term, defined - // once, and both report_incomplete parameters bound explicitly to it. - // - // 2d is the last bold step, so bound the slice at the next heading too — otherwise it - // swallows Step 4, whose separate "created/recognized" binding wording belongs to #1963. - const marker = '**2d. Self-Validation:**'; - const start = activateSkill.indexOf(marker); - expect(start, `"${marker}" is missing`).toBeGreaterThan(-1); - const rest = activateSkill.slice(start + marker.length); - const end = rest.search(/\n#{1,5} |\n\*\*\d[a-z]?\./); - const body = (end === -1 ? rest : rest.slice(0, end)).replace(/\s+/g, ' '); - - expect(body, '2d must define the counted quantity once, as the created count').toMatch( - /\*\*created count\*\* is the number of `create-issue` calls this run emitted/i, - ); - // Nothing may reintroduce a competing noun for the same quantity. - expect(body, '2d must not mix in a second term for the same count').not.toMatch( - /requested\/recognized|created\/recognized|requested count|recognized count/i, + it.each([ + null, {}, { success: true }, { staged: true, issue: issue() }, { issue: issue() }, + { staged: false, number: 42 }, { staged: false, issue: { ...issue(), pull_request: {} } }, + { staged: false, temporary_id: 'aw_task1', issue_number: 42 }, + ])('rejects acknowledgements, staged results and claimed mappings: %j', returned => { + expect(() => validateCreateReceipt(returned, operation, capture)) + .toThrow(/did not return an actual issue/); + }); + + it.each([ + { number: undefined }, { number: 0 }, { number: 1.5 }, { number: '42' }, + { number: 'aw_task1' }, { number: '#aw_task1' }, { number: Number.MAX_SAFE_INTEGER + 1 }, + { id: undefined }, { id: 0 }, { id: '1042' }, { id: Number.MAX_SAFE_INTEGER + 1 }, + { node_id: undefined }, { node_id: '' }, { node_id: 1042 }, + ])('rejects missing or non-API issue identities: %j', mutation => { + expect(() => validateCreateReceipt({ staged: false, issue: { ...issue(), ...mutation } }, operation, capture)) + .toThrow(/actual issue identities are missing or malformed/); + }); + + it.each([ + { html_url: 'https://github.com/example/other/issues/42' }, + { url: 'https://api.github.com/repos/example/other/issues/42' }, + { html_url: 'https://github.com/example/project/issues/43' }, + { url: 'https://api.github.com/repos/example/project/issues/43' }, + { html_url: 'https://github.com/example/project/pull/42' }, + { html_url: 'https://github.com/example/project/issues/42?claimed=true' }, + { html_url: undefined }, { url: undefined }, + ])('rejects repository, resource and exact URL mismatches: %j', mutation => { + expect(() => validateCreateReceipt({ staged: false, issue: { ...issue(), ...mutation } }, operation, capture)) + .toThrow(/repository or URL does not match/); + }); + + it.each([ + { operation_id: 'task:1' }, { number: 42 }, { id: 1042 }, { node_id: 'I_example_42' }, + ])('rejects collisions in each independent identity: %j', collision => { + const previous = { + operation_id: 'task:2', repository: capture.repository, + number: 43, id: 1043, node_id: 'I_example_43', ...collision, + }; + expect(() => validateCreateReceipt(result(), operation, capture, [previous])) + .toThrow(/duplicate operation or actual issue identity/); + }); + + it('accepts distinct real identities regardless of matching titles', () => { + const previous = validateCreateReceipt( + { staged: false, issue: { ...issue(), title: 'Same title' } }, operation, capture, ); - // Both parameters must state what they carry, so {N} and {M} are unambiguous. - expect(body).toMatch(/`created=\{N\}` set to that created count/); - expect(body).toMatch(/`expected=\{M\}` set to the declared total/); + const next = { + number: 43, id: 1043, node_id: 'I_example_43', title: 'Same title', + html_url: 'https://github.com/example/project/issues/43', + url: 'https://api.github.com/repos/example/project/issues/43', + }; + expect(validateCreateReceipt({ staged: false, issue: next }, { id: 'task:2' }, capture, [previous])) + .toMatchObject({ operation_id: 'task:2', number: 43, id: 1043 }); }); }); -// --------------------------------------------------------------------------- -// Compiled-artifact verification -// --------------------------------------------------------------------------- -// -// The prose assertions above are only a contract if gh-aw actually compiles the -// requirement into the safe-output handler config. Fails closed (never skips) per this -// repo's #1833/#1834 convention: an unmeasured contract is indistinguishable from a -// violated one. - +// Native safe-output constraints still apply to non-activation commands. const GH_AW_INSTALL_HINT = '`gh aw` is required to compile the workflow this gate inspects. Install it with ' + - '`gh extension install --pin v0.87.10 github/gh-aw` (matches .github/workflows/squad-ci.yml). ' + + '`gh extension install --pin v0.91.5 github/gh-aw` (matches .github/workflows/squad-ci.yml). ' + 'This gate fails closed rather than skipping: an unmeasured contract is ' + 'indistinguishable from a violated one (#1834).'; @@ -295,31 +146,21 @@ describe('gh-aw: require-temporary-id compiles into the real safe-output config function lockText(): string { if (compiledLock !== null) return compiledLock; - - const versionProbe = spawnSync('gh', ['aw', '--version'], { encoding: 'utf8' }); - if (versionProbe.status !== 0) { - throw new Error(`gh aw --version failed. ${GH_AW_INSTALL_HINT}`); - } - + const compiler = process.env.SQUAD_GH_AW_BIN ?? 'gh'; + const prefix = process.env.SQUAD_GH_AW_BIN ? [] : ['aw']; + const versionProbe = spawnSync(compiler, [...prefix, '--version'], { encoding: 'utf8' }); + if (versionProbe.status !== 0) throw new Error(`gh aw --version failed. ${GH_AW_INSTALL_HINT}`); + expect(`${versionProbe.stdout}\n${versionProbe.stderr}`).toContain('v0.91.5'); mkdirSync(TEST_WORKSPACES_DIR, { recursive: true }); const workspace = mkdtempSync(join(TEST_WORKSPACES_DIR, 'temp-ids-')); execFileSync('git', ['init', '--quiet'], { cwd: workspace }); - // This repo ships the gh-aw *source* from a top-level `workflows/` dir; downstream - // consumers install it into `.github/workflows/` via `gh aw add`. Mirror that real - // deployment layout, matching .github/workflows/squad-ci.yml's own compile gate. cpSync(WORKFLOWS_DIR, join(workspace, '.github', 'workflows'), { recursive: true }); - execFileSync('gh', ['aw', 'compile', '.github/workflows/squad.md', '--strict', '--approve', '--no-check-update'], { - cwd: workspace, - encoding: 'utf8', - stdio: 'pipe', + execFileSync(compiler, [...prefix, 'compile', '.github/workflows/squad.md', '--strict', '--no-check-update'], { + cwd: workspace, encoding: 'utf8', stdio: 'pipe', }); - const lockPath = join(workspace, '.github', 'workflows', 'squad.lock.yml'); if (!existsSync(lockPath)) { - throw new Error( - `gh aw compile produced no squad.lock.yml — the compiled artifact this gate ` + - `inspects is absent, so the contract is unmeasured. ${GH_AW_INSTALL_HINT}`, - ); + throw new Error(`gh aw compile produced no squad.lock.yml — the contract is unmeasured. ${GH_AW_INSTALL_HINT}`); } compiledLock = readText(lockPath); return compiledLock; @@ -330,16 +171,11 @@ describe('gh-aw: require-temporary-id compiles into the real safe-output config }); it('wires require_temporary_id: true into the create_issue handler config', () => { - // gh-aw embeds this JSON inside a double-quoted YAML env value with every `"` - // backslash-escaped. De-escape before matching so this holds regardless of a given - // compiler version's quoting choice. const compiled = lockText().replace(/\\"/g, '"'); expect(compiled).toMatch(/"create_issue":\{[^}]*"require_temporary_id":true/); }); it('adds temporary_id to create_issue\'s required fields', () => { - // gh-aw emits this block pretty-printed across multiple lines, so match with - // whitespace tolerance rather than assuming compact JSON. const compiled = lockText().replace(/\\"/g, '"'); expect(compiled).toMatch( /"required_field_additions":\s*\{\s*"create_issue":\s*\[\s*"temporary_id"\s*\]/, @@ -347,10 +183,6 @@ describe('gh-aw: require-temporary-id compiles into the real safe-output config }); it('tells the agent, in its own tool constraints, that temporary_id is required', () => { - // The prompt-side constraint string is what the model actually reads at runtime; - // without it the enforcement is a silent rejection the agent cannot anticipate. - // De-escaping reintroduces quotes inside the value (`Labels ["squad"]`), so match - // with a bounded lazy span rather than a quote-delimited one. const compiled = lockText().replace(/\\"/g, '"'); expect(compiled).toMatch(/"create_issue":\s*"\s*CONSTRAINTS:[\s\S]{0,300}?temporary_id is required/); }); diff --git a/test/gh-aw-agent-binding-correspondence.test.ts b/test/gh-aw-agent-binding-correspondence.test.ts index 0cb006832..2e7cb5613 100644 --- a/test/gh-aw-agent-binding-correspondence.test.ts +++ b/test/gh-aw-agent-binding-correspondence.test.ts @@ -1,198 +1,173 @@ /** - * Agent-binding correspondence in `workflows/squad.md` (#1859, #1860). - * - * Two defects survived every guard the workflow already had, because those guards - * verify **membership** and the defects were failures of **correspondence**. - * - * Team Guard TG-2 certifies a vocabulary — the set of names that may become a - * `squad:{agent}` label — and validation Check 10 asserts every `Agent` value is a - * member of it. Neither asks whether the label applied to task *N* is the agent the - * plan assigned to task *N*. **A label can be simultaneously certified and wrong.** - * - * Measured on `octodemo/aspiregregator-squad-e2e` (run 32778953402, 12/12 values - * certified, TG-2 green, Check 10 green): - * - * #1859 — the accepted plan assigns task 6 to `McManus`. Issue #17 was created - * with `squad:kint`, the owner of its parent epic (2.1). 11 of 12 correct, - * and the one failure was invisible to every membership check because - * `kint` is a perfectly valid roster name. - * - * #1860 — the activation summary reported `— squad:kint` on epic #6 and - * `squad:kint / squad:mcmanus` on epic #7. Both issues carry `[squad]` - * only. The summary also omitted the `Non-roster agent values` heading - * that the multi-owner epic required, so a run that silently dropped two - * bindings read as clean. - * - * The proximate cause was ambiguity, not disobedience: the epic and task rules both - * said `Labels: squad, squad:{agent}` and never bound `{agent}` to a source. Reading - * tasks grouped under an epic, inheriting the epic's agent is the natural resolution. - * - * These tests lock the disambiguation in place. They assert prompt text rather than - * behavior, which is a weaker instrument than the executed-shell assertions in - * `gh-aw-activate-roster-binding.test.ts` — deterministic post-activation enforcement - * is #1801. What they do buy is that the specific ambiguity which produced #1859 - * cannot silently return, and every failure names the rule that went missing. + * #1859/#1860: membership is not per-task correspondence. Exercise the trusted + * parser, receipt mapper and checker instead of the removed model create rules. */ - import { describe, it, expect } from 'vitest'; -import { readFileSync } from 'node:fs'; -import { join } from 'node:path'; - -const SQUAD_WORKFLOW = join(process.cwd(), 'workflows', 'squad.md'); -const workflow = readFileSync(SQUAD_WORKFLOW, 'utf8').replace(/\r\n/g, '\n'); - -/** - * Whitespace-collapsed view for prose assertions. - * - * Rules that span two or three wrapped lines are otherwise unmatchable, and a test - * that fails when a maintainer rewraps a paragraph teaches people to delete it. - * Matching meaning, not layout. - */ -const prose = workflow.replace(/\s+/g, ' '); - -/** - * Return the single line declaring labels for a `create-issue` step. - * - * Anchored on the step heading so a rule added to the *other* step cannot satisfy - * an assertion about this one — the two rules are near-identical in wording and - * that is precisely how the epic's agent leaked onto tasks in the first place. - */ -function labelLineAfter(heading: string): string { - const at = workflow.indexOf(heading); - expect(at, `"${heading}" is missing from workflows/squad.md`).toBeGreaterThan(-1); - - const line = workflow - .slice(at) - .split('\n') - .find(l => /^-\s+Labels:/.test(l.trim())); - - expect(line, `no "- Labels:" line follows "${heading}" in workflows/squad.md`).toBeDefined(); - return line ?? ''; +import { + ACTIVATION_RECEIPT_SCHEMA, buildActivationBindings, digest, frozenAssignments, + parseActivationPlan, +} from '../workflows/shared/squad-activation.mjs'; +import { + parseAgentRegistry, parseRoster, validateActivation, +} from '../workflows/shared/check-agent-binding.mjs'; + +const team = '## Members\n| Name | Role |\n|---|---|\n| Kint | Lead |\n| McManus | Engineer |\n'; +const registry = { + schema: 'squad-agent-provenance/v1', schema_version: 1, revision: 1, + generated_at: '2026-01-01T00:00:00Z', + agents: Object.fromEntries(['Kint', 'McManus'].map(name => [name.toLowerCase(), { + display_name: name, persistent_name: name, role: 'Engineer', universe: 'synthetic', + status: 'active', created_at: '2026-01-01T00:00:00Z', updated_at: '2026-01-01T00:00:00Z', + }])), +}; +const table = (rows: string[]) => [ + '| # | Title | Agent | Agent ID | Depends On | Epic |', + '|---|---|---|---|---|---|', ...rows, +].join('\n'); +const rows = [ + '| 1 | Same title | Kint | kint | None | mixed |', + '| 2 | Same title | McManus | mcmanus | 1 | mixed |', + '| 3 | Solo task | McManus | mcmanus | 2 | solo |', +]; + +function fixture(body = table(rows), phase: number | null = null) { + const fullPlan = parseActivationPlan(body, 'implementation', registry, team); + const plan = structuredClone(fullPlan); + if (phase !== null) { + plan.tasks = plan.tasks.filter((task: any) => task.phase === phase); + plan.epics = [...new Set(plan.tasks.map((task: any) => task.epic))]; + } + const operations = [ + ...plan.epics.map((epic: string) => ({ id: `epic:${epic}` })), + ...plan.tasks.map((task: any) => ({ id: `task:${task.id}` })), + ]; + const capture = { + repository: 'owner/repo', origin_issue: 42, run_id: 900, run_attempt: 1, + registry, plan, fullPlan, phase, operations, + }; + const journal = { + schema: ACTIVATION_RECEIPT_SCHEMA, capture_sha256: digest(capture), + repository: capture.repository, run_id: capture.run_id, run_attempt: 1, + started: operations.map(operation => operation.id), + parent_fallbacks: [], + receipts: operations.map((operation, index) => ({ + operation_id: operation.id, repository: capture.repository, + number: 101 + index, id: 1001 + index, node_id: `I_${index}`, + url: `https://github.com/owner/repo/issues/${101 + index}`, + })), + }; + const bindings = buildActivationBindings(capture, journal); + const artifact = { + squad_artifact: phase === null ? 'activated' : 'phases-activated', + schema_version: '1', origin_issue: 42, phases: phase === null ? [] : [phase], bindings, + }; + // Independent expectations: never derive actual API labels from the output under test. + const labels = new Map>([ + [101, new Set(['squad'])], [102, new Set(['squad', 'squad:mcmanus'])], + [103, new Set(['squad', 'squad:kint'])], [104, new Set(['squad', 'squad:mcmanus'])], + [105, new Set(['squad', 'squad:mcmanus'])], + ]); + const check = () => validateActivation(artifact, parseRoster(team), labels, 42, { + repository: capture.repository, registry: parseAgentRegistry(registry), + epicAssignments: frozenAssignments(fullPlan), + }); + return { capture, journal, bindings, artifact, labels, check }; } -const EPIC_HEADING = '**2b. Create Epic Issues:**'; -const TASK_HEADING = '**2c. Create Task Issues:**'; - describe('gh-aw: agent-binding correspondence (#1859, #1860)', () => { - it('finds both create-issue label rules, so the assertions below are not vacuous', () => { - // Without this, a renamed heading would make every test here pass by finding - // nothing to object to — the same empty-set failure mode that let #1822 through. - expect(labelLineAfter(EPIC_HEADING).length).toBeGreaterThan(0); - expect(labelLineAfter(TASK_HEADING).length).toBeGreaterThan(0); + it('executes both epic and task mapping boundaries, with distinct receipt identities', () => { + const f = fixture(); + expect(f.capture.operations.map(operation => operation.id)).toEqual([ + 'epic:mixed', 'epic:solo', 'task:1', 'task:2', 'task:3', + ]); + expect(f.bindings.map((binding: any) => [binding.task, binding.issue, binding.epic_issue])) + .toEqual([['1', '#103', '#101'], ['2', '#104', '#101'], ['3', '#105', '#102']]); + expect(() => f.check()).not.toThrow(); }); - it('binds a task label to that task\'s own Agent cell', () => { - const rule = labelLineAfter(TASK_HEADING); - - expect( - /own\s+`Agent`\s+cell/i.test(rule), - `The task label rule must name the task's OWN Agent cell as the source. ` + - `Left unbound it reads as "some agent", and the model resolves it from the ` + - `surrounding epic — which is #1859 exactly. Found:\n${rule}`, - ).toBe(true); - - expect( - /matches this task/i.test(rule), - `The task label rule must say which plan row to read — the one whose "#" ` + - `matches this task. Found:\n${rule}`, - ).toBe(true); + it("binds each task to its own frozen Agent cell even when titles are identical", () => { + const f = fixture(); + expect(f.bindings.map((binding: any) => [binding.task, binding.agent, binding.agent_id, binding.label])) + .toEqual([ + ['1', 'Kint', 'kint', 'squad:kint'], + ['2', 'McManus', 'mcmanus', 'squad:mcmanus'], + ['3', 'McManus', 'mcmanus', 'squad:mcmanus'], + ]); }); - it('forbids inheriting the epic\'s agent or carrying the previous task\'s forward', () => { - const rule = labelLineAfter(TASK_HEADING); - - expect( - /never inherit the parent epic/i.test(rule), - `#1859's observed failure was inheritance from the parent epic. The rule must ` + - `prohibit it by name; a positive instruction alone was already present and ` + - `did not prevent it. Found:\n${rule}`, - ).toBe(true); - - expect( - /carry the previous task/i.test(rule), - `Sequential create-issue calls make carry-forward the other natural drift. ` + - `Found:\n${rule}`, - ).toBe(true); + it.each(['previous task', 'parent epic'])('rejects inheriting the %s certified owner', source => { + const f = fixture(); + const target = f.bindings[1]; + if (source === 'previous task') { + target.agent = 'Kint'; + target.agent_id = 'kint'; + target.label = 'squad:kint'; + f.labels.set(104, new Set(['squad', 'squad:kint'])); + } else { + // #1859: substitute a different but certified parent owner. + target.label = 'squad:kint'; + f.labels.set(104, new Set(['squad', 'squad:kint'])); + } + expect(() => f.check()).toThrow(); }); - it('derives an epic label from its own tasks and refuses to guess for multi-owner epics', () => { - const rule = labelLineAfter(EPIC_HEADING); - - expect( - /`Epic`\s+cell\s+names\s+this\s+epic/i.test(rule), - `An epic has no Agent column of its own; its label is only well-defined as the ` + - `agents of the tasks that name it. Epic 2.1 carried squad:kint while its only ` + - `task belonged to McManus. Found:\n${rule}`, - ).toBe(true); - - expect( - /two or more/i.test(rule) && /only\s+`squad`/i.test(rule), - `A multi-owner epic has no single correct label. Epic 1.2 spanned Kint and ` + - `McManus; the summary printed "squad:kint / squad:mcmanus", which is not a ` + - `label. The rule must resolve to bare "squad". Found:\n${rule}`, - ).toBe(true); - - expect( - /never\s+choose\s+one\s+of\s+several/i.test(rule), - `Picking an arbitrary owner is the failure that looks most like success — the ` + - `label is certified, present, and wrong. Found:\n${rule}`, - ).toBe(true); + it('derives each epic from only its own tasks and never chooses one of multiple owners', () => { + const f = fixture(); + expect(f.bindings[0]).toMatchObject({ + epic_agents: ['kint', 'mcmanus'], epic_label: null, epic_omission_reason: 'multi-owner', + }); + expect(f.bindings[2]).toMatchObject({ epic_agents: ['mcmanus'], epic_label: 'squad:mcmanus' }); + f.bindings[0].epic_label = 'squad:kint'; + f.labels.set(101, new Set(['squad', 'squad:kint'])); + expect(() => f.check()).toThrow(); }); - it('states that membership is not correspondence', () => { - expect( - /simultaneously certified and wrong/i.test(prose), - `The roster-binding gate must say why TG-2 and Check 10 passing is not ` + - `evidence that the binding is right. Both were green for #1859. Without ` + - `this, a future reader reasonably concludes the gate already covers it.`, - ).toBe(true); - - expect( - /membership across the run is not evidence/i.test(prose), - `The gate must require per-issue verification. "Every value is a roster name" ` + - `is true of a run in which every label is on the wrong issue.`, - ).toBe(true); + it('checks full-plan epic ownership even when the selected phase has only one owner', () => { + const f = fixture([ + '### Phase 1', table([rows[0]]), '### Phase 2', + table(['| 2 | Future task | McManus | mcmanus | 1 | mixed |']), + ].join('\n'), 1); + f.labels.clear(); + f.labels.set(101, new Set(['squad'])); + f.labels.set(102, new Set(['squad', 'squad:kint'])); + expect(f.bindings[0]).toMatchObject({ + epic_agents: ['kint', 'mcmanus'], epic_agent_ids: ['kint', 'mcmanus'], + epic_label: null, epic_omission_reason: 'multi-owner', + }); + expect(() => f.check()).not.toThrow(); + f.bindings[0].epic_agents = ['kint']; + f.bindings[0].epic_agent_ids = ['kint']; + f.bindings[0].epic_label = 'squad:kint'; + f.labels.set(101, new Set(['squad', 'squad:kint'])); + expect(() => f.check()).toThrow(); }); - it('requires the summary to report labels applied, not intended', () => { - // #1860 required the summary to record what happened rather than restate the plan, - // and pinned the sentence that made `create-issue` the evidence. #1963 found that - // evidence to be unsound: `create-issue`'s `labels:` field silently drops names the - // repository lacks (#1959), so a successful call proves nothing about labels. The - // condition is now an accepted `add_labels` call for that same issue — strictly - // stronger, and #1860's own defect (squad:kint attributed to an epic that never - // received it) is still caught, now for the right reason. - expect( - /only after an `add_labels` call carrying that label\s+was accepted for that same issue/i.test(prose), - `#1860: the summary attributed squad:kint to epic #6, which never received it. ` + - `The summary must be a record of what happened, not a restatement of the plan.`, - ).toBe(true); - - expect( - /A successful `create-issue` is \*\*not\*\* evidence/i.test(prose), - `#1963: create-issue must be ruled out explicitly. Leaving it unaddressed lets a ` + - `run cite issue creation as proof a label landed, which is exactly the ` + - `over-claim #1860 reported.`, - ).toBe(true); + it('rejects a correct roster label on the wrong issue, despite run-wide membership', () => { + const f = fixture(); + f.labels.set(103, new Set(['squad', 'squad:mcmanus'])); + f.labels.set(104, new Set(['squad', 'squad:kint'])); + expect(() => f.check()).toThrow(); + }); - expect( - /Omitting the heading while omitting the label/i.test(prose), - `The two omissions compound: dropping a label AND its "Non-roster agent values" ` + - `entry produces a summary indistinguishable from a clean run. The gate must ` + - `name that combination, since each omission alone looks benign.`, - ).toBe(true); + it('requires live labels, not merely planned labels or a successful issue receipt', () => { + const f = fixture(); + f.labels.set(102, new Set(['squad'])); + expect(() => f.check()).toThrow(); + f.labels.set(102, new Set(['squad', 'squad:mcmanus'])); + expect(() => f.check()).not.toThrow(); }); - it('keeps the Non-roster agent values heading required, not conditional on taste', () => { - const at = prose.indexOf('Non-roster agent values'); - expect(at, '`Non-roster agent values` heading rule is missing').toBeGreaterThan(-1); + it('preserves explicit omission reasons rather than presenting multi-owner epics as clean bindings', () => { + const f = fixture(); + expect(f.bindings.slice(0, 2)).toEqual(expect.arrayContaining([ + expect.objectContaining({ epic: 'mixed', epic_label: null, epic_omission_reason: 'multi-owner' }), + ])); + delete f.bindings[0].epic_omission_reason; + expect(() => f.check()).toThrow(); + }); - // The heading is referenced in more than one rule; require that at least one - // occurrence is stated as mandatory. Epic 1.2 was a legitimate multi-owner case - // that correctly received no agent label — and was never reported. - expect( - /`Non-roster agent values` heading is \*\*required\*\*/i.test(prose), - 'A "should" here yields silence in exactly the multi-owner case that needs it.', - ).toBe(true); + it('fails before receipts when an owner is a role or its stable identity was remapped', () => { + expect(() => fixture(table([rows[0].replace('Kint | kint', 'Lead | -')]))).toThrow(); + expect(() => fixture(table([rows[0].replace('Kint | kint', 'Kint | mcmanus')]))).toThrow(); }); }); diff --git a/test/gh-aw-command-authorization.test.ts b/test/gh-aw-command-authorization.test.ts index 039c1ff9f..8f6f7612f 100644 --- a/test/gh-aw-command-authorization.test.ts +++ b/test/gh-aw-command-authorization.test.ts @@ -3,12 +3,15 @@ import { cpSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } f import { dirname, join } from 'node:path'; import { execFileSync } from 'node:child_process'; import { createRequire } from 'node:module'; +import { pathToFileURL } from 'node:url'; import { compileFunction, constants } from 'node:vm'; import { parse } from 'yaml'; import { createFirstInstallFixture } from './helpers/gh-aw-install-fixture.js'; import { authorizeSquadCommand, classifySquadCommand } from '../workflows/shared/squad-command-contract.mjs'; import { compileWithPinnedActions } from '../workflows/shared/squad-install-verifier.mjs'; import { DIAGNOSTIC_RUNTIME_SHA, diagnosticRuntime, fetchDiagnosticRuntime } from './helpers/gh-aw-diagnostic-runtime.js'; +import { validateCreateReceipt } from '../workflows/shared/squad-activation.mjs'; +import { parseStructuredData } from '../workflows/shared/check-agent-binding.mjs'; interface Step { name?: string; @@ -18,7 +21,7 @@ interface Step { with?: { script?: string; ref?: string; path?: string }; } interface Lock { - jobs: Record; steps: Step[] }>; + jobs: Record; permissions?: Record; steps: Step[] }>; } type Item = Record; const root = process.cwd(); @@ -83,6 +86,7 @@ beforeAll(async () => { 'mcp_server_core.cjs', 'safe_outputs_mcp_arguments.cjs', 'safe_outputs_bootstrap.cjs', 'safe_outputs_handlers.cjs', 'safe_outputs_append.cjs', 'generate_safe_outputs_tools.cjs', 'safe_outputs_tools.json', 'collect_ndjson_output.cjs', 'process_safe_outputs.cjs', 'add_comment.cjs', + 'index.cjs', 'shim.cjs', ]); mkdirSync(trusted, { recursive: true }); cpSync(join(root, 'workflows/shared/squad-command-contract.mjs'), join(trusted, 'squad-command-contract.mjs')); @@ -267,6 +271,139 @@ async function writeComments(items: Item[]) { } describe('compiled Squad command authorization boundary (#2185)', () => { + it('kills a live-source mutation that checks only HTML-comment-stripped authority', async () => { + const source = readFileSync(join(trusted, 'squad-command-contract.mjs'), 'utf8'); + const mutated = source.replace('const bodies = [rawBody, rawBody.replace(', 'const bodies = [rawBody.replace('); + expect(mutated).not.toBe(source); + const mutantPath = join(trusted, 'squad-command-contract-stripped-mutant.mjs'); + writeFileSync(mutantPath, mutated); + const mutant = await import(pathToFileURL(mutantPath).href); + const body = [ + '~~~~text', '', '~~~~', + ].join('\n'); + const { output } = await serializeRequests([{ name: 'add_comment', arguments: { item_number: 42, body } }]); + const guarded = await runGuard({ payload: payloadFor('plan validate'), permission: 'admin', items: output.items }); + expect(guarded.failures.join('\n')).toMatch(/trusted writer/); + expect(guarded.mutations).toEqual([]); + const write = vi.fn(); + // The historical vulnerability is reproduced by removing only the raw-body + // inspection; the exact same serialized payload must fail the real gate. + await expect(mutant.enforceSquadCommandContract({ + payload: payloadFor('plan validate'), eventName: 'issue_comment', actor: 'maintainer', + resolvePermission: async () => 'admin', agentOutputPath: outputPath, createComment: write, + })).resolves.toMatchObject({ status: 'accepted' }); + expect(write).not.toHaveBeenCalled(); + }); + + it('rejects normalized terminal lifecycle in both the guard and generic compiled writer', async () => { + const body = [ + '## Planning Lifecycle', '~~~', + '**State:** Activated', + '**Activation:** ✅ Done', + '**Last command:** `/squad activate`', + '**Next action:** `/squad implement`', '~~~', + ].join('\n'); + const { output } = await serializeRequests([{ name: 'upsert_lifecycle_state', arguments: { body } }]); + const guarded = await runGuard({ payload: payloadFor('triage'), permission: 'admin', items: output.items }); + expect(guarded.failures.join('\n')).toMatch(/trusted writer/); + expect(guarded.mutations).toEqual([]); + const direct = await writeLifecycle(String(output.items[0].body)); + expect(direct.failures.join('\n')).toMatch(/trusted activation finalizer/); + expect(direct.writes).toEqual([]); + }); + + it('executes the documented pinned index createIssue export and validates its actual API result', async () => { + const job = Object.values(dispatcher.jobs).find(job => + job.steps.some(step => step.name === 'Setup pinned issue creation API'))!; + const runtime = diagnosticRuntime(diagnosticSources, scratch, [], compiledEnv({ env: job.env })); + const api = runtime.load('js/index.cjs'); + const created = { + number: 101, id: 1101, node_id: 'I_synthetic_101', + html_url: 'https://github.com/owner/repo/issues/101', + url: 'https://api.github.com/repos/owner/repo/issues/101', + }; + const create = vi.fn(async () => ({ data: created })); + const core = Object.fromEntries(['debug', 'info', 'warning', 'error', 'setOutput'].map(name => [name, vi.fn()])); + api.setupGlobals(core, { hook: { before: () => {} }, rest: { issues: { create } } }, { + repo: { owner: 'owner', repo: 'repo' }, payload: { issue: { number: 42 } }, + }, {}, {}, () => ({})); + const result = await api.createIssue({ + owner: 'owner', repo: 'repo', title: 'Synthetic task', + body: sanitizeContent('**Parent:** #42\n**Depends On:** None'), labels: ['squad'], + }); + expect(create).toHaveBeenCalledTimes(1); + expect(result.staged).toBe(false); + expect(result.issue).toBe(created); + expect(validateCreateReceipt(result, { id: 'task:1' }, { repository: 'owner/repo' })) + .toMatchObject({ operation_id: 'task:1', number: 101, id: 1101, node_id: 'I_synthetic_101' }); + expect(() => validateCreateReceipt({ + ...result, issue: { ...created, url: 'https://api.github.com/repos/foreign/repo/issues/101' }, + }, { id: 'task:1' }, { repository: 'owner/repo' })).toThrow(/repository/); + expect(runtime.ioErrors).toEqual([]); + }); + + it('compiles the trusted checkout, pinned create API and capture-before-write finalization order', () => { + const job = Object.values(dispatcher.jobs).find(job => + job.steps.some(step => step.name === 'Setup pinned issue creation API'))!; + expect(job).toBeDefined(); + expect(job.permissions).toEqual({ contents: 'read', actions: 'read', issues: 'write' }); + const names = job.steps.map(step => step.name); + const ordered = [ + 'Freeze and validate activation inputs', 'Persist frozen activation capture', + 'Reserve activation and record actual create results', + 'Persist actual activation receipts including partial outcomes', + 'Validate actual bindings before publishing acceptance', + ]; + expect(ordered.map(name => names.indexOf(name))).toEqual( + [...ordered.map(name => names.indexOf(name))].sort((a, b) => a - b)); + expect(ordered.every(name => names.includes(name))).toBe(true); + expect(job.steps.find(step => step.name === 'Checkout trusted activation implementation')?.with?.ref) + .toBe('${{ github.workflow_sha }}'); + const create = job.steps.find(step => step.id === 'activation_create')!; + expect(create.with?.script).toContain("require(join(actionsDir, 'index.cjs'))"); + expect(create.env?.SQUAD_WORKFLOW_SHA).toBe('${{ github.workflow_sha }}'); + expect(create.env?.CAPTURE_ARTIFACT_ID).toBe('${{ steps.activation_capture.outputs.artifact-id }}'); + expect(job.steps.find(step => step.id === 'activation_receipts')?.if).toContain('always()'); + expect(job.steps.find(step => step.name === ordered.at(-1))?.if).not.toContain('always()'); + }); + + it.each(['admin', 'read'])('rejects real serialized malformed legacy activation for %s before writers', async permission => { + const { output, responses } = await serializeRequests([ + { name: 'create_issue', arguments: { title: 'Example work', body: '**Depends On:** `aw_wi1`', temporary_id: 'aw_wi2' } }, + { name: 'add_comment', arguments: { + item_number: 42, body: 'Activation bindings:\n```json\n[{"task":"1","issue":"aw_wi2","epic_issue":"aw_ph1","artifact":"plan"}]\n```', + data: { squad_artifact: 'plan-accepted', schema_version: '1', origin_issue: 42, phases: [] }, + } }, + ]); + expect(responses.every(response => !response.error && !response.result.isError)).toBe(true); + expect(output.items).toHaveLength(2); + const result = await runGuard({ payload: payloadFor('activate'), permission, items: output.items }); + expect(result.failures.join('\n')).toMatch(permission === 'admin' ? /activate_plan|trusted writer/ : /require write|refused mutating/); + expect(result.mutations).toEqual([]); + }); + + it('serializes exactly one custom activation request through the actual pinned MCP adapter', async () => { + const { output, responses } = await serializeRequests([{ name: 'activate_plan', arguments: { plan_comment_id: '123' } }]); + expect(responses[0].error).toBeUndefined(); + expect(output.items).toEqual([{ type: 'activate_plan', plan_comment_id: '123' }]); + const result = await runGuard({ payload: payloadFor('activate'), permission: 'admin', items: output.items }); + expect(result.failures).toEqual([]); + expect(result.mutations).toEqual([]); + }); + + it.each(['activate', 'plan accept', 'plan activate', `reconcile activation 30 ${'a'.repeat(64)}`])( + 'compiled guard refuses absent current output for %s, regardless of historical records', async command => { + for (const items of [[], [{ type: 'noop', message: 'Already completed elsewhere' }]]) { + const result = await runGuard({ payload: payloadFor(command), permission: 'admin', items }); + expect(result.failures.join('\n')).toMatch(/requires|requires exactly/i); + expect(result.mutations).toEqual([]); + } + }, + ); + it('accepts real pinned MCP requests through serialized plan output and the compiled writers', async () => { const data = { squad_artifact: 'plan', schema_version: '1', origin_issue: 42, phases: [] }; const body = lifecycleFromTemplate(installedDispatcher, 'plan').replace('@contributor', 'Contributor'); @@ -807,6 +944,40 @@ describe('compiled Squad command authorization boundary (#2185)', () => { } }, 120_000); + it.each([ + ['tilde fence', '~~~~text', '~~~~'], + ['backtick fence', '````text', '````'], + ['long tilde fence', '~~~~~~text', '~~~~~~'], + ['indented tilde fence', ' ~~~~text', ' ~~~~'], + ])('blocks authorized-mode hidden authority through the actual adapter: %s', async (_name, open, close) => { + for (const artifact of ['plan-accepted', 'phases-accepted', 'activated', 'phases-activated', 'activation-reconciled']) { + for (const withBindings of [false, true]) { + const raw = [ + open, '', close, + ].join('\n'); + const { output, responses } = await serializeRequests([{ + name: 'add_comment', arguments: { item_number: 42, body: raw }, + }]); + expect(responses.every(response => !response.error && !response.result.isError)).toBe(true); + expect(output.items).toHaveLength(1); + const posted = sanitizeContent(String(output.items[0].body)); + expect(posted).toContain('/); + const jobs = parseDocument(compiled).toJS().jobs; + const activation = jobs.activate_plan; + expect(activation.needs).toContain('safe_outputs'); + expect(activation.permissions).toEqual({ contents: 'read', actions: 'read', issues: 'write' }); + expect(activation.if).toContain("contains(needs.agent.outputs.output_types, 'activate_plan')"); + const steps = activation.steps; + const step = (name: string) => { + const matches = steps.filter((item: any) => item.name === name); + expect(matches, `compiled activation step ${name}`).toHaveLength(1); + return matches[0]; + }; + expect(step('Checkout trusted activation implementation').with).toMatchObject({ + ref: '${{ github.workflow_sha }}', 'persist-credentials': false, path: '.squad-activation-trusted-base', + }); + expect(step('Setup pinned issue creation API').uses) + .toBe('github/gh-aw-actions/setup@16b430146d5d5646eccef5a1ceb72152333b18b9'); + const prepare = step('Freeze and validate activation inputs'); + const capture = step('Persist frozen activation capture'); + const create = step('Reserve activation and record actual create results'); + const receipts = step('Persist actual activation receipts including partial outcomes'); + const finalize = step('Validate actual bindings before publishing acceptance'); + expect(prepare.with.script).toContain('activation.prepareActivation'); + expect(capture.with['if-no-files-found']).toBe('error'); + expect(create.with.script).toContain("const { setupGlobals, createIssue } = require(join(actionsDir, 'index.cjs'))"); + expect(create.with.script).toContain('createIssue, sanitizeContent'); + expect(create.with.script).toContain('activation.createActivation'); + expect(create.env.CAPTURE_ARTIFACT_ID).toContain('steps.activation_capture.outputs.artifact-id'); + expect(receipts.if).toContain("always() && steps.activation_create.outcome != 'skipped'"); + expect(receipts.with['if-no-files-found']).toBe('error'); + expect(finalize.env.RECEIPT_ARTIFACT_ID).toContain('steps.activation_receipts.outputs.artifact-id'); + expect(finalize.with.script).toContain('activation.finalizeActivation'); + expect(finalize.if).not.toContain('always()'); + const order = [prepare, capture, create, receipts, finalize].map(item => steps.indexOf(item)); + expect(order).toEqual([...order].sort((left, right) => left - right)); + expect(jobs.conclusion.needs).toContain('activate_plan'); }, 20000); it('keeps safe-output expressions valid after JSON serialization', () => { @@ -2460,32 +2587,37 @@ Use dispatch_workflow to request a retrospective: describe('gh-aw: Plan Activate hardening behaviors', () => { const content = readText(SQUAD_WORKFLOW); - it('includes output budget awareness guidance', () => { - expect(content).toContain('Output Budget Awareness'); - expect(content).toMatch(/total.*>\s*50.*phased activation|phased.*activation.*>\s*50/i); - }); - - it('includes label pre-flight step before issue creation', () => { - expect(content).toContain('Label Pre-flight'); - const preflight = (content.match(/##### Label Pre-flight\n([\s\S]*?)(?=\n#####|\n####)/)?.[1] ?? '').replace( - /\s+/g, - ' ' - ); - expect(preflight).toMatch(/squad.*label.*exist|label.*squad.*exist/i); - }); - - it('label pre-flight grounds any label-creation claim in the add-labels safe output (#1955)', () => { - // Forward-port note (#1955): the workflow used to have no way to create a missing - // label at all, so any claim of "it creates labels" would have been a hallucinated - // capability. That gap is now closed by a real `add-labels` safe output with - // `create-if-missing: true` — the claim is legitimate, but it must be attributed to - // that declared mechanism, not to bare `issues: write` permission or vague - // "safe-output permissions handle the write" hand-waving with no named mechanism. - const preflight = content.match(/##### Label Pre-flight\n([\s\S]*?)(?=\n#####|\n####)/)?.[1] ?? ''; - expect(preflight, 'Label Pre-flight section must exist').not.toBe(''); - expect(preflight).not.toMatch(/safe-output permissions handle the write|no additional token scope/i); - expect(preflight).toMatch(/add-labels|add_labels/); - expect(preflight).toMatch(/create-if-missing/); + it('enforces the frozen planning policy with a hard ceiling of 50 issues', () => { + expect(activationIssueLimit('')).toBe(20); + expect(activationIssueLimit('Squad-Policy: lean')).toBe(10); + expect(activationIssueLimit('Squad-Policy: enterprise')).toBe(50); + expect(activationIssueLimit('Squad-Setting: max_issues=75')).toBe(50); + expect(activationIssueLimit('', '---\nprofile: custom\nmax_issues: 7\n---\n')).toBe(7); + expect(() => activationIssueLimit('Squad-Setting: max_issues=0')).toThrow(); + const fixture = activationFixture(); + const oversized = Array.from({ length: 51 }, (_, i) => `| t${i} | Task | Builder | builder | None |`); + expect(() => parseActivationPlan( + ['| # | Title | Owner | Agent ID | Depends On |', '|---|---|---|---|---|', ...oversized].join('\n'), + 'plan', fixture.capture.registry, fixture.capture.team, + )).toThrow(/exceeds 50 issues/); + }); + + it('provisions every required label before the first actual issue create', async () => { + const fixture = activationFixture(); + await fixture.create(); + const labels = fixture.github.rest.issues.createLabel; + expect(labels.mock.calls.map(([params]: any) => params.name)).toEqual(['squad', 'squad:builder']); + expect(Math.max(...labels.mock.invocationCallOrder)).toBeLessThan(fixture.createIssue.mock.invocationCallOrder[0]); + }); + + it('does not claim successful label provisioning after permission or server failures', async () => { + for (const status of [403, 500]) { + const fixture = activationFixture(); + fixture.github.rest.issues.getLabel.mockRejectedValueOnce(Object.assign(new Error('Label lookup failed'), { status })); + await expect(fixture.create()).rejects.toThrow('Label lookup failed'); + expect(fixture.github.rest.issues.createLabel).not.toHaveBeenCalled(); + expect(fixture.createIssue).not.toHaveBeenCalled(); + } }); it('label pre-flight declares add-labels/create-if-missing instead of an unconfigured gap (#1955)', () => { @@ -2499,23 +2631,41 @@ describe('gh-aw: Plan Activate hardening behaviors', () => { expect(content).not.toMatch(/create-label.*safe-output|safe-output.*create-label/i); }); - it('label pre-flight auto-provisions missing labels instead of omitting them (#1955)', () => { - // Forward-port note (#1955): this used to assert labels that don't yet exist are - // "omitted and reported, not silently applied" — the exact fresh-repo defect #1955 - // reports. The fix auto-creates them via create-if-missing instead of omitting them. - expect(content).not.toMatch(/unavailable labels are omitted and reported/i); - expect(content).toMatch(/auto-creates|auto-provision/i); - }); - - it('includes transient failure handling with single retry', () => { - expect(content).toContain('Transient Failure Handling'); - expect(content).toMatch(/5xx.*retry|retry.*5xx/i); - }); - - it('includes graceful sub-issue fallback for 404/422', () => { - expect(content).toContain('Sub-issue Fallback'); - expect(content).toMatch(/404.*422|422.*404/); - expect(content).toMatch(/degrade gracefully|graceful/i); + it('sends per-operation labels to actual creates and stops if provisioning fails (#1955)', async () => { + const fixture = activationFixture(); + await fixture.create(); + expect(fixture.createIssue.mock.calls.map(([params]) => params.labels)) + .toEqual(fixture.capture.operations.map((operation: any) => operation.labels)); + const failed = activationFixture(); + failed.github.rest.issues.createLabel.mockRejectedValueOnce(new Error('Provisioning failed')); + await expect(failed.create()).rejects.toThrow('Provisioning failed'); + expect(failed.createIssue).not.toHaveBeenCalled(); + }); + + it('never retries an ambiguous create failure and retains the durable no-create reservation', async () => { + const fixture = activationFixture(); + fixture.createIssue.mockRejectedValueOnce(Object.assign(new Error('Ambiguous create'), { status: 503 })); + await expect(fixture.create()).rejects.toThrow('Ambiguous create'); + expect(fixture.savedJournal()).toMatchObject({ started: ['task:first'], receipts: [] }); + await expect(fixture.create()).rejects.toThrow(/reserved or accepted/); + expect(fixture.createIssue).toHaveBeenCalledTimes(1); + }); + + it('falls back only for sub-issue 404/422 while retaining actual textual parent references', async () => { + for (const status of [404, 422, 500]) { + const fixture = activationFixture(); + fixture.github.rest.issues.addSubIssue.mockRejectedValueOnce(Object.assign(new Error('Parent link failed'), { status })); + if (status === 500) { + await expect(fixture.create()).rejects.toThrow('Parent link failed'); + expect(fixture.createIssue).toHaveBeenCalledTimes(1); + } else { + const journal = await fixture.create(); + expect(journal.parent_fallbacks).toEqual(['task:first']); + expect(fixture.createIssue.mock.calls[0][0].body).toContain('**Parent:** #42'); + expect(fixture.github.rest.issues.addSubIssue.mock.calls[0][0]) + .toMatchObject({ issue_number: 42, sub_issue_id: 1101 }); + } + } }); }); @@ -2526,34 +2676,75 @@ describe('gh-aw: Plan Activate hardening behaviors', () => { describe('gh-aw: Plan Activate atomic task-call contract', () => { const content = readText(SQUAD_WORKFLOW); - it('2c contains explicit ATOMIC CONTRACT heading', () => { - expect(content).toMatch(/ATOMIC CONTRACT/i); - }); - - it('2c requires immediate call after each task body (one compose → one call)', () => { - // Must encode the sequential compose/call/verify pattern - expect(content).toMatch(/compose.*call.*verify|compose only that task.*call.*immediately/i); - }); - - it('2c explicitly forbids batching task bodies before calls', () => { - expect(content).toMatch(/DO NOT.*compose.*batch|DO NOT.*buffer|not.*compose.*multiple.*before/i); - }); - - it('2c task body is compact: one sentence scope, 1-2 acceptance criteria', () => { - expect(content).toMatch(/one sentence.*scope|1-2 acceptance criteria/i); - }); - - it('2d incomplete fallback calls report_incomplete with created/expected counts', () => { - expect(content).toContain('report_incomplete'); - expect(content).toMatch(/created.*expected|expected.*created/i); - }); - - it('2d incomplete fallback never noops on count mismatch', () => { - expect(content).toMatch(/never noop/i); + it('accepts only one activate_plan request with the exact numeric plan comment ID', () => { + expect(activationRequest([{ type: 'activate_plan', plan_comment_id: '10' }])).toBe(10); + for (const items of [ + [], [{ type: 'create_issue', title: 'Task' }], + [{ type: 'activate_plan', plan_comment_id: 'aw_wi1' }], + [{ type: 'activate_plan', plan_comment_id: '10', bindings: [] }], + [{ type: 'activate_plan', plan_comment_id: '10' }, { type: 'upsert_lifecycle_state', body: 'Activated' }], + ]) expect(() => activationRequest(items)).toThrow(); + }); + + it('records an actual receipt before composing a dependent task body', async () => { + const fixture = activationFixture(); + const journal = await fixture.create(); + expect(journal.receipts.map((receipt: any) => [receipt.operation_id, receipt.number])) + .toEqual([['task:first', 101], ['task:second', 102]]); + expect(fixture.createIssue.mock.calls[1][0].body).toContain('**Depends On:** #101'); + }); + + it('stops before the next operation when a synchronous create returns a staged placeholder', async () => { + const fixture = activationFixture(); + fixture.createIssue.mockResolvedValueOnce({ staged: true, issue: { number: 'aw_wi1' } } as any); + await expect(fixture.create()).rejects.toThrow(/did not return an actual issue/); + expect(fixture.createIssue).toHaveBeenCalledTimes(1); + expect(fixture.savedJournal()).toMatchObject({ started: ['task:first'], receipts: [] }); + }); + + it('binds each generated body to only its stable work ID and the frozen plan revision', async () => { + const fixture = activationFixture(); + await fixture.create(); + for (const [index, [params]] of fixture.createIssue.mock.calls.entries()) { + expect(params.body).toContain(`**Stable work ID:** ${fixture.capture.operations[index].id}`); + expect(params.body).toContain(`Plan SHA-256: ${fixture.capture.plan_comment.sha256}`); + expect(params.body).toContain(`Frozen plan: ${fixture.capture.plan_comment.url}`); + expect(params.body).toContain('This issue covers only the stable work ID above.'); + } }); - it('2d states re-run is idempotent via title match', () => { - expect(content).toMatch(/idempotent via title match/i); + it('retains exact completed and started operation identities after partial failure', async () => { + const fixture = activationFixture(); + const actualCreate = fixture.createIssue.getMockImplementation()!; + fixture.createIssue.mockImplementationOnce(actualCreate).mockRejectedValueOnce(new Error('Interrupted')); + await expect(fixture.create()).rejects.toThrow('Interrupted'); + expect(fixture.savedJournal()).toMatchObject({ + started: ['task:first', 'task:second'], + receipts: [{ operation_id: 'task:first', number: 101 }], + }); + expect(() => validateReceiptSet(fixture.capture, fixture.savedJournal())).toThrow(/actual create receipts are missing/); + }); + + it('withholds acceptance and lifecycle publication when any actual receipt is missing', async () => { + const fixture = activationFixture(); + await fixture.create(); + const journal = fixture.savedJournal(); + journal.receipts.pop(); + const writes = fixture.github.rest.issues.createComment.mock.calls.length; + await expect(finalizeActivation({ ...fixture, journal, receiptArtifactId: 801 })) + .rejects.toThrow(/actual create receipts are missing/); + expect(fixture.github.rest.issues.createComment).toHaveBeenCalledTimes(writes); + expect(fixture.comments).toHaveLength(1); + expect(fixture.comments[0].body).toContain('Activation pending - not accepted'); + }); + + it('never uses duplicate titles as identity or retries a reserved activation', async () => { + const fixture = activationFixture(); + await fixture.create(); + expect(fixture.createIssue.mock.calls.map(([params]) => params.title)).toEqual(['Same title', 'Same title']); + await expect(fixture.create()).rejects.toThrow(/reserved or accepted/); + expect(fixture.createIssue).toHaveBeenCalledTimes(2); + expect(content).toContain('Never deduplicate by title'); }); }); @@ -2653,16 +2844,42 @@ describe('gh-aw: auto-cast pivot and resumable work (#1689)', () => { expect(content).toMatch(/Never instruct.*\/squad cast|never.*\/squad cast separately/i); }); - it('partial activation N/M copy uses plan total not safe-output cap', () => { - expect(content).toMatch(/plan.*declared total|use the plan.*total.*not the safe-output cap/i); - }); - - it('partial activation post message includes rerun instruction', () => { - expect(content).toMatch(/N of M issues created.*rerun the identical|rerun the identical.*command to continue/i); + it('partial activation completeness uses the frozen operations rather than the native create cap', async () => { + const fixture = activationFixture(); + const journal = await fixture.create(); + expect(journal.schema).toBe(ACTIVATION_RECEIPT_SCHEMA); + expect(journal.capture_sha256).toBe(digest(fixture.capture)); + expect(validateReceiptSet(fixture.capture, journal).size).toBe(fixture.capture.operations.length); + const extra = structuredClone(journal); + extra.receipts.push({ ...extra.receipts[0], operation_id: 'task:extra' }); + expect(() => validateReceiptSet(fixture.capture, extra)).toThrow(); }); - it('partial activation never surfaces safe-output cap as reason', () => { - expect(content).toMatch(/Never surface.*safe-output cap|never surface.*create-issue.*cap/i); + it('partial activation recovery instructs no-create reconciliation instead of blind reruns', () => { + const acceptance = content.slice( + content.indexOf('## skill: `squad-plan-accept`'), + content.indexOf('## skill: `squad-plan-revise`'), + ); + expect(acceptance).toContain('every rerun is no-create'); + expect(acceptance).toContain('reconcile_activation'); + expect(acceptance).toContain('current-state-authorized'); + expect(acceptance).not.toMatch(/N of M issues created.*rerun the identical|idempotent via title match/i); + }); + + it('actual receipt validation distinguishes identity failure from planning capacity', async () => { + const fixture = activationFixture(); + const journal = await fixture.create(); + const operation = fixture.capture.operations[0]; + const issue = { number: 101, id: 1101, node_id: 'I_101', + html_url: 'https://github.com/owner/repo/issues/101', + url: 'https://api.github.com/repos/owner/repo/issues/101' }; + expect(validateCreateReceipt({ staged: false, issue }, operation, fixture.capture)).toEqual(journal.receipts[0]); + for (const mutation of [ + { number: 'aw_wi1' }, { id: null }, { node_id: '' }, + { html_url: 'https://github.com/other/repo/issues/101' }, { pull_request: {} }, + ]) expect(() => validateCreateReceipt( + { staged: false, issue: { ...issue, ...mutation } }, operation, fixture.capture, + )).toThrow(/actual issue|identities|repository or URL/); }); it('safe-output caps (75 create-issue / 20 add-comment) are not conflated with plan limits', () => { @@ -3134,7 +3351,7 @@ describe('gh-aw: Cast replaces disposable bootstrap state (#1909)', () => { it('configures one bounded durable Cast failure output that calls core.setFailed', () => { const castFailureJob = frontmatter.match( - /^ jobs:\n cast-failure:[\s\S]*?(?=^ data:)/m, + /^ cast-failure:\n[\s\S]*?(?=^ [\w-]+:|^ data:)/m, )?.[0] ?? ''; expect(castFailureJob).not.toBe(''); expect(castFailureJob).toContain('max: 1'); @@ -3860,7 +4077,7 @@ describe('gh-aw: canonical package integrity contract', () => { return verifyInstall(root).failures; } - it('registers the exact nested 8/17/1 topology and safe trigger probe', () => { + it('registers the exact nested 8/20/1 topology and safe trigger probe', () => { expect(checkSource(process.cwd())).toEqual([]); const contract = JSON.parse(readFileSync(join(process.cwd(), CONTRACT_SOURCE), 'utf8')); const ciRuntimeCount = readText(join(process.cwd(), '.github/workflows/squad-ci.yml')).match( @@ -3868,10 +4085,15 @@ describe('gh-aw: canonical package integrity contract', () => { ); const ci = readText(join(process.cwd(), '.github/workflows/squad-ci.yml')); expect(contract.workflows.map((entry: { name: string }) => entry.name)).toEqual(WORKFLOW_NAMES); + expect(contract.workflows).toHaveLength(8); expect(contract.shared_runtime).toHaveLength(RUNTIME_TUPLES.length); - expect(contract.shared_runtime).toHaveLength(17); - expect(ciRuntimeCount?.[1], 'strict compile CI runtime cardinality must match the package manifest') - .toBe(String(contract.shared_runtime.length)); + expect(contract.shared_runtime).toHaveLength(20); + const sharedResources = contract.shared_runtime.filter((entry: { destination: string }) => + entry.destination.startsWith('.github/workflows/shared/')); + expect(ciRuntimeCount?.[1], 'strict compile CI shared-directory count must match materialized destinations') + .toBe(String(sharedResources.length)); + expect(sharedResources).toHaveLength(19); + expect(ci).toContain('test -f .github/workflows/squad-agent-binding-check.yml'); const seedStart = ci.indexOf('mkdir -p .github/workflows'); const addStart = ci.indexOf('gh aw add --force "$GITHUB_WORKSPACE/workflows"'); expect(seedStart, 'clean-consumer CI must seed all dispatched peers before gh aw add compiles') @@ -3888,7 +4110,18 @@ describe('gh-aw: canonical package integrity contract', () => { path: 'shared/squad-review-guard.mjs', destination: '.github/workflows/shared/squad-review-guard.mjs', })); + for (const resource of ['squad-activation.mjs', 'check-agent-binding.mjs']) { + expect(contract.shared_runtime).toContainEqual(expect.objectContaining({ + path: `shared/${resource}`, destination: `.github/workflows/shared/${resource}`, + })); + } + expect(contract.shared_runtime).toContainEqual(expect.objectContaining({ + path: 'shared/squad-agent-binding-check.yml', + package_destination: '.github/aw/squad/runtime/shared/squad-agent-binding-check.yml', + destination: '.github/workflows/squad-agent-binding-check.yml', + })); expect(contract.skills).toHaveLength(SKILL_TUPLES.length); + expect(contract.skills).toHaveLength(1); expect(contract.package).toBe('bradygaster/squad/workflows'); expect(contract.manifest).toBe('workflows/aw.yml'); expect(contract.bootstrap.trigger_probe).toBe(TRIGGER_PROBE); @@ -3920,6 +4153,39 @@ describe('gh-aw: canonical package integrity contract', () => { expect(existsSync(join(process.cwd(), 'aw.yml'))).toBe(false); }, 120_000); + it('installs through the actual pinned native manifest boundary and verifies the materialized checker', () => { + const root = createTestWorkspace('gh-aw-native-package-'); + execFileSync('git', ['init', '--quiet'], { cwd: root }); + execFileSync('git', ['remote', 'add', 'origin', 'https://github.com/example/squad-consumer.git'], { cwd: root }); + for (const name of WORKFLOW_NAMES) { + copyInto(root, `workflows/package/${name}.md`, `.github/workflows/${name}.md`); + } + execFileSync('gh', ['aw', 'add', '--force', WORKFLOWS_DIR], { + cwd: root, stdio: 'pipe', timeout: 90_000, + }); + unlinkSync(join(root, '.github/skills/agentic-workflows/SKILL.md')); + const destination = '.github/workflows/squad-agent-binding-check.yml'; + expect(existsSync(join(root, destination))).toBe(false); + writeLocalTestOwnership(root, revisionA); + materializeRuntime(root); + expect(readText(join(root, destination))).toBe(readText(join(WORKFLOWS_DIR, 'shared/squad-agent-binding-check.yml'))); + compileWithPinnedActions(root); + expect(verifyInstall(root, { expectedRevision: revisionA }).failures).toEqual([]); + writeFileSync(join(root, destination), 'tampered native checker\n'); + expect(verifyInstall(root, { expectedRevision: revisionA }).failures.join('\n')) + .toContain(destination); + expect(() => materializeRuntime(root)).toThrow(/refusing to overwrite/); + expect(readText(join(root, destination))).toBe('tampered native checker\n'); + }, 120_000); + + it('refuses to overwrite an unrelated pre-existing native checker during materialization', () => { + const root = makeConsumer(revisionA, false); + const destination = join(root, '.github/workflows/squad-agent-binding-check.yml'); + writeFileSync(destination, 'name: Existing unrelated workflow\n'); + expect(() => materializeRuntime(root)).toThrow(/refusing to overwrite/); + expect(readText(destination)).toBe('name: Existing unrelated workflow\n'); + }); + it('accepts a coherent consumer with the exact ownership cardinality', () => { const root = makeConsumer(); expect(verifyInstall(root, { expectedRevision: revisionA }).failures).toEqual([]); diff --git a/test/helpers/gh-aw-diagnostic-runtime.ts b/test/helpers/gh-aw-diagnostic-runtime.ts index 32b7fe67a..c43d00d0b 100644 --- a/test/helpers/gh-aw-diagnostic-runtime.ts +++ b/test/helpers/gh-aw-diagnostic-runtime.ts @@ -149,6 +149,7 @@ export function diagnosticRuntime( modules.set(name, module); const require = (request: string): any => { if (request.startsWith('.')) return load(posix.normalize(posix.join(posix.dirname(name), request))); + if (sources.has(request)) return load(request); if (request === 'fs' || request === 'node:fs') return filesystem; if (request === 'os' || request === 'node:os') return { ...requireNode(request), tmpdir: () => scratch }; if (['path', 'node:path', 'crypto', 'node:crypto', 'util', 'node:util', 'vm', 'node:vm', diff --git a/test/recast-identity-neutrality.test.ts b/test/recast-identity-neutrality.test.ts index baf20ae6a..5388ef2c1 100644 --- a/test/recast-identity-neutrality.test.ts +++ b/test/recast-identity-neutrality.test.ts @@ -2,6 +2,7 @@ import { readdirSync, readFileSync } from 'node:fs'; import { join, relative } from 'node:path'; import { runInNewContext } from 'node:vm'; import { describe, expect, it } from 'vitest'; +import { parseActivationPlan } from '../workflows/shared/squad-activation.mjs'; const workflowCopies = [ '.github/workflows/squad-triage.yml', @@ -157,26 +158,25 @@ describe('product and team isolation', () => { expect(isolationCheck).not.toContain("'test/cli-packaging-smoke.test.ts'"); const activationWorkflow = readFileSync('workflows/squad.md', 'utf8'); - expect(activationWorkflow).toMatch( - /replace each run of non-`a-z0-9`\s+characters with `-`/ - ); - expect(activationWorkflow).toContain('"label":"squad:{slugged Agent cell}"'); - expect(activationWorkflow).not.toContain( - '"label":"squad:{lowercased Agent cell}"' - ); - const phaseLabels = activationWorkflow.match( - /\*\*Label set, per issue:\*\*([\s\S]*?)- The triggering intent issue/ - )?.[1] ?? ''; - expect(phaseLabels).toContain('emit `squad:{owner-slug}`'); - expect(phaseLabels).toMatch(/Phase issue:[\s\S]*slugged as above/); - - const labelPreflight = activationWorkflow.match( - /##### Label Pre-flight([\s\S]*?)##### Transient Failure Handling/ - )?.[1] ?? ''; - expect(labelPreflight).toContain('mint `squad:{agent-slug}`'); - expect(labelPreflight).toMatch( - /replacing each run of\s+non-`a-z0-9` characters with `-`/ - ); + expect(activationWorkflow).toContain('Call `activate_plan` exactly once'); + expect(activationWorkflow).toContain('Do not call `create_issue`, `add_labels`, `add_comment`'); + const name = '--Example Runtime_Engineer--'; + const timestamp = '2026-01-01T00:00:00Z'; + const plan = parseActivationPlan([ + '| # | Title | Owner | Agent ID | Depends On |', + '|---|---|---|---|---|', + `| task-1 | Synthetic task | ${name} | runtime-engineer | None |`, + ].join('\n'), 'plan', { + schema: 'squad-agent-provenance/v1', schema_version: 1, revision: 1, + agents: { 'runtime-engineer': { + display_name: name, persistent_name: name, status: 'active', role: 'Engineer', + universe: 'synthetic', created_at: timestamp, updated_at: timestamp, + } }, + }, `## Members\n| Name | Role |\n|---|---|\n| ${name} | Engineer |`); + const sync = labelSyncHelpers(readFileSync(labelSyncWorkflowCopies[0], 'utf8')); + expect(plan.tasks[0].label).toBe(`squad:${sync.slugify(name)}`); + expect(plan.tasks[0].label).toBe('squad:example-runtime-engineer'); + expect(plan.tasks[0].agent_id).toBe('runtime-engineer'); const testFiles = filesUnder('test').filter(file => file.endsWith('.ts')); for (const file of testFiles) { diff --git a/workflows/aw.yml b/workflows/aw.yml index 1a213088e..9889796e9 100644 --- a/workflows/aw.yml +++ b/workflows/aw.yml @@ -23,6 +23,12 @@ includes: destination: .github/workflows/squad-command-router.md - skills/gh-aw-enlistment resources: + - source: shared/squad-agent-binding-check.yml + destination: .github/aw/squad/runtime/shared/squad-agent-binding-check.yml + - source: shared/squad-activation.mjs + destination: .github/workflows/shared/squad-activation.mjs + - source: shared/check-agent-binding.mjs + destination: .github/workflows/shared/check-agent-binding.mjs - source: shared/squad-review-guard.mjs destination: .github/workflows/shared/squad-review-guard.mjs - source: shared/squad-install-verifier.mjs diff --git a/workflows/package/squad-bootstrap.md b/workflows/package/squad-bootstrap.md index 79197e1d8..efcc6e251 100644 --- a/workflows/package/squad-bootstrap.md +++ b/workflows/package/squad-bootstrap.md @@ -153,7 +153,7 @@ pre-agent-steps: node --check "$path" >/dev/null } # BEGIN GENERATED RESOURCE DIGESTS - check_hash "$install_verifier" "ce387c5f0c9826a91d4132188f67a50e6996b3d4aa996ad0e5ad79dcf8acb132" + check_hash "$install_verifier" "a8b8fedd8b2804a061c367f6c67113979aa9d792526e8d052a2f01f697bf3642" check_hash "$cast_validator" "15f0d47917d269b5b967dc2a782a0eb761c39af7a92ddb64fed687a427070760" check_hash "$bootstrap_validator" "d552d041f006249bfaa67b10a75877f813796b7698c718ac7eb7cf16f3a72e31" # END GENERATED RESOURCE DIGESTS diff --git a/workflows/package/squad-deps-worker.md b/workflows/package/squad-deps-worker.md index a387db064..378552d76 100644 --- a/workflows/package/squad-deps-worker.md +++ b/workflows/package/squad-deps-worker.md @@ -205,11 +205,12 @@ safe-outputs: /^(?:[-*]\s+)?(?:\*\*)?Activation:(?:\*\*)?\s+✅\s+Done\b/im.test(body) || /^\|\s*Activat(?:e|ion|ed)\s*\|\s*✅\s+Done\b[^|]*\|/im.test(body); const hasTerminalState = - /^(?:[-*]\s+)?\*\*(?:Current state|State):\*\*\s+Activated\s*$/im.test(body) && - hasActivationDone && - /^(?:[-*]\s+)?\*\*Last command:\*\*\s+`\/squad (?:activate|plan accept|plan activate)(?: phase \d+)?`(?:\s+.*)?$/im.test(body) && - /^(?:[-*]\s+)?\*\*Next (?:action|command|recommended):\*\*\s+\S.+$/im.test(body); - const hasNextAction = hasNextCommand || hasTerminalState; + /^(?:[-*]\s+)?\*\*(?:Current state|State):\*\*\s+Activated\s*$/im.test(body); + if (hasTerminalState || hasActivationDone) { + core.setFailed("Activated lifecycle belongs exclusively to the trusted activation finalizer."); + return; + } + const hasNextAction = hasNextCommand; if (!hasLifecycleHeading || !hasState || !hasLastCommand || !hasNextAction) { core.setFailed("Lifecycle body must include an H2 lifecycle heading plus state, last-command, and a nonterminal next-action value consisting of a backticked /squad command."); return; @@ -416,7 +417,7 @@ jobs: - detection - safe_outputs if: |- - !cancelled() && needs.agent.result == 'success' && needs.detection.result == 'success' && needs.safe_outputs.result == 'success' && !contains(needs.agent.outputs.output_types, 'upsert_lifecycle_state') && ( + !cancelled() && needs.agent.result == 'success' && needs.detection.result == 'success' && needs.safe_outputs.result == 'success' && !contains(needs.agent.outputs.output_types, 'activate_plan') && !contains(needs.agent.outputs.output_types, 'upsert_lifecycle_state') && ( (github.event_name == 'issue_comment' && (github.event.comment.body == '/squad activate' || github.event.comment.body == '/squad plan accept' || diff --git a/workflows/package/squad-implement-worker.md b/workflows/package/squad-implement-worker.md index c21bdee5f..9aeb38b52 100644 --- a/workflows/package/squad-implement-worker.md +++ b/workflows/package/squad-implement-worker.md @@ -205,11 +205,12 @@ safe-outputs: /^(?:[-*]\s+)?(?:\*\*)?Activation:(?:\*\*)?\s+✅\s+Done\b/im.test(body) || /^\|\s*Activat(?:e|ion|ed)\s*\|\s*✅\s+Done\b[^|]*\|/im.test(body); const hasTerminalState = - /^(?:[-*]\s+)?\*\*(?:Current state|State):\*\*\s+Activated\s*$/im.test(body) && - hasActivationDone && - /^(?:[-*]\s+)?\*\*Last command:\*\*\s+`\/squad (?:activate|plan accept|plan activate)(?: phase \d+)?`(?:\s+.*)?$/im.test(body) && - /^(?:[-*]\s+)?\*\*Next (?:action|command|recommended):\*\*\s+\S.+$/im.test(body); - const hasNextAction = hasNextCommand || hasTerminalState; + /^(?:[-*]\s+)?\*\*(?:Current state|State):\*\*\s+Activated\s*$/im.test(body); + if (hasTerminalState || hasActivationDone) { + core.setFailed("Activated lifecycle belongs exclusively to the trusted activation finalizer."); + return; + } + const hasNextAction = hasNextCommand; if (!hasLifecycleHeading || !hasState || !hasLastCommand || !hasNextAction) { core.setFailed("Lifecycle body must include an H2 lifecycle heading plus state, last-command, and a nonterminal next-action value consisting of a backticked /squad command."); return; @@ -551,7 +552,7 @@ jobs: - detection - safe_outputs if: |- - !cancelled() && needs.agent.result == 'success' && needs.detection.result == 'success' && needs.safe_outputs.result == 'success' && !contains(needs.agent.outputs.output_types, 'upsert_lifecycle_state') && ( + !cancelled() && needs.agent.result == 'success' && needs.detection.result == 'success' && needs.safe_outputs.result == 'success' && !contains(needs.agent.outputs.output_types, 'activate_plan') && !contains(needs.agent.outputs.output_types, 'upsert_lifecycle_state') && ( (github.event_name == 'issue_comment' && (github.event.comment.body == '/squad activate' || github.event.comment.body == '/squad plan accept' || diff --git a/workflows/package/squad-retro.md b/workflows/package/squad-retro.md index 2d04d6416..2161b8b7f 100644 --- a/workflows/package/squad-retro.md +++ b/workflows/package/squad-retro.md @@ -205,11 +205,12 @@ safe-outputs: /^(?:[-*]\s+)?(?:\*\*)?Activation:(?:\*\*)?\s+✅\s+Done\b/im.test(body) || /^\|\s*Activat(?:e|ion|ed)\s*\|\s*✅\s+Done\b[^|]*\|/im.test(body); const hasTerminalState = - /^(?:[-*]\s+)?\*\*(?:Current state|State):\*\*\s+Activated\s*$/im.test(body) && - hasActivationDone && - /^(?:[-*]\s+)?\*\*Last command:\*\*\s+`\/squad (?:activate|plan accept|plan activate)(?: phase \d+)?`(?:\s+.*)?$/im.test(body) && - /^(?:[-*]\s+)?\*\*Next (?:action|command|recommended):\*\*\s+\S.+$/im.test(body); - const hasNextAction = hasNextCommand || hasTerminalState; + /^(?:[-*]\s+)?\*\*(?:Current state|State):\*\*\s+Activated\s*$/im.test(body); + if (hasTerminalState || hasActivationDone) { + core.setFailed("Activated lifecycle belongs exclusively to the trusted activation finalizer."); + return; + } + const hasNextAction = hasNextCommand; if (!hasLifecycleHeading || !hasState || !hasLastCommand || !hasNextAction) { core.setFailed("Lifecycle body must include an H2 lifecycle heading plus state, last-command, and a nonterminal next-action value consisting of a backticked /squad command."); return; @@ -536,7 +537,7 @@ jobs: - detection - safe_outputs if: |- - !cancelled() && needs.agent.result == 'success' && needs.detection.result == 'success' && needs.safe_outputs.result == 'success' && !contains(needs.agent.outputs.output_types, 'upsert_lifecycle_state') && ( + !cancelled() && needs.agent.result == 'success' && needs.detection.result == 'success' && needs.safe_outputs.result == 'success' && !contains(needs.agent.outputs.output_types, 'activate_plan') && !contains(needs.agent.outputs.output_types, 'upsert_lifecycle_state') && ( (github.event_name == 'issue_comment' && (github.event.comment.body == '/squad activate' || github.event.comment.body == '/squad plan accept' || diff --git a/workflows/package/squad.md b/workflows/package/squad.md index e6150330d..30a14e871 100644 --- a/workflows/package/squad.md +++ b/workflows/package/squad.md @@ -205,11 +205,12 @@ safe-outputs: /^(?:[-*]\s+)?(?:\*\*)?Activation:(?:\*\*)?\s+✅\s+Done\b/im.test(body) || /^\|\s*Activat(?:e|ion|ed)\s*\|\s*✅\s+Done\b[^|]*\|/im.test(body); const hasTerminalState = - /^(?:[-*]\s+)?\*\*(?:Current state|State):\*\*\s+Activated\s*$/im.test(body) && - hasActivationDone && - /^(?:[-*]\s+)?\*\*Last command:\*\*\s+`\/squad (?:activate|plan accept|plan activate)(?: phase \d+)?`(?:\s+.*)?$/im.test(body) && - /^(?:[-*]\s+)?\*\*Next (?:action|command|recommended):\*\*\s+\S.+$/im.test(body); - const hasNextAction = hasNextCommand || hasTerminalState; + /^(?:[-*]\s+)?\*\*(?:Current state|State):\*\*\s+Activated\s*$/im.test(body); + if (hasTerminalState || hasActivationDone) { + core.setFailed("Activated lifecycle belongs exclusively to the trusted activation finalizer."); + return; + } + const hasNextAction = hasNextCommand; if (!hasLifecycleHeading || !hasState || !hasLastCommand || !hasNextAction) { core.setFailed("Lifecycle body must include an H2 lifecycle heading plus state, last-command, and a nonterminal next-action value consisting of a backticked /squad command."); return; @@ -254,6 +255,160 @@ safe-outputs: body: finalBody, }); } + reconcile-activation: + name: Reconcile explicitly authorized current activation state + description: Reconcile only the explicit request bound by the authorized command. Creates no issues and accepts no model-provided mappings. + runs-on: ubuntu-latest + needs: safe_outputs + if: needs.safe_outputs.result == 'success' + permissions: + contents: read + issues: write + max: 1 + inputs: + action: + description: Must be the literal reconcile. + type: string + required: true + steps: + - name: Checkout trusted reconciliation implementation + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + ref: ${{ github.workflow_sha }} + persist-credentials: false + path: .squad-activation-trusted-base + - name: Validate authorization and reconcile existing dependency references + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 + env: + SQUAD_WORKFLOW_SHA: ${{ github.workflow_sha }} + with: + script: | + const { join } = require('node:path'); + const { pathToFileURL } = require('node:url'); + const { readFileSync } = require('node:fs'); + const items = JSON.parse(readFileSync(process.env.GH_AW_AGENT_OUTPUT, 'utf8')).items; + if (items.length !== 1 || items[0].type !== 'reconcile_activation' || + items[0].action !== 'reconcile' || + Object.keys(items[0]).some(key => !['type', 'action'].includes(key))) { + throw new Error('Reconciliation rejects model-provided mappings or additional operations.'); + } + const root = join(process.env.GITHUB_WORKSPACE, '.squad-activation-trusted-base'); + const activation = await import(pathToFileURL(join(root, + '.github/workflows/shared/squad-activation.mjs')).href); + const result = await activation.reconcileActivation({ github, context, env: process.env, root }); + core.info(result.unchanged + ? 'Existing validated reconciliation retained; no new publication is asserted.' + : 'This execution published and independently checked its exact reconciliation response.'); + activate-plan: + name: Create and finalize validated activation + description: Activate the exact plan comment through the trusted deterministic writer. Do not call native issue creation, acceptance or lifecycle tools for activation. + runs-on: ubuntu-latest + needs: safe_outputs + if: needs.safe_outputs.result == 'success' + permissions: + contents: read + actions: read + issues: write + max: 1 + inputs: + plan_comment_id: + description: Exact numeric ID of the latest plan or accepted implementation plan comment on the origin issue. + type: string + required: true + steps: + - name: Checkout trusted activation implementation + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + ref: ${{ github.workflow_sha }} + persist-credentials: false + path: .squad-activation-trusted-base + - name: Setup pinned issue creation API + uses: github/gh-aw-actions/setup@v0.91.5 + with: + destination: ${{ runner.temp }}/gh-aw/actions + - name: Freeze and validate activation inputs + id: activation_prepare + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 + env: + SQUAD_WORKFLOW_SHA: ${{ github.workflow_sha }} + with: + script: | + const { join } = require('node:path'); + const { pathToFileURL } = require('node:url'); + const root = join(process.env.GITHUB_WORKSPACE, '.squad-activation-trusted-base'); + const activation = await import(pathToFileURL(join(root, + '.github/workflows/shared/squad-activation.mjs')).href); + const result = await activation.prepareActivation({ + github, context, env: process.env, root, + outputPath: process.env.GH_AW_AGENT_OUTPUT, + capturePath: join(process.env.RUNNER_TEMP, 'squad-activation-capture.json'), + }); + core.setOutput('fresh', result.existingAcceptanceId ? 'false' : 'true'); + if (result.existingAcceptanceId) core.info('Existing valid acceptance retained; no creates, acceptance edits or new historical attestation.'); + - name: Persist frozen activation capture + id: activation_capture + if: steps.activation_prepare.outputs.fresh == 'true' + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a + with: + name: squad-activation-capture-${{ github.run_id }}-${{ github.run_attempt }} + path: ${{ runner.temp }}/squad-activation-capture.json + if-no-files-found: error + - name: Reserve activation and record actual create results + id: activation_create + if: steps.activation_prepare.outputs.fresh == 'true' + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 + env: + CAPTURE_ARTIFACT_ID: ${{ steps.activation_capture.outputs.artifact-id }} + SQUAD_WORKFLOW_SHA: ${{ github.workflow_sha }} + with: + script: | + const { join } = require('node:path'); + const { pathToFileURL } = require('node:url'); + const { readFileSync } = require('node:fs'); + const actionsDir = join(process.env.RUNNER_TEMP, 'gh-aw/actions'); + const { setupGlobals, createIssue } = require(join(actionsDir, 'index.cjs')); + const { sanitizeContent } = require(join(actionsDir, 'sanitize_content.cjs')); + const { formatIssueBody } = require(join(actionsDir, 'issue_body.cjs')); + setupGlobals(core, github, context, exec, io, getOctokit); + const activation = await import(pathToFileURL(join(process.env.GITHUB_WORKSPACE, + '.squad-activation-trusted-base/.github/workflows/shared/squad-activation.mjs')).href); + await activation.createActivation({ + github, context, env: process.env, + root: join(process.env.GITHUB_WORKSPACE, '.squad-activation-trusted-base'), + createIssue, sanitizeContent, formatIssueBody, + capture: JSON.parse(readFileSync(join(process.env.RUNNER_TEMP, 'squad-activation-capture.json'), 'utf8')), + captureArtifactId: Number(process.env.CAPTURE_ARTIFACT_ID), + journalPath: join(process.env.RUNNER_TEMP, 'squad-activation-receipts.json'), + }); + - name: Persist actual activation receipts including partial outcomes + id: activation_receipts + if: always() && steps.activation_create.outcome != 'skipped' + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a + with: + name: squad-activation-receipts-${{ github.run_id }}-${{ github.run_attempt }} + path: ${{ runner.temp }}/squad-activation-receipts.json + if-no-files-found: error + - name: Validate actual bindings before publishing acceptance + if: steps.activation_prepare.outputs.fresh == 'true' + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 + env: + RECEIPT_ARTIFACT_ID: ${{ steps.activation_receipts.outputs.artifact-id }} + SQUAD_WORKFLOW_SHA: ${{ github.workflow_sha }} + with: + script: | + const { join } = require('node:path'); + const { pathToFileURL } = require('node:url'); + const { readFileSync } = require('node:fs'); + const activation = await import(pathToFileURL(join(process.env.GITHUB_WORKSPACE, + '.squad-activation-trusted-base/.github/workflows/shared/squad-activation.mjs')).href); + await activation.finalizeActivation({ + github, context, env: process.env, + root: join(process.env.GITHUB_WORKSPACE, '.squad-activation-trusted-base'), + capture: JSON.parse(readFileSync(join(process.env.RUNNER_TEMP, 'squad-activation-capture.json'), 'utf8')), + journal: JSON.parse(readFileSync(join(process.env.RUNNER_TEMP, 'squad-activation-receipts.json'), 'utf8')), + receiptArtifactId: Number(process.env.RECEIPT_ARTIFACT_ID), + }); + core.info('This execution published and independently checked its exact activation response.'); cast-failure: name: Fail incomplete Cast description: Fail a Cast run from an emitted factual failure record. @@ -495,7 +650,7 @@ jobs: - detection - safe_outputs if: |- - !cancelled() && needs.agent.result == 'success' && needs.detection.result == 'success' && needs.safe_outputs.result == 'success' && !contains(needs.agent.outputs.output_types, 'upsert_lifecycle_state') && ( + !cancelled() && needs.agent.result == 'success' && needs.detection.result == 'success' && needs.safe_outputs.result == 'success' && !contains(needs.agent.outputs.output_types, 'activate_plan') && !contains(needs.agent.outputs.output_types, 'upsert_lifecycle_state') && ( (github.event_name == 'issue_comment' && (github.event.comment.body == '/squad activate' || github.event.comment.body == '/squad plan accept' || @@ -1294,23 +1449,45 @@ the safe-output `data` envelope because gh-aw expands nested data schemas beyond GitHub's expression-size limit. The post-activation checker can still fail closed without matching model-authored titles. -**Issue references are quoted strings, never bare numbers.** `issue` and `epic_issue` -carry a `#`-prefixed reference in a JSON string: an item's own gh-aw `temporary_id` -(`"#aw_task3"`) when this run created it, or its verified real number (`"#123"`) when the -item was reused or matched by title. The agent never learns a created issue's real number -during its turn, so it never writes one; gh-aw rewrites `#aw_…` references in a comment body -to `#{real number}` once the issue exists. Quoting is required for validity: that -substitution is plain text replacement across the whole body — it does not skip fenced code -blocks — and preserves the `#`, so bare `"issue":#aw_task3` becomes invalid `"issue":#42` -while quoted becomes `"issue":"#42"`. A reference still matching `#aw_…` was never resolved; -consumers MUST treat it as a failure rather than skipping or repairing it. - -**Reported labels mean accepted label operations.** A `label` / `epic_label` asserts that an -`add_labels` safe output carrying that label was accepted for that same issue, targeted by -its temporary ID or verified real number. It does not assert the label was observed on the -issue — safe outputs are applied after the agent turn — and never means it was carried by -`create-issue`, whose `labels:` field cannot create a missing label. Verifying bindings -against the labels actually present is the post-activation checker's job. +**Issue references are quoted, actual numeric references.** `issue` and `epic_issue` +use strings such as `"#123"`. The model emits only `activate_plan`; it must not +emit issue creation, acceptance, bindings or an Activated lifecycle. The trusted +custom job freezes the exact API plan comment and committed registry, reserves +the activation before creating anything, and receives actual IDs directly from +the pinned runtime's synchronous `createIssue` API. The finalizer validates every +operation, dependency, live label and frozen assignment before publishing. +Neither bare nor `#`-prefixed `aw_` tokens are valid consumable references. +Do not infer identity from titles, labels or model claims. + +**Labels remain checked against current API state.** Older records' labels mean +accepted operations, not proof that those operations landed. New finalization +also checks the live result. Epic ownership is derived from the entire frozen +plan, including future phases; verifier-supplied `epicAssignments` preserves that +contract when a phase contains only a subset of its epic's tasks. + +**Recovery is explicit, current-state-only and creates no issues.** An eligible +operator binds a request-comment ID/hash to the original acceptance ID/hash, +current plan/registry and complete numeric task/epic mappings. A validated +`activation-reconciled` record has `basis: current-state-authorized` and explicit +`supersedes_comment_id`/`supersedes_sha256`. Preserve the original bytes, even if +its bindings are malformed. The supplement never claims historical attestation. +Only independently verified request evidence may authorize reader selection; +an actor name, authorization flag or digest inside a record is not proof. +Reject competing, unknown, changed or ambiguous chains; never choose newest. + +Durable capture/receipt artifacts are diagnostics, not producer attestations. +A pending reservation cannot authorize recovery. A missing receipt after an +operation started leaves the activation non-consumable and forbids recreation. +An explicitly authorized current-state request may name that reservation using +`source_kind: pending`, its exact comment ID/hash, matching frozen plan/registry +and complete existing numeric mappings. Only the new request grants authority. +The supplement records `supersedes_kind: pending`; incomplete coverage never +accepts missing work or permits creation, and no historical attestation is claimed. +For later phases, explicitly reconcile complete prior-phase coverage first +when attestable producer mappings are unavailable. Create only the newly +selected phase; carry exact predecessor ID/hash links and validated prior IDs. +Downstream readers must run the packaged binding checker or the SDK selector +with verifier-supplied context; a lifecycle state or green run is insufficient. --- @@ -1321,7 +1498,7 @@ against the labels actually present is the post-activation checker's job. | `research` | Research findings | `[]` | 1 current per issue | Replace body | | `plan` | Fast-path plan | `[]` | 1 current per issue | Replace body | | `plan-accepted` | Fast-path full acceptance | `[]` | 1 total | Immutable | -| `phases-accepted` | Fast-path accepted phase state | Accumulated accepted phases | 1 current per issue | Replace accumulated state | +| `phases-accepted` | Fast-path accepted phase state | Accumulated accepted phases | Explicit continuation chain | Immutable new revision; retain predecessors | | `triage` | Triage disposition | `[]` | 1 current per issue | Replace body | | `program` | Program plan | `[]` | 1 current per issue | Replace body | | `implementation` | Implementation plan | `[]` | 1 current per issue | Replace body | @@ -1329,8 +1506,9 @@ against the labels actually present is the post-activation checker's job. | `scope-accepted` | Scope acceptance | `[]` | 1 total | Immutable | | `impl-accepted` | Full implementation acceptance | `[]` | 1 total | Immutable | | `impl-phases-accepted` | Accepted implementation phase state | Accumulated accepted phases | 1 current per issue | Replace accumulated state | -| `phases-activated` | Activated phase state | Accumulated activated phases | 1 current per issue | Replace accumulated state | +| `phases-activated` | Activated phase state | Accumulated activated phases | Explicit continuation chain | Immutable new revision; retain predecessors | | `activated` | Terminal activation record | All phases when phased; otherwise `[]` | 1 total | Immutable | +| `activation-reconciled` | Authorized current-state supplement | Explicit covered phases, or `[]` for whole plan | 1 per exact superseded source | Immutable; preserve original | | `lifecycle-state` | Lifecycle summary | `[]` | 1 current per issue | Updated every transition | Every entry also requires `schema_version: "1"` and the triggering `origin_issue`. @@ -1711,6 +1889,7 @@ Repository owners must configure Copilot setup steps separately when needed. | `/squad plan accept implementation phase {N}` | Plan Accept Implementation | | `/squad plan activate` | Plan Activate | | `/squad plan activate phase {N}` | Plan Activate | +| `/squad reconcile activation ` | Authorized current-state reconciliation | | `/squad implement` | Implement | | `/squad` (no args) | Cast | @@ -1872,6 +2051,7 @@ Each mode's playbook ships as a **skill**. Enter this section only after **Actor | `plan accept scope` | `squad-plan-accept-scope` | | `plan accept implementation` | `squad-plan-accept-implementation` | | `plan activate` | `squad-plan-activate` | +| `reconcile activation` | `squad-reconcile-activation` | | `implement` | `squad-implement` | **Planning modes only** — before running the mode skill, also load `squad-planning-policy` (policy resolution) and `squad-planning-ontology` (artifact schemas and the lifecycle state machine). The non-planning modes (Cast, Connect, Adopt, Cast Member, Retire, Status, Review Relay, Approve Improvement, Revoke Improvement, Implement) must not load them. @@ -1945,7 +2125,7 @@ Reuses TG-1's committed-HEAD read (working-tree presets cannot leak); emits lowe - **`ROSTER_MEMBER:` lines** are the **certified roster set** — bind only to these, reproduce them verbatim as provenance; a name outside them (bar `@copilot`) must never become a `squad:{name}` label. - **`AGENT_IDENTITY:` lines** are the only authority for stable agent IDs and registry revision. Copy IDs directly; never slug or match labels/roles/paths to invent one. Every roster-assigned new plan row must carry the matching ID. -- **`IDENTITY_UNREADABLE:`** halts new planning and identity binding. Existing legacy plans may activate only with explicit `legacy-plan-missing-id` omissions. +- **`IDENTITY_UNREADABLE:`** halts planning and activation. The trusted activation writer requires explicit, validated Agent ID columns. - **`ROSTER_UNREADABLE:`** halts binding with its named reason — never a provenance sentence for a read that did not happen, never a preset fallback; treat as `TEAM_ABSENT`. ### Auto-Cast Pivot @@ -2770,230 +2950,87 @@ Set Plan = `✅ Done`, state = Planned, last command = `/squad plan`, next = ## skill: `squad-plan-accept` --- -description: Review and activate a fast plan (whole plan or a single phase), accepting it and creating issues. ---- - -`/squad activate` [phase {N}] (recommended) or `/squad plan accept` [phase {N}] -(supported legacy alias) — review the latest fast plan, then combine -scope+implementation acceptance and activation for simple workflows. - -**Behavior:** If `program` or `implementation` artifacts exist, run Accept Scope → Accept Impl → Activate in sequence. If only a `plan` artifact exists, use the fast-path behavior below. - -**Acknowledge:** `🤖 Squad is creating the planned issues…` - -##### Step 1: Find Plan and Route - -Resolve which planning path this issue is on, in this order: - -1. Find the latest `program` and `implementation` artifacts for this issue. If - **either** exists, this is a granular (long-path) plan. Run the granular - sequence in this exact order — **Accept Scope** (`squad-plan-accept-scope`) → - **Accept Implementation** (`squad-plan-accept-implementation`) → **Activate** - (`squad-plan-activate`) — each honoring its own preconditions (e.g. Accept - Implementation requires a `validation` PASS). Then stop; do NOT run the - fast-path steps below. -2. Otherwise, find the latest `plan` artifact. If found, continue with the - fast-path behavior in the steps below. -3. If none of `program`, `implementation`, or `plan` exist, reply "No plan found. - Run `/squad plan` first." and stop. - -##### Step 1a: Phase Resolution - -1. Extract `requested_phase` from args (or null). -2. Paginate all comments and select the newest `plan-accepted`, - `phases-accepted`, and `lifecycle-state` artifacts whose `origin_issue` - matches this issue. -3. **Whole-plan idempotency:** when `requested_phase` is null and a matching - `plan-accepted` artifact exists, create no issues and post no acceptance - artifact. Before stopping, inspect the newest lifecycle state. If it is - missing or does not record State = Activated, Activation = `✅ Done`, and the - invoked activation command, call `upsert_lifecycle_state` exactly once with - the terminal body from Step 5. Return `noop` only when that lifecycle state - is already terminal and consistent. Then stop. -4. **Phase-specific:** read `accepted_phases` from the latest matching - `phases-accepted` artifact (or `[]`). Already accepted → verify the lifecycle - reflects that phase, repair it if stale, then stop with the next-available - hint. Out of order → stop with the sequential hint. -5. Filter items: by phase if set, by unaccepted if prior phases exist, all if fresh. -6. If no items remain after filter: stop. -7. Determine hierarchy only from the latest plan artifact's headings. Any heading - matching `### Phase {N}` makes the plan explicitly phased, including a lone - `### Phase 1`; every accepted row must remain in its declared phase. With no - matching heading, the plan is flat. Do not infer hierarchy from prose, task - count, dependency shape, or personal preference. - -##### Step 2: Create Sub-Issues — Hierarchical - -The origin issue is always the root. Apply Step 1a's heading rule exactly. If the -plan has explicit `### Phase {N}` headings, create one phase issue per accepted -phase under the origin issue, then create that phase's task issues under its phase -issue; never flatten it. For a flat plan, create exactly one issue per accepted -work-item row and set every task's parent to the origin issue. -Do not create an additional epic, summary, root, or phase issue for a flat plan. - -Before any `create-issue` call, run Team Guard Step TG-2 and validate every -accepted plan row. Freeze a binding for each task number containing that row's original `Owner` and `Depends On` values, -plus its authoritative `Agent ID`. If TG-2 emitted a `ROSTER_UNREADABLE:` -line, stop before mutation and report that named reason. An individual `Owner` -matching no certified active roster name and not `@copilot` does **not** stop the -run — matching `squad-plan-activate`, create that issue with the base `squad` label -only, omit the owner label, continue, and record the value under -`Non-roster agent values` (Step 4). Never substitute, re-route, or fall back to -another identity during acceptance. -A roster Owner's `Agent ID` MUST exactly match its `AGENT_IDENTITY:` record; -stop before mutation on mismatch. `@copilot`, non-roster, and legacy rows use -only the explicit identity-omission cases defined in Step 4. - -For every roster member label, derive the slug exactly as label synchronization -does: lowercase the certified display name, replace each run of non-`a-z0-9` -characters with `-`, then trim leading and trailing `-`. The special `@copilot` -value maps to `squad:copilot` instead. - -For each work item, `create-issue`: -- Title: work item title -- Temporary ID: `temporary_id` is required on every `create-issue` call (`require-temporary-id: true`). Mint one per item: `#aw_ph{N}` for a phase issue and `#aw_wi{N}` for a work item, where `{N}` is that row's plan number with non-alphanumeric characters replaced by `_`. Must match `^#?aw_[A-Za-z0-9_]{3,12}$` and be unique in this run — gh-aw silently lets a duplicate's last writer own the mapping. -- Labels: `squad` (color `9B8FCC`), plus `squad:{owner-slug}` (color `9B8FCC`) where `{owner-slug}` is derived from the frozen row `Owner` lowercased first, then normalized by the slug rule above. Map `@copilot` to `squad:copilot`; never `squad:@copilot` — `@copilot` is the one permitted non-roster value and it is mapped, not slugged verbatim. Mint the member label only from that task's certified binding; never re-read team.md, re-route the task, or carry another row's owner forward. An `Owner` certified by neither route gets `squad` alone: omit the owner label, continue, and record the value under `Non-roster agent values` (Step 4). On `ROSTER_UNREADABLE:`, stop and report that reason; never mint from a preset or remembered roster. This computes the label set; `add_labels` applies it (see Fast-Path Label Provisioning) — `create-issue`'s `labels:` field alone cannot land it on a fresh repository. -- Body: scope, acceptance criteria, context (parent, phase, size, depends on, owner), notes, footer -- Parent: phase issue (hierarchical) or root (flat). For a phase issue created in this run, pass its `#aw_ph{N}` temporary ID — `create-issue` resolves it. The flat-plan root is the triggering issue's own real number. Never guess a number for an issue this run created. -- Size: set Project field if available, else body `**Size:**` line -- Label application: in the same turn as this `create-issue` call, call `add_labels` with `item_number` set to this item's own temporary ID and this item's computed label set (see Fast-Path Label Provisioning). `create-if-missing` provisions `squad`/`squad:{owner}` on a fresh repository automatically. - -Copy every frozen `Depends On` value into the created issue body. Cross-phase -deps: look up real issue numbers from prior acceptance comments without changing -the declared task dependencies. - -Create in dependency order. - -##### Fast-Path Label Provisioning - -`create-issue`'s own `labels:` field cannot provision a label: GitHub silently drops -label names that do not already exist in the target repository instead of creating -them, so on a fresh repository every fast-path issue would come out unlabeled. The -`add-labels` safe output (`allowed: [squad, "squad:*"]`, `create-if-missing: true`) is -the only operation here that creates a missing label, and it is the same one -`squad-plan-activate` uses — both activation paths provision labels identically, so -`/squad activate` needs no manual label setup on a fresh repository. - -In the same turn as each `create-issue` call in Step 2, call `add_labels` with: - -- `item_number` — that same `create-issue` call's `temporary_id` (`#aw_ph{N}` for a - phase issue, `#aw_wi{N}` for a work item). For an issue this run did **not** create - — one recognized by Step 1a's idempotency check or matched by title — pass its - verified real number instead; a temporary ID maps only issues this run created. -- `labels` — exactly the set computed below for that one issue, and nothing else. - -**Explicit targeting is mandatory.** Every `add_labels` call MUST pass `item_number`. -Omitting it does not fail — it silently applies the labels to the **triggering intent -issue**, branding the user's own request with an activated item's owner label. Never -reuse another item's temporary ID, and never emit `add_labels` for an item whose -`create-issue` call was not made in this run. `create-issue` returns no real issue -number during this run, so never predict or infer one and never pause between the two -calls waiting for one; gh-aw resolves `add_labels` after the `create-issue` that -minted the ID, so `create-issue` first and `add_labels` immediately after is the -supported order. - -**Label set, per issue:** - -- Work item: `squad`, plus `squad:{owner}` conceptually; emit `squad:{owner-slug}`, - derived from that row's own frozen certified `Owner`, lowercased then slugged as above. - `@copilot` maps to the existing `squad:copilot` routing label — never `squad:@copilot`. - Never inherit the phase issue's owner or carry the previous row's value forward. -- Phase issue: `squad`, plus `squad:{owner}` conceptually; emit `squad:{owner-slug}` - only when every accepted row in that phase names one and the same owner, slugged as above. - Two or more distinct owners is a multi-owner phase: apply only `squad`, choose - none of them, and record it under a - `Non-roster agent values` heading in the Step 4 summary. -- The triggering intent issue is never an `add_labels` target. It is the flat-plan - parent, not an activated item, and receives no owner label from this run. - -An `Owner` that is neither a certified roster name nor `@copilot` never becomes a -`squad:{owner}` label: send `squad` alone for that issue and record the value under -`Non-roster agent values` in the Step 4 summary — the same omit-and-record contract -`squad-plan-activate` uses, so an uncertified value can reach `add_labels` only as -the base `squad` label. - -Re-applying an already-present label on a rerun is a no-op under add-only merge -semantics, so this is safe under Step 1a's idempotency path. Labels must have -descriptions and intentional colors when they already exist; a label auto-provisioned -by `create-if-missing` on a fresh repository instead receives gh-aw's deterministic -color and an empty description — that is expected, not a failure, and must not be -reported as one. Report only the labels an accepted `add_labels` call carried for that -same issue; never a label that was skipped, deferred, or merely intended, and never one -attributed to `create-issue` (see Step 4, Label operations accepted). - -##### Step 3: Preserve Dependencies - -For every non-empty frozen `Depends On` entry, preserve the corresponding task -references in the created issue body using the created issue-number map. Do not -infer, drop, or reorder dependencies. - -Add native `blockedBy` relationships only when an available approved safe-output -tool explicitly exposes that field or operation. Do not bypass safe outputs with -a direct write API call. When native edges are unavailable, body references are -the expected fallback, and the acceptance summary MUST say that dependencies -were preserved in issue bodies without native edges. - -##### Step 4: Post Summary - -Artifact data varies: -- Phase-specific: `data: {"squad_artifact":"phases-accepted","schema_version":"1","origin_issue":{issue_number},"phases":[{accumulated}]}` → Phase accepted table + remaining phases table + the `Activation bindings:` JSON array. -- Full (no phases): `data: {"squad_artifact":"plan-accepted","schema_version":"1","origin_issue":{issue_number},"phases":[]}` → All issues table + the `Activation bindings:` JSON array. - -Report the exact number of created task issues, their actual parent hierarchy, -and whether dependencies use native edges or the body-reference fallback. Never -claim an epic, phase issue, sub-issue relationship, or native dependency edge -that was not created. - -**Every phase and full acceptance artifact body MUST include an `Activation -bindings:` fenced JSON block containing a non-empty array** — the identical -versioned provenance, binding shape, quoting, and omission semantics as -`squad-plan-activate` Step 4: one object per created/recognized work item with -repository/origin/artifact/registry provenance, -`task`/`issue`/`epic`/`epic_issue`/`agent_id`/`epic_agent_ids`/`agent`/`epic_agents`, plus `label` or -`omission_reason`, and `epic_label` or `epic_omission_reason` (`multi-owner` or -`non-roster`). `issue` and `epic_issue` are quoted JSON strings — that item's own -`temporary_id` when created this run, its verified real number when reused — -never bare numbers; a surviving `#aw_…` reference means `create-issue` never -landed and must be left unresolved rather than repaired. Never omit a created -or recognized task from `bindings`, never infer an issue number, and never -emit an empty array — the deterministic post-activation checker treats a -missing, empty, malformed, or unresolved bindings block on a `plan-accepted` -or `phases-accepted` artifact as a failure exactly as it does for `activated` -and `phases-activated`. - -###### Label operations accepted - -Identical semantics to `squad-plan-activate` Step 4. A label reaches an activated issue through exactly one route: -an accepted `add_labels` operation targeting that issue. Report `squad:{owner-slug}` only when -this run made an `add_labels` call carrying that label and targeting that same issue — by -its own `temporary_id`, or by its verified real number for a reused issue. A successful -`create-issue` is **not** evidence: its `labels:` field cannot land a label on a fresh -repository, so no summary may say a label was carried by, applied by, or included in issue -creation. Never report a label merely computed, intended, skipped, or deferred, never borrow -another item's label operation, and never report an accepted operation as an omission. - -`add_labels` is a safe output: this run knows only that the call was accepted for a specific -target, never the GitHub API result. State it at that strength — never write that a label -was verified, confirmed, or checked on the issue, because nothing here reads labels back. - -Whenever an accepted `Owner` did not become a `squad:{owner}` label — a multi-owner -phase issue, or a value certified by neither the roster nor `@copilot` — a -`Non-roster agent values` heading is **required** in this summary, naming the value -and the issue it applied to. Omitting the label while omitting the heading reports a -clean run that did not happen. Conversely, never emit the heading for an owner that -*did* become an accepted label — that manufactures a defect that did not occur. - -##### Step 5: Update Fast-Path Lifecycle - -Call `upsert_lifecycle_state` once with the complete lifecycle body. - -- Phase-specific: keep Plan = `✅ Done`, record phase `{N}` activated, set the - last command to the invoked `/squad activate phase {N}` or legacy alias, and - point next to the next unactivated phase. -- Full or last phase: set Plan = `✅ Done`, Activation = `✅ Done`, state = - Activated, and the last command to the invoked `/squad activate` or legacy - alias. This is terminal. Set Next action to explicit terminal prose such as - `None — activation is complete`; do not invent another slash command. +description: Activate the latest frozen plan through the trusted deterministic writer. +--- + +`/squad activate [phase N]` and `/squad plan accept [phase N]` use the same +trusted activation writer. The model never creates the issues or writes acceptance. + +1. Paginate all origin-issue comments. Identify the latest `plan` or + `implementation` artifact, matching the current origin issue. +2. Call `activate_plan` exactly once with `plan_comment_id` set to that + comment's numeric ID as a string. Emit no other safe-output operation. +3. Stop. The writer freezes the exact comment and committed registry, validates + stable IDs, owners, dependencies, hierarchy and capacity, then captures actual + issue IDs from the pinned runtime's supported synchronous createIssue API. + +Do not call `create_issue`, `add_labels`, `add_comment` or +`upsert_lifecycle_state` for activation. Do not author Activation bindings, +acceptance metadata, issue-number maps, or an Activated lifecycle. In particular, +a bare `aw_wi1` in JSON is not an actual issue number. + +The finalizer's `Activation bindings:` maps `"task":"{plan # cell}"` to actual +numeric issue references, including `"epic_issue":"{epic issue reference}"` and +`"epic_agents":["{all distinct lowercased Agent cells in the frozen epic}"]`. +These describe its output fields, not a model-authored acceptance template. + +The writer alone publishes versioned acceptance and lifecycle after every actual +ID, dependency reference, frozen owner, and live label validates and capture plus +receipts have been durably uploaded. A failed or ambiguous write is not acceptance. +The pending reservation is durable before issue creation; every rerun is no-create +unless independently validated current-state recovery supplies the missing evidence. +Never deduplicate by title, guess issue numbers, recreate an acceptance, or edit an +invalid acceptance. Artifact hashes identify bytes; they are not historical producer +attestation. + +Supported plan tables require unique stable `#`, `Title`, `Owner`, +`Agent ID` and `Depends On` columns. Dependencies are unique comma-separated +stable task IDs, or `None`/`-` for none. Explicit `### Phase N` headings +define hierarchy; a flat plan uses the origin issue as parent without labeling it. +Granular tables use `Agent` instead of `Owner` and also require `Epic`; +granular activation requires prior scope and implementation acceptance. Malformed +tables, omitted identity columns, missing prior-phase authority, cycles, and plans +over the applicable per-invocation policy limit (never above 50 issues) fail before +issue creation. Larger plans may activate bounded phases. + +For phase continuation, first supply an authorized reconciliation request covering +all prior phases and their exact existing numeric issue IDs. The writer verifies +the frozen plan/registry, current hierarchy/dependencies, and explicit predecessor +chain, then creates only the newly selected work. Shared epics retain the complete +plan's owner/label set, including future phases. Missing or stale mappings block +that invocation; do not infer a map from titles or rerun native creates. + +For an existing invalid record, use the separately authorized +`/squad reconcile activation ` contract. +The model must never supply the authoritative mapping. + +## skill: `squad-reconcile-activation` +--- +description: Reconcile explicit operator-approved current issue mappings without creating issues. +--- + +For `/squad reconcile activation `, +call `reconcile_activation` exactly once with `action: "reconcile"`, and +emit no other safe output. The trusted writer reads the request identified by +the authorized command, verifies its author permission and exact content hash, +and freezes current plan/registry and numeric task/epic issue mappings. + +It may correct only approved temporary references in one standalone +`**Depends On:**` field. Titles, scope prose, owners, labels, hierarchy and the +original acceptance remain unchanged. Fresh reads and post-write checks detect +conflicts but are not an atomic compare-and-swap; external editors must avoid +concurrent changes. Partial correction is not acceptance. The explicit +`activation-reconciled` supplement has basis `current-state-authorized`, +links the original comment ID/hash, and makes no historical creation claim. +Only the deterministic chain-validated reader may select it; never select the +newest matching envelope as a fallback. + +Downstream consumption must run the installed read-only checker +`.github/workflows/shared/check-agent-binding.mjs` with all origin records and +the current team/registry. It verifies the API request and source hashes before +skipping a superseded malformed original. The SDK `selectActivationRecord` API +requires independently supplied reconciliation evidence and frozen epic +assignments for partial-phase groups. Structural parsing alone is not admission. ## skill: `squad-plan-revise` --- @@ -3299,6 +3336,15 @@ this synthesis is Check 16 ❌ Critical and cannot become `RESULT: PASS`. Structure: `## ✅/❌ Squad Plan Validation — PASSED/FAILED` → Validated artifacts + timestamp → Results table (Check|Status|Details; all checks 1–16) → Adversarial Evidence (Steelman of the opposition; Load-bearing assumptions; 30-day pre-mortem; Alternative approach; Remaining risk acceptance, each with WHAT/WHY/HOW) → Validator Synthesis (independent reasoning across structural and adversarial evidence) → Issues Found (Critical ❌ then Warnings ⚠️ with fix instructions) → Summary (counts + validator-owned verdict) → Next action. +For deterministic phase acceptance, include exactly one plain `RESULT: PASS` or +`RESULT: FAIL` line, `**Implementation plan:** `, and +`**Implementation SHA-256:** `. +The results table uses exactly `Check | Status | Details`, with unique numeric +Check cells 1–16 and `PASS`/`FAIL` status. Include a `Task | Size | Traces to` +Sizing Summary table covering every stable task ID. This is frozen validator +evidence, not permission to invent findings, skip checks, or re-derive sizing +during acceptance. + Rules: any ❌ = FAILED heading+verdict. Warnings alone ≠ failure. Structural PASS alone cannot produce overall PASS. Overall PASS requires checks 1–16 to have run, complete adversarial evidence, explicit remaining-risk @@ -3362,6 +3408,15 @@ description: Accept the implementation plan (whole or per phase) and lock it for `/squad plan accept implementation` [phase {N}] — locks the implementation plan (the HOW). Supports incremental phase acceptance. +**Phase-specific command:** use the `squad-plan-accept` trusted `activate_plan` +procedure only; do not emit separate acceptance, lifecycle or create operations. +The trusted writer binds the current implementation revision and selected phase, +requires the existing scope acceptance and matching passed validation (all sixteen +checks plus authoritative sizing/traceability), validates the prior-phase mapping +bridge, then records implementation-phase acceptance and creates/finalizes only +that phase. Missing/stale prerequisites fail before creation. Retries never +recreate prior work. The remaining steps below apply to whole-plan acceptance. + **Acknowledge:** `🤖 Squad is creating implementation tasks…` ##### Step 1: Validate @@ -3395,310 +3450,20 @@ Both include: impl plan link, scope acceptance link, accepted by, date, counts. Phase: `🔄 Phase {N} of {total}`. Full: `✅ Done`, next = `/squad plan activate`. -##### Step 5: Auto-Activate (Phase-Specific Only) - -After phase acceptance, check if ready for automatic activation: -1. All prior phases must be accepted AND activated (check the latest `phases-activated` artifact's `phases` array). -2. If Phase 1 or all prior activated: auto-activate using Plan Activate logic for this phase. -3. If prior phases not activated: tell user to activate them first. -4. Update lifecycle to reflect both acceptance and activation. +Whole-plan acceptance remains acceptance-only; next use `/squad plan activate`. +Phase acceptance and activation use the single trusted path above, never a +second native create path or model-authored Activated lifecycle. ## skill: `squad-plan-activate` --- -description: Activate an accepted plan by creating the epic and task issues on GitHub. +description: Activate an accepted implementation plan through the trusted deterministic writer. --- -`/squad plan activate` [phase {N}] — creates real GitHub issues/milestones. Irreversible. - -**Acknowledge:** Phase: `🤖 Squad is activating Phase {N}…` Full: `🤖 Squad is activating the team…` - -##### Step 1: Validate - -**Phase-specific:** -1. Check the latest `impl-phases-accepted` artifact's `phases` array contains requested phase. If not: stop. -2. Check ordering: prior phase must be in the latest `phases-activated` artifact's `phases` array. If not: stop. -3. Check not already activated. - -**No phase:** -1. Find an `impl-accepted` artifact or fully accepted `impl-phases-accepted` state. If none: stop. -2. Check for an `activated` artifact: if it exists, only create missing issues (idempotent). - -##### Hallucination Guard - -`create-issue` does **not** return a real GitHub issue number during this run — gh-aw defers -creation to the safe-output job, so the agent sees only a success acknowledgement. NEVER -predict, infer, or "read back" a number for an issue this run created, and never wait for -one. Use a real number only when verified independently: a pre-existing issue matched by -title, the triggering issue, or one recorded in a prior run's artifact. - -##### Temporary-ID Contract - -Every `create-issue` call MUST carry a `temporary_id`, and every operation pointing back at -that issue MUST reuse the identical value. `require-temporary-id: true` enforces the first -half — a call without one is rejected. The second half is yours. - -**Form.** Matches `^#?aw_[A-Za-z0-9_]{3,12}$`. Write the canonical `#aw_…` form. Dots, -hyphens, spaces, and `:` are illegal. - -**Minting — derive, never invent.** Epic: `#aw_epic{K}`, `{K}` = the epic's 1-based position -in the accepted plan. Task: `#aw_task{N}`, `{N}` = that task's own `#` cell with every -character outside `A-Za-z0-9` replaced by `_` (`2.3` → `#aw_task2_3`). If a derived ID -exceeds 12 characters after `aw_`, drop the `epic`/`task` word (`#aw_e{K}` / `#aw_t{N}`) -rather than truncating the number. - -**Uniqueness is your responsibility.** gh-aw does not reject a duplicate `temporary_id`; it -silently lets the last `create-issue` using it own the mapping, so every later reference -lands on the wrong issue. Confirm each ID is unused; epics and tasks share one namespace. - -**Explicit targeting is mandatory.** Every `add_labels` call MUST pass `item_number`. Omitting -it does not fail — it silently labels the **triggering intent issue**, branding the user's own -request with an activated item's owner label. - -**Existing and reused issues.** An issue recognized by Step 1's idempotent-rerun path or a -dedup-by-title match has a real, verified number: target it by that number, not a temporary -ID, which only maps issues this run created. - -##### Output Budget Awareness - -Count expected issues before starting. If total > 50: recommend phased activation (`/squad plan activate phase {N}`) and proceed with the current phase only. If total > 30: use compact issue bodies (scope + acceptance criteria only; omit elaboration). - -**Activation capacity budget.** The largest activation supported in a single run is -**50 issues** — the `enterprise` profile's `max_issues: 50` (the highest documented -limit) and the threshold the phased-activation rule above enforces. Worst case: - -| Safe output | Worst case | `max` | -|---|---|---| -| `create-issue` | 50 — one per epic/task | 75 | -| `add_labels` | 50 — one per created issue | 110 | -| Labels in one call | 2 — `squad` + `squad:{agent}` | 10 | -| Label names per run | 100 — 50 × 2 | 110 | - -**`max` limits safe-output items (tool calls), not label names inside a call.** One -`add_labels` call carrying two labels consumes **one** unit of budget, not two. gh-aw's -injected constraint now states "Maximum 110 add_labels call(s) allowed" and a maximum -of 10 labels per call. The existing 110 budget also covers the older compiler's -ambiguous label-name reading — 50 calls and 100 names — so neither can justify -skipping a label operation. Never batch several issues' labels into one call to save -budget (it breaks per-issue correspondence), and never stop labeling early. - -**Reaching a cap is enforced twice, and neither layer fails the run.** A call past the -limit is rejected at invocation time with a JSON-RPC error (`E002: {type} limit -reached`) the agent *does* see; a surplus item is dropped at collection time with a -warning. Neither marks the run failed, so it can finish green with labels missing — -Step 2e, not the cap machinery, is what notices. - -##### Label Pre-flight - -**Roster binding gate — run this before any `create-issue` call.** - -1. Run Team Guard Step TG-2; its `ROSTER_MEMBER:` lines are the **certified roster - set** — the only valid source for a `squad:{agent-slug}` label this run. Do not re-read - team.md or recall a name. -2. If TG-2 emitted a `ROSTER_UNREADABLE:` line, STOP: report that named reason in the - activation summary and mint no `squad:{agent-slug}` label. Never print a roster-provenance - sentence for a read that did not happen, and never fall back to a preset or - remembered roster. -3. Reproduce the certified `ROSTER_MEMBER:` lines verbatim in the summary as the - provenance of the labels applied — the summary may name only values TG-2 emitted. -4. For every roster `Agent`, require the plan `Agent ID` to exactly match the - same `AGENT_IDENTITY:` record; stop before mutation on mismatch and never - substitute an ID. Then certify its lowercased raw value against a - `ROSTER_MEMBER:` name and mint `squad:{agent-slug}` by replacing each run of - non-`a-z0-9` characters with `-` and trimming leading and trailing `-`. The special - value `@copilot` maps to the existing `squad:copilot` routing label — never - `squad:@copilot`. -5. A value matching no certified name and not `@copilot` MUST NOT become a - `squad:{agent}` label: apply only `squad` for that issue and record the value under a - `Non-roster agent values` heading, naming the certified set it should come from. - `{agent}` is the rejected raw value; emitted labels use `{agent-slug}`. -6. Completeness: when the plan names at least one roster `Agent`, at least one - `squad:{agent-slug}` label MUST be applied across the created issues. Zero labels on a - plan with roster owners is a binding failure, not a pass — report it, don't proceed - silently. -7. **Correspondence — the label must match *this* issue's own row.** Steps 4-6 certify the - *vocabulary*: that each value is a roster Name. They do not check that the right issue - received the right value. **A label can be simultaneously certified and wrong.** Before - each `create-issue` call, re-read the agent from that issue's own source — a task's own - `Agent` cell, an epic's derived task-set — and never from the row above it, the parent - epic, or the previous call. Verify per issue; membership across the run is not evidence. -8. **Report what was accepted, not what was intended.** The activation summary may name a - `squad:{agent-slug}` label for an issue only after an `add_labels` call carrying that label - was accepted for that same issue — targeted by its own `temporary_id`, or by its verified - real number for a reused issue. A successful `create-issue` is **not** evidence: its - `labels:` field cannot land a label on a fresh repository, so a label is never "carried - by" issue creation. Never state a label that was skipped, omitted, deferred, or assumed, - and never report an accepted label operation as an omission. Whenever an `Agent` value did - not become a label — multi-owner epic, uncertified name, unavailable label — the - `Non-roster agent values` heading is **required**, and must name the value and the issue - it applied to. Omitting the heading while omitting the label reports a clean run that did - not happen. See Step 4's Label operations accepted section for the full contract. - -**Label provisioning.** The `add-labels` safe output (`allowed: [squad, "squad:*"]`, -`create-if-missing: true`) auto-creates `squad` and any `squad:{agent-slug}` label the first -time this run needs it — a fresh repository with zero Squad labels requires no manual -provisioning and is never a prerequisite gap. `create-issue`'s own `labels:` field cannot -do this: GitHub silently drops label names that do not already exist in the target -repository instead of creating them, which is the behavior that previously produced the -"prerequisite gap" reported here. Do not rely on `create-issue`'s `labels:` field alone -to land a label on a fresh repository. - -In the same turn as each `create-issue` call in Steps 2b/2c, call `add_labels` with -`item_number` set to that call's `temporary_id` and exactly the label set Steps 4-8 computed -for that issue — `squad` alone, or `squad` plus the one `squad:{agent-slug}` label the -correspondence rule (Step 7) certified. Do not wait for a returned issue number; none -arrives. gh-aw resolves `add_labels` after the `create-issue` that minted the ID, so that -order is the supported one. `create-if-missing` creates any label that does not yet exist -before applying it; re-applying an already-present label on a rerun is a no-op, so this is -safe under the Step 1 idempotent-rerun path. Never emit `add_labels` for an item whose -`create-issue` call was not made in this run. - -##### Transient Failure Handling - -On `5xx` response from `create-issue`: wait briefly and retry once. On second failure or `4xx`: record the issue title as skipped in the activation summary, continue with remaining issues. Never abort the full run for a single transient failure. - -##### Sub-issue Fallback - -When setting a `parent` sub-issue relationship returns `404` or `422` (feature disabled or repo plan): degrade gracefully — record the intended parent as a body reference, then continue. If that parent was minted this run, write its temporary ID (`Parent: #aw_epic{K}`); gh-aw rewrites an `#aw_…` body reference to the real `#{number}`, so no number is predicted. If the parent is pre-existing or was matched by dedup, write its verified real number (`Parent: #{issue_number}`) — gh-aw leaves an unresolved `#aw_…` reference in the body verbatim, so a temporary ID it never minted would ship as a meaningless literal. Never fail activation over sub-issue API unavailability. - -##### Step 2: Create Issues — Full Hierarchy - -Root → Epics → Tasks. Phase-specific: filter to matching phase heading. - -**2a. Create Milestones:** Check for existing, create missing. Record IDs. On failure: document in root issue body instead. - -**2b. Create Epic Issues:** `create-issue` per epic (dedup by title `[Epic] {name}` if already exists from prior phase). -- Title: `[Epic] {name}` -- Temporary ID: `temporary_id: "#aw_epic{K}"` per the Temporary-ID Contract. Required — the call is rejected without it. -- Labels: `squad` (0075ca), `squad:{agent-slug}` (e4e669) where `{agent-slug}` is **derived from this epic's own tasks**: collect the `Agent` values of every implementation-plan row whose `Epic` cell names this epic. Exactly one distinct roster value → mint its label using the lowercase/non-alphanumeric/hyphen slug rule above; exactly `@copilot` → mint `squad:copilot`. Two or more → multi-owner epic: apply only `squad` and record it under `Non-roster agent values`. Never mint a single agent label for a multi-owner epic, and never choose one of several. -- Body: outcome, stories, epic-level acceptance criteria, context (parent, initiative, milestone, deps) -- Parent: sub-issue of root intent issue — the triggering issue's own real number, which is known independently of this run's creations -- Milestone: assigned -- Label application: in the same turn as this `create-issue` call, call `add_labels` with `item_number` set to this epic's `#aw_epic{K}` temporary ID and this epic's computed label set (see Label Pre-flight). A dedup-by-title match instead targets that existing issue's verified real number. `create-if-missing` provisions `squad`/`squad:{agent}` on a fresh repository automatically. - -**⚠️ DO NOT STOP after epics. Tasks MUST follow immediately.** - -**2c. Create Task Issues:** `create-issue` per task in dependency order. - -> **⚠️ ATOMIC CONTRACT — strictly one task at a time:** -> For each task: compose ONLY that task's body → call `create-issue` immediately, carrying that task's `temporary_id` → call `add_labels` with `item_number` set to that same temporary ID and the task's computed label set → then move to the next task. -> **DO NOT** compose or buffer multiple task bodies before making calls. One compose → one `create-issue` call → one `add_labels` call, repeated per task. Do not pause for a returned issue number between the two calls; none is returned. - -- Title: task title -- Temporary ID: `temporary_id: "#aw_task{N}"` per the Temporary-ID Contract. Required, and unique across every epic and task in this run. -- Labels: `squad` (0075ca), `squad:{agent-slug}` (e4e669) where `{agent-slug}` is derived from **this task's own `Agent` cell** using the same lowercase/non-alphanumeric/hyphen slug rule as label synchronization — read from the implementation-plan row whose `#` matches this task. Map `@copilot` to `squad:copilot`. Never inherit the parent epic's agent, and never carry the previous task's value forward: re-read the `Agent` cell for every task, because consecutive tasks under one epic routinely have different agents. No `size:*` labels unless policy says so. -- Body: one sentence describing scope; 1-2 acceptance criteria; one compact context line (parent epic, size, deps) -- Parent: sub-issue of EPIC (not root). If 2b minted this epic in this run, pass its `#aw_epic{K}` temporary ID, which `create-issue`'s `parent` field accepts. If 2b instead matched a pre-existing epic by title, that epic has no temporary ID in this run — pass its verified real number. Never guess the epic's real number, and never pass a temporary ID that was not minted this run. -- Milestone: same as parent epic -- Size: Project field if available, else body line -- Label application: same as epics — call `add_labels` with `item_number` set to this task's temporary ID and the task's computed label set (see Label Pre-flight). `create-if-missing` provisions `squad`/`squad:{agent}` on a fresh repository automatically. - -**2d. Self-Validation:** The **created count** is the number of `create-issue` calls this run emitted. Compare it against the plan's declared total (not the safe-output cap). If it is lower: call `report_incomplete` immediately with `created={N}` set to that created count, `expected={M}` set to the declared total, and the last task's temporary ID — never noop, and never substitute a guessed issue number. Post: `N of M issues created so far — rerun the identical activation command to continue.` Re-runs are idempotent via title match. Never surface the `create-issue` or `add-comment` safe-output caps as a guessed reason for a partial run — name a cap only when Step 2e observed one actually being reached. - -**2e. Label-Operation Reconciliation — no activation is complete until every activated item's labels are accounted for.** - -While Steps 2b/2c run, keep two counts: `activated` (issues created or recognized this run) and `labeled` (issues whose `add_labels` call was accepted). An `add_labels` call that was never made, was rejected, or returned an error counts as **unlabeled**. These counts track *label operations*, not labels present on GitHub: acceptance means the call was queued for a specific target this turn, and gh-aw applies it in the post-agent job. Never state or imply that a counted label was applied, landed, or was confirmed on the issue — nothing here reads labels back. At the end of Step 2: - -1. `labeled == activated` → the activation is complete; proceed to Step 3. -2. `labeled < activated` → **this is an incomplete activation, not a successful one.** Call `report_incomplete` with a `reason` naming the shortfall (`{labeled} of {activated} activated issues had a label operation accepted`) and `details` listing **every affected work item — the identifier you used to target its `add_labels` call, its title, and the label set that missing operation targeted**. For an item created this run that identifier is the `temporary_id` you minted under the Temporary-ID Contract (`#aw_epic{K}` / `#aw_task{N}`) — not a GitHub issue number, because creation is deferred to the safe-output job and no real number exists yet. Quote a real number only where one is independently verified: an epic or task matched by dedup-by-title, or an issue recognized by Step 1's idempotent-rerun path. Never predict, infer, or invent a number. `report_incomplete` records the reason in Actions logs and fails the conclusion step; automatic tracking-issue reporting is disabled. Include the shortfall in the guarded activation summary as well, so the originating issue retains an actionable signal. - -**Cap exhaustion is a reportable, nameable cause.** If the shortfall is because a cap was reached, say so in the `reason`, name which cap (`create-issue` 75 or `add_labels` 110) and list the work items that did not fit, and recommend `/squad plan activate phase {N}`. This is the one case where a cap may be named as the cause: it was observed, not guessed. Do not infer a cap from a rejection you never received, and do not treat the absence of an `E002` error as proof that every label operation was accepted: the count comparison, not the error stream, is the authority. - -**Never report a clean activation you did not perform.** An `activated` or `phases-activated` artifact listing every item as activated while `labeled < activated`, or omitting the shortfall, is a false success report — the silent truncation this step exists to prevent. - -Labels must have descriptions and intentional colors when they already exist in the -repository. A label auto-provisioned by `add-labels`'s `create-if-missing` on a fresh -repository instead receives gh-aw's deterministic color and an empty description — that -is expected, not a failure, and must not be reported as one. - -##### Step 3: Native Dependency Edges - -Declare `blocked_by` on the `create-issue` call itself, passing the blocking item's -temporary ID (`#aw_task{N}` / `#aw_epic{K}`) — `blocked_by` resolves temporary IDs. Use a -verified real number only for a dependency on a pre-existing issue. Never call a write API -with a guessed number to add an edge. Graceful fallback to a body reference. Never fail -activation over edge creation. - -##### Step 4: Post Activation Record - -**LAST action** — only after Steps 2+3 complete. - -Phase artifact: `data: {"squad_artifact":"phases-activated","schema_version":"1","origin_issue":{issue_number},"phases":[{accumulated}]}` → `## ✅ Phase {N} Activated — {count} issues` + issue table + remaining phases table. - -Every phase and full activation artifact body MUST include an `Activation bindings:` fenced JSON block containing a non-empty array built only from accepted activation operations. Each row is a `squad-work-agent-binding/v1` producer record. Copy `$GITHUB_REPOSITORY`, the current origin issue, artifact kind, and TG-2 registry revision exactly. Emit one object per created/recognized task: - -`{"binding_schema":"squad-work-agent-binding/v1","binding_version":1,"producer":"squad","repository":"{owner/repo}","origin_issue":{origin issue},"artifact":"{artifact kind}","registry_schema":"squad-agent-provenance/v1","registry_revision":{TG-2 revision},"task":"{plan # cell}","issue":"{task issue reference}","epic":"{Epic cell}","epic_issue":"{epic issue reference}","agent_id":"{Agent ID cell}","epic_agent_ids":["{all distinct Agent ID cells for this epic}"],"agent":"{raw Agent cell}","epic_agents":["{all distinct lowercased Agent cells for this epic}"],"label":"squad:{slugged Agent cell}","epic_label":"squad:{slugged sole epic task agent}"}`. Slug label fields only. Every binding for one epic carries the same complete ID/name sets across all phases. - -For `@copilot` or non-roster work, set `agent_id:null` plus `identity_omission_reason:"external-agent"` or `"non-roster"`. For an older accepted plan with no ID column, use `"legacy-plan-missing-id"`; never reconstruct one from its name or label. Exclude unavailable IDs from `epic_agent_ids` and add `epic_identity_omission_reason:"partial"` when any epic task lacks an ID. - -###### Issue references in bindings — quoted, never bare - -`issue` and `epic_issue` are **JSON strings**, never bare numbers, and never a real number -for an issue this run created. - -- **Created this run:** that item's own `temporary_id`, quoted — `"issue":"#aw_task{N}"`, - `"epic_issue":"#aw_epic{K}"`. gh-aw rewrites an `#aw_…` reference in a comment body to - `#{real number}` once the issue exists, so the posted artifact carries the real number - without this run predicting one. -- **Reused or pre-existing** (Step 1 idempotent rerun, dedup-by-title): its verified real - number in the same quoted form — `"issue":"#123"`. One shape covers both. - -**The quoting is load-bearing.** gh-aw's substitution is a plain text replacement over the -whole body — it does not skip fenced code blocks — and it keeps the `#`. Bare, -`"issue":#aw_task1` becomes `"issue":#42`, which is invalid JSON and fails the whole block. -Quoted, `"issue":"#aw_task1"` becomes `"issue":"#42"`, which parses. Never emit a bare -`#aw_…`, a bare number, or a `{created task issue number}` placeholder in these two fields. - -An `#aw_…` surviving into the posted artifact was never resolved — that `create-issue` did -not land. Leave it rather than repairing it by hand: the consumer fails closed on it, which -is correct. - -For a multi-owner epic, omit `epic_label` and set `"epic_omission_reason":"multi-owner"` on each of its task bindings. For a task whose agent is not certified by TG-2, omit `label` and set `"omission_reason":"non-roster"`; if that task is the epic's sole owner, likewise omit `epic_label` and set `"epic_omission_reason":"non-roster"`. Never omit a created task from `bindings`, never infer an issue number, and never emit an empty array. The deterministic post-activation workflow treats missing, empty, malformed, or unresolved bindings as a failure. The safe-output schema deliberately uses one uniform task-binding shape because gh-aw's data schema dialect does not support conditional `if`/`then` or `allOf`; the checker enforces activation-only presence and cross-row epic consistency. - -Phase artifact: `data: {"squad_artifact":"phases-activated","schema_version":"1","origin_issue":{issue_number},"phases":[{accumulated}]}` → `## ✅ Phase {N} Activated — {count} issues` + issue table + remaining phases table + the `Activation bindings:` JSON array. - -Full artifact: `data: {"squad_artifact":"activated","schema_version":"1","origin_issue":{issue_number},"phases":[]}` → `## ✅ Plan Activated — {epic_count} epics, {task_count} tasks` + hierarchy summary, created epics table, created tasks table, dependency order + the `Activation bindings:` JSON array. - -Terminal (last phase): emit `data: {"squad_artifact":"activated","schema_version":"1","origin_issue":{issue_number},"phases":[{all_phases}]}` with an "All Phases Activated" heading and the accumulated `Activation bindings:` JSON array. - -###### Label operations accepted - -A label reaches an activated issue through exactly one route: an accepted `add_labels` -operation targeting that issue. `create-issue`'s `labels:` field never lands a label this -workflow can claim — it silently drops names the repository lacks — so it is never evidence. - -**The rule.** Report `squad:{agent}` for an issue only when this run made an `add_labels` -call that carried that label and targeted that same issue — by its own `temporary_id`, or by -its verified real number for a reused issue. Every `label` and `epic_label` in the bindings -block, and every label named in the prose or issue tables, MUST trace to such a call. - -**Forbidden:** reporting a label because `create-issue` succeeded or its `labels:` field -named it (a successful `create-issue` means an issue was requested — nothing more); reporting -a label that was computed or intended but whose `add_labels` call was never made, was -skipped, or was rejected; reporting a label from another item's `add_labels` call -(per-issue correspondence holds exactly as in Label Pre-flight Step 7); and reporting an -omission when that item's call was in fact made and accepted — a silent under-claim is as -wrong as an over-claim. - -**What "accepted" means.** `add_labels` is a safe output: the call is accepted and queued -this turn, and gh-aw applies it in the post-agent job. This run has evidence only that the -operation was accepted *for a specific target*, never the GitHub API result. State it at -that strength — never write that a label was "verified", "confirmed on the issue", or -"checked", because nothing here reads labels back. The deterministic post-activation checker -compares these bindings against the labels actually present; over-claiming defeats it. - -**Omission is reported, never inferred.** Whenever an `Agent` value did not become a -`squad:{agent}` label — multi-owner epic, uncertified name, or a label operation not made or -not accepted — the `Non-roster agent values` heading is **required**, naming the value and -the issue it applied to, and the matching binding carries its `omission_reason` / -`epic_omission_reason`. Applying bare `squad` while omitting the heading reports a clean run -that did not happen. Conversely, never emit the heading for an owner that *did* become an -accepted label — that manufactures a defect. - -##### Step 5: Update Lifecycle - -Phase: `🔄 Phase {N} of {total} activated`. Next: accept/activate next phase. -Full/last: `✅ Done`, state = Activated, last command = invoked -`/squad plan activate`. Terminal — use terminal prose for Next action. +Use the same `activate_plan` procedure and no-create recovery rules as +`squad-plan-accept` above. The latest implementation plan must have explicit +stable task IDs, Agent IDs and Epic IDs, and prior scope/implementation acceptance. +The model emits one `activate_plan` request only. The trusted writer owns +all issue creation, binding finalization and lifecycle publication. ## end skill: `squad-plan-activate` diff --git a/workflows/shared/check-agent-binding.mjs b/workflows/shared/check-agent-binding.mjs new file mode 100644 index 000000000..5e1d28ffa --- /dev/null +++ b/workflows/shared/check-agent-binding.mjs @@ -0,0 +1,1319 @@ +import { readFile } from 'node:fs/promises'; +import { createHash } from 'node:crypto'; +import { dirname, join } from 'node:path'; +import { pathToFileURL } from 'node:url'; + +// `activated` / `phases-activated` come from the granular `/squad plan activate` path +// (`squad-plan-activate`); `plan-accepted` / `phases-accepted` come from the recommended +// `/squad activate` fast path (`squad-plan-accept`). Both paths create or recognize issues +// and apply labels the same way, so both carry the same mandatory `Activation bindings:` +// contract — see workflows/shared/squad-planning-ontology.md and the E4 preflight package A +// fast-path artifact-integrity fix. +const ACTIVATION_ARTIFACTS = new Set(['activated', 'phases-activated', 'plan-accepted', 'phases-accepted', 'activation-reconciled']); +const ACTIVATION_ARTIFACT_PATTERN = new RegExp( + `"squad_artifact"\\s*:\\s*"?(?:${[...ACTIVATION_ARTIFACTS].join('|')})`, + 'i', +); +const VALID_OMISSIONS = new Set(['multi-owner', 'non-roster', 'origin']); +const TEMPORARY_ID = /^#?aw_[A-Za-z0-9_]{3,12}$/i; +const RESOLVED_REFERENCE = /^#?(\d+)$/; +const AGENT_PROVENANCE_SCHEMA = 'squad-agent-provenance/v1'; +const WORK_AGENT_BINDING_SCHEMA = 'squad-work-agent-binding/v1'; +const CASTING_HISTORY_FIELDS = new Set([ + 'assignment_cast_snapshots', + 'universe_usage_history', + 'transaction_id', + 'registry_revision', +]); + +function parseCastingObject(raw, label) { + let value; + try { + value = JSON.parse(raw); + } catch (error) { + throw new Error(`${label} is invalid JSON: ${error.message}`); + } + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new Error(`${label} root is malformed`); + } + return value; +} + +function validateCastingHistory(history, registry) { + const unknownField = Object.keys(history).find(key => !CASTING_HISTORY_FIELDS.has(key)); + if (unknownField !== undefined) { + throw new Error(`casting history contains unknown top-level field "${unknownField}"`); + } + if (!history.assignment_cast_snapshots + || typeof history.assignment_cast_snapshots !== 'object' + || Array.isArray(history.assignment_cast_snapshots) + || !Array.isArray(history.universe_usage_history)) { + throw new Error('casting history shape is malformed'); + } + for (const [key, snapshot] of Object.entries(history.assignment_cast_snapshots)) { + if (!key || !snapshot || typeof snapshot !== 'object' || Array.isArray(snapshot) + || typeof snapshot.created_at !== 'string' + || !Number.isFinite(Date.parse(snapshot.created_at)) + || !Array.isArray(snapshot.agents) + || snapshot.agents.some(agent => typeof agent !== 'string' || !(agent in registry.agents)) + || typeof snapshot.universe !== 'string' || !snapshot.universe) { + throw new Error('casting history snapshot is malformed or references unknown agents'); + } + } + for (const usage of history.universe_usage_history) { + if (!usage || typeof usage !== 'object' || Array.isArray(usage) + || typeof usage.universe !== 'string' || !usage.universe + || typeof usage.used_at !== 'string' + || !Number.isFinite(Date.parse(usage.used_at))) { + throw new Error('casting universe usage history is malformed'); + } + } +} + +function isCanonicalLegacyGenesis(registry, history) { + const agents = registry.agents; + const agentIds = Object.keys(agents); + const snapshots = Object.entries(history.assignment_cast_snapshots); + const usage = history.universe_usage_history; + if (agentIds.length === 0 || snapshots.length !== 1 || usage.length !== 1) { + return false; + } + + const snapshotEntry = snapshots[0]; + if (!snapshotEntry) return false; + const [snapshotKey, rawSnapshot] = snapshotEntry; + const rawUsage = usage[0]; + if (hasRevisionToken(snapshotKey) + || !rawSnapshot + || typeof rawSnapshot !== 'object' + || Array.isArray(rawSnapshot) + || !rawUsage + || typeof rawUsage !== 'object' + || Array.isArray(rawUsage)) { + return false; + } + const snapshot = rawSnapshot; + const usageRecord = rawUsage; + const snapshotAgents = snapshot.agents; + const snapshotCreatedAt = Date.parse(snapshot.created_at); + const generatedAt = Date.parse(registry.generated_at); + if (!Array.isArray(snapshotAgents) + || snapshotAgents.some(agentId => typeof agentId !== 'string') + || new Set(snapshotAgents).size !== snapshotAgents.length + || snapshotAgents.length !== agentIds.length + || snapshotAgents.some(agentId => !Object.hasOwn(agents, agentId)) + || typeof snapshot.universe !== 'string' + || snapshot.universe.length === 0 + || !Number.isFinite(snapshotCreatedAt) + || snapshotCreatedAt > generatedAt + || usageRecord.universe !== snapshot.universe + || Date.parse(usageRecord.used_at) !== snapshotCreatedAt) { + return false; + } + + return Object.values(agents).every(agent => + agent.status === 'active' + && agent.universe === snapshot.universe + && Date.parse(agent.created_at) === snapshotCreatedAt); +} + +const REVISION_TOKEN_PATTERN = /(?:^|[-_])(?:revision[-_]*\d+|r\d+)(?=[-_]|$)/i; + +function hasRevisionToken(snapshotKey) { + return REVISION_TOKEN_PATTERN.test(snapshotKey); +} + +function validateLegacyCastingGeneration(registry, history) { + if (registry.transaction_id !== undefined + || history.transaction_id !== undefined + || history.registry_revision !== undefined) { + throw new Error('casting generation metadata exists without a commit manifest'); + } + const snapshots = Object.entries(history.assignment_cast_snapshots); + if (registry.revision === 1 && Object.keys(registry.agents).length === 0 + && snapshots.length === 0 && history.universe_usage_history.length === 0) { + return; + } + if (registry.revision === 1 && isCanonicalLegacyGenesis(registry, history)) { + return; + } + const revisions = new Set(); + let currentGeneration = false; + const generatedAt = Date.parse(registry.generated_at); + const snapshotEvidence = new Map(); + for (const [key, snapshot] of snapshots) { + const match = key.match(/(?:^|[-_])(?:revision-|r)(\d+)(?:[-_]|$)/i); + if (!match) throw new Error('casting history snapshot lacks generation evidence'); + const revision = Number(match[1]); + if (!Number.isSafeInteger(revision) || revision < 1 || revision > registry.revision + || revisions.has(revision) || Date.parse(snapshot.created_at) > generatedAt) { + throw new Error('casting registry/history pair is mixed-generation'); + } + revisions.add(revision); + if (revision === registry.revision && Date.parse(snapshot.created_at) === generatedAt) { + currentGeneration = true; + } + const evidence = `${snapshot.universe}\u0000${snapshot.created_at}`; + snapshotEvidence.set(evidence, (snapshotEvidence.get(evidence) ?? 0) + 1); + } + const usageEvidence = new Map(); + for (const usage of history.universe_usage_history) { + if (Date.parse(usage.used_at) > generatedAt) { + throw new Error('casting registry/history pair is mixed-generation'); + } + const evidence = `${usage.universe}\u0000${usage.used_at}`; + usageEvidence.set(evidence, (usageEvidence.get(evidence) ?? 0) + 1); + } + const complete = Array.from({ length: registry.revision }, (_, index) => index + 1); + const implicitGenesis = registry.revision === 1 ? complete : complete.slice(1); + const revisionEvidence = [complete, implicitGenesis].some(expected => + expected.length === revisions.size && expected.every(revision => revisions.has(revision))); + const matchingEvidence = snapshotEvidence.size === usageEvidence.size + && [...snapshotEvidence].every(([key, count]) => usageEvidence.get(key) === count); + if (!revisionEvidence || !currentGeneration || !matchingEvidence) { + throw new Error('casting registry/history pair is mixed-generation'); + } +} + +async function readOptionalFile(path) { + try { + return await readFile(path, 'utf8'); + } catch (error) { + if (error?.code === 'ENOENT') return undefined; + throw error; + } +} + +export async function validateCastingPairFiles(registryFile) { + const castingDir = dirname(registryFile); + const [registryRaw, historyRaw, journalRaw, manifestRaw] = await Promise.all([ + readOptionalFile(registryFile), + readOptionalFile(join(castingDir, 'history.json')), + readOptionalFile(join(castingDir, 'registry-history.transaction.json')), + readOptionalFile(join(castingDir, 'registry-history.commit.json')), + ]); + if (registryRaw === undefined || historyRaw === undefined) { + throw new Error('complete casting registry/history pair is required'); + } + if (journalRaw !== undefined) { + throw new Error('casting transaction is awaiting roll-forward'); + } + const registry = parseCastingObject(registryRaw, 'casting registry'); + const history = parseCastingObject(historyRaw, 'casting history'); + parseAgentRegistry(registry); + validateCastingHistory(history, registry); + if (manifestRaw === undefined) { + validateLegacyCastingGeneration(registry, history); + return registry; + } + const manifest = parseCastingObject(manifestRaw, 'casting commit manifest'); + const digest = raw => createHash('sha256').update(raw).digest('hex'); + if (typeof manifest.transaction_id !== 'string' + || !Number.isSafeInteger(manifest.registry_revision) + || manifest.registry_revision < 1 + || registry.transaction_id !== manifest.transaction_id + || history.transaction_id !== manifest.transaction_id + || history.registry_revision !== manifest.registry_revision + || registry.revision !== manifest.registry_revision + || digest(registryRaw) !== manifest.registry_sha256 + || digest(historyRaw) !== manifest.history_sha256) { + throw new Error('casting registry/history pair does not match the stable commit manifest'); + } + return registry; +} + +// Standalone certainty claims a label-operation report may never make: safe outputs like +// `add_labels` are applied in a post-agent job, so an activation/acceptance run only ever +// knows a call was *accepted for a specific target* — never that GitHub applied it. Word +// boundaries keep this from matching substrings ("unverified", "prechecked"). +const FORBIDDEN_LABEL_CLAIM_WORDS = ['applied', 'received', 'landed', 'verified', 'confirmed', 'checked']; +// A clause is treated as a compliant, honest non-claim (never flagged) when it explicitly +// negates the outcome — e.g. "not verified", "never confirmed" — since that is the accurate, +// honest statement the contract requires, not an over-claim. +const NEGATION_NEARBY = /\b(never|not|no|n't|without|cannot|can't|won't|isn't|wasn't|doesn't|didn't|nor)\b/i; +// Scope the forbidden-word scan to clauses that are actually reporting a label operation — +// mentioning "label"/"labels" or a `squad:*` token — rather than scanning the whole comment. +// A blanket scan would false-positive on an unrelated quoted task title like "Verified Email +// Addresses" sitting in the created-issues table. +const LABEL_OPERATION_CONTEXT = /\blabels?\b|\bsquad:[a-z0-9_.-]+\b/i; +// Protects a `squad:{agent}` token's colon from being mistaken for a clause boundary while +// splitting a line into clauses. Requires each embedded `.` to be followed by another name +// character (e.g. `squad:kint.jones`), so a sentence-ending period right after an agent name +// (`squad:kint.`) is left alone as a real delimiter, not swallowed into the token. +const SQUAD_TOKEN = /\bsquad:([a-z0-9_-]+(?:\.[a-z0-9_-]+)*)/gi; +// Placeholder standing in for a protected token's colon while clauses are split; chosen to +// never collide with real comment text. +const SQUAD_COLON_PLACEHOLDER = '\u0000'; + +function normalize(value) { + return typeof value === 'string' ? value.trim().toLowerCase() : ''; +} + +function slugify(value) { + return normalize(value).replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, ''); +} + +export function parseAgentRegistry(value) { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new Error('agent provenance registry root is malformed'); + } + if (value.schema !== AGENT_PROVENANCE_SCHEMA || value.schema_version !== 1) { + throw new Error(`agent provenance registry must use ${AGENT_PROVENANCE_SCHEMA}`); + } + if (!Number.isInteger(value.revision) || value.revision < 1) { + throw new Error('agent provenance registry revision must be a positive integer'); + } + if (!value.agents || typeof value.agents !== 'object' || Array.isArray(value.agents)) { + throw new Error('agent provenance registry agents map is malformed'); + } + const agents = new Map(); + const displayNames = new Set(); + for (const [id, record] of Object.entries(value.agents)) { + if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(id) || !record || typeof record !== 'object') { + throw new Error(`agent provenance registry entry "${id}" is malformed`); + } + if (!['active', 'inactive', 'retired'].includes(record.status)) { + throw new Error(`agent provenance registry entry "${id}" has invalid status`); + } + const displayName = typeof record.display_name === 'string' ? record.display_name.trim() : ''; + if (!displayName || record.persistent_name !== displayName || + typeof record.role !== 'string' || !record.role.trim() || + typeof record.universe !== 'string' || !record.universe.trim() || + !Number.isFinite(Date.parse(record.created_at)) || + !Number.isFinite(Date.parse(record.updated_at)) || + (record.status === 'retired' && !Number.isFinite(Date.parse(record.retired_at)))) { + throw new Error(`agent provenance registry entry "${id}" is incomplete`); + } + const normalizedName = normalize(displayName); + if (displayNames.has(normalizedName)) { + throw new Error(`agent provenance registry display name "${displayName}" is duplicated`); + } + displayNames.add(normalizedName); + if (record.avatar !== undefined) { + const expectedPrefix = `.squad/agents/${id}/`; + const avatarPath = record.avatar?.path; + if (record.avatar?.kind !== 'repository-path' + || typeof avatarPath !== 'string' + || !avatarPath.startsWith(expectedPrefix) + || avatarPath.length === expectedPrefix.length + || avatarPath.includes('\\') + || avatarPath.split('/').some(segment => segment === '.' || segment === '..')) { + throw new Error(`agent provenance registry entry "${id}" has invalid avatar path`); + } + } + agents.set(id, record); + } + return { revision: value.revision, agents }; +} + +/** + * Resolve a binding issue reference to a real issue number. + * + * Activation writes these as quoted `#`-prefixed strings so gh-aw's temporary-ID + * substitution — a plain text replacement that does not skip fenced code blocks and keeps + * the `#` — yields valid JSON. A reference the runtime resolved arrives as `"#42"`; one it + * could not resolve arrives verbatim as `"#aw_task3"`, which means that `create-issue` + * never landed. Fail closed on the latter rather than skipping the binding. Bare integers + * stay accepted so artifacts written before this contract still validate. + */ +function resolveIssueReference(value, description, invalidMessage) { + if (Number.isSafeInteger(value)) { + if (value < 1) throw new Error(invalidMessage); + return value; + } + if (typeof value === 'string') { + const trimmed = value.trim(); + if (TEMPORARY_ID.test(trimmed)) { + throw new Error( + `${description} "${trimmed}" is an unresolved temporary ID — the referenced issue was never created`, + ); + } + const resolved = RESOLVED_REFERENCE.exec(trimmed); + if (resolved && Number.isSafeInteger(Number(resolved[1])) && Number(resolved[1]) >= 1) return Number(resolved[1]); + } + throw new Error(invalidMessage); +} + +/** + * Best-effort issue number extraction for label prefetching. Unlike + * `resolveIssueReference()`, this never throws: an unresolved/malformed + * reference is simply skipped here so `validateTaskBinding()` can report the + * real error (e.g. an unresolved temporary ID) instead of a misleading + * "labels could not be resolved" failure. + */ +export function extractIssueNumber(value) { + if (Number.isInteger(value)) return value; + if (typeof value === 'string') { + const resolved = RESOLVED_REFERENCE.exec(value.trim()); + if (resolved) return Number(resolved[1]); + } + return undefined; +} + +export function parseStructuredData(comment, { envelopeOnly = false } = {}) { + const blocks = [...comment.matchAll(/Structured data:\s*```json\s*([\s\S]*?)```/gi)]; + if (blocks.length === 0) { + if (/Structured data:/i.test(comment) && ACTIVATION_ARTIFACT_PATTERN.test(comment)) { + throw new Error('activation structured data block could not be parsed'); + } + return null; + } + + let parsed; + try { + parsed = JSON.parse(blocks.at(-1)[1]); + } catch (error) { + throw new Error(`activation structured data is invalid JSON: ${error.message}`); + } + if (!envelopeOnly && ACTIVATION_ARTIFACTS.has(parsed.squad_artifact)) { + const bindingBlock = /Activation bindings:\s*```json\s*([\s\S]*?)```/i.exec(comment); + if (!bindingBlock) return parsed; + try { + parsed.bindings = JSON.parse(bindingBlock[1]); + } catch (error) { + throw new Error(`activation bindings are invalid JSON: ${error.message}`); + } + } + return parsed; +} + +/** + * Strip content a forbidden-word scan must never see as a claim: fenced/inline code and + * quoted strings. A quoted work-item title ("Verified Email Addresses") or a JSON fragment + * inside backticks is data being described, not a certainty claim about a label operation. + */ +function stripQuotedAndCode(line) { + return line + .replace(/`[^`]*`/g, ' ') + .replace(/"[^"]*"/g, ' ') + .replace(/'[^']*'/g, ' '); +} + +/** + * Split a line into clauses on clause/sentence-ending punctuation (`.`, `,`, `;`, `:`) so a + * negation and a certainty claim are only ever treated as connected when they actually share + * the same clause. Without this, "No issues were skipped: Label squad:kint was verified for + * #42." would let the unrelated negation on "skipped" blanket-suppress the real, separate + * claim that follows the colon — and a claim about a *different* subject sharing a line with + * a label mention (e.g. "Reported squad:kint for issue #123, its verified real number") would + * wrongly inherit label-operation context from a clause it isn't part of. + * + * `squad:{agent}` tokens are protected first so the colon inside them is never itself treated + * as a clause boundary. + */ +function splitClauses(line) { + const guarded = line.replace(SQUAD_TOKEN, (_match, agent) => `squad${SQUAD_COLON_PLACEHOLDER}${agent}`); + return guarded + .split(/[.,;:]+/) + .map(clause => clause.replaceAll(SQUAD_COLON_PLACEHOLDER, ':')) + .filter(clause => clause.trim().length > 0); +} + +/** + * Reject standalone certainty claims ("applied", "received", "landed", "verified", + * "confirmed", "checked") in an activation/acceptance comment's label-operation reporting. + * `add_labels` is a safe output applied in a post-agent job — the run only ever knows a call + * was *accepted* for a target, never that GitHub actually applied it. A summary claiming more + * than that is over-claiming an outcome nothing here observed. + * + * Deliberately scoped, not a blanket whole-comment or whole-line scan: + * - Markdown table rows (`| ... |`) are skipped — that's where quoted work-item titles live. + * - Fenced code blocks, inline code, and quoted strings are stripped before matching, so a + * quoted title or JSON fragment containing a forbidden word never counts. + * - Each line is split into clauses (see `splitClauses`), and label-operation context + * (`label`/`labels`/`squad:*`), the forbidden word, and any negation must all be found + * within the *same* clause. This is what lets a genuinely unrelated negation elsewhere on + * the line ("No issues were skipped: ... was verified ...") fail to suppress a real claim, + * while a forbidden word describing an unrelated subject in a different clause on the same + * line as a label mention ("Reported squad:kint for issue #123, its verified real number") + * is correctly out of scope — its clause carries no label-operation context at all. + * - A clause that explicitly negates the claim ("was not verified", "never confirmed") is + * the honest, compliant statement the contract requires, so it is never flagged. + * + * No-op for artifact types outside the activation contract (fast-path `plan-accepted` / + * `phases-accepted` and granular `activated` / `phases-activated`). + */ +export function assertAcceptedOnlyLabelWording(commentBody, artifactType) { + if (!ACTIVATION_ARTIFACTS.has(artifactType)) return { skipped: true }; + const body = typeof commentBody === 'string' ? commentBody : ''; + const withoutFences = body.replace(/```[\s\S]*?```/g, ' '); + for (const rawLine of withoutFences.split(/\r?\n/)) { + const trimmed = rawLine.trim(); + if (!trimmed || trimmed.startsWith('|')) continue; + const scrubbed = stripQuotedAndCode(trimmed); + for (const clause of splitClauses(scrubbed)) { + if (!LABEL_OPERATION_CONTEXT.test(clause)) continue; + if (NEGATION_NEARBY.test(clause)) continue; + for (const word of FORBIDDEN_LABEL_CLAIM_WORDS) { + if (new RegExp(`\\b${word}\\b`, 'i').test(clause)) { + throw new Error( + `label-operation report uses forbidden certainty claim "${word}" — report label ` + + `operations as accepted only, never applied/received/landed/verified/confirmed/` + + `checked: "${trimmed}"`, + ); + } + } + } + } + return { skipped: false }; +} + +export function parseRoster(teamMarkdown) { + const lines = teamMarkdown.replace(/\r/g, '').split('\n'); + const section = lines.findIndex(line => /^##\s+Members\s*$/.test(line.trim())); + if (section < 0) throw new Error('roster has no ## Members section'); + + const nextSection = lines.findIndex((line, index) => index > section && /^##\s/.test(line)); + const table = lines.slice(section + 1, nextSection < 0 ? undefined : nextSection) + .filter(line => line.trim().startsWith('|')); + if (table.length < 3) throw new Error('roster Members table is missing or empty'); + + const cells = line => line.split('|').slice(1, -1).map(cell => cell.trim()); + const header = cells(table[0]); + const nameIndex = header.findIndex(cell => normalize(cell) === 'name'); + if (nameIndex < 0) throw new Error('roster Members table has no Name column'); + + const names = table.slice(2) + .map(line => normalize(cells(line)[nameIndex])) + .filter(Boolean); + if (names.length === 0) throw new Error('roster Members table has no members'); + + const namesBySlug = new Map(); + for (const name of names) { + const slug = slugify(name); + const existing = namesBySlug.get(slug); + if (existing) { + throw new Error(`roster member label slug "${slug}" is ambiguous: "${existing}" and "${name}"`); + } + namesBySlug.set(slug, name); + } + return new Set(names); +} + +function expectedLabel(agent, roster) { + if (agent === '@copilot') return { label: 'squad:copilot', omission: null }; + if (roster.has(agent)) return { label: `squad:${slugify(agent)}`, omission: null }; + return { label: null, omission: 'non-roster' }; +} + +function validateReportedOutcome(binding, prefix, expected) { + const labelKey = prefix ? `${prefix}_label` : 'label'; + const omissionKey = prefix ? `${prefix}_omission_reason` : 'omission_reason'; + if (binding[labelKey] !== expected.label && !(expected.label === null && binding[labelKey] === undefined)) { + throw new Error( + `issue #${binding.issue}: reported ${labelKey} ${binding[labelKey] ?? '(none)'} does not match ${expected.label ?? 'bare squad'}`, + ); + } + if (binding[omissionKey] !== expected.omission && !(expected.omission === null && binding[omissionKey] === undefined)) { + throw new Error( + `issue #${binding.issue}: ${omissionKey} must be ${expected.omission ?? 'absent'}`, + ); + } + if (binding[omissionKey] && !VALID_OMISSIONS.has(binding[omissionKey])) { + throw new Error(`issue #${binding.issue}: invalid ${omissionKey}`); + } +} + +function validateBindingAuthority(binding, issue, artifact, authority) { + if (!authority) return { epicAgentIds: [] }; + if (binding.binding_schema !== WORK_AGENT_BINDING_SCHEMA || binding.binding_version !== 1) { + throw new Error(`issue #${issue}: binding must use ${WORK_AGENT_BINDING_SCHEMA}`); + } + if (binding.producer !== 'squad') { + throw new Error(`issue #${issue}: binding producer must be squad`); + } + if (binding.repository !== authority.repository) { + throw new Error(`issue #${issue}: binding repository does not match ${authority.repository}`); + } + if (binding.origin_issue !== artifact.origin_issue || binding.artifact !== artifact.squad_artifact) { + throw new Error(`issue #${issue}: binding origin or artifact identity does not match its comment`); + } + if (binding.registry_schema !== AGENT_PROVENANCE_SCHEMA) { + throw new Error(`issue #${issue}: binding registry schema is unsupported`); + } + if (!Number.isInteger(binding.registry_revision) || binding.registry_revision < 1) { + throw new Error(`issue #${issue}: binding registry revision is invalid`); + } + if (binding.registry_revision > authority.registry.revision) { + throw new Error(`issue #${issue}: binding registry revision is newer than the available registry`); + } + if (binding.agent_id === null) { + if (!['external-agent', 'non-roster', 'legacy-plan-missing-id'].includes(binding.identity_omission_reason)) { + throw new Error(`issue #${issue}: null agent_id requires an explicit identity omission reason`); + } + } else { + if (binding.identity_omission_reason !== undefined) { + throw new Error(`issue #${issue}: identity omission reason conflicts with agent_id`); + } + if (typeof binding.agent_id !== 'string' || !authority.registry.agents.has(binding.agent_id)) { + throw new Error(`issue #${issue}: agent_id is absent from the producer registry`); + } + } + if (!Array.isArray(binding.epic_agent_ids)) { + throw new Error(`issue #${issue}: epic_agent_ids must be an array`); + } + const epicAgentIds = [...new Set(binding.epic_agent_ids)]; + if ( + epicAgentIds.length !== binding.epic_agent_ids.length || + (binding.agent_id !== null && !epicAgentIds.includes(binding.agent_id)) + ) { + throw new Error(`issue #${issue}: epic_agent_ids are duplicated or exclude agent_id`); + } + for (const agentId of epicAgentIds) { + if (typeof agentId !== 'string' || !authority.registry.agents.has(agentId)) { + throw new Error(`issue #${issue}: epic_agent_ids references an unknown producer id`); + } + } + if (binding.epic_identity_omission_reason !== undefined && + binding.epic_identity_omission_reason !== 'partial') { + throw new Error(`issue #${issue}: invalid epic identity omission reason`); + } + return { epicAgentIds: epicAgentIds.sort() }; +} + +function validateTaskBinding(binding, roster, artifact, authority) { + if (!binding || typeof binding !== 'object') { + throw new Error('binding has no valid issue number'); + } + const issue = resolveIssueReference(binding.issue, 'binding issue', 'binding has no valid issue number'); + const epicIssue = resolveIssueReference( + binding.epic_issue, + `issue #${issue}: epic_issue`, + `issue #${issue}: missing epic issue linkage`, + ); + if (!normalize(binding.task)) throw new Error(`issue #${issue}: binding has no plan task number`); + if (!normalize(binding.epic)) throw new Error(`issue #${issue}: missing epic linkage`); + const agent = normalize(binding.agent); + if (!agent) throw new Error(`issue #${issue}: binding has no agent`); + if (!Array.isArray(binding.epic_agents) || binding.epic_agents.length === 0) { + throw new Error(`issue #${issue}: epic_agents must be a non-empty array`); + } + const epicAgents = [...new Set(binding.epic_agents.map(normalize).filter(Boolean))].sort(); + if (epicAgents.length !== binding.epic_agents.length || !epicAgents.includes(agent)) { + throw new Error(`issue #${issue}: epic_agents are empty, duplicated, or exclude the task agent`); + } + const { epicAgentIds } = validateBindingAuthority(binding, issue, artifact, authority); + const expected = expectedLabel(agent, roster); + validateReportedOutcome({ ...binding, issue }, '', expected); + return { issue, epicIssue, epicAgents, epicAgentIds, expected }; +} + +function validateActualLabels(issue, labels, expected) { + if (!labels) throw new Error(`issue #${issue}: labels could not be resolved`); + if (!labels.has('squad')) throw new Error(`issue #${issue}: missing squad label`); + const actualAgentLabels = [...labels].filter(label => label.startsWith('squad:')); + const expectedAgentLabels = expected.label ? [expected.label] : []; + if (actualAgentLabels.length !== expectedAgentLabels.length || actualAgentLabels[0] !== expectedAgentLabels[0]) { + throw new Error( + `issue #${issue}: expected ${expected.label ?? 'bare squad'}, found ${actualAgentLabels.join(', ') || 'bare squad'}`, + ); + } +} + +export function validateBindings(artifact, roster, labelsByIssue) { + return validateActivation(artifact, roster, labelsByIssue); +} + +export function validateActivation(artifact, roster, labelsByIssue, expectedOrigin, authority) { + if (!artifact || !ACTIVATION_ARTIFACTS.has(artifact.squad_artifact)) return { skipped: true }; + if (artifact.schema_version !== '1') throw new Error('activation artifact schema_version must be 1'); + if (!Number.isInteger(artifact.origin_issue) || artifact.origin_issue < 1) { + throw new Error('activation artifact origin_issue is invalid'); + } + if (expectedOrigin !== undefined && artifact.origin_issue !== expectedOrigin) { + throw new Error(`activation artifact origin_issue ${artifact.origin_issue} does not match comment issue ${expectedOrigin}`); + } + if (!Array.isArray(artifact.phases) || artifact.phases.some(phase => !Number.isSafeInteger(phase) || phase < 1) || + new Set(artifact.phases).size !== artifact.phases.length) { + throw new Error('activation artifact phases are invalid'); + } + if (!Array.isArray(artifact.bindings) || artifact.bindings.length === 0) { + throw new Error('activation artifact bindings are missing or empty'); + } + + const seen = new Set(); + const seenTasks = new Set(); + const epics = new Map(); + const epicIssuesByIdentifier = new Map(); + const assignments = authority?.epicAssignments; + if (assignments !== undefined) { + if (!Array.isArray(assignments) || assignments.length === 0) throw new Error('frozen epic assignments are missing'); + const tasks = new Set(); + for (const assignment of assignments) { + if (!assignment || typeof assignment.task !== 'string' || !assignment.task.trim() || + typeof assignment.epic !== 'string' || !assignment.epic.trim() || + typeof assignment.agent !== 'string' || !assignment.agent.trim() || tasks.has(assignment.task)) { + throw new Error('frozen epic assignments contain malformed or duplicate tasks'); + } + tasks.add(assignment.task); + if (assignment.agentId !== null) { + const agent = authority.registry.agents.get(assignment.agentId); + if (!agent || agent.status !== 'active' || normalize(agent.display_name) !== normalize(assignment.agent)) { + throw new Error('frozen epic assignment agent identity mismatch'); + } + } else if (roster.has(normalize(assignment.agent))) { + throw new Error('frozen roster assignment cannot omit its agent identity'); + } + } + } + let expectedRegistryRevision; + for (const rawBinding of artifact.bindings) { + const { issue, epicIssue, epicAgents, epicAgentIds, expected } = + validateTaskBinding(rawBinding, roster, artifact, authority); + const binding = { ...rawBinding, issue, epic_issue: epicIssue }; + if (issue === artifact.origin_issue || (epicIssue === artifact.origin_issue && normalize(binding.epic) !== 'origin')) { + throw new Error(`issue #${issue}: task/epic role collides with the origin issue`); + } + if (assignments) { + const assignment = assignments.find(assignment => assignment.task === binding.task); + if (!assignment || assignment.epic !== binding.epic || assignment.agentId !== binding.agent_id || + normalize(assignment.agent) !== normalize(binding.agent)) throw new Error('binding conflicts with frozen epic assignment'); + } + if (seen.has(issue)) throw new Error(`issue #${issue}: duplicate binding`); + seen.add(issue); + if (authority) { + if (seenTasks.has(binding.task)) throw new Error(`task ${binding.task}: duplicate binding`); + seenTasks.add(binding.task); + expectedRegistryRevision ??= binding.registry_revision; + if (binding.registry_revision !== expectedRegistryRevision) { + throw new Error(`issue #${issue}: registry_revision conflicts with other bindings`); + } + } + if (!authority?.structuralOnly) validateActualLabels(issue, labelsByIssue.get(issue), expected); + + const epicIdentifier = normalize(binding.epic); + const priorEpicIssue = epicIssuesByIdentifier.get(epicIdentifier); + if (priorEpicIssue !== undefined && priorEpicIssue !== epicIssue) { + throw new Error(`epic ${epicIdentifier}: maps to multiple epic issue numbers`); + } + epicIssuesByIdentifier.set(epicIdentifier, epicIssue); + + const epic = epics.get(epicIssue) ?? { + epic: epicIdentifier, + agents: epicAgents, + agentIds: epicAgentIds, + bindings: [], + }; + if (epic.epic !== epicIdentifier) { + throw new Error(`epic issue #${epicIssue}: conflicting epic identifiers`); + } + if (epic.agents.join('\0') !== epicAgents.join('\0')) { + throw new Error(`epic issue #${epicIssue}: inconsistent epic_agents sets`); + } + if (authority && epic.agentIds.join('\0') !== epicAgentIds.join('\0')) { + throw new Error(`epic issue #${epicIssue}: inconsistent epic_agent_ids sets`); + } + epic.bindings.push(binding); + epics.set(epicIssue, epic); + } + + for (const [epicIssue, epic] of epics) { + if (seen.has(epicIssue)) throw new Error(`epic issue #${epicIssue}: also used as a task issue`); + if (authority) { + const peers = assignments + ? assignments.filter(assignment => normalize(assignment.epic) === epic.epic) + .map(assignment => ({ agent: assignment.agent, agent_id: assignment.agentId })) + : epic.bindings; + const expectedAgentIds = [...new Set(peers.map(binding => binding.agent_id).filter(agentId => agentId !== null))].sort(); + const hasIdentityOmission = peers.some(binding => binding.agent_id === null); + if (assignments && [...new Set(peers.map(binding => normalize(binding.agent)))].sort().join('\0') !== epic.agents.join('\0')) { + throw new Error(`epic issue #${epicIssue}: epic_agents conflict with the frozen plan`); + } + for (const binding of epic.bindings) { + const actualAgentIds = [...binding.epic_agent_ids].sort(); + if (actualAgentIds.join('\0') !== expectedAgentIds.join('\0')) { + throw new Error(`epic issue #${epicIssue}: epic_agent_ids do not match the complete epic task-agent set`); + } + if (hasIdentityOmission && binding.epic_identity_omission_reason !== 'partial') { + throw new Error(`epic issue #${epicIssue}: every binding requires partial omission when an epic task omits agent_id`); + } + if (!hasIdentityOmission && binding.epic_identity_omission_reason !== undefined) { + throw new Error(`epic issue #${epicIssue}: partial omission conflicts with a complete epic agent set`); + } + } + } + const originParent = epicIssue === artifact.origin_issue && epic.epic === 'origin'; + const expected = originParent + ? { label: null, omission: 'origin' } + : epic.agents.length > 1 + ? { label: null, omission: 'multi-owner' } + : expectedLabel(epic.agents[0], roster); + for (const binding of epic.bindings) validateReportedOutcome(binding, 'epic', expected); + if (!originParent && !authority?.structuralOnly) validateActualLabels(epicIssue, labelsByIssue.get(epicIssue), expected); + } + return { skipped: false, checked: seen.size, epics: epics.size }; +} + +export function parsePendingReservation(body, repository, originIssue) { + const blocks = [...String(body).matchAll(/```json\s*([\s\S]*?)```/g)]; + if (blocks.length !== 1) throw new Error('Pending source requires one reservation JSON block'); + const value = JSON.parse(blocks[0][1]); + const keys = ['schema', 'repository', 'origin_issue', 'run_id', 'run_attempt', 'capture_sha256', + 'capture_artifact_id', 'plan_comment_id', 'plan_sha256', 'registry_sha256', 'phases', + ...(Object.hasOwn(value, 'prior') ? ['prior'] : [])]; + if (Object.keys(value).sort().join('\0') !== keys.sort().join('\0') || + value.schema !== 'squad-activation-pending/v1' || value.repository !== repository || value.origin_issue !== originIssue || + ![value.origin_issue, value.run_id, value.run_attempt, value.capture_artifact_id, value.plan_comment_id] + .every(id => Number.isSafeInteger(id) && id > 0) || + ![value.capture_sha256, value.plan_sha256, value.registry_sha256].every(hash => typeof hash === 'string' && /^[a-f0-9]{64}$/.test(hash)) || + !Array.isArray(value.phases) || value.phases.some((phase, index) => + !Number.isSafeInteger(phase) || phase < 1 || (index > 0 && value.phases[index - 1] >= phase))) { + throw new Error('Pending source has malformed repository/origin/frozen snapshot metadata'); + } + return value; +} + +function classifyActivationHistory(record, context) { + const blocks = [...record.body.matchAll(/Structured data:\s*```json\s*([\s\S]*?)```/g)]; + if (blocks.length === 0 && record.body.includes('squad-activation-pending/v1')) { + const pending = parsePendingReservation(record.body, context.repository, context.originIssue); + return { ...record, pending, artifact: { squad_artifact: 'activation-pending', + schema_version: '1', origin_issue: pending.origin_issue, phases: pending.phases } }; + } + const reconciliationMarker = /Activation reconciliation:|activation-reconciled/.test(record.body); + const activationMarker = reconciliationMarker || /Activation bindings:/.test(record.body); + if (!/Structured data:/.test(record.body) && !activationMarker) return undefined; + let artifact; + try { + if (blocks.length !== 1) throw new Error('exactly one structured envelope is required'); + artifact = JSON.parse(blocks[0][1]); + } catch (error) { + if (activationMarker || /activated|plan-accepted|phases-accepted/.test(record.body)) throw error; + return undefined; + } + const knownArtifact = ACTIVATION_ARTIFACTS.has(artifact?.squad_artifact); + if (!knownArtifact && !activationMarker) return undefined; + if (!reconciliationMarker && knownArtifact && artifact.squad_artifact !== 'activation-reconciled' && + Number.isSafeInteger(artifact.origin_issue) && artifact.origin_issue > 0 && + artifact.origin_issue !== context.originIssue) return undefined; + if (!knownArtifact) throw new Error('Activation selection refused: unknown activation artifact'); + if (artifact.origin_issue !== context.originIssue || artifact.schema_version !== '1') { + throw new Error('Activation selection refused: artifact origin/schema mismatch'); + } + if (reconciliationMarker && artifact.squad_artifact !== 'activation-reconciled') { + throw new Error('Activation selection refused: reconciliation requires activation-reconciled artifact'); + } + return { ...record, artifact }; +} + +/** Evidence must come from the trusted API verifier, never from the artifact being selected. */ +function selectActivationPair(records, registryValue, context) { + const fail = message => { throw new Error(`Activation selection refused: ${message}`); }; + const hash = value => createHash('sha256').update(typeof value === 'string' ? value : JSON.stringify(value)).digest('hex'); + const ids = new Set(); + const parsed = records.map(record => { + if (!Number.isSafeInteger(record.id) || record.id < 1 || ids.has(record.id) || typeof record.body !== 'string') { + fail('duplicate or malformed comment identity'); + } + ids.add(record.id); + return classifyActivationHistory(record, context); + }).filter(Boolean); + const reconciled = parsed.filter(record => record.artifact.squad_artifact === 'activation-reconciled'); + if (reconciled.length > 1) fail('competing reconciliations'); + let selected; + if (reconciled.length) { + selected = reconciled[0]; + const blocks = [...selected.body.matchAll(/Activation reconciliation:\s*```json\s*([\s\S]*?)```/g)]; + if (blocks.length !== 1) fail('reconciliation record is missing or duplicated'); + const value = JSON.parse(blocks[0][1]); + const keys = ['schema', 'basis', 'request_comment_id', 'request_sha256', 'actor_id', + 'supersedes_comment_id', 'supersedes_sha256', 'plan_sha256', 'registry_sha256', 'bindings_sha256', + ...(Object.hasOwn(value, 'supersedes_kind') ? ['supersedes_kind'] : [])]; + if (!value || Object.keys(value).sort().join('\0') !== keys.sort().join('\0') || + value.schema !== 'squad-activation-reconciliation/v1' || value.basis !== 'current-state-authorized') fail('invalid reconciliation schema/basis'); + const evidence = (context.reconciliations ?? []).filter(item => + item.requestCommentId === value.request_comment_id && item.requestSha256 === value.request_sha256 && + item.actorId === value.actor_id && item.sourceCommentId === value.supersedes_comment_id && + item.sourceSha256 === value.supersedes_sha256 && item.planSha256 === value.plan_sha256 && + item.registrySha256 === value.registry_sha256 && item.bindingsSha256 === value.bindings_sha256 && + (item.sourceKind ?? 'activation') === (value.supersedes_kind ?? 'activation')); + if (evidence.length !== 1) fail('independently verified reconciliation evidence is missing or ambiguous'); + const phases = evidence[0].phases ?? []; + if (!Array.isArray(phases) || phases.some((phase, index) => + !Number.isSafeInteger(phase) || phase < 1 || (index > 0 && phases[index - 1] >= phase)) || + !Array.isArray(selected.artifact.phases) || + JSON.stringify(selected.artifact.phases) !== JSON.stringify(phases)) fail('authorized phase coverage mismatch'); + if (![value.request_comment_id, value.actor_id, value.supersedes_comment_id].every(id => Number.isSafeInteger(id) && id > 0) || + ![value.request_sha256, value.supersedes_sha256, value.plan_sha256, value.registry_sha256, value.bindings_sha256] + .every(value => typeof value === 'string' && /^[a-f0-9]{64}$/.test(value))) fail('malformed reconciliation identities/hashes'); + const source = parsed.find(record => record.id === value.supersedes_comment_id); + if (!source || source.artifact.squad_artifact === 'activation-reconciled' || + hash(source.body) !== value.supersedes_sha256 || hash(registryValue) !== value.registry_sha256) fail('superseded source or frozen registry mismatch'); + if (source.pending) { + if (value.supersedes_kind !== 'pending' || source.pending.plan_sha256 !== value.plan_sha256 || + source.pending.registry_sha256 !== value.registry_sha256 || + JSON.stringify(source.pending.phases) !== JSON.stringify(phases)) fail('pending source lacks exact authorized frozen coverage'); + } else { + if (value.supersedes_kind !== undefined) fail('source kind does not match the original activation'); + if (JSON.stringify(source.artifact.phases) !== JSON.stringify(phases)) fail('activation source phase coverage mismatch'); + } + if (parsed.length !== 2) fail('ambiguous unsuperseded activation record'); + const bindings = [...selected.body.matchAll(/Activation bindings:\s*```json\s*([\s\S]*?)```/g)]; + if (bindings.length !== 1 || hash(JSON.parse(bindings[0][1])) !== value.bindings_sha256) fail('binding digest mismatch'); + } else { + if ((context.reconciliations ?? []).length > 0) fail('verifier evidence has no matching reconciliation'); + if (parsed.length > 1) fail('ambiguous original activation records'); + selected = parsed[0]; + if (selected?.pending) fail('pending reservation alone is not activation authority'); + } + if (!selected) return undefined; + const blocks = [...selected.body.matchAll(/Activation bindings:\s*```json\s*([\s\S]*?)```/g)]; + if (blocks.length !== 1) fail('bindings are missing or duplicated'); + const bindings = JSON.parse(blocks[0][1]); + const registry = parseAgentRegistry(registryValue); + const roster = new Set([...registry.agents.values()].filter(agent => agent.status === 'active') + .map(agent => normalize(agent.display_name))); + validateActivation({ ...selected.artifact, bindings }, roster, new Map(), context.originIssue, { + repository: context.repository, registry, structuralOnly: true, epicAssignments: context.epicAssignments, + }); + return { id: selected.id, artifact: selected.artifact.squad_artifact, bindings }; +} + +function readChainSection(body, label) { + const labels = body.match(new RegExp(`^${label}:[ \\t]*\\r?$`, 'gm')) ?? []; + const blocks = [...body.matchAll(new RegExp( + `^${label}:[ \\t]*\\r?\\n\\s*\`\`\`json[ \\t]*\\r?\\n([\\s\\S]*?)^\`\`\`[ \\t]*\\r?$`, 'gm', + ))]; + if (labels.length !== 1 || blocks.length !== 1) throw new Error(`Activation chain: exactly one ${label} section is required`); + return JSON.parse(blocks[0][1]); +} + +/** Scoped pairs remain supported; complete histories require independent proof for every node. */ +export function selectActivationRecord(records, registryValue, context) { + const nodes = records.flatMap(record => { + try { + const envelope = readChainSection(record.body, 'Structured data'); + return envelope && envelope.squad_artifact === 'activation-reconciled' ? [{ record, envelope }] : []; + } catch { + return []; + } + }); + if (nodes.length <= 1 && (context.reconciliations ?? []).length <= 1) { + return selectActivationPair(records, registryValue, context); + } + const fail = message => { throw new Error(`Activation chain: ${message}`); }; + const hash = value => createHash('sha256').update(value, 'utf8').digest('hex'); + const positive = value => Number.isSafeInteger(value) && value > 0; + const object = value => value !== null && typeof value === 'object' && !Array.isArray(value); + const issueKey = value => value.replace(/^#/, ''); + if (nodes.length < 2 || nodes.length !== context.reconciliations.length) fail('missing or extra verifier proofs'); + const byId = new Map(); + for (const record of records) { + if (!positive(record.id) || typeof record.body !== 'string' || byId.has(record.id)) { + fail('duplicate or malformed record identity'); + } + byId.set(record.id, record); + } + const usedRecords = new Set(); + const usedProofs = new Set(); + const usedRequests = new Set(); + const validated = new Map(); + for (const { record } of nodes) { + const metadata = readChainSection(record.body, 'Activation reconciliation'); + const proofs = context.reconciliations.filter(proof => + proof.requestCommentId === metadata.request_comment_id && proof.requestSha256 === metadata.request_sha256 && + proof.actorId === metadata.actor_id && proof.sourceCommentId === metadata.supersedes_comment_id && + proof.sourceSha256 === metadata.supersedes_sha256 && proof.planSha256 === metadata.plan_sha256 && + proof.registrySha256 === metadata.registry_sha256 && proof.bindingsSha256 === metadata.bindings_sha256 && + (proof.sourceKind ?? 'activation') === (metadata.supersedes_kind ?? 'activation')); + if (proofs.length !== 1) fail('missing or duplicate matching verifier proof'); + const proof = proofs[0]; + const source = byId.get(proof.sourceCommentId) ?? fail('missing source'); + if (usedProofs.has(proof) || usedRequests.has(proof.requestCommentId) || + usedRecords.has(source.id) || usedRecords.has(record.id)) fail('missing source or competing reconciliation'); + const selected = selectActivationPair([source, record], registryValue, { ...context, reconciliations: [proof] }); + if (!selected || selected.id !== record.id) fail('invalid reconciliation node'); + const first = validated.values().next().value; + if (first && (proof.planSha256 !== first.proof.planSha256 || + proof.registrySha256 !== first.proof.registrySha256)) fail('inconsistent frozen plan or registry'); + let carrier; + if (proof.sourceKind === 'pending') { + carrier = parsePendingReservation(source.body, context.repository, context.originIssue); + } else if (source.body.includes('Activation evidence:')) { + carrier = readChainSection(source.body, 'Activation evidence'); + if (!object(carrier) || carrier.schema !== 'squad-activation-finalization/v1' || + carrier.basis !== 'current-trusted-execution' || carrier.plan_sha256 !== proof.planSha256 || + carrier.registry_sha256 !== proof.registrySha256) fail('invalid finalization source link carrier'); + } + usedRecords.add(source.id); + usedRecords.add(record.id); + usedProofs.add(proof); + usedRequests.add(proof.requestCommentId); + validated.set(record.id, { selected, source, proof, prior: carrier?.prior }); + } + if (records.some(record => !usedRecords.has(record.id) && classifyActivationHistory(record, context) !== undefined) || + usedProofs.size !== context.reconciliations.length) { + fail('unconsumed history records or verifier proofs'); + } + const parents = new Map(); + const children = new Set(); + for (const [id, node] of validated) { + if (node.prior === undefined) continue; + const link = node.prior; + const keys = ['source_comment_id', 'source_sha256', 'reconciliation_comment_id', 'reconciliation_sha256']; + if (!object(link) || Object.keys(link).sort().join() !== [...keys].sort().join() || + !positive(link.source_comment_id) || !positive(link.reconciliation_comment_id) || + ![link.source_sha256, link.reconciliation_sha256].every(value => + typeof value === 'string' && /^[a-f0-9]{64}$/.test(value))) fail('malformed prior link'); + const parentId = link.reconciliation_comment_id; + const parent = validated.get(parentId); + if (!parent || parent.source.id !== link.source_comment_id || + hash(parent.source.body) !== link.source_sha256 || + hash(byId.get(parentId).body) !== link.reconciliation_sha256) fail('unknown, missing, or changed ancestor'); + if (children.has(parentId)) fail('forked phase history'); + const before = parent.proof.phases ?? []; + const after = node.proof.phases ?? []; + if (before.length === 0 || before.length >= after.length || !before.every((phase, index) => after[index] === phase)) { + fail('phase coverage must strictly expand'); + } + const current = new Map(node.selected.bindings.map(binding => [binding.task, binding])); + for (const priorBinding of parent.selected.bindings) { + const next = current.get(priorBinding.task); + if (!next || issueKey(next.issue) !== issueKey(priorBinding.issue) || next.epic !== priorBinding.epic || + issueKey(next.epic_issue) !== issueKey(priorBinding.epic_issue) || next.agent_id !== priorBinding.agent_id || + next.registry_revision !== priorBinding.registry_revision || + [...next.epic_agent_ids].sort().join() !== [...priorBinding.epic_agent_ids].sort().join() || + next.identity_omission_reason !== priorBinding.identity_omission_reason || + next.epic_identity_omission_reason !== priorBinding.epic_identity_omission_reason) { + fail('prior task or epic bindings changed'); + } + } + parents.set(id, parentId); + children.add(parentId); + } + const heads = [...validated.keys()].filter(id => !children.has(id)); + if (heads.length !== 1) fail('competing heads or cyclic history'); + const visited = new Set(); + let id = heads[0]; + while (id !== undefined) { + if (visited.has(id)) fail('cyclic history'); + visited.add(id); + id = parents.get(id); + } + if (visited.size !== validated.size) fail('disconnected or cyclic history'); + return validated.get(heads[0]).selected; +} + +const AUTOMATIC_AUDIT_LIMITS = Object.freeze({ requests: 250, discoveryPages: 5, origins: 5, originPages: 10 }); + +function incompleteAudit(reason) { + throw new Error(`Historical audit incomplete: ${reason}; run the manual full-history audit`); +} + +/** Timestamps select historical candidates only; they never attest current-run output. */ +export async function collectHistoricalActivationRecords({ github, context }) { + const automatic = context.eventName === 'workflow_run'; + if (!automatic && context.eventName !== 'workflow_dispatch') incompleteAudit('unsupported audit event'); + let requestsUsed = 0; + const readPages = async (method, params, limit = Infinity) => { + const all = []; + for (let page = 1; ; page++) { + if (page > limit) incompleteAudit('comment pagination limit exhausted'); + if (automatic && requestsUsed >= AUTOMATIC_AUDIT_LIMITS.requests) incompleteAudit('API request budget exhausted'); + requestsUsed++; + const response = await method({ ...params, per_page: 100, page }); + if (!Array.isArray(response.data) || response.data.length > 100 || + response.data.some(comment => !comment || typeof comment.body !== 'string')) { + incompleteAudit('comment discovery returned malformed records'); + } + all.push(...response.data); + const next = /<[^>]+>;\s*rel="next"/.test(response.headers?.link ?? ''); + if (!next && (response.headers !== undefined || response.data.length < 100)) return all; + } + }; + let since; + if (automatic) { + const completed = Date.parse(context.payload.workflow_run?.updated_at); + if (!Number.isFinite(completed)) incompleteAudit('trigger completion timestamp is missing'); + since = new Date(completed - 24 * 60 * 60 * 1000).toISOString(); + } + const discovered = await readPages(github.rest.issues.listCommentsForRepo, { + ...context.repo, sort: 'updated', direction: 'asc', ...(automatic ? { since } : {}), + }, automatic ? AUTOMATIC_AUDIT_LIMITS.discoveryPages : Infinity); + const candidate = comment => /"squad_artifact"\s*:\s*"?(?:phases-activated|activated|phases-accepted|plan-accepted|activation-reconciled)|squad-activation-pending\/v1/i + .test(comment.body ?? ''); + const originFor = comment => { + const prefix = `https://api.github.com/repos/${context.repo.owner}/${context.repo.repo}/issues/`; + const value = String(comment.issue_url ?? ''); + const number = value.startsWith(prefix) ? Number(value.slice(prefix.length)) : NaN; + if (!Number.isSafeInteger(number) || number <= 0) incompleteAudit('candidate origin identity is missing'); + return number; + }; + const origins = [...new Set(discovered.filter(candidate).map(originFor))]; + if (automatic && origins.length > AUTOMATIC_AUDIT_LIMITS.origins) incompleteAudit('candidate origin limit exhausted'); + const comments = []; + for (const origin of origins) { + // Always fetch the complete origin, including predecessors older than discovery. + const history = await readPages(github.rest.issues.listComments, { + ...context.repo, issue_number: origin, + }, automatic ? AUTOMATIC_AUDIT_LIMITS.originPages : Infinity); + const records = history.filter(candidate).map(comment => { + if (originFor(comment) !== origin || !Number.isSafeInteger(comment.id) || comment.id <= 0) { + incompleteAudit('origin history identity changed'); + } + return { id: comment.id, body: comment.body, issue: origin }; + }); + if (!records.some(record => ACTIVATION_ARTIFACT_PATTERN.test(record.body))) { + incompleteAudit(`origin #${origin} has no activation evidence`); + } + comments.push(...records.filter(record => ACTIVATION_ARTIFACT_PATTERN.test(record.body))); + } + return { + schema: 'squad-historical-audit/v1', scope: automatic ? 'recent-candidates' : 'full-history', + ...(automatic ? { since } : {}), requestsUsed, origins, comments, + }; +} + +function readOnlyGithub(repo, token, requestBudget = Infinity) { + let requests = 0; + const request = async (path, page) => { + if (requests >= requestBudget) incompleteAudit('API request budget exhausted during complete-chain verification'); + requests++; + const response = await fetch(`https://api.github.com/repos/${repo}/${path}${page ? `?per_page=100&page=${page}` : ''}`, { + headers: { Accept: 'application/vnd.github+json', Authorization: `Bearer ${token}`, + 'X-GitHub-Api-Version': '2022-11-28' }, + }); + if (!response.ok) throw Object.assign(new Error(`Reconciliation evidence API returned ${response.status}`), + { status: response.status }); + return { data: await response.json(), headers: { link: response.headers.get('link') ?? '' } }; + }; + const rest = { + issues: { + get: ({ issue_number }) => request(`issues/${issue_number}`), + getComment: ({ comment_id }) => request(`issues/comments/${comment_id}`), + getParent: ({ issue_number }) => request(`issues/${issue_number}/parent`), + listComments: ({ issue_number, page }) => request(`issues/${issue_number}/comments`, page), + }, + repos: { getCollaboratorPermissionLevel: ({ username }) => + request(`collaborators/${encodeURIComponent(username)}/permission`) }, + }; + return { rest, paginate: async (method, params) => { + const all = []; + for (let page = 1; ; page++) { + const { data, headers } = await method({ ...params, page }); + if (!Array.isArray(data)) throw new Error('Evidence pagination returned a non-array'); + all.push(...data); + if (!/<[^>]+>;\s*rel="next"/.test(headers?.link ?? '') && + (headers !== undefined || data.length < 100)) return all; + } + } }; +} + +async function fetchLabels(github, repo, issues) { + const labels = new Map(); + const [owner, name] = repo.split('/'); + for (const issue of issues) { + const { data: body } = await github.rest.issues.get({ owner, repo: name, issue_number: issue }); + labels.set(issue, new Set(body.labels.map(label => normalize(typeof label === 'string' ? label : label.name)))); + } + return labels; +} + +/** + * The caller must hold the response of its own synchronous createComment call. + * Neither a disk manifest nor a comment's claimed run IDs can supply this receipt. + */ +export async function checkActivationPublication({ github, context, root, receipt, expectedBody }) { + const repository = `${context.repo.owner}/${context.repo.repo}`; + const origin = Number(context.payload.inputs?.issue_number ?? context.payload.issue?.number); + const checkIdentity = comment => { + if (!Number.isSafeInteger(comment?.id) || comment.id <= 0 || + comment.user?.id !== 41898282 || comment.user?.login !== 'github-actions[bot]' || + comment.user?.type !== 'Bot' || + comment.issue_url !== `https://api.github.com/repos/${repository}/issues/${origin}` || + comment.html_url !== `https://github.com/${repository}/issues/${origin}#issuecomment-${comment.id}` || + comment.body !== expectedBody) { + throw new Error('Activation publication receipt is missing or differs from the exact trusted write'); + } + }; + checkIdentity(receipt); + const current = (await github.rest.issues.getComment({ ...context.repo, comment_id: receipt.id })).data; + checkIdentity(current); + if (current.id !== receipt.id) throw new Error('Activation publication readback changed comment identity'); + const artifact = parseStructuredData(current.body); + if (!ACTIVATION_ARTIFACTS.has(artifact?.squad_artifact) || artifact.origin_issue !== origin) { + throw new Error('Activation publication has no matching activation envelope'); + } + const { verifyPublishedActivation, verifyReconciliationRequest, selectionEvidence, + frozenAssignments, activationHistoryHead } = await import('./squad-activation.mjs'); + let registry; + let assignments; + if (artifact.squad_artifact === 'activation-reconciled') { + const metadata = readChainSection(current.body, 'Activation reconciliation'); + const verified = await verifyReconciliationRequest({ + github, context, root, + requestCommentId: metadata.request_comment_id, requestSha256: metadata.request_sha256, + }); + if (verified.snapshots.some(snapshot => snapshot.body !== snapshot.after)) { + throw new Error('Activation publication dependencies are not corrected'); + } + registry = verified.registry; + assignments = frozenAssignments(verified.fullPlan); + const selected = selectActivationRecord([verified.original, current], registry, { + repository, originIssue: origin, reconciliations: [selectionEvidence(verified)], epicAssignments: assignments, + }); + if (selected?.id !== current.id) throw new Error('Activation publication is not the verified reconciliation'); + } else { + const verified = await verifyPublishedActivation({ github, context, root, comment: current }); + registry = verified.registry; + assignments = verified.assignments; + } + const labels = new Map(); + for (const issue of new Set(artifact.bindings.flatMap(binding => + [extractIssueNumber(binding.issue), extractIssueNumber(binding.epic_issue)]))) { + const actual = (await github.rest.issues.get({ ...context.repo, issue_number: issue })).data; + if (actual.pull_request || actual.number !== issue || + actual.url !== `https://api.github.com/repos/${repository}/issues/${issue}`) { + throw new Error('Activation publication references an invalid current issue'); + } + labels.set(issue, new Set(actual.labels.map(label => normalize(typeof label === 'string' ? label : label.name)))); + } + assertAcceptedOnlyLabelWording(current.body, artifact.squad_artifact); + validateActivation(artifact, parseRoster(await readFile(join(root, '.squad/team.md'), 'utf8')), + labels, origin, { repository, registry: parseAgentRegistry(registry), epicAssignments: assignments }); + const history = await github.paginate(github.rest.issues.listComments, { + ...context.repo, issue_number: origin, per_page: 100, + }); + if (activationHistoryHead(history, origin, repository) !== current.id) { + throw new Error('Activation publication is not the sole current history head'); + } + return current; +} + +export async function main() { + const args = Object.fromEntries(process.argv.slice(2).map(arg => { + const [key, ...value] = arg.replace(/^--/, '').split('='); + return [key, value.join('=')]; + })); + if ((!args['comment-file'] && !args['comments-file']) || !args['team-file'] || !args.repo) { + throw new Error('usage: check-agent-binding.mjs --comment-file=PATH|--comments-file=PATH --team-file=PATH --repo=OWNER/REPO'); + } + + const roster = parseRoster(await readFile(args['team-file'], 'utf8')); + const registryFile = args['registry-file'] ?? join(dirname(args['team-file']), 'casting', 'registry.json'); + const registry = parseAgentRegistry(await validateCastingPairFiles(registryFile)); + const token = process.env.GITHUB_TOKEN; + if (!token) throw new Error('GITHUB_TOKEN is required for activation binding checks'); + + const input = args['comments-file'] + ? JSON.parse(await readFile(args['comments-file'], 'utf8')) + : [{ body: await readFile(args['comment-file'], 'utf8') }]; + let requestBudget = Infinity; + let auditScope; + let comments = input; + if (!Array.isArray(input)) { + if (input?.schema !== 'squad-historical-audit/v1' || + !['recent-candidates', 'full-history'].includes(input.scope) || + !Number.isSafeInteger(input.requestsUsed) || input.requestsUsed < 0 || + !Array.isArray(input.origins) || input.origins.some(origin => !Number.isSafeInteger(origin) || origin <= 0) || + new Set(input.origins).size !== input.origins.length || !Array.isArray(input.comments)) { + incompleteAudit('collector result is missing or malformed'); + } + auditScope = input.scope; + comments = input.comments; + if (auditScope === 'recent-candidates') { + if (!Number.isFinite(Date.parse(input.since)) || input.origins.length > AUTOMATIC_AUDIT_LIMITS.origins || + input.requestsUsed > AUTOMATIC_AUDIT_LIMITS.requests) incompleteAudit('invalid automatic discovery scope or budget'); + requestBudget = AUTOMATIC_AUDIT_LIMITS.requests - input.requestsUsed; + } + if (comments.some(comment => !input.origins.includes(comment.issue)) || + input.origins.some(origin => !comments.some(comment => comment.issue === origin))) { + incompleteAudit('collector candidate evidence is missing'); + } + } + if (!Array.isArray(comments)) throw new Error('comments file must contain an array'); + + const github = readOnlyGithub(args.repo, token, requestBudget); + const [owner, repo] = args.repo.split('/'); + const root = dirname(dirname(args['team-file'])); + const { verifyReconciliationRequest, selectionEvidence, frozenAssignments, verifyPublishedActivation, activationHistoryHead } = + await import('./squad-activation.mjs'); + const superseded = new Set(); + const assignments = new Map(); + const historyOrigins = new Set(); + const verifiedIds = new Set(); + for (const comment of comments) { + const artifact = parseStructuredData(comment.body ?? '', { envelopeOnly: true }); + if (artifact?.squad_artifact !== 'activation-reconciled') continue; + const records = [...comment.body.matchAll(/Activation reconciliation:\s*```json\s*([\s\S]*?)```/g)]; + if (records.length !== 1) throw new Error('Reconciliation metadata is missing or duplicated'); + const metadata = JSON.parse(records[0][1]); + const verified = await verifyReconciliationRequest({ + github, context: { repo: { owner, repo }, payload: { issue: { number: artifact.origin_issue } } }, + root, requestCommentId: metadata.request_comment_id, requestSha256: metadata.request_sha256, + }); + if (verified.snapshots.some(snapshot => snapshot.body !== snapshot.after)) throw new Error('Dependency corrections are incomplete'); + const supplements = verified.comments.filter(item => { + if (parseStructuredData(item.body ?? '', { envelopeOnly: true })?.squad_artifact !== 'activation-reconciled') return false; + const value = [...item.body.matchAll(/Activation reconciliation:\s*```json\s*([\s\S]*?)```/g)]; + if (value.length !== 1) throw new Error('Reconciliation metadata is missing or duplicated'); + return JSON.parse(value[0][1]).supersedes_comment_id === verified.original.id; + }); + const selected = selectActivationRecord([verified.original, ...supplements], verified.registry, { + repository: args.repo, originIssue: artifact.origin_issue, + reconciliations: [selectionEvidence(verified)], epicAssignments: frozenAssignments(verified.fullPlan), + }); + if (!supplements.some(item => item.id === selected.id && item.body === comment.body)) { + throw new Error('Reconciliation input differs from the independently selected API record'); + } + superseded.add(verified.original.id); + assignments.set(comment, frozenAssignments(verified.fullPlan)); + historyOrigins.add(artifact.origin_issue); + verifiedIds.add(selected.id); + } + let checked = 0; + const auditedOrigins = new Set(); + for (const comment of comments) { + if (superseded.has(comment.id)) continue; + const artifact = parseStructuredData(comment.body ?? ''); + if (!artifact || !ACTIVATION_ARTIFACTS.has(artifact.squad_artifact)) continue; + if (comment.body.includes('Activation evidence:')) { + if (!Number.isSafeInteger(comment.id) || comment.id <= 0) { + throw new Error('Published activation requires its collected API comment ID'); + } + const actual = (await github.rest.issues.getComment({ owner, repo, comment_id: comment.id })).data; + if (actual?.id !== comment.id || actual.body !== comment.body) { + throw new Error('Published activation input differs from its current API record'); + } + const verified = await verifyPublishedActivation({ + github, context: { repo: { owner, repo }, payload: { issue: { number: artifact.origin_issue } } }, + root, comment: actual, + }); + assignments.set(comment, verified.assignments); + historyOrigins.add(artifact.origin_issue); + verifiedIds.add(actual.id); + } + assertAcceptedOnlyLabelWording(comment.body ?? '', artifact.squad_artifact); + const issues = Array.isArray(artifact.bindings) + ? artifact.bindings.flatMap(binding => [extractIssueNumber(binding?.issue), extractIssueNumber(binding?.epic_issue)]).filter(Number.isInteger) + : []; + const labels = await fetchLabels(github, args.repo, [...new Set(issues)]); + const result = validateActivation(artifact, roster, labels, comment.issue, { + repository: args.repo, + registry, + epicAssignments: assignments.get(comment), + }); + checked += result.checked; + auditedOrigins.add(artifact.origin_issue); + } + for (const origin of historyOrigins) { + const current = await github.paginate(github.rest.issues.listComments, { + owner, repo, issue_number: origin, per_page: 100, + }); + const head = activationHistoryHead(current, origin, args.repo); + if (!verifiedIds.has(head)) throw new Error('Current activation head was not independently verified from the collected input'); + } + if (auditScope && input.origins.some(origin => !auditedOrigins.has(origin))) { + incompleteAudit('a candidate origin has no validated activation evidence'); + } + if (auditScope) console.log(`Historical audit scope: ${auditScope}; no current-run completion or repository-wide coverage outside that scope is asserted.`); + console.log(checked === 0 ? 'No historical activation artifact found; no current-run completion is asserted.' + : `Validated ${checked} activation bindings.`); +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + main().catch(error => { + console.error(`Agent binding check failed: ${error.message}`); + process.exitCode = 1; + }); +} diff --git a/workflows/shared/squad-activation.mjs b/workflows/shared/squad-activation.mjs new file mode 100644 index 000000000..2a9fada31 --- /dev/null +++ b/workflows/shared/squad-activation.mjs @@ -0,0 +1,1523 @@ +import { createHash } from 'node:crypto'; +import { isDeepStrictEqual } from 'node:util'; +import { readFileSync, writeFileSync } from 'node:fs'; +import { + checkActivationPublication, extractIssueNumber, parseAgentRegistry, parsePendingReservation, parseRoster, parseStructuredData, selectActivationRecord, + validateActivation, validateCastingPairFiles, +} from './check-agent-binding.mjs'; +import { authorizeSquadCommand, classifySquadCommand, markdownContentLines } from './squad-command-contract.mjs'; + +export const ACTIVATION_CAPTURE_SCHEMA = 'squad-activation-capture/v1'; +export const ACTIVATION_RECEIPT_SCHEMA = 'squad-activation-receipts/v1'; +export const ACTIVATION_MODES = new Set(['activate', 'plan accept', 'plan activate']); +const ACCEPTANCES = new Set(['plan-accepted', 'phases-accepted', 'activated', 'phases-activated', 'activation-reconciled']); +const STABLE_ID = /^[A-Za-z0-9]+(?:[._-][A-Za-z0-9]+)*$/; +const SHA = /^[a-f0-9]{40}$/; +const BOT_ID = 41898282; +const MAX_ISSUES = 50; +const MAX_PLAN_BYTES = 35000; +const MAX_ISSUE_BODY_CHARACTERS = 65536; + +function isActivationCommand(command) { + return ACTIVATION_MODES.has(command.mode) || + (command.mode === 'plan accept implementation' && command.phase !== null); +} + +function requireThat(condition, reason) { + if (!condition) throw new Error(`Activation refused: ${reason}`); +} + +export function digest(value) { + return createHash('sha256').update(typeof value === 'string' ? value : JSON.stringify(value)).digest('hex'); +} + +function positive(value) { + return Number.isSafeInteger(value) && value > 0; +} + +function normalize(value) { + return String(value ?? '').trim().toLowerCase(); +} + +function envelope(body) { + return parseStructuredData(body ?? '', { envelopeOnly: true }); +} + +function requireWorkflowRecord(comment, repository, origin) { + requireThat(comment?.user?.id === BOT_ID && comment.user.login === 'github-actions[bot]' && + comment.user.type === 'Bot' && + comment.issue_url === `https://api.github.com/repos/${repository}/issues/${origin}`, + 'acceptance and plan authority must be workflow-bot records on this origin; explicit reconciliation is required'); +} + +function labelFor(name, roster) { + if (name === '@copilot') return 'squad:copilot'; + if (!roster.has(normalize(name))) return null; + return `squad:${normalize(name).replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '')}`; +} + +function tableCells(line) { + requireThat(!line.includes('\\|'), 'escaped pipes in plan tables are unsupported'); + return line.trim().split('|').slice(1, -1).map(cell => cell.trim().replace(/^`([^`]+)`$/, '$1')); +} + +function dependencies(value) { + if (['', '-', '\u2014', 'none'].includes(normalize(value))) return []; + const values = value.split(',').map(cell => cell.trim().replace(/^`([^`]+)`$/, '$1')); + requireThat(values.every(id => STABLE_ID.test(id)) && new Set(values).size === values.length, + 'Depends On must contain unique comma-separated stable task IDs'); + return values; +} + +/** Parse only the documented table grammar; prose and titles never establish identity. */ +export function parseActivationPlan(body, kind, registryValue, teamMarkdown, selectedPhases = null) { + requireThat(typeof body === 'string' && Buffer.byteLength(body) <= MAX_PLAN_BYTES, + `frozen plan must be at most ${MAX_PLAN_BYTES} bytes`); + requireThat(['plan', 'implementation'].includes(kind), 'unsupported plan artifact'); + const registry = parseAgentRegistry(registryValue); + const roster = parseRoster(teamMarkdown); + const lines = markdownContentLines(body); + const phased = lines.some(line => /^### Phase [1-9]\d*(?:\s|$)/.test(line)); + let phase = null; + const tasks = []; + const ids = new Set(); + for (let index = 0; index < lines.length; index++) { + const line = lines[index]; + const heading = /^### Phase ([1-9]\d*)(?:\s|$)/.exec(line); + if (heading) phase = Number(heading[1]); + if (!line.trim().startsWith('|')) continue; + const header = tableCells(line); + if (!header.includes('#')) continue; + const required = ['#', 'Title', kind === 'plan' ? 'Owner' : 'Agent', 'Agent ID', 'Depends On']; + if (kind === 'implementation') required.push('Epic'); + requireThat(required.every(name => header.includes(name)) && new Set(header).size === header.length, + `task table requires ${required.join(', ')}`); + requireThat(/^\s*\|(?:\s*:?-+:?\s*\|)+\s*$/.test(lines[++index] ?? ''), + 'task table separator is missing'); + while (lines[index + 1]?.trim().startsWith('|')) { + const cells = tableCells(lines[++index]); + requireThat(cells.length === header.length, 'task table row has the wrong column count'); + const row = Object.fromEntries(header.map((key, offset) => [key, cells[offset]])); + const id = row['#']; + requireThat(STABLE_ID.test(id) && !ids.has(id), 'stable task IDs are missing, malformed or duplicated'); + ids.add(id); + requireThat(row.Title && !/[\r\n]/.test(row.Title), `task ${id} has no title`); + requireThat(!phased || positive(phase), `task ${id} is outside a declared phase`); + const agent = row[kind === 'plan' ? 'Owner' : 'Agent']; + requireThat(agent, `task ${id} has no owner`); + const agentId = row['Agent ID']; + const active = Object.entries(registryValue.agents).find(([, record]) => + record.status === 'active' && normalize(record.display_name) === normalize(agent)); + let identity = null; + let omission; + if (roster.has(normalize(agent))) { + requireThat(active && agentId === active[0] && registry.agents.has(agentId), + `task ${id} Agent ID does not match its active frozen roster owner`); + identity = agentId; + } else { + requireThat(agent === '@copilot', `task ${id} has an unknown non-roster owner`); + requireThat(['-', '\u2014'].includes(agentId), `task ${id} cannot assert a non-roster Agent ID`); + omission = 'external-agent'; + } + const epic = kind === 'implementation' ? row.Epic : phased ? `phase-${phase}` : 'origin'; + requireThat(STABLE_ID.test(epic), `task ${id} has an invalid epic ID`); + tasks.push({ + id, title: row.Title, phase, epic, agent, agent_id: identity, + ...(omission ? { identity_omission_reason: omission } : {}), + dependencies: dependencies(row['Depends On']), label: labelFor(agent, roster), + }); + } + } + requireThat(tasks.length > 0, 'plan has no supported task table'); + const byId = new Map(tasks.map(task => [task.id, task])); + const visiting = new Set(); + const visited = new Set(); + const sorted = []; + const visit = task => { + requireThat(!visiting.has(task.id), `task ${task.id} has a cyclic dependency`); + if (visited.has(task.id)) return; + visiting.add(task.id); + for (const dependency of task.dependencies) { + requireThat(byId.has(dependency), `task ${task.id} references missing task ${dependency}`); + visit(byId.get(dependency)); + } + visiting.delete(task.id); + visited.add(task.id); + sorted.push(task); + }; + tasks.forEach(visit); + const epics = [...new Set(tasks.map(task => task.epic))].filter(epic => epic !== 'origin'); + const selected = selectedPhases === null ? tasks : tasks.filter(task => selectedPhases.includes(task.phase)); + const selectedEpics = new Set(selected.map(task => task.epic).filter(epic => epic !== 'origin')); + requireThat(selected.length + selectedEpics.size <= MAX_ISSUES, `activation exceeds ${MAX_ISSUES} issues`); + return { kind, phased, tasks: sorted, epics }; +} + +export function activationIssueLimit(issueBody, repositoryPolicy = '') { + const profiles = { default: 20, lean: 10, enterprise: 50, spike: 5 }; + const visible = String(issueBody).replace(//g, '').replace(/```[\s\S]*?```/g, ''); + const profileLines = [...visible.matchAll(/^Squad-Policy:\s*([a-z][a-z0-9-]*)\s*$/gm)]; + requireThat(profileLines.length <= 1 && + [...visible.matchAll(/^Squad-Policy:/gm)].length === profileLines.length, + 'planning policy directive is malformed or ambiguous'); + const frontmatter = repositoryPolicy.match(/^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/)?.[1] ?? ''; + requireThat(!repositoryPolicy.trim() || frontmatter, 'repository planning policy requires supported YAML frontmatter'); + const profile = profileLines[0]?.[1] ?? frontmatter.match(/^profile:\s*([a-z][a-z0-9-]*)\s*$/m)?.[1] ?? 'default'; + const settings = [...frontmatter.matchAll(/^\s*max_issues:\s*(\d+)\s*$/gm)]; + requireThat(settings.length <= 1 && + [...frontmatter.matchAll(/^\s*max_issues:/gm)].length === settings.length, + 'repository max_issues is malformed or ambiguous'); + requireThat(Object.hasOwn(profiles, profile) || (profile === 'custom' && settings.length === 1), 'unsupported planning policy profile'); + let limit = profileLines.length && Object.hasOwn(profiles, profile) + ? profiles[profile] : settings.length ? Number(settings[0][1]) : profiles[profile]; + const overrides = [...visible.matchAll(/^Squad-Setting:\s*(.+)$/gm)] + .flatMap(match => match[1].split(',').map(value => value.trim())) + .filter(value => value.startsWith('max_issues=')); + requireThat(overrides.length <= 1 && overrides.every(value => /^max_issues=[1-9]\d*$/.test(value)), + 'max_issues override is malformed or ambiguous'); + if (overrides.length) limit = Number(overrides[0].slice('max_issues='.length)); + requireThat(positive(limit), 'max_issues must be a positive safe integer'); + return Math.min(limit, MAX_ISSUES); +} + +/** Validate an actual create result, not an agent's claimed issue-number mapping. */ +export function validateCreateReceipt(result, operation, capture, prior = []) { + requireThat(result?.staged === false && result.issue && !result.issue.pull_request, + `operation ${operation.id}: createIssue did not return an actual issue`); + const issue = result.issue; + requireThat(positive(issue.number) && positive(issue.id) && + typeof issue.node_id === 'string' && issue.node_id.length > 0, + `operation ${operation.id}: actual issue identities are missing or malformed`); + requireThat(issue.html_url === `https://github.com/${capture.repository}/issues/${issue.number}` && + issue.url === `https://api.github.com/repos/${capture.repository}/issues/${issue.number}`, + `operation ${operation.id}: create result repository or URL does not match`); + requireThat(!prior.some(receipt => receipt.operation_id === operation.id || + receipt.number === issue.number || receipt.id === issue.id || receipt.node_id === issue.node_id), + `operation ${operation.id}: duplicate operation or actual issue identity`); + return { + operation_id: operation.id, repository: capture.repository, + number: issue.number, id: issue.id, node_id: issue.node_id, url: issue.html_url, + }; +} + +export function validateReceiptSet(capture, journal) { + requireThat(journal?.schema === ACTIVATION_RECEIPT_SCHEMA && + journal.capture_sha256 === digest(capture) && + journal.repository === capture.repository && journal.run_id === capture.run_id && + journal.run_attempt === capture.run_attempt, 'receipt journal does not match the frozen capture'); + requireThat(Array.isArray(journal.receipts) && Array.isArray(journal.started), + 'receipt journal is malformed'); + requireThat(Array.isArray(journal.parent_fallbacks) && + new Set(journal.parent_fallbacks).size === journal.parent_fallbacks.length && + journal.parent_fallbacks.every(id => capture.operations.some(operation => operation.id === id)), + 'receipt journal parent fallback metadata is invalid'); + requireThat(new Set(journal.started).size === journal.started.length && + isDeepStrictEqual(journal.started, capture.operations.map(operation => operation.id)), + 'operations are incomplete, duplicated or out of order; no-create recovery required'); + requireThat(journal.receipts.length === capture.operations.length, + 'actual create receipts are missing; an API write may have occurred; recreation is forbidden'); + const checked = []; + for (const [index, operation] of capture.operations.entries()) { + const receipt = journal.receipts[index]; + requireThat(receipt?.operation_id === operation.id && receipt.repository === capture.repository, + 'receipt operation identity or repository mismatch'); + checked.push(validateCreateReceipt({ staged: false, issue: { + number: receipt.number, id: receipt.id, node_id: receipt.node_id, + html_url: receipt.url, url: `https://api.github.com/repos/${receipt.repository}/issues/${receipt.number}`, + } }, operation, capture, checked)); + } + return new Map(checked.map(receipt => [receipt.operation_id, receipt])); +} + +export function buildActivationBindings(capture, journal) { + const receipts = validateReceiptSet(capture, journal); + for (const receipt of capture.prior?.issues ?? []) { + requireThat(![...receipts.values()].some(current => current.number === receipt.number || current.id === receipt.id || + current.node_id === receipt.node_id), 'new issue collides with an explicitly mapped prior issue'); + receipts.set(receipt.operation_id, receipt); + } + return bindingsForIssueMap({ ...capture, plan: { + ...capture.plan, tasks: [...(capture.prior?.tasks ?? []), ...capture.plan.tasks], + } }, receipts); +} + +function bindingsForIssueMap(capture, receipts) { + const artifact = capture.plan.kind === 'plan' + ? (capture.phase === null ? 'plan-accepted' : 'phases-accepted') + : (capture.phase === null ? 'activated' : 'phases-activated'); + return capture.plan.tasks.map(task => { + const peers = (capture.fullPlan ?? capture.plan).tasks.filter(peer => peer.epic === task.epic); + const agents = [...new Set(peers.map(peer => normalize(peer.agent)))].sort(); + const agentIds = [...new Set(peers.map(peer => peer.agent_id).filter(Boolean))].sort(); + const root = task.epic === 'origin'; + const parentLabel = agents.length === 1 ? peers[0].label : null; + return { + binding_schema: 'squad-work-agent-binding/v1', binding_version: 1, + producer: 'squad', repository: capture.repository, origin_issue: capture.origin_issue, + artifact, registry_schema: 'squad-agent-provenance/v1', registry_revision: capture.registry.revision, + task: task.id, issue: `#${receipts.get(`task:${task.id}`).number}`, + epic: task.epic, epic_issue: `#${root ? capture.origin_issue : receipts.get(`epic:${task.epic}`).number}`, + agent: task.agent, agent_id: task.agent_id, epic_agents: agents, epic_agent_ids: agentIds, + ...(task.identity_omission_reason ? { identity_omission_reason: task.identity_omission_reason } : {}), + ...(peers.some(peer => peer.agent_id === null) ? { epic_identity_omission_reason: 'partial' } : {}), + label: task.label, + ...(!task.label ? { omission_reason: 'non-roster' } : {}), + epic_label: root ? null : parentLabel, + ...(root ? { epic_omission_reason: 'origin' } : !parentLabel + ? { epic_omission_reason: agents.length > 1 ? 'multi-owner' : 'non-roster' } : {}), + depends_on: task.dependencies.map(id => `#${receipts.get(`task:${id}`).number}`), + }; + }); +} + +export function activationRequest(items) { + requireThat(Array.isArray(items) && items.length === 1 && items[0]?.type === 'activate_plan', + 'activation requires exactly one activate_plan request; native creates, acceptance and lifecycle outputs are forbidden'); + const request = items[0]; + requireThat(Object.keys(request).every(key => ['type', 'plan_comment_id'].includes(key)) && + typeof request.plan_comment_id === 'string' && /^[1-9]\d*$/.test(request.plan_comment_id) && + positive(Number(request.plan_comment_id)), 'activate_plan requires an exact numeric plan_comment_id'); + return Number(request.plan_comment_id); +} + +export function frozenAssignments(plan) { + return plan.tasks.map(task => ({ task: task.id, epic: task.epic, agent: task.agent, agentId: task.agent_id })); +} + +function activationHeads(records, comments, repository) { + const superseded = new Set(); + const visited = new Set(); + const visiting = new Set(); + const reference = (id, hash) => { + const source = comments.find(comment => comment.id === id); + requireThat(source && digest(source.body) === hash, 'activation ancestry reference is missing or changed'); + superseded.add(id); + visit(source); + }; + const visit = comment => { + requireThat(!visiting.has(comment.id), 'activation ancestry is cyclic'); + if (visited.has(comment.id)) return; + visiting.add(comment.id); + const artifact = envelope(comment.body); + if (artifact?.squad_artifact === 'activation-reconciled') { + const metadata = jsonBlock(comment.body, 'Activation reconciliation'); + reference(metadata.supersedes_comment_id, metadata.supersedes_sha256); + } else { + const origin = records[0]?.artifact.origin_issue; + const prior = comment.body.includes('squad-activation-pending/v1') + ? parsePendingReservation(comment.body, repository, origin).prior + : comment.body.includes('Activation evidence:') ? jsonBlock(comment.body, 'Activation evidence').prior : null; + if (prior) { + reference(prior.source_comment_id, prior.source_sha256); + reference(prior.reconciliation_comment_id, prior.reconciliation_sha256); + } + } + visiting.delete(comment.id); + visited.add(comment.id); + }; + for (const record of records) visit(record.comment); + return records.filter(record => !superseded.has(record.comment.id)); +} + +/** Structural head check only; the caller must independently verify the returned record. */ +export function activationHistoryHead(comments, origin, repository) { + const records = comments.map(comment => ({ comment, artifact: envelope(comment.body) })) + .filter(({ artifact }) => ACCEPTANCES.has(artifact?.squad_artifact) && artifact.origin_issue === origin); + const heads = activationHeads(records, comments, repository); + requireThat(heads.length === 1, 'activation history has competing, cyclic or missing heads'); + return heads[0].comment.id; +} + +function coveredHistory(verified, selected) { + const allowed = new Set([selected.id]); + const pending = new Set(); + let source = verified.original; + let sourceKind = verified.request.source_kind ?? 'activation'; + let phases = verified.request.phases ?? []; + while (source) { + requireThat(!allowed.has(source.id), 'cyclic phase continuation ancestry'); + allowed.add(source.id); + if (sourceKind !== 'pending' && !source.body.includes('Activation evidence:')) break; + const evidence = sourceKind === 'pending' + ? { ...parsePendingReservation(source.body, verified.request.repository, verified.request.origin_issue), pending_comment_id: source.id } + : jsonBlock(source.body, 'Activation evidence'); + requireThat(['squad-activation-finalization/v1', 'squad-activation-pending/v1'].includes(evidence.schema), + 'unknown phase finalization evidence'); + const reservation = verified.comments.find(comment => comment.id === evidence.pending_comment_id); + if (reservation) { + const blocks = [...reservation.body.matchAll(/```json\s*([\s\S]*?)```/g)]; + requireThat(blocks.length === 1, 'prior reservation is malformed'); + const value = JSON.parse(blocks[0][1]); + requireThat(value.schema === 'squad-activation-pending/v1' && + value.repository === verified.request.repository && value.origin_issue === verified.request.origin_issue && + value.capture_sha256 === evidence.capture_sha256 && + value.run_id === evidence.run_id && value.run_attempt === evidence.run_attempt, + 'prior reservation does not match the explicitly covered source record'); + pending.add(reservation.id); + } + + if (!evidence.prior) break; + const link = evidence.prior; + exactKeys(link, ['source_comment_id', 'source_sha256', 'reconciliation_comment_id', 'reconciliation_sha256'], + 'phase ancestry reference is malformed'); + const previous = verified.comments.find(comment => comment.id === link.source_comment_id); + const supplement = verified.comments.find(comment => comment.id === link.reconciliation_comment_id); + requireThat(previous && supplement && digest(previous.body) === link.source_sha256 && + digest(supplement.body) === link.reconciliation_sha256 && !allowed.has(supplement.id), + 'phase ancestry source/supplement is missing, changed or cyclic'); + const supplementEnvelope = envelope(supplement.body); + const record = jsonBlock(supplement.body, 'Activation reconciliation'); + const previousEnvelope = record.supersedes_kind === 'pending' + ? { squad_artifact: 'activation-pending', ...parsePendingReservation(previous.body, + verified.request.repository, verified.request.origin_issue) } : envelope(previous.body); + requireThat((ACCEPTANCES.has(previousEnvelope?.squad_artifact) || previousEnvelope?.squad_artifact === 'activation-pending') && + previousEnvelope.squad_artifact !== 'activation-reconciled' && + previousEnvelope.origin_issue === verified.request.origin_issue && + supplementEnvelope?.squad_artifact === 'activation-reconciled' && + supplementEnvelope.origin_issue === verified.request.origin_issue && + Array.isArray(supplementEnvelope.phases) && supplementEnvelope.phases.length < phases.length && + supplementEnvelope.phases.every(phase => phases.includes(phase)) && + record.supersedes_comment_id === previous.id && record.supersedes_sha256 === link.source_sha256 && + record.plan_sha256 === digest(verified.planComment.body) && record.registry_sha256 === digest(verified.registry), + 'phase ancestry is not an exact covered predecessor'); + allowed.add(supplement.id); + phases = supplementEnvelope.phases; + source = previous; + sourceKind = record.supersedes_kind ?? 'activation'; + } + return { allowed_comment_ids: [...allowed], ignored_pending_ids: [...pending] }; +} + +/** Capture from trusted API/base inputs, never from an agent-authored snapshot. */ +export async function prepareActivation({ github, context, env, root, outputPath, capturePath }) { + const command = classifySquadCommand(context.payload, env.GITHUB_EVENT_NAME); + requireThat(isActivationCommand(command), 'command is not an activation mode'); + await authorizeSquadCommand({ + payload: context.payload, eventName: env.GITHUB_EVENT_NAME, actor: context.actor, result: command, + resolvePermission: async username => (await github.rest.repos.getCollaboratorPermissionLevel({ + ...context.repo, username, + })).data.permission, + }); + const origin = Number(context.payload.inputs?.issue_number ?? context.payload.issue?.number); + requireThat(positive(origin) && !context.payload.issue?.pull_request, 'origin must be an issue'); + const originResponse = await github.rest.issues.get({ ...context.repo, issue_number: origin }); + requireThat(!originResponse.data.pull_request, 'activation cannot target a pull request'); + const planId = activationRequest(JSON.parse(readFileSync(outputPath, 'utf8')).items); + const registry = await validateCastingPairFiles(`${root}/.squad/casting/registry.json`); + const team = readFileSync(`${root}/.squad/team.md`, 'utf8'); + const comments = await github.paginate(github.rest.issues.listComments, { + ...context.repo, issue_number: origin, per_page: 100, + }); + const accepted = comments.map(comment => ({ comment, artifact: envelope(comment.body) })) + .filter(({ artifact }) => ACCEPTANCES.has(artifact?.squad_artifact) && artifact.origin_issue === origin); + let prior; + if (command.phase !== null && accepted.length && + !accepted.some(({ artifact }) => artifact.phases?.includes(command.phase))) { + const bridges = []; + for (const candidate of accepted.filter(({ artifact }) => artifact.squad_artifact === 'activation-reconciled')) { + requireWorkflowRecord(candidate.comment, `${context.repo.owner}/${context.repo.repo}`, origin); + const metadata = jsonBlock(candidate.comment.body, 'Activation reconciliation'); + const verified = await verifyReconciliationRequest({ + github, context, root, requestCommentId: metadata.request_comment_id, requestSha256: metadata.request_sha256, + }); + const phases = verified.request.phases ?? []; + if (!phases.length || phases.some(phase => phase >= command.phase)) continue; + requireThat(verified.snapshots.every(snapshot => snapshot.body === snapshot.after), 'prior-phase dependencies are not reconciled'); + selectActivationRecord([verified.original, candidate.comment], registry, { + repository: `${context.repo.owner}/${context.repo.repo}`, originIssue: origin, + reconciliations: [selectionEvidence(verified)], epicAssignments: frozenAssignments(verified.fullPlan), + }); + bridges.push({ verified, candidate, phases }); + } + const complete = bridges.filter(bridge => { + const previous = [...new Set(bridge.verified.fullPlan.tasks.filter(task => task.phase < command.phase).map(task => task.phase))]; + return isDeepStrictEqual([...bridge.phases].sort((a, b) => a - b), previous.sort((a, b) => a - b)); + }); + requireThat(complete.length === 1, + 'exactly one current-state authorized prior-phase mapping bridge is required; reconcile the complete prior-phase coverage'); + const { verified, candidate, phases } = complete[0]; + prior = { + request_comment_id: verified.requestCommentId, request_sha256: verified.requestSha256, + source_comment_id: verified.original.id, reconciliation_comment_id: candidate.comment.id, + source_sha256: digest(verified.original.body), reconciliation_sha256: digest(candidate.comment.body), + ...coveredHistory(verified, candidate.comment), + phases, tasks: verified.plan.tasks, issues: verified.snapshots, + plan_sha256: digest(verified.planComment.body), registry_sha256: digest(verified.registry), + }; + } + + if (accepted.length && !prior) { + const selected = activationHeads(accepted, comments, `${context.repo.owner}/${context.repo.repo}`).filter(({ artifact }) => + command.phase === null || artifact.phases?.includes(command.phase)); + requireThat(selected.length === 1, + 'existing phase/supersession authority is incomplete or ambiguous; later-phase creation is blocked'); + const { comment } = selected[0]; + requireWorkflowRecord(comment, `${context.repo.owner}/${context.repo.repo}`, origin); + const artifact = parseStructuredData(comment.body); + let assignments; + let fullPlan; + if (artifact.squad_artifact === 'activation-reconciled') { + const metadata = jsonBlock(comment.body, 'Activation reconciliation'); + const verified = await verifyReconciliationRequest({ + github, context, root, requestCommentId: metadata.request_comment_id, requestSha256: metadata.request_sha256, + }); + requireThat(verified.snapshots.every(snapshot => snapshot.body === snapshot.after), 'current dependency corrections are incomplete'); + selectActivationRecord([verified.original, comment], registry, { + repository: `${context.repo.owner}/${context.repo.repo}`, originIssue: origin, + reconciliations: [selectionEvidence(verified)], epicAssignments: frozenAssignments(verified.fullPlan), + }); + assignments = frozenAssignments(verified.fullPlan); + fullPlan = verified.fullPlan; + } else if (comment.body.includes('Activation evidence:')) { + const published = await verifyPublishedActivation({ github, context, root, comment }); + fullPlan = published.fullPlan; + assignments = published.assignments; + } else { + fullPlan = await verifyLegacyActivation({ + github, context, comments, comment, planId, registry, team, origin, + }); + assignments = frozenAssignments(fullPlan); + } + if (command.phase === null && artifact.phases?.length) { + requireThat(assignments?.length === artifact.bindings?.length, + 'only prior phases are accepted; activate the explicitly selected next phase using its mapping bridge'); + } + const labels = new Map(); + for (const issue of new Set((artifact.bindings ?? []).flatMap(binding => + [extractIssueNumber(binding.issue), extractIssueNumber(binding.epic_issue)]).filter(positive))) { + const actual = (await github.rest.issues.get({ ...context.repo, issue_number: issue })).data; + requireThat(!actual.pull_request, 'existing binding references a pull request'); + labels.set(issue, new Set(actual.labels.map(label => normalize(typeof label === 'string' ? label : label.name)))); + } + validateActivation(artifact, parseRoster(team), labels, origin, { + repository: `${context.repo.owner}/${context.repo.repo}`, registry: parseAgentRegistry(registry), + epicAssignments: assignments, + }); + await upsertActivationLifecycle({ github, context, origin, fullPlan, phases: artifact.phases, + command: `/squad ${command.argumentText}` }); + return { existingAcceptanceId: comment.id }; + } + for (const comment of comments) { + // Even an untrusted reservation stops creation; it can deny service, never authorize a retry. + const artifact = envelope(comment.body); + requireThat(!ACCEPTANCES.has(artifact?.squad_artifact) || prior?.allowed_comment_ids.includes(comment.id), + 'an acceptance already exists; preserve it. Missing authenticated historical capture/operation receipts; no-create reconciliation is required'); + requireThat(!String(comment.body ?? '').includes('squad-activation-pending/v1') || prior?.ignored_pending_ids.includes(comment.id), + 'a pending activation exists; an API write may have occurred. No-create recovery requires an authorized current-state request with source_kind: pending, its exact comment ID/hash, and complete existing issue mappings'); + } + const candidates = comments.filter(comment => { + const artifact = envelope(comment.body); + return ['plan', 'implementation'].includes(artifact?.squad_artifact) && + artifact.schema_version === '1' && artifact.origin_issue === origin; + }).sort((left, right) => String(left.created_at).localeCompare(String(right.created_at)) || left.id - right.id); + const planComment = candidates.at(-1); + if (!planComment && comments.some(comment => envelope(comment.body)?.squad_artifact === 'program' && + envelope(comment.body)?.origin_issue === origin)) { + throw new Error('Activation refused: only a program plan exists. Use /squad plan accept scope, /squad plan implementation, /squad plan validate and /squad plan accept implementation before /squad plan activate.'); + } + requireThat(planComment?.id === planId, 'request does not identify the latest supported plan comment'); + requireThat(planComment.user?.id === BOT_ID && planComment.user?.login === 'github-actions[bot]' && + planComment.user?.type === 'Bot', 'plan comment must be produced by the repository workflow bot'); + const envelopes = [...planComment.body.matchAll(/Structured data:\s*```json\s*([\s\S]*?)```/gi)]; + requireThat(envelopes.length === 1, 'plan comment must contain exactly one structured envelope'); + const artifact = parseStructuredData(planComment.body); + requireThat(Array.isArray(artifact.phases) && artifact.phases.length === 0, 'draft plan phases envelope is invalid'); + const plan = parseActivationPlan(planComment.body, artifact.squad_artifact, registry, team, []); + const fullPlan = structuredClone(plan); + if (prior) requireThat(prior.plan_sha256 === digest(planComment.body) && prior.registry_sha256 === digest(registry), + 'prior-phase bridge is stale for this exact plan/registry'); + if (command.phase !== null) { + requireThat(plan.phased, 'phase activation requires explicit Phase headings'); + const previousPhases = [...new Set(fullPlan.tasks.filter(task => task.phase < command.phase).map(task => task.phase))] + .sort((left, right) => left - right); + requireThat(isDeepStrictEqual(prior?.phases ?? [], previousPhases), + 'complete prior-phase mapping bridge is required before a later phase, including independent tasks'); + const selected = plan.tasks.filter(task => task.phase === command.phase); + requireThat(selected.length > 0, 'requested phase has no tasks'); + const selectedIds = new Set(selected.map(task => task.id)); + const previousIds = new Set(prior?.tasks.map(task => task.id) ?? []); + requireThat(selected.every(task => task.dependencies.every(id => selectedIds.has(id) || previousIds.has(id))), + 'authenticated prior-phase issue mappings are unavailable; cross-phase activation requires no-create reconciliation'); + plan.tasks = selected; + plan.epics = [...new Set(selected.map(task => task.epic))].filter(epic => epic !== 'origin'); + } + let approvals; + if (artifact.squad_artifact === 'implementation') { + const acceptedComments = comments.filter(comment => comment.user?.id === BOT_ID && + envelope(comment.body)?.schema_version === '1' && envelope(comment.body)?.origin_issue === origin); + const scope = acceptedComments.filter(comment => envelope(comment.body).squad_artifact === 'scope-accepted').at(-1); + requireThat(scope, 'granular activation requires existing scope-accepted'); + if (command.mode === 'plan accept implementation') { + const validation = acceptedComments.filter(comment => envelope(comment.body).squad_artifact === 'validation').at(-1); + requireThat(validation, 'combined phase acceptance requires a current validation artifact'); + validateImplementationVerdict(validation.body, planComment, fullPlan); + approvals = { scope, validation }; + } else { + const implementation = acceptedComments.find(comment => { + const value = envelope(comment.body); + return value.squad_artifact === 'impl-accepted' || + (command.phase !== null && value.squad_artifact === 'impl-phases-accepted' && value.phases?.includes(command.phase)); + }); + requireThat(implementation, 'granular activation requires existing impl-accepted or selected implementation-phase acceptance'); + approvals = { scope, implementation }; + } + } + requireThat(command.mode !== 'plan accept implementation' || artifact.squad_artifact === 'implementation', + 'combined acceptance requires an implementation plan'); + const runId = Number(env.GITHUB_RUN_ID); + const attempt = Number(env.GITHUB_RUN_ATTEMPT); + requireThat(positive(runId) && positive(attempt) && SHA.test(env.SQUAD_WORKFLOW_SHA ?? ''), + 'trusted run/attempt/workflow identity is unavailable'); + const operations = [ + ...plan.epics.filter(epic => !prior?.issues.some(issue => issue.operation_id === `epic:${epic}`)).map(epic => ({ + id: `epic:${epic}`, epic, title: `[Epic] ${epic}`, + labels: ['squad', ...new Set(fullPlan.tasks.filter(task => task.epic === epic).map(task => task.label))], + })).map(operation => { + const names = new Set(fullPlan.tasks.filter(task => task.epic === operation.epic).map(task => normalize(task.agent))); + return { ...operation, labels: names.size === 1 ? operation.labels.filter(Boolean) : ['squad'] }; + }), + ...plan.tasks.map(task => ({ + id: `task:${task.id}`, task: task.id, epic: task.epic, title: task.title, + labels: ['squad', ...(task.label ? [task.label] : [])], + })), + ]; + let repositoryPolicy = ''; + try { repositoryPolicy = readFileSync(`${root}/.squad/planning-policy.md`, 'utf8'); } + catch (error) { if (error.code !== 'ENOENT') throw error; } + const issueLimit = activationIssueLimit(originResponse.data.body ?? '', repositoryPolicy); + requireThat(operations.length <= issueLimit && new Set(operations.map(operation => operation.id)).size === operations.length, + 'operation cap or unique stable operation identity violated'); + const capture = { + schema: ACTIVATION_CAPTURE_SCHEMA, repository: `${context.repo.owner}/${context.repo.repo}`, + origin_issue: origin, run_id: runId, run_attempt: attempt, workflow_sha: env.SQUAD_WORKFLOW_SHA, + command: command.mode, phase: command.phase, actor: context.actor, + plan_comment: { id: planComment.id, author_id: planComment.user.id, updated_at: planComment.updated_at, + body: planComment.body, sha256: digest(planComment.body), url: planComment.html_url }, + registry, registry_sha256: digest(registry), team, plan, fullPlan, operations, issue_limit: issueLimit, + ...(prior ? { prior } : {}), + ...(approvals ? { approvals } : {}), + }; + writeFileSync(capturePath, `${JSON.stringify(capture)}\n`); + return capture; +} + +async function revalidateCapture({ github, context, env, root, capture }) { + const command = classifySquadCommand(context.payload, env.GITHUB_EVENT_NAME); + requireThat(isActivationCommand(command) && command.mode === capture.command && + command.phase === capture.phase && context.actor === capture.actor && + capture.repository === `${context.repo.owner}/${context.repo.repo}` && + capture.origin_issue === Number(context.payload.inputs?.issue_number ?? context.payload.issue?.number) && + capture.run_id === Number(env.GITHUB_RUN_ID) && capture.run_attempt === Number(env.GITHUB_RUN_ATTEMPT) && + capture.workflow_sha === env.SQUAD_WORKFLOW_SHA, 'capture does not match the executing command/run/attempt'); + await authorizeSquadCommand({ + payload: context.payload, eventName: env.GITHUB_EVENT_NAME, actor: context.actor, result: command, + resolvePermission: async username => (await github.rest.repos.getCollaboratorPermissionLevel({ + ...context.repo, username, + })).data.permission, + }); + const registry = await validateCastingPairFiles(`${root}/.squad/casting/registry.json`); + requireThat(digest(registry) === capture.registry_sha256 && + readFileSync(`${root}/.squad/team.md`, 'utf8') === capture.team, 'frozen registry/roster changed during execution'); + for (const approval of Object.values(capture.approvals ?? {})) { + const current = (await github.rest.issues.getComment({ ...context.repo, comment_id: approval.id })).data; + requireThat(current?.updated_at === approval.updated_at && current.body === approval.body, + 'scope acceptance, implementation acceptance or validation changed during execution'); + } + if (capture.prior) { + const verified = await verifyReconciliationRequest({ github, context, root, + requestCommentId: capture.prior.request_comment_id, requestSha256: capture.prior.request_sha256 }); + const selected = verified.comments.find(comment => comment.id === capture.prior.reconciliation_comment_id); + requireWorkflowRecord(selected, capture.repository, capture.origin_issue); + requireThat(selected && digest(selected.body) === capture.prior.reconciliation_sha256 && + digest(verified.original.body) === capture.prior.source_sha256 && + verified.snapshots.every(snapshot => snapshot.body === snapshot.after) && + isDeepStrictEqual(verified.snapshots.map(snapshot => [snapshot.number, snapshot.after_sha256]), + capture.prior.issues.map(snapshot => [snapshot.number, snapshot.after_sha256])), + 'prior-phase bridge changed during execution'); + selectActivationRecord([verified.original, selected], registry, { + repository: capture.repository, originIssue: capture.origin_issue, + reconciliations: [selectionEvidence(verified)], epicAssignments: frozenAssignments(verified.fullPlan), + }); + requireThat(isDeepStrictEqual(coveredHistory(verified, selected), { + allowed_comment_ids: capture.prior.allowed_comment_ids, ignored_pending_ids: capture.prior.ignored_pending_ids, + }), 'prior-phase ancestry changed during execution'); + } +} + +function pendingBody(capture, artifactId) { + return [ + '## Activation pending - not accepted', + 'Issue creation may have started. Do not repeat creation or infer task identity from titles.', + 'This reservation is a no-create guard, not recovery authority or historical attestation.', + '', + '```json', + JSON.stringify({ schema: 'squad-activation-pending/v1', repository: capture.repository, + origin_issue: capture.origin_issue, run_id: capture.run_id, run_attempt: capture.run_attempt, + capture_sha256: digest(capture), capture_artifact_id: artifactId, + plan_comment_id: capture.plan_comment.id, plan_sha256: capture.plan_comment.sha256, + registry_sha256: capture.registry_sha256, + phases: capture.phase === null ? [] : [...(capture.prior?.phases ?? []), capture.phase], + ...(capture.prior ? { prior: { + source_comment_id: capture.prior.source_comment_id, source_sha256: capture.prior.source_sha256, + reconciliation_comment_id: capture.prior.reconciliation_comment_id, + reconciliation_sha256: capture.prior.reconciliation_sha256, + } } : {}), + }), + '```', + ].join('\n'); +} + +function validationTable(body, columns) { + const lines = body.replace(/\r\n?/g, '\n').split('\n'); + const headers = lines.map((line, index) => ({ line, index })).filter(({ line }) => + line.trim().startsWith('|') && isDeepStrictEqual(tableCells(line), columns)); + requireThat(headers.length === 1, `validation requires one ${columns.join(' | ')} table`); + let index = headers[0].index; + requireThat(/^\s*\|(?:\s*:?-+:?\s*\|)+\s*$/.test(lines[++index] ?? ''), 'validation table separator is missing'); + const rows = []; + while (lines[index + 1]?.trim().startsWith('|')) { + const cells = tableCells(lines[++index]); + requireThat(cells.length === columns.length, 'validation table row is malformed'); + rows.push(Object.fromEntries(columns.map((column, offset) => [column, cells[offset]]))); + } + return rows; +} + +function validateImplementationVerdict(body, planComment, plan) { + const verdicts = [...body.matchAll(/^RESULT:\s*(PASS|FAIL)\s*$/gm)]; + requireThat(verdicts.length === 1 && verdicts[0][1] === 'PASS' && + body.split(/\r?\n/).includes(`**Implementation plan:** ${planComment.html_url}`) && + body.split(/\r?\n/).includes(`**Implementation SHA-256:** ${digest(planComment.body)}`), + 'validation must PASS and bind this exact implementation comment/revision'); + const checks = validationTable(body, ['Check', 'Status', 'Details']); + requireThat(checks.length === 16 && new Set(checks.map(row => row.Check)).size === 16 && + checks.every(row => /^(?:[1-9]|1[0-6])$/.test(row.Check) && + /^(?:PASS|✅(?:\s+(?:PASS|Done))?)$/i.test(row.Status) && row.Details), + 'all sixteen structural/adversarial validation checks must pass'); + const sizes = validationTable(body, ['Task', 'Size', 'Traces to']); + requireThat(sizes.length === plan.tasks.length && new Set(sizes.map(row => row.Task)).size === sizes.length && + sizes.every(row => plan.tasks.some(task => task.id === row.Task) && + ['XS', 'S', 'M', 'L'].includes(row.Size) && row['Traces to'].trim() && + !['-', '\u2014', 'none'].includes(normalize(row['Traces to']))), + 'validation sizing/traceability must cover the exact plan, with task sizes no larger than L'); +} + +function bodyFor(operation, capture, journal) { + const receipts = new Map([...(capture.prior?.issues ?? []), ...journal.receipts] + .map(receipt => [receipt.operation_id, receipt])); + const task = operation.task ? capture.plan.tasks.find(task => task.id === operation.task) : null; + const parent = !task || task.epic === 'origin' ? capture.origin_issue : receipts.get(`epic:${task.epic}`)?.number; + requireThat(positive(parent), `operation ${operation.id}: parent has no actual create receipt`); + const deps = task ? task.dependencies.map(id => { + const receipt = receipts.get(`task:${id}`); + requireThat(receipt, `operation ${operation.id}: dependency ${id} has no actual create receipt`); + return `#${receipt.number}`; + }) : []; + return { + parent, + body: [ + `## ${operation.title}`, '', `**Stable work ID:** ${operation.id}`, + `**Parent:** #${parent}`, `**Depends On:** ${deps.length ? deps.join(', ') : 'None'}`, + ...(task ? [`**Owner:** ${task.agent}`] : []), '', + `Frozen plan: ${capture.plan_comment.url}`, + `Plan SHA-256: ${capture.plan_comment.sha256}`, '', + 'The frozen plan below is reference context. This issue covers only the stable work ID above.', + '', ...capture.plan_comment.body.split(/\r?\n/).map(line => `> ${line}`), + ].join('\n'), + }; +} + +function checkedIssueBody(body, operation, sanitizeContent, formatIssueBody, repo) { + const sanitized = sanitizeContent(body); + requireThat(typeof sanitized === 'string', `operation ${operation.id}: sanitizer returned a non-string issue body`); + const attributed = formatIssueBody(sanitized, { repo }); + requireThat(typeof attributed === 'string' && attributed.length <= MAX_ISSUE_BODY_CHARACTERS, + `operation ${operation.id}: rendered issue body exceeds the pinned runtime's ${MAX_ISSUE_BODY_CHARACTERS}-character limit including attribution`); + return sanitized; +} + +function identityLines(body) { + return String(body ?? '').split(/\r?\n/).filter(line => + /^\*\*(?:Stable work ID|Parent|Depends On):\*\*/.test(line)); +} + +function omissionSummary(bindings) { + const epics = new Map(); + for (const binding of bindings) { + if (binding.epic_omission_reason) epics.set(binding.epic_issue, + `- Epic ${binding.epic_issue}: ${binding.epic_omission_reason}; owners: ${binding.epic_agents.join(', ')}. No sole-owner label is asserted.`); + } + return epics.size ? ['', '### Non-roster agent values', ...epics.values()] : []; +} + +async function verifyCurrentParent(github, context, issue, parent, operation, fallbacks) { + let current; + try { + current = (await github.rest.issues.getParent({ ...context.repo, issue_number: issue })).data; + } catch (error) { + if ([404, 422].includes(error.status) && fallbacks.includes(operation)) return; + throw error; + } + requireThat(current?.number === parent && + current.url === `https://api.github.com/repos/${context.repo.owner}/${context.repo.repo}/issues/${parent}`, + `operation ${operation}: current API hierarchy differs from the frozen binding`); +} + +/** Only this trusted caller receives the runtime's synchronous createIssue result. */ +export async function createActivation({ + github, context, env, root, capture, captureArtifactId, journalPath, createIssue, sanitizeContent, formatIssueBody, +}) { + requireThat(capture.schema === ACTIVATION_CAPTURE_SCHEMA && + capture.repository === `${context.repo.owner}/${context.repo.repo}` && + positive(captureArtifactId), 'trusted capture identity or durable capture artifact is missing'); + await revalidateCapture({ github, context, env, root, capture }); + requireThat(typeof formatIssueBody === 'function', 'pinned runtime issue-body formatter is required before reservation'); + const titles = new Map(capture.operations.map(operation => { + const title = sanitizeContent(operation.title); + requireThat(typeof title === 'string' && title.trim().length > 0 && title.length <= 256, + `operation ${operation.id}: sanitized title is empty or exceeds GitHub's limit`); + requireThat(operation.labels.every(name => name.length <= 50 && + (name === 'squad' || /^squad:[a-z0-9]+(?:-[a-z0-9]+)*$/.test(name))), + `operation ${operation.id}: unsupported activation label`); + return [operation.id, title]; + })); + // Reserve only after rendering every body with the longest accepted numeric + // references and the pinned runtime's attribution and UTF-16 length bound. + const preview = { receipts: capture.operations.map(operation => ({ + operation_id: operation.id, number: Number.MAX_SAFE_INTEGER, + })) }; + for (const operation of capture.operations) { + checkedIssueBody(bodyFor(operation, capture, preview).body, operation, sanitizeContent, formatIssueBody, context.repo); + } + const comments = await github.paginate(github.rest.issues.listComments, { + ...context.repo, issue_number: capture.origin_issue, per_page: 100, + }); + requireThat(!comments.some(comment => + (String(comment.body ?? '').includes('squad-activation-pending/v1') && !capture.prior?.ignored_pending_ids.includes(comment.id)) || + (ACCEPTANCES.has(envelope(comment.body)?.squad_artifact) && !capture.prior?.allowed_comment_ids.includes(comment.id))), + 'activation was reserved or accepted after capture; no issue may be recreated'); + const currentPlan = (await github.rest.issues.getComment({ + ...context.repo, comment_id: capture.plan_comment.id, + })).data; + requireThat(currentPlan.updated_at === capture.plan_comment.updated_at && + digest(currentPlan.body) === capture.plan_comment.sha256, 'plan changed after capture'); + // Label provisioning and exact approval upserts are retry-safe prerequisites, + // unlike createIssue, whose ambiguous outcomes must retain the reservation. + const labels = [...new Set(capture.operations.flatMap(operation => operation.labels))]; + for (const name of labels) { + requireThat(name === 'squad' || /^squad:[a-z0-9]+(?:-[a-z0-9]+)*$/.test(name), 'unsupported activation label'); + try { + await github.rest.issues.getLabel({ ...context.repo, name }); + } catch (error) { + if (error.status !== 404) throw error; + await github.rest.issues.createLabel({ ...context.repo, name, color: '9B8FCC' }); + } + } + if (capture.approvals?.validation) { + const body = [ + `## Implementation Phase ${capture.phase} Accepted`, + `**Implementation plan:** ${capture.plan_comment.url}`, + `**Implementation SHA-256:** ${capture.plan_comment.sha256}`, + `Validation: ${capture.approvals.validation.html_url}`, + `Accepted by: ${capture.actor}`, + '', 'Structured data:', '```json', + JSON.stringify({ squad_artifact: 'impl-phases-accepted', schema_version: '1', + origin_issue: capture.origin_issue, phases: [...(capture.prior?.phases ?? []), capture.phase] }), '```', + ].join('\n'); + if (!comments.some(comment => comment.body === body && comment.user?.id === BOT_ID && + comment.user?.login === 'github-actions[bot]' && comment.user?.type === 'Bot' && + comment.issue_url === `https://api.github.com/repos/${capture.repository}/issues/${capture.origin_issue}`)) { + await github.rest.issues.createComment({ ...context.repo, issue_number: capture.origin_issue, body }); + } + } + const beforeReservation = await github.paginate(github.rest.issues.listComments, { + ...context.repo, issue_number: capture.origin_issue, per_page: 100, + }); + requireThat(!beforeReservation.some(comment => + (String(comment.body ?? '').includes('squad-activation-pending/v1') && !capture.prior?.ignored_pending_ids.includes(comment.id)) || + (ACCEPTANCES.has(envelope(comment.body)?.squad_artifact) && !capture.prior?.allowed_comment_ids.includes(comment.id))), + 'activation was reserved or accepted during preflight; no issue may be recreated'); + const pending = await github.rest.issues.createComment({ + ...context.repo, issue_number: capture.origin_issue, body: pendingBody(capture, captureArtifactId), + }); + requireThat(positive(pending.data.id), 'durable no-create reservation identity is missing'); + const journal = { + schema: ACTIVATION_RECEIPT_SCHEMA, repository: capture.repository, + capture_sha256: digest(capture), run_id: capture.run_id, run_attempt: capture.run_attempt, + pending_comment_id: pending.data.id, capture_artifact_id: captureArtifactId, + started: [], receipts: [], parent_fallbacks: [], + }; + const save = () => writeFileSync(journalPath, `${JSON.stringify(journal)}\n`); + save(); + for (const operation of capture.operations) { + const content = bodyFor(operation, capture, journal); + const body = checkedIssueBody(content.body, operation, sanitizeContent, formatIssueBody, context.repo); + journal.started.push(operation.id); + save(); + // No retry on an ambiguous API failure: the durable reservation outlives this runner. + const result = await createIssue({ + ...context.repo, title: titles.get(operation.id), body, + labels: operation.labels, + }); + const receipt = validateCreateReceipt(result, operation, capture, [...(capture.prior?.issues ?? []), ...journal.receipts]); + journal.receipts.push(receipt); + save(); + try { + await github.rest.issues.addSubIssue({ + ...context.repo, issue_number: content.parent, sub_issue_id: receipt.id, + }); + } catch (error) { + if (![404, 422].includes(error.status)) throw error; + journal.parent_fallbacks.push(operation.id); + save(); + } + } + validateReceiptSet(capture, journal); + return journal; +} + +export async function finalizeActivation({ github, context, env, root, capture, journal, receiptArtifactId }) { + requireThat(positive(receiptArtifactId), 'durable receipt artifact upload is missing; acceptance is withheld'); + await revalidateCapture({ github, context, env, root, capture }); + const receipts = validateReceiptSet(capture, journal); + const bindings = buildActivationBindings(capture, journal); + const labels = new Map(); + if (capture.prior) { + for (const previous of capture.prior.issues) { + const current = (await github.rest.issues.get({ ...context.repo, issue_number: previous.number })).data; + requireThat(current.id === previous.id && current.node_id === previous.node_id && + digest(current.body) === previous.after_sha256, 'prior-phase issue changed during activation'); + labels.set(previous.number, new Set(current.labels.map(label => normalize(typeof label === 'string' ? label : label.name)))); + } + } + for (const operation of capture.operations) { + const receipt = receipts.get(operation.id); + const issue = (await github.rest.issues.get({ + ...context.repo, issue_number: receipt.number, + })).data; + requireThat(!issue.pull_request && issue.id === receipt.id && issue.node_id === receipt.node_id && + issue.number === receipt.number && issue.url === `https://api.github.com/repos/${capture.repository}/issues/${receipt.number}`, + `operation ${operation.id}: current issue identity differs from the actual receipt`); + const expected = identityLines(bodyFor(operation, capture, journal).body); + requireThat(isDeepStrictEqual(identityLines(issue.body), expected), + `operation ${operation.id}: current parent/dependency/stable ID references differ from the frozen binding`); + await verifyCurrentParent(github, context, receipt.number, bodyFor(operation, capture, journal).parent, + operation.id, journal.parent_fallbacks); + labels.set(receipt.number, new Set(issue.labels.map(label => normalize(typeof label === 'string' ? label : label.name)))); + } + const artifact = { + squad_artifact: bindings[0].artifact, schema_version: '1', origin_issue: capture.origin_issue, + phases: capture.phase === null ? [] : [...(capture.prior?.phases ?? []), capture.phase], bindings, + }; + validateActivation(artifact, parseRoster(capture.team), labels, capture.origin_issue, { + repository: capture.repository, registry: parseAgentRegistry(capture.registry), + epicAssignments: frozenAssignments(capture.fullPlan ?? capture.plan), + }); + const currentPlan = (await github.rest.issues.getComment({ + ...context.repo, comment_id: capture.plan_comment.id, + })).data; + requireThat(digest(currentPlan.body) === capture.plan_comment.sha256 && + currentPlan.updated_at === capture.plan_comment.updated_at, 'plan changed during activation; acceptance is withheld'); + const comments = await github.paginate(github.rest.issues.listComments, { + ...context.repo, issue_number: capture.origin_issue, per_page: 100, + }); + const reservation = comments.find(comment => comment.id === journal.pending_comment_id); + requireThat(reservation?.body === pendingBody(capture, journal.capture_artifact_id), + 'no-create reservation changed or disappeared; acceptance is withheld'); + requireThat(!comments.some(comment => ACCEPTANCES.has(envelope(comment.body)?.squad_artifact) && + !capture.prior?.allowed_comment_ids.includes(comment.id)), + 'acceptance already exists; never recreate or edit it'); + const evidence = { + schema: 'squad-activation-finalization/v1', basis: 'current-trusted-execution', + run_id: capture.run_id, run_attempt: capture.run_attempt, workflow_sha: capture.workflow_sha, + capture_sha256: digest(capture), receipts_sha256: digest(journal), + capture_artifact_id: journal.capture_artifact_id, receipts_artifact_id: receiptArtifactId, + pending_comment_id: journal.pending_comment_id, + parent_fallbacks: journal.parent_fallbacks, + plan_comment_id: capture.plan_comment.id, plan_sha256: capture.plan_comment.sha256, + registry_sha256: capture.registry_sha256, + ...(capture.prior ? { prior: { + source_comment_id: capture.prior.source_comment_id, source_sha256: capture.prior.source_sha256, + reconciliation_comment_id: capture.prior.reconciliation_comment_id, + reconciliation_sha256: capture.prior.reconciliation_sha256, + } } : {}), + }; + const { bindings: _bindings, ...recordEnvelope } = artifact; + const body = [ + '## Plan activated', '', + `${bindings.length} task issues have complete actual-ID bindings.`, + 'Dependencies are recorded in issue bodies; this record makes no native dependency-edge claim.', + journal.parent_fallbacks.length + ? 'Some native parent links were unavailable; the parent body references are the recorded fallback.' + : 'Native parent links were requested successfully.', + ...omissionSummary(bindings), + '', 'Activation bindings:', '```json', JSON.stringify(bindings), '```', + '', 'Activation evidence:', '```json', JSON.stringify(evidence), '```', + '', 'Structured data:', '```json', JSON.stringify(recordEnvelope), '```', + ].join('\n'); + await publishActivation({ github, context, env, root, body }); + await upsertActivationLifecycle({ github, context, origin: capture.origin_issue, + fullPlan: capture.fullPlan, phases: artifact.phases, + command: `/squad ${capture.command}${capture.phase === null ? '' : ` phase ${capture.phase}`}` }); + return evidence; +} + +async function publishActivation({ github, context, env, root, body }) { + requireThat(positive(Number(env.GITHUB_RUN_ID)) && positive(Number(env.GITHUB_RUN_ATTEMPT)) && + SHA.test(env.SQUAD_WORKFLOW_SHA ?? ''), 'trusted publication run/attempt/workflow identity is unavailable'); + // Keep the authenticated write response in this execution. Historical searches, + // uploaded artifacts and self-asserted run fields never substitute for it. + const receipt = (await github.rest.issues.createComment({ + ...context.repo, + issue_number: Number(context.payload.inputs?.issue_number ?? context.payload.issue?.number), + body, + })).data; + return checkActivationPublication({ github, context, root, receipt, expectedBody: body }); +} + +async function upsertActivationLifecycle({ github, context, origin, fullPlan, phases, command }) { + const remaining = phases.length && fullPlan + ? [...new Set(fullPlan.tasks.map(task => task.phase))].filter(phase => !phases.includes(phase)).sort((a, b) => a - b) + : []; + const next = remaining.length + ? `\`/squad ${fullPlan.kind === 'implementation' ? 'plan activate' : 'activate'} phase ${remaining[0]}\`` + : 'Track progress on the created task issues; no further planning action is required.'; + const body = [ + '## Squad lifecycle', '', + `**State:** ${remaining.length ? 'Partially activated' : 'Activated'}`, + `**Activation:** ${remaining.length ? `Phases ${phases.join(', ')} activated; ${remaining.length} remaining` : '\u2705 Done'}`, + `**Last command:** \`${command}\``, `**Next action:** ${next}`, + '', 'Structured data:', '```json', + JSON.stringify({ squad_artifact: 'lifecycle-state', schema_version: '1', origin_issue: origin, phases }), '```', + ].join('\n'); + const comments = await github.paginate(github.rest.issues.listComments, { + ...context.repo, issue_number: origin, per_page: 100, + }); + const current = comments.filter(comment => comment.user?.id === BOT_ID && + comment.user?.type === 'Bot' && envelope(comment.body)?.squad_artifact === 'lifecycle-state' && + envelope(comment.body)?.origin_issue === origin) + .sort((left, right) => String(left.created_at).localeCompare(String(right.created_at)) || left.id - right.id).at(-1); + if (current?.body === body) return; + if (current) await github.rest.issues.updateComment({ ...context.repo, comment_id: current.id, body }); + else await github.rest.issues.createComment({ ...context.repo, issue_number: origin, body }); +} + +const RECONCILIATION_REQUEST_SCHEMA = 'squad-activation-reconciliation-request/v1'; + +export function selectionEvidence(verified) { + return { + requestCommentId: verified.requestCommentId, requestSha256: verified.requestSha256, actorId: verified.actorId, + sourceCommentId: verified.original.id, sourceSha256: digest(verified.original.body), + planSha256: digest(verified.planComment.body), registrySha256: digest(verified.registry), + bindingsSha256: digest(verified.bindings), phases: verified.request.phases ?? [], + sourceKind: verified.request.source_kind ?? 'activation', + }; +} + +async function verifyLegacyActivation({ github, context, comments, comment, planId, registry, team, origin }) { + const repository = `${context.repo.owner}/${context.repo.repo}`; + const artifact = parseStructuredData(comment.body); + const plans = comments.filter(item => { + const value = envelope(item.body); + return ['plan', 'implementation'].includes(value?.squad_artifact) && + value.schema_version === '1' && value.origin_issue === origin; + }).sort((left, right) => String(left.created_at).localeCompare(String(right.created_at)) || left.id - right.id); + const planComment = plans.at(-1); + requireWorkflowRecord(planComment, repository, origin); + requireThat(planComment.id === planId, 'legacy acceptance requires the current explicit plan; reconcile changed sources'); + const draft = jsonBlock(planComment.body, 'Structured data'); + requireThat(Array.isArray(draft.phases) && draft.phases.length === 0, 'legacy source draft has invalid phases'); + const fullPlan = parseActivationPlan(planComment.body, draft.squad_artifact, registry, team, []); + validateActivation(artifact, parseRoster(team), new Map(), origin, { + repository, registry: parseAgentRegistry(registry), structuralOnly: true, epicAssignments: frozenAssignments(fullPlan), + }); + requireThat(artifact.phases.every(phase => fullPlan.tasks.some(task => task.phase === phase)), + 'legacy acceptance names an unknown plan phase; explicit reconciliation is required'); + const plan = { ...fullPlan, tasks: fullPlan.tasks.filter(task => + artifact.phases.length === 0 || artifact.phases.includes(task.phase)) }; + requireThat(plan.tasks.length === artifact.bindings.length && + plan.tasks.every(task => artifact.bindings.some(binding => binding.task === task.id)), + 'legacy acceptance must cover the exact plan task set; explicit reconciliation is required'); + const receipts = new Map(); + for (const binding of artifact.bindings) { + receipts.set(`task:${binding.task}`, { number: extractIssueNumber(binding.issue) }); + if (binding.epic !== 'origin') receipts.set(`epic:${binding.epic}`, { number: extractIssueNumber(binding.epic_issue) }); + } + requireThat(plan.tasks.every(task => task.dependencies.every(id => receipts.has(`task:${id}`))), + 'legacy phase coverage omits dependencies; explicit reconciliation is required'); + const expected = bindingsForIssueMap({ + repository, origin_issue: origin, plan, fullPlan, registry, phase: artifact.phases.length ? artifact.phases.at(-1) : null, + }, receipts); + requireThat(isDeepStrictEqual(expected, artifact.bindings), + 'legacy bindings differ from the exact plan; explicit reconciliation is required'); + // Old records remain readable, but promotion needs issue-side source identity, + // not an unverified model mapping, a matching title, or ownership labels. + for (const [operation, receipt] of receipts) { + const issue = (await github.rest.issues.get({ ...context.repo, issue_number: receipt.number })).data; + const binding = expected.find(binding => operation === `task:${binding.task}`); + const parent = binding ? extractIssueNumber(binding.epic_issue) : origin; + const fields = [ + `**Stable work ID:** ${operation}`, `**Parent:** #${parent}`, + `**Depends On:** ${binding?.depends_on.length ? binding.depends_on.join(', ') : 'None'}`, + ]; + const sourceLines = String(issue.body ?? '').split(/\r?\n/).filter(line => + /^(?:Frozen plan:|Plan SHA-256:|\*\*Owner:\*\*)/.test(line)); + requireThat(!issue.pull_request && issue.number === receipt.number && + issue.url === `https://api.github.com/repos/${repository}/issues/${receipt.number}` && + isDeepStrictEqual(identityLines(issue.body), fields) && + isDeepStrictEqual(sourceLines, [ + ...(binding ? [`**Owner:** ${binding.agent}`] : []), + `Frozen plan: ${planComment.html_url}`, `Plan SHA-256: ${digest(planComment.body)}`, + ]), 'legacy issue source/task/owner/parent/dependencies are unproved; explicit reconciliation is required'); + await verifyCurrentParent(github, context, receipt.number, parent, operation, []); + } + return fullPlan; +} + +export async function verifyPublishedActivation({ github, context, root, comment }) { + const artifact = parseStructuredData(comment.body); + const evidence = jsonBlock(comment.body, 'Activation evidence'); + const repository = `${context.repo.owner}/${context.repo.repo}`; + requireThat(comment.user?.id === BOT_ID && comment.user?.login === 'github-actions[bot]' && comment.user?.type === 'Bot' && + comment.issue_url === `https://api.github.com/repos/${repository}/issues/${artifact.origin_issue}`, + 'published activation must be a workflow-bot record on this origin'); + requireThat(evidence.schema === 'squad-activation-finalization/v1' && + evidence.basis === 'current-trusted-execution' && positive(evidence.plan_comment_id), + 'published finalization evidence is unsupported'); + const planComment = (await github.rest.issues.getComment({ + ...context.repo, comment_id: evidence.plan_comment_id, + })).data; + requireThat(planComment.user?.id === BOT_ID && + planComment.issue_url === `https://api.github.com/repos/${repository}/issues/${artifact.origin_issue}` && + digest(planComment.body) === evidence.plan_sha256, 'published frozen plan reference changed'); + const registry = await validateCastingPairFiles(`${root}/.squad/casting/registry.json`); + const team = readFileSync(`${root}/.squad/team.md`, 'utf8'); + requireThat(digest(registry) === evidence.registry_sha256, 'published registry snapshot changed'); + const fullPlan = parseActivationPlan(planComment.body, envelope(planComment.body)?.squad_artifact, registry, team, []); + const plan = { ...fullPlan, tasks: fullPlan.tasks.filter(task => + artifact.phases.length === 0 || artifact.phases.includes(task.phase)) }; + const assignments = frozenAssignments(fullPlan); + validateActivation(artifact, parseRoster(team), new Map(), artifact.origin_issue, { + repository, registry: parseAgentRegistry(registry), structuralOnly: true, epicAssignments: assignments, + }); + requireThat(plan.tasks.length === artifact.bindings.length && + plan.tasks.every(task => artifact.bindings.some(binding => binding.task === task.id)), + 'published bindings do not cover the exact frozen phase task set'); + const receipts = new Map(); + for (const binding of artifact.bindings) { + receipts.set(`task:${binding.task}`, { number: extractIssueNumber(binding.issue) }); + if (binding.epic !== 'origin') receipts.set(`epic:${binding.epic}`, { number: extractIssueNumber(binding.epic_issue) }); + } + requireThat(plan.tasks.every(task => task.dependencies.every(id => receipts.has(`task:${id}`))), + 'published phase coverage omits a dependency'); + const expected = bindingsForIssueMap({ + repository, origin_issue: artifact.origin_issue, plan, fullPlan, registry, + phase: artifact.phases.length ? artifact.phases.at(-1) : null, + }, receipts); + requireThat(digest(expected) === digest(artifact.bindings), 'published bindings conflict with the frozen plan'); + const priorTasks = new Set(); + const priorEpics = new Set(); + if (evidence.prior) { + const link = evidence.prior; + exactKeys(link, ['source_comment_id', 'source_sha256', 'reconciliation_comment_id', 'reconciliation_sha256'], + 'published prior-phase reference is malformed'); + const supplement = (await github.rest.issues.getComment({ + ...context.repo, comment_id: link.reconciliation_comment_id, + })).data; + requireWorkflowRecord(supplement, repository, artifact.origin_issue); + requireThat(supplement && digest(supplement.body) === link.reconciliation_sha256, + 'published prior-phase supplement changed'); + const metadata = jsonBlock(supplement.body, 'Activation reconciliation'); + const verified = await verifyReconciliationRequest({ github, context, root, + requestCommentId: metadata.request_comment_id, requestSha256: metadata.request_sha256 }); + requireThat(verified.original.id === link.source_comment_id && + digest(verified.original.body) === link.source_sha256 && + digest(verified.planComment.body) === evidence.plan_sha256 && + digest(verified.registry) === evidence.registry_sha256 && + isDeepStrictEqual(verified.request.phases, artifact.phases.slice(0, -1)) && + verified.snapshots.every(snapshot => snapshot.body === snapshot.after), + 'published prior-phase authority or current dependencies changed'); + selectActivationRecord([verified.original, supplement], registry, { + repository, originIssue: artifact.origin_issue, + reconciliations: [selectionEvidence(verified)], epicAssignments: assignments, + }); + coveredHistory(verified, supplement); + for (const binding of verified.bindings) { + const current = expected.find(candidate => candidate.task === binding.task); + requireThat(current?.issue === binding.issue && current.epic_issue === binding.epic_issue, + 'published prior-phase issue map differs from its authorized bridge'); + priorTasks.add(binding.task); + if (binding.epic !== 'origin') priorEpics.add(binding.epic); + } + } + const fallbacks = Object.hasOwn(evidence, 'parent_fallbacks') ? evidence.parent_fallbacks : []; + const newOperations = new Set(expected.flatMap(binding => [ + ...(!priorTasks.has(binding.task) ? [`task:${binding.task}`] : []), + ...(binding.epic !== 'origin' && !priorEpics.has(binding.epic) ? [`epic:${binding.epic}`] : []), + ])); + requireThat(Array.isArray(fallbacks) && new Set(fallbacks).size === fallbacks.length && + fallbacks.every(operation => newOperations.has(operation)), 'published parent fallback metadata is invalid'); + const epics = new Set(); + for (const binding of expected) { + if (priorTasks.has(binding.task)) continue; + const issue = (await github.rest.issues.get({ + ...context.repo, issue_number: extractIssueNumber(binding.issue), + })).data; + requireThat(!issue.pull_request && issue.number === extractIssueNumber(binding.issue) && + issue.url === `https://api.github.com/repos/${repository}/issues/${issue.number}` && + isDeepStrictEqual(identityLines(issue.body), + [`**Stable work ID:** task:${binding.task}`, `**Parent:** ${binding.epic_issue}`, + `**Depends On:** ${binding.depends_on.length ? binding.depends_on.join(', ') : 'None'}`] + ), + 'published task identity/parent/dependencies differ from current issue content'); + await verifyCurrentParent(github, context, issue.number, extractIssueNumber(binding.epic_issue), + `task:${binding.task}`, fallbacks); + if (binding.epic !== 'origin' && !priorEpics.has(binding.epic) && !epics.has(binding.epic)) { + epics.add(binding.epic); + await verifyCurrentParent(github, context, extractIssueNumber(binding.epic_issue), artifact.origin_issue, + `epic:${binding.epic}`, fallbacks); + } + } + return { fullPlan, registry, assignments }; +} + +function exactKeys(value, keys, reason) { + requireThat(value && typeof value === 'object' && !Array.isArray(value) && + Object.keys(value).sort().join('\0') === [...keys].sort().join('\0'), reason); +} + +function jsonBlock(body, heading) { + const matches = [...String(body).matchAll(new RegExp(`${heading}:\\s*\`\`\`json\\s*([\\s\\S]*?)\`\`\``, 'g'))]; + requireThat(matches.length === 1, `exactly one ${heading} JSON block is required`); + return JSON.parse(matches[0][1]); +} + +/** Explicitly approved token mappings are the only identities a reference repair may use. */ +export function correctDependencyReferences(body, expectedDependencies, tokenMappings) { + requireThat(typeof body === 'string', 'existing issue body is missing'); + const matches = [...body.matchAll(/^\*\*Depends On:\*\*[^\r\n]*$/gm)]; + requireThat(matches.length === 1, 'exactly one standalone **Depends On:** field is required'); + const match = matches[0]; + const corrected = match[0].replace(/`(aw_[A-Za-z0-9_]{3,12})`/g, (_whole, token) => { + requireThat(positive(tokenMappings[token]), `dependency token ${token} has no explicitly approved issue mapping`); + return `\`#${tokenMappings[token]}\``; + }); + requireThat(!/aw_/.test(corrected), 'dependency field contains an unsupported unresolved reference'); + const actual = [...corrected.matchAll(/#([1-9]\d*)/g)].map(item => Number(item[1])); + requireThat(actual.every(positive) && new Set(actual).size === actual.length && + isDeepStrictEqual([...actual].sort((a, b) => a - b), [...expectedDependencies].sort((a, b) => a - b)), + 'dependency references do not match the freshly frozen plan and explicit issue mapping'); + if (expectedDependencies.length === 0) { + requireThat(/^\*\*Depends On:\*\*\s*(?:None|[-\u2014](?:\s*\(none\))?)\s*$/i.test(corrected), + 'empty dependency field has unsupported content'); + } + return body.slice(0, match.index) + corrected + body.slice(match.index + match[0].length); +} + +export async function verifyReconciliationRequest({ + github, context, root, requestCommentId, requestSha256, ancestors = [], +}) { + const repository = `${context.repo.owner}/${context.repo.repo}`; + requireThat(positive(requestCommentId) && /^[a-f0-9]{64}$/.test(requestSha256), 'invalid reconciliation request identity'); + requireThat(!ancestors.includes(requestCommentId), 'reconciliation request ancestry is cyclic'); + const comment = (await github.rest.issues.getComment({ ...context.repo, comment_id: requestCommentId })).data; + requireThat(digest(comment.body) === requestSha256, 'authorized request content digest changed'); + requireThat(comment.user?.type === 'User' && positive(comment.user.id), 'request must identify an eligible operator account'); + const permission = (await github.rest.repos.getCollaboratorPermissionLevel({ + ...context.repo, username: comment.user.login, + })).data.permission; + requireThat(['write', 'maintain', 'admin'].includes(permission), 'request author is not authorized for reconciliation'); + const request = jsonBlock(comment.body, 'Activation reconciliation request'); + exactKeys(request, ['schema', 'repository', 'origin_issue', 'original', 'plan', 'registry', 'tasks', 'epics', + ...(Object.hasOwn(request, 'phases') ? ['phases'] : []), + ...(Object.hasOwn(request, 'source_kind') ? ['source_kind'] : [])], + 'reconciliation request fields are unsupported'); + requireThat(request.source_kind === undefined || request.source_kind === 'pending', 'unknown reconciliation source kind'); + requireThat(request.schema === RECONCILIATION_REQUEST_SCHEMA && request.repository === repository && + positive(request.origin_issue) && comment.issue_url === `https://api.github.com/repos/${repository}/issues/${request.origin_issue}`, + 'request repository/origin identity mismatch'); + const commandOrigin = Number(context.payload.inputs?.issue_number ?? context.payload.issue?.number); + requireThat(request.origin_issue === commandOrigin, 'request does not belong to the invoking origin issue'); + for (const reference of [request.original, request.plan]) { + exactKeys(reference, ['comment_id', 'sha256'], 'comment reference fields are unsupported'); + requireThat(positive(reference.comment_id) && /^[a-f0-9]{64}$/.test(reference.sha256), + 'comment reference ID/hash is malformed'); + } + exactKeys(request.registry, ['revision', 'sha256'], 'registry reference fields are unsupported'); + const comments = await github.paginate(github.rest.issues.listComments, { + ...context.repo, issue_number: request.origin_issue, per_page: 100, + }); + const original = comments.find(item => item.id === request.original.comment_id); + const planComment = comments.find(item => item.id === request.plan.comment_id); + requireThat(original && digest(original.body) === request.original.sha256 && + planComment && digest(planComment.body) === request.plan.sha256, 'original acceptance or current plan content changed'); + const pending = request.source_kind === 'pending' + ? parsePendingReservation(original.body, repository, request.origin_issue) : null; + if (pending) requireThat(original.user?.id === BOT_ID && original.user?.login === 'github-actions[bot]' && + original.user?.type === 'Bot' && pending.plan_comment_id === request.plan.comment_id && + pending.plan_sha256 === request.plan.sha256 && pending.registry_sha256 === request.registry.sha256 && + isDeepStrictEqual(pending.phases, request.phases ?? []), + 'pending source is forged, stale or does not cover the approved frozen plan/phases'); + const oldEnvelope = pending + ? { squad_artifact: 'activation-pending', schema_version: '1', origin_issue: pending.origin_issue, phases: pending.phases } + : jsonBlock(original.body, 'Structured data'); + requireThat((ACCEPTANCES.has(oldEnvelope.squad_artifact) || pending) && oldEnvelope.squad_artifact !== 'activation-reconciled' && + oldEnvelope.origin_issue === request.origin_issue && oldEnvelope.schema_version === '1', + 'superseded record must be an original activation for this origin, not another reconciliation'); + const planEnvelope = jsonBlock(planComment.body, 'Structured data'); + requireThat(['plan', 'implementation'].includes(planEnvelope.squad_artifact) && + planEnvelope.origin_issue === request.origin_issue && planEnvelope.schema_version === '1', + 'current plan envelope is invalid'); + const latestPlan = comments.filter(item => { + const value = envelope(item.body); + return value?.squad_artifact === planEnvelope.squad_artifact && value.origin_issue === request.origin_issue; + }).sort((left, right) => String(left.created_at).localeCompare(String(right.created_at)) || left.id - right.id).at(-1); + requireThat(latestPlan?.id === planComment.id, 'approved plan is no longer the current plan'); + const registry = await validateCastingPairFiles(`${root}/.squad/casting/registry.json`); + requireThat(request.registry.revision === registry.revision && request.registry.sha256 === digest(registry), + 'current registry differs from the approved request'); + const team = readFileSync(`${root}/.squad/team.md`, 'utf8'); + const phases = request.phases === undefined ? [] : request.phases; + requireThat(Array.isArray(phases) && + phases.every((phase, index) => positive(phase) && (index === 0 || phases[index - 1] < phase)), + 'selected prior-phase coverage must be ascending unique phase IDs'); + requireThat(isDeepStrictEqual(phases, oldEnvelope.phases), + 'selected prior-phase coverage must exactly match the original phase artifact'); + const fullPlan = parseActivationPlan(planComment.body, planEnvelope.squad_artifact, registry, team, []); + const plan = structuredClone(fullPlan); + if (phases.length > 0) { + requireThat(phases.every(phase => fullPlan.tasks.some(task => task.phase === phase)), + 'selected prior phase does not exist in the frozen plan'); + plan.tasks = plan.tasks.filter(task => phases.includes(task.phase)); + plan.epics = [...new Set(plan.tasks.map(task => task.epic))].filter(epic => epic !== 'origin'); + requireThat(plan.tasks.length > 0, 'selected prior phases contain no tasks'); + requireThat(plan.tasks.every(task => task.dependencies.every(id => plan.tasks.some(candidate => candidate.id === id))), + 'prior-phase request omits a dependency phase'); + } + requireThat(Array.isArray(request.tasks) && Array.isArray(request.epics), 'explicit task/epic mappings are required'); + const issueIds = new Set([request.origin_issue]); + const taskIds = new Set(); + const epicIds = new Set(); + const tokens = new Set(); + for (const mapping of request.tasks) { + exactKeys(mapping, ['task', 'issue', 'temporary_id'], 'task mappings require task, issue and explicit temporary_id'); + requireThat(plan.tasks.some(task => task.id === mapping.task) && !taskIds.has(mapping.task) && + positive(mapping.issue) && !issueIds.has(mapping.issue), + 'task mapping is unknown, duplicated, incomplete or colliding'); + requireThat(mapping.temporary_id === null || (typeof mapping.temporary_id === 'string' && + /^aw_[A-Za-z0-9_]{3,12}$/.test(mapping.temporary_id) && !tokens.has(mapping.temporary_id)), + 'explicit temporary token mapping is malformed or duplicated'); + taskIds.add(mapping.task); issueIds.add(mapping.issue); + if (mapping.temporary_id) tokens.add(mapping.temporary_id); + } + for (const mapping of request.epics) { + exactKeys(mapping, ['epic', 'issue'], 'epic mappings require epic and issue'); + requireThat(plan.epics.includes(mapping.epic) && !epicIds.has(mapping.epic) && + positive(mapping.issue) && !issueIds.has(mapping.issue), 'epic mapping is unknown, duplicated or colliding'); + epicIds.add(mapping.epic); issueIds.add(mapping.issue); + } + requireThat(taskIds.size === plan.tasks.length && epicIds.size === plan.epics.length, + 'explicit mappings do not cover the complete frozen plan'); + const taskMap = new Map(request.tasks.map(mapping => [mapping.task, mapping.issue])); + const epicMap = new Map(request.epics.map(mapping => [mapping.epic, mapping.issue])); + const tokenMap = Object.fromEntries(request.tasks.filter(mapping => mapping.temporary_id) + .map(mapping => [mapping.temporary_id, mapping.issue])); + const snapshots = []; + const nodeIds = new Set(); + const databaseIds = new Set(); + for (const mapping of [...request.epics.map(item => ({ key: `epic:${item.epic}`, ...item })), + ...request.tasks.map(item => ({ key: `task:${item.task}`, ...item }))]) { + const issue = (await github.rest.issues.get({ ...context.repo, issue_number: mapping.issue })).data; + requireThat(!issue.pull_request && issue.number === mapping.issue && positive(issue.id) && + typeof issue.node_id === 'string' && issue.node_id.length > 0 && + !nodeIds.has(issue.node_id) && !databaseIds.has(issue.id) && + issue.html_url === `https://github.com/${repository}/issues/${mapping.issue}` && + issue.url === `https://api.github.com/repos/${repository}/issues/${mapping.issue}`, + 'current mapped issue identity is malformed, duplicated or belongs to another repository'); + nodeIds.add(issue.node_id); databaseIds.add(issue.id); + const task = mapping.task ? plan.tasks.find(task => task.id === mapping.task) : null; + const parent = !task || task.epic === 'origin' ? request.origin_issue : epicMap.get(task.epic); + const currentParent = (await github.rest.issues.getParent({ + ...context.repo, issue_number: mapping.issue, + })).data; + requireThat(currentParent.number === parent && + currentParent.url === `https://api.github.com/repos/${repository}/issues/${parent}`, + 'current hierarchy does not match the explicit approved mapping; hierarchy changes are forbidden'); + const after = task ? correctDependencyReferences(issue.body, + task.dependencies.map(id => taskMap.get(id)), tokenMap) : issue.body; + snapshots.push({ operation_id: mapping.key, number: mapping.issue, id: issue.id, node_id: issue.node_id, + url: issue.html_url, repository, body: issue.body, after, updated_at: issue.updated_at, + labels: issue.labels.map(label => typeof label === 'string' ? label : label.name), + title: issue.title, assignees: (issue.assignees ?? []).map(assignee => assignee.id), + before_sha256: digest(issue.body), after_sha256: digest(after) }); + } + const bindings = bindingsForIssueMap({ + repository, origin_issue: request.origin_issue, plan, fullPlan, registry, phase: null, + }, new Map(snapshots.map(snapshot => [snapshot.operation_id, snapshot]))) + .map(binding => ({ ...binding, artifact: 'activation-reconciled' })); + validateActivation({ squad_artifact: 'activation-reconciled', schema_version: '1', + origin_issue: request.origin_issue, phases: [], bindings }, parseRoster(team), + new Map(snapshots.map(item => [item.number, new Set(item.labels.map(normalize))])), + request.origin_issue, { repository, registry: parseAgentRegistry(registry), epicAssignments: frozenAssignments(fullPlan) }); + const predecessor = pending?.prior ?? (original.body.includes('Activation evidence:') + ? jsonBlock(original.body, 'Activation evidence').prior : null); + if (predecessor) { + exactKeys(predecessor, ['source_comment_id', 'source_sha256', 'reconciliation_comment_id', 'reconciliation_sha256'], + 'reconciliation predecessor is malformed'); + const supplement = comments.find(item => item.id === predecessor.reconciliation_comment_id); + requireWorkflowRecord(supplement, repository, request.origin_issue); + requireThat(supplement && digest(supplement.body) === predecessor.reconciliation_sha256, + 'reconciliation predecessor supplement is missing or changed'); + const metadata = jsonBlock(supplement.body, 'Activation reconciliation'); + const previous = await verifyReconciliationRequest({ github, context, root, + requestCommentId: metadata.request_comment_id, requestSha256: metadata.request_sha256, + ancestors: [...ancestors, requestCommentId] }); + requireThat(previous.original.id === predecessor.source_comment_id && + digest(previous.original.body) === predecessor.source_sha256 && + digest(previous.planComment.body) === digest(planComment.body) && digest(previous.registry) === digest(registry) && + Array.isArray(request.phases) && Array.isArray(previous.request.phases) && + previous.request.phases.length > 0 && + previous.request.phases.length < request.phases.length && + previous.request.phases.every((phase, index) => request.phases[index] === phase) && + previous.snapshots.every(snapshot => snapshot.body === snapshot.after) && + previous.bindings.every(binding => bindings.some(current => current.task === binding.task && + current.issue === binding.issue && current.epic_issue === binding.epic_issue)), + 'reconciliation predecessor coverage, frozen inputs or prior issue maps changed'); + selectActivationRecord([previous.original, supplement], registry, { + repository, originIssue: request.origin_issue, reconciliations: [selectionEvidence(previous)], + epicAssignments: frozenAssignments(fullPlan), + }); + } + return { request, requestCommentId, requestSha256, actorId: comment.user.id, comments, + original, planComment, registry, team, plan, fullPlan, snapshots, bindings, predecessor }; +} + +/** Current-state repair is authorized separately; it never asserts historical creation provenance. */ +export async function reconcileActivation({ github, context, env, root }) { + const command = classifySquadCommand(context.payload, env.GITHUB_EVENT_NAME); + requireThat(command.mode === 'reconcile activation', 'reconciliation command is required'); + await authorizeSquadCommand({ + payload: context.payload, eventName: env.GITHUB_EVENT_NAME, actor: context.actor, result: command, + resolvePermission: async username => (await github.rest.repos.getCollaboratorPermissionLevel({ + ...context.repo, username, + })).data.permission, + }); + const [, , id, sha256] = command.argumentText.split(' '); + let verified = await verifyReconciliationRequest({ + github, context, root, requestCommentId: Number(id), requestSha256: sha256, + }); + const existing = verified.comments.filter(comment => + envelope(comment.body)?.squad_artifact === 'activation-reconciled' && + jsonBlock(comment.body, 'Activation reconciliation').supersedes_comment_id === verified.original.id); + requireThat(existing.length <= 1, 'competing reconciliation records require resolution'); + if (existing.length) { + requireWorkflowRecord(existing[0], verified.request.repository, verified.request.origin_issue); + selectActivationRecord([verified.original, existing[0]], verified.registry, { + repository: verified.request.repository, originIssue: verified.request.origin_issue, + reconciliations: [selectionEvidence(verified)], epicAssignments: frozenAssignments(verified.fullPlan), + }); + requireThat(verified.snapshots.every(snapshot => snapshot.body === snapshot.after), + 'existing reconciliation conflicts with current authorized inputs; no fallback is allowed'); + const heads = activationHeads(verified.comments.map(comment => ({ comment, artifact: envelope(comment.body) })) + .filter(({ artifact }) => ACCEPTANCES.has(artifact?.squad_artifact) && + artifact.origin_issue === verified.request.origin_issue), verified.comments, verified.request.repository); + requireThat(heads.length === 1, 'competing activation heads prevent lifecycle recovery'); + if (heads[0].comment.id !== existing[0].id) { + const head = heads[0].comment; + if (heads[0].artifact.squad_artifact === 'activation-reconciled') { + const metadata = jsonBlock(head.body, 'Activation reconciliation'); + const current = await verifyReconciliationRequest({ github, context, root, + requestCommentId: metadata.request_comment_id, requestSha256: metadata.request_sha256 }); + selectActivationRecord([current.original, head], current.registry, { + repository: current.request.repository, originIssue: current.request.origin_issue, + reconciliations: [selectionEvidence(current)], epicAssignments: frozenAssignments(current.fullPlan), + }); + requireThat(current.snapshots.every(snapshot => snapshot.body === snapshot.after), + 'current descendant dependencies changed'); + } else { + await verifyPublishedActivation({ github, context, root, comment: head }); + } + // An older retry must never replace a validated descendant's cumulative lifecycle. + return { id: existing[0].id, unchanged: true }; + } + await upsertActivationLifecycle({ github, context, origin: verified.request.origin_issue, + fullPlan: verified.fullPlan, phases: verified.request.phases ?? [], command: `/squad ${command.argumentText}` }); + return { id: existing[0].id, unchanged: true }; + } + const heads = activationHeads(verified.comments.map(comment => ({ comment, artifact: envelope(comment.body) })) + .filter(({ artifact }) => ACCEPTANCES.has(artifact?.squad_artifact) && + artifact.origin_issue === verified.request.origin_issue), verified.comments, verified.request.repository); + requireThat(heads.every(head => head.comment.id === verified.original.id || + (verified.request.source_kind === 'pending' && head.comment.id === verified.predecessor?.reconciliation_comment_id)), + 'the requested source is not the sole current activation head; no competing supplement may be published'); + const repairs = []; + for (const snapshot of verified.snapshots) { + if (snapshot.body === snapshot.after) continue; + const current = (await github.rest.issues.get({ ...context.repo, issue_number: snapshot.number })).data; + requireThat(current.id === snapshot.id && current.node_id === snapshot.node_id && + current.updated_at === snapshot.updated_at && digest(current.body) === snapshot.before_sha256 && + current.title === snapshot.title && + isDeepStrictEqual(current.labels.map(label => typeof label === 'string' ? label : label.name), snapshot.labels) && + isDeepStrictEqual((current.assignees ?? []).map(assignee => assignee.id), snapshot.assignees), + `issue #${snapshot.number} changed before correction; no overwrite is allowed`); + await github.rest.issues.createComment({ + ...context.repo, issue_number: verified.request.origin_issue, + body: [ + 'Dependency correction attempt (not acceptance or historical attestation):', '```json', + JSON.stringify({ schema: 'squad-dependency-correction/v1', request_comment_id: Number(id), + request_sha256: sha256, issue: snapshot.number, before_sha256: snapshot.before_sha256, + after_sha256: snapshot.after_sha256 }), '```', + ].join('\n'), + }); + const request = (await github.rest.issues.getComment({ ...context.repo, comment_id: Number(id) })).data; + requireThat(digest(request.body) === sha256 && request.user?.id === verified.actorId, + 'authorized request changed before dependency correction'); + const permission = (await github.rest.repos.getCollaboratorPermissionLevel({ + ...context.repo, username: request.user.login, + })).data.permission; + requireThat(['write', 'maintain', 'admin'].includes(permission), 'request author lost correction permission'); + for (const reference of [verified.original, verified.planComment]) { + const current = (await github.rest.issues.getComment({ ...context.repo, comment_id: reference.id })).data; + requireThat(digest(current.body) === digest(reference.body), 'frozen source or plan changed before correction'); + } + const beforeWrite = (await github.rest.issues.get({ ...context.repo, issue_number: snapshot.number })).data; + requireThat(beforeWrite.updated_at === snapshot.updated_at && digest(beforeWrite.body) === snapshot.before_sha256, + `issue #${snapshot.number} changed while recording the correction; no overwrite is allowed`); + // GitHub provides no atomic body compare-and-swap. A fresh read narrows, but cannot eliminate, the race. + await github.rest.issues.update({ ...context.repo, issue_number: snapshot.number, body: snapshot.after }); + const after = (await github.rest.issues.get({ ...context.repo, issue_number: snapshot.number })).data; + requireThat(digest(after.body) === snapshot.after_sha256 && after.title === snapshot.title && + isDeepStrictEqual(after.labels.map(label => typeof label === 'string' ? label : label.name), snapshot.labels), + `issue #${snapshot.number} changed during correction; reconciliation is withheld`); + repairs.push({ issue: snapshot.number, before_sha256: snapshot.before_sha256, after_sha256: snapshot.after_sha256 }); + } + verified = await verifyReconciliationRequest({ + github, context, root, requestCommentId: Number(id), requestSha256: sha256, + }); + requireThat(verified.snapshots.every(snapshot => snapshot.body === snapshot.after), 'dependency corrections are incomplete'); + const record = { + schema: 'squad-activation-reconciliation/v1', basis: 'current-state-authorized', + request_comment_id: Number(id), request_sha256: sha256, actor_id: verified.actorId, + supersedes_comment_id: verified.original.id, supersedes_sha256: digest(verified.original.body), + plan_sha256: digest(verified.planComment.body), registry_sha256: digest(verified.registry), + bindings_sha256: digest(verified.bindings), + ...(verified.request.source_kind === 'pending' ? { supersedes_kind: 'pending' } : {}), + }; + const body = [ + '## Activation reconciled against current state', '', + `This explicitly supersedes the referenced ${verified.request.source_kind === 'pending' ? 'pending reservation' : 'invalid acceptance'} for validated readers only.`, + 'It preserves the original record and makes no historical creation-attestation claim. No issues were created.', + 'Dependency corrections use fresh conflict checks, not atomic compare-and-swap.', + ...omissionSummary(verified.bindings), + '', 'Activation bindings:', '```json', JSON.stringify(verified.bindings), '```', + '', 'Activation reconciliation:', '```json', JSON.stringify(record), '```', + '', 'Dependency corrections:', '```json', JSON.stringify(repairs), '```', + '', 'Structured data:', '```json', + JSON.stringify({ squad_artifact: 'activation-reconciled', schema_version: '1', + origin_issue: verified.request.origin_issue, phases: verified.request.phases ?? [] }), '```', + ].join('\n'); + const result = await publishActivation({ github, context, env, root, body }); + await upsertActivationLifecycle({ github, context, origin: verified.request.origin_issue, + fullPlan: verified.fullPlan, phases: verified.request.phases ?? [], command: `/squad ${command.argumentText}` }); + return result; +} diff --git a/workflows/shared/squad-agent-binding-check.yml b/workflows/shared/squad-agent-binding-check.yml new file mode 100644 index 000000000..667045c8f --- /dev/null +++ b/workflows/shared/squad-agent-binding-check.yml @@ -0,0 +1,42 @@ +name: Squad Agent Binding Check + +on: + workflow_dispatch: + workflow_run: + workflows: [Squad] + types: [completed] + +permissions: + contents: read + issues: read + +jobs: + check: + name: Historical bindings (not current-run completion) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v6 + with: + node-version: 22 + - name: Collect historical activation records (not run outputs) + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 + with: + script: | + const fs = require('node:fs'); + const { pathToFileURL } = require('node:url'); + const { join } = require('node:path'); + const { collectHistoricalActivationRecords } = await import(pathToFileURL( + join(process.env.GITHUB_WORKSPACE, '.github/workflows/shared/check-agent-binding.mjs')).href); + const audit = await collectHistoricalActivationRecords({ github, context }); + fs.writeFileSync(`${process.env.RUNNER_TEMP}/activation-comments.json`, JSON.stringify(audit)); + - name: Audit historical bindings without asserting current-run completion + env: + GITHUB_TOKEN: ${{ github.token }} + run: >- + node .github/workflows/shared/check-agent-binding.mjs + --comments-file="$RUNNER_TEMP/activation-comments.json" + --team-file=".squad/team.md" + --repo="${{ github.repository }}" diff --git a/workflows/shared/squad-command-contract.mjs b/workflows/shared/squad-command-contract.mjs index 0fc4c77ee..aabc9c692 100644 --- a/workflows/shared/squad-command-contract.mjs +++ b/workflows/shared/squad-command-contract.mjs @@ -78,6 +78,7 @@ export const VALID_COMMANDS = Object.freeze([ '/squad plan accept implementation [phase N]', '/squad plan activate [phase N]', '/squad implement', + '/squad reconcile activation ', ]); function normalizeNewlines(value) { @@ -105,19 +106,10 @@ function sourceFromPayload(payload, eventName) { return { source: 'none', text: '' }; } -function extractInvocation(source, text) { - if (source === 'workflow_dispatch') { - if (!text) return null; - const slash = text.match(/^\/squad(?:\s|$)/); - return { - rejectedCommand: text, - argumentText: slash ? text.slice(slash[0].length).trim() : text.trim(), - malformedPrefix: text.toLowerCase().startsWith('/squad') && !slash, - }; - } - +/** Blank code regions without joining the surrounding Markdown blocks. */ +export function markdownContentLines(text) { let fence = null; - for (const originalLine of normalizeNewlines(text).split('\n')) { + return normalizeNewlines(text).split('\n').map(originalLine => { if (fence) { const closingFence = originalLine.match(/^ {0,3}(`{3,}|~{3,})[ \t]*$/); if (closingFence @@ -125,7 +117,7 @@ function extractInvocation(source, text) { && closingFence[1].length >= fence.length) { fence = null; } - continue; + return ''; } const openingFence = originalLine.match(/^ {0,3}(`{3,}|~{3,})(.*)$/); if (openingFence) { @@ -133,9 +125,24 @@ function extractInvocation(source, text) { delimiter: openingFence[1][0], length: openingFence[1].length, }; - continue; + return ''; } - if (/^(?: {4}| {0,3}\t)/.test(originalLine)) continue; + return /^(?: {4}| {0,3}\t)/.test(originalLine) ? '' : originalLine; + }); +} + +function extractInvocation(source, text) { + if (source === 'workflow_dispatch') { + if (!text) return null; + const slash = text.match(/^\/squad(?:\s|$)/); + return { + rejectedCommand: text, + argumentText: slash ? text.slice(slash[0].length).trim() : text.trim(), + malformedPrefix: text.toLowerCase().startsWith('/squad') && !slash, + }; + } + + for (const originalLine of markdownContentLines(text)) { const line = originalLine.replace(/`+[^`]*`+/g, '').trim(); if (!/^\/squad/i.test(line)) continue; const slash = line.match(/^\/squad(?:\s|$)/); @@ -170,6 +177,10 @@ function classifyArgument(argumentText) { const normalized = argumentText.replace(/[ \t]+/g, ' ').trim(); const lowered = normalized.toLowerCase(); + if (/^reconcile activation [1-9]\d* [a-f0-9]{64}$/.test(normalized)) { + return accepted('reconcile activation', normalized); + } + const exact = NO_ARGUMENT_COMMANDS.get(lowered); if (exact) return accepted(exact, normalized); @@ -437,6 +448,45 @@ export async function enforceSquadCommandContract({ await authorizeSquadCommand({ payload, eventName, actor, resolvePermission, result }); const items = readOutputItems(agentOutputPath); if (!commandRequiresAuthorization(result)) validateOpenModeOutputs(result, payload, items); + const activationArtifacts = new Set(['plan-accepted', 'phases-accepted', 'activated', 'phases-activated', 'activation-reconciled']); + for (const item of items) { + if (!item || typeof item !== 'object') throw new Error('Squad output item is malformed.'); + const rawBody = typeof item.body === 'string' ? item.body : ''; + // Readers can see fenced HTML comments; also inspect the normalized view + // so comment-split lifecycle text cannot bypass the trusted writer boundary. + const bodies = [rawBody, rawBody.replace(//g, '')]; + if (activationArtifacts.has(item.data?.squad_artifact) || + bodies.some(body => /Activation (?:bindings|reconciliation):/i.test(body) || + /squad-activation-(?:pending|finalization)\/v1/.test(body) || + (item.type === 'upsert_lifecycle_state' && + (/^(?:[-*]\s+)?\*\*(?:Current state|State):\*\*\s+Activated\s*$/im.test(body) || + /^(?:[-*]\s+)?(?:\*\*)?Activation:(?:\*\*)?\s+(?:✅\s+)?Done\b/im.test(body) || + /^\|\s*Activat(?:e|ion|ed)\s*\|\s*✅\s+Done\b/im.test(body))) || + [...body.matchAll(/Structured data:\s*```json\s*([\s\S]*?)```/gi)].some(match => { + try { return activationArtifacts.has(JSON.parse(match[1])?.squad_artifact); } + catch { return true; } + })) || + (result.mode.startsWith('plan') && item.type === 'create_issue')) { + throw new Error('Activation acceptance, bindings and terminal lifecycle belong exclusively to the trusted writer.'); + } + } + if (['activate', 'plan accept', 'plan activate'].includes(result.mode) || + (result.mode === 'plan accept implementation' && result.phase !== null)) { + if (items.length !== 1 || items[0]?.type !== 'activate_plan' || + typeof items[0].plan_comment_id !== 'string' || + !/^[1-9]\d*$/.test(items[0].plan_comment_id) || + !Number.isSafeInteger(Number(items[0].plan_comment_id)) || + Object.keys(items[0]).some(key => !['type', 'plan_comment_id'].includes(key))) { + throw new Error('Activation requires one activate_plan request with plan_comment_id; native issue creation and model acceptance/lifecycle outputs are forbidden.'); + } + } + if (result.mode === 'reconcile activation') { + if (items.length !== 1 || items[0]?.type !== 'reconcile_activation' || + items[0].action !== 'reconcile' || + Object.keys(items[0]).some(key => !['type', 'action'].includes(key))) { + throw new Error('Reconciliation requires exactly one reconcile_activation request with no model-supplied mappings.'); + } + } return result; } if (result.status === 'none') throw new Error('No explicit Squad command; refusing all safe outputs.'); diff --git a/workflows/shared/squad-install-verifier.mjs b/workflows/shared/squad-install-verifier.mjs index 6c5fc04bb..b58f7a442 100644 --- a/workflows/shared/squad-install-verifier.mjs +++ b/workflows/shared/squad-install-verifier.mjs @@ -23,7 +23,7 @@ export const PACKAGE_MANIFEST = 'workflows/aw.yml'; export const CONTRACT_SOURCE = 'workflows/squad-workflows.manifest.json'; export const CONTRACT_DESTINATION = '.github/aw/squad-workflows.manifest.json'; export const MIN_GH_AW_VERSION = 'v0.91.5'; -export const OWNERSHIP_ENTRY_COUNT = 26; +export const OWNERSHIP_ENTRY_COUNT = 29; export const OWNERSHIP_DESTINATION = '.github/aw/packages/bradygaster-squad-workflows-3632054824e8.json'; export const UNOWNED_MUTABLE_ROUTER_SKILL = @@ -61,6 +61,9 @@ export const WORKFLOW_TUPLES = deepFreeze([ ]); export const RUNTIME_TUPLES = deepFreeze([ + ['shared/squad-agent-binding-check.yml', 'workflows/shared/squad-agent-binding-check.yml', '.github/aw/squad/runtime/shared/squad-agent-binding-check.yml', '.github/workflows/squad-agent-binding-check.yml', 'manifest'], + ['shared/squad-activation.mjs', 'workflows/shared/squad-activation.mjs', '.github/workflows/shared/squad-activation.mjs', '.github/workflows/shared/squad-activation.mjs', 'manifest'], + ['shared/check-agent-binding.mjs', 'workflows/shared/check-agent-binding.mjs', '.github/workflows/shared/check-agent-binding.mjs', '.github/workflows/shared/check-agent-binding.mjs', 'manifest'], ['shared/squad-review-guard.mjs', 'workflows/shared/squad-review-guard.mjs', '.github/workflows/shared/squad-review-guard.mjs', '.github/workflows/shared/squad-review-guard.mjs', 'manifest'], ['shared/squad-install-verifier.mjs', 'workflows/shared/squad-install-verifier.mjs', '.github/workflows/shared/squad-install-verifier.mjs', '.github/workflows/shared/squad-install-verifier.mjs', 'manifest'], ['shared/squad-command-contract.mjs', 'workflows/shared/squad-command-contract.mjs', '.github/workflows/shared/squad-command-contract.mjs', '.github/workflows/shared/squad-command-contract.mjs', 'manifest'], @@ -1070,6 +1073,10 @@ export function materializeRuntime(root) { throw new Error(`Package-owned runtime digest mismatch for ${entry.package_destination}.`); } if (entry.package_destination === entry.destination) continue; + if (entry.destination === '.github/workflows/squad-agent-binding-check.yml' && + existsSync(safePath(root, entry.destination)) && fileDigest(root, entry.destination) !== entry.sha256) { + throw new Error(`Existing native checker differs from the approved package; refusing to overwrite ${entry.destination}.`); + } writePath(root, entry.destination, content); if (fileDigest(root, entry.destination) !== entry.sha256) { throw new Error(`Materialized runtime digest mismatch for ${entry.destination}.`); diff --git a/workflows/shared/squad-planning-ontology.md b/workflows/shared/squad-planning-ontology.md index 14891a317..dce402c2a 100644 --- a/workflows/shared/squad-planning-ontology.md +++ b/workflows/shared/squad-planning-ontology.md @@ -337,23 +337,45 @@ the safe-output `data` envelope because gh-aw expands nested data schemas beyond GitHub's expression-size limit. The post-activation checker can still fail closed without matching model-authored titles. -**Issue references are quoted strings, never bare numbers.** `issue` and `epic_issue` -carry a `#`-prefixed reference in a JSON string: an item's own gh-aw `temporary_id` -(`"#aw_task3"`) when this run created it, or its verified real number (`"#123"`) when the -item was reused or matched by title. The agent never learns a created issue's real number -during its turn, so it never writes one; gh-aw rewrites `#aw_…` references in a comment body -to `#{real number}` once the issue exists. Quoting is required for validity: that -substitution is plain text replacement across the whole body — it does not skip fenced code -blocks — and preserves the `#`, so bare `"issue":#aw_task3` becomes invalid `"issue":#42` -while quoted becomes `"issue":"#42"`. A reference still matching `#aw_…` was never resolved; -consumers MUST treat it as a failure rather than skipping or repairing it. - -**Reported labels mean accepted label operations.** A `label` / `epic_label` asserts that an -`add_labels` safe output carrying that label was accepted for that same issue, targeted by -its temporary ID or verified real number. It does not assert the label was observed on the -issue — safe outputs are applied after the agent turn — and never means it was carried by -`create-issue`, whose `labels:` field cannot create a missing label. Verifying bindings -against the labels actually present is the post-activation checker's job. +**Issue references are quoted, actual numeric references.** `issue` and `epic_issue` +use strings such as `"#123"`. The model emits only `activate_plan`; it must not +emit issue creation, acceptance, bindings or an Activated lifecycle. The trusted +custom job freezes the exact API plan comment and committed registry, reserves +the activation before creating anything, and receives actual IDs directly from +the pinned runtime's synchronous `createIssue` API. The finalizer validates every +operation, dependency, live label and frozen assignment before publishing. +Neither bare nor `#`-prefixed `aw_` tokens are valid consumable references. +Do not infer identity from titles, labels or model claims. + +**Labels remain checked against current API state.** Older records' labels mean +accepted operations, not proof that those operations landed. New finalization +also checks the live result. Epic ownership is derived from the entire frozen +plan, including future phases; verifier-supplied `epicAssignments` preserves that +contract when a phase contains only a subset of its epic's tasks. + +**Recovery is explicit, current-state-only and creates no issues.** An eligible +operator binds a request-comment ID/hash to the original acceptance ID/hash, +current plan/registry and complete numeric task/epic mappings. A validated +`activation-reconciled` record has `basis: current-state-authorized` and explicit +`supersedes_comment_id`/`supersedes_sha256`. Preserve the original bytes, even if +its bindings are malformed. The supplement never claims historical attestation. +Only independently verified request evidence may authorize reader selection; +an actor name, authorization flag or digest inside a record is not proof. +Reject competing, unknown, changed or ambiguous chains; never choose newest. + +Durable capture/receipt artifacts are diagnostics, not producer attestations. +A pending reservation cannot authorize recovery. A missing receipt after an +operation started leaves the activation non-consumable and forbids recreation. +An explicitly authorized current-state request may name that reservation using +`source_kind: pending`, its exact comment ID/hash, matching frozen plan/registry +and complete existing numeric mappings. Only the new request grants authority. +The supplement records `supersedes_kind: pending`; incomplete coverage never +accepts missing work or permits creation, and no historical attestation is claimed. +For later phases, explicitly reconcile complete prior-phase coverage first +when attestable producer mappings are unavailable. Create only the newly +selected phase; carry exact predecessor ID/hash links and validated prior IDs. +Downstream readers must run the packaged binding checker or the SDK selector +with verifier-supplied context; a lifecycle state or green run is insufficient. --- @@ -364,7 +386,7 @@ against the labels actually present is the post-activation checker's job. | `research` | Research findings | `[]` | 1 current per issue | Replace body | | `plan` | Fast-path plan | `[]` | 1 current per issue | Replace body | | `plan-accepted` | Fast-path full acceptance | `[]` | 1 total | Immutable | -| `phases-accepted` | Fast-path accepted phase state | Accumulated accepted phases | 1 current per issue | Replace accumulated state | +| `phases-accepted` | Fast-path accepted phase state | Accumulated accepted phases | Explicit continuation chain | Immutable new revision; retain predecessors | | `triage` | Triage disposition | `[]` | 1 current per issue | Replace body | | `program` | Program plan | `[]` | 1 current per issue | Replace body | | `implementation` | Implementation plan | `[]` | 1 current per issue | Replace body | @@ -372,8 +394,9 @@ against the labels actually present is the post-activation checker's job. | `scope-accepted` | Scope acceptance | `[]` | 1 total | Immutable | | `impl-accepted` | Full implementation acceptance | `[]` | 1 total | Immutable | | `impl-phases-accepted` | Accepted implementation phase state | Accumulated accepted phases | 1 current per issue | Replace accumulated state | -| `phases-activated` | Activated phase state | Accumulated activated phases | 1 current per issue | Replace accumulated state | +| `phases-activated` | Activated phase state | Accumulated activated phases | Explicit continuation chain | Immutable new revision; retain predecessors | | `activated` | Terminal activation record | All phases when phased; otherwise `[]` | 1 total | Immutable | +| `activation-reconciled` | Authorized current-state supplement | Explicit covered phases, or `[]` for whole plan | 1 per exact superseded source | Immutable; preserve original | | `lifecycle-state` | Lifecycle summary | `[]` | 1 current per issue | Updated every transition | Every entry also requires `schema_version: "1"` and the triggering `origin_issue`. diff --git a/workflows/shared/squad.md b/workflows/shared/squad.md index 2a9150636..94bb618cd 100644 --- a/workflows/shared/squad.md +++ b/workflows/shared/squad.md @@ -241,11 +241,12 @@ safe-outputs: /^(?:[-*]\s+)?(?:\*\*)?Activation:(?:\*\*)?\s+✅\s+Done\b/im.test(body) || /^\|\s*Activat(?:e|ion|ed)\s*\|\s*✅\s+Done\b[^|]*\|/im.test(body); const hasTerminalState = - /^(?:[-*]\s+)?\*\*(?:Current state|State):\*\*\s+Activated\s*$/im.test(body) && - hasActivationDone && - /^(?:[-*]\s+)?\*\*Last command:\*\*\s+`\/squad (?:activate|plan accept|plan activate)(?: phase \d+)?`(?:\s+.*)?$/im.test(body) && - /^(?:[-*]\s+)?\*\*Next (?:action|command|recommended):\*\*\s+\S.+$/im.test(body); - const hasNextAction = hasNextCommand || hasTerminalState; + /^(?:[-*]\s+)?\*\*(?:Current state|State):\*\*\s+Activated\s*$/im.test(body); + if (hasTerminalState || hasActivationDone) { + core.setFailed("Activated lifecycle belongs exclusively to the trusted activation finalizer."); + return; + } + const hasNextAction = hasNextCommand; if (!hasLifecycleHeading || !hasState || !hasLastCommand || !hasNextAction) { core.setFailed("Lifecycle body must include an H2 lifecycle heading plus state, last-command, and a nonterminal next-action value consisting of a backticked /squad command."); return; @@ -303,6 +304,7 @@ jobs: needs.agent.result == 'success' && needs.detection.result == 'success' && needs.safe_outputs.result == 'success' && + !contains(needs.agent.outputs.output_types, 'activate_plan') && !contains(needs.agent.outputs.output_types, 'upsert_lifecycle_state') && ( (github.event_name == 'issue_comment' && diff --git a/workflows/squad-bootstrap.md b/workflows/squad-bootstrap.md index 01cf7068e..921fc8c20 100644 --- a/workflows/squad-bootstrap.md +++ b/workflows/squad-bootstrap.md @@ -160,7 +160,7 @@ pre-agent-steps: node --check "$path" >/dev/null } # BEGIN GENERATED RESOURCE DIGESTS - check_hash "$install_verifier" "ce387c5f0c9826a91d4132188f67a50e6996b3d4aa996ad0e5ad79dcf8acb132" + check_hash "$install_verifier" "a8b8fedd8b2804a061c367f6c67113979aa9d792526e8d052a2f01f697bf3642" check_hash "$cast_validator" "15f0d47917d269b5b967dc2a782a0eb761c39af7a92ddb64fed687a427070760" check_hash "$bootstrap_validator" "d552d041f006249bfaa67b10a75877f813796b7698c718ac7eb7cf16f3a72e31" # END GENERATED RESOURCE DIGESTS diff --git a/workflows/squad-workflows.manifest.json b/workflows/squad-workflows.manifest.json index 231df1d24..b2ba4b5b8 100644 --- a/workflows/squad-workflows.manifest.json +++ b/workflows/squad-workflows.manifest.json @@ -14,18 +14,18 @@ "source": "workflows/package/squad.md", "destination": ".github/workflows/squad.md", "lock": ".github/workflows/squad.lock.yml", - "source_sha256": "85f4fab3f545d1d2e2c8aabc511118e4d34b9bd2e008e4a4e0756d7f8fae9bf6", - "lock_sha256": "3c447de8a7998d659ef6a4155e49b7c4785ac2e8361a1d854ffbd5acd258348b", - "package_lock_sha256": "8823d25b1fc8aed5981d14363a3311a381d7207b89105203a1202dc6f78c2c78" + "source_sha256": "fec10a3aa8e0a793d505805078d1a8dc6d64b16b90b65042482e965f174c94d0", + "lock_sha256": "5df2c10668052fd80d7359fe6421307d641812dde47eeab2e2ee96407983654c", + "package_lock_sha256": "b7eaa2d20c0ca207f1812f083893f7cdab571ca713205ad30248a0be3c7760e9" }, { "name": "squad-implement-worker", "source": "workflows/package/squad-implement-worker.md", "destination": ".github/workflows/squad-implement-worker.md", "lock": ".github/workflows/squad-implement-worker.lock.yml", - "source_sha256": "adf3d810ae789453eef68c8ef168dd6327f2d0985ae0602ee91890ef1d7b81a9", - "lock_sha256": "6d209c356856b9aada9e3befb640517dd171b8ba39761c48686667907ebc2822", - "package_lock_sha256": "06a32b8bbda47b26182a82e16fca5be8ce7844a6098e1b13ab3c2daaf6360faf" + "source_sha256": "eb9120e504545bcb73f189e0c03db8405b79b586752a337a35e86329037a64a5", + "lock_sha256": "e4d1bf687823a99c3fcc9d8e1419f2d975c807e7ddb9a5508d03e1e6759846b6", + "package_lock_sha256": "4384bc689f78e4ea55482ce2bbc5498d29f282b5db7359d95f3662d56721e434" }, { "name": "squad-review", @@ -41,18 +41,18 @@ "source": "workflows/package/squad-deps-worker.md", "destination": ".github/workflows/squad-deps-worker.md", "lock": ".github/workflows/squad-deps-worker.lock.yml", - "source_sha256": "04c0135625218f5321bab8af5a0bac3b2a60434fb2364582b4ab2f8e1e570804", - "lock_sha256": "03cee66ac9ddf291dc5dc856757c9fb10d06d525e8fc349179892f94bb1560b3", - "package_lock_sha256": "57883c07743764e1f9fd7732e7959867661d388793a01c30dc008ed36c4d13a9" + "source_sha256": "d3d1c7757657d6fb2fe736660cb5c03718723f6d9c66d667fe12938c896a5139", + "lock_sha256": "bedea967dd7115b937f33c234ad196930acaba3dd0ee11d6a66ac0ffd4b4bcd1", + "package_lock_sha256": "ea8f862e15b34ba344176a59231c018618efa1c93feac9863f802071b9eefd06" }, { "name": "squad-retro", "source": "workflows/package/squad-retro.md", "destination": ".github/workflows/squad-retro.md", "lock": ".github/workflows/squad-retro.lock.yml", - "source_sha256": "b511ba1b2e28db26efbb5ad7441b361dfcf96bdb489b01e61ea6b523fa1eba22", - "lock_sha256": "95a77ca182f00b6cd1b438fb862310c5a80a5d2624b3accd1410a7aa1e12fec8", - "package_lock_sha256": "716097c0d07d763b0998def79ddd14c7894e5dbf36660949fa3c7e2e10205ba9" + "source_sha256": "baab5800ce5a567507a3cc19d1a513b37812fc247bfdb85d5fc8b6b903d9567b", + "lock_sha256": "89b51cd028d2adeb794fbf4dfc9830398fd89ef3056472929bca00a898963ca3", + "package_lock_sha256": "ace1a43ed7267c7711692ec83e2a4c510721da0f42d00e32c1cc506a0d22c6b5" }, { "name": "squad-improvement-worker", @@ -68,9 +68,9 @@ "source": "workflows/package/squad-bootstrap.md", "destination": ".github/workflows/squad-bootstrap.md", "lock": ".github/workflows/squad-bootstrap.lock.yml", - "source_sha256": "4c90b08e00d806b4f8539677e6304b8357d4ac53b306ff7691364c4bc96d15ca", - "lock_sha256": "b16d006c2d3f2fe8a281957240f7c5124028c65664bed1a18387788aa994364e", - "package_lock_sha256": "123a4428d8adc22c0610b5828d83bd3efd45822759c42d750709e03696bdce5b" + "source_sha256": "51d612983253e367a21918b849a20177aa6cddfa7e5f6cf7f7823cafa012816d", + "lock_sha256": "ab601855b1c26e636c3cc25d5c3306eaf18dc81335a675eb73b5019aa3a8cd40", + "package_lock_sha256": "164215a224d29a80818b61abed2748943423ea424fbc0fc25a74129de661b695" }, { "name": "squad-command-router", @@ -88,13 +88,13 @@ "workflows": [ { "name": "squad", - "lock_sha256": "3c447de8a7998d659ef6a4155e49b7c4785ac2e8361a1d854ffbd5acd258348b", - "package_lock_sha256": "8823d25b1fc8aed5981d14363a3311a381d7207b89105203a1202dc6f78c2c78" + "lock_sha256": "5df2c10668052fd80d7359fe6421307d641812dde47eeab2e2ee96407983654c", + "package_lock_sha256": "b7eaa2d20c0ca207f1812f083893f7cdab571ca713205ad30248a0be3c7760e9" }, { "name": "squad-implement-worker", - "lock_sha256": "0dbb677dfe011e2bc45e0139dd160f160c79f60aa3c70cc44ec8a33d3b746ccf", - "package_lock_sha256": "c3d099614d956765351f9eb77ba2677280f6874811bf3a1f65a762f7a8553b7d" + "lock_sha256": "08d80c7f48104cdc34c6f6c44207fcb514a61ae7c21500d809be36888d065888", + "package_lock_sha256": "f19b95088aee9947cb8f1abe027cd116da325d12a381c80863cf2c074c992b8d" }, { "name": "squad-review", @@ -103,13 +103,13 @@ }, { "name": "squad-deps-worker", - "lock_sha256": "22c338f2cdad4cc3f8a5d04330ea482c35b147f12fa96de42cced2536c4979ac", - "package_lock_sha256": "8122bd80347464d301150c42dc5ec760fcc0d6388a22c3d125fcc9245adf60b5" + "lock_sha256": "2ac0372236b531dd60311c75be12e108b4b37cd57b3974fb5b6987fdc37dd925", + "package_lock_sha256": "b5729a10bd8d9d000d004037041f9eeadea5fab75698fe33b63a995d1f913c89" }, { "name": "squad-retro", - "lock_sha256": "97baa50c5cd9616674b8a8793e560469b2713e1bac326ddc2467e6674b156b60", - "package_lock_sha256": "795ece98cb5a645f11292665a27d3c5181e8b41a0f936e538f05dbd52edb5322" + "lock_sha256": "2594b0d1ed58c8a1d31b77e17e20e9b8995ac077e0d3c795d50bb34d7803818d", + "package_lock_sha256": "0958d0ea358c9f56493e7a4e5740aec4e8d01664b20a1baf541083b937d4e3a9" }, { "name": "squad-improvement-worker", @@ -118,8 +118,8 @@ }, { "name": "squad-bootstrap", - "lock_sha256": "ab74b96e34f2a82719782fa362dc12424f99b47430709e64a786f50fdd8d5e52", - "package_lock_sha256": "8a5897cc3febd6ebd6b1b30fec32bc0a0af4a0e5c54145f1bc40eff94b51a58b" + "lock_sha256": "af3d33ec1b7440b9017d135db384d2b5adfb493aa4ca9b8aad585d1eb1ec1438", + "package_lock_sha256": "4e915f52ad29f6bff47352d23457a0e23390103ebbf4dcc7cb8916870b860a75" }, { "name": "squad-command-router", @@ -130,6 +130,30 @@ } ], "shared_runtime": [ + { + "path": "shared/squad-agent-binding-check.yml", + "source": "workflows/shared/squad-agent-binding-check.yml", + "package_destination": ".github/aw/squad/runtime/shared/squad-agent-binding-check.yml", + "destination": ".github/workflows/squad-agent-binding-check.yml", + "ownership": "manifest", + "sha256": "e3098d9e9c4f5c711f37718083fb285b4778ecc987fee0c864429b8d868336b3" + }, + { + "path": "shared/squad-activation.mjs", + "source": "workflows/shared/squad-activation.mjs", + "package_destination": ".github/workflows/shared/squad-activation.mjs", + "destination": ".github/workflows/shared/squad-activation.mjs", + "ownership": "manifest", + "sha256": "16c579cc85ff191614a9635757ee5281f4f9f3f1f0f8ead44fe33c44b90a0980" + }, + { + "path": "shared/check-agent-binding.mjs", + "source": "workflows/shared/check-agent-binding.mjs", + "package_destination": ".github/workflows/shared/check-agent-binding.mjs", + "destination": ".github/workflows/shared/check-agent-binding.mjs", + "ownership": "manifest", + "sha256": "8ccf2d63049b38a52f78068f2b4dead16a9cda5d947618bf484832e339952892" + }, { "path": "shared/squad-review-guard.mjs", "source": "workflows/shared/squad-review-guard.mjs", @@ -144,7 +168,7 @@ "package_destination": ".github/workflows/shared/squad-install-verifier.mjs", "destination": ".github/workflows/shared/squad-install-verifier.mjs", "ownership": "manifest", - "sha256": "ce387c5f0c9826a91d4132188f67a50e6996b3d4aa996ad0e5ad79dcf8acb132" + "sha256": "a8b8fedd8b2804a061c367f6c67113979aa9d792526e8d052a2f01f697bf3642" }, { "path": "shared/squad-command-contract.mjs", @@ -152,7 +176,7 @@ "package_destination": ".github/workflows/shared/squad-command-contract.mjs", "destination": ".github/workflows/shared/squad-command-contract.mjs", "ownership": "manifest", - "sha256": "ccef806935f1283f404641d57aeb604021e284ff6a4b6463b7fff271cf8c521c" + "sha256": "577e8787ae2e6ccd767e136f97f187166b8bd1d7f599f4c8b86d3bc571daf2de" }, { "path": "shared/squad-cast-validator.mjs", @@ -208,7 +232,7 @@ "package_destination": ".github/aw/squad/runtime/shared/squad.md", "destination": ".github/workflows/shared/squad.md", "ownership": "manifest", - "sha256": "812f86d56eb984f65c6c31572d9697f32e80452f7f02aff885eaf2516447d48a" + "sha256": "eabfe1d25a37076c0e513a35984ec9c8d5e30a8db425f8f009245fd6212fc617" }, { "path": "shared/squad-planning-ontology.md", @@ -216,7 +240,7 @@ "package_destination": ".github/aw/squad/runtime/shared/squad-planning-ontology.md", "destination": ".github/workflows/shared/squad-planning-ontology.md", "ownership": "manifest", - "sha256": "c4037fb6c3a0eec6a74fc48a3506daba5c27e249df61f6b519865e0efa617af4" + "sha256": "a9d87e8a834bf43bb4e5a68e9ebb0972e4965a76801e29407a6e5c7484e40ff9" }, { "path": "shared/squad-planning-policy.md", diff --git a/workflows/squad.md b/workflows/squad.md index bec88c1f4..03fb8290f 100644 --- a/workflows/squad.md +++ b/workflows/squad.md @@ -46,6 +46,8 @@ imports: - shared/squad-planning-ontology.md - shared/squad-planning-policy.md resources: + - shared/squad-activation.mjs + - shared/check-agent-binding.mjs - shared/squad-command-contract.mjs - shared/squad-cast-validator.mjs - shared/squad-bootstrap-validator.mjs @@ -273,6 +275,160 @@ safe-outputs: run-failure: "🤖 [{workflow_name}]({run_url}) {status}. The requested result was not delivered." pull-request-created: "🤖 Squad created [PR #{item_number}]({item_url}) for review. If its checks show `action_required`, approve the workflow run before merging." jobs: + reconcile-activation: + name: Reconcile explicitly authorized current activation state + description: Reconcile only the explicit request bound by the authorized command. Creates no issues and accepts no model-provided mappings. + runs-on: ubuntu-latest + needs: safe_outputs + if: needs.safe_outputs.result == 'success' + permissions: + contents: read + issues: write + max: 1 + inputs: + action: + description: Must be the literal reconcile. + type: string + required: true + steps: + - name: Checkout trusted reconciliation implementation + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.workflow_sha }} + persist-credentials: false + path: .squad-activation-trusted-base + - name: Validate authorization and reconcile existing dependency references + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + SQUAD_WORKFLOW_SHA: ${{ github.workflow_sha }} + with: + script: | + const { join } = require('node:path'); + const { pathToFileURL } = require('node:url'); + const { readFileSync } = require('node:fs'); + const items = JSON.parse(readFileSync(process.env.GH_AW_AGENT_OUTPUT, 'utf8')).items; + if (items.length !== 1 || items[0].type !== 'reconcile_activation' || + items[0].action !== 'reconcile' || + Object.keys(items[0]).some(key => !['type', 'action'].includes(key))) { + throw new Error('Reconciliation rejects model-provided mappings or additional operations.'); + } + const root = join(process.env.GITHUB_WORKSPACE, '.squad-activation-trusted-base'); + const activation = await import(pathToFileURL(join(root, + '.github/workflows/shared/squad-activation.mjs')).href); + const result = await activation.reconcileActivation({ github, context, env: process.env, root }); + core.info(result.unchanged + ? 'Existing validated reconciliation retained; no new publication is asserted.' + : 'This execution published and independently checked its exact reconciliation response.'); + activate-plan: + name: Create and finalize validated activation + description: Activate the exact plan comment through the trusted deterministic writer. Do not call native issue creation, acceptance or lifecycle tools for activation. + runs-on: ubuntu-latest + needs: safe_outputs + if: needs.safe_outputs.result == 'success' + permissions: + contents: read + actions: read + issues: write + max: 1 + inputs: + plan_comment_id: + description: Exact numeric ID of the latest plan or accepted implementation plan comment on the origin issue. + type: string + required: true + steps: + - name: Checkout trusted activation implementation + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.workflow_sha }} + persist-credentials: false + path: .squad-activation-trusted-base + - name: Setup pinned issue creation API + uses: github/gh-aw-actions/setup@v0.91.5 + with: + destination: ${{ runner.temp }}/gh-aw/actions + - name: Freeze and validate activation inputs + id: activation_prepare + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + SQUAD_WORKFLOW_SHA: ${{ github.workflow_sha }} + with: + script: | + const { join } = require('node:path'); + const { pathToFileURL } = require('node:url'); + const root = join(process.env.GITHUB_WORKSPACE, '.squad-activation-trusted-base'); + const activation = await import(pathToFileURL(join(root, + '.github/workflows/shared/squad-activation.mjs')).href); + const result = await activation.prepareActivation({ + github, context, env: process.env, root, + outputPath: process.env.GH_AW_AGENT_OUTPUT, + capturePath: join(process.env.RUNNER_TEMP, 'squad-activation-capture.json'), + }); + core.setOutput('fresh', result.existingAcceptanceId ? 'false' : 'true'); + if (result.existingAcceptanceId) core.info('Existing valid acceptance retained; no creates, acceptance edits or new historical attestation.'); + - name: Persist frozen activation capture + id: activation_capture + if: steps.activation_prepare.outputs.fresh == 'true' + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: squad-activation-capture-${{ github.run_id }}-${{ github.run_attempt }} + path: ${{ runner.temp }}/squad-activation-capture.json + if-no-files-found: error + - name: Reserve activation and record actual create results + id: activation_create + if: steps.activation_prepare.outputs.fresh == 'true' + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + CAPTURE_ARTIFACT_ID: ${{ steps.activation_capture.outputs.artifact-id }} + SQUAD_WORKFLOW_SHA: ${{ github.workflow_sha }} + with: + script: | + const { join } = require('node:path'); + const { pathToFileURL } = require('node:url'); + const { readFileSync } = require('node:fs'); + const actionsDir = join(process.env.RUNNER_TEMP, 'gh-aw/actions'); + const { setupGlobals, createIssue } = require(join(actionsDir, 'index.cjs')); + const { sanitizeContent } = require(join(actionsDir, 'sanitize_content.cjs')); + const { formatIssueBody } = require(join(actionsDir, 'issue_body.cjs')); + setupGlobals(core, github, context, exec, io, getOctokit); + const activation = await import(pathToFileURL(join(process.env.GITHUB_WORKSPACE, + '.squad-activation-trusted-base/.github/workflows/shared/squad-activation.mjs')).href); + await activation.createActivation({ + github, context, env: process.env, + root: join(process.env.GITHUB_WORKSPACE, '.squad-activation-trusted-base'), + createIssue, sanitizeContent, formatIssueBody, + capture: JSON.parse(readFileSync(join(process.env.RUNNER_TEMP, 'squad-activation-capture.json'), 'utf8')), + captureArtifactId: Number(process.env.CAPTURE_ARTIFACT_ID), + journalPath: join(process.env.RUNNER_TEMP, 'squad-activation-receipts.json'), + }); + - name: Persist actual activation receipts including partial outcomes + id: activation_receipts + if: always() && steps.activation_create.outcome != 'skipped' + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: squad-activation-receipts-${{ github.run_id }}-${{ github.run_attempt }} + path: ${{ runner.temp }}/squad-activation-receipts.json + if-no-files-found: error + - name: Validate actual bindings before publishing acceptance + if: steps.activation_prepare.outputs.fresh == 'true' + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + RECEIPT_ARTIFACT_ID: ${{ steps.activation_receipts.outputs.artifact-id }} + SQUAD_WORKFLOW_SHA: ${{ github.workflow_sha }} + with: + script: | + const { join } = require('node:path'); + const { pathToFileURL } = require('node:url'); + const { readFileSync } = require('node:fs'); + const activation = await import(pathToFileURL(join(process.env.GITHUB_WORKSPACE, + '.squad-activation-trusted-base/.github/workflows/shared/squad-activation.mjs')).href); + await activation.finalizeActivation({ + github, context, env: process.env, + root: join(process.env.GITHUB_WORKSPACE, '.squad-activation-trusted-base'), + capture: JSON.parse(readFileSync(join(process.env.RUNNER_TEMP, 'squad-activation-capture.json'), 'utf8')), + journal: JSON.parse(readFileSync(join(process.env.RUNNER_TEMP, 'squad-activation-receipts.json'), 'utf8')), + receiptArtifactId: Number(process.env.RECEIPT_ARTIFACT_ID), + }); + core.info('This execution published and independently checked its exact activation response.'); cast-failure: name: Fail incomplete Cast description: "Fail a Cast run from an emitted factual failure record." @@ -578,6 +734,7 @@ Repository owners must configure Copilot setup steps separately when needed. | `/squad plan accept implementation phase {N}` | Plan Accept Implementation | | `/squad plan activate` | Plan Activate | | `/squad plan activate phase {N}` | Plan Activate | +| `/squad reconcile activation ` | Authorized current-state reconciliation | | `/squad implement` | Implement | | `/squad` (no args) | Cast | @@ -739,6 +896,7 @@ Each mode's playbook ships as a **skill**. Enter this section only after **Actor | `plan accept scope` | `squad-plan-accept-scope` | | `plan accept implementation` | `squad-plan-accept-implementation` | | `plan activate` | `squad-plan-activate` | +| `reconcile activation` | `squad-reconcile-activation` | | `implement` | `squad-implement` | **Planning modes only** — before running the mode skill, also load `squad-planning-policy` (policy resolution) and `squad-planning-ontology` (artifact schemas and the lifecycle state machine). The non-planning modes (Cast, Connect, Adopt, Cast Member, Retire, Status, Review Relay, Approve Improvement, Revoke Improvement, Implement) must not load them. @@ -812,7 +970,7 @@ Reuses TG-1's committed-HEAD read (working-tree presets cannot leak); emits lowe - **`ROSTER_MEMBER:` lines** are the **certified roster set** — bind only to these, reproduce them verbatim as provenance; a name outside them (bar `@copilot`) must never become a `squad:{name}` label. - **`AGENT_IDENTITY:` lines** are the only authority for stable agent IDs and registry revision. Copy IDs directly; never slug or match labels/roles/paths to invent one. Every roster-assigned new plan row must carry the matching ID. -- **`IDENTITY_UNREADABLE:`** halts new planning and identity binding. Existing legacy plans may activate only with explicit `legacy-plan-missing-id` omissions. +- **`IDENTITY_UNREADABLE:`** halts planning and activation. The trusted activation writer requires explicit, validated Agent ID columns. - **`ROSTER_UNREADABLE:`** halts binding with its named reason — never a provenance sentence for a read that did not happen, never a preset fallback; treat as `TEAM_ABSENT`. ### Auto-Cast Pivot @@ -1637,230 +1795,87 @@ Set Plan = `✅ Done`, state = Planned, last command = `/squad plan`, next = ## skill: `squad-plan-accept` --- -description: Review and activate a fast plan (whole plan or a single phase), accepting it and creating issues. +description: Activate the latest frozen plan through the trusted deterministic writer. --- -`/squad activate` [phase {N}] (recommended) or `/squad plan accept` [phase {N}] -(supported legacy alias) — review the latest fast plan, then combine -scope+implementation acceptance and activation for simple workflows. - -**Behavior:** If `program` or `implementation` artifacts exist, run Accept Scope → Accept Impl → Activate in sequence. If only a `plan` artifact exists, use the fast-path behavior below. - -**Acknowledge:** `🤖 Squad is creating the planned issues…` - -##### Step 1: Find Plan and Route - -Resolve which planning path this issue is on, in this order: - -1. Find the latest `program` and `implementation` artifacts for this issue. If - **either** exists, this is a granular (long-path) plan. Run the granular - sequence in this exact order — **Accept Scope** (`squad-plan-accept-scope`) → - **Accept Implementation** (`squad-plan-accept-implementation`) → **Activate** - (`squad-plan-activate`) — each honoring its own preconditions (e.g. Accept - Implementation requires a `validation` PASS). Then stop; do NOT run the - fast-path steps below. -2. Otherwise, find the latest `plan` artifact. If found, continue with the - fast-path behavior in the steps below. -3. If none of `program`, `implementation`, or `plan` exist, reply "No plan found. - Run `/squad plan` first." and stop. - -##### Step 1a: Phase Resolution - -1. Extract `requested_phase` from args (or null). -2. Paginate all comments and select the newest `plan-accepted`, - `phases-accepted`, and `lifecycle-state` artifacts whose `origin_issue` - matches this issue. -3. **Whole-plan idempotency:** when `requested_phase` is null and a matching - `plan-accepted` artifact exists, create no issues and post no acceptance - artifact. Before stopping, inspect the newest lifecycle state. If it is - missing or does not record State = Activated, Activation = `✅ Done`, and the - invoked activation command, call `upsert_lifecycle_state` exactly once with - the terminal body from Step 5. Return `noop` only when that lifecycle state - is already terminal and consistent. Then stop. -4. **Phase-specific:** read `accepted_phases` from the latest matching - `phases-accepted` artifact (or `[]`). Already accepted → verify the lifecycle - reflects that phase, repair it if stale, then stop with the next-available - hint. Out of order → stop with the sequential hint. -5. Filter items: by phase if set, by unaccepted if prior phases exist, all if fresh. -6. If no items remain after filter: stop. -7. Determine hierarchy only from the latest plan artifact's headings. Any heading - matching `### Phase {N}` makes the plan explicitly phased, including a lone - `### Phase 1`; every accepted row must remain in its declared phase. With no - matching heading, the plan is flat. Do not infer hierarchy from prose, task - count, dependency shape, or personal preference. - -##### Step 2: Create Sub-Issues — Hierarchical - -The origin issue is always the root. Apply Step 1a's heading rule exactly. If the -plan has explicit `### Phase {N}` headings, create one phase issue per accepted -phase under the origin issue, then create that phase's task issues under its phase -issue; never flatten it. For a flat plan, create exactly one issue per accepted -work-item row and set every task's parent to the origin issue. -Do not create an additional epic, summary, root, or phase issue for a flat plan. - -Before any `create-issue` call, run Team Guard Step TG-2 and validate every -accepted plan row. Freeze a binding for each task number containing that row's original `Owner` and `Depends On` values, -plus its authoritative `Agent ID`. If TG-2 emitted a `ROSTER_UNREADABLE:` -line, stop before mutation and report that named reason. An individual `Owner` -matching no certified active roster name and not `@copilot` does **not** stop the -run — matching `squad-plan-activate`, create that issue with the base `squad` label -only, omit the owner label, continue, and record the value under -`Non-roster agent values` (Step 4). Never substitute, re-route, or fall back to -another identity during acceptance. -A roster Owner's `Agent ID` MUST exactly match its `AGENT_IDENTITY:` record; -stop before mutation on mismatch. `@copilot`, non-roster, and legacy rows use -only the explicit identity-omission cases defined in Step 4. - -For every roster member label, derive the slug exactly as label synchronization -does: lowercase the certified display name, replace each run of non-`a-z0-9` -characters with `-`, then trim leading and trailing `-`. The special `@copilot` -value maps to `squad:copilot` instead. - -For each work item, `create-issue`: -- Title: work item title -- Temporary ID: `temporary_id` is required on every `create-issue` call (`require-temporary-id: true`). Mint one per item: `#aw_ph{N}` for a phase issue and `#aw_wi{N}` for a work item, where `{N}` is that row's plan number with non-alphanumeric characters replaced by `_`. Must match `^#?aw_[A-Za-z0-9_]{3,12}$` and be unique in this run — gh-aw silently lets a duplicate's last writer own the mapping. -- Labels: `squad` (color `9B8FCC`), plus `squad:{owner-slug}` (color `9B8FCC`) where `{owner-slug}` is derived from the frozen row `Owner` lowercased first, then normalized by the slug rule above. Map `@copilot` to `squad:copilot`; never `squad:@copilot` — `@copilot` is the one permitted non-roster value and it is mapped, not slugged verbatim. Mint the member label only from that task's certified binding; never re-read team.md, re-route the task, or carry another row's owner forward. An `Owner` certified by neither route gets `squad` alone: omit the owner label, continue, and record the value under `Non-roster agent values` (Step 4). On `ROSTER_UNREADABLE:`, stop and report that reason; never mint from a preset or remembered roster. This computes the label set; `add_labels` applies it (see Fast-Path Label Provisioning) — `create-issue`'s `labels:` field alone cannot land it on a fresh repository. -- Body: scope, acceptance criteria, context (parent, phase, size, depends on, owner), notes, footer -- Parent: phase issue (hierarchical) or root (flat). For a phase issue created in this run, pass its `#aw_ph{N}` temporary ID — `create-issue` resolves it. The flat-plan root is the triggering issue's own real number. Never guess a number for an issue this run created. -- Size: set Project field if available, else body `**Size:**` line -- Label application: in the same turn as this `create-issue` call, call `add_labels` with `item_number` set to this item's own temporary ID and this item's computed label set (see Fast-Path Label Provisioning). `create-if-missing` provisions `squad`/`squad:{owner}` on a fresh repository automatically. - -Copy every frozen `Depends On` value into the created issue body. Cross-phase -deps: look up real issue numbers from prior acceptance comments without changing -the declared task dependencies. - -Create in dependency order. - -##### Fast-Path Label Provisioning - -`create-issue`'s own `labels:` field cannot provision a label: GitHub silently drops -label names that do not already exist in the target repository instead of creating -them, so on a fresh repository every fast-path issue would come out unlabeled. The -`add-labels` safe output (`allowed: [squad, "squad:*"]`, `create-if-missing: true`) is -the only operation here that creates a missing label, and it is the same one -`squad-plan-activate` uses — both activation paths provision labels identically, so -`/squad activate` needs no manual label setup on a fresh repository. - -In the same turn as each `create-issue` call in Step 2, call `add_labels` with: - -- `item_number` — that same `create-issue` call's `temporary_id` (`#aw_ph{N}` for a - phase issue, `#aw_wi{N}` for a work item). For an issue this run did **not** create - — one recognized by Step 1a's idempotency check or matched by title — pass its - verified real number instead; a temporary ID maps only issues this run created. -- `labels` — exactly the set computed below for that one issue, and nothing else. - -**Explicit targeting is mandatory.** Every `add_labels` call MUST pass `item_number`. -Omitting it does not fail — it silently applies the labels to the **triggering intent -issue**, branding the user's own request with an activated item's owner label. Never -reuse another item's temporary ID, and never emit `add_labels` for an item whose -`create-issue` call was not made in this run. `create-issue` returns no real issue -number during this run, so never predict or infer one and never pause between the two -calls waiting for one; gh-aw resolves `add_labels` after the `create-issue` that -minted the ID, so `create-issue` first and `add_labels` immediately after is the -supported order. - -**Label set, per issue:** - -- Work item: `squad`, plus `squad:{owner}` conceptually; emit `squad:{owner-slug}`, - derived from that row's own frozen certified `Owner`, lowercased then slugged as above. - `@copilot` maps to the existing `squad:copilot` routing label — never `squad:@copilot`. - Never inherit the phase issue's owner or carry the previous row's value forward. -- Phase issue: `squad`, plus `squad:{owner}` conceptually; emit `squad:{owner-slug}` - only when every accepted row in that phase names one and the same owner, slugged as above. - Two or more distinct owners is a multi-owner phase: apply only `squad`, choose - none of them, and record it under a - `Non-roster agent values` heading in the Step 4 summary. -- The triggering intent issue is never an `add_labels` target. It is the flat-plan - parent, not an activated item, and receives no owner label from this run. - -An `Owner` that is neither a certified roster name nor `@copilot` never becomes a -`squad:{owner}` label: send `squad` alone for that issue and record the value under -`Non-roster agent values` in the Step 4 summary — the same omit-and-record contract -`squad-plan-activate` uses, so an uncertified value can reach `add_labels` only as -the base `squad` label. - -Re-applying an already-present label on a rerun is a no-op under add-only merge -semantics, so this is safe under Step 1a's idempotency path. Labels must have -descriptions and intentional colors when they already exist; a label auto-provisioned -by `create-if-missing` on a fresh repository instead receives gh-aw's deterministic -color and an empty description — that is expected, not a failure, and must not be -reported as one. Report only the labels an accepted `add_labels` call carried for that -same issue; never a label that was skipped, deferred, or merely intended, and never one -attributed to `create-issue` (see Step 4, Label operations accepted). - -##### Step 3: Preserve Dependencies - -For every non-empty frozen `Depends On` entry, preserve the corresponding task -references in the created issue body using the created issue-number map. Do not -infer, drop, or reorder dependencies. - -Add native `blockedBy` relationships only when an available approved safe-output -tool explicitly exposes that field or operation. Do not bypass safe outputs with -a direct write API call. When native edges are unavailable, body references are -the expected fallback, and the acceptance summary MUST say that dependencies -were preserved in issue bodies without native edges. - -##### Step 4: Post Summary - -Artifact data varies: -- Phase-specific: `data: {"squad_artifact":"phases-accepted","schema_version":"1","origin_issue":{issue_number},"phases":[{accumulated}]}` → Phase accepted table + remaining phases table + the `Activation bindings:` JSON array. -- Full (no phases): `data: {"squad_artifact":"plan-accepted","schema_version":"1","origin_issue":{issue_number},"phases":[]}` → All issues table + the `Activation bindings:` JSON array. - -Report the exact number of created task issues, their actual parent hierarchy, -and whether dependencies use native edges or the body-reference fallback. Never -claim an epic, phase issue, sub-issue relationship, or native dependency edge -that was not created. - -**Every phase and full acceptance artifact body MUST include an `Activation -bindings:` fenced JSON block containing a non-empty array** — the identical -versioned provenance, binding shape, quoting, and omission semantics as -`squad-plan-activate` Step 4: one object per created/recognized work item with -repository/origin/artifact/registry provenance, -`task`/`issue`/`epic`/`epic_issue`/`agent_id`/`epic_agent_ids`/`agent`/`epic_agents`, plus `label` or -`omission_reason`, and `epic_label` or `epic_omission_reason` (`multi-owner` or -`non-roster`). `issue` and `epic_issue` are quoted JSON strings — that item's own -`temporary_id` when created this run, its verified real number when reused — -never bare numbers; a surviving `#aw_…` reference means `create-issue` never -landed and must be left unresolved rather than repaired. Never omit a created -or recognized task from `bindings`, never infer an issue number, and never -emit an empty array — the deterministic post-activation checker treats a -missing, empty, malformed, or unresolved bindings block on a `plan-accepted` -or `phases-accepted` artifact as a failure exactly as it does for `activated` -and `phases-activated`. - -###### Label operations accepted - -Identical semantics to `squad-plan-activate` Step 4. A label reaches an activated issue through exactly one route: -an accepted `add_labels` operation targeting that issue. Report `squad:{owner-slug}` only when -this run made an `add_labels` call carrying that label and targeting that same issue — by -its own `temporary_id`, or by its verified real number for a reused issue. A successful -`create-issue` is **not** evidence: its `labels:` field cannot land a label on a fresh -repository, so no summary may say a label was carried by, applied by, or included in issue -creation. Never report a label merely computed, intended, skipped, or deferred, never borrow -another item's label operation, and never report an accepted operation as an omission. - -`add_labels` is a safe output: this run knows only that the call was accepted for a specific -target, never the GitHub API result. State it at that strength — never write that a label -was verified, confirmed, or checked on the issue, because nothing here reads labels back. - -Whenever an accepted `Owner` did not become a `squad:{owner}` label — a multi-owner -phase issue, or a value certified by neither the roster nor `@copilot` — a -`Non-roster agent values` heading is **required** in this summary, naming the value -and the issue it applied to. Omitting the label while omitting the heading reports a -clean run that did not happen. Conversely, never emit the heading for an owner that -*did* become an accepted label — that manufactures a defect that did not occur. - -##### Step 5: Update Fast-Path Lifecycle - -Call `upsert_lifecycle_state` once with the complete lifecycle body. +`/squad activate [phase N]` and `/squad plan accept [phase N]` use the same +trusted activation writer. The model never creates the issues or writes acceptance. + +1. Paginate all origin-issue comments. Identify the latest `plan` or + `implementation` artifact, matching the current origin issue. +2. Call `activate_plan` exactly once with `plan_comment_id` set to that + comment's numeric ID as a string. Emit no other safe-output operation. +3. Stop. The writer freezes the exact comment and committed registry, validates + stable IDs, owners, dependencies, hierarchy and capacity, then captures actual + issue IDs from the pinned runtime's supported synchronous createIssue API. + +Do not call `create_issue`, `add_labels`, `add_comment` or +`upsert_lifecycle_state` for activation. Do not author Activation bindings, +acceptance metadata, issue-number maps, or an Activated lifecycle. In particular, +a bare `aw_wi1` in JSON is not an actual issue number. + +The finalizer's `Activation bindings:` maps `"task":"{plan # cell}"` to actual +numeric issue references, including `"epic_issue":"{epic issue reference}"` and +`"epic_agents":["{all distinct lowercased Agent cells in the frozen epic}"]`. +These describe its output fields, not a model-authored acceptance template. + +The writer alone publishes versioned acceptance and lifecycle after every actual +ID, dependency reference, frozen owner, and live label validates and capture plus +receipts have been durably uploaded. A failed or ambiguous write is not acceptance. +The pending reservation is durable before issue creation; every rerun is no-create +unless independently validated current-state recovery supplies the missing evidence. +Never deduplicate by title, guess issue numbers, recreate an acceptance, or edit an +invalid acceptance. Artifact hashes identify bytes; they are not historical producer +attestation. + +Supported plan tables require unique stable `#`, `Title`, `Owner`, +`Agent ID` and `Depends On` columns. Dependencies are unique comma-separated +stable task IDs, or `None`/`-` for none. Explicit `### Phase N` headings +define hierarchy; a flat plan uses the origin issue as parent without labeling it. +Granular tables use `Agent` instead of `Owner` and also require `Epic`; +granular activation requires prior scope and implementation acceptance. Malformed +tables, omitted identity columns, missing prior-phase authority, cycles, and plans +over the applicable per-invocation policy limit (never above 50 issues) fail before +issue creation. Larger plans may activate bounded phases. + +For phase continuation, first supply an authorized reconciliation request covering +all prior phases and their exact existing numeric issue IDs. The writer verifies +the frozen plan/registry, current hierarchy/dependencies, and explicit predecessor +chain, then creates only the newly selected work. Shared epics retain the complete +plan's owner/label set, including future phases. Missing or stale mappings block +that invocation; do not infer a map from titles or rerun native creates. + +For an existing invalid record, use the separately authorized +`/squad reconcile activation ` contract. +The model must never supply the authoritative mapping. + +## skill: `squad-reconcile-activation` +--- +description: Reconcile explicit operator-approved current issue mappings without creating issues. +--- -- Phase-specific: keep Plan = `✅ Done`, record phase `{N}` activated, set the - last command to the invoked `/squad activate phase {N}` or legacy alias, and - point next to the next unactivated phase. -- Full or last phase: set Plan = `✅ Done`, Activation = `✅ Done`, state = - Activated, and the last command to the invoked `/squad activate` or legacy - alias. This is terminal. Set Next action to explicit terminal prose such as - `None — activation is complete`; do not invent another slash command. +For `/squad reconcile activation `, +call `reconcile_activation` exactly once with `action: "reconcile"`, and +emit no other safe output. The trusted writer reads the request identified by +the authorized command, verifies its author permission and exact content hash, +and freezes current plan/registry and numeric task/epic issue mappings. + +It may correct only approved temporary references in one standalone +`**Depends On:**` field. Titles, scope prose, owners, labels, hierarchy and the +original acceptance remain unchanged. Fresh reads and post-write checks detect +conflicts but are not an atomic compare-and-swap; external editors must avoid +concurrent changes. Partial correction is not acceptance. The explicit +`activation-reconciled` supplement has basis `current-state-authorized`, +links the original comment ID/hash, and makes no historical creation claim. +Only the deterministic chain-validated reader may select it; never select the +newest matching envelope as a fallback. + +Downstream consumption must run the installed read-only checker +`.github/workflows/shared/check-agent-binding.mjs` with all origin records and +the current team/registry. It verifies the API request and source hashes before +skipping a superseded malformed original. The SDK `selectActivationRecord` API +requires independently supplied reconciliation evidence and frozen epic +assignments for partial-phase groups. Structural parsing alone is not admission. ## skill: `squad-plan-revise` --- @@ -2166,6 +2181,15 @@ this synthesis is Check 16 ❌ Critical and cannot become `RESULT: PASS`. Structure: `## ✅/❌ Squad Plan Validation — PASSED/FAILED` → Validated artifacts + timestamp → Results table (Check|Status|Details; all checks 1–16) → Adversarial Evidence (Steelman of the opposition; Load-bearing assumptions; 30-day pre-mortem; Alternative approach; Remaining risk acceptance, each with WHAT/WHY/HOW) → Validator Synthesis (independent reasoning across structural and adversarial evidence) → Issues Found (Critical ❌ then Warnings ⚠️ with fix instructions) → Summary (counts + validator-owned verdict) → Next action. +For deterministic phase acceptance, include exactly one plain `RESULT: PASS` or +`RESULT: FAIL` line, `**Implementation plan:** `, and +`**Implementation SHA-256:** `. +The results table uses exactly `Check | Status | Details`, with unique numeric +Check cells 1–16 and `PASS`/`FAIL` status. Include a `Task | Size | Traces to` +Sizing Summary table covering every stable task ID. This is frozen validator +evidence, not permission to invent findings, skip checks, or re-derive sizing +during acceptance. + Rules: any ❌ = FAILED heading+verdict. Warnings alone ≠ failure. Structural PASS alone cannot produce overall PASS. Overall PASS requires checks 1–16 to have run, complete adversarial evidence, explicit remaining-risk @@ -2229,6 +2253,15 @@ description: Accept the implementation plan (whole or per phase) and lock it for `/squad plan accept implementation` [phase {N}] — locks the implementation plan (the HOW). Supports incremental phase acceptance. +**Phase-specific command:** use the `squad-plan-accept` trusted `activate_plan` +procedure only; do not emit separate acceptance, lifecycle or create operations. +The trusted writer binds the current implementation revision and selected phase, +requires the existing scope acceptance and matching passed validation (all sixteen +checks plus authoritative sizing/traceability), validates the prior-phase mapping +bridge, then records implementation-phase acceptance and creates/finalizes only +that phase. Missing/stale prerequisites fail before creation. Retries never +recreate prior work. The remaining steps below apply to whole-plan acceptance. + **Acknowledge:** `🤖 Squad is creating implementation tasks…` ##### Step 1: Validate @@ -2262,310 +2295,20 @@ Both include: impl plan link, scope acceptance link, accepted by, date, counts. Phase: `🔄 Phase {N} of {total}`. Full: `✅ Done`, next = `/squad plan activate`. -##### Step 5: Auto-Activate (Phase-Specific Only) - -After phase acceptance, check if ready for automatic activation: -1. All prior phases must be accepted AND activated (check the latest `phases-activated` artifact's `phases` array). -2. If Phase 1 or all prior activated: auto-activate using Plan Activate logic for this phase. -3. If prior phases not activated: tell user to activate them first. -4. Update lifecycle to reflect both acceptance and activation. +Whole-plan acceptance remains acceptance-only; next use `/squad plan activate`. +Phase acceptance and activation use the single trusted path above, never a +second native create path or model-authored Activated lifecycle. ## skill: `squad-plan-activate` --- -description: Activate an accepted plan by creating the epic and task issues on GitHub. +description: Activate an accepted implementation plan through the trusted deterministic writer. --- -`/squad plan activate` [phase {N}] — creates real GitHub issues/milestones. Irreversible. - -**Acknowledge:** Phase: `🤖 Squad is activating Phase {N}…` Full: `🤖 Squad is activating the team…` - -##### Step 1: Validate - -**Phase-specific:** -1. Check the latest `impl-phases-accepted` artifact's `phases` array contains requested phase. If not: stop. -2. Check ordering: prior phase must be in the latest `phases-activated` artifact's `phases` array. If not: stop. -3. Check not already activated. - -**No phase:** -1. Find an `impl-accepted` artifact or fully accepted `impl-phases-accepted` state. If none: stop. -2. Check for an `activated` artifact: if it exists, only create missing issues (idempotent). - -##### Hallucination Guard - -`create-issue` does **not** return a real GitHub issue number during this run — gh-aw defers -creation to the safe-output job, so the agent sees only a success acknowledgement. NEVER -predict, infer, or "read back" a number for an issue this run created, and never wait for -one. Use a real number only when verified independently: a pre-existing issue matched by -title, the triggering issue, or one recorded in a prior run's artifact. - -##### Temporary-ID Contract - -Every `create-issue` call MUST carry a `temporary_id`, and every operation pointing back at -that issue MUST reuse the identical value. `require-temporary-id: true` enforces the first -half — a call without one is rejected. The second half is yours. - -**Form.** Matches `^#?aw_[A-Za-z0-9_]{3,12}$`. Write the canonical `#aw_…` form. Dots, -hyphens, spaces, and `:` are illegal. - -**Minting — derive, never invent.** Epic: `#aw_epic{K}`, `{K}` = the epic's 1-based position -in the accepted plan. Task: `#aw_task{N}`, `{N}` = that task's own `#` cell with every -character outside `A-Za-z0-9` replaced by `_` (`2.3` → `#aw_task2_3`). If a derived ID -exceeds 12 characters after `aw_`, drop the `epic`/`task` word (`#aw_e{K}` / `#aw_t{N}`) -rather than truncating the number. - -**Uniqueness is your responsibility.** gh-aw does not reject a duplicate `temporary_id`; it -silently lets the last `create-issue` using it own the mapping, so every later reference -lands on the wrong issue. Confirm each ID is unused; epics and tasks share one namespace. - -**Explicit targeting is mandatory.** Every `add_labels` call MUST pass `item_number`. Omitting -it does not fail — it silently labels the **triggering intent issue**, branding the user's own -request with an activated item's owner label. - -**Existing and reused issues.** An issue recognized by Step 1's idempotent-rerun path or a -dedup-by-title match has a real, verified number: target it by that number, not a temporary -ID, which only maps issues this run created. - -##### Output Budget Awareness - -Count expected issues before starting. If total > 50: recommend phased activation (`/squad plan activate phase {N}`) and proceed with the current phase only. If total > 30: use compact issue bodies (scope + acceptance criteria only; omit elaboration). - -**Activation capacity budget.** The largest activation supported in a single run is -**50 issues** — the `enterprise` profile's `max_issues: 50` (the highest documented -limit) and the threshold the phased-activation rule above enforces. Worst case: - -| Safe output | Worst case | `max` | -|---|---|---| -| `create-issue` | 50 — one per epic/task | 75 | -| `add_labels` | 50 — one per created issue | 110 | -| Labels in one call | 2 — `squad` + `squad:{agent}` | 10 | -| Label names per run | 100 — 50 × 2 | 110 | - -**`max` limits safe-output items (tool calls), not label names inside a call.** One -`add_labels` call carrying two labels consumes **one** unit of budget, not two. gh-aw's -injected constraint now states "Maximum 110 add_labels call(s) allowed" and a maximum -of 10 labels per call. The existing 110 budget also covers the older compiler's -ambiguous label-name reading — 50 calls and 100 names — so neither can justify -skipping a label operation. Never batch several issues' labels into one call to save -budget (it breaks per-issue correspondence), and never stop labeling early. - -**Reaching a cap is enforced twice, and neither layer fails the run.** A call past the -limit is rejected at invocation time with a JSON-RPC error (`E002: {type} limit -reached`) the agent *does* see; a surplus item is dropped at collection time with a -warning. Neither marks the run failed, so it can finish green with labels missing — -Step 2e, not the cap machinery, is what notices. - -##### Label Pre-flight - -**Roster binding gate — run this before any `create-issue` call.** - -1. Run Team Guard Step TG-2; its `ROSTER_MEMBER:` lines are the **certified roster - set** — the only valid source for a `squad:{agent-slug}` label this run. Do not re-read - team.md or recall a name. -2. If TG-2 emitted a `ROSTER_UNREADABLE:` line, STOP: report that named reason in the - activation summary and mint no `squad:{agent-slug}` label. Never print a roster-provenance - sentence for a read that did not happen, and never fall back to a preset or - remembered roster. -3. Reproduce the certified `ROSTER_MEMBER:` lines verbatim in the summary as the - provenance of the labels applied — the summary may name only values TG-2 emitted. -4. For every roster `Agent`, require the plan `Agent ID` to exactly match the - same `AGENT_IDENTITY:` record; stop before mutation on mismatch and never - substitute an ID. Then certify its lowercased raw value against a - `ROSTER_MEMBER:` name and mint `squad:{agent-slug}` by replacing each run of - non-`a-z0-9` characters with `-` and trimming leading and trailing `-`. The special - value `@copilot` maps to the existing `squad:copilot` routing label — never - `squad:@copilot`. -5. A value matching no certified name and not `@copilot` MUST NOT become a - `squad:{agent}` label: apply only `squad` for that issue and record the value under a - `Non-roster agent values` heading, naming the certified set it should come from. - `{agent}` is the rejected raw value; emitted labels use `{agent-slug}`. -6. Completeness: when the plan names at least one roster `Agent`, at least one - `squad:{agent-slug}` label MUST be applied across the created issues. Zero labels on a - plan with roster owners is a binding failure, not a pass — report it, don't proceed - silently. -7. **Correspondence — the label must match *this* issue's own row.** Steps 4-6 certify the - *vocabulary*: that each value is a roster Name. They do not check that the right issue - received the right value. **A label can be simultaneously certified and wrong.** Before - each `create-issue` call, re-read the agent from that issue's own source — a task's own - `Agent` cell, an epic's derived task-set — and never from the row above it, the parent - epic, or the previous call. Verify per issue; membership across the run is not evidence. -8. **Report what was accepted, not what was intended.** The activation summary may name a - `squad:{agent-slug}` label for an issue only after an `add_labels` call carrying that label - was accepted for that same issue — targeted by its own `temporary_id`, or by its verified - real number for a reused issue. A successful `create-issue` is **not** evidence: its - `labels:` field cannot land a label on a fresh repository, so a label is never "carried - by" issue creation. Never state a label that was skipped, omitted, deferred, or assumed, - and never report an accepted label operation as an omission. Whenever an `Agent` value did - not become a label — multi-owner epic, uncertified name, unavailable label — the - `Non-roster agent values` heading is **required**, and must name the value and the issue - it applied to. Omitting the heading while omitting the label reports a clean run that did - not happen. See Step 4's Label operations accepted section for the full contract. - -**Label provisioning.** The `add-labels` safe output (`allowed: [squad, "squad:*"]`, -`create-if-missing: true`) auto-creates `squad` and any `squad:{agent-slug}` label the first -time this run needs it — a fresh repository with zero Squad labels requires no manual -provisioning and is never a prerequisite gap. `create-issue`'s own `labels:` field cannot -do this: GitHub silently drops label names that do not already exist in the target -repository instead of creating them, which is the behavior that previously produced the -"prerequisite gap" reported here. Do not rely on `create-issue`'s `labels:` field alone -to land a label on a fresh repository. - -In the same turn as each `create-issue` call in Steps 2b/2c, call `add_labels` with -`item_number` set to that call's `temporary_id` and exactly the label set Steps 4-8 computed -for that issue — `squad` alone, or `squad` plus the one `squad:{agent-slug}` label the -correspondence rule (Step 7) certified. Do not wait for a returned issue number; none -arrives. gh-aw resolves `add_labels` after the `create-issue` that minted the ID, so that -order is the supported one. `create-if-missing` creates any label that does not yet exist -before applying it; re-applying an already-present label on a rerun is a no-op, so this is -safe under the Step 1 idempotent-rerun path. Never emit `add_labels` for an item whose -`create-issue` call was not made in this run. - -##### Transient Failure Handling - -On `5xx` response from `create-issue`: wait briefly and retry once. On second failure or `4xx`: record the issue title as skipped in the activation summary, continue with remaining issues. Never abort the full run for a single transient failure. - -##### Sub-issue Fallback - -When setting a `parent` sub-issue relationship returns `404` or `422` (feature disabled or repo plan): degrade gracefully — record the intended parent as a body reference, then continue. If that parent was minted this run, write its temporary ID (`Parent: #aw_epic{K}`); gh-aw rewrites an `#aw_…` body reference to the real `#{number}`, so no number is predicted. If the parent is pre-existing or was matched by dedup, write its verified real number (`Parent: #{issue_number}`) — gh-aw leaves an unresolved `#aw_…` reference in the body verbatim, so a temporary ID it never minted would ship as a meaningless literal. Never fail activation over sub-issue API unavailability. - -##### Step 2: Create Issues — Full Hierarchy - -Root → Epics → Tasks. Phase-specific: filter to matching phase heading. - -**2a. Create Milestones:** Check for existing, create missing. Record IDs. On failure: document in root issue body instead. - -**2b. Create Epic Issues:** `create-issue` per epic (dedup by title `[Epic] {name}` if already exists from prior phase). -- Title: `[Epic] {name}` -- Temporary ID: `temporary_id: "#aw_epic{K}"` per the Temporary-ID Contract. Required — the call is rejected without it. -- Labels: `squad` (0075ca), `squad:{agent-slug}` (e4e669) where `{agent-slug}` is **derived from this epic's own tasks**: collect the `Agent` values of every implementation-plan row whose `Epic` cell names this epic. Exactly one distinct roster value → mint its label using the lowercase/non-alphanumeric/hyphen slug rule above; exactly `@copilot` → mint `squad:copilot`. Two or more → multi-owner epic: apply only `squad` and record it under `Non-roster agent values`. Never mint a single agent label for a multi-owner epic, and never choose one of several. -- Body: outcome, stories, epic-level acceptance criteria, context (parent, initiative, milestone, deps) -- Parent: sub-issue of root intent issue — the triggering issue's own real number, which is known independently of this run's creations -- Milestone: assigned -- Label application: in the same turn as this `create-issue` call, call `add_labels` with `item_number` set to this epic's `#aw_epic{K}` temporary ID and this epic's computed label set (see Label Pre-flight). A dedup-by-title match instead targets that existing issue's verified real number. `create-if-missing` provisions `squad`/`squad:{agent}` on a fresh repository automatically. - -**⚠️ DO NOT STOP after epics. Tasks MUST follow immediately.** - -**2c. Create Task Issues:** `create-issue` per task in dependency order. - -> **⚠️ ATOMIC CONTRACT — strictly one task at a time:** -> For each task: compose ONLY that task's body → call `create-issue` immediately, carrying that task's `temporary_id` → call `add_labels` with `item_number` set to that same temporary ID and the task's computed label set → then move to the next task. -> **DO NOT** compose or buffer multiple task bodies before making calls. One compose → one `create-issue` call → one `add_labels` call, repeated per task. Do not pause for a returned issue number between the two calls; none is returned. - -- Title: task title -- Temporary ID: `temporary_id: "#aw_task{N}"` per the Temporary-ID Contract. Required, and unique across every epic and task in this run. -- Labels: `squad` (0075ca), `squad:{agent-slug}` (e4e669) where `{agent-slug}` is derived from **this task's own `Agent` cell** using the same lowercase/non-alphanumeric/hyphen slug rule as label synchronization — read from the implementation-plan row whose `#` matches this task. Map `@copilot` to `squad:copilot`. Never inherit the parent epic's agent, and never carry the previous task's value forward: re-read the `Agent` cell for every task, because consecutive tasks under one epic routinely have different agents. No `size:*` labels unless policy says so. -- Body: one sentence describing scope; 1-2 acceptance criteria; one compact context line (parent epic, size, deps) -- Parent: sub-issue of EPIC (not root). If 2b minted this epic in this run, pass its `#aw_epic{K}` temporary ID, which `create-issue`'s `parent` field accepts. If 2b instead matched a pre-existing epic by title, that epic has no temporary ID in this run — pass its verified real number. Never guess the epic's real number, and never pass a temporary ID that was not minted this run. -- Milestone: same as parent epic -- Size: Project field if available, else body line -- Label application: same as epics — call `add_labels` with `item_number` set to this task's temporary ID and the task's computed label set (see Label Pre-flight). `create-if-missing` provisions `squad`/`squad:{agent}` on a fresh repository automatically. - -**2d. Self-Validation:** The **created count** is the number of `create-issue` calls this run emitted. Compare it against the plan's declared total (not the safe-output cap). If it is lower: call `report_incomplete` immediately with `created={N}` set to that created count, `expected={M}` set to the declared total, and the last task's temporary ID — never noop, and never substitute a guessed issue number. Post: `N of M issues created so far — rerun the identical activation command to continue.` Re-runs are idempotent via title match. Never surface the `create-issue` or `add-comment` safe-output caps as a guessed reason for a partial run — name a cap only when Step 2e observed one actually being reached. - -**2e. Label-Operation Reconciliation — no activation is complete until every activated item's labels are accounted for.** - -While Steps 2b/2c run, keep two counts: `activated` (issues created or recognized this run) and `labeled` (issues whose `add_labels` call was accepted). An `add_labels` call that was never made, was rejected, or returned an error counts as **unlabeled**. These counts track *label operations*, not labels present on GitHub: acceptance means the call was queued for a specific target this turn, and gh-aw applies it in the post-agent job. Never state or imply that a counted label was applied, landed, or was confirmed on the issue — nothing here reads labels back. At the end of Step 2: - -1. `labeled == activated` → the activation is complete; proceed to Step 3. -2. `labeled < activated` → **this is an incomplete activation, not a successful one.** Call `report_incomplete` with a `reason` naming the shortfall (`{labeled} of {activated} activated issues had a label operation accepted`) and `details` listing **every affected work item — the identifier you used to target its `add_labels` call, its title, and the label set that missing operation targeted**. For an item created this run that identifier is the `temporary_id` you minted under the Temporary-ID Contract (`#aw_epic{K}` / `#aw_task{N}`) — not a GitHub issue number, because creation is deferred to the safe-output job and no real number exists yet. Quote a real number only where one is independently verified: an epic or task matched by dedup-by-title, or an issue recognized by Step 1's idempotent-rerun path. Never predict, infer, or invent a number. `report_incomplete` records the reason in Actions logs and fails the conclusion step; automatic tracking-issue reporting is disabled. Include the shortfall in the guarded activation summary as well, so the originating issue retains an actionable signal. - -**Cap exhaustion is a reportable, nameable cause.** If the shortfall is because a cap was reached, say so in the `reason`, name which cap (`create-issue` 75 or `add_labels` 110) and list the work items that did not fit, and recommend `/squad plan activate phase {N}`. This is the one case where a cap may be named as the cause: it was observed, not guessed. Do not infer a cap from a rejection you never received, and do not treat the absence of an `E002` error as proof that every label operation was accepted: the count comparison, not the error stream, is the authority. - -**Never report a clean activation you did not perform.** An `activated` or `phases-activated` artifact listing every item as activated while `labeled < activated`, or omitting the shortfall, is a false success report — the silent truncation this step exists to prevent. - -Labels must have descriptions and intentional colors when they already exist in the -repository. A label auto-provisioned by `add-labels`'s `create-if-missing` on a fresh -repository instead receives gh-aw's deterministic color and an empty description — that -is expected, not a failure, and must not be reported as one. - -##### Step 3: Native Dependency Edges - -Declare `blocked_by` on the `create-issue` call itself, passing the blocking item's -temporary ID (`#aw_task{N}` / `#aw_epic{K}`) — `blocked_by` resolves temporary IDs. Use a -verified real number only for a dependency on a pre-existing issue. Never call a write API -with a guessed number to add an edge. Graceful fallback to a body reference. Never fail -activation over edge creation. - -##### Step 4: Post Activation Record - -**LAST action** — only after Steps 2+3 complete. - -Phase artifact: `data: {"squad_artifact":"phases-activated","schema_version":"1","origin_issue":{issue_number},"phases":[{accumulated}]}` → `## ✅ Phase {N} Activated — {count} issues` + issue table + remaining phases table. - -Every phase and full activation artifact body MUST include an `Activation bindings:` fenced JSON block containing a non-empty array built only from accepted activation operations. Each row is a `squad-work-agent-binding/v1` producer record. Copy `$GITHUB_REPOSITORY`, the current origin issue, artifact kind, and TG-2 registry revision exactly. Emit one object per created/recognized task: - -`{"binding_schema":"squad-work-agent-binding/v1","binding_version":1,"producer":"squad","repository":"{owner/repo}","origin_issue":{origin issue},"artifact":"{artifact kind}","registry_schema":"squad-agent-provenance/v1","registry_revision":{TG-2 revision},"task":"{plan # cell}","issue":"{task issue reference}","epic":"{Epic cell}","epic_issue":"{epic issue reference}","agent_id":"{Agent ID cell}","epic_agent_ids":["{all distinct Agent ID cells for this epic}"],"agent":"{raw Agent cell}","epic_agents":["{all distinct lowercased Agent cells for this epic}"],"label":"squad:{slugged Agent cell}","epic_label":"squad:{slugged sole epic task agent}"}`. Slug label fields only. Every binding for one epic carries the same complete ID/name sets across all phases. - -For `@copilot` or non-roster work, set `agent_id:null` plus `identity_omission_reason:"external-agent"` or `"non-roster"`. For an older accepted plan with no ID column, use `"legacy-plan-missing-id"`; never reconstruct one from its name or label. Exclude unavailable IDs from `epic_agent_ids` and add `epic_identity_omission_reason:"partial"` when any epic task lacks an ID. - -###### Issue references in bindings — quoted, never bare - -`issue` and `epic_issue` are **JSON strings**, never bare numbers, and never a real number -for an issue this run created. - -- **Created this run:** that item's own `temporary_id`, quoted — `"issue":"#aw_task{N}"`, - `"epic_issue":"#aw_epic{K}"`. gh-aw rewrites an `#aw_…` reference in a comment body to - `#{real number}` once the issue exists, so the posted artifact carries the real number - without this run predicting one. -- **Reused or pre-existing** (Step 1 idempotent rerun, dedup-by-title): its verified real - number in the same quoted form — `"issue":"#123"`. One shape covers both. - -**The quoting is load-bearing.** gh-aw's substitution is a plain text replacement over the -whole body — it does not skip fenced code blocks — and it keeps the `#`. Bare, -`"issue":#aw_task1` becomes `"issue":#42`, which is invalid JSON and fails the whole block. -Quoted, `"issue":"#aw_task1"` becomes `"issue":"#42"`, which parses. Never emit a bare -`#aw_…`, a bare number, or a `{created task issue number}` placeholder in these two fields. - -An `#aw_…` surviving into the posted artifact was never resolved — that `create-issue` did -not land. Leave it rather than repairing it by hand: the consumer fails closed on it, which -is correct. - -For a multi-owner epic, omit `epic_label` and set `"epic_omission_reason":"multi-owner"` on each of its task bindings. For a task whose agent is not certified by TG-2, omit `label` and set `"omission_reason":"non-roster"`; if that task is the epic's sole owner, likewise omit `epic_label` and set `"epic_omission_reason":"non-roster"`. Never omit a created task from `bindings`, never infer an issue number, and never emit an empty array. The deterministic post-activation workflow treats missing, empty, malformed, or unresolved bindings as a failure. The safe-output schema deliberately uses one uniform task-binding shape because gh-aw's data schema dialect does not support conditional `if`/`then` or `allOf`; the checker enforces activation-only presence and cross-row epic consistency. - -Phase artifact: `data: {"squad_artifact":"phases-activated","schema_version":"1","origin_issue":{issue_number},"phases":[{accumulated}]}` → `## ✅ Phase {N} Activated — {count} issues` + issue table + remaining phases table + the `Activation bindings:` JSON array. - -Full artifact: `data: {"squad_artifact":"activated","schema_version":"1","origin_issue":{issue_number},"phases":[]}` → `## ✅ Plan Activated — {epic_count} epics, {task_count} tasks` + hierarchy summary, created epics table, created tasks table, dependency order + the `Activation bindings:` JSON array. - -Terminal (last phase): emit `data: {"squad_artifact":"activated","schema_version":"1","origin_issue":{issue_number},"phases":[{all_phases}]}` with an "All Phases Activated" heading and the accumulated `Activation bindings:` JSON array. - -###### Label operations accepted - -A label reaches an activated issue through exactly one route: an accepted `add_labels` -operation targeting that issue. `create-issue`'s `labels:` field never lands a label this -workflow can claim — it silently drops names the repository lacks — so it is never evidence. - -**The rule.** Report `squad:{agent}` for an issue only when this run made an `add_labels` -call that carried that label and targeted that same issue — by its own `temporary_id`, or by -its verified real number for a reused issue. Every `label` and `epic_label` in the bindings -block, and every label named in the prose or issue tables, MUST trace to such a call. - -**Forbidden:** reporting a label because `create-issue` succeeded or its `labels:` field -named it (a successful `create-issue` means an issue was requested — nothing more); reporting -a label that was computed or intended but whose `add_labels` call was never made, was -skipped, or was rejected; reporting a label from another item's `add_labels` call -(per-issue correspondence holds exactly as in Label Pre-flight Step 7); and reporting an -omission when that item's call was in fact made and accepted — a silent under-claim is as -wrong as an over-claim. - -**What "accepted" means.** `add_labels` is a safe output: the call is accepted and queued -this turn, and gh-aw applies it in the post-agent job. This run has evidence only that the -operation was accepted *for a specific target*, never the GitHub API result. State it at -that strength — never write that a label was "verified", "confirmed on the issue", or -"checked", because nothing here reads labels back. The deterministic post-activation checker -compares these bindings against the labels actually present; over-claiming defeats it. - -**Omission is reported, never inferred.** Whenever an `Agent` value did not become a -`squad:{agent}` label — multi-owner epic, uncertified name, or a label operation not made or -not accepted — the `Non-roster agent values` heading is **required**, naming the value and -the issue it applied to, and the matching binding carries its `omission_reason` / -`epic_omission_reason`. Applying bare `squad` while omitting the heading reports a clean run -that did not happen. Conversely, never emit the heading for an owner that *did* become an -accepted label — that manufactures a defect. - -##### Step 5: Update Lifecycle - -Phase: `🔄 Phase {N} of {total} activated`. Next: accept/activate next phase. -Full/last: `✅ Done`, state = Activated, last command = invoked -`/squad plan activate`. Terminal — use terminal prose for Next action. +Use the same `activate_plan` procedure and no-create recovery rules as +`squad-plan-accept` above. The latest implementation plan must have explicit +stable task IDs, Agent IDs and Epic IDs, and prior scope/implementation acceptance. +The model emits one `activate_plan` request only. The trusted writer owns +all issue creation, binding finalization and lifecycle publication. ## end skill: `squad-plan-activate`