diff --git a/autoplan/SKILL.md b/autoplan/SKILL.md index e8899e09e..962e7a4d8 100644 --- a/autoplan/SKILL.md +++ b/autoplan/SKILL.md @@ -625,8 +625,10 @@ in an unattended run, never a warning; interactive runs follow the repair rule a Write `$_AP/gate.json` (shape: `gstack-gate --help`): one item per User Challenge (`uc`, approval; `why` = you said / both recommend / why / blind spots / cost if wrong), per taste decision (`d`, auto; `why` = principle and the alternative's -impact) and `p1` (approval: a) approve as-is (recommended) b) with overrides -c) interrogate d) revise e) reject). Run `~/.claude/skills/gstack/bin/gstack-gate render $_AP/gate.json` +impact) and `p1` (approval: a) approve as-is b) take the recommended changes c) interrogate +d) revise e) reject), recommending `b` if a recommended option changes the plan, else `a`. +Every `uc`/`d` option sets `changes_plan` (`true` if it changes the plan, +else `false`). Run `~/.claude/skills/gstack/bin/gstack-gate render $_AP/gate.json` and append its `decisions` output to `$_AP/decisions.jsonl`. Write the full review to `$_AP/review-record.md` and its Plan Summary alone to `$_AP/summary.md`: @@ -641,7 +643,7 @@ findings outside this plan, or `None.`); also written above `## Implementation p [1-3 sentence summary] ### Decisions Made: [N] total ([M] auto-decided, [K] taste choices, [J] user challenges) -Auto-decided: see the plan file's Decision Audit Trail; the rest are the brief's decisions. +Auto-decided: see the plan's Decision Audit Trail. ### Review Scores CEO, Design, DX and Eng: phase summary plus Codex, Claude @@ -649,7 +651,7 @@ and consensus status; say skipped where a phase did not run. Then the CYCLE line from `~/.claude/skills/gstack/bin/gstack-autoplan-timing summary --run --out "$_AP"`. ### Cross-Phase Themes -Concerns independently raised in 2+ phases, or "No cross-phase themes — each phase's concerns were distinct." +Concerns raised independently in 2+ phases, or "None." ### Deferred to TODOS.md [Items auto-deferred with reasons] @@ -659,21 +661,23 @@ Concerns independently raised in 2+ phases, or "No cross-phase themes — each p ``` Present `~/.claude/skills/gstack/bin/gstack-owner-brief render --out "$_AP" --write` -verbatim (URGENT block, plain summary, numbered decisions seven per page with each -auto default, review-record path; `--page N` for later pages), never the full review. +verbatim (`--page N` for later pages), never the full review. Parse the reply: `~/.claude/skills/gstack/bin/gstack-gate parse $_AP/gate.json --reply "" --gate-rev `; -unparsed or stale replies are re-presented (`gate_rev`+1), never guessed. `all` or `p1a` -without overrides = A; `d` overrides = B; `uc` overrides = B2; `p1c`/`p1d`/`p1e` -= C/D/E. Under `SESSION_KIND: unattended` Read `sections/unattended.md`, end here +unparsed or stale replies are re-presented (`gate_rev`+1), never guessed. `rec` (alias +`all`), the one-word default, takes every recommendation, plan changes included; `as-is` keeps +the plan as first written. First match: any `pending` id = approve nothing (bare `p1a` leaves +`uc` pending; re-parse the owner's whole reply); +`p1c`/`p1d`/`p1e` = C/D/E; a chosen `changes_plan` option (or an unflagged override) += B on a `d`, B2 on a `uc`; else = A. Under `SESSION_KIND: unattended` Read `sections/unattended.md`, end here with its `GSTACK_RESULT` line and never approve. **Option handling:** -- A: mark APPROVED, run `~/.claude/skills/gstack/bin/gstack-owner-brief close --out "$_AP" --plan ` (draft-direction lines become the decided option and date), write review logs, suggest /ship -- B: ask which overrides, apply, then follow D's affected-phase rerun rule (Eng last) before re-presenting the gate; same 3-cycle cap as D. -- B2: accept/reject User Challenges one at a time; rejected ones preserve the user's direction. Re-run Eng, then re-present the gate. +- A: mark APPROVED, run `~/.claude/skills/gstack/bin/gstack-owner-brief close --out "$_AP" --plan `, write review logs, suggest /ship +- B: apply each chosen change, rerun as in D before re-presenting the gate; same 3-cycle cap as D. +- B2: apply each accepted User Challenge (rejected ones keep the user's direction), re-run Eng, re-present the gate. - C: answer freeform, re-present the gate -- D: make changes, re-run affected phases (scope→1, design→2, dx→2.5, test plan→3, arch→3; a re-run of any earlier phase re-runs Eng after it — the gate always reviews the final plan). Max 3 cycles. +- D: make changes, re-run affected phases (scope→1, design→2, dx→2.5, test plan→3, arch→3), then Eng; max 3 cycles. - E: start over **Starting an affected-phase rerun:** Keep the current Implementation plan and all @@ -681,9 +685,9 @@ prior accepted obligations intact. Move that phase's applied `autoplan-baseline- record verbatim into fenced history in Review record, keeping its original source SHA. Create a fresh amendment checkpoint. New baseline edits use `create`'s `baselineEdits.record` and `sourceSha256`; the review projection hash is not baseline -identity. Carry forward unchanged accepted requirements. Never replay old replacements +identity. Never replay old replacements or rewrite a historical source SHA. This is a new phase invocation; compaction -resumes the existing one and its checkpoint. Eng still runs last. +resumes the existing one and its checkpoint. --- diff --git a/autoplan/SKILL.md.tmpl b/autoplan/SKILL.md.tmpl index c5f48565f..57c9a5e10 100644 --- a/autoplan/SKILL.md.tmpl +++ b/autoplan/SKILL.md.tmpl @@ -360,8 +360,10 @@ there with each incomplete item. Write `$_AP/gate.json` (shape: `gstack-gate --help`): one item per User Challenge (`uc`, approval; `why` = you said / both recommend / why / blind spots / cost if wrong), per taste decision (`d`, auto; `why` = principle and the alternative's -impact) and `p1` (approval: a) approve as-is (recommended) b) with overrides -c) interrogate d) revise e) reject). Run `~/.claude/skills/gstack/bin/gstack-gate render $_AP/gate.json` +impact) and `p1` (approval: a) approve as-is b) take the recommended changes c) interrogate +d) revise e) reject), recommending `b` if a recommended option changes the plan, else `a`. +Every `uc`/`d` option sets `changes_plan` (`true` if it changes the plan, +else `false`). Run `~/.claude/skills/gstack/bin/gstack-gate render $_AP/gate.json` and append its `decisions` output to `$_AP/decisions.jsonl`. Write the full review to `$_AP/review-record.md` and its Plan Summary alone to `$_AP/summary.md`: @@ -376,7 +378,7 @@ findings outside this plan, or `None.`); also written above `## Implementation p [1-3 sentence summary] ### Decisions Made: [N] total ([M] auto-decided, [K] taste choices, [J] user challenges) -Auto-decided: see the plan file's Decision Audit Trail; the rest are the brief's decisions. +Auto-decided: see the plan's Decision Audit Trail. ### Review Scores CEO, Design, DX and Eng: phase summary plus {{OUTSIDE_LABEL}}, {{NATIVE_LABEL}} @@ -384,7 +386,7 @@ and consensus status; say skipped where a phase did not run. Then the CYCLE line from `~/.claude/skills/gstack/bin/gstack-autoplan-timing summary --run --out "$_AP"`. ### Cross-Phase Themes -Concerns independently raised in 2+ phases, or "No cross-phase themes — each phase's concerns were distinct." +Concerns raised independently in 2+ phases, or "None." ### Deferred to TODOS.md [Items auto-deferred with reasons] @@ -394,21 +396,23 @@ Concerns independently raised in 2+ phases, or "No cross-phase themes — each p ``` Present `~/.claude/skills/gstack/bin/gstack-owner-brief render --out "$_AP" --write` -verbatim (URGENT block, plain summary, numbered decisions seven per page with each -auto default, review-record path; `--page N` for later pages), never the full review. +verbatim (`--page N` for later pages), never the full review. Parse the reply: `~/.claude/skills/gstack/bin/gstack-gate parse $_AP/gate.json --reply "" --gate-rev `; -unparsed or stale replies are re-presented (`gate_rev`+1), never guessed. `all` or `p1a` -without overrides = A; `d` overrides = B; `uc` overrides = B2; `p1c`/`p1d`/`p1e` -= C/D/E. Under `SESSION_KIND: unattended` Read `sections/unattended.md`, end here +unparsed or stale replies are re-presented (`gate_rev`+1), never guessed. `rec` (alias +`all`), the one-word default, takes every recommendation, plan changes included; `as-is` keeps +the plan as first written. First match: any `pending` id = approve nothing (bare `p1a` leaves +`uc` pending; re-parse the owner's whole reply); +`p1c`/`p1d`/`p1e` = C/D/E; a chosen `changes_plan` option (or an unflagged override) += B on a `d`, B2 on a `uc`; else = A. Under `SESSION_KIND: unattended` Read `sections/unattended.md`, end here with its `GSTACK_RESULT` line and never approve. **Option handling:** -- A: mark APPROVED, run `~/.claude/skills/gstack/bin/gstack-owner-brief close --out "$_AP" --plan ` (draft-direction lines become the decided option and date), write review logs, suggest /ship -- B: ask which overrides, apply, then follow D's affected-phase rerun rule (Eng last) before re-presenting the gate; same 3-cycle cap as D. -- B2: accept/reject User Challenges one at a time; rejected ones preserve the user's direction. Re-run Eng, then re-present the gate. +- A: mark APPROVED, run `~/.claude/skills/gstack/bin/gstack-owner-brief close --out "$_AP" --plan `, write review logs, suggest /ship +- B: apply each chosen change, rerun as in D before re-presenting the gate; same 3-cycle cap as D. +- B2: apply each accepted User Challenge (rejected ones keep the user's direction), re-run Eng, re-present the gate. - C: answer freeform, re-present the gate -- D: make changes, re-run affected phases (scope→1, design→2, dx→2.5, test plan→3, arch→3; a re-run of any earlier phase re-runs Eng after it — the gate always reviews the final plan). Max 3 cycles. +- D: make changes, re-run affected phases (scope→1, design→2, dx→2.5, test plan→3, arch→3), then Eng; max 3 cycles. - E: start over **Starting an affected-phase rerun:** Keep the current Implementation plan and all @@ -416,9 +420,9 @@ prior accepted obligations intact. Move that phase's applied `autoplan-baseline- record verbatim into fenced history in Review record, keeping its original source SHA. Create a fresh amendment checkpoint. New baseline edits use `create`'s `baselineEdits.record` and `sourceSha256`; the review projection hash is not baseline -identity. Carry forward unchanged accepted requirements. Never replay old replacements +identity. Never replay old replacements or rewrite a historical source SHA. This is a new phase invocation; compaction -resumes the existing one and its checkpoint. Eng still runs last. +resumes the existing one and its checkpoint. --- diff --git a/changelog.d/capy-make-autoplans-rec-and.md b/changelog.d/capy-make-autoplans-rec-and.md new file mode 100644 index 000000000..39ddd0021 --- /dev/null +++ b/changelog.d/capy-make-autoplans-rec-and.md @@ -0,0 +1 @@ +- **/autoplan's `rec` and `all` replies now apply every recommended plan change, as `gstack-gate` already did.** The skill's reply table sent `all` (and left `rec` unmapped) straight to "approve as written", while `gstack-gate parse` resolves both to every item's recommendation, plan changes included, so decisions.jsonl and the approved plan could disagree. `rec` is now the one-word default that takes those changes through the affected-phase rerun before the gate comes back; `as-is` stays the plan as first written. The plan-approval item now recommends `b` (take the recommended changes) whenever a recommended option changes the plan. A bare `p1a` with a user challenge unanswered no longer approves anything: the gate waits for the owner's own answer to that `uc` item and re-parses their whole reply. Every `uc`/`d` option in a hand-written gate now sets `changes_plan`, and an explicit override of an option with no flag still counts as a plan change, as `gstack-autoplan answer` already treats it. A test reads the skill's reply table and checks it against what `gstack-gate parse` returns for `all`, `rec`, `p1a` and an override. diff --git a/test/fixtures/skill-md-size-allowlist.json b/test/fixtures/skill-md-size-allowlist.json index 5bd4e25fd..7ee2c844e 100644 --- a/test/fixtures/skill-md-size-allowlist.json +++ b/test/fixtures/skill-md-size-allowlist.json @@ -2,53 +2,53 @@ "_comment": "Generated SKILL.md files over the limit, pinned at their exact character count (#3199). test/skill-md-size-guard.test.ts fails when a pinned skill grows past its pin or shrinks below it: lower the pin when a skill shrinks and delete it once the skill is within the limit. Raising a pin is an owner decision. The shared-preamble hoist (GSTA-96) moved every tier 2+ skill's shared preamble into sections/preamble.md; the pins below are what remains.", "limit": 100000, "pins": { - "agy/gstack-autoplan": 109575, + "agy/gstack-autoplan": 109540, "agy/gstack-plan-design-review": 109096, "agy/gstack-plan-devex-review": 114924, "agy/gstack-plan-eng-review": 143580, "agy/gstack-review": 104735, - "codex/gstack-autoplan": 108318, + "codex/gstack-autoplan": 108283, "codex/gstack-plan-design-review": 110453, "codex/gstack-plan-devex-review": 116432, "codex/gstack-plan-eng-review": 144745, "codex/gstack-review": 104119, - "copilot/gstack-autoplan": 109018, + "copilot/gstack-autoplan": 108983, "copilot/gstack-plan-design-review": 109362, "copilot/gstack-plan-devex-review": 114386, "copilot/gstack-plan-eng-review": 143745, "copilot/gstack-review": 104316, - "cursor/gstack-autoplan": 108250, + "cursor/gstack-autoplan": 108215, "cursor/gstack-plan-design-review": 107904, "cursor/gstack-plan-devex-review": 113656, "cursor/gstack-plan-eng-review": 142335, "cursor/gstack-review": 122788, - "factory/gstack-autoplan": 110528, + "factory/gstack-autoplan": 110493, "factory/gstack-plan-design-review": 111002, "factory/gstack-plan-devex-review": 117019, "factory/gstack-plan-eng-review": 145443, "factory/gstack-review": 126561, - "gbrain/gstack-autoplan": 108657, + "gbrain/gstack-autoplan": 108622, "gbrain/gstack-plan-eng-review": 124963, - "gemini/gstack-autoplan": 108808, + "gemini/gstack-autoplan": 108773, "gemini/gstack-plan-design-review": 109190, "gemini/gstack-plan-devex-review": 114214, "gemini/gstack-plan-eng-review": 143578, "gemini/gstack-review": 104138, - "hermes/gstack-autoplan": 108611, + "hermes/gstack-autoplan": 108576, "hermes/gstack-plan-eng-review": 124999, - "kiro/gstack-autoplan": 108024, + "kiro/gstack-autoplan": 107989, "kiro/gstack-plan-design-review": 107750, "kiro/gstack-plan-devex-review": 113508, "kiro/gstack-plan-eng-review": 142197, "kiro/gstack-review": 122612, - "openclaw/gstack-autoplan": 108480, + "openclaw/gstack-autoplan": 108445, "openclaw/gstack-plan-eng-review": 123478, - "opencode/gstack-autoplan": 108999, + "opencode/gstack-autoplan": 108964, "opencode/gstack-plan-design-review": 109218, "opencode/gstack-plan-devex-review": 114264, "opencode/gstack-plan-eng-review": 143604, "opencode/gstack-review": 123410, - "slate/gstack-autoplan": 108137, + "slate/gstack-autoplan": 108102, "slate/gstack-plan-design-review": 107827, "slate/gstack-plan-devex-review": 113582, "slate/gstack-plan-eng-review": 142266, diff --git a/test/gate-list.test.ts b/test/gate-list.test.ts index 13c446077..c4b11bf5c 100644 --- a/test/gate-list.test.ts +++ b/test/gate-list.test.ts @@ -279,3 +279,99 @@ describe('the /autoplan Phase 4 gate as documented (#4181)', () => { expect(results.slice(1).map(r => chosen(JSON.parse(r.out))[id])).toEqual(['a', 'c', 'd', 'e']); }); }); + +describe('the /autoplan Phase 4 reply table agrees with gstack-gate (GST-2139)', () => { + // Value: protects=the reply-to-option table /autoplan acts on matches what bin/gstack-gate parse resolves for `all`, `rec` and `p1a`; + // fails_when=`all`/`rec` send the run to approve-as-written while the gate took every recommended plan change, `p1a` approves while a uc item is pending, or an override of an unflagged option approves (answerGate counts it as a plan change); why_new=the #4181 case proves the replies parse, not what the skill does with them; seam=none + const tmpl = fs.readFileSync(path.join(ROOT, 'autoplan', 'SKILL.md.tmpl'), 'utf8').replace(/\s+/g, ' '); + const ticks = (s: string) => [...s.matchAll(/`([^`]+)`/g)].map(m => m[1]!); + const topLevel = (text: string): string[] => { + const out: string[] = []; let depth = 0; let code = false; let start = 0; + for (let i = 0; i < text.length; i++) { + const ch = text[i]!; + if (ch === '`') code = !code; + else if (!code && ch === '(') depth++; + else if (!code && ch === ')') depth--; + else if (!code && depth === 0 && (ch === ';' || ch === '.') && text[i + 1] === ' ') { out.push(text.slice(start, i).trim()); start = i + 2; } + } + return [...out, text.slice(start).trim()].filter(Boolean); + }; + const table = topLevel(/Parse the reply:.*?never guessed\. (.*?) Under `SESSION_KIND/.exec(tmpl)?.[1] ?? ''); + const handling = Object.fromEntries([...tmpl.matchAll(/- ([A-E]2?): (.*?)(?= - [A-E]2?: |\*\*Starting)/g)].map(m => [m[1]!, m[2]!])); + const write_ = /Write `\$_AP\/gate\.json`.*?gstack-gate render/.exec(tmpl)?.[0] ?? ''; + const p1 = /and `p1` \(approval: (a\) .*? e\) \w+)\)/.exec(write_)?.[1] ?? ''; + const parts = p1.split(/(?:^| )([a-e])\) /).slice(1); + const p1Options = parts.flatMap((key, i) => (i % 2 ? [] : [{ key, text: parts[i + 1]!.replace(/ \(recommended\)$/, '') }])); + const p1Recommended = parts.flatMap((key, i) => (i % 2 === 0 && / \(recommended\)$/.test(parts[i + 1]!) ? [key] : []))[0] + ?? /recommending `([a-e])`/.exec(tmpl.slice(tmpl.indexOf(p1)))?.[1]; + const keep = { key: 'a', text: 'keep your direction', changes_plan: false }; + const change = { key: 'b', text: 'take the reviewers\' change', changes_plan: true }; + const gate = (): GateList => ({ schema_version: 1, run: 'autoplan-2139', gate_rev: 1, items: [ + { id: 'uc1', title: 'User challenge both voices raised', options: [keep, change], recommended: 'b', kind: 'approval' }, + { id: 'd1', title: 'Taste decision', options: [keep, change], recommended: 'b', kind: 'auto' }, + { id: 'p1', title: 'Plan approval', options: p1Options, recommended: p1Recommended ?? 'a', kind: 'approval' }, + ] }); + + /** The skill's options for a reply: the first table row whose condition the gate's parse meets. */ + function documented(reply: string, g: GateList, parsed: ReturnType) { + const said = reply.toLowerCase().split(/\s+/); + const optionOf = (c: { id: string; chosen: string }) => g.items.find(i => i.id === c.id)!.options.find(o => o.key === c.chosen)!; + for (const row of table) { + const changed = parsed.choices.filter(c => c.id !== 'p1' && (optionOf(c).changes_plan ?? (/unflagged override/.test(row) && c.how === 'override'))); + const at = row.indexOf(' = '); + if (at < 0) continue; + const lhs = row.slice(0, at), rhs = row.slice(at + 3), names = ticks(lhs); + const hit = /\belse\b/.test(lhs) ? true + : names.includes('pending') ? parsed.pending.length > 0 + : names.includes('changes_plan') ? changed.length > 0 + : names.some(t => said.includes(t) || (/^[a-z]+$/.test(t) && parsed.choices.some(c => c.how === 'override' && said.includes(c.id + c.chosen) && c.id.startsWith(t.slice(0, -3))))); + if (!hit) continue; + const perKind = [...rhs.matchAll(/([A-E]2?) on an? `([a-z]+)`/g)]; + const letters = perKind.length ? perKind.filter(m => changed.some(c => c.id.startsWith(m[2]!) && c.id !== 'p1')).map(m => m[1]!) + : /^[A-E](\/[A-E])+$/.test(rhs.trim()) ? [rhs.trim().split('/')[names.findIndex(t => said.includes(t))]!] + : [...rhs.replace(/`[^`]*`/g, '').replace(/\([^)]*\)/g, '').matchAll(/(?:^|[\s,])([A-E]2?)(?=$|[\s,.])/g)].map(m => m[1]!); + return { row, letters: [...new Set(letters)].sort() }; + } + return { row: '(no row)', letters: [] as string[] }; + } + const approves = (letters: string[]) => letters.filter(l => /mark APPROVED/.test(handling[l] ?? '')); + + test('`all` and `rec` apply every recommended plan change, never the plan as first written', () => { + expect(Object.keys(handling)).toEqual(['A', 'B', 'B2', 'C', 'D', 'E']); + expect(p1Options.map(o => o.key)).toEqual(['a', 'b', 'c', 'd', 'e']); + expect(write_).toContain('Every `uc`/`d` option sets `changes_plan`'); + const g = gate(); + const file = write('gate.json', g); + const seen = ['all', 'rec'].map(reply => { + const r = run(['parse', file, '--reply', reply, '--gate-rev', '1', '--json']); + const parsed = JSON.parse(r.out) as ReturnType; + const doc = documented(reply, g, parsed); + return { reply, code: r.code, gate: chosen(parsed), p1: p1Options.find(o => o.key === chosen(parsed).p1)?.text, letters: doc.letters, approves: approves(doc.letters), row: doc.row }; + }); + expect(seen.map(({ row: _row, p1, ...rest }) => ({ ...rest, p1_as_written: /as-is|as written/.test(p1 ?? '') }))) + .toEqual(['all', 'rec'].map(reply => ({ reply, code: EXIT.ok, gate: { uc1: 'b', d1: 'b', p1: 'b' }, letters: ['B', 'B2'], approves: [], p1_as_written: false }))); + }); + + test('`p1a` with a user challenge unanswered approves nothing', () => { + const g = gate(); + const file = write('gate.json', g); + const r = run(['parse', file, '--reply', 'p1a', '--gate-rev', '1', '--json']); + const parsed = JSON.parse(r.out) as ReturnType; + const doc = documented('p1a', g, parsed); + expect({ code: r.code, pending: parsed.pending, p1: chosen(parsed).p1, approves: approves(doc.letters), row_names_pending: ticks(doc.row.split(' = ')[0]!).includes('pending') }) + .toEqual({ code: EXIT.ok, pending: ['uc1'], p1: 'a', approves: [], row_names_pending: true }); + }); + + test('an override of an unflagged option changes the plan, as answerGate counts it', () => { + const unflagged = [{ key: 'a', text: 'keep your direction' }, { key: 'b', text: 'the alternative' }]; + const g: GateList = { schema_version: 1, run: 'autoplan-2139', gate_rev: 1, items: [ + { id: 'd1', title: 'Taste decision', options: unflagged, recommended: 'a', kind: 'auto' }, + { id: 'p1', title: 'Plan approval', options: p1Options, recommended: 'a', kind: 'approval' }, + ] }; + const r = run(['parse', write('gate.json', g), '--reply', 'p1a d1b', '--gate-rev', '1', '--json']); + const parsed = JSON.parse(r.out) as ReturnType; + const doc = documented('p1a d1b', g, parsed); + expect({ code: r.code, d1: parsed.choices.find(c => c.id === 'd1'), letters: doc.letters, approves: approves(doc.letters) }) + .toEqual({ code: EXIT.ok, d1: { id: 'd1', chosen: 'b', kind: 'auto', how: 'override' }, letters: ['B'], approves: [] }); + }); +});