mandrel 2.10.0 → 2.12.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.
Files changed (42) hide show
  1. package/.agents/docs/configuration.md +35 -33
  2. package/.agents/rules/orchestration-error-handling.md +9 -1
  3. package/.agents/schemas/agentrc.schema.json +13 -8
  4. package/.agents/scripts/acceptance-eval.js +9 -5
  5. package/.agents/scripts/lib/audit-suite/audit-rules-reader.js +48 -0
  6. package/.agents/scripts/lib/audit-suite/selector.js +1 -26
  7. package/.agents/scripts/lib/baselines/env-overrides.js +33 -0
  8. package/.agents/scripts/lib/baselines/git-base.js +0 -0
  9. package/.agents/scripts/lib/baselines/preview-gates.js +5 -0
  10. package/.agents/scripts/lib/config/gates/maintainability.schema.js +10 -1
  11. package/.agents/scripts/lib/config/quality.js +13 -0
  12. package/.agents/scripts/lib/config-settings-schema.js +12 -16
  13. package/.agents/scripts/lib/orchestration/ceremony-routing.js +45 -0
  14. package/.agents/scripts/lib/orchestration/check-baselines/phases/evaluate.js +97 -4
  15. package/.agents/scripts/lib/orchestration/check-baselines/phases/parse-args.js +7 -0
  16. package/.agents/scripts/lib/orchestration/complexity-gate.js +561 -184
  17. package/.agents/scripts/lib/orchestration/plan-context.js +69 -10
  18. package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +117 -60
  19. package/.agents/scripts/lib/orchestration/plan-persist/story-ops.js +21 -15
  20. package/.agents/scripts/lib/orchestration/resolve-stories.js +28 -7
  21. package/.agents/scripts/lib/orchestration/review-depth.js +9 -4
  22. package/.agents/scripts/lib/orchestration/single-story-close/gate-log.js +186 -0
  23. package/.agents/scripts/lib/orchestration/single-story-close/phases/close-validation.js +21 -3
  24. package/.agents/scripts/lib/orchestration/spec-budget.js +78 -0
  25. package/.agents/scripts/lib/orchestration/story-body-gate.js +72 -0
  26. package/.agents/scripts/lib/orchestration/ticket-validator-conflicts.js +6 -0
  27. package/.agents/scripts/lib/orchestration/ticket-validator.js +18 -62
  28. package/.agents/scripts/plan-context.js +23 -5
  29. package/.agents/scripts/resolve-stories.js +2 -0
  30. package/.agents/workflows/deliver.md +28 -28
  31. package/.agents/workflows/helpers/acceptance-self-eval.md +16 -5
  32. package/.agents/workflows/helpers/deliver-digest.md +126 -0
  33. package/.agents/workflows/helpers/deliver-reference.md +30 -5
  34. package/.agents/workflows/helpers/deliver-story-reference.md +38 -12
  35. package/.agents/workflows/helpers/deliver-story.md +34 -37
  36. package/.agents/workflows/helpers/plan-reference.md +79 -44
  37. package/.agents/workflows/plan.md +11 -10
  38. package/docs/CHANGELOG.md +31 -0
  39. package/lib/cli/registry.js +31 -14
  40. package/lib/migrations/index.js +2 -0
  41. package/lib/migrations/steps/2.11.0-retire-max-seed-words.js +92 -0
  42. package/package.json +1 -1
