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,124 +1,50 @@
1
1
  /**
2
- * plan-critic-conditions.js — size/heuristic-conditional dispatch decisions for
3
- * the /mandrel-plan author-step critics (Epic #4474 PR6, design §4).
2
+ * plan-critic-conditions.js — the pre-mortem critic's dispatch decision for
3
+ * the `/mandrel-plan` critic CLI (Epic #4474 PR6, design §4; narrowed to one
4
+ * arm by Story #5312).
4
5
  *
5
- * The collapsed plan flow keeps the consolidation and pre-mortem critics as
6
- * fresh-context sub-agent dispatches, but makes each dispatch
7
- * **conditional** instead of unconditional — the dominant plan cost is
8
- * turns × standing context, and an unconditional critic pays a full
9
- * sub-agent spawn even when it provably has nothing to find. This module
10
- * computes those decisions deterministically so the workflow never judges
11
- * its own dispatch conditions:
6
+ * The critic is a fresh-context sub-agent dispatch, and the dispatch is
7
+ * **conditional**: the dominant plan cost is turns × standing context, and an
8
+ * unconditional critic pays a full sub-agent spawn even when it provably has
9
+ * nothing to find. This module computes the decision deterministically so
10
+ * the workflow never judges its own dispatch condition.
12
11
  *
13
- * - **Consolidation**: dispatch only when the existing
14
- * `evaluateConsolidationPrecondition` gate says `dispatch: true` AND
15
- * (the draft has more than `CONSOLIDATION_STORY_THRESHOLD` stories OR
16
- * the precondition confirmed a divergence from the Delivery Slicing
17
- * table). A fail-open precondition (missing/unparseable table) on a
18
- * small draft is NOT a confirmed divergence — it skips, because a
19
- * ≤-threshold draft is small enough for gate #2's single-view review
20
- * to catch a distorted shape without a dedicated sub-agent.
21
- * - **Pre-mortem**: dispatch when the ticket count is at least half
22
- * of `maxTickets`, OR any configured `planning.riskHeuristics` phrase
23
- * matches the plan text (case-insensitive substring), OR the
24
- * **external-dependency probe** (Story #4700) finds an out-of-repo marker
25
- * in the plan text. Story #4542 removed the authored-risk-verdict condition
26
- * along with the verdict itself; every surviving condition reads the plan's
27
- * own observable text and shape rather than a self-assessment.
28
- *
29
- * The external-dependency probe (Story #4700) is what gives the default N=1
30
- * path a cheap viability check: on that path the size condition is unreachable
31
- * (`count*2 >= maxTickets` never holds at one ticket) and a repo whose resolved
32
- * `planning.riskHeuristics` is empty has no phrase to match, so a plan-time
33
- * discoverable blocker — a scoped package the plan names that no manifest
34
- * declares, a cross-repo reference, an external service prerequisite — reached
12
+ * One trigger survives: the **external-dependency probe** (Story #4700),
13
+ * which finds an out-of-repo marker in the plan text — a scoped package the
14
+ * plan names that no manifest declares, a cross-repo reference, an external
15
+ * service prerequisite — so a plan-time discoverable blocker does not reach
35
16
  * delivery unquestioned (the swarm-os #757 shape). The probe is deliberately
36
- * **conservative**: it matches only explicit markers (npm scoped-package specs,
37
- * `github.com/<owner>/<repo>` URLs, prerequisite-keyword-anchored endpoints),
38
- * never NLP guesswork, so a plan with no such marker dispatches exactly as it
39
- * did before.
17
+ * **conservative**: it matches only explicit markers (npm scoped-package
18
+ * specs, `github.com/<owner>/<repo>` URLs, prerequisite-keyword-anchored
19
+ * endpoints), never NLP guesswork.
20
+ *
21
+ * Story #5312 deleted the two triggers that sat beside it — the ticket count
22
+ * reaching half a `maxTickets` budget, and a `planning.riskHeuristics` phrase
23
+ * matching the plan text — with the constants they read. The count trigger
24
+ * was unreachable at the default N=1; the phrase list was empty in every
25
+ * consumer that resolved it. The consolidation critic went with them: its
26
+ * one deterministic input was a `## Delivery Slicing` table no Story carries.
40
27
  *
41
- * Under-firing risk (design PR6 note): the persist validators are
42
- * unchanged hard gates and G2's cohort re-measures plan quality; every
43
- * skip decision this module produces is logged to the plan-metrics ledger
44
- * (`appendCriticSkip`) by the caller so under-firing is auditable.
28
+ * Under-firing risk (design PR6 note): the persist validators are unchanged
29
+ * hard gates; every skip decision this module produces is logged to the
30
+ * plan-metrics ledger (`appendCriticSkip`) by the caller so under-firing is
31
+ * auditable.
45
32
  *
46
33
  * Pure, synchronous, no I/O. The single caller is `plan-critics-evaluate.js`,
47
- * driven by the `plan-critics.js` CLI that `/mandrel-plan` runs between Author and
48
- * Persist (Story #4592); the CLI owns reading the authored artifacts and the
49
- * resolved config.
50
- */
51
-
52
- import { evaluateConsolidationPrecondition } from './consolidation-precondition.js';
53
-
54
- /**
55
- * Draft-story count above which the consolidation critic fires even
56
- * without a confirmed slicing divergence (#4474 PR6: "> 5 stories").
34
+ * driven by the `plan-critics.js` CLI the operator runs between Author and
35
+ * Persist when they want the critic; the CLI owns reading the authored
36
+ * artifacts and the resolved config.
57
37
  */
