cadet-agent 0.53.0 → 0.56.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/README.md +18 -4
- package/package.json +1 -1
- package/src/cli.mjs +698 -17
- package/src/harness/acceptance-form.mjs +281 -0
- package/src/harness/architecture.mjs +201 -0
- package/src/harness/commands.mjs +60 -0
- package/src/harness/context-protocol.mjs +470 -0
- package/src/harness/design-review.mjs +162 -0
- package/src/harness/gates.mjs +364 -0
- package/src/harness/hosts.mjs +349 -0
- package/src/harness/index.mjs +42 -3
- package/src/harness/policy.mjs +283 -10
- package/src/harness/routing.mjs +14 -2
- package/src/harness/state.mjs +185 -15
- package/src/harness/status.mjs +132 -0
- package/src/harness/verification.mjs +51 -28
- package/src/install.mjs +129 -0
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Human acceptance: the form, and reading it back.
|
|
3
|
+
*
|
|
4
|
+
* The gate is human-owned, but the RECORD should not be homework. Typing the same
|
|
5
|
+
* acceptance into a hand-written template and then into six CLI flags is duplication,
|
|
6
|
+
* and duplication is what turns a gate into a documentation chore: the two copies
|
|
7
|
+
* drift, the person fills both, and the one that matters is whichever the machine read.
|
|
8
|
+
*
|
|
9
|
+
* So the capture works the way the design review does — the artifact IS the input —
|
|
10
|
+
* with one difference that makes it easy rather than merely possible: the form is
|
|
11
|
+
* GENERATED from state first, so everything the framework already knows (the epic, its
|
|
12
|
+
* stories, the revision, the files, the candidate limitations) is already written down
|
|
13
|
+
* and the person fills only the two things only a person can say: what they did and saw,
|
|
14
|
+
* and what they accepted as missing. The command to record it is printed at the bottom
|
|
15
|
+
* of the form itself, with the real path in it.
|
|
16
|
+
*
|
|
17
|
+
* Two commands, no retyping, and they are the only route — there are no
|
|
18
|
+
* `--witness`/`--limitations` flags to type the same acceptance into twice:
|
|
19
|
+
*
|
|
20
|
+
* cadet-agent harness acceptance-form --epic <id> # writes a filled-in form
|
|
21
|
+
* cadet-agent harness confirm --gate humanAcceptanceConfirmed --artifact <the form>
|
|
22
|
+
*
|
|
23
|
+
* The shape lives in `templates/HumanAcceptanceTemplate.md`, which the generator reads,
|
|
24
|
+
* so the shipped template and the generated form cannot drift into two formats.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { existsSync, readFileSync, mkdirSync, writeFileSync } from 'node:fs';
|
|
28
|
+
import { spawnSync } from 'node:child_process';
|
|
29
|
+
import { dirname, join } from 'node:path';
|
|
30
|
+
|
|
31
|
+
/** The gate this form records. */
|
|
32
|
+
export const ACCEPTANCE_FORM_GATE = 'humanAcceptanceConfirmed';
|
|
33
|
+
|
|
34
|
+
/** Where a generated form goes inside an epic's own artifact directory. */
|
|
35
|
+
export const ACCEPTANCE_FORM_FILENAME = 'HumanAcceptance.md';
|
|
36
|
+
|
|
37
|
+
/** The template the generator fills. A managed path, so a synced repository has it. */
|
|
38
|
+
export const ACCEPTANCE_TEMPLATE_RELATIVE = '.cadet/agent/core/templates/HumanAcceptanceTemplate.md';
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* The fields only a person can supply. The generator leaves all three as unfilled
|
|
42
|
+
* slots, and the parser refuses a form where any of them still is one: a sentinel left
|
|
43
|
+
* in place means the person did not answer, and recording that would put the
|
|
44
|
+
* framework's own placeholder text into the record as if someone had written it.
|
|
45
|
+
*/
|
|
46
|
+
export const ACCEPTANCE_HUMAN_FIELDS = Object.freeze(['acceptor', 'witness', 'limitations']);
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* A slot that the generator failed to fill is still a slot.
|
|
50
|
+
*
|
|
51
|
+
* Matched attribute-wise rather than up to the first `>`: a slot's `note` is prose and
|
|
52
|
+
* some notes contain `>` (`revision=<sha>`, for one), so a naive `[^>]*` stops
|
|
53
|
+
* mid-attribute and leaves the slot unfilled. Silent is the bad part — the form then
|
|
54
|
+
* records the template's own instructions as an answer.
|
|
55
|
+
*/
|
|
56
|
+
const UNFILLED = /<slot\b/;
|
|
57
|
+
const slotTag = (id) => new RegExp(`<slot\\s+id="${id}"(?:[^>"]|"[^"]*")*/>`);
|
|
58
|
+
|
|
59
|
+
/** True when a captured value is empty or still the template's placeholder. */
|
|
60
|
+
export function isUnfilled(value) {
|
|
61
|
+
return value === null || value === undefined || String(value).trim() === '' || UNFILLED.test(String(value));
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** The path of the generated form for an epic. */
|
|
65
|
+
export function acceptanceFormPath(targetDir, epicId) {
|
|
66
|
+
return join(targetDir, '.cadet', 'agent', 'project-plans', epicId, ACCEPTANCE_FORM_FILENAME);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** The machine's best answer for "which revision is this", or `unknown`. */
|
|
70
|
+
function revisionOf(targetDir) {
|
|
71
|
+
try {
|
|
72
|
+
const r = spawnSync('git', ['-C', targetDir, 'rev-parse', '--short', 'HEAD'], { encoding: 'utf-8', windowsHide: true });
|
|
73
|
+
const sha = (r.stdout || '').trim();
|
|
74
|
+
return r.status === 0 && sha ? sha : 'unknown';
|
|
75
|
+
} catch {
|
|
76
|
+
return 'unknown';
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** The Unity editor version, when the repository records one. */
|
|
81
|
+
function editorVersionOf(targetDir) {
|
|
82
|
+
try {
|
|
83
|
+
const text = readFileSync(join(targetDir, 'ProjectSettings', 'ProjectVersion.txt'), 'utf-8');
|
|
84
|
+
const m = text.match(/m_EditorVersion:\s*(\S+)/);
|
|
85
|
+
return m ? m[1] : null;
|
|
86
|
+
} catch {
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* What the machine already knows, written into the form so nobody retypes it.
|
|
93
|
+
*
|
|
94
|
+
* The candidates section is the part that saves the most work: the limitations a person
|
|
95
|
+
* would otherwise have to remember are exactly the ones the record already holds — a
|
|
96
|
+
* deferral that is still open, an exception someone accepted earlier, a story that never
|
|
97
|
+
* reached validation. They are listed as candidates, not as claims: the person keeps what
|
|
98
|
+
* applies and deletes the rest, and the parser reads only the Accepted limitations
|
|
99
|
+
* section, never this list.
|
|
100
|
+
*/
|
|
101
|
+
export function acceptanceCandidates(state) {
|
|
102
|
+
const out = [];
|
|
103
|
+
const epic = state?.activeWorkItem?.epicId;
|
|
104
|
+
const epics = state?.epics || {};
|
|
105
|
+
|
|
106
|
+
for (const [epicId, entry] of Object.entries(epics)) {
|
|
107
|
+
for (const [story, status] of Object.entries(entry?.stories || {})) {
|
|
108
|
+
if (status !== 'done' && status !== 'validated') {
|
|
109
|
+
out.push(`${epicId === epic ? '' : `(${epicId}) `}${story} is "${status}", not done — say whether that is accepted or outstanding`);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
for (const exception of state?.gateExceptions || []) {
|
|
115
|
+
const expiry = exception.expiresAt ? `expires ${String(exception.expiresAt).slice(0, 10)}` : 'no expiry';
|
|
116
|
+
out.push(`gate \`${exception.gate}\` was excepted as \`${exception.category}\` (${expiry})`);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (Array.isArray(state?.gateEvidence)) {
|
|
120
|
+
const deferred = state.gateEvidence.filter((e) => typeof e?.result === 'string' && /deferr/i.test(e.result));
|
|
121
|
+
for (const record of deferred) {
|
|
122
|
+
out.push(`\`${record.gate}\` recorded a deferral: ${String(record.result).slice(0, 120)}`);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return out;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Build the form for an epic by filling the shipped template's known slots.
|
|
131
|
+
*
|
|
132
|
+
* Blank slots the person must answer are left exactly as the template wrote them, so an
|
|
133
|
+
* unfinished form is obvious to a reader and refusable by the parser — no second marker
|
|
134
|
+
* convention to learn.
|
|
135
|
+
*/
|
|
136
|
+
export function buildAcceptanceForm({ template, state, epicId, targetDir, now = new Date() }) {
|
|
137
|
+
if (typeof template !== 'string' || template.trim() === '') {
|
|
138
|
+
throw new Error('the human-acceptance template is empty');
|
|
139
|
+
}
|
|
140
|
+
const stories = Object.entries(state?.epics?.[epicId]?.stories || {});
|
|
141
|
+
const files = [];
|
|
142
|
+
for (const [story] of stories) {
|
|
143
|
+
for (const candidate of [
|
|
144
|
+
join('.cadet', 'agent', 'project-plans', epicId, story),
|
|
145
|
+
join('.cadet', 'agent', 'story-breakdown', story),
|
|
146
|
+
]) {
|
|
147
|
+
if (existsSync(join(targetDir, candidate))) files.push(candidate.replace(/\\/g, '/'));
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const environment = [`revision=${revisionOf(targetDir)}`];
|
|
152
|
+
const editor = editorVersionOf(targetDir);
|
|
153
|
+
if (editor) environment.push(`editor=${editor}`);
|
|
154
|
+
environment.push(`date=${now.toISOString().slice(0, 10)}`);
|
|
155
|
+
|
|
156
|
+
const scopeReviewed = stories.length
|
|
157
|
+
? stories.map(([story, status]) => `- ${story} — ${status}`).join('\n')
|
|
158
|
+
: '- (no stories recorded for this epic yet)';
|
|
159
|
+
|
|
160
|
+
const candidates = acceptanceCandidates(state);
|
|
161
|
+
const candidateText = candidates.length
|
|
162
|
+
? candidates.map((line) => `- ${line}`).join('\n')
|
|
163
|
+
: '- (nothing outstanding in state)';
|
|
164
|
+
|
|
165
|
+
const formRelative = acceptanceFormPath(targetDir, epicId)
|
|
166
|
+
.slice(targetDir.length + 1)
|
|
167
|
+
.replace(/\\/g, '/');
|
|
168
|
+
const recording = [
|
|
169
|
+
'1. Fill in **Accepted by**, **## Witness** and **## Accepted limitations** — the three',
|
|
170
|
+
' fields with a `<slot .../>` in them. Everything else is already written down.',
|
|
171
|
+
'2. Record it:',
|
|
172
|
+
'',
|
|
173
|
+
' ```',
|
|
174
|
+
` cadet-agent harness confirm --gate ${ACCEPTANCE_FORM_GATE} --artifact ${formRelative} \\`,
|
|
175
|
+
' --reason "<one line: why this was accepted>" --expires-at <ISO-8601>',
|
|
176
|
+
' ```',
|
|
177
|
+
'',
|
|
178
|
+
' `--reason` and `--expires-at` are required by strict closure, and they are the only',
|
|
179
|
+
' fields to pass: the form already carries the scope and the environment, and passing',
|
|
180
|
+
' `--scope` or `--environment` as well is refused as a second, competing source.',
|
|
181
|
+
'',
|
|
182
|
+
' The command reads this file, binds the files it names, and records the gate. It',
|
|
183
|
+
' refuses a form whose witness or limitations is still a placeholder, so an',
|
|
184
|
+
' unfinished form cannot be recorded by accident.',
|
|
185
|
+
'3. Where a user cannot reach the work at all, do not accept it: record a',
|
|
186
|
+
' `non-user-facing` gate exception instead, naming who judged it.',
|
|
187
|
+
].join('\n');
|
|
188
|
+
|
|
189
|
+
const filled = {
|
|
190
|
+
epic: epicId,
|
|
191
|
+
date: now.toISOString().slice(0, 10),
|
|
192
|
+
files: files.length ? files.join(', ') : '(none found — list the files this acceptance covers)',
|
|
193
|
+
environment: environment.join(', '),
|
|
194
|
+
scopeReviewed,
|
|
195
|
+
candidates: candidateText,
|
|
196
|
+
recording,
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
let text = template;
|
|
200
|
+
for (const [id, value] of Object.entries(filled)) {
|
|
201
|
+
text = replaceSlot(text, id, value);
|
|
202
|
+
}
|
|
203
|
+
return { text, filled: Object.keys(filled), files };
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** Replace one `<slot id="x" .../>` occurrence with a value, keeping a trailing newline sane. */
|
|
207
|
+
function replaceSlot(text, id, value) {
|
|
208
|
+
const re = slotTag(id);
|
|
209
|
+
if (!re.test(text)) return text;
|
|
210
|
+
return text.replace(re, value);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Read a filled form back.
|
|
215
|
+
*
|
|
216
|
+
* Returns the values it found plus `incomplete`, the human fields still unanswered. It
|
|
217
|
+
* makes no judgement about the acceptance itself — that is the point of the gate — and it
|
|
218
|
+
* does not read the candidates section, which exists to help a person write the
|
|
219
|
+
* limitations section, not to supply it.
|
|
220
|
+
*/
|
|
221
|
+
export function parseAcceptanceForm(text) {
|
|
222
|
+
if (typeof text !== 'string' || text.trim() === '') {
|
|
223
|
+
return { incomplete: [...ACCEPTANCE_HUMAN_FIELDS], error: 'the form is empty' };
|
|
224
|
+
}
|
|
225
|
+
const lines = text.split(/\r?\n/);
|
|
226
|
+
|
|
227
|
+
const scalar = (label) => {
|
|
228
|
+
const re = new RegExp(`^${label}:\\s*(.*)$`, 'i');
|
|
229
|
+
for (const line of lines) {
|
|
230
|
+
const m = line.match(re);
|
|
231
|
+
if (m) return m[1].trim();
|
|
232
|
+
}
|
|
233
|
+
return null;
|
|
234
|
+
};
|
|
235
|
+
|
|
236
|
+
const section = (heading) => {
|
|
237
|
+
const start = lines.findIndex((line) => line.trim().toLowerCase() === `## ${heading}`.toLowerCase());
|
|
238
|
+
if (start < 0) return null;
|
|
239
|
+
const body = [];
|
|
240
|
+
for (let i = start + 1; i < lines.length; i += 1) {
|
|
241
|
+
if (/^##\s/.test(lines[i]) || /^---\s*$/.test(lines[i])) break;
|
|
242
|
+
body.push(lines[i]);
|
|
243
|
+
}
|
|
244
|
+
return body.join('\n').trim();
|
|
245
|
+
};
|
|
246
|
+
|
|
247
|
+
const form = {
|
|
248
|
+
// The epic is the form's title, so it is written once and read once. A second
|
|
249
|
+
// `Epic:` line would be a copy that can disagree with the heading.
|
|
250
|
+
epic: (text.match(/^#\s*Human Acceptance:\s*(.+)$/m) || [])[1]?.trim() || null,
|
|
251
|
+
acceptor: scalar('Accepted by'),
|
|
252
|
+
date: scalar('Date'),
|
|
253
|
+
files: scalar('Files'),
|
|
254
|
+
environment: scalar('Environment'),
|
|
255
|
+
scopeReviewed: section('Scope reviewed'),
|
|
256
|
+
witness: section('Witness'),
|
|
257
|
+
limitations: section('Accepted limitations'),
|
|
258
|
+
};
|
|
259
|
+
|
|
260
|
+
const incomplete = ACCEPTANCE_HUMAN_FIELDS.filter((field) => isUnfilled(form[field]));
|
|
261
|
+
if (isUnfilled(form.epic)) incomplete.push('epic');
|
|
262
|
+
|
|
263
|
+
const fileList = (form.files || '')
|
|
264
|
+
.split(',')
|
|
265
|
+
.map((f) => f.trim().replace(/\\/g, '/'))
|
|
266
|
+
.filter((f) => f && !f.startsWith('('))
|
|
267
|
+
.filter((f) => !isUnfilled(f));
|
|
268
|
+
|
|
269
|
+
return { ...form, fileList, incomplete };
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/** Write the form, create-only: a half-filled form is never overwritten. */
|
|
273
|
+
export function writeAcceptanceForm(targetDir, epicId, text, { out = null } = {}) {
|
|
274
|
+
const path = out || acceptanceFormPath(targetDir, epicId);
|
|
275
|
+
if (existsSync(path)) {
|
|
276
|
+
return { written: false, path, reason: 'a form already exists; edit it and record it, or move it aside to regenerate' };
|
|
277
|
+
}
|
|
278
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
279
|
+
writeFileSync(path, text);
|
|
280
|
+
return { written: true, path };
|
|
281
|
+
}
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Architecture fitness — executable constraints, and the line between them and a review.
|
|
3
|
+
*
|
|
4
|
+
* A project declares checks: dependency direction, a forbidden reference, a layer that may
|
|
5
|
+
* not import another. Each check is a command with a stable id, the files it governs, a
|
|
6
|
+
* severity, and optionally an artifact it must write. This module answers the three
|
|
7
|
+
* questions the gate needs:
|
|
8
|
+
*
|
|
9
|
+
* 1. which checks apply to the files that changed (`applicableChecks`);
|
|
10
|
+
* 2. what each applicable check proved (`runArchitectureChecks`);
|
|
11
|
+
* 3. does the set of results satisfy the gate (`summariseChecks`).
|
|
12
|
+
*
|
|
13
|
+
* What it deliberately does NOT do: decide whether the architecture is good. A command can
|
|
14
|
+
* prove that `using UnityEngine` is absent from a core assembly; it cannot prove the assembly
|
|
15
|
+
* was a good idea. That question belongs to `designReviewCompleted`, and the two gates are
|
|
16
|
+
* separate so that neither claim borrows the other's credibility.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
20
|
+
import { join, resolve } from 'node:path';
|
|
21
|
+
|
|
22
|
+
import { runCommand } from './verification.mjs';
|
|
23
|
+
import { sha256 } from './util.mjs';
|
|
24
|
+
|
|
25
|
+
/** Default time a single check may run, when the check declares none. */
|
|
26
|
+
export const DEFAULT_CHECK_TIMEOUT_MS = 120000;
|
|
27
|
+
|
|
28
|
+
/** Every outcome a single check can reach. */
|
|
29
|
+
export const CHECK_STATUSES = Object.freeze(['passed', 'failed', 'blocked']);
|
|
30
|
+
|
|
31
|
+
const normalise = (path) => String(path).replace(/\\/g, '/').replace(/^\.\//, '');
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Does a scope govern a path? Compared at a directory boundary.
|
|
35
|
+
*
|
|
36
|
+
* `src/Core` governs `src/Core/EnemyGrid.cs` and does NOT govern `src/CoreX/Other.cs`. A
|
|
37
|
+
* plain `startsWith` would say yes to the second, and a check that silently governs the
|
|
38
|
+
* wrong directory is worse than one that governs nothing: it reports a pass for files it
|
|
39
|
+
* never looked at.
|
|
40
|
+
*/
|
|
41
|
+
export function scopeMatches(scope, path) {
|
|
42
|
+
const prefix = normalise(scope).replace(/\/+$/, '');
|
|
43
|
+
const target = normalise(path);
|
|
44
|
+
return target === prefix || target.startsWith(`${prefix}/`);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The checks that apply to a set of files, and the ones that do not.
|
|
49
|
+
*
|
|
50
|
+
* A check with no `files` always applies: it is a constraint on the repository rather than
|
|
51
|
+
* on a directory. A check with scopes applies when any relevant file is under one of them.
|
|
52
|
+
*/
|
|
53
|
+
export function applicableChecks(checks = [], relevantFiles = []) {
|
|
54
|
+
const applicable = [];
|
|
55
|
+
const skipped = [];
|
|
56
|
+
for (const check of checks) {
|
|
57
|
+
const scopes = Array.isArray(check?.files) ? check.files : [];
|
|
58
|
+
const applies = scopes.length === 0 || relevantFiles.some((file) => scopes.some((scope) => scopeMatches(scope, file)));
|
|
59
|
+
(applies ? applicable : skipped).push(check);
|
|
60
|
+
}
|
|
61
|
+
return { applicable, skipped };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Run the applicable checks and classify each outcome.
|
|
66
|
+
*
|
|
67
|
+
* The classification matters more than the runner:
|
|
68
|
+
*
|
|
69
|
+
* - `failed` — the check ran and reported a violation (non-zero exit).
|
|
70
|
+
* - `blocked` — the check never completed: it timed out, it never launched, or it
|
|
71
|
+
* declared an artifact it did not write (or wrote unparseable JSON into). A blocked
|
|
72
|
+
* REQUIRED check does not satisfy the gate, and it is not a red: nothing was disproved.
|
|
73
|
+
* The framework draws the same line for `harness verify`, and drawing it the other way
|
|
74
|
+
* here would report a green tree as broken and a broken check as green.
|
|
75
|
+
*/
|
|
76
|
+
export async function runArchitectureChecks({
|
|
77
|
+
checks = [],
|
|
78
|
+
relevantFiles = [],
|
|
79
|
+
rootDir = process.cwd(),
|
|
80
|
+
defaultTimeoutMs = DEFAULT_CHECK_TIMEOUT_MS,
|
|
81
|
+
runCommandImpl = runCommand,
|
|
82
|
+
evidenceDir = null,
|
|
83
|
+
} = {}) {
|
|
84
|
+
const { applicable, skipped } = applicableChecks(checks, relevantFiles);
|
|
85
|
+
const results = [];
|
|
86
|
+
|
|
87
|
+
for (const check of applicable) {
|
|
88
|
+
const timeoutMs = check.timeoutMs ?? defaultTimeoutMs;
|
|
89
|
+
const run = await runCommandImpl(check.command, {
|
|
90
|
+
cwd: resolve(rootDir, check.cwd || '.'),
|
|
91
|
+
timeoutMs,
|
|
92
|
+
maxInlineBytes: 16000,
|
|
93
|
+
previewBytes: 2000,
|
|
94
|
+
artifactDir: evidenceDir,
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
let status = 'passed';
|
|
98
|
+
let reason = null;
|
|
99
|
+
if (run.timedOut) {
|
|
100
|
+
status = 'blocked';
|
|
101
|
+
reason = `timed out after ${timeoutMs}ms`;
|
|
102
|
+
} else if (run.launchFailed) {
|
|
103
|
+
status = 'blocked';
|
|
104
|
+
reason = `the command never launched (${run.launchFailureReason || 'unknown reason'})`;
|
|
105
|
+
} else if (run.exitCode !== 0) {
|
|
106
|
+
status = 'failed';
|
|
107
|
+
reason = `exit ${run.exitCode}`;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
let artifactPath = null;
|
|
111
|
+
let artifactHash = null;
|
|
112
|
+
if (check.artifact) {
|
|
113
|
+
const absolute = resolve(rootDir, check.artifact);
|
|
114
|
+
if (!existsSync(absolute)) {
|
|
115
|
+
status = status === 'passed' ? 'blocked' : status;
|
|
116
|
+
reason = `${reason ? `${reason}; ` : ''}the check declared an artifact at ${check.artifact} and wrote none`;
|
|
117
|
+
} else {
|
|
118
|
+
const text = readFileSync(absolute, 'utf-8');
|
|
119
|
+
artifactPath = normalise(check.artifact);
|
|
120
|
+
artifactHash = sha256(text);
|
|
121
|
+
if (check.artifactFormat === 'json') {
|
|
122
|
+
try {
|
|
123
|
+
JSON.parse(text);
|
|
124
|
+
} catch (err) {
|
|
125
|
+
status = status === 'passed' ? 'blocked' : status;
|
|
126
|
+
reason = `${reason ? `${reason}; ` : ''}the artifact is not valid JSON (${err.message})`;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
results.push({
|
|
133
|
+
id: check.id,
|
|
134
|
+
severity: check.severity || 'required',
|
|
135
|
+
status,
|
|
136
|
+
command: check.command,
|
|
137
|
+
cwd: check.cwd || '.',
|
|
138
|
+
exitCode: run.exitCode ?? null,
|
|
139
|
+
reason,
|
|
140
|
+
durationMs: run.durationMs ?? null,
|
|
141
|
+
artifactPath,
|
|
142
|
+
artifactHash,
|
|
143
|
+
refs: [...(check.refs || [])],
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return { results, skipped: skipped.map((c) => c.id) };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* The verdict: does this set of results satisfy the gate?
|
|
152
|
+
*
|
|
153
|
+
* A required check that failed or was blocked does not. An advisory check that failed does
|
|
154
|
+
* not block the gate — that is what advisory means — but it is reported in the summary and
|
|
155
|
+
* written into the record, because an advisory result nobody reads is the same as no check
|
|
156
|
+
* at all.
|
|
157
|
+
*/
|
|
158
|
+
export function summariseChecks(results = [], { declared = 0, skipped = [] } = {}) {
|
|
159
|
+
const byId = (status, severity) => results
|
|
160
|
+
.filter((r) => r.status === status && (severity === undefined || r.severity === severity))
|
|
161
|
+
.map((r) => r.id);
|
|
162
|
+
|
|
163
|
+
const failed = byId('failed', 'required');
|
|
164
|
+
const blocked = byId('blocked', 'required');
|
|
165
|
+
const advisoryFailed = [...byId('failed', 'advisory'), ...byId('blocked', 'advisory')];
|
|
166
|
+
const passed = byId('passed');
|
|
167
|
+
|
|
168
|
+
const notes = [];
|
|
169
|
+
if (results.length === 0) {
|
|
170
|
+
notes.push(declared === 0
|
|
171
|
+
? 'the project declares no architecture checks, so nothing was verified'
|
|
172
|
+
: `no check was applicable to the changed files (${skipped.length} declared check(s) skipped)`);
|
|
173
|
+
}
|
|
174
|
+
if (advisoryFailed.length > 0) {
|
|
175
|
+
notes.push(`advisory check(s) did not pass and do not block: ${advisoryFailed.join(', ')}`);
|
|
176
|
+
}
|
|
177
|
+
if (blocked.length > 0) {
|
|
178
|
+
notes.push(`required check(s) could not complete: ${blocked.join(', ')}`);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
return {
|
|
182
|
+
gatePassed: failed.length === 0 && blocked.length === 0,
|
|
183
|
+
passed,
|
|
184
|
+
failed,
|
|
185
|
+
blocked,
|
|
186
|
+
advisoryFailed,
|
|
187
|
+
skipped,
|
|
188
|
+
note: notes.join('; '),
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** A one-line-per-check reading for a human, and for `describe*`-style refusals. */
|
|
193
|
+
export function describeCheckResults(results = [], summary = null) {
|
|
194
|
+
const lines = results.map((r) => {
|
|
195
|
+
const mark = r.status === 'passed' ? '✅' : r.status === 'failed' ? '❌' : '⚠️';
|
|
196
|
+
const refs = r.refs?.length ? ` (${r.refs.join(', ')})` : '';
|
|
197
|
+
return ` ${mark} ${r.id} [${r.severity}] ${r.status}${r.reason ? ` — ${r.reason}` : ''}${refs}`;
|
|
198
|
+
});
|
|
199
|
+
if (summary?.skipped?.length) lines.push(` ⏭ skipped, not applicable: ${summary.skipped.join(', ')}`);
|
|
200
|
+
return lines;
|
|
201
|
+
}
|
package/src/harness/commands.mjs
CHANGED
|
@@ -128,10 +128,62 @@ export const COMMANDS = {
|
|
|
128
128
|
writes: ['.cadet/runs/**', '.cadet/state.json'],
|
|
129
129
|
unattended: true,
|
|
130
130
|
},
|
|
131
|
+
'harness verify-design-review': {
|
|
132
|
+
mutates: true,
|
|
133
|
+
summary: 'Check the design-review artifact and record designReviewCompleted.',
|
|
134
|
+
// Like verify-acs and verify-reachability: it records evidence for its gate, so it
|
|
135
|
+
// writes the ledger and state. The artifact itself is the reviewer's, and this
|
|
136
|
+
// command never writes it — a check that authored the thing it checks would prove
|
|
137
|
+
// nothing.
|
|
138
|
+
writes: ['.cadet/runs/**', '.cadet/state.json'],
|
|
139
|
+
unattended: true,
|
|
140
|
+
},
|
|
141
|
+
'harness context plan': {
|
|
142
|
+
mutates: true,
|
|
143
|
+
summary: 'Write the context plan for the current phase: what must be loaded, and why.',
|
|
144
|
+
writes: ['.cadet/context/plan.json'],
|
|
145
|
+
unattended: true,
|
|
146
|
+
},
|
|
147
|
+
'harness context record': {
|
|
148
|
+
mutates: true,
|
|
149
|
+
summary: 'Record what the host loaded, and the level it can claim for that record.',
|
|
150
|
+
writes: ['.cadet/context/record.json'],
|
|
151
|
+
unattended: true,
|
|
152
|
+
},
|
|
153
|
+
'harness context validate': {
|
|
154
|
+
mutates: false,
|
|
155
|
+
summary: 'Compare the plan, the record and the files as they are now. Read-only.',
|
|
156
|
+
unattended: true,
|
|
157
|
+
},
|
|
158
|
+
'harness verify-architecture': {
|
|
159
|
+
mutates: true,
|
|
160
|
+
summary: 'Run the project\'s declared architecture checks and record the gate.',
|
|
161
|
+
// Same posture as verify-acs, verify-reachability and verify-design-review: it
|
|
162
|
+
// records evidence for its gate, so it writes the ledger and state. The checks
|
|
163
|
+
// themselves are the project's, declared in the policy — this command never writes
|
|
164
|
+
// them, and it accepts no command-line override of what they run.
|
|
165
|
+
writes: ['.cadet/runs/**', '.cadet/state.json'],
|
|
166
|
+
unattended: true,
|
|
167
|
+
},
|
|
168
|
+
'harness acceptance-form': {
|
|
169
|
+
mutates: true,
|
|
170
|
+
summary: 'Write a human-acceptance form for an epic, pre-filled from state.',
|
|
171
|
+
// It writes exactly one file, and only when that file does not exist: a form is a
|
|
172
|
+
// person's worksheet once they have touched it, and a regeneration would discard
|
|
173
|
+
// what they wrote. It never writes state — recording the gate is `harness confirm`.
|
|
174
|
+
writes: ['.cadet/agent/project-plans/**'],
|
|
175
|
+
unattended: true,
|
|
176
|
+
},
|
|
131
177
|
'harness report': {
|
|
132
178
|
mutates: false,
|
|
133
179
|
summary: 'Summarize budget consumption and failures.',
|
|
134
180
|
},
|
|
181
|
+
// The health line: the framework's entire per-reply output. Read-only, and it
|
|
182
|
+
// writes no ledger, so printing it cannot change the state it reports on.
|
|
183
|
+
'harness status': {
|
|
184
|
+
mutates: false,
|
|
185
|
+
summary: 'Print the one-line framework health line: ok, or the problem.',
|
|
186
|
+
},
|
|
135
187
|
'harness changes': {
|
|
136
188
|
mutates: false,
|
|
137
189
|
summary: 'List the files a story changed, with status, line counts, and links.',
|
|
@@ -191,6 +243,14 @@ export function resolveCommand(argv) {
|
|
|
191
243
|
if (top === 'state' || top === 'harness') {
|
|
192
244
|
const sub = argv[3];
|
|
193
245
|
if (!sub || sub.startsWith('-')) return null;
|
|
246
|
+
// Longest key first: `harness context validate` must resolve to its own entry — a read —
|
|
247
|
+
// rather than to the group entry that also covers the two commands that write. Otherwise a
|
|
248
|
+
// read-only command would inherit the group's write declaration and lose its guard.
|
|
249
|
+
const action = argv[4] && !argv[4].startsWith('-') ? argv[4] : null;
|
|
250
|
+
if (action) {
|
|
251
|
+
const nested = `${top} ${sub} ${action}`;
|
|
252
|
+
if (Object.hasOwn(COMMANDS, nested)) return nested;
|
|
253
|
+
}
|
|
194
254
|
const key = `${top} ${sub}`;
|
|
195
255
|
return Object.hasOwn(COMMANDS, key) ? key : null;
|
|
196
256
|
}
|