session-orchestrator 5.9.0 → 5.11.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 (78) 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-plugin/plugin.json +1 -1
  5. package/AGENTS.md +2 -0
  6. package/CHANGELOG.md +65 -2
  7. package/README.md +11 -9
  8. package/SECURITY.md +1 -1
  9. package/agents/code-implementer.md +7 -1
  10. package/docs/USER-GUIDE.md +2 -0
  11. package/docs/agent-authoring.md +2 -0
  12. package/docs/ci-setup.md +46 -1
  13. package/docs/events-schema.md +37 -6
  14. package/docs/fleet-checkin-audit.md +172 -0
  15. package/docs/owner-config-schema.md +18 -1
  16. package/docs/owner-privacy-patterns.md +85 -0
  17. package/docs/role-profile-contract.md +139 -0
  18. package/docs/scope-collision-guard.md +15 -2
  19. package/docs/session-config-reference.md +1 -1
  20. package/evals/instruction-ablation/run.mjs +11 -6
  21. package/hooks/_lib/hook-import-set.json +27 -2
  22. package/hooks/_lib/orchestrator-ignore.mjs +112 -0
  23. package/hooks/enforce-commands.mjs +9 -19
  24. package/hooks/enforce-scope.mjs +23 -42
  25. package/hooks/hooks-codex.json +1 -1
  26. package/hooks/hooks.json +1 -1
  27. package/hooks/on-session-end.mjs +8 -1
  28. package/hooks/on-session-start.mjs +7 -0
  29. package/hooks/post-bash-write-verify.mjs +54 -24
  30. package/hooks/pre-task-scope-disjoint.mjs +12 -0
  31. package/hooks/subagent-telemetry.mjs +8 -3
  32. package/hooks/wave-scope-commit-guard.mjs +4 -1
  33. package/package.json +2 -2
  34. package/rules/opt-in-stack/security-web.md +1 -1
  35. package/scripts/emit-session.mjs +28 -0
  36. package/scripts/fleet-checkin-audit.mjs +138 -0
  37. package/scripts/lib/common.mjs +2 -4
  38. package/scripts/lib/context-overhead-format.mjs +57 -0
  39. package/scripts/lib/fleet-checkin-audit.mjs +244 -0
  40. package/scripts/lib/gates/gate-full.mjs +6 -0
  41. package/scripts/lib/gates/gate-helpers.mjs +2 -0
  42. package/scripts/lib/memory-proposals/sink.mjs +79 -17
  43. package/scripts/lib/memory-proposals/store.mjs +245 -9
  44. package/scripts/lib/named-vault-resolver.mjs +1 -1
  45. package/scripts/lib/owner-yaml.mjs +33 -3
  46. package/scripts/lib/platform.mjs +70 -1
  47. package/scripts/lib/pre-push-remote-gate.mjs +107 -0
  48. package/scripts/lib/project-hygiene.mjs +37 -0
  49. package/scripts/lib/quality-gate.mjs +6 -1
  50. package/scripts/lib/redact-spans.mjs +8 -17
  51. package/scripts/lib/role-profile/schema.mjs +219 -0
  52. package/scripts/lib/scope-gate.mjs +68 -5
  53. package/scripts/lib/session-lock.mjs +57 -10
  54. package/scripts/lib/session-record-repair.mjs +63 -30
  55. package/scripts/lib/session-schema/constants.mjs +8 -0
  56. package/scripts/lib/session-schema/validator.mjs +33 -0
  57. package/scripts/lib/subagents-schema.mjs +9 -0
  58. package/scripts/lib/telemetry/pricing.mjs +11 -0
  59. package/scripts/lib/validate/check-owner-leakage.mjs +198 -1015
  60. package/scripts/lib/vault-backfill/template.mjs +1 -1
  61. package/scripts/lib/vault-mirror/render-sessions.mjs +15 -3
  62. package/scripts/lib/vault-status/board-writer.mjs +15 -7
  63. package/scripts/measure-context-overhead.sh +4 -21
  64. package/scripts/migrate-vault-paths.mjs +57 -60
  65. package/scripts/release.mjs +463 -48
  66. package/scripts/run-quality-gate.mjs +47 -0
  67. package/scripts/validate-wave-scope.mjs +59 -6
  68. package/scripts/vault-consolidate.mjs +19 -4
  69. package/skills/claude-md-drift-check/SKILL.md +1 -1
  70. package/skills/claude-md-drift-check/checker.mjs +22 -9
  71. package/skills/npm-publish/SKILL.md +3 -3
  72. package/skills/release/SKILL.md +2 -2
  73. package/skills/repo-audit/SKILL.md +1 -1
  74. package/skills/session-end/metrics-collection.md +28 -4
  75. package/skills/session-end/session-metrics-write.md +1 -1
  76. package/skills/session-start/references/phase-1-2-session-lock.md +1 -1
  77. package/skills/wave-executor/circuit-breaker.md +1 -0
  78. package/skills/wave-executor/references/wave-loop-review.md +26 -2
