@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,934 +1,910 @@
1
- /**
2
- * MORPH-SPEC State Manager Library
3
- *
4
- * Manages state.json for tracking features, progress, agents, and checkpoints.
5
- * Used both by CLI commands and internal automation.
6
- */
7
-
8
- import { readFileSync, writeFileSync, renameSync, unlinkSync, existsSync, mkdirSync, rmdirSync, statSync } from 'fs';
9
- import { join, dirname } from 'path';
10
- import { detectWorkflow } from '../workflows/workflow-detector.js';
11
- import { getAllOutputPaths, getOutputPath } from '../paths/output-schema.js';
12
-
13
- const STATE_FILE_NAME = '.morph/state.json';
14
-
15
- // ============================================================================
16
- // Core Functions
17
- // ============================================================================
18
-
19
- /**
20
- * Get the state file path (looks in current working directory)
21
- */
22
- export function getStatePath() {
23
- return join(process.cwd(), STATE_FILE_NAME);
24
- }
25
- /**
26
- * Derive current phase from filesystem — checks for phase folders in descending order.
27
- * Returns the phase corresponding to the highest-numbered folder present.
28
- *
29
- * @param {string} featurePath - Absolute path to .morph/features/{feature}/
30
- * @returns {string} Phase name: 'review' | 'implement' | 'plan' | 'uiux' | 'proposal'
31
- */
32
- export function derivePhase(featurePath) {
33
- const phaseMap = [
34
- ['4-review', 'review'],
35
- ['3-implement', 'implement'],
36
- ['2-plan', 'plan'],
37
- ['1-design', 'uiux'],
38
- ['0-proposal', 'proposal'],
39
- ];
40
- for (const [folder, phase] of phaseMap) {
41
- if (existsSync(join(featurePath, folder))) return phase;
42
- }
43
- return 'proposal';
44
- }
45
-
46
- /**
47
- * Derive output existence from filesystem — checks if each output file exists at its expected path.
48
- * Returns an object matching the old outputs shape for backwards-compatible display.
49
- *
50
- * @param {string} featureName - Feature name
51
- * @param {string} [baseDir] - Project base dir (defaults to cwd)
52
- * @returns {Object} Map of outputType -> { created: boolean, path: string }
53
- */
54
- export function deriveOutputs(featureName, baseDir = process.cwd()) {
55
- const outputPaths = getAllOutputPaths(featureName);
56
- const result = {};
57
- for (const [type, { path: relPath }] of Object.entries(outputPaths)) {
58
- const absPath = join(baseDir, relPath);
59
- result[type] = { created: existsSync(absPath), path: relPath };
60
- }
61
- return result;
62
- }
63
-
64
- /**
65
- * Check if state file exists
66
- */
67
- export function stateExists() {
68
- return existsSync(getStatePath());
69
- }
70
-
71
- /**
72
- * Load state from disk
73
- * @param {boolean} throwOnError - If false, returns null instead of throwing
74
- * @returns {Object|null} State object or null
75
- */
76
- export function loadState(throwOnError = true) {
77
- const statePath = getStatePath();
78
-
79
- if (!existsSync(statePath)) {
80
- if (throwOnError) {
81
- throw new Error(`State file not found: ${statePath}\nRun 'morph-spec init' to initialize MORPH in this project.`);
82
- }
83
- return null;
84
- }
85
-
86
- try {
87
- const content = readFileSync(statePath, 'utf8');
88
- const state = JSON.parse(content);
89
-
90
- // Migrate v3.0.0 → v4.0.0 (new directory layout)
91
- if (state.version === '3.0.0') {
92
- state.version = '4.0.0';
93
- for (const [name, feature] of Object.entries(state.features || {})) {
94
- if (!feature.outputs) continue;
95
- const outputPathMap = getAllOutputPaths(name);
96
- for (const [key, { path: newPath }] of Object.entries(outputPathMap)) {
97
- if (feature.outputs[key]) {
98
- feature.outputs[key].path = newPath;
99
- }
100
- }
101
- }
102
- writeFileSync(statePath, JSON.stringify(state, null, 2), 'utf8');
103
- }
104
-
105
- // Migrate v4.x -> v5.0.0: remove outputs and phase (now derived from filesystem)
106
- if (state.version && state.version.startsWith('4.')) {
107
- state.version = '5.0.0';
108
- for (const feature of Object.values(state.features || {})) {
109
- delete feature.outputs;
110
- delete feature.phase;
111
- }
112
- writeFileSync(statePath, JSON.stringify(state, null, 2), 'utf8');
113
- }
114
-
115
- return state;
116
- } catch (err) {
117
- if (throwOnError) {
118
- throw new Error(`Failed to parse state.json: ${err.message}`);
119
- }
120
- return null;
121
- }
122
- }
123
-
124
- /**
125
- * Save state to disk
126
- * @param {Object} state - State object to save
127
- */
128
- export function saveState(state) {
129
- state.metadata = state.metadata || {};
130
- state.metadata.lastUpdated = new Date().toISOString();
131
-
132
- const statePath = getStatePath();
133
- const stateDir = dirname(statePath);
134
-
135
- // Ensure .morph directory exists
136
- if (!existsSync(stateDir)) {
137
- mkdirSync(stateDir, { recursive: true });
138
- }
139
-
140
- // Atomic write: write to a per-process temp file then rename.
141
- // Per-process name prevents two concurrent CLI invocations (e.g. `task done && task start`)
142
- // from clobbering each other's temp file on Windows.
143
- const tmpPath = `${statePath}.tmp.${process.pid}`;
144
- writeFileSync(tmpPath, JSON.stringify(state, null, 2), 'utf8');
145
-
146
- // Retry rename on EPERM — Windows holds a brief exclusive lock on the target
147
- // when another process is reading it; a short backoff resolves the contention.
148
- const MAX_RETRIES = 5;
149
- for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
150
- try {
151
- renameSync(tmpPath, statePath);
152
- return;
153
- } catch (err) {
154
- if (err.code === 'EPERM' && attempt < MAX_RETRIES) {
155
- // Synchronous sleep: CPU-friendly, works on Node.js main thread (v9.4+)
156
- Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 50 * (attempt + 1));
157
- } else {
158
- try { unlinkSync(tmpPath); } catch { /* ignore cleanup errors */ }
159
- throw err;
160
- }
161
- }
162
- }
163
- }
164
-
165
- // ============================================================================
166
- // File Locking (BUG-002)
167
- // ============================================================================
168
-
169
- const LOCK_DIR_NAME = '.morph/state.json.lock';
170
- const LOCK_MAX_RETRIES = 5;
171
- const LOCK_BASE_MS = 100;
172
- const LOCK_STALE_MS = 30_000;
173
-
174
- /**
175
- * Wrap a read-modify-write cycle on state.json with a mkdir-based mutex.
176
- * @param {Function} fn - Async function to execute while holding the lock
177
- * @returns {Promise<*>} Return value of fn
178
- */
179
- export async function withStateLock(fn) {
180
- const lockPath = join(process.cwd(), LOCK_DIR_NAME);
181
-
182
- for (let attempt = 0; attempt <= LOCK_MAX_RETRIES; attempt++) {
183
- try {
184
- mkdirSync(lockPath);
185
- break;
186
- } catch (err) {
187
- if (err.code !== 'EEXIST') throw err;
188
- try {
189
- const lockStat = statSync(lockPath);
190
- if (Date.now() - lockStat.mtimeMs > LOCK_STALE_MS) {
191
- try { rmdirSync(lockPath); } catch { }
192
- continue;
193
- }
194
- } catch { }
195
- if (attempt === LOCK_MAX_RETRIES) {
196
- throw new Error('Could not acquire state lock after retries');
197
- }
198
- Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, LOCK_BASE_MS * (2 ** attempt));
199
- }
200
- }
201
-
202
- try {
203
- return await fn();
204
- } finally {
205
- try { rmdirSync(lockPath); } catch { }
206
- }
207
- }
208
-
209
- /**
210
- * Initialize new state file
211
- * @param {Object} options - Options
212
- * @param {boolean} options.force - Overwrite existing file
213
- * @param {string} options.projectName - Project name
214
- * @param {string} options.projectType - Project type (e.g., 'dotnet-nextjs')
215
- * @returns {Object} Initial state
216
- */
217
- export function initState(options = {}) {
218
- const { force = false, projectName = '{PROJECT_NAME}', projectType = 'dotnet-nextjs' } = options;
219
-
220
- if (stateExists() && !force) {
221
- throw new Error('State file already exists. Use force=true to overwrite.');
222
- }
223
-
224
- const initialState = {
225
- version: "5.0.0",
226
- project: {
227
- name: projectName,
228
- type: projectType,
229
- createdAt: new Date().toISOString(),
230
- updatedAt: new Date().toISOString()
231
- },
232
- features: {},
233
- threads: {},
234
- metadata: {
235
- totalFeatures: 0,
236
- completedFeatures: 0,
237
- totalTimeSpent: 0,
238
- lastUpdated: new Date().toISOString()
239
- }
240
- };
241
-
242
- saveState(initialState);
243
- return initialState;
244
- }
245
-
246
- // ============================================================================
247
- // Feature Operations
248
- // ============================================================================
249
-
250
- /**
251
- * Get feature from state
252
- * @param {string} featureName - Feature name
253
- * @returns {Object|null} Feature object or null
254
- */
255
- export function getFeature(featureName) {
256
- const state = loadState();
257
- return state.features[featureName] || null;
258
- }
259
-
260
- /**
261
- * Create or get feature with default structure
262
- * @param {string} featureName - Feature name
263
- * @param {Object} options - Options
264
- * @param {string} options.userRequest - User's feature request for workflow detection
265
- * @param {string} options.workflow - Manual workflow override
266
- * @param {string} options.projectPath - Project path
267
- * @returns {Promise<Object>} Feature object
268
- */
269
- async function ensureFeature(featureName, options = {}) {
270
- const state = loadState();
271
-
272
- if (!state.features[featureName]) {
273
- // Detect workflow if userRequest provided
274
- let workflowId = 'auto';
275
- let workflowDetection = {
276
- auto: false,
277
- confidence: 0,
278
- userOverride: false
279
- };
280
-
281
- if (options.workflow) {
282
- // Manual override
283
- workflowId = options.workflow;
284
- workflowDetection.userOverride = true;
285
- } else if (options.userRequest) {
286
- // Auto-detect
287
- try {
288
- const detection = await detectWorkflow({
289
- userRequest: options.userRequest,
290
- projectPath: options.projectPath || '.',
291
- featureName
292
- });
293
- workflowId = detection.workflowId;
294
- workflowDetection.auto = true;
295
- workflowDetection.confidence = detection.confidence;
296
- workflowDetection.matchedKeywords = detection.matchedKeywords;
297
- workflowDetection.estimatedComplexity = detection.estimatedComplexity;
298
- workflowDetection.reasoning = detection.reasoning;
299
- workflowDetection.warnings = detection.warnings || [];
300
- } catch (err) {
301
- // If detection fails, fall back to auto
302
- console.warn('Workflow detection failed:', err.message);
303
- }
304
- }
305
-
306
- state.features[featureName] = {
307
- status: "draft",
308
- workflow: workflowId, // auto | standard | nodejs-cli
309
- workflowDetection,
310
- createdAt: new Date().toISOString(),
311
- updatedAt: new Date().toISOString(),
312
- activeAgents: [],
313
- approvalGates: {
314
- proposal: { approved: false, timestamp: null, approvedBy: null },
315
- uiux: { approved: false, timestamp: null, approvedBy: null },
316
- plan: { approved: false, timestamp: null, approvedBy: null },
317
- review: { approved: false, timestamp: null, approvedBy: null }
318
- },
319
- tasks: {
320
- total: 0,
321
- completed: 0,
322
- inProgress: 0,
323
- pending: 0
324
- },
325
- checkpoints: [],
326
- dispatchLog: [],
327
- trustConfig: {},
328
- contextBundles: [],
329
- fileChanges: []
330
- };
331
-
332
- state.metadata.totalFeatures++;
333
- saveState(state);
334
- }
335
-
336
- return state.features[featureName];
337
- }
338
-
339
- /**
340
- * Update feature property (supports nested keys like "tasks.completed")
341
- * @param {string} featureName - Feature name
342
- * @param {string} key - Property key (supports dot notation)
343
- * @param {any} value - Value to set
344
- */
345
- export async function updateFeature(featureName, key, value) {
346
- await ensureFeature(featureName);
347
- const state = loadState(); // Load AFTER ensuring feature exists
348
-
349
- const keys = key.split('.');
350
- let target = state.features[featureName];
351
-
352
- for (let i = 0; i < keys.length - 1; i++) {
353
- if (!target[keys[i]]) {
354
- target[keys[i]] = {};
355
- }
356
- target = target[keys[i]];
357
- }
358
-
359
- const finalKey = keys[keys.length - 1];
360
- target[finalKey] = value;
361
- state.features[featureName].updatedAt = new Date().toISOString();
362
-
363
- saveState(state);
364
- }
365
-
366
- /**
367
- * Update multiple feature properties at once
368
- * @param {string} featureName - Feature name
369
- * @param {Object} updates - Object with key-value pairs to update
370
- */
371
- export async function updateFeatureMultiple(featureName, updates) {
372
- await ensureFeature(featureName);
373
- const state = loadState(); // Load AFTER ensuring feature exists
374
-
375
- for (const [key, value] of Object.entries(updates)) {
376
- const keys = key.split('.');
377
- let target = state.features[featureName];
378
-
379
- for (let i = 0; i < keys.length - 1; i++) {
380
- if (!target[keys[i]]) {
381
- target[keys[i]] = {};
382
- }
383
- target = target[keys[i]];
384
- }
385
-
386
- const finalKey = keys[keys.length - 1];
387
- target[finalKey] = value;
388
- }
389
-
390
- state.features[featureName].updatedAt = new Date().toISOString();
391
- saveState(state);
392
- }
393
-
394
- /**
395
- * Add checkpoint to feature
396
- * @param {string} featureName - Feature name
397
- * @param {string} note - Checkpoint note
398
- * @returns {Object} Checkpoint object
399
- */
400
- export async function addCheckpoint(featureName, note) {
401
- await ensureFeature(featureName);
402
- const state = loadState();
403
- const feature = state.features[featureName];
404
-
405
- const checkpoint = {
406
- passed: true,
407
- checkpointNum: feature.checkpoints.length + 1,
408
- timestamp: new Date().toISOString(),
409
- phase: feature.phase || 'proposal',
410
- results: [],
411
- summary: {
412
- note: note,
413
- completedTasks: feature.tasks.completed
414
- }
415
- };
416
-
417
- feature.checkpoints.push(checkpoint);
418
- feature.updatedAt = new Date().toISOString();
419
-
420
- saveState(state);
421
- return checkpoint;
422
- }
423
-
424
- /**
425
- * Add agent to feature
426
- * @param {string} featureName - Feature name
427
- * @param {string} agentId - Agent ID
428
- * @returns {boolean} True if added, false if already exists
429
- */
430
- export async function addAgent(featureName, agentId) {
431
- await ensureFeature(featureName);
432
- const state = loadState(); // Load AFTER ensuring feature exists
433
-
434
- if (!state.features[featureName].activeAgents.includes(agentId)) {
435
- state.features[featureName].activeAgents.push(agentId);
436
- state.features[featureName].updatedAt = new Date().toISOString();
437
- saveState(state);
438
- return true;
439
- }
440
-
441
- return false;
442
- }
443
-
444
- /**
445
- * Remove agent from feature
446
- * @param {string} featureName - Feature name
447
- * @param {string} agentId - Agent ID
448
- * @returns {boolean} True if removed, false if not found
449
- */
450
- export function removeAgent(featureName, agentId) {
451
- const state = loadState();
452
-
453
- if (!state.features[featureName]) {
454
- throw new Error(`Feature '${featureName}' not found.`);
455
- }
456
-
457
- const index = state.features[featureName].activeAgents.indexOf(agentId);
458
- if (index > -1) {
459
- state.features[featureName].activeAgents.splice(index, 1);
460
- state.features[featureName].updatedAt = new Date().toISOString();
461
- saveState(state);
462
- return true;
463
- }
464
-
465
- return false;
466
- }
467
-
468
- /**
469
- * Log a dispatch event to state.json.
470
- * Appends entry to feature.dispatchLog, capped at 50 entries (oldest trimmed).
471
- *
472
- * @param {string} featureName - Feature name
473
- * @param {{ timestamp: string, phase: string, mode: string, agents: Array<{ id: string, subagentType: string, group: string, tasks: string[] }> }} entry - Dispatch event to log
474
- */
475
- export function logDispatch(featureName, entry) {
476
- const state = loadState();
477
- const feature = state.features?.[featureName];
478
- if (!feature) return;
479
-
480
- if (!Array.isArray(feature.dispatchLog)) feature.dispatchLog = [];
481
- feature.dispatchLog.push(entry);
482
-
483
- // Cap at 50 entries
484
- if (feature.dispatchLog.length > 50) {
485
- feature.dispatchLog = feature.dispatchLog.slice(-50);
486
- }
487
-
488
- saveState(state);
489
- }
490
-
491
- /**
492
- * Normalize output type from kebab-case to camelCase (BUG-002 fix - Enhanced)
493
- * Supports both kebab-case and camelCase input formats for all output types
494
- * @param {string} type - Output type (e.g., 'ui-design-system', 'stack-scan', or 'uiDesignSystem')
495
- * @returns {string} Normalized type in camelCase
496
- */
497
- function normalizeOutputType(type) {
498
- // If string doesn't contain hyphens, assume it's already camelCase
499
- if (!type.includes('-')) {
500
- return type;
501
- }
502
-
503
- // Convert kebab-case to camelCase automatically
504
- // Example: 'ui-design-system' → 'uiDesignSystem'
505
- // Example: 'stack-scan' → 'stackScan'
506
- const camelCase = type.replace(/-([a-z])/g, (_, letter) => letter.toUpperCase());
507
-
508
- return camelCase;
509
- }
510
-
511
- /**
512
- * Calculate Levenshtein distance between two strings
513
- * Used for fuzzy matching and typo detection
514
- * @param {string} a - First string
515
- * @param {string} b - Second string
516
- * @returns {number} Edit distance
517
- */
518
- function levenshteinDistance(a, b) {
519
- const matrix = [];
520
-
521
- for (let i = 0; i <= b.length; i++) {
522
- matrix[i] = [i];
523
- }
524
-
525
- for (let j = 0; j <= a.length; j++) {
526
- matrix[0][j] = j;
527
- }
528
-
529
- for (let i = 1; i <= b.length; i++) {
530
- for (let j = 1; j <= a.length; j++) {
531
- if (b.charAt(i - 1) === a.charAt(j - 1)) {
532
- matrix[i][j] = matrix[i - 1][j - 1];
533
- } else {
534
- matrix[i][j] = Math.min(
535
- matrix[i - 1][j - 1] + 1, // substitution
536
- matrix[i][j - 1] + 1, // insertion
537
- matrix[i - 1][j] + 1 // deletion
538
- );
539
- }
540
- }
541
- }
542
-
543
- return matrix[b.length][a.length];
544
- }
545
-
546
- /**
547
- * Find closest matching output type for error suggestions
548
- * @param {string} inputType - The invalid input type
549
- * @param {Array<string>} validTypes - List of valid output types
550
- * @returns {string|null} Closest match or null
551
- */
552
- function findClosestMatch(inputType, validTypes) {
553
- let closestMatch = null;
554
- let minDistance = Infinity;
555
-
556
- const normalizedInput = inputType.toLowerCase();
557
-
558
- for (const validType of validTypes) {
559
- const distance = levenshteinDistance(normalizedInput, validType.toLowerCase());
560
-
561
- // Consider it a match if distance is less than 3 (likely typo)
562
- // or if one string contains the other (partial match)
563
- if (distance < minDistance && distance <= 3) {
564
- minDistance = distance;
565
- closestMatch = validType;
566
- }
567
-
568
- // Also check for partial matches
569
- if (validType.toLowerCase().includes(normalizedInput) ||
570
- normalizedInput.includes(validType.toLowerCase())) {
571
- if (distance < minDistance) {
572
- minDistance = distance;
573
- closestMatch = validType;
574
- }
575
- }
576
- }
577
-
578
- // Only suggest if similarity is reasonable
579
- return minDistance <= 3 ? closestMatch : null;
580
- }
581
-
582
- /**
583
- * Mark output as created
584
- * @param {string} featureName - Feature name
585
- * @param {string} outputType - Output type (proposal, spec, mandate, etc.)
586
- */
587
- export async function markOutput(featureName, outputType) {
588
- await ensureFeature(featureName);
589
- const state = loadState();
590
-
591
- const normalized = normalizeOutputType(outputType);
592
-
593
- // Initialize outputs on demand (not pre-stored)
594
- if (!state.features[featureName].outputs) {
595
- state.features[featureName].outputs = getAllOutputPaths(featureName);
596
- }
597
-
598
- if (!state.features[featureName].outputs[normalized]) {
599
- // Try to find closest match for better error message
600
- const validCamelCaseTypes = Object.keys(state.features[featureName].outputs);
601
- const suggestion = findClosestMatch(outputType, validCamelCaseTypes);
602
-
603
- let errorMsg = `Output type '${outputType}' not valid.`;
604
-
605
- if (suggestion) {
606
- errorMsg += `\n\nDid you mean '${suggestion}'?`;
607
- // If the suggestion has a kebab-case alternative, show it too
608
- const kebabAlternative = {
609
- 'uiDesignSystem': 'ui-design-system',
610
- 'uiMockups': 'ui-mockups',
611
- 'uiComponents': 'ui-components',
612
- 'uiFlows': 'ui-flows'
613
- }[suggestion];
614
- if (kebabAlternative) {
615
- errorMsg += ` (also accepts '${kebabAlternative}')`;
616
- }
617
- }
618
-
619
- errorMsg += `\n\nValid types:\n`;
620
- errorMsg += ` ${validCamelCaseTypes.join(', ')}\n`;
621
- errorMsg += `\nNote: kebab-case is also accepted (e.g., 'ui-design-system', 'stack-scan')`;
622
-
623
- throw new Error(errorMsg);
624
- }
625
-
626
- state.features[featureName].outputs[normalized].created = true;
627
- state.features[featureName].updatedAt = new Date().toISOString();
628
-
629
- // Track the file change automatically
630
- const outputPath = state.features[featureName].outputs[normalized].path;
631
- if (outputPath) {
632
- if (!Array.isArray(state.features[featureName].fileChanges)) {
633
- state.features[featureName].fileChanges = [];
634
- }
635
- state.features[featureName].fileChanges.push({
636
- path: outputPath,
637
- action: 'created',
638
- phase: state.features[featureName].phase,
639
- timestamp: new Date().toISOString()
640
- });
641
- }
642
-
643
- saveState(state);
644
- }
645
-
646
- /**
647
- * Track a file change for a feature
648
- * @param {string} featureName - Feature name
649
- * @param {string} filePath - Path of the changed file
650
- * @param {string} action - 'created' | 'modified' | 'deleted'
651
- * @param {string} phase - Phase during which the change occurred
652
- */
653
- export async function trackFileChange(featureName, filePath, action, phase) {
654
- await ensureFeature(featureName);
655
- const state = loadState();
656
-
657
- if (!Array.isArray(state.features[featureName].fileChanges)) {
658
- state.features[featureName].fileChanges = [];
659
- }
660
-
661
- state.features[featureName].fileChanges.push({
662
- path: filePath,
663
- action,
664
- phase: phase || state.features[featureName].phase || 'proposal',
665
- timestamp: new Date().toISOString()
666
- });
667
-
668
- state.features[featureName].updatedAt = new Date().toISOString();
669
- saveState(state);
670
- }
671
-
672
- /**
673
- * Get file changes for a feature, optionally grouped by phase
674
- * @param {string} featureName - Feature name
675
- * @param {Object} options - Options
676
- * @param {boolean} options.groupByPhase - Group changes by phase
677
- * @returns {Array|Object} File changes
678
- */
679
- export function getFileChanges(featureName, options = {}) {
680
- const state = loadState();
681
- const feature = state.features[featureName];
682
-
683
- if (!feature) {
684
- throw new Error(`Feature '${featureName}' not found.`);
685
- }
686
-
687
- const changes = feature.fileChanges || [];
688
-
689
- if (options.groupByPhase) {
690
- const grouped = {};
691
- for (const change of changes) {
692
- const phase = change.phase || 'unknown';
693
- if (!grouped[phase]) grouped[phase] = [];
694
- grouped[phase].push(change);
695
- }
696
- return grouped;
697
- }
698
-
699
- return changes;
700
- }
701
-
702
- /**
703
- * Skip a phase and record it
704
- * @param {string} featureName - Feature name
705
- * @param {string} phase - Phase to skip
706
- * @param {string} reason - Reason for skipping
707
- */
708
- export async function skipPhase(featureName, phase, reason = '') {
709
- await ensureFeature(featureName);
710
- const state = loadState();
711
-
712
- if (!state.features[featureName].skippedPhases) {
713
- state.features[featureName].skippedPhases = [];
714
- }
715
-
716
- // Don't add duplicates
717
- const existing = state.features[featureName].skippedPhases.find(s => s.phase === phase);
718
- if (!existing) {
719
- state.features[featureName].skippedPhases.push({
720
- phase,
721
- reason,
722
- timestamp: new Date().toISOString()
723
- });
724
- }
725
-
726
- state.features[featureName].updatedAt = new Date().toISOString();
727
- saveState(state);
728
- }
729
-
730
- /**
731
- * Get skipped phases for a feature
732
- * @param {string} featureName - Feature name
733
- * @returns {Array} Array of skipped phases
734
- */
735
- export function getSkippedPhases(featureName) {
736
- const state = loadState();
737
- if (!state.features[featureName]) {
738
- return [];
739
- }
740
- return state.features[featureName].skippedPhases || [];
741
- }
742
-
743
- /**
744
- * List all features
745
- * @returns {Array} Array of [featureName, featureObject] tuples
746
- */
747
- export function listFeatures() {
748
- const state = loadState();
749
- return Object.entries(state.features);
750
- }
751
-
752
- /**
753
- * Get project summary
754
- * @returns {Object} Summary with metadata
755
- */
756
- export function getSummary() {
757
- const state = loadState();
758
- return {
759
- project: state.project,
760
- metadata: state.metadata,
761
- featuresCount: Object.keys(state.features).length
762
- };
763
- }
764
-
765
- // ============================================================================
766
- // Approval Gates Operations
767
- // ============================================================================
768
-
769
- /**
770
- * Set approval gate status
771
- * @param {string} featureName - Feature name
772
- * @param {string} gate - Gate name (proposal, uiux, plan, review)
773
- * @param {boolean} approved - Approval status
774
- * @param {Object} metadata - Additional metadata (approvedBy, reason, etc.)
775
- */
776
- export async function setApprovalGate(featureName, gate, approved, metadata = {}) {
777
- const state = loadState();
778
- const feature = await ensureFeature(featureName);
779
-
780
- if (!feature.approvalGates) {
781
- feature.approvalGates = {
782
- proposal: { approved: false, timestamp: null, approvedBy: null },
783
- uiux: { approved: false, timestamp: null, approvedBy: null },
784
- plan: { approved: false, timestamp: null, approvedBy: null },
785
- review: { approved: false, timestamp: null, approvedBy: null }
786
- };
787
- }
788
-
789
- feature.approvalGates[gate] = {
790
- approved,
791
- timestamp: metadata.approvedAt || metadata.rejectedAt || new Date().toISOString(),
792
- approvedBy: metadata.approvedBy || metadata.rejectedBy || null,
793
- ...metadata
794
- };
795
-
796
- state.features[featureName] = feature;
797
- saveState(state);
798
- }
799
-
800
- /**
801
- * Get approval gate status
802
- * @param {string} featureName - Feature name
803
- * @param {string} gate - Gate name
804
- * @returns {Object|null} Gate object or null
805
- */
806
- export function getApprovalGate(featureName, gate) {
807
- const state = loadState();
808
- const feature = state.features[featureName];
809
-
810
- if (!feature || !feature.approvalGates) {
811
- return null;
812
- }
813
-
814
- return feature.approvalGates[gate] || null;
815
- }
816
-
817
- /**
818
- * Check if feature is pending approval
819
- * @param {string} featureName - Feature name
820
- * @returns {boolean} True if any gate is pending approval
821
- */
822
- export function isPendingApproval(featureName) {
823
- const state = loadState();
824
- const feature = state.features[featureName];
825
-
826
- if (!feature || !feature.approvalGates) {
827
- return false;
828
- }
829
-
830
- const currentPhase = feature.phase || 'proposal';
831
-
832
- // Check if current phase has an approval gate
833
- const phaseGateMap = {
834
- 'plan': 'plan',
835
- 'review': 'review',
836
- 'uiux': 'uiux'
837
- };
838
-
839
- const relevantGate = phaseGateMap[currentPhase];
840
- if (!relevantGate) {
841
- return false;
842
- }
843
-
844
- const gate = feature.approvalGates[relevantGate];
845
- return gate && !gate.approved;
846
- }
847
-
848
- /**
849
- * Get approval history for a feature
850
- * @param {string} featureName - Feature name
851
- * @returns {Array} Array of approval events
852
- */
853
- export function getApprovalHistory(featureName) {
854
- const state = loadState();
855
- const feature = state.features[featureName];
856
-
857
- if (!feature || !feature.approvalGates) {
858
- return [];
859
- }
860
-
861
- const history = [];
862
-
863
- Object.entries(feature.approvalGates).forEach(([gate, data]) => {
864
- if (data.timestamp) {
865
- history.push({
866
- gate,
867
- approved: data.approved,
868
- timestamp: data.timestamp,
869
- approvedBy: data.approvedBy || data.rejectedBy,
870
- reason: data.reason
871
- });
872
- }
873
- });
874
-
875
- // Sort by timestamp
876
- return history.sort((a, b) => new Date(a.timestamp) - new Date(b.timestamp));
877
- }
878
-
879
- /**
880
- * Find which agent was assigned a specific task in the latest dispatch.
881
- * Searches only the last dispatchLog entry.
882
- *
883
- * @param {string} featureName
884
- * @param {string} taskId
885
- * @returns {string|null} Agent ID or null if not found
886
- */
887
- export function resolveAgentForTask(featureName, taskId) {
888
- const state = loadState();
889
- const feature = state.features?.[featureName];
890
- if (!feature) return null;
891
-
892
- const log = feature.dispatchLog;
893
- if (!Array.isArray(log) || log.length === 0) return null;
894
-
895
- const latest = log[log.length - 1];
896
- for (const agent of latest.agents || []) {
897
- if (Array.isArray(agent.tasks) && agent.tasks.includes(taskId)) {
898
- return agent.id;
899
- }
900
- }
901
- return null;
902
- }
903
-
904
- /**
905
- * Update timing fields on a specific agent in the latest dispatch entry.
906
- * Fail-open: returns silently if feature/dispatch/agent not found.
907
- *
908
- * @param {string} featureName
909
- * @param {string} agentId - Agent ID to update
910
- * @param {Object} updates - Fields to merge (startedAt, completedAt, tasksCompleted)
911
- */
912
- export function updateDispatchAgentTiming(featureName, agentId, updates) {
913
- const state = loadState();
914
- const feature = state.features?.[featureName];
915
- if (!feature) return;
916
-
917
- const log = feature.dispatchLog;
918
- if (!Array.isArray(log) || log.length === 0) return;
919
-
920
- const latest = log[log.length - 1];
921
- const agent = (latest.agents || []).find(a => a.id === agentId);
922
- if (!agent) return;
923
-
924
- Object.assign(agent, updates);
925
-
926
- // If all agents now have completedAt, set entry.completedAt to the latest
927
- const allComplete = (latest.agents || []).every(a => a.completedAt);
928
- if (allComplete && latest.agents.length > 0) {
929
- const timestamps = latest.agents.map(a => new Date(a.completedAt).getTime());
930
- latest.completedAt = new Date(Math.max(...timestamps)).toISOString();
931
- }
932
-
933
- saveState(state);
934
- }
1
+ /**
2
+ * MORPH-SPEC State Manager Library
3
+ *
4
+ * Manages state.json for tracking features, progress, agents, and checkpoints.
5
+ * Used both by CLI commands and internal automation.
6
+ */
7
+
8
+ import { readFileSync, writeFileSync, renameSync, unlinkSync, existsSync, mkdirSync, rmdirSync, statSync } from 'fs';
9
+ import { join, dirname } from 'path';
10
+ import { getAllOutputPaths, getOutputPath } from '../paths/output-schema.js';
11
+
12
+ const STATE_FILE_NAME = '.morph/state.json';
13
+
14
+ // ============================================================================
15
+ // Core Functions
16
+ // ============================================================================
17
+
18
+ /**
19
+ * Get the state file path (looks in current working directory)
20
+ */
21
+ export function getStatePath() {
22
+ return join(process.cwd(), STATE_FILE_NAME);
23
+ }
24
+ /**
25
+ * Derive current phase from filesystem.
26
+ *
27
+ * Walks the phase map in descending order and returns the first phase whose
28
+ * CANONICAL ARTIFACT exists. An empty folder is not enough — `mkdir 2-plan/`
29
+ * does not count as "in plan phase" until `spec.md` (the first canonical
30
+ * plan-phase output) lands. If no canonical artifact is found anywhere,
31
+ * returns `'proposal'` (the starting phase).
32
+ *
33
+ * This avoids the chicken-and-egg problem where the agent pre-creates all
34
+ * phase folders, the derived phase jumps to the highest one, and
35
+ * `enforce-phase-writes` then blocks the agent from writing back to the
36
+ * prior phase folder.
37
+ *
38
+ * @param {string} featurePath - Absolute path to .morph/features/{feature}/
39
+ * @returns {string} Phase name: 'review' | 'implement' | 'plan' | 'uiux' | 'proposal'
40
+ */
41
+ export function derivePhase(featurePath) {
42
+ const phaseMap = [
43
+ ['4-review', 'review', 'review-report.md'],
44
+ ['3-implement', 'implement', 'recap.md'],
45
+ ['2-plan', 'plan', 'spec.md'],
46
+ ['1-design', 'uiux', 'design-system.md'],
47
+ ['0-proposal', 'proposal', 'proposal.md'],
48
+ ];
49
+ for (const [folder, phase, canonical] of phaseMap) {
50
+ if (existsSync(join(featurePath, folder, canonical))) return phase;
51
+ }
52
+ return 'proposal';
53
+ }
54
+
55
+ /**
56
+ * Derive output existence from filesystem — checks if each output file exists at its expected path.
57
+ * Returns an object matching the old outputs shape for backwards-compatible display.
58
+ *
59
+ * @param {string} featureName - Feature name
60
+ * @param {string} [baseDir] - Project base dir (defaults to cwd)
61
+ * @returns {Object} Map of outputType -> { created: boolean, path: string }
62
+ */
63
+ export function deriveOutputs(featureName, baseDir = process.cwd()) {
64
+ const outputPaths = getAllOutputPaths(featureName);
65
+ const result = {};
66
+ for (const [type, { path: relPath }] of Object.entries(outputPaths)) {
67
+ const absPath = join(baseDir, relPath);
68
+ result[type] = { created: existsSync(absPath), path: relPath };
69
+ }
70
+ return result;
71
+ }
72
+
73
+ /**
74
+ * Check if state file exists
75
+ */
76
+ export function stateExists() {
77
+ return existsSync(getStatePath());
78
+ }
79
+
80
+ /**
81
+ * Load state from disk
82
+ * @param {boolean} throwOnError - If false, returns null instead of throwing
83
+ * @returns {Object|null} State object or null
84
+ */
85
+ export function loadState(throwOnError = true) {
86
+ const statePath = getStatePath();
87
+
88
+ if (!existsSync(statePath)) {
89
+ if (throwOnError) {
90
+ throw new Error(`State file not found: ${statePath}\nRun 'morph-spec init' to initialize MORPH in this project.`);
91
+ }
92
+ return null;
93
+ }
94
+
95
+ try {
96
+ const content = readFileSync(statePath, 'utf8');
97
+ const state = JSON.parse(content);
98
+
99
+ // Migrate v3.0.0 → v4.0.0 (new directory layout)
100
+ if (state.version === '3.0.0') {
101
+ state.version = '4.0.0';
102
+ for (const [name, feature] of Object.entries(state.features || {})) {
103
+ if (!feature.outputs) continue;
104
+ const outputPathMap = getAllOutputPaths(name);
105
+ for (const [key, { path: newPath }] of Object.entries(outputPathMap)) {
106
+ if (feature.outputs[key]) {
107
+ feature.outputs[key].path = newPath;
108
+ }
109
+ }
110
+ }
111
+ writeFileSync(statePath, JSON.stringify(state, null, 2), 'utf8');
112
+ }
113
+
114
+ // Migrate v4.x -> v5.0.0: remove outputs and phase (now derived from
115
+ // filesystem) and workflow/workflowDetection (v5 has fixed phases, no
116
+ // workflow selection).
117
+ if (state.version && state.version.startsWith('4.')) {
118
+ state.version = '5.0.0';
119
+ for (const feature of Object.values(state.features || {})) {
120
+ delete feature.outputs;
121
+ delete feature.phase;
122
+ delete feature.workflow;
123
+ delete feature.workflowDetection;
124
+ }
125
+ writeFileSync(statePath, JSON.stringify(state, null, 2), 'utf8');
126
+ }
127
+
128
+ return state;
129
+ } catch (err) {
130
+ if (throwOnError) {
131
+ throw new Error(`Failed to parse state.json: ${err.message}`);
132
+ }
133
+ return null;
134
+ }
135
+ }
136
+
137
+ /**
138
+ * Save state to disk
139
+ * @param {Object} state - State object to save
140
+ */
141
+ export function saveState(state) {
142
+ state.metadata = state.metadata || {};
143
+ state.metadata.lastUpdated = new Date().toISOString();
144
+
145
+ const statePath = getStatePath();
146
+ const stateDir = dirname(statePath);
147
+
148
+ // Ensure .morph directory exists
149
+ if (!existsSync(stateDir)) {
150
+ mkdirSync(stateDir, { recursive: true });
151
+ }
152
+
153
+ // Atomic write: write to a per-process temp file then rename.
154
+ // Per-process name prevents two concurrent CLI invocations (e.g. `task done && task start`)
155
+ // from clobbering each other's temp file on Windows.
156
+ const tmpPath = `${statePath}.tmp.${process.pid}`;
157
+ writeFileSync(tmpPath, JSON.stringify(state, null, 2), 'utf8');
158
+
159
+ // Retry rename on EPERM — Windows holds a brief exclusive lock on the target
160
+ // when another process is reading it; a short backoff resolves the contention.
161
+ const MAX_RETRIES = 5;
162
+ for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
163
+ try {
164
+ renameSync(tmpPath, statePath);
165
+ return;
166
+ } catch (err) {
167
+ if (err.code === 'EPERM' && attempt < MAX_RETRIES) {
168
+ // Synchronous sleep: CPU-friendly, works on Node.js main thread (v9.4+)
169
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 50 * (attempt + 1));
170
+ } else {
171
+ try { unlinkSync(tmpPath); } catch { /* ignore cleanup errors */ }
172
+ throw err;
173
+ }
174
+ }
175
+ }
176
+ }
177
+
178
+ // ============================================================================
179
+ // File Locking (BUG-002)
180
+ // ============================================================================
181
+
182
+ const LOCK_DIR_NAME = '.morph/state.json.lock';
183
+ const LOCK_MAX_RETRIES = 5;
184
+ const LOCK_BASE_MS = 100;
185
+ const LOCK_STALE_MS = 30_000;
186
+
187
+ /**
188
+ * Wrap a read-modify-write cycle on state.json with a mkdir-based mutex.
189
+ * @param {Function} fn - Async function to execute while holding the lock
190
+ * @returns {Promise<*>} Return value of fn
191
+ */
192
+ export async function withStateLock(fn) {
193
+ const lockPath = join(process.cwd(), LOCK_DIR_NAME);
194
+
195
+ for (let attempt = 0; attempt <= LOCK_MAX_RETRIES; attempt++) {
196
+ try {
197
+ mkdirSync(lockPath);
198
+ break;
199
+ } catch (err) {
200
+ if (err.code !== 'EEXIST') throw err;
201
+ try {
202
+ const lockStat = statSync(lockPath);
203
+ if (Date.now() - lockStat.mtimeMs > LOCK_STALE_MS) {
204
+ try { rmdirSync(lockPath); } catch { }
205
+ continue;
206
+ }
207
+ } catch { }
208
+ if (attempt === LOCK_MAX_RETRIES) {
209
+ throw new Error('Could not acquire state lock after retries');
210
+ }
211
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, LOCK_BASE_MS * (2 ** attempt));
212
+ }
213
+ }
214
+
215
+ try {
216
+ return await fn();
217
+ } finally {
218
+ try { rmdirSync(lockPath); } catch { }
219
+ }
220
+ }
221
+
222
+ /**
223
+ * Initialize new state file
224
+ * @param {Object} options - Options
225
+ * @param {boolean} options.force - Overwrite existing file
226
+ * @param {string} options.projectName - Project name
227
+ * @param {string} options.projectType - Project type (e.g., 'dotnet-nextjs')
228
+ * @returns {Object} Initial state
229
+ */
230
+ export function initState(options = {}) {
231
+ const { force = false, projectName = '{PROJECT_NAME}', projectType = 'dotnet-nextjs' } = options;
232
+
233
+ if (stateExists() && !force) {
234
+ throw new Error('State file already exists. Use force=true to overwrite.');
235
+ }
236
+
237
+ const initialState = {
238
+ version: "5.0.0",
239
+ project: {
240
+ name: projectName,
241
+ type: projectType,
242
+ createdAt: new Date().toISOString(),
243
+ updatedAt: new Date().toISOString()
244
+ },
245
+ features: {},
246
+ threads: {},
247
+ metadata: {
248
+ totalFeatures: 0,
249
+ completedFeatures: 0,
250
+ totalTimeSpent: 0,
251
+ lastUpdated: new Date().toISOString()
252
+ }
253
+ };
254
+
255
+ saveState(initialState);
256
+ return initialState;
257
+ }
258
+
259
+ // ============================================================================
260
+ // Feature Operations
261
+ // ============================================================================
262
+
263
+ /**
264
+ * Get feature from state
265
+ * @param {string} featureName - Feature name
266
+ * @returns {Object|null} Feature object or null
267
+ */
268
+ export function getFeature(featureName) {
269
+ const state = loadState();
270
+ return state.features[featureName] || null;
271
+ }
272
+
273
+ /**
274
+ * Create or get feature with default structure.
275
+ * v5: every feature follows the fixed phase sequence
276
+ * (proposal → uiux → plan → implement → review). No workflow selection.
277
+ * @param {string} featureName - Feature name
278
+ * @returns {Promise<Object>} Feature object
279
+ */
280
+ async function ensureFeature(featureName) {
281
+ const state = loadState();
282
+
283
+ if (!state.features[featureName]) {
284
+ state.features[featureName] = {
285
+ status: "draft",
286
+ createdAt: new Date().toISOString(),
287
+ updatedAt: new Date().toISOString(),
288
+ activeAgents: [],
289
+ approvalGates: {
290
+ proposal: { approved: false, timestamp: null, approvedBy: null },
291
+ uiux: { approved: false, timestamp: null, approvedBy: null },
292
+ plan: { approved: false, timestamp: null, approvedBy: null },
293
+ review: { approved: false, timestamp: null, approvedBy: null }
294
+ },
295
+ tasks: {
296
+ total: 0,
297
+ completed: 0,
298
+ inProgress: 0,
299
+ pending: 0
300
+ },
301
+ checkpoints: [],
302
+ dispatchLog: [],
303
+ trustConfig: {},
304
+ contextBundles: [],
305
+ fileChanges: []
306
+ };
307
+
308
+ state.metadata.totalFeatures++;
309
+ saveState(state);
310
+ }
311
+
312
+ return state.features[featureName];
313
+ }
314
+
315
+ /**
316
+ * Update feature property (supports nested keys like "tasks.completed")
317
+ * @param {string} featureName - Feature name
318
+ * @param {string} key - Property key (supports dot notation)
319
+ * @param {any} value - Value to set
320
+ */
321
+ export async function updateFeature(featureName, key, value) {
322
+ await ensureFeature(featureName);
323
+ const state = loadState(); // Load AFTER ensuring feature exists
324
+
325
+ const keys = key.split('.');
326
+ let target = state.features[featureName];
327
+
328
+ for (let i = 0; i < keys.length - 1; i++) {
329
+ if (!target[keys[i]]) {
330
+ target[keys[i]] = {};
331
+ }
332
+ target = target[keys[i]];
333
+ }
334
+
335
+ const finalKey = keys[keys.length - 1];
336
+ target[finalKey] = value;
337
+ state.features[featureName].updatedAt = new Date().toISOString();
338
+
339
+ saveState(state);
340
+ }
341
+
342
+ /**
343
+ * Update multiple feature properties at once
344
+ * @param {string} featureName - Feature name
345
+ * @param {Object} updates - Object with key-value pairs to update
346
+ */
347
+ export async function updateFeatureMultiple(featureName, updates) {
348
+ await ensureFeature(featureName);
349
+ const state = loadState(); // Load AFTER ensuring feature exists
350
+
351
+ for (const [key, value] of Object.entries(updates)) {
352
+ const keys = key.split('.');
353
+ let target = state.features[featureName];
354
+
355
+ for (let i = 0; i < keys.length - 1; i++) {
356
+ if (!target[keys[i]]) {
357
+ target[keys[i]] = {};
358
+ }
359
+ target = target[keys[i]];
360
+ }
361
+
362
+ const finalKey = keys[keys.length - 1];
363
+ target[finalKey] = value;
364
+ }
365
+
366
+ state.features[featureName].updatedAt = new Date().toISOString();
367
+ saveState(state);
368
+ }
369
+
370
+ /**
371
+ * Add checkpoint to feature
372
+ * @param {string} featureName - Feature name
373
+ * @param {string} note - Checkpoint note
374
+ * @returns {Object} Checkpoint object
375
+ */
376
+ export async function addCheckpoint(featureName, note) {
377
+ await ensureFeature(featureName);
378
+ const state = loadState();
379
+ const feature = state.features[featureName];
380
+
381
+ const checkpoint = {
382
+ passed: true,
383
+ checkpointNum: feature.checkpoints.length + 1,
384
+ timestamp: new Date().toISOString(),
385
+ phase: feature.phase || 'proposal',
386
+ results: [],
387
+ summary: {
388
+ note: note,
389
+ completedTasks: feature.tasks.completed
390
+ }
391
+ };
392
+
393
+ feature.checkpoints.push(checkpoint);
394
+ feature.updatedAt = new Date().toISOString();
395
+
396
+ saveState(state);
397
+ return checkpoint;
398
+ }
399
+
400
+ /**
401
+ * Add agent to feature
402
+ * @param {string} featureName - Feature name
403
+ * @param {string} agentId - Agent ID
404
+ * @returns {boolean} True if added, false if already exists
405
+ */
406
+ export async function addAgent(featureName, agentId) {
407
+ await ensureFeature(featureName);
408
+ const state = loadState(); // Load AFTER ensuring feature exists
409
+
410
+ if (!state.features[featureName].activeAgents.includes(agentId)) {
411
+ state.features[featureName].activeAgents.push(agentId);
412
+ state.features[featureName].updatedAt = new Date().toISOString();
413
+ saveState(state);
414
+ return true;
415
+ }
416
+
417
+ return false;
418
+ }
419
+
420
+ /**
421
+ * Remove agent from feature
422
+ * @param {string} featureName - Feature name
423
+ * @param {string} agentId - Agent ID
424
+ * @returns {boolean} True if removed, false if not found
425
+ */
426
+ export function removeAgent(featureName, agentId) {
427
+ const state = loadState();
428
+
429
+ if (!state.features[featureName]) {
430
+ throw new Error(`Feature '${featureName}' not found.`);
431
+ }
432
+
433
+ const index = state.features[featureName].activeAgents.indexOf(agentId);
434
+ if (index > -1) {
435
+ state.features[featureName].activeAgents.splice(index, 1);
436
+ state.features[featureName].updatedAt = new Date().toISOString();
437
+ saveState(state);
438
+ return true;
439
+ }
440
+
441
+ return false;
442
+ }
443
+
444
+ /**
445
+ * Log a dispatch event to state.json.
446
+ * Appends entry to feature.dispatchLog, capped at 50 entries (oldest trimmed).
447
+ *
448
+ * @param {string} featureName - Feature name
449
+ * @param {{ timestamp: string, phase: string, mode: string, agents: Array<{ id: string, subagentType: string, group: string, tasks: string[] }> }} entry - Dispatch event to log
450
+ */
451
+ export function logDispatch(featureName, entry) {
452
+ const state = loadState();
453
+ const feature = state.features?.[featureName];
454
+ if (!feature) return;
455
+
456
+ if (!Array.isArray(feature.dispatchLog)) feature.dispatchLog = [];
457
+ feature.dispatchLog.push(entry);
458
+
459
+ // Cap at 50 entries
460
+ if (feature.dispatchLog.length > 50) {
461
+ feature.dispatchLog = feature.dispatchLog.slice(-50);
462
+ }
463
+
464
+ saveState(state);
465
+ }
466
+
467
+ /**
468
+ * Normalize output type from kebab-case to camelCase (BUG-002 fix - Enhanced)
469
+ * Supports both kebab-case and camelCase input formats for all output types
470
+ * @param {string} type - Output type (e.g., 'ui-design-system', 'stack-scan', or 'uiDesignSystem')
471
+ * @returns {string} Normalized type in camelCase
472
+ */
473
+ function normalizeOutputType(type) {
474
+ // If string doesn't contain hyphens, assume it's already camelCase
475
+ if (!type.includes('-')) {
476
+ return type;
477
+ }
478
+
479
+ // Convert kebab-case to camelCase automatically
480
+ // Example: 'ui-design-system' → 'uiDesignSystem'
481
+ // Example: 'stack-scan' → 'stackScan'
482
+ const camelCase = type.replace(/-([a-z])/g, (_, letter) => letter.toUpperCase());
483
+
484
+ return camelCase;
485
+ }
486
+
487
+ /**
488
+ * Calculate Levenshtein distance between two strings
489
+ * Used for fuzzy matching and typo detection
490
+ * @param {string} a - First string
491
+ * @param {string} b - Second string
492
+ * @returns {number} Edit distance
493
+ */
494
+ function levenshteinDistance(a, b) {
495
+ const matrix = [];
496
+
497
+ for (let i = 0; i <= b.length; i++) {
498
+ matrix[i] = [i];
499
+ }
500
+
501
+ for (let j = 0; j <= a.length; j++) {
502
+ matrix[0][j] = j;
503
+ }
504
+
505
+ for (let i = 1; i <= b.length; i++) {
506
+ for (let j = 1; j <= a.length; j++) {
507
+ if (b.charAt(i - 1) === a.charAt(j - 1)) {
508
+ matrix[i][j] = matrix[i - 1][j - 1];
509
+ } else {
510
+ matrix[i][j] = Math.min(
511
+ matrix[i - 1][j - 1] + 1, // substitution
512
+ matrix[i][j - 1] + 1, // insertion
513
+ matrix[i - 1][j] + 1 // deletion
514
+ );
515
+ }
516
+ }
517
+ }
518
+
519
+ return matrix[b.length][a.length];
520
+ }
521
+
522
+ /**
523
+ * Find closest matching output type for error suggestions
524
+ * @param {string} inputType - The invalid input type
525
+ * @param {Array<string>} validTypes - List of valid output types
526
+ * @returns {string|null} Closest match or null
527
+ */
528
+ function findClosestMatch(inputType, validTypes) {
529
+ let closestMatch = null;
530
+ let minDistance = Infinity;
531
+
532
+ const normalizedInput = inputType.toLowerCase();
533
+
534
+ for (const validType of validTypes) {
535
+ const distance = levenshteinDistance(normalizedInput, validType.toLowerCase());
536
+
537
+ // Consider it a match if distance is less than 3 (likely typo)
538
+ // or if one string contains the other (partial match)
539
+ if (distance < minDistance && distance <= 3) {
540
+ minDistance = distance;
541
+ closestMatch = validType;
542
+ }
543
+
544
+ // Also check for partial matches
545
+ if (validType.toLowerCase().includes(normalizedInput) ||
546
+ normalizedInput.includes(validType.toLowerCase())) {
547
+ if (distance < minDistance) {
548
+ minDistance = distance;
549
+ closestMatch = validType;
550
+ }
551
+ }
552
+ }
553
+
554
+ // Only suggest if similarity is reasonable
555
+ return minDistance <= 3 ? closestMatch : null;
556
+ }
557
+
558
+ /**
559
+ * Mark output as created
560
+ * @param {string} featureName - Feature name
561
+ * @param {string} outputType - Output type (proposal, spec, mandate, etc.)
562
+ */
563
+ export async function markOutput(featureName, outputType) {
564
+ await ensureFeature(featureName);
565
+ const state = loadState();
566
+
567
+ const normalized = normalizeOutputType(outputType);
568
+
569
+ // Initialize outputs on demand (not pre-stored)
570
+ if (!state.features[featureName].outputs) {
571
+ state.features[featureName].outputs = getAllOutputPaths(featureName);
572
+ }
573
+
574
+ if (!state.features[featureName].outputs[normalized]) {
575
+ // Try to find closest match for better error message
576
+ const validCamelCaseTypes = Object.keys(state.features[featureName].outputs);
577
+ const suggestion = findClosestMatch(outputType, validCamelCaseTypes);
578
+
579
+ let errorMsg = `Output type '${outputType}' not valid.`;
580
+
581
+ if (suggestion) {
582
+ errorMsg += `\n\nDid you mean '${suggestion}'?`;
583
+ // If the suggestion has a kebab-case alternative, show it too
584
+ const kebabAlternative = {
585
+ 'uiDesignSystem': 'ui-design-system',
586
+ 'uiMockups': 'ui-mockups',
587
+ 'uiComponents': 'ui-components',
588
+ 'uiFlows': 'ui-flows'
589
+ }[suggestion];
590
+ if (kebabAlternative) {
591
+ errorMsg += ` (also accepts '${kebabAlternative}')`;
592
+ }
593
+ }
594
+
595
+ errorMsg += `\n\nValid types:\n`;
596
+ errorMsg += ` ${validCamelCaseTypes.join(', ')}\n`;
597
+ errorMsg += `\nNote: kebab-case is also accepted (e.g., 'ui-design-system', 'stack-scan')`;
598
+
599
+ throw new Error(errorMsg);
600
+ }
601
+
602
+ state.features[featureName].outputs[normalized].created = true;
603
+ state.features[featureName].updatedAt = new Date().toISOString();
604
+
605
+ // Track the file change automatically
606
+ const outputPath = state.features[featureName].outputs[normalized].path;
607
+ if (outputPath) {
608
+ if (!Array.isArray(state.features[featureName].fileChanges)) {
609
+ state.features[featureName].fileChanges = [];
610
+ }
611
+ state.features[featureName].fileChanges.push({
612
+ path: outputPath,
613
+ action: 'created',
614
+ phase: state.features[featureName].phase,
615
+ timestamp: new Date().toISOString()
616
+ });
617
+ }
618
+
619
+ saveState(state);
620
+ }
621
+
622
+ /**
623
+ * Track a file change for a feature
624
+ * @param {string} featureName - Feature name
625
+ * @param {string} filePath - Path of the changed file
626
+ * @param {string} action - 'created' | 'modified' | 'deleted'
627
+ * @param {string} phase - Phase during which the change occurred
628
+ */
629
+ export async function trackFileChange(featureName, filePath, action, phase) {
630
+ await ensureFeature(featureName);
631
+ const state = loadState();
632
+
633
+ if (!Array.isArray(state.features[featureName].fileChanges)) {
634
+ state.features[featureName].fileChanges = [];
635
+ }
636
+
637
+ state.features[featureName].fileChanges.push({
638
+ path: filePath,
639
+ action,
640
+ phase: phase || state.features[featureName].phase || 'proposal',
641
+ timestamp: new Date().toISOString()
642
+ });
643
+
644
+ state.features[featureName].updatedAt = new Date().toISOString();
645
+ saveState(state);
646
+ }
647
+
648
+ /**
649
+ * Get file changes for a feature, optionally grouped by phase
650
+ * @param {string} featureName - Feature name
651
+ * @param {Object} options - Options
652
+ * @param {boolean} options.groupByPhase - Group changes by phase
653
+ * @returns {Array|Object} File changes
654
+ */
655
+ export function getFileChanges(featureName, options = {}) {
656
+ const state = loadState();
657
+ const feature = state.features[featureName];
658
+
659
+ if (!feature) {
660
+ throw new Error(`Feature '${featureName}' not found.`);
661
+ }
662
+
663
+ const changes = feature.fileChanges || [];
664
+
665
+ if (options.groupByPhase) {
666
+ const grouped = {};
667
+ for (const change of changes) {
668
+ const phase = change.phase || 'unknown';
669
+ if (!grouped[phase]) grouped[phase] = [];
670
+ grouped[phase].push(change);
671
+ }
672
+ return grouped;
673
+ }
674
+
675
+ return changes;
676
+ }
677
+
678
+ /**
679
+ * Skip a phase and record it
680
+ * @param {string} featureName - Feature name
681
+ * @param {string} phase - Phase to skip
682
+ * @param {string} reason - Reason for skipping
683
+ */
684
+ export async function skipPhase(featureName, phase, reason = '') {
685
+ await ensureFeature(featureName);
686
+ const state = loadState();
687
+
688
+ if (!state.features[featureName].skippedPhases) {
689
+ state.features[featureName].skippedPhases = [];
690
+ }
691
+
692
+ // Don't add duplicates
693
+ const existing = state.features[featureName].skippedPhases.find(s => s.phase === phase);
694
+ if (!existing) {
695
+ state.features[featureName].skippedPhases.push({
696
+ phase,
697
+ reason,
698
+ timestamp: new Date().toISOString()
699
+ });
700
+ }
701
+
702
+ state.features[featureName].updatedAt = new Date().toISOString();
703
+ saveState(state);
704
+ }
705
+
706
+ /**
707
+ * Get skipped phases for a feature
708
+ * @param {string} featureName - Feature name
709
+ * @returns {Array} Array of skipped phases
710
+ */
711
+ export function getSkippedPhases(featureName) {
712
+ const state = loadState();
713
+ if (!state.features[featureName]) {
714
+ return [];
715
+ }
716
+ return state.features[featureName].skippedPhases || [];
717
+ }
718
+
719
+ /**
720
+ * List all features
721
+ * @returns {Array} Array of [featureName, featureObject] tuples
722
+ */
723
+ export function listFeatures() {
724
+ const state = loadState();
725
+ return Object.entries(state.features);
726
+ }
727
+
728
+ /**
729
+ * Get project summary
730
+ * @returns {Object} Summary with metadata
731
+ */
732
+ export function getSummary() {
733
+ const state = loadState();
734
+ return {
735
+ project: state.project,
736
+ metadata: state.metadata,
737
+ featuresCount: Object.keys(state.features).length
738
+ };
739
+ }
740
+
741
+ // ============================================================================
742
+ // Approval Gates Operations
743
+ // ============================================================================
744
+
745
+ /**
746
+ * Set approval gate status
747
+ * @param {string} featureName - Feature name
748
+ * @param {string} gate - Gate name (proposal, uiux, plan, review)
749
+ * @param {boolean} approved - Approval status
750
+ * @param {Object} metadata - Additional metadata (approvedBy, reason, etc.)
751
+ */
752
+ export async function setApprovalGate(featureName, gate, approved, metadata = {}) {
753
+ const state = loadState();
754
+ const feature = await ensureFeature(featureName);
755
+
756
+ if (!feature.approvalGates) {
757
+ feature.approvalGates = {
758
+ proposal: { approved: false, timestamp: null, approvedBy: null },
759
+ uiux: { approved: false, timestamp: null, approvedBy: null },
760
+ plan: { approved: false, timestamp: null, approvedBy: null },
761
+ review: { approved: false, timestamp: null, approvedBy: null }
762
+ };
763
+ }
764
+
765
+ feature.approvalGates[gate] = {
766
+ approved,
767
+ timestamp: metadata.approvedAt || metadata.rejectedAt || new Date().toISOString(),
768
+ approvedBy: metadata.approvedBy || metadata.rejectedBy || null,
769
+ ...metadata
770
+ };
771
+
772
+ state.features[featureName] = feature;
773
+ saveState(state);
774
+ }
775
+
776
+ /**
777
+ * Get approval gate status
778
+ * @param {string} featureName - Feature name
779
+ * @param {string} gate - Gate name
780
+ * @returns {Object|null} Gate object or null
781
+ */
782
+ export function getApprovalGate(featureName, gate) {
783
+ const state = loadState();
784
+ const feature = state.features[featureName];
785
+
786
+ if (!feature || !feature.approvalGates) {
787
+ return null;
788
+ }
789
+
790
+ return feature.approvalGates[gate] || null;
791
+ }
792
+
793
+ /**
794
+ * Check if feature is pending approval
795
+ * @param {string} featureName - Feature name
796
+ * @returns {boolean} True if any gate is pending approval
797
+ */
798
+ export function isPendingApproval(featureName) {
799
+ const state = loadState();
800
+ const feature = state.features[featureName];
801
+
802
+ if (!feature || !feature.approvalGates) {
803
+ return false;
804
+ }
805
+
806
+ const currentPhase = feature.phase || 'proposal';
807
+
808
+ // Check if current phase has an approval gate
809
+ const phaseGateMap = {
810
+ 'plan': 'plan',
811
+ 'review': 'review',
812
+ 'uiux': 'uiux'
813
+ };
814
+
815
+ const relevantGate = phaseGateMap[currentPhase];
816
+ if (!relevantGate) {
817
+ return false;
818
+ }
819
+
820
+ const gate = feature.approvalGates[relevantGate];
821
+ return gate && !gate.approved;
822
+ }
823
+
824
+ /**
825
+ * Get approval history for a feature
826
+ * @param {string} featureName - Feature name
827
+ * @returns {Array} Array of approval events
828
+ */
829
+ export function getApprovalHistory(featureName) {
830
+ const state = loadState();
831
+ const feature = state.features[featureName];
832
+
833
+ if (!feature || !feature.approvalGates) {
834
+ return [];
835
+ }
836
+
837
+ const history = [];
838
+
839
+ Object.entries(feature.approvalGates).forEach(([gate, data]) => {
840
+ if (data.timestamp) {
841
+ history.push({
842
+ gate,
843
+ approved: data.approved,
844
+ timestamp: data.timestamp,
845
+ approvedBy: data.approvedBy || data.rejectedBy,
846
+ reason: data.reason
847
+ });
848
+ }
849
+ });
850
+
851
+ // Sort by timestamp
852
+ return history.sort((a, b) => new Date(a.timestamp) - new Date(b.timestamp));
853
+ }
854
+
855
+ /**
856
+ * Find which agent was assigned a specific task in the latest dispatch.
857
+ * Searches only the last dispatchLog entry.
858
+ *
859
+ * @param {string} featureName
860
+ * @param {string} taskId
861
+ * @returns {string|null} Agent ID or null if not found
862
+ */
863
+ export function resolveAgentForTask(featureName, taskId) {
864
+ const state = loadState();
865
+ const feature = state.features?.[featureName];
866
+ if (!feature) return null;
867
+
868
+ const log = feature.dispatchLog;
869
+ if (!Array.isArray(log) || log.length === 0) return null;
870
+
871
+ const latest = log[log.length - 1];
872
+ for (const agent of latest.agents || []) {
873
+ if (Array.isArray(agent.tasks) && agent.tasks.includes(taskId)) {
874
+ return agent.id;
875
+ }
876
+ }
877
+ return null;
878
+ }
879
+
880
+ /**
881
+ * Update timing fields on a specific agent in the latest dispatch entry.
882
+ * Fail-open: returns silently if feature/dispatch/agent not found.
883
+ *
884
+ * @param {string} featureName
885
+ * @param {string} agentId - Agent ID to update
886
+ * @param {Object} updates - Fields to merge (startedAt, completedAt, tasksCompleted)
887
+ */
888
+ export function updateDispatchAgentTiming(featureName, agentId, updates) {
889
+ const state = loadState();
890
+ const feature = state.features?.[featureName];
891
+ if (!feature) return;
892
+
893
+ const log = feature.dispatchLog;
894
+ if (!Array.isArray(log) || log.length === 0) return;
895
+
896
+ const latest = log[log.length - 1];
897
+ const agent = (latest.agents || []).find(a => a.id === agentId);
898
+ if (!agent) return;
899
+
900
+ Object.assign(agent, updates);
901
+
902
+ // If all agents now have completedAt, set entry.completedAt to the latest
903
+ const allComplete = (latest.agents || []).every(a => a.completedAt);
904
+ if (allComplete && latest.agents.length > 0) {
905
+ const timestamps = latest.agents.map(a => new Date(a.completedAt).getTime());
906
+ latest.completedAt = new Date(Math.max(...timestamps)).toISOString();
907
+ }
908
+
909
+ saveState(state);
910
+ }