@haposoft/cafekit 0.16.0-rc.6 → 0.16.0-rc.8

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 (87) hide show
  1. package/README.md +21 -58
  2. package/bin/__tests__/change-firewall.semantic-firewall.test.js +4 -5
  3. package/bin/__tests__/codex-hooks.test.js +382 -0
  4. package/bin/__tests__/codex-native.test.js +579 -80
  5. package/bin/__tests__/develop-contract.test.js +155 -76
  6. package/bin/__tests__/installer-safety.test.js +19 -154
  7. package/bin/__tests__/package-inventory.test.js +494 -26
  8. package/bin/__tests__/release-preflight.semantic-firewall.test.js +2 -2
  9. package/bin/__tests__/validator-grounding.test.js +11 -11
  10. package/bin/install.js +2 -7
  11. package/bin/lib/context.js +0 -45
  12. package/bin/lib/copy-utils.js +1 -1
  13. package/bin/lib/i18n.js +0 -3
  14. package/bin/lib/manifest.js +1 -1
  15. package/bin/phases/claude-runtime.js +1 -3
  16. package/bin/phases/codex-runtime.js +4 -2
  17. package/bin/phases/copy-payload.js +3 -15
  18. package/bin/phases/post-install.js +5 -17
  19. package/bin/phases/root-config.js +2 -3
  20. package/bin/phases/select-platform.js +2 -5
  21. package/bin/phases/summary.js +0 -1
  22. package/package.json +2 -5
  23. package/src/claude/CLAUDE.md +19 -6
  24. package/src/claude/agents/project-manager.md +41 -9
  25. package/src/claude/agents/spec-maker.md +82 -155
  26. package/src/claude/gitignore +1 -1
  27. package/src/claude/hooks/__tests__/spec-gate.test.js +238 -0
  28. package/src/claude/hooks/__tests__/state.test.js +249 -3
  29. package/src/claude/hooks/lib/context.cjs +1 -1
  30. package/src/claude/hooks/lib/detect.cjs +1 -1
  31. package/src/claude/hooks/spec-gate.cjs +44 -9
  32. package/src/claude/hooks/spec-state.cjs +51 -13
  33. package/src/claude/hooks/task-scaffold-guard.cjs +1 -1
  34. package/src/claude/migration-manifest.json +17 -3
  35. package/src/claude/rules/manage-docs.md +43 -24
  36. package/src/claude/rules/state-sync.md +57 -62
  37. package/src/claude/rules/workflow.md +30 -13
  38. package/src/claude/scripts/change-firewall.cjs +2 -2
  39. package/src/claude/scripts/generate-skill-catalog.cjs +0 -1
  40. package/src/claude/scripts/spec-receipt.cjs +196 -1
  41. package/src/claude/scripts/spec-resolver.cjs +428 -0
  42. package/src/claude/skills/develop/SKILL.md +89 -125
  43. package/src/claude/skills/develop/references/parallel-waves.md +75 -162
  44. package/src/claude/skills/develop/references/quality-gate.md +61 -121
  45. package/src/claude/skills/develop/references/subagent-patterns.md +31 -38
  46. package/src/claude/skills/question/SKILL.md +1 -1
  47. package/src/claude/skills/specs/SKILL.md +152 -204
  48. package/src/claude/skills/specs/references/review.md +124 -243
  49. package/src/claude/skills/specs/references/templates.md +202 -0
  50. package/src/claude/skills/sync/SKILL.md +66 -90
  51. package/src/claude/skills/sync/references/sync-protocols.md +62 -83
  52. package/src/codex/AGENTS.md +20 -7
  53. package/src/codex/hooks/lib/spec-receipt.cjs +18 -0
  54. package/src/codex/hooks/lib/spec-utils.cjs +25 -0
  55. package/src/codex/hooks/spec-gate.cjs +46 -16
  56. package/src/codex/hooks/spec-state.cjs +54 -16
  57. package/src/codex/rules/state-sync.md +57 -61
  58. package/src/common/AGENTS.md +8 -1
  59. package/bin/__tests__/opencode-spec-gate.test.js +0 -799
  60. package/bin/lib/opencode-install.js +0 -899
  61. package/bin/phases/opencode-runtime.js +0 -38
  62. package/src/claude/skills/specs/references/archive-workflow.md +0 -74
  63. package/src/claude/skills/specs/references/ask-user-question-gates.md +0 -70
  64. package/src/claude/skills/specs/references/codebase-analysis.md +0 -102
  65. package/src/claude/skills/specs/references/cross-spec-dependency.md +0 -76
  66. package/src/claude/skills/specs/references/research-strategy.md +0 -107
  67. package/src/claude/skills/specs/references/scope-inquiry.md +0 -107
  68. package/src/claude/skills/specs/references/translation-mirror.md +0 -83
  69. package/src/claude/skills/specs/rules/design-discovery-full.md +0 -45
  70. package/src/claude/skills/specs/rules/design-discovery-light.md +0 -28
  71. package/src/claude/skills/specs/rules/design-principles.md +0 -138
  72. package/src/claude/skills/specs/rules/design-review.md +0 -55
  73. package/src/claude/skills/specs/rules/ears-format.md +0 -42
  74. package/src/claude/skills/specs/rules/phase-decision-matrix.md +0 -56
  75. package/src/claude/skills/specs/rules/task-scoring-rubric.md +0 -52
  76. package/src/claude/skills/specs/rules/tasks-generation.md +0 -189
  77. package/src/opencode/AGENTS.md +0 -41
  78. package/src/opencode/plugins/docs-sync.ts +0 -175
  79. package/src/opencode/plugins/inspect-block.ts +0 -164
  80. package/src/opencode/plugins/privacy-block.ts +0 -182
  81. package/src/opencode/plugins/rules.ts +0 -155
  82. package/src/opencode/plugins/session.ts +0 -179
  83. package/src/opencode/plugins/spec-gate.ts +0 -457
  84. package/src/opencode/plugins/spec-state.ts +0 -311
  85. package/src/opencode/plugins/state.ts +0 -253
  86. package/src/opencode/plugins/task-scaffold-guard.ts +0 -161
  87. package/src/opencode/plugins/usage.ts +0 -178
