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
@@ -0,0 +1,93 @@
1
+ import { createRequire } from 'node:module';
2
+ import path from 'node:path';
3
+ import { Logger } from './Logger.js';
4
+
5
+ const require = createRequire(import.meta.url);
6
+
7
+ const TS_EXTS = new Set(['.ts', '.tsx', '.mts', '.cts']);
8
+
9
+ let _ts = null;
10
+ let _tsLoadFailed = false;
11
+
12
+ function loadTypeScript() {
13
+ if (_ts) return _ts;
14
+ if (_tsLoadFailed) return null;
15
+ try {
16
+ _ts = require('typescript');
17
+ return _ts;
18
+ } catch {
19
+ _tsLoadFailed = true;
20
+ return null;
21
+ }
22
+ }
23
+
24
+ /**
25
+ * Resolve the `typescript` package version, used to stamp baselines so
26
+ * consumers can detect transpiler drift. Returns `'0.0.0'` when the
27
+ * dependency is unresolvable — callers treat that sentinel as "unknown
28
+ * environment" and may refuse to persist a baseline that includes TS rows.
29
+ *
30
+ * @returns {string}
31
+ */
32
+ export function resolveTsTranspilerVersion() {
33
+ const ts = loadTypeScript();
34
+ if (ts && typeof ts.version === 'string') return ts.version;
35
+ return '0.0.0';
36
+ }
37
+
38
+ function isTypeScriptPath(filePath) {
39
+ return TS_EXTS.has(path.extname(String(filePath)).toLowerCase());
40
+ }
41
+
42
+ /**
43
+ * Pre-transpile TypeScript or TSX sources to JavaScript that the
44
+ * Esprima-based escomplex kernel can parse. Returns the input unchanged
45
+ * for `.js` / `.mjs` / `.cjs` paths.
46
+ *
47
+ * Type annotations introduce no control flow, so the transpiled output
48
+ * scores identically to the original TS for cyclomatic complexity,
49
+ * Halstead volume, and the maintainability index. `.tsx` uses the
50
+ * `react-jsx` emit so JSX expressions become function calls escomplex
51
+ * can read; `.preserve` would leave JSX in the output and Esprima would
52
+ * choke on it.
53
+ *
54
+ * On transpile failure the helper returns `null` — callers treat that
55
+ * as "skip this file" rather than crashing the scan.
56
+ *
57
+ * @param {string} filePath
58
+ * @param {string} source
59
+ * @returns {string|null}
60
+ */
61
+ export function transpileIfNeeded(filePath, source) {
62
+ if (!isTypeScriptPath(filePath)) return source;
63
+ const ts = loadTypeScript();
64
+ if (!ts) {
65
+ Logger.warn(
66
+ `[Maintainability] ⚠ typescript package not resolvable; cannot score ${filePath}. ` +
67
+ "Install with 'npm install --save-dev typescript' (peer dep, >=5.0.0).",
68
+ );
69
+ return null;
70
+ }
71
+ try {
72
+ const result = ts.transpileModule(source, {
73
+ compilerOptions: {
74
+ target: ts.ScriptTarget.ESNext,
75
+ module: ts.ModuleKind.ESNext,
76
+ isolatedModules: true,
77
+ noEmitHelpers: true,
78
+ importHelpers: false,
79
+ removeComments: false,
80
+ jsx: ts.JsxEmit.ReactJSX,
81
+ sourceMap: false,
82
+ },
83
+ fileName: path.basename(filePath),
84
+ reportDiagnostics: false,
85
+ });
86
+ return result.outputText;
87
+ } catch (err) {
88
+ Logger.warn(
89
+ `[Maintainability] ⚠ TS transpile failed for ${filePath}: ${err?.message ?? err}; skipping.`,
90
+ );
91
+ return null;
92
+ }
93
+ }
@@ -36,22 +36,11 @@ import { WaveRunnerError } from './wave-runner-error.js';
36
36
  * currentWave: number
37
37
  * totalWaves: number
38
38
  *
