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.
- package/.claude-plugin/marketplace.json +12 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/.cursor-plugin/plugin.json +1 -1
- package/CHANGELOG.md +51 -0
- package/README.md +22 -11
- package/SECURITY.md +1 -1
- package/agents/code-implementer.md +7 -1
- package/commands/session.md +3 -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/install.md +12 -8
- package/docs/role-profile-contract.md +139 -0
- package/docs/scope-collision-guard.md +3 -0
- package/docs/session-config-reference.md +7 -7
- package/docs/session-config-template.md +2 -2
- 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 +26 -10
- 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 -2
- 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/fleet-checkin.mjs +61 -3
- package/scripts/lib/common.mjs +2 -4
- package/scripts/lib/config/remote-hosts.mjs +2 -2
- package/scripts/lib/config.mjs +1 -1
- package/scripts/lib/context-overhead-format.mjs +57 -0
- package/scripts/lib/fleet-checkin-audit.mjs +244 -0
- package/scripts/lib/fleet-protocol.mjs +17 -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-end/vault-orphans.mjs +205 -0
- package/scripts/lib/session-invocation.mjs +14 -3
- package/scripts/lib/session-lock.mjs +57 -10
- package/scripts/lib/session-record-repair.mjs +63 -30
- package/scripts/lib/session-registry.mjs +14 -4
- package/scripts/lib/session-schema/constants.mjs +8 -0
- package/scripts/lib/session-schema/validator.mjs +33 -0
- package/scripts/lib/so-hud/collect.mjs +130 -0
- package/scripts/lib/so-hud/runtime.mjs +76 -0
- package/scripts/lib/state-md/idle-reset.mjs +239 -0
- package/scripts/lib/state-md/recommendations.mjs +26 -4
- package/scripts/lib/subagents-schema.mjs +9 -0
- package/scripts/lib/telemetry/pricing.mjs +11 -0
- package/scripts/lib/vault-mirror/namespace.mjs +3 -2
- package/scripts/lib/vault-mirror/process.mjs +143 -40
- package/scripts/lib/vault-mirror/render-learnings.mjs +6 -5
- package/scripts/lib/vault-mirror/utils.mjs +27 -13
- package/scripts/lib/vault-status/board-writer.mjs +32 -4
- package/scripts/measure-context-overhead.sh +4 -21
- package/scripts/release.mjs +359 -11
- package/scripts/resolve-session-invocation.mjs +64 -8
- package/scripts/run-quality-gate.mjs +47 -0
- package/scripts/so-hud.mjs +45 -0
- package/scripts/state-md-idle-reset.mjs +81 -0
- package/scripts/validate-wave-scope.mjs +59 -6
- package/scripts/vault-orphans.mjs +171 -0
- package/skills/_shared/fleet-protocol.md +24 -2
- package/skills/_shared/state-ownership.md +3 -3
- package/skills/autopilot/SKILL.md +3 -2
- 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/SKILL.md +3 -2
- package/skills/session-end/metrics-collection.md +2 -2
- package/skills/session-end/references/phase-3-7e-vault-orphans.md +63 -0
- package/skills/session-end/references/phase-3-documentation-updates.md +11 -1
- package/skills/session-end/references/session-summary-template.md +4 -0
- package/skills/session-end/session-metrics-write.md +1 -1
- package/skills/session-start/SKILL.md +1 -1
- package/skills/session-start/phase-7-5-mode-selector.md +3 -1
- package/skills/session-start/references/phase-1-2-session-lock.md +1 -1
- package/skills/session-start/references/phase-1-5-session-continuity.md +12 -4
- package/skills/wave-executor/circuit-breaker.md +1 -0
- 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 =
|
|
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 {
|
|
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
|
-
|
|
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
|