@polymorphism-tech/morph-spec 7.2.0 → 7.4.0

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 (74) hide show
  1. package/CLAUDE.md +1 -1
  2. package/README.md +2 -2
  3. package/bin/morph-spec.js +4 -2
  4. package/claude-plugin.json +1 -1
  5. package/docs/CHEATSHEET.md +1 -1
  6. package/docs/QUICKSTART.md +1 -1
  7. package/framework/CLAUDE.md +2 -2
  8. package/framework/MORPH.md +29 -12
  9. package/framework/agents.json +132 -132
  10. package/framework/commands/morph-apply.md +1 -1
  11. package/framework/commands/morph-proposal.md +1 -1
  12. package/framework/evals/rubrics/README.md +9 -8
  13. package/framework/hooks/README.md +3 -1
  14. package/framework/hooks/claude-code/core/gate-guard.js +160 -0
  15. package/framework/hooks/claude-code/core/state-sync.js +15 -3
  16. package/framework/hooks/claude-code/core/validate-completion.js +241 -136
  17. package/framework/hooks/shared/state-reader.js +236 -221
  18. package/framework/phases.json +1 -1
  19. package/framework/skills/README.md +2 -2
  20. package/framework/skills/core/morph-apply/SKILL.md +63 -21
  21. package/framework/skills/core/morph-brainstorming/SKILL.md +5 -1
  22. package/framework/skills/core/morph-eval/SKILL.md +4 -5
  23. package/framework/skills/core/morph-implement/SKILL.md +47 -19
  24. package/framework/skills/core/morph-plan/SKILL.md +8 -2
  25. package/framework/skills/core/morph-proposal/SKILL.md +1 -1
  26. package/framework/skills/core/morph-review/SKILL.md +13 -6
  27. package/framework/skills/core/morph-stack-scan/SKILL.md +1 -1
  28. package/framework/skills/core/morph-standards/SKILL.md +1 -1
  29. package/framework/standards/ai-agents/setup.md +258 -244
  30. package/framework/templates/README.md +708 -709
  31. package/framework/templates/REGISTRY.json +1 -20
  32. package/framework/templates/context/CONTEXT-FEATURE.md +276 -276
  33. package/framework/templates/docs/proposal.md +171 -171
  34. package/framework/templates/feature/recap.md +145 -145
  35. package/package.json +1 -1
  36. package/src/commands/project/doctor.js +941 -946
  37. package/src/commands/project/init.js +517 -515
  38. package/src/commands/project/status.js +373 -376
  39. package/src/commands/state/approve.js +251 -213
  40. package/src/commands/validation/validate-feature.js +356 -287
  41. package/src/core/state/state-manager.js +910 -934
  42. package/src/dashboard/app/App.jsx +29 -31
  43. package/src/dashboard/app/components/Sidebar.jsx +44 -45
  44. package/src/dashboard/scanner/index.js +20 -21
  45. package/src/lib/context/context-builder.js +3 -3
  46. package/src/lib/generators/context-generator.js +2 -3
  47. package/src/lib/generators/mandate-generator.js +48 -92
  48. package/src/lib/generators/metadata-extractor.js +385 -386
  49. package/src/lib/generators/recap-generator.js +285 -285
  50. package/src/lib/risk-detector.js +2 -2
  51. package/src/lib/validators/content/content-validator.js +89 -286
  52. package/src/lib/validators/content/index.js +0 -1
  53. package/src/utils/hooks-installer.js +527 -516
  54. package/framework/templates/feature/tasks.md +0 -199
  55. package/framework/workflows/README.md +0 -1041
  56. package/framework/workflows/configs/fast-track.json +0 -46
  57. package/framework/workflows/configs/full.json +0 -64
  58. package/framework/workflows/configs/quick.json +0 -32
  59. package/framework/workflows/configs/standard.json +0 -57
  60. package/framework/workflows/docs/STORY-DRIVEN-DEVELOPMENT.md +0 -392
  61. package/framework/workflows/docs/design-impl.md +0 -37
  62. package/framework/workflows/docs/enforcement-pipeline.md +0 -668
  63. package/framework/workflows/docs/fast-track.md +0 -29
  64. package/framework/workflows/docs/full-morph.md +0 -69
  65. package/framework/workflows/docs/standard.md +0 -49
  66. package/framework/workflows/docs/ui-refresh.md +0 -39
  67. package/src/commands/state/index.js +0 -7
  68. package/src/core/workflows/workflow-detector.js +0 -586
  69. package/src/core/workflows/workflow-router.js +0 -204
  70. package/src/dashboard/app/pages/Workflows.jsx +0 -346
  71. package/src/dashboard/scanner/workflows-scanner.js +0 -18
  72. package/src/lib/phase-chain/phase-validator.js +0 -359
  73. package/src/lib/validators/content/cross-phase-validator.js +0 -281
  74. package/src/lib/validators/spec-validator.js +0 -312
