@paths.design/caws-cli 10.0.1 → 10.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/README.md +13 -5
  2. package/dist/budget-derivation.js +221 -74
  3. package/dist/commands/evaluate.js +26 -12
  4. package/dist/commands/gates.js +31 -4
  5. package/dist/commands/init.js +7 -4
  6. package/dist/commands/iterate.js +7 -3
  7. package/dist/commands/scope.js +264 -0
  8. package/dist/commands/sidecar.js +6 -3
  9. package/dist/commands/specs.js +148 -1
  10. package/dist/commands/status.js +8 -4
  11. package/dist/commands/templates.js +0 -8
  12. package/dist/commands/validate.js +34 -13
  13. package/dist/commands/verify-acs.js +25 -10
  14. package/dist/commands/waivers.js +147 -5
  15. package/dist/commands/worktree.js +81 -1
  16. package/dist/gates/budget-limit.js +6 -1
  17. package/dist/gates/spec-completeness.js +8 -1
  18. package/dist/index.js +27 -0
  19. package/dist/policy/PolicyManager.js +9 -7
  20. package/dist/session/session-manager.js +34 -0
  21. package/dist/templates/.caws/schemas/policy.schema.json +96 -34
  22. package/dist/templates/.caws/schemas/scope.schema.json +3 -3
  23. package/dist/templates/.caws/schemas/waivers.schema.json +91 -21
  24. package/dist/templates/.caws/schemas/working-spec.schema.json +253 -89
  25. package/dist/templates/.caws/templates/working-spec.template.yml +3 -1
  26. package/dist/templates/.caws/tools/scope-guard.js +66 -15
  27. package/dist/templates/.claude/README.md +1 -1
  28. package/dist/templates/.claude/hooks/protected-paths.sh +39 -0
  29. package/dist/templates/.claude/hooks/scope-guard.sh +106 -27
  30. package/dist/templates/.claude/hooks/worktree-write-guard.sh +96 -3
  31. package/dist/templates/.claude/settings.json +5 -0
  32. package/dist/templates/CLAUDE.md +34 -0
  33. package/dist/templates/agents.md +21 -0
  34. package/dist/utils/event-log.js +584 -0
  35. package/dist/utils/event-renderer.js +521 -0
  36. package/dist/utils/schema-validator.js +10 -2
  37. package/dist/utils/working-state.js +25 -0
  38. package/dist/validation/spec-validation.js +99 -9
  39. package/dist/waivers-manager.js +84 -0
  40. package/dist/worktree/worktree-manager.js +214 -8
  41. package/package.json +5 -4
  42. package/templates/.caws/schemas/policy.schema.json +96 -34
  43. package/templates/.caws/schemas/scope.schema.json +3 -3
  44. package/templates/.caws/schemas/waivers.schema.json +91 -21
  45. package/templates/.caws/schemas/working-spec.schema.json +253 -89
  46. package/templates/.caws/templates/working-spec.template.yml +3 -1
  47. package/templates/.caws/tools/scope-guard.js +66 -15
  48. package/templates/.claude/README.md +1 -1
  49. package/templates/.claude/hooks/protected-paths.sh +39 -0
  50. package/templates/.claude/hooks/scope-guard.sh +106 -27
  51. package/templates/.claude/hooks/worktree-write-guard.sh +96 -3
  52. package/templates/.claude/settings.json +5 -0
  53. package/templates/CLAUDE.md +34 -0
  54. package/templates/agents.md +21 -0
@@ -204,31 +204,83 @@ if command -v node >/dev/null 2>&1; then
204
204
  process.exit(0);
205
205
  }
206
206
 
207
- // Collect all active specs (working-spec + feature specs)
208
- const specs = [];
207
+ const projectDir = '$PROJECT_DIR';
209
208
 
