qaa-agent 1.6.2 → 1.7.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 (78) hide show
  1. package/.mcp.json +8 -8
  2. package/CHANGELOG.md +93 -71
  3. package/CLAUDE.md +553 -553
  4. package/agents/qa-pipeline-orchestrator.md +1378 -1378
  5. package/agents/qaa-analyzer.md +539 -524
  6. package/agents/qaa-bug-detective.md +479 -446
  7. package/agents/qaa-codebase-mapper.md +935 -935
  8. package/agents/qaa-discovery.md +384 -0
  9. package/agents/qaa-e2e-runner.md +416 -415
  10. package/agents/qaa-executor.md +651 -651
  11. package/agents/qaa-planner.md +405 -390
  12. package/agents/qaa-project-researcher.md +319 -319
  13. package/agents/qaa-scanner.md +424 -424
  14. package/agents/qaa-testid-injector.md +643 -585
  15. package/agents/qaa-validator.md +490 -452
  16. package/bin/install.cjs +200 -198
  17. package/bin/lib/commands.cjs +709 -709
  18. package/bin/lib/config.cjs +307 -307
  19. package/bin/lib/core.cjs +497 -497
  20. package/bin/lib/frontmatter.cjs +299 -299
  21. package/bin/lib/init.cjs +989 -989
  22. package/bin/lib/milestone.cjs +241 -241
  23. package/bin/lib/model-profiles.cjs +60 -60
  24. package/bin/lib/phase.cjs +911 -911
  25. package/bin/lib/roadmap.cjs +306 -306
  26. package/bin/lib/state.cjs +748 -748
  27. package/bin/lib/template.cjs +222 -222
  28. package/bin/lib/verify.cjs +842 -842
  29. package/bin/qaa-tools.cjs +607 -607
  30. package/commands/qa-audit.md +119 -0
  31. package/commands/qa-create-test.md +288 -0
  32. package/commands/qa-fix.md +147 -0
  33. package/commands/qa-map.md +137 -0
  34. package/{.claude/commands → commands}/qa-pr.md +23 -23
  35. package/{.claude/commands → commands}/qa-start.md +22 -22
  36. package/{.claude/commands → commands}/qa-testid.md +19 -19
  37. package/docs/COMMANDS.md +341 -341
  38. package/docs/DEMO.md +182 -182
  39. package/docs/TESTING.md +156 -156
  40. package/package.json +6 -7
  41. package/{.claude/settings.json → settings.json} +1 -2
  42. package/templates/failure-classification.md +391 -391
  43. package/templates/gap-analysis.md +409 -409
  44. package/templates/pr-template.md +48 -48
  45. package/templates/qa-analysis.md +381 -381
  46. package/templates/qa-audit-report.md +465 -465
  47. package/templates/qa-repo-blueprint.md +636 -636
  48. package/templates/scan-manifest.md +312 -312
  49. package/templates/test-inventory.md +582 -582
  50. package/templates/testid-audit-report.md +354 -354
  51. package/templates/validation-report.md +243 -243
  52. package/workflows/qa-analyze.md +296 -296
  53. package/workflows/qa-from-ticket.md +536 -536
  54. package/workflows/qa-gap.md +309 -303
  55. package/workflows/qa-pr.md +389 -389
  56. package/workflows/qa-start.md +1192 -1168
  57. package/workflows/qa-testid.md +384 -356
  58. package/workflows/qa-validate.md +299 -295
  59. package/.claude/commands/create-test.md +0 -164
  60. package/.claude/commands/qa-audit.md +0 -37
  61. package/.claude/commands/qa-blueprint.md +0 -54
  62. package/.claude/commands/qa-fix.md +0 -36
  63. package/.claude/commands/qa-from-ticket.md +0 -24
  64. package/.claude/commands/qa-gap.md +0 -20
  65. package/.claude/commands/qa-map.md +0 -47
  66. package/.claude/commands/qa-pom.md +0 -36
  67. package/.claude/commands/qa-pyramid.md +0 -37
  68. package/.claude/commands/qa-report.md +0 -38
  69. package/.claude/commands/qa-research.md +0 -33
  70. package/.claude/commands/qa-validate.md +0 -42
  71. package/.claude/commands/update-test.md +0 -58
  72. package/.claude/skills/qa-learner/SKILL.md +0 -150
  73. /package/{.claude/skills → skills}/qa-bug-detective/SKILL.md +0 -0
  74. /package/{.claude/skills → skills}/qa-repo-analyzer/SKILL.md +0 -0
  75. /package/{.claude/skills → skills}/qa-self-validator/SKILL.md +0 -0
  76. /package/{.claude/skills → skills}/qa-template-engine/SKILL.md +0 -0
  77. /package/{.claude/skills → skills}/qa-testid-injector/SKILL.md +0 -0
  78. /package/{.claude/skills → skills}/qa-workflow-documenter/SKILL.md +0 -0
