session-orchestrator 5.4.0 → 5.6.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 (151) 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/060-evolve.mdc +5 -5
  5. package/.cursor-plugin/plugin.json +1 -1
  6. package/CHANGELOG.md +115 -3
  7. package/README.md +30 -15
  8. package/docs/ci-setup.md +120 -23
  9. package/docs/components.md +7 -7
  10. package/docs/events-schema.md +17 -14
  11. package/docs/install.md +14 -2
  12. package/docs/migration-v4.md +24 -17
  13. package/docs/plugin-architecture-v3.md +1 -1
  14. package/docs/scope-collision-guard.md +19 -15
  15. package/docs/session-config-reference.md +7 -7
  16. package/docs/vault-docs-architecture.md +8 -4
  17. package/hooks/_lib/hook-import-set.json +17 -8
  18. package/hooks/_lib/lock-bootstrap.mjs +118 -26
  19. package/hooks/config-protection.mjs +1 -1
  20. package/hooks/enforce-commands.mjs +28 -13
  21. package/hooks/enforce-scope.mjs +201 -23
  22. package/hooks/hooks-codex.json +1 -1
  23. package/hooks/hooks.json +1 -1
  24. package/hooks/on-session-start.mjs +4 -2
  25. package/hooks/on-stop.mjs +77 -171
  26. package/hooks/post-bash-write-verify.mjs +219 -24
  27. package/hooks/post-edit-validate.mjs +15 -5
  28. package/hooks/post-tool-batch-wave-signal.mjs +41 -156
  29. package/hooks/pre-bash-memory-propose-audit.mjs +28 -11
  30. package/hooks/pre-bash-staging-fence.mjs +29 -8
  31. package/hooks/pre-task-scope-disjoint.mjs +603 -241
  32. package/hooks/wave-scope-commit-guard.mjs +235 -73
  33. package/package.json +9 -8
  34. package/scripts/apply-session-learnings.mjs +35 -14
  35. package/scripts/archive-closed-prds.mjs +38 -11
  36. package/scripts/autopilot.mjs +8 -1
  37. package/scripts/backfill-abandoned-sessions.mjs +80 -5
  38. package/scripts/backfill-learnings-from-vault.mjs +49 -22
  39. package/scripts/codex-install.mjs +3 -0
  40. package/scripts/cursor-install.mjs +46 -2
  41. package/scripts/emit-session.mjs +5 -3
  42. package/scripts/export-hw-learnings.mjs +18 -7
  43. package/scripts/fleet-checkin.mjs +26 -3
  44. package/scripts/lib/autopilot/loop.mjs +25 -10
  45. package/scripts/lib/autopilot/stall-sampler.mjs +13 -9
  46. package/scripts/lib/blocked-commands-policy.mjs +40 -0
  47. package/scripts/lib/common.mjs +41 -0
  48. package/scripts/lib/config/vault-integration.mjs +151 -16
  49. package/scripts/lib/config.mjs +5 -1
  50. package/scripts/lib/eval/engine.mjs +13 -0
  51. package/scripts/lib/events-retention-banner.mjs +393 -0
  52. package/scripts/lib/events-rotation.mjs +21 -6
  53. package/scripts/lib/events.mjs +51 -3
  54. package/scripts/lib/fleet-protocol.mjs +20 -2
  55. package/scripts/lib/hardening.mjs +1 -0
  56. package/scripts/lib/harness-audit/categories/category4.mjs +8 -6
  57. package/scripts/lib/instruction-budget-guard.mjs +8 -0
  58. package/scripts/lib/learnings/expiry-sweep.mjs +387 -20
  59. package/scripts/lib/learnings/io.mjs +125 -16
  60. package/scripts/lib/lock-reaper.mjs +38 -14
  61. package/scripts/lib/maintenance-due-banner.mjs +23 -8
  62. package/scripts/lib/named-vault-resolver.mjs +48 -5
  63. package/scripts/lib/orphan-reaper/no-follow-append.mjs +87 -0
  64. package/scripts/lib/orphan-reaper/reaper-audit.mjs +101 -69
  65. package/scripts/lib/orphan-reaper/scan-throttle.mjs +79 -27
  66. package/scripts/lib/orphan-reaper/scan.mjs +4 -0
  67. package/scripts/lib/orphan-reaper/trigger.mjs +173 -0
  68. package/scripts/lib/orphan-reaper.mjs +5 -1
  69. package/scripts/lib/platform.mjs +206 -2
  70. package/scripts/lib/plugin-update-banner.mjs +3 -1
  71. package/scripts/lib/process-group.mjs +83 -22
  72. package/scripts/lib/scope-baseline.mjs +116 -25
  73. package/scripts/lib/scope-echo.mjs +7 -0
  74. package/scripts/lib/scope-gate.mjs +201 -27
  75. package/scripts/lib/session-close-backfill.mjs +202 -4
  76. package/scripts/lib/session-end/tail-runner.mjs +10 -5
  77. package/scripts/lib/session-lock-shape.mjs +6 -3
  78. package/scripts/lib/session-lock.mjs +320 -67
  79. package/scripts/lib/session-registry.mjs +41 -16
  80. package/scripts/lib/session-start-probes.mjs +242 -68
  81. package/scripts/lib/session-token-rollup.mjs +39 -34
  82. package/scripts/lib/sessions-staleness-banner.mjs +3 -1
  83. package/scripts/lib/tail-window.mjs +27 -3
  84. package/scripts/lib/telemetry/pricing.mjs +5 -2
  85. package/scripts/lib/telemetry/sync.mjs +17 -0
  86. package/scripts/lib/tests-src-ratio.mjs +77 -2
  87. package/scripts/lib/tmux-layout/telemetry-stats.mjs +12 -0
  88. package/scripts/lib/validate/check-test-value-bans.mjs +320 -32
  89. package/scripts/lib/validate/check-vcs-repo-flag.mjs +1 -1
  90. package/scripts/lib/vault-mirror/auto-commit.mjs +5 -3
  91. package/scripts/lib/vault-mirror/namespace.mjs +47 -26
  92. package/scripts/lib/vault-mirror/process.mjs +350 -62
  93. package/scripts/lib/vault-mirror/pseudonym-map.mjs +14 -5
  94. package/scripts/lib/vault-mirror/render-learnings.mjs +66 -3
  95. package/scripts/lib/vault-mirror/render-sessions.mjs +28 -4
  96. package/scripts/lib/vault-mirror/telemetry.mjs +18 -11
  97. package/scripts/lib/vault-status/board-writer.mjs +5 -1
  98. package/scripts/lib/vcs-repo-spec.mjs +8 -0
  99. package/scripts/materialize-wave-scope.mjs +7 -4
  100. package/scripts/release.mjs +1 -0
  101. package/scripts/run-migrate-v2-cross-repo.mjs +14 -2
  102. package/scripts/run-quality-gate.mjs +8 -5
  103. package/scripts/self-update.mjs +229 -0
  104. package/scripts/sweep-expired-learnings.mjs +560 -28
  105. package/scripts/validate-wave-scope.mjs +60 -6
  106. package/scripts/vault-backfill.mjs +50 -37
  107. package/scripts/vault-mirror.mjs +170 -33
  108. package/skills/_shared/fleet-protocol.md +74 -71
  109. package/skills/_shared/parallel-aware-auq.md +1 -1
  110. package/skills/_shared/state-ownership.md +38 -14
  111. package/skills/autopilot/SKILL.md +1 -1
  112. package/skills/bootstrap/SKILL.md +5 -3
  113. package/skills/bootstrap/_shared-template.md +96 -1
  114. package/skills/bootstrap/fast-template.md +3 -0
  115. package/skills/bootstrap/references/bootstrap-refresh-lock-flow.md +5 -3
  116. package/skills/bootstrap/standard-template.md +2 -0
  117. package/skills/discovery/probes/ssot-code-diff.mjs +12 -6
  118. package/skills/discovery/probes/vault-narrative-staleness.mjs +6 -2
  119. package/skills/discovery/probes/vault-staleness.mjs +9 -3
  120. package/skills/docs-orchestrator/SKILL.md +12 -5
  121. package/skills/docs-orchestrator/audience-mapping.md +5 -3
  122. package/skills/evolve/SKILL.md +11 -2
  123. package/skills/evolve/references/evolve-analyze-mode.md +37 -17
  124. package/skills/session-end/learning-patterns.md +3 -3
  125. package/skills/session-end/metrics-collection.md +1 -1
  126. package/skills/session-end/phase-3-2-docs-verification.md +7 -6
  127. package/skills/session-end/plan-verification.md +25 -9
  128. package/skills/session-end/references/phase-3-documentation-updates.md +11 -1
  129. package/skills/session-end/session-metrics-write.md +45 -10
  130. package/skills/session-start/SKILL.md +3 -3
  131. package/skills/session-start/references/operations-contract.md +5 -3
  132. package/skills/session-start/references/phase-1-2-session-lock.md +32 -7
  133. package/skills/session-start/references/phase-4-ssot-environment-check.md +8 -1
  134. package/skills/vault-mirror/SKILL.md +21 -11
  135. package/skills/wave-executor/SKILL.md +1 -1
  136. package/skills/wave-executor/circuit-breaker.md +1 -1
  137. package/skills/wave-executor/references/wave-executor-state-init.md +1 -1
  138. package/skills/wave-executor/references/wave-loop-dispatch.md +8 -0
  139. package/skills/wave-executor/references/wave-loop-review.md +2 -2
  140. package/skills/wave-executor/references/wave-loop-scope-manifest.md +14 -6
  141. package/.agents/skills/navigator/SKILL.md +0 -22
  142. package/.agents/skills/navigator/agents/openai.yaml +0 -5
  143. package/.codex-plugin/skills/navigator/SKILL.md +0 -22
  144. package/.codex-plugin/skills/navigator/agents/openai.yaml +0 -5
  145. package/.cursor/commands/navigator.md +0 -15
  146. package/.cursor/skills/navigator/SKILL.md +0 -13
  147. package/pi/prompts/navigator.md +0 -12
  148. package/skills/navigator/SKILL.md +0 -193
  149. package/skills/navigator/references/account-check.sh +0 -87
  150. package/skills/navigator/references/fleet-decisions.mjs +0 -231
  151. package/skills/navigator/references/slot-check.sh +0 -114
