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.
- package/README.md +78 -16
- package/package.json +4 -1
- package/src/cli.mjs +331 -105
- package/src/harness/commands.mjs +85 -7
- package/src/harness/context-protocol.mjs +146 -17
- package/src/harness/csharp.mjs +259 -0
- package/src/harness/diagram.mjs +735 -0
- package/src/harness/gates.mjs +29 -1
- package/src/harness/index.mjs +25 -8
- package/src/harness/noml.mjs +47 -0
- package/src/harness/play.mjs +271 -0
- package/src/harness/policy.mjs +61 -1
- package/src/harness/reconcile.mjs +34 -2
- package/src/harness/state.mjs +298 -12
- package/src/harness/verification.mjs +6 -12
- package/src/harness/acceptance-form.mjs +0 -281
|
@@ -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
|
-
}
|