session-orchestrator 5.9.0 → 5.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. package/.claude-plugin/marketplace.json +1 -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/AGENTS.md +2 -0
  6. package/CHANGELOG.md +65 -2
  7. package/README.md +11 -9
  8. package/SECURITY.md +1 -1
  9. package/agents/code-implementer.md +7 -1
  10. package/docs/USER-GUIDE.md +2 -0
  11. package/docs/agent-authoring.md +2 -0
  12. package/docs/ci-setup.md +46 -1
  13. package/docs/events-schema.md +37 -6
  14. package/docs/fleet-checkin-audit.md +172 -0
  15. package/docs/owner-config-schema.md +18 -1
  16. package/docs/owner-privacy-patterns.md +85 -0
  17. package/docs/role-profile-contract.md +139 -0
  18. package/docs/scope-collision-guard.md +15 -2
  19. package/docs/session-config-reference.md +1 -1
  20. package/evals/instruction-ablation/run.mjs +11 -6
  21. package/hooks/_lib/hook-import-set.json +27 -2
  22. package/hooks/_lib/orchestrator-ignore.mjs +112 -0
  23. package/hooks/enforce-commands.mjs +9 -19
  24. package/hooks/enforce-scope.mjs +23 -42
  25. package/hooks/hooks-codex.json +1 -1
  26. package/hooks/hooks.json +1 -1
  27. package/hooks/on-session-end.mjs +8 -1
  28. package/hooks/on-session-start.mjs +7 -0
  29. package/hooks/post-bash-write-verify.mjs +54 -24
  30. package/hooks/pre-task-scope-disjoint.mjs +12 -0
  31. package/hooks/subagent-telemetry.mjs +8 -3
  32. package/hooks/wave-scope-commit-guard.mjs +4 -1
  33. package/package.json +2 -2
  34. package/rules/opt-in-stack/security-web.md +1 -1
  35. package/scripts/emit-session.mjs +28 -0
  36. package/scripts/fleet-checkin-audit.mjs +138 -0
  37. package/scripts/lib/common.mjs +2 -4
  38. package/scripts/lib/context-overhead-format.mjs +57 -0
  39. package/scripts/lib/fleet-checkin-audit.mjs +244 -0
  40. package/scripts/lib/gates/gate-full.mjs +6 -0
  41. package/scripts/lib/gates/gate-helpers.mjs +2 -0
  42. package/scripts/lib/memory-proposals/sink.mjs +79 -17
  43. package/scripts/lib/memory-proposals/store.mjs +245 -9
  44. package/scripts/lib/named-vault-resolver.mjs +1 -1
  45. package/scripts/lib/owner-yaml.mjs +33 -3
  46. package/scripts/lib/platform.mjs +70 -1
  47. package/scripts/lib/pre-push-remote-gate.mjs +107 -0
  48. package/scripts/lib/project-hygiene.mjs +37 -0
  49. package/scripts/lib/quality-gate.mjs +6 -1
  50. package/scripts/lib/redact-spans.mjs +8 -17
  51. package/scripts/lib/role-profile/schema.mjs +219 -0
  52. package/scripts/lib/scope-gate.mjs +68 -5
  53. package/scripts/lib/session-lock.mjs +57 -10
  54. package/scripts/lib/session-record-repair.mjs +63 -30
  55. package/scripts/lib/session-schema/constants.mjs +8 -0
  56. package/scripts/lib/session-schema/validator.mjs +33 -0
  57. package/scripts/lib/subagents-schema.mjs +9 -0
  58. package/scripts/lib/telemetry/pricing.mjs +11 -0
  59. package/scripts/lib/validate/check-owner-leakage.mjs +198 -1015
  60. package/scripts/lib/vault-backfill/template.mjs +1 -1
  61. package/scripts/lib/vault-mirror/render-sessions.mjs +15 -3
  62. package/scripts/lib/vault-status/board-writer.mjs +15 -7
  63. package/scripts/measure-context-overhead.sh +4 -21
  64. package/scripts/migrate-vault-paths.mjs +57 -60
  65. package/scripts/release.mjs +463 -48
  66. package/scripts/run-quality-gate.mjs +47 -0
  67. package/scripts/validate-wave-scope.mjs +59 -6
  68. package/scripts/vault-consolidate.mjs +19 -4
  69. package/skills/claude-md-drift-check/SKILL.md +1 -1
  70. package/skills/claude-md-drift-check/checker.mjs +22 -9
  71. package/skills/npm-publish/SKILL.md +3 -3
  72. package/skills/release/SKILL.md +2 -2
  73. package/skills/repo-audit/SKILL.md +1 -1
  74. package/skills/session-end/metrics-collection.md +28 -4
  75. package/skills/session-end/session-metrics-write.md +1 -1
  76. package/skills/session-start/references/phase-1-2-session-lock.md +1 -1
  77. package/skills/wave-executor/circuit-breaker.md +1 -0
  78. package/skills/wave-executor/references/wave-loop-review.md +26 -2
