@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.
- package/README.md +21 -58
- package/bin/__tests__/change-firewall.semantic-firewall.test.js +4 -5
- package/bin/__tests__/codex-hooks.test.js +382 -0
- package/bin/__tests__/codex-native.test.js +579 -80
- package/bin/__tests__/develop-contract.test.js +155 -76
- package/bin/__tests__/installer-safety.test.js +19 -154
- package/bin/__tests__/package-inventory.test.js +494 -26
- package/bin/__tests__/release-preflight.semantic-firewall.test.js +2 -2
- package/bin/__tests__/validator-grounding.test.js +11 -11
- package/bin/install.js +2 -7
- package/bin/lib/context.js +0 -45
- package/bin/lib/copy-utils.js +1 -1
- package/bin/lib/i18n.js +0 -3
- package/bin/lib/manifest.js +1 -1
- package/bin/phases/claude-runtime.js +1 -3
- package/bin/phases/codex-runtime.js +4 -2
- package/bin/phases/copy-payload.js +3 -15
- package/bin/phases/post-install.js +5 -17
- package/bin/phases/root-config.js +2 -3
- package/bin/phases/select-platform.js +2 -5
- package/bin/phases/summary.js +0 -1
- package/package.json +2 -5
- package/src/claude/CLAUDE.md +19 -6
- package/src/claude/agents/project-manager.md +41 -9
- package/src/claude/agents/spec-maker.md +82 -155
- package/src/claude/gitignore +1 -1
- package/src/claude/hooks/__tests__/spec-gate.test.js +238 -0
- package/src/claude/hooks/__tests__/state.test.js +249 -3
- package/src/claude/hooks/lib/context.cjs +1 -1
- package/src/claude/hooks/lib/detect.cjs +1 -1
- package/src/claude/hooks/spec-gate.cjs +44 -9
- package/src/claude/hooks/spec-state.cjs +51 -13
- package/src/claude/hooks/task-scaffold-guard.cjs +1 -1
- package/src/claude/migration-manifest.json +17 -3
- package/src/claude/rules/manage-docs.md +43 -24
- package/src/claude/rules/state-sync.md +57 -62
- package/src/claude/rules/workflow.md +30 -13
- package/src/claude/scripts/change-firewall.cjs +2 -2
- package/src/claude/scripts/generate-skill-catalog.cjs +0 -1
- package/src/claude/scripts/spec-receipt.cjs +196 -1
- package/src/claude/scripts/spec-resolver.cjs +428 -0
- package/src/claude/skills/develop/SKILL.md +89 -125
- package/src/claude/skills/develop/references/parallel-waves.md +75 -162
- package/src/claude/skills/develop/references/quality-gate.md +61 -121
- package/src/claude/skills/develop/references/subagent-patterns.md +31 -38
- package/src/claude/skills/question/SKILL.md +1 -1
- package/src/claude/skills/specs/SKILL.md +152 -204
- package/src/claude/skills/specs/references/review.md +124 -243
- package/src/claude/skills/specs/references/templates.md +202 -0
- package/src/claude/skills/sync/SKILL.md +66 -90
- package/src/claude/skills/sync/references/sync-protocols.md +62 -83
- package/src/codex/AGENTS.md +20 -7
- package/src/codex/hooks/lib/spec-receipt.cjs +18 -0
- package/src/codex/hooks/lib/spec-utils.cjs +25 -0
- package/src/codex/hooks/spec-gate.cjs +46 -16
- package/src/codex/hooks/spec-state.cjs +54 -16
- package/src/codex/rules/state-sync.md +57 -61
- package/src/common/AGENTS.md +8 -1
- package/bin/__tests__/opencode-spec-gate.test.js +0 -799
- package/bin/lib/opencode-install.js +0 -899
- package/bin/phases/opencode-runtime.js +0 -38
- package/src/claude/skills/specs/references/archive-workflow.md +0 -74
- package/src/claude/skills/specs/references/ask-user-question-gates.md +0 -70
- package/src/claude/skills/specs/references/codebase-analysis.md +0 -102
- package/src/claude/skills/specs/references/cross-spec-dependency.md +0 -76
- package/src/claude/skills/specs/references/research-strategy.md +0 -107
- package/src/claude/skills/specs/references/scope-inquiry.md +0 -107
- package/src/claude/skills/specs/references/translation-mirror.md +0 -83
- package/src/claude/skills/specs/rules/design-discovery-full.md +0 -45
- package/src/claude/skills/specs/rules/design-discovery-light.md +0 -28
- package/src/claude/skills/specs/rules/design-principles.md +0 -138
- package/src/claude/skills/specs/rules/design-review.md +0 -55
- package/src/claude/skills/specs/rules/ears-format.md +0 -42
- package/src/claude/skills/specs/rules/phase-decision-matrix.md +0 -56
- package/src/claude/skills/specs/rules/task-scoring-rubric.md +0 -52
- package/src/claude/skills/specs/rules/tasks-generation.md +0 -189
- package/src/opencode/AGENTS.md +0 -41
- package/src/opencode/plugins/docs-sync.ts +0 -175
- package/src/opencode/plugins/inspect-block.ts +0 -164
- package/src/opencode/plugins/privacy-block.ts +0 -182
- package/src/opencode/plugins/rules.ts +0 -155
- package/src/opencode/plugins/session.ts +0 -179
- package/src/opencode/plugins/spec-gate.ts +0 -457
- package/src/opencode/plugins/spec-state.ts +0 -311
- package/src/opencode/plugins/state.ts +0 -253
- package/src/opencode/plugins/task-scaffold-guard.ts +0 -161
- 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
|
};
|