@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.
@@ -0,0 +1,351 @@
1
+ /**
2
+ * `emit` — append one run event to the current delivery run's journal.
3
+ *
4
+ * THE EXECUTOR'S ONE WRITE PATH. Everything an executor knows that the product
5
+ * cannot observe — which lenses it chose and why, what it decided at a fork,
6
+ * what a review round cost — arrives here or is lost to a transcript. The
7
+ * command is deliberately small: a kind, a JSON payload, and the store.
8
+ *
9
+ * WHAT `emit` MAY NOT SET. Not `seq`, which is the store's, taken inside its
10
+ * critical section. Not `at`, which is this process's own instant. Not
11
+ * `actor`, which is always `executor` here — the boundary wrap is the only
12
+ * writer of `cli`, and the store refuses `cli` on any other kind. And not
13
+ * `command.completed` at all: a completion is a claim that the product ran a
14
+ * command, and only the product may make it. That refusal is noted in the
15
+ * run's note exactly like any other refused append, so an executor that tries
16
+ * leaves a trace rather than nothing.
17
+ *
18
+ * ORDER OF OPERATIONS, AND WHY IT IS THIS ORDER.
19
+ * 1. Arguments. A malformed invocation is a usage error and touches nothing.
20
+ * 2. The repository. Outside one there is no store, so there is nowhere to
21
+ * write and nowhere to note.
22
+ * 3. The run. Resolution precedes kind validation, because a refused append
23
+ * is recorded against a run — with no run there is nothing to note
24
+ * against, and inventing one would create a journal for a typo.
25
+ * `run.started` is the single exception: it allocates the run it needs.
26
+ * 4. The store. Kind and payload validation, the secret discipline, and the
27
+ * note on refusal are all the store's, so there is exactly one
28
+ * implementation of each.
29
+ */
30
+ import {
31
+ RUN_STORE_ID,
32
+ reduceToProviderId,
33
+ type RunStore,
34
+ } from "@agent-delivery-harness/kernel";
35
+ import {
36
+ buildRunEvent,
37
+ oneLine,
38
+ resolveRunSurface,
39
+ runSurfaceBlocker,
40
+ type RunSurface,
41
+ } from "../run-surface.ts";
42
+ import type { CommandResult, ConfigFreeCommandContext, ConfigFreeCommandDescriptor } from "../boundary.ts";
43
+
44
+ const USAGE = "Usage: delivery-harness emit <kind> [--run <id>] [--json <payload>] [--force]";
45
+
46
+ interface ParsedArgs {
47
+ readonly kind: string;
48
+ readonly run?: string;
49
+ readonly json?: string;
50
+ readonly force: boolean;
51
+ }
52
+
53
+ type ArgParse = { readonly ok: true; readonly args: ParsedArgs } | { readonly ok: false; readonly message: string };
54
+
55
+ function parseArgs(args: readonly string[]): ArgParse {
56
+ let kind: string | undefined;
57
+ let run: string | undefined;
58
+ let json: string | undefined;
59
+ let force = false;
60
+
61
+ for (let index = 0; index < args.length; index += 1) {
62
+ const token = args[index]!;
63
+ if (token === "--force") {
64
+ force = true;
65
+ continue;
66
+ }
67
+ if (token === "--run" || token === "--json") {
68
+ const value = args[index + 1];
69
+ if (value === undefined) return { ok: false, message: `${token} needs a value.\n${USAGE}` };
70
+ if (token === "--run") run = value;
71
+ else json = value;
72
+ index += 1;
73
+ continue;
74
+ }
75
+ if (token.startsWith("--")) return { ok: false, message: `Unknown flag ${oneLine(token, 64)}.\n${USAGE}` };
76
+ if (kind !== undefined) return { ok: false, message: `emit takes one kind.\n${USAGE}` };
77
+ kind = token;
78
+ }
79
+
80
+ 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 }) } };
82
+ }
83
+
84
+ /**
85
+ * The payload as the caller supplied it. Text that is not JSON is passed
86
+ * through as-is rather than rejected here: the store owns payload validation,
87
+ * and routing it there is what puts the refusal in the run's note.
88
+ */
89
+ function parsePayload(text: string): unknown {
90
+ const trimmed = text.trim();
91
+ if (trimmed.length === 0) return {};
92
+ try {
93
+ return JSON.parse(trimmed) as unknown;
94
+ } catch {
95
+ return trimmed;
96
+ }
97
+ }
98
+
99
+ const noRun = (details: string) =>
100
+ runSurfaceBlocker({
101
+ code: "run_unresolvable",
102
+ summary: "There is no current run to emit against.",
103
+ details,
104
+ remediation: {
105
+ id: "start-a-run",
106
+ summary: "Run `delivery-harness emit run.started` in this worktree, or name an existing run with --run.",
107
+ },
108
+ });
109
+
110
+ /**
111
+ * Resolves the run this event belongs to: `--run` when given, else the
112
+ * invoking worktree's pointer. An explicit `--run` resolves only when the id
113
+ * passes the charset check AND its journal exists, so no arbitrary id ever
114
+ * causes a journal or a note to be created.
115
+ */
116
+ async function resolveRun(store: RunStore, surface: RunSurface, named: string | undefined): Promise<string | undefined> {
117
+ if (named !== undefined) {
118
+ if (named.length > 128 || !RUN_STORE_ID.test(named)) return undefined;
119
+ const read = await store.read(named);
120
+ return read.ok ? named : undefined;
121
+ }
122
+ const current = await store.current(surface.worktreeKey);
123
+ return current.ok ? current.runId : undefined;
124
+ }
125
+
126
+ export const emitCommand: ConfigFreeCommandDescriptor = {
127
+ name: "emit",
128
+ sourceId: "delivery-harness.cli.emit",
129
+ summary: "Append one run event to the current delivery run's journal.",
130
+ configFree: true,
131
+ async run(context: ConfigFreeCommandContext): Promise<CommandResult> {
132
+ const parsed = parseArgs(context.args);
133
+ if (!parsed.ok) return { kind: "usage", message: parsed.message };
134
+ const { kind, run: named, force } = parsed.args;
135
+
136
+ const resolved = await resolveRunSurface(context.rootDir);
137
+ if (!resolved.ok) {
138
+ return {
139
+ kind: "blocked",
140
+ blockers: [
141
+ runSurfaceBlocker({
142
+ code: "run_store_unresolvable",
143
+ summary: "The run store could not be resolved.",
144
+ details: oneLine(resolved.reason, 200),
145
+ remediation: {
146
+ id: "run-inside-a-repository",
147
+ summary: "Run this command inside a git repository; the run store lives under its common directory.",
148
+ },
149
+ }),
150
+ ],
151
+ };
152
+ }
153
+ const surface = resolved.surface;
154
+ const store = surface.store;
155
+
156
+ if (kind === "run.started") {
157
+ return startRun(surface, force, parsePayload(parsed.args.json ?? (await context.readStdin())));
158
+ }
159
+
160
+ const runId = await resolveRun(store, surface, named);
161
+ if (runId === undefined) {
162
+ return {
163
+ kind: "blocked",
164
+ blockers: [
165
+ noRun(
166
+ named === undefined
167
+ ? "no current run is pointed at from this worktree, and no --run was given"
168
+ : `--run ${oneLine(named, 128)} names no readable journal in this store`,
169
+ ),
170
+ ],
171
+ };
172
+ }
173
+
174
+ const payload = parsePayload(parsed.args.json ?? (await context.readStdin()));
175
+ const event = buildRunEvent({ runId, commonDir: surface.commonDir, kind, role: "executor", payload });
176
+
177
+ // `command.completed` is the CLI's to write. The refusal is recorded in the
178
+ // run's note through the store's one bounded note writer, so an attempt
179
+ // leaves the same trace as any other refused append.
180
+ if (kind === "command.completed") {
181
+ await store.noteRefusal(runId, event, {
182
+ code: "unsupported_combination",
183
+ pointer: "/kind",
184
+ message: "command.completed is written by the CLI; emit may not write it",
185
+ });
186
+ return {
187
+ kind: "blocked",
188
+ blockers: [
189
+ runSurfaceBlocker({
190
+ code: "run_event_refused",
191
+ summary: "command.completed is written by the CLI, never by emit.",
192
+ details: `run ${runId}: the product's own commands append their completions; an executor reports a non-product gate with gate.reported`,
193
+ remediation: {
194
+ id: "use-gate-reported",
195
+ summary: "Emit gate.reported when the repository's gate is not a product command.",
196
+ },
197
+ }),
198
+ ],
199
+ };
200
+ }
201
+
202
+ const appended = await store.append(runId, event);
203
+ if (!appended.ok) {
204
+ const first = appended.rejections[0];
205
+ return {
206
+ kind: "blocked",
207
+ blockers: [
208
+ runSurfaceBlocker({
209
+ code: "run_event_refused",
210
+ // The rejected kind is echoed reduced to the bound the note records
211
+ // it at, so the diagnostic and the durable line say the same thing
212
+ // and neither can carry an escape sequence to the terminal.
213
+ summary: `The run event was refused: ${reduceToProviderId(kind)}`,
214
+ details: `run ${runId}: ${first === undefined ? "the store refused the append" : `${first.code} at ${oneLine(first.pointer, 64) || "/"}: ${oneLine(first.message, 200)}`}`,
215
+ remediation: {
216
+ id: "correct-the-event",
217
+ summary: "Correct the kind or the payload against the run-event/1 contract and emit again.",
218
+ },
219
+ }),
220
+ ],
221
+ };
222
+ }
223
+
224
+ if (kind === "run.ended") await store.clearCurrent(surface.worktreeKey, runId);
225
+ return { kind: "ok", summary: `emitted ${appended.event.kind} seq ${appended.event.seq} to run ${runId}` };
226
+ },
227
+ };
228
+
229
+ /**
230
+ * `run.started` is the one kind that allocates rather than resolves.
231
+ *
232
+ * The pointer is read BEFORE the journal is allocated, so the ordinary refusal
233
+ * — a run is already current and no `--force` was given — leaves no orphan
234
+ * journal behind. `--force` carries the displaced run's id into the new run's
235
+ * own `run.started` payload, which is what makes a restarted delivery legible
236
+ * afterwards.
237
+ */
238
+ async function startRun(surface: RunSurface, force: boolean, supplied: unknown): Promise<CommandResult> {
239
+ const store = surface.store;
240
+ const existing = await store.current(surface.worktreeKey);
241
+ const displaced = existing.ok ? existing.runId : undefined;
242
+ if (displaced !== undefined && !force) {
243
+ return {
244
+ kind: "blocked",
245
+ blockers: [
246
+ runSurfaceBlocker({
247
+ code: "run_already_current",
248
+ summary: "A run is already current for this worktree.",
249
+ details: `run ${displaced} is current; --force displaces it and records it as displacedRunId`,
250
+ remediation: {
251
+ id: "end-or-force",
252
+ summary: "End the current run with `emit run.ended`, or start this one with --force.",
253
+ },
254
+ }),
255
+ ],
256
+ };
257
+ }
258
+
259
+ const allocated = await store.allocate();
260
+ if (!allocated.ok) {
261
+ return {
262
+ kind: "blocked",
263
+ blockers: [
264
+ runSurfaceBlocker({
265
+ code: "run_not_allocated",
266
+ summary: "A run journal could not be allocated.",
267
+ details: oneLine(allocated.rejections[0]?.message ?? "the store refused to allocate", 200),
268
+ remediation: { id: "check-the-store", summary: "Check that the repository's git common directory is writable." },
269
+ }),
270
+ ],
271
+ };
272
+ }
273
+ const runId = allocated.runId;
274
+
275
+ const payload =
276
+ typeof supplied === "object" && supplied !== null && displaced !== undefined
277
+ ? { ...(supplied as Record<string, unknown>), displacedRunId: displaced }
278
+ : supplied;
279
+
280
+ const appended = await store.append(
281
+ runId,
282
+ buildRunEvent({ runId, commonDir: surface.commonDir, kind: "run.started", role: "executor", payload }),
283
+ );
284
+ if (!appended.ok) {
285
+ const first = appended.rejections[0];
286
+ return {
287
+ kind: "blocked",
288
+ blockers: [
289
+ runSurfaceBlocker({
290
+ code: "run_event_refused",
291
+ summary: "The run event was refused: run.started",
292
+ details: `run ${runId}: ${first === undefined ? "the store refused the append" : `${first.code} at ${oneLine(first.pointer, 64) || "/"}: ${oneLine(first.message, 200)}`}`,
293
+ remediation: {
294
+ id: "correct-the-event",
295
+ summary: "Correct the payload against the run-event/1 contract and emit again.",
296
+ },
297
+ }),
298
+ ],
299
+ };
300
+ }
301
+
302
+ let pointed = await store.setCurrent(surface.worktreeKey, runId, { force });
303
+ let displacedStale = false;
304
+ // A STALE POINTER IS NOT A CONFLICT. `current` answers `undefined` both when
305
+ // no pointer exists and when one exists naming a journal this store can no
306
+ // longer read — a pruned store, a restored `.git`. In the second case the
307
+ // exclusive create below refuses, and without this the caller's only recourse
308
+ // is to retry, allocating one more orphan journal per attempt. Re-reading the
309
+ // pointer separates the two: a run that is now current is a genuine race and
310
+ // is still refused; a pointer that still names nothing is stale and is
311
+ // displaced, which is what the operator meant.
312
+ if (!pointed.ok && !force) {
313
+ const recheck = await store.current(surface.worktreeKey);
314
+ if (recheck.ok && recheck.runId === undefined) {
315
+ pointed = await store.setCurrent(surface.worktreeKey, runId, { force: true });
316
+ displacedStale = pointed.ok;
317
+ }
318
+ }
319
+ if (!pointed.ok) {
320
+ return {
321
+ kind: "blocked",
322
+ blockers: [
323
+ runSurfaceBlocker({
324
+ code: "run_pointer_refused",
325
+ summary: "The worktree pointer could not be written.",
326
+ details: `run ${runId}: ${oneLine(pointed.rejections[0]?.message ?? "the pointer was refused", 200)}`,
327
+ // `--run` cannot help here: `run.started` allocates rather than
328
+ // resolves, so it never reads that flag. `--force` is the one thing
329
+ // that displaces a pointer this command refused to overwrite.
330
+ remediation: { id: "force-the-start", summary: "Start the run with --force to displace the pointer that is already current." },
331
+ }),
332
+ ],
333
+ };
334
+ }
335
+
336
+ // A stale displacement has no id to record — the pointer named nothing this
337
+ // store could read, which is why it was displaceable at all — so the summary
338
+ // is the only place the operator learns it happened. It is also the only
339
+ // signal that the store itself needs attention: a pointer reaches this state
340
+ // through a pruned journal, a truncated write, or a permissions fault.
341
+ return {
342
+ kind: "ok",
343
+ summary: `started run ${runId}${
344
+ displaced === undefined
345
+ ? displacedStale
346
+ ? " (displaced a stale pointer naming no readable run; the run store may need attention)"
347
+ : ""
348
+ : ` (displaced ${displaced})`
349
+ }`,
350
+ };
351
+ }
@@ -9,29 +9,113 @@
9
9
  * only ever offers a waiver to a `human` context, all-or-nothing over waivable
