@paths.design/caws-cli 10.1.0 → 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.
@@ -21,14 +21,27 @@ function matchesAny(filePath, patterns) {
21
21
  }
22
22
 
23
23
  /**
24
- * Check if a file is an infrastructure file that always passes scope checks.
25
- * Root-level files are exempt UNLESS they match an explicit scope.out pattern.
24
+ * Check if a file is infrastructure or lives in a policy-declared
25
+ * non-governed zone. Exempt files bypass both scope.in and scope.out.
26
+ *
26
27
  * @param {string} filePath - File path to check
27
- * @returns {boolean} Whether the file is exempt from scope.in checks
28
+ * @param {string[]} [nonGovernedZones=[]] - Glob patterns from
29
+ * policy.non_governed_zones. Paths matching any pattern are exempt
30
+ * from scope enforcement entirely. (CAWSFIX-26 / D9)
31
+ * @returns {boolean} Whether the file is exempt from scope checks
28
32
  */
29
- function isExempt(filePath) {
33
+ function isExempt(filePath, nonGovernedZones = []) {
30
34
  // .caws and .claude directories always pass (infrastructure)
31
35
  if (filePath.startsWith('.caws/') || filePath.startsWith('.claude/')) return true;
36
+
37
+ // Policy-declared non-governed zones short-circuit scope enforcement.
38
+ // Intentionally wins over scope.out: the contract is that these
39
+ // subtrees are outside the governance model, not merely excluded
40
+ // from one spec's scope.
41
+ if (nonGovernedZones.length > 0 && matchesAny(filePath, nonGovernedZones)) {
42
+ return true;
43
+ }
44
+
32
45
  return false;
33
46
  }
34
47
 
@@ -47,14 +60,20 @@ function isRootLevel(filePath) {
47
60
  * @param {Object} params - Gate parameters
48
61
  * @param {string[]} params.stagedFiles - Staged file paths
49
62
  * @param {Object} params.spec - Working spec with scope.in/scope.out
63
+ * @param {Object} [params.policy] - Optional CAWS policy. Reads
64
+ * policy.non_governed_zones for path exemption (CAWSFIX-26 / D9).
65
+ * When absent or the field is empty, only infra dirs are exempt.
50
66
  * @returns {Promise<Object>} Gate result with status and messages
51
67
  */
52
- async function run({ stagedFiles, spec }) {
68
+ async function run({ stagedFiles, spec, policy }) {
53
69
  const messages = [];
54
70
  const violations = [];
55
71
 
56
72
  const scopeIn = spec?.scope?.in || [];
57
73
  const scopeOut = spec?.scope?.out || [];
74
+ const nonGovernedZones = Array.isArray(policy?.non_governed_zones)
75
+ ? policy.non_governed_zones
76
+ : [];
58
77
 
59
78
  // If no scope defined, pass
60
79
  if (scopeIn.length === 0 && scopeOut.length === 0) {
@@ -62,8 +81,8 @@ async function run({ stagedFiles, spec }) {
62
81
  }
63
82
 
64
83
  for (const file of stagedFiles) {
65
- // Infrastructure dirs are always exempt
66
- if (isExempt(file)) continue;
84
+ // Infrastructure dirs AND policy-declared non-governed zones are exempt.
85
+ if (isExempt(file, nonGovernedZones)) continue;
67
86
 
68
87
  // Check scope.out first (explicit exclusion) — applies to ALL files including root-level
69
88
  if (scopeOut.length > 0 && matchesAny(file, scopeOut)) {
package/dist/index.js CHANGED
@@ -233,6 +233,11 @@ specsCmd
233
233
  .description('Close a completed spec (removes scope enforcement)')
234
234
  .action((id) => specsCommand('close', { id }));
235
235
 
236
+ specsCmd
237
+ .command('archive <id>')
238
+ .description('Archive a spec — move to .caws/specs/.archive/ and flip status to archived')
239
+ .action((id) => specsCommand('archive', { id }));
240
+
236
241
  specsCmd
237
242
  .command('conflicts')
238
243
  .description('Check for scope conflicts between specs')
@@ -364,6 +369,7 @@ worktreeCmd
364
369
  .option('--dry-run', 'Preview conflicts without merging', false)
365
370
  .option('--message <msg>', 'Custom merge commit message')
366
371
  .option('--no-delete-branch', 'Keep the branch after merging')
372
+ .option('--takeover', 'Force takeover of a foreign worktree claim (writes prior_owners audit)', false)
367
373
  .action((name, options) => worktreeCommand('merge', { name, ...options }));
368
374
 
369
375
  worktreeCmd
@@ -385,8 +391,31 @@ worktreeCmd
385
391
  .command('bind <spec-id>')
386
392
  .description('Bind a spec to this worktree (fixes mutual reference)')
387
393
  .option('--name <name>', 'Worktree name (auto-detected from cwd if omitted)')
394
+ .option('--takeover', 'Force takeover of a foreign worktree claim (writes prior_owners audit)', false)
388
395
  .action((specId, options) => worktreeCommand('bind', { specId, ...options }));
389
396
 
397
+ worktreeCmd
398
+ .command('claim <name>')
399
+ .description('Claim worktree session ownership (read-only without --takeover)')
400
+ .option('--takeover', 'Force takeover of a foreign claim (writes prior_owners audit)', false)
401
+ .action((name, options) => worktreeCommand('claim', { name, ...options }));
402
+
403
+ // Agents command group
404
+ const { agentsCommand } = require('./commands/agents');
405
+ const agentsCmd = program
406
+ .command('agents')
407
+ .description('Inspect the agent registry and session-log pointers');
408
+
409
+ agentsCmd
410
+ .command('list')
411
+ .description('List active CAWS-registered agent sessions')
412
+ .action(() => agentsCommand('list', {}));
413
+
414
+ agentsCmd
415
+ .command('show <session-id>')
416
+ .description('Show details for a specific agent session, including session-log pointer')
417
+ .action((id) => agentsCommand('show', { id }));
418
+
390
419
  // Scope command group
391
420
  const scopeCmd = program
392
421
  .command('scope')
@@ -356,6 +356,11 @@ class PolicyManager {
356
356
  description: 'Scan for TODO/FIXME/HACK/XXX markers',
357
357
  },
358
358
  },
359
+ // CAWSFIX-26 / D9: empty by default. Projects that need to opt a
360
+ // subtree out of scope enforcement (e.g., research/, playground/)
361
+ // add glob patterns here, e.g. ['research/**']. Paths matching any
362
+ // pattern bypass scope-boundary checks entirely.
363
+ non_governed_zones: [],
359
364
  };
360
365
  }
361
366
 
@@ -105,6 +105,11 @@
105
105
  },
106
106
  "additionalProperties": false,
107
107
  "description": "Quality gate configurations"
108
+ },
109
+ "non_governed_zones": {
110
+ "type": "array",
111
+ "items": { "type": "string" },
112
+ "description": "Glob patterns (picomatch, dot:true) for paths declared outside CAWS scope enforcement. Any file matching a pattern is exempt from scope-boundary checks — neither spec.scope.in nor spec.scope.out are consulted. Intended for research, playground, or experimental subtrees where governance is explicitly off by design. Example: [\"research/**\", \"playground/**\"]. (CAWSFIX-26 / D9)"
108
113
  }
109
114
  },
110
115
  "additionalProperties": false,
@@ -23,8 +23,8 @@
23
23
  "properties": {
24
24
  "id": {
25
25
  "type": "string",
26
- "pattern": "^[A-Z][A-Z0-9]*(-[A-Z0-9]+)*-\\d+$",
27
- "description": "Unique identifier for the change. Format: uppercase/digit PREFIX segments separated by dashes, final segment is one+ digits (e.g. CAWSFIX-16, P03-TRUTH-001, ALG-001A-HARDEN-01). Matches SPEC_ID_PATTERN in spec-validation.js (CAWSFIX-21 alignment)."
26
+ "pattern": "^[A-Z][A-Z0-9]*(-[A-Z0-9]+)*-\\d+[a-z]*$",
27
+ "description": "Unique identifier for the change. Format: uppercase/digit PREFIX segments separated by dashes, final segment is one+ digits with an optional lowercase suffix (e.g. CAWSFIX-16, P03-TRUTH-001, ALG-001A-HARDEN-01, APC-01a). Matches SPEC_ID_PATTERN in spec-validation.js (CAWSFIX-25 alignment)."
28
28
  },
29
29
  "title": {
30
30
  "type": "string",
@@ -10,9 +10,27 @@ When multiple agents are working on this project, each agent MUST work in its ow
10
10
  ## Before starting work
11
11
 
12
12
  1. Check if worktrees exist: `caws worktree list` shows all active worktrees with last commit time and owner
13
- 2. If worktrees are active and you are on the base branch, switch to your assigned worktree
14
- 3. If no worktree exists for you, create one with `caws worktree create <name>` or `caws parallel setup <plan-file>`
15
- 4. **Never touch a worktree you did not create.** Do not destroy, prune, stash, or "clean up" another agent's worktree — even if it looks stale. Another agent may be actively working in it. If you think a worktree is abandoned, leave it alone and let the user decide.
13
+ 2. Check who's actually working: `caws agents list` shows registered sessions and their bound worktree/spec, formatted as `<sessionId>:<platform>`
14
+ 3. If you're inside a worktree, run `caws status` the Claim panel shows the current owner, last heartbeat, and any session-log path under `tmp/<sessionId>/`
15
+ 4. If worktrees are active and you are on the base branch, switch to your assigned worktree
16
+ 5. If no worktree exists for you, create one with `caws worktree create <name>` or `caws parallel setup <plan-file>`
17
+ 6. **Never touch a worktree you did not create.** Do not destroy, prune, stash, or "clean up" another agent's worktree — even if it looks stale. Another agent may be actively working in it. If you think a worktree is abandoned, leave it alone and let the user decide.
18
+
19
+ ## Foreign-claim soft-block
20
+
21
+ `caws worktree bind`, `merge`, and `claim` refuse to mutate a worktree whose `worktrees.json:owner` is a session id different from the current session — unless `--takeover` is supplied. The refusal prints a structured warning naming the claimer, the heartbeat age, any session-log pointer under `tmp/<sessionId>/`, and the exact `--takeover` command:
22
+
23
+ ```
24
+ Worktree 'wt-foo' is claimed by 8be65780-...:claude-code
25
+ Last heartbeat: 2026-04-27T17:04:00Z (23 min ago)
26
+ Session log: tmp/8be65780-72e0-4fc7-a989-4ebac148c18d
27
+ 15 turns, last turn 2026-04-27T17:26:49Z
28
+ To proceed: caws worktree claim wt-foo --takeover
29
+ ```
30
+
31
+ **Decision-gating uses session-id equality only.** A stale heartbeat is NOT authorization to take over — paused sessions are not ended sessions. Read the session log under `tmp/<sessionId>/` for context first. Take over only when you have explicit user authorization.
32
+
33
+ `--takeover` writes a durable `prior_owners` audit on the worktree entry (sessionId, platform, lastSeen-at-takeover, takenOver_at) so the handoff is traceable in `worktrees.json`, not just in agent memory.
16
34
 
17
35
  ## Forbidden operations when worktrees are active
18
36
 
@@ -102,6 +102,12 @@ caws scope show
102
102
 
103
103
  # Fix a broken binding
104
104
  caws worktree bind <spec-id>
105
+
106
+ # Inspect the agent registry — who is currently working what
107
+ caws agents list
108
+
109
+ # Inspect a specific worktree's claim (read-only by default)
110
+ caws worktree claim <name>
105
111
  ```
106
112
 
107
113
  **Recovery checklist** (when the scope guard blocks you unexpectedly):
@@ -110,6 +116,22 @@ caws worktree bind <spec-id>
110
116
  3. If authoritative but still blocked: the file is genuinely outside your spec's scope. Update your spec's `scope.in` if the file should be in scope, or request a waiver
111
117
  4. Do NOT modify another spec's `scope.out` to unblock yourself — that defeats the isolation
112
118
 
119
+ ### Agent Claims & Multi-Agent Coordination
120
+
121
+ Each session gets registered in `.caws/agents.json` automatically (via the session-log hook and on every CAWS lifecycle CLI invocation). Worktree session ownership is tracked in `.caws/worktrees.json:owner` as a session id.
122
+
123
+ `caws worktree bind`, `merge`, and `claim` will refuse to mutate a worktree owned by a different session id without explicit `--takeover`. The refusal prints a structured warning naming the claimer as `<sessionId>:<platform>`, the heartbeat age, and any matching `tmp/<sessionId>/` session-log path so you can read context before deciding.
124
+
125
+ **Decision-gating uses session-id equality only.** TTL pruning of `agents.json` is registry hygiene; it does NOT authorize takeover. A stale heartbeat doesn't mean the prior session is dead — it may be paused.
126
+
127
+ `--takeover` writes a durable `prior_owners` audit on the worktree entry (sessionId, platform, lastSeen-at-takeover, takenOver_at) so handoffs are traceable in `worktrees.json`, not just in agent memory.
128
+
129
+ ### Spec lifecycle: archive
130
+
131
+ Use `caws specs archive <id>` to move a closed spec to the canonical `.caws/specs/.archive/` directory. The directory is filesystem-authoritative — `caws specs list` reports any file under `.archive/` as `status: archived` regardless of the YAML literal. This means manually-moved legacy specs (no registry entry) are correctly classified.
132
+
133
+ If you try to `caws specs create <id>` for an id that already exists in `.archive/`, the command refuses without `--force`. With `--force`, the archived YAML is removed and a fresh draft is created — useful for resurrecting an old id with new intent.
134
+
113
135
  > **Budget note**: `change_budget:` in a spec is informational documentation only. CAWS
114
136
  > derives the enforced budget from `policy.yaml` keyed on `risk_tier`. The field in the
115
137
  > spec is not used by `caws validate` for enforcement.
@@ -59,6 +59,32 @@ caws worktree bind <spec-id>
59
59
  3. If authoritative but blocked: update your spec's `scope.in`
60
60
  4. Do NOT edit another spec's `scope.out` to unblock yourself
61
61
 
62
+ ## Multi-Agent Claims
63
+
64
+ Each session is registered in `.caws/agents.json` automatically. Worktree session ownership is recorded in `.caws/worktrees.json:owner` as a session id. `caws worktree bind`, `merge`, and `claim` will refuse to mutate a worktree owned by a different session id without `--takeover`.
65
+
66
+ ```bash
67
+ # See registered agents (composite <sessionId>:<platform> format)
68
+ caws agents list
69
+
70
+ # Inspect a worktree's claim — read-only by default
71
+ caws worktree claim <name>
72
+
73
+ # Take over a foreign claim (writes prior_owners audit)
74
+ caws worktree claim <name> --takeover
75
+ ```
76
+
77
+ When a refusal fires, the warning includes the claimer's session id, heartbeat age, and a pointer to any `tmp/<sessionId>/` session-log directory — read that log for context before deciding to take over. A stale heartbeat does NOT mean the prior session is dead; it may be paused.
78
+
79
+ ## Spec Lifecycle: Archive
80
+
81
+ ```bash
82
+ # Move a closed spec to the canonical archive
83
+ caws specs archive <spec-id>
84
+ ```
85
+
86
+ The `.caws/specs/.archive/` directory is filesystem-authoritative — `caws specs list` reports any file under it as `archived` regardless of YAML status. `caws specs create` refuses ids that collide with archived files unless `--force` is supplied (which removes the archived copy and writes a fresh draft).
87
+
62
88
  ## Key Rules
63
89
 
64
90
  1. **Stay in scope** -- only edit files listed in `scope.in`, never touch `scope.out`
@@ -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,