mandrel 2.59.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 (97) hide show
  1. package/.agents/README.md +11 -9
  2. package/.agents/agents/acceptance-critic.md +24 -43
  3. package/.agents/agents/story-worker.md +18 -19
  4. package/.agents/docs/SDLC.md +6 -6
  5. package/.agents/docs/agentrc-reference.json +1 -2
  6. package/.agents/docs/configuration.md +29 -46
  7. package/.agents/docs/quality-gates.md +8 -4
  8. package/.agents/docs/workflows.md +1 -1
  9. package/.agents/instructions.md +4 -5
  10. package/.agents/rules/ci-remediation.md +41 -8
  11. package/.agents/rules/known-tooling-behavior.md +65 -15
  12. package/.agents/schemas/acceptance-eval-verdict.schema.json +1 -1
  13. package/.agents/schemas/agentrc.schema.json +6 -11
  14. package/.agents/schemas/story-deliver-terminal.schema.json +3 -3
  15. package/.agents/scripts/README.md +11 -1
  16. package/.agents/scripts/acceptance-eval.js +25 -27
  17. package/.agents/scripts/ceremony-derive.js +15 -10
  18. package/.agents/scripts/check-context-budget.js +148 -228
  19. package/.agents/scripts/check-schema-references.js +5 -3
  20. package/.agents/scripts/check-workflow-citations.js +33 -147
  21. package/.agents/scripts/coverage-capture.js +7 -4
  22. package/.agents/scripts/deliver-light.js +41 -100
  23. package/.agents/scripts/deliver-run.js +631 -0
  24. package/.agents/scripts/file-ci-gap.js +59 -11
  25. package/.agents/scripts/lib/baselines/crap-preview-incremental.js +6 -2
  26. package/.agents/scripts/lib/changed-files.js +30 -0
  27. package/.agents/scripts/lib/config/delivery-routing.js +5 -4
  28. package/.agents/scripts/lib/config/explain.js +1 -3
  29. package/.agents/scripts/lib/config/gates/crap-incremental-coverage.schema.js +1 -1
  30. package/.agents/scripts/lib/config-resolver.js +1 -0
  31. package/.agents/scripts/lib/config-settings-schema-delivery.js +28 -21
  32. package/.agents/scripts/lib/coverage-capture-fullscope.js +10 -2
  33. package/.agents/scripts/lib/coverage-capture-incremental.js +3 -2
  34. package/.agents/scripts/lib/coverage-capture-usage.js +4 -1
  35. package/.agents/scripts/lib/doc-tiers.js +4 -2
  36. package/.agents/scripts/lib/feedback-loop/graduator-core.js +7 -6
  37. package/.agents/scripts/lib/feedback-loop/retro-proposals-graduator.js +7 -5
  38. package/.agents/scripts/lib/generated/agentrc-validator.js +1 -1
  39. package/.agents/scripts/lib/gh-exec.js +160 -0
  40. package/.agents/scripts/lib/observability/source-classifier.js +1 -0
  41. package/.agents/scripts/lib/orchestration/ceremony-routing.js +74 -132
  42. package/.agents/scripts/lib/orchestration/ci-rerun-guard.js +123 -12
  43. package/.agents/scripts/lib/orchestration/complexity-gate.js +180 -352
  44. package/.agents/scripts/lib/orchestration/light-suitability.js +71 -136
  45. package/.agents/scripts/lib/orchestration/plan-context.js +13 -25
  46. package/.agents/scripts/lib/orchestration/plan-persist/changes-repair.js +8 -6
  47. package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +76 -95
  48. package/.agents/scripts/lib/orchestration/plan-persist/story-ops.js +35 -18
  49. package/.agents/scripts/lib/orchestration/plan-persist/summary.js +11 -11
  50. package/.agents/scripts/lib/orchestration/plan-persist/supersede-ops.js +63 -29
  51. package/.agents/scripts/lib/orchestration/review-depth.js +14 -11
  52. package/.agents/scripts/lib/orchestration/run-epilogue.js +260 -182
  53. package/.agents/scripts/lib/orchestration/run-scoped-config.js +63 -99
  54. package/.agents/scripts/lib/orchestration/single-story-close/phases/base-sync.js +3 -3
  55. package/.agents/scripts/lib/orchestration/single-story-close/phases/graphql-preflight.js +137 -0
  56. package/.agents/scripts/lib/orchestration/single-story-close/runner.js +105 -18
  57. package/.agents/scripts/lib/orchestration/story-deliver-terminal.js +4 -3
  58. package/.agents/scripts/lib/orchestration/story-follow-ups.js +156 -39
  59. package/.agents/scripts/lib/orchestration/story-init-envelope.js +71 -0
  60. package/.agents/scripts/lib/orchestration/task-body-validator.js +8 -17
  61. package/.agents/scripts/lib/orchestration/ticket-validator.js +44 -183
  62. package/.agents/scripts/lib/orchestration/ticketing/reads.js +14 -25
  63. package/.agents/scripts/lib/story-body/body-format-lints.js +58 -12
  64. package/.agents/scripts/lib/story-body/story-body.js +83 -29
  65. package/.agents/scripts/lib/templates/decomposer-prompts.js +7 -15
  66. package/.agents/scripts/lib/wave-runner/live-probe.js +31 -5
  67. package/.agents/scripts/merge-baseline.js +4 -5
  68. package/.agents/scripts/plan-context.js +117 -28
  69. package/.agents/scripts/plan-persist.js +79 -28
  70. package/.agents/scripts/plan-run-epilogue.js +11 -8
  71. package/.agents/scripts/pr-watch-with-update.js +9 -2
  72. package/.agents/scripts/run-verify.js +13 -6
  73. package/.agents/scripts/single-story-init.js +7 -57
  74. package/.agents/scripts/stories-wave-tick.js +160 -26
  75. package/.agents/skills/core/gates-and-baselines/reference.md +0 -1
  76. package/.agents/skills/skills.index.json +2 -2
  77. package/.agents/skills/stack/qa/playwright/SKILL.md +26 -0
  78. package/.agents/workflows/helpers/acceptance-self-eval.md +84 -157
  79. package/.agents/workflows/helpers/code-review.md +4 -2
  80. package/.agents/workflows/helpers/deliver-digest.md +31 -24
  81. package/.agents/workflows/helpers/deliver-light.md +92 -101
  82. package/.agents/workflows/helpers/deliver-reference.md +116 -100
  83. package/.agents/workflows/helpers/deliver-story-reference.md +58 -124
  84. package/.agents/workflows/helpers/deliver-story.md +17 -18
  85. package/.agents/workflows/helpers/plan-reference.md +65 -54
  86. package/.agents/workflows/mandrel-deliver.md +47 -31
  87. package/.agents/workflows/mandrel-plan.md +22 -21
  88. package/.agents/workflows/mandrel-update.md +36 -21
  89. package/docs/CHANGELOG.md +35 -0
  90. package/lib/cli/update.js +376 -17
  91. package/lib/migrations/index.js +2 -0
  92. package/lib/migrations/steps/2.60.0-retire-audit-results-autofile.js +40 -0
  93. package/package.json +2 -1
  94. package/.agents/schemas/model-attribution.schema.json +0 -53
  95. package/.agents/scripts/lib/orchestration/model-attribution.js +0 -418
  96. package/.agents/scripts/lib/orchestration/story-plan-state.js +0 -33
  97. package/.agents/scripts/lib/orchestration/structured-comment-parser.js +0 -67
