From f8545a1ba3175ce7a8dd93cdc39b604e366fd7bd Mon Sep 17 00:00:00 2001 From: Brady Gaster Date: Tue, 15 Sep 2026 10:48:25 -0700 Subject: [PATCH 1/2] fix(gh-aw): align lifecycle producer contract Closes #2051 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- test/gh-aw-lifecycle-upsert.test.ts | 101 +++++++++++++++++++- test/gh-aw-plan-lifecycle.test.ts | 1 + test/gh-aw-quality.test.ts | 6 +- workflows/shared/squad-planning-ontology.md | 7 +- workflows/shared/squad.md | 19 ++-- workflows/squad.md | 5 +- 6 files changed, 125 insertions(+), 14 deletions(-) diff --git a/test/gh-aw-lifecycle-upsert.test.ts b/test/gh-aw-lifecycle-upsert.test.ts index cd8316d23..306ea4a7f 100644 --- a/test/gh-aw-lifecycle-upsert.test.ts +++ b/test/gh-aw-lifecycle-upsert.test.ts @@ -376,6 +376,55 @@ describe('#1916: deterministic lifecycle safe output', () => { '| Plan | โœ… Done |', '| Activate | โœ… Done |', ].join('\n'); + const liveScopeAcceptanceBody = [ + '## ๐Ÿงญ Squad Planning Lifecycle', + '', + '**State:** Scope Accepted', + '', + '| Field | Value |', + '|---|---|', + '| Research | โœ… Done |', + '| Triage | โœ… Done |', + '| Program Plan | โœ… Done (revised) |', + '| Implementation Plan | โœ… Done |', + '| Validation | โœ… Done (PASS) |', + '| Scope | โœ… Done |', + '| Implementation Acceptance | โฌœ Pending |', + '', + '**Last command:** `/squad plan accept scope`', + '', + '**Next action:** `/squad plan accept implementation`', + ].join('\n'); + const liveImplementationAcceptanceBody = [ + '## Planning Lifecycle', + '', + '| Phase | Status | Artifact | Updated |', + '|-------|--------|----------|---------|', + '| Intent | โœ… Done | (issue body) | 2026-09-15 |', + '| Scope Accepted | โœ… Done | comment | 2026-09-15 |', + '| Impl Accepted | โœ… Done | (this run) | 2026-09-15 |', + '| Activated | โฌš Pending | โ€” | โ€” |', + '', + '**Current state:** Implementation Accepted', + '**Last command:** `/squad plan accept implementation` by @bradygaster at 2026-09-15 15:16 UTC', + '**Next action:** `/squad plan activate`', + '**Guidance:** Create sub-issues and begin execution.', + ].join('\n'); + const liveActivatedBody = [ + '## ๐Ÿงญ Squad Planning Lifecycle', + '', + '**State:** Activated', + '', + '| Field | Value |', + '|---|---|', + '| Scope Acceptance | โœ… Done |', + '| Implementation Acceptance | โœ… Done (full, 5 tasks) |', + '| Activation | โœ… Done โ€” 4 epics, 5 tasks created as sub-issues of #4 |', + '', + '**Last command:** `/squad plan activate`', + '', + '**Next action:** No further planning command required โ€” this plan is fully activated. Begin implementation execution starting with the Epic 1.1 spike task.', + ].join('\n'); it('creates the first tracker with the fixed structured envelope', async () => { const result = await runLifecycleUpsert([ @@ -497,7 +546,10 @@ describe('#1916: deterministic lifecycle safe output', () => { ['progress-table activation with command attribution', terminalTableBody], ['plain progress-list activation', terminalProgressBody], ['activate-stage table row', terminalActivateTableBody], - ])('accepts live terminal presentation: %s', async (_name, liveBody) => { + ['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) => { const result = await runLifecycleUpsert([ { type: 'upsert_lifecycle_state', body: liveBody }, ]); @@ -561,6 +613,25 @@ describe('#1916: deterministic lifecycle safe output', () => { expect(result.created).toEqual([]); }); + it('rejects the live implementation-acceptance mutation from run 34986842391', async () => { + const result = await runLifecycleUpsert([ + { + type: 'upsert_lifecycle_state', + body: liveImplementationAcceptanceBody + .replace( + '**Next action:** `/squad plan activate`', + '**Next action:** `/squad plan activate` โ€” create sub-issues and begin execution.', + ) + .replace('\n**Guidance:** Create sub-issues and begin execution.', ''), + }, + ]); + + expect(result.failures).toEqual([ + 'Lifecycle body must include an H2 lifecycle heading plus state, last-command, and a nonterminal next-action value consisting of a backticked /squad command.', + ]); + expect(result.created).toEqual([]); + }); + it.each([ ['state', body.replace('**Current state:** Planned\n', '')], ['last command', body.replace('**Last command:** `/squad plan`\n', '')], @@ -607,6 +678,19 @@ describe('#1928: deterministic terminal lifecycle repair', () => { created_at: '2026-08-28T01:00:00Z', user: { login: 'github-actions[bot]' }, }; + const activated = { + id: 11, + body: [ + '## Plan activated', + '', + 'Structured data:', + '```json', + '{"squad_artifact":"activated","schema_version":"1","origin_issue":5,"phases":[]}', + '```', + ].join('\n'), + created_at: '2026-08-28T01:30:00Z', + user: { login: 'github-actions[bot]' }, + }; const lifecycleEnvelope = '{"squad_artifact":"lifecycle-state","schema_version":"1","origin_issue":5,"phases":[]}'; const stale = { @@ -640,6 +724,19 @@ describe('#1928: deterministic terminal lifecycle repair', () => { expect(result.updated[0].body).toContain(lifecycleEnvelope); }); + it('repairs granular /squad plan activate lifecycle state from a trusted activation', async () => { + const result = await runLifecycleRepair( + [activated, stale], + '/squad plan activate', + ); + + expect(result.failures).toEqual([]); + expect(result.created).toEqual([]); + expect(result.updated).toHaveLength(1); + expect(result.updated[0].body).toContain('- **State:** Activated'); + expect(result.updated[0].body).toContain('- **Last command:** `/squad plan activate`'); + }); + it('does nothing when the newest tracker is already terminal', async () => { const terminal = { ...stale, @@ -667,7 +764,7 @@ describe('#1928: deterministic terminal lifecycle repair', () => { expect(result.created).toEqual([]); expect(result.updated).toEqual([]); expect(result.info).toContain( - 'No trusted whole-plan acceptance artifact; lifecycle repair is not applicable.', + 'No trusted whole-plan acceptance or activation artifact; lifecycle repair is not applicable.', ); }); diff --git a/test/gh-aw-plan-lifecycle.test.ts b/test/gh-aw-plan-lifecycle.test.ts index 3a786d608..4673cdecd 100644 --- a/test/gh-aw-plan-lifecycle.test.ts +++ b/test/gh-aw-plan-lifecycle.test.ts @@ -1031,6 +1031,7 @@ describe('#1916: fast-path commands maintain the planning lifecycle state', () = 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', () => { diff --git a/test/gh-aw-quality.test.ts b/test/gh-aw-quality.test.ts index 9151cd879..5bd450810 100644 --- a/test/gh-aw-quality.test.ts +++ b/test/gh-aw-quality.test.ts @@ -492,7 +492,7 @@ describe('#1916: lifecycle comment updates use a deterministic safe-output job', "!contains(needs.agent.outputs.output_types, 'upsert_lifecycle_state')", ); expect(shared).toContain("github.event.comment.body == '/squad activate'"); - expect(shared).toContain('envelope?.squad_artifact === "plan-accepted"'); + expect(shared).toContain('["plan-accepted", "activated"].includes(envelope?.squad_artifact)'); expect(shared).toContain('name: Repair terminal lifecycle after idempotent activation'); }); @@ -1661,6 +1661,10 @@ describe('gh-aw: compiled workflow shell input security contract', () => { ); expect(compiled).toContain('github.rest.repos.getCollaboratorPermissionLevel'); expect(compiled).toContain('["admin", "maintain", "write"].includes(permission)'); + expect(compiled).toContain("github.event.comment.body == '/squad plan activate'"); + expect(compiled).toContain( + '["/squad activate", "/squad plan accept", "/squad plan activate"].includes(command)', + ); }, 20000); it('compiles Cast failure into a queryable post-agent job that fails the run', () => { diff --git a/workflows/shared/squad-planning-ontology.md b/workflows/shared/squad-planning-ontology.md index 55e5f754c..c522d3e9d 100644 --- a/workflows/shared/squad-planning-ontology.md +++ b/workflows/shared/squad-planning-ontology.md @@ -395,10 +395,15 @@ This comment is created on first transition and updated on every subsequent tran **Current state:** Triaged **Last command:** `/squad triage` by @user at -**Next action:** `/squad plan program` โ€” create a program plan from triage dispositions +**Next action:** `/squad plan program` +**Guidance:** Create a program plan from triage dispositions. **Also available:** `/squad triage revise ` โ€” adjust triage before planning ``` +For nonterminal states, `Next action` is only the backticked `/squad` command; +put prose elsewhere. `Activated` is terminal and may use terminal prose there. +Its canonical granular command is `/squad plan activate`. + Status icons: `โœ… Done` ยท `โณ In Progress` ยท `โฌš Pending` ยท `โŒ Failed` ยท `โญ Skipped` --- diff --git a/workflows/shared/squad.md b/workflows/shared/squad.md index 9484384e2..8da2c44d9 100644 --- a/workflows/shared/squad.md +++ b/workflows/shared/squad.md @@ -247,11 +247,11 @@ safe-outputs: const hasNextCommand = /^(?:[-*]\s+)?\*\*Next (?:action|command|recommended):\*\*\s+`\/squad\b[^`]*`[ \t]*$/im.test(body); const hasActivationDone = /^(?:[-*]\s+)?(?:\*\*)?Activation:(?:\*\*)?\s+โœ…\s+Done\b/im.test(body) || - /^\|\s*Activat(?:e|ion|ed)\s*\|\s*โœ…\s+Done\s*\|/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)(?: phase \d+)?`(?:\s+.*)?$/im.test(body) && + /^(?:[-*]\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; if (!hasLifecycleHeading || !hasState || !hasLastCommand || !hasNextAction) { @@ -315,7 +315,8 @@ jobs: !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') + github.event.comment.body == '/squad plan accept' || + github.event.comment.body == '/squad plan activate') }} runs-on: ubuntu-slim permissions: @@ -334,7 +335,7 @@ jobs: if ( !Number.isInteger(issueNumber) || issueNumber <= 0 || - !["/squad activate", "/squad plan accept"].includes(command) + !["/squad activate", "/squad plan accept", "/squad plan activate"].includes(command) ) { core.setFailed("A valid whole-plan activation command and issue number are required."); return; @@ -387,16 +388,16 @@ jobs: comment, envelope: envelopeFor(comment), })); - const accepted = artifacts.some( + const activatedOrAccepted = artifacts.some( ({ envelope }) => - envelope?.squad_artifact === "plan-accepted" && + ["plan-accepted", "activated"].includes(envelope?.squad_artifact) && envelope?.schema_version === "1" && envelope?.origin_issue === issueNumber && Array.isArray(envelope?.phases) && envelope.phases.length === 0, ); - if (!accepted) { - core.info("No trusted whole-plan acceptance artifact; lifecycle repair is not applicable."); + if (!activatedOrAccepted) { + core.info("No trusted whole-plan acceptance or activation artifact; lifecycle repair is not applicable."); return; } @@ -415,7 +416,7 @@ jobs: const terminal = /^(?:[-*]\s+)?\*\*(?:Current state|State):\*\*\s+Activated\s*$/im.test(lifecycleBody) && /^(?:[-*]\s+)?\*\*Activation:\*\*\s+โœ…\s+Done\s*$/im.test(lifecycleBody) && - /^(?:[-*]\s+)?\*\*Last command:\*\*\s+`\/squad (?:activate|plan accept)`\s*$/im.test(lifecycleBody); + /^(?:[-*]\s+)?\*\*Last command:\*\*\s+`\/squad (?:activate|plan accept|plan activate)`\s*$/im.test(lifecycleBody); if (terminal) { core.info("The newest lifecycle tracker already records terminal activation."); return; diff --git a/workflows/squad.md b/workflows/squad.md index 4e43a0074..b8ec4e1c2 100644 --- a/workflows/squad.md +++ b/workflows/squad.md @@ -395,6 +395,8 @@ matching the exact structured fields, and choosing the newest match. For each lifecycle-state write, call `upsert_lifecycle_state` once with the complete body. It updates the newest trusted tracker or creates the first one. +For nonterminal states, `Next action` MUST be only the backticked `/squad` +command; put prose elsewhere. `Activated` may use terminal prose instead. # Squad โ€” `/squad` Slash Command @@ -2553,7 +2555,8 @@ 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. Terminal โ€” no next action needed. +Full/last: `โœ… Done`, state = Activated, last command = invoked +`/squad plan activate`. Terminal โ€” use terminal prose for Next action. ## end skill: `squad-plan-activate` From fa661c88865378caa6f24e1f1111e8e030dde7b2 Mon Sep 17 00:00:00 2001 From: Brady Gaster Date: Tue, 15 Sep 2026 11:29:50 -0700 Subject: [PATCH 2/2] fix(gh-aw): preserve phased lifecycle repairs Closes #2051 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- test/gh-aw-lifecycle-upsert.test.ts | 35 +++++++++++++++++++++++++++-- test/gh-aw-quality.test.ts | 2 +- workflows/shared/squad.md | 21 ++++++++--------- 3 files changed, 45 insertions(+), 13 deletions(-) diff --git a/test/gh-aw-lifecycle-upsert.test.ts b/test/gh-aw-lifecycle-upsert.test.ts index 306ea4a7f..9ee835a7d 100644 --- a/test/gh-aw-lifecycle-upsert.test.ts +++ b/test/gh-aw-lifecycle-upsert.test.ts @@ -685,7 +685,7 @@ describe('#1928: deterministic terminal lifecycle repair', () => { '', 'Structured data:', '```json', - '{"squad_artifact":"activated","schema_version":"1","origin_issue":5,"phases":[]}', + '{"squad_artifact":"activated","schema_version":"1","origin_issue":5,"phases":[1,2]}', '```', ].join('\n'), created_at: '2026-08-28T01:30:00Z', @@ -724,7 +724,7 @@ describe('#1928: deterministic terminal lifecycle repair', () => { expect(result.updated[0].body).toContain(lifecycleEnvelope); }); - it('repairs granular /squad plan activate lifecycle state from a trusted activation', async () => { + it('repairs granular /squad plan activate lifecycle state from a trusted phased activation', async () => { const result = await runLifecycleRepair( [activated, stale], '/squad plan activate', @@ -754,6 +754,37 @@ describe('#1928: deterministic terminal lifecycle repair', () => { ); }); + it('preserves a live terminal tracker with detailed activation and command attribution', async () => { + const terminal = { + ...stale, + body: [ + '## ๐Ÿงญ Squad Lifecycle State โ€” Issue #5', + '', + '**State:** Activated', + '', + '| Stage | Status |', + '| --- | --- |', + '| Activation | โœ… Done โ€” created 3 task issues and milestone v1 |', + '', + '**Last command:** `/squad plan activate` by @maintainer', + '**Next action:** Track progress on the created task issues; no further planning action is required.', + '', + 'Structured data:', + '```json', + lifecycleEnvelope, + '```', + ].join('\n'), + }; + const result = await runLifecycleRepair([activated, terminal], '/squad plan activate'); + + expect(result.failures).toEqual([]); + expect(result.created).toEqual([]); + expect(result.updated).toEqual([]); + expect(result.info).toContain( + 'The newest lifecycle tracker already records terminal activation.', + ); + }); + it('does not trust a user-authored acceptance artifact', async () => { const result = await runLifecycleRepair([ { ...accepted, user: { login: 'untrusted-user' } }, diff --git a/test/gh-aw-quality.test.ts b/test/gh-aw-quality.test.ts index 5bd450810..594c57c3e 100644 --- a/test/gh-aw-quality.test.ts +++ b/test/gh-aw-quality.test.ts @@ -492,7 +492,7 @@ describe('#1916: lifecycle comment updates use a deterministic safe-output job', "!contains(needs.agent.outputs.output_types, 'upsert_lifecycle_state')", ); expect(shared).toContain("github.event.comment.body == '/squad activate'"); - expect(shared).toContain('["plan-accepted", "activated"].includes(envelope?.squad_artifact)'); + expect(shared).toContain('["plan-accepted", "activated"].includes(e?.squad_artifact)'); expect(shared).toContain('name: Repair terminal lifecycle after idempotent activation'); }); diff --git a/workflows/shared/squad.md b/workflows/shared/squad.md index 8da2c44d9..5e331e064 100644 --- a/workflows/shared/squad.md +++ b/workflows/shared/squad.md @@ -388,15 +388,15 @@ jobs: comment, envelope: envelopeFor(comment), })); - const activatedOrAccepted = artifacts.some( - ({ envelope }) => - ["plan-accepted", "activated"].includes(envelope?.squad_artifact) && - envelope?.schema_version === "1" && - envelope?.origin_issue === issueNumber && - Array.isArray(envelope?.phases) && - envelope.phases.length === 0, + const ok = artifacts.some( + ({ envelope: e }) => + ["plan-accepted", "activated"].includes(e?.squad_artifact) && + e?.schema_version === "1" && + e?.origin_issue === issueNumber && + Array.isArray(e?.phases) && + (e.squad_artifact === "activated" || e.phases.length === 0), ); - if (!activatedOrAccepted) { + if (!ok) { core.info("No trusted whole-plan acceptance or activation artifact; lifecycle repair is not applicable."); return; } @@ -415,8 +415,9 @@ jobs: const lifecycleBody = String(lifecycle?.body || ""); const terminal = /^(?:[-*]\s+)?\*\*(?:Current state|State):\*\*\s+Activated\s*$/im.test(lifecycleBody) && - /^(?:[-*]\s+)?\*\*Activation:\*\*\s+โœ…\s+Done\s*$/im.test(lifecycleBody) && - /^(?:[-*]\s+)?\*\*Last command:\*\*\s+`\/squad (?:activate|plan accept|plan activate)`\s*$/im.test(lifecycleBody); + (/^(?:[-*]\s+)?(?:\*\*)?Activation:(?:\*\*)?\s+โœ…\s+Done\b/im.test(lifecycleBody) || + /^\|\s*Activat(?:e|ion|ed)\s*\|\s*โœ…\s+Done\b[^|]*\|/im.test(lifecycleBody)) && + /^(?:[-*]\s+)?\*\*Last command:\*\*\s+`\/squad (?:activate|plan accept|plan activate)(?: phase \d+)?`(?:\s+.*)?$/im.test(lifecycleBody); if (terminal) { core.info("The newest lifecycle tracker already records terminal activation."); return;