@haposoft/cafekit 0.18.0 → 0.18.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.
Files changed (162) hide show
  1. package/README.md +12 -1
  2. package/bin/__tests__/claude-settings.test.js +123 -0
  3. package/bin/__tests__/codex-ask-guard.test.js +123 -0
  4. package/bin/__tests__/codex-ask-projection.test.js +51 -0
  5. package/bin/__tests__/codex-hooks-ownership.test.js +80 -1
  6. package/bin/__tests__/codex-hooks.test.js +938 -237
  7. package/bin/__tests__/codex-native-hook-boundaries.test.js +136 -0
  8. package/bin/__tests__/codex-native.test.js +222 -149
  9. package/bin/__tests__/codex-projection-parity.test.js +314 -0
  10. package/bin/__tests__/codex-quiet-hooks.test.js +170 -0
  11. package/bin/__tests__/codex-research-projection.test.js +70 -0
  12. package/bin/__tests__/codex-specs-projection.test.js +76 -0
  13. package/bin/__tests__/develop-contract.test.js +102 -1362
  14. package/bin/__tests__/docs-sync-on-demand.test.js +25 -20
  15. package/bin/__tests__/inspect-paths.test.js +183 -0
  16. package/bin/__tests__/installer-safety.test.js +335 -0
  17. package/bin/__tests__/legacy-authority-decoupled.test.js +14 -19
  18. package/bin/__tests__/legacy-hooks-retired.test.js +18 -8
  19. package/bin/__tests__/omp-bridge.test.js +66 -12
  20. package/bin/__tests__/omp-hooks.test.js +11 -5
  21. package/bin/__tests__/orca-session.test.js +7 -0
  22. package/bin/__tests__/package-inventory.test.js +1 -395
  23. package/bin/__tests__/privacy-paths.test.js +317 -0
  24. package/bin/__tests__/spec-narrowing.test.js +30 -134
  25. package/bin/__tests__/specs-v2-execution-closeout.test.js +0 -372
  26. package/bin/__tests__/test-runtime-dependency-closure.cjs +1 -2
  27. package/bin/__tests__/usage-hook-retired.test.js +5 -3
  28. package/bin/__tests__/validator-grounding.test.js +17 -2929
  29. package/bin/install.js +25 -0
  30. package/bin/lib/codex-install.js +134 -12
  31. package/bin/lib/context.js +1 -1
  32. package/bin/lib/managed-writer.js +2 -3
  33. package/bin/phases/claude-runtime.js +91 -25
  34. package/bin/phases/claude-settings.js +22 -1
  35. package/bin/phases/codex-hooks.js +96 -33
  36. package/bin/phases/codex-runtime.js +45 -49
  37. package/bin/phases/copy-payload.js +11 -28
  38. package/bin/phases/omp-runtime.js +6 -3
  39. package/package.json +2 -3
  40. package/src/claude/CLAUDE.md +4 -0
  41. package/src/claude/agents/code-auditor.md +9 -6
  42. package/src/claude/agents/debugger.md +3 -9
  43. package/src/claude/agents/docs-keeper.md +4 -7
  44. package/src/claude/agents/git-ops.md +2 -4
  45. package/src/claude/agents/implementer.md +6 -7
  46. package/src/claude/agents/inspector.md +2 -0
  47. package/src/claude/agents/project-manager.md +2 -2
  48. package/src/claude/agents/spec-maker.md +4 -4
  49. package/src/claude/agents/test-runner.md +8 -11
  50. package/src/claude/agents/ui-ux-designer.md +0 -1
  51. package/src/claude/gitignore +3 -0
  52. package/src/claude/hooks/__tests__/installed-root.test.js +111 -0
  53. package/src/claude/hooks/__tests__/precompact.test.js +206 -10
  54. package/src/claude/hooks/__tests__/privacy-block.test.js +27 -0
  55. package/src/claude/hooks/__tests__/session-writeenv.test.js +25 -0
  56. package/src/claude/hooks/__tests__/spec-gate.test.js +311 -340
  57. package/src/claude/hooks/__tests__/spec-state-touched.test.js +230 -8
  58. package/src/claude/hooks/__tests__/state.test.js +207 -7
  59. package/src/claude/hooks/__tests__/update-check.test.js +88 -0
  60. package/src/claude/hooks/agent.cjs +7 -6
  61. package/src/claude/hooks/inspect-block.cjs +15 -63
  62. package/src/claude/hooks/lib/inspect-paths.cjs +304 -0
  63. package/src/claude/hooks/lib/parser.cjs +1 -1
  64. package/src/claude/hooks/lib/privacy-command-analysis.cjs +191 -17
  65. package/src/claude/hooks/lib/privacy-paths.cjs +313 -0
  66. package/src/claude/hooks/lib/project-root.cjs +15 -0
  67. package/src/claude/hooks/lib/rules-reservation.cjs +36 -0
  68. package/src/claude/hooks/lib/spec-touch.cjs +67 -0
  69. package/src/claude/hooks/lib/update-check.cjs +77 -0
  70. package/src/claude/hooks/precompact.cjs +8 -15
  71. package/src/claude/hooks/privacy-block.cjs +23 -205
  72. package/src/claude/hooks/rules.cjs +6 -28
  73. package/src/claude/hooks/session.cjs +34 -117
  74. package/src/claude/hooks/spec-gate.cjs +17 -89
  75. package/src/claude/hooks/spec-state.cjs +89 -96
  76. package/src/claude/hooks/state.cjs +96 -51
  77. package/src/claude/migration-manifest.json +20 -8
  78. package/src/claude/output-styles/coding-level-0-eli5.md +9 -9
  79. package/src/claude/output-styles/coding-level-1-junior.md +3 -3
  80. package/src/claude/output-styles/coding-level-2-mid.md +3 -3
  81. package/src/claude/output-styles/coding-level-3-senior.md +2 -2
  82. package/src/claude/output-styles/coding-level-4-lead.md +5 -5
  83. package/src/claude/output-styles/coding-level-5-god.md +6 -6
  84. package/src/claude/rules/ai-dev-rules.md +1 -2
  85. package/src/claude/rules/hook-protocols.md +1 -1
  86. package/src/claude/rules/workflow.md +1 -1
  87. package/src/claude/runtime.json +0 -3
  88. package/src/claude/scripts/generate-skill-catalog.cjs +2 -2
  89. package/src/claude/scripts/provenance.cjs +14 -0
  90. package/src/claude/scripts/spec-receipt.cjs +0 -114
  91. package/src/claude/scripts/spec-resolver.cjs +41 -457
  92. package/src/claude/scripts/workflow-policy.cjs +3 -1801
  93. package/src/claude/settings/settings.json +1 -1
  94. package/src/claude/skills/agent-browser/SKILL.md +1 -1
  95. package/src/claude/skills/ai-multimodal/scripts/check_setup.py +8 -68
  96. package/src/claude/skills/ai-multimodal/scripts/gemini_batch_process.py +6 -28
  97. package/src/claude/skills/chrome-devtools/SKILL.md +14 -31
  98. package/src/claude/skills/code-review/SKILL.md +25 -108
  99. package/src/claude/skills/code-review/references/spec-compliance-review.md +4 -7
  100. package/src/claude/skills/develop/SKILL.md +22 -70
  101. package/src/claude/skills/develop/references/subagent-patterns.md +0 -6
  102. package/src/claude/skills/git/SKILL.md +1 -1
  103. package/src/claude/skills/git/references/worktree-blueprint.md +1 -1
  104. package/src/claude/skills/research/SKILL.md +24 -55
  105. package/src/claude/skills/sync/SKILL.md +12 -34
  106. package/src/claude/skills/sync/references/rebind-and-audit.md +2 -2
  107. package/src/claude/skills/test/SKILL.md +39 -123
  108. package/src/claude/skills/test/references/execution-strategy.md +11 -22
  109. package/src/claude/skills/ui-ux/SKILL.md +1 -1
  110. package/src/claude/skills/ui-ux-pro-max/SKILL.md +24 -79
  111. package/src/claude/skills/web-testing/SKILL.md +2 -20
  112. package/src/codex/AGENTS.md +8 -0
  113. package/src/codex/hooks/ask-guard.cjs +54 -0
  114. package/src/codex/hooks/inspect-block.cjs +11 -7
  115. package/src/codex/hooks/lib/inspect-paths.cjs +1 -199
  116. package/src/codex/hooks/lib/privacy-paths.cjs +1 -0
  117. package/src/codex/hooks/lib/privacy-state.cjs +98 -16
  118. package/src/codex/hooks/lib/rules-reservation.cjs +38 -0
  119. package/src/codex/hooks/lib/secret-keywords.cjs +1 -0
  120. package/src/codex/hooks/lib/spec-receipt.cjs +2 -30
  121. package/src/codex/hooks/lib/spec-session-touch.cjs +100 -0
  122. package/src/codex/hooks/lib/spec-touch.cjs +3 -0
  123. package/src/codex/hooks/lib/spec-utils.cjs +6 -45
  124. package/src/codex/hooks/lib/update-check.cjs +1 -0
  125. package/src/codex/hooks/precompact.cjs +39 -0
  126. package/src/codex/hooks/privacy-approval.cjs +28 -17
  127. package/src/codex/hooks/privacy-block.cjs +9 -215
  128. package/src/codex/hooks/rules.cjs +4 -30
  129. package/src/codex/hooks/secret-output-guardrail.cjs +1 -1
  130. package/src/codex/hooks/session.cjs +47 -11
  131. package/src/codex/hooks/spec-gate.cjs +32 -74
  132. package/src/codex/hooks/spec-state.cjs +68 -46
  133. package/src/codex/hooks/state.cjs +25 -4
  134. package/src/codex/hooks.json +39 -7
  135. package/src/codex/rules/hook-protocols.md +13 -6
  136. package/src/common/AGENTS.md +4 -10
  137. package/src/omp/extensions/cafekit-bridge.mjs +12 -4
  138. package/src/omp/hooks/privacy-block.cjs +24 -206
  139. package/bin/__tests__/change-firewall.semantic-firewall.test.js +0 -2133
  140. package/bin/__tests__/finalizer-pass-gate.semantic-firewall.test.js +0 -342
  141. package/bin/__tests__/release-preflight.semantic-firewall.test.js +0 -586
  142. package/bin/__tests__/review-receipt-c2.semantic-firewall.test.js +0 -378
  143. package/bin/__tests__/specs-v2-policy-and-scaffold.test.js +0 -665
  144. package/bin/__tests__/specs-v2-validator-grounder-contract.test.js +0 -1132
  145. package/src/claude/hooks/lib/context.cjs +0 -527
  146. package/src/claude/hooks/lib/detect.cjs +0 -474
  147. package/src/claude/scripts/change-firewall.cjs +0 -1748
  148. package/src/claude/scripts/spec-authoring-digest.cjs +0 -151
  149. package/src/claude/scripts/spec-authoring-validation.cjs +0 -126
  150. package/src/claude/scripts/spec-final-state.cjs +0 -290
  151. package/src/claude/scripts/spec-ground.cjs +0 -650
  152. package/src/claude/scripts/spec-readiness.cjs +0 -425
  153. package/src/claude/scripts/spec-scaffold.cjs +0 -2317
  154. package/src/claude/scripts/spec-semantic-model.cjs +0 -396
  155. package/src/claude/scripts/validate-spec-output.cjs +0 -3914
  156. package/src/claude/skills/code-review/references/verification-gate.md +0 -41
  157. package/src/claude/skills/specs/templates/design.md +0 -82
  158. package/src/claude/skills/specs/templates/requirements-init.md +0 -8
  159. package/src/claude/skills/specs/templates/requirements.md +0 -42
  160. package/src/claude/skills/specs/templates/spec-state.json +0 -56
  161. package/src/claude/skills/specs/templates/task.md +0 -47
  162. package/src/codex/rules/state-sync.md +0 -60
