mandrel 2.25.0 → 2.26.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 (131) hide show
  1. package/.agents/agents/acceptance-critic.md +10 -6
  2. package/.agents/audit-checklists/baselines.md +21 -0
  3. package/.agents/docs/quality-gates.md +80 -18
  4. package/.agents/docs/workflows.md +3 -1
  5. package/.agents/instructions.md +1 -1
  6. package/.agents/schemas/audit-rules.json +15 -0
  7. package/.agents/schemas/baselines/audit-baselines-envelope.schema.json +242 -0
  8. package/.agents/schemas/baselines/baseline-envelope.schema.json +4 -0
  9. package/.agents/schemas/baselines/crap.schema.json +8 -0
  10. package/.agents/schemas/model-attribution.schema.json +4 -0
  11. package/.agents/scripts/acceptance-eval.js +89 -6
  12. package/.agents/scripts/audit-baselines.js +136 -0
  13. package/.agents/scripts/check-arch-cycles.js +12 -93
  14. package/.agents/scripts/check-baseline-drift.js +16 -3
  15. package/.agents/scripts/check-baselines.js +19 -3
  16. package/.agents/scripts/check-cyclomatic.js +214 -0
  17. package/.agents/scripts/check-schema-references.js +392 -0
  18. package/.agents/scripts/check-test-temp-hygiene.js +38 -1
  19. package/.agents/scripts/check-workflow-timeouts.js +291 -0
  20. package/.agents/scripts/diagnose-friction.js +85 -19
  21. package/.agents/scripts/lib/audit-baselines/engine.js +177 -0
  22. package/.agents/scripts/lib/audit-baselines/gate-surface.js +63 -0
  23. package/.agents/scripts/lib/audit-baselines/headroom.js +72 -0
  24. package/.agents/scripts/lib/audit-baselines/hotspots.js +69 -0
  25. package/.agents/scripts/lib/audit-baselines/kinds.js +313 -0
  26. package/.agents/scripts/lib/audit-baselines/outliers.js +100 -0
  27. package/.agents/scripts/lib/audit-baselines/read.js +87 -0
  28. package/.agents/scripts/lib/audit-baselines/staleness.js +123 -0
  29. package/.agents/scripts/lib/audit-baselines/surface-entry.js +106 -0
  30. package/.agents/scripts/lib/audit-baselines/trend.js +125 -0
  31. package/.agents/scripts/lib/audit-baselines/weights.js +193 -0
  32. package/.agents/scripts/lib/audit-suite/index.js +0 -5
  33. package/.agents/scripts/lib/audit-suite/selector.js +9 -62
  34. package/.agents/scripts/lib/audit-to-stories/audit-lenses.js +1 -0
  35. package/.agents/scripts/lib/baseline-schema-registry.js +13 -1
  36. package/.agents/scripts/lib/baselines/diff-scope-cli.js +22 -160
  37. package/.agents/scripts/lib/baselines/duplication-scanner.js +27 -0
  38. package/.agents/scripts/lib/baselines/git-base.js +26 -4
  39. package/.agents/scripts/lib/baselines/kinds/crap.js +112 -15
  40. package/.agents/scripts/lib/baselines/refresh-service.js +69 -11
  41. package/.agents/scripts/lib/baselines/scope.js +39 -90
  42. package/.agents/scripts/lib/baselines/writer.js +16 -11
  43. package/.agents/scripts/lib/changed-files.js +8 -1
  44. package/.agents/scripts/lib/cli-args.js +115 -1
  45. package/.agents/scripts/lib/close-validation/runner.js +70 -25
  46. package/.agents/scripts/lib/crap-engine.js +32 -13
  47. package/.agents/scripts/lib/crap-method-identity.js +153 -0
  48. package/.agents/scripts/lib/crap-utils.js +13 -0
  49. package/.agents/scripts/lib/cyclomatic-ceiling.js +265 -0
  50. package/.agents/scripts/lib/feedback-loop/audit-results-graduator.js +0 -2
  51. package/.agents/scripts/lib/feedback-loop/prior-feedback-fetcher.js +0 -2
  52. package/.agents/scripts/lib/feedback-loop/retro-proposals-graduator.js +0 -2
  53. package/.agents/scripts/lib/git-utils.js +136 -80
  54. package/.agents/scripts/lib/import-graph.js +156 -0
  55. package/.agents/scripts/lib/observability/runtime-friction.js +17 -2
  56. package/.agents/scripts/lib/observability/source-classifier.js +175 -2
  57. package/.agents/scripts/lib/orchestration/ceremony-routing.js +17 -12
  58. package/.agents/scripts/lib/orchestration/check-baselines/phases/compare.js +36 -6
  59. package/.agents/scripts/lib/orchestration/check-baselines/phases/evaluate.js +5 -0
  60. package/.agents/scripts/lib/orchestration/check-baselines/phases/floors.js +12 -1
  61. package/.agents/scripts/lib/orchestration/check-baselines/phases/report.js +8 -1
  62. package/.agents/scripts/lib/orchestration/git-cleanup/phases/phase-drivers.js +10 -5
  63. package/.agents/scripts/lib/orchestration/git-cleanup/phases/render.js +39 -3
  64. package/.agents/scripts/lib/orchestration/plan-context.js +119 -66
  65. package/.agents/scripts/lib/orchestration/plan-persist/fan-out-gate.js +31 -5
  66. package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +209 -109
  67. package/.agents/scripts/lib/orchestration/plan-persist/story-ops.js +48 -12
  68. package/.agents/scripts/lib/orchestration/plan-persist/supersede-ops.js +79 -22
  69. package/.agents/scripts/lib/orchestration/plan-text-hygiene.js +51 -20
  70. package/.agents/scripts/lib/orchestration/planning/authoring-context.js +70 -74
  71. package/.agents/scripts/lib/orchestration/planning/memory-pool-advisory.js +231 -0
  72. package/.agents/scripts/lib/orchestration/resolve-stories.js +18 -17
  73. package/.agents/scripts/lib/orchestration/run-epilogue.js +12 -0
  74. package/.agents/scripts/lib/orchestration/single-story-close/phases/confirm-merge.js +29 -3
  75. package/.agents/scripts/lib/orchestration/single-story-close/phases/normalize-pr-title.js +6 -6
  76. package/.agents/scripts/lib/orchestration/single-story-close/phases/options.js +42 -38
  77. package/.agents/scripts/lib/orchestration/single-story-close/phases/push.js +6 -1
  78. package/.agents/scripts/lib/orchestration/single-story-close/runner.js +245 -140
  79. package/.agents/scripts/lib/orchestration/spec-budget.js +16 -5
  80. package/.agents/scripts/lib/orchestration/story-follow-ups.js +182 -95
  81. package/.agents/scripts/lib/orchestration/ticket-validator-conflicts.js +22 -0
  82. package/.agents/scripts/lib/orchestration/ticket-validator.js +5 -11
  83. package/.agents/scripts/lib/orchestration/ticketing/reads.js +4 -4
  84. package/.agents/scripts/lib/story-adjacency.js +3 -3
  85. package/.agents/scripts/lib/test-runner-contract.js +134 -0
  86. package/.agents/scripts/lib/test-tiers.js +11 -2
  87. package/.agents/scripts/lib/util/concurrent-map.js +17 -0
  88. package/.agents/scripts/lib/util/parse-id-list.js +103 -0
  89. package/.agents/scripts/lib/wave-runner/live-probe.js +24 -14
  90. package/.agents/scripts/lib/wave-runner/ready-set.js +189 -42
  91. package/.agents/scripts/lib/workers/combined-mi-crap-worker.js +4 -10
  92. package/.agents/scripts/lib/workers/crap-worker.js +2 -10
  93. package/.agents/scripts/lib/workers/maintainability-report-worker.js +4 -10
  94. package/.agents/scripts/lib/workers/maintainability-worker.js +4 -10
  95. package/.agents/scripts/lib/workers/serve-worker-messages.js +35 -0
  96. package/.agents/scripts/lib/worktree/git-hooks.js +206 -0
  97. package/.agents/scripts/lib/worktree/lifecycle/creation.js +6 -0
  98. package/.agents/scripts/lib/worktree-manager.js +14 -0
  99. package/.agents/scripts/plan-run-epilogue.js +17 -5
  100. package/.agents/scripts/providers/github/tickets.js +33 -10
  101. package/.agents/scripts/provision-git-hooks.js +85 -0
  102. package/.agents/scripts/quality-preview.js +112 -28
  103. package/.agents/scripts/resolve-stories.js +4 -1
  104. package/.agents/scripts/run-coverage.js +86 -35
  105. package/.agents/scripts/run-lint.js +20 -0
  106. package/.agents/scripts/run-tests.js +26 -36
  107. package/.agents/scripts/single-story-close.js +28 -2
  108. package/.agents/scripts/single-story-confirm-merge.js +22 -6
  109. package/.agents/scripts/stories-wave-tick.js +214 -38
  110. package/.agents/scripts/update-coverage-baseline.js +34 -4
  111. package/.agents/scripts/update-duplication-baseline.js +209 -83
  112. package/.agents/scripts/validate-docs-freshness.js +1 -0
  113. package/.agents/skills/core/diagnose-friction/SKILL.md +4 -1
  114. package/.agents/skills/core/gates-and-baselines/SKILL.md +17 -11
  115. package/.agents/skills/skills.index.json +2 -2
  116. package/.agents/workflows/audit-baselines.md +289 -0
  117. package/.agents/workflows/audit-navigability.md +5 -4
  118. package/.agents/workflows/deliver.md +13 -4
  119. package/.agents/workflows/helpers/acceptance-self-eval.md +47 -10
  120. package/.agents/workflows/helpers/code-quality-guardrails.md +9 -2
  121. package/.agents/workflows/helpers/deliver-digest.md +41 -21
  122. package/.agents/workflows/helpers/deliver-reference.md +77 -1
  123. package/.agents/workflows/helpers/deliver-story-reference.md +47 -6
  124. package/.agents/workflows/helpers/plan-reference.md +15 -5
  125. package/.agents/workflows/memory-consolidate.md +116 -0
  126. package/.agents/workflows/plan.md +3 -0
  127. package/README.md +13 -6
  128. package/docs/CHANGELOG.md +64 -0
  129. package/package.json +9 -4
  130. package/.agents/schemas/friction-event.schema.json +0 -56
  131. package/.agents/scripts/lib/feedback-loop/memory-freshness.js +0 -707
