@paths.design/caws-cli 9.2.0 → 9.3.1

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 (43) hide show
  1. package/dist/commands/specs.js +28 -15
  2. package/dist/commands/status.js +1 -1
  3. package/dist/commands/verify-acs.js +471 -0
  4. package/dist/index.js +13 -1
  5. package/dist/parallel/parallel-manager.js +5 -12
  6. package/dist/scaffold/cursor-hooks.js +0 -1
  7. package/dist/scaffold/git-hooks.js +18 -1
  8. package/dist/templates/.caws/tools/README.md +4 -7
  9. package/dist/templates/.caws/tools/scope-guard.js +115 -171
  10. package/dist/templates/.claude/hooks/audit.sh +25 -0
  11. package/dist/templates/.claude/hooks/block-dangerous.sh +39 -0
  12. package/dist/templates/.claude/hooks/lite-sprawl-check.sh +30 -2
  13. package/dist/templates/.claude/hooks/naming-check.sh +5 -2
  14. package/dist/templates/.claude/hooks/scope-guard.sh +66 -4
  15. package/dist/templates/.claude/hooks/session-log.sh +38 -5
  16. package/dist/templates/.claude/rules/worktree-isolation.md +4 -1
  17. package/dist/templates/.cursor/README.md +0 -9
  18. package/dist/templates/.cursor/hooks/audit.sh +1 -1
  19. package/dist/templates/.cursor/hooks/block-dangerous.sh +1 -0
  20. package/dist/templates/.cursor/hooks/scan-secrets.sh +8 -3
  21. package/dist/templates/.cursor/hooks.json +0 -8
  22. package/dist/templates/.vscode/launch.json +0 -12
  23. package/dist/utils/detection.js +38 -0
  24. package/dist/utils/project-analysis.js +0 -1
  25. package/dist/utils/spec-resolver.js +23 -10
  26. package/dist/worktree/worktree-manager.js +160 -6
  27. package/package.json +1 -1
  28. package/templates/.caws/tools/README.md +4 -7
  29. package/templates/.caws/tools/scope-guard.js +115 -171
  30. package/templates/.claude/hooks/audit.sh +25 -0
  31. package/templates/.claude/hooks/block-dangerous.sh +39 -0
  32. package/templates/.claude/hooks/lite-sprawl-check.sh +30 -2
  33. package/templates/.claude/hooks/naming-check.sh +5 -2
  34. package/templates/.claude/hooks/scope-guard.sh +66 -4
  35. package/templates/.claude/hooks/session-log.sh +38 -5
  36. package/templates/.claude/rules/worktree-isolation.md +4 -1
  37. package/templates/.cursor/README.md +0 -9
  38. package/templates/.cursor/hooks/audit.sh +1 -1
  39. package/templates/.cursor/hooks/block-dangerous.sh +1 -0
  40. package/templates/.cursor/hooks/scan-secrets.sh +8 -3
  41. package/templates/.cursor/hooks.json +0 -8
  42. package/templates/.vscode/launch.json +0 -12
  43. package/templates/.cursor/hooks/caws-tool-validation.sh +0 -121
@@ -157,6 +157,33 @@ def rel(path):
157
157
  return path[len(cwd) + 1:]
158
158
  return path or ""
159
159
 
160
+ def decode_structured_text_payload(raw):
161
+ """Decode JSON-escaped text payloads (e.g., Agent/Task tool outputs)."""
162
+ if not isinstance(raw, str):
163
+ return raw
164
+ payload = raw.strip()
165
+ if not payload or payload[0] not in "[{":
166
+ return raw
167
+ try:
168
+ parsed = json.loads(payload)
169
+ except Exception:
170
+ return raw
171
+
172
+ text_blocks = []
173
+ if isinstance(parsed, dict):
174
+ parsed = [parsed]
175
+ if isinstance(parsed, list):
176
+ for item in parsed:
177
+ if not isinstance(item, dict):
178
+ continue
179
+ text = item.get("text")
180
+ if isinstance(text, str) and text.strip():
181
+ text_blocks.append(text)
182
+
183
+ if text_blocks:
184
+ return "\n\n".join(text_blocks)
185
+ return raw
186
+
160
187
  # ---- Accumulate turns as chronological event timelines ----
161
188
  turns = []
