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.
@@ -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
+ }
@@ -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
  }