@farmslot/agent-runtime 0.1.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 +13 -0
- package/LICENSE +21 -0
- package/README.md +58 -0
- package/bin/farmslot-agent.mjs +481 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +8 -0
- package/dist/index.js.map +1 -0
- package/dist/recipe-quality.d.ts +27 -0
- package/dist/recipe-quality.d.ts.map +1 -0
- package/dist/recipe-quality.js +178 -0
- package/dist/recipe-quality.js.map +1 -0
- package/package.json +73 -0
- package/scripts/check-task-artifact-contract.mjs +400 -0
- package/scripts/mark-checklist-step.cjs +488 -0
- package/scripts/worker-terminal-contract.cjs +382 -0
|
@@ -0,0 +1,488 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
const fs = require('node:fs');
|
|
3
|
+
const path = require('node:path');
|
|
4
|
+
const { spawnSync } = require('node:child_process');
|
|
5
|
+
const {
|
|
6
|
+
expandedArtifactsForCommand,
|
|
7
|
+
resolveWorkerTerminalContract,
|
|
8
|
+
} = require('./worker-terminal-contract.cjs');
|
|
9
|
+
|
|
10
|
+
const START_COMMANDS = new Set(['start']);
|
|
11
|
+
const TERMINAL_COMMANDS = new Set(['complete', 'no-change', 'blocked']);
|
|
12
|
+
const NO_CHANGE_REPORT = path.join('artifacts', 'no-change-report.md');
|
|
13
|
+
const LEARNINGS_ARTIFACT = path.join('artifacts', 'learnings.md');
|
|
14
|
+
const ARTIFACT_CONTRACT_SCRIPT = path.resolve(__dirname, 'check-task-artifact-contract.mjs');
|
|
15
|
+
|
|
16
|
+
const FLOW_REPORT_ARTIFACTS = {
|
|
17
|
+
'fix-bug': ['pr-description.md', 'report.md'],
|
|
18
|
+
'review-pr': ['review.md', 'report.md'],
|
|
19
|
+
dev: ['pr-description.md', 'report.md'],
|
|
20
|
+
'pr-complete': ['comments-report.md', 'report.md'],
|
|
21
|
+
'merge-main': ['report.md', 'merge-report.md'],
|
|
22
|
+
};
|
|
23
|
+
const FALLBACK_REPORT_ARTIFACTS = [
|
|
24
|
+
'report.md',
|
|
25
|
+
'review.md',
|
|
26
|
+
'comments-report.md',
|
|
27
|
+
'merge-report.md',
|
|
28
|
+
];
|
|
29
|
+
|
|
30
|
+
const usageLine =
|
|
31
|
+
'usage: mark <step-number|start|complete|no-change|blocked> [--reason text] [--already-fixed] [--mark-last] [--no-self-review] [--skip-learnings] [--skip-checklist]';
|
|
32
|
+
|
|
33
|
+
function printHelp() {
|
|
34
|
+
console.log(
|
|
35
|
+
[
|
|
36
|
+
usageLine,
|
|
37
|
+
'',
|
|
38
|
+
'Bootstrap: ./mark start — worker-owned SIGNAL.json with status running (no checklist box).',
|
|
39
|
+
'Progress: ./mark 1, ./mark 2, ... — checks the box and appends checklistTiming.',
|
|
40
|
+
'Terminal:',
|
|
41
|
+
' ./mark complete [--mark-last] [--no-self-review] [--skip-learnings] [--skip-checklist]',
|
|
42
|
+
' ./mark no-change --reason "..." [--already-fixed] [--mark-last] [--skip-learnings] [--skip-checklist]',
|
|
43
|
+
' ./mark blocked --reason "..." [--mark-last]',
|
|
44
|
+
'Terminal success paths require non-empty artifacts/learnings.md and a flow outcome artifact (complete) or no-change-report (no-change).',
|
|
45
|
+
'PR flows (dev, fix-bug): write artifacts/pr-description.md. Other flows: review.md, merge-report.md, report.md, etc.',
|
|
46
|
+
'With --mark-last, every checklist box must be [x] unless --skip-checklist. complete also runs check-task-artifact-contract.mjs.',
|
|
47
|
+
'Do not write SIGNAL.json by hand or with echo.',
|
|
48
|
+
].join('\n'),
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function usage() {
|
|
53
|
+
console.error(usageLine);
|
|
54
|
+
console.error('run ./mark --help for details');
|
|
55
|
+
process.exit(2);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const args = process.argv.slice(2);
|
|
59
|
+
if (args.length === 1 && (args[0] === '--help' || args[0] === '-h')) {
|
|
60
|
+
printHelp();
|
|
61
|
+
process.exit(0);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const [taskPath, signalPath, stepRaw, ...rest] = args;
|
|
65
|
+
if (stepRaw === '--help' || stepRaw === '-h') {
|
|
66
|
+
printHelp();
|
|
67
|
+
process.exit(0);
|
|
68
|
+
}
|
|
69
|
+
if (!taskPath || !signalPath || !stepRaw) usage();
|
|
70
|
+
|
|
71
|
+
const opts = {};
|
|
72
|
+
for (let i = 0; i < rest.length; i += 1) {
|
|
73
|
+
const key = rest[i];
|
|
74
|
+
if (
|
|
75
|
+
key === '--mark-last' ||
|
|
76
|
+
key === '--already-fixed' ||
|
|
77
|
+
key === '--no-self-review' ||
|
|
78
|
+
key === '--skip-learnings' ||
|
|
79
|
+
key === '--skip-checklist'
|
|
80
|
+
) {
|
|
81
|
+
opts[key.slice(2)] = true;
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
const value = rest[i + 1];
|
|
85
|
+
if (!key?.startsWith('--') || value === undefined) usage();
|
|
86
|
+
opts[key.slice(2)] = value;
|
|
87
|
+
i += 1;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const stepToken = String(stepRaw).toLowerCase();
|
|
91
|
+
const isStartCommand = START_COMMANDS.has(stepToken);
|
|
92
|
+
const terminalCommand = TERMINAL_COMMANDS.has(stepToken) ? stepToken : null;
|
|
93
|
+
let stepNumber = null;
|
|
94
|
+
if (!isStartCommand && !terminalCommand) {
|
|
95
|
+
stepNumber = Number(stepRaw);
|
|
96
|
+
if (!Number.isInteger(stepNumber) || stepNumber < 1) usage();
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (isStartCommand && Object.keys(opts).length > 0) usage();
|
|
100
|
+
if (terminalCommand === 'no-change' || terminalCommand === 'blocked') {
|
|
101
|
+
if (!opts.reason?.trim()) {
|
|
102
|
+
console.error(`${terminalCommand} requires --reason`);
|
|
103
|
+
process.exit(1);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const SIGNAL_PASSTHROUGH_KEYS = ['role', 'contextId', 'prNumber'];
|
|
108
|
+
|
|
109
|
+
function pickSignalPassthrough(signal) {
|
|
110
|
+
const out = {};
|
|
111
|
+
for (const key of SIGNAL_PASSTHROUGH_KEYS) {
|
|
112
|
+
if (signal[key] !== undefined) out[key] = signal[key];
|
|
113
|
+
}
|
|
114
|
+
return out;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function resolveTerminalPreset(command) {
|
|
118
|
+
switch (command) {
|
|
119
|
+
case 'complete':
|
|
120
|
+
return { status: 'complete', outcome: 'success', disposition: 'fixed' };
|
|
121
|
+
case 'no-change':
|
|
122
|
+
return {
|
|
123
|
+
status: 'complete',
|
|
124
|
+
outcome: 'success',
|
|
125
|
+
disposition: opts['already-fixed'] ? 'already_fixed' : 'not_reproducible',
|
|
126
|
+
};
|
|
127
|
+
case 'blocked':
|
|
128
|
+
return { status: 'blocked', outcome: 'partial', disposition: 'blocked' };
|
|
129
|
+
default:
|
|
130
|
+
usage();
|
|
131
|
+
return null;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function loadTerminalContract(taskDir, taskPath) {
|
|
136
|
+
const contractPath = path.join(taskDir, 'inputs', 'worker-terminal-contract.json');
|
|
137
|
+
if (fs.existsSync(contractPath)) {
|
|
138
|
+
return JSON.parse(fs.readFileSync(contractPath, 'utf8'));
|
|
139
|
+
}
|
|
140
|
+
const flowType = inferFlowType(taskPath);
|
|
141
|
+
return flowType ? resolveWorkerTerminalContract(null, flowType) : null;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function taskFileExists(taskDir, storedPath) {
|
|
145
|
+
try {
|
|
146
|
+
return fs.statSync(path.join(taskDir, storedPath)).isFile();
|
|
147
|
+
} catch {
|
|
148
|
+
return false;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function assertArtifactFile(taskDir, storedPath) {
|
|
153
|
+
const abs = path.join(taskDir, storedPath);
|
|
154
|
+
if (!fs.existsSync(abs)) {
|
|
155
|
+
console.error(`missing required artifact: ${storedPath}`);
|
|
156
|
+
process.exit(1);
|
|
157
|
+
}
|
|
158
|
+
if (storedPath.endsWith('.md')) {
|
|
159
|
+
const text = fs.readFileSync(abs, 'utf8').trim();
|
|
160
|
+
if (!text) {
|
|
161
|
+
console.error(`${storedPath} exists but is empty`);
|
|
162
|
+
process.exit(1);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function assertRequiredArtifacts(taskDir, terminalCommand, contract) {
|
|
168
|
+
if (!contract) return;
|
|
169
|
+
const expanded = expandedArtifactsForCommand(contract, terminalCommand, (rel) =>
|
|
170
|
+
taskFileExists(taskDir, rel),
|
|
171
|
+
);
|
|
172
|
+
for (const storedPath of expanded.artifacts) {
|
|
173
|
+
if (storedPath === LEARNINGS_ARTIFACT && opts['skip-learnings']) continue;
|
|
174
|
+
assertArtifactFile(taskDir, storedPath);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function assertNoChangeReport(taskDir) {
|
|
179
|
+
const reportAbs = path.join(taskDir, NO_CHANGE_REPORT);
|
|
180
|
+
if (!fs.existsSync(reportAbs) || fs.statSync(reportAbs).size === 0) {
|
|
181
|
+
console.error(`missing required report: ${NO_CHANGE_REPORT}`);
|
|
182
|
+
process.exit(1);
|
|
183
|
+
}
|
|
184
|
+
return NO_CHANGE_REPORT;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function assertLearningsArtifact(taskDir) {
|
|
188
|
+
const learningsAbs = path.join(taskDir, LEARNINGS_ARTIFACT);
|
|
189
|
+
if (!fs.existsSync(learningsAbs)) {
|
|
190
|
+
console.error(`missing required artifact: ${LEARNINGS_ARTIFACT}`);
|
|
191
|
+
process.exit(1);
|
|
192
|
+
}
|
|
193
|
+
const text = fs.readFileSync(learningsAbs, 'utf8').trim();
|
|
194
|
+
if (!text) {
|
|
195
|
+
console.error(`${LEARNINGS_ARTIFACT} exists but is empty — write at least one bullet`);
|
|
196
|
+
process.exit(1);
|
|
197
|
+
}
|
|
198
|
+
return LEARNINGS_ARTIFACT;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function readArtifactText(taskDir, relativePath) {
|
|
202
|
+
const artifactAbs = path.join(taskDir, 'artifacts', relativePath);
|
|
203
|
+
if (!fs.existsSync(artifactAbs)) return null;
|
|
204
|
+
const text = fs.readFileSync(artifactAbs, 'utf8').trim();
|
|
205
|
+
return text || null;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function inferFlowType(taskPath) {
|
|
209
|
+
let head = '';
|
|
210
|
+
try {
|
|
211
|
+
head = fs.readFileSync(taskPath, 'utf8').split('\n').slice(0, 24).join('\n');
|
|
212
|
+
} catch {
|
|
213
|
+
return null;
|
|
214
|
+
}
|
|
215
|
+
const workerMatch = head.match(/^#\s*Worker:\s*([^\n—-]+)/im);
|
|
216
|
+
if (workerMatch) {
|
|
217
|
+
const label = workerMatch[1].trim().toLowerCase();
|
|
218
|
+
if (label.includes('fix-bug') || label.includes('fix bug')) return 'fix-bug';
|
|
219
|
+
if (label.includes('review-pr') || label.includes('review pr')) return 'review-pr';
|
|
220
|
+
if (label.includes('pr-complete') || label.includes('pr complete')) return 'pr-complete';
|
|
221
|
+
if (label.includes('merge-main') || label.includes('merge main')) return 'merge-main';
|
|
222
|
+
if (label.includes('interactive dev')) return 'dev';
|
|
223
|
+
if (/\bdev\b/.test(label) && !label.includes('review')) return 'dev';
|
|
224
|
+
}
|
|
225
|
+
return null;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function assertWorkerReport(taskDir, taskPath) {
|
|
229
|
+
const flowType = inferFlowType(taskPath);
|
|
230
|
+
const candidates = flowType
|
|
231
|
+
? (FLOW_REPORT_ARTIFACTS[flowType] ?? FALLBACK_REPORT_ARTIFACTS)
|
|
232
|
+
: FALLBACK_REPORT_ARTIFACTS;
|
|
233
|
+
const found = candidates.find((name) => readArtifactText(taskDir, name));
|
|
234
|
+
if (found) return `artifacts/${found}`;
|
|
235
|
+
console.error(
|
|
236
|
+
`missing required worker report — expected non-empty artifacts/{${candidates.join(', ')}}`,
|
|
237
|
+
);
|
|
238
|
+
process.exit(1);
|
|
239
|
+
return null;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function assertChecklistComplete(taskPath, { allowOneUnchecked = false } = {}) {
|
|
243
|
+
const parsed = parseChecklist(fs.readFileSync(taskPath, 'utf8'));
|
|
244
|
+
const unchecked = parsed.items.filter((entry) => !entry.checked);
|
|
245
|
+
if (unchecked.length === 0) return;
|
|
246
|
+
if (allowOneUnchecked && unchecked.length === 1) return;
|
|
247
|
+
const summary = unchecked
|
|
248
|
+
.slice(0, 5)
|
|
249
|
+
.map((entry) => `${entry.stepNumber}:${entry.label}`)
|
|
250
|
+
.join('; ');
|
|
251
|
+
console.error(
|
|
252
|
+
`checklist incomplete — ${unchecked.length} step(s) still [ ] (${summary}${unchecked.length > 5 ? '; …' : ''})`,
|
|
253
|
+
);
|
|
254
|
+
process.exit(1);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function assertArtifactContract(taskDir, contract, terminalCommand) {
|
|
258
|
+
if (!fs.existsSync(ARTIFACT_CONTRACT_SCRIPT)) {
|
|
259
|
+
console.error(`missing artifact contract script: ${ARTIFACT_CONTRACT_SCRIPT}`);
|
|
260
|
+
process.exit(1);
|
|
261
|
+
}
|
|
262
|
+
const contractPath = path.join(taskDir, 'inputs', 'worker-terminal-contract.json');
|
|
263
|
+
const args = [ARTIFACT_CONTRACT_SCRIPT, taskDir];
|
|
264
|
+
if (fs.existsSync(contractPath)) {
|
|
265
|
+
args.push('--contract', contractPath);
|
|
266
|
+
if (terminalCommand) args.push('--terminal', terminalCommand);
|
|
267
|
+
if (opts['skip-learnings']) args.push('--skip-learnings');
|
|
268
|
+
} else {
|
|
269
|
+
if (!opts['skip-learnings']) args.push('--require-learnings');
|
|
270
|
+
if (fs.existsSync(path.join(taskDir, 'artifacts', 'recipe.json'))) {
|
|
271
|
+
args.push('--require-recipe-coverage-if-recipe', '--require-recipe-quality-if-recipe');
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
const result = spawnSync(process.execPath, args, { encoding: 'utf8' });
|
|
275
|
+
if (result.status !== 0) {
|
|
276
|
+
if (result.stderr) process.stderr.write(result.stderr);
|
|
277
|
+
if (result.stdout) process.stdout.write(result.stdout);
|
|
278
|
+
process.exit(result.status ?? 1);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function assertTerminalPackagedEvidence(taskPath, taskDir, terminalCommand) {
|
|
283
|
+
if (terminalCommand === 'blocked') return;
|
|
284
|
+
|
|
285
|
+
const contract = loadTerminalContract(taskDir, taskPath);
|
|
286
|
+
if (contract) {
|
|
287
|
+
assertRequiredArtifacts(taskDir, terminalCommand, contract);
|
|
288
|
+
assertArtifactContract(taskDir, contract, terminalCommand);
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
if (terminalCommand === 'no-change') {
|
|
293
|
+
assertNoChangeReport(taskDir);
|
|
294
|
+
} else if (terminalCommand === 'complete') {
|
|
295
|
+
assertWorkerReport(taskDir, taskPath);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
if (!opts['skip-learnings']) {
|
|
299
|
+
assertLearningsArtifact(taskDir);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
if (terminalCommand === 'complete' || terminalCommand === 'no-change') {
|
|
303
|
+
assertArtifactContract(taskDir);
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function buildSignalUpdate(signal, terminal, target, timing, events, now, taskPath, _taskDir) {
|
|
308
|
+
const base = {
|
|
309
|
+
...pickSignalPassthrough(signal),
|
|
310
|
+
step: target?.label ?? signal.step ?? 'complete',
|
|
311
|
+
checklistTiming: {
|
|
312
|
+
schemaVersion: 1,
|
|
313
|
+
source: timing.source || path.basename(taskPath),
|
|
314
|
+
events,
|
|
315
|
+
},
|
|
316
|
+
timestamp: now,
|
|
317
|
+
};
|
|
318
|
+
|
|
319
|
+
if (!terminal) {
|
|
320
|
+
return { ...base, status: 'running' };
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
const preset = resolveTerminalPreset(terminal.command);
|
|
324
|
+
const next = {
|
|
325
|
+
...base,
|
|
326
|
+
status: preset.status,
|
|
327
|
+
outcome: preset.outcome,
|
|
328
|
+
disposition: preset.disposition,
|
|
329
|
+
...(opts.reason ? { reason: opts.reason } : {}),
|
|
330
|
+
...(terminal.command === 'complete' && opts['no-self-review']
|
|
331
|
+
? { needsSelfReview: false }
|
|
332
|
+
: {}),
|
|
333
|
+
};
|
|
334
|
+
|
|
335
|
+
const contract = terminal ? loadTerminalContract(_taskDir, taskPath) : null;
|
|
336
|
+
if (terminal.command === 'no-change') {
|
|
337
|
+
next.evidence = {
|
|
338
|
+
reportPath: contract?.commands?.['no-change']?.report ?? NO_CHANGE_REPORT,
|
|
339
|
+
};
|
|
340
|
+
} else if (terminal.command === 'complete') {
|
|
341
|
+
const reportPath = contract?.commands?.complete?.report;
|
|
342
|
+
if (reportPath) next.evidence = { reportPath };
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
return next;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
function atomicWrite(file, content, mode) {
|
|
349
|
+
const dir = path.dirname(file);
|
|
350
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
351
|
+
const tmp = path.join(dir, `.${path.basename(file)}.${process.pid}.tmp`);
|
|
352
|
+
fs.writeFileSync(tmp, content, mode ? { mode } : undefined);
|
|
353
|
+
fs.renameSync(tmp, file);
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
function readJson(file) {
|
|
357
|
+
try {
|
|
358
|
+
return JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
359
|
+
} catch (err) {
|
|
360
|
+
if (err && err.code === 'ENOENT') return {};
|
|
361
|
+
throw err;
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
function stripLabel(raw) {
|
|
366
|
+
return raw
|
|
367
|
+
.replace(/^\*\*(.*?)\*\*\s*[—-]?\s*/, '$1 ')
|
|
368
|
+
.replace(/`([^`]+)`/g, '$1')
|
|
369
|
+
.replace(/\s+/g, ' ')
|
|
370
|
+
.trim();
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
function parseChecklist(markdown) {
|
|
374
|
+
const lines = markdown.split(/\n/);
|
|
375
|
+
const items = [];
|
|
376
|
+
let inFence = false;
|
|
377
|
+
let seen = 0;
|
|
378
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
379
|
+
if (/^\s*```/.test(lines[i])) {
|
|
380
|
+
inFence = !inFence;
|
|
381
|
+
continue;
|
|
382
|
+
}
|
|
383
|
+
if (inFence) continue;
|
|
384
|
+
const match = lines[i].match(/^(\s*[-*]\s+\[)( |x|X)(\]\s+)(.*)$/);
|
|
385
|
+
if (!match) continue;
|
|
386
|
+
seen += 1;
|
|
387
|
+
items.push({
|
|
388
|
+
lineIndex: i,
|
|
389
|
+
stepNumber: seen,
|
|
390
|
+
checked: match[2].toLowerCase() === 'x',
|
|
391
|
+
prefix: match[1],
|
|
392
|
+
suffix: match[3],
|
|
393
|
+
rawLabel: match[4],
|
|
394
|
+
label: stripLabel(match[4]),
|
|
395
|
+
});
|
|
396
|
+
}
|
|
397
|
+
return { lines, items };
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
function markStepInLines(lines, item) {
|
|
401
|
+
if (item.checked) return false;
|
|
402
|
+
lines[item.lineIndex] = `${item.prefix}x${item.suffix}${item.rawLabel}`;
|
|
403
|
+
return true;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
function resolveTarget(taskPath, stepNumber, markLast) {
|
|
407
|
+
const original = fs.readFileSync(taskPath, 'utf8');
|
|
408
|
+
const parsed = parseChecklist(original);
|
|
409
|
+
let item = null;
|
|
410
|
+
if (stepNumber != null) {
|
|
411
|
+
item = parsed.items.find((entry) => entry.stepNumber === stepNumber) ?? null;
|
|
412
|
+
if (!item) {
|
|
413
|
+
console.error(`checklist step ${stepNumber} not found in ${taskPath}`);
|
|
414
|
+
process.exit(1);
|
|
415
|
+
}
|
|
416
|
+
} else if (markLast) {
|
|
417
|
+
const unchecked = parsed.items.filter((entry) => !entry.checked);
|
|
418
|
+
item = unchecked.length ? unchecked[unchecked.length - 1] : null;
|
|
419
|
+
}
|
|
420
|
+
let updated = original;
|
|
421
|
+
if (item) {
|
|
422
|
+
const nextLines = [...parsed.lines];
|
|
423
|
+
markStepInLines(nextLines, item);
|
|
424
|
+
updated = nextLines.join('\n');
|
|
425
|
+
if (updated !== original) atomicWrite(taskPath, updated);
|
|
426
|
+
}
|
|
427
|
+
return item
|
|
428
|
+
? { stepNumber: item.stepNumber, label: item.label }
|
|
429
|
+
: { stepNumber: null, label: 'complete' };
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
const taskDir = path.dirname(signalPath);
|
|
433
|
+
if (
|
|
434
|
+
terminalCommand &&
|
|
435
|
+
(terminalCommand === 'complete' || terminalCommand === 'no-change') &&
|
|
436
|
+
opts['mark-last'] &&
|
|
437
|
+
!opts['skip-checklist']
|
|
438
|
+
) {
|
|
439
|
+
assertChecklistComplete(taskPath, { allowOneUnchecked: true });
|
|
440
|
+
}
|
|
441
|
+
const target = isStartCommand
|
|
442
|
+
? { stepNumber: null, label: 'started' }
|
|
443
|
+
: resolveTarget(taskPath, stepNumber, Boolean(terminalCommand && opts['mark-last']));
|
|
444
|
+
|
|
445
|
+
const now = new Date().toISOString();
|
|
446
|
+
const signal = readJson(signalPath);
|
|
447
|
+
const timing =
|
|
448
|
+
signal.checklistTiming && typeof signal.checklistTiming === 'object'
|
|
449
|
+
? signal.checklistTiming
|
|
450
|
+
: { schemaVersion: 1, source: path.basename(taskPath), events: [] };
|
|
451
|
+
const events = Array.isArray(timing.events) ? [...timing.events] : [];
|
|
452
|
+
|
|
453
|
+
if (
|
|
454
|
+
!isStartCommand &&
|
|
455
|
+
target.stepNumber != null &&
|
|
456
|
+
!events.some(
|
|
457
|
+
(event) =>
|
|
458
|
+
event && (event.stepNumber === target.stepNumber || event.index === target.stepNumber - 1),
|
|
459
|
+
)
|
|
460
|
+
) {
|
|
461
|
+
events.push({ stepNumber: target.stepNumber, label: target.label, checkedAt: now });
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
if (terminalCommand) {
|
|
465
|
+
assertTerminalPackagedEvidence(taskPath, taskDir, terminalCommand);
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
const next = buildSignalUpdate(
|
|
469
|
+
signal,
|
|
470
|
+
terminalCommand ? { command: terminalCommand } : null,
|
|
471
|
+
target,
|
|
472
|
+
timing,
|
|
473
|
+
events,
|
|
474
|
+
now,
|
|
475
|
+
taskPath,
|
|
476
|
+
taskDir,
|
|
477
|
+
);
|
|
478
|
+
atomicWrite(signalPath, `${JSON.stringify(next, null, 2)}\n`, 0o644);
|
|
479
|
+
|
|
480
|
+
if (isStartCommand) {
|
|
481
|
+
console.log('signal started');
|
|
482
|
+
} else if (terminalCommand) {
|
|
483
|
+
console.log(
|
|
484
|
+
`signal ${terminalCommand}: status=${next.status} disposition=${next.disposition ?? 'n/a'}`,
|
|
485
|
+
);
|
|
486
|
+
} else {
|
|
487
|
+
console.log(`marked ${target.stepNumber}: ${target.label}`);
|
|
488
|
+
}
|