@@ -31,15 +31,13 @@ import {
31
31
  renderAcceptanceSpecSystemPrompt,
32
32
  renderTechSpecSystemPrompt,
33
33
  } from '../templates/spec-author-prompts.js';
34
- import { concurrentMap } from '../util/concurrent-map.js';
34
+ import { concurrentMap, FANOUT_CONCURRENCY } from '../util/concurrent-map.js';
35
35
  import { buildComplexitySignals } from './complexity-gate.js';
36
36
  import { parseDeliverySlicingTable } from './consolidation-precondition.js';
37
37
  import { buildDocsDigest } from './docs-digest.js';
38
38
  import { buildAuthoringContext } from './planning/authoring-context.js';
39
39
  import { buildDecomposerSystemPrompt } from './planning/decomposer-context.js';
40
40
 
41
- /** Bounded concurrency for `--tickets` source-ticket hydration. */
42
- const SOURCE_TICKET_FETCH_CONCURRENCY = 4;
43
41
  /**
44
42
  * Envelope byte ceiling (regression guard for the design's named PR2 risk:
45
43
  * two envelopes → one bigger one). This is the **only** live bound on
@@ -229,7 +227,8 @@ export function renderStoriesTemplate({ complexitySignals = null } = {}) {
229
227
  'codes, security invariants, and load-bearing constraints with ' +
230
228
  'their why. Implementation choices belong to the deliverer unless ' +
231
229
  'load-bearing. No per-file behavior paragraphs, no current-state ' +
232
- 'narration. Keep it under ~250 words (soft advisory budget). ' +
230
+ 'narration. Aim for ~250 words; an advisory warning fires past 350, ' +
231
+ 'and it never fails the persist. ' +
233
232
  'Delete this field when acceptance[] carries the whole contract.',
234
233
  changes: buildTemplateChanges(complexitySignals),
235
234
  non_goals: [],
@@ -813,6 +812,84 @@ async function searchStoryDuplicates({
813
812
  }
814
813
  }
815
814
 
815
+ /**
816
+ * Gather the three independent envelope inputs — the open-Story duplicate
817
+ * search, the folded authoring context, and the inline docs digest — under
818
+ * bounded concurrency (Story #4952).
819
+ *
820
+ * None of the three reads a value the others produce, so the result is a pure
821
+ * function of `seed` and the injected config: the assembled envelope is
822
+ * **byte-identical** to the serial build for the same inputs, whichever order
823
+ * the three happen to settle in. `concurrentMap` preserves input order, so the
824
+ * destructuring below is positional and stable.
825
+ *
826
+ * `docsContextFiles` is emptied for the `buildAuthoringContext` call: the
827
+ * per-plan digest-file path needs a plan id that does not exist yet — the
828
+ * inline digest gathered alongside it replaces that pointer.
829
+ *
830
+ * @param {{
831
+ * seed: string,
832
+ * epicTitle: string,
833
+ * excludeIds?: Iterable<number|string>,
834
+ * provider: object,
835
+ * config: object,
836
+ * settings: object,
837
+ * cwd?: string,
838
+ * }} args
839
+ * @returns {Promise<{
840
+ * duplicates: Array<object>,
841
+ * authoring: object,
842
+ * docsContext: { mode: 'digest-inline', digest: string }|null,
843
+ * }>}
844
+ */
845
+ async function gatherEnvelopeInputs({
846
+ seed,
847
+ epicTitle,
848
+ excludeIds = [],
849
+ provider,
850
+ config,
851
+ settings,
852
+ cwd,
853
+ }) {
854
+ const paths = settings?.paths ?? {};
855
+ const [duplicates, authoring, inlineDigest] = await concurrentMap(
856
+ [
857
+ () => searchStoryDuplicates({ seed, provider, config, excludeIds }),
858
+ () =>
859
+ buildAuthoringContext(
860
+ 0,
861
+ /* provider (unused behind the prefetch seam) */ {},
862
+ { ...settings, docsContextFiles: [] },
863
+ {
864
+ epic: { id: 0, title: epicTitle, body: seed },
865
+ github: config.github ?? null,
866
+ cwd,
867
+ },
868
+ ),
869
+ () =>
870
+ buildDocsDigest({
871
+ docsContextFiles: settings?.docsContextFiles,
872
+ docsRoot: paths.docsRoot,
873
+ }),
874
+ ],
875
+ (gather) => gather(),
876
+ // The per-mode envelope gathers (Story #4952): the duplicate search, the
877
+ // authoring-context fold and the docs digest have no data dependency on
878
+ // one another, so their serialization was incidental and `/plan` paid it
879
+ // with the operator waiting at Gate #1.
880
+ { concurrency: FANOUT_CONCURRENCY },
881
+ );
882
+
883
+ return {
884
+ duplicates,
885
+ authoring,
886
+ docsContext:
887
+ inlineDigest == null
888
+ ? null
889
+ : { mode: 'digest-inline', digest: inlineDigest },
890
+ };
891
+ }
892
+
816
893
  /**
817
894
  * Build the seed-file (ideation) envelope. No parent ticket
818
895
  * exists yet — creation moves to the persist half — so the open-Story
@@ -836,37 +913,17 @@ async function buildSeedFileModeEnvelope({
836
913
  );
837
914
  }
838
915
 
839
- const duplicates = await searchStoryDuplicates({
916
+ // Dup search, the authoring-context fold grounded in the seed prose, and the
917
+ // inline docs digest are independent — gathered concurrently (Story #4952).
918
+ const { duplicates, authoring, docsContext } = await gatherEnvelopeInputs({
840
919
  seed: content,
920
+ epicTitle: seedFilePath ?? 'seed',
841
921
  provider,
842
922
  config,
923
+ settings,
924
+ cwd,
843
925
  });
844
926
 
845
- // Fold the authoring-context builders grounded in the seed prose.
846
- // `docsContextFiles` is emptied for this call: the per-plan digest-file
847
- // path needs a plan id that does not exist yet — the inline digest
848
- // below replaces it.
849
- const authoring = await buildAuthoringContext(
850
- 0,
851
- /* provider (unused behind the prefetch seam) */ {},
852
- { ...settings, docsContextFiles: [] },
853
- {
854
- epic: { id: 0, title: seedFilePath ?? 'seed', body: content },
855
- github: config.github ?? null,
856
- cwd,
857
- },
858
- );
859
-
860
- const paths = settings?.paths ?? {};
861
- const inlineDigest = await buildDocsDigest({
862
- docsContextFiles: settings?.docsContextFiles,
863
- docsRoot: paths.docsRoot,
864
- });
865
- const docsContext =
866
- inlineDigest == null
867
- ? null
868
- : { mode: 'digest-inline', digest: inlineDigest };
869
-
870
927
  const limits = getLimits(config);
