cadet-agent 0.60.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 +40 -12
- package/package.json +4 -1
- package/src/cli.mjs +180 -201
- package/src/harness/commands.mjs +23 -20
- package/src/harness/context-protocol.mjs +143 -14
- package/src/harness/csharp.mjs +259 -0
- package/src/harness/diagram.mjs +735 -0
- package/src/harness/gates.mjs +3 -2
- package/src/harness/index.mjs +17 -12
- package/src/harness/noml.mjs +47 -0
- package/src/harness/play.mjs +2 -2
- package/src/harness/reconcile.mjs +34 -2
- package/src/harness/state.mjs +245 -10
- package/src/harness/verification.mjs +6 -12
- package/src/harness/acceptance-form.mjs +0 -281
- package/src/harness/play-form.mjs +0 -193
|
@@ -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
|
-
}
|
|
@@ -1,193 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The user-playthrough form — the only route by which `userPlaythroughConfirmed` is
|
|
3
|
-
* recorded for a story whose `Play:` declaration is `required`.
|
|
4
|
-
*
|
|
5
|
-
* Why a form rather than a flag. The gate asks whether a PERSON played the delivered
|
|
6
|
-
* work and what they saw. A flag route would let an agent answer that question with a
|
|
7
|
-
* sentence, which is exactly the failure the gate exists to close; the form leaves the
|
|
8
|
-
* three fields only a person can supply unfilled, and `harness confirm` refuses a form
|
|
9
|
-
* that still holds a placeholder. It mirrors the human-acceptance form deliberately —
|
|
10
|
-
* same shape, same refusal, one story instead of one epic.
|
|
11
|
-
*
|
|
12
|
-
* A `Play: deferred to <work item>` declaration never needs a form: the declaration is
|
|
13
|
-
* the answer, and `harness verify-play` records it. This module is for the other half.
|
|
14
|
-
*/
|
|
15
|
-
|
|
16
|
-
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
17
|
-
import { execFileSync } from 'node:child_process';
|
|
18
|
-
import { dirname, join } from 'node:path';
|
|
19
|
-
|
|
20
|
-
import { isUnfilled } from './acceptance-form.mjs';
|
|
21
|
-
|
|
22
|
-
/** The gate this form records. */
|
|
23
|
-
export const PLAY_FORM_GATE = 'userPlaythroughConfirmed';
|
|
24
|
-
|
|
25
|
-
/** The form's file name, written beside the story it belongs to. */
|
|
26
|
-
export const PLAY_FORM_SUFFIX = '-UserPlaythrough.md';
|
|
27
|
-
|
|
28
|
-
/** The template, relative to the repository root. */
|
|
29
|
-
export const PLAY_TEMPLATE_RELATIVE = '.cadet/agent/core/templates/UserPlaythroughTemplate.md';
|
|
30
|
-
|
|
31
|
-
/**
|
|
32
|
-
* The fields only a person can supply. The generator leaves all three unfilled and the
|
|
33
|
-
* parser refuses a form where any of them still is one.
|
|
34
|
-
*/
|
|
35
|
-
export const PLAY_HUMAN_FIELDS = Object.freeze(['player', 'witness', 'limitations']);
|
|
36
|
-
|
|
37
|
-
/** The form's path: beside the story, whose name it extends. */
|
|
38
|
-
export function playFormPath(targetDir, storyPath) {
|
|
39
|
-
const rel = storyPath.replace(/\\/g, '/');
|
|
40
|
-
const stem = rel.endsWith('.md') ? rel.slice(0, -3) : rel;
|
|
41
|
-
return join(targetDir, `${stem}${PLAY_FORM_SUFFIX}`);
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
/** Fill every `<slot id="…"/>` in the template from a value map. */
|
|
45
|
-
function fillSlots(text, values) {
|
|
46
|
-
return text.replace(/<slot\s+id="([^"]+)"((?:"[^"]*"|[^>"])*)\/>/g, (whole, id, attrs) => {
|
|
47
|
-
const value = values[id];
|
|
48
|
-
return value === undefined || value === null ? whole : String(value);
|
|
49
|
-
});
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
/** Strip the template's authoring attributes from a filled form. */
|
|
53
|
-
function stripAttributes(text) {
|
|
54
|
-
return text.replace(/<slot\s+id="([^"]+)"(?:"[^"]*"|[^>"])*\/>/g, (whole, id) => {
|
|
55
|
-
const m = whole.match(/^<slot\s+id="[^"]+"[^>]*?\/>$/);
|
|
56
|
-
return m ? `<slot id="${id}"/>` : whole;
|
|
57
|
-
});
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
/** The machine's best answer for "which revision is this", or `unknown`. */
|
|
61
|
-
function revisionOf(targetDir) {
|
|
62
|
-
try {
|
|
63
|
-
const out = execFileSync('git', ['rev-parse', '--short', 'HEAD'], { cwd: targetDir, encoding: 'utf-8' });
|
|
64
|
-
return out.trim() || 'unknown';
|
|
65
|
-
} catch {
|
|
66
|
-
return 'unknown';
|
|
67
|
-
}
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
/**
|
|
71
|
-
* Build the form for one story.
|
|
72
|
-
*
|
|
73
|
-
* `storyPath` is repository-relative. The story itself is bound as the relevant file, so
|
|
74
|
-
* editing the story after the playthrough makes the record stale rather than leaving it
|
|
75
|
-
* looking current.
|
|
76
|
-
*/
|
|
77
|
-
export function buildPlayForm({ template, state, storyPath, storyRel, epicId = null, targetDir, now = new Date(), declaration = null }) {
|
|
78
|
-
const storyName = storyRel.replace(/\\/g, '/').split('/').pop() || storyRel;
|
|
79
|
-
const instruction = declaration && declaration.kind === 'required' && declaration.instruction
|
|
80
|
-
? declaration.instruction
|
|
81
|
-
: '(the story declares no instruction — see its Play: line)';
|
|
82
|
-
|
|
83
|
-
const candidates = [];
|
|
84
|
-
candidates.push(`- \`Play:\` declaration: ${declaration && declaration.declared ? declaration.kind : 'MISSING — the story declares no Play: line'}`);
|
|
85
|
-
if (state && state.gates && typeof state.gates === 'object') {
|
|
86
|
-
const unmet = Object.entries(state.gates).filter(([, value]) => value !== true).map(([gate]) => gate);
|
|
87
|
-
if (unmet.length > 0) candidates.push(`- gates not yet true in state.json: ${unmet.join(', ')}`);
|
|
88
|
-
}
|
|
89
|
-
const workItem = state && state.activeWorkItem && state.activeWorkItem.storyId
|
|
90
|
-
? `${state.activeWorkItem.epicId}::${state.activeWorkItem.storyId}`
|
|
91
|
-
: '(no active work item)';
|
|
92
|
-
|
|
93
|
-
const values = {
|
|
94
|
-
story: `\`${storyName}\` (${workItem})`,
|
|
95
|
-
date: now.toISOString(),
|
|
96
|
-
revision: revisionOf(targetDir),
|
|
97
|
-
files: storyRel.replace(/\\/g, '/'),
|
|
98
|
-
environment: 'editor=<version>, scene=<path>',
|
|
99
|
-
instruction,
|
|
100
|
-
candidates: candidates.join('\n'),
|
|
101
|
-
recording: [
|
|
102
|
-
'Fill in every field above, then record it — nothing is retyped:',
|
|
103
|
-
'',
|
|
104
|
-
'```',
|
|
105
|
-
`cadet-agent harness confirm --gate ${PLAY_FORM_GATE} \\`,
|
|
106
|
-
` --artifact <this file> \\`,
|
|
107
|
-
` --reason "<why this playthrough is the record>" --expires-at <ISO-8601>`,
|
|
108
|
-
'```',
|
|
109
|
-
'',
|
|
110
|
-
'A form with an unfilled field is refused. The play is the person\'s; the agent only records it.',
|
|
111
|
-
].join('\n'),
|
|
112
|
-
};
|
|
113
|
-
|
|
114
|
-
return stripAttributes(fillSlots(template, values));
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
/**
|
|
118
|
-
* Parse a filled form.
|
|
119
|
-
*
|
|
120
|
-
* Returns the values it found plus `incomplete`, the human fields still unanswered. It
|
|
121
|
-
* makes no judgement about the playthrough itself — that is the gate's question, and the
|
|
122
|
-
* point of the record is that the person answered it.
|
|
123
|
-
*/
|
|
124
|
-
export function parsePlayForm(text) {
|
|
125
|
-
if (typeof text !== 'string' || text.trim() === '') {
|
|
126
|
-
return { incomplete: [...PLAY_HUMAN_FIELDS], error: 'the form is empty' };
|
|
127
|
-
}
|
|
128
|
-
const lines = text.split(/\r?\n/);
|
|
129
|
-
|
|
130
|
-
const scalar = (label) => {
|
|
131
|
-
const re = new RegExp(`^${label}:\\s*(.*)$`, 'i');
|
|
132
|
-
for (const line of lines) {
|
|
133
|
-
const m = line.match(re);
|
|
134
|
-
if (m) return m[1].trim();
|
|
135
|
-
}
|
|
136
|
-
return null;
|
|
137
|
-
};
|
|
138
|
-
|
|
139
|
-
const section = (heading) => {
|
|
140
|
-
const start = lines.findIndex((line) => line.trim().toLowerCase() === `## ${heading}`.toLowerCase());
|
|
141
|
-
if (start < 0) return null;
|
|
142
|
-
const body = [];
|
|
143
|
-
for (let i = start + 1; i < lines.length; i += 1) {
|
|
144
|
-
if (/^##\s/.test(lines[i]) || /^---\s*$/.test(lines[i])) break;
|
|
145
|
-
body.push(lines[i]);
|
|
146
|
-
}
|
|
147
|
-
return body.join('\n').trim();
|
|
148
|
-
};
|
|
149
|
-
|
|
150
|
-
const form = {
|
|
151
|
-
story: (text.match(/^#\s*User Playthrough:\s*(.+)$/m) || [])[1]?.trim() || null,
|
|
152
|
-
player: scalar('Played by'),
|
|
153
|
-
date: scalar('Date'),
|
|
154
|
-
revision: scalar('Revision'),
|
|
155
|
-
files: scalar('Files'),
|
|
156
|
-
environment: scalar('Environment'),
|
|
157
|
-
witness: section('What I did'),
|
|
158
|
-
instruction: section('What the story claims'),
|
|
159
|
-
limitations: section('Accepted limitations'),
|
|
160
|
-
};
|
|
161
|
-
|
|
162
|
-
const incomplete = PLAY_HUMAN_FIELDS.filter((field) => isUnfilled(form[field]));
|
|
163
|
-
if (isUnfilled(form.story)) incomplete.push('story');
|
|
164
|
-
|
|
165
|
-
const fileList = (form.files || '')
|
|
166
|
-
.split(',')
|
|
167
|
-
.map((f) => f.trim().replace(/\\/g, '/'))
|
|
168
|
-
.filter((f) => f && !f.startsWith('('))
|
|
169
|
-
.filter((f) => !isUnfilled(f));
|
|
170
|
-
|
|
171
|
-
return { ...form, fileList, incomplete };
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
/** Write the form, create-only: a half-filled form is never overwritten. */
|
|
175
|
-
export function writePlayForm(targetDir, storyRel, text, { out = null } = {}) {
|
|
176
|
-
const path = out ? join(targetDir, out) : playFormPath(targetDir, storyRel);
|
|
177
|
-
if (existsSync(path)) {
|
|
178
|
-
return { written: false, path, reason: 'a form already exists; edit it and record it, or move it aside to regenerate' };
|
|
179
|
-
}
|
|
180
|
-
mkdirSync(dirname(path), { recursive: true });
|
|
181
|
-
writeFileSync(path, text);
|
|
182
|
-
return { written: true, path };
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
/** Read the template, or throw with a message a caller can print. */
|
|
186
|
-
export function readPlayTemplate(targetDir) {
|
|
187
|
-
const path = join(targetDir, PLAY_TEMPLATE_RELATIVE);
|
|
188
|
-
try {
|
|
189
|
-
return readFileSync(path, 'utf-8');
|
|
190
|
-
} catch (err) {
|
|
191
|
-
throw new Error(`the playthrough template could not be read at ${PLAY_TEMPLATE_RELATIVE}: ${err.message}`);
|
|
192
|
-
}
|
|
193
|
-
}
|