session-orchestrator 5.10.0 → 5.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. package/.claude-plugin/marketplace.json +12 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.codex-plugin/plugin.json +1 -1
  4. package/.cursor-plugin/plugin.json +1 -1
  5. package/CHANGELOG.md +51 -0
  6. package/README.md +22 -11
  7. package/SECURITY.md +1 -1
  8. package/agents/code-implementer.md +7 -1
  9. package/commands/session.md +3 -1
  10. package/docs/agent-authoring.md +2 -0
  11. package/docs/events-schema.md +2 -1
  12. package/docs/fleet-checkin-audit.md +172 -0
  13. package/docs/install.md +12 -8
  14. package/docs/role-profile-contract.md +139 -0
  15. package/docs/scope-collision-guard.md +3 -0
  16. package/docs/session-config-reference.md +7 -7
  17. package/docs/session-config-template.md +2 -2
  18. package/evals/instruction-ablation/run.mjs +11 -6
  19. package/hooks/_lib/hook-import-set.json +27 -2
  20. package/hooks/_lib/orchestrator-ignore.mjs +112 -0
  21. package/hooks/hooks-codex.json +1 -1
  22. package/hooks/hooks.json +1 -1
  23. package/hooks/on-session-end.mjs +8 -1
  24. package/hooks/on-session-start.mjs +26 -10
  25. package/hooks/post-bash-write-verify.mjs +16 -3
  26. package/hooks/pre-task-scope-disjoint.mjs +12 -0
  27. package/hooks/subagent-telemetry.mjs +8 -3
  28. package/hooks/wave-scope-commit-guard.mjs +4 -1
  29. package/package.json +1 -2
  30. package/rules/opt-in-stack/security-web.md +1 -1
  31. package/scripts/emit-session.mjs +13 -0
  32. package/scripts/fleet-checkin-audit.mjs +138 -0
  33. package/scripts/fleet-checkin.mjs +61 -3
  34. package/scripts/lib/common.mjs +2 -4
  35. package/scripts/lib/config/remote-hosts.mjs +2 -2
  36. package/scripts/lib/config.mjs +1 -1
  37. package/scripts/lib/context-overhead-format.mjs +57 -0
  38. package/scripts/lib/fleet-checkin-audit.mjs +244 -0
  39. package/scripts/lib/fleet-protocol.mjs +17 -0
  40. package/scripts/lib/memory-proposals/sink.mjs +79 -17
  41. package/scripts/lib/memory-proposals/store.mjs +245 -9
  42. package/scripts/lib/project-hygiene.mjs +37 -0
  43. package/scripts/lib/role-profile/schema.mjs +219 -0
  44. package/scripts/lib/session-end/vault-orphans.mjs +205 -0
  45. package/scripts/lib/session-invocation.mjs +14 -3
  46. package/scripts/lib/session-lock.mjs +57 -10
  47. package/scripts/lib/session-record-repair.mjs +63 -30
  48. package/scripts/lib/session-registry.mjs +14 -4
  49. package/scripts/lib/session-schema/constants.mjs +8 -0
  50. package/scripts/lib/session-schema/validator.mjs +33 -0
  51. package/scripts/lib/so-hud/collect.mjs +130 -0
  52. package/scripts/lib/so-hud/runtime.mjs +76 -0
  53. package/scripts/lib/state-md/idle-reset.mjs +239 -0
  54. package/scripts/lib/state-md/recommendations.mjs +26 -4
  55. package/scripts/lib/subagents-schema.mjs +9 -0
  56. package/scripts/lib/telemetry/pricing.mjs +11 -0
  57. package/scripts/lib/vault-mirror/namespace.mjs +3 -2
  58. package/scripts/lib/vault-mirror/process.mjs +143 -40
  59. package/scripts/lib/vault-mirror/render-learnings.mjs +6 -5
  60. package/scripts/lib/vault-mirror/utils.mjs +27 -13
  61. package/scripts/lib/vault-status/board-writer.mjs +32 -4
  62. package/scripts/measure-context-overhead.sh +4 -21
  63. package/scripts/release.mjs +359 -11
  64. package/scripts/resolve-session-invocation.mjs +64 -8
  65. package/scripts/run-quality-gate.mjs +47 -0
  66. package/scripts/so-hud.mjs +45 -0
  67. package/scripts/state-md-idle-reset.mjs +81 -0
  68. package/scripts/validate-wave-scope.mjs +59 -6
  69. package/scripts/vault-orphans.mjs +171 -0
  70. package/skills/_shared/fleet-protocol.md +24 -2
  71. package/skills/_shared/state-ownership.md +3 -3
  72. package/skills/autopilot/SKILL.md +3 -2
  73. package/skills/claude-md-drift-check/checker.mjs +8 -6
  74. package/skills/npm-publish/SKILL.md +3 -3
  75. package/skills/release/SKILL.md +2 -2
  76. package/skills/repo-audit/SKILL.md +1 -1
  77. package/skills/session-end/SKILL.md +3 -2
  78. package/skills/session-end/metrics-collection.md +2 -2
  79. package/skills/session-end/references/phase-3-7e-vault-orphans.md +63 -0
  80. package/skills/session-end/references/phase-3-documentation-updates.md +11 -1
  81. package/skills/session-end/references/session-summary-template.md +4 -0
  82. package/skills/session-end/session-metrics-write.md +1 -1
  83. package/skills/session-start/SKILL.md +1 -1
  84. package/skills/session-start/phase-7-5-mode-selector.md +3 -1
  85. package/skills/session-start/references/phase-1-2-session-lock.md +1 -1
  86. package/skills/session-start/references/phase-1-5-session-continuity.md +12 -4
  87. package/skills/wave-executor/circuit-breaker.md +1 -0
  88. package/AGENTS.md +0 -205
