@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.
- package/README.md +13 -5
- package/dist/budget-derivation.js +221 -74
- package/dist/commands/evaluate.js +26 -12
- package/dist/commands/gates.js +31 -4
- package/dist/commands/init.js +7 -4
- package/dist/commands/iterate.js +7 -3
- package/dist/commands/scope.js +264 -0
- package/dist/commands/sidecar.js +6 -3
- package/dist/commands/specs.js +148 -1
- package/dist/commands/status.js +8 -4
- package/dist/commands/templates.js +0 -8
- package/dist/commands/validate.js +34 -13
- package/dist/commands/verify-acs.js +25 -10
- package/dist/commands/waivers.js +147 -5
- package/dist/commands/worktree.js +81 -1
- package/dist/gates/budget-limit.js +6 -1
- package/dist/gates/spec-completeness.js +8 -1
- package/dist/index.js +27 -0
- package/dist/policy/PolicyManager.js +9 -7
- package/dist/session/session-manager.js +34 -0
- package/dist/templates/.caws/schemas/policy.schema.json +96 -34
- package/dist/templates/.caws/schemas/scope.schema.json +3 -3
- package/dist/templates/.caws/schemas/waivers.schema.json +91 -21
- package/dist/templates/.caws/schemas/working-spec.schema.json +253 -89
- package/dist/templates/.caws/templates/working-spec.template.yml +3 -1
- package/dist/templates/.caws/tools/scope-guard.js +66 -15
- package/dist/templates/.claude/README.md +1 -1
- package/dist/templates/.claude/hooks/protected-paths.sh +39 -0
- package/dist/templates/.claude/hooks/scope-guard.sh +106 -27
- package/dist/templates/.claude/hooks/worktree-write-guard.sh +96 -3
- package/dist/templates/.claude/settings.json +5 -0
- package/dist/templates/CLAUDE.md +34 -0
- package/dist/templates/agents.md +21 -0
- package/dist/utils/event-log.js +584 -0
- package/dist/utils/event-renderer.js +521 -0
- package/dist/utils/schema-validator.js +10 -2
- package/dist/utils/working-state.js +25 -0
- package/dist/validation/spec-validation.js +99 -9
- package/dist/waivers-manager.js +84 -0
- package/dist/worktree/worktree-manager.js +214 -8
- package/package.json +5 -4
- package/templates/.caws/schemas/policy.schema.json +96 -34
- package/templates/.caws/schemas/scope.schema.json +3 -3
- package/templates/.caws/schemas/waivers.schema.json +91 -21
- package/templates/.caws/schemas/working-spec.schema.json +253 -89
- package/templates/.caws/templates/working-spec.template.yml +3 -1
- package/templates/.caws/tools/scope-guard.js +66 -15
- package/templates/.claude/README.md +1 -1
- package/templates/.claude/hooks/protected-paths.sh +39 -0
- package/templates/.claude/hooks/scope-guard.sh +106 -27
- package/templates/.claude/hooks/worktree-write-guard.sh +96 -3
- package/templates/.claude/settings.json +5 -0
- package/templates/CLAUDE.md +34 -0
- package/templates/agents.md +21 -0
|
@@ -70,26 +70,71 @@ function checkFileScope(filePath, projectDir) {
|
|
|
70
70
|
return { inScope: true, reason: 'js-yaml not available' };
|
|
71
71
|
}
|
|
72
72
|
|
|
73
|
-
|
|
73
|
+
// --- Authoritative spec detection ---
|
|
74
|
+
// If inside a worktree with a mutual spec binding, only check that spec.
|
|
75
|
+
let authoritativeSpec = null;
|
|
76
|
+
let mode = 'union';
|
|
74
77
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
78
|
+
const registryPath = path.join(projectDir, '.caws', 'worktrees.json');
|
|
79
|
+
const worktreesBase = path.join(projectDir, '.caws', 'worktrees');
|
|
80
|
+
const cwd = process.cwd();
|
|
81
|
+
|
|
82
|
+
if (cwd.startsWith(worktreesBase + path.sep)) {
|
|
83
|
+
const relative = path.relative(worktreesBase, cwd);
|
|
84
|
+
const worktreeName = relative.split(path.sep)[0];
|
|
85
|
+
|
|
86
|
+
if (worktreeName && fs.existsSync(registryPath)) {
|
|
87
|
+
try {
|
|
88
|
+
const reg = JSON.parse(fs.readFileSync(registryPath, 'utf8'));
|
|
89
|
+
const entry = reg.worktrees && reg.worktrees[worktreeName];
|
|
90
|
+
|
|
91
|
+
if (entry && entry.specId) {
|
|
92
|
+
const specCandidates = [
|
|
93
|
+
path.join(specsDir, entry.specId + '.yaml'),
|
|
94
|
+
path.join(specsDir, entry.specId + '.yml'),
|
|
95
|
+
];
|
|
96
|
+
for (const candidate of specCandidates) {
|
|
97
|
+
if (fs.existsSync(candidate)) {
|
|
98
|
+
try {
|
|
99
|
+
const s = yaml.load(fs.readFileSync(candidate, 'utf8'));
|
|
100
|
+
if (s && !TERMINAL.has(s.status) && s.worktree === worktreeName) {
|
|
101
|
+
authoritativeSpec = { source: path.basename(candidate), spec: s };
|
|
102
|
+
mode = 'authoritative';
|
|
103
|
+
}
|
|
104
|
+
} catch (_) {}
|
|
105
|
+
break;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
} catch (_) {}
|
|
110
|
+
}
|
|
82
111
|
}
|
|
83
112
|
|
|
84
|
-
|
|
85
|
-
|
|
113
|
+
// --- Collect specs based on mode ---
|
|
114
|
+
const specs = [];
|
|
115
|
+
|
|
116
|
+
if (authoritativeSpec) {
|
|
117
|
+
specs.push(authoritativeSpec);
|
|
118
|
+
} else {
|
|
119
|
+
if (fs.existsSync(specFile)) {
|
|
86
120
|
try {
|
|
87
|
-
const s = yaml.load(fs.readFileSync(
|
|
121
|
+
const s = yaml.load(fs.readFileSync(specFile, 'utf8'));
|
|
88
122
|
if (s && !TERMINAL.has(s.status)) {
|
|
89
|
-
specs.push({ source:
|
|
123
|
+
specs.push({ source: 'working-spec', spec: s });
|
|
90
124
|
}
|
|
91
125
|
} catch (_) {}
|
|
92
126
|
}
|
|
127
|
+
|
|
128
|
+
if (fs.existsSync(specsDir)) {
|
|
129
|
+
for (const f of fs.readdirSync(specsDir).filter(f => f.endsWith('.yaml') || f.endsWith('.yml'))) {
|
|
130
|
+
try {
|
|
131
|
+
const s = yaml.load(fs.readFileSync(path.join(specsDir, f), 'utf8'));
|
|
132
|
+
if (s && !TERMINAL.has(s.status)) {
|
|
133
|
+
specs.push({ source: f, spec: s });
|
|
134
|
+
}
|
|
135
|
+
} catch (_) {}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
93
138
|
}
|
|
94
139
|
|
|
95
140
|
if (specs.length === 0) {
|
|
@@ -100,17 +145,23 @@ function checkFileScope(filePath, projectDir) {
|
|
|
100
145
|
for (const { source, spec } of specs) {
|
|
101
146
|
for (const pattern of (spec.scope?.out || [])) {
|
|
102
147
|
if (globToRegex(pattern).test(filePath)) {
|
|
103
|
-
|
|
148
|
+
const modeHint = mode === 'union'
|
|
149
|
+
? '. No authoritative spec bound — checking all active specs. Fix: caws worktree bind <spec-id>'
|
|
150
|
+
: '';
|
|
151
|
+
return { inScope: false, reason: `out-of-scope in ${source} (pattern: ${pattern})${modeHint}` };
|
|
104
152
|
}
|
|
105
153
|
}
|
|
106
154
|
}
|
|
107
155
|
|
|
108
|
-
//
|
|
156
|
+
// scope.in — must match at least one
|
|
109
157
|
const allIn = specs.flatMap(({ spec }) => spec.scope?.in || []);
|
|
110
158
|
if (allIn.length > 0) {
|
|
111
159
|
const found = allIn.some(pattern => globToRegex(pattern).test(filePath));
|
|
112
160
|
if (!found) {
|
|
113
|
-
|
|
161
|
+
const modeHint = mode === 'union'
|
|
162
|
+
? '. No authoritative spec bound — checking all active specs. Fix: caws worktree bind <spec-id>'
|
|
163
|
+
: '';
|
|
164
|
+
return { inScope: false, reason: `not in any active spec scope.in${modeHint}` };
|
|
114
165
|
}
|
|
115
166
|
}
|
|
116
167
|
|
|
@@ -38,7 +38,7 @@ Run before Claude executes a tool:
|
|
|
38
38
|
|------|---------|---------|
|
|
39
39
|
| `block-dangerous.sh` | `Bash` | Block destructive shell commands |
|
|
40
40
|
| `scan-secrets.sh` | `Read` | Warn when reading sensitive files |
|
|
41
|
-
| `scope-guard.sh` | `Write\|Edit` | Check scope boundaries before edits |
|
|
41
|
+
| `scope-guard.sh` | `Write\|Edit` | Check scope boundaries before edits (use `caws scope show` to diagnose blocks) |
|
|
42
42
|
|
|
43
43
|
### PostToolUse Hooks
|
|
44
44
|
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# CAWS Protected Paths Guard for Claude Code
|
|
3
|
+
# Blocks direct Write/Edit access to guard code and guard state.
|
|
4
|
+
# @author @darianrosebrook
|
|
5
|
+
|
|
6
|
+
set -euo pipefail
|
|
7
|
+
|
|
8
|
+
INPUT=$(cat)
|
|
9
|
+
|
|
10
|
+
TOOL_NAME=$(echo "$INPUT" | jq -r '.tool_name // ""')
|
|
11
|
+
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // ""')
|
|
12
|
+
|
|
13
|
+
case "$TOOL_NAME" in
|
|
14
|
+
Write|Edit) ;;
|
|
15
|
+
*) exit 0 ;;
|
|
16
|
+
esac
|
|
17
|
+
|
|
18
|
+
if [[ -z "$FILE_PATH" ]]; then
|
|
19
|
+
exit 0
|
|
20
|
+
fi
|
|
21
|
+
|
|
22
|
+
# If you are reading this because a write was blocked, do not edit hook files or
|
|
23
|
+
# strike-state files to bypass a guard. Switch into the correct worktree, fix the
|
|
24
|
+
# active spec scope, or ask the user if the guard itself is wrong.
|
|
25
|
+
case "$FILE_PATH" in
|
|
26
|
+
*/.claude/hooks/*)
|
|
27
|
+
echo "BLOCKED: $FILE_PATH is protected." >&2
|
|
28
|
+
echo "Ask the user for permission before editing Claude hook scripts." >&2
|
|
29
|
+
exit 1
|
|
30
|
+
;;
|
|
31
|
+
*/.claude/logs/guard-strikes-*.json)
|
|
32
|
+
echo "BLOCKED: $FILE_PATH is protected guard state." >&2
|
|
33
|
+
echo "Do not reset or edit strike counters to bypass enforcement." >&2
|
|
34
|
+
echo "Switch into the correct worktree, update the active CAWS spec scope, or ask the user for direction instead." >&2
|
|
35
|
+
exit 2
|
|
36
|
+
;;
|
|
37
|
+
esac
|
|
38
|
+
|
|
39
|
+
exit 0
|
|
@@ -204,31 +204,83 @@ if command -v node >/dev/null 2>&1; then
|
|
|
204
204
|
process.exit(0);
|
|
205
205
|
}
|
|
206
206
|
|
|
207
|
-
|
|
208
|
-
const specs = [];
|
|
207
|
+
const projectDir = '$PROJECT_DIR';
|
|
209
208
|
|
|
210
|
-
//
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
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
|
-
//
|
|
222
|
-
const
|
|
223
|
-
|
|
224
|
-
|
|
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(
|
|
266
|
+
const s = yaml.load(fs.readFileSync(mainSpec, 'utf8'));
|
|
227
267
|
if (s && !TERMINAL.has(s.status)) {
|
|
228
|
-
specs.push({ source:
|
|
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
|
|
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
|
-
//
|
|
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
|
-
|
|
286
|
-
|
|
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
|
-
|
|
289
|
-
|
|
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 "
|
|
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
|
-
#
|
|
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
|
-
|
|
73
|
-
|
|
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",
|
package/dist/templates/CLAUDE.md
CHANGED
|
@@ -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.
|
package/dist/templates/agents.md
CHANGED
|
@@ -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`
|