58
- export const CONSOLIDATION_STORY_THRESHOLD = 5;
59
38
 
60
39
  /**
61
40
  * @typedef {Object} CriticDispatchDecision
62
- * @property {'consolidation'|'pre-mortem'} critic
41
+ * @property {'pre-mortem'} critic
63
42
  * @property {boolean} dispatch
64
43
  * @property {string[]} reasons Why the critic fires — or why it is safe to
65
44
  * skip. Never empty: a skip's reasons are the audit trail the
66
45
  * plan-metrics ledger records.
67
46
  */
68
47
 
69
- /**
70
- * Decide the consolidation dispatch: precondition AND size/divergence.
71
- *
72
- * @param {object} input
73
- * @param {object[]} input.draftStories - The draft `tickets.json` array
74
- * (raw Story objects with top-level `slug` / `depends_on` / `body`).
75
- * @param {string} input.specText - The text carrying the `## Delivery
76
- * Slicing` table. At author time this is the authored `techspec.md`
77
- * content (the Epic body carries the same folded section post-persist).
78
- * @returns {CriticDispatchDecision}
79
- */
80
- export function evaluateConsolidationDispatch({ draftStories, specText }) {
81
- const precondition = evaluateConsolidationPrecondition({
82
- draftStories,
83
- epicBody: specText,
84
- });
85
-
86
- if (!precondition.dispatch) {
87
- return {
88
- critic: 'consolidation',
89
- dispatch: false,
90
- reasons: precondition.reasons,
91
- };
92
- }
93
-
94
- const storyCount = draftStories.length;
95
- const oversized = storyCount > CONSOLIDATION_STORY_THRESHOLD;
96
- const diverges = precondition.cause === 'divergence';
97
-
98
- if (!oversized && !diverges) {
99
- return {
100
- critic: 'consolidation',
101
- dispatch: false,
102
- reasons: [
103
- `Draft has ${storyCount} story(ies) (≤ ${CONSOLIDATION_STORY_THRESHOLD}) and no confirmed Delivery Slicing divergence — gate #2's single-view review covers a draft this small.`,
104
- ...precondition.reasons,
105
- ],
106
- };
107
- }
108
-
109
- const reasons = [];
110
- if (diverges) reasons.push(...precondition.reasons);
111
- if (oversized) {
112
- reasons.push(
113
- `Draft has ${storyCount} stories (> ${CONSOLIDATION_STORY_THRESHOLD}) — large enough that a distorted shape can hide from the gate #2 single view.`,
114
- );
115
- }
116
- if (!diverges && precondition.cause === 'fail-open') {
117
- reasons.push(...precondition.reasons);
118
- }
119
- return { critic: 'consolidation', dispatch: true, reasons };
120
- }
121
-
122
48
  /**
123
49
  * Explicit npm scoped-package marker: `@scope/name`. Requires the leading `@`
124
50
  * and an interior `/`, so bare GitHub handles (`@dsj1984`) and the
@@ -215,8 +141,8 @@ function matchExternalServicePrereqs(planText) {
215
141
  /**
216
142
  * The external-dependency probe (Story #4700): a conservative, marker-only
217
143
  * scan of the draft plan text for artifacts outside the current repo that the
218
- * plan depends on. A match is the pre-mortem's third dispatch condition; a
219
- * no-match plan behaves exactly as it did before this probe existed.
144
+ * plan depends on. A match is the pre-mortem's dispatch condition; a no-match
145
+ * plan skips the critic.
220
146
  *
221
147
  * @param {object} input
222
148
  * @param {string} [input.planText] - Concatenated plan text (tech spec +
@@ -262,19 +188,12 @@ export function evaluateExternalDependencyProbe({
262
188
  }
263
189
 
264
190
  /**
265
- * Decide the pre-mortem dispatch: size ≥ ½ budget, a risk-heuristic phrase
266
- * match, or an external-dependency probe match (Story #4700).
191
+ * Decide the pre-mortem dispatch: an external-dependency probe match
192
+ * (Story #4700) — the one deterministic trigger left after Story #5312.
267
193
  *
268
194
  * @param {object} input
269
- * @param {number} input.ticketCount - Draft ticket count (0 in the
270
- * single-delivery shape — no tickets exist).
271
- * @param {number} input.maxTickets - The reviewability budget
272
- * (`getLimits(config).maxTickets`).
273
- * @param {string[]} [input.riskHeuristics] - `planning.riskHeuristics`
274
- * phrases from the resolved config.
275
- * @param {string} [input.planText] - Concatenated plan text the heuristics and
276
- * the external-dependency probe match against (tech spec + serialized
277
- * tickets).
195
+ * @param {string} [input.planText] - Concatenated plan text the probe
196
+ * matches against (tech spec + serialized tickets).
278
197
  * @param {string[]} [input.knownPackages] - Package specifiers the repo's own
279
198
  * manifests declare (own name + dependency maps + workspace package names),
280
199
  * passed to the external-dependency probe.
@@ -284,58 +203,28 @@ export function evaluateExternalDependencyProbe({
284
203
  * @returns {CriticDispatchDecision}
285
204
  */
