@paths.design/caws-cli 10.0.1 → 10.1.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 (54) hide show
  1. package/README.md +13 -5
  2. package/dist/budget-derivation.js +221 -74
  3. package/dist/commands/evaluate.js +26 -12
  4. package/dist/commands/gates.js +31 -4
  5. package/dist/commands/init.js +7 -4
  6. package/dist/commands/iterate.js +7 -3
  7. package/dist/commands/scope.js +264 -0
  8. package/dist/commands/sidecar.js +6 -3
  9. package/dist/commands/specs.js +148 -1
  10. package/dist/commands/status.js +8 -4
  11. package/dist/commands/templates.js +0 -8
  12. package/dist/commands/validate.js +34 -13
  13. package/dist/commands/verify-acs.js +25 -10
  14. package/dist/commands/waivers.js +147 -5
  15. package/dist/commands/worktree.js +81 -1
  16. package/dist/gates/budget-limit.js +6 -1
  17. package/dist/gates/spec-completeness.js +8 -1
  18. package/dist/index.js +27 -0
  19. package/dist/policy/PolicyManager.js +9 -7
  20. package/dist/session/session-manager.js +34 -0
  21. package/dist/templates/.caws/schemas/policy.schema.json +96 -34
  22. package/dist/templates/.caws/schemas/scope.schema.json +3 -3
  23. package/dist/templates/.caws/schemas/waivers.schema.json +91 -21
  24. package/dist/templates/.caws/schemas/working-spec.schema.json +253 -89
  25. package/dist/templates/.caws/templates/working-spec.template.yml +3 -1
  26. package/dist/templates/.caws/tools/scope-guard.js +66 -15
  27. package/dist/templates/.claude/README.md +1 -1
  28. package/dist/templates/.claude/hooks/protected-paths.sh +39 -0
  29. package/dist/templates/.claude/hooks/scope-guard.sh +106 -27
  30. package/dist/templates/.claude/hooks/worktree-write-guard.sh +96 -3
  31. package/dist/templates/.claude/settings.json +5 -0
  32. package/dist/templates/CLAUDE.md +34 -0
  33. package/dist/templates/agents.md +21 -0
  34. package/dist/utils/event-log.js +584 -0
  35. package/dist/utils/event-renderer.js +521 -0
  36. package/dist/utils/schema-validator.js +10 -2
  37. package/dist/utils/working-state.js +25 -0
  38. package/dist/validation/spec-validation.js +99 -9
  39. package/dist/waivers-manager.js +84 -0
  40. package/dist/worktree/worktree-manager.js +214 -8
  41. package/package.json +5 -4
  42. package/templates/.caws/schemas/policy.schema.json +96 -34
  43. package/templates/.caws/schemas/scope.schema.json +3 -3
  44. package/templates/.caws/schemas/waivers.schema.json +91 -21
  45. package/templates/.caws/schemas/working-spec.schema.json +253 -89
  46. package/templates/.caws/templates/working-spec.template.yml +3 -1
  47. package/templates/.caws/tools/scope-guard.js +66 -15
  48. package/templates/.claude/README.md +1 -1
  49. package/templates/.claude/hooks/protected-paths.sh +39 -0
  50. package/templates/.claude/hooks/scope-guard.sh +106 -27
  51. package/templates/.claude/hooks/worktree-write-guard.sh +96 -3
  52. package/templates/.claude/settings.json +5 -0
  53. package/templates/CLAUDE.md +34 -0
  54. package/templates/agents.md +21 -0
