@agent-delivery-harness/cli 0.1.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 ADDED
@@ -0,0 +1,202 @@
1
+
2
+ Apache License
3
+ Version 2.0, January 2004
4
+ http://www.apache.org/licenses/
5
+
6
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
7
+
8
+ 1. Definitions.
9
+
10
+ "License" shall mean the terms and conditions for use, reproduction,
11
+ and distribution as defined by Sections 1 through 9 of this document.
12
+
13
+ "Licensor" shall mean the copyright owner or entity authorized by
14
+ the copyright owner that is granting the License.
15
+
16
+ "Legal Entity" shall mean the union of the acting entity and all
17
+ other entities that control, are controlled by, or are under common
18
+ control with that entity. For the purposes of this definition,
19
+ "control" means (i) the power, direct or indirect, to cause the
20
+ direction or management of such entity, whether by contract or
21
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
22
+ outstanding shares, or (iii) beneficial ownership of such entity.
23
+
24
+ "You" (or "Your") shall mean an individual or Legal Entity
25
+ exercising permissions granted by this License.
26
+
27
+ "Source" form shall mean the preferred form for making modifications,
28
+ including but not limited to software source code, documentation
29
+ source, and configuration files.
30
+
31
+ "Object" form shall mean any form resulting from mechanical
32
+ transformation or translation of a Source form, including but
33
+ not limited to compiled object code, generated documentation,
34
+ and conversions to other media types.
35
+
36
+ "Work" shall mean the work of authorship, whether in Source or
37
+ Object form, made available under the License, as indicated by a
38
+ copyright notice that is included in or attached to the work
39
+ (an example is provided in the Appendix below).
40
+
41
+ "Derivative Works" shall mean any work, whether in Source or Object
42
+ form, that is based on (or derived from) the Work and for which the
43
+ editorial revisions, annotations, elaborations, or other modifications
44
+ represent, as a whole, an original work of authorship. For the purposes
45
+ of this License, Derivative Works shall not include works that remain
46
+ separable from, or merely link (or bind by name) to the interfaces of,
47
+ the Work and Derivative Works thereof.
48
+
49
+ "Contribution" shall mean any work of authorship, including
50
+ the original version of the Work and any modifications or additions
51
+ to that Work or Derivative Works thereof, that is intentionally
52
+ submitted to Licensor for inclusion in the Work by the copyright owner
53
+ or by an individual or Legal Entity authorized to submit on behalf of
54
+ the copyright owner. For the purposes of this definition, "submitted"
55
+ means any form of electronic, verbal, or written communication sent
56
+ to the Licensor or its representatives, including but not limited to
57
+ communication on electronic mailing lists, source code control systems,
58
+ and issue tracking systems that are managed by, or on behalf of, the
59
+ Licensor for the purpose of discussing and improving the Work, but
60
+ excluding communication that is conspicuously marked or otherwise
61
+ designated in writing by the copyright owner as "Not a Contribution."
62
+
63
+ "Contributor" shall mean Licensor and any individual or Legal Entity
64
+ on behalf of whom a Contribution has been received by Licensor and
65
+ subsequently incorporated within the Work.
66
+
67
+ 2. Grant of Copyright License. Subject to the terms and conditions of
68
+ this License, each Contributor hereby grants to You a perpetual,
69
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
70
+ copyright license to reproduce, prepare Derivative Works of,
71
+ publicly display, publicly perform, sublicense, and distribute the
72
+ Work and such Derivative Works in Source or Object form.
73
+
74
+ 3. Grant of Patent License. Subject to the terms and conditions of
75
+ this License, each Contributor hereby grants to You a perpetual,
76
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
77
+ (except as stated in this section) patent license to make, have made,
78
+ use, offer to sell, sell, import, and otherwise transfer the Work,
79
+ where such license applies only to those patent claims licensable
80
+ by such Contributor that are necessarily infringed by their
81
+ Contribution(s) alone or by combination of their Contribution(s)
82
+ with the Work to which such Contribution(s) was submitted. If You
83
+ institute patent litigation against any entity (including a
84
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
85
+ or a Contribution incorporated within the Work constitutes direct
86
+ or contributory patent infringement, then any patent licenses
87
+ granted to You under this License for that Work shall terminate
88
+ as of the date such litigation is filed.
89
+
90
+ 4. Redistribution. You may reproduce and distribute copies of the
91
+ Work or Derivative Works thereof in any medium, with or without
92
+ modifications, and in Source or Object form, provided that You
93
+ meet the following conditions:
94
+
95
+ (a) You must give any other recipients of the Work or
96
+ Derivative Works a copy of this License; and
97
+
98
+ (b) You must cause any modified files to carry prominent notices
99
+ stating that You changed the files; and
100
+
101
+ (c) You must retain, in the Source form of any Derivative Works
102
+ that You distribute, all copyright, patent, trademark, and
103
+ attribution notices from the Source form of the Work,
104
+ excluding those notices that do not pertain to any part of
105
+ the Derivative Works; and
106
+
107
+ (d) If the Work includes a "NOTICE" text file as part of its
108
+ distribution, then any Derivative Works that You distribute must
109
+ include a readable copy of the attribution notices contained
110
+ within such NOTICE file, excluding those notices that do not
111
+ pertain to any part of the Derivative Works, in at least one
112
+ of the following places: within a NOTICE text file distributed
113
+ as part of the Derivative Works; within the Source form or
114
+ documentation, if provided along with the Derivative Works; or,
115
+ within a display generated by the Derivative Works, if and
116
+ wherever such third-party notices normally appear. The contents
117
+ of the NOTICE file are for informational purposes only and
118
+ do not modify the License. You may add Your own attribution
119
+ notices within Derivative Works that You distribute, alongside
120
+ or as an addendum to the NOTICE text from the Work, provided
121
+ that such additional attribution notices cannot be construed
122
+ as modifying the License.
123
+
124
+ You may add Your own copyright statement to Your modifications and
125
+ may provide additional or different license terms and conditions
126
+ for use, reproduction, or distribution of Your modifications, or
127
+ for any such Derivative Works as a whole, provided Your use,
128
+ reproduction, and distribution of the Work otherwise complies with
129
+ the conditions stated in this License.
130
+
131
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
132
+ any Contribution intentionally submitted for inclusion in the Work
133
+ by You to the Licensor shall be under the terms and conditions of
134
+ this License, without any additional terms or conditions.
135
+ Notwithstanding the above, nothing herein shall supersede or modify
136
+ the terms of any separate license agreement you may have executed
137
+ with Licensor regarding such Contributions.
138
+
139
+ 6. Trademarks. This License does not grant permission to use the trade
140
+ names, trademarks, service marks, or product names of the Licensor,
141
+ except as required for reasonable and customary use in describing the
142
+ origin of the Work and reproducing the content of the NOTICE file.
143
+
144
+ 7. Disclaimer of Warranty. Unless required by applicable law or
145
+ agreed to in writing, Licensor provides the Work (and each
146
+ Contributor provides its Contributions) on an "AS IS" BASIS,
147
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
148
+ implied, including, without limitation, any warranties or conditions
149
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
150
+ PARTICULAR PURPOSE. You are solely responsible for determining the
151
+ appropriateness of using or redistributing the Work and assume any
152
+ risks associated with Your exercise of permissions under this License.
153
+
154
+ 8. Limitation of Liability. In no event and under no legal theory,
155
+ whether in tort (including negligence), contract, or otherwise,
156
+ unless required by applicable law (such as deliberate and grossly
157
+ negligent acts) or agreed to in writing, shall any Contributor be
158
+ liable to You for damages, including any direct, indirect, special,
159
+ incidental, or consequential damages of any character arising as a
160
+ result of this License or out of the use or inability to use the
161
+ Work (including but not limited to damages for loss of goodwill,
162
+ work stoppage, computer failure or malfunction, or any and all
163
+ other commercial damages or losses), even if such Contributor
164
+ has been advised of the possibility of such damages.
165
+
166
+ 9. Accepting Warranty or Additional Liability. While redistributing
167
+ the Work or Derivative Works thereof, You may choose to offer,
168
+ and charge a fee for, acceptance of support, warranty, indemnity,
169
+ or other liability obligations and/or rights consistent with this
170
+ License. However, in accepting such obligations, You may act only
171
+ on Your own behalf and on Your sole responsibility, not on behalf
172
+ of any other Contributor, and only if You agree to indemnify,
173
+ defend, and hold each Contributor harmless for any liability
174
+ incurred by, or claims asserted against, such Contributor by reason
175
+ of your accepting any such warranty or additional liability.
176
+
177
+ END OF TERMS AND CONDITIONS
178
+
179
+ APPENDIX: How to apply the Apache License to your work.
180
+
181
+ To apply the Apache License to your work, attach the following
182
+ boilerplate notice, with the fields enclosed by brackets "[]"
183
+ replaced with your own identifying information. (Don't include
184
+ the brackets!) The text should be enclosed in the appropriate
185
+ comment syntax for the file format. We also recommend that a
186
+ file or class name and description of purpose be included on the
187
+ same "printed page" as the copyright notice for easier
188
+ identification within third-party archives.
189
+
190
+ Copyright [yyyy] [name of copyright owner]
191
+
192
+ Licensed under the Apache License, Version 2.0 (the "License");
193
+ you may not use this file except in compliance with the License.
194
+ You may obtain a copy of the License at
195
+
196
+ http://www.apache.org/licenses/LICENSE-2.0
197
+
198
+ Unless required by applicable law or agreed to in writing, software
199
+ distributed under the License is distributed on an "AS IS" BASIS,
200
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
201
+ See the License for the specific language governing permissions and
202
+ limitations under the License.
package/NOTICE ADDED
@@ -0,0 +1,5 @@
1
+ delivery-harness
2
+ Copyright the delivery-harness authors
3
+
4
+ This product includes software developed by the delivery-harness authors
5
+ (https://github.com/kwam1na/agent-delivery-harness).
package/package.json ADDED
@@ -0,0 +1,22 @@
1
+ {
2
+ "name": "@agent-delivery-harness/cli",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "description": "Delivery harness CLI: prepare, review-context, submit-evidence, gate, record, verify, check",
6
+ "license": "Apache-2.0",
7
+ "engines": {
8
+ "node": ">=22"
9
+ },
10
+ "dependencies": {
11
+ "@agent-delivery-harness/kernel": "0.1.0"
12
+ },
13
+ "exports": {
14
+ ".": "./src/index.ts"
15
+ },
16
+ "files": [
17
+ "src",
18
+ "!src/**/*.test.ts",
19
+ "LICENSE",
20
+ "NOTICE"
21
+ ]
22
+ }
@@ -0,0 +1,326 @@
1
+ /**
2
+ * The one boundary every CLI command runs behind.
3
+ *
4
+ * WHY ONE BOUNDARY. Seven commands, one place that loads config, wires the repo,
5
+ * classifies exit codes, and renders failures. A command never touches
6
+ * `process`, never prints a stack, never chooses an exit code of its own: it
7
+ * returns a typed result and the boundary maps it. That is what keeps the three
8
+ * exit semantics — policy block, usage error, interruption — identical across
9
+ * commands, and what keeps every operator-facing byte flowing through the one
10
+ * neutralizing renderer.
11
+ *
12
+ * EXIT CODES.
13
+ * 0 the command completed and its check passed
14
+ * 1 a policy block (a typed blocker, a gate that did not admit, a failed
15
+ * verification) OR an unexpected internal error (rendered as a redacted
16
+ * `internal_error` blocker — never reported as a policy decision, but
17
+ * still a non-zero failure)
18
+ * 2 a usage error (unknown command, missing or malformed arguments)
19
+ * 130 interruption (SIGINT) — the shell convention 128 + SIGINT(2)
20
+ *
21
+ * REPO COHERENCE. Capture and the evidence store must address the same
22
+ * repository — the recorder's coherence requirement. Both are wired here, from one
23
+ * `rootDir`, with the store's own `workspaceId` handed to the capture — so a
24
+ * captured candidate can never disagree with the store about which workspace it
25
+ * belongs to. Admission still guards `workspace_incoherent`; this makes the
26
+ * disagreement unconstructible in the first place.
27
+ */
28
+ import path from "node:path";
29
+ import { pathToFileURL } from "node:url";
30
+ import {
31
+ BlockedError,
32
+ classifyExecutionContext,
33
+ createArtifactsPort,
34
+ createBlocker,
35
+ createCandidateCapture,
36
+ createInternalErrorBlocker,
37
+ evaluateCandidateActivation,
38
+ renderBlockers,
39
+ resolveRecordStorage,
40
+ validateHarnessConfig,
41
+ withDeliverableIdentity,
42
+ type ArtifactsPort,
43
+ type Blocker,
44
+ type CaptureCandidate,
45
+ type CapturedCandidate,
46
+ type EnvSnapshot,
47
+ type ExecutionContext,
48
+ type HarnessConfig,
49
+ type LiveProviderResult,
50
+ type NonEmptyTuple,
51
+ type ReviewActivationProjection,
52
+ type WaiverPrompt,
53
+ } from "@agent-delivery-harness/kernel";
54
+
55
+ // ── Exit codes ───────────────────────────────────────────────────────────────
56
+
57
+ export const EXIT_OK = 0;
58
+ export const EXIT_POLICY = 1;
59
+ export const EXIT_USAGE = 2;
60
+ export const EXIT_INTERRUPTED = 130;
61
+
62
+ /**
63
+ * Thrown when the operator interrupts the process (SIGINT), typically at an
64
+ * interactive prompt. The boundary maps it to exit 130 — distinct from a policy
65
+ * block, because an interrupted run reached no verdict.
66
+ */
67
+ export class CliInterruption extends Error {
68
+ constructor(message = "Interrupted.") {
69
+ super(message);
70
+ this.name = "CliInterruption";
71
+ }
72
+ }
73
+
74
+ // ── Command contract ─────────────────────────────────────────────────────────
75
+
76
+ /**
77
+ * What a command returns. `ok` is a pass; `blocked` is a policy failure carrying
78
+ * the typed blockers to render; `usage` is an argument or invocation error.
79
+ */
80
+ export type CommandResult =
81
+ | { readonly kind: "ok"; readonly summary?: string }
82
+ | { readonly kind: "blocked"; readonly blockers: readonly Blocker[] }
83
+ | { readonly kind: "usage"; readonly message: string };
84
+
85
+ /** The repository wiring shared by every command in one invocation. */
86
+ export interface RepoWiring {
87
+ readonly rootDir: string;
88
+ readonly workspaceId: string;
89
+ readonly captureCandidate: CaptureCandidate;
90
+ readonly projectActivation: (candidate: CapturedCandidate) => Promise<ReviewActivationProjection>;
91
+ /** Storage options threaded to every kernel call so all share one namespace. */
92
+ readonly storageOptions: { readonly storageNamespace: string };
93
+ }
94
+
95
+ /** Everything a command may reach, none of it the ambient process. */
96
+ export interface CommandContext {
97
+ readonly rootDir: string;
98
+ readonly config: HarnessConfig;
99
+ readonly env: EnvSnapshot;
100
+ readonly stdinIsTTY: boolean;
101
+ readonly stdoutIsTTY: boolean;
102
+ /** Positional and flag arguments after the command name. */
103
+ readonly args: readonly string[];
104
+ /**
105
+ * Wires capture and the store from this repo, memoized. Lazy so `--help`
106
+ * wires nothing and so a command owns how it renders a store that will not
107
+ * resolve. Rejects with a `BlockedError` the boundary maps to exit 1.
108
+ */
109
+ wire(): Promise<RepoWiring>;
110
+ readonly artifacts: ArtifactsPort;
111
+ /** Present only when the run can ask a human; the boundary gates it on a TTY. */
112
+ readonly promptForWaiver?: WaiverPrompt;
113
+ readonly liveResults?: readonly LiveProviderResult[];
114
+ /** Emits one line of operator-facing output to stdout. */
115
+ readonly write: (text: string) => void;
116
+ /** Classifies the execution context from this invocation's env + TTY. */
117
+ classifyContext(): ExecutionContext;
118
+ }
119
+
120
+ export interface CommandDescriptor {
121
+ readonly name: string;
122
+ /** The blocker `source.id` this command stamps on failures it raises itself. */
123
+ readonly sourceId: string;
124
+ readonly summary: string;
125
+ run(context: CommandContext): Promise<CommandResult>;
126
+ }
127
+
128
+ // ── Runtime the boundary is driven with ──────────────────────────────────────
129
+
130
+ export interface CliRuntime {
131
+ readonly cwd: string;
132
+ readonly env: EnvSnapshot;
133
+ readonly stdinIsTTY: boolean;
134
+ readonly stdoutIsTTY: boolean;
135
+ readonly stdout: (text: string) => void;
136
+ readonly stderr: (text: string) => void;
137
+ /** Loads the consumer config. Defaults to importing `harness.config.ts`. */
138
+ readonly loadConfig?: (rootDir: string) => Promise<HarnessConfig>;
139
+ /** The interactive waiver prompt. Only ever offered under a TTY. */
140
+ readonly promptForWaiver?: WaiverPrompt;
141
+ /** The filesystem port. Defaults to one rooted in the system temp directory. */
142
+ readonly artifacts?: ArtifactsPort;
143
+ readonly liveResults?: readonly LiveProviderResult[];
144
+ }
145
+
146
+ // ── Blocker helpers ──────────────────────────────────────────────────────────
147
+
148
+ const COMMAND_SOURCE = { kind: "command", id: "delivery-harness.cli" } as const;
149
+
150
+ export function commandBlocker(input: {
151
+ readonly code: string;
152
+ readonly sourceId: string;
153
+ readonly summary: string;
154
+ readonly details?: string;
155
+ readonly remediations: NonEmptyTuple<Parameters<typeof createBlocker>[0]["remediations"][number]>;
156
+ }): Blocker {
157
+ return createBlocker({
158
+ code: input.code,
159
+ source: { kind: "command", id: input.sourceId },
160
+ summary: input.summary,
161
+ ...(input.details === undefined ? {} : { details: input.details }),
162
+ remediations: input.remediations,
163
+ });
164
+ }
165
+
166
+ // ── Config loading ───────────────────────────────────────────────────────────
167
+
168
+ /**
169
+ * The default config loader: import `harness.config.ts` from the repo root and
170
+ * take its default export, which `defineHarnessConfig` has already validated. A
171
+ * config that fails to load — absent, unparseable, or invalid — becomes one
172
+ * typed blocker rather than an unhandled throw.
173
+ */
174
+ export async function importHarnessConfig(rootDir: string): Promise<HarnessConfig> {
175
+ const configPath = path.join(rootDir, "harness.config.ts");
176
+ let loaded: unknown;
177
+ try {
178
+ const module = (await import(pathToFileURL(configPath).href)) as { default?: unknown };
179
+ loaded = module.default;
180
+ } catch (error) {
181
+ throw new BlockedError([
182
+ commandBlocker({
183
+ code: "config_unloadable",
184
+ sourceId: "delivery-harness.cli.config",
185
+ summary: "The harness configuration could not be loaded.",
186
+ details: `${configPath}: ${error instanceof Error ? error.message : String(error)}`,
187
+ remediations: [
188
+ {
189
+ id: "create-harness-config",
190
+ kind: "manual_action",
191
+ summary: "Provide a valid harness.config.ts at the repository root.",
192
+ },
193
+ ],
194
+ }),
195
+ ]);
196
+ }
197
+ const validation = validateHarnessConfig(loaded);
198
+ if (!validation.ok) {
199
+ throw new BlockedError(validation.blockers);
200
+ }
201
+ return validation.config;
202
+ }
203
+
204
+ // ── Repo wiring (coherence lives here) ───────────────────────────────────────
205
+
206
+ /**
207
+ * Wires capture and the evidence store from one `rootDir`. The store's
208
+ * `workspaceId` is what the capture stamps onto the candidate, so the two can
209
+ * never disagree — the coherence the admission adapter guards is guaranteed at
210
+ * the source here.
211
+ */
212
+ export async function wireRepo(rootDir: string, config: HarnessConfig): Promise<RepoWiring> {
213
+ const storageOptions = { storageNamespace: config.storageNamespace };
214
+ const storage = await resolveRecordStorage(rootDir, storageOptions);
215
+ const captureCandidate = createCandidateCapture({
216
+ rootDir,
217
+ config,
218
+ workspaceId: storage.workspaceId,
219
+ computeIdentity: withDeliverableIdentity(),
220
+ });
221
+ const projectActivation = (candidate: CapturedCandidate): Promise<ReviewActivationProjection> =>
222
+ evaluateCandidateActivation({ rootDir, candidate, config });
223
+ return { rootDir, workspaceId: storage.workspaceId, captureCandidate, projectActivation, storageOptions };
224
+ }
225
+
226
+ // ── The boundary ─────────────────────────────────────────────────────────────
227
+
228
+ const USAGE = (commands: readonly CommandDescriptor[]): string =>
229
+ [
230
+ "Usage: delivery-harness <command> [options]",
231
+ "",
232
+ "Commands:",
233
+ ...commands.map((command) => ` ${command.name.padEnd(16)}${command.summary}`),
234
+ ].join("\n");
235
+
236
+ /**
237
+ * Runs one CLI invocation to an exit code. Total: it maps every command result
238
+ * and every throw to one of the four codes, and renders every failure through
239
+ * the neutralizing blocker renderer. Never throws.
240
+ */
241
+ export async function runCliBoundary(
242
+ argv: readonly string[],
243
+ commands: readonly CommandDescriptor[],
244
+ runtime: CliRuntime,
245
+ ): Promise<number> {
246
+ const [commandName, ...args] = argv;
247
+
248
+ if (commandName === undefined || commandName === "--help" || commandName === "-h" || commandName === "help") {
249
+ runtime.stdout(`${USAGE(commands)}\n`);
250
+ return commandName === undefined ? EXIT_USAGE : EXIT_OK;
251
+ }
252
+
253
+ const descriptor = commands.find((command) => command.name === commandName);
254
+ if (descriptor === undefined) {
255
+ runtime.stderr(`Unknown command: ${commandName}\n\n${USAGE(commands)}\n`);
256
+ return EXIT_USAGE;
257
+ }
258
+
259
+ const loadConfig = runtime.loadConfig ?? importHarnessConfig;
260
+ const artifacts = runtime.artifacts ?? createArtifactsPort();
261
+
262
+ try {
263
+ const config = await loadConfig(runtime.cwd);
264
+
265
+ let wiringPromise: Promise<RepoWiring> | undefined;
266
+ const wire = (): Promise<RepoWiring> => {
267
+ wiringPromise ??= wireRepo(runtime.cwd, config);
268
+ return wiringPromise;
269
+ };
270
+
271
+ const context: CommandContext = {
272
+ rootDir: runtime.cwd,
273
+ config,
274
+ env: runtime.env,
275
+ stdinIsTTY: runtime.stdinIsTTY,
276
+ stdoutIsTTY: runtime.stdoutIsTTY,
277
+ args,
278
+ wire,
279
+ artifacts,
280
+ // The waiver prompt is offered only under a real TTY. A non-interactive
281
+ // invocation never prompts — it blocks — no matter what the run wired.
282
+ ...(runtime.stdinIsTTY && runtime.stdoutIsTTY && runtime.promptForWaiver !== undefined
283
+ ? { promptForWaiver: runtime.promptForWaiver }
284
+ : {}),
285
+ ...(runtime.liveResults === undefined ? {} : { liveResults: runtime.liveResults }),
286
+ write: (text) => runtime.stdout(`${text}\n`),
287
+ classifyContext: () =>
288
+ classifyExecutionContext({
289
+ config,
290
+ env: runtime.env,
291
+ stdinIsTTY: runtime.stdinIsTTY,
292
+ stdoutIsTTY: runtime.stdoutIsTTY,
293
+ }),
294
+ };
295
+
296
+ const result = await descriptor.run(context);
297
+ if (result.kind === "ok") {
298
+ if (result.summary !== undefined && result.summary !== "") runtime.stdout(`${result.summary}\n`);
299
+ return EXIT_OK;
300
+ }
301
+ if (result.kind === "usage") {
302
+ runtime.stderr(`${result.message}\n`);
303
+ return EXIT_USAGE;
304
+ }
305
+ runtime.stderr(`${renderBlockers(result.blockers)}\n`);
306
+ return EXIT_POLICY;
307
+ } catch (error) {
308
+ if (error instanceof CliInterruption) {
309
+ runtime.stderr(`${error.message}\n`);
310
+ return EXIT_INTERRUPTED;
311
+ }
312
+ if (error instanceof BlockedError) {
313
+ runtime.stderr(`${renderBlockers(error.blockers)}\n`);
314
+ return EXIT_POLICY;
315
+ }
316
+ const blocker = createInternalErrorBlocker({
317
+ source: { kind: "command", id: descriptor.sourceId },
318
+ error,
319
+ reproduce: ["delivery-harness", descriptor.name],
320
+ });
321
+ runtime.stderr(`${renderBlockers([blocker])}\n`);
322
+ return EXIT_POLICY;
323
+ }
324
+ }
325
+
326
+ export { COMMAND_SOURCE };
@@ -0,0 +1,45 @@
1
+ /**
2
+ * `check` — confirm the config loads and the evidence store is usable.
3
+ *
4
+ * A preflight the operator runs before anything else. Config validity is proven
5
+ * by the boundary having loaded it at all; this command additionally resolves the
6
+ * git-private store *and proves it can be written to*, because a store that
7
+ * resolves but cannot be written is exactly the failure the operator wants to
8
+ * learn about here rather than halfway through a submission. The probe is
9
+ * written through the fs port and removed again, so the check leaves nothing
10
+ * behind. An unresolvable or unwritable store is a typed block, never a crash.
11
+ */
12
+ import path from "node:path";
13
+ import { resolveRecordStorage, BlockedError } from "@agent-delivery-harness/kernel";
14
+ import type { CommandContext, CommandDescriptor, CommandResult } from "../boundary.ts";
15
+
16
+ /** Named so an interrupted run leaves something obviously disposable. */
17
+ const PROBE_FILE = ".delivery-harness-write-probe";
18
+
19
+ export const checkCommand: CommandDescriptor = {
20
+ name: "check",
21
+ sourceId: "delivery-harness.cli.check",
22
+ summary: "Confirm the config loads and the evidence store is usable.",
23
+ async run(context: CommandContext): Promise<CommandResult> {
24
+ try {
25
+ const storage = await resolveRecordStorage(context.rootDir, { storageNamespace: context.config.storageNamespace });
26
+ const probe = path.join(storage.storageDir, PROBE_FILE);
27
+ await context.artifacts.writeTextFile(probe, "probe\n", { mode: 0o600 });
28
+ await context.artifacts.removeFile(probe);
29
+ return {
30
+ kind: "ok",
31
+ summary: [
32
+ `ok: gate ${context.config.gateId}`,
33
+ ` ${context.config.obligations.length} obligation(s), ${context.config.providers.length} provider(s)`,
34
+ ` store ${storage.storageDir} (writable)`,
35
+ ` delivery record path ${context.config.deliveryRecordPath} (base movement: ${context.config.deliveryRecordVerification.baseMovement})`,
36
+ ].join("\n"),
37
+ };
38
+ } catch (error) {
39
+ if (error instanceof BlockedError) {
40
+ return { kind: "blocked", blockers: [...error.blockers] };
41
+ }
42
+ throw error;
43
+ }
44
+ },
45
+ };
@@ -0,0 +1,46 @@
1
+ /**
2
+ * `gate` — evaluate the delivery gate and, under a TTY, offer a scoped waiver.
3
+ *
4
+ * The command classifies the execution context from this invocation's env and
5
+ * TTY, then runs the admission adapter. The waiver prompt is the one piece of
6
+ * interactive I/O the CLI owns: it is handed to admission only when the boundary
7
+ * saw a real TTY (the boundary already gated `context.promptForWaiver` on that),
8
+ * so a non-interactive run can never be prompted — it blocks. Admission itself
9
+ * only ever offers a waiver to a `human` context, all-or-nothing over waivable
10
+ * findings; the CLI adds no waiver logic of its own.
11
+ */
12
+ import { runAdmission } from "@agent-delivery-harness/kernel";
13
+ import type { CommandContext, CommandDescriptor, CommandResult } from "../boundary.ts";
14
+
15
+ export const gateCommand: CommandDescriptor = {
16
+ name: "gate",
17
+ sourceId: "delivery-harness.cli.gate",
18
+ summary: "Evaluate the delivery gate for the current candidate.",
19
+ 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
+ );
35
+
36
+ if (result.admitted) {
37
+ const waiverNote =
38
+ result.waiver === "accepted"
39
+ ? ` (waived: ${result.waivedObligationIds.join(", ")})`
40
+ : "";
41
+ const kinds = (result.decision?.resolutions ?? []).map((resolution) => `${resolution.obligationId}=${resolution.kind}`);
42
+ return { kind: "ok", summary: `admitted${waiverNote}: ${kinds.join(", ")}` };
43
+ }
44
+ return { kind: "blocked", blockers: [...result.blockers] };
45
+ },
46
+ };
@@ -0,0 +1,31 @@
1
+ /**
2
+ * `prepare` — capture the candidate and publish its preparation receipt.
3
+ *
4
+ * The receipt is the ordering mechanism: no receipt, no review context, no
5
+ * admission. Capturing an unprepared tree (dirty or with untracked files) is a
6
+ * typed block, not a silent skip.
7
+ */
8
+ import { publishPreparationReceipt } from "@agent-delivery-harness/kernel";
9
+ import type { CommandContext, CommandDescriptor, CommandResult } from "../boundary.ts";
10
+
11
+ export const prepareCommand: CommandDescriptor = {
12
+ name: "prepare",
13
+ sourceId: "delivery-harness.cli.prepare",
14
+ summary: "Capture the candidate and publish its preparation receipt.",
15
+ async run(context: CommandContext): Promise<CommandResult> {
16
+ const wiring = await context.wire();
17
+ const capture = await wiring.captureCandidate();
18
+ if (!capture.ok) {
19
+ return { kind: "blocked", blockers: [...capture.blockers] };
20
+ }
21
+ const published = await publishPreparationReceipt(
22
+ context.rootDir,
23
+ { config: context.config, candidate: capture.candidate },
24
+ wiring.storageOptions,
25
+ );
26
+ return {
27
+ kind: "ok",
28
+ summary: `prepared ${context.config.gateId}: tree ${capture.candidate.treeSha} (${capture.candidate.mode}); receipt ${published.path}`,
29
+ };
30
+ },
31
+ };
@@ -0,0 +1,96 @@
1
+ /**
2
+ * `record` — promote an admitted gate into the tracked delivery record.
3
+ *
4
+ * The record is the one artifact that crosses out of the git-private workspace
5
+ * into the tracked tree. The command runs the gate, refuses unless it admitted,
6
+ * and refuses again if the deliverable identity moved between the gate and the
7
+ * write — a record must describe the candidate it attests, so a re-capture
8
+ * adjacent to the write is what makes "record after an edit" a refusal rather
9
+ * than a lie. The record object and its bytes are produced by the kernel
10
+ * (`delivery-record.ts`, produce-only); the single write goes through the fs
11
+ * port, and the candidate-keyed path keeps parallel branches from colliding
12
+ * while staying exactly recomputable by the Action.
13
+ */
14
+ import {
15
+ buildDeliveryRecord,
16
+ deliveryRecordBytes,
17
+ deliveryRecordPathFor,
18
+ discoverRecords,
19
+ runAdmission,
20
+ type EvidenceRecord,
21
+ } from "@agent-delivery-harness/kernel";
22
+ import path from "node:path";
23
+ import { commandBlocker } from "../boundary.ts";
24
+ import type { CommandContext, CommandDescriptor, CommandResult } from "../boundary.ts";
25
+
26
+ export const recordCommand: CommandDescriptor = {
27
+ name: "record",
28
+ sourceId: "delivery-harness.cli.record",
29
+ summary: "Write the tracked delivery record for an admitted gate.",
30
+ async run(context: CommandContext): Promise<CommandResult> {
31
+ const wiring = await context.wire();
32
+
33
+ // The gate is run without a prompt: `record` is not the waiver surface. If a
34
+ // waiver is needed, the operator runs `gate` first; here a non-admitting gate
35
+ // is simply a refusal.
36
+ const admission = await runAdmission(
37
+ { rootDir: context.rootDir, config: context.config, context: context.classifyContext() },
38
+ { captureCandidate: wiring.captureCandidate, projectActivation: wiring.projectActivation, ...wiring.storageOptions },
39
+ );
40
+ if (!admission.admitted || admission.decision === undefined) {
41
+ return { kind: "blocked", blockers: [...admission.blockers] };
42
+ }
43
+ const decision = admission.decision;
44
+
45
+ // Refuse when the gate result no longer describes the current deliverable
46
+ // identity: a re-capture adjacent to the write.
47
+ const recheck = await wiring.captureCandidate();
48
+ if (!recheck.ok) {
49
+ return { kind: "blocked", blockers: [...recheck.blockers] };
50
+ }
51
+ if (recheck.candidate.deliverable.digest !== decision.candidate.deliverable.digest) {
52
+ return {
53
+ kind: "blocked",
54
+ blockers: [
55
+ commandBlocker({
56
+ code: "record_identity_changed",
57
+ sourceId: "delivery-harness.cli.record",
58
+ summary: "The deliverable identity changed after the gate; nothing was recorded.",
59
+ details: `gate ${decision.candidate.deliverable.digest} but current ${recheck.candidate.deliverable.digest}`,
60
+ remediations: [
61
+ {
62
+ id: "reprepare-and-record",
63
+ kind: "command",
64
+ command: ["delivery-harness", "prepare"],
65
+ summary: "Re-prepare the candidate and re-run the gate before recording.",
66
+ },
67
+ ],
68
+ }),
69
+ ],
70
+ };
71
+ }
72
+
73
+ // Gather the evidence records backing the decision so each evidence claim can
74
+ // be stamped with its manifest digest.
75
+ const evidenceRecords: EvidenceRecord[] = [];
76
+ for (const obligation of context.config.obligations) {
77
+ const discovery = await discoverRecords(context.rootDir, {
78
+ gateId: context.config.gateId,
79
+ obligationId: obligation.id,
80
+ ...wiring.storageOptions,
81
+ });
82
+ evidenceRecords.push(...discovery.records);
83
+ }
84
+
85
+ const built = buildDeliveryRecord({ config: context.config, decision, evidenceRecords });
86
+ if (!built.ok) {
87
+ return { kind: "blocked", blockers: [...built.blockers] };
88
+ }
89
+
90
+ const relativePath = deliveryRecordPathFor(context.config, decision.candidate.deliverable.digest);
91
+ const absolutePath = path.join(context.rootDir, relativePath);
92
+ await context.artifacts.writeTextFile(absolutePath, deliveryRecordBytes(built.record));
93
+
94
+ return { kind: "ok", summary: `recorded ${relativePath}` };
95
+ },
96
+ };
@@ -0,0 +1,66 @@
1
+ /**
2
+ * `review-context` — the reviewable-change context for a prepared candidate.
3
+ *
4
+ * Requires a current receipt. A missing receipt blocks and names `prepare`; a
5
+ * stale one blocks with its own distinct class. Only once the receipt is current
6
+ * does the command report what a provider should review and submit evidence for.
7
+ */
8
+ import { evaluatePreparationReceipt } from "@agent-delivery-harness/kernel";
9
+ import { commandBlocker } from "../boundary.ts";
10
+ import type { CommandContext, CommandDescriptor, CommandResult } from "../boundary.ts";
11
+
12
+ export const reviewContextCommand: CommandDescriptor = {
13
+ name: "review-context",
14
+ sourceId: "delivery-harness.cli.review-context",
15
+ summary: "Show the reviewable-change context for the prepared candidate.",
16
+ async run(context: CommandContext): Promise<CommandResult> {
17
+ const wiring = await context.wire();
18
+ const capture = await wiring.captureCandidate();
19
+ if (!capture.ok) {
20
+ return { kind: "blocked", blockers: [...capture.blockers] };
21
+ }
22
+
23
+ const evaluation = await evaluatePreparationReceipt(
24
+ context.rootDir,
25
+ { config: context.config, candidate: capture.candidate },
26
+ wiring.storageOptions,
27
+ );
28
+ if (!evaluation.prepared) {
29
+ // The kernel's receipt blockers already name the failure class; when the
30
+ // receipt is absent, point the operator at the command that creates it.
31
+ const blockers = [...evaluation.blockers];
32
+ if (evaluation.failure === "missing") {
33
+ blockers.push(
34
+ commandBlocker({
35
+ code: "review_context_requires_receipt",
36
+ sourceId: "delivery-harness.cli.review-context",
37
+ summary: "Review context is unavailable until the candidate is prepared.",
38
+ remediations: [
39
+ {
40
+ id: "run-prepare",
41
+ kind: "command",
42
+ command: ["delivery-harness", "prepare"],
43
+ summary: "Publish a preparation receipt for the current candidate.",
44
+ },
45
+ ],
46
+ }),
47
+ );
48
+ }
49
+ return { kind: "blocked", blockers };
50
+ }
51
+
52
+ const projection = await wiring.projectActivation(capture.candidate);
53
+ const active = projection.relevantLineCount >= context.config.activationThreshold || projection.hasRelevantBinaryChange;
54
+ return {
55
+ kind: "ok",
56
+ summary: [
57
+ `review context for ${context.config.gateId}:`,
58
+ ` candidate tree ${capture.candidate.treeSha} (${capture.candidate.mode})`,
59
+ ` relevant lines ${projection.relevantLineCount} across ${projection.changedEntryCount} changed entr${projection.changedEntryCount === 1 ? "y" : "ies"}`,
60
+ ` activation ${active ? "active" : "inactive"} (threshold ${context.config.activationThreshold})`,
61
+ ...(projection.sensitivePathIds.length > 0 ? [` sensitive: ${projection.sensitivePathIds.join(", ")}`] : []),
62
+ ` submit evidence with: delivery-harness submit-evidence --manifest <path>`,
63
+ ].join("\n"),
64
+ };
65
+ },
66
+ };
@@ -0,0 +1,48 @@
1
+ /**
2
+ * `submit-evidence` — validate a provider manifest and publish per-claim records.
3
+ *
4
+ * A thin caller over the recorder: the SUB rules, the run-root containment and
5
+ * artifact digest checks, and the record writes all live in the kernel. The
6
+ * command's only jobs are to find the manifest path in argv and to map the three
7
+ * submission outcomes onto exit codes. Rejections and blocks both surface their
8
+ * typed blockers; an acceptance reports the digest and the records it wrote.
9
+ */
10
+ import { submitManifest } from "@agent-delivery-harness/kernel";
11
+ import type { CommandContext, CommandDescriptor, CommandResult } from "../boundary.ts";
12
+
13
+ function manifestPathFrom(args: readonly string[]): string | undefined {
14
+ const flagIndex = args.indexOf("--manifest");
15
+ if (flagIndex !== -1) return args[flagIndex + 1];
16
+ // A lone positional is accepted too, but never a flag mistaken for a path.
17
+ const positional = args.find((argument) => !argument.startsWith("-"));
18
+ return positional;
19
+ }
20
+
21
+ export const submitEvidenceCommand: CommandDescriptor = {
22
+ name: "submit-evidence",
23
+ sourceId: "delivery-harness.cli.submit-evidence",
24
+ summary: "Validate a provider manifest and publish its evidence records.",
25
+ async run(context: CommandContext): Promise<CommandResult> {
26
+ const manifestPath = manifestPathFrom(context.args);
27
+ if (manifestPath === undefined || manifestPath === "") {
28
+ return { kind: "usage", message: "submit-evidence requires --manifest <path>." };
29
+ }
30
+
31
+ const wiring = await context.wire();
32
+ const outcome = await submitManifest(
33
+ { rootDir: context.rootDir, manifestPath, config: context.config },
34
+ { captureCandidate: wiring.captureCandidate, artifacts: context.artifacts, ...wiring.storageOptions },
35
+ );
36
+
37
+ if (outcome.status === "accepted") {
38
+ const lines = outcome.records.map(
39
+ (record) => ` ${record.obligationId}: ${record.status} ${record.recordId}`,
40
+ );
41
+ return {
42
+ kind: "ok",
43
+ summary: [`accepted (manifestDigest ${outcome.manifestDigest}):`, ...lines].join("\n"),
44
+ };
45
+ }
46
+ return { kind: "blocked", blockers: [...outcome.blockers] };
47
+ },
48
+ };
@@ -0,0 +1,83 @@
1
+ /**
2
+ * `verify` — recompute the deliverable identity and check the tracked record.
3
+ *
4
+ * The command captures the current candidate, derives the candidate-keyed record
5
+ * path from the recomputed deliverable identity (the same exact lookup the
6
+ * Action performs from the PR head), reads and parses the record, and hands it to
7
+ * the pure `verifyDeliveryRecord` core. A missing record names the command that
8
+ * writes it; a failed check surfaces the named drift class. When the base-movement
9
+ * policy is `allow`, a passing check that relaxed base drift names the relaxation.
10
+ */
11
+ import { readFile } from "node:fs/promises";
12
+ import path from "node:path";
13
+ import { deliveryRecordPathFor, parseDeliveryRecord, verifyDeliveryRecord } from "@agent-delivery-harness/kernel";
14
+ import { commandBlocker } from "../boundary.ts";
15
+ import type { CommandContext, CommandDescriptor, CommandResult } from "../boundary.ts";
16
+
17
+ export const verifyCommand: CommandDescriptor = {
18
+ name: "verify",
19
+ sourceId: "delivery-harness.cli.verify",
20
+ summary: "Verify the tracked delivery record against the current candidate.",
21
+ async run(context: CommandContext): Promise<CommandResult> {
22
+ const wiring = await context.wire();
23
+ const capture = await wiring.captureCandidate();
24
+ if (!capture.ok) {
25
+ return { kind: "blocked", blockers: [...capture.blockers] };
26
+ }
27
+ const identity = {
28
+ deliverableDigest: capture.candidate.deliverable.digest,
29
+ identityToken: capture.candidate.deliverable.identity,
30
+ };
31
+ const base = {
32
+ ref: capture.candidate.base.ref,
33
+ tipSha: capture.candidate.base.tipSha,
34
+ mergeBaseSha: capture.candidate.base.mergeBaseSha,
35
+ };
36
+
37
+ const relativePath = deliveryRecordPathFor(context.config, identity.deliverableDigest);
38
+ const absolutePath = path.join(context.rootDir, relativePath);
39
+
40
+ let text: string;
41
+ try {
42
+ text = await readFile(absolutePath, "utf8");
43
+ } catch {
44
+ return {
45
+ kind: "blocked",
46
+ blockers: [
47
+ commandBlocker({
48
+ code: "delivery_record_missing",
49
+ sourceId: "delivery-harness.cli.verify",
50
+ summary: "No delivery record describes the current candidate.",
51
+ details: `expected ${relativePath}`,
52
+ remediations: [
53
+ {
54
+ id: "run-record",
55
+ kind: "command",
56
+ command: ["delivery-harness", "record"],
57
+ summary: "Record the admitted gate for this candidate.",
58
+ },
59
+ ],
60
+ }),
61
+ ],
62
+ };
63
+ }
64
+
65
+ const parsed = parseDeliveryRecord(text);
66
+ if (!parsed.ok) {
67
+ return { kind: "blocked", blockers: [...parsed.blockers] };
68
+ }
69
+
70
+ const check = verifyDeliveryRecord(context.config, parsed.record, identity, base);
71
+ if (!check.ok) {
72
+ return { kind: "blocked", blockers: [...check.blockers] };
73
+ }
74
+
75
+ const relaxation = check.baseMovementRelaxed
76
+ ? ` (base movement relaxed by policy: ${check.relaxedDriftClasses.join(", ")})`
77
+ : "";
78
+ return {
79
+ kind: "ok",
80
+ summary: `verified ${relativePath}${relaxation}; attestation: ${check.attestationLabel}`,
81
+ };
82
+ },
83
+ };
package/src/index.ts ADDED
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Delivery harness CLI: the seven-command operator surface.
3
+ *
4
+ * THE COMMAND REGISTRY. `COMMANDS` is the single source of truth for which
5
+ * commands exist. Every command module under `commands/` must appear here, and
6
+ * `scripts/check-cli-inventory.ts` enforces that against the filesystem: a
7
+ * command file that is not registered is a finding, and an empty registry fails
8
+ * the sensor outright (a CLI that offers nothing is not a CLI). Registration is
9
+ * the mechanism; the sensor is only the alarm.
10
+ *
11
+ * The boundary, the exit codes, the config loader, and the repo wiring all live
12
+ * in `boundary.ts`; each command is a thin, testable unit behind it.
13
+ */
14
+ import { checkCommand } from "./commands/check.ts";
15
+ import { gateCommand } from "./commands/gate.ts";
16
+ import { prepareCommand } from "./commands/prepare.ts";
17
+ import { recordCommand } from "./commands/record.ts";
18
+ import { reviewContextCommand } from "./commands/review-context.ts";
19
+ import { submitEvidenceCommand } from "./commands/submit-evidence.ts";
20
+ import { verifyCommand } from "./commands/verify.ts";
21
+ import { runCliBoundary, type CliRuntime, type CommandDescriptor } from "./boundary.ts";
22
+
23
+ export const PACKAGE_NAME = "@agent-delivery-harness/cli";
24
+
25
+ /**
26
+ * The command registry. The order here is the order `--help` lists them, and it
27
+ * follows the loop an operator walks: prepare, review, submit, gate, record,
28
+ * verify — with `check` last as the standalone preflight.
29
+ */
30
+ export const COMMANDS: readonly CommandDescriptor[] = [
31
+ prepareCommand,
32
+ reviewContextCommand,
33
+ submitEvidenceCommand,
34
+ gateCommand,
35
+ recordCommand,
36
+ verifyCommand,
37
+ checkCommand,
38
+ ];
39
+
40
+ export {
41
+ EXIT_INTERRUPTED,
42
+ EXIT_OK,
43
+ EXIT_POLICY,
44
+ EXIT_USAGE,
45
+ CliInterruption,
46
+ runCliBoundary,
47
+ wireRepo,
48
+ importHarnessConfig,
49
+ commandBlocker,
50
+ type CliRuntime,
51
+ type CommandContext,
52
+ type CommandDescriptor,
53
+ type CommandResult,
54
+ type RepoWiring,
55
+ } from "./boundary.ts";
56
+
57
+ export { prepareCommand } from "./commands/prepare.ts";
58
+ export { reviewContextCommand } from "./commands/review-context.ts";
59
+ export { submitEvidenceCommand } from "./commands/submit-evidence.ts";
60
+ export { gateCommand } from "./commands/gate.ts";
61
+ export { recordCommand } from "./commands/record.ts";
62
+ export { verifyCommand } from "./commands/verify.ts";
63
+ export { checkCommand } from "./commands/check.ts";
64
+
65
+ /** Runs the CLI against a runtime and returns the process exit code. */
66
+ export function runCli(argv: readonly string[], runtime: CliRuntime): Promise<number> {
67
+ return runCliBoundary(argv, COMMANDS, runtime);
68
+ }
package/src/main.ts ADDED
@@ -0,0 +1,148 @@
1
+ /**
2
+ * The executable entry point: builds a {@link CliRuntime} from the ambient
3
+ * process and runs the CLI.
4
+ *
5
+ * This is the one CLI module that reads `process`, `stdin`/`stdout` TTY flags,
6
+ * and installs a SIGINT handler — all inside functions, never at import time.
7
+ * The interactive waiver prompt lives here because it is the boundary's I/O: it
8
+ * prints every obligation one "yes" would cover, reads a single line, and turns
9
+ * a Ctrl-C into the typed {@link CliInterruption} the boundary maps to exit 130.
10
+ */
11
+ import { realpathSync } from "node:fs";
12
+ import { createInterface } from "node:readline";
13
+ import { fileURLToPath } from "node:url";
14
+ import { CliInterruption, EXIT_POLICY, runCli, type CliRuntime } from "./index.ts";
15
+ import type { WaiverPrompt } from "@agent-delivery-harness/kernel";
16
+
17
+ /**
18
+ * Reads a yes/no answer after naming every obligation the waiver would cover.
19
+ * A SIGINT during the read rejects with {@link CliInterruption} rather than
20
+ * tearing the process down, so the boundary can report exit 130.
21
+ */
22
+ /**
23
+ * EVERY PATH OUT OF THIS PROMPT SETTLES.
24
+ *
25
+ * A readline question has three exits, not one. The callback fires on a
26
+ * submitted line; `SIGINT` fires on Ctrl-C; and `close` fires on Ctrl-D — stdin
27
+ * reaching EOF with no line ever submitted. Waiting only on the callback leaves
28
+ * the EOF path hanging forever: the returned promise never settles, the awaiting
29
+ * gate never returns, the event loop drains with nothing left to do, and Node
30
+ * exits 0. A gate that admitted nothing then reports success, which is the worst
31
+ * failure this program has — a wrongful pass at the merge gate.
32
+ *
33
+ * So `close` resolves, and it resolves *false*: the prompt is `[y/N]`, its
34
+ * default is decline, and a caller who never said yes has not said yes. The
35
+ * settle guard makes the three exits mutually exclusive, because `close` also
36
+ * fires immediately after the other two — without it, an interrupted prompt
37
+ * would reject and then resolve, and a settled promise silently ignoring its
38
+ * second settlement is exactly how this class of bug hides.
39
+ */
40
+ export function createWaiverPrompt(input: NodeJS.ReadableStream, output: NodeJS.WritableStream): WaiverPrompt {
41
+ return (_decision, obligationIds) =>
42
+ new Promise<boolean>((resolve, reject) => {
43
+ const rl = createInterface({ input, output });
44
+ let settled = false;
45
+ const settle = (action: () => void): void => {
46
+ if (settled) return;
47
+ settled = true;
48
+ action();
49
+ };
50
+
51
+ rl.on("SIGINT", () => {
52
+ settle(() => {
53
+ rl.close();
54
+ reject(new CliInterruption("Waiver prompt interrupted."));
55
+ });
56
+ });
57
+ // Ctrl-D, a closed pipe, or any other end of input.
58
+ rl.on("close", () => {
59
+ settle(() => resolve(false));
60
+ });
61
+
62
+ output.write(`Waiving covers ${obligationIds.length} obligation(s): ${obligationIds.join(", ")}.\n`);
63
+ rl.question("Waive all of them? [y/N] ", (answer) => {
64
+ settle(() => {
65
+ rl.close();
66
+ resolve(/^\s*y(es)?\s*$/i.test(answer));
67
+ });
68
+ });
69
+ });
70
+ }
71
+
72
+ export const readlineWaiverPrompt: WaiverPrompt = (decision, obligationIds) =>
73
+ createWaiverPrompt(process.stdin, process.stderr)(decision, obligationIds);
74
+
75
+ /** The spelling the filesystem can vouch for: the realpath where it can answer, the spelling itself where it cannot. */
76
+ function canonicalEntryPath(entryPath: string): string {
77
+ try {
78
+ return realpathSync(entryPath);
79
+ } catch {
80
+ return entryPath;
81
+ }
82
+ }
83
+
84
+ /**
85
+ * Whether this module is the entry the process was started with.
86
+ *
87
+ * argv and `import.meta.url` may spell the same file differently: argv is the
88
+ * caller's spelling, and Node builds the module URL from the realpath by
89
+ * default but from the caller's spelling under `--preserve-symlinks-main`. So
90
+ * each side is canonicalized independently and the canonical forms compared:
91
+ * a symlinked spelling matches its realpath whenever the link can be read
92
+ * (`/tmp` → `/private/tmp` on macOS, a wrapper script's stored path, a pnpm
93
+ * workspace link), and equal spellings still match when neither side resolves.
94
+ *
95
+ * What is NOT claimed: a symlink the filesystem cannot resolve cannot be seen
96
+ * through, and the failing-exit-code floor below sits inside this guard, so an
97
+ * under-match exits 0 in silence — the CLI reporting success having verified
98
+ * nothing. The floor cannot be hoisted above the guard: that would stamp a
99
+ * failing exit code on every process that merely *imports* this module. And a
100
+ * non-`file:` module href (a bundled or single-executable build) never
101
+ * matches — such a build must invoke `main` explicitly.
102
+ */
103
+ export function invokedDirectly(argvEntry: string | undefined, moduleHref: string): boolean {
104
+ if (argvEntry === undefined) return false;
105
+ let modulePath: string;
106
+ try {
107
+ modulePath = fileURLToPath(moduleHref);
108
+ } catch {
109
+ return false;
110
+ }
111
+ return canonicalEntryPath(argvEntry) === canonicalEntryPath(modulePath);
112
+ }
113
+
114
+ export function defaultRuntime(): CliRuntime {
115
+ return {
116
+ cwd: process.cwd(),
117
+ env: process.env,
118
+ stdinIsTTY: process.stdin.isTTY === true,
119
+ stdoutIsTTY: process.stdout.isTTY === true,
120
+ stdout: (text) => process.stdout.write(text),
121
+ stderr: (text) => process.stderr.write(text),
122
+ promptForWaiver: readlineWaiverPrompt,
123
+ };
124
+ }
125
+
126
+ export async function main(argv: readonly string[]): Promise<number> {
127
+ return runCli(argv, defaultRuntime());
128
+ }
129
+
130
+ if (invokedDirectly(process.argv[1], import.meta.url)) {
131
+ // FAIL CLOSED BEFORE ANYTHING RUNS.
132
+ //
133
+ // The exit code starts at a failure and is overwritten only by a real verdict.
134
+ // Node's default is 0, so *any* way of leaving without settling — a promise
135
+ // that never resolves, an event loop that drains early, a path nobody has
136
+ // thought of yet — reports success from a gate that decided nothing. Setting
137
+ // it first inverts that default: the only way to exit 0 is for the boundary to
138
+ // have returned 0.
139
+ process.exitCode = EXIT_POLICY;
140
+ main(process.argv.slice(2))
141
+ .then((code) => {
142
+ process.exitCode = code;
143
+ })
144
+ .catch((error: unknown) => {
145
+ process.stderr.write(`${error instanceof Error ? error.stack ?? error.message : String(error)}\n`);
146
+ process.exitCode = EXIT_POLICY;
147
+ });
148
+ }