@paths.design/caws-cli 10.0.1 → 10.2.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 (60) hide show
  1. package/README.md +13 -5
  2. package/dist/budget-derivation.js +221 -74
  3. package/dist/commands/agents.js +124 -0
  4. package/dist/commands/evaluate.js +26 -12
  5. package/dist/commands/gates.js +31 -4
  6. package/dist/commands/init.js +7 -4
  7. package/dist/commands/iterate.js +7 -3
  8. package/dist/commands/scope.js +264 -0
  9. package/dist/commands/sidecar.js +6 -3
  10. package/dist/commands/specs.js +359 -4
  11. package/dist/commands/status.js +29 -4
  12. package/dist/commands/templates.js +0 -8
  13. package/dist/commands/validate.js +34 -13
  14. package/dist/commands/verify-acs.js +25 -10
  15. package/dist/commands/waivers.js +147 -5
  16. package/dist/commands/worktree.js +200 -4
  17. package/dist/gates/budget-limit.js +6 -1
  18. package/dist/gates/scope-boundary.js +26 -7
  19. package/dist/gates/spec-completeness.js +8 -1
  20. package/dist/index.js +56 -0
  21. package/dist/policy/PolicyManager.js +14 -7
  22. package/dist/session/session-manager.js +34 -0
  23. package/dist/templates/.caws/schemas/policy.schema.json +101 -34
  24. package/dist/templates/.caws/schemas/scope.schema.json +3 -3
  25. package/dist/templates/.caws/schemas/waivers.schema.json +91 -21
  26. package/dist/templates/.caws/schemas/working-spec.schema.json +253 -89
  27. package/dist/templates/.caws/templates/working-spec.template.yml +3 -1
  28. package/dist/templates/.caws/tools/scope-guard.js +66 -15
  29. package/dist/templates/.claude/README.md +1 -1
  30. package/dist/templates/.claude/hooks/protected-paths.sh +39 -0
  31. package/dist/templates/.claude/hooks/scope-guard.sh +106 -27
  32. package/dist/templates/.claude/hooks/worktree-write-guard.sh +96 -3
  33. package/dist/templates/.claude/rules/worktree-isolation.md +21 -3
  34. package/dist/templates/.claude/settings.json +5 -0
  35. package/dist/templates/CLAUDE.md +56 -0
  36. package/dist/templates/agents.md +47 -0
  37. package/dist/utils/agent-display.js +210 -0
  38. package/dist/utils/agent-session.js +142 -0
  39. package/dist/utils/event-log.js +584 -0
  40. package/dist/utils/event-renderer.js +521 -0
  41. package/dist/utils/schema-validator.js +10 -2
  42. package/dist/utils/working-state.js +25 -0
  43. package/dist/validation/spec-validation.js +102 -9
  44. package/dist/waivers-manager.js +84 -0
  45. package/dist/worktree/worktree-manager.js +593 -26
  46. package/package.json +5 -4
  47. package/templates/.caws/schemas/policy.schema.json +101 -34
  48. package/templates/.caws/schemas/scope.schema.json +3 -3
  49. package/templates/.caws/schemas/waivers.schema.json +91 -21
  50. package/templates/.caws/schemas/working-spec.schema.json +253 -89
  51. package/templates/.caws/templates/working-spec.template.yml +3 -1
  52. package/templates/.caws/tools/scope-guard.js +66 -15
  53. package/templates/.claude/README.md +1 -1
  54. package/templates/.claude/hooks/protected-paths.sh +39 -0
  55. package/templates/.claude/hooks/scope-guard.sh +106 -27
  56. package/templates/.claude/hooks/worktree-write-guard.sh +96 -3
  57. package/templates/.claude/rules/worktree-isolation.md +21 -3
  58. package/templates/.claude/settings.json +5 -0
  59. package/templates/CLAUDE.md +56 -0
  60. package/templates/agents.md +47 -0
@@ -16,8 +16,9 @@ const { SPEC_TYPES } = require('../constants/spec-types');
16
16
  const { suggestFeatureBreakdown } = require('../utils/spec-resolver');
17
17
  const { findProjectRoot } = require('../utils/detection');
18
18
  const { loadRegistry: loadWorktreeRegistry, getRepoRoot } = require('../worktree/worktree-manager');