286
205
  export function evaluatePremortemDispatch({
287
- ticketCount,
288
- maxTickets,
289
- riskHeuristics = [],
290
206
  planText = '',
291
207
  knownPackages = [],
292
208
  ownerRepo = null,
293
209
  }) {
294
- if (!Number.isInteger(maxTickets) || maxTickets <= 0) {
295
- throw new TypeError(
296
- 'evaluatePremortemDispatch: maxTickets must be a positive integer',
297
- );
298
- }
299
- const reasons = [];
300
-
301
- const count = Number.isInteger(ticketCount) ? ticketCount : 0;
302
- if (count * 2 >= maxTickets) {
303
- reasons.push(
304
- `Ticket count ${count} is at least half the reviewability budget (maxTickets ${maxTickets}).`,
305
- );
306
- }
307
-
308
- const haystack = String(planText).toLowerCase();
309
- const matched = riskHeuristics.filter(
310
- (phrase) =>
311
- typeof phrase === 'string' &&
312
- phrase.trim().length > 0 &&
313
- haystack.includes(phrase.trim().toLowerCase()),
314
- );
315
- if (matched.length > 0) {
316
- reasons.push(
317
- `planning.riskHeuristics match(es) in the plan text: ${matched.map((p) => `"${p.trim()}"`).join(', ')}.`,
318
- );
319
- }
320
-
321
210
  const externalDeps = evaluateExternalDependencyProbe({
322
211
  planText,
323
212
  knownPackages,
324
213
  ownerRepo,
325
214
  });
326
215
  if (externalDeps.matched) {
327
- reasons.push(...externalDeps.reasons);
328
- }
329
-
330
- if (reasons.length > 0) {
331
- return { critic: 'pre-mortem', dispatch: true, reasons };
216
+ return {
217
+ critic: 'pre-mortem',
218
+ dispatch: true,
219
+ reasons: externalDeps.reasons,
220
+ };
332
221
  }
333
222
 
334
223
  return {
335
224
  critic: 'pre-mortem',
336
225
  dispatch: false,
337
226
  reasons: [
338
- `Ticket count ${count} is under half the budget (maxTickets ${maxTickets}), no planning.riskHeuristics phrase matches the plan text, and the external-dependency probe found no out-of-repo markers.`,
227
+ 'The external-dependency probe found no out-of-repo markers in the plan text.',
339
228
  ],
340
229
  };
341
230
  }
