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
@@ -6,20 +6,11 @@ import { gitSpawn } from '../git-utils.js';
6
6
  import { Logger } from '../Logger.js';
7
7
  import { validateStoryFileAssumptions } from './file-assumptions.js';
8
8
  import { isExternalDependencyRef } from './plan-persist/external-deps.js';
9
- import { computeSpecBudgetFindings } from './spec-budget.js';
10
9
  import {
11
10
  assertStoryBodiesParse,
12
11
  parseStoryBodyOrThrow,
13
12
  } from './story-body-gate.js';
14
- import {
15
- CONFLICT_KINDS,
16
- computeConflictFindings,
17
- renderHardConflictError,
18
- } from './ticket-validator-conflicts.js';
19
- import {
20
- computeSizingFindings,
21
- renderHardFindingError,
22
- } from './ticket-validator-sizing.js';
13
+ import { computeConflictFindings } from './ticket-validator-conflicts.js';
23
14
 
24
15
  /**
25
16
  * Regex matching code-asset paths the freshness gate cares about. The three
@@ -230,10 +221,13 @@ function makeMemoizedGitRunner(runner) {
230
221
  }
231
222
 
232
223
  /**
233
- * Verify that every code-asset path referenced by a Task body or AC exists at
234
- * `baseBranchRef`. A missing path means the planner LLM hallucinated (or the
235
- * path was deleted between planning and decomposition) — refuse to decompose
236
- * because the resulting Task would be unimplementable as written.
224
+ * Check that every code-asset path referenced by a Story body or AC exists at
225
+ * `baseBranchRef`, and report the ones that do not. A missing path usually
226
+ * means the planner named a file it is about to create without declaring it,
227
+ * or a stale reference — worth saying, not worth refusing on: Story #5312
228
+ * demoted this gate from a throw to the **warning list** the dry-run prints,
229
+ * because the paths a goal or acceptance line names are prose the deliverer
230
+ * reads against the real tree, never a contract the validator can hold it to.
237
231
  *
238
232
  * Only Stories are scanned — they are the implementation unit; the Epic
239
233
  * carries narrative copy, not implementation paths.
@@ -243,7 +237,7 @@ function makeMemoizedGitRunner(runner) {
243
237
  * @param {string} opts.baseBranchRef - Ref to probe (e.g. 'main' or 'origin/main').
244
238
  * @param {Function} [opts.gitRunner] - Probe override (testing seam).
245
239
  * @param {string} [opts.cwd] - Repo cwd (forwarded to default runner).
246
- * @throws {ValidationError} when one or more Story references are stale.
240
+ * @returns {string[]} One warning line per missing reference, empty when clean.
247
241
  */
248
242
  export function validateAcFreshness({
249
243
  tickets,
@@ -286,12 +280,7 @@ export function validateAcFreshness({
286
280
  }
287
281
  }
288
282
  }
289
- if (misses.length === 0) return;
290
- const lines = misses.map((m) => renderMissLine(m)).join('\n');
291
- throw new ValidationError(
292
- `Cross-Validation Failed: ${misses.length} Story reference(s) name files that do not exist at ${baseBranchRef}:\n${lines}\n\nEither declare the path in body.changes (signals net-new) or correct the reference.`,
293
- { misses, baseBranchRef },
294
- );
283
+ return misses.map((m) => renderMissLine(m, baseBranchRef));
295
284
  }
296
285
 
297
286
  /**
@@ -406,40 +395,35 @@ export function validateAcceptanceSubjectPrefix({ tickets }) {
406
395
  }
407
396
 
408
397
  /**
409
- * Render one missing-path line with a remediation hint pointing at the
410
- * task's `body.changes`. For `tests/**` paths we suggest the explicit
398
+ * Render one missing-path warning with a remediation hint pointing at the
399
+ * Story's `changes[]`. For `tests/**` paths we suggest the explicit
411
400
  * "add the test file" verb; for everything else we emit a generic hint
412
401
  * since the planner knows whether the path is net-new or a typo.
413
402
  */
