@paths.design/caws-cli 9.3.2 → 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 (286) hide show
  1. package/README.md +71 -32
  2. package/dist/budget-derivation.js +221 -74
  3. package/dist/commands/archive.js +67 -28
  4. package/dist/commands/burnup.js +20 -11
  5. package/dist/commands/diagnose.js +34 -22
  6. package/dist/commands/evaluate.js +41 -15
  7. package/dist/commands/gates.js +149 -0
  8. package/dist/commands/init.js +150 -19
  9. package/dist/commands/iterate.js +81 -4
  10. package/dist/commands/parallel.js +4 -0
  11. package/dist/commands/plan.js +9 -19
  12. package/dist/commands/provenance.js +53 -17
  13. package/dist/commands/quality-monitor.js +64 -45
  14. package/dist/commands/scope.js +264 -0
  15. package/dist/commands/sidecar.js +74 -0
  16. package/dist/commands/specs.js +381 -45
  17. package/dist/commands/status.js +117 -9
  18. package/dist/commands/templates.js +0 -8
  19. package/dist/commands/tutorial.js +10 -9
  20. package/dist/commands/validate.js +70 -6
  21. package/dist/commands/verify-acs.js +48 -76
  22. package/dist/commands/waivers.js +212 -13
  23. package/dist/commands/worktree.js +131 -26
  24. package/dist/error-handler.js +2 -13
  25. package/dist/gates/budget-limit.js +121 -0
  26. package/dist/gates/feedback.js +260 -0
  27. package/dist/gates/format.js +179 -0
  28. package/dist/gates/god-object.js +117 -0
  29. package/dist/gates/pipeline.js +167 -0
  30. package/dist/gates/scope-boundary.js +93 -0
  31. package/dist/gates/spec-completeness.js +109 -0
  32. package/dist/gates/todo-detection.js +205 -0
  33. package/dist/index.js +157 -151
  34. package/dist/parallel/parallel-manager.js +3 -3
  35. package/dist/policy/PolicyManager.js +51 -17
  36. package/dist/scaffold/claude-hooks.js +24 -1
  37. package/dist/scaffold/git-hooks.js +45 -102
  38. package/dist/scaffold/index.js +4 -3
  39. package/dist/session/session-manager.js +105 -14
  40. package/dist/sidecars/index.js +33 -0
  41. package/dist/sidecars/listeners.js +40 -0
  42. package/dist/sidecars/provenance-summary.js +238 -0
  43. package/dist/sidecars/quality-gaps.js +258 -0
  44. package/dist/sidecars/schema.js +149 -0
  45. package/dist/sidecars/spec-drift.js +151 -0
  46. package/dist/sidecars/waiver-draft.js +176 -0
  47. package/dist/templates/.caws/schemas/policy.schema.json +112 -0
  48. package/dist/templates/.caws/schemas/scope.schema.json +3 -3
  49. package/dist/templates/.caws/schemas/waivers.schema.json +96 -20
  50. package/dist/templates/.caws/schemas/working-spec.schema.json +264 -57
  51. package/dist/templates/.caws/schemas/worktrees.schema.json +3 -1
  52. package/dist/templates/.caws/templates/working-spec.template.yml +10 -4
  53. package/dist/templates/.caws/tools/scope-guard.js +66 -15
  54. package/dist/templates/.claude/README.md +1 -1
  55. package/dist/templates/.claude/hooks/audit.sh +0 -0
  56. package/dist/templates/.claude/hooks/block-dangerous.sh +52 -11
  57. package/dist/templates/.claude/hooks/classify_command.py +592 -0
  58. package/dist/templates/.claude/hooks/doc-frontmatter-check.sh +173 -0
  59. package/dist/templates/.claude/hooks/protected-paths.sh +39 -0
  60. package/dist/templates/.claude/hooks/quality-check.sh +23 -10
  61. package/dist/templates/.claude/hooks/scope-guard.sh +136 -55
  62. package/dist/templates/.claude/hooks/session-caws-status.sh +2 -2
  63. package/dist/templates/.claude/hooks/session-log.sh +76 -3
  64. package/dist/templates/.claude/hooks/stop-worktree-check.sh +1 -1
  65. package/dist/templates/.claude/hooks/test_classify_command.py +370 -0
  66. package/dist/templates/.claude/hooks/test_wrapper_smoke.sh +96 -0
  67. package/dist/templates/.claude/hooks/worktree-guard.sh +2 -2
  68. package/dist/templates/.claude/hooks/worktree-write-guard.sh +97 -4
  69. package/dist/templates/.claude/settings.json +31 -0
  70. package/dist/templates/.cursor/hooks/caws-quality-check.sh +4 -4
  71. package/dist/templates/.cursor/hooks/caws-scope-guard.sh +1 -1
  72. package/dist/templates/.cursor/hooks/session-log.sh +924 -0
  73. package/dist/templates/.cursor/hooks.json +25 -0
  74. package/dist/templates/.cursor/rules/02-quality-gates.mdc +3 -5
  75. package/dist/templates/.cursor/rules/10-documentation-quality-standards.mdc +6 -11
  76. package/dist/templates/.cursor/rules/11-scope-management-waivers.mdc +14 -18
  77. package/dist/templates/.cursor/rules/12-implementation-completeness.mdc +4 -4
  78. package/dist/templates/.cursor/rules/13-language-agnostic-standards.mdc +3 -13
  79. package/dist/templates/.github/copilot-instructions.md +5 -5
  80. package/dist/templates/.idea/runConfigurations/CAWS_Evaluate.xml +1 -1
  81. package/dist/templates/.junie/guidelines.md +2 -2
  82. package/dist/templates/.vscode/settings.json +3 -1
  83. package/dist/templates/.windsurf/rules/caws-quality-standards.md +2 -2
  84. package/dist/templates/.windsurf/workflows/caws-guided-development.md +3 -3
  85. package/dist/templates/CLAUDE.md +77 -8
  86. package/dist/templates/agents.md +50 -9
  87. package/dist/templates/docs/README.md +8 -7
  88. package/dist/templates/scripts/new_feature.sh +80 -0
  89. package/dist/test-analysis.js +43 -30
  90. package/dist/tool-loader.js +1 -1
  91. package/dist/utils/agent-session.js +202 -0
  92. package/dist/utils/detection.js +8 -2
  93. package/dist/utils/event-log.js +584 -0
  94. package/dist/utils/event-renderer.js +521 -0
  95. package/dist/utils/finalization.js +7 -6
  96. package/dist/utils/gitignore-updater.js +3 -0
  97. package/dist/utils/lifecycle-events.js +94 -0
  98. package/dist/utils/quality-gates-utils.js +29 -44
  99. package/dist/utils/schema-validator.js +50 -0
  100. package/dist/utils/spec-resolver.js +93 -21
  101. package/dist/utils/working-state.js +530 -0
  102. package/dist/validation/spec-validation.js +191 -31
  103. package/dist/waivers-manager.js +144 -6
  104. package/dist/worktree/worktree-manager.js +598 -95
  105. package/package.json +9 -8
  106. package/templates/.caws/schemas/policy.schema.json +112 -0
  107. package/templates/.caws/schemas/scope.schema.json +3 -3
  108. package/templates/.caws/schemas/waivers.schema.json +96 -20
  109. package/templates/.caws/schemas/working-spec.schema.json +264 -57
  110. package/templates/.caws/schemas/worktrees.schema.json +3 -1
  111. package/templates/.caws/templates/working-spec.template.yml +10 -4
  112. package/templates/.caws/tools/scope-guard.js +66 -15
  113. package/templates/.claude/README.md +1 -1
  114. package/templates/.claude/hooks/block-dangerous.sh +52 -11
  115. package/templates/.claude/hooks/classify_command.py +592 -0
  116. package/templates/.claude/hooks/doc-frontmatter-check.sh +173 -0
  117. package/templates/.claude/hooks/protected-paths.sh +39 -0
  118. package/templates/.claude/hooks/quality-check.sh +23 -10
  119. package/templates/.claude/hooks/scope-guard.sh +136 -55
  120. package/templates/.claude/hooks/session-caws-status.sh +2 -2
  121. package/templates/.claude/hooks/session-log.sh +76 -3
  122. package/templates/.claude/hooks/stop-worktree-check.sh +1 -1
  123. package/templates/.claude/hooks/test_classify_command.py +370 -0
  124. package/templates/.claude/hooks/test_wrapper_smoke.sh +96 -0
  125. package/templates/.claude/hooks/worktree-guard.sh +2 -2
  126. package/templates/.claude/hooks/worktree-write-guard.sh +97 -4
  127. package/templates/.claude/settings.json +31 -0
  128. package/templates/.cursor/hooks/caws-quality-check.sh +4 -4
  129. package/templates/.cursor/hooks/caws-scope-guard.sh +1 -1
  130. package/templates/.cursor/hooks/session-log.sh +924 -0
  131. package/templates/.cursor/hooks.json +25 -0
  132. package/templates/.cursor/rules/02-quality-gates.mdc +3 -5
  133. package/templates/.cursor/rules/10-documentation-quality-standards.mdc +6 -11
  134. package/templates/.cursor/rules/11-scope-management-waivers.mdc +14 -18
  135. package/templates/.cursor/rules/12-implementation-completeness.mdc +4 -4
  136. package/templates/.cursor/rules/13-language-agnostic-standards.mdc +3 -13
  137. package/templates/.github/copilot-instructions.md +5 -5
  138. package/templates/.idea/runConfigurations/CAWS_Evaluate.xml +1 -1
  139. package/templates/.junie/guidelines.md +2 -2
  140. package/templates/.vscode/settings.json +3 -1
  141. package/templates/.windsurf/rules/caws-quality-standards.md +2 -2
  142. package/templates/.windsurf/workflows/caws-guided-development.md +3 -3
  143. package/templates/CLAUDE.md +77 -8
  144. package/templates/{AGENTS.md → agents.md} +50 -9
  145. package/templates/docs/README.md +8 -7
  146. package/templates/scripts/new_feature.sh +80 -0
  147. package/dist/budget-derivation.d.ts +0 -74
  148. package/dist/budget-derivation.d.ts.map +0 -1
  149. package/dist/cicd-optimizer.d.ts +0 -142
  150. package/dist/cicd-optimizer.d.ts.map +0 -1
  151. package/dist/commands/archive.d.ts +0 -51
  152. package/dist/commands/archive.d.ts.map +0 -1
  153. package/dist/commands/burnup.d.ts +0 -6
  154. package/dist/commands/burnup.d.ts.map +0 -1
  155. package/dist/commands/diagnose.d.ts +0 -52
  156. package/dist/commands/diagnose.d.ts.map +0 -1
  157. package/dist/commands/evaluate.d.ts +0 -8
  158. package/dist/commands/evaluate.d.ts.map +0 -1
  159. package/dist/commands/init.d.ts +0 -5
  160. package/dist/commands/init.d.ts.map +0 -1
  161. package/dist/commands/iterate.d.ts +0 -8
  162. package/dist/commands/iterate.d.ts.map +0 -1
  163. package/dist/commands/mode.d.ts +0 -25
  164. package/dist/commands/mode.d.ts.map +0 -1
  165. package/dist/commands/parallel.d.ts +0 -7
  166. package/dist/commands/parallel.d.ts.map +0 -1
  167. package/dist/commands/plan.d.ts +0 -49
  168. package/dist/commands/plan.d.ts.map +0 -1
  169. package/dist/commands/provenance.d.ts +0 -32
  170. package/dist/commands/provenance.d.ts.map +0 -1
  171. package/dist/commands/quality-gates.d.ts +0 -6
  172. package/dist/commands/quality-gates.d.ts.map +0 -1
  173. package/dist/commands/quality-gates.js +0 -444
  174. package/dist/commands/quality-monitor.d.ts +0 -17
  175. package/dist/commands/quality-monitor.d.ts.map +0 -1
  176. package/dist/commands/session.d.ts +0 -7
  177. package/dist/commands/session.d.ts.map +0 -1
  178. package/dist/commands/specs.d.ts +0 -77
  179. package/dist/commands/specs.d.ts.map +0 -1
  180. package/dist/commands/status.d.ts +0 -44
  181. package/dist/commands/status.d.ts.map +0 -1
  182. package/dist/commands/templates.d.ts +0 -74
  183. package/dist/commands/templates.d.ts.map +0 -1
  184. package/dist/commands/tool.d.ts +0 -13
  185. package/dist/commands/tool.d.ts.map +0 -1
  186. package/dist/commands/troubleshoot.d.ts +0 -8
  187. package/dist/commands/troubleshoot.d.ts.map +0 -1
  188. package/dist/commands/troubleshoot.js +0 -104
  189. package/dist/commands/tutorial.d.ts +0 -55
  190. package/dist/commands/tutorial.d.ts.map +0 -1
  191. package/dist/commands/validate.d.ts +0 -15
  192. package/dist/commands/validate.d.ts.map +0 -1
  193. package/dist/commands/waivers.d.ts +0 -8
  194. package/dist/commands/waivers.d.ts.map +0 -1
  195. package/dist/commands/workflow.d.ts +0 -85
  196. package/dist/commands/workflow.d.ts.map +0 -1
  197. package/dist/commands/worktree.d.ts +0 -7
  198. package/dist/commands/worktree.d.ts.map +0 -1
  199. package/dist/config/index.d.ts +0 -29
  200. package/dist/config/index.d.ts.map +0 -1
  201. package/dist/config/lite-scope.d.ts +0 -33
  202. package/dist/config/lite-scope.d.ts.map +0 -1
  203. package/dist/config/modes.d.ts +0 -264
  204. package/dist/config/modes.d.ts.map +0 -1
  205. package/dist/constants/spec-types.d.ts +0 -93
  206. package/dist/constants/spec-types.d.ts.map +0 -1
  207. package/dist/error-handler.d.ts +0 -151
  208. package/dist/error-handler.d.ts.map +0 -1
  209. package/dist/generators/jest-config-generator.d.ts +0 -32
  210. package/dist/generators/jest-config-generator.d.ts.map +0 -1
  211. package/dist/generators/jest-config.d.ts +0 -32
  212. package/dist/generators/jest-config.d.ts.map +0 -1
  213. package/dist/generators/jest-config.js +0 -242
  214. package/dist/generators/working-spec.d.ts +0 -13
  215. package/dist/generators/working-spec.d.ts.map +0 -1
  216. package/dist/index-new.d.ts +0 -5
  217. package/dist/index-new.d.ts.map +0 -1
  218. package/dist/index-new.js +0 -317
  219. package/dist/index.d.ts +0 -5
  220. package/dist/index.d.ts.map +0 -1
  221. package/dist/index.js.backup +0 -4711
  222. package/dist/minimal-cli.d.ts +0 -3
  223. package/dist/minimal-cli.d.ts.map +0 -1
  224. package/dist/parallel/parallel-manager.d.ts +0 -67
  225. package/dist/parallel/parallel-manager.d.ts.map +0 -1
  226. package/dist/policy/PolicyManager.d.ts +0 -104
  227. package/dist/policy/PolicyManager.d.ts.map +0 -1
  228. package/dist/scaffold/claude-hooks.d.ts +0 -28
  229. package/dist/scaffold/claude-hooks.d.ts.map +0 -1
  230. package/dist/scaffold/cursor-hooks.d.ts +0 -7
  231. package/dist/scaffold/cursor-hooks.d.ts.map +0 -1
  232. package/dist/scaffold/git-hooks.d.ts +0 -38
  233. package/dist/scaffold/git-hooks.d.ts.map +0 -1
  234. package/dist/scaffold/index.d.ts +0 -17
  235. package/dist/scaffold/index.d.ts.map +0 -1
  236. package/dist/session/session-manager.d.ts +0 -94
  237. package/dist/session/session-manager.d.ts.map +0 -1
  238. package/dist/spec/SpecFileManager.d.ts +0 -146
  239. package/dist/spec/SpecFileManager.d.ts.map +0 -1
  240. package/dist/templates/.cursor/hooks/caws-tool-validation.sh +0 -121
  241. package/dist/templates/.github/copilot/instructions.md +0 -311
  242. package/dist/test-analysis.d.ts +0 -231
  243. package/dist/test-analysis.d.ts.map +0 -1
  244. package/dist/tool-interface.d.ts +0 -236
  245. package/dist/tool-interface.d.ts.map +0 -1
  246. package/dist/tool-loader.d.ts +0 -77
  247. package/dist/tool-loader.d.ts.map +0 -1
  248. package/dist/tool-validator.d.ts +0 -72
  249. package/dist/tool-validator.d.ts.map +0 -1
  250. package/dist/utils/async-utils.d.ts +0 -73
  251. package/dist/utils/async-utils.d.ts.map +0 -1
  252. package/dist/utils/command-wrapper.d.ts +0 -66
  253. package/dist/utils/command-wrapper.d.ts.map +0 -1
  254. package/dist/utils/detection.d.ts +0 -14
  255. package/dist/utils/detection.d.ts.map +0 -1
  256. package/dist/utils/error-categories.d.ts +0 -52
  257. package/dist/utils/error-categories.d.ts.map +0 -1
  258. package/dist/utils/finalization.d.ts +0 -17
  259. package/dist/utils/finalization.d.ts.map +0 -1
  260. package/dist/utils/git-lock.d.ts +0 -13
  261. package/dist/utils/git-lock.d.ts.map +0 -1
  262. package/dist/utils/gitignore-updater.d.ts +0 -39
  263. package/dist/utils/gitignore-updater.d.ts.map +0 -1
  264. package/dist/utils/ide-detection.d.ts +0 -89
  265. package/dist/utils/ide-detection.d.ts.map +0 -1
  266. package/dist/utils/project-analysis.d.ts +0 -34
  267. package/dist/utils/project-analysis.d.ts.map +0 -1
  268. package/dist/utils/promise-utils.d.ts +0 -30
  269. package/dist/utils/promise-utils.d.ts.map +0 -1
  270. package/dist/utils/quality-gates-utils.d.ts +0 -49
  271. package/dist/utils/quality-gates-utils.d.ts.map +0 -1
  272. package/dist/utils/quality-gates.d.ts +0 -49
  273. package/dist/utils/quality-gates.d.ts.map +0 -1
  274. package/dist/utils/quality-gates.js +0 -402
  275. package/dist/utils/spec-resolver.d.ts +0 -80
  276. package/dist/utils/spec-resolver.d.ts.map +0 -1
  277. package/dist/utils/typescript-detector.d.ts +0 -66
  278. package/dist/utils/typescript-detector.d.ts.map +0 -1
  279. package/dist/utils/yaml-validation.d.ts +0 -32
  280. package/dist/utils/yaml-validation.d.ts.map +0 -1
  281. package/dist/validation/spec-validation.d.ts +0 -43
  282. package/dist/validation/spec-validation.d.ts.map +0 -1
  283. package/dist/waivers-manager.d.ts +0 -167
  284. package/dist/waivers-manager.d.ts.map +0 -1
  285. package/dist/worktree/worktree-manager.d.ts +0 -54
  286. package/dist/worktree/worktree-manager.d.ts.map +0 -1