@@ -301,6 +301,18 @@ const DISPATCH_TOOL = 'Agent';
301
301
  * A flat sibling, not a directory, on purpose: bootstrap's `store-lock-ignore`
302
302
  * step (#1495) ignores `.orchestrator/wave-dispatch-scopes.*` in consumer repos,
303
303
  * which covers this name; a directory would surface untracked in each of them.
304
+ *
305
+ * NAMED CEILING (BV-004, #1504 point 7a): nothing ever deletes a per-session
306
+ * file — the hook only writes its own, and no sweep exists (a sweep would delete
307
+ * files that may be a live peer's state, PSA-003). Each FILE is bounded: it holds
308
+ * one `waveKey`'s agents, reset on the next wave. The DIRECTORY is not: it grows
309
+ * by one file (plus a transient `.lock`) per session that dispatched an agent.
310
+ * Measured 2026-10-08 in this repo's main checkout: 7 files, largest 2918 bytes,
311
+ * oldest 2026-10-03 — negligible. Fine under ~500 files; the hook never lists
312
+ * the directory, so the count costs disk, not latency.
313
+ * Revisit-Trigger: `ls .orchestrator/wave-dispatch-scopes.*.json | wc -l`
314
+ * passes 500 in any repo, OR a reader starts enumerating these files — then
315
+ * build an age-gated sweep that skips any session with a live `session.lock`.
304
316
  */
305
317
  const LEDGER_DIR_REL = '.orchestrator';
306
318
  const LEDGER_STEM = 'wave-dispatch-scopes';
@@ -113,6 +113,7 @@ import { appendSubagent } from '../scripts/lib/subagents-schema.mjs';
113
113
  import { getProjectDir } from '../scripts/lib/platform.mjs';
114
114
  import { resolveSubagentSidecar } from './_lib/subagent-paths.mjs';
115
115
  import { readTailWindow } from '../scripts/lib/tail-window.mjs';
116
+ import { COST_BASIS } from '../scripts/lib/telemetry/pricing.mjs';
116
117
 
117
118
  // ---------------------------------------------------------------------------
118
119
  // Constants
@@ -859,14 +860,18 @@ async function main() {
859
860
  // the rollup can price it part by part; absent on every other record.
860
861
  if (modelsUsage !== null) record.models_usage = modelsUsage;
861
862
 
862
- // Cost is best-effort / forward-compat (#624): the native transcript does
863
- // NOT expose total_cost_usd today, so this is null in practice. No rate
864
- // table is applied HERE — use the native cost only, default null when absent.
863
+ // Cost is best-effort (#624): retain the native payload value when valid,
864
+ // including a genuine zero; use null when absent. No rate table is applied
865
+ // HERE. Provenance (#1543) requires an explicit recognized payload basis;
866
+ // a native number alone says nothing about billing or API list prices.
865
867
  const totalCostUsd =
866
868
  typeof input.total_cost_usd === 'number' && Number.isFinite(input.total_cost_usd) && input.total_cost_usd >= 0
867
869
  ? input.total_cost_usd
868
870
  : null;
869
871
  record.total_cost_usd = totalCostUsd;
872
+ record.cost_basis = totalCostUsd !== null && input.cost_basis === COST_BASIS
873
+ ? input.cost_basis
874
+ : null;
870
875
 
871
876
  // OTel alias — #411 additive. `gen_ai.usage.input_tokens` stays the RAW
872
877
  // UNCACHED value (OTel semantic), which is why it is deliberately NOT equal
@@ -336,7 +336,10 @@ async function main() {
336
336
  // when every candidate is foreign the first comes back and
337
337
  // `scopeCommitVerdict` passes it as before. The git process inherits the
338
338
  // committing session's env; there is no hook payload here, so the env tier is
339
- // the only process-local identity.
339
+ // the only process-local identity. CEILING (#1504 point 7b): a shell that
340
+ // exports a foreign CLAUDE_CODE_SESSION_ID makes this session's manifest read
341
+ // `foreign` and the guard stands down: the same power `--no-verify` grants, so it
342
+ // stays named, not fixed (docs/scope-collision-guard.md § 6 item 12).
340
343
  const ownIds = new Set(readProcessLocalSessionIds());
341
344
 
342
345
  // #1511 point d — ALSO read the manifest at the SESSION root the scope guards
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "session-orchestrator",
3
- "version": "5.10.0",
3
+ "version": "5.12.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",
@@ -28,7 +28,6 @@
28
28
  },
29
29
  "files": [
30
30
  ".mcp.json",
31
- "AGENTS.md",
32
31
  "CHANGELOG.md",
33
32
  "pi/",
34
33
  "skills/",
@@ -19,7 +19,7 @@ tier: wave-only
19
19
 
20
20
  CSRF, rate limiting, CSP, and transport security. Core security rules are in `security.md` (always-on).
21
21
 
22
- ## CSRF Protection (SEC-005)
22
+ ## CSRF Protection (SEC-018)
23
23
  - **Next.js 15+ Server Actions**: Built-in CSRF protection via Origin header validation against Host header. No manual CSRF token needed.
24
24
  - **Manual CSRF tokens required for**: custom API routes (`/api/*`), form submissions to external endpoints, any non-Server-Action mutation endpoint.
25
25
  - For manual CSRF token validation, use `crypto.timingSafeEqual()` for timing-safe comparison.
@@ -39,6 +39,7 @@ import {
39
39
  import { parseStateMd, readSessionProfile, resolveStateMdPath } from './lib/state-md.mjs';
40
40
  import { readProcessLocalSessionIds } from './lib/session-identity/own-session.mjs';
41
41
  import { rollupSessionTokens } from './lib/session-token-rollup.mjs';
42
+ import { COST_BASIS, PRICING_TABLE_DATE } from './lib/telemetry/pricing.mjs';
42
43
  import { serializeSessionLineChecked } from './lib/session-schema/serializer.mjs';
43
44
  import {
44
45
  validateSession,
@@ -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
+ }
@@ -15,6 +15,10 @@
15
15
  * Then reads the navigator lease and emits `orchestrator.fleet.checkin` with the
16
16
  * payload `{ session, repo, modus, kandidaten, navigator_state }` — no paths, no
17
17
  * quota, no `auftrag_ref`.
18
+ * Without fleet integration, valid input exits 0 with `skipped: "no-fleet"` and no directory, file or event.
19
+ * That holds for `plattform: "kopflos"` too: the record cannot prove a coordinator started the run,
20
+ * and a write would create the navigator directory, which then flips every later solo session to
21
+ * fleet. A headless run without fleet integration applies the Standard-Auflagen instead (#1559 review).
18
22
  *
19
23
  * stdout: one JSON line
20
24
  * `{ ok, path, navigator_state, navigator_adresse, fallback_due_at, event }`.
@@ -29,16 +33,17 @@
29
33
  * Exit codes:
30
34
  * 0 — check-in written (an event failure only WARNs; `event: false` on stdout)
31
35
  * 1 — write error
32
- * 2 — invalid input (not JSON, not an object, or validation errors; no file)
36
+ * 2 — invalid input or semantic session ID occupied by another repo (no write/event)
33
37
  */
34
38
 
35
39
  import { randomBytes } from 'node:crypto';
36
- import { promises as fs } from 'node:fs';
40
+ import { promises as fs, constants as fsConstants } from 'node:fs';
37
41
  import os from 'node:os';
38
42
  import path from 'node:path';
39
43
  import { emitEvent } from './lib/events.mjs';
40
44
  import {
41
45
  checkinPath,
46
+ fleetIntegrationEnabled,
42
47
  fallbackDueAt,
43
48
  navigatorDir,
44
49
  readNavigatorLease,
@@ -63,6 +68,7 @@ async function readStdin() {
63
68
  * planted symlink cannot redirect the chmod or the write.
64
69
  * @param {string} target
65
70
  * @param {object} record
71
+ * @returns {Promise<boolean>} false when another repo already owns the target
66
72
  */
67
73
  async function writeCheckinFile(target, record) {
68
74
  const checkinDir = path.dirname(target);
@@ -79,7 +85,46 @@ async function writeCheckinFile(target, record) {
79
85
  await fs.writeFile(tmp, JSON.stringify(record, null, 2) + '\n', { mode: 0o600, flag: 'wx' });
80
86
  // The creation mode is filtered through the umask; pin it explicitly.
81
87
  await fs.chmod(tmp, 0o600);
88
+ let existing;
89
+ try {
90
+ // Fail-open on platforms without safe-open flags; never fall back to a
91
+ // potentially blocking read that follows symlinks.
92
+ if (typeof fsConstants.O_NOFOLLOW === 'number' && typeof fsConstants.O_NONBLOCK === 'number') {
93
+ const handle = await fs.open(target, fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW | fsConstants.O_NONBLOCK);
94
+ try {
95
+ const stat = await handle.stat();
96
+ const limit = 64 * 1024;
97
+ // Check-ins are small: non-files and larger targets are unreadable for
98
+ // this guard and fail-open. Bound reads even if the file grows after stat.
99
+ if (stat.isFile() && stat.size <= limit) {
100
+ const buffer = Buffer.alloc(limit);
101
+ let length = 0;
102
+ while (length < limit) {
103
+ const { bytesRead } = await handle.read(buffer, length, limit - length, length);
104
+ if (bytesRead === 0) break;
105
+ length += bytesRead;
106
+ }
107
+ if ((await handle.stat()).size <= limit) {
108
+ existing = JSON.parse(buffer.subarray(0, length).toString('utf8'));
109
+ }
110
+ }
111
+ } finally {
112
+ await handle.close();
113
+ }
114
+ }
115
+ } catch {
116
+ // Fail-open: an unreadable or invalid-JSON target gives the navigator no readable check-in to preserve.
117
+ }
118
+ if (typeof existing?.repo === 'string' && existing.repo !== record.repo) {
119
+ await fs.rm(tmp, { force: true });
120
+ process.stderr.write(`${PREFIX} semantic session ID ${record.session} is already occupied by another repo: ${JSON.stringify(existing.repo)}\n`);
121
+ return false;
122
+ }
123
+ // BV-004 ceiling: protects an already-visible foreign check-in, not concurrent
124
+ // writers between read and rename; revisit with atomic coordination when a
125
+ // simultaneous cross-repo collision is observed.
82
126
  await fs.rename(tmp, target);
127
+ return true;
83
128
  } catch (err) {
84
129
  await fs.rm(tmp, { force: true }).catch(() => {});
85
130
  throw err;
@@ -107,9 +152,22 @@ async function main() {
107
152
  return 2;
108
153
  }
109
154
 
155
+ if (!fleetIntegrationEnabled()) {
156
+ process.stdout.write(JSON.stringify({
157
+ ok: true,
158
+ skipped: 'no-fleet',
159
+ path: null,
160
+ navigator_state: 'none',
161
+ navigator_adresse: null,
162
+ fallback_due_at: null,
163
+ event: false,
164
+ }) + '\n');
165
+ return 0;
166
+ }
167
+
110
168
  const target = checkinPath(record.session);
111
169
  try {
112
- await writeCheckinFile(target, record);
170
+ if (!await writeCheckinFile(target, record)) return 2;
113
171
  } catch (err) {
114
172
  process.stderr.write(`${PREFIX} write failed: ${err instanceof Error ? err.message : String(err)}\n`);
115
173
  return 1;
@@ -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 { execSync } from 'node:child_process';
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
- try {
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
  }
@@ -18,7 +18,7 @@ import { preprocessBlockLinesNoDash } from './block-preprocess.mjs';
18
18
  *
19
19
  * Block shape:
20
20
  * remote-hosts:
21
- * - alias: m5 # required; becomes `-H <alias>` argv
21
+ * - alias: build-host # required; becomes `-H <alias>` argv
22
22
  * roles-allowed: [test, ui, perf] # subset of ALLOWED_REMOTE_ROLES
23
23
  * repo-path: ~/Projects/Alice # optional
24
24
  * claude-path: ~/.local/bin/claude # optional
@@ -54,7 +54,7 @@ export const ALLOWED_REMOTE_ROLES = Object.freeze(['test', 'ui', 'perf']);
54
54
  // The first character is anchored separately from the rest because a charset
55
55
  // cannot express a POSITION: an alias of `-H` passes any hyphen-inclusive
56
56
  // allowlist and then reaches argv as an OPTION token, where the CLI swallows the
57
- // operand behind it. Interior hyphens (`m5-box`) stay legal.
57
+ // operand behind it. Interior hyphens (`build-host`) stay legal.
58
58
  const SAFE_NAME_RE = /^[A-Za-z0-9._][A-Za-z0-9._-]*$/;
59
59
 
60
60
  // `repo-path` / `claude-path` are filesystem paths handed to a remote shell —
@@ -327,7 +327,7 @@ export function parseSessionConfig(mdContent, { hostPaths } = {}) {
327
327
  // dispatched natively, exactly as before.
328
328
  // impl: code-implementer → native Agent dispatch (unchanged)
329
329
  // impl: cursor:composer-2.5 → foreign model over the Cursor channel
330
- // test: ssh:m5 → same Claude, another host (#1160)
330
+ // test: ssh:build-host → same Claude, another host (#1160)
331
331
  // An unknown prefix is a typo or a channel this build cannot route, and
332
332
  // must fail loudly: silently accepting it would dispatch to an agent that
333
333
  // does not exist, which surfaces only as an empty wave much later.
@@ -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
+ }