39
- * When `spec` is omitted, the planner falls back to the checkpoint's
40
- * `state.plan` (the GH-derived dependency-DAG grouping originally seeded
41
- * by /epic-plan) — behaviour is byte-identical to the pre-spec path.
42
- * When `spec` is supplied, wave grouping is driven by `spec.stories[].wave`
43
- * (the declarative SSOT from `.agents/epics/<epic-id>.yaml`) and slugs are
44
- * resolved to GH issue numbers via the sibling `<epic-id>.state.json`
45
- * mapping; the checkpoint is still consulted for `currentWave`,
46
- * `totalWaves`, and `waves[]` history but its `plan[]` is overridden.
39
+ * Wave grouping comes from the checkpoint's `state.plan` (the GH-derived
40
+ * dependency-DAG grouping originally seeded by /epic-plan).
47
41
  *
48
42
  * @typedef {object} WaveTickArgs
49
43
  * @property {number | { id: number }} epic
50
- * @property {object} [spec] Parsed epic-spec (see lib/spec/loader.js). When
51
- * provided, wave grouping comes from `spec.stories[].wave`.
52
- * @property {object} [state] Parsed epic-state (see lib/spec/loader.js).
53
- * When `spec` is provided, this must be supplied so slugs can resolve to
54
- * issue numbers via `state.mapping[slug].issueNumber`.
55
44
  * @property {{
56
45
  * provider?: object,
57
46
  * epicRunStateStore?: { read: () => Promise<object|null> },
@@ -74,8 +63,6 @@ export async function tick(args = {}) {
74
63
  } = args.collaborators ?? {};
75
64
  const ctx = args.ctx ?? {};
76
65
  const provider = collabProvider ?? ctx.provider;
77
- const spec = args.spec ?? null;
78
- const specState = args.state ?? null;
79
66
  if (!provider) {
80
67
  throw new WaveRunnerError('invalid-input', 'provider is required');
81
68
  }
@@ -104,7 +91,8 @@ export async function tick(args = {}) {
104
91
  }
105
92
 
106
93
  const currentWave = positiveIntOrZero(state.currentWave);
107
- const { plan, totalWaves } = resolvePlan(state, spec, specState);
94
+ const plan = Array.isArray(state.plan) ? state.plan : [];
95
+ const totalWaves = positiveIntOrZero(state.totalWaves);
108
96
  const history = Array.isArray(state.waves) ? state.waves : [];
109
97
 
110
98
  if (totalWaves === 0 || currentWave >= totalWaves) {
@@ -460,143 +448,6 @@ function storyIdOf(s) {
460
448
  return s.id ?? s.storyId ?? s.number;
461
449
  }
462
450
 
463
- /**
464
- * Walk `spec.features[].stories[]` and bucket entries by their `wave`
465
- * value, mapping slugs → GH issue numbers via the sibling state file.
466
- * Returns `Story[][]` indexed by wave number; missing waves between 0 and
467
- * the highest declared wave are emitted as empty arrays so wave N is
468
- * always reachable as `plan[N]`.
469
- *
470
- * Each emitted entry is shaped to match the checkpoint plan's
471
- * `{ id, title }` contract that the rest of `tick()` already consumes:
472
- *
473
- * - `id` is the GH issue number resolved from `state.mapping[slug].issueNumber`
474
- * so the same provider.getTicket(id) path used by the spec-less plan
475
- * keeps working unchanged.
476
- * - `title` is carried through from `story.title` so the wave-start
477
- * signal can include the Story's human-readable name without an
478
- * extra provider round-trip.
479
- * - `slug` is preserved on the entry so observability + future
480
- * re-resolution paths can re-key against the spec.
481
- *
482
- * When a Story slug has no resolved `issueNumber` in `state.mapping`
483
- * (a fresh spec entry the reconciler has not materialised yet), the entry
484
- * is skipped — un-materialised Stories cannot be dispatched anyway, and
485
- * including them with a `null` id would surface as a `story-fetch`
486
- * failure inside `tick()`. The reconciler will close the loop on the
487
- * next apply; until then, an empty wave is a faithful reflection of
488
- * GitHub state.
489
- *
490
- * Pure function — does not read disk, does not call GH. Callers are
491
- * expected to compose it with `loadSpec` + `loadState` from
492
- * `lib/spec/loader.js`.
493
- *
494
- * @param {object} spec Parsed epic-spec (see lib/spec/loader.js).
495
- * @param {{mapping?: Record<string, {issueNumber?: number}>}|null} [state]
496
- * Parsed epic-state. May be omitted; if missing, no entries can be
497
- * resolved and `groupByWave` returns `[]`.
498
- * @returns {Array<Array<{id: number, title?: string, slug: string}>>}
499
- */
500
- export function groupByWave(spec, state = null) {
501
- const mapping =
502
- state && typeof state.mapping === 'object' && state.mapping !== null
503
- ? state.mapping
504
- : {};
505
- const entries = extractValidStoryEntries(spec, mapping);
506
- if (entries.length === 0) return [];
507
- const byWave = new Map();
508
- let maxWave = -1;
509
- for (const { wave, entry } of entries) {
510
- if (!byWave.has(wave)) byWave.set(wave, []);
511
- byWave.get(wave).push(entry);
512
- if (wave > maxWave) maxWave = wave;
513
- }
514
- if (maxWave < 0) return [];
515
- const out = [];
516
- for (let i = 0; i <= maxWave; i += 1) {
517
- out.push(byWave.get(i) ?? []);
518
- }
519
- return out;
520
- }
521
-
522
- /**
523
- * Walk every feature/story pair in `spec` and emit only the entries that
524
- * survive the spec-validity cascade: the story must be a non-null object,
525
- * declare a non-negative integer `wave`, declare a string `slug`, and
526
- * resolve to a numeric `issueNumber` in `mapping`. Each surviving entry
527
- * is returned as `{ wave, entry }` where `entry` carries the same shape
528
- * (`{ id, title, slug }`) that `groupByWave` previously pushed into its
529
- * per-wave bucket.
530
- *
531
- * Extracted from `groupByWave` so the bucketing transform stays
532
- * straight-line; this predicate owns the entire defensive guard cascade
533
- * and is the right place to add new validation rules going forward.
534
- *
535
- * @param {object|null|undefined} spec Parsed epic-spec.
536
- * @param {Record<string, {issueNumber?: number}>} mapping
537
- * Slug → issue-number lookup from the sibling state file.
538
- * @returns {Array<{wave: number, entry: {id: number, title?: string, slug: string}}>}
539
- */
540
- export function extractValidStoryEntries(spec, mapping) {
541
- const out = [];
542
- const features = Array.isArray(spec?.features) ? spec.features : [];
543
- for (const feature of features) {
544
- const stories = Array.isArray(feature?.stories) ? feature.stories : [];
545
- for (const story of stories) {
546
- const resolved = resolveStoryEntry(story, mapping);
547
- if (resolved) out.push(resolved);
548
- }
549
- }
550
- return out;
551
- }
552
-
553
- /**
554
- * Validate a single `story` against the spec-validity cascade and return
555
- * `{ wave, entry }` when every guard passes, or `null` when any guard
556
- * trips. Splitting the per-story cascade out keeps both
557
- * `extractValidStoryEntries` (which owns iteration) and `resolveStoryEntry`
558
- * (which owns validation) below CRAP 5 even when none of the branches are
559
- * exercised at runtime — the predicate's cyclomatic footprint is small
560
- * enough that uncovered branches do not blow the baseline budget.
561
- *
562
- * @param {*} story Candidate story from `spec.features[].stories[]`.
563
- * @param {Record<string, {issueNumber?: number}>} mapping Slug → issue lookup.
564
- * @returns {{wave: number, entry: {id: number, title?: string, slug: string}} | null}
565
- */
566
- function resolveStoryEntry(story, mapping) {
567
- if (!story || typeof story !== 'object') return null;
568
- if (!Number.isInteger(story.wave) || story.wave < 0) return null;
569
- if (typeof story.slug !== 'string' || !story.slug) return null;
570
- const mapped = mapping[story.slug];
571
- if (!mapped || typeof mapped.issueNumber !== 'number') return null;
572
- return {
573
- wave: story.wave,
574
- entry: { id: mapped.issueNumber, title: story.title, slug: story.slug },
575
- };
576
- }
577
-
578
- /**
579
- * Resolve which plan + totalWaves drive this tick. When `spec` is
580
- * supplied, the spec-derived grouping wins (and totalWaves comes from
581
- * the spec since the checkpoint may lag); otherwise the checkpoint's
582
- * plan is used unchanged. Extracted so `tick()`'s cyclomatic complexity
583
- * stays inside its baseline budget — the route choice is now a single
584
- * call, not three ternaries inline.
585
- *
586
- * @param {object} state Checkpoint state (already validated as object).
587
- * @param {object|null} spec Parsed epic-spec or `null` when omitted.
588
- * @param {object|null} specState Parsed epic-state for slug mapping.
589
- * @returns {{plan: Array<Array<object>>, totalWaves: number}}
590
- */
591
- function resolvePlan(state, spec, specState) {
592
- if (spec) {
593
- const specPlan = groupByWave(spec, specState);
594
- return { plan: specPlan, totalWaves: specPlan.length };
595
- }
596
- const plan = Array.isArray(state.plan) ? state.plan : [];
597
- return { plan, totalWaves: positiveIntOrZero(state.totalWaves) };
598
- }
599
-
600
451
  /**
601
452
  * A Story is "done" when it carries `agent::done` OR its GitHub issue is
602
453
  * `state === 'closed'`. The closed-state arm (Story #3907) is what aligns the
@@ -29,7 +29,7 @@
29
29
  import fs from 'node:fs';
30
30
  import { parentPort } from 'node:worker_threads';
31
31
  import { calculateCrapForSource } from '../crap-engine.js';
32
- import { transpileIfNeeded } from '../maintainability-utils.js';
32
+ import { transpileIfNeeded } from '../transpile.js';
33
33
 
34
34
  /**
35
35
  * Pure handler for a single inbound worker message. Exported so unit
@@ -39,7 +39,7 @@ import {
39
39
  calculateReport,
40
40
  calculateReportForFile,
41
41
  } from '../maintainability-engine.js';
42
- import { transpileIfNeeded } from '../maintainability-utils.js';
42
+ import { transpileIfNeeded } from '../transpile.js';
43
43
 
44
44
  /**
45
45
  * Score a pre-sourced content string (Story #3696). Mirrors
@@ -12,6 +12,7 @@ import fs from 'node:fs';
12
12
  import {
13
13
  applyNodeModulesStrategy,
14
14
  installDependencies,
15
+ probeReusedInstall,
15
16
  } from '../node-modules-strategy.js';
16
17
  import {
17
18
  findByPath,
@@ -41,6 +42,23 @@ export async function ensure(ctx, storyId, branch) {
41
42
  if (typeof ctx.onPhase === 'function') ctx.onPhase(name);
42
43
  };
43
44
 
45
+ // Reuse must not blindly skip the install: when the prior run's install
46
+ // failed (or was interrupted), `skipped/worktree-reused` defeats the
47
+ // retry exactly when it matters — `deriveInstallAction('skipped')` skips.
48
+ // Probe for a completed install; on a failed probe, retry the install
49
+ // here and surface its real outcome.
50
+ const reuseInstallStatus = () => {
51
+ const strategy = ctx.config?.nodeModulesStrategy ?? 'per-worktree';
52
+ const probe = probeReusedInstall(strategy, wtPath);
53
+ if (probe.status !== 'failed') return probe;
54
+ ctx.logger.warn(
55
+ `worktree.reuse install probe failed (${probe.reason}) — retrying install in ${wtPath}`,
56
+ );
57
+ // `ctx.installDependencies` is a test-injection seam; production ctx
58
+ // bags leave it unset and get the real installer.
59
+ return (ctx.installDependencies ?? installDependencies)(ctx, wtPath);
60
+ };
61
+
44
62
  if (existing) {
45
63
  if (existing.branch && existing.branch !== br) {
46
64
  throw new Error(
@@ -53,7 +71,7 @@ export async function ensure(ctx, storyId, branch) {
53
71
  return {
54
72
  path: wtPath,
55
73
  created: false,
56
- installStatus: { status: 'skipped', reason: 'worktree-reused' },
74
+ installStatus: reuseInstallStatus(),
57
75
  };
58
76
  }
59
77
 
@@ -87,7 +105,7 @@ export async function ensure(ctx, storyId, branch) {
87
105
  return {
88
106
  path: wtPath,
89
107
  created: false,
90
- installStatus: { status: 'skipped', reason: 'worktree-reused' },
108
+ installStatus: reuseInstallStatus(),
91
109
  };
92
110
  }
93
111
  }
@@ -118,10 +118,89 @@ export function findHoldersInPath(wtPath, opts = {}) {
118
118
  }));
119
119
  }
120
120
 
121
+ /**
122
+ * Pure: compute the set of pids that must never be killed — `selfPid` plus
123
+ * its full ancestor chain — from a process table of `{ pid, ppid }` rows.
124
+ * Cycle-guarded (a corrupt/raced table cannot loop forever). `selfPid` is
125
+ * always included even when the table is empty or missing its row, so the
126
+ * guard fails safe.
127
+ *
128
+ * @param {number} selfPid
129
+ * @param {Array<{ pid: number, ppid?: number }>} table
130
+ * @returns {Set<number>}
131
+ */
132
+ export function computeProtectedPids(selfPid, table) {
133
+ const protectedPids = new Set([selfPid]);
134
+ if (!Array.isArray(table) || table.length === 0) return protectedPids;
135
+ const parentOf = new Map();
136
+ for (const row of table) {
137
+ if (row && typeof row.pid === 'number' && typeof row.ppid === 'number') {
138
+ parentOf.set(row.pid, row.ppid);
139
+ }
140
+ }
141
+ let cursor = selfPid;
142
+ while (parentOf.has(cursor)) {
143
+ const ppid = parentOf.get(cursor);
144
+ if (protectedPids.has(ppid)) break; // cycle guard
145
+ protectedPids.add(ppid);
146
+ cursor = ppid;
147
+ }
148
+ return protectedPids;
149
+ }
150
+
151
+ /**
152
+ * Enumerate the full Windows process table as `{ pid, ppid }` rows so the
153
+ * kill set can exclude the invoking shell / orchestrator ancestry.
154
+ * Best-effort: any failure returns `[]` (callers still protect `selfPid`).
155
+ *
156
+ * @param {object} [opts]
157
+ * @param {Function} [opts.spawn] Injection point for tests (default `spawnSync`).
158
+ * @param {string} [opts.platform] Override `process.platform` for tests.
159
+ * @returns {Array<{ pid: number, ppid: number }>}
160
+ */
161
+ export function fetchProcessTable(opts = {}) {
162
+ const spawn = opts.spawn ?? spawnSync;
163
+ const platform = opts.platform ?? process.platform;
164
+ if (platform !== 'win32') return [];
165
+
166
+ const script =
167
+ 'Get-CimInstance Win32_Process | Select-Object ProcessId, ParentProcessId | ConvertTo-Json -Compress';
168
+ let res;
169
+ try {
170
+ res = spawn(
171
+ 'powershell.exe',
172
+ ['-NoProfile', '-NonInteractive', '-Command', script],
173
+ { encoding: 'utf8', timeout: 15_000 },
174
+ );
175
+ } catch {
176
+ return [];
177
+ }
178
+ if (!res || res.status !== 0 || !res.stdout) return [];
179
+ let parsed;
180
+ try {
181
+ parsed = JSON.parse(String(res.stdout).trim());
182
+ } catch {
183
+ return [];
184
+ }
185
+ const list = Array.isArray(parsed) ? parsed : [parsed];
186
+ return list
187
+ .filter((p) => p && typeof p.ProcessId === 'number')
188
+ .map((p) => ({
189
+ pid: p.ProcessId,
190
+ ppid: typeof p.ParentProcessId === 'number' ? p.ParentProcessId : -1,
191
+ }));
192
+ }
193
+
121
194
  /**
122
195
  * `taskkill /T /F /PID <pid>` for each holder. Returns the pids reported
123
196
  * as terminated. Per-pid failures are logged but do not throw — caller
124
197
  * decides whether the partial kill is enough to retry.
198
+ *
199
+ * Self-preservation (Story #4018): holders are matched by command-line
200
+ * substring, which can select the invoking shell or the orchestrator's own
201
+ * ancestor chain (any ancestor whose command line mentions the worktree
202
+ * path). Before any `taskkill /T /F`, the kill set excludes `selfPid` and
203
+ * its full ancestor chain — `/T` on an ancestor would kill this process too.
125
204
  */
126
205
  export function terminateHolders(holders, opts = {}) {
127
206
  const spawn = opts.spawn ?? spawnSync;
@@ -130,9 +209,20 @@ export function terminateHolders(holders, opts = {}) {
130
209
  if (platform !== 'win32') return [];
131
210
  if (!Array.isArray(holders) || holders.length === 0) return [];
132
211
 
212
+ const selfPid = opts.selfPid ?? process.pid;
213
+ const protectedPids =
214
+ opts.protectedPids ??
215
+ computeProtectedPids(selfPid, fetchProcessTable({ spawn, platform }));
216
+
133
217
  const killed = [];
134
218
  for (const h of holders) {
135
219
  if (!h || typeof h.pid !== 'number') continue;
220
+ if (protectedPids.has(h.pid)) {
221
+ logger.warn(
222
+ `force-drain: skipping pid=${h.pid} name=${h.name ?? '?'} — self/ancestor of this process (never killed)`,
223
+ );
224
+ continue;
225
+ }
136
226
  let res;
137
227
  try {
138
228
  res = spawn('taskkill.exe', ['/T', '/F', '/PID', String(h.pid)], {
@@ -134,7 +134,7 @@ async function fsRmWithRetry(
134
134
  return { success: true, attempts: attempt };
135
135
  } catch (err) {
136
136
  lastErr = err;
137
- if (attempt < maxRetries) {
137
+ if (attempt < maxRetries && retryDelay > 0) {
138
138
  await new Promise((r) => setTimeout(r, retryDelay));
139
139
  }
140
140
  }
@@ -160,8 +160,8 @@ function handleRemoveFailure(
160
160
  classification,
161
161
  attempt,
162
162
  maxAttempts,
163
+ { retryDelaysMs = [0, 150, 350, 700, 1200, 2000], sleepFn = sleepSync } = {},
163
164
  ) {
164
- const retryDelaysMs = [0, 150, 350, 700, 1200, 2000];
165
165
  const { isLockLike, isCwdLike } = classification;
166
166
  if ((isLockLike || isCwdLike) && attempt < maxAttempts) {
167
167
  const delay = retryDelaysMs[attempt] ?? 300;
@@ -169,13 +169,13 @@ function handleRemoveFailure(
169
169
  ctx.logger.warn(
170
170
  `worktree.reap remove hit ${reasonClass} error; retrying in ${delay}ms (${attempt}/${maxAttempts})`,
171
171
  );
172
- sleepSync(delay);
172
+ sleepFn(delay);
173
173
  return 'continue';
174
174
  }
175
175
  return 'break';
176
176
  }
177
177
 
178
- async function runGitWorktreeRemoveLoop(ctx, wtPath) {
178
+ async function runGitWorktreeRemoveLoop(ctx, wtPath, retryOpts = {}) {
179
179
  const maxAttempts = ctx.platform === 'win32' ? 6 : 2;
180
180
  let lastReason = 'worktree-remove-failed';
181
181
  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
@@ -193,6 +193,7 @@ async function runGitWorktreeRemoveLoop(ctx, wtPath) {
193
193
  classification,
194
194
  attempt,
195
195
  maxAttempts,
196
+ retryOpts,
196
197
  );
197
198
  if (action === 'break') break;
198
199
  }
@@ -205,6 +206,7 @@ function tryForceRemoveFallback(
205
206
  lastReason,
206
207
  forceRemoveBackoffMs,
207
208
  maxAttempts,
209
+ sleepFn = sleepSync,
208
210
  ) {
209
211
  if (!(WINDOWS_LOCK_RE.test(lastReason) || WINDOWS_CWD_RE.test(lastReason))) {
210
212
  return { handled: false, lastReason };
@@ -212,7 +214,7 @@ function tryForceRemoveFallback(
212
214
  ctx.logger.warn(
213
215
  `worktree.reap remove exhausted Windows lock retry; retrying with --force in ${forceRemoveBackoffMs}ms path=${wtPath}`,
214
216
  );
215
- sleepSync(forceRemoveBackoffMs);
217
+ sleepFn(forceRemoveBackoffMs);
216
218
  const forced = ctx.git.gitSpawn(
217
219
  ctx.repoRoot,
218
220
  'worktree',
@@ -248,8 +250,9 @@ async function tryStage15WindowsFsRm({
248
250
  push,
249
251
  lastReason,
250
252
  priorAttempts,
253
+ sleepFn = sleepSync,
251
254
  }) {
252
- sleepSync(forceRemoveBackoffMs);
255
+ sleepFn(forceRemoveBackoffMs);
253
256
  try {
254
257
  await fsRm(wtPath, {
255
258
  recursive: true,
@@ -304,6 +307,7 @@ async function handleFsRmFailure({
304
307
  lastReason,
305
308
  forceRemoveBackoffMs,
306
309
  fsRm,
310
+ sleepFn,
307
311
  }) {
308
312
  // Stage 1.5 — coverage-leak quiesce + extended fs.rm budget (Windows only).
309
313
  if (ctx.platform === 'win32') {
@@ -316,6 +320,7 @@ async function handleFsRmFailure({
316
320
  push,
317
321
  lastReason,
318
322
  priorAttempts: rmResult.attempts,
323
+ sleepFn,
319
324
  });
320
325
  if (stage15) return stage15;
321
326
  }
@@ -347,7 +352,12 @@ async function handleFsRmFailure({
347
352
  export async function removeWorktreeWithRecovery(ctx, wtPath, opts = {}) {
348
353
  const { storyId = null, branch = null, push = false } = opts;
349
354
  const forceRemoveBackoffMs = opts.forceRemoveBackoffMs ?? 3000;
350
- const removeLoop = await runGitWorktreeRemoveLoop(ctx, wtPath);
355
+ const retryDelay = opts.retryDelay ?? 200;
356
+ const { retryDelaysMs, sleepFn } = opts;
357
+ const removeLoop = await runGitWorktreeRemoveLoop(ctx, wtPath, {
358
+ ...(retryDelaysMs ? { retryDelaysMs } : {}),
359
+ ...(sleepFn ? { sleepFn } : {}),
360
+ });
351
361
  if (removeLoop.removed) return { removed: true };
352
362
  let { lastReason } = removeLoop;
353
363
  if (ctx.platform === 'win32' && opts.forceRemoveFallback !== false) {
@@ -357,6 +367,7 @@ export async function removeWorktreeWithRecovery(ctx, wtPath, opts = {}) {
357
367
  lastReason,
358
368
  forceRemoveBackoffMs,
359
369
  removeLoop.maxAttempts,
370
+ sleepFn,
360
371
  );
361
372
  if (fallback.handled) return fallback.result;
362
373
  lastReason = fallback.lastReason;
@@ -367,7 +378,7 @@ export async function removeWorktreeWithRecovery(ctx, wtPath, opts = {}) {
367
378
  const fsRm = ctx.fsRm ?? fsPromisesRm;
368
379
  const rmResult = await fsRmWithRetry(fsRm, wtPath, {
369
380
  maxRetries: 5,
370
- retryDelay: 200,
381
+ retryDelay,
371
382
  });
372
383
  if (!rmResult.success) {
373
384
  return handleFsRmFailure({
@@ -380,6 +391,7 @@ export async function removeWorktreeWithRecovery(ctx, wtPath, opts = {}) {
380
391
  lastReason,
381
392
  forceRemoveBackoffMs,
382
393
  fsRm,
394
+ sleepFn,
383
395
  });
384
396
  }
385
397
  finalizeGitWorktreeRemove(ctx);
@@ -571,6 +583,12 @@ export async function reap(ctx, storyId, opts = {}) {
571
583
  storyId: storyIdN,
572
584
  branch,
573
585
  push: opts.push === true,
586
+ ...(opts.retryDelaysMs ? { retryDelaysMs: opts.retryDelaysMs } : {}),
587
+ ...(opts.retryDelay !== undefined ? { retryDelay: opts.retryDelay } : {}),
588
+ ...(opts.sleepFn ? { sleepFn: opts.sleepFn } : {}),
589
+ ...(opts.forceRemoveBackoffMs !== undefined
590
+ ? { forceRemoveBackoffMs: opts.forceRemoveBackoffMs }
591
+ : {}),
574
592
  });
575
593
  if (!removeResult.removed) {
576
594
  return {
@@ -119,6 +119,80 @@ export function selectInstallCommand(strategy, wtPath, fsLike = fs) {
119
119
  return { cmd: 'npm', args: ['ci'] };
120
120
  }
121
121
 
122
+ /**
123
+ * Per-package-manager "install completed" marker files written into
124
+ * `node_modules/` by the install command itself. Their presence (and
125
+ * freshness relative to the lockfile) is the cheapest reliable signal that a
126
+ * prior install ran to completion — a failed/interrupted install leaves
127
+ * `node_modules` partially populated without (or with a stale) marker.
128
+ */
129
+ const INSTALL_MARKERS = [
130
+ '.package-lock.json', // npm ci / npm install
131
+ '.modules.yaml', // pnpm
132
+ '.yarn-state.yml', // yarn berry (node-modules linker)
133
+ '.yarn-integrity', // yarn classic
134
+ ];
135
+
136
+ const LOCKFILES = ['package-lock.json', 'pnpm-lock.yaml', 'yarn.lock'];
137
+
138
+ function safeMtimeMs(fsLike, p) {
139
+ try {
140
+ return fsLike.statSync(p).mtimeMs;
141
+ } catch {
142
+ return null;
143
+ }
144
+ }
145
+
146
+ /**
147
+ * Pure: probe whether a **reused** worktree already carries a completed,
148
+ * up-to-date install. Worktree reuse must not blindly report
149
+ * `skipped/worktree-reused` — when the prior run's install *failed*, that
150
+ * status defeats the install retry exactly when it matters
151
+ * (`deriveInstallAction('skipped')` treats it as "nothing to do").
152
+ *
153
+ * Returns the same shape as `installDependencies`:
154
+ * - `{ status: 'skipped', reason: 'worktree-reused' }` — a completed
155
+ * install was detected (or the strategy never installs per-tree);
156
+ * safe to skip.
157
+ * - `{ status: 'failed', reason }` — missing/incomplete/stale install
158
+ * detected; callers should retry the install.
159
+ *
160
+ * @param {string} strategy One of `per-worktree | pnpm-store | symlink`.
161
+ * @param {string} wtPath Absolute worktree path.
162
+ * @param {{ existsSync: Function, statSync: Function }} [fsLike] Injectable for tests.
163
+ * @returns {{ status: 'skipped' | 'failed', reason: string }}
164
+ */
165
+ export function probeReusedInstall(strategy, wtPath, fsLike = fs) {
166
+ // `symlink` re-points node_modules at a donor — no per-tree install to probe.
167
+ if (strategy === 'symlink') {
168
+ return { status: 'skipped', reason: 'worktree-reused' };
169
+ }
170
+ if (!fsLike.existsSync(path.join(wtPath, 'package.json'))) {
171
+ return { status: 'skipped', reason: 'no-package-json' };
172
+ }
173
+ const nmPath = path.join(wtPath, 'node_modules');
174
+ if (!fsLike.existsSync(nmPath)) {
175
+ return { status: 'failed', reason: 'reuse-node-modules-missing' };
176
+ }
177
+ const marker = INSTALL_MARKERS.map((m) => path.join(nmPath, m)).find((p) =>
178
+ fsLike.existsSync(p),
179
+ );
180
+ if (!marker) {
181
+ return { status: 'failed', reason: 'reuse-install-incomplete' };
182
+ }
183
+ const markerMtime = safeMtimeMs(fsLike, marker);
184
+ const lockfile = LOCKFILES.map((l) => path.join(wtPath, l)).find((p) =>
185
+ fsLike.existsSync(p),
186
+ );
187
+ if (lockfile && markerMtime !== null) {
188
+ const lockMtime = safeMtimeMs(fsLike, lockfile);
189
+ if (lockMtime !== null && lockMtime > markerMtime) {
190
+ return { status: 'failed', reason: 'reuse-node-modules-stale' };
191
+ }
192
+ }
193
+ return { status: 'skipped', reason: 'worktree-reused' };
194
+ }
195
+
122
196
  /** Pure: retry policy keyed off the chosen command. pnpm gets 3× + 5min. */
123
197
  export function installRetryPolicy(cmd) {
124
198
  const isPnpm = cmd === 'pnpm';