@@ -1,299 +1,299 @@
1
- /**
2
- * Frontmatter — YAML frontmatter parsing, serialization, and CRUD commands
3
- */
4
-
5
- const fs = require('fs');
6
- const path = require('path');
7
- const { safeReadFile, output, error } = require('./core.cjs');
8
-
9
- // ─── Parsing engine ───────────────────────────────────────────────────────────
10
-
11
- function extractFrontmatter(content) {
12
- const frontmatter = {};
13
- const match = content.match(/^---\n([\s\S]+?)\n---/);
14
- if (!match) return frontmatter;
15
-
16
- const yaml = match[1];
17
- const lines = yaml.split('\n');
18
-
19
- // Stack to track nested objects: [{obj, key, indent}]
20
- // obj = object to write to, key = current key collecting array items, indent = indentation level
21
- let stack = [{ obj: frontmatter, key: null, indent: -1 }];
22
-
23
- for (const line of lines) {
24
- // Skip empty lines
25
- if (line.trim() === '') continue;
26
-
27
- // Calculate indentation (number of leading spaces)
28
- const indentMatch = line.match(/^(\s*)/);
29
- const indent = indentMatch ? indentMatch[1].length : 0;
30
-
31
- // Pop stack back to appropriate level
32
- while (stack.length > 1 && indent <= stack[stack.length - 1].indent) {
33
- stack.pop();
34
- }
35
-
36
- const current = stack[stack.length - 1];
37
-
38
- // Check for key: value pattern
39
- const keyMatch = line.match(/^(\s*)([a-zA-Z0-9_-]+):\s*(.*)/);
40
- if (keyMatch) {
41
- const key = keyMatch[2];
42
- const value = keyMatch[3].trim();
43
-
44
- if (value === '' || value === '[') {
45
- // Key with no value or opening bracket — could be nested object or array
46
- // We'll determine based on next lines, for now create placeholder
47
- current.obj[key] = value === '[' ? [] : {};
48
- current.key = null;
49
- // Push new context for potential nested content
50
- stack.push({ obj: current.obj[key], key: null, indent });
51
- } else if (value.startsWith('[') && value.endsWith(']')) {
52
- // Inline array: key: [a, b, c]
53
- current.obj[key] = value.slice(1, -1).split(',').map(s => s.trim().replace(/^["']|["']$/g, '')).filter(Boolean);
54
- current.key = null;
55
- } else {
56
- // Simple key: value
57
- current.obj[key] = value.replace(/^["']|["']$/g, '');
58
- current.key = null;
59
- }
60
- } else if (line.trim().startsWith('- ')) {
61
- // Array item
62
- const itemValue = line.trim().slice(2).replace(/^["']|["']$/g, '');
63
-
64
- // If current context is an empty object, convert to array
65
- if (typeof current.obj === 'object' && !Array.isArray(current.obj) && Object.keys(current.obj).length === 0) {
66
- // Find the key in parent that points to this object and convert it
67
- const parent = stack.length > 1 ? stack[stack.length - 2] : null;
68
- if (parent) {
69
- for (const k of Object.keys(parent.obj)) {
70
- if (parent.obj[k] === current.obj) {
71
- parent.obj[k] = [itemValue];
72
- current.obj = parent.obj[k];
73
- break;
74
- }
75
- }
76
- }
77
- } else if (Array.isArray(current.obj)) {
78
- current.obj.push(itemValue);
79
- }
80
- }
81
- }
82
-
83
- return frontmatter;
84
- }
85
-
86
- function reconstructFrontmatter(obj) {
87
- const lines = [];
88
- for (const [key, value] of Object.entries(obj)) {
89
- if (value === null || value === undefined) continue;
90
- if (Array.isArray(value)) {
91
- if (value.length === 0) {
92
- lines.push(`${key}: []`);
93
- } else if (value.every(v => typeof v === 'string') && value.length <= 3 && value.join(', ').length < 60) {
94
- lines.push(`${key}: [${value.join(', ')}]`);
95
- } else {
96
- lines.push(`${key}:`);
97
- for (const item of value) {
98
- lines.push(` - ${typeof item === 'string' && (item.includes(':') || item.includes('#')) ? `"${item}"` : item}`);
99
- }
100
- }
101
- } else if (typeof value === 'object') {
102
- lines.push(`${key}:`);
103
- for (const [subkey, subval] of Object.entries(value)) {
104
- if (subval === null || subval === undefined) continue;
105
- if (Array.isArray(subval)) {
106
- if (subval.length === 0) {
107
- lines.push(` ${subkey}: []`);
108
- } else if (subval.every(v => typeof v === 'string') && subval.length <= 3 && subval.join(', ').length < 60) {
109
- lines.push(` ${subkey}: [${subval.join(', ')}]`);
110
- } else {
111
- lines.push(` ${subkey}:`);
112
- for (const item of subval) {
113
- lines.push(` - ${typeof item === 'string' && (item.includes(':') || item.includes('#')) ? `"${item}"` : item}`);
114
- }
115
- }
116
- } else if (typeof subval === 'object') {
117
- lines.push(` ${subkey}:`);
118
- for (const [subsubkey, subsubval] of Object.entries(subval)) {
119
- if (subsubval === null || subsubval === undefined) continue;
120
- if (Array.isArray(subsubval)) {
121
- if (subsubval.length === 0) {
122
- lines.push(` ${subsubkey}: []`);
123
- } else {
124
- lines.push(` ${subsubkey}:`);
125
- for (const item of subsubval) {
126
- lines.push(` - ${item}`);
127
- }
128
- }
129
- } else {
130
- lines.push(` ${subsubkey}: ${subsubval}`);
131
- }
132
- }
133
- } else {
134
- const sv = String(subval);
135
- lines.push(` ${subkey}: ${sv.includes(':') || sv.includes('#') ? `"${sv}"` : sv}`);
136
- }
137
- }
138
- } else {
139
- const sv = String(value);
140
- if (sv.includes(':') || sv.includes('#') || sv.startsWith('[') || sv.startsWith('{')) {
141
- lines.push(`${key}: "${sv}"`);
142
- } else {
143
- lines.push(`${key}: ${sv}`);
144
- }
145
- }
146
- }
147
- return lines.join('\n');
148
- }
149
-
150
- function spliceFrontmatter(content, newObj) {
151
- const yamlStr = reconstructFrontmatter(newObj);
152
- const match = content.match(/^---\n[\s\S]+?\n---/);
153
- if (match) {
154
- return `---\n${yamlStr}\n---` + content.slice(match[0].length);
155
- }
156
- return `---\n${yamlStr}\n---\n\n` + content;
157
- }
158
-
159
- function parseMustHavesBlock(content, blockName) {
160
- // Extract a specific block from must_haves in raw frontmatter YAML
161
- // Handles 3-level nesting: must_haves > artifacts/key_links > [{path, provides, ...}]
162
- const fmMatch = content.match(/^---\n([\s\S]+?)\n---/);
163
- if (!fmMatch) return [];
164
-
165
- const yaml = fmMatch[1];
166
- // Find the block (e.g., "truths:", "artifacts:", "key_links:")
167
- const blockPattern = new RegExp(`^\\s{4}${blockName}:\\s*$`, 'm');
168
- const blockStart = yaml.search(blockPattern);
169
- if (blockStart === -1) return [];
170
-
171
- const afterBlock = yaml.slice(blockStart);
172
- const blockLines = afterBlock.split('\n').slice(1); // skip the header line
173
-
174
- const items = [];
175
- let current = null;
176
-
177
- for (const line of blockLines) {
178
- // Stop at same or lower indent level (non-continuation)
179
- if (line.trim() === '') continue;
180
- const indent = line.match(/^(\s*)/)[1].length;
181
- if (indent <= 4 && line.trim() !== '') break; // back to must_haves level or higher
182
-
183
- if (line.match(/^\s{6}-\s+/)) {
184
- // New list item at 6-space indent
185
- if (current) items.push(current);
186
- current = {};
187
- // Check if it's a simple string item
188
- const simpleMatch = line.match(/^\s{6}-\s+"?([^"]+)"?\s*$/);
189
- if (simpleMatch && !line.includes(':')) {
190
- current = simpleMatch[1];
191
- } else {
192
- // Key-value on same line as dash: "- path: value"
193
- const kvMatch = line.match(/^\s{6}-\s+(\w+):\s*"?([^"]*)"?\s*$/);
194
- if (kvMatch) {
195
- current = {};
196
- current[kvMatch[1]] = kvMatch[2];
197
- }
198
- }
199
- } else if (current && typeof current === 'object') {
200
- // Continuation key-value at 8+ space indent
201
- const kvMatch = line.match(/^\s{8,}(\w+):\s*"?([^"]*)"?\s*$/);
202
- if (kvMatch) {
203
- const val = kvMatch[2];
204
- // Try to parse as number
205
- current[kvMatch[1]] = /^\d+$/.test(val) ? parseInt(val, 10) : val;
206
- }
207
- // Array items under a key
208
- const arrMatch = line.match(/^\s{10,}-\s+"?([^"]+)"?\s*$/);
209
- if (arrMatch) {
210
- // Find the last key added and convert to array
211
- const keys = Object.keys(current);
212
- const lastKey = keys[keys.length - 1];
213
- if (lastKey && !Array.isArray(current[lastKey])) {
214
- current[lastKey] = current[lastKey] ? [current[lastKey]] : [];
215
- }
216
- if (lastKey) current[lastKey].push(arrMatch[1]);
217
- }
218
- }
219
- }
220
- if (current) items.push(current);
221
-
222
- return items;
223
- }
224
-
225
- // ─── Frontmatter CRUD commands ────────────────────────────────────────────────
226
-
227
- const FRONTMATTER_SCHEMAS = {
228
- plan: { required: ['phase', 'plan', 'type', 'wave', 'depends_on', 'files_modified', 'autonomous', 'must_haves'] },
229
- summary: { required: ['phase', 'plan', 'subsystem', 'tags', 'duration', 'completed'] },
230
- verification: { required: ['phase', 'verified', 'status', 'score'] },
231
- };
232
-
233
- function cmdFrontmatterGet(cwd, filePath, field, raw) {
234
- if (!filePath) { error('file path required'); }
235
- const fullPath = path.isAbsolute(filePath) ? filePath : path.join(cwd, filePath);
236
- const content = safeReadFile(fullPath);
237
- if (!content) { output({ error: 'File not found', path: filePath }, raw); return; }
238
- const fm = extractFrontmatter(content);
239
- if (field) {
240
- const value = fm[field];
241
- if (value === undefined) { output({ error: 'Field not found', field }, raw); return; }
242
- output({ [field]: value }, raw, JSON.stringify(value));
243
- } else {
244
- output(fm, raw);
245
- }
246
- }
247
-
248
- function cmdFrontmatterSet(cwd, filePath, field, value, raw) {
249
- if (!filePath || !field || value === undefined) { error('file, field, and value required'); }
250
- const fullPath = path.isAbsolute(filePath) ? filePath : path.join(cwd, filePath);
251
- if (!fs.existsSync(fullPath)) { output({ error: 'File not found', path: filePath }, raw); return; }
252
- const content = fs.readFileSync(fullPath, 'utf-8');
253
- const fm = extractFrontmatter(content);
254
- let parsedValue;
255
- try { parsedValue = JSON.parse(value); } catch { parsedValue = value; }
256
- fm[field] = parsedValue;
257
- const newContent = spliceFrontmatter(content, fm);
258
- fs.writeFileSync(fullPath, newContent, 'utf-8');
259
- output({ updated: true, field, value: parsedValue }, raw, 'true');
260
- }
261
-
262
- function cmdFrontmatterMerge(cwd, filePath, data, raw) {
263
- if (!filePath || !data) { error('file and data required'); }
264
- const fullPath = path.isAbsolute(filePath) ? filePath : path.join(cwd, filePath);
265
- if (!fs.existsSync(fullPath)) { output({ error: 'File not found', path: filePath }, raw); return; }
266
- const content = fs.readFileSync(fullPath, 'utf-8');
267
- const fm = extractFrontmatter(content);
268
- let mergeData;
269
- try { mergeData = JSON.parse(data); } catch { error('Invalid JSON for --data'); return; }
270
- Object.assign(fm, mergeData);
271
- const newContent = spliceFrontmatter(content, fm);
272
- fs.writeFileSync(fullPath, newContent, 'utf-8');
273
- output({ merged: true, fields: Object.keys(mergeData) }, raw, 'true');
274
- }
275
-
276
- function cmdFrontmatterValidate(cwd, filePath, schemaName, raw) {
277
- if (!filePath || !schemaName) { error('file and schema required'); }
278
- const schema = FRONTMATTER_SCHEMAS[schemaName];
279
- if (!schema) { error(`Unknown schema: ${schemaName}. Available: ${Object.keys(FRONTMATTER_SCHEMAS).join(', ')}`); }
280
- const fullPath = path.isAbsolute(filePath) ? filePath : path.join(cwd, filePath);
281
- const content = safeReadFile(fullPath);
282
- if (!content) { output({ error: 'File not found', path: filePath }, raw); return; }
283
- const fm = extractFrontmatter(content);
284
- const missing = schema.required.filter(f => fm[f] === undefined);
285
- const present = schema.required.filter(f => fm[f] !== undefined);
286
- output({ valid: missing.length === 0, missing, present, schema: schemaName }, raw, missing.length === 0 ? 'valid' : 'invalid');
287
- }
288
-
289
- module.exports = {
290
- extractFrontmatter,
291
- reconstructFrontmatter,
292
- spliceFrontmatter,
293
- parseMustHavesBlock,
294
- FRONTMATTER_SCHEMAS,
295
- cmdFrontmatterGet,
296
- cmdFrontmatterSet,
297
- cmdFrontmatterMerge,
298
- cmdFrontmatterValidate,
299
- };
1
+ /**
2
+ * Frontmatter — YAML frontmatter parsing, serialization, and CRUD commands
3
+ */
4
+
5
+ const fs = require('fs');
6
+ const path = require('path');
7
+ const { safeReadFile, output, error } = require('./core.cjs');
8
+
9
+ // ─── Parsing engine ───────────────────────────────────────────────────────────
10
+
11
+ function extractFrontmatter(content) {
12
+ const frontmatter = {};
13
+ const match = content.match(/^---\n([\s\S]+?)\n---/);
14
+ if (!match) return frontmatter;
15
+
16
+ const yaml = match[1];
17
+ const lines = yaml.split('\n');
18
+
19
+ // Stack to track nested objects: [{obj, key, indent}]
20
+ // obj = object to write to, key = current key collecting array items, indent = indentation level
21
+ let stack = [{ obj: frontmatter, key: null, indent: -1 }];
22
+
23
+ for (const line of lines) {
24
+ // Skip empty lines
25
+ if (line.trim() === '') continue;
26
+
27
+ // Calculate indentation (number of leading spaces)
28
+ const indentMatch = line.match(/^(\s*)/);
29
+ const indent = indentMatch ? indentMatch[1].length : 0;
30
+
31
+ // Pop stack back to appropriate level
32
+ while (stack.length > 1 && indent <= stack[stack.length - 1].indent) {
33
+ stack.pop();
34
+ }
35
+
36
+ const current = stack[stack.length - 1];
37
+
38
+ // Check for key: value pattern
39
+ const keyMatch = line.match(/^(\s*)([a-zA-Z0-9_-]+):\s*(.*)/);
40
+ if (keyMatch) {
41
+ const key = keyMatch[2];
42
+ const value = keyMatch[3].trim();
43
+
44
+ if (value === '' || value === '[') {
45
+ // Key with no value or opening bracket — could be nested object or array
46
+ // We'll determine based on next lines, for now create placeholder
47
+ current.obj[key] = value === '[' ? [] : {};
48
+ current.key = null;
49
+ // Push new context for potential nested content
50
+ stack.push({ obj: current.obj[key], key: null, indent });
51
+ } else if (value.startsWith('[') && value.endsWith(']')) {
52
+ // Inline array: key: [a, b, c]
53
+ current.obj[key] = value.slice(1, -1).split(',').map(s => s.trim().replace(/^["']|["']$/g, '')).filter(Boolean);
54
+ current.key = null;
55
+ } else {
56
+ // Simple key: value
57
+ current.obj[key] = value.replace(/^["']|["']$/g, '');
58
+ current.key = null;
59
+ }
60
+ } else if (line.trim().startsWith('- ')) {
61
+ // Array item
62
+ const itemValue = line.trim().slice(2).replace(/^["']|["']$/g, '');
63
+
64
+ // If current context is an empty object, convert to array
65
+ if (typeof current.obj === 'object' && !Array.isArray(current.obj) && Object.keys(current.obj).length === 0) {
66
+ // Find the key in parent that points to this object and convert it
67
+ const parent = stack.length > 1 ? stack[stack.length - 2] : null;
68
+ if (parent) {
69
+ for (const k of Object.keys(parent.obj)) {
70
+ if (parent.obj[k] === current.obj) {
71
+ parent.obj[k] = [itemValue];
72
+ current.obj = parent.obj[k];
73
+ break;
74
+ }
75
+ }
76
+ }
77
+ } else if (Array.isArray(current.obj)) {
78
+ current.obj.push(itemValue);
79
+ }
80
+ }
81
+ }
82
+
83
+ return frontmatter;
84
+ }
85
+
86
+ function reconstructFrontmatter(obj) {
87
+ const lines = [];
88
+ for (const [key, value] of Object.entries(obj)) {
89
+ if (value === null || value === undefined) continue;
90
+ if (Array.isArray(value)) {
91
+ if (value.length === 0) {
92
+ lines.push(`${key}: []`);
93
+ } else if (value.every(v => typeof v === 'string') && value.length <= 3 && value.join(', ').length < 60) {
94
+ lines.push(`${key}: [${value.join(', ')}]`);
95
+ } else {
96
+ lines.push(`${key}:`);
97
+ for (const item of value) {
98
+ lines.push(` - ${typeof item === 'string' && (item.includes(':') || item.includes('#')) ? `"${item}"` : item}`);
99
+ }
100
+ }
101
+ } else if (typeof value === 'object') {
102
+ lines.push(`${key}:`);
103
+ for (const [subkey, subval] of Object.entries(value)) {
104
+ if (subval === null || subval === undefined) continue;
105
+ if (Array.isArray(subval)) {
106
+ if (subval.length === 0) {
107
+ lines.push(` ${subkey}: []`);
108
+ } else if (subval.every(v => typeof v === 'string') && subval.length <= 3 && subval.join(', ').length < 60) {
109
+ lines.push(` ${subkey}: [${subval.join(', ')}]`);
110
+ } else {
111
+ lines.push(` ${subkey}:`);
112
+ for (const item of subval) {
113
+ lines.push(` - ${typeof item === 'string' && (item.includes(':') || item.includes('#')) ? `"${item}"` : item}`);
114
+ }
115
+ }
116
+ } else if (typeof subval === 'object') {
117
+ lines.push(` ${subkey}:`);
118
+ for (const [subsubkey, subsubval] of Object.entries(subval)) {
119
+ if (subsubval === null || subsubval === undefined) continue;
120
+ if (Array.isArray(subsubval)) {
121
+ if (subsubval.length === 0) {
122
+ lines.push(` ${subsubkey}: []`);
123
+ } else {
124
+ lines.push(` ${subsubkey}:`);
125
+ for (const item of subsubval) {
126
+ lines.push(` - ${item}`);
127
+ }
128
+ }
129
+ } else {
130
+ lines.push(` ${subsubkey}: ${subsubval}`);
131
+ }
132
+ }
133
+ } else {
134
+ const sv = String(subval);
135
+ lines.push(` ${subkey}: ${sv.includes(':') || sv.includes('#') ? `"${sv}"` : sv}`);
136
+ }
137
+ }
138
+ } else {
139
+ const sv = String(value);
140
+ if (sv.includes(':') || sv.includes('#') || sv.startsWith('[') || sv.startsWith('{')) {
141
+ lines.push(`${key}: "${sv}"`);
142
+ } else {
143
+ lines.push(`${key}: ${sv}`);
144
+ }
145
+ }
146
+ }
147
+ return lines.join('\n');
148
+ }
149
+
150
+ function spliceFrontmatter(content, newObj) {
151
+ const yamlStr = reconstructFrontmatter(newObj);
152
+ const match = content.match(/^---\n[\s\S]+?\n---/);
153
+ if (match) {
154
+ return `---\n${yamlStr}\n---` + content.slice(match[0].length);
155
+ }
156
+ return `---\n${yamlStr}\n---\n\n` + content;
157
+ }
158
+
159
+ function parseMustHavesBlock(content, blockName) {
160
+ // Extract a specific block from must_haves in raw frontmatter YAML
161
+ // Handles 3-level nesting: must_haves > artifacts/key_links > [{path, provides, ...}]
162
+ const fmMatch = content.match(/^---\n([\s\S]+?)\n---/);
163
+ if (!fmMatch) return [];
164
+
165
+ const yaml = fmMatch[1];
166
+ // Find the block (e.g., "truths:", "artifacts:", "key_links:")
167
+ const blockPattern = new RegExp(`^\\s{4}${blockName}:\\s*$`, 'm');
168
+ const blockStart = yaml.search(blockPattern);
169
+ if (blockStart === -1) return [];
170
+
171
+ const afterBlock = yaml.slice(blockStart);
172
+ const blockLines = afterBlock.split('\n').slice(1); // skip the header line
173
+
174
+ const items = [];
175
+ let current = null;
176
+
177
+ for (const line of blockLines) {
178
+ // Stop at same or lower indent level (non-continuation)
179
+ if (line.trim() === '') continue;
180
+ const indent = line.match(/^(\s*)/)[1].length;
181
+ if (indent <= 4 && line.trim() !== '') break; // back to must_haves level or higher
182
+
183
+ if (line.match(/^\s{6}-\s+/)) {
184
+ // New list item at 6-space indent
185
+ if (current) items.push(current);
186
+ current = {};
187
+ // Check if it's a simple string item
188
+ const simpleMatch = line.match(/^\s{6}-\s+"?([^"]+)"?\s*$/);
189
+ if (simpleMatch && !line.includes(':')) {
190
+ current = simpleMatch[1];
191
+ } else {
192
+ // Key-value on same line as dash: "- path: value"
193
+ const kvMatch = line.match(/^\s{6}-\s+(\w+):\s*"?([^"]*)"?\s*$/);
194
+ if (kvMatch) {
195
+ current = {};
196
+ current[kvMatch[1]] = kvMatch[2];
197
+ }
198
+ }
199
+ } else if (current && typeof current === 'object') {
200
+ // Continuation key-value at 8+ space indent
201
+ const kvMatch = line.match(/^\s{8,}(\w+):\s*"?([^"]*)"?\s*$/);
202
+ if (kvMatch) {
203
+ const val = kvMatch[2];
204
+ // Try to parse as number
205
+ current[kvMatch[1]] = /^\d+$/.test(val) ? parseInt(val, 10) : val;
206
+ }
207
+ // Array items under a key
208
+ const arrMatch = line.match(/^\s{10,}-\s+"?([^"]+)"?\s*$/);
209
+ if (arrMatch) {
210
+ // Find the last key added and convert to array
211
+ const keys = Object.keys(current);
212
+ const lastKey = keys[keys.length - 1];
213
+ if (lastKey && !Array.isArray(current[lastKey])) {
214
+ current[lastKey] = current[lastKey] ? [current[lastKey]] : [];
215
+ }
216
+ if (lastKey) current[lastKey].push(arrMatch[1]);
217
+ }
218
+ }
219
+ }
220
+ if (current) items.push(current);
221
+
222
+ return items;
223
+ }
224
+
225
+ // ─── Frontmatter CRUD commands ────────────────────────────────────────────────
226
+
227
+ const FRONTMATTER_SCHEMAS = {
228
+ plan: { required: ['phase', 'plan', 'type', 'wave', 'depends_on', 'files_modified', 'autonomous', 'must_haves'] },
229
+ summary: { required: ['phase', 'plan', 'subsystem', 'tags', 'duration', 'completed'] },
230
+ verification: { required: ['phase', 'verified', 'status', 'score'] },
231
+ };
232
+
233
+ function cmdFrontmatterGet(cwd, filePath, field, raw) {
234
+ if (!filePath) { error('file path required'); }
235
+ const fullPath = path.isAbsolute(filePath) ? filePath : path.join(cwd, filePath);
236
+ const content = safeReadFile(fullPath);
237
+ if (!content) { output({ error: 'File not found', path: filePath }, raw); return; }
238
+ const fm = extractFrontmatter(content);
239
+ if (field) {
240
+ const value = fm[field];
241
+ if (value === undefined) { output({ error: 'Field not found', field }, raw); return; }
242
+ output({ [field]: value }, raw, JSON.stringify(value));
243
+ } else {
244
+ output(fm, raw);
245
+ }
246
+ }
247
+
248
+ function cmdFrontmatterSet(cwd, filePath, field, value, raw) {
249
+ if (!filePath || !field || value === undefined) { error('file, field, and value required'); }
250
+ const fullPath = path.isAbsolute(filePath) ? filePath : path.join(cwd, filePath);
251
+ if (!fs.existsSync(fullPath)) { output({ error: 'File not found', path: filePath }, raw); return; }
252
+ const content = fs.readFileSync(fullPath, 'utf-8');
253
+ const fm = extractFrontmatter(content);
254
+ let parsedValue;
255
+ try { parsedValue = JSON.parse(value); } catch { parsedValue = value; }
256
+ fm[field] = parsedValue;
257
+ const newContent = spliceFrontmatter(content, fm);
258
+ fs.writeFileSync(fullPath, newContent, 'utf-8');
259
+ output({ updated: true, field, value: parsedValue }, raw, 'true');
260
+ }
261
+
262
+ function cmdFrontmatterMerge(cwd, filePath, data, raw) {
263
+ if (!filePath || !data) { error('file and data required'); }
264
+ const fullPath = path.isAbsolute(filePath) ? filePath : path.join(cwd, filePath);
265
+ if (!fs.existsSync(fullPath)) { output({ error: 'File not found', path: filePath }, raw); return; }
266
+ const content = fs.readFileSync(fullPath, 'utf-8');
267
+ const fm = extractFrontmatter(content);
268
+ let mergeData;
269
+ try { mergeData = JSON.parse(data); } catch { error('Invalid JSON for --data'); return; }
270
+ Object.assign(fm, mergeData);
271
+ const newContent = spliceFrontmatter(content, fm);
272
+ fs.writeFileSync(fullPath, newContent, 'utf-8');
273
+ output({ merged: true, fields: Object.keys(mergeData) }, raw, 'true');
274
+ }
275
+
276
+ function cmdFrontmatterValidate(cwd, filePath, schemaName, raw) {
277
+ if (!filePath || !schemaName) { error('file and schema required'); }
278
+ const schema = FRONTMATTER_SCHEMAS[schemaName];
279
+ if (!schema) { error(`Unknown schema: ${schemaName}. Available: ${Object.keys(FRONTMATTER_SCHEMAS).join(', ')}`); }
280
+ const fullPath = path.isAbsolute(filePath) ? filePath : path.join(cwd, filePath);
281
+ const content = safeReadFile(fullPath);
282
+ if (!content) { output({ error: 'File not found', path: filePath }, raw); return; }
283
+ const fm = extractFrontmatter(content);
284
+ const missing = schema.required.filter(f => fm[f] === undefined);
285
+ const present = schema.required.filter(f => fm[f] !== undefined);
286
+ output({ valid: missing.length === 0, missing, present, schema: schemaName }, raw, missing.length === 0 ? 'valid' : 'invalid');
287
+ }
288
+
289
+ module.exports = {
290
+ extractFrontmatter,
291
+ reconstructFrontmatter,
292
+ spliceFrontmatter,
293
+ parseMustHavesBlock,
294
+ FRONTMATTER_SCHEMAS,
295
+ cmdFrontmatterGet,
296
+ cmdFrontmatterSet,
297
+ cmdFrontmatterMerge,
298
+ cmdFrontmatterValidate,
299
+ };