@@ -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
@@ -36,20 +36,16 @@
36
36
  * --no-close-superseded Keep the source tickets open (no comment, no
37
37
  * close) — for a genuinely partial supersede
38
38
  * --dry-run Assemble + validate without GitHub writes
39
- * --chain-on-clean Fast path (Story #4741; any plan since Story
40
- * #5312): run the write-free dry-run first, and
41
- * when it passes clean chain straight into the
42
- * real persist in the SAME invocation, collapsing
43
- * the two operator round-trips into one. A
44
- * dry-run failure stops before any createIssue.
45
- * Ignored when `--dry-run` is also set
46
39
  * --force-review Operator-forced review stop before persist lands
47
40
  *
48
- * Run `--dry-run` first. It exercises every gate — the changes[] repair, the
49
- * validator, DAG, reachability, split/supersede partition, Spec fold —
50
- * write-free, and lists every warning (a footprint probe that disagrees with
51
- * the base branch, an open question in a body) so an authoring mistake
52
- * 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.
53
49
  *
54
50
  * stdout is reserved for the JSON result (Story #2278 discipline, extended to
55
51
  * this CLI by Story #4541): `routeAllOutputToStderr()` runs before any
@@ -82,12 +78,11 @@ import {
82
78
  } from './lib/orchestration/plan-persist/plan-context-source.js';
83
79
  import {
84
80
  runPlanPersist,
85
- writeCheckpointV2,
81
+ writePlanSummaryComment,
86
82
  } from './lib/orchestration/plan-persist/run-plan-persist.js';
87
83
  import {
88
84
  buildPlanSummaryCommentBody,
89
85
  buildWaveTable,
90
- PLAN_SUMMARY_COMMENT_TYPE,
91
86
  } from './lib/orchestration/plan-persist/summary.js';
92
87
  import { resolveSourceTicketIds } from './lib/orchestration/plan-persist/supersede-ops.js';
93
88
  import { createProvider } from './lib/provider-factory.js';
@@ -95,9 +90,8 @@ import { createProvider } from './lib/provider-factory.js';
95
90
  export {
96
91
  buildPlanSummaryCommentBody,
97
92
  buildWaveTable,
98
- PLAN_SUMMARY_COMMENT_TYPE,
99
93
  runPlanPersist,
100
- writeCheckpointV2,
94
+ writePlanSummaryComment,
101
95
  };
102
96
 
103
97
  const CLI_OPTIONS = {
@@ -109,7 +103,6 @@ const CLI_OPTIONS = {
109
103
  'close-superseded': { type: 'boolean', default: true },
110
104
  'no-close-superseded': { type: 'boolean', default: false },
111
105
  'dry-run': { type: 'boolean', default: false },
112
- 'chain-on-clean': { type: 'boolean', default: false },
113
106
  'force-review': { type: 'boolean', default: false },
114
107
  'epic-title': { type: 'string' },
115
108
  'epic-goal': { type: 'string' },
@@ -120,7 +113,7 @@ const USAGE =
120
113
  'Usage: plan-persist.js --stories <file> ' +
121
114
  '[--tech-spec <file>] [--plan-dir <dir>] [--plan-context <file>] ' +
122
115
  '[--source-tickets <ids>] [--no-close-superseded] ' +
123
- '[--dry-run] [--chain-on-clean] [--force-review] ' +
116
+ '[--dry-run] [--force-review] ' +
124
117
  '[--epic-title <text> --epic-goal <text> | --epic <id>]';
125
118
 
126
119
  async function readOptional(filePath, { required }) {
@@ -320,8 +313,9 @@ async function runPersistInvocation({
320
313
  }
321
314
 
322
315
  /**
323
- * Fast path (Story #4741 AC-1/AC-3; widened to any plan by Story #5312):
324
- * 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.
325
319
  *
326
320
  * Two passes over the **same** loaded artifacts:
327
321
  *
@@ -334,6 +328,14 @@ async function runPersistInvocation({
334
328
  * to gate this step went with the plan-side lite claim: a clean dry-run
335
329
  * is the review the chain exists to fold.
336
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
+ *
337
339
  * Exported for tests — this is where the round-trip collapse lives, so a
338
340
  * regression here silently re-opens the second operator round-trip (or
339
341
  * worse, persists a plan the dry-run never gated).
@@ -366,6 +368,14 @@ export async function runPersistChain({
366
368
  metricsSince,
367
369
  dryRun: false,
368
370
  });
371
+ persistResult.repairs = mergeEvidence(
372
+ dryResult.repairs,
373
+ persistResult.repairs,
374
+ );
375
+ persistResult.warnings = mergeEvidence(
376
+ dryResult.warnings,
377
+ persistResult.warnings,
378
+ );
369
379
  persistResult.chain = {
370
380
  attempted: true,
371
381
  persisted: true,
@@ -374,6 +384,51 @@ export async function runPersistChain({
374
384
  return persistResult;
375
385
  }
376
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
+
377
432
  /**
378
433
  * Attach the plan-metrics roll-up for **this** invocation.
379
434
  *
@@ -440,10 +495,7 @@ async function main() {
440
495
  const paths = resolveInputPaths(values);
441
496
  const artifacts = await loadArtifacts(paths);
442
497
 
443
- // `--chain-on-clean` collapses the dry-run + persist operator round-trips
444
- // (Story #4741). `--dry-run` always wins — an explicit dry-run never writes.
445
- const useChain =
446
- values['chain-on-clean'] === true && values['dry-run'] !== true;
498
+ const useChain = shouldChainPersist(values);
447
499
 
448
500
  let result;
449
501
  try {
@@ -482,7 +534,7 @@ runAsCli(import.meta.url, main, {
482
534
  invocation:
483
535
  'node .agents/scripts/plan-persist.js --stories <file> [--tech-spec <file>] [--dry-run] [options]',
484
536
  summary:
485
- '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.',
486
538
  flags: [
487
539
  ['--stories <file>', 'Authored stories.json (required).'],
488
540
  ['--tech-spec <file>', 'Optional companion techspec.md.'],
@@ -493,7 +545,6 @@ runAsCli(import.meta.url, main, {
493
545
  ],
494
546
  ['--source-tickets <ids>', 'Ticket ids this plan supersedes.'],
495
547
  ['--dry-run', 'Validate and report; create nothing.'],
496
- ['--chain-on-clean', 'Persist immediately when the dry run is clean.'],
497
548
  ['--no-close-superseded', 'Leave superseded source tickets open.'],
498
549
  [
499
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 };
@@ -7,7 +7,8 @@
7
7
  *
8
8
  * Order: audit (SCA) → lint (includes docs:check + the arch-cycles ratchet) →
9
9
  * full test suite → unified baselines → the standalone ratchets
10
- * (dead-exports ×2, context-budget, cyclomatic, schema-references).
10
+ * (dead-exports ×2, context-budget, workflow-citations, cyclomatic,
11
+ * schema-references).
11
12
  *
12
13
  * The `audit` step runs `npm audit --audit-level=high`, matching CI's
13
14
  * "Dependency Vulnerability Audit (SCA)" gate so a local green no longer hides
@@ -33,9 +34,14 @@
33
34
  * being added here, so a green `verify` still hid both. Like their neighbours
34
35
  * they are pure-Node and cost milliseconds.
35
36
  *
36
- * Still NOT mirrored: `check-workflow-citations.js` and
37
- * `check-baseline-scope.js` run in CI's `baselines` job only —
38
- * `.agents/rules/known-tooling-behavior.md` entry 2 carries the current
37
+ * Story #5340 closed the `check-workflow-citations.js` gap the same way, once
38
+ * demoting it to a report made it free: it had been exempted here because
39
+ * `tests/check-workflow-citations.test.js` re-ran the same ratchet, so a
40
+ * `verify` step would have double-paid it. That test now asserts the report,
41
+ * not a ceiling, so the step is the only local surface that prints the count.
42
+ *
43
+ * Still NOT mirrored: `check-baseline-scope.js` runs in CI's `baselines` job
44
+ * only — `.agents/rules/known-tooling-behavior.md` entry 2 carries the current
39
45
  * coverage table. (`prune-baseline-orphans.js --check` used to sit in that
40
46
  * list; it no longer runs in CI at all — the un-attributed duplicate of the
41
47
  * scope gate's `extra` direction reds every open PR on inherited rows.) Nor are the CI gates this command structurally cannot
@@ -50,8 +56,8 @@ import { spawnSync } from 'node:child_process';
50
56
  import { runAsCli } from './lib/cli-utils.js';
51
57
 
52
58
  /**
53
- * A gate step: `node .agents/scripts/<script>` plus any extra args. Seven of
54
- * the ten steps share exactly that shape, so spelling it once leaves the list
59
+ * A gate step: `node .agents/scripts/<script>` plus any extra args. Eight of
60
+ * the eleven steps share exactly that shape, so spelling it once leaves the list
55
61
  * below readable as what it actually is — a gate *order* — instead of a wall
56
62
  * of spawn tuples.
57
63
  *
@@ -79,6 +85,7 @@ const STEPS = [
79
85
  gate('dead-exports', 'check-dead-exports.js'),
80
86
  gate('dead-exports-production', 'check-dead-exports.js', '--production'),
81
87
  gate('context-budget', 'check-context-budget.js'),
88
+ gate('workflow-citations', 'check-workflow-citations.js'),
82
89
  gate('cyclomatic', 'check-cyclomatic.js'),
83
90
  gate('schema-references', 'check-schema-references.js'),
84
91
  ];
@@ -70,7 +70,6 @@ import { handleRemoteVerificationFailure } from './lib/orchestration/story-init-
70
70
  import {
71
71
  STATE_LABELS,
72
72
  transitionTicketState,
73
- upsertStructuredComment,
74
73
  } from './lib/orchestration/ticketing.js';
75
74
  import { createProvider } from './lib/provider-factory.js';
76
75
  import { buildProtectionCtx } from './lib/single-story-sweep/protection-ctx.js';
@@ -770,28 +769,13 @@ export async function runSingleStoryInit({
770
769
  remoteProbe: { remoteUrl: remote.remoteUrl, detail: remote.detail },
771
770
  };
772
771
 
773
- // Upsert the `story-init` structured comment (no-op under --dry-run). The
774
- // `agent::executing` flip already happened above, before provisioning, so the
775
- // claim is label-visible during the install window (see `flipStoryToExecuting`).
776
- if (!dryRun) {
777
- try {
778
- await upsertStructuredComment(
779
- provider,
780
- storyId,
781
- 'story-init',
782
- renderSingleStoryInitComment(result),
783
- );
784
- progress(
785
- 'COMMENT',
786
- `📝 Upserted story-init structured comment on #${storyId}.`,
787
- );
788
- } catch (err) {
789
- Logger.error(
790
- `[single-story-init] ⚠️ Failed to upsert story-init structured comment: ${err?.message ?? err}`,
791
- );
792
- }
793
- }
794
-
772
+ // Story #5343 — init posts no `story-init` comment. It was one GitHub write
773
+ // per Story restating what its own envelope already carries; the envelope is
774
+ // on stdout and on disk (below), `deliver-recover.js` classifies state from
775
+ // labels, the PR probe and disk artifacts, and `run-scoped-config.js` reads
776
+ // the base-branch pin off that same envelope. The `agent::executing` flip
777
+ // above is what makes the claim visible during the install window.
778
+ //
795
779
  // Story #4685 — route the full result to a temp log and emit a single-line
796
780
  // summary carrying the fields the orchestrating agent acts on (workCwd,
797
781
  // remoteVerified). The `## Spec` names this the hot-path stdout to quiet.
@@ -820,40 +804,6 @@ export async function runSingleStoryInit({
820
804
  return { success: true, result };
821
805
  }
822
806
 
823
- export function renderSingleStoryInitComment(result) {
824
- const payload = {
825
- storyId: result.storyId,
826
- epicId: null,
827
- standalone: true,
828
- storyBranch: result.storyBranch,
829
- baseBranch: result.baseBranch,
830
- runScopedConfig: result.runScopedConfig,
831
- worktreeEnabled: result.worktreeEnabled,
832
- workCwd: result.workCwd,
833
- worktreeCreated: result.worktreeCreated,
834
- dependenciesInstalled: result.dependenciesInstalled,
835
- installStatus: result.installStatus,
836
- remoteVerified: result.remoteVerified,
837
- remoteProbe: result.remoteProbe,
838
- };
839
- return [
840
- '## Story init (standalone)',
841
- '',
842
- `- **standalone:** \`true\``,
843
- `- **storyBranch:** \`${result.storyBranch}\``,
844
- `- **baseBranch:** \`${result.baseBranch}\``,
845
- `- **workCwd:** \`${result.workCwd}\``,
846
- `- **worktreeEnabled:** \`${result.worktreeEnabled}\``,
847
- `- **remoteVerified:** \`${result.remoteVerified}\``,
848
- `- **dependenciesInstalled:** \`${result.dependenciesInstalled}\``,
849
- '',
850
- '```json',
851
- JSON.stringify(payload, null, 2),
852
- '```',
853
- '',
854
- ].join('\n');
855
- }
856
-
857
807
  runAsCli(import.meta.url, runSingleStoryInit, {
858
808
  source: 'single-story-init',
859
809
  usage: {