session-orchestrator 5.8.0 → 5.10.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 (52) 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/CHANGELOG.md +52 -2
  6. package/README.md +14 -10
  7. package/docs/USER-GUIDE.md +2 -0
  8. package/docs/ci-setup.md +46 -1
  9. package/docs/events-schema.md +71 -1
  10. package/docs/owner-config-schema.md +18 -1
  11. package/docs/owner-privacy-patterns.md +85 -0
  12. package/docs/scope-collision-guard.md +12 -2
  13. package/hooks/enforce-commands.mjs +9 -19
  14. package/hooks/enforce-scope.mjs +23 -42
  15. package/hooks/hooks-codex.json +1 -1
  16. package/hooks/hooks.json +1 -1
  17. package/hooks/post-bash-write-verify.mjs +38 -21
  18. package/package.json +2 -2
  19. package/scripts/emit-session.mjs +15 -0
  20. package/scripts/lib/ci-status-banner.mjs +38 -5
  21. package/scripts/lib/events-schema.mjs +8 -0
  22. package/scripts/lib/gates/gate-full.mjs +6 -0
  23. package/scripts/lib/gates/gate-helpers.mjs +2 -0
  24. package/scripts/lib/named-vault-resolver.mjs +1 -1
  25. package/scripts/lib/owner-yaml.mjs +33 -3
  26. package/scripts/lib/platform.mjs +70 -1
  27. package/scripts/lib/pre-push-remote-gate.mjs +107 -0
  28. package/scripts/lib/quality-gate-wave.mjs +40 -0
  29. package/scripts/lib/quality-gate.mjs +32 -26
  30. package/scripts/lib/redact-spans.mjs +8 -17
  31. package/scripts/lib/scope-gate.mjs +68 -5
  32. package/scripts/lib/session-record-repair.mjs +19 -8
  33. package/scripts/lib/session-schema/validator.mjs +12 -0
  34. package/scripts/lib/session-start-probes.mjs +20 -5
  35. package/scripts/lib/validate/check-owner-leakage.mjs +198 -1015
  36. package/scripts/lib/vault-backfill/template.mjs +1 -1
  37. package/scripts/lib/vault-mirror/render-sessions.mjs +15 -3
  38. package/scripts/lib/vault-status/board-writer.mjs +15 -7
  39. package/scripts/lib/wave-executor/remote-dispatch.mjs +27 -9
  40. package/scripts/lib/wave-resource-gate.mjs +2 -1
  41. package/scripts/migrate-vault-paths.mjs +57 -60
  42. package/scripts/release.mjs +107 -37
  43. package/scripts/run-quality-gate.mjs +9 -39
  44. package/scripts/site-numbers.mjs +4 -0
  45. package/scripts/vault-consolidate.mjs +19 -4
  46. package/skills/_shared/fleet-protocol.md +2 -0
  47. package/skills/claude-md-drift-check/SKILL.md +1 -1
  48. package/skills/claude-md-drift-check/checker.mjs +14 -3
  49. package/skills/remote-offload/SKILL.md +1 -1
  50. package/skills/session-end/metrics-collection.md +39 -9
  51. package/skills/wave-executor/references/wave-loop-dispatch.md +1 -1
  52. package/skills/wave-executor/references/wave-loop-review.md +37 -7
@@ -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.8.0 — /session [housekeeping|feature|deep] | /plan [new|feature|retro] | /discovery [scope] | /evolve [analyze|review|list]'",
10
+ "command": "echo '🎯 Session Orchestrator v5.10.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.8.0 — /session [housekeeping|feature|deep] | /plan [new|feature|retro] | /discovery [scope] | /evolve [analyze|review|list]'",
9
+ "command": "echo '🎯 Session Orchestrator v5.10.0 — /session [housekeeping|feature|deep] | /plan [new|feature|retro] | /discovery [scope] | /evolve [analyze|review|list]'",
10
10
  "async": false
