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.
Files changed (187) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.codex-plugin/plugin.json +1 -1
  4. package/.cursor/rules/030-wave-execution.mdc +17 -1
  5. package/CHANGELOG.md +105 -412
  6. package/README.md +12 -9
  7. package/SECURITY.md +190 -27
  8. package/agents/AGENTS.md +20 -3
  9. package/agents/code-implementer.md +6 -6
  10. package/agents/db-specialist.md +1 -1
  11. package/agents/qa-strategist.md +31 -6
  12. package/agents/schemas/qa-strategist.schema.json +27 -0
  13. package/agents/schemas/test-writer.schema.json +60 -2
  14. package/agents/security-reviewer.md +1 -1
  15. package/agents/session-reviewer.md +1 -1
  16. package/agents/test-writer.md +29 -10
  17. package/agents/ui-developer.md +1 -1
  18. package/commands/contract-version-bump.md +28 -0
  19. package/commands/portfolio.md +1 -1
  20. package/docs/USER-GUIDE.md +8 -3
  21. package/docs/ci-setup.md +121 -7
  22. package/docs/codex-setup.md +1 -1
  23. package/docs/components.md +6 -6
  24. package/docs/cursor-setup.md +22 -9
  25. package/docs/events-schema.md +5 -1
  26. package/docs/instruction-delivery.md +444 -0
  27. package/docs/rule-authoring.md +58 -9
  28. package/docs/session-config-reference.md +244 -9
  29. package/docs/session-config-template.md +39 -3
  30. package/hooks/_lib/guard-source-loader.mjs +467 -0
  31. package/hooks/_lib/lock-bootstrap.mjs +21 -0
  32. package/hooks/_lib/vcs-create-matcher.mjs +119 -0
  33. package/hooks/config-protection.mjs +0 -0
  34. package/hooks/enforce-commands.mjs +10 -2
  35. package/hooks/hooks-codex.json +1 -1
  36. package/hooks/hooks-cursor.json +11 -2
  37. package/hooks/hooks-pi.json +10 -0
  38. package/hooks/hooks.json +21 -1
  39. package/hooks/on-session-end.mjs +178 -18
  40. package/hooks/on-session-start.mjs +23 -0
  41. package/hooks/post-bash-write-verify.mjs +977 -0
  42. package/hooks/post-subagent-discovery-validator.mjs +256 -41
  43. package/hooks/pre-bash-destructive-guard.mjs +525 -160
  44. package/hooks/pre-bash-issue-budget.mjs +167 -0
  45. package/hooks/pre-bash-sessions-ledger-guard.mjs +627 -0
  46. package/hooks/pre-bash-templates-first.mjs +96 -63
  47. package/hooks/subagent-telemetry.mjs +527 -37
  48. package/package.json +5 -2
  49. package/pi/prompts/contract-version-bump.md +12 -0
  50. package/rules/README.md +32 -0
  51. package/scripts/archive-closed-prds.mjs +12 -22
  52. package/scripts/autopilot-multi.mjs +103 -20
  53. package/scripts/backfill-abandoned-sessions.mjs +160 -4
  54. package/scripts/check-doc-consistency.sh +17 -1
  55. package/scripts/eval-session.mjs +50 -9
  56. package/scripts/fleet-instruction-scan.mjs +141 -0
  57. package/scripts/lib/autopilot/mr-draft.mjs +31 -1
  58. package/scripts/lib/autopilot/worktree-pipeline.mjs +113 -5
  59. package/scripts/lib/backlog-scan.mjs +39 -6
  60. package/scripts/lib/blocked-commands-policy.mjs +340 -0
  61. package/scripts/lib/ci-status-banner.mjs +75 -12
  62. package/scripts/lib/claude-md-budget-lint.mjs +283 -34
  63. package/scripts/lib/command-blocker.mjs +1013 -58
  64. package/scripts/lib/config/config-protection.mjs +2 -1
  65. package/scripts/lib/config/drift-check.mjs +9 -1
  66. package/scripts/lib/config/gitlab-portfolio.mjs +1 -1
  67. package/scripts/lib/config/issue-budget.mjs +123 -0
  68. package/scripts/lib/config/reconcile.mjs +21 -0
  69. package/scripts/lib/config/section-extractor.mjs +121 -1
  70. package/scripts/lib/config-schema.mjs +23 -3
  71. package/scripts/lib/config.mjs +17 -0
  72. package/scripts/lib/convergence-monitor.mjs +49 -3
  73. package/scripts/lib/description-surface.mjs +535 -0
  74. package/scripts/lib/dispatcher/enumerate.mjs +26 -40
  75. package/scripts/lib/ecosystem-wizard/config-writer.mjs +26 -24
  76. package/scripts/lib/ecosystem-wizard/wizard-prompt.mjs +1 -1
  77. package/scripts/lib/eval/engine.mjs +47 -5
  78. package/scripts/lib/events.mjs +59 -7
  79. package/scripts/lib/gates/gate-full.mjs +15 -3
  80. package/scripts/lib/gates/gate-helpers.mjs +132 -6
  81. package/scripts/lib/gitlab-ops/stale-mr-sweep.mjs +28 -8
  82. package/scripts/lib/gitlab-portfolio/aggregator.mjs +8 -2
  83. package/scripts/lib/gitlab-portfolio/cli.mjs +1 -1
  84. package/scripts/lib/handover-gate.mjs +7 -3
  85. package/scripts/lib/harness-audit/categories/category4.mjs +9 -3
  86. package/scripts/lib/instruction-budget-guard.mjs +402 -51
  87. package/scripts/lib/io.mjs +345 -10
  88. package/scripts/lib/issue-budget.mjs +269 -0
  89. package/scripts/lib/issue-close-strip-labels.mjs +39 -9
  90. package/scripts/lib/label-scope.mjs +47 -0
  91. package/scripts/lib/learnings/schema.mjs +43 -3
  92. package/scripts/lib/lock-reaper.mjs +1 -2
  93. package/scripts/lib/memory-proposals/schema.mjs +36 -1
  94. package/scripts/lib/peer-discovery.mjs +645 -0
  95. package/scripts/lib/pi-hook-bridge.mjs +146 -17
  96. package/scripts/lib/product-repo-detect.mjs +9 -8
  97. package/scripts/lib/project-hygiene.mjs +432 -0
  98. package/scripts/lib/quality-gate.mjs +167 -0
  99. package/scripts/lib/recommendations-v0.mjs +1 -1
  100. package/scripts/lib/reconcile/eligibility.mjs +1 -1
  101. package/scripts/lib/reconcile/emitter.mjs +23 -4
  102. package/scripts/lib/reconcile/engine.mjs +147 -39
  103. package/scripts/lib/reconcile/idempotency.mjs +114 -14
  104. package/scripts/lib/reconcile-nudge-banner.mjs +65 -9
  105. package/scripts/lib/resource-probe/evaluate.mjs +70 -4
  106. package/scripts/lib/resource-probe.mjs +19 -0
  107. package/scripts/lib/rule-loader.mjs +6 -0
  108. package/scripts/lib/scope-baseline.mjs +564 -0
  109. package/scripts/lib/scope-gate.mjs +399 -98
  110. package/scripts/lib/session-close-backfill.mjs +61 -6
  111. package/scripts/lib/session-end/phase-skip.mjs +1 -0
  112. package/scripts/lib/session-id.mjs +221 -41
  113. package/scripts/lib/session-lock.mjs +304 -6
  114. package/scripts/lib/session-schema/constants.mjs +22 -3
  115. package/scripts/lib/session-schema/validator.mjs +16 -0
  116. package/scripts/lib/sessions-integrity-banner.mjs +294 -0
  117. package/scripts/lib/sessions-staleness-banner.mjs +121 -12
  118. package/scripts/lib/skill-evolution/idempotency.mjs +135 -16
  119. package/scripts/lib/skill-evolution/mr-opener.mjs +9 -1
  120. package/scripts/lib/spiral-carryover.mjs +142 -30
  121. package/scripts/lib/state-md/mission-status.mjs +53 -3
  122. package/scripts/lib/subagents-schema.mjs +43 -9
  123. package/scripts/lib/test-runner/issue-reconcile.mjs +53 -13
  124. package/scripts/lib/tests-src-ratio.mjs +484 -0
  125. package/scripts/lib/validate/check-agents.mjs +56 -0
  126. package/scripts/lib/validate/check-hooks-symmetry.mjs +244 -10
  127. package/scripts/lib/validate/check-rules.mjs +217 -35
  128. package/scripts/lib/validate/check-test-value-bans.mjs +782 -0
  129. package/scripts/lib/validate/check-unicode-safety.mjs +1 -0
  130. package/scripts/lib/validate-vendored-rules.mjs +10 -2
  131. package/scripts/lib/vault-archive.mjs +17 -2
  132. package/scripts/lib/vault-backfill/glab.mjs +8 -0
  133. package/scripts/lib/vault-mirror/process.mjs +30 -0
  134. package/scripts/lib/vault-mirror/render-sessions.mjs +293 -36
  135. package/scripts/lib/vcs-repo-spec.mjs +362 -0
  136. package/scripts/lib/wave-resource-gate.mjs +115 -11
  137. package/scripts/lib/worktree/listing.mjs +44 -7
  138. package/scripts/mcp-server.sh +17 -3
  139. package/scripts/measure-context-overhead.sh +151 -0
  140. package/scripts/memory-propose.mjs +72 -9
  141. package/scripts/print-applicable-rules.mjs +51 -12
  142. package/scripts/release.mjs +534 -0
  143. package/scripts/run-quality-gate.mjs +123 -5
  144. package/scripts/validate-wave-scope.mjs +182 -17
  145. package/scripts/vault-integration-watcher.mjs +32 -10
  146. package/skills/_shared/config-reading.md +2 -2
  147. package/skills/bootstrap/fast-template.md +1 -1
  148. package/skills/claude-md-drift-check/checker.mjs +145 -28
  149. package/skills/contract-version-bump/SKILL.md +219 -0
  150. package/skills/discovery/SKILL.md +4 -4
  151. package/skills/discovery/issue-templates.md +11 -11
  152. package/skills/discovery/probes-audit.md +1 -1
  153. package/skills/discovery/probes-feature.md +1 -1
  154. package/skills/discovery/probes-session.md +26 -5
  155. package/skills/ecosystem-health/SKILL.md +1 -1
  156. package/skills/ecosystem-health/wizard.md +4 -4
  157. package/skills/evolve/SKILL.md +1 -0
  158. package/skills/gitlab-ops/SKILL.md +20 -12
  159. package/skills/gitlab-portfolio/SKILL.md +2 -2
  160. package/skills/hook-development/SKILL.md +1 -1
  161. package/skills/mode-selector/SKILL.md +1 -1
  162. package/skills/npm-publish/SKILL.md +17 -1
  163. package/skills/plan/SKILL.md +5 -5
  164. package/skills/plan/mode-feature.md +4 -4
  165. package/skills/plan/mode-new.md +10 -10
  166. package/skills/plan/mode-retro.md +1 -1
  167. package/skills/quality-gates/SKILL.md +1 -1
  168. package/skills/reconcile/SKILL.md +21 -4
  169. package/skills/session-end/SKILL.md +34 -13
  170. package/skills/session-end/discovery-scan.md +4 -2
  171. package/skills/session-end/drift-operations.md +4 -4
  172. package/skills/session-end/metrics-collection.md +13 -0
  173. package/skills/session-end/phase-3-2-docs-verification.md +1 -1
  174. package/skills/session-end/phase-3-6-tail.md +2 -1
  175. package/skills/session-end/plan-verification.md +5 -2
  176. package/skills/session-end/vault-operations.md +1 -1
  177. package/skills/session-end/verification-checklist.md +1 -1
  178. package/skills/session-plan/SKILL.md +6 -2
  179. package/skills/session-plan/wave-template.md +2 -0
  180. package/skills/session-start/SKILL.md +73 -7
  181. package/skills/session-start/phase-4-5-resource-health.md +15 -2
  182. package/skills/test-runner/SKILL.md +2 -2
  183. package/skills/vault-sync/validator.mjs +108 -7
  184. package/skills/wave-executor/SKILL.md +5 -2
  185. package/skills/wave-executor/circuit-breaker.md +2 -0
  186. package/skills/wave-executor/wave-loop.md +163 -10
  187. 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
- const raw = fs.readFileSync(lockFile, 'utf8');
325
- return parseLock(raw);
326
- } catch {
327
- return null;
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
- * @param {{ sessionId: string, repoRoot?: string }} args
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 additive fields introduced for the remote-agent substrate (ADR-364 thin-slice).
86
- * These are NOT in REQUIRED_FIELDS older entries lacking them validate cleanly.
87
- * Validator: see `_validateOptionalFields` in validator.mjs.
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)) {