@agent-delivery-harness/cli 0.2.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 +4 -4
- package/src/boundary.ts +77 -61
- 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 +33 -8
- package/src/commands/maintain.ts +1 -0
- package/src/commands/managed.ts +1 -0
- package/src/commands/prepare.ts +123 -22
- package/src/commands/record.ts +98 -3
- package/src/commands/resume.ts +53 -0
- package/src/commands/review-context.ts +19 -0
- package/src/commands/runs.ts +415 -30
- package/src/commands/save-context.ts +51 -0
- package/src/commands/submit-evidence.ts +46 -7
- package/src/commands/verify.ts +29 -8
- package/src/declared-checks.ts +50 -0
- package/src/index.ts +14 -1
- package/src/main.ts +20 -47
- package/src/ordinary-context.ts +99 -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 +164 -0
- package/src/run-live.ts +102 -0
- package/src/run-projection.ts +129 -11
- package/src/run-server.ts +394 -149
- package/src/run-surface.ts +81 -8
- 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,75 @@
|
|
|
1
|
+
/** Best-effort CLI observations. No caller uses these results for admission. */
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import type { RunEventKind, RunPreparationObservation, WaiverPrompt } from "@agent-delivery-harness/kernel";
|
|
4
|
+
import { buildRunEvent, resolveRunSurface } from "./run-surface.ts";
|
|
5
|
+
|
|
6
|
+
export interface CommandObservation {
|
|
7
|
+
readonly needsCandidate: boolean;
|
|
8
|
+
start(candidateTreeSha: string): Promise<void>;
|
|
9
|
+
prompt(prompt: WaiverPrompt): WaiverPrompt;
|
|
10
|
+
finish(exitCode: number, durationMs: number, digest?: string, preparation?: RunPreparationObservation): Promise<void>;
|
|
11
|
+
}
|
|
12
|
+
const absent: CommandObservation = { needsCandidate: false, start: async () => {}, prompt: prompt => prompt, finish: async () => {} };
|
|
13
|
+
function outcome(code: number) { return code === 0 ? "ok" : code === 2 ? "usage" : code === 130 ? "interrupted" : "policy"; }
|
|
14
|
+
|
|
15
|
+
/** Pin the current run once, so a pointer change cannot split an invocation. */
|
|
16
|
+
export async function beginCommandObservation(cwd: string, command: string): Promise<CommandObservation> {
|
|
17
|
+
try {
|
|
18
|
+
const resolved = await resolveRunSurface(cwd);
|
|
19
|
+
if (!resolved.ok) return absent;
|
|
20
|
+
const { store, commonDir, worktreeKey } = resolved.surface;
|
|
21
|
+
const current = await store.current(worktreeKey);
|
|
22
|
+
if (!current.ok || current.runId === undefined) return absent;
|
|
23
|
+
const runId = current.runId;
|
|
24
|
+
const history = await store.read(runId);
|
|
25
|
+
if (!history.ok) return absent;
|
|
26
|
+
// Capability is selected before emission. Never upgrade an existing writer.
|
|
27
|
+
const version = history.events[0]?.version ?? "run-event/1";
|
|
28
|
+
const attemptId = `command-${randomUUID()}`;
|
|
29
|
+
const activityId = attemptId;
|
|
30
|
+
let candidateTreeSha: string | undefined;
|
|
31
|
+
async function emit(kind: RunEventKind, payload: Record<string, unknown>) {
|
|
32
|
+
try {
|
|
33
|
+
await store.append(runId, buildRunEvent({runId,commonDir,kind,role:"cli",version,
|
|
34
|
+
...(version === "run-event/2" ? {eventId:randomUUID()} : {}),payload}));
|
|
35
|
+
} catch { /* Observations never change the command's result. */ }
|
|
36
|
+
}
|
|
37
|
+
const binding = () => ({activityId,attemptId,candidateTreeSha});
|
|
38
|
+
async function activity(state: string, nextStep: string) {
|
|
39
|
+
if (version !== "run-event/2" || candidateTreeSha === undefined) return;
|
|
40
|
+
await emit("activity.observed", {...binding(),state,owner:"delivery-harness.cli",phase:command,nextStep});
|
|
41
|
+
}
|
|
42
|
+
return {
|
|
43
|
+
needsCandidate: version === "run-event/2",
|
|
44
|
+
async start(tree) {
|
|
45
|
+
candidateTreeSha = tree;
|
|
46
|
+
await activity("running", "Await command outcome; intermediate progress is unavailable unless explicitly observed.");
|
|
47
|
+
},
|
|
48
|
+
prompt(prompt) {
|
|
49
|
+
return async (decision, obligations) => {
|
|
50
|
+
if (version !== "run-event/2" || candidateTreeSha === undefined) return prompt(decision, obligations);
|
|
51
|
+
const waitId = `wait-${randomUUID()}`;
|
|
52
|
+
const scope = "This command invocation and the waiver decision's candidate and obligations only; this observation grants no authority.";
|
|
53
|
+
await activity("waiting", "Respond to the native waiver prompt.");
|
|
54
|
+
await emit("wait.started", {...binding(),waitId,owner:"operator",waitingOn:"human",reason:"The executing command opened its native waiver prompt.",nextAction:"Answer the native prompt.",scope});
|
|
55
|
+
try {
|
|
56
|
+
const answer = await prompt(decision, obligations);
|
|
57
|
+
await emit("wait.resolved", {...binding(),waitId,resolution:answer === false ? "Native prompt declined." : "Native prompt answered; approval remains subject to the gate.",scope});
|
|
58
|
+
return answer;
|
|
59
|
+
} catch (error) {
|
|
60
|
+
await emit("wait.resolved", {...binding(),waitId,resolution:"Native prompt ended without a decision.",scope});
|
|
61
|
+
throw error;
|
|
62
|
+
} finally {
|
|
63
|
+
await activity("running", "Await command outcome.");
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
},
|
|
67
|
+
async finish(code, durationMs, digest, preparation) {
|
|
68
|
+
await activity(code === 0 ? "completed" : code === 130 ? "interrupted" : "failed", `Command returned ${outcome(code)} (exit ${code}); continue the declared delivery workflow.`);
|
|
69
|
+
await emit("command.completed", {command,outcome:outcome(code),durationMs,
|
|
70
|
+
...(code === 0 && digest !== undefined ? {digest} : {}),
|
|
71
|
+
...(version === "run-event/2" && command === "prepare" && code === 0 && preparation !== undefined ? {preparation} : {})});
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
} catch { return absent; }
|
|
75
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `admit` — carry a concluded host review through the existing admission path.
|
|
3
|
+
*
|
|
4
|
+
* The command does not run reviewers and does not construct an approval. The
|
|
5
|
+
* supplied review-outcome/1 document must already name the digest of the exact
|
|
6
|
+
* review-context/1 document the current prepared candidate produces. Rebuilding
|
|
7
|
+
* that deterministic context after preparation lets the existing emitter retain
|
|
8
|
+
* it with the supplied outcome while refusing any candidate, base, policy,
|
|
9
|
+
* wiring, release, or charter drift.
|
|
10
|
+
*/
|
|
11
|
+
import { readFile } from "node:fs/promises";
|
|
12
|
+
import path from "node:path";
|
|
13
|
+
|
|
14
|
+
import { commandBlocker, type CommandContext, type CommandDescriptor, type CommandResult } from "../boundary.ts";
|
|
15
|
+
import { emitReviewEvidence, OutcomeError } from "../review-evidence.ts";
|
|
16
|
+
import { gateCommand } from "./gate.ts";
|
|
17
|
+
import { prepareCommand } from "./prepare.ts";
|
|
18
|
+
import { recordCommand } from "./record.ts";
|
|
19
|
+
import { reviewContextCommand } from "./review-context.ts";
|
|
20
|
+
import { submitEvidenceCommand } from "./submit-evidence.ts";
|
|
21
|
+
|
|
22
|
+
const USAGE = [
|
|
23
|
+
"Usage: delivery-harness admit --outcome <review-outcome.json>",
|
|
24
|
+
"Prepares the candidate, binds the concluded outcome to its exact current review context, submits it, gates it, and writes the tracked record.",
|
|
25
|
+
"The command never runs reviewers or commits the record.",
|
|
26
|
+
].join("\n");
|
|
27
|
+
|
|
28
|
+
function withArgs(context: CommandContext, args: readonly string[]): CommandContext {
|
|
29
|
+
return { ...context, args };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function report(context: CommandContext, label: string, result: CommandResult): CommandResult | undefined {
|
|
33
|
+
if (result.kind !== "ok") return result;
|
|
34
|
+
if (result.summary !== undefined && result.summary !== "") context.write(`${label}: ${result.summary}`);
|
|
35
|
+
return undefined;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function invalidOutcome(message: string): CommandResult {
|
|
39
|
+
return {
|
|
40
|
+
kind: "blocked",
|
|
41
|
+
blockers: [commandBlocker({
|
|
42
|
+
code: "review_outcome_invalid",
|
|
43
|
+
sourceId: "delivery-harness.cli.admit",
|
|
44
|
+
summary: message,
|
|
45
|
+
remediations: [{
|
|
46
|
+
id: "supply-reviewed-current-context",
|
|
47
|
+
kind: "manual_action",
|
|
48
|
+
summary: "Prepare the candidate, retain review-context --json before independent review, and supply the concluded outcome naming that exact digest.",
|
|
49
|
+
}],
|
|
50
|
+
})],
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export const admitCommand: CommandDescriptor = {
|
|
55
|
+
name: "admit",
|
|
56
|
+
sourceId: "delivery-harness.cli.admit",
|
|
57
|
+
summary: "Prepare and admit a concluded review outcome through the tracked record write.",
|
|
58
|
+
usage: USAGE,
|
|
59
|
+
async run(context: CommandContext): Promise<CommandResult> {
|
|
60
|
+
if (context.args.length !== 2 || context.args[0] !== "--outcome" || !context.args[1] || context.args[1].startsWith("-")) {
|
|
61
|
+
return { kind: "usage", message: `admit requires --outcome <review-outcome.json>.\n${USAGE}` };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
let outcome: unknown;
|
|
65
|
+
try {
|
|
66
|
+
outcome = JSON.parse(await readFile(path.resolve(context.rootDir, context.args[1]), "utf8"));
|
|
67
|
+
} catch {
|
|
68
|
+
return { kind: "usage", message: `The review outcome must be a readable JSON document.\n${USAGE}` };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const prepared = await prepareCommand.run(withArgs(context, []));
|
|
72
|
+
const stoppedAfterPrepare = report(context, "prepared", prepared);
|
|
73
|
+
if (stoppedAfterPrepare !== undefined) return stoppedAfterPrepare;
|
|
74
|
+
|
|
75
|
+
const reviewed = await reviewContextCommand.run(withArgs(context, ["--json"]));
|
|
76
|
+
if (reviewed.kind !== "ok") return reviewed;
|
|
77
|
+
if (reviewed.summary === undefined) return invalidOutcome("the prepared candidate produced no review context");
|
|
78
|
+
|
|
79
|
+
let originalContext: unknown;
|
|
80
|
+
try {
|
|
81
|
+
originalContext = JSON.parse(reviewed.summary);
|
|
82
|
+
} catch {
|
|
83
|
+
return invalidOutcome("the prepared candidate produced an unreadable review context");
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
let manifestPath: string;
|
|
87
|
+
try {
|
|
88
|
+
const emitted = await emitReviewEvidence(context, originalContext, outcome);
|
|
89
|
+
manifestPath = emitted.manifestPath;
|
|
90
|
+
} catch (error) {
|
|
91
|
+
if (!(error instanceof OutcomeError)) throw error;
|
|
92
|
+
return invalidOutcome(error.message);
|
|
93
|
+
}
|
|
94
|
+
context.write(`review evidence: ${manifestPath}`);
|
|
95
|
+
|
|
96
|
+
const submitted = await submitEvidenceCommand.run(withArgs(context, ["--manifest", manifestPath]));
|
|
97
|
+
const stoppedAfterSubmission = report(context, "submitted", submitted);
|
|
98
|
+
if (stoppedAfterSubmission !== undefined) return stoppedAfterSubmission;
|
|
99
|
+
|
|
100
|
+
const gated = await gateCommand.run(withArgs(context, []));
|
|
101
|
+
const stoppedAfterGate = report(context, "gate", gated);
|
|
102
|
+
if (stoppedAfterGate !== undefined) return stoppedAfterGate;
|
|
103
|
+
|
|
104
|
+
return recordCommand.run(withArgs(context, []));
|
|
105
|
+
},
|
|
106
|
+
};
|
package/src/commands/check.ts
CHANGED
|
@@ -10,8 +10,9 @@
|
|
|
10
10
|
* behind. An unresolvable or unwritable store is a typed block, never a crash.
|
|
11
11
|
*/
|
|
12
12
|
import path from "node:path";
|
|
13
|
-
import { resolveRecordStorage, BlockedError } from "@agent-delivery-harness/kernel";
|
|
13
|
+
import { deliveryRecordPathFor, resolveRecordStorage, BlockedError } from "@agent-delivery-harness/kernel";
|
|
14
14
|
import type { CommandContext, CommandDescriptor, CommandResult } from "../boundary.ts";
|
|
15
|
+
import { oneLine } from "../run-surface.ts";
|
|
15
16
|
|
|
16
17
|
/** Named so an interrupted run leaves something obviously disposable. */
|
|
17
18
|
const PROBE_FILE = ".delivery-harness-write-probe";
|
|
@@ -20,19 +21,29 @@ export const checkCommand: CommandDescriptor = {
|
|
|
20
21
|
name: "check",
|
|
21
22
|
sourceId: "delivery-harness.cli.check",
|
|
22
23
|
summary: "Confirm the config loads and the evidence store is usable.",
|
|
24
|
+
usage: "Usage: delivery-harness check\nTakes no arguments.",
|
|
23
25
|
async run(context: CommandContext): Promise<CommandResult> {
|
|
26
|
+
const unexpected = context.args[0];
|
|
27
|
+
if (unexpected !== undefined) {
|
|
28
|
+
return { kind: "usage", message: `check takes no arguments, and ${oneLine(unexpected, 64)} is one.\n${checkCommand.usage}` };
|
|
29
|
+
}
|
|
24
30
|
try {
|
|
25
31
|
const storage = await resolveRecordStorage(context.rootDir, { storageNamespace: context.config.storageNamespace });
|
|
26
32
|
const probe = path.join(storage.storageDir, PROBE_FILE);
|
|
27
33
|
await context.artifacts.writeTextFile(probe, "probe\n", { mode: 0o600 });
|
|
28
34
|
await context.artifacts.removeFile(probe);
|
|
35
|
+
const recordPath = deliveryRecordPathFor(context.config, "<deliverableDigest>");
|
|
36
|
+
const baseMovement = context.config.deliveryRecordVerification.baseMovement === "stale"
|
|
37
|
+
? "base movement stale: verification refuses base-ref, base-tip, or merge-base drift; admit again against the current base"
|
|
38
|
+
: "base movement allow: verification may accept base drift and names every relaxed drift class";
|
|
29
39
|
return {
|
|
30
40
|
kind: "ok",
|
|
31
41
|
summary: [
|
|
32
42
|
`ok: gate ${context.config.gateId}`,
|
|
33
43
|
` ${context.config.obligations.length} obligation(s), ${context.config.providers.length} provider(s)`,
|
|
34
44
|
` store ${storage.storageDir} (writable)`,
|
|
35
|
-
` delivery record path ${
|
|
45
|
+
` delivery record path ${recordPath}`,
|
|
46
|
+
` ${baseMovement}`,
|
|
36
47
|
].join("\n"),
|
|
37
48
|
};
|
|
38
49
|
} catch (error) {
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { commandBlocker, type CommandDescriptor } from "../boundary.ts";
|
|
4
|
+
import { emitReviewEvidence, OutcomeError } from "../review-evidence.ts";
|
|
5
|
+
|
|
6
|
+
export const emitReviewEvidenceCommand: CommandDescriptor = {
|
|
7
|
+
name: "emit-review-evidence",
|
|
8
|
+
sourceId: "delivery-harness.cli.emit-review-evidence",
|
|
9
|
+
summary: "Bind concluded review outcomes to their original prepared context.",
|
|
10
|
+
usage: "Usage: delivery-harness emit-review-evidence --context <review-context.json>\nReads a review-outcome/1 document on stdin.",
|
|
11
|
+
async run(context) {
|
|
12
|
+
if (context.args.length !== 2 || context.args[0] !== "--context" || !context.args[1] || context.args[1].startsWith("-")) {
|
|
13
|
+
return { kind: "usage", message: "emit-review-evidence requires --context <review-context.json> and a review-outcome/1 document on stdin." };
|
|
14
|
+
}
|
|
15
|
+
let original: unknown;
|
|
16
|
+
let document: unknown;
|
|
17
|
+
try {
|
|
18
|
+
original = JSON.parse(await readFile(path.resolve(context.rootDir, context.args[1]), "utf8"));
|
|
19
|
+
const raw = await context.readStdin?.() ?? "";
|
|
20
|
+
if (!raw.trim()) return { kind: "usage", message: "The review-outcome/1 document is required on stdin." };
|
|
21
|
+
document = JSON.parse(raw);
|
|
22
|
+
} catch {
|
|
23
|
+
return { kind: "usage", message: "The original context and review outcome must be readable JSON documents." };
|
|
24
|
+
}
|
|
25
|
+
try {
|
|
26
|
+
const result = await emitReviewEvidence(context, original, document);
|
|
27
|
+
return { kind: "ok", summary: result.manifestPath };
|
|
28
|
+
} catch (error) {
|
|
29
|
+
if (!(error instanceof OutcomeError)) throw error;
|
|
30
|
+
return { kind: "blocked", blockers: [commandBlocker({
|
|
31
|
+
code: "review_outcome_invalid",
|
|
32
|
+
sourceId: "delivery-harness.cli.emit-review-evidence",
|
|
33
|
+
summary: error.message,
|
|
34
|
+
remediations: [{ id: "supply-reviewed-context", kind: "manual_action", summary: "Use the original review-context --json output and concluded outcomes naming its digest; acquire a new review if the deliverable or review inputs changed." }],
|
|
35
|
+
})] };
|
|
36
|
+
}
|
|
37
|
+
},
|
|
38
|
+
};
|
package/src/commands/emit.ts
CHANGED
|
@@ -41,13 +41,18 @@ import {
|
|
|
41
41
|
} from "../run-surface.ts";
|
|
42
42
|
import type { CommandResult, ConfigFreeCommandContext, ConfigFreeCommandDescriptor } from "../boundary.ts";
|
|
43
43
|
|
|
44
|
-
const USAGE = "Usage: delivery-harness emit <kind> [--run <id>] [--json <payload>] [--force]";
|
|
44
|
+
const USAGE = "Usage: delivery-harness emit <kind> [--run <id>] [--json <payload>] [--force] [--version 1|2] [--event-id <id>]";
|
|
45
|
+
|
|
46
|
+
const PAYLOAD_USAGE =
|
|
47
|
+
"emit needs a JSON payload: use --json <payload>, or omit --json only when piping JSON to stdin until EOF.";
|
|
45
48
|
|
|
46
49
|
interface ParsedArgs {
|
|
47
50
|
readonly kind: string;
|
|
48
51
|
readonly run?: string;
|
|
49
52
|
readonly json?: string;
|
|
50
53
|
readonly force: boolean;
|
|
54
|
+
readonly version?: "run-event/1" | "run-event/2";
|
|
55
|
+
readonly eventId?: string;
|
|
51
56
|
}
|
|
52
57
|
|
|
53
58
|
type ArgParse = { readonly ok: true; readonly args: ParsedArgs } | { readonly ok: false; readonly message: string };
|
|
@@ -57,6 +62,8 @@ function parseArgs(args: readonly string[]): ArgParse {
|
|
|
57
62
|
let run: string | undefined;
|
|
58
63
|
let json: string | undefined;
|
|
59
64
|
let force = false;
|
|
65
|
+
let version: "run-event/1" | "run-event/2" | undefined;
|
|
66
|
+
let eventId: string | undefined;
|
|
60
67
|
|
|
61
68
|
for (let index = 0; index < args.length; index += 1) {
|
|
62
69
|
const token = args[index]!;
|
|
@@ -64,11 +71,16 @@ function parseArgs(args: readonly string[]): ArgParse {
|
|
|
64
71
|
force = true;
|
|
65
72
|
continue;
|
|
66
73
|
}
|
|
67
|
-
if (token === "--run" || token === "--json") {
|
|
74
|
+
if (token === "--run" || token === "--json" || token === "--version" || token === "--event-id") {
|
|
68
75
|
const value = args[index + 1];
|
|
69
76
|
if (value === undefined) return { ok: false, message: `${token} needs a value.\n${USAGE}` };
|
|
70
77
|
if (token === "--run") run = value;
|
|
71
|
-
else json = value;
|
|
78
|
+
else if (token === "--json") json = value;
|
|
79
|
+
else if (token === "--event-id") eventId = value;
|
|
80
|
+
else {
|
|
81
|
+
if (value !== "1" && value !== "2") return { ok: false, message: `--version must be 1 or 2.\n${USAGE}` };
|
|
82
|
+
version = value === "1" ? "run-event/1" : "run-event/2";
|
|
83
|
+
}
|
|
72
84
|
index += 1;
|
|
73
85
|
continue;
|
|
74
86
|
}
|
|
@@ -78,7 +90,8 @@ function parseArgs(args: readonly string[]): ArgParse {
|
|
|
78
90
|
}
|
|
79
91
|
|
|
80
92
|
if (kind === undefined) return { ok: false, message: `emit needs a kind.\n${USAGE}` };
|
|
81
|
-
return { ok: true, args: { kind, force, ...(run === undefined ? {} : { run }), ...(json === undefined ? {} : { json })
|
|
93
|
+
return { ok: true, args: { kind, force, ...(run === undefined ? {} : { run }), ...(json === undefined ? {} : { json }),
|
|
94
|
+
...(version === undefined ? {} : { version }), ...(eventId === undefined ? {} : { eventId }) } };
|
|
82
95
|
}
|
|
83
96
|
|
|
84
97
|
/**
|
|
@@ -96,6 +109,18 @@ function parsePayload(text: string): unknown {
|
|
|
96
109
|
}
|
|
97
110
|
}
|
|
98
111
|
|
|
112
|
+
async function suppliedPayload(context: ConfigFreeCommandContext, json: string | undefined): Promise<
|
|
113
|
+
{ readonly ok: true; readonly payload: unknown } | { readonly ok: false; readonly message: string }
|
|
114
|
+
> {
|
|
115
|
+
if (json !== undefined) return { ok: true, payload: parsePayload(json) };
|
|
116
|
+
if (context.stdinIsTTY === true) {
|
|
117
|
+
return { ok: false, message: `${PAYLOAD_USAGE}\nstdin is interactive, so emit will not wait for terminal EOF.\n${USAGE}` };
|
|
118
|
+
}
|
|
119
|
+
const text = await context.readStdin();
|
|
120
|
+
if (text.trim() === "") return { ok: false, message: `${PAYLOAD_USAGE}\n${USAGE}` };
|
|
121
|
+
return { ok: true, payload: parsePayload(text) };
|
|
122
|
+
}
|
|
123
|
+
|
|
99
124
|
const noRun = (details: string) =>
|
|
100
125
|
runSurfaceBlocker({
|
|
101
126
|
code: "run_unresolvable",
|
|
@@ -127,6 +152,7 @@ export const emitCommand: ConfigFreeCommandDescriptor = {
|
|
|
127
152
|
name: "emit",
|
|
128
153
|
sourceId: "delivery-harness.cli.emit",
|
|
129
154
|
summary: "Append one run event to the current delivery run's journal.",
|
|
155
|
+
usage: USAGE,
|
|
130
156
|
configFree: true,
|
|
131
157
|
async run(context: ConfigFreeCommandContext): Promise<CommandResult> {
|
|
132
158
|
const parsed = parseArgs(context.args);
|
|
@@ -154,7 +180,18 @@ export const emitCommand: ConfigFreeCommandDescriptor = {
|
|
|
154
180
|
const store = surface.store;
|
|
155
181
|
|
|
156
182
|
if (kind === "run.started") {
|
|
157
|
-
|
|
183
|
+
const version = parsed.args.version ?? "run-event/1";
|
|
184
|
+
if ((version === "run-event/2") !== (parsed.args.eventId !== undefined)) {
|
|
185
|
+
return {
|
|
186
|
+
kind: "usage",
|
|
187
|
+
message: version === "run-event/2"
|
|
188
|
+
? `run.started is creating a version 2 run; every emit needs --event-id.\n${USAGE}`
|
|
189
|
+
: `run.started is creating a version 1 run and does not accept --event-id; drop it.\n${USAGE}`,
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
const supplied = await suppliedPayload(context, parsed.args.json);
|
|
193
|
+
if (!supplied.ok) return { kind: "usage", message: supplied.message };
|
|
194
|
+
return startRun(surface, force, supplied.payload, version, parsed.args.eventId);
|
|
158
195
|
}
|
|
159
196
|
|
|
160
197
|
const runId = await resolveRun(store, surface, named);
|
|
@@ -171,8 +208,25 @@ export const emitCommand: ConfigFreeCommandDescriptor = {
|
|
|
171
208
|
};
|
|
172
209
|
}
|
|
173
210
|
|
|
174
|
-
const
|
|
175
|
-
|
|
211
|
+
const history = await store.read(runId);
|
|
212
|
+
if (!history.ok) return { kind: "blocked", blockers: [noRun("the selected run is unreadable")] };
|
|
213
|
+
const version = history.events[0]?.version ?? "run-event/1";
|
|
214
|
+
if (parsed.args.version !== undefined && parsed.args.version !== version) {
|
|
215
|
+
return { kind: "usage", message: "A run's writer version cannot change. End it and start an explicitly linked successor run." };
|
|
216
|
+
}
|
|
217
|
+
if ((version === "run-event/2") !== (parsed.args.eventId !== undefined)) {
|
|
218
|
+
return {
|
|
219
|
+
kind: "usage",
|
|
220
|
+
message: version === "run-event/2"
|
|
221
|
+
? `run ${runId} is version 2; every emit needs --event-id.\n${USAGE}`
|
|
222
|
+
: `run ${runId} is version 1 and does not accept --event-id; drop it, and drop the version-2 payload members roundId, bound, grace and reopensRoundId.\n${USAGE}`,
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
const supplied = await suppliedPayload(context, parsed.args.json);
|
|
226
|
+
if (!supplied.ok) return { kind: "usage", message: supplied.message };
|
|
227
|
+
const payload = supplied.payload;
|
|
228
|
+
const event = buildRunEvent({ runId, commonDir: surface.commonDir, kind, role: "executor", payload, version,
|
|
229
|
+
...(parsed.args.eventId === undefined ? {} : { eventId: parsed.args.eventId }) });
|
|
176
230
|
|
|
177
231
|
// `command.completed` is the CLI's to write. The refusal is recorded in the
|
|
178
232
|
// run's note through the store's one bounded note writer, so an attempt
|
|
@@ -199,7 +253,9 @@ export const emitCommand: ConfigFreeCommandDescriptor = {
|
|
|
199
253
|
};
|
|
200
254
|
}
|
|
201
255
|
|
|
202
|
-
|
|
256
|
+
// Resolve generated timestamps inside the append lock so concurrent retries
|
|
257
|
+
// retain the first observation instant while changed content still refuses.
|
|
258
|
+
const appended = await store.append(runId, event, { reuseExistingTimestamp: true });
|
|
203
259
|
if (!appended.ok) {
|
|
204
260
|
const first = appended.rejections[0];
|
|
205
261
|
return {
|
|
@@ -214,7 +270,7 @@ export const emitCommand: ConfigFreeCommandDescriptor = {
|
|
|
214
270
|
details: `run ${runId}: ${first === undefined ? "the store refused the append" : `${first.code} at ${oneLine(first.pointer, 64) || "/"}: ${oneLine(first.message, 200)}`}`,
|
|
215
271
|
remediation: {
|
|
216
272
|
id: "correct-the-event",
|
|
217
|
-
summary:
|
|
273
|
+
summary: `Correct the kind or the payload against the ${version} contract and emit again.`,
|
|
218
274
|
},
|
|
219
275
|
}),
|
|
220
276
|
],
|
|
@@ -234,8 +290,17 @@ export const emitCommand: ConfigFreeCommandDescriptor = {
|
|
|
234
290
|
* journal behind. `--force` carries the displaced run's id into the new run's
|
|
235
291
|
* own `run.started` payload, which is what makes a restarted delivery legible
|
|
236
292
|
* afterwards.
|
|
293
|
+
*
|
|
294
|
+
* THE TWO REFUSALS THAT COME AFTER ALLOCATION are the append and the exclusive
|
|
295
|
+
* pointer write, and each takes the journal back through `discard`. Without
|
|
296
|
+
* that, a payload the store refuses leaves an empty journal per retry, and the
|
|
297
|
+
* loser of a pointer race leaves a journal carrying a real `run.started` —
|
|
298
|
+
* both of which `runs list` shows as open runs an operator cannot tell from an
|
|
299
|
+
* abandoned delivery. The removal is best-effort by construction: this command
|
|
300
|
+
* has already decided to refuse, and a store failure may not change that.
|
|
237
301
|
*/
|
|
238
|
-
async function startRun(surface: RunSurface, force: boolean, supplied: unknown
|
|
302
|
+
async function startRun(surface: RunSurface, force: boolean, supplied: unknown,
|
|
303
|
+
version: "run-event/1" | "run-event/2", eventId?: string): Promise<CommandResult> {
|
|
239
304
|
const store = surface.store;
|
|
240
305
|
const existing = await store.current(surface.worktreeKey);
|
|
241
306
|
const displaced = existing.ok ? existing.runId : undefined;
|
|
@@ -279,10 +344,12 @@ async function startRun(surface: RunSurface, force: boolean, supplied: unknown):
|
|
|
279
344
|
|
|
280
345
|
const appended = await store.append(
|
|
281
346
|
runId,
|
|
282
|
-
buildRunEvent({ runId, commonDir: surface.commonDir, kind: "run.started", role: "executor", payload
|
|
347
|
+
buildRunEvent({ runId, commonDir: surface.commonDir, kind: "run.started", role: "executor", payload, version,
|
|
348
|
+
...(eventId === undefined ? {} : { eventId }) }),
|
|
283
349
|
);
|
|
284
350
|
if (!appended.ok) {
|
|
285
351
|
const first = appended.rejections[0];
|
|
352
|
+
await store.discard(runId);
|
|
286
353
|
return {
|
|
287
354
|
kind: "blocked",
|
|
288
355
|
blockers: [
|
|
@@ -292,7 +359,7 @@ async function startRun(surface: RunSurface, force: boolean, supplied: unknown):
|
|
|
292
359
|
details: `run ${runId}: ${first === undefined ? "the store refused the append" : `${first.code} at ${oneLine(first.pointer, 64) || "/"}: ${oneLine(first.message, 200)}`}`,
|
|
293
360
|
remediation: {
|
|
294
361
|
id: "correct-the-event",
|
|
295
|
-
summary:
|
|
362
|
+
summary: `Correct the payload against the ${version} contract and emit again.`,
|
|
296
363
|
},
|
|
297
364
|
}),
|
|
298
365
|
],
|
|
@@ -317,6 +384,7 @@ async function startRun(surface: RunSurface, force: boolean, supplied: unknown):
|
|
|
317
384
|
}
|
|
318
385
|
}
|
|
319
386
|
if (!pointed.ok) {
|
|
387
|
+
await store.discard(runId);
|
|
320
388
|
return {
|
|
321
389
|
kind: "blocked",
|
|
322
390
|
blockers: [
|
package/src/commands/gate.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { runDeclaredCheck } from "../declared-checks.ts";
|
|
1
2
|
/**
|
|
2
3
|
* `gate` — evaluate the delivery gate and, under a TTY, offer a scoped waiver.
|
|
3
4
|
*
|
|
@@ -9,8 +10,9 @@
|
|
|
9
10
|
* only ever offers a waiver to a `human` context, all-or-nothing over waivable
|
|
10
11
|
* findings; the CLI adds no waiver logic of its own.
|
|
11
12
|
*/
|
|
12
|
-
import { runAdmission, type AdmissionResult, type Blocker, type LiveProviderResult } from "@agent-delivery-harness/kernel";
|
|
13
|
+
import { computeDeliverableIdentity, runAdmission, type AdmissionResult, type Blocker, type LiveProviderResult } from "@agent-delivery-harness/kernel";
|
|
13
14
|
import { CliInterruption, type CommandContext, type CommandDescriptor, type CommandResult } from "../boundary.ts";
|
|
15
|
+
import { oneLine } from "../run-surface.ts";
|
|
14
16
|
|
|
15
17
|
/**
|
|
16
18
|
* Runs the ordinary admission first, invokes only configured providers that can
|
|
@@ -21,7 +23,7 @@ import { CliInterruption, type CommandContext, type CommandDescriptor, type Comm
|
|
|
21
23
|
export async function runProviderBackedAdmission(
|
|
22
24
|
context: CommandContext,
|
|
23
25
|
options: { readonly allowPrompt: boolean; readonly includeInjectedLiveResults: boolean },
|
|
24
|
-
): Promise<AdmissionResult> {
|
|
26
|
+
): Promise<AdmissionResult & { readonly observedLiveResults?: readonly LiveProviderResult[] }> {
|
|
25
27
|
const wiring = await context.wire();
|
|
26
28
|
const admissionOptions = {
|
|
27
29
|
captureCandidate: wiring.captureCandidate,
|
|
@@ -39,7 +41,7 @@ export async function runProviderBackedAdmission(
|
|
|
39
41
|
...(options.allowPrompt && context.promptForWaiver !== undefined ? { promptForWaiver: context.promptForWaiver } : {}),
|
|
40
42
|
};
|
|
41
43
|
|
|
42
|
-
if (!context.config.providers.some((provider) => provider.command !== undefined)) {
|
|
44
|
+
if (!context.config.providers.some((provider) => provider.command !== undefined || provider.check !== undefined)) {
|
|
43
45
|
return runAdmission(input, finalAdmissionOptions);
|
|
44
46
|
}
|
|
45
47
|
|
|
@@ -58,7 +60,8 @@ export async function runProviderBackedAdmission(
|
|
|
58
60
|
for (const finding of resolution.providerFindings ?? []) {
|
|
59
61
|
if (finding.code !== missingCode || finding.providerId === undefined || attempted.has(finding.providerId)) continue;
|
|
60
62
|
const registration = context.config.providers.find((provider) => provider.id === finding.providerId);
|
|
61
|
-
if (registration
|
|
63
|
+
if (registration === undefined || (registration.command === undefined && registration.check === undefined) || !obligation.providers.includes(registration.id)) continue;
|
|
64
|
+
if (registration.check !== undefined && (obligation.freshness !== "exact_candidate" || !obligation.acceptedPayloadSpecs.includes("checks.passed/1") || admission.decision.resolutions.some(resolution => resolution.kind !== "satisfied_evidence" && resolution.kind !== "not_applicable" && resolution.kind !== "waived" && context.config.obligations.find(entry => entry.id === resolution.obligationId)?.acceptedPayloadSpecs.includes("review.green/1")))) continue;
|
|
62
65
|
const entry = requested.get(registration.id) ?? { obligationIds: [], requiresEvidence: false, needsLiveResult: false };
|
|
63
66
|
entry.obligationIds.push(obligation.id);
|
|
64
67
|
entry.requiresEvidence ||= obligation.freshness === "exact_candidate";
|
|
@@ -73,6 +76,12 @@ export async function runProviderBackedAdmission(
|
|
|
73
76
|
if (next === undefined) break;
|
|
74
77
|
const [providerId, request] = next;
|
|
75
78
|
attempted.add(providerId);
|
|
79
|
+
const registration = context.config.providers.find(provider => provider.id === providerId)!;
|
|
80
|
+
if (registration.check !== undefined) {
|
|
81
|
+
attemptBlockers.push(...await runDeclaredCheck(context, registration, request.obligationIds, admission.candidate));
|
|
82
|
+
admission = await runAdmission({ ...input, ...(liveResults.length === 0 ? {} : { liveResults }) }, admissionOptions);
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
76
85
|
const result = await context.invokeProvider?.({
|
|
77
86
|
providerId,
|
|
78
87
|
requiresEvidence: request.requiresEvidence,
|
|
@@ -100,21 +109,28 @@ export async function runProviderBackedAdmission(
|
|
|
100
109
|
);
|
|
101
110
|
}
|
|
102
111
|
|
|
103
|
-
if (admission.admitted) return admission;
|
|
112
|
+
if (admission.admitted) return { ...admission, observedLiveResults: liveResults };
|
|
104
113
|
const final = await runAdmission(
|
|
105
114
|
{ ...input, ...(liveResults.length === 0 ? {} : { liveResults }) },
|
|
106
115
|
finalAdmissionOptions,
|
|
107
116
|
);
|
|
108
117
|
return attemptBlockers.length === 0 || final.admitted
|
|
109
|
-
? final
|
|
110
|
-
: { ...final, blockers: [...attemptBlockers, ...final.blockers] };
|
|
118
|
+
? { ...final, observedLiveResults: liveResults }
|
|
119
|
+
: { ...final, observedLiveResults: liveResults, blockers: [...attemptBlockers, ...final.blockers] };
|
|
111
120
|
}
|
|
112
121
|
|
|
113
122
|
export const gateCommand: CommandDescriptor = {
|
|
114
123
|
name: "gate",
|
|
115
124
|
sourceId: "delivery-harness.cli.gate",
|
|
116
125
|
summary: "Evaluate the delivery gate for the current candidate.",
|
|
126
|
+
usage: "Usage: delivery-harness gate\nTakes no arguments; a waiver is offered only under a real TTY.",
|
|
117
127
|
async run(context: CommandContext): Promise<CommandResult> {
|
|
128
|
+
// Arguments before admission: `gate` accepts none, and a call it cannot
|
|
129
|
+
// honour is answered about the call rather than performed approximately.
|
|
130
|
+
const unexpected = context.args[0];
|
|
131
|
+
if (unexpected !== undefined) {
|
|
132
|
+
return { kind: "usage", message: `gate takes no arguments, and ${oneLine(unexpected, 64)} is one.\n${gateCommand.usage}` };
|
|
133
|
+
}
|
|
118
134
|
const result = await runProviderBackedAdmission(context, { allowPrompt: true, includeInjectedLiveResults: true });
|
|
119
135
|
|
|
120
136
|
if (result.admitted) {
|
|
@@ -123,7 +139,16 @@ export const gateCommand: CommandDescriptor = {
|
|
|
123
139
|
? ` (waived: ${result.waivedObligationIds.join(", ")})`
|
|
124
140
|
: "";
|
|
125
141
|
const kinds = (result.decision?.resolutions ?? []).map((resolution) => `${resolution.obligationId}=${resolution.kind}`);
|
|
126
|
-
|
|
142
|
+
// This observation describes the admitted immutable tree. Failure to
|
|
143
|
+
// observe it cannot change admission; the run journal is not evidence.
|
|
144
|
+
let digest: string | undefined;
|
|
145
|
+
if (result.candidate !== undefined) {
|
|
146
|
+
try {
|
|
147
|
+
digest = await computeDeliverableIdentity({ rootDir: context.rootDir, treeSha: result.candidate.treeSha,
|
|
148
|
+
config: { ...context.config, computingIdentityVersion: "validation-tree/v1", reviewNeutral: context.config.recordNeutral } });
|
|
149
|
+
} catch { /* Best-effort observability, like the completion append. */ }
|
|
150
|
+
}
|
|
151
|
+
return { kind: "ok", summary: `admitted${waiverNote}: ${kinds.join(", ")}`, ...(digest === undefined ? {} : { digest }) };
|
|
127
152
|
}
|
|
128
153
|
return { kind: "blocked", blockers: [...result.blockers] };
|
|
129
154
|
},
|
package/src/commands/maintain.ts
CHANGED
|
@@ -129,6 +129,7 @@ export const maintainCommand: CommandDescriptor = {
|
|
|
129
129
|
name: "maintain",
|
|
130
130
|
sourceId: SOURCE_ID,
|
|
131
131
|
summary: "Maintain the product installation (update, rollback, trust-state pin/revoke/unrevoke/high-water-mark).",
|
|
132
|
+
usage: `Usage: delivery-harness maintain <operation> [options]\nOperations: ${MAINTAIN_OPERATIONS.join(" | ")}`,
|
|
132
133
|
async run(context: CommandContext): Promise<CommandResult> {
|
|
133
134
|
const [operation, ...rest] = context.args;
|
|
134
135
|
if (operation === undefined) {
|
package/src/commands/managed.ts
CHANGED
|
@@ -228,6 +228,7 @@ export const managedCommand: CommandDescriptor = {
|
|
|
228
228
|
name: "managed",
|
|
229
229
|
sourceId: SOURCE_ID,
|
|
230
230
|
summary: "Drive the managed delivery's next checkpoint (status, stages, sensor, review, admission, record, finish).",
|
|
231
|
+
usage: `Usage: delivery-harness managed <operation> [options]\nOperations: ${MANAGED_OPERATIONS.join(" | ")}`,
|
|
231
232
|
async run(context: CommandContext): Promise<CommandResult> {
|
|
232
233
|
const [operation, ...rest] = context.args;
|
|
233
234
|
if (operation === undefined) {
|