session-orchestrator 5.3.0 → 5.5.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 (260) hide show
  1. package/.agents/skills/claude-md-drift-check/SKILL.md +1 -1
  2. package/.agents/skills/test-audit/SKILL.md +21 -0
  3. package/.agents/skills/test-audit/agents/openai.yaml +5 -0
  4. package/.claude-plugin/marketplace.json +1 -1
  5. package/.claude-plugin/plugin.json +1 -1
  6. package/.codex-plugin/plugin.json +1 -1
  7. package/.codex-plugin/skills/claude-md-drift-check/SKILL.md +1 -1
  8. package/.codex-plugin/skills/test-audit/SKILL.md +21 -0
  9. package/.codex-plugin/skills/test-audit/agents/openai.yaml +5 -0
  10. package/.cursor/commands/test-audit.md +14 -0
  11. package/.cursor/rules/060-evolve.mdc +28 -13
  12. package/.cursor/skills/claude-md-drift-check/SKILL.md +1 -1
  13. package/.cursor/skills/test-audit/SKILL.md +12 -0
  14. package/.cursor-plugin/plugin.json +1 -1
  15. package/.orchestrator/policy/blocked-commands.json +1 -1
  16. package/AGENTS.md +9 -31
  17. package/CHANGELOG.md +162 -0
  18. package/README.md +14 -10
  19. package/docs/agent-authoring.md +2 -2
  20. package/docs/baseline.md +10 -1
  21. package/docs/ci-setup.md +154 -4
  22. package/docs/components.md +11 -11
  23. package/docs/events-schema.md +25 -19
  24. package/docs/install.md +14 -2
  25. package/docs/migration-v4.md +24 -17
  26. package/docs/persona-panel.md +5 -4
  27. package/docs/plugin-architecture-v3.md +1 -1
  28. package/docs/rule-authoring.md +27 -12
  29. package/docs/scope-collision-guard.md +40 -16
  30. package/docs/session-config-reference.md +61 -12
  31. package/docs/session-config-template.md +11 -2
  32. package/docs/vault-docs-architecture.md +8 -4
  33. package/hooks/_lib/hook-import-set.json +42 -29
  34. package/hooks/_lib/lock-bootstrap.mjs +118 -26
  35. package/hooks/config-protection.mjs +1 -1
  36. package/hooks/enforce-commands.mjs +28 -13
  37. package/hooks/enforce-scope.mjs +201 -23
  38. package/hooks/hooks-codex.json +1 -1
  39. package/hooks/hooks-cursor.json +5 -0
  40. package/hooks/hooks-pi.json +5 -0
  41. package/hooks/hooks.json +6 -1
  42. package/hooks/on-session-start.mjs +57 -2
  43. package/hooks/on-stop.mjs +70 -168
  44. package/hooks/post-bash-write-verify.mjs +98 -17
  45. package/hooks/post-edit-validate.mjs +15 -5
  46. package/hooks/post-tool-batch-wave-signal.mjs +32 -152
  47. package/hooks/pre-bash-memory-propose-audit.mjs +71 -10
  48. package/hooks/pre-bash-sessions-ledger-guard.mjs +28 -2
  49. package/hooks/pre-bash-staging-fence.mjs +29 -8
  50. package/hooks/pre-edit-sessions-ledger-guard.mjs +162 -0
  51. package/hooks/pre-task-scope-disjoint.mjs +1143 -279
  52. package/hooks/subagent-telemetry.mjs +96 -19
  53. package/hooks/wave-scope-commit-guard.mjs +168 -72
  54. package/package.json +2 -6
  55. package/pi/prompts/test-audit.md +12 -0
  56. package/rules/README.md +6 -1
  57. package/rules/_index.md +3 -3
  58. package/rules/always-on/bash-harness-pitfalls.md +7 -1
  59. package/rules/opt-in-domain/prompt-caching.md +5 -1
  60. package/rules/opt-in-stack/backend-data.md +9 -1
  61. package/rules/opt-in-stack/backend.md +7 -1
  62. package/rules/opt-in-stack/frontend.md +6 -1
  63. package/rules/opt-in-stack/security-web.md +7 -1
  64. package/rules/opt-in-stack/swift.md +5 -1
  65. package/scripts/apply-session-learnings.mjs +513 -0
  66. package/scripts/archive-closed-prds.mjs +40 -12
  67. package/scripts/autopilot.mjs +57 -54
  68. package/scripts/backfill-abandoned-sessions.mjs +81 -6
  69. package/scripts/backfill-learnings-from-vault.mjs +49 -22
  70. package/scripts/baseline-archetypes.mjs +1 -1
  71. package/scripts/check-package-manager.mjs +62 -5
  72. package/scripts/codex-install.mjs +3 -0
  73. package/scripts/cursor-install.mjs +47 -6
  74. package/scripts/emit-session.mjs +298 -2
  75. package/scripts/export-hw-learnings.mjs +40 -10
  76. package/scripts/fleet-checkin.mjs +170 -0
  77. package/scripts/lib/agent-frontmatter.mjs +3 -3
  78. package/scripts/lib/auto-dream.mjs +7 -6
  79. package/scripts/lib/autopilot/flags.mjs +6 -1
  80. package/scripts/lib/autopilot/loop.mjs +32 -13
  81. package/scripts/lib/autopilot/stall-sampler.mjs +13 -9
  82. package/scripts/lib/baseline-archetypes.mjs +41 -22
  83. package/scripts/lib/blocked-commands-policy.mjs +40 -0
  84. package/scripts/lib/common.mjs +41 -0
  85. package/scripts/lib/config/host-paths.mjs +79 -0
  86. package/scripts/lib/config/persona-gate-wave.mjs +3 -3
  87. package/scripts/lib/config/reaper.mjs +7 -6
  88. package/scripts/lib/config/vault-integration.mjs +151 -16
  89. package/scripts/lib/config-schema.mjs +13 -0
  90. package/scripts/lib/config.mjs +75 -10
  91. package/scripts/lib/eval/engine.mjs +13 -0
  92. package/scripts/lib/events-retention-banner.mjs +393 -0
  93. package/scripts/lib/events-rotation.mjs +21 -6
  94. package/scripts/lib/events.mjs +72 -5
  95. package/scripts/lib/fleet-protocol.mjs +288 -0
  96. package/scripts/lib/gates/gate-baseline.mjs +13 -3
  97. package/scripts/lib/gates/gate-full.mjs +7 -2
  98. package/scripts/lib/gates/gate-helpers.mjs +93 -2
  99. package/scripts/lib/gates/gate-incremental.mjs +23 -22
  100. package/scripts/lib/gates/gate-per-file.mjs +16 -7
  101. package/scripts/lib/hardening.mjs +1 -0
  102. package/scripts/lib/harness-audit/categories/category4.mjs +8 -6
  103. package/scripts/lib/instruction-budget-guard.mjs +77 -11
  104. package/scripts/lib/learnings/expiry-sweep.mjs +420 -22
  105. package/scripts/lib/learnings/io.mjs +274 -37
  106. package/scripts/lib/learnings/schema.mjs +163 -15
  107. package/scripts/lib/learnings/sizing-subject.mjs +2 -2
  108. package/scripts/lib/lock-reaper.mjs +38 -14
  109. package/scripts/lib/maintenance-due-banner.mjs +23 -8
  110. package/scripts/lib/memory-proposals/schema.mjs +11 -5
  111. package/scripts/lib/named-baseline-resolver.mjs +53 -28
  112. package/scripts/lib/named-vault-resolver.mjs +147 -5
  113. package/scripts/lib/orphan-reaper/defaults.mjs +41 -0
  114. package/scripts/lib/orphan-reaper/no-follow-append.mjs +87 -0
  115. package/scripts/lib/orphan-reaper/ps-snapshot.mjs +181 -0
  116. package/scripts/lib/orphan-reaper/reaper-audit.mjs +323 -0
  117. package/scripts/lib/orphan-reaper/reaper-decide.mjs +395 -0
  118. package/scripts/lib/orphan-reaper/scan-throttle.mjs +144 -0
  119. package/scripts/lib/orphan-reaper/scan.mjs +510 -0
  120. package/scripts/lib/orphan-reaper/trigger.mjs +173 -0
  121. package/scripts/lib/orphan-reaper.mjs +47 -1315
  122. package/scripts/lib/owner-yaml.mjs +104 -6
  123. package/scripts/lib/persona-panel/catalog-loader.mjs +1 -1
  124. package/scripts/lib/platform.mjs +206 -2
  125. package/scripts/lib/plugin-update-banner.mjs +3 -1
  126. package/scripts/lib/process-group.mjs +134 -22
  127. package/scripts/lib/project-hygiene.mjs +21 -1
  128. package/scripts/lib/quality-gate.mjs +45 -5
  129. package/scripts/lib/resource-probe.mjs +1 -1
  130. package/scripts/lib/rule-loader.mjs +100 -62
  131. package/scripts/lib/rules-sync.mjs +27 -12
  132. package/scripts/lib/scope-baseline.mjs +119 -29
  133. package/scripts/lib/scope-echo.mjs +7 -0
  134. package/scripts/lib/scope-gate.mjs +194 -27
  135. package/scripts/lib/session-close-backfill.mjs +129 -4
  136. package/scripts/lib/session-end/tail-runner.mjs +10 -5
  137. package/scripts/lib/session-lock-shape.mjs +6 -3
  138. package/scripts/lib/session-lock.mjs +320 -67
  139. package/scripts/lib/session-record-repair.mjs +183 -26
  140. package/scripts/lib/session-registry.mjs +67 -17
  141. package/scripts/lib/session-schema/constants.mjs +30 -4
  142. package/scripts/lib/session-schema/validator.mjs +28 -1
  143. package/scripts/lib/session-start-probes.mjs +242 -68
  144. package/scripts/lib/session-token-rollup.mjs +252 -54
  145. package/scripts/lib/sessions-canonical.mjs +100 -9
  146. package/scripts/lib/sessions-staleness-banner.mjs +3 -1
  147. package/scripts/lib/sh-quote.mjs +29 -0
  148. package/scripts/lib/subagents-schema.mjs +30 -3
  149. package/scripts/lib/tail-window.mjs +27 -3
  150. package/scripts/lib/telemetry/pricing.mjs +121 -71
  151. package/scripts/lib/telemetry/sync.mjs +17 -0
  152. package/scripts/lib/tests-src-ratio.mjs +215 -23
  153. package/scripts/lib/tmux-layout/layouts.mjs +1 -11
  154. package/scripts/lib/tmux-layout/telemetry-stats.mjs +12 -0
  155. package/scripts/lib/tmux-layout/tmux-shell.mjs +2 -11
  156. package/scripts/lib/ux-grill/reconcile.mjs +1 -1
  157. package/scripts/lib/validate/check-agents.mjs +1 -1
  158. package/scripts/lib/validate/check-hooks-symmetry.mjs +5 -0
  159. package/scripts/lib/validate/check-learnings-shell-writes.mjs +214 -0
  160. package/scripts/lib/validate/check-test-value-bans.mjs +388 -27
  161. package/scripts/lib/validate/check-unicode-safety.mjs +1 -0
  162. package/scripts/lib/validate/check-unwired-features.mjs +1 -1
  163. package/scripts/lib/validate/check-vcs-repo-flag.mjs +1 -1
  164. package/scripts/lib/validate-vendored-rules.mjs +206 -81
  165. package/scripts/lib/vault-mirror/auto-commit.mjs +5 -3
  166. package/scripts/lib/vault-mirror/namespace.mjs +47 -26
  167. package/scripts/lib/vault-mirror/process.mjs +350 -62
  168. package/scripts/lib/vault-mirror/pseudonym-map.mjs +14 -5
  169. package/scripts/lib/vault-mirror/render-learnings.mjs +66 -3
  170. package/scripts/lib/vault-mirror/render-sessions.mjs +28 -4
  171. package/scripts/lib/vault-mirror/telemetry.mjs +25 -11
  172. package/scripts/lib/vault-status/board-writer.mjs +53 -12
  173. package/scripts/lib/vault-status/narrative-mirror.mjs +40 -9
  174. package/scripts/lib/vcs-repo-spec.mjs +8 -0
  175. package/scripts/lib/wave-executor/dispatch-common.mjs +10 -18
  176. package/scripts/lib/wave-executor/foreign-dispatch.mjs +1 -1
  177. package/scripts/lib/wave-executor/remote-dispatch.mjs +1 -1
  178. package/scripts/lib/wave-resource-gate.mjs +34 -8
  179. package/scripts/materialize-wave-scope.mjs +7 -4
  180. package/scripts/memory-propose.mjs +18 -5
  181. package/scripts/release-session-lock.mjs +2 -1
  182. package/scripts/release.mjs +11 -3
  183. package/scripts/repair-invalid-sessions.mjs +37 -4
  184. package/scripts/run-migrate-v2-cross-repo.mjs +171 -31
  185. package/scripts/run-quality-gate.mjs +331 -307
  186. package/scripts/self-update.mjs +229 -0
  187. package/scripts/site-numbers.mjs +163 -5
  188. package/scripts/sweep-expired-learnings.mjs +603 -20
  189. package/scripts/sweep-expired-rules.mjs +2 -1
  190. package/scripts/validate-plugin.mjs +7 -0
  191. package/scripts/validate-wave-scope.mjs +21 -2
  192. package/scripts/vault-backfill.mjs +50 -37
  193. package/scripts/vault-integration-watcher.mjs +2 -1
  194. package/scripts/vault-mirror.mjs +238 -75
  195. package/scripts/wave-scope-binding.mjs +2 -1
  196. package/skills/_shared/config-reading.md +3 -3
  197. package/skills/_shared/fleet-protocol.md +134 -0
  198. package/skills/_shared/state-ownership.md +41 -12
  199. package/skills/autopilot/SKILL.md +25 -11
  200. package/skills/bootstrap/SKILL.md +6 -4
  201. package/skills/bootstrap/_shared-template.md +97 -2
  202. package/skills/bootstrap/fast-template.md +4 -1
  203. package/skills/bootstrap/private-contract.md +2 -1
  204. package/skills/bootstrap/public-fallback.md +6 -3
  205. package/skills/bootstrap/references/bootstrap-refresh-lock-flow.md +5 -3
  206. package/skills/bootstrap/references/bootstrap-retroactive-flow.md +2 -1
  207. package/skills/bootstrap/standard-template.md +2 -0
  208. package/skills/claude-md-drift-check/SKILL.md +10 -10
  209. package/skills/claude-md-drift-check/checker.mjs +26 -18
  210. package/skills/discovery/probes/ssot-code-diff.mjs +12 -6
  211. package/skills/discovery/probes/vault-narrative-staleness.mjs +6 -2
  212. package/skills/discovery/probes/vault-staleness.mjs +9 -3
  213. package/skills/docs-orchestrator/SKILL.md +12 -5
  214. package/skills/docs-orchestrator/audience-mapping.md +5 -3
  215. package/skills/evolve/SKILL.md +13 -4
  216. package/skills/evolve/references/evolve-analyze-mode.md +42 -18
  217. package/skills/memory-cleanup/SKILL.md +1 -1
  218. package/skills/persona-panel/SKILL.md +2 -2
  219. package/skills/persona-panel/persona-format.md +2 -2
  220. package/skills/persona-panel/presets/designer-lens.md +1 -1
  221. package/skills/persona-panel/presets/engineer-lens.md +1 -1
  222. package/skills/persona-panel/presets/pm-lens.md +1 -1
  223. package/skills/plan/mode-retro.md +19 -4
  224. package/skills/quality-gates/SKILL.md +6 -4
  225. package/skills/remote-offload/SKILL.md +4 -2
  226. package/skills/session-end/learning-patterns.md +23 -13
  227. package/skills/session-end/metrics-collection.md +6 -7
  228. package/skills/session-end/phase-3-2-docs-verification.md +7 -6
  229. package/skills/session-end/plan-verification.md +25 -9
  230. package/skills/session-end/references/phase-3-documentation-updates.md +13 -3
  231. package/skills/session-end/references/phase-4a-worktree-cleanup.md +2 -0
  232. package/skills/session-end/references/session-summary-template.md +1 -1
  233. package/skills/session-end/session-metrics-write.md +65 -68
  234. package/skills/session-plan/SKILL.md +0 -3
  235. package/skills/session-start/SKILL.md +8 -2
  236. package/skills/session-start/references/operations-contract.md +6 -0
  237. package/skills/session-start/references/phase-1-2-session-lock.md +32 -7
  238. package/skills/session-start/references/phase-1-7-vault-status-board.md +1 -1
  239. package/skills/session-start/references/phase-4-ssot-environment-check.md +10 -3
  240. package/skills/test-audit/SKILL.md +152 -0
  241. package/skills/test-audit/references/campaign.md +111 -0
  242. package/skills/test-audit/references/mutate.sh +50 -0
  243. package/skills/vault-mirror/SKILL.md +21 -11
  244. package/skills/wave-executor/SKILL.md +2 -2
  245. package/skills/wave-executor/circuit-breaker.md +1 -1
  246. package/skills/wave-executor/references/wave-executor-state-init.md +3 -0
  247. package/skills/wave-executor/references/wave-loop-dispatch.md +11 -1
  248. package/skills/wave-executor/references/wave-loop-review.md +4 -6
  249. package/skills/wave-executor/references/wave-loop-scope-manifest.md +13 -5
  250. package/templates/personas/README.md +5 -5
  251. package/templates/personas/accounting-compliance.v1.md +1 -1
  252. package/templates/personas/accounting-tax-advisor.v1.md +1 -1
  253. package/templates/personas/buyer-p1-cto.v1.md +1 -1
  254. package/templates/personas/buyer-p2-kanzlei.v1.md +1 -1
  255. package/templates/personas/buyer-p3-build.v1.md +1 -1
  256. package/templates/personas/buyer-p4-tech-veto.v1.md +1 -1
  257. package/templates/personas/buyer-p5-solo.v1.md +1 -1
  258. package/templates/personas/buyer-p6-ld.v1.md +1 -1
  259. package/templates/personas/klima-ai-expert.v1.md +1 -1
  260. package/templates/personas/klima-physicist.v1.md +1 -1