19
- const { getAgentSessionId } = require('../utils/agent-session');
19
+ const { getAgentSessionId, refreshAgentClaim } = 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';
@@ -194,6 +224,17 @@ function normalizeSpecForValidation(spec = {}) {
194
224
  * List all spec files in the specs directory
195
225
  * @returns {Promise<Array>} Array of spec file info
196
226
  */
227
+ // Files under this subdir of `.caws/specs/` are treated as archived,
228
+ // regardless of what the YAML's `status` field says (CAWSFIX-29 invariant:
229
+ // directory location is authoritative for archive state).
230
+ const ARCHIVE_SUBDIR = '.archive';
231
+
232
+ function isArchivePath(relPath) {
233
+ if (!relPath) return false;
234
+ const normalized = relPath.replace(/\\/g, '/');
235
+ return normalized === ARCHIVE_SUBDIR || normalized.startsWith(`${ARCHIVE_SUBDIR}/`);
236
+ }
237
+
197
238
  async function listSpecFiles() {
198
239
  const specsDir = getSpecsDir();
199
240
  if (!(await fs.pathExists(specsDir))) {
@@ -209,13 +250,14 @@ async function listSpecFiles() {
209
250
  try {
210
251
  const content = await fs.readFile(filePath, 'utf8');
211
252
  const spec = yaml.load(content);
253
+ const inArchive = isArchivePath(file);
212
254
 
213
255
  specs.push({
214
256
  id: spec.id || path.basename(file, path.extname(file)),
215
257
  path: file,
216
258
  type: spec.type || 'feature',
217
259
  title: spec.title || 'Untitled',
218
- status: spec.status || 'draft',
260
+ status: inArchive ? 'archived' : spec.status || 'draft',
219
261
  risk_tier: spec.risk_tier || 'T3',
220
262
  mode: spec.mode || 'development',
221
263
  created_at: spec.created_at || new Date().toISOString(),
@@ -259,6 +301,40 @@ async function createSpec(id, options = {}) {
259
301
  const existingSpecPath = path.join(specsDir, `${id}.yaml`);
260
302
  const specExists = await fs.pathExists(existingSpecPath);
261
303
 
304
+ // CAWSFIX-30: archive-collision guard. An id that lives in `.archive/`
305
+ // is still a taken id — surface it before going further. Detection is
306
+ // filesystem-driven (not registry-driven) so manually-moved legacy
307
+ // specs are also caught.
308
+ const archivedSpecPath = path.join(specsDir, ARCHIVE_SUBDIR, `${id}.yaml`);
309
+ const archivedExists = !specExists && (await fs.pathExists(archivedSpecPath));
310
+ if (archivedExists && !force) {
311
+ console.error(
312
+ chalk.red(
313
+ `Spec '${id}' already exists in archive: ${path.relative(findProjectRoot(), archivedSpecPath)}`
314
+ )
315
+ );
316
+ console.error(
317
+ chalk.yellow(
318
+ `Use --force to remove the archived copy and resurrect the id, ` +
319
+ `or pick a different id.`
320
+ )
321
+ );
322
+ throw new Error(
323
+ `Spec '${id}' collides with archived spec at .archive/${id}.yaml. ` +
324
+ `Use --force to resurrect or choose another id.`
325
+ );
326
+ }
327
+ if (archivedExists && force) {
328
+ // Resurrection: drop the archived YAML and any registry pointer so
329
+ // the rest of createSpec can write a fresh draft cleanly.
330
+ await fs.remove(archivedSpecPath);
331
+ const registry = await loadSpecsRegistry();
332
+ if (registry.specs[id]) {
333
+ delete registry.specs[id];
334
+ await saveSpecsRegistry(registry);
335
+ }
336
+ }
337
+
262
338
  // Handle conflict resolution
263
339
  let answer = null;
264
340
 
@@ -374,6 +450,11 @@ async function createSpec(id, options = {}) {
374
450
  contracts: [],
375
451
  };
376
452
 
453
+ const detectedWorktree = detectCurrentWorktreeName();
454
+ if (detectedWorktree) {
455
+ defaultSpec.worktree = detectedWorktree;
456
+ }
457
+
377
458
  // Merge template, but preserve required structure
378
459
  // Map template.criteria to acceptance if present
379
460
  const templateAcceptance = template?.criteria || template?.acceptance;
@@ -456,6 +537,67 @@ async function createSpec(id, options = {}) {
456
537
  saveState(id, initialState, findProjectRoot());
457
538
  } catch { /* non-fatal */ }
458
539
 
540
+ // CAWSFIX-06: warn when a feature spec is created without contracts.
541
+ // Contract-first development is a CAWS value proposition; empty `contracts`
542
+ // on a feature-type spec is discouraged but not fatal. Emit a non-fatal
543
+ // warning to stderr so agents and humans notice and can update the spec.
544
+ //
545
+ // Note: the spec's acceptance text uses "mode=feature" colloquially, but in
546
+ // CAWS the discriminator is the `type` field (feature/fix/refactor/chore),
547
+ // not the `mode` field (development/pilot/etc.). We key off `type` to match
548
+ // the --type CLI flag and the schema.
549
+ const specType = parsedSpec.type || type;
550
+ const specContracts = Array.isArray(parsedSpec.contracts) ? parsedSpec.contracts : [];
551
+ if (specType === 'feature' && specContracts.length === 0) {
552
+ console.warn(
553
+ chalk.yellow(
554
+ `⚠ Spec ${id} has mode=feature but no contracts. ` +
555
+ `mode=feature without contracts is discouraged — ` +
556
+ `run 'caws specs update ${id}' to add a contract reference.`
557
+ )
558
+ );
559
+ }
560
+
561
+ // EVLOG-001: emit spec_created event alongside state write.
562
+ //
563
+ // Spec-lifecycle events (spec_created / spec_closed / spec_deleted) are
564
+ // **informational redundancy** with the spec file + registry, which are
565
+ // the true sources of truth for spec identity. In contrast, the
566
+ // validation/evaluation/gates/verify_acs events are the ONLY record of
567
+ // those verification runs and losing them is real data loss.
568
+ //
569
+ // So we deliberately wrap spec-lifecycle emits in try/catch: a
570
+ // filesystem error here (test mocks, readonly fs, etc.) must not crash
571
+ // the spec create/close/delete flow, because the spec file itself is
572
+ // already persisted by the time we get here. This is a principled
573
+ // divergence from the strict contract for the observation events —
574
+ // see docs/internal/EVENTS_LOG_MIGRATION.md §4.5 and EVLOG-001 spec.
575
+ try {
576
+ await appendEvent(
577
+ {
578
+ actor: 'cli',
579
+ event: 'spec_created',
580
+ spec_id: id,
581
+ data: {
582
+ id,
583
+ type: parsedSpec.type || type,
584
+ title: parsedSpec.title || title,
585
+ risk_tier: parsedSpec.risk_tier || numericRiskTier,
586
+ mode: parsedSpec.mode || mode,
587
+ },
588
+ },
589
+ { projectRoot: findProjectRoot() }
590
+ );
591
+ } catch (err) {
592
+ // Surface on stderr but don't propagate — the spec is already created.
593
+
594
+ console.error(`event-log: failed to record spec_created for ${id}: ${err.message}`);
595
+ }
596
+
597
+ // CAWSFIX-31: refresh the current agent's claim so agents.json reflects
598
+ // the current spec context. Best-effort — failures must not break the op.
599
+ refreshAgentClaim(findProjectRoot(), { specId: id });
600
+
459
601
  return {
460
602
  id,
461
603
  path: fileName,
@@ -717,6 +859,23 @@ async function deleteSpec(id) {
717
859
  delete registry.specs[id];
718
860
  await saveSpecsRegistry(registry);
719
861
 
862
+ // EVLOG-001: emit spec_deleted event in best-effort mode. See the
863
+ // createSpec commentary for why spec-lifecycle events diverge from
864
+ // the strict fail-loud contract used by the observation events.
865
+ try {
866
+ await appendEvent(
867
+ { actor: 'cli', event: 'spec_deleted', spec_id: id, data: { id } },
868
+ { projectRoot: findProjectRoot() }
869
+ );
870
+ } catch (err) {
871
+
872
+ console.error(`event-log: failed to record spec_deleted for ${id}: ${err.message}`);
873
+ }
874
+
875
+ // CAWSFIX-31: every lifecycle verb refreshes for consistency, even
876
+ // delete (no other cleanup runs on delete; this is signal-of-presence).
877
+ refreshAgentClaim(findProjectRoot(), { specId: id });
878
+
720
879
  return true;
721
880
  }
722
881
 
@@ -769,7 +928,178 @@ async function closeSpec(id) {
769
928
  return false;
770
929
  }
771
930
 
772
- return await updateSpec(id, { status: 'closed' });
931
+ // CAWSFIX-15: status-only flip uses targeted line-replace so the diff
932
+ // stays a single line. Full `updateSpec` reserializes the whole YAML,
933
+ // reordering fields and injecting `*ref_0` anchors for the
934
+ // acceptance/acceptance_criteria alias — ~20 lines of noise for what
935
+ // should be a one-word change.
936
+ const specPath = path.join(getSpecsDir(), registry.specs[id].path);
937
+ const original = await fs.readFile(specPath, 'utf8');
938
+ const nowIso = new Date().toISOString();
939
+ let patched = original.replace(/^status:\s*\S+\s*$/m, 'status: closed');
940
+ patched = patched.replace(/^updated_at:.*$/m, `updated_at: '${nowIso}'`);
941
+ let ok = false;
942
+ if (patched !== original) {
943
+ await fs.writeFile(specPath, patched);
944
+ registry.specs[id] = {
945
+ ...registry.specs[id],
946
+ status: 'closed',
947
+ updated_at: nowIso,
948
+ };
949
+ await saveSpecsRegistry(registry);
950
+ ok = true;
951
+ }
952
+
953
+ // EVLOG-001: emit spec_closed event after the status update succeeds.
954
+ // Records the prior status so the renderer can reconstruct the lifecycle.
955
+ // Best-effort mode — see createSpec commentary.
956
+ if (ok) {
957
+ try {
958
+ await appendEvent(
959
+ {
960
+ actor: 'cli',
961
+ event: 'spec_closed',
962
+ spec_id: id,
963
+ data: { id, prior_status: currentStatus },
964
+ },
965
+ { projectRoot: findProjectRoot() }
966
+ );
967
+ } catch (err) {
968
+
969
+ console.error(`event-log: failed to record spec_closed for ${id}: ${err.message}`);
970
+ }
971
+
972
+ // CAWSFIX-31: refresh agent claim — also fires on no-op closes
973
+ // (already-closed specs return ok=true after an early exit) only
974
+ // when the status actually flipped, since this is the signal that
975
+ // matters: the agent is currently working on this spec.
976
+ refreshAgentClaim(findProjectRoot(), { specId: id });
977
+ }
978
+
979
+ return ok;
980
+ }
981
+
982
+ /**
983
+ * Archive a spec: move its YAML to `.caws/specs/.archive/<id>.yaml`,
984
+ * flip status to `archived`, update the registry, and emit a `spec_archived`
985
+ * event. The archive directory is the canonical truth for archive state —
986
+ * the listing layer (listSpecFiles) treats any file under `.archive/` as
987
+ * archived regardless of the YAML literal.
988
+ *
989
+ * @param {string} id - Spec identifier
990
+ * @returns {Promise<boolean>} true on success (including idempotent no-ops),
991
+ * false on validation/lookup failure.
992
+ */
993
+ async function archiveSpec(id) {
994
+ // Path-traversal guard: ids must be plain filenames, not paths.
995
+ // Reject before touching any filesystem state.
996
+ if (!id || typeof id !== 'string' || path.basename(id) !== id || id.includes('..')) {
997
+ console.error(chalk.red(`Invalid spec id '${id}': must be a plain identifier`));
998
+ return false;
999
+ }
1000
+
1001
+ const registry = await loadSpecsRegistry();
1002
+ const entry = registry.specs[id];
1003
+ if (!entry) {
1004
+ console.error(chalk.red(`Spec '${id}' not found`));
1005
+ return false;
1006
+ }
1007
+
1008
+ // Block if owned by another session (mirror closeSpec/deleteSpec).
1009
+ const currentSession = getAgentSessionId(findProjectRoot());
1010
+ if (entry.owner && currentSession && entry.owner !== currentSession) {
1011
+ console.error(
1012
+ chalk.red(
1013
+ `Cannot archive spec '${id}': owned by another session (${entry.owner}). ` +
1014
+ `Only the creator session can archive a spec.`
1015
+ )
1016
+ );
1017
+ return false;
1018
+ }
1019
+
1020
+ // Block if active worktrees still reference the spec — archiving removes
1021
+ // scope enforcement and would invalidate in-flight work.
1022
+ const referencingWorktrees = getWorktreesReferencingSpec(id);
1023
+ if (referencingWorktrees.length > 0) {
1024
+ const names = referencingWorktrees.join(', ');
1025
+ console.error(
1026
+ chalk.red(
1027
+ `Cannot archive spec '${id}': active worktree(s) [${names}] reference it. ` +
1028
+ `Destroy the worktree(s) first with 'caws worktree destroy <name>'.`
1029
+ )
1030
+ );
1031
+ return false;
1032
+ }
1033
+
1034
+ const specsDir = getSpecsDir();
1035
+ const priorPath = entry.path;
1036
+ const currentSpecPath = path.join(specsDir, priorPath);
1037
+
1038
+ // If the file is already in the archive directory, the canonical
1039
+ // location is satisfied — just ensure the registry status agrees and exit.
1040
+ if (isArchivePath(priorPath)) {
1041
+ if (entry.status !== 'archived') {
1042
+ registry.specs[id] = { ...entry, status: 'archived' };
1043
+ await saveSpecsRegistry(registry);
1044
+ }
1045
+ console.log(chalk.yellow(`Spec '${id}' is already archived.`));
1046
+ return true;
1047
+ }
1048
+
1049
+ if (!(await fs.pathExists(currentSpecPath))) {
1050
+ console.error(
1051
+ chalk.red(`Cannot archive spec '${id}': file missing at ${currentSpecPath}`)
1052
+ );
1053
+ return false;
1054
+ }
1055
+
1056
+ // CAWSFIX-15-style targeted rewrite: only `status:` and `updated_at:`
1057
+ // lines move. Comments, ordering, and YAML aliases survive untouched.
1058
+ const original = await fs.readFile(currentSpecPath, 'utf8');
1059
+ const priorStatus = entry.status || 'draft';
1060
+ const nowIso = new Date().toISOString();
1061
+ let patched = original.replace(/^status:\s*\S+\s*$/m, 'status: archived');
1062
+ patched = patched.replace(/^updated_at:.*$/m, `updated_at: '${nowIso}'`);
1063
+
1064
+ const archiveDir = path.join(specsDir, ARCHIVE_SUBDIR);
1065
+ await fs.ensureDir(archiveDir);
1066
+ const newRelPath = `${ARCHIVE_SUBDIR}/${id}.yaml`;
1067
+ const newAbsPath = path.join(specsDir, newRelPath);
1068
+
1069
+ // Write the patched content to the archive location, then remove the
1070
+ // original. fs-extra's writeFile is atomic-enough for single-file moves
1071
+ // on the same filesystem; we avoid `move` because we already mutated content.
1072
+ await fs.writeFile(newAbsPath, patched);
1073
+ await fs.remove(currentSpecPath);
1074
+
1075
+ registry.specs[id] = {
1076
+ ...entry,
1077
+ path: newRelPath,
1078
+ status: 'archived',
1079
+ updated_at: nowIso,
1080
+ };
1081
+ await saveSpecsRegistry(registry);
1082
+
1083
+ // Best-effort event emission, matching spec_closed/spec_deleted policy:
1084
+ // event-log failure does not roll back the archive operation.
1085
+ try {
1086
+ await appendEvent(
1087
+ {
1088
+ actor: 'cli',
1089
+ event: 'spec_archived',
1090
+ spec_id: id,
1091
+ data: { id, prior_status: priorStatus, prior_path: priorPath },
1092
+ },
1093
+ { projectRoot: findProjectRoot() }
1094
+ );
1095
+ } catch (err) {
1096
+ console.error(`event-log: failed to record spec_archived for ${id}: ${err.message}`);
1097
+ }
1098
+
1099
+ // CAWSFIX-31: refresh agent claim after a successful archive transition.
1100
+ refreshAgentClaim(findProjectRoot(), { specId: id });
1101
+
1102
+ return true;
773
1103
  }
774
1104
 
775
1105
  /**
@@ -1255,6 +1585,28 @@ async function specsCommand(action, options = {}) {
1255
1585
  });
1256
1586
  }
1257
1587
 
1588
+ case 'archive': {
1589
+ if (!options.id) {
1590
+ throw new Error('Spec ID is required. Usage: caws specs archive <id>');
1591
+ }
1592
+
1593
+ const archived = await archiveSpec(options.id);
1594
+ if (!archived) {
1595
+ throw new Error(`Could not archive spec '${options.id}'`);
1596
+ }
1597
+
1598
+ console.log(
1599
+ chalk.green(
1600
+ `Archived spec: ${options.id} -- moved to .caws/specs/.archive/`
1601
+ )
1602
+ );
1603
+
1604
+ return outputResult({
1605
+ command: 'specs archive',
1606
+ spec: options.id,
1607
+ });
1608
+ }
1609
+
1258
1610
  case 'types': {
1259
1611
  console.log(chalk.bold.cyan('\nAvailable Spec Types'));
1260
1612
  console.log(chalk.cyan('==============================================\n'));
@@ -1273,7 +1625,7 @@ async function specsCommand(action, options = {}) {
1273
1625
 
1274
1626
  default:
1275
1627
  throw new Error(
1276
- `Unknown specs action: ${action}. Use: list, create, show, update, delete, close, conflicts, migrate, types`
1628
+ `Unknown specs action: ${action}. Use: list, create, show, update, delete, close, archive, conflicts, migrate, types`
1277
1629
  );
1278
1630
  }
1279
1631
  },
@@ -1292,10 +1644,13 @@ module.exports = {
1292
1644
  updateSpec,
1293
1645
  deleteSpec,
1294
1646
  closeSpec,
1647
+ archiveSpec,
1295
1648
  displaySpecsTable,
1296
1649
  displaySpecDetails,
1297
1650
  askConflictResolution,
1651
+ isArchivePath,
1298
1652
  SPECS_DIR,
1299
1653
  SPECS_REGISTRY,
1654
+ ARCHIVE_SUBDIR,
1300
1655
  SPEC_TYPES,
1301
1656
  };
@@ -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)
@@ -359,6 +363,27 @@ function displayStatus(data) {
359
363
  console.log(chalk.bold.cyan('\nCAWS Project Status'));
360
364
  console.log(chalk.cyan('==============================================\n'));
361
365
 
366
+ // CAWSFIX-31: Surface worktree claim if cwd is inside a worktree.
367
+ // Best-effort — failures (no .caws, no registry, etc.) are silent.
368
+ try {
369
+ const path = require('path');
370
+ const { findProjectRoot } = require('../utils/detection');
371
+ const { renderClaimPanel } = require('../utils/agent-display');
372
+ const root = findProjectRoot();
373
+ const cwd = process.cwd();
374
+ const worktreesBase = path.join(root, '.caws', 'worktrees');
375
+ if (cwd.startsWith(worktreesBase + path.sep)) {
376
+ const worktreeName = path.relative(worktreesBase, cwd).split(path.sep)[0];
377
+ const panel = renderClaimPanel(root, worktreeName);
378
+ if (panel) {
379
+ console.log(chalk.green(panel));
380
+ console.log('');
381
+ }
382
+ }
383
+ } catch {
384
+ // best-effort
385
+ }
386
+
362
387
  // Working Spec Status
363
388
  if (spec) {
364
389
  console.log(chalk.green('Working Spec'));
@@ -375,10 +400,10 @@ function displayStatus(data) {
375
400
 
376
401
  console.log('');
377
402
 
378
- // Working State
403
+ // Working State (EVLOG-002: from event log)
379
404
  if (spec && spec.id) {
380
405
  let ws = null;
381
- try { ws = loadState(spec.id); } catch { /* non-fatal */ }
406
+ try { ws = loadStateFromEvents(spec.id); } catch { /* non-fatal */ }
382
407
  if (ws && ws.phase !== 'not-started') {
383
408
  const phaseLabels = {
384
409
  'not-started': 'Not Started',
@@ -1052,7 +1077,7 @@ async function statusCommand(options = {}) {
1052
1077
  passed: gates.passed,
1053
1078
  message: gates.message,
1054
1079
  },
1055
- workingState: spec && spec.id ? (loadState(spec.id) || null) : null,
1080
+ workingState: spec && spec.id ? (loadStateFromEvents(spec.id) || null) : null,
1056
1081
  overallProgress: calculateOverallProgress({
1057
1082
  spec,
1058
1083
  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') {