162
189
  # Each turn: {user, timeline: [{kind, ...}, ...], edits, reads, searches, commands}
@@ -242,18 +269,24 @@ for line in sys.stdin:
242
269
  tool_info = pending_tools.get(tid, {})
243
270
  name = tool_info.get("name", "unknown")
244
271
 
245
- # Decide if this result is notable enough to show inline
246
- # Task results are always notable (subagent did substantive work)
247
- notable = is_error or name == "Task"
272
+ # Always capture tool results for Bash, Task, Agent.
273
+ # For Read/Write/Edit, only capture if notable (errors, test output, etc.)
274
+ # to avoid dumping entire file contents into turn logs.
275
+ always_capture = name in ("Bash", "Task", "Agent")
276
+ notable = is_error
248
277
  if not notable and content:
249
278
  content_lower = content.lower()
250
279
  notable = any(kw.lower() in content_lower for kw in NOTABLE_KW)
251
280
 
252
- if notable and content:
281
+ if (always_capture or notable) and content:
253
282
  # Cap file-content tools (full file reads/writes blow out turn files)
254
283
  display = content
255
284
  if name in ("Read", "Write", "Edit") and len(content) > 2000:
256
285
  display = content[:2000] + "\n...(file content truncated)"
286
+ elif name in ("Task", "Agent"):
287
+ display = decode_structured_text_payload(content)
288
+ elif name == "Bash" and len(content) > 5000:
289
+ display = content[:5000] + "\n...(output truncated at 5000 chars)"
257
290
  # Graft result onto the original tool_call entry (not a separate timeline item)
258
291
  if tool_info:
259
292
  tool_info["output"] = display
@@ -281,7 +314,7 @@ for i, turn in enumerate(turns):
281
314
  md_lines = [f"# Turn {num}", ""]
282
315
 
283
316
  if turn["user"]:
284
- md_lines.extend([f"> ---user---\n{turn['user']}\n---/user---", ""])
317
+ md_lines.extend([f"> ---user---\n{turn['user']}\n---\/user---", ""])
285
318
 
286
319
  for event in turn["timeline"]:
287
320
  kind = event["kind"]
@@ -12,16 +12,19 @@ When multiple agents are working on this project, each agent MUST work in its ow
12
12
  1. Check if worktrees exist: `caws worktree list` shows all active worktrees with last commit time and owner
13
13
  2. If worktrees are active and you are on the base branch, switch to your assigned worktree
14
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.
15
16
 
16
17
  ## Forbidden operations when worktrees are active
17
18
 
18
19
  - `git commit --amend` -- rewrites history that other agents depend on
20
+ - `git rebase` -- rewrites branch history; the hook blocks this automatically while worktrees are active. If you need code from main, create a new worktree from current main instead
21
+ - `git cherry-pick` -- replays commits across branches; blocked while worktrees are active to prevent cross-boundary contamination
19
22
  - `git stash` / `git stash pop` -- stash is shared across all worktrees; using it can destroy another agent's uncommitted work
20
23
  - `git reset --hard` -- discards work that other agents may depend on
21
24
  - `git push --force` -- rewrites remote history
22
25
  - Direct commits to the base branch -- only `merge(worktree):` and `wip(checkpoint):` formats are allowed
23
26
  - Copying files between your worktree and the main repo directory -- defeats isolation
24
- - Destroying another agent's active worktree -- `caws worktree destroy` will block this unless you use `--force`
27
+ - Destroying another agent's worktree -- even with `--force`. If you did not create it, do not destroy it. Period.
25
28
 
26
29
  ## Merging worktree branches back to base
27
30
 
@@ -9,7 +9,6 @@ Cursor hooks enable seamless integration between CAWS and the Cursor IDE, provid
9
9
  - **Real-time quality validation** as you code
10
10
  - **Automatic spec validation** when editing working specs
11
11
  - **Scope enforcement** preventing out-of-scope file access
12
- - **Tool validation** for safe MCP execution
13
12
  - **Quality monitoring** after file edits
14
13
 
15
14
  ## Hook Configuration
