session-orchestrator 3.16.0 → 3.19.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 (220) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.codex-plugin/plugin.json +1 -1
  4. package/.cursor/rules/030-wave-execution.mdc +17 -1
  5. package/CHANGELOG.md +130 -412
  6. package/README.md +17 -12
  7. package/SECURITY.md +190 -27
  8. package/agents/AGENTS.md +20 -3
  9. package/agents/code-implementer.md +6 -6
  10. package/agents/db-specialist.md +1 -1
  11. package/agents/qa-strategist.md +31 -6
  12. package/agents/schemas/qa-strategist.schema.json +27 -0
  13. package/agents/schemas/test-writer.schema.json +60 -2
  14. package/agents/security-reviewer.md +1 -1
  15. package/agents/session-reviewer.md +1 -1
  16. package/agents/test-writer.md +29 -10
  17. package/agents/ui-developer.md +1 -1
  18. package/commands/contract-version-bump.md +28 -0
  19. package/commands/portfolio.md +1 -1
  20. package/docs/README.md +2 -1
  21. package/docs/USER-GUIDE.md +8 -3
  22. package/docs/ci-setup.md +121 -7
  23. package/docs/codex-setup.md +1 -1
  24. package/docs/components.md +7 -7
  25. package/docs/cursor-setup.md +22 -9
  26. package/docs/events-schema.md +5 -1
  27. package/docs/instruction-delivery.md +444 -0
  28. package/docs/pi-setup.md +1 -1
  29. package/docs/rule-authoring.md +58 -9
  30. package/docs/session-config-reference.md +306 -6
  31. package/docs/session-config-template.md +66 -3
  32. package/docs/telemetry/telemetry-claims.md +204 -0
  33. package/docs/telemetry.md +158 -0
  34. package/hooks/_lib/guard-source-loader.mjs +467 -0
  35. package/hooks/_lib/lock-bootstrap.mjs +21 -0
  36. package/hooks/_lib/vcs-create-matcher.mjs +119 -0
  37. package/hooks/config-protection.mjs +0 -0
  38. package/hooks/enforce-commands.mjs +10 -2
  39. package/hooks/hooks-codex.json +1 -1
  40. package/hooks/hooks-cursor.json +11 -2
  41. package/hooks/hooks-pi.json +10 -0
  42. package/hooks/hooks.json +21 -1
  43. package/hooks/on-session-end.mjs +178 -18
  44. package/hooks/on-session-start.mjs +23 -0
  45. package/hooks/post-bash-write-verify.mjs +977 -0
  46. package/hooks/post-subagent-discovery-validator.mjs +256 -41
  47. package/hooks/pre-bash-destructive-guard.mjs +525 -160
  48. package/hooks/pre-bash-issue-budget.mjs +167 -0
  49. package/hooks/pre-bash-sessions-ledger-guard.mjs +627 -0
  50. package/hooks/pre-bash-templates-first.mjs +96 -63
  51. package/hooks/skill-invocation-telemetry.mjs +109 -10
  52. package/hooks/subagent-telemetry.mjs +527 -37
  53. package/package.json +16 -3
  54. package/pi/prompts/contract-version-bump.md +12 -0
  55. package/rules/README.md +32 -0
  56. package/scripts/archive-closed-prds.mjs +12 -22
  57. package/scripts/autopilot-multi.mjs +103 -20
  58. package/scripts/backfill-abandoned-sessions.mjs +160 -4
  59. package/scripts/check-doc-consistency.sh +17 -1
  60. package/scripts/compute-grounding-injection.sh +18 -3
  61. package/scripts/dialectic-deriver.mjs +7 -2
  62. package/scripts/eval-session.mjs +50 -9
  63. package/scripts/fleet-instruction-scan.mjs +141 -0
  64. package/scripts/lib/auto-dialectic.mjs +11 -2
  65. package/scripts/lib/auto-dream.mjs +16 -5
  66. package/scripts/lib/autopilot/mr-draft.mjs +31 -1
  67. package/scripts/lib/autopilot/worktree-pipeline.mjs +113 -5
  68. package/scripts/lib/backlog-scan.mjs +39 -6
  69. package/scripts/lib/blocked-commands-policy.mjs +340 -0
  70. package/scripts/lib/build-live-signals.mjs +7 -4
  71. package/scripts/lib/ci-status-banner.mjs +75 -12
  72. package/scripts/lib/claude-md-budget-lint.mjs +283 -34
  73. package/scripts/lib/command-blocker.mjs +1013 -58
  74. package/scripts/lib/config/config-protection.mjs +2 -1
  75. package/scripts/lib/config/context-coverage.mjs +82 -0
  76. package/scripts/lib/config/drift-check.mjs +9 -1
  77. package/scripts/lib/config/gitlab-portfolio.mjs +1 -1
  78. package/scripts/lib/config/issue-budget.mjs +123 -0
  79. package/scripts/lib/config/moc-staleness.mjs +98 -0
  80. package/scripts/lib/config/reconcile.mjs +21 -0
  81. package/scripts/lib/config/section-extractor.mjs +121 -1
  82. package/scripts/lib/config/worktree-orphans.mjs +138 -0
  83. package/scripts/lib/config-schema.mjs +23 -3
  84. package/scripts/lib/config.mjs +32 -0
  85. package/scripts/lib/context-coverage-banner.mjs +223 -0
  86. package/scripts/lib/convergence-monitor.mjs +49 -3
  87. package/scripts/lib/description-surface.mjs +535 -0
  88. package/scripts/lib/dispatcher/enumerate.mjs +136 -30
  89. package/scripts/lib/dispatcher/rank.mjs +22 -8
  90. package/scripts/lib/ecosystem-wizard/config-writer.mjs +26 -24
  91. package/scripts/lib/ecosystem-wizard/wizard-prompt.mjs +1 -1
  92. package/scripts/lib/eval/engine.mjs +47 -5
  93. package/scripts/lib/events.mjs +59 -7
  94. package/scripts/lib/evolve/autonomy-verdict.mjs +5 -0
  95. package/scripts/lib/evolve/autopilot-effectiveness.mjs +54 -7
  96. package/scripts/lib/gates/gate-full.mjs +15 -3
  97. package/scripts/lib/gates/gate-helpers.mjs +132 -6
  98. package/scripts/lib/gitlab-ops/stale-mr-sweep.mjs +28 -8
  99. package/scripts/lib/gitlab-portfolio/aggregator.mjs +8 -2
  100. package/scripts/lib/gitlab-portfolio/cli.mjs +1 -1
  101. package/scripts/lib/handover-gate.mjs +7 -3
  102. package/scripts/lib/harness-audit/categories/category4.mjs +22 -5
  103. package/scripts/lib/instruction-budget-guard.mjs +402 -51
  104. package/scripts/lib/io.mjs +345 -10
  105. package/scripts/lib/issue-budget.mjs +269 -0
  106. package/scripts/lib/issue-close-strip-labels.mjs +39 -9
  107. package/scripts/lib/label-scope.mjs +47 -0
  108. package/scripts/lib/learnings/schema.mjs +43 -3
  109. package/scripts/lib/lock-reaper.mjs +1 -2
  110. package/scripts/lib/memory-proposals/schema.mjs +36 -1
  111. package/scripts/lib/moc-staleness-banner.mjs +267 -0
  112. package/scripts/lib/peer-discovery.mjs +645 -0
  113. package/scripts/lib/pi-hook-bridge.mjs +146 -17
  114. package/scripts/lib/product-repo-detect.mjs +9 -8
  115. package/scripts/lib/project-hygiene.mjs +432 -0
  116. package/scripts/lib/quality-gate.mjs +167 -0
  117. package/scripts/lib/recommendations-v0.mjs +1 -1
  118. package/scripts/lib/reconcile/eligibility.mjs +1 -1
  119. package/scripts/lib/reconcile/emitter.mjs +23 -4
  120. package/scripts/lib/reconcile/engine.mjs +147 -39
  121. package/scripts/lib/reconcile/idempotency.mjs +114 -14
  122. package/scripts/lib/reconcile-nudge-banner.mjs +65 -9
  123. package/scripts/lib/resource-probe/evaluate.mjs +70 -4
  124. package/scripts/lib/resource-probe.mjs +19 -0
  125. package/scripts/lib/rule-loader.mjs +6 -0
  126. package/scripts/lib/scope-baseline.mjs +564 -0
  127. package/scripts/lib/scope-gate.mjs +399 -98
  128. package/scripts/lib/session-close-backfill.mjs +61 -6
  129. package/scripts/lib/session-end/phase-skip.mjs +1 -0
  130. package/scripts/lib/session-end/worktree-orphan-sweep.mjs +252 -0
  131. package/scripts/lib/session-id.mjs +221 -41
  132. package/scripts/lib/session-lock.mjs +304 -6
  133. package/scripts/lib/session-schema/constants.mjs +22 -3
  134. package/scripts/lib/session-schema/filters.mjs +88 -0
  135. package/scripts/lib/session-schema/validator.mjs +16 -0
  136. package/scripts/lib/session-schema.mjs +1 -0
  137. package/scripts/lib/sessions-integrity-banner.mjs +294 -0
  138. package/scripts/lib/sessions-staleness-banner.mjs +121 -12
  139. package/scripts/lib/skill-evolution/idempotency.mjs +135 -16
  140. package/scripts/lib/skill-evolution/mr-opener.mjs +9 -1
  141. package/scripts/lib/skill-health/join.mjs +35 -9
  142. package/scripts/lib/spiral-carryover.mjs +142 -30
  143. package/scripts/lib/state-md/mission-status.mjs +53 -3
  144. package/scripts/lib/subagents-schema.mjs +43 -9
  145. package/scripts/lib/telemetry/anon-id.mjs +141 -0
  146. package/scripts/lib/telemetry/consent.mjs +299 -0
  147. package/scripts/lib/telemetry/paths.mjs +27 -0
  148. package/scripts/lib/telemetry/queue.mjs +287 -0
  149. package/scripts/lib/telemetry/schema.mjs +384 -0
  150. package/scripts/lib/telemetry/sync.mjs +312 -0
  151. package/scripts/lib/test-runner/issue-reconcile.mjs +53 -13
  152. package/scripts/lib/tests-src-ratio.mjs +484 -0
  153. package/scripts/lib/validate/check-agents.mjs +56 -0
  154. package/scripts/lib/validate/check-hooks-symmetry.mjs +244 -10
  155. package/scripts/lib/validate/check-rules.mjs +217 -35
  156. package/scripts/lib/validate/check-test-value-bans.mjs +782 -0
  157. package/scripts/lib/validate/check-unicode-safety.mjs +1 -0
  158. package/scripts/lib/validate-vendored-rules.mjs +10 -2
  159. package/scripts/lib/vault-archive.mjs +17 -2
  160. package/scripts/lib/vault-backfill/glab.mjs +8 -0
  161. package/scripts/lib/vault-mirror/process.mjs +30 -0
  162. package/scripts/lib/vault-mirror/render-sessions.mjs +293 -36
  163. package/scripts/lib/vault-status/board-writer.mjs +63 -5
  164. package/scripts/lib/vault-status/narrative-mirror.mjs +13 -7
  165. package/scripts/lib/vcs-repo-spec.mjs +362 -0
  166. package/scripts/lib/wave-resource-gate.mjs +115 -11
  167. package/scripts/lib/worktree/listing.mjs +44 -7
  168. package/scripts/mcp-server.sh +32 -6
  169. package/scripts/measure-context-overhead.sh +151 -0
  170. package/scripts/memory-propose.mjs +72 -9
  171. package/scripts/print-applicable-rules.mjs +51 -12
  172. package/scripts/release.mjs +534 -0
  173. package/scripts/run-quality-gate.mjs +123 -5
  174. package/scripts/telemetry.mjs +250 -0
  175. package/scripts/validate-wave-scope.mjs +182 -17
  176. package/scripts/vault-integration-watcher.mjs +32 -10
  177. package/skills/_shared/config-reading.md +2 -2
  178. package/skills/bootstrap/fast-template.md +1 -1
  179. package/skills/claude-md-drift-check/checker.mjs +145 -28
  180. package/skills/contract-version-bump/SKILL.md +219 -0
  181. package/skills/discovery/SKILL.md +4 -4
  182. package/skills/discovery/issue-templates.md +11 -11
  183. package/skills/discovery/probes-audit.md +1 -1
  184. package/skills/discovery/probes-feature.md +1 -1
  185. package/skills/discovery/probes-session.md +26 -5
  186. package/skills/ecosystem-health/SKILL.md +1 -1
  187. package/skills/ecosystem-health/wizard.md +4 -4
  188. package/skills/evolve/SKILL.md +1 -0
  189. package/skills/gitlab-ops/SKILL.md +20 -12
  190. package/skills/gitlab-portfolio/SKILL.md +2 -2
  191. package/skills/hook-development/SKILL.md +1 -1
  192. package/skills/mode-selector/SKILL.md +1 -1
  193. package/skills/npm-publish/SKILL.md +97 -0
  194. package/skills/plan/SKILL.md +5 -5
  195. package/skills/plan/mode-feature.md +4 -4
  196. package/skills/plan/mode-new.md +10 -10
  197. package/skills/plan/mode-retro.md +1 -1
  198. package/skills/quality-gates/SKILL.md +1 -1
  199. package/skills/reconcile/SKILL.md +21 -4
  200. package/skills/session-end/SKILL.md +108 -14
  201. package/skills/session-end/discovery-scan.md +4 -2
  202. package/skills/session-end/drift-operations.md +4 -4
  203. package/skills/session-end/metrics-collection.md +13 -0
  204. package/skills/session-end/phase-3-2-docs-verification.md +1 -1
  205. package/skills/session-end/phase-3-6-tail.md +2 -1
  206. package/skills/session-end/plan-verification.md +5 -2
  207. package/skills/session-end/vault-operations.md +1 -1
  208. package/skills/session-end/verification-checklist.md +1 -1
  209. package/skills/session-plan/SKILL.md +6 -2
  210. package/skills/session-plan/wave-template.md +2 -0
  211. package/skills/session-start/SKILL.md +149 -7
  212. package/skills/session-start/phase-4-5-resource-health.md +15 -2
  213. package/skills/test-runner/SKILL.md +2 -2
  214. package/skills/vault-sync/SKILL.md +1 -1
  215. package/skills/vault-sync/package-lock.json +3 -3
  216. package/skills/vault-sync/validator.mjs +228 -40
  217. package/skills/wave-executor/SKILL.md +5 -2
  218. package/skills/wave-executor/circuit-breaker.md +2 -0
  219. package/skills/wave-executor/wave-loop.md +163 -10
  220. package/templates/_shared/loop.md +4 -4