@@ -0,0 +1,264 @@
1
+ /**
2
+ * @fileoverview CAWS Scope CLI Command
3
+ * Inspects and displays effective scope boundaries for the current context
4
+ * @author @darianrosebrook
5
+ */
6
+
7
+ const chalk = require('chalk');
8
+ const path = require('path');
9
+ const fs = require('fs-extra');
10
+ const yaml = require('js-yaml');
11
+ const {
12
+ getRepoRoot,
13
+ loadRegistry,
14
+ findFeatureSpecPath,
15
+ WORKTREES_DIR,
16
+ } = require('../worktree/worktree-manager');
17
+
18
+ /**
19
+ * Handle scope subcommands
20
+ * @param {string} subcommand - Subcommand name
21
+ * @param {Object} options - Command options
22
+ */
23
+ async function scopeCommand(subcommand, options = {}) {
24
+ try {
25
+ switch (subcommand) {
26
+ case 'show':
27
+ return handleShow(options);
28
+ default:
29
+ console.error(chalk.red(`Unknown scope subcommand: ${subcommand}`));
30
+ console.log(chalk.blue('Available: show'));
31
+ process.exit(1);
32
+ }
33
+ } catch (error) {
34
+ console.error(chalk.red(`${error.message}`));
35
+ process.exit(1);
36
+ }
37
+ }
38
+
39
+ /**
40
+ * Detect if current working directory is inside a worktree
41
+ * @param {string} root - Repository root
42
+ * @returns {{ inWorktree: boolean, worktreeName: string|null }}
43
+ */
44
+ function detectWorktreeContext(root) {
45
+ const cwd = process.cwd();
46
+ const worktreesBase = path.join(root, WORKTREES_DIR);
47
+
48
+ if (!cwd.startsWith(worktreesBase + path.sep) && cwd !== worktreesBase) {
49
+ return { inWorktree: false, worktreeName: null };
50
+ }
51
+
52
+ // Extract worktree name: first path segment after the worktrees dir
53
+ const relative = path.relative(worktreesBase, cwd);
54
+ const worktreeName = relative.split(path.sep)[0];
55
+
56
+ if (!worktreeName) {
57
+ return { inWorktree: false, worktreeName: null };
58
+ }
59
+
60
+ return { inWorktree: true, worktreeName };
61
+ }
62
+
63
+ /**
64
+ * Load a spec file and return its parsed contents
65
+ * @param {string} specPath - Absolute path to spec YAML
66
+ * @returns {Object|null}
67
+ */
68
+ function loadSpec(specPath) {
69
+ try {
70
+ if (!fs.existsSync(specPath)) return null;
71
+ const content = fs.readFileSync(specPath, 'utf8');
72
+ return yaml.load(content);
73
+ } catch {
74
+ return null;
75
+ }
76
+ }
77
+
78
+ /**
79
+ * Find all active spec files in .caws/specs/
80
+ * @param {string} root - Repository root
81
+ * @returns {Array<{ id: string, path: string, data: Object }>}
82
+ */
83
+ function findAllActiveSpecs(root) {
84
+ const specsDir = path.join(root, '.caws', 'specs');
85
+ if (!fs.existsSync(specsDir)) return [];
86
+
87
+ const files = fs.readdirSync(specsDir).filter(f => f.endsWith('.yaml') || f.endsWith('.yml'));
88
+ const specs = [];
89
+
90
+ for (const file of files) {
91
+ const specPath = path.join(specsDir, file);
92
+ const data = loadSpec(specPath);
93
+ if (!data) continue;
94
+
95
+ // Skip closed/archived specs
96
+ const status = (data.status || '').toLowerCase();
97
+ if (status === 'closed' || status === 'archived') continue;
98
+
99
+ const id = path.basename(file, path.extname(file));
100
+ specs.push({ id, path: specPath, data });
101
+ }
102
+
103
+ return specs;
104
+ }
105
+
106
+ /**
107
+ * Print scope patterns for a spec
108
+ * @param {Object} data - Parsed spec YAML
109
+ * @param {string} indent - Indentation prefix
110
+ */
111
+ function printScopePatterns(data, indent = ' ') {
112
+ const scope = data.scope || {};
113
+ const scopeIn = scope.in || scope.include || [];
114
+ const scopeOut = scope.out || scope.exclude || [];
115
+
116
+ if (scopeIn.length > 0) {
117
+ console.log(chalk.green(`${indent}scope.in:`));
118
+ for (const pattern of scopeIn) {
119
+ console.log(chalk.gray(`${indent} - ${pattern}`));
120
+ }
121
+ } else {
122
+ console.log(chalk.yellow(`${indent}scope.in: (none defined)`));
123
+ }
124
+
125
+ if (scopeOut.length > 0) {
126
+ console.log(chalk.red(`${indent}scope.out:`));
127
+ for (const pattern of scopeOut) {
128
+ console.log(chalk.gray(`${indent} - ${pattern}`));
129
+ }
130
+ } else {
131
+ console.log(chalk.gray(`${indent}scope.out: (none)`));
132
+ }
133
+ }
134
+
135
+ /**
136
+ * Handle the 'show' subcommand
137
+ * @param {Object} options - Command options
138
+ */
139
+ function handleShow(_options) {
140
+ const root = getRepoRoot();
141
+ const { inWorktree, worktreeName } = detectWorktreeContext(root);
142
+
143
+ console.log(chalk.bold.cyan('CAWS Scope Inspector'));
144
+ console.log(chalk.cyan('='.repeat(50)));
145
+ console.log('');
146
+
147
+ if (inWorktree) {
148
+ return handleAuthoritativeMode(root, worktreeName);
149
+ } else {
150
+ return handleUnionMode(root);
151
+ }
152
+ }
153
+
154
+ /**
155
+ * Handle authoritative mode: agent is inside a worktree with a bound spec
156
+ * @param {string} root - Repository root
157
+ * @param {string} worktreeName - Name of the worktree
158
+ */
159
+ function handleAuthoritativeMode(root, worktreeName) {
160
+ console.log(chalk.white(`Worktree: ${chalk.bold(worktreeName)}`));
161
+
162
+ const registry = loadRegistry(root);
163
+ const entry = registry.worktrees ? registry.worktrees[worktreeName] : null;
164
+
165
+ if (!entry) {
166
+ console.log(chalk.red(`Worktree '${worktreeName}' not found in registry.`));
167
+ console.log(chalk.yellow('The scope guard is operating in union mode (all active specs).'));
168
+ return handleUnionMode(root);
169
+ }
170
+
171
+ const specId = entry.specId;
172
+
173
+ if (!specId) {
174
+ console.log(chalk.yellow('Mode: union (no spec bound to this worktree)'));
175
+ console.log('');
176
+ console.log(chalk.yellow('This worktree has no spec binding. The scope guard checks'));
177
+ console.log(chalk.yellow('against the union of all active specs.'));
178
+ console.log('');
179
+ console.log(chalk.blue('To bind a spec: caws worktree bind <spec-id>'));
180
+ console.log('');
181
+ return handleUnionMode(root);
182
+ }
183
+
184
+ // Load the spec
185
+ const specPath = findFeatureSpecPath(root, specId);
186
+ if (!specPath) {
187
+ console.log(chalk.red(`Bound spec '${specId}' not found on disk.`));
188
+ console.log(chalk.yellow('Fix: recreate the spec or rebind with a valid spec ID.'));
189
+ console.log(chalk.blue(` caws worktree bind <valid-spec-id>`));
190
+ return;
191
+ }
192
+
193
+ const specData = loadSpec(specPath);
194
+ if (!specData) {
195
+ console.log(chalk.red(`Failed to parse spec file: ${specPath}`));
196
+ return;
197
+ }
198
+
199
+ console.log(chalk.green(`Mode: authoritative (single bound spec)`));
200
+ console.log(chalk.white(`Spec: ${chalk.bold(specId)}`));
201
+ if (specData.title) {
202
+ console.log(chalk.gray(`Title: ${specData.title}`));
203
+ }
204
+ console.log('');
205
+
206
+ // Print scope patterns
207
+ printScopePatterns(specData);
208
+ console.log('');
209
+
210
+ // Check binding health: mutual reference
211
+ const specWorktreeRef = specData.worktree || null;
212
+ const registrySpecRef = specId;
213
+
214
+ if (specWorktreeRef !== worktreeName) {
215
+ console.log(chalk.yellow('Binding health: BROKEN'));
216
+ console.log(chalk.yellow(` Registry points to spec '${registrySpecRef}'`));
217
+ console.log(chalk.yellow(` Spec 'worktree' field: ${specWorktreeRef || '(missing)'} (expected: ${worktreeName})`));
218
+ console.log('');
219
+ console.log(chalk.blue(`Fix: caws worktree bind ${specId}`));
220
+ } else {
221
+ console.log(chalk.green('Binding health: OK'));
222
+ console.log(chalk.gray(` Registry -> spec: ${registrySpecRef}`));
223
+ console.log(chalk.gray(` Spec -> worktree: ${specWorktreeRef}`));
224
+ }
225
+ }
226
+
227
+ /**
228
+ * Handle union mode: no worktree or no spec binding
229
+ * @param {string} root - Repository root
230
+ */
231
+ function handleUnionMode(root) {
232
+ const specs = findAllActiveSpecs(root);
233
+
234
+ if (specs.length === 0) {
235
+ console.log(chalk.gray('Mode: union (no active specs found)'));
236
+ console.log('');
237
+ console.log(chalk.gray('No active feature specs in .caws/specs/.'));
238
+ console.log(chalk.gray('The scope guard has no patterns to enforce.'));
239
+ console.log('');
240
+ console.log(chalk.blue('Create a spec: caws specs create <id> --title "description"'));
241
+ return;
242
+ }
243
+
244
+ console.log(chalk.white('Mode: union (checking all active specs)'));
245
+ console.log(chalk.gray(`Active specs: ${specs.length}`));
246
+ console.log('');
247
+
248
+ for (const spec of specs) {
249
+ const statusLabel = spec.data.status || 'draft';
250
+ console.log(chalk.white(` ${chalk.bold(spec.id)} [${statusLabel}]`));
251
+ if (spec.data.title) {
252
+ console.log(chalk.gray(` Title: ${spec.data.title}`));
253
+ }
254
+ printScopePatterns(spec.data, ' ');
255
+
256
+ // Check if this spec has a worktree binding
257
+ if (spec.data.worktree) {
258
+ console.log(chalk.gray(` worktree: ${spec.data.worktree}`));
259
+ }
260
+ console.log('');
261
+ }
262
+ }
263
+
264
+ module.exports = { scopeCommand };
@@ -7,7 +7,10 @@
7
7
 