11
11
  },
12
12
  {
@@ -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,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "session-orchestrator",
3
- "version": "5.8.0",
3
+ "version": "5.10.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
  },
@@ -48,6 +48,7 @@ import {
48
48
  aliasLegacyEndedAt,
49
49
  normalizeWaveKeys,
50
50
  } from './lib/session-schema.mjs';
51
+ import { stableHostname } from './lib/host-identity.mjs';
51
52
  import { isMainModule } from './lib/is-main-module.mjs';
52
53
 
53
54
  /**
@@ -297,6 +298,20 @@ async function main() {
297
298
  recordSessionId: repaired.session_id,
298
299
  });
299
300
 
301
+ // #1054: origin belongs to the session, not the later mirror writer.
302
+ // Only the owned new-record path may derive this machine's identity:
303
+ // resolveOwnSessionUuid gates shared markers, while --session-uuid is the
304
+ // caller's explicit assertion that this is its own session (not proof of a
305
+ // historical import's origin). Incoming raw_session_id signals replayed
306
+ // provenance: leave its host unknown rather than guess. Explicit host keys,
307
+ // including null/empty values, always win. Never backfill existing records.
308
+ const carriesOrigin = ['raw_session_id', 'host_id', 'host', 'host_class']
309
+ .some((key) => Object.prototype.hasOwnProperty.call(repaired, key));
310
+ if (ownUuid && !carriesOrigin) {
311
+ const hostId = stableHostname();
312
+ if (hostId) repaired = { ...repaired, host_id: hostId };
313
+ }
314
+
300
315
  // An explicit session_start_ref that is not a full sha (#1443) names a
301
316
  // DIFFERENT commit once the short form becomes ambiguous. Dropped with a WARN
302
317
  // rather than rejected: the record is the load-bearing artefact, and the
@@ -1089,8 +1089,23 @@ async function checkGithub(repoRoot, deps = {}) {
1089
1089
  };
1090
1090
  }
1091
1091
 
1092
+ // #856: lifecycle and conclusion are separate facts. Failure retains
1093
+ // priority even in an inconsistent row; only completed success grants green.
1094
+ // Counts are additive diagnostics, never a new status or gate authority.
1095
+ const checkRunCounts = { success: 0, failure: 0, inProgress: 0, queued: 0, cancelled: 0, skipped: 0, neutral: 0, waiting: 0, other: 0 };
1096
+ for (const run of checkRuns) {
1097
+ if (run?.conclusion === 'failure' || run?.conclusion === 'action_required') checkRunCounts.failure++;
1098
+ else if (run?.status === 'completed' && run?.conclusion === 'success') checkRunCounts.success++;
1099
+ else if (run?.status === 'completed' && run?.conclusion === 'cancelled') checkRunCounts.cancelled++;
1100
+ else if (run?.status === 'completed' && run?.conclusion === 'skipped') checkRunCounts.skipped++;
1101
+ else if (run?.status === 'completed' && run?.conclusion === 'neutral') checkRunCounts.neutral++;
1102
+ else if (['waiting', 'pending', 'requested'].includes(run?.status) && run?.conclusion === null) checkRunCounts.waiting++;
1103
+ else if (run?.status === 'in_progress' && run?.conclusion === null) checkRunCounts.inProgress++;
1104
+ else if (run?.status === 'queued' && run?.conclusion === null) checkRunCounts.queued++;
1105
+ else checkRunCounts.other++;
1106
+ }
1092
1107
  const failedRun = checkRuns.find(
1093
- (r) => r.conclusion === 'failure' || r.conclusion === 'action_required',
1108
+ (r) => r?.conclusion === 'failure' || r?.conclusion === 'action_required',
1094
1109
  );
1095
1110
 
1096
1111
  if (failedRun) {
@@ -1101,11 +1116,12 @@ async function checkGithub(repoRoot, deps = {}) {
1101
1116
  details: {
1102
1117
  cliUsed: 'gh',
1103
1118
  reason: 'lastGreen-not-implemented-for-github',
1119
+ checkRunCounts,
1104
1120
  },
1105
1121
  };
1106
1122
  }
1107
1123
 
1108
- const allSuccess = checkRuns.every((r) => r.conclusion === 'success');
1124
+ const allSuccess = checkRunCounts.success === checkRuns.length;
1109
1125
  if (allSuccess) {
1110
1126
  return {
1111
1127
  status: 'green',
@@ -1116,13 +1132,24 @@ async function checkGithub(repoRoot, deps = {}) {
1116
1132
  };
1117
1133
  }
1118
1134
 
1119
- // Some runs pending / in-progress / etc.
1135
+ const states = [
1136
+ ['inProgress', 'check-runs-in-progress'],
1137
+ ['queued', 'check-runs-queued'],
1138
+ ['cancelled', 'check-runs-cancelled'],
1139
+ ['skipped', 'check-runs-skipped'],
1140
+ ['neutral', 'check-runs-neutral'],
1141
+ ['waiting', 'check-runs-waiting'],
1142
+ ['other', 'check-runs-unrecognised'],
1143
+ ].filter(([key]) => checkRunCounts[key] > 0);
1144
+ // Successes may accompany one unknown state; counts expose the whole mix.
1145
+ const reason = states.length === 1 ? states[0][1] : 'check-runs-mixed';
1120
1146
  return {
1121
1147
  status: 'unknown',
1122
1148
  ok: false,
1123
1149
  details: {
1124
1150
  cliUsed: 'gh',
1125
- reason: 'check-runs-not-complete',
1151
+ reason,
1152
+ checkRunCounts,
1126
1153
  },
1127
1154
  };
1128
1155
  }
@@ -1189,6 +1216,7 @@ async function checkGithub(repoRoot, deps = {}) {
1189
1216
  * cliUsed: 'glab'|'gh',
1190
1217
  * reason?: string,
1191
1218
  * error?: string,
1219
+ * checkRunCounts?: { success: number, failure: number, inProgress: number, queued: number, cancelled: number, skipped: number, neutral: number, waiting: number, other: number },
1192
1220
  * matchedRef?: string,
1193
1221
  * candidateCount?: number,
1194
1222
  * candidateStatuses?: string[],
@@ -1202,7 +1230,12 @@ async function checkGithub(repoRoot, deps = {}) {
1202
1230
  * #857 "several contradicting pipelines" and "only foreign-ref pipelines"
1203
1231
  * findings — and the #1390 "same-sha rows the ref preference set aside"
1204
1232
  * (`droppedCount`/`droppedStatuses`) — are delivered through ADDITIVE `details`
1205
- * fields and `details.reason`,
1233
+ * fields and `details.reason`. GitHub's `checkRunCounts` counts each row once:
1234
+ * `failure` includes action_required; `other` includes malformed/inconsistent
1235
+ * rows and unsupported statuses/conclusions. Non-success reasons are
1236
+ * check-runs-in-progress, check-runs-queued, check-runs-cancelled,
1237
+ * check-runs-skipped, check-runs-neutral, check-runs-waiting (#1526),
1238
+ * check-runs-unrecognised, or check-runs-mixed (multiple non-success kinds),
1206
1239
  * never as a new status value — consumers fail open on an unknown status string
1207
1240
  * and the session-start renderer prints nothing for one.
1208
1241
  */
