mandrel 1.58.0 → 1.59.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 (129) hide show
  1. package/.agents/README.md +89 -87
  2. package/.agents/docs/SDLC.md +11 -7
  3. package/.agents/docs/workflows.md +2 -1
  4. package/.agents/schemas/audit-rules.json +20 -0
  5. package/.agents/scripts/acceptance-eval.js +20 -3
  6. package/.agents/scripts/assert-branch.js +1 -3
  7. package/.agents/scripts/bootstrap.js +1 -1
  8. package/.agents/scripts/check-arch-cycles.js +360 -0
  9. package/.agents/scripts/coverage-capture.js +24 -3
  10. package/.agents/scripts/epic-deliver-preflight.js +5 -3
  11. package/.agents/scripts/epic-deliver-prepare.js +12 -4
  12. package/.agents/scripts/epic-execute-record-wave.js +1 -1
  13. package/.agents/scripts/evidence-gate.js +1 -1
  14. package/.agents/scripts/git-rebase-and-resolve.js +1 -1
  15. package/.agents/scripts/hierarchy-gate.js +34 -14
  16. package/.agents/scripts/lib/baselines/kinds/coverage.js +33 -149
  17. package/.agents/scripts/lib/baselines/kinds/duplication.js +27 -116
  18. package/.agents/scripts/lib/baselines/kinds/kind-factory.js +192 -0
  19. package/.agents/scripts/lib/baselines/kinds/lighthouse.js +34 -133
  20. package/.agents/scripts/lib/baselines/kinds/maintainability.js +31 -124
  21. package/.agents/scripts/lib/baselines/kinds/mutation.js +25 -111
  22. package/.agents/scripts/lib/baselines/maintainability-baseline-io.js +59 -0
  23. package/.agents/scripts/lib/baselines/maintainability-baseline-save.js +37 -0
  24. package/.agents/scripts/lib/baselines/writer.js +1 -1
  25. package/.agents/scripts/lib/close-validation/commands.js +188 -0
  26. package/.agents/scripts/lib/close-validation/gates.js +235 -0
  27. package/.agents/scripts/lib/close-validation/process.js +101 -0
  28. package/.agents/scripts/lib/close-validation/projections/maintainability.js +1 -1
  29. package/.agents/scripts/lib/close-validation/runner.js +325 -0
  30. package/.agents/scripts/lib/close-validation/telemetry.js +70 -0
  31. package/.agents/scripts/lib/config/quality.js +6 -6
  32. package/.agents/scripts/lib/config-resolver.js +2 -5
  33. package/.agents/scripts/lib/coverage-capture.js +147 -4
  34. package/.agents/scripts/lib/cpu-pool.js +14 -0
  35. package/.agents/scripts/lib/crap-utils.js +6 -11
  36. package/.agents/scripts/lib/dynamic-workflow/documentation-report-contract.js +87 -0
  37. package/.agents/scripts/lib/git-utils.js +24 -22
  38. package/.agents/scripts/lib/maintainability-engine.js +1 -1
  39. package/.agents/scripts/lib/maintainability-utils.js +4 -187
  40. package/.agents/scripts/lib/observability/perf-report-readers.js +32 -23
  41. package/.agents/scripts/lib/orchestration/acceptance-eval-decision.js +80 -6
  42. package/.agents/scripts/lib/orchestration/code-review.js +90 -77
  43. package/.agents/scripts/lib/orchestration/dispatch-pipeline.js +5 -12
  44. package/.agents/scripts/lib/orchestration/epic-deliver-lease-guard.js +14 -14
  45. package/.agents/scripts/lib/orchestration/epic-plan-decompose/phases/planning-artifacts.js +2 -2
  46. package/.agents/scripts/lib/orchestration/epic-plan-lease-guard.js +184 -49
  47. package/.agents/scripts/lib/orchestration/epic-plan-spec/phases/drain.js +1 -1
  48. package/.agents/scripts/lib/orchestration/epic-plan-spec/phases/plan-epic.js +26 -2
  49. package/.agents/scripts/lib/orchestration/epic-plan-spec/phases/run-spec-phase.js +26 -6
  50. package/.agents/scripts/lib/orchestration/epic-runner/phases/build-wave-dag.js +7 -20
  51. package/.agents/scripts/lib/orchestration/epic-runner/progress-reporter/composition.js +1 -2
  52. package/.agents/scripts/lib/orchestration/epic-runner/progress-reporter/signals.js +0 -6
  53. package/.agents/scripts/lib/orchestration/epic-runner/progress-signals/component-drift.js +103 -0
  54. package/.agents/scripts/lib/orchestration/epic-runner/progress-signals/crap-drift.js +22 -64
  55. package/.agents/scripts/lib/orchestration/epic-runner/progress-signals/maintainability-drift.js +38 -76
  56. package/.agents/scripts/lib/orchestration/epic-runner/story-run-progress-writer.js +2 -2
  57. package/.agents/scripts/lib/orchestration/epic-runner/sub-agent-return.js +4 -16
  58. package/.agents/scripts/lib/orchestration/file-assumptions.js +4 -3
  59. package/.agents/scripts/lib/orchestration/lease-guard-shared.js +144 -0
  60. package/.agents/scripts/lib/orchestration/lifecycle/emit-story-heartbeat.js +2 -2
  61. package/.agents/scripts/lib/orchestration/lifecycle/listeners/watcher.js +7 -7
  62. package/.agents/scripts/lib/orchestration/post-merge/phases/notification.js +3 -3
  63. package/.agents/scripts/lib/orchestration/post-merge/phases/worktree-reap.js +7 -7
  64. package/.agents/scripts/lib/orchestration/preflight-cache.js +35 -12
  65. package/.agents/scripts/lib/orchestration/review-providers/codex.js +5 -60
  66. package/.agents/scripts/lib/orchestration/review-providers/native.js +7 -6
  67. package/.agents/scripts/lib/orchestration/review-providers/parse-findings.js +105 -0
  68. package/.agents/scripts/lib/orchestration/review-providers/security-review.js +7 -59
  69. package/.agents/scripts/lib/orchestration/single-story-close/phases/close-validation.js +2 -4
  70. package/.agents/scripts/lib/orchestration/single-story-close/phases/options.js +1 -1
  71. package/.agents/scripts/lib/orchestration/single-story-close/runner.js +2 -4
  72. package/.agents/scripts/lib/orchestration/single-story-lease-guard.js +32 -35
  73. package/.agents/scripts/lib/orchestration/skill-capsule-loader.js +1 -2
  74. package/.agents/scripts/lib/orchestration/story-close/auto-refresh-runner.js +451 -503
  75. package/.agents/scripts/lib/orchestration/story-close/baseline-attribution/phases/pre-merge-attribution.js +8 -2
  76. package/.agents/scripts/lib/orchestration/story-close/baseline-attribution/phases/refresh-commit.js +47 -2
  77. package/.agents/scripts/lib/orchestration/story-close/baseline-attribution/phases/regression-projection.js +2 -2
  78. package/.agents/scripts/lib/orchestration/story-close/format-autofix.js +358 -54
  79. package/.agents/scripts/lib/orchestration/story-close/phases/close.js +1 -1
  80. package/.agents/scripts/lib/orchestration/story-close/phases/gates.js +3 -2
  81. package/.agents/scripts/lib/orchestration/story-close/phases/locked-pipeline.js +30 -3
  82. package/.agents/scripts/lib/orchestration/story-close/post-merge-close.js +5 -18
  83. package/.agents/scripts/lib/orchestration/story-close/pre-merge-validation.js +3 -3
  84. package/.agents/scripts/lib/orchestration/story-close-recovery.js +33 -16
  85. package/.agents/scripts/lib/orchestration/story-reachability.js +47 -0
  86. package/.agents/scripts/lib/orchestration/ticket-validator-conflicts.js +2 -33
  87. package/.agents/scripts/lib/orchestration/ticketing/bulk.js +42 -64
  88. package/.agents/scripts/lib/orchestration/ticketing/reads.js +9 -0
  89. package/.agents/scripts/lib/orchestration/ticketing/state.js +50 -436
  90. package/.agents/scripts/lib/orchestration/ticketing/transition.js +471 -0
  91. package/.agents/scripts/lib/orchestration/ticketing.js +0 -1
  92. package/.agents/scripts/lib/orchestration/wave-record-notifications.js +1 -1
  93. package/.agents/scripts/lib/orchestration/wave-record-projection.js +1 -7
  94. package/.agents/scripts/lib/project-root.js +17 -0
  95. package/.agents/scripts/lib/story-adjacency.js +76 -0
  96. package/.agents/scripts/lib/story-lifecycle.js +1 -1
  97. package/.agents/scripts/lib/transpile.js +93 -0
  98. package/.agents/scripts/lib/wave-runner/tick.js +4 -153
  99. package/.agents/scripts/lib/workers/crap-worker.js +1 -1
  100. package/.agents/scripts/lib/workers/maintainability-report-worker.js +1 -1
  101. package/.agents/scripts/lib/worktree/lifecycle/creation.js +20 -2
  102. package/.agents/scripts/lib/worktree/lifecycle/force-drain.js +90 -0
  103. package/.agents/scripts/lib/worktree/lifecycle/reap.js +26 -8
  104. package/.agents/scripts/lib/worktree/node-modules-strategy.js +74 -0
  105. package/.agents/scripts/providers/github/tickets.js +110 -6
  106. package/.agents/scripts/run-lint.js +9 -0
  107. package/.agents/scripts/run-tests.js +24 -4
  108. package/.agents/scripts/stories-wave-tick.js +8 -5
  109. package/.agents/scripts/story-init.js +149 -10
  110. package/.agents/scripts/sync-branch-from-base.js +1 -1
  111. package/.agents/skills/stack/qa/lighthouse-baseline/SKILL.md +1 -1
  112. package/.agents/workflows/audit-documentation.md +226 -0
  113. package/.agents/workflows/epic-deliver.md +16 -23
  114. package/.agents/workflows/epic-plan.md +1 -1
  115. package/.agents/workflows/helpers/epic-deliver-story.md +17 -28
  116. package/.agents/workflows/helpers/single-story-deliver.md +2 -1
  117. package/.agents/workflows/onboard.md +4 -3
  118. package/.agents/workflows/story-deliver.md +1 -1
  119. package/README.md +13 -8
  120. package/lib/cli/init.js +336 -0
  121. package/package.json +2 -1
  122. package/.agents/scripts/lib/auto-refresh-baselines.js +0 -308
  123. package/.agents/scripts/lib/close-validation.js +0 -897
  124. package/.agents/scripts/lib/orchestration/cascade-grouping.js +0 -275
  125. package/.agents/scripts/lib/orchestration/epic-runner/progress-reporter.js +0 -69
  126. package/.agents/scripts/lib/orchestration/story-close/format-autofix-scoped.js +0 -221
  127. package/.agents/scripts/lib/orchestration/story-close/format-autofix-shared.js +0 -123
  128. package/.agents/scripts/lib/task-utils.js +0 -26
  129. package/.agents/scripts/story-deliver-prepare.js +0 -267