@@ -107,12 +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 resolveSessionRoot;
111
110
  // The launch dir the harness states — platform.mjs's one definition of the env
112
111
  // chain, so the relocation trace below cannot drift from the clamp (#1504).
113
112
  let launchDirFromEnv;
114
113
  // #1504 point 6 — the manifest that governs THIS session; bound from scope-gate below.
115
114
  let findOwnScopeFile;
115
+ let resolveScopeContext;
116
116
  let pathMatchesPattern;
117
117
  let suggestForScopeViolation;
118
118
  let readJson;
@@ -202,7 +202,7 @@ async function bootstrap() {
202
202
 
203
203
  ({ readStdin, emitAllow, emitDeny, emitWarn } = modules.io);
204
204
  ({ isPathInside, relativeFromRoot } = modules.pathUtils);
205
- ({ resolveProjectDir, resolveSessionRoot, launchDirFromEnv } = modules.platform);
205
+ ({ resolveProjectDir, launchDirFromEnv } = modules.platform);
206
206
  ({ pathMatchesPattern, suggestForScopeViolation } = modules.hardening);
207
207
  ({ readJson } = modules.common);
208
208
  ({
@@ -212,6 +212,7 @@ async function bootstrap() {
212
212
  gradeScopeEntry,
213
213
  canonicalizeGrantPrefix,
214
214
  findOwnScopeFile,
215
+ resolveScopeContext,
215
216
  } = modules.scopeGate);
216
217
  ({ readProcessLocalSessionIds, classifyManifestSession } = modules.sessionIdentity);
217
218
  }
@@ -230,38 +231,18 @@ async function main() {
230
231
  // Gate 2: file_path must be a non-empty string
231
232
  if (!filePath || typeof filePath !== 'string') return emitAllow();
232
233
 
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);
240
-
241
- // Resolve symlinks in the project root itself so that realpath(file) comparisons
242
- // are consistent. On macOS /tmp → /private/tmp; mismatches would cause false denials.
243
- let projectRoot;
244
- try {
245
- projectRoot = await fs.realpath(projectRootRaw);
246
- } catch {
247
- projectRoot = projectRootRaw;
248
- }
249
-
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).
234
+ // Control state follows the session coordinator; relative grants follow
235
+ // the actual isolated agent checkout. Entered coordinator worktrees are
236
+ // discovered only through bounded reciprocal Git metadata and own identity.
254
237
  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);
238
+ const context = resolveScopeContext({ cwd: input.cwd, ownIds, classify: classifyManifestSession });
239
+ if (context.error) return emitDeny('Scope coordinator cannot be resolved', context.error);
240
+ const projectRootRaw = context.writeRoot;
241
+ let projectRoot;
242
+ try { projectRoot = await fs.realpath(projectRootRaw); }
243
+ catch { projectRoot = projectRootRaw; }
244
+ const manifestRoot = context.manifestRoot;
245
+ const located = context;
265
246
  const scopePath = located.path ?? located.foreignPath;
266
247
  if (!scopePath) {
267
248
  await recordHiddenManifestOncePerWave({ projectRootRaw, projectRoot, ownIds, filePath });
@@ -354,7 +335,7 @@ async function main() {
354
335
  wave: scope.wave,
355
336
  file_path: filePath,
356
337
  },
357
- { repoRoot: projectRoot },
338
+ { repoRoot: manifestRoot },
358
339
  );
359
340
  } catch { /* observability is best-effort — never blocks the decision */ }
360
341
  return emitAllow();
@@ -395,10 +376,10 @@ async function main() {
395
376
  role: scope.role,
396
377
  parseOk,
397
378
  scopeMtimeMs: await mtimeMsOf(scopePath),
398
- sessionStartMs: sessionStartedAtMs(projectRoot),
379
+ sessionStartMs: sessionStartedAtMs(manifestRoot),
399
380
  })
400
381
  : null;
401
- const scopeRelRaw = relativeFromRoot(projectRoot, scopePath);
382
+ const scopeRelRaw = relativeFromRoot(manifestRoot, scopePath);
402
383
  const scopeHint = (scopeRelRaw ?? scopePath).split(path.sep).join('/');
