@farmslot/agent-runtime 0.11.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 +24 -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 +64 -0
- package/scripts/mark-checklist-step.cjs +97 -67
- 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,724 @@
|
|
|
1
|
+
const { createHash } = require('node:crypto');
|
|
2
|
+
const fs = require('node:fs');
|
|
3
|
+
const path = require('node:path');
|
|
4
|
+
|
|
5
|
+
const {
|
|
6
|
+
checklistNumberingMismatches,
|
|
7
|
+
checklistStepName,
|
|
8
|
+
enumerateChecklistCheckboxes,
|
|
9
|
+
isSettledSubtaskStatus,
|
|
10
|
+
resolveChecklistTargetWithOverrides,
|
|
11
|
+
SUBTASK_ID_PATTERN,
|
|
12
|
+
SUBTASK_INDEX_FILE,
|
|
13
|
+
SUBTASKS_DIR,
|
|
14
|
+
subtaskPaths,
|
|
15
|
+
targetForChecklistBasename,
|
|
16
|
+
} = require('./checklist-target.cjs');
|
|
17
|
+
const {
|
|
18
|
+
atomicWrite,
|
|
19
|
+
markStepInFile,
|
|
20
|
+
parseChecklist,
|
|
21
|
+
pickSignalPassthrough,
|
|
22
|
+
readJson,
|
|
23
|
+
writeIndex,
|
|
24
|
+
writeSignal,
|
|
25
|
+
} = require('./mark-io.cjs');
|
|
26
|
+
|
|
27
|
+
// Child checklist units (ADR-060 / plans/sub-task-observability-v1.md).
|
|
28
|
+
//
|
|
29
|
+
// `mark` is the only writer of `subtasks/`: it materializes a child checklist
|
|
30
|
+
// from a source, registers it in `subtasks/index.json`, and maintains the child
|
|
31
|
+
// signal plus the parent signal effects. Farmslot never spawns the child; a
|
|
32
|
+
// child unit is files and signals only.
|
|
33
|
+
//
|
|
34
|
+
// A child unit is NOT a role switch: it never writes `checklist-target.json` and
|
|
35
|
+
// never changes the run's active task file.
|
|
36
|
+
|
|
37
|
+
const HANDOFF_INPUT = path.join('inputs', 'handoff.json');
|
|
38
|
+
const SUBTASK_INDEX_REL = `${SUBTASKS_DIR}/${SUBTASK_INDEX_FILE}`;
|
|
39
|
+
|
|
40
|
+
/** A refused command: the message is worker-facing, the code is the exit code. */
|
|
41
|
+
class SubtaskRefusal extends Error {
|
|
42
|
+
constructor(message, code = 1) {
|
|
43
|
+
super(message);
|
|
44
|
+
this.name = 'SubtaskRefusal';
|
|
45
|
+
this.code = code;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function usageRefusal(message) {
|
|
50
|
+
return new SubtaskRefusal(message, 2);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const SUB_USAGE = [
|
|
54
|
+
'usage: mark <task-dir> sub <command>',
|
|
55
|
+
'',
|
|
56
|
+
' sub start <id> --step N --from <path|template:<id>|inline:<text>> [--var K=V ...] [--checklist FILE.md]',
|
|
57
|
+
' register one child unit on parent step N: writes subtasks/<id>.md,',
|
|
58
|
+
' subtasks/index.json and subtasks/<id>-SIGNAL.json.',
|
|
59
|
+
' sub <id> <n> tick child box n (child status running)',
|
|
60
|
+
' sub <id> complete [--report PATH] [--mark-last]',
|
|
61
|
+
' finish the child, tick the parent box',
|
|
62
|
+
' sub <id> blocked --reason "..." block the child and the parent signal',
|
|
63
|
+
' sub <id> status print the child projection as JSON',
|
|
64
|
+
'',
|
|
65
|
+
'A child unit has no flow terminal contract; --report is its only artifact rule.',
|
|
66
|
+
'Placeholders in --from are rendered with the task vars from inputs/handoff.json',
|
|
67
|
+
'(TASK_DIR, FLOW, PROJECT, DOMAIN, GH_REPO, TITLE, TICKET, TEMPLATE) plus --var.',
|
|
68
|
+
].join('\n');
|
|
69
|
+
|
|
70
|
+
function printSubtaskHelp() {
|
|
71
|
+
console.log(SUB_USAGE);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// ---------------------------------------------------------------------------
|
|
75
|
+
// index
|
|
76
|
+
|
|
77
|
+
function subtaskIndexPath(taskDir) {
|
|
78
|
+
return path.join(taskDir, SUBTASKS_DIR, SUBTASK_INDEX_FILE);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** The registry, or null when this task dir has no child unit. Throws on a corrupt file. */
|
|
82
|
+
function readSubtaskIndex(taskDir) {
|
|
83
|
+
const file = subtaskIndexPath(taskDir);
|
|
84
|
+
if (!fs.existsSync(file)) return null;
|
|
85
|
+
const parsed = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
86
|
+
if (
|
|
87
|
+
!parsed ||
|
|
88
|
+
typeof parsed !== 'object' ||
|
|
89
|
+
parsed.schemaVersion !== 1 ||
|
|
90
|
+
!Array.isArray(parsed.units)
|
|
91
|
+
) {
|
|
92
|
+
throw new SubtaskRefusal(
|
|
93
|
+
`invalid ${SUBTASK_INDEX_REL}: expected { "schemaVersion": 1, "units": [] } — only mark writes this file`,
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
return parsed;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function writeSubtaskIndex(taskDir, index) {
|
|
100
|
+
writeIndex(subtaskIndexPath(taskDir), index);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function unitById(index, id) {
|
|
104
|
+
return index?.units.find((unit) => unit.id === id) ?? null;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** The child unit registered on a parent step, whatever its status. */
|
|
108
|
+
function subtaskOwningStep(taskDir, parentChecklistBasename, stepNumber) {
|
|
109
|
+
const index = readSubtaskIndex(taskDir);
|
|
110
|
+
if (!index) return null;
|
|
111
|
+
return (
|
|
112
|
+
index.units.find(
|
|
113
|
+
(unit) =>
|
|
114
|
+
unit.parent?.checklist === parentChecklistBasename &&
|
|
115
|
+
unit.parent?.stepNumber === stepNumber,
|
|
116
|
+
) ?? null
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function childStatus(taskDir, unit) {
|
|
121
|
+
const signal = readJson(path.join(taskDir, unit.signal));
|
|
122
|
+
return typeof signal.status === 'string' ? signal.status : null;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Registered units that have not finished. Settled means `complete` or `done`;
|
|
127
|
+
* a `blocked` child is still open and still owns its parent step.
|
|
128
|
+
*/
|
|
129
|
+
function openSubtaskUnits(taskDir) {
|
|
130
|
+
const index = readSubtaskIndex(taskDir);
|
|
131
|
+
if (!index) return [];
|
|
132
|
+
return index.units
|
|
133
|
+
.map((unit) => ({ unit, status: childStatus(taskDir, unit) }))
|
|
134
|
+
.filter((entry) => !isSettledSubtaskStatus(entry.status));
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function openSubtaskRefusal(open, terminalCommand) {
|
|
138
|
+
const detail = open
|
|
139
|
+
.map((entry) => `${entry.unit.id} (${entry.status ?? 'no signal'})`)
|
|
140
|
+
.join(', ');
|
|
141
|
+
const finish = open.map((entry) => `./mark sub ${entry.unit.id} complete`).join(' && ');
|
|
142
|
+
return `cannot ${terminalCommand} while a subtask is open: ${detail}; finish it with ${finish}`;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// ---------------------------------------------------------------------------
|
|
146
|
+
// materialization
|
|
147
|
+
|
|
148
|
+
function sha256Text(text) {
|
|
149
|
+
return createHash('sha256').update(text).digest('hex');
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Body of a markdown document, YAML frontmatter removed. Behavioural mirror of
|
|
154
|
+
* `parseMarkdownDocument` in src/execution-template/frontmatter.ts: the closing
|
|
155
|
+
* fence must be exactly `---` on its own line, and an unterminated block is
|
|
156
|
+
* content (see test/subtask-render-parity.test.ts).
|
|
157
|
+
*/
|
|
158
|
+
function stripMarkdownFrontmatter(text) {
|
|
159
|
+
const normalized = String(text).replace(/^\uFEFF/, '');
|
|
160
|
+
if (!normalized.startsWith('---\n') && !normalized.startsWith('---\r\n')) return normalized;
|
|
161
|
+
let end = -1;
|
|
162
|
+
for (let from = 3; ; ) {
|
|
163
|
+
const candidate = normalized.indexOf('\n---', from);
|
|
164
|
+
if (candidate === -1) break;
|
|
165
|
+
const after = normalized.slice(candidate + 4, candidate + 6);
|
|
166
|
+
if (after === '' || after.startsWith('\n') || after === '\r\n' || after.startsWith('\r\n')) {
|
|
167
|
+
end = candidate;
|
|
168
|
+
break;
|
|
169
|
+
}
|
|
170
|
+
from = candidate + 1;
|
|
171
|
+
}
|
|
172
|
+
if (end === -1) return normalized;
|
|
173
|
+
return normalized.slice(end + '\n---'.length).replace(/^\r?\n/, '');
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const PLACEHOLDER_TOKEN_RE = /\{\{[^{}\n]+\}\}/g;
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Substitute `{{KEY}}` after the same guard the task writer applies.
|
|
180
|
+
* Behavioural mirror of `renderTemplatePlaceholders` /
|
|
181
|
+
* `assertNoUnknownPlaceholders` in @farmslot/protocol (see
|
|
182
|
+
* test/subtask-render-parity.test.ts): an unexpandable token is a refusal, never
|
|
183
|
+
* a silently rendered `{{TOKEN}}` in the child checklist.
|
|
184
|
+
*/
|
|
185
|
+
function renderPlaceholders(template, vars, source) {
|
|
186
|
+
const known = new Set(Object.keys(vars));
|
|
187
|
+
const unknown = [
|
|
188
|
+
...new Set(Array.from(template.matchAll(PLACEHOLDER_TOKEN_RE), (m) => m[0])),
|
|
189
|
+
].filter((token) => {
|
|
190
|
+
const name = token.slice(2, -2);
|
|
191
|
+
return !/^[A-Za-z_][A-Za-z0-9_]*$/.test(name) || !known.has(name);
|
|
192
|
+
});
|
|
193
|
+
if (unknown.length > 0) {
|
|
194
|
+
throw new Error(
|
|
195
|
+
`${source} references placeholder(s) with no expansion value: ${unknown.join(', ')} — ` +
|
|
196
|
+
`supply the variable or remove the placeholder from the template`,
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
let content = template;
|
|
200
|
+
for (const [key, value] of Object.entries(vars)) {
|
|
201
|
+
content = content.replaceAll(`{{${key}}}`, value);
|
|
202
|
+
}
|
|
203
|
+
return content;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** Task vars a child source may reference, from the handoff plus explicit --var. */
|
|
207
|
+
function taskVars(taskDir, extra) {
|
|
208
|
+
const handoff = readJson(path.join(taskDir, HANDOFF_INPUT));
|
|
209
|
+
const vars = { TASK_DIR: taskDir };
|
|
210
|
+
const text = (value) => (typeof value === 'string' && value.trim() ? value : null);
|
|
211
|
+
const task = handoff.task && typeof handoff.task === 'object' ? handoff.task : {};
|
|
212
|
+
const assign = (key, value) => {
|
|
213
|
+
if (value !== null) vars[key] = value;
|
|
214
|
+
};
|
|
215
|
+
assign('FLOW', text(handoff.flow));
|
|
216
|
+
assign('PROJECT', text(handoff.project));
|
|
217
|
+
assign('DOMAIN', text(handoff.domain));
|
|
218
|
+
assign('GH_REPO', text(handoff.repo));
|
|
219
|
+
assign('TITLE', text(task.title));
|
|
220
|
+
assign('TICKET', text(task.ticket));
|
|
221
|
+
assign('TEMPLATE', text(handoff.executionTemplate?.id));
|
|
222
|
+
return { ...vars, ...extra };
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Source markdown for a child unit. `template:<id>` is refused: resolving a
|
|
227
|
+
* catalog id needs the project's configured template sources, which `mark` (a
|
|
228
|
+
* task-dir-local engine) cannot reach — materialize it first instead of
|
|
229
|
+
* guessing a root.
|
|
230
|
+
*/
|
|
231
|
+
function readSubtaskSource(spec, taskDir) {
|
|
232
|
+
if (typeof spec !== 'string' || !spec.trim()) {
|
|
233
|
+
throw usageRefusal('sub start requires --from <path|template:<id>|inline:<text>>');
|
|
234
|
+
}
|
|
235
|
+
if (spec.startsWith('inline:')) {
|
|
236
|
+
const text = spec.slice('inline:'.length);
|
|
237
|
+
if (!text.trim()) throw new SubtaskRefusal('--from inline: requires checklist text');
|
|
238
|
+
return { kind: 'inline', text };
|
|
239
|
+
}
|
|
240
|
+
if (spec.startsWith('template:')) {
|
|
241
|
+
const id = spec.slice('template:'.length);
|
|
242
|
+
throw new SubtaskRefusal(
|
|
243
|
+
`--from template:${id} is not supported by mark: resolving a catalog id needs the project's ` +
|
|
244
|
+
`execution-template sources, which the task-dir mark engine cannot read. Materialize it first ` +
|
|
245
|
+
`(farmslot-agent execution-template materialize --id ${id} --out <path>) and pass --from <path>, ` +
|
|
246
|
+
`or use --from inline:<text>.`,
|
|
247
|
+
);
|
|
248
|
+
}
|
|
249
|
+
const candidates = path.isAbsolute(spec)
|
|
250
|
+
? [spec]
|
|
251
|
+
: [path.resolve(process.cwd(), spec), path.resolve(taskDir, spec)];
|
|
252
|
+
for (const candidate of candidates) {
|
|
253
|
+
if (fs.existsSync(candidate) && fs.statSync(candidate).isFile()) {
|
|
254
|
+
return { kind: 'skill', ref: spec, text: fs.readFileSync(candidate, 'utf8') };
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
throw new SubtaskRefusal(`--from source not found: tried ${candidates.join(', ')}`);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// ---------------------------------------------------------------------------
|
|
261
|
+
// signals
|
|
262
|
+
|
|
263
|
+
function timingOf(signal, source) {
|
|
264
|
+
const timing =
|
|
265
|
+
signal.checklistTiming && typeof signal.checklistTiming === 'object'
|
|
266
|
+
? signal.checklistTiming
|
|
267
|
+
: { schemaVersion: 1, source, events: [] };
|
|
268
|
+
const events = Array.isArray(timing.events) ? [...timing.events] : [];
|
|
269
|
+
return { source: timing.source || source, events };
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function appendTimingEvent(events, stepNumber, label, now) {
|
|
273
|
+
if (stepNumber == null) return events;
|
|
274
|
+
const seen = events.some(
|
|
275
|
+
(event) => event && (event.stepNumber === stepNumber || event.index === stepNumber - 1),
|
|
276
|
+
);
|
|
277
|
+
if (seen) return events;
|
|
278
|
+
return [...events, { stepNumber, label, checkedAt: now }];
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* The parent-signal effect of a child command. Shape-identical to a parent
|
|
283
|
+
* `mark` write (same passthrough keys, same `checklistTiming`), so nothing
|
|
284
|
+
* downstream can tell a child-driven parent mark from a hand-run one.
|
|
285
|
+
*/
|
|
286
|
+
function writeParentSignal(taskDir, unit, { status, stepLabel, reason, event, now }) {
|
|
287
|
+
const target = targetForChecklistBasename(unit.parent.checklist);
|
|
288
|
+
const signalPath = path.join(taskDir, target.signal);
|
|
289
|
+
const signal = readJson(signalPath);
|
|
290
|
+
const timing = timingOf(signal, unit.parent.checklist);
|
|
291
|
+
const events = event
|
|
292
|
+
? appendTimingEvent(timing.events, event.stepNumber, event.label, now)
|
|
293
|
+
: timing.events;
|
|
294
|
+
// Key order mirrors the parent mark path's buildSignalUpdate (passthrough,
|
|
295
|
+
// step, checklistTiming, timestamp, then the status block) so a child-driven
|
|
296
|
+
// parent mark and a hand-run one produce the same bytes, not just the same
|
|
297
|
+
// values.
|
|
298
|
+
const next = {
|
|
299
|
+
...pickSignalPassthrough(signal),
|
|
300
|
+
step: stepLabel,
|
|
301
|
+
checklistTiming: { schemaVersion: 1, source: timing.source, events },
|
|
302
|
+
timestamp: now,
|
|
303
|
+
status,
|
|
304
|
+
...(status === 'blocked' ? { outcome: 'partial', disposition: 'blocked' } : {}),
|
|
305
|
+
...(reason ? { reason } : {}),
|
|
306
|
+
};
|
|
307
|
+
writeSignal(signalPath, next);
|
|
308
|
+
return next;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function writeChildSignal(
|
|
312
|
+
taskDir,
|
|
313
|
+
unit,
|
|
314
|
+
{ status, stepLabel, reason, outcome, disposition, events, reportPath, now },
|
|
315
|
+
) {
|
|
316
|
+
const signalPath = path.join(taskDir, unit.signal);
|
|
317
|
+
const signal = readJson(signalPath);
|
|
318
|
+
const next = {
|
|
319
|
+
role: 'subtask',
|
|
320
|
+
contextId: unit.id,
|
|
321
|
+
...(signal.attemptId !== undefined ? { attemptId: signal.attemptId } : {}),
|
|
322
|
+
parent: unit.parent,
|
|
323
|
+
status,
|
|
324
|
+
...(outcome ? { outcome } : {}),
|
|
325
|
+
...(disposition ? { disposition } : {}),
|
|
326
|
+
...(reason ? { reason } : {}),
|
|
327
|
+
...(reportPath ? { evidence: { reportPath } } : {}),
|
|
328
|
+
step: stepLabel,
|
|
329
|
+
checklistTiming: { schemaVersion: 1, source: unit.checklist, events },
|
|
330
|
+
timestamp: now,
|
|
331
|
+
};
|
|
332
|
+
writeSignal(signalPath, next);
|
|
333
|
+
return next;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// ---------------------------------------------------------------------------
|
|
337
|
+
// projection
|
|
338
|
+
|
|
339
|
+
/** The child projection `sub status` prints and the gateway mirrors in Phase 2. */
|
|
340
|
+
function subtaskProjection(taskDir, unit) {
|
|
341
|
+
const checklistPath = path.join(taskDir, unit.checklist);
|
|
342
|
+
const items = fs.existsSync(checklistPath)
|
|
343
|
+
? enumerateChecklistCheckboxes(fs.readFileSync(checklistPath, 'utf8'))
|
|
344
|
+
: [];
|
|
345
|
+
const signal = readJson(path.join(taskDir, unit.signal));
|
|
346
|
+
const timing = signal.checklistTiming;
|
|
347
|
+
const events = Array.isArray(timing?.events) ? timing.events : [];
|
|
348
|
+
const status = typeof signal.status === 'string' ? signal.status : null;
|
|
349
|
+
const open = items.find((item) => !item.checked) ?? null;
|
|
350
|
+
const lastEvent = events.length > 0 ? events[events.length - 1] : null;
|
|
351
|
+
return {
|
|
352
|
+
id: unit.id,
|
|
353
|
+
status,
|
|
354
|
+
settled: isSettledSubtaskStatus(status),
|
|
355
|
+
parent: unit.parent,
|
|
356
|
+
completedSteps: items.filter((item) => item.checked).length,
|
|
357
|
+
totalSteps: items.length,
|
|
358
|
+
currentStep: open ? checklistStepName(open.rawLabel) : null,
|
|
359
|
+
lastEventAt: lastEvent?.checkedAt ?? null,
|
|
360
|
+
};
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// ---------------------------------------------------------------------------
|
|
364
|
+
// verbs
|
|
365
|
+
|
|
366
|
+
function parsedChecklistFile(taskDir, relativePath) {
|
|
367
|
+
const absolute = path.join(taskDir, relativePath);
|
|
368
|
+
if (!fs.existsSync(absolute)) {
|
|
369
|
+
throw new SubtaskRefusal(`missing checklist: ${relativePath}`);
|
|
370
|
+
}
|
|
371
|
+
return { absolute, ...parseChecklist(fs.readFileSync(absolute, 'utf8')) };
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
function parseStepNumber(raw, label) {
|
|
375
|
+
const value = Number(raw);
|
|
376
|
+
if (!Number.isInteger(value) || value < 1) {
|
|
377
|
+
throw usageRefusal(`${label} must be a positive integer (got ${raw})`);
|
|
378
|
+
}
|
|
379
|
+
return value;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
function parseFlags(rest, allowed, label) {
|
|
383
|
+
const flags = { vars: {} };
|
|
384
|
+
for (let i = 0; i < rest.length; i += 1) {
|
|
385
|
+
const key = rest[i];
|
|
386
|
+
if (key === '--mark-last') {
|
|
387
|
+
if (!allowed.includes(key)) throw usageRefusal(`${label} does not accept ${key}`);
|
|
388
|
+
flags['mark-last'] = true;
|
|
389
|
+
continue;
|
|
390
|
+
}
|
|
391
|
+
if (!allowed.includes(key)) throw usageRefusal(`${label} does not accept ${key}`);
|
|
392
|
+
const value = rest[i + 1];
|
|
393
|
+
if (value === undefined) throw usageRefusal(`${key} requires a value`);
|
|
394
|
+
i += 1;
|
|
395
|
+
if (key === '--var') {
|
|
396
|
+
const eq = value.indexOf('=');
|
|
397
|
+
if (eq <= 0) throw usageRefusal('--var must use KEY=VALUE');
|
|
398
|
+
flags.vars[value.slice(0, eq)] = value.slice(eq + 1);
|
|
399
|
+
continue;
|
|
400
|
+
}
|
|
401
|
+
flags[key.slice(2)] = value;
|
|
402
|
+
}
|
|
403
|
+
return flags;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/**
|
|
407
|
+
* Register one child unit on a parent step.
|
|
408
|
+
*
|
|
409
|
+
* Ownership is per task directory, not per attempt: registration is refused
|
|
410
|
+
* whenever the step already has a unit, whatever that unit's status, and ids are
|
|
411
|
+
* unique across the directory ("one id per step for the life of the task
|
|
412
|
+
* directory"). A fresh `mark start` therefore leaves existing children owning
|
|
413
|
+
* their steps by design — an attempt that must redo child work gets a fresh task
|
|
414
|
+
* directory, which is how a relaunch already works. Without that rule a retry
|
|
415
|
+
* could reopen a step whose box a settled child had already ticked.
|
|
416
|
+
*/
|
|
417
|
+
function subStart(taskDir, rest) {
|
|
418
|
+
const id = rest[0];
|
|
419
|
+
if (!id || id.startsWith('-')) throw usageRefusal('sub start requires <id>');
|
|
420
|
+
if (!SUBTASK_ID_PATTERN.test(id)) {
|
|
421
|
+
throw new SubtaskRefusal(
|
|
422
|
+
`invalid subtask id '${id}': use a slug matching ${SUBTASK_ID_PATTERN}`,
|
|
423
|
+
);
|
|
424
|
+
}
|
|
425
|
+
const flags = parseFlags(
|
|
426
|
+
rest.slice(1),
|
|
427
|
+
['--step', '--from', '--var', '--checklist', '--signal'],
|
|
428
|
+
'sub start',
|
|
429
|
+
);
|
|
430
|
+
if (!flags.step) throw usageRefusal('sub start requires --step N');
|
|
431
|
+
const stepNumber = parseStepNumber(flags.step, '--step');
|
|
432
|
+
|
|
433
|
+
const target = resolveChecklistTargetWithOverrides(taskDir, {
|
|
434
|
+
...(flags.checklist ? { checklist: flags.checklist } : {}),
|
|
435
|
+
...(flags.signal ? { signal: flags.signal } : {}),
|
|
436
|
+
});
|
|
437
|
+
const parent = parsedChecklistFile(taskDir, target.checklist);
|
|
438
|
+
const parentRow = parent.items.find((item) => item.stepNumber === stepNumber) ?? null;
|
|
439
|
+
if (!parentRow) {
|
|
440
|
+
throw new SubtaskRefusal(`checklist step ${stepNumber} not found in ${target.checklist}`);
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
const index = readSubtaskIndex(taskDir) ?? { schemaVersion: 1, units: [] };
|
|
444
|
+
// One child per step for the LIFE of the task directory: a settled child has
|
|
445
|
+
// already ticked the box, so a second registration would reopen finished work.
|
|
446
|
+
const owner = index.units.find(
|
|
447
|
+
(unit) => unit.parent?.checklist === target.checklist && unit.parent?.stepNumber === stepNumber,
|
|
448
|
+
);
|
|
449
|
+
if (owner) {
|
|
450
|
+
throw new SubtaskRefusal(`step ${stepNumber} already owned by subtask ${owner.id}`);
|
|
451
|
+
}
|
|
452
|
+
if (unitById(index, id)) {
|
|
453
|
+
throw new SubtaskRefusal(`subtask ${id} already exists; one id per task directory`);
|
|
454
|
+
}
|
|
455
|
+
if (parentRow.checked) {
|
|
456
|
+
throw new SubtaskRefusal(
|
|
457
|
+
`step ${stepNumber} is already checked; a subtask cannot be registered on a completed step`,
|
|
458
|
+
);
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
const source = readSubtaskSource(flags.from, taskDir);
|
|
462
|
+
const body = stripMarkdownFrontmatter(source.text);
|
|
463
|
+
const label = `Subtask ${id} source ${source.ref ?? '(inline)'}`;
|
|
464
|
+
let rendered;
|
|
465
|
+
try {
|
|
466
|
+
rendered = renderPlaceholders(body, taskVars(taskDir, flags.vars), label);
|
|
467
|
+
} catch (err) {
|
|
468
|
+
throw new SubtaskRefusal(err instanceof Error ? err.message : String(err));
|
|
469
|
+
}
|
|
470
|
+
if (!rendered.endsWith('\n')) rendered += '\n';
|
|
471
|
+
|
|
472
|
+
const childItems = enumerateChecklistCheckboxes(rendered);
|
|
473
|
+
if (childItems.length === 0) {
|
|
474
|
+
throw new SubtaskRefusal(
|
|
475
|
+
`a child unit must have at least one step — ${label} has no enumerable checkbox ` +
|
|
476
|
+
`(section headings matching the informational skip list are not counted)`,
|
|
477
|
+
);
|
|
478
|
+
}
|
|
479
|
+
const mismatches = checklistNumberingMismatches(rendered);
|
|
480
|
+
if (mismatches.length > 0) {
|
|
481
|
+
throw new SubtaskRefusal(
|
|
482
|
+
`${label} has checklist numbering that does not match step positions:\n` +
|
|
483
|
+
mismatches.map((line) => `- ${line}`).join('\n'),
|
|
484
|
+
);
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
const paths = subtaskPaths(id);
|
|
488
|
+
const now = new Date().toISOString();
|
|
489
|
+
const unit = {
|
|
490
|
+
id,
|
|
491
|
+
parent: { checklist: target.checklist, stepNumber },
|
|
492
|
+
checklist: paths.checklist,
|
|
493
|
+
signal: paths.signal,
|
|
494
|
+
source: {
|
|
495
|
+
kind: source.kind,
|
|
496
|
+
...(source.ref ? { ref: source.ref } : {}),
|
|
497
|
+
sha256: sha256Text(source.text),
|
|
498
|
+
renderedSha256: sha256Text(rendered),
|
|
499
|
+
},
|
|
500
|
+
registeredAt: now,
|
|
501
|
+
};
|
|
502
|
+
|
|
503
|
+
atomicWrite(path.join(taskDir, paths.checklist), rendered);
|
|
504
|
+
writeSubtaskIndex(taskDir, { schemaVersion: 1, units: [...index.units, unit] });
|
|
505
|
+
const parentSignal = readJson(path.join(taskDir, target.signal));
|
|
506
|
+
writeSignal(path.join(taskDir, paths.signal), {
|
|
507
|
+
role: 'subtask',
|
|
508
|
+
contextId: id,
|
|
509
|
+
// Share the parent's attempt so every signal of one attempt correlates;
|
|
510
|
+
// contextId tells them apart.
|
|
511
|
+
...(parentSignal.attemptId !== undefined ? { attemptId: parentSignal.attemptId } : {}),
|
|
512
|
+
parent: unit.parent,
|
|
513
|
+
status: 'running',
|
|
514
|
+
checklistTiming: { schemaVersion: 1, source: paths.checklist, events: [] },
|
|
515
|
+
timestamp: now,
|
|
516
|
+
});
|
|
517
|
+
|
|
518
|
+
console.log(
|
|
519
|
+
`subtask ${id} registered on ${target.checklist} step ${stepNumber}: ${paths.checklist} (${childItems.length} step(s))`,
|
|
520
|
+
);
|
|
521
|
+
return 0;
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
function subStep(taskDir, unit, rawStep) {
|
|
525
|
+
const stepNumber = parseStepNumber(rawStep, 'subtask step');
|
|
526
|
+
const child = parsedChecklistFile(taskDir, unit.checklist);
|
|
527
|
+
const row = child.items.find((item) => item.stepNumber === stepNumber) ?? null;
|
|
528
|
+
if (!row) {
|
|
529
|
+
throw new SubtaskRefusal(`subtask ${unit.id} has no step ${stepNumber} in ${unit.checklist}`);
|
|
530
|
+
}
|
|
531
|
+
markStepInFile(child.absolute, row);
|
|
532
|
+
|
|
533
|
+
const now = new Date().toISOString();
|
|
534
|
+
const signal = readJson(path.join(taskDir, unit.signal));
|
|
535
|
+
const timing = timingOf(signal, unit.checklist);
|
|
536
|
+
writeChildSignal(taskDir, unit, {
|
|
537
|
+
status: 'running',
|
|
538
|
+
stepLabel: row.label,
|
|
539
|
+
events: appendTimingEvent(timing.events, row.stepNumber, row.label, now),
|
|
540
|
+
now,
|
|
541
|
+
});
|
|
542
|
+
resumeParentSignal(taskDir, unit, now);
|
|
543
|
+
console.log(`marked subtask ${unit.id} ${stepNumber}: ${row.label}`);
|
|
544
|
+
return 0;
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
/**
|
|
548
|
+
* A child `blocked` blocked the parent signal too; resuming the child restores
|
|
549
|
+
* `running` on both. Any other parent status is left alone: the parent is the
|
|
550
|
+
* worker's own signal and a child step is not a parent mark.
|
|
551
|
+
*/
|
|
552
|
+
function resumeParentSignal(taskDir, unit, now) {
|
|
553
|
+
const target = targetForChecklistBasename(unit.parent.checklist);
|
|
554
|
+
const parentSignal = readJson(path.join(taskDir, target.signal));
|
|
555
|
+
if (parentSignal.status !== 'blocked') return;
|
|
556
|
+
const parent = parsedChecklistFile(taskDir, unit.parent.checklist);
|
|
557
|
+
const parentRow = parent.items.find((item) => item.stepNumber === unit.parent.stepNumber) ?? null;
|
|
558
|
+
writeParentSignal(taskDir, unit, {
|
|
559
|
+
status: 'running',
|
|
560
|
+
stepLabel: parentRow?.label ?? parentSignal.step ?? 'running',
|
|
561
|
+
now,
|
|
562
|
+
});
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
function subComplete(taskDir, unit, rest) {
|
|
566
|
+
const flags = parseFlags(rest, ['--report', '--mark-last'], 'sub complete');
|
|
567
|
+
if (flags.report !== undefined) {
|
|
568
|
+
const reportAbs = path.join(taskDir, flags.report);
|
|
569
|
+
if (!fs.existsSync(reportAbs) || !fs.statSync(reportAbs).isFile()) {
|
|
570
|
+
throw new SubtaskRefusal(`missing required artifact: ${flags.report}`);
|
|
571
|
+
}
|
|
572
|
+
if (!fs.readFileSync(reportAbs, 'utf8').trim()) {
|
|
573
|
+
throw new SubtaskRefusal(`${flags.report} exists but is empty`);
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
const child = parsedChecklistFile(taskDir, unit.checklist);
|
|
578
|
+
const unchecked = child.items.filter((item) => !item.checked);
|
|
579
|
+
const allowOneUnchecked = Boolean(flags['mark-last']);
|
|
580
|
+
if (unchecked.length > (allowOneUnchecked ? 1 : 0)) {
|
|
581
|
+
const summary = unchecked
|
|
582
|
+
.slice(0, 5)
|
|
583
|
+
.map((entry) => `${entry.stepNumber}:${entry.label}`)
|
|
584
|
+
.join('; ');
|
|
585
|
+
throw new SubtaskRefusal(
|
|
586
|
+
`subtask ${unit.id} checklist incomplete — ${unchecked.length} step(s) still [ ] ` +
|
|
587
|
+
`(${summary}${unchecked.length > 5 ? '; …' : ''})` +
|
|
588
|
+
(allowOneUnchecked ? '' : ' — mark them, or pass --mark-last for the final box'),
|
|
589
|
+
);
|
|
590
|
+
}
|
|
591
|
+
const lastRow =
|
|
592
|
+
allowOneUnchecked && unchecked.length === 1 ? unchecked[unchecked.length - 1] : null;
|
|
593
|
+
if (lastRow) markStepInFile(child.absolute, lastRow);
|
|
594
|
+
|
|
595
|
+
const now = new Date().toISOString();
|
|
596
|
+
const signal = readJson(path.join(taskDir, unit.signal));
|
|
597
|
+
const timing = timingOf(signal, unit.checklist);
|
|
598
|
+
const events = lastRow
|
|
599
|
+
? appendTimingEvent(timing.events, lastRow.stepNumber, lastRow.label, now)
|
|
600
|
+
: timing.events;
|
|
601
|
+
// No flow terminal contract for a child unit: no inferFlowType, no
|
|
602
|
+
// terminalContractInputForChecklist, no check-task-artifact-contract.mjs.
|
|
603
|
+
writeChildSignal(taskDir, unit, {
|
|
604
|
+
status: 'complete',
|
|
605
|
+
outcome: 'success',
|
|
606
|
+
disposition: 'fixed',
|
|
607
|
+
stepLabel: lastRow?.label ?? signal.step ?? 'complete',
|
|
608
|
+
...(flags.report ? { reportPath: flags.report } : {}),
|
|
609
|
+
events,
|
|
610
|
+
now,
|
|
611
|
+
});
|
|
612
|
+
|
|
613
|
+
const parent = parsedChecklistFile(taskDir, unit.parent.checklist);
|
|
614
|
+
const parentRow = parent.items.find((item) => item.stepNumber === unit.parent.stepNumber) ?? null;
|
|
615
|
+
if (!parentRow) {
|
|
616
|
+
throw new SubtaskRefusal(
|
|
617
|
+
`parent step ${unit.parent.stepNumber} is no longer in ${unit.parent.checklist}; the checklist changed under the subtask`,
|
|
618
|
+
);
|
|
619
|
+
}
|
|
620
|
+
markStepInFile(parent.absolute, parentRow);
|
|
621
|
+
writeParentSignal(taskDir, unit, {
|
|
622
|
+
status: 'running',
|
|
623
|
+
stepLabel: parentRow.label,
|
|
624
|
+
event: { stepNumber: parentRow.stepNumber, label: parentRow.label },
|
|
625
|
+
now,
|
|
626
|
+
});
|
|
627
|
+
|
|
628
|
+
console.log(
|
|
629
|
+
`subtask ${unit.id} complete: ticked ${unit.parent.checklist} step ${parentRow.stepNumber} (${parentRow.label})`,
|
|
630
|
+
);
|
|
631
|
+
return 0;
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
function subBlocked(taskDir, unit, rest) {
|
|
635
|
+
const flags = parseFlags(rest, ['--reason'], 'sub blocked');
|
|
636
|
+
const reason = flags.reason?.trim();
|
|
637
|
+
if (!reason) throw usageRefusal('sub blocked requires --reason');
|
|
638
|
+
|
|
639
|
+
const child = parsedChecklistFile(taskDir, unit.checklist);
|
|
640
|
+
const signal = readJson(path.join(taskDir, unit.signal));
|
|
641
|
+
const timing = timingOf(signal, unit.checklist);
|
|
642
|
+
const open = child.items.find((item) => !item.checked) ?? null;
|
|
643
|
+
const now = new Date().toISOString();
|
|
644
|
+
// A child signal never carries `failed`: work that cannot finish is blocked
|
|
645
|
+
// with a reason, and the operator uses the existing blocked-run actions.
|
|
646
|
+
writeChildSignal(taskDir, unit, {
|
|
647
|
+
status: 'blocked',
|
|
648
|
+
outcome: 'partial',
|
|
649
|
+
disposition: 'blocked',
|
|
650
|
+
reason,
|
|
651
|
+
stepLabel: signal.step ?? open?.label ?? 'blocked',
|
|
652
|
+
events: timing.events,
|
|
653
|
+
now,
|
|
654
|
+
});
|
|
655
|
+
|
|
656
|
+
const parent = parsedChecklistFile(taskDir, unit.parent.checklist);
|
|
657
|
+
const parentRow = parent.items.find((item) => item.stepNumber === unit.parent.stepNumber) ?? null;
|
|
658
|
+
writeParentSignal(taskDir, unit, {
|
|
659
|
+
status: 'blocked',
|
|
660
|
+
stepLabel: parentRow?.label ?? `step ${unit.parent.stepNumber}`,
|
|
661
|
+
reason: `subtask ${unit.id}: ${reason}`,
|
|
662
|
+
now,
|
|
663
|
+
});
|
|
664
|
+
|
|
665
|
+
console.log(`subtask ${unit.id} blocked: ${reason}`);
|
|
666
|
+
return 0;
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
function subStatus(taskDir, unit) {
|
|
670
|
+
console.log(JSON.stringify(subtaskProjection(taskDir, unit), null, 2));
|
|
671
|
+
return 0;
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
/** `mark <task-dir> sub …`. Returns the process exit code; never throws. */
|
|
675
|
+
function runSubtaskCommand(taskDirRaw, args) {
|
|
676
|
+
const taskDir = path.resolve(taskDirRaw);
|
|
677
|
+
try {
|
|
678
|
+
if (args.length === 0) throw usageRefusal(SUB_USAGE);
|
|
679
|
+
if (args[0] === '--help' || args[0] === '-h') {
|
|
680
|
+
printSubtaskHelp();
|
|
681
|
+
return 0;
|
|
682
|
+
}
|
|
683
|
+
if (args[0] === 'start') return subStart(taskDir, args.slice(1));
|
|
684
|
+
|
|
685
|
+
const id = args[0];
|
|
686
|
+
const verb = args[1];
|
|
687
|
+
if (verb === undefined) throw usageRefusal(SUB_USAGE);
|
|
688
|
+
const index = readSubtaskIndex(taskDir);
|
|
689
|
+
const unit = unitById(index, id);
|
|
690
|
+
if (!unit) {
|
|
691
|
+
const known = (index?.units ?? []).map((entry) => entry.id);
|
|
692
|
+
throw new SubtaskRefusal(
|
|
693
|
+
`unknown subtask ${id}${known.length ? ` (registered: ${known.join(', ')})` : ''}; ` +
|
|
694
|
+
`register it with ./mark sub start ${id} --step N --from <source>`,
|
|
695
|
+
);
|
|
696
|
+
}
|
|
697
|
+
if (verb === 'complete') return subComplete(taskDir, unit, args.slice(2));
|
|
698
|
+
if (verb === 'blocked') return subBlocked(taskDir, unit, args.slice(2));
|
|
699
|
+
if (verb === 'status') return subStatus(taskDir, unit);
|
|
700
|
+
return subStep(taskDir, unit, verb);
|
|
701
|
+
} catch (err) {
|
|
702
|
+
if (err instanceof SubtaskRefusal) {
|
|
703
|
+
console.error(err.message);
|
|
704
|
+
return err.code;
|
|
705
|
+
}
|
|
706
|
+
console.error(err instanceof Error ? err.message : String(err));
|
|
707
|
+
return 1;
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
module.exports = {
|
|
712
|
+
SUBTASK_INDEX_REL,
|
|
713
|
+
SubtaskRefusal,
|
|
714
|
+
openSubtaskRefusal,
|
|
715
|
+
openSubtaskUnits,
|
|
716
|
+
readSubtaskIndex,
|
|
717
|
+
renderPlaceholders,
|
|
718
|
+
runSubtaskCommand,
|
|
719
|
+
stripMarkdownFrontmatter,
|
|
720
|
+
subtaskIndexPath,
|
|
721
|
+
subtaskOwningStep,
|
|
722
|
+
subtaskProjection,
|
|
723
|
+
taskVars,
|
|
724
|
+
};
|