mandrel 1.71.0 → 1.73.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -227,13 +227,51 @@ export function buildContextEnvelope({
227
227
  }
228
228
 
229
229
  /**
230
- * Strip the leading "Tech Stack" section of `docs/architecture.md` for
231
- * the host LLM. Returns `null` when the file or section is missing.
230
+ * Extract the "Tech Stack" `##` section from a markdown document.
231
+ *
232
+ * Tolerates a numbered / decorated heading (`## 1. Tech Stack`,
233
+ * `## Tech Stack`, etc.) and a section that is the final `##` in the
234
+ * file (the terminator matches the next `##` heading **or** end-of-file).
235
+ * Returns the matched section text (re-headed to a clean `## Tech Stack`)
236
+ * or `null` when no Tech Stack heading is present.
237
+ *
238
+ * @param {string} content
239
+ * @returns {string|null}
240
+ */
241
+ function extractTechStackSection(content) {
242
+ const match = content.match(
243
+ /^##\s+(?:\d+[.)]\s+)?Tech Stack\s*$([\s\S]*?)(?=^##\s+|(?![\s\S]))/m,
244
+ );
245
+ return match ? `## Tech Stack${match[1]}`.trim() : null;
246
+ }
247
+
248
+ /**
249
+ * Resolve the project's Tech Stack inventory for the host LLM, in order:
250
+ *
251
+ * 1. A dedicated `docs/tech-stack.md` when present (the emerging
252
+ * single-ownership convention — WHAT in tech-stack.md, HOW in
253
+ * architecture.md, WHY in the ADRs). Its full body is returned.
254
+ * 2. Otherwise, the `## Tech Stack` section of `docs/architecture.md`,
255
+ * tolerating a numbered/decorated heading and a final-section
256
+ * heading (no following `##` required).
257
+ *
258
+ * Returns `null` when neither source yields an inventory.
232
259
  *
233
260
  * @param {string} projectRoot
234
261
  * @returns {Promise<string|null>}
235
262
  */
236
263
  export async function readTechStackSummary(projectRoot) {
264
+ const dedicatedPath = path.join(projectRoot, 'docs', 'tech-stack.md');
265
+ try {
266
+ const dedicated = await readFile(dedicatedPath, 'utf8');
267
+ const trimmed = dedicated.trim();
268
+ if (trimmed) {
269
+ return trimmed;
270
+ }
271
+ } catch {
272
+ // No dedicated tech-stack.md — fall through to architecture.md.
273
+ }
274
+
237
275
  const archPath = path.join(projectRoot, 'docs', 'architecture.md');
238
276
  let content;
239
277
  try {
@@ -241,6 +279,5 @@ export async function readTechStackSummary(projectRoot) {
241
279
  } catch {
242
280
  return null;
243
281
  }
244
- const match = content.match(/^##\s+Tech Stack\s*$([\s\S]*?)(?=^##\s+\S)/m);
245
- return match ? `## Tech Stack${match[1]}`.trim() : null;
282
+ return extractTechStackSection(content);
246
283
  }
@@ -0,0 +1,261 @@
1
+ #!/usr/bin/env node
2
+ // .agents/scripts/lint-issue-body.js
3
+ /**
4
+ * Issue-body conformance lint (Story #4227).
5
+ *
6
+ * Runs the canonical `story-body.parse()` against a human-opened
7
+ * `type::story` / `type::epic` issue body and reports whether the body
8
+ * round-trips. This is the drift guard between the generated GitHub Issue
9
+ * Forms (`lib/bootstrap/issue-forms-template.js`) and the parser: if a
10
+ * human files a ticket whose body `parse()` rejects (or which lacks the
11
+ * binding `goal` / `acceptance` / `verify` sections), the lint surfaces a
12
+ * **comment** on the issue rather than failing silently — the supported
13
+ * human entry points (`/plan` from an existing Epic ID, the qa-assist →
14
+ * `/plan` handoff) depend on a parseable body.
15
+ *
16
+ * ## Design
17
+ *
18
+ * - `evaluateIssueBody(body)` is **pure** (no I/O): it parses the body and
19
+ * returns a structured conformance verdict. This is the unit-tested core.
20
+ * - The CLI wrapper reads the issue body + labels (via `gh` or env), runs
21
+ * the evaluator, and posts/updates a single marker comment when the body
22
+ * is non-conformant. Network-touching, exercised in CI.
23
+ *
24
+ * GitHub Issue Forms render a skipped optional field as the literal
25
+ * `_No response_`; the evaluator strips that sentinel so an empty optional
26
+ * section does not masquerade as content.
27
+ *
28
+ * @module lint-issue-body
29
+ */
30
+
31
+ import { spawnSync } from 'node:child_process';
32
+ import { runAsCli } from './lib/cli-utils.js';
33
+ import { parse, StoryBodyParseError } from './lib/story-body/story-body.js';
34
+
35
+ /**
36
+ * Marker that identifies the lint's own comment so re-runs update rather
37
+ * than duplicate it.
38
+ */
39
+ export const LINT_COMMENT_MARKER = '<!-- mandrel:issue-body-conformance -->';
40
+
41
+ /**
42
+ * The binding sections every conformant ticket body MUST carry. `changes`
43
+ * and `references` are advisory (per the Engineer persona's implementation
44
+ * latitude), so they are not required here.
45
+ */
46
+ const REQUIRED_SECTIONS = [
47
+ { field: 'goal', label: 'Goal' },
48
+ { field: 'acceptance', label: 'Acceptance' },
49
+ { field: 'verify', label: 'Verify' },
50
+ ];
51
+
52
+ /**
53
+ * Strip the GitHub Issue Form empty-field sentinel so a skipped optional
54
+ * field is treated as absent rather than literal content.
55
+ *
56
+ * @param {string} body
57
+ * @returns {string}
58
+ */
59
+ function stripNoResponseSentinel(body) {
60
+ return body
61
+ .split('\n')
62
+ .filter((line) => line.trim() !== '_No response_')
63
+ .join('\n');
64
+ }
65
+
66
+ /**
67
+ * @typedef {object} ConformanceVerdict
68
+ * @property {boolean} conformant - True when the body parses AND carries
69
+ * every required section with non-empty content.
70
+ * @property {string[]} problems - Human-readable problem statements
71
+ * (empty when conformant).
72
+ * @property {string[]} warnings - Non-fatal parser warnings surfaced for
73
+ * transparency (e.g. legacy-path-entry).
74
+ * @property {boolean} parseFailed - True when `parse()` threw (fail-closed).
75
+ */
76
+
77
+ /**
78
+ * Evaluate an issue body for conformance with the canonical Story-body
79
+ * schema. Pure — no I/O. Fail-closed parse errors are caught and reported
80
+ * as a non-conformant verdict (never thrown), because the caller's job is
81
+ * to *comment*, not to crash CI.
82
+ *
83
+ * @param {string} body - Raw issue-body markdown.
84
+ * @returns {ConformanceVerdict}
85
+ */
86
+ export function evaluateIssueBody(body) {
87
+ if (typeof body !== 'string' || body.trim().length === 0) {
88
+ return {
89
+ conformant: false,
90
+ problems: ['The issue body is empty.'],
91
+ warnings: [],
92
+ parseFailed: true,
93
+ };
94
+ }
95
+
96
+ const cleaned = stripNoResponseSentinel(body);
97
+
98
+ let result;
99
+ try {
100
+ result = parse(cleaned);
101
+ } catch (err) {
102
+ if (err instanceof StoryBodyParseError) {
103
+ return {
104
+ conformant: false,
105
+ problems: [
106
+ `The body could not be parsed into the canonical schema: ${err.message}`,
107
+ ],
108
+ warnings: [],
109
+ parseFailed: true,
110
+ };
111
+ }
112
+ throw err;
113
+ }
114
+
115
+ const problems = [];
116
+
117
+ // A legacy string body parses but carries no structured sections — that
118
+ // is exactly the human-filed shape this lint exists to catch.
119
+ if (result.info.isLegacyStringBody) {
120
+ problems.push(
121
+ 'The body has no recognised `## Goal` / `## Acceptance` / `## Verify` sections. ' +
122
+ 'File via the Story/Epic issue form so it round-trips through the parser.',
123
+ );
124
+ }
125
+
126
+ for (const { field, label } of REQUIRED_SECTIONS) {
127
+ const value = result.body[field];
128
+ const empty =
129
+ value == null ||
130
+ (typeof value === 'string' && value.trim().length === 0) ||
131
+ (Array.isArray(value) && value.length === 0);
132
+ if (empty) {
133
+ problems.push(
134
+ `The required \`## ${label}\` section is missing or empty.`,
135
+ );
136
+ }
137
+ }
138
+
139
+ return {
140
+ conformant: problems.length === 0,
141
+ problems,
142
+ warnings: result.warnings,
143
+ parseFailed: false,
144
+ };
145
+ }
146
+
147
+ /**
148
+ * Render the markdown comment body the lint posts on a non-conformant
149
+ * issue. Carries {@link LINT_COMMENT_MARKER} so re-runs update in place.
150
+ *
151
+ * @param {ConformanceVerdict} verdict
152
+ * @returns {string}
153
+ */
154
+ export function renderConformanceComment(verdict) {
155
+ const lines = [
156
+ LINT_COMMENT_MARKER,
157
+ '### ⚠️ Ticket body does not round-trip through the Mandrel parser',
158
+ '',
159
+ 'Agents build ticket bodies from a canonical schema that this body does ' +
160
+ 'not match, so the supported human entry points (e.g. `/plan` from an ' +
161
+ 'existing Epic ID) will reject it. Please fix the following:',
162
+ '',
163
+ ...verdict.problems.map((p) => `- ${p}`),
164
+ '',
165
+ 'The quickest fix is to refile using the **Story** or **Epic** issue form ' +
166
+ '(New issue → pick the template), which lays out the required sections.',
167
+ ];
168
+ if (verdict.warnings.length > 0) {
169
+ lines.push(
170
+ '',
171
+ '<details><summary>Parser warnings (non-blocking)</summary>',
172
+ '',
173
+ ...verdict.warnings.map((w) => `- ${w}`),
174
+ '',
175
+ '</details>',
176
+ );
177
+ }
178
+ return lines.join('\n');
179
+ }
180
+
181
+ /**
182
+ * Thin `gh` wrapper. Returns the trimmed stdout, throwing on a non-zero
183
+ * exit so the CLI surfaces the failure (orchestration-error-handling rule).
184
+ *
185
+ * @param {string[]} args
186
+ * @returns {string}
187
+ */
188
+ function gh(args) {
189
+ const res = spawnSync('gh', args, { encoding: 'utf8' });
190
+ if (res.status !== 0) {
191
+ throw new Error(
192
+ `gh ${args.join(' ')} failed (exit ${res.status}): ${res.stderr?.trim() ?? ''}`,
193
+ );
194
+ }
195
+ return (res.stdout ?? '').trim();
196
+ }
197
+
198
+ /**
199
+ * CLI entry. Reads the target issue (number from `--issue` or the
200
+ * `ISSUE_NUMBER` env), fetches its body + labels, and — when the body is
201
+ * non-conformant — upserts a single marker comment. Always exits 0 (the
202
+ * lint *informs*, it does not block), unless an unexpected I/O error occurs.
203
+ *
204
+ * Flags:
205
+ * --issue <n> Issue number (defaults to env ISSUE_NUMBER).
206
+ * --repo <o/r> owner/repo (defaults to env GITHUB_REPOSITORY).
207
+ * --dry-run Evaluate + print the verdict, never touch GitHub.
208
+ */
209
+ async function main() {
210
+ const args = process.argv.slice(2);
211
+ const get = (flag) => {
212
+ const i = args.indexOf(flag);
213
+ return i >= 0 ? args[i + 1] : undefined;
214
+ };
215
+ const dryRun = args.includes('--dry-run');
216
+ const issue = get('--issue') ?? process.env.ISSUE_NUMBER;
217
+ const repo = get('--repo') ?? process.env.GITHUB_REPOSITORY;
218
+
219
+ if (!issue) {
220
+ throw new Error('lint-issue-body: --issue <n> or ISSUE_NUMBER is required');
221
+ }
222
+
223
+ const repoArgs = repo ? ['--repo', repo] : [];
224
+ const raw = gh([
225
+ 'issue',
226
+ 'view',
227
+ String(issue),
228
+ ...repoArgs,
229
+ '--json',
230
+ 'body,labels',
231
+ ]);
232
+ const { body, labels } = JSON.parse(raw);
233
+ const labelNames = (labels ?? []).map((l) => l.name);
234
+ const isTicket =
235
+ labelNames.includes('type::story') || labelNames.includes('type::epic');
236
+
237
+ if (!isTicket) {
238
+ // Machine-parsable JSON envelope → process.stdout.write (not console.log),
239
+ // per the .agents/scripts logging contract (tests/enforcement/no-console).
240
+ process.stdout.write(
241
+ `${JSON.stringify({ issue: Number(issue), skipped: 'not-a-ticket' })}\n`,
242
+ );
243
+ return;
244
+ }
245
+
246
+ const verdict = evaluateIssueBody(body ?? '');
247
+ process.stdout.write(
248
+ `${JSON.stringify({
249
+ issue: Number(issue),
250
+ conformant: verdict.conformant,
251
+ problems: verdict.problems,
252
+ })}\n`,
253
+ );
254
+
255
+ if (verdict.conformant || dryRun) return;
256
+
257
+ const comment = renderConformanceComment(verdict);
258
+ gh(['issue', 'comment', String(issue), ...repoArgs, '--body', comment]);
259
+ }
260
+
261
+ runAsCli(import.meta.url, main, { source: 'lint-issue-body' });
@@ -12,7 +12,7 @@ description:
12
12
 
13
13
  - Document the **why**, not the what. Capture context, constraints, alternatives considered, and trade-offs — code already shows what was built.
14
14
  - Write an ADR for any decision that would be expensive to reverse (framework choice, data model, auth strategy, API architecture, hosting platform).
15
- - Store ADRs at `docs/decisions/` with sequential numbering (`ADR-001`, `ADR-002`, ) and the canonical sections: **Status, Date, Context, Decision, Alternatives Considered, Consequences**.
15
+ - Mandrel ships **two first-class decisions-log layouts** — pick one at onboarding (see [Decisions-log layouts](#decisions-log-layouts)): the **single-file dated-entry** `docs/decisions.md` (default; best for small projects) or the **index + `docs/decisions/` directory** (MADR-style, one file per ADR; best once the log outgrows a single file). Either way, the canonical ADR sections are **Status, Date, Deciders, Context, Decision, (Alternatives Considered), Consequences**.
16
16
  - Mark an ADR's status as `Accepted`, `Superseded by ADR-XXX`, or `Deprecated`. Never silently delete an ADR — supersede it.
17
17
  - Do **not** document obvious code; do **not** restate what the code already says. Stale or redundant docs are worse than no docs.
18
18
  - Comments explain **non-obvious intent** (the why). If a comment describes what the code does, refactor the code instead.
@@ -54,9 +54,41 @@ highest-value documentation you can write.
54
54
  - Choosing between build tools, hosting platforms, or infrastructure
55
55
  - Any decision that would be expensive to reverse
56
56
 
57
+ ### Decisions-log layouts
58
+
59
+ Mandrel ships **two supported layouts** for the decisions log. Both keep the
60
+ mandatory-read file named `docs/decisions.md` (the `project.docsContextFiles`
61
+ default), so `config-resolver.js` and every `.agents/` reference resolve the
62
+ same regardless of which you pick — only the **shape** differs. Choose one at
63
+ onboarding:
64
+
65
+ | Layout | Shape | Template(s) | When to use |
66
+ | ----------------------------------- | --------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------- |
67
+ | **Single-file dated entries** (default) | One `decisions.md` of append-only `## YYYY-MM-DD — title` entries | [`templates/docs/decisions.md`](../../../templates/docs/decisions.md) | Small projects; a handful of decisions; you want everything in one scannable file. |
68
+ | **Index + `decisions/` directory** | `decisions.md` is a one-row-per-ADR **index**; each ADR is `decisions/NNNN-*.md` | [`templates/docs/decisions.index.md`](../../../templates/docs/decisions.index.md) + [`templates/docs/decisions/_template.md`](../../../templates/docs/decisions/_template.md) | The log has outgrown a single file (dozens of ADRs); you want per-decision history and `git blame` per ADR. |
69
+
70
+ To adopt the directory layout, replace `decisions.md` with the index variant,
71
+ create a `decisions/` directory beside it, and scaffold each ADR from
72
+ `decisions/_template.md` using zero-padded sequential numbering
73
+ (`0001-*.md`, `0002-*.md`, …).
74
+
75
+ > **Loading model (resolved design question).** The decisions **index** is the
76
+ > only artifact loaded into mandatory task context — individual ADR bodies
77
+ > under `decisions/` are **lazy / link-followed**, not auto-loaded. This is
78
+ > **index-only by default**: auto-loading every ADR body into each task's
79
+ > context would reintroduce exactly the bloat the split exists to remove.
80
+ > `project.docsContextFiles` entries are plain filenames resolved against the
81
+ > docs root (no glob expansion in the loader), so the index ships as a normal
82
+ > mandatory-read with no loader change. A project that genuinely wants the full
83
+ > ADR set in mandatory context can add explicit per-file entries (or a
84
+ > `decisions/*.md`-style entry if it maintains its own globbing) as a
85
+ > deliberate opt-in, but that is the exception, not the default.
86
+
57
87
  ### ADR Template
58
88
 
59
- Store ADRs in `docs/decisions/` with sequential numbering:
89
+ In the **single-file** layout, append a short dated entry per the
90
+ `templates/docs/decisions.md` format. In the **directory** layout, store ADRs
91
+ in `docs/decisions/` with sequential numbering:
60
92
 
61
93
  ```markdown
62
94
  # ADR-001: Use PostgreSQL for primary database
@@ -69,6 +101,10 @@ Accepted | Superseded by ADR-XXX | Deprecated
69
101
 
70
102
  2025-01-15
71
103
 
104
+ ## Deciders
105
+
106
+ The platform team (architect + two senior engineers).
107
+
72
108
  ## Context
73
109
 
74
110
  We need a primary database for the task management application. Key
@@ -27,4 +27,7 @@ Describe the top-level directories and their responsibilities.
27
27
 
28
28
  ## Key Decisions
29
29
 
30
- Link to `decisions.md` for the architectural decision log.
30
+ Link to `decisions.md` for the architectural decision log. Mandrel supports two
31
+ first-class layouts for it: a single-file dated-entry `decisions.md` (default)
32
+ or an index + `decisions/` ADR directory — see
33
+ [`.agents/skills/core/documentation-and-adrs/SKILL.md`](../../skills/core/documentation-and-adrs/SKILL.md).
@@ -0,0 +1,35 @@
1
+ # ADR-NNNN: <short decision title>
2
+
3
+ > Copy this file to `decisions/NNNN-<kebab-title>.md` (zero-padded, sequential
4
+ > — `0001`, `0002`, …) and add a matching row to the `decisions.md` index.
5
+ > ADRs are **append-only**: never rewrite or delete an accepted ADR — supersede
6
+ > it with a new one and flip this one's **Status** to `Superseded by ADR-NNNN`.
7
+
8
+ ## Status
9
+
10
+ Accepted
11
+
12
+ <!-- One of: Proposed | Accepted | Superseded by ADR-NNNN | Deprecated -->
13
+
14
+ ## Date
15
+
16
+ YYYY-MM-DD
17
+
18
+ ## Deciders
19
+
20
+ <!-- Who made the call (names / roles / "the team"). -->
21
+
22
+ ## Context
23
+
24
+ <!-- What forced the decision: the constraint, problem, or trade-off. What
25
+ were the requirements and the forces in tension? -->
26
+
27
+ ## Decision
28
+
29
+ <!-- What was chosen, stated plainly. -->
30
+
31
+ ## Consequences
32
+
33
+ <!-- What this enables and what it costs going forward — positive and negative.
34
+ Include follow-on work, new constraints, and anything a future reader must
35
+ know before reversing this. -->
@@ -0,0 +1,49 @@
1
+ # Architectural Decisions Log (Index)
2
+
3
+ > **Directory-layout variant.** This is the MADR-style **index + `decisions/`
4
+ > directory** alternative to the default single-file dated-entry
5
+ > [`decisions.md`](decisions.md). To adopt it, replace your `decisions.md` with
6
+ > this index, create a `decisions/` directory next to it, and scaffold ADRs
7
+ > from [`decisions/_template.md`](decisions/_template.md). This file stays named
8
+ > `decisions.md` so it remains the `project.docsContextFiles` mandatory-read
9
+ > every `.agents/` reference and `config-resolver.js` already point at — only
10
+ > its **shape** changes from dated entries to an index.
11
+ >
12
+ > Prefer this layout once the single-file log grows past a few dozen entries
13
+ > (athportal hit ~1060 lines / 32 ADRs before splitting). For small projects,
14
+ > keep the default single-file template instead.
15
+
16
+ ## How this layout works
17
+
18
+ - **This file is the index** — one row per ADR, newest at the top. It is the
19
+ mandatory-read; agents scan the index and follow the link into a specific
20
+ ADR only when the detail is relevant (index-only by default — see
21
+ [Loading model](#loading-model)).
22
+ - **Each ADR is its own file** under `decisions/`, named
23
+ `NNNN-<kebab-title>.md` with a zero-padded sequential number.
24
+ - **ADRs are append-only.** Never rewrite or delete an accepted ADR — write a
25
+ new one and flip the old one's status to `Superseded by ADR-NNNN`.
26
+ - **Scaffold new ADRs** from [`decisions/_template.md`](decisions/_template.md)
27
+ (Status / Date / Deciders / Context / Decision / Consequences).
28
+
29
+ ## Loading model
30
+
31
+ This index is the only decisions artifact loaded into mandatory task context
32
+ (`project.docsContextFiles`). Individual ADR bodies under `decisions/` are
33
+ **lazy / link-followed**, not auto-loaded — that is the whole point of the
34
+ split: keep the per-task context lean while preserving the full decision
35
+ history on disk. If a project genuinely wants the entire ADR set in mandatory
36
+ context, it can add an explicit `decisions/*.md`-style entry to
37
+ `project.docsContextFiles` as an opt-in (see the configuration reference), but
38
+ index-only is the intended default.
39
+
40
+ ## Index
41
+
42
+ | ADR | Title | Status | Date |
43
+ | -------- | ---------------------------------------- | -------- | ---------- |
44
+ | ADR-0001 | _Example — replace with your first ADR_ | Proposed | YYYY-MM-DD |
45
+
46
+ _Add new rows above this line, newest first. Once you scaffold a real ADR from
47
+ [`decisions/_template.md`](decisions/_template.md) into
48
+ `decisions/0001-<title>.md`, link the ADR id to that file (e.g.
49
+ `[ADR-0001](decisions/0001-<title>.md)`) and delete this example row._
@@ -4,6 +4,17 @@
4
4
  > decisions here as dated entries. This file is one of the
5
5
  > `project.docsContextFiles` mandatory-reads — agents consult it before every
6
6
  > task to avoid re-litigating settled choices.
7
+ >
8
+ > **Two supported layouts — pick one at onboarding.** This single-file
9
+ > dated-entry layout is the **default**, ideal for small projects. Once the
10
+ > log grows past a few dozen entries it becomes a context-bloat liability;
11
+ > at that point switch to the first-class **index + `decisions/` directory**
12
+ > variant ([`decisions.index.md`](decisions.index.md) + the ADR scaffold at
13
+ > [`decisions/_template.md`](decisions/_template.md)). Both layouts keep the
14
+ > file named `decisions.md` so the `project.docsContextFiles` mandatory-read
15
+ > resolves the same; only the shape differs. See
16
+ > [`.agents/skills/core/documentation-and-adrs/SKILL.md`](../../skills/core/documentation-and-adrs/SKILL.md)
17
+ > for when to choose which.
7
18
 
8
19
  ## Format
9
20
 
@@ -74,14 +74,22 @@ Phase 5 (Re-Plan Detection).
74
74
  one-pager feeds the scope-triage gate below, whose verdict folds into
75
75
  the **same** Phase 1 HITL confirmation. Do not stop twice.
76
76
 
77
- 3. **HITL stop — confirm the sharpened one-pager**: Display the one-pager
78
- to the operator and **STOP**. Do not proceed to Phase 2 until the
79
- user explicitly confirms the direction. This is the same gate the
80
- skill's own Phase 3 enforces; surfacing it here makes the wait
77
+ 3. **HITL stop — confirm the sharpened one-pager** (**gate #1**): Display
78
+ the one-pager to the operator and **STOP**. Do not proceed to Phase 2
79
+ until the user explicitly confirms the direction. This is the same gate
80
+ the skill's own Phase 3 enforces; surfacing it here makes the wait
81
81
  contract visible to `/plan` callers. When the Phase 1.5 verdict is
82
82
  `story` or `borderline`, this stop carries the three-way choice the
83
83
  triage gate defines (below) instead of a plain confirm.
84
84
 
85
+ > **`--yes` (headless) auto-proceed.** When `/plan` was invoked with
86
+ > `--yes`, this gate does **not** STOP: the one-pager confirm resolves as
87
+ > **approved** and the run continues to Phase 2. A `story` / `borderline`
88
+ > triage verdict resolves to its **Recommended** branch (below) rather
89
+ > than prompting the three-way choice. Display the one-pager and the
90
+ > verdict line for the record, then proceed without waiting. See
91
+ > [`plan.md` § Headless / non-interactive mode](../plan.md#headless--non-interactive-mode---yes).
92
+
85
93
  ## Phase 1.5: Scope Triage (ideation path only)
86
94
 
87
95
  This phase runs **only** on the ideation path, immediately after Phase 1
@@ -124,6 +132,15 @@ skill states once).
124
132
  avoid the ceremony tax of pushing a story-sized scope through the full Epic
125
133
  pipeline.
126
134
 
135
+ > **`--yes` (headless) exception.** "Never auto-route" is the interactive
136
+ > contract. Under `--yes` the operator has *pre-authorized* the
137
+ > recommendation: the three-way choice resolves to its **Recommended**
138
+ > branch deterministically — `single Story` hands off to
139
+ > `/plan --from-notes <path>` (carrying `--yes` so the receiving story
140
+ > path also auto-proceeds), and an `epic` verdict simply continues to
141
+ > Phase 2. No operator wait. This is the only sanctioned auto-route, and it
142
+ > exists solely to make `/plan` driveable headlessly.
143
+
127
144
  ## Phase 2: Cross-Epic Duplicate Search
128
145
 
129
146
  Runs immediately after Phase 1 (and only on the s-plan-ideation path).
@@ -396,6 +413,19 @@ for the scoring logic.
396
413
  permission") is honored — no `gh issue edit` call until the
397
414
  operator confirms.
398
415
 
416
+ > **`--yes` (headless) auto-proceed.** This refinement-diff confirm is the
417
+ > clarity-gate face of `/plan`'s **gate #1** on the existing-Epic
418
+ > (`/plan <epicId>`) path — it is an operator *wait*, not a deterministic
419
+ > validator (the deterministic half is the section-presence *scoring* in
420
+ > step 1, which always runs). When `/plan` was invoked with `--yes`, this
421
+ > confirm does **not** STOP: the sharpened body is auto-**approved** and the
422
+ > run proceeds to step 6 (persist). The blast-radius note is still displayed
423
+ > for the record; only the operator wait is suppressed. This keeps
424
+ > `/plan <epicId> --yes` driveable headlessly even when the Epic body needs
425
+ > refinement (`gh issue edit` still runs only via the step 6 persist call,
426
+ > which the auto-approval authorizes). See
427
+ > [`plan.md` § Headless / non-interactive mode](../plan.md#headless--non-interactive-mode---yes).
428
+
399
429
  6. **Persist**: On approval, run the persist mode:
400
430
 
401
431
  ```bash
@@ -556,18 +586,31 @@ for the scoring logic.
556
586
  `planningRisk.requiresReview` unless the operator passed
557
587
  `--force-review`:
558
588
  - **High risk** (`requiresReview === true`) or **operator override**
559
- (`--force-review`): **STOP**. Ask the USER to review the generated
560
- PRD, Tech Spec, and Acceptance Spec on GitHub. Approval is the
561
- user's verbal OK in this session — the three context tickets stay
589
+ (`--force-review`) **gate #2**: **STOP**. Ask the USER to review the
590
+ generated PRD, Tech Spec, and Acceptance Spec on GitHub. Approval is
591
+ the user's verbal OK in this session — the three context tickets stay
562
592
  **open** through delivery and are closed automatically by
563
593
  `/deliver` when the Epic PR opens. Do NOT proceed
564
594
  to decomposition until the user confirms the plan is accurate.
595
+
596
+ > **`--yes` (headless) auto-proceed.** When `/plan` was invoked with
597
+ > `--yes`, this review gate does **not** STOP, even when
598
+ > `requiresReview === true` or `--force-review` was also passed: the
599
+ > review resolves as **approved** and the run **continues directly to
600
+ > Phase 8**, exactly as the low-risk auto-proceed branch below. The
601
+ > three context tickets stay **open** through delivery as usual; only
602
+ > the operator *wait* is suppressed. This is `/plan`'s **gate #2** —
603
+ > the second and last HITL STOP `--yes` suppresses. `--yes` does
604
+ > **not** alter risk routing or the review criteria themselves; it
605
+ > only forces a proceed where this gate would otherwise STOP. See
606
+ > [`plan.md` § Headless / non-interactive mode](../plan.md#headless--non-interactive-mode---yes).
565
607
  - **Low risk** (`requiresReview === false` and no `--force-review`):
566
608
  Emit the auto-proceed message from the persist stdout
567
609
  (`reviewRouting.operatorMessage`) and **continue directly to Phase 8**
568
610
  without an extra review stop. The Epic still carries
569
611
  `agent::review-spec` until decomposition completes; the routing
570
- decision is recorded in the `epic-plan-state` checkpoint.
612
+ decision is recorded in the `epic-plan-state` checkpoint. (`--yes` is
613
+ a no-op on this branch — there is no STOP to suppress.)
571
614
 
572
615
  5. **Tech Spec freshness check (advisory)**: After the Tech Spec issue
573
616
  is created, `epic-plan-spec.js` runs
@@ -68,6 +68,9 @@ to authoring the standalone Story body from the handed-off one-pager.
68
68
 
69
69
  # Inspect the draft body without creating an Issue:
70
70
  /plan --dry-run --body temp/single-story-draft.md
71
+
72
+ # Headless / non-interactive (auto-proceeds the draft-confirm gate):
73
+ /plan --idea "rip out the unused TaskBodyMigrator export" --yes
71
74
  ```
72
75
 
73
76
  ## Phase 1 — Emit Context
@@ -94,7 +97,7 @@ Envelope fields (`kind: "story-plan-context"`, `version: 1`):
94
97
  | `bodyTemplate` | Contents of `.agents/templates/single-story-body.md`. |
95
98
  | `requiredSections` | `["Context", "Acceptance Criteria", "Out of Scope", "Notes"]`. |
96
99
  | `duplicateCandidates` | Ranked open Stories whose titles fuzzy-match the seed. |
97
- | `techStack` | The `## Tech Stack` section of `docs/architecture.md`. |
100
+ | `techStack` | The project's Tech Stack inventory, resolved in order: `docs/tech-stack.md` (full body) when present, else the `## Tech Stack` section of `docs/architecture.md` (numbered/decorated and final-section headings tolerated). |
98
101
  | `deliverContract` | Workflow path + required/forbidden labels and references. |
99
102
 
100
103
  ### Refine heuristic
@@ -153,8 +156,18 @@ add a second stop.
153
156
 
154
157
  Display the draft to the operator and **STOP**. Do not call the persist phase
155
158
  until the operator explicitly confirms the draft. This mirrors the HITL gate
156
- `/plan` Phase 3 enforces before opening the Epic Issue. The scope-triage
157
- verdict folds into this same stop:
159
+ `/plan` Phase 3 enforces before opening the Epic Issue. This is the
160
+ story-path face of `/plan`'s **gate #1** (the ideation one-pager /
161
+ scope-triage confirm). The scope-triage verdict folds into this same stop:
162
+
163
+ > **`--yes` (headless) auto-proceed.** When `/plan` was invoked with `--yes`,
164
+ > this gate does **not** STOP: the draft confirm resolves as **approved** and
165
+ > the run proceeds to Phase 3 (persist). An `epic` verdict resolves to its
166
+ > **Recommended** branch — escalate to `/plan --idea` (carrying `--yes`),
167
+ > abandoning the draft — rather than prompting the three-way choice. Display
168
+ > the draft and the verdict line for the record, then proceed without
169
+ > waiting. See
170
+ > [`plan.md` § Headless / non-interactive mode](../plan.md#headless--non-interactive-mode---yes).
158
171
 
159
172
  - **`story` verdict (or gate skipped via handoff)** → no extra prompt. The
160
173
  operator confirms the draft as usual and the run proceeds to Phase 3
@@ -177,6 +190,9 @@ verdict folds into this same stop:
177
190
 
178
191
  **Never auto-route.** The verdict is advisory; the operator always decides,
179
192
  and no `agent::*` / label transition happens on either side of the choice.
193
+ (**`--yes` exception:** headless mode pre-authorizes the **Recommended**
194
+ branch deterministically — see the `--yes` note above — the only sanctioned
195
+ auto-route, present solely to make `/plan` driveable without an operator.)
180
196
 
181
197
  ## Phase 3 — Persist (`gh issue create`)
182
198