@farmslot/skills 0.1.0 → 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 +15 -0
- package/LICENSE +21 -0
- package/README.md +7 -2
- package/bin/farmslot-checklist.mjs +2 -0
- package/package.json +9 -3
- package/scripts/check-task-artifact-contract.mjs +310 -0
- package/scripts/mark-checklist-step.cjs +488 -0
- package/scripts/worker-terminal-contract.cjs +382 -0
- package/skills/project-adopt/SKILL.md +2 -2
- package/skills/recipe-cook/SKILL.md +1 -1
- package/skills/recipe-cook/references/TASK.md +1 -1
- package/skills/recipe-harness/SKILL.md +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,4 +4,19 @@ 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
|
+
|
|
16
|
+
## 0.1.1 - 2026-06-30
|
|
17
|
+
|
|
18
|
+
- Single-path mark CLI for worker terminal signals.
|
|
19
|
+
|
|
20
|
+
## 0.1.0 - 2026-06-28
|
|
21
|
+
|
|
7
22
|
- Add the generic recipe-first skills package and migrate the former top-level recipe cooking kit into `skills/recipe-cook`.
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Arthur Breton
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -6,7 +6,7 @@ Public guide: https://farmslot.io/docs/guides/recipe-skills-adoption
|
|
|
6
6
|
|
|
7
7
|
## What This Package Is
|
|
8
8
|
|
|
9
|
-
`@farmslot/skills` is the recipe-first adoption kit. It teaches an agent how to write, review, diagnose, and adopt validation recipes before a team installs the full Farmslot
|
|
9
|
+
`@farmslot/skills` is the recipe-first adoption kit. It teaches an agent how to write, review, diagnose, and adopt validation recipes before a team installs the full Farmslot framework.
|
|
10
10
|
|
|
11
11
|
It does not require Gateway, Command Center, a pool file, slots, or Companion for first use.
|
|
12
12
|
|
|
@@ -49,7 +49,7 @@ Supported layouts:
|
|
|
49
49
|
2. Recipe runner: add a local command that can execute one recipe and emit artifacts.
|
|
50
50
|
3. Project recipe layer: add recipes, runner manifests, project actions, and artifact conventions.
|
|
51
51
|
4. Farmslot project integration: add `project.json` hooks for Command Center and slots.
|
|
52
|
-
5. Full
|
|
52
|
+
5. Full framework: adopt multi-slot dispatch, live steering, Companion supervision, and retrospectives.
|
|
53
53
|
|
|
54
54
|
## Source layout
|
|
55
55
|
|
|
@@ -61,6 +61,7 @@ Supported layouts:
|
|
|
61
61
|
|
|
62
62
|
## Maintenance rules
|
|
63
63
|
|
|
64
|
+
- `scripts/mark-checklist-step.cjs` mirrors consensys-skills `recipe-harness/scripts/mark-checklist-step.cjs`. Edit the recipe-harness copy first; sync the mirror before shipping Farmslot gateway or npm changes.
|
|
64
65
|
- Keep package payloads self-contained; consumers should not need the Farmslot monorepo.
|
|
65
66
|
- Update `CHANGELOG.md` before packaging or publishing.
|
|
66
67
|
- Run the package readiness guard when files move, bins change, or exports are added.
|
|
@@ -72,3 +73,7 @@ yarn workspace @farmslot/skills build
|
|
|
72
73
|
yarn workspace @farmslot/skills test
|
|
73
74
|
node scripts/quality/check-farmslot-package-readiness.mjs --packages @farmslot/skills
|
|
74
75
|
```
|
|
76
|
+
|
|
77
|
+
## License
|
|
78
|
+
|
|
79
|
+
MIT. See [LICENSE](LICENSE).
|
package/package.json
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
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",
|
|
7
7
|
"bin": {
|
|
8
|
+
"farmslot-checklist": "./bin/farmslot-checklist.mjs",
|
|
8
9
|
"farmslot-skills": "./bin/farmslot-skills.mjs"
|
|
9
10
|
},
|
|
10
11
|
"exports": {
|
|
@@ -14,11 +15,14 @@
|
|
|
14
15
|
"default": "./dist/index.js"
|
|
15
16
|
},
|
|
16
17
|
"./skills/*": "./skills/*/SKILL.md",
|
|
17
|
-
"./schemas/*": "./schemas/*"
|
|
18
|
+
"./schemas/*": "./schemas/*",
|
|
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"
|
|
18
22
|
},
|
|
19
23
|
"scripts": {
|
|
20
24
|
"typecheck": "tsc --noEmit --project tsconfig.json",
|
|
21
|
-
"test": "node test/prepare-cooking-run.test.cjs && node test/run-cooking-lane.test.cjs && node test/validator-loop.test.cjs && node test/package-exports.test.cjs",
|
|
25
|
+
"test": "node test/mark-checklist-step.test.cjs && node test/prepare-cooking-run.test.cjs && node test/run-cooking-lane.test.cjs && node test/validator-loop.test.cjs && node test/package-exports.test.cjs",
|
|
22
26
|
"format": "prettier --write --ignore-path ../../.prettierignore .",
|
|
23
27
|
"format:check": "prettier --check --ignore-path ../../.prettierignore .",
|
|
24
28
|
"lint": "eslint \"src/**/*.ts\"",
|
|
@@ -53,6 +57,7 @@
|
|
|
53
57
|
],
|
|
54
58
|
"files": [
|
|
55
59
|
"README.md",
|
|
60
|
+
"LICENSE",
|
|
56
61
|
"CHANGELOG.md",
|
|
57
62
|
"bin/**/*.mjs",
|
|
58
63
|
"dist/**/*.js",
|
|
@@ -61,6 +66,7 @@
|
|
|
61
66
|
"dist/**/*.js.map",
|
|
62
67
|
"schemas/**/*.json",
|
|
63
68
|
"scripts/**/*.cjs",
|
|
69
|
+
"scripts/**/*.mjs",
|
|
64
70
|
"skills/**/*.md",
|
|
65
71
|
"templates/**/*.json"
|
|
66
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');
|
|
@@ -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
|
+
}
|
|
@@ -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
|
+
};
|
|
@@ -17,7 +17,7 @@ Recommend a pragmatic adoption path for recipe-first validation.
|
|
|
17
17
|
1. Recipe runner: add a local command that executes one recipe and emits artifacts.
|
|
18
18
|
2. Project recipe layer: add recipes, runner manifest, project actions, and artifact conventions.
|
|
19
19
|
3. Farmslot project integration: add `project.json` hooks for slot-based execution.
|
|
20
|
-
4. Full
|
|
20
|
+
4. Full framework: adopt Gateway, Command Center, multi-slot dispatch, Companion, and retrospectives.
|
|
21
21
|
|
|
22
22
|
## Workflow
|
|
23
23
|
|
|
@@ -28,6 +28,6 @@ Recommend a pragmatic adoption path for recipe-first validation.
|
|
|
28
28
|
|
|
29
29
|
## Hard Rules
|
|
30
30
|
|
|
31
|
-
- Do not prescribe the full Farmslot
|
|
31
|
+
- Do not prescribe the full Farmslot framework when a local recipe runner is enough.
|
|
32
32
|
- Do not add domain-specific assumptions.
|
|
33
33
|
- Do not require a project to change its test stack before proving the first useful recipe.
|
|
@@ -61,7 +61,7 @@ If an interactive slot exists, do not stop after pasting or emitting JSON in cha
|
|
|
61
61
|
|
|
62
62
|
The harness order is mandatory.
|
|
63
63
|
Do not silently batch or skip steps.
|
|
64
|
-
Mark each completed checklist step `[x]`
|
|
64
|
+
Mark each completed checklist step immediately. If the run-local task has a `mark` helper, use `./mark N` (1-based); otherwise change `[ ]` to `[x]` yourself. Do not batch checkbox updates.
|
|
65
65
|
|
|
66
66
|
## Output Contract
|
|
67
67
|
|
|
@@ -10,7 +10,7 @@ Do not edit the template in-place during a cooking run.
|
|
|
10
10
|
|
|
11
11
|
Every time you complete a checklist step:
|
|
12
12
|
|
|
13
|
-
1. immediately change `- [ ]` to `- [x]`
|
|
13
|
+
1. run `./mark N` if present (1-based); otherwise immediately change `- [ ]` to `- [x]`
|
|
14
14
|
2. update `STATUS` if needed
|
|
15
15
|
3. only then move to the next step
|
|
16
16
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: recipe-harness
|
|
3
|
-
description: Help run or install a local recipe runtime without assuming the full Farmslot
|
|
3
|
+
description: Help run or install a local recipe runtime without assuming the full Farmslot framework
|
|
4
4
|
compatibility: Claude, Codex, Cursor, and Markdown skill runners with shell access
|
|
5
5
|
metadata:
|
|
6
6
|
package: '@farmslot/skills'
|