@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.
@@ -8,11 +8,25 @@ const {
8
8
  resolveWorkerTerminalContract,
9
9
  } = require('./worker-terminal-contract.cjs');
10
10
  const {
11
- checklistStepName,
12
- enumerateChecklistCheckboxes,
13
11
  parseTaskDirMarkArgs,
14
12
  terminalContractInputForChecklist,
15
13
  } = require('./checklist-target.cjs');
14
+ const {
15
+ atomicWrite,
16
+ markStepInLines,
17
+ parseChecklist,
18
+ pickSignalPassthrough,
19
+ readJson,
20
+ signalContentUnchanged,
21
+ writeSignal,
22
+ } = require('./mark-io.cjs');
23
+ const { AcceptanceRefusal, requireHandoffAcceptanceCriteria } = require('./acceptance-ledger.cjs');
24
+ const {
25
+ openSubtaskRefusal,
26
+ openSubtaskUnits,
27
+ runSubtaskCommand,
28
+ subtaskOwningStep,
29
+ } = require('./subtask-unit.cjs');
16
30
 
17
31
  const START_COMMANDS = new Set(['start']);
18
32
  const TERMINAL_COMMANDS = new Set(['complete', 'no-change', 'blocked']);
@@ -49,6 +63,8 @@ function printHelp() {
49
63
  'Override: farmslot-agent mark <task-dir> --checklist SELF-REVIEW.md <step> — optional --signal; signal defaults from checklist name.',
50
64
  'Bootstrap: ./mark start — role-owned signal with status running (no checklist box).',
51
65
  'Progress: ./mark 1, ./mark 2, ... — checks the box and appends checklistTiming.',
66
+ 'Child units: ./mark sub start <id> --step N --from <path|inline:text>, then ./mark sub <id> <n> | complete [--report PATH] [--mark-last] | blocked --reason "..." | status.',
67
+ ' While a child owns step N, ./mark N is refused; the child complete ticks it. Run ./mark sub --help for the child verbs.',
52
68
  'Terminal:',
53
69
  ' ./mark complete [--mark-last] [--no-self-review] [--skip-learnings] [--skip-checklist]',
54
70
  ' ./mark no-change --reason "..." [--already-fixed] [--mark-last] [--skip-learnings] [--skip-checklist]',
@@ -86,6 +102,11 @@ function resolveMarkInvocation(rawArgs) {
86
102
  const firstPath = path.resolve(first);
87
103
  const firstIsDir = fs.existsSync(firstPath) && fs.statSync(firstPath).isDirectory();
88
104
  if (firstIsDir) {
105
+ // Child-unit verbs own their own argument grammar (`sub start <id> …`), so
106
+ // they are routed before the parent step parser sees a non-step token.
107
+ if (rawArgs[1] === 'sub') {
108
+ process.exit(runSubtaskCommand(firstPath, rawArgs.slice(2)));
109
+ }
89
110
  const parsed = parseTaskDirMarkArgs(firstPath, rawArgs.slice(1), {
90
111
  isMarkStepToken,
91
112
  usage,
@@ -139,16 +160,6 @@ if (terminalCommand === 'no-change' || terminalCommand === 'blocked') {
139
160
  }
140
161
  }
141
162
 
142
- const SIGNAL_PASSTHROUGH_KEYS = ['role', 'contextId', 'attemptId', 'prNumber'];
143
-
144
- function pickSignalPassthrough(signal) {
145
- const out = {};
146
- for (const key of SIGNAL_PASSTHROUGH_KEYS) {
147
- if (signal[key] !== undefined) out[key] = signal[key];
148
- }
149
- return out;
150
- }
151
-
152
163
  function resolveTerminalPreset(command) {
153
164
  switch (command) {
154
165
  case 'complete':
@@ -309,6 +320,25 @@ function assertArtifactContract(taskDir, taskPath, contract, terminalCommand) {
309
320
  }
310
321
  const contractPath = terminalContractPath(taskDir, taskPath);
311
322
  const args = [ARTIFACT_CONTRACT_SCRIPT, taskDir];
323
+ // The ledger blocks `complete` only where the project opted in
324
+ // (`worker_terminal.acceptance.require`) AND the task registered criteria
325
+ // (ADR-060). Without the opt-in a template that does not write a ledger yet
326
+ // still completes; the ledger is watched, projected and preferred for coverage
327
+ // either way.
328
+ if (terminalCommand === 'complete' && contract?.acceptance?.require === true) {
329
+ let criteria;
330
+ try {
331
+ criteria = requireHandoffAcceptanceCriteria(taskDir);
332
+ } catch (err) {
333
+ // Fail closed, the same way the gateway's backup check does: a handoff the
334
+ // engine cannot read hides whether the criteria were judged, and dropping
335
+ // the rule the project asked for is how an unproven run closes.
336
+ if (!(err instanceof AcceptanceRefusal)) throw err;
337
+ console.error(err.message);
338
+ process.exit(1);
339
+ }
340
+ if (criteria.length > 0) args.push('--require-acceptance-status');
341
+ }
312
342
  if (fs.existsSync(contractPath)) {
313
343
  args.push('--contract', contractPath);
314
344
  if (terminalCommand) args.push('--terminal', terminalCommand);
@@ -403,45 +433,6 @@ function buildSignalUpdate(signal, terminal, target, timing, events, now, taskPa
403
433
  return next;
404
434
  }
405
435
 
406
- function atomicWrite(file, content, mode) {
407
- const dir = path.dirname(file);
408
- fs.mkdirSync(dir, { recursive: true });
409
- const tmp = path.join(dir, `.${path.basename(file)}.${process.pid}.tmp`);
410
- fs.writeFileSync(tmp, content, mode ? { mode } : undefined);
411
- fs.renameSync(tmp, file);
412
- }
413
-
414
- function readJson(file) {
415
- try {
416
- return JSON.parse(fs.readFileSync(file, 'utf8'));
417
- } catch (err) {
418
- if (err && err.code === 'ENOENT') return {};
419
- throw err;
420
- }
421
- }
422
-
423
- // Step enumeration is shared with the gateway parsers (generateTaskSchema in
424
- // tasks/writer.ts and parseCheckboxStates in methods/task.ts) via the
425
- // checklist-target enumerator: same skip sections, <details> handling, and
426
- // checkbox shape. Any divergence makes `mark N` check a different box than
427
- // the one progress reporting counts as step N (checkbox-formatted Acceptance
428
- // Criteria used to shift every step by the AC count).
429
- function parseChecklist(markdown) {
430
- const lines = markdown.split(/\n/);
431
- const items = enumerateChecklistCheckboxes(markdown).map((item) => ({
432
- ...item,
433
- label: checklistStepName(item.rawLabel),
434
- }));
435
- return { lines, items };
436
- }
437
-
438
- function markStepInLines(lines, item) {
439
- if (item.checked) return false;
440
- const before = lines[item.lineIndex];
441
- lines[item.lineIndex] = before.replace(/^(\s*- \[)( |x|X)(\])/, '$1x$3');
442
- return lines[item.lineIndex] !== before;
443
- }
444
-
445
436
  function resolveTarget(taskPath, stepNumber, markLast) {
446
437
  const original = fs.readFileSync(taskPath, 'utf8');
447
438
  const parsed = parseChecklist(original);
@@ -458,6 +449,9 @@ function resolveTarget(taskPath, stepNumber, markLast) {
458
449
  }
459
450
  let updated = original;
460
451
  if (item) {
452
+ // Also covers `--mark-last`: the parent must not tick a box whose child is
453
+ // still open, whichever way the row was chosen.
454
+ assertStepNotOwnedBySubtask(item.stepNumber);
461
455
  const nextLines = [...parsed.lines];
462
456
  markStepInLines(nextLines, item);
463
457
  updated = nextLines.join('\n');
@@ -469,6 +463,36 @@ function resolveTarget(taskPath, stepNumber, markLast) {
469
463
  }
470
464
 
471
465
  const taskDir = path.dirname(signalPath);
466
+
467
+ /**
468
+ * A step owned by a child unit belongs to that child until it is settled
469
+ * (`complete` or `done` — a `blocked` child keeps ownership). The child's
470
+ * `complete` ticks the box, so a later `mark N` on it is an idempotent no-op.
471
+ */
472
+ function assertStepNotOwnedBySubtask(stepNumber) {
473
+ if (stepNumber == null) return;
474
+ const owner = subtaskOwningStep(taskDir, path.basename(taskPath), stepNumber);
475
+ if (!owner) return;
476
+ const open = openSubtaskUnits(taskDir).find((entry) => entry.unit.id === owner.id);
477
+ if (!open) return;
478
+ console.error(
479
+ `step ${stepNumber} is owned by subtask ${owner.id}; finish it with ./mark sub ${owner.id} complete`,
480
+ );
481
+ process.exit(1);
482
+ }
483
+
484
+ /** Every registered child must be settled before the parent reports success. */
485
+ function assertNoOpenSubtasks(command) {
486
+ const open = openSubtaskUnits(taskDir);
487
+ if (open.length === 0) return;
488
+ console.error(openSubtaskRefusal(open, command));
489
+ process.exit(1);
490
+ }
491
+
492
+ // `blocked` stays available while a child is open: the run is blocked either way.
493
+ if (terminalCommand === 'complete' || terminalCommand === 'no-change') {
494
+ assertNoOpenSubtasks(terminalCommand);
495
+ }
472
496
  if (
473
497
  terminalCommand &&
474
498
  (terminalCommand === 'complete' || terminalCommand === 'no-change') &&
@@ -514,14 +538,27 @@ const next = buildSignalUpdate(
514
538
  taskPath,
515
539
  taskDir,
516
540
  );
517
- atomicWrite(signalPath, `${JSON.stringify(next, null, 2)}\n`, 0o644);
518
-
519
- if (isStartCommand) {
520
- console.log('signal started');
521
- } else if (terminalCommand) {
522
- console.log(
523
- `signal ${terminalCommand}: status=${next.status} disposition=${next.disposition ?? 'n/a'}`,
524
- );
541
+ // Re-marking a step that is already done changes nothing: the box was checked
542
+ // and its timing event already exists, so the only difference from the stored
543
+ // signal would be a fresh `timestamp`. Compare content, never the command: that
544
+ // keeps `start` (new attemptId), a terminal mark (new status), a resume from
545
+ // `blocked`, and a box checked by hand without an event all writing as before,
546
+ // while a genuinely empty re-mark stays a no-op.
547
+ const alreadyRecorded = !isStartCommand && !terminalCommand && signalContentUnchanged(signal, next);
548
+
549
+ if (alreadyRecorded) {
550
+ console.log(`already marked ${target.stepNumber}: ${target.label}`);
525
551
  } else {
526
- console.log(`marked ${target.stepNumber}: ${target.label}`);
552
+ // One signal writer for the parent and child paths, so their bytes and mode
553
+ // cannot drift (mark-io.cjs writeSignal).
554
+ writeSignal(signalPath, next);
555
+ if (isStartCommand) {
556
+ console.log('signal started');
557
+ } else if (terminalCommand) {
558
+ console.log(
559
+ `signal ${terminalCommand}: status=${next.status} disposition=${next.disposition ?? 'n/a'}`,
560
+ );
561
+ } else {
562
+ console.log(`marked ${target.stepNumber}: ${target.label}`);
563
+ }
527
564
  }
@@ -0,0 +1,118 @@
1
+ const fs = require('node:fs');
2
+ const path = require('node:path');
3
+
4
+ const { checklistStepName, enumerateChecklistCheckboxes } = require('./checklist-target.cjs');
5
+
6
+ // One definition of how the mark engine reads and writes task-dir files, shared
7
+ // by the parent mark path and the child-unit (`sub`) verbs so both write the
8
+ // same bytes the same way.
9
+
10
+ function atomicWrite(file, content, mode) {
11
+ const dir = path.dirname(file);
12
+ fs.mkdirSync(dir, { recursive: true });
13
+ const tmp = path.join(dir, `.${path.basename(file)}.${process.pid}.tmp`);
14
+ fs.writeFileSync(tmp, content, mode ? { mode } : undefined);
15
+ fs.renameSync(tmp, file);
16
+ }
17
+
18
+ function readJson(file) {
19
+ try {
20
+ return JSON.parse(fs.readFileSync(file, 'utf8'));
21
+ } catch (err) {
22
+ if (err && err.code === 'ENOENT') return {};
23
+ throw err;
24
+ }
25
+ }
26
+
27
+ /** Signal JSON with a trailing newline and the mode every signal file carries. */
28
+ function writeSignal(signalPath, signal) {
29
+ atomicWrite(signalPath, `${JSON.stringify(signal, null, 2)}\n`, 0o644);
30
+ }
31
+
32
+ /** Registry JSON with a trailing newline and the same mode as a signal file. */
33
+ function writeIndex(indexPath, index) {
34
+ atomicWrite(indexPath, `${JSON.stringify(index, null, 2)}\n`, 0o644);
35
+ }
36
+
37
+ /**
38
+ * Stable string for "is this the same signal content?" — key order and object
39
+ * nesting normalized, so two writers that agree on values but not on insertion
40
+ * order compare equal.
41
+ */
42
+ function canonicalJson(value) {
43
+ if (Array.isArray(value)) return `[${value.map(canonicalJson).join(',')}]`;
44
+ if (value && typeof value === 'object') {
45
+ return `{${Object.keys(value)
46
+ .sort()
47
+ .map((key) => `${JSON.stringify(key)}:${canonicalJson(value[key])}`)
48
+ .join(',')}}`;
49
+ }
50
+ return JSON.stringify(value);
51
+ }
52
+
53
+ /** True when two signals differ only by `timestamp` (or not at all). */
54
+ function signalContentUnchanged(stored, candidate) {
55
+ const withoutTimestamp = (signal) => {
56
+ const { timestamp: _timestamp, ...rest } = signal;
57
+ return canonicalJson(rest);
58
+ };
59
+ return withoutTimestamp(stored) === withoutTimestamp(candidate);
60
+ }
61
+
62
+ const SIGNAL_PASSTHROUGH_KEYS = ['role', 'contextId', 'attemptId', 'prNumber'];
63
+
64
+ function pickSignalPassthrough(signal) {
65
+ const out = {};
66
+ for (const key of SIGNAL_PASSTHROUGH_KEYS) {
67
+ if (signal[key] !== undefined) out[key] = signal[key];
68
+ }
69
+ return out;
70
+ }
71
+
72
+ // Step enumeration is shared with the gateway parsers (generateTaskSchema in
73
+ // tasks/writer.ts and parseCheckboxStates in methods/task.ts) via the
74
+ // checklist-target enumerator: same skip sections, <details> handling, and
75
+ // checkbox shape. Any divergence makes `mark N` check a different box than
76
+ // the one progress reporting counts as step N (checkbox-formatted Acceptance
77
+ // Criteria used to shift every step by the AC count).
78
+ function parseChecklist(markdown) {
79
+ const lines = markdown.split(/\n/);
80
+ const items = enumerateChecklistCheckboxes(markdown).map((item) => ({
81
+ ...item,
82
+ label: checklistStepName(item.rawLabel),
83
+ }));
84
+ return { lines, items };
85
+ }
86
+
87
+ function markStepInLines(lines, item) {
88
+ if (item.checked) return false;
89
+ const before = lines[item.lineIndex];
90
+ lines[item.lineIndex] = before.replace(/^(\s*- \[)( |x|X)(\])/, '$1x$3');
91
+ return lines[item.lineIndex] !== before;
92
+ }
93
+
94
+ /** Tick one enumerated row in a checklist file. Returns true when the file changed. */
95
+ function markStepInFile(filePath, item) {
96
+ const original = fs.readFileSync(filePath, 'utf8');
97
+ const parsed = parseChecklist(original);
98
+ const lines = [...parsed.lines];
99
+ markStepInLines(lines, item);
100
+ const updated = lines.join('\n');
101
+ if (updated === original) return false;
102
+ atomicWrite(filePath, updated);
103
+ return true;
104
+ }
105
+
106
+ module.exports = {
107
+ atomicWrite,
108
+ readJson,
109
+ writeSignal,
110
+ writeIndex,
111
+ canonicalJson,
112
+ signalContentUnchanged,
113
+ SIGNAL_PASSTHROUGH_KEYS,
114
+ pickSignalPassthrough,
115
+ parseChecklist,
116
+ markStepInLines,
117
+ markStepInFile,
118
+ };