210
- // Load working-spec.yaml if present
211
- const mainSpec = '$SPEC_FILE';
212
- if (fs.existsSync(mainSpec)) {
213
- try {
214
- const s = yaml.load(fs.readFileSync(mainSpec, 'utf8'));
215
- if (s && !TERMINAL.has(s.status)) {
216
- specs.push({ source: 'working-spec', spec: s });
217
- }
218
- } catch (_) {}
209
+ // --- Authoritative spec detection ---
210
+ // If we are inside a worktree with a bound specId, ONLY check that spec.
211
+ // This prevents unrelated specs from blocking writes via broad scope.out.
212
+ let authoritativeSpec = null;
213
+ let mode = 'union';
214
+
215
+ const registryPath = path.join(projectDir, '.caws', 'worktrees.json');
216
+ const cwd = process.cwd();
217
+ const worktreesBase = path.join(projectDir, '.caws', 'worktrees');
218
+
219
+ if (cwd.startsWith(worktreesBase + '/')) {
220
+ const relative = cwd.slice(worktreesBase.length + 1);
221
+ const worktreeName = relative.split('/')[0];
222
+
223
+ if (worktreeName && fs.existsSync(registryPath)) {
224
+ try {
225
+ const reg = JSON.parse(fs.readFileSync(registryPath, 'utf8'));
226
+ const entry = reg.worktrees && reg.worktrees[worktreeName];
227
+
228
+ if (entry && entry.specId) {
229
+ // Try to load the bound spec
230
+ const specsDir = '$SPECS_DIR';
231
+ const specCandidates = [
232
+ path.join(specsDir, entry.specId + '.yaml'),
233
+ path.join(specsDir, entry.specId + '.yml'),
234
+ ];
235
+ for (const candidate of specCandidates) {
236
+ if (fs.existsSync(candidate)) {
237
+ try {
238
+ const s = yaml.load(fs.readFileSync(candidate, 'utf8'));
239
+ if (s && !TERMINAL.has(s.status)) {
240
+ // Verify mutual binding: spec must also reference this worktree
241
+ if (s.worktree === worktreeName) {
242
+ authoritativeSpec = { source: path.basename(candidate), spec: s };
243
+ mode = 'authoritative';
244
+ }
245
+ }
246
+ } catch (_) {}
247
+ break;
248
+ }
249
+ }
250
+ }
251
+ } catch (_) {}
252
+ }
219
253
  }
220
254
 
221
- // Load feature specs from .caws/specs/
222
- const specsDir = '$SPECS_DIR';
223
- if (fs.existsSync(specsDir)) {
224
- for (const f of fs.readdirSync(specsDir).filter(f => f.endsWith('.yaml') || f.endsWith('.yml'))) {
255
+ // --- Collect specs based on mode ---
256
+ const specs = [];
257
+
258
+ if (authoritativeSpec) {
259
+ // Authoritative: only the bound spec matters
260
+ specs.push(authoritativeSpec);
261
+ } else {
262
+ // Union: load all active specs
263
+ const mainSpec = '$SPEC_FILE';
264
+ if (fs.existsSync(mainSpec)) {
225
265
  try {
226
- const s = yaml.load(fs.readFileSync(path.join(specsDir, f), 'utf8'));
266
+ const s = yaml.load(fs.readFileSync(mainSpec, 'utf8'));
227
267
  if (s && !TERMINAL.has(s.status)) {
228
- specs.push({ source: f, spec: s });
268
+ specs.push({ source: 'working-spec', spec: s });
229
269
  }
230
270
  } catch (_) {}
231
271
  }
272
+
273
+ const specsDir = '$SPECS_DIR';
274
+ if (fs.existsSync(specsDir)) {
275
+ for (const f of fs.readdirSync(specsDir).filter(f => f.endsWith('.yaml') || f.endsWith('.yml'))) {
276
+ try {
277
+ const s = yaml.load(fs.readFileSync(path.join(specsDir, f), 'utf8'));
278
+ if (s && !TERMINAL.has(s.status)) {
279
+ specs.push({ source: f, spec: s });
280
+ }
281
+ } catch (_) {}
282
+ }
283
+ }
232
284
  }
233
285
 
234
286
  // No active specs — allow everything
@@ -237,18 +289,18 @@ if command -v node >/dev/null 2>&1; then
237
289
  process.exit(0);
238
290
  }
239
291
 
240
- // Check scope.out across ALL active specs — any match blocks
292
+ // Check scope.out — any match blocks
241
293
  for (const { source, spec } of specs) {
242
294
  for (const pattern of (spec.scope?.out || [])) {
243
295
  const regex = globToRegex(pattern);
244
296
  if (regex.test(filePath)) {
245
- console.log('out_of_scope:' + source + ':' + pattern);
297
+ console.log('out_of_scope:' + mode + ':' + source + ':' + pattern);
246
298
  process.exit(0);
247
299
  }
248
300
  }
249
301
  }
250
302
 
251
- // Union all scope.in patterns — file must match at least one
303
+ // scope.in — file must match at least one pattern
252
304
  const allInScope = specs.flatMap(({ spec }) => spec.scope?.in || []);
253
305
  if (allInScope.length > 0) {
254
306
  let found = false;
@@ -260,7 +312,7 @@ if command -v node >/dev/null 2>&1; then
260
312
  }
261
313
  }
262
314
  if (!found) {
263
- console.log('not_in_scope');
315
+ console.log('not_in_scope:' + mode);
264
316
  process.exit(0);
265
317
  }
266
318
  }
