mandrel 2.56.0 → 2.57.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 (106) hide show
  1. package/.agents/agents/plan-critic.md +13 -18
  2. package/.agents/agents/story-worker.md +25 -34
  3. package/.agents/docs/agentrc-reference.json +0 -30
  4. package/.agents/docs/configuration.md +8 -28
  5. package/.agents/docs/execution-reference.md +5 -5
  6. package/.agents/docs/quality-gates.md +8 -7
  7. package/.agents/instructions.md +9 -10
  8. package/.agents/schemas/agentrc.schema.json +9 -185
  9. package/.agents/schemas/story-deliver-terminal.schema.json +1 -1
  10. package/.agents/scripts/acceptance-eval.js +107 -17
  11. package/.agents/scripts/ceremony-derive.js +191 -0
  12. package/.agents/scripts/check-context-budget.js +28 -33
  13. package/.agents/scripts/check-cyclomatic.js +4 -3
  14. package/.agents/scripts/deliver-light.js +31 -94
  15. package/.agents/scripts/lib/audit-suite/checklist-threading.js +15 -2
  16. package/.agents/scripts/lib/baselines/coverage-updater-cli.js +110 -0
  17. package/.agents/scripts/lib/baselines/crap-preview-scan.js +25 -0
  18. package/.agents/scripts/lib/baselines/crap-updater-cli.js +223 -0
  19. package/.agents/scripts/lib/bdd-scenario-budget.js +21 -3
  20. package/.agents/scripts/lib/bootstrap/quality-bootstrap.js +0 -1
  21. package/.agents/scripts/lib/close-validation/gates.js +52 -1
  22. package/.agents/scripts/lib/config/acceptance-eval.js +25 -57
  23. package/.agents/scripts/lib/config/delivery-routing.js +7 -33
  24. package/.agents/scripts/lib/config/explain.js +0 -19
  25. package/.agents/scripts/lib/config/limits.js +18 -78
  26. package/.agents/scripts/lib/config/quality.js +6 -3
  27. package/.agents/scripts/lib/config/runners.js +3 -2
  28. package/.agents/scripts/lib/config-settings-schema-delivery.js +15 -68
  29. package/.agents/scripts/lib/config-settings-schema-quality.js +0 -14
  30. package/.agents/scripts/lib/config-settings-schema.js +16 -143
  31. package/.agents/scripts/lib/crap-engine.js +35 -4
  32. package/.agents/scripts/lib/crap-utils.js +17 -1
  33. package/.agents/scripts/lib/cyclomatic-ceiling.js +19 -7
  34. package/.agents/scripts/lib/generated/agentrc-validator.js +1 -1
  35. package/.agents/scripts/lib/observability/runtime-friction.js +1 -1
  36. package/.agents/scripts/lib/observability/source-classifier.js +1 -0
  37. package/.agents/scripts/lib/orchestration/acceptance-eval-decision.js +5 -4
  38. package/.agents/scripts/lib/orchestration/ceremony-routing.js +19 -73
  39. package/.agents/scripts/lib/orchestration/complexity-gate.js +46 -212
  40. package/.agents/scripts/lib/orchestration/file-assumptions.js +32 -17
  41. package/.agents/scripts/lib/orchestration/light-escalation.js +3 -3
  42. package/.agents/scripts/lib/orchestration/light-suitability.js +66 -233
  43. package/.agents/scripts/lib/orchestration/plan-context.js +181 -387
  44. package/.agents/scripts/lib/orchestration/plan-critic-conditions.js +42 -153
  45. package/.agents/scripts/lib/orchestration/plan-critics-evaluate.js +14 -70
  46. package/.agents/scripts/lib/orchestration/plan-persist/changes-repair.js +300 -0
  47. package/.agents/scripts/lib/orchestration/plan-persist/persist-helpers.js +131 -168
  48. package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +118 -297
  49. package/.agents/scripts/lib/orchestration/plan-persist/soft-findings.js +55 -0
  50. package/.agents/scripts/lib/orchestration/plan-persist/story-ops.js +16 -65
  51. package/.agents/scripts/lib/orchestration/plan-persist/wave-serialisation.js +22 -35
  52. package/.agents/scripts/lib/orchestration/plan-text-hygiene.js +30 -139
  53. package/.agents/scripts/lib/orchestration/planning/memory-pool-advisory.js +61 -223
  54. package/.agents/scripts/lib/orchestration/single-story-close/phases/close-validation.js +5 -0
  55. package/.agents/scripts/lib/orchestration/single-story-close/phases/pre-gate-steps.js +46 -16
  56. package/.agents/scripts/lib/orchestration/story-close/context-budget-writeback.js +213 -0
  57. package/.agents/scripts/lib/orchestration/task-body-validator.js +10 -63
  58. package/.agents/scripts/lib/orchestration/ticket-validator-conflicts.js +33 -539
  59. package/.agents/scripts/lib/orchestration/ticket-validator-sizing.js +21 -414
  60. package/.agents/scripts/lib/orchestration/ticket-validator.js +54 -118
  61. package/.agents/scripts/lib/orchestration/verify-credit.js +69 -24
  62. package/.agents/scripts/lib/story-body/body-format-lints.js +15 -85
  63. package/.agents/scripts/lib/story-body/story-body.js +17 -237
  64. package/.agents/scripts/lib/templates/decomposer-prompts.js +84 -121
  65. package/.agents/scripts/lib/test-isolate/cli-options.js +93 -0
  66. package/.agents/scripts/lib/test-isolate/progress-log.js +45 -0
  67. package/.agents/scripts/lib/test-isolate/render-report.js +97 -0
  68. package/.agents/scripts/lib/test-isolate/run-isolate.js +87 -0
  69. package/.agents/scripts/lib/test-run-credit.js +266 -0
  70. package/.agents/scripts/lib/wave-runner/footprint.js +48 -358
  71. package/.agents/scripts/lib/wave-runner/ready-set.js +6 -5
  72. package/.agents/scripts/lib/workers/crap-worker.js +32 -41
  73. package/.agents/scripts/plan-context.js +7 -9
  74. package/.agents/scripts/plan-critics.js +28 -54
  75. package/.agents/scripts/plan-persist.js +25 -68
  76. package/.agents/scripts/quality-preview.js +51 -0
  77. package/.agents/scripts/run-tests.js +12 -0
  78. package/.agents/scripts/stories-wave-tick.js +23 -45
  79. package/.agents/scripts/test-isolate.js +13 -180
  80. package/.agents/scripts/update-coverage-baseline.js +25 -70
  81. package/.agents/scripts/update-crap-baseline.js +19 -123
  82. package/.agents/skills/core/scope-triage/SKILL.md +3 -3
  83. package/.agents/workflows/audit-clean-code.md +4 -3
  84. package/.agents/workflows/helpers/acceptance-self-eval.md +41 -41
  85. package/.agents/workflows/helpers/code-quality-guardrails.md +4 -4
  86. package/.agents/workflows/helpers/code-review.md +2 -3
  87. package/.agents/workflows/helpers/deliver-digest.md +41 -57
  88. package/.agents/workflows/helpers/deliver-light.md +40 -105
  89. package/.agents/workflows/helpers/deliver-reference.md +1 -1
  90. package/.agents/workflows/helpers/deliver-story-reference.md +37 -58
  91. package/.agents/workflows/helpers/deliver-story.md +9 -13
  92. package/.agents/workflows/helpers/plan-reference.md +132 -219
  93. package/.agents/workflows/mandrel-plan.md +27 -40
  94. package/.agents/workflows/memory-consolidate.md +9 -13
  95. package/docs/CHANGELOG.md +23 -0
  96. package/lib/migrations/index.js +4 -0
  97. package/lib/migrations/steps/2.57.0-retire-delivery-limit-knobs.js +45 -0
  98. package/lib/migrations/steps/2.57.0-retire-planning-limit-knobs.js +59 -0
  99. package/package.json +1 -1
  100. package/.agents/scripts/lib/framework-version.js +0 -39
  101. package/.agents/scripts/lib/orchestration/consolidation-precondition.js +0 -223
  102. package/.agents/scripts/lib/orchestration/plan-persist/fan-out-gate.js +0 -97
  103. package/.agents/scripts/lib/orchestration/planning/decomposer-context.js +0 -26
  104. package/.agents/scripts/lib/orchestration/spec-budget.js +0 -89
  105. package/.agents/scripts/lib/orchestration/spec-spill.js +0 -74
  106. package/.agents/scripts/lib/orchestration/verify-tier-repair.js +0 -107