@@ -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
+ }
@@ -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
  }
@@ -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
+ }
@@ -0,0 +1,244 @@
1
+ /** Pure audit of explicitly complete, caller-supplied fleet snapshots (#1462). */
2
+ import { validateCheckin } from './fleet-protocol.mjs';
3
+
4
+ const THIRTY_MINUTES_MS = 30 * 60 * 1000;
5
+ const FRESHNESS_MS = 15 * 60 * 1000;
6
+ const object = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
7
+ const text = (v) => typeof v === 'string' && v.length > 0 && v.trim() === v;
8
+
9
+ // Require an unambiguous UTC instant and reject Date.parse's calendar rollover.
10
+ function instant(v) {
11
+ if (typeof v !== 'string' || !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{3})?Z$/.test(v)) return NaN;
12
+ const ms = Date.parse(v);
13
+ return Number.isFinite(ms) && new Date(ms).toISOString() === v.replace(/Z$/, v.includes('.') ? 'Z' : '.000Z') ? ms : NaN;
14
+ }
15
+
16
+ /** Canonical issue IID, preserving the existing integer/string check-in contract. */
17
+ export function normalizeCandidate(value) {
18
+ if (typeof value === 'number') return Number.isSafeInteger(value) && value >= 0 ? String(value) : null;
19
+ if (typeof value !== 'string' || !/^#?\d{1,9}$/.test(value)) return null;
20
+ return String(Number(value.replace(/^#/, '')));
21
+ }
22
+
23
+ const measurement = (value, reasons) => ({
24
+ status: reasons.length ? 'not-measurable' : 'measured',
25
+ value: reasons.length ? null : value,
26
+ reasons: [...new Set(reasons)],
27
+ });
28
+
29
+ function snapshotProblems(snapshot, name, at) {
30
+ const reasons = [];
31
+ if (!object(snapshot)) return [`${name}:missing-snapshot`];
32
+ if (snapshot.version !== 1) reasons.push(`${name}:unsupported-version`);
33
+ if (snapshot.complete !== true) reasons.push(`${name}:incomplete-population`);
34
+ if (!Array.isArray(snapshot.records)) reasons.push(`${name}:missing-records`);
35
+ if (!Number.isFinite(instant(snapshot.captured_at)) || instant(snapshot.captured_at) !== at) {
36
+ reasons.push(`${name}:capture-time-mismatch`);
37
+ }
38
+ return reasons;
39
+ }
40
+
41
+ function bindRepos(bindings, issues) {
42
+ const keys = new Set();
43
+ const hashes = new Map();
44
+ const names = new Map();
45
+ const reasons = [];
46
+ const fail = (index, code) => {
47
+ issues.push({ source: 'bindings', index, code });
48
+ reasons.push(`bindings:${code}`);
49
+ };
50
+ if (!Array.isArray(bindings)) {
51
+ fail(null, 'missing-bindings');
52
+ return { keys, hashes, names, reasons };
53
+ }
54
+ const insert = (map, value, key, index) => {
55
+ if (map.has(value)) fail(index, 'duplicate-identity');
56
+ else map.set(value, key);
57
+ };
58
+ bindings.forEach((binding, index) => {
59
+ if (!object(binding) || !text(binding.repo_key)
60
+ || !Array.isArray(binding.registry_hashes) || binding.registry_hashes.length === 0
61
+ || !binding.registry_hashes.every((v) => typeof v === 'string' && /^[a-f0-9]{64}$/.test(v))
62
+ || !Array.isArray(binding.checkin_repos) || binding.checkin_repos.length === 0
63
+ || !binding.checkin_repos.every(text)) {
64
+ fail(index, 'invalid-binding');
65
+ return;
66
+ }
67
+ if (keys.has(binding.repo_key)) fail(index, 'duplicate-repo-key');
68
+ keys.add(binding.repo_key);
69
+ binding.registry_hashes.forEach((v) => insert(hashes, v, binding.repo_key, index));
70
+ binding.checkin_repos.forEach((v) => insert(names, v, binding.repo_key, index));
71
+ });
72
+ return { keys, hashes, names, reasons };
73
+ }
74
+
75
+ function duplicates(rows, field, source, issues) {
76
+ const counts = new Map();
77
+ for (const row of rows) {
78
+ const id = row.record?.[field];
79
+ if (text(id)) counts.set(id, (counts.get(id) || 0) + 1);
80
+ }
81
+ for (const row of rows) {
82
+ if (counts.get(row.record?.[field]) > 1) {
83
+ row.ambiguous = true;
84
+ issues.push({ source, index: row.index, code: 'duplicate-session' });
85
+ }
86
+ }
87
+ }
88
+
89
+ const usable = (row) => row.errors.length === 0 && !row.ambiguous;
90
+ function summarize(rows, present) {
91
+ if (!present) return { raw: null, valid: null, invalid: null, ambiguous: null, stale: null };
92
+ return {
93
+ raw: rows.length,
94
+ valid: rows.filter(usable).length,
95
+ invalid: rows.filter((row) => row.errors.length > 0).length,
96
+ ambiguous: rows.filter((row) => row.ambiguous).length,
97
+ stale: rows.filter((row) => usable(row) && row.stale).length,
98
+ };
99
+ }
100
+
101
+ /**
102
+ * No filesystem, clock, environment, or input mutation. Repo bindings and history
103
+ * completeness are assertions of the caller, never independently discovered.
104
+ * @param {{registry?: object, checkins?: object, repos?: string[], at?: string}} input
105
+ */
106
+ export function auditCheckins({ registry, checkins, repos, at } = {}) {
107
+ const now = instant(at);
108
+ const issues = [];
109
+ const common = [];
110
+ if (!Number.isFinite(now)) common.push('audit:invalid-measurement-time');
111
+ const binding = bindRepos(registry?.repo_bindings, issues);
112
+ common.push(...binding.reasons);
113
+ if (!Array.isArray(repos) || repos.length === 0 || !repos.every(text)
114
+ || new Set(repos).size !== repos.length) common.push('audit:invalid-repo-selection');
115
+ const selected = new Set(Array.isArray(repos) ? repos : []);
116
+ if ([...selected].some((repo) => !binding.keys.has(repo))) common.push('audit:unknown-selected-repo');
117
+
118
+ const registryReasons = [...common, ...snapshotProblems(registry, 'registry', now)];
119
+ const checkinReasons = [...snapshotProblems(checkins, 'checkins', now)];
120
+ const registryRows = (Array.isArray(registry?.records) ? registry.records : []).map((record, index) => {
121
+ const errors = [];
122
+ if (!object(record) || !text(record.session_id) || /[\\/]/.test(record.session_id) || record.session_id.includes('\x00')
123
+ || record.session_id === '.' || record.session_id === '..'
124
+ || ('mode' in record && record.mode !== null && typeof record.mode !== 'string')
125
+ || ('role' in record && record.role !== null && typeof record.role !== 'string')) errors.push('invalid-registry-record');
126
+ const repo = binding.hashes.get(record?.repo_path_hash);
127
+ if (!repo) errors.push('unknown-repo-identity');
128
+ const start = instant(record?.started_at);
129
+ const heartbeat = instant(record?.last_heartbeat);
130
+ if (!Number.isFinite(start) || !Number.isFinite(heartbeat) || start > heartbeat || heartbeat > now) errors.push('invalid-registry-time');
131
+ return { record, index, repo, start, heartbeat, errors, ambiguous: false, stale: now - heartbeat > FRESHNESS_MS };
132
+ });
133
+ duplicates(registryRows, 'session_id', 'registry', issues);
134
+ for (const row of registryRows) {
135
+ for (const code of row.errors) issues.push({ source: 'registry', index: row.index, code });
136
+ if (!usable(row)) registryReasons.push('registry:invalid-or-ambiguous-records');
137
+ }
138
+ const checkinRows = (Array.isArray(checkins?.records) ? checkins.records : []).map((record, index) => {
139
+ const errors = validateCheckin(record).length ? ['invalid-checkin-record'] : [];
140
+ const repo = binding.names.get(record?.repo);
141
+ if (!repo) errors.push('unknown-repo-identity');
142
+ const time = instant(record?.zeit);
143
+ if (!Number.isFinite(time) || time > now) errors.push('invalid-checkin-time');
144
+ const candidates = Array.isArray(record?.kandidaten) ? record.kandidaten.map(normalizeCandidate) : [];
145
+ if (candidates.includes(null)) errors.push('invalid-candidate');
146
+ const matches = registryRows.filter((r) => usable(r) && r.record.session_id === record?.session && r.repo === repo);
147
+ if (matches.length !== 1) errors.push('unmatched-session-identity');
148
+ else if (time < matches[0].start) errors.push('checkin-before-registration');
149
+ return { record, index, repo, time, candidates, errors, ambiguous: false, registry: matches[0], stale: false };
150
+ });
151
+ duplicates(checkinRows, 'session', 'checkins', issues);
152
+ for (const row of checkinRows) {
153
+ for (const code of row.errors) issues.push({ source: 'checkins', index: row.index, code });
154
+ if (!usable(row)) checkinReasons.push('checkins:invalid-or-ambiguous-records');
155
+ }
156
+
157
+ const population = registryRows.filter((row) => usable(row) && selected.has(row.repo));
158
+ const fresh = population.filter((row) => !row.stale);
159
+ const due = fresh.filter((row) => now - row.start >= THIRTY_MINUTES_MS);
160
+ const missing = due.filter((row) => !checkinRows.some((c) => usable(c) && c.registry === row));
161
+ const pointReasons = [...registryReasons, ...checkinReasons];
162
+
163
+ const coverage = checkins?.coverage;
164
+ const historyReasons = [...pointReasons];
165
+ const from = instant(coverage?.from);
166
+ const to = instant(coverage?.to);
167
+ if (!object(coverage) || coverage.complete !== true || !Number.isFinite(from)
168
+ || !Number.isFinite(to) || from >= to || to > now) historyReasons.push('history:missing-or-invalid-coverage');
169
+ if (!Array.isArray(checkins?.claims)) historyReasons.push('history:missing-claims');
170
+ const claims = (Array.isArray(checkins?.claims) ? checkins.claims : []).map((claim, index) => {
171
+ const errors = [];
172
+ const start = instant(claim?.from);
173
+ const end = instant(claim?.to);
174
+ const candidate = normalizeCandidate(claim?.candidate);
175
+ if (!object(claim) || !text(claim.session) || !text(claim.repo_key)
176
+ || !text(claim.evidence_ref) || candidate === null) errors.push('invalid-claim');
177
+ const matches = checkinRows.filter((c) => usable(c) && c.record.session === claim?.session
178
+ && c.repo === claim?.repo_key && c.candidates.includes(candidate));
179
+ if (matches.length !== 1) errors.push('unmatched-claim-identity');
180
+ if (!Number.isFinite(start) || !Number.isFinite(end) || start >= end || end > now
181
+ || start < from || end > to || (matches.length === 1 && start < matches[0].time)) errors.push('invalid-claim-interval');
182
+ for (const code of errors) issues.push({ source: 'claims', index, code });
183
+ if (errors.length) historyReasons.push('history:invalid-claims');
184
+ return { index, session: claim?.session, repo_key: claim?.repo_key, candidate, from: start, to: end, errors };
185
+ });
186
+ // Repeated/overlapping evidence for one session is ambiguous; do not multiply
187
+ // collision counts by silently treating those claims as independent starts.
188
+ for (let i = 0; i < claims.length; i++) {
189
+ for (let j = i + 1; j < claims.length; j++) {
190
+ const a = claims[i]; const b = claims[j];
191
+ if (!a.errors.length && !b.errors.length && a.session === b.session
192
+ && a.repo_key === b.repo_key && a.candidate === b.candidate
193
+ && Math.max(a.from, b.from) < Math.min(a.to, b.to)) {
194
+ historyReasons.push('history:duplicate-overlapping-claims');
195
+ issues.push({ source: 'claims', index: b.index, code: 'duplicate-overlapping-claims' });
196
+ }
197
+ }
198
+ }
199
+ const collisions = [];
200
+ for (let i = 0; i < claims.length; i++) {
201
+ for (let j = i + 1; j < claims.length; j++) {
202
+ const a = claims[i]; const b = claims[j];
203
+ if (!a.errors.length && !b.errors.length && a.session !== b.session
204
+ && selected.has(a.repo_key) && a.repo_key === b.repo_key && a.candidate === b.candidate
205
+ && Math.max(a.from, b.from) < Math.min(a.to, b.to)) {
206
+ collisions.push({ repo_key: a.repo_key, candidate: a.candidate, sessions: [a.session, b.session],
207
+ from: new Date(Math.max(a.from, b.from)).toISOString(), to: new Date(Math.min(a.to, b.to)).toISOString() });
208
+ }
209
+ }
210
+ }
211
+ const currentCandidates = [];
212
+ for (const row of checkinRows.filter((c) => usable(c) && selected.has(c.repo) && !c.registry.stale)) {
213
+ for (const candidate of new Set(row.candidates)) {
214
+ currentCandidates.push({ repo_key: row.repo, session: row.record.session, candidate });
215
+ }
216
+ }
217
+ const metrics = {
218
+ selected: measurement(population.length, registryReasons),
219
+ fresh: measurement(fresh.length, registryReasons),
220
+ due: measurement(due.length, registryReasons),
221
+ missing_after_30m: measurement(missing.length, pointReasons),
222
+ collisions: measurement(collisions.length, historyReasons),
223
+ };
224
+ return {
225
+ version: 1,
226
+ status: Object.values(metrics).every((metric) => metric.status === 'measured') ? 'measured' : 'not-measurable',
227
+ at: typeof at === 'string' ? at : null,
228
+ repos: [...selected],
229
+ snapshots: {
230
+ registry: summarize(registryRows, Array.isArray(registry?.records)),
231
+ checkins: summarize(checkinRows, Array.isArray(checkins?.records)),
232
+ },
233
+ metrics,
234
+ coverage: historyReasons.includes('history:missing-or-invalid-coverage') ? null : {
235
+ complete: true, from: new Date(from).toISOString(), to: new Date(to).toISOString(),
236
+ },
237
+ issues,
238
+ findings: {
239
+ missing_sessions: pointReasons.length ? [] : missing.map((row) => ({ repo_key: row.repo, session: row.record.session_id })),
240
+ collisions: historyReasons.length ? [] : collisions,
241
+ current_candidates: pointReasons.length ? [] : currentCandidates,
242
+ },
243
+ };
244
+ }
@@ -227,4 +227,10 @@ if (failed) {
227
227
  }
228
228
  }
229
229
 
230
+ // stderr is asynchronous when piped (pre-push/offload capture). Drain queued
231
+ // writes before the forced exit; natural exit would retain kill-ladder timers.
232
+ await Promise.all([
233
+ new Promise((resolve) => process.stdout.write('', resolve)),
234
+ new Promise((resolve) => process.stderr.write('', resolve)),
235
+ ]);
230
236
  process.exit(failed ? 2 : 0);
@@ -565,6 +565,8 @@ export function extractTestCounts(output) {
565
565
  const EMPTY = { passed: 0, failed: 0, total: 0, files: null };
566
566
  if (!output) return EMPTY;
567
567
 
568
+ // Colored Vitest labels must match the same summaries as piped plain text.
569
+ output = output.replace(ANSI_ESCAPE, '');
568
570
  const lines = output.split('\n');
569
571
  const summaryLines = lines.filter((line) => TEST_SUMMARY_LINE.test(line));
570
572
  const scope = summaryLines.length > 0 ? summaryLines[summaryLines.length - 1] : output;
@@ -42,7 +42,7 @@
42
42
  */
43
43
 
44
44
  import { appendFile, mkdir, readdir, rm } from 'node:fs/promises';
45
- import { readFileSync, existsSync } from 'node:fs';
45
+ import { readFileSync, existsSync, lstatSync, realpathSync } from 'node:fs';
46
46
  import path from 'node:path';
47
47
  import { appendLearning } from '../learnings/io.mjs';
48
48
  import { validatePathInsideProject } from '../path-utils.mjs';
@@ -64,6 +64,66 @@ const PROPOSALS_ARCHIVE_REL = path.join('.orchestrator', 'runtime', 'proposals-a
64
64
  // Internal helpers
65
65
  // ---------------------------------------------------------------------------
66
66
 
67
+ /**
68
+ * Close the shared validator's ENOENT gap for sink targets (#1544).
69
+ * Resolve the nearest existing component, including dangling symlinks, before
70
+ * mkdir/append can follow it. Canonical roots permit root aliases and temporary
71
+ * directory aliases. Like the store guard, this is check-before-write: revisit
72
+ * if another principal can modify these parents between validation and writing.
73
+ */
74
+ function validateSinkTarget(relPath, repoRoot) {
75
+ try {
76
+ let root = path.resolve(repoRoot);
77
+ for (let ancestor = root; ; ancestor = path.dirname(ancestor)) {
78
+ try {
79
+ lstatSync(ancestor);
80
+ } catch (err) {
81
+ if (err?.code === 'ENOENT') continue;
82
+ throw err;
83
+ }
84
+ try {
85
+ root = path.resolve(realpathSync(ancestor), path.relative(ancestor, root));
86
+ } catch (err) {
87
+ if (err?.code === 'ENOENT' || err?.code === 'ELOOP') return { ok: false, reason: 'symlink' };
88
+ throw err;
89
+ }
90
+ break;
91
+ }
92
+ const result = validatePathInsideProject(relPath, root, { canonicalizeRoot: true });
93
+ if (!result.ok || result.realPath !== undefined) return result;
94
+ const fold = (p) => process.platform === 'win32' ? p.toLowerCase() : p;
95
+ const inside = (p) => {
96
+ const relative = path.relative(fold(root), fold(p));
97
+ return relative === '' || (relative !== '..' && !relative.startsWith('..' + path.sep) && !path.isAbsolute(relative));
98
+ };
99
+ for (let current = result.lexicalPath; inside(current); current = path.dirname(current)) {
100
+ try {
101
+ lstatSync(current);
102
+ } catch (err) {
103
+ if (err?.code === 'ENOENT') continue;
104
+ throw err;
105
+ }
106
+ try {
107
+ return inside(realpathSync(current)) ? result : { ok: false, reason: 'symlink' };
108
+ } catch (err) {
109
+ if (err?.code !== 'ENOENT' && err?.code !== 'ELOOP') throw err;
110
+ // A dangling link is an existing component whose resolution failed.
111
+ // A concurrently removed ordinary component can safely defer upward.
112
+ try {
113
+ lstatSync(current);
114
+ } catch (retryErr) {
115
+ if (retryErr?.code === 'ENOENT') continue;
116
+ throw retryErr;
117
+ }
118
+ return { ok: false, reason: 'symlink' };
119
+ }
120
+ }
121
+ return result; // A missing trusted root will be created as a plain tree.
122
+ } catch {
123
+ return { ok: false, reason: 'filesystem' };
124
+ }
125
+ }
126
+
67
127
  /**
68
128
  * Build a learning record from an approved proposal.
69
129
  *
@@ -159,7 +219,7 @@ export async function writeApproved({ approved, repoRoot, sessionId, ...rest })
159
219
  return { written: 0, errors: [] };
160
220
  }
161
221
 
162
- const learningsResult = validatePathInsideProject(LEARNINGS_REL, repoRoot, { canonicalizeRoot: true });
222
+ const learningsResult = validateSinkTarget(LEARNINGS_REL, repoRoot);
163
223
  if (!learningsResult.ok) {
164
224
  return { written: 0, errors: [`path-safety: ${learningsResult.reason} (${LEARNINGS_REL})`] };
165
225
  }
@@ -202,7 +262,7 @@ export async function archiveRejected({ rejected, repoRoot, reason }) {
202
262
  return { archived: 0, errors: [] };
203
263
  }
204
264
 
205
- const rejectedResult = validatePathInsideProject(REJECTED_LOG_REL, repoRoot, { canonicalizeRoot: true });
265
+ const rejectedResult = validateSinkTarget(REJECTED_LOG_REL, repoRoot);
206
266
  if (!rejectedResult.ok) {
207
267
  return { archived: 0, errors: [`path-safety: ${rejectedResult.reason} (${REJECTED_LOG_REL})`] };
208
268
  }
@@ -260,8 +320,9 @@ export async function archiveRejected({ rejected, repoRoot, reason }) {
260
320
  * call actually wrote anything, so a caller-side mistake (wrong arg name,
261
321
  * partial write failure) no longer means the queued proposals are gone
262
322
  * for good — they are recoverable from the archive sidecar. Best-effort:
263
- * an archive failure never blocks the clear itself, and an empty/missing
264
- * proposals.jsonl produces no archive append (nothing to preserve).
323
+ * ordinary archive I/O failures do not block clear. An unsafe required
324
+ * recovery target blocks clear and preserves the queue and summaries.
325
+ * An empty/missing proposals.jsonl produces no archive append.
265
326
  * 1. Clear proposals.jsonl via tmp-file + rename (POSIX-atomic on the same
266
327
  * filesystem) instead of an in-place `writeFileSync` truncate — this also
267
328
  * brings the implementation in line with the documented contract in
@@ -282,30 +343,31 @@ export async function archiveRejected({ rejected, repoRoot, reason }) {
282
343
  *
283
344
  * @param {object} opts
284
345
  * @param {string} opts.repoRoot - absolute project root path
285
- * @returns {Promise<{ cleared: boolean, summariesCleared: number }>}
346
+ * @returns {Promise<{ cleared: boolean, summariesCleared: number, reason?: string }>}
286
347
  */
287
348
  export async function clearProposalsJsonl({ repoRoot }) {
288
- const proposalsResult = validatePathInsideProject(PROPOSALS_REL, repoRoot, { canonicalizeRoot: true });
349
+ const proposalsResult = validateSinkTarget(PROPOSALS_REL, repoRoot);
289
350
  if (!proposalsResult.ok) {
290
- return { cleared: false, summariesCleared: 0 };
351
+ return { cleared: false, summariesCleared: 0, reason: `path-safety: ${proposalsResult.reason}` };
291
352
  }
292
353
  const proposalsPath = proposalsResult.realPath ?? proposalsResult.lexicalPath;
293
354
  const metricsDirPath = path.dirname(proposalsPath);
294
355
 
295
356
  // Step 0 (#797): archive pre-clear content before truncating. Best-effort
296
- // — never lets an archive-side failure block the clear (same never-throw
297
- // contract as the rest of this function).
357
+ // — ordinary archive I/O failures do not block clear; an unsafe required
358
+ // recovery target does, preserving the queue and its summaries.
298
359
  if (existsSync(proposalsPath)) {
299
360
  try {
300
361
  const preClearContent = readFileSync(proposalsPath, 'utf8');
301
362
  if (preClearContent.length > 0) {
302
- const archiveResult = validatePathInsideProject(PROPOSALS_ARCHIVE_REL, repoRoot, { canonicalizeRoot: true });
303
- if (archiveResult.ok) {
304
- const archivePath = archiveResult.realPath ?? archiveResult.lexicalPath;
305
- await mkdir(path.dirname(archivePath), { recursive: true });
306
- const normalized = preClearContent.endsWith('\n') ? preClearContent : `${preClearContent}\n`;
307
- await appendFile(archivePath, normalized, 'utf8');
363
+ const archiveResult = validateSinkTarget(PROPOSALS_ARCHIVE_REL, repoRoot);
364
+ if (!archiveResult.ok) {
365
+ return { cleared: false, summariesCleared: 0, reason: `path-safety: ${archiveResult.reason}` };
308
366
  }
367
+ const archivePath = archiveResult.realPath ?? archiveResult.lexicalPath;
368
+ await mkdir(path.dirname(archivePath), { recursive: true });
369
+ const normalized = preClearContent.endsWith('\n') ? preClearContent : `${preClearContent}\n`;
370
+ await appendFile(archivePath, normalized, 'utf8');
309
371
  }
310
372
  } catch {
311
373
  // Best-effort — a read/append failure on the archive sidecar must not
@@ -501,6 +563,6 @@ export async function promoteAndClear({ approved, sessionId, repoRoot, ...rest }
501
563
  expected,
502
564
  cleared: c.cleared,
503
565
  summariesCleared: c.summariesCleared,
504
- skippedReason: null,
566
+ skippedReason: c.cleared ? null : (c.reason ?? 'clear-failed'),
505
567
  };
506
568
  }