@paths.design/caws-cli 11.1.0 → 11.1.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.
- package/package.json +6 -3
- package/templates/hook-packs/claude-code/CLAUDE.md +172 -0
- package/templates/hook-packs/claude-code/audit.sh +121 -0
- package/templates/hook-packs/claude-code/block-dangerous.sh +158 -0
- package/templates/hook-packs/claude-code/classify_command.py +1064 -0
- package/templates/hook-packs/claude-code/dispatch/post_tool_use.sh +63 -0
- package/templates/hook-packs/claude-code/dispatch/pre_tool_use.sh +50 -0
- package/templates/hook-packs/claude-code/dispatch/session_start.sh +41 -0
- package/templates/hook-packs/claude-code/dispatch/stop.sh +37 -0
- package/templates/hook-packs/claude-code/guard-strikes.sh +140 -0
- package/templates/hook-packs/claude-code/lib/parse-input.sh +127 -0
- package/templates/hook-packs/claude-code/lib/run-handlers.sh +212 -0
- package/templates/hook-packs/claude-code/reset-danger-latch.sh +21 -0
- package/templates/hook-packs/claude-code/reset-strikes.sh +243 -0
- package/templates/hook-packs/claude-code/runtime-paths.sh +80 -0
- package/templates/hook-packs/claude-code/scope-guard.sh +392 -0
- package/templates/hook-packs/claude-code/session-caws-status.sh +171 -0
- package/templates/hook-packs/claude-code/session-log.sh +180 -0
- package/templates/hook-packs/claude-code/worktree-guard.sh +240 -0
- package/templates/hook-packs/claude-code/worktree-write-guard.sh +77 -0
|
@@ -0,0 +1,392 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# CAWS-MANAGED-HOOK
|
|
3
|
+
# hook_pack: claude-code
|
|
4
|
+
# hook_pack_version: 2
|
|
5
|
+
# caws_min_major: 11
|
|
6
|
+
# lineage_refs: 8,11,12,16
|
|
7
|
+
# do_not_edit_directly: update via `caws init --agent-surface claude-code`
|
|
8
|
+
#
|
|
9
|
+
# CAWS Scope Guard Hook for Claude Code (v11-shape).
|
|
10
|
+
# Validates file edits against scope boundaries from per-feature specs under .caws/specs/.
|
|
11
|
+
#
|
|
12
|
+
# Lifecycle resolution (v11-shape, with v10 fallback):
|
|
13
|
+
# lifecycle_state first, status second.
|
|
14
|
+
# Terminal (not enforced): closed, archived, completed.
|
|
15
|
+
# active: participates in union enforcement.
|
|
16
|
+
# draft: does NOT participate in union-wide blocking unless authoritative/bound.
|
|
17
|
+
# Both fields missing: treat as active (legacy compatibility).
|
|
18
|
+
#
|
|
19
|
+
# Worktree registry shape compatibility:
|
|
20
|
+
# v11 direct-key: { "<name>": { ... } }
|
|
21
|
+
# v10 nested: { "worktrees": { "<name>": { ... } } }
|
|
22
|
+
# Bound id key: specId (v10) OR spec_id (v11) — both accepted.
|
|
23
|
+
|
|
24
|
+
set -euo pipefail
|
|
25
|
+
|
|
26
|
+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
27
|
+
# shellcheck source=lib/parse-input.sh
|
|
28
|
+
source "$SCRIPT_DIR/lib/parse-input.sh"
|
|
29
|
+
# shellcheck source=guard-strikes.sh
|
|
30
|
+
source "$SCRIPT_DIR/guard-strikes.sh"
|
|
31
|
+
parse_hook_input
|
|
32
|
+
|
|
33
|
+
# Back-compat aliases kept to minimize diff in the scope-resolution logic below.
|
|
34
|
+
FILE_PATH="$HOOK_FILE_PATH"
|
|
35
|
+
TOOL_NAME="$HOOK_TOOL_NAME"
|
|
36
|
+
SESSION_ID="$HOOK_SESSION_ID"
|
|
37
|
+
|
|
38
|
+
# Only check Write/Edit operations
|
|
39
|
+
if [[ "$TOOL_NAME" != "Write" ]] && [[ "$TOOL_NAME" != "Edit" ]]; then
|
|
40
|
+
exit 0
|
|
41
|
+
fi
|
|
42
|
+
|
|
43
|
+
if [[ -z "$FILE_PATH" ]]; then
|
|
44
|
+
exit 0
|
|
45
|
+
fi
|
|
46
|
+
|
|
47
|
+
emit_scope_progression() {
|
|
48
|
+
local detail="$1"
|
|
49
|
+
# Strike-level diagnostic triage: strike 1 fires often (any agent
|
|
50
|
+
# touching the edge of its lane) and the edit proceeds — keep the
|
|
51
|
+
# message short so it informs without burying. Strike 2 escalates to
|
|
52
|
+
# user-approval and adds the spec/binding-fix options. Strike 3 is the
|
|
53
|
+
# hard block and surfaces the full reset-strikes + binding guidance.
|
|
54
|
+
local fix_options="Fix options: (1) edit a file already in scope, (2) update the bound spec's scope.in if this path should be in scope, (3) ask the user."
|
|
55
|
+
local hard_block_guidance="If prior strikes from earlier edits are cornering this session and the scope is now correct, ask the user to run: bash .claude/hooks/reset-strikes.sh --current (or --session <uuid>) to clear stale strike state. Verify the worktree binding: the spec must declare 'worktree: <name>' and .caws/worktrees.json must map that same worktree name to the correct 'specId' (v10) or 'spec_id' (v11). On CAWS v11.0 the worktree lifecycle CLI is not yet restored; on v11.1+ use 'caws worktree bind'. Do not edit .claude/hooks/, .claude/logs/guard-strikes-*.json, or other guard state to bypass this check."
|
|
56
|
+
|
|
57
|
+
guard_enforce_progressive_strikes \
|
|
58
|
+
"$SESSION_ID" \
|
|
59
|
+
"scope_guard" \
|
|
60
|
+
"$WORK_DIR" \
|
|
61
|
+
"Scope guard strike 1 of 3 for '$REL_PATH'. This edit proceeds, but a second out-of-scope edit will require user approval. $detail" \
|
|
62
|
+
"Scope guard strike 2 of 3 for '$REL_PATH'. Blocked — asking the user for approval. $detail $fix_options" \
|
|
63
|
+
"Scope guard strike 3 of 3 for '$REL_PATH'. Hard-blocked until scope is corrected. $detail $fix_options $hard_block_guidance"
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
resolve_worktree_root() {
|
|
67
|
+
local candidate="${1:-}"
|
|
68
|
+
|
|
69
|
+
if [[ -n "$candidate" ]] && [[ "$candidate" =~ ^(.*\/\.caws\/worktrees\/[^/]+)($|/) ]]; then
|
|
70
|
+
printf '%s\n' "${BASH_REMATCH[1]}"
|
|
71
|
+
return 0
|
|
72
|
+
fi
|
|
73
|
+
|
|
74
|
+
return 1
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
# Always-allowed paths bypass scope checks entirely.
|
|
78
|
+
ALLOW_PREFIXES=(
|
|
79
|
+
"$HOME/.claude/"
|
|
80
|
+
".caws/"
|
|
81
|
+
".claude/"
|
|
82
|
+
"docs/"
|
|
83
|
+
"tests/"
|
|
84
|
+
"scripts/"
|
|
85
|
+
"tmp/"
|
|
86
|
+
".archive/"
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
# Policy-declared non-governed zones (CAWSFIX-26 / ledger D9).
|
|
90
|
+
POLICY_FILE="${CLAUDE_PROJECT_DIR:-.}/.caws/policy.yaml"
|
|
91
|
+
if [[ -f "$POLICY_FILE" ]]; then
|
|
92
|
+
while IFS= read -r raw_zone; do
|
|
93
|
+
[[ -z "$raw_zone" ]] && continue
|
|
94
|
+
raw_zone="${raw_zone%\"}"; raw_zone="${raw_zone#\"}"
|
|
95
|
+
raw_zone="${raw_zone%\'}"; raw_zone="${raw_zone#\'}"
|
|
96
|
+
raw_zone="${raw_zone%/\*\*}"
|
|
97
|
+
raw_zone="${raw_zone%/\*}"
|
|
98
|
+
[[ "$raw_zone" != */ ]] && raw_zone="${raw_zone}/"
|
|
99
|
+
ALLOW_PREFIXES+=("$raw_zone")
|
|
100
|
+
done < <(awk '
|
|
101
|
+
/^non_governed_zones:[[:space:]]*$/ { in_zones = 1; next }
|
|
102
|
+
/^[^[:space:]#-]/ && in_zones { in_zones = 0 }
|
|
103
|
+
in_zones && /^[[:space:]]+-[[:space:]]+/ {
|
|
104
|
+
sub(/^[[:space:]]+-[[:space:]]+/, "")
|
|
105
|
+
sub(/[[:space:]]+#.*$/, "")
|
|
106
|
+
print
|
|
107
|
+
}
|
|
108
|
+
' "$POLICY_FILE" 2>/dev/null)
|
|
109
|
+
fi
|
|
110
|
+
|
|
111
|
+
WORK_DIR="${HOOK_CWD:-${CLAUDE_PROJECT_DIR:-.}}"
|
|
112
|
+
PROJECT_DIR="${CLAUDE_PROJECT_DIR:-.}"
|
|
113
|
+
|
|
114
|
+
FILE_WORKTREE_ROOT="$(resolve_worktree_root "$FILE_PATH" || true)"
|
|
115
|
+
CWD_WORKTREE_ROOT="$(resolve_worktree_root "$HOOK_CWD" || true)"
|
|
116
|
+
PROJECT_WORKTREE_ROOT="$(resolve_worktree_root "$PROJECT_DIR" || true)"
|
|
117
|
+
|
|
118
|
+
if [[ -n "$FILE_WORKTREE_ROOT" ]]; then
|
|
119
|
+
WORK_DIR="$FILE_WORKTREE_ROOT"
|
|
120
|
+
elif [[ -n "$CWD_WORKTREE_ROOT" ]]; then
|
|
121
|
+
WORK_DIR="$CWD_WORKTREE_ROOT"
|
|
122
|
+
elif [[ -n "$PROJECT_WORKTREE_ROOT" ]]; then
|
|
123
|
+
WORK_DIR="$PROJECT_WORKTREE_ROOT"
|
|
124
|
+
fi
|
|
125
|
+
|
|
126
|
+
PROJECT_DIR="$(cd "$PROJECT_DIR" 2>/dev/null && pwd || printf '%s\n' "$PROJECT_DIR")"
|
|
127
|
+
WORK_DIR="$(cd "$WORK_DIR" 2>/dev/null && pwd || printf '%s\n' "$WORK_DIR")"
|
|
128
|
+
WORKTREE_NAME=""
|
|
129
|
+
if [[ "$WORK_DIR" =~ \/\.caws\/worktrees\/([^/]+)$ ]]; then
|
|
130
|
+
WORKTREE_NAME="${BASH_REMATCH[1]}"
|
|
131
|
+
fi
|
|
132
|
+
|
|
133
|
+
if [[ -d "$WORK_DIR/.caws/specs" ]]; then
|
|
134
|
+
SCOPE_FILE="$WORK_DIR/.caws/scope.json"
|
|
135
|
+
SPECS_BASE="$WORK_DIR"
|
|
136
|
+
else
|
|
137
|
+
SCOPE_FILE="$PROJECT_DIR/.caws/scope.json"
|
|
138
|
+
SPECS_BASE="$PROJECT_DIR"
|
|
139
|
+
fi
|
|
140
|
+
|
|
141
|
+
if [[ ! -f "$SCOPE_FILE" ]] && [[ ! -d "$SPECS_BASE/.caws/specs" ]]; then
|
|
142
|
+
exit 0
|
|
143
|
+
fi
|
|
144
|
+
|
|
145
|
+
if [[ "$FILE_PATH" == "$WORK_DIR"/* ]]; then
|
|
146
|
+
REL_PATH="${FILE_PATH#$WORK_DIR/}"
|
|
147
|
+
elif [[ "$FILE_PATH" == "$PROJECT_DIR"/* ]]; then
|
|
148
|
+
REL_PATH="${FILE_PATH#$PROJECT_DIR/}"
|
|
149
|
+
else
|
|
150
|
+
REL_PATH="$FILE_PATH"
|
|
151
|
+
fi
|
|
152
|
+
|
|
153
|
+
if [[ "$REL_PATH" != */* ]]; then
|
|
154
|
+
exit 0
|
|
155
|
+
fi
|
|
156
|
+
for prefix in "${ALLOW_PREFIXES[@]}"; do
|
|
157
|
+
if [[ "$FILE_PATH" == "${prefix}"* ]] || [[ "$REL_PATH" == "${prefix}"* ]]; then
|
|
158
|
+
exit 0
|
|
159
|
+
fi
|
|
160
|
+
done
|
|
161
|
+
|
|
162
|
+
# Lite mode: scope.json (no .caws/specs/)
|
|
163
|
+
if [[ ! -d "$SPECS_BASE/.caws/specs" ]] && [[ -f "$SCOPE_FILE" ]]; then
|
|
164
|
+
if command -v node >/dev/null 2>&1; then
|
|
165
|
+
LITE_CHECK=$(node -e "
|
|
166
|
+
var fs = require('fs');
|
|
167
|
+
var path = require('path');
|
|
168
|
+
try {
|
|
169
|
+
var scope = JSON.parse(fs.readFileSync('$SCOPE_FILE', 'utf8'));
|
|
170
|
+
var filePath = '$REL_PATH';
|
|
171
|
+
var dirs = scope.allowedDirectories || [];
|
|
172
|
+
var banned = scope.bannedPatterns || {};
|
|
173
|
+
|
|
174
|
+
var basename = path.basename(filePath);
|
|
175
|
+
var bannedFiles = banned.files || [];
|
|
176
|
+
for (var i = 0; i < bannedFiles.length; i++) {
|
|
177
|
+
var regex = new RegExp(bannedFiles[i].replace(/\\*/g, '.*').replace(/\\?/g, '.'));
|
|
178
|
+
if (regex.test(basename)) {
|
|
179
|
+
console.log('banned:' + bannedFiles[i]);
|
|
180
|
+
process.exit(0);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
var bannedDocs = banned.docs || [];
|
|
185
|
+
for (var i = 0; i < bannedDocs.length; i++) {
|
|
186
|
+
var regex = new RegExp(bannedDocs[i].replace(/\\*/g, '.*').replace(/\\?/g, '.'));
|
|
187
|
+
if (regex.test(basename)) {
|
|
188
|
+
console.log('banned:' + bannedDocs[i]);
|
|
189
|
+
process.exit(0);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
if (dirs.length > 0) {
|
|
194
|
+
var normalized = filePath.replace(/\\\\\\\\/g, '/');
|
|
195
|
+
var found = false;
|
|
196
|
+
for (var i = 0; i < dirs.length; i++) {
|
|
197
|
+
var d = dirs[i].replace(/\\/$/, '');
|
|
198
|
+
if (normalized.startsWith(d + '/') || normalized === d) { found = true; break; }
|
|
199
|
+
}
|
|
200
|
+
if (!found) {
|
|
201
|
+
console.log('not_allowed');
|
|
202
|
+
process.exit(0);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
console.log('allowed');
|
|
206
|
+
} catch (error) {
|
|
207
|
+
console.log('error:' + error.message);
|
|
208
|
+
}
|
|
209
|
+
" 2>&1)
|
|
210
|
+
|
|
211
|
+
if [[ "$LITE_CHECK" == banned:* ]]; then
|
|
212
|
+
PATTERN="${LITE_CHECK#banned:}"
|
|
213
|
+
emit_scope_progression "This file matches banned pattern '$PATTERN' in .caws/scope.json."
|
|
214
|
+
exit 0
|
|
215
|
+
fi
|
|
216
|
+
|
|
217
|
+
if [[ "$LITE_CHECK" == "not_allowed" ]]; then
|
|
218
|
+
emit_scope_progression "This file is outside the allowed directories in .caws/scope.json."
|
|
219
|
+
exit 0
|
|
220
|
+
fi
|
|
221
|
+
|
|
222
|
+
exit 0
|
|
223
|
+
fi
|
|
224
|
+
fi
|
|
225
|
+
|
|
226
|
+
# Full mode: per-feature specs under .caws/specs/ (v11-shape aware)
|
|
227
|
+
SPECS_DIR="$SPECS_BASE/.caws/specs"
|
|
228
|
+
|
|
229
|
+
if command -v node >/dev/null 2>&1; then
|
|
230
|
+
SCOPE_CHECK=$(node -e "
|
|
231
|
+
var yaml = require('js-yaml');
|
|
232
|
+
var fs = require('fs');
|
|
233
|
+
var path = require('path');
|
|
234
|
+
|
|
235
|
+
try {
|
|
236
|
+
var filePath = '$REL_PATH';
|
|
237
|
+
var projectDir = '$PROJECT_DIR';
|
|
238
|
+
var worktreeName = '$WORKTREE_NAME';
|
|
239
|
+
|
|
240
|
+
// v11-shape lifecycle resolution.
|
|
241
|
+
// Read lifecycle_state first, fall back to status, then 'active'.
|
|
242
|
+
function lifecycleOf(s) {
|
|
243
|
+
return (s && (s.lifecycle_state || s.status)) || 'active';
|
|
244
|
+
}
|
|
245
|
+
// Terminal: not enforced at all.
|
|
246
|
+
var TERMINAL = { closed: 1, archived: 1, completed: 1 };
|
|
247
|
+
// Draft: does not participate in union-wide blocking. Only enforces
|
|
248
|
+
// scope when it is the authoritative/bound spec.
|
|
249
|
+
function isDraft(state) { return state === 'draft'; }
|
|
250
|
+
|
|
251
|
+
// Collect all non-terminal per-feature specs under .caws/specs/.
|
|
252
|
+
// Draft specs are collected but separately tagged.
|
|
253
|
+
var specs = [];
|
|
254
|
+
|
|
255
|
+
var specsDir = '$SPECS_DIR';
|
|
256
|
+
if (fs.existsSync(specsDir)) {
|
|
257
|
+
var files = fs.readdirSync(specsDir).filter(function(f) { return f.endsWith('.yaml') || f.endsWith('.yml'); });
|
|
258
|
+
for (var fi = 0; fi < files.length; fi++) {
|
|
259
|
+
try {
|
|
260
|
+
var s = yaml.load(fs.readFileSync(path.join(specsDir, files[fi]), 'utf8'));
|
|
261
|
+
if (!s) continue;
|
|
262
|
+
var state = lifecycleOf(s);
|
|
263
|
+
if (TERMINAL[state]) continue;
|
|
264
|
+
specs.push({ source: files[fi], spec: s, state: state });
|
|
265
|
+
} catch (_) {}
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
if (specs.length === 0) {
|
|
270
|
+
console.log('in_scope');
|
|
271
|
+
process.exit(0);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// Authoritative binding lookup (v10 + v11 registry shape compat).
|
|
275
|
+
function worktreeEntry(registry, name) {
|
|
276
|
+
if (!registry) return null;
|
|
277
|
+
if (registry.worktrees && registry.worktrees[name]) return registry.worktrees[name];
|
|
278
|
+
if (registry[name] && typeof registry[name] === 'object') return registry[name];
|
|
279
|
+
return null;
|
|
280
|
+
}
|
|
281
|
+
function boundSpecIdOf(entry) {
|
|
282
|
+
if (!entry) return null;
|
|
283
|
+
return entry.specId || entry.spec_id || null;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
var authoritativeSpec = null;
|
|
287
|
+
if (worktreeName) {
|
|
288
|
+
try {
|
|
289
|
+
var registryPath = path.join(projectDir, '.caws', 'worktrees.json');
|
|
290
|
+
if (fs.existsSync(registryPath)) {
|
|
291
|
+
var registry = JSON.parse(fs.readFileSync(registryPath, 'utf8'));
|
|
292
|
+
var entry = worktreeEntry(registry, worktreeName);
|
|
293
|
+
var boundId = boundSpecIdOf(entry);
|
|
294
|
+
if (boundId) {
|
|
295
|
+
for (var si = 0; si < specs.length; si++) {
|
|
296
|
+
var candidate = specs[si].spec || {};
|
|
297
|
+
if (candidate.id === boundId && candidate.worktree === worktreeName) {
|
|
298
|
+
authoritativeSpec = specs[si];
|
|
299
|
+
break;
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
} catch (_) {}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
var mode = authoritativeSpec ? 'authoritative' : 'union';
|
|
308
|
+
var specsToCheck;
|
|
309
|
+
if (authoritativeSpec) {
|
|
310
|
+
specsToCheck = [authoritativeSpec];
|
|
311
|
+
} else {
|
|
312
|
+
// Union mode: drafts do NOT participate. Only active specs.
|
|
313
|
+
specsToCheck = specs.filter(function(s) { return !isDraft(s.state); });
|
|
314
|
+
if (specsToCheck.length === 0) {
|
|
315
|
+
// Only drafts present, none authoritative — allow.
|
|
316
|
+
console.log('in_scope');
|
|
317
|
+
process.exit(0);
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// Check scope.out across applicable specs — any match blocks
|
|
322
|
+
for (var si = 0; si < specsToCheck.length; si++) {
|
|
323
|
+
var outPatterns = (specsToCheck[si].spec.scope && specsToCheck[si].spec.scope.out) || [];
|
|
324
|
+
for (var pi = 0; pi < outPatterns.length; pi++) {
|
|
325
|
+
var regex = new RegExp(outPatterns[pi].replace(/\\*/g, '.*').replace(/\\?/g, '.'));
|
|
326
|
+
if (regex.test(filePath)) {
|
|
327
|
+
console.log('out_of_scope:' + mode + ':' + specsToCheck[si].source + ':' + outPatterns[pi]);
|
|
328
|
+
process.exit(0);
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// Union all scope.in patterns — file must match at least one
|
|
334
|
+
var allInScope = [];
|
|
335
|
+
for (var si = 0; si < specsToCheck.length; si++) {
|
|
336
|
+
var inPatterns = (specsToCheck[si].spec.scope && specsToCheck[si].spec.scope.in) || [];
|
|
337
|
+
for (var pi = 0; pi < inPatterns.length; pi++) {
|
|
338
|
+
allInScope.push(inPatterns[pi]);
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
if (allInScope.length > 0) {
|
|
342
|
+
var found = false;
|
|
343
|
+
for (var pi = 0; pi < allInScope.length; pi++) {
|
|
344
|
+
var regex = new RegExp(allInScope[pi].replace(/\\*/g, '.*').replace(/\\?/g, '.'));
|
|
345
|
+
if (regex.test(filePath)) {
|
|
346
|
+
found = true;
|
|
347
|
+
break;
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
if (!found) {
|
|
351
|
+
console.log('not_in_scope:' + mode);
|
|
352
|
+
process.exit(0);
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
console.log('in_scope');
|
|
357
|
+
} catch (error) {
|
|
358
|
+
console.log('error:' + error.message);
|
|
359
|
+
}
|
|
360
|
+
" 2>&1)
|
|
361
|
+
|
|
362
|
+
if [[ "$SCOPE_CHECK" == out_of_scope:* ]]; then
|
|
363
|
+
DETAIL="${SCOPE_CHECK#out_of_scope:}"
|
|
364
|
+
MODE="${DETAIL%%:*}"
|
|
365
|
+
REST="${DETAIL#*:}"
|
|
366
|
+
SOURCE="${REST%%:*}"
|
|
367
|
+
PATTERN="${REST#*:}"
|
|
368
|
+
if [[ "$MODE" == "union" ]]; then
|
|
369
|
+
emit_scope_progression "This file is marked out-of-scope in '$SOURCE' by pattern '$PATTERN'. Mode: union (no authoritative spec bound). An unrelated spec may be blocking this edit. Diagnose: caws scope show."
|
|
370
|
+
else
|
|
371
|
+
emit_scope_progression "This file is marked out-of-scope in '$SOURCE' by pattern '$PATTERN'. Mode: authoritative (checking only your bound spec)."
|
|
372
|
+
fi
|
|
373
|
+
exit 0
|
|
374
|
+
fi
|
|
375
|
+
|
|
376
|
+
if [[ "$SCOPE_CHECK" == not_in_scope:* ]]; then
|
|
377
|
+
MODE="${SCOPE_CHECK#not_in_scope:}"
|
|
378
|
+
if [[ "$MODE" == "union" ]]; then
|
|
379
|
+
emit_scope_progression "This file is not in the defined scope of any active spec. Mode: union (no authoritative spec bound). Diagnose: caws scope show."
|
|
380
|
+
else
|
|
381
|
+
emit_scope_progression "This file is not in the defined scope of your bound spec. Mode: authoritative. Update your spec's scope.in if this file should be in scope."
|
|
382
|
+
fi
|
|
383
|
+
exit 0
|
|
384
|
+
fi
|
|
385
|
+
|
|
386
|
+
if [[ "$SCOPE_CHECK" == "not_in_scope" ]]; then
|
|
387
|
+
emit_scope_progression "This file is not in the defined scope of any active spec. Diagnose: caws scope show."
|
|
388
|
+
exit 0
|
|
389
|
+
fi
|
|
390
|
+
fi
|
|
391
|
+
|
|
392
|
+
exit 0
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# CAWS-MANAGED-HOOK
|
|
3
|
+
# hook_pack: claude-code
|
|
4
|
+
# hook_pack_version: 2
|
|
5
|
+
# caws_min_major: 11
|
|
6
|
+
# lineage_refs: 4,11
|
|
7
|
+
# do_not_edit_directly: update via `caws init --agent-surface claude-code`
|
|
8
|
+
#
|
|
9
|
+
# CAWS Session Status Hook for Claude Code (v11-shape).
|
|
10
|
+
# Fires on session-start. Surfaces:
|
|
11
|
+
# - active-worktree warning (dual-shape registry compatible)
|
|
12
|
+
# - global vs repo CAWS version skew warning
|
|
13
|
+
# - caws status briefing (v11-shape)
|
|
14
|
+
# Never blocks; emits to stdout for the agent's session start.
|
|
15
|
+
|
|
16
|
+
set -euo pipefail
|
|
17
|
+
|
|
18
|
+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
19
|
+
# shellcheck source=lib/parse-input.sh
|
|
20
|
+
source "$SCRIPT_DIR/lib/parse-input.sh"
|
|
21
|
+
# Hook does not read stdin fields -- dispatches on a positional arg.
|
|
22
|
+
# Sourcing parse-input.sh still wires up PATH (nvm/homebrew) for CAWS CLI.
|
|
23
|
+
|
|
24
|
+
EVENT_TYPE="${1:-}"
|
|
25
|
+
if [ "$EVENT_TYPE" != "session-start" ]; then
|
|
26
|
+
exit 0
|
|
27
|
+
fi
|
|
28
|
+
|
|
29
|
+
if ! command -v caws &>/dev/null; then
|
|
30
|
+
echo "CAWS CLI not found. Install with: npm install -g @paths.design/caws-cli"
|
|
31
|
+
exit 0
|
|
32
|
+
fi
|
|
33
|
+
|
|
34
|
+
if [ ! -d "${CLAUDE_PROJECT_DIR:-.}/.caws" ]; then
|
|
35
|
+
exit 0
|
|
36
|
+
fi
|
|
37
|
+
|
|
38
|
+
cd "${CLAUDE_PROJECT_DIR:-.}"
|
|
39
|
+
|
|
40
|
+
CAWS_ROOT="."
|
|
41
|
+
if command -v git >/dev/null 2>&1; then
|
|
42
|
+
_GIT_COMMON=$(git rev-parse --git-common-dir 2>/dev/null || echo ".git")
|
|
43
|
+
if [ "$_GIT_COMMON" != ".git" ]; then
|
|
44
|
+
_CANDIDATE=$(cd "$_GIT_COMMON/.." 2>/dev/null && pwd || echo "")
|
|
45
|
+
if [ -n "$_CANDIDATE" ] && [ -d "$_CANDIDATE/.caws" ]; then
|
|
46
|
+
CAWS_ROOT="$_CANDIDATE"
|
|
47
|
+
fi
|
|
48
|
+
fi
|
|
49
|
+
fi
|
|
50
|
+
|
|
51
|
+
# --- Active-worktree warning (dual-shape registry compatible) ---
|
|
52
|
+
CURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo "unknown")
|
|
53
|
+
|
|
54
|
+
if [ -f "$CAWS_ROOT/.caws/worktrees.json" ] && command -v node >/dev/null 2>&1; then
|
|
55
|
+
WT_INFO=$(node -e "
|
|
56
|
+
try {
|
|
57
|
+
var reg = JSON.parse(require('fs').readFileSync('$CAWS_ROOT/.caws/worktrees.json', 'utf8'));
|
|
58
|
+
function entriesOf(r) {
|
|
59
|
+
if (!r || typeof r !== 'object') return [];
|
|
60
|
+
if (r.worktrees && typeof r.worktrees === 'object') return Object.values(r.worktrees);
|
|
61
|
+
// v11 direct-key: filter to objects with a 'status' field.
|
|
62
|
+
var out = [];
|
|
63
|
+
for (var k in r) {
|
|
64
|
+
if (Object.prototype.hasOwnProperty.call(r, k)) {
|
|
65
|
+
var v = r[k];
|
|
66
|
+
if (v && typeof v === 'object' && typeof v.status === 'string') {
|
|
67
|
+
// For v11 direct-key, the worktree name is the outer key,
|
|
68
|
+
// not entry.name. Synthesize name from key when absent.
|
|
69
|
+
if (!v.name) v = Object.assign({}, v, { name: k });
|
|
70
|
+
out.push(v);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return out;
|
|
75
|
+
}
|
|
76
|
+
var entries = entriesOf(reg);
|
|
77
|
+
var active = entries.filter(function(w) { return w.status === 'active'; });
|
|
78
|
+
if (active.length > 0) {
|
|
79
|
+
var names = active.map(function(w) { return (w.name || '<unknown>') + ' (' + (w.branch || '?') + ')'; });
|
|
80
|
+
var bases = active.map(function(w) { return w.baseBranch || ''; }).filter(function(v,i,a) { return v && a.indexOf(v) === i; });
|
|
81
|
+
console.log(active.length + ':' + names.join(', ') + ':' + bases.join(','));
|
|
82
|
+
} else {
|
|
83
|
+
console.log('0::');
|
|
84
|
+
}
|
|
85
|
+
} catch(e) { console.log('0::'); }
|
|
86
|
+
" 2>/dev/null || echo "0::")
|
|
87
|
+
|
|
88
|
+
WT_COUNT=$(echo "$WT_INFO" | cut -d: -f1)
|
|
89
|
+
WT_NAMES=$(echo "$WT_INFO" | cut -d: -f2)
|
|
90
|
+
WT_BASES=$(echo "$WT_INFO" | cut -d: -f3)
|
|
91
|
+
|
|
92
|
+
if [ "$WT_COUNT" -gt 0 ] 2>/dev/null; then
|
|
93
|
+
BASE_BRANCH=$(echo "$WT_BASES" | cut -d',' -f1)
|
|
94
|
+
|
|
95
|
+
echo ""
|
|
96
|
+
echo "================================================================"
|
|
97
|
+
echo " ACTIVE WORKTREES DETECTED: $WT_COUNT worktree(s)"
|
|
98
|
+
echo " $WT_NAMES"
|
|
99
|
+
echo "================================================================"
|
|
100
|
+
|
|
101
|
+
if [ -n "$BASE_BRANCH" ] && [ "$CURRENT_BRANCH" = "$BASE_BRANCH" ]; then
|
|
102
|
+
echo ""
|
|
103
|
+
echo " Worktrees are preferred for isolated feature work, but direct"
|
|
104
|
+
echo " checkpoint edits on $CURRENT_BRANCH are allowed."
|
|
105
|
+
echo ""
|
|
106
|
+
echo " If a worktree was created for your task:"
|
|
107
|
+
echo " cd $CAWS_ROOT/.caws/worktrees/<name>/"
|
|
108
|
+
echo ""
|
|
109
|
+
echo " Worktree lifecycle commands (create/destroy/merge) return in"
|
|
110
|
+
echo " CAWS v11.1+; if you are on v11.0 they are not yet available."
|
|
111
|
+
echo ""
|
|
112
|
+
else
|
|
113
|
+
echo ""
|
|
114
|
+
echo " You are on branch '$CURRENT_BRANCH' (worktree). Good."
|
|
115
|
+
echo " Other active worktrees: $WT_NAMES"
|
|
116
|
+
fi
|
|
117
|
+
echo "================================================================"
|
|
118
|
+
echo ""
|
|
119
|
+
fi
|
|
120
|
+
fi
|
|
121
|
+
|
|
122
|
+
# --- Version-skew warning (advisory, never blocks) ---
|
|
123
|
+
#
|
|
124
|
+
# Hooks parse local CAWS state directly. The global `caws` binary may be a
|
|
125
|
+
# different major version than the repo's caws-cli — for example, an
|
|
126
|
+
# operator has the v10 binary globally installed while editing a v11 repo,
|
|
127
|
+
# or vice versa during transitions. Diagnostics from a mismatched binary
|
|
128
|
+
# can recommend commands that do not exist in the target version.
|
|
129
|
+
if command -v caws >/dev/null 2>&1 && command -v node >/dev/null 2>&1; then
|
|
130
|
+
GLOBAL_VER="$(caws --version 2>/dev/null | head -1 | tr -d '[:space:]' || echo '')"
|
|
131
|
+
GLOBAL_MAJOR="${GLOBAL_VER%%.*}"
|
|
132
|
+
REPO_PKG_JSON=""
|
|
133
|
+
for cand in \
|
|
134
|
+
"$CAWS_ROOT/packages/caws-cli/package.json" \
|
|
135
|
+
"$CAWS_ROOT/node_modules/@paths.design/caws-cli/package.json"; do
|
|
136
|
+
if [ -f "$cand" ]; then REPO_PKG_JSON="$cand"; break; fi
|
|
137
|
+
done
|
|
138
|
+
if [ -n "$REPO_PKG_JSON" ] && [ -n "$GLOBAL_MAJOR" ]; then
|
|
139
|
+
REPO_VER="$(node -e "
|
|
140
|
+
try { console.log((require('$REPO_PKG_JSON').version || '').trim()); }
|
|
141
|
+
catch(e) { console.log(''); }
|
|
142
|
+
" 2>/dev/null || echo '')"
|
|
143
|
+
REPO_MAJOR="${REPO_VER%%.*}"
|
|
144
|
+
if [ -n "$REPO_MAJOR" ] && [ "$REPO_MAJOR" != "$GLOBAL_MAJOR" ]; then
|
|
145
|
+
echo ""
|
|
146
|
+
echo "WARNING: global caws major version ($GLOBAL_MAJOR) differs from repo caws-cli major version ($REPO_MAJOR)."
|
|
147
|
+
echo "Hooks parse local state directly, but any CLI advice in diagnostics may be invalid."
|
|
148
|
+
echo "Consider: npm install -g @paths.design/caws-cli@^$REPO_MAJOR"
|
|
149
|
+
echo ""
|
|
150
|
+
fi
|
|
151
|
+
fi
|
|
152
|
+
fi
|
|
153
|
+
|
|
154
|
+
# --- CAWS status briefing (v11-shape) ---
|
|
155
|
+
# v11 replaces `caws session briefing` with `caws status`. Fall back if unavailable.
|
|
156
|
+
if caws status >/dev/null 2>&1; then
|
|
157
|
+
caws status 2>/dev/null || true
|
|
158
|
+
else
|
|
159
|
+
echo "--- CAWS Session Briefing (fallback) ---"
|
|
160
|
+
HEAD_SHA=$(git rev-parse --short HEAD 2>/dev/null || echo "unknown")
|
|
161
|
+
BRANCH=$(git branch --show-current 2>/dev/null || echo "detached")
|
|
162
|
+
DIRTY_COUNT=$(git status --porcelain 2>/dev/null | wc -l | tr -d ' ')
|
|
163
|
+
echo "Git: ${BRANCH} @ ${HEAD_SHA} (${DIRTY_COUNT} dirty files)"
|
|
164
|
+
if [ "$DIRTY_COUNT" -gt 0 ]; then
|
|
165
|
+
echo "WARNING: Working tree has uncommitted changes from a prior session."
|
|
166
|
+
echo "Classify and commit or stash them before starting new work."
|
|
167
|
+
fi
|
|
168
|
+
echo "--- End CAWS Briefing ---"
|
|
169
|
+
fi
|
|
170
|
+
|
|
171
|
+
exit 0
|