@@ -1,11 +1,13 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  /**
4
- * plan-critics.js — the /mandrel-plan critic-dispatch verdict CLI (Story #4592).
4
+ * plan-critics.js — the /mandrel-plan pre-mortem dispatch verdict CLI
5
+ * (Story #4592; pre-mortem only since Story #5312).
5
6
  *
6
- * `/mandrel-plan` step 2.5 (between Author and Persist) runs this against the draft
7
- * `stories.json`. It evaluates the consolidation + pre-mortem dispatch
8
- * conditions and prints the verdict as JSON on stdout so the workflow can
7
+ * The operator runs this between Author and Persist when they want the
8
+ * pre-mortem — it is no longer a step of the `/mandrel-plan` spine. It
9
+ * evaluates the pre-mortem dispatch condition against the draft
10
+ * `stories.json` and prints the verdict as JSON on stdout so the workflow can
9
11
  * act on it — dispatching a fresh-context critic sub-agent and folding its
10
12
  * findings into a re-author round **before** the plan is persisted.
11
13
  *
@@ -28,21 +30,15 @@
28
30
  *
29
31
  * CLI:
30
32
  * --stories <file> Required. The draft Story ticket array (JSON).
31
- * --tech-spec <file> Optional. Shared Tech Spec carrying the
32
- * `## Delivery Slicing` table the consolidation
33
- * precondition reads.
33
+ * --tech-spec <file> Optional. Shared Tech Spec, folded into the plan
34
+ * text the external-dependency probe scans.
34
35
  *
35
36
  * stdout is reserved for the verdict JSON (Story #2278 discipline):
36
37
  *
37
38
  * {
38
- * "consolidation": { "critic": "consolidation", "dispatch": false, "reasons": [...] },
39
- * "premortem": { "critic": "pre-mortem", "dispatch": true, "reasons": [...] },
40
- * "textHygiene": { "critic": "text-hygiene", "findings": [...] }
39
+ * "premortem": { "critic": "pre-mortem", "dispatch": true, "reasons": [...] }
41
40
  * }
42
41
  *
43
- * `textHygiene` (Story #4599) is advisory-only: deterministic body lints with
44
- * no dispatch semantics — its findings fold into the re-author round.
45
- *
46
42
  * Human-readable log lines go to stderr, matching the sibling `plan-persist`.
47
43
  *
48
44
  * Exit codes: 0 success (any verdict); 1 usage/IO error.
@@ -192,11 +188,11 @@ export async function loadCriticArtifacts({
192
188
  }
193
189
 
194
190
  /**
195
- * Log each decision and record every skip on the plan-metrics ledger. The
191
+ * Log the decision and record a skip on the plan-metrics ledger. The
196
192
  * ledger write is best-effort by `appendCriticSkip`'s own contract — it can
197
193
  * never fail the plan step.
198
194
  *
199
- * @param {{ consolidation: object, premortem: object, textHygiene?: object }} verdict
195
+ * @param {{ premortem: object }} verdict
200
196
  * @param {object} config
201
197
  * @param {{ append?: typeof appendCriticSkip }} [deps]
202
198
  * @returns {Promise<void>}
@@ -206,48 +202,26 @@ export async function recordCriticSkips(
206
202
  config,
207
203
  { append = appendCriticSkip } = {},
208
204
  ) {
209
- for (const decision of [verdict.consolidation, verdict.premortem]) {
210
- Logger.info(
211
- `[plan-critics] critic ${decision.critic}: ` +
212
- `${decision.dispatch ? 'dispatch' : 'skip'} — ` +
213
- decision.reasons.join('; '),
214
- );
215
- if (!decision.dispatch) {
216
- await append(
217
- {
218
- critic: decision.critic,
219
- reasons: decision.reasons,
220
- cli: PLAN_CRITICS_CLI,
221
- },
222
- config,
223
- );
224
- }
225
- }
226
-
227
- // Text hygiene (Story #4599) is advisory-only — no dispatch semantics, so
228
- // "skip" here means "zero findings". Recording that keeps the lint's
229
- // fire/skip accounting on the same ledger as the dispatching critics.
230
- const hygiene = verdict.textHygiene;
231
- if (hygiene) {
232
- const count = hygiene.findings.length;
233
- Logger.info(
234
- `[plan-critics] critic ${hygiene.critic}: ${count} finding(s) (advisory).`,
205
+ const decision = verdict.premortem;
206
+ Logger.info(
207
+ `[plan-critics] critic ${decision.critic}: ` +
208
+ `${decision.dispatch ? 'dispatch' : 'skip'} — ` +
209
+ decision.reasons.join('; '),
210
+ );
211
+ if (!decision.dispatch) {
212
+ await append(
213
+ {
214
+ critic: decision.critic,
215
+ reasons: decision.reasons,
216
+ cli: PLAN_CRITICS_CLI,
217
+ },
218
+ config,
235
219
  );
236
- if (count === 0) {
237
- await append(
238
- {
239
- critic: hygiene.critic,
240
- reasons: ['No text-hygiene findings over the draft stories.'],
241
- cli: PLAN_CRITICS_CLI,
242
- },
243
- config,
244
- );
245
- }
246
220
  }
247
221
  }
248
222
 
249
223
  /**
250
- * Load the artifacts, evaluate both critics, record the skips, and return the
224
+ * Load the artifacts, evaluate the pre-mortem, record a skip, and return the
251
225
  * verdict. Exported as the CLI's whole body so tests drive it in-process with
252
226
  * an explicit config and ledger seam.
253
227
  *
@@ -262,7 +236,7 @@ export async function recordCriticSkips(
262
236
  * manifests declare, forwarded to the pre-mortem external-dependency probe
263
237
  * (Story #4700). `main()` resolves them via `collectRepoPackages`; tests may
264
238
  * pass an explicit set or omit it (defaults to `[]`).
265
- * @returns {Promise<{ consolidation: object, premortem: object, textHygiene: object }>}
239
+ * @returns {Promise<{ premortem: object }>}
266
240
  */
