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,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
|
+
}
|
|
@@ -64,6 +64,23 @@ export function navigatorDir({ env = process.env, home = os.homedir() } = {}) {
|
|
|
64
64
|
return path.join(home, '.config', 'navigator');
|
|
65
65
|
}
|
|
66
66
|
|
|
67
|
+
/**
|
|
68
|
+
* True when fleet integration is explicitly configured or its default directory exists.
|
|
69
|
+
* A nonblank override opts in even before the directory is created; stat errors disable it.
|
|
70
|
+
* @param {{ env?: Record<string, string|undefined>, home?: string }} [opts]
|
|
71
|
+
* @returns {boolean}
|
|
72
|
+
*/
|
|
73
|
+
export function fleetIntegrationEnabled({ env = process.env, home = os.homedir() } = {}) {
|
|
74
|
+
const raw = env[NAVIGATOR_CONFIG_DIR_ENV];
|
|
75
|
+
const override = typeof raw === 'string' ? raw.trim() : '';
|
|
76
|
+
if (override) return true;
|
|
77
|
+
try {
|
|
78
|
+
return fsSync.statSync(path.join(home, '.config', 'navigator')).isDirectory();
|
|
79
|
+
} catch {
|
|
80
|
+
return false;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
67
84
|
/**
|
|
68
85
|
* Path of the navigator lease file.
|
|
69
86
|
* @param {{ env?: Record<string, string|undefined>, home?: string }} [opts]
|
|
@@ -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 =
|
|
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 =
|
|
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
|
-
*
|
|
264
|
-
*
|
|
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 =
|
|
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
|
-
// —
|
|
297
|
-
//
|
|
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 =
|
|
303
|
-
if (archiveResult.ok) {
|
|
304
|
-
|
|
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
|
}
|