cadet-agent 0.56.0 → 0.63.1

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.
@@ -1,281 +0,0 @@
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
- }