From 4709920b2bece5e7a1b142a5fc7bfba765ed680a Mon Sep 17 00:00:00 2001 From: Brady Gaster Date: Sun, 11 Oct 2026 19:32:39 -0700 Subject: [PATCH 01/11] fix(gh-aw): finalize activation with validated actual issue bindings Add deterministic activation admission/finalization, explicit current-state no-create reconciliation, safe phase bridges and authenticated consumer selection. Preserve invalid source records and fail closed on ambiguous writes or ancestry.\n\nCloses #2203 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 9370fe46-c2d1-486c-ab53-dc21c7f4d0fb --- .changeset/activation-reconciliation.md | 23 + .../workflows/squad-agent-binding-check.yml | 3 +- .github/workflows/squad-ci.yml | 2 +- docs/src/content/docs/guide/gh-aw.md | 252 +-- .../squad-sdk/src/casting/agent-provenance.ts | 553 ++++++- packages/squad-sdk/src/casting/index.ts | 6 + scripts/check-agent-binding.mjs | 761 +-------- scripts/gh-aw-hosted-e2e.mjs | 8 +- test/agent-provenance.test.ts | 908 ++++++++++- ...ivate-fast-path-label-provisioning.test.ts | 557 +++---- ...h-aw-activation-artifact-integrity.test.ts | 324 ++-- test/gh-aw-activation-capacity.test.ts | 625 +++----- test/gh-aw-activation-finalization.test.ts | 1281 +++++++++++++++ ...h-aw-activation-label-provisioning.test.ts | 590 +++---- .../gh-aw-activation-summary-outcomes.test.ts | 736 ++++----- test/gh-aw-activation-temporary-ids.test.ts | 394 ++--- ...gh-aw-agent-binding-correspondence.test.ts | 327 ++-- test/gh-aw-command-authorization.test.ts | 100 +- test/gh-aw-hosted-e2e.test.ts | 2 +- test/gh-aw-lifecycle-upsert.test.ts | 30 +- test/gh-aw-plan-lifecycle.test.ts | 403 +++-- test/gh-aw-quality.test.ts | 486 ++++-- test/helpers/gh-aw-diagnostic-runtime.ts | 1 + workflows/aw.yml | 6 + workflows/package/squad-bootstrap.md | 2 +- workflows/package/squad-deps-worker.md | 13 +- workflows/package/squad-implement-worker.md | 13 +- workflows/package/squad-retro.md | 13 +- workflows/package/squad.md | 856 ++++------ workflows/shared/check-agent-binding.mjs | 1123 +++++++++++++ workflows/shared/squad-activation.mjs | 1410 +++++++++++++++++ .../shared/squad-agent-binding-check.yml | 43 + workflows/shared/squad-command-contract.mjs | 42 + workflows/shared/squad-install-verifier.mjs | 5 +- workflows/shared/squad-planning-ontology.md | 61 +- workflows/shared/squad.md | 12 +- workflows/squad-bootstrap.md | 2 +- workflows/squad-workflows.manifest.json | 82 +- workflows/squad.md | 778 +++------ 39 files changed, 8537 insertions(+), 4296 deletions(-) create mode 100644 .changeset/activation-reconciliation.md create mode 100644 test/gh-aw-activation-finalization.test.ts create mode 100644 workflows/shared/check-agent-binding.mjs create mode 100644 workflows/shared/squad-activation.mjs create mode 100644 workflows/shared/squad-agent-binding-check.yml diff --git a/.changeset/activation-reconciliation.md b/.changeset/activation-reconciliation.md new file mode 100644 index 000000000..3b9d2e793 --- /dev/null +++ b/.changeset/activation-reconciliation.md @@ -0,0 +1,23 @@ +--- +"@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. +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. diff --git a/.github/workflows/squad-agent-binding-check.yml b/.github/workflows/squad-agent-binding-check.yml index 6373c5653..8bd88d255 100644 --- a/.github/workflows/squad-agent-binding-check.yml +++ b/.github/workflows/squad-agent-binding-check.yml @@ -34,8 +34,9 @@ jobs: 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 ?? '')) + && /"squad_artifact"\s*:\s*"?(?:phases-activated|activated|phases-accepted|plan-accepted|activation-reconciled)/i.test(comment.body ?? '')) .map(comment => ({ + id: comment.id, body: comment.body, issue: Number(comment.issue_url.split('/').at(-1)), })); diff --git a/.github/workflows/squad-ci.yml b/.github/workflows/squad-ci.yml index 80a603917..3073bedb0 100644 --- a/.github/workflows/squad-ci.yml +++ b/.github/workflows/squad-ci.yml @@ -788,7 +788,7 @@ 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 ' ')" = 20 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..be7cf9d11 100644 --- a/docs/src/content/docs/guide/gh-aw.md +++ b/docs/src/content/docs/guide/gh-aw.md @@ -1377,6 +1377,124 @@ 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. +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. + +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. + +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. 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. + ### Granular lifecycle ``` @@ -1574,108 +1692,50 @@ 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. - -> **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. +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. -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) | +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. -`max` counts safe-output **items (tool calls)**, not label names, and it is -enforced at invocation and collection — neither enforcement fails the run. - -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 +1757,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 +1775,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 +1798,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 diff --git a/packages/squad-sdk/src/casting/agent-provenance.ts b/packages/squad-sdk/src/casting/agent-provenance.ts index c7cb84763..eda519b0b 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,55 @@ export interface WorkAgentBindingContext { repository: string; originIssue: number; artifact: WorkAgentBinding['artifact']; + /** Full frozen-plan assignments supplied independently by the trusted verifier. */ + epicAssignments?: readonly EpicAssignment[]; +} + +export interface EpicAssignment { + task: string; + epic: string; + agent: string; + agentId: string | null; +} + +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'; +} + +export interface ActivationSelectionContext { + repository: string; + originIssue: number; + reconciliations: readonly ReconciliationEvidence[]; + /** Full frozen-plan assignments, never authorization claimed by an artifact. */ + epicAssignments?: readonly EpicAssignment[]; +} + +export interface SelectedActivationRecord { + id: number; + artifact: WorkAgentBinding['artifact']; + bindings: WorkAgentBinding[]; } export class AgentProvenanceError extends Error { @@ -97,6 +148,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 +307,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 +362,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 +375,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 +412,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 +473,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 +513,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 +544,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 +608,374 @@ 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'); + } + 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'); + } +} + +/** + * 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); + // A pending reservation is never an acceptance, including any prior claims. + try { + const pending = readPendingSourceBody(record.body); + if (isRecord(pending) && pending.schema === 'squad-activation-pending/v1') continue; + } catch { + // Non-pending comments still undergo the normal activation classification. + } + const reconciliationMarker = /Activation reconciliation:|activation-reconciled/.test(record.body); + const activationMarker = reconciliationMarker || /Activation bindings:/.test(record.body); + if (!/Structured data:/.test(record.body) && !activationMarker) continue; + let envelope: unknown; + try { + envelope = readActivationSection(record.body, 'Structured data'); + } catch (error) { + if (activationMarker || /activated|plan-accepted|phases-accepted/.test(record.body)) throw error; + continue; + } + const knownArtifact = isRecord(envelope) + && ACTIVATION_ARTIFACTS.includes(envelope.squad_artifact as WorkAgentBinding['artifact']); + if (!knownArtifact && !activationMarker) continue; + if (!reconciliationMarker && isRecord(envelope) && knownArtifact + && envelope.squad_artifact !== 'activation-reconciled' + && isPositiveSafeInteger(envelope.origin_issue) && envelope.origin_issue !== context.originIssue) { + continue; + } + const artifact = activationEnvelope(envelope, context.originIssue); + if (reconciliationMarker && artifact !== 'activation-reconciled') { + throw new AgentProvenanceError('Reconciliation requires activation-reconciled artifact'); + } + 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 sourceArtifact = activationEnvelope( + readActivationSection(source.body, 'Structured data'), context.originIssue, + ); + 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 (usedRecords.size !== records.length || 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) => after.includes(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..02d1ff977 100644 --- a/test/agent-provenance.test.ts +++ b/test/agent-provenance.test.ts @@ -1,12 +1,24 @@ +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 { 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 +50,900 @@ 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' }])) { + 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('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().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(); + 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(); + 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()); + 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, 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('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('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..fc86b3abb 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,256 @@ /** - * 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) |'); - }); - - 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); - }); - - 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('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'); - }); -}); - -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); - }); - - 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('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); + 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('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, - ); - }); + 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('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.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('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); + 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 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\}`/); +describe('gh-aw: frozen fast-path owner and actual-ID correspondence', () => { + 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('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('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('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('rejects duplicate stable IDs rather than last-writer-wins mappings', () => { + expect(() => parse(table([...rows, rows[0]]))).toThrow(/stable task IDs.*duplicated/); }); - 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.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('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('refuses an unreadable roster rather than treating it as an empty certified set', () => { + expect(() => parse(table(rows), 'not a roster')).toThrow(); }); - 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('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('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('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('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('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('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.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('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('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('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..739f081b9 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,7 +635,7 @@ 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); @@ -648,7 +646,7 @@ describe('gh-aw: squad-agent-binding-check.yml collector passes fast-path artifa it('the embedded 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}`, 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..9062291a7 --- /dev/null +++ b/test/gh-aw-activation-finalization.test.ts @@ -0,0 +1,1281 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { randomUUID } from 'node:crypto'; +import { mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { + buildActivationBindings, + correctDependencyReferences, + createActivation, + digest, + finalizeActivation, + frozenAssignments, + prepareActivation, + reconcileActivation, + selectionEvidence, + validateReceiptSet, + verifyReconciliationRequest, + verifyPublishedActivation, +} from '../workflows/shared/squad-activation.mjs'; +import { + parseStructuredData, + 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'; + +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; + } = {}; + 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); + 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, + }); + 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, + }; +} + +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}`])); + expect(f.writes[0].method).toBe('createComment'); + expect(f.writes[0].params.body).toContain('squad-activation-pending/v1'); + 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, + })).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, + })).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.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 }; +} + +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('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(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 }>) { + const input = join(f.root, 'comments.json'); + writeFileSync(input, JSON.stringify(comments.map(({ id, body }) => ({ id, body, issue: 42 })))); + 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/', ''); + let data; + if (path === 'issues/42/comments') data = [...f.comments.values()]; + 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 }); + })); + 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 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', () => { + 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('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..36d7072c2 100644 --- a/test/gh-aw-activation-label-provisioning.test.ts +++ b/test/gh-aw-activation-label-provisioning.test.ts @@ -1,309 +1,367 @@ /** - * 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, + }); + 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('does not create any issue when missing-label provisioning fails', async () => { + const f = fixture(); + f.api.createLabel.mockRejectedValueOnce(Object.assign(new Error('Permission denied'), { status: 403 })); + await expect(f.create(await f.prepare())).rejects.toThrow('Permission denied'); + expect(f.createIssue).not.toHaveBeenCalled(); + expect(JSON.parse(readFileSync(f.journalPath, 'utf8')).started).toEqual([]); + }); - 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..8e5033004 100644 --- a/test/gh-aw-activation-summary-outcomes.test.ts +++ b/test/gh-aw-activation-summary-outcomes.test.ts @@ -1,416 +1,438 @@ /** - * 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, + }); + 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..e74e671de 100644 --- a/test/gh-aw-command-authorization.test.ts +++ b/test/gh-aw-command-authorization.test.ts @@ -9,6 +9,7 @@ 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'; interface Step { name?: string; @@ -18,7 +19,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 +84,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 +269,102 @@ async function writeComments(items: Item[]) { } describe('compiled Squad command authorization boundary (#2185)', () => { + 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('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'); diff --git a/test/gh-aw-hosted-e2e.test.ts b/test/gh-aw-hosted-e2e.test.ts index 1da5390e0..48a4f7866 100644 --- a/test/gh-aw-hosted-e2e.test.ts +++ b/test/gh-aw-hosted-e2e.test.ts @@ -258,7 +258,7 @@ describe('Squad gh-aw hosted E2E controller', () => { const contract = loadBundleContract(ROOT); expect(contract.package).toBe('bradygaster/squad/workflows'); expect(contract.workflows).toHaveLength(8); - expect(contract.runtime).toHaveLength(17); + expect(contract.runtime).toHaveLength(20); expect(contract.skills).toHaveLength(1); expect(contract.triggerProbe).toBe('shared/squad-bootstrap-trigger-probe.json'); expect(() => loadInstalledBundleContract(resolve(ROOT, 'does-not-exist'))).toThrow(/missing or invalid/); diff --git a/test/gh-aw-lifecycle-upsert.test.ts b/test/gh-aw-lifecycle-upsert.test.ts index c4cea7fc0..35b3873c3 100644 --- a/test/gh-aw-lifecycle-upsert.test.ts +++ b/test/gh-aw-lifecycle-upsert.test.ts @@ -560,31 +560,33 @@ describe('#1916: deterministic lifecycle safe output', () => { expect(result.created[0].body).toContain(issueHeadingBody); }); - it('accepts non-command guidance for the terminal Activated state', async () => { + it('rejects model terminal activation even with otherwise valid terminal guidance', async () => { const result = await runLifecycleUpsert([ { type: 'upsert_lifecycle_state', body: terminalBody }, ]); - expect(result.failures).toEqual([]); - expect(result.created).toHaveLength(1); - expect(result.created[0].body).toContain(terminalBody); + expect(result.failures).toEqual(['Activated lifecycle belongs exclusively to the trusted activation finalizer.']); + expect(result.created).toEqual([]); + expect(result.updated).toEqual([]); }); it.each([ - ['progress-table activation with command attribution', terminalTableBody], - ['plain progress-list activation', terminalProgressBody], - ['activate-stage table row', terminalActivateTableBody], - ['scope acceptance from the E2E planning run', liveScopeAcceptanceBody], - ['implementation acceptance with separate guidance', liveImplementationAcceptanceBody], - ['terminal granular activation from run 34987615314', liveActivatedBody], - ])('accepts live lifecycle presentation: %s', async (_name, liveBody) => { + ['progress-table activation with command attribution', terminalTableBody, true], + ['plain progress-list activation', terminalProgressBody, true], + ['activate-stage table row', terminalActivateTableBody, true], + ['scope acceptance from the E2E planning run', liveScopeAcceptanceBody, false], + ['implementation acceptance with separate guidance', liveImplementationAcceptanceBody, false], + ['terminal granular activation', liveActivatedBody, true], + ])('enforces trusted-finalizer ownership for lifecycle presentation: %s', async (_name, liveBody, terminal) => { const result = await runLifecycleUpsert([ { type: 'upsert_lifecycle_state', body: liveBody }, ]); - expect(result.failures).toEqual([]); - expect(result.created).toHaveLength(1); - expect(result.created[0].body).toContain(liveBody); + expect(result.failures).toEqual(terminal + ? ['Activated lifecycle belongs exclusively to the trusted activation finalizer.'] : []); + expect(result.created).toHaveLength(terminal ? 0 : 1); + if (!terminal) expect(result.created[0].body).toContain(liveBody); + expect(result.updated).toEqual([]); }); it('rejects non-command next actions for nonterminal states', async () => { diff --git a/test/gh-aw-plan-lifecycle.test.ts b/test/gh-aw-plan-lifecycle.test.ts index dc6934d71..c7eed688b 100644 --- a/test/gh-aw-plan-lifecycle.test.ts +++ b/test/gh-aw-plan-lifecycle.test.ts @@ -11,9 +11,13 @@ * regression produces a single failing criterion. */ -import { describe, expect, it } from 'vitest'; -import { readFileSync } from 'node:fs'; +import { afterAll, describe, expect, it, vi } from 'vitest'; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; +import { + createActivation, digest, finalizeActivation, parseActivationPlan, prepareActivation, +} from '../workflows/shared/squad-activation.mjs'; +import { parseStructuredData } from '../workflows/shared/check-agent-binding.mjs'; const WORKFLOWS_DIR = join(process.cwd(), 'workflows'); const SQUAD_WORKFLOW = join(WORKFLOWS_DIR, 'squad.md'); @@ -110,6 +114,126 @@ const team = [ ].join('\n'); const guide = readText(GH_AW_GUIDE); +const lifecycleWorkspace = join(process.cwd(), '.test-workspaces-plan-lifecycle'); +afterAll(() => rmSync(lifecycleWorkspace, { recursive: true, force: true })); +const stamp = '2026-01-01T00:00:00Z'; +const frozenRegistry = { + schema: 'squad-agent-provenance/v1', schema_version: 1, revision: 1, + generated_at: stamp, transaction_id: 'lifecycle-fixture', + agents: Object.fromEntries(['Architect', 'Builder', 'Writer'].map(name => [name.toLowerCase(), { + display_name: name, persistent_name: name, role: 'Engineer', universe: 'synthetic', + status: 'active', created_at: stamp, updated_at: stamp, + }])), +}; +const structured = (kind: string, phases: number[] = []) => + `Structured data:\n\`\`\`json\n${JSON.stringify({ + squad_artifact: kind, schema_version: '1', origin_issue: 42, phases, + })}\n\`\`\``; +function taskTable(rows = [ + '| 1 | Same title | Builder | builder | None |', + '| 2 | Same title | Architect | architect | 1 |', +], granular = false) { + return [ + `| # | Title | ${granular ? 'Agent' : 'Owner'} | Agent ID | Depends On |${granular ? ' Epic |' : ''}`, + `|---|---|---|---|---|${granular ? '---|' : ''}`, ...rows, + ].join('\n'); +} + +/** API doubles copy snapshots so changing current state never changes a frozen capture. */ +function activationFixture(body = taskTable(), command = 'activate', kind = 'plan') { + mkdirSync(lifecycleWorkspace, { recursive: true }); + const root = mkdtempSync(join(lifecycleWorkspace, 'runtime-')); + mkdirSync(join(root, '.squad/casting'), { recursive: true }); + const registryRaw = JSON.stringify(frozenRegistry); + const historyRaw = JSON.stringify({ + transaction_id: frozenRegistry.transaction_id, registry_revision: 1, + assignment_cast_snapshots: { genesis: { + created_at: stamp, agents: ['architect', 'builder', 'writer'], universe: 'synthetic', + } }, + universe_usage_history: [{ universe: 'synthetic', used_at: stamp }], + }); + 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: frozenRegistry.transaction_id, registry_revision: 1, + registry_sha256: digest(registryRaw), history_sha256: digest(historyRaw), + })); + writeFileSync(join(root, '.squad/team.md'), team); + const outputPath = join(root, 'request.json'); + writeFileSync(outputPath, JSON.stringify({ items: [{ type: 'activate_plan', plan_comment_id: '10' }] })); + const bot = { id: 41898282, login: 'github-actions[bot]', type: 'Bot' }; + const operator = { id: 700, login: 'operator', type: 'User' }; + 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 comment = (id: number, text: string) => ({ + id, body: text, user: bot, created_at: stamp, updated_at: stamp, + issue_url: 'https://api.github.com/repos/owner/repo/issues/42', + html_url: `https://github.com/owner/repo/issues/42#issuecomment-${id}`, + }); + const comments = new Map([[10, comment(10, `${body}\n${structured(kind)}`)]]); + const issue = (number: number, fields: 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}`, ...fields, + }); + const issues = new Map([[42, issue(42)]]); + const parents = new Map(); + const rest = { + repos: { getCollaboratorPermissionLevel: vi.fn(async () => ({ data: { permission: 'admin' } })) }, + issues: { + get: vi.fn(async ({ issue_number }: any) => ({ 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 () => ({ data: {} })), + 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 created = comment(1000 + comments.size, body); + comments.set(created.id, created); + return { data: structuredClone(created) }; + }), + 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 created = issue(100 + issues.size, params); + issues.set(created.number, structuredClone(created)); + return { staged: false, issue: created }; + }); + const prepare = () => prepareActivation({ + github, context, env, root, outputPath, capturePath: join(root, 'capture.json'), + }); + const create = (capture: any) => createActivation({ + github, context, env, root, capture, createIssue, captureArtifactId: 800, + journalPath: join(root, 'receipts.json'), sanitizeContent: (text: string) => text, + }); + const finalize = (capture: any, journal: any) => finalizeActivation({ + github, context, env, root, capture, journal, receiptArtifactId: 801, + }); + const artifacts = (kind: string) => [...comments.values()].filter(item => + parseStructuredData(item.body)?.squad_artifact === kind); + return { prepare, create, finalize, artifacts, comments, comment, issues, createIssue, github, context }; +} + // --------------------------------------------------------------------------- // team.md parsing — Name column vs Role column // --------------------------------------------------------------------------- @@ -226,10 +350,13 @@ describe('#1759: Owner/Agent bind to the cast Name column', () => { expect(block).toMatch(/every work item `Owner` MUST match one certified name/i); }); - it('squad-plan-accept mints squad:{owner} from the cast Name, not a role', () => { - const block = skillBlock(squad, 'squad-plan-accept'); - expect(block).toMatch(/certified\s+active roster name/i); - expect(block).toContain('`squad:{owner}`'); + it('trusted acceptance mints squad:{owner} from the cast Name, not a role', () => { + const plan = parseActivationPlan(taskTable(), 'plan', frozenRegistry, team); + expect(plan.tasks.map((task: any) => [task.agent, task.agent_id, task.label])).toEqual([ + ['Builder', 'builder', 'squad:builder'], ['Architect', 'architect', 'squad:architect'], + ]); + expect(() => parseActivationPlan(taskTable().replace('Builder | builder', 'lead | -'), + 'plan', frozenRegistry, team)).toThrow(/unknown non-roster owner/); }); it('squad-plan-implementation binds the Agent column to the cast Name', () => { @@ -242,7 +369,6 @@ describe('#1759: Owner/Agent bind to the cast Name column', () => { describe('#1903: fast-path planning binds certified roster owners end to end', () => { const plan = skillBlock(squad, 'squad-plan'); - const accept = skillBlock(squad, 'squad-plan-accept'); it('requires every fast-path Owner to be certified before posting', () => { const gate = plan.match(/3\. Use the `ROSTER_MEMBER:`[\s\S]*?(?=\n4\.)/)?.[0] ?? ''; @@ -256,45 +382,67 @@ describe('#1903: fast-path planning binds certified roster owners end to end', ( expect(finalCheck).toMatch(/Copy each row's `Depends On` value unchanged/i); }); - it('freezes each accepted row and derives its lowercase member label without remapping', () => { - const preflight = accept.match(/Before any `create-issue` call[\s\S]*?(?=\nFor each work item)/)?.[0] ?? ''; - expect(preflight, 'squad-plan-accept must validate bindings before mutation').not.toBe(''); - expect(preflight).toMatch(/original `Owner` and `Depends On` values/i); - expect(preflight).toMatch(/stop before mutation/i); - expect(preflight).toMatch(/never\s+substitute, re-route, or fall back/i); - - const labelRule = accept.match(/^- Labels:.*$/m)?.[0] ?? ''; - expect(labelRule).toMatch(/frozen row `Owner` lowercased/i); - expect(labelRule).toMatch(/only from that task's certified binding/i); + it('freezes each accepted row and derives its lowercase member label without remapping', async () => { + const f = activationFixture(); + const capture = await f.prepare(); + expect(capture.plan.tasks.map((task: any) => [task.id, task.agent, task.dependencies, task.label])) + .toEqual([['1', 'Builder', [], 'squad:builder'], ['2', 'Architect', ['1'], 'squad:architect']]); + f.comments.get(10)!.body = f.comments.get(10)!.body.replace('Builder | builder', 'Writer | writer'); + await expect(f.create(capture)).rejects.toThrow(/plan changed after capture/); + expect(f.createIssue).not.toHaveBeenCalled(); + expect(f.github.rest.issues.createComment).not.toHaveBeenCalled(); }); - it('creates only the planned tasks under the origin issue for a flat plan', () => { - expect(accept).toMatch(/origin issue is always the root/i); - expect(accept).toMatch(/flat plan, create exactly one issue per[\s\S]*work-item row/i); - expect(accept).toMatch(/every task's parent to the origin issue/i); - expect(accept).toMatch(/Do not\s+create an additional epic, summary, root, or phase issue/i); + it('creates only the planned tasks under the origin issue for a flat plan', async () => { + const f = activationFixture(); + const capture = await f.prepare(); + expect(capture.operations.map((operation: any) => operation.id)).toEqual(['task:1', 'task:2']); + await f.create(capture); + expect(f.createIssue).toHaveBeenCalledTimes(2); + expect(f.github.rest.issues.addSubIssue.mock.calls.map(([params]) => params.issue_number)).toEqual([42, 42]); + expect(f.issues.get(42).labels).toEqual([]); + for (const [params] of f.createIssue.mock.calls) expect(params.body).toContain('**Parent:** #42'); }); it('resolves fast-plan hierarchy only from explicit phase headings', () => { expect(plan).toMatch(/single `### Phase 1` heading makes the plan phased/i); expect(plan).toMatch(/flat plan MUST use one[\s\S]*no `### Phase \{N\}` headings/i); - expect(accept).toMatch(/Determine hierarchy only from the latest plan artifact's headings/i); - expect(accept).toMatch(/Any heading[\s\S]*`### Phase \{N\}` makes the plan explicitly phased/i); - expect(accept).toMatch(/Do not infer hierarchy from prose, task\s+count, dependency shape, or personal preference/i); - }); - - it('preserves every declared dependency through the safe-output capability', () => { - expect(accept).toMatch(/Copy every frozen `Depends On` value into the created issue body/i); - expect(accept).toMatch(/Do not\s+infer, drop, or reorder dependencies/i); - expect(accept).toMatch(/native `blockedBy` relationships only when[\s\S]*safe-output[\s\S]*explicitly exposes/i); - expect(accept).toMatch(/Do not bypass safe outputs with\s+a direct write API call/i); - expect(accept).toMatch(/body references are\s+the expected fallback/i); - }); - - it('reports the created hierarchy and dependency mode without overclaiming', () => { - expect(accept).toMatch(/Report the exact number of created task issues/i); - expect(accept).toMatch(/whether dependencies use native edges or the body-reference fallback/i); - expect(accept).toMatch(/Never\s+claim an epic, phase issue, sub-issue relationship, or native dependency edge/i); + const flat = parseActivationPlan(`This prose describes Phase 1.\n${taskTable()}`, 'plan', frozenRegistry, team); + expect(flat.phased).toBe(false); + expect(flat.epics).toEqual([]); + const phased = parseActivationPlan(`### Phase 1\n${taskTable()}`, 'plan', frozenRegistry, team); + expect(phased.phased).toBe(true); + expect(phased.epics).toEqual(['phase-1']); + expect(phased.tasks.map((task: any) => task.epic)).toEqual(['phase-1', 'phase-1']); + }); + + it('preserves every declared dependency through the trusted body-reference fallback', async () => { + const f = activationFixture(taskTable([ + '| 3 | Consumer | Writer | writer | 2, 1 |', + '| 1 | First | Builder | builder | None |', + '| 2 | Second | Architect | architect | 1 |', + ])); + const capture = await f.prepare(); + expect(capture.plan.tasks.map((task: any) => task.id)).toEqual(['1', '2', '3']); + await f.create(capture); + expect(f.createIssue.mock.calls.map(([params]) => + params.body.match(/^\*\*Depends On:\*\* (.*)$/m)?.[1])).toEqual(['None', '#101', '#102, #101']); + expect(f.createIssue.mock.calls.map(([params]) => params.labels)).toEqual([ + ['squad', 'squad:builder'], ['squad', 'squad:architect'], ['squad', 'squad:writer'], + ]); + }); + + it('reports the created hierarchy and dependency mode without overclaiming', async () => { + const f = activationFixture(); + f.github.rest.issues.addSubIssue.mockRejectedValue(Object.assign(new Error('Unavailable'), { status: 404 })); + const capture = await f.prepare(); + const journal = await f.create(capture); + await f.finalize(capture, journal); + const body = f.artifacts('plan-accepted')[0].body; + expect(body).toContain('2 task issues have complete actual-ID bindings.'); + expect(body).toContain('no native dependency-edge claim'); + expect(body).toContain('parent body references are the recorded fallback'); + expect(parseStructuredData(body).bindings.map((binding: any) => binding.epic_issue)).toEqual(['#42', '#42']); }); }); @@ -323,9 +471,8 @@ describe('/squad activate reuses the fast-path acceptance lifecycle', () => { it('prefers activate in fast-plan next steps while retaining the legacy alias', () => { expect(plan).toMatch(/Next Steps \(`\/squad activate` preferred/); expect(plan).toContain('`/squad plan accept` remains a supported legacy alias'); - expect(accept).toContain('`/squad activate` [phase {N}] (recommended)'); - expect(accept).toContain('`/squad plan accept` [phase {N}]'); - expect(accept).toContain('(supported legacy alias)'); + expect(accept).toContain('`/squad activate [phase N]` and `/squad plan accept [phase N]` use the same'); + expect(accept).toContain('trusted activation writer'); }); it('documents the recommended three-step lifecycle and both compatibility paths', () => { @@ -417,35 +564,55 @@ describe('#1784: squad-plan-validate treats the Name column as sole truth', () = // --------------------------------------------------------------------------- describe('#1758.1: squad-plan-accept routes granular plans before failing', () => { - const block = skillBlock(squad, 'squad-plan-accept'); - - it('Step 1 checks program/implementation before replying "No plan found"', () => { - const step1 = block.match(/##### Step 1: Find Plan and Route([\s\S]*?)#####/)?.[1] ?? ''; - expect(step1, 'Step 1 must be a routing step').not.toBe(''); - - const programIdx = step1.indexOf('`program`'); - const noPlanIdx = step1.indexOf('No plan found'); - expect(programIdx).toBeGreaterThan(-1); - expect(noPlanIdx).toBeGreaterThan(-1); - // The hard-fail must come AFTER the program/implementation routing check, - // so the routing note is reachable rather than dead code. - expect(programIdx).toBeLessThan(noPlanIdx); - }); - - it('routes to Accept Scope -> Accept Implementation -> Activate when granular artifacts exist', () => { - const step1 = block.match(/##### Step 1: Find Plan and Route([\s\S]*?)#####/)?.[1] ?? ''; - const scopeIdx = step1.indexOf('Accept Scope'); - const implIdx = step1.indexOf('Accept Implementation'); - const activateIdx = step1.indexOf('Activate'); - expect(scopeIdx).toBeGreaterThan(-1); - expect(implIdx).toBeGreaterThan(scopeIdx); - expect(activateIdx).toBeGreaterThan(implIdx); - }); - - it('only replies "No plan found" when none of plan/program/implementation exist', () => { - expect(block).toMatch( - /none of `program`, `implementation`, or `plan` exist, reply "No plan found/, - ); + const implementation = taskTable(['| 1 | Granular task | Builder | builder | None | epic1 |'], true); + + it('recognizes an implementation artifact before refusing its missing prerequisite', async () => { + const f = activationFixture(implementation, 'activate', 'implementation'); + await expect(f.prepare()).rejects.toThrow(/granular activation requires existing scope-accepted/); + expect(f.createIssue).not.toHaveBeenCalled(); + expect(f.github.rest.issues.createComment).not.toHaveBeenCalled(); + }); + + it('requires Accept Scope then Accept Implementation before granular activation', async () => { + const f = activationFixture(implementation, 'plan accept', 'implementation'); + f.comments.set(7, f.comment(7, structured('scope-accepted'))); + await expect(f.prepare()).rejects.toThrow(/granular activation requires existing impl-accepted/); + expect(f.createIssue).not.toHaveBeenCalled(); + f.comments.set(8, f.comment(8, structured('impl-accepted'))); + const capture = await f.prepare(); + expect(capture.plan.kind).toBe('implementation'); + expect(capture.operations.map((operation: any) => operation.id)).toEqual(['epic:epic1', 'task:1']); + const journal = await f.create(capture); + await f.finalize(capture, journal); + expect(f.artifacts('activated')).toHaveLength(1); + }); + + it('fails closed without a supported task plan and never turns a program into invented tasks', async () => { + for (const kind of ['program', 'research']) { + const f = activationFixture('No implementation task table exists.', 'activate', kind); + const original = f.comments.get(10)!.body; + const failure = await f.prepare().then(() => null, (error: Error) => error); + expect(failure).toBeInstanceOf(Error); + if (kind === 'program') { + expect(failure!.message).toContain('only a program plan exists'); + expect(failure!.message.match(/\/squad plan (?:accept scope|implementation|validate|accept implementation|activate)/g)) + .toEqual([ + '/squad plan accept scope', + '/squad plan implementation', + '/squad plan validate', + '/squad plan accept implementation', + '/squad plan activate', + ]); + } else { + expect(failure!.message).toContain('latest supported plan comment'); + } + expect(f.comments.get(10)!.body).toBe(original); + expect(f.createIssue).not.toHaveBeenCalled(); + expect(f.github.rest.issues.createComment).not.toHaveBeenCalled(); + expect(f.github.rest.issues.updateComment).not.toHaveBeenCalled(); + expect(f.github.rest.issues.createLabel).not.toHaveBeenCalled(); + expect(f.github.rest.issues.addSubIssue).not.toHaveBeenCalled(); + } }); }); @@ -1013,7 +1180,6 @@ describe('#1914: research creates the planning lifecycle state', () => { describe('#1916: fast-path commands maintain the planning lifecycle state', () => { const plan = skillBlock(squad, 'squad-plan'); const revise = skillBlock(squad, 'squad-plan-revise'); - const activate = skillBlock(squad, 'squad-plan-accept'); const lifecycleUpsert = 'Call `upsert_lifecycle_state` once with the complete lifecycle body.'; @@ -1036,29 +1202,78 @@ describe('#1916: fast-path commands maintain the planning lifecycle state', () = expect(revise).toContain('next = `/squad activate`'); }); - it('records phase progress or terminal activation after fast-path acceptance', () => { - const lifecycle = - activate.match(/Step 5: Update Fast-Path Lifecycle([\s\S]*)$/)?.[1] ?? ''; + it('records phase progress or terminal activation only after trusted finalization', async () => { + const body = [ + '### Phase 1', taskTable(['| 1 | First task | Builder | builder | None |']), + '### Phase 2', taskTable(['| 2 | Next task | Architect | architect | 1 |']), + ].join('\n'); + for (const command of ['activate phase 1', 'activate']) { + const f = activationFixture(body, command); + const capture = await f.prepare(); + const journal = await f.create(capture); + expect(f.artifacts('lifecycle-state')).toHaveLength(0); + await f.finalize(capture, journal); + const lifecycle = f.artifacts('lifecycle-state')[0]; + expect(lifecycle).toBeDefined(); + if (command.endsWith('phase 1')) { + expect(parseStructuredData(lifecycle.body).phases).toEqual([1]); + expect(lifecycle.body).toContain('Partially activated'); + expect(lifecycle.body).toContain('/squad activate phase 2'); + expect(f.createIssue).toHaveBeenCalledTimes(2); + } else { + expect(lifecycle.body).toContain('Activated'); + expect(lifecycle.body).toContain('no further planning action is required'); + expect(f.createIssue).toHaveBeenCalledTimes(4); + } + } + }); - expect(lifecycle).toContain(lifecycleUpsert); - expect(lifecycle).toContain('record phase `{N}` activated'); - expect(lifecycle).toContain('point next to the next unactivated phase'); - expect(lifecycle).toContain('Activation = `✅ Done`'); - expect(lifecycle).toContain('state =\n Activated'); - expect(lifecycle).toContain('This is terminal'); - expect(lifecycle).toContain('explicit terminal prose'); - }); - - it('repairs stale lifecycle state on an idempotent activate rerun', () => { - expect(activate).toContain('**Whole-plan idempotency:**'); - expect(activate).toContain('create no issues and post no acceptance'); - expect(activate).toContain('inspect the newest lifecycle state'); - expect(activate).toContain( - 'call `upsert_lifecycle_state` exactly once with', - ); - expect(activate).toContain( - 'Return `noop` only when that lifecycle state', - ); + it('repairs stale lifecycle state on a verified no-create rerun without rewriting acceptance', async () => { + const f = activationFixture(); + const capture = await f.prepare(); + const journal = await f.create(capture); + await f.finalize(capture, journal); + const acceptance = f.artifacts('plan-accepted')[0]; + const original = acceptance.body; + const lifecycle = f.artifacts('lifecycle-state')[0]; + const expectedLifecycle = lifecycle.body; + lifecycle.body = `## Squad lifecycle\n**State:** Planned\n${structured('lifecycle-state')}`; + f.github.rest.issues.createComment.mockClear(); + await expect(f.prepare()).resolves.toEqual({ existingAcceptanceId: acceptance.id }); + expect(f.createIssue).toHaveBeenCalledTimes(2); + expect(acceptance.body).toBe(original); + expect(lifecycle.body).toBe(expectedLifecycle); + expect(f.github.rest.issues.updateComment).toHaveBeenCalledTimes(1); + expect(f.github.rest.issues.createComment).not.toHaveBeenCalled(); + await f.prepare(); + expect(f.github.rest.issues.updateComment).toHaveBeenCalledTimes(1); + }); + + it('refuses a later phase without an explicit current-state mapping bridge, even for independent tasks', async () => { + const body = [ + '### Phase 1', taskTable(['| 1 | First task | Builder | builder | None |']), + '### Phase 2', taskTable(['| 2 | Independent task | Architect | architect | None |']), + ].join('\n'); + const f = activationFixture(body, 'activate phase 2'); + await expect(f.prepare()).rejects.toThrow(/prior-phase mapping bridge/); + expect(f.createIssue).not.toHaveBeenCalled(); + expect(f.github.rest.issues.createComment).not.toHaveBeenCalled(); + }); + + it('never repairs invalid acceptance by rerunning creates or relabeling current issues', async () => { + const f = activationFixture(); + const capture = await f.prepare(); + const journal = await f.create(capture); + await f.finalize(capture, journal); + const acceptance = f.artifacts('plan-accepted')[0]; + const original = acceptance.body; + f.issues.get(101).labels = ['squad']; + f.github.rest.issues.createComment.mockClear(); + await expect(f.prepare()).rejects.toThrow(/expected squad:builder/); + expect(f.createIssue).toHaveBeenCalledTimes(2); + expect(f.github.rest.issues.createComment).not.toHaveBeenCalled(); + expect(f.github.rest.issues.updateComment).not.toHaveBeenCalled(); + expect(acceptance.body).toBe(original); }); }); diff --git a/test/gh-aw-quality.test.ts b/test/gh-aw-quality.test.ts index 1cd8c0a75..f37de86bc 100644 --- a/test/gh-aw-quality.test.ts +++ b/test/gh-aw-quality.test.ts @@ -18,6 +18,11 @@ import { minimatch } from 'minimatch'; import { isMap, isSeq, parseDocument, type YAMLMap } from 'yaml'; import { POSIX_SHELL, NO_POSIX_SHELL_MESSAGE, requirePosixShell } from './posix-shell'; import { createFirstInstallFixture } from './helpers/gh-aw-install-fixture.js'; +import { + ACTIVATION_CAPTURE_SCHEMA, ACTIVATION_RECEIPT_SCHEMA, activationIssueLimit, activationRequest, + createActivation, digest, finalizeActivation, parseActivationPlan, validateCreateReceipt, validateReceiptSet, +} from '../workflows/shared/squad-activation.mjs'; +import { classifySquadCommand } from '../workflows/shared/squad-command-contract.mjs'; import { extractRunBlocks, scanRunBlocks, @@ -333,6 +338,116 @@ function createTestWorkspace(prefix: string): string { return mkdtempSync(join(TEST_WORKSPACES_DIR, prefix)); } +function dispatchedSkill(content: string, command: string): string { + const invocation = command + .replace('', '123') + .replace('', 'a'.repeat(64)) + .replace(/\{N\}/g, '1') + .replace(/<[^>]+>/g, 'example'); + const parsed = classifySquadCommand({ comment: { body: invocation } }, 'issue_comment'); + expect(parsed.status, `${command} must be accepted by the real command parser`).toBe('accepted'); + const table = content.match(/^## Execute Mode\n([\s\S]*?)(?=\n## )/m)?.[1] ?? ''; + const mappings = [...table.matchAll(/^\| `([^`]+)` \| `(squad-[^`]+)` \|$/gm)]; + const matches = mappings.filter(([, mode]) => mode === parsed.mode); + expect(matches, `${command} must have exactly one parsed-mode dispatch row`).toHaveLength(1); + return matches[0][2]; +} + +function activationFixture() { + const root = createTestWorkspace('quality-activation-'); + mkdirSync(join(root, '.squad/casting'), { recursive: true }); + const timestamp = '2026-01-01T00:00:00Z'; + const registry = { + schema: 'squad-agent-provenance/v1', schema_version: 1, revision: 1, + generated_at: timestamp, transaction_id: 'quality-fixture', + agents: { builder: { + agent_id: 'builder', display_name: 'Builder', persistent_name: 'Builder', + 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: ['builder'], universe: 'synthetic' } }, + universe_usage_history: [{ universe: 'synthetic', used_at: timestamp }], + }; + for (const [name, value] of Object.entries({ + registry, history, + 'registry-history.commit': { + transaction_id: registry.transaction_id, registry_revision: 1, + registry_sha256: digest(JSON.stringify(registry)), history_sha256: digest(JSON.stringify(history)), + }, + })) writeFileSync(join(root, `.squad/casting/${name}.json`), JSON.stringify(value)); + const team = '## Members\n| Name | Role |\n|------|------|\n| Builder | Engineer |\n'; + writeFileSync(join(root, '.squad/team.md'), team); + const body = [ + '| # | Title | Owner | Agent ID | Depends On |', '|---|---|---|---|---|', + '| first | Same title | Builder | builder | None |', + '| second | Same title | Builder | builder | first |', + ].join('\n'); + const plan = parseActivationPlan(body, 'plan', registry, team); + const context = { + repo: { owner: 'owner', repo: 'repo' }, actor: 'operator', + payload: { + action: 'created', sender: { login: 'operator', type: 'User' }, + issue: { number: 42 }, + comment: { body: '/squad activate', user: { login: 'operator', type: 'User' } }, + }, + }; + const env = { + GITHUB_EVENT_NAME: 'issue_comment', GITHUB_RUN_ID: '900', GITHUB_RUN_ATTEMPT: '1', + SQUAD_WORKFLOW_SHA: 'a'.repeat(40), + }; + const capture = { + schema: ACTIVATION_CAPTURE_SCHEMA, repository: 'owner/repo', origin_issue: 42, + run_id: 900, run_attempt: 1, workflow_sha: env.SQUAD_WORKFLOW_SHA, command: 'activate', + phase: null, actor: 'operator', registry, registry_sha256: digest(registry), team, plan, fullPlan: plan, + plan_comment: { id: 10, body, sha256: digest(body), updated_at: timestamp, + url: 'https://github.com/owner/repo/issues/42#issuecomment-10' }, + operations: plan.tasks.map((task: any) => ({ + id: `task:${task.id}`, task: task.id, epic: task.epic, title: task.title, + labels: ['squad', task.label], + })), + issue_limit: 20, + }; + const comments: any[] = []; + const rest = { + repos: { getCollaboratorPermissionLevel: vi.fn(async () => ({ data: { permission: 'write' } })) }, + issues: { + listComments: vi.fn(async () => ({ data: structuredClone(comments) })), + getComment: vi.fn(async () => ({ data: structuredClone(capture.plan_comment) })), + createComment: vi.fn(async (params: any) => { + const comment = { ...params, id: 1000 + comments.length }; + comments.push(comment); + return { data: comment }; + }), + getLabel: vi.fn(async () => { throw Object.assign(new Error('Missing'), { status: 404 }); }), + createLabel: vi.fn(async () => ({})), + addSubIssue: vi.fn(async () => ({})), + }, + }; + const github = { rest, paginate: async (method: any, params: any) => (await method(params)).data }; + const journalPath = join(root, 'receipts.json'); + const savedJournal = () => JSON.parse(readFileSync(journalPath, 'utf8')); + const createIssue = vi.fn(async (params: any) => { + const journal = savedJournal(); + expect(comments.find(comment => comment.id === journal.pending_comment_id)?.body) + .toContain('squad-activation-pending/v1'); + expect(journal.started).toHaveLength(journal.receipts.length + 1); + const number = 101 + journal.receipts.length; + return { staged: false, issue: { + ...params, number, id: number + 1000, node_id: `I_${number}`, + html_url: `https://github.com/owner/repo/issues/${number}`, + url: `https://api.github.com/repos/owner/repo/issues/${number}`, + } }; + }); + const create = () => createActivation({ + github, context, env, root, capture, captureArtifactId: 800, journalPath, createIssue, + sanitizeContent: (value: string) => value, + }); + return { root, capture, context, env, github, comments, createIssue, create, savedJournal }; +} + // --------------------------------------------------------------------------- // Test: Safe-Output Configuration Validation // --------------------------------------------------------------------------- @@ -582,31 +697,10 @@ describe('gh-aw: mode dispatch completeness', () => { }); it('each mode in the dispatch table has a corresponding section', () => { - // Modes that have dedicated sections (#### Mode Name or similar heading) - const headingRegex = /^#{2,4}\s+(.+?)$/gm; - const headings: string[] = []; - let match: RegExpExecArray | null; - while ((match = headingRegex.exec(content)) !== null) { - headings.push(match[1].trim().toLowerCase()); - } - - // Unique mode names from the dispatch table (normalize combined modes) - const uniqueModes = [...new Set(modeTable.map(r => r.mode))]; - - for (const mode of uniqueModes) { - // Strip qualifiers like "(fast-path)" — they annotate the table entry, - // they are not part of the section/skill name. - const normalized = mode.replace(/\([^)]*\)/g, '').trim().toLowerCase(); - // Check if any heading contains the mode name (flexible match) - // For multi-word modes like "Plan Accept", check for "plan accept" or "plan-accept" - // Also check if the base mode has a section (e.g., "Cast" for "Cast Mode") - const found = headings.some(h => - h.includes(normalized) || - h.includes(normalized.replace(/\s+/g, '-')) || - h.includes(`${normalized} mode`) || - h === `${normalized} mode` - ); - expect(found, `Mode "${mode}" should have a corresponding section heading`).toBe(true); + for (const { command } of modeTable) { + const skill = dispatchedSkill(content, command); + expect(content, `${command} must dispatch to an existing skill section`) + .toContain(`## skill: \`${skill}\``); } }); @@ -652,6 +746,8 @@ describe('gh-aw: shared component imports', () => { it('declares the plaintext Cast validator resource and canonical built-in charter resources, not imported skills', () => { const runtimeResources = [ + 'shared/squad-activation.mjs', + 'shared/check-agent-binding.mjs', 'shared/squad-command-contract.mjs', 'shared/squad-cast-validator.mjs', 'shared/squad-bootstrap-validator.mjs', @@ -739,9 +835,12 @@ describe('gh-aw: clean install runtime resource closure', () => { 'squad-retro', 'squad-improvement-worker', 'squad-bootstrap', + 'squad-command-router', ]; const expectedRuntimeResources = [ + 'shared/check-agent-binding.mjs', 'shared/implementation-provenance-v1.schema.json', + 'shared/squad-activation.mjs', 'shared/squad-bootstrap-validator.mjs', 'shared/squad-cast-validator.mjs', 'shared/squad-command-contract.mjs', @@ -842,7 +941,8 @@ describe('gh-aw: planning state machine', () => { function registryArtifactKinds(): string[] { const registry = ontologyContent.match(/## 4\. Structured Artifact Registry([\s\S]*?)(?=\n---|\n## \d)/); if (!registry) throw new Error('Structured Artifact Registry section is missing'); - return [...registry[1].matchAll(/^\| `([^`]+)` \|/gm)].map(match => match[1]); + return [...registry[1].matchAll(/^\| `([^`]+)` \|/gm)] + .map(match => match[1]).filter(kind => kind !== 'squad_artifact'); } it('uses no Squad HTML comment as machine state', () => { @@ -878,11 +978,23 @@ describe('gh-aw: planning state machine', () => { } }); - it('workflow schema enumerates every registered artifact kind', () => { + it('registered artifacts have a model schema or an explicit trusted-only publisher', () => { + const schema = parseDocument(extractFrontmatter(SQUAD_WORKFLOW)).toJS()['safe-outputs'].data; + const kinds = schema.properties.squad_artifact.enum; + const trustedPublisher = readText(join(SHARED_DIR, 'squad-activation.mjs')); for (const artifactKind of registryArtifactKinds()) { - expect(squadContent, `safe-outputs.data should allow "${artifactKind}"`).toMatch( - new RegExp(`^\\s+- ${artifactKind}$`, 'm') - ); + if (artifactKind === 'activation-reconciled') { + expect(kinds, 'the model must not author a trusted reconciliation supplement').not.toContain(artifactKind); + expect(trustedPublisher).toContain("squad_artifact: 'activation-reconciled'"); + expect(trustedPublisher).toContain("basis: 'current-state-authorized'"); + const job = parseDocument(extractFrontmatter(SQUAD_WORKFLOW)).toJS()['safe-outputs'].jobs['reconcile-activation']; + expect(job.max).toBe(1); + expect(Object.keys(job.inputs)).toEqual(['action']); + expect(job.steps.at(-1).with.script).toContain('await activation.reconcileActivation'); + expect(job.steps.at(-1).with.script).toContain('Reconciliation rejects model-provided mappings'); + } else { + expect(kinds, `safe-outputs.data should allow "${artifactKind}"`).toContain(artifactKind); + } } }); @@ -1510,25 +1622,9 @@ describe('gh-aw: inline skill extraction', () => { const modes = extractModeTable(readText(SQUAD_WORKFLOW)); expect(modes.length, 'mode table should parse').toBeGreaterThan(15); - const dispatchTable = readText(SQUAD_WORKFLOW); - for (const { mode } of modes) { - const normalizedMode = mode.replace(/\([^)]*\)/g, '').trim(); - const slug = - 'squad-' + - normalizedMode - .toLowerCase() - .replace(/[^a-z0-9]+/g, '-') - .replace(/(^-|-$)/g, ''); - - const hasSkill = skillNames.has(slug); - // Some modes legitimately share a playbook; accept an explicit mapping row. - const hasMapping = new RegExp(`\\|[^|\\n]*${normalizedMode.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&')}[^|\\n]*\\|[^|\\n]*squad-`, 'i').test(dispatchTable); - - expect( - hasSkill || hasMapping, - `Mode "${mode}" has no "## skill: \`${slug}\`" block and no row in the Execute Mode ` + - `dispatch table pointing at a skill. It would have no playbook at runtime.` - ).toBe(true); + for (const { command } of modes) { + const skill = dispatchedSkill(readText(SQUAD_WORKFLOW), command); + expect(skillNames.has(skill), `${command} must load extracted skill ${skill}`).toBe(true); } }); }); @@ -1620,6 +1716,41 @@ describe('gh-aw: compiled workflow shell input security contract', () => { expect(compiled).toContain('{{#runtime-import .github/workflows/shared/squad-planning-ontology.md}}'); expect(compiled).toContain('{{#runtime-import .github/workflows/squad.md}}'); expect(compiled).not.toMatch(//); + 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 +2591,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 +2635,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 +2680,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 +2848,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 +3355,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 +4081,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,8 +4089,9 @@ 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(contract.shared_runtime).toHaveLength(20); expect(ciRuntimeCount?.[1], 'strict compile CI runtime cardinality must match the package manifest') .toBe(String(contract.shared_runtime.length)); const seedStart = ci.indexOf('mkdir -p .github/workflows'); @@ -3888,7 +4110,13 @@ 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', 'squad-agent-binding-check.yml']) { + expect(contract.shared_runtime).toContainEqual(expect.objectContaining({ + path: `shared/${resource}`, destination: `.github/workflows/shared/${resource}`, + })); + } 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); 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/workflows/aw.yml b/workflows/aw.yml index 1a213088e..badb45e45 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/workflows/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..07c120aa6 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" "d0db895a0a19a5728ad81eadcc84c7a77717bb9f3e0dcfb63650308e238ac2a3" 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..5ab70fb4c 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,153 @@ 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 + 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); + await activation.reconcileActivation({ github, context, env: process.env, root }); + 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')); + 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, + 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), + }); cast-failure: name: Fail incomplete Cast description: Fail a Cast run from an emitted factual failure record. @@ -495,7 +643,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 +1442,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 +1491,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 +1499,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 +1882,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 +2044,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 +2118,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 +2943,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 +3329,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 +3401,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 +3443,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..54f7607b6 --- /dev/null +++ b/workflows/shared/check-agent-binding.mjs @@ -0,0 +1,1123 @@ +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; +} + +/** 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); + 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 } }; + } + if (blocks.length !== 1) fail('exactly one structured envelope is required'); + const artifact = JSON.parse(blocks[0][1]); + if (!ACTIVATION_ARTIFACTS.has(artifact.squad_artifact)) fail('unknown activation artifact'); + if (artifact.origin_issue !== context.originIssue || artifact.schema_version !== '1') fail('artifact origin/schema mismatch'); + return { ...record, artifact }; + }); + 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 (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 (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 (usedRecords.size !== records.length || 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 => after.includes(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; +} + +function readOnlyGithub(repo, token) { + const request = async (path, page) => { + 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() }; + }; + 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 } = await method({ ...params, page }); + if (!Array.isArray(data)) throw new Error('Evidence pagination returned a non-array'); + all.push(...data); + if (data.length < 100) return all; + } + } }; +} + +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; +} + +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 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'); + + const github = readOnlyGithub(args.repo, token); + 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; + 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(args.repo, [...new Set(issues)], token); + const result = validateActivation(artifact, roster, labels, comment.issue, { + repository: args.repo, + registry, + epicAssignments: assignments.get(comment), + }); + checked += result.checked; + } + 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'); + } + console.log(checked === 0 ? 'No activation artifact found; skipping.' : `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..3995af9c8 --- /dev/null +++ b/workflows/shared/squad-activation.mjs @@ -0,0 +1,1410 @@ +import { createHash } from 'node:crypto'; +import { isDeepStrictEqual } from 'node:util'; +import { readFileSync, writeFileSync } from 'node:fs'; +import { + extractIssueNumber, parseAgentRegistry, parsePendingReservation, parseRoster, parseStructuredData, selectActivationRecord, + validateActivation, validateCastingPairFiles, +} from './check-agent-binding.mjs'; +import { authorizeSquadCommand, classifySquadCommand } 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; + +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 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 = body.replace(/\r\n?/g, '\n').split('\n'); + const phased = lines.some(line => /^### Phase [1-9]\d*(?:\s|$)/.test(line)); + let phase = null; + let fence = false; + const tasks = []; + const ids = new Set(); + for (let index = 0; index < lines.length; index++) { + const line = lines[index]; + if (/^\s*```/.test(line)) { fence = !fence; continue; } + if (fence) continue; + 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')) { + 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]; + 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 if (artifact.phases?.length) { + const currentPlan = comments.find(item => item.id === planId); + requireThat(currentPlan?.user?.id === BOT_ID, 'phased no-op requires the frozen plan source'); + fullPlan = parseActivationPlan(currentPlan.body, + envelope(currentPlan.body)?.squad_artifact, registry, team, []); + 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); + 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 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, +}) { + 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 }); + 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]; + })); + 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'); + 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(); + if (capture.approvals?.validation) { + await github.rest.issues.createComment({ + ...context.repo, issue_number: capture.origin_issue, + 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'), + }); + } + 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' }); + } + } + for (const operation of capture.operations) { + const content = bodyFor(operation, capture, journal); + 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: sanitizeContent(content.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 github.rest.issues.createComment({ ...context.repo, issue_number: capture.origin_issue, 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 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', + }; +} + +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?.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; + 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'); + if (request.phases !== undefined) requireThat(Array.isArray(request.phases) && request.phases.length > 0 && + request.phases.every((phase, index) => positive(phase) && (index === 0 || request.phases[index - 1] < phase)) && + Array.isArray(oldEnvelope.phases), 'selected prior-phase coverage must be ascending unique phase IDs'); + const fullPlan = parseActivationPlan(planComment.body, planEnvelope.squad_artifact, registry, team, []); + const plan = structuredClone(fullPlan); + if (request.phases !== undefined) { + requireThat(Array.isArray(request.phases) && request.phases.length > 0 && + request.phases.every(positive) && new Set(request.phases).size === request.phases.length && + isDeepStrictEqual([...request.phases].sort((a, b) => a - b), [...oldEnvelope.phases].sort((a, b) => a - b)), + 'selected prior-phase coverage must exactly match the original phase artifact'); + plan.tasks = plan.tasks.filter(task => request.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); + 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 < 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) { + 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 github.rest.issues.createComment({ + ...context.repo, issue_number: verified.request.origin_issue, body, + })).data; + 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..80490e34d --- /dev/null +++ b/workflows/shared/squad-agent-binding-check.yml @@ -0,0 +1,43 @@ +name: Squad Agent Binding Check + +on: + workflow_run: + workflows: [Squad] + types: [completed] + +permissions: + contents: read + issues: read + +jobs: + check: + 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 activation records without changing them + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 + with: + script: | + const fs = require('node:fs'); + const run = context.payload.workflow_run; + const comments = await github.paginate(github.rest.issues.listCommentsForRepo, { + ...context.repo, since: run.run_started_at, per_page: 100, + }); + const records = comments + .filter(comment => new Date(comment.created_at) <= new Date(run.updated_at)) + .filter(comment => /"squad_artifact"\s*:\s*"(?:activated|phases-activated|plan-accepted|phases-accepted|activation-reconciled)"/.test(comment.body ?? '')) + .map(comment => ({ id: comment.id, body: comment.body, issue: Number(comment.issue_url.split('/').at(-1)) })); + fs.writeFileSync(`${process.env.RUNNER_TEMP}/activation-comments.json`, JSON.stringify(records)); + - name: Validate current bindings and explicitly authorized reconciliation + 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..9082af1b3 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) { @@ -170,6 +171,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 +442,43 @@ 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 body = typeof item.body === 'string' ? item.body.replace(//g, '') : ''; + const envelopes = [...body.matchAll(/Structured data:\s*```json\s*([\s\S]*?)```/gi)]; + if (activationArtifacts.has(item.data?.squad_artifact) || + /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))) || + envelopes.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..3bb3c0a68 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/workflows/squad-agent-binding-check.yml', '.github/workflows/shared/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'], 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..25acbcb84 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" "d0db895a0a19a5728ad81eadcc84c7a77717bb9f3e0dcfb63650308e238ac2a3" 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..f430d247d 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": "f9cba967f09d1fde5706e770522d47958c5d9ca66633715c5aa17f6523f80cef", + "lock_sha256": "f4c602bfe0c82b5be0bac43a90c9426566cb7b6dffd1ead4fce9fcc4ba9182f8", + "package_lock_sha256": "44882baa454f6859e321c2d9fbe80720a2cece9e93abf110dd61a3824e2d955e" }, { "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": "718fc271fe532e6ea22f2202ccb98360e5cfef0da0c78b9f42127192785be0bf", + "lock_sha256": "7e8d508214a08be31d614cb9f6366991c456d114751c06f687c2f6fcd595e40f", + "package_lock_sha256": "b2c45bd1cf05e22d873b5adcbadd4b5df320366c7a71f0eab66a8563241caed3" }, { "name": "squad-command-router", @@ -88,13 +88,13 @@ "workflows": [ { "name": "squad", - "lock_sha256": "3c447de8a7998d659ef6a4155e49b7c4785ac2e8361a1d854ffbd5acd258348b", - "package_lock_sha256": "8823d25b1fc8aed5981d14363a3311a381d7207b89105203a1202dc6f78c2c78" + "lock_sha256": "f4c602bfe0c82b5be0bac43a90c9426566cb7b6dffd1ead4fce9fcc4ba9182f8", + "package_lock_sha256": "44882baa454f6859e321c2d9fbe80720a2cece9e93abf110dd61a3824e2d955e" }, { "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": "bc4f8875d70e643f8b0106c1f16da586e6b827267d0b49812152847224df5987", + "package_lock_sha256": "4e27b3cc4f3d1ae30bed183ba57557520f5af3d7fcae8d834daf0f9ede9373e1" }, { "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/workflows/squad-agent-binding-check.yml", + "destination": ".github/workflows/shared/squad-agent-binding-check.yml", + "ownership": "manifest", + "sha256": "d789689d0e110bbc75fc42a1def649e9e9ec713a50ec8acc33e174b1a29e4c17" + }, + { + "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": "6bec070882ed78e26dea8bbeb8d86a2fcbdc6b9679475e2c064793adb91db10b" + }, + { + "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": "059410e62d02effa9299382149f7626f9b46e333e55ebcd42fad55e376473f08" + }, { "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": "d0db895a0a19a5728ad81eadcc84c7a77717bb9f3e0dcfb63650308e238ac2a3" }, { "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": "1efe8ba26bed5d68422002f28d95ee39282a19e0ad90e7c2e5b61cd8e2630093" }, { "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..2ce26bf16 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,153 @@ 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 + 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); + await activation.reconcileActivation({ github, context, env: process.env, root }); + 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')); + 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, + 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), + }); cast-failure: name: Fail incomplete Cast description: "Fail a Cast run from an emitted factual failure record." @@ -578,6 +727,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 +889,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 +963,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 +1788,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 +2174,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 +2246,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 +2288,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` From 365b323f4cf7e58db88d6069e4d968545ee6113b Mon Sep 17 00:00:00 2001 From: Brady Gaster Date: Sun, 11 Oct 2026 20:04:33 -0700 Subject: [PATCH 02/11] fix(gh-aw): independently repair activation review findings Revision independently authored by github-integration-dev under reviewer lockout. Address legacy promotion authority, full-body acceptance forgery, phase-scope parity, rendered body bounds, and retry-safe prerequisites. Original author performed only validation and mechanical integration. Closes #2203 Revision-Author: github-integration-dev Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 9370fe46-c2d1-486c-ab53-dc21c7f4d0fb --- .changeset/activation-reconciliation.md | 2 + .../squad-sdk/src/casting/agent-provenance.ts | 3 + test/agent-provenance.test.ts | 30 +++ test/gh-aw-activation-finalization.test.ts | 227 +++++++++++++++++- ...h-aw-activation-label-provisioning.test.ts | 30 ++- test/gh-aw-command-authorization.test.ts | 63 +++++ workflows/shared/squad-activation.mjs | 158 +++++++++--- workflows/shared/squad-command-contract.mjs | 12 +- 8 files changed, 483 insertions(+), 42 deletions(-) diff --git a/.changeset/activation-reconciliation.md b/.changeset/activation-reconciliation.md index 3b9d2e793..2fbd010ee 100644 --- a/.changeset/activation-reconciliation.md +++ b/.changeset/activation-reconciliation.md @@ -8,6 +8,8 @@ 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. +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. diff --git a/packages/squad-sdk/src/casting/agent-provenance.ts b/packages/squad-sdk/src/casting/agent-provenance.ts index eda519b0b..2f983b2a0 100644 --- a/packages/squad-sdk/src/casting/agent-provenance.ts +++ b/packages/squad-sdk/src/casting/agent-provenance.ts @@ -641,6 +641,9 @@ function activationEnvelope(value: unknown, originIssue: number): WorkAgentBindi || !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']; } diff --git a/test/agent-provenance.test.ts b/test/agent-provenance.test.ts index 02d1ff977..8196440bd 100644 --- a/test/agent-provenance.test.ts +++ b/test/agent-provenance.test.ts @@ -259,6 +259,36 @@ describe('agent identity provenance contract', () => { }, ); + 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' }, diff --git a/test/gh-aw-activation-finalization.test.ts b/test/gh-aw-activation-finalization.test.ts index 9062291a7..bfdde9fb8 100644 --- a/test/gh-aw-activation-finalization.test.ts +++ b/test/gh-aw-activation-finalization.test.ts @@ -223,6 +223,208 @@ function fixture({ count = 2, epic = false } = {}) { }; } +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('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, + 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-character 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 / 3)) + '😀'.repeat(extra % 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, [ @@ -325,8 +527,9 @@ describe('trusted whole-plan activation finalization', () => { 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}`])); - expect(f.writes[0].method).toBe('createComment'); - expect(f.writes[0].params.body).toContain('squad-activation-pending/v1'); + 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); @@ -844,6 +1047,26 @@ function addValidation(f: ReturnType) { } 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); diff --git a/test/gh-aw-activation-label-provisioning.test.ts b/test/gh-aw-activation-label-provisioning.test.ts index 36d7072c2..f60b1c01c 100644 --- a/test/gh-aw-activation-label-provisioning.test.ts +++ b/test/gh-aw-activation-label-provisioning.test.ts @@ -222,12 +222,34 @@ describe('gh-aw: trusted fresh-repository label provisioning', () => { expect(f.createIssue).not.toHaveBeenCalled(); }); - it('does not create any issue when missing-label provisioning fails', async () => { + 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(); - f.api.createLabel.mockRejectedValueOnce(Object.assign(new Error('Permission denied'), { status: 403 })); - await expect(f.create(await f.prepare())).rejects.toThrow('Permission denied'); + 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(JSON.parse(readFileSync(f.journalPath, 'utf8')).started).toEqual([]); + 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); }); it.each([false, true])('rejects non-roster creation before reservation or label writes (implementation=%s)', async implementation => { diff --git a/test/gh-aw-command-authorization.test.ts b/test/gh-aw-command-authorization.test.ts index e74e671de..e905941ac 100644 --- a/test/gh-aw-command-authorization.test.ts +++ b/test/gh-aw-command-authorization.test.ts @@ -3,6 +3,7 @@ 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'; @@ -10,6 +11,7 @@ import { authorizeSquadCommand, classifySquadCommand } from '../workflows/shared 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; @@ -269,6 +271,33 @@ 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', '~~~', @@ -905,6 +934,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('/g, '') : ''; - const envelopes = [...body.matchAll(/Structured data:\s*```json\s*([\s\S]*?)```/gi)]; + 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) || - /Activation (?:bindings|reconciliation):/i.test(body) || + 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))) || - envelopes.some(match => { + [...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.'); } From cfb2e9649e3858c1acd88917bed9e12895edc97b Mon Sep 17 00:00:00 2001 From: Brady Gaster Date: Sun, 11 Oct 2026 20:06:27 -0700 Subject: [PATCH 03/11] fix(gh-aw): materialize the verified native activation checker Stage native YAML through the supported .github/aw package destination and materialize its verified workflow without overwriting unrelated content. Exercise the actual installer and preserve canonical label/isolation gates. Keep topology at nineteen shared resources plus one native checker. Closes #2203 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 9370fe46-c2d1-486c-ab53-dc21c7f4d0fb --- .github/workflows/squad-ci.yml | 3 +- docs/src/content/docs/guide/gh-aw.md | 25 ++++++++-- ...ivate-fast-path-label-provisioning.test.ts | 40 +++++++++++++++ test/gh-aw-implement-workflow.test.ts | 2 +- test/gh-aw-quality.test.ts | 49 +++++++++++++++++-- test/recast-identity-neutrality.test.ts | 40 +++++++-------- workflows/aw.yml | 2 +- workflows/package/squad-bootstrap.md | 2 +- workflows/shared/squad-install-verifier.mjs | 6 ++- workflows/squad-bootstrap.md | 2 +- workflows/squad-workflows.manifest.json | 20 ++++---- 11 files changed, 147 insertions(+), 44 deletions(-) diff --git a/.github/workflows/squad-ci.yml b/.github/workflows/squad-ci.yml index 3073bedb0..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 ' ')" = 20 + 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 be7cf9d11..2503575b3 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 @@ -1385,6 +1393,11 @@ 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 @@ -1460,6 +1473,8 @@ 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 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 fc86b3abb..b01448691 100644 --- a/test/gh-aw-activate-fast-path-label-provisioning.test.ts +++ b/test/gh-aw-activate-fast-path-label-provisioning.test.ts @@ -129,6 +129,46 @@ describe('gh-aw: fast-path request boundary', () => { }); 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('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('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'], diff --git a/test/gh-aw-implement-workflow.test.ts b/test/gh-aw-implement-workflow.test.ts index aea074819..d2098e4f3 100644 --- a/test/gh-aw-implement-workflow.test.ts +++ b/test/gh-aw-implement-workflow.test.ts @@ -259,7 +259,7 @@ describe('gh-aw implement workflows', () => { it('documents one immutable nested native package installation', () => { const normalizedGuide = guide.replace(/\r\n/g, '\n'); expect(normalizedGuide).toContain('gh aw add "bradygaster/squad/workflows@${SQUAD_SHA}"'); - expect(normalizedGuide).toContain('exactly eight workflows, seventeen runtime'); + expect(normalizedGuide).toContain('exactly eight agentic workflows, twenty runtime'); expect(normalizedGuide).toContain('one enlistment skill'); expect(normalizedGuide).not.toMatch(/gh aw add \\\n\s+bradygaster\/squad\/workflows\/squad\.md/); }); diff --git a/test/gh-aw-quality.test.ts b/test/gh-aw-quality.test.ts index f37de86bc..7d1235da4 100644 --- a/test/gh-aw-quality.test.ts +++ b/test/gh-aw-quality.test.ts @@ -50,6 +50,7 @@ import { validateCompilerActionPins, verifyInstall, verifyCanonicalManifest, + writeLocalTestOwnership, } from '../workflows/shared/squad-install-verifier.mjs'; const WORKFLOWS_DIR = join(process.cwd(), 'workflows'); @@ -4092,8 +4093,12 @@ describe('gh-aw: canonical package integrity contract', () => { expect(contract.workflows).toHaveLength(8); expect(contract.shared_runtime).toHaveLength(RUNTIME_TUPLES.length); expect(contract.shared_runtime).toHaveLength(20); - expect(ciRuntimeCount?.[1], 'strict compile CI runtime cardinality must match the package manifest') - .toBe(String(contract.shared_runtime.length)); + 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') @@ -4110,11 +4115,16 @@ 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', 'squad-agent-binding-check.yml']) { + 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'); @@ -4148,6 +4158,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/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 badb45e45..9889796e9 100644 --- a/workflows/aw.yml +++ b/workflows/aw.yml @@ -24,7 +24,7 @@ includes: - skills/gh-aw-enlistment resources: - source: shared/squad-agent-binding-check.yml - destination: .github/workflows/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 diff --git a/workflows/package/squad-bootstrap.md b/workflows/package/squad-bootstrap.md index 07c120aa6..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" "d0db895a0a19a5728ad81eadcc84c7a77717bb9f3e0dcfb63650308e238ac2a3" + check_hash "$install_verifier" "a8b8fedd8b2804a061c367f6c67113979aa9d792526e8d052a2f01f697bf3642" check_hash "$cast_validator" "15f0d47917d269b5b967dc2a782a0eb761c39af7a92ddb64fed687a427070760" check_hash "$bootstrap_validator" "d552d041f006249bfaa67b10a75877f813796b7698c718ac7eb7cf16f3a72e31" # END GENERATED RESOURCE DIGESTS diff --git a/workflows/shared/squad-install-verifier.mjs b/workflows/shared/squad-install-verifier.mjs index 3bb3c0a68..b58f7a442 100644 --- a/workflows/shared/squad-install-verifier.mjs +++ b/workflows/shared/squad-install-verifier.mjs @@ -61,7 +61,7 @@ export const WORKFLOW_TUPLES = deepFreeze([ ]); export const RUNTIME_TUPLES = deepFreeze([ - ['shared/squad-agent-binding-check.yml', 'workflows/shared/squad-agent-binding-check.yml', '.github/workflows/squad-agent-binding-check.yml', '.github/workflows/shared/squad-agent-binding-check.yml', 'manifest'], + ['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'], @@ -1073,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/squad-bootstrap.md b/workflows/squad-bootstrap.md index 25acbcb84..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" "d0db895a0a19a5728ad81eadcc84c7a77717bb9f3e0dcfb63650308e238ac2a3" + 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 f430d247d..fd83dc0c0 100644 --- a/workflows/squad-workflows.manifest.json +++ b/workflows/squad-workflows.manifest.json @@ -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": "718fc271fe532e6ea22f2202ccb98360e5cfef0da0c78b9f42127192785be0bf", - "lock_sha256": "7e8d508214a08be31d614cb9f6366991c456d114751c06f687c2f6fcd595e40f", - "package_lock_sha256": "b2c45bd1cf05e22d873b5adcbadd4b5df320366c7a71f0eab66a8563241caed3" + "source_sha256": "51d612983253e367a21918b849a20177aa6cddfa7e5f6cf7f7823cafa012816d", + "lock_sha256": "ab601855b1c26e636c3cc25d5c3306eaf18dc81335a675eb73b5019aa3a8cd40", + "package_lock_sha256": "164215a224d29a80818b61abed2748943423ea424fbc0fc25a74129de661b695" }, { "name": "squad-command-router", @@ -118,8 +118,8 @@ }, { "name": "squad-bootstrap", - "lock_sha256": "bc4f8875d70e643f8b0106c1f16da586e6b827267d0b49812152847224df5987", - "package_lock_sha256": "4e27b3cc4f3d1ae30bed183ba57557520f5af3d7fcae8d834daf0f9ede9373e1" + "lock_sha256": "af3d33ec1b7440b9017d135db384d2b5adfb493aa4ca9b8aad585d1eb1ec1438", + "package_lock_sha256": "4e915f52ad29f6bff47352d23457a0e23390103ebbf4dcc7cb8916870b860a75" }, { "name": "squad-command-router", @@ -133,8 +133,8 @@ { "path": "shared/squad-agent-binding-check.yml", "source": "workflows/shared/squad-agent-binding-check.yml", - "package_destination": ".github/workflows/squad-agent-binding-check.yml", - "destination": ".github/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": "d789689d0e110bbc75fc42a1def649e9e9ec713a50ec8acc33e174b1a29e4c17" }, @@ -144,7 +144,7 @@ "package_destination": ".github/workflows/shared/squad-activation.mjs", "destination": ".github/workflows/shared/squad-activation.mjs", "ownership": "manifest", - "sha256": "6bec070882ed78e26dea8bbeb8d86a2fcbdc6b9679475e2c064793adb91db10b" + "sha256": "d2509a6456802ee6c215676812857a6ec6da1c0b98066fa68949c435e68fb0e4" }, { "path": "shared/check-agent-binding.mjs", @@ -168,7 +168,7 @@ "package_destination": ".github/workflows/shared/squad-install-verifier.mjs", "destination": ".github/workflows/shared/squad-install-verifier.mjs", "ownership": "manifest", - "sha256": "d0db895a0a19a5728ad81eadcc84c7a77717bb9f3e0dcfb63650308e238ac2a3" + "sha256": "a8b8fedd8b2804a061c367f6c67113979aa9d792526e8d052a2f01f697bf3642" }, { "path": "shared/squad-command-contract.mjs", @@ -176,7 +176,7 @@ "package_destination": ".github/workflows/shared/squad-command-contract.mjs", "destination": ".github/workflows/shared/squad-command-contract.mjs", "ownership": "manifest", - "sha256": "1efe8ba26bed5d68422002f28d95ee39282a19e0ad90e7c2e5b61cd8e2630093" + "sha256": "a03a22e33e18a2452099af1651a6a67f3831b31a20895c3148362c6549ea792e" }, { "path": "shared/squad-cast-validator.mjs", From 5142706f413cd634a8aa2eb8d212653ba242712a Mon Sep 17 00:00:00 2001 From: Brady Gaster Date: Mon, 12 Oct 2026 08:57:02 -0700 Subject: [PATCH 04/11] fix(gh-aw): verify publication within the trusted writer Independently authored by architect670b57f4 under reviewer lockout; original owner performed mechanical package generation and validation only. Closes #2203 Revision-Author: architect (670b57f4-0ee3-4c01-b347-5b798cb4ef14) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 9370fe46-c2d1-486c-ab53-dc21c7f4d0fb --- .../workflows/squad-agent-binding-check.yml | 11 +- docs/src/content/docs/guide/gh-aw.md | 18 ++ test/gh-aw-activation-finalization.test.ts | 303 +++++++++++++++++- test/gh-aw-command-authorization.test.ts | 10 + workflows/package/squad.md | 8 +- workflows/shared/check-agent-binding.mjs | 74 ++++- workflows/shared/squad-activation.mjs | 27 +- .../shared/squad-agent-binding-check.yml | 11 +- workflows/squad-workflows.manifest.json | 16 +- workflows/squad.md | 8 +- 10 files changed, 456 insertions(+), 30 deletions(-) diff --git a/.github/workflows/squad-agent-binding-check.yml b/.github/workflows/squad-agent-binding-check.yml index 8bd88d255..6dd0dfb9d 100644 --- a/.github/workflows/squad-agent-binding-check.yml +++ b/.github/workflows/squad-agent-binding-check.yml @@ -13,26 +13,25 @@ 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); + // Current-run completion is checked against the synchronous publication + // response inside the trusted writer. This is only a historical audit. 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|activation-reconciled)/i.test(comment.body ?? '')) .map(comment => ({ @@ -44,7 +43,7 @@ jobs: `${process.env.RUNNER_TEMP}/activation-comments.json`, JSON.stringify(activationComments), ); - - name: Check agent bindings + - name: Audit historical bindings without asserting current-run completion env: GITHUB_TOKEN: ${{ github.token }} run: >- diff --git a/docs/src/content/docs/guide/gh-aw.md b/docs/src/content/docs/guide/gh-aw.md index 2503575b3..d068d36f9 100644 --- a/docs/src/content/docs/guide/gh-aw.md +++ b/docs/src/content/docs/guide/gh-aw.md @@ -1403,6 +1403,24 @@ 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 over repository comments, including old predecessors. 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 diff --git a/test/gh-aw-activation-finalization.test.ts b/test/gh-aw-activation-finalization.test.ts index bfdde9fb8..8b0c43652 100644 --- a/test/gh-aw-activation-finalization.test.ts +++ b/test/gh-aw-activation-finalization.test.ts @@ -1,7 +1,12 @@ -import { afterEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'; import { randomUUID } from 'node:crypto'; -import { mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +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 { 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, @@ -132,6 +137,7 @@ function fixture({ count = 2, epic = false } = {}) { update?: (params: any) => void; pendingIdMissing?: boolean; parentStatus?: number; + publicationResponse?: (value: any) => any; } = {}; const reads = new Map(); let nextComment = 1000; @@ -170,6 +176,9 @@ function fixture({ count = 2, epic = false } = {}) { 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) }; }), @@ -1032,6 +1041,256 @@ function phasedFixture(sharedEpic = false) { 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'); + 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'); + }, + }; + } + + 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(`${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, [ @@ -1209,6 +1468,46 @@ async function runCollectedChecker(f: ReturnType, comments: Arra } describe('live downstream verification', () => { + it.each(['.github/workflows/squad-agent-binding-check.yml', 'workflows/shared/squad-agent-binding-check.yml'])( + '%s executes a historical audit, retaining old explicit predecessors without run attestation', async path => { + 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 paginate = vi.fn(async (_method: any, params: any) => { + expect(params).not.toHaveProperty('since'); + return clone([...f.comments.values()]); + }); + await compileFunction(`return (async () => { ${collector.with.script} })();`, + ['github', 'context', 'process', 'require'])( + { paginate, rest: { issues: { listCommentsForRepo: vi.fn() } } }, + { 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 } }, + (name: string) => { + expect(name).toBe('node:fs'); + return { writeFileSync: (path: string, body: string) => files.set(path, body) }; + }, + ); + const records = JSON.parse(files.get(join(f.root, 'activation-comments.json'))!); + 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)).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(); diff --git a/test/gh-aw-command-authorization.test.ts b/test/gh-aw-command-authorization.test.ts index e905941ac..eff3f1f9c 100644 --- a/test/gh-aw-command-authorization.test.ts +++ b/test/gh-aw-command-authorization.test.ts @@ -394,6 +394,16 @@ describe('compiled Squad command authorization boundary (#2185)', () => { 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'); diff --git a/workflows/package/squad.md b/workflows/package/squad.md index 5ab70fb4c..7012c6ea0 100644 --- a/workflows/package/squad.md +++ b/workflows/package/squad.md @@ -279,6 +279,8 @@ safe-outputs: 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'); @@ -293,7 +295,10 @@ safe-outputs: 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); - await activation.reconcileActivation({ github, context, env: process.env, root }); + 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. @@ -402,6 +407,7 @@ safe-outputs: 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. diff --git a/workflows/shared/check-agent-binding.mjs b/workflows/shared/check-agent-binding.mjs index 54f7607b6..39a330f46 100644 --- a/workflows/shared/check-agent-binding.mjs +++ b/workflows/shared/check-agent-binding.mjs @@ -1014,6 +1014,77 @@ async function fetchLabels(repo, issues, token) { 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('='); @@ -1112,7 +1183,8 @@ export async function main() { 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'); } - console.log(checked === 0 ? 'No activation artifact found; skipping.' : `Validated ${checked} activation bindings.`); + 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) { diff --git a/workflows/shared/squad-activation.mjs b/workflows/shared/squad-activation.mjs index d8057ab16..b3331fd69 100644 --- a/workflows/shared/squad-activation.mjs +++ b/workflows/shared/squad-activation.mjs @@ -2,7 +2,7 @@ import { createHash } from 'node:crypto'; import { isDeepStrictEqual } from 'node:util'; import { readFileSync, writeFileSync } from 'node:fs'; import { - extractIssueNumber, parseAgentRegistry, parsePendingReservation, parseRoster, parseStructuredData, selectActivationRecord, + checkActivationPublication, extractIssueNumber, parseAgentRegistry, parsePendingReservation, parseRoster, parseStructuredData, selectActivationRecord, validateActivation, validateCastingPairFiles, } from './check-agent-binding.mjs'; import { authorizeSquadCommand, classifySquadCommand } from './squad-command-contract.mjs'; @@ -630,6 +630,7 @@ async function revalidateCapture({ github, context, env, root, capture }) { 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) && @@ -954,13 +955,26 @@ export async function finalizeActivation({ github, context, env, root, capture, '', 'Activation evidence:', '```json', JSON.stringify(evidence), '```', '', 'Structured data:', '```json', JSON.stringify(recordEnvelope), '```', ].join('\n'); - await github.rest.issues.createComment({ ...context.repo, issue_number: capture.origin_issue, body }); + 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) @@ -1064,7 +1078,7 @@ 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?.type === 'Bot' && + 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' && @@ -1110,6 +1124,7 @@ export async function verifyPublishedActivation({ github, context, root, comment 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'); @@ -1349,6 +1364,7 @@ export async function verifyReconciliationRequest({ 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'); @@ -1393,6 +1409,7 @@ export async function reconcileActivation({ github, context, env, root }) { 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), @@ -1497,9 +1514,7 @@ export async function reconcileActivation({ github, context, env, root }) { JSON.stringify({ squad_artifact: 'activation-reconciled', schema_version: '1', origin_issue: verified.request.origin_issue, phases: verified.request.phases ?? [] }), '```', ].join('\n'); - const result = (await github.rest.issues.createComment({ - ...context.repo, issue_number: verified.request.origin_issue, body, - })).data; + 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 index 80490e34d..07f37c695 100644 --- a/workflows/shared/squad-agent-binding-check.yml +++ b/workflows/shared/squad-agent-binding-check.yml @@ -11,6 +11,7 @@ permissions: jobs: check: + name: Historical bindings (not current-run completion) runs-on: ubuntu-latest steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -19,21 +20,21 @@ jobs: - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v6 with: node-version: 22 - - name: Collect activation records without changing them + - name: Collect historical activation records (not run outputs) uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 with: script: | const fs = require('node:fs'); - const run = context.payload.workflow_run; + // Only the synchronous trusted writer verifies current-run publication. + // Preserve complete history here, including explicit predecessor records. const comments = await github.paginate(github.rest.issues.listCommentsForRepo, { - ...context.repo, since: run.run_started_at, per_page: 100, + ...context.repo, per_page: 100, }); const records = comments - .filter(comment => new Date(comment.created_at) <= new Date(run.updated_at)) .filter(comment => /"squad_artifact"\s*:\s*"(?:activated|phases-activated|plan-accepted|phases-accepted|activation-reconciled)"/.test(comment.body ?? '')) .map(comment => ({ id: comment.id, body: comment.body, issue: Number(comment.issue_url.split('/').at(-1)) })); fs.writeFileSync(`${process.env.RUNNER_TEMP}/activation-comments.json`, JSON.stringify(records)); - - name: Validate current bindings and explicitly authorized reconciliation + - name: Audit historical bindings without asserting current-run completion env: GITHUB_TOKEN: ${{ github.token }} run: >- diff --git a/workflows/squad-workflows.manifest.json b/workflows/squad-workflows.manifest.json index fd83dc0c0..3a8adafdf 100644 --- a/workflows/squad-workflows.manifest.json +++ b/workflows/squad-workflows.manifest.json @@ -14,9 +14,9 @@ "source": "workflows/package/squad.md", "destination": ".github/workflows/squad.md", "lock": ".github/workflows/squad.lock.yml", - "source_sha256": "f9cba967f09d1fde5706e770522d47958c5d9ca66633715c5aa17f6523f80cef", - "lock_sha256": "f4c602bfe0c82b5be0bac43a90c9426566cb7b6dffd1ead4fce9fcc4ba9182f8", - "package_lock_sha256": "44882baa454f6859e321c2d9fbe80720a2cece9e93abf110dd61a3824e2d955e" + "source_sha256": "911d20204f70def43625b9e6fd66e07b0da012122e4827cfc0165531042d57f3", + "lock_sha256": "e3358d8e2c51ce00c2bbc7509394bc4f17aed7d21bcc4be2629dcc58a7ddc0f5", + "package_lock_sha256": "82eee8e33a5ad0b6f85070676b16d46e20076348bc99bb98ec990f4a0c42fa7f" }, { "name": "squad-implement-worker", @@ -88,8 +88,8 @@ "workflows": [ { "name": "squad", - "lock_sha256": "f4c602bfe0c82b5be0bac43a90c9426566cb7b6dffd1ead4fce9fcc4ba9182f8", - "package_lock_sha256": "44882baa454f6859e321c2d9fbe80720a2cece9e93abf110dd61a3824e2d955e" + "lock_sha256": "e3358d8e2c51ce00c2bbc7509394bc4f17aed7d21bcc4be2629dcc58a7ddc0f5", + "package_lock_sha256": "82eee8e33a5ad0b6f85070676b16d46e20076348bc99bb98ec990f4a0c42fa7f" }, { "name": "squad-implement-worker", @@ -136,7 +136,7 @@ "package_destination": ".github/aw/squad/runtime/shared/squad-agent-binding-check.yml", "destination": ".github/workflows/squad-agent-binding-check.yml", "ownership": "manifest", - "sha256": "d789689d0e110bbc75fc42a1def649e9e9ec713a50ec8acc33e174b1a29e4c17" + "sha256": "29445e87b5ee6a185d52b3890ba662acffa2ba2747d3359ed928514f5dd2e925" }, { "path": "shared/squad-activation.mjs", @@ -144,7 +144,7 @@ "package_destination": ".github/workflows/shared/squad-activation.mjs", "destination": ".github/workflows/shared/squad-activation.mjs", "ownership": "manifest", - "sha256": "d2509a6456802ee6c215676812857a6ec6da1c0b98066fa68949c435e68fb0e4" + "sha256": "a6f73d4ed2fd274efe30623803c52e29fd14072c58dcd7c7d3e2a1a0dc54d1b6" }, { "path": "shared/check-agent-binding.mjs", @@ -152,7 +152,7 @@ "package_destination": ".github/workflows/shared/check-agent-binding.mjs", "destination": ".github/workflows/shared/check-agent-binding.mjs", "ownership": "manifest", - "sha256": "059410e62d02effa9299382149f7626f9b46e333e55ebcd42fad55e376473f08" + "sha256": "41b0d33d28c272800081ed4c21966430821e55ced4095c9fe76e14e4763688ca" }, { "path": "shared/squad-review-guard.mjs", diff --git a/workflows/squad.md b/workflows/squad.md index 2ce26bf16..de0f3bbdb 100644 --- a/workflows/squad.md +++ b/workflows/squad.md @@ -299,6 +299,8 @@ safe-outputs: 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'); @@ -313,7 +315,10 @@ safe-outputs: 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); - await activation.reconcileActivation({ github, context, env: process.env, root }); + 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. @@ -422,6 +427,7 @@ safe-outputs: 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." From 4438fe716b290842f04b7a8d78bf9e44a623ad84 Mon Sep 17 00:00:00 2001 From: Brady Gaster Date: Mon, 12 Oct 2026 09:05:10 -0700 Subject: [PATCH 05/11] docs(gh-aw): explain verified casting selection and examples Independent docs-devrel revision adds public casting usage, executable fixture instructions and four declaration JSDoc comments only. Closes #2203 Revision-Author: docs-devrel (42cb5949-3981-4e7a-a012-70862d711675) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 9370fe46-c2d1-486c-ab53-dc21c7f4d0fb --- docs/src/content/docs/guide/gh-aw.md | 103 ++++++++++++++++++ .../squad-sdk/src/casting/agent-provenance.ts | 18 +++ 2 files changed, 121 insertions(+) diff --git a/docs/src/content/docs/guide/gh-aw.md b/docs/src/content/docs/guide/gh-aw.md index d068d36f9..60702ca9c 100644 --- a/docs/src/content/docs/guide/gh-aw.md +++ b/docs/src/content/docs/guide/gh-aw.md @@ -1528,6 +1528,109 @@ was published but the lifecycle write failed, an identical activation rerun vali 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. 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 ``` diff --git a/packages/squad-sdk/src/casting/agent-provenance.ts b/packages/squad-sdk/src/casting/agent-provenance.ts index 2f983b2a0..aba1af879 100644 --- a/packages/squad-sdk/src/casting/agent-provenance.ts +++ b/packages/squad-sdk/src/casting/agent-provenance.ts @@ -87,6 +87,10 @@ export interface WorkAgentBindingContext { 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; @@ -94,6 +98,10 @@ export interface EpicAssignment { 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; @@ -120,6 +128,11 @@ export interface ReconciliationEvidence { 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; @@ -128,6 +141,11 @@ export interface ActivationSelectionContext { 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']; From 2aafc58637ba4544068e0112b5543441b08b3ec4 Mon Sep 17 00:00:00 2001 From: Brady Gaster Date: Mon, 12 Oct 2026 09:28:10 -0700 Subject: [PATCH 06/11] test(gh-aw): compile contract fixture with verified action pins Use the existing canonical pinning helper before compiling; preserve the exact immutable SHA assertion. Separate mechanical fixture correction for Closes #2203. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 9370fe46-c2d1-486c-ab53-dc21c7f4d0fb --- test/gh-aw-quality.test.ts | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/test/gh-aw-quality.test.ts b/test/gh-aw-quality.test.ts index 7d1235da4..f11173f67 100644 --- a/test/gh-aw-quality.test.ts +++ b/test/gh-aw-quality.test.ts @@ -1678,14 +1678,8 @@ describe('gh-aw: compiled workflow shell input security contract', () => { // real deployment layout so `squad-implement-worker` resolves as it will in // every real install. cpSync(WORKFLOWS_DIR, join(workspace, '.github', 'workflows'), { recursive: true }); - // CI compiles with --approve after review. The newly approved action is - // SHA-pinned to this repository and only downloads checksum-verified release - // assets; it receives no secret input. - execFileSync('gh', ['aw', 'compile', '.github/workflows/squad.md', '--strict', '--approve'], { - cwd: workspace, - encoding: 'utf8', - stdio: 'pipe', - }); + // Match consumer compilation: seed and verify immutable compiler-action pins. + compileWithPinnedActions(workspace); const lockPath = join(workspace, '.github', 'workflows', 'squad.lock.yml'); if (!existsSync(lockPath)) { From da46d7ccaea9440600e6377bd7bf4406512ff486 Mon Sep 17 00:00:00 2001 From: Brady Gaster Date: Mon, 12 Oct 2026 09:28:10 -0700 Subject: [PATCH 07/11] fix(gh-aw): preserve recovery scopes and rendered output bounds Independent state-storage-dev correction of code-block task parsing, scoped reconciliation, ordinary-discussion parity, complete attributed UTF-16 body limits and recovery guidance. Generated package metadata refreshed mechanically. Closes #2203. Revision-Author: state-storage-dev (e617e959-be96-44dd-915b-d015eac1da78) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 9370fe46-c2d1-486c-ab53-dc21c7f4d0fb --- .changeset/activation-reconciliation.md | 2 + docs/src/content/docs/guide/gh-aw.md | 17 +- .../squad-sdk/src/casting/agent-provenance.ts | 71 ++++--- test/agent-provenance.test.ts | 40 +++- test/gh-aw-activation-finalization.test.ts | 179 +++++++++++++++++- ...h-aw-activation-label-provisioning.test.ts | 1 + .../gh-aw-activation-summary-outcomes.test.ts | 1 + test/gh-aw-command-authorization.test.ts | 18 ++ test/gh-aw-plan-lifecycle.test.ts | 1 + test/gh-aw-quality.test.ts | 1 + workflows/package/squad.md | 3 +- workflows/shared/check-agent-binding.mjs | 51 +++-- workflows/shared/squad-activation.mjs | 41 ++-- workflows/shared/squad-command-contract.mjs | 36 ++-- workflows/squad-workflows.manifest.json | 16 +- workflows/squad.md | 3 +- 16 files changed, 380 insertions(+), 101 deletions(-) diff --git a/.changeset/activation-reconciliation.md b/.changeset/activation-reconciliation.md index 2fbd010ee..35fe20675 100644 --- a/.changeset/activation-reconciliation.md +++ b/.changeset/activation-reconciliation.md @@ -23,3 +23,5 @@ 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. diff --git a/docs/src/content/docs/guide/gh-aw.md b/docs/src/content/docs/guide/gh-aw.md index 60702ca9c..abcf5af50 100644 --- a/docs/src/content/docs/guide/gh-aw.md +++ b/docs/src/content/docs/guide/gh-aw.md @@ -848,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. @@ -1506,7 +1506,9 @@ 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. After reconciliation, +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 @@ -1853,6 +1855,9 @@ 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. 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 @@ -2567,14 +2572,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 aba1af879..e4aecca36 100644 --- a/packages/squad-sdk/src/casting/agent-provenance.ts +++ b/packages/squad-sdk/src/casting/agent-provenance.ts @@ -684,6 +684,42 @@ function readPendingSourceBody(body: string): unknown { } } +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 @@ -734,35 +770,8 @@ function selectActivationPair( throw new AgentProvenanceError('Invalid or duplicate activation record id'); } byId.set(record.id, record); - // A pending reservation is never an acceptance, including any prior claims. - try { - const pending = readPendingSourceBody(record.body); - if (isRecord(pending) && pending.schema === 'squad-activation-pending/v1') continue; - } catch { - // Non-pending comments still undergo the normal activation classification. - } - const reconciliationMarker = /Activation reconciliation:|activation-reconciled/.test(record.body); - const activationMarker = reconciliationMarker || /Activation bindings:/.test(record.body); - if (!/Structured data:/.test(record.body) && !activationMarker) continue; - let envelope: unknown; - try { - envelope = readActivationSection(record.body, 'Structured data'); - } catch (error) { - if (activationMarker || /activated|plan-accepted|phases-accepted/.test(record.body)) throw error; - continue; - } - const knownArtifact = isRecord(envelope) - && ACTIVATION_ARTIFACTS.includes(envelope.squad_artifact as WorkAgentBinding['artifact']); - if (!knownArtifact && !activationMarker) continue; - if (!reconciliationMarker && isRecord(envelope) && knownArtifact - && envelope.squad_artifact !== 'activation-reconciled' - && isPositiveSafeInteger(envelope.origin_issue) && envelope.origin_issue !== context.originIssue) { - continue; - } - const artifact = activationEnvelope(envelope, context.originIssue); - if (reconciliationMarker && artifact !== 'activation-reconciled') { - throw new AgentProvenanceError('Reconciliation requires activation-reconciled artifact'); - } + 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'); @@ -940,7 +949,9 @@ export function selectActivationRecord( usedRequests.add(proof.requestCommentId); validated.set(record.id, { selected: selected!, source, proof, prior: carrier?.prior }); } - if (usedRecords.size !== records.length || usedProofs.size !== context.reconciliations.length) { + 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(); diff --git a/test/agent-provenance.test.ts b/test/agent-provenance.test.ts index 8196440bd..d01da3b52 100644 --- a/test/agent-provenance.test.ts +++ b/test/agent-provenance.test.ts @@ -8,6 +8,7 @@ import { 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, @@ -849,7 +850,7 @@ ${section('Activation evidence', { } function rejectChain(records: ActivationRecord[], proofs: ReconciliationEvidence[]) { - for (const selector of [selectActivationRecord, selectChecker]) { + for (const selector of [selectActivationRecord, selectBuiltSdk, selectChecker]) { expect(() => selector(records, registry(), context(proofs))).toThrow(); } } @@ -900,6 +901,43 @@ ${section('Activation 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); diff --git a/test/gh-aw-activation-finalization.test.ts b/test/gh-aw-activation-finalization.test.ts index 8b0c43652..71b1467f1 100644 --- a/test/gh-aw-activation-finalization.test.ts +++ b/test/gh-aw-activation-finalization.test.ts @@ -4,6 +4,7 @@ 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'; @@ -29,6 +30,7 @@ import { } 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'; @@ -220,7 +222,8 @@ function fixture({ count = 2, epic = false } = {}) { }); 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, + 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 }); @@ -326,6 +329,18 @@ describe('authenticated legacy activation promotion', () => { }); 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); @@ -343,6 +358,7 @@ describe('retry-safe activation preflight', () => { 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; @@ -354,13 +370,13 @@ describe('retry-safe activation preflight', () => { expect(f.createIssue).not.toHaveBeenCalled(); }); - it.each([65535, 65536, 65537])('bounds the exact rendered %i-character body including Unicode', async size => { + 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 extra = size - baselineBody.length; const f = fixture({ count: 1 }); - f.comments.get(10)!.body += '\n'.repeat(Math.floor(extra / 3)) + '😀'.repeat(extra % 3); + 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) { @@ -369,7 +385,7 @@ describe('retry-safe activation preflight', () => { expect(f.createIssue).not.toHaveBeenCalled(); } else { const journal = await f.create(capture); - expect([...f.writes.find(write => write.method === 'createIssue')!.params.body]).toHaveLength(size); + expect(f.writes.find(write => write.method === 'createIssue')!.params.body).toHaveLength(size); await f.finalize(capture, journal); expect(f.createIssue).toHaveBeenCalledTimes(1); } @@ -623,6 +639,7 @@ describe('trusted whole-plan activation finalization', () => { 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(); @@ -703,6 +720,7 @@ describe('trusted lifecycle recovery and pre-write validation', () => { 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(); @@ -1090,6 +1108,7 @@ describe('trusted synchronous publication completion (source and strict-compiled } }, (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, ); @@ -1113,11 +1132,161 @@ describe('trusted synchronous publication completion (source and strict-compiled }; } + 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); diff --git a/test/gh-aw-activation-label-provisioning.test.ts b/test/gh-aw-activation-label-provisioning.test.ts index f60b1c01c..cb8885d57 100644 --- a/test/gh-aw-activation-label-provisioning.test.ts +++ b/test/gh-aw-activation-label-provisioning.test.ts @@ -152,6 +152,7 @@ function fixture({ owners = ['Builder', 'Reviewer', '@copilot'], implementation 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, diff --git a/test/gh-aw-activation-summary-outcomes.test.ts b/test/gh-aw-activation-summary-outcomes.test.ts index 8e5033004..d9d504fa1 100644 --- a/test/gh-aw-activation-summary-outcomes.test.ts +++ b/test/gh-aw-activation-summary-outcomes.test.ts @@ -138,6 +138,7 @@ function fixture(command: string = 'activate', secondOwner = 'Kint') { 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 }); diff --git a/test/gh-aw-command-authorization.test.ts b/test/gh-aw-command-authorization.test.ts index eff3f1f9c..8f6f7612f 100644 --- a/test/gh-aw-command-authorization.test.ts +++ b/test/gh-aw-command-authorization.test.ts @@ -1107,6 +1107,24 @@ describe('compiled Squad command authorization boundary (#2185)', () => { })).rejects.toThrow('refused mutating mode'); }); + it.each([ + '~~~markdown\n/squad activate\n~~~', + '````markdown\n```\n/squad activate\n```\n````', + '~~~~markdown\n~~~\n/squad activate\n~~~\n~~~~', + ' ~~~markdown\n/squad activate\n ~~~~', + ' /squad activate', + '\t/squad activate', + ' \t/squad activate', + ])('preserves command exclusion for the shared Markdown code rules: %s', example => { + for (const newline of ['\n', '\r\n']) { + const payload = payloadFor('activate'); + payload.comment!.body = example.replaceAll('\n', newline); + expect(classifySquadCommand(payload, 'issue_comment').status).toBe('none'); + payload.comment!.body += `${newline}${newline}/squad plan`; + expect(classifySquadCommand(payload, 'issue_comment')).toMatchObject({ status: 'accepted', mode: 'plan' }); + } + }); + it('detects a live-source mutation that removes authorization after strict compilation', async () => { const fixture = createFirstInstallFixture('a'.repeat(40), 'package'); const mutationRoot = join(scratch, 'mutant'); diff --git a/test/gh-aw-plan-lifecycle.test.ts b/test/gh-aw-plan-lifecycle.test.ts index c7eed688b..10925e03d 100644 --- a/test/gh-aw-plan-lifecycle.test.ts +++ b/test/gh-aw-plan-lifecycle.test.ts @@ -225,6 +225,7 @@ function activationFixture(body = taskTable(), command = 'activate', kind = 'pla const create = (capture: any) => createActivation({ github, context, env, root, capture, createIssue, captureArtifactId: 800, journalPath: join(root, 'receipts.json'), sanitizeContent: (text: string) => text, + formatIssueBody: (value: string) => value, }); const finalize = (capture: any, journal: any) => finalizeActivation({ github, context, env, root, capture, journal, receiptArtifactId: 801, diff --git a/test/gh-aw-quality.test.ts b/test/gh-aw-quality.test.ts index f11173f67..63a81d481 100644 --- a/test/gh-aw-quality.test.ts +++ b/test/gh-aw-quality.test.ts @@ -445,6 +445,7 @@ function activationFixture() { const create = () => createActivation({ github, context, env, root, capture, captureArtifactId: 800, journalPath, createIssue, sanitizeContent: (value: string) => value, + formatIssueBody: (value: string) => value, }); return { root, capture, context, env, github, comments, createIssue, create, savedJournal }; } diff --git a/workflows/package/squad.md b/workflows/package/squad.md index 7012c6ea0..30a14e871 100644 --- a/workflows/package/squad.md +++ b/workflows/package/squad.md @@ -368,13 +368,14 @@ safe-outputs: 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, + 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'), diff --git a/workflows/shared/check-agent-binding.mjs b/workflows/shared/check-agent-binding.mjs index 39a330f46..dc3771d90 100644 --- a/workflows/shared/check-agent-binding.mjs +++ b/workflows/shared/check-agent-binding.mjs @@ -763,6 +763,39 @@ export function parsePendingReservation(body, repository, originIssue) { 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}`); }; @@ -773,18 +806,8 @@ function selectActivationPair(records, registryValue, context) { fail('duplicate or malformed comment identity'); } ids.add(record.id); - 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 } }; - } - if (blocks.length !== 1) fail('exactly one structured envelope is required'); - const artifact = JSON.parse(blocks[0][1]); - if (!ACTIVATION_ARTIFACTS.has(artifact.squad_artifact)) fail('unknown activation artifact'); - if (artifact.origin_issue !== context.originIssue || artifact.schema_version !== '1') fail('artifact origin/schema mismatch'); - return { ...record, artifact }; - }); + 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; @@ -825,6 +848,7 @@ function selectActivationPair(records, registryValue, context) { 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'); @@ -914,7 +938,8 @@ export function selectActivationRecord(records, registryValue, context) { usedRequests.add(proof.requestCommentId); validated.set(record.id, { selected, source, proof, prior: carrier?.prior }); } - if (usedRecords.size !== records.length || usedProofs.size !== context.reconciliations.length) { + 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(); diff --git a/workflows/shared/squad-activation.mjs b/workflows/shared/squad-activation.mjs index b3331fd69..0a4b33022 100644 --- a/workflows/shared/squad-activation.mjs +++ b/workflows/shared/squad-activation.mjs @@ -5,7 +5,7 @@ import { checkActivationPublication, extractIssueNumber, parseAgentRegistry, parsePendingReservation, parseRoster, parseStructuredData, selectActivationRecord, validateActivation, validateCastingPairFiles, } from './check-agent-binding.mjs'; -import { authorizeSquadCommand, classifySquadCommand } from './squad-command-contract.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'; @@ -76,16 +76,13 @@ export function parseActivationPlan(body, kind, registryValue, teamMarkdown, sel requireThat(['plan', 'implementation'].includes(kind), 'unsupported plan artifact'); const registry = parseAgentRegistry(registryValue); const roster = parseRoster(teamMarkdown); - const lines = body.replace(/\r\n?/g, '\n').split('\n'); + const lines = markdownContentLines(body); const phased = lines.some(line => /^### Phase [1-9]\d*(?:\s|$)/.test(line)); let phase = null; - let fence = false; const tasks = []; const ids = new Set(); for (let index = 0; index < lines.length; index++) { const line = lines[index]; - if (/^\s*```/.test(line)) { fence = !fence; continue; } - if (fence) continue; const heading = /^### Phase ([1-9]\d*)(?:\s|$)/.exec(line); if (heading) phase = Number(heading[1]); if (!line.trim().startsWith('|')) continue; @@ -730,10 +727,12 @@ function bodyFor(operation, capture, journal) { }; } -function checkedIssueBody(body, operation, sanitizeContent) { +function checkedIssueBody(body, operation, sanitizeContent, formatIssueBody, repo) { const sanitized = sanitizeContent(body); - requireThat(typeof sanitized === 'string' && [...sanitized].length <= MAX_ISSUE_BODY_CHARACTERS, - `operation ${operation.id}: rendered issue body exceeds GitHub's ${MAX_ISSUE_BODY_CHARACTERS}-character limit`); + 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; } @@ -766,12 +765,13 @@ async function verifyCurrentParent(github, context, issue, parent, operation, fa /** Only this trusted caller receives the runtime's synchronous createIssue result. */ export async function createActivation({ - github, context, env, root, capture, captureArtifactId, journalPath, createIssue, sanitizeContent, + 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, @@ -782,12 +782,12 @@ export async function createActivation({ return [operation.id, title]; })); // Reserve only after rendering every body with the longest accepted numeric - // references. GitHub counts Unicode characters, not UTF-8 bytes. + // 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); + 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, @@ -851,7 +851,7 @@ export async function createActivation({ save(); for (const operation of capture.operations) { const content = bodyFor(operation, capture, journal); - const body = checkedIssueBody(content.body, operation, sanitizeContent); + 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. @@ -1277,17 +1277,16 @@ export async function verifyReconciliationRequest({ 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'); - if (request.phases !== undefined) requireThat(Array.isArray(request.phases) && request.phases.length > 0 && - request.phases.every((phase, index) => positive(phase) && (index === 0 || request.phases[index - 1] < phase)) && - Array.isArray(oldEnvelope.phases), 'selected prior-phase coverage must be ascending unique phase IDs'); + 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 (request.phases !== undefined) { - requireThat(Array.isArray(request.phases) && request.phases.length > 0 && - request.phases.every(positive) && new Set(request.phases).size === request.phases.length && - isDeepStrictEqual([...request.phases].sort((a, b) => a - b), [...oldEnvelope.phases].sort((a, b) => a - b)), - 'selected prior-phase coverage must exactly match the original phase artifact'); - plan.tasks = plan.tasks.filter(task => request.phases.includes(task.phase)); + if (phases.length > 0) { + 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))), diff --git a/workflows/shared/squad-command-contract.mjs b/workflows/shared/squad-command-contract.mjs index 95680786d..aabc9c692 100644 --- a/workflows/shared/squad-command-contract.mjs +++ b/workflows/shared/squad-command-contract.mjs @@ -106,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 @@ -126,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) { @@ -134,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|$)/); diff --git a/workflows/squad-workflows.manifest.json b/workflows/squad-workflows.manifest.json index 3a8adafdf..34d52689d 100644 --- a/workflows/squad-workflows.manifest.json +++ b/workflows/squad-workflows.manifest.json @@ -14,9 +14,9 @@ "source": "workflows/package/squad.md", "destination": ".github/workflows/squad.md", "lock": ".github/workflows/squad.lock.yml", - "source_sha256": "911d20204f70def43625b9e6fd66e07b0da012122e4827cfc0165531042d57f3", - "lock_sha256": "e3358d8e2c51ce00c2bbc7509394bc4f17aed7d21bcc4be2629dcc58a7ddc0f5", - "package_lock_sha256": "82eee8e33a5ad0b6f85070676b16d46e20076348bc99bb98ec990f4a0c42fa7f" + "source_sha256": "fec10a3aa8e0a793d505805078d1a8dc6d64b16b90b65042482e965f174c94d0", + "lock_sha256": "5df2c10668052fd80d7359fe6421307d641812dde47eeab2e2ee96407983654c", + "package_lock_sha256": "b7eaa2d20c0ca207f1812f083893f7cdab571ca713205ad30248a0be3c7760e9" }, { "name": "squad-implement-worker", @@ -88,8 +88,8 @@ "workflows": [ { "name": "squad", - "lock_sha256": "e3358d8e2c51ce00c2bbc7509394bc4f17aed7d21bcc4be2629dcc58a7ddc0f5", - "package_lock_sha256": "82eee8e33a5ad0b6f85070676b16d46e20076348bc99bb98ec990f4a0c42fa7f" + "lock_sha256": "5df2c10668052fd80d7359fe6421307d641812dde47eeab2e2ee96407983654c", + "package_lock_sha256": "b7eaa2d20c0ca207f1812f083893f7cdab571ca713205ad30248a0be3c7760e9" }, { "name": "squad-implement-worker", @@ -144,7 +144,7 @@ "package_destination": ".github/workflows/shared/squad-activation.mjs", "destination": ".github/workflows/shared/squad-activation.mjs", "ownership": "manifest", - "sha256": "a6f73d4ed2fd274efe30623803c52e29fd14072c58dcd7c7d3e2a1a0dc54d1b6" + "sha256": "b1ddb1582ec799b53155e1ea64193f8273354a04c5c6da00d1a6ad2b4b912af0" }, { "path": "shared/check-agent-binding.mjs", @@ -152,7 +152,7 @@ "package_destination": ".github/workflows/shared/check-agent-binding.mjs", "destination": ".github/workflows/shared/check-agent-binding.mjs", "ownership": "manifest", - "sha256": "41b0d33d28c272800081ed4c21966430821e55ced4095c9fe76e14e4763688ca" + "sha256": "ea5e11079e0dfc12374932225cec752108d6119e50340a4e210f9c22bfff4f6b" }, { "path": "shared/squad-review-guard.mjs", @@ -176,7 +176,7 @@ "package_destination": ".github/workflows/shared/squad-command-contract.mjs", "destination": ".github/workflows/shared/squad-command-contract.mjs", "ownership": "manifest", - "sha256": "a03a22e33e18a2452099af1651a6a67f3831b31a20895c3148362c6549ea792e" + "sha256": "577e8787ae2e6ccd767e136f97f187166b8bd1d7f599f4c8b86d3bc571daf2de" }, { "path": "shared/squad-cast-validator.mjs", diff --git a/workflows/squad.md b/workflows/squad.md index de0f3bbdb..03fb8290f 100644 --- a/workflows/squad.md +++ b/workflows/squad.md @@ -388,13 +388,14 @@ safe-outputs: 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, + 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'), From d97b1e6ae73ee7c3608f96660c232df5fb7c8b1b Mon Sep 17 00:00:00 2001 From: Brady Gaster Date: Mon, 12 Oct 2026 09:32:03 -0700 Subject: [PATCH 08/11] docs(gh-aw): build current SDK before activation examples Document the SDK build prerequisite for both supported compiler paths. Independently revised and exercised by docs-devrel against freshly generated public casting exports. Closes #2203. Revision-Author: docs-devrel (42cb5949-3981-4e7a-a012-70862d711675) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 9370fe46-c2d1-486c-ab53-dc21c7f4d0fb --- docs/src/content/docs/guide/gh-aw.md | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/docs/src/content/docs/guide/gh-aw.md b/docs/src/content/docs/guide/gh-aw.md index abcf5af50..3e4f30cf0 100644 --- a/docs/src/content/docs/guide/gh-aw.md +++ b/docs/src/content/docs/guide/gh-aw.md @@ -1614,7 +1614,14 @@ 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. With the pinned extension available, run: +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" && From 7912e35e735b7b7bdaf5a69c70bb807c5af2f9f9 Mon Sep 17 00:00:00 2001 From: Brady Gaster Date: Mon, 12 Oct 2026 09:58:47 -0700 Subject: [PATCH 09/11] fix(gh-aw): bound automatic historical activation audits Retain automatic audits with bounded historical-origin discovery and a shared API budget. Preserve complete-chain validation and explicit incomplete outcomes; keep manual full-history audits available. Independently implemented by state-storage-dev, with mechanically refreshed package integrity metadata. Closes #2203. Revision-Author: state-storage-dev (e617e959-be96-44dd-915b-d015eac1da78) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 9370fe46-c2d1-486c-ab53-dc21c7f4d0fb --- .../workflows/squad-agent-binding-check.yml | 22 +- docs/src/content/docs/guide/gh-aw.md | 19 +- ...h-aw-activation-artifact-integrity.test.ts | 10 +- test/gh-aw-activation-finalization.test.ts | 247 ++++++++++++++++-- workflows/shared/check-agent-binding.mjs | 130 +++++++-- .../shared/squad-agent-binding-check.yml | 16 +- workflows/squad-workflows.manifest.json | 4 +- 7 files changed, 383 insertions(+), 65 deletions(-) diff --git a/.github/workflows/squad-agent-binding-check.yml b/.github/workflows/squad-agent-binding-check.yml index 6dd0dfb9d..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. @@ -25,23 +26,14 @@ jobs: with: script: | const fs = require('node:fs'); - // Current-run completion is checked against the synchronous publication - // response inside the trusted writer. This is only a historical audit. - const comments = await github.paginate(github.rest.issues.listCommentsForRepo, { - ...context.repo, - per_page: 100, - }); - const activationComments = comments - .filter(comment => /Structured data:/i.test(comment.body ?? '') - && /"squad_artifact"\s*:\s*"?(?:phases-activated|activated|phases-accepted|plan-accepted|activation-reconciled)/i.test(comment.body ?? '')) - .map(comment => ({ - id: comment.id, - 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: Audit historical bindings without asserting current-run completion env: diff --git a/docs/src/content/docs/guide/gh-aw.md b/docs/src/content/docs/guide/gh-aw.md index 3e4f30cf0..7ba720a78 100644 --- a/docs/src/content/docs/guide/gh-aw.md +++ b/docs/src/content/docs/guide/gh-aw.md @@ -1414,7 +1414,24 @@ 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 over repository comments, including old predecessors. Its success (including +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. diff --git a/test/gh-aw-activation-artifact-integrity.test.ts b/test/gh-aw-activation-artifact-integrity.test.ts index 739f081b9..8086633e2 100644 --- a/test/gh-aw-activation-artifact-integrity.test.ts +++ b/test/gh-aw-activation-artifact-integrity.test.ts @@ -640,11 +640,13 @@ const COLLECTOR_ARTIFACT_REGEX_SOURCE = 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', 'activation-reconciled']) { expect( @@ -654,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-finalization.test.ts b/test/gh-aw-activation-finalization.test.ts index 71b1467f1..9e2c1aad6 100644 --- a/test/gh-aw-activation-finalization.test.ts +++ b/test/gh-aw-activation-finalization.test.ts @@ -24,6 +24,7 @@ import { } from '../workflows/shared/squad-activation.mjs'; import { parseStructuredData, + collectHistoricalActivationRecords, selectActivationRecord as selectChecker, validateCastingPairFiles, main as checkBindings, @@ -1599,9 +1600,13 @@ describe('explicit pending-source current-state reconciliation', () => { }); }); -async function runCollectedChecker(f: ReturnType, comments: Array<{ id: number; body: string }>) { +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'); - writeFileSync(input, JSON.stringify(comments.map(({ id, body }) => ({ id, body, issue: 42 })))); + 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(() => {}); @@ -1609,8 +1614,14 @@ async function runCollectedChecker(f: ReturnType, comments: Arra 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; - if (path === 'issues/42/comments') data = [...f.comments.values()]; + 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])); @@ -1620,12 +1631,12 @@ async function runCollectedChecker(f: ReturnType, comments: Arra 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 }); + 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 checkBindings(); + await (options.checker ?? checkBindings)(); return log.mock.calls.map(args => args[0]); } finally { process.argv = argv; @@ -1637,8 +1648,194 @@ async function runCollectedChecker(f: ReturnType, comments: Arra } describe('live downstream verification', () => { - it.each(['.github/workflows/squad-agent-binding-check.yml', 'workflows/shared/squad-agent-binding-check.yml'])( - '%s executes a historical audit, retaining old explicit predecessors without run attestation', async path => { + 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)); @@ -1651,26 +1848,42 @@ describe('live downstream verification', () => { 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 paginate = vi.fn(async (_method: any, params: any) => { + 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 clone([...f.comments.values()]); + 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'])( - { paginate, rest: { issues: { listCommentsForRepo: vi.fn() } } }, - { repo: f.context.repo, payload: { workflow_run: { + ['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 } }, + { env: { RUNNER_TEMP: f.root, GITHUB_WORKSPACE: workspace } }, (name: string) => { - expect(name).toBe('node:fs'); - return { writeFileSync: (path: string, body: string) => files.set(path, body) }; + if (name === 'node:fs') return { writeFileSync: (path: string, body: string) => files.set(path, body) }; + return createRequire(import.meta.url)(name); }, ); - const records = JSON.parse(files.get(join(f.root, 'activation-comments.json'))!); + 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)).toContain('Validated 3 activation bindings.'); + 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.', ); diff --git a/workflows/shared/check-agent-binding.mjs b/workflows/shared/check-agent-binding.mjs index dc3771d90..a40a4f964 100644 --- a/workflows/shared/check-agent-binding.mjs +++ b/workflows/shared/check-agent-binding.mjs @@ -991,15 +991,88 @@ export function selectActivationRecord(records, registryValue, context) { return validated.get(heads[0]).selected; } -function readOnlyGithub(repo, token) { +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() }; + return { data: await response.json(), headers: { link: response.headers.get('link') ?? '' } }; }; const rest = { issues: { @@ -1014,26 +1087,20 @@ function readOnlyGithub(repo, token) { return { rest, paginate: async (method, params) => { const all = []; for (let page = 1; ; page++) { - const { data } = await method({ ...params, 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 (data.length < 100) return all; + if (!/<[^>]+>;\s*rel="next"/.test(headers?.link ?? '') && + (headers !== undefined || data.length < 100)) return all; } } }; } -async function fetchLabels(repo, issues, token) { +async function fetchLabels(github, repo, issues) { const labels = new Map(); + const [owner, name] = repo.split('/'); 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(); + 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; @@ -1125,12 +1192,35 @@ export async function main() { const token = process.env.GITHUB_TOKEN; if (!token) throw new Error('GITHUB_TOKEN is required for activation binding checks'); - const comments = args['comments-file'] + 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); + 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 } = @@ -1169,6 +1259,7 @@ export async function main() { 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 ?? ''); @@ -1193,13 +1284,14 @@ export async function main() { 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 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, { @@ -1208,6 +1300,10 @@ export async function main() { 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.`); } diff --git a/workflows/shared/squad-agent-binding-check.yml b/workflows/shared/squad-agent-binding-check.yml index 07f37c695..667045c8f 100644 --- a/workflows/shared/squad-agent-binding-check.yml +++ b/workflows/shared/squad-agent-binding-check.yml @@ -1,6 +1,7 @@ name: Squad Agent Binding Check on: + workflow_dispatch: workflow_run: workflows: [Squad] types: [completed] @@ -25,15 +26,12 @@ jobs: with: script: | const fs = require('node:fs'); - // Only the synchronous trusted writer verifies current-run publication. - // Preserve complete history here, including explicit predecessor records. - const comments = await github.paginate(github.rest.issues.listCommentsForRepo, { - ...context.repo, per_page: 100, - }); - const records = comments - .filter(comment => /"squad_artifact"\s*:\s*"(?:activated|phases-activated|plan-accepted|phases-accepted|activation-reconciled)"/.test(comment.body ?? '')) - .map(comment => ({ id: comment.id, body: comment.body, issue: Number(comment.issue_url.split('/').at(-1)) })); - fs.writeFileSync(`${process.env.RUNNER_TEMP}/activation-comments.json`, JSON.stringify(records)); + 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 }} diff --git a/workflows/squad-workflows.manifest.json b/workflows/squad-workflows.manifest.json index 34d52689d..7c96a1026 100644 --- a/workflows/squad-workflows.manifest.json +++ b/workflows/squad-workflows.manifest.json @@ -136,7 +136,7 @@ "package_destination": ".github/aw/squad/runtime/shared/squad-agent-binding-check.yml", "destination": ".github/workflows/squad-agent-binding-check.yml", "ownership": "manifest", - "sha256": "29445e87b5ee6a185d52b3890ba662acffa2ba2747d3359ed928514f5dd2e925" + "sha256": "e3098d9e9c4f5c711f37718083fb285b4778ecc987fee0c864429b8d868336b3" }, { "path": "shared/squad-activation.mjs", @@ -152,7 +152,7 @@ "package_destination": ".github/workflows/shared/check-agent-binding.mjs", "destination": ".github/workflows/shared/check-agent-binding.mjs", "ownership": "manifest", - "sha256": "ea5e11079e0dfc12374932225cec752108d6119e50340a4e210f9c22bfff4f6b" + "sha256": "261d766684ad213a41848e8c62e026b01492bf624d0e141c1ae7a36308c83b56" }, { "path": "shared/squad-review-guard.mjs", From c2b3cc9867f57f2dac0a91653cbd8005e11d4aa5 Mon Sep 17 00:00:00 2001 From: Brady Gaster Date: Mon, 12 Oct 2026 10:16:16 -0700 Subject: [PATCH 10/11] fix(gh-aw): align reconciliation consumer phase scope Require source activation phase scope to match verified evidence in both SDK and canonical checker. Add built-SDK parity and defensive mismatch regressions without changing public APIs or trusted writer behavior. Closes #2203. Revision-Author: typescript-api-contract-designer (c2726384-189e-4dfe-a10f-f466007b4852) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 9370fe46-c2d1-486c-ab53-dc21c7f4d0fb --- .changeset/activation-reconciliation.md | 2 + .../squad-sdk/src/casting/agent-provenance.ts | 9 ++- test/agent-provenance.test.ts | 10 +-- test/gh-aw-activation-finalization.test.ts | 67 +++++++++++++++++++ workflows/shared/check-agent-binding.mjs | 5 +- workflows/squad-workflows.manifest.json | 2 +- 6 files changed, 85 insertions(+), 10 deletions(-) diff --git a/.changeset/activation-reconciliation.md b/.changeset/activation-reconciliation.md index 35fe20675..ed5be1d3f 100644 --- a/.changeset/activation-reconciliation.md +++ b/.changeset/activation-reconciliation.md @@ -8,6 +8,8 @@ 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 diff --git a/packages/squad-sdk/src/casting/agent-provenance.ts b/packages/squad-sdk/src/casting/agent-provenance.ts index e4aecca36..d2f3a2bbe 100644 --- a/packages/squad-sdk/src/casting/agent-provenance.ts +++ b/packages/squad-sdk/src/casting/agent-provenance.ts @@ -822,9 +822,12 @@ function selectActivationPair( throw new AgentProvenanceError('Malformed pending source or mismatched verifier plan, registry, or phase scope'); } } else { - const sourceArtifact = activationEnvelope( - readActivationSection(source.body, 'Structured data'), context.originIssue, - ); + 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'); } diff --git a/test/agent-provenance.test.ts b/test/agent-provenance.test.ts index d01da3b52..656aa6bee 100644 --- a/test/agent-provenance.test.ts +++ b/test/agent-provenance.test.ts @@ -74,7 +74,7 @@ describe('agent identity provenance contract', () => { const context = (reconciliations: readonly ReconciliationEvidence[] = []) => ({ repository: 'owner/repo', originIssue: 45, reconciliations, }); - function reconciled(sourceBody = body('activated', [{ issue: 'temporary-task' }])) { + function reconciled(sourceBody = body('activated', [{ issue: 'temporary-task' }], 45, [])) { const source = { id: 10, body: sourceBody }; const rows = bindings('activation-reconciled'); const currentRegistry = registry(); @@ -317,7 +317,7 @@ describe('agent identity provenance contract', () => { }); it('accepts explicit supersession of malformed original binding JSON', () => { - const test = reconciled(body().replace(JSON.stringify(bindings()), '{not 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); @@ -383,7 +383,7 @@ describe('agent identity provenance contract', () => { }); it('accepts only the explicitly verified canonical phase scope', () => { - const test = reconciled(); + 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] }; @@ -418,7 +418,7 @@ describe('agent identity provenance contract', () => { }); function frozenPhase() { - const test = reconciled(); + const test = reconciled(body('activated', [{ issue: 'temporary-task' }], 45, [1])); test.currentRegistry.agents['quality-engineer'] = { ...test.currentRegistry.agents['runtime-engineer']!, display_name: 'Orbit', @@ -663,7 +663,7 @@ describe('agent identity provenance contract', () => { }); it('rejects duplicates, competing reconciliations, and unsuperseded old acceptances', () => { - const test = reconciled(body()); + const test = reconciled(body('activated', bindings(), 45, [])); for (const records of [ [test.source, test.source], [test.source, { ...test.source, id: 11 }], diff --git a/test/gh-aw-activation-finalization.test.ts b/test/gh-aw-activation-finalization.test.ts index 9e2c1aad6..2be2b99d7 100644 --- a/test/gh-aw-activation-finalization.test.ts +++ b/test/gh-aw-activation-finalization.test.ts @@ -987,6 +987,72 @@ describe('dependency correction field grammar', () => { }); 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])); }], @@ -1577,6 +1643,7 @@ describe('explicit pending-source current-state reconciliation', () => { 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); diff --git a/workflows/shared/check-agent-binding.mjs b/workflows/shared/check-agent-binding.mjs index a40a4f964..88d0dca07 100644 --- a/workflows/shared/check-agent-binding.mjs +++ b/workflows/shared/check-agent-binding.mjs @@ -843,7 +843,10 @@ function selectActivationPair(records, registryValue, context) { 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'); + } 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'); diff --git a/workflows/squad-workflows.manifest.json b/workflows/squad-workflows.manifest.json index 7c96a1026..fbc4372b3 100644 --- a/workflows/squad-workflows.manifest.json +++ b/workflows/squad-workflows.manifest.json @@ -152,7 +152,7 @@ "package_destination": ".github/workflows/shared/check-agent-binding.mjs", "destination": ".github/workflows/shared/check-agent-binding.mjs", "ownership": "manifest", - "sha256": "261d766684ad213a41848e8c62e026b01492bf624d0e141c1ae7a36308c83b56" + "sha256": "db562006405e027ab16d06816e7c1ea7e1b651a390f4a423520af8835fe9835c" }, { "path": "shared/squad-review-guard.mjs", From f558617be67e51dc0ef8b6223e739e5369ec5b07 Mon Sep 17 00:00:00 2001 From: Brady Gaster Date: Mon, 12 Oct 2026 10:39:51 -0700 Subject: [PATCH 11/11] fix(gh-aw): enforce consistent phase ancestry invariants Require positional-prefix ancestry and reject whole-plan predecessor expansion and nonexistent selected phases. Exercise real producer/verifier/built SDK/packaged checker contracts across a bounded matrix, retaining sparse phases and no-create recovery. Closes #2203. Revision-Author: sdk-runtime-dev (d6968b87-1bf1-4aac-b06c-e0479ef763a3) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 9370fe46-c2d1-486c-ab53-dc21c7f4d0fb --- .changeset/activation-reconciliation.md | 3 + .../squad-sdk/src/casting/agent-provenance.ts | 2 +- test/agent-provenance.test.ts | 12 ++ test/gh-aw-activation-finalization.test.ts | 204 ++++++++++++++++++ workflows/shared/check-agent-binding.mjs | 2 +- workflows/shared/squad-activation.mjs | 3 + workflows/squad-workflows.manifest.json | 4 +- 7 files changed, 226 insertions(+), 4 deletions(-) diff --git a/.changeset/activation-reconciliation.md b/.changeset/activation-reconciliation.md index ed5be1d3f..bfe3d6255 100644 --- a/.changeset/activation-reconciliation.md +++ b/.changeset/activation-reconciliation.md @@ -27,3 +27,6 @@ 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/packages/squad-sdk/src/casting/agent-provenance.ts b/packages/squad-sdk/src/casting/agent-provenance.ts index d2f3a2bbe..f8818fec8 100644 --- a/packages/squad-sdk/src/casting/agent-provenance.ts +++ b/packages/squad-sdk/src/casting/agent-provenance.ts @@ -978,7 +978,7 @@ export function selectActivationRecord( 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) => after.includes(phase))) { + 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])); diff --git a/test/agent-provenance.test.ts b/test/agent-provenance.test.ts index 656aa6bee..7009e726e 100644 --- a/test/agent-provenance.test.ts +++ b/test/agent-provenance.test.ts @@ -993,6 +993,18 @@ ${section('Activation 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 }; diff --git a/test/gh-aw-activation-finalization.test.ts b/test/gh-aw-activation-finalization.test.ts index 2be2b99d7..1b3af1ae5 100644 --- a/test/gh-aw-activation-finalization.test.ts +++ b/test/gh-aw-activation-finalization.test.ts @@ -2059,6 +2059,210 @@ describe('live downstream verification', () => { }); }); +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(); diff --git a/workflows/shared/check-agent-binding.mjs b/workflows/shared/check-agent-binding.mjs index 88d0dca07..5e1d28ffa 100644 --- a/workflows/shared/check-agent-binding.mjs +++ b/workflows/shared/check-agent-binding.mjs @@ -963,7 +963,7 @@ export function selectActivationRecord(records, registryValue, context) { 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 => after.includes(phase))) { + 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])); diff --git a/workflows/shared/squad-activation.mjs b/workflows/shared/squad-activation.mjs index 0a4b33022..2a9fada31 100644 --- a/workflows/shared/squad-activation.mjs +++ b/workflows/shared/squad-activation.mjs @@ -1286,6 +1286,8 @@ export async function verifyReconciliationRequest({ 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'); @@ -1374,6 +1376,7 @@ export async function verifyReconciliationRequest({ 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) && diff --git a/workflows/squad-workflows.manifest.json b/workflows/squad-workflows.manifest.json index fbc4372b3..b2ba4b5b8 100644 --- a/workflows/squad-workflows.manifest.json +++ b/workflows/squad-workflows.manifest.json @@ -144,7 +144,7 @@ "package_destination": ".github/workflows/shared/squad-activation.mjs", "destination": ".github/workflows/shared/squad-activation.mjs", "ownership": "manifest", - "sha256": "b1ddb1582ec799b53155e1ea64193f8273354a04c5c6da00d1a6ad2b4b912af0" + "sha256": "16c579cc85ff191614a9635757ee5281f4f9f3f1f0f8ead44fe33c44b90a0980" }, { "path": "shared/check-agent-binding.mjs", @@ -152,7 +152,7 @@ "package_destination": ".github/workflows/shared/check-agent-binding.mjs", "destination": ".github/workflows/shared/check-agent-binding.mjs", "ownership": "manifest", - "sha256": "db562006405e027ab16d06816e7c1ea7e1b651a390f4a423520af8835fe9835c" + "sha256": "8ccf2d63049b38a52f78068f2b4dead16a9cda5d947618bf484832e339952892" }, { "path": "shared/squad-review-guard.mjs",