@@ -68,6 +68,244 @@ function candidateError(reason, code = 'malformed') {
68
68
  return error;
69
69
  }
70
70
 
71
+ const WORKFLOW_STATUSES = new Set(['pending', 'in_progress', 'paused', 'blocked', 'done']);
72
+ const WORKFLOW_TASK_NAME = /^task-[A-Za-z0-9][A-Za-z0-9._-]*\.md$/;
73
+ const WORKFLOW_CONTRACT = 'process-first-ready-v1';
74
+
75
+ function annotatedMarkdownLines(content) {
76
+ let fence = null;
77
+ return String(content || '').split(/\r?\n/).map((line, index) => {
78
+ const fenceMatch = line.match(/^ {0,3}(`{3,}|~{3,})(.*)$/);
79
+ const outsideFence = fence === null && !fenceMatch;
80
+ let fenceEvent = null;
81
+ if (fenceMatch) {
82
+ const marker = fenceMatch[1];
83
+ if (!fence) {
84
+ fence = { char: marker[0], length: marker.length };
85
+ fenceEvent = 'open';
86
+ } else if (marker[0] === fence.char && marker.length >= fence.length && fenceMatch[2].trim() === '') {
87
+ fence = null;
88
+ fenceEvent = 'close';
89
+ }
90
+ }
91
+ return { index, line, outsideFence, fenceEvent };
92
+ });
93
+ }
94
+
95
+ function workflowPlanContract(content) {
96
+ const markers = annotatedMarkdownLines(content)
97
+ .filter(({ line, outsideFence }) => outsideFence && /^\s*Specs-Contract\s*:/i.test(line))
98
+ .map(({ line }) => line);
99
+ if (markers.length === 0) return { workflowContract: null, queueReady: false };
100
+ if (markers.length !== 1 || markers[0] !== `Specs-Contract: ${WORKFLOW_CONTRACT}`) {
101
+ throw candidateError(`plan.md must contain exactly one Specs-Contract: ${WORKFLOW_CONTRACT} marker`);
102
+ }
103
+ return { workflowContract: WORKFLOW_CONTRACT, queueReady: true };
104
+ }
105
+
106
+ function workflowTaskStatus(content, taskName) {
107
+ const values = annotatedMarkdownLines(content)
108
+ .filter(({ outsideFence }) => outsideFence)
109
+ .map(({ line }) => line.match(/^\s*(?:\*\*)?Status(?:\*\*)?\s*:\s*(?:\*\*)?\s*([A-Za-z_-]+)(?:\*\*)?\s*$/i))
110
+ .filter(Boolean)
111
+ .map((match) => match[1].toLowerCase().replaceAll('-', '_'));
112
+ if (values.length !== 1 || !WORKFLOW_STATUSES.has(values[0])) {
113
+ throw candidateError(`${taskName} must contain exactly one supported Status field`);
114
+ }
115
+ return values[0];
116
+ }
117
+
118
+ function workflowTaskNumericPrefix(taskName) {
119
+ const match = taskName.match(/^task-(\d+)(?:-|\.md$)/);
120
+ return match ? match[1].replace(/^0+(?=\d)/, '') : null;
121
+ }
122
+
123
+ function workflowTaskDependencies(content, taskName, taskNames, queueReady) {
124
+ const lines = annotatedMarkdownLines(content);
125
+ let sectionStart = -1;
126
+ let sectionEnd = lines.length;
127
+ let sectionCount = 0;
128
+
129
+ for (let index = 0; index < lines.length; index += 1) {
130
+ if (!lines[index].outsideFence) continue;
131
+
132
+ const heading = lines[index].line.match(/^(#{1,6})\s+(.+?)\s*$/);
133
+ if (!heading || heading[1].length > 2) continue;
134
+ const title = heading[2].trim().toLowerCase();
135
+ if (heading[1].length === 2 && title === 'dependencies') {
136
+ sectionCount += 1;
137
+ if (sectionStart < 0) sectionStart = index + 1;
138
+ continue;
139
+ }
140
+ if (sectionStart >= 0 && sectionEnd === lines.length) sectionEnd = index;
141
+ }
142
+
143
+ if (!queueReady && sectionCount === 0) return [];
144
+ if (sectionCount !== 1 || sectionStart < 0) {
145
+ throw candidateError(`${taskName} must contain exactly one ## Dependencies section`);
146
+ }
147
+
148
+ const entries = lines.slice(sectionStart, sectionEnd)
149
+ .filter(({ outsideFence }) => outsideFence)
150
+ .map(({ line }) => line.trim())
151
+ .filter(Boolean)
152
+ .map((line) => {
153
+ const bullet = line.match(/^-\s+(.+?)\s*$/);
154
+ if (!bullet) throw candidateError(`${taskName} Dependencies must contain only bullet entries`);
155
+ const value = bullet[1].replace(/^`([^`]+)`$/, '$1').trim();
156
+ if (!value) throw candidateError(`${taskName} contains an empty dependency`);
157
+ return value;
158
+ });
159
+
160
+ if (entries.length === 1 && entries[0].toLowerCase() === 'none') return [];
161
+ if (entries.length === 0 || entries.some((entry) => entry.toLowerCase() === 'none')) {
162
+ throw candidateError(`${taskName} must use either - none or exact task basenames`);
163
+ }
164
+ const resolved = entries.map((entry) => {
165
+ if (WORKFLOW_TASK_NAME.test(entry)) return entry;
166
+ const legacy = !queueReady && entry.match(/^(?:Task\s+)?(\d+)$/i);
167
+ if (!legacy) throw candidateError(`${taskName} dependencies must be exact flat task basenames`);
168
+ const numericId = legacy[1].replace(/^0+(?=\d)/, '');
169
+ const matches = taskNames.filter((candidate) => workflowTaskNumericPrefix(candidate) === numericId);
170
+ if (matches.length !== 1) {
171
+ throw candidateError(`${taskName} legacy dependency ${entry} maps to ${matches.length} task basenames`);
172
+ }
173
+ return matches[0];
174
+ });
175
+ if (new Set(resolved).size !== resolved.length) {
176
+ throw candidateError(`${taskName} contains duplicate dependencies`);
177
+ }
178
+ return resolved;
179
+ }
180
+
181
+ function assertAcyclicWorkflow(tasks) {
182
+ const dependencies = new Map(tasks.map((task) => [task.path, [...task.dependencies].sort()]));
183
+ const state = new Map();
184
+ const stack = [];
185
+
186
+ function visit(taskPath) {
187
+ state.set(taskPath, 'visiting');
188
+ stack.push(taskPath);
189
+ for (const dependency of dependencies.get(taskPath) || []) {
190
+ if (state.get(dependency) === 'visiting') {
191
+ const start = stack.indexOf(dependency);
192
+ throw candidateError(`dependency cycle detected: ${[...stack.slice(start), dependency].join(' -> ')}`);
193
+ }
194
+ if (state.get(dependency) !== 'visited') visit(dependency);
195
+ }
196
+ stack.pop();
197
+ state.set(taskPath, 'visited');
198
+ }
199
+
200
+ for (const taskPath of [...dependencies.keys()].sort()) {
201
+ if (!state.has(taskPath)) visit(taskPath);
202
+ }
203
+ }
204
+
205
+ function inspectWorkflowFeature(specsDir, canonicalSpecs, requestedName) {
206
+ const featureDir = path.join(specsDir, requestedName);
207
+ let featureLstat;
208
+ try {
209
+ featureLstat = fs.lstatSync(featureDir);
210
+ } catch (error) {
211
+ if (isMissingError(error)) return { missing: true };
212
+ throw candidateError(`feature lstat error: ${error.message}`);
213
+ }
214
+ if (featureLstat.isSymbolicLink() || !featureLstat.isDirectory()) {
215
+ throw candidateError('workflow feature entry must be a regular directory');
216
+ }
217
+
218
+ const canonicalFeature = fs.realpathSync(featureDir);
219
+ if (!isPathInside(canonicalSpecs, canonicalFeature) || path.dirname(canonicalFeature) !== canonicalSpecs) {
220
+ throw candidateError('workflow feature directory must be a direct child of specs root');
221
+ }
222
+
223
+ const featureName = path.basename(canonicalFeature);
224
+ if (fs.existsSync(path.join(canonicalFeature, 'spec.json'))) {
225
+ return { legacyPresent: true, featureName, featureDir: canonicalFeature };
226
+ }
227
+
228
+ const planFile = path.join(canonicalFeature, 'plan.md');
229
+ let planLstat;
230
+ try {
231
+ planLstat = fs.lstatSync(planFile);
232
+ } catch (error) {
233
+ if (isMissingError(error)) return { missingWorkflow: true, featureName, featureDir: canonicalFeature };
234
+ throw candidateError(`plan.md lstat error: ${error.message}`);
235
+ }
236
+ if (planLstat.isSymbolicLink() || !planLstat.isFile() || fs.realpathSync(planFile) !== planFile) {
237
+ throw candidateError('plan.md must be a regular non-symlink file in the workflow feature directory');
238
+ }
239
+ const { workflowContract, queueReady } = workflowPlanContract(fs.readFileSync(planFile, 'utf8'));
240
+
241
+ const taskNames = fs.readdirSync(canonicalFeature, { withFileTypes: true })
242
+ .filter((entry) => WORKFLOW_TASK_NAME.test(entry.name))
243
+ .map((entry) => entry.name)
244
+ .sort();
245
+ if (taskNames.length === 0) {
246
+ return { missingWorkflow: true, featureName, featureDir: canonicalFeature };
247
+ }
248
+
249
+ const tasks = taskNames.map((taskName) => {
250
+ const taskFile = path.join(canonicalFeature, taskName);
251
+ const taskLstat = fs.lstatSync(taskFile);
252
+ if (taskLstat.isSymbolicLink() || !taskLstat.isFile() || fs.realpathSync(taskFile) !== taskFile) {
253
+ throw candidateError(`${taskName} must be a regular non-symlink file in the workflow feature directory`);
254
+ }
255
+ const taskContent = fs.readFileSync(taskFile, 'utf8');
256
+ return {
257
+ path: taskName,
258
+ status: workflowTaskStatus(taskContent, taskName),
259
+ dependencies: workflowTaskDependencies(taskContent, taskName, taskNames, queueReady),
260
+ };
261
+ });
262
+ const taskNameSet = new Set(taskNames);
263
+ for (const task of tasks) {
264
+ if (task.dependencies.includes(task.path)) {
265
+ throw candidateError(`${task.path} cannot depend on itself`);
266
+ }
267
+ const missing = task.dependencies.find((dependency) => !taskNameSet.has(dependency));
268
+ if (missing) throw candidateError(`${task.path} references missing dependency ${missing}`);
269
+ }
270
+ assertAcyclicWorkflow(tasks);
271
+ const taskRegistry = Object.fromEntries(tasks.map((task) => [task.path, {
272
+ status: task.status,
273
+ dependencies: task.dependencies,
274
+ }]));
275
+ const allTasksDone = tasks.every((task) => task.status === 'done');
276
+ return {
277
+ layoutKind: 'process-v3',
278
+ featureName,
279
+ specsDir: canonicalSpecs,
280
+ featureDir: canonicalFeature,
281
+ planFile,
282
+ stateFile: planFile,
283
+ phase: allTasksDone ? 'done' : 'execution',
284
+ taskRegistry,
285
+ tasks,
286
+ allTasksDone,
287
+ workflowContract,
288
+ queueReady,
289
+ };
290
+ }
291
+
292
+ function normalizeLegacyWorkflowCandidate(candidate) {
293
+ const registry = candidate.spec?.task_registry || {};
294
+ const tasks = Object.entries(registry).map(([taskPath, task]) => ({
295
+ path: taskPath,
296
+ status: task?.status || 'pending',
297
+ }));
298
+ return {
299
+ ...candidate,
300
+ layoutKind: 'legacy-spec',
301
+ stateFile: candidate.specFile,
302
+ phase: candidate.spec?.current_phase || candidate.spec?.phase || 'unknown',
303
+ taskRegistry: registry,
304
+ tasks,
305
+ allTasksDone: tasks.length > 0 && tasks.every((task) => task.status === 'done'),
306
+ };
307
+ }
308
+
71
309
  function inspectFeature(specsDir, canonicalSpecs, requestedName) {
72
310
  const featureDir = path.join(specsDir, requestedName);
73
311
  let featureLstat;
@@ -491,11 +729,201 @@ function resolvePersistedSpec({ projectRoot, runtime, explicitFeature, explicitP
491
729
  }
492
730
  }
493
731
 
732
+ function scanWorkflowFeatures(specsDir) {
733
+ let canonicalSpecs;
734
+ try {
735
+ canonicalSpecs = fs.realpathSync(specsDir);
736
+ } catch (error) {
737
+ if (isMissingError(error)) return { active: [], candidates: [], invalid: [] };
738
+ return { active: [], candidates: [], invalid: [{ featureName: '<specs>', reason: error.message }] };
739
+ }
740
+
741
+ const active = [];
742
+ const candidates = [];
743
+ const invalid = [];
744
+ const entries = fs.readdirSync(canonicalSpecs, { withFileTypes: true })
745
+ .filter((entry) => entry.isDirectory() || entry.isSymbolicLink())
746
+ .sort((left, right) => left.name.localeCompare(right.name));
747
+ for (const entry of entries) {
748
+ try {
749
+ const candidate = inspectWorkflowFeature(canonicalSpecs, canonicalSpecs, entry.name);
750
+ if (candidate.missing || candidate.missingWorkflow || candidate.legacyPresent) continue;
751
+ candidates.push(candidate);
752
+ if (!candidate.allTasksDone) active.push(candidate);
753
+ } catch (error) {
754
+ invalid.push({ featureName: entry.name, reason: error.message, planFile: path.join(canonicalSpecs, entry.name, 'plan.md') });
755
+ }
756
+ }
757
+ return { active, candidates, invalid };
758
+ }
759
+
760
+ function explicitWorkflowFeatureName({ projectRoot, specsDir, explicitFeature, explicitPath }) {
761
+ if (explicitFeature !== undefined && explicitFeature !== null) {
762
+ if (typeof explicitFeature !== 'string' || explicitFeature.trim() === ''
763
+ || explicitFeature.includes('/') || explicitFeature.includes('\\') || explicitFeature.includes('..')) {
764
+ return { error: 'explicit_malformed', explicitFeature, reason: `malformed feature name: ${explicitFeature}` };
765
+ }
766
+ return { featureName: explicitFeature.trim() };
767
+ }
768
+
769
+ if (typeof explicitPath !== 'string' || explicitPath.trim() === '') {
770
+ return { error: 'explicit_malformed', explicitPath, reason: 'empty explicit path' };
771
+ }
772
+ const targetPath = path.resolve(projectRoot, explicitPath);
773
+ let targetLstat;
774
+ try {
775
+ targetLstat = fs.lstatSync(targetPath);
776
+ } catch (error) {
777
+ return isMissingError(error)
778
+ ? { error: 'explicit_not_found', explicitPath, reason: `workflow target not found at ${explicitPath}` }
779
+ : { error: 'explicit_malformed', explicitPath, reason: `explicit path stat error: ${error.message}` };
780
+ }
781
+ if (targetLstat.isSymbolicLink()) {
782
+ return { error: 'explicit_malformed', explicitPath, reason: 'explicit workflow target must not be a symlink' };
783
+ }
784
+
785
+ let featureDir;
786
+ if (targetLstat.isDirectory()) {
787
+ featureDir = targetPath;
788
+ } else if (targetLstat.isFile()
789
+ && (path.basename(targetPath) === 'plan.md' || WORKFLOW_TASK_NAME.test(path.basename(targetPath)))) {
790
+ featureDir = path.dirname(targetPath);
791
+ } else {
792
+ return { error: 'explicit_malformed', explicitPath, reason: 'explicit workflow path must target a feature directory, plan.md, or flat task-*.md' };
793
+ }
794
+ const relative = path.relative(path.resolve(specsDir), featureDir);
795
+ if (!relative || relative.includes(path.sep) || relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
796
+ return { error: 'explicit_malformed', explicitPath, reason: 'explicit workflow path must target a direct child of specs root' };
797
+ }
798
+ return { featureName: relative };
799
+ }
800
+
801
+ /**
802
+ * Resolve the authoring/execution workflow visible to prompt and Stop hooks.
803
+ * Persisted spec resolution remains unchanged; process-v3 is an additive adapter
804
+ * and never becomes completion-authority input by way of findAllSpecCandidates.
805
+ */
806
+ function resolveWorkflowCandidate({ projectRoot, runtime, explicitFeature, explicitPath, target, includeCompleted = false } = {}) {
807
+ if (!projectRoot) throw new TypeError('projectRoot required');
808
+ const normalized = extractExplicitTarget(
809
+ target,
810
+ explicitFeature !== undefined ? { explicitFeature } : null,
811
+ explicitPath !== undefined ? { explicitPath } : null,
812
+ );
813
+ if (normalized) {
814
+ if (Object.prototype.hasOwnProperty.call(normalized, 'explicitFeature')) explicitFeature = normalized.explicitFeature;
815
+ if (Object.prototype.hasOwnProperty.call(normalized, 'explicitPath')) explicitPath = normalized.explicitPath;
816
+ }
817
+ const hasExplicit = explicitFeature !== undefined && explicitFeature !== null
818
+ || explicitPath !== undefined && explicitPath !== null;
819
+ let specsDir;
820
+ try {
821
+ specsDir = specsDirectory(projectRoot, runtime || {});
822
+ } catch (error) {
823
+ return { error: hasExplicit ? 'explicit_malformed' : 'invalid_specs', candidates: ['<specs>'], reason: error.message };
824
+ }
825
+
826
+ if (hasExplicit) {
827
+ const legacyDirect = resolveActiveSpec({ projectRoot, runtime, explicitFeature, explicitPath });
828
+ if (legacyDirect && !legacyDirect.error) return normalizeLegacyWorkflowCandidate(legacyDirect);
829
+ const identity = explicitWorkflowFeatureName({ projectRoot, specsDir, explicitFeature, explicitPath });
830
+ if (identity.error) return identity;
831
+
832
+ const legacy = resolveActiveSpec({ projectRoot, runtime, explicitFeature: identity.featureName });
833
+ if (legacy && !legacy.error) return normalizeLegacyWorkflowCandidate(legacy);
834
+ if (legacy && legacy.error !== 'explicit_not_found') return legacy;
835
+
836
+ let canonicalSpecs;
837
+ try {
838
+ canonicalSpecs = fs.realpathSync(specsDir);
839
+ const candidate = inspectWorkflowFeature(specsDir, canonicalSpecs, identity.featureName);
840
+ if (candidate.missing || candidate.missingWorkflow || candidate.legacyPresent) {
841
+ return { error: 'explicit_not_found', explicitFeature: identity.featureName, explicitPath, reason: `workflow not found for feature ${identity.featureName}` };
842
+ }
843
+ return candidate;
844
+ } catch (error) {
845
+ return { error: 'explicit_malformed', explicitFeature: identity.featureName, explicitPath, reason: error.message };
846
+ }
847
+ }
848
+
849
+ let legacyActive;
850
+ try {
851
+ const legacyCandidates = includeCompleted
852
+ ? findAllSpecCandidates(projectRoot, runtime || {})
853
+ : findAllActiveSpecs(projectRoot, runtime || {});
854
+ legacyActive = legacyCandidates.map(normalizeLegacyWorkflowCandidate);
855
+ } catch (error) {
856
+ return {
857
+ error: 'invalid_specs',
858
+ candidates: Array.isArray(error.invalid) ? error.invalid.map((entry) => entry.featureName) : ['<specs>'],
859
+ invalid: error.invalid || [],
860
+ reason: error.message,
861
+ };
862
+ }
863
+ const workflow = scanWorkflowFeatures(specsDir);
864
+ if (workflow.invalid.length > 0) {
865
+ return {
866
+ error: 'invalid_specs',
867
+ candidates: workflow.invalid.map((entry) => entry.featureName),
868
+ invalid: workflow.invalid,
869
+ reason: `Invalid workflow candidates: ${workflow.invalid.map((entry) => `${entry.featureName}: ${entry.reason}`).join('; ')}`,
870
+ };
871
+ }
872
+ const workflowCandidates = includeCompleted ? workflow.candidates : workflow.active;
873
+ const active = [...legacyActive, ...workflowCandidates]
874
+ .sort((left, right) => left.featureName.localeCompare(right.featureName));
875
+ if (active.length === 0) return null;
876
+ if (active.length === 1) return active[0];
877
+ return {
878
+ error: includeCompleted ? 'multiple_persisted' : 'multiple_active',
879
+ candidates: active.map((candidate) => candidate.featureName),
880
+ active,
881
+ reason: `Multiple ${includeCompleted ? 'persisted' : 'active'} workflows found: ${active.map((candidate) => candidate.featureName).join(', ')}. Provide explicit feature.`,
882
+ };
883
+ }
884
+
885
+ /**
886
+ * Narrow a Stop-gate ambiguity without changing legacy persisted-spec rules.
887
+ * A single unfinished process-v3 packet is the current workflow; completed
888
+ * packets are historical. If every process-v3 packet is complete, return the
889
+ * set so the gate can revalidate all receipts instead of blocking on identity.
890
+ */
891
+ function refineWorkflowGateResolution(resolved) {
892
+ if (!resolved || resolved.error !== 'multiple_persisted' || !Array.isArray(resolved.active)) {
893
+ return resolved;
894
+ }
895
+ const candidates = resolved.active;
896
+ if (candidates.length === 0 || candidates.some((candidate) => candidate.layoutKind !== 'process-v3')) {
897
+ return resolved;
898
+ }
899
+
900
+ const unfinished = candidates.filter((candidate) => !candidate.allTasksDone);
901
+ if (unfinished.length === 1) return unfinished[0];
902
+ if (unfinished.length > 1) {
903
+ return {
904
+ ...resolved,
905
+ error: 'multiple_active',
906
+ candidates: unfinished.map((candidate) => candidate.featureName),
907
+ active: unfinished,
908
+ reason: `Multiple active workflows found: ${unfinished.map((candidate) => candidate.featureName).join(', ')}. Provide explicit feature.`,
909
+ };
910
+ }
911
+ return {
912
+ layoutKind: 'process-v3-completed-set',
913
+ candidates,
914
+ specsDir: candidates[0].specsDir,
915
+ allTasksDone: true,
916
+ };
917
+ }
918
+
494
919
  module.exports = {
920
+ annotatedMarkdownLines,
495
921
  specsDirectory,
496
922
  findAllActiveSpecs,
497
923
  findAllSpecCandidates,
498
924
  extractExplicitTarget,
499
925
  resolveActiveSpec,
500
926
  resolvePersistedSpec,
927
+ resolveWorkflowCandidate,
928
+ refineWorkflowGateResolution,
501
929
  };