mandrel 2.58.0 → 2.60.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 (124) hide show
  1. package/.agents/README.md +17 -12
  2. package/.agents/agents/acceptance-critic.md +24 -43
  3. package/.agents/agents/story-worker.md +18 -19
  4. package/.agents/docs/SDLC.md +12 -13
  5. package/.agents/docs/agentrc-reference.json +1 -2
  6. package/.agents/docs/configuration.md +29 -46
  7. package/.agents/docs/quality-gates.md +9 -5
  8. package/.agents/docs/workflows.md +1 -1
  9. package/.agents/instructions.md +5 -7
  10. package/.agents/rules/ci-remediation.md +41 -8
  11. package/.agents/rules/known-tooling-behavior.md +65 -15
  12. package/.agents/runtime-deps.json +7 -2
  13. package/.agents/schemas/acceptance-eval-verdict.schema.json +1 -1
  14. package/.agents/schemas/agentrc.schema.json +6 -11
  15. package/.agents/schemas/crap-baseline.schema.json +1 -1
  16. package/.agents/schemas/crap-report.schema.json +1 -1
  17. package/.agents/schemas/story-deliver-terminal.schema.json +3 -3
  18. package/.agents/scripts/README.md +11 -1
  19. package/.agents/scripts/acceptance-eval.js +25 -27
  20. package/.agents/scripts/ceremony-derive.js +15 -10
  21. package/.agents/scripts/check-context-budget.js +148 -228
  22. package/.agents/scripts/check-schema-references.js +5 -3
  23. package/.agents/scripts/check-workflow-citations.js +33 -147
  24. package/.agents/scripts/coverage-capture.js +7 -4
  25. package/.agents/scripts/deliver-light.js +41 -100
  26. package/.agents/scripts/deliver-run.js +631 -0
  27. package/.agents/scripts/file-ci-gap.js +59 -11
  28. package/.agents/scripts/install-matrix-assert.js +48 -3
  29. package/.agents/scripts/lib/audit-to-stories/seed-from-findings.js +51 -33
  30. package/.agents/scripts/lib/baselines/crap-preview-incremental.js +6 -2
  31. package/.agents/scripts/lib/baselines/kinds/_crap-read.js +0 -8
  32. package/.agents/scripts/lib/baselines/kinds/crap.js +35 -18
  33. package/.agents/scripts/lib/changed-files.js +30 -0
  34. package/.agents/scripts/lib/config/delivery-routing.js +5 -4
  35. package/.agents/scripts/lib/config/explain.js +1 -3
  36. package/.agents/scripts/lib/config/gates/crap-incremental-coverage.schema.js +1 -1
  37. package/.agents/scripts/lib/config-resolver.js +1 -0
  38. package/.agents/scripts/lib/config-settings-schema-delivery.js +28 -21
  39. package/.agents/scripts/lib/coverage-capture-fullscope.js +10 -2
  40. package/.agents/scripts/lib/coverage-capture-incremental.js +3 -2
  41. package/.agents/scripts/lib/coverage-capture-usage.js +4 -1
  42. package/.agents/scripts/lib/crap-engine.js +2 -2
  43. package/.agents/scripts/lib/crap-utils.js +21 -5
  44. package/.agents/scripts/lib/doc-tiers.js +4 -2
  45. package/.agents/scripts/lib/escomplex-ast-compat.js +39 -17
  46. package/.agents/scripts/lib/escomplex-kernel.js +298 -0
  47. package/.agents/scripts/lib/feedback-loop/graduator-core.js +7 -6
  48. package/.agents/scripts/lib/feedback-loop/retro-proposals-graduator.js +7 -5
  49. package/.agents/scripts/lib/generated/agentrc-validator.js +1 -1
  50. package/.agents/scripts/lib/gh-exec.js +160 -0
  51. package/.agents/scripts/lib/maintainability-engine.js +3 -3
  52. package/.agents/scripts/lib/observability/source-classifier.js +1 -0
  53. package/.agents/scripts/lib/orchestration/ceremony-routing.js +74 -132
  54. package/.agents/scripts/lib/orchestration/ci-rerun-guard.js +123 -12
  55. package/.agents/scripts/lib/orchestration/complexity-gate.js +180 -352
  56. package/.agents/scripts/lib/orchestration/light-suitability.js +71 -136
  57. package/.agents/scripts/lib/orchestration/plan-context.js +44 -50
  58. package/.agents/scripts/lib/orchestration/plan-persist/changes-repair.js +8 -6
  59. package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +104 -119
  60. package/.agents/scripts/lib/orchestration/plan-persist/story-ops.js +41 -25
  61. package/.agents/scripts/lib/orchestration/plan-persist/summary.js +11 -11
  62. package/.agents/scripts/lib/orchestration/plan-persist/supersede-ops.js +63 -29
  63. package/.agents/scripts/lib/orchestration/plan-persist/wave-collision-gate.js +107 -0
  64. package/.agents/scripts/lib/orchestration/review-depth.js +14 -11
  65. package/.agents/scripts/lib/orchestration/run-epilogue.js +260 -182
  66. package/.agents/scripts/lib/orchestration/run-scoped-config.js +63 -99
  67. package/.agents/scripts/lib/orchestration/single-story-close/phases/base-sync.js +3 -3
  68. package/.agents/scripts/lib/orchestration/single-story-close/phases/graphql-preflight.js +137 -0
  69. package/.agents/scripts/lib/orchestration/single-story-close/runner.js +105 -18
  70. package/.agents/scripts/lib/orchestration/story-deliver-terminal.js +4 -3
  71. package/.agents/scripts/lib/orchestration/story-follow-ups.js +156 -39
  72. package/.agents/scripts/lib/orchestration/story-init-envelope.js +71 -0
  73. package/.agents/scripts/lib/orchestration/task-body-validator.js +8 -17
  74. package/.agents/scripts/lib/orchestration/ticket-validator-conflicts.js +25 -209
  75. package/.agents/scripts/lib/orchestration/ticket-validator-sizing.js +8 -5
  76. package/.agents/scripts/lib/orchestration/ticket-validator.js +44 -183
  77. package/.agents/scripts/lib/orchestration/ticketing/reads.js +14 -25
  78. package/.agents/scripts/lib/runtime-deps/dep-resolution.js +155 -0
  79. package/.agents/scripts/lib/runtime-deps/ensure-installed.js +44 -9
  80. package/.agents/scripts/lib/runtime-deps/parser-major.js +110 -0
  81. package/.agents/scripts/lib/runtime-deps/preflight.js +6 -25
  82. package/.agents/scripts/lib/runtime-deps/scan-imports.js +46 -1
  83. package/.agents/scripts/lib/skills/walk-skill-files.js +1 -1
  84. package/.agents/scripts/lib/story-body/body-format-lints.js +58 -12
  85. package/.agents/scripts/lib/story-body/story-body.js +83 -29
  86. package/.agents/scripts/lib/templates/decomposer-prompts.js +28 -33
  87. package/.agents/scripts/lib/wave-runner/live-probe.js +31 -5
  88. package/.agents/scripts/merge-baseline.js +4 -5
  89. package/.agents/scripts/plan-context.js +117 -28
  90. package/.agents/scripts/plan-persist.js +79 -39
  91. package/.agents/scripts/plan-run-epilogue.js +11 -8
  92. package/.agents/scripts/pr-watch-with-update.js +9 -2
  93. package/.agents/scripts/run-verify.js +13 -6
  94. package/.agents/scripts/single-story-init.js +7 -57
  95. package/.agents/scripts/stories-wave-tick.js +160 -26
  96. package/.agents/skills/core/gates-and-baselines/reference.md +0 -1
  97. package/.agents/skills/skills.index.json +2 -12
  98. package/.agents/skills/stack/qa/playwright/SKILL.md +26 -0
  99. package/.agents/workflows/audit-to-stories.md +14 -11
  100. package/.agents/workflows/helpers/acceptance-self-eval.md +84 -157
  101. package/.agents/workflows/helpers/code-review.md +4 -2
  102. package/.agents/workflows/helpers/deliver-digest.md +31 -24
  103. package/.agents/workflows/helpers/deliver-light.md +92 -101
  104. package/.agents/workflows/helpers/deliver-reference.md +116 -100
  105. package/.agents/workflows/helpers/deliver-story-reference.md +58 -124
  106. package/.agents/workflows/helpers/deliver-story.md +17 -18
  107. package/.agents/workflows/helpers/plan-reference.md +82 -60
  108. package/.agents/workflows/mandrel-deliver.md +47 -31
  109. package/.agents/workflows/mandrel-plan.md +32 -30
  110. package/.agents/workflows/mandrel-update.md +36 -21
  111. package/README.md +3 -3
  112. package/docs/CHANGELOG.md +43 -0
  113. package/lib/cli/registry.js +45 -25
  114. package/lib/cli/update.js +376 -17
  115. package/lib/migrations/index.js +2 -0
  116. package/lib/migrations/steps/2.60.0-retire-audit-results-autofile.js +40 -0
  117. package/package.json +8 -2
  118. package/.agents/schemas/model-attribution.schema.json +0 -53
  119. package/.agents/scripts/lib/orchestration/model-attribution.js +0 -418
  120. package/.agents/scripts/lib/orchestration/split-policy-validator.js +0 -188
  121. package/.agents/scripts/lib/orchestration/story-plan-state.js +0 -33
  122. package/.agents/scripts/lib/orchestration/structured-comment-parser.js +0 -67
  123. package/.agents/scripts/lib/templates/spec-author-prompts.js +0 -76
  124. package/.agents/skills/core/scope-triage/SKILL.md +0 -48
