@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.
@@ -0,0 +1,382 @@
1
+ /** @typedef {'complete' | 'no-change' | 'blocked'} WorkerTerminalCommand */
2
+
3
+ /**
4
+ * @typedef {object} WorkerTerminalCommandSpec
5
+ * @property {string} [report]
6
+ * @property {string[]} artifacts
7
+ */
8
+
9
+ /**
10
+ * @typedef {object} WorkerTerminalWhenPresentRule
11
+ * @property {string} path
12
+ * @property {string[]} alsoRequire
13
+ * @property {boolean} [requireRecipeQuality]
14
+ * @property {boolean} [requireRecipeCoverage]
15
+ */
16
+
17
+ /**
18
+ * @typedef {object} WorkerTerminalProjectConfig
19
+ * @property {boolean} [requireSignal]
20
+ * @property {Partial<Record<WorkerTerminalCommand, WorkerTerminalCommandSpec>>} [complete]
21
+ * @property {Partial<Record<WorkerTerminalCommand, WorkerTerminalCommandSpec>>} [no-change]
22
+ * @property {Partial<Record<WorkerTerminalCommand, WorkerTerminalCommandSpec>>} [blocked]
23
+ * @property {Record<string, Partial<Record<WorkerTerminalCommand, WorkerTerminalCommandSpec>>>} [flows]
24
+ * @property {WorkerTerminalWhenPresentRule[]} [whenPresent]
25
+ */
26
+
27
+ /**
28
+ * @typedef {object} WorkerTerminalContractDocument
29
+ * @property {1} schemaVersion
30
+ * @property {string} flowType
31
+ * @property {string} [mode]
32
+ * @property {boolean} requireSignal
33
+ * @property {Record<WorkerTerminalCommand, WorkerTerminalCommandSpec>} commands
34
+ * @property {WorkerTerminalWhenPresentRule[]} whenPresent
35
+ * @property {string} resolvedAt
36
+ * @property {'builtin' | 'project'} source
37
+ */
38
+
39
+ const TERMINAL_MARK_RE = /(?:\.\/|\{\{TASK_DIR\}\}\/)mark\s+(complete|no-change|blocked)\b/;
40
+ const TERMINAL_COMMANDS = /** @type {const} */ (['complete', 'no-change', 'blocked']);
41
+
42
+ const LEARNINGS = 'artifacts/learnings.md';
43
+
44
+ const PR_DESCRIPTION = 'artifacts/pr-description.md';
45
+
46
+ /** @type {Record<string, Record<WorkerTerminalCommand, WorkerTerminalCommandSpec>>} */
47
+ const BUILTIN_FLOW_COMMANDS = {
48
+ dev: {
49
+ complete: { report: PR_DESCRIPTION, artifacts: [LEARNINGS, PR_DESCRIPTION] },
50
+ 'no-change': {
51
+ report: 'artifacts/no-change-report.md',
52
+ artifacts: [LEARNINGS, 'artifacts/no-change-report.md'],
53
+ },
54
+ blocked: { artifacts: [] },
55
+ },
56
+ 'fix-bug': {
57
+ complete: { report: PR_DESCRIPTION, artifacts: [LEARNINGS, PR_DESCRIPTION] },
58
+ 'no-change': {
59
+ report: 'artifacts/no-change-report.md',
60
+ artifacts: [LEARNINGS, 'artifacts/no-change-report.md'],
61
+ },
62
+ blocked: { artifacts: [] },
63
+ },
64
+ 'review-pr': {
65
+ complete: {
66
+ report: 'artifacts/review.md',
67
+ artifacts: [LEARNINGS, 'artifacts/review.md', 'artifacts/line-comments.json'],
68
+ },
69
+ 'no-change': {
70
+ report: 'artifacts/no-change-report.md',
71
+ artifacts: [LEARNINGS, 'artifacts/no-change-report.md'],
72
+ },
73
+ blocked: { artifacts: [] },
74
+ },
75
+ 'pr-complete': {
76
+ complete: {
77
+ report: 'artifacts/comments-report.md',
78
+ artifacts: [LEARNINGS, 'artifacts/comments-report.md'],
79
+ },
80
+ 'no-change': {
81
+ report: 'artifacts/no-change-report.md',
82
+ artifacts: [LEARNINGS, 'artifacts/no-change-report.md'],
83
+ },
84
+ blocked: { artifacts: [] },
85
+ },
86
+ 'merge-main': {
87
+ complete: {
88
+ report: 'artifacts/report.md',
89
+ artifacts: [LEARNINGS, 'artifacts/report.md'],
90
+ },
91
+ 'no-change': {
92
+ report: 'artifacts/no-change-report.md',
93
+ artifacts: [LEARNINGS, 'artifacts/no-change-report.md'],
94
+ },
95
+ blocked: { artifacts: [] },
96
+ },
97
+ 'ci-fix': {
98
+ complete: { report: 'artifacts/report.md', artifacts: [LEARNINGS, 'artifacts/report.md'] },
99
+ 'no-change': {
100
+ report: 'artifacts/no-change-report.md',
101
+ artifacts: [LEARNINGS, 'artifacts/no-change-report.md'],
102
+ },
103
+ blocked: { artifacts: [] },
104
+ },
105
+ 'self-review': {
106
+ complete: { artifacts: [LEARNINGS] },
107
+ 'no-change': {
108
+ report: 'artifacts/no-change-report.md',
109
+ artifacts: [LEARNINGS, 'artifacts/no-change-report.md'],
110
+ },
111
+ blocked: { artifacts: [] },
112
+ },
113
+ 'self-review-fix': {
114
+ complete: { report: 'artifacts/report.md', artifacts: [LEARNINGS, 'artifacts/report.md'] },
115
+ 'no-change': {
116
+ report: 'artifacts/no-change-report.md',
117
+ artifacts: [LEARNINGS, 'artifacts/no-change-report.md'],
118
+ },
119
+ blocked: { artifacts: [] },
120
+ },
121
+ 'validate-dep': {
122
+ complete: { report: 'artifacts/report.md', artifacts: [LEARNINGS, 'artifacts/report.md'] },
123
+ 'no-change': {
124
+ report: 'artifacts/no-change-report.md',
125
+ artifacts: [LEARNINGS, 'artifacts/no-change-report.md'],
126
+ },
127
+ blocked: { artifacts: [] },
128
+ },
129
+ };
130
+
131
+ const DEFAULT_WHEN_PRESENT = [
132
+ {
133
+ path: 'artifacts/recipe.json',
134
+ alsoRequire: ['artifacts/recipe-coverage.md', 'artifacts/evidence-manifest.json'],
135
+ requireRecipeQuality: true,
136
+ requireRecipeCoverage: true,
137
+ },
138
+ ];
139
+
140
+ const DEFAULT_BLOCKED = { artifacts: [] };
141
+ const DEFAULT_NO_CHANGE = {
142
+ report: 'artifacts/no-change-report.md',
143
+ artifacts: [LEARNINGS, 'artifacts/no-change-report.md'],
144
+ };
145
+ const DEFAULT_COMPLETE = {
146
+ report: 'artifacts/report.md',
147
+ artifacts: [LEARNINGS, 'artifacts/report.md'],
148
+ };
149
+
150
+ /** @param {WorkerTerminalCommandSpec | undefined} spec */
151
+ function normalizeCommandSpec(spec) {
152
+ if (!spec) return { artifacts: [] };
153
+ const artifacts = [...new Set((spec.artifacts ?? []).filter(Boolean))];
154
+ if (spec.report && !artifacts.includes(spec.report)) artifacts.unshift(spec.report);
155
+ return {
156
+ ...(spec.report ? { report: spec.report } : {}),
157
+ artifacts,
158
+ };
159
+ }
160
+
161
+ /** @param {Partial<Record<WorkerTerminalCommand, WorkerTerminalCommandSpec>> | undefined} layer */
162
+ function normalizeCommandLayer(layer) {
163
+ /** @type {Record<WorkerTerminalCommand, WorkerTerminalCommandSpec>} */
164
+ const out = {
165
+ complete: normalizeCommandSpec(layer?.complete ?? DEFAULT_COMPLETE),
166
+ 'no-change': normalizeCommandSpec(layer?.['no-change'] ?? DEFAULT_NO_CHANGE),
167
+ blocked: normalizeCommandSpec(layer?.blocked ?? DEFAULT_BLOCKED),
168
+ };
169
+ return out;
170
+ }
171
+
172
+ /**
173
+ * @param {WorkerTerminalProjectConfig | null | undefined} projectConfig
174
+ * @param {string} flowType
175
+ * @param {{ mode?: string | null, now?: string }} [options]
176
+ * @returns {WorkerTerminalContractDocument}
177
+ */
178
+ function resolveWorkerTerminalContract(projectConfig, flowType, options = {}) {
179
+ const mode = options.mode ?? undefined;
180
+ const now = options.now ?? new Date().toISOString();
181
+ const flowKey = String(flowType || '').trim() || 'unknown';
182
+
183
+ const defaultsLayer = normalizeCommandLayer({
184
+ complete: projectConfig?.complete,
185
+ 'no-change': projectConfig?.['no-change'],
186
+ blocked: projectConfig?.blocked,
187
+ });
188
+
189
+ const builtin = BUILTIN_FLOW_COMMANDS[flowKey];
190
+ const flowLayer = projectConfig?.flows?.[flowKey];
191
+ /** @type {Record<WorkerTerminalCommand, WorkerTerminalCommandSpec>} */
192
+ const commands = {
193
+ complete: normalizeCommandSpec(
194
+ flowLayer?.complete ?? builtin?.complete ?? defaultsLayer.complete,
195
+ ),
196
+ 'no-change': normalizeCommandSpec(
197
+ flowLayer?.['no-change'] ?? builtin?.['no-change'] ?? defaultsLayer['no-change'],
198
+ ),
199
+ blocked: normalizeCommandSpec(flowLayer?.blocked ?? builtin?.blocked ?? defaultsLayer.blocked),
200
+ };
201
+
202
+ let requireSignal = projectConfig?.requireSignal ?? true;
203
+ if (flowKey === 'pr-complete' && mode === 'interactive') {
204
+ requireSignal = false;
205
+ }
206
+
207
+ const whenPresent = Array.isArray(projectConfig?.whenPresent)
208
+ ? projectConfig.whenPresent.map((rule) => ({
209
+ path: rule.path,
210
+ alsoRequire: [...(rule.alsoRequire ?? [])],
211
+ ...(rule.requireRecipeQuality ? { requireRecipeQuality: true } : {}),
212
+ ...(rule.requireRecipeCoverage ? { requireRecipeCoverage: true } : {}),
213
+ }))
214
+ : DEFAULT_WHEN_PRESENT.map((rule) => ({ ...rule, alsoRequire: [...rule.alsoRequire] }));
215
+
216
+ return {
217
+ schemaVersion: 1,
218
+ flowType: flowKey,
219
+ ...(mode ? { mode } : {}),
220
+ requireSignal,
221
+ commands,
222
+ whenPresent,
223
+ resolvedAt: now,
224
+ source: projectConfig ? 'project' : 'builtin',
225
+ };
226
+ }
227
+
228
+ /** @param {string} content */
229
+ function templateUsesTerminalMark(content) {
230
+ return (
231
+ TERMINAL_MARK_RE.test(content) ||
232
+ /mark-checklist-step\.cjs[\s\S]{0,400}?\bcomplete\b/.test(content)
233
+ );
234
+ }
235
+
236
+ /** @param {string} content */
237
+ function templateTerminalCommands(content) {
238
+ let scope = content;
239
+ const checklistIdx = content.indexOf('## Checklist');
240
+ if (checklistIdx >= 0) {
241
+ scope = content.slice(checklistIdx);
242
+ } else {
243
+ const completionIdx = content.indexOf('## Completion signal');
244
+ if (completionIdx >= 0) scope = content.slice(completionIdx);
245
+ else {
246
+ const taskIdx = content.indexOf('## Task');
247
+ if (taskIdx >= 0) scope = content.slice(taskIdx);
248
+ }
249
+ }
250
+ const commands = new Set();
251
+ for (const match of scope.matchAll(
252
+ /(?:\.\/|\{\{TASK_DIR\}\}\/)mark\s+(complete|no-change|blocked)\b/g,
253
+ )) {
254
+ commands.add(match[1]);
255
+ }
256
+ if (commands.size === 0 && /mark-checklist-step\.cjs[\s\S]{0,400}?\bcomplete\b/.test(scope)) {
257
+ commands.add('complete');
258
+ }
259
+ return [...commands];
260
+ }
261
+
262
+ /**
263
+ * @param {string} templateContent
264
+ * @param {WorkerTerminalContractDocument} contract
265
+ */
266
+ /** @param {string} content */
267
+ function templateBodyScope(content) {
268
+ const checklistIdx = content.indexOf('## Checklist');
269
+ if (checklistIdx >= 0) return content.slice(checklistIdx);
270
+ const completionIdx = content.indexOf('## Completion signal');
271
+ if (completionIdx >= 0) return content.slice(completionIdx);
272
+ const taskIdx = content.indexOf('## Task');
273
+ if (taskIdx >= 0) return content.slice(taskIdx);
274
+ return content;
275
+ }
276
+
277
+ /**
278
+ * Deterministic structure checks beyond terminal artifact contract.
279
+ * @param {string} templateContent
280
+ */
281
+ function lintWorkerTemplateStructure(templateContent) {
282
+ /** @type {string[]} */
283
+ const issues = [];
284
+ if (/(?<!\{)\{TASK_DIR\}(?!\})/.test(templateContent)) {
285
+ issues.push('uses `{TASK_DIR}` — must be `{{TASK_DIR}}` (double braces)');
286
+ }
287
+
288
+ if (
289
+ templateUsesTerminalMark(templateContent) &&
290
+ !templateContent.includes('## Checklist') &&
291
+ !templateContent.includes('## Completion signal')
292
+ ) {
293
+ issues.push('terminal template missing `## Checklist` or `## Completion signal` section');
294
+ }
295
+
296
+ if (!templateContent.includes('## Task') && !templateContent.includes('TASK_DIR:')) {
297
+ issues.push('missing `## Task` block or `TASK_DIR:` metadata');
298
+ }
299
+
300
+ const scope = templateBodyScope(templateContent);
301
+ const steps = [...scope.matchAll(/\*\*(\d+)\./g)].map((match) => match[1]);
302
+ const duplicateSteps = [...new Set(steps.filter((step, index) => steps.indexOf(step) !== index))];
303
+ if (duplicateSteps.length > 0) {
304
+ issues.push(`duplicate checklist step number(s): ${duplicateSteps.join(', ')}`);
305
+ }
306
+
307
+ return issues;
308
+ }
309
+
310
+ function lintWorkerTemplateAgainstContract(templateContent, contract) {
311
+ /** @type {string[]} */
312
+ const issues = [];
313
+ if (!templateUsesTerminalMark(templateContent)) {
314
+ issues.push(
315
+ 'terminal worker template must instruct `./mark complete`, `./mark no-change`, or `./mark blocked`',
316
+ );
317
+ return issues;
318
+ }
319
+
320
+ if (contract.requireSignal) {
321
+ if (!templateUsesTerminalMark(templateContent)) {
322
+ issues.push(
323
+ 'requireSignal is true but template has no terminal `./mark` or mark-checklist-step command',
324
+ );
325
+ }
326
+ if (/never hand-write `SIGNAL\.json`|Do not write.*SIGNAL\.json/i.test(templateContent)) {
327
+ // allowed when paired with mark instructions
328
+ }
329
+ }
330
+
331
+ const commands = templateTerminalCommands(templateContent);
332
+ const commandSet = new Set(commands.length > 0 ? commands : ['complete']);
333
+ for (const command of commandSet) {
334
+ const spec = contract.commands[/** @type {WorkerTerminalCommand} */ (command)];
335
+ if (!spec) continue;
336
+ for (const artifactPath of spec.artifacts) {
337
+ if (!templateContent.includes(artifactPath)) {
338
+ issues.push(
339
+ `missing checklist/reference to required artifact \`${artifactPath}\` for \`${command}\``,
340
+ );
341
+ }
342
+ }
343
+ }
344
+
345
+ return issues;
346
+ }
347
+
348
+ /**
349
+ * @param {WorkerTerminalContractDocument} contract
350
+ * @param {WorkerTerminalCommand} command
351
+ * @param {(relPath: string) => boolean} fileExists
352
+ */
353
+ function expandedArtifactsForCommand(contract, command, fileExists) {
354
+ const spec = contract.commands[command] ?? { artifacts: [] };
355
+ const required = new Set(spec.artifacts);
356
+ for (const rule of contract.whenPresent ?? []) {
357
+ if (!rule.path || !fileExists(rule.path)) continue;
358
+ for (const path of rule.alsoRequire ?? []) required.add(path);
359
+ }
360
+ return {
361
+ report: spec.report,
362
+ artifacts: [...required],
363
+ requireRecipeQuality: (contract.whenPresent ?? []).some(
364
+ (rule) => rule.path && fileExists(rule.path) && rule.requireRecipeQuality,
365
+ ),
366
+ requireRecipeCoverage: (contract.whenPresent ?? []).some(
367
+ (rule) => rule.path && fileExists(rule.path) && rule.requireRecipeCoverage,
368
+ ),
369
+ };
370
+ }
371
+
372
+ module.exports = {
373
+ TERMINAL_COMMANDS,
374
+ BUILTIN_FLOW_COMMANDS,
375
+ resolveWorkerTerminalContract,
376
+ lintWorkerTemplateAgainstContract,
377
+ lintWorkerTemplateStructure,
378
+ templateUsesTerminalMark,
379
+ templateTerminalCommands,
380
+ templateBodyScope,
381
+ expandedArtifactsForCommand,
382
+ };