@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.
@@ -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
+ };