@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.
@@ -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,8 +86,29 @@ 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
 
105
+ // checklistStepName — mirror of the protocol rule for the name a step is shown
106
+ // under: the bold lead, numbering kept, instructions dropped. `mark` records it
107
+ // in SIGNAL.json so events and every step view name a row the same way.
108
+ function checklistStepName(rawLabel) {
109
+ return rawLabel.replace(/^\*\*(.+?)\*\*.*$/, '$1').trim();
110
+ }
111
+
86
112
  function signalFileForChecklist(checklistBasename) {
87
113
  if (
88
114
  checklistBasename === TASK_PROGRESS_MARKDOWN ||
@@ -94,6 +120,37 @@ function signalFileForChecklist(checklistBasename) {
94
120
  return `${base}${ROLE_SIGNAL_SUFFIX}`;
95
121
  }
96
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
+
97
154
  function taskDirRelPath(taskDir, basename) {
98
155
  const normalized = String(taskDir).replace(/\/+$/, '');
99
156
  return `${normalized}/${basename}`;
@@ -268,7 +325,14 @@ function resolveChecklistPaths(taskDir) {
268
325
 
269
326
  module.exports = {
270
327
  CHECKLIST_SKIP_SECTIONS,
328
+ checklistStepName,
329
+ checklistNumberingMismatches,
271
330
  enumerateChecklistCheckboxes,
331
+ SUBTASKS_DIR,
332
+ SUBTASK_INDEX_FILE,
333
+ SUBTASK_ID_PATTERN,
334
+ isSettledSubtaskStatus,
335
+ subtaskPaths,
272
336
  CHECKLIST_TARGET_MANIFEST,
273
337
  TASK_PROGRESS_MARKDOWN,
274
338
  INTERACTIVE_CHECKLIST_MARKDOWN,