@@ -25,11 +25,11 @@
25
25
  * ttl_hours: number,
26
26
  * }
27
27
  *
28
- * The current scripts/lib/session-lock.mjs (pre-I3) writes the v1 shape
29
- * (no last_heartbeat, no semantic_session_id). This helper layers v2 fields
30
- * on top via an atomic tmp+rename overwrite — when I3 ships its v2 schema,
31
- * this helper's overlay becomes a no-op (the field is already there) and
32
- * everything continues to work.
28
+ * scripts/lib/session-lock.mjs `buildLock()` has written `last_heartbeat`
29
+ * itself since Epic #583 W2-I3 (`last_heartbeat` = `started_at` at genesis).
30
+ * This helper's tmp+rename overlay therefore re-writes an identical
31
+ * `last_heartbeat`, and its remaining job is `semantic_session_id`, which
32
+ * `acquire()` / `forceAcquire()` are not given here.
33
33
  *
34
34
  * @module hooks/_lib/lock-bootstrap
35
35
  */
@@ -55,6 +55,10 @@ import { writeJsonAtomicSync } from '../../scripts/lib/io.mjs';
55
55
  */
56
56
  export const SAME_LOGICAL_SESSION_SOURCES = new Set(['resume', 'clear', 'compact']);
57
57
 