8
8
  const chalk = require('chalk');
9
9
  const { resolveSpec } = require('../utils/spec-resolver');
10
- const { loadState } = require('../utils/working-state');
10
+ // EVLOG-002 Phase 2 read flip: sidecars read state from the event log via the
11
+ // pure renderer. loadStateFromEvents matches loadState's null contract exactly,
12
+ // so the existing "state may be null — sidecars handle that" behavior stays.
13
+ const { loadStateFromEvents } = require('../utils/event-renderer');
11
14
  const { commandWrapper } = require('../utils/command-wrapper');
12
15
  const { SIDECARS, formatSidecarText } = require('../sidecars');
13
16
 
@@ -45,8 +48,8 @@ async function sidecarCommand(subcommand, options = {}) {
45
48
  process.exit(1);
46
49
  }
47
50
 
48
- // Load working state (may be null — sidecars handle that)
49
- const state = loadState(spec.id);
51
+ // Load working state (may be null — sidecars handle that; EVLOG-002: from event log)
52
+ const state = loadStateFromEvents(spec.id);
50
53
 
51
54
  // Build sidecar-specific options
52
55
  const sidecarOptions = {};
@@ -18,6 +18,7 @@ const { findProjectRoot } = require('../utils/detection');
18
18
  const { loadRegistry: loadWorktreeRegistry, getRepoRoot } = require('../worktree/worktree-manager');
