@farmslot/agent-runtime 0.12.0 → 0.13.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/CHANGELOG.md +20 -0
- package/bin/farmslot-agent.mjs +7 -0
- package/dist/execution-template/subtask-render-parity.test.d.ts +2 -0
- package/dist/execution-template/subtask-render-parity.test.d.ts.map +1 -0
- package/dist/execution-template/subtask-render-parity.test.js +63 -0
- package/dist/execution-template/subtask-render-parity.test.js.map +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/task-init/index.d.ts.map +1 -1
- package/dist/task-init/index.js +1 -0
- package/dist/task-init/index.js.map +1 -1
- package/dist/task-init/task-document.d.ts +16 -2
- package/dist/task-init/task-document.d.ts.map +1 -1
- package/dist/task-init/task-document.js +21 -8
- package/dist/task-init/task-document.js.map +1 -1
- package/package.json +7 -3
- package/scripts/acceptance-cli.cjs +139 -0
- package/scripts/acceptance-ledger.cjs +412 -0
- package/scripts/check-task-artifact-contract.mjs +25 -1
- package/scripts/checklist-target.cjs +56 -0
- package/scripts/mark-checklist-step.cjs +97 -60
- package/scripts/mark-io.cjs +118 -0
- package/scripts/subtask-unit.cjs +724 -0
- package/scripts/task-init-cli.mjs +4 -0
- package/scripts/worker-terminal-contract.cjs +21 -0
|
@@ -0,0 +1,412 @@
|
|
|
1
|
+
const fs = require('node:fs');
|
|
2
|
+
const path = require('node:path');
|
|
3
|
+
|
|
4
|
+
const { atomicWrite, readJson } = require('./mark-io.cjs');
|
|
5
|
+
|
|
6
|
+
// Acceptance-criteria ledger (ADR-060 / plans/sub-task-observability-v1.md).
|
|
7
|
+
//
|
|
8
|
+
// `farmslot-agent ac` is the only writer of artifacts/acceptance-status.json, the
|
|
9
|
+
// way `mark` is the only writer of SIGNAL.json. The ids come from task init, which
|
|
10
|
+
// records the criteria in inputs/handoff.json; this module never invents one.
|
|
11
|
+
//
|
|
12
|
+
// The constants and the validate/summarize/render helpers are a behavioral mirror
|
|
13
|
+
// of @farmslot/protocol/contracts/acceptance (see test/acceptance-ledger-sync.test.mjs).
|
|
14
|
+
// The mirror exists because the mark engine and this CLI are CJS and must run on a
|
|
15
|
+
// slot without a built protocol package.
|
|
16
|
+
|
|
17
|
+
const HANDOFF_INPUT = path.join('inputs', 'handoff.json');
|
|
18
|
+
const ACCEPTANCE_STATUS_ARTIFACT = 'artifacts/acceptance-status.json';
|
|
19
|
+
const ACCEPTANCE_VERDICTS = ['proven', 'weak', 'missing', 'untestable'];
|
|
20
|
+
const ACCEPTANCE_PROOF_MODES = ['state', 'visual', 'mixed'];
|
|
21
|
+
const ACCEPTANCE_CRITERION_ID_PATTERN = /^AC-[1-9][0-9]*$/;
|
|
22
|
+
|
|
23
|
+
/** A refused command: the message is worker-facing, the code is the exit code. */
|
|
24
|
+
class AcceptanceRefusal extends Error {
|
|
25
|
+
constructor(message, code = 1) {
|
|
26
|
+
super(message);
|
|
27
|
+
this.name = 'AcceptanceRefusal';
|
|
28
|
+
this.code = code;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* `AC-<N>` for a criterion's position in the handoff array.
|
|
34
|
+
* @param {number} index 0-based position in inputs/handoff.json task.acceptanceCriteria.
|
|
35
|
+
*/
|
|
36
|
+
function acceptanceCriterionId(index) {
|
|
37
|
+
return `AC-${index + 1}`;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function isRecord(value) {
|
|
41
|
+
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function isNonEmptyString(value) {
|
|
45
|
+
return typeof value === 'string' && value.trim().length > 0;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function validateAcceptanceStatusLedger(value) {
|
|
49
|
+
const issues = [];
|
|
50
|
+
if (!isRecord(value)) return ['acceptance ledger: expected object'];
|
|
51
|
+
if (value.schemaVersion !== 1) issues.push('acceptance ledger schemaVersion: expected 1');
|
|
52
|
+
if (!Array.isArray(value.criteria)) {
|
|
53
|
+
issues.push('acceptance ledger criteria: expected array');
|
|
54
|
+
return issues;
|
|
55
|
+
}
|
|
56
|
+
const seen = new Set();
|
|
57
|
+
value.criteria.forEach((entry, index) => {
|
|
58
|
+
const prefix = `acceptance ledger criteria[${index}]`;
|
|
59
|
+
if (!isRecord(entry)) {
|
|
60
|
+
issues.push(`${prefix}: expected object`);
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
if (!isNonEmptyString(entry.id) || !ACCEPTANCE_CRITERION_ID_PATTERN.test(entry.id)) {
|
|
64
|
+
issues.push(`${prefix}.id: expected AC-<N>`);
|
|
65
|
+
} else if (seen.has(entry.id)) {
|
|
66
|
+
issues.push(`${prefix}.id: duplicate ${entry.id}`);
|
|
67
|
+
} else {
|
|
68
|
+
seen.add(entry.id);
|
|
69
|
+
}
|
|
70
|
+
if (typeof entry.text !== 'string') issues.push(`${prefix}.text: expected string`);
|
|
71
|
+
if (!ACCEPTANCE_VERDICTS.includes(entry.verdict)) {
|
|
72
|
+
issues.push(`${prefix}.verdict: expected one of ${ACCEPTANCE_VERDICTS.join(', ')}`);
|
|
73
|
+
}
|
|
74
|
+
if (entry.proofMode !== undefined && !ACCEPTANCE_PROOF_MODES.includes(entry.proofMode)) {
|
|
75
|
+
issues.push(`${prefix}.proofMode: expected one of ${ACCEPTANCE_PROOF_MODES.join(', ')}`);
|
|
76
|
+
}
|
|
77
|
+
for (const key of ['evidence', 'recipeNodes']) {
|
|
78
|
+
const list = entry[key];
|
|
79
|
+
if (!Array.isArray(list) || list.some((item) => !isNonEmptyString(item))) {
|
|
80
|
+
issues.push(`${prefix}.${key}: expected an array of non-empty strings`);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
if (entry.note !== undefined && typeof entry.note !== 'string') {
|
|
84
|
+
issues.push(`${prefix}.note: expected string`);
|
|
85
|
+
}
|
|
86
|
+
if (!isNonEmptyString(entry.updatedAt)) {
|
|
87
|
+
issues.push(`${prefix}.updatedAt: expected non-empty string`);
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
return issues;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function acceptanceCriteriaView(criteria, ledger) {
|
|
94
|
+
const byId = new Map((ledger?.criteria ?? []).map((entry) => [entry.id, entry]));
|
|
95
|
+
const rows = criteria.map((criterion) => ({
|
|
96
|
+
...criterion,
|
|
97
|
+
status: byId.get(criterion.id) ?? null,
|
|
98
|
+
}));
|
|
99
|
+
for (const entry of ledger?.criteria ?? []) {
|
|
100
|
+
if (!criteria.some((criterion) => criterion.id === entry.id)) {
|
|
101
|
+
rows.push({ id: entry.id, text: entry.text, status: entry });
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return rows;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function summarizeAcceptanceStatus(ledger, criteria) {
|
|
108
|
+
const total = criteria
|
|
109
|
+
? Math.max(criteria.length, ledger.criteria.length)
|
|
110
|
+
: ledger.criteria.length;
|
|
111
|
+
const summary = {
|
|
112
|
+
proven: 0,
|
|
113
|
+
weak: 0,
|
|
114
|
+
missing: 0,
|
|
115
|
+
untestable: 0,
|
|
116
|
+
unrecorded: 0,
|
|
117
|
+
total,
|
|
118
|
+
};
|
|
119
|
+
for (const criterion of ledger.criteria) summary[criterion.verdict] += 1;
|
|
120
|
+
summary.unrecorded =
|
|
121
|
+
total - (summary.proven + summary.weak + summary.missing + summary.untestable);
|
|
122
|
+
return summary;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function cell(value) {
|
|
126
|
+
return (
|
|
127
|
+
value
|
|
128
|
+
.replace(/\|/g, '\\|')
|
|
129
|
+
.replace(/\s*\n\s*/g, ' ')
|
|
130
|
+
.trim() || '-'
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function list(values) {
|
|
135
|
+
return values.length > 0 ? cell(values.join(', ')) : '-';
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function renderAcceptanceCoverage(ledger, criteria = ledger.criteria) {
|
|
139
|
+
const summary = summarizeAcceptanceStatus(ledger, criteria);
|
|
140
|
+
const rows = acceptanceCriteriaView(criteria, ledger);
|
|
141
|
+
const untestable = ledger.criteria
|
|
142
|
+
.filter((criterion) => criterion.verdict === 'untestable')
|
|
143
|
+
.map((criterion) => criterion.id);
|
|
144
|
+
const lines = [
|
|
145
|
+
'## Recipe coverage',
|
|
146
|
+
'',
|
|
147
|
+
'| AC | Criterion | Verdict | Proof mode | Recipe nodes | Evidence | Note |',
|
|
148
|
+
'| --- | --- | --- | --- | --- | --- | --- |',
|
|
149
|
+
];
|
|
150
|
+
for (const row of rows) {
|
|
151
|
+
const status = row.status;
|
|
152
|
+
lines.push(
|
|
153
|
+
`| ${row.id} | ${cell(row.text)} | ${status ? status.verdict.toUpperCase() : 'NO VERDICT'} | ` +
|
|
154
|
+
`${status?.proofMode ?? '-'} | ${list(status?.recipeNodes ?? [])} | ` +
|
|
155
|
+
`${list(status?.evidence ?? [])} | ${cell(status?.note ?? '')} |`,
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
lines.push(
|
|
159
|
+
'',
|
|
160
|
+
`Overall recipe coverage: ${summary.proven}/${summary.total} ACs PROVEN ` +
|
|
161
|
+
`(untestable: ${untestable.length > 0 ? untestable.join(', ') : 'none'}, ` +
|
|
162
|
+
`weak: ${summary.weak}, missing: ${summary.missing}` +
|
|
163
|
+
`${summary.unrecorded > 0 ? `, no verdict: ${summary.unrecorded}` : ''})`,
|
|
164
|
+
'',
|
|
165
|
+
);
|
|
166
|
+
return lines.join('\n');
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// ---------------------------------------------------------------------------
|
|
170
|
+
// task-dir IO
|
|
171
|
+
|
|
172
|
+
function ledgerPath(taskDir) {
|
|
173
|
+
return path.join(taskDir, ACCEPTANCE_STATUS_ARTIFACT);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* The criteria task init recorded, as `{ id, text }` in handoff order. An empty
|
|
178
|
+
* array means this run has no acceptance criteria, so the ledger does not apply.
|
|
179
|
+
*/
|
|
180
|
+
function handoffAcceptanceCriteria(taskDir) {
|
|
181
|
+
return criteriaFromHandoff(readJson(path.join(taskDir, HANDOFF_INPUT)));
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** The `AC-<N>` list a parsed handoff carries; positions are the ids. */
|
|
185
|
+
function criteriaFromHandoff(handoff) {
|
|
186
|
+
const task = isRecord(handoff.task) ? handoff.task : {};
|
|
187
|
+
const criteria = Array.isArray(task.acceptanceCriteria) ? task.acceptanceCriteria : [];
|
|
188
|
+
return criteria
|
|
189
|
+
.map((text, index) => ({ id: acceptanceCriterionId(index), text: String(text) }))
|
|
190
|
+
.filter((criterion) => criterion.text.trim().length > 0);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Same read, for the path that enforces the ledger. A missing handoff means no
|
|
195
|
+
* criteria, as ENOENT does everywhere; anything else — unreadable file, invalid
|
|
196
|
+
* JSON, a non-array `acceptanceCriteria` — refuses, because a task whose criteria
|
|
197
|
+
* cannot be read has not proven them. The permissive reader above still serves
|
|
198
|
+
* `ac list` / `ac render`, where a broken handoff is the CLI's own refusal.
|
|
199
|
+
*
|
|
200
|
+
* The gateway's terminal check applies the identical rule from the slot, so a
|
|
201
|
+
* signal written around this engine cannot pass what `mark` refuses.
|
|
202
|
+
*/
|
|
203
|
+
function requireHandoffAcceptanceCriteria(taskDir) {
|
|
204
|
+
let handoff;
|
|
205
|
+
try {
|
|
206
|
+
handoff = readJson(path.join(taskDir, HANDOFF_INPUT));
|
|
207
|
+
} catch (err) {
|
|
208
|
+
throw new AcceptanceRefusal(handoffReadRefusal(`invalid ${HANDOFF_INPUT}: ${err.message}`));
|
|
209
|
+
}
|
|
210
|
+
const task = isRecord(handoff.task) ? handoff.task : {};
|
|
211
|
+
if (task.acceptanceCriteria !== undefined && !Array.isArray(task.acceptanceCriteria)) {
|
|
212
|
+
throw new AcceptanceRefusal(
|
|
213
|
+
handoffReadRefusal(`invalid ${HANDOFF_INPUT}: task.acceptanceCriteria must be an array`),
|
|
214
|
+
);
|
|
215
|
+
}
|
|
216
|
+
// One parse: the handoff read above is the only one this path makes.
|
|
217
|
+
return criteriaFromHandoff(handoff);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** One message shape for a handoff the acceptance rule cannot read. */
|
|
221
|
+
function handoffReadRefusal(detail) {
|
|
222
|
+
return (
|
|
223
|
+
`cannot complete: ${detail}. The acceptance criteria (ADR-060) come from that file, so ` +
|
|
224
|
+
`nothing can tell whether every criterion has a verdict. Restore ${HANDOFF_INPUT} from the ` +
|
|
225
|
+
`orchestrator copy, then run ./mark complete again.`
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** The stored ledger, or null when the run has not written one yet. */
|
|
230
|
+
function readAcceptanceLedger(taskDir) {
|
|
231
|
+
const file = ledgerPath(taskDir);
|
|
232
|
+
if (!fs.existsSync(file)) return null;
|
|
233
|
+
const text = fs.readFileSync(file, 'utf8');
|
|
234
|
+
try {
|
|
235
|
+
return JSON.parse(text);
|
|
236
|
+
} catch (err) {
|
|
237
|
+
throw new AcceptanceRefusal(`${ACCEPTANCE_STATUS_ARTIFACT}: invalid JSON (${err.message})`);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function writeAcceptanceLedger(taskDir, ledger) {
|
|
242
|
+
const issues = validateAcceptanceStatusLedger(ledger);
|
|
243
|
+
if (issues.length > 0) {
|
|
244
|
+
throw new AcceptanceRefusal(
|
|
245
|
+
`refusing to write an invalid ${ACCEPTANCE_STATUS_ARTIFACT}:\n- ${issues.join('\n- ')}`,
|
|
246
|
+
);
|
|
247
|
+
}
|
|
248
|
+
atomicWrite(ledgerPath(taskDir), `${JSON.stringify(ledger, null, 2)}\n`, 0o644);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** Task-dir relative evidence path, refused when it escapes the dir or is missing. */
|
|
252
|
+
function assertEvidencePath(taskDir, rawPath) {
|
|
253
|
+
const normalized = rawPath.replace(/\\/g, '/').replace(/^\.\/+/, '');
|
|
254
|
+
if (!normalized || path.posix.isAbsolute(normalized) || path.isAbsolute(rawPath)) {
|
|
255
|
+
throw new AcceptanceRefusal(`evidence path must be relative to the task dir: ${rawPath}`);
|
|
256
|
+
}
|
|
257
|
+
if (normalized.split('/').some((segment) => segment === '..')) {
|
|
258
|
+
throw new AcceptanceRefusal(`evidence path must stay inside the task dir: ${rawPath}`);
|
|
259
|
+
}
|
|
260
|
+
if (!fs.existsSync(path.join(taskDir, normalized))) {
|
|
261
|
+
throw new AcceptanceRefusal(`evidence path does not exist: ${normalized}`);
|
|
262
|
+
}
|
|
263
|
+
return normalized;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Record one verdict. The criterion must be one task init registered: the ledger
|
|
268
|
+
* carries exactly the handoff ids, never an id the worker made up.
|
|
269
|
+
*/
|
|
270
|
+
function setAcceptanceVerdict(taskDir, input) {
|
|
271
|
+
const criteria = handoffAcceptanceCriteria(taskDir);
|
|
272
|
+
if (criteria.length === 0) {
|
|
273
|
+
throw new AcceptanceRefusal(
|
|
274
|
+
`${HANDOFF_INPUT} lists no acceptance criteria; there is no ledger to write`,
|
|
275
|
+
);
|
|
276
|
+
}
|
|
277
|
+
const criterion = criteria.find((entry) => entry.id === input.id);
|
|
278
|
+
if (!criterion) {
|
|
279
|
+
throw new AcceptanceRefusal(
|
|
280
|
+
`unknown acceptance criterion ${input.id}; ${HANDOFF_INPUT} lists ${criteria
|
|
281
|
+
.map((entry) => entry.id)
|
|
282
|
+
.join(', ')}`,
|
|
283
|
+
);
|
|
284
|
+
}
|
|
285
|
+
if (!ACCEPTANCE_VERDICTS.includes(input.verdict)) {
|
|
286
|
+
throw new AcceptanceRefusal(
|
|
287
|
+
`unknown verdict ${input.verdict}; expected one of ${ACCEPTANCE_VERDICTS.join(', ')}`,
|
|
288
|
+
);
|
|
289
|
+
}
|
|
290
|
+
if (input.proofMode !== undefined && !ACCEPTANCE_PROOF_MODES.includes(input.proofMode)) {
|
|
291
|
+
throw new AcceptanceRefusal(
|
|
292
|
+
`unknown proof mode ${input.proofMode}; expected one of ${ACCEPTANCE_PROOF_MODES.join(', ')}`,
|
|
293
|
+
);
|
|
294
|
+
}
|
|
295
|
+
const evidence = (input.evidence ?? []).map((entry) => assertEvidencePath(taskDir, entry));
|
|
296
|
+
const stored = readAcceptanceLedger(taskDir);
|
|
297
|
+
if (stored) {
|
|
298
|
+
const issues = validateAcceptanceStatusLedger(stored);
|
|
299
|
+
if (issues.length > 0) {
|
|
300
|
+
throw new AcceptanceRefusal(
|
|
301
|
+
`${ACCEPTANCE_STATUS_ARTIFACT} does not match the ledger contract:\n- ${issues.join('\n- ')}`,
|
|
302
|
+
);
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
const byId = new Map((stored?.criteria ?? []).map((entry) => [entry.id, entry]));
|
|
306
|
+
byId.set(criterion.id, {
|
|
307
|
+
id: criterion.id,
|
|
308
|
+
text: criterion.text,
|
|
309
|
+
verdict: input.verdict,
|
|
310
|
+
...(input.proofMode ? { proofMode: input.proofMode } : {}),
|
|
311
|
+
evidence,
|
|
312
|
+
recipeNodes: [...(input.recipeNodes ?? [])],
|
|
313
|
+
...(input.note ? { note: input.note } : {}),
|
|
314
|
+
updatedAt: input.now ?? new Date().toISOString(),
|
|
315
|
+
});
|
|
316
|
+
// Handoff order, so the ledger and the rendered table always read like TASK.md.
|
|
317
|
+
const ledger = {
|
|
318
|
+
schemaVersion: 1,
|
|
319
|
+
criteria: criteria.map((entry) => byId.get(entry.id)).filter(Boolean),
|
|
320
|
+
};
|
|
321
|
+
writeAcceptanceLedger(taskDir, ledger);
|
|
322
|
+
return ledger;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/** Every handoff criterion with its current verdict, or null when unrecorded. */
|
|
326
|
+
function acceptanceStatusList(taskDir) {
|
|
327
|
+
const stored = readAcceptanceLedger(taskDir);
|
|
328
|
+
const byId = new Map((stored?.criteria ?? []).map((entry) => [entry.id, entry]));
|
|
329
|
+
return handoffAcceptanceCriteria(taskDir).map((criterion) => {
|
|
330
|
+
const entry = byId.get(criterion.id);
|
|
331
|
+
return {
|
|
332
|
+
id: criterion.id,
|
|
333
|
+
text: criterion.text,
|
|
334
|
+
verdict: entry ? entry.verdict : null,
|
|
335
|
+
...(entry?.proofMode ? { proofMode: entry.proofMode } : {}),
|
|
336
|
+
evidence: entry?.evidence ?? [],
|
|
337
|
+
recipeNodes: entry?.recipeNodes ?? [],
|
|
338
|
+
...(entry?.note ? { note: entry.note } : {}),
|
|
339
|
+
updatedAt: entry?.updatedAt ?? null,
|
|
340
|
+
};
|
|
341
|
+
});
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* Terminal-contract issues for the ledger: every registered criterion needs a
|
|
346
|
+
* verdict, and `weak` or `missing` fails unless the flow's contract waives it
|
|
347
|
+
* (`acceptance.allowWeak`). Returns an empty array when the run has no criteria.
|
|
348
|
+
*/
|
|
349
|
+
function acceptanceContractIssues(taskDir, options = {}) {
|
|
350
|
+
const criteria = handoffAcceptanceCriteria(taskDir);
|
|
351
|
+
if (criteria.length === 0) return [];
|
|
352
|
+
let stored;
|
|
353
|
+
try {
|
|
354
|
+
stored = readAcceptanceLedger(taskDir);
|
|
355
|
+
} catch (err) {
|
|
356
|
+
if (!(err instanceof AcceptanceRefusal)) throw err;
|
|
357
|
+
return [err.message];
|
|
358
|
+
}
|
|
359
|
+
if (!stored) {
|
|
360
|
+
return [
|
|
361
|
+
`${ACCEPTANCE_STATUS_ARTIFACT} is missing but ${HANDOFF_INPUT} lists ${criteria.length} ` +
|
|
362
|
+
'acceptance criteria — record a verdict for each with `farmslot-agent ac set <id> <verdict>`',
|
|
363
|
+
];
|
|
364
|
+
}
|
|
365
|
+
const issues = validateAcceptanceStatusLedger(stored);
|
|
366
|
+
if (issues.length > 0) return issues;
|
|
367
|
+
const known = new Set(criteria.map((criterion) => criterion.id));
|
|
368
|
+
for (const entry of stored.criteria) {
|
|
369
|
+
if (!known.has(entry.id)) {
|
|
370
|
+
issues.push(
|
|
371
|
+
`${ACCEPTANCE_STATUS_ARTIFACT}: ${entry.id} is not an acceptance criterion of this task`,
|
|
372
|
+
);
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
const byId = new Map(stored.criteria.map((entry) => [entry.id, entry]));
|
|
376
|
+
for (const criterion of criteria) {
|
|
377
|
+
const entry = byId.get(criterion.id);
|
|
378
|
+
if (!entry) {
|
|
379
|
+
issues.push(
|
|
380
|
+
`${criterion.id} has no verdict — run \`farmslot-agent ac set ${criterion.id} <verdict>\``,
|
|
381
|
+
);
|
|
382
|
+
continue;
|
|
383
|
+
}
|
|
384
|
+
if (!options.allowWeak && (entry.verdict === 'missing' || entry.verdict === 'weak')) {
|
|
385
|
+
issues.push(
|
|
386
|
+
`${criterion.id} is ${entry.verdict}: prove it, or record \`untestable\` with a note ` +
|
|
387
|
+
'(a flow may waive this with worker_terminal.acceptance.allowWeak)',
|
|
388
|
+
);
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
return issues;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
module.exports = {
|
|
395
|
+
ACCEPTANCE_CRITERION_ID_PATTERN,
|
|
396
|
+
acceptanceCriteriaView,
|
|
397
|
+
ACCEPTANCE_PROOF_MODES,
|
|
398
|
+
ACCEPTANCE_STATUS_ARTIFACT,
|
|
399
|
+
ACCEPTANCE_VERDICTS,
|
|
400
|
+
AcceptanceRefusal,
|
|
401
|
+
acceptanceContractIssues,
|
|
402
|
+
acceptanceCriterionId,
|
|
403
|
+
acceptanceStatusList,
|
|
404
|
+
handoffAcceptanceCriteria,
|
|
405
|
+
requireHandoffAcceptanceCriteria,
|
|
406
|
+
readAcceptanceLedger,
|
|
407
|
+
renderAcceptanceCoverage,
|
|
408
|
+
setAcceptanceVerdict,
|
|
409
|
+
summarizeAcceptanceStatus,
|
|
410
|
+
validateAcceptanceStatusLedger,
|
|
411
|
+
writeAcceptanceLedger,
|
|
412
|
+
};
|
|
@@ -6,6 +6,8 @@ import { fileURLToPath } from 'node:url';
|
|
|
6
6
|
|
|
7
7
|
const require = createRequire(import.meta.url);
|
|
8
8
|
const { expandedArtifactsForCommand } = require('./worker-terminal-contract.cjs');
|
|
9
|
+
const { SUBTASK_INDEX_REL, openSubtaskUnits } = require('./subtask-unit.cjs');
|
|
10
|
+
const { acceptanceContractIssues } = require('./acceptance-ledger.cjs');
|
|
9
11
|
const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
10
12
|
const workspaceProtocolRoot = path.resolve(packageRoot, '../protocol');
|
|
11
13
|
|
|
@@ -102,7 +104,7 @@ for (let i = 0; i < rawArgs.length; i += 1) {
|
|
|
102
104
|
|
|
103
105
|
if (!taskDir) {
|
|
104
106
|
console.error(
|
|
105
|
-
'usage: check-task-artifact-contract.mjs <task-dir> [--contract path] [--terminal complete|no-change|blocked] [--require-recipe-quality-if-recipe] [--require-recipe-coverage-if-recipe] [--require-learnings] [--skip-learnings]',
|
|
107
|
+
'usage: check-task-artifact-contract.mjs <task-dir> [--contract path] [--terminal complete|no-change|blocked] [--require-recipe-quality-if-recipe] [--require-recipe-coverage-if-recipe] [--require-acceptance-status] [--require-learnings] [--skip-learnings]',
|
|
106
108
|
);
|
|
107
109
|
process.exit(2);
|
|
108
110
|
}
|
|
@@ -673,6 +675,28 @@ if (!isSelfReviewTerminal) {
|
|
|
673
675
|
}
|
|
674
676
|
}
|
|
675
677
|
|
|
678
|
+
// A registered child unit is part of the parent's proof: the parent cannot
|
|
679
|
+
// report success while a child checklist is still running or blocked.
|
|
680
|
+
if (fileExists(SUBTASK_INDEX_REL)) {
|
|
681
|
+
for (const { unit, status } of openSubtaskUnits(taskDir)) {
|
|
682
|
+
issues.push(
|
|
683
|
+
`${unit.checklist}: subtask ${unit.id} is not settled (status ${status ?? 'no signal'}) — ` +
|
|
684
|
+
`finish it with ./mark sub ${unit.id} complete`,
|
|
685
|
+
);
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
// The acceptance ledger is the run's proof record (ADR-060): every criterion task
|
|
690
|
+
// init registered in inputs/handoff.json needs a verdict, and `weak` / `missing`
|
|
691
|
+
// fails unless the flow's terminal contract waives it.
|
|
692
|
+
if (flags.has('--require-acceptance-status')) {
|
|
693
|
+
for (const issue of acceptanceContractIssues(taskDir, {
|
|
694
|
+
allowWeak: terminalContract?.acceptance?.allowWeak === true,
|
|
695
|
+
})) {
|
|
696
|
+
issues.push(issue);
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
|
|
676
700
|
if (flags.has('--require-learnings')) {
|
|
677
701
|
const learnings = readText('artifacts/learnings.md');
|
|
678
702
|
if (!learnings?.trim()) {
|
|
@@ -9,6 +9,11 @@ const INTERACTIVE_CHECKLIST_MARKDOWN = 'CHECKLIST.md';
|
|
|
9
9
|
const WORKER_SIGNAL_FILE = 'SIGNAL.json';
|
|
10
10
|
const ROLE_SIGNAL_SUFFIX = '-SIGNAL.json';
|
|
11
11
|
|
|
12
|
+
// Child checklist units (ADR-060): one directory, one index, slug ids.
|
|
13
|
+
const SUBTASKS_DIR = 'subtasks';
|
|
14
|
+
const SUBTASK_INDEX_FILE = 'index.json';
|
|
15
|
+
const SUBTASK_ID_PATTERN = /^[a-z0-9-]+$/;
|
|
16
|
+
|
|
12
17
|
const SELF_REVIEW_CHECKLIST = 'SELF-REVIEW.md';
|
|
13
18
|
const SELF_REVIEW_FIX_CHECKLIST = 'SELF-REVIEW-FIX.md';
|
|
14
19
|
const CI_FIX_CHECKLIST = 'CI-FIX.md';
|
|
@@ -81,6 +86,20 @@ function enumerateChecklistCheckboxes(markdown) {
|
|
|
81
86
|
}
|
|
82
87
|
return items;
|
|
83
88
|
}
|
|
89
|
+
// Mirror of @farmslot/protocol/checklist-target checklistNumberingMismatches:
|
|
90
|
+
// a label carrying explicit numbering must match its enumerated position, or an
|
|
91
|
+
// inserted row silently shifts every later step onto the wrong box.
|
|
92
|
+
function checklistNumberingMismatches(markdown) {
|
|
93
|
+
const mismatches = [];
|
|
94
|
+
for (const item of enumerateChecklistCheckboxes(markdown)) {
|
|
95
|
+
const labeled = item.rawLabel.match(/^\*{0,2}(\d+[a-z]?)[.)]/i);
|
|
96
|
+
if (labeled && labeled[1] !== String(item.stepNumber)) {
|
|
97
|
+
mismatches.push(`position ${item.stepNumber} is labeled "${labeled[1]}"`);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return mismatches;
|
|
101
|
+
}
|
|
102
|
+
|
|
84
103
|
const WORKER_TERMINAL_CONTRACT_INPUT = path.join('inputs', 'worker-terminal-contract.json');
|
|
85
104
|
|
|
86
105
|
// checklistStepName — mirror of the protocol rule for the name a step is shown
|
|
@@ -101,6 +120,37 @@ function signalFileForChecklist(checklistBasename) {
|
|
|
101
120
|
return `${base}${ROLE_SIGNAL_SUFFIX}`;
|
|
102
121
|
}
|
|
103
122
|
|
|
123
|
+
// Mirror of @farmslot/protocol/transport/signal isSettledSubtaskStatus. NOT the
|
|
124
|
+
// terminal predicate: a `blocked` child keeps ownership of its parent step.
|
|
125
|
+
const WORKER_SIGNAL_STATUS_IS_SETTLED = {
|
|
126
|
+
running: false,
|
|
127
|
+
blocked: false,
|
|
128
|
+
complete: true,
|
|
129
|
+
failed: false,
|
|
130
|
+
done: true,
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
function isSettledSubtaskStatus(status) {
|
|
134
|
+
return (
|
|
135
|
+
status !== null &&
|
|
136
|
+
status !== undefined &&
|
|
137
|
+
Object.hasOwn(WORKER_SIGNAL_STATUS_IS_SETTLED, status) &&
|
|
138
|
+
WORKER_SIGNAL_STATUS_IS_SETTLED[status]
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Task-dir relative paths of a child unit pair. Mirror of the protocol
|
|
144
|
+
* `subtaskPaths`: the signal basename comes from signalFileForChecklist, only
|
|
145
|
+
* the `subtasks/` prefix is new.
|
|
146
|
+
*/
|
|
147
|
+
function subtaskPaths(id) {
|
|
148
|
+
return {
|
|
149
|
+
checklist: `${SUBTASKS_DIR}/${id}.md`,
|
|
150
|
+
signal: `${SUBTASKS_DIR}/${signalFileForChecklist(`${id}.md`)}`,
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
104
154
|
function taskDirRelPath(taskDir, basename) {
|
|
105
155
|
const normalized = String(taskDir).replace(/\/+$/, '');
|
|
106
156
|
return `${normalized}/${basename}`;
|
|
@@ -276,7 +326,13 @@ function resolveChecklistPaths(taskDir) {
|
|
|
276
326
|
module.exports = {
|
|
277
327
|
CHECKLIST_SKIP_SECTIONS,
|
|
278
328
|
checklistStepName,
|
|
329
|
+
checklistNumberingMismatches,
|
|
279
330
|
enumerateChecklistCheckboxes,
|
|
331
|
+
SUBTASKS_DIR,
|
|
332
|
+
SUBTASK_INDEX_FILE,
|
|
333
|
+
SUBTASK_ID_PATTERN,
|
|
334
|
+
isSettledSubtaskStatus,
|
|
335
|
+
subtaskPaths,
|
|
280
336
|
CHECKLIST_TARGET_MANIFEST,
|
|
281
337
|
TASK_PROGRESS_MARKDOWN,
|
|
282
338
|
INTERACTIVE_CHECKLIST_MARKDOWN,
|