58
+ /** Re-reads of a just-acquired lock that answers ENOENT, and the pause between them (#1494). */
59
+ const ENRICH_READ_RETRIES = 5;
60
+ const ENRICH_READ_RETRY_MS = 10;
61
+
58
62
  /**
59
63
  * Bootstrap the session.lock for this hook invocation.
60
64
  *
@@ -189,6 +193,13 @@ export async function bootstrapLock({
189
193
  // session-lock.mjs; matching only the new one keeps this force-branch
190
194
  // reachable.
191
195
  acquireResult.reason === 'stale-heartbeat' ||
196
+ // A lock file that is not a lock record (invalid JSON or not the lock
197
+ // shape) can never be a live lock — every writer writes atomically — so
198
+ // it is reclaimed like a stale one, with one stderr WARN below. Before
199
+ // acquire() reported 'corrupt' it answered 'active' / existingLock:null,
200
+ // which this predicate could never force: one damaged file wedged every
201
+ // new session in the repo.
202
+ acquireResult.reason === 'corrupt' ||
192
203
  (acquireResult.reason === 'active' &&
193
204
  acquireResult.existingLock &&
194
205
  (acquireResult.existingLock.session_id === sessionId ||
@@ -201,12 +212,41 @@ export async function bootstrapLock({
201
212
  acquireResult.existingLock.session_id === predecessorSessionId)))
202
213
  );
203
214
 
215
+ // Recorded on the acquired event below, so a takeover of a stale or corrupt
216
+ // lock is countable — its stderr WARN is discarded by the harness (#1401).
217
+ let reclaimed = null;
204
218
  if (!acquireResult.ok && shouldForce) {
219
+ const reclaimingCorrupt = acquireResult.reason === 'corrupt';
220
+ const forcedFrom = acquireResult.reason;
221
+ // A session resuming its OWN stale lock (same raw id after > ttl idle) is
222
+ // a refresh, not a takeover — counting it would inflate the very figure
223
+ // `reclaimed` exists to make countable.
224
+ const priorOwner = acquireResult.existingLock?.session_id;
205
225
  try {
206
- acquireResult = forceAcquireFn({ sessionId, mode, ttlHours, repoRoot });
226
+ // A stale or corrupt lock is reclaimed single-winner (#1494): two starts
227
+ // that both saw it used to force it, both got ok:true, and the later
228
+ // rename won. With onlyIfNotLive the loser gets the winner's live lock
229
+ // back as 'active' and bails into the conflict signal below. The same-
230
+ // session refresh of a LIVE lock keeps the unconditional overwrite.
231
+ const reclaim = forcedFrom === 'stale-heartbeat' || forcedFrom === 'corrupt';
232
+ acquireResult = forceAcquireFn({ sessionId, mode, ttlHours, repoRoot, ...(reclaim ? { onlyIfNotLive: true } : {}) });
207
233
  } catch {
208
234
  return null;
209
235
  }
236
+ if (
237
+ acquireResult?.ok === true &&
238
+ (forcedFrom === 'corrupt' || (forcedFrom === 'stale-heartbeat' && priorOwner !== sessionId))
239
+ ) {
240
+ reclaimed = forcedFrom;
241
+ }
242
+ if (reclaimingCorrupt) {
243
+ // The file's content is not echoed: it is unbounded and may be anything.
244
+ process.stderr.write(
245
+ acquireResult && acquireResult.ok === true
246
+ ? '⚠ lock-bootstrap: .orchestrator/session.lock was not a valid lock record — reclaimed for this session\n'
247
+ : `⚠ lock-bootstrap: .orchestrator/session.lock is not a valid lock record and could not be reclaimed (${acquireResult?.reason ?? 'unknown'}) — this session runs without a lock\n`,
248
+ );
249
+ }
210
250
  }
211
251
 
212
252
  // Any other non-ok reason (parallel-conflict, fs-error, other-session-active)
@@ -221,6 +261,20 @@ export async function bootstrapLock({
221
261
  // failure is swallowed and the bail proceeds. The return contract is unchanged —
222
262
  // bootstrapLock STILL returns null on this path.
223
263
  if (!acquireResult || acquireResult.ok !== true) {
264
+ // #1494 / #599: a lock path that exists but cannot be read (EACCES, or a
265
+ // directory) is never reclaimed — but it is no longer silent either. The
266
+ // WARN is discarded by the harness, so the read_anomaly event (the one
267
+ // SessionEnd already emits for the same state) is the countable trace.
268
+ if (acquireResult && acquireResult.reason === 'unreadable') {
269
+ process.stderr.write(
270
+ `⚠ lock-bootstrap: .orchestrator/session.lock exists but cannot be read (${acquireResult.error ?? 'unknown'}) — this session runs without a lock\n`,
271
+ );
272
+ await emitBestEffort(_emitEventImpl, 'orchestrator.session.lock.read_anomaly', {
273
+ session_id: sessionId,
274
+ status: 'unreadable',
275
+ ...(typeof acquireResult.error === 'string' ? { error: acquireResult.error } : {}),
276
+ });
277
+ }
224
278
  if (
225
279
  acquireResult &&
226
280
  acquireResult.reason === 'active' &&
@@ -236,19 +290,46 @@ export async function bootstrapLock({
236
290
 
237
291
  // Step 2: enrich the lock with v2 fields (last_heartbeat + semantic_session_id).
238
292
  // We re-read the file fresh (acquire() just wrote it) and overlay the new
239
- // fields, then atomically tmp+rename. When I3 lands and acquire() writes the
240
- // v2 shape natively, this overlay becomes idempotent (already-present fields
241
- // get overwritten with identical values).
293
+ // fields, then atomically tmp+rename. acquire() already writes
294
+ // last_heartbeat (Epic #583 W2-I3), so that half of the overlay re-writes an
295
+ // identical value; semantic_session_id is the half that adds information.
242
296
  const lockFile = path.join(repoRoot, '.orchestrator', 'session.lock');
297
+ let raw;
298
+ // ENOENT right after ok:true is most likely a racer holding OUR fresh lock in
299
+ // its tombstone: a second start that saw the same stale lock renames it
300
+ // aside, finds it live and links it back within microseconds
301
+ // (session-lock.mjs reclaimIfNotLive). Reading once and giving up left the
302
+ // lock ours but without semantic_session_id, owner proof and lock.acquired
303
+ // (#1494 review: two racers are enough). Bounded: 5 retries × 10 ms, so a lock
304
+ // that is really gone costs this hook at most ~50 ms. Measured 2026-10-02
305
+ // (2000 live-restore reclaims, load average ~9): the racer's rename→link-back
306
+ // window was p50 0.34 ms, p99 2.4 ms, max 31.7 ms. Ceiling (BV-004): a window
307
+ // longer than ~50 ms still ends in the lockless-enrichment null above;
308
+ // revisit if lock.acquired goes missing for sessions that own the lock.
309
+ for (let attempt = 0; raw === undefined; attempt += 1) {
310
+ try {
311
+ raw = fs.readFileSync(lockFile, 'utf8');
312
+ } catch (err) {
313
+ if (err?.code !== 'ENOENT' || attempt >= ENRICH_READ_RETRIES) return null;
314
+ await new Promise((resolve) => setTimeout(resolve, ENRICH_READ_RETRY_MS));
315
+ }
316
+ }
243
317
  let baseLock;
244
318
  try {
245
- const raw = fs.readFileSync(lockFile, 'utf8');
246
319
  baseLock = JSON.parse(raw);
247
320
  if (typeof baseLock !== 'object' || baseLock === null) return null;
248
321
  } catch {
249
- // Lock vanished between write and read — best-effort, return null.
250
322
  return null;
251
323
  }
324
+ // The file can belong to another session by now. The reclaim above is
325
+ // single-winner since #1494 (tombstone rename + link-create in
326
+ // forceAcquire's onlyIfNotLive), but a plain forceAcquire — the same-session
327
+ // refresh, an operator's Phase-1.2 force-take — still overwrites
328
+ // unconditionally, and a link-back that loses to a third writer drops the
329
+ // lock it was restoring (session-lock.mjs reclaimIfNotLive, ceiling 2).
330
+ // Enriching a foreign lock would stamp OUR label onto ITS record and write
331
+ // an owner proof that verifies against it, so it is left as is.
332
+ if (baseLock.session_id !== sessionId) return null;
252
333
 
253
334
  const startedAt = typeof baseLock.started_at === 'string'
254
335
  ? baseLock.started_at
@@ -301,27 +382,38 @@ export async function bootstrapLock({
301
382
  }
302
383
  } catch { /* best-effort — a missing proof never breaks session-start */ }