@@ -321,7 +321,13 @@ export async function planEpic(
321
321
  Logger.warn(
322
322
  `[Epic Planner] Epic #${epicId} already has all requested planning artifacts. Aborting to prevent duplicates. Use --force to re-plan.`,
323
323
  );
324
- return;
324
+ return {
325
+ persisted: false,
326
+ reason: 'already-planned',
327
+ prdId: existing.prd,
328
+ techSpecId: existing.techSpec,
329
+ acceptanceSpecId: existing.acceptanceSpec,
330
+ };
325
331
  }
326
332
  // Under --force we now OVERWRITE the canonical context tickets in place
327
333
  // (same issue numbers, refreshed bodies) rather than closing + recreating
@@ -388,8 +394,18 @@ export async function planEpic(
388
394
  if (acceptanceSpecId !== null) {
389
395
  artifactLines.push(`- [ ] Acceptance Spec: #${acceptanceSpecId}`);
390
396
  }
397
+ // Idempotent append (Story #4019): strip any pre-existing
398
+ // `## Planning Artifacts` section before re-appending. The `--force`
399
+ // path already stripped it in `healAndCleanupArtifacts`, but the
400
+ // partial-recovery rerun (e.g. PRD present, Tech Spec missing) reaches
401
+ // here with a body that may still carry a stale section — without the
402
+ // strip, every rerun stacked a duplicate section onto the Epic body.
391
403
  const appendBody = `\n\n## Planning Artifacts\n${artifactLines.join('\n')}\n`;
392
- const newBody = epic.body + appendBody;
404
+ const strippedBody = epic.body.replace(
405
+ /\n*## Planning Artifacts[\s\S]*$/,
406
+ '',
407
+ );
408
+ const newBody = strippedBody + appendBody;
393
409
 
394
410
  /** @type {{ add?: string[], remove?: string[] }} */
395
411
  const labelMutations = {};
@@ -411,4 +427,12 @@ export async function planEpic(
411
427
 
412
428
  Logger.info(`[Epic Planner] Epic #${epicId} updated successfully.`);
413
429
  Logger.info(`[Epic Planner] Planning pipeline complete!`);
430
+
431
+ return {
432
+ persisted: true,
433
+ reason: force ? 'force-replan' : 'persisted',
434
+ prdId,
435
+ techSpecId,
436
+ acceptanceSpecId,
437
+ };
414
438
  }
@@ -7,10 +7,10 @@
7
7
  */
8
8
 
9
9
  import path from 'node:path';
10
- import { PROJECT_ROOT } from '../../../config-resolver.js';
11
10
  import { Logger } from '../../../Logger.js';
12
11
  import { AGENT_LABELS, TYPE_LABELS } from '../../../label-constants.js';
13
12
  import { cleanupPhaseTempFiles } from '../../../plan-phase-cleanup.js';
13
+ import { PROJECT_ROOT } from '../../../project-root.js';
14
14
  import { acquireEpicPlanLease } from '../../epic-plan-lease-guard.js';
15
15
  import {
16
16
  initialize as initializePlanState,
@@ -119,7 +119,7 @@ export async function runSpecPhase(
119
119
 
120
120
  await initializePlanState({ provider, epicId });
121
121
 
122
- await planEpic(
122
+ const planResult = await planEpic(
123
123
  epicId,
124
124
  provider,
125
125
  { prdContent, techSpecContent, acceptanceSpecContent },
@@ -129,6 +129,7 @@ export async function runSpecPhase(
129
129
  planningRisk,
130
130
  },
131
131
  );
132
+ const specChanged = planResult?.persisted !== false;
132
133
 
133
134
  const afterPlan = await provider.getEpic(epicId);
134
135
  const prdId = afterPlan.linkedIssues?.prd ?? null;
@@ -199,10 +200,27 @@ export async function runSpecPhase(
199
200
  Logger.info(`[epic-plan-spec] Review routing: ${reviewRouting.decision}.`);
200
201
  Logger.info(`[epic-plan-spec] ${reviewRouting.operatorMessage}`);
201
202
 
202
- Logger.info(
203
- `[epic-plan-spec] Flipping Epic #${epicId} to ${AGENT_LABELS.REVIEW_SPEC}...`,
204
- );
205
- await setEpicLabel(provider, epicId, AGENT_LABELS.REVIEW_SPEC);
203
+ // Story #4019 (refining #3905): a spec-phase rerun that changed nothing
204
+ // (planEpic short-circuited on `already-planned`) MUST NOT demote a
205
+ // fully-decomposed `agent::ready` Epic back to `agent::review-spec` —
206
+ // there is no new spec content to review. The demotion fires only when
207
+ // the spec actually persisted/changed, or when the Epic is not at
208
+ // `agent::ready` (where the flip is the normal forward transition).
209
+ const epicLabels = afterPlan.labels ?? [];
210
+ const skipDemotion = !specChanged && epicLabels.includes(AGENT_LABELS.READY);
211
+ let labelTransition;
212
+ if (skipDemotion) {
213
+ labelTransition = 'kept-ready';
214
+ Logger.info(
215
+ `[epic-plan-spec] Spec unchanged (${planResult?.reason ?? 'already-planned'}) and Epic #${epicId} is ${AGENT_LABELS.READY} — keeping ${AGENT_LABELS.READY} (no demotion to ${AGENT_LABELS.REVIEW_SPEC}).`,
216
+ );
217
+ } else {
218
+ labelTransition = 'review-spec';
219
+ Logger.info(
220
+ `[epic-plan-spec] Flipping Epic #${epicId} to ${AGENT_LABELS.REVIEW_SPEC}...`,
221
+ );
222
+ await setEpicLabel(provider, epicId, AGENT_LABELS.REVIEW_SPEC);
223
+ }
206
224
 
207
225
  const cleanup = await cleanupPhaseTempFiles({ phase: 'spec', epicId });
208
226
 
@@ -231,5 +249,7 @@ export async function runSpecPhase(
231
249
  freshness,
232
250
  planningRisk,
233
251
  reviewRouting,
252
+ specChanged,
253
+ labelTransition,
234
254
  };
235
255
  }
@@ -19,9 +19,9 @@
19
19
  * Throws if no open Stories are found.
20
20
  */
21
21
 
22
- import { parseBlockedBy } from '../../../dependency-parser.js';
23
22
  import { computeWaves } from '../../../Graph.js';
24
23
  import { TYPE_LABELS } from '../../../label-constants.js';
24
+ import { buildStoryAdjacency } from '../../../story-adjacency.js';
25
25
  import { WaveScheduler } from '../wave-scheduler.js';
26
26
 
27
27
  /**
@@ -116,30 +116,17 @@ function normalizeWavesForEmit(waves) {
116
116
  * Convert an ordered list of story tickets into the adjacency/taskMap shape
117
117
  * that `Graph.computeWaves()` expects.
118
118
  *
119
- * Dependency source order (must match manifest-builder.js so dispatch manifest
120
- * and runtime wave scheduling never disagree):
121
- * 1. Canonical: `blocked by #NNN` / `depends on #NNN` parsed from the story
122
- * ticket body via `parseBlockedBy` (same parser the dispatcher uses).
123
- * 2. Fallback: explicit `dependencies` array on the provider-returned story
124
- * object (present in fixture / test payloads; optional in live GitHub
125
- * payloads).
126
- * Only edges to other stories in this Epic are retained — foreign IDs are
127
- * dropped so the DAG stays closed over the scheduled set.
119
+ * The adjacency comes from the shared story-level builder
120
+ * (`lib/story-adjacency.js#buildStoryAdjacency`), which owns the
121
+ * dependency-source ordering contract (body `blocked by` references via
122
+ * `parseBlockedBy`, then explicit `dependencies[]`) and drops foreign
123
+ * edges so the DAG stays closed over the scheduled set.
128
124
  */
129
125
  function buildStoryDag(stories) {
130
- const adjacency = new Map();
126
+ const adjacency = buildStoryAdjacency(stories);
131
127
  const taskMap = new Map();
132
- const storyIds = new Set(stories.map((s) => Number(s.id ?? s.number)));
133
128
  for (const s of stories) {
134
129
  const id = Number(s.id ?? s.number);
135
- const fromBody = parseBlockedBy(s.body ?? '');
136
- const fromField = Array.isArray(s.dependencies)
137
- ? s.dependencies.map(Number)
138
- : [];
139
- const merged = [...new Set([...fromBody, ...fromField])]
140
- .map(Number)
141
- .filter((dep) => dep !== id && storyIds.has(dep));
142
- adjacency.set(id, merged);
143
130
  taskMap.set(id, { ...s, id });
144
131
  }
145
132
  return { adjacency, taskMap };
@@ -24,7 +24,7 @@ import { EPIC_RUN_PROGRESS_TYPE, STATE_EMOJI } from './signals.js';
24
24
  * (`…`) when the string was longer. Returns the empty string for any
25
25
  * falsy input so table cells never render `undefined`/`null`.
26
26
  */
27
- export function truncate(s, n) {
27
+ function truncate(s, n) {
28
28
  if (!s) return '';
29
29
  return s.length > n ? `${s.slice(0, n - 1)}…` : s;
30
30
  }
@@ -272,7 +272,6 @@ export async function renderProgressBody({
272
272
  * wave: number,
273
273
  * concurrencyCap?: number,
274
274
  * stories?: Array<{ id: number, title?: string, state?: string,
275
- * tasksDone?: number, tasksTotal?: number,
276
275
  * blockerCommentId?: string }>,
277
276
  * }>,
278
277
  * currentWave: number,
@@ -100,7 +100,6 @@ export function phaseToState(phase) {
100
100
  * storyId: number,
101
101
  * branch?: string,
102
102
  * phase: 'init'|'implementing'|'closing'|'blocked'|'done',
103
- * tasks?: [{ id, title?, state, commitSha? }],
104
103
  * title?: string,
105
104
  * updatedAt?: string,
106
105
  * }
@@ -109,16 +108,11 @@ export function parseStoryRunProgressComment(comment) {
109
108
  const payload = parseFencedJsonComment(comment);
110
109
  if (!payload || typeof payload !== 'object') return null;
111
110
  const phase = typeof payload.phase === 'string' ? payload.phase : undefined;
112
- const tasks = Array.isArray(payload.tasks) ? payload.tasks : [];
113
- const tasksTotal = tasks.length;
114
- const tasksDone = tasks.filter((t) => t && t.state === 'done').length;
115
111
  return {
116
112
  storyId: Number(payload.storyId),
117
113
  title: typeof payload.title === 'string' ? payload.title : '',
118
114
  phase,
119
115
  state: phaseToState(phase),
120
- tasksDone,
121
- tasksTotal,
122
116
  };
123
117
  }
124
118
 
@@ -0,0 +1,103 @@
1
+ import path from 'node:path';
2
+ import { resolveComponents } from '../../../baselines/components.js';
3
+ import { componentOrder } from './_bullet-format.js';
4
+
5
+ /**
6
+ * Shared component-regression walk for the progress-signal drift detectors
7
+ * (Story #3984). `crap-drift.js` and `maintainability-drift.js` previously
8
+ * duplicated this baseline-rollup → per-component compare → bullet-list
9
+ * shape verbatim; the only divergence was the breach comparator (CRAP is
10
+ * "lower is better", maintainability is "higher is better") and the bullet
11
+ * text. Both detectors now parameterize this walk.
12
+ *
13
+ * Pure — the caller loads the baseline via `lib/baselines/reader.js#load(...)`
14
+ * and passes the resulting `{ rollup }` plus the gate config block.
15
+ *
16
+ * Only components whose rollup breaches the configured floor surface — a
17
+ * breach in a component-scoped floor does NOT report against `*` unless
18
+ * `*` itself breaches. This keeps the rollout narrowly targeted: when an
19
+ * operator wires up a per-component floor for `api`, regressing `api`
20
+ * names `api` — not `*`.
21
+ *
22
+ * @param {{
23
+ * rollup?: Record<string, Record<string, number>>,
24
+ * gateConfig?: { floors?: Record<string, Record<string, number>> } & object,
25
+ * }} params
26
+ * @param {{
27
+ * isBreach: (value: number, floor: number) => boolean,
28
+ * formatBullet: (name: string, axis: string, value: number, floor: number) => string,
29
+ * }} spec
30
+ * @returns {string[]}
31
+ */
32
+ export function walkComponentRegressions(params = {}, spec) {
33
+ const rollup = params.rollup ?? {};
34
+ const gateConfig = params.gateConfig ?? {};
35
+ const floors = gateConfig.floors ?? {};
36
+ const components = resolveComponents(gateConfig);
37
+ const names = new Set([
38
+ ...Object.keys(components),
39
+ ...Object.keys(floors),
40
+ ...Object.keys(rollup),
41
+ ]);
42
+ const bullets = [];
43
+ for (const name of [...names].sort(componentOrder)) {
44
+ const aggregate = rollup[name];
45
+ if (!aggregate || typeof aggregate !== 'object') continue;
46
+ const floor = floors[name] ?? floors['*'];
47
+ if (!floor || typeof floor !== 'object') continue;
48
+ for (const axis of Object.keys(floor).sort()) {
49
+ const target = floor[axis];
50
+ const value = aggregate[axis];
51
+ if (typeof target !== 'number' || !Number.isFinite(target)) continue;
52
+ if (typeof value !== 'number' || !Number.isFinite(value)) continue;
53
+ if (!spec.isBreach(value, target)) continue;
54
+ bullets.push(spec.formatBullet(name, axis, value, target));
55
+ }
56
+ }
57
+ return bullets;
58
+ }
59
+
60
+ /**
61
+ * Shared wave-start snapshot persistence for the drift detectors. Both
62
+ * detectors persist a `{ capturedAt, ...metadata, scores }` JSON document
63
+ * under `<cwd>/<baselineDir>/<filename>` so a resumed epic run can re-read
64
+ * the wave-start anchor rather than lose it, and both treat persistence as
65
+ * best-effort (the in-memory baseline still works when a write fails).
66
+ *
67
+ * @param {{
68
+ * fs: { readFileSync?: Function, writeFileSync?: Function, mkdirSync?: Function },
69
+ * baselinePath: string,
70
+ * metadata?: Record<string, unknown>,
71
+ * }} opts
72
+ */
73
+ export function createSnapshotStore({ fs, baselinePath, metadata = {} }) {
74
+ return {
75
+ persist(scores) {
76
+ if (!fs.writeFileSync) return;
77
+ try {
78
+ fs.mkdirSync?.(path.dirname(baselinePath), { recursive: true });
79
+ fs.writeFileSync(
80
+ baselinePath,
81
+ JSON.stringify(
82
+ { capturedAt: new Date().toISOString(), ...metadata, scores },
83
+ null,
84
+ 2,
85
+ ),
86
+ );
87
+ } catch {
88
+ // persistence is best-effort; the in-memory baseline still works
89
+ }
90
+ },
91
+
92
+ load() {
93
+ if (!fs.readFileSync) return null;
94
+ try {
95
+ const raw = fs.readFileSync(baselinePath, 'utf-8');
96
+ const parsed = JSON.parse(raw);
97
+ return parsed?.scores ?? null;
98
+ } catch {
99
+ return null;
100
+ }
101
+ },
102
+ };
103
+ }
@@ -1,9 +1,12 @@
1
1
  import nodeFs from 'node:fs';
2
2
  import path from 'node:path';
3
- import { resolveComponents } from '../../../baselines/components.js';
4
3
  import { loadCoverage as defaultLoadCoverage } from '../../../coverage-utils.js';
5
4
  import { calculateCrapForSource } from '../../../crap-engine.js';
6
- import { componentOrder, formatNumber } from './_bullet-format.js';
5
+ import { formatNumber } from './_bullet-format.js';
6
+ import {
7
+ createSnapshotStore,
8
+ walkComponentRegressions,
9
+ } from './component-drift.js';
7
10
 
8
11
  const DEFAULT_THRESHOLD = 5.0;
9
12
  const DEFAULT_CEILING = 30;
@@ -43,11 +46,10 @@ export function coverageKeyMatches(key, suffix) {
43
46
  *
44
47
  * 🧨 crap: <component> <axis> <value> > floor <floor>
45
48
  *
46
- * Only components whose rollup exceeds the configured floor surface a
47
- * breach in a component-scoped floor does NOT report against `*` unless
48
- * `*` itself breaches. This keeps the rollout narrowly targeted: when an
49
- * operator wires up a per-component floor for `api`, regressing `api`
50
- * names `api` — not `*`.
49
+ * CRAP is a "lower is better" gate every axis breach reports when
50
+ * `value > floor`. Component-scoped breaches do NOT trigger a `*` bullet
51
+ * unless `*` itself breaches. The walk itself is the shared
52
+ * `component-drift.js` helper (Story #3984).
51
53
  *
52
54
  * @param {{
53
55
  * rollup?: Record<string, Record<string, number>>,
@@ -56,34 +58,11 @@ export function coverageKeyMatches(key, suffix) {
56
58
  * @returns {string[]}
57
59
  */
58
60
  export function detectComponentRegressions(params = {}) {
59
- const rollup = params.rollup ?? {};
60
- const gateConfig = params.gateConfig ?? {};
61
- const floors = gateConfig.floors ?? {};
62
- const components = resolveComponents(gateConfig);
63
- const names = new Set([
64
- ...Object.keys(components),
65
- ...Object.keys(floors),
66
- ...Object.keys(rollup),
67
- ]);
68
- const bullets = [];
69
- // CRAP is a "lower is better" gate — every axis under crap is ≤ floor.
70
- for (const name of [...names].sort(componentOrder)) {
71
- const aggregate = rollup[name];
72
- if (!aggregate || typeof aggregate !== 'object') continue;
73
- const floor = floors[name] ?? floors['*'];
74
- if (!floor || typeof floor !== 'object') continue;
75
- for (const axis of Object.keys(floor).sort()) {
76
- const cap = floor[axis];
77
- const value = aggregate[axis];
78
- if (typeof cap !== 'number' || !Number.isFinite(cap)) continue;
79
- if (typeof value !== 'number' || !Number.isFinite(value)) continue;
80
- if (value <= cap) continue;
81
- bullets.push(
82
- `🧨 crap: ${name} ${axis} ${formatNumber(value)} > floor ${formatNumber(cap)}`,
83
- );
84
- }
85
- }
86
- return bullets;
61
+ return walkComponentRegressions(params, {
62
+ isBreach: (value, cap) => value > cap,
63
+ formatBullet: (name, axis, value, cap) =>
64
+ `🧨 crap: ${name} ${axis} ${formatNumber(value)} > floor ${formatNumber(cap)}`,
65
+ });
87
66
  }
88
67
 
89
68
  /**
@@ -141,6 +120,11 @@ export function createCrapDriftDetector(opts = {}) {
141
120
  const baselineDir = opts.baselineDir ?? '.agents/state';
142
121
  const baselinePath = path.join(cwd, baselineDir, BASELINE_FILENAME);
143
122
  const logger = opts.logger ?? null;
123
+ const store = createSnapshotStore({
124
+ fs,
125
+ baselinePath,
126
+ metadata: { ceiling, threshold },
127
+ });
144
128
 
145
129
  let baseline = null;
146
130
 
@@ -220,39 +204,13 @@ export function createCrapDriftDetector(opts = {}) {
220
204
  }
221
205
  }
222
206
  baseline = snapshot;
223
- if (fs.writeFileSync) {
224
- try {
225
- fs.mkdirSync?.(path.dirname(baselinePath), { recursive: true });
226
- fs.writeFileSync(
227
- baselinePath,
228
- JSON.stringify(
229
- {
230
- capturedAt: new Date().toISOString(),
231
- ceiling,
232
- threshold,
233
- scores: snapshot,
234
- },
235
- null,
236
- 2,
237
- ),
238
- );
239
- } catch {
240
- // persistence is best-effort; the in-memory baseline still works
241
- }
242
- }
207
+ store.persist(snapshot);
243
208
  return snapshot;
244
209
  },
245
210
 
246
211
  loadBaseline() {
247
- if (!fs.readFileSync) return null;
248
- try {
249
- const raw = fs.readFileSync(baselinePath, 'utf-8');
250
- const parsed = JSON.parse(raw);
251
- baseline = parsed?.scores ?? null;
252
- return baseline;
253
- } catch {
254
- return null;
255
- }
212
+ baseline = store.load();
213
+ return baseline;
256
214
  },
257
215
 
258
216
  async detect() {
@@ -1,9 +1,12 @@
1
1
  import nodeFs from 'node:fs';
2
2
  import path from 'node:path';
3
3
 
4
- import { resolveComponents } from '../../../baselines/components.js';
5
4
  import { calculateForSource } from '../../../maintainability-engine.js';
6
- import { componentOrder, formatNumber } from './_bullet-format.js';
5
+ import { formatNumber } from './_bullet-format.js';
6
+ import {
7
+ createSnapshotStore,
8
+ walkComponentRegressions,
9
+ } from './component-drift.js';
7
10
 
8
11
  const DEFAULT_THRESHOLD = 2.0;
9
12
  // Distinct from the canonical ratchet baseline at `baselines/maintainability.json`
@@ -12,6 +15,35 @@ const DEFAULT_THRESHOLD = 2.0;
12
15
  // grep for the canonical baseline no longer hits the snapshot.
13
16
  const BASELINE_FILENAME = 'wave-mi-snapshot.json';
14
17
 
18
+ /**
19
+ * Detect per-component maintainability regressions from a baseline rollup
20
+ * against the gate's configured floors. Pure — the caller loads the
21
+ * baseline via `lib/baselines/reader.js#load('maintainability')` and passes
22
+ * the resulting `{ rollup }` plus the gate config block.
23
+ *
24
+ * Bullet shape (Task #1919, Epic #1786):
25
+ *
26
+ * 📉 maintainability: <component> <axis> <value> < floor <floor>
27
+ *
28
+ * Maintainability is "higher is better" — every axis breach reports when
29
+ * `value < floor`. Component-scoped breaches do NOT trigger a `*` bullet
30
+ * unless `*` itself breaches. The walk itself is the shared
31
+ * `component-drift.js` helper (Story #3984).
32
+ *
33
+ * @param {{
34
+ * rollup?: Record<string, Record<string, number>>,
35
+ * gateConfig?: { floors?: Record<string, Record<string, number>> } & object,
36
+ * }} params
37
+ * @returns {string[]}
38
+ */
39
+ export function detectComponentRegressions(params = {}) {
40
+ return walkComponentRegressions(params, {
41
+ isBreach: (value, target) => value < target,
42
+ formatBullet: (name, axis, value, target) =>
43
+ `📉 maintainability: ${name} ${axis} ${formatNumber(value)} < floor ${formatNumber(target)}`,
44
+ });
45
+ }
46
+
15
47
  /**
16
48
  * Detects per-file maintainability drop versus a wave-start baseline.
17
49
  *
@@ -40,56 +72,6 @@ const BASELINE_FILENAME = 'wave-mi-snapshot.json';
40
72
  * baselineDir?: string, // directory (under cwd) to persist snapshot
41
73
  * }} [opts]
42
74
  */
43
- /**
44
- * Detect per-component maintainability regressions from a baseline rollup
45
- * against the gate's configured floors. Pure — the caller loads the
46
- * baseline via `lib/baselines/reader.js#load('maintainability')` and passes
47
- * the resulting `{ rollup }` plus the gate config block.
48
- *
49
- * Bullet shape (Task #1919, Epic #1786):
50
- *
51
- * 📉 maintainability: <component> <axis> <value> < floor <floor>
52
- *
53
- * Maintainability is "higher is better" — every axis breach reports when
54
- * `value < floor`. Component-scoped breaches do NOT trigger a `*` bullet
55
- * unless `*` itself breaches.
56
- *
57
- * @param {{
58
- * rollup?: Record<string, Record<string, number>>,
59
- * gateConfig?: { floors?: Record<string, Record<string, number>> } & object,
60
- * }} params
61
- * @returns {string[]}
62
- */
63
- export function detectComponentRegressions(params = {}) {
64
- const rollup = params.rollup ?? {};
65
- const gateConfig = params.gateConfig ?? {};
66
- const floors = gateConfig.floors ?? {};
67
- const components = resolveComponents(gateConfig);
68
- const names = new Set([
69
- ...Object.keys(components),
70
- ...Object.keys(floors),
71
- ...Object.keys(rollup),
72
- ]);
73
- const bullets = [];
74
- for (const name of [...names].sort(componentOrder)) {
75
- const aggregate = rollup[name];
76
- if (!aggregate || typeof aggregate !== 'object') continue;
77
- const floor = floors[name] ?? floors['*'];
78
- if (!floor || typeof floor !== 'object') continue;
79
- for (const axis of Object.keys(floor).sort()) {
80
- const target = floor[axis];
81
- const value = aggregate[axis];
82
- if (typeof target !== 'number' || !Number.isFinite(target)) continue;
83
- if (typeof value !== 'number' || !Number.isFinite(value)) continue;
84
- if (value >= target) continue;
85
- bullets.push(
86
- `📉 maintainability: ${name} ${axis} ${formatNumber(value)} < floor ${formatNumber(target)}`,
87
- );
88
- }
89
- }
90
- return bullets;
91
- }
92
-
93
75
  export function createMaintainabilityDriftDetector(opts = {}) {
94
76
  const fs = opts.fs ?? nodeFs;
95
77
  const cwd = opts.cwd ?? process.cwd();
@@ -100,6 +82,7 @@ export function createMaintainabilityDriftDetector(opts = {}) {
100
82
  : DEFAULT_THRESHOLD;
101
83
  const baselineDir = opts.baselineDir ?? '.agents/state';
102
84
  const baselinePath = path.join(cwd, baselineDir, BASELINE_FILENAME);
85
+ const store = createSnapshotStore({ fs, baselinePath });
103
86
 
104
87
  let baseline = null;
105
88
 
@@ -126,34 +109,13 @@ export function createMaintainabilityDriftDetector(opts = {}) {
126
109
  if (s != null) snapshot[f] = s;
127
110
  }
128
111
  baseline = snapshot;
129
- if (fs.writeFileSync) {
130
- try {
131
- fs.mkdirSync?.(path.dirname(baselinePath), { recursive: true });
132
- fs.writeFileSync(
133
- baselinePath,
134
- JSON.stringify(
135
- { capturedAt: new Date().toISOString(), scores: snapshot },
136
- null,
137
- 2,
138
- ),
139
- );
140
- } catch {
141
- // persistence is best-effort; the in-memory baseline still works
142
- }
143
- }
112
+ store.persist(snapshot);
144
113
  return snapshot;
145
114
  },
146
115
 
147
116
  loadBaseline() {
148
- if (!fs.readFileSync) return null;
149
- try {
150
- const raw = fs.readFileSync(baselinePath, 'utf-8');
151
- const parsed = JSON.parse(raw);
152
- baseline = parsed?.scores ?? null;
153
- return baseline;
154
- } catch {
155
- return null;
156
- }
117
+ baseline = store.load();
118
+ return baseline;
157
119
  },
158
120
 
159
121
  async detect() {
@@ -13,7 +13,7 @@
13
13
  *
14
14
  * The renderer survives because its `{ body, payload }` output still feeds
15
15
  * two non-comment contracts: the `renderedBody` markdown the `/story-deliver`
16
- * and `single-story-deliver` CLIs (`story-phase.js`, `story-deliver-prepare.js`)
16
+ * and `single-story-deliver` CLIs (`story-phase.js`, the inline `story-init.js` prepare step)
17
17
  * relay to chat so the operator sees the phase table inline, and the snapshot
18
18
  * payload returned in those CLIs' JSON envelopes. `upsertStoryRunProgress`
19
19
  * therefore renders-only: it computes the body/payload and (optionally) mirrors
@@ -90,7 +90,7 @@ const STORY_PHASE_STATUS_EMOJI = {
90
90
  /**
91
91
  * Build the canonical default `phases[]` array for a freshly-initialized
92
92
  * 3-tier Story snapshot. All entries are `pending`; timestamps are null.
93
- * Exported so call sites (story-deliver-prepare, story-phase) and
93
+ * Exported so call sites (the story-init prepare step, story-phase) and
94
94
  * tests can build the same shape without re-implementing it.
95
95
  *
96
96
  * @returns {Array<{ name: string, status: 'pending', startedAt: null, endedAt: null }>}
@@ -9,8 +9,6 @@
9
9
  * "storyId": <number>,
10
10
  * "status": "done" | "blocked" | "failed",
11
11
  * "phase": "init|implementing|closing|blocked|done",
12
- * "tasksDone": <number>,
13
- * "tasksTotal": <number>,
14
12
  * "branchDeleted": <boolean>,
15
13
  * "blockerCommentId": <string|null>,
16
14
  * "detail": <string|undefined>,
@@ -119,8 +117,6 @@ function validateStoryReturnShape(obj) {
119
117
  }
120
118
  const value = { storyId, status };
121
119
  if (typeof obj.phase === 'string') value.phase = obj.phase;
122
- if (Number.isInteger(obj.tasksDone)) value.tasksDone = obj.tasksDone;
123
- if (Number.isInteger(obj.tasksTotal)) value.tasksTotal = obj.tasksTotal;
124
120
  if (typeof obj.branchDeleted === 'boolean') {
125
121
  value.branchDeleted = obj.branchDeleted;
126
122
  }
@@ -143,8 +139,8 @@ function quote(text) {
143
139
  * Story ticket's live state when the sub-agent return cannot be trusted.
144
140
  *
145
141
  * The result is always conservative — `status: 'failed'` unless the live
146
- * ticket carries `agent::done` (or `state: 'closed'`). The phase / counters
147
- * are best-effort, sourced from the Story's `story-run-progress` comment;
142
+ * ticket carries `agent::done` (or `state: 'closed'`). The phase is
143
+ * best-effort, sourced from the Story's `story-run-progress` comment;
148
144
  * absence of the comment is non-fatal.
149
145
  *
150
146
  * Story #3907 — a Story carrying `agent::blocked` reconciles to
@@ -163,8 +159,6 @@ function quote(text) {
163
159
  * storyId: number,
164
160
  * status: 'done' | 'blocked' | 'failed',
165
161
  * phase?: string,
166
- * tasksDone?: number,
167
- * tasksTotal?: number,
168
162
  * blockerCommentId?: string,
169
163
  * reconciledFromGitHub: true,
170
164
  * reconcileError?: string,
@@ -223,8 +217,8 @@ export async function reconcileStoryFromGitHub({ provider, storyId } = {}) {
223
217
  }
224
218
  }
225
219
 
226
- // Cross-look the story-run-progress comment for phase / task counters when
227
- // present. Story #3909 retired the per-Story story-run-progress *comment*
220
+ // Cross-look the story-run-progress comment for the phase when present.
221
+ // Story #3909 retired the per-Story story-run-progress *comment*
228
222
  // (the redundant mid-flight surface), so this read now usually finds nothing
229
223
  // and the reconciled row degrades to label-only — which is fine: the labels
230
224
  // are the authoritative state. Failure here is non-fatal.
@@ -237,12 +231,6 @@ export async function reconcileStoryFromGitHub({ provider, storyId } = {}) {
237
231
  const payload = comment ? parseFencedJsonComment(comment) : null;
238
232
  if (payload && typeof payload === 'object') {
239
233
  if (typeof payload.phase === 'string') out.phase = payload.phase;
240
- if (Array.isArray(payload.tasks)) {
241
- out.tasksTotal = payload.tasks.length;
242
- out.tasksDone = payload.tasks.filter(
243
- (t) => t && t.state === 'done',
244
- ).length;
245
- }
246
234
  }
247
235
  } catch (err) {
248
236
  out.reconcileError = err?.message ?? String(err);