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
@@ -0,0 +1,130 @@
1
+ import path from 'node:path';
2
+ import { parseStateMd } from '../state-md/yaml-parser.mjs';
3
+
4
+ export const EVENT_NAMES = new Set([
5
+ 'orchestrator.quality_gate.passed', 'orchestrator.quality_gate.failed',
6
+ 'orchestrator.vault.mirror_run_completed', 'orchestrator.vault.board_written',
7
+ ]);
8
+ const text = v => typeof v === 'string' && v.length ? v : null;
9
+ const number = v => typeof v === 'number' && Number.isFinite(v) ? v : null;
10
+ const positive = v => Number.isSafeInteger(v) && v > 0 ? v : null;
11
+ export const safeId = v => typeof v === 'string' && /^[a-zA-Z0-9_-]{1,200}$/.test(v);
12
+
13
+ export function parseEvents(tail) {
14
+ return tail.split('\n').flatMap(line => {
15
+ try {
16
+ const e = JSON.parse(line);
17
+ return EVENT_NAMES.has(e.event ?? e.event_type) ? [e] : [];
18
+ } catch { return []; }
19
+ }).sort((a, b) => (Date.parse(a.timestamp) || 0) - (Date.parse(b.timestamp) || 0));
20
+ }
21
+
22
+ export function gateFromEvent(e) {
23
+ if (!e) return null;
24
+ const failed = (e.event ?? e.event_type).endsWith('.failed') || e.check_failed === true || e.timed_out === true || (number(e.exit_code) !== null && e.exit_code !== 0);
25
+ const reporting = ['baseline', 'incremental', 'per-file'].includes(e.variant);
26
+ const proven = e.exit_code === 0 && (reporting ? e.check_failed === false : ['full-gate', 'auto-fix-loop'].includes(e.variant));
27
+ return { variant: text(e.variant), passed: failed ? false : proven ? true : null,
28
+ check_failed: typeof e.check_failed === 'boolean' ? e.check_failed : null,
29
+ exit_code: number(e.exit_code), at: text(e.timestamp) };
30
+ }
31
+
32
+ /** Both scope sources must be readable; incomplete dispatch never proves disjointness. */
33
+ export function scopePaths(aggregate, dispatch) {
34
+ if (!Array.isArray(aggregate) || !dispatch || !Array.isArray(dispatch.agents)) return null;
35
+ if ((dispatch.scopeSignals?.['marker-absent'] ?? 0) > 0 || (dispatch.scopeSignals?.unparseable ?? 0) > 0) return null;
36
+ const rows = [...aggregate, ...dispatch.agents];
37
+ if (rows.some(r => !r || !Array.isArray(r.files) || r.files.some(f => typeof f !== 'string'))) return null;
38
+ return [...new Set(rows.flatMap(r => r.files))];
39
+ }
40
+
41
+ /** All I/O and time are supplied. No writes, host discovery or defaults hidden in this fold. */
42
+ export async function collectSnapshot(repoRoot, readers) {
43
+ const degraded = [];
44
+ const read = async (file, json = false) => {
45
+ try {
46
+ const raw = await readers.readText(path.join(repoRoot, file));
47
+ return { value: json ? JSON.parse(raw) : raw, absent: false };
48
+ } catch (e) {
49
+ if (e.code !== 'ENOENT') degraded.push(`${file}:unreadable`);
50
+ return { value: null, absent: e.code === 'ENOENT' };
51
+ }
52
+ };
53
+ const now = readers.now();
54
+ const [lockResult, waveRead, stateRead, agentStatus, discovery, eventsRead, config, greenRead] = await Promise.all([
55
+ readers.lock(repoRoot), read('.claude/wave-scope.json', true),
56
+ readers.statePath ? read(readers.statePath) : read('.claude/STATE.md'),
57
+ readers.agents(repoRoot), readers.discover(repoRoot), readers.events(repoRoot), readers.config(repoRoot), read('.orchestrator/runtime/last-green-sha.txt'),
58
+ ]);
59
+ degraded.push(...(discovery.degraded ?? []), ...(eventsRead.degraded ?? []), ...(config.degraded ?? []));
60
+ if (!['ok', 'absent'].includes(lockResult.status)) degraded.push(`lock:${lockResult.status}`);
61
+ const lock = lockResult.lock ?? null;
62
+ const sid = readers.sessionId ?? lock?.session_id ?? null;
63
+ const wave = waveRead.value && typeof waveRead.value === 'object' && !Array.isArray(waveRead.value) ? waveRead.value : null;
64
+ const foreignWave = wave?.session_id && sid && wave.session_id !== sid;
65
+ if (foreignWave) degraded.push('wave:foreign-session');
66
+ const ownWave = foreignWave || !positive(wave?.wave) ? null : wave;
67
+ if (!waveRead.absent && !ownWave && !foreignWave) degraded.push('wave:invalid-or-unreadable');
68
+ const waveNumber = positive(ownWave?.wave);
69
+ const fm = parseStateMd(stateRead.value)?.frontmatter ?? {};
70
+ const foreignState = fm['session-id'] && sid && fm['session-id'] !== sid;
71
+ if (foreignState) degraded.push('state:foreign-session');
72
+ const state = foreignState ? {} : fm;
73
+ const dispatch = safeId(sid) ? (await read(`.orchestrator/wave-dispatch-scopes.${sid}.json`, true)).value : null;
74
+ const dispatchWave = positive(Number(String(dispatch?.waveKey ?? '').match(/\|w(\d+)\|/)?.[1]));
75
+ const currentDispatch = dispatch && waveNumber && dispatchWave === waveNumber && Array.isArray(dispatch.agents) ? dispatch : null;
76
+ const entries = new Map();
77
+ for (const a of currentDispatch?.agents ?? []) {
78
+ if (!text(a?.id)) continue;
79
+ entries.set(a.id, { id: a.id, typ: text(a.typ ?? a.agent_type ?? a.type), modellfamilie: text(a.modellfamilie ?? a.model), status: null, tokens: null, letzter_request_at: null, quelle: 'dispatch' });
80
+ }
81
+ for (const [id, a] of Object.entries(agentStatus.entries ?? {})) {
82
+ // Unbound/foreign status cannot make the current wave look alive.
83
+ if (!sid || (a.session_id ?? a.sessionId) !== sid) continue;
84
+ if (waveNumber && (a.wave ?? a.wave_number) !== waveNumber) continue;
85
+ const existing = entries.get(id);
86
+ entries.set(id, { id, typ: text(a.agent_type) ?? existing?.typ ?? null, modellfamilie: text(a.model) ?? existing?.modellfamilie ?? null,
87
+ status: text(a.status ?? a.text), tokens: null, letzter_request_at: null, quelle: existing ? 'dispatch+agent-status' : 'agent-status' });
88
+ }
89
+ if (agentStatus.degraded) degraded.push('agents:degraded');
90
+ const events = parseEvents(eventsRead.text ?? '');
91
+ const gates = events.filter(e => (e.event ?? e.event_type).startsWith('orchestrator.quality_gate.'));
92
+ const lastGate = gates.reduce((last, event) => {
93
+ const next = gateFromEvent(event);
94
+ return last?.passed === false && next.passed === null ? last : next;
95
+ }, null);
96
+ const lockLive = lock ? readers.isLockLive(lock, now) : false;
97
+ const sessionLockLive = lockLive && sid !== null && lock.session_id === sid;
98
+ const latest = name => events.filter(e => (e.event ?? e.event_type) === name && e.dry_run !== true).at(-1);
99
+ const mirror = latest('orchestrator.vault.mirror_run_completed');
100
+ const board = latest('orchestrator.vault.board_written');
101
+ const peers = await Promise.all((discovery.sessions ?? []).filter(s => s.sessionId !== sid).map(async s => {
102
+ const root = s.worktreePath;
103
+ const peerRead = async file => { try { return JSON.parse(await readers.readText(path.join(root, file))); } catch { return null; } };
104
+ const peerLock = (await readers.lock(root)).lock;
105
+ const peerStateRaw = await readers.readText(path.join(root, '.claude/STATE.md')).catch(() => null);
106
+ const peerFm = parseStateMd(peerStateRaw)?.frontmatter;
107
+ const bound = peerFm?.['session-id'] === s.sessionId;
108
+ const stateWave = bound ? positive(peerFm['current-wave']) : null;
109
+ const pd = safeId(s.sessionId) ? await peerRead(`.orchestrator/wave-dispatch-scopes.${s.sessionId}.json`) : null;
110
+ const dispatchNumber = pd?.waveKey?.startsWith(`${s.sessionId}|`) ? positive(Number(pd.waveKey.split('|')[1]?.slice(1))) : null;
111
+ const currentWave = stateWave ?? dispatchNumber;
112
+ const aggregate = currentWave ? await peerRead(`.claude/filescopes/wave-${currentWave}.scopes.json`) : null;
113
+ return { session_id: s.sessionId, branch: text(s.branch), status: bound ? text(peerFm.status) : null,
114
+ current_wave: currentWave, last_heartbeat: peerLock?.session_id === s.sessionId ? text(peerLock.last_heartbeat) : text(s.last_heartbeat),
115
+ gleiches_working_copy: path.resolve(root) === path.resolve(repoRoot), scope_paths: dispatchNumber !== null && dispatchNumber !== currentWave ? null : scopePaths(aggregate, pd) };
116
+ }));
117
+ return {
118
+ schema_version: 1, measured_at: new Date(now).toISOString(), repo: { root: repoRoot, name: path.basename(repoRoot) },
119
+ lock: lock ? { session_id: text(lock.session_id), live: lockLive, last_heartbeat: text(lock.last_heartbeat) } : null,
120
+ session: sid ? { session_id: sid, semantic_session_id: text(lock?.semantic_session_id) } : null,
121
+ wave: { running: waveRead.absent ? false : ownWave ? sessionLockLive : null, number: waveNumber, role: text(ownWave?.role),
122
+ agents_total: Array.isArray(currentDispatch?.agents) ? currentDispatch.agents.length : null, source: ownWave ? '.claude/wave-scope.json' : null },
123
+ agents: { entries: [...entries.values()], source: agentStatus.source ?? (currentDispatch ? 'dispatch' : null), at: agentStatus.at ?? text(currentDispatch?.updated), degraded: agentStatus.degraded ?? null },
124
+ state: { status: text(state.status), session_type: text(state['session-type'] ?? state.type ?? state.mode), current_wave: positive(state['current-wave']), total_waves: positive(state['total-waves'] ?? state.waves) },
125
+ gate: { last: lastGate, last_green_sha: typeof greenRead.value === 'string' && /^[a-f0-9]{7,64}$/i.test(greenRead.value.trim()) ? greenRead.value.trim() : null },
126
+ peers, vault: { enabled: config.enabled ?? null, mode: config.mode ?? null,
127
+ last_run: mirror ? { at: text(mirror.timestamp), created: number(mirror.created), updated: number(mirror.updated), failed: number(mirror.failed), aborted: mirror.aborted ?? null } : null,
128
+ board: board ? { action: text(board.action), at: text(board.timestamp) } : null }, degraded: [...new Set(degraded)],
129
+ };
130
+ }
@@ -0,0 +1,76 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { execFile } from 'node:child_process';
4
+ import { promisify } from 'node:util';
5
+ import { discoverActiveSessions } from '../session-discovery.mjs';
6
+ import { readRegistry, activeDir } from '../session-registry.mjs';
7
+ import { readCurrentStatus } from '../agent-status.mjs';
8
+ import { readLockDetailed, isLockLive } from '../session-lock.mjs';
9
+ import { readTailWindow } from '../tail-window.mjs';
10
+ import { parseSessionConfig } from '../config.mjs';
11
+ import { resolveStateMdPath } from '../state-md.mjs';
12
+ import { collectSnapshot } from './collect.mjs';
13
+ const exec = promisify(execFile);
14
+ export const MAX_EVENT_BYTES = 512 * 1024;
15
+
16
+ export function readEventTail(repoRoot) {
17
+ try {
18
+ const tail = readTailWindow(path.join(repoRoot, '.orchestrator/metrics/events.jsonl'), MAX_EVENT_BYTES);
19
+ return { text: tail.cut ? tail.text.slice(tail.text.indexOf('\n') + 1) : tail.text, degraded: tail.cut ? ['events:tail-only'] : [] };
20
+ } catch (e) { return { text: '', degraded: [e.code === 'ENOENT' ? 'events:absent' : 'events:unreadable'] }; }
21
+ }
22
+
23
+ async function registryWithHealth() {
24
+ // The canonical reader intentionally swallows failures; distinguish its directory first.
25
+ try { await fs.readdir(activeDir()); }
26
+ catch (e) { if (e.code === 'ENOENT') return []; throw e; }
27
+ return readRegistry();
28
+ }
29
+
30
+ /** Capture fallback at the injected boundaries, without intercepting global stderr. */
31
+ export async function discover(repoRoot, { run = exec, registry = registryWithHealth, discovery = discoverActiveSessions, now = Date.now() } = {}) {
32
+ const degraded = [];
33
+ let registryEntries = [];
34
+ const sessions = await discovery(repoRoot, {
35
+ now, timeoutMs: 1000,
36
+ listWorktreesImpl: async () => {
37
+ try {
38
+ const { stdout } = await run('git', ['-C', repoRoot, 'worktree', 'list', '--porcelain'], { timeout: 700, maxBuffer: 512 * 1024 });
39
+ return stdout.trim().split('\n\n').filter(Boolean).map(block => {
40
+ const lines = block.split('\n');
41
+ return { path: lines.find(l => l.startsWith('worktree '))?.slice(9), branch: lines.find(l => l.startsWith('branch '))?.slice(7).replace(/^refs\/heads\//, '') ?? '' };
42
+ }).filter(w => w.path);
43
+ } catch (e) {
44
+ degraded.push(e.killed || e.code === 'ETIMEDOUT' ? 'discovery-timeout' : 'discovery-fallback');
45
+ return [{ path: repoRoot, branch: '' }];
46
+ }
47
+ },
48
+ registryReader: async () => {
49
+ try { registryEntries = await registry(); return registryEntries; } catch { degraded.push('registry-unreadable'); return []; }
50
+ },
51
+ });
52
+ if (sessions.some(s => s.registryOnly)) degraded.push('discovery:registry-fallback');
53
+ return { sessions: sessions.map(s => ({ ...s, last_heartbeat: registryEntries.find(r => r.session_id === s.sessionId)?.last_heartbeat ?? null })), degraded };
54
+ }
55
+
56
+ async function config(repoRoot) {
57
+ for (const name of ['CLAUDE.md', 'AGENTS.md']) {
58
+ try {
59
+ const source = await fs.readFile(path.join(repoRoot, name), 'utf8');
60
+ if (!/^vault-integration:/m.test(source)) continue;
61
+ // No host-local owner.yaml reads and no vault path resolution outside this repo.
62
+ const parsed = parseSessionConfig(source, { hostPaths: { env: {}, ownerConfig: {} } });
63
+ const value = parsed['vault-integration'];
64
+ return { enabled: value?.enabled ?? null, mode: value?.mode ?? null };
65
+ } catch (e) { if (e.code !== 'ENOENT') return { degraded: ['config:unreadable'] }; }
66
+ }
67
+ return { enabled: null, mode: null };
68
+ }
69
+ export async function measure(repoRoot) {
70
+ return collectSnapshot(repoRoot, {
71
+ now: Date.now, readText: file => fs.readFile(file, 'utf8'), lock: root => readLockDetailed({ repoRoot: root }),
72
+ isLockLive, agents: root => readCurrentStatus({ repoRoot: root }), discover, events: readEventTail, config,
73
+ statePath: path.relative(repoRoot, resolveStateMdPath(repoRoot)),
74
+ sessionId: process.env.CLAUDE_SESSION_ID ?? process.env.SO_SESSION_ID ?? null,
75
+ });
76
+ }
@@ -0,0 +1,239 @@
1
+ /**
2
+ * Idle Reset for STATE.md (issue #1560) — the code form of session-start
3
+ * Phase 1.5 § "Idle Reset" (skills/session-start/references/phase-1-5-session-continuity.md).
4
+ *
5
+ * Until #1560 the reset existed only as prose and every coordinator rewrote
6
+ * STATE.md by hand. Its Rule 6 deleted the five recommendation keys from the
7
+ * frontmatter, which is exactly what Phase 7.5's `buildLiveSignals()` reads —
8
+ * so after every clean `/close` the Mode-Selector saw no recommendation and
9
+ * fell back to `feature` at confidence 0. The keys now STAY in the frontmatter:
10
+ * they are the session-to-session handoff, and session-end Phase 3.7a (their
11
+ * sole writer) overwrites them at the next close.
12
+ *
13
+ * Deliberately NOT re-exported from `scripts/lib/state-md.mjs`: that barrel is
14
+ * part of the hook import closure (`hooks/_lib/hook-import-set.json`), and no
15
+ * hook needs the reset. The CLI `scripts/state-md-idle-reset.mjs` imports this
16
+ * module directly.
17
+ */
18
+
19
+ import { parseStateMd, serializeStateMd } from './yaml-parser.mjs';
20
+ import { existsSync } from 'node:fs';
21
+ import { resolveStateMdPath, updateFrontmatterFields, writeStateMd } from './frontmatter-mutators.mjs';
22
+ import { parseRecommendations } from './recommendations.mjs';
23
+
24
+ /** Rule 7: the drift-baseline keys that must not survive into session N+1. */
25
+ const SCOPE_BASELINE_KEYS = [
26
+ 'scope-baseline-intent',
27
+ 'scope-baseline-owner-boundary',
28
+ 'scope-baseline-planned-files',
29
+ 'scope-baseline-session',
30
+ 'scope-baseline-frozen-at',
31
+ ];
32
+
33
+ /** A level-2 heading (`## X`), never a deeper one (`### X`). */
34
+ const H2_RE = /^##(?!#)\s/;
35
+
36
+ /** Rule 6: heading of the human-readable recommendations copy. */
37
+ const RECOMMENDATIONS_HEADING = '### Recommendations (archived from v1.1 frontmatter)';
38
+
39
+ /**
40
+ * Splits a STATE.md body into the text before the first level-2 heading and
41
+ * one record per level-2 section. Every record keeps its raw lines (heading
42
+ * included, up to the next level-2 heading), so re-joining an untouched
43
+ * section is byte-identical.
44
+ *
45
+ * KNOWN CEILING: a `## ` line inside a fenced code block is read as a heading,
46
+ * the same limitation every helper in body-sections.mjs shares. No STATE.md
47
+ * section observed so far carries fenced code.
48
+ *
49
+ * @param {string} body
50
+ * @returns {{preamble: string[], sections: Array<{heading: string, lines: string[]}>}}
51
+ */
52
+ function splitSections(body) {
53
+ const preamble = [];
54
+ const sections = [];
55
+ for (const line of body.split('\n')) {
56
+ if (H2_RE.test(line)) sections.push({ heading: line, lines: [line] });
57
+ else if (sections.length === 0) preamble.push(line);
58
+ else sections[sections.length - 1].lines.push(line);
59
+ }
60
+ return { preamble, sections };
61
+ }
62
+
63
+ /** @param {string} heading @param {string} title */
64
+ function isSection(heading, title) {
65
+ // The title must END there (or open a parenthesis): `## Wave History Archive`
66
+ // is a different section.
67
+ return new RegExp(`^##\\s+${title}(?:\\s*\\(|\\s*$)`).test(heading);
68
+ }
69
+
70
+ /** Drops leading and trailing blank lines. */
71
+ function trimBlankLines(lines) {
72
+ let start = 0;
73
+ let end = lines.length;
74
+ while (start < end && lines[start].trim() === '') start++;
75
+ while (end > start && lines[end - 1].trim() === '') end--;
76
+ return lines.slice(start, end);
77
+ }
78
+
79
+ /** @param {number} x a ratio in 0..1 */
80
+ function pct(x) {
81
+ return `${Math.round(x * 100)}%`;
82
+ }
83
+
84
+ /**
85
+ * Rule 6: the human-readable copy of the recommendation fields. Null fields
86
+ * are omitted; `null` overall when there is nothing to show.
87
+ *
88
+ * @param {object} frontmatter
89
+ * @returns {string|null}
90
+ */
91
+ function renderRecommendationsBlock(frontmatter) {
92
+ const rec = parseRecommendations(frontmatter);
93
+ if (rec === null) return null;
94
+ const bullets = [];
95
+ if (rec.mode !== null) bullets.push(`- **Recommended mode:** ${rec.mode}`);
96
+ if (rec.rationale !== null) bullets.push(`- **Rationale:** ${rec.rationale}`);
97
+ if (rec.completionRate !== null) bullets.push(`- **Completion rate:** ${pct(rec.completionRate)}`);
98
+ if (rec.carryoverRatio !== null) bullets.push(`- **Carryover ratio:** ${pct(rec.carryoverRatio)}`);
99
+ if (rec.priorities !== null) {
100
+ const list = rec.priorities.length > 0 ? rec.priorities.map((id) => `#${id}`).join(', ') : 'none';
101
+ bullets.push(`- **Top priorities:** ${list}`);
102
+ }
103
+ if (bullets.length === 0) return null;
104
+ return [RECOMMENDATIONS_HEADING, ...bullets].join('\n');
105
+ }
106
+
107
+ /**
108
+ * Removes one `### <heading>` subsection (up to the next `### ` or `## ` line).
109
+ *
110
+ * @param {string[]} lines
111
+ * @param {string} heading the full `### …` line
112
+ * @returns {string[]}
113
+ */
114
+ function dropSubsection(lines, heading) {
115
+ const out = [];
116
+ let skipping = false;
117
+ for (const line of lines) {
118
+ if (line.trimEnd() === heading) skipping = true;
119
+ else if (skipping && /^#{2,3}\s/.test(line)) skipping = false;
120
+ if (!skipping) out.push(line);
121
+ }
122
+ return out;
123
+ }
124
+
125
+ /**
126
+ * Resets a `status: completed` STATE.md to idle (Phase 1.5 § Idle Reset,
127
+ * Rules 1-7). Pure: string in, result out, no file I/O.
128
+ *
129
+ * 1. `status: idle`. 2. `current-wave: 0`. 3. Every `## Wave History` body moves
130
+ * into a new `## Previous Session` section (appended at the end of the body,
131
+ * replacing any earlier one) under `### Wave History (<session>)`; the original
132
+ * section is removed. 4. `## Deviations` keeps its heading with an empty body;
133
+ * `## What Not To Retry` and `## Open Questions` stay byte-for-byte. 5. Other
134
+ * frontmatter keys are untouched. 6. The five recommendation keys STAY in the
135
+ * frontmatter (Phase 7.5 reads them); a readable copy heads `## Previous Session`.
136
+ * 7. The five `scope-baseline-*` keys are deleted, no other key.
137
+ *
138
+ * When neither a Wave History section nor a recommendation exists, an existing
139
+ * `## Previous Session` is left as it is — there is nothing to replace it with.
140
+ * With recommendations but no Wave History (a coordinator-direct session), the
141
+ * earlier archive body stays below the new recommendations block.
142
+ *
143
+ * @param {string} contents STATE.md contents
144
+ * @returns {{changed: boolean, contents: string, reason?: 'status-not-completed'|'unparseable'}}
145
+ */
146
+ export function resetStateMdToIdle(contents) {
147
+ const parsed = parseStateMd(contents);
148
+ if (parsed === null) return { changed: false, contents, reason: 'unparseable' };
149
+ if (parsed.frontmatter.status !== 'completed') {
150
+ return { changed: false, contents, reason: 'status-not-completed' };
151
+ }
152
+
153
+ const { preamble, sections } = splitSections(parsed.body);
154
+ const waveHistory = sections.filter((s) => isSection(s.heading, 'Wave History'));
155
+ const recBlock = renderRecommendationsBlock(parsed.frontmatter);
156
+ const archive = waveHistory.length > 0 || recBlock !== null;
157
+ // Rule 6 PREPENDS: without a new Wave History, the earlier archive stays below
158
+ // the fresh recommendations block (minus its own, now superseded, copy).
159
+ const keptArchive =
160
+ waveHistory.length === 0
161
+ ? trimBlankLines(
162
+ dropSubsection(
163
+ sections.filter((s) => isSection(s.heading, 'Previous Session')).flatMap((s) => s.lines.slice(1)),
164
+ RECOMMENDATIONS_HEADING,
165
+ ),
166
+ )
167
+ : [];
168
+
169
+ const lines = [...preamble];
170
+ for (const { heading, lines: own } of sections) {
171
+ if (isSection(heading, 'Wave History')) continue;
172
+ if (archive && isSection(heading, 'Previous Session')) continue;
173
+ lines.push(...(isSection(heading, 'Deviations') ? [heading, ''] : own));
174
+ }
175
+
176
+ let body = lines.join('\n');
177
+ if (archive) {
178
+ const blocks = [];
179
+ if (recBlock !== null) blocks.push(recBlock);
180
+ if (waveHistory.length > 0) {
181
+ const session = typeof parsed.frontmatter.session === 'string' ? parsed.frontmatter.session : '';
182
+ const heading = session ? `### Wave History (${session})` : '### Wave History';
183
+ const moved = trimBlankLines(waveHistory.flatMap((s) => s.lines.slice(1)));
184
+ blocks.push([heading, ...moved].join('\n'));
185
+ }
186
+ if (keptArchive.length > 0) blocks.push(keptArchive.join('\n'));
187
+ // Only ever ADD newlines before the new section — trimming would change the
188
+ // trailing bytes of whatever section now precedes it (possibly WNTR).
189
+ if (body.length > 0 && !body.endsWith('\n\n')) body += body.endsWith('\n') ? '\n' : '\n\n';
190
+ body += `## Previous Session\n\n${blocks.join('\n\n')}\n`;
191
+ }
192
+
193
+ const fields = { status: 'idle', 'current-wave': 0 };
194
+ for (const key of SCOPE_BASELINE_KEYS) fields[key] = null;
195
+ const next = updateFrontmatterFields(
196
+ serializeStateMd({ frontmatter: parsed.frontmatter, body }),
197
+ fields,
198
+ );
199
+ return { changed: next !== contents, contents: next };
200
+ }
201
+
202
+ /**
203
+ * Applies `resetStateMdToIdle` to the repo's STATE.md under the STATE.md lock
204
+ * (`writeStateMd`). A missing or empty STATE.md is a no-op with reason `missing`.
205
+ * A write the size-ceiling / frontmatter-safe guards refuse comes back as
206
+ * `changed: false` with that guard's reason.
207
+ *
208
+ * @param {string} repoRoot absolute repository root (required)
209
+ * @param {object} [opts] passed through to `writeStateMd`
210
+ * @returns {Promise<{changed: boolean, reason?: string, path: string}>}
211
+ * @throws {Error} when repoRoot is missing
212
+ */
213
+ export async function resetStateMdToIdleOnDisk(repoRoot, opts = {}) {
214
+ if (!repoRoot) {
215
+ throw new Error(`resetStateMdToIdleOnDisk: repoRoot is required (got ${typeof repoRoot}).`);
216
+ }
217
+ // No STATE.md: answer before the lock, which would otherwise create
218
+ // <repoRoot>/.orchestrator/ in whatever directory a mistyped root names.
219
+ const statePath = resolveStateMdPath(repoRoot);
220
+ if (!existsSync(statePath)) return { changed: false, reason: 'missing', path: statePath };
221
+ let reason;
222
+ const result = await writeStateMd(
223
+ repoRoot,
224
+ (contents) => {
225
+ if (contents === '') {
226
+ reason = 'missing';
227
+ return null;
228
+ }
229
+ const reset = resetStateMdToIdle(contents);
230
+ reason = reset.reason;
231
+ return reset.changed ? reset.contents : null;
232
+ },
233
+ opts,
234
+ );
235
+ const out = { changed: result.written, path: result.path };
236
+ const why = result.reason ?? reason;
237
+ if (why !== undefined) out.reason = why;
238
+ return out;
239
+ }
@@ -37,10 +37,7 @@ export function parseRecommendations(frontmatter) {
37
37
  const mode = typeof frontmatter['recommended-mode'] === 'string'
38
38
  ? frontmatter['recommended-mode']
39
39
  : null;
40
- const priorities = Array.isArray(frontmatter['top-priorities'])
41
- && frontmatter['top-priorities'].every((x) => Number.isInteger(x))
42
- ? frontmatter['top-priorities'].slice()
43
- : null;
40
+ const priorities = parsePriorities(frontmatter['top-priorities']);
44
41
  const carryoverRatio = typeof frontmatter['carryover-ratio'] === 'number'
45
42
  && !Number.isNaN(frontmatter['carryover-ratio'])
46
43
  ? frontmatter['carryover-ratio']
@@ -55,3 +52,28 @@ export function parseRecommendations(frontmatter) {
55
52
 
56
53
  return { mode, priorities, carryoverRatio, completionRate, rationale };
57
54
  }
55
+
56
+ /** An entry that is exactly an issue reference of THIS repo: `42` or `#42`. */
57
+ const ISSUE_REF_RE = /^#?\d+$/;
58
+
59
+ /**
60
+ * Normalises `top-priorities` to issue numbers. Integers pass; strings that are
61
+ * exactly an issue ref (`"#42"`, `"42"`) become integers; every other entry is
62
+ * dropped. A hand-written `"#370 fix banner"` or `"other-repo#74"` is never
63
+ * mined for digits, because the number may belong to another repo. An array
64
+ * that held entries but keeps none is a type mismatch (`null`), as before.
65
+ *
66
+ * @param {unknown} raw
67
+ * @returns {number[]|null}
68
+ */
69
+ function parsePriorities(raw) {
70
+ if (!Array.isArray(raw)) return null;
71
+ const ids = [];
72
+ for (const entry of raw) {
73
+ if (Number.isInteger(entry)) ids.push(entry);
74
+ else if (typeof entry === 'string' && ISSUE_REF_RE.test(entry.trim())) {
75
+ ids.push(Number.parseInt(entry.trim().replace('#', ''), 10));
76
+ }
77
+ }
78
+ return raw.length > 0 && ids.length === 0 ? null : ids;
79
+ }
@@ -55,6 +55,8 @@
55
55
  * total_cost_usd number | null — native total cost in USD (#624, fractional,
56
56
  * best-effort: null when the harness does not
57
57
  * expose it; no rate table is applied)
58
+ * cost_basis 'api-list-equivalent' | null — explicitly supplied cost
59
+ * provenance; null means unknown (#1543)
58
60
  *
59
61
  * OTel aliases (optional, stop-only, additive — #411, schema_version=1 backwards-compat):
60
62
  * gen_ai.usage.input_tokens integer | null — RAW UNCACHED prompt tokens (OTel
@@ -69,6 +71,7 @@
69
71
  import { readFile, appendFile, mkdir } from 'node:fs/promises';
70
72
  import { existsSync } from 'node:fs';
71
73
  import path from 'node:path';
74
+ import { COST_BASIS } from './telemetry/pricing.mjs';
72
75
 
73
76
  // ---------------------------------------------------------------------------
74
77
  // Constants
@@ -267,6 +270,11 @@ export function validateSubagent(entry, options = {}) {
267
270
  }
268
271
  }
269
272
 
273
+ // #1543 additive: never infer provenance from a native dollar value.
274
+ if (entry.cost_basis !== undefined && entry.cost_basis !== null && entry.cost_basis !== COST_BASIS) {
275
+ throw new ValidationError(`cost_basis must be ${COST_BASIS} or null`, 'cost_basis');
276
+ }
277
+
270
278
  // OTel alias — #411 additive, schema_version=1 backwards-compat
271
279
  const otelInputTokens = entry['gen_ai.usage.input_tokens'];
272
280
  if (otelInputTokens !== undefined && otelInputTokens !== null) {
@@ -334,6 +342,7 @@ export function normalizeSubagent(entry) {
334
342
  model: entry.model ?? null,
335
343
  // total_cost_usd — #624 additive, best-effort native cost (null when absent)
336
344
  total_cost_usd: entry.total_cost_usd ?? null,
345
+ cost_basis: entry.cost_basis ?? null,
337
346
  // OTel alias — #411 additive, schema_version=1 backwards-compat
338
347
  'gen_ai.usage.input_tokens': entry['gen_ai.usage.input_tokens'] ?? null,
339
348
  'gen_ai.usage.cache_read_input_tokens': entry['gen_ai.usage.cache_read_input_tokens'] ?? null,
@@ -62,6 +62,17 @@
62
62
  */
63
63
  export const PRICING_TABLE_DATE = '2026-09-29';
64
64
 
65
+ /**
66
+ * What a dollar figure computed from this table IS (#1470 Pkt 4): the API
67
+ * list-price equivalent of the metered tokens — never a payment. Subscription
68
+ * quota, purchased credits and what was actually billed are not visible to
69
+ * this module, so a consumer must not present the figure as any of them.
70
+ * Written beside every persisted cost as `cost_basis`; the session validator's
71
+ * `COST_BASES` must contain it (pinned by tests/scripts/emit-session.test.mjs).
72
+ * @type {'api-list-equivalent'}
73
+ */
74
+ export const COST_BASIS = 'api-list-equivalent';
75
+
65
76
  /**
66
77
  * @typedef {Object} PricingRow
67
78
  * @property {number} input - USD per 1M uncached prompt tokens.
@@ -55,7 +55,7 @@
55
55
 
56
56
  import { basename, resolve } from 'node:path';
57
57
 
58
- import { subjectToSlug } from './utils.mjs';
58
+ import { legacySubjectToSlug } from './utils.mjs';
59
59
  import { isOwnerLeakySegment } from '../../lib/validate/check-owner-leakage.mjs';
60
60
  import { loadPseudonymMap } from './pseudonym-map.mjs';
61
61
  import { loadHostPaths, resolveHostPath } from '../config/host-paths.mjs';
@@ -311,7 +311,8 @@ export function resolveRepoNamespace({ vaultName = null, repoRoot = null } = {})
311
311
  : (declaredSlug ?? deriveRepo({ repoRoot: root }));
312
312
 
313
313
  // Sanitise: collapse to last path segment, lowercase, strip non-[a-z0-9-].
314
- const seg = subjectToSlug(base);
314
+ // Repository storage identity stays historical; only new note names transliterate.
315
+ const seg = legacySubjectToSlug(base);
315
316
 
316
317
  // Leak-guard: check both the raw base AND the sanitised segment.
317
318
  // A personal home path or a private project slug must be caught before writing