@@ -0,0 +1,186 @@
1
+ /**
2
+ * single-story-close/gate-log.js — bounded gate output for the close path
3
+ * (Story #4736).
4
+ *
5
+ * ## Why
6
+ *
7
+ * `runCloseValidation` streams every child gate's stdout/stderr line through
8
+ * an injected `log`, and the close phase used to hand it `Logger.info` — whose
9
+ * default sink is `console.log`. A single successful close therefore wrote the
10
+ * whole of `npm test`, the linter, and the baseline checks onto the invoking
11
+ * agent's stdout: ~50KB, over the host's inline tool-result ceiling. The caller
12
+ * got a truncated preview, had to open the persisted file anyway, and re-ran
13
+ * close for a clean envelope — burning the run's most expensive stretch to
14
+ * re-derive output it already had.
15
+ *
16
+ * Story #4708 set the contract this restores compliance with (see
17
+ * `rules/orchestration-error-handling.md` § Output Contract): compact digest
18
+ * plus an on-disk artifact path, ≤ ~2KB on the **default success path**.
19
+ *
20
+ * ## The shape
21
+ *
22
+ * A sink captures every gate line to a log under the gitignored temp tree and
23
+ * emits nothing inline. What happens next depends on the outcome, because the
24
+ * two outcomes want opposite things:
25
+ *
26
+ * - **success** — the caller wants the verdict, not the evidence.
27
+ * {@link GateLogSink#digest} is one line: the pass count and the log path.
28
+ * - **failure** — the evidence IS the point, and making the caller open a
29
+ * file to see why a gate went red just moves the cost.
30
+ * {@link GateLogSink#replay} puts the captured tail back inline.
31
+ *
32
+ * `AGENT_LOG_LEVEL=verbose` opts back into live inline streaming (the
33
+ * "existing log-level control"): the capture still happens, so the artifact is
34
+ * written either way.
35
+ *
36
+ * The sink never throws. A log directory that cannot be written degrades to
37
+ * inline streaming — losing the size bound is strictly better than losing the
38
+ * gate output that says why a close failed.
39
+ */
40
+
41
+ import nodeFs from 'node:fs';
42
+ import path from 'node:path';
43
+
44
+ import { Logger, resolveLevel } from '../../Logger.js';
45
+
46
+ /**
47
+ * How many trailing captured lines {@link GateLogSink#replay} puts back
48
+ * inline. A failed gate's actionable evidence — the assertion, the stack, the
49
+ * summary counts — sits at the end of its output; the head is startup noise.
50
+ * The full text is always in the artifact regardless.
51
+ */
52
+ export const REPLAY_TAIL_LINES = 200;
53
+
54
+ /** Basename of the per-Story gate log inside the temp directory. */
55
+ function logNameFor(storyId) {
56
+ return `close-gates-${storyId ?? 'unknown'}.log`;
57
+ }
58
+
59
+ /**
60
+ * A capturing sink for close-validation gate output.
61
+ *
62
+ * Not exported as a constructor — {@link createGateLogSink} owns the
63
+ * degradation decision, so every instance in the wild has already resolved
64
+ * whether it has a writable artifact.
65
+ */
66
+ class GateLogSink {
67
+ /**
68
+ * @param {{ logPath: string|null, streamInline: boolean, write: (line: string) => void, emit: (line: string) => void }} args
69
+ */
70
+ constructor({ logPath, streamInline, write, emit }) {
71
+ /** Absolute path of the artifact, or `null` when capture is unavailable. */
72
+ this.logPath = logPath;
73
+ /** Whether lines are ALSO echoed inline as they arrive. */
74
+ this.streamInline = streamInline;
75
+ /** Number of lines captured so far. */
76
+ this.lineCount = 0;
77
+ this._write = write;
78
+ this._emit = emit;
79
+ this._tail = [];
80
+ }
81
+
82
+ /**
83
+ * The `log` callable handed to `runCloseValidation` / `buildDefaultGates`.
84
+ * Bound, because it is passed by reference into the gate machinery.
85
+ *
86
+ * @type {(message: string) => void}
87
+ */
88
+ get log() {
89
+ return (message) => {
90
+ const line = String(message ?? '');
91
+ this.lineCount += 1;
92
+ this._tail.push(line);
93
+ if (this._tail.length > REPLAY_TAIL_LINES) this._tail.shift();
94
+ this._write(line);
95
+ if (this.streamInline) this._emit(line);
96
+ };
97
+ }
98
+
99
+ /**
100
+ * The success-path digest: one line, no gate output. Names the artifact so
101
+ * the caller can open it on demand rather than carrying it all session.
102
+ *
103
+ * @returns {string}
104
+ */
105
+ digest() {
106
+ const where = this.logPath
107
+ ? `full gate output → ${this.logPath}`
108
+ : 'full gate output was streamed inline (no artifact could be written)';
109
+ return `${this.lineCount} line(s) of gate output captured; ${where}`;
110
+ }
111
+
112
+ /**
113
+ * Put the captured tail back inline — the failure path, where the evidence
114
+ * is what the caller came for. A no-op when the lines were already streamed
115
+ * inline (verbose, or degraded capture), so nothing is ever printed twice.
116
+ *
117
+ * @returns {number} Lines replayed.
118
+ */
119
+ replay() {
120
+ if (this.streamInline || this._tail.length === 0) return 0;
121
+ const dropped = this.lineCount - this._tail.length;
122
+ if (dropped > 0) {
123
+ this._emit(
124
+ `[close-validation] … ${dropped} earlier line(s) omitted; full output → ${this.logPath}`,
125
+ );
126
+ }
127
+ for (const line of this._tail) this._emit(line);
128
+ return this._tail.length;
129
+ }
130
+ }
131
+
132
+ /**
133
+ * Build the gate-output sink for one close run.
134
+ *
135
+ * @param {{
136
+ * storyId: number|null,
137
+ * cwd?: string,
138
+ * logDir?: string,
139
+ * fs?: typeof nodeFs,
140
+ * logger?: { info: (m: string) => void },
141
+ * level?: string,
142
+ * }} [args] `logDir` defaults to `<cwd>/temp/orchestration`; `level` defaults
143
+ * to the live Logger level so `AGENT_LOG_LEVEL=verbose` restores streaming.
144
+ * @returns {GateLogSink}
145
+ */
146
+ export function createGateLogSink({
147
+ storyId = null,
148
+ cwd = process.cwd(),
149
+ logDir,
150
+ fs = nodeFs,
151
+ logger = Logger,
152
+ level,
153
+ } = {}) {
154
+ const emit = (line) => logger.info?.(line);
155
+ const verbose = (level ?? resolveLevel()) === 'verbose';
156
+ const dir = logDir ?? path.join(cwd, 'temp', 'orchestration');
157
+
158
+ let handle = null;
159
+ let logPath = null;
160
+ try {
161
+ fs.mkdirSync(dir, { recursive: true });
162
+ logPath = path.join(dir, logNameFor(storyId));
163
+ // Truncate: each close run owns its artifact outright, so a re-run never
164
+ // hands the reader a file interleaving two runs' gates.
165
+ handle = fs.openSync(logPath, 'w');
166
+ } catch {
167
+ // No artifact — fall back to inline streaming rather than dropping the
168
+ // gate output on the floor.
169
+ return new GateLogSink({
170
+ logPath: null,
171
+ streamInline: true,
172
+ write: () => {},
173
+ emit,
174
+ });
175
+ }
176
+
177
+ const write = (line) => {
178
+ try {
179
+ fs.writeSync(handle, `${line}\n`);
180
+ } catch {
181
+ /* best-effort: a mid-run write failure must not abort the close */
182
+ }
183
+ };
184
+
185
+ return new GateLogSink({ logPath, streamInline: verbose, write, emit });
186
+ }
@@ -24,6 +24,15 @@
24
24
  * the same, with `baseBranch` as the diff anchor and the Story worktree as