414
- function renderMissLine({ slug, path }) {
403
+ function renderMissLine({ slug, path }, baseBranchRef) {
415
404
  const verb = path.startsWith('tests/') ? 'add test file' : 'create';
416
- return ` - "${slug}" → ${path}\n hint: if net-new, add '${path}: ${verb}' to body.changes; otherwise fix the typo or stale reference against current main.`;
405
+ return `Story "${slug}" references ${path}, which does not exist at ${baseBranchRef} — if net-new, declare {"path":"${path}","assumption":"creates"} in changes[] (${verb}); otherwise fix the typo or stale reference.`;
417
406
  }
418
407
 
419
408
  /**
420
409
  * Validates the generated ticket hierarchy and handles lifting cross-story dependencies.
421
410
  *
422
- * The returned tickets array carries two extra non-array properties:
423
- * - `findings` — structured sizing findings (hard + soft) keyed by the
424
- * three-layer sizing model. The bounded re-decomposition loop in
425
- * `/mandrel-plan` reads `findings.filter(f => f.severity === 'hard')` to decide
426
- * whether to re-prompt.
427
- * - `errors` — human-readable strings, one per hard finding. Non-empty
428
- * `errors[]` is the AC-visible "block normalization" signal; the legacy
429
- * hierarchy/cycle/freshness checks continue to throw, so callers that
430
- * only inspect the array shape are unaffected when no sizing
431
- * violations occur.
411
+ * The returned tickets array carries extra non-array properties:
412
+ * - `findings` — the advisory cross-Story conflict findings.
413
+ * - `errors` — human-readable strings, one per hard refusal: a `deletes`
414
+ * naming a path absent at base (prefixed `File assumption mismatch:`).
415
+ * The hierarchy/cycle/parse checks continue to throw.
416
+ * - `warnings` — the demoted footprint probes (Story #5312): a `creates`
417
+ * or `refactors-existing` mismatch, a goal/acceptance/verify path absent
418
+ * at base. Listed by the dry-run; the persist proceeds.
419
+ * - `normalizations` — the `refactors-existing`→`creates` rewrites applied.
432
420
  *
433
421
  * @param {object[]} tickets - Array of ticket objects parsed from LLM output.
434
422
  * @param {object} [opts]
435
- * @param {string} [opts.baseBranchRef] - When set, runs `validateAcFreshness` against this ref.
423
+ * @param {string} [opts.baseBranchRef] - When set, runs the base-branch probes against this ref.
436
424
  * @param {Function} [opts.gitRunner] - Optional git probe override.
437
- * @param {string} [opts.cwd] - Repo cwd (forwarded to the freshness gate).
438
- * @param {object} [opts.modelCapacity] - Programmatic override of `DEFAULT_MODEL_CAPACITY` (tests only — not read from `.agentrc.json`).
439
- * @param {object} [opts.conflictPolicy] - Severity controls for cross-Story conflict findings.
440
- * @param {boolean} [opts.conflictPolicy.failOnSharedEditors=false] - Upgrade `shared-editor` findings to `hard`.
441
- * @param {boolean} [opts.conflictPolicy.requireExplicitCrossStoryDeps=false] - Upgrade `implicit-cross-story-dep` findings to `hard`.
442
- * @returns {object[] & { findings: object[], errors: string[] }} Validated tickets with normalized dependencies and attached sizing + conflict findings.
425
+ * @param {string} [opts.cwd] - Repo cwd (forwarded to the probes).
426
+ * @returns {object[] & { findings: object[], errors: string[], warnings: string[], normalizations: object[] }}
443
427
  */
444
428
  /**
445
429
  * Internal helpers extracted from `validateAndNormalizeTickets` so each
@@ -603,13 +587,12 @@ function assertAcyclic(slugAdjacency) {
603
587
 
604
588
  function attachFindingsAndErrors(
605
589
  tickets,
606
- findings,
607
- errors,
608
- normalizations = [],
590
+ { findings, errors, warnings, normalizations },
609
591
  ) {
610
592
  for (const [key, value] of [
611
593
  ['findings', findings],
612
594
  ['errors', errors],
595
+ ['warnings', warnings],
613
596
  // Story #5265: the auto-normalizations the assumption gate applied. They
614
597
  // used to end at a `Logger.warn` and die with the process, so persist's
615
598
  // emitted result reported a plan whose declarations it had silently
@@ -660,25 +643,26 @@ export function validateAndNormalizeTickets(tickets, opts = {}) {
660
643
  ? makeMemoizedGitRunner(opts.gitRunner ?? defaultGitRunner)
661
644
  : null;
662
645
 
663
- // Refuse to decompose when any Task body or AC names a code-asset path
664
- // missing from the Epic's base branch tree. Skipped when the caller
665
- // omits `baseBranchRef` so legacy unit tests keep their existing
666
- // semantics; production call-sites always pass it.
646
+ const warnings = [];
647
+ // Story #5312: a goal / acceptance / verify path absent at base is a
648
+ // warning the dry-run lists, not a refusal. Skipped when the caller omits
649
+ // `baseBranchRef` so unit tests keep their semantics; production
650
+ // call-sites always pass it.
667
651
  if (opts.baseBranchRef) {
668
- validateAcFreshness({
669
- tickets,
670
- baseBranchRef: opts.baseBranchRef,
671
- gitRunner: sharedGitRunner,
672
- cwd: opts.cwd,
673
- });
652
+ warnings.push(
653
+ ...validateAcFreshness({
654
+ tickets,
655
+ baseBranchRef: opts.baseBranchRef,
656
+ gitRunner: sharedGitRunner,
657
+ cwd: opts.cwd,
658
+ }),
659
+ );
674
660
  }
675
661
 
676
662
  // Story #2636 — Phase 8 path-assumption gate. Cross-check every Story's
677
663
  // declared `{ path, assumption }` against the actual state of the base
678
- // branch and batch the mismatches per-Story into the validator's errors
679
- // envelope. Skipped when the caller omits `baseBranchRef` so legacy
680
- // unit tests keep their semantics; production call-sites always pass
681
- // it.
664
+ // branch. A `deletes` on an absent path batches into the validator's
665
+ // errors envelope; every other mismatch is a warning (Story #5312).
682
666
  let assumptionErrors = [];
683
667
  let assumptionNormalizations = [];
684
668
  if (opts.baseBranchRef) {
@@ -688,71 +672,23 @@ export function validateAndNormalizeTickets(tickets, opts = {}) {
688
672
  gitRunner: sharedGitRunner,
689
673
  cwd: opts.cwd,
690
674
  });
691
- // Auto-normalizations (#4496 fix 5) get their own prefix so the logged
692
- // warning is self-explanatory; everything else on the warnings channel
693
- // is a legacy-shape deprecation nudge.
694
- const normalizationWarnings = new Set(
695
- (assumptionReport.normalizations ?? []).map((n) => n.path),
696
- );
697
- for (const warning of assumptionReport.warnings) {
698
- const isNormalization = warning.includes('auto-normalized to "creates"');
699
- Logger.warn(
700
- `[ticket-validator] ${isNormalization ? 'assumption-normalized' : 'assumption-deprecation'}: ${warning}`,
701
- );
702
- }
703
- if (normalizationWarnings.size > 0) {
704
- Logger.warn(
705
- `[ticket-validator] ${normalizationWarnings.size} refactors-existing ` +
706
- 'declaration(s) on base-untracked path(s) auto-normalized to ' +
707
- '"creates" — the gate proceeds; update the plan declarations at ' +
708
- 'the next amend.',
709
- );
710
- }
675
+ warnings.push(...assumptionReport.warnings);
711
676
  assumptionErrors = assumptionReport.errors;
712
677
  assumptionNormalizations = assumptionReport.normalizations ?? [];
713
678
  }
714
679
 
715
- const sizingFindings = computeSizingFindings({
716
- stories,
717
- capacity: opts.modelCapacity,
718
- });
719
680
  // Cross-Story path-conflict pass observes the story-level depends_on
720
- // graph. Findings are appended to the same `findings` array consumed by
721
- // the decompose-loop's hard-finding gate; severity is controlled by
722
- // `opts.conflictPolicy`.
723
- const conflictFindings = computeConflictFindings({
724
- stories,
725
- policy: opts.conflictPolicy,
681
+ // graph. Every finding is advisory; the persist surfaces them and the
682
+ // plan summary renders the shared-editor class beside the wave table.
683
+ const findings = computeConflictFindings({ stories });
684
+ const errors = assumptionErrors.map((e) => `File assumption mismatch: ${e}`);
685
+
686
+ attachFindingsAndErrors(tickets, {
687
+ findings,
688
+ errors,
689
+ warnings,
690
+ normalizations: assumptionNormalizations,
726
691
  });
727
- // Advisory `## Spec` word-budget pass (Story #4723) — soft findings only,
728
- // never promoted to `errors[]`, so an over-budget Spec cannot fail the
729
- // persist. Runs after `assertStoryBodiesParse`, so string bodies parse.
730
- // This pass computes but does not report: the sole production caller always
731
- // runs the persist soft-finding surface, which reports every soft kind
732
- // uniformly. Warning here too made `spec-word-budget` the only kind logged
733
- // twice per run (Story #4907).
734
- const specBudgetFindings = computeSpecBudgetFindings({ stories });
735
- const findings = [
736
- ...sizingFindings,
737
- ...conflictFindings,
738
- ...specBudgetFindings,
739
- ];
740
- const errors = findings
741
- .filter((f) => f.severity === 'hard')
742
- .map((f) =>
743
- CONFLICT_KINDS.has(f.kind)
744
- ? renderHardConflictError(f)
745
- : renderHardFindingError(f),
746
- );
747
- // Append per-Story path-assumption mismatches (Story #2636) to the
748
- // hard-error list. The decompose loop already gates on
749
- // `errors.length > 0` to trigger a re-prompt, so the new check
750
- // participates in the same loop without bespoke wiring.
751
- for (const e of assumptionErrors) {
752
- errors.push(`File assumption mismatch: ${e}`);
753
- }
754
-
755
- attachFindingsAndErrors(tickets, findings, errors, assumptionNormalizations);
756
692
  return tickets;
757
693
  }
758
694
 
@@ -12,8 +12,10 @@
12
12
  * the entry as credited instead of telling the caller to spawn it.
13
13
  *
14
14
  * It only ever *reads*. Nothing here writes a capture stamp or an evidence
15
- * record — an entry that is not covered by a fresh stamp is reported
16
- * `spawn: true` and runs for real, so the credit can never manufacture a pass.
15
+ * record — an entry that is not covered by a fresh stamp or a credited
16
+ * `test` evidence record (Story #5313: a green bare `npm test` deposits one)
17
+ * is reported `spawn: true` and runs for real, so the credit can never
18
+ * manufacture a pass.
17
19
  *
18
20
  * @see .agents/scripts/lib/coverage-capture.js (`isCoverageFresh`)
19
21
  * @see .agents/scripts/lib/validation-evidence.js (`shouldSkip`)
@@ -23,7 +25,11 @@ import { getQuality, resolveConfig } from '../config-resolver.js';
23
25
  import { isCoverageFresh } from '../coverage-capture.js';
24
26
  import { gitSpawn } from '../git-utils.js';
25
27
  import { hasNpmScript, readPackageScripts } from '../npm-scripts.js';
26
- import { hashCommandConfig, shouldSkip } from '../validation-evidence.js';
28
+ import {
29
+ hashCommandConfig,
30
+ shouldSkip,
31
+ treeFingerprint,
32
+ } from '../validation-evidence.js';
27
33
 
28
34
  /**
29
35
  * The shape a `verify[]` array is supposed to have, stated once so the
@@ -169,6 +175,7 @@ export function resolveVerifyCredit(
169
175
  isCoverageFreshImpl = isCoverageFresh,
170
176
  shouldSkipImpl = shouldSkip,
171
177
  hashCommandConfigImpl = hashCommandConfig,
178
+ treeFingerprintImpl = treeFingerprint,
172
179
  gitSpawnFn = gitSpawn,
173
180
  } = deps;
174
181
 
@@ -189,43 +196,81 @@ export function resolveVerifyCredit(
189
196
  ? 'capture'
190
197
  : 'evidence';
191
198
 
199
+ let stampReason = null;
192
200
  if (mode === 'capture') {
193
201
  const freshness = isCoverageFreshImpl({
194
202
  coveragePath: crap.coveragePath,
195
203
  targetDirs: crap.targetDirs,
196
204
  cwd: worktree,
197
205
  });
198
- const fresh = freshness?.fresh === true;
199
- return {
200
- ...scoped,
201
- mode,
202
- credited: fresh,
203
- spawn: !fresh,
204
- reason: fresh ? 'capture-stamp-fresh' : (freshness?.reason ?? 'unknown'),
205
- };
206
+ if (freshness?.fresh === true) {
207
+ return {
208
+ ...scoped,
209
+ mode,
210
+ credited: true,
211
+ spawn: false,
212
+ reason: 'capture-stamp-fresh',
213
+ };
214
+ }
215
+ stampReason = freshness?.reason ?? 'unknown';
206
216
  }
207
217
 
218
+ // Story #5313 — a green bare `npm test` deposits the `test` evidence record
219
+ // close reads, so a stale (or absent) capture stamp is not the last word:
220
+ // the evidence keyspace is consulted in both modes before spawning. When it
221
+ // credits nothing either, capture mode reports the stamp's own reason.
222
+ const verdict = readTestEvidence({
223
+ storyId,
224
+ worktree,
225
+ cwd,
226
+ gitSpawnFn,
227
+ shouldSkipImpl,
228
+ hashCommandConfigImpl,
229
+ treeFingerprintImpl,
230
+ });
231
+ const credited = verdict.skip === true;
232
+ return {
233
+ ...scoped,
234
+ mode,
235
+ credited,
236
+ spawn: !credited,
237
+ reason: credited ? verdict.reason : (stampReason ?? verdict.reason),
238
+ };
239
+ }
240
+
241
+ /**
242
+ * Consult the `test` evidence record for the worktree's HEAD — the record a
243
+ * green bare `npm test` deposits (Story #5313) and `evidence-gate.js` wrote
244
+ * before it. Total: an unreadable HEAD is `no-head`, never a credit.
245
+ *
246
+ * @param {{ storyId: number|string, worktree: string, cwd: string, gitSpawnFn: Function, shouldSkipImpl: Function, hashCommandConfigImpl: Function, treeFingerprintImpl: Function }} args
247
+ * @returns {{ skip: boolean, reason: string }}
248
+ */
249
+ function readTestEvidence({
250
+ storyId,
251
+ worktree,
252
+ cwd,
253
+ gitSpawnFn,
254
+ shouldSkipImpl,
255
+ hashCommandConfigImpl,
256
+ treeFingerprintImpl,
257
+ }) {
208
258
  const headSha = readHeadSha(worktree, gitSpawnFn);
209
- if (!headSha) {
210
- return { ...scoped, mode, credited: false, spawn: true, reason: 'no-head' };
211
- }
212
- const [cmd, ...args] = command.split(/\s+/).filter(Boolean);
213
- const verdict = shouldSkipImpl(
259
+ if (!headSha) return { skip: false, reason: 'no-head' };
260
+ return shouldSkipImpl(
214
261
  {
215
262
  storyId,
216
263
  gateName: 'test',
217
264
  currentSha: headSha,
218
- configHash: hashCommandConfigImpl({ cmd, args, cwd: worktree }),
265
+ configHash: hashCommandConfigImpl({
266
+ cmd: 'npm',
267
+ args: ['test'],
268
+ cwd: worktree,
269
+ }),
270
+ inputFingerprint: treeFingerprintImpl(worktree, gitSpawnFn),
219
271
  },
220
272
  { cwd, standalone: true },
221
273
  );
222
- return {
223
- ...scoped,
224
- mode,
225
- credited: verdict.skip === true,
226
- spawn: verdict.skip !== true,
227
- reason: verdict.reason,
228
- };
229
274
  }
230
275
 
231
276
  /**
@@ -4,7 +4,7 @@
4
4
  * inference a failing dry-run surfaces (Story #4684).
5
5
  *
6
6
  * The problem this closes: deterministic format rules (structured `## Changes`
7
- * bullet shape, verify-tier suffixes) used to be discovered only as persist
7
+ * bullet shape, non-empty sections) used to be discovered only as persist
8
8
  * dry-run failures — every miss cost a full re-author round-trip at
9
9
  * resident-context prices. This module is the single home for two remedies:
10
10
  *
@@ -13,11 +13,16 @@
13
13
  * them into the story-author system prompt so the first draft is
14
14
  * lint-clean by construction; a test enumerates the registry against the
15
15
  * rendered prompt (Story #4684 AC-1).
16
- * 2. `suggestPathEntryFix` / `suggestVerifyFix` — the mechanical rewrites.
17
- * `story-body.js` (`parsePathEntry`) and `task-body-validator.js`
18
- * (`collectChangesErrors` / `collectVerifyErrors`) call them so a failing
19
- * lint emits the corrected form ready to paste rather than a bare reject
20
- * (AC-2).
16
+ * 2. `suggestPathEntryFix` — the mechanical rewrite. `story-body.js`
17
+ * (`parsePathEntry`) and `task-body-validator.js`
18
+ * (`collectChangesErrors`) call it so a failing lint emits the corrected
19
+ * form ready to paste rather than a bare reject (AC-2), and the persist
20
+ * dry-run applies the same salvage for real (Story #5312,
21
+ * `plan-persist/changes-repair.js`).
22
+ *
23
+ * Story #5312 deleted the `verify-tier-suffix` / `verify-manual-reason` lints
24
+ * with the tier suffix itself: a `verify[]` entry is any command, and the
25
+ * `manual:<reason>` escape is gone with the rule it escaped.
21
26
  *
22
27
  * Import hygiene: this module imports only the cycle-free
23
28
  * `file-assumption-enum.js` leaf. It must NOT import `story-body.js` or
@@ -27,20 +32,6 @@
27
32
 
28
33
  import { FILE_ASSUMPTION_VALUES } from '../orchestration/file-assumption-enum.js';
29
34
 
30
- /**
31
- * Canonical testing-tier vocabulary an inferred verify suffix may name. Kept in
32
- * sync with `task-body-validator.js`'s `VERIFY_TIER_VALUES` by a test rather
33
- * than an import (importing that module here would create a cycle).
34
- *
35
- * @type {readonly ['unit','contract','e2e','validate']}
36
- */
37
- const INFERABLE_VERIFY_TIERS = Object.freeze([
38
- 'unit',
39
- 'contract',
40
- 'e2e',
41
- 'validate',
42
- ]);
43
-
44
35
  /**
45
36
  * The default assumption a mechanical `## Changes` auto-fix proposes. Most
46
37
  * bare-path bullets an author drops are in-place edits, so `refactors-existing`
@@ -54,48 +45,6 @@ const DEFAULT_SUGGESTED_ASSUMPTION = 'refactors-existing';
54
45
  // a false positive only produces an unhelpful (still-valid) fix-it string.
55
46
  const PATH_LIKE_RE = /[\w@*-]*[/.][\w@./*-]+/;
56
47
 
57
- /**
58
- * Infer the testing tier a bare `verify[]` command implies, when it is
59
- * unambiguous. Returns `null` when no confident inference is possible (the
60
- * author must then choose the tier themselves — the lint still fires, just
61
- * without a fix-it).
62
- *
63
- * @param {unknown} command
64
- * @returns {'unit'|'contract'|'e2e'|'validate'|null}
65
- */
66
- function inferVerifyTier(command) {
67
- if (typeof command !== 'string') return null;
68
- const c = command.toLowerCase();
69
- if (/\bvalidate\b/.test(c)) return 'validate';
70
- if (/playwright|\.spec\.|\be2e\b/.test(c)) return 'e2e';
71
- if (/\.test\.|node --test|node:test|\bvitest\b|\bjest\b/.test(c)) {
72
- return 'unit';
73
- }
74
- if (/\bcontract\b/.test(c)) return 'contract';
75
- return null;
76
- }
77
-
78
- /**
79
- * Propose the corrected form of a `verify[]` entry that is missing its tier
80
- * suffix, when the tier is inferable from the command. Returns `null` when the
81
- * entry is a `manual:` escape, is empty, or the tier cannot be inferred.
82
- *
83
- * @param {unknown} entry
84
- * @returns {string|null} e.g. `"npm run validate (validate)"`.
85
- */
86
- export function suggestVerifyFix(entry) {
87
- if (typeof entry !== 'string') return null;
88
- const trimmed = entry.trim();
89
- if (trimmed === '' || trimmed.startsWith('manual:')) return null;
90
- const tier = inferVerifyTier(trimmed);
91
- if (tier === null) return null;
92
- // Drop any trailing (…) — a wrong/partial tier suffix — before appending the
93
- // inferred one, so `npm test (smoke)` becomes `npm test (unit)` not a double.
94
- const base = trimmed.replace(/\s*\([^)]*\)\s*$/, '').trim();
95
- if (base === '') return null;
96
- return `${base} (${tier})`;
97
- }
98
-
99
48
  /**
100
49
  * Propose the canonical `{ path, assumption }` object form for a `## Changes` /
101
50
  * `## References` bullet an author wrote as a bare path string (or a humanized
@@ -140,9 +89,8 @@ export function suggestPathEntryFix(raw) {
140
89
  /**
141
90
  * The enumerated deterministic lints that can reject an authored Story body at
142
91
  * persist time. Each carries a concrete example so the story-author prompt can
143
- * state the requirement example-first (Story #4684 AC-1). The two `autoFixable`
144
- * lints are the mechanical rewrites whose dry-run failure carries the corrected
145
- * form (AC-2).
92
+ * state the requirement example-first (Story #4684 AC-1). The one `autoFixable`
93
+ * lint is the mechanical rewrite the dry-run applies (Story #5312).
146
94
  *
147
95
  * @type {ReadonlyArray<BodyFormatLint>}
148
96
  */
@@ -179,29 +127,11 @@ export const BODY_FORMAT_LINTS = Object.freeze([
179
127
  goodExample: '- {"path": "src/app.js", "assumption": "creates"}',
180
128
  autoFixable: false,
181
129
  },
182
- {
183
- id: 'verify-tier-suffix',
184
- summary:
185
- 'Every `verify[]` entry MUST end with a tier in parentheses — one of ' +
186
- `(${INFERABLE_VERIFY_TIERS.join(' | ')}) — or be a \`manual:<reason>\` escape.`,
187
- badExample: 'npm test -- src/app.test.js',
188
- goodExample: 'npm test -- src/app.test.js (unit)',
189
- autoFixable: true,
190
- },
191
130
  {
192
131
  id: 'verify-non-empty',
193
- summary:
194
- 'A Story MUST list at least one `verify[]` entry (use `manual:<reason>` only when truly unverifiable in isolation).',
132
+ summary: 'A Story MUST list at least one `verify[]` entry.',
195
133
  badExample: '"verify": []',
196
- goodExample: '"verify": ["npm run validate (validate)"]',
197
- autoFixable: false,
198
- },
199
- {
200
- id: 'verify-manual-reason',
201
- summary:
202
- 'A `manual:` verify entry MUST carry a reason after the colon; a bare `manual:` is rejected.',
203
- badExample: 'manual:',
204
- goodExample: 'manual: copy-only edit an auditor eyeballs',
134
+ goodExample: '"verify": ["npm run validate"]',
205
135
  autoFixable: false,
206
136
  },
207
137
  {