@@ -1,13 +1,13 @@
1
1
  /**
2
2
  * plan-critics-evaluate.js — shared critic-dispatch evaluation for the
3
- * collapsed /mandrel-plan flow (#4496 fix 6).
3
+ * collapsed /mandrel-plan flow (#4496 fix 6; pre-mortem only since Story #5312).
4
4
  *
5
- * One consumer: the `plan-critics.js` CLI, which `/mandrel-plan` runs between its
6
- * Author and Persist steps. The CLI loads the draft artifacts, calls this
7
- * module, prints the verdict as JSON, and records every skip on the
8
- * plan-metrics ledger; the workflow dispatches a fresh-context critic
9
- * sub-agent on a `dispatch: true` verdict and folds the findings into a
10
- * re-author round before persist.
5
+ * One consumer: the `plan-critics.js` CLI, which the operator runs between
6
+ * Author and Persist when they want the pre-mortem. The CLI loads the draft
7
+ * artifacts, calls this module, prints the verdict as JSON, and records a
8
+ * skip on the plan-metrics ledger; the workflow dispatches a fresh-context
9
+ * critic sub-agent on a `dispatch: true` verdict and folds the findings into
10
+ * a re-author round before persist.
11
11
  *
12
12
  * Story #4592 moved that evaluation here from `run-plan-persist.js`, which
13
13
  * ran it after authoring was finished and immediately before
@@ -21,26 +21,7 @@
21
21
  * @module lib/orchestration/plan-critics-evaluate
22
22
  */
23
23
 
24
- import { getLimits } from '../config-resolver.js';
25
- import {
26
- evaluateConsolidationDispatch,
27
- evaluatePremortemDispatch,
28
- } from './plan-critic-conditions.js';
29
- import { evaluateTextHygiene } from './plan-text-hygiene.js';
30
-
31
- /**
32
- * Resolve the planning risk heuristics list from the canonical config
33
- * block (same resolution `plan-context.js` and the decompose context use).
34
- *
35
- * @param {object} config
36
- * @returns {string[]}
37
- */
38
- function resolveRiskHeuristics(config = {}) {
39
- if (Array.isArray(config.planning?.riskHeuristics)) {
40
- return config.planning.riskHeuristics;
41
- }
42
- return [];
43
- }
24
+ import { evaluatePremortemDispatch } from './plan-critic-conditions.js';
44
25
 
