@agent-delivery-harness/cli 0.2.0 → 0.7.3
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 +5 -5
- package/src/boundary.ts +84 -63
- package/src/check-snapshot.ts +167 -0
- 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 +80 -12
- package/src/commands/gate.ts +64 -13
- package/src/commands/maintain.ts +1 -0
- package/src/commands/managed.ts +105 -34
- package/src/commands/prepare.ts +148 -22
- package/src/commands/record.ts +107 -3
- package/src/commands/resume.ts +53 -0
- package/src/commands/review-context.ts +19 -0
- package/src/commands/runs.ts +461 -30
- package/src/commands/save-context.ts +51 -0
- package/src/commands/submit-evidence.ts +46 -7
- package/src/commands/verify.ts +156 -10
- package/src/declared-checks.ts +50 -0
- package/src/index.ts +17 -1
- package/src/main.ts +56 -65
- package/src/ordinary-context.ts +83 -0
- package/src/provider-rails.ts +23 -704
- 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 +202 -0
- package/src/run-live.ts +102 -0
- package/src/run-projection.ts +327 -12
- package/src/run-server.ts +403 -149
- package/src/run-surface.ts +196 -9
- 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
- package/src/scoped-attempts.ts +86 -0
- package/src/scoped-candidate.ts +28 -0
- package/src/scoped-checks.ts +196 -0
- package/src/scoped-diagnostics.ts +124 -0
- package/src/scoped-observations.ts +51 -0
- package/src/snapshot-inventory-worker.ts +40 -0
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 {
|
|
14
|
-
import {
|
|
15
|
-
import type { WaiverPrompt } from "@agent-delivery-harness/kernel";
|
|
12
|
+
import { CliInterruption, EXIT_INTERRUPTED, EXIT_POLICY, runCli, type CliRuntime } from "./index.ts";
|
|
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.
|
|
@@ -37,33 +35,49 @@ import type { WaiverPrompt } from "@agent-delivery-harness/kernel";
|
|
|
37
35
|
* would reject and then resolve, and a settled promise silently ignoring its
|
|
38
36
|
* second settlement is exactly how this class of bug hides.
|
|
39
37
|
*/
|
|
40
|
-
export function createWaiverPrompt(input: NodeJS.ReadableStream, output: NodeJS.WritableStream): WaiverPrompt {
|
|
41
|
-
return (
|
|
42
|
-
new Promise<
|
|
38
|
+
export function createWaiverPrompt(input: NodeJS.ReadableStream, output: NodeJS.WritableStream, signal?: AbortSignal): WaiverPrompt {
|
|
39
|
+
return (decision, obligationIds) =>
|
|
40
|
+
new Promise<Awaited<ReturnType<WaiverPrompt>>>((resolve, reject) => {
|
|
41
|
+
if (signal?.aborted) { reject(new CliInterruption()); return; }
|
|
43
42
|
const rl = createInterface({ input, output });
|
|
44
43
|
let settled = false;
|
|
45
44
|
const settle = (action: () => void): void => {
|
|
46
45
|
if (settled) return;
|
|
47
46
|
settled = true;
|
|
47
|
+
signal?.removeEventListener("abort", interrupt);
|
|
48
48
|
action();
|
|
49
49
|
};
|
|
50
50
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
reject(new CliInterruption("Waiver prompt interrupted."));
|
|
55
|
-
});
|
|
51
|
+
const interrupt = () => settle(() => {
|
|
52
|
+
rl.close();
|
|
53
|
+
reject(new CliInterruption("Waiver prompt interrupted."));
|
|
56
54
|
});
|
|
55
|
+
rl.on("SIGINT", interrupt);
|
|
56
|
+
signal?.addEventListener("abort", interrupt, { once: true });
|
|
57
57
|
// Ctrl-D, a closed pipe, or any other end of input.
|
|
58
58
|
rl.on("close", () => {
|
|
59
59
|
settle(() => resolve(false));
|
|
60
60
|
});
|
|
61
61
|
|
|
62
62
|
output.write(`Waiving covers ${obligationIds.length} obligation(s): ${obligationIds.join(", ")}.\n`);
|
|
63
|
+
output.write(`Candidate: ${decision.candidate.treeSha}. Approval covers only these findings under the current policy; live obligations require new approval each invocation.\n`);
|
|
64
|
+
for (const resolution of decision.resolutions) {
|
|
65
|
+
if (resolution.kind === "blocked" && obligationIds.includes(resolution.obligationId)) {
|
|
66
|
+
for (const blocker of resolution.blockers) output.write(`${resolution.obligationId}: [${blocker.code}] ${blocker.summary}\n`);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
const finish = (value: Awaited<ReturnType<WaiverPrompt>>) => settle(() => {
|
|
70
|
+
rl.close();
|
|
71
|
+
resolve(value);
|
|
72
|
+
});
|
|
63
73
|
rl.question("Waive all of them? [y/N] ", (answer) => {
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
74
|
+
if (!/^\s*y(es)?\s*$/i.test(answer)) return finish(false);
|
|
75
|
+
rl.question("Author: ", (author) => {
|
|
76
|
+
if (!author.trim() || author.length > 256) return finish(false);
|
|
77
|
+
rl.question("Reason: ", (reason) => {
|
|
78
|
+
if (!reason.trim() || reason.length > 4096) return finish(false);
|
|
79
|
+
finish({ author: author.trim(), reason: reason.trim() });
|
|
80
|
+
});
|
|
67
81
|
});
|
|
68
82
|
});
|
|
69
83
|
});
|
|
@@ -72,45 +86,6 @@ export function createWaiverPrompt(input: NodeJS.ReadableStream, output: NodeJS.
|
|
|
72
86
|
export const readlineWaiverPrompt: WaiverPrompt = (decision, obligationIds) =>
|
|
73
87
|
createWaiverPrompt(process.stdin, process.stderr)(decision, obligationIds);
|
|
74
88
|
|
|
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
|
-
/**
|
|
85
|
-
* Whether this module is the entry the process was started with.
|
|
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.
|
|
94
|
-
*
|
|
95
|
-
* What is NOT claimed: a symlink the filesystem cannot resolve cannot be seen
|
|
96
|
-
* through, and the failing-exit-code floor below sits inside this guard, so an
|
|
97
|
-
* under-match exits 0 in silence — the CLI reporting success having verified
|
|
98
|
-
* nothing. The floor cannot be hoisted above the guard: that would stamp a
|
|
99
|
-
* failing exit code on every process that merely *imports* this module. And a
|
|
100
|
-
* non-`file:` module href (a bundled or single-executable build) never
|
|
101
|
-
* matches — such a build must invoke `main` explicitly.
|
|
102
|
-
*/
|
|
103
|
-
export function invokedDirectly(argvEntry: string | undefined, moduleHref: string): boolean {
|
|
104
|
-
if (argvEntry === undefined) return false;
|
|
105
|
-
let modulePath: string;
|
|
106
|
-
try {
|
|
107
|
-
modulePath = fileURLToPath(moduleHref);
|
|
108
|
-
} catch {
|
|
109
|
-
return false;
|
|
110
|
-
}
|
|
111
|
-
return canonicalEntryPath(argvEntry) === canonicalEntryPath(modulePath);
|
|
112
|
-
}
|
|
113
|
-
|
|
114
89
|
/**
|
|
115
90
|
* Reads the whole of stdin as UTF-8.
|
|
116
91
|
*
|
|
@@ -120,20 +95,29 @@ export function invokedDirectly(argvEntry: string | undefined, moduleHref: strin
|
|
|
120
95
|
* reads as empty, and the store refuses the empty payload with a diagnostic,
|
|
121
96
|
* which is a far better answer than silence.
|
|
122
97
|
*/
|
|
123
|
-
export function readStdinText(input: NodeJS.ReadableStream & { isTTY?: boolean }): Promise<string> {
|
|
98
|
+
export function readStdinText(input: NodeJS.ReadableStream & { isTTY?: boolean }, signal?: AbortSignal): Promise<string> {
|
|
99
|
+
if (signal?.aborted) return Promise.reject(new CliInterruption());
|
|
124
100
|
if (input.isTTY === true) return Promise.resolve("");
|
|
125
|
-
return new Promise((resolve) => {
|
|
101
|
+
return new Promise((resolve, reject) => {
|
|
126
102
|
let text = "";
|
|
103
|
+
const cleanup = () => {
|
|
104
|
+
input.removeListener("data", data);
|
|
105
|
+
input.removeListener("error", finish);
|
|
106
|
+
input.removeListener("end", finish);
|
|
107
|
+
signal?.removeEventListener("abort", interrupt);
|
|
108
|
+
};
|
|
109
|
+
const data = (chunk: string) => { text += chunk; };
|
|
110
|
+
const finish = () => { cleanup(); resolve(text); };
|
|
111
|
+
const interrupt = () => { cleanup(); input.pause(); reject(new CliInterruption()); };
|
|
127
112
|
input.setEncoding("utf8");
|
|
128
|
-
input.on("data",
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
input.once("end", () => resolve(text));
|
|
113
|
+
input.on("data", data);
|
|
114
|
+
input.once("error", finish);
|
|
115
|
+
input.once("end", finish);
|
|
116
|
+
signal?.addEventListener("abort", interrupt, { once: true });
|
|
133
117
|
});
|
|
134
118
|
}
|
|
135
119
|
|
|
136
|
-
export function defaultRuntime(): CliRuntime {
|
|
120
|
+
export function defaultRuntime(signal?: AbortSignal): CliRuntime {
|
|
137
121
|
return {
|
|
138
122
|
cwd: process.cwd(),
|
|
139
123
|
env: process.env,
|
|
@@ -141,13 +125,20 @@ export function defaultRuntime(): CliRuntime {
|
|
|
141
125
|
stdoutIsTTY: process.stdout.isTTY === true,
|
|
142
126
|
stdout: (text) => process.stdout.write(text),
|
|
143
127
|
stderr: (text) => process.stderr.write(text),
|
|
144
|
-
|
|
145
|
-
|
|
128
|
+
...(signal ? { signal } : {}),
|
|
129
|
+
promptForWaiver: createWaiverPrompt(process.stdin, process.stderr, signal),
|
|
130
|
+
readStdin: () => readStdinText(process.stdin, signal),
|
|
146
131
|
};
|
|
147
132
|
}
|
|
148
133
|
|
|
149
134
|
export async function main(argv: readonly string[]): Promise<number> {
|
|
150
|
-
|
|
135
|
+
const controller = new AbortController();
|
|
136
|
+
const interrupt = () => controller.abort();
|
|
137
|
+
process.on("SIGINT", interrupt);
|
|
138
|
+
try {
|
|
139
|
+
const code = await runCli(argv, defaultRuntime(controller.signal));
|
|
140
|
+
return controller.signal.aborted ? EXIT_INTERRUPTED : code;
|
|
141
|
+
} finally { process.removeListener("SIGINT", interrupt); }
|
|
151
142
|
}
|
|
152
143
|
|
|
153
144
|
if (invokedDirectly(process.argv[1], import.meta.url)) {
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { HARNESS_VERSION, digestCanonical, type Blocker, type RunEvent, type RunEventVersion } from "@agent-delivery-harness/kernel";
|
|
4
|
+
import { commandBlocker, type CommandContext } from "./boundary.ts";
|
|
5
|
+
import { oneLine, resolveRunSurface, type RunSurface } from "./run-surface.ts";
|
|
6
|
+
|
|
7
|
+
export function recoveryBlocker(code: string, summary: string, details?: string): Blocker {
|
|
8
|
+
return commandBlocker({ code, sourceId: "delivery-harness.cli.resume", summary,
|
|
9
|
+
...(details === undefined ? {} : { details }),
|
|
10
|
+
remediations: [{ id: "reconcile-delivery-context", kind: "manual_action", summary: "Inspect the saved context and actual workspace/external state with host tools; rerun preparation and gates where evidence is not current. Never replay an uncertain action automatically." }],
|
|
11
|
+
});
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* What a store rejection may say to an operator: the store's own code, the
|
|
16
|
+
* JSON pointer at the offending member, and the store's own message. All three
|
|
17
|
+
* are written by the store rather than taken from the refused payload, so the
|
|
18
|
+
* diagnostic names the field without ever echoing its value — a contract a
|
|
19
|
+
* caller must keep, because the payload it refused is the one place a
|
|
20
|
+
* credential could have been.
|
|
21
|
+
*/
|
|
22
|
+
export function rejectionDetails(rejections: readonly { readonly code: string; readonly pointer: string; readonly message: string }[]): string {
|
|
23
|
+
const first = rejections[0];
|
|
24
|
+
if (first === undefined) return "the store refused the append";
|
|
25
|
+
return `${oneLine(first.code, 64)} at ${oneLine(first.pointer, 128) || "/"}: ${oneLine(first.message, 200)}`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The writer members an ordinary-context event carries, for the version the
|
|
30
|
+
* selected run was started at.
|
|
31
|
+
*
|
|
32
|
+
* WHY THE ID IS DERIVED FROM THE OBSERVATION. A v2 event id is a retry key:
|
|
33
|
+
* the store admits a repeat of an id only when the whole event matches, and
|
|
34
|
+
* refuses the id outright for different content. An id derived from the
|
|
35
|
+
* payload's canonical digest is therefore exactly the key the contract wants —
|
|
36
|
+
* the same save repeated is the same event and appends nothing new, while a
|
|
37
|
+
* changed observation is a different id and a new entry, so neither an
|
|
38
|
+
* interrupted retry nor a genuine second save can produce a refusal an
|
|
39
|
+
* operator has to reason about. It is also stable across processes and hosts,
|
|
40
|
+
* which a random id is not.
|
|
41
|
+
*/
|
|
42
|
+
export function ordinaryEventWriter(version: RunEventVersion, kind: "context.saved", payload: unknown): {
|
|
43
|
+
readonly version: RunEventVersion;
|
|
44
|
+
readonly eventId?: string;
|
|
45
|
+
} {
|
|
46
|
+
if (version !== "run-event/2") return { version };
|
|
47
|
+
// `kind` is narrowed to the one kind this writer serves, so the prefix is a
|
|
48
|
+
// constant that keeps the id readable and inside the store's 128-character
|
|
49
|
+
// run-id charset. It is deliberately not a general scoping claim: dots are
|
|
50
|
+
// folded to hyphens, which is not injective over arbitrary kinds, so a
|
|
51
|
+
// second kind must not be added here without an injective encoding.
|
|
52
|
+
return { version, eventId: `${kind.replaceAll(".", "-")}-${digestCanonical(payload)}` };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export async function recoveryRun(rootDir: string, named?: string): Promise<
|
|
56
|
+
{ ok: true; surface: RunSurface; runId: string; events: readonly RunEvent[]; version: RunEventVersion } | { ok: false; blocker: Blocker }
|
|
57
|
+
> {
|
|
58
|
+
const resolved = await resolveRunSurface(rootDir);
|
|
59
|
+
if (!resolved.ok) return { ok: false, blocker: recoveryBlocker("resume_context_invalid", "The repository run store cannot be read.") };
|
|
60
|
+
const current = await resolved.surface.store.current(resolved.surface.worktreeKey);
|
|
61
|
+
const runId = named ?? (current.ok ? current.runId : undefined);
|
|
62
|
+
if (!runId) return { ok: false, blocker: recoveryBlocker("resume_context_missing", "No current delivery run was found; start a run or name its existing id with --run.") };
|
|
63
|
+
const read = await resolved.surface.store.read(runId);
|
|
64
|
+
if (!read.ok) return { ok: false, blocker: recoveryBlocker("resume_context_invalid", "The delivery journal is missing, corrupt, or inaccessible.", rejectionDetails(read.rejections)) };
|
|
65
|
+
// The writer version is selected when the run starts and never upgraded, so
|
|
66
|
+
// every writer into this journal reads it from the journal's first event.
|
|
67
|
+
return { ok: true, surface: resolved.surface, runId, events: read.events, version: read.events[0]?.version ?? "run-event/1" };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export async function installedRelease(rootDir: string) {
|
|
71
|
+
const document: unknown = JSON.parse(await readFile(path.join(rootDir, ".agent-skills/active.json"), "utf8"));
|
|
72
|
+
const release = (document as { release?: { releaseId?: unknown; profile?: unknown; archiveSha256?: unknown } } | null)?.release;
|
|
73
|
+
if (!release || typeof release.releaseId !== "string" || typeof release.profile !== "string" ||
|
|
74
|
+
typeof release.archiveSha256 !== "string" || !/^[0-9a-f]{64}$/.test(release.archiveSha256)) {
|
|
75
|
+
throw new Error("Installed workflow release identity is missing or malformed.");
|
|
76
|
+
}
|
|
77
|
+
return { runtimeVersion: HARNESS_VERSION, releaseId: release.releaseId, profile: release.profile, archiveSha256: release.archiveSha256 };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export const policyDigest = (context: CommandContext) => digestCanonical({ config: context.config, policyBinding: context.policyBinding ?? null });
|
|
81
|
+
|
|
82
|
+
// One reduction is shared with the atomic journal append validator.
|
|
83
|
+
export { reconciliationActions } from "@agent-delivery-harness/kernel";
|