@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
|
@@ -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;
|
|
@@ -157,6 +157,33 @@ def rel(path):
|
|
|
157
157
|
return path[len(cwd) + 1:]
|
|
158
158
|
return path or ""
|
|
159
159
|
|
|
160
|
+
def decode_structured_text_payload(raw):
|
|
161
|
+
"""Decode JSON-escaped text payloads (e.g., Agent/Task tool outputs)."""
|
|
162
|
+
if not isinstance(raw, str):
|
|
163
|
+
return raw
|
|
164
|
+
payload = raw.strip()
|
|
165
|
+
if not payload or payload[0] not in "[{":
|
|
166
|
+
return raw
|
|
167
|
+
try:
|
|
168
|
+
parsed = json.loads(payload)
|
|
169
|
+
except Exception:
|
|
170
|
+
return raw
|
|
171
|
+
|
|
172
|
+
text_blocks = []
|
|
173
|
+
if isinstance(parsed, dict):
|
|
174
|
+
parsed = [parsed]
|
|
175
|
+
if isinstance(parsed, list):
|
|
176
|
+
for item in parsed:
|
|
177
|
+
if not isinstance(item, dict):
|
|
178
|
+
continue
|
|
179
|
+
text = item.get("text")
|
|
180
|
+
if isinstance(text, str) and text.strip():
|
|
181
|
+
text_blocks.append(text)
|
|
182
|
+
|
|
183
|
+
if text_blocks:
|
|
184
|
+
return "\n\n".join(text_blocks)
|
|
185
|
+
return raw
|
|
186
|
+
|
|
160
187
|
# ---- Accumulate turns as chronological event timelines ----
|
|
161
188
|
turns = []
|
|
162
189
|
# Each turn: {user, timeline: [{kind, ...}, ...], edits, reads, searches, commands}
|
|
@@ -242,18 +269,24 @@ for line in sys.stdin:
|
|
|
242
269
|
tool_info = pending_tools.get(tid, {})
|
|
243
270
|
name = tool_info.get("name", "unknown")
|
|
244
271
|
|
|
245
|
-
#
|
|
246
|
-
#
|
|
247
|
-
|
|
272
|
+
# Always capture tool results for Bash, Task, Agent.
|
|
273
|
+
# For Read/Write/Edit, only capture if notable (errors, test output, etc.)
|
|
274
|
+
# to avoid dumping entire file contents into turn logs.
|
|
275
|
+
always_capture = name in ("Bash", "Task", "Agent")
|
|
276
|
+
notable = is_error
|
|
248
277
|
if not notable and content:
|
|
249
278
|
content_lower = content.lower()
|
|
250
279
|
notable = any(kw.lower() in content_lower for kw in NOTABLE_KW)
|
|
251
280
|
|
|
252
|
-
if notable and content:
|
|
281
|
+
if (always_capture or notable) and content:
|
|
253
282
|
# Cap file-content tools (full file reads/writes blow out turn files)
|
|
254
283
|
display = content
|
|
255
284
|
if name in ("Read", "Write", "Edit") and len(content) > 2000:
|
|
256
285
|
display = content[:2000] + "\n...(file content truncated)"
|
|
286
|
+
elif name in ("Task", "Agent"):
|
|
287
|
+
display = decode_structured_text_payload(content)
|
|
288
|
+
elif name == "Bash" and len(content) > 5000:
|
|
289
|
+
display = content[:5000] + "\n...(output truncated at 5000 chars)"
|
|
257
290
|
# Graft result onto the original tool_call entry (not a separate timeline item)
|
|
258
291
|
if tool_info:
|
|
259
292
|
tool_info["output"] = display
|
|
@@ -281,7 +314,7 @@ for i, turn in enumerate(turns):
|
|
|
281
314
|
md_lines = [f"# Turn {num}", ""]
|
|
282
315
|
|
|
283
316
|
if turn["user"]:
|
|
284
|
-
md_lines.extend([f"> ---user---\n{turn['user']}\n
|
|
317
|
+
md_lines.extend([f"> ---user---\n{turn['user']}\n---\/user---", ""])
|
|
285
318
|
|
|
286
319
|
for event in turn["timeline"]:
|
|
287
320
|
kind = event["kind"]
|