mandrel 1.92.0 → 1.93.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.
@@ -15,7 +15,13 @@
15
15
  *
16
16
  * Rules (one error per mismatched path):
17
17
  * - `creates` + path **exists** → error (Story would clobber).
18
- * - `refactors-existing` + path **absent** → error (no target to refactor).
18
+ * - `refactors-existing` (via `changes`) + path **absent** →
19
+ * auto-normalized to `creates` with a logged warning (#4496 fix 5):
20
+ * a refactor declaration against a base-untracked path is
21
+ * deterministically a create, so rejecting it only forces a
22
+ * reject→amend→re-persist cycle for a mechanical rewrite. Genuine
23
+ * mismatches keep failing — a `references`-sourced `refactors-existing`
24
+ * on an absent path is a missing read dependency and stays an error.
19
25
  * - `exists` + path **absent** → error (read dependency missing).
20
26
  * - `deletes` + path **absent** → error (nothing to delete).
21
27
  *
@@ -185,6 +191,18 @@ function renderMismatch({
185
191
  return `"${slug}" → body.${source} declares assumption="${assumption}" for ${path} but the path already exists at the base branch.`;
186
192
  }
187
193
 
194
+ /**
195
+ * Render an auto-normalization (#4496 fix 5) into a stable warning string.
196
+ * Kept pure and exported through the report so callers log a
197
+ * self-explanatory line rather than re-deriving the rationale.
198
+ *
199
+ * @param {{ slug: string, source: string, path: string, assumption: string }} normalization
200
+ * @returns {string}
201
+ */
202
+ function renderNormalization({ slug, source, path, assumption }) {
203
+ return `"${slug}" → body.${source} declares assumption="${assumption}" for ${path} but the path is untracked at the base branch — auto-normalized to "creates" (a refactor of a base-untracked path is deterministically a create). Declare assumption="creates" in the plan to silence this warning.`;
204
+ }
205
+
188
206
  /**
189
207
  * Index, across every Story, which Stories declare a `creates` (and which
190
208
  * declare a `deletes`) for each `changes`-sourced path. The maps drive the
@@ -251,8 +269,11 @@ function predecessorMutator(index, path, predecessors) {
251
269
  *
252
270
  * {
253
271
  * errors: string[] // one entry per mismatch, batched per Story
254
- * warnings: string[] // legacy/no-assumption deprecation nudges
272
+ * warnings: string[] // legacy/no-assumption deprecation nudges +
273
+ * // auto-normalization notices (#4496 fix 5)
255
274
  * mismatches: object[] // structured payload for downstream tooling
275
+ * normalizations: object[] // `refactors-existing`→`creates`
276
+ * // auto-normalizations on base-untracked paths
256
277
  * }
257
278
  *
258
279
  * Under the 2-tier hierarchy the Story is the implementation unit, so the
@@ -283,6 +304,7 @@ export function validateStoryFileAssumptions(opts) {
283
304
  const errors = [];
284
305
  const warnings = [];
285
306
  const mismatches = [];
307
+ const normalizations = [];
286
308
  const probeCache = new Map();
287
309
 
288
310
  // Wave-aware setup (Story #3960): transitive predecessor sets over the
@@ -350,6 +372,14 @@ export function validateStoryFileAssumptions(opts) {
350
372
  predecessorCreator,
351
373
  });
352
374
  if (mismatch !== null) {
375
+ // Auto-normalization (#4496 fix 5): a deterministic
376
+ // `refactors-existing`→`creates` rewrite is a warning, never a
377
+ // rejection — genuine mismatches keep flowing to `errors`.
378
+ if (mismatch.normalizedTo === 'creates') {
379
+ normalizations.push(mismatch);
380
+ warnings.push(renderNormalization(mismatch));
381
+ continue;
382
+ }
353
383
  mismatches.push(mismatch);
354
384
  errors.push(renderMismatch(mismatch));
355
385
  continue;
@@ -384,7 +414,7 @@ export function validateStoryFileAssumptions(opts) {
384
414
  }
385
415
  }
386
416
  }
387
- return { errors, warnings, mismatches };
417
+ return { errors, warnings, mismatches, normalizations };
388
418
  }
389
419
 
390
420
  /**
@@ -468,12 +498,43 @@ function checkAssumption({
468
498
  }
469
499
  return null;
470
500
  case 'refactors-existing':
501
+ // Validate against the simulated tree: a predecessor `creates` makes
502
+ // an otherwise-absent base path present, so `refactors-existing`
503
+ // against it is no longer a false-positive mismatch (Story #3960).
504
+ if (!simulatedExists) {
505
+ // Auto-normalization (#4496 fix 5): a `changes`-sourced refactor
506
+ // declaration on a path untracked at the base branch (and not
507
+ // produced by any predecessor) is deterministically a create —
508
+ // downgrade to a normalization warning instead of rejecting. Two
509
+ // genuine mismatches stay hard errors: a `references`-sourced entry
510
+ // (a read dependency this Story does not author cannot be "created"
511
+ // here), and a base-TRACKED path a predecessor deletes (the absence
512
+ // is a plan-shape conflict, not an untracked-path misdeclaration).
513
+ if (source === 'changes' && !baseExists) {
514
+ return {
515
+ slug,
516
+ source,
517
+ path,
518
+ assumption,
519
+ expected: 'creates',
520
+ actual: 'absent',
521
+ normalizedTo: 'creates',
522
+ };
523
+ }
524
+ return {
525
+ slug,
526
+ source,
527
+ path,
528
+ assumption,
529
+ expected: 'present',
530
+ actual: 'absent',
531
+ };
532
+ }
533
+ return null;
471
534
  case 'exists':
472
535
  case 'deletes':
473
- // Validate against the simulated tree: a predecessor `creates` makes
474
- // an otherwise-absent base path present, so `refactors-existing` /
475
- // `exists` / `deletes` against it is no longer a false-positive
476
- // mismatch (Story #3960).
536
+ // Same simulated-tree overlay as above (Story #3960); an absent path
537
+ // remains a genuine mismatch for both assumptions.
477
538
  if (!simulatedExists) {
478
539
  return {
479
540
  slug,
@@ -11,7 +11,7 @@
11
11
  * file instead of shim-scripting library imports (the bench measured
12
12
  * ~12–15 turns of shim-writing for the dup search alone).
13
13
  *
14
- * Two modes (the design's mode matrix):
14
+ * Three modes (the design's mode matrix + the #4496 seed entry):
15
15
  * - `epic` — the Epic exists. Carries `epic`, `clarity` (the Epic
16
16
  * Clarity Gate rubric — free, same body fetch), `replan`
17
17
  * (already-planned signals) and `planState`.
@@ -19,6 +19,14 @@
19
19
  * to the persist half). Carries `onePager` and
20
20
  * `duplicates[]` (cross-Epic dup search). Clarity is not
21
21
  * scored — the ideation path is definitionally clear.
22
+ * - `seed` — headless ideation entry (#4496 fix 1): the one-pager
23
+ * does not exist yet either. The dup search runs off the
24
+ * raw seed text, and the envelope additively carries
25
+ * `seed`, `scopeTriage` (the scope-triage rubric applied
26
+ * CLI-side — no skill Reads on the headless path) and
27
+ * `onePagerSpec` (the canonical one-pager sections, so
28
+ * the authoring pass writes the one-pager in the SAME
29
+ * batched write as the spec artifacts).
22
30
  *
23
31
  * All fields are JSON-serialisable; the module performs no GitHub writes.
24
32
  * The only I/O surfaces are the injected `provider` (reads) and the
@@ -79,6 +87,131 @@ export const TICKET_SCHEMA_DESCRIPTOR = Object.freeze({
79
87
  'validateAndNormalizeTickets (lib/orchestration/ticket-validator.js) at persist time',
80
88
  });
81
89
 
90
+ /**
91
+ * Canonical one-pager authoring descriptor for the `seed` envelope
92
+ * (#4496 fix 1). The section names are the ones `plan-epic.md`'s ideation
93
+ * entry has always named, chosen so the authored headings parse against
94
+ * the `SECTION_RE` map in `lib/epic-plan-ideation.js` (which renders the
95
+ * Epic body from the one-pager at persist time via
96
+ * `.agents/templates/epic-from-idea.md`).
97
+ */
98
+ export const ONE_PAGER_AUTHORING_SPEC = Object.freeze({
99
+ sections: Object.freeze([
100
+ 'Problem Statement',
101
+ 'Recommended Direction',
102
+ 'Key Assumptions',
103
+ 'MVP Scope',
104
+ 'Not Doing',
105
+ ]),
106
+ instruction:
107
+ 'Author the one-pager markdown (the canonical sections above, as `## ` ' +
108
+ 'headings) in the SAME batched write as the other planning artifacts — ' +
109
+ 'no separate ideation pass and no idea-refinement skill activation on ' +
110
+ 'this path. Every unresolved unknown lands in Key Assumptions instead ' +
111
+ 'of a question.',
112
+ consumedBy:
113
+ 'plan-persist.js --one-pager (ideation Epic creation via ' +
114
+ '.agents/templates/epic-from-idea.md)',
115
+ });
116
+
117
+ /**
118
+ * Count top-level enumerated items (`- `, `* `, `1. `) anywhere in a
119
+ * free-form seed text. Unlike {@link countScopeItems} this does not require
120
+ * a scope-shaped heading — a raw `--idea` seed rarely has one.
121
+ *
122
+ * @param {string} text
123
+ * @returns {number}
124
+ */
125
+ function countEnumeratedItems(text) {
126
+ if (typeof text !== 'string' || text.length === 0) return 0;
127
+ return text
128
+ .split(/\r?\n/)
129
+ .filter((line) => /^\s*(?:[-*]|\d+\.)\s+\S/.test(line)).length;
130
+ }
131
+
132
+ /**
133
+ * Delta-shaped change-request verbs — the `core/scope-triage` skill's
134
+ * change-request rubric routes these to `story` by default when the
135
+ * footprint stays inside Story width.
136
+ */
137
+ const DELTA_VERB_RE =
138
+ /\b(fix(?:es)?|tweak(?:s)?|extend(?:s)?|update(?:s)?|adjust(?:s)?|rename(?:s)?|correct(?:s)?|patch(?:es)?|bug|regression|flaky)\b/i;
139
+
140
+ /**
141
+ * Deterministic, CLI-applied scope-triage verdict over a raw `--idea` seed
142
+ * (#4496 fix 6). Embedding the verdict in the `--seed` envelope removes the
143
+ * two skill Reads (`core/scope-triage` + the gate fragment's rubric pass)
144
+ * from the headless path; the attended path keeps the skill-based judgment.
145
+ *
146
+ * The heuristics anchor to the same sizing SSOT the skill anchors to —
147
+ * `DELIVERABLE_GRANULARITY_GUIDANCE` / `DEFAULT_TASK_SIZING` in
148
+ * `ticket-validator-sizing.js` (one Story = one coherent capability slice;
149
+ * multiple independent capabilities = an Epic) — and to the skill's
150
+ * change-request delta rubric. Like the skill, the verdict is **advisory**:
151
+ * being wrong in the `epic` direction is cheap (the consolidation critic and
152
+ * the sizing validator catch an over-planned Story later), and `borderline`
153
+ * is a first-class output, not a forced call.
154
+ *
155
+ * @param {{ seedText?: string }} args
156
+ * @returns {{ verdict: 'epic'|'story'|'borderline', reasons: string[], advisory: true, appliedBy: 'cli' }}
157
+ */
158
+ export function buildScopeTriageSignal({ seedText = '' } = {}) {
159
+ const advisory = /** @type {const} */ (true);
160
+ const appliedBy = /** @type {const} */ ('cli');
161
+ const text = typeof seedText === 'string' ? seedText : '';
162
+ const listItems = countEnumeratedItems(text);
163
+ const wordCount = text.split(/\s+/).filter(Boolean).length;
164
+
165
+ if (listItems >= 3) {
166
+ return {
167
+ verdict: 'epic',
168
+ reasons: [
169
+ `seed enumerates ${listItems} candidate capabilities — a genuine fan-out surface`,
170
+ ],
171
+ advisory,
172
+ appliedBy,
173
+ };
174
+ }
175
+ if (listItems >= 1) {
176
+ return {
177
+ verdict: 'story',
178
+ reasons: [
179
+ `seed enumerates ${listItems} capability item(s) — one coherent change with one reason to exist`,
180
+ ],
181
+ advisory,
182
+ appliedBy,
183
+ };
184
+ }
185
+ if (DELTA_VERB_RE.test(text) && wordCount <= 120) {
186
+ return {
187
+ verdict: 'story',
188
+ reasons: [
189
+ 'delta-shaped seed (change-request verb, no capability enumeration) within Story width',
190
+ ],
191
+ advisory,
192
+ appliedBy,
193
+ };
194
+ }
195
+ if (wordCount >= 250) {
196
+ return {
197
+ verdict: 'epic',
198
+ reasons: [
199
+ `broad prose seed (~${wordCount} words) with no enumeration — plausibly multiple independent capabilities`,
200
+ ],
201
+ advisory,
202
+ appliedBy,
203
+ };
204
+ }
205
+ return {
206
+ verdict: 'borderline',
207
+ reasons: [
208
+ 'no capability enumeration and no clear delta signal — could be one ambitious Story or a small Epic; the operator (or the --yes Recommended branch) decides',
209
+ ],
210
+ advisory,
211
+ appliedBy,
212
+ };
213
+ }
214
+
82
215
  /**
83
216
  * Resolve the planning risk heuristics list from the canonical config
84
217
  * block (same resolution the decompose context uses).
@@ -449,14 +582,56 @@ async function buildOnePagerModeEnvelope({
449
582
  };
450
583
  }
451
584
 
585
+ /**
586
+ * Build the seed-mode (headless ideation) envelope — #4496 fix 1. The
587
+ * one-pager does not exist yet: the dup search and the authoring-context
588
+ * fold both run off the raw seed text (the same builders the one-pager mode
589
+ * uses), and the envelope additively carries `seed`, the CLI-applied
590
+ * `scopeTriage` verdict (fix 6 — no skill Reads on the headless path), and
591
+ * `onePagerSpec` so the one-pager sections are authored in the same batched
592
+ * write as the spec artifacts.
593
+ */
594
+ async function buildSeedModeEnvelope({
595
+ seedText,
596
+ provider,
597
+ config,
598
+ settings,
599
+ fullContext,
600
+ cwd,
601
+ }) {
602
+ if (typeof seedText !== 'string' || seedText.trim().length === 0) {
603
+ throw new Error(
604
+ '[plan-context] --seed requires non-empty seed text — nothing to plan from.',
605
+ );
606
+ }
607
+ const base = await buildOnePagerModeEnvelope({
608
+ onePagerPath: undefined,
609
+ onePagerContent: seedText,
610
+ provider,
611
+ config,
612
+ settings,
613
+ fullContext,
614
+ cwd,
615
+ });
616
+ const { onePager: _onePager, ...rest } = base;
617
+ return {
618
+ ...rest,
619
+ mode: 'seed',
620
+ seed: { text: seedText },
621
+ scopeTriage: buildScopeTriageSignal({ seedText }),
622
+ onePagerSpec: ONE_PAGER_AUTHORING_SPEC,
623
+ };
624
+ }
625
+
452
626
  /**
453
627
  * Build the single planner-context envelope.
454
628
  *
455
629
  * @param {{
456
- * mode: 'epic'|'one-pager',
630
+ * mode: 'epic'|'one-pager'|'seed',
457
631
  * epicId?: number,
458
632
  * onePagerPath?: string,
459
633
  * onePagerContent?: string,
634
+ * seedText?: string,
460
635
  * provider: object,
461
636
  * config: object,
462
637
  * settings: object,
@@ -469,6 +644,7 @@ export async function buildPlanContext({
469
644
  epicId,
470
645
  onePagerPath,
471
646
  onePagerContent,
647
+ seedText,
472
648
  provider,
473
649
  config = {},
474
650
  settings = {},
@@ -504,7 +680,17 @@ export async function buildPlanContext({
504
680
  cwd,
505
681
  });
506
682
  }
683
+ if (mode === 'seed') {
684
+ return buildSeedModeEnvelope({
685
+ seedText,
686
+ provider,
687
+ config,
688
+ settings,
689
+ fullContext,
690
+ cwd,
691
+ });
692
+ }
507
693
  throw new Error(
508
- `[plan-context] unknown mode "${mode}" — expected "epic" or "one-pager".`,
694
+ `[plan-context] unknown mode "${mode}" — expected "epic", "one-pager" or "seed".`,
509
695
  );
510
696
  }
@@ -0,0 +1,99 @@
1
+ /**
2
+ * plan-critics-evaluate.js — shared critic-dispatch evaluation for the
3
+ * collapsed /plan flow (#4496 fix 6; extracted from the `plan-critics.js`
4
+ * CLI so the persist surface folds the same evaluation in as a pre-write
5
+ * phase).
6
+ *
7
+ * Two consumers:
8
+ * - `plan-persist.js` (via `runPlanPersist`) — evaluates the dispatch
9
+ * conditions as a deterministic pre-write phase, prints the verdicts,
10
+ * and records every skip on the plan-metrics ledger, so the headless
11
+ * path never pays a standalone CLI turn for the same decision.
12
+ * - `plan-critics.js` — the standalone CLI survives one release as a
13
+ * thin shim over this module for the attended pre-gate evaluation
14
+ * (the verdict folds into gate #2's view before the persist runs).
15
+ *
16
+ * Pure evaluation: no file I/O, no GitHub calls, no ledger writes — the
17
+ * callers own artifact loading and skip recording.
18
+ *
19
+ * @module lib/orchestration/plan-critics-evaluate
20
+ */
21
+
22
+ import { getLimits } from '../config-resolver.js';
23
+ import {
24
+ evaluateConsolidationDispatch,
25
+ evaluatePremortemDispatch,
26
+ } from './plan-critic-conditions.js';
27
+
28
+ /**
29
+ * Resolve the planning risk heuristics list from the canonical config
30
+ * block (same resolution `plan-context.js` and the decompose context use).
31
+ *
32
+ * @param {object} config
33
+ * @returns {string[]}
34
+ */
35
+ function resolveRiskHeuristics(config = {}) {
36
+ if (Array.isArray(config.planning?.riskHeuristics)) {
37
+ return config.planning.riskHeuristics;
38
+ }
39
+ return config.agentSettings?.planning?.riskHeuristics || [];
40
+ }
41
+
42
+ /**
43
+ * Evaluate the consolidation + pre-mortem critic dispatch conditions over
44
+ * the authored planning artifacts (design §4 / #4474 PR6 conditions,
45
+ * unchanged):
46
+ *
47
+ * - Consolidation: skipped outright when `tickets` is null/absent (the
48
+ * single-delivery shape authors no draft tickets); otherwise the
49
+ * deterministic precondition + size/divergence conditions.
50
+ * - Pre-mortem: risk verdict overall level high, OR ticket count at least
51
+ * half `maxTickets`, OR any `planning.riskHeuristics` phrase matching
52
+ * the plan text.
53
+ *
54
+ * @param {{
55
+ * techSpecContent: string,
56
+ * riskVerdict: { summary?: string },
57
+ * tickets?: Array<object>|null,
58
+ * config?: object,
59
+ * }} args
60
+ * @returns {{
61
+ * consolidation: { critic: string, dispatch: boolean, reasons: string[] },
62
+ * premortem: { critic: string, dispatch: boolean, reasons: string[] },
63
+ * }}
64
+ */
65
+ export function evaluatePlanCritics({
66
+ techSpecContent,
67
+ riskVerdict,
68
+ tickets = null,
69
+ config = {},
70
+ }) {
71
+ const ticketList = Array.isArray(tickets) ? tickets : null;
72
+ const consolidation =
73
+ ticketList === null
74
+ ? {
75
+ critic: 'consolidation',
76
+ dispatch: false,
77
+ reasons: [
78
+ 'single-delivery shape — no draft tickets exist to consolidate.',
79
+ ],
80
+ }
81
+ : evaluateConsolidationDispatch({
82
+ draftStories: ticketList,
83
+ specText: techSpecContent,
84
+ });
85
+
86
+ const premortem = evaluatePremortemDispatch({
87
+ riskVerdict,
88
+ ticketCount: ticketList?.length ?? 0,
89
+ maxTickets: getLimits(config).maxTickets,
90
+ riskHeuristics: resolveRiskHeuristics(config),
91
+ planText: [
92
+ techSpecContent ?? '',
93
+ ticketList ? JSON.stringify(ticketList) : '',
94
+ riskVerdict?.summary ?? '',
95
+ ].join('\n'),
96
+ });
97
+
98
+ return { consolidation, premortem };
99
+ }
@@ -130,6 +130,7 @@ import {
130
130
  read as readPlanState,
131
131
  write as writePlanState,
132
132
  } from '../epic-plan-state-store.js';
133
+ import { evaluatePlanCritics } from '../plan-critics-evaluate.js';
133
134
  import {
134
135
  appendCriticSkip,
135
136
  readPlanMetrics,
@@ -426,9 +427,10 @@ export async function runPlanPersist({
426
427
  let validated = null;
427
428
  let amendPartition = null;
428
429
  let reachability = null;
430
+ let gateSet = null;
429
431
  if (mode !== 'single') {
430
432
  amendPartition = mode === 'amend' ? partitionAmendTickets(tickets) : null;
431
- const gateSet = mode === 'amend' ? buildMergedTicketSet(tickets) : tickets;
433
+ gateSet = mode === 'amend' ? buildMergedTicketSet(tickets) : tickets;
432
434
  const maxTickets = getLimits(config).maxTickets;
433
435
  if (gateSet.length > maxTickets && !allowOverBudget) {
434
436
  throw new Error(
@@ -533,6 +535,40 @@ export async function runPlanPersist({
533
535
  );
534
536
  }
535
537
 
538
+ // ---- Step 4.7: folded critic dispatch evaluation (#4496 fix 6 — the
539
+ // former standalone `plan-critics.js` turn). Deterministic and git-local,
540
+ // still zero provider calls: the verdicts are printed (and returned on
541
+ // the result) as part of the pre-write phase, and every skip decision is
542
+ // appended to the plan-metrics ledger exactly as the standalone CLI did,
543
+ // so under-firing stays auditable without a separate invocation on the
544
+ // headless path. Advisory by construction — the deterministic validators
545
+ // above remain the unchanged hard gates. ----
546
+ const critics = evaluatePlanCritics({
547
+ techSpecContent,
548
+ riskVerdict,
549
+ tickets: gateSet,
550
+ config,
551
+ });
552
+ for (const decision of [critics.consolidation, critics.premortem]) {
553
+ Logger.info(
554
+ `[plan-persist] critic ${decision.critic}: ` +
555
+ `${decision.dispatch ? 'dispatch' : 'skip'} — ` +
556
+ decision.reasons.join('; '),
557
+ );
558
+ if (!decision.dispatch) {
559
+ // Best-effort by contract — a failed append never fails the persist.
560
+ await appendCriticSkip(
561
+ {
562
+ critic: decision.critic,
563
+ reasons: decision.reasons,
564
+ cli: 'plan-persist',
565
+ epicId: requestedEpicId,
566
+ },
567
+ config,
568
+ );
569
+ }
570
+ }
571
+
536
572
  // ---- Step 5: ideation fold / Epic resolution (first provider call). ----
537
573
  const { epicId, epic, created } = await resolveTargetEpic({
538
574
  epicId: requestedEpicId,
@@ -902,6 +938,7 @@ export async function runPlanPersist({
902
938
  freshness,
903
939
  healthcheck,
904
940
  reachability,
941
+ critics,
905
942
  reconcile,
906
943
  specPath: specFilePath,
907
944
  waveTable,
@@ -89,10 +89,16 @@ function renderWaveTableLines(waveTable) {
89
89
  * record `{ deliveryShape, sliceCount, routingReasons }` (Epic #4474 PR4)
90
90
  * for the spec-only mode.
91
91
  *
92
+ * Every auto-waiver the persist derived is printed WITH its reason
93
+ * (#4496 fix 2) — e.g. the no-BDD-runner acceptance-disposition waiver
94
+ * (`planningRisk.acceptanceWaivedReason`) — so the summary is
95
+ * self-explanatory and a headless reader never has to re-derive a persist
96
+ * outcome from framework source.
97
+ *
92
98
  * @param {{
93
99
  * epicId: number,
94
100
  * ticketCount: number,
95
- * planningRisk: { overallLevel?: string, gateDecision?: string },
101
+ * planningRisk: { overallLevel?: string, gateDecision?: string, acceptanceDisposition?: string, acceptanceWaivedReason?: string },
96
102
  * reviewRouting: { decision?: string },
97
103
  * freshness?: { stale?: number, ambiguous?: number },
98
104
  * healthcheck?: { ok?: boolean, waived?: boolean, skipped?: boolean },
@@ -137,6 +143,14 @@ export function buildPlanSummaryCommentBody({
137
143
  ? `- Single-delivery plan (\`delivery::single\`): no Story tree — the Delivery Slicing table is the audit trail.`
138
144
  : `- ${ticketCount} Story ticket(s) persisted across ${waveTable.length} wave(s).`;
139
145
 
146
+ // Auto-waivers always ship with their reason (#4496 fix 2): the summary
147
+ // is authoritative, so the line must be self-explanatory on its own.
148
+ const waiverLines = planningRisk?.acceptanceWaivedReason
149
+ ? [
150
+ `- ⚠️ Acceptance disposition auto-waived to \`not-applicable\` — ${planningRisk.acceptanceWaivedReason}`,
151
+ ]
152
+ : [];
153
+
140
154
  const amendLines = amend
141
155
  ? [
142
156
  `- Amend delta: ${amend.created.length} added, ${amend.recreated.length} modified (closed + recreated), ${amend.closed.length} closed, ${amend.keptCount} kept untouched.`,
@@ -176,6 +190,7 @@ export function buildPlanSummaryCommentBody({
176
190
  headLine,
177
191
  ...amendLines,
178
192
  `- Risk: ${planningRisk?.overallLevel ?? 'unknown'} · ${planningRisk?.gateDecision ?? 'unknown'} (review routing: ${reviewRouting?.decision ?? 'unknown'}).`,
193
+ ...waiverLines,
179
194
  freshnessLine,
180
195
  healthcheckLine,
181
196
  // G2 measurement receipt (Epic #4474 PR1/PR7): the plan-CLI invocation
@@ -589,8 +589,25 @@ export function validateAndNormalizeTickets(tickets, opts = {}) {
589
589
  gitRunner: sharedGitRunner,
590
590
  cwd: opts.cwd,
591
591
  });
592
+ // Auto-normalizations (#4496 fix 5) get their own prefix so the logged
593
+ // warning is self-explanatory; everything else on the warnings channel
594
+ // is a legacy-shape deprecation nudge.
595
+ const normalizationWarnings = new Set(
596
+ (assumptionReport.normalizations ?? []).map((n) => n.path),
597
+ );
592
598
  for (const warning of assumptionReport.warnings) {
593
- Logger.warn(`[ticket-validator] assumption-deprecation: ${warning}`);
599
+ const isNormalization = warning.includes('auto-normalized to "creates"');
600
+ Logger.warn(
601
+ `[ticket-validator] ${isNormalization ? 'assumption-normalized' : 'assumption-deprecation'}: ${warning}`,
602
+ );
603
+ }
604
+ if (normalizationWarnings.size > 0) {
605
+ Logger.warn(
606
+ `[ticket-validator] ${normalizationWarnings.size} refactors-existing ` +
607
+ 'declaration(s) on base-untracked path(s) auto-normalized to ' +
608
+ '"creates" — the gate proceeds; update the plan declarations at ' +
609
+ 'the next amend.',
610
+ );
594
611
  }
595
612
  assumptionErrors = assumptionReport.errors;
596
613
  }
@@ -11,7 +11,7 @@
11
11
  * stdout-pure JSON envelope. The PR7 cutover retired the delegate CLIs —
12
12
  * this is the only emit-context surface.
13
13
  *
14
- * Two entry forms (exactly one is required):
14
+ * Three entry forms (exactly one is required):
15
15
  *
16
16
  * --epic <id> Existing-Epic mode. Envelope carries `epic`,
17
17
  * `clarity` (Epic Clarity Gate rubric), `replan`
@@ -23,6 +23,15 @@
23
23
  * dup search). No clarity score: the ideation path
24
24
  * is definitionally clear.
25
25
  *
26
+ * --seed "<text>" Headless ideation entry (#4496 fix 1) — neither
27
+ * the Epic nor the one-pager exists yet. The dup
28
+ * search runs off the raw seed text, and the
29
+ * envelope additively carries `seed`, `scopeTriage`
30
+ * (the scope-triage rubric applied CLI-side — fix 6)
31
+ * and `onePagerSpec`, so the one-pager sections are
32
+ * authored in the same batched write as the spec
33
+ * artifacts.
34
+ *
26
35
  * Flags:
27
36
  * --pretty Pretty-print the JSON envelope.
28
37
  * --full-context Bypass the planning-context budget (unbounded body).
@@ -58,10 +67,11 @@ import { createProvider } from './lib/provider-factory.js';
58
67
  * captured output is exactly one `JSON.parse`-able payload.
59
68
  *
60
69
  * @param {{
61
- * mode: 'epic'|'one-pager',
70
+ * mode: 'epic'|'one-pager'|'seed',
62
71
  * epicId?: number,
63
72
  * onePagerPath?: string,
64
73
  * onePagerContent?: string,
74
+ * seedText?: string,
65
75
  * provider: object,
66
76
  * config: object,
67
77
  * settings: object,
@@ -77,6 +87,7 @@ export async function emitPlanContext({
77
87
  epicId,
78
88
  onePagerPath,
79
89
  onePagerContent,
90
+ seedText,
80
91
  provider,
81
92
  config,
82
93
  settings,
@@ -90,6 +101,7 @@ export async function emitPlanContext({
90
101
  epicId,
91
102
  onePagerPath,
92
103
  onePagerContent,
104
+ seedText,
93
105
  provider,
94
106
  config,
95
107
  settings,
@@ -108,6 +120,7 @@ async function main() {
108
120
  options: {
109
121
  epic: { type: 'string' },
110
122
  'one-pager': { type: 'string' },
123
+ seed: { type: 'string' },
111
124
  pretty: { type: 'boolean', default: false },
112
125
  'full-context': { type: 'boolean', default: false },
113
126
  },
@@ -117,12 +130,16 @@ async function main() {
117
130
  const hasEpic = typeof values.epic === 'string' && values.epic.length > 0;
118
131
  const hasOnePager =
119
132
  typeof values['one-pager'] === 'string' && values['one-pager'].length > 0;
120
- if (hasEpic === hasOnePager) {
133
+ const hasSeed = typeof values.seed === 'string' && values.seed.length > 0;
134
+ const entryForms = [hasEpic, hasOnePager, hasSeed].filter(Boolean).length;
135
+ if (entryForms !== 1) {
121
136
  throw new Error(
122
- 'Pass exactly one of --epic <id> or --one-pager <path>. ' +
123
- '(--epic: existing-Epic mode; --one-pager: ideation mode.)',
137
+ 'Pass exactly one of --epic <id>, --one-pager <path> or --seed "<text>". ' +
138
+ '(--epic: existing-Epic mode; --one-pager: ideation mode; ' +
139
+ '--seed: headless ideation entry.)',
124
140
  );
125
141
  }
142
+ const mode = hasEpic ? 'epic' : hasOnePager ? 'one-pager' : 'seed';
126
143
 
127
144
  let epicId;
128
145
  if (hasEpic) {
@@ -159,21 +176,22 @@ async function main() {
159
176
  const provider = createProvider(config);
160
177
 
161
178
  // Plan-metrics ledger (#4474 PR1): stamp entry/exit + mode so the folded
162
- // emit surface is measured against the 12-phase baseline. One-pager mode
163
- // has no Epic yet, so the record routes to the standalone stream
164
- // (epicId null) exactly like `story-plan.js`.
179
+ // emit surface is measured against the 12-phase baseline. One-pager and
180
+ // seed modes have no Epic yet, so the record routes to the standalone
181
+ // stream (epicId null) exactly like `story-plan.js`.
165
182
  await recordPlanInvocation(
166
183
  {
167
184
  cli: 'plan-context',
168
- mode: hasEpic ? 'epic' : 'one-pager',
185
+ mode,
169
186
  epicId: hasEpic ? epicId : null,
170
187
  config,
171
188
  },
172
189
  () =>
173
190
  emitPlanContext({
174
- mode: hasEpic ? 'epic' : 'one-pager',
191
+ mode,
175
192
  epicId,
176
193
  onePagerPath: hasOnePager ? values['one-pager'] : undefined,
194
+ seedText: hasSeed ? values.seed : undefined,
177
195
  provider,
178
196
  config,
179
197
  settings,
@@ -5,15 +5,24 @@
5
5
  * author-step critics of the collapsed /plan flow (Epic #4474 PR6,
6
6
  * design §4).
7
7
  *
8
+ * **Thin shim (#4496 fix 6).** The evaluation itself now lives in
9
+ * `lib/orchestration/plan-critics-evaluate.js` and is folded into
10
+ * `plan-persist.js` as a pre-write phase, so the headless (`--yes`) path
11
+ * never pays a standalone CLI turn for the dispatch decision. This CLI
12
+ * survives one release for the attended pre-gate evaluation (the verdict
13
+ * folds into gate #2's view before the persist runs) and for any external
14
+ * scripting; it delegates to the shared module and keeps its exact output
15
+ * contract.
16
+ *
8
17
  * Runs between authoring and gate #2, entirely git-local (zero GitHub
9
18
  * calls): reads the authored artifacts, evaluates the risk/size dispatch
10
19
  * conditions for the consolidation (8.3) and pre-mortem (8.5) critics via
11
- * `lib/orchestration/plan-critic-conditions.js`, and emits one JSON
12
- * verdict on stdout. The workflow dispatches a fresh-context sub-agent
13
- * ONLY for critics with `dispatch: true`; every skip decision is appended
14
- * to the plan-metrics ledger (`kind: "critic-skip"`, with reasons) so
15
- * under-firing is auditable — the persist validators remain unchanged
16
- * hard gates regardless of what this gate decides.
20
+ * the shared evaluator, and emits one JSON verdict on stdout. The workflow
21
+ * dispatches a fresh-context sub-agent ONLY for critics with
22
+ * `dispatch: true`; every skip decision is appended to the plan-metrics
23
+ * ledger (`kind: "critic-skip"`, with reasons) so under-firing is
24
+ * auditable — the persist validators remain unchanged hard gates
25
+ * regardless of what this gate decides.
17
26
  *
18
27
  * Conditions (design §4 / §6 PR6):
19
28
  * - Consolidation: the existing deterministic precondition
@@ -53,16 +62,12 @@ import { parseArgs } from 'node:util';
53
62
  import { runAsCli } from './lib/cli-utils.js';
54
63
  import { epicArtifactPath } from './lib/config/temp-paths.js';
55
64
  import {
56
- getLimits,
57
65
  resolveConfig,
58
66
  validateOrchestrationConfig,
59
67
  } from './lib/config-resolver.js';
60
68
  import { routeAllOutputToStderr } from './lib/Logger.js';
61
69
  import { loadRiskVerdict } from './lib/orchestration/epic-plan-spec/phases/risk-verdict.js';
62
- import {
63
- evaluateConsolidationDispatch,
64
- evaluatePremortemDispatch,
65
- } from './lib/orchestration/plan-critic-conditions.js';
70
+ import { evaluatePlanCritics } from './lib/orchestration/plan-critics-evaluate.js';
66
71
  import {
67
72
  appendCriticSkip,
68
73
  recordPlanInvocation,
@@ -72,20 +77,6 @@ const USAGE =
72
77
  'Usage: plan-critics.js (--epic <EpicId> | --tech-spec <file> ' +
73
78
  '--risk-verdict <file> [--tickets <file>]) [--pretty]';
74
79
 
75
- /**
76
- * Resolve the planning risk heuristics list from the canonical config
77
- * block (same resolution `plan-context.js` and the decompose context use).
78
- *
79
- * @param {object} config
80
- * @returns {string[]}
81
- */
82
- function resolveRiskHeuristics(config = {}) {
83
- if (Array.isArray(config.planning?.riskHeuristics)) {
84
- return config.planning.riskHeuristics;
85
- }
86
- return config.agentSettings?.planning?.riskHeuristics || [];
87
- }
88
-
89
80
  async function readOptional(filePath, { required }) {
90
81
  try {
91
82
  return await readFile(filePath, 'utf8');
@@ -172,30 +163,11 @@ async function main() {
172
163
  }
173
164
  }
174
165
 
175
- const consolidation =
176
- tickets === null
177
- ? {
178
- critic: 'consolidation',
179
- dispatch: false,
180
- reasons: [
181
- 'single-delivery shape — no draft tickets exist to consolidate.',
182
- ],
183
- }
184
- : evaluateConsolidationDispatch({
185
- draftStories: tickets,
186
- specText: techSpecContent,
187
- });
188
-
189
- const premortem = evaluatePremortemDispatch({
166
+ const { consolidation, premortem } = evaluatePlanCritics({
167
+ techSpecContent,
190
168
  riskVerdict,
191
- ticketCount: tickets?.length ?? 0,
192
- maxTickets: getLimits(config).maxTickets,
193
- riskHeuristics: resolveRiskHeuristics(config),
194
- planText: [
195
- techSpecContent,
196
- ticketsRaw ?? '',
197
- riskVerdict.summary ?? '',
198
- ].join('\n'),
169
+ tickets,
170
+ config,
199
171
  });
200
172
 
201
173
  // Skip-audit trail (#4474 PR6): every non-dispatch is a ledger
@@ -100,19 +100,30 @@ reseeding from live GitHub state when the file is missing — so only the
100
100
  genuinely-missing children are created; the existing tree is never
101
101
  duplicated.
102
102
 
103
- ## Measurement — the G2 acceptance gate (Epic #4474)
103
+ ## Measurement — the G2 acceptance gate (Epic #4474, restated per #4496)
104
104
 
105
- The 3-step collapse is **measured, not asserted**. The acceptance bar the
106
- next benchmark cohort reads as the gate:
105
+ The 3-step collapse is **measured, not asserted**. The gate is stated
106
+ **per mode** (#4496 adopted in the bench roadmap after the first measured
107
+ headless run):
107
108
 
108
- - **Turns-per-plan ~15** at the epic rung (from the 55–72-turn 12-phase
109
- baseline). The turns proxy is the plan-metrics invocation ledger
109
+ - **Epic-mode** (`/plan <id> --yes`): **≤ ~12 turns / ~1.1M tokens**.
110
+ - **Ideation-mode** (`/plan --idea --yes`): **≤ ~15 turns / ≤ ~1.5M
111
+ tokens** once the #4496 fixes (seed-mode ideation, authoritative persist
112
+ summaries, batched artifact writes) are in play; **interim smoke
113
+ threshold ≤ ~20 turns / ≤ ~2.0M tokens** for runs measured before/during
114
+ the rollout.
115
+ - Verification is a cheap direct `/plan --idea --yes` probe reading the
116
+ plan-metrics ledger (~$3–4), not a full bench cell.
117
+
118
+ Shared mechanics behind both rungs (from the 55–72-turn / 7.3–8.9M
119
+ 12-phase baseline):
120
+
121
+ - The turns proxy is the plan-metrics invocation ledger
110
122
  (`temp/epic-<id>/plan-metrics.json`) plus the host session's turn
111
123
  accounting — ledger records count CLI invocations from the parent
112
124
  session's perspective, not sub-agent turns.
113
- - **Plan tokens ~1.5M** at the epic rung (from 7.3–8.9M) — owned by the
114
- host's session accounting (mandrel-bench `modelUsage`), not by any
115
- in-repo counter.
125
+ - Plan tokens are owned by the host's session accounting (mandrel-bench
126
+ `modelUsage`), not by any in-repo counter.
116
127
  - **Unchanged validator coverage** — every deterministic gate of the
117
128
  retired pipeline still fires on the persist path: section gate, ticket
118
129
  validator, file-assumption, DAG, budget, draft reachability, inline
@@ -35,6 +35,9 @@ confirmed (gate #1).
35
35
 
36
36
  ### Ideation entry (`--idea "<seed>"` or no argument)
37
37
 
38
+ **Attended (no `--yes`)** — the interactive grill loop is HITL by
39
+ definition and stays:
40
+
38
41
  1. Activate the [`core/idea-refinement`](../../skills/core/idea-refinement/SKILL.md)
39
42
  skill with the seed. It returns a one-pager with the canonical sections
40
43
  (Problem Statement, Recommended Direction, Key Assumptions, MVP Scope,
@@ -64,6 +67,26 @@ confirmed (gate #1).
64
67
  operator either confirms the new Epic is distinct or folds the idea into
65
68
  an existing Epic (in which case `/plan` exits).
66
69
 
70
+ **Headless (`--yes`) — seed entry (#4496 fix 1).** There is no one to
71
+ grill, so the one-pager prelude is pure ceremony: do **not** activate the
72
+ `idea-refinement` skill and do **not** write a separate one-pager file
73
+ before authoring. Instead, emit the envelope directly off the seed:
74
+
75
+ ```bash
76
+ node .agents/scripts/plan-context.js --seed "<seed text>" \
77
+ > temp/plan-ideation/<slug>/plan-context.json
78
+ ```
79
+
80
+ (slug from the seed's leading phrase; the tree is gitignored). The seed envelope is the one-pager envelope plus three additive fields:
81
+ `seed` (the raw text), `scopeTriage` (the scope-triage rubric applied
82
+ CLI-side — no skill Reads on this path; a `story` / `borderline` verdict
83
+ resolves to its Recommended handoff per
84
+ [`scope-triage-gate.md`](scope-triage-gate.md)), and `onePagerSpec` (the
85
+ canonical one-pager sections). The one-pager markdown is authored **in
86
+ step 2's single batched write**, alongside the other artifacts — every
87
+ unresolved unknown lands in its Key Assumptions section. `duplicates[]`
88
+ resolves headlessly per gate #1's `--yes` note.
89
+
67
90
  ### Existing-Epic entry (`/plan <epicId>`)
68
91
 
69
92
  1. Emit the authoring envelope:
@@ -126,25 +149,38 @@ step 2 until the operator explicitly confirms.
126
149
  Read the envelope with the `Read` tool and write the planning artifacts to
127
150
  `temp/epic-[Epic_ID]/` (ideation: the `temp/plan-ideation/<slug>/` tree).
128
151
  The single `plan-context.json` envelope supersedes the per-phase
129
- `planner-context.json` / `decomposer-context.json` files the authoring
130
- skills name — read the envelope wherever a skill asks for either.
131
-
132
- 1. **`techspec.md`** activate the
133
- [`epic-plan-spec-author`](../../skills/core/epic-plan-spec-author/SKILL.md)
134
- skill. The Tech Spec opens with `## Delivery Slicing` and never restates
135
- the Epic's Context/Goal/Scope.
136
- 2. **`risk-verdict.json`** — same skill; the schema-conformant verdict
152
+ `planner-context.json` / `decomposer-context.json` files.
153
+
154
+ **The envelope's `systemPrompts` ARE the authoring instructions on this
155
+ path** (#4496 fix 4): `systemPrompts.spec` / `systemPrompts.acceptance`
156
+ govern the Tech Spec, risk verdict, and Acceptance Spec;
157
+ `systemPrompts.decompose` (with `ticketSchema` and `maxTickets`) governs
158
+ the tickets. Do **not** read the
159
+ [`epic-plan-spec-author`](../../skills/core/epic-plan-spec-author/SKILL.md)
160
+ or
161
+ [`epic-plan-decompose-author`](../../skills/core/epic-plan-decompose-author/SKILL.md)
162
+ SKILL.md files here — they remain the reference source the prompts render
163
+ from, but re-reading them double-pays instructions the envelope already
164
+ carries.
165
+
166
+ **Batched writes (#4496 fix 3).** Emit the artifact files as **parallel
167
+ `Write` calls in ONE message** — `techspec.md`, `risk-verdict.json`,
168
+ `acceptance-spec.md`, and `tickets.json` together (ideation: the one-pager
169
+ markdown joins the same batch). Never write them one-per-turn.
170
+
171
+ 1. **`techspec.md`** — per `systemPrompts.spec`. The Tech Spec opens with
172
+ `## Delivery Slicing` and never restates the Epic's Context/Goal/Scope.
173
+ 2. **`risk-verdict.json`** — the schema-conformant verdict
137
174
  **plus `deliveryShape: "fan-out"|"single"`** and a one-line rationale.
138
175
  Seed the shape from the envelope's `deliveryShapeSignal` (advisory —
139
176
  the operator vetoes it at gate #2). `"single"` means one-pass-sized or a
140
177
  pure dependent chain: the plan ships as spec-only, with **no tickets**.
141
- 3. **`acceptance-spec.md`** — same skill; omit only when the Epic carries
142
- the `acceptance::n-a` waiver label.
143
- 4. **`tickets.json`** — fan-out shape only: activate the
144
- [`epic-plan-decompose-author`](../../skills/core/epic-plan-decompose-author/SKILL.md)
145
- skill against the same envelope (its `systemPrompts.decompose`,
146
- `ticketSchema`, and `maxTickets` fields). In single-delivery shape author
147
- **no** tickets file — the Delivery Slicing table is the plan.
178
+ 3. **`acceptance-spec.md`** — per `systemPrompts.acceptance`; omit only
179
+ when the Epic carries the `acceptance::n-a` waiver label.
180
+ 4. **`tickets.json`** — fan-out shape only: author against the same
181
+ envelope (its `systemPrompts.decompose`, `ticketSchema`, and
182
+ `maxTickets` fields). In single-delivery shape author **no** tickets
183
+ file the Delivery Slicing table is the plan.
148
184
 
149
185
  > **One-pass refinement contract (amend, don't regenerate).** When a critic
150
186
  > flags Stories or the step 3 persist rejects an artifact, apply **targeted
@@ -153,8 +189,12 @@ skills name — read the envelope wherever a skill asks for either.
153
189
 
154
190
  ### Conditional critics (between authoring and gate #2)
155
191
 
156
- Evaluate the dispatch conditions deterministically first one git-local
157
- CLI call, zero GitHub reads:
192
+ The evaluation is folded into `plan-persist.js` as a deterministic
193
+ pre-write phase (#4496 fix 6) — the persist prints both verdicts, returns
194
+ them on its result envelope, and ledger-logs every skip. **Attended runs**
195
+ evaluate the dispatch conditions before gate #2 so the verdicts fold into
196
+ its view — one git-local CLI call, zero GitHub reads, via the thin shim
197
+ (kept one release over the shared evaluator):
158
198
 
159
199
  ```bash
160
200
  node .agents/scripts/plan-critics.js --epic [Epic_ID]
@@ -167,6 +207,14 @@ one-line note. Every skip decision is appended to the plan-metrics ledger
167
207
  (`kind: "critic-skip"`, with reasons) so under-firing is auditable — the
168
208
  persist validators remain unchanged hard gates either way.
169
209
 
210
+ > **`--yes` (headless).** Do **not** run the standalone CLI and do **not**
211
+ > dispatch critic sub-agents: the critics' findings would fold into a gate
212
+ > that auto-proceeds, so a report nobody reviews is pure spend. The
213
+ > persist's folded pre-write evaluation is the audit record — its verdicts
214
+ > print in the persist output, a `dispatch: true` verdict surfaces as a
215
+ > one-line advisory note in the run summary, and every skip still lands on
216
+ > the plan-metrics ledger.
217
+
170
218
  Both critics are **fresh-context sub-agents** (`Agent` tool,
171
219
  `subagent_type: general-purpose`) — never inline skill activations, so they
172
220
  cannot grade their own homework. Both are report-only: they never write to
@@ -253,6 +301,24 @@ comment carrying the dry-run wave table → temp cleanup **only at terminal
253
301
  success**, so a failed run leaves the artifacts in place for `--force` /
254
302
  `--resume` reuse.
255
303
 
304
+ Two deterministic softenings ride the gate list:
305
+
306
+ - The file-assumption gate **auto-normalizes** a `refactors-existing`
307
+ declaration on a base-untracked path to `creates` with a logged warning
308
+ (#4496 fix 5) — a refactor of a path that does not exist is
309
+ deterministically a create, so no amend cycle is forced. Genuine
310
+ mismatches (an absent read dependency, a clobbering `creates`, a missing
311
+ `deletes` target) still reject.
312
+ - Every auto-waiver the persist derives is printed **with its reason** in
313
+ the `plan-summary` comment and the result JSON (#4496 fix 2) — e.g. the
314
+ no-BDD-runner acceptance-disposition waiver.
315
+
316
+ > **`--yes` (headless): persist outcomes are authoritative.** Read the
317
+ > persist's result JSON and `plan-summary` receipts as the final word — do
318
+ > **not** re-derive a waiver, disposition, or routing decision from
319
+ > framework source. If a summary line seems surprising, its reason is on
320
+ > the line itself; spend zero turns re-verifying it.
321
+
256
322
  ### Persist rejections and soft failures
257
323
 
258
324
  Each rejection names the artifact and the gap; apply the one-pass amend
@@ -87,6 +87,15 @@ meanings; it only forces the Recommended resolution where the gate would
87
87
  otherwise STOP. See
88
88
  [`plan.md` § Headless / non-interactive mode](../plan.md#headless--non-interactive-mode---yes).
89
89
 
90
+ **Headless seed entry — the verdict is already in the envelope (#4496).**
91
+ On the `--idea` `--yes` path the rubric is applied **CLI-side** by
92
+ `plan-context.js --seed` and shipped as the envelope's `scopeTriage` field
93
+ (`{ verdict, reasons, advisory: true, appliedBy: "cli" }`), anchored to the
94
+ same sizing SSOT this skill anchors to. Do **not** Read the
95
+ `core/scope-triage` skill headless — use the envelope verdict and resolve it
96
+ per this section. The attended paths keep the skill-based judgment
97
+ unchanged.
98
+
90
99
  ## No-re-triage rule
91
100
 
92
101
  A **scope-triage handoff** is a triage decision *already made*. When `/plan` is
@@ -88,7 +88,10 @@ exactly **two** HITL STOP gates, and `--yes` deterministically auto-proceeds
88
88
  exactly one bounded pass**: no operator questions are asked — facts come
89
89
  from the codebase, and every unresolved unknown lands in the one-pager's
90
90
  **Key Assumptions** section instead of a question, so a headless driver
91
- can never hang inside a free-form interrogation. The verdict / clarity
91
+ can never hang inside a free-form interrogation. On the `--idea` Epic
92
+ path this bounded pass IS the seed entry (#4496): `plan-context.js
93
+ --seed` replaces the idea-refinement prelude, and the one-pager is
94
+ authored in the same batched write as the spec artifacts. The verdict / clarity
92
95
  scoring is still recorded in chat (one line); only the *wait* is
93
96
  suppressed — the deterministic clarity *scoring* inside the
94
97
  `plan-context.js` envelope always runs.
@@ -126,15 +129,24 @@ advisory critic diffs — auto-proceed for the same headless reason.
126
129
  1. **Parse args.** Exactly one of `<epicId>`, `--idea`, `--from-notes`, or
127
130
  `--body` must be present; anything else is a usage error naming the four
128
131
  forms. A `--body` invocation routes to the story path (no triage).
129
- 2. **Triage (idea path only).** Run the
132
+ 2. **Triage (idea path only).** Attended: run the
130
133
  [`core/scope-triage`](../skills/core/scope-triage/SKILL.md) skill on the
131
- seed. Record the verdict in chat (one line).
134
+ seed. Record the verdict in chat (one line). **Under `--yes`, do not
135
+ Read the skill**: delegate straight to
136
+ [`helpers/plan-epic.md`](helpers/plan-epic.md)'s ideation entry — its
137
+ `plan-context.js --seed` envelope carries the rubric's verdict applied
138
+ CLI-side (`scopeTriage`), and a `story` / `borderline` verdict resolves
139
+ to the Recommended handoff from inside the helper (see
140
+ [`helpers/scope-triage-gate.md`](helpers/scope-triage-gate.md)).
132
141
  3. **Delegate.** Read the selected path helper **in full** and execute it
133
142
  from its entry, forwarding the absorbed flags (including `--yes`). The
134
143
  helper's steps, HITL gates, and scripts are the procedure — this router
135
144
  adds no step content. When `--yes` is present, the two HITL STOP gates
136
145
  auto-proceed per [Headless / non-interactive mode](#headless--non-interactive-mode---yes)
137
- above; every deterministic gate still runs.
146
+ above; every deterministic gate still runs. **Under `--yes`, when the
147
+ helper content is already injected or present in context, execute it
148
+ directly — do not spend a separate read-in-full turn re-reading content
149
+ you already hold.**
138
150
  4. **Internal returns.** When a path helper would historically have handed
139
151
  off to the other planning command, switch helpers in-place and continue;
140
152
  surface the switch to the operator as a one-line note.
package/docs/CHANGELOG.md CHANGED
@@ -2,6 +2,13 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file.
4
4
 
5
+ ## [1.93.0](https://github.com/dsj1984/mandrel/compare/mandrel-v1.92.0...mandrel-v1.93.0) (2026-07-12)
6
+
7
+
8
+ ### Added
9
+
10
+ * **plan:** close the measured turn gap — seed-mode ideation, authoritative summaries, batched writes (refs [#4496](https://github.com/dsj1984/mandrel/issues/4496)) ([#4497](https://github.com/dsj1984/mandrel/issues/4497)) ([ae98701](https://github.com/dsj1984/mandrel/commit/ae987014d68669eb0032a68000ed328c7a3c702d))
11
+
5
12
  ## [1.92.0](https://github.com/dsj1984/mandrel/compare/mandrel-v1.91.0...mandrel-v1.92.0) (2026-07-12)
6
13
 
7
14
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mandrel",
3
- "version": "1.92.0",
3
+ "version": "1.93.0",
4
4
  "description": "Claude Code-first opinionated workflow framework: instructions, personas, skills, and SDLC workflows that govern AI coding assistants.",
5
5
  "files": [
6
6
  ".agents/",