@haposoft/cafekit 0.16.0-rc.2 → 0.16.0-rc.3
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/bin/__tests__/codex-hooks.test.js +355 -12
- package/bin/__tests__/codex-native.test.js +1 -1
- package/bin/__tests__/develop-contract.test.js +1374 -78
- package/bin/__tests__/installer-safety.test.js +43 -29
- package/bin/__tests__/opencode-spec-gate.test.js +791 -0
- package/bin/__tests__/package-inventory.test.js +157 -0
- package/bin/__tests__/validator-grounding.test.js +707 -2
- package/package.json +1 -1
- package/src/claude/agents/spec-maker.md +107 -200
- package/src/claude/hooks/__tests__/completion-authority.test.js +720 -0
- package/src/claude/hooks/__tests__/spec-gate.test.js +511 -38
- package/src/claude/hooks/completion-authority-check.cjs +307 -0
- package/src/claude/hooks/completion-authority-state.cjs +525 -0
- package/src/claude/hooks/completion-authority.cjs +148 -0
- package/src/claude/hooks/session.cjs +33 -19
- package/src/claude/hooks/spec-gate.cjs +173 -61
- package/src/claude/hooks/spec-state.cjs +104 -45
- package/src/claude/migration-manifest.json +6 -1
- package/src/claude/scripts/provenance.cjs +315 -0
- package/src/claude/scripts/spec-resolver.cjs +450 -0
- package/src/claude/scripts/spec-scaffold.cjs +716 -41
- package/src/claude/scripts/validate-spec-output.cjs +355 -53
- package/src/claude/scripts/workflow-policy.cjs +1529 -162
- package/src/claude/settings/settings.json +8 -0
- package/src/claude/skills/code-review/SKILL.md +85 -66
- package/src/claude/skills/develop/SKILL.md +153 -285
- package/src/claude/skills/develop/references/quality-gate.md +116 -148
- package/src/claude/skills/develop/references/subagent-patterns.md +42 -37
- package/src/claude/skills/specs/SKILL.md +170 -381
- package/src/claude/skills/specs/rules/task-scoring-rubric.md +45 -46
- package/src/claude/skills/specs/rules/tasks-generation.md +49 -20
- package/src/claude/skills/specs/templates/spec-state.json +23 -2
- package/src/claude/skills/specs/templates/task.md +20 -0
- package/src/claude/skills/sync/SKILL.md +15 -8
- package/src/claude/skills/sync/references/sync-protocols.md +4 -4
- package/src/claude/skills/test/SKILL.md +141 -167
- package/src/codex/hooks/completion-authority-check.cjs +260 -0
- package/src/codex/hooks/completion-authority-state.cjs +525 -0
- package/src/codex/hooks/completion-authority.cjs +100 -0
- package/src/codex/hooks/lib/hook-context.cjs +42 -2
- package/src/codex/hooks/lib/spec-receipt.cjs +76 -43
- package/src/codex/hooks/lib/spec-utils.cjs +53 -27
- package/src/codex/hooks/session.cjs +2 -0
- package/src/codex/hooks/spec-gate.cjs +132 -20
- package/src/codex/hooks/spec-state.cjs +56 -10
- package/src/codex/hooks.json +10 -0
- package/src/opencode/AGENTS.md +4 -2
- package/src/opencode/plugins/spec-gate.ts +296 -122
- package/src/opencode/plugins/spec-state.ts +183 -116
|
@@ -12,31 +12,113 @@
|
|
|
12
12
|
* for the model to fill. It NEVER overwrites an existing spec directory.
|
|
13
13
|
*
|
|
14
14
|
* Usage:
|
|
15
|
-
* node spec-scaffold.cjs <feature> --tasks "R0-01-slug,R1-01-slug,..." \
|
|
16
|
-
* [--
|
|
15
|
+
* node spec-scaffold.cjs <feature> [--tasks "R0-01-slug,R1-01-slug,..."] \
|
|
16
|
+
* [--lane Standard|Critical] [--risks auth,privacy] \
|
|
17
|
+
* [--lang en] [--title "..."] [--specs-root specs] \
|
|
18
|
+
* [--artifacts '{"R1-01-slug":["dist/output.js"]}']
|
|
17
19
|
*
|
|
18
20
|
* Exit: 0 = scaffolded, 2 = usage/precondition error.
|
|
19
21
|
*/
|
|
20
22
|
|
|
21
23
|
const fs = require('fs');
|
|
22
24
|
const path = require('path');
|
|
25
|
+
const POLICY = require('./workflow-policy.cjs');
|
|
23
26
|
|
|
24
27
|
const TEMPLATES = path.join(__dirname, '..', 'skills', 'specs', 'templates');
|
|
25
28
|
const TASK_ID_RE = /^R(\d+)-(\d+)-([a-z0-9]+(?:-[a-z0-9]+)*)$/;
|
|
29
|
+
const TASK_PATH_RE = /^tasks\/task-(R\d+-\d{2}-[a-z0-9]+(?:-[a-z0-9]+)*)\.md$/;
|
|
30
|
+
const FEATURE_NAME_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
|
31
|
+
const REGISTRY_KEYS = [
|
|
32
|
+
'id',
|
|
33
|
+
'title',
|
|
34
|
+
'status',
|
|
35
|
+
'dependencies',
|
|
36
|
+
'blocker',
|
|
37
|
+
'started_at',
|
|
38
|
+
'completed_at',
|
|
39
|
+
'last_updated_at',
|
|
40
|
+
];
|
|
41
|
+
|
|
42
|
+
class ScaffoldPreconditionError extends Error {}
|
|
26
43
|
|
|
27
44
|
function usage() {
|
|
28
|
-
console.error('Usage: node spec-scaffold.cjs <feature> --tasks "R0-01-slug,R1-01-slug" [--lang en] [--title "..."] [--specs-root specs]');
|
|
45
|
+
console.error('Usage: node spec-scaffold.cjs <feature> [--tasks "R0-01-slug,R1-01-slug"] [--lane Standard|Critical] [--risks auth,privacy] [--lang en] [--title "..."] [--specs-root specs] [--artifacts \'{"R1-01-slug":["dist/output.js"]}\']');
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function failPrecondition(message) {
|
|
49
|
+
throw new ScaffoldPreconditionError(message);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function observeInstalledPolicyBaseline({ args, specDir, spec }) {
|
|
53
|
+
const adapterDir = path.dirname(__dirname);
|
|
54
|
+
const adapterName = path.basename(adapterDir);
|
|
55
|
+
if (path.basename(__dirname) !== 'scripts' || (adapterName !== '.claude' && adapterName !== '.codex')) return;
|
|
56
|
+
|
|
57
|
+
const statePath = path.join(adapterDir, 'hooks', 'completion-authority-state.cjs');
|
|
58
|
+
const checkPath = path.join(adapterDir, 'hooks', 'completion-authority-check.cjs');
|
|
59
|
+
if (![statePath, checkPath].every((filePath) => {
|
|
60
|
+
try {
|
|
61
|
+
return fs.statSync(filePath).isFile();
|
|
62
|
+
} catch {
|
|
63
|
+
return false;
|
|
64
|
+
}
|
|
65
|
+
})) return;
|
|
66
|
+
|
|
67
|
+
let installedRoot;
|
|
68
|
+
let scaffoldCwd;
|
|
69
|
+
let specFile;
|
|
70
|
+
try {
|
|
71
|
+
installedRoot = fs.realpathSync(path.resolve(adapterDir, '..'));
|
|
72
|
+
scaffoldCwd = fs.realpathSync(path.resolve(process.cwd()));
|
|
73
|
+
specFile = path.join(specDir, 'spec.json');
|
|
74
|
+
const canonicalSpecFile = fs.realpathSync(specFile);
|
|
75
|
+
if (!isWithin(installedRoot, scaffoldCwd) || !isWithin(installedRoot, canonicalSpecFile)) {
|
|
76
|
+
throw new Error(`scaffold cwd and spec path must stay inside installed project root ${installedRoot}`);
|
|
77
|
+
}
|
|
78
|
+
} catch (error) {
|
|
79
|
+
throw new Error(`installed ${adapterName} scaffold authority baseline could not be verified: ${error.message}`);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
let STATE;
|
|
83
|
+
let check;
|
|
84
|
+
try {
|
|
85
|
+
STATE = require(statePath);
|
|
86
|
+
check = require(checkPath);
|
|
87
|
+
} catch (error) {
|
|
88
|
+
throw new Error(`installed ${adapterName} scaffold authority hooks are unavailable: ${error.message}`);
|
|
89
|
+
}
|
|
90
|
+
if (typeof check.observePolicyBaseline !== 'function') {
|
|
91
|
+
throw new Error(`installed ${adapterName} scaffold authority check does not expose observePolicyBaseline`);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
let result;
|
|
95
|
+
try {
|
|
96
|
+
result = check.observePolicyBaseline({
|
|
97
|
+
STATE,
|
|
98
|
+
policy: POLICY,
|
|
99
|
+
projectRoot: installedRoot,
|
|
100
|
+
candidate: { spec, specFile, featureName: args.feature },
|
|
101
|
+
});
|
|
102
|
+
} catch (error) {
|
|
103
|
+
throw new Error(`installed ${adapterName} scaffold authority baseline failed: ${error.message}`);
|
|
104
|
+
}
|
|
105
|
+
if (!result || result.ok !== true) {
|
|
106
|
+
throw new Error(`installed ${adapterName} scaffold authority baseline rejected the spec: ${result?.reason || 'unknown reason'}`);
|
|
107
|
+
}
|
|
29
108
|
}
|
|
30
109
|
|
|
31
110
|
function parseArgs(argv) {
|
|
32
|
-
const a = { feature: null, tasks: null, lang: 'en', title: null, specsRoot: 'specs', tasksOnly: false };
|
|
111
|
+
const a = { feature: null, tasks: null, lane: null, risks: null, lang: 'en', title: null, specsRoot: 'specs', tasksOnly: false, artifacts: null };
|
|
33
112
|
for (let i = 2; i < argv.length; i++) {
|
|
34
113
|
const v = argv[i];
|
|
35
114
|
if (v === '--tasks') a.tasks = argv[++i];
|
|
115
|
+
else if (v === '--lane') a.lane = argv[++i];
|
|
116
|
+
else if (v === '--risks') a.risks = argv[++i];
|
|
36
117
|
else if (v === '--lang') a.lang = argv[++i];
|
|
37
118
|
else if (v === '--title') a.title = argv[++i];
|
|
38
119
|
else if (v === '--specs-root') a.specsRoot = argv[++i];
|
|
39
120
|
else if (v === '--tasks-only') a.tasksOnly = true;
|
|
121
|
+
else if (v === '--artifacts') a.artifacts = argv[++i];
|
|
40
122
|
else if (!a.feature) a.feature = v;
|
|
41
123
|
}
|
|
42
124
|
return a;
|
|
@@ -45,8 +127,7 @@ function parseArgs(argv) {
|
|
|
45
127
|
function readTemplate(name) {
|
|
46
128
|
const p = path.join(TEMPLATES, name);
|
|
47
129
|
if (!fs.existsSync(p)) {
|
|
48
|
-
|
|
49
|
-
process.exit(2);
|
|
130
|
+
failPrecondition(`template not found: ${p}`);
|
|
50
131
|
}
|
|
51
132
|
return fs.readFileSync(p, 'utf8');
|
|
52
133
|
}
|
|
@@ -56,8 +137,8 @@ function titleFromSlug(slug) {
|
|
|
56
137
|
return s.charAt(0).toUpperCase() + s.slice(1);
|
|
57
138
|
}
|
|
58
139
|
|
|
59
|
-
function registryEntry(t) {
|
|
60
|
-
|
|
140
|
+
function registryEntry(t, artifactDeclarations) {
|
|
141
|
+
const entry = {
|
|
61
142
|
id: `R${t.req}-${t.seq}`,
|
|
62
143
|
title: titleFromSlug(t.slug),
|
|
63
144
|
status: 'pending',
|
|
@@ -67,6 +148,44 @@ function registryEntry(t) {
|
|
|
67
148
|
completed_at: null,
|
|
68
149
|
last_updated_at: null,
|
|
69
150
|
};
|
|
151
|
+
const artifacts = artifactDeclarations.get(t.file);
|
|
152
|
+
if (artifacts) entry.artifacts = artifacts;
|
|
153
|
+
return entry;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function parseArtifactDeclarations(raw, parsedTasks) {
|
|
157
|
+
if (raw === null || raw === undefined) return new Map();
|
|
158
|
+
let declarations;
|
|
159
|
+
try {
|
|
160
|
+
declarations = JSON.parse(raw);
|
|
161
|
+
} catch (error) {
|
|
162
|
+
failPrecondition(`--artifacts must be valid JSON (${error.message})`);
|
|
163
|
+
}
|
|
164
|
+
if (!declarations || typeof declarations !== 'object' || Array.isArray(declarations)) {
|
|
165
|
+
failPrecondition('--artifacts must be an object keyed by task id or task path');
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const result = new Map();
|
|
169
|
+
for (const [key, artifacts] of Object.entries(declarations)) {
|
|
170
|
+
const task = parsedTasks.find((candidate) => key === candidate.raw || key === candidate.file);
|
|
171
|
+
if (!task) {
|
|
172
|
+
failPrecondition(`--artifacts references unknown task "${key}"`);
|
|
173
|
+
}
|
|
174
|
+
if (!Array.isArray(artifacts) || artifacts.length === 0 || artifacts.some((artifact) => (
|
|
175
|
+
typeof artifact !== 'string'
|
|
176
|
+
|| artifact.trim() === ''
|
|
177
|
+
|| artifact !== artifact.trim()
|
|
178
|
+
|| /^(?:[A-Za-z]:[\\/]|[\\/]|https?:\/\/)/.test(artifact)
|
|
179
|
+
|| artifact.split(/[\\/]+/).includes('..')
|
|
180
|
+
))) {
|
|
181
|
+
failPrecondition(`--artifacts for "${key}" must be a non-empty array of safe relative paths`);
|
|
182
|
+
}
|
|
183
|
+
if (new Set(artifacts).size !== artifacts.length) {
|
|
184
|
+
failPrecondition(`--artifacts for "${key}" must not contain duplicate paths`);
|
|
185
|
+
}
|
|
186
|
+
result.set(task.file, artifacts);
|
|
187
|
+
}
|
|
188
|
+
return result;
|
|
70
189
|
}
|
|
71
190
|
|
|
72
191
|
function fillTask(taskTpl, t, feature) {
|
|
@@ -80,6 +199,20 @@ function fillTask(taskTpl, t, feature) {
|
|
|
80
199
|
.replace(/\{\{DEPENDENCIES\}\}/g, 'none');
|
|
81
200
|
}
|
|
82
201
|
|
|
202
|
+
function parseRiskNames(raw) {
|
|
203
|
+
if (!raw) return [];
|
|
204
|
+
const aliases = new Map([
|
|
205
|
+
['auth', 'auth'],
|
|
206
|
+
['authentication', 'auth'],
|
|
207
|
+
['authorization', 'auth'],
|
|
208
|
+
]);
|
|
209
|
+
return [...new Set(raw
|
|
210
|
+
.split(',')
|
|
211
|
+
.map((risk) => risk.trim().toLowerCase())
|
|
212
|
+
.filter(Boolean)
|
|
213
|
+
.map((risk) => aliases.get(risk) || risk))];
|
|
214
|
+
}
|
|
215
|
+
|
|
83
216
|
function nowIso() {
|
|
84
217
|
// Local ISO with offset, e.g. 2026-06-21T12:30:00+07:00
|
|
85
218
|
const d = new Date();
|
|
@@ -91,22 +224,514 @@ function nowIso() {
|
|
|
91
224
|
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}${sign}${oh}:${om}`;
|
|
92
225
|
}
|
|
93
226
|
|
|
227
|
+
function hasOwn(value, key) {
|
|
228
|
+
return value !== null && typeof value === 'object' && Object.prototype.hasOwnProperty.call(value, key);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function isPlainObject(value) {
|
|
232
|
+
return Boolean(value && typeof value === 'object' && !Array.isArray(value));
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function cloneJson(value) {
|
|
236
|
+
return JSON.parse(JSON.stringify(value));
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function isWithin(root, target) {
|
|
240
|
+
const relative = path.relative(path.resolve(root), path.resolve(target));
|
|
241
|
+
return relative === '' || (relative !== '..' && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative));
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function assertNoSymlinkPath(root, target, label) {
|
|
245
|
+
const resolvedRoot = path.resolve(root);
|
|
246
|
+
const resolvedTarget = path.resolve(target);
|
|
247
|
+
if (!isWithin(resolvedRoot, resolvedTarget)) {
|
|
248
|
+
failPrecondition(`${label} escapes the work root: ${target}`);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const relative = path.relative(resolvedRoot, resolvedTarget);
|
|
252
|
+
let current = resolvedRoot;
|
|
253
|
+
for (const segment of relative.split(path.sep).filter(Boolean)) {
|
|
254
|
+
current = path.join(current, segment);
|
|
255
|
+
let stat;
|
|
256
|
+
try {
|
|
257
|
+
stat = fs.lstatSync(current);
|
|
258
|
+
} catch (error) {
|
|
259
|
+
if (error.code === 'ENOENT') break;
|
|
260
|
+
failPrecondition(`${label} cannot be inspected (${error.message})`);
|
|
261
|
+
}
|
|
262
|
+
if (stat.isSymbolicLink()) failPrecondition(`${label} cannot traverse a symlink: ${current}`);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function assertExistingDirectory(root, directory, label) {
|
|
267
|
+
assertNoSymlinkPath(root, directory, label);
|
|
268
|
+
let stat;
|
|
269
|
+
try {
|
|
270
|
+
stat = fs.lstatSync(directory);
|
|
271
|
+
} catch (error) {
|
|
272
|
+
failPrecondition(`${label} is missing (${error.message})`);
|
|
273
|
+
}
|
|
274
|
+
if (!stat.isDirectory()) failPrecondition(`${label} must be a directory`);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function assertRegularFileOrMissing(root, filePath, label) {
|
|
278
|
+
assertNoSymlinkPath(root, filePath, label);
|
|
279
|
+
try {
|
|
280
|
+
const stat = fs.lstatSync(filePath);
|
|
281
|
+
if (!stat.isFile()) failPrecondition(`${label} must be a regular file`);
|
|
282
|
+
} catch (error) {
|
|
283
|
+
if (error instanceof ScaffoldPreconditionError) throw error;
|
|
284
|
+
if (error.code !== 'ENOENT') failPrecondition(`${label} cannot be inspected (${error.message})`);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function safeRelativePath(value, label) {
|
|
289
|
+
if (
|
|
290
|
+
typeof value !== 'string'
|
|
291
|
+
|| value.trim() === ''
|
|
292
|
+
|| value !== value.trim()
|
|
293
|
+
|| path.isAbsolute(value)
|
|
294
|
+
|| /^[A-Za-z]:[\\/]/.test(value)
|
|
295
|
+
|| value.split(/[\\/]+/).includes('..')
|
|
296
|
+
) {
|
|
297
|
+
failPrecondition(`${label} must be a safe relative path`);
|
|
298
|
+
}
|
|
299
|
+
return value;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function validateArtifactList(artifacts, label) {
|
|
303
|
+
if (!Array.isArray(artifacts) || artifacts.length === 0) {
|
|
304
|
+
failPrecondition(`${label} must be a non-empty array of safe relative paths`);
|
|
305
|
+
}
|
|
306
|
+
const seen = new Set();
|
|
307
|
+
for (const artifact of artifacts) {
|
|
308
|
+
safeRelativePath(artifact, `${label} entry`);
|
|
309
|
+
if (seen.has(artifact)) failPrecondition(`${label} must not contain duplicate paths`);
|
|
310
|
+
seen.add(artifact);
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function parseTaskPath(taskPath, label) {
|
|
315
|
+
const match = typeof taskPath === 'string' && taskPath.match(TASK_PATH_RE);
|
|
316
|
+
if (!match) failPrecondition(`${label} must match tasks/task-R{N}-{SEQ}-<slug>.md`);
|
|
317
|
+
const rawId = match[1];
|
|
318
|
+
const idParts = rawId.match(TASK_ID_RE);
|
|
319
|
+
if (!idParts) failPrecondition(`${label} contains an invalid task id`);
|
|
320
|
+
return { id: `R${idParts[1]}-${idParts[2]}`, rawId, req: idParts[1], seq: idParts[2], slug: idParts[3] };
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function validateTaskPathList(taskFiles, label) {
|
|
324
|
+
if (!Array.isArray(taskFiles)) failPrecondition(`${label} must be an array`);
|
|
325
|
+
const seen = new Set();
|
|
326
|
+
for (const taskPath of taskFiles) {
|
|
327
|
+
parseTaskPath(taskPath, `${label} entry`);
|
|
328
|
+
if (seen.has(taskPath)) failPrecondition(`${label} contains duplicate path ${taskPath}`);
|
|
329
|
+
seen.add(taskPath);
|
|
330
|
+
}
|
|
331
|
+
return [...taskFiles].sort();
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function validateRegistryEntry(taskPath, entry, declaredSet) {
|
|
335
|
+
if (!isPlainObject(entry)) failPrecondition(`task_registry.${taskPath} must be an object`);
|
|
336
|
+
for (const key of REGISTRY_KEYS) {
|
|
337
|
+
if (!hasOwn(entry, key)) failPrecondition(`task_registry.${taskPath} is missing ${key}`);
|
|
338
|
+
}
|
|
339
|
+
const identity = parseTaskPath(taskPath, `task_registry.${taskPath}`);
|
|
340
|
+
if (entry.id !== identity.id) failPrecondition(`task_registry.${taskPath}.id conflicts with its task path`);
|
|
341
|
+
if (typeof entry.title !== 'string' || entry.title.trim() === '') failPrecondition(`task_registry.${taskPath}.title must be a non-empty string`);
|
|
342
|
+
if (typeof entry.status !== 'string' || entry.status.trim() === '') failPrecondition(`task_registry.${taskPath}.status must be a non-empty string`);
|
|
343
|
+
if (!Array.isArray(entry.dependencies)) failPrecondition(`task_registry.${taskPath}.dependencies must be an array`);
|
|
344
|
+
const dependencies = new Set();
|
|
345
|
+
for (const dependency of entry.dependencies) {
|
|
346
|
+
parseTaskPath(dependency, `task_registry.${taskPath}.dependencies entry`);
|
|
347
|
+
if (dependencies.has(dependency)) failPrecondition(`task_registry.${taskPath}.dependencies contains a duplicate`);
|
|
348
|
+
if (dependency === taskPath) failPrecondition(`task_registry.${taskPath}.dependencies cannot contain itself`);
|
|
349
|
+
if (!declaredSet.has(dependency)) failPrecondition(`task_registry.${taskPath}.dependencies references unknown task ${dependency}`);
|
|
350
|
+
dependencies.add(dependency);
|
|
351
|
+
}
|
|
352
|
+
if (hasOwn(entry, 'artifacts')) validateArtifactList(entry.artifacts, `task_registry.${taskPath}.artifacts`);
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
function validateDependencyCycles(taskFiles, registry) {
|
|
356
|
+
const visiting = new Set();
|
|
357
|
+
const visited = new Set();
|
|
358
|
+
function visit(taskPath, stack = []) {
|
|
359
|
+
if (visiting.has(taskPath)) {
|
|
360
|
+
const start = stack.indexOf(taskPath);
|
|
361
|
+
failPrecondition(`task_registry.dependencies contains a cycle: ${stack.slice(start).concat(taskPath).join(' -> ')}`);
|
|
362
|
+
}
|
|
363
|
+
if (visited.has(taskPath)) return;
|
|
364
|
+
visiting.add(taskPath);
|
|
365
|
+
for (const dependency of registry[taskPath].dependencies) visit(dependency, [...stack, taskPath]);
|
|
366
|
+
visiting.delete(taskPath);
|
|
367
|
+
visited.add(taskPath);
|
|
368
|
+
}
|
|
369
|
+
for (const taskPath of taskFiles) visit(taskPath);
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
function listExistingTaskFiles(workRoot, specDir) {
|
|
373
|
+
const tasksDir = path.join(specDir, 'tasks');
|
|
374
|
+
assertNoSymlinkPath(workRoot, tasksDir, 'tasks directory');
|
|
375
|
+
if (!fs.existsSync(tasksDir)) return [];
|
|
376
|
+
assertExistingDirectory(workRoot, tasksDir, 'tasks directory');
|
|
377
|
+
const files = [];
|
|
378
|
+
for (const entry of fs.readdirSync(tasksDir, { withFileTypes: true })) {
|
|
379
|
+
const fullPath = path.join(tasksDir, entry.name);
|
|
380
|
+
if (entry.isSymbolicLink()) failPrecondition(`tasks entry cannot be a symlink: ${fullPath}`);
|
|
381
|
+
if (!entry.isFile()) failPrecondition(`tasks entry must be a regular file: ${fullPath}`);
|
|
382
|
+
const relative = `tasks/${entry.name}`;
|
|
383
|
+
parseTaskPath(relative, `tasks entry ${relative}`);
|
|
384
|
+
files.push(relative);
|
|
385
|
+
}
|
|
386
|
+
return files.sort();
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
function readPersistedPolicy(spec) {
|
|
390
|
+
if (hasOwn(spec, 'override_receipt')) {
|
|
391
|
+
failPrecondition('existing spec contains legacy override_receipt; persist only workflow_policy.override_receipt');
|
|
392
|
+
}
|
|
393
|
+
if (!hasOwn(spec, 'workflow_policy')) {
|
|
394
|
+
const legacy = spec.design_context?.execution_tier;
|
|
395
|
+
failPrecondition(
|
|
396
|
+
legacy
|
|
397
|
+
? 'existing spec has no persisted workflow_policy; design_context.execution_tier is read-only and cannot create policy'
|
|
398
|
+
: 'existing spec must contain a persisted workflow_policy snapshot',
|
|
399
|
+
);
|
|
400
|
+
}
|
|
401
|
+
try {
|
|
402
|
+
return POLICY.readWorkflowPolicySnapshot(spec);
|
|
403
|
+
} catch (error) {
|
|
404
|
+
failPrecondition(`existing workflow_policy is invalid (${error.message})`);
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
function validateResearchTopology(workRoot, specDir, spec, needsResearch) {
|
|
409
|
+
const canonical = 'research.md';
|
|
410
|
+
if (hasOwn(spec, 'research') && spec.research !== null) {
|
|
411
|
+
const pointer = isPlainObject(spec.research) ? spec.research.path : spec.research;
|
|
412
|
+
if (typeof pointer !== 'string') failPrecondition('spec.research must be null, a safe relative path, or an object with a path');
|
|
413
|
+
const safePointer = safeRelativePath(pointer, 'spec.research');
|
|
414
|
+
const pointerPath = path.resolve(specDir, safePointer);
|
|
415
|
+
assertNoSymlinkPath(workRoot, pointerPath, 'spec.research path');
|
|
416
|
+
if (!isWithin(specDir, pointerPath)) failPrecondition('spec.research path must stay inside the spec directory');
|
|
417
|
+
if (needsResearch && safePointer !== canonical) {
|
|
418
|
+
failPrecondition('spec.research path must be research.md when needsResearchGrounding is required');
|
|
419
|
+
}
|
|
420
|
+
assertRegularFileOrMissing(workRoot, pointerPath, 'spec.research path');
|
|
421
|
+
}
|
|
422
|
+
const researchPath = path.join(specDir, canonical);
|
|
423
|
+
assertRegularFileOrMissing(workRoot, researchPath, 'research.md');
|
|
424
|
+
return researchPath;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
function inspectTaskTopology(workRoot, specDir, spec, parsed, artifactDeclarations) {
|
|
428
|
+
const hasTaskFiles = hasOwn(spec, 'task_files');
|
|
429
|
+
const hasRegistry = hasOwn(spec, 'task_registry');
|
|
430
|
+
if (hasTaskFiles !== hasRegistry) failPrecondition('task_files and task_registry must be present together');
|
|
431
|
+
|
|
432
|
+
const declared = hasTaskFiles ? validateTaskPathList(spec.task_files, 'task_files') : [];
|
|
433
|
+
const registry = hasRegistry ? spec.task_registry : {};
|
|
434
|
+
if (hasRegistry && !isPlainObject(registry)) failPrecondition('task_registry must be an object keyed by task file path');
|
|
435
|
+
const declaredSet = new Set(declared);
|
|
436
|
+
if (hasRegistry) {
|
|
437
|
+
const registryIds = new Set();
|
|
438
|
+
for (const taskPath of Object.keys(registry)) {
|
|
439
|
+
parseTaskPath(taskPath, `task_registry key ${taskPath}`);
|
|
440
|
+
if (!declaredSet.has(taskPath)) failPrecondition(`task_registry has an unregistered entry ${taskPath}`);
|
|
441
|
+
validateRegistryEntry(taskPath, registry[taskPath], declaredSet);
|
|
442
|
+
if (registryIds.has(registry[taskPath].id)) failPrecondition(`task_registry contains duplicate task id ${registry[taskPath].id}`);
|
|
443
|
+
registryIds.add(registry[taskPath].id);
|
|
444
|
+
}
|
|
445
|
+
for (const taskPath of declared) {
|
|
446
|
+
if (!hasOwn(registry, taskPath)) failPrecondition(`task_registry is missing entry ${taskPath}`);
|
|
447
|
+
}
|
|
448
|
+
validateDependencyCycles(declared, registry);
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
const existing = listExistingTaskFiles(workRoot, specDir);
|
|
452
|
+
const existingSet = new Set(existing);
|
|
453
|
+
if (!hasTaskFiles && existing.length > 0) failPrecondition('tasks directory contains files but task_files/task_registry are absent');
|
|
454
|
+
for (const taskPath of existing) {
|
|
455
|
+
if (!declaredSet.has(taskPath)) failPrecondition(`task_files is missing existing task file ${taskPath}`);
|
|
456
|
+
}
|
|
457
|
+
const requested = new Set(parsed.map((task) => task.file));
|
|
458
|
+
for (const taskPath of declared) {
|
|
459
|
+
if (!existingSet.has(taskPath)) failPrecondition(`task_files declares missing task file ${taskPath}`);
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
for (const task of parsed) {
|
|
463
|
+
if (!existingSet.has(task.file)) continue;
|
|
464
|
+
const entry = registry[task.file];
|
|
465
|
+
const expected = registryEntry(task, new Map());
|
|
466
|
+
if (entry.id !== expected.id || entry.title !== expected.title) {
|
|
467
|
+
failPrecondition(`conflicting duplicate task ${task.raw}: existing registry entry differs`);
|
|
468
|
+
}
|
|
469
|
+
if (artifactDeclarations.has(task.file)) {
|
|
470
|
+
if (!hasOwn(entry, 'artifacts') || JSON.stringify(entry.artifacts) !== JSON.stringify(expected.artifacts)) {
|
|
471
|
+
failPrecondition(`conflicting duplicate task ${task.raw}: existing registry artifacts differ`);
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
return {
|
|
477
|
+
declared,
|
|
478
|
+
registry,
|
|
479
|
+
existing,
|
|
480
|
+
existingSet,
|
|
481
|
+
requested,
|
|
482
|
+
hasTaskTopology: hasTaskFiles,
|
|
483
|
+
};
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
function captureTarget(workRoot, specDir, relativePath) {
|
|
487
|
+
const target = path.join(specDir, relativePath);
|
|
488
|
+
assertNoSymlinkPath(workRoot, target, `target ${relativePath}`);
|
|
489
|
+
try {
|
|
490
|
+
const stat = fs.lstatSync(target);
|
|
491
|
+
if (!stat.isFile()) failPrecondition(`target ${relativePath} must be a regular file`);
|
|
492
|
+
return { exists: true, bytes: fs.readFileSync(target) };
|
|
493
|
+
} catch (error) {
|
|
494
|
+
if (error instanceof ScaffoldPreconditionError) throw error;
|
|
495
|
+
if (error.code !== 'ENOENT') failPrecondition(`target ${relativePath} cannot be inspected (${error.message})`);
|
|
496
|
+
return { exists: false, bytes: null };
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
function parseFailureInjection() {
|
|
501
|
+
const raw = process.env.CAFEKIT_SCAFFOLD_FAIL_AFTER_WRITES;
|
|
502
|
+
if (raw === undefined) return null;
|
|
503
|
+
if (!/^[1-9]\d*$/.test(raw)) failPrecondition('CAFEKIT_SCAFFOLD_FAIL_AFTER_WRITES must be a positive integer');
|
|
504
|
+
return Number(raw);
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
function stageChanges(workRoot, specDir, changes) {
|
|
508
|
+
const parent = path.dirname(specDir);
|
|
509
|
+
assertExistingDirectory(workRoot, parent, 'spec parent directory');
|
|
510
|
+
const stageDir = fs.mkdtempSync(path.join(parent, '.cafekit-scaffold-stage-'));
|
|
511
|
+
try {
|
|
512
|
+
for (const change of changes) {
|
|
513
|
+
safeRelativePath(change.path, `staged path ${change.path}`);
|
|
514
|
+
const staged = path.join(stageDir, change.path);
|
|
515
|
+
fs.mkdirSync(path.dirname(staged), { recursive: true });
|
|
516
|
+
fs.writeFileSync(staged, change.body, { encoding: 'utf8', flag: 'wx' });
|
|
517
|
+
}
|
|
518
|
+
return stageDir;
|
|
519
|
+
} catch (error) {
|
|
520
|
+
fs.rmSync(stageDir, { recursive: true, force: true });
|
|
521
|
+
throw error;
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
function ensureTargetParent(workRoot, specDir, relativePath, createdDirs) {
|
|
526
|
+
const parent = path.dirname(relativePath);
|
|
527
|
+
if (parent === '.') return;
|
|
528
|
+
let current = specDir;
|
|
529
|
+
for (const segment of parent.split(path.sep)) {
|
|
530
|
+
current = path.join(current, segment);
|
|
531
|
+
assertNoSymlinkPath(workRoot, current, `target parent ${current}`);
|
|
532
|
+
if (fs.existsSync(current)) {
|
|
533
|
+
assertExistingDirectory(workRoot, current, `target parent ${current}`);
|
|
534
|
+
} else {
|
|
535
|
+
fs.mkdirSync(current);
|
|
536
|
+
createdDirs.push(current);
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
function removeTarget(target) {
|
|
542
|
+
try {
|
|
543
|
+
const stat = fs.lstatSync(target);
|
|
544
|
+
if (stat.isDirectory()) throw new Error(`rollback target is a directory: ${target}`);
|
|
545
|
+
fs.unlinkSync(target);
|
|
546
|
+
} catch (error) {
|
|
547
|
+
if (error.code !== 'ENOENT') throw error;
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
function rollbackChanges(applied, createdDirs, backupDir) {
|
|
552
|
+
for (let index = applied.length - 1; index >= 0; index -= 1) {
|
|
553
|
+
const record = applied[index];
|
|
554
|
+
const target = record.target;
|
|
555
|
+
if (record.targetInstalled) removeTarget(target);
|
|
556
|
+
if (record.backupMoved) {
|
|
557
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
558
|
+
fs.renameSync(record.backup, target);
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
for (let index = createdDirs.length - 1; index >= 0; index -= 1) {
|
|
562
|
+
try {
|
|
563
|
+
fs.rmdirSync(createdDirs[index]);
|
|
564
|
+
} catch (error) {
|
|
565
|
+
if (error.code !== 'ENOENT') throw error;
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
fs.rmSync(backupDir, { recursive: true, force: true });
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
function commitChanges(workRoot, specDir, changes, snapshots, failureAfter) {
|
|
572
|
+
const stageDir = stageChanges(workRoot, specDir, changes);
|
|
573
|
+
const parent = path.dirname(specDir);
|
|
574
|
+
let backupDir;
|
|
575
|
+
try {
|
|
576
|
+
backupDir = fs.mkdtempSync(path.join(parent, '.cafekit-scaffold-backup-'));
|
|
577
|
+
} catch (error) {
|
|
578
|
+
fs.rmSync(stageDir, { recursive: true, force: true });
|
|
579
|
+
throw error;
|
|
580
|
+
}
|
|
581
|
+
const applied = [];
|
|
582
|
+
const createdDirs = [];
|
|
583
|
+
let writes = 0;
|
|
584
|
+
try {
|
|
585
|
+
for (const change of changes) {
|
|
586
|
+
const target = path.join(specDir, change.path);
|
|
587
|
+
const before = captureTarget(workRoot, specDir, change.path);
|
|
588
|
+
const expected = snapshots.get(change.path);
|
|
589
|
+
if (before.exists !== expected.exists || (before.exists && !before.bytes.equals(expected.bytes))) {
|
|
590
|
+
failPrecondition(`target changed during preflight: ${change.path}`);
|
|
591
|
+
}
|
|
592
|
+
ensureTargetParent(workRoot, specDir, change.path, createdDirs);
|
|
593
|
+
const record = {
|
|
594
|
+
target,
|
|
595
|
+
backup: path.join(backupDir, change.path),
|
|
596
|
+
targetInstalled: false,
|
|
597
|
+
backupMoved: false,
|
|
598
|
+
};
|
|
599
|
+
applied.push(record);
|
|
600
|
+
if (before.exists) {
|
|
601
|
+
fs.mkdirSync(path.dirname(record.backup), { recursive: true });
|
|
602
|
+
fs.renameSync(target, record.backup);
|
|
603
|
+
record.backupMoved = true;
|
|
604
|
+
}
|
|
605
|
+
fs.renameSync(path.join(stageDir, change.path), target);
|
|
606
|
+
record.targetInstalled = true;
|
|
607
|
+
writes += 1;
|
|
608
|
+
if (failureAfter !== null && writes >= failureAfter) {
|
|
609
|
+
throw new Error(`injected failure after ${writes} staged write(s)`);
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
} catch (error) {
|
|
613
|
+
let rollbackError = null;
|
|
614
|
+
try {
|
|
615
|
+
rollbackChanges(applied, createdDirs, backupDir);
|
|
616
|
+
} catch (rollbackFailure) {
|
|
617
|
+
rollbackError = rollbackFailure;
|
|
618
|
+
}
|
|
619
|
+
fs.rmSync(stageDir, { recursive: true, force: true });
|
|
620
|
+
if (rollbackError) throw new Error(`${error.message}; rollback failed: ${rollbackError.message}`);
|
|
621
|
+
throw new Error(`${error.message}; transaction rolled back`);
|
|
622
|
+
}
|
|
623
|
+
fs.rmSync(backupDir, { recursive: true, force: true });
|
|
624
|
+
fs.rmSync(stageDir, { recursive: true, force: true });
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
function scaffoldTasksOnly({ args, specDir, specPath, spec, persistedPolicy, parsed, artifactDeclarations, taskTpl, ts }) {
|
|
628
|
+
const workRoot = path.resolve(process.cwd());
|
|
629
|
+
assertExistingDirectory(workRoot, specDir, 'spec directory');
|
|
630
|
+
assertRegularFileOrMissing(workRoot, specPath, 'spec.json');
|
|
631
|
+
const topology = inspectTaskTopology(workRoot, specDir, spec, parsed, artifactDeclarations);
|
|
632
|
+
|
|
633
|
+
let nextPolicy = persistedPolicy;
|
|
634
|
+
if (args.risks && args.risks.length > 0) {
|
|
635
|
+
try {
|
|
636
|
+
nextPolicy = POLICY.escalateWorkflowPolicy(spec, { risks: args.risks });
|
|
637
|
+
} catch (error) {
|
|
638
|
+
failPrecondition(`--tasks-only cannot escalate workflow policy (${error.message})`);
|
|
639
|
+
}
|
|
640
|
+
const validation = POLICY.validateWorkflowPolicySnapshot(nextPolicy);
|
|
641
|
+
if (!validation.valid) failPrecondition(`--tasks-only escalation produced an invalid workflow_policy (${validation.errors.join('; ')})`);
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
const needsResearch = nextPolicy.proof_obligations.includes('needsResearchGrounding');
|
|
645
|
+
const researchPath = validateResearchTopology(workRoot, specDir, spec, needsResearch);
|
|
646
|
+
const researchExists = fs.existsSync(researchPath);
|
|
647
|
+
const researchTemplate = needsResearch && !researchExists
|
|
648
|
+
? readTemplate('research.md').replace(/\{\{FEATURE_NAME\}\}/g, args.feature)
|
|
649
|
+
: null;
|
|
650
|
+
const newTasks = parsed.filter((task) => !topology.existingSet.has(task.file));
|
|
651
|
+
const nextSpec = cloneJson(spec);
|
|
652
|
+
let specChanged = false;
|
|
653
|
+
if (!topology.hasTaskTopology && newTasks.length > 0) {
|
|
654
|
+
nextSpec.task_files = [];
|
|
655
|
+
nextSpec.task_registry = {};
|
|
656
|
+
specChanged = true;
|
|
657
|
+
}
|
|
658
|
+
for (const task of newTasks) {
|
|
659
|
+
nextSpec.task_files.push(task.file);
|
|
660
|
+
nextSpec.task_registry[task.file] = registryEntry(task, artifactDeclarations);
|
|
661
|
+
specChanged = true;
|
|
662
|
+
}
|
|
663
|
+
if (JSON.stringify(nextPolicy) !== JSON.stringify(persistedPolicy)) {
|
|
664
|
+
nextSpec.workflow_policy = nextPolicy;
|
|
665
|
+
specChanged = true;
|
|
666
|
+
}
|
|
667
|
+
if (specChanged) {
|
|
668
|
+
nextSpec.task_files.sort();
|
|
669
|
+
nextSpec.updated_at = ts;
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
const changes = newTasks.map((task) => ({
|
|
673
|
+
path: task.file,
|
|
674
|
+
body: fillTask(taskTpl, task, args.feature),
|
|
675
|
+
}));
|
|
676
|
+
if (researchTemplate !== null) changes.push({ path: 'research.md', body: researchTemplate });
|
|
677
|
+
if (specChanged) changes.push({ path: 'spec.json', body: `${JSON.stringify(nextSpec, null, 2)}\n` });
|
|
678
|
+
if (changes.length === 0) return { createdTasks: [], researchCreated: false };
|
|
679
|
+
|
|
680
|
+
const expectedAbsent = new Set(newTasks.map((task) => task.file));
|
|
681
|
+
if (researchTemplate !== null) expectedAbsent.add('research.md');
|
|
682
|
+
const snapshots = new Map();
|
|
683
|
+
for (const change of changes) {
|
|
684
|
+
const snapshot = captureTarget(workRoot, specDir, change.path);
|
|
685
|
+
if (expectedAbsent.has(change.path) && snapshot.exists) {
|
|
686
|
+
failPrecondition(`target appeared after preflight: ${change.path}`);
|
|
687
|
+
}
|
|
688
|
+
snapshots.set(change.path, snapshot);
|
|
689
|
+
}
|
|
690
|
+
commitChanges(workRoot, specDir, changes, snapshots, parseFailureInjection());
|
|
691
|
+
return { createdTasks: newTasks.map((task) => task.file), researchCreated: researchTemplate !== null };
|
|
692
|
+
}
|
|
693
|
+
|
|
94
694
|
function main() {
|
|
95
695
|
const args = parseArgs(process.argv);
|
|
96
|
-
if (!args.feature ||
|
|
696
|
+
if (!args.feature || (args.tasks === null && args.tasksOnly) || (args.lane === 'Critical' && !args.tasks)) {
|
|
697
|
+
usage();
|
|
698
|
+
failPrecondition('missing feature or required task arguments');
|
|
699
|
+
}
|
|
700
|
+
if (typeof args.lane === 'string' && args.lane.toLowerCase() === 'direct') {
|
|
701
|
+
failPrecondition('Direct lane does not create a spec; run targeted verification from the Direct workflow instead');
|
|
702
|
+
}
|
|
703
|
+
if (args.lane && !POLICY.LANES.includes(args.lane)) {
|
|
704
|
+
failPrecondition(`--lane must be one of ${POLICY.LANES.join(', ')}`);
|
|
705
|
+
}
|
|
97
706
|
|
|
98
|
-
const ids = args.tasks.split(',').map((s) => s.trim()).filter(Boolean);
|
|
707
|
+
const ids = (args.tasks || '').split(',').map((s) => s.trim()).filter(Boolean);
|
|
708
|
+
if (args.tasksOnly && ids.length === 0) {
|
|
709
|
+
failPrecondition('--tasks-only requires at least one task id');
|
|
710
|
+
}
|
|
99
711
|
const parsed = [];
|
|
712
|
+
const seenIds = new Set();
|
|
100
713
|
for (const id of ids) {
|
|
101
714
|
const m = id.match(TASK_ID_RE);
|
|
102
715
|
if (!m) {
|
|
103
|
-
|
|
104
|
-
process.exit(2);
|
|
716
|
+
failPrecondition(`invalid task id "${id}" (want R{N}-{SEQ}-<slug>, SEQ 2 digits)`);
|
|
105
717
|
}
|
|
718
|
+
const logicalId = `R${m[1]}-${m[2]}`;
|
|
719
|
+
if (seenIds.has(logicalId)) failPrecondition(`duplicate task id "${logicalId}"`);
|
|
720
|
+
seenIds.add(logicalId);
|
|
106
721
|
parsed.push({ raw: id, req: m[1], seq: m[2], slug: m[3], file: `tasks/task-${id}.md` });
|
|
107
722
|
}
|
|
723
|
+
const artifactDeclarations = parseArtifactDeclarations(args.artifacts, parsed);
|
|
724
|
+
const riskNames = parseRiskNames(args.risks);
|
|
108
725
|
|
|
726
|
+
const workRoot = path.resolve(process.cwd());
|
|
727
|
+
safeRelativePath(args.specsRoot, '--specs-root');
|
|
728
|
+
safeRelativePath(args.feature, 'feature');
|
|
729
|
+
if (!FEATURE_NAME_RE.test(args.feature)) {
|
|
730
|
+
failPrecondition('feature must be one safe path segment starting with an alphanumeric character');
|
|
731
|
+
}
|
|
109
732
|
const specDir = path.resolve(process.cwd(), args.specsRoot, args.feature);
|
|
733
|
+
if (!isWithin(workRoot, specDir)) failPrecondition(`spec directory escapes the work root: ${specDir}`);
|
|
734
|
+
assertNoSymlinkPath(workRoot, specDir, 'spec directory');
|
|
110
735
|
const taskTpl = readTemplate('task.md');
|
|
111
736
|
const ts = nowIso();
|
|
112
737
|
const created = [];
|
|
@@ -114,39 +739,62 @@ function main() {
|
|
|
114
739
|
// --- --tasks-only: add task stubs + merge registry into an existing spec ---
|
|
115
740
|
if (args.tasksOnly) {
|
|
116
741
|
const specPath = path.join(specDir, 'spec.json');
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
742
|
+
assertNoSymlinkPath(workRoot, specPath, 'spec.json');
|
|
743
|
+
if (!fs.existsSync(specPath)) failPrecondition(`--tasks-only needs an existing spec.json at ${specPath}`);
|
|
744
|
+
let spec;
|
|
745
|
+
try {
|
|
746
|
+
spec = JSON.parse(fs.readFileSync(specPath, 'utf8'));
|
|
747
|
+
} catch (error) {
|
|
748
|
+
failPrecondition(`--tasks-only existing spec.json is invalid JSON (${error.message})`);
|
|
120
749
|
}
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
750
|
+
if (!spec || typeof spec !== 'object' || Array.isArray(spec)) {
|
|
751
|
+
failPrecondition('--tasks-only existing spec.json must contain a JSON object');
|
|
752
|
+
}
|
|
753
|
+
const persistedPolicy = readPersistedPolicy(spec);
|
|
754
|
+
if (args.lane && args.lane !== persistedPolicy.lane) {
|
|
755
|
+
failPrecondition(`--tasks-only --lane ${args.lane} does not match persisted workflow_policy.lane ${persistedPolicy.lane}; omit --lane or use the persisted lane (downgrade is not allowed)`);
|
|
756
|
+
}
|
|
757
|
+
const result = scaffoldTasksOnly({
|
|
758
|
+
args: { ...args, risks: riskNames },
|
|
759
|
+
specDir,
|
|
760
|
+
specPath,
|
|
761
|
+
spec,
|
|
762
|
+
persistedPolicy,
|
|
763
|
+
parsed,
|
|
764
|
+
artifactDeclarations,
|
|
765
|
+
taskTpl,
|
|
766
|
+
ts,
|
|
767
|
+
});
|
|
768
|
+
const createdTasks = result.createdTasks;
|
|
136
769
|
const relTo = path.relative(process.cwd(), specDir) || specDir;
|
|
137
770
|
console.log(`SCAFFOLDED (tasks-only) ${relTo}`);
|
|
138
|
-
console.log(`- ${
|
|
771
|
+
console.log(`- ${createdTasks.length} new task stub(s); task_files + task_registry merged.`);
|
|
772
|
+
if (result.researchCreated) console.log('- research.md created for needsResearchGrounding.');
|
|
139
773
|
console.log(`- NEXT: Edit-fill {{...}} in each stub; set dependencies; run validator + spec-ground.`);
|
|
140
774
|
return;
|
|
141
775
|
}
|
|
142
776
|
|
|
777
|
+
const policyInput = args.lane ? { risks: riskNames, override: args.lane } : { risks: riskNames };
|
|
778
|
+
let selectedPolicy;
|
|
779
|
+
try {
|
|
780
|
+
selectedPolicy = POLICY.workflowPolicySnapshot(policyInput);
|
|
781
|
+
} catch (error) {
|
|
782
|
+
failPrecondition(error.message);
|
|
783
|
+
}
|
|
784
|
+
if (selectedPolicy.lane === 'Critical' && parsed.length === 0) {
|
|
785
|
+
console.error('precondition: Critical workflow policy requires a task bundle; pass --tasks with at least one task id');
|
|
786
|
+
process.exit(2);
|
|
787
|
+
}
|
|
788
|
+
|
|
143
789
|
if (fs.existsSync(specDir)) {
|
|
144
790
|
console.error(`precondition: spec dir already exists, refusing to overwrite: ${specDir}`);
|
|
145
791
|
process.exit(2);
|
|
146
792
|
}
|
|
147
793
|
|
|
148
|
-
fs.mkdirSync(
|
|
149
|
-
|
|
794
|
+
fs.mkdirSync(specDir, { recursive: true });
|
|
795
|
+
if (parsed.length > 0) {
|
|
796
|
+
fs.mkdirSync(path.join(specDir, 'tasks'), { recursive: true });
|
|
797
|
+
}
|
|
150
798
|
|
|
151
799
|
// --- spec.json (from spec-state.json template) ---
|
|
152
800
|
const spec = JSON.parse(readTemplate('spec-state.json'));
|
|
@@ -158,19 +806,41 @@ function main() {
|
|
|
158
806
|
spec.timestamps.init = ts;
|
|
159
807
|
spec.scope_lock = spec.scope_lock || {};
|
|
160
808
|
spec.scope_lock.source = args.title || `{{PROJECT_DESCRIPTION}}`;
|
|
161
|
-
spec.
|
|
162
|
-
spec.
|
|
163
|
-
|
|
809
|
+
delete spec.workflow_policy;
|
|
810
|
+
delete spec.override_receipt;
|
|
811
|
+
Object.assign(spec, POLICY.persistWorkflowPolicySnapshot(spec, policyInput));
|
|
812
|
+
if (parsed.length > 0) {
|
|
813
|
+
spec.task_files = parsed.map((t) => t.file);
|
|
814
|
+
spec.task_registry = {};
|
|
815
|
+
for (const t of parsed) spec.task_registry[t.file] = registryEntry(t, artifactDeclarations);
|
|
816
|
+
} else {
|
|
817
|
+
delete spec.task_files;
|
|
818
|
+
delete spec.task_registry;
|
|
819
|
+
}
|
|
164
820
|
fs.writeFileSync(path.join(specDir, 'spec.json'), JSON.stringify(spec, null, 2) + '\n');
|
|
821
|
+
observeInstalledPolicyBaseline({ args, specDir, spec });
|
|
165
822
|
created.push('spec.json');
|
|
166
823
|
|
|
167
824
|
// --- doc templates (placeholders left for the model to fill) ---
|
|
168
|
-
|
|
825
|
+
const docs = [
|
|
826
|
+
['requirements.md', 'requirements.md'],
|
|
827
|
+
...(selectedPolicy.proof_obligations.includes('needsResearchGrounding')
|
|
828
|
+
? [['research.md', 'research.md']]
|
|
829
|
+
: []),
|
|
830
|
+
['design.md', 'design.md'],
|
|
831
|
+
];
|
|
832
|
+
for (const [tpl, out] of docs) {
|
|
169
833
|
const body = readTemplate(tpl).replace(/\{\{FEATURE_NAME\}\}/g, args.feature);
|
|
170
834
|
fs.writeFileSync(path.join(specDir, out), body);
|
|
171
835
|
created.push(out);
|
|
172
836
|
}
|
|
173
837
|
|
|
838
|
+
fs.writeFileSync(
|
|
839
|
+
path.join(specDir, 'feature-receipt.md'),
|
|
840
|
+
'# Feature Verification Receipt\n\nVerification: PENDING\n\nStatus: in_progress\n\nBlocker: awaiting execution proof\n',
|
|
841
|
+
);
|
|
842
|
+
created.push('feature-receipt.md');
|
|
843
|
+
|
|
174
844
|
// --- task stubs (fill the cheap placeholders; leave the rest) ---
|
|
175
845
|
for (const t of parsed) {
|
|
176
846
|
fs.writeFileSync(path.join(specDir, t.file), fillTask(taskTpl, t, args.feature));
|
|
@@ -179,9 +849,14 @@ function main() {
|
|
|
179
849
|
|
|
180
850
|
const rel = path.relative(process.cwd(), specDir) || specDir;
|
|
181
851
|
console.log(`SCAFFOLDED ${rel}`);
|
|
182
|
-
console.log(`- ${created.length} files created: spec.json +
|
|
183
|
-
console.log(`- task_files + task_registry pre-populated (${parsed.length} entries, all pending)`);
|
|
184
|
-
console.log(
|
|
852
|
+
console.log(`- ${created.length} files created: spec.json + ${docs.length} docs + feature-receipt.md + ${parsed.length} task stub(s)`);
|
|
853
|
+
if (parsed.length > 0) console.log(`- task_files + task_registry pre-populated (${parsed.length} entries, all pending)`);
|
|
854
|
+
console.log('- NEXT: Fill all spec placeholders (do NOT leave any); run validator + spec-ground before ready; keep feature-receipt.md PENDING until implementation evidence, then complete its canonical receipt at closeout.');
|
|
185
855
|
}
|
|
186
856
|
|
|
187
|
-
|
|
857
|
+
try {
|
|
858
|
+
main();
|
|
859
|
+
} catch (error) {
|
|
860
|
+
console.error(`precondition: ${error.message}`);
|
|
861
|
+
process.exitCode = 2;
|
|
862
|
+
}
|