303
384
 
304
- // Step 3: best-effort observability breadcrumb. Failures are swallowed
305
- // so a missing events module never breaks the hook.
385
+ // Step 3: best-effort observability breadcrumb.
386
+ await emitBestEffort(_emitEventImpl, 'orchestrator.session.lock.acquired', {
387
+ session_id: enriched.session_id,
388
+ semantic_session_id: enriched.semantic_session_id,
389
+ mode: enriched.mode,
390
+ pid: enriched.pid,
391
+ host: enriched.host,
392
+ ttl_hours: enriched.ttl_hours,
393
+ ...(reclaimed ? { reclaimed } : {}),
394
+ });
395
+
396
+ return enriched;
397
+ }
398
+
399
+ /**
400
+ * Emit one event; every failure is swallowed so a missing events module never
401
+ * breaks the hook (observability is best-effort).
402
+ *
403
+ * @param {Function|undefined} emitImpl — the `_emitEventImpl` test seam.
404
+ * @param {string} name
405
+ * @param {object} payload
406
+ * @returns {Promise<void>}
407
+ */
408
+ async function emitBestEffort(emitImpl, name, payload) {
306
409
  try {
307
- let emitFn = _emitEventImpl;
410
+ let emitFn = emitImpl;
308
411
  if (!emitFn) {
309
412
  const eventsMod = await import('../../scripts/lib/events.mjs');
310
413
  emitFn = eventsMod.emitEvent;
311
414
  }
312
- if (typeof emitFn === 'function') {
313
- await emitFn('orchestrator.session.lock.acquired', {
314
- session_id: enriched.session_id,
315
- semantic_session_id: enriched.semantic_session_id,
316
- mode: enriched.mode,
317
- pid: enriched.pid,
318
- host: enriched.host,
319
- ttl_hours: enriched.ttl_hours,
320
- });
321
- }
415
+ if (typeof emitFn === 'function') await emitFn(name, payload);
322
416
  } catch { /* observability is best-effort */ }
323
-
324
- return enriched;
325
417
  }
