@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
@@ -0,0 +1,210 @@
1
+ /**
2
+ * @fileoverview CAWSFIX-31 — agent claim display formatters.
3
+ *
4
+ * Single-purpose helpers for rendering agent / claim information so the
5
+ * format ("<sessionId>:<platform>", claim panels, soft-block warnings)
6
+ * is consistent across `caws status`, `caws agents`, and the
7
+ * worktree-manager soft-block surface.
8
+ *
9
+ * Display only — no I/O of its own beyond the small loaders it needs.
10
+ *
11
+ * @author @darianrosebrook
12
+ */
13
+
14
+ const path = require('path');
15
+ const fs = require('fs');
16
+
17
+ const {
18
+ loadAgentRegistry,
19
+ findSessionLogs,
20
+ } = require('./agent-session');
21
+
22
+ /**
23
+ * Composite identifier used in every visible reference to an agent.
24
+ * Format: `<sessionId>:<platform>`. Lets readers trace provenance to
25
+ * platform-specific transcript directories.
26
+ *
27
+ * @param {string} sessionId
28
+ * @param {string} platform - 'claude-code' | 'cursor' | 'unknown'
29
+ * @returns {string}
30
+ */
31
+ function formatAgentRef(sessionId, platform) {
32
+ const sid = sessionId || 'unknown';
33
+ const plat = platform || 'unknown';
34
+ return `${sid}:${plat}`;
35
+ }
36
+
37
+ /**
38
+ * Compute a short human-readable age for a heartbeat timestamp.
39
+ * @param {string|null} iso
40
+ * @returns {string}
41
+ */
42
+ function formatHeartbeatAge(iso) {
43
+ if (!iso) return 'unknown';
44
+ const t = Date.parse(iso);
45
+ if (isNaN(t)) return 'unknown';
46
+ const ms = Date.now() - t;
47
+ if (ms < 0) return 'in the future';
48
+ const sec = Math.round(ms / 1000);
49
+ if (sec < 60) return `${sec}s ago`;
50
+ const min = Math.round(sec / 60);
51
+ if (min < 60) return `${min} min ago`;
52
+ const hr = Math.round(min / 60);
53
+ if (hr < 24) return `${hr}h ago`;
54
+ const days = Math.round(hr / 24);
55
+ return `${days}d ago`;
56
+ }
57
+
58
+ /**
59
+ * Format a single session-log pointer for inclusion in a warning.
60
+ * Path is project-relative when possible.
61
+ *
62
+ * @param {object} log - Result from findSessionLogs
63
+ * @param {string} root - Project root (for relative path)
64
+ * @returns {string}
65
+ */
66
+ function formatSessionLogPointer(log, root) {
67
+ const rel = path.relative(root, log.path) || log.path;
68
+ const parts = [`tmp/${path.basename(log.path)}`, `${log.turnCount} turns`];
69
+ if (log.lastTurn) parts.push(`last turn ${log.lastTurn}`);
70
+ return ` Session log: ${rel}\n ${parts.join(', ')}`;
71
+ }
72
+
73
+ /**
74
+ * Build the structured warning printed when a foreign claim is
75
+ * detected on a worktree (for `assertWorktreeOwnership` soft-block,
76
+ * `caws worktree claim` read-only mode, etc.).
77
+ *
78
+ * @param {object} args
79
+ * @param {string} args.worktree - Worktree name
80
+ * @param {object|null} args.priorOwnerEntry - The agents.json entry for the
81
+ * prior owner, or null when TTL-pruned.
82
+ * @param {string} args.priorOwnerSessionId - Sid from worktrees.json:owner
83
+ * @param {Array} args.sessionLogs - findSessionLogs() result
84
+ * @param {string} args.root - Project root for relative paths
85
+ * @param {string} args.takeoverCommand - Exact command to suggest
86
+ * @returns {string}
87
+ */
88
+ function formatClaimNotice(args) {
89
+ const {
90
+ worktree,
91
+ priorOwnerEntry,
92
+ priorOwnerSessionId,
93
+ sessionLogs = [],
94
+ root,
95
+ takeoverCommand,
96
+ } = args;
97
+
98
+ const platform = priorOwnerEntry ? priorOwnerEntry.platform : 'unknown';
99
+ const ref = formatAgentRef(priorOwnerSessionId, platform);
100
+
101
+ const lines = [
102
+ `Worktree '${worktree}' is claimed by ${ref}`,
103
+ ];
104
+
105
+ if (priorOwnerEntry) {
106
+ const age = formatHeartbeatAge(priorOwnerEntry.lastSeen);
107
+ lines.push(` Last heartbeat: ${priorOwnerEntry.lastSeen} (${age})`);
108
+ } else {
109
+ lines.push(` Last heartbeat: no live agent registry entry (pruned or stale)`);
110
+ }
111
+
112
+ for (const log of sessionLogs) {
113
+ lines.push(formatSessionLogPointer(log, root));
114
+ }
115
+
116
+ if (takeoverCommand) {
117
+ lines.push(` To proceed: ${takeoverCommand}`);
118
+ }
119
+
120
+ return lines.join('\n');
121
+ }
122
+
123
+ /**
124
+ * Build a softer hint when a worktree has no CAWS-tracked owner but a
125
+ * matching session-log directory exists (the "may still be active"
126
+ * scenario from AC A8).
127
+ *
128
+ * @param {object} args
129
+ * @param {string} args.worktree
130
+ * @param {Array} args.sessionLogs
131
+ * @param {string} args.root
132
+ * @returns {string}
133
+ */
134
+ function formatOrphanLogHint(args) {
135
+ const { worktree, sessionLogs = [], root } = args;
136
+ const lines = [
137
+ `No active CAWS claim on worktree '${worktree}', but a session log exists.`,
138
+ ` The previous session may still be active — read for context before continuing:`,
139
+ ];
140
+ for (const log of sessionLogs) {
141
+ lines.push(formatSessionLogPointer(log, root));
142
+ }
143
+ return lines.join('\n');
144
+ }
145
+
146
+ /**
147
+ * Render the Claim panel that `caws status` includes when cwd is
148
+ * inside a worktree. Returns a multi-line string (caller prints it).
149
+ *
150
+ * @param {string} root - Project root
151
+ * @param {string} worktreeName - Worktree to inspect
152
+ * @returns {string}
153
+ */
154
+ function renderClaimPanel(root, worktreeName) {
155
+ let entry = null;
156
+ try {
157
+ const wtRegistryPath = path.join(root, '.caws', 'worktrees.json');
158
+ if (fs.existsSync(wtRegistryPath)) {
159
+ const reg = JSON.parse(fs.readFileSync(wtRegistryPath, 'utf8'));
160
+ entry = reg.worktrees && reg.worktrees[worktreeName];
161
+ }
162
+ } catch {
163
+ // Best-effort.
164
+ }
165
+
166
+ if (!entry || !entry.owner) {
167
+ return `Claim: no active claim on worktree '${worktreeName}'`;
168
+ }
169
+
170
+ const agentRegistry = loadAgentRegistry(root);
171
+ const ownerEntry = agentRegistry.agents[entry.owner] || null;
172
+ const platform = ownerEntry ? ownerEntry.platform : 'unknown';
173
+ const ref = formatAgentRef(entry.owner, platform);
174
+
175
+ const lines = [`Claim: worktree '${worktreeName}' owned by ${ref}`];
176
+ if (ownerEntry) {
177
+ lines.push(
178
+ ` Last heartbeat: ${ownerEntry.lastSeen} (${formatHeartbeatAge(ownerEntry.lastSeen)})`
179
+ );
180
+ if (ownerEntry.specId) {
181
+ lines.push(` Spec: ${ownerEntry.specId}`);
182
+ }
183
+ } else {
184
+ lines.push(` Last heartbeat: no live agent registry entry (pruned)`);
185
+ }
186
+
187
+ // Surface session-log pointers if present (filter by branch when known).
188
+ const branch = entry.branch || null;
189
+ const logs = findSessionLogs(root, { sessionId: entry.owner }).concat(
190
+ branch ? findSessionLogs(root, { branch }) : []
191
+ );
192
+ // Dedupe by path
193
+ const seen = new Set();
194
+ for (const log of logs) {
195
+ if (seen.has(log.path)) continue;
196
+ seen.add(log.path);
197
+ lines.push(formatSessionLogPointer(log, root));
198
+ }
199
+
200
+ return lines.join('\n');
201
+ }
202
+
203
+ module.exports = {
204
+ formatAgentRef,
205
+ formatHeartbeatAge,
206
+ formatSessionLogPointer,
207
+ formatClaimNotice,
208
+ formatOrphanLogHint,
209
+ renderClaimPanel,
210
+ };
@@ -122,18 +122,26 @@ function saveAgentRegistry(root, registry) {
122
122
  * @param {string} agent.platform - 'claude-code' | 'cursor' | 'unknown'
123
123
  * @param {string} [agent.model] - Model name if known
124
124
  * @param {string} [agent.specId] - Active spec ID if known
125
+ * @param {string|null} [agent.worktree] - Active worktree name if known
125
126
  * @param {number} [agent.ttl] - Custom TTL in ms (default 30 min)
126
127
  */
127
128
  function heartbeatAgent(root, agent) {
128
129
  const registry = loadAgentRegistry(root);
129
130
  const existing = registry.agents[agent.sessionId] || {};
130
131
 
132
+ // CAWSFIX-31: `worktree` is allowed to be set to null explicitly (e.g.,
133
+ // refreshing a spec-only operation). Distinguish "not provided" from
134
+ // "explicitly null" using `in` so the previous worktree binding isn't
135
+ // silently preserved when the caller intends to clear it.
136
+ const worktreeProvided = Object.prototype.hasOwnProperty.call(agent, 'worktree');
137
+
131
138
  registry.agents[agent.sessionId] = {
132
139
  ...existing,
133
140
  sessionId: agent.sessionId,
134
141
  platform: agent.platform || existing.platform || 'unknown',
135
142
  model: agent.model || existing.model || null,
136
143
  specId: agent.specId || existing.specId || null,
144
+ worktree: worktreeProvided ? agent.worktree : (existing.worktree || null),
137
145
  ttl: agent.ttl || existing.ttl || DEFAULT_TTL_MS,
138
146
  firstSeen: existing.firstSeen || new Date().toISOString(),
139
147
  lastSeen: new Date().toISOString(),
@@ -142,6 +150,138 @@ function heartbeatAgent(root, agent) {
142
150
  saveAgentRegistry(root, registry);
143
151
  }
144
152
 
153
+ /**
154
+ * Refresh the current agent session's claim with the operation's specId
155
+ * and (optionally) worktree context.
156
+ *
157
+ * CAWSFIX-31: every CAWS lifecycle CLI op (specs create/close/archive/
158
+ * delete, worktree create/bind/merge) calls this so agents.json stays
159
+ * fresh even when the IDE session-log hook hasn't fired (e.g., between
160
+ * SessionStart and PreCompact, or in non-Claude-Code environments).
161
+ *
162
+ * Best-effort: silently no-ops when no session id can be determined or
163
+ * when the project root has no .caws/ directory. Never throws.
164
+ *
165
+ * @param {string} root - Project root
166
+ * @param {object} ctx - Refresh context
167
+ * @param {string|null} [ctx.specId] - Spec the operation touched
168
+ * @param {string|null} [ctx.worktree] - Worktree the operation touched
169
+ */
170
+ function refreshAgentClaim(root, ctx = {}) {
171
+ try {
172
+ const sessionId = getAgentSessionId(root);
173
+ if (!sessionId) return;
174
+ const platform = getAgentPlatform();
175
+ heartbeatAgent(root, {
176
+ sessionId,
177
+ platform,
178
+ specId: ctx.specId || null,
179
+ worktree: ctx.worktree !== undefined ? ctx.worktree : null,
180
+ });
181
+ } catch {
182
+ // Best-effort: a failure here must never break the lifecycle op.
183
+ }
184
+ }
185
+
186
+ /**
187
+ * Find session-log directories under `tmp/` whose `.meta.json` matches
188
+ * the given session id or branch.
189
+ *
190
+ * CAWSFIX-31: the CLI surfaces these paths as pointers when a foreign
191
+ * claim is detected. It does not interpret the contents — the agent
192
+ * reads them and decides whether to take over.
193
+ *
194
+ * Returns an array of `{ sessionId, path, branch, turnCount, lastTurn }`.
195
+ * Tolerates missing/malformed `.meta.json`. Refuses to follow symlinks
196
+ * outside `<root>/tmp/` for safety.
197
+ *
198
+ * @param {string} root - Project root
199
+ * @param {object} [filters] - Optional filters
200
+ * @param {string} [filters.sessionId] - Only return logs for this id
201
+ * @param {string} [filters.branch] - Only return logs whose meta.branch matches
202
+ * @returns {Array<object>}
203
+ */
204
+ function findSessionLogs(root, filters = {}) {
205
+ const results = [];
206
+ const tmpDir = path.join(root, 'tmp');
207
+ if (!fs.existsSync(tmpDir)) return results;
208
+
209
+ let realTmp;
210
+ try {
211
+ realTmp = fs.realpathSync(tmpDir);
212
+ } catch {
213
+ return results;
214
+ }
215
+
216
+ let entries;
217
+ try {
218
+ entries = fs.readdirSync(tmpDir, { withFileTypes: true });
219
+ } catch {
220
+ return results;
221
+ }
222
+
223
+ for (const entry of entries) {
224
+ if (!entry.isDirectory()) continue;
225
+ const sid = entry.name;
226
+ if (filters.sessionId && sid !== filters.sessionId) continue;
227
+
228
+ const dirPath = path.join(tmpDir, sid);
229
+ let realDir;
230
+ try {
231
+ realDir = fs.realpathSync(dirPath);
232
+ } catch {
233
+ continue;
234
+ }
235
+ // Symlink-escape guard: realpath must remain under realTmp.
236
+ if (!realDir.startsWith(realTmp + path.sep) && realDir !== realTmp) continue;
237
+
238
+ const metaPath = path.join(dirPath, '.meta.json');
239
+ if (!fs.existsSync(metaPath)) continue;
240
+
241
+ let meta = {};
242
+ try {
243
+ meta = JSON.parse(fs.readFileSync(metaPath, 'utf8'));
244
+ } catch {
245
+ // Malformed .meta.json — skip but don't crash.
246
+ continue;
247
+ }
248
+
249
+ if (filters.branch && meta.branch !== filters.branch) continue;
250
+
251
+ // Count turn files and capture latest turn timestamp.
252
+ let turnCount = 0;
253
+ let lastTurn = null;
254
+ try {
255
+ const files = fs.readdirSync(dirPath);
256
+ const turnFiles = files
257
+ .filter((f) => /^turn-\d+\.json$/.test(f))
258
+ .sort();
259
+ turnCount = turnFiles.length;
260
+ if (turnFiles.length > 0) {
261
+ const latest = turnFiles[turnFiles.length - 1];
262
+ try {
263
+ const turnData = JSON.parse(fs.readFileSync(path.join(dirPath, latest), 'utf8'));
264
+ lastTurn = turnData.ts_end || turnData.ts_start || null;
265
+ } catch {
266
+ lastTurn = null;
267
+ }
268
+ }
269
+ } catch {
270
+ // best-effort; leave defaults
271
+ }
272
+
273
+ results.push({
274
+ sessionId: sid,
275
+ path: dirPath,
276
+ branch: meta.branch || null,
277
+ turnCount,
278
+ lastTurn,
279
+ });
280
+ }
281
+
282
+ return results;
283
+ }
284
+
145
285
  /**
146
286
  * Remove an agent session from the registry.
147
287
  * Called on session stop.
@@ -194,6 +334,8 @@ module.exports = {
194
334
  loadAgentRegistry,
195
335
  saveAgentRegistry,
196
336
  heartbeatAgent,
337
+ refreshAgentClaim,
338
+ findSessionLogs,
197
339
  removeAgent,
198
340
  findActiveAgent,
199
341
  listActiveAgents,