@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,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
+ }
@@ -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 { deliveryRecordPathFor, parseDeliveryRecord, verifyDeliveryRecord } from "@agent-delivery-harness/kernel";
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
- const check = verifyDeliveryRecord(context.config, parsed.record, identity, base);
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: `verified ${relativePath}${relaxation}; attestation: ${check.attestationLabel}`,
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 seven-command operator surface.
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 CliRuntime, type CommandDescriptor } from "./boundary.ts";
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` last as the standalone preflight.
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 CommandDescriptor[] = [
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