@haposoft/cafekit 0.16.0-rc.1 → 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.
Files changed (51) hide show
  1. package/bin/__tests__/codex-hooks.test.js +355 -12
  2. package/bin/__tests__/codex-native.test.js +1 -1
  3. package/bin/__tests__/develop-contract.test.js +1374 -78
  4. package/bin/__tests__/installer-safety.test.js +43 -29
  5. package/bin/__tests__/opencode-spec-gate.test.js +791 -0
  6. package/bin/__tests__/package-inventory.test.js +157 -0
  7. package/bin/__tests__/validator-grounding.test.js +707 -2
  8. package/package.json +1 -1
  9. package/src/claude/agents/code-auditor.md +8 -1
  10. package/src/claude/agents/spec-maker.md +107 -200
  11. package/src/claude/hooks/__tests__/completion-authority.test.js +720 -0
  12. package/src/claude/hooks/__tests__/spec-gate.test.js +511 -38
  13. package/src/claude/hooks/completion-authority-check.cjs +307 -0
  14. package/src/claude/hooks/completion-authority-state.cjs +525 -0
  15. package/src/claude/hooks/completion-authority.cjs +148 -0
  16. package/src/claude/hooks/session.cjs +33 -19
  17. package/src/claude/hooks/spec-gate.cjs +173 -61
  18. package/src/claude/hooks/spec-state.cjs +104 -45
  19. package/src/claude/migration-manifest.json +6 -1
  20. package/src/claude/scripts/provenance.cjs +315 -0
  21. package/src/claude/scripts/spec-resolver.cjs +450 -0
  22. package/src/claude/scripts/spec-scaffold.cjs +716 -41
  23. package/src/claude/scripts/validate-spec-output.cjs +355 -53
  24. package/src/claude/scripts/workflow-policy.cjs +1529 -162
  25. package/src/claude/settings/settings.json +8 -0
  26. package/src/claude/skills/backend-development/references/backend-security.md +52 -0
  27. package/src/claude/skills/code-review/SKILL.md +85 -66
  28. package/src/claude/skills/develop/SKILL.md +153 -285
  29. package/src/claude/skills/develop/references/quality-gate.md +112 -124
  30. package/src/claude/skills/develop/references/subagent-patterns.md +42 -37
  31. package/src/claude/skills/specs/SKILL.md +170 -381
  32. package/src/claude/skills/specs/rules/task-scoring-rubric.md +45 -46
  33. package/src/claude/skills/specs/rules/tasks-generation.md +49 -20
  34. package/src/claude/skills/specs/templates/spec-state.json +23 -2
  35. package/src/claude/skills/specs/templates/task.md +20 -0
  36. package/src/claude/skills/sync/SKILL.md +15 -8
  37. package/src/claude/skills/sync/references/sync-protocols.md +4 -4
  38. package/src/claude/skills/test/SKILL.md +141 -167
  39. package/src/codex/hooks/completion-authority-check.cjs +260 -0
  40. package/src/codex/hooks/completion-authority-state.cjs +525 -0
  41. package/src/codex/hooks/completion-authority.cjs +100 -0
  42. package/src/codex/hooks/lib/hook-context.cjs +42 -2
  43. package/src/codex/hooks/lib/spec-receipt.cjs +76 -43
  44. package/src/codex/hooks/lib/spec-utils.cjs +53 -27
  45. package/src/codex/hooks/session.cjs +2 -0
  46. package/src/codex/hooks/spec-gate.cjs +132 -20
  47. package/src/codex/hooks/spec-state.cjs +56 -10
  48. package/src/codex/hooks.json +10 -0
  49. package/src/opencode/AGENTS.md +4 -2
  50. package/src/opencode/plugins/spec-gate.ts +296 -122
  51. 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
- * [--lang en] [--title "..."] [--specs-root specs]
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
- console.error(`precondition: template not found: ${p}`);
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
- return {
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 || !args.tasks) { usage(); process.exit(2); }
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
- console.error(`precondition: invalid task id "${id}" (want R{N}-{SEQ}-<slug>, SEQ 2 digits)`);
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
- if (!fs.existsSync(specPath)) {
118
- console.error(`precondition: --tasks-only needs an existing spec.json at ${specPath}`);
119
- process.exit(2);
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
- const spec = JSON.parse(fs.readFileSync(specPath, 'utf8'));
122
- spec.task_files = spec.task_files || [];
123
- spec.task_registry = spec.task_registry || {};
124
- fs.mkdirSync(path.join(specDir, 'tasks'), { recursive: true });
125
- for (const t of parsed) {
126
- const abs = path.join(specDir, t.file);
127
- if (fs.existsSync(abs)) continue; // never overwrite an already-filled task
128
- fs.writeFileSync(abs, fillTask(taskTpl, t, args.feature));
129
- if (!spec.task_files.includes(t.file)) spec.task_files.push(t.file);
130
- spec.task_registry[t.file] = registryEntry(t);
131
- created.push(t.file);
132
- }
133
- spec.task_files.sort();
134
- spec.updated_at = ts;
135
- fs.writeFileSync(specPath, JSON.stringify(spec, null, 2) + '\n');
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(`- ${created.length} new task stub(s); task_files + task_registry merged.`);
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(path.join(specDir, 'tasks'), { recursive: true });
149
- fs.mkdirSync(path.join(specDir, 'reports'), { recursive: true });
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.task_files = parsed.map((t) => t.file);
162
- spec.task_registry = {};
163
- for (const t of parsed) spec.task_registry[t.file] = registryEntry(t);
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
- for (const [tpl, out] of [['requirements.md', 'requirements.md'], ['research.md', 'research.md'], ['design.md', 'design.md']]) {
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 + 3 docs + ${parsed.length} task stub(s)`);
183
- console.log(`- task_files + task_registry pre-populated (${parsed.length} entries, all pending)`);
184
- console.log(`- NEXT: Edit-fill the {{...}} placeholders (do NOT leave any); set dependencies in task_registry; run validator + spec-ground before ready.`);
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
- main();
857
+ try {
858
+ main();
859
+ } catch (error) {
860
+ console.error(`precondition: ${error.message}`);
861
+ process.exitCode = 2;
862
+ }