@@ -221,9 +221,27 @@ function isCandidateDeadByAge({ relaxDeadByAge, assumeDeadBeforeMs, lastEventMs,
221
221
  function synthesizeRecord({ recordId, synthetic, gathered, nowMs }) {
222
222
  const startedIso = canonicalIso(gathered.startedAt, gathered.earliestMs ?? nowMs);
223
223
  const startedMs = Date.parse(startedIso);
224
- const terminalMs = Number.isFinite(gathered.lastTerminalMs) ? gathered.lastTerminalMs : nowMs;
225
- // completed_at = last terminal event (or now), never earlier than started_at.
226
- const completedIso = new Date(Math.max(startedMs, terminalMs)).toISOString();
224
+ // completed_at is events-attested, never the backfill-run wall-clock (#914 R1).
225
+ // A fabricated `nowMs` produced ~64h of phantom runtime on real records
226
+ // (e.g. main-2026-07-18-session-2: started 2026-07-18, "completed" 2026-07-21).
227
+ // Precedence, all events-derived — the schema requires a string, so `null`
228
+ // is not an option (session-schema/validator.mjs rejects non-string):
229
+ // 1. lastTerminalMs — a real STOPPED/ENDED event: the true end.
230
+ // 2. lastEventMs — last life-sign; an ESTIMATE (flagged), never the run time.
231
+ // 3. startedIso — no post-start event at all → duration 0 (flagged).
232
+ let completedEstimated = false;
233
+ let completedMs;
234
+ if (Number.isFinite(gathered.lastTerminalMs)) {
235
+ completedMs = gathered.lastTerminalMs;
236
+ } else if (Number.isFinite(gathered.lastEventMs)) {
237
+ completedMs = gathered.lastEventMs;
238
+ completedEstimated = true;
239
+ } else {
240
+ completedMs = startedMs;
241
+ completedEstimated = true;
242
+ }
243
+ // Guard the same monotonic invariant as before: never earlier than started_at.
244
+ const completedIso = new Date(Math.max(startedMs, completedMs)).toISOString();
227
245
 
228
246
  let sessionType = 'housekeeping';
229
247
  let inferred = true;
@@ -238,6 +256,10 @@ function synthesizeRecord({ recordId, synthetic, gathered, nowMs }) {
238
256
  const incomplete = ['total_waves', 'waves', 'agent_summary', 'total_agents', 'total_files_changed'];
239
257
  if (!startedFound) incomplete.push('started_at');
240
258
  if (!branchFound) incomplete.push('branch');
259
+ // completed_at was estimated from lastEventMs (or defaulted to started_at) —
260
+ // no terminal event was found, so mark it incomplete so downstream duration
261
+ // consumers can tell an events-attested end apart from an estimate (#914 R1).
262
+ if (completedEstimated) incomplete.push('completed_at');
241
263
 
242
264
  const record = {
243
265
  session_id: recordId,
@@ -261,6 +283,7 @@ function synthesizeRecord({ recordId, synthetic, gathered, nowMs }) {
261
283
  if (branchFound) record.branch = gathered.branch;
262
284
  if (inferred) record._session_type_inferred = true;
263
285
  if (synthetic) record._synthetic_session_id = true;
286
+ if (completedEstimated) record._completed_at_estimated = true;
264
287
  return record;
265
288
  }
266
289
 
@@ -296,6 +319,7 @@ function checkAlreadyRecorded(readFileSync, sessionsPath, { recordId, sessionId
296
319
  * { action: 'would-backfill', sessionId, record, deadByAge? } — dryRun only, not written
297
320
  * { action: 'skipped-no-identifier' } — neither id known
298
321
  * { action: 'skipped-already-recorded', sessionId } — already in sessions.jsonl
322
+ * { action: 'skipped-own-live-lock', sessionId } — candidate IS this live session (#863)
299
323
  * { action: 'skipped-foreign-live-lock', sessionId, lockSessionId }
300
324
  * { action: 'skipped-marker-exists', sessionId } — lost the TOCTOU claim
301
325
  * { action: 'error', error, sessionId? } — any failure, swallowed
@@ -421,7 +445,14 @@ export async function backfillAbandonedSession({
421
445
  if (dupe) return dupe;
422
446
  }
423
447
 
424
- // -- Liveness guard — never overwrite a FOREIGN live lock ---------------
448
+ // -- Liveness guard — never overwrite a FOREIGN live lock, and never ----
449
+ // record OUR OWN live lock as 'abandoned' (#863 defect 1). Before this
450
+ // fix, the guard below only ever ran when `foreign` was true — the
451
+ // "this candidate IS the currently-live session" branch fell straight
452
+ // through to synthesis + append, backfilling a session as 'abandoned'
453
+ // mere seconds after it started (observed on-disk: main-2026-07-21-
454
+ // session-2, started 13:58:28.189Z, recorded abandoned 13:58:31.065Z).
455
+ //
425
456
  // deadByAge (#731): set when a foreign live lock was present but the
426
457
  // candidate qualified for relaxation — surfaced on the final result so
427
458
  // callers (the migration CLI's summary) can count relaxed backfills.
@@ -436,8 +467,32 @@ export async function backfillAbandonedSession({
436
467
  const ownByUuid = Boolean(sessionId) && lock.session_id === sessionId;
437
468
  const ownBySemantic =
438
469
  (Boolean(semanticSessionId) && lock.semantic_session_id === semanticSessionId) ||
439
- (Boolean(recordId) && lock.semantic_session_id === recordId);
440
- const foreign = !ownByUuid && !ownBySemantic;
470
+ (Boolean(recordId) && lock.semantic_session_id === recordId) ||
471
+ // #863 (d) lock-shape trap: some on-disk locks store the semantic
472
+ // id directly in `session_id` with no separate `semantic_session_id`
473
+ // field at all (the "generated-semantic" acquisition path in
474
+ // on-session-start.mjs mints `session_id === the semantic id`, and
475
+ // bootstrapLock's v2 enrichment step — which would otherwise add
476
+ // `semantic_session_id` — never ran for that lock). Without this
477
+ // fallback, ownBySemantic is dead code for that shape and a
478
+ // genuinely-own lock is misclassified `foreign`, which can then be
479
+ // wrongly bypassed by the dead-by-age relaxation below despite
480
+ // being live right now.
481
+ (Boolean(semanticSessionId) && lock.session_id === semanticSessionId) ||
482
+ (Boolean(recordId) && lock.session_id === recordId);
483
+ const own = ownByUuid || ownBySemantic;
484
+ const foreign = !own;
485
+
486
+ // #863 defect 1 — an OWN lock that is still live means this session
487
+ // is actively running right now; it must never be recorded
488
+ // 'abandoned'. Runs BEFORE the foreign-live-lock guard below (which
489
+ // only ever fires when `foreign` is true). A STALE own lock
490
+ // (isLockLive === false) falls through unchanged — this is a
491
+ // liveness gate, not a blanket own-session off-switch.
492
+ if (own && isLockLive(lock, nowMs)) {
493
+ return { action: 'skipped-own-live-lock', sessionId: recordId };
494
+ }
495
+
441
496
  if (foreign && isLockLive(lock, nowMs)) {
442
497
  const relaxed = isCandidateDeadByAge({
443
498
  relaxDeadByAge,
@@ -276,6 +276,7 @@ async function decideReconcile({ repoRoot, cfg }) {
276
276
  ruleExpiryDays: cfg?.reconcile?.['rule-expiry-days'] ?? undefined,
277
277
  minRuleDays: cfg?.reconcile?.['min-rule-days'] ?? undefined,
278
278
  minInsightChars: cfg?.reconcile?.['min-insight-chars'] ?? undefined,
279
+ maxProposalsPerRun: cfg?.reconcile?.['max-proposals-per-run'] ?? undefined,
279
280
  now: new Date(),
280
281
  dryRun: true, // never write the candidate sidecar from the aggregator
281
282
  });
@@ -0,0 +1,252 @@
1
+ /**
2
+ * worktree-orphan-sweep.mjs — Phase 4b Worktree-Orphan Sweep (#831 / B5).
3
+ *
4
+ * Identifies worktree branches with 0 commits ahead of the base branch —
5
+ * orphans left behind by finished sessions — and returns them as DATA.
6
+ *
7
+ * ┌──────────────────────────────────────────────────────────────────────┐
8
+ * │ THE IRON RULE: this module PROPOSES, it never DISPOSES. │
9
+ * │ It executes ZERO mutating commands — no `git worktree remove`, no │
10
+ * │ `git worktree prune`, no `git branch -d/-D`, no `git push --delete`, │
11
+ * │ no rm/rmSync/unlinkSync, no reset/clean/checkout --/stash. The only │
12
+ * │ git invocations it makes are read-only: `worktree list --porcelain`, │
13
+ * │ `rev-list --count --end-of-options`, and — via isWorktreeClean() — │
14
+ * │ `status --porcelain` plus `status --short --branch`. │
15
+ * └──────────────────────────────────────────────────────────────────────┘
16
+ *
17
+ * Grounding: `.claude/rules/parallel-sessions.md` § PSA-003 ("Never Destroy
18
+ * What You Didn't Create") — the test is *"Did I create this file/commit/
19
+ * change? If not, it is not mine to touch."* A sweep probe created none of the
20
+ * worktrees it inspects, so removal is categorically not its call. The return
21
+ * field is deliberately named `candidates` (not `orphans`, not `toDelete`, not
22
+ * `removals`): the name itself encodes "the coordinator decides". The operator
23
+ * AUQ is rendered by the coordinator at session-end Phase 4b, never here.
24
+ *
25
+ * CONSERVATIVE DEFAULT (safety-critical): any git error, unparseable output,
26
+ * detached HEAD, unresolvable branch, or ambiguity of any kind → the worktree
27
+ * is NOT reported as a candidate. Silence must never be read as "safe to
28
+ * delete". Precedent: `isWorktreeClean()` in ./worktree-cleanup.mjs returns
29
+ * false on any git error, documented as "conservative PSA-003 default: never
30
+ * auto-remove a worktree we could not verify".
31
+ *
32
+ * Banner contract: mirrors the other `checkXxx()` probes
33
+ * (`scripts/lib/peer-cards/staleness-banner.mjs`,
34
+ * `scripts/lib/vault-staleness-banner.mjs`) — a single entry point returning
35
+ * `null` (silent no-op) or one `{ severity, message, ... }` object. Never an
36
+ * array, never `undefined`, never a throw.
37
+ *
38
+ * DI seam (#580-DI-001): SYNCHRONOUS `execFileFn` (default `execFileSync`),
39
+ * matching its Phase 4a sibling ./worktree-cleanup.mjs and
40
+ * scripts/lib/memory-cleanup/worktree-sweep.mjs, because session-end Phase 4b
41
+ * runs in a synchronous coordinator step. The autopilot worktree driver
42
+ * (scripts/lib/autopilot/worktree-pipeline.mjs) deliberately uses an ASYNC
43
+ * `opts.$` (zx) seam instead; the seams are kept divergent on purpose —
44
+ * unifying them would break the sync/async boundary.
45
+ *
46
+ * #577 HARDEN-001: every git call is `execFileFn('git', [args…])` with an args
47
+ * ARRAY and no shell. Branch and path values are attacker-influenceable and are
48
+ * never interpolated into a shell string.
49
+ */
50
+
51
+ import path from 'node:path';
52
+ import { execFileSync } from 'node:child_process';
53
+
54
+ import { isWorktreeClean } from './worktree-cleanup.mjs';
55
+
56
+ /**
57
+ * Strip a `<mainRepoName>-` prefix from a worktree directory basename to
58
+ * recover the session id, mirroring the auto-promoted layout
59
+ * `<basePath>/<repo-name>-<sessionId>/` used by `enterWorktree()`.
60
+ *
61
+ * Falls back to the full basename when the prefix does not match — a worktree
62
+ * created by hand still deserves a stable identifier in the report.
63
+ *
64
+ * @param {string} wtPath
65
+ * @param {string} mainRepoName
66
+ * @returns {string}
67
+ */
68
+ function deriveSessionId(wtPath, mainRepoName) {
69
+ const basename = path.basename(wtPath);
70
+ if (mainRepoName && basename.startsWith(`${mainRepoName}-`)) {
71
+ return basename.slice(mainRepoName.length + 1);
72
+ }
73
+ return basename;
74
+ }
75
+
76
+ /**
77
+ * Sweep for worktree branches with 0 commits ahead of the base branch.
78
+ *
79
+ * @param {object} [opts]
80
+ * @param {string} opts.repoRoot - REQUIRED absolute path to the repo root.
81
+ * @param {string} [opts.mainCheckoutRoot] - Absolute path to the main checkout.
82
+ * When omitted it is derived from the FIRST `worktree ` line of the porcelain
83
+ * output. (`path.basename(repoRoot)` is explicitly NOT a correct derivation —
84
+ * see the W3 T2 finding documented in ./worktree-cleanup.mjs.)
85
+ * @param {object} [opts.config] - Config. BOTH shapes are accepted, because the
86
+ * two sibling probes take the FULL config and index their own block
87
+ * internally while this one historically took the already-indexed block —
88
+ * an asymmetry that made the natural copy-paste (`config: $CONFIG`) leave
89
+ * `cfg.enabled` undefined:
90
+ * • the FULL config — an object carrying a `worktree-orphans` key, which is
91
+ * then indexed here; or
92
+ * • the already-indexed BLOCK — `{ enabled, 'base-branch', mode }`.
93
+ * OPT-IN: absent, `undefined`, `{}`, or any shape without `enabled: true`
94
+ * returns `null` before a single git invocation.
95
+ * @param {Function} [opts.execFileFn] - Injectable execFileSync seam for tests.
96
+ * Signature: (file: string, args: string[], options) => string. Also handed
97
+ * to `isWorktreeClean()` so the dirty-check shares the same seam.
98
+ * @returns {null | {severity: 'warn', message: string,
99
+ * candidates: Array<{wtPath: string, branch: string, sessionId: string, aheadCount: 0}>}}
100
+ * `null` on bad input, when disabled, on any failure, or when nothing is
101
+ * found. Otherwise ONE object whose `candidates` are proposals only —
102
+ * nothing has been, or will be, removed by this module.
103
+ */
104
+ export function checkWorktreeOrphans({ repoRoot, mainCheckoutRoot, config, execFileFn } = {}) {
105
+ // Silent no-op on bad input (consistent with the other Phase 4/4b banners).
106
+ if (!repoRoot || typeof repoRoot !== 'string') return null;
107
+
108
+ try {
109
+ const raw = config && typeof config === 'object' ? config : {};
110
+ // Accept the FULL config or the already-indexed block (see @param config).
111
+ const nested = raw['worktree-orphans'];
112
+ const cfg = nested && typeof nested === 'object' ? nested : raw;
113
+
114
+ // Config gate BEFORE any git invocation — a disabled sweep costs nothing.
115
+ // MUST be `!== true`, never `=== false`: `undefined !== false`, so the
116
+ // latter fails OPEN and runs git for a repo that never opted in —
117
+ // contradicting the parser's "never pays a single git invocation"
118
+ // guarantee, with a deletion prompt as the downstream consequence.
119
+ if (cfg.enabled !== true || cfg.mode === 'off') return null;
120
+
121
+ const exec = typeof execFileFn === 'function' ? execFileFn : execFileSync;
122
+
123
+ const rawBase = cfg['base-branch'];
124
+ const baseBranch = typeof rawBase === 'string' && rawBase.trim() ? rawBase.trim() : 'main';
125
+
126
+ // Anchor for `git worktree list`: the injected main checkout when provided,
127
+ // otherwise repoRoot (git reports the same worktree set from any member).
128
+ const listAnchor =
129
+ mainCheckoutRoot && typeof mainCheckoutRoot === 'string' ? mainCheckoutRoot : repoRoot;
130
+
131
+ // ── READ-ONLY GIT CALL 1 of 2 ───────────────────────────────────────────
132
+ let out;
133
+ try {
134
+ out = exec('git', ['-C', listAnchor, 'worktree', 'list', '--porcelain'], {
135
+ encoding: 'utf8',
136
+ });
137
+ } catch {
138
+ // Not a git repo, or git unavailable → conservative no-op.
139
+ return null;
140
+ }
141
+ if (typeof out !== 'string' || out.trim().length === 0) return null;
142
+
143
+ const entries = out.split('\n\n').filter(Boolean);
144
+
145
+ // Derive the canonical main checkout from the FIRST `worktree ` line.
146
+ let resolvedMain = mainCheckoutRoot;
147
+ if (!resolvedMain || typeof resolvedMain !== 'string') {
148
+ const firstLine = out.split('\n').find((l) => l.startsWith('worktree '));
149
+ if (!firstLine) return null;
150
+ resolvedMain = firstLine.slice('worktree '.length).trim();
151
+ }
152
+ if (!resolvedMain) return null;
153
+
154
+ const mainRepoName = path.basename(resolvedMain);
155
+ const candidates = [];
156
+
157
+ for (const entry of entries) {
158
+ const wtMatch = entry.match(/^worktree (.+)$/m);
159
+ if (!wtMatch) continue;
160
+ const wtPath = wtMatch[1].trim();
161
+ if (!wtPath) continue;
162
+
163
+ // The main checkout is never a candidate.
164
+ let isMain = false;
165
+ try {
166
+ isMain = path.resolve(wtPath) === path.resolve(resolvedMain);
167
+ } catch {
168
+ // Unresolvable path → conservative: treat as main (i.e. skip it).
169
+ isMain = true;
170
+ }
171
+ if (isMain) continue;
172
+
173
+ // Branch must be resolvable. A detached HEAD has no `branch` line →
174
+ // conservative: not a candidate.
175
+ const branchMatch = entry.match(/^branch refs\/heads\/(.+)$/m);
176
+ if (!branchMatch) continue;
177
+ const branch = branchMatch[1].trim();
178
+ if (!branch) continue;
179
+
180
+ // ── READ-ONLY GIT CALL: ahead-count ───────────────────────────────────
181
+ // `--end-of-options` is load-bearing, not decoration. Without it a
182
+ // base-branch shaped like an option (`--glob=refs/heads/*`) is parsed by
183
+ // rev-list as an OPTION rather than a revision: it exits 0 with `0`, so
184
+ // `Number.isFinite(0)` holds, no error path fires, and EVERY worktree is
185
+ // silently reported as a 0-ahead orphan. With it, the same value yields
186
+ // rc=128 ("option must come before non-option arguments") and lands in
187
+ // the conservative `continue` below. The config parser rejects such
188
+ // values too (_isSafeBaseBranch); this is the second layer, for callers
189
+ // that construct the config block by hand.
190
+ let countOut;
191
+ try {
192
+ countOut = exec(
193
+ 'git',
194
+ ['-C', resolvedMain, 'rev-list', '--count', '--end-of-options', `${baseBranch}..${branch}`],
195
+ { encoding: 'utf8' },
196
+ );
197
+ } catch {
198
+ // Unknown base branch, missing ref, or any git error → conservative:
199
+ // not a candidate. A sibling worktree is unaffected by this failure.
200
+ continue;
201
+ }
202
+
203
+ if (typeof countOut !== 'string') continue;
204
+ const aheadCount = parseInt(countOut.trim(), 10);
205
+ // Unparseable output → conservative: not a candidate.
206
+ if (!Number.isFinite(aheadCount)) continue;
207
+ // Any work ahead of the base branch → definitively NOT an orphan.
208
+ if (aheadCount !== 0) continue;
209
+
210
+ // ── READ-ONLY GIT CALLS: uncommitted-work check ───────────────────────
211
+ // 0 commits ahead is NOT sufficient evidence of orphan-ness: a worktree
212
+ // can hold modified, staged, or untracked work that no commit-count ever
213
+ // sees. Reusing the Phase 4a sibling keeps both phases on one definition
214
+ // of "clean" (status --porcelain + ahead-scan, false on ANY git error —
215
+ // its documented conservative PSA-003 default, which maps exactly onto
216
+ // "not a candidate" here without needing a separate error branch).
217
+ //
218
+ // EXCLUDE rather than flag: the field is named `candidates` because the
219
+ // coordinator renders it as a deletion prompt, and a worktree holding
220
+ // live work is not a candidate for deletion review at all. A `dirty: true`
221
+ // flag would push that judgement into the prompt layer, where a
222
+ // mis-rendered label degrades straight into data loss.
223
+ if (!isWorktreeClean(wtPath, { execFileFn: exec })) continue;
224
+
225
+ candidates.push({
226
+ wtPath,
227
+ branch,
228
+ sessionId: deriveSessionId(wtPath, mainRepoName),
229
+ aheadCount: 0,
230
+ });
231
+ }
232
+
233
+ if (candidates.length === 0) return null;
234
+
235
+ const listed = candidates.map((c) => `${path.basename(c.wtPath)} (${c.branch})`).join(', ');
236
+ const subject =
237
+ candidates.length === 1
238
+ ? '1 worktree branch has 0 commits ahead of the base branch'
239
+ : `${candidates.length} worktree branches have 0 commits ahead of the base branch`;
240
+
241
+ // The trailing "nothing was removed" clause is MANDATORY — it is the
242
+ // operator-visible proof of the no-delete invariant above.
243
+ const message =
244
+ `⚠ worktree-orphans: ${subject} — ${listed} — ` +
245
+ `review via the cleanup prompt; nothing was removed.`;
246
+
247
+ return { severity: 'warn', message, candidates };
248
+ } catch {
249
+ // Defensive catch-all — banner must never throw.
250
+ return null;
251
+ }
252
+ }