267
241
  export async function evaluateCriticArtifacts({
268
242
  storiesPath,
@@ -316,7 +290,7 @@ runAsCli(import.meta.url, main, {
316
290
  invocation:
317
291
  'node .agents/scripts/plan-critics.js --stories <file> [--tech-spec <file>]',
318
292
  summary:
319
- 'Score an authored plan draft with the maker-blind critics and print the verdict JSON on stdout.',
293
+ 'Decide whether the maker-blind pre-mortem critic should run on an authored plan draft and print the verdict JSON on stdout.',
320
294
  flags: [
321
295
  ['--stories <file>', 'Authored stories.json (required).'],
322
296
  ['--tech-spec <file>', 'Optional companion techspec.md.'],
@@ -7,8 +7,8 @@
7
7
  * Given the author-written planning artifacts (`stories.json`, optional shared
8
8
  * Tech Spec), this CLI validates and creates Story issue(s) directly:
9
9
  *
10
- * ticket validator / DAG / capacity → reachability →
11
- * split-policy partition → fold/spill Spec into each Story body →
10
+ * changes[] repair → ticket validator / DAG → reachability →
11
+ * split-policy partition → fold Spec into each Story body →
12
12
  * createIssue(s) with type::story, resumably by plan fingerprint (NOT
13
13
  * agent::ready) → story-plan-state on every Story;
14
14
  * plan-summary on the primary → flip every Story to agent::ready →
@@ -34,31 +34,23 @@
34
34
  * ids, for hand-driven runs. Each id must be
35
35
  * claimed by exactly one Story's `supersedes[]`;
36
36
  * they are commented on and closed as superseded
37
- * --route-downgrade-reason <text>
38
- * Audited planner downgrade (Story #4707): treat
39
- * the envelope's `full` complexity verdict as
40
- * `lite`, recording <text> on every Story's
41
- * story-plan-state checkpoint. Absent this flag
42
- * the deterministic verdict stands; the gate
43
- * itself still fails toward `full`
44
37
  * --no-close-superseded Keep the source tickets open (no comment, no
45
38
  * close) — for a genuinely partial supersede
46
39
  * --dry-run Assemble + validate without GitHub writes
47
- * --chain-on-clean Plan-diet fast path (Story #4741): run the
48
- * write-free dry-run first, and — only when it
49
- * passes clean AND the plan resolves to the `lite`
50
- * route — chain straight into the real persist in
51
- * the SAME invocation, collapsing the two operator
52
- * round-trips into one. A dry-run failure stops
53
- * before any createIssue; a full-route plan keeps
54
- * its review round-trip (the chain declines, no
55
- * writes). Ignored when `--dry-run` is also set
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
56
47
  * --force-review Operator-forced review stop before persist lands
57
- * --allow-over-budget / --allow-large-fan-out
58
48
  *
59
- * Run `--dry-run` first. It exercises every gate — validator, DAG, capacity,
60
- * budget, reachability, split/supersede partition, Spec fold — write-free, so
61
- * an authoring mistake surfaces before a single issue exists.
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.
62
54
  *
63
55
  * stdout is reserved for the JSON result (Story #2278 discipline, extended to
64
56
  * this CLI by Story #4541): `routeAllOutputToStderr()` runs before any
@@ -116,14 +108,11 @@ const CLI_OPTIONS = {
116
108
  'plan-context': { type: 'string' },
117
109
  'plan-acceptance': { type: 'string' },
118
110
  'source-tickets': { type: 'string' },
119
- 'route-downgrade-reason': { type: 'string' },
120
111
  'close-superseded': { type: 'boolean', default: true },
121
112
  'no-close-superseded': { type: 'boolean', default: false },
122
113
  'dry-run': { type: 'boolean', default: false },
123
114
  'chain-on-clean': { type: 'boolean', default: false },
124
115
  'force-review': { type: 'boolean', default: false },
125
- 'allow-over-budget': { type: 'boolean', default: false },
126
- 'allow-large-fan-out': { type: 'boolean', default: false },
127
116
  'epic-title': { type: 'string' },
128
117
  'epic-goal': { type: 'string' },
129
118
  epic: { type: 'string' },
@@ -134,9 +123,7 @@ const USAGE =
134
123
  '[--tech-spec <file>] [--plan-dir <dir>] [--plan-context <file>] ' +
135
124
  '[--plan-acceptance <file>] ' +
136
125
  '[--source-tickets <ids>] [--no-close-superseded] ' +
137
- '[--route-downgrade-reason <text>] ' +
138
126
  '[--dry-run] [--chain-on-clean] [--force-review] ' +
139
- '[--allow-over-budget] [--allow-large-fan-out] ' +
140
127
  '[--epic-title <text> --epic-goal <text> | --epic <id>]';
141
128
 
142
129
  async function readOptional(filePath, { required }) {
@@ -293,14 +280,11 @@ export function buildPersistOptions(values, paths, planContextEnvelope) {
293
280
 
294
281
  return {
295
282
  forceReview: values['force-review'],
296
- allowOverBudget: values['allow-over-budget'],
297
- allowLargeFanOut: values['allow-large-fan-out'],
298
283
  dryRun: values['dry-run'],
299
284
  planDir: paths.planDir,
300
285
  skipCleanup: values['dry-run'],
301
286
  sourceTicketIds: source.ids,
302
287
  sourceTicketOrigin: source.origin,
303
- routeDowngradeReason: values['route-downgrade-reason'] ?? null,
304
288
  epic: resolveEpicRequest(values),
305
289
  adoptEpicId: resolveEpicAdoptionId(values),
306
290
  // Default-on: `--no-close-superseded` is the explicit escape and always
@@ -323,12 +307,6 @@ async function runPersistInvocation({
323
307
  const paths = resolveInputPaths(values);
324
308
  const effectiveDryRun =
325
309
  typeof dryRun === 'boolean' ? dryRun : values['dry-run'] === true;
326
- const settings = {
327
- baseBranch: config.project?.baseBranch,
328
- paths: config.project?.paths,
329
- planning: config.planning,
330
- docsContextFiles: config.project?.docsContextFiles,
331
- };
332
310
 
333
311
  return recordPlanInvocation(
334
312
  {
@@ -341,7 +319,6 @@ async function runPersistInvocation({
341
319
  provider,
342
320
  artifacts,
343
321
  config,
344
- settings,
345
322
  opts: {
346
323
  ...buildPersistOptions(values, paths, artifacts.planContextEnvelope),
347
324
  dryRun: effectiveDryRun,
@@ -353,23 +330,23 @@ async function runPersistInvocation({
353
330
  }
354
331
 
355
332
  /**
356
- * Plan-diet fast path (Story #4741 AC-1/AC-3): chain the lite dry-run into the
357
- * real persist in ONE operator invocation.
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.
358
335
  *
359
336
  * Two passes over the **same** loaded artifacts:
360
337
  *
361
338
  * 1. A write-free dry-run. Every gate runs before any `createIssue` can
362
339
  * happen, so a validation failure — which throws or returns reachability
363
340
  * orphans — stops here, before a single issue exists (AC-3).
364
- * 2. The real write, run **only** when the dry-run passed clean AND resolved
365
- * to the `lite` route. Because it replays the identical artifacts, the
366
- * persisted output is byte-identical to what the dry-run validated
367
- * (AC-1). A full-route plan keeps its review round-trip: the chain
368
- * declines and returns the dry-run result, mutating nothing.
341
+ * 2. The real write, run when the dry-run passed clean. Because it replays
342
+ * the identical artifacts, the persisted output is byte-identical to
343
+ * what the dry-run validated (AC-1). The lite-route condition that used
344
+ * to gate this step went with the plan-side lite claim: a clean dry-run
345
+ * is the review the chain exists to fold.
369
346
  *
370
- * Exported for tests — this is where the round-trip collapse and its
371
- * fail-closed guard live, so a regression here silently re-opens the second
372
- * operator round-trip (or worse, persists a plan the dry-run never gated).
347
+ * Exported for tests — this is where the round-trip collapse lives, so a
348
+ * regression here silently re-opens the second operator round-trip (or
349
+ * worse, persists a plan the dry-run never gated).
373
350
  *
374
351
  * @param {{ values: object, config: object, provider: object,
375
352
  * artifacts: object, metricsSince: string }} args
@@ -391,20 +368,6 @@ export async function runPersistChain({
391
368
  dryRun: true,
392
369
  });
393
370
 
394
- if (dryResult.route?.route !== 'lite') {
395
- dryResult.chain = {
396
- attempted: true,
397
- persisted: false,
398
- reason: 'route-not-lite',
399
- };
400
- Logger.info(
401
- '[plan-persist] --chain-on-clean: dry-run clean but the plan did not ' +
402
- 'resolve to the lite route — declining the auto-persist; run persist ' +
403
- 'explicitly after review.',
404
- );
405
- return dryResult;
406
- }
407
-
408
371
  const persistResult = await runPersistInvocation({
409
372
  values,
410
373
  config,
@@ -416,7 +379,7 @@ export async function runPersistChain({
416
379
  persistResult.chain = {
417
380
  attempted: true,
418
381
  persisted: true,
419
- reason: 'lite-dry-run-clean',
382
+ reason: 'dry-run-clean',
420
383
  };
421
384
  return persistResult;
422
385
  }
@@ -540,10 +503,6 @@ runAsCli(import.meta.url, main, {
540
503
  ],
541
504
  ['--plan-acceptance <file>', 'Acceptance artifact to attach.'],
542
505
  ['--source-tickets <ids>', 'Ticket ids this plan supersedes.'],
543
- [
544
- '--route-downgrade-reason <text>',
545
- 'Why the authored route was downgraded.',
546
- ],
547
506
  ['--dry-run', 'Validate and report; create nothing.'],
548
507
  ['--chain-on-clean', 'Persist immediately when the dry run is clean.'],
549
508
  ['--no-close-superseded', 'Leave superseded source tickets open.'],
@@ -551,8 +510,6 @@ runAsCli(import.meta.url, main, {
551
510
  '--force-review',
552
511
  'Require the review gate even when it would be skipped.',
553
512
  ],
554
- ['--allow-over-budget', 'Permit a Spec over the context budget.'],
555
- ['--allow-large-fan-out', 'Permit a Story count above the fan-out gate.'],
556
513
  [
557
514
  '--epic-title <text>',
558
515
  'Group the persisted Stories under a container Epic with this title (needs --epic-goal).',
@@ -249,9 +249,56 @@ export function mergeEnvelopes(
249
249
  (crapEnvelope?.summary?.newViolations ?? 0),
250
250
  },
251
251
  cyclomaticFlag: flag,
252
+ // Story #5313: advisories ride the merge but never the exit code.
253
+ advisories: advisoriesOf(crapEnvelope),
252
254
  };
253
255
  }
254
256
 
257
+ /**
258
+ * The cyclomatic advisories a CRAP envelope carries (Story #5313), or none.
259
+ *
260
+ * @param {{ cyclomaticAdvisories?: unknown } | null | undefined} crapEnvelope
261
+ * @returns {Array<{ file: string, method: string, startLine: number, cyclomatic: number }>}
262
+ */
263
+ function advisoriesOf(crapEnvelope) {
264
+ const list = crapEnvelope?.cyclomaticAdvisories;
265
+ return Array.isArray(list) ? list : [];
266
+ }
267
+
268
+ /**
269
+ * Print the advisories block when there is one (Story #5313). Split out of
270
+ * `emitReport` so that function's branching stays inside its committed
271
+ * cyclomatic budget.
272
+ *
273
+ * @param {Array<object>} advisories
274
+ * @param {{ write: (s: string) => void }} stdout
275
+ * @returns {void}
276
+ */
277
+ function writeAdvisories(advisories, stdout) {
278
+ const block = renderAdvisories(advisories);
279
+ if (block) stdout.write(`\n${block}\n`);
280
+ }
281
+
282
+ /**
283
+ * Render the cyclomatic advisories block (Story #5313), or `null` when the
284
+ * scan carried none. One line per method at or over the ceiling; the block
285
+ * says outright that it is advisory so a reader does not hunt for the exit
286
+ * code it did not change.
287
+ *
288
+ * @param {Array<{ file: string, method: string, startLine: number, cyclomatic: number }>} advisories
289
+ * @returns {string|null}
290
+ */
291
+ export function renderAdvisories(advisories) {
292
+ const list = Array.isArray(advisories) ? advisories : [];
293
+ if (list.length === 0) return null;
294
+ return [
295
+ `Advisory — ${list.length} method(s) at cyclomatic 12 or above (reported, not a verdict; check-cyclomatic.js owns the ratchet):`,
296
+ ...list.map(
297
+ (a) => ` - ${a.file}:${a.startLine} ${a.method} (c=${a.cyclomatic})`,
298
+ ),
299
+ ].join('\n');
300
+ }
301
+
255
302
  /**
256
303
  * Render the named diagnostics a gate envelope carries, or `null` when it
257
304
  * carries none (Story #4866).
@@ -286,6 +333,9 @@ export function renderDiagnostics(results) {
286
333
  * Both signals are combined so a transient gate failure (e.g. JSON write
287
334
  * error) still surfaces even if the violations array happens to be empty.
288
335
  *
336
+ * Advisories (`merged.advisories`, Story #5313) are deliberately not read
337
+ * here: a method at cyclomatic 12 or above is reported and exits 0.
338
+ *
289
339
  * @param {{ rows: Array<unknown>, totals: { miRegressions: number, crapViolations: number } }} merged
290
340
  * @param {number} miExit
291
341
  * @param {number} crapExit
@@ -484,6 +534,7 @@ function emitReport({
484
534
  stdout.write('\n--- quality:preview ---\n');
485
535
  stdout.write(stagedScopeLine({ staged, ref, cwd }));
486
536
  stdout.write(`${renderTable(merged)}\n`);
537
+ writeAdvisories(merged.advisories, stdout);
487
538
  const diagnostics = renderDiagnostics([miResult, crapResult]);
488
539
  if (diagnostics) stdout.write(`\n${diagnostics}\n`);
489
540
  if (miExit !== 0 || crapExit !== 0) {
@@ -28,6 +28,11 @@
28
28
  * (`MAX_TARGET_CHARS`) and spawns one `node --test` process per chunk,
29
29
  * aggregating the exit codes. On POSIX the far larger
30
30
  * `POSIX_MAX_TARGET_CHARS` budget collapses every tier back into one spawn.
31
+ *
32
+ * Suite credit (Story #5313): a green full-tier run inside a `story-<id>`
33
+ * checkout deposits the `test` gate's evidence record — the one
34
+ * `single-story-close.js` reads — so a bare `npm test` in the worktree is
35
+ * credited at unchanged HEAD. See `lib/test-run-credit.js`.
31
36
  */
32
37
 
33
38
  import { spawnSync } from 'node:child_process';
@@ -37,6 +42,7 @@ import { assertNoReservedIdStreams } from './check-test-temp-hygiene.js';
37
42
  import { cleanupRepoTestTempArtifacts } from './cleanup-repo-test-temp.js';
38
43
  import { runAsCli } from './lib/cli-utils.js';
39
44
  import { buildWebhookSafeTestEnv } from './lib/test-env.js';
45
+ import { reportTestRunCredit } from './lib/test-run-credit.js';
40
46
  import {
41
47
  resolveTestConcurrency,
42
48
  runTierPreflight,
@@ -171,6 +177,7 @@ export function runTestSuite({
171
177
  maxTargetChars = resolveMaxTargetChars(),
172
178
  fixtureStreamGuard = assertNoReservedIdStreams,
173
179
  preflight = runTierPreflight,
180
+ depositCredit = reportTestRunCredit,
174
181
  } = {}) {
175
182
  const { tier, rest } = parseTierArgv(argv);
176
183
 
@@ -185,6 +192,7 @@ export function runTestSuite({
185
192
  const chunks = chunkTestTargets(targets, maxTargetChars);
186
193
 
187
194
  const env = buildWebhookSafeTestEnv(process.env);
195
+ const startedAt = Date.now();
188
196
  let status = 0;
189
197
  let spawnError = null;
190
198
 
@@ -224,6 +232,10 @@ export function runTestSuite({
224
232
  throw spawnError;
225
233
  }
226
234
 
235
+ // Story #5313 — a green full run earns close's `test` credit; best-effort,
236
+ // never the exit code.
237
+ depositCredit({ cwd, tier, status, durationMs: Date.now() - startedAt });
238
+
227
239
  return status;
228
240
  }
229
241
 
@@ -55,12 +55,13 @@
55
55
  * inFlight: number,
56
56
  * cycleError: string | null,
57
57
  * wedged: { reason, stories: [{ id, unmetBlockers }] } | null,
58
- * inFlightReservation: { available, withheld: [{ id, blockedBy, reason, source, paths, attribution }], note },
59
- * footprintGuard: { mode, withheld: [{ id, blockedBy, scope, source, paths, attribution }], advisory, note }
58
+ * inFlightReservation: { available, withheld: [{ id, blockedBy, reason, source, paths }], note },
59
+ * footprintGuard: { mode, withheld: [{ id, blockedBy, scope, source, paths }], advisory, note }
60
60
  * }
61
61
  *
62
62
  * `inFlightReservation` reports the cross-beat half of the co-dispatch guard
63
- * (Story #4875 widened the footprint; Story #4950 made it reserve). Under
63
+ * (Story #4950 made it reserve; Story #5313 narrowed the footprint back to
64
+ * the declared `changes[]`). Under
64
65
  * `--probe-live` the in-flight Stories' own records are handed to the kernel,
65
66
  * so a candidate sharing a CONCRETE path with a Story dispatched on an EARLIER
66
67
  * beat is withheld and named here with its blocker and a `reason`
@@ -75,10 +76,10 @@
75
76
  * reported nowhere: a same-beat overlap skip was a bare `continue` inside
76
77
  * `planReadySet`, so the Story vanished from `ready[]` with no field anywhere
77
78
  * naming the collision. Every entry in either report now also carries the
78
- * colliding `paths` and a `source` tag — `declared-overlap` when both Stories'
79
- * `changes[]` named the path (intended serialization: two Stories really do
80
- * rewrite the same generated baseline) versus `scraped-overlap` when only the
81
- * text evidence produced it. `mode` names the `footprintGuard` config value;
79
+ * colliding `paths` and a `source` tag — `declared-overlap`, the one class
80
+ * left since Story #5313: both Stories' `changes[]` named the path (intended
81
+ * serialization: two Stories really do rewrite the same generated baseline)
82
+ * or one declared a glob. `mode` names the `footprintGuard` config value;
82
83
  * under `advisory` the collisions are detected and listed in `advisory[]` but
83
84
  * dispatch follows the declared `depends_on` edges alone.
84
85
  *
@@ -130,10 +131,7 @@ import { AGENT_LABELS } from './lib/label-constants.js';
130
131
  import { parseIds } from './lib/orchestration/resolve-stories.js';
131
132
  import { buildStoryAdjacency } from './lib/story-adjacency.js';
132
133
  import { expandIdList } from './lib/util/parse-id-list.js';
133
- import {
134
- OVERLAP_SOURCES,
135
- renderScrapeAttribution,
136
- } from './lib/wave-runner/footprint.js';
134
+ import { OVERLAP_SOURCES } from './lib/wave-runner/footprint.js';
137
135
  import {
138
136
  createProbeContext,
139
137
  probeLiveState,
@@ -240,10 +238,7 @@ Output envelope:
240
238
  "blockedBy": 4949,
241
239
  "reason": "in-flight-earlier-beat",
242
240
  "source": "declared-overlap",
243
- "paths": ["lib/shared.js"],
244
- "attribution": [
245
- { "path": "lib/shared.js", "declared": true, "fields": [] }
246
- ]
241
+ "paths": ["lib/shared.js"]
247
242
  }
248
243
  ],
249
244
  "note": "..."
@@ -255,15 +250,8 @@ Output envelope:
255
250
  "id": 4952,
256
251
  "blockedBy": 4951,
257
252
  "scope": "beat",
258
- "source": "scraped-overlap",
259
- "paths": ["lib/other.js"],
260
- "attribution": [
261
- {
262
- "path": "lib/other.js",
263
- "declared": false,
264
- "fields": ["body:Verify"]
265
- }
266
- ]
253
+ "source": "declared-overlap",
254
+ "paths": ["lib/other.js"]
267
255
  }
268
256
  ],
269
257
  "advisory": [],
@@ -280,11 +268,9 @@ report is { available: false } and selection de-conflicts within the beat only.
280
268
  footprintGuard names each Story withheld from THIS beat by a peer already
281
269
  admitted on it — the half that used to be an unreported skip — and every
282
270
  entry in either report carries the colliding paths plus a source tag
283
- (declared-overlap when both changes[] declarations named the path, else
284
- scraped-overlap from the text evidence) and an "attribution" list naming, per
285
- path, the field the scrape read it from ("title", "spec", or "body:<section>"
286
- — so a path that reached the comparison only because every Story RUNS it in
287
- "## Verify" says so). Its "mode" echoes
271
+ (declared-overlap: both changes[] declarations named the path, or one declared
272
+ a glob — the text scrape that used to widen this was retired in Story #5313).
273
+ Its "mode" echoes
288
274
  delivery.deliverRunner.footprintGuard: under "advisory" the collisions are
289
275
  detected and listed in "advisory" but never withhold, and dispatch follows the
290
276
  declared depends_on edges alone.
@@ -406,7 +392,6 @@ export function buildReservationReport(
406
392
  : RESERVATION_REASONS.EARLIER_BEAT,
407
393
  source: w.source ?? OVERLAP_SOURCES.DECLARED,
408
394
  paths: w.paths ?? [],
409
- attribution: w.attribution ?? [],
410
395
  }));
411
396
  return {
412
397
  available: true,
@@ -438,16 +423,12 @@ export function buildReservationReport(
438
423
  */
439
424
  export function buildFootprintGuardReport(footprintWithholds, mode) {
440
425
  const ledger = Array.isArray(footprintWithholds) ? footprintWithholds : [];
441
- const project = ({ id, blockedBy, scope, source, paths, attribution }) => ({
426
+ const project = ({ id, blockedBy, scope, source, paths }) => ({
442
427
  id,
443
428
  blockedBy,
444
429
  scope,
445
430
  source,
446
431
  paths,
447
- // Story #5265: the per-path field attribution rides the entry itself, so
448
- // a consumer reading the envelope never has to re-derive where a scraped
449
- // path came from (and cannot get a different answer than the note did).
450
- attribution: attribution ?? [],
451
432
  });
452
433
  const beat = ledger
453
434
  .filter((w) => w.scope === WITHHOLD_SCOPES.BEAT && w.enforced)
@@ -474,22 +455,19 @@ export function buildFootprintGuardReport(footprintWithholds, mode) {
474
455
  function footprintGuardNote(beat, advisory, mode) {
475
456
  const detail = (entries) =>
476
457
  entries
477
- .map((w) => {
478
- const scraped = renderScrapeAttribution(w.attribution);
479
- const provenance = scraped ? `, scraped from ${scraped}` : '';
480
- return `#${w.id} ← #${w.blockedBy} on ${w.paths.join(', ')} (${w.source}${provenance})`;
481
- })
458
+ .map(
459
+ (w) =>
460
+ `#${w.id} ← #${w.blockedBy} on ${w.paths.join(', ')} (${w.source})`,
461
+ )
482
462
  .join('; ');
483
463
  if (beat.length > 0) {
484
464
  return (
485
465
  `${beat.length} Story(ies) withheld from THIS beat because their file ` +
486
466
  `footprint overlaps a peer already admitted on it — ${detail(beat)}. ` +
487
467
  `Each is still eligible and re-admits on a later beat once its peer ` +
488
- `lands. A ${OVERLAP_SOURCES.SCRAPED} source means the collision came ` +
489
- `from path evidence in the Story text rather than from either ` +
490
- `changes[] declaration — the 'scraped from' clause names the field ` +
491
- `each such path was read out of, so a path only cited in '## Verify' ` +
492
- `is distinguishable from an unpredicted edit target.`
468
+ `lands. Both Stories declared every colliding path in changes[] (or ` +
469
+ `one declared a glob): since Story #5313 the guard reads declarations ` +
470
+ `only, never the Story text.`
493
471
  );
494
472
  }
495
473
  if (advisory.length > 0) {