@@ -58,10 +58,6 @@ function assertSpecsRootContained(projectRoot, specsDir) {
58
58
  }
59
59
  }
60
60
 
61
- function isPlainObject(value) {
62
- return Boolean(value && typeof value === 'object' && !Array.isArray(value));
63
- }
64
-
65
61
  function candidateError(reason, code = 'malformed') {
66
62
  const error = new Error(reason);
67
63
  error.code = code;
@@ -221,10 +217,6 @@ function inspectWorkflowFeature(specsDir, canonicalSpecs, requestedName) {
221
217
  }
222
218
 
223
219
  const featureName = path.basename(canonicalFeature);
224
- if (fs.existsSync(path.join(canonicalFeature, 'spec.json'))) {
225
- return { legacyPresent: true, featureName, featureDir: canonicalFeature };
226
- }
227
-
228
220
  const planFile = path.join(canonicalFeature, 'plan.md');
229
221
  let planLstat;
230
222
  try {
@@ -289,210 +281,6 @@ function inspectWorkflowFeature(specsDir, canonicalSpecs, requestedName) {
289
281
  };
290
282
  }
291
283
 
292
- // Legacy registries written before the current vocabulary use `completed`, which today's
293
- // validator rejects but which exists in upgraded repositories. Reading it as finished is
294
- // what lets those packets stop competing for the gate's attention; `spec-final-state.cjs`
295
- // already reads spec status the same way. Process-first packets keep the strict set.
296
- const LEGACY_TASK_DONE = new Set(['done', 'completed', 'complete']);
297
-
298
- function normalizeLegacyWorkflowCandidate(candidate) {
299
- const registry = candidate.spec?.task_registry || {};
300
- const tasks = Object.entries(registry).map(([taskPath, task]) => ({
301
- path: taskPath,
302
- status: task?.status || 'pending',
303
- }));
304
- return {
305
- ...candidate,
306
- layoutKind: 'legacy-spec',
307
- stateFile: candidate.specFile,
308
- phase: candidate.spec?.current_phase || candidate.spec?.phase || 'unknown',
309
- taskRegistry: registry,
310
- tasks,
311
- allTasksDone: tasks.length > 0 && tasks.every((task) => LEGACY_TASK_DONE.has(task.status)),
312
- };
313
- }
314
-
315
- function inspectFeature(specsDir, canonicalSpecs, requestedName) {
316
- const featureDir = path.join(specsDir, requestedName);
317
- let featureLstat;
318
- try {
319
- featureLstat = fs.lstatSync(featureDir);
320
- } catch (e) {
321
- if (isMissingError(e)) return { missing: true };
322
- throw candidateError(`feature lstat error: ${e.message}`);
323
- }
324
-
325
- let canonicalFeature;
326
- try {
327
- canonicalFeature = fs.realpathSync(featureDir);
328
- if (!fs.statSync(featureDir).isDirectory()) {
329
- throw candidateError('feature entry must be a directory');
330
- }
331
- } catch (e) {
332
- if (e && e.code) throw e;
333
- throw candidateError(`feature canonicalization error: ${e.message}`);
334
- }
335
- if (canonicalFeature !== canonicalSpecs && !isPathInside(canonicalSpecs, canonicalFeature)) {
336
- throw candidateError('feature symlink escapes specs root');
337
- }
338
- if (path.dirname(canonicalFeature) !== canonicalSpecs) {
339
- throw candidateError('feature directory must resolve to a direct child of specs root');
340
- }
341
-
342
- const featureName = path.basename(canonicalFeature);
343
- const specFile = path.join(featureDir, 'spec.json');
344
- let specLstat;
345
- try {
346
- specLstat = fs.lstatSync(specFile);
347
- } catch (e) {
348
- if (isMissingError(e)) return { missingSpec: true, featureName, featureDir, canonicalFeature, specFile };
349
- throw candidateError(`spec.json lstat error: ${e.message}`);
350
- }
351
-
352
- let canonicalSpecFile;
353
- try {
354
- canonicalSpecFile = fs.realpathSync(specFile);
355
- if (!fs.statSync(specFile).isFile()) {
356
- throw candidateError('spec.json must be a regular file');
357
- }
358
- } catch (e) {
359
- if (e && e.code) throw e;
360
- throw candidateError(`spec.json canonicalization error: ${e.message}`);
361
- }
362
- if (canonicalSpecFile !== canonicalSpecs && !isPathInside(canonicalSpecs, canonicalSpecFile)) {
363
- throw candidateError('spec file symlink escapes specs root');
364
- }
365
- const expectedSpecFile = path.join(canonicalFeature, 'spec.json');
366
- if (canonicalSpecFile !== expectedSpecFile) {
367
- throw candidateError(`spec.json does not belong to feature directory ${featureName}`);
368
- }
369
-
370
- let spec;
371
- try {
372
- spec = JSON.parse(fs.readFileSync(canonicalSpecFile, 'utf8'));
373
- } catch (e) {
374
- throw candidateError(e.message);
375
- }
376
- if (!isPlainObject(spec)) throw candidateError('spec.json must contain a JSON object');
377
- if (typeof spec.feature_name !== 'string' || spec.feature_name.trim() !== featureName) {
378
- throw candidateError(`spec.json.feature_name must equal canonical feature name ${featureName}`);
379
- }
380
-
381
- return {
382
- featureName,
383
- spec,
384
- specsDir,
385
- featureDir: canonicalFeature,
386
- specFile: canonicalSpecFile,
387
- canonicalFeature,
388
- canonicalSpecFile,
389
- featureLstat,
390
- specLstat,
391
- };
392
- }
393
-
394
- function scanSpecs(specsDir) {
395
- let canonicalSpecs;
396
- let specsLstat = null;
397
- try {
398
- specsLstat = fs.lstatSync(specsDir);
399
- } catch (e) {
400
- if (isMissingError(e)) return { active: [], candidates: [], invalid: [], canonicalSpecs: path.resolve(specsDir) };
401
- return { active: [], candidates: [], invalid: [{ featureName: '<specs>', reason: `specs lstat error: ${e.message}`, specFile: specsDir }], canonicalSpecs: path.resolve(specsDir) };
402
- }
403
- try {
404
- canonicalSpecs = fs.realpathSync(specsDir);
405
- } catch (e) {
406
- return { active: [], candidates: [], invalid: [{ featureName: '<specs>', reason: `specs root canonicalization error: ${e.message}`, specFile: specsDir }], canonicalSpecs: path.resolve(specsDir) };
407
- }
408
-
409
- let entries;
410
- try {
411
- entries = fs.readdirSync(specsDir, { withFileTypes: true });
412
- } catch (e) {
413
- return { active: [], candidates: [], invalid: [{ featureName: '<specs>', reason: `specs directory read error: ${e.message}`, specFile: specsDir }], canonicalSpecs };
414
- }
415
- const active = [];
416
- const candidates = [];
417
- const invalid = [];
418
- const seenCanonicalFeatures = new Set();
419
- const sorted = entries.sort((a, b) => a.name.localeCompare(b.name));
420
- for (const entry of sorted) {
421
- try {
422
- const candidate = inspectFeature(specsDir, canonicalSpecs, entry.name);
423
- if (candidate.missing) {
424
- invalid.push({
425
- featureName: entry.name,
426
- reason: 'feature entry disappeared during scan',
427
- specFile: path.join(specsDir, entry.name, 'spec.json'),
428
- });
429
- continue;
430
- }
431
- if (candidate.missingSpec) {
432
- continue;
433
- }
434
- if (seenCanonicalFeatures.has(candidate.canonicalFeature)) continue;
435
- seenCanonicalFeatures.add(candidate.canonicalFeature);
436
- candidates.push(candidate);
437
- if (candidate.spec.status === 'in_progress' || candidate.spec.status === 'in-progress') {
438
- active.push(candidate);
439
- }
440
- } catch (e) {
441
- invalid.push({
442
- featureName: entry.name,
443
- reason: e.message,
444
- specFile: path.join(specsDir, entry.name, 'spec.json'),
445
- });
446
- }
447
- }
448
- return { active, candidates, invalid, canonicalSpecs };
449
- }
450
-
451
- function findAllSpecCandidates(projectRoot, runtime) {
452
- let specsDir;
453
- try {
454
- specsDir = specsDirectory(projectRoot, runtime);
455
- } catch (error) {
456
- const wrapped = new Error(`Invalid spec candidates: <specs>: ${error.message}`);
457
- wrapped.code = 'INVALID_SPECS';
458
- wrapped.invalid = [{ featureName: '<specs>', reason: error.message, specFile: path.resolve(projectRoot, 'specs') }];
459
- throw wrapped;
460
- }
461
- const scanned = scanSpecs(specsDir);
462
- if (scanned.invalid.length > 0) {
463
- const error = new Error(`Invalid spec candidates: ${scanned.invalid.map((item) => `${item.featureName}: ${item.reason}`).join('; ')}`);
464
- error.code = 'INVALID_SPECS';
465
- error.invalid = scanned.invalid;
466
- throw error;
467
- }
468
- return scanned.candidates.sort((left, right) => left.featureName.localeCompare(right.featureName));
469
- }
470
-
471
- function findAllActiveSpecs(projectRoot, runtime) {
472
- let specsDir;
473
- try {
474
- specsDir = specsDirectory(projectRoot, runtime);
475
- } catch (error) {
476
- const wrapped = new Error(`Invalid spec candidates: <specs>: ${error.message}`);
477
- wrapped.code = 'INVALID_SPECS';
478
- wrapped.invalid = [{ featureName: '<specs>', reason: error.message, specFile: path.resolve(projectRoot, 'specs') }];
479
- throw wrapped;
480
- }
481
- const { active, invalid } = scanSpecs(specsDir);
482
- if (invalid.length > 0) {
483
- const error = new Error(`Invalid spec candidates: ${invalid.map((item) => `${item.featureName}: ${item.reason}`).join('; ')}`);
484
- error.code = 'INVALID_SPECS';
485
- error.invalid = invalid;
486
- throw error;
487
- }
488
- return active;
489
- }
490
-
491
- function isPathInsideLegacy(parent, child) {
492
- const rel = path.relative(parent, child);
493
- return rel !== '' && !rel.startsWith('..' + path.sep) && rel !== '..' && !path.isAbsolute(rel);
494
- }
495
-
496
284
  function explicitTargetValue(value) {
497
285
  return typeof value === 'string' ? value.trim() : value;
498
286
  }
@@ -535,7 +323,39 @@ function readActiveFeatureTarget({ projectRoot, runtime } = {}) {
535
323
  const value = parsed.featureName;
536
324
  if (typeof value !== 'string' || /[\r\n]/.test(value)) return null;
537
325
  const trimmed = value.trim();
538
- return trimmed ? { explicitFeature: trimmed } : null;
326
+ if (!trimmed) return null;
327
+ // A recorded target naming a legacy spec.json packet is ignored rather than turned into
328
+ // a Stop block: CafeKit no longer reads those packets. A missing directory still reaches
329
+ // the resolver and fails as explicit_not_found; a name that is not a plain directory
330
+ // segment is left for the resolver to reject before anything outside specs is touched.
331
+ if (SAFE_PACKET_NAME.test(trimmed) && isLegacyPacketDir(path.join(specsDir, trimmed))) return null;
332
+ return { explicitFeature: trimmed };
333
+ }
334
+
335
+ // Names printed back to the model are limited to plain directory segments.
336
+ const SAFE_PACKET_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
337
+
338
+ // A legacy packet is a direct specs/<x>/ directory holding spec.json and no plan.md.
339
+ // A directory with both files is read as a process-first packet.
340
+ function isLegacyPacketDir(featureDir) {
341
+ const info = lstatOptional(featureDir);
342
+ if (!info.exists || info.isSymlink || !info.stat.isDirectory()) return false;
343
+ return fs.existsSync(path.join(featureDir, 'spec.json'))
344
+ && !fs.existsSync(path.join(featureDir, 'plan.md'));
345
+ }
346
+
347
+ function findLegacyPackets(projectRoot, runtime) {
348
+ let specsDir;
349
+ try {
350
+ specsDir = specsDirectory(projectRoot, runtime || {});
351
+ return fs.readdirSync(specsDir, { withFileTypes: true })
352
+ .filter((entry) => entry.isDirectory() && SAFE_PACKET_NAME.test(entry.name)
353
+ && isLegacyPacketDir(path.join(specsDir, entry.name)))
354
+ .map((entry) => entry.name)
355
+ .sort();
356
+ } catch {
357
+ return [];
358
+ }
539
359
  }
540
360
 
541
361
  /**
@@ -583,212 +403,6 @@ function lstatOptional(p) {
583
403
  }
584
404
  }
585
405
 
586
- function resolveActiveSpec({ projectRoot, runtime, explicitFeature, explicitPath, target } = {}) {
587
- if (!projectRoot) throw new TypeError('projectRoot required');
588
- const rt = runtime || {};
589
- const normalizedTarget = extractExplicitTarget(target);
590
- if (explicitFeature === undefined && normalizedTarget
591
- && Object.prototype.hasOwnProperty.call(normalizedTarget, 'explicitFeature')) {
592
- explicitFeature = normalizedTarget.explicitFeature;
593
- }
594
- if (explicitPath === undefined && normalizedTarget
595
- && Object.prototype.hasOwnProperty.call(normalizedTarget, 'explicitPath')) {
596
- explicitPath = normalizedTarget.explicitPath;
597
- }
598
- const hasExplicitFeature = explicitFeature !== undefined && explicitFeature !== null;
599
- const hasExplicitPath = explicitPath !== undefined && explicitPath !== null;
600
- const hasExplicitTarget = hasExplicitFeature || hasExplicitPath;
601
- let specsDir;
602
- try {
603
- specsDir = specsDirectory(projectRoot, rt);
604
- } catch (error) {
605
- return {
606
- error: hasExplicitTarget ? 'explicit_malformed' : 'invalid_specs',
607
- candidates: ['<specs>'],
608
- explicitFeature,
609
- explicitPath,
610
- reason: error.message,
611
- };
612
- }
613
- let canonicalSpecs;
614
- const specsLstat = lstatOptional(specsDir);
615
- if (specsLstat.error) {
616
- if (hasExplicitTarget) {
617
- return { error: 'explicit_malformed', explicitFeature, explicitPath, reason: `specs lstat error: ${specsLstat.error.message}` };
618
- }
619
- return { error: 'invalid_specs', candidates: ['<specs>'], invalid: [{ featureName: '<specs>', reason: `specs lstat error: ${specsLstat.error.message}`, specFile: specsDir }], reason: `Invalid spec JSON: <specs>: specs lstat error: ${specsLstat.error.message}` };
620
- }
621
- if (!specsLstat.exists) {
622
- canonicalSpecs = path.resolve(specsDir);
623
- } else {
624
- try {
625
- canonicalSpecs = fs.realpathSync(specsDir);
626
- } catch (e) {
627
- if (hasExplicitTarget) {
628
- return { error: 'explicit_malformed', explicitFeature, explicitPath, reason: `specs root canonicalization error: ${e.message}` };
629
- }
630
- return { error: 'invalid_specs', candidates: ['<specs>'], invalid: [{ featureName: '<specs>', reason: `specs root canonicalization error: ${e.message}`, specFile: specsDir }], reason: `Invalid spec JSON: <specs>: specs root canonicalization error: ${e.message}` };
631
- }
632
- }
633
-
634
- if (hasExplicitFeature) {
635
- if (typeof explicitFeature !== 'string' || explicitFeature.trim() === '' || explicitFeature.includes('/') || explicitFeature.includes('\\') || explicitFeature.includes('..')) {
636
- return { error: 'explicit_malformed', explicitFeature, reason: `malformed feature name: ${explicitFeature}` };
637
- }
638
- const featureDir = path.join(specsDir, explicitFeature);
639
- const resolvedFeature = path.resolve(featureDir);
640
- if (!isPathInsideLegacy(path.resolve(specsDir), resolvedFeature) && resolvedFeature !== path.resolve(specsDir)) {
641
- return { error: 'explicit_malformed', explicitFeature, reason: 'feature path escapes specs root' };
642
- }
643
- if (path.relative(path.resolve(specsDir), resolvedFeature) !== explicitFeature) {
644
- return { error: 'explicit_malformed', explicitFeature, reason: 'feature name does not canonicalize inside specs root' };
645
- }
646
- try {
647
- const candidate = inspectFeature(specsDir, canonicalSpecs, explicitFeature);
648
- if (candidate.missing || candidate.missingSpec) {
649
- return { error: 'explicit_not_found', explicitFeature, reason: `spec not found for feature ${explicitFeature}` };
650
- }
651
- if (candidate.featureName !== explicitFeature) {
652
- return {
653
- error: 'explicit_malformed',
654
- explicitFeature,
655
- reason: `feature directory resolves to canonical feature ${candidate.featureName}`,
656
- };
657
- }
658
- return candidate;
659
- } catch (e) {
660
- return { error: 'explicit_malformed', explicitFeature, reason: e.message };
661
- }
662
- }
663
-
664
- if (hasExplicitPath) {
665
- if (typeof explicitPath !== 'string' || explicitPath.trim() === '') {
666
- return { error: 'explicit_malformed', explicitPath, reason: 'empty explicit path' };
667
- }
668
- let specFile = explicitPath;
669
- if (!path.isAbsolute(specFile)) {
670
- specFile = path.resolve(projectRoot, specFile);
671
- } else {
672
- specFile = path.resolve(specFile);
673
- }
674
- try {
675
- const st = fs.lstatSync(specFile);
676
- if (st.isDirectory()) {
677
- specFile = path.join(specFile, 'spec.json');
678
- } else if (st.isSymbolicLink()) {
679
- try {
680
- if (fs.statSync(specFile).isDirectory()) specFile = path.join(specFile, 'spec.json');
681
- } catch (e) {
682
- if (!isMissingError(e)) throw e;
683
- }
684
- }
685
- } catch (e) {
686
- if (!isMissingError(e)) {
687
- return { error: 'explicit_malformed', explicitPath, reason: `explicit path stat error: ${e.message}` };
688
- }
689
- // If lstat is missing, the candidate is handled as an explicit not-found below.
690
- }
691
- const featureDir = path.dirname(specFile);
692
- if (!isPathInsideLegacy(path.resolve(specsDir), path.resolve(featureDir)) && path.resolve(featureDir) !== path.resolve(specsDir)) {
693
- return { error: 'explicit_malformed', explicitPath, reason: 'explicit path escapes specs root' };
694
- }
695
- const rel = path.relative(path.resolve(specsDir), path.resolve(specFile));
696
- const segs = rel.split(path.sep);
697
- if (segs.length !== 2 || segs[1] !== 'spec.json') {
698
- return { error: 'explicit_malformed', explicitPath, reason: 'explicit path must be <specs>/<feature>/spec.json or <specs>/<feature>' };
699
- }
700
- try {
701
- const candidate = inspectFeature(specsDir, canonicalSpecs, segs[0]);
702
- if (candidate.missing || candidate.missingSpec) {
703
- return { error: 'explicit_not_found', explicitPath, reason: `spec not found at ${explicitPath}` };
704
- }
705
- if (candidate.featureName !== segs[0]) {
706
- return {
707
- error: 'explicit_malformed',
708
- explicitPath,
709
- reason: `explicit path resolves to canonical feature ${candidate.featureName}, not ${segs[0]}`,
710
- };
711
- }
712
- return candidate;
713
- } catch (e) {
714
- return { error: 'explicit_malformed', explicitPath, reason: e.message };
715
- }
716
- }
717
-
718
- const { active, invalid } = scanSpecs(specsDir);
719
- if (invalid.length > 0) {
720
- return {
721
- error: 'invalid_specs',
722
- candidates: invalid.map((i) => i.featureName),
723
- invalid,
724
- reason: `Invalid spec JSON: ${invalid.map((i) => `${i.featureName}: ${i.reason}`).join('; ')}`,
725
- };
726
- }
727
- active.sort((left, right) => left.featureName.localeCompare(right.featureName));
728
- if (active.length === 0) return null;
729
- if (active.length === 1) return active[0];
730
- return {
731
- error: 'multiple_active',
732
- candidates: active.map((a) => a.featureName),
733
- active,
734
- reason: `Multiple active specs found: ${active.map((a) => a.featureName).join(', ')}. Provide explicit feature.`,
735
- };
736
- }
737
-
738
- /**
739
- * Resolve one persisted feature identity. Explicit host targets are inspected
740
- * directly and never scan siblings; only the no-target path performs a global
741
- * persisted-candidate scan and therefore owns ambiguity/invalid-sibling errors.
742
- */
743
- function resolvePersistedSpec({ projectRoot, runtime, explicitFeature, explicitPath, target } = {}) {
744
- const normalized = extractExplicitTarget(
745
- target,
746
- explicitFeature !== undefined ? { explicitFeature } : null,
747
- explicitPath !== undefined ? { explicitPath } : null,
748
- );
749
- if (normalized) {
750
- const value = Object.prototype.hasOwnProperty.call(normalized, 'explicitFeature')
751
- ? normalized.explicitFeature
752
- : normalized.explicitPath;
753
- if (value === null || value === undefined) {
754
- return { error: 'explicit_malformed', ...normalized, reason: 'explicit target must be a non-empty string' };
755
- }
756
- return resolveActiveSpec({ projectRoot, runtime, ...normalized });
757
- }
758
- // Only now, with the caller having named nothing: a recorded target must never
759
- // redirect a hook that already knows which feature its turn is about.
760
- const recorded = readActiveFeatureTarget({ projectRoot, runtime });
761
- if (recorded) return resolveActiveSpec({ projectRoot, runtime, ...recorded });
762
- try {
763
- const candidates = findAllSpecCandidates(projectRoot, runtime);
764
- if (candidates.length === 0) return null;
765
- // One packet resolves on its own terms. Closeout approval is claimed against a
766
- // finished spec, so status must never decide identity here.
767
- if (candidates.length === 1) return candidates[0];
768
- // This path serves closeout approval, so only a packet actually claiming closeout is
769
- // a real rival. Counting finished history as ambiguity is what made the gate
770
- // unsatisfiable in a repository that had simply accumulated features.
771
- const { isDurableCloseout } = require('./spec-final-state.cjs');
772
- const claiming = candidates.filter((candidate) => isDurableCloseout(candidate.spec));
773
- if (claiming.length === 1) return claiming[0];
774
- if (claiming.length === 0) return null;
775
- return {
776
- error: 'multiple_persisted',
777
- candidates: claiming.map((candidate) => candidate.featureName),
778
- reason: `Multiple persisted specs found: ${claiming.map((candidate) => candidate.featureName).join(', ')}. Provide explicit feature.`,
779
- };
780
- } catch (error) {
781
- return {
782
- error: 'invalid_specs',
783
- candidates: Array.isArray(error.invalid)
784
- ? error.invalid.map((entry) => entry.featureName)
785
- : ['<specs>'],
786
- invalid: error.invalid || [],
787
- reason: error.message,
788
- };
789
- }
790
- }
791
-
792
406
  function scanWorkflowFeatures(specsDir) {
793
407
  let canonicalSpecs;
794
408
  try {
@@ -807,7 +421,7 @@ function scanWorkflowFeatures(specsDir) {
807
421
  for (const entry of entries) {
808
422
  try {
809
423
  const candidate = inspectWorkflowFeature(canonicalSpecs, canonicalSpecs, entry.name);
810
- if (candidate.missing || candidate.missingWorkflow || candidate.legacyPresent) continue;
424
+ if (candidate.missing || candidate.missingWorkflow) continue;
811
425
  candidates.push(candidate);
812
426
  if (!candidate.allTasksDone) active.push(candidate);
813
427
  } catch (error) {
@@ -859,8 +473,8 @@ function explicitWorkflowFeatureName({ projectRoot, specsDir, explicitFeature, e
859
473
  }
860
474
 
861
475
  /**
862
- * Resolve the authoring/execution workflow visible to prompt and Stop hooks.
863
- * Persisted spec resolution remains unchanged; process-v3 is an additive adapter.
476
+ * Resolve the process-first packet (plan.md plus flat task files) visible to prompt
477
+ * and Stop hooks.
864
478
  */
865
479
  function resolveWorkflowCandidate({ projectRoot, runtime, explicitFeature, explicitPath, target, includeCompleted = false } = {}) {
866
480
  if (!projectRoot) throw new TypeError('projectRoot required');
@@ -883,20 +497,14 @@ function resolveWorkflowCandidate({ projectRoot, runtime, explicitFeature, expli
883
497
  }
884
498
 
885
499
  if (hasExplicit) {
886
- const legacyDirect = resolveActiveSpec({ projectRoot, runtime, explicitFeature, explicitPath });
887
- if (legacyDirect && !legacyDirect.error) return normalizeLegacyWorkflowCandidate(legacyDirect);
888
500
  const identity = explicitWorkflowFeatureName({ projectRoot, specsDir, explicitFeature, explicitPath });
889
501
  if (identity.error) return identity;
890
502
 
891
- const legacy = resolveActiveSpec({ projectRoot, runtime, explicitFeature: identity.featureName });
892
- if (legacy && !legacy.error) return normalizeLegacyWorkflowCandidate(legacy);
893
- if (legacy && legacy.error !== 'explicit_not_found') return legacy;
894
-
895
503
  let canonicalSpecs;
896
504
  try {
897
505
  canonicalSpecs = fs.realpathSync(specsDir);
898
506
  const candidate = inspectWorkflowFeature(specsDir, canonicalSpecs, identity.featureName);
899
- if (candidate.missing || candidate.missingWorkflow || candidate.legacyPresent) {
507
+ if (candidate.missing || candidate.missingWorkflow) {
900
508
  return { error: 'explicit_not_found', explicitFeature: identity.featureName, explicitPath, reason: `workflow not found for feature ${identity.featureName}` };
901
509
  }
902
510
  return candidate;
@@ -905,20 +513,6 @@ function resolveWorkflowCandidate({ projectRoot, runtime, explicitFeature, expli
905
513
  }
906
514
  }
907
515
 
908
- let legacyActive;
909
- try {
910
- const legacyCandidates = includeCompleted
911
- ? findAllSpecCandidates(projectRoot, runtime || {})
912
- : findAllActiveSpecs(projectRoot, runtime || {});
913
- legacyActive = legacyCandidates.map(normalizeLegacyWorkflowCandidate);
914
- } catch (error) {
915
- return {
916
- error: 'invalid_specs',
917
- candidates: Array.isArray(error.invalid) ? error.invalid.map((entry) => entry.featureName) : ['<specs>'],
918
- invalid: error.invalid || [],
919
- reason: error.message,
920
- };
921
- }
922
516
  const workflow = scanWorkflowFeatures(specsDir);
923
517
  if (workflow.invalid.length > 0) {
924
518
  return {
@@ -929,7 +523,7 @@ function resolveWorkflowCandidate({ projectRoot, runtime, explicitFeature, expli
929
523
  };
930
524
  }
931
525
  const workflowCandidates = includeCompleted ? workflow.candidates : workflow.active;
932
- const active = [...legacyActive, ...workflowCandidates]
526
+ const active = [...workflowCandidates]
933
527
  .sort((left, right) => left.featureName.localeCompare(right.featureName));
934
528
  if (active.length === 0) return null;
935
529
  if (active.length === 1) return active[0];
@@ -942,8 +536,7 @@ function resolveWorkflowCandidate({ projectRoot, runtime, explicitFeature, expli
942
536
  }
943
537
 
944
538
  /**
945
- * Narrow a Stop-gate ambiguity without changing legacy persisted-spec rules.
946
- * A single unfinished process-v3 packet is the current workflow; completed
539
+ * Narrow a Stop-gate ambiguity. A single unfinished process-v3 packet is the current workflow; completed
947
540
  * packets are historical. If every process-v3 packet is complete, return the
948
541
  * set so the gate can revalidate all receipts instead of blocking on identity.
949
542
  */
@@ -954,9 +547,7 @@ function refineWorkflowGateResolution(resolved) {
954
547
  const candidates = resolved.active;
955
548
  if (candidates.length === 0) return resolved;
956
549
 
957
- // Which packet still has work decides this, not which layout it uses: a repository of
958
- // legacy packets was permanently ambiguous only because the check below used to sit
959
- // here and reject the whole set on sight.
550
+ // Which packet still has work decides this.
960
551
  const unfinished = candidates.filter((candidate) => !candidate.allTasksDone);
961
552
  if (unfinished.length === 1) return unfinished[0];
962
553
  if (unfinished.length > 1) {
@@ -968,10 +559,6 @@ function refineWorkflowGateResolution(resolved) {
968
559
  reason: `Multiple active workflows found: ${unfinished.map((candidate) => candidate.featureName).join(', ')}. Provide explicit feature.`,
969
560
  };
970
561
  }
971
- // The bulk-audit branch exits before the semantic-digest, FLASH_UNVERIFIED,
972
- // feature-receipt, and completion-policy layers that the single-candidate path runs, so
973
- // only process-first packets may reach it. Anything else keeps the existing ambiguity.
974
- if (candidates.some((candidate) => candidate.layoutKind !== 'process-v3')) return resolved;
975
562
  return {
976
563
  layoutKind: 'process-v3-completed-set',
977
564
  candidates,
@@ -982,13 +569,10 @@ function refineWorkflowGateResolution(resolved) {
982
569
 
983
570
  module.exports = {
984
571
  annotatedMarkdownLines,
572
+ findLegacyPackets,
985
573
  specsDirectory,
986
- findAllActiveSpecs,
987
- findAllSpecCandidates,
988
574
  extractExplicitTarget,
989
- resolveActiveSpec,
990
575
  readActiveFeatureTarget,
991
- resolvePersistedSpec,
992
576
  resolveWorkflowCandidate,
993
577
  refineWorkflowGateResolution,
994
578
  };