326
418
 
327
419
  /**
@@ -81,7 +81,7 @@ const PROTECTED_PATTERNS = [
81
81
  /^tsconfig.*\.json$/, // tsconfig.json, tsconfig.base.json, tsconfig.build.json, …
82
82
  /^\.prettierrc(?:\..+)?$/, // .prettierrc, .prettierrc.json, .prettierrc.js, …
83
83
  /^prettier\.config\.(?:js|cjs|mjs)$/,
84
- /^commitlint\.config\.(?:js|cjs|mjs|ts)$/,
84
+ /^commitlint(?:\.ci)?\.config\.(?:js|cjs|mjs|ts)$/, // + the strict CI config (commitlint.ci.config.mjs)
85
85
  ];
86
86
 
87
87
  /**
@@ -95,9 +95,10 @@ import { pathToFileURL } from 'node:url';
95
95
  /** @type {typeof import('../scripts/lib/io.mjs').emitAllow} */ let emitAllow;
96
96
  /** @type {typeof import('../scripts/lib/io.mjs').emitDeny} */ let emitDeny;
97
97
  /** @type {typeof import('../scripts/lib/io.mjs').emitWarn} */ let emitWarn;
98
- let resolveProjectDir;
98
+ let resolveSessionRoot;
99
99
  let readJson;
100
- let findScopeFile;
100
+ // #1504 point 6 — bound from scope-gate below.
101
+ let findOwnScopeFile;
101
102
  let extractBashWriteTargets;
102
103
  let pathMatchesPattern;
103
104
  /** @type {typeof import('../scripts/lib/session-identity/own-session.mjs').readProcessLocalSessionIds} */