25
25
  * the commit target.
26
26
  *
27
+ * Bounded gate output (Story #4736). Every gate line goes to the run's
28
+ * `gate-log.js` sink — an artifact under the gitignored temp tree — instead
29
+ * of straight to the agent's stdout, where a passing `npm test` alone once
30
+ * pushed a successful close past the host's inline tool-result ceiling. A
31
+ * clean run reports one digest line naming the artifact; a failing gate
32
+ * replays its captured tail inline, because that is exactly when the caller
33
+ * needs the evidence in front of them. `AGENT_LOG_LEVEL=verbose` restores
34
+ * live streaming.
35
+ *
27
36
  * `runCloseValidation`, `buildDefaultGates`, and `runScopedFormatAutofix`
28
37
  * are accepted as injected dependencies so the parent CLI's cache-busted
29
38
  * bindings win in tests that mock the upstream module URLs.
@@ -33,6 +42,7 @@ import { buildDefaultGates as defaultBuildDefaultGates } from '../../../close-va
33
42
  import { runCloseValidation as defaultRunCloseValidation } from '../../../close-validation/runner.js';
34
43
  import { Logger } from '../../../Logger.js';
35
44
  import { runScopedFormatAutofix as defaultRunScopedFormatAutofix } from '../../story-close/format-autofix.js';
45
+ import { createGateLogSink as defaultCreateGateLogSink } from '../gate-log.js';
36
46
 
37
47
  /**
38
48
  * Run the close-validation gate chain. Throws on first gate failure.
@@ -60,6 +70,7 @@ import { runScopedFormatAutofix as defaultRunScopedFormatAutofix } from '../../s
60
70
  * runCloseValidation?: typeof defaultRunCloseValidation,
61
71
  * buildDefaultGates?: typeof defaultBuildDefaultGates,
62
72
  * runScopedFormatAutofix?: typeof defaultRunScopedFormatAutofix,
73
+ * createGateLogSink?: typeof defaultCreateGateLogSink,
63
74
  * }} args
64
75
  */
65
76
  export async function runCloseValidationPhase({
@@ -73,6 +84,7 @@ export async function runCloseValidationPhase({
73
84
  runCloseValidation = defaultRunCloseValidation,
74
85
  buildDefaultGates = defaultBuildDefaultGates,
75
86
  runScopedFormatAutofix = defaultRunScopedFormatAutofix,
87
+ createGateLogSink = defaultCreateGateLogSink,
76
88
  }) {
77
89
  // Story #4250 — format-autofix self-heal before the check-only gates.
78
90
  // Mirrors the Epic path (story-close/phases/gates.js): the formatter is
@@ -122,6 +134,9 @@ export async function runCloseValidationPhase({
122
134
  'VALIDATE',
123
135
  `Running close-validation gates against baseline ${baseBranch}${worktreePath ? ` in ${worktreePath}` : ''}...`,
124
136
  );
137
+ // Story #4736 — one sink for both `log` seams (gate construction and gate
138
+ // execution), so nothing in the chain can route around the artifact.
139
+ const gateLog = createGateLogSink({ storyId, cwd });
125
140
  const validation = await runCloseValidation({
126
141
  cwd,
127
142
  worktreePath,
@@ -129,9 +144,9 @@ export async function runCloseValidationPhase({
129
144
  config,
130
145
  baseBranch,
131
146
  cwd: worktreePath || cwd,
132
- log: (m) => Logger.info(m),
147
+ log: gateLog.log,
133
148
  }),
134
- log: (m) => Logger.info(m),
149
+ log: gateLog.log,
135
150
  storyId,
136
151
  // Story #4250 — standalone storyId-anchored evidence keyspace. No
137
152
  // epicId; the standalone flag routes the cache to
@@ -141,10 +156,13 @@ export async function runCloseValidationPhase({
141
156
  if (!validation.ok) {
142
157
  const [first] = validation.failed;
143
158
  const { gate, status, cwd: gateCwd } = first;
159
+ // The evidence is the point on this path: replay the captured tail inline
160
+ // rather than making the caller open a file to learn why close stopped.
161
+ gateLog.replay();
144
162
  throw new Error(
145
163
  `[single-story-close] Gate failed: ${gate.name} (exit ${status})${gateCwd ? ` in ${gateCwd}` : ''}.` +
146
164
  (gate.hint ? ` ${gate.hint}` : ''),
147
165
  );
148
166
  }
149
- progress('VALIDATE', '✅ All gates passed.');
167
+ progress('VALIDATE', `✅ All gates passed. ${gateLog.digest()}`);
150
168
  }
@@ -0,0 +1,78 @@
1
+ /**
2
+ * lib/orchestration/spec-budget.js — the soft `## Spec` word-budget pass
3
+ * (Story #4723), extracted from `ticket-validator.js` so the advisory
4
+ * length nudge lives beside neither the hard validators nor their error
5
+ * channel: everything here is `'soft'` by construction and can never fail
6
+ * a persist.
7
+ */
8
+
9
+ import { parse as parseStoryBody } from '../story-body/story-body.js';
10
+
11
+ /**
12
+ * Soft advisory word budget for a Story's inline `## Spec` (Story #4723).
13
+ * ~250 words is the #4707 contract-level-prose target: interfaces,
14
+ * invariants, and load-bearing constraints — not route-by-route behavior
15
+ * narration. Distinct from the hard ~1500-token fail-closed ceiling in
16
+ * `spec-spill.js`: this budget only warns; it never fails the persist.
17
+ */
18
+ export const SPEC_SOFT_WORD_BUDGET = 250;
19
+
20
+ /**
21
+ * Resolve a Story's Spec prose across both authoring shapes: the canonical
22
+ * serialized string body (parsed; `## Spec` text block) and the
23
+ * pre-serialize structured object body (`body.spec`). Returns `''` when
24
+ * absent — or when a string body does not parse: this pass is advisory, so
25
+ * an unreadable body contributes no finding here and is left to the hard
26
+ * parse gate (`assertStoryBodiesParse`) to reject.
27
+ *
28
+ * @param {object} story
29
+ * @returns {string}
30
+ */
31
+ function resolveSpecText(story) {
32
+ const body = story?.body;
33
+ if (typeof body === 'string' && body.trim().length > 0) {
34
+ let spec;
35
+ try {
36
+ spec = parseStoryBody(body).body.spec;
37
+ } catch {
38
+ return '';
39
+ }
40
+ return typeof spec === 'string' ? spec : '';
41
+ }
42
+ if (body !== null && typeof body === 'object') {
43
+ return typeof body?.spec === 'string' ? body.spec : '';
44
+ }
45
+ return '';
46
+ }
47
+
48
+ /**
49
+ * Advisory `## Spec` length pass (Story #4723). Emits one `'soft'` finding
50
+ * per Story whose Spec prose exceeds {@link SPEC_SOFT_WORD_BUDGET} words,
51
+ * nudging the author toward contract-level prose (#4707). Soft only — the
52
+ * findings never reach the validator's `errors[]` channel, so an
53
+ * over-budget Spec never fails the persist.
54
+ *
55
+ * @param {{ stories: object[] }} opts
56
+ * @returns {object[]} Zero or more `spec-word-budget` findings.
57
+ */
58
+ export function computeSpecBudgetFindings({ stories }) {
59
+ const findings = [];
60
+ for (const story of stories ?? []) {
61
+ const words = resolveSpecText(story).split(/\s+/).filter(Boolean).length;
62
+ if (words <= SPEC_SOFT_WORD_BUDGET) continue;
63
+ findings.push({
64
+ kind: 'spec-word-budget',
65
+ severity: 'soft',
66
+ ticketSlug: story.slug ?? '<unknown>',
67
+ words,
68
+ budget: SPEC_SOFT_WORD_BUDGET,
69
+ message:
70
+ `Story "${story.slug ?? '<unknown>'}" ## Spec is ~${words} words ` +
71
+ `(soft budget ${SPEC_SOFT_WORD_BUDGET}). Prefer contract-level prose ` +
72
+ '(interfaces, invariants, load-bearing constraints with their why) ' +
73
+ 'over per-file behavior narration — advisory only; the persist ' +
74
+ 'proceeds.',
75
+ });
76
+ }
77
+ return findings;
78
+ }
@@ -0,0 +1,72 @@
1
+ /**
2
+ * lib/orchestration/story-body-gate.js — the Story-body parse gate
3
+ * (Story #4541), extracted from `ticket-validator.js`: the one place a
4
+ * serialized Story body is parsed with parse failures translated into the
5
+ * validator's operator-legible `ValidationError` shape. Both the gate that
6
+ * refuses a plan up front (`assertStoryBodiesParse`) and the per-call
7
+ * translating parser (`parseStoryBodyOrThrow`) the downstream validators
8
+ * lean on live here.
9
+ */
10
+
11
+ import { ValidationError } from '../errors/index.js';
12
+ import {
13
+ parse as parseStoryBody,
14
+ StoryBodyParseError,
15
+ } from '../story-body/story-body.js';
16
+
17
+ /**
18
+ * Parse a Story's serialized markdown body, translating a
19
+ * `StoryBodyParseError` into a `ValidationError` that names the offending
20
+ * **section** and **entry** (Story #4541).
21
+ *
22
+ * `StoryBodyParseError` already carries `field` (the section the parser was
23
+ * reading) and `raw` (the entry text that failed); this lifts both into an
24
+ * operator-legible message and a structured `violation` payload so an
25
+ * authoring loop can point at the exact bullet instead of re-deriving it
26
+ * from a downstream freshness miss.
27
+ *
28
+ * @param {object} story Story whose `body` is a non-empty markdown string.
29
+ * @returns {object} The structured body.
30
+ * @throws {ValidationError} `code: 'story-body-unparseable'`.
31
+ */
32
+ export function parseStoryBodyOrThrow(story) {
33
+ try {
34
+ return parseStoryBody(story.body).body;
35
+ } catch (err) {
36
+ if (!(err instanceof StoryBodyParseError)) throw err;
37
+ const slug = story.slug ?? '<unknown>';
38
+ const section = err.field ?? 'body';
39
+ const entry = err.raw ?? null;
40
+ const entryLine = entry === null ? '' : `\n entry: ${entry}`;
41
+ const violation = { slug, section, entry, reason: err.message };
42
+ const error = new ValidationError(
43
+ `Cross-Validation Failed: Story "${slug}" has an unparseable body — ` +
44
+ `the ## ${section} section could not be read: ${err.message}` +
45
+ `${entryLine}\n\nFix the offending entry; this is a malformed body, ` +
46
+ 'not a stale path reference.',
47
+ { violations: [violation] },
48
+ );
49
+ error.code = 'story-body-unparseable';
50
+ error.violations = [violation];
51
+ throw error;
52
+ }
53
+ }
54
+
55
+ /**
56
+ * Refuse the plan when any Story's serialized body cannot be parsed, before
57
+ * either git-probe gate runs (Story #4541). Ordering matters: the freshness
58
+ * gate consults `body.changes` for its net-new whitelist, so an unparseable
59
+ * body used to reach the operator as a freshness miss naming declared paths.
60
+ *
61
+ * @param {{ tickets: object[] }} opts
62
+ * @throws {ValidationError} `code: 'story-body-unparseable'` on the first
63
+ * offending Story.
64
+ */
65
+ export function assertStoryBodiesParse({ tickets }) {
66
+ for (const story of (tickets ?? []).filter((t) => t.type === 'story')) {
67
+ if (typeof story.body !== 'string' || story.body.trim().length === 0) {
68
+ continue;
69
+ }
70
+ parseStoryBodyOrThrow(story);
71
+ }
72
+ }
@@ -829,6 +829,12 @@ export function renderHardConflictError(finding) {
829
829
  if (finding.kind === 'missing-bdd-scaffold') {
830
830
  return `Missing BDD scaffold: Story "${finding.consumer.storySlug}" verifies against "${finding.path}" (created by Story "${finding.producer.storySlug}") via body.${finding.consumer.sourceField}, but "${finding.consumer.storySlug}" has no depends_on path to "${finding.producer.storySlug}" — the .feature file is scaffolded in the same wave (or later), so verification runs before the file exists. Add depends_on: ["${finding.producer.storySlug}"] to the consumer Story so the scaffold lands in an earlier wave.`;
831
831
  }
832
+ // Findings from other passes (sizing, spec-word-budget) carry their own
833
+ // message — render it rather than a shape-blind generic line, so the soft
834
+ // surface (`surfaceSoftConflictFindings`) stays legible for every kind.
835
+ if (typeof finding.message === 'string' && finding.message.length > 0) {
836
+ return finding.message;
837
+ }
832
838
  return `Conflict finding ${finding.kind} on path "${finding.path ?? '<unknown>'}".`;
833
839
  }
834
840
 
@@ -3,11 +3,12 @@ import { detectCycle } from '../Graph.js';
3
3
  import { gitSpawn } from '../git-utils.js';
4
4
 
5
5
  import { Logger } from '../Logger.js';
6
- import {
7
- parse as parseStoryBody,
8
- StoryBodyParseError,
9
- } from '../story-body/story-body.js';
10
6
  import { validateStoryFileAssumptions } from './file-assumptions.js';
7
+ import { computeSpecBudgetFindings } from './spec-budget.js';
8
+ import {
9
+ assertStoryBodiesParse,
10
+ parseStoryBodyOrThrow,
11
+ } from './story-body-gate.js';
11
12
  import {
12
13
  computeConflictFindings,
13
14
  renderHardConflictError,
@@ -46,63 +47,6 @@ function collectPathsFromText(text, paths) {
46
47
  }
47
48
  }
48
49
 
49
- /**
50
- * Parse a Story's serialized markdown body, translating a
51
- * `StoryBodyParseError` into a `ValidationError` that names the offending
52
- * **section** and **entry** (Story #4541).
53
- *
54
- * `StoryBodyParseError` already carries `field` (the section the parser was
55
- * reading) and `raw` (the entry text that failed); this lifts both into an
56
- * operator-legible message and a structured `violation` payload so an
57
- * authoring loop can point at the exact bullet instead of re-deriving it
58
- * from a downstream freshness miss.
59
- *
60
- * @param {object} story Story whose `body` is a non-empty markdown string.
61
- * @returns {object} The structured body.
62
- * @throws {ValidationError} `code: 'story-body-unparseable'`.
63
- */
64
- function parseStoryBodyOrThrow(story) {
65
- try {
66
- return parseStoryBody(story.body).body;
67
- } catch (err) {
68
- if (!(err instanceof StoryBodyParseError)) throw err;
69
- const slug = story.slug ?? '<unknown>';
70
- const section = err.field ?? 'body';
71
- const entry = err.raw ?? null;
72
- const entryLine = entry === null ? '' : `\n entry: ${entry}`;
73
- const violation = { slug, section, entry, reason: err.message };
74
- const error = new ValidationError(
75
- `Cross-Validation Failed: Story "${slug}" has an unparseable body — ` +
76
- `the ## ${section} section could not be read: ${err.message}` +
77
- `${entryLine}\n\nFix the offending entry; this is a malformed body, ` +
78
- 'not a stale path reference.',
79
- { violations: [violation] },
80
- );
81
- error.code = 'story-body-unparseable';
82
- error.violations = [violation];
83
- throw error;
84
- }
85
- }
86
-
87
- /**
88
- * Refuse the plan when any Story's serialized body cannot be parsed, before
89
- * either git-probe gate runs (Story #4541). Ordering matters: the freshness
90
- * gate consults `body.changes` for its net-new whitelist, so an unparseable
91
- * body used to reach the operator as a freshness miss naming declared paths.
92
- *
93
- * @param {{ tickets: object[] }} opts
94
- * @throws {ValidationError} `code: 'story-body-unparseable'` on the first
95
- * offending Story.
96
- */
97
- function assertStoryBodiesParse({ tickets }) {
98
- for (const story of (tickets ?? []).filter((t) => t.type === 'story')) {
99
- if (typeof story.body !== 'string' || story.body.trim().length === 0) {
100
- continue;
101
- }
102
- parseStoryBodyOrThrow(story);
103
- }
104
- }
105
-
106
50
  /**
107
51
  * Resolve every acceptance line a Story declares, across both authoring
108
52
  * shapes (Story #4541).
@@ -721,7 +665,19 @@ export function validateAndNormalizeTickets(tickets, opts = {}) {
721
665
  stories,
722
666
  policy: opts.conflictPolicy,
723
667
  });
724
- const findings = [...sizingFindings, ...conflictFindings];
668
+ // Advisory `## Spec` word-budget pass (Story #4723) — soft findings only,
669
+ // surfaced as warnings here and via the persist soft-finding channel;
670
+ // never promoted to `errors[]`, so an over-budget Spec cannot fail the
671
+ // persist. Runs after `assertStoryBodiesParse`, so string bodies parse.
672
+ const specBudgetFindings = computeSpecBudgetFindings({ stories });
673
+ for (const finding of specBudgetFindings) {
674
+ Logger.warn(`[ticket-validator] spec-word-budget: ${finding.message}`);
675
+ }
676
+ const findings = [
677
+ ...sizingFindings,
678
+ ...conflictFindings,
679
+ ...specBudgetFindings,
680
+ ];
725
681
  const CONFLICT_KINDS = new Set([
726
682
  'shared-editor',
727
683
  'implicit-cross-story-dep',
@@ -124,7 +124,7 @@ export async function emitPlanContext({
124
124
  // later turn. When it is captured to disk anyway, stdout carries a
125
125
  // compact digest naming the artifact instead of the payload itself.
126
126
  await writeEnvelopeFile(outPath, json);
127
- await writeStoriesTemplateFile(outPath);
127
+ await writeStoriesTemplateFile(outPath, envelope);
128
128
  const resolved = path.resolve(outPath);
129
129
  const digest = {
130
130
  digest: 'plan-context',
@@ -137,7 +137,16 @@ export async function emitPlanContext({
137
137
  bytes: Buffer.byteLength(json, 'utf8'),
138
138
  sourceTickets: (envelope.sourceTickets ?? []).map((t) => t.id),
139
139
  duplicates: (envelope.duplicates ?? []).length,
140
- complexityRoute: envelope.complexityRoute?.route ?? null,
140
+ // Advisory only (Story #4722): signals, no route the planner owns
141
+ // the trivial-vs-standard verdict and persist validates it by shape.
142
+ complexitySignals: envelope.complexitySignals
143
+ ? {
144
+ artifactCount: envelope.complexitySignals.artifactCount,
145
+ riskHeuristicHits: envelope.complexitySignals.riskHeuristicHits,
146
+ sensitivePathClasses:
147
+ envelope.complexitySignals.sensitivePathClasses,
148
+ }
149
+ : null,
141
150
  };
142
151
  stdout.write(`${JSON.stringify(digest)}\n`);
143
152
  } else {
@@ -177,18 +186,27 @@ async function writeEnvelopeFile(outPath, json) {
177
186
  * requires reading `story-body.js` source. Written whenever `--out` is
178
187
  * passed, and throwing on failure for the same reason the envelope write
179
188
  * does: a silently missing template re-opens the format-discovery loop it
180
- * exists to close.
189
+ * exists to close. The envelope's advisory `complexitySignals` are threaded
190
+ * through so the skeleton's `changes[]` arrive pre-resolved to
191
+ * creates-vs-refactors against the repo snapshot (Story #4723).
181
192
  *
182
193
  * @param {string} outPath The envelope `--out` path; the template lands in
183
194
  * the same directory as {@link STORIES_TEMPLATE_FILENAME}.
195
+ * @param {object} [envelope] The emitted plan-context envelope.
184
196
  */
185
- async function writeStoriesTemplateFile(outPath) {
197
+ async function writeStoriesTemplateFile(outPath, envelope = {}) {
186
198
  const resolved = path.resolve(
187
199
  path.dirname(path.resolve(outPath)),
188
200
  STORIES_TEMPLATE_FILENAME,
189
201
  );
190
202
  try {
191
- await writeFile(resolved, renderStoriesTemplate(), 'utf8');
203
+ await writeFile(
204
+ resolved,
205
+ renderStoriesTemplate({
206
+ complexitySignals: envelope?.complexitySignals ?? null,
207
+ }),
208
+ 'utf8',
209
+ );
192
210
  } catch (err) {
193
211
  throw new Error(
194
212
  `[plan-context] cannot write stories template to ${resolved}: ${err.message}`,
@@ -209,6 +209,7 @@ async function main() {
209
209
  stories,
210
210
  nativeEdges,
211
211
  warn: (m) => Logger.warn(m),
212
+ config,
212
213
  });
213
214
  const foreignDone = await resolveForeignDone({
214
215
  provider,
@@ -220,6 +221,7 @@ async function main() {
220
221
  nativeEdges,
221
222
  foreignDone,
222
223
  warn: () => {},
224
+ config,
223
225
  });
224
226
 
225
227
  process.stdout.write(