@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.
- package/dist/commands/agents.js +124 -0
- package/dist/commands/specs.js +214 -6
- package/dist/commands/status.js +21 -0
- package/dist/commands/worktree.js +134 -18
- package/dist/gates/scope-boundary.js +26 -7
- package/dist/index.js +29 -0
- package/dist/policy/PolicyManager.js +5 -0
- package/dist/templates/.caws/schemas/policy.schema.json +5 -0
- package/dist/templates/.caws/schemas/working-spec.schema.json +2 -2
- package/dist/templates/.claude/rules/worktree-isolation.md +21 -3
- package/dist/templates/CLAUDE.md +22 -0
- package/dist/templates/agents.md +26 -0
- package/dist/utils/agent-display.js +210 -0
- package/dist/utils/agent-session.js +142 -0
- package/dist/validation/spec-validation.js +7 -4
- package/dist/worktree/worktree-manager.js +407 -46
- package/package.json +1 -1
- package/templates/.caws/schemas/policy.schema.json +5 -0
- package/templates/.caws/schemas/working-spec.schema.json +2 -2
- package/templates/.claude/rules/worktree-isolation.md +21 -3
- package/templates/CLAUDE.md +22 -0
- package/templates/agents.md +26 -0
|
@@ -21,14 +21,27 @@ function matchesAny(filePath, patterns) {
|
|
|
21
21
|
}
|
|
22
22
|
|
|
23
23
|
/**
|
|
24
|
-
* Check if a file is
|
|
25
|
-
*
|
|
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
|
-
* @
|
|
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
|
|
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-
|
|
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.
|
|
14
|
-
3. If
|
|
15
|
-
4.
|
|
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
|
|
package/dist/templates/CLAUDE.md
CHANGED
|
@@ -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.
|
package/dist/templates/agents.md
CHANGED
|
@@ -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,
|