@paths.design/caws-cli 9.2.0 → 9.3.1

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 (43) hide show
  1. package/dist/commands/specs.js +28 -15
  2. package/dist/commands/status.js +1 -1
  3. package/dist/commands/verify-acs.js +471 -0
  4. package/dist/index.js +13 -1
  5. package/dist/parallel/parallel-manager.js +5 -12
  6. package/dist/scaffold/cursor-hooks.js +0 -1
  7. package/dist/scaffold/git-hooks.js +18 -1
  8. package/dist/templates/.caws/tools/README.md +4 -7
  9. package/dist/templates/.caws/tools/scope-guard.js +115 -171
  10. package/dist/templates/.claude/hooks/audit.sh +25 -0
  11. package/dist/templates/.claude/hooks/block-dangerous.sh +39 -0
  12. package/dist/templates/.claude/hooks/lite-sprawl-check.sh +30 -2
  13. package/dist/templates/.claude/hooks/naming-check.sh +5 -2
  14. package/dist/templates/.claude/hooks/scope-guard.sh +66 -4
  15. package/dist/templates/.claude/hooks/session-log.sh +38 -5
  16. package/dist/templates/.claude/rules/worktree-isolation.md +4 -1
  17. package/dist/templates/.cursor/README.md +0 -9
  18. package/dist/templates/.cursor/hooks/audit.sh +1 -1
  19. package/dist/templates/.cursor/hooks/block-dangerous.sh +1 -0
  20. package/dist/templates/.cursor/hooks/scan-secrets.sh +8 -3
  21. package/dist/templates/.cursor/hooks.json +0 -8
  22. package/dist/templates/.vscode/launch.json +0 -12
  23. package/dist/utils/detection.js +38 -0
  24. package/dist/utils/project-analysis.js +0 -1
  25. package/dist/utils/spec-resolver.js +23 -10
  26. package/dist/worktree/worktree-manager.js +160 -6
  27. package/package.json +1 -1
  28. package/templates/.caws/tools/README.md +4 -7
  29. package/templates/.caws/tools/scope-guard.js +115 -171
  30. package/templates/.claude/hooks/audit.sh +25 -0
  31. package/templates/.claude/hooks/block-dangerous.sh +39 -0
  32. package/templates/.claude/hooks/lite-sprawl-check.sh +30 -2
  33. package/templates/.claude/hooks/naming-check.sh +5 -2
  34. package/templates/.claude/hooks/scope-guard.sh +66 -4
  35. package/templates/.claude/hooks/session-log.sh +38 -5
  36. package/templates/.claude/rules/worktree-isolation.md +4 -1
  37. package/templates/.cursor/README.md +0 -9
  38. package/templates/.cursor/hooks/audit.sh +1 -1
  39. package/templates/.cursor/hooks/block-dangerous.sh +1 -0
  40. package/templates/.cursor/hooks/scan-secrets.sh +8 -3
  41. package/templates/.cursor/hooks.json +0 -8
  42. package/templates/.vscode/launch.json +0 -12
  43. package/templates/.cursor/hooks/caws-tool-validation.sh +0 -121
@@ -14,10 +14,19 @@ const { SPEC_TYPES } = require('../constants/spec-types');
14
14
 
15
15
  // Import suggestFeatureBreakdown from spec-resolver
16
16
  const { suggestFeatureBreakdown } = require('../utils/spec-resolver');
17
+ const { findProjectRoot } = require('../utils/detection');
17
18
 
18
19
  /**
19
- * Specs directory structure
20
+ * Specs directory structure — anchored to the CAWS project root,
21
+ * not process.cwd(), so the CLI works from subdirectories and monorepos.
20
22
  */
23
+ function getSpecsDir() {
24
+ return path.join(findProjectRoot(), '.caws', 'specs');
25
+ }
26
+ function getSpecsRegistry() {
27
+ return path.join(findProjectRoot(), '.caws', 'specs', 'registry.json');
28
+ }
29
+ // Legacy constants kept for backward compatibility in tests
21
30
  const SPECS_DIR = '.caws/specs';
22
31
  const SPECS_REGISTRY = '.caws/specs/registry.json';
23
32
 
@@ -27,7 +36,8 @@ const SPECS_REGISTRY = '.caws/specs/registry.json';
27
36
  */