403
384
 
404
385
  /**
@@ -514,7 +495,7 @@ async function main() {
514
495
  if (matchedGrant !== null) {
515
496
  const grade = gradeScopeEntry(matchedGrant, { resolve: canonicalizeGrantPrefix });
516
497
  if (grade?.verdict === 'error') {
517
- await recordRefusedGrantOncePerWave({ scope, scopePath, projectRoot, input, grant: matchedGrant, grade, filePath: resolvedPath });
498
+ await recordRefusedGrantOncePerWave({ scope, scopePath, projectRoot: manifestRoot, input, grant: matchedGrant, grade, filePath: resolvedPath });
518
499
  return emitWarn(
519
500
  `Gate 5b honoured an out-of-repo grant that scripts/validate-wave-scope.mjs would REFUSE ` +
520
501
  `before dispatch — allowedPaths ${grade.message}. The write to '${resolvedPath}' is ` +
@@ -566,9 +547,9 @@ async function main() {
566
547
  // not grant the path (Discovery's `allowedPaths: []`) that is a DENY.
567
548
  // #1492: the LAUNCH dir too — the harness keys auto-memory on the path it
568
549
  // was launched with, which after `EnterWorktree` is no longer the root.
569
- const memoryDirs = await ownMemoryDirs([resolveProjectDir(), projectRootRaw, projectRoot]);
550
+ const memoryDirs = await ownMemoryDirs([resolveProjectDir(), manifestRoot]);
570
551
  const caller = classifyCaller(input);
571
- if (caller !== 'subagent' && memoryDirs.some((dir) => isInsideDir(resolvedPath, dir))) {
552
+ if (!context.isolated && caller !== 'subagent' && memoryDirs.some((dir) => isInsideDir(resolvedPath, dir))) {
572
553
  // One event per decision point, awaited before emitAllow() —
573
554
  // emitAllow() calls process.exit(), which would discard a pending append.
574
555
  try {
@@ -589,7 +570,7 @@ async function main() {
589
570
  // `'coordinator'` = `agent_type` without `agent_id`.
590
571
  discriminator: caller,
591
572
  },
592
- { repoRoot: projectRoot },
573
+ { repoRoot: manifestRoot },
593
574
  );
594
575
  } catch { /* observability is best-effort — never blocks the decision */ }
595
576
  return emitAllow();