@@ -282,18 +334,45 @@ if command -v node >/dev/null 2>&1; then
282
334
 
283
335
  if [[ "$SCOPE_CHECK" == out_of_scope:* ]]; then
284
336
  DETAIL="${SCOPE_CHECK#out_of_scope:}"
285
- SOURCE="${DETAIL%%:*}"
286
- PATTERN="${DETAIL#*:}"
337
+ # Format: mode:source:pattern
338
+ MODE="${DETAIL%%:*}"
339
+ REST="${DETAIL#*:}"
340
+ SOURCE="${REST%%:*}"
341
+ PATTERN="${REST#*:}"
287
342
  echo "BLOCKED: $REL_PATH is excluded by scope.out in $SOURCE (pattern: $PATTERN)"
288
- echo " Scope allows: files not matching scope.out patterns"
289
- echo " To modify scope, update the spec's scope.out field"
343
+ if [[ "$MODE" == "union" ]]; then
344
+ echo " Mode: union (no authoritative spec bound to this worktree)"
345
+ echo " The scope guard is checking ALL active specs because the worktree<->spec"
346
+ echo " binding is missing. An unrelated spec may be blocking this edit."
347
+ echo " Fix: caws worktree bind <your-spec-id>"
348
+ echo " Diagnose: caws scope show"
349
+ else
350
+ echo " Mode: authoritative (checking only your bound spec)"
351
+ echo " To modify scope, update the spec's scope.out field"
352
+ fi
353
+ exit 2
354
+ fi
355
+
356
+ if [[ "$SCOPE_CHECK" == not_in_scope:* ]]; then
357
+ MODE="${SCOPE_CHECK#not_in_scope:}"
358
+ echo "BLOCKED: $REL_PATH is not in the defined scope.in of any active spec"
359
+ if [[ "$MODE" == "union" ]]; then
360
+ echo " Mode: union (no authoritative spec bound to this worktree)"
361
+ echo " The scope guard is checking ALL active specs because the worktree<->spec"
362
+ echo " binding is missing. Your file may be in a scope that no spec covers."
363
+ echo " Fix: caws worktree bind <your-spec-id>"
364
+ echo " Diagnose: caws scope show"
365
+ else
366
+ echo " Mode: authoritative (checking only your bound spec)"
367
+ echo " To modify scope, update the spec's scope.in field"
368
+ fi
290
369
  exit 2
291
370
  fi
292
371
 
372
+ # Legacy fallback for unqualified not_in_scope (shouldn't happen with updated logic)
293
373
  if [[ "$SCOPE_CHECK" == "not_in_scope" ]]; then
294
374
  echo "BLOCKED: $REL_PATH is not in the defined scope.in of any active spec"
295
- echo " Scope allows: files matching scope.in patterns in active specs"
296
- echo " To modify scope, update the spec's scope.in field"
375
+ echo " Diagnose: caws scope show"
297
376
  exit 2
298
377
  fi
299
378
  fi
@@ -66,14 +66,107 @@ if [[ "$WT_COUNT" -le 0 ]] 2>/dev/null; then
66
66
  exit 0
67
67
  fi
68
68
 
69
- # Allow edits to configuration and documentation (benign, no merge conflict risk)
69
+ # Main is blocked during active worktree work because shared unstaged state makes
70
+ # agents stash, checkpoint, or explain each other's edits. Keep direct main edits
71
+ # limited to coordination/docs/scratch paths, then use active spec scope below to
72
+ # permit only files no worktree claims.
70
73
  if [[ -n "$FILE_PATH" ]]; then
71
74
  case "$FILE_PATH" in