19
19
  const { getAgentSessionId } = require('../utils/agent-session');
20
20
  const { initializeState, saveState, deleteState } = require('../utils/working-state');
21
+ const { appendEvent } = require('../utils/event-log');
21
22
 
22
23
  /**
23
24
  * Check if a spec is referenced by any active worktree.
@@ -56,6 +57,35 @@ function getSpecsDir() {
56
57
  function getSpecsRegistry() {
57
58
  return path.join(findProjectRoot(), '.caws', 'specs', 'registry.json');
58
59
  }
60
+
61
+ function detectCurrentWorktreeName() {
62
+ const cwd = process.cwd().replace(/\\/g, '/');
63
+ const worktreeMatch = cwd.match(/\/\.caws\/worktrees\/([^/]+)(?:\/|$)/);
64
+ if (worktreeMatch) {
65
+ return worktreeMatch[1];
66
+ }
67
+
68
+ try {
69
+ const root = getRepoRoot();
70
+ const branch = require('child_process')
71
+ .execFileSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], {
72
+ cwd: root,
73
+ encoding: 'utf8',
74
+ stdio: 'pipe',
75
+ })
76
+ .trim();
77
+ const registry = loadWorktreeRegistry(root);
78
+ for (const [name, entry] of Object.entries(registry.worktrees || {})) {
79
+ if (entry.branch === branch && entry.status !== 'destroyed' && entry.status !== 'merged') {
80
+ return name;
81
+ }
82
+ }
83
+ } catch {
84
+ // Best-effort only; specs can still be created outside a worktree.
85
+ }
86
+
87
+ return null;
88
+ }
59
89
  // Legacy constants kept for backward compatibility in tests
60
90
  const SPECS_DIR = '.caws/specs';
61
91
  const SPECS_REGISTRY = '.caws/specs/registry.json';
@@ -374,6 +404,11 @@ async function createSpec(id, options = {}) {
374
404
  contracts: [],
375
405
  };
376
406
 
407
+ const detectedWorktree = detectCurrentWorktreeName();
408
+ if (detectedWorktree) {
409
+ defaultSpec.worktree = detectedWorktree;
410
+ }
411
+
377
412
  // Merge template, but preserve required structure
378
413
  // Map template.criteria to acceptance if present
379
414
  const templateAcceptance = template?.criteria || template?.acceptance;
@@ -456,6 +491,63 @@ async function createSpec(id, options = {}) {
456
491
  saveState(id, initialState, findProjectRoot());
457
492
  } catch { /* non-fatal */ }
