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.
- 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/AGENTS.md +2 -0
- package/CHANGELOG.md +65 -2
- package/README.md +11 -9
- package/SECURITY.md +1 -1
- package/agents/code-implementer.md +7 -1
- package/docs/USER-GUIDE.md +2 -0
- package/docs/agent-authoring.md +2 -0
- package/docs/ci-setup.md +46 -1
- package/docs/events-schema.md +37 -6
- package/docs/fleet-checkin-audit.md +172 -0
- package/docs/owner-config-schema.md +18 -1
- package/docs/owner-privacy-patterns.md +85 -0
- package/docs/role-profile-contract.md +139 -0
- package/docs/scope-collision-guard.md +15 -2
- package/docs/session-config-reference.md +1 -1
- package/evals/instruction-ablation/run.mjs +11 -6
- package/hooks/_lib/hook-import-set.json +27 -2
- package/hooks/_lib/orchestrator-ignore.mjs +112 -0
- 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/on-session-end.mjs +8 -1
- package/hooks/on-session-start.mjs +7 -0
- package/hooks/post-bash-write-verify.mjs +54 -24
- package/hooks/pre-task-scope-disjoint.mjs +12 -0
- package/hooks/subagent-telemetry.mjs +8 -3
- package/hooks/wave-scope-commit-guard.mjs +4 -1
- package/package.json +2 -2
- package/rules/opt-in-stack/security-web.md +1 -1
- package/scripts/emit-session.mjs +28 -0
- package/scripts/fleet-checkin-audit.mjs +138 -0
- package/scripts/lib/common.mjs +2 -4
- package/scripts/lib/context-overhead-format.mjs +57 -0
- package/scripts/lib/fleet-checkin-audit.mjs +244 -0
- package/scripts/lib/gates/gate-full.mjs +6 -0
- package/scripts/lib/gates/gate-helpers.mjs +2 -0
- package/scripts/lib/memory-proposals/sink.mjs +79 -17
- package/scripts/lib/memory-proposals/store.mjs +245 -9
- 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/project-hygiene.mjs +37 -0
- package/scripts/lib/quality-gate.mjs +6 -1
- package/scripts/lib/redact-spans.mjs +8 -17
- package/scripts/lib/role-profile/schema.mjs +219 -0
- package/scripts/lib/scope-gate.mjs +68 -5
- package/scripts/lib/session-lock.mjs +57 -10
- package/scripts/lib/session-record-repair.mjs +63 -30
- package/scripts/lib/session-schema/constants.mjs +8 -0
- package/scripts/lib/session-schema/validator.mjs +33 -0
- package/scripts/lib/subagents-schema.mjs +9 -0
- package/scripts/lib/telemetry/pricing.mjs +11 -0
- 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/measure-context-overhead.sh +4 -21
- package/scripts/migrate-vault-paths.mjs +57 -60
- package/scripts/release.mjs +463 -48
- package/scripts/run-quality-gate.mjs +47 -0
- package/scripts/validate-wave-scope.mjs +59 -6
- package/scripts/vault-consolidate.mjs +19 -4
- package/skills/claude-md-drift-check/SKILL.md +1 -1
- package/skills/claude-md-drift-check/checker.mjs +22 -9
- package/skills/npm-publish/SKILL.md +3 -3
- package/skills/release/SKILL.md +2 -2
- package/skills/repo-audit/SKILL.md +1 -1
- package/skills/session-end/metrics-collection.md +28 -4
- package/skills/session-end/session-metrics-write.md +1 -1
- package/skills/session-start/references/phase-1-2-session-lock.md +1 -1
- package/skills/wave-executor/circuit-breaker.md +1 -0
- package/skills/wave-executor/references/wave-loop-review.md +26 -2
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.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
|
+
"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
|
{
|
package/hooks/on-session-end.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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,
|
|
@@ -1282,10 +1299,23 @@ async function main() {
|
|
|
1282
1299
|
? []
|
|
1283
1300
|
: readPeerScopeRecords(path.dirname(scopePath), scope.wave, sidecarRaw);
|
|
1284
1301
|
if (sidecarBinding.notify) {
|
|
1285
|
-
|
|
1286
|
-
|
|
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
|
|
863
|
-
//
|
|
864
|
-
//
|
|
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.
|
|
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": "^
|
|
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-
|
|
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.
|
package/scripts/emit-session.mjs
CHANGED
|
@@ -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 —
|