session-orchestrator 3.17.0 → 3.20.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 +185 -412
- package/README.md +12 -9
- 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/commands/session.md +6 -2
- package/docs/USER-GUIDE.md +9 -4
- package/docs/ci-setup.md +121 -7
- package/docs/codex-setup.md +1 -1
- package/docs/components.md +6 -6
- package/docs/cursor-setup.md +22 -9
- package/docs/events-schema.md +5 -1
- package/docs/instruction-delivery.md +794 -0
- package/docs/rule-authoring.md +58 -9
- package/docs/session-config-reference.md +245 -50
- package/docs/session-config-template.md +39 -26
- package/hooks/_lib/guard-source-loader.mjs +680 -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 +226 -19
- package/hooks/enforce-scope.mjs +133 -9
- 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 +30 -4
- 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 +616 -164
- package/hooks/pre-bash-issue-budget.mjs +167 -0
- package/hooks/pre-bash-sessions-ledger-guard.mjs +1054 -0
- package/hooks/pre-bash-templates-first.mjs +96 -63
- package/hooks/subagent-telemetry.mjs +527 -37
- package/package.json +6 -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/backfill-learnings-from-vault.mjs +967 -0
- package/scripts/check-doc-consistency.sh +17 -1
- package/scripts/emit-session.mjs +3 -40
- package/scripts/eval-session.mjs +50 -9
- package/scripts/fleet-instruction-scan.mjs +141 -0
- 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/ci-status-banner.mjs +75 -12
- package/scripts/lib/claude-md-budget-lint.mjs +283 -34
- package/scripts/lib/command-blocker.mjs +1273 -58
- package/scripts/lib/config/config-protection.mjs +2 -1
- 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/reconcile.mjs +21 -0
- package/scripts/lib/config/section-extractor.mjs +121 -1
- package/scripts/lib/config-schema.mjs +23 -3
- package/scripts/lib/config.mjs +17 -0
- package/scripts/lib/convergence-monitor.mjs +49 -3
- package/scripts/lib/description-surface.mjs +535 -0
- package/scripts/lib/dispatcher/enumerate.mjs +26 -40
- 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/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/hardening.mjs +9 -9
- package/scripts/lib/harness-audit/categories/category4.mjs +9 -3
- 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/affinity.mjs +434 -0
- package/scripts/lib/learnings/candidates.mjs +736 -0
- package/scripts/lib/learnings/expiry-sweep.mjs +408 -53
- package/scripts/lib/learnings/judgment.mjs +782 -0
- package/scripts/lib/learnings/kebab.mjs +128 -0
- package/scripts/lib/learnings/schema.mjs +43 -3
- package/scripts/lib/learnings/select.mjs +550 -0
- package/scripts/lib/lock-reaper.mjs +1 -2
- package/scripts/lib/memory-proposals/schema.mjs +36 -1
- 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 +128 -24
- package/scripts/lib/reconcile/engine.mjs +156 -54
- package/scripts/lib/reconcile/idempotency.mjs +114 -14
- package/scripts/lib/reconcile/renderer.mjs +141 -25
- package/scripts/lib/reconcile/sanitize.mjs +518 -0
- package/scripts/lib/reconcile/writer.mjs +95 -1
- 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 +568 -145
- package/scripts/lib/session-close-backfill.mjs +63 -8
- package/scripts/lib/session-end/phase-skip.mjs +1 -0
- package/scripts/lib/session-id.mjs +221 -41
- package/scripts/lib/session-lock.mjs +304 -6
- package/scripts/lib/session-record-repair.mjs +551 -0
- package/scripts/lib/session-schema/constants.mjs +22 -3
- package/scripts/lib/session-schema/serializer.mjs +54 -0
- package/scripts/lib/session-schema/validator.mjs +16 -0
- package/scripts/lib/session-schema.mjs +1 -0
- package/scripts/lib/session-token-rollup.mjs +68 -6
- 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/soul-resolve.mjs +12 -0
- 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/test-runner/issue-reconcile.mjs +53 -13
- package/scripts/lib/tests-src-ratio.mjs +484 -0
- package/scripts/lib/tmux-layout/telemetry.mjs +43 -10
- package/scripts/lib/validate/check-agents.mjs +56 -0
- package/scripts/lib/validate/check-banner-parity.mjs +376 -0
- package/scripts/lib/validate/check-guard-requires-parity.mjs +1148 -0
- package/scripts/lib/validate/check-hooks-symmetry.mjs +244 -10
- package/scripts/lib/validate/check-learning-provenance.mjs +511 -0
- package/scripts/lib/validate/check-owner-leakage.mjs +3 -3
- package/scripts/lib/validate/check-rules.mjs +244 -36
- 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/check-unwired-features.mjs +549 -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/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 +17 -3
- package/scripts/measure-context-overhead.sh +151 -0
- package/scripts/memory-propose.mjs +72 -9
- package/scripts/print-applicable-rules.mjs +218 -16
- package/scripts/print-learnings-index.mjs +474 -0
- package/scripts/release.mjs +534 -0
- package/scripts/repair-invalid-sessions.mjs +209 -0
- package/scripts/run-quality-gate.mjs +123 -5
- package/scripts/sweep-expired-learnings.mjs +192 -32
- package/scripts/validate-plugin.mjs +21 -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/brainstorm/soul.md +47 -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 +117 -18
- package/skills/gitlab-ops/SKILL.md +25 -12
- package/skills/gitlab-portfolio/SKILL.md +2 -2
- package/skills/grill/soul.md +44 -1
- package/skills/hook-development/SKILL.md +1 -1
- package/skills/mode-selector/SKILL.md +1 -1
- package/skills/npm-publish/SKILL.md +17 -1
- 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/plan/soul.md +46 -3
- package/skills/quality-gates/SKILL.md +1 -1
- package/skills/reconcile/SKILL.md +21 -4
- package/skills/session-end/SKILL.md +34 -36
- 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 +32 -2
- package/skills/session-end/plan-verification.md +6 -7
- package/skills/session-end/session-metrics-write.md +2 -0
- 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 +75 -7
- package/skills/session-start/phase-4-5-resource-health.md +15 -2
- package/skills/session-start/soul.md +41 -1
- package/skills/test-runner/SKILL.md +2 -2
- package/skills/vault-sync/validator.mjs +108 -7
- package/skills/wave-executor/SKILL.md +6 -7
- package/skills/wave-executor/circuit-breaker.md +2 -0
- package/skills/wave-executor/wave-loop.md +198 -80
- package/templates/_shared/loop.md +4 -4
|
@@ -42,11 +42,31 @@
|
|
|
42
42
|
* - checkPeerStateMd throws (it shouldn't) → contribute no state-md entry.
|
|
43
43
|
* - Worst case returns `{ peers: [] }`.
|
|
44
44
|
*
|
|
45
|
+
* ---------------------------------------------------------------------------
|
|
46
|
+
* Second export: checkLiveForeignSession (Issue #908)
|
|
47
|
+
* ---------------------------------------------------------------------------
|
|
48
|
+
*
|
|
49
|
+
* findPeers is the raw union — powerful, but every consumer so far had to
|
|
50
|
+
* re-derive the same three non-obvious rules to turn it into the one question
|
|
51
|
+
* that is actually asked ("is a live FOREIGN session running in <repo>?"):
|
|
52
|
+
* which id-space self-excludes on which surface, which liveness signal is
|
|
53
|
+
* canonical, and which `source` values may be counted. Getting any of the
|
|
54
|
+
* three wrong is silent — you get a plausible boolean that is simply false.
|
|
55
|
+
* checkLiveForeignSession answers the question once, so no caller has to.
|
|
56
|
+
* See its own JSDoc for the decision record.
|
|
57
|
+
*
|
|
45
58
|
* @module peer-discovery
|
|
46
59
|
*/
|
|
47
60
|
|
|
61
|
+
import fs from 'node:fs';
|
|
62
|
+
import path from 'node:path';
|
|
63
|
+
import { fileURLToPath } from 'node:url';
|
|
64
|
+
import { parseArgs } from 'node:util';
|
|
65
|
+
|
|
48
66
|
import { discoverActiveSessions } from './session-discovery.mjs';
|
|
67
|
+
import { readLock, isLockLive, LOCK_PATH } from './session-lock.mjs';
|
|
49
68
|
import { checkPeerStateMd } from './state-md-peer-guard.mjs';
|
|
69
|
+
import { listWorktreesChecked } from './worktree/listing.mjs';
|
|
50
70
|
|
|
51
71
|
/** Closed enum of provenance sources. */
|
|
52
72
|
const SOURCE_DISCOVERED = 'discovered'; // lock + registry unified (irreversibly merged upstream)
|
|
@@ -198,3 +218,628 @@ export async function findPeers(repoRoot, opts = {}) {
|
|
|
198
218
|
|
|
199
219
|
return { peers };
|
|
200
220
|
}
|
|
221
|
+
|
|
222
|
+
// ===========================================================================
|
|
223
|
+
// checkLiveForeignSession (Issue #908) — "is a live FOREIGN session running
|
|
224
|
+
// in this repo?" as a single mechanical verdict.
|
|
225
|
+
// ===========================================================================
|
|
226
|
+
|
|
227
|
+
/** Verdict `probe` values — which measurement actually ran. */
|
|
228
|
+
const PROBE_NONE = 'none'; // pre-check refused to measure (fail-safe verdict)
|
|
229
|
+
const PROBE_LOCK_ONLY = 'lock-only'; // cheap path: one sync lock read, no git
|
|
230
|
+
const PROBE_FULL = 'full'; // full path: findPeers (worktrees + registry + STATE.md)
|
|
231
|
+
const PROBE_FULL_DEGRADED = 'full-degraded'; // full path ran but returned demonstrably incomplete data
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Resolve a path to its canonical form, falling back to the merely-resolved
|
|
235
|
+
* form when the path does not exist (realpathSync throws on ENOENT). Never
|
|
236
|
+
* throws — a non-canonical comparison is still a useful comparison.
|
|
237
|
+
*
|
|
238
|
+
* @param {string} p
|
|
239
|
+
* @returns {string}
|
|
240
|
+
*/
|
|
241
|
+
function _realpathOrResolved(p) {
|
|
242
|
+
const resolved = path.resolve(p);
|
|
243
|
+
try {
|
|
244
|
+
return fs.realpathSync(resolved);
|
|
245
|
+
} catch {
|
|
246
|
+
return resolved;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Nearest ancestor of `dirAbs` (inclusive) that holds a `.git` entry — i.e. the
|
|
252
|
+
* working-copy root `dirAbs` belongs to. `null` when `dirAbs` is not inside any
|
|
253
|
+
* working copy.
|
|
254
|
+
*
|
|
255
|
+
* DELIBERATELY NOT `git rev-parse --show-toplevel`. This runs on the probe-
|
|
256
|
+
* SELECTION path, whose whole justification is that the `'lock-only'` branch
|
|
257
|
+
* touches no git process; a spawn here would re-introduce exactly the cost the
|
|
258
|
+
* two-probe split exists to avoid, and it would pay it on EVERY call — including
|
|
259
|
+
* the calls that then take the cheap branch. The cases `rev-parse` would decide
|
|
260
|
+
* differently are `GIT_DIR`/`GIT_WORK_TREE` env overrides and `core.worktree`
|
|
261
|
+
* indirection; no call site in this repo produces them, and when they do occur
|
|
262
|
+
* the disagreement costs at most a probe-variant choice, never a wrong verdict
|
|
263
|
+
* direction (the `'lock-only'` branch reads the target's OWN lock either way).
|
|
264
|
+
*
|
|
265
|
+
* `.git` is a directory in a normal checkout and a FILE in a linked worktree.
|
|
266
|
+
* `existsSync` accepts both, which is what we want: a linked worktree's root IS
|
|
267
|
+
* its own working copy (see the sibling-worktree note on `_isOwnWorkingCopy`).
|
|
268
|
+
*
|
|
269
|
+
* @param {string} dirAbs
|
|
270
|
+
* @returns {string|null}
|
|
271
|
+
*/
|
|
272
|
+
function _workingCopyRootOf(dirAbs) {
|
|
273
|
+
// Bounded by path depth; one stat syscall per level.
|
|
274
|
+
let cur = dirAbs;
|
|
275
|
+
for (;;) {
|
|
276
|
+
if (fs.existsSync(path.join(cur, '.git'))) return cur;
|
|
277
|
+
const parent = path.dirname(cur);
|
|
278
|
+
if (parent === cur) return null; // reached the filesystem root
|
|
279
|
+
cur = parent;
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* True when `cwdAbs` and `repoRootAbs` denote the SAME working copy.
|
|
285
|
+
*
|
|
286
|
+
* Containment alone is NOT the test. The full probe enumerates peers through
|
|
287
|
+
* `findPeers` → `discoverActiveSessions` → `listWorktrees`, and `listWorktrees`
|
|
288
|
+
* runs `git worktree list` in `process.cwd()` (worktree/listing.mjs) — it can
|
|
289
|
+
* only ever measure the working copy the PROCESS sits in. Selecting it for a
|
|
290
|
+
* repo that merely CONTAINS (or is contained by) the cwd measures a different
|
|
291
|
+
* repository and then reports THAT repository's emptiness as the target's
|
|
292
|
+
* verdict — a successful measurement of the wrong thing, which the fail-safe
|
|
293
|
+
* direction cannot catch because nothing failed.
|
|
294
|
+
*
|
|
295
|
+
* Concretely reachable: a portfolio workspace directory (`<home>/Projects/
|
|
296
|
+
* <workspace>/`) is commonly a git repo with its own `.orchestrator/` state AND
|
|
297
|
+
* an ancestor of every product repo checked out beneath it. Under pure
|
|
298
|
+
* containment a probe of that parent from a child repo took the full path and
|
|
299
|
+
* answered `live: false` from the CHILD's worktree list.
|
|
300
|
+
*
|
|
301
|
+
* So: same directory ⇒ same working copy (no identity question to ask);
|
|
302
|
+
* otherwise containment is necessary but must be confirmed by IDENTITY — the
|
|
303
|
+
* working-copy root the cwd belongs to has to BE `repoRootAbs`.
|
|
304
|
+
*
|
|
305
|
+
* A SIBLING worktree (`<base>/<repo>-<sessionId>/`) is deliberately NOT "the
|
|
306
|
+
* same working copy": it is a different checkout with its own lock, so a probe
|
|
307
|
+
* against it is a foreign-repo probe.
|
|
308
|
+
*
|
|
309
|
+
* @param {string} repoRootAbs
|
|
310
|
+
* @param {string} cwdAbs
|
|
311
|
+
* @returns {boolean}
|
|
312
|
+
*/
|
|
313
|
+
function _isOwnWorkingCopy(repoRootAbs, cwdAbs) {
|
|
314
|
+
if (repoRootAbs === cwdAbs) return true;
|
|
315
|
+
|
|
316
|
+
const contained = cwdAbs.startsWith(repoRootAbs + path.sep)
|
|
317
|
+
|| repoRootAbs.startsWith(cwdAbs + path.sep);
|
|
318
|
+
if (!contained) return false;
|
|
319
|
+
|
|
320
|
+
const cwdRoot = _workingCopyRootOf(cwdAbs);
|
|
321
|
+
if (cwdRoot === null) return false; // cwd is in no working copy → nothing to own
|
|
322
|
+
return _realpathOrResolved(cwdRoot) === repoRootAbs;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* Normalise a raw schema-v2 lock body into the verdict's `peer` shape.
|
|
327
|
+
*
|
|
328
|
+
* @param {object} lock Parsed lock body.
|
|
329
|
+
* @param {string} worktreePath Absolute path the lock was read from.
|
|
330
|
+
* @param {number} nowMs
|
|
331
|
+
* @returns {object}
|
|
332
|
+
*/
|
|
333
|
+
function _peerFromLockBody(lock, worktreePath, nowMs) {
|
|
334
|
+
const ageHours = _ageHoursFrom(lock.started_at, nowMs);
|
|
335
|
+
return {
|
|
336
|
+
source: SOURCE_DISCOVERED,
|
|
337
|
+
sessionId: typeof lock.session_id === 'string' ? lock.session_id : null,
|
|
338
|
+
semanticSessionId: typeof lock.semantic_session_id === 'string' ? lock.semantic_session_id : null,
|
|
339
|
+
mode: lock.mode ?? null,
|
|
340
|
+
host: lock.host ?? null,
|
|
341
|
+
pid: typeof lock.pid === 'number' ? lock.pid : null,
|
|
342
|
+
startedAt: typeof lock.started_at === 'string' ? lock.started_at : null,
|
|
343
|
+
lastHeartbeat: typeof lock.last_heartbeat === 'string' ? lock.last_heartbeat : null,
|
|
344
|
+
ageHours: ageHours === undefined ? null : ageHours,
|
|
345
|
+
worktreePath,
|
|
346
|
+
};
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* Normalise a findPeers `source: 'discovered'` entry into the verdict's `peer`
|
|
351
|
+
* shape. Fields that surface cannot supply are emitted as null rather than
|
|
352
|
+
* omitted, so consumers never have to branch on shape.
|
|
353
|
+
*
|
|
354
|
+
* @param {object} p
|
|
355
|
+
* @returns {object}
|
|
356
|
+
*/
|
|
357
|
+
function _peerFromFindPeersEntry(p) {
|
|
358
|
+
return {
|
|
359
|
+
source: SOURCE_DISCOVERED,
|
|
360
|
+
sessionId: p.sessionId,
|
|
361
|
+
semanticSessionId: null,
|
|
362
|
+
mode: p.mode ?? null,
|
|
363
|
+
host: p.host ?? null,
|
|
364
|
+
pid: typeof p.pid === 'number' ? p.pid : null,
|
|
365
|
+
startedAt: null, // discoverActiveSessions does not thread startedAt through findPeers
|
|
366
|
+
lastHeartbeat: null,
|
|
367
|
+
ageHours: typeof p.ageHours === 'number' ? p.ageHours : null,
|
|
368
|
+
worktreePath: p.worktreePath ?? null,
|
|
369
|
+
};
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/**
|
|
373
|
+
* Build a verdict object. Central so every return path emits the same shape.
|
|
374
|
+
*
|
|
375
|
+
* @param {boolean} live
|
|
376
|
+
* @param {string} reason
|
|
377
|
+
* @param {string} probe
|
|
378
|
+
* @param {{peerCount?: number, peer?: object|null}} [extra]
|
|
379
|
+
* @returns {{live: boolean, reason: string, probe: string, peerCount: number, peer: object|null}}
|
|
380
|
+
*/
|
|
381
|
+
function _verdict(live, reason, probe, extra = {}) {
|
|
382
|
+
return {
|
|
383
|
+
live,
|
|
384
|
+
reason,
|
|
385
|
+
probe,
|
|
386
|
+
peerCount: extra.peerCount ?? 0,
|
|
387
|
+
peer: extra.peer ?? null,
|
|
388
|
+
};
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* Timeout for the #919.3 git-surface confirmation probe. Mirrors
|
|
393
|
+
* session-discovery's DEFAULT_DISCOVERY_TIMEOUT_MS: a hung git must degrade
|
|
394
|
+
* the verdict (fail-safe), never hang it.
|
|
395
|
+
*/
|
|
396
|
+
const WORKTREE_CONFIRM_TIMEOUT_MS = 2000;
|
|
397
|
+
|
|
398
|
+
/**
|
|
399
|
+
* #919.3 — confirm that `git worktree list` can actually RUN in this process's
|
|
400
|
+
* working copy. Called ONLY on the full path's residual branch (discovered
|
|
401
|
+
* surface produced nothing AND no live own lock exists to canary against —
|
|
402
|
+
* see the RESIDUAL GAP note on `checkLiveForeignSession`).
|
|
403
|
+
*
|
|
404
|
+
* Honours the same DI seam findPeers forwards (`opts.listWorktreesImpl`): a
|
|
405
|
+
* seam that RESOLVES (any value) proves the surface functional; a seam that
|
|
406
|
+
* throws/rejects reproduces the git failure. Without a seam,
|
|
407
|
+
* `listWorktreesChecked()` supplies the real signal (`ok: false` = git did not
|
|
408
|
+
* run — the exact state the bare `listWorktrees()` swallows into `[]`).
|
|
409
|
+
*
|
|
410
|
+
* Raced against a 2s timeout: a hung git resolves to `false` (not confirmed ⇒
|
|
411
|
+
* fail safe). Never throws; never rejects.
|
|
412
|
+
*
|
|
413
|
+
* @param {object} opts The `checkLiveForeignSession` opts (for the DI seam).
|
|
414
|
+
* @returns {Promise<boolean>} true ⇔ `git worktree list` demonstrably ran.
|
|
415
|
+
*/
|
|
416
|
+
async function _confirmWorktreeListRan(opts) {
|
|
417
|
+
let timer;
|
|
418
|
+
try {
|
|
419
|
+
const probe = typeof opts.listWorktreesImpl === 'function'
|
|
420
|
+
? Promise.resolve().then(() => opts.listWorktreesImpl()).then(() => true, () => false)
|
|
421
|
+
: listWorktreesChecked().then((r) => r?.ok === true, () => false);
|
|
422
|
+
const timeout = new Promise((resolve) => {
|
|
423
|
+
timer = setTimeout(() => resolve(false), WORKTREE_CONFIRM_TIMEOUT_MS);
|
|
424
|
+
if (typeof timer?.unref === 'function') timer.unref();
|
|
425
|
+
});
|
|
426
|
+
return (await Promise.race([probe, timeout])) === true;
|
|
427
|
+
} catch {
|
|
428
|
+
return false; // unmeasurable ⇒ not confirmed ⇒ the caller fails safe
|
|
429
|
+
} finally {
|
|
430
|
+
if (timer !== undefined) clearTimeout(timer);
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/**
|
|
435
|
+
* checkLiveForeignSession — mechanically decide whether a LIVE FOREIGN session
|
|
436
|
+
* is running in `repoRoot` (Issue #908, Baustein 3).
|
|
437
|
+
*
|
|
438
|
+
* Consumer contract: when this returns `live: true`, every fact harvested from
|
|
439
|
+
* that repo is volatile BY DEFINITION — regardless of how recently it was
|
|
440
|
+
* measured — because someone else may be committing into it right now. The
|
|
441
|
+
* staleness-annotation consumer reads this as "threshold 0: state facts from
|
|
442
|
+
* this repo as ASSERTED, not as established".
|
|
443
|
+
*
|
|
444
|
+
* ── Two probes, auto-selected (no knob the caller can set wrong) ────────────
|
|
445
|
+
*
|
|
446
|
+
* The right measurement depends on WHOSE working copy `repoRoot` is, and that
|
|
447
|
+
* is derivable — so it is derived here rather than delegated to a parameter:
|
|
448
|
+
*
|
|
449
|
+
* - `repoRoot` is MY working copy (same directory, or a containment relation
|
|
450
|
+
* CONFIRMED BY REPO IDENTITY — see `_isOwnWorkingCopy`): probe `'full'`.
|
|
451
|
+
* Sibling worktrees and host-registry entries are real peers here, so the
|
|
452
|
+
* full findPeers union runs — and self-exclusion becomes load-bearing (see
|
|
453
|
+
* below). Containment alone is explicitly NOT sufficient: `listWorktrees`
|
|
454
|
+
* runs `git worktree list` in `process.cwd()`, so the full probe can only
|
|
455
|
+
* measure the working copy this PROCESS is in; picking it for an ancestor
|
|
456
|
+
* or descendant directory that is a DIFFERENT repository measures the wrong
|
|
457
|
+
* repo and returns its emptiness as the target's verdict.
|
|
458
|
+
*
|
|
459
|
+
* - `repoRoot` is a FOREIGN repo (the actual #908 case): probe `'lock-only'`.
|
|
460
|
+
* Two synchronous calls — `readLock` + `isLockLive` — with no `git worktree
|
|
461
|
+
* list`, no 2s race, no registry read. Self-exclusion is trivially
|
|
462
|
+
* satisfied (my session is not in that repo's lock). This mirrors
|
|
463
|
+
* `scripts/lib/dispatcher/enumerate.mjs :: resolveCandidate`, which makes
|
|
464
|
+
* exactly this trade for exactly this reason.
|
|
465
|
+
*
|
|
466
|
+
* There is deliberately NO probe override: an API in which the caller can pick
|
|
467
|
+
* the wrong variant is an API in which the caller eventually will (this failure
|
|
468
|
+
* mode was observed live — see the id-space note below). `opts.cwd` exists as a
|
|
469
|
+
* test seam for the auto-detection input, not as a way to force a probe.
|
|
470
|
+
*
|
|
471
|
+
* KNOWN LIMIT (accepted, cost-driven): the `'lock-only'` probe reads only that
|
|
472
|
+
* repo root's own lock. A foreign session running exclusively in a SIBLING
|
|
473
|
+
* worktree of a foreign repo is not seen. Callers who need that coverage must
|
|
474
|
+
* call `findPeers(<that repo>)` directly and pay the git cost.
|
|
475
|
+
*
|
|
476
|
+
* ── Self-exclusion is derived, never trusted (Falle 1) ──────────────────────
|
|
477
|
+
*
|
|
478
|
+
* `findPeers`'s `opts.mySessionId` self-excludes on Surface A+B ONLY when it is
|
|
479
|
+
* the UUID from `session.lock` — a semantic id (`<branch>-<date>-<mode>-<n>`)
|
|
480
|
+
* silently fails to exclude there, and the caller's own heartbeat comes back as
|
|
481
|
+
* a "peer" of itself. That mistake was made live by a caller reading the very
|
|
482
|
+
* JSDoc that documents it. So this function does not accept the id it needs:
|
|
483
|
+
* it READS it, from `readLock({ repoRoot }).session_id` (the UUID) plus
|
|
484
|
+
* `.semantic_session_id`, and post-filters BOTH id-spaces out of the result —
|
|
485
|
+
* which also covers the id-space mismatch on Surface C (STATE.md `session:`)
|
|
486
|
+
* and any registry entry a non-Claude harness wrote under a semantic id.
|
|
487
|
+
* `opts.mySessionId` is accepted and IGNORED (see its param doc).
|
|
488
|
+
*
|
|
489
|
+
* ── Liveness is heartbeat-based, never PID-based (Falle 2) ──────────────────
|
|
490
|
+
*
|
|
491
|
+
* The canonical test is `isLockLive(lock, nowMs)` (heartbeat vs `ttl_hours`,
|
|
492
|
+
* falling back to `started_at`). `process.kill(pid, 0)` / `isPidAliveOnHost`
|
|
493
|
+
* are deliberately NOT in this decision tree: the recorded pid is the ephemeral
|
|
494
|
+
* SessionStart-HOOK pid, not the session's, so a dead pid says nothing about
|
|
495
|
+
* the session. A lock with a FRESH heartbeat and a DEAD pid therefore counts as
|
|
496
|
+
* LIVE — that is the contract, not a bug (session-discovery.mjs module header;
|
|
497
|
+
* #799 evaluated 2026-07-17 → explicit NO-GO, "do not re-attempt without new
|
|
498
|
+
* evidence"). `tests/lib/peer-discovery.test.mjs` I3 pins it.
|
|
499
|
+
*
|
|
500
|
+
* ── Only `source: 'discovered'` counts (Falle 3) ────────────────────────────
|
|
501
|
+
*
|
|
502
|
+
* `findPeers` intentionally does NOT dedupe across sources, so one peer can
|
|
503
|
+
* appear twice (once `'discovered'`, once `'state-md'`) — a raw `peers.length`
|
|
504
|
+
* is double-counting AND over-triggering. Only `'discovered'` (live lock /
|
|
505
|
+
* fresh registry heartbeat) is evidence of a RUNNING session; `'state-md'` is a
|
|
506
|
+
* committed artifact that outlives its session (a crashed session leaves it
|
|
507
|
+
* behind), so it is excluded from the liveness decision. Same filter as
|
|
508
|
+
* `skills/_shared/parallel-aware-preamble.md` (`p.source !== 'state-md'`).
|
|
509
|
+
* Within `'discovered'`, `discoverActiveSessions` has already deduped by
|
|
510
|
+
* sessionId, so `peerCount` needs no further dedupe.
|
|
511
|
+
*
|
|
512
|
+
* ── Fail-safe direction: UNKNOWN ⇒ live: true ───────────────────────────────
|
|
513
|
+
*
|
|
514
|
+
* The two errors are not symmetric. A false `true` costs one redundant
|
|
515
|
+
* re-measurement. A false `false` reprints 9-hour-old numbers as current fact —
|
|
516
|
+
* the exact #908 incident. So the invariant is: **`live: false` is returned
|
|
517
|
+
* ONLY after a measurement that succeeded and found nothing.** Anything that
|
|
518
|
+
* prevents measurement — unusable `repoRoot` (`'invalid-repo-root'`), path gone
|
|
519
|
+
* (`'repo-root-missing'`), lock present but unparseable (`'lock-unreadable'`),
|
|
520
|
+
* or any unexpected throw (`'probe-error'`) — yields `live: true`.
|
|
521
|
+
* A repo with NO lock is not "unknown": it is a successful measurement with a
|
|
522
|
+
* negative result (`'no-lock'`, `live: false`).
|
|
523
|
+
*
|
|
524
|
+
* ── The full path needs its own liveness proof (Falle 4) ────────────────────
|
|
525
|
+
*
|
|
526
|
+
* `findPeers` fails open PER SURFACE and returns only `{ peers }` — there is no
|
|
527
|
+
* degraded/error channel — so "both surfaces broke" and "measured, nobody home"
|
|
528
|
+
* arrive as the same value. Left alone, the full path would answer `no-peers` →
|
|
529
|
+
* `live: false` after measuring nothing, i.e. exactly the invariant above
|
|
530
|
+
* inverted on the branch that runs most often.
|
|
531
|
+
*
|
|
532
|
+
* Decision (#908 F3): do NOT plumb a degraded flag through findPeers. The
|
|
533
|
+
* fail-open behaviour that hides the failure lives one and two layers further
|
|
534
|
+
* down (`discoverActiveSessions` catches and falls back; `listWorktrees`
|
|
535
|
+
* returns `[]` on git failure), so a flag on findPeers would only ever report
|
|
536
|
+
* failures injected through a test seam — green tests, zero production effect.
|
|
537
|
+
* Instead the verdict is validated against a signal this function already
|
|
538
|
+
* holds: **when our own lock is live, the discovered surface MUST have returned
|
|
539
|
+
* our own entry.** `discoverActiveSessions` reads this worktree's lock either
|
|
540
|
+
* via `git worktree list` or, when that throws/times out, via its A1
|
|
541
|
+
* single-worktree fallback. Zero discovered entries under a live own lock is
|
|
542
|
+
* therefore not a quiet repo — it is a surface that produced nothing at all →
|
|
543
|
+
* `live: true`, `reason: 'probe-degraded'`, `probe: 'full-degraded'`. The
|
|
544
|
+
* distinct probe value names the difference rather than hiding it inside
|
|
545
|
+
* `'full'`. This is also why `findPeers` is called with `mySessionId: null`:
|
|
546
|
+
* self-exclusion is done by the post-filter (which covers both id-spaces
|
|
547
|
+
* anyway), and excluding our own entry upstream would erase the canary.
|
|
548
|
+
*
|
|
549
|
+
* RESIDUAL GAP — CLOSED (#919.3): the canary needs a live own lock to assert
|
|
550
|
+
* against. When `repoRoot` is my own working copy and NO live lock exists
|
|
551
|
+
* there, a total surface failure used to be indistinguishable from a quiet
|
|
552
|
+
* repo and yielded `live: false`. The liveness contract that closing it
|
|
553
|
+
* required now exists: `listWorktreesChecked()` (worktree/listing.mjs) reports
|
|
554
|
+
* whether `git worktree list` actually RAN (`ok: false` = the git invocation
|
|
555
|
+
* failed — previously swallowed into `[]`). So when the discovered surface
|
|
556
|
+
* produced NOTHING and no live own lock can vouch for it, the full path runs
|
|
557
|
+
* that check directly (raced against a 2s timeout, mirroring
|
|
558
|
+
* session-discovery's own race): git demonstrably ran → `no-peers` stands as a
|
|
559
|
+
* genuine measurement; git failed or hung → `live: true`,
|
|
560
|
+
* `reason: 'probe-degraded'`, `probe: 'full-degraded'` — the same fail-safe
|
|
561
|
+
* direction as every other unmeasurable state. Tests reach this confirmation
|
|
562
|
+
* through the SAME `opts.listWorktreesImpl` seam findPeers forwards: a seam
|
|
563
|
+
* that resolves proves the surface functional, a throwing/rejecting seam
|
|
564
|
+
* reproduces the git failure.
|
|
565
|
+
*
|
|
566
|
+
* NEVER THROWS — like every other function in this module. All paths return a
|
|
567
|
+
* verdict; the outermost catch maps the impossible case to `'probe-error'`.
|
|
568
|
+
*
|
|
569
|
+
* @param {string} repoRoot Absolute path to the repository root to probe.
|
|
570
|
+
* @param {object} [opts]
|
|
571
|
+
* @param {string} [opts.mySessionId] ACCEPTED AND IGNORED. Kept so the many
|
|
572
|
+
* existing `{ mySessionId }` call sites can be pointed at this function
|
|
573
|
+
* verbatim, and so passing the WRONG id-space (the observed live failure) is
|
|
574
|
+
* inert instead of silently wrong. Self-exclusion is derived from the lock —
|
|
575
|
+
* an id supplied by the caller is unverifiable, and trusting it could only
|
|
576
|
+
* ever REMOVE peers, i.e. push the verdict to the unsafe side.
|
|
577
|
+
* @param {string} [opts.cwd] Test seam for the own-vs-foreign auto-detection
|
|
578
|
+
* (defaults to `process.cwd()`). Not a probe override.
|
|
579
|
+
* @param {number} [opts.now] ms-since-epoch (test seam for heartbeat/age).
|
|
580
|
+
* @param {number} [opts.freshnessMin] Forwarded to findPeers (full probe only).
|
|
581
|
+
* @param {number} [opts.maxAgeHours] Forwarded to findPeers (full probe only).
|
|
582
|
+
* @param {Function} [opts.listWorktreesImpl] DI seam, forwarded to findPeers.
|
|
583
|
+
* @param {Function} [opts.registryReader] DI seam, forwarded to findPeers.
|
|
584
|
+
* @param {Function} [opts.findPeersImpl] DI seam replacing findPeers (tests).
|
|
585
|
+
* @returns {Promise<{
|
|
586
|
+
* live: boolean,
|
|
587
|
+
* reason: 'live-peer-lock'|'live-peer-discovered'|'no-lock'|'lock-expired'|'no-peers'
|
|
588
|
+
* |'invalid-repo-root'|'repo-root-missing'|'lock-unreadable'|'probe-degraded'
|
|
589
|
+
* |'probe-error',
|
|
590
|
+
* probe: 'none'|'lock-only'|'full'|'full-degraded',
|
|
591
|
+
* peerCount: number,
|
|
592
|
+
* peer: object|null
|
|
593
|
+
* }>} `peer` is a representative live peer (the first) or null; `peerCount`
|
|
594
|
+
* carries the total, so a consumer can phrase "live foreign session, 2h old"
|
|
595
|
+
* without measuring a second time.
|
|
596
|
+
*/
|
|
597
|
+
export async function checkLiveForeignSession(repoRoot, opts = {}) {
|
|
598
|
+
const nowMs = typeof opts.now === 'number' ? opts.now : Date.now();
|
|
599
|
+
|
|
600
|
+
try {
|
|
601
|
+
// -- Pre-checks: anything that prevents measurement fails SAFE (live:true).
|
|
602
|
+
if (typeof repoRoot !== 'string' || repoRoot.trim() === '') {
|
|
603
|
+
return _verdict(true, 'invalid-repo-root', PROBE_NONE);
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
const rootAbs = _realpathOrResolved(repoRoot);
|
|
607
|
+
if (!fs.existsSync(rootAbs)) {
|
|
608
|
+
return _verdict(true, 'repo-root-missing', PROBE_NONE);
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
// readLock() collapses "absent" and "unparseable" into null. They are
|
|
612
|
+
// opposite verdicts here (nobody-home vs cannot-tell), so split them: a
|
|
613
|
+
// lock file that exists but does not parse means SOMETHING wrote it and we
|
|
614
|
+
// cannot say whether that session is alive → fail safe.
|
|
615
|
+
const lock = readLock({ repoRoot: rootAbs });
|
|
616
|
+
if (lock === null && fs.existsSync(path.join(rootAbs, LOCK_PATH))) {
|
|
617
|
+
return _verdict(true, 'lock-unreadable', PROBE_NONE);
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
const cwdAbs = _realpathOrResolved(
|
|
621
|
+
typeof opts.cwd === 'string' && opts.cwd.trim() !== '' ? opts.cwd : process.cwd(),
|
|
622
|
+
);
|
|
623
|
+
|
|
624
|
+
// ------------------------------------------------------------------
|
|
625
|
+
// Cheap probe — foreign repo. Two sync calls, no git, no registry.
|
|
626
|
+
// ------------------------------------------------------------------
|
|
627
|
+
if (!_isOwnWorkingCopy(rootAbs, cwdAbs)) {
|
|
628
|
+
if (lock === null) {
|
|
629
|
+
return _verdict(false, 'no-lock', PROBE_LOCK_ONLY);
|
|
630
|
+
}
|
|
631
|
+
if (!isLockLive(lock, nowMs)) {
|
|
632
|
+
// Heartbeat older than ttl_hours — dead lease, not a live session.
|
|
633
|
+
return _verdict(false, 'lock-expired', PROBE_LOCK_ONLY);
|
|
634
|
+
}
|
|
635
|
+
// Fresh heartbeat ⇒ live, whatever the recorded pid says (#799).
|
|
636
|
+
return _verdict(true, 'live-peer-lock', PROBE_LOCK_ONLY, {
|
|
637
|
+
peerCount: 1,
|
|
638
|
+
peer: _peerFromLockBody(lock, rootAbs, nowMs),
|
|
639
|
+
});
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
// ------------------------------------------------------------------
|
|
643
|
+
// Full probe — my own working copy: sibling worktrees + registry count.
|
|
644
|
+
// ------------------------------------------------------------------
|
|
645
|
+
|
|
646
|
+
// Self-ids are READ, never accepted from the caller (see JSDoc, Falle 1).
|
|
647
|
+
// `mySessionId: null` is passed DELIBERATELY: self-exclusion happens
|
|
648
|
+
// entirely in the post-filter below (it covers both id-spaces, which
|
|
649
|
+
// findPeers's single-id seam cannot), and suppressing our own entry INSIDE
|
|
650
|
+
// findPeers would also destroy the canary — our own entry is the only
|
|
651
|
+
// production-observable proof that the discovered surface functioned.
|
|
652
|
+
const selfIds = new Set();
|
|
653
|
+
if (lock && typeof lock.session_id === 'string') selfIds.add(lock.session_id);
|
|
654
|
+
if (lock && typeof lock.semantic_session_id === 'string' && lock.semantic_session_id !== '') {
|
|
655
|
+
selfIds.add(lock.semantic_session_id);
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
const findPeersFn = typeof opts.findPeersImpl === 'function' ? opts.findPeersImpl : findPeers;
|
|
659
|
+
const result = await findPeersFn(rootAbs, {
|
|
660
|
+
mySessionId: null,
|
|
661
|
+
now: nowMs,
|
|
662
|
+
freshnessMin: opts.freshnessMin,
|
|
663
|
+
maxAgeHours: opts.maxAgeHours,
|
|
664
|
+
listWorktreesImpl: opts.listWorktreesImpl,
|
|
665
|
+
registryReader: opts.registryReader,
|
|
666
|
+
});
|
|
667
|
+
|
|
668
|
+
const allPeers = Array.isArray(result?.peers) ? result.peers : [];
|
|
669
|
+
// Falle 3: filter by source — a raw count double-counts (no cross-source
|
|
670
|
+
// dedup upstream, by design) and would treat a leftover STATE.md as a
|
|
671
|
+
// running session. No extra sessionId dedupe: within 'discovered',
|
|
672
|
+
// discoverActiveSessions already deduped.
|
|
673
|
+
const discoveredAll = allPeers.filter((p) => (
|
|
674
|
+
p && p.source === SOURCE_DISCOVERED && typeof p.sessionId === 'string'
|
|
675
|
+
));
|
|
676
|
+
const livePeers = discoveredAll.filter((p) => !selfIds.has(p.sessionId));
|
|
677
|
+
|
|
678
|
+
if (livePeers.length === 0) {
|
|
679
|
+
// Falle 4 — the full path's negative verdict needs a liveness proof of
|
|
680
|
+
// its own. findPeers fails open per surface and returns ONLY `{ peers }`,
|
|
681
|
+
// so "both surfaces broke" and "measured, nobody home" are the same
|
|
682
|
+
// value. Canary: when our own lock is live, the discovered surface MUST
|
|
683
|
+
// have returned our own entry — `discoverActiveSessions` reads this
|
|
684
|
+
// worktree's lock either through `git worktree list` or, on failure, the
|
|
685
|
+
// A1 single-worktree fallback. Zero discovered entries under a live own
|
|
686
|
+
// lock therefore means the surface produced nothing at all, not that the
|
|
687
|
+
// repo is quiet → fail safe.
|
|
688
|
+
if (discoveredAll.length === 0 && lock !== null && isLockLive(lock, nowMs)) {
|
|
689
|
+
return _verdict(true, 'probe-degraded', PROBE_FULL_DEGRADED);
|
|
690
|
+
}
|
|
691
|
+
// #919.3 — the canary above needs a live own lock to assert against.
|
|
692
|
+
// Without one, "total surface failure" and "quiet repo" still arrive as
|
|
693
|
+
// the same empty list (the documented residual gap). Close it with a
|
|
694
|
+
// direct liveness check of the git surface itself: did `git worktree
|
|
695
|
+
// list` actually RUN? Only reached when the discovered surface produced
|
|
696
|
+
// NOTHING — any discovered entry is already proof the surface
|
|
697
|
+
// functioned, so the extra git spawn is paid solely on the branch that
|
|
698
|
+
// needs it.
|
|
699
|
+
if (discoveredAll.length === 0) {
|
|
700
|
+
const gitRan = await _confirmWorktreeListRan(opts);
|
|
701
|
+
if (!gitRan) {
|
|
702
|
+
return _verdict(true, 'probe-degraded', PROBE_FULL_DEGRADED);
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
return _verdict(false, 'no-peers', PROBE_FULL);
|
|
706
|
+
}
|
|
707
|
+
return _verdict(true, 'live-peer-discovered', PROBE_FULL, {
|
|
708
|
+
peerCount: livePeers.length,
|
|
709
|
+
peer: _peerFromFindPeersEntry(livePeers[0]),
|
|
710
|
+
});
|
|
711
|
+
} catch {
|
|
712
|
+
// Unreachable by contract (every callee is documented never-throws), but a
|
|
713
|
+
// regression here must not turn a probe into an exception at the call site.
|
|
714
|
+
return _verdict(true, 'probe-error', PROBE_NONE);
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
// ===========================================================================
|
|
719
|
+
// CLI entry (#908 Befund 3) — `checkLiveForeignSession` from a shell.
|
|
720
|
+
//
|
|
721
|
+
// The consumer of this function is the Fact-Staleness Annotation rule in
|
|
722
|
+
// `skills/wave-executor/wave-loop.md` (Trigger 3), which a COORDINATOR LLM
|
|
723
|
+
// evaluates once per wave. A coordinator has Bash, not an ESM module loader —
|
|
724
|
+
// documenting the call as an `import` made the trigger unevaluatable, so the
|
|
725
|
+
// rule shipped inert. This entry point is what makes it executable.
|
|
726
|
+
//
|
|
727
|
+
// Contract follows `.claude/rules/cli-design.md` and the in-repo precedent of
|
|
728
|
+
// `scripts/lib/fetch-baseline.mjs` (a lib module with a documented CLI invoked
|
|
729
|
+
// from a skill body): data → stdout, diagnostics → stderr, `--json` for
|
|
730
|
+
// machine-readable output, documented exit codes.
|
|
731
|
+
//
|
|
732
|
+
// NOTE on exit codes: a live peer is a RESULT, not an error. `live` is read
|
|
733
|
+
// from the payload; the exit code reports whether the probe RAN. Encoding the
|
|
734
|
+
// verdict in the exit code would collide with the usage-error code and would
|
|
735
|
+
// invert the fail-safe direction on any shell that treats non-zero as failure.
|
|
736
|
+
// ===========================================================================
|
|
737
|
+
|
|
738
|
+
const CLI_USAGE = `Usage: node scripts/lib/peer-discovery.mjs --check-live <repoRoot> [--cwd <dir>] [--json]
|
|
739
|
+
|
|
740
|
+
Probe whether a LIVE FOREIGN session is running in <repoRoot>.
|
|
741
|
+
|
|
742
|
+
Options:
|
|
743
|
+
--check-live <repoRoot> Repository root to probe (required).
|
|
744
|
+
--cwd <dir> Input for own-vs-foreign probe selection (default: process.cwd()).
|
|
745
|
+
--json Emit the verdict as JSON on stdout.
|
|
746
|
+
-h, --help Print this help.
|
|
747
|
+
|
|
748
|
+
Output (stdout) — JSON shape with --json:
|
|
749
|
+
{ "live": bool, "reason": string, "probe": string, "peerCount": number, "peer": object|null }
|
|
750
|
+
|
|
751
|
+
Exit codes:
|
|
752
|
+
0 the probe ran; a verdict was produced (read "live" from the payload —
|
|
753
|
+
a live foreign session is a RESULT, not an error)
|
|
754
|
+
1 usage error (missing or unknown argument)
|
|
755
|
+
2 system error (the probe threw, which its contract forbids)
|
|
756
|
+
`;
|
|
757
|
+
|
|
758
|
+
/**
|
|
759
|
+
* One-line human rendering of a verdict. Key=value so it stays greppable
|
|
760
|
+
* without being mistaken for the machine format (`--json`).
|
|
761
|
+
*
|
|
762
|
+
* @param {{live:boolean,reason:string,probe:string,peerCount:number,peer:object|null}} v
|
|
763
|
+
* @returns {string}
|
|
764
|
+
*/
|
|
765
|
+
function _formatVerdictHuman(v) {
|
|
766
|
+
const parts = [
|
|
767
|
+
`live=${v.live}`,
|
|
768
|
+
`reason=${v.reason}`,
|
|
769
|
+
`probe=${v.probe}`,
|
|
770
|
+
`peerCount=${v.peerCount}`,
|
|
771
|
+
];
|
|
772
|
+
if (v.peer && v.peer.sessionId) parts.push(`peer=${v.peer.sessionId}`);
|
|
773
|
+
return parts.join(' ');
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
/**
|
|
777
|
+
* CLI main. Never throws; sets `process.exitCode` and returns so a pending
|
|
778
|
+
* stdout write can drain naturally (an explicit `process.exit()` races the
|
|
779
|
+
* kernel pipe buffer — see the same note in `scripts/lib/description-surface.mjs`).
|
|
780
|
+
*
|
|
781
|
+
* @param {string[]} argv Arguments after `node <file>`.
|
|
782
|
+
* @returns {Promise<void>}
|
|
783
|
+
*/
|
|
784
|
+
async function _cliMain(argv) {
|
|
785
|
+
let parsed;
|
|
786
|
+
try {
|
|
787
|
+
parsed = parseArgs({
|
|
788
|
+
args: argv,
|
|
789
|
+
options: {
|
|
790
|
+
'check-live': { type: 'string' },
|
|
791
|
+
cwd: { type: 'string' },
|
|
792
|
+
json: { type: 'boolean', default: false },
|
|
793
|
+
help: { type: 'boolean', short: 'h', default: false },
|
|
794
|
+
},
|
|
795
|
+
allowPositionals: false,
|
|
796
|
+
strict: true,
|
|
797
|
+
});
|
|
798
|
+
} catch (err) {
|
|
799
|
+
process.stderr.write(`peer-discovery: ${err.message}\n\n${CLI_USAGE}`);
|
|
800
|
+
process.exitCode = 1;
|
|
801
|
+
return;
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
if (parsed.values.help) {
|
|
805
|
+
process.stdout.write(CLI_USAGE);
|
|
806
|
+
return;
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
const target = parsed.values['check-live'];
|
|
810
|
+
if (typeof target !== 'string' || target.trim() === '') {
|
|
811
|
+
process.stderr.write(`peer-discovery: --check-live <repoRoot> is required\n\n${CLI_USAGE}`);
|
|
812
|
+
process.exitCode = 1;
|
|
813
|
+
return;
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
let verdict;
|
|
817
|
+
try {
|
|
818
|
+
verdict = await checkLiveForeignSession(target, {
|
|
819
|
+
cwd: typeof parsed.values.cwd === 'string' ? parsed.values.cwd : undefined,
|
|
820
|
+
});
|
|
821
|
+
} catch (err) {
|
|
822
|
+
// Unreachable by contract — kept so a regression surfaces as exit 2 with a
|
|
823
|
+
// one-line diagnostic instead of an unhandled rejection stack trace.
|
|
824
|
+
process.stderr.write(`peer-discovery: probe failed unexpectedly: ${err.message}\n`);
|
|
825
|
+
process.exitCode = 2;
|
|
826
|
+
return;
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
process.stdout.write(
|
|
830
|
+
(parsed.values.json ? JSON.stringify(verdict) : _formatVerdictHuman(verdict)) + '\n',
|
|
831
|
+
);
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
835
|
+
const _isCliMain =
|
|
836
|
+
typeof process !== 'undefined'
|
|
837
|
+
&& process.argv[1] !== null
|
|
838
|
+
&& process.argv[1] !== undefined
|
|
839
|
+
&& path.resolve(process.argv[1]) === path.resolve(__filename);
|
|
840
|
+
|
|
841
|
+
// Called WITHOUT `await` on purpose: a top-level await would make this module
|
|
842
|
+
// async for every importer, and `_cliMain` cannot reject (all paths are caught
|
|
843
|
+
// internally). Node drains the event loop before exiting, so `process.exitCode`
|
|
844
|
+
// set inside still takes effect. Same shape as scripts/lib/description-surface.mjs.
|
|
845
|
+
if (_isCliMain) _cliMain(process.argv.slice(2));
|