72
- */.claude/*|*/.caws/*) exit 0 ;;
73
- */docs/*) exit 0 ;;
75
+ .caws/*|*/.caws/*) exit 0 ;;
76
+ .claude/*|*/.claude/*) exit 0 ;;
77
+ .gitignore|*/.gitignore) exit 0 ;;
78
+ .tmp/*|*/.tmp/*) exit 0 ;;
79
+ tmp/*|*/tmp/*) exit 0 ;;
80
+ .archive/*|*/.archive/*) exit 0 ;;
81
+ .githooks/*|*/.githooks/*) exit 0 ;;
82
+ .github/*|*/.github/*) exit 0 ;;
83
+ docs/*|*/docs/*) exit 0 ;;
74
84
  esac
75
85
  fi
76
86
 
87
+ if [[ -n "$FILE_PATH" ]]; then
88
+ REL_PATH="$FILE_PATH"
89
+ if [[ "$FILE_PATH" == "$PROJECT_DIR"/* ]]; then
90
+ REL_PATH="${FILE_PATH#$PROJECT_DIR/}"
91
+ fi
92
+
93
+ SPEC_CONTENTION_CHECK=$(PROJECT_DIR="$PROJECT_DIR" CURRENT_BRANCH="$CURRENT_BRANCH" REL_PATH="$REL_PATH" node -e "
94
+ var fs = require('fs');
95
+ var path = require('path');
96
+ var yaml;
97
+
98
+ try {
99
+ yaml = require('js-yaml');
100
+ } catch (_) {
101
+ console.log('unknown:no-js-yaml');
102
+ process.exit(0);
103
+ }
104
+
105
+ function globToRegExp(pattern) {
106
+ return new RegExp(String(pattern).replace(/\\*/g, '.*').replace(/\\?/g, '.'));
107
+ }
108
+
109
+ try {
110
+ var projectDir = process.env.PROJECT_DIR;
111
+ var currentBranch = process.env.CURRENT_BRANCH;
112
+ var relPath = process.env.REL_PATH;
113
+ var registryPath = path.join(projectDir, '.caws', 'worktrees.json');
114
+ var registry = JSON.parse(fs.readFileSync(registryPath, 'utf8'));
115
+ var worktrees = Object.values(registry.worktrees || {}).filter(function(w) {
116
+ return (w.status === 'active' || w.status === 'fresh' || w.status === 'merged') && w.baseBranch === currentBranch;
117
+ });
118
+
119
+ if (worktrees.length === 0) {
120
+ console.log('unknown:no-registry-worktrees');
121
+ process.exit(0);
122
+ }
123
+
124
+ for (var wi = 0; wi < worktrees.length; wi++) {
125
+ var wt = worktrees[wi];
126
+ if (!wt.specId) {
127
+ console.log('unknown:missing-specId:' + (wt.name || 'unnamed'));
128
+ process.exit(0);
129
+ }
130
+
131
+ var specPath = path.join(projectDir, '.caws', 'specs', wt.specId + '.yaml');
132
+ if (!fs.existsSync(specPath)) {
133
+ specPath = path.join(projectDir, '.caws', 'specs', wt.specId + '.yml');
134
+ }
135
+ if (!fs.existsSync(specPath)) {
136
+ console.log('unknown:missing-spec:' + wt.specId);
137
+ process.exit(0);
138
+ }
139
+
140
+ var spec = yaml.load(fs.readFileSync(specPath, 'utf8')) || {};
141
+ var scope = spec.scope || {};
142
+ var patterns = []
143
+ .concat(Array.isArray(scope.in) ? scope.in : [])
144
+ .concat(Array.isArray(scope.out) ? scope.out : []);
145
+
146
+ if (patterns.length === 0) {
147
+ console.log('unknown:missing-scope:' + wt.specId);
148
+ process.exit(0);
149
+ }
150
+
151
+ for (var pi = 0; pi < patterns.length; pi++) {
152
+ if (globToRegExp(patterns[pi]).test(relPath)) {
153
+ console.log('claimed:' + (wt.name || wt.specId) + ':' + patterns[pi]);
154
+ process.exit(0);
155
+ }
156
+ }
157
+ }
158
+
159
+ console.log('clear');
160
+ } catch (error) {
161
+ console.log('unknown:' + error.message);
162
+ }
163
+ " 2>/dev/null || echo "unknown:node-error")
164
+
165
+ if [[ "$SPEC_CONTENTION_CHECK" == "clear" ]]; then
166
+ exit 0
167
+ fi
168
+ fi
169
+
77
170
  # Allow edits during an active merge (conflict resolution).
78
171
  # The worktree-isolation rules explicitly permit merge commits on the base branch.
79
172
  # Conflict resolution requires Write/Edit on the conflicted files.