@@ -101,22 +101,37 @@ import { classifyStory, storyIdOf } from './ready-set.js';
101
101
  * Ids outside the probed set are ignored: they are not part of this run and
102
102
  * must not consume its cap.
103
103
  *
104
+ * The second arm is also reported on its own, as `unlabelled`. A claimed id
105
+ * that still reads `agent::ready` is in flight either because its init is
106
+ * still running or because the spawn that claimed it never started one, and
107
+ * those two are indistinguishable here — but only the second pins the id in
108
+ * the caller's ledger forever. Live state cannot tell them apart; the operator
109
+ * can, so the fact is surfaced rather than acted on (Story #5363).
110
+ *
104
111
  * @param {Array<{id?: number, number?: number, labels?: string[], state?: string}>} storyRecords
105
112
  * @param {Iterable<number>} [dispatched] Ids the host has spawned.
106
- * @returns {Set<number>} In-flight Story ids.
113
+ * @returns {{inFlight: Set<number>, unlabelled: Set<number>}} In-flight Story
114
+ * ids, and the subset of them claimed by the caller that live state still
115
+ * reports as `agent::ready`.
107
116
  */
108
117
  function deriveInFlightIds(storyRecords, dispatched = []) {
109
118
  const claimed = new Set(dispatched);
110
- const inFlight = new Set();
119
+ // Bucketed by live class rather than tested twice. The admission rule is
120
+ // unchanged; it is only that the `ready` bucket IS the claimed-but-unlabelled
121
+ // set, since `ready` is the one class the rule admits on the caller's claim.
122
+ const byClass = { executing: new Set(), ready: new Set() };
111
123
  for (const rec of storyRecords) {
112
124
  const id = storyIdOf(rec);
113
125
  if (id === null) continue;
114
126
  const cls = classifyStory(rec);
115
127
  if (cls === 'executing' || (claimed.has(id) && cls === 'ready')) {
116
- inFlight.add(id);
128
+ byClass[cls].add(id);
117
129
  }
118
130
  }
119
- return inFlight;
131
+ return {
132
+ inFlight: new Set([...byClass.executing, ...byClass.ready]),
133
+ unlabelled: byClass.ready,
134
+ };
120
135
  }
121
136
 
122
137
  /**
@@ -251,6 +266,7 @@ export function createProbeContext({
251
266
  * doneIds: Set<number>,
252
267
  * inFlight: number,
253
268
  * blockedIds: number[],
269
+ * stalledDispatch: number[],
254
270
  * foreignHeld: Array<{id: number, holder: string}>
255
271
  * }>}
256
272
  */
@@ -294,7 +310,10 @@ export async function probeLiveState({
294
310
  // They are consumed in-process by `planReadySet` and never serialized into
295
311
  // the beat envelope, which stays a list of ids.
296
312
  const bodyById = new Map(stories.map((s) => [s.id, s.body ?? '']));
297
- const inFlightIds = deriveInFlightIds(stories, dispatched);
313
+ const { inFlight: inFlightIds, unlabelled } = deriveInFlightIds(
314
+ stories,
315
+ dispatched,
316
+ );
298
317
  // A Story another operator's lease holds occupies a (global) dispatch slot
299
318
  // just like an in-flight one: fold it into the in-flight set so it is both
300
319
  // withheld (via the projected label) and excluded from a false wedge, but
@@ -322,6 +341,13 @@ export async function probeLiveState({
322
341
  doneIds: new Set(envelope.done),
323
342
  inFlight: inFlightIds.size,
324
343
  blockedIds: deriveBlockedIds(stories),
344
+ // Claimed by the caller, still labelled `agent::ready`: a live init window
345
+ // or a spawn that never reached one. Reported, never released here. A
346
+ // foreign lease is its own withhold reason and outranks the claim, so it
347
+ // is subtracted — one Story must not carry two recoveries.
348
+ stalledDispatch: [...unlabelled]
349
+ .filter((id) => !foreignHeld.has(id))
350
+ .sort((a, b) => a - b),
325
351
  foreignHeld: [...foreignHeld].map(([id, holder]) => ({ id, holder })),
326
352
  };
327
353
  }
@@ -36,9 +36,8 @@
36
36
  *
37
37
  * ## Not every `baselines/*.json` is an envelope
38
38
  *
39
- * That glob also matches arch-cycles, cyclomatic, dead-exports, audit-ledger,
40
- * context-budget and workflow-citations — files with their own shapes and no
41
- * row identity. Anything whose `$schema` is not a known per-kind envelope is
39
+ * That glob also matches arch-cycles, cyclomatic, dead-exports, audit-ledger
40
+ * and context-budget — files with their own shapes and no row identity. Anything whose `$schema` is not a known per-kind envelope is
42
41
  * handed straight back to `git merge-file`, so registering the driver cannot
43
42
  * change their behaviour.
44
43
  */
@@ -164,8 +163,8 @@ function delegateToGit(basePath, oursPath, theirsPath) {
164
163
  * `git merge-file` even though `.gitattributes` routes them here, so the
165
164
  * attribute promised a row merge the driver never performed.
166
165
  *
167
- * Anything else — arch-cycles, audit-ledger, context-budget,
168
- * workflow-citations — resolves `null` and is handed back to git unchanged.
166
+ * Anything else — arch-cycles, audit-ledger, context-budget — resolves `null`
167
+ * and is handed back to git unchanged.
169
168
  *
170
169
  * @param {unknown} ours
171
170
  * @param {unknown} theirs
@@ -24,21 +24,22 @@
24
24
  * scratch (Story #4741). Envelope carries `amends`.
25
25
  *
26
26
  * Flags:
27
- * --out <path> Write the envelope to <path> (parent dirs created).
28
- * `/mandrel-plan` points this at `<plan-dir>/plan-context.json`,
29
- * which is where `plan-persist.js` auto-discovers the
30
- * `--tickets` source ids from (Story #4554). Without a
31
- * captured envelope persist cannot know a `--tickets` run
32
- * happened, and superseding degrades to the
33
- * `--source-tickets` flag. With --out, stdout carries a
34
- * compact digest naming the artifact instead of the full
35
- * envelope (Story #4708 script-output contract).
36
- * --pretty Pretty-print the JSON envelope (no-op with --out).
27
+ * --out <path> Override where the envelope is written (parent dirs
28
+ * created). **Optional since Story #5342** — with no
29
+ * `--out` the envelope lands at
30
+ * `<tempRoot>/plan-<slug>/plan-context.json`, the plan
31
+ * directory `/mandrel-plan` would have named by hand, and
32
+ * `stories.template.json` lands beside it. That is where
33
+ * `plan-persist.js` auto-discovers the `--tickets` source
34
+ * ids from (Story #4554); without a captured envelope
35
+ * persist cannot know a `--tickets` run happened, and
36
+ * superseding degrades to the `--source-tickets` flag.
37
+ * --pretty Pretty-print the written JSON envelope.
37
38
  *
38
39
  * stdout is reserved for a single JSON payload (Story #2278 discipline) —
39
- * the envelope, or the digest when --out captures it:
40
- * `routeAllOutputToStderr()` runs before any pipeline code so the stream
41
- * is unconditionally parseable by `JSON.parse`.
40
+ * the compact digest naming the written artifacts (Story #4708
41
+ * script-output contract): `routeAllOutputToStderr()` runs before any
42
+ * pipeline code so the stream is unconditionally parseable by `JSON.parse`.
42
43
  *
43
44
  * Exit codes:
44
45
  * 0 — envelope emitted.
@@ -54,6 +55,8 @@ import path from 'node:path';
54
55
  import { parseArgs } from 'node:util';
55
56
  import { runAsCli } from './lib/cli-utils.js';
56
57
  import {
58
+ getPaths,
59
+ PROJECT_ROOT,
57
60
  resolveConfig,
58
61
  validateOrchestrationConfig,
59
62
  } from './lib/config-resolver.js';
@@ -66,6 +69,77 @@ import {
66
69
  import { recordPlanInvocation } from './lib/orchestration/plan-metrics.js';
67
70
  import { createProvider } from './lib/provider-factory.js';
68
71
 
72
+ /** Longest slug segment a default plan directory carries. */
73
+ const PLAN_SLUG_MAX_LENGTH = 48;
74
+
75
+ /**
76
+ * Reduce free text to the hyphen-case segment a plan directory is named by.
77
+ *
78
+ * Deliberately lossy: the slug is a human-readable handle on a temp
79
+ * directory, not an identity — two runs from the same seed land in the same
80
+ * directory and the second overwrites the first, which is the idempotent
81
+ * behaviour the operator already got from typing the same `--out` twice.
82
+ *
83
+ * @param {string} raw
84
+ * @returns {string} A non-empty hyphen-case slug (`plan` when nothing survives).
85
+ */
86
+ export function slugifyPlanLabel(raw) {
87
+ const slug = String(raw ?? '')
88
+ .toLowerCase()
89
+ .replace(/[^a-z0-9]+/g, '-')
90
+ .replace(/^-+|-+$/g, '')
91
+ .slice(0, PLAN_SLUG_MAX_LENGTH)
92
+ .replace(/-+$/, '');
93
+ return slug === '' ? 'plan' : slug;
94
+ }
95
+
96
+ /**
97
+ * Resolve where the envelope is written when the operator passed no `--out`
98
+ * (Story #5342).
99
+ *
100
+ * `--out` was mandatory in practice and optional in the CLI: persist
101
+ * auto-discovers the envelope and the `stories.template.json` beside it from
102
+ * the plan directory, so a run without it silently lost superseding and the
103
+ * authoring skeleton. The path it always pointed at is derivable — the
104
+ * configured `tempRoot`, a `plan-<slug>` directory named for what is being
105
+ * planned — so the CLI derives it rather than asking.
106
+ *
107
+ * Exported for tests: this is the join where a missing flag stops costing
108
+ * the plan its source ids.
109
+ *
110
+ * @param {object} args
111
+ * @param {string} args.mode One of `seed` | `seed-file` | `tickets` | `amends`.
112
+ * @param {string} [args.seedText]
113
+ * @param {string} [args.seedFilePath]
114
+ * @param {number[]} [args.ticketIds]
115
+ * @param {number} [args.amendsId]
116
+ * @param {object} [args.config] Resolved config (for `project.paths.tempRoot`).
117
+ * @param {string} [args.cwd]
118
+ * @returns {string} Absolute path to the envelope file.
119
+ */
120
+ export function resolveDefaultOutPath({
121
+ mode,
122
+ seedText,
123
+ seedFilePath,
124
+ ticketIds,
125
+ amendsId,
126
+ config,
127
+ cwd = PROJECT_ROOT,
128
+ }) {
129
+ const byMode = {
130
+ amends: () => `amends-${amendsId}`,
131
+ tickets: () => `tickets-${(ticketIds ?? []).join('-')}`,
132
+ 'seed-file': () => path.parse(String(seedFilePath ?? '')).name,
133
+ };
134
+ const label = (byMode[mode] ?? (() => String(seedText ?? '')))();
135
+ return path.resolve(
136
+ cwd,
137
+ getPaths(config).tempRoot,
138
+ `plan-${slugifyPlanLabel(label)}`,
139
+ 'plan-context.json',
140
+ );
141
+ }
142
+
69
143
  /**
70
144
  * Parse a comma-/space-separated ticket id list into positive integers.
71
145
  *
@@ -109,8 +183,10 @@ export function parseAmendsId(raw) {
109
183
  }
110
184
 
111
185
  /**
112
- * Build the envelope and write it to `stdout` as a single JSON line
113
- * (or pretty-printed with --pretty). Exported for tests.
186
+ * Build the envelope, write it (plus the `stories.template.json` skeleton)
187
+ * to `outPath` — or to the derived default when none was passed
188
+ * (Story #5342) — and print the compact digest on stdout. Exported for
189
+ * tests.
114
190
  *
115
191
  * @param {object} args
116
192
  * @returns {Promise<object>} the emitted envelope.
@@ -145,14 +221,26 @@ export async function emitPlanContext({
145
221
  const json = pretty
146
222
  ? JSON.stringify(envelope, null, 2)
147
223
  : JSON.stringify(envelope);
148
- if (outPath) {
224
+ const resolvedOut =
225
+ outPath ??
226
+ resolveDefaultOutPath({
227
+ mode,
228
+ seedText,
229
+ seedFilePath,
230
+ ticketIds,
231
+ amendsId,
232
+ config,
233
+ cwd: cwd ?? undefined,
234
+ });
235
+ {
149
236
  // Script-output contract (Story #4708, AC-5): the full envelope is a
150
237
  // ~40KB artifact that would ride resident in the transcript for every
151
- // later turn. When it is captured to disk anyway, stdout carries a
152
- // compact digest naming the artifact instead of the payload itself.
153
- await writeEnvelopeFile(outPath, json);
154
- await writeStoriesTemplateFile(outPath, envelope);
155
- const resolved = path.resolve(outPath);
238
+ // later turn. It is always captured to disk (Story #5342 derives the
239
+ // path when `--out` is absent), so stdout carries a compact digest
240
+ // naming the artifacts instead of the payload itself.
241
+ await writeEnvelopeFile(resolvedOut, json);
242
+ await writeStoriesTemplateFile(resolvedOut, envelope);
243
+ const resolved = path.resolve(resolvedOut);
156
244
  const digest = {
157
245
  digest: 'plan-context',
158
246
  mode: envelope.mode,
@@ -182,8 +270,6 @@ export async function emitPlanContext({
182
270
  amends: envelope.amends ? { id: envelope.amends.id } : null,
183
271
  };
184
272
  stdout.write(`${JSON.stringify(digest)}\n`);
185
- } else {
186
- stdout.write(`${json}\n`);
187
273
  }
188
274
  return envelope;
189
275
  }
@@ -216,8 +302,8 @@ async function writeEnvelopeFile(outPath, json) {
216
302
  * Emit the ready-to-fill Story authoring template next to the captured
217
303
  * envelope (Story #4707 — one-shot authoring). The planner copies it to
218
304
  * `stories.json` and fills the placeholders; no step of the authoring path
219
- * requires reading `story-body.js` source. Written whenever `--out` is
220
- * passed, and throwing on failure for the same reason the envelope write
305
+ * requires reading `story-body.js` source. Written on every run (Story
306
+ * #5342), and throwing on failure for the same reason the envelope write
221
307
  * does: a silently missing template re-opens the format-discovery loop it
222
308
  * exists to close. The envelope's advisory `complexitySignals` are threaded
223
309
  * through so the skeleton's `changes[]` arrive pre-resolved to
@@ -346,14 +432,17 @@ runAsCli(import.meta.url, main, {
346
432
  invocation:
347
433
  'node .agents/scripts/plan-context.js (--seed "<text>" | --seed-file <path> | --tickets <ids> | --amends <id>) [--out <path>] [--pretty]',
348
434
  summary:
349
- 'Build the /mandrel-plan authoring-context envelope on stdout. Exactly one entry form must be supplied.',
435
+ 'Build the /mandrel-plan authoring-context envelope. Writes it (and stories.template.json) under <tempRoot>/plan-<slug>/ and prints the digest on stdout. Exactly one entry form must be supplied.',
350
436
  flags: [
351
437
  ['--seed "<text>"', 'Inline seed prose.'],
352
438
  ['--seed-file <path>', 'Seed document to read.'],
353
439
  ['--tickets <ids>', 'Comma-separated existing ticket ids to re-plan.'],
354
440
  ['--amends <id>', 'Amend the Spec of an existing Story.'],
355
- ['--out <path>', 'Write the envelope to a file instead of stdout.'],
356
- ['--pretty', 'Pretty-print the JSON envelope.'],
441
+ [
442
+ '--out <path>',
443
+ 'Override the derived <tempRoot>/plan-<slug>/plan-context.json path.',
444
+ ],
445
+ ['--pretty', 'Pretty-print the written JSON envelope.'],
357
446
  ],
358
447
  },
359
448
  });
@@ -10,8 +10,8 @@
10
10
  * changes[] repair → ticket validator / DAG → reachability →
11
11
  * split-policy partition → fold Spec into each Story body →
12
12
  * createIssue(s) with type::story, resumably by plan fingerprint (NOT
13
- * agent::ready) → story-plan-state on every Story;
14
- * plan-summary on the primary → flip every Story to agent::ready →
13
+ * agent::ready) → one `story-plan-state` comment (the plan summary) on
14
+ * every Story → flip every Story to agent::ready →
15
15
  * comment + close superseded source tickets → temp cleanup + stale reap.
16
16
  *
17
17
  * Story #4542 retired the authored risk verdict: persist neither requires nor
@@ -29,7 +29,6 @@
29
29
  * --plan-context <file> Optional explicit path to the `plan-context.js`
30
30
  * envelope. Its `sourceTickets[]` is what makes
31
31
  * `--tickets` superseding work without a flag
32
- * --plan-acceptance <file> Optional JSON string[] for partition coverage
33
32
  * --source-tickets <ids> Explicit OVERRIDE of the envelope-derived source
34
33
  * ids, for hand-driven runs. Each id must be
35
34
  * claimed by exactly one Story's `supersedes[]`;
@@ -37,20 +36,16 @@
37
36
  * --no-close-superseded Keep the source tickets open (no comment, no
38
37
  * close) — for a genuinely partial supersede
39
38
  * --dry-run Assemble + validate without GitHub writes
40
- * --chain-on-clean Fast path (Story #4741; any plan since Story
41
- * #5312): run the write-free dry-run first, and
42
- * when it passes clean chain straight into the
43
- * real persist in the SAME invocation, collapsing
44
- * the two operator round-trips into one. A
45
- * dry-run failure stops before any createIssue.
46
- * Ignored when `--dry-run` is also set
47
39
  * --force-review Operator-forced review stop before persist lands
48
40
  *
49
- * Run `--dry-run` first. It exercises every gate — the changes[] repair, the
50
- * validator, DAG, reachability, split/supersede partition, Spec fold —
51
- * write-free, and lists every warning (a footprint probe that disagrees with
52
- * the base branch, an open question in a body) so an authoring mistake
53
- * surfaces before a single issue exists.
41
+ * **Persist is one command (Story #5342).** Without `--dry-run` the CLI runs
42
+ * the write-free dry-run first — the
43
+ * changes[] repair, the validator, DAG, reachability, split/supersede
44
+ * partition, Spec fold — and, when the gate list comes back clean, chains
45
+ * straight into the real persist in the SAME invocation. A dry-run failure
46
+ * stops before any `createIssue`, and the run lists every warning (a
47
+ * footprint probe that disagrees with the base branch, an empty `verify[]`,
48
+ * an open question in a body) either way. `--dry-run` still creates nothing.
54
49
  *
55
50
  * stdout is reserved for the JSON result (Story #2278 discipline, extended to
56
51
  * this CLI by Story #4541): `routeAllOutputToStderr()` runs before any
@@ -83,12 +78,11 @@ import {
83
78
  } from './lib/orchestration/plan-persist/plan-context-source.js';
84
79
  import {
85
80
  runPlanPersist,
86
- writeCheckpointV2,
81
+ writePlanSummaryComment,
87
82
  } from './lib/orchestration/plan-persist/run-plan-persist.js';
88
83
  import {
89
84
  buildPlanSummaryCommentBody,
90
85
  buildWaveTable,
91
- PLAN_SUMMARY_COMMENT_TYPE,
92
86
  } from './lib/orchestration/plan-persist/summary.js';
93
87
  import { resolveSourceTicketIds } from './lib/orchestration/plan-persist/supersede-ops.js';
94
88
  import { createProvider } from './lib/provider-factory.js';
@@ -96,9 +90,8 @@ import { createProvider } from './lib/provider-factory.js';
96
90
  export {
97
91
  buildPlanSummaryCommentBody,
98
92
  buildWaveTable,
99
- PLAN_SUMMARY_COMMENT_TYPE,
100
93
  runPlanPersist,
101
- writeCheckpointV2,
94
+ writePlanSummaryComment,
102
95
  };
103
96
 
104
97
  const CLI_OPTIONS = {
@@ -106,12 +99,10 @@ const CLI_OPTIONS = {
106
99
  'tech-spec': { type: 'string' },
107
100
  'plan-dir': { type: 'string' },
108
101
  'plan-context': { type: 'string' },
109
- 'plan-acceptance': { type: 'string' },
110
102
  'source-tickets': { type: 'string' },
111
103
  'close-superseded': { type: 'boolean', default: true },
112
104
  'no-close-superseded': { type: 'boolean', default: false },
113
105
  'dry-run': { type: 'boolean', default: false },
114
- 'chain-on-clean': { type: 'boolean', default: false },
115
106
  'force-review': { type: 'boolean', default: false },
116
107
  'epic-title': { type: 'string' },
117
108
  'epic-goal': { type: 'string' },
@@ -121,9 +112,8 @@ const CLI_OPTIONS = {
121
112
  const USAGE =
122
113
  'Usage: plan-persist.js --stories <file> ' +
123
114
  '[--tech-spec <file>] [--plan-dir <dir>] [--plan-context <file>] ' +
124
- '[--plan-acceptance <file>] ' +
125
115
  '[--source-tickets <ids>] [--no-close-superseded] ' +
126
- '[--dry-run] [--chain-on-clean] [--force-review] ' +
116
+ '[--dry-run] [--force-review] ' +
127
117
  '[--epic-title <text> --epic-goal <text> | --epic <id>]';
128
118
 
129
119
  async function readOptional(filePath, { required }) {
@@ -159,9 +149,6 @@ export function resolveInputPaths(values) {
159
149
  techSpecPath: values['tech-spec']
160
150
  ? path.resolve(values['tech-spec'])
161
151
  : null,
162
- planAcceptancePath: values['plan-acceptance']
163
- ? path.resolve(values['plan-acceptance'])
164
- : null,
165
152
  planDir,
166
153
  planContextPath: resolvePlanContextPath(values['plan-context'], planDir),
167
154
  };
@@ -172,9 +159,6 @@ async function loadArtifacts(paths) {
172
159
  const techSpecContent = paths.techSpecPath
173
160
  ? await readOptional(paths.techSpecPath, { required: true })
174
161
  : null;
175
- const planAcceptance = paths.planAcceptancePath
176
- ? await readJsonFile(paths.planAcceptancePath, 'plan-acceptance')
177
- : null;
178
162
  const planContextEnvelope = await loadPlanContextEnvelope(
179
163
  paths.planContextPath,
180
164
  );
@@ -182,7 +166,6 @@ async function loadArtifacts(paths) {
182
166
  return {
183
167
  stories,
184
168
  techSpecContent,
185
- planAcceptance,
186
169
  planContextEnvelope,
187
170
  };
188
171
  }
@@ -330,8 +313,9 @@ async function runPersistInvocation({
330
313
  }
331
314
 
332
315
  /**
333
- * Fast path (Story #4741 AC-1/AC-3; widened to any plan by Story #5312):
334
- * chain a clean dry-run into the real persist in ONE operator invocation.
316
+ * The default persist path (Story #4741 AC-1/AC-3; widened to any plan by
317
+ * Story #5312; made the default by Story #5342): chain a clean dry-run into
318
+ * the real persist in ONE operator invocation.
335
319
  *
336
320
  * Two passes over the **same** loaded artifacts:
337
321
  *
@@ -344,6 +328,14 @@ async function runPersistInvocation({
344
328
  * to gate this step went with the plan-side lite claim: a clean dry-run
345
329
  * is the review the chain exists to fold.
346
330
  *
331
+ * The caller reads the **second** pass's envelope, so the first pass's
332
+ * evidence has to be carried onto it (Story #5361). The repair pass mutates
333
+ * the loaded tickets in place, which is what makes replaying the identical
334
+ * artifacts possible at all — and it is also why pass 2 recomputes an empty
335
+ * `repairs[]`: by then there is nothing left to repair. The evidence is
336
+ * preserved rather than re-derived, because re-running the repair pass would
337
+ * report repairs the persisting pass did not make.
338
+ *
347
339
  * Exported for tests — this is where the round-trip collapse lives, so a
348
340
  * regression here silently re-opens the second operator round-trip (or
349
341
  * worse, persists a plan the dry-run never gated).
@@ -376,6 +368,14 @@ export async function runPersistChain({
376
368
  metricsSince,
377
369
  dryRun: false,
378
370
  });
371
+ persistResult.repairs = mergeEvidence(
372
+ dryResult.repairs,
373
+ persistResult.repairs,
374
+ );
375
+ persistResult.warnings = mergeEvidence(
376
+ dryResult.warnings,
377
+ persistResult.warnings,
378
+ );
379
379
  persistResult.chain = {
380
380
  attempted: true,
381
381
  persisted: true,
@@ -384,6 +384,51 @@ export async function runPersistChain({
384
384
  return persistResult;
385
385
  }
386
386
 
387
+ /**
388
+ * Union two evidence lists, dry-run first, dropping an entry the second pass
389
+ * reported identically. Order is the operator's reading order; the dedupe is
390
+ * by rendered content because a repair is a plain record and a warning is a
391
+ * string, so two passes that noticed the same thing noticed it byte-for-byte.
392
+ *
393
+ * @param {unknown} first
394
+ * @param {unknown} second
395
+ * @returns {unknown[]}
396
+ */
397
+ function mergeEvidence(first, second) {
398
+ const merged = [];
399
+ const seen = new Set();
400
+ for (const entry of [
401
+ ...(Array.isArray(first) ? first : []),
402
+ ...(Array.isArray(second) ? second : []),
403
+ ]) {
404
+ const key = JSON.stringify(entry);
405
+ if (seen.has(key)) continue;
406
+ seen.add(key);
407
+ merged.push(entry);
408
+ }
409
+ return merged;
410
+ }
411
+
412
+ /**
413
+ * Decide whether this invocation persists after its gates, or only validates.
414
+ *
415
+ * Story #5342: chaining is the default, not a flag. Every invocation that is
416
+ * not an explicit `--dry-run` runs the gate list and then persists what it
417
+ * passed, so the two operator round-trips collapse without anyone having to
418
+ * remember an opt-in. Story #5361 removed the no-op alias Story #5342 had
419
+ * kept for existing call-sites, rather than accepting and ignoring it: a flag
420
+ * that cannot change an outcome is a shim, and `parseArgs` refuses an unknown
421
+ * option, so passing it now fails loudly instead of reading as honoured.
422
+ *
423
+ * Exported for tests: this one predicate is what makes the CLI one command.
424
+ *
425
+ * @param {object} values Parsed `parseArgs` values.
426
+ * @returns {boolean} `true` to run the gates and then persist.
427
+ */
428
+ export function shouldChainPersist(values) {
429
+ return values?.['dry-run'] !== true;
430
+ }
431
+
387
432
  /**
388
433
  * Attach the plan-metrics roll-up for **this** invocation.
389
434
  *
@@ -450,10 +495,7 @@ async function main() {
450
495
  const paths = resolveInputPaths(values);
451
496
  const artifacts = await loadArtifacts(paths);
452
497
 
453
- // `--chain-on-clean` collapses the dry-run + persist operator round-trips
454
- // (Story #4741). `--dry-run` always wins — an explicit dry-run never writes.
455
- const useChain =
456
- values['chain-on-clean'] === true && values['dry-run'] !== true;
498
+ const useChain = shouldChainPersist(values);
457
499
 
458
500
  let result;
459
501
  try {
@@ -492,7 +534,7 @@ runAsCli(import.meta.url, main, {
492
534
  invocation:
493
535
  'node .agents/scripts/plan-persist.js --stories <file> [--tech-spec <file>] [--dry-run] [options]',
494
536
  summary:
495
- 'Validate an authored plan and persist it as GitHub Stories. Prints the result envelope as JSON on stdout.',
537
+ 'Validate an authored plan and persist it as GitHub Stories in one invocation (pass --dry-run to validate only). Prints the result envelope as JSON on stdout.',
496
538
  flags: [
497
539
  ['--stories <file>', 'Authored stories.json (required).'],
498
540
  ['--tech-spec <file>', 'Optional companion techspec.md.'],
@@ -501,10 +543,8 @@ runAsCli(import.meta.url, main, {
501
543
  '--plan-context <file>',
502
544
  'The plan-context envelope this draft was authored against.',
503
545
  ],
504
- ['--plan-acceptance <file>', 'Acceptance artifact to attach.'],
505
546
  ['--source-tickets <ids>', 'Ticket ids this plan supersedes.'],
506
547
  ['--dry-run', 'Validate and report; create nothing.'],
507
- ['--chain-on-clean', 'Persist immediately when the dry run is clean.'],
508
548
  ['--no-close-superseded', 'Leave superseded source tickets open.'],
509
549
  [
510
550
  '--force-review',
@@ -25,6 +25,8 @@ import { expandIdList } from './lib/util/parse-id-list.js';
25
25
  const CLI_OPTIONS = {
26
26
  stories: { type: 'string' },
27
27
  cwd: { type: 'string' },
28
+ /** Story #5343 — opt-in; see `run-epilogue.js` RUN_EPILOGUE_STEP_KINDS. */
29
+ 'audit-roster': { type: 'boolean', default: false },
28
30
  };
29
31
 
30
32
  /**
@@ -51,15 +53,10 @@ export async function main(argv = process.argv.slice(2), deps = {}) {
51
53
  options: CLI_OPTIONS,
52
54
  strict: false,
53
55
  });
54
- const hasStoriesFlag =
55
- typeof values.stories === 'string' && values.stories.trim().length > 0;
56
- if (!hasStoriesFlag) {
56
+ if (typeof values.stories !== 'string' || !values.stories.trim()) {
57
57
  throw new Error('Usage: node plan-run-epilogue.js --stories 1,2,3');
58
58
  }
59
- const cwd =
60
- typeof values.cwd === 'string' && values.cwd.trim()
61
- ? values.cwd.trim()
62
- : process.cwd();
59
+ const cwd = values.cwd?.trim() || process.cwd();
63
60
  const config = resolveConfigImpl({ cwd });
64
61
  const provider = createProviderImpl(config);
65
62
 
@@ -87,6 +84,7 @@ export async function main(argv = process.argv.slice(2), deps = {}) {
87
84
  provider,
88
85
  config,
89
86
  cwd,
87
+ auditRoster: values['audit-roster'] === true,
90
88
  });
91
89
  warnOnUnresolvedBase(result, logger);
92
90
  warnOnEmptyRollup(result, logger);
@@ -99,6 +97,7 @@ export async function main(argv = process.argv.slice(2), deps = {}) {
99
97
 
100
98
  /**
101
99
  * Surface an unresolvable combined landed diff as a loud operator warning.
100
+ * Only reachable under `--audit-roster`: a default run enumerates no roster.
102
101
  *
103
102
  * The roster's changed-file set is the input the host walks its audit lenses
104
103
  * against; a silent absence would read as "nothing changed" and the lens walk
@@ -167,7 +166,7 @@ function warnOnEmptyRollup(result, logger = Logger) {
167
166
  await runAsCli(import.meta.url, main, {
168
167
  usage: {
169
168
  invocation:
170
- 'node .agents/scripts/plan-run-epilogue.js --stories <id,id,...> [--cwd <path>]',
169
+ 'node .agents/scripts/plan-run-epilogue.js --stories <id,id,...> [--cwd <path>] [--audit-roster]',
171
170
  summary:
172
171
  'Close out a delivery run: roll up the delivered Stories’ signals and report the run’s loop health.',
173
172
  flags: [
@@ -176,6 +175,10 @@ await runAsCli(import.meta.url, main, {
176
175
  'Comma-separated delivered Story ids, singles or A-B ranges (required).',
177
176
  ],
178
177
  ['--cwd <path>', 'Repository root (default: process cwd).'],
178
+ [
179
+ '--audit-roster',
180
+ 'Also select the cross-Story audit lens roster and post plan-run-audit-roster (off by default; the host then walks every listed lens).',
181
+ ],
179
182
  ],
180
183
  },
181
184
  });
@@ -458,12 +458,19 @@ async function evaluateGreenWatch({
458
458
  }
459
459
  const headSha = headShaFn({ prRef, cwd });
460
460
  const { verdict, reason } = classifyGreenVerdict({ digest, headSha });
461
- if (verdict === 'fix-at-source') {
461
+ if (verdict === 'fix-at-source' || verdict === 'rerun-permitted') {
462
+ // Story #5343 — `rerun-permitted` is the one same-SHA green the rule
463
+ // admits: `file-ci-gap.js` recorded a proven `capacity` /
464
+ // `unreproducible-tier` verdict for THIS head, so there is no fix at
465
+ // source to make. Retiring the digest spends the allowance with it, which
466
+ // is what makes it exactly one: a second red writes a fresh digest that
467
+ // carries none.
462
468
  retireDigestFn({ storyId, tempRoot, cwd });
463
469
  const reArm = await reArmFn({ cwd, prNumber });
464
470
  const reArmed = Boolean(reArm?.enabled);
465
471
  logger.info?.(
466
- `[pr-watch] green on a NEW head SHA (${reason}) — fix at source; digest retired, ` +
472
+ `[pr-watch] ${verdict === 'rerun-permitted' ? 'green admitted on the SAME head SHA' : 'green on a NEW head SHA'} ` +
473
+ `(${reason}) — digest retired, ` +
467
474
  `auto-merge ${reArmed ? 're-armed' : `NOT re-armed (${reArm?.reason ?? 'unknown'})`}.`,
468
475
  );
469
476
  return { verdict, reason, exitCode: 0, headSha, reArmed };