@@ -19,7 +18,6 @@ The `hooks.json` file defines when each hook runs:
19
18
  ```json
20
19
  {
21
20
  "beforeShellExecution": ["block-dangerous.sh", "audit.sh"],
22
- "beforeMCPExecution": ["audit.sh", "caws-tool-validation.sh"],
23
21
  "beforeReadFile": ["scan-secrets.sh", "caws-scope-guard.sh"],
24
22
  "afterFileEdit": ["format.sh", "naming-check.sh", "validate-spec.sh", "caws-quality-check.sh", "audit.sh"],
25
23
  "beforeSubmitPrompt": ["caws-scope-guard.sh", "audit.sh"],
@@ -43,12 +41,6 @@ The `hooks.json` file defines when each hook runs:
43
41
  - **Blocks**: Yes (for out-of-scope file access)
44
42
  - **Requires**: `.caws/working-spec.yaml`
45
43
 
46
- #### `caws-tool-validation.sh`
47
- - **Trigger**: `beforeMCPExecution`
48
- - **Purpose**: Validates CAWS MCP tool calls for security
49
- - **Blocks**: Yes (for dangerous operations or invalid waivers)
50
- - **Validates**: Waiver creation, tool permissions, command safety
51
-
52
44
  ### General Security Hooks
53
45
 
54
46
  #### `block-dangerous.sh`
@@ -242,7 +234,6 @@ Cursor Hooks ←→ CAWS CLI ←→ VS Code Extension
242
234
 
243
235
  - **Git Hooks**: `.git/hooks/` for commit/push validation
244
236
  - **VS Code Extension**: Rich UI for CAWS operations
245
- - **MCP Server**: Agent tool integration
246
237
  - **CAWS CLI**: Core functionality
247
238
 
248
239
  ### Data Flow
@@ -2,7 +2,7 @@
2
2
  # Cursor Hook: Audit Trail
3
3
  #
4
4
  # Purpose: Log all Cursor AI events for provenance tracking
5
- # Event: All (beforeShellExecution, beforeMCPExecution, beforeReadFile,
5
+ # Event: All (beforeShellExecution, beforeReadFile,
6
6
  # afterFileEdit, beforeSubmitPrompt, stop)
7
7
  #
8
8
  # @author @darianrosebrook
@@ -29,6 +29,7 @@ HARD_BLOCKS=(
29
29
  "git commit --amend --no-edit" # Can rewrite commit history destructively
30
30
  "git reset --hard" # Can lose uncommitted work and stashed changes
31
31
  "git push --force" # Can overwrite remote repository history
32
+ "git rebase" # Rewrites branch history; blocked while worktrees are active
32
33
  "dd if="
33
34
  "mkfs"
34
35
  "format c:"
@@ -28,14 +28,19 @@ if [[ "$FILE_PATH" =~ \.(pem|key|p12|pfx|cert|crt)$ ]]; then
28
28
  fi
29
29
 
30
30
  # Scan content for common secret patterns
31
- if echo "$CONTENT" | grep -qiE "(api[_-]?key|secret[_-]?key|password|private[_-]?key|access[_-]?token|bearer\s+[A-Za-z0-9_\-\.]+|AKIA[0-9A-Z]{16})"; then
31
+ # bearer requires 20+ chars to avoid false positives on short tokens in docs
32
+ # AKIA prefix is specific to AWS access keys
33
+ if echo "$CONTENT" | grep -qiE "(api[_-]?key\s*[:=]\s*['\"]?[A-Za-z0-9_\-]{16,}|secret[_-]?key\s*[:=]\s*['\"]?[A-Za-z0-9_\-]{16,}|password\s*[:=]\s*['\"]?[^\s'\"]{8,}|private[_-]?key\s*[:=]|access[_-]?token\s*[:=]\s*['\"]?[A-Za-z0-9_\-]{16,}|[Bb]earer\s+[A-Za-z0-9_\-\.]{20,}|AKIA[0-9A-Z]{16})"; then
32
34
  # Don't block, but warn
33
35
  echo '{"permission":"allow","userMessage":"⚠️ Warning: Potential secrets detected in file. Ensure they are not committed.","agentMessage":"This file may contain secrets. Use placeholder values or environment variables."}' 2>/dev/null
34
36
  exit 0
35
37
  fi
36
38
 
37
- # Check for common PII patterns (SSN, credit card, etc.)
38
- if echo "$CONTENT" | grep -qE "([0-9]{3}-[0-9]{2}-[0-9]{4}|[0-9]{4}[- ]?[0-9]{4}[- ]?[0-9]{4}[- ]?[0-9]{4})"; then
39
+ # Check for common PII patterns (SSN, credit card)
40
+ # SSN: exactly 3-2-4 digit pattern (not inside longer numbers)
41
+ # Credit card: require at least a Luhn-plausible 13-19 digit sequence with separators
42
+ if echo "$CONTENT" | grep -qE "(^|[^0-9])[0-9]{3}-[0-9]{2}-[0-9]{4}($|[^0-9])" || \
43
+ echo "$CONTENT" | grep -qE "(^|[^0-9])[0-9]{4}[- ][0-9]{4}[- ][0-9]{4}[- ][0-9]{4}($|[^0-9])"; then
39
44
  echo '{"permission":"allow","userMessage":"⚠️ Warning: Potential PII detected. Ensure compliance with data protection policies.","agentMessage":"This file may contain PII (SSN, credit card). Use anonymized test data."}' 2>/dev/null
40
45
  exit 0
41
46
  fi
@@ -9,14 +9,6 @@
9
9
  "command": "./.cursor/hooks/audit.sh"
10
10
  }
11
11
  ],
12
- "beforeMCPExecution": [
13
- {
14
- "command": "./.cursor/hooks/audit.sh"
15
- },
16
- {
17
- "command": "./.cursor/hooks/caws-tool-validation.sh"
18
- }
19
- ],
20
12
  "beforeReadFile": [
21
13
  {
22
14
  "command": "./.cursor/hooks/scan-secrets.sh"
@@ -1,18 +1,6 @@
1
1
  {
2
2
  "version": "0.2.0",
3
3
  "configurations": [
4
- {
5
- "name": "Debug MCP Server",
6
- "type": "node",
7
- "request": "launch",
8
- "program": "${workspaceFolder}/packages/caws-mcp-server/index.js",
9
- "args": [],
10
- "env": {
11
- "NODE_ENV": "development",
12
- "CAWS_DEBUG": "true"
13
- },
14
- "console": "integratedTerminal"
15
- },
16
4
  {
17
5
  "name": "Debug CAWS CLI",
18
6
  "type": "node",
@@ -196,7 +196,45 @@ function detectCAWSSetup(cwd = process.cwd()) {
196
196
  };
197
197
  }
198
198
 
199
+ /**
200
+ * Find the CAWS project root by walking up from startDir looking for .caws/
201
+ * Falls back to git root, then to process.cwd()
202
+ * @param {string} [startDir] - Directory to start searching from
203
+ * @returns {string} Project root directory path
204
+ */
205
+ function findProjectRoot(startDir = process.cwd()) {
206
+ // In a monorepo, nested packages may have their own .caws/ (scaffold debris).
207
+ // The git root's .caws/ is authoritative — check it first.
208
+ try {
209
+ const { execFileSync } = require('child_process');
210
+ const gitRoot = execFileSync('git', ['rev-parse', '--show-toplevel'], {
211
+ encoding: 'utf8',
212
+ cwd: startDir,
213
+ stdio: ['pipe', 'pipe', 'pipe'],
214
+ }).trim();
215
+ if (gitRoot && fs.existsSync(path.join(gitRoot, '.caws'))) {
216
+ return gitRoot;
217
+ }
218
+ } catch {
219
+ // Not a git repo or git not available — fall through
220
+ }
221
+
222
+ // Walk up looking for .caws/ directory (non-git projects)
223
+ let dir = path.resolve(startDir);
224
+ const root = path.parse(dir).root;
225
+ while (dir !== root) {
226
+ if (fs.existsSync(path.join(dir, '.caws'))) {
227
+ return dir;
228
+ }
229
+ dir = path.dirname(dir);
230
+ }
231
+
232
+ // Final fallback: cwd
233
+ return process.cwd();
234
+ }
235
+
199
236
  module.exports = {
200
237
  detectCAWSSetup,
201
238
  findPackageRoot,
239
+ findProjectRoot,
202
240
  };
@@ -347,7 +347,6 @@ function getTodoAnalyzerSuggestion(cwd = process.cwd()) {
347
347
  suggestions.push(
348
348
  ' - Install Node.js: https://nodejs.org/ (then use: npx --yes @paths.design/quality-gates)'
349
349
  );
350
- suggestions.push(' - Use CAWS MCP server: caws quality-gates (via MCP)');
351
350
  }
352
351
 
353
352
  // Check for project-specific scripts (language-agnostic - if they exist, suggest them)
@@ -12,6 +12,7 @@ const chalk = require('chalk');
12
12
 
13
13
  // Import SPEC_TYPES from constants for consistent display
14
14
  const { SPEC_TYPES } = require('../constants/spec-types');
15
+ const { findProjectRoot } = require('./detection');
15
16
 
16
17
  /**
17
18
  * Spec resolution priority:
@@ -22,6 +23,18 @@ const SPECS_DIR = '.caws/specs';
22
23
  const LEGACY_SPEC = '.caws/working-spec.yaml';
23
24
  const SPECS_REGISTRY = '.caws/specs/registry.json';
24
25
 
26
+ /**
27
+ * Get the project root for spec resolution.
28
+ * Caches per process to avoid repeated filesystem walks.
29
+ */
30
+ let _cachedProjectRoot = null;
31
+ function getProjectRoot() {
32
+ if (!_cachedProjectRoot) {
33
+ _cachedProjectRoot = findProjectRoot();
34
+ }
35
+ return _cachedProjectRoot;
36
+ }
37
+
25
38
  /**
26
39
  * Resolve spec file path based on priority
27
40
  * @param {Object} options - Resolution options
@@ -36,7 +49,7 @@ async function resolveSpec(options = {}) {
36
49
 
37
50
  // 1. Explicit file path takes highest priority
38
51
  if (specFile) {
39
- const explicitPath = path.isAbsolute(specFile) ? specFile : path.join(process.cwd(), specFile);
52
+ const explicitPath = path.isAbsolute(specFile) ? specFile : path.join(getProjectRoot(), specFile);
40
53
 
41
54
  if (await fs.pathExists(explicitPath)) {
42
55
  const yaml = require('js-yaml');
@@ -55,7 +68,7 @@ async function resolveSpec(options = {}) {
55
68
 
56
69
  // 2. Feature-specific spec (preferred for multi-agent)
57
70
  if (specId) {
58
- const featurePath = path.join(process.cwd(), SPECS_DIR, `${specId}.yaml`);
71
+ const featurePath = path.join(getProjectRoot(), SPECS_DIR, `${specId}.yaml`);
59
72
 
60
73
  if (await fs.pathExists(featurePath)) {
61
74
  const yaml = require('js-yaml');
@@ -83,7 +96,7 @@ async function resolveSpec(options = {}) {
83
96
  if (specIds.length === 1) {
84
97
  // Single spec - use it automatically
85
98
  const singleSpecId = specIds[0];
86
- const singleSpecPath = path.join(process.cwd(), SPECS_DIR, registry.specs[singleSpecId].path);
99
+ const singleSpecPath = path.join(getProjectRoot(), SPECS_DIR, registry.specs[singleSpecId].path);
87
100
 
88
101
  if (await fs.pathExists(singleSpecPath)) {
89
102
  const yaml = require('js-yaml');
@@ -179,7 +192,7 @@ async function resolveSpec(options = {}) {
179
192
  }
180
193
 
181
194
  // 4. Fall back to legacy working-spec.yaml (with warning)
182
- const legacyPath = path.join(process.cwd(), LEGACY_SPEC);
195
+ const legacyPath = path.join(getProjectRoot(), LEGACY_SPEC);
183
196
 
184
197
  if (await fs.pathExists(legacyPath)) {
185
198
  const yaml = require('js-yaml');
@@ -211,7 +224,7 @@ async function resolveSpec(options = {}) {
211
224
  * @returns {Promise<Object>} Registry data
212
225
  */
213
226
  async function loadSpecsRegistry() {
214
- const registryPath = path.join(process.cwd(), SPECS_REGISTRY);
227
+ const registryPath = path.join(getProjectRoot(), SPECS_REGISTRY);
215
228
 
216
229
  if (!(await fs.pathExists(registryPath))) {
217
230
  return {
@@ -241,7 +254,7 @@ async function listAvailableSpecs() {
241
254
  const specs = [];
242
255
 
243
256
  // Check feature-specific specs
244
- const specsDir = path.join(process.cwd(), SPECS_DIR);
257
+ const specsDir = path.join(getProjectRoot(), SPECS_DIR);
245
258
  if (await fs.pathExists(specsDir)) {
246
259
  const files = await fs.readdir(specsDir);
247
260
  const yamlFiles = files.filter((f) => f.endsWith('.yaml') || f.endsWith('.yml'));
@@ -257,7 +270,7 @@ async function listAvailableSpecs() {
257
270
 
258
271
  specs.push({
259
272
  id: spec.id || path.basename(file, path.extname(file)),
260
- path: path.relative(process.cwd(), specPath),
273
+ path: path.relative(getProjectRoot(), specPath),
261
274
  type: 'feature',
262
275
  title: spec.title || 'Untitled',
263
276
  });
@@ -268,7 +281,7 @@ async function listAvailableSpecs() {
268
281
  }
269
282
 
270
283
  // Check legacy working-spec.yaml
271
- const legacyPath = path.join(process.cwd(), LEGACY_SPEC);
284
+ const legacyPath = path.join(getProjectRoot(), LEGACY_SPEC);
272
285
  if (await fs.pathExists(legacyPath)) {
273
286
  try {
274
287
  const yaml = require('js-yaml');
@@ -342,7 +355,7 @@ async function interactiveSpecSelection(specIds) {
342
355
  async function checkMultiSpecStatus() {
343
356
  const registry = await loadSpecsRegistry();
344
357
  const hasFeatureSpecs = Object.keys(registry.specs ?? {}).length > 0;
345
- const legacyPath = path.join(process.cwd(), LEGACY_SPEC);
358
+ const legacyPath = path.join(getProjectRoot(), LEGACY_SPEC);
346
359
  const hasLegacySpec = await fs.pathExists(legacyPath);
347
360
 
348
361
  return {
@@ -374,7 +387,7 @@ async function checkScopeConflicts(specIds) {
374
387
  try {
375
388
  spec = yaml.load(content);
376
389
  } catch (yamlError) {
377
- const relativePath = path.relative(process.cwd(), specPath);
390
+ const relativePath = path.relative(getProjectRoot(), specPath);
378
391
  throw new Error(
379
392
  `Invalid YAML syntax in ${relativePath}: ${yamlError.message}\n` +
380
393
  (yamlError.mark
@@ -101,6 +101,106 @@ function saveRegistry(root, registry) {
101
101
  fs.writeFileSync(registryPath, JSON.stringify(registry, null, 2));
102
102
  }
103
103
 
104
+ /**
105
+ * Discover git worktrees under .caws/worktrees/ that are not in the registry.
106
+ * @param {string} root - Repository root
107
+ * @param {Object} registry - Current registry object
108
+ * @returns {Array<{ name: string, path: string, branch: string }>}
109
+ */
110
+ function discoverUnregisteredWorktrees(root, registry) {
111
+ const unregistered = [];
112
+ try {
113
+ const output = execFileSync('git', ['worktree', 'list', '--porcelain'], {
114
+ cwd: root,
115
+ encoding: 'utf8',
116
+ stdio: 'pipe',
117
+ });
118
+ let worktreesDir;
119
+ try {
120
+ worktreesDir = fs.realpathSync(path.resolve(root, WORKTREES_DIR));
121
+ } catch {
122
+ // Directory might not exist yet
123
+ worktreesDir = path.resolve(root, WORKTREES_DIR);
124
+ }
125
+
126
+ const blocks = output.split('\n\n').filter(Boolean);
127
+ for (const block of blocks) {
128
+ const lines = block.split('\n');
129
+ const wtLine = lines.find((l) => l.startsWith('worktree '));
130
+ const branchLine = lines.find((l) => l.startsWith('branch '));
131
+ if (!wtLine) continue;
132
+
133
+ const wtPath = wtLine.replace('worktree ', '');
134
+ let resolvedPath;
135
+ try {
136
+ resolvedPath = fs.realpathSync(wtPath);
137
+ } catch {
138
+ resolvedPath = path.resolve(wtPath);
139
+ }
140
+
141
+ // Only consider worktrees under .caws/worktrees/
142
+ if (!resolvedPath.startsWith(worktreesDir + path.sep)) continue;
143
+
144
+ const name = path.basename(resolvedPath);
145
+ if (registry.worktrees[name]) continue;
146
+
147
+ const branch = branchLine
148
+ ? branchLine.replace('branch refs/heads/', '')
149
+ : `${BRANCH_PREFIX}${name}`;
150
+ unregistered.push({ name, path: resolvedPath, branch });
151
+ }
152
+ } catch {
153
+ // git worktree list failed
154
+ }
155
+ return unregistered;
156
+ }
157
+
158
+ /**
159
+ * Auto-register an unregistered worktree. Infers baseBranch via merge-base.
160
+ * @param {string} root - Repository root
161
+ * @param {Object} registry - Registry object (mutated in place)
162
+ * @param {{ name: string, path: string, branch: string }} discovered
163
+ * @returns {Object} The registered entry
164
+ */
165
+ function autoRegisterWorktree(root, registry, discovered) {
166
+ let baseBranch = 'main';
167
+ try {
168
+ execFileSync(
169
+ 'git',
170
+ ['merge-base', discovered.branch, 'main'],
171
+ { cwd: root, encoding: 'utf8', stdio: 'pipe' }
172
+ );
173
+ } catch {
174
+ try {
175
+ execFileSync(
176
+ 'git',
177
+ ['merge-base', discovered.branch, 'master'],
178
+ { cwd: root, encoding: 'utf8', stdio: 'pipe' }
179
+ );
180
+ baseBranch = 'master';
181
+ } catch {
182
+ // Keep 'main' as default
183
+ }
184
+ }
185
+
186
+ const entry = {
187
+ name: discovered.name,
188
+ path: discovered.path,
189
+ branch: discovered.branch,
190
+ baseBranch,
191
+ scope: null,
192
+ specId: null,
193
+ owner: null,
194
+ createdAt: new Date().toISOString(),
195
+ status: 'active',
196
+ autoRegistered: true,
197
+ };
198
+
199
+ registry.worktrees[discovered.name] = entry;
200
+ saveRegistry(root, registry);
201
+ return entry;
202
+ }
203
+
104
204
  /**
105
205
  * Create a new git worktree with scope isolation
106
206
  * @param {string} name - Worktree name
@@ -308,6 +408,25 @@ function listWorktrees() {
308
408
  };
309
409
  });
310
410
 
411
+ // Append unregistered worktrees discovered from git
412
+ const unregistered = discoverUnregisteredWorktrees(root, registry);
413
+ for (const discovered of unregistered) {
414
+ const lastCommit = getLastCommitInfo(discovered.branch, root);
415
+ entries.push({
416
+ name: discovered.name,
417
+ path: discovered.path,
418
+ branch: discovered.branch,
419
+ baseBranch: null,
420
+ scope: null,
421
+ specId: null,
422
+ owner: null,
423
+ createdAt: null,
424
+ status: 'unregistered',
425
+ lastCommit,
426
+ merged: false,
427
+ });
428
+ }
429
+
311
430
  return entries;
312
431
  }
313
432
 
@@ -323,9 +442,17 @@ function destroyWorktree(name, options = {}) {
323
442
  const registry = loadRegistry(root);
324
443
  const { deleteBranch = false, force = false } = options;
325
444
 
326
- const entry = registry.worktrees[name];
445
+ let entry = registry.worktrees[name];
327
446
  if (!entry) {
328
- throw new Error(`Worktree '${name}' not found in registry`);
447
+ // Fallback: scan git for unregistered worktree and auto-register
448
+ const unregistered = discoverUnregisteredWorktrees(root, registry);
449
+ const discovered = unregistered.find((u) => u.name === name);
450
+ if (discovered) {
451
+ console.log(chalk.yellow(`Worktree '${name}' not in registry but found in git. Auto-registering.`));
452
+ entry = autoRegisterWorktree(root, registry, discovered);
453
+ } else {
454
+ throw new Error(`Worktree '${name}' not found in registry or git worktree list`);
455
+ }
329
456
  }
330
457
 
331
458
  // Ownership check: refuse to destroy another agent's active worktree without --force
@@ -343,10 +470,27 @@ function destroyWorktree(name, options = {}) {
343
470
  `Worktree '${name}' belongs to another session${recency}.\n` +
344
471
  ` Owner: ${entry.owner}\n` +
345
472
  ` You: ${currentSession}\n` +
346
- `Another agent may be actively working here. Use --force to override.`
473
+ `Another agent may be actively working here.\n` +
474
+ `Do NOT destroy worktrees you did not create. Ask the user if cleanup is needed.`
347
475
  );
348
476
  }
349
477
 
478
+ // Even with --force, warn loudly when destroying another session's worktree
479
+ if (
480
+ force &&
481
+ entry.status === 'active' &&
482
+ entry.owner &&
483
+ currentSession &&
484
+ entry.owner !== currentSession
485
+ ) {
486
+ const lastCommit = entry.branch ? getLastCommitInfo(entry.branch, root) : null;
487
+ const recency = lastCommit ? ` (last commit: ${lastCommit.age})` : '';
488
+ console.log(chalk.red(`\n ⚠ WARNING: Force-destroying worktree '${name}' owned by another session${recency}`));
489
+ console.log(chalk.red(` Owner: ${entry.owner}`));
490
+ console.log(chalk.red(` You: ${currentSession}`));
491
+ console.log(chalk.red(` If the other agent is still running, this WILL break their work.\n`));
492
+ }
493
+
350
494
  // Auto-force when the branch is already merged to its base branch.
351
495
  // Dirty files in a merged worktree are definitionally stale.
352
496
  const merged = entry.branch && entry.baseBranch
@@ -421,9 +565,17 @@ function mergeWorktree(name, options = {}) {
421
565
  const registry = loadRegistry(root);
422
566
  const { dryRun = false, deleteBranch = true, message } = options;
423
567
 
424
- const entry = registry.worktrees[name];
568
+ let entry = registry.worktrees[name];
425
569
  if (!entry) {
426
- throw new Error(`Worktree '${name}' not found in registry`);
570
+ // Fallback: scan git for unregistered worktree and auto-register
571
+ const unregistered = discoverUnregisteredWorktrees(root, registry);
572
+ const discovered = unregistered.find((u) => u.name === name);
573
+ if (discovered) {
574
+ console.log(chalk.yellow(`Worktree '${name}' not in registry but found in git. Auto-registering.`));
575
+ entry = autoRegisterWorktree(root, registry, discovered);
576
+ } else {
577
+ throw new Error(`Worktree '${name}' not found in registry or git worktree list`);
578
+ }
427
579
  }
428
580
 
429
581
  const baseBranch = entry.baseBranch || 'main';
@@ -452,7 +604,7 @@ function mergeWorktree(name, options = {}) {
452
604
  let conflicts = [];
453
605
  try {
454
606
  // New-style merge-tree: takes two branches, computes merge-base automatically
455
- const mergeTreeResult = execFileSync(
607
+ execFileSync(
456
608
  'git',
457
609
  ['merge-tree', '--write-tree', baseBranch, entry.branch],
458
610
  { cwd: root, encoding: 'utf8', stdio: 'pipe' }
@@ -607,6 +759,8 @@ module.exports = {
607
759
  getRepoRoot,
608
760
  getLastCommitInfo,
609
761
  isBranchMerged,
762
+ discoverUnregisteredWorktrees,
763
+ autoRegisterWorktree,
610
764
  WORKTREES_DIR,
611
765
  REGISTRY_FILE,
612
766
  BRANCH_PREFIX,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@paths.design/caws-cli",
3
- "version": "9.2.0",
3
+ "version": "9.3.1",
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": {
@@ -4,18 +4,15 @@ This directory contains CAWS-specific tools that aren't available in the CLI.
4
4
 
5
5
  ## scope-guard.js
6
6
 
7
- Enforces that experimental code stays within designated sandbox areas. Used by Cursor hooks for scope validation.
7
+ Checks whether a file is within scope of active working-spec and feature specs. Used by Cursor hooks for scope validation on file attachments.
8
8
 
9
9
  ```bash
10
- # Validate experimental code containment
11
- node .caws/tools/scope-guard.js validate
10
+ # Check if a file is in scope
11
+ node .caws/tools/scope-guard.js check src/index.js
12
12
 
13
- # Check containment status
14
- node .caws/tools/scope-guard.js check .caws/working-spec.yaml
13
+ # Exit code 0 = in scope, 1 = out of scope
15
14
  ```
16
15
 
17
16
  **Usage in Cursor Hooks:**
18
17
 
19
18
  The `.cursor/hooks/scope-guard.sh` hook automatically uses this tool to validate file attachments against working spec scope boundaries.
20
-
21
-