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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/.cursor/rules/030-wave-execution.mdc +17 -1
- package/CHANGELOG.md +130 -412
- package/README.md +17 -12
- package/SECURITY.md +190 -27
- package/agents/AGENTS.md +20 -3
- package/agents/code-implementer.md +6 -6
- package/agents/db-specialist.md +1 -1
- package/agents/qa-strategist.md +31 -6
- package/agents/schemas/qa-strategist.schema.json +27 -0
- package/agents/schemas/test-writer.schema.json +60 -2
- package/agents/security-reviewer.md +1 -1
- package/agents/session-reviewer.md +1 -1
- package/agents/test-writer.md +29 -10
- package/agents/ui-developer.md +1 -1
- package/commands/contract-version-bump.md +28 -0
- package/commands/portfolio.md +1 -1
- package/docs/README.md +2 -1
- package/docs/USER-GUIDE.md +8 -3
- package/docs/ci-setup.md +121 -7
- package/docs/codex-setup.md +1 -1
- package/docs/components.md +7 -7
- package/docs/cursor-setup.md +22 -9
- package/docs/events-schema.md +5 -1
- package/docs/instruction-delivery.md +444 -0
- package/docs/pi-setup.md +1 -1
- package/docs/rule-authoring.md +58 -9
- package/docs/session-config-reference.md +306 -6
- package/docs/session-config-template.md +66 -3
- package/docs/telemetry/telemetry-claims.md +204 -0
- package/docs/telemetry.md +158 -0
- package/hooks/_lib/guard-source-loader.mjs +467 -0
- package/hooks/_lib/lock-bootstrap.mjs +21 -0
- package/hooks/_lib/vcs-create-matcher.mjs +119 -0
- package/hooks/config-protection.mjs +0 -0
- package/hooks/enforce-commands.mjs +10 -2
- package/hooks/hooks-codex.json +1 -1
- package/hooks/hooks-cursor.json +11 -2
- package/hooks/hooks-pi.json +10 -0
- package/hooks/hooks.json +21 -1
- package/hooks/on-session-end.mjs +178 -18
- package/hooks/on-session-start.mjs +23 -0
- package/hooks/post-bash-write-verify.mjs +977 -0
- package/hooks/post-subagent-discovery-validator.mjs +256 -41
- package/hooks/pre-bash-destructive-guard.mjs +525 -160
- package/hooks/pre-bash-issue-budget.mjs +167 -0
- package/hooks/pre-bash-sessions-ledger-guard.mjs +627 -0
- package/hooks/pre-bash-templates-first.mjs +96 -63
- package/hooks/skill-invocation-telemetry.mjs +109 -10
- package/hooks/subagent-telemetry.mjs +527 -37
- package/package.json +16 -3
- package/pi/prompts/contract-version-bump.md +12 -0
- package/rules/README.md +32 -0
- package/scripts/archive-closed-prds.mjs +12 -22
- package/scripts/autopilot-multi.mjs +103 -20
- package/scripts/backfill-abandoned-sessions.mjs +160 -4
- package/scripts/check-doc-consistency.sh +17 -1
- package/scripts/compute-grounding-injection.sh +18 -3
- package/scripts/dialectic-deriver.mjs +7 -2
- package/scripts/eval-session.mjs +50 -9
- package/scripts/fleet-instruction-scan.mjs +141 -0
- package/scripts/lib/auto-dialectic.mjs +11 -2
- package/scripts/lib/auto-dream.mjs +16 -5
- package/scripts/lib/autopilot/mr-draft.mjs +31 -1
- package/scripts/lib/autopilot/worktree-pipeline.mjs +113 -5
- package/scripts/lib/backlog-scan.mjs +39 -6
- package/scripts/lib/blocked-commands-policy.mjs +340 -0
- package/scripts/lib/build-live-signals.mjs +7 -4
- package/scripts/lib/ci-status-banner.mjs +75 -12
- package/scripts/lib/claude-md-budget-lint.mjs +283 -34
- package/scripts/lib/command-blocker.mjs +1013 -58
- package/scripts/lib/config/config-protection.mjs +2 -1
- package/scripts/lib/config/context-coverage.mjs +82 -0
- package/scripts/lib/config/drift-check.mjs +9 -1
- package/scripts/lib/config/gitlab-portfolio.mjs +1 -1
- package/scripts/lib/config/issue-budget.mjs +123 -0
- package/scripts/lib/config/moc-staleness.mjs +98 -0
- package/scripts/lib/config/reconcile.mjs +21 -0
- package/scripts/lib/config/section-extractor.mjs +121 -1
- package/scripts/lib/config/worktree-orphans.mjs +138 -0
- package/scripts/lib/config-schema.mjs +23 -3
- package/scripts/lib/config.mjs +32 -0
- package/scripts/lib/context-coverage-banner.mjs +223 -0
- package/scripts/lib/convergence-monitor.mjs +49 -3
- package/scripts/lib/description-surface.mjs +535 -0
- package/scripts/lib/dispatcher/enumerate.mjs +136 -30
- package/scripts/lib/dispatcher/rank.mjs +22 -8
- package/scripts/lib/ecosystem-wizard/config-writer.mjs +26 -24
- package/scripts/lib/ecosystem-wizard/wizard-prompt.mjs +1 -1
- package/scripts/lib/eval/engine.mjs +47 -5
- package/scripts/lib/events.mjs +59 -7
- package/scripts/lib/evolve/autonomy-verdict.mjs +5 -0
- package/scripts/lib/evolve/autopilot-effectiveness.mjs +54 -7
- package/scripts/lib/gates/gate-full.mjs +15 -3
- package/scripts/lib/gates/gate-helpers.mjs +132 -6
- package/scripts/lib/gitlab-ops/stale-mr-sweep.mjs +28 -8
- package/scripts/lib/gitlab-portfolio/aggregator.mjs +8 -2
- package/scripts/lib/gitlab-portfolio/cli.mjs +1 -1
- package/scripts/lib/handover-gate.mjs +7 -3
- package/scripts/lib/harness-audit/categories/category4.mjs +22 -5
- package/scripts/lib/instruction-budget-guard.mjs +402 -51
- package/scripts/lib/io.mjs +345 -10
- package/scripts/lib/issue-budget.mjs +269 -0
- package/scripts/lib/issue-close-strip-labels.mjs +39 -9
- package/scripts/lib/label-scope.mjs +47 -0
- package/scripts/lib/learnings/schema.mjs +43 -3
- package/scripts/lib/lock-reaper.mjs +1 -2
- package/scripts/lib/memory-proposals/schema.mjs +36 -1
- package/scripts/lib/moc-staleness-banner.mjs +267 -0
- package/scripts/lib/peer-discovery.mjs +645 -0
- package/scripts/lib/pi-hook-bridge.mjs +146 -17
- package/scripts/lib/product-repo-detect.mjs +9 -8
- package/scripts/lib/project-hygiene.mjs +432 -0
- package/scripts/lib/quality-gate.mjs +167 -0
- package/scripts/lib/recommendations-v0.mjs +1 -1
- package/scripts/lib/reconcile/eligibility.mjs +1 -1
- package/scripts/lib/reconcile/emitter.mjs +23 -4
- package/scripts/lib/reconcile/engine.mjs +147 -39
- package/scripts/lib/reconcile/idempotency.mjs +114 -14
- package/scripts/lib/reconcile-nudge-banner.mjs +65 -9
- package/scripts/lib/resource-probe/evaluate.mjs +70 -4
- package/scripts/lib/resource-probe.mjs +19 -0
- package/scripts/lib/rule-loader.mjs +6 -0
- package/scripts/lib/scope-baseline.mjs +564 -0
- package/scripts/lib/scope-gate.mjs +399 -98
- package/scripts/lib/session-close-backfill.mjs +61 -6
- package/scripts/lib/session-end/phase-skip.mjs +1 -0
- package/scripts/lib/session-end/worktree-orphan-sweep.mjs +252 -0
- package/scripts/lib/session-id.mjs +221 -41
- package/scripts/lib/session-lock.mjs +304 -6
- package/scripts/lib/session-schema/constants.mjs +22 -3
- package/scripts/lib/session-schema/filters.mjs +88 -0
- package/scripts/lib/session-schema/validator.mjs +16 -0
- package/scripts/lib/session-schema.mjs +1 -0
- package/scripts/lib/sessions-integrity-banner.mjs +294 -0
- package/scripts/lib/sessions-staleness-banner.mjs +121 -12
- package/scripts/lib/skill-evolution/idempotency.mjs +135 -16
- package/scripts/lib/skill-evolution/mr-opener.mjs +9 -1
- package/scripts/lib/skill-health/join.mjs +35 -9
- package/scripts/lib/spiral-carryover.mjs +142 -30
- package/scripts/lib/state-md/mission-status.mjs +53 -3
- package/scripts/lib/subagents-schema.mjs +43 -9
- package/scripts/lib/telemetry/anon-id.mjs +141 -0
- package/scripts/lib/telemetry/consent.mjs +299 -0
- package/scripts/lib/telemetry/paths.mjs +27 -0
- package/scripts/lib/telemetry/queue.mjs +287 -0
- package/scripts/lib/telemetry/schema.mjs +384 -0
- package/scripts/lib/telemetry/sync.mjs +312 -0
- package/scripts/lib/test-runner/issue-reconcile.mjs +53 -13
- package/scripts/lib/tests-src-ratio.mjs +484 -0
- package/scripts/lib/validate/check-agents.mjs +56 -0
- package/scripts/lib/validate/check-hooks-symmetry.mjs +244 -10
- package/scripts/lib/validate/check-rules.mjs +217 -35
- package/scripts/lib/validate/check-test-value-bans.mjs +782 -0
- package/scripts/lib/validate/check-unicode-safety.mjs +1 -0
- package/scripts/lib/validate-vendored-rules.mjs +10 -2
- package/scripts/lib/vault-archive.mjs +17 -2
- package/scripts/lib/vault-backfill/glab.mjs +8 -0
- package/scripts/lib/vault-mirror/process.mjs +30 -0
- package/scripts/lib/vault-mirror/render-sessions.mjs +293 -36
- package/scripts/lib/vault-status/board-writer.mjs +63 -5
- package/scripts/lib/vault-status/narrative-mirror.mjs +13 -7
- package/scripts/lib/vcs-repo-spec.mjs +362 -0
- package/scripts/lib/wave-resource-gate.mjs +115 -11
- package/scripts/lib/worktree/listing.mjs +44 -7
- package/scripts/mcp-server.sh +32 -6
- package/scripts/measure-context-overhead.sh +151 -0
- package/scripts/memory-propose.mjs +72 -9
- package/scripts/print-applicable-rules.mjs +51 -12
- package/scripts/release.mjs +534 -0
- package/scripts/run-quality-gate.mjs +123 -5
- package/scripts/telemetry.mjs +250 -0
- package/scripts/validate-wave-scope.mjs +182 -17
- package/scripts/vault-integration-watcher.mjs +32 -10
- package/skills/_shared/config-reading.md +2 -2
- package/skills/bootstrap/fast-template.md +1 -1
- package/skills/claude-md-drift-check/checker.mjs +145 -28
- package/skills/contract-version-bump/SKILL.md +219 -0
- package/skills/discovery/SKILL.md +4 -4
- package/skills/discovery/issue-templates.md +11 -11
- package/skills/discovery/probes-audit.md +1 -1
- package/skills/discovery/probes-feature.md +1 -1
- package/skills/discovery/probes-session.md +26 -5
- package/skills/ecosystem-health/SKILL.md +1 -1
- package/skills/ecosystem-health/wizard.md +4 -4
- package/skills/evolve/SKILL.md +1 -0
- package/skills/gitlab-ops/SKILL.md +20 -12
- package/skills/gitlab-portfolio/SKILL.md +2 -2
- package/skills/hook-development/SKILL.md +1 -1
- package/skills/mode-selector/SKILL.md +1 -1
- package/skills/npm-publish/SKILL.md +97 -0
- package/skills/plan/SKILL.md +5 -5
- package/skills/plan/mode-feature.md +4 -4
- package/skills/plan/mode-new.md +10 -10
- package/skills/plan/mode-retro.md +1 -1
- package/skills/quality-gates/SKILL.md +1 -1
- package/skills/reconcile/SKILL.md +21 -4
- package/skills/session-end/SKILL.md +108 -14
- package/skills/session-end/discovery-scan.md +4 -2
- package/skills/session-end/drift-operations.md +4 -4
- package/skills/session-end/metrics-collection.md +13 -0
- package/skills/session-end/phase-3-2-docs-verification.md +1 -1
- package/skills/session-end/phase-3-6-tail.md +2 -1
- package/skills/session-end/plan-verification.md +5 -2
- package/skills/session-end/vault-operations.md +1 -1
- package/skills/session-end/verification-checklist.md +1 -1
- package/skills/session-plan/SKILL.md +6 -2
- package/skills/session-plan/wave-template.md +2 -0
- package/skills/session-start/SKILL.md +149 -7
- package/skills/session-start/phase-4-5-resource-health.md +15 -2
- package/skills/test-runner/SKILL.md +2 -2
- package/skills/vault-sync/SKILL.md +1 -1
- package/skills/vault-sync/package-lock.json +3 -3
- package/skills/vault-sync/validator.mjs +228 -40
- package/skills/wave-executor/SKILL.md +5 -2
- package/skills/wave-executor/circuit-breaker.md +2 -0
- package/skills/wave-executor/wave-loop.md +163 -10
- 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
|
-
|
|
225
|
-
//
|
|
226
|
-
|
|
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
|
-
|
|
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
|
+
}
|