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