@@ -239,5 +239,13 @@ export function validateEventRecord(record) {
239
239
  );
240
240
  }
241
241
 
242
+ if (
243
+ ['orchestrator.quality_gate.passed', 'orchestrator.quality_gate.failed'].includes(record.event) &&
244
+ Object.hasOwn(record, 'wave_number') &&
245
+ (!Number.isSafeInteger(record.wave_number) || record.wave_number <= 0)
246
+ ) {
247
+ errors.push('quality_gate wave_number must be a positive safe integer when present');
248
+ }
249
+
242
250
  return { valid: errors.length === 0, errors };
243
251
  }
@@ -227,4 +227,10 @@ if (failed) {
227
227
  }
228
228
  }
229
229
 
230
+ // stderr is asynchronous when piped (pre-push/offload capture). Drain queued
231
+ // writes before the forced exit; natural exit would retain kill-ladder timers.
232
+ await Promise.all([
233
+ new Promise((resolve) => process.stdout.write('', resolve)),
234
+ new Promise((resolve) => process.stderr.write('', resolve)),
235
+ ]);
230
236
  process.exit(failed ? 2 : 0);
@@ -565,6 +565,8 @@ export function extractTestCounts(output) {
565
565
  const EMPTY = { passed: 0, failed: 0, total: 0, files: null };
566
566
  if (!output) return EMPTY;
567
567
 
568
+ // Colored Vitest labels must match the same summaries as piped plain text.
569
+ output = output.replace(ANSI_ESCAPE, '');
568
570
  const lines = output.split('\n');
569
571
  const summaryLines = lines.filter((line) => TEST_SUMMARY_LINE.test(line));
570
572
  const scope = summaryLines.length > 0 ? summaryLines[summaryLines.length - 1] : output;
@@ -209,7 +209,7 @@ export function canonicalSuffixesFromVaults(vaults, envOverride) {
209
209
  *
210
210
  * First-match-wins; caller is responsible for WARN on ambiguity if needed.
211
211
  *
212
- * @param {string} repoSlug — e.g. 'bernhard-group/foo'
212
+ * @param {string} repoSlug — e.g. 'example-org/foo'
213
213
  * @param {Array<{name:string, suffix:string, root:string, match:{'org-prefix'?:string}}>} vaults
214
214
  * @returns {{name:string, suffix:string, root:string, match:object}|null}
215
215
  */
@@ -41,6 +41,9 @@
41
41
  * mode: 'off' | 'warn' | 'strict' (applied only when weaker than the committed mode;
42
42
  * precedence env SO_VAULT_INTEGRATION > this > committed.
43
43
  * An invalid value drops ONLY this section, see loadOwnerConfig)
44
+ * drift-check: (optional; host-local path exceptions — #937)
45
+ * foreign-host-prefixes: string[] (narrow absolute directory prefixes;
46
+ * missing matching paths warn, unmatched paths error)
44
47
  * vault-dirs: (optional list; per-directory vault-dir — agents/vault#319)
45
48
  * - path: string (the vault directory)
46
49
  * match: { path-prefix: string } (required; cwd directory prefix, first match wins)
@@ -54,7 +57,7 @@
54
57
  * validateOwnerSections(obj) — pure validation, no I/O; bucketed per section (#820)
55
58
  * validateOwnerConfig(obj) — pure validation, no I/O; thin wrapper over validateOwnerSections
56
59
  * loadOwnerConfig({path?}) — reads file; per-section tolerance for OPTIONAL
57
- * sections (paths/dispatcher/vault-integration/vaults/
60
+ * sections (paths/dispatcher/vault-integration/drift-check/vaults/
58
61
  * baselines/vault-dirs) — #820
59
62
  * writeOwnerConfig(config, {path?}) — validates, writes YAML, creates dir
60
63
  * getDefaults() — returns sensible default config object
@@ -218,7 +221,7 @@ const REQUIRED_SECTIONS = /** @type {const} */ (['owner', 'tone', 'efficiency',
218
221
  * override", so a dropped section simply leaves the committed gate in force.
219
222
  */
220
223
  export const OPTIONAL_OBJECT_SECTIONS = /** @type {const} */ (
221
- Object.freeze(['paths', 'dispatcher', 'vault-integration'])
224
+ Object.freeze(['paths', 'dispatcher', 'vault-integration', 'drift-check'])
222
225
  );
223
226
 
224
227
  /**
@@ -397,6 +400,33 @@ export function validateOwnerSections(obj) {
397
400
  sections['hardware-sharing'] = { valid: errors.length === 0, errors };
398
401
  }
399
402
 
403
+ // Host-local exception list only; invalid entries drop the entire section.
404
+ {
405
+ const errors = [];
406
+ const drift = obj['drift-check'];
407
+ if (drift !== undefined && drift !== null) {
408
+ if (!isPlainObject(drift)) {
409
+ errors.push('drift-check must be an object when present');
410
+ } else if (drift['foreign-host-prefixes'] !== undefined) {
411
+ const prefixes = drift['foreign-host-prefixes'];
412
+ if (!Array.isArray(prefixes)) {
413
+ errors.push('drift-check.foreign-host-prefixes must be an array');
414
+ } else {
415
+ for (const prefix of prefixes) {
416
+ const parts = typeof prefix === 'string' ? prefix.split('/').filter(Boolean) : [];
417
+ if (typeof prefix !== 'string' || !prefix.startsWith('/') ||
418
+ !/^\/[A-Za-z0-9._/-]+$/.test(prefix) || prefix.includes('//') ||
419
+ parts.some((part) => part === '.' || part === '..') ||
420
+ parts.length < 3 || (['Users', 'home'].includes(parts[0]) && parts.length < 4)) {
421
+ errors.push('drift-check.foreign-host-prefixes entries must be narrow absolute directory prefixes (no roots, home directories, traversal or globs)');
422
+ }
423
+ }
424
+ }
425
+ }
426
+ }
427
+ sections['drift-check'] = { valid: errors.length === 0, errors };
428
+ }
429
+
400
430
  // ── paths (optional; host-local path overrides — #653) ───────────────────────
401
431
  {
402
432
  const errors = [];
@@ -627,7 +657,7 @@ export function validateOwnerConfig(obj) {
627
657
  * Per-section tolerance (#820): an invalid REQUIRED section (owner, tone,
628
658
  * efficiency, hardware-sharing) still discards the whole file (legacy
629
659
  * behaviour, unchanged). An invalid OPTIONAL object section (paths,
630
- * dispatcher, vault-integration) is instead replaced by its default value — the rest of the
660
+ * dispatcher, vault-integration, drift-check) is instead replaced by its default value — the rest of the
631
661
  * file survives, `source` becomes `'partial'`, and the drop is reported via
632
662
  * `droppedSections` + a stderr WARN. OPTIONAL list sections (vaults,
633
663
  * baselines, vault-dirs) are passed through UNTOUCHED even when strict-invalid — their
@@ -13,7 +13,7 @@
13
13
  * NOTHING in this module touches the filesystem at import time.
14
14
  */
15
15
 
16
- import { existsSync, readFileSync, realpathSync, statSync } from 'node:fs';
16
+ import { existsSync, readFileSync, realpathSync, statSync, lstatSync, opendirSync } from 'node:fs';
17
17
  import os from 'node:os';
18
18
  import path from 'node:path';
19
19
  import { resolvePluginRoot as _resolvePluginRootRobust } from './plugin-root.mjs';
@@ -458,6 +458,75 @@ export function resolveSessionRoot(cwd, toplevel = '') {
458
458
  return launch !== '' && _isStrictlyInside(launch, root) ? launch : root;
459
459
  }
460
460
 
461
+ /** Read small Git metadata after rejecting nonregular or symlink entries. */
462
+ function readWorktreeMetadata(file) {
463
+ const info = lstatSync(file);
464
+ if (!info.isFile() || info.size > 8192) throw new TypeError('Invalid worktree metadata');
465
+ return readFileSync(file, 'utf8').trim();
466
+ }
467
+
468
+ function worktreeBinding(root) {
469
+ const text = readWorktreeMetadata(path.join(root, '.git'));
470
+ const match = /^gitdir: (.+)$/.exec(text);
471
+ if (!match) throw new TypeError('Invalid worktree pointer');
472
+ const gitDir = _canonical(path.resolve(root, match[1]));
473
+ const commonDir = _canonical(path.resolve(gitDir, readWorktreeMetadata(path.join(gitDir, 'commondir'))));
474
+ if (path.dirname(gitDir) !== path.join(commonDir, 'worktrees')) throw new TypeError('Invalid worktree admin directory');
475
+ const backPointer = _canonical(readWorktreeMetadata(path.join(gitDir, 'gitdir')));
476
+ if (backPointer !== _canonical(path.join(root, '.git'))) throw new TypeError('Worktree pointer mismatch');
477
+ return { gitDir, commonDir };
478
+ }
479
+
480
+ /**
481
+ * Verified Claude harness agent checkout, or null for every ordinary checkout.
482
+ * Keeps resolveSessionRoot's lifecycle contract unchanged (#1504 items 1–2).
483
+ * @param {string} cwd
484
+ * @returns {{writeRoot: string, commonDir: string}|null}
485
+ */
486
+ export function isolatedAgentCheckout(cwd) {
487
+ const root = typeof cwd === 'string' ? dotGitAncestor(cwd) : '';
488
+ if (!root || _liftHarnessAgentWorktree(root) === root) return null;
489
+ try {
490
+ const binding = worktreeBinding(root);
491
+ const launchRoot = _liftHarnessAgentWorktree(root);
492
+ const dotGit = path.join(launchRoot, '.git');
493
+ const launchCommon = lstatSync(dotGit).isDirectory()
494
+ ? _canonical(dotGit) : worktreeBinding(launchRoot).commonDir;
495
+ if (binding.commonDir !== launchCommon) return null;
496
+ return { writeRoot: _canonical(root), commonDir: binding.commonDir };
497
+ } catch { return null; }
498
+ }
499
+
500
+ /**
501
+ * Enumerate reciprocal worktree roots in one Git admin directory, without a
502
+ * recursive scan. Ceiling: 256 entries / 8 KiB metadata; revisit if a real repo
503
+ * exceeds this. An incomplete enumeration is an error, never a clean absence.
504
+ * @param {string} commonDir
505
+ * @returns {{roots: string[], error: string|null}}
506
+ */
507
+ export function registeredWorktreeRoots(commonDir) {
508
+ const roots = new Set();
509
+ let dir;
510
+ try {
511
+ dir = opendirSync(path.join(commonDir, 'worktrees'));
512
+ let count = 0;
513
+ for (let entry; (entry = dir.readSync()) !== null;) {
514
+ if (++count > 256) return { roots: [], error: 'worktree-limit' };
515
+ if (!entry.isDirectory()) continue;
516
+ const admin = path.join(commonDir, 'worktrees', entry.name);
517
+ try {
518
+ const pointer = readWorktreeMetadata(path.join(admin, 'gitdir'));
519
+ if (!path.isAbsolute(pointer) || path.basename(pointer) !== '.git') continue;
520
+ const root = _canonical(path.dirname(pointer));
521
+ const binding = worktreeBinding(root);
522
+ if (binding.commonDir === commonDir && binding.gitDir === _canonical(admin)) roots.add(root);
523
+ } catch { /* stale or invalid registration cannot lend authority */ }
524
+ }
525
+ return { roots: [...roots], error: null };
526
+ } catch { return { roots: [], error: 'worktree-discovery-unreadable' }; }
527
+ finally { if (dir) dir.closeSync(); }
528
+ }
529
+
461
530
  // ---------------------------------------------------------------------------
462
531
  // resolveEventsLedgerRoot (#1514 point 1)
463
532
  // ---------------------------------------------------------------------------