@farmslot/skills 0.1.1 → 0.1.2
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
CHANGED
|
@@ -4,6 +4,15 @@ All notable changes to `@farmslot/skills` are tracked here.
|
|
|
4
4
|
|
|
5
5
|
## Unreleased
|
|
6
6
|
|
|
7
|
+
- Active-development baseline; add user-facing changes here before release or package publication.
|
|
8
|
+
|
|
9
|
+
## 0.1.2 - 2026-07-03
|
|
10
|
+
|
|
11
|
+
- Colocate finish-contract scripts in `packages/skills/scripts/` (`mark-checklist-step.cjs`, `worker-terminal-contract.cjs`, `check-task-artifact-contract.mjs`) as the canonical source for `@farmslot/skills` and consensys-skills delegation
|
|
12
|
+
- **dev / fix-bug:** terminal `./mark complete` requires `artifacts/learnings.md` + `artifacts/pr-description.md` (not `report.md`)
|
|
13
|
+
- `./mark` infers dev/fix-bug flow from embedded checklist `Skill:` header (standalone recipe skills)
|
|
14
|
+
- Export `./scripts/worker-terminal-contract.cjs` and `./scripts/check-task-artifact-contract.mjs` from the package
|
|
15
|
+
|
|
7
16
|
## 0.1.1 - 2026-06-30
|
|
8
17
|
|
|
9
18
|
- Single-path mark CLI for worker terminal signals.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@farmslot/skills",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|
|
@@ -16,7 +16,9 @@
|
|
|
16
16
|
},
|
|
17
17
|
"./skills/*": "./skills/*/SKILL.md",
|
|
18
18
|
"./schemas/*": "./schemas/*",
|
|
19
|
-
"./scripts/mark-checklist-step.cjs": "./scripts/mark-checklist-step.cjs"
|
|
19
|
+
"./scripts/mark-checklist-step.cjs": "./scripts/mark-checklist-step.cjs",
|
|
20
|
+
"./scripts/worker-terminal-contract.cjs": "./scripts/worker-terminal-contract.cjs",
|
|
21
|
+
"./scripts/check-task-artifact-contract.mjs": "./scripts/check-task-artifact-contract.mjs"
|
|
20
22
|
},
|
|
21
23
|
"scripts": {
|
|
22
24
|
"typecheck": "tsc --noEmit --project tsconfig.json",
|
|
@@ -64,6 +66,7 @@
|
|
|
64
66
|
"dist/**/*.js.map",
|
|
65
67
|
"schemas/**/*.json",
|
|
66
68
|
"scripts/**/*.cjs",
|
|
69
|
+
"scripts/**/*.mjs",
|
|
67
70
|
"skills/**/*.md",
|
|
68
71
|
"templates/**/*.json"
|
|
69
72
|
],
|
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { existsSync, readFileSync, statSync } from 'node:fs';
|
|
3
|
+
import { createRequire } from 'node:module';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
|
|
6
|
+
const require = createRequire(import.meta.url);
|
|
7
|
+
const { expandedArtifactsForCommand } = require('./worker-terminal-contract.cjs');
|
|
8
|
+
|
|
9
|
+
const taskDir = process.argv[2];
|
|
10
|
+
const rawArgs = process.argv.slice(3);
|
|
11
|
+
const flags = new Set();
|
|
12
|
+
let contractPath;
|
|
13
|
+
let terminalCommand;
|
|
14
|
+
for (let i = 0; i < rawArgs.length; i += 1) {
|
|
15
|
+
const arg = rawArgs[i];
|
|
16
|
+
if (arg === '--contract') {
|
|
17
|
+
contractPath = rawArgs[i + 1];
|
|
18
|
+
i += 1;
|
|
19
|
+
continue;
|
|
20
|
+
}
|
|
21
|
+
if (arg === '--terminal') {
|
|
22
|
+
terminalCommand = rawArgs[i + 1];
|
|
23
|
+
i += 1;
|
|
24
|
+
continue;
|
|
25
|
+
}
|
|
26
|
+
flags.add(arg);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
if (!taskDir) {
|
|
30
|
+
console.error(
|
|
31
|
+
'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]',
|
|
32
|
+
);
|
|
33
|
+
process.exit(2);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const issues = [];
|
|
37
|
+
const mediaExt = /\.(png|jpe?g|gif|mp4|mov|webm)$/i;
|
|
38
|
+
const internalArtifactPath =
|
|
39
|
+
/^artifacts\/(?:harness-launch|harness-relaunch|harness-relaunch-node20|runtime-launch|runtime-relaunch|runner-blockers)\//;
|
|
40
|
+
const allowedManifestKeys = new Set([
|
|
41
|
+
'version',
|
|
42
|
+
'preferred_mode',
|
|
43
|
+
'summary',
|
|
44
|
+
'before_after_pairs',
|
|
45
|
+
'standalone',
|
|
46
|
+
'omit',
|
|
47
|
+
'videos',
|
|
48
|
+
'before_state_capture',
|
|
49
|
+
]);
|
|
50
|
+
const allowedPairKeys = new Set(['label', 'covers', 'before', 'after', 'note']);
|
|
51
|
+
const allowedStandaloneKeys = new Set(['label', 'covers', 'file', 'note']);
|
|
52
|
+
const allowedVideoKeys = new Set(['before', 'after', 'preferred', 'note']);
|
|
53
|
+
const allowedOmitKeys = new Set(['file', 'reason']);
|
|
54
|
+
|
|
55
|
+
function fileExists(rel) {
|
|
56
|
+
try {
|
|
57
|
+
return statSync(path.join(taskDir, rel)).isFile();
|
|
58
|
+
} catch {
|
|
59
|
+
return false;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function readText(rel) {
|
|
64
|
+
const p = path.join(taskDir, rel);
|
|
65
|
+
return existsSync(p) ? readFileSync(p, 'utf8') : null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function isRecord(value) {
|
|
69
|
+
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function unknownKeys(record, allowed, prefix) {
|
|
73
|
+
for (const key of Object.keys(record)) {
|
|
74
|
+
if (!allowed.has(key)) issues.push(`${prefix}.${key}: unknown key`);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function optionalString(record, key, prefix) {
|
|
79
|
+
if (record[key] !== undefined && typeof record[key] !== 'string') {
|
|
80
|
+
issues.push(`${prefix}.${key}: expected string`);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function optionalStringArray(record, key, prefix) {
|
|
85
|
+
if (record[key] === undefined) return;
|
|
86
|
+
if (!Array.isArray(record[key]) || record[key].some((entry) => typeof entry !== 'string')) {
|
|
87
|
+
issues.push(`${prefix}.${key}: expected string[]`);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function normalizeArtifactPath(value) {
|
|
92
|
+
if (typeof value !== 'string') return null;
|
|
93
|
+
const trimmed = value.trim();
|
|
94
|
+
if (!trimmed || /^(?:[a-z][a-z0-9+.-]*:|\/\/|#)/i.test(trimmed)) return null;
|
|
95
|
+
const pathPart = trimmed.split(/[?#]/, 1)[0];
|
|
96
|
+
const normalized = pathPart.replace(/\\/g, '/').replace(/^\.\/+/g, '');
|
|
97
|
+
if (!normalized || path.posix.isAbsolute(normalized) || normalized.includes('\0')) return null;
|
|
98
|
+
const segments = normalized.split('/').filter(Boolean);
|
|
99
|
+
if (segments.some((segment) => segment === '.' || segment === '..')) return null;
|
|
100
|
+
const withoutArtifacts =
|
|
101
|
+
segments[0] === 'artifacts' ? segments.slice(1).join('/') : segments.join('/');
|
|
102
|
+
if (!withoutArtifacts || !mediaExt.test(withoutArtifacts)) return null;
|
|
103
|
+
return `artifacts/${withoutArtifacts}`;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function addRef(refs, value) {
|
|
107
|
+
const normalized = normalizeArtifactPath(value);
|
|
108
|
+
if (!normalized) return;
|
|
109
|
+
if (internalArtifactPath.test(normalized)) {
|
|
110
|
+
issues.push(`evidence-manifest references internal artifact: ${normalized}`);
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
refs.add(normalized);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function validateRecipeQualityArtifact() {
|
|
117
|
+
const text = readText('artifacts/recipe-quality.json');
|
|
118
|
+
if (!text) return;
|
|
119
|
+
let artifact;
|
|
120
|
+
try {
|
|
121
|
+
artifact = JSON.parse(text);
|
|
122
|
+
} catch (error) {
|
|
123
|
+
issues.push(`recipe-quality.json: invalid JSON: ${error.message}`);
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
if (!isRecord(artifact)) {
|
|
127
|
+
issues.push('recipe-quality.json: expected object');
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
if (artifact.version !== 1) issues.push('recipe-quality.json: version must be 1');
|
|
131
|
+
if (!['pass', 'warn', 'fail'].includes(artifact.verdict)) {
|
|
132
|
+
issues.push('recipe-quality.json: verdict must be pass, warn, or fail');
|
|
133
|
+
}
|
|
134
|
+
if (!isRecord(artifact.compact) || !['PASS', 'WARN', 'FAIL'].includes(artifact.compact.verdict)) {
|
|
135
|
+
issues.push('recipe-quality.json: compact.verdict must be PASS, WARN, or FAIL');
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function parseManifest() {
|
|
140
|
+
const text = readText('artifacts/evidence-manifest.json');
|
|
141
|
+
if (!text) return null;
|
|
142
|
+
let manifest;
|
|
143
|
+
try {
|
|
144
|
+
manifest = JSON.parse(text);
|
|
145
|
+
} catch (error) {
|
|
146
|
+
issues.push(`evidence-manifest.json: invalid JSON: ${error.message}`);
|
|
147
|
+
return null;
|
|
148
|
+
}
|
|
149
|
+
if (!isRecord(manifest)) {
|
|
150
|
+
issues.push('evidence-manifest.json: expected object');
|
|
151
|
+
return null;
|
|
152
|
+
}
|
|
153
|
+
unknownKeys(manifest, allowedManifestKeys, 'manifest');
|
|
154
|
+
if (manifest.version !== undefined && typeof manifest.version !== 'number') {
|
|
155
|
+
issues.push('manifest.version: expected number');
|
|
156
|
+
}
|
|
157
|
+
if (
|
|
158
|
+
manifest.preferred_mode !== undefined &&
|
|
159
|
+
!['screenshots', 'video'].includes(manifest.preferred_mode)
|
|
160
|
+
) {
|
|
161
|
+
issues.push('manifest.preferred_mode: expected screenshots or video');
|
|
162
|
+
}
|
|
163
|
+
optionalString(manifest, 'summary', 'manifest');
|
|
164
|
+
|
|
165
|
+
if (manifest.before_after_pairs !== undefined) {
|
|
166
|
+
if (!Array.isArray(manifest.before_after_pairs)) {
|
|
167
|
+
issues.push('manifest.before_after_pairs: expected array');
|
|
168
|
+
} else {
|
|
169
|
+
manifest.before_after_pairs.forEach((entry, index) => {
|
|
170
|
+
const prefix = `manifest.before_after_pairs[${index}]`;
|
|
171
|
+
if (!isRecord(entry)) return issues.push(`${prefix}: expected object`);
|
|
172
|
+
unknownKeys(entry, allowedPairKeys, prefix);
|
|
173
|
+
if (typeof entry.label !== 'string' || !entry.label.trim())
|
|
174
|
+
issues.push(`${prefix}.label: expected non-empty string`);
|
|
175
|
+
optionalStringArray(entry, 'covers', prefix);
|
|
176
|
+
optionalString(entry, 'before', prefix);
|
|
177
|
+
optionalString(entry, 'after', prefix);
|
|
178
|
+
optionalString(entry, 'note', prefix);
|
|
179
|
+
if (entry.before === undefined && entry.after === undefined)
|
|
180
|
+
issues.push(`${prefix}: expected before or after`);
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
if (manifest.standalone !== undefined) {
|
|
186
|
+
if (!Array.isArray(manifest.standalone)) {
|
|
187
|
+
issues.push('manifest.standalone: expected array');
|
|
188
|
+
} else {
|
|
189
|
+
manifest.standalone.forEach((entry, index) => {
|
|
190
|
+
const prefix = `manifest.standalone[${index}]`;
|
|
191
|
+
if (!isRecord(entry)) return issues.push(`${prefix}: expected object`);
|
|
192
|
+
unknownKeys(entry, allowedStandaloneKeys, prefix);
|
|
193
|
+
if (typeof entry.label !== 'string' || !entry.label.trim())
|
|
194
|
+
issues.push(`${prefix}.label: expected non-empty string`);
|
|
195
|
+
optionalStringArray(entry, 'covers', prefix);
|
|
196
|
+
if (typeof entry.file !== 'string' || !entry.file.trim())
|
|
197
|
+
issues.push(`${prefix}.file: expected non-empty string`);
|
|
198
|
+
optionalString(entry, 'note', prefix);
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
if (manifest.videos !== undefined) {
|
|
204
|
+
if (!isRecord(manifest.videos)) {
|
|
205
|
+
issues.push('manifest.videos: expected object');
|
|
206
|
+
} else {
|
|
207
|
+
unknownKeys(manifest.videos, allowedVideoKeys, 'manifest.videos');
|
|
208
|
+
optionalString(manifest.videos, 'before', 'manifest.videos');
|
|
209
|
+
optionalString(manifest.videos, 'after', 'manifest.videos');
|
|
210
|
+
optionalString(manifest.videos, 'note', 'manifest.videos');
|
|
211
|
+
if (
|
|
212
|
+
manifest.videos.preferred !== undefined &&
|
|
213
|
+
typeof manifest.videos.preferred !== 'boolean'
|
|
214
|
+
) {
|
|
215
|
+
issues.push('manifest.videos.preferred: expected boolean');
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
if (manifest.omit !== undefined) {
|
|
221
|
+
if (!Array.isArray(manifest.omit)) {
|
|
222
|
+
issues.push('manifest.omit: expected array');
|
|
223
|
+
} else {
|
|
224
|
+
manifest.omit.forEach((entry, index) => {
|
|
225
|
+
const prefix = `manifest.omit[${index}]`;
|
|
226
|
+
if (typeof entry === 'string') return;
|
|
227
|
+
if (!isRecord(entry)) return issues.push(`${prefix}: expected string or object`);
|
|
228
|
+
unknownKeys(entry, allowedOmitKeys, prefix);
|
|
229
|
+
if (typeof entry.file !== 'string' || !entry.file.trim())
|
|
230
|
+
issues.push(`${prefix}.file: expected non-empty string`);
|
|
231
|
+
optionalString(entry, 'reason', prefix);
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const refs = new Set();
|
|
237
|
+
for (const pair of manifest.before_after_pairs ?? []) {
|
|
238
|
+
addRef(refs, pair?.before);
|
|
239
|
+
addRef(refs, pair?.after);
|
|
240
|
+
}
|
|
241
|
+
for (const entry of manifest.standalone ?? []) addRef(refs, entry?.file);
|
|
242
|
+
addRef(refs, manifest.videos?.before);
|
|
243
|
+
addRef(refs, manifest.videos?.after);
|
|
244
|
+
|
|
245
|
+
for (const ref of [...refs].sort()) {
|
|
246
|
+
if (!fileExists(ref)) issues.push(`evidence-manifest references missing artifact: ${ref}`);
|
|
247
|
+
}
|
|
248
|
+
return { manifest, refs };
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const hasRecipe = fileExists('artifacts/recipe.json');
|
|
252
|
+
if (
|
|
253
|
+
hasRecipe &&
|
|
254
|
+
flags.has('--require-recipe-quality-if-recipe') &&
|
|
255
|
+
!fileExists('artifacts/recipe-quality.json')
|
|
256
|
+
) {
|
|
257
|
+
issues.push('recipe.json exists but artifacts/recipe-quality.json is missing');
|
|
258
|
+
}
|
|
259
|
+
if (
|
|
260
|
+
hasRecipe &&
|
|
261
|
+
flags.has('--require-recipe-coverage-if-recipe') &&
|
|
262
|
+
!fileExists('artifacts/recipe-coverage.md')
|
|
263
|
+
) {
|
|
264
|
+
issues.push('recipe.json exists but artifacts/recipe-coverage.md is missing');
|
|
265
|
+
}
|
|
266
|
+
validateRecipeQualityArtifact();
|
|
267
|
+
|
|
268
|
+
const parsed = parseManifest();
|
|
269
|
+
const coverage = readText('artifacts/recipe-coverage.md');
|
|
270
|
+
if (coverage && /\|\s*(visual|mixed)\s*\|/i.test(coverage)) {
|
|
271
|
+
const refCount = parsed?.refs?.size ?? 0;
|
|
272
|
+
if (refCount === 0)
|
|
273
|
+
issues.push(
|
|
274
|
+
'recipe-coverage.md declares visual/mixed proof but evidence-manifest has no media references',
|
|
275
|
+
);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
if (flags.has('--require-learnings')) {
|
|
279
|
+
const learnings = readText('artifacts/learnings.md');
|
|
280
|
+
if (!learnings?.trim()) {
|
|
281
|
+
issues.push('artifacts/learnings.md: required non-empty learnings artifact');
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
if (contractPath && existsSync(contractPath) && terminalCommand) {
|
|
286
|
+
const contract = JSON.parse(readFileSync(contractPath, 'utf8'));
|
|
287
|
+
const expanded = expandedArtifactsForCommand(contract, terminalCommand, fileExists);
|
|
288
|
+
for (const artifactPath of expanded.artifacts) {
|
|
289
|
+
if (flags.has('--skip-learnings') && artifactPath === 'artifacts/learnings.md') continue;
|
|
290
|
+
if (artifactPath.endsWith('.md') && !readText(artifactPath)?.trim()) {
|
|
291
|
+
issues.push(`${artifactPath}: required non-empty artifact (worker terminal contract)`);
|
|
292
|
+
}
|
|
293
|
+
if (!fileExists(artifactPath)) {
|
|
294
|
+
issues.push(`${artifactPath}: required artifact missing (worker terminal contract)`);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
if (expanded.requireRecipeQuality && hasRecipe && !fileExists('artifacts/recipe-quality.json')) {
|
|
298
|
+
issues.push('recipe.json exists but artifacts/recipe-quality.json is missing');
|
|
299
|
+
}
|
|
300
|
+
if (expanded.requireRecipeCoverage && hasRecipe && !fileExists('artifacts/recipe-coverage.md')) {
|
|
301
|
+
issues.push('recipe.json exists but artifacts/recipe-coverage.md is missing');
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
if (issues.length > 0) {
|
|
306
|
+
console.error('TASK_ARTIFACT_CONTRACT_FAIL');
|
|
307
|
+
for (const issue of issues) console.error(`- ${issue}`);
|
|
308
|
+
process.exit(1);
|
|
309
|
+
}
|
|
310
|
+
console.log('TASK_ARTIFACT_CONTRACT_PASS');
|
|
@@ -1,13 +1,34 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
const fs = require('node:fs');
|
|
3
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');
|
|
4
9
|
|
|
5
10
|
const START_COMMANDS = new Set(['start']);
|
|
6
11
|
const TERMINAL_COMMANDS = new Set(['complete', 'no-change', 'blocked']);
|
|
7
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
|
+
];
|
|
8
29
|
|
|
9
30
|
const usageLine =
|
|
10
|
-
'usage: mark <step-number|start|complete|no-change|blocked> [--reason text] [--already-fixed] [--mark-last] [--no-self-review]';
|
|
31
|
+
'usage: mark <step-number|start|complete|no-change|blocked> [--reason text] [--already-fixed] [--mark-last] [--no-self-review] [--skip-learnings] [--skip-checklist]';
|
|
11
32
|
|
|
12
33
|
function printHelp() {
|
|
13
34
|
console.log(
|
|
@@ -17,9 +38,12 @@ function printHelp() {
|
|
|
17
38
|
'Bootstrap: ./mark start — worker-owned SIGNAL.json with status running (no checklist box).',
|
|
18
39
|
'Progress: ./mark 1, ./mark 2, ... — checks the box and appends checklistTiming.',
|
|
19
40
|
'Terminal:',
|
|
20
|
-
' ./mark complete [--mark-last] [--no-self-review]',
|
|
21
|
-
' ./mark no-change --reason "..." [--already-fixed] [--mark-last]',
|
|
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]',
|
|
22
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.',
|
|
23
47
|
'Do not write SIGNAL.json by hand or with echo.',
|
|
24
48
|
].join('\n'),
|
|
25
49
|
);
|
|
@@ -47,7 +71,13 @@ if (!taskPath || !signalPath || !stepRaw) usage();
|
|
|
47
71
|
const opts = {};
|
|
48
72
|
for (let i = 0; i < rest.length; i += 1) {
|
|
49
73
|
const key = rest[i];
|
|
50
|
-
if (
|
|
74
|
+
if (
|
|
75
|
+
key === '--mark-last' ||
|
|
76
|
+
key === '--already-fixed' ||
|
|
77
|
+
key === '--no-self-review' ||
|
|
78
|
+
key === '--skip-learnings' ||
|
|
79
|
+
key === '--skip-checklist'
|
|
80
|
+
) {
|
|
51
81
|
opts[key.slice(2)] = true;
|
|
52
82
|
continue;
|
|
53
83
|
}
|
|
@@ -102,6 +132,49 @@ function resolveTerminalPreset(command) {
|
|
|
102
132
|
}
|
|
103
133
|
}
|
|
104
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
|
+
|
|
105
178
|
function assertNoChangeReport(taskDir) {
|
|
106
179
|
const reportAbs = path.join(taskDir, NO_CHANGE_REPORT);
|
|
107
180
|
if (!fs.existsSync(reportAbs) || fs.statSync(reportAbs).size === 0) {
|
|
@@ -111,7 +184,127 @@ function assertNoChangeReport(taskDir) {
|
|
|
111
184
|
return NO_CHANGE_REPORT;
|
|
112
185
|
}
|
|
113
186
|
|
|
114
|
-
function
|
|
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) {
|
|
115
308
|
const base = {
|
|
116
309
|
...pickSignalPassthrough(signal),
|
|
117
310
|
step: target?.label ?? signal.step ?? 'complete',
|
|
@@ -139,8 +332,14 @@ function buildSignalUpdate(signal, terminal, target, timing, events, now, taskPa
|
|
|
139
332
|
: {}),
|
|
140
333
|
};
|
|
141
334
|
|
|
335
|
+
const contract = terminal ? loadTerminalContract(_taskDir, taskPath) : null;
|
|
142
336
|
if (terminal.command === 'no-change') {
|
|
143
|
-
next.evidence = {
|
|
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 };
|
|
144
343
|
}
|
|
145
344
|
|
|
146
345
|
return next;
|
|
@@ -231,6 +430,14 @@ function resolveTarget(taskPath, stepNumber, markLast) {
|
|
|
231
430
|
}
|
|
232
431
|
|
|
233
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
|
+
}
|
|
234
441
|
const target = isStartCommand
|
|
235
442
|
? { stepNumber: null, label: 'started' }
|
|
236
443
|
: resolveTarget(taskPath, stepNumber, Boolean(terminalCommand && opts['mark-last']));
|
|
@@ -254,6 +461,10 @@ if (
|
|
|
254
461
|
events.push({ stepNumber: target.stepNumber, label: target.label, checkedAt: now });
|
|
255
462
|
}
|
|
256
463
|
|
|
464
|
+
if (terminalCommand) {
|
|
465
|
+
assertTerminalPackagedEvidence(taskPath, taskDir, terminalCommand);
|
|
466
|
+
}
|
|
467
|
+
|
|
257
468
|
const next = buildSignalUpdate(
|
|
258
469
|
signal,
|
|
259
470
|
terminalCommand ? { command: terminalCommand } : null,
|
|
@@ -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
|
+
};
|