@agent-delivery-harness/cli 0.1.0 → 0.2.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/package.json +8 -3
- package/src/boundary.ts +282 -5
- package/src/commands/emit.ts +351 -0
- package/src/commands/gate.ts +101 -17
- package/src/commands/maintain.ts +202 -0
- package/src/commands/managed.ts +477 -0
- package/src/commands/prepare.ts +8 -1
- package/src/commands/record.ts +2 -5
- package/src/commands/runs.ts +255 -0
- package/src/commands/verify.ts +150 -3
- package/src/index.ts +27 -4
- package/src/main.ts +23 -0
- package/src/provider-rails.ts +705 -0
- package/src/run-projection.ts +278 -0
- package/src/run-server.ts +660 -0
- package/src/run-surface.ts +261 -0
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `runs` — read the run store back: `runs list`, `runs show <id>`, and
|
|
3
|
+
* `runs serve`, the local page over the same files.
|
|
4
|
+
*
|
|
5
|
+
* THE VIEWER IS NOT A JUDGE. Everything rendered here is self-attested: an
|
|
6
|
+
* executor wrote most of it, and the executor could have written anything.
|
|
7
|
+
* Every readout says so in as many words, because the failure this surface
|
|
8
|
+
* invites is an operator reading `complete` as though the product had verified
|
|
9
|
+
* something. It has not. The completeness readout is observability, and the
|
|
10
|
+
* viewer supplies no record tree SHA at all, so every rule phrased over a
|
|
11
|
+
* record's candidate is evaluated over any paired round and labeled unbound.
|
|
12
|
+
*
|
|
13
|
+
* EVERY STRING HERE IS HOSTILE UNTIL RENDERED. Rationales, decisions, blocker
|
|
14
|
+
* summaries and gate labels are executor-written free text on their way to a
|
|
15
|
+
* terminal. `oneLine` neutralizes the escape sequences and collapses the
|
|
16
|
+
* whitespace, so a rationale carrying a newline and a plausible-looking
|
|
17
|
+
* completion row renders as one row's worth of text and forges nothing.
|
|
18
|
+
*/
|
|
19
|
+
import { stat } from "node:fs/promises";
|
|
20
|
+
import path from "node:path";
|
|
21
|
+
import { evaluateRunJournal } from "@agent-delivery-harness/kernel";
|
|
22
|
+
import {
|
|
23
|
+
READOUT_LABELS,
|
|
24
|
+
detailOf,
|
|
25
|
+
readoutRows,
|
|
26
|
+
roundRows,
|
|
27
|
+
} from "../run-projection.ts";
|
|
28
|
+
import { startRunServer, type RunServerHandle } from "../run-server.ts";
|
|
29
|
+
import { oneLine, oneLineOf, resolveRunSurface, runSurfaceBlocker, type RunSurface } from "../run-surface.ts";
|
|
30
|
+
import type { CommandResult, ConfigFreeCommandContext, ConfigFreeCommandDescriptor } from "../boundary.ts";
|
|
31
|
+
|
|
32
|
+
const USAGE = [
|
|
33
|
+
"Usage: delivery-harness runs list",
|
|
34
|
+
" delivery-harness runs show <run-id>",
|
|
35
|
+
" delivery-harness runs serve [--repo <path>]... [--port <n>]",
|
|
36
|
+
].join("\n");
|
|
37
|
+
|
|
38
|
+
const unresolvable = (reason: string): CommandResult => ({
|
|
39
|
+
kind: "blocked",
|
|
40
|
+
blockers: [
|
|
41
|
+
runSurfaceBlocker({
|
|
42
|
+
code: "run_store_unresolvable",
|
|
43
|
+
summary: "The run store could not be resolved.",
|
|
44
|
+
details: oneLine(reason, 200),
|
|
45
|
+
remediation: {
|
|
46
|
+
id: "run-inside-a-repository",
|
|
47
|
+
summary: "Run this command inside a git repository; the run store lives under its common directory.",
|
|
48
|
+
},
|
|
49
|
+
}),
|
|
50
|
+
],
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
export const runsCommand: ConfigFreeCommandDescriptor = {
|
|
54
|
+
name: "runs",
|
|
55
|
+
sourceId: "delivery-harness.cli.runs",
|
|
56
|
+
summary: "List, show, and serve the delivery runs this repository has recorded.",
|
|
57
|
+
configFree: true,
|
|
58
|
+
async run(context: ConfigFreeCommandContext): Promise<CommandResult> {
|
|
59
|
+
const [subcommand, ...rest] = context.args;
|
|
60
|
+
if (subcommand === undefined) return { kind: "usage", message: `runs needs a subcommand.\n${USAGE}` };
|
|
61
|
+
if (subcommand !== "list" && subcommand !== "show" && subcommand !== "serve") {
|
|
62
|
+
return { kind: "usage", message: `Unknown runs subcommand ${oneLine(subcommand, 64)}.\n${USAGE}` };
|
|
63
|
+
}
|
|
64
|
+
if (subcommand === "show" && rest[0] === undefined) {
|
|
65
|
+
return { kind: "usage", message: `runs show needs a run id.\n${USAGE}` };
|
|
66
|
+
}
|
|
67
|
+
// `serve` resolves its OWN repositories — one per `--repo`, none of them
|
|
68
|
+
// necessarily the invoking worktree — so it never asks the invoking
|
|
69
|
+
// worktree's store to resolve first.
|
|
70
|
+
if (subcommand === "serve") return serveRuns(context, rest);
|
|
71
|
+
|
|
72
|
+
const resolved = await resolveRunSurface(context.rootDir);
|
|
73
|
+
if (!resolved.ok) return unresolvable(resolved.reason);
|
|
74
|
+
|
|
75
|
+
return subcommand === "list"
|
|
76
|
+
? listRuns(resolved.surface, context)
|
|
77
|
+
: showRun(resolved.surface, context, rest[0]!);
|
|
78
|
+
},
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
// ── list ─────────────────────────────────────────────────────────────────────
|
|
82
|
+
|
|
83
|
+
/** The journal's size on disk, or zero where it cannot be measured. */
|
|
84
|
+
async function sizeOf(runsDir: string, runId: string): Promise<number> {
|
|
85
|
+
try {
|
|
86
|
+
return (await stat(path.join(runsDir, `${runId}.jsonl`))).size;
|
|
87
|
+
} catch {
|
|
88
|
+
return 0;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async function listRuns(surface: RunSurface, context: ConfigFreeCommandContext): Promise<CommandResult> {
|
|
93
|
+
const runIds = await surface.store.list();
|
|
94
|
+
const current = await surface.store.current(surface.worktreeKey);
|
|
95
|
+
const currentRunId = current.ok ? current.runId : undefined;
|
|
96
|
+
|
|
97
|
+
// The status column carries a completeness verdict, so this readout is
|
|
98
|
+
// labeled exactly like `show`'s. `list` is the command an operator reaches
|
|
99
|
+
// for first, before it knows which id to show; an unlabeled `complete` here
|
|
100
|
+
// is the misreading the labels exist to prevent.
|
|
101
|
+
const lines: string[] = [`runs in ${oneLine(surface.runsDir, 400)}`, ` (${READOUT_LABELS})`];
|
|
102
|
+
let total = 0;
|
|
103
|
+
for (const runId of runIds) {
|
|
104
|
+
const size = await sizeOf(surface.runsDir, runId);
|
|
105
|
+
total += size;
|
|
106
|
+
const read = await surface.store.read(runId);
|
|
107
|
+
if (!read.ok) {
|
|
108
|
+
lines.push(` ${runId} unreadable ${size} bytes`);
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
const evaluation = evaluateRunJournal(read.events);
|
|
112
|
+
const open = !read.events.some((event) => event.kind === "run.ended");
|
|
113
|
+
lines.push(
|
|
114
|
+
` ${runId} ${evaluation.status} ${open ? "open" : "ended"}${runId === currentRunId ? " current" : ""} ${size} bytes`,
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
lines.push(`total ${total} bytes across ${runIds.length} run(s)`);
|
|
118
|
+
for (const line of lines) context.write(line);
|
|
119
|
+
return { kind: "ok" };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// ── show ─────────────────────────────────────────────────────────────────────
|
|
123
|
+
|
|
124
|
+
async function showRun(surface: RunSurface, context: ConfigFreeCommandContext, runId: string): Promise<CommandResult> {
|
|
125
|
+
const read = await surface.store.read(runId);
|
|
126
|
+
if (!read.ok) {
|
|
127
|
+
return {
|
|
128
|
+
kind: "blocked",
|
|
129
|
+
blockers: [
|
|
130
|
+
runSurfaceBlocker({
|
|
131
|
+
code: "run_unresolvable",
|
|
132
|
+
summary: "That run has no readable journal in this store.",
|
|
133
|
+
details: `run ${oneLine(runId, 128)}: ${oneLine(read.rejections[0]?.message ?? "unreadable", 200)}`,
|
|
134
|
+
remediation: { id: "list-the-runs", summary: "Run `delivery-harness runs list` to see the runs this repository holds." },
|
|
135
|
+
}),
|
|
136
|
+
],
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const events = read.events;
|
|
141
|
+
const open = !events.some((event) => event.kind === "run.ended");
|
|
142
|
+
const current = await surface.store.current(surface.worktreeKey);
|
|
143
|
+
const isCurrent = current.ok && current.runId === runId;
|
|
144
|
+
|
|
145
|
+
context.write(`run ${runId} ${open ? "open" : "ended"}${isCurrent ? " current in this worktree" : ""}`);
|
|
146
|
+
context.write(" events:");
|
|
147
|
+
for (const event of events) {
|
|
148
|
+
context.write(` ${event.seq} ${event.at} ${event.kind.padEnd(20)} ${event.actor.role.padEnd(8)} ${detailOf(event)}`);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const rounds = roundRows(events);
|
|
152
|
+
if (rounds.length > 0) {
|
|
153
|
+
context.write(" rounds:");
|
|
154
|
+
for (const row of rounds) context.write(` ${row}`);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const decisions = events.filter((event) => event.kind === "decision.recorded");
|
|
158
|
+
if (decisions.length > 0) {
|
|
159
|
+
context.write(" decisions:");
|
|
160
|
+
for (const decision of decisions) context.write(` ${detailOf(decision)}`);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const notes = await surface.store.readNotes(runId);
|
|
164
|
+
if (notes.length > 0) {
|
|
165
|
+
context.write(" refused appends:");
|
|
166
|
+
for (const entry of notes) {
|
|
167
|
+
const note = (typeof entry === "object" && entry !== null ? entry : {}) as Record<string, unknown>;
|
|
168
|
+
context.write(
|
|
169
|
+
` ${oneLineOf(note["at"], 32)} ${oneLineOf(note["kind"], 128)} ${oneLineOf(note["code"], 64)}${note["pattern"] === undefined ? "" : ` ${oneLineOf(note["pattern"], 64)}`}`,
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// No record tree sha and no mandated pair: the viewer has neither, and
|
|
175
|
+
// pretending otherwise would turn an observation into a claim.
|
|
176
|
+
for (const row of readoutRows(events, evaluateRunJournal(events), context.rootDir)) context.write(row);
|
|
177
|
+
return { kind: "ok" };
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// ── serve ────────────────────────────────────────────────────────────────────
|
|
181
|
+
|
|
182
|
+
interface ServeArgs {
|
|
183
|
+
readonly repos: readonly string[];
|
|
184
|
+
readonly port?: number;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
type ServeParse = { readonly ok: true; readonly args: ServeArgs } | { readonly ok: false; readonly message: string };
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* The separate-argument form every other command uses. `--flag=value` is
|
|
191
|
+
* REFUSED rather than accepted as a convenience: one spelling means an
|
|
192
|
+
* operator who mistypes a path gets a usage error instead of a server quietly
|
|
193
|
+
* watching a repository named `--repo=/some/path`.
|
|
194
|
+
*/
|
|
195
|
+
function parseServeArgs(args: readonly string[], rootDir: string): ServeParse {
|
|
196
|
+
const repos: string[] = [];
|
|
197
|
+
let port: number | undefined;
|
|
198
|
+
|
|
199
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
200
|
+
const token = args[index]!;
|
|
201
|
+
if (token === "--repo" || token === "--port") {
|
|
202
|
+
const value = args[index + 1];
|
|
203
|
+
if (value === undefined) return { ok: false, message: `${token} needs a value.\n${USAGE}` };
|
|
204
|
+
index += 1;
|
|
205
|
+
if (token === "--repo") {
|
|
206
|
+
repos.push(path.resolve(rootDir, value));
|
|
207
|
+
continue;
|
|
208
|
+
}
|
|
209
|
+
if (!/^\d{1,5}$/.test(value)) return { ok: false, message: `--port needs a port number.\n${USAGE}` };
|
|
210
|
+
const parsed = Number(value);
|
|
211
|
+
if (parsed > 65535) return { ok: false, message: `--port needs a port number.\n${USAGE}` };
|
|
212
|
+
port = parsed;
|
|
213
|
+
continue;
|
|
214
|
+
}
|
|
215
|
+
if (token.startsWith("--")) return { ok: false, message: `Unknown flag ${oneLine(token, 64)}.\n${USAGE}` };
|
|
216
|
+
return { ok: false, message: `runs serve takes no positional arguments.\n${USAGE}` };
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// No `--repo` means the worktree the operator is standing in, which is the
|
|
220
|
+
// only repository they can have meant.
|
|
221
|
+
return { ok: true, args: { repos: repos.length === 0 ? [rootDir] : repos, ...(port === undefined ? {} : { port }) } };
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Serves until the invocation is signalled.
|
|
226
|
+
*
|
|
227
|
+
* There is no other exit. A viewer's job is to be there while the operator
|
|
228
|
+
* watches, and the operator ends it with the interrupt the boundary already
|
|
229
|
+
* maps; a run ending is not a reason to stop serving, because the next run
|
|
230
|
+
* starts in the same store.
|
|
231
|
+
*/
|
|
232
|
+
async function serveRuns(context: ConfigFreeCommandContext, args: readonly string[]): Promise<CommandResult> {
|
|
233
|
+
const parsed = parseServeArgs(args, context.rootDir);
|
|
234
|
+
if (!parsed.ok) return { kind: "usage", message: parsed.message };
|
|
235
|
+
|
|
236
|
+
const started = await startRunServer({ repos: parsed.args.repos, ...(parsed.args.port === undefined ? {} : { port: parsed.args.port }) });
|
|
237
|
+
if (!started.ok) return unresolvable(started.reason);
|
|
238
|
+
|
|
239
|
+
const server: RunServerHandle = started.server;
|
|
240
|
+
context.write(`serving ${parsed.args.repos.length} repository path(s) at ${server.url}`);
|
|
241
|
+
context.write(` (${READOUT_LABELS})`);
|
|
242
|
+
try {
|
|
243
|
+
await untilSignalled(context.signal);
|
|
244
|
+
} finally {
|
|
245
|
+
await server.close();
|
|
246
|
+
}
|
|
247
|
+
return { kind: "ok" };
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** Resolves when the invocation's signal aborts; never, when it has none. */
|
|
251
|
+
function untilSignalled(signal: AbortSignal | undefined): Promise<void> {
|
|
252
|
+
if (signal === undefined) return new Promise<void>(() => {});
|
|
253
|
+
if (signal.aborted) return Promise.resolve();
|
|
254
|
+
return new Promise<void>((resolve) => signal.addEventListener("abort", () => resolve(), { once: true }));
|
|
255
|
+
}
|
package/src/commands/verify.ts
CHANGED
|
@@ -7,18 +7,120 @@
|
|
|
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
|
+
type CandidateTreeEntry,
|
|
41
|
+
type RunJournalRow,
|
|
42
|
+
} from "@agent-delivery-harness/kernel";
|
|
14
43
|
import { commandBlocker } from "../boundary.ts";
|
|
15
44
|
import type { CommandContext, CommandDescriptor, CommandResult } from "../boundary.ts";
|
|
45
|
+
import { oneLine, resolveRunJournalRow, runJournalRows } from "../run-surface.ts";
|
|
46
|
+
|
|
47
|
+
const USAGE = "Usage: delivery-harness verify [--require-run-journal] [--mandated-lens <id>]...";
|
|
48
|
+
|
|
49
|
+
interface ParsedArgs {
|
|
50
|
+
readonly requireRunJournal: boolean;
|
|
51
|
+
readonly mandatedLensIds: readonly string[];
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
type ArgParse = { readonly ok: true; readonly args: ParsedArgs } | { readonly ok: false; readonly message: string };
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* The separate-argument form every other command's flags take: `--flag value`,
|
|
58
|
+
* never `--flag=value`. The joined form is not silently split, because a
|
|
59
|
+
* `--mandated-lens=x` that quietly worked here and nowhere else would be a
|
|
60
|
+
* second grammar for the same CLI. It falls through to the unknown-flag arm,
|
|
61
|
+
* which is a usage error naming the token.
|
|
62
|
+
*/
|
|
63
|
+
function parseArgs(args: readonly string[]): ArgParse {
|
|
64
|
+
let requireRunJournal = false;
|
|
65
|
+
const mandatedLensIds: string[] = [];
|
|
66
|
+
|
|
67
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
68
|
+
const token = args[index]!;
|
|
69
|
+
if (token === "--require-run-journal") {
|
|
70
|
+
requireRunJournal = true;
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
if (token === "--mandated-lens") {
|
|
74
|
+
const value = args[index + 1];
|
|
75
|
+
if (value === undefined) return { ok: false, message: `${token} needs a value.\n${USAGE}` };
|
|
76
|
+
// Bounded before the kernel sees it: an id is compared against journal
|
|
77
|
+
// content, and an unbounded one would be echoed into the row it produces.
|
|
78
|
+
if (value.length > MAX_RUN_PROVIDER_ID || !RUN_PROVIDER_ID.test(value)) {
|
|
79
|
+
return { ok: false, message: `${token} takes a bounded lens id, not ${oneLine(value, 64)}.\n${USAGE}` };
|
|
80
|
+
}
|
|
81
|
+
mandatedLensIds.push(value);
|
|
82
|
+
index += 1;
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
if (token.startsWith("-")) return { ok: false, message: `Unknown flag ${oneLine(token, 64)}.\n${USAGE}` };
|
|
86
|
+
return { ok: false, message: `verify takes no positional arguments, and ${oneLine(token, 64)} is one.\n${USAGE}` };
|
|
87
|
+
}
|
|
88
|
+
return { ok: true, args: { requireRunJournal, mandatedLensIds } };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* The opt-in's refusal. It names the status, the missing entries, and the
|
|
93
|
+
* violated constraints, all of them product-defined names from the evaluator's
|
|
94
|
+
* two closed sets — never journal-derived text.
|
|
95
|
+
*/
|
|
96
|
+
function runJournalBlocker(row: RunJournalRow) {
|
|
97
|
+
const missing = row.missing.length === 0 ? "(none)" : row.missing.join(", ");
|
|
98
|
+
const violations = row.violations === undefined || row.violations.length === 0 ? "(none)" : row.violations.join(", ");
|
|
99
|
+
return commandBlocker({
|
|
100
|
+
code: "run_journal_incomplete",
|
|
101
|
+
sourceId: "delivery-harness.cli.verify",
|
|
102
|
+
summary: "The run journal for this candidate is not complete, and --require-run-journal was given.",
|
|
103
|
+
details: `status ${row.status}${row.runId === undefined ? "" : ` (run ${oneLine(row.runId, 128)})`}; missing: ${missing}; violations: ${violations}`,
|
|
104
|
+
remediations: [
|
|
105
|
+
{
|
|
106
|
+
id: "emit-the-missing-run-events",
|
|
107
|
+
kind: "manual_action",
|
|
108
|
+
summary: "Emit the run events this delivery did not journal, or drop --require-run-journal: the row is observability, not evidence.",
|
|
109
|
+
},
|
|
110
|
+
],
|
|
111
|
+
});
|
|
112
|
+
}
|
|
16
113
|
|
|
17
114
|
export const verifyCommand: CommandDescriptor = {
|
|
18
115
|
name: "verify",
|
|
19
116
|
sourceId: "delivery-harness.cli.verify",
|
|
20
117
|
summary: "Verify the tracked delivery record against the current candidate.",
|
|
21
118
|
async run(context: CommandContext): Promise<CommandResult> {
|
|
119
|
+
// Arguments first: a malformed invocation is a usage error and captures
|
|
120
|
+
// nothing, exactly as `emit` and `submit-evidence` order it.
|
|
121
|
+
const parsedArgs = parseArgs(context.args);
|
|
122
|
+
if (!parsedArgs.ok) return { kind: "usage", message: parsedArgs.message };
|
|
123
|
+
|
|
22
124
|
const wiring = await context.wire();
|
|
23
125
|
const capture = await wiring.captureCandidate();
|
|
24
126
|
if (!capture.ok) {
|
|
@@ -67,17 +169,62 @@ export const verifyCommand: CommandDescriptor = {
|
|
|
67
169
|
return { kind: "blocked", blockers: [...parsed.blockers] };
|
|
68
170
|
}
|
|
69
171
|
|
|
70
|
-
|
|
172
|
+
// The tracked tree's own entries, so this command rejects a candidate
|
|
173
|
+
// carrying a projection or discovery-configuration path exactly as the
|
|
174
|
+
// Action does. An unreadable listing supplies no entries rather than a
|
|
175
|
+
// false clean bill: the check simply does not run.
|
|
176
|
+
//
|
|
177
|
+
// Mode and object, not just the name: the one admitted exception under
|
|
178
|
+
// `.claude/skills/` turns on the entry being a symlink and on where its
|
|
179
|
+
// committed target resolves, and both facts live in the tree.
|
|
180
|
+
const listing = await runGitCommand(["git", "ls-tree", "-r", "-z", "--full-tree", "HEAD"], {
|
|
181
|
+
cwd: context.rootDir,
|
|
182
|
+
});
|
|
183
|
+
const candidateTreePaths: CandidateTreeEntry[] = [];
|
|
184
|
+
if (listing.exitCode === 0) {
|
|
185
|
+
for (const entry of parseCandidateTreeListing(listing.stdout)) {
|
|
186
|
+
if (!needsCommittedSymlinkTarget(entry)) {
|
|
187
|
+
candidateTreePaths.push(entry);
|
|
188
|
+
continue;
|
|
189
|
+
}
|
|
190
|
+
// The target read out of the committed blob, never off the filesystem:
|
|
191
|
+
// the working tree's link may differ from the one under review. A blob
|
|
192
|
+
// that will not read leaves the target absent, and an entry with no
|
|
193
|
+
// target cannot reach the exception.
|
|
194
|
+
const blob = await runGitCommand(["git", "cat-file", "blob", entry.objectSha], { cwd: context.rootDir });
|
|
195
|
+
candidateTreePaths.push(blob.exitCode === 0 ? { ...entry, symlinkTarget: blob.stdout } : entry);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// The row is resolved from the RECORD'S tree sha, not the recomputed
|
|
200
|
+
// identity: the identity digest excludes the review-neutral paths, and what
|
|
201
|
+
// a review round and a `pr.opened` bind is the raw tree the record carries.
|
|
202
|
+
const runJournal = await resolveRunJournalRow({
|
|
203
|
+
cwd: context.rootDir,
|
|
204
|
+
treeSha: parsed.record.candidateBinding.treeSha,
|
|
205
|
+
...(parsedArgs.args.mandatedLensIds.length === 0 ? {} : { mandatedLensIds: parsedArgs.args.mandatedLensIds }),
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
const check = verifyDeliveryRecord(context.config, parsed.record, identity, base, { candidateTreePaths, runJournal });
|
|
71
209
|
if (!check.ok) {
|
|
72
210
|
return { kind: "blocked", blockers: [...check.blockers] };
|
|
73
211
|
}
|
|
74
212
|
|
|
213
|
+
// The opt-in is judged AFTER the record's own verification, so a delivery
|
|
214
|
+
// whose record is bad is never told its journal is the problem.
|
|
215
|
+
if (parsedArgs.args.requireRunJournal && runJournal.status !== "complete") {
|
|
216
|
+
return { kind: "blocked", blockers: [runJournalBlocker(runJournal)] };
|
|
217
|
+
}
|
|
218
|
+
|
|
75
219
|
const relaxation = check.baseMovementRelaxed
|
|
76
220
|
? ` (base movement relaxed by policy: ${check.relaxedDriftClasses.join(", ")})`
|
|
77
221
|
: "";
|
|
78
222
|
return {
|
|
79
223
|
kind: "ok",
|
|
80
|
-
summary:
|
|
224
|
+
summary: [
|
|
225
|
+
`verified ${relativePath}${relaxation}; attestation: ${check.attestationLabel}`,
|
|
226
|
+
...runJournalRows(runJournal),
|
|
227
|
+
].join("\n"),
|
|
81
228
|
};
|
|
82
229
|
},
|
|
83
230
|
};
|
package/src/index.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Delivery harness CLI: the
|
|
2
|
+
* Delivery harness CLI: the eleven-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,22 +12,32 @@
|
|
|
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 { emitCommand } from "./commands/emit.ts";
|
|
15
16
|
import { gateCommand } from "./commands/gate.ts";
|
|
17
|
+
import { maintainCommand } from "./commands/maintain.ts";
|
|
18
|
+
import { managedCommand } from "./commands/managed.ts";
|
|
16
19
|
import { prepareCommand } from "./commands/prepare.ts";
|
|
17
20
|
import { recordCommand } from "./commands/record.ts";
|
|
18
21
|
import { reviewContextCommand } from "./commands/review-context.ts";
|
|
22
|
+
import { runsCommand } from "./commands/runs.ts";
|
|
19
23
|
import { submitEvidenceCommand } from "./commands/submit-evidence.ts";
|
|
20
24
|
import { verifyCommand } from "./commands/verify.ts";
|
|
21
|
-
import { runCliBoundary, type
|
|
25
|
+
import { runCliBoundary, type AnyCommandDescriptor, type CliRuntime } from "./boundary.ts";
|
|
22
26
|
|
|
23
27
|
export const PACKAGE_NAME = "@agent-delivery-harness/cli";
|
|
24
28
|
|
|
25
29
|
/**
|
|
26
30
|
* The command registry. The order here is the order `--help` lists them, and it
|
|
27
31
|
* follows the loop an operator walks: prepare, review, submit, gate, record,
|
|
28
|
-
* verify — with `check`
|
|
32
|
+
* verify — with `check` as the standalone preflight and `managed` as the
|
|
33
|
+
* host-facing managed-delivery checkpoint surface and `maintain` as the
|
|
34
|
+
* installation-scoped maintenance lane.
|
|
35
|
+
*
|
|
36
|
+
* `emit` and `runs` come last because they are a different class: config-free
|
|
37
|
+
* commands, dispatched before `harness.config.ts` is loaded, that read and
|
|
38
|
+
* write the run store rather than anything a delivery decision depends on.
|
|
29
39
|
*/
|
|
30
|
-
export const COMMANDS: readonly
|
|
40
|
+
export const COMMANDS: readonly AnyCommandDescriptor[] = [
|
|
31
41
|
prepareCommand,
|
|
32
42
|
reviewContextCommand,
|
|
33
43
|
submitEvidenceCommand,
|
|
@@ -35,22 +45,31 @@ export const COMMANDS: readonly CommandDescriptor[] = [
|
|
|
35
45
|
recordCommand,
|
|
36
46
|
verifyCommand,
|
|
37
47
|
checkCommand,
|
|
48
|
+
managedCommand,
|
|
49
|
+
maintainCommand,
|
|
50
|
+
emitCommand,
|
|
51
|
+
runsCommand,
|
|
38
52
|
];
|
|
39
53
|
|
|
40
54
|
export {
|
|
55
|
+
COMPLETION_WRAPPED_COMMANDS,
|
|
41
56
|
EXIT_INTERRUPTED,
|
|
42
57
|
EXIT_OK,
|
|
43
58
|
EXIT_POLICY,
|
|
44
59
|
EXIT_USAGE,
|
|
45
60
|
CliInterruption,
|
|
61
|
+
isConfigFreeCommand,
|
|
46
62
|
runCliBoundary,
|
|
47
63
|
wireRepo,
|
|
48
64
|
importHarnessConfig,
|
|
49
65
|
commandBlocker,
|
|
66
|
+
type AnyCommandDescriptor,
|
|
50
67
|
type CliRuntime,
|
|
51
68
|
type CommandContext,
|
|
52
69
|
type CommandDescriptor,
|
|
53
70
|
type CommandResult,
|
|
71
|
+
type ConfigFreeCommandContext,
|
|
72
|
+
type ConfigFreeCommandDescriptor,
|
|
54
73
|
type RepoWiring,
|
|
55
74
|
} from "./boundary.ts";
|
|
56
75
|
|
|
@@ -61,6 +80,10 @@ export { gateCommand } from "./commands/gate.ts";
|
|
|
61
80
|
export { recordCommand } from "./commands/record.ts";
|
|
62
81
|
export { verifyCommand } from "./commands/verify.ts";
|
|
63
82
|
export { checkCommand } from "./commands/check.ts";
|
|
83
|
+
export { managedCommand } from "./commands/managed.ts";
|
|
84
|
+
export { maintainCommand } from "./commands/maintain.ts";
|
|
85
|
+
export { emitCommand } from "./commands/emit.ts";
|
|
86
|
+
export { runsCommand } from "./commands/runs.ts";
|
|
64
87
|
|
|
65
88
|
/** Runs the CLI against a runtime and returns the process exit code. */
|
|
66
89
|
export function runCli(argv: readonly string[], runtime: CliRuntime): Promise<number> {
|
package/src/main.ts
CHANGED
|
@@ -111,6 +111,28 @@ export function invokedDirectly(argvEntry: string | undefined, moduleHref: strin
|
|
|
111
111
|
return canonicalEntryPath(argvEntry) === canonicalEntryPath(modulePath);
|
|
112
112
|
}
|
|
113
113
|
|
|
114
|
+
/**
|
|
115
|
+
* Reads the whole of stdin as UTF-8.
|
|
116
|
+
*
|
|
117
|
+
* `emit` takes its payload here, and the one thing that must never happen is a
|
|
118
|
+
* hang: a terminal with no pipe attached would otherwise leave the command
|
|
119
|
+
* waiting forever for a line nobody is going to type. A TTY stdin therefore
|
|
120
|
+
* reads as empty, and the store refuses the empty payload with a diagnostic,
|
|
121
|
+
* which is a far better answer than silence.
|
|
122
|
+
*/
|
|
123
|
+
export function readStdinText(input: NodeJS.ReadableStream & { isTTY?: boolean }): Promise<string> {
|
|
124
|
+
if (input.isTTY === true) return Promise.resolve("");
|
|
125
|
+
return new Promise((resolve) => {
|
|
126
|
+
let text = "";
|
|
127
|
+
input.setEncoding("utf8");
|
|
128
|
+
input.on("data", (chunk: string) => {
|
|
129
|
+
text += chunk;
|
|
130
|
+
});
|
|
131
|
+
input.once("error", () => resolve(text));
|
|
132
|
+
input.once("end", () => resolve(text));
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
|
|
114
136
|
export function defaultRuntime(): CliRuntime {
|
|
115
137
|
return {
|
|
116
138
|
cwd: process.cwd(),
|
|
@@ -120,6 +142,7 @@ export function defaultRuntime(): CliRuntime {
|
|
|
120
142
|
stdout: (text) => process.stdout.write(text),
|
|
121
143
|
stderr: (text) => process.stderr.write(text),
|
|
122
144
|
promptForWaiver: readlineWaiverPrompt,
|
|
145
|
+
readStdin: () => readStdinText(process.stdin),
|
|
123
146
|
};
|
|
124
147
|
}
|
|
125
148
|
|