@@ -1,359 +0,0 @@
1
- /**
2
- * Phase Validator — centralized phase validation and eligibility checking.
3
- *
4
- * Exports: PHASES, validatePhase, computePassRate, checkPhaseEligibility
5
- *
6
- * Callers:
7
- * - src/commands/state/advance-phase.js
8
- * - src/commands/state/phase-runner.js
9
- * - src/commands/project/status.js
10
- */
11
-
12
- import fs from 'fs';
13
- import path, { join } from 'path';
14
- import { loadState, derivePhase, getFeature } from '../../core/state/state-manager.js';
15
- import { getWorkflowConfig } from '../../core/workflows/workflow-detector.js';
16
-
17
- // v5: trust-manager removed. Auto-approval is no longer a CLI feature —
18
- // approval gates are handled by Claude presenting the gate via skill template
19
- // and the user responding. Stub to keep legacy callers happy.
20
- const shouldAutoApprove = () => false;
21
-
22
- // ─────────────────────────────────────────────────────────────────────────────
23
- // Phase definitions (from validate-phase.js)
24
- // ─────────────────────────────────────────────────────────────────────────────
25
-
26
- export const PHASES = {
27
- 'proposal': {
28
- order: 0,
29
- name: 'FASE 0: PROPOSAL',
30
- requiredOutputs: [],
31
- description: 'Initial proposal and agent detection'
32
- },
33
- 'uiux': {
34
- order: 1,
35
- name: 'FASE 1: UI/UX DESIGN',
36
- requiredOutputs: ['proposal.md'],
37
- optionalOutputs: ['design-system.md', 'mockups.md', 'components.md', 'flows.md'],
38
- description: 'UI/UX design (conditional - only if frontend)',
39
- optional: true
40
- },
41
- 'plan': {
42
- order: 2,
43
- name: 'FASE 2: PLAN',
44
- requiredOutputs: ['proposal.md'],
45
- description: 'Technical specification, implementation plan, task breakdown, and mandate'
46
- },
47
- 'implement': {
48
- order: 3,
49
- name: 'FASE 3: IMPLEMENT',
50
- requiredOutputs: ['proposal.md', 'spec.md', 'tasks.md'],
51
- description: 'Execute tasks and implement code'
52
- },
53
- 'review': {
54
- order: 4,
55
- name: 'FASE 4: REVIEW',
56
- requiredOutputs: ['proposal.md', 'spec.md', 'mandate.md', 'tasks.md', 'recap.md', 'review-report.md'],
57
- description: 'Post-implementation quality review against mandate'
58
- }
59
- };
60
-
61
- // Map output filenames to their phase subfolders (v5)
62
- const OUTPUT_PHASE_MAP = {
63
- 'proposal.md': '0-proposal',
64
- 'stack-scan.md': '0-proposal',
65
- 'migration-plan.md': '0-proposal',
66
- 'spec.md': '2-plan',
67
- 'mandate.md': '2-plan',
68
- 'decisions.md': '2-plan',
69
- 'design-system.md': '1-design',
70
- 'mockups.md': '1-design',
71
- 'components.md': '1-design',
72
- 'flows.md': '1-design',
73
- 'tasks.md': '2-plan',
74
- 'recap.md': '3-implement',
75
- 'review-report.md': '4-review'
76
- };
77
-
78
- function checkOutput(featurePath, outputFile) {
79
- const phaseDir = OUTPUT_PHASE_MAP[outputFile] || '';
80
- const filePath = path.join(featurePath, phaseDir, outputFile);
81
- if (fs.existsSync(filePath)) return true;
82
- // Fallback: check feature root (some generators place files there instead of phase subdirs)
83
- if (phaseDir) {
84
- return fs.existsSync(path.join(featurePath, outputFile));
85
- }
86
- return false;
87
- }
88
-
89
- /**
90
- * Validate phase prerequisites for a feature.
91
- * Used by advance-phase.js before advancing.
92
- */
93
- export function validatePhase(featureName, targetPhase) {
94
- const featurePath = path.join(process.cwd(), '.morph/features', featureName);
95
-
96
- if (!fs.existsSync(featurePath)) {
97
- return {
98
- valid: false,
99
- error: `Feature directory not found: ${featurePath}`,
100
- suggestion: `Run 'morph-spec state init ${featureName}' to start`,
101
- missingOutputs: [],
102
- phase: null
103
- };
104
- }
105
-
106
- const phaseDefinition = PHASES[targetPhase];
107
- if (!phaseDefinition) {
108
- return {
109
- valid: false,
110
- error: `Unknown phase: ${targetPhase}`,
111
- validPhases: Object.keys(PHASES),
112
- missingOutputs: [],
113
- phase: null
114
- };
115
- }
116
-
117
- // Workflow-aware: only require outputs that the workflow declares as required.
118
- // Fast-track skips design/plan, so spec.md and plan.md won't exist.
119
- let requiredOutputs = phaseDefinition.requiredOutputs;
120
- try {
121
- const feature = getFeature(featureName);
122
- if (feature?.workflow && feature.workflow !== 'auto') {
123
- const wfConfig = getWorkflowConfig(feature.workflow);
124
- if (wfConfig?.outputs?.required) {
125
- const workflowRequired = new Set(wfConfig.outputs.required);
126
- requiredOutputs = requiredOutputs.filter(o => workflowRequired.has(o));
127
- }
128
- }
129
- } catch {
130
- // Fall through to default requiredOutputs
131
- }
132
-
133
- const missingOutputs = [];
134
- for (const output of requiredOutputs) {
135
- if (!checkOutput(featurePath, output)) {
136
- missingOutputs.push(output);
137
- }
138
- }
139
-
140
- // Special validation: implement phase requires tasks.md to have actual tasks
141
- if (targetPhase === 'implement') {
142
- const tasksFilePath = path.join(featurePath, '2-plan', 'tasks.md');
143
- if (fs.existsSync(tasksFilePath)) {
144
- const content = fs.readFileSync(tasksFilePath, 'utf-8');
145
- const taskCount = (content.match(/^###\s+T\d+/gm) || []).length;
146
- if (taskCount === 0) {
147
- missingOutputs.push(`tasks.md exists but has no task entries — add ### T001: Task title entries`);
148
- }
149
- }
150
- }
151
-
152
- // Warn if skipping phases
153
- let stateWarning = null;
154
- try {
155
- const feature = getFeature(featureName);
156
- if (feature) {
157
- const currentPhaseOrder = PHASES[feature.phase]?.order ?? -1;
158
- const targetPhaseOrder = phaseDefinition.order;
159
- if (targetPhaseOrder > currentPhaseOrder + 1) {
160
- stateWarning = `Skipping phases: current is '${feature.phase}' (order ${currentPhaseOrder}), target is '${targetPhase}' (order ${targetPhaseOrder})`;
161
- }
162
- }
163
- } catch {
164
- // State file may not exist
165
- }
166
-
167
- return {
168
- valid: missingOutputs.length === 0,
169
- error: null,
170
- missingOutputs,
171
- phase: phaseDefinition,
172
- featurePath,
173
- stateWarning
174
- };
175
- }
176
-
177
- // ─────────────────────────────────────────────────────────────────────────────
178
- // Phase eligibility
179
- // ─────────────────────────────────────────────────────────────────────────────
180
-
181
- const PHASE_ORDER = ['proposal', 'uiux', 'plan', 'implement', 'review'];
182
-
183
- function getNextPhase(currentPhase, featureName) {
184
- const idx = PHASE_ORDER.indexOf(currentPhase);
185
- if (idx === -1 || idx >= PHASE_ORDER.length - 1) return null;
186
-
187
- // Workflow-aware: skip phases not in the workflow's run list
188
- let workflowConfig = null;
189
- if (featureName) {
190
- try {
191
- const feature = getFeature(featureName);
192
- if (feature?.workflow && feature.workflow !== 'auto') {
193
- workflowConfig = getWorkflowConfig(feature.workflow);
194
- }
195
- } catch { /* fall through */ }
196
- }
197
-
198
- for (let i = idx + 1; i < PHASE_ORDER.length; i++) {
199
- const candidate = PHASE_ORDER[i];
200
- if (workflowConfig?.phases?.run && !workflowConfig.phases.run.includes(candidate)) {
201
- continue;
202
- }
203
- return candidate;
204
- }
205
- return null;
206
- }
207
-
208
- /**
209
- * Compute overall validation pass rate from validationHistory.
210
- * Returns null when no history exists.
211
- */
212
- export function computePassRate(validationHistory) {
213
- if (!validationHistory || Object.keys(validationHistory).length === 0) return null;
214
- const entries = Object.values(validationHistory);
215
- const passed = entries.filter(e => e.status === 'passed').length;
216
- return entries.length > 0 ? passed / entries.length : null;
217
- }
218
-
219
- // Map output type names to file paths for eligibility checks (v5)
220
- const OUTPUT_PATH_MAP = {
221
- 'proposal': '0-proposal/proposal.md',
222
- 'stackScan': '0-proposal/stack-scan.md',
223
- 'migrationPlan': '0-proposal/migration-plan.md',
224
- 'spec': '2-plan/spec.md',
225
- 'mandate': '2-plan/mandate.md',
226
- 'tasks': '2-plan/tasks.md',
227
- 'decisions': '2-plan/decisions.md',
228
- 'recap': '3-implement/recap.md',
229
- 'reviewReport': '4-review/review-report.md',
230
- };
231
-
232
- function getMissingRequiredOutputs(featureName, phase, projectPath) {
233
- const phaseDef = PHASES[phase];
234
- if (!phaseDef?.requiredOutputs) return [];
235
-
236
- const missing = [];
237
- const featureBase = join(projectPath, '.morph', 'features', featureName);
238
-
239
- for (const output of phaseDef.requiredOutputs) {
240
- const relPath = OUTPUT_PATH_MAP[output];
241
- if (relPath && !fs.existsSync(join(featureBase, relPath))) {
242
- missing.push(output);
243
- }
244
- }
245
-
246
- return missing;
247
- }
248
-
249
- function getBlockedTasks(feature) {
250
- const history = feature.validationHistory || {};
251
- return Object.entries(history)
252
- .filter(([, entry]) => entry.status === 'blocked')
253
- .map(([taskId]) => taskId);
254
- }
255
-
256
- /**
257
- * Check whether a feature is eligible to auto-advance to the next phase.
258
- * Used by phase-runner.js.
259
- */
260
- export function checkPhaseEligibility(featureName, opts = {}) {
261
- const projectPath = opts.projectPath || process.cwd();
262
-
263
- const state = loadState(false);
264
- if (!state) {
265
- return {
266
- eligible: false,
267
- blockers: [{ type: 'state_error', items: ['state.json not found'] }],
268
- currentPhase: 'unknown',
269
- nextPhase: null,
270
- passRate: null,
271
- };
272
- }
273
-
274
- const feature = state.features?.[featureName];
275
- if (!feature) {
276
- return {
277
- eligible: false,
278
- blockers: [{ type: 'state_error', items: [`Feature '${featureName}' not found in state.json`] }],
279
- currentPhase: 'unknown',
280
- nextPhase: null,
281
- passRate: null,
282
- };
283
- }
284
-
285
- const featureFolderPath = join(projectPath, '.morph', 'features', featureName);
286
- const currentPhase = feature.phase || derivePhase(featureFolderPath);
287
- const nextPhase = getNextPhase(currentPhase, featureName);
288
-
289
- if (!nextPhase) {
290
- return {
291
- eligible: false,
292
- blockers: [{ type: 'state_error', items: ['Feature is at the final phase'] }],
293
- currentPhase,
294
- nextPhase: null,
295
- passRate: null,
296
- };
297
- }
298
-
299
- let workflowConfig = null;
300
- if (feature.workflow && feature.workflow !== 'auto') {
301
- try {
302
- workflowConfig = getWorkflowConfig(feature.workflow);
303
- } catch {
304
- // Non-blocking: fall through to defaults
305
- }
306
- }
307
-
308
- const minPassRate = workflowConfig?.phaseChain?.pauseOn
309
- ? 0.80
310
- : workflowConfig?.eligibility?.minPassRate ?? 0.80;
311
-
312
- const blockers = [];
313
-
314
- const missingOutputs = getMissingRequiredOutputs(featureName, currentPhase, projectPath);
315
- if (missingOutputs.length > 0) {
316
- blockers.push({ type: 'missing_outputs', items: missingOutputs });
317
- }
318
-
319
- const blockedTasks = getBlockedTasks(feature);
320
- if (blockedTasks.length > 0) {
321
- blockers.push({ type: 'blocked_tasks', items: blockedTasks });
322
- }
323
-
324
- const passRate = computePassRate(feature.validationHistory);
325
- if (passRate !== null && passRate < minPassRate) {
326
- blockers.push({
327
- type: 'low_pass_rate',
328
- current: passRate,
329
- required: minPassRate,
330
- });
331
- }
332
-
333
- try {
334
- const requiredGateMap = { plan: 'plan', uiux: 'uiux', review: 'review' };
335
- const gate = requiredGateMap[currentPhase];
336
- if (gate) {
337
- const trustResult = shouldAutoApprove(featureName, gate);
338
- if (!trustResult.autoApprove) {
339
- const gateStatus = feature.approvalGates?.[gate];
340
- if (!gateStatus?.approved) {
341
- blockers.push({
342
- type: 'trust_too_low',
343
- items: [`Gate '${gate}' requires trust level ${trustResult.level || 'medium+'}`],
344
- });
345
- }
346
- }
347
- }
348
- } catch {
349
- // Trust manager unavailable — non-blocking
350
- }
351
-
352
- return {
353
- eligible: blockers.length === 0,
354
- blockers,
355
- currentPhase,
356
- nextPhase,
357
- passRate,
358
- };
359
- }
@@ -1,281 +0,0 @@
1
- /**
2
- * Cross-Phase Validator
3
- *
4
- * Validates consistency across morph-spec phase outputs:
5
- * - FR coverage: every spec FR is referenced by at least one task
6
- * - Mandate integrity: mandate includes all task IDs with acceptance criteria
7
- * - Contract alignment: entities in spec Data Model appear in contracts
8
- * - Dependency ordering: task dependencies are topologically valid
9
- */
10
-
11
- import { readFileSync, existsSync } from 'fs';
12
- import { join } from 'path';
13
- import { parseFRSections, parseContracts, extractTaskIds } from '../../generators/mandate-generator.js';
14
- import { getAbsoluteOutputPath } from '../../../core/paths/output-schema.js';
15
-
16
- /**
17
- * v5: parseTaskFromMarkdown lived in sprint-contract-generator. Inlined here as
18
- * a minimal parser that extracts FR references from a task block.
19
- */
20
- function parseTaskFromMarkdown(tasksContent, taskId) {
21
- const headingRe = new RegExp(`^### ${taskId}\\b[^\\n]*\\n([\\s\\S]*?)(?=^### |\\Z)`, 'm');
22
- const match = tasksContent.match(headingRe);
23
- if (!match) return null;
24
- const block = match[1];
25
- const frRefs = [...block.matchAll(/\bFR-?\d+\b/g)].map(m => m[0]);
26
- return { id: taskId, frs: [...new Set(frRefs)], block };
27
- }
28
-
29
- /**
30
- * Compute FR coverage — how well tasks.md covers spec.md FRs.
31
- *
32
- * @param {string} specContent - Full spec.md content
33
- * @param {string} tasksContent - Full tasks.md content
34
- * @returns {{ ratio: number, coveredFRs: string[], uncoveredFRs: string[], taskFRRefs: Map<string, string[]> }}
35
- */
36
- export function computeFRCoverage(specContent, tasksContent) {
37
- const frSections = parseFRSections(specContent);
38
- const taskIds = extractTaskIds(tasksContent);
39
-
40
- // Collect all FR references from tasks
41
- const allReferencedFRs = new Set();
42
- const taskFRRefs = new Map();
43
-
44
- for (const taskId of taskIds) {
45
- const task = parseTaskFromMarkdown(tasksContent, taskId);
46
- if (task && task.frs.length > 0) {
47
- taskFRRefs.set(taskId, task.frs);
48
- for (const fr of task.frs) {
49
- allReferencedFRs.add(fr);
50
- }
51
- }
52
- }
53
-
54
- const specFRIds = [...frSections.keys()];
55
- const coveredFRs = specFRIds.filter(fr => allReferencedFRs.has(fr));
56
- const uncoveredFRs = specFRIds.filter(fr => !allReferencedFRs.has(fr));
57
-
58
- return {
59
- ratio: specFRIds.length > 0 ? coveredFRs.length / specFRIds.length : 1,
60
- coveredFRs,
61
- uncoveredFRs,
62
- taskFRRefs,
63
- };
64
- }
65
-
66
- /**
67
- * Check mandate integrity — does the mandate include all tasks with acceptance criteria?
68
- *
69
- * @param {string} mandateContent - Full mandate.md content
70
- * @param {string} tasksContent - Full tasks.md content
71
- * @returns {{ complete: boolean, ratio: number, missingTasks: string[], tasksWithCriteria: string[], tasksWithoutCriteria: string[] }}
72
- */
73
- export function checkMandateIntegrity(mandateContent, tasksContent) {
74
- const taskIds = extractTaskIds(tasksContent);
75
- const missingTasks = [];
76
- const tasksWithCriteria = [];
77
- const tasksWithoutCriteria = [];
78
-
79
- for (const taskId of taskIds) {
80
- const taskPattern = new RegExp(`^## ${taskId}:`, 'm');
81
- if (!taskPattern.test(mandateContent)) {
82
- missingTasks.push(taskId);
83
- continue;
84
- }
85
-
86
- // Check if this task section has acceptance criteria
87
- const sectionStart = mandateContent.indexOf(`## ${taskId}:`);
88
- const nextSection = mandateContent.slice(sectionStart + 1).search(/\n## /);
89
- const sectionEnd = nextSection === -1 ? mandateContent.length : sectionStart + 1 + nextSection;
90
- const section = mandateContent.slice(sectionStart, sectionEnd);
91
-
92
- if (/### Acceptance Criteria/i.test(section) && /- \[[ x]\]/.test(section)) {
93
- tasksWithCriteria.push(taskId);
94
- } else {
95
- tasksWithoutCriteria.push(taskId);
96
- }
97
- }
98
-
99
- const includedCount = taskIds.length - missingTasks.length;
100
- return {
101
- complete: missingTasks.length === 0,
102
- ratio: taskIds.length > 0 ? includedCount / taskIds.length : 1,
103
- missingTasks,
104
- tasksWithCriteria,
105
- tasksWithoutCriteria,
106
- };
107
- }
108
-
109
- /**
110
- * Check contract alignment — do entities in spec Data Model appear in contracts?
111
- *
112
- * @param {string} specContent - Full spec.md content
113
- * @param {string} contractsContent - Full contracts file content (CS or TS)
114
- * @returns {{ aligned: boolean, ratio: number, matchedEntities: string[], unmatchedEntities: string[] }}
115
- */
116
- export function checkContractAlignment(specContent, contractsContent) {
117
- // Extract entity names from Data Model section in spec
118
- const dataModelMatch = specContent.match(
119
- /^##\s+(?:\d+\.?\s+)?Data Model\s*\n([\s\S]*?)(?=\n##\s|$)/im
120
- );
121
-
122
- if (!dataModelMatch) {
123
- return { aligned: true, ratio: 1, matchedEntities: [], unmatchedEntities: [] };
124
- }
125
-
126
- const dataModelSection = dataModelMatch[1];
127
- // Look for entity names: typically bold, backticked, or in headings
128
- const entityPatterns = [
129
- /\*\*(\w+)\*\*/g, // **Entity**
130
- /`(\w+)`/g, // `Entity`
131
- /^###\s+(\w+)/gm, // ### Entity
132
- ];
133
-
134
- const entities = new Set();
135
- for (const pattern of entityPatterns) {
136
- let match;
137
- while ((match = pattern.exec(dataModelSection)) !== null) {
138
- const name = match[1];
139
- // Filter out common non-entity words
140
- if (name.length > 2 && !/^(The|This|Each|All|and|for|with|from|that|has|can|will|not|are)$/i.test(name)) {
141
- entities.add(name);
142
- }
143
- }
144
- }
145
-
146
- if (entities.size === 0) {
147
- return { aligned: true, ratio: 1, matchedEntities: [], unmatchedEntities: [] };
148
- }
149
-
150
- const contracts = parseContracts(contractsContent);
151
- const contractNames = [...contracts.keys()];
152
- const contractNamesLower = contractNames.map(n => n.toLowerCase());
153
-
154
- const matchedEntities = [];
155
- const unmatchedEntities = [];
156
-
157
- for (const entity of entities) {
158
- const entityLower = entity.toLowerCase();
159
- // Check if entity appears in any contract name (partial match)
160
- const found = contractNamesLower.some(cn => cn.includes(entityLower) || entityLower.includes(cn));
161
- if (found) {
162
- matchedEntities.push(entity);
163
- } else {
164
- unmatchedEntities.push(entity);
165
- }
166
- }
167
-
168
- return {
169
- aligned: unmatchedEntities.length === 0,
170
- ratio: entities.size > 0 ? matchedEntities.length / entities.size : 1,
171
- matchedEntities,
172
- unmatchedEntities,
173
- };
174
- }
175
-
176
- /**
177
- * Check dependency ordering — are task dependencies topologically valid?
178
- *
179
- * @param {string} tasksContent - Full tasks.md content
180
- * @returns {{ valid: boolean, errors: string[] }}
181
- */
182
- export function checkDependencyOrdering(tasksContent) {
183
- const taskIds = extractTaskIds(tasksContent);
184
- const errors = [];
185
-
186
- // Build dependency graph
187
- const depPattern = /\*\*Dependenc(?:y|ies):\*\*\s*([T\d, ]+)/gi;
188
- const taskIdSet = new Set(taskIds);
189
- const taskOrder = new Map(taskIds.map((id, i) => [id, i]));
190
-
191
- // Parse each task's dependencies
192
- for (const taskId of taskIds) {
193
- const task = parseTaskFromMarkdown(tasksContent, taskId);
194
- if (!task) continue;
195
-
196
- // Extract dependencies from task description
197
- const depMatches = [...task.description.matchAll(depPattern)];
198
- for (const depMatch of depMatches) {
199
- const deps = depMatch[1].split(/[,\s]+/).filter(s => /^T\d{3}$/.test(s));
200
- for (const dep of deps) {
201
- if (!taskIdSet.has(dep)) {
202
- errors.push(`${taskId} depends on ${dep} which does not exist`);
203
- } else if (taskOrder.get(dep) >= taskOrder.get(taskId)) {
204
- errors.push(`${taskId} depends on ${dep} which comes after it (ordering issue)`);
205
- }
206
- }
207
- }
208
- }
209
-
210
- return {
211
- valid: errors.length === 0,
212
- errors,
213
- };
214
- }
215
-
216
- /**
217
- * Run full cross-phase validation on a feature's outputs.
218
- *
219
- * @param {string} projectPath - Project root
220
- * @param {string} featureName - Feature name
221
- * @returns {Object} Full validation result with scores per dimension
222
- */
223
- export function validateCrossPhase(projectPath, featureName) {
224
- const specPath = getAbsoluteOutputPath(projectPath, featureName, 'spec');
225
- const tasksPath = getAbsoluteOutputPath(projectPath, featureName, 'tasks');
226
-
227
- const result = {
228
- valid: true,
229
- errors: [],
230
- warnings: [],
231
- dimensions: {},
232
- };
233
-
234
- // Require at minimum spec and tasks
235
- if (!existsSync(specPath) || !existsSync(tasksPath)) {
236
- result.valid = false;
237
- if (!existsSync(specPath)) result.errors.push('spec.md not found');
238
- if (!existsSync(tasksPath)) result.errors.push('tasks.md not found');
239
- return result;
240
- }
241
-
242
- const specContent = readFileSync(specPath, 'utf-8');
243
- const tasksContent = readFileSync(tasksPath, 'utf-8');
244
-
245
- // 1. FR Coverage
246
- const frCoverage = computeFRCoverage(specContent, tasksContent);
247
- result.dimensions.frCoverage = frCoverage;
248
- if (frCoverage.uncoveredFRs.length > 0) {
249
- result.warnings.push(`Uncovered FRs: ${frCoverage.uncoveredFRs.join(', ')}`);
250
- }
251
- if (frCoverage.ratio < 0.9) {
252
- result.errors.push(`FR coverage too low: ${(frCoverage.ratio * 100).toFixed(0)}% (threshold: 90%)`);
253
- result.valid = false;
254
- }
255
-
256
- // 2. Mandate integrity (if mandate exists)
257
- const mandatePath = getAbsoluteOutputPath(projectPath, featureName, 'mandate');
258
- if (existsSync(mandatePath)) {
259
- const mandateContent = readFileSync(mandatePath, 'utf-8');
260
- const mandateIntegrity = checkMandateIntegrity(mandateContent, tasksContent);
261
- result.dimensions.mandateIntegrity = mandateIntegrity;
262
- if (mandateIntegrity.missingTasks.length > 0) {
263
- result.errors.push(`Mandate missing tasks: ${mandateIntegrity.missingTasks.join(', ')}`);
264
- result.valid = false;
265
- }
266
- }
267
-
268
- // 3. Contract alignment removed in v5 — contracts live in spec.md (NLH model).
269
- // See morph-implement skill for source-level contract checks.
270
-
271
- // 4. Dependency ordering
272
- const depOrdering = checkDependencyOrdering(tasksContent);
273
- result.dimensions.dependencyOrdering = depOrdering;
274
- if (!depOrdering.valid) {
275
- for (const err of depOrdering.errors) {
276
- result.warnings.push(`Dependency issue: ${err}`);
277
- }
278
- }
279
-
280
- return result;
281
- }