@@ -642,7 +623,7 @@ async function main() {
642
623
  // `emitAllow()` (`process.exit(0)`, no log, no event), so whether it ever fired
643
624
  // was unfalsifiable after the fact.
644
625
  const carveoutCaller = classifyCaller(input);
645
- if (carveoutCaller !== 'subagent' && isCoordinatorCarveout(normalizedRel, projectRoot, scopePath)) {
626
+ if (!context.isolated && carveoutCaller !== 'subagent' && isCoordinatorCarveout(normalizedRel, projectRoot, scopePath)) {
646
627
  // One event per decision point, awaited before emitAllow() — emitAllow()
647
628
  // calls process.exit(), which would discard a pending append.
648
629
  try {
@@ -659,7 +640,7 @@ async function main() {
659
640
  file_path: normalizedRel,
660
641
  discriminator: carveoutCaller,
661
642
  },
662
- { repoRoot: projectRoot },
643
+ { repoRoot: manifestRoot },
663
644
  );
664
645
  } catch { /* observability is best-effort — never blocks the decision */ }
665
646
  return emitAllow();
@@ -7,7 +7,7 @@
7
7
  "hooks": [
8
8
  {
9
9
  "type": "command",
10
- "command": "echo '🎯 Session Orchestrator v5.9.0 — /session [housekeeping|feature|deep] | /plan [new|feature|retro] | /discovery [scope] | /evolve [analyze|review|list]'",
10
+ "command": "echo '🎯 Session Orchestrator v5.11.0 — /session [housekeeping|feature|deep] | /plan [new|feature|retro] | /discovery [scope] | /evolve [analyze|review|list]'",
11
11
  "async": false
12
12
  },
13
13
  {
package/hooks/hooks.json CHANGED
@@ -6,7 +6,7 @@
6
6
  "hooks": [
7
7
  {
8
8
  "type": "command",
9
- "command": "echo '🎯 Session Orchestrator v5.9.0 — /session [housekeeping|feature|deep] | /plan [new|feature|retro] | /discovery [scope] | /evolve [analyze|review|list]'",
9
+ "command": "echo '🎯 Session Orchestrator v5.11.0 — /session [housekeeping|feature|deep] | /plan [new|feature|retro] | /discovery [scope] | /evolve [analyze|review|list]'",
10
10
  "async": false
11
11
  },
12
12
  {
@@ -43,6 +43,7 @@ import {
43
43
  release,
44
44
  loadOwnerProof,
45
45
  OWNER_PROOF_RELPATH,
46
+ isLockOwnedByProof,
46
47
  } from '../scripts/lib/session-lock.mjs';
47
48
  import { parseSessionId } from '../scripts/lib/session-id.mjs';
48
49
  import { isMainModule } from '../scripts/lib/is-main-module.mjs';
@@ -789,8 +790,14 @@ async function main() {
789
790
  // millisecond started_at cannot match any FUTURE lock, so it
790
791
  // self-invalidates), this just avoids the stale artifact. ENOENT
791
792
  // (no proof was ever written) lands in the same swallow.
793
+ // Only when the proof still describes the lock we released: since
794
+ // #1541 every acquire() rewrites it, so a successor that acquired
795
+ // during the awaited emit above owns a fresh proof we must not
796
+ // remove. A narrow window between this read and the unlink remains.
792
797
  try {
793
- await fs.unlink(path.join(projectRoot, OWNER_PROOF_RELPATH));
798
+ if (isLockOwnedByProof(lock, loadOwnerProof({ repoRoot: projectRoot }))) {
799
+ await fs.unlink(path.join(projectRoot, OWNER_PROOF_RELPATH));
800
+ }
794
801
  } catch { /* best-effort — a leftover proof is self-invalidating */ }
795
802
  }
796
803
  } else {
@@ -36,6 +36,7 @@ import { randomUUID } from 'node:crypto';
36
36
  import path from 'node:path';
37
37
 
38
38
  import { shouldRunHook } from './_lib/profile-gate.mjs';
39
+ import { ensureOrchestratorIgnore } from './_lib/orchestrator-ignore.mjs';
39
40
 
40
41
  import { emitEvent, eventsFilePath } from '../scripts/lib/events.mjs';
41
42
  import { readNavigatorLease } from '../scripts/lib/fleet-protocol.mjs';
@@ -781,6 +782,12 @@ function maybeQueueConsentNudge() {
781
782
  async function main() {
782
783
  const projectRoot = resolveProjectDir();
783
784
 
785
+ // #1515 Pkt 5 — before this hook's own writers (host.json, session.lock,
786
+ // events.jsonl) run: an un-bootstrapped repo gets `.orchestrator/.gitignore`
787
+ // so the runtime state never shows up in `git status`. Bootstrapped repos
788
+ // pay two existsSync and nothing else. Never throws.
789
+ await ensureOrchestratorIgnore(projectRoot);
790
+
784
791
  // Read optional stdin payload in parallel with git info so we don't stall
785
792
  // the hook. Both are best-effort and the promise races against a 500 ms cap
786
793
  // inside readStdinJson itself.
@@ -156,7 +156,6 @@ import { execFileSync } from 'node:child_process';
156
156
  import { existsSync, readFileSync, mkdirSync, writeFileSync, renameSync, realpathSync, statSync } from 'node:fs';
157
157
 
158
158
  import { readStdin, writeStdoutLineSync } from '../scripts/lib/io.mjs';
159
- import { resolveSessionRoot } from '../scripts/lib/platform.mjs';
160
159
  import { pathMatchesPattern } from '../scripts/lib/hardening.mjs';
161
160
  // #1057 — `sessionAgeMs` and its private `clockAgeMs` helper MOVED to the lib so
162
161
  // hooks/enforce-scope.mjs can read the same session clock without a hook->hook
@@ -169,7 +168,8 @@ import {
169
168
  PEER_RECORD_PREFIX,
170
169
  isPeerRecordId,
171
170
  isScopeDeclarationPath,
172
- findOwnScopeFile,
171
+ resolveScopeContext,
172
+ canonicalizeGrantPrefix,
173
173
  } from '../scripts/lib/scope-gate.mjs';
174
174
  // #1153 P1 — the same process-local ownership check hooks/enforce-scope.mjs
175
175
  // applies at Gate 3b. This hook reads the SAME working-copy `wave-scope.json`,
@@ -371,11 +371,23 @@ export function isOutsideSessionRoot(relPath) {
371
371
  *
372
372
  * @param {string} relPath
373
373
  * @param {string[]} allowedPaths
374
+ * @param {string|null} [writeRoot] actual checkout root for absolute grants
374
375
  * @returns {boolean}
375
376
  */
376
- export function isInScope(relPath, allowedPaths) {
377
+ export function isInScope(relPath, allowedPaths, writeRoot = null) {
377
378
  if (!Array.isArray(allowedPaths) || allowedPaths.length === 0) return false;
378
- return allowedPaths.some((p) => typeof p === 'string' && pathMatchesPattern(relPath, p));
379
+ return allowedPaths.some((p) => {
380
+ if (typeof p !== 'string') return false;
381
+ if (!path.isAbsolute(p)) return pathMatchesPattern(relPath, p);
382
+ if (typeof writeRoot !== 'string' || !path.isAbsolute(writeRoot) || path.isAbsolute(relPath) || isOutsideSessionRoot(relPath)) return false;
383
+ // Match the canonical target, as PreToolUse does, but never let an absolute
384
+ // grant suppress reporting for a path escaping the observed checkout.
385
+ const root = canonicalizeGrantPrefix(writeRoot);
386
+ const target = canonicalizeGrantPrefix(path.resolve(root, relPath));
387
+ const relative = path.relative(root, target);
388
+ if (path.isAbsolute(relative) || isOutsideSessionRoot(relative.split(path.sep).join('/'))) return false;
389
+ return pathMatchesPattern(target, p);
390
+ });
379
391
  }
380
392
 
381
393
  /**
@@ -599,6 +611,7 @@ export function snapshotPathFor(repoRoot) {
599
611
  * @param {object} args
600
612
  * @param {string[]} args.dirtyPaths repo-relative paths from git status
601
613
  * @param {string[]} args.allowedPaths wave allowedPaths
614
+ * @param {string|null} [args.writeRoot] actual checkout root for absolute grants
602
615
  * @param {object|null} args.snapshot previous `{ signature, paths[] }`, or null
603
616
  * @param {string} args.signature current scope signature
604
617
  * @param {number|null} [args.scopeMtimeMs] mtime (ms) of wave-scope.json, or null
@@ -606,12 +619,12 @@ export function snapshotPathFor(repoRoot) {
606
619
  * dirty path; null = not attributable (deleted path / stat failure)
607
620
  * @returns {{ report: string[], nextSnapshot: { signature: string, paths: string[] }, rebaselined: boolean }}
608
621
  */
609
- export function computeReport({ dirtyPaths, allowedPaths, snapshot, signature, scopeMtimeMs = null, mtimeMs = null, scopeRelPath = null }) {
622
+ export function computeReport({ dirtyPaths, allowedPaths, snapshot, signature, scopeMtimeMs = null, mtimeMs = null, scopeRelPath = null, writeRoot = null }) {
610
623
  const outOfScope = dirtyPaths
611
624
  .filter((p) => !isIgnoredPath(p))
612
625
  // A `../…` path (outside the project root, see rebaseToSessionRoot) is never
613
626
  // in scope — not even under a broad glob that would happen to match it.
614
- .filter((p) => isOutsideSessionRoot(p) || !isInScope(p, allowedPaths))
627
+ .filter((p) => isOutsideSessionRoot(p) || !isInScope(p, allowedPaths, writeRoot))
615
628
  // The wave-scope.json control file is out-of-scope by construction (it is
616
629
  // never under allowedPaths) but its CHANGES are reported via the content-hash
617
630
  // control-notice path, not here. Excluding it keeps the mtime re-baseline
@@ -970,18 +983,22 @@ async function main() {
970
983
  // G2 — only Bash calls carry the bypass risk this hook watches.
971
984
  if (input.tool_name !== 'Bash') return;
972
985
 
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);
977
- let repoRoot;
978
- try {
979
- repoRoot = realpathSync(repoRootRaw);
980
- } catch {
981
- repoRoot = repoRootRaw;
986
+ // Coordinator control state and isolated-agent write targets may live in
987
+ // different working copies; the shared guard context resolves both.
988
+ const ownIds = new Set(readProcessLocalSessionIds({ hookInput: input }));
989
+ const context = resolveScopeContext({ cwd: input.cwd, ownIds, classify: classifyManifestSession });
990
+ if (context.error) {
991
+ emitMessages([`bash-write-verify: scope coordinator unresolved (${context.error})`], true);
992
+ return;
982
993
  }
983
-
984
- const snapFile = snapshotPathFor(repoRoot);
994
+ const repoRootRaw = context.writeRoot;
995
+ let repoRoot;
996
+ try { repoRoot = realpathSync(repoRootRaw); }
997
+ catch { repoRoot = repoRootRaw; }
998
+ // Isolated snapshots bind both trees: two agents never share report-once state,
999
+ // and reselecting a coordinator cannot inherit another manifest's trust.
1000
+ const snapFile = snapshotPathFor(context.isolated
1001
+ ? `${repoRoot}\0${context.manifestRoot}\0${context.path ?? context.foreignPath ?? ""}` : repoRoot);
985
1002
  const snapshot = readSnapshot(snapFile);
986
1003
  // The ONE trust decision (see `trustedScopeState`). Every downstream use of a
987
1004
  // "previous" value reads this binding, so no code path can accidentally act on
@@ -1004,8 +1021,7 @@ async function main() {
1004
1021
  // peer's higher-precedence manifest no longer hides this session's own one;
1005
1022
  // only when every candidate is foreign does the first come back, and G3b
1006
1023
  // 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);
1024
+ const located = context;
1009
1025
  const scopePath = located.path ?? located.foreignPath;
1010
1026
  if (!scopePath) {
1011
1027
  if (prevScopeState && prevScopeState.hash !== 'absent') {
@@ -1096,7 +1112,7 @@ async function main() {
1096
1112
  own_session: [...ownIds],
1097
1113
  wave: scope?.wave,
1098
1114
  },
1099
- { repoRoot },
1115
+ { repoRoot: context.manifestRoot },
1100
1116
  );
1101
1117
  } catch { /* observability is best-effort — never blocks the decision */ }
1102
1118
 
@@ -1156,7 +1172,7 @@ async function main() {
1156
1172
  // `toContain('out-of-scope.mjs')` test to say otherwise.
1157
1173
  const missingSnapshotNotice = prevScopeState === null
1158
1174
  ? formatSnapshotMissingNotice({
1159
- sessionAge: sessionAgeMs(repoRoot),
1175
+ sessionAge: sessionAgeMs(context.manifestRoot),
1160
1176
  scopeAge: typeof scopeMtimeMs === 'number' ? Date.now() - scopeMtimeMs : null,
1161
1177
  reason: snapshot === null ? 'absent' : 'untrusted',
1162
1178
  })
@@ -1239,6 +1255,7 @@ async function main() {
1239
1255
  const { report, nextSnapshot } = computeReport({
1240
1256
  dirtyPaths,
1241
1257
  allowedPaths,
1258
+ writeRoot: repoRoot,
1242
1259
  // `prevRecord`, not `snapshot`: an untrusted record's report-once list is a
1243
1260
  // forgeable claim about what was already reported (see the binding above).
1244
1261
  snapshot: prevRecord,
@@ -1282,10 +1299,23 @@ async function main() {
1282
1299
  ? []
1283
1300
  : readPeerScopeRecords(path.dirname(scopePath), scope.wave, sidecarRaw);
1284
1301
  if (sidecarBinding.notify) {
1285
- messages.push(formatSidecarMismatchNotice(
1286
- path.relative(repoRoot, path.join(path.dirname(scopePath), 'filescopes', `wave-${scope.wave}.scopes.json`)),
1287
- ));
1302
+ const relSidecar = path.relative(
1303
+ repoRoot,
1304
+ path.join(path.dirname(scopePath), 'filescopes', `wave-${scope.wave}.scopes.json`),
1305
+ );
1306
+ messages.push(formatSidecarMismatchNotice(relSidecar));
1288
1307
  warn = true;
1308
+ // #1514 point 6 (HR-105) — the notice alone left its firing rate unmeasurable.
1309
+ // Same once-per-new-hash dedupe as the notice (`bindPeerSidecar().notify`);
1310
+ // awaited so the append cannot be lost to the process exiting.
1311
+ try {
1312
+ const { emitEvent } = await import('../scripts/lib/events.mjs');
1313
+ await emitEvent(
1314
+ 'orchestrator.scope.sidecar_mismatch',
1315
+ { hook: 'post-bash-write-verify', sidecar: relSidecar.split(path.sep).join('/'), wave: scope.wave },
1316
+ { repoRoot: context.manifestRoot },
1317
+ );
1318
+ } catch { /* observability is best-effort — never blocks the notice */ }
1289
1319
  }
1290
1320
  const violations = [];
1291
1321
  for (const relPath of report) {
@@ -301,6 +301,18 @@ const DISPATCH_TOOL = 'Agent';
301
301
  * A flat sibling, not a directory, on purpose: bootstrap's `store-lock-ignore`
302
302
  * step (#1495) ignores `.orchestrator/wave-dispatch-scopes.*` in consumer repos,
303
303
  * which covers this name; a directory would surface untracked in each of them.
304
+ *
305
+ * NAMED CEILING (BV-004, #1504 point 7a): nothing ever deletes a per-session
306
+ * file — the hook only writes its own, and no sweep exists (a sweep would delete
307
+ * files that may be a live peer's state, PSA-003). Each FILE is bounded: it holds
308
+ * one `waveKey`'s agents, reset on the next wave. The DIRECTORY is not: it grows
309
+ * by one file (plus a transient `.lock`) per session that dispatched an agent.
310
+ * Measured 2026-10-08 in this repo's main checkout: 7 files, largest 2918 bytes,
311
+ * oldest 2026-10-03 — negligible. Fine under ~500 files; the hook never lists
312
+ * the directory, so the count costs disk, not latency.
313
+ * Revisit-Trigger: `ls .orchestrator/wave-dispatch-scopes.*.json | wc -l`
314
+ * passes 500 in any repo, OR a reader starts enumerating these files — then
315
+ * build an age-gated sweep that skips any session with a live `session.lock`.
304
316
  */
305
317
  const LEDGER_DIR_REL = '.orchestrator';
306
318
  const LEDGER_STEM = 'wave-dispatch-scopes';
@@ -113,6 +113,7 @@ import { appendSubagent } from '../scripts/lib/subagents-schema.mjs';
113
113
  import { getProjectDir } from '../scripts/lib/platform.mjs';
114
114
  import { resolveSubagentSidecar } from './_lib/subagent-paths.mjs';
115
115
  import { readTailWindow } from '../scripts/lib/tail-window.mjs';
116
+ import { COST_BASIS } from '../scripts/lib/telemetry/pricing.mjs';
116
117
 
117
118
  // ---------------------------------------------------------------------------
118
119
  // Constants
@@ -859,14 +860,18 @@ async function main() {
859
860
  // the rollup can price it part by part; absent on every other record.
860
861
  if (modelsUsage !== null) record.models_usage = modelsUsage;
861
862
 
862
- // Cost is best-effort / forward-compat (#624): the native transcript does
863
- // NOT expose total_cost_usd today, so this is null in practice. No rate
864
- // table is applied HERE — use the native cost only, default null when absent.
863
+ // Cost is best-effort (#624): retain the native payload value when valid,
864
+ // including a genuine zero; use null when absent. No rate table is applied
865
+ // HERE. Provenance (#1543) requires an explicit recognized payload basis;
866
+ // a native number alone says nothing about billing or API list prices.
865
867
  const totalCostUsd =
866
868
  typeof input.total_cost_usd === 'number' && Number.isFinite(input.total_cost_usd) && input.total_cost_usd >= 0
867
869
  ? input.total_cost_usd
868
870
  : null;
869
871
  record.total_cost_usd = totalCostUsd;
872
+ record.cost_basis = totalCostUsd !== null && input.cost_basis === COST_BASIS
873
+ ? input.cost_basis
874
+ : null;
870
875
 
871
876
  // OTel alias — #411 additive. `gen_ai.usage.input_tokens` stays the RAW
872
877
  // UNCACHED value (OTel semantic), which is why it is deliberately NOT equal
@@ -336,7 +336,10 @@ async function main() {
336
336
  // when every candidate is foreign the first comes back and
337
337
  // `scopeCommitVerdict` passes it as before. The git process inherits the
338
338
  // committing session's env; there is no hook payload here, so the env tier is
339
- // the only process-local identity.
339
+ // the only process-local identity. CEILING (#1504 point 7b): a shell that
340
+ // exports a foreign CLAUDE_CODE_SESSION_ID makes this session's manifest read
341
+ // `foreign` and the guard stands down: the same power `--no-verify` grants, so it
342
+ // stays named, not fixed (docs/scope-collision-guard.md § 6 item 12).
340
343
  const ownIds = new Set(readProcessLocalSessionIds());
341
344
 
342
345
  // #1511 point d — ALSO read the manifest at the SESSION root the scope guards
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "session-orchestrator",
3
- "version": "5.9.0",
3
+ "version": "5.11.0",
4
4
  "description": "Your coding agents get an agreed plan, a check after each round of changes and a handover to the next session. Plan, Go, Close: /session reads your repo and agrees the scope, /go runs the work in waves with your tests and typecheck after each implementation wave, /close runs the full gate, verifies, commits and files the rest as issues. Scope and destructive-command guards, crash-safe resume, learnings you approve. For Claude Code, Codex CLI, Cursor and Pi.",
5
5
  "type": "module",
6
6
  "homepage": "https://session-orchestrator.com",
@@ -96,7 +96,7 @@
96
96
  "@vitest/coverage-v8": "^4.1.11",
97
97
  "eslint": "^10.2.1",
98
98
  "husky": "^9.1.7",
99
- "lint-staged": "^15.2.10",
99
+ "lint-staged": "^16.4.0",
100
100
  "prettier": "^3.0.0",
101
101
  "vitest": "^4.1.11"
102
102
  },
@@ -19,7 +19,7 @@ tier: wave-only
19
19
 
20
20
  CSRF, rate limiting, CSP, and transport security. Core security rules are in `security.md` (always-on).
21
21
 
22
- ## CSRF Protection (SEC-005)
22
+ ## CSRF Protection (SEC-018)
23
23
  - **Next.js 15+ Server Actions**: Built-in CSRF protection via Origin header validation against Host header. No manual CSRF token needed.
24
24
  - **Manual CSRF tokens required for**: custom API routes (`/api/*`), form submissions to external endpoints, any non-Server-Action mutation endpoint.
25
25
  - For manual CSRF token validation, use `crypto.timingSafeEqual()` for timing-safe comparison.
@@ -39,6 +39,7 @@ import {
39
39
  import { parseStateMd, readSessionProfile, resolveStateMdPath } from './lib/state-md.mjs';
40
40
  import { readProcessLocalSessionIds } from './lib/session-identity/own-session.mjs';
41
41
  import { rollupSessionTokens } from './lib/session-token-rollup.mjs';
42
+ import { COST_BASIS, PRICING_TABLE_DATE } from './lib/telemetry/pricing.mjs';
42
43
  import { serializeSessionLineChecked } from './lib/session-schema/serializer.mjs';
43
44
  import {
44
45
  validateSession,
@@ -48,6 +49,7 @@ import {
48
49
  aliasLegacyEndedAt,
49
50
  normalizeWaveKeys,
50
51
  } from './lib/session-schema.mjs';
52
+ import { stableHostname } from './lib/host-identity.mjs';
51
53
  import { isMainModule } from './lib/is-main-module.mjs';
52
54
 
53
55
  /**
@@ -297,6 +299,20 @@ async function main() {
297
299
  recordSessionId: repaired.session_id,
298
300
  });
299
301
 
302
+ // #1054: origin belongs to the session, not the later mirror writer.
303
+ // Only the owned new-record path may derive this machine's identity:
304
+ // resolveOwnSessionUuid gates shared markers, while --session-uuid is the
305
+ // caller's explicit assertion that this is its own session (not proof of a
306
+ // historical import's origin). Incoming raw_session_id signals replayed
307
+ // provenance: leave its host unknown rather than guess. Explicit host keys,
308
+ // including null/empty values, always win. Never backfill existing records.
309
+ const carriesOrigin = ['raw_session_id', 'host_id', 'host', 'host_class']
310
+ .some((key) => Object.prototype.hasOwnProperty.call(repaired, key));
311
+ if (ownUuid && !carriesOrigin) {
312
+ const hostId = stableHostname();
313
+ if (hostId) repaired = { ...repaired, host_id: hostId };
314
+ }
315
+
300
316
  // An explicit session_start_ref that is not a full sha (#1443) names a
301
317
  // DIFFERENT commit once the short form becomes ambiguous. Dropped with a WARN
302
318
  // rather than rejected: the record is the load-bearing artefact, and the
@@ -527,6 +543,18 @@ async function main() {
527
543
  }
528
544
  merged[key] = rollup[key];
529
545
  }
546
+ // #1470 Pkt 4/6 — label the merged cost fields. A dollar figure is the
547
+ // API list-price equivalent, never a payment (`cost_basis`, only beside
548
+ // a written `total_cost_usd`). The coverage counters, too, depend on
549
+ // which models the table knows, so `pricing_table_date` names the table
550
+ // that produced them — read from pricing.mjs, the same module the rollup
551
+ // priced with, never a second copy of the date.
552
+ if ('total_cost_usd' in merged && !hasOwn('cost_basis')) {
553
+ merged.cost_basis = COST_BASIS;
554
+ }
555
+ if ('cost_records_total' in merged && !hasOwn('pricing_table_date')) {
556
+ merged.pricing_table_date = PRICING_TABLE_DATE;
557
+ }
530
558
  // Enrichment must never be the reason the ledger gains no line: validate
531
559
  // the enriched record first and fall back to the un-enriched one. Blamed
532
560
  // on the rollup only when the un-enriched record validates on its own —