10
10
  * findings; the CLI adds no waiver logic of its own.
11
11
  */
12
- import { runAdmission } from "@agent-delivery-harness/kernel";
13
- import type { CommandContext, CommandDescriptor, CommandResult } from "../boundary.ts";
12
+ import { runAdmission, type AdmissionResult, type Blocker, type LiveProviderResult } from "@agent-delivery-harness/kernel";
13
+ import { CliInterruption, type CommandContext, type CommandDescriptor, type CommandResult } from "../boundary.ts";
14
+
15
+ /**
16
+ * Runs the ordinary admission first, invokes only configured providers that can
17
+ * answer the resulting missing-evidence/live-result blocks, then re-evaluates
18
+ * through the same admission adapter. Configs without provider commands take
19
+ * the pre-existing path unchanged.
20
+ */
21
+ export async function runProviderBackedAdmission(
22
+ context: CommandContext,
23
+ options: { readonly allowPrompt: boolean; readonly includeInjectedLiveResults: boolean },
24
+ ): Promise<AdmissionResult> {
25
+ const wiring = await context.wire();
26
+ const admissionOptions = {
27
+ captureCandidate: wiring.captureCandidate,
28
+ projectActivation: wiring.projectActivation,
29
+ ...wiring.storageOptions,
30
+ };
31
+ const input = {
32
+ rootDir: context.rootDir,
33
+ config: context.config,
34
+ context: context.classifyContext(),
35
+ ...(options.includeInjectedLiveResults && context.liveResults !== undefined ? { liveResults: context.liveResults } : {}),
36
+ };
37
+ const finalAdmissionOptions = {
38
+ ...admissionOptions,
39
+ ...(options.allowPrompt && context.promptForWaiver !== undefined ? { promptForWaiver: context.promptForWaiver } : {}),
40
+ };
41
+
42
+ if (!context.config.providers.some((provider) => provider.command !== undefined)) {
43
+ return runAdmission(input, finalAdmissionOptions);
44
+ }
45
+
46
+ const liveResults: LiveProviderResult[] = options.includeInjectedLiveResults ? [...(context.liveResults ?? [])] : [];
47
+ const attempted = new Set<string>();
48
+ const attemptBlockers: Blocker[] = [];
49
+ let admission = await runAdmission(input, admissionOptions);
50
+
51
+ while (!admission.admitted && admission.decision !== undefined && admission.candidate !== undefined) {
52
+ const requested = new Map<string, { obligationIds: string[]; requiresEvidence: boolean; needsLiveResult: boolean }>();
53
+ for (const resolution of admission.decision.resolutions) {
54
+ if (resolution.kind !== "blocked") continue;
55
+ const obligation = context.config.obligations.find((entry) => entry.id === resolution.obligationId);
56
+ if (obligation === undefined) continue;
57
+ const missingCode = obligation.freshness === "live" ? "live_provider_missing" : "review_evidence_missing";
58
+ for (const finding of resolution.providerFindings ?? []) {
59
+ if (finding.code !== missingCode || finding.providerId === undefined || attempted.has(finding.providerId)) continue;
60
+ const registration = context.config.providers.find((provider) => provider.id === finding.providerId);
61
+ if (registration?.command === undefined || !obligation.providers.includes(registration.id)) continue;
62
+ const entry = requested.get(registration.id) ?? { obligationIds: [], requiresEvidence: false, needsLiveResult: false };
63
+ entry.obligationIds.push(obligation.id);
64
+ entry.requiresEvidence ||= obligation.freshness === "exact_candidate";
65
+ entry.needsLiveResult ||= obligation.freshness === "live";
66
+ requested.set(registration.id, entry);
67
+ }
68
+ }
69
+
70
+ const next = requested.entries().next().value as
71
+ | [string, { obligationIds: string[]; requiresEvidence: boolean; needsLiveResult: boolean }]
72
+ | undefined;
73
+ if (next === undefined) break;
74
+ const [providerId, request] = next;
75
+ attempted.add(providerId);
76
+ const result = await context.invokeProvider?.({
77
+ providerId,
78
+ requiresEvidence: request.requiresEvidence,
79
+ payload: {
80
+ gateId: context.config.gateId,
81
+ providerId,
82
+ obligationIds: [...new Set(request.obligationIds)].sort(),
83
+ candidate: admission.candidate,
84
+ },
85
+ });
86
+ if (result === undefined) continue;
87
+ if (result.kind === "interrupted") throw new CliInterruption("Provider invocation interrupted before a trustworthy terminal outcome.");
88
+ if (result.kind === "blocked") {
89
+ attemptBlockers.push(...result.blockers);
90
+ if (request.needsLiveResult) {
91
+ liveResults.push({ providerId, runId: result.runId, status: "failed", findings: [] });
92
+ }
93
+ } else if (request.needsLiveResult) {
94
+ liveResults.push(result.liveResult);
95
+ }
96
+
97
+ admission = await runAdmission(
98
+ { ...input, ...(liveResults.length === 0 ? {} : { liveResults }) },
99
+ admissionOptions,
100
+ );
101
+ }
102
+
103
+ if (admission.admitted) return admission;
104
+ const final = await runAdmission(
105
+ { ...input, ...(liveResults.length === 0 ? {} : { liveResults }) },
106
+ finalAdmissionOptions,
107
+ );
108
+ return attemptBlockers.length === 0 || final.admitted
109
+ ? final
110
+ : { ...final, blockers: [...attemptBlockers, ...final.blockers] };
111
+ }
14
112
 