45
26
  /**
46
27
  * Resolve the `{ owner, repo }` the external-dependency probe's cross-repo arm
@@ -57,24 +38,11 @@ function resolveOwnerRepo(config = {}) {
57
38
  }
58
39
 
59
40
  /**
60
- * Evaluate the consolidation + pre-mortem critic dispatch conditions over
61
- * the authored planning artifacts (#4474 PR6 conditions, unchanged):
62
- *
63
- * - Consolidation: skipped outright when `tickets` is null/absent (the
64
- * single-delivery shape authors no draft tickets); otherwise the
65
- * deterministic precondition + size/divergence conditions.
66
- * - Pre-mortem: ticket count at least half `maxTickets`, OR any
67
- * `planning.riskHeuristics` phrase matching the plan text, OR the
68
- * external-dependency probe (Story #4700) matching an out-of-repo marker
69
- * — a scoped package absent from `knownPackages`, a cross-repo
70
- * `github.com/<owner>/<repo>` reference, or a named external service
71
- * prerequisite. Story #4542 retired its authored-risk-level condition with
72
- * the verdict itself.
73
- * - Text hygiene (Story #4599, advisory-only): deterministic body lints
74
- * (dangling-citation / open-question / slicing-mass) over the draft
75
- * stories. It has no `dispatch` semantics and spawns nothing — its
76
- * `findings[]` are re-author-round input, and the consolidation /
77
- * premortem dispatch verdicts are untouched by it.
41
+ * Evaluate the pre-mortem critic's dispatch condition over the authored
42
+ * planning artifacts: the external-dependency probe (Story #4700) matching an
43
+ * out-of-repo marker — a scoped package absent from `knownPackages`, a
44
+ * cross-repo `github.com/<owner>/<repo>` reference, or a named external
45
+ * service prerequisite.
78
46
  *
79
47
  * @param {{
80
48
  * techSpecContent: string,
@@ -88,9 +56,7 @@ function resolveOwnerRepo(config = {}) {
88
56
  * that gathers them; this module stays pure. Empty when unresolved, which
89
57
  * only widens what the probe treats as external.
90
58
  * @returns {{
91
- * consolidation: { critic: string, dispatch: boolean, reasons: string[] },
92
59
  * premortem: { critic: string, dispatch: boolean, reasons: string[] },
93
- * textHygiene: { critic: string, findings: Array<object> },
94
60
  * }}
95
61
  */
