session-orchestrator 3.17.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 +105 -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/docs/USER-GUIDE.md +8 -3
- 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 +444 -0
- package/docs/rule-authoring.md +58 -9
- package/docs/session-config-reference.md +244 -9
- package/docs/session-config-template.md +39 -3
- 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/subagent-telemetry.mjs +527 -37
- package/package.json +5 -2
- 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/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 +1013 -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/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/schema.mjs +43 -3
- 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 +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-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/validator.mjs +16 -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/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/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/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 +51 -12
- package/scripts/release.mjs +534 -0
- package/scripts/run-quality-gate.mjs +123 -5
- 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 +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/quality-gates/SKILL.md +1 -1
- package/skills/reconcile/SKILL.md +21 -4
- package/skills/session-end/SKILL.md +34 -13
- 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 +73 -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/validator.mjs +108 -7
- 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
|
@@ -14,6 +14,13 @@
|
|
|
14
14
|
* a different hostname (can't signal a remote process).
|
|
15
15
|
* - Decision deferred: acquire() reports stale locks but does NOT auto-clear
|
|
16
16
|
* them. session-start handles the recovery AUQ flow (W3-C3).
|
|
17
|
+
* - Owner-proof, not just id-match (#906-class fix): the semantic session id
|
|
18
|
+
* is NOT globally unique (the id-counter can hand out the same
|
|
19
|
+
* `<branch>-<date>-<mode>-<n>` to two different session processes on the
|
|
20
|
+
* same day). `buildLockOwnerProof()` / `isLockOwnedByProof()` verify
|
|
21
|
+
* ownership via a SECOND identity factor (pid + host + started_at)
|
|
22
|
+
* instead of trusting a session_id/semantic_session_id match alone. See
|
|
23
|
+
* their JSDoc for the fail-closed contract and factor-choice rationale.
|
|
17
24
|
*
|
|
18
25
|
* BARREL CONTRACT (#630 A1 barrel-preserving split):
|
|
19
26
|
* This module bundled THREE orthogonal lock protocols. Two of them — the
|
|
@@ -37,6 +44,7 @@ import path from 'node:path';
|
|
|
37
44
|
import crypto from 'node:crypto';
|
|
38
45
|
import { classifyMode } from './exclusivity-matrix.mjs';
|
|
39
46
|
import { isPidAliveOnHost } from './file-lock.mjs';
|
|
47
|
+
import { writeJsonAtomicSync } from './io.mjs';
|
|
40
48
|
|
|
41
49
|
// isPidAliveOnHost moved into file-lock.mjs in #630 (the file-lock primitive
|
|
42
50
|
// owns it so the dependency edge points file-lock → io, never the reverse).
|
|
@@ -76,6 +84,14 @@ export {
|
|
|
76
84
|
export const DEFAULT_TTL_HOURS = 4;
|
|
77
85
|
export const LOCK_PATH = '.orchestrator/session.lock';
|
|
78
86
|
|
|
87
|
+
/**
|
|
88
|
+
* Where the durable lock-ownership proof lives, relative to the repo root
|
|
89
|
+
* (#987 Part 1). `.orchestrator/runtime/` is machine-local, gitignored state —
|
|
90
|
+
* the proof must survive across hook subprocesses of the SAME logical session
|
|
91
|
+
* but never travel via VCS.
|
|
92
|
+
*/
|
|
93
|
+
export const OWNER_PROOF_RELPATH = '.orchestrator/runtime/lock-owner-proof.json';
|
|
94
|
+
|
|
79
95
|
// ---------------------------------------------------------------------------
|
|
80
96
|
// Internal helpers
|
|
81
97
|
// ---------------------------------------------------------------------------
|
|
@@ -315,17 +331,80 @@ function createSessionLockExclusive(lockFile, lock) {
|
|
|
315
331
|
* Returns the parsed lock object, or null if absent or unparseable.
|
|
316
332
|
* Never throws.
|
|
317
333
|
*
|
|
334
|
+
* BACK-COMPAT CONTRACT (load-bearing — grep-verified 2026-07-29):
|
|
335
|
+
* A bare `grep -rn "readLock("` over scripts/ + hooks/ reports 14 hits, but
|
|
336
|
+
* 6 of those are comment/JSDoc mentions, not calls. Filtering them out —
|
|
337
|
+
* `grep -rn "readLock(" --include='*.mjs' scripts/ hooks/ | grep -v
|
|
338
|
+
* "scripts/lib/session-lock.mjs" | grep -vE ':\s*(\*|//)' | wc -l` → 8 real
|
|
339
|
+
* production call sites across 6 files (lock-reaper, peer-discovery,
|
|
340
|
+
* session-close-backfill, session-discovery, sessions-staleness-banner,
|
|
341
|
+
* vault-status/board-writer). The tests/ figure has the same caveat: of the
|
|
342
|
+
* 45 raw hits, ~11 are comments and 3 are local same-named test doubles.
|
|
343
|
+
* Counting raw grep hits as call sites OVERSTATES the surface — the number
|
|
344
|
+
* is smaller than first briefed, but every one of those 8 relies on the
|
|
345
|
+
* "absent/unreadable/corrupt all collapse to null" contract below, so the
|
|
346
|
+
* conclusion is unchanged. This function's signature and null behaviour
|
|
347
|
+
* MUST stay byte-for-byte identical — the discriminated read that
|
|
348
|
+
* distinguishes those three cases is the NEW, additive `readLockDetailed()`
|
|
349
|
+
* below; `readLock()` is now a thin projection of it back onto the legacy
|
|
350
|
+
* contract (RCR-007: changing the 59-call-site contract itself would be a
|
|
351
|
+
* public-API break, not a same-scope fix).
|
|
352
|
+
*
|
|
318
353
|
* @param {{ repoRoot?: string }} [opts]
|
|
319
354
|
* @returns {{ session_id: string, started_at: string, mode: string, pid: number, host: string, ttl_hours: number } | null}
|
|
320
355
|
*/
|
|
321
356
|
export function readLock(opts = {}) {
|
|
357
|
+
const detailed = readLockDetailed(opts);
|
|
358
|
+
return detailed.status === 'ok' ? detailed.lock : null;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/**
|
|
362
|
+
* Read the lock file and DISCRIMINATE why it might not yield a usable lock —
|
|
363
|
+
* additive alongside `readLock()` (see its back-compat contract note above),
|
|
364
|
+
* which collapses every non-ok case to `null`. That collapse is exactly how
|
|
365
|
+
* a vanished/unreadable lock has read as "no lock" instead of "anomaly"
|
|
366
|
+
* throughout this session's investigation (#906-class incident).
|
|
367
|
+
*
|
|
368
|
+
* Four discriminated outcomes:
|
|
369
|
+
* - `{ status: 'absent' }` — the file does not exist (ENOENT). This is the
|
|
370
|
+
* ONLY case that should be treated as "no lock, proceed as if free".
|
|
371
|
+
* - `{ status: 'unreadable', error }` — the file exists but could not be
|
|
372
|
+
* read (e.g. EACCES, EISDIR). Distinct from `absent` on purpose: an
|
|
373
|
+
* unreadable lock is an ANOMALY a caller may want to surface, not a
|
|
374
|
+
* green light to acquire.
|
|
375
|
+
* - `{ status: 'corrupt', raw }` — the file was read but its contents
|
|
376
|
+
* failed `parseLock()` (invalid JSON, or valid JSON missing the required
|
|
377
|
+
* shape). `raw` is included so a caller can log/diagnose without a
|
|
378
|
+
* second read.
|
|
379
|
+
* - `{ status: 'ok', lock }` — the file was read and parsed successfully.
|
|
380
|
+
*
|
|
381
|
+
* Never throws.
|
|
382
|
+
*
|
|
383
|
+
* @param {{ repoRoot?: string }} [opts]
|
|
384
|
+
* @returns {
|
|
385
|
+
* { status: 'absent' } |
|
|
386
|
+
* { status: 'unreadable', error: string } |
|
|
387
|
+
* { status: 'corrupt', raw: string } |
|
|
388
|
+
* { status: 'ok', lock: object }
|
|
389
|
+
* }
|
|
390
|
+
*/
|
|
391
|
+
export function readLockDetailed(opts = {}) {
|
|
322
392
|
const lockFile = lockPathFor(opts.repoRoot);
|
|
393
|
+
let raw;
|
|
323
394
|
try {
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
395
|
+
raw = fs.readFileSync(lockFile, 'utf8');
|
|
396
|
+
} catch (err) {
|
|
397
|
+
if (err && err.code === 'ENOENT') {
|
|
398
|
+
return { status: 'absent' };
|
|
399
|
+
}
|
|
400
|
+
return { status: 'unreadable', error: err.message };
|
|
328
401
|
}
|
|
402
|
+
|
|
403
|
+
const lock = parseLock(raw);
|
|
404
|
+
if (lock === null) {
|
|
405
|
+
return { status: 'corrupt', raw };
|
|
406
|
+
}
|
|
407
|
+
return { status: 'ok', lock };
|
|
329
408
|
}
|
|
330
409
|
|
|
331
410
|
/**
|
|
@@ -558,6 +637,206 @@ export function forceAcquire({ sessionId, mode, ttlHours = DEFAULT_TTL_HOURS, re
|
|
|
558
637
|
}
|
|
559
638
|
}
|
|
560
639
|
|
|
640
|
+
/**
|
|
641
|
+
* Extract an ownership-proof object from a lock — the field-name translation
|
|
642
|
+
* layer `isLockOwnedByProof()` expects. Intended usage: a caller that just
|
|
643
|
+
* created/observed a lock IT WROTE (the `lock` returned by
|
|
644
|
+
* `acquire()`/`forceAcquire()`, or a `readLock()`/`readLockDetailed()` taken
|
|
645
|
+
* immediately after) calls this ONCE at genesis time and persists the
|
|
646
|
+
* RESULT — not the raw lock — as its durable "I own this" evidence. Later,
|
|
647
|
+
* a different process invocation (e.g. a subsequent hook subprocess in the
|
|
648
|
+
* same logical session) can present that persisted proof to
|
|
649
|
+
* `isLockOwnedByProof()` against whatever lock is on disk AT THAT TIME.
|
|
650
|
+
*
|
|
651
|
+
* Pure, no-throw. Returns `null` (never a partial object) when any of the
|
|
652
|
+
* three required fields is missing or the wrong type — a caller cannot
|
|
653
|
+
* construct a proof from a lock it cannot fully observe.
|
|
654
|
+
*
|
|
655
|
+
* @param {object|null} lock
|
|
656
|
+
* @returns {{ pid: number, host: string, startedAt: string } | null}
|
|
657
|
+
*/
|
|
658
|
+
export function buildLockOwnerProof(lock) {
|
|
659
|
+
if (!lock || typeof lock !== 'object') return null;
|
|
660
|
+
const { pid, host, started_at: startedAt } = lock;
|
|
661
|
+
if (typeof pid !== 'number') return null;
|
|
662
|
+
if (typeof host !== 'string' || host.length === 0) return null;
|
|
663
|
+
if (typeof startedAt !== 'string' || startedAt.length === 0) return null;
|
|
664
|
+
return { pid, host, startedAt };
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
/**
|
|
668
|
+
* Verify that a caller genuinely owns a lock, using a SECOND identity factor
|
|
669
|
+
* beyond the (non-unique) session_id / semantic_session_id.
|
|
670
|
+
*
|
|
671
|
+
* WHY THIS EXISTS (#906-class bug, this session's root-cause finding): the
|
|
672
|
+
* semantic session id (`<branch>-<date>-<mode>-<n>`) is NOT globally unique
|
|
673
|
+
* — the id-counter can hand out the SAME id to two different session
|
|
674
|
+
* processes on the same day (live-observed collisions this session:
|
|
675
|
+
* `main-2026-07-29-deep-1` and `main-2026-07-29-session-1`, each assigned
|
|
676
|
+
* twice). `hooks/on-session-end.mjs`'s `ownBySemanticStrict` path currently
|
|
677
|
+
* decides lock ownership from that colliding id alone and can delete a
|
|
678
|
+
* LIVE, foreign session's lock as a result. `release()`'s pre-existing
|
|
679
|
+
* `existing.session_id !== sessionId` check (below) is a TOCTOU re-read
|
|
680
|
+
* guard — it re-derives the exact same potentially-colliding key, so it
|
|
681
|
+
* catches "the lock changed between my two reads" but NOT "the lock I'm
|
|
682
|
+
* looking at was never mine to begin with". This function is the missing
|
|
683
|
+
* proof.
|
|
684
|
+
*
|
|
685
|
+
* FACTOR CHOICE — pid + host + started_at, never session_id:
|
|
686
|
+
* - `host` is required first: liveness/identity cannot be asserted across
|
|
687
|
+
* machines anyway (see `isPidAliveOnHost`), so a lock written on a
|
|
688
|
+
* DIFFERENT host is rejected outright, no further comparison needed.
|
|
689
|
+
* - `pid` alone is not unique on a long-lived host — the OS recycles PIDs
|
|
690
|
+
* once the original process exits — so it cannot carry the proof by
|
|
691
|
+
* itself.
|
|
692
|
+
* - `started_at` is the actual discriminator: its ISO-8601-with-
|
|
693
|
+
* milliseconds value is knowable ONLY to a caller that was PRESENT at
|
|
694
|
+
* lock-creation time (it wrote the lock itself, or read it back
|
|
695
|
+
* immediately after `acquire()`/`forceAcquire()` returned). A same-day
|
|
696
|
+
* semantic-id collision from an unrelated process has, with
|
|
697
|
+
* overwhelming probability, a DIFFERENT millisecond `started_at` — this
|
|
698
|
+
* is exactly the discriminator the id-collision case lacks.
|
|
699
|
+
* - Combined, `pid + host + started_at` is a proof of PRESENCE AT GENESIS,
|
|
700
|
+
* not a re-assertion of the same collidable name.
|
|
701
|
+
*
|
|
702
|
+
* FAIL-CLOSED CONTRACT: any missing/malformed field on EITHER side (the
|
|
703
|
+
* live lock or the caller's proof) returns `false`. This function must
|
|
704
|
+
* never answer "true" when it cannot actually confirm ownership — silently
|
|
705
|
+
* defaulting to permissive on incomplete data is the exact fail-open shape
|
|
706
|
+
* this session exists to close (see the `console.log + process.exit()`
|
|
707
|
+
* stdout-truncation rule and the exit-code-to-JSON-migration rule in this
|
|
708
|
+
* repo's reconciled learnings for the same failure class in other guards).
|
|
709
|
+
*
|
|
710
|
+
* @param {object|null} lock The CURRENT on-disk lock (e.g. from `readLock()`).
|
|
711
|
+
* @param {{ pid: number, host: string, startedAt: string }|null} proof
|
|
712
|
+
* Typically the return value of `buildLockOwnerProof()` captured at
|
|
713
|
+
* genesis time. NEVER re-derive this from `process.pid`/`os.hostname()`
|
|
714
|
+
* at check-time — the checking process is very likely a DIFFERENT
|
|
715
|
+
* subprocess (each hook invocation is its own process) than the one that
|
|
716
|
+
* originally wrote the lock.
|
|
717
|
+
* @returns {boolean} true only when pid, host, AND started_at all match
|
|
718
|
+
* exactly between `lock` and `proof`.
|
|
719
|
+
*/
|
|
720
|
+
export function isLockOwnedByProof(lock, proof) {
|
|
721
|
+
if (!lock || typeof lock !== 'object') return false;
|
|
722
|
+
if (!proof || typeof proof !== 'object') return false;
|
|
723
|
+
|
|
724
|
+
const { pid: lockPid, host: lockHost, started_at: lockStartedAt } = lock;
|
|
725
|
+
const { pid: proofPid, host: proofHost, startedAt: proofStartedAt } = proof;
|
|
726
|
+
|
|
727
|
+
if (typeof lockPid !== 'number' || typeof proofPid !== 'number') return false;
|
|
728
|
+
if (typeof lockHost !== 'string' || lockHost.length === 0) return false;
|
|
729
|
+
if (typeof proofHost !== 'string' || proofHost.length === 0) return false;
|
|
730
|
+
if (typeof lockStartedAt !== 'string' || lockStartedAt.length === 0) return false;
|
|
731
|
+
if (typeof proofStartedAt !== 'string' || proofStartedAt.length === 0) return false;
|
|
732
|
+
|
|
733
|
+
return lockPid === proofPid && lockHost === proofHost && lockStartedAt === proofStartedAt;
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
/**
|
|
737
|
+
* Persist the ownership proof of a just-written lock to
|
|
738
|
+
* `.orchestrator/runtime/lock-owner-proof.json` (#987 Part 1 — proof
|
|
739
|
+
* persistence at lock genesis; Part 2, the on-session-end consumption side,
|
|
740
|
+
* is a separate change).
|
|
741
|
+
*
|
|
742
|
+
* Intended caller: `bootstrapLock()` (hooks/_lib/lock-bootstrap.mjs)
|
|
743
|
+
* immediately after its enriched-lock write — the `lock` argument there is
|
|
744
|
+
* byte-identical to the on-disk lock, so the proof it yields will verify via
|
|
745
|
+
* `isLockOwnedByProof()` against any later re-read of that same lock.
|
|
746
|
+
*
|
|
747
|
+
* Envelope shape (schema_version 1):
|
|
748
|
+
* {
|
|
749
|
+
* schema_version: 1,
|
|
750
|
+
* proof: { pid, host, startedAt }, // the ONLY field ever compared
|
|
751
|
+
* lock_session_id, // forensic-only
|
|
752
|
+
* semantic_session_id, // forensic-only
|
|
753
|
+
* repo_root, // forensic-only
|
|
754
|
+
* written_at, // forensic-only
|
|
755
|
+
* }
|
|
756
|
+
*
|
|
757
|
+
* INVARIANT: only `proof` may ever participate in an ownership comparison.
|
|
758
|
+
* `lock_session_id` / `semantic_session_id` rotate per session and the
|
|
759
|
+
* semantic form COLLIDES across same-day sessions — trusting them is exactly
|
|
760
|
+
* the #906-class bug this proof exists to close. Consumers go through
|
|
761
|
+
* `loadOwnerProof()`, which strips the envelope and returns the triple alone.
|
|
762
|
+
*
|
|
763
|
+
* No-throw. When the lock cannot yield a full proof (missing/mistyped
|
|
764
|
+
* pid/host/started_at), this is a deliberate no-op: nothing is written and
|
|
765
|
+
* `{ ok: false, reason: 'unproovable-lock' }` is returned — a partial proof
|
|
766
|
+
* on disk would be a fail-open artifact.
|
|
767
|
+
*
|
|
768
|
+
* @param {{ repoRoot?: string, lock: object|null }} args
|
|
769
|
+
* @returns {{ ok: true, path: string }
|
|
770
|
+
* |{ ok: false, reason: 'unproovable-lock' }
|
|
771
|
+
* |{ ok: false, reason: 'fs-error', error: string }}
|
|
772
|
+
*/
|
|
773
|
+
export function writeOwnerProof({ repoRoot, lock } = {}) {
|
|
774
|
+
try {
|
|
775
|
+
const proof = buildLockOwnerProof(lock);
|
|
776
|
+
if (proof === null) {
|
|
777
|
+
return { ok: false, reason: 'unproovable-lock' };
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
const root = repoRoot ?? process.cwd();
|
|
781
|
+
const proofFile = path.join(root, OWNER_PROOF_RELPATH);
|
|
782
|
+
const envelope = {
|
|
783
|
+
schema_version: 1,
|
|
784
|
+
proof,
|
|
785
|
+
lock_session_id: typeof lock.session_id === 'string' ? lock.session_id : null,
|
|
786
|
+
semantic_session_id:
|
|
787
|
+
typeof lock.semantic_session_id === 'string' ? lock.semantic_session_id : null,
|
|
788
|
+
repo_root: root,
|
|
789
|
+
written_at: nowIso(),
|
|
790
|
+
};
|
|
791
|
+
|
|
792
|
+
const w = writeJsonAtomicSync(proofFile, envelope, { tmpPrefix: '.lock-owner-proof.tmp' });
|
|
793
|
+
if (!w.ok) return w;
|
|
794
|
+
return { ok: true, path: proofFile };
|
|
795
|
+
} catch (err) {
|
|
796
|
+
return { ok: false, reason: 'fs-error', error: err?.message ?? String(err) };
|
|
797
|
+
}
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
/**
|
|
801
|
+
* Load the persisted ownership proof written by `writeOwnerProof()` and
|
|
802
|
+
* return the inner `{ pid, host, startedAt }` triple — nothing else from the
|
|
803
|
+
* envelope ever escapes (the forensic fields must not leak into comparisons,
|
|
804
|
+
* see the INVARIANT on `writeOwnerProof()`).
|
|
805
|
+
*
|
|
806
|
+
* FAIL-CLOSED, never throws: a missing file, unreadable file, malformed
|
|
807
|
+
* JSON, an envelope without a `proof` object, or a proof with any
|
|
808
|
+
* missing/mistyped field all return `null`. A `null` proof presented to
|
|
809
|
+
* `isLockOwnedByProof()` yields `false` — the consumer degrades to
|
|
810
|
+
* "cannot prove ownership", never to "assume ownership".
|
|
811
|
+
*
|
|
812
|
+
* Note the proof carries no self-expiry: a stale proof from a PREVIOUS
|
|
813
|
+
* session is harmless by construction, because the triple it holds (its
|
|
814
|
+
* millisecond `started_at` above all) will not match any newer lock —
|
|
815
|
+
* `isLockOwnedByProof()` rejects it. See the stale-proof test in
|
|
816
|
+
* tests/lib/session-lock.test.mjs.
|
|
817
|
+
*
|
|
818
|
+
* @param {{ repoRoot?: string }} args
|
|
819
|
+
* @returns {{ pid: number, host: string, startedAt: string } | null}
|
|
820
|
+
*/
|
|
821
|
+
export function loadOwnerProof({ repoRoot } = {}) {
|
|
822
|
+
try {
|
|
823
|
+
const proofFile = path.join(repoRoot ?? process.cwd(), OWNER_PROOF_RELPATH);
|
|
824
|
+
const raw = fs.readFileSync(proofFile, 'utf8');
|
|
825
|
+
const envelope = JSON.parse(raw);
|
|
826
|
+
if (!envelope || typeof envelope !== 'object') return null;
|
|
827
|
+
|
|
828
|
+
const p = envelope.proof;
|
|
829
|
+
if (!p || typeof p !== 'object') return null;
|
|
830
|
+
if (typeof p.pid !== 'number') return null;
|
|
831
|
+
if (typeof p.host !== 'string' || p.host.length === 0) return null;
|
|
832
|
+
if (typeof p.startedAt !== 'string' || p.startedAt.length === 0) return null;
|
|
833
|
+
|
|
834
|
+
return { pid: p.pid, host: p.host, startedAt: p.startedAt };
|
|
835
|
+
} catch {
|
|
836
|
+
return null;
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
|
|
561
840
|
/**
|
|
562
841
|
* Release the lock IFF it belongs to the given session_id.
|
|
563
842
|
* Silent no-op when the lock belongs to a different session or does not exist.
|
|
@@ -580,10 +859,20 @@ export function forceAcquire({ sessionId, mode, ttlHours = DEFAULT_TTL_HOURS, re
|
|
|
580
859
|
* lock present is fine (ours is gone); only our own lock still being
|
|
581
860
|
* observable after the bounded retry sets `verified: false`.
|
|
582
861
|
*
|
|
583
|
-
*
|
|
862
|
+
* Optional proof-gated ownership (#906-class fix, additive): pass `proof`
|
|
863
|
+
* (see `buildLockOwnerProof()` / `isLockOwnedByProof()`) to require a SECOND
|
|
864
|
+
* identity factor beyond `session_id` before deleting — this is what makes
|
|
865
|
+
* a same-day semantic-id collision safe to release against. When `proof` is
|
|
866
|
+
* omitted (the default), behaviour is BYTE-IDENTICAL to before this change:
|
|
867
|
+
* only the `session_id` match gates the delete. This keeps the ONE other
|
|
868
|
+
* external caller (`scripts/lib/autopilot/worktree-pipeline.mjs`, which does
|
|
869
|
+
* not pass `proof`) working unchanged — it stays on the weaker,
|
|
870
|
+
* session_id-only path.
|
|
871
|
+
*
|
|
872
|
+
* @param {{ sessionId: string, repoRoot?: string, proof?: { pid: number, host: string, startedAt: string } }} args
|
|
584
873
|
* @returns {{ ok: true, deleted: boolean, reason?: string, verified?: boolean }}
|
|
585
874
|
*/
|
|
586
|
-
export function release({ sessionId, repoRoot } = {}) {
|
|
875
|
+
export function release({ sessionId, repoRoot, proof } = {}) {
|
|
587
876
|
const lockFile = lockPathFor(repoRoot);
|
|
588
877
|
try {
|
|
589
878
|
const existing = readLock({ repoRoot });
|
|
@@ -596,6 +885,15 @@ export function release({ sessionId, repoRoot } = {}) {
|
|
|
596
885
|
return { ok: true, deleted: false, reason: 'session-mismatch' };
|
|
597
886
|
}
|
|
598
887
|
|
|
888
|
+
// Proof-gated release (additive, #906-class fix): when the caller supplies
|
|
889
|
+
// `proof`, the session_id match above is NOT sufficient by itself —
|
|
890
|
+
// session_id collisions are the documented root cause behind this check.
|
|
891
|
+
// Omitting `proof` leaves this branch dead code, preserving the exact
|
|
892
|
+
// pre-existing behaviour for callers that don't pass it.
|
|
893
|
+
if (proof !== undefined && !isLockOwnedByProof(existing, proof)) {
|
|
894
|
+
return { ok: true, deleted: false, reason: 'proof-mismatch' };
|
|
895
|
+
}
|
|
896
|
+
|
|
599
897
|
fs.unlinkSync(lockFile);
|
|
600
898
|
|
|
601
899
|
// Post-delete verify: only retry when OUR OWN lock is still observable.
|
|
@@ -82,9 +82,17 @@ export const REQUIRED_FIELDS = Object.freeze([
|
|
|
82
82
|
export const AGENT_SUMMARY_FIELDS = Object.freeze(['complete', 'partial', 'failed', 'spiral']);
|
|
83
83
|
|
|
84
84
|
/**
|
|
85
|
-
* Optional
|
|
86
|
-
*
|
|
87
|
-
*
|
|
85
|
+
* Optional fields — declared here so a field's status is STATED, never inferred
|
|
86
|
+
* from the presence of an `if` in the validator. Seeded by the remote-agent
|
|
87
|
+
* substrate (ADR-364 thin-slice) and grown additively since (#644, #724, #773,
|
|
88
|
+
* #964). These are NOT in REQUIRED_FIELDS — older entries lacking them validate
|
|
89
|
+
* cleanly. Validator: see `_validateOptionalFields` in validator.mjs.
|
|
90
|
+
*
|
|
91
|
+
* NOT YET EXHAUSTIVE. `_validateOptionalFields` additionally shape-checks
|
|
92
|
+
* `discovery_stats`, `review_stats`, `platform`, `branch`, `base_branch`,
|
|
93
|
+
* `notes`, `duration_seconds`, `issues_closed` and `issues_created` without
|
|
94
|
+
* listing them here. Treat membership as "declared optional", never absence as
|
|
95
|
+
* "not a known field" — see the #964 follow-up note in the session report.
|
|
88
96
|
*/
|
|
89
97
|
export const OPTIONAL_FIELDS = Object.freeze([
|
|
90
98
|
'agent_identity',
|
|
@@ -112,4 +120,15 @@ export const OPTIONAL_FIELDS = Object.freeze([
|
|
|
112
120
|
'open_questions_asked',
|
|
113
121
|
'open_questions_answered',
|
|
114
122
|
'open_questions_deferred',
|
|
123
|
+
// #964 — `effectiveness` was shape-checked by `_validateOptionalFields` while
|
|
124
|
+
// appearing in NEITHER list, so its status could only be inferred from an
|
|
125
|
+
// `if`. It is OPTIONAL on the write path and stays that way: making it
|
|
126
|
+
// required would retroactively invalidate the 10 existing records that lack
|
|
127
|
+
// it, plus every `abandoned` stub the SessionEnd backfill (#724 C1) will ever
|
|
128
|
+
// write. It is REQUIRED by the vault-mirror v1 renderer
|
|
129
|
+
// (`RENDERABLE_SESSION_FIELDS_V1`, scripts/lib/vault-mirror/render-sessions.mjs)
|
|
130
|
+
// — that is a strictly stronger, deliberately separate contract: "renderable
|
|
131
|
+
// into a note a human reads" ⊃ "schema-valid". A record missing it is a clean
|
|
132
|
+
// vault-mirror skip, NOT a malformed record.
|
|
133
|
+
'effectiveness',
|
|
115
134
|
]);
|
|
@@ -105,6 +105,22 @@ function _validateSchemaVersion(entry) {
|
|
|
105
105
|
}
|
|
106
106
|
}
|
|
107
107
|
|
|
108
|
+
/**
|
|
109
|
+
* Required-field gate for the WRITE path.
|
|
110
|
+
*
|
|
111
|
+
* #964 — the predicate is KEY PRESENCE (`field in entry`), deliberately NOT
|
|
112
|
+
* value presence. An explicit `{ total_waves: null }` passes here and is then
|
|
113
|
+
* rejected by the typed sub-validator below with a message that names the type
|
|
114
|
+
* violation, which is the more useful diagnostic. The vault-mirror renderers
|
|
115
|
+
* use the OTHER predicate (`entry[field] === null || undefined`) because a null
|
|
116
|
+
* value is unrenderable even though it is well-formed. The two predicates agree
|
|
117
|
+
* on every live record but are not the same test — do not "unify" them without
|
|
118
|
+
* deciding which failure each side is supposed to report.
|
|
119
|
+
*
|
|
120
|
+
* The renderers' field LISTS are likewise a separate, strictly stronger
|
|
121
|
+
* contract; the superset relationship between them is enforced mechanically in
|
|
122
|
+
* tests/lib/vault-mirror/render-sessions.test.mjs, not by prose here.
|
|
123
|
+
*/
|
|
108
124
|
function _validateRequiredFields(entry) {
|
|
109
125
|
for (const field of REQUIRED_FIELDS) {
|
|
110
126
|
if (!(field in entry)) {
|