458
493
 
494
+ // CAWSFIX-06: warn when a feature spec is created without contracts.
495
+ // Contract-first development is a CAWS value proposition; empty `contracts`
496
+ // on a feature-type spec is discouraged but not fatal. Emit a non-fatal
497
+ // warning to stderr so agents and humans notice and can update the spec.
498
+ //
499
+ // Note: the spec's acceptance text uses "mode=feature" colloquially, but in
500
+ // CAWS the discriminator is the `type` field (feature/fix/refactor/chore),
501
+ // not the `mode` field (development/pilot/etc.). We key off `type` to match
502
+ // the --type CLI flag and the schema.
503
+ const specType = parsedSpec.type || type;
504
+ const specContracts = Array.isArray(parsedSpec.contracts) ? parsedSpec.contracts : [];
505
+ if (specType === 'feature' && specContracts.length === 0) {
506
+ console.warn(
507
+ chalk.yellow(
508
+ `⚠ Spec ${id} has mode=feature but no contracts. ` +
509
+ `mode=feature without contracts is discouraged — ` +
510
+ `run 'caws specs update ${id}' to add a contract reference.`
511
+ )
512
+ );
513
+ }
514
+
515
+ // EVLOG-001: emit spec_created event alongside state write.
516
+ //
517
+ // Spec-lifecycle events (spec_created / spec_closed / spec_deleted) are
518
+ // **informational redundancy** with the spec file + registry, which are
519
+ // the true sources of truth for spec identity. In contrast, the
520
+ // validation/evaluation/gates/verify_acs events are the ONLY record of
521
+ // those verification runs and losing them is real data loss.
522
+ //
523
+ // So we deliberately wrap spec-lifecycle emits in try/catch: a
524
+ // filesystem error here (test mocks, readonly fs, etc.) must not crash
525
+ // the spec create/close/delete flow, because the spec file itself is
526
+ // already persisted by the time we get here. This is a principled
527
+ // divergence from the strict contract for the observation events —
528
+ // see docs/internal/EVENTS_LOG_MIGRATION.md §4.5 and EVLOG-001 spec.
529
+ try {
530
+ await appendEvent(
531
+ {
532
+ actor: 'cli',
533
+ event: 'spec_created',
534
+ spec_id: id,
535
+ data: {
536
+ id,
537
+ type: parsedSpec.type || type,
538
+ title: parsedSpec.title || title,
539
+ risk_tier: parsedSpec.risk_tier || numericRiskTier,
540
+ mode: parsedSpec.mode || mode,
541
+ },
542
+ },
543
+ { projectRoot: findProjectRoot() }
544
+ );
545
+ } catch (err) {
546
+ // Surface on stderr but don't propagate — the spec is already created.
547
+
548
+ console.error(`event-log: failed to record spec_created for ${id}: ${err.message}`);
549
+ }
550
+
459
551
  return {
460
552
  id,
461
553
  path: fileName,
@@ -717,6 +809,19 @@ async function deleteSpec(id) {
717
809
  delete registry.specs[id];
718
810
  await saveSpecsRegistry(registry);
719
811
 
812
+ // EVLOG-001: emit spec_deleted event in best-effort mode. See the
813
+ // createSpec commentary for why spec-lifecycle events diverge from
814
+ // the strict fail-loud contract used by the observation events.
815
+ try {
816
+ await appendEvent(
817
+ { actor: 'cli', event: 'spec_deleted', spec_id: id, data: { id } },
818
+ { projectRoot: findProjectRoot() }
819
+ );
820
+ } catch (err) {
821
+
822
+ console.error(`event-log: failed to record spec_deleted for ${id}: ${err.message}`);
823
+ }
824
+
720
825
  return true;
721
826
  }
722
827
 
@@ -769,7 +874,49 @@ async function closeSpec(id) {
769
874
  return false;
770
875
  }
771
876
 
772
- return await updateSpec(id, { status: 'closed' });
877
+ // CAWSFIX-15: status-only flip uses targeted line-replace so the diff
878
+ // stays a single line. Full `updateSpec` reserializes the whole YAML,
879
+ // reordering fields and injecting `*ref_0` anchors for the
880
+ // acceptance/acceptance_criteria alias — ~20 lines of noise for what
881
+ // should be a one-word change.
882
+ const specPath = path.join(getSpecsDir(), registry.specs[id].path);
883
+ const original = await fs.readFile(specPath, 'utf8');
884
+ const nowIso = new Date().toISOString();
885
+ let patched = original.replace(/^status:\s*\S+\s*$/m, 'status: closed');
886
+ patched = patched.replace(/^updated_at:.*$/m, `updated_at: '${nowIso}'`);
887
+ let ok = false;
888
+ if (patched !== original) {
889
+ await fs.writeFile(specPath, patched);
890
+ registry.specs[id] = {
891
+ ...registry.specs[id],
892
+ status: 'closed',
893
+ updated_at: nowIso,
894
+ };
895
+ await saveSpecsRegistry(registry);
896
+ ok = true;
897
+ }
898
+
899
+ // EVLOG-001: emit spec_closed event after the status update succeeds.
900
+ // Records the prior status so the renderer can reconstruct the lifecycle.
901
+ // Best-effort mode — see createSpec commentary.
902
+ if (ok) {
903
+ try {
904
+ await appendEvent(
905
+ {
906
+ actor: 'cli',
907
+ event: 'spec_closed',
908
+ spec_id: id,
909
+ data: { id, prior_status: currentStatus },
910
+ },
911
+ { projectRoot: findProjectRoot() }
912
+ );
913
+ } catch (err) {
914
+
915
+ console.error(`event-log: failed to record spec_closed for ${id}: ${err.message}`);
916
+ }
917
+ }
918
+
919
+ return ok;
773
920
  }