28
37
  async function loadSpecsRegistry() {
29
38
  try {
30
- if (!(await fs.pathExists(SPECS_REGISTRY))) {
39
+ const registryPath = getSpecsRegistry();
40
+ if (!(await fs.pathExists(registryPath))) {
31
41
  return {
32
42
  version: '1.0.0',
33
43
  specs: {},
@@ -35,7 +45,7 @@ async function loadSpecsRegistry() {
35
45
  };
36
46
  }
37
47
 
38
- const registry = JSON.parse(await fs.readFile(SPECS_REGISTRY, 'utf8'));
48
+ const registry = JSON.parse(await fs.readFile(registryPath, 'utf8'));
39
49
  return registry;
40
50
  } catch (error) {
41
51
  return {
@@ -52,9 +62,10 @@ async function loadSpecsRegistry() {
52
62
  * @returns {Promise<void>}
53
63
  */
54
64
  async function saveSpecsRegistry(registry) {
55
- await fs.ensureDir(path.dirname(SPECS_REGISTRY));
65
+ const registryPath = getSpecsRegistry();
66
+ await fs.ensureDir(path.dirname(registryPath));
56
67
  registry.lastUpdated = new Date().toISOString();
57
- await fs.writeFile(SPECS_REGISTRY, JSON.stringify(registry, null, 2));
68
+ await fs.writeFile(registryPath, JSON.stringify(registry, null, 2));
58
69
  }
59
70
 
60
71
  /**
@@ -62,16 +73,17 @@ async function saveSpecsRegistry(registry) {
62
73
  * @returns {Promise<Array>} Array of spec file info
63
74
  */
64
75
  async function listSpecFiles() {
65
- if (!(await fs.pathExists(SPECS_DIR))) {
76
+ const specsDir = getSpecsDir();
77
+ if (!(await fs.pathExists(specsDir))) {
66
78
  return [];
67
79
  }
68
80
 
69
- const files = await fs.readdir(SPECS_DIR, { recursive: true });
81
+ const files = await fs.readdir(specsDir, { recursive: true });
70
82
  const yamlFiles = files.filter((file) => file.endsWith('.yaml') || file.endsWith('.yml'));
71
83
 
72
84
  const specs = [];
73
85
  for (const file of yamlFiles) {
74
- const filePath = path.join(SPECS_DIR, file);
86
+ const filePath = path.join(specsDir, file);
75
87
  try {
76
88
  const content = await fs.readFile(filePath, 'utf8');
77
89
  const spec = yaml.load(content);
@@ -121,7 +133,8 @@ async function createSpec(id, options = {}) {
121
133
  }
122
134
 
123
135
  // Check for existing spec
124
- const existingSpecPath = path.join(SPECS_DIR, `${id}.yaml`);
136
+ const specsDir = getSpecsDir();
137
+ const existingSpecPath = path.join(specsDir, `${id}.yaml`);
125
138
  const specExists = await fs.pathExists(existingSpecPath);
126
139
 
127
140
  // Handle conflict resolution
@@ -185,7 +198,7 @@ async function createSpec(id, options = {}) {
185
198
  }
186
199
 
187
200
  // Ensure specs directory exists
188
- await fs.ensureDir(SPECS_DIR);
201
+ await fs.ensureDir(specsDir);
189
202
 
190
203
  // Generate spec content with all required fields
191
204
  // Merge template carefully to preserve required fields and structure
@@ -260,7 +273,7 @@ async function createSpec(id, options = {}) {
260
273
 
261
274
  // Create file path
262
275
  const fileName = `${id}.yaml`;
263
- const filePath = path.join(SPECS_DIR, fileName);
276
+ const filePath = path.join(specsDir, fileName);
264
277
 
265
278
  // Write spec file
266
279
  const yamlContent = yaml.dump(specContent, { indent: 2 });
@@ -340,7 +353,7 @@ async function loadSpec(id) {
340
353
  return null;
341
354
  }
342
355
 
343
- const specPath = path.join(SPECS_DIR, registry.specs[id].path);
356
+ const specPath = path.join(getSpecsDir(), registry.specs[id].path);
344
357
 
345
358
  try {
346
359
  const content = await fs.readFile(specPath, 'utf8');
@@ -389,7 +402,7 @@ async function updateSpec(id, updates = {}) {
389
402
  await saveSpecsRegistry(registry);
390
403
 
391
404
  // Write back to file
392
- const specPath = path.join(SPECS_DIR, registry.specs[id].path);
405
+ const specPath = path.join(getSpecsDir(), registry.specs[id].path);
393
406
  await fs.writeFile(specPath, yaml.dump(updatedSpec, { indent: 2 }));
394
407
 
395
408
  return true;
@@ -534,7 +547,7 @@ async function deleteSpec(id) {
534
547
  return false;
535
548
  }
536
549
 
537
- const specPath = path.join(SPECS_DIR, registry.specs[id].path);
550
+ const specPath = path.join(getSpecsDir(), registry.specs[id].path);
538
551
 
539
552
  // Remove file
540
553
  await fs.remove(specPath);
@@ -673,7 +686,7 @@ async function migrateFromLegacy(options = {}, createSpecFn = createSpec) {
673
686
  const yaml = require('js-yaml');
674
687
  const chalk = require('chalk');
675
688
 
676
- const legacyPath = path.join(process.cwd(), '.caws', 'working-spec.yaml');
689
+ const legacyPath = path.join(findProjectRoot(), '.caws', 'working-spec.yaml');
677
690
 
678
691
  if (!(await fs.pathExists(legacyPath))) {
679
692
  throw new Error('No legacy working-spec.yaml found to migrate');
@@ -184,7 +184,7 @@ async function loadWaiverStatus() {
184
184
  async function checkQualityGates() {
185
185
  return {
186
186
  checked: false,
187
- message: 'Run: caws quality-gates or use MCP tool caws_quality_gates_run for full gate status',
187
+ message: 'Run: caws quality-gates for full gate status',
188
188
  };
189
189
  }
190
190
 
@@ -0,0 +1,471 @@
1
+ /**
2
+ * @fileoverview Verify Acceptance Criteria Command
3
+ * Mechanically verifies that ACs in CAWS specs are backed by real test evidence.
4
+ * Language-agnostic: detects test runner from project structure.
5
+ * @author @darianrosebrook
6
+ */
7
+
8
+ const fs = require('fs-extra');
9
+ const path = require('path');
10
+ const yaml = require('js-yaml');
11
+ const chalk = require('chalk');
12
+ const { execFileSync } = require('child_process');
13
+ const { findProjectRoot } = require('../utils/detection');
14
+
15
+ const SPECS_DIR = '.caws/specs';
16
+ const TERMINAL_STATUSES = new Set(['completed', 'closed', 'archived']);
17
+
18
+ /**
19
+ * Detect the project's test runner from config files.
20
+ * Returns a runner ID used to determine collect/run commands.
21
+ * @param {string} projectRoot
22
+ * @returns {'pytest'|'jest'|'vitest'|'cargo'|'go'|'unknown'}
23
+ */
24
+ function detectTestRunner(projectRoot) {
25
+ const checks = [
26
+ { files: ['pytest.ini', 'conftest.py', 'setup.cfg'], content: [['pyproject.toml', '[tool.pytest']], runner: 'pytest' },
27
+ { files: ['vitest.config.ts', 'vitest.config.js', 'vitest.config.mts'], runner: 'vitest' },
28
+ { files: ['jest.config.js', 'jest.config.ts', 'jest.config.mjs', 'jest.config.cjs'], content: [['package.json', '"jest"']], runner: 'jest' },
29
+ { files: ['Cargo.toml'], runner: 'cargo' },
30
+ { files: ['go.mod'], runner: 'go' },
31
+ ];
32
+
33
+ for (const check of checks) {
34
+ for (const f of (check.files || [])) {
35
+ if (fs.existsSync(path.join(projectRoot, f))) return check.runner;
36
+ }
37
+ for (const [f, needle] of (check.content || [])) {
38
+ const fp = path.join(projectRoot, f);
39
+ if (fs.existsSync(fp)) {
40
+ try {
41
+ if (fs.readFileSync(fp, 'utf8').includes(needle)) return check.runner;
42
+ } catch (_) { /* ignore unreadable config */ }
43
+ }
44
+ }
45
+ }
46
+ return 'unknown';
47
+ }
48
+
49
+ /**
50
+ * Collect (verify existence of) a test nodeid.
51
+ * @param {string} nodeid
52
+ * @param {string} runner
53
+ * @param {string} projectRoot
54
+ * @returns {{found: boolean, detail: string}}
55
+ */
56
+ function collectNodeid(nodeid, runner, projectRoot) {
57
+ try {
58
+ switch (runner) {
59
+ case 'pytest': {
60
+ const out = execFileSync('python3', ['-m', 'pytest', '--collect-only', '-q', nodeid], {
61
+ cwd: projectRoot, encoding: 'utf8', timeout: 30000, stdio: ['pipe', 'pipe', 'pipe'],
62
+ });
63
+ const lines = out.trim().split('\n').filter(l => l && !l.startsWith('='));
64
+ return { found: lines.length > 0, detail: `${lines.length} test(s) collected` };
65
+ }
66
+ case 'jest':
67
+ case 'vitest': {
68
+ // For jest/vitest, split nodeid into file path and optional test name
69
+ const parts = nodeid.split('::');
70
+ const testFile = parts[0];
71
+ if (!fs.existsSync(path.join(projectRoot, testFile))) {
72
+ return { found: false, detail: `test file not found: ${testFile}` };
73
+ }
74
+ if (parts.length > 1) {
75
+ const content = fs.readFileSync(path.join(projectRoot, testFile), 'utf8');
76
+ const testName = parts[parts.length - 1];
77
+ if (!content.includes(testName)) {
78
+ return { found: false, detail: `test name '${testName}' not found in ${testFile}` };
79
+ }
80
+ }
81
+ return { found: true, detail: `test file exists: ${testFile}` };
82
+ }
83
+ case 'cargo': {
84
+ execFileSync('cargo', ['test', nodeid, '--no-run'], {
85
+ cwd: projectRoot, encoding: 'utf8', timeout: 60000, stdio: ['pipe', 'pipe', 'pipe'],
86
+ });
87
+ return { found: true, detail: 'compiled successfully' };
88
+ }
89
+ case 'go': {
90
+ const parts = nodeid.split('::');
91
+ const pkg = parts[0] || './...';
92
+ const testName = parts[1] || '';
93
+ const args = ['test', '-list', testName || '.*', pkg];
94
+ const out = execFileSync('go', args, {
95
+ cwd: projectRoot, encoding: 'utf8', timeout: 30000, stdio: ['pipe', 'pipe', 'pipe'],
96
+ });
97
+ const found = testName ? out.includes(testName) : out.trim().length > 0;
98
+ return { found, detail: found ? 'test found' : 'test not found' };
99
+ }
100
+ default: {
101
+ // Unknown runner — check if the file exists
102
+ const testFile = nodeid.split('::')[0];
103
+ if (fs.existsSync(path.join(projectRoot, testFile))) {
104
+ return { found: true, detail: 'test file exists (runner unknown, cannot collect)' };
105
+ }
106
+ return { found: false, detail: `test file not found: ${testFile}` };
107
+ }
108
+ }
109
+ } catch (err) {
110
+ const msg = err.stderr ? err.stderr.toString().split('\n')[0] : err.message;
111
+ return { found: false, detail: msg.slice(0, 120) };
112
+ }
113
+ }
114
+
115
+ /**
116
+ * Run a test nodeid and check if it passes.
117
+ * @param {string} nodeid
118
+ * @param {string} runner
119
+ * @param {string} projectRoot
120
+ * @returns {{passed: boolean, detail: string}}
121
+ */
122
+ function runNodeid(nodeid, runner, projectRoot) {
123
+ try {
124
+ switch (runner) {
125
+ case 'pytest': {
126
+ execFileSync('python3', ['-m', 'pytest', '-x', '--tb=short', nodeid], {
127
+ cwd: projectRoot, encoding: 'utf8', timeout: 120000, stdio: ['pipe', 'pipe', 'pipe'],
128
+ });
129
+ return { passed: true, detail: 'tests passed' };
130
+ }
131
+ case 'jest': {
132
+ const parts = nodeid.split('::');
133
+ const args = ['jest', '--testPathPattern', parts[0]];
134
+ if (parts.length > 1) args.push('--testNamePattern', parts[parts.length - 1]);
135
+ execFileSync('npx', args, {
136
+ cwd: projectRoot, encoding: 'utf8', timeout: 120000, stdio: ['pipe', 'pipe', 'pipe'],
137
+ });
138
+ return { passed: true, detail: 'tests passed' };
139
+ }
140
+ case 'vitest': {
141
+ const parts = nodeid.split('::');
142
+ const args = ['vitest', 'run', parts[0]];
143
+ if (parts.length > 1) args.push('--testNamePattern', parts[parts.length - 1]);
144
+ execFileSync('npx', args, {
145
+ cwd: projectRoot, encoding: 'utf8', timeout: 120000, stdio: ['pipe', 'pipe', 'pipe'],
146
+ });
147
+ return { passed: true, detail: 'tests passed' };
148
+ }
149
+ case 'cargo': {
150
+ execFileSync('cargo', ['test', nodeid], {
151
+ cwd: projectRoot, encoding: 'utf8', timeout: 120000, stdio: ['pipe', 'pipe', 'pipe'],
152
+ });
153
+ return { passed: true, detail: 'tests passed' };
154
+ }
155
+ case 'go': {
156
+ const parts = nodeid.split('::');
157
+ const pkg = parts[0] || './...';
158
+ const testName = parts[1] || '.*';
159
+ execFileSync('go', ['test', '-run', testName, '-v', pkg], {
160
+ cwd: projectRoot, encoding: 'utf8', timeout: 120000, stdio: ['pipe', 'pipe', 'pipe'],
161
+ });
162
+ return { passed: true, detail: 'tests passed' };
163
+ }
164
+ default:
165
+ return { passed: false, detail: 'unknown test runner — cannot execute' };
166
+ }
167
+ } catch (err) {
168
+ const msg = err.stderr ? err.stderr.toString().split('\n').slice(-3).join(' ') : err.message;
169
+ return { passed: false, detail: msg.slice(0, 200) };
170
+ }
171
+ }
172
+
173
+ /**
174
+ * Run a test_command and check exit code.
175
+ * @param {string} command
176
+ * @param {string} projectRoot
177
+ * @returns {{passed: boolean, detail: string}}
178
+ */
179
+ function runTestCommand(command, projectRoot) {
180
+ try {
181
+ execFileSync('sh', ['-c', command], {
182
+ cwd: projectRoot, encoding: 'utf8', timeout: 120000, stdio: ['pipe', 'pipe', 'pipe'],
183
+ });
184
+ return { passed: true, detail: 'exit code 0' };
185
+ } catch (err) {
186
+ const msg = err.stderr ? err.stderr.toString().split('\n').slice(-2).join(' ') : err.message;
187
+ return { passed: false, detail: `exit code ${err.status || 'unknown'}: ${msg.slice(0, 150)}` };
188
+ }
189
+ }
190
+
191
+ /**
192
+ * Check if an evidence artifact exists.
193
+ * @param {string} evidence
194
+ * @param {string} projectRoot
195
+ * @returns {{found: boolean, detail: string}}
196
+ */
197
+ function checkEvidence(evidence, projectRoot) {
198
+ // If it looks like a file path, check directly
199
+ if (evidence.includes('/') || evidence.includes('.')) {
200
+ const fp = path.join(projectRoot, evidence);
201
+ if (fs.existsSync(fp)) {
202
+ return { found: true, detail: `artifact found: ${evidence}` };
203
+ }
204
+ }
205
+
206
+ // Search for the evidence ID in common test output directories
207
+ const searchDirs = ['test-results', 'test-scenarios', 'coverage', '.caws/evidence'];
208
+ for (const dir of searchDirs) {
209
+ const dp = path.join(projectRoot, dir);
210
+ if (fs.existsSync(dp)) {
211
+ try {
212
+ const files = execFileSync('find', [dp, '-name', `*${evidence}*`, '-type', 'f'], {
213
+ encoding: 'utf8', timeout: 5000, stdio: ['pipe', 'pipe', 'pipe'],
214
+ }).trim();
215
+ if (files) {
216
+ return { found: true, detail: `found: ${files.split('\n')[0]}` };
217
+ }
218
+ } catch (_) { /* ignore unreadable config */ }
219
+ }
220
+ }
221
+
222
+ return { found: false, detail: `artifact not found for: ${evidence}` };
223
+ }
224
+
225
+ /**
226
+ * Extract and merge acceptance criteria from a spec.
227
+ * Handles both `acceptance` and `acceptance_criteria` fields.
228
+ * @param {Object} spec
229
+ * @returns {Array<{id: string, description: string, test_nodeids?: string[], test_command?: string, evidence?: string, narrative?: string}>}
230
+ */
231
+ function extractACs(spec) {
232
+ const byId = new Map();
233
+
234
+ // Load from `acceptance` field (given/when/then format)
235
+ for (const ac of (spec.acceptance || [])) {
236
+ if (!ac.id) continue;
237
+ const entry = {
238
+ id: ac.id,
239
+ description: ac.then || ac.description || '',
240
+ test_nodeids: ac.test_nodeids || null,
241
+ test_command: ac.test_command || null,
242
+ evidence: ac.evidence || null,
243
+ narrative: ac.test || (ac.given ? `Given ${ac.given}, when ${ac.when}, then ${ac.then}` : null),
244
+ };
245
+ byId.set(ac.id, entry);
246
+ }
247
+
248
+ // Load from `acceptance_criteria` field (id/description format) — overrides mechanical fields
249
+ for (const ac of (spec.acceptance_criteria || [])) {
250
+ if (!ac.id) continue;
251
+ const existing = byId.get(ac.id) || { id: ac.id };
252
+ existing.description = ac.description || existing.description || '';
253
+ if (ac.test_nodeids) existing.test_nodeids = ac.test_nodeids;
254
+ if (ac.test_command) existing.test_command = ac.test_command;
255
+ if (ac.evidence) existing.evidence = ac.evidence;
256
+ if (ac.test) existing.narrative = ac.test;
257
+ byId.set(ac.id, existing);
258
+ }
259
+
260
+ return Array.from(byId.values());
261
+ }
262
+
263
+ /**
264
+ * Verify all ACs in a spec.
265
+ * @param {Object} spec
266
+ * @param {string} projectRoot
267
+ * @param {Object} options
268
+ * @param {boolean} options.run - Actually run tests (vs collect-only)
269
+ * @param {string} options.runner - Test runner override
270
+ * @returns {{specId: string, title: string, results: Array}}
271
+ */
272
+ function verifySpec(spec, projectRoot, options = {}) {
273
+ const runner = options.runner || detectTestRunner(projectRoot);
274
+ const acs = extractACs(spec);
275
+ const results = [];
276
+
277
+ for (const ac of acs) {
278
+ const result = { id: ac.id, description: ac.description, method: null, status: null, detail: '' };
279
+
280
+ if (ac.test_command) {
281
+ result.method = 'test_command';
282
+ const { passed, detail } = runTestCommand(ac.test_command, projectRoot);
283
+ result.status = passed ? 'PASS' : 'FAIL';
284
+ result.detail = detail;
285
+ } else if (ac.test_nodeids && ac.test_nodeids.length > 0) {
286
+ result.method = 'test_nodeids';
287
+ const nodeResults = [];
288
+ for (const nodeid of ac.test_nodeids) {
289
+ if (options.run) {
290
+ nodeResults.push(runNodeid(nodeid, runner, projectRoot));
291
+ } else {
292
+ const { found, detail } = collectNodeid(nodeid, runner, projectRoot);
293
+ nodeResults.push({ passed: found, detail });
294
+ }
295
+ }
296
+ const allOk = nodeResults.every(r => r.passed);
297
+ result.status = allOk ? 'PASS' : 'FAIL';
298
+ result.detail = nodeResults.map(r => r.detail).join('; ');
299
+ } else if (ac.evidence) {
300
+ result.method = 'evidence';
301
+ const { found, detail } = checkEvidence(ac.evidence, projectRoot);
302
+ result.status = found ? 'PASS' : 'FAIL';
303
+ result.detail = detail;
304
+ } else {
305
+ result.method = 'narrative';
306
+ result.status = 'unchecked';
307
+ result.detail = ac.narrative || 'no mechanical verification available';
308
+ }
309
+
310
+ results.push(result);
311
+ }
312
+
313
+ return {
314
+ specId: spec.id || 'unknown',
315
+ title: spec.title || '',
316
+ runner,
317
+ results,
318
+ };
319
+ }
320
+
321
+ /**
322
+ * Load all active specs from .caws/specs/
323
+ * @param {string} projectRoot
324
+ * @param {string} [targetSpecId] - If provided, only load this spec
325
+ * @returns {Array<{path: string, spec: Object}>}
326
+ */
327
+ function loadSpecs(projectRoot, targetSpecId) {
328
+ const specs = [];
329
+ const specsDir = path.join(projectRoot, SPECS_DIR);
330
+
331
+ if (targetSpecId) {
332
+ const specPath = path.join(specsDir, `${targetSpecId}.yaml`);
333
+ if (!fs.existsSync(specPath)) {
334
+ const ymlPath = path.join(specsDir, `${targetSpecId}.yml`);
335
+ if (!fs.existsSync(ymlPath)) {
336
+ throw new Error(`Spec '${targetSpecId}' not found at ${specPath}`);
337
+ }
338
+ specs.push({ path: ymlPath, spec: yaml.load(fs.readFileSync(ymlPath, 'utf8')) });
339
+ } else {
340
+ specs.push({ path: specPath, spec: yaml.load(fs.readFileSync(specPath, 'utf8')) });
341
+ }
342
+ return specs;
343
+ }
344
+
345
+ // Also check working-spec.yaml
346
+ const workingSpec = path.join(projectRoot, '.caws/working-spec.yaml');
347
+ if (fs.existsSync(workingSpec)) {
348
+ try {
349
+ const s = yaml.load(fs.readFileSync(workingSpec, 'utf8'));
350
+ if (s && !TERMINAL_STATUSES.has(s.status)) {
351
+ specs.push({ path: workingSpec, spec: s });
352
+ }
353
+ } catch (_) { /* ignore unreadable config */ }
354
+ }
355
+
356
+ if (fs.existsSync(specsDir)) {
357
+ for (const f of fs.readdirSync(specsDir).filter(f => f.endsWith('.yaml') || f.endsWith('.yml'))) {
358
+ try {
359
+ const s = yaml.load(fs.readFileSync(path.join(specsDir, f), 'utf8'));
360
+ if (s && !TERMINAL_STATUSES.has(s.status)) {
361
+ specs.push({ path: path.join(specsDir, f), spec: s });
362
+ }
363
+ } catch (_) { /* ignore unreadable config */ }
364
+ }
365
+ }
366
+
367
+ return specs;
368
+ }
369
+
370
+ /**
371
+ * Main command handler
372
+ */
373
+ async function verifyAcsCommand(options = {}) {
374
+ const projectRoot = findProjectRoot();
375
+
376
+ // Check for CAWS project
377
+ if (!fs.existsSync(path.join(projectRoot, '.caws'))) {
378
+ console.error(chalk.red('Not a CAWS project (no .caws/ directory found)'));
379
+ process.exit(1);
380
+ }
381
+
382
+ const specs = loadSpecs(projectRoot, options.specId);
383
+
384
+ if (specs.length === 0) {
385
+ console.log(chalk.yellow('No active specs found.'));
386
+ return;
387
+ }
388
+
389
+ const allResults = [];
390
+ let totalAcs = 0, totalMechanical = 0, totalPass = 0, totalFail = 0, totalUnchecked = 0;
391
+
392
+ for (const { spec } of specs) {
393
+ const acs = extractACs(spec);
394
+ if (acs.length === 0) continue;
395
+
396
+ const result = verifySpec(spec, projectRoot, {
397
+ run: options.run || false,
398
+ runner: options.runner,
399
+ });
400
+ allResults.push(result);
401
+
402
+ // Tally
403
+ for (const r of result.results) {
404
+ totalAcs++;
405
+ if (r.status === 'PASS') { totalPass++; totalMechanical++; }
406
+ else if (r.status === 'FAIL') { totalFail++; totalMechanical++; }
407
+ else { totalUnchecked++; }
408
+ }
409
+ }
410
+
411
+ // Output
412
+ if (options.format === 'json') {
413
+ console.log(JSON.stringify({
414
+ summary: { total: totalAcs, mechanical: totalMechanical, pass: totalPass, fail: totalFail, unchecked: totalUnchecked },
415
+ specs: allResults,
416
+ }, null, 2));
417
+ process.exit(totalFail > 0 ? 1 : 0);
418
+ return;
419
+ }
420
+
421
+ // Table output
422
+ for (const result of allResults) {
423
+ console.log(chalk.cyan(`\n## ${result.specId} — ${result.title}`));
424
+ console.log(chalk.gray(` Test runner: ${result.runner} | Mode: ${options.run ? 'run' : 'collect-only'}`));
425
+ console.log();
426
+
427
+ const widths = { id: 8, desc: 40, method: 14, status: 10 };
428
+ console.log(
429
+ chalk.gray(
430
+ ` ${'AC'.padEnd(widths.id)}${'Description'.padEnd(widths.desc)}${'Method'.padEnd(widths.method)}${'Status'.padEnd(widths.status)}Detail`
431
+ )
432
+ );
433
+ console.log(chalk.gray(' ' + '-'.repeat(widths.id + widths.desc + widths.method + widths.status + 20)));
434
+
435
+ for (const r of result.results) {
436
+ const statusColor = r.status === 'PASS' ? chalk.green : r.status === 'FAIL' ? chalk.red : chalk.yellow;
437
+ const desc = r.description.length > widths.desc - 2
438
+ ? r.description.slice(0, widths.desc - 5) + '...'
439
+ : r.description;
440
+ const detail = r.detail.length > 60 ? r.detail.slice(0, 57) + '...' : r.detail;
441
+
442
+ console.log(
443
+ ` ${chalk.white(r.id.padEnd(widths.id))}${desc.padEnd(widths.desc)}${(r.method || '').padEnd(widths.method)}${statusColor(r.status.padEnd(widths.status))}${chalk.gray(detail)}`
444
+ );
445
+ }
446
+ }
447
+
448
+ // Summary
449
+ console.log(chalk.cyan('\n## Summary'));
450
+ console.log(` Total ACs: ${totalAcs} | Mechanical: ${totalMechanical} | ${chalk.green(`Pass: ${totalPass}`)} | ${chalk.red(`Fail: ${totalFail}`)} | ${chalk.yellow(`Unchecked: ${totalUnchecked}`)}`);
451
+
452
+ if (totalFail > 0) {
453
+ console.log(chalk.red('\n Some acceptance criteria failed verification.'));
454
+ process.exit(1);
455
+ } else if (totalMechanical === 0 && totalAcs > 0) {
456
+ console.log(chalk.yellow('\n No ACs have mechanical verification. Consider adding test_nodeids or test_command.'));
457
+ } else {
458
+ console.log(chalk.green('\n All mechanically-verifiable ACs passed.'));
459
+ }
460
+ }
461
+
462
+ module.exports = {
463
+ verifyAcsCommand,
464
+ verifySpec,
465
+ extractACs,
466
+ detectTestRunner,
467
+ collectNodeid,
468
+ runNodeid,
469
+ runTestCommand,
470
+ checkEvidence,
471
+ };
package/dist/index.js CHANGED
@@ -52,6 +52,7 @@ const { planCommand } = require('./commands/plan');
52
52
  const { worktreeCommand } = require('./commands/worktree');
53
53
  const { sessionCommand } = require('./commands/session');
54
54
  const { parallelCommand } = require('./commands/parallel');
55
+ const { verifyAcsCommand } = require('./commands/verify-acs');
55
56
 
56
57
  // Import scaffold functionality
57
58
  const { scaffoldProject, setScaffoldDependencies } = require('./scaffold');
@@ -463,7 +464,7 @@ parallelCmd
463
464
  parallelCmd
464
465
  .command('merge')
465
466
  .description('Merge all parallel branches back to base')
466
- .option('--strategy <strategy>', 'Merge strategy: rebase, merge, or squash', 'merge')
467
+ .option('--strategy <strategy>', 'Merge strategy: merge or squash', 'merge')
467
468
  .option('--dry-run', 'Preview merge without executing', false)
468
469
  .option('--force', 'Force merge even with detected conflicts', false)
469
470
  .action((options) => parallelCommand('merge', options));
@@ -490,6 +491,16 @@ program
490
491
  .option('--fix', 'Apply automatic fixes', false)
491
492
  .action(diagnoseCommand);
492
493
 
494
+ // Verify Acceptance Criteria command
495
+ program
496
+ .command('verify-acs')
497
+ .description('Verify acceptance criteria in specs are backed by test evidence')
498
+ .option('--spec-id <id>', 'Verify only this spec')
499
+ .option('--run', 'Actually run tests (default: collect-only)', false)
500
+ .option('--runner <runner>', 'Force test runner (pytest, jest, vitest, cargo, go)')
501
+ .option('--format <format>', 'Output format (text, json)', 'text')
502
+ .action(verifyAcsCommand);
503
+
493
504
  // Evaluate command
494
505
  program
495
506
  .command('evaluate [spec-file]')
@@ -746,6 +757,7 @@ program.exitOverride((err) => {
746
757
  'worktree',
747
758
  'session',
748
759
  'parallel',
760
+ 'verify-acs',
749
761
  ];
750
762
  const similar = findSimilarCommand(commandName, validCommands);
751
763
 
@@ -20,7 +20,9 @@ const {
20
20
  // session-manager available if needed: require('../session/session-manager')
21
21
 
22
22
  const PARALLEL_REGISTRY = '.caws/parallel.json';
23
- const VALID_STRATEGIES = ['merge', 'rebase', 'squash'];
23
+ // 'rebase' removed: it rewrites branch history, which is unsafe when
24
+ // worktrees are still active and other agents may depend on those commits.
25
+ const VALID_STRATEGIES = ['merge', 'squash'];
24
26
  const NAME_REGEX = /^[a-zA-Z0-9_-]+$/;
25
27
 
26
28
  /**
@@ -353,12 +355,7 @@ function mergeParallel(options = {}) {
353
355
 
354
356
  for (const agent of activeAgents) {
355
357
  try {
356
- if (strategy === 'rebase') {
357
- execFileSync('git', ['rebase', agent.branch], {
358
- cwd: root,
359
- stdio: 'pipe',
360
- });
361
- } else if (strategy === 'squash') {
358
+ if (strategy === 'squash') {
362
359
  execFileSync('git', ['merge', '--squash', agent.branch], {
363
360
  cwd: root,
364
361
  stdio: 'pipe',
@@ -380,11 +377,7 @@ function mergeParallel(options = {}) {
380
377
  try {
381
378
  execFileSync('git', ['merge', '--abort'], { cwd: root, stdio: 'pipe' });
382
379
  } catch {
383
- try {
384
- execFileSync('git', ['rebase', '--abort'], { cwd: root, stdio: 'pipe' });
385
- } catch {
386
- // Already clean
387
- }
380
+ // Already clean
388
381
  }
389
382
  failed.push({ name: agent.name, error: err.message });
390
383
  }