15
113
  export const gateCommand: CommandDescriptor = {
16
114
  name: "gate",
17
115
  sourceId: "delivery-harness.cli.gate",
18
116
  summary: "Evaluate the delivery gate for the current candidate.",
19
117
  async run(context: CommandContext): Promise<CommandResult> {
20
- const wiring = await context.wire();
21
- const result = await runAdmission(
22
- {
23
- rootDir: context.rootDir,
24
- config: context.config,
25
- context: context.classifyContext(),
26
- ...(context.liveResults === undefined ? {} : { liveResults: context.liveResults }),
27
- },
28
- {
29
- captureCandidate: wiring.captureCandidate,
30
- projectActivation: wiring.projectActivation,
31
- ...(context.promptForWaiver === undefined ? {} : { promptForWaiver: context.promptForWaiver }),
32
- ...wiring.storageOptions,
33
- },
34
- );
118
+ const result = await runProviderBackedAdmission(context, { allowPrompt: true, includeInjectedLiveResults: true });
35
119
 
36
120
  if (result.admitted) {
37
121
  const waiverNote =
@@ -0,0 +1,202 @@
1
+ /**
2
+ * `maintain` — the installation-scoped maintenance lane: update, rollback, and
3
+ * trust-state maintenance.
4
+ *
5
+ * WHY THIS IS A SEPARATE COMMAND. `managed` addresses one delivery and its
6
+ * checkpoints; these operations address the installation itself and touch no
7
+ * delivery journal at all. Folding them into `managed` would have made a
8
+ * delivery resolve before an operator could repair the installation that
9
+ * delivery is blocked on — the wrong order for exactly the case the lane
10
+ * exists to serve.
11
+ *
12
+ * WHAT DEFENDS THIS SURFACE, PRECISELY. Every operation here consumes a
13
+ * maintenance-lane sensitive assertion bound to the target installation and
14
+ * generation identities, evaluated by the installation's configured assertion
15
+ * source in the model-external lane, and consumed BEFORE any byte moves. The
16
+ * caller cannot mint that assertion and cannot answer the evaluation: the
17
+ * operating system evaluates it and the product never sees a credential. So a
18
+ * model that invokes one of these cannot complete it.
19
+ *
20
+ * WHAT IT DOES NOT DEFEND, STATED PLAINLY BECAUSE IT IS EASY TO ASSUME
21
+ * OTHERWISE. It does not stop the prompt from being raised. Probing an
22
+ * OS-native source checks that the platform's authentication surfaces exist,
23
+ * and existence is independent of whether the caller holds a terminal — so on a
24
+ * desktop platform a non-interactive session can still reach `evaluate`, which
25
+ * raises an interactive dialog in the operator's own session. Authorization
26
+ * holds, because nobody but the operator can answer it; what is not defended is
27
+ * the operator's attention. Prompt fatigue is the residual risk on this surface,
28
+ * and it is the reason to keep the lane narrow rather than to believe it closed.
29
+ *
30
+ * Operator confirmations are the class that must never be nameable on a shell
31
+ * surface, and none of them appear here.
32
+ *
33
+ * The installation is located the same way `managed` locates it: from the
34
+ * product namespace's pointer under the common git directory, never a
35
+ * candidate-writable path.
36
+ */
37
+ import { execFile } from "node:child_process";
38
+ import { readFile } from "node:fs/promises";
39
+ import path from "node:path";
40
+ import { compiledAdopterPolicyBindingDigest, createManagedDeliveryFacade, type CompiledAdopterPolicyBinding, type ManagedDeliveryFacade } from "@agent-delivery-harness/kernel";
41
+ import { commandBlocker } from "../boundary.ts";
42
+ import type { CommandContext, CommandDescriptor, CommandResult } from "../boundary.ts";
43
+
44
+ const SOURCE_ID = "delivery-harness.cli.maintain";
45
+
46
+ /** Every operation this command answers, in the order its usage lists them. */
47
+ const MAINTAIN_OPERATIONS: readonly string[] = Object.freeze([
48
+ "update",
49
+ "rollback",
50
+ "pin",
51
+ "revoke",
52
+ "unrevoke",
53
+ "advance-high-water-mark",
54
+ ]);
55
+
56
+ const blocked = (code: string, summary: string, remediation: string): CommandResult => ({
57
+ kind: "blocked",
58
+ blockers: [
59
+ commandBlocker({
60
+ code,
61
+ sourceId: SOURCE_ID,
62
+ summary,
63
+ remediations: [{ id: `${code.replaceAll("_", "-")}-remediation`, kind: "manual_action", summary: remediation }],
64
+ }),
65
+ ],
66
+ });
67
+
68
+ const gitCommonDir = (cwd: string): Promise<string | undefined> =>
69
+ new Promise((resolve) => {
70
+ execFile("git", ["rev-parse", "--path-format=absolute", "--git-common-dir"], { cwd, encoding: "utf8" }, (error, stdout) => {
71
+ resolve(error === null ? stdout.trim() : undefined);
72
+ });
73
+ });
74
+
75
+ const flag = (args: readonly string[], name: string): string | undefined => {
76
+ const index = args.indexOf(name);
77
+ return index === -1 ? undefined : args[index + 1];
78
+ };
79
+
80
+ function nowInstant(): string {
81
+ return `${new Date().toISOString().slice(0, 19)}Z`;
82
+ }
83
+
84
+ async function resolveFacade(context: CommandContext): Promise<ManagedDeliveryFacade | CommandResult> {
85
+ const common = await gitCommonDir(context.rootDir);
86
+ if (common === undefined) {
87
+ return blocked("not_a_repository", "The working directory is not a git repository.", "Run from the repository the product is installed for.");
88
+ }
89
+ let pointer: { installationPath: string; receiptDir: string; hostVersion: string; policyBindingDigest: string };
90
+ try {
91
+ pointer = JSON.parse(await readFile(path.join(common, "managed-delivery", "facade.json"), "utf8")) as typeof pointer;
92
+ } catch {
93
+ return blocked(
94
+ "no_managed_installation",
95
+ "No managed installation is registered for this repository.",
96
+ "Install the composition and register a delivery first.",
97
+ );
98
+ }
99
+ let policyBinding: CompiledAdopterPolicyBinding | undefined = context.policyBinding;
100
+ if (policyBinding === undefined) {
101
+ try {
102
+ policyBinding = JSON.parse(await readFile(path.join(common, "managed-delivery", "policy-binding.json"), "utf8")) as CompiledAdopterPolicyBinding;
103
+ } catch {
104
+ return blocked(
105
+ "policy_binding_missing",
106
+ "No compiled adopter policy binding is retained for this installation.",
107
+ "Register a delivery through an adopter binding, or pass one through the embedding runtime.",
108
+ );
109
+ }
110
+ }
111
+ if (pointer.policyBindingDigest !== compiledAdopterPolicyBindingDigest(policyBinding)) {
112
+ return blocked(
113
+ "policy_binding_mismatch",
114
+ "The retained compiled adopter policy binding does not match the product namespace pointer.",
115
+ "Restore the exact binding captured at registration; drift requires a new owner-approved delivery.",
116
+ );
117
+ }
118
+ return createManagedDeliveryFacade({
119
+ repoDir: context.rootDir,
120
+ policyBinding,
121
+ installation: { installationPath: pointer.installationPath, receiptDir: pointer.receiptDir },
122
+ hostVersion: pointer.hostVersion,
123
+ });
124
+ }
125
+
126
+ const isCommandResult = (value: ManagedDeliveryFacade | CommandResult): value is CommandResult => "kind" in value;
127
+
128
+ export const maintainCommand: CommandDescriptor = {
129
+ name: "maintain",
130
+ sourceId: SOURCE_ID,
131
+ summary: "Maintain the product installation (update, rollback, trust-state pin/revoke/unrevoke/high-water-mark).",
132
+ async run(context: CommandContext): Promise<CommandResult> {
133
+ const [operation, ...rest] = context.args;
134
+ if (operation === undefined) {
135
+ return { kind: "usage", message: `maintain requires an operation: ${MAINTAIN_OPERATIONS.join(" | ")}` };
136
+ }
137
+ // A typo is a usage error about the call, answered before the repository
138
+ // or the installation is consulted.
139
+ if (!MAINTAIN_OPERATIONS.includes(operation)) {
140
+ return { kind: "usage", message: `Unknown maintain operation: ${operation}.` };
141
+ }
142
+ const resolved = await resolveFacade(context);
143
+ if (isCommandResult(resolved)) return resolved;
144
+ const facade = resolved;
145
+ const now = nowInstant();
146
+ const emit = (value: unknown): void => context.write(`${JSON.stringify(value, null, 2)}\n`);
147
+
148
+ switch (operation) {
149
+ case "update": {
150
+ const packedDir = flag(rest, "--packed");
151
+ if (packedDir === undefined) {
152
+ return { kind: "usage", message: "update requires --packed <dir> naming the verified packed generation to install." };
153
+ }
154
+ const outcome = await facade.updateComposition({ packedDir: path.resolve(context.rootDir, packedDir), now });
155
+ if (!outcome.ok) return { kind: "blocked", blockers: [...outcome.blockers] };
156
+ emit(outcome);
157
+ return {
158
+ kind: "ok",
159
+ summary: outcome.noOp
160
+ ? `already at ${outcome.generationDigest}`
161
+ : `updated ${outcome.priorGenerationDigest} -> ${outcome.generationDigest}`,
162
+ };
163
+ }
164
+ case "rollback": {
165
+ const target = flag(rest, "--generation");
166
+ if (target === undefined) {
167
+ return { kind: "usage", message: "rollback requires --generation <digest> naming a previously accepted generation." };
168
+ }
169
+ const outcome = await facade.rollbackComposition({ targetGenerationDigest: target, now });
170
+ if (!outcome.ok) return { kind: "blocked", blockers: [...outcome.blockers] };
171
+ return { kind: "ok", summary: `rolled back to ${outcome.generationDigest}` };
172
+ }
173
+ case "pin":
174
+ case "revoke":
175
+ case "unrevoke": {
176
+ const generationDigest = flag(rest, "--generation");
177
+ if (generationDigest === undefined) {
178
+ return { kind: "usage", message: `${operation} requires --generation <digest>.` };
179
+ }
180
+ const outcome = await facade.maintainTrustState({ operation, generationDigest, now });
181
+ if (!outcome.ok) return { kind: "blocked", blockers: [...outcome.blockers] };
182
+ emit(outcome.state);
183
+ return { kind: "ok", summary: `${operation} recorded at revocation epoch ${outcome.state.revocationEpoch}` };
184
+ }
185
+ case "advance-high-water-mark": {
186
+ const raw = flag(rest, "--to");
187
+ const highWaterMark = raw === undefined ? Number.NaN : Number.parseInt(raw, 10);
188
+ if (!Number.isInteger(highWaterMark)) {
189
+ return { kind: "usage", message: "advance-high-water-mark requires --to <integer>." };
190
+ }
191
+ const outcome = await facade.maintainTrustState({ operation: "advance-high-water-mark", highWaterMark, now });
192
+ if (!outcome.ok) return { kind: "blocked", blockers: [...outcome.blockers] };
193
+ emit(outcome.state);
194
+ return { kind: "ok", summary: `high-water mark is ${outcome.state.highWaterMark}` };
195
+ }
196
+ default:
197
+ // Unreachable: checked against MAINTAIN_OPERATIONS above. Kept so a
198
+ // name added to that list without a case here fails loudly.
199
+ return { kind: "usage", message: `Unknown maintain operation: ${operation}.` };
200
+ }
201
+ },
202
+ };