session-orchestrator 5.10.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 +32 -0
- package/README.md +9 -8
- package/SECURITY.md +1 -1
- package/agents/code-implementer.md +7 -1
- package/docs/agent-authoring.md +2 -0
- package/docs/events-schema.md +2 -1
- package/docs/fleet-checkin-audit.md +172 -0
- package/docs/role-profile-contract.md +139 -0
- package/docs/scope-collision-guard.md +3 -0
- 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/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 +16 -3
- 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 +1 -1
- package/rules/opt-in-stack/security-web.md +1 -1
- package/scripts/emit-session.mjs +13 -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/memory-proposals/sink.mjs +79 -17
- package/scripts/lib/memory-proposals/store.mjs +245 -9
- package/scripts/lib/project-hygiene.mjs +37 -0
- package/scripts/lib/role-profile/schema.mjs +219 -0
- 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/measure-context-overhead.sh +4 -21
- package/scripts/release.mjs +359 -11
- package/scripts/run-quality-gate.mjs +47 -0
- package/scripts/validate-wave-scope.mjs +59 -6
- package/skills/claude-md-drift-check/checker.mjs +8 -6
- 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 +2 -2
- 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
|
@@ -243,7 +243,7 @@ function runCell(variant, kase, run) {
|
|
|
243
243
|
if (DRY) return { variant: variant.id, case: kase.id, run, dry: true, workdir: wd };
|
|
244
244
|
|
|
245
245
|
let usage = {};
|
|
246
|
-
let cost =
|
|
246
|
+
let cost = null;
|
|
247
247
|
let text = '';
|
|
248
248
|
let error = null;
|
|
249
249
|
try {
|
|
@@ -255,7 +255,9 @@ function runCell(variant, kase, run) {
|
|
|
255
255
|
});
|
|
256
256
|
const j = JSON.parse(out);
|
|
257
257
|
usage = j.usage || {};
|
|
258
|
-
cost
|
|
258
|
+
// Missing or non-numeric cost stays unknown; a reported 0 stays 0 (#1547).
|
|
259
|
+
const c = j.total_cost_usd;
|
|
260
|
+
cost = typeof c === 'number' && Number.isFinite(c) && c >= 0 ? c : null;
|
|
259
261
|
text = typeof j.result === 'string' ? j.result : '';
|
|
260
262
|
} catch (e) {
|
|
261
263
|
error = String(e.message || e).slice(0, 300);
|
|
@@ -275,7 +277,7 @@ function runCell(variant, kase, run) {
|
|
|
275
277
|
error,
|
|
276
278
|
context_tokens: ctx,
|
|
277
279
|
output_tokens: usage.output_tokens || 0,
|
|
278
|
-
cost_usd: Number(cost.toFixed(4)),
|
|
280
|
+
cost_usd: cost === null ? null : Number(cost.toFixed(4)),
|
|
279
281
|
oracles,
|
|
280
282
|
reply_excerpt: text.slice(0, 300),
|
|
281
283
|
workdir: wd,
|
|
@@ -300,7 +302,7 @@ for (const kase of cases) {
|
|
|
300
302
|
const rec = runCell(variant, kase, run);
|
|
301
303
|
records.push(rec);
|
|
302
304
|
if (!DRY) appendFileSync(outFile, JSON.stringify(rec) + '\n');
|
|
303
|
-
process.stderr.write(rec.dry ? 'built\n' : `${rec.pass ? 'PASS' : 'FAIL'} (${rec.cost_usd ??
|
|
305
|
+
process.stderr.write(rec.dry ? 'built\n' : `${rec.pass ? 'PASS' : 'FAIL'} (${rec.cost_usd ?? 'unknown'} USD)\n`);
|
|
304
306
|
}
|
|
305
307
|
}
|
|
306
308
|
}
|
|
@@ -324,13 +326,16 @@ for (const kase of cases) {
|
|
|
324
326
|
console.log(row.join('\t'));
|
|
325
327
|
}
|
|
326
328
|
|
|
327
|
-
|
|
329
|
+
// One unknown cell makes the total unknown; a partial sum would read as complete.
|
|
330
|
+
const spend = records.some((r) => r.cost_usd === null)
|
|
331
|
+
? 'unknown'
|
|
332
|
+
: records.reduce((a, r) => a + r.cost_usd, 0).toFixed(2);
|
|
328
333
|
const ctxAvg = (id) => {
|
|
329
334
|
const c = records.filter((r) => r.variant === id && r.context_tokens);
|
|
330
335
|
return c.length ? Math.round(c.reduce((a, r) => a + r.context_tokens, 0) / c.length) : 0;
|
|
331
336
|
};
|
|
332
337
|
console.log('\n=== mean context tokens by variant ===');
|
|
333
338
|
for (const v of variants) console.log(`${v.id}\t${ctxAvg(v.id)}`);
|
|
334
|
-
console.log(`\ntotal spend: USD ${spend
|
|
339
|
+
console.log(`\ntotal spend: USD ${spend}`);
|
|
335
340
|
console.log(`results: ${outFile}`);
|
|
336
341
|
console.log(`workdirs: ${ROOT} (throwaway; inspect failures there, remove when done)`);
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
|
-
"generated_at": "2026-10-
|
|
3
|
-
"head": "
|
|
2
|
+
"generated_at": "2026-10-10T12:04:56.821Z",
|
|
3
|
+
"head": "f4f6200c067d28025bf70e34296cd5ebabcfc7f8",
|
|
4
4
|
"entries": [
|
|
5
5
|
{
|
|
6
6
|
"file": "hooks/_lib/atomic-json.mjs",
|
|
@@ -34,6 +34,12 @@
|
|
|
34
34
|
"on-session-end.mjs"
|
|
35
35
|
]
|
|
36
36
|
},
|
|
37
|
+
{
|
|
38
|
+
"file": "hooks/_lib/orchestrator-ignore.mjs",
|
|
39
|
+
"reachable_from": [
|
|
40
|
+
"on-session-start.mjs"
|
|
41
|
+
]
|
|
42
|
+
},
|
|
37
43
|
{
|
|
38
44
|
"file": "hooks/_lib/profile-gate.mjs",
|
|
39
45
|
"reachable_from": [
|
|
@@ -1158,6 +1164,19 @@
|
|
|
1158
1164
|
"post-edit-validate.mjs"
|
|
1159
1165
|
]
|
|
1160
1166
|
},
|
|
1167
|
+
{
|
|
1168
|
+
"file": "scripts/lib/native-command.mjs",
|
|
1169
|
+
"reachable_from": [
|
|
1170
|
+
"on-session-end.mjs",
|
|
1171
|
+
"on-session-start.mjs",
|
|
1172
|
+
"on-stop.mjs",
|
|
1173
|
+
"post-bash-issue-budget-refund.mjs",
|
|
1174
|
+
"post-edit-validate.mjs",
|
|
1175
|
+
"post-subagent-discovery-validator.mjs",
|
|
1176
|
+
"pre-bash-issue-budget.mjs",
|
|
1177
|
+
"pre-bash-templates-first.mjs"
|
|
1178
|
+
]
|
|
1179
|
+
},
|
|
1161
1180
|
{
|
|
1162
1181
|
"file": "scripts/lib/orphan-reaper/defaults.mjs",
|
|
1163
1182
|
"reachable_from": [
|
|
@@ -1633,6 +1652,12 @@
|
|
|
1633
1652
|
"skill-invocation-telemetry.mjs"
|
|
1634
1653
|
]
|
|
1635
1654
|
},
|
|
1655
|
+
{
|
|
1656
|
+
"file": "scripts/lib/telemetry/pricing.mjs",
|
|
1657
|
+
"reachable_from": [
|
|
1658
|
+
"subagent-telemetry.mjs"
|
|
1659
|
+
]
|
|
1660
|
+
},
|
|
1636
1661
|
{
|
|
1637
1662
|
"file": "scripts/lib/telemetry/queue.mjs",
|
|
1638
1663
|
"reachable_from": [
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* orchestrator-ignore.mjs — keep the plugin's runtime state out of `git status`
|
|
3
|
+
* in a repo that has never been bootstrapped (#1515 Pkt 5).
|
|
4
|
+
*
|
|
5
|
+
* Measured 2026-10-08 @ 644743cc: one SessionStart in a fresh `git init` repo
|
|
6
|
+
* left five untracked files under `.orchestrator/` (session.lock,
|
|
7
|
+
* current-session.json, host.json, runtime/lock-owner-proof.json,
|
|
8
|
+
* metrics/events.jsonl) and nothing ignored them. Bootstrapped repos get their
|
|
9
|
+
* runtime ignore lines from `skills/bootstrap/_shared-template.md`
|
|
10
|
+
* § store-lock-ignore; an un-bootstrapped one gets nothing.
|
|
11
|
+
*
|
|
12
|
+
* Root, not per writer (BV-003): 23 hook-reachable modules call mkdir, and
|
|
13
|
+
* `.orchestrator/` is created by whichever runs first. SessionStart is the first
|
|
14
|
+
* plugin hook of every session (hooks/hooks.json), so the one call in
|
|
15
|
+
* on-session-start.mjs covers the session. Because SessionStart runs async, a
|
|
16
|
+
* PreToolUse hook MAY have created the directory a moment earlier — which is
|
|
17
|
+
* why the decision keys on "nothing under .orchestrator/ is tracked" (one
|
|
18
|
+
* `git ls-files`), not on "the directory was just created".
|
|
19
|
+
*
|
|
20
|
+
* Invariants:
|
|
21
|
+
* - A repo WITH `.orchestrator/bootstrap.lock` is never given the file. If it
|
|
22
|
+
* carries a file this helper wrote (byte-identical), the file is removed:
|
|
23
|
+
* after bootstrap, steering/, peers/, policy/ and the ledgers are durable
|
|
24
|
+
* project data and must not stay ignored.
|
|
25
|
+
* - An existing `.orchestrator/.gitignore` is never overwritten.
|
|
26
|
+
* - Any tracked path under `.orchestrator/`, or git failing (no repo, no git
|
|
27
|
+
* binary), means: write nothing.
|
|
28
|
+
* - Never throws; returns a reason string for tests and diagnostics.
|
|
29
|
+
*
|
|
30
|
+
* The negations are the exact `.orchestrator/` paths a bootstrap run stages
|
|
31
|
+
* with `git add -- <file>` (BOOTSTRAP_FILES in skills/bootstrap/*-template.md,
|
|
32
|
+
* measured 2026-10-08). The Bootstrap Gate runs bootstrap in the SAME session
|
|
33
|
+
* this file was written, before the next SessionStart can remove it, and git
|
|
34
|
+
* refuses `git add` on an explicitly named ignored path. NAMED CEILING
|
|
35
|
+
* (BV-004): the list is hand-copied; revisit when a template adds a new
|
|
36
|
+
* `BOOTSTRAP_FILES+=(.orchestrator/...)` entry.
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
import { readFile, writeFile, link, unlink, mkdir } from 'node:fs/promises';
|
|
40
|
+
import { existsSync, lstatSync } from 'node:fs';
|
|
41
|
+
import { execFile } from 'node:child_process';
|
|
42
|
+
import path from 'node:path';
|
|
43
|
+
|
|
44
|
+
/** Directories that hold durable project data, never plugin runtime output. */
|
|
45
|
+
const PROJECT_DATA_DIRS = ['steering', 'peers', 'policy'];
|
|
46
|
+
|
|
47
|
+
export const GENERATED_IGNORE = [
|
|
48
|
+
'# Written by session-orchestrator (#1515): this repo has no .orchestrator/bootstrap.lock,',
|
|
49
|
+
'# so the plugin runtime state below stays out of git status. Removed automatically',
|
|
50
|
+
'# at the first session start after /bootstrap.',
|
|
51
|
+
'*',
|
|
52
|
+
'!*/',
|
|
53
|
+
'!/bootstrap.lock',
|
|
54
|
+
'!/metrics/learnings.jsonl',
|
|
55
|
+
'!/metrics/sessions.jsonl',
|
|
56
|
+
'!/metrics/README.md',
|
|
57
|
+
'!/policy/quality-gates.json',
|
|
58
|
+
'',
|
|
59
|
+
].join('\n');
|
|
60
|
+
|
|
61
|
+
function trackedUnderOrchestrator(repoRoot) {
|
|
62
|
+
return new Promise((resolve) => {
|
|
63
|
+
execFile('git', ['ls-files', '-z', '--', '.orchestrator'], { cwd: repoRoot, timeout: 2000 }, (err, stdout) => {
|
|
64
|
+
if (err) return resolve(null); // unknown → caller writes nothing
|
|
65
|
+
resolve(stdout.length > 0);
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* @param {string} repoRoot
|
|
72
|
+
* @returns {Promise<'written'|'removed'|'bootstrapped'|'present'|'project-data'|'symlink'|'tracked'|'no-git'|'error'>}
|
|
73
|
+
*/
|
|
74
|
+
export async function ensureOrchestratorIgnore(repoRoot) {
|
|
75
|
+
try {
|
|
76
|
+
const dir = path.join(repoRoot, '.orchestrator');
|
|
77
|
+
const target = path.join(dir, '.gitignore');
|
|
78
|
+
if (existsSync(path.join(dir, 'bootstrap.lock'))) {
|
|
79
|
+
if (existsSync(target)) {
|
|
80
|
+
const current = await readFile(target, 'utf8').catch(() => null);
|
|
81
|
+
if (current === GENERATED_IGNORE) {
|
|
82
|
+
await unlink(target);
|
|
83
|
+
return 'removed';
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return 'bootstrapped';
|
|
87
|
+
}
|
|
88
|
+
if (existsSync(target)) return 'present';
|
|
89
|
+
// Durable project data (bootstrap/_shared-template.md #store-lock-ignore)
|
|
90
|
+
// the user may hold untracked on purpose: ignoring it would hide it from
|
|
91
|
+
// git status without a word. Only plugin runtime output may be swept.
|
|
92
|
+
if (PROJECT_DATA_DIRS.some((d) => existsSync(path.join(dir, d)))) return 'project-data';
|
|
93
|
+
// A symlinked .orchestrator would put the file outside the repo.
|
|
94
|
+
if (existsSync(dir) && lstatSync(dir).isSymbolicLink()) return 'symlink';
|
|
95
|
+
const tracked = await trackedUnderOrchestrator(repoRoot);
|
|
96
|
+
if (tracked === null) return 'no-git';
|
|
97
|
+
if (tracked) return 'tracked';
|
|
98
|
+
await mkdir(dir, { recursive: true });
|
|
99
|
+
// tmp + link: atomic, and link() fails with EEXIST instead of replacing a
|
|
100
|
+
// .gitignore another process created in between (rename would overwrite).
|
|
101
|
+
const tmp = `${target}.tmp-${process.pid}`;
|
|
102
|
+
await writeFile(tmp, GENERATED_IGNORE, 'utf8');
|
|
103
|
+
try {
|
|
104
|
+
await link(tmp, target);
|
|
105
|
+
} finally {
|
|
106
|
+
await unlink(tmp).catch(() => {});
|
|
107
|
+
}
|
|
108
|
+
return 'written';
|
|
109
|
+
} catch (err) {
|
|
110
|
+
return err && err.code === 'EEXIST' ? 'present' : 'error';
|
|
111
|
+
}
|
|
112
|
+
}
|
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.
|
|
@@ -1299,10 +1299,23 @@ async function main() {
|
|
|
1299
1299
|
? []
|
|
1300
1300
|
: readPeerScopeRecords(path.dirname(scopePath), scope.wave, sidecarRaw);
|
|
1301
1301
|
if (sidecarBinding.notify) {
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
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));
|
|
1305
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 */ }
|
|
1306
1319
|
}
|
|
1307
1320
|
const violations = [];
|
|
1308
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",
|
|
@@ -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,
|
|
@@ -542,6 +543,18 @@ async function main() {
|
|
|
542
543
|
}
|
|
543
544
|
merged[key] = rollup[key];
|
|
544
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
|
+
}
|
|
545
558
|
// Enrichment must never be the reason the ledger gains no line: validate
|
|
546
559
|
// the enriched record first and fall back to the un-enriched one. Blamed
|
|
547
560
|
// on the rollup only when the un-enriched record validates on its own —
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/** Read-only CLI for explicitly supplied, stable snapshot copies (#1462). */
|
|
3
|
+
import { constants, promises as fs } from 'node:fs';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { TextDecoder } from 'node:util';
|
|
6
|
+
import { auditCheckins } from './lib/fleet-checkin-audit.mjs';
|
|
7
|
+
import { isMainModule } from './lib/is-main-module.mjs';
|
|
8
|
+
|
|
9
|
+
const HELP = `Usage: fleet-checkin-audit --input-root DIR --registry FILE --checkins FILE --repo KEY [--repo KEY] --at UTC [--json]
|
|
10
|
+
All five input flags are required. --repo may repeat with distinct explicit keys.
|
|
11
|
+
--at is an exact UTC instant (YYYY-MM-DDTHH:MM:SS[.sss]Z).
|
|
12
|
+
--json emits one JSON report; --help and --version are standalone commands.
|
|
13
|
+
Supply complete registry/check-in snapshot copies with capture times matching --at.
|
|
14
|
+
Only explicit regular files inside --input-root are read; symlinks are refused.
|
|
15
|
+
Use stable trusted copies: simultaneous parent-directory renames are not atomic.
|
|
16
|
+
No discovery, live configuration, referenced-repo reads, events or input writes.
|
|
17
|
+
Exit 0: measured, including findings; 1: invalid arguments, paths or JSON;
|
|
18
|
+
2: unavailable inputs/system I/O; 3: incomplete or not-measurable snapshot evidence.
|
|
19
|
+
stdout contains the report; stderr contains only diagnostics.
|
|
20
|
+
`;
|
|
21
|
+
const failure = (category, exit, errno) => Object.assign(new Error(category), { category, exit, errno });
|
|
22
|
+
|
|
23
|
+
function argumentsFor(argv) {
|
|
24
|
+
const args = { repos: [], json: false };
|
|
25
|
+
const values = new Map([['--input-root', 'root'], ['--registry', 'registry'], ['--checkins', 'checkins'], ['--at', 'at']]);
|
|
26
|
+
for (let i = 0; i < argv.length; i++) {
|
|
27
|
+
const flag = argv[i];
|
|
28
|
+
if (flag === '--json') { args.json = true; continue; }
|
|
29
|
+
if (flag === '--help' || flag === '--version') {
|
|
30
|
+
if (argv.length !== 1) throw failure('invalid-arguments', 1);
|
|
31
|
+
args.action = flag;
|
|
32
|
+
return args;
|
|
33
|
+
}
|
|
34
|
+
if (flag !== '--repo' && !values.has(flag)) throw failure('invalid-arguments', 1);
|
|
35
|
+
const value = argv[++i];
|
|
36
|
+
if (!value || value.startsWith('--')) throw failure('invalid-arguments', 1);
|
|
37
|
+
if (flag === '--repo') args.repos.push(value);
|
|
38
|
+
else {
|
|
39
|
+
const key = values.get(flag);
|
|
40
|
+
if (args[key] !== undefined) throw failure('duplicate-argument', 1);
|
|
41
|
+
args[key] = value;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
if (!args.root || !args.registry || !args.checkins || !args.at || !args.repos.length) throw failure('missing-arguments', 1);
|
|
45
|
+
if (new Set(args.repos).size !== args.repos.length || args.repos.some((repo) => repo.trim() !== repo)) {
|
|
46
|
+
throw failure('invalid-repo-selection', 1);
|
|
47
|
+
}
|
|
48
|
+
const instant = Date.parse(args.at);
|
|
49
|
+
if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{3})?Z$/.test(args.at)
|
|
50
|
+
|| !Number.isFinite(instant)
|
|
51
|
+
|| new Date(instant).toISOString() !== args.at.replace(/Z$/, args.at.includes('.') ? 'Z' : '.000Z')) {
|
|
52
|
+
throw failure('invalid-measurement-time', 1);
|
|
53
|
+
}
|
|
54
|
+
return args;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Inspect every component before reading contents; parent rename races remain
|
|
58
|
+
* outside this portable path-based reader's guarantee (use stable copies). */
|
|
59
|
+
async function inspect(root, name) {
|
|
60
|
+
const target = path.resolve(root, name);
|
|
61
|
+
const relative = path.relative(root, target);
|
|
62
|
+
if (!relative || relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
|
|
63
|
+
throw failure('path-outside-input-root', 1);
|
|
64
|
+
}
|
|
65
|
+
const parsed = path.parse(target);
|
|
66
|
+
let current = parsed.root;
|
|
67
|
+
let stat;
|
|
68
|
+
const components = target.slice(parsed.root.length).split(path.sep);
|
|
69
|
+
for (let i = 0; i < components.length; i++) {
|
|
70
|
+
current = path.join(current, components[i]);
|
|
71
|
+
stat = await fs.lstat(current);
|
|
72
|
+
if (stat.isSymbolicLink()) throw failure('symlink-input', 1);
|
|
73
|
+
if (i < components.length - 1 && !stat.isDirectory()) throw failure('non-directory-parent', 1);
|
|
74
|
+
}
|
|
75
|
+
if (!stat?.isFile()) throw failure('non-regular-input', 1);
|
|
76
|
+
return { target, stat };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async function readSnapshot({ target, stat }) {
|
|
80
|
+
// The parent check above and this open are not atomic: a same-user process can swap a parent
|
|
81
|
+
// directory in between. Fine while the operator names the snapshot files and the result grants
|
|
82
|
+
// nothing; revisit (#1549) once paths come from a foreign owner or the result gates a decision.
|
|
83
|
+
if (typeof constants.O_NOFOLLOW !== 'number' || typeof constants.O_NONBLOCK !== 'number') {
|
|
84
|
+
throw failure('unsupported-safe-open', 2);
|
|
85
|
+
}
|
|
86
|
+
const handle = await fs.open(target, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
87
|
+
let bytes;
|
|
88
|
+
try {
|
|
89
|
+
const actual = await handle.stat();
|
|
90
|
+
if (!actual.isFile() || actual.dev !== stat.dev || actual.ino !== stat.ino) throw failure('changed-input-file', 1);
|
|
91
|
+
bytes = await handle.readFile();
|
|
92
|
+
} finally { await handle.close(); }
|
|
93
|
+
try { return JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(bytes)); }
|
|
94
|
+
catch { throw failure('malformed-json-or-utf8', 1); }
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function render(report) {
|
|
98
|
+
if (report.error) return `not-measurable: ${report.error.category}\n`;
|
|
99
|
+
return [`status: ${report.status}`, ...Object.entries(report.metrics).map(([key, metric]) =>
|
|
100
|
+
`${key}: ${metric.status === 'measured' ? metric.value : `not-measurable (${metric.reasons.join(', ')})`}`)].join('\n') + '\n';
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** The real CLI boundary; callers may route its output to their own streams. */
|
|
104
|
+
export async function main(argv = process.argv.slice(2), io = { stdout: process.stdout, stderr: process.stderr }) {
|
|
105
|
+
let args;
|
|
106
|
+
try {
|
|
107
|
+
args = argumentsFor(argv);
|
|
108
|
+
if (args.action === '--help') { io.stdout.write(HELP); return 0; }
|
|
109
|
+
if (args.action === '--version') {
|
|
110
|
+
const manifest = JSON.parse(await fs.readFile(new URL('../package.json', import.meta.url), 'utf8'));
|
|
111
|
+
io.stdout.write(`${manifest.version}\n`);
|
|
112
|
+
return 0;
|
|
113
|
+
}
|
|
114
|
+
const root = path.resolve(args.root);
|
|
115
|
+
// Inspect BOTH paths before opening either snapshot for content.
|
|
116
|
+
const registryFile = await inspect(root, args.registry);
|
|
117
|
+
const checkinFile = await inspect(root, args.checkins);
|
|
118
|
+
const registry = await readSnapshot(registryFile);
|
|
119
|
+
const checkins = await readSnapshot(checkinFile);
|
|
120
|
+
const report = auditCheckins({ registry, checkins, repos: args.repos, at: args.at });
|
|
121
|
+
io.stdout.write(args.json ? `${JSON.stringify(report)}\n` : render(report));
|
|
122
|
+
return report.status === 'measured' ? 0 : 3;
|
|
123
|
+
} catch (error) {
|
|
124
|
+
const category = error.category || 'input-io-error';
|
|
125
|
+
const errno = typeof error.code === 'string' && /^[A-Z0-9_]+$/.test(error.code) ? error.code : undefined;
|
|
126
|
+
const report = { version: 1, status: 'not-measurable', error: { category, ...(errno ? { errno } : {}) } };
|
|
127
|
+
io.stderr.write(`fleet-checkin-audit: ${category}${errno ? ` (${errno})` : ''}\n`);
|
|
128
|
+
io.stdout.write((args?.json || argv.includes('--json')) ? `${JSON.stringify(report)}\n` : render(report));
|
|
129
|
+
return error.exit || 2;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (isMainModule(import.meta.url)) {
|
|
134
|
+
main().then((code) => { process.exitCode = code; }, () => {
|
|
135
|
+
process.stderr.write('fleet-checkin-audit: output-io-error\n');
|
|
136
|
+
process.exitCode = 2;
|
|
137
|
+
});
|
|
138
|
+
}
|
package/scripts/lib/common.mjs
CHANGED
|
@@ -12,7 +12,7 @@ import path from 'node:path';
|
|
|
12
12
|
import crypto from 'node:crypto';
|
|
13
13
|
import { promises as fs, existsSync, statSync } from 'node:fs';
|
|
14
14
|
import { fileURLToPath } from 'node:url';
|
|
15
|
-
import {
|
|
15
|
+
import { commandOnPath } from './native-command.mjs';
|
|
16
16
|
|
|
17
17
|
// ---------------------------------------------------------------------------
|
|
18
18
|
// Path helpers
|
|
@@ -182,9 +182,7 @@ export function warn(message) {
|
|
|
182
182
|
* @throws {Error} when jq is not on PATH
|
|
183
183
|
*/
|
|
184
184
|
export function requireJq() {
|
|
185
|
-
|
|
186
|
-
execSync('command -v jq', { stdio: 'ignore', shell: true });
|
|
187
|
-
} catch {
|
|
185
|
+
if (!commandOnPath('jq')) {
|
|
188
186
|
throw new Error('jq is required but not installed. Install via: brew install jq');
|
|
189
187
|
}
|
|
190
188
|
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/** Format Claude JSON accounting without inventing a price or its provenance. */
|
|
2
|
+
import { isMainModule } from './is-main-module.mjs';
|
|
3
|
+
import { COST_BASIS } from './telemetry/pricing.mjs';
|
|
4
|
+
|
|
5
|
+
export const CONTEXT_OVERHEAD_HEADER =
|
|
6
|
+
'LABEL\tCONTEXT_TOK\tcache_create\tcache_read\toutput\tUSD\tCOST_BASIS\n';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* @param {string} text JSON response text
|
|
10
|
+
* @param {string} label measurement label
|
|
11
|
+
* @returns {string} TSV row, or a recognizable PARSE-ERROR / NO-USAGE row
|
|
12
|
+
*/
|
|
13
|
+
export function formatContextOverhead(text, label) {
|
|
14
|
+
let response;
|
|
15
|
+
try {
|
|
16
|
+
response = JSON.parse(text);
|
|
17
|
+
} catch {
|
|
18
|
+
return `${label}\tPARSE-ERROR\n`;
|
|
19
|
+
}
|
|
20
|
+
const usage = response?.usage;
|
|
21
|
+
// An error reply (e.g. "Not logged in") carries no usage; 0 tokens would be invented (#1547).
|
|
22
|
+
if (typeof usage !== 'object' || usage === null || Array.isArray(usage)) {
|
|
23
|
+
return `${label}\tNO-USAGE\n`;
|
|
24
|
+
}
|
|
25
|
+
const context =
|
|
26
|
+
(usage.input_tokens || 0) +
|
|
27
|
+
(usage.cache_creation_input_tokens || 0) +
|
|
28
|
+
(usage.cache_read_input_tokens || 0);
|
|
29
|
+
const cost = response?.total_cost_usd;
|
|
30
|
+
const validCost = typeof cost === 'number' && Number.isFinite(cost) && cost >= 0;
|
|
31
|
+
// Native numbers alone do not establish billing or API-list provenance.
|
|
32
|
+
const basis = validCost && response.cost_basis === COST_BASIS
|
|
33
|
+
? response.cost_basis
|
|
34
|
+
: 'unknown';
|
|
35
|
+
return [
|
|
36
|
+
label, context, usage.cache_creation_input_tokens || 0,
|
|
37
|
+
usage.cache_read_input_tokens || 0, usage.output_tokens || 0,
|
|
38
|
+
validCost ? cost.toFixed(4) : 'unknown', basis,
|
|
39
|
+
].join('\t') + '\n';
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (isMainModule(import.meta.url)) {
|
|
43
|
+
const args = process.argv.slice(2);
|
|
44
|
+
if (args.length === 1 && args[0] === '--header') {
|
|
45
|
+
process.stdout.write(CONTEXT_OVERHEAD_HEADER);
|
|
46
|
+
} else if (args.length === 2 && args[0] === '--') {
|
|
47
|
+
let text = '';
|
|
48
|
+
process.stdin.setEncoding('utf8');
|
|
49
|
+
process.stdin.on('data', (chunk) => { text += chunk; });
|
|
50
|
+
process.stdin.on('end', () => {
|
|
51
|
+
process.stdout.write(formatContextOverhead(text, args[1]));
|
|
52
|
+
});
|
|
53
|
+
} else {
|
|
54
|
+
process.stderr.write('Usage: context-overhead-format.mjs --header | -- <label> (JSON on stdin)\n');
|
|
55
|
+
process.exitCode = 1;
|
|
56
|
+
}
|
|
57
|
+
}
|