@@ -109,6 +109,10 @@
109
109
  * observed actor could rewrite the gate unreported. Because the file is
110
110
  * typically gitignored, `git status` cannot see it anyway — change detection
111
111
  * for it is content-hash-based against the snapshot (`formatControlNotice`).
112
+ * Its sibling control state `<state-dir>/filescopes/**` (the per-agent scope
113
+ * declarations, #1020) is likewise not on the list: `computeReport` drops it
114
+ * from the violation set beside the manifest itself, keyed on the ACTIVE
115
+ * manifest's state dir (`isScopeDeclarationPath`, #1027 Pkt 5).
112
116
  *
113
117
  * The hook's OWN snapshot lives in `$TMPDIR`, never inside the repo, so it is
114
118
  * structurally incapable of reporting its own bookkeeping.
@@ -149,18 +153,24 @@ import os from 'node:os';
149
153
  import { fileURLToPath } from 'node:url';
150
154
  import { createHash } from 'node:crypto';
151
155
  import { execFileSync } from 'node:child_process';
152
- import { readFileSync, mkdirSync, writeFileSync, renameSync, realpathSync, statSync } from 'node:fs';
156
+ import { existsSync, readFileSync, mkdirSync, writeFileSync, renameSync, realpathSync, statSync } from 'node:fs';
153
157
 
154
158
  import { readStdin, writeStdoutLineSync } from '../scripts/lib/io.mjs';
155
- import { resolveProjectDir } from '../scripts/lib/platform.mjs';
156
- import { findScopeFile, pathMatchesPattern } from '../scripts/lib/hardening.mjs';
159
+ import { resolveSessionRoot } from '../scripts/lib/platform.mjs';
160
+ import { pathMatchesPattern } from '../scripts/lib/hardening.mjs';
157
161
  // #1057 — `sessionAgeMs` and its private `clockAgeMs` helper MOVED to the lib so
158
162
  // hooks/enforce-scope.mjs can read the same session clock without a hook->hook
159
163
  // import. Re-exported below, so this hook's public surface — and
160
164
  // tests/hooks/post-bash-write-verify.test.mjs, which imports the named export —
161
165
  // is unchanged. Two byte-identical copies of a clock is exactly the one-fact-two-
162
166
  // copies class this repo keeps paying for.
163
- import { sessionAgeMs, PEER_RECORD_PREFIX, isPeerRecordId } from '../scripts/lib/scope-gate.mjs';
167
+ import {
168
+ sessionAgeMs,
169
+ PEER_RECORD_PREFIX,
170
+ isPeerRecordId,
171
+ isScopeDeclarationPath,
172
+ findOwnScopeFile,
173
+ } from '../scripts/lib/scope-gate.mjs';
164
174
  // #1153 P1 — the same process-local ownership check hooks/enforce-scope.mjs
165
175
  // applies at Gate 3b. This hook reads the SAME working-copy `wave-scope.json`,
166
176
  // so without it a peer session's manifest drives this session's advisories.
@@ -313,6 +323,45 @@ export function parsePorcelainZ(raw) {
313
323
  return out;
314
324
  }
315
325
 
326
+ /**
327
+ * Rebase toplevel-relative porcelain paths onto the SESSION ROOT.
328
+ *
329
+ * `git status --porcelain` names paths relative to the git TOPLEVEL, while
330
+ * `allowedPaths`, the ignore list and the manifest path are relative to the
331
+ * session root — since #1492 the LAUNCH DIR, which in a session started from a
332
+ * repo subdirectory (`<mono>/packages/foo`) sits below the toplevel. Judged
333
+ * unrebased, an in-scope write to `packages/foo/src/new.mjs` failed `src/**`
334
+ * (false positive) and a write to `<mono>/src/evil.mjs` matched it (false
335
+ * negative). A path under the prefix loses it; every other path is outside the
336
+ * project root and is spelled `../…` from the session root — the same category
337
+ * enforce-scope Gate 6 denies, and a spelling no allowedPaths entry can grant
338
+ * (see {@link isOutsideSessionRoot}).
339
+ *
340
+ * @param {string[]} paths toplevel-relative, forward slashes
341
+ * @param {string} prefix session root relative to the toplevel ('' = same dir)
342
+ * @returns {string[]}
343
+ */
344
+ export function rebaseToSessionRoot(paths, prefix) {
345
+ const clean = typeof prefix === 'string' ? prefix.replace(/\/+$/, '') : '';
346
+ // '' = the session root IS the toplevel; '..'/absolute cannot come from
347
+ // `--show-prefix` and would make the arithmetic meaningless — leave as is.
348
+ if (clean === '' || clean.startsWith('..') || clean.startsWith('/')) return paths;
349
+ const head = `${clean}/`;
350
+ const ups = clean.split('/').map(() => '..').join('/');
351
+ return paths.map((p) => (p.startsWith(head) ? p.slice(head.length) : `${ups}/${p}`));
352
+ }
353
+
354
+ /**
355
+ * Is this (session-root-relative) path outside the project root — a `../…`
356
+ * spelling produced by {@link rebaseToSessionRoot}? Never in scope.
357
+ *
358
+ * @param {string} relPath
359
+ * @returns {boolean}
360
+ */
361
+ export function isOutsideSessionRoot(relPath) {
362
+ return relPath === '..' || relPath.startsWith('../');
363
+ }
364
+
316
365
  /**
317
366
  * Is a repo-relative path covered by the wave's `allowedPaths`?
318
367
  *
@@ -359,14 +408,11 @@ export { PEER_RECORD_PREFIX };
359
408
  * @param {unknown} wave the manifest's `wave` field
360
409
  * @returns {Array<{ id: string, files: string[] }>}
361
410
  */
362
- export function readPeerScopeRecords(stateDir, wave) {
363
- if (typeof stateDir !== 'string' || stateDir === '') return [];
364
- if (typeof wave !== 'number' || !Number.isInteger(wave) || wave <= 0) return [];
411
+ export function readPeerScopeRecords(stateDir, wave, raw = readPeerSidecarRaw(stateDir, wave)) {
412
+ if (typeof raw !== 'string') return [];
365
413
  let parsed;
366
414
  try {
367
- parsed = JSON.parse(
368
- readFileSync(path.join(stateDir, 'filescopes', `wave-${wave}.scopes.json`), 'utf8'),
369
- );
415
+ parsed = JSON.parse(raw);
370
416
  } catch {
371
417
  return [];
372
418
  }
@@ -380,18 +426,115 @@ export function readPeerScopeRecords(stateDir, wave) {
380
426
  .map((r) => ({ id: r.id, files: r.files.filter((f) => typeof f === 'string') }));
381
427
  }
382
428
 
429
+ /**
430
+ * The raw bytes of a wave's aggregate scope sidecar, or null (absent,
431
+ * unreadable, or no usable state dir / wave). Read ONCE per call so the bytes
432
+ * that are hashed ({@link bindPeerSidecar}) are the bytes that are parsed.
433
+ *
434
+ * @param {string} stateDir
435
+ * @param {unknown} wave
436
+ * @returns {string|null}
437
+ */
438
+ export function readPeerSidecarRaw(stateDir, wave) {
439
+ if (typeof stateDir !== 'string' || stateDir === '') return null;
440
+ if (typeof wave !== 'number' || !Number.isInteger(wave) || wave <= 0) return null;
441
+ try {
442
+ return readFileSync(path.join(stateDir, 'filescopes', `wave-${wave}.scopes.json`), 'utf8');
443
+ } catch {
444
+ return null;
445
+ }
446
+ }
447
+
448
+ /**
449
+ * Bind the aggregate sidecar's content to the manifest it was written for
450
+ * (#1504 point 5). The sidecar carried no integrity anchor: a `peer-session-*`
451
+ * record appended AFTER dispatch downgraded matching violations to a peer-write
452
+ * notice, because the control hash covers only `wave-scope.json`.
453
+ *
454
+ * The anchor is the trusted snapshot's `scopeState` — the one record this hook
455
+ * already treats as control state. The coordinator's order is materialize →
456
+ * assert-disjoint → union → write manifest, so the sidecar is final by the time
457
+ * the manifest is; the hash is therefore (re)bound whenever the MANIFEST hash
458
+ * changes (or no binding exists yet), and must hold while the manifest stands.
459
+ * No coordinator step and no new field: a materializer-written digest would sit
460
+ * in a file the same actor can rewrite, which binds nothing.
461
+ *
462
+ * manifest changed / no prior binding → bind the current hash, honour records
463
+ * same manifest, same sidecar hash → honour records
464
+ * same manifest, sidecar now absent → nothing to honour; keep the binding so
465
+ * a re-created sidecar still mismatches
466
+ * same manifest, DIFFERENT sidecar → `tampered`: honour NO peer record
467
+ * (fail toward reporting) and keep the
468
+ * ORIGINAL binding until the manifest is
469
+ * rewritten; the notice fires ONCE per
470
+ * mismatching sidecar hash (`notify`)
471
+ *
472
+ * Once, not per call: a legitimate mid-wave Peer-Scope-Union rewrites the sidecar
473
+ * while `--union` (which excludes peer records) leaves the manifest bytes
474
+ * unchanged, so `tampered` holds for the rest of the wave. Repeating the ⚠ on
475
+ * every Bash call would be an always-on signal (HR-101). The marker
476
+ * `sidecarNotified` is persisted in the trusted snapshot by the caller only on
477
+ * the path that emits; a further change of the sidecar (a new hash) notifies again.
478
+ *
479
+ * A snapshot written before this field existed has no `sidecarHash` and binds
480
+ * silently on its first call — the same one-time adoption the control hash had.
481
+ *
482
+ * NAMED CEILING: a sidecar changed BEFORE this hook first observes the manifest
483
+ * (no trusted snapshot yet) is bound as found; so is one rewritten together with
484
+ * the manifest (that path already raises the control-file notice). A forged
485
+ * COMPLETE snapshot record is believed — `trustedScopeState`'s residual.
486
+ *
487
+ * @param {{ hash: string, sidecarHash?: unknown }|null} prevScopeState trusted prior state
488
+ * @param {string} manifestHash this call's manifest hash
489
+ * @param {string} sidecarHash this call's sidecar hash, or 'absent'
490
+ * @returns {{ sidecarHash: string, tampered: boolean, notify: boolean, sidecarNotified?: string }}
491
+ * the hash to persist, the verdict, whether to emit the mismatch notice, and
492
+ * (when tampered) the marker to persist once it has been emitted
493
+ */
494
+ export function bindPeerSidecar(prevScopeState, manifestHash, sidecarHash) {
495
+ const bound = prevScopeState?.sidecarHash;
496
+ if (!prevScopeState || prevScopeState.hash !== manifestHash || typeof bound !== 'string') {
497
+ return { sidecarHash, tampered: false, notify: false };
498
+ }
499
+ if (bound === sidecarHash || sidecarHash === 'absent') {
500
+ return { sidecarHash: bound, tampered: false, notify: false };
501
+ }
502
+ return {
503
+ sidecarHash: bound,
504
+ tampered: true,
505
+ notify: prevScopeState.sidecarNotified !== sidecarHash,
506
+ sidecarNotified: sidecarHash,
507
+ };
508
+ }
509
+
510
+ /**
511
+ * Render the sidecar-mismatch notice (#1504 point 5) — an alarm (⚠), because the
512
+ * consequence is that agreed peer writes now report as violations.
513
+ *
514
+ * @param {string} relSidecarPath
515
+ * @returns {string}
516
+ */
517
+ export function formatSidecarMismatchNotice(relSidecarPath) {
518
+ return `bash-write-verify: ${relSidecarPath} changed since wave-scope.json was written — `
519
+ + 'its peer-session-* records are NOT honoured (writes under them report as violations) '
520
+ + 'until the manifest is rewritten (#1504 point 5).';
521
+ }
522
+
383
523
  /**
384
524
  * Which peer record — if any — declared this path?
385
525
  *
386
526
  * Same matcher as `isInScope`, so a peer's declaration is read exactly the way
387
- * the wave's own `allowedPaths` are.
527
+ * the wave's own `allowedPaths` are — and the same outside-root guard as
528
+ * `computeReport`: a `../…` path is outside the project root, which no peer
529
+ * record can grant any more than `allowedPaths` can (a peer glob over every
530
+ * `.md` file would otherwise file `<mono>/NOTES.md` as an agreed peer write).
388
531
  *
389
532
  * @param {string} relPath
390
533
  * @param {Array<{ id: string, files: string[] }>} peerRecords
391
534
  * @returns {string|null} the peer record id, or null
392
535
  */
393
536
  export function peerRecordFor(relPath, peerRecords) {
394
- if (!Array.isArray(peerRecords)) return null;
537
+ if (!Array.isArray(peerRecords) || isOutsideSessionRoot(relPath)) return null;
395
538
  for (const record of peerRecords) {
396
539
  if (record.files.some((p) => pathMatchesPattern(relPath, p))) return record.id;
397
540
  }
@@ -466,14 +609,22 @@ export function snapshotPathFor(repoRoot) {
466
609
  export function computeReport({ dirtyPaths, allowedPaths, snapshot, signature, scopeMtimeMs = null, mtimeMs = null, scopeRelPath = null }) {
467
610
  const outOfScope = dirtyPaths
468
611
  .filter((p) => !isIgnoredPath(p))
469
- .filter((p) => !isInScope(p, allowedPaths))
612
+ // A `../…` path (outside the project root, see rebaseToSessionRoot) is never
613
+ // in scope — not even under a broad glob that would happen to match it.
614
+ .filter((p) => isOutsideSessionRoot(p) || !isInScope(p, allowedPaths))
470
615
  // The wave-scope.json control file is out-of-scope by construction (it is
471
616
  // never under allowedPaths) but its CHANGES are reported via the content-hash
472
617
  // control-notice path, not here. Excluding it keeps the mtime re-baseline
473
618
  // filter from reporting the scope file against ITSELF — its mtime always
474
619
  // equals scopeMtimeMs, so the MED-3 `>=` boundary would otherwise flag it on
475
620
  // every first run (#938 MED-3 follow-through).
476
- .filter((p) => p !== scopeRelPath);
621
+ .filter((p) => p !== scopeRelPath)
622
+ // Its sibling control state, `<state-dir>/filescopes/` (the per-agent scope
623
+ // declarations `materialize-wave-scope.mjs` writes once per wave), is no
624
+ // wave territory either — excluded the same way, not via
625
+ // IGNORED_PATH_PATTERNS, so the exemption follows THIS manifest's state dir
626
+ // instead of granting all four harness dirs (#1027 Pkt 5).
627
+ .filter((p) => !isScopeDeclarationPath(p, scopeRelPath));
477
628
 
478
629
  const rebaselined = !snapshot || snapshot.signature !== signature;
479
630
  const seen = rebaselined || !Array.isArray(snapshot?.paths) ? new Set() : new Set(snapshot.paths);
@@ -515,7 +666,9 @@ export function computeReport({ dirtyPaths, allowedPaths, snapshot, signature, s
515
666
  * @returns {string}
516
667
  */
517
668
  export function formatMessage(report, allowedCount) {
518
- const shown = report.slice(0, MAX_REPORTED_PATHS);
669
+ const shown = report
670
+ .slice(0, MAX_REPORTED_PATHS)
671
+ .map((p) => (isOutsideSessionRoot(p) ? `${p} (outside project root)` : p));
519
672
  const more = report.length - shown.length;
520
673
  const tail = more > 0 ? ` (+${more} more)` : '';
521
674
  const msg =
@@ -608,12 +761,13 @@ export function formatSnapshotMissingNotice({ sessionAge = null, scopeAge = null
608
761
  // ---------------------------------------------------------------------------
609
762
 
610
763
  /**
611
- * Collect the working-tree dirty set. Returns null when git is unavailable, the
764
+ * Collect the working-tree dirty set, rebased onto the session root
765
+ * ({@link rebaseToSessionRoot}). Returns null when git is unavailable, the
612
766
  * directory is not a repo, or the call times out — all of which mean "no signal
613
767
  * to report", never a warning.
614
768
  *
615
- * @param {string} repoRoot
616
- * @returns {string[]|null}
769
+ * @param {string} repoRoot the session root
770
+ * @returns {string[]|null} session-root-relative paths (`../…` = outside it)
617
771
  */
618
772
  function readDirtyPaths(repoRoot) {
619
773
  try {
@@ -630,7 +784,16 @@ function readDirtyPaths(repoRoot) {
630
784
  ['--no-optional-locks', 'status', '--porcelain', '-z', '--no-renames', '--untracked-files=all'],
631
785
  { cwd: repoRoot, encoding: 'utf8', timeout: GIT_TIMEOUT_MS, maxBuffer: GIT_MAX_BUFFER, stdio: ['ignore', 'pipe', 'ignore'] },
632
786
  );
633
- return parsePorcelainZ(raw);
787
+ // Porcelain paths are toplevel-relative; everything they are judged against
788
+ // is session-root-relative. A `.git` AT the session root makes it the
789
+ // toplevel (prefix ''), so the extra spawn is paid only by a session
790
+ // launched from a repo subdirectory.
791
+ const prefix = existsSync(path.join(repoRoot, '.git'))
792
+ ? ''
793
+ : execFileSync('git', ['rev-parse', '--show-prefix'], {
794
+ cwd: repoRoot, encoding: 'utf8', timeout: GIT_TIMEOUT_MS, stdio: ['ignore', 'pipe', 'ignore'],
795
+ }).trim();
796
+ return rebaseToSessionRoot(parsePorcelainZ(raw), prefix);
634
797
  } catch {
635
798
  return null;
636
799
  }
@@ -807,7 +970,10 @@ async function main() {
807
970
  // G2 — only Bash calls carry the bypass risk this hook watches.
808
971
  if (input.tool_name !== 'Bash') return;
809
972
 
810
- const repoRootRaw = resolveProjectDir();
973
+ // #1492 — the SESSION's working copy, not the launch dir `$CLAUDE_PROJECT_DIR`
974
+ // keeps after `EnterWorktree` (the manifest lives in the worktree). See
975
+ // `resolveSessionRoot` in scripts/lib/platform.mjs.
976
+ const repoRootRaw = resolveSessionRoot(input.cwd);
811
977
  let repoRoot;
812
978
  try {
813
979
  repoRoot = realpathSync(repoRootRaw);
@@ -833,7 +999,14 @@ async function main() {
833
999
  // G3 — no wave scope → nothing defines "outside". #938: if a previous call
834
1000
  // RECORDED a scope state, the control file's disappearance is itself a
835
1001
  // control-file change and gets one visible (non-alarming) notice.
836
- const scopePath = findScopeFile(repoRoot);
1002
+ //
1003
+ // #1504 point 6 — the first manifest that is not provably a peer's, so a
1004
+ // peer's higher-precedence manifest no longer hides this session's own one;
1005
+ // only when every candidate is foreign does the first come back, and G3b
1006
+ // below keeps its stand-down and its rebind notice unchanged.
1007
+ const ownIds = new Set(readProcessLocalSessionIds({ hookInput: input }));
1008
+ const located = findOwnScopeFile(repoRoot, ownIds, classifyManifestSession);
1009
+ const scopePath = located.path ?? located.foreignPath;
837
1010
  if (!scopePath) {
838
1011
  if (prevScopeState && prevScopeState.hash !== 'absent') {
839
1012
  writeSnapshot(snapFile, {
@@ -857,7 +1030,7 @@ async function main() {
857
1030
  } catch {
858
1031
  /* raw stays null (unreadable) or scope stays null (unparseable) */
859
1032
  }
860
- if (raw === null) return; // file vanished between findScopeFile and read
1033
+ if (raw === null) return; // file vanished between findOwnScopeFile and read
861
1034
 
862
1035
  // The control-file identity of THIS call, computed BEFORE the G3b
863
1036
  // stand-down (W4/F7). Ordering is the whole fix: G3b used to `return` above
@@ -878,6 +1051,16 @@ async function main() {
878
1051
  : 'unparseable',
879
1052
  gateOn: scope ? scope?.gates?.['bash-write-verify'] !== false : true,
880
1053
  };
1054
+ // #1504 point 5 — bind the aggregate sidecar to this manifest (see
1055
+ // `bindPeerSidecar`). Part of `currentScopeState` so EVERY snapshot write
1056
+ // below, stand-down paths included, carries the binding forward.
1057
+ const sidecarRaw = readPeerSidecarRaw(path.dirname(scopePath), scope?.wave);
1058
+ const sidecarBinding = bindPeerSidecar(
1059
+ prevScopeState,
1060
+ currentScopeState.hash,
1061
+ sidecarRaw === null ? 'absent' : createHash('sha1').update(sidecarRaw).digest('hex').slice(0, 16),
1062
+ );
1063
+ currentScopeState.sidecarHash = sidecarBinding.sidecarHash;
881
1064
 
882
1065
  // G3b (#1153 P1) — is this manifest even MINE?
883
1066
  //
@@ -898,7 +1081,6 @@ async function main() {
898
1081
  // CLI, Cursor today) the own-id set is empty, so this gate is permanently
899
1082
  // `'unknown'` = full pre-#1153 behaviour there.
900
1083
  {
901
- const ownIds = new Set(readProcessLocalSessionIds({ hookInput: input }));
902
1084
  const { verdict, manifestIds } = classifyManifestSession(scope ?? {}, ownIds);
903
1085
  if (verdict === 'foreign') {
904
1086
  // Observability only — one event per stand-down decision, awaited so the
@@ -1072,6 +1254,9 @@ async function main() {
1072
1254
  },
1073
1255
  });
1074
1256
 
1257
+ // The mismatch notice below is emitted on this path only, so its once-marker
1258
+ // is persisted here and nowhere else (see `bindPeerSidecar`).
1259
+ if (sidecarBinding.tampered) currentScopeState.sidecarNotified = sidecarBinding.sidecarNotified;
1075
1260
  writeSnapshot(snapFile, { ...nextSnapshot, scopeState: currentScopeState });
1076
1261
 
1077
1262
  const messages = [];
@@ -1091,7 +1276,17 @@ async function main() {
1091
1276
  // reachable. Such a write is agreed, not a bypass. Split it out of the violation report and
1092
1277
  // name it, so the peer's file is countable without being an alarm. Sidecar
1093
1278
  // absent ⇒ `peerRecords` is empty ⇒ every path stays a violation, unchanged.
1094
- const peerRecords = readPeerScopeRecords(path.dirname(scopePath), scope.wave);
1279
+ // #1504 point 5 — a sidecar changed under a standing manifest is untrusted:
1280
+ // no peer record is honoured, and the mismatch is named.
1281
+ const peerRecords = sidecarBinding.tampered
1282
+ ? []
1283
+ : readPeerScopeRecords(path.dirname(scopePath), scope.wave, sidecarRaw);
1284
+ if (sidecarBinding.notify) {
1285
+ messages.push(formatSidecarMismatchNotice(
1286
+ path.relative(repoRoot, path.join(path.dirname(scopePath), 'filescopes', `wave-${scope.wave}.scopes.json`)),
1287
+ ));
1288
+ warn = true;
1289
+ }
1095
1290
  const violations = [];
1096
1291
  for (const relPath of report) {
1097
1292
  const peerId = peerRecordFor(relPath, peerRecords);
@@ -35,8 +35,13 @@ import { shouldRunHook } from './_lib/profile-gate.mjs';
35
35
  import { isMainModule } from '../scripts/lib/is-main-module.mjs';
36
36
 
37
37
  import { readStdin } from '../scripts/lib/io.mjs';
38
- import { resolveProjectDir } from '../scripts/lib/platform.mjs';
39
- import { findScopeFile, gateEnabled } from '../scripts/lib/hardening.mjs';
38
+ import { resolveSessionRoot } from '../scripts/lib/platform.mjs';
39
+ import { gateEnabled } from '../scripts/lib/hardening.mjs';
40
+ import { findOwnScopeFile } from '../scripts/lib/scope-gate.mjs';
41
+ import {
42
+ classifyManifestSession,
43
+ readProcessLocalSessionIds,
44
+ } from '../scripts/lib/session-identity/own-session.mjs';
40
45
  import { readConfigFile, parseSessionConfig } from '../scripts/lib/config.mjs';
41
46
 
42
47
  // ---------------------------------------------------------------------------
@@ -184,7 +189,9 @@ async function main() {
184
189
  const ext = path.extname(filePath).toLowerCase();
185
190
  if (!TS_EXTS.has(ext)) process.exit(0);
186
191
 
187
- const projectRoot = resolveProjectDir();
192
+ // The SESSION root the scope guards use (#1492, #1504 point 4) — not the
193
+ // launch dir, which stays put while a session works in an entered worktree.
194
+ const projectRoot = resolveSessionRoot(input?.cwd);
188
195
 
189
196
  // Compute relative path (REQ-03)
190
197
  const absFilePath = path.isAbsolute(filePath)
@@ -192,8 +199,11 @@ async function main() {
192
199
  : path.resolve(projectRoot, filePath);
193
200
  const relPath = path.relative(projectRoot, absFilePath);
194
201
 
195
- // G5: per-gate toggle — skip if post-edit-validate gate is disabled
196
- const scopeFile = findScopeFile(projectRoot);
202
+ // G5: per-gate toggle — skip if post-edit-validate gate is disabled. Only
203
+ // THIS session's manifest may switch it off: a peer's manifest in the same
204
+ // working copy is skipped, and a peer-only result means no toggle (#1504).
205
+ const ownIds = new Set(readProcessLocalSessionIds({ hookInput: input }));
206
+ const { path: scopeFile } = findOwnScopeFile(projectRoot, ownIds, classifyManifestSession);
197
207
  if (scopeFile && !gateEnabled(scopeFile, 'post-edit-validate')) {
198
208
  process.exit(0);
199
209
  }