871
928
  const heuristics = resolveRiskHeuristics(config);
872
929
 
@@ -892,7 +949,7 @@ async function buildSeedFileModeEnvelope({
892
949
  docsContext,
893
950
  bddRunner: authoring.bddRunner,
894
951
  bddScenarios: authoring.bddScenarios,
895
- memoryFreshness: authoring.memoryFreshness,
952
+ memoryPoolAdvisory: authoring.memoryPoolAdvisory,
896
953
  priorFeedback: authoring.priorFeedback,
897
954
  ticketSchema: TICKET_SCHEMA_DESCRIPTOR,
898
955
  maxTickets: limits.maxTickets,
@@ -975,7 +1032,8 @@ async function fetchSourceTickets(ticketIds, provider) {
975
1032
  state: ticket.state ?? undefined,
976
1033
  };
977
1034
  },
978
- { concurrency: SOURCE_TICKET_FETCH_CONCURRENCY },
1035
+ // `--tickets` source-ticket hydration: one independent read per id.
1036
+ { concurrency: FANOUT_CONCURRENCY },
979
1037
  );
980
1038
  }
981
1039
 
@@ -1001,37 +1059,18 @@ async function buildTicketsModeEnvelope({
1001
1059
  .map((t) => `# ${t.title}\n\n${t.body}`)
1002
1060
  .join('\n\n---\n\n');
1003
1061
 
1004
- const duplicates = await searchStoryDuplicates({
1062
+ // Same three independent gathers as seed-file mode, concurrent under the
1063
+ // same bound (Story #4952); only the source-ticket hydration above is a
1064
+ // genuine data dependency, because `seed` is derived from it.
1065
+ const { duplicates, authoring, docsContext } = await gatherEnvelopeInputs({
1005
1066
  seed,
1067
+ epicTitle: sourceTickets[0]?.title ?? 'tickets',
1068
+ excludeIds: ticketIds,
1006
1069
  provider,
1007
1070
  config,
1008
- excludeIds: ticketIds,
1009
- });
1010
-
1011
- const authoring = await buildAuthoringContext(
1012
- 0,
1013
- {},
1014
- { ...settings, docsContextFiles: [] },
1015
- {
1016
- epic: {
1017
- id: 0,
1018
- title: sourceTickets[0]?.title ?? 'tickets',
1019
- body: seed,
1020
- },
1021
- github: config.github ?? null,
1022
- cwd,
1023
- },
1024
- );
1025
-
1026
- const paths = settings?.paths ?? {};
1027
- const inlineDigest = await buildDocsDigest({
1028
- docsContextFiles: settings?.docsContextFiles,
1029
- docsRoot: paths.docsRoot,
1071
+ settings,
1072
+ cwd,
1030
1073
  });
1031
- const docsContext =
1032
- inlineDigest == null
1033
- ? null
1034
- : { mode: 'digest-inline', digest: inlineDigest };
1035
1074
 
1036
1075
  const limits = getLimits(config);
1037
1076
  const heuristics = resolveRiskHeuristics(config);
@@ -1053,7 +1092,7 @@ async function buildTicketsModeEnvelope({
1053
1092
  docsContext,
1054
1093
  bddRunner: authoring.bddRunner,
1055
1094
  bddScenarios: authoring.bddScenarios,
1056
- memoryFreshness: authoring.memoryFreshness,
1095
+ memoryPoolAdvisory: authoring.memoryPoolAdvisory,
1057
1096
  priorFeedback: authoring.priorFeedback,
1058
1097
  ticketSchema: TICKET_SCHEMA_DESCRIPTOR,
1059
1098
  maxTickets: limits.maxTickets,
@@ -1147,12 +1186,26 @@ async function buildAmendmentModeEnvelope({
1147
1186
 
1148
1187
  const heuristics = resolveRiskHeuristics(config);
1149
1188
  const limits = getLimits(config);
1150
- const duplicates = await searchStoryDuplicates({
1151
- seed: priorBody,
1152
- provider,
1153
- config,
1154
- excludeIds: [amendsId],
1155
- });
1189
+ // Story #4952 this builder's independent-gather set has exactly one
1190
+ // member. `provider.getTicket` above is a hard data dependency (the prior
1191
+ // body IS the seed), and the mode deliberately carries no authoring-context
1192
+ // fold and no docs digest — the prior artifacts are the grounding. It still
1193
+ // goes through the same bounded gather as the other two builders so one file
1194
+ // does not carry two ways of gathering independent envelope inputs.
1195
+ const [duplicates] = await concurrentMap(
1196
+ [
1197
+ () =>
1198
+ searchStoryDuplicates({
1199
+ seed: priorBody,
1200
+ provider,
1201
+ config,
1202
+ excludeIds: [amendsId],
1203
+ }),
1204
+ ],
1205
+ (gather) => gather(),
1206
+ // Same independent-gather fan-out as the seed-mode envelope above.
1207
+ { concurrency: FANOUT_CONCURRENCY },
1208
+ );
1156
1209
 
1157
1210
  return {
1158
1211
  mode: 'amends',
@@ -7,6 +7,7 @@
7
7
 
8
8
  import { Logger } from '../../Logger.js';
9
9
  import {
10
+ CONFLICT_KINDS,
10
11
  renderFanOutEvidence,
11
12
  renderFanOutRemedy,
12
13
  renderHardConflictError,
@@ -54,6 +55,15 @@ export function enforceFanOutGate(
54
55
  }
55
56
 
56
57
  /**
58
+ * Report every soft finding the validator produced, each under its own kind.
59
+ *
60
+ * Only the {@link CONFLICT_KINDS} are cross-Story conflicts. The advisory
61
+ * kinds — `spec-word-budget` and the sizing findings — are single-Story
62
+ * nudges, and announcing them as conflicts overstated them and taught readers
63
+ * to discount the whole channel (Story #4907). `fan-out-warning` is excluded
64
+ * throughout: {@link enforceFanOutGate} owns it and has already either thrown
65
+ * or logged the override.
66
+ *
57
67
  * @param {object[]} findings
58
68
  * @param {string} [tag]
59
69
  */
@@ -62,10 +72,26 @@ export function surfaceSoftConflictFindings(findings, tag = 'plan-persist') {
62
72
  (f) => f?.severity === 'soft' && f?.kind !== 'fan-out-warning',
63
73
  );
64
74
  if (soft.length === 0) return;
65
- Logger.warn(
66
- `[${tag}] ${soft.length} soft cross-Story conflict finding(s) — review before approving the plan:`,
67
- );
68
- for (const finding of soft) {
69
- Logger.warn(`[${tag}] soft conflict: ${renderHardConflictError(finding)}`);
75
+ const conflicts = soft.filter((f) => CONFLICT_KINDS.has(f?.kind));
76
+ const advisories = soft.filter((f) => !CONFLICT_KINDS.has(f?.kind));
77
+ if (conflicts.length > 0) {
78
+ Logger.warn(
79
+ `[${tag}] ${conflicts.length} soft cross-Story conflict finding(s) — review before approving the plan:`,
80
+ );
81
+ for (const finding of conflicts) {
82
+ Logger.warn(
83
+ `[${tag}] soft conflict: ${renderHardConflictError(finding)}`,
84
+ );
85
+ }
86
+ }
87
+ if (advisories.length > 0) {
88
+ Logger.warn(
89
+ `[${tag}] ${advisories.length} advisory finding(s) — the persist proceeds:`,
90
+ );
91
+ for (const finding of advisories) {
92
+ Logger.warn(
93
+ `[${tag}] advisory (${finding.kind}): ${renderHardConflictError(finding)}`,
94
+ );
95
+ }
70
96
  }
71
97
  }
@@ -38,11 +38,14 @@
38
38
  */
39
39
 
40
40
  import { rm } from 'node:fs/promises';
41
- import path from 'node:path';
42
41
  import { getLimits, PROJECT_ROOT } from '../../config-resolver.js';
43
42
  import { gitSpawn } from '../../git-utils.js';
44
43
  import { Logger } from '../../Logger.js';
45
44
  import { sweepTempRetention } from '../../temp-retention.js';
45
+ import {
46
+ concurrentMap,
47
+ FANOUT_CONCURRENCY,
48
+ } from '../../util/concurrent-map.js';
46
49
  import {
47
50
  deriveStoryShape,
48
51
  LITE_ROUTE_LABEL,
@@ -410,6 +413,198 @@ export async function reapStalePlanDirs({
410
413
  return { reaped: result.purged.map((entry) => entry.path) };
411
414
  }
412
415
 
416
+ /**
417
+ * Fail closed on a payload that cannot be persisted, and warn on an
418
+ * explicitly-authorized over-budget one. Extracted from `runPlanPersist`
419
+ * (Story #4926) so the entry point carries the flow, not the guards.
420
+ *
421
+ * @param {unknown} rawStories
422
+ * @param {{ maxTickets: number, allowOverBudget: boolean }} limits
423
+ * @returns {void}
424
+ * @throws {Error} On an empty payload or an unauthorized over-budget one.
425
+ */
426
+ function assertPersistablePlan(rawStories, { maxTickets, allowOverBudget }) {
427
+ if (!Array.isArray(rawStories) || rawStories.length === 0) {
428
+ throw new Error(
429
+ '[plan-persist] stories payload must be a non-empty array ' +
430
+ '(--stories <file>). Default is one Story.',
431
+ );
432
+ }
433
+ if (rawStories.length <= maxTickets) return;
434
+ if (!allowOverBudget) {
435
+ throw new Error(
436
+ `[plan-persist] Stories (${rawStories.length}) exceed the reviewability ` +
437
+ `budget (${maxTickets}). Re-scope, or rerun with --allow-over-budget.`,
438
+ );
439
+ }
440
+ Logger.warn(
441
+ `[plan-persist] Persisting an over-budget plan: ${rawStories.length} ` +
442
+ `Stories vs. budget ${maxTickets} (--allow-over-budget).`,
443
+ );
444
+ }
445
+
446
+ /**
447
+ * Enforce the draft-reachability critic: orphans throw, a skip is ledgered.
448
+ *
449
+ * @param {{ status: string, reasons: string[], orphans?: object[] }} reachability
450
+ * @param {object} config
451
+ * @returns {Promise<void>}
452
+ * @throws {Error} `PLAN_REACHABILITY_ORPHANS` when the draft orphans a Story.
453
+ */
454
+ async function enforceReachability(reachability, config) {
455
+ if (reachability.status === 'orphans') {
456
+ const err = new Error(renderReachabilityOrphans(reachability));
457
+ err.code = 'PLAN_REACHABILITY_ORPHANS';
458
+ err.orphans = reachability.orphans;
459
+ throw err;
460
+ }
461
+ Logger.info(`[plan-persist] reachability: ${reachability.reasons[0]}`);
462
+ if (reachability.status === 'skipped') {
463
+ await appendCriticSkip(
464
+ {
465
+ critic: 'reachability',
466
+ reasons: reachability.reasons,
467
+ cli: 'plan-persist',
468
+ },
469
+ config,
470
+ );
471
+ }
472
+ }
473
+
474
+ /**
475
+ * Write the per-Story checkpoint, upsert the plan-summary comment, and flip
476
+ * every created Story to `agent::ready`. Terminal ordering is load-bearing:
477
+ * `agent::ready` lands last so it can honestly mean "fully persisted"
478
+ * (Story #4541). A dry run performs none of it.
479
+ *
480
+ * **The checkpoints fan out; the phase boundary does not** (Story #4952). The
481
+ * per-Story upserts are independent of one another and run under bounded
482
+ * concurrency, but the `await` on that whole fan-out is what keeps the
483
+ * Story #4541 invariant intact: *every* checkpoint is on its ticket before the
484
+ * first `agent::ready` flip is issued, so `ready` still means "fully
485
+ * persisted" and a `/deliver` that picks a Story up cannot read a null
486
+ * checkpoint. Concurrency inside the phase is safe; overlapping the phases is
487
+ * the race this ordering exists to close.
488
+ *
489
+ * @param {object} args
490
+ * @returns {Promise<void>}
491
+ */
492
+ async function persistStoryArtifacts({
493
+ provider,
494
+ created,
495
+ primary,
496
+ route,
497
+ summaryBody,
498
+ }) {
499
+ const cohort = created.map((createdStory) => ({
500
+ slug: createdStory.slug,
501
+ id: createdStory.id,
502
+ }));
503
+ await concurrentMap(
504
+ created,
505
+ (story) =>
506
+ writeCheckpointV2(provider, story.id, {
507
+ persist: {
508
+ completedAt: new Date().toISOString(),
509
+ storyCount: created.length,
510
+ primaryStoryId: primary.id,
511
+ stories: cohort,
512
+ },
513
+ // Ledger the authored route verdict — the recorded reason and the
514
+ // per-Story shape evidence, including a shape-refused claim — on plan
515
+ // state (Story #4722). No authored verdict writes no block: absence
516
+ // is the standard full path.
517
+ ...(route ? { route } : {}),
518
+ }),
519
+ // The per-Story checkpoint upserts (Story #4952): each targets a
520
+ // different issue and reads nothing another writes, so this loop was
521
+ // serial only by construction — but see {@link persistStoryArtifacts}
522
+ // for the phase ordering that is *not* incidental.
523
+ { concurrency: FANOUT_CONCURRENCY },
524
+ );
525
+ await upsertStructuredComment(
526
+ provider,
527
+ primary.id,
528
+ PLAN_SUMMARY_COMMENT_TYPE,
529
+ summaryBody,
530
+ );
531
+ await markStoriesReady({ provider, created });
532
+ }
533
+
534
+ /**
535
+ * Remove this run's plan directory on terminal success, then sweep the
536
+ * abandoned ones terminal-success cleanup can never reach (Story #4541).
537
+ *
538
+ * @param {{ config: object, planDir: string|null, skipCleanup: boolean }} args
539
+ * @returns {Promise<void>}
540
+ */
541
+ async function cleanupPlanDirs({ config, planDir, skipCleanup }) {
542
+ if (!skipCleanup && planDir) {
543
+ try {
544
+ await rm(planDir, { recursive: true, force: true });
545
+ } catch (err) {
546
+ Logger.warn(`[plan-persist] temp cleanup skipped: ${err.message}`);
547
+ }
548
+ }
549
+ await reapStalePlanDirs({ config, keepDir: skipCleanup ? planDir : null });
550
+ }
551
+
552
+ /**
553
+ * Log the effective complexity route (Story #4722). Lite is upheld by the
554
+ * shape backstop; anything else reports why it fell back to full.
555
+ *
556
+ * @param {object|null} route
557
+ * @param {boolean} isLiteRoute
558
+ * @returns {void}
559
+ */
560
+ function logEffectiveRoute(route, isLiteRoute) {
561
+ if (isLiteRoute) {
562
+ Logger.info(
563
+ `[plan-persist] ceremony-lite route upheld by the shape backstop: ` +
564
+ `created Stories carry the ${LITE_ROUTE_LABEL} hint ` +
565
+ `(recorded reason: ${route.authored.reason}). /deliver re-derives ` +
566
+ 'the route from each Story body — the label is never the control signal.',
567
+ );
568
+ return;
569
+ }
570
+ if (route) {
571
+ Logger.warn(
572
+ `[plan-persist] ${route.reasons.join('; ')} — persisting as full ` +
573
+ '(no route hint label).',
574
+ );
575
+ }
576
+ }
577
+
578
+ /**
579
+ * Log the operator-facing persist epilogue: adoption, the ready primary, the
580
+ * deliver command, and the cohort grouping label.
581
+ *
582
+ * @param {{ created: object[], primary: object, planRunLabel: string }} args
583
+ * @returns {void}
584
+ */
585
+ function logPersistEpilogue({ created, primary, planRunLabel }) {
586
+ const adopted = created.filter((story) => story.adopted);
587
+ if (adopted.length > 0) {
588
+ Logger.info(
589
+ `[plan-persist] resumed ${adopted.length} of ${created.length} Story(ies) ` +
590
+ `from a previous persist: ${adopted.map((s2) => `#${s2.id}`).join(', ')}.`,
591
+ );
592
+ }
593
+ Logger.info(
594
+ `[plan-persist] Persisted ${created.length} Story(ies)` +
595
+ `; primary #${primary.id} is agent::ready.`,
596
+ );
597
+ Logger.info(
598
+ `[plan-persist] Deliver with: /deliver ${created.map((s2) => s2.id).join(' ')}`,
599
+ );
600
+ // Metadata only — a GitHub filter for the cohort this run authored, never
601
+ // a delivery-resolution input (/deliver stays ids-only, Story #4540).
602
+ Logger.info(
603
+ `[plan-persist] Cohort grouping label: ${planRunLabel} — filter with ` +
604
+ `label:${planRunLabel}`,
605
+ );
606
+ }
607
+
413
608
  /**
414
609
  * Execute the flat Story persist end to end.
415
610
  *
@@ -475,26 +670,10 @@ export async function runPlanPersist({
475
670
  // through the shared standalone ledger (Story #4541).
476
671
  const runStartedAt = opts.metricsSince ?? new Date().toISOString();
477
672
 
478
- if (!Array.isArray(rawStories) || rawStories.length === 0) {
479
- throw new Error(
480
- '[plan-persist] stories payload must be a non-empty array ' +
481
- '(--stories <file>). Default is one Story.',
482
- );
483
- }
484
-
485
- const maxTickets = getLimits(config).maxTickets;
486
- if (rawStories.length > maxTickets && !allowOverBudget) {
487
- throw new Error(
488
- `[plan-persist] Stories (${rawStories.length}) exceed the reviewability ` +
489
- `budget (${maxTickets}). Re-scope, or rerun with --allow-over-budget.`,
490
- );
491
- }
492
- if (rawStories.length > maxTickets && allowOverBudget) {
493
- Logger.warn(
494
- `[plan-persist] Persisting an over-budget plan: ${rawStories.length} ` +
495
- `Stories vs. budget ${maxTickets} (--allow-over-budget).`,
496
- );
497
- }
673
+ assertPersistablePlan(rawStories, {
674
+ maxTickets: getLimits(config).maxTickets,
675
+ allowOverBudget,
676
+ });
498
677
 
499
678
  Logger.info(
500
679
  `[plan-persist] Running cross-validation on ${rawStories.length} Story ticket(s)...`,
@@ -516,23 +695,7 @@ export async function runPlanPersist({
516
695
  tickets: rawStories,
517
696
  config,
518
697
  });
519
- if (reachability.status === 'orphans') {
520
- const err = new Error(renderReachabilityOrphans(reachability));
521
- err.code = 'PLAN_REACHABILITY_ORPHANS';
522
- err.orphans = reachability.orphans;
523
- throw err;
524
- }
525
- Logger.info(`[plan-persist] reachability: ${reachability.reasons[0]}`);
526
- if (reachability.status === 'skipped') {
527
- await appendCriticSkip(
528
- {
529
- critic: 'reachability',
530
- reasons: reachability.reasons,
531
- cli: 'plan-persist',
532
- },
533
- config,
534
- );
535
- }
698
+ await enforceReachability(reachability, config);
536
699
 
537
700
  // Split policy + inline Spec fold (over-budget Specs fail closed — no docs/).
538
701
  const { stories } = assemblePlanStories(rawStories, {
@@ -559,19 +722,7 @@ export async function runPlanPersist({
559
722
  injectedRules,
560
723
  });
561
724
  const isLiteRoute = route?.route === 'lite';
562
- if (isLiteRoute) {
563
- Logger.info(
564
- `[plan-persist] ceremony-lite route upheld by the shape backstop: ` +
565
- `created Stories carry the ${LITE_ROUTE_LABEL} hint ` +
566
- `(recorded reason: ${route.authored.reason}). /deliver re-derives ` +
567
- 'the route from each Story body — the label is never the control signal.',
568
- );
569
- } else if (route) {
570
- Logger.warn(
571
- `[plan-persist] ${route.reasons.join('; ')} — persisting as full ` +
572
- '(no route hint label).',
573
- );
574
- }
725
+ logEffectiveRoute(route, isLiteRoute);
575
726
 
576
727
  const { created, planRunLabel } = await createStoryIssues({
577
728
  provider,
@@ -616,35 +767,13 @@ export async function runPlanPersist({
616
767
  });
617
768
 
618
769
  if (!dryRun) {
619
- for (const story of created) {
620
- await writeCheckpointV2(provider, story.id, {
621
- persist: {
622
- completedAt: new Date().toISOString(),
623
- storyCount: created.length,
624
- primaryStoryId: primary.id,
625
- stories: created.map((createdStory) => ({
626
- slug: createdStory.slug,
627
- id: createdStory.id,
628
- })),
629
- },
630
- // Ledger the authored route verdict — the recorded reason and the
631
- // per-Story shape evidence, including a shape-refused claim — on plan
632
- // state (Story #4722). No authored verdict writes no block: absence
633
- // is the standard full path.
634
- ...(route ? { route } : {}),
635
- });
636
- }
637
- await upsertStructuredComment(
770
+ await persistStoryArtifacts({
638
771
  provider,
639
- primary.id,
640
- PLAN_SUMMARY_COMMENT_TYPE,
772
+ created,
773
+ primary,
774
+ route,
641
775
  summaryBody,
642
- );
643
-
644
- // Terminal step: every checkpoint above is now on every Story, so
645
- // `agent::ready` can honestly mean "fully persisted" (Story #4541).
646
- // Anything that picks a Story up from here reads a real checkpoint.
647
- await markStoriesReady({ provider, created });
776
+ });
648
777
  }
649
778
 
650
779
  const supersede = await runSupersedePhase({
@@ -660,37 +789,8 @@ export async function runPlanPersist({
660
789
  // any) rather than reading as a clean no-op — Story #4554.
661
790
  supersede.sourceTicketOrigin = sourceTicketOrigin;
662
791
 
663
- if (!skipCleanup && planDir) {
664
- try {
665
- await rm(planDir, { recursive: true, force: true });
666
- } catch (err) {
667
- Logger.warn(`[plan-persist] temp cleanup skipped: ${err.message}`);
668
- }
669
- }
670
- // Terminal-success cleanup only ever removes *this* run's planDir, so
671
- // abandoned ones accumulated forever. Sweep them (Story #4541).
672
- await reapStalePlanDirs({ config, keepDir: skipCleanup ? planDir : null });
673
-
674
- const adopted = created.filter((story) => story.adopted);
675
- if (adopted.length > 0) {
676
- Logger.info(
677
- `[plan-persist] resumed ${adopted.length} of ${created.length} Story(ies) ` +
678
- `from a previous persist: ${adopted.map((s2) => `#${s2.id}`).join(', ')}.`,
679
- );
680
- }
681
- Logger.info(
682
- `[plan-persist] Persisted ${created.length} Story(ies)` +
683
- `; primary #${primary.id} is agent::ready.`,
684
- );
685
- Logger.info(
686
- `[plan-persist] Deliver with: /deliver ${created.map((s2) => s2.id).join(' ')}`,
687
- );
688
- // Metadata only — a GitHub filter for the cohort this run authored, never
689
- // a delivery-resolution input (/deliver stays ids-only, Story #4540).
690
- Logger.info(
691
- `[plan-persist] Cohort grouping label: ${planRunLabel} — filter with ` +
692
- `label:${planRunLabel}`,
693
- );
792
+ await cleanupPlanDirs({ config, planDir, skipCleanup });
793
+ logPersistEpilogue({ created, primary, planRunLabel });
694
794
 
695
795
  return {
696
796
  stories: created,