@@ -165,7 +166,8 @@ function bannerProjectDir() {
165
166
  * the entry-point catch banners.
166
167
  *
167
168
  * `hardening.mjs` is bound (no headFallback — it carries relative imports) for
168
- * `findScopeFile` / `extractBashWriteTargets` / `pathMatchesPattern`; the two
169
+ * `extractBashWriteTargets` / `pathMatchesPattern` (and `scope-gate.mjs` for
170
+ * `findOwnScopeFile`, #1504 point 6); the two
169
171
  * command-matching primitives come from the direct `blocker` namespace, which is
170
172
  * the only entry that opts into the `git show HEAD:` recovery. Because hardening
171
173
  * transitively imports command-blocker (via scope-gate.mjs), a broken command-blocker
@@ -184,6 +186,9 @@ async function bootstrap() {
184
186
  platform: { specifier: lib('platform.mjs') },
185
187
  common: { specifier: lib('common.mjs') },
186
188
  hardening: { specifier: lib('hardening.mjs') },
189
+ // #1504 point 6 — `findOwnScopeFile`. Already reachable through the
190
+ // hardening barrel, so no new module edge.
191
+ scopeGate: { specifier: lib('scope-gate.mjs') },
187
192
  blocker: {
188
193
  specifier: lib('command-blocker.mjs'),
189
194
  headFallback: true,
@@ -203,9 +208,10 @@ async function bootstrap() {
203
208
  );
204
209
 
205
210
  ({ readStdin, emitAllow, emitDeny, emitWarn } = modules.io);
206
- ({ resolveProjectDir } = modules.platform);
211
+ ({ resolveSessionRoot } = modules.platform);
207
212
  ({ readJson } = modules.common);
208
- ({ findScopeFile, extractBashWriteTargets, pathMatchesPattern } = modules.hardening);
213
+ ({ extractBashWriteTargets, pathMatchesPattern } = modules.hardening);
214
+ ({ findOwnScopeFile } = modules.scopeGate);
209
215
  ({ readProcessLocalSessionIds, classifyManifestSession } = modules.sessionIdentity);
210
216
  blocker = modules.blocker;
211
217
  degradedLabels = degraded;
@@ -275,10 +281,22 @@ async function main() {
275
281
  );
276
282
  }
277
283
 
278
- const projectRoot = resolveProjectDir();
279
-
280
- // G3 — no scope file → allow
281
- const scopePath = findScopeFile(projectRoot);
284
+ // #1492 — the SESSION's working copy, not the launch dir `$CLAUDE_PROJECT_DIR`
285
+ // keeps after `EnterWorktree` (the manifest lives in the worktree). See
286
+ // `resolveSessionRoot` in scripts/lib/platform.mjs.
287
+ const projectRoot = resolveSessionRoot(input.cwd);
288
+
289
+ // `new Set(...)` is load-bearing: `readProcessLocalSessionIds` returns a
290
+ // string[], and `classifyManifestSession` does `ownIds instanceof Set ?
291
+ // ownIds : new Set()` — a bare array would silently become EMPTY.
292
+ const ownIds = new Set(readProcessLocalSessionIds({ hookInput: input }));
293
+
294
+ // G3 — no scope file → allow. #1504 point 6: the first manifest that is not
295
+ // provably a peer's, so a peer's higher-precedence `.pi`/`.cursor`/`.codex`
296
+ // manifest no longer hides this session's own one; only when every candidate
297
+ // is foreign does the first come back, and G3b below stands down as before.
298
+ const located = findOwnScopeFile(projectRoot, ownIds, classifyManifestSession);
299
+ const scopePath = located.path ?? located.foreignPath;
282
300
  if (!scopePath) return flushNotices(notices);
283
301
 
284
302
  // SECURITY-REQ-08: read scope file exactly once; use the parsed object
@@ -313,10 +331,7 @@ async function main() {
313
331
  // empty, so this gate is permanently `'unknown'` = enforce = pre-#1153
314
332
  // behaviour there. Revisit when those hook payloads carry a session id.
315
333
  {
316
- // `new Set(...)` is load-bearing: `readProcessLocalSessionIds` returns a
317
- // string[], and `classifyManifestSession` does `ownIds instanceof Set ?
318
- // ownIds : new Set()` — a bare array would silently become EMPTY.
319
- const ownIds = new Set(readProcessLocalSessionIds({ hookInput: input }));
334
+ // `ownIds` is the process-local set built above G3.
320
335
  const { verdict, manifestIds } = classifyManifestSession(scope, ownIds);
321
336
  if (verdict === 'foreign') {
322
337
  // Observability only, and deliberately NOT emitWarn: this branch is hit on
@@ -107,7 +107,12 @@ import { isMainModule } from '../scripts/lib/is-main-module.mjs';
107
107
  let isPathInside;
108
108
  let relativeFromRoot;
109
109
  let resolveProjectDir;
110
- let findScopeFile;
110
+ let resolveSessionRoot;
111
+ // The launch dir the harness states — platform.mjs's one definition of the env
112
+ // chain, so the relocation trace below cannot drift from the clamp (#1504).
113
+ let launchDirFromEnv;
114
+ // #1504 point 6 — the manifest that governs THIS session; bound from scope-gate below.
115
+ let findOwnScopeFile;
111
116
  let pathMatchesPattern;
112
117
  let suggestForScopeViolation;
113
118
  let readJson;
@@ -197,8 +202,8 @@ async function bootstrap() {
197
202
 
198
203
  ({ readStdin, emitAllow, emitDeny, emitWarn } = modules.io);
199
204
  ({ isPathInside, relativeFromRoot } = modules.pathUtils);
200
- ({ resolveProjectDir } = modules.platform);
201
- ({ findScopeFile, pathMatchesPattern, suggestForScopeViolation } = modules.hardening);
205
+ ({ resolveProjectDir, resolveSessionRoot, launchDirFromEnv } = modules.platform);
206
+ ({ pathMatchesPattern, suggestForScopeViolation } = modules.hardening);
202
207
  ({ readJson } = modules.common);
203
208
  ({
204
209
  classifyEmptyScope,
@@ -206,6 +211,7 @@ async function bootstrap() {
206
211
  sessionStartedAtMs,
207
212
  gradeScopeEntry,
208
213
  canonicalizeGrantPrefix,
214
+ findOwnScopeFile,
209
215
  } = modules.scopeGate);
210
216
  ({ readProcessLocalSessionIds, classifyManifestSession } = modules.sessionIdentity);
211
217
  }
@@ -224,7 +230,13 @@ async function main() {
224
230
  // Gate 2: file_path must be a non-empty string
225
231
  if (!filePath || typeof filePath !== 'string') return emitAllow();
226
232
 
227
- const projectRootRaw = resolveProjectDir();
233
+ // #1492 — the SESSION's working copy, not the launch dir: after
234
+ // `EnterWorktree`, `$CLAUDE_PROJECT_DIR` still names the dir the session was
235
+ // launched in, while the manifest lives in the worktree. Never above the launch
236
+ // dir; a harness subagent worktree (`isolation: "worktree"`) is lifted to the
237
+ // launch checkout it sits under — still unenforced in an entered-worktree
238
+ // session (named ceiling in `resolveSessionRoot`, scripts/lib/platform.mjs).
239
+ const projectRootRaw = resolveSessionRoot(input.cwd);
228
240
 
229
241
  // Resolve symlinks in the project root itself so that realpath(file) comparisons
230
242
  // are consistent. On macOS /tmp → /private/tmp; mismatches would cause false denials.
@@ -235,9 +247,26 @@ async function main() {
235
247
  projectRoot = projectRootRaw;
236
248
  }
237
249
 
238
- // Gate 3: no wave-scope.json → nothing to enforce
239
- const scopePath = findScopeFile(projectRoot);
240
- if (!scopePath) return emitAllow();
250
+ // `new Set(...)` is load-bearing: `readProcessLocalSessionIds` returns a
251
+ // string[], and `classifyManifestSession` does `ownIds instanceof Set ?
252
+ // ownIds : new Set()` — a bare array would silently become EMPTY, making
253
+ // every manifest read `unknown`. Process-local only (#1194, see Gate 3b).
254
+ const ownIds = new Set(readProcessLocalSessionIds({ hookInput: input }));
255
+
256
+ // Gate 3: no wave-scope.json → nothing to enforce.
257
+ //
258
+ // #1504 point 6 — the first manifest that is not provably a peer's, in the
259
+ // `.pi` > `.cursor` > `.codex` > `.claude` order. `findScopeFile` stopped at
260
+ // the first file that exists, so a peer's `.pi`/`.cursor` manifest made Gate
261
+ // 3b stand down and this session's own `.claude/wave-scope.json` was never
262
+ // read. Only when EVERY candidate is foreign does the first of them come back
263
+ // (`foreignPath`), so Gate 3b's stand-down below stays unchanged.
264
+ const located = findOwnScopeFile(projectRoot, ownIds, classifyManifestSession);
265
+ const scopePath = located.path ?? located.foreignPath;
266
+ if (!scopePath) {
267
+ await recordHiddenManifestOncePerWave({ projectRootRaw, projectRoot, ownIds, filePath });
268
+ return emitAllow();
269
+ }
241
270
 
242
271
  // SECURITY-REQ-08: read scope file once; pass parsed object to all subsequent checks
243
272
  //
@@ -305,11 +334,7 @@ async function main() {
305
334
  // `enforcement: "off"` already grants in the same file — this gate adds no new
306
335
  // authority, and the manifest is the coordinator's own artefact either way.
307
336
  {
308
- // `new Set(...)` is load-bearing: `readProcessLocalSessionIds` returns a
309
- // string[], and `classifyManifestSession` does `ownIds instanceof Set ?
310
- // ownIds : new Set()` — a bare array would silently become EMPTY, making
311
- // every manifest read `unknown`.
312
- const ownIds = new Set(readProcessLocalSessionIds({ hookInput: input }));
337
+ // `ownIds` is the process-local set built above Gate 3.
313
338
  const { verdict, manifestIds } = classifyManifestSession(scope, ownIds);
314
339
  if (verdict === 'foreign') {
315
340
  // Observability only, and deliberately NOT emitWarn: this branch is hit on
@@ -489,6 +514,7 @@ async function main() {
489
514
  if (matchedGrant !== null) {
490
515
  const grade = gradeScopeEntry(matchedGrant, { resolve: canonicalizeGrantPrefix });
491
516
  if (grade?.verdict === 'error') {
517
+ await recordRefusedGrantOncePerWave({ scope, scopePath, projectRoot, input, grant: matchedGrant, grade, filePath: resolvedPath });
492
518
  return emitWarn(
493
519
  `Gate 5b honoured an out-of-repo grant that scripts/validate-wave-scope.mjs would REFUSE ` +
494
520
  `before dispatch — allowedPaths ${grade.message}. The write to '${resolvedPath}' is ` +
@@ -538,7 +564,9 @@ async function main() {
538
564
  // dispatched wave agent's. See {@link classifyCaller} for the measurement.
539
565
  // A subagent falls through to the gates below — under a manifest that does
540
566
  // not grant the path (Discovery's `allowedPaths: []`) that is a DENY.
541
- const memoryDirs = await ownMemoryDirs(projectRootRaw, projectRoot);
567
+ // #1492: the LAUNCH dir too — the harness keys auto-memory on the path it
568
+ // was launched with, which after `EnterWorktree` is no longer the root.
569
+ const memoryDirs = await ownMemoryDirs([resolveProjectDir(), projectRootRaw, projectRoot]);
542
570
  const caller = classifyCaller(input);
543
571
  if (caller !== 'subagent' && memoryDirs.some((dir) => isInsideDir(resolvedPath, dir))) {
544
572
  // One event per decision point, awaited before emitAllow() —
@@ -652,6 +680,155 @@ async function main() {
652
680
  return emitAllow();
653
681
  }
654
682
 
683
+ /**
684
+ * #1485 point 1 — the catch event for Gate 5b's WARN branch, ONCE per wave.
685
+ *
686
+ * `scripts/validate-wave-scope.mjs` has no mechanical caller; the Gate 5b WARN
687
+ * is the only place a refused grant is seen at all, and a `systemMessage` is not
688
+ * countable. Owner decision 2026-10-02, option (b): the FIRST WARN of a wave
689
+ * writes `orchestrator.scope.refused_grant_allowed`, so "how many waves ran on
690
+ * a manifest the validator would refuse" becomes a ledger query (HR-105). The
691
+ * WARN itself is unchanged and still fires on every write.
692
+ *
693
+ * DEDUPE: a marker `.orchestrator/tmp/gate5b-refused-grant/<session>-w<wave>`
694
+ * created with `flag: 'wx'` (O_CREAT|O_EXCL). Exclusive create is atomic, so of
695
+ * two hook processes racing on the first WARN exactly one wins and emits — no
696
+ * lock, no read-modify-write. The session joins the key because wave numbers
697
+ * restart in every session sharing this working copy. `.orchestrator/tmp/` is
698
+ * gitignored; one marker per (session, wave) that ever hit a refused grant.
699
+ *
700
+ * Any marker failure OTHER than EEXIST (unwritable dir, ENOTDIR) still emits:
701
+ * over-reporting a rare event beats re-creating the silent gap this closes.
702
+ *
703
+ * COST: runs only on the Gate 5b `error` branch — an ordinary in-repo write
704
+ * never reaches it, so the hot path pays nothing. NAMED CEILING (BV-004): a
705
+ * legacy manifest without a session id and a payload without one share the key
706
+ * `no-session-w<N>` across sessions; revisit if such manifests are still seen.
707
+ *
708
+ * Never throws — observability must not change the WARN.
709
+ *
710
+ * @param {{scope: Record<string, unknown>, scopePath: string, projectRoot: string,
711
+ * input: Record<string, unknown>, grant: string, grade: {code?: string},
712
+ * filePath: string}} facts
713
+ * @returns {Promise<void>}
714
+ */
715
+ async function recordRefusedGrantOncePerWave({ scope, scopePath, projectRoot, input, grant, grade, filePath }) {
716
+ try {
717
+ const session = String(
718
+ scope.session_id ?? scope.session ?? input?.session_id ?? process.env.CLAUDE_CODE_SESSION_ID ?? 'no-session',
719
+ );
720
+ const key = `${session}-w${scope.wave ?? 'unknown'}`.replace(/[^A-Za-z0-9._-]/g, '_').slice(0, 160);
721
+ const markerDir = path.join(projectRoot, '.orchestrator', 'tmp', 'gate5b-refused-grant');
722
+ try { await fs.mkdir(markerDir, { recursive: true }); } catch { /* the create below decides */ }
723
+ try {
724
+ await fs.writeFile(path.join(markerDir, key), `${new Date().toISOString()}\n`, { flag: 'wx' });
725
+ } catch (e) {
726
+ if (e?.code === 'EEXIST') return; // this wave already reported
727
+ }
728
+ const { emitEvent } = await import('../scripts/lib/events.mjs');
729
+ await emitEvent(
730
+ 'orchestrator.scope.refused_grant_allowed',
731
+ {
732
+ hook: HOOK_NAME,
733
+ manifest: scopePath,
734
+ wave: scope.wave,
735
+ grant,
736
+ code: grade?.code,
737
+ file_path: filePath,
738
+ },
739
+ { repoRoot: projectRoot },
740
+ );
741
+ } catch { /* observability is best-effort — never changes the WARN */ }
742
+ }
743
+
744
+ /**
745
+ * #1504 point 3 — the catch event for a session root that HID an active wave,
746
+ * ONCE per (session, wave, session root).
747
+ *
748
+ * `resolveSessionRoot()` answers with the repo the payload `cwd` sits in. A wave
749
+ * agent that `cd`s into a nested or sibling repo (or a session that entered a
750
+ * worktree) therefore resolves a root without a manifest, and Gate 3 allows
751
+ * every write there while the wave's manifest sits at the launch dir, unread.
752
+ * The revisit trigger `resolveSessionRoot` names for exactly that relocation
753
+ * ("an `orchestrator.scope.*` record") could never fire: this allow left no
754
+ * trace. Behaviour stays ALLOW — this is the trace, not a new deny.
755
+ *
756
+ * Fires only when BOTH hold, so the ordinary no-manifest allow — every write in
757
+ * every repo without a wave — stays silent (HR-101):
758
+ * 1. the harness states a launch dir and it is not the resolved session root;
759
+ * 2. the launch root carries a manifest that is not provably a peer's
760
+ * ({@link findOwnScopeFile}: own, or unbound/legacy — one that WOULD have
761
+ * been enforced). A peer's manifest at the launch root hides nothing of ours.
762
+ *
763
+ * COST on the hot path: rung 1 is an env read plus a string compare — no
764
+ * filesystem call while the session root IS the launch dir, i.e. for every
765
+ * session that never left its checkout. One `realpath` when only the spelling
766
+ * differs (macOS `/var` → `/private/var`); the ≤4 `existsSync` of rung 2 only
767
+ * for a genuinely relocated root.
768
+ *
769
+ * DEDUPE: the {@link recordRefusedGrantOncePerWave} pattern — an exclusive
770
+ * create (`flag: 'wx'`) of `<launch>/.orchestrator/tmp/scope-root-relocated/<key>`.
771
+ * The event goes to the LAUNCH repo's ledger, where the wave's other records
772
+ * live. The session root's basename joins the key so a second relocated repo in
773
+ * one wave is reported too. NAMED CEILING (BV-004): two relocated roots sharing a
774
+ * basename in one wave report once; revisit if a record ever names the wrong repo.
775
+ *
776
+ * Never throws — observability must not change the allow.
777
+ *
778
+ * @param {{projectRootRaw: string, projectRoot: string, ownIds: Set<string>,
779
+ * filePath: string}} facts
780
+ * @returns {Promise<void>}
781
+ */
782
+ async function recordHiddenManifestOncePerWave({ projectRootRaw, projectRoot, ownIds, filePath }) {
783
+ try {
784
+ const launchRaw = launchDirFromEnv();
785
+ if (launchRaw === '') return;
786
+ const launchResolved = path.resolve(launchRaw);
787
+ if (launchResolved === projectRootRaw || launchResolved === projectRoot) return;
788
+ let launch;
789
+ try {
790
+ launch = await fs.realpath(launchResolved);
791
+ } catch {
792
+ return; // a launch dir that does not exist holds no manifest
793
+ }
794
+ if (launch === projectRoot) return;
795
+
796
+ const { path: manifest } = findOwnScopeFile(launch, ownIds, classifyManifestSession);
797
+ if (!manifest) return;
798
+
799
+ let scope = {};
800
+ try {
801
+ const parsed = JSON.parse(await fs.readFile(manifest, 'utf8'));
802
+ if (parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed)) scope = parsed;
803
+ } catch { /* unreadable → keyed and reported as wave `unknown` */ }
804
+
805
+ const session = String(scope.session_id ?? scope.session ?? [...ownIds][0] ?? 'no-session');
806
+ const key = `${session}-w${scope.wave ?? 'unknown'}-${path.basename(projectRoot)}`
807
+ .replace(/[^A-Za-z0-9._-]/g, '_')
808
+ .slice(0, 160);
809
+ const markerDir = path.join(launch, '.orchestrator', 'tmp', 'scope-root-relocated');
810
+ try { await fs.mkdir(markerDir, { recursive: true }); } catch { /* the create below decides */ }
811
+ try {
812
+ await fs.writeFile(path.join(markerDir, key), `${new Date().toISOString()}\n`, { flag: 'wx' });
813
+ } catch (e) {
814
+ if (e?.code === 'EEXIST') return; // this root already reported for this wave
815
+ }
816
+ const { emitEvent } = await import('../scripts/lib/events.mjs');
817
+ await emitEvent(
818
+ 'orchestrator.scope.manifest_hidden_by_session_root',
819
+ {
820
+ hook: HOOK_NAME,
821
+ manifest,
822
+ launch_root: launch,
823
+ session_root: projectRoot,
824
+ wave: scope.wave,
825
+ file_path: path.isAbsolute(filePath) ? filePath : path.resolve(projectRoot, filePath),
826
+ },
827
+ { repoRoot: launch },
828
+ );
829
+ } catch { /* observability is best-effort — never changes the allow */ }
830
+ }
831
+
655
832
  /**
656
833
  * `mtimeMs` of a file, or `null` when it cannot be stat'ed.
657
834
  *
@@ -679,21 +856,22 @@ async function mtimeMsOf(file) {
679
856
  /**
680
857
  * #1295 — the Claude Code auto-memory directories that belong to THIS repo.
681
858
  *
682
- * Returns at most two paths, both naming the SAME repo: the encoding of the
683
- * project root as the harness saw it (`CLAUDE_PROJECT_DIR` / cwd) and — when it
684
- * differs — the encoding of its realpath. Both are needed because Claude Code
685
- * encodes the path it was LAUNCHED with, while this hook compares against the
686
- * canonical root (on macOS `/tmp` → `/private/tmp`). Two encodings of one repo
687
- * is not a wider grant: a sibling repo's root encodes to neither.
859
+ * One path per distinct root (per home spelling): the encoding of the launch
860
+ * dir the harness saw (`CLAUDE_PROJECT_DIR` / cwd), of the session root
861
+ * (#1492 — a worktree the session entered) and of its realpath. All are needed
862
+ * because Claude Code encodes the path it was LAUNCHED with, while this hook
863
+ * compares against the canonical session root (on macOS `/tmp` →
864
+ * `/private/tmp`). Keeping the launch dir keeps the pre-#1492 grant exactly
865
+ * (the launch dir WAS the root then); the session-root entries name the
866
+ * worktree this session works in. A sibling repo's root encodes to none of them.
688
867
  *
689
868
  * Returns `[]` when the encoder cannot be loaded, so the caller falls through to
690
869
  * its deny (fail-closed).
691
870
  *
692
- * @param {string} projectRootRaw — project root as resolved from env/cwd
693
- * @param {string} projectRoot — its realpath
871
+ * @param {string[]} candidateRoots — launch dir, session root, its realpath
694
872
  * @returns {Promise<string[]>}
695
873
  */
696
- async function ownMemoryDirs(projectRootRaw, projectRoot) {
874
+ async function ownMemoryDirs(candidateRoots) {
697
875
  try {
698
876
  const [{ encodeProjectDir }, { homedir }] = await Promise.all([
699
877
  import('../scripts/lib/wave-transcript-tail.mjs'),
@@ -710,7 +888,7 @@ async function ownMemoryDirs(projectRootRaw, projectRoot) {
710
888
  } catch { /* non-existent home — the raw form is all there is */ }
711
889
  const homes = new Set([home, homeReal]);
712
890
  const roots = new Set(
713
- [projectRootRaw, projectRoot].filter((p) => typeof p === 'string' && p.length > 0),
891
+ candidateRoots.filter((p) => typeof p === 'string' && p.length > 0),
714
892
  );
715
893
  const dirs = [];
716
894
  for (const h of homes) {
@@ -7,7 +7,7 @@
7
7
  "hooks": [
8
8
  {
9
9
  "type": "command",
10
- "command": "echo '🎯 Session Orchestrator v5.3.0 — /session [housekeeping|feature|deep] | /plan [new|feature|retro] | /discovery [scope] | /evolve [analyze|review|list]'",
10
+ "command": "echo '🎯 Session Orchestrator v5.5.0 — /session [housekeeping|feature|deep] | /plan [new|feature|retro] | /discovery [scope] | /evolve [analyze|review|list]'",
11
11
  "async": false
12
12
  },
13
13
  {
@@ -83,6 +83,11 @@
83
83
  "type": "command",
84
84
  "command": "sh \"$CURSOR_PLUGIN_ROOT/hooks/run-node.sh\" \"$CURSOR_PLUGIN_ROOT/hooks/config-protection.mjs\"",
85
85
  "timeout": 5
86
+ },
87
+ {
88
+ "type": "command",
89
+ "command": "sh \"$CURSOR_PLUGIN_ROOT/hooks/run-node.sh\" \"$CURSOR_PLUGIN_ROOT/hooks/pre-edit-sessions-ledger-guard.mjs\"",
90
+ "timeout": 5
86
91
  }
87
92
  ]
88
93
  }
@@ -38,6 +38,11 @@
38
38
  "type": "command",
39
39
  "command": "sh \"$PI_PLUGIN_ROOT/hooks/run-node.sh\" \"$PI_PLUGIN_ROOT/hooks/config-protection.mjs\"",
40
40
  "timeout": 5
41
+ },
42
+ {
43
+ "type": "command",
44
+ "command": "sh \"$PI_PLUGIN_ROOT/hooks/run-node.sh\" \"$PI_PLUGIN_ROOT/hooks/pre-edit-sessions-ledger-guard.mjs\"",
45
+ "timeout": 5
41
46
  }
42
47
  ]
43
48
  },