@paths.design/caws-cli 8.3.0 → 9.0.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.
@@ -0,0 +1,207 @@
1
+ #!/bin/bash
2
+ # CAWS Worktree Safety Guard for Claude Code
3
+ # Blocks dangerous operations when parallel worktrees are active
4
+ # @author @darianrosebrook
5
+
6
+ set -euo pipefail
7
+
8
+ # Read JSON input from Claude Code
9
+ INPUT=$(cat)
10
+
11
+ # Extract tool info
12
+ TOOL_NAME=$(echo "$INPUT" | jq -r '.tool_name // ""')
13
+ COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // ""')
14
+
15
+ # Only check Bash tool
16
+ if [[ "$TOOL_NAME" != "Bash" ]] || [[ -z "$COMMAND" ]]; then
17
+ exit 0
18
+ fi
19
+
20
+ # --- Resolve main repo root ---
21
+ # When running inside a worktree, CLAUDE_PROJECT_DIR points to the
22
+ # worktree directory, but .caws/worktrees.json only exists in the main
23
+ # repo. Use git's common dir to find the true repo root.
24
+ PROJECT_DIR="${CLAUDE_PROJECT_DIR:-.}"
25
+
26
+ if command -v git >/dev/null 2>&1; then
27
+ GIT_COMMON_DIR=$(cd "$PROJECT_DIR" && git rev-parse --git-common-dir 2>/dev/null || echo "")
28
+ if [[ -n "$GIT_COMMON_DIR" ]] && [[ "$GIT_COMMON_DIR" != ".git" ]]; then
29
+ # Inside a worktree: --git-common-dir returns the main repo's .git path
30
+ # (e.g., /path/to/repo/.git or /path/to/repo/.git/worktrees/<name>/..)
31
+ CANDIDATE=$(cd "$PROJECT_DIR" && cd "$GIT_COMMON_DIR/.." 2>/dev/null && pwd || echo "")
32
+ if [[ -n "$CANDIDATE" ]] && [[ -d "$CANDIDATE/.caws" ]]; then
33
+ PROJECT_DIR="$CANDIDATE"
34
+ fi
35
+ fi
36
+ fi
37
+
38
+ # --- Gap 2: Block sparse checkout before the git-only filter ---
39
+ # This must run before the "only check git commands" early-exit
40
+ if echo "$COMMAND" | grep -qE 'caws\s+(worktree\s+create|parallel\s+setup).*--scope'; then
41
+ echo "BLOCKED: --scope (sparse checkout) is not allowed." >&2
42
+ echo "Sparse checkout breaks cross-module imports in most projects." >&2
43
+ echo "Use full worktrees without --scope. Scope enforcement comes from" >&2
44
+ echo "CAWS feature specs and lane discipline, not from hiding files." >&2
45
+ exit 2
46
+ fi
47
+
48
+ # --- Gap 5: Block cross-boundary file copies ---
49
+ WORKTREE_BASE="$PROJECT_DIR/.caws/worktrees"
50
+ if [[ -d "$WORKTREE_BASE" ]]; then
51
+ if echo "$COMMAND" | grep -qE '\b(cp|mv)\b'; then
52
+ if echo "$COMMAND" | grep -qF ".caws/worktrees/" || echo "$COMMAND" | grep -qF "$WORKTREE_BASE"; then
53
+ # Check if the command references both a worktree path and the main repo
54
+ HAS_WT_PATH=false
55
+ HAS_MAIN_PATH=false
56
+ if echo "$COMMAND" | grep -qE '\.caws/worktrees/|'"$(echo "$WORKTREE_BASE" | sed 's/[\/&]/\\&/g')"''; then
57
+ HAS_WT_PATH=true
58
+ fi
59
+ # Check if destination/source is outside the worktree
60
+ if echo "$COMMAND" | grep -qE "(^|\s)$PROJECT_DIR/[^.]|core/|src/|tests/|packages/" && [[ "$HAS_WT_PATH" == "true" ]]; then
61
+ HAS_MAIN_PATH=true
62
+ fi
63
+ if [[ "$HAS_WT_PATH" == "true" ]] && [[ "$HAS_MAIN_PATH" == "true" ]]; then
64
+ echo "BLOCKED: Copying files between a worktree and the main repo is forbidden." >&2
65
+ echo "This bypasses worktree isolation. Work entirely within your worktree." >&2
66
+ echo "If tests need the main repo's venv, activate it with:" >&2
67
+ echo " source $PROJECT_DIR/.venv/bin/activate" >&2
68
+ exit 2
69
+ fi
70
+ fi
71
+ fi
72
+ fi
73
+
74
+ # Only check git commands from here on
75
+ if ! echo "$COMMAND" | grep -qE '(^|\s|&&|\|)git\s'; then
76
+ exit 0
77
+ fi
78
+
79
+ # --- Determine if worktrees are active ---
80
+ WORKTREES_ACTIVE=false
81
+ PARALLEL_BASE=""
82
+
83
+ # Check .caws/parallel.json
84
+ if [[ -f "$PROJECT_DIR/.caws/parallel.json" ]] && command -v node >/dev/null 2>&1; then
85
+ PARALLEL_INFO=$(node -e "
86
+ try {
87
+ var reg = JSON.parse(require('fs').readFileSync('$PROJECT_DIR/.caws/parallel.json', 'utf8'));
88
+ var agents = (reg.agents || []).length;
89
+ console.log(agents + ':' + (reg.baseBranch || ''));
90
+ } catch(e) { console.log('0:'); }
91
+ " 2>/dev/null || echo "0:")
92
+
93
+ AGENT_COUNT=$(echo "$PARALLEL_INFO" | cut -d: -f1)
94
+ PARALLEL_BASE=$(echo "$PARALLEL_INFO" | cut -d: -f2)
95
+
96
+ if [[ "$AGENT_COUNT" -gt 0 ]] 2>/dev/null; then
97
+ WORKTREES_ACTIVE=true
98
+ fi
99
+ fi
100
+
101
+ # Check .caws/worktrees.json
102
+ if [[ "$WORKTREES_ACTIVE" != "true" ]] && [[ -f "$PROJECT_DIR/.caws/worktrees.json" ]] && command -v node >/dev/null 2>&1; then
103
+ ACTIVE_COUNT=$(node -e "
104
+ try {
105
+ var reg = JSON.parse(require('fs').readFileSync('$PROJECT_DIR/.caws/worktrees.json', 'utf8'));
106
+ var active = Object.values(reg.worktrees || {}).filter(function(w) { return w.status === 'active'; });
107
+ console.log(active.length);
108
+ } catch(e) { console.log('0'); }
109
+ " 2>/dev/null || echo "0")
110
+
111
+ if [[ "$ACTIVE_COUNT" -gt 0 ]] 2>/dev/null; then
112
+ WORKTREES_ACTIVE=true
113
+ fi
114
+ fi
115
+
116
+ # If no worktrees are active, allow everything
117
+ if [[ "$WORKTREES_ACTIVE" != "true" ]]; then
118
+ exit 0
119
+ fi
120
+
121
+ # --- Block dangerous git operations when worktrees are active ---
122
+
123
+ # Block git commit --amend
124
+ if echo "$COMMAND" | grep -qE 'git\s+commit\s+.*--amend'; then
125
+ echo "BLOCKED: git commit --amend is not allowed while worktrees are active." >&2
126
+ echo "Amending commits risks rewriting another agent's work." >&2
127
+ echo "Create a new commit instead." >&2
128
+ exit 2
129
+ fi
130
+
131
+ # Block git stash (shared across worktrees)
132
+ if echo "$COMMAND" | grep -qE 'git\s+stash' && ! echo "$COMMAND" | grep -qE 'git\s+stash\s+list'; then
133
+ echo "BLOCKED: git stash is not allowed while worktrees are active." >&2
134
+ echo "Stash is shared across all worktrees and can capture or destroy another agent's work." >&2
135
+ echo "Commit your changes to your branch instead." >&2
136
+ exit 2
137
+ fi
138
+
139
+ # Block git reset --hard
140
+ if echo "$COMMAND" | grep -qE 'git\s+reset\s+--hard'; then
141
+ echo "BLOCKED: git reset --hard is not allowed while worktrees are active." >&2
142
+ echo "This could discard work that other agents depend on." >&2
143
+ exit 2
144
+ fi
145
+
146
+ # Block git push --force
147
+ if echo "$COMMAND" | grep -qE 'git\s+push\s+.*(--force|-f\s)'; then
148
+ echo "BLOCKED: Force push is not allowed while worktrees are active." >&2
149
+ echo "This could rewrite history that other agents have based work on." >&2
150
+ exit 2
151
+ fi
152
+
153
+ # --- Base branch protections ---
154
+ # Use the agent's actual working directory (CLAUDE_PROJECT_DIR), not the resolved
155
+ # main repo root (PROJECT_DIR). In a worktree, PROJECT_DIR points to the main repo
156
+ # (to find .caws/worktrees.json), but the agent's branch is in CLAUDE_PROJECT_DIR.
157
+ AGENT_DIR="${CLAUDE_PROJECT_DIR:-.}"
158
+ CURRENT_BRANCH=$(cd "$AGENT_DIR" && git rev-parse --abbrev-ref HEAD 2>/dev/null || echo "unknown")
159
+
160
+ # Determine the base branch to protect
161
+ BASE_BRANCH="$PARALLEL_BASE"
162
+ if [[ -z "$BASE_BRANCH" ]] && [[ -f "$PROJECT_DIR/.caws/worktrees.json" ]] && command -v node >/dev/null 2>&1; then
163
+ BASE_BRANCH=$(node -e "
164
+ try {
165
+ var reg = JSON.parse(require('fs').readFileSync('$PROJECT_DIR/.caws/worktrees.json', 'utf8'));
166
+ var active = Object.values(reg.worktrees || {}).filter(function(w) { return w.status === 'active'; });
167
+ if (active.length > 0) console.log(active[0].baseBranch || '');
168
+ else console.log('');
169
+ } catch(e) { console.log(''); }
170
+ " 2>/dev/null || echo "")
171
+ fi
172
+
173
+ if [[ -n "$BASE_BRANCH" ]] && [[ "$CURRENT_BRANCH" == "$BASE_BRANCH" ]]; then
174
+ # Block push from base branch
175
+ if echo "$COMMAND" | grep -qE 'git\s+push'; then
176
+ echo "BLOCKED: Pushing from the base branch ($BASE_BRANCH) while worktrees are active." >&2
177
+ echo "You should be working in a worktree, not on the base branch." >&2
178
+ echo "Use: cd .caws/worktrees/<name>/" >&2
179
+ exit 2
180
+ fi
181
+
182
+ # Allow git merge into base branch (merging completed worktree branches back)
183
+ # The commit-msg hook enforces the merge(worktree): message format
184
+ if echo "$COMMAND" | grep -qE 'git\s+merge\b'; then
185
+ echo '{
186
+ "hookSpecificOutput": {
187
+ "hookEventName": "PreToolUse",
188
+ "additionalContext": "Merging into base branch ('"$BASE_BRANCH"') while worktrees are active. The commit-msg hook will enforce the merge(worktree): message format. Make sure the worktree for this branch has been destroyed first."
189
+ }
190
+ }'
191
+ exit 0
192
+ fi
193
+
194
+ # Warn (but don't block) commits on base branch — the pre-commit + commit-msg hooks handle blocking
195
+ if echo "$COMMAND" | grep -qE 'git\s+commit\b' && ! echo "$COMMAND" | grep -qE '--amend'; then
196
+ echo '{
197
+ "hookSpecificOutput": {
198
+ "hookEventName": "PreToolUse",
199
+ "additionalContext": "WARNING: You are committing to the base branch ('"$BASE_BRANCH"') while worktrees are active. Only merge commits with the format merge(worktree): <description> are allowed. The pre-commit hook will block direct commits."
200
+ }
201
+ }'
202
+ exit 0
203
+ fi
204
+ fi
205
+
206
+ # Allow the command
207
+ exit 0
@@ -0,0 +1,84 @@
1
+ #!/bin/bash
2
+ # CAWS Worktree Write Guard for Claude Code
3
+ # Blocks Write/Edit on the base branch while worktrees are active.
4
+ # This prevents agents from modifying files on main and then trying to
5
+ # create worktrees retroactively to commit them.
6
+ # @author @darianrosebrook
7
+
8
+ set -euo pipefail
9
+
10
+ # Read JSON input from Claude Code
11
+ INPUT=$(cat)
12
+
13
+ # Extract tool info
14
+ TOOL_NAME=$(echo "$INPUT" | jq -r '.tool_name // ""')
15
+ FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // ""')
16
+
17
+ # Only check Write and Edit tools
18
+ case "$TOOL_NAME" in
19
+ Write|Edit) ;;
20
+ *) exit 0 ;;
21
+ esac
22
+
23
+ # --- Resolve main repo root ---
24
+ PROJECT_DIR="${CLAUDE_PROJECT_DIR:-.}"
25
+
26
+ if command -v git >/dev/null 2>&1; then
27
+ GIT_COMMON_DIR=$(cd "$PROJECT_DIR" && git rev-parse --git-common-dir 2>/dev/null || echo "")
28
+ if [[ -n "$GIT_COMMON_DIR" ]] && [[ "$GIT_COMMON_DIR" != ".git" ]]; then
29
+ CANDIDATE=$(cd "$PROJECT_DIR" && cd "$GIT_COMMON_DIR/.." 2>/dev/null && pwd || echo "")
30
+ if [[ -n "$CANDIDATE" ]] && [[ -d "$CANDIDATE/.caws" ]]; then
31
+ PROJECT_DIR="$CANDIDATE"
32
+ fi
33
+ fi
34
+ fi
35
+
36
+ # --- Check for active worktrees ---
37
+ if [[ ! -f "$PROJECT_DIR/.caws/worktrees.json" ]]; then
38
+ exit 0
39
+ fi
40
+
41
+ if ! command -v node >/dev/null 2>&1; then
42
+ exit 0
43
+ fi
44
+
45
+ # Use the agent's actual working directory, not the resolved main repo root.
46
+ # In a worktree, PROJECT_DIR points to the main repo (to find .caws/worktrees.json),
47
+ # but the agent's branch is in CLAUDE_PROJECT_DIR.
48
+ AGENT_DIR="${CLAUDE_PROJECT_DIR:-.}"
49
+ CURRENT_BRANCH=$(cd "$AGENT_DIR" && git rev-parse --abbrev-ref HEAD 2>/dev/null || echo "unknown")
50
+
51
+ WT_INFO=$(node -e "
52
+ try {
53
+ var reg = JSON.parse(require('fs').readFileSync('$PROJECT_DIR/.caws/worktrees.json', 'utf8'));
54
+ var active = Object.values(reg.worktrees || {}).filter(function(w) {
55
+ return w.status === 'active' && w.baseBranch === '$CURRENT_BRANCH';
56
+ });
57
+ console.log(active.length + ':' + active.map(function(w) { return w.name; }).join(', '));
58
+ } catch(e) { console.log('0:'); }
59
+ " 2>/dev/null || echo "0:")
60
+
61
+ WT_COUNT=$(echo "$WT_INFO" | cut -d: -f1)
62
+ WT_NAMES=$(echo "$WT_INFO" | cut -d: -f2)
63
+
64
+ if [[ "$WT_COUNT" -le 0 ]] 2>/dev/null; then
65
+ exit 0
66
+ fi
67
+
68
+ # Allow edits to .claude/ configuration (hooks, settings, rules)
69
+ if [[ -n "$FILE_PATH" ]]; then
70
+ case "$FILE_PATH" in
71
+ */.claude/*|*/.caws/*) exit 0 ;;
72
+ esac
73
+ fi
74
+
75
+ # Block: we're on the base branch with active worktrees
76
+ echo "BLOCKED: Cannot write/edit files on '$CURRENT_BRANCH' while $WT_COUNT worktree(s) are active: $WT_NAMES" >&2
77
+ echo "" >&2
78
+ echo "You MUST work in a worktree, not on the base branch." >&2
79
+ echo " To use an existing worktree: cd $PROJECT_DIR/.caws/worktrees/<name>/" >&2
80
+ echo " To create a new worktree: caws worktree create <name>" >&2
81
+ echo "" >&2
82
+ echo "Do NOT make changes on main and create a worktree retroactively." >&2
83
+ echo "The worktree must exist BEFORE you start making changes." >&2
84
+ exit 2
@@ -0,0 +1,26 @@
1
+ ---
2
+ description: Git safety rules for all agents
3
+ globs:
4
+ ---
5
+
6
+ # Git Safety
7
+
8
+ ## Commit discipline
9
+
10
+ - Commit after each logical unit of work (a module + its tests, a bugfix, a refactor pass)
11
+ - Use conventional commits: `feat:`, `fix:`, `refactor:`, `docs:`, `chore:`, `test:`, `perf:`
12
+ - Never accumulate uncommitted changes across multiple unrelated concerns
13
+ - Never leave uncommitted changes at session end; commit as `wip(<scope>): <description>` if incomplete
14
+
15
+ ## Forbidden operations
16
+
17
+ - `git push --force` or `git push -f` -- never rewrite remote history
18
+ - `git reset --hard` -- use `git stash` or `git checkout -- <file>` for targeted reverts (but not during parallel work)
19
+ - `git clean -f` -- may delete another agent's untracked files
20
+ - `git checkout .` or `git restore .` -- bulk discard is dangerous
21
+
22
+ ## Branch hygiene
23
+
24
+ - Work on feature branches, not directly on main/master
25
+ - One concern per branch
26
+ - Delete branches after merging
@@ -0,0 +1,51 @@
1
+ ---
2
+ description: Rules for safe multi-agent git worktree isolation
3
+ globs:
4
+ ---
5
+
6
+ # Multi-Agent Worktree Safety
7
+
8
+ When multiple agents are working on this project, each agent MUST work in its own git worktree. Never have two agents committing to the same branch.
9
+
10
+ ## Before starting work
11
+
12
+ 1. Check if worktrees exist: look for `.caws/worktrees.json` or `.caws/parallel.json`
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
+
16
+ ## Forbidden operations when worktrees are active
17
+
18
+ - `git commit --amend` -- rewrites history that other agents depend on
19
+ - `git stash` / `git stash pop` -- stash is shared across all worktrees; using it can destroy another agent's uncommitted work
20
+ - `git reset --hard` -- discards work that other agents may depend on
21
+ - `git push --force` -- rewrites remote history
22
+ - Direct commits to the base branch -- only `merge(worktree):` and `wip(checkpoint):` formats are allowed
23
+ - Copying files between your worktree and the main repo directory -- defeats isolation
24
+
25
+ ## Merging worktree branches back to base
26
+
27
+ Merge commits ARE allowed on the base branch while other worktrees are active. This lets you incrementally merge completed work without waiting for all agents to finish.
28
+
29
+ 1. Destroy the worktree first: `caws worktree destroy <name>`
30
+ 2. Switch to the base branch: `git checkout main`
31
+ 3. Merge with: `git merge --no-ff <worktree-branch>`
32
+ 4. The commit-msg hook enforces the `merge(worktree): <description>` format for non-FF merges
33
+ 5. For manual merge commits: `git commit -m "merge(worktree): integrate scenarios work"`
34
+
35
+ ## Virtual environment in worktrees
36
+
37
+ Do NOT create a new virtual environment in your worktree. Use the main repo's venv:
38
+
39
+ ```bash
40
+ source <main-repo-path>/.venv/bin/activate
41
+ ```
42
+
43
+ If your project uses `.caws/scope.json`, the `designatedVenvPath` field specifies the correct venv location.
44
+
45
+ ## When your work is done
46
+
47
+ 1. Commit all changes to your worktree branch
48
+ 2. Run tests in your worktree to verify
49
+ 3. Destroy your worktree with `caws worktree destroy <name>`
50
+ 4. Merge your branch to base: `git merge --no-ff <branch>` (uses `merge(worktree):` format)
51
+ 5. Delete the branch if no longer needed: `git branch -d <branch>`
@@ -8,6 +8,11 @@
8
8
  "type": "command",
9
9
  "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/block-dangerous.sh",
10
10
  "timeout": 10
11
+ },
12
+ {
13
+ "type": "command",
14
+ "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/worktree-guard.sh",
15
+ "timeout": 10
11
16
  }
12
17
  ]
13
18
  },
@@ -24,6 +29,11 @@
24
29
  {
25
30
  "matcher": "Write|Edit",
26
31
  "hooks": [
32
+ {
33
+ "type": "command",
34
+ "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/worktree-write-guard.sh",
35
+ "timeout": 10
36
+ },
27
37
  {
28
38
  "type": "command",
29
39
  "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/scope-guard.sh",
@@ -87,6 +97,11 @@
87
97
  "type": "command",
88
98
  "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/audit.sh stop",
89
99
  "timeout": 5
100
+ },
101
+ {
102
+ "type": "command",
103
+ "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/stop-worktree-check.sh",
104
+ "timeout": 10
90
105
  }
91
106
  ]
92
107
  }
@@ -112,6 +112,22 @@ const validateWorkingSpec = (spec, _options = {}) => {
112
112
  };
113
113
  }
114
114
 
115
+ // Validate status field if present
116
+ if (spec.status) {
117
+ const { SPEC_STATUSES } = require('../constants/spec-types');
118
+ if (!SPEC_STATUSES[spec.status]) {
119
+ return {
120
+ valid: false,
121
+ errors: [
122
+ {
123
+ instancePath: '/status',
124
+ message: `Invalid status '${spec.status}'. Valid values: ${Object.keys(SPEC_STATUSES).join(', ')}`,
125
+ },
126
+ ],
127
+ };
128
+ }
129
+ }
130
+
115
131
  // Validate experimental mode
116
132
  if (spec.experimental_mode) {
117
133
  if (typeof spec.experimental_mode !== 'object') {
@@ -206,6 +206,7 @@ function createWorktree(name, options = {}) {
206
206
  baseBranch: base,
207
207
  scope: scope || null,
208
208
  specId: specId || null,
209
+ owner: options.owner || process.env.CLAUDE_SESSION_ID || null,
209
210
  createdAt: new Date().toISOString(),
210
211
  status: 'active',
211
212
  };
@@ -276,27 +277,32 @@ function destroyWorktree(name, options = {}) {
276
277
  throw new Error(`Worktree '${name}' not found in registry`);
277
278
  }
278
279
 
279
- // Remove git worktree
280
- try {
281
- const args = ['worktree', 'remove'];
282
- if (force) args.push('--force');
283
- args.push(entry.path);
284
- execFileSync('git', args, { cwd: root, stdio: 'pipe' });
285
- } catch (error) {
286
- if (force) {
287
- // Force cleanup: remove directory manually
288
- if (fs.existsSync(entry.path)) {
280
+ // Remove git worktree — handle already-deleted directories gracefully
281
+ const dirExists = fs.existsSync(entry.path);
282
+ if (dirExists) {
283
+ try {
284
+ const args = ['worktree', 'remove'];
285
+ if (force) args.push('--force');
286
+ args.push(entry.path);
287
+ execFileSync('git', args, { cwd: root, stdio: 'pipe' });
288
+ } catch (error) {
289
+ if (force) {
290
+ // Force cleanup: remove directory manually
289
291
  fs.removeSync(entry.path);
292
+ } else {
293
+ throw new Error(`Failed to remove worktree: ${error.message}. Use --force to override.`);
290
294
  }
291
- // Prune git worktree list
292
- try {
293
- execFileSync('git', ['worktree', 'prune'], { cwd: root, stdio: 'pipe' });
294
- } catch {
295
- // Non-fatal
296
- }
297
- } else {
298
- throw new Error(`Failed to remove worktree: ${error.message}. Use --force to override.`);
299
295
  }
296
+ } else {
297
+ // Directory already gone — just clean up git's tracking
298
+ console.log(` Worktree directory already removed, cleaning up registry`);
299
+ }
300
+
301
+ // Always prune git's worktree list to stay in sync
302
+ try {
303
+ execFileSync('git', ['worktree', 'prune'], { cwd: root, stdio: 'pipe' });
304
+ } catch {
305
+ // Non-fatal
300
306
  }
301
307
 
302
308
  // Optionally delete branch
@@ -337,15 +343,19 @@ function pruneWorktrees(options = {}) {
337
343
  for (const [name, entry] of Object.entries(registry.worktrees)) {
338
344
  const created = new Date(entry.createdAt);
339
345
  const ageDays = (now - created) / (1000 * 60 * 60 * 24);
346
+ const dirExists = fs.existsSync(entry.path);
340
347
 
341
348
  const shouldPrune =
349
+ // Always prune destroyed entries
342
350
  entry.status === 'destroyed' ||
343
- (!fs.existsSync(entry.path) && ageDays > maxAgeDays) ||
344
- (maxAgeDays === 0 && entry.status === 'destroyed');
351
+ // Prune active entries whose directory is gone (filesystem-registry desync)
352
+ (entry.status === 'active' && !dirExists) ||
353
+ // Prune old missing entries
354
+ (!dirExists && ageDays > maxAgeDays);
345
355
 
346
356
  if (shouldPrune) {
347
357
  // Clean up filesystem if still exists
348
- if (fs.existsSync(entry.path)) {
358
+ if (dirExists) {
349
359
  try {
350
360
  execFileSync('git', ['worktree', 'remove', '--force', entry.path], {
351
361
  cwd: root,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@paths.design/caws-cli",
3
- "version": "8.3.0",
3
+ "version": "9.0.0",
4
4
  "description": "CAWS CLI - Coding Agent Workflow System command-line tools for spec management, quality gates, and AI-assisted development",
5
5
  "main": "dist/index.js",
6
6
  "bin": {
@@ -1,6 +1,7 @@
1
1
  #!/bin/bash
2
2
  # CAWS Scope Guard Hook for Claude Code
3
- # Validates file edits against the working spec's scope boundaries
3
+ # Validates file edits against scope boundaries from working-spec + feature specs
4
+ # Specs with terminal status (completed, closed, archived) are skipped
4
5
  # @author @darianrosebrook
5
6
 
6
7
  set -euo pipefail
@@ -25,8 +26,8 @@ PROJECT_DIR="${CLAUDE_PROJECT_DIR:-.}"
25
26
  SPEC_FILE="$PROJECT_DIR/.caws/working-spec.yaml"
26
27
  SCOPE_FILE="$PROJECT_DIR/.caws/scope.json"
27
28
 
28
- # Check if spec file or scope.json exists
29
- if [[ ! -f "$SPEC_FILE" ]] && [[ ! -f "$SCOPE_FILE" ]]; then
29
+ # Check if any spec infrastructure exists
30
+ if [[ ! -f "$SPEC_FILE" ]] && [[ ! -f "$SCOPE_FILE" ]] && [[ ! -d "$PROJECT_DIR/.caws/specs" ]]; then
30
31
  exit 0
31
32
  fi
32
33
 
@@ -119,7 +120,9 @@ if [[ ! -f "$SPEC_FILE" ]] && [[ -f "$SCOPE_FILE" ]]; then
119
120
  fi
120
121
  fi
121
122
 
122
- # Use Node.js to parse YAML and check scope
123
+ # Use Node.js to parse YAML and check scope across working spec + active feature specs
124
+ SPECS_DIR="$PROJECT_DIR/.caws/specs"
125
+
123
126
  if command -v node >/dev/null 2>&1; then
124
127
  SCOPE_CHECK=$(node -e "
125
128
  const yaml = require('js-yaml');
@@ -127,26 +130,67 @@ if command -v node >/dev/null 2>&1; then
127
130
  const path = require('path');
128
131
 
129
132
  try {
130
- const spec = yaml.load(fs.readFileSync('$SPEC_FILE', 'utf8'));
131
133
  const filePath = '$REL_PATH';
132
134
 
133
- // Check if file is explicitly out of scope
134
- const outOfScope = spec.scope?.out || [];
135
- for (const pattern of outOfScope) {
136
- // Simple glob-like matching
137
- const regex = new RegExp(pattern.replace(/\*/g, '.*').replace(/\?/g, '.'));
138
- if (regex.test(filePath)) {
139
- console.log('out_of_scope:' + pattern);
140
- process.exit(0);
135
+ // Terminal statuses: specs that are done scope no longer enforced
136
+ const TERMINAL = new Set(['completed', 'closed', 'archived']);
137
+
138
+ // Smart allowlist: root-level files, .caws/, .claude/ always pass
139
+ if (!filePath.includes('/') || filePath.startsWith('.caws/') || filePath.startsWith('.claude/')) {
140
+ console.log('in_scope');
141
+ process.exit(0);
142
+ }
143
+
144
+ // Collect all active specs (working-spec + feature specs)
145
+ const specs = [];
146
+
147
+ // Load working-spec.yaml if present
148
+ const mainSpec = '$SPEC_FILE';
149
+ if (fs.existsSync(mainSpec)) {
150
+ try {
151
+ const s = yaml.load(fs.readFileSync(mainSpec, 'utf8'));
152
+ if (s && !TERMINAL.has(s.status)) {
153
+ specs.push({ source: 'working-spec', spec: s });
154
+ }
155
+ } catch (_) {}
156
+ }
157
+
158
+ // Load feature specs from .caws/specs/
159
+ const specsDir = '$SPECS_DIR';
160
+ if (fs.existsSync(specsDir)) {
161
+ for (const f of fs.readdirSync(specsDir).filter(f => f.endsWith('.yaml') || f.endsWith('.yml'))) {
162
+ try {
163
+ const s = yaml.load(fs.readFileSync(path.join(specsDir, f), 'utf8'));
164
+ if (s && !TERMINAL.has(s.status)) {
165
+ specs.push({ source: f, spec: s });
166
+ }
167
+ } catch (_) {}
141
168
  }
142
169
  }
143
170
 
144
- // Check if file is in scope (if scope is explicitly defined)
145
- const inScope = spec.scope?.in || [];
146
- if (inScope.length > 0) {
171
+ // No active specs allow everything
172
+ if (specs.length === 0) {
173
+ console.log('in_scope');
174
+ process.exit(0);
175
+ }
176
+
177
+ // Check scope.out across ALL active specs — any match blocks
178
+ for (const { source, spec } of specs) {
179
+ for (const pattern of (spec.scope?.out || [])) {
180
+ const regex = new RegExp(pattern.replace(/\\*/g, '.*').replace(/\\?/g, '.'));
181
+ if (regex.test(filePath)) {
182
+ console.log('out_of_scope:' + source + ':' + pattern);
183
+ process.exit(0);
184
+ }
185
+ }
186
+ }
187
+
188
+ // Union all scope.in patterns — file must match at least one
189
+ const allInScope = specs.flatMap(({ spec }) => spec.scope?.in || []);
190
+ if (allInScope.length > 0) {
147
191
  let found = false;
148
- for (const pattern of inScope) {
149
- const regex = new RegExp(pattern.replace(/\*/g, '.*').replace(/\?/g, '.'));
192
+ for (const pattern of allInScope) {
193
+ const regex = new RegExp(pattern.replace(/\\*/g, '.*').replace(/\\?/g, '.'));
150
194
  if (regex.test(filePath)) {
151
195
  found = true;
152
196
  break;
@@ -165,12 +209,14 @@ if command -v node >/dev/null 2>&1; then
165
209
  " 2>&1)
166
210
 
167
211
  if [[ "$SCOPE_CHECK" == out_of_scope:* ]]; then
168
- PATTERN="${SCOPE_CHECK#out_of_scope:}"
212
+ DETAIL="${SCOPE_CHECK#out_of_scope:}"
213
+ SOURCE="${DETAIL%%:*}"
214
+ PATTERN="${DETAIL#*:}"
169
215
  echo '{
170
216
  "hookSpecificOutput": {
171
217
  "hookEventName": "PreToolUse",
172
218
  "permissionDecision": "ask",
173
- "permissionDecisionReason": "This file ('"$REL_PATH"') is marked as out-of-scope in the working spec (pattern: '"$PATTERN"'). Editing it may cause scope creep. Please confirm this edit is intentional."
219
+ "permissionDecisionReason": "This file ('"$REL_PATH"') is marked as out-of-scope in '"$SOURCE"' (pattern: '"$PATTERN"'). Editing it may cause scope creep. Please confirm this edit is intentional."
174
220
  }
175
221
  }'
176
222
  exit 0
@@ -181,7 +227,7 @@ if command -v node >/dev/null 2>&1; then
181
227
  "hookSpecificOutput": {
182
228
  "hookEventName": "PreToolUse",
183
229
  "permissionDecision": "ask",
184
- "permissionDecisionReason": "This file ('"$REL_PATH"') is not in the defined scope of the working spec. Editing it may cause scope creep. Please confirm this edit is intentional."
230
+ "permissionDecisionReason": "This file ('"$REL_PATH"') is not in the defined scope of any active spec. Editing it may cause scope creep. Please confirm this edit is intentional."
185
231
  }
186
232
  }'
187
233
  exit 0