@@ -29,6 +29,11 @@
29
29
  {
30
30
  "matcher": "Write|Edit",
31
31
  "hooks": [
32
+ {
33
+ "type": "command",
34
+ "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/protected-paths.sh",
35
+ "timeout": 5
36
+ },
32
37
  {
33
38
  "type": "command",
34
39
  "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/worktree-write-guard.sh",
@@ -29,6 +29,9 @@ Before writing code, check the canonical spec for the current feature:
29
29
  # Create a feature spec for isolated work
30
30
  caws specs create FEAT-001 --type feature --title "description"
31
31
 
32
+ # If you're in a CAWS worktree, the created spec should record it:
33
+ # worktree: <worktree-name>
34
+
32
35
  # Validate the feature spec
33
36
  caws validate --spec-id FEAT-001
34
37
 
@@ -68,6 +71,7 @@ Canonical feature specs live at `.caws/specs/<ID>.yaml` (create with `caws specs
68
71
 
69
72
  - **Risk tier**: Quality requirements (T1: critical, T2: standard, T3: low risk)
70
73
  - **Mode**: The type of change (`feature`, `refactor`, `fix`, `doc`, `chore`) -- required
74
+ - **Worktree**: The owning CAWS worktree name for this spec (`worktree`) -- recommended for all isolated work
71
75
  - **Blast radius**: Which modules are affected (`blast_radius.modules`) -- required
72
76
  - **Operational rollback SLO**: Time target for rollback (e.g. `"30m"`) -- required
73
77
  - **Scope**: Which files you can edit (`scope.in`) and which are off-limits (`scope.out`)
@@ -76,6 +80,36 @@ Canonical feature specs live at `.caws/specs/<ID>.yaml` (create with `caws specs
76
80
 
77
81
  Always stay within scope boundaries and change budgets.
78
82
 
83
+ Recommended operating rule: one active feature spec, one active worktree. If a task has a worktree, record that ownership in the spec YAML with `worktree: <name>`.
84
+
85
+ ### Scope and Worktree Binding
86
+
87
+ The scope guard enforces file edit boundaries based on your spec's `scope.in` and `scope.out` patterns. **How it enforces depends on whether your worktree is bound to a spec:**
88
+
89
+ - **Authoritative mode** (worktree bound to a spec): Only your spec's scope patterns are checked. Other agents' specs cannot block your edits. This is the correct state.
90
+ - **Union mode** (no binding): The guard checks ALL active specs. Any `scope.out` from any spec can block you, even unrelated ones. This is the common source of "why is spec X blocking me?" confusion.
91
+
92
+ **The mutual binding** requires both sides:
93
+ 1. The worktree registry (`.caws/worktrees.json`) must have `specId` pointing to your spec
94
+ 2. Your spec (`.caws/specs/<id>.yaml`) must have `worktree: <name>` pointing to your worktree
95
+
96
+ If either side is missing, the guard falls back to union mode.
97
+
98
+ **Quick commands:**
99
+ ```bash
100
+ # See your effective scope and binding health
101
+ caws scope show
102
+
103
+ # Fix a broken binding
104
+ caws worktree bind <spec-id>
105
+ ```
106
+
107
+ **Recovery checklist** (when the scope guard blocks you unexpectedly):
108
+ 1. Run `caws scope show` — check if you're in authoritative or union mode
109
+ 2. If union mode: bind your spec with `caws worktree bind <spec-id>`
110
+ 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
+ 4. Do NOT modify another spec's `scope.out` to unblock yourself — that defeats the isolation
112
+
79
113
  > **Budget note**: `change_budget:` in a spec is informational documentation only. CAWS
80
114
  > derives the enforced budget from `policy.yaml` keyed on `risk_tier`. The field in the
81
115
  > spec is not used by `caws validate` for enforcement.
@@ -38,6 +38,27 @@ caws specs create my-feature --type feature --title "My Feature"
38
38
  caws validate --spec-id my-feature
39
39
  ```
40
40
 
41
+ ## Scope and Worktree Binding
42
+
43
+ The scope guard enforces `scope.in` and `scope.out` from your spec. How it enforces depends on binding:
44
+
45
+ - **Authoritative mode** (worktree bound to a spec): Only your spec's scope is checked. Other agents' specs cannot block you.
46
+ - **Union mode** (no binding): ALL active specs are checked. Any `scope.out` from any spec can block you.
47
+
48
+ ```bash
49
+ # See your effective scope and binding health
50
+ caws scope show
51
+
52
+ # Fix a broken binding
53
+ caws worktree bind <spec-id>
54
+ ```
55
+
56
+ **Recovery** (when blocked unexpectedly):
57
+ 1. Run `caws scope show` to check mode and binding health
58
+ 2. If union mode: `caws worktree bind <spec-id>`
59
+ 3. If authoritative but blocked: update your spec's `scope.in`
60
+ 4. Do NOT edit another spec's `scope.out` to unblock yourself
61
+
41
62
  ## Key Rules
42
63
 
43
64
  1. **Stay in scope** -- only edit files listed in `scope.in`, never touch `scope.out`