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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/.cursor-plugin/plugin.json +1 -1
- package/CHANGELOG.md +52 -2
- package/README.md +14 -10
- package/docs/USER-GUIDE.md +2 -0
- package/docs/ci-setup.md +46 -1
- package/docs/events-schema.md +71 -1
- package/docs/owner-config-schema.md +18 -1
- package/docs/owner-privacy-patterns.md +85 -0
- package/docs/scope-collision-guard.md +12 -2
- package/hooks/enforce-commands.mjs +9 -19
- package/hooks/enforce-scope.mjs +23 -42
- package/hooks/hooks-codex.json +1 -1
- package/hooks/hooks.json +1 -1
- package/hooks/post-bash-write-verify.mjs +38 -21
- package/package.json +2 -2
- package/scripts/emit-session.mjs +15 -0
- package/scripts/lib/ci-status-banner.mjs +38 -5
- package/scripts/lib/events-schema.mjs +8 -0
- package/scripts/lib/gates/gate-full.mjs +6 -0
- package/scripts/lib/gates/gate-helpers.mjs +2 -0
- package/scripts/lib/named-vault-resolver.mjs +1 -1
- package/scripts/lib/owner-yaml.mjs +33 -3
- package/scripts/lib/platform.mjs +70 -1
- package/scripts/lib/pre-push-remote-gate.mjs +107 -0
- package/scripts/lib/quality-gate-wave.mjs +40 -0
- package/scripts/lib/quality-gate.mjs +32 -26
- package/scripts/lib/redact-spans.mjs +8 -17
- package/scripts/lib/scope-gate.mjs +68 -5
- package/scripts/lib/session-record-repair.mjs +19 -8
- package/scripts/lib/session-schema/validator.mjs +12 -0
- package/scripts/lib/session-start-probes.mjs +20 -5
- package/scripts/lib/validate/check-owner-leakage.mjs +198 -1015
- package/scripts/lib/vault-backfill/template.mjs +1 -1
- package/scripts/lib/vault-mirror/render-sessions.mjs +15 -3
- package/scripts/lib/vault-status/board-writer.mjs +15 -7
- package/scripts/lib/wave-executor/remote-dispatch.mjs +27 -9
- package/scripts/lib/wave-resource-gate.mjs +2 -1
- package/scripts/migrate-vault-paths.mjs +57 -60
- package/scripts/release.mjs +107 -37
- package/scripts/run-quality-gate.mjs +9 -39
- package/scripts/site-numbers.mjs +4 -0
- package/scripts/vault-consolidate.mjs +19 -4
- package/skills/_shared/fleet-protocol.md +2 -0
- package/skills/claude-md-drift-check/SKILL.md +1 -1
- package/skills/claude-md-drift-check/checker.mjs +14 -3
- package/skills/remote-offload/SKILL.md +1 -1
- package/skills/session-end/metrics-collection.md +39 -9
- package/skills/wave-executor/references/wave-loop-dispatch.md +1 -1
- package/skills/wave-executor/references/wave-loop-review.md +37 -7
package/hooks/enforce-scope.mjs
CHANGED
|
@@ -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,
|
|
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
|
-
//
|
|
234
|
-
//
|
|
235
|
-
//
|
|
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
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
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:
|
|
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(
|
|
379
|
+
sessionStartMs: sessionStartedAtMs(manifestRoot),
|
|
399
380
|
})
|
|
400
381
|
: null;
|
|
401
|
-
const scopeRelRaw = relativeFromRoot(
|
|
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(),
|
|
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:
|
|
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:
|
|
643
|
+
{ repoRoot: manifestRoot },
|
|
663
644
|
);
|
|
664
645
|
} catch { /* observability is best-effort — never blocks the decision */ }
|
|
665
646
|
return emitAllow();
|
package/hooks/hooks-codex.json
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
"hooks": [
|
|
8
8
|
{
|
|
9
9
|
"type": "command",
|
|
10
|
-
"command": "echo '🎯 Session Orchestrator v5.
|
|
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.
|
|
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
|
-
|
|
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) =>
|
|
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
|
-
//
|
|
974
|
-
//
|
|
975
|
-
|
|
976
|
-
const
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
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
|
-
|
|
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
|
|
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(
|
|
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.
|
|
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": "^
|
|
99
|
+
"lint-staged": "^16.4.0",
|
|
100
100
|
"prettier": "^3.0.0",
|
|
101
101
|
"vitest": "^4.1.11"
|
|
102
102
|
},
|
package/scripts/emit-session.mjs
CHANGED
|
@@ -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
|
|
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 =
|
|
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
|
-
|
|
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
|
|
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. '
|
|
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
|
package/scripts/lib/platform.mjs
CHANGED
|
@@ -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
|
// ---------------------------------------------------------------------------
|