96
62
  export function evaluatePlanCritics({
@@ -100,24 +66,7 @@ export function evaluatePlanCritics({
100
66
  knownPackages = [],
101
67
  }) {
102
68
  const ticketList = Array.isArray(tickets) ? tickets : null;
103
- const consolidation =
104
- ticketList === null
105
- ? {
106
- critic: 'consolidation',
107
- dispatch: false,
108
- reasons: [
109
- 'single-delivery shape — no draft tickets exist to consolidate.',
110
- ],
111
- }
112
- : evaluateConsolidationDispatch({
113
- draftStories: ticketList,
114
- specText: techSpecContent,
115
- });
116
-
117
69
  const premortem = evaluatePremortemDispatch({
118
- ticketCount: ticketList?.length ?? 0,
119
- maxTickets: getLimits(config).maxTickets,
120
- riskHeuristics: resolveRiskHeuristics(config),
121
70
  planText: [
122
71
  techSpecContent ?? '',
123
72
  ticketList ? JSON.stringify(ticketList) : '',
@@ -126,10 +75,5 @@ export function evaluatePlanCritics({
126
75
  ownerRepo: resolveOwnerRepo(config),
127
76
  });
128
77
 
129
- const textHygiene = {
130
- critic: 'text-hygiene',
131
- findings: evaluateTextHygiene({ draftStories: ticketList }).findings,
132
- };
133
-
134
- return { consolidation, premortem, textHygiene };
78
+ return { premortem };
135
79
  }
@@ -0,0 +1,300 @@
1
+ /**
2
+ * changes-repair.js — repair-before-judging for `changes[]` entries
3
+ * (Story #5312).
4
+ *
5
+ * The `{ path, assumption }` object shape is a deterministic, mechanically
6
+ * derivable formality. The validator already knew how to salvage a path from
7
+ * a plain-string bullet (`suggestPathEntryFix`) — then rejected the plan
8
+ * anyway and charged the author a full re-drafting round to paste that exact
9
+ * object back. Story #5005 made the same call for the verify-tier suffix and
10
+ * repaired it instead; the suffix is gone now, and the repair moves to the
11
+ * one formality left: this module applies the inference the validator
12
+ * trusts, so the dry-run rewrites and **reports** each repair rather than
13
+ * refusing on it.
14
+ *
15
+ * Two shapes are repaired, on both authoring surfaces (a structured object
16
+ * body's `changes[]` array and a serialized string body's `## Changes`
17
+ * section):
18
+ *
19
+ * - a **plain-string bullet** (`src/app.js`, `` `src/app.js` ``,
20
+ * `src/app.js — adds the route`) becomes `{ path, assumption }`, the
21
+ * assumption resolved by probing the base branch — a path present at
22
+ * base is a `refactors-existing`, an absent one a `creates`;
23
+ * - a **trailing parenthetical** on a path (`src/app.js (new)`,
24
+ * `` `src/app.js (creates)` — refactors-existing ``) is stripped; an
25
+ * authored assumption is kept, an absent one probed as above.
26
+ *
27
+ * A string nothing path-shaped can be salvaged from is left untouched and
28
+ * still fails the body-shape validator — that is the one `changes[]` failure
29
+ * only the author can resolve.
30
+ *
31
+ * It lives beside the validator rather than inside it because the validator's
32
+ * job is to *judge*: mixing a mutating repair pass into a module of pure
33
+ * collectors muddies both. `persist-helpers.js#validateTickets` calls this
34
+ * first, then the validators.
35
+ *
36
+ * @module lib/orchestration/plan-persist/changes-repair
37
+ */
38
+
39
+ import { FILE_ASSUMPTION_VALUES } from '../file-assumption-enum.js';
40
+
41
+ /** The `## Changes` heading (either level the parser accepts). */
42
+ const CHANGES_HEADING_RE = /^#{2,3}\s+Changes\s*$/i;
43
+
44
+ /** Any heading — the end of the `## Changes` section. */
45
+ const ANY_HEADING_RE = /^#{1,6}\s+\S/;
46
+
47
+ /** A trailing `(…)` on a path token. */
48
+ const TRAILING_PARENTHETICAL_RE = /\s*\([^)]*\)\s*$/;
49
+
50
+ /** The humanized canonical bullet: `` `path` — assumption ``. */
51
+ const HUMANIZED_RE = /^`([^`]+)`\s+—\s+(\S+)$/;
52
+
53
+ // A token that looks like a file path / glob / module id: it carries a `/` or a
54
+ // `.`-separated segment. Deliberately loose — a false positive only produces a
55
+ // `{ path, assumption }` entry the base-branch probes then judge.
56
+ const PATH_LIKE_RE = /^[\w@*-]*[/.][\w@./*-]+$/;
57
+
58
+ /**
59
+ * Strip a trailing parenthetical from a path token, reporting whether one
60
+ * was present.
61
+ *
62
+ * @param {string} raw
63
+ * @returns {{ path: string, stripped: boolean }}
64
+ */
65
+ function stripParenthetical(raw) {
66
+ const path = raw.replace(TRAILING_PARENTHETICAL_RE, '').trim();
67
+ return { path, stripped: path !== raw.trim() };
68
+ }
69
+
70
+ /**
71
+ * Salvage the path token from a plain-string bullet: drop a leading list
72
+ * marker, take the segment before any humanized ` — ` tail, peel quotes and
73
+ * backticks, strip a trailing parenthetical. Returns `null` when nothing
74
+ * path-shaped survives.
75
+ *
76
+ * @param {string} raw
77
+ * @returns {string|null}
78
+ */
79
+ function salvagePath(raw) {
80
+ let s = raw
81
+ .trim()
82
+ .replace(/^[-*]\s+/, '')
83
+ .trim();
84
+ s = s.split('—')[0].trim();
85
+ s = s
86
+ .replace(/^[`'"]+/, '')
87
+ .replace(/[`'"]+$/, '')
88
+ .trim();
89
+ s = stripParenthetical(s).path;
90
+ return s !== '' && PATH_LIKE_RE.test(s) ? s : null;
91
+ }
92
+
93
+ /**
94
+ * Resolve the assumption for a path with none authored: present at base →
95
+ * `refactors-existing`, absent → `creates`.
96
+ *
97
+ * @param {string} path
98
+ * @param {(path: string) => boolean} existsAtBase
99
+ * @returns {'refactors-existing'|'creates'}
100
+ */
101
+ function probeAssumption(path, existsAtBase) {
102
+ return existsAtBase(path) ? 'refactors-existing' : 'creates';
103
+ }
104
+
105
+ /**
106
+ * Repair one structured `changes[]` item. Returns the corrected entry and a
107
+ * repair record, or `null` when the item needs no repair (or cannot be
108
+ * repaired).
109
+ *
110
+ * @param {unknown} item
111
+ * @param {(path: string) => boolean} existsAtBase
112
+ * @returns {{ entry: { path: string, assumption: string }, repair: object }|null}
113
+ */
114
+ function repairStructuredItem(item, existsAtBase) {
115
+ if (typeof item === 'string') {
116
+ const path = salvagePath(item);
117
+ if (path === null) return null;
118
+ const assumption = probeAssumption(path, existsAtBase);
119
+ return {
120
+ entry: { path, assumption },
121
+ repair: { from: item, path, assumption, reason: 'plain-string' },
122
+ };
123
+ }
124
+ if (item === null || typeof item !== 'object') return null;
125
+ if (typeof item.path !== 'string') return null;
126
+ const { path, stripped } = stripParenthetical(item.path);
127
+ const authored = FILE_ASSUMPTION_VALUES.includes(item.assumption);
128
+ if (!stripped && authored) return null;
129
+ if (path === '') return null;
130
+ const assumption = authored
131
+ ? item.assumption
132
+ : probeAssumption(path, existsAtBase);
133
+ return {
134
+ entry: { path, assumption },
135
+ repair: {
136
+ from: item.path,
137
+ path,
138
+ assumption,
139
+ reason: stripped ? 'trailing-parenthetical' : 'missing-assumption',
140
+ },
141
+ };
142
+ }
143
+
144
+ /**
145
+ * Repair one line of a serialized `## Changes` section. Returns the rewritten
146
+ * line and a repair record, or `null` when the line is already canonical or
147
+ * cannot be repaired.
148
+ *
149
+ * @param {string} line
150
+ * @param {(path: string) => boolean} existsAtBase
151
+ * @returns {{ line: string, repair: object }|null}
152
+ */
153
+ function repairSectionLine(line, existsAtBase) {
154
+ const marker = line.match(/^(\s*[-*]\s+)/);
155
+ if (!marker) return null;
156
+ const content = line.slice(marker[1].length).trim();
157
+ if (content === '') return null;
158
+ const humanized = content.match(HUMANIZED_RE);
159
+ if (humanized) {
160
+ const { path, stripped } = stripParenthetical(humanized[1]);
161
+ const authored = FILE_ASSUMPTION_VALUES.includes(humanized[2]);
162
+ if (!stripped && authored) return null;
163
+ if (path === '') return null;
164
+ const assumption = authored
165
+ ? humanized[2]
166
+ : probeAssumption(path, existsAtBase);
167
+ return {
168
+ line: `${marker[1]}\`${path}\` — ${assumption}`,
169
+ repair: {
170
+ from: content,
171
+ path,
172
+ assumption,
173
+ reason: stripped ? 'trailing-parenthetical' : 'missing-assumption',
174
+ },
175
+ };
176
+ }
177
+ if (content.startsWith('{')) {
178
+ let parsed;
179
+ try {
180
+ parsed = JSON.parse(content);
181
+ } catch {
182
+ return null;
183
+ }
184
+ const repaired = repairStructuredItem(parsed, existsAtBase);
185
+ if (repaired === null) return null;
186
+ return {
187
+ line: `${marker[1]}\`${repaired.entry.path}\` — ${repaired.entry.assumption}`,
188
+ repair: repaired.repair,
189
+ };
190
+ }
191
+ const path = salvagePath(content);
192
+ if (path === null) return null;
193
+ const assumption = probeAssumption(path, existsAtBase);
194
+ return {
195
+ line: `${marker[1]}\`${path}\` — ${assumption}`,
196
+ repair: { from: content, path, assumption, reason: 'plain-string' },
197
+ };
198
+ }
199
+
200
+ /**
201
+ * Repair the `## Changes` section of a serialized body in place. Only lines
202
+ * between the heading and the next heading are touched; the rest of the
203
+ * body is byte-identical.
204
+ *
205
+ * @param {string} body
206
+ * @param {(path: string) => boolean} existsAtBase
207
+ * @returns {{ body: string, repairs: object[] }}
208
+ */
209
+ function repairSerializedBody(body, existsAtBase) {
210
+ const lines = body.split('\n');
211
+ const repairs = [];
212
+ let inChanges = false;
213
+ for (let i = 0; i < lines.length; i += 1) {
214
+ const line = lines[i];
215
+ if (CHANGES_HEADING_RE.test(line.trim())) {
216
+ inChanges = true;
217
+ continue;
218
+ }
219
+ if (!inChanges) continue;
220
+ if (ANY_HEADING_RE.test(line) || line.trim().startsWith('---')) {
221
+ inChanges = false;
222
+ continue;
223
+ }
224
+ const repaired = repairSectionLine(line, existsAtBase);
225
+ if (repaired === null) continue;
226
+ lines[i] = repaired.line;
227
+ repairs.push(repaired.repair);
228
+ }
229
+ return { body: lines.join('\n'), repairs };
230
+ }
231
+
232
+ /**
233
+ * Repair the `changes[]` of one ticket, on whichever surface carries it.
234
+ *
235
+ * @param {object} ticket Mutated in place.
236
+ * @param {(path: string) => boolean} existsAtBase
237
+ * @returns {object[]} The repairs applied to this ticket.
238
+ */
239
+ function repairTicket(ticket, existsAtBase) {
240
+ const body = ticket.body;
241
+ if (typeof body === 'string') {
242
+ const { body: next, repairs } = repairSerializedBody(body, existsAtBase);
243
+ if (repairs.length > 0) ticket.body = next;
244
+ return repairs;
245
+ }
246
+ const changes =
247
+ body && typeof body === 'object' && Array.isArray(body.changes)
248
+ ? body.changes
249
+ : Array.isArray(ticket.changes)
250
+ ? ticket.changes
251
+ : null;
252
+ if (changes === null) return [];
253
+ const repairs = [];
254
+ for (let i = 0; i < changes.length; i += 1) {
255
+ const repaired = repairStructuredItem(changes[i], existsAtBase);
256
+ if (repaired === null) continue;
257
+ changes[i] = repaired.entry;
258
+ repairs.push(repaired.repair);
259
+ }
260
+ return repairs;
261
+ }
262
+
263
+ /**
264
+ * Render one repair as the dry-run line the operator reads.
265
+ *
266
+ * @param {{ slug: string, from: string, path: string, assumption: string, reason: string }} repair
267
+ * @returns {string}
268
+ */
269
+ export function renderChangeRepair({ slug, from, path, assumption, reason }) {
270
+ const why =
271
+ reason === 'plain-string'
272
+ ? 'plain-string bullet'
273
+ : reason === 'trailing-parenthetical'
274
+ ? 'trailing parenthetical'
275
+ : 'missing assumption';
276
+ return `Story "${slug}": changes[] entry "${from}" (${why}) repaired to {"path":"${path}","assumption":"${assumption}"} by probing base.`;
277
+ }
278
+
279
+ /**
280
+ * Rewrite every repairable `changes[]` entry across the draft, probing the
281
+ * base branch for the assumption where none was authored. Mutates `tickets`
282
+ * in place (the persist pipeline threads this same array on to assembly)
283
+ * and returns the repairs, each tagged with the Story's slug. Total — a
284
+ * non-array argument and non-Story tickets are no-ops.
285
+ *
286
+ * @param {object[]} tickets
287
+ * @param {{ existsAtBase: (path: string) => boolean }} args
288
+ * @returns {Array<{ slug: string, from: string, path: string, assumption: string, reason: string }>}
289
+ */
290
+ export function repairChangeEntries(tickets, { existsAtBase }) {
291
+ const repairs = [];
292
+ for (const ticket of Array.isArray(tickets) ? tickets : []) {
293
+ if (!ticket || ticket.type !== 'story') continue;
294
+ const slug = ticket.slug ?? ticket.title ?? '<unknown>';
295
+ for (const repair of repairTicket(ticket, existsAtBase)) {
296
+ repairs.push({ slug, ...repair });
297
+ }
298
+ }
299
+ return repairs;
300
+ }