@agent-delivery-harness/cli 0.1.0 → 0.5.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/LICENSE +104 -201
- package/NOTICE +1 -1
- package/package.json +10 -5
- package/src/boundary.ts +302 -9
- package/src/command-activity.ts +75 -0
- package/src/commands/admit.ts +106 -0
- package/src/commands/check.ts +13 -2
- package/src/commands/emit-review-evidence.ts +38 -0
- package/src/commands/emit.ts +419 -0
- package/src/commands/gate.ts +127 -18
- package/src/commands/maintain.ts +203 -0
- package/src/commands/managed.ts +478 -0
- package/src/commands/prepare.ts +123 -15
- package/src/commands/record.ts +100 -8
- package/src/commands/resume.ts +53 -0
- package/src/commands/review-context.ts +19 -0
- package/src/commands/runs.ts +640 -0
- package/src/commands/save-context.ts +51 -0
- package/src/commands/submit-evidence.ts +46 -7
- package/src/commands/verify.ts +173 -5
- package/src/declared-checks.ts +50 -0
- package/src/index.ts +40 -4
- package/src/main.ts +38 -42
- package/src/ordinary-context.ts +99 -0
- package/src/provider-rails.ts +24 -0
- package/src/record-retention.ts +325 -0
- package/src/review-evidence.ts +284 -0
- package/src/run-archive-commands.ts +164 -0
- package/src/run-archive.ts +127 -0
- package/src/run-artifact-commands.ts +163 -0
- package/src/run-attachments.ts +112 -0
- package/src/run-export.ts +164 -0
- package/src/run-live.ts +102 -0
- package/src/run-projection.ts +396 -0
- package/src/run-server.ts +905 -0
- package/src/run-surface.ts +334 -0
- package/src/run-view-command.ts +77 -0
- package/src/run-view-html.ts +138 -0
- package/src/run-view-record.ts +119 -0
- package/src/run-view.ts +343 -0
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { buildRunEvent } from "../run-surface.ts";
|
|
2
|
+
import { installedRelease, ordinaryEventWriter, policyDigest, recoveryBlocker, recoveryRun, rejectionDetails } from "../ordinary-context.ts";
|
|
3
|
+
import type { CommandDescriptor } from "../boundary.ts";
|
|
4
|
+
|
|
5
|
+
const USAGE = "Usage: delivery-harness save-context --json '{\"contract\":{\"objective\":\"...\",\"acceptanceCriteria\":[\"...\"],\"finishLine\":\"merge-ready\"},\"stage\":\"work\"}'";
|
|
6
|
+
|
|
7
|
+
export const saveContextCommand: CommandDescriptor = {
|
|
8
|
+
name: "save-context", sourceId: "delivery-harness.cli.save-context",
|
|
9
|
+
summary: "Save a bounded delivery contract and stage observation in the current run.",
|
|
10
|
+
usage: USAGE,
|
|
11
|
+
async run(context) {
|
|
12
|
+
if (context.args.length !== 2 || context.args[0] !== "--json") return { kind: "usage", message: USAGE };
|
|
13
|
+
let input: { contract?: unknown; stage?: unknown };
|
|
14
|
+
try {
|
|
15
|
+
const value: unknown = JSON.parse(context.args[1]!);
|
|
16
|
+
if (!value || typeof value !== "object" || Array.isArray(value) || Object.keys(value).some(key => key !== "contract" && key !== "stage")) throw new Error();
|
|
17
|
+
input = value;
|
|
18
|
+
} catch { return { kind: "usage", message: "Context must be a JSON object with only contract and stage." }; }
|
|
19
|
+
const run = await recoveryRun(context.rootDir);
|
|
20
|
+
if (!run.ok) return { kind: "blocked", blockers: [run.blocker] };
|
|
21
|
+
const wiring = await context.wire();
|
|
22
|
+
const capture = await wiring.captureCandidate();
|
|
23
|
+
if (!capture.ok) return { kind: "blocked", blockers: capture.blockers };
|
|
24
|
+
let release;
|
|
25
|
+
try { release = await installedRelease(context.rootDir); }
|
|
26
|
+
catch { return { kind: "blocked", blockers: [recoveryBlocker("resume_release_unreadable", "The installed workflow release identity cannot be read; restore a valid installation before saving context.")] }; }
|
|
27
|
+
const candidate = capture.candidate;
|
|
28
|
+
// An omitted member is absent, never present-and-undefined: the contract
|
|
29
|
+
// reports it as a missing member, and the v2 retry key stays derivable.
|
|
30
|
+
const payload = { spec: "ordinary-run-context/1",
|
|
31
|
+
...(input.contract === undefined ? {} : { contract: input.contract }),
|
|
32
|
+
...(input.stage === undefined ? {} : { stage: input.stage }),
|
|
33
|
+
candidateTreeSha: candidate.treeSha,
|
|
34
|
+
candidateBinding: { deliverableDigest: candidate.deliverable.digest, identity: candidate.deliverable.identity,
|
|
35
|
+
baseRef: candidate.base.ref, baseTipSha: candidate.base.tipSha, mergeBaseSha: candidate.base.mergeBaseSha, workspaceId: candidate.workspaceId },
|
|
36
|
+
policyDigest: policyDigest(context), release };
|
|
37
|
+
// The run's own writer version, never this command's: a v2 journal admits
|
|
38
|
+
// no v1 event, and a v2 event needs the retry key the writer derives.
|
|
39
|
+
const writer = ordinaryEventWriter(run.version, "context.saved", payload);
|
|
40
|
+
const appended = await run.surface.store.append(
|
|
41
|
+
run.runId,
|
|
42
|
+
buildRunEvent({ runId: run.runId, commonDir: run.surface.commonDir, kind: "context.saved", role: "executor", payload, ...writer }),
|
|
43
|
+
// An exact retry of one save is one observation, at its first instant.
|
|
44
|
+
{ reuseExistingTimestamp: true },
|
|
45
|
+
);
|
|
46
|
+
if (!appended.ok) return { kind: "blocked", blockers: [recoveryBlocker("resume_context_invalid",
|
|
47
|
+
"Context was refused by the bounded run-event contract; inspect contract, stage, and secret-free inputs.",
|
|
48
|
+
`run ${run.runId}: ${rejectionDetails(appended.rejections)}`)] };
|
|
49
|
+
return { kind: "ok", summary: `saved ordinary context for ${run.runId}; stage is an observation, not evidence` };
|
|
50
|
+
},
|
|
51
|
+
};
|
|
@@ -9,21 +9,60 @@
|
|
|
9
9
|
*/
|
|
10
10
|
import { submitManifest } from "@agent-delivery-harness/kernel";
|
|
11
11
|
import type { CommandContext, CommandDescriptor, CommandResult } from "../boundary.ts";
|
|
12
|
+
import { oneLine } from "../run-surface.ts";
|
|
12
13
|
|
|
13
|
-
function
|
|
14
|
-
const
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
14
|
+
function manifestPathsFrom(args: readonly string[]): string[] | undefined {
|
|
15
|
+
const paths: string[] = [];
|
|
16
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
17
|
+
const token = args[index]!;
|
|
18
|
+
if (token === "--manifest") {
|
|
19
|
+
const value = args[++index];
|
|
20
|
+
if (value === undefined || value === "") return undefined;
|
|
21
|
+
paths.push(value);
|
|
22
|
+
} else if (!token.startsWith("-")) paths.push(token);
|
|
23
|
+
}
|
|
24
|
+
return paths;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The one flag this command has, and everything else that looks like a flag.
|
|
29
|
+
* `--manifest`'s VALUE is skipped rather than scanned, so a path that happens
|
|
30
|
+
* to begin with a hyphen is still that flag's value and not an unknown flag —
|
|
31
|
+
* rejecting legitimate arguments generically is the failure mode this guard
|
|
32
|
+
* has to avoid while it closes the silently-ignored one.
|
|
33
|
+
*/
|
|
34
|
+
function unknownFlagIn(args: readonly string[]): string | undefined {
|
|
35
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
36
|
+
const token = args[index]!;
|
|
37
|
+
if (token === "--manifest") {
|
|
38
|
+
index += 1;
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
if (token.startsWith("-")) return token;
|
|
42
|
+
}
|
|
43
|
+
return undefined;
|
|
19
44
|
}
|
|
20
45
|
|
|
21
46
|
export const submitEvidenceCommand: CommandDescriptor = {
|
|
22
47
|
name: "submit-evidence",
|
|
23
48
|
sourceId: "delivery-harness.cli.submit-evidence",
|
|
24
49
|
summary: "Validate a provider manifest and publish its evidence records.",
|
|
50
|
+
usage: "Usage: delivery-harness submit-evidence --manifest <path>",
|
|
25
51
|
async run(context: CommandContext): Promise<CommandResult> {
|
|
26
|
-
|
|
52
|
+
// An unrecognized flag is refused before the manifest is looked for, so a
|
|
53
|
+
// mistyped flag can never be ignored into a positional read of the argv.
|
|
54
|
+
const unknown = unknownFlagIn(context.args);
|
|
55
|
+
if (unknown !== undefined) {
|
|
56
|
+
return { kind: "usage", message: `Unknown flag ${oneLine(unknown, 64)}.\n${submitEvidenceCommand.usage}` };
|
|
57
|
+
}
|
|
58
|
+
const paths = manifestPathsFrom(context.args);
|
|
59
|
+
if (paths === undefined) {
|
|
60
|
+
return { kind: "usage", message: "submit-evidence requires --manifest <path>." };
|
|
61
|
+
}
|
|
62
|
+
if (paths.length > 1) {
|
|
63
|
+
return { kind: "usage", message: `submit-evidence requires one manifest path; received ${paths.map(value => oneLine(value, 256)).join(", ")}.` };
|
|
64
|
+
}
|
|
65
|
+
const manifestPath = paths[0];
|
|
27
66
|
if (manifestPath === undefined || manifestPath === "") {
|
|
28
67
|
return { kind: "usage", message: "submit-evidence requires --manifest <path>." };
|
|
29
68
|
}
|
package/src/commands/verify.ts
CHANGED
|
@@ -7,18 +7,124 @@
|
|
|
7
7
|
* the pure `verifyDeliveryRecord` core. A missing record names the command that
|
|
8
8
|
* writes it; a failed check surfaces the named drift class. When the base-movement
|
|
9
9
|
* policy is `allow`, a passing check that relaxed base drift names the relaxation.
|
|
10
|
+
*
|
|
11
|
+
* THE RUN-JOURNAL ROW, AND WHY IT IS LOCAL ONLY. `verify` is the one caller
|
|
12
|
+
* that holds both halves of the question "was this candidate journaled": a
|
|
13
|
+
* record binding an exact tree sha, and a repository whose run store it can
|
|
14
|
+
* scan for a journal bound to the same one. So it resolves the row and reports
|
|
15
|
+
* it — and reporting is all it does by default. The row changes no exit code
|
|
16
|
+
* unless the operator asks for that with `--require-run-journal`, a LOCAL
|
|
17
|
+
* opt-in: it is not passed by the GitHub Action, not read by the gate, and not
|
|
18
|
+
* consulted by admission. A run journal is self-attested observability that
|
|
19
|
+
* anything the owner executes can append to, so a delivery that could be
|
|
20
|
+
* admitted or refused on one would be resting its gate on a file its own
|
|
21
|
+
* candidate scripts can write.
|
|
22
|
+
*
|
|
23
|
+
* `--mandated-lens <id>` is the second half of the same opt-in: supplied, the
|
|
24
|
+
* evaluator checks the journal's declared mandated pair against these ids
|
|
25
|
+
* rather than merely checking that it declared two non-empty ones. Repeatable,
|
|
26
|
+
* separate-argument form, and bounded to the run family's own id charset before
|
|
27
|
+
* it reaches the kernel.
|
|
10
28
|
*/
|
|
11
29
|
import { readFile } from "node:fs/promises";
|
|
12
30
|
import path from "node:path";
|
|
13
|
-
import {
|
|
31
|
+
import {
|
|
32
|
+
MAX_RUN_PROVIDER_ID,
|
|
33
|
+
RUN_PROVIDER_ID,
|
|
34
|
+
deliveryRecordPathFor,
|
|
35
|
+
needsCommittedSymlinkTarget,
|
|
36
|
+
parseCandidateTreeListing,
|
|
37
|
+
parseDeliveryRecord,
|
|
38
|
+
runGitCommand,
|
|
39
|
+
verifyDeliveryRecord,
|
|
40
|
+
capturePortableVerificationInputs,
|
|
41
|
+
collectLiveProviderResults,
|
|
42
|
+
type CandidateTreeEntry,
|
|
43
|
+
type RunJournalRow,
|
|
44
|
+
} from "@agent-delivery-harness/kernel";
|
|
14
45
|
import { commandBlocker } from "../boundary.ts";
|
|
15
46
|
import type { CommandContext, CommandDescriptor, CommandResult } from "../boundary.ts";
|
|
47
|
+
import { oneLine, resolveRunJournalRow, runJournalRows } from "../run-surface.ts";
|
|
48
|
+
|
|
49
|
+
const USAGE = "Usage: delivery-harness verify [--require-run-journal] [--mandated-lens <id>]...";
|
|
50
|
+
|
|
51
|
+
interface ParsedArgs {
|
|
52
|
+
readonly requireRunJournal: boolean;
|
|
53
|
+
readonly mandatedLensIds: readonly string[];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
type ArgParse = { readonly ok: true; readonly args: ParsedArgs } | { readonly ok: false; readonly message: string };
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* The separate-argument form every other command's flags take: `--flag value`,
|
|
60
|
+
* never `--flag=value`. The joined form is not silently split, because a
|
|
61
|
+
* `--mandated-lens=x` that quietly worked here and nowhere else would be a
|
|
62
|
+
* second grammar for the same CLI. It falls through to the unknown-flag arm,
|
|
63
|
+
* which is a usage error naming the token.
|
|
64
|
+
*/
|
|
65
|
+
function parseArgs(args: readonly string[]): ArgParse {
|
|
66
|
+
let requireRunJournal = false;
|
|
67
|
+
const mandatedLensIds: string[] = [];
|
|
68
|
+
|
|
69
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
70
|
+
const token = args[index]!;
|
|
71
|
+
if (token === "--require-run-journal") {
|
|
72
|
+
requireRunJournal = true;
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
if (token === "--mandated-lens") {
|
|
76
|
+
const value = args[index + 1];
|
|
77
|
+
if (value === undefined) return { ok: false, message: `${token} needs a value.\n${USAGE}` };
|
|
78
|
+
// Bounded before the kernel sees it: an id is compared against journal
|
|
79
|
+
// content, and an unbounded one would be echoed into the row it produces.
|
|
80
|
+
if (value.length > MAX_RUN_PROVIDER_ID || !RUN_PROVIDER_ID.test(value)) {
|
|
81
|
+
return { ok: false, message: `${token} takes a bounded lens id, not ${oneLine(value, 64)}.\n${USAGE}` };
|
|
82
|
+
}
|
|
83
|
+
mandatedLensIds.push(value);
|
|
84
|
+
index += 1;
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
if (token.startsWith("-")) return { ok: false, message: `Unknown flag ${oneLine(token, 64)}.\n${USAGE}` };
|
|
88
|
+
return { ok: false, message: `verify takes no positional arguments, and ${oneLine(token, 64)} is one.\n${USAGE}` };
|
|
89
|
+
}
|
|
90
|
+
return { ok: true, args: { requireRunJournal, mandatedLensIds } };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* The opt-in's refusal. It names the status, the missing entries, and the
|
|
95
|
+
* violated constraints, all of them product-defined names from the evaluator's
|
|
96
|
+
* two closed sets — never journal-derived text.
|
|
97
|
+
*/
|
|
98
|
+
function runJournalBlocker(row: RunJournalRow) {
|
|
99
|
+
const missing = row.missing.length === 0 ? "(none)" : row.missing.join(", ");
|
|
100
|
+
const violations = row.violations === undefined || row.violations.length === 0 ? "(none)" : row.violations.join(", ");
|
|
101
|
+
return commandBlocker({
|
|
102
|
+
code: "run_journal_incomplete",
|
|
103
|
+
sourceId: "delivery-harness.cli.verify",
|
|
104
|
+
summary: "The run journal for this candidate is not complete, and --require-run-journal was given.",
|
|
105
|
+
details: `status ${row.status}${row.runId === undefined ? "" : ` (run ${oneLine(row.runId, 128)})`}; missing: ${missing}; violations: ${violations}`,
|
|
106
|
+
remediations: [
|
|
107
|
+
{
|
|
108
|
+
id: "emit-the-missing-run-events",
|
|
109
|
+
kind: "manual_action",
|
|
110
|
+
summary: "Emit the run events this delivery did not journal, or drop --require-run-journal: the row is observability, not evidence.",
|
|
111
|
+
},
|
|
112
|
+
],
|
|
113
|
+
});
|
|
114
|
+
}
|
|
16
115
|
|
|
17
116
|
export const verifyCommand: CommandDescriptor = {
|
|
18
117
|
name: "verify",
|
|
19
118
|
sourceId: "delivery-harness.cli.verify",
|
|
20
119
|
summary: "Verify the tracked delivery record against the current candidate.",
|
|
120
|
+
usage: USAGE,
|
|
21
121
|
async run(context: CommandContext): Promise<CommandResult> {
|
|
122
|
+
const observedAt = `${new Date().toISOString().slice(0, 19)}Z`;
|
|
123
|
+
// Arguments first: a malformed invocation is a usage error and captures
|
|
124
|
+
// nothing, exactly as `emit` and `submit-evidence` order it.
|
|
125
|
+
const parsedArgs = parseArgs(context.args);
|
|
126
|
+
if (!parsedArgs.ok) return { kind: "usage", message: parsedArgs.message };
|
|
127
|
+
|
|
22
128
|
const wiring = await context.wire();
|
|
23
129
|
const capture = await wiring.captureCandidate();
|
|
24
130
|
if (!capture.ok) {
|
|
@@ -67,17 +173,79 @@ export const verifyCommand: CommandDescriptor = {
|
|
|
67
173
|
return { kind: "blocked", blockers: [...parsed.blockers] };
|
|
68
174
|
}
|
|
69
175
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
176
|
+
// The tracked tree's own entries, so this command rejects a candidate
|
|
177
|
+
// carrying a projection or discovery-configuration path exactly as the
|
|
178
|
+
// Action does. An unreadable listing supplies no entries rather than a
|
|
179
|
+
// false clean bill: the check simply does not run.
|
|
180
|
+
//
|
|
181
|
+
// Mode and object, not just the name: the one admitted exception under
|
|
182
|
+
// `.claude/skills/` turns on the entry being a symlink and on where its
|
|
183
|
+
// committed target resolves, and both facts live in the tree.
|
|
184
|
+
const listing = await runGitCommand(["git", "ls-tree", "-r", "-z", "--full-tree", "HEAD"], {
|
|
185
|
+
cwd: context.rootDir,
|
|
186
|
+
});
|
|
187
|
+
const candidateTreePaths: CandidateTreeEntry[] = [];
|
|
188
|
+
if (listing.exitCode === 0) {
|
|
189
|
+
for (const entry of parseCandidateTreeListing(listing.stdout)) {
|
|
190
|
+
if (!needsCommittedSymlinkTarget(entry)) {
|
|
191
|
+
candidateTreePaths.push(entry);
|
|
192
|
+
continue;
|
|
193
|
+
}
|
|
194
|
+
// The target read out of the committed blob, never off the filesystem:
|
|
195
|
+
// the working tree's link may differ from the one under review. A blob
|
|
196
|
+
// that will not read leaves the target absent, and an entry with no
|
|
197
|
+
// target cannot reach the exception.
|
|
198
|
+
const blob = await runGitCommand(["git", "cat-file", "blob", entry.objectSha], { cwd: context.rootDir });
|
|
199
|
+
candidateTreePaths.push(blob.exitCode === 0 ? { ...entry, symlinkTarget: blob.stdout } : entry);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const inputs = await capturePortableVerificationInputs(context.rootDir, context.config, capture.candidate, parsed.record);
|
|
204
|
+
const live = await collectLiveProviderResults({rootDir:context.rootDir,config:context.config,candidate:capture.candidate,
|
|
205
|
+
projection:inputs.projection,evidenceContext:inputs.evidenceContext,env:context.env,...(context.signal===undefined?{}:{signal:context.signal})});
|
|
206
|
+
// Verify the portable record before letting any retained projection widen
|
|
207
|
+
// the observational tree coordinates. Corrupt or invented projection bytes
|
|
208
|
+
// therefore fail as record evidence and never influence journal matching.
|
|
209
|
+
const verified = verifyDeliveryRecord(context.config, parsed.record, identity, base,
|
|
210
|
+
{ candidateTreePaths, ...inputs, observedAt, liveResults:live.liveResults, executionContext: context.classifyContext() });
|
|
211
|
+
if (!verified.ok) {
|
|
212
|
+
return { kind: "blocked", blockers: [...live.blockers, ...verified.blockers] };
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// The record tree remains the primary coordinate. A product-validated
|
|
216
|
+
// review-neutral projection may additionally name the earlier raw tree the
|
|
217
|
+
// reviewers actually read; the journal stays observational either way.
|
|
218
|
+
const runJournal = await resolveRunJournalRow({
|
|
219
|
+
cwd: context.rootDir,
|
|
220
|
+
treeSha: parsed.record.candidateBinding.treeSha,
|
|
221
|
+
reviewedCandidateTreeShas: verified.reviewedCandidateTreeShas,
|
|
222
|
+
...(parsedArgs.args.mandatedLensIds.length === 0 ? {} : { mandatedLensIds: parsedArgs.args.mandatedLensIds }),
|
|
223
|
+
});
|
|
224
|
+
const check = verifyDeliveryRecord(context.config, parsed.record, identity, base,
|
|
225
|
+
{ candidateTreePaths, runJournal, ...inputs, observedAt, liveResults:live.liveResults, executionContext: context.classifyContext() });
|
|
226
|
+
|
|
227
|
+
// The opt-in is judged AFTER the record's own verification, so a delivery
|
|
228
|
+
// whose record is bad is never told its journal is the problem.
|
|
229
|
+
if (parsedArgs.args.requireRunJournal && runJournal.status !== "complete") {
|
|
230
|
+
return { kind: "blocked", blockers: [runJournalBlocker(runJournal)] };
|
|
73
231
|
}
|
|
74
232
|
|
|
75
233
|
const relaxation = check.baseMovementRelaxed
|
|
76
234
|
? ` (base movement relaxed by policy: ${check.relaxedDriftClasses.join(", ")})`
|
|
77
235
|
: "";
|
|
236
|
+
const exemption = check.hostedChecks.exemption;
|
|
237
|
+
const hostedCheckRow = exemption === undefined ? [] : [
|
|
238
|
+
`hosted checks: exempted for ${oneLine(exemption.scope.repositoryId, 128)} at ${oneLine(exemption.scope.baseRef, 256)}; granted by ${oneLine(exemption.grantedBy, 256)}; until ${exemption.until}; reason: ${oneLine(exemption.reason, 512)}`,
|
|
239
|
+
];
|
|
78
240
|
return {
|
|
79
241
|
kind: "ok",
|
|
80
|
-
summary:
|
|
242
|
+
summary: [
|
|
243
|
+
`verified ${relativePath}${relaxation}; attestation: ${check.attestationLabel}`,
|
|
244
|
+
`recorded base: ${oneLine(parsed.record.candidateBinding.baseRef, 256)} at ${parsed.record.candidateBinding.baseTipSha}`,
|
|
245
|
+
`observed base: ${oneLine(base.ref, 256)} at ${base.tipSha}`,
|
|
246
|
+
...hostedCheckRow,
|
|
247
|
+
...runJournalRows(runJournal),
|
|
248
|
+
].join("\n"),
|
|
81
249
|
};
|
|
82
250
|
},
|
|
83
251
|
};
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { captureCheckBindings, captureCheckOutputSnapshots, digestCanonical, classifyCandidateDrift, computeCheckWiringFingerprint, createBlocker, createExecPort, sha256Hex, submitManifest, type Blocker, type CandidateBinding, type ProviderRegistration } from "@agent-delivery-harness/kernel";
|
|
4
|
+
import type { CommandContext } from "./boundary.ts";
|
|
5
|
+
|
|
6
|
+
/** Runs one declared argv check and submits its product-constructed terminal evidence. */
|
|
7
|
+
export async function runDeclaredCheck(context: CommandContext, provider: ProviderRegistration, obligationIds: readonly string[], before: CandidateBinding): Promise<readonly Blocker[]> {
|
|
8
|
+
const check = provider.check!;
|
|
9
|
+
const wiring = await context.wire();
|
|
10
|
+
const fail = (code: string, summary: string, details?: string): readonly Blocker[] => [createBlocker({ code, source: { kind: "command", id: "delivery-harness.cli.gate" }, summary,
|
|
11
|
+
...(details === undefined ? {} : { details }), remediations: [{ id: "repair-declared-check", kind: "manual_action", summary: "Fix the declared check or its inputs, prepare the stable candidate and run gate again." }] })];
|
|
12
|
+
const start = await wiring.captureCandidate();
|
|
13
|
+
if (!start.ok) return start.blockers;
|
|
14
|
+
if (classifyCandidateDrift(before, start.candidate).length) return fail("check_candidate_changed", "The candidate changed after review admission and before the check started.");
|
|
15
|
+
const fingerprint = await computeCheckWiringFingerprint(context.rootDir, context.config, wiring.storageOptions);
|
|
16
|
+
context.write(`checking ${provider.id}`);
|
|
17
|
+
const result = await createExecPort().run({ command: check.command[0], args: check.command.slice(1), cwd: context.rootDir,
|
|
18
|
+
env: Object.fromEntries(Object.entries(context.env).filter((entry): entry is [string, string] => entry[1] !== undefined)),
|
|
19
|
+
timeoutMs: check.timeoutMs, maxBuffer: 1024 * 1024, ...(context.signal === undefined ? {} : { signal: context.signal }) });
|
|
20
|
+
if (result.code !== 0 || context.signal?.aborted) return fail("check_command_failed", `Declared check ${provider.id} did not complete successfully.`, `exit ${result.code}${result.errorCode === undefined ? "" : ` (${result.errorCode})`}\n${`${result.stdout}\n${result.stderr}`.slice(-4000)}`);
|
|
21
|
+
const captured = await wiring.captureCandidate();
|
|
22
|
+
if (!captured.ok) return captured.blockers;
|
|
23
|
+
if (classifyCandidateDrift(before, captured.candidate).length || start.candidate.headSha !== captured.candidate.headSha || fingerprint !== await computeCheckWiringFingerprint(context.rootDir, context.config, wiring.storageOptions)) return fail("check_candidate_changed", "The candidate, base or wiring changed while the check ran.");
|
|
24
|
+
const binding = (await captureCheckBindings(context.rootDir, context.config, captured.candidate, wiring.storageOptions))[provider.id];
|
|
25
|
+
if (binding === undefined) return fail("check_output_missing", `Declared check ${provider.id} has a missing, unreadable, oversized or escaped output.`);
|
|
26
|
+
const runId = randomUUID(), finalPassId = "pass-1";
|
|
27
|
+
const allocation = await context.artifacts.allocateRunRoot({ providerId: provider.id, runId });
|
|
28
|
+
if (!allocation.ok) return fail("check_artifact_unavailable", "Cannot allocate the declared check evidence root.");
|
|
29
|
+
const snapshots = await captureCheckOutputSnapshots(context.rootDir, check.outputs ?? []);
|
|
30
|
+
if (snapshots === undefined || digestCanonical(snapshots.map(({ path, sha256 }) => ({ path, sha256 }))) !== binding.outputsDigest) return fail("check_output_missing", "Declared outputs changed before evidence retention.");
|
|
31
|
+
const outputArtifacts: { path: string; sha256: string; role: string }[] = [];
|
|
32
|
+
for (const [index, output] of snapshots.entries()) {
|
|
33
|
+
const contents = JSON.stringify({ path: output.path, base64: output.base64 });
|
|
34
|
+
const artifactPath = `check-output-${index}.json`;
|
|
35
|
+
await context.artifacts.writeTextFile(path.join(allocation.runRoot.path, artifactPath), contents);
|
|
36
|
+
outputArtifacts.push({ path: artifactPath, sha256: sha256Hex(contents), role: "check-output" });
|
|
37
|
+
}
|
|
38
|
+
const payload = { verdict: "green", exitCode: 0, binding };
|
|
39
|
+
const terminal = JSON.stringify({ providerId: provider.id, runId, finalPassId, ...payload });
|
|
40
|
+
await context.artifacts.writeTextFile(path.join(allocation.runRoot.path, "check-result.json"), terminal);
|
|
41
|
+
const candidate = captured.candidate;
|
|
42
|
+
const manifest = { spec: "delivery-evidence/1", provider: { id: provider.id, runId, finalPassId },
|
|
43
|
+
candidate: { vcs: "git", treeSha: candidate.treeSha, headSha: candidate.headSha, deliverable: candidate.deliverable, base: candidate.base, workspaceId: candidate.workspaceId },
|
|
44
|
+
runHistory: [{ preparedTreeSha: candidate.treeSha, evaluatedInPassId: finalPassId }], artifacts: [{ path: "check-result.json", sha256: sha256Hex(terminal), role: "check-result" }, ...outputArtifacts],
|
|
45
|
+
attestation: { level: "self", signatures: [] }, recordedAt: new Date().toISOString(), claims: obligationIds.map(obligation => ({ obligation, payloadSpec: "checks.passed/1", payload })) };
|
|
46
|
+
const manifestPath = path.join(allocation.runRoot.path, "manifest.json");
|
|
47
|
+
await context.artifacts.writeTextFile(manifestPath, JSON.stringify(manifest));
|
|
48
|
+
const outcome = await submitManifest({ rootDir: context.rootDir, config: context.config, manifestPath }, { captureCandidate: wiring.captureCandidate, artifacts: context.artifacts, ...wiring.storageOptions });
|
|
49
|
+
return outcome.status === "accepted" ? [] : outcome.blockers;
|
|
50
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Delivery harness CLI: the
|
|
2
|
+
* Delivery harness CLI: the fifteen-command operator surface.
|
|
3
3
|
*
|
|
4
4
|
* THE COMMAND REGISTRY. `COMMANDS` is the single source of truth for which
|
|
5
5
|
* commands exist. Every command module under `commands/` must appear here, and
|
|
@@ -12,55 +12,91 @@
|
|
|
12
12
|
* in `boundary.ts`; each command is a thin, testable unit behind it.
|
|
13
13
|
*/
|
|
14
14
|
import { checkCommand } from "./commands/check.ts";
|
|
15
|
+
import { admitCommand } from "./commands/admit.ts";
|
|
16
|
+
import { emitCommand } from "./commands/emit.ts";
|
|
15
17
|
import { gateCommand } from "./commands/gate.ts";
|
|
18
|
+
import { maintainCommand } from "./commands/maintain.ts";
|
|
19
|
+
import { managedCommand } from "./commands/managed.ts";
|
|
16
20
|
import { prepareCommand } from "./commands/prepare.ts";
|
|
17
21
|
import { recordCommand } from "./commands/record.ts";
|
|
18
22
|
import { reviewContextCommand } from "./commands/review-context.ts";
|
|
23
|
+
import { emitReviewEvidenceCommand } from "./commands/emit-review-evidence.ts";
|
|
24
|
+
import { runsCommand } from "./commands/runs.ts";
|
|
19
25
|
import { submitEvidenceCommand } from "./commands/submit-evidence.ts";
|
|
20
26
|
import { verifyCommand } from "./commands/verify.ts";
|
|
21
|
-
import {
|
|
27
|
+
import { saveContextCommand } from "./commands/save-context.ts";
|
|
28
|
+
import { resumeCommand } from "./commands/resume.ts";
|
|
29
|
+
import { runCliBoundary, type AnyCommandDescriptor, type CliRuntime } from "./boundary.ts";
|
|
22
30
|
|
|
23
31
|
export const PACKAGE_NAME = "@agent-delivery-harness/cli";
|
|
32
|
+
export { buildRunExport, parseRunExport, type DeliveryRunExport, type RunExportParseResult } from "./run-export.ts";
|
|
24
33
|
|
|
25
34
|
/**
|
|
26
35
|
* The command registry. The order here is the order `--help` lists them, and it
|
|
27
36
|
* follows the loop an operator walks: prepare, review, submit, gate, record,
|
|
28
|
-
* verify — with `check`
|
|
37
|
+
* verify — with `check` as the standalone preflight and `managed` as the
|
|
38
|
+
* host-facing managed-delivery checkpoint surface and `maintain` as the
|
|
39
|
+
* installation-scoped maintenance lane.
|
|
40
|
+
*
|
|
41
|
+
* `emit` and `runs` come last because they are a different class: config-free
|
|
42
|
+
* commands, dispatched before `harness.config.ts` is loaded, that read and
|
|
43
|
+
* write the run store rather than anything a delivery decision depends on.
|
|
29
44
|
*/
|
|
30
|
-
export const COMMANDS: readonly
|
|
45
|
+
export const COMMANDS: readonly AnyCommandDescriptor[] = [
|
|
46
|
+
admitCommand,
|
|
31
47
|
prepareCommand,
|
|
32
48
|
reviewContextCommand,
|
|
49
|
+
emitReviewEvidenceCommand,
|
|
33
50
|
submitEvidenceCommand,
|
|
34
51
|
gateCommand,
|
|
35
52
|
recordCommand,
|
|
36
53
|
verifyCommand,
|
|
37
54
|
checkCommand,
|
|
55
|
+
managedCommand,
|
|
56
|
+
maintainCommand,
|
|
57
|
+
emitCommand,
|
|
58
|
+
runsCommand,
|
|
59
|
+
saveContextCommand,
|
|
60
|
+
resumeCommand,
|
|
38
61
|
];
|
|
39
62
|
|
|
40
63
|
export {
|
|
64
|
+
COMPLETION_WRAPPED_COMMANDS,
|
|
41
65
|
EXIT_INTERRUPTED,
|
|
42
66
|
EXIT_OK,
|
|
43
67
|
EXIT_POLICY,
|
|
44
68
|
EXIT_USAGE,
|
|
45
69
|
CliInterruption,
|
|
70
|
+
isConfigFreeCommand,
|
|
46
71
|
runCliBoundary,
|
|
47
72
|
wireRepo,
|
|
48
73
|
importHarnessConfig,
|
|
49
74
|
commandBlocker,
|
|
75
|
+
type AnyCommandDescriptor,
|
|
50
76
|
type CliRuntime,
|
|
51
77
|
type CommandContext,
|
|
52
78
|
type CommandDescriptor,
|
|
53
79
|
type CommandResult,
|
|
80
|
+
type ConfigFreeCommandContext,
|
|
81
|
+
type ConfigFreeCommandDescriptor,
|
|
54
82
|
type RepoWiring,
|
|
55
83
|
} from "./boundary.ts";
|
|
56
84
|
|
|
57
85
|
export { prepareCommand } from "./commands/prepare.ts";
|
|
86
|
+
export { admitCommand } from "./commands/admit.ts";
|
|
58
87
|
export { reviewContextCommand } from "./commands/review-context.ts";
|
|
88
|
+
export { emitReviewEvidenceCommand } from "./commands/emit-review-evidence.ts";
|
|
59
89
|
export { submitEvidenceCommand } from "./commands/submit-evidence.ts";
|
|
60
90
|
export { gateCommand } from "./commands/gate.ts";
|
|
61
91
|
export { recordCommand } from "./commands/record.ts";
|
|
62
92
|
export { verifyCommand } from "./commands/verify.ts";
|
|
63
93
|
export { checkCommand } from "./commands/check.ts";
|
|
94
|
+
export { managedCommand } from "./commands/managed.ts";
|
|
95
|
+
export { maintainCommand } from "./commands/maintain.ts";
|
|
96
|
+
export { emitCommand } from "./commands/emit.ts";
|
|
97
|
+
export { runsCommand } from "./commands/runs.ts";
|
|
98
|
+
export { saveContextCommand } from "./commands/save-context.ts";
|
|
99
|
+
export { resumeCommand } from "./commands/resume.ts";
|
|
64
100
|
|
|
65
101
|
/** Runs the CLI against a runtime and returns the process exit code. */
|
|
66
102
|
export function runCli(argv: readonly string[], runtime: CliRuntime): Promise<number> {
|
package/src/main.ts
CHANGED
|
@@ -8,11 +8,9 @@
|
|
|
8
8
|
* prints every obligation one "yes" would cover, reads a single line, and turns
|
|
9
9
|
* a Ctrl-C into the typed {@link CliInterruption} the boundary maps to exit 130.
|
|
10
10
|
*/
|
|
11
|
-
import { realpathSync } from "node:fs";
|
|
12
11
|
import { createInterface } from "node:readline";
|
|
13
|
-
import { fileURLToPath } from "node:url";
|
|
14
12
|
import { CliInterruption, EXIT_POLICY, runCli, type CliRuntime } from "./index.ts";
|
|
15
|
-
import type
|
|
13
|
+
import { invokedDirectly, type WaiverPrompt } from "@agent-delivery-harness/kernel";
|
|
16
14
|
|
|
17
15
|
/**
|
|
18
16
|
* Reads a yes/no answer after naming every obligation the waiver would cover.
|
|
@@ -38,8 +36,8 @@ import type { WaiverPrompt } from "@agent-delivery-harness/kernel";
|
|
|
38
36
|
* second settlement is exactly how this class of bug hides.
|
|
39
37
|
*/
|
|
40
38
|
export function createWaiverPrompt(input: NodeJS.ReadableStream, output: NodeJS.WritableStream): WaiverPrompt {
|
|
41
|
-
return (
|
|
42
|
-
new Promise<
|
|
39
|
+
return (decision, obligationIds) =>
|
|
40
|
+
new Promise<Awaited<ReturnType<WaiverPrompt>>>((resolve, reject) => {
|
|
43
41
|
const rl = createInterface({ input, output });
|
|
44
42
|
let settled = false;
|
|
45
43
|
const settle = (action: () => void): void => {
|
|
@@ -60,10 +58,24 @@ export function createWaiverPrompt(input: NodeJS.ReadableStream, output: NodeJS.
|
|
|
60
58
|
});
|
|
61
59
|
|
|
62
60
|
output.write(`Waiving covers ${obligationIds.length} obligation(s): ${obligationIds.join(", ")}.\n`);
|
|
61
|
+
output.write(`Candidate: ${decision.candidate.treeSha}. Approval covers only these findings under the current policy; live obligations require new approval each invocation.\n`);
|
|
62
|
+
for (const resolution of decision.resolutions) {
|
|
63
|
+
if (resolution.kind === "blocked" && obligationIds.includes(resolution.obligationId)) {
|
|
64
|
+
for (const blocker of resolution.blockers) output.write(`${resolution.obligationId}: [${blocker.code}] ${blocker.summary}\n`);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
const finish = (value: Awaited<ReturnType<WaiverPrompt>>) => settle(() => {
|
|
68
|
+
rl.close();
|
|
69
|
+
resolve(value);
|
|
70
|
+
});
|
|
63
71
|
rl.question("Waive all of them? [y/N] ", (answer) => {
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
72
|
+
if (!/^\s*y(es)?\s*$/i.test(answer)) return finish(false);
|
|
73
|
+
rl.question("Author: ", (author) => {
|
|
74
|
+
if (!author.trim() || author.length > 256) return finish(false);
|
|
75
|
+
rl.question("Reason: ", (reason) => {
|
|
76
|
+
if (!reason.trim() || reason.length > 4096) return finish(false);
|
|
77
|
+
finish({ author: author.trim(), reason: reason.trim() });
|
|
78
|
+
});
|
|
67
79
|
});
|
|
68
80
|
});
|
|
69
81
|
});
|
|
@@ -72,43 +84,26 @@ export function createWaiverPrompt(input: NodeJS.ReadableStream, output: NodeJS.
|
|
|
72
84
|
export const readlineWaiverPrompt: WaiverPrompt = (decision, obligationIds) =>
|
|
73
85
|
createWaiverPrompt(process.stdin, process.stderr)(decision, obligationIds);
|
|
74
86
|
|
|
75
|
-
/** The spelling the filesystem can vouch for: the realpath where it can answer, the spelling itself where it cannot. */
|
|
76
|
-
function canonicalEntryPath(entryPath: string): string {
|
|
77
|
-
try {
|
|
78
|
-
return realpathSync(entryPath);
|
|
79
|
-
} catch {
|
|
80
|
-
return entryPath;
|
|
81
|
-
}
|
|
82
|
-
}
|
|
83
|
-
|
|
84
87
|
/**
|
|
85
|
-
*
|
|
86
|
-
*
|
|
87
|
-
* argv and `import.meta.url` may spell the same file differently: argv is the
|
|
88
|
-
* caller's spelling, and Node builds the module URL from the realpath by
|
|
89
|
-
* default but from the caller's spelling under `--preserve-symlinks-main`. So
|
|
90
|
-
* each side is canonicalized independently and the canonical forms compared:
|
|
91
|
-
* a symlinked spelling matches its realpath whenever the link can be read
|
|
92
|
-
* (`/tmp` → `/private/tmp` on macOS, a wrapper script's stored path, a pnpm
|
|
93
|
-
* workspace link), and equal spellings still match when neither side resolves.
|
|
88
|
+
* Reads the whole of stdin as UTF-8.
|
|
94
89
|
*
|
|
95
|
-
*
|
|
96
|
-
*
|
|
97
|
-
*
|
|
98
|
-
*
|
|
99
|
-
*
|
|
100
|
-
* non-`file:` module href (a bundled or single-executable build) never
|
|
101
|
-
* matches — such a build must invoke `main` explicitly.
|
|
90
|
+
* `emit` takes its payload here, and the one thing that must never happen is a
|
|
91
|
+
* hang: a terminal with no pipe attached would otherwise leave the command
|
|
92
|
+
* waiting forever for a line nobody is going to type. A TTY stdin therefore
|
|
93
|
+
* reads as empty, and the store refuses the empty payload with a diagnostic,
|
|
94
|
+
* which is a far better answer than silence.
|
|
102
95
|
*/
|
|
103
|
-
export function
|
|
104
|
-
if (
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
96
|
+
export function readStdinText(input: NodeJS.ReadableStream & { isTTY?: boolean }): Promise<string> {
|
|
97
|
+
if (input.isTTY === true) return Promise.resolve("");
|
|
98
|
+
return new Promise((resolve) => {
|
|
99
|
+
let text = "";
|
|
100
|
+
input.setEncoding("utf8");
|
|
101
|
+
input.on("data", (chunk: string) => {
|
|
102
|
+
text += chunk;
|
|
103
|
+
});
|
|
104
|
+
input.once("error", () => resolve(text));
|
|
105
|
+
input.once("end", () => resolve(text));
|
|
106
|
+
});
|
|
112
107
|
}
|
|
113
108
|
|
|
114
109
|
export function defaultRuntime(): CliRuntime {
|
|
@@ -120,6 +115,7 @@ export function defaultRuntime(): CliRuntime {
|
|
|
120
115
|
stdout: (text) => process.stdout.write(text),
|
|
121
116
|
stderr: (text) => process.stderr.write(text),
|
|
122
117
|
promptForWaiver: readlineWaiverPrompt,
|
|
118
|
+
readStdin: () => readStdinText(process.stdin),
|
|
123
119
|
};
|
|
124
120
|
}
|
|
125
121
|
|