@open-agent-toolkit/cli 0.2.5 → 0.2.7
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/assets/docs/cli-utilities/configuration.md +40 -0
- package/assets/docs/cli-utilities/tool-packs.md +31 -0
- package/assets/docs/workflows/projects/artifacts.md +27 -0
- package/assets/docs/workflows/skills/explainer-kit.md +125 -0
- package/assets/docs/workflows/skills/index.md +4 -0
- package/assets/migration/pjm-restructure.md +12 -0
- package/assets/public-package-versions.json +4 -4
- package/assets/skills/explainer-kit/SKILL.md +95 -0
- package/assets/skills/explainer-kit/examples/project-explainer/content.md +31 -0
- package/assets/skills/explainer-kit/examples/project-explainer/fact-base.md +22 -0
- package/assets/skills/explainer-kit/examples/project-recap/content.md +34 -0
- package/assets/skills/explainer-kit/examples/project-recap/fact-base.md +22 -0
- package/assets/skills/explainer-kit/examples/theme-bundle.json +87 -0
- package/assets/skills/explainer-kit/palettes/ember.json +37 -0
- package/assets/skills/explainer-kit/palettes/forest.json +37 -0
- package/assets/skills/explainer-kit/palettes/neutral.json +37 -0
- package/assets/skills/explainer-kit/palettes/ocean.json +37 -0
- package/assets/skills/explainer-kit/palettes/violet.json +37 -0
- package/assets/skills/explainer-kit/profiles/clean.json +42 -0
- package/assets/skills/explainer-kit/profiles/editorial.json +42 -0
- package/assets/skills/explainer-kit/profiles/technical.json +39 -0
- package/assets/skills/explainer-kit/recipes/engineer-tour.json +33 -0
- package/assets/skills/explainer-kit/recipes/project-explainer.json +33 -0
- package/assets/skills/explainer-kit/recipes/project-recap.json +34 -0
- package/assets/skills/explainer-kit/references/contracts.md +101 -0
- package/assets/skills/explainer-kit/references/destination-contract.md +94 -0
- package/assets/skills/explainer-kit/references/extension-contract.md +71 -0
- package/assets/skills/explainer-kit/references/fact-base-contract.md +119 -0
- package/assets/skills/explainer-kit/schemas/build-record.schema.json +92 -0
- package/assets/skills/explainer-kit/schemas/durability-evidence.schema.json +53 -0
- package/assets/skills/explainer-kit/schemas/fact-base.schema.json +129 -0
- package/assets/skills/explainer-kit/schemas/manifest.schema.json +193 -0
- package/assets/skills/explainer-kit/schemas/publish-receipt.schema.json +97 -0
- package/assets/skills/explainer-kit/schemas/publish-request.schema.json +32 -0
- package/assets/skills/explainer-kit/schemas/run-request.schema.json +132 -0
- package/assets/skills/explainer-kit/schemas/theme.schema.json +217 -0
- package/assets/skills/explainer-kit/scripts/lib/content-approval.mjs +180 -0
- package/assets/skills/explainer-kit/scripts/lib/contracts.mjs +680 -0
- package/assets/skills/explainer-kit/scripts/lib/durability.mjs +538 -0
- package/assets/skills/explainer-kit/scripts/lib/fact-base.mjs +494 -0
- package/assets/skills/explainer-kit/scripts/lib/fs-safe.mjs +218 -0
- package/assets/skills/explainer-kit/scripts/lib/qa.mjs +512 -0
- package/assets/skills/explainer-kit/scripts/lib/recipes.mjs +289 -0
- package/assets/skills/explainer-kit/scripts/lib/records.mjs +231 -0
- package/assets/skills/explainer-kit/scripts/lib/render.mjs +485 -0
- package/assets/skills/explainer-kit/scripts/lib/s3-static.mjs +587 -0
- package/assets/skills/explainer-kit/scripts/lib/safe-paths.mjs +96 -0
- package/assets/skills/explainer-kit/scripts/lib/theme.mjs +332 -0
- package/assets/skills/explainer-kit/scripts/publish.mjs +76 -0
- package/assets/skills/explainer-kit/scripts/record-durability.mjs +64 -0
- package/assets/skills/explainer-kit/scripts/render-qa.mjs +273 -0
- package/assets/skills/explainer-kit/scripts/run.mjs +778 -0
- package/assets/skills/explainer-kit/scripts/validate.mjs +59 -0
- package/assets/skills/explainer-kit/templates/deck-shell.html +233 -0
- package/assets/skills/explainer-kit/templates/diagram-shell.html +219 -0
- package/assets/skills/explainer-kit/templates/engineer-tour.html +273 -0
- package/assets/skills/explainer-kit/templates/house-style.html +218 -0
- package/assets/skills/oat-explainer-kit/SKILL.md +92 -0
- package/assets/skills/oat-explainer-kit/references/config-contract.md +67 -0
- package/assets/skills/oat-explainer-kit/references/lifecycle-contract.md +143 -0
- package/assets/skills/oat-explainer-kit/references/migration.md +143 -0
- package/assets/skills/oat-explainer-kit/scripts/bind-project-sources.mjs +103 -0
- package/assets/skills/oat-explainer-kit/scripts/check-core.mjs +127 -0
- package/assets/skills/oat-explainer-kit/scripts/finalize-tracked-run.mjs +392 -0
- package/assets/skills/oat-explainer-kit/scripts/persist-intent.mjs +130 -0
- package/assets/skills/oat-explainer-kit/scripts/resolve-config.mjs +293 -0
- package/assets/skills/oat-explainer-kit/scripts/resolve-intent.mjs +209 -0
- package/assets/skills/oat-explainer-kit/scripts/resolve-paths.mjs +166 -0
- package/assets/skills/oat-explainer-kit/scripts/run.mjs +284 -0
- package/assets/skills/oat-project-autonomous/SKILL.md +22 -1
- package/assets/skills/oat-project-autonomous/references/gate-inventory.md +5 -1
- package/assets/skills/oat-project-complete/SKILL.md +166 -28
- package/assets/skills/oat-project-document/references/docs/autonomy-contract.md +5 -1
- package/assets/skills/oat-project-implement/SKILL.md +1 -1
- package/assets/skills/oat-project-implement/references/completion-and-closeout.md +17 -0
- package/assets/skills/oat-project-implement/references/docs/autonomy-contract.md +5 -1
- package/assets/skills/oat-project-plan/SKILL.md +30 -1
- package/assets/skills/oat-project-pr-final/references/docs/autonomy-contract.md +5 -1
- package/assets/skills/oat-project-quick-start/references/docs/autonomy-contract.md +5 -1
- package/assets/skills/oat-project-summary/SKILL.md +20 -1
- package/assets/skills/oat-wave-execute/SKILL.md +1 -1
- package/dist/commands/config/index.d.ts.map +1 -1
- package/dist/commands/config/index.js +232 -1
- package/dist/commands/init/tools/shared/skill-manifest.d.ts +2 -2
- package/dist/commands/init/tools/shared/skill-manifest.d.ts.map +1 -1
- package/dist/commands/init/tools/shared/skill-manifest.js +2 -0
- package/dist/commands/project/archive/archive-utils.d.ts +12 -0
- package/dist/commands/project/archive/archive-utils.d.ts.map +1 -1
- package/dist/commands/project/archive/archive-utils.js +320 -8
- package/dist/commands/project/archive/index.d.ts.map +1 -1
- package/dist/commands/project/archive/index.js +1 -0
- package/dist/commands/project/archive/push-runner.d.ts +1 -0
- package/dist/commands/project/archive/push-runner.d.ts.map +1 -1
- package/dist/commands/project/archive/push-runner.js +9 -2
- package/dist/config/oat-config.d.ts +26 -0
- package/dist/config/oat-config.d.ts.map +1 -1
- package/dist/config/oat-config.js +88 -0
- package/dist/config/resolve.d.ts.map +1 -1
- package/dist/config/resolve.js +18 -0
- package/dist/validation/project-state.d.ts +3 -0
- package/dist/validation/project-state.d.ts.map +1 -1
- package/dist/validation/project-state.js +91 -0
- package/package.json +2 -2
- /package/assets/skills/{oat-wave-execute/assets/program-recap.recipe.json → explainer-kit/recipes/program-recap.json} +0 -0
|
@@ -0,0 +1,494 @@
|
|
|
1
|
+
import { canonicalHash, validateContract } from './contracts.mjs';
|
|
2
|
+
|
|
3
|
+
const FACT_BASE_VERSION = 'explainer-kit.fact-base/v1';
|
|
4
|
+
const DEFAULT_MAX_AGE_MS = 24 * 60 * 60 * 1000;
|
|
5
|
+
const FINDING_REASONS = new Set([
|
|
6
|
+
'contradictory',
|
|
7
|
+
'stale',
|
|
8
|
+
'missing-evidence',
|
|
9
|
+
'needs-confirmation',
|
|
10
|
+
]);
|
|
11
|
+
|
|
12
|
+
export async function processFactBase(binding, options = {}) {
|
|
13
|
+
assertBinding(binding);
|
|
14
|
+
const now = options.now ?? new Date().toISOString();
|
|
15
|
+
|
|
16
|
+
if (binding.mode === 'supplied') {
|
|
17
|
+
return processSupplied(binding, {
|
|
18
|
+
now,
|
|
19
|
+
maxAgeMs: options.maxAgeMs ?? DEFAULT_MAX_AGE_MS,
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
if (typeof options.critic !== 'function') {
|
|
24
|
+
throw new Error(
|
|
25
|
+
'Federated fact-base processing requires a provider-neutral critic callback.',
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
return processFederated(binding, { critic: options.critic, now });
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function processSupplied(binding, { now, maxAgeMs }) {
|
|
33
|
+
const factBase = structuredClone(binding.factBase);
|
|
34
|
+
if (!factBase || typeof factBase !== 'object') {
|
|
35
|
+
throw new Error('Supplied mode requires a factBase object.');
|
|
36
|
+
}
|
|
37
|
+
if (
|
|
38
|
+
factBase.mode !== 'supplied' ||
|
|
39
|
+
factBase.freshnessPolicy !== binding.freshnessPolicy
|
|
40
|
+
) {
|
|
41
|
+
throw new Error(
|
|
42
|
+
'Supplied fact base mode and freshness policy must match its binding.',
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
assertValidFactBase(factBase);
|
|
47
|
+
assertCitationConsistency(factBase);
|
|
48
|
+
|
|
49
|
+
const nowMs = Date.parse(now);
|
|
50
|
+
const staleInputs = [
|
|
51
|
+
{ label: 'fact base', timestamp: factBase.generatedAt },
|
|
52
|
+
...factBase.sources
|
|
53
|
+
.filter(({ observedAt }) => observedAt)
|
|
54
|
+
.map(({ id, observedAt }) => ({
|
|
55
|
+
label: `source ${id}`,
|
|
56
|
+
timestamp: observedAt,
|
|
57
|
+
})),
|
|
58
|
+
].filter(({ timestamp }) => {
|
|
59
|
+
const ageMs = nowMs - Date.parse(timestamp);
|
|
60
|
+
return Number.isFinite(ageMs) && ageMs > maxAgeMs;
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
return {
|
|
64
|
+
factBase,
|
|
65
|
+
checks: {
|
|
66
|
+
kind: 'lightweight-consistency-freshness',
|
|
67
|
+
consistency: 'passed',
|
|
68
|
+
freshness: staleInputs.length > 0 ? 'warned' : 'passed',
|
|
69
|
+
warnings: staleInputs.map(
|
|
70
|
+
({ label, timestamp }) =>
|
|
71
|
+
`Supplied ${label} is stale: ${timestamp} exceeds the ${maxAgeMs}ms freshness window.`,
|
|
72
|
+
),
|
|
73
|
+
},
|
|
74
|
+
critic: {
|
|
75
|
+
invoked: false,
|
|
76
|
+
reason: 'supplied-mode-lightweight-check-only',
|
|
77
|
+
},
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async function processFederated(binding, { critic, now }) {
|
|
82
|
+
const documents = binding.sourceDocuments;
|
|
83
|
+
if (!Array.isArray(documents) || documents.length === 0) {
|
|
84
|
+
throw new Error(
|
|
85
|
+
'Federated mode requires at least one source document with claims.',
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const sources = documents.map(({ source }) => structuredClone(source));
|
|
90
|
+
assertUniqueNonEmptyIds(sources, 'source');
|
|
91
|
+
const sourceById = new Map(sources.map((source) => [source.id, source]));
|
|
92
|
+
const observations = collectObservations(documents, sourceById);
|
|
93
|
+
const overrides = normalizeOverrides(binding.overrides ?? []);
|
|
94
|
+
const overrideByClaim = new Map(
|
|
95
|
+
overrides.map((override) => [override.claimId, override]),
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
const claims = [];
|
|
99
|
+
const unresolvedClaims = [];
|
|
100
|
+
for (const [claimId, entries] of [...observations].sort(([left], [right]) =>
|
|
101
|
+
left.localeCompare(right),
|
|
102
|
+
)) {
|
|
103
|
+
const override = overrideByClaim.get(claimId);
|
|
104
|
+
if (override) {
|
|
105
|
+
claims.push({
|
|
106
|
+
id: claimId,
|
|
107
|
+
text: override.decision,
|
|
108
|
+
status: 'overridden',
|
|
109
|
+
citations: citationsFor(entries),
|
|
110
|
+
});
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const resolved = resolveObservations(claimId, entries);
|
|
115
|
+
if (resolved.status === 'confirmed') {
|
|
116
|
+
claims.push(resolved.claim);
|
|
117
|
+
} else {
|
|
118
|
+
unresolvedClaims.push(resolved.claim);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
for (const override of overrides) {
|
|
123
|
+
if (!observations.has(override.claimId)) {
|
|
124
|
+
throw new Error(
|
|
125
|
+
`Operator override references unknown claim ${override.claimId}.`,
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const criticRequest = {
|
|
131
|
+
freshnessPolicy: binding.freshnessPolicy,
|
|
132
|
+
sources: structuredClone(sources),
|
|
133
|
+
claims: structuredClone(claims),
|
|
134
|
+
overrides: structuredClone(overrides),
|
|
135
|
+
};
|
|
136
|
+
const criticResult = await critic(criticRequest);
|
|
137
|
+
const criticProvenance = validateCriticResult(criticResult, now, sources);
|
|
138
|
+
sources.push(criticProvenance.source);
|
|
139
|
+
|
|
140
|
+
integrateCriticFindings({
|
|
141
|
+
findings: criticResult.findings,
|
|
142
|
+
claims,
|
|
143
|
+
unresolvedClaims,
|
|
144
|
+
overrides: overrideByClaim,
|
|
145
|
+
sourceById: new Map(sources.map((source) => [source.id, source])),
|
|
146
|
+
criticSourceId: criticProvenance.source.id,
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
const factBase = {
|
|
150
|
+
schemaVersion: FACT_BASE_VERSION,
|
|
151
|
+
generatedAt: now,
|
|
152
|
+
mode: 'federated',
|
|
153
|
+
freshnessPolicy: binding.freshnessPolicy,
|
|
154
|
+
sources,
|
|
155
|
+
claims: claims.sort(byId),
|
|
156
|
+
unresolvedClaims: unresolvedClaims.sort(byId),
|
|
157
|
+
overrides,
|
|
158
|
+
};
|
|
159
|
+
assertValidFactBase(factBase);
|
|
160
|
+
assertCitationConsistency(factBase);
|
|
161
|
+
|
|
162
|
+
return {
|
|
163
|
+
factBase,
|
|
164
|
+
checks: {
|
|
165
|
+
kind: 'federated-reconciliation',
|
|
166
|
+
consistency: 'passed',
|
|
167
|
+
freshness: 'passed',
|
|
168
|
+
warnings: [],
|
|
169
|
+
},
|
|
170
|
+
critic: criticProvenance.record,
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function assertBinding(binding) {
|
|
175
|
+
if (!binding || typeof binding !== 'object') {
|
|
176
|
+
throw new Error('Fact-base binding must be an object.');
|
|
177
|
+
}
|
|
178
|
+
if (!['supplied', 'federated'].includes(binding.mode)) {
|
|
179
|
+
throw new Error(`Unsupported fact-base mode: ${binding.mode}`);
|
|
180
|
+
}
|
|
181
|
+
if (binding.freshnessPolicy !== 'live-wins') {
|
|
182
|
+
throw new Error('Fact-base freshnessPolicy must be live-wins.');
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function assertValidFactBase(factBase) {
|
|
187
|
+
const validation = validateContract('fact-base', factBase);
|
|
188
|
+
if (!validation.valid) {
|
|
189
|
+
throw new Error(
|
|
190
|
+
`Invalid fact-base contract: ${validation.errors
|
|
191
|
+
.map(({ path, message }) => `${path}: ${message}`)
|
|
192
|
+
.join('; ')}`,
|
|
193
|
+
);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function assertCitationConsistency(factBase) {
|
|
198
|
+
const sourceIds = new Set(factBase.sources.map(({ id }) => id));
|
|
199
|
+
assertUniqueNonEmptyIds(factBase.sources, 'source');
|
|
200
|
+
assertUniqueNonEmptyIds(
|
|
201
|
+
[...factBase.claims, ...factBase.unresolvedClaims],
|
|
202
|
+
'claim',
|
|
203
|
+
);
|
|
204
|
+
|
|
205
|
+
for (const claim of [...factBase.claims, ...factBase.unresolvedClaims]) {
|
|
206
|
+
for (const citation of claim.citations) {
|
|
207
|
+
if (!sourceIds.has(citation.sourceId)) {
|
|
208
|
+
throw new Error(
|
|
209
|
+
`Claim ${claim.id} cites unknown source ${citation.sourceId}.`,
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
for (const override of factBase.overrides) {
|
|
216
|
+
if (!factBase.claims.some(({ id }) => id === override.claimId)) {
|
|
217
|
+
throw new Error(
|
|
218
|
+
`Override references unknown confirmed claim ${override.claimId}.`,
|
|
219
|
+
);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function assertUniqueNonEmptyIds(values, label) {
|
|
225
|
+
const ids = values.map(({ id }) => id);
|
|
226
|
+
if (
|
|
227
|
+
ids.some((id) => typeof id !== 'string' || id.length === 0) ||
|
|
228
|
+
new Set(ids).size !== ids.length
|
|
229
|
+
) {
|
|
230
|
+
throw new Error(`${label} IDs must be non-empty and unique.`);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function collectObservations(documents, sourceById) {
|
|
235
|
+
const observations = new Map();
|
|
236
|
+
for (const document of documents) {
|
|
237
|
+
if (
|
|
238
|
+
!document ||
|
|
239
|
+
typeof document !== 'object' ||
|
|
240
|
+
!document.source ||
|
|
241
|
+
!sourceById.has(document.source.id)
|
|
242
|
+
) {
|
|
243
|
+
throw new Error('Every source document requires a valid source.');
|
|
244
|
+
}
|
|
245
|
+
if (!Array.isArray(document.claims)) {
|
|
246
|
+
throw new Error(`Source ${document.source.id} requires a claims array.`);
|
|
247
|
+
}
|
|
248
|
+
for (const claim of document.claims) {
|
|
249
|
+
if (
|
|
250
|
+
!claim ||
|
|
251
|
+
typeof claim.id !== 'string' ||
|
|
252
|
+
claim.id.length === 0 ||
|
|
253
|
+
typeof claim.text !== 'string' ||
|
|
254
|
+
claim.text.length === 0
|
|
255
|
+
) {
|
|
256
|
+
throw new Error(
|
|
257
|
+
`Source ${document.source.id} contains an invalid claim.`,
|
|
258
|
+
);
|
|
259
|
+
}
|
|
260
|
+
const entries = observations.get(claim.id) ?? [];
|
|
261
|
+
entries.push({
|
|
262
|
+
claimId: claim.id,
|
|
263
|
+
text: claim.text,
|
|
264
|
+
source: sourceById.get(document.source.id),
|
|
265
|
+
locator: claim.locator ?? document.source.locator,
|
|
266
|
+
});
|
|
267
|
+
observations.set(claim.id, entries);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
return observations;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function normalizeOverrides(overrides) {
|
|
274
|
+
const normalized = structuredClone(overrides);
|
|
275
|
+
const claimIds = normalized.map(({ claimId }) => claimId);
|
|
276
|
+
if (new Set(claimIds).size !== claimIds.length) {
|
|
277
|
+
throw new Error('Operator overrides must have unique claim IDs.');
|
|
278
|
+
}
|
|
279
|
+
for (const override of normalized) {
|
|
280
|
+
if (
|
|
281
|
+
typeof override.claimId !== 'string' ||
|
|
282
|
+
override.claimId.length === 0 ||
|
|
283
|
+
typeof override.decision !== 'string' ||
|
|
284
|
+
override.decision.length === 0 ||
|
|
285
|
+
Number.isNaN(Date.parse(override.confirmedAt))
|
|
286
|
+
) {
|
|
287
|
+
throw new Error('Operator override is invalid.');
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
return normalized.sort((left, right) =>
|
|
291
|
+
left.claimId.localeCompare(right.claimId),
|
|
292
|
+
);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function resolveObservations(claimId, entries) {
|
|
296
|
+
const byText = new Map();
|
|
297
|
+
for (const entry of entries) {
|
|
298
|
+
const values = byText.get(entry.text) ?? [];
|
|
299
|
+
values.push(entry);
|
|
300
|
+
byText.set(entry.text, values);
|
|
301
|
+
}
|
|
302
|
+
if (byText.size === 1) {
|
|
303
|
+
return {
|
|
304
|
+
status: 'confirmed',
|
|
305
|
+
claim: {
|
|
306
|
+
id: claimId,
|
|
307
|
+
text: entries[0].text,
|
|
308
|
+
status: 'confirmed',
|
|
309
|
+
citations: citationsFor(entries),
|
|
310
|
+
},
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
const ranked = entries
|
|
315
|
+
.map((entry, index) => ({
|
|
316
|
+
entry,
|
|
317
|
+
index,
|
|
318
|
+
authoritative:
|
|
319
|
+
entry.source.authoritativeFor?.includes(claimId) === true ? 1 : 0,
|
|
320
|
+
observedAt: Date.parse(entry.source.observedAt ?? '') || 0,
|
|
321
|
+
}))
|
|
322
|
+
.sort(
|
|
323
|
+
(left, right) =>
|
|
324
|
+
right.authoritative - left.authoritative ||
|
|
325
|
+
right.observedAt - left.observedAt ||
|
|
326
|
+
left.index - right.index,
|
|
327
|
+
);
|
|
328
|
+
const winner = ranked[0];
|
|
329
|
+
const runnerUp = ranked[1];
|
|
330
|
+
const decisive =
|
|
331
|
+
winner.authoritative > runnerUp.authoritative ||
|
|
332
|
+
winner.observedAt > runnerUp.observedAt;
|
|
333
|
+
|
|
334
|
+
if (decisive) {
|
|
335
|
+
return {
|
|
336
|
+
status: 'confirmed',
|
|
337
|
+
claim: {
|
|
338
|
+
id: claimId,
|
|
339
|
+
text: winner.entry.text,
|
|
340
|
+
status: 'confirmed',
|
|
341
|
+
citations: citationsFor(
|
|
342
|
+
entries.filter(({ text }) => text === winner.entry.text),
|
|
343
|
+
),
|
|
344
|
+
},
|
|
345
|
+
};
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
return {
|
|
349
|
+
status: 'unresolved',
|
|
350
|
+
claim: {
|
|
351
|
+
id: claimId,
|
|
352
|
+
text: `Conflicting values: ${[...byText.keys()].sort().join(' | ')}`,
|
|
353
|
+
reason: 'contradictory',
|
|
354
|
+
citations: citationsFor(entries),
|
|
355
|
+
},
|
|
356
|
+
};
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
function citationsFor(entries) {
|
|
360
|
+
const citations = entries.map(({ source, locator }) => ({
|
|
361
|
+
sourceId: source.id,
|
|
362
|
+
locator,
|
|
363
|
+
}));
|
|
364
|
+
return uniqueCitations(citations);
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
function validateCriticResult(result, now, sources) {
|
|
368
|
+
if (
|
|
369
|
+
!result ||
|
|
370
|
+
typeof result !== 'object' ||
|
|
371
|
+
typeof result.criticId !== 'string' ||
|
|
372
|
+
!/^[a-z0-9][a-z0-9._-]*$/.test(result.criticId) ||
|
|
373
|
+
!Array.isArray(result.findings)
|
|
374
|
+
) {
|
|
375
|
+
throw new Error(
|
|
376
|
+
'Critic callback must return criticId and a findings array.',
|
|
377
|
+
);
|
|
378
|
+
}
|
|
379
|
+
const executedAt = result.executedAt ?? now;
|
|
380
|
+
if (Number.isNaN(Date.parse(executedAt))) {
|
|
381
|
+
throw new Error('Critic executedAt must be an ISO timestamp.');
|
|
382
|
+
}
|
|
383
|
+
const sourceId = `critic:${result.criticId}`;
|
|
384
|
+
if (sources.some(({ id }) => id === sourceId)) {
|
|
385
|
+
throw new Error(`Critic provenance source ID collides: ${sourceId}.`);
|
|
386
|
+
}
|
|
387
|
+
const resultHash = canonicalHash(result);
|
|
388
|
+
return {
|
|
389
|
+
source: {
|
|
390
|
+
id: sourceId,
|
|
391
|
+
kind: 'other',
|
|
392
|
+
locator: `critic-callback:${result.criticId}`,
|
|
393
|
+
hash: resultHash,
|
|
394
|
+
observedAt: executedAt,
|
|
395
|
+
},
|
|
396
|
+
record: {
|
|
397
|
+
invoked: true,
|
|
398
|
+
criticId: result.criticId,
|
|
399
|
+
executedAt,
|
|
400
|
+
sourceId,
|
|
401
|
+
resultHash,
|
|
402
|
+
},
|
|
403
|
+
};
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
function integrateCriticFindings({
|
|
407
|
+
findings,
|
|
408
|
+
claims,
|
|
409
|
+
unresolvedClaims,
|
|
410
|
+
overrides,
|
|
411
|
+
sourceById,
|
|
412
|
+
criticSourceId,
|
|
413
|
+
}) {
|
|
414
|
+
for (const finding of findings) {
|
|
415
|
+
validateFinding(finding, sourceById);
|
|
416
|
+
if (overrides.has(finding.claimId)) {
|
|
417
|
+
continue;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
const citations = uniqueCitations([
|
|
421
|
+
{
|
|
422
|
+
sourceId: criticSourceId,
|
|
423
|
+
locator: `critic-finding:${finding.claimId}`,
|
|
424
|
+
},
|
|
425
|
+
...finding.sourceIds.map((sourceId) => ({
|
|
426
|
+
sourceId,
|
|
427
|
+
locator: sourceById.get(sourceId).locator,
|
|
428
|
+
})),
|
|
429
|
+
]);
|
|
430
|
+
const existingUnresolved = unresolvedClaims.find(
|
|
431
|
+
({ id }) => id === finding.claimId,
|
|
432
|
+
);
|
|
433
|
+
if (existingUnresolved) {
|
|
434
|
+
existingUnresolved.reason = finding.classification;
|
|
435
|
+
existingUnresolved.text = finding.text;
|
|
436
|
+
existingUnresolved.citations = uniqueCitations([
|
|
437
|
+
...existingUnresolved.citations,
|
|
438
|
+
...citations,
|
|
439
|
+
]);
|
|
440
|
+
continue;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
const claimIndex = claims.findIndex(({ id }) => id === finding.claimId);
|
|
444
|
+
if (claimIndex >= 0) {
|
|
445
|
+
claims.splice(claimIndex, 1);
|
|
446
|
+
}
|
|
447
|
+
unresolvedClaims.push({
|
|
448
|
+
id: finding.claimId,
|
|
449
|
+
text: finding.text,
|
|
450
|
+
reason: finding.classification,
|
|
451
|
+
citations,
|
|
452
|
+
});
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
function validateFinding(finding, sourceById) {
|
|
457
|
+
if (
|
|
458
|
+
!finding ||
|
|
459
|
+
typeof finding.claimId !== 'string' ||
|
|
460
|
+
finding.claimId.length === 0 ||
|
|
461
|
+
typeof finding.text !== 'string' ||
|
|
462
|
+
finding.text.length === 0 ||
|
|
463
|
+
!FINDING_REASONS.has(finding.classification) ||
|
|
464
|
+
!Array.isArray(finding.sourceIds)
|
|
465
|
+
) {
|
|
466
|
+
throw new Error('Critic returned an invalid finding.');
|
|
467
|
+
}
|
|
468
|
+
for (const sourceId of finding.sourceIds) {
|
|
469
|
+
if (!sourceById.has(sourceId)) {
|
|
470
|
+
throw new Error(`Critic finding cites unknown source ${sourceId}.`);
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
function uniqueCitations(citations) {
|
|
476
|
+
return [
|
|
477
|
+
...new Map(
|
|
478
|
+
citations
|
|
479
|
+
.sort(
|
|
480
|
+
(left, right) =>
|
|
481
|
+
left.sourceId.localeCompare(right.sourceId) ||
|
|
482
|
+
left.locator.localeCompare(right.locator),
|
|
483
|
+
)
|
|
484
|
+
.map((citation) => [
|
|
485
|
+
`${citation.sourceId}\0${citation.locator}`,
|
|
486
|
+
citation,
|
|
487
|
+
]),
|
|
488
|
+
).values(),
|
|
489
|
+
];
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
function byId(left, right) {
|
|
493
|
+
return left.id.localeCompare(right.id);
|
|
494
|
+
}
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { lstat, mkdir, open, realpath, rename, rm } from 'node:fs/promises';
|
|
3
|
+
import { basename, dirname, join, relative, resolve, sep } from 'node:path';
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
validatePortablePath,
|
|
7
|
+
validateSafeRelativePath,
|
|
8
|
+
} from './safe-paths.mjs';
|
|
9
|
+
|
|
10
|
+
export function normalizeSlug(candidate) {
|
|
11
|
+
if (
|
|
12
|
+
typeof candidate !== 'string' ||
|
|
13
|
+
candidate.includes('/') ||
|
|
14
|
+
candidate.includes('\\') ||
|
|
15
|
+
candidate.includes('\0') ||
|
|
16
|
+
candidate === '.' ||
|
|
17
|
+
candidate === '..'
|
|
18
|
+
) {
|
|
19
|
+
throw new Error('Slug must be text, not a path or traversal value.');
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const slug = candidate
|
|
23
|
+
.normalize('NFKD')
|
|
24
|
+
.replaceAll(/\p{Mark}/gu, '')
|
|
25
|
+
.toLowerCase()
|
|
26
|
+
.trim()
|
|
27
|
+
.replaceAll(/[^a-z0-9]+/g, '-')
|
|
28
|
+
.replaceAll(/^-+|-+$/g, '');
|
|
29
|
+
|
|
30
|
+
if (!slug) {
|
|
31
|
+
throw new Error('Slug must contain at least one letter or number.');
|
|
32
|
+
}
|
|
33
|
+
return slug;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export async function createConfinedRunRoot(outputRoot, slug) {
|
|
37
|
+
const checkedRoot = validatePortablePath(outputRoot);
|
|
38
|
+
if (!checkedRoot.valid) {
|
|
39
|
+
throw new Error(`Unsafe output root: ${checkedRoot.errors[0].message}`);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const normalizedSlug = normalizeSlug(slug);
|
|
43
|
+
const requestedRoot = resolve(checkedRoot.normalizedPath);
|
|
44
|
+
await mkdir(requestedRoot, { recursive: true });
|
|
45
|
+
const canonicalRoot = await realpath(requestedRoot);
|
|
46
|
+
const runRoot = join(canonicalRoot, normalizedSlug);
|
|
47
|
+
|
|
48
|
+
if (!isWithin(canonicalRoot, runRoot)) {
|
|
49
|
+
throw new Error('Normalized run root escapes the output root.');
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
try {
|
|
53
|
+
const existing = await lstat(runRoot);
|
|
54
|
+
if (existing.isSymbolicLink()) {
|
|
55
|
+
throw new Error('Run root cannot be a symlink.');
|
|
56
|
+
}
|
|
57
|
+
if (!existing.isDirectory()) {
|
|
58
|
+
throw new Error('Run root exists and is not a directory.');
|
|
59
|
+
}
|
|
60
|
+
} catch (error) {
|
|
61
|
+
if (error?.code !== 'ENOENT') {
|
|
62
|
+
throw error;
|
|
63
|
+
}
|
|
64
|
+
await mkdir(runRoot);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const canonicalRunRoot = await realpath(runRoot);
|
|
68
|
+
if (!isWithin(canonicalRoot, canonicalRunRoot)) {
|
|
69
|
+
throw new Error('Run root escapes the output root through a symlink.');
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return {
|
|
73
|
+
outputRoot: canonicalRoot,
|
|
74
|
+
runRoot: canonicalRunRoot,
|
|
75
|
+
slug: normalizedSlug,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export async function writeJsonAtomic(root, relativePath, value) {
|
|
80
|
+
return writeFileAtomic(
|
|
81
|
+
root,
|
|
82
|
+
relativePath,
|
|
83
|
+
`${JSON.stringify(value, null, 2)}\n`,
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export async function writeTextAtomic(root, relativePath, value) {
|
|
88
|
+
if (typeof value !== 'string') {
|
|
89
|
+
throw new TypeError('Atomic text writes require a string value.');
|
|
90
|
+
}
|
|
91
|
+
return writeFileAtomic(root, relativePath, value);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export async function writeFileAtomic(root, relativePath, value) {
|
|
95
|
+
const checkedPath = validateSafeRelativePath(relativePath);
|
|
96
|
+
if (!checkedPath.valid) {
|
|
97
|
+
throw new Error(`Unsafe output path: ${checkedPath.errors[0].message}`);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const rootStat = await lstat(root);
|
|
101
|
+
if (rootStat.isSymbolicLink() || !rootStat.isDirectory()) {
|
|
102
|
+
throw new Error(
|
|
103
|
+
'Atomic write root must be a real directory, not a symlink.',
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
const canonicalRoot = await realpath(root);
|
|
107
|
+
const targetPath = resolve(canonicalRoot, checkedPath.normalizedPath);
|
|
108
|
+
if (!isWithin(canonicalRoot, targetPath)) {
|
|
109
|
+
throw new Error('Atomic write target escapes its configured root.');
|
|
110
|
+
}
|
|
111
|
+
const canonicalParent = await createConfinedDirectories(
|
|
112
|
+
canonicalRoot,
|
|
113
|
+
dirname(checkedPath.normalizedPath),
|
|
114
|
+
);
|
|
115
|
+
|
|
116
|
+
try {
|
|
117
|
+
const target = await lstat(targetPath);
|
|
118
|
+
if (target.isSymbolicLink()) {
|
|
119
|
+
throw new Error('Atomic write target cannot be a symlink.');
|
|
120
|
+
}
|
|
121
|
+
} catch (error) {
|
|
122
|
+
if (error?.code !== 'ENOENT') {
|
|
123
|
+
throw error;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const temporaryPath = join(
|
|
128
|
+
canonicalParent,
|
|
129
|
+
`.${basename(targetPath)}.tmp-${randomUUID()}`,
|
|
130
|
+
);
|
|
131
|
+
let handle;
|
|
132
|
+
|
|
133
|
+
try {
|
|
134
|
+
handle = await open(temporaryPath, 'wx', 0o600);
|
|
135
|
+
await handle.writeFile(value);
|
|
136
|
+
await handle.sync();
|
|
137
|
+
await handle.close();
|
|
138
|
+
handle = undefined;
|
|
139
|
+
await assertConfinedDirectory(canonicalRoot, canonicalParent);
|
|
140
|
+
await rejectSymlinkTarget(targetPath);
|
|
141
|
+
await rename(temporaryPath, targetPath);
|
|
142
|
+
const directoryHandle = await open(canonicalParent, 'r');
|
|
143
|
+
try {
|
|
144
|
+
await directoryHandle.sync();
|
|
145
|
+
} finally {
|
|
146
|
+
await directoryHandle.close();
|
|
147
|
+
}
|
|
148
|
+
} catch (error) {
|
|
149
|
+
await handle?.close().catch(() => {});
|
|
150
|
+
await rm(temporaryPath, { force: true }).catch(() => {});
|
|
151
|
+
throw error;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return targetPath;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
async function createConfinedDirectories(canonicalRoot, relativeDirectory) {
|
|
158
|
+
const segments =
|
|
159
|
+
relativeDirectory === '.' ? [] : relativeDirectory.split(/[\\/]/);
|
|
160
|
+
let current = canonicalRoot;
|
|
161
|
+
for (const segment of segments) {
|
|
162
|
+
current = join(current, segment);
|
|
163
|
+
try {
|
|
164
|
+
const existing = await lstat(current);
|
|
165
|
+
if (existing.isSymbolicLink()) {
|
|
166
|
+
throw new Error(
|
|
167
|
+
`Atomic write ancestor cannot be a symlink: ${segment}.`,
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
if (!existing.isDirectory()) {
|
|
171
|
+
throw new Error(
|
|
172
|
+
`Atomic write ancestor is not a directory: ${segment}.`,
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
} catch (error) {
|
|
176
|
+
if (error?.code !== 'ENOENT') throw error;
|
|
177
|
+
try {
|
|
178
|
+
await mkdir(current, { mode: 0o700 });
|
|
179
|
+
} catch (mkdirError) {
|
|
180
|
+
if (mkdirError?.code !== 'EEXIST') throw mkdirError;
|
|
181
|
+
}
|
|
182
|
+
const created = await lstat(current);
|
|
183
|
+
if (created.isSymbolicLink() || !created.isDirectory()) {
|
|
184
|
+
throw new Error(`Atomic write ancestor is unsafe: ${segment}.`, {
|
|
185
|
+
cause: error,
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
await assertConfinedDirectory(canonicalRoot, current);
|
|
190
|
+
}
|
|
191
|
+
return current;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
async function assertConfinedDirectory(canonicalRoot, directory) {
|
|
195
|
+
const stat = await lstat(directory);
|
|
196
|
+
if (stat.isSymbolicLink() || !stat.isDirectory()) {
|
|
197
|
+
throw new Error('Atomic write ancestor must be a real directory.');
|
|
198
|
+
}
|
|
199
|
+
const canonicalDirectory = await realpath(directory);
|
|
200
|
+
if (!isWithin(canonicalRoot, canonicalDirectory)) {
|
|
201
|
+
throw new Error('Atomic write ancestor escapes its configured root.');
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
async function rejectSymlinkTarget(targetPath) {
|
|
206
|
+
try {
|
|
207
|
+
if ((await lstat(targetPath)).isSymbolicLink()) {
|
|
208
|
+
throw new Error('Atomic write target cannot be a symlink.');
|
|
209
|
+
}
|
|
210
|
+
} catch (error) {
|
|
211
|
+
if (error?.code !== 'ENOENT') throw error;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function isWithin(root, target) {
|
|
216
|
+
const path = relative(root, target);
|
|
217
|
+
return path === '' || (!path.startsWith(`..${sep}`) && path !== '..');
|
|
218
|
+
}
|