@@ -0,0 +1,584 @@
1
+ /**
2
+ * @fileoverview Event Log — append-only provenance surface
3
+ *
4
+ * CAWS events are written once to `.caws/events.jsonl` and never rewritten.
5
+ * Every other view (per-spec state, session registry, provenance chain) is
6
+ * a pure function of this log. See docs/internal/EVENTS_LOG_MIGRATION.md
7
+ * for the full design.
8
+ *
9
+ * Contract highlights:
10
+ * - Append-only. Readers tolerate partial last lines.
11
+ * - Hash-chained. Each event carries prev_hash and event_hash (sha256).
12
+ * - Fail-loud. Events missing a required spec_id throw; no silent writes.
13
+ * - Cross-platform file lock via `fs.openSync(lockPath, 'wx')` sentinel.
14
+ *
15
+ * This file ships as Phase 1 (dual-write). State-layer writes in
16
+ * working-state.js continue unchanged; this log is additive.
17
+ *
18
+ * @author @darianrosebrook
19
+ */
20
+
21
+ const fs = require('fs');
22
+ const path = require('path');
23
+ const crypto = require('crypto');
24
+
25
+ // ---------------------------------------------------------------------------
26
+ // Constants
27
+ // ---------------------------------------------------------------------------
28
+
29
+ const EVENTS_FILE = '.caws/events.jsonl';
30
+ const LOCK_SUFFIX = '.lock';
31
+ const HASH_DOMAIN = 'caws.events.v1';
32
+ const LOCK_RETRY_MS = 20;
33
+ const LOCK_RETRY_MAX = 50; // ~1s total
34
+
35
+ /**
36
+ * Events that require a spec_id in their payload. appendEvent throws if
37
+ * spec_id is missing for any event listed here. This is the fence that
38
+ * prevents the `.caws/state/undefined.json` bug class.
39
+ */
40
+ const REQUIRES_SPEC_ID = new Set([
41
+ 'validation_completed',
42
+ 'evaluation_completed',
43
+ 'verify_acs_completed',
44
+ 'gates_evaluated',
45
+ 'spec_created',
46
+ 'spec_updated',
47
+ 'spec_closed',
48
+ 'spec_archived',
49
+ 'spec_deleted',
50
+ 'spec_drift_detected',
51
+ 'waiver_applied',
52
+ ]);
53
+
54
+ /**
55
+ * Events that optionally carry a spec_id. These are allowed to omit it.
56
+ * Any event not in REQUIRES_SPEC_ID and not in OPTIONAL_SPEC_ID is
57
+ * treated as an unknown event type — appendEvent will still write it,
58
+ * but it's recorded as-is without spec_id validation.
59
+ */
60
+ const OPTIONAL_SPEC_ID = new Set([
61
+ 'session_started',
62
+ 'session_ended',
63
+ 'commit_made',
64
+ 'branch_switched',
65
+ 'worktree_created',
66
+ 'worktree_merged',
67
+ 'worktree_destroyed',
68
+ ]);
69
+
70
+ // ---------------------------------------------------------------------------
71
+ // Helpers
72
+ // ---------------------------------------------------------------------------
73
+
74
+ /**
75
+ * Resolve the absolute path to `.caws/events.jsonl` for a project root.
76
+ * @param {string} projectRoot
77
+ * @returns {string}
78
+ */
79
+ function getEventsPath(projectRoot) {
80
+ return path.join(projectRoot, EVENTS_FILE);
81
+ }
82
+
83
+ /**
84
+ * Canonicalize an event object for hashing. Keys are sorted alphabetically
85
+ * and serialized with no whitespace, so two structurally-equivalent events
86
+ * always produce the same hash regardless of key insertion order.
87
+ *
88
+ * This is a deliberate subset of RFC 8785 (JCS) — sufficient for our flat
89
+ * event shape but not a full implementation.
90
+ *
91
+ * @param {object} obj
92
+ * @returns {string}
93
+ */
94
+ function canonicalJson(obj) {
95
+ if (obj === null) return 'null';
96
+ if (typeof obj === 'number') {
97
+ if (!Number.isFinite(obj)) {
98
+ throw new Error(`canonicalJson: non-finite number ${obj}`);
99
+ }
100
+ return JSON.stringify(obj);
101
+ }
102
+ if (typeof obj === 'string') return JSON.stringify(obj);
103
+ if (typeof obj === 'boolean') return obj ? 'true' : 'false';
104
+ if (Array.isArray(obj)) {
105
+ return '[' + obj.map(canonicalJson).join(',') + ']';
106
+ }
107
+ if (typeof obj === 'object') {
108
+ const keys = Object.keys(obj).sort();
109
+ const parts = keys.map((k) => JSON.stringify(k) + ':' + canonicalJson(obj[k]));
110
+ return '{' + parts.join(',') + '}';
111
+ }
112
+ throw new Error(`canonicalJson: unsupported type ${typeof obj}`);
113
+ }
114
+
115
+ /**
116
+ * Compute sha256 of the domain-separated canonical JSON of an event.
117
+ * The `event_hash` field, if present on the input, is stripped before
118
+ * hashing so the hash can be stored back on the event itself.
119
+ *
120
+ * @param {object} event
121
+ * @returns {string} "sha256:<hex>"
122
+ */
123
+ function computeEventHash(event) {
124
+ const withoutHash = { ...event };
125
+ delete withoutHash.event_hash;
126
+ const canonical = canonicalJson(withoutHash);
127
+ const hash = crypto
128
+ .createHash('sha256')
129
+ .update(HASH_DOMAIN)
130
+ .update('\x00')
131
+ .update(canonical)
132
+ .digest('hex');
133
+ return `sha256:${hash}`;
134
+ }
135
+
136
+ /**
137
+ * Sleep for a number of milliseconds. Used by the async lock retry loop.
138
+ * @param {number} ms
139
+ * @returns {Promise<void>}
140
+ */
141
+ function sleep(ms) {
142
+ return new Promise((resolve) => { globalThis.setTimeout(resolve, ms); });
143
+ }
144
+
145
+ /**
146
+ * Synchronously sleep for a number of milliseconds. Node has no built-in
147
+ * sync sleep; `Atomics.wait` on a dummy Int32Array blocks the thread
148
+ * without CPU burn and is the least-ugly cross-platform option.
149
+ * Used by the sync lock retry loop for call sites that cannot await.
150
+ * @param {number} ms
151
+ */
152
+ function sleepSync(ms) {
153
+ const buf = new Int32Array(new SharedArrayBuffer(4));
154
+ Atomics.wait(buf, 0, 0, ms);
155
+ }
156
+
157
+ /**
158
+ * Acquire an exclusive lock on the events file by creating a sentinel
159
+ * lockfile with the `wx` flag (fails atomically if the file already
160
+ * exists). Retries with a short backoff.
161
+ *
162
+ * Returns an opaque handle that must be passed to releaseLock.
163
+ *
164
+ * @param {string} eventsPath
165
+ * @returns {Promise<{lockPath: string, fd: number}>}
166
+ */
167
+ async function acquireLock(eventsPath) {
168
+ const lockPath = eventsPath + LOCK_SUFFIX;
169
+ const dir = path.dirname(eventsPath);
170
+ if (!fs.existsSync(dir)) {
171
+ fs.mkdirSync(dir, { recursive: true });
172
+ }
173
+
174
+ for (let attempt = 0; attempt < LOCK_RETRY_MAX; attempt++) {
175
+ try {
176
+ const fd = fs.openSync(lockPath, 'wx');
177
+ // Write pid to the lock so stale locks are diagnosable.
178
+ fs.writeSync(fd, String(process.pid));
179
+ return { lockPath, fd };
180
+ } catch (err) {
181
+ if (err.code !== 'EEXIST') throw err;
182
+ // Check for stale lock (>30s old) — clean it up so one crashed
183
+ // writer doesn't block forever. This is still race-prone against
184
+ // another writer that just grabbed it; we accept that risk because
185
+ // the worst case is a rejected append, not corruption.
186
+ try {
187
+ const stat = fs.statSync(lockPath);
188
+ if (Date.now() - stat.mtimeMs > 30_000) {
189
+ fs.unlinkSync(lockPath);
190
+ continue; // retry immediately without backoff
191
+ }
192
+ } catch {
193
+ /* stat failed — file may have been released; retry */
194
+ }
195
+ await sleep(LOCK_RETRY_MS);
196
+ }
197
+ }
198
+ throw new Error(
199
+ `event-log: could not acquire lock on ${lockPath} after ${LOCK_RETRY_MAX * LOCK_RETRY_MS}ms — another writer may be stuck`
200
+ );
201
+ }
202
+
203
+ /**
204
+ * Synchronous lock acquirer. Same behavior as `acquireLock` but blocks
205
+ * the thread via `sleepSync`. Intended for call sites that cannot await
206
+ * (e.g. session-manager.startSession/endSession which are synchronous
207
+ * for historical reasons).
208
+ *
209
+ * @param {string} eventsPath
210
+ * @returns {{lockPath: string, fd: number}}
211
+ */
212
+ function acquireLockSync(eventsPath) {
213
+ const lockPath = eventsPath + LOCK_SUFFIX;
214
+ const dir = path.dirname(eventsPath);
215
+ if (!fs.existsSync(dir)) {
216
+ fs.mkdirSync(dir, { recursive: true });
217
+ }
218
+
219
+ for (let attempt = 0; attempt < LOCK_RETRY_MAX; attempt++) {
220
+ try {
221
+ const fd = fs.openSync(lockPath, 'wx');
222
+ fs.writeSync(fd, String(process.pid));
223
+ return { lockPath, fd };
224
+ } catch (err) {
225
+ if (err.code !== 'EEXIST') throw err;
226
+ try {
227
+ const stat = fs.statSync(lockPath);
228
+ if (Date.now() - stat.mtimeMs > 30_000) {
229
+ fs.unlinkSync(lockPath);
230
+ continue;
231
+ }
232
+ } catch {
233
+ /* stat failed — retry */
234
+ }
235
+ sleepSync(LOCK_RETRY_MS);
236
+ }
237
+ }
238
+ throw new Error(
239
+ `event-log: could not acquire lock on ${lockPath} after ${LOCK_RETRY_MAX * LOCK_RETRY_MS}ms — another writer may be stuck`
240
+ );
241
+ }
242
+
243
+ /**
244
+ * Release a lock acquired via acquireLock. Never throws; a failed release
245
+ * is logged but not propagated, because the caller has already written.
246
+ *
247
+ * @param {{lockPath: string, fd: number}} handle
248
+ */
249
+ function releaseLock(handle) {
250
+ try {
251
+ fs.closeSync(handle.fd);
252
+ } catch {
253
+ /* close failure is non-fatal; the unlink below is the real release */
254
+ }
255
+ try {
256
+ fs.unlinkSync(handle.lockPath);
257
+ } catch (err) {
258
+ if (err.code !== 'ENOENT') {
259
+ // Surface unexpected release failures on stderr so they're diagnosable.
260
+
261
+ console.error(`event-log: failed to release lock ${handle.lockPath}: ${err.message}`);
262
+ }
263
+ }
264
+ }
265
+
266
+ /**
267
+ * Read the last non-empty line of a file without loading the whole file
268
+ * into memory. Used to find the tail of the event log for seq/prev_hash
269
+ * continuity.
270
+ *
271
+ * Returns `null` if the file does not exist or contains only whitespace.
272
+ *
273
+ * @param {string} filePath
274
+ * @returns {string|null}
275
+ */
276
+ function readLastLine(filePath) {
277
+ if (!fs.existsSync(filePath)) return null;
278
+ const stat = fs.statSync(filePath);
279
+ if (stat.size === 0) return null;
280
+
281
+ // Read from the end in chunks until we have at least one complete line.
282
+ const fd = fs.openSync(filePath, 'r');
283
+ try {
284
+ const chunkSize = 4096;
285
+ let buffer = Buffer.alloc(0);
286
+ let pos = stat.size;
287
+ while (pos > 0) {
288
+ const readSize = Math.min(chunkSize, pos);
289
+ pos -= readSize;
290
+ const chunk = Buffer.alloc(readSize);
291
+ fs.readSync(fd, chunk, 0, readSize, pos);
292
+ buffer = Buffer.concat([chunk, buffer]);
293
+ const text = buffer.toString('utf8');
294
+ const lines = text.split('\n').filter((l) => l.length > 0);
295
+ if (lines.length >= 2 || pos === 0) {
296
+ return lines[lines.length - 1] || null;
297
+ }
298
+ }
299
+ return null;
300
+ } finally {
301
+ fs.closeSync(fd);
302
+ }
303
+ }
304
+
305
+ // ---------------------------------------------------------------------------
306
+ // Public API
307
+ // ---------------------------------------------------------------------------
308
+
309
+ /**
310
+ * Append a single event to the project's event log.
311
+ *
312
+ * The event is stamped with a monotonic `seq`, an ISO-8601 `ts`, a
313
+ * `prev_hash` linking it to the previous event, and an `event_hash`
314
+ * computed over its canonical JSON (excluding the hash field itself).
315
+ *
316
+ * This function is **intentionally non-tolerant**:
317
+ * - If `event` is an event type that requires `spec_id` and one is
318
+ * missing, it throws. Do NOT wrap calls in `try { ... } catch {}`.
319
+ * - If the lock cannot be acquired, it throws.
320
+ * - If the last line of the file is malformed, it throws.
321
+ *
322
+ * Silent loss of provenance is the current failure mode of `.caws/state/`
323
+ * and the whole point of this module is to reverse that default.
324
+ *
325
+ * @param {object} params
326
+ * @param {string} params.actor — who emitted the event (cli, hook, session, agent, subagent-name)
327
+ * @param {string} params.event — event type from the v0 vocabulary
328
+ * @param {string} [params.spec_id] — required for spec-scoped events, optional otherwise
329
+ * @param {object} [params.data] — event-type-specific payload
330
+ * @param {object} [options]
331
+ * @param {string} [options.projectRoot] — defaults to cwd
332
+ * @param {string} [options.session_id] — session correlator; defaults to env CAWS_SESSION_ID or "standalone"
333
+ * @returns {Promise<{seq: number, event_hash: string, prev_hash: string}>}
334
+ */
335
+ /**
336
+ * Shared contract validation for both the async and sync append paths.
337
+ * Throws on any violation. Returns a normalized descriptor the
338
+ * file-writing helper consumes.
339
+ *
340
+ * @param {object} params
341
+ * @param {object} options
342
+ * @returns {{actor: string, event: string, spec_id: (string|undefined), data: (object|undefined), sessionId: string, eventsPath: string}}
343
+ */
344
+ function validateAppendParams(params, options) {
345
+ const { actor, event, spec_id, data } = params || {};
346
+ const projectRoot = options.projectRoot || process.cwd();
347
+ const sessionId = options.session_id || process.env.CAWS_SESSION_ID || 'standalone';
348
+
349
+ if (!actor || typeof actor !== 'string') {
350
+ throw new Error('event-log.appendEvent: `actor` is required (non-empty string)');
351
+ }
352
+ if (!event || typeof event !== 'string') {
353
+ throw new Error('event-log.appendEvent: `event` is required (non-empty string)');
354
+ }
355
+ if (REQUIRES_SPEC_ID.has(event)) {
356
+ if (!spec_id || typeof spec_id !== 'string' || spec_id.trim() === '') {
357
+ throw new Error(
358
+ `event-log.appendEvent: event "${event}" requires a non-empty spec_id ` +
359
+ `(got ${JSON.stringify(spec_id)}). This is the fence that prevents the ` +
360
+ `.caws/state/undefined.json bug class — do not catch this error and continue.`
361
+ );
362
+ }
363
+ }
364
+
365
+ return {
366
+ actor,
367
+ event,
368
+ spec_id,
369
+ data,
370
+ sessionId,
371
+ eventsPath: getEventsPath(projectRoot),
372
+ };
373
+ }
374
+
375
+ /**
376
+ * Shared critical section: read tail, build event, write. Assumes the
377
+ * caller already holds the lock. Returns the new event (with seq, hashes).
378
+ *
379
+ * @param {object} ctx — output of validateAppendParams
380
+ * @returns {{seq: number, event_hash: string, prev_hash: string}}
381
+ */
382
+ function writeEventUnderLock(ctx) {
383
+ const { actor, event, spec_id, data, sessionId, eventsPath } = ctx;
384
+
385
+ const lastLine = readLastLine(eventsPath);
386
+ let seq = 1;
387
+ let prevHash = '';
388
+ if (lastLine !== null) {
389
+ let lastEvent;
390
+ try {
391
+ lastEvent = JSON.parse(lastLine);
392
+ } catch (parseErr) {
393
+ throw new Error(
394
+ `event-log.appendEvent: last line of ${eventsPath} is malformed: ${parseErr.message}. ` +
395
+ `The log is corrupt; manual inspection required before continuing.`
396
+ );
397
+ }
398
+ if (typeof lastEvent.seq !== 'number' || !Number.isInteger(lastEvent.seq)) {
399
+ throw new Error(
400
+ `event-log.appendEvent: last event missing integer seq (got ${JSON.stringify(lastEvent.seq)})`
401
+ );
402
+ }
403
+ seq = lastEvent.seq + 1;
404
+ prevHash = typeof lastEvent.event_hash === 'string' ? lastEvent.event_hash : '';
405
+ }
406
+
407
+ const newEvent = {
408
+ seq,
409
+ ts: new Date().toISOString(),
410
+ session_id: sessionId,
411
+ actor,
412
+ event,
413
+ };
414
+ if (spec_id !== undefined && spec_id !== null && spec_id !== '') {
415
+ newEvent.spec_id = spec_id;
416
+ }
417
+ if (data !== undefined) {
418
+ newEvent.data = data;
419
+ }
420
+ newEvent.prev_hash = prevHash;
421
+ newEvent.event_hash = computeEventHash(newEvent);
422
+
423
+ fs.appendFileSync(eventsPath, JSON.stringify(newEvent) + '\n', { encoding: 'utf8' });
424
+
425
+ return {
426
+ seq: newEvent.seq,
427
+ event_hash: newEvent.event_hash,
428
+ prev_hash: newEvent.prev_hash,
429
+ };
430
+ }
431
+
432
+ async function appendEvent(params, options = {}) {
433
+ const ctx = validateAppendParams(params, options);
434
+ const handle = await acquireLock(ctx.eventsPath);
435
+ try {
436
+ return writeEventUnderLock(ctx);
437
+ } finally {
438
+ releaseLock(handle);
439
+ }
440
+ }
441
+
442
+ /**
443
+ * Synchronous variant of `appendEvent`. Same contract, same fail-loud
444
+ * behavior. Intended for call sites that cannot await (synchronous
445
+ * session manager functions, hooks, etc.). Blocks the thread during
446
+ * lock contention via `Atomics.wait`.
447
+ *
448
+ * Prefer `appendEvent` in async contexts — it cooperates with the event
449
+ * loop instead of blocking it.
450
+ *
451
+ * @param {object} params — same as appendEvent
452
+ * @param {object} [options] — same as appendEvent
453
+ * @returns {{seq: number, event_hash: string, prev_hash: string}}
454
+ */
455
+ function appendEventSync(params, options = {}) {
456
+ const ctx = validateAppendParams(params, options);
457
+ const handle = acquireLockSync(ctx.eventsPath);
458
+ try {
459
+ return writeEventUnderLock(ctx);
460
+ } finally {
461
+ releaseLock(handle);
462
+ }
463
+ }
464
+
465
+ /**
466
+ * Read all events from the project's event log, in seq order.
467
+ *
468
+ * Tolerates a partial trailing line (from a crashed writer) by discarding
469
+ * it. Returns an array of parsed events. The caller is responsible for
470
+ * filtering by spec_id or event type.
471
+ *
472
+ * This is intentionally eager (not a stream) in Phase 1 — the expected
473
+ * event log size for CLI-scale projects is under 10k lines, well within
474
+ * memory. A streaming reader is a future addition when compaction lands.
475
+ *
476
+ * @param {object} [options]
477
+ * @param {string} [options.projectRoot] — defaults to cwd
478
+ * @param {boolean} [options.strict] — if true, throw on any malformed line (default false: discard trailing partial)
479
+ * @returns {object[]}
480
+ */
481
+ function readEvents(options = {}) {
482
+ const projectRoot = options.projectRoot || process.cwd();
483
+ const strict = options.strict === true;
484
+ const eventsPath = getEventsPath(projectRoot);
485
+
486
+ if (!fs.existsSync(eventsPath)) return [];
487
+ const content = fs.readFileSync(eventsPath, 'utf8');
488
+ if (content.length === 0) return [];
489
+
490
+ const lines = content.split('\n');
491
+ // The file ends in \n, so the split yields a trailing empty element.
492
+ // Any other empty element is a malformed blank line.
493
+ const events = [];
494
+ for (let i = 0; i < lines.length; i++) {
495
+ const line = lines[i];
496
+ const isLast = i === lines.length - 1;
497
+ if (line.length === 0) {
498
+ if (isLast) continue; // normal trailing newline
499
+ if (strict) {
500
+ throw new Error(`event-log.readEvents: empty line at index ${i} (strict mode)`);
501
+ }
502
+ continue;
503
+ }
504
+ try {
505
+ events.push(JSON.parse(line));
506
+ } catch (err) {
507
+ if (isLast) {
508
+ // Partial trailing line from a crashed writer — tolerate unless strict.
509
+ if (strict) {
510
+ throw new Error(
511
+ `event-log.readEvents: partial trailing line (strict mode): ${err.message}`
512
+ );
513
+ }
514
+ // Drop it silently; the next append will overwrite it.
515
+ continue;
516
+ }
517
+ throw new Error(
518
+ `event-log.readEvents: malformed line at index ${i}: ${err.message}. ` +
519
+ `The log is corrupt; manual inspection required.`
520
+ );
521
+ }
522
+ }
523
+ return events;
524
+ }
525
+
526
+ /**
527
+ * Verify the hash chain of the event log end-to-end. Walks every event,
528
+ * recomputes its event_hash, and asserts prev_hash matches the previous
529
+ * event's event_hash.
530
+ *
531
+ * Intended for a future `caws events verify` command; exported here so
532
+ * tests can use it to prove chain continuity.
533
+ *
534
+ * @param {object} [options]
535
+ * @param {string} [options.projectRoot]
536
+ * @returns {{ok: boolean, count: number, firstBadSeq?: number, reason?: string}}
537
+ */
538
+ function verifyChain(options = {}) {
539
+ const events = readEvents({ ...options, strict: true });
540
+ let prevHash = '';
541
+ for (const event of events) {
542
+ if (event.prev_hash !== prevHash) {
543
+ return {
544
+ ok: false,
545
+ count: events.length,
546
+ firstBadSeq: event.seq,
547
+ reason: `prev_hash mismatch at seq ${event.seq}: expected ${JSON.stringify(prevHash)}, got ${JSON.stringify(event.prev_hash)}`,
548
+ };
549
+ }
550
+ const recomputed = computeEventHash(event);
551
+ if (recomputed !== event.event_hash) {
552
+ return {
553
+ ok: false,
554
+ count: events.length,
555
+ firstBadSeq: event.seq,
556
+ reason: `event_hash mismatch at seq ${event.seq}: stored ${event.event_hash}, recomputed ${recomputed}`,
557
+ };
558
+ }
559
+ prevHash = event.event_hash;
560
+ }
561
+ return { ok: true, count: events.length };
562
+ }
563
+
564
+ // ---------------------------------------------------------------------------
565
+ // Exports
566
+ // ---------------------------------------------------------------------------
567
+
568
+ module.exports = {
569
+ appendEvent,
570
+ appendEventSync,
571
+ readEvents,
572
+ verifyChain,
573
+
574
+ // Exposed for tests and the renderer; not part of the stable public API.
575
+ _internal: {
576
+ canonicalJson,
577
+ computeEventHash,
578
+ readLastLine,
579
+ REQUIRES_SPEC_ID,
580
+ OPTIONAL_SPEC_ID,
581
+ HASH_DOMAIN,
582
+ EVENTS_FILE,
583
+ },
584
+ };