774
921
 
775
922
  /**
@@ -12,7 +12,11 @@ const chalk = require('chalk');
12
12
  const { safeAsync, outputResult } = require('../error-handler');
13
13
  const { parallel } = require('../utils/async-utils');
14
14
  const { resolveSpec } = require('../utils/spec-resolver');
15
- const { loadState } = require('../utils/working-state');
15
+ // EVLOG-002 Phase 2 read flip: status reads working state from the event log
16
+ // via the pure renderer. loadStateFromEvents matches loadState's null contract
17
+ // exactly, so the `ws && ws.phase !== 'not-started'` guard and the
18
+ // `loadState(id) || null` coalesce below stay semantically correct.
19
+ const { loadStateFromEvents } = require('../utils/event-renderer');
16
20
 
17
21
  /**
18
22
  * Load working specification (legacy single file approach)
@@ -375,10 +379,10 @@ function displayStatus(data) {
375
379
 
376
380
  console.log('');
377
381
 
378
- // Working State
382
+ // Working State (EVLOG-002: from event log)
379
383
  if (spec && spec.id) {
380
384
  let ws = null;
381
- try { ws = loadState(spec.id); } catch { /* non-fatal */ }
385
+ try { ws = loadStateFromEvents(spec.id); } catch { /* non-fatal */ }
382
386
  if (ws && ws.phase !== 'not-started') {
383
387
  const phaseLabels = {
384
388
  'not-started': 'Not Started',
@@ -1052,7 +1056,7 @@ async function statusCommand(options = {}) {
1052
1056
  passed: gates.passed,
1053
1057
  message: gates.message,
1054
1058
  },
1055
- workingState: spec && spec.id ? (loadState(spec.id) || null) : null,
1059
+ workingState: spec && spec.id ? (loadStateFromEvents(spec.id) || null) : null,
1056
1060
  overallProgress: calculateOverallProgress({
1057
1061
  spec,
1058
1062
  specSelection,
@@ -53,14 +53,6 @@ const BUILTIN_TEMPLATES = {
53
53
  features: ['React', 'TypeScript', 'Storybook', 'Jest', 'Publishing'],
54
54
  path: 'templates/react/component-library',
55
55
  },
56
- 'vscode-extension': {
57
- name: 'VS Code Extension',
58
- description: 'VS Code extension with TypeScript',
59
- category: 'Extension',
60
- tier: 2,
61
- features: ['TypeScript', 'VS Code API', 'Jest', 'Publishing'],
62
- path: 'templates/vscode-extension',
63
- },
64
56
  };
65
57
 
66
58
  /**
@@ -20,6 +20,7 @@ const {
20
20
  loadSpecsRegistry,
21
21
  } = require('../utils/spec-resolver');
22
22
  const { recordValidation } = require('../utils/working-state');
23
+ const { appendEvent } = require('../utils/event-log');
23
24
  const { lifecycle, EVENTS } = require('../utils/lifecycle-events');
24
25
 
25
26
  /**
@@ -143,19 +144,39 @@ async function validateCommand(specFile, options = {}) {
143
144
 
144
145
  const finalResult = enhancedValidation;
145
146
 
146
- // Record to working state
147
- try {
148
- const grade = finalResult.complianceScore !== undefined
149
- ? getComplianceGrade(finalResult.complianceScore)
150
- : null;
151
- recordValidation(spec.id, {
152
- passed: finalResult.valid,
153
- compliance_score: finalResult.complianceScore ?? null,
154
- grade,
155
- error_count: (finalResult.errors || []).length,
156
- warning_count: (finalResult.warnings || []).length,
157
- });
158
- } catch { /* non-fatal */ }
147
+ // Record to working state (Phase 1 dual-write: state layer + event log)
148
+ const validationGrade = finalResult.complianceScore !== undefined
149
+ ? getComplianceGrade(finalResult.complianceScore)
150
+ : null;
151
+ const validationPayload = {
152
+ passed: finalResult.valid,
153
+ compliance_score: finalResult.complianceScore ?? null,
154
+ grade: validationGrade,
155
+ error_count: (finalResult.errors || []).length,
156
+ warning_count: (finalResult.warnings || []).length,
157
+ };
158
+ // CAWSFIX-02: guard recordValidation with the same `spec && spec.id`
159
+ // check that gates.js already uses and that the appendEvent call below
160
+ // enforces. Without this, legacy working-specs without an id silently
161
+ // wrote `.caws/state/undefined.json` — now blocked by the state-layer
162
+ // fence, but guarding here keeps the intent explicit.
163
+ if (spec && spec.id) {
164
+ try {
165
+ recordValidation(spec.id, validationPayload);
166
+ } catch { /* non-fatal */ }
167
+ }
168
+
169
+ // EVLOG-001: emit event log entry alongside state write. Only if
170
+ // spec.id is present — the fence in appendEvent would throw otherwise,
171
+ // which is intentional for the undefined.json bug class but wrong for
172
+ // legitimate legacy specs without an id field. Errors here are NOT
173
+ // swallowed: a failure to append an event is a real defect we want to
174
+ // surface, not a silent data-loss event.
175
+ if (spec && spec.id) {
176
+ await appendEvent(
177
+ { actor: 'cli', event: 'validation_completed', spec_id: spec.id, data: validationPayload }
178
+ );
179
+ }
159
180
 
160
181
  // Emit lifecycle event
161
182
  try {
@@ -12,6 +12,7 @@ const { execFileSync } = require('child_process');
12
12
  const { findProjectRoot } = require('../utils/detection');
13
13
  const { resolveSpec } = require('../utils/spec-resolver');
14
14
  const { recordACVerification } = require('../utils/working-state');
15
+ const { appendEvent } = require('../utils/event-log');
15
16
 
16
17
  /**
17
18
  * Detect the project's test runner from config files.
@@ -350,16 +351,30 @@ async function verifyAcsCommand(options = {}) {
350
351
  else { totalUnchecked++; }
351
352
  }
352
353
 
353
- // Record to working state
354
- try {
355
- recordACVerification(resolved.spec.id, {
356
- total: totalAcs,
357
- pass: totalPass,
358
- fail: totalFail,
359
- unchecked: totalUnchecked,
360
- results: result.results,
361
- }, projectRoot);
362
- } catch { /* non-fatal */ }
354
+ // Record to working state (Phase 1 dual-write: state layer + event log)
355
+ const acPayload = {
356
+ total: totalAcs,
357
+ pass: totalPass,
358
+ fail: totalFail,
359
+ unchecked: totalUnchecked,
360
+ results: result.results,
361
+ };
362
+ // CAWSFIX-02: guard recordACVerification with `resolved.spec && resolved.spec.id`
363
+ // check to prevent the .caws/state/undefined.json bug class. Matches the
364
+ // pattern gates.js already uses and the appendEvent call below.
365
+ if (resolved.spec && resolved.spec.id) {
366
+ try {
367
+ recordACVerification(resolved.spec.id, acPayload, projectRoot);
368
+ } catch { /* non-fatal */ }
369
+ }
370
+
371
+ // EVLOG-001: emit verify_acs_completed event alongside state write.
372
+ if (resolved.spec && resolved.spec.id) {
373
+ await appendEvent(
374
+ { actor: 'cli', event: 'verify_acs_completed', spec_id: resolved.spec.id, data: acPayload },
375
+ { projectRoot }
376
+ );
377
+ }
363
378
 
364
379
  // Output
365
380
  if (options.format === 'json') {