@open-agent-toolkit/cli 0.2.53 → 0.2.55
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/agents/recon-worker.md +105 -0
- package/assets/bundle-metadata.json +1 -1
- package/assets/docs/cli-utilities/tool-packs.md +62 -8
- package/assets/docs/contributing/skills.md +26 -0
- package/assets/docs/workflows/skills/index.md +3 -0
- package/assets/docs/workflows/skills/recon.md +210 -0
- package/assets/public-package-versions.json +4 -4
- package/assets/skills/oat-dispatch-subagents/SKILL.md +1 -1
- package/assets/skills/recon/SKILL.md +298 -0
- package/assets/skills/recon/references/packet-contract.md +340 -0
- package/assets/skills/recon/references/profiles.md +58 -0
- package/assets/skills/recon/references/worker-contract.md +71 -0
- package/assets/skills/recon/scripts/create-review-brief.mjs +452 -0
- package/assets/skills/recon/scripts/lib/canonical-json.mjs +55 -0
- package/assets/skills/recon/scripts/lib/contracts.mjs +1969 -0
- package/assets/skills/recon/scripts/lib/safe-path.mjs +120 -0
- package/assets/skills/recon/scripts/lib/validated-run.mjs +80 -0
- package/assets/skills/recon/scripts/reconcile-ledger.mjs +360 -0
- package/assets/skills/recon/scripts/render-packet.mjs +379 -0
- package/assets/skills/recon/scripts/validate-artifact.mjs +96 -0
- package/assets/skills/recon/scripts/validate-packet.mjs +2192 -0
- package/dist/commands/doctor/index.js +5 -5
- package/dist/commands/init/index.d.ts.map +1 -1
- package/dist/commands/init/index.js +6 -7
- package/dist/commands/status/index.d.ts +2 -2
- package/dist/commands/status/index.d.ts.map +1 -1
- package/dist/commands/status/index.js +7 -4
- package/dist/commands/sync/index.d.ts.map +1 -1
- package/dist/commands/sync/index.js +24 -8
- package/dist/commands/sync/sync.types.d.ts +2 -2
- package/dist/commands/sync/sync.types.d.ts.map +1 -1
- package/dist/commands/tools/migrate/index.d.ts.map +1 -1
- package/dist/commands/tools/migrate/index.js +25 -1
- package/dist/commands/tools/migrate/migrate-pack.d.ts +5 -0
- package/dist/commands/tools/migrate/migrate-pack.d.ts.map +1 -1
- package/dist/commands/tools/migrate/migrate-pack.js +66 -7
- package/dist/commands/tools/remove/index.js +1 -1
- package/dist/commands/tools/remove/remove-tools.d.ts +5 -1
- package/dist/commands/tools/remove/remove-tools.d.ts.map +1 -1
- package/dist/commands/tools/remove/remove-tools.js +81 -1
- package/dist/commands/tools/shared/apply-pack-reconcile.d.ts +3 -0
- package/dist/commands/tools/shared/apply-pack-reconcile.d.ts.map +1 -1
- package/dist/commands/tools/shared/apply-pack-reconcile.js +40 -9
- package/dist/commands/tools/shared/format-pack-inventory.d.ts.map +1 -1
- package/dist/commands/tools/shared/format-pack-inventory.js +3 -0
- package/dist/commands/tools/shared/install-sync-context.d.ts +2 -1
- package/dist/commands/tools/shared/install-sync-context.d.ts.map +1 -1
- package/dist/commands/tools/shared/install-sync-context.js +14 -3
- package/dist/commands/tools/shared/pack-dependencies.d.ts +14 -0
- package/dist/commands/tools/shared/pack-dependencies.d.ts.map +1 -0
- package/dist/commands/tools/shared/pack-dependencies.js +54 -0
- package/dist/commands/tools/shared/pack-inventory.d.ts.map +1 -1
- package/dist/commands/tools/shared/pack-inventory.js +9 -9
- package/dist/commands/tools/shared/pack-lifecycle.d.ts +9 -1
- package/dist/commands/tools/shared/pack-lifecycle.d.ts.map +1 -1
- package/dist/commands/tools/shared/pack-lifecycle.js +102 -9
- package/dist/commands/tools/shared/pack-manifest.d.ts +2 -2
- package/dist/commands/tools/shared/pack-manifest.d.ts.map +1 -1
- package/dist/commands/tools/shared/pack-manifest.js +91 -2
- package/dist/commands/tools/shared/pack-reconcile.d.ts +15 -1
- package/dist/commands/tools/shared/pack-reconcile.d.ts.map +1 -1
- package/dist/commands/tools/shared/pack-reconcile.js +75 -13
- package/dist/commands/tools/shared/scoped-pack-intent.d.ts +8 -0
- package/dist/commands/tools/shared/scoped-pack-intent.d.ts.map +1 -1
- package/dist/commands/tools/shared/scoped-pack-intent.js +61 -15
- package/dist/commands/tools/shared/types.d.ts +7 -0
- package/dist/commands/tools/shared/types.d.ts.map +1 -1
- package/dist/commands/tools/update/update-tools.d.ts.map +1 -1
- package/dist/commands/tools/update/update-tools.js +7 -1
- package/dist/config/oat-config.d.ts +5 -1
- package/dist/config/oat-config.d.ts.map +1 -1
- package/dist/config/oat-config.js +19 -0
- package/dist/engine/index.d.ts +1 -1
- package/dist/engine/index.d.ts.map +1 -1
- package/dist/engine/scanner.d.ts +9 -1
- package/dist/engine/scanner.d.ts.map +1 -1
- package/dist/engine/scanner.js +61 -4
- package/dist/shared/types.d.ts +1 -0
- package/dist/shared/types.d.ts.map +1 -1
- package/dist/shared/types.js +3 -0
- package/package.json +2 -2
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# Recon Profiles
|
|
2
|
+
|
|
3
|
+
Profiles define assurance topology, not a model tier. The controller sizes
|
|
4
|
+
adaptive lanes from a mechanical source inventory, shows exact counts before
|
|
5
|
+
approval, and never exceeds the approved hard cap. Every wave remains
|
|
6
|
+
homogeneous and every pass uses the same approved model and effort.
|
|
7
|
+
|
|
8
|
+
## quick
|
|
9
|
+
|
|
10
|
+
Use for bounded orientation where independently checked locators and supported
|
|
11
|
+
claims are sufficient.
|
|
12
|
+
|
|
13
|
+
- Required: one mapping wave, one or more non-overlapping gather lanes, one
|
|
14
|
+
compile lane, deterministic schema and locator validation, and rendering.
|
|
15
|
+
- Adaptive lane range: 1–4 gather lanes; hard cap 4.
|
|
16
|
+
- Maximum concurrency: 4.
|
|
17
|
+
- Assurance ceiling: `supported`; a quick packet is never `verified`.
|
|
18
|
+
- Conditional work: no semantic, adversarial, or coverage worker is implied.
|
|
19
|
+
|
|
20
|
+
## standard
|
|
21
|
+
|
|
22
|
+
Use for load-bearing evidence that needs independent semantic challenge.
|
|
23
|
+
|
|
24
|
+
- Required: quick topology plus selectively blind semantic verification,
|
|
25
|
+
adversarial review, coverage review, and reconciliation.
|
|
26
|
+
- Adaptive lane range: 1–6 gather lanes and 1–3 verification lanes; hard cap 10
|
|
27
|
+
total worker lanes.
|
|
28
|
+
- Maximum concurrency: 6.
|
|
29
|
+
- Assurance ceiling: `verified` only after exact locator validation, affirmative
|
|
30
|
+
independent semantic review, no unresolved material challenge, and adequate
|
|
31
|
+
coverage.
|
|
32
|
+
- Conditional work: at most one approved reconciliation lane for a material
|
|
33
|
+
contradiction.
|
|
34
|
+
|
|
35
|
+
## thorough
|
|
36
|
+
|
|
37
|
+
Use when expensive failure or correlated blind spots justify redundant source
|
|
38
|
+
reopening and explicit contradiction resolution.
|
|
39
|
+
|
|
40
|
+
- Required: standard topology plus redundant independent gathering and
|
|
41
|
+
verification for load-bearing claims and explicit contradiction resolution.
|
|
42
|
+
- Adaptive lane range: 2–10 gather lanes and 2–6 verification or adversarial
|
|
43
|
+
lanes; hard cap 20 total worker lanes.
|
|
44
|
+
- Maximum concurrency: 8.
|
|
45
|
+
- Assurance ceiling: `verified` only for claims affirmed by the required
|
|
46
|
+
independent passes and left without unresolved material challenge.
|
|
47
|
+
- Conditional work: up to two approved contradiction-resolution lanes within
|
|
48
|
+
the manifest's hard cap.
|
|
49
|
+
|
|
50
|
+
## Planning Rules
|
|
51
|
+
|
|
52
|
+
1. Partition by source or question so lane inputs and outputs never overlap.
|
|
53
|
+
2. Classify required and conditional waves before target preparation.
|
|
54
|
+
3. Compute the run-wide maximum model-class floor from every allowed wave.
|
|
55
|
+
4. Reduce lanes when scope is small; never invent work to fill a profile cap.
|
|
56
|
+
5. If runtime limits prevent the requested topology, prepare a different
|
|
57
|
+
profile for approval or publish an honest partial after an accepted failure.
|
|
58
|
+
6. Never add a lane, retry, or replacement beyond the approved envelope.
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# Recon Worker Contract
|
|
2
|
+
|
|
3
|
+
Every recon assignment is a non-interactive leaf task. The controller supplies
|
|
4
|
+
the complete assignment; a worker does not infer broader authority.
|
|
5
|
+
|
|
6
|
+
## Required Assignment Envelope
|
|
7
|
+
|
|
8
|
+
The assignment must declare:
|
|
9
|
+
|
|
10
|
+
- `runId`, `waveId`, `laneId`, and exactly one mode;
|
|
11
|
+
- bounded objective, included scope, and excluded scope;
|
|
12
|
+
- allowed inputs and excluded inputs;
|
|
13
|
+
- source-read authority in `readSources`, including allowed read-only tools;
|
|
14
|
+
- sole `writePath`, contained by the packet directory and unique to the lane;
|
|
15
|
+
- required artifact `kind`, `schemaVersion`, and output schema;
|
|
16
|
+
- enforcement level and deadline; and
|
|
17
|
+
- whether failure should be recorded as required, optional, or conditional.
|
|
18
|
+
|
|
19
|
+
Reject an incomplete or contradictory assignment before reading sources. Never
|
|
20
|
+
request credentials, mutate an investigated source, broaden scope, or choose an
|
|
21
|
+
alternate write path.
|
|
22
|
+
|
|
23
|
+
## Modes
|
|
24
|
+
|
|
25
|
+
- `map`: inventory only the assigned source partition and report candidate
|
|
26
|
+
evidence areas, unavailable inputs, and coverage gaps.
|
|
27
|
+
- `gather`: inspect assigned sources and emit source-grounded findings with
|
|
28
|
+
typed locators, minimal display excerpts, uncertainty, and contradictions.
|
|
29
|
+
- `compile`: consume only designated dossiers and create a provisional claim
|
|
30
|
+
ledger candidate. Deduplicate without inventing evidence.
|
|
31
|
+
- `verify`: consume an immutable verification brief, reopen only its declared
|
|
32
|
+
sources, test locators and claim semantics, and report claim dispositions.
|
|
33
|
+
- `adversary`: consume scope, questions, and provisional statements only; seek
|
|
34
|
+
counterevidence, unsupported inference, and missing alternatives.
|
|
35
|
+
- `coverage`: compare declared scope and questions with ledger coverage without
|
|
36
|
+
reading gatherer reasoning.
|
|
37
|
+
- `reconcile`: apply review dispositions and contradiction outcomes to a new
|
|
38
|
+
ledger candidate. Preserve prior revisions and never invent evidence.
|
|
39
|
+
|
|
40
|
+
No other mode is valid.
|
|
41
|
+
|
|
42
|
+
## Output Contract
|
|
43
|
+
|
|
44
|
+
Write exactly one JSON artifact at `writePath`. The artifact must include:
|
|
45
|
+
|
|
46
|
+
- `kind`, `schemaVersion`, run/wave/lane identity, mode, and outcome;
|
|
47
|
+
- the exact allowed and excluded inputs actually honored;
|
|
48
|
+
- findings or dispositions with evidence IDs and typed locators;
|
|
49
|
+
- uncertainty, contradictions, and gaps as explicit arrays;
|
|
50
|
+
- safe categorical diagnostics for unavailable or invalid inputs; and
|
|
51
|
+
- direct input artifact references and digests when applicable.
|
|
52
|
+
|
|
53
|
+
Persist minimal excerpts only. Detect and redact secret spans before writing;
|
|
54
|
+
never persist the secret or its sensitive-span digest. Finish by returning the
|
|
55
|
+
artifact path and compact outcome only.
|
|
56
|
+
|
|
57
|
+
The controller validates the candidate against `packet-contract.md` with the
|
|
58
|
+
bundled deterministic artifact validator. A validation failure quarantines the
|
|
59
|
+
candidate; it never authorizes the worker to rewrite a shared artifact, retry,
|
|
60
|
+
or launch a replacement.
|
|
61
|
+
|
|
62
|
+
## Invariants
|
|
63
|
+
|
|
64
|
+
- Read only the assignment's allowed inputs using its source-read authority.
|
|
65
|
+
- Write only the assigned artifact; never update a shared ledger or manifest.
|
|
66
|
+
- Never interact with the user.
|
|
67
|
+
- Never dispatch children, helpers, reviewers, or replacement workers.
|
|
68
|
+
- Never read excluded inputs, prior review conclusions, or raw reasoning that
|
|
69
|
+
the selected mode is required to remain blind to.
|
|
70
|
+
- Report uncertainty and contradiction instead of converting them to
|
|
71
|
+
confidence scores.
|
|
@@ -0,0 +1,452 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
4
|
+
import { dirname, relative, resolve } from 'node:path';
|
|
5
|
+
import { pathToFileURL } from 'node:url';
|
|
6
|
+
|
|
7
|
+
import { canonicalJson, hashFile } from './lib/canonical-json.mjs';
|
|
8
|
+
import { issue, isObject, validateArtifactShape } from './lib/contracts.mjs';
|
|
9
|
+
import { assertSafeOutputPath } from './lib/safe-path.mjs';
|
|
10
|
+
|
|
11
|
+
const commonKeys = [
|
|
12
|
+
'kind',
|
|
13
|
+
'schemaVersion',
|
|
14
|
+
'id',
|
|
15
|
+
'runId',
|
|
16
|
+
'mode',
|
|
17
|
+
'createdAt',
|
|
18
|
+
'excludedInputs',
|
|
19
|
+
];
|
|
20
|
+
const allowedKeys = {
|
|
21
|
+
verify: new Set([...commonKeys, 'claims', 'sources']),
|
|
22
|
+
adversary: new Set([
|
|
23
|
+
...commonKeys,
|
|
24
|
+
'scope',
|
|
25
|
+
'questions',
|
|
26
|
+
'provisionalStatements',
|
|
27
|
+
]),
|
|
28
|
+
coverage: new Set([...commonKeys, 'scope', 'questions', 'claims']),
|
|
29
|
+
};
|
|
30
|
+
const forbiddenKeys = new Set([
|
|
31
|
+
'derivedFrom',
|
|
32
|
+
'provenance',
|
|
33
|
+
'reviewIds',
|
|
34
|
+
'synthesis',
|
|
35
|
+
'compilerReasoning',
|
|
36
|
+
'inputArtifacts',
|
|
37
|
+
'priorReviews',
|
|
38
|
+
'dossierPath',
|
|
39
|
+
]);
|
|
40
|
+
|
|
41
|
+
const reviewSourceFields = {
|
|
42
|
+
repository: ['root', 'revision', 'dirty', 'contentHashes'],
|
|
43
|
+
file: ['path', 'contentHash'],
|
|
44
|
+
url: ['url', 'capturePath', 'captureDigest', 'validatorState'],
|
|
45
|
+
'command-output': [
|
|
46
|
+
'argv',
|
|
47
|
+
'cwd',
|
|
48
|
+
'exitStatus',
|
|
49
|
+
'outputPath',
|
|
50
|
+
'outputDigest',
|
|
51
|
+
'environmentNames',
|
|
52
|
+
],
|
|
53
|
+
'connected-resource': [
|
|
54
|
+
'system',
|
|
55
|
+
'resourceId',
|
|
56
|
+
'resourceVersion',
|
|
57
|
+
'retrievalToken',
|
|
58
|
+
'capturePath',
|
|
59
|
+
'captureDigest',
|
|
60
|
+
],
|
|
61
|
+
};
|
|
62
|
+
const commonReviewSourceFields = [
|
|
63
|
+
'id',
|
|
64
|
+
'kind',
|
|
65
|
+
'available',
|
|
66
|
+
'authority',
|
|
67
|
+
'observedAt',
|
|
68
|
+
'validationState',
|
|
69
|
+
];
|
|
70
|
+
|
|
71
|
+
function selectedClaims(ledger, claimIds) {
|
|
72
|
+
const selected = claimIds ? new Set(claimIds) : null;
|
|
73
|
+
return ledger.claims.filter((claim) => !selected || selected.has(claim.id));
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function projectReviewSource(source) {
|
|
77
|
+
const kindFields = reviewSourceFields[source.kind];
|
|
78
|
+
if (!kindFields) {
|
|
79
|
+
throw new Error(`Cannot project unknown source kind ${source.kind}`);
|
|
80
|
+
}
|
|
81
|
+
return Object.fromEntries(
|
|
82
|
+
[...commonReviewSourceFields, ...kindFields]
|
|
83
|
+
.filter((key) => Object.hasOwn(source, key))
|
|
84
|
+
.map((key) => [key, structuredClone(source[key])]),
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function verificationBrief(input) {
|
|
89
|
+
const claims = selectedClaims(input.ledger, input.claimIds);
|
|
90
|
+
const evidenceById = new Map(
|
|
91
|
+
input.ledger.evidence.map((evidence) => [evidence.id, evidence]),
|
|
92
|
+
);
|
|
93
|
+
const sourceIds = new Set();
|
|
94
|
+
const projectedClaims = claims.map((claim) => ({
|
|
95
|
+
id: claim.id,
|
|
96
|
+
statement: claim.statement,
|
|
97
|
+
evidence: claim.evidence.map((link) => {
|
|
98
|
+
const evidence = evidenceById.get(link.evidenceId);
|
|
99
|
+
if (!evidence) {
|
|
100
|
+
throw new Error(
|
|
101
|
+
`Claim ${claim.id} references missing evidence ${link.evidenceId}`,
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
sourceIds.add(evidence.sourceId);
|
|
105
|
+
return {
|
|
106
|
+
id: evidence.id,
|
|
107
|
+
sourceId: evidence.sourceId,
|
|
108
|
+
displayExcerpt: evidence.displayExcerpt,
|
|
109
|
+
locator: structuredClone(evidence.locator),
|
|
110
|
+
};
|
|
111
|
+
}),
|
|
112
|
+
}));
|
|
113
|
+
const declaredSourceIds = new Set(
|
|
114
|
+
input.manifest.sources.map((source) => source.id),
|
|
115
|
+
);
|
|
116
|
+
for (const sourceId of sourceIds) {
|
|
117
|
+
if (!declaredSourceIds.has(sourceId)) {
|
|
118
|
+
throw new Error(`Evidence references missing source ${sourceId}`);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return {
|
|
122
|
+
kind: 'recon.review-brief',
|
|
123
|
+
schemaVersion: 1,
|
|
124
|
+
id: input.id,
|
|
125
|
+
runId: input.manifest.run.id,
|
|
126
|
+
mode: 'verify',
|
|
127
|
+
createdAt: input.createdAt,
|
|
128
|
+
excludedInputs: [
|
|
129
|
+
'worker_intermediates',
|
|
130
|
+
'prior_reasoning',
|
|
131
|
+
'consumer_summary',
|
|
132
|
+
'artifact_lineage',
|
|
133
|
+
'earlier_reviews',
|
|
134
|
+
],
|
|
135
|
+
claims: projectedClaims,
|
|
136
|
+
sources: input.manifest.sources
|
|
137
|
+
.filter((source) => sourceIds.has(source.id))
|
|
138
|
+
.map(projectReviewSource),
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function adversarialBrief(input) {
|
|
143
|
+
return {
|
|
144
|
+
kind: 'recon.review-brief',
|
|
145
|
+
schemaVersion: 1,
|
|
146
|
+
id: input.id,
|
|
147
|
+
runId: input.manifest.run.id,
|
|
148
|
+
mode: 'adversary',
|
|
149
|
+
createdAt: input.createdAt,
|
|
150
|
+
excludedInputs: [
|
|
151
|
+
'worker_intermediates',
|
|
152
|
+
'prior_reasoning',
|
|
153
|
+
'consumer_summary',
|
|
154
|
+
'artifact_lineage',
|
|
155
|
+
'earlier_reviews',
|
|
156
|
+
'verification_conclusions',
|
|
157
|
+
],
|
|
158
|
+
scope: {
|
|
159
|
+
included: structuredClone(input.manifest.request.includedScope ?? []),
|
|
160
|
+
excluded: structuredClone(input.manifest.request.excludedScope ?? []),
|
|
161
|
+
},
|
|
162
|
+
questions: structuredClone(input.manifest.request.questions ?? []),
|
|
163
|
+
provisionalStatements: selectedClaims(input.ledger, input.claimIds).map(
|
|
164
|
+
(claim) => ({ id: claim.id, statement: claim.statement }),
|
|
165
|
+
),
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function coverageBrief(input) {
|
|
170
|
+
return {
|
|
171
|
+
kind: 'recon.review-brief',
|
|
172
|
+
schemaVersion: 1,
|
|
173
|
+
id: input.id,
|
|
174
|
+
runId: input.manifest.run.id,
|
|
175
|
+
mode: 'coverage',
|
|
176
|
+
createdAt: input.createdAt,
|
|
177
|
+
excludedInputs: [
|
|
178
|
+
'worker_intermediates',
|
|
179
|
+
'prior_reasoning',
|
|
180
|
+
'consumer_summary',
|
|
181
|
+
'artifact_lineage',
|
|
182
|
+
'earlier_reviews',
|
|
183
|
+
'verification_conclusions',
|
|
184
|
+
'adversarial_conclusions',
|
|
185
|
+
],
|
|
186
|
+
scope: {
|
|
187
|
+
included: structuredClone(input.manifest.request.includedScope ?? []),
|
|
188
|
+
excluded: structuredClone(input.manifest.request.excludedScope ?? []),
|
|
189
|
+
},
|
|
190
|
+
questions: structuredClone(input.manifest.request.questions ?? []),
|
|
191
|
+
claims: selectedClaims(input.ledger, input.claimIds).map((claim) => ({
|
|
192
|
+
id: claim.id,
|
|
193
|
+
statement: claim.statement,
|
|
194
|
+
})),
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export function createReviewBrief(input) {
|
|
199
|
+
if (!['verify', 'adversary', 'coverage'].includes(input.mode)) {
|
|
200
|
+
throw new Error(`Unsupported review brief mode: ${input.mode}`);
|
|
201
|
+
}
|
|
202
|
+
if (!input.id || !input.createdAt || !input.manifest || !input.ledger) {
|
|
203
|
+
throw new Error(
|
|
204
|
+
'Review brief requires id, createdAt, manifest, and ledger',
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
const brief =
|
|
208
|
+
input.mode === 'verify'
|
|
209
|
+
? verificationBrief(input)
|
|
210
|
+
: input.mode === 'adversary'
|
|
211
|
+
? adversarialBrief(input)
|
|
212
|
+
: coverageBrief(input);
|
|
213
|
+
const result = validateReviewBrief(brief);
|
|
214
|
+
if (!result.valid) {
|
|
215
|
+
throw new Error(
|
|
216
|
+
`Review brief violates selective blindness: ${result.errors[0].message}`,
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
return brief;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function inspectForbidden(value, path, errors) {
|
|
223
|
+
if (typeof value === 'string') {
|
|
224
|
+
if (/raw[\\/]dossiers/i.test(value)) {
|
|
225
|
+
errors.push(
|
|
226
|
+
issue('BLINDNESS_VIOLATION', 'Raw dossier paths are forbidden', path),
|
|
227
|
+
);
|
|
228
|
+
}
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
if (Array.isArray(value)) {
|
|
232
|
+
value.forEach((item, index) =>
|
|
233
|
+
inspectForbidden(item, `${path}[${index}]`, errors),
|
|
234
|
+
);
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
if (!isObject(value)) return;
|
|
238
|
+
for (const [key, item] of Object.entries(value)) {
|
|
239
|
+
if (forbiddenKeys.has(key)) {
|
|
240
|
+
errors.push(
|
|
241
|
+
issue(
|
|
242
|
+
'BLINDNESS_VIOLATION',
|
|
243
|
+
`Forbidden field ${key}`,
|
|
244
|
+
`${path}.${key}`,
|
|
245
|
+
),
|
|
246
|
+
);
|
|
247
|
+
}
|
|
248
|
+
inspectForbidden(item, `${path}.${key}`, errors);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
export function validateReviewBrief(brief) {
|
|
253
|
+
const errors = [];
|
|
254
|
+
if (
|
|
255
|
+
!isObject(brief) ||
|
|
256
|
+
brief.kind !== 'recon.review-brief' ||
|
|
257
|
+
brief.schemaVersion !== 1
|
|
258
|
+
) {
|
|
259
|
+
errors.push(
|
|
260
|
+
issue('INVALID_REVIEW_BRIEF', 'Expected recon.review-brief version 1'),
|
|
261
|
+
);
|
|
262
|
+
return { valid: false, errors };
|
|
263
|
+
}
|
|
264
|
+
const allowed = allowedKeys[brief.mode];
|
|
265
|
+
if (!allowed) {
|
|
266
|
+
errors.push(
|
|
267
|
+
issue(
|
|
268
|
+
'INVALID_REVIEW_MODE',
|
|
269
|
+
'Review mode must be verify, adversary, or coverage',
|
|
270
|
+
'$.mode',
|
|
271
|
+
),
|
|
272
|
+
);
|
|
273
|
+
return { valid: false, errors };
|
|
274
|
+
}
|
|
275
|
+
for (const key of Object.keys(brief)) {
|
|
276
|
+
if (!allowed.has(key)) {
|
|
277
|
+
errors.push(
|
|
278
|
+
issue(
|
|
279
|
+
'BLINDNESS_VIOLATION',
|
|
280
|
+
`Unexpected top-level field ${key}`,
|
|
281
|
+
`$.${key}`,
|
|
282
|
+
),
|
|
283
|
+
);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
inspectForbidden(brief, '$', errors);
|
|
287
|
+
if (brief.mode === 'verify') {
|
|
288
|
+
if (!Array.isArray(brief.claims) || !Array.isArray(brief.sources)) {
|
|
289
|
+
errors.push(
|
|
290
|
+
issue(
|
|
291
|
+
'INVALID_REVIEW_BRIEF',
|
|
292
|
+
'Verification brief needs claims and sources',
|
|
293
|
+
),
|
|
294
|
+
);
|
|
295
|
+
}
|
|
296
|
+
for (const [index, claim] of (brief.claims ?? []).entries()) {
|
|
297
|
+
const keys = Object.keys(claim).sort();
|
|
298
|
+
if (keys.join(',') !== 'evidence,id,statement') {
|
|
299
|
+
errors.push(
|
|
300
|
+
issue(
|
|
301
|
+
'BLINDNESS_VIOLATION',
|
|
302
|
+
'Verification claims have an invalid projection',
|
|
303
|
+
`$.claims[${index}]`,
|
|
304
|
+
),
|
|
305
|
+
);
|
|
306
|
+
}
|
|
307
|
+
for (const [evidenceIndex, evidence] of (
|
|
308
|
+
claim.evidence ?? []
|
|
309
|
+
).entries()) {
|
|
310
|
+
const evidenceKeys = Object.keys(evidence).sort();
|
|
311
|
+
if (evidenceKeys.join(',') !== 'displayExcerpt,id,locator,sourceId') {
|
|
312
|
+
errors.push(
|
|
313
|
+
issue(
|
|
314
|
+
'BLINDNESS_VIOLATION',
|
|
315
|
+
'Verification evidence has an invalid projection',
|
|
316
|
+
`$.claims[${index}].evidence[${evidenceIndex}]`,
|
|
317
|
+
),
|
|
318
|
+
);
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
} else if (brief.mode === 'adversary') {
|
|
323
|
+
if (
|
|
324
|
+
!Array.isArray(brief.questions) ||
|
|
325
|
+
!Array.isArray(brief.provisionalStatements) ||
|
|
326
|
+
!isObject(brief.scope)
|
|
327
|
+
) {
|
|
328
|
+
errors.push(
|
|
329
|
+
issue(
|
|
330
|
+
'INVALID_REVIEW_BRIEF',
|
|
331
|
+
'Adversarial brief needs scope, questions, and provisional statements',
|
|
332
|
+
),
|
|
333
|
+
);
|
|
334
|
+
}
|
|
335
|
+
for (const [index, statement] of (
|
|
336
|
+
brief.provisionalStatements ?? []
|
|
337
|
+
).entries()) {
|
|
338
|
+
if (
|
|
339
|
+
!isObject(statement) ||
|
|
340
|
+
Object.keys(statement).sort().join(',') !== 'id,statement'
|
|
341
|
+
) {
|
|
342
|
+
errors.push(
|
|
343
|
+
issue(
|
|
344
|
+
'BLINDNESS_VIOLATION',
|
|
345
|
+
'Adversarial statements have an invalid projection',
|
|
346
|
+
`$.provisionalStatements[${index}]`,
|
|
347
|
+
),
|
|
348
|
+
);
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
} else if (brief.mode === 'coverage') {
|
|
352
|
+
if (
|
|
353
|
+
!Array.isArray(brief.questions) ||
|
|
354
|
+
!Array.isArray(brief.claims) ||
|
|
355
|
+
!isObject(brief.scope)
|
|
356
|
+
) {
|
|
357
|
+
errors.push(
|
|
358
|
+
issue(
|
|
359
|
+
'INVALID_REVIEW_BRIEF',
|
|
360
|
+
'Coverage brief needs scope, questions, and claims',
|
|
361
|
+
),
|
|
362
|
+
);
|
|
363
|
+
}
|
|
364
|
+
for (const [index, claim] of (brief.claims ?? []).entries()) {
|
|
365
|
+
if (Object.keys(claim).sort().join(',') !== 'id,statement') {
|
|
366
|
+
errors.push(
|
|
367
|
+
issue(
|
|
368
|
+
'BLINDNESS_VIOLATION',
|
|
369
|
+
'Coverage claims have an invalid projection',
|
|
370
|
+
`$.claims[${index}]`,
|
|
371
|
+
),
|
|
372
|
+
);
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
errors.push(...validateArtifactShape(brief).errors);
|
|
377
|
+
return { valid: errors.length === 0, errors };
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
export async function writeReviewBrief({ packetRoot, outputPath, brief }) {
|
|
381
|
+
const root = resolve(packetRoot);
|
|
382
|
+
const target = resolve(outputPath);
|
|
383
|
+
await assertSafeOutputPath(root, target);
|
|
384
|
+
const result = validateReviewBrief(brief);
|
|
385
|
+
if (!result.valid) {
|
|
386
|
+
throw new Error(
|
|
387
|
+
`Review brief violates selective blindness: ${result.errors[0].message}`,
|
|
388
|
+
);
|
|
389
|
+
}
|
|
390
|
+
await mkdir(dirname(target), { recursive: true });
|
|
391
|
+
try {
|
|
392
|
+
await writeFile(target, `${canonicalJson(brief)}\n`, {
|
|
393
|
+
encoding: 'utf8',
|
|
394
|
+
flag: 'wx',
|
|
395
|
+
});
|
|
396
|
+
} catch (error) {
|
|
397
|
+
if (error && typeof error === 'object' && error.code === 'EEXIST') {
|
|
398
|
+
throw new Error(`Review brief already exists: ${target}`, {
|
|
399
|
+
cause: error,
|
|
400
|
+
});
|
|
401
|
+
}
|
|
402
|
+
throw error;
|
|
403
|
+
}
|
|
404
|
+
return { path: relative(root, target), digest: await hashFile(target) };
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
async function main(argv) {
|
|
408
|
+
const options = Object.fromEntries(
|
|
409
|
+
argv.reduce((pairs, value, index) => {
|
|
410
|
+
if (value.startsWith('--')) pairs.push([value.slice(2), argv[index + 1]]);
|
|
411
|
+
return pairs;
|
|
412
|
+
}, []),
|
|
413
|
+
);
|
|
414
|
+
for (const key of [
|
|
415
|
+
'mode',
|
|
416
|
+
'id',
|
|
417
|
+
'created-at',
|
|
418
|
+
'manifest',
|
|
419
|
+
'ledger',
|
|
420
|
+
'output',
|
|
421
|
+
'packet-root',
|
|
422
|
+
]) {
|
|
423
|
+
if (!options[key]) throw new Error(`Missing --${key}`);
|
|
424
|
+
}
|
|
425
|
+
const [manifest, ledger] = await Promise.all([
|
|
426
|
+
readFile(options.manifest, 'utf8').then(JSON.parse),
|
|
427
|
+
readFile(options.ledger, 'utf8').then(JSON.parse),
|
|
428
|
+
]);
|
|
429
|
+
const brief = createReviewBrief({
|
|
430
|
+
mode: options.mode,
|
|
431
|
+
id: options.id,
|
|
432
|
+
createdAt: options['created-at'],
|
|
433
|
+
manifest,
|
|
434
|
+
ledger,
|
|
435
|
+
});
|
|
436
|
+
const reference = await writeReviewBrief({
|
|
437
|
+
packetRoot: options['packet-root'],
|
|
438
|
+
outputPath: options.output,
|
|
439
|
+
brief,
|
|
440
|
+
});
|
|
441
|
+
process.stdout.write(`${JSON.stringify(reference, null, 2)}\n`);
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
if (
|
|
445
|
+
process.argv[1] &&
|
|
446
|
+
import.meta.url === pathToFileURL(process.argv[1]).href
|
|
447
|
+
) {
|
|
448
|
+
main(process.argv.slice(2)).catch((error) => {
|
|
449
|
+
process.stderr.write(`${error instanceof Error ? error.message : error}\n`);
|
|
450
|
+
process.exitCode = 1;
|
|
451
|
+
});
|
|
452
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { readFile } from 'node:fs/promises';
|
|
3
|
+
|
|
4
|
+
function canonicalize(value, seen) {
|
|
5
|
+
if (
|
|
6
|
+
value === null ||
|
|
7
|
+
typeof value === 'boolean' ||
|
|
8
|
+
typeof value === 'string'
|
|
9
|
+
) {
|
|
10
|
+
return JSON.stringify(value);
|
|
11
|
+
}
|
|
12
|
+
if (typeof value === 'number') {
|
|
13
|
+
if (!Number.isFinite(value)) {
|
|
14
|
+
throw new TypeError('Canonical JSON cannot encode non-finite numbers');
|
|
15
|
+
}
|
|
16
|
+
return JSON.stringify(value);
|
|
17
|
+
}
|
|
18
|
+
if (Array.isArray(value)) {
|
|
19
|
+
return `[${value.map((item) => canonicalize(item, seen)).join(',')}]`;
|
|
20
|
+
}
|
|
21
|
+
if (typeof value !== 'object' || value === undefined) {
|
|
22
|
+
throw new TypeError(`Canonical JSON cannot encode ${typeof value}`);
|
|
23
|
+
}
|
|
24
|
+
if (seen.has(value)) {
|
|
25
|
+
throw new TypeError('Canonical JSON cannot encode cyclic values');
|
|
26
|
+
}
|
|
27
|
+
seen.add(value);
|
|
28
|
+
const entries = Object.keys(value)
|
|
29
|
+
.sort()
|
|
30
|
+
.map((key) => {
|
|
31
|
+
const item = value[key];
|
|
32
|
+
if (item === undefined) {
|
|
33
|
+
throw new TypeError(`Canonical JSON cannot encode undefined at ${key}`);
|
|
34
|
+
}
|
|
35
|
+
return `${JSON.stringify(key)}:${canonicalize(item, seen)}`;
|
|
36
|
+
});
|
|
37
|
+
seen.delete(value);
|
|
38
|
+
return `{${entries.join(',')}}`;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function canonicalJson(value) {
|
|
42
|
+
return canonicalize(value, new Set());
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function sha256(value) {
|
|
46
|
+
return `sha256:${createHash('sha256').update(value).digest('hex')}`;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function hashCanonicalJson(value) {
|
|
50
|
+
return sha256(Buffer.from(canonicalJson(value), 'utf8'));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export async function hashFile(path) {
|
|
54
|
+
return sha256(await readFile(path));
|
|
55
|
+
}
|