@paths.design/caws-cli 9.2.0 → 9.3.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/dist/commands/specs.js +28 -15
- package/dist/commands/status.js +1 -1
- package/dist/commands/verify-acs.js +471 -0
- package/dist/index.js +13 -1
- package/dist/parallel/parallel-manager.js +5 -12
- package/dist/scaffold/cursor-hooks.js +0 -1
- package/dist/scaffold/git-hooks.js +18 -1
- package/dist/templates/.caws/tools/README.md +4 -7
- package/dist/templates/.caws/tools/scope-guard.js +115 -171
- package/dist/templates/.claude/hooks/audit.sh +25 -0
- package/dist/templates/.claude/hooks/block-dangerous.sh +39 -0
- package/dist/templates/.claude/hooks/lite-sprawl-check.sh +30 -2
- package/dist/templates/.claude/hooks/naming-check.sh +5 -2
- package/dist/templates/.claude/hooks/scope-guard.sh +66 -4
- package/dist/templates/.claude/hooks/session-log.sh +38 -5
- package/dist/templates/.claude/rules/worktree-isolation.md +4 -1
- package/dist/templates/.cursor/README.md +0 -9
- package/dist/templates/.cursor/hooks/audit.sh +1 -1
- package/dist/templates/.cursor/hooks/block-dangerous.sh +1 -0
- package/dist/templates/.cursor/hooks/scan-secrets.sh +8 -3
- package/dist/templates/.cursor/hooks.json +0 -8
- package/dist/templates/.vscode/launch.json +0 -12
- package/dist/utils/detection.js +37 -0
- package/dist/utils/project-analysis.js +0 -1
- package/dist/utils/spec-resolver.js +23 -10
- package/dist/worktree/worktree-manager.js +18 -1
- package/package.json +1 -1
- package/templates/.caws/tools/README.md +4 -7
- package/templates/.caws/tools/scope-guard.js +115 -171
- package/templates/.claude/hooks/audit.sh +25 -0
- package/templates/.claude/hooks/block-dangerous.sh +39 -0
- package/templates/.claude/hooks/lite-sprawl-check.sh +30 -2
- package/templates/.claude/hooks/naming-check.sh +5 -2
- package/templates/.claude/hooks/scope-guard.sh +66 -4
- package/templates/.claude/hooks/session-log.sh +38 -5
- package/templates/.claude/rules/worktree-isolation.md +4 -1
- package/templates/.cursor/README.md +0 -9
- package/templates/.cursor/hooks/audit.sh +1 -1
- package/templates/.cursor/hooks/block-dangerous.sh +1 -0
- package/templates/.cursor/hooks/scan-secrets.sh +8 -3
- package/templates/.cursor/hooks.json +0 -8
- package/templates/.vscode/launch.json +0 -12
- package/templates/.cursor/hooks/caws-tool-validation.sh +0 -121
|
@@ -106,7 +106,6 @@ async function scaffoldCursorHooks(projectDir, levels = ['safety', 'quality', 's
|
|
|
106
106
|
{ command: './.cursor/hooks/block-dangerous.sh' },
|
|
107
107
|
{ command: './.cursor/hooks/audit.sh' },
|
|
108
108
|
];
|
|
109
|
-
hooksConfig.hooks.beforeMCPExecution = [{ command: './.cursor/hooks/audit.sh' }];
|
|
110
109
|
hooksConfig.hooks.beforeReadFile = [{ command: './.cursor/hooks/scan-secrets.sh' }];
|
|
111
110
|
}
|
|
112
111
|
|
|
@@ -408,8 +408,25 @@ elif [ -f "scripts/quality-gates/run-quality-gates.js" ]; then
|
|
|
408
408
|
fi
|
|
409
409
|
# Option 3: CAWS CLI validation
|
|
410
410
|
elif command -v caws >/dev/null 2>&1; then
|
|
411
|
+
# In a worktree, validate only the associated spec to avoid false positives
|
|
412
|
+
CAWS_VALIDATE_ARGS="--quiet"
|
|
413
|
+
WORKTREE_BRANCH=$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo "")
|
|
414
|
+
if [ -f ".caws/worktrees.json" ] && command -v node >/dev/null 2>&1; then
|
|
415
|
+
SPEC_ID=$(node -e "
|
|
416
|
+
try {
|
|
417
|
+
var reg = JSON.parse(require('fs').readFileSync('.caws/worktrees.json', 'utf8'));
|
|
418
|
+
var wt = Object.values(reg.worktrees || {}).find(function(w) {
|
|
419
|
+
return w.branch === '$WORKTREE_BRANCH';
|
|
420
|
+
});
|
|
421
|
+
if (wt && wt.specId) console.log(wt.specId);
|
|
422
|
+
} catch(e) {}
|
|
423
|
+
" 2>/dev/null || echo "")
|
|
424
|
+
if [ -n "$SPEC_ID" ]; then
|
|
425
|
+
CAWS_VALIDATE_ARGS="--quiet --spec-id $SPEC_ID"
|
|
426
|
+
fi
|
|
427
|
+
fi
|
|
411
428
|
echo "Running CAWS CLI validation..."
|
|
412
|
-
if caws validate
|
|
429
|
+
if caws validate $CAWS_VALIDATE_ARGS 2>/dev/null; then
|
|
413
430
|
echo "CAWS validation passed"
|
|
414
431
|
QUALITY_GATES_RAN=true
|
|
415
432
|
else
|
|
@@ -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
|
-
|
|
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
|
-
#
|
|
11
|
-
node .caws/tools/scope-guard.js
|
|
10
|
+
# Check if a file is in scope
|
|
11
|
+
node .caws/tools/scope-guard.js check src/index.js
|
|
12
12
|
|
|
13
|
-
#
|
|
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
|
-
|
|
@@ -1,208 +1,152 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* @fileoverview CAWS Scope Guard
|
|
5
|
-
*
|
|
4
|
+
* @fileoverview CAWS Scope Guard (file-level)
|
|
5
|
+
* Checks whether a given file path is within scope of active specs.
|
|
6
|
+
* Used by Cursor hooks for scope validation on file attachments.
|
|
6
7
|
* @author @darianrosebrook
|
|
7
8
|
*/
|
|
8
9
|
|
|
9
10
|
const fs = require('fs');
|
|
10
|
-
const
|
|
11
|
+
const path = require('path');
|
|
11
12
|
|
|
12
13
|
/**
|
|
13
|
-
*
|
|
14
|
-
* @param {string} workingSpecPath - Path to working spec file
|
|
15
|
-
* @returns {Object} Scope validation results
|
|
14
|
+
* Convert a glob pattern to a RegExp, handling **, *, ?, [abc], {a,b}
|
|
16
15
|
*/
|
|
17
|
-
function
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
16
|
+
function globToRegex(pattern) {
|
|
17
|
+
let i = 0, re = '';
|
|
18
|
+
while (i < pattern.length) {
|
|
19
|
+
const c = pattern[i];
|
|
20
|
+
if (c === '*' && pattern[i + 1] === '*') {
|
|
21
|
+
re += '.*'; i += 2;
|
|
22
|
+
if (pattern[i] === '/') i++; // skip trailing slash after **
|
|
23
|
+
} else if (c === '*') {
|
|
24
|
+
re += '[^/]*'; i++;
|
|
25
|
+
} else if (c === '?') {
|
|
26
|
+
re += '[^/]'; i++;
|
|
27
|
+
} else if (c === '[') {
|
|
28
|
+
const end = pattern.indexOf(']', i);
|
|
29
|
+
if (end > i) { re += pattern.slice(i, end + 1); i = end + 1; }
|
|
30
|
+
else { re += '\\['; i++; }
|
|
31
|
+
} else if (c === '{') {
|
|
32
|
+
const end = pattern.indexOf('}', i);
|
|
33
|
+
if (end > i) {
|
|
34
|
+
const alts = pattern.slice(i + 1, end).split(',').map(a => a.trim());
|
|
35
|
+
re += '(?:' + alts.join('|') + ')'; i = end + 1;
|
|
36
|
+
} else { re += '\\{'; i++; }
|
|
37
|
+
} else if ('.+^$|()'.includes(c)) {
|
|
38
|
+
re += '\\' + c; i++;
|
|
39
|
+
} else {
|
|
40
|
+
re += c; i++;
|
|
39
41
|
}
|
|
42
|
+
}
|
|
43
|
+
return new RegExp(re);
|
|
44
|
+
}
|
|
40
45
|
|
|
41
|
-
|
|
42
|
-
console.log(`🔍 Checking containment for experimental code in: ${sandboxLocation}`);
|
|
46
|
+
const TERMINAL = new Set(['completed', 'closed', 'archived']);
|
|
43
47
|
|
|
44
|
-
|
|
45
|
-
|
|
48
|
+
/**
|
|
49
|
+
* Check if a file is within scope of active specs.
|
|
50
|
+
* @param {string} filePath - Relative path from project root
|
|
51
|
+
* @param {string} projectDir - Project root directory
|
|
52
|
+
* @returns {{inScope: boolean, reason: string}}
|
|
53
|
+
*/
|
|
54
|
+
function checkFileScope(filePath, projectDir) {
|
|
55
|
+
// Smart allowlist: root-level files, .caws/, .claude/ always pass
|
|
56
|
+
if (!filePath.includes('/') || filePath.startsWith('.caws/') || filePath.startsWith('.claude/')) {
|
|
57
|
+
return { inScope: true, reason: 'allowlisted path' };
|
|
58
|
+
}
|
|
46
59
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
return results;
|
|
50
|
-
}
|
|
60
|
+
const specFile = path.join(projectDir, '.caws/working-spec.yaml');
|
|
61
|
+
const specsDir = path.join(projectDir, '.caws/specs');
|
|
51
62
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
file.startsWith(sandboxLocation) ||
|
|
56
|
-
file.includes(`/${sandboxLocation}`) ||
|
|
57
|
-
file.includes(sandboxLocation);
|
|
58
|
-
|
|
59
|
-
if (isInSandbox) {
|
|
60
|
-
results.experimentalFiles.push(file);
|
|
61
|
-
console.log(`✅ Experimental file properly contained: ${file}`);
|
|
62
|
-
} else {
|
|
63
|
-
results.nonExperimentalFiles.push(file);
|
|
64
|
-
results.valid = false;
|
|
65
|
-
results.errors.push(`Experimental code found outside sandbox: ${file}`);
|
|
66
|
-
console.error(`❌ Experimental code outside sandbox: ${file}`);
|
|
67
|
-
}
|
|
68
|
-
});
|
|
69
|
-
|
|
70
|
-
// Check if experimental files actually exist
|
|
71
|
-
results.experimentalFiles.forEach((file) => {
|
|
72
|
-
if (!fs.existsSync(file)) {
|
|
73
|
-
results.warnings.push(`Experimental file not found (may have been deleted): ${file}`);
|
|
74
|
-
console.warn(`⚠️ Experimental file not found: ${file}`);
|
|
75
|
-
}
|
|
76
|
-
});
|
|
63
|
+
if (!fs.existsSync(specFile) && !fs.existsSync(specsDir)) {
|
|
64
|
+
return { inScope: true, reason: 'no specs found' };
|
|
65
|
+
}
|
|
77
66
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
return {
|
|
67
|
+
// Load all active specs
|
|
68
|
+
let yaml;
|
|
69
|
+
try { yaml = require('js-yaml'); } catch (_) {
|
|
70
|
+
return { inScope: true, reason: 'js-yaml not available' };
|
|
82
71
|
}
|
|
83
|
-
}
|
|
84
72
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
const staged = execSync('git diff --cached --name-only', { encoding: 'utf8' })
|
|
93
|
-
.split('\n')
|
|
94
|
-
.filter((file) => file.trim());
|
|
95
|
-
|
|
96
|
-
const modified = execSync('git diff --name-only', { encoding: 'utf8' })
|
|
97
|
-
.split('\n')
|
|
98
|
-
.filter((file) => file.trim());
|
|
99
|
-
|
|
100
|
-
// Combine and deduplicate
|
|
101
|
-
const allFiles = [...new Set([...staged, ...modified])];
|
|
102
|
-
|
|
103
|
-
// Filter out deleted files (they might still be in the diff)
|
|
104
|
-
return allFiles.filter((file) => {
|
|
105
|
-
try {
|
|
106
|
-
return fs.existsSync(file);
|
|
107
|
-
} catch {
|
|
108
|
-
return false;
|
|
73
|
+
const specs = [];
|
|
74
|
+
|
|
75
|
+
if (fs.existsSync(specFile)) {
|
|
76
|
+
try {
|
|
77
|
+
const s = yaml.load(fs.readFileSync(specFile, 'utf8'));
|
|
78
|
+
if (s && !TERMINAL.has(s.status)) {
|
|
79
|
+
specs.push({ source: 'working-spec', spec: s });
|
|
109
80
|
}
|
|
110
|
-
})
|
|
111
|
-
} catch (error) {
|
|
112
|
-
console.warn('⚠️ Could not get changed files from git:', error.message);
|
|
113
|
-
return [];
|
|
81
|
+
} catch (_) {}
|
|
114
82
|
}
|
|
115
|
-
}
|
|
116
83
|
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
if (!results.valid) {
|
|
127
|
-
console.error('\n❌ Experimental containment validation failed:');
|
|
128
|
-
results.errors.forEach((error) => {
|
|
129
|
-
console.error(` - ${error}`);
|
|
130
|
-
});
|
|
131
|
-
|
|
132
|
-
if (results.warnings.length > 0) {
|
|
133
|
-
console.warn('\n⚠️ Warnings:');
|
|
134
|
-
results.warnings.forEach((warning) => {
|
|
135
|
-
console.warn(` - ${warning}`);
|
|
136
|
-
});
|
|
84
|
+
if (fs.existsSync(specsDir)) {
|
|
85
|
+
for (const f of fs.readdirSync(specsDir).filter(f => f.endsWith('.yaml') || f.endsWith('.yml'))) {
|
|
86
|
+
try {
|
|
87
|
+
const s = yaml.load(fs.readFileSync(path.join(specsDir, f), 'utf8'));
|
|
88
|
+
if (s && !TERMINAL.has(s.status)) {
|
|
89
|
+
specs.push({ source: f, spec: s });
|
|
90
|
+
}
|
|
91
|
+
} catch (_) {}
|
|
137
92
|
}
|
|
93
|
+
}
|
|
138
94
|
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
console.error(' 3. Or disable experimental mode if this is production code');
|
|
95
|
+
if (specs.length === 0) {
|
|
96
|
+
return { inScope: true, reason: 'no active specs' };
|
|
97
|
+
}
|
|
143
98
|
|
|
144
|
-
|
|
99
|
+
// Check scope.out — any match blocks
|
|
100
|
+
for (const { source, spec } of specs) {
|
|
101
|
+
for (const pattern of (spec.scope?.out || [])) {
|
|
102
|
+
if (globToRegex(pattern).test(filePath)) {
|
|
103
|
+
return { inScope: false, reason: `out-of-scope in ${source} (pattern: ${pattern})` };
|
|
104
|
+
}
|
|
105
|
+
}
|
|
145
106
|
}
|
|
146
107
|
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
108
|
+
// Union all scope.in — must match at least one
|
|
109
|
+
const allIn = specs.flatMap(({ spec }) => spec.scope?.in || []);
|
|
110
|
+
if (allIn.length > 0) {
|
|
111
|
+
const found = allIn.some(pattern => globToRegex(pattern).test(filePath));
|
|
112
|
+
if (!found) {
|
|
113
|
+
return { inScope: false, reason: 'not in any active spec scope.in' };
|
|
114
|
+
}
|
|
152
115
|
}
|
|
153
116
|
|
|
154
|
-
|
|
155
|
-
console.log(` - Files in sandbox: ${results.experimentalFiles.length}`);
|
|
156
|
-
console.log(` - Files outside sandbox: ${results.nonExperimentalFiles.length}`);
|
|
117
|
+
return { inScope: true, reason: 'in scope' };
|
|
157
118
|
}
|
|
158
119
|
|
|
159
120
|
// CLI interface
|
|
160
121
|
if (require.main === module) {
|
|
161
122
|
const command = process.argv[2];
|
|
162
|
-
const
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
console.log(`
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
console.
|
|
177
|
-
|
|
178
|
-
if (results.errors.length > 0) {
|
|
179
|
-
console.log('\n❌ Errors:');
|
|
180
|
-
results.errors.forEach((error) => console.log(` - ${error}`));
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
if (results.warnings.length > 0) {
|
|
184
|
-
console.log('\n⚠️ Warnings:');
|
|
185
|
-
results.warnings.forEach((warning) => console.log(` - ${warning}`));
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
process.exit(results.valid ? 0 : 1);
|
|
189
|
-
break;
|
|
190
|
-
|
|
191
|
-
default:
|
|
192
|
-
console.log('CAWS Scope Guard');
|
|
193
|
-
console.log('Usage:');
|
|
194
|
-
console.log(' node scope-guard.js validate [spec-path]');
|
|
195
|
-
console.log(' node scope-guard.js check [spec-path]');
|
|
196
|
-
console.log('');
|
|
197
|
-
console.log('Examples:');
|
|
198
|
-
console.log(' node scope-guard.js validate');
|
|
199
|
-
console.log(' node scope-guard.js check .caws/working-spec.yaml');
|
|
123
|
+
const filePath = process.argv[3];
|
|
124
|
+
|
|
125
|
+
if (command === 'check' && filePath) {
|
|
126
|
+
// Resolve relative to cwd
|
|
127
|
+
const projectDir = process.cwd();
|
|
128
|
+
const rel = filePath.startsWith(projectDir)
|
|
129
|
+
? filePath.slice(projectDir.length + 1)
|
|
130
|
+
: filePath;
|
|
131
|
+
|
|
132
|
+
const result = checkFileScope(rel, projectDir);
|
|
133
|
+
if (result.inScope) {
|
|
134
|
+
console.log(`in_scope: ${result.reason}`);
|
|
135
|
+
process.exit(0);
|
|
136
|
+
} else {
|
|
137
|
+
console.error(`out_of_scope: ${result.reason}`);
|
|
200
138
|
process.exit(1);
|
|
139
|
+
}
|
|
140
|
+
} else {
|
|
141
|
+
console.log('CAWS Scope Guard');
|
|
142
|
+
console.log('Usage:');
|
|
143
|
+
console.log(' node scope-guard.js check <file-path>');
|
|
144
|
+
console.log('');
|
|
145
|
+
console.log('Examples:');
|
|
146
|
+
console.log(' node scope-guard.js check src/index.js');
|
|
147
|
+
console.log(' node scope-guard.js check packages/cli/lib/main.ts');
|
|
148
|
+
process.exit(1);
|
|
201
149
|
}
|
|
202
150
|
}
|
|
203
151
|
|
|
204
|
-
module.exports = {
|
|
205
|
-
checkExperimentalContainment,
|
|
206
|
-
validateExperimentalScope,
|
|
207
|
-
getChangedFiles,
|
|
208
|
-
};
|
|
152
|
+
module.exports = { checkFileScope, globToRegex };
|
|
@@ -88,6 +88,31 @@ case "$EVENT_TYPE" in
|
|
|
88
88
|
;;
|
|
89
89
|
esac
|
|
90
90
|
|
|
91
|
+
# --- Log rotation ---
|
|
92
|
+
# Keep main audit.log under 10MB; keep date-logs for 30 days
|
|
93
|
+
rotate_logs() {
|
|
94
|
+
# Rotate main audit.log at 10MB
|
|
95
|
+
if [[ -f "$LOG_FILE" ]]; then
|
|
96
|
+
local size
|
|
97
|
+
size=$(wc -c < "$LOG_FILE" 2>/dev/null | tr -d ' ')
|
|
98
|
+
if [[ "$size" -gt 10485760 ]]; then
|
|
99
|
+
# Keep last rotated copy, discard older
|
|
100
|
+
[[ -f "${LOG_FILE}.1" ]] && rm -f "${LOG_FILE}.1"
|
|
101
|
+
mv "$LOG_FILE" "${LOG_FILE}.1"
|
|
102
|
+
fi
|
|
103
|
+
fi
|
|
104
|
+
|
|
105
|
+
# Prune date-based logs older than 30 days
|
|
106
|
+
if [[ -d "$LOG_DIR" ]]; then
|
|
107
|
+
find "$LOG_DIR" -name 'audit-*.log' -type f -mtime +30 -delete 2>/dev/null || true
|
|
108
|
+
fi
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
# Run rotation check ~1% of the time (avoid stat overhead on every tool call)
|
|
112
|
+
if [[ $(( RANDOM % 100 )) -eq 0 ]]; then
|
|
113
|
+
rotate_logs
|
|
114
|
+
fi
|
|
115
|
+
|
|
91
116
|
# Append to log files
|
|
92
117
|
echo "$LOG_ENTRY" >> "$LOG_FILE"
|
|
93
118
|
echo "$LOG_ENTRY" >> "$DATE_LOG_FILE"
|
|
@@ -75,6 +75,8 @@ DANGEROUS_PATTERNS=(
|
|
|
75
75
|
'git clean -f'
|
|
76
76
|
'git checkout \.'
|
|
77
77
|
'git restore \.'
|
|
78
|
+
'(^|&&|\|\||;|\|)\s*git rebase'
|
|
79
|
+
'(^|&&|\|\||;|\|)\s*git cherry-pick'
|
|
78
80
|
|
|
79
81
|
# Virtual environment creation (prevents venv sprawl)
|
|
80
82
|
'python -m venv'
|
|
@@ -91,6 +93,43 @@ for pattern in "${DANGEROUS_PATTERNS[@]}"; do
|
|
|
91
93
|
continue
|
|
92
94
|
fi
|
|
93
95
|
|
|
96
|
+
# Allow git rebase/cherry-pick only when no worktrees are active
|
|
97
|
+
if [[ "$pattern" == *"git rebase"* ]] || [[ "$pattern" == *"git cherry-pick"* ]]; then
|
|
98
|
+
PROJECT_DIR="${CLAUDE_PROJECT_DIR:-.}"
|
|
99
|
+
# Resolve to main repo root if we're in a worktree
|
|
100
|
+
if command -v git >/dev/null 2>&1; then
|
|
101
|
+
GIT_COMMON=$(cd "$PROJECT_DIR" && git rev-parse --git-common-dir 2>/dev/null || echo "")
|
|
102
|
+
if [[ -n "$GIT_COMMON" ]] && [[ "$GIT_COMMON" != ".git" ]]; then
|
|
103
|
+
CANDIDATE=$(cd "$PROJECT_DIR" && cd "$GIT_COMMON/.." 2>/dev/null && pwd || echo "")
|
|
104
|
+
if [[ -n "$CANDIDATE" ]] && [[ -d "$CANDIDATE/.caws" ]]; then
|
|
105
|
+
PROJECT_DIR="$CANDIDATE"
|
|
106
|
+
fi
|
|
107
|
+
fi
|
|
108
|
+
fi
|
|
109
|
+
WT_FILE="$PROJECT_DIR/.caws/worktrees.json"
|
|
110
|
+
if [[ -f "$WT_FILE" ]] && command -v node >/dev/null 2>&1; then
|
|
111
|
+
ACTIVE_COUNT=$(node -e "
|
|
112
|
+
try {
|
|
113
|
+
var r = JSON.parse(require('fs').readFileSync('$WT_FILE','utf8'));
|
|
114
|
+
var c = Object.values(r.worktrees||{}).filter(function(w){return w.status==='active';}).length;
|
|
115
|
+
console.log(c);
|
|
116
|
+
} catch(e) { console.log(0); }
|
|
117
|
+
" 2>/dev/null || echo "0")
|
|
118
|
+
if [[ "$ACTIVE_COUNT" -gt 0 ]]; then
|
|
119
|
+
# Extract the specific git subcommand for the message
|
|
120
|
+
GIT_SUBCMD="git operation"
|
|
121
|
+
[[ "$pattern" == *"git rebase"* ]] && GIT_SUBCMD="git rebase"
|
|
122
|
+
[[ "$pattern" == *"git cherry-pick"* ]] && GIT_SUBCMD="git cherry-pick"
|
|
123
|
+
echo "BLOCKED: $GIT_SUBCMD is forbidden while $ACTIVE_COUNT worktree(s) are active." >&2
|
|
124
|
+
echo "This can replay or rewrite commits across worktree boundaries." >&2
|
|
125
|
+
echo "Command was: $COMMAND" >&2
|
|
126
|
+
exit 2
|
|
127
|
+
fi
|
|
128
|
+
fi
|
|
129
|
+
# No active worktrees — allow
|
|
130
|
+
continue
|
|
131
|
+
fi
|
|
132
|
+
|
|
94
133
|
# Allow venv commands if target matches designated venv path from scope.json
|
|
95
134
|
if echo "$pattern" | grep -qE '(python.*venv|virtualenv|conda create)'; then
|
|
96
135
|
PROJECT_DIR="${CLAUDE_PROJECT_DIR:-.}"
|
|
@@ -49,9 +49,37 @@ if command -v node >/dev/null 2>&1; then
|
|
|
49
49
|
const basename = '$BASENAME';
|
|
50
50
|
const banned = scope.bannedPatterns || {};
|
|
51
51
|
|
|
52
|
+
function globToRegex(pattern) {
|
|
53
|
+
let i = 0, re = '';
|
|
54
|
+
while (i < pattern.length) {
|
|
55
|
+
const c = pattern[i];
|
|
56
|
+
if (c === '*' && pattern[i+1] === '*') {
|
|
57
|
+
re += '.*'; i += 2;
|
|
58
|
+
if (pattern[i] === '/') i++;
|
|
59
|
+
} else if (c === '*') {
|
|
60
|
+
re += '[^/]*'; i++;
|
|
61
|
+
} else if (c === '?') {
|
|
62
|
+
re += '[^/]'; i++;
|
|
63
|
+
} else if (c === '[') {
|
|
64
|
+
const end = pattern.indexOf(']', i);
|
|
65
|
+
if (end > i) { re += pattern.slice(i, end + 1); i = end + 1; }
|
|
66
|
+
else { re += '\\\\['; i++; }
|
|
67
|
+
} else if (c === '{') {
|
|
68
|
+
const end = pattern.indexOf('}', i);
|
|
69
|
+
if (end > i) {
|
|
70
|
+
const alts = pattern.slice(i + 1, end).split(',').map(a => a.trim());
|
|
71
|
+
re += '(?:' + alts.join('|') + ')'; i = end + 1;
|
|
72
|
+
} else { re += '\\\\{'; i++; }
|
|
73
|
+
} else if ('.+^$|()'.includes(c)) {
|
|
74
|
+
re += '\\\\' + c; i++;
|
|
75
|
+
} else {
|
|
76
|
+
re += c; i++;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return new RegExp('^' + re + '$');
|
|
80
|
+
}
|
|
52
81
|
function matchGlob(str, pattern) {
|
|
53
|
-
|
|
54
|
-
return regex.test(str);
|
|
82
|
+
return globToRegex(pattern).test(str);
|
|
55
83
|
}
|
|
56
84
|
|
|
57
85
|
// Check banned file patterns
|
|
@@ -51,9 +51,12 @@ BANNED_MODIFIERS=(
|
|
|
51
51
|
# Convert filename to lowercase for checking
|
|
52
52
|
FILENAME_LOWER=$(echo "$FILENAME" | tr '[:upper:]' '[:lower:]')
|
|
53
53
|
|
|
54
|
-
# Check for banned modifiers
|
|
54
|
+
# Check for banned modifiers (word-boundary aware)
|
|
55
55
|
for modifier in "${BANNED_MODIFIERS[@]}"; do
|
|
56
|
-
|
|
56
|
+
# Match modifier preceded by start-of-string, hyphen, underscore, or dot
|
|
57
|
+
# and followed by end-of-string, hyphen, underscore, or dot
|
|
58
|
+
# Prevents false positives like "old" in "gold_oracle" or "new" in "renewable"
|
|
59
|
+
if [[ "$FILENAME_LOWER" =~ (^|[-_.])"$modifier"([-_.]|$) ]]; then
|
|
57
60
|
# Special case: allow test files that follow conventions
|
|
58
61
|
if [[ "$modifier" == "test-" ]] || [[ "$modifier" == "-test" ]] || [[ "$modifier" == "_test" ]]; then
|
|
59
62
|
if [[ "$FILENAME_LOWER" =~ \.(test|spec)\.(js|ts|jsx|tsx|py|go|rs)$ ]]; then
|
|
@@ -44,6 +44,37 @@ if [[ ! -f "$SPEC_FILE" ]] && [[ -f "$SCOPE_FILE" ]]; then
|
|
|
44
44
|
LITE_CHECK=$(node -e "
|
|
45
45
|
const fs = require('fs');
|
|
46
46
|
const path = require('path');
|
|
47
|
+
|
|
48
|
+
function globToRegex(pattern) {
|
|
49
|
+
let i = 0, re = '';
|
|
50
|
+
while (i < pattern.length) {
|
|
51
|
+
const c = pattern[i];
|
|
52
|
+
if (c === '*' && pattern[i+1] === '*') {
|
|
53
|
+
re += '.*'; i += 2;
|
|
54
|
+
if (pattern[i] === '/') i++;
|
|
55
|
+
} else if (c === '*') {
|
|
56
|
+
re += '[^/]*'; i++;
|
|
57
|
+
} else if (c === '?') {
|
|
58
|
+
re += '[^/]'; i++;
|
|
59
|
+
} else if (c === '[') {
|
|
60
|
+
const end = pattern.indexOf(']', i);
|
|
61
|
+
if (end > i) { re += pattern.slice(i, end + 1); i = end + 1; }
|
|
62
|
+
else { re += '\\\\['; i++; }
|
|
63
|
+
} else if (c === '{') {
|
|
64
|
+
const end = pattern.indexOf('}', i);
|
|
65
|
+
if (end > i) {
|
|
66
|
+
const alts = pattern.slice(i + 1, end).split(',').map(a => a.trim());
|
|
67
|
+
re += '(?:' + alts.join('|') + ')'; i = end + 1;
|
|
68
|
+
} else { re += '\\\\{'; i++; }
|
|
69
|
+
} else if ('.+^$|()'.includes(c)) {
|
|
70
|
+
re += '\\\\' + c; i++;
|
|
71
|
+
} else {
|
|
72
|
+
re += c; i++;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return new RegExp(re);
|
|
76
|
+
}
|
|
77
|
+
|
|
47
78
|
try {
|
|
48
79
|
const scope = JSON.parse(fs.readFileSync('$SCOPE_FILE', 'utf8'));
|
|
49
80
|
const filePath = '$REL_PATH';
|
|
@@ -54,7 +85,7 @@ if [[ ! -f "$SPEC_FILE" ]] && [[ -f "$SCOPE_FILE" ]]; then
|
|
|
54
85
|
const basename = path.basename(filePath);
|
|
55
86
|
const bannedFiles = banned.files || [];
|
|
56
87
|
for (const pattern of bannedFiles) {
|
|
57
|
-
const regex =
|
|
88
|
+
const regex = globToRegex(pattern);
|
|
58
89
|
if (regex.test(basename)) {
|
|
59
90
|
console.log('banned:' + pattern);
|
|
60
91
|
process.exit(0);
|
|
@@ -64,7 +95,7 @@ if [[ ! -f "$SPEC_FILE" ]] && [[ -f "$SCOPE_FILE" ]]; then
|
|
|
64
95
|
// Check banned doc patterns
|
|
65
96
|
const bannedDocs = banned.docs || [];
|
|
66
97
|
for (const pattern of bannedDocs) {
|
|
67
|
-
const regex =
|
|
98
|
+
const regex = globToRegex(pattern);
|
|
68
99
|
if (regex.test(basename)) {
|
|
69
100
|
console.log('banned:' + pattern);
|
|
70
101
|
process.exit(0);
|
|
@@ -129,6 +160,37 @@ if command -v node >/dev/null 2>&1; then
|
|
|
129
160
|
const fs = require('fs');
|
|
130
161
|
const path = require('path');
|
|
131
162
|
|
|
163
|
+
// Convert glob pattern to regex, handling **, *, ?, [abc], {a,b}
|
|
164
|
+
function globToRegex(pattern) {
|
|
165
|
+
let i = 0, re = '';
|
|
166
|
+
while (i < pattern.length) {
|
|
167
|
+
const c = pattern[i];
|
|
168
|
+
if (c === '*' && pattern[i+1] === '*') {
|
|
169
|
+
re += '.*'; i += 2;
|
|
170
|
+
if (pattern[i] === '/') i++; // skip trailing slash after **
|
|
171
|
+
} else if (c === '*') {
|
|
172
|
+
re += '[^/]*'; i++;
|
|
173
|
+
} else if (c === '?') {
|
|
174
|
+
re += '[^/]'; i++;
|
|
175
|
+
} else if (c === '[') {
|
|
176
|
+
const end = pattern.indexOf(']', i);
|
|
177
|
+
if (end > i) { re += pattern.slice(i, end + 1); i = end + 1; }
|
|
178
|
+
else { re += '\\\\['; i++; }
|
|
179
|
+
} else if (c === '{') {
|
|
180
|
+
const end = pattern.indexOf('}', i);
|
|
181
|
+
if (end > i) {
|
|
182
|
+
const alts = pattern.slice(i + 1, end).split(',').map(a => a.trim());
|
|
183
|
+
re += '(?:' + alts.join('|') + ')'; i = end + 1;
|
|
184
|
+
} else { re += '\\\\{'; i++; }
|
|
185
|
+
} else if ('.+^$|()'.includes(c)) {
|
|
186
|
+
re += '\\\\' + c; i++;
|
|
187
|
+
} else {
|
|
188
|
+
re += c; i++;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
return new RegExp(re);
|
|
192
|
+
}
|
|
193
|
+
|
|
132
194
|
try {
|
|
133
195
|
const filePath = '$REL_PATH';
|
|
134
196
|
|
|
@@ -177,7 +239,7 @@ if command -v node >/dev/null 2>&1; then
|
|
|
177
239
|
// Check scope.out across ALL active specs — any match blocks
|
|
178
240
|
for (const { source, spec } of specs) {
|
|
179
241
|
for (const pattern of (spec.scope?.out || [])) {
|
|
180
|
-
const regex =
|
|
242
|
+
const regex = globToRegex(pattern);
|
|
181
243
|
if (regex.test(filePath)) {
|
|
182
244
|
console.log('out_of_scope:' + source + ':' + pattern);
|
|
183
245
|
process.exit(0);
|
|
@@ -190,7 +252,7 @@ if command -v node >/dev/null 2>&1; then
|
|
|
190
252
|
if (allInScope.length > 0) {
|
|
191
253
|
let found = false;
|
|
192
254
|
for (const pattern of allInScope) {
|
|
193
|
-
const regex =
|
|
255
|
+
const regex = globToRegex(pattern);
|
|
194
256
|
if (regex.test(filePath)) {
|
|
195
257
|
found = true;
|
|
196
258
|
break;
|