@haposoft/cafekit 0.16.0-rc.3 → 0.16.0-rc.4

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 (91) hide show
  1. package/bin/__tests__/codex-hooks.test.js +444 -14
  2. package/bin/__tests__/codex-native.test.js +228 -7
  3. package/bin/__tests__/develop-contract.test.js +615 -185
  4. package/bin/__tests__/installer-safety.test.js +39 -0
  5. package/bin/__tests__/opencode-spec-gate.test.js +24 -16
  6. package/bin/__tests__/package-inventory.test.js +687 -13
  7. package/bin/__tests__/specs-v2-execution-closeout.test.js +421 -0
  8. package/bin/__tests__/specs-v2-policy-and-scaffold.test.js +601 -0
  9. package/bin/__tests__/specs-v2-validator-grounder-contract.test.js +952 -0
  10. package/bin/__tests__/test-runtime-dependency-closure.cjs +182 -0
  11. package/bin/__tests__/validator-grounding.test.js +1914 -221
  12. package/bin/lib/managed-writer.js +12 -3
  13. package/bin/phases/codex-runtime.js +17 -1
  14. package/package.json +1 -1
  15. package/src/claude/CLAUDE.md +6 -0
  16. package/src/claude/agents/code-auditor.md +20 -0
  17. package/src/claude/agents/spec-maker.md +143 -99
  18. package/src/claude/hooks/__tests__/completion-authority.test.js +291 -58
  19. package/src/claude/hooks/__tests__/privacy-block.test.js +172 -0
  20. package/src/claude/hooks/__tests__/semantic-review-authority.test.js +257 -0
  21. package/src/claude/hooks/__tests__/spec-gate.test.js +115 -40
  22. package/src/claude/hooks/completion-authority-check.cjs +37 -288
  23. package/src/claude/hooks/completion-authority-state.cjs +10 -8
  24. package/src/claude/hooks/completion-authority.cjs +13 -7
  25. package/src/claude/hooks/docs-sync.cjs +5 -2
  26. package/src/claude/hooks/lib/privacy-command-analysis.cjs +393 -0
  27. package/src/claude/hooks/lib/runtime-path-safety.cjs +119 -0
  28. package/src/claude/hooks/privacy-block.cjs +77 -30
  29. package/src/claude/hooks/semantic-review-authority.cjs +152 -0
  30. package/src/claude/hooks/spec-gate.cjs +47 -117
  31. package/src/claude/hooks/spec-state.cjs +8 -2
  32. package/src/claude/migration-manifest.json +9 -1
  33. package/src/claude/rules/state-sync.md +52 -21
  34. package/src/claude/rules/workflow.md +17 -9
  35. package/src/claude/scripts/spec-final-state.cjs +244 -0
  36. package/src/claude/scripts/spec-ground.cjs +535 -173
  37. package/src/claude/scripts/spec-readiness.cjs +118 -0
  38. package/src/claude/scripts/spec-receipt.cjs +162 -0
  39. package/src/claude/scripts/spec-resolver.cjs +67 -16
  40. package/src/claude/scripts/spec-scaffold.cjs +1354 -139
  41. package/src/claude/scripts/spec-semantic-model.cjs +396 -0
  42. package/src/claude/scripts/validate-spec-output.cjs +2782 -142
  43. package/src/claude/scripts/workflow-policy.cjs +501 -183
  44. package/src/claude/settings/settings.json +4 -0
  45. package/src/claude/skills/code-review/SKILL.md +12 -3
  46. package/src/claude/skills/code-review/references/spec-compliance-review.md +10 -2
  47. package/src/claude/skills/code-review/references/verification-gate.md +13 -0
  48. package/src/claude/skills/develop/SKILL.md +47 -60
  49. package/src/claude/skills/develop/references/parallel-waves.md +9 -6
  50. package/src/claude/skills/develop/references/quality-gate.md +19 -12
  51. package/src/claude/skills/specs/SKILL.md +183 -177
  52. package/src/claude/skills/specs/references/ask-user-question-gates.md +27 -18
  53. package/src/claude/skills/specs/references/cross-spec-dependency.md +8 -4
  54. package/src/claude/skills/specs/references/research-strategy.md +4 -1
  55. package/src/claude/skills/specs/references/review.md +200 -260
  56. package/src/claude/skills/specs/references/scope-inquiry.md +18 -5
  57. package/src/claude/skills/specs/rules/design-discovery-full.md +33 -81
  58. package/src/claude/skills/specs/rules/design-discovery-light.md +21 -42
  59. package/src/claude/skills/specs/rules/design-principles.md +136 -180
  60. package/src/claude/skills/specs/rules/design-review.md +42 -97
  61. package/src/claude/skills/specs/rules/ears-format.md +42 -50
  62. package/src/claude/skills/specs/rules/phase-decision-matrix.md +42 -41
  63. package/src/claude/skills/specs/rules/task-scoring-rubric.md +2 -1
  64. package/src/claude/skills/specs/rules/tasks-generation.md +189 -300
  65. package/src/claude/skills/specs/templates/design.md +49 -263
  66. package/src/claude/skills/specs/templates/requirements.md +26 -36
  67. package/src/claude/skills/specs/templates/research.md +9 -98
  68. package/src/claude/skills/specs/templates/spec-state.json +23 -85
  69. package/src/claude/skills/specs/templates/task.md +30 -102
  70. package/src/claude/skills/sync/SKILL.md +33 -10
  71. package/src/claude/skills/sync/references/sync-protocols.md +31 -13
  72. package/src/claude/skills/test/SKILL.md +17 -7
  73. package/src/codex/AGENTS.md +6 -0
  74. package/src/codex/hooks/completion-authority-check.cjs +43 -240
  75. package/src/codex/hooks/completion-authority-state.cjs +10 -8
  76. package/src/codex/hooks/completion-authority.cjs +13 -7
  77. package/src/codex/hooks/lib/hook-context.cjs +16 -2
  78. package/src/codex/hooks/lib/privacy-command-analysis.cjs +3 -0
  79. package/src/codex/hooks/lib/privacy-state.cjs +38 -16
  80. package/src/codex/hooks/lib/runtime-path-safety.cjs +3 -0
  81. package/src/codex/hooks/lib/spec-receipt.cjs +72 -134
  82. package/src/codex/hooks/lib/spec-utils.cjs +56 -5
  83. package/src/codex/hooks/privacy-approval.cjs +1 -1
  84. package/src/codex/hooks/privacy-block.cjs +67 -11
  85. package/src/codex/hooks/semantic-review-authority.cjs +130 -0
  86. package/src/codex/hooks/spec-gate.cjs +49 -34
  87. package/src/codex/hooks/spec-state.cjs +10 -3
  88. package/src/codex/hooks/state.cjs +2 -0
  89. package/src/codex/hooks.json +6 -1
  90. package/src/codex/rules/state-sync.md +47 -27
  91. package/src/common/AGENTS.md +1 -1
@@ -0,0 +1,118 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ const fs = require('fs');
5
+ const path = require('path');
6
+ const SEMANTIC = require('./spec-semantic-model.cjs');
7
+ const VALIDATOR = require('./validate-spec-output.cjs');
8
+ const { canonicalProjectRoot, groundSpec } = require('./spec-ground.cjs');
9
+
10
+ function plain(value) {
11
+ return Boolean(value && typeof value === 'object' && !Array.isArray(value));
12
+ }
13
+
14
+ function exactKeys(value, fields) {
15
+ return plain(value)
16
+ && JSON.stringify(Object.keys(value).sort()) === JSON.stringify([...fields].sort());
17
+ }
18
+
19
+ function readReviewResult(input) {
20
+ if (!exactKeys(input, ['reviewed_criteria', 'counterexamples'])) {
21
+ throw new Error('semantic review result fields must be exactly reviewed_criteria and counterexamples');
22
+ }
23
+ return JSON.parse(JSON.stringify(input));
24
+ }
25
+
26
+ function atomicReplace(file, bytes, io = fs) {
27
+ const stat = io.lstatSync(file);
28
+ if (!stat.isFile() || stat.isSymbolicLink()) throw new Error('spec.json must be a regular non-symlink file');
29
+ const original = io.readFileSync(file);
30
+ const temporary = path.join(path.dirname(file), `.${path.basename(file)}.finalize-${process.pid}-${Date.now()}`);
31
+ let descriptor;
32
+ try {
33
+ descriptor = io.openSync(temporary, 'wx', stat.mode);
34
+ io.writeFileSync(descriptor, bytes);
35
+ io.fsyncSync(descriptor);
36
+ io.closeSync(descriptor);
37
+ descriptor = undefined;
38
+ if (process.env.CAFEKIT_FINALIZE_FAIL_BEFORE_RENAME === '1') throw new Error('injected finalization failure before rename');
39
+ io.renameSync(temporary, file);
40
+ } catch (error) {
41
+ if (descriptor !== undefined) try { io.closeSync(descriptor); } catch {}
42
+ try { io.unlinkSync(temporary); } catch {}
43
+ if (!io.readFileSync(file).equals(original)) {
44
+ const rollback = `${temporary}-rollback`;
45
+ try {
46
+ io.writeFileSync(rollback, original, { flag: 'wx', mode: stat.mode });
47
+ io.renameSync(rollback, file);
48
+ } finally { try { io.unlinkSync(rollback); } catch {} }
49
+ }
50
+ throw error;
51
+ }
52
+ }
53
+
54
+ function finalizeReadiness({ specDir, reviewResult, projectRoot, io = fs }) {
55
+ const directory = io.realpathSync(path.resolve(specDir));
56
+ const root = projectRoot ? io.realpathSync(path.resolve(projectRoot)) : canonicalProjectRoot(directory);
57
+ const relative = path.relative(root, directory);
58
+ if (relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
59
+ throw new Error('spec directory escapes the canonical project root');
60
+ }
61
+ const specFile = path.join(directory, 'spec.json');
62
+ const original = io.readFileSync(specFile);
63
+ let spec;
64
+ try { spec = JSON.parse(original); } catch (error) { throw new Error(`spec.json is invalid JSON (${error.message})`); }
65
+ if (!plain(spec) || spec.schema_version !== '2.1') throw new Error('readiness finalization accepts canonical schema 2.1 only');
66
+ const review = readReviewResult(reviewResult);
67
+ const projection = SEMANTIC.modelFromMarkdown(directory, spec);
68
+ if (projection.errors.length) throw new Error(`semantic projection failed (${projection.errors.join('; ')})`);
69
+ const candidate = JSON.parse(JSON.stringify(spec));
70
+ candidate.semantic_model = projection.model;
71
+ candidate.validation = {
72
+ status: 'completed',
73
+ semantic_review: {
74
+ status: 'not-run', semantic_digest: null, reviewed_criteria: [], counterexamples: [],
75
+ },
76
+ };
77
+ candidate.ready_for_implementation = false;
78
+ const digestResult = VALIDATOR.computeSemanticDigest21(directory, candidate);
79
+ if (digestResult.errors.length) throw new Error(`semantic digest failed (${digestResult.errors.join('; ')})`);
80
+ candidate.validation.semantic_review = {
81
+ status: 'completed', semantic_digest: digestResult.digest,
82
+ reviewed_criteria: review.reviewed_criteria, counterexamples: review.counterexamples,
83
+ };
84
+ candidate.ready_for_implementation = true;
85
+ candidate.updated_at = new Date().toISOString();
86
+ const validation = VALIDATOR.validateSpec(directory, candidate);
87
+ if (validation.errors.length) throw new Error(`readiness validation failed (${validation.errors.join('; ')})`);
88
+ const grounding = groundSpec({ specDir: directory, root, spec: candidate });
89
+ if (grounding.errors.length) throw new Error(`readiness grounding failed (${grounding.errors.join('; ')})`);
90
+ const bytes = Buffer.from(`${JSON.stringify(candidate, null, 2)}\n`);
91
+ atomicReplace(specFile, bytes, io);
92
+ return { spec: candidate, semantic_digest: digestResult.digest };
93
+ }
94
+
95
+ function usage() {
96
+ process.stderr.write('Usage: node .claude/scripts/spec-readiness.cjs specs/<feature> --review-result <review.json>\n');
97
+ }
98
+
99
+ function main() {
100
+ const args = process.argv.slice(2);
101
+ const marker = args.indexOf('--review-result');
102
+ if (args.length !== 3 || marker !== 1) { usage(); process.exitCode = 2; return; }
103
+ try {
104
+ const reviewFile = path.resolve(args[2]);
105
+ const reviewStat = fs.lstatSync(reviewFile);
106
+ if (!reviewStat.isFile() || reviewStat.isSymbolicLink()) throw new Error('review result must be a regular non-symlink JSON file');
107
+ const reviewResult = JSON.parse(fs.readFileSync(reviewFile, 'utf8'));
108
+ const result = finalizeReadiness({ specDir: args[0], reviewResult });
109
+ process.stdout.write(`SPEC_READY ${args[0]} ${result.semantic_digest}\n`);
110
+ } catch (error) {
111
+ process.stderr.write(`SPEC_NOT_READY ${error.message}\n`);
112
+ process.exitCode = 1;
113
+ }
114
+ }
115
+
116
+ module.exports = { atomicReplace, finalizeReadiness, readReviewResult };
117
+
118
+ if (require.main === module) main();
@@ -0,0 +1,162 @@
1
+ 'use strict';
2
+
3
+ const crypto = require('crypto');
4
+ const fs = require('fs');
5
+ const path = require('path');
6
+
7
+ const EVIDENCE_HEADINGS = new Set([
8
+ 'Evidence',
9
+ 'Task Test Plan & Verification Evidence',
10
+ 'Verification & Evidence',
11
+ ]);
12
+
13
+ function inside(root, target) {
14
+ const relative = path.relative(path.resolve(root), path.resolve(target));
15
+ return relative === '' || (relative !== '..' && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative));
16
+ }
17
+
18
+ function safeRead(root, relativePath) {
19
+ if (typeof relativePath !== 'string' || path.isAbsolute(relativePath)) return { status: 'unsafe' };
20
+ const segments = relativePath.split(/[\\/]+/);
21
+ if (segments.length === 0 || segments.some((segment) => !segment || segment === '.' || segment === '..')) return { status: 'unsafe' };
22
+ const resolvedRoot = path.resolve(root);
23
+ const target = path.resolve(resolvedRoot, ...segments);
24
+ if (!inside(resolvedRoot, target)) return { status: 'unsafe' };
25
+ try {
26
+ const canonicalRoot = fs.realpathSync(resolvedRoot);
27
+ let current = resolvedRoot;
28
+ for (const segment of segments) {
29
+ current = path.join(current, segment);
30
+ const stat = fs.lstatSync(current);
31
+ if (stat.isSymbolicLink()) return { status: 'unsafe' };
32
+ }
33
+ const canonicalTarget = fs.realpathSync(target);
34
+ if (!inside(canonicalRoot, canonicalTarget) || !fs.statSync(target).isFile()) return { status: 'unsafe' };
35
+ return { status: 'ok', path: canonicalTarget, bytes: fs.readFileSync(target) };
36
+ } catch (error) {
37
+ return { status: error && error.code === 'ENOENT' ? 'missing' : 'unsafe' };
38
+ }
39
+ }
40
+
41
+ function canonicalTaskReceiptPath(taskPath) {
42
+ if (typeof taskPath !== 'string') return null;
43
+ const match = taskPath.match(/^tasks\/(task-[A-Za-z0-9][A-Za-z0-9._-]*\.md)$/);
44
+ return match ? `receipts/${match[1]}` : null;
45
+ }
46
+
47
+ function evidenceBody(text, policy = {}) {
48
+ const lines = String(text || '').split('\n');
49
+ let start = -1;
50
+ let level = 0;
51
+ for (let index = 0; index < lines.length; index += 1) {
52
+ const match = lines[index].match(/^(#{2,3})\s+(.+?)\s*$/);
53
+ if (match && EVIDENCE_HEADINGS.has(match[2])) {
54
+ start = index + 1;
55
+ level = match[1].length;
56
+ break;
57
+ }
58
+ }
59
+ if (start < 0) return null;
60
+ let end = lines.length;
61
+ for (let index = start; index < lines.length; index += 1) {
62
+ const heading = lines[index].match(/^(#{1,6})\s+/);
63
+ const tap = typeof policy.isTapMetadataHeading === 'function' && policy.isTapMetadataHeading(lines[index]);
64
+ if (heading && heading[1].length <= level && !tap) { end = index; break; }
65
+ }
66
+ return lines.slice(start, end).join('\n');
67
+ }
68
+
69
+ function fieldValues(body, name) {
70
+ const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
71
+ const matcher = new RegExp(`^\\s*${escaped}\\s*:\\s*(.*?)\\s*$`, 'i');
72
+ return String(body || '').split('\n').map((line) => line.match(matcher)).filter(Boolean).map((match) => match[1]);
73
+ }
74
+
75
+ function concrete(value) {
76
+ return typeof value === 'string' && value.trim() !== '' && !/^(?:N\/A|NONE|PENDING|TBD|UNKNOWN|\{\{.*\}\})$/i.test(value.trim());
77
+ }
78
+
79
+ function plannedField(taskText, name) {
80
+ const plan = String(taskText || '').split(/^##\s+Verification Plan\s*$/m)[1];
81
+ if (!plan) return null;
82
+ const value = fieldValues(plan.split(/^##\s+/m)[0], `- **${name}**`)[0];
83
+ return value ? value.replace(/^`|`$/g, '').trim() : null;
84
+ }
85
+
86
+ function identity(body) {
87
+ const names = ['Verification', 'Command', 'Commands', 'Exit', 'Result', 'Base', 'Head', 'base_sha', 'head_sha'];
88
+ const fields = names.flatMap((name) => fieldValues(body, name).map((value) => [name.toLowerCase(), value.trim()]));
89
+ const artifacts = String(body || '').split('\n').filter((line) => /^\s*Artifacts?\s*:|^\s*Artifact\s+produced\b/i.test(line)).map((line) => line.trim());
90
+ return crypto.createHash('sha256').update(JSON.stringify({ fields, artifacts })).digest('hex');
91
+ }
92
+
93
+ function canonicalFailures(body, task, runtimeContext, policy) {
94
+ if (!policy || typeof policy.validateCanonicalReceipt !== 'function') return ['validator_unavailable'];
95
+ const options = typeof policy.receiptValidatorOptions === 'function'
96
+ ? policy.receiptValidatorOptions(task || {}, { runtimeContext, requireProvenanceBinding: true })
97
+ : { requireProvenanceBinding: true };
98
+ return policy.validateCanonicalReceipt(body, options);
99
+ }
100
+
101
+ function separateMetadataFailures(body, taskPath, taskText) {
102
+ const failures = [];
103
+ const basename = path.posix.basename(taskPath);
104
+ if (fieldValues(body, 'Task').length !== 1 || fieldValues(body, 'Task')[0] !== basename) failures.push('task_identity');
105
+ if (fieldValues(body, 'Task path').length !== 1 || fieldValues(body, 'Task path')[0] !== taskPath) failures.push('task_identity');
106
+ if (!concrete(fieldValues(body, 'Expected')[0]) || !concrete(fieldValues(body, 'Observed')[0])) failures.push('expected_observed');
107
+ const plannedCommand = plannedField(taskText, 'Command');
108
+ const actualCommand = fieldValues(body, 'Command')[0] || fieldValues(body, 'Commands')[0];
109
+ if (plannedCommand && !/^N\/A\b/i.test(plannedCommand) && actualCommand !== plannedCommand) failures.push('command_identity');
110
+ for (const name of ['Negative path', 'Reachability']) {
111
+ const planned = plannedField(taskText, name);
112
+ if (planned && !/^(?:N\/A|none|not relevant)\b/i.test(planned) && !concrete(fieldValues(body, name)[0])) failures.push(name.toLowerCase().replace(' ', '_'));
113
+ }
114
+ return [...new Set(failures)];
115
+ }
116
+
117
+ function readTaskProof(featureDir, taskPath, policy = {}) {
118
+ const receiptPath = canonicalTaskReceiptPath(taskPath);
119
+ if (typeof taskPath !== 'string' || !/^tasks\/[^/]+\.md$/.test(taskPath)) return { status: 'unsafe', taskPath, receiptPath: null };
120
+ const taskFile = safeRead(featureDir, taskPath);
121
+ if (taskFile.status !== 'ok') return { status: taskFile.status, taskPath, receiptPath };
122
+ const taskText = taskFile.bytes.toString('utf8');
123
+ const separate = receiptPath ? safeRead(featureDir, receiptPath) : { status: 'missing' };
124
+ const legacyBody = evidenceBody(taskText, policy);
125
+ if (separate.status === 'unsafe') return { status: 'unsafe', taskPath, receiptPath, taskFile };
126
+ if (separate.status === 'ok') {
127
+ const body = separate.bytes.toString('utf8');
128
+ return { status: 'ok', source: 'separate', body, receiptBytes: separate.bytes, receiptFile: separate, taskFile, taskText, taskPath, receiptPath, legacyBody };
129
+ }
130
+ if (legacyBody !== null) return { status: 'ok', source: 'legacy', body: legacyBody, receiptBytes: Buffer.from(legacyBody), taskFile, taskText, taskPath, receiptPath, legacyBody };
131
+ return { status: 'missing', taskPath, receiptPath, taskFile, taskText };
132
+ }
133
+
134
+ function checkTaskReceipt(featureDir, taskPath, task, runtimeContext, policy) {
135
+ const proof = readTaskProof(featureDir, taskPath, policy);
136
+ const failures = [];
137
+ if (proof.status !== 'ok') return { ...proof, failures: [proof.status === 'unsafe' ? 'unsafe_path' : 'missing_receipt'] };
138
+ const status = proof.taskText.split('\n').find((line) => /^\s*(?:\*\*)?Status(?:\*\*)?\s*:/i.test(line));
139
+ if (!status || !/\bdone\b/i.test(status)) failures.push('task_status');
140
+ failures.push(...canonicalFailures(proof.body, task, runtimeContext, policy));
141
+ if (proof.source === 'separate') {
142
+ failures.push(...separateMetadataFailures(proof.body, taskPath, proof.taskText));
143
+ if (proof.legacyBody !== null
144
+ && /^\s*Verification:\s*PASS\s*$/m.test(proof.legacyBody)
145
+ && identity(proof.legacyBody) !== identity(proof.body)) failures.push('receipt_conflict');
146
+ }
147
+ const completedAt = task && task.completed_at;
148
+ if (typeof completedAt !== 'string' || !/^\d{4}-\d{2}-\d{2}T/.test(completedAt) || Number.isNaN(Date.parse(completedAt))) failures.push('completed_at');
149
+ return { ...proof, failures: [...new Set(failures)] };
150
+ }
151
+
152
+ function checkFeatureReceipt(featureDir, runtimeContext, policy) {
153
+ const receipt = safeRead(featureDir, 'feature-receipt.md');
154
+ if (receipt.status !== 'ok') return { status: receipt.status, failures: [receipt.status === 'unsafe' ? 'unsafe_path' : 'missing_receipt'] };
155
+ const body = receipt.bytes.toString('utf8');
156
+ const failures = canonicalFailures(body, {}, runtimeContext, policy);
157
+ if (fieldValues(body, 'Feature').length !== 1 || fieldValues(body, 'Feature')[0] !== path.basename(featureDir)) failures.push('feature_identity');
158
+ if (!concrete(fieldValues(body, 'Expected')[0]) || !concrete(fieldValues(body, 'Observed')[0])) failures.push('expected_observed');
159
+ return { status: 'ok', source: 'feature', body, receiptBytes: receipt.bytes, receiptFile: receipt, failures: [...new Set(failures)] };
160
+ }
161
+
162
+ module.exports = { canonicalTaskReceiptPath, checkFeatureReceipt, checkTaskReceipt, evidenceBody, readTaskProof, safeRead };
@@ -250,9 +250,7 @@ function isPathInsideLegacy(parent, child) {
250
250
  }
251
251
 
252
252
  function explicitTargetValue(value) {
253
- if (typeof value !== 'string') return null;
254
- const trimmed = value.trim();
255
- return trimmed ? trimmed : null;
253
+ return typeof value === 'string' ? value.trim() : value;
256
254
  }
257
255
 
258
256
  /**
@@ -265,12 +263,14 @@ function extractExplicitTarget(...sources) {
265
263
  if (!source || typeof source !== 'object') continue;
266
264
 
267
265
  for (const key of ['explicitFeature', 'featureName', 'feature']) {
268
- const value = explicitTargetValue(source[key]);
269
- if (value) return { explicitFeature: value };
266
+ if (Object.prototype.hasOwnProperty.call(source, key)) {
267
+ return { explicitFeature: explicitTargetValue(source[key]) };
268
+ }
270
269
  }
271
270
  for (const key of ['explicitPath', 'specPath', 'spec_path', 'featurePath']) {
272
- const value = explicitTargetValue(source[key]);
273
- if (value) return { explicitPath: value };
271
+ if (Object.prototype.hasOwnProperty.call(source, key)) {
272
+ return { explicitPath: explicitTargetValue(source[key]) };
273
+ }
274
274
  }
275
275
 
276
276
  const target = source.target;
@@ -278,8 +278,8 @@ function extractExplicitTarget(...sources) {
278
278
  const nested = extractExplicitTarget(target);
279
279
  if (nested) return nested;
280
280
  }
281
- const targetValue = explicitTargetValue(target);
282
- if (targetValue) {
281
+ if (Object.prototype.hasOwnProperty.call(source, 'target')) {
282
+ const targetValue = explicitTargetValue(target);
283
283
  return /[\\/]/.test(targetValue) || /(?:^|[\\/])spec\.json$/.test(targetValue)
284
284
  ? { explicitPath: targetValue }
285
285
  : { explicitFeature: targetValue };
@@ -302,14 +302,23 @@ function resolveActiveSpec({ projectRoot, runtime, explicitFeature, explicitPath
302
302
  if (!projectRoot) throw new TypeError('projectRoot required');
303
303
  const rt = runtime || {};
304
304
  const normalizedTarget = extractExplicitTarget(target);
305
- explicitFeature = explicitFeature || normalizedTarget?.explicitFeature;
306
- explicitPath = explicitPath || normalizedTarget?.explicitPath;
305
+ if (explicitFeature === undefined && normalizedTarget
306
+ && Object.prototype.hasOwnProperty.call(normalizedTarget, 'explicitFeature')) {
307
+ explicitFeature = normalizedTarget.explicitFeature;
308
+ }
309
+ if (explicitPath === undefined && normalizedTarget
310
+ && Object.prototype.hasOwnProperty.call(normalizedTarget, 'explicitPath')) {
311
+ explicitPath = normalizedTarget.explicitPath;
312
+ }
313
+ const hasExplicitFeature = explicitFeature !== undefined && explicitFeature !== null;
314
+ const hasExplicitPath = explicitPath !== undefined && explicitPath !== null;
315
+ const hasExplicitTarget = hasExplicitFeature || hasExplicitPath;
307
316
  let specsDir;
308
317
  try {
309
318
  specsDir = specsDirectory(projectRoot, rt);
310
319
  } catch (error) {
311
320
  return {
312
- error: explicitFeature || explicitPath ? 'explicit_malformed' : 'invalid_specs',
321
+ error: hasExplicitTarget ? 'explicit_malformed' : 'invalid_specs',
313
322
  candidates: ['<specs>'],
314
323
  explicitFeature,
315
324
  explicitPath,
@@ -319,7 +328,7 @@ function resolveActiveSpec({ projectRoot, runtime, explicitFeature, explicitPath
319
328
  let canonicalSpecs;
320
329
  const specsLstat = lstatOptional(specsDir);
321
330
  if (specsLstat.error) {
322
- if (explicitFeature || explicitPath) {
331
+ if (hasExplicitTarget) {
323
332
  return { error: 'explicit_malformed', explicitFeature, explicitPath, reason: `specs lstat error: ${specsLstat.error.message}` };
324
333
  }
325
334
  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}` };
@@ -330,14 +339,14 @@ function resolveActiveSpec({ projectRoot, runtime, explicitFeature, explicitPath
330
339
  try {
331
340
  canonicalSpecs = fs.realpathSync(specsDir);
332
341
  } catch (e) {
333
- if (explicitFeature || explicitPath) {
342
+ if (hasExplicitTarget) {
334
343
  return { error: 'explicit_malformed', explicitFeature, explicitPath, reason: `specs root canonicalization error: ${e.message}` };
335
344
  }
336
345
  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}` };
337
346
  }
338
347
  }
339
348
 
340
- if (explicitFeature) {
349
+ if (hasExplicitFeature) {
341
350
  if (typeof explicitFeature !== 'string' || explicitFeature.trim() === '' || explicitFeature.includes('/') || explicitFeature.includes('\\') || explicitFeature.includes('..')) {
342
351
  return { error: 'explicit_malformed', explicitFeature, reason: `malformed feature name: ${explicitFeature}` };
343
352
  }
@@ -367,7 +376,7 @@ function resolveActiveSpec({ projectRoot, runtime, explicitFeature, explicitPath
367
376
  }
368
377
  }
369
378
 
370
- if (explicitPath) {
379
+ if (hasExplicitPath) {
371
380
  if (typeof explicitPath !== 'string' || explicitPath.trim() === '') {
372
381
  return { error: 'explicit_malformed', explicitPath, reason: 'empty explicit path' };
373
382
  }
@@ -441,10 +450,52 @@ function resolveActiveSpec({ projectRoot, runtime, explicitFeature, explicitPath
441
450
  };
442
451
  }
443
452
 
453
+ /**
454
+ * Resolve one persisted feature identity. Explicit host targets are inspected
455
+ * directly and never scan siblings; only the no-target path performs a global
456
+ * persisted-candidate scan and therefore owns ambiguity/invalid-sibling errors.
457
+ */
458
+ function resolvePersistedSpec({ projectRoot, runtime, explicitFeature, explicitPath, target } = {}) {
459
+ const normalized = extractExplicitTarget(
460
+ target,
461
+ explicitFeature !== undefined ? { explicitFeature } : null,
462
+ explicitPath !== undefined ? { explicitPath } : null,
463
+ );
464
+ if (normalized) {
465
+ const value = Object.prototype.hasOwnProperty.call(normalized, 'explicitFeature')
466
+ ? normalized.explicitFeature
467
+ : normalized.explicitPath;
468
+ if (value === null || value === undefined) {
469
+ return { error: 'explicit_malformed', ...normalized, reason: 'explicit target must be a non-empty string' };
470
+ }
471
+ return resolveActiveSpec({ projectRoot, runtime, ...normalized });
472
+ }
473
+ try {
474
+ const candidates = findAllSpecCandidates(projectRoot, runtime);
475
+ if (candidates.length === 0) return null;
476
+ if (candidates.length === 1) return candidates[0];
477
+ return {
478
+ error: 'multiple_persisted',
479
+ candidates: candidates.map((candidate) => candidate.featureName),
480
+ reason: `Multiple persisted specs found: ${candidates.map((candidate) => candidate.featureName).join(', ')}. Provide explicit feature.`,
481
+ };
482
+ } catch (error) {
483
+ return {
484
+ error: 'invalid_specs',
485
+ candidates: Array.isArray(error.invalid)
486
+ ? error.invalid.map((entry) => entry.featureName)
487
+ : ['<specs>'],
488
+ invalid: error.invalid || [],
489
+ reason: error.message,
490
+ };
491
+ }
492
+ }
493
+
444
494
  module.exports = {
445
495
  specsDirectory,
446
496
  findAllActiveSpecs,
447
497
  findAllSpecCandidates,
448
498
  extractExplicitTarget,
449
499
  resolveActiveSpec,
500
+ resolvePersistedSpec,
450
501
  };