@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 +202 -0
- package/NOTICE +5 -0
- package/package.json +22 -0
- package/src/boundary.ts +326 -0
- package/src/commands/check.ts +45 -0
- package/src/commands/gate.ts +46 -0
- package/src/commands/prepare.ts +31 -0
- package/src/commands/record.ts +96 -0
- package/src/commands/review-context.ts +66 -0
- package/src/commands/submit-evidence.ts +48 -0
- package/src/commands/verify.ts +83 -0
- package/src/index.ts +68 -0
- package/src/main.ts +148 -0
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
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
|
+
}
|
package/src/boundary.ts
ADDED
|
@@ -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
|
+
}
|