@fro.bot/systematic 1.23.2 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/README.md +75 -61
  2. package/agents/research/best-practices-researcher.md +2 -3
  3. package/agents/research/issue-intelligence-analyst.md +2 -3
  4. package/package.json +2 -3
  5. package/skills/ce-brainstorm/SKILL.md +10 -11
  6. package/skills/ce-compound/SKILL.md +11 -11
  7. package/skills/ce-compound-refresh/SKILL.md +2 -2
  8. package/skills/ce-ideate/SKILL.md +3 -4
  9. package/skills/ce-plan/SKILL.md +8 -8
  10. package/skills/ce-plan-beta/SKILL.md +9 -10
  11. package/skills/ce-review/SKILL.md +7 -7
  12. package/skills/ce-work/SKILL.md +4 -4
  13. package/skills/ce-work-beta/SKILL.md +556 -0
  14. package/skills/claude-permissions-optimizer/SKILL.md +161 -0
  15. package/skills/claude-permissions-optimizer/scripts/extract-commands.mjs +805 -0
  16. package/skills/deepen-plan/SKILL.md +15 -15
  17. package/skills/deepen-plan-beta/SKILL.md +3 -3
  18. package/skills/deploy-docs/SKILL.md +8 -8
  19. package/skills/file-todos/SKILL.md +2 -1
  20. package/skills/generate_command/SKILL.md +1 -1
  21. package/skills/{report-bug → report-bug-ce}/SKILL.md +38 -33
  22. package/skills/resolve-todo-parallel/SKILL.md +65 -0
  23. package/skills/setup/SKILL.md +3 -3
  24. package/skills/test-browser/SKILL.md +3 -4
  25. package/commands/.gitkeep +0 -0
  26. package/skills/create-agent-skill/SKILL.md +0 -10
  27. package/skills/create-agent-skills/SKILL.md +0 -265
  28. package/skills/create-agent-skills/references/api-security.md +0 -226
  29. package/skills/create-agent-skills/references/be-clear-and-direct.md +0 -531
  30. package/skills/create-agent-skills/references/best-practices.md +0 -404
  31. package/skills/create-agent-skills/references/common-patterns.md +0 -595
  32. package/skills/create-agent-skills/references/core-principles.md +0 -437
  33. package/skills/create-agent-skills/references/executable-code.md +0 -175
  34. package/skills/create-agent-skills/references/iteration-and-testing.md +0 -474
  35. package/skills/create-agent-skills/references/official-spec.md +0 -134
  36. package/skills/create-agent-skills/references/recommended-structure.md +0 -168
  37. package/skills/create-agent-skills/references/skill-structure.md +0 -152
  38. package/skills/create-agent-skills/references/using-scripts.md +0 -113
  39. package/skills/create-agent-skills/references/using-templates.md +0 -112
  40. package/skills/create-agent-skills/references/workflows-and-validation.md +0 -510
  41. package/skills/create-agent-skills/templates/router-skill.md +0 -73
  42. package/skills/create-agent-skills/templates/simple-skill.md +0 -33
  43. package/skills/create-agent-skills/workflows/add-reference.md +0 -96
  44. package/skills/create-agent-skills/workflows/add-script.md +0 -93
  45. package/skills/create-agent-skills/workflows/add-template.md +0 -74
  46. package/skills/create-agent-skills/workflows/add-workflow.md +0 -126
  47. package/skills/create-agent-skills/workflows/audit-skill.md +0 -138
  48. package/skills/create-agent-skills/workflows/create-domain-expertise-skill.md +0 -605
  49. package/skills/create-agent-skills/workflows/create-new-skill.md +0 -197
  50. package/skills/create-agent-skills/workflows/get-guidance.md +0 -121
  51. package/skills/create-agent-skills/workflows/upgrade-to-router.md +0 -161
  52. package/skills/create-agent-skills/workflows/verify-skill.md +0 -204
  53. package/skills/heal-skill/SKILL.md +0 -148
  54. package/skills/resolve_parallel/SKILL.md +0 -36
  55. package/skills/resolve_todo_parallel/SKILL.md +0 -38
@@ -0,0 +1,805 @@
1
+ #!/usr/bin/env node
2
+
3
+ // Extracts, normalizes, and pre-classifies Bash commands from OpenCode sessions.
4
+ // Filters against the current allowlist, groups by normalized pattern, and classifies
5
+ // each pattern as green/yellow/red so the model can review rather than classify from scratch.
6
+ //
7
+ // Usage: node extract-commands.mjs [--days <N>] [--project-slug <slug>] [--min-count 5]
8
+ // [--settings <path>] [--settings <path>] ...
9
+ //
10
+ // Analyzes the most recent sessions, bounded by both count and time.
11
+ // Defaults: last 200 sessions or 30 days, whichever is more restrictive.
12
+ //
13
+ // Output: JSON with { green, yellowFootnote, stats }
14
+
15
+ import { readdir, readFile, stat } from 'node:fs/promises'
16
+ import { homedir } from 'node:os'
17
+ import { join } from 'node:path'
18
+
19
+ const args = process.argv.slice(2)
20
+
21
+ function flag(name, fallback) {
22
+ const i = args.indexOf(`--${name}`)
23
+ return i !== -1 && args[i + 1] ? args[i + 1] : fallback
24
+ }
25
+
26
+ function flagAll(name) {
27
+ const results = []
28
+ let i = 0
29
+ while (i < args.length) {
30
+ if (args[i] === `--${name}` && args[i + 1]) {
31
+ results.push(args[i + 1])
32
+ i += 2
33
+ } else {
34
+ i++
35
+ }
36
+ }
37
+ return results
38
+ }
39
+
40
+ const days = parseInt(flag('days', '30'), 10)
41
+ const maxSessions = parseInt(flag('max-sessions', '500'), 10)
42
+ const minCount = parseInt(flag('min-count', '5'), 10)
43
+ const projectSlugFilter = flag('project-slug', null)
44
+ const settingsPaths = flagAll('settings')
45
+ const opencodeDir =
46
+ process.env.OPENCODE_CONFIG_DIR || join(homedir(), '.config', 'opencode')
47
+ const projectsDir = join(opencodeDir, 'projects')
48
+ const cutoff = Date.now() - days * 24 * 60 * 60 * 1000
49
+
50
+ // ── Allowlist loading ──────────────────────────────────────────────────────
51
+
52
+ const allowPatterns = []
53
+
54
+ async function loadAllowlist(filePath) {
55
+ try {
56
+ const content = await readFile(filePath, 'utf-8')
57
+ const settings = JSON.parse(content)
58
+ const allow = settings?.permissions?.allow || []
59
+ for (const rule of allow) {
60
+ const match = rule.match(/^Bash\((.+)\)$/)
61
+ if (match) {
62
+ allowPatterns.push(match[1])
63
+ } else if (rule === 'Bash' || rule === 'Bash(*)') {
64
+ allowPatterns.push('*')
65
+ }
66
+ }
67
+ } catch {
68
+ // file doesn't exist or isn't valid JSON
69
+ }
70
+ }
71
+
72
+ if (settingsPaths.length === 0) {
73
+ settingsPaths.push(join(opencodeDir, 'settings.json'))
74
+ settingsPaths.push(join(process.cwd(), '.opencode', 'settings.json'))
75
+ settingsPaths.push(join(process.cwd(), '.opencode', 'settings.local.json'))
76
+ }
77
+
78
+ for (const p of settingsPaths) {
79
+ await loadAllowlist(p)
80
+ }
81
+
82
+ function isAllowed(command) {
83
+ for (const pattern of allowPatterns) {
84
+ if (pattern === '*') return true
85
+ if (matchGlob(pattern, command)) return true
86
+ }
87
+ return false
88
+ }
89
+
90
+ function matchGlob(pattern, command) {
91
+ const normalized = pattern.replace(/:(\*)$/, ' $1')
92
+ let regexStr
93
+ if (normalized.endsWith(' *')) {
94
+ const base = normalized.slice(0, -2)
95
+ const escaped = base.replace(/[.+^${}()|[\]\\]/g, '\\$&')
96
+ regexStr = `^${escaped}($| .*)`
97
+ } else {
98
+ regexStr =
99
+ '^' +
100
+ normalized.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*') +
101
+ '$'
102
+ }
103
+ try {
104
+ return new RegExp(regexStr).test(command)
105
+ } catch {
106
+ return false
107
+ }
108
+ }
109
+
110
+ // ── Classification rules ───────────────────────────────────────────────────
111
+
112
+ // RED: patterns that should never be allowlisted with wildcards.
113
+ // Checked first -- highest priority.
114
+ const RED_PATTERNS = [
115
+ // Destructive file ops -- all rm variants
116
+ { test: /^rm\s/, reason: 'Irreversible file deletion' },
117
+ { test: /^sudo\s/, reason: 'Privilege escalation' },
118
+ { test: /^su\s/, reason: 'Privilege escalation' },
119
+ // find with destructive actions (must be before GREEN_BASES check)
120
+ {
121
+ test: /\bfind\b.*\s-delete\b/,
122
+ reason: 'find -delete permanently removes files',
123
+ },
124
+ {
125
+ test: /\bfind\b.*\s-exec\s+rm\b/,
126
+ reason: 'find -exec rm permanently removes files',
127
+ },
128
+ // ast-grep rewrite modifies files in place
129
+ {
130
+ test: /\b(ast-grep|sg)\b.*--rewrite\b/,
131
+ reason: 'ast-grep --rewrite modifies files in place',
132
+ },
133
+ // sed -i edits files in place
134
+ { test: /\bsed\s+.*-i\b/, reason: 'sed -i modifies files in place' },
135
+ // Git irreversible
136
+ {
137
+ test: /git\s+(?:\S+\s+)*push\s+.*--force(?!-with-lease)/,
138
+ reason: 'Force push overwrites remote history',
139
+ },
140
+ {
141
+ test: /git\s+(?:\S+\s+)*push\s+.*\s-f\b/,
142
+ reason: 'Force push overwrites remote history',
143
+ },
144
+ {
145
+ test: /git\s+(?:\S+\s+)*push\s+-f\b/,
146
+ reason: 'Force push overwrites remote history',
147
+ },
148
+ { test: /git\s+reset\s+--(hard|merge)/, reason: 'Destroys uncommitted work' },
149
+ {
150
+ test: /git\s+clean\s+.*(-[a-z]*f[a-z]*\b|--force\b)/,
151
+ reason: 'Permanently deletes untracked files',
152
+ },
153
+ { test: /git\s+commit\s+.*--no-verify/, reason: 'Skips safety hooks' },
154
+ { test: /git\s+config\s+--system/, reason: 'System-wide config change' },
155
+ { test: /git\s+filter-branch/, reason: 'Rewrites entire repo history' },
156
+ { test: /git\s+filter-repo/, reason: 'Rewrites repo history' },
157
+ {
158
+ test: /git\s+gc\s+.*--aggressive/,
159
+ reason: 'Can remove recoverable objects',
160
+ },
161
+ { test: /git\s+reflog\s+expire/, reason: 'Removes recovery safety net' },
162
+ {
163
+ test: /git\s+stash\s+clear\b/,
164
+ reason: 'Removes ALL stash entries permanently',
165
+ },
166
+ {
167
+ test: /git\s+branch\s+.*(-D\b|--force\b)/,
168
+ reason: 'Force-deletes without merge check',
169
+ },
170
+ { test: /git\s+checkout\s+.*\s--\s/, reason: 'Discards uncommitted changes' },
171
+ { test: /git\s+checkout\s+--\s/, reason: 'Discards uncommitted changes' },
172
+ {
173
+ test: /git\s+restore\s+(?!.*(-S\b|--staged\b))/,
174
+ reason: 'Discards working tree changes',
175
+ },
176
+ // Publishing -- permanent across all ecosystems
177
+ {
178
+ test: /\b(npm|yarn|pnpm)\s+publish\b/,
179
+ reason: 'Permanent package publishing',
180
+ },
181
+ { test: /\bnpm\s+unpublish\b/, reason: 'Permanent package removal' },
182
+ { test: /\bcargo\s+publish\b/, reason: 'Permanent crate publishing' },
183
+ { test: /\bcargo\s+yank\b/, reason: 'Unavails crate version' },
184
+ { test: /\bgem\s+push\b/, reason: 'Permanent gem publishing' },
185
+ { test: /\bpoetry\s+publish\b/, reason: 'Permanent package publishing' },
186
+ { test: /\btwine\s+upload\b/, reason: 'Permanent package publishing' },
187
+ { test: /\bgh\s+release\s+create\b/, reason: 'Permanent release creation' },
188
+ // Shell injection
189
+ { test: /\|\s*(sh|bash|zsh)\b/, reason: 'Pipe to shell execution' },
190
+ { test: /\beval\s/, reason: 'Arbitrary code execution' },
191
+ // Docker destructive
192
+ { test: /docker\s+run\s+.*--privileged/, reason: 'Full host access' },
193
+ {
194
+ test: /docker\s+system\s+prune\b(?!.*--dry-run)/,
195
+ reason: 'Removes all unused data',
196
+ },
197
+ { test: /docker\s+volume\s+(rm|prune)\b/, reason: 'Permanent data deletion' },
198
+ {
199
+ test: /docker[- ]compose\s+down\s+.*(-v\b|--volumes\b)/,
200
+ reason: 'Removes volumes and data',
201
+ },
202
+ {
203
+ test: /docker[- ]compose\s+down\s+.*--rmi\b/,
204
+ reason: 'Removes all images',
205
+ },
206
+ {
207
+ test: /docker\s+(rm|rmi)\s+.*-[a-z]*f/,
208
+ reason: 'Force removes without confirmation',
209
+ },
210
+ // System
211
+ { test: /^reboot\b/, reason: 'System restart' },
212
+ { test: /^shutdown\b/, reason: 'System halt' },
213
+ { test: /^halt\b/, reason: 'System halt' },
214
+ {
215
+ test: /\bsystemctl\s+(stop|disable|mask)\b/,
216
+ reason: 'Stops system services',
217
+ },
218
+ { test: /\bkill\s+-9\b/, reason: 'Force kill without cleanup' },
219
+ { test: /\bpkill\s+-9\b/, reason: 'Force kill by name' },
220
+ // Disk destructive
221
+ { test: /\bdd\s+.*\bof=/, reason: 'Raw disk write' },
222
+ { test: /\bmkfs\b/, reason: 'Formats disk partition' },
223
+ // Permissions
224
+ { test: /\bchmod\s+777\b/, reason: 'World-writable permissions' },
225
+ { test: /\bchmod\s+-R\b/, reason: 'Recursive permission change' },
226
+ { test: /\bchown\s+-R\b/, reason: 'Recursive ownership change' },
227
+ // Database destructive
228
+ {
229
+ test: /\bDROP\s+(DATABASE|TABLE|SCHEMA)\b/i,
230
+ reason: 'Permanent data deletion',
231
+ },
232
+ { test: /\bTRUNCATE\b/i, reason: 'Permanent row deletion' },
233
+ // Network
234
+ { test: /^(nc|ncat)\s/, reason: 'Raw socket access' },
235
+ // Credential exposure
236
+ { test: /\bcat\s+\.env.*\|/, reason: 'Credential exposure via pipe' },
237
+ { test: /\bprintenv\b.*\|/, reason: 'Credential exposure via pipe' },
238
+ // Package removal (from DCG)
239
+ { test: /\bpip3?\s+uninstall\b/, reason: 'Package removal' },
240
+ {
241
+ test: /\bapt(?:-get)?\s+(remove|purge|autoremove)\b/,
242
+ reason: 'Package removal',
243
+ },
244
+ { test: /\bbrew\s+uninstall\b/, reason: 'Package removal' },
245
+ ]
246
+
247
+ // GREEN: base commands that are always read-only / safe.
248
+ // NOTE: `find` is intentionally excluded -- `find -delete` and `find -exec rm`
249
+ // are destructive. Safe find usage is handled via GREEN_COMPOUND instead.
250
+ const GREEN_BASES = new Set([
251
+ 'ls',
252
+ 'cat',
253
+ 'head',
254
+ 'tail',
255
+ 'wc',
256
+ 'file',
257
+ 'tree',
258
+ 'stat',
259
+ 'du',
260
+ 'diff',
261
+ 'grep',
262
+ 'rg',
263
+ 'ag',
264
+ 'ack',
265
+ 'which',
266
+ 'whoami',
267
+ 'pwd',
268
+ 'echo',
269
+ 'printf',
270
+ 'env',
271
+ 'printenv',
272
+ 'uname',
273
+ 'hostname',
274
+ 'jq',
275
+ 'sort',
276
+ 'uniq',
277
+ 'tr',
278
+ 'cut',
279
+ 'less',
280
+ 'more',
281
+ 'man',
282
+ 'type',
283
+ 'realpath',
284
+ 'dirname',
285
+ 'basename',
286
+ 'date',
287
+ 'ps',
288
+ 'top',
289
+ 'htop',
290
+ 'free',
291
+ 'uptime',
292
+ 'id',
293
+ 'groups',
294
+ 'lsof',
295
+ 'open',
296
+ 'xdg-open',
297
+ ])
298
+
299
+ // GREEN: compound patterns
300
+ const GREEN_COMPOUND = [
301
+ /--version\s*$/,
302
+ /--help(\s|$)/,
303
+ /^git\s+(status|log|diff|show|blame|shortlog|branch\s+-[alv]|remote\s+-v|rev-parse|describe|reflog\b(?!\s+expire))\b/,
304
+ /^git\s+tag\s+(-l\b|--list\b)/, // tag listing (not creation)
305
+ /^git\s+stash\s+(list|show)\b/, // stash read-only operations
306
+ /^(npm|bun|pnpm|yarn)\s+run\s+(test|lint|build|check|typecheck)\b/,
307
+ /^(npm|bun|pnpm|yarn)\s+(test|lint|audit|outdated|list)\b/,
308
+ /^(npx|bunx)\s+(vitest|jest|eslint|prettier|tsc)\b/,
309
+ /^(pytest|jest|cargo\s+test|go\s+test|rspec|bundle\s+exec\s+rspec|make\s+test|rake\s+rspec)\b/,
310
+ /^(eslint|prettier|rubocop|black|flake8|cargo\s+(clippy|fmt)|gofmt|golangci-lint|tsc(\s+--noEmit)?|mypy|pyright)\b/,
311
+ /^(cargo\s+(build|check|doc|bench)|go\s+(build|vet))\b/,
312
+ /^pnpm\s+--filter\s/,
313
+ /^(npm|bun|pnpm|yarn)\s+(typecheck|format|verify|validate|check|analyze)\b/, // common safe script names
314
+ /^git\s+-C\s+\S+\s+(status|log|diff|show|branch|remote|rev-parse|describe)\b/, // git -C <dir> <read-only>
315
+ /^docker\s+(ps|images|logs|inspect|stats|system\s+df)\b/,
316
+ /^docker[- ]compose\s+(ps|logs|config)\b/,
317
+ /^systemctl\s+(status|list-|show|is-|cat)\b/,
318
+ /^journalctl\b/,
319
+ /^(pg_dump|mysqldump)\b(?!.*--clean)/,
320
+ /\b--dry-run\b/,
321
+ /^git\s+clean\s+.*(-[a-z]*n|--dry-run)\b/, // git clean dry run
322
+ // NOTE: find is intentionally NOT green. Bash(find *) would also match
323
+ // find -delete and find -exec rm in OpenCode's allowlist glob matching.
324
+ // Commands with mode-switching flags: only green when the normalized pattern
325
+ // is narrow enough that the allowlist glob can't match the destructive form.
326
+ // Bash(sed -n *) is safe; Bash(sed *) would also match sed -i.
327
+ /^sed\s+-(?!i\b)[a-zA-Z]\s/, // sed with a non-destructive flag (matches normalized sed -n *, sed -e *, etc.)
328
+ /^(ast-grep|sg)\b(?!.*--rewrite)/, // ast-grep without --rewrite
329
+ /^find\s+-(?:name|type|path|iname)\s/, // find with safe predicate flag (matches normalized form)
330
+ // gh CLI read-only operations
331
+ /^gh\s+(pr|issue|run)\s+(view|list|status|diff|checks)\b/,
332
+ /^gh\s+repo\s+(view|list|clone)\b/,
333
+ /^gh\s+api\b/,
334
+ ]
335
+
336
+ // YELLOW: base commands that modify local state but are recoverable
337
+ const YELLOW_BASES = new Set([
338
+ 'mkdir',
339
+ 'touch',
340
+ 'cp',
341
+ 'mv',
342
+ 'tee',
343
+ 'curl',
344
+ 'wget',
345
+ 'ssh',
346
+ 'scp',
347
+ 'rsync',
348
+ 'python',
349
+ 'python3',
350
+ 'node',
351
+ 'ruby',
352
+ 'perl',
353
+ 'make',
354
+ 'just',
355
+ 'awk', // awk can write files; safe forms handled case-by-case if needed
356
+ ])
357
+
358
+ // YELLOW: compound patterns
359
+ const YELLOW_COMPOUND = [
360
+ /^git\s+(add|commit(?!\s+.*--no-verify)|checkout(?!\s+--\s)|switch|pull|push(?!\s+.*--force)(?!\s+.*-f\b)|fetch|merge|rebase|stash(?!\s+clear\b)|branch\b(?!\s+.*(-D\b|--force\b))|cherry-pick|tag|clone)\b/,
361
+ /^git\s+push\s+--force-with-lease\b/,
362
+ /^git\s+restore\s+.*(-S\b|--staged\b)/, // restore --staged is safe (just unstages)
363
+ /^git\s+gc\b(?!\s+.*--aggressive)/,
364
+ /^(npm|bun|pnpm|yarn)\s+install\b/,
365
+ /^(npm|bun|pnpm|yarn)\s+(add|remove|uninstall|update)\b/,
366
+ /^(npm|bun|pnpm)\s+run\s+(start|dev|serve)\b/,
367
+ /^(pip|pip3)\s+install\b(?!\s+https?:)/,
368
+ /^bundle\s+install\b/,
369
+ /^(cargo\s+add|go\s+get)\b/,
370
+ /^docker\s+(build|run(?!\s+.*--privileged)|stop|start)\b/,
371
+ /^docker[- ]compose\s+(up|down\b(?!\s+.*(-v\b|--volumes\b|--rmi\b)))/,
372
+ /^systemctl\s+restart\b/,
373
+ /^kill\s+(?!.*-9)\d/,
374
+ /^rake\b/,
375
+ // gh CLI write operations (recoverable)
376
+ /^gh\s+(pr|issue)\s+(create|edit|comment|close|reopen|merge)\b/,
377
+ /^gh\s+run\s+(rerun|cancel|watch)\b/,
378
+ ]
379
+
380
+ function classify(command) {
381
+ // Extract the first command from compound chains (&&, ||, ;) and pipes
382
+ // so that `cd /dir && git branch -D feat` classifies as green (cd),
383
+ // not red (git branch -D). This matches what normalize() does.
384
+ const compoundMatch = command.match(/^(.+?)\s*(&&|\|\||;)\s*(.+)$/)
385
+ if (compoundMatch) return classify(compoundMatch[1].trim())
386
+ const pipeMatch = command.match(/^(.+?)\s*\|\s*(.+)$/)
387
+ if (pipeMatch && !/\|\s*(sh|bash|zsh)\b/.test(command)) {
388
+ return classify(pipeMatch[1].trim())
389
+ }
390
+
391
+ // RED check first (highest priority)
392
+ for (const { test, reason } of RED_PATTERNS) {
393
+ if (test.test(command)) return { tier: 'red', reason }
394
+ }
395
+
396
+ // GREEN checks
397
+ const baseCmd = command.split(/\s+/)[0]
398
+ if (GREEN_BASES.has(baseCmd)) return { tier: 'green' }
399
+ for (const re of GREEN_COMPOUND) {
400
+ if (re.test(command)) return { tier: 'green' }
401
+ }
402
+
403
+ // YELLOW checks
404
+ if (YELLOW_BASES.has(baseCmd)) return { tier: 'yellow' }
405
+ for (const re of YELLOW_COMPOUND) {
406
+ if (re.test(command)) return { tier: 'yellow' }
407
+ }
408
+
409
+ // Unclassified -- silently dropped from output
410
+ return { tier: 'unknown' }
411
+ }
412
+
413
+ // ── Normalization ──────────────────────────────────────────────────────────
414
+
415
+ // Risk-modifying flags that must NOT be collapsed into wildcards.
416
+ // Global flags are always preserved; context-specific flags only matter
417
+ // for certain base commands.
418
+ const GLOBAL_RISK_FLAGS = new Set([
419
+ '--force',
420
+ '--hard',
421
+ '-rf',
422
+ '--privileged',
423
+ '--no-verify',
424
+ '--system',
425
+ '--force-with-lease',
426
+ '-D',
427
+ '--force-if-includes',
428
+ '--volumes',
429
+ '--rmi',
430
+ '--rewrite',
431
+ '--delete',
432
+ ])
433
+
434
+ // Flags that are only risky for specific base commands.
435
+ // -f means force-push in git, force-remove in docker, but pattern-file in grep.
436
+ // -v means remove-volumes in docker-compose, but verbose everywhere else.
437
+ const CONTEXTUAL_RISK_FLAGS = {
438
+ '-f': new Set(['git', 'docker', 'rm']),
439
+ '-v': new Set(['docker', 'docker-compose']),
440
+ }
441
+
442
+ function isRiskFlag(token, base) {
443
+ if (GLOBAL_RISK_FLAGS.has(token)) return true
444
+ // Check context-specific flags
445
+ const contexts = CONTEXTUAL_RISK_FLAGS[token]
446
+ if (contexts && base && contexts.has(base)) return true
447
+ // Combined short flags containing risk chars: -rf, -fr, -fR, etc.
448
+ if (/^-[a-zA-Z]*[rf][a-zA-Z]*$/.test(token) && token.length <= 4) return true
449
+ return false
450
+ }
451
+
452
+ // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: command normalization intentionally centralizes risk checks and pattern shaping.
453
+ function normalize(command) {
454
+ // Don't normalize shell injection patterns
455
+ if (/\|\s*(sh|bash|zsh)\b/.test(command)) return command
456
+ // Don't normalize sudo -- keep as-is
457
+ if (/^sudo\s/.test(command)) return 'sudo *'
458
+
459
+ // Handle pnpm --filter <pkg> <subcommand> specially
460
+ const pnpmFilter = command.match(/^pnpm\s+--filter\s+\S+\s+(\S+)/)
461
+ if (pnpmFilter) return `pnpm --filter * ${pnpmFilter[1]} *`
462
+
463
+ // Handle sed specially -- preserve the mode flag to keep safe patterns narrow.
464
+ // sed -i (in-place) is destructive; sed -n, sed -e, bare sed are read-only.
465
+ if (/^sed\s/.test(command)) {
466
+ if (/\s-i\b/.test(command)) return 'sed -i *'
467
+ const sedFlag = command.match(/^sed\s+(-[a-zA-Z])\s/)
468
+ return sedFlag ? `sed ${sedFlag[1]} *` : 'sed *'
469
+ }
470
+
471
+ // Handle ast-grep specially -- preserve --rewrite flag.
472
+ if (/^(ast-grep|sg)\s/.test(command)) {
473
+ const base = command.startsWith('sg') ? 'sg' : 'ast-grep'
474
+ return /\s--rewrite\b/.test(command) ? `${base} --rewrite *` : `${base} *`
475
+ }
476
+
477
+ // Handle find specially -- preserve key action flags.
478
+ // find -delete and find -exec rm are destructive; find -name/-type are safe.
479
+ if (/^find\s/.test(command)) {
480
+ if (/\s-delete\b/.test(command)) return 'find -delete *'
481
+ if (/\s-exec\s/.test(command)) return 'find -exec *'
482
+ // Extract the first predicate flag for a narrower safe pattern
483
+ const findFlag = command.match(/\s(-(?:name|type|path|iname))\s/)
484
+ return findFlag ? `find ${findFlag[1]} *` : 'find *'
485
+ }
486
+
487
+ // Handle git -C <dir> <subcommand> -- strip the -C <dir> and normalize the git subcommand
488
+ const gitC = command.match(/^git\s+-C\s+\S+\s+(.+)$/)
489
+ if (gitC) return normalize(`git ${gitC[1]}`)
490
+
491
+ // Split on compound operators -- normalize the first command only
492
+ const compoundMatch = command.match(/^(.+?)\s*(&&|\|\||;)\s*(.+)$/)
493
+ if (compoundMatch) {
494
+ return normalize(compoundMatch[1].trim())
495
+ }
496
+
497
+ // Strip trailing pipe chains for normalization (e.g., `cmd | tail -5`)
498
+ // but preserve pipe-to-shell (already handled by shell injection check above)
499
+ const pipeMatch = command.match(/^(.+?)\s*\|\s*(.+)$/)
500
+ if (pipeMatch) {
501
+ return normalize(pipeMatch[1].trim())
502
+ }
503
+
504
+ // Strip trailing redirections (2>&1, > file, >> file)
505
+ const cleaned = command
506
+ .replace(/\s*[12]?>>?\s*\S+\s*$/, '')
507
+ .replace(/\s*2>&1\s*$/, '')
508
+ .trim()
509
+
510
+ const parts = cleaned.split(/\s+/)
511
+ if (parts.length === 0) return command
512
+
513
+ const base = parts[0]
514
+
515
+ // For git/docker/gh/npm etc, include the subcommand
516
+ const multiWordBases = [
517
+ 'git',
518
+ 'docker',
519
+ 'docker-compose',
520
+ 'gh',
521
+ 'npm',
522
+ 'bun',
523
+ 'pnpm',
524
+ 'yarn',
525
+ 'cargo',
526
+ 'pip',
527
+ 'pip3',
528
+ 'bundle',
529
+ 'systemctl',
530
+ 'kubectl',
531
+ ]
532
+
533
+ let prefix = base
534
+ let argStart = 1
535
+
536
+ if (multiWordBases.includes(base) && parts.length > 1) {
537
+ prefix = `${base} ${parts[1]}`
538
+ argStart = 2
539
+ }
540
+
541
+ // Preserve risk-modifying flags in the remaining args
542
+ const preservedFlags = []
543
+ for (let i = argStart; i < parts.length; i++) {
544
+ if (isRiskFlag(parts[i], base)) {
545
+ preservedFlags.push(parts[i])
546
+ }
547
+ }
548
+
549
+ // Build the normalized pattern
550
+ if (parts.length <= argStart && preservedFlags.length === 0) {
551
+ return prefix // no args, no flags: e.g., "git status"
552
+ }
553
+
554
+ const flagStr =
555
+ preservedFlags.length > 0 ? ` ${preservedFlags.join(' ')}` : ''
556
+ const hasVaryingArgs = parts.length > argStart + preservedFlags.length
557
+
558
+ if (hasVaryingArgs) {
559
+ return `${prefix + flagStr} *`
560
+ }
561
+ return prefix + flagStr
562
+ }
563
+
564
+ // ── Session file scanning ──────────────────────────────────────────────────
565
+
566
+ const commands = new Map()
567
+ let filesScanned = 0
568
+ const sessionsScanned = new Set()
569
+
570
+ async function listDirs(dir) {
571
+ try {
572
+ const entries = await readdir(dir, { withFileTypes: true })
573
+ return entries.filter((e) => e.isDirectory()).map((e) => e.name)
574
+ } catch {
575
+ return []
576
+ }
577
+ }
578
+
579
+ async function listJsonlFiles(dir) {
580
+ try {
581
+ const entries = await readdir(dir, { withFileTypes: true })
582
+ return entries
583
+ .filter((e) => e.isFile() && e.name.endsWith('.jsonl'))
584
+ .map((e) => e.name)
585
+ } catch {
586
+ return []
587
+ }
588
+ }
589
+
590
+ // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: transcript parsing requires defensive guards for heterogeneous session data.
591
+ async function processFile(filePath, sessionId) {
592
+ try {
593
+ filesScanned++
594
+ sessionsScanned.add(sessionId)
595
+
596
+ const content = await readFile(filePath, 'utf-8')
597
+ for (const line of content.split('\n')) {
598
+ if (!line.includes('"Bash"')) continue
599
+ try {
600
+ const record = JSON.parse(line)
601
+ if (record.type !== 'assistant') continue
602
+ const blocks = record.message?.content
603
+ if (!Array.isArray(blocks)) continue
604
+ for (const block of blocks) {
605
+ if (block.type !== 'tool_use' || block.name !== 'Bash') continue
606
+ const cmd = block.input?.command
607
+ if (!cmd) continue
608
+ const ts = record.timestamp
609
+ ? new Date(record.timestamp).getTime()
610
+ : info.mtimeMs
611
+ const existing = commands.get(cmd)
612
+ if (existing) {
613
+ existing.count++
614
+ existing.sessions.add(sessionId)
615
+ existing.firstSeen = Math.min(existing.firstSeen, ts)
616
+ existing.lastSeen = Math.max(existing.lastSeen, ts)
617
+ } else {
618
+ commands.set(cmd, {
619
+ count: 1,
620
+ sessions: new Set([sessionId]),
621
+ firstSeen: ts,
622
+ lastSeen: ts,
623
+ })
624
+ }
625
+ }
626
+ } catch {
627
+ // skip malformed lines
628
+ }
629
+ }
630
+ } catch {
631
+ // skip unreadable files
632
+ }
633
+ }
634
+
635
+ // Collect all candidate session files, then sort by recency and limit
636
+ const candidates = []
637
+ const projectSlugs = await listDirs(projectsDir)
638
+ for (const slug of projectSlugs) {
639
+ if (projectSlugFilter && slug !== projectSlugFilter) continue
640
+ const slugDir = join(projectsDir, slug)
641
+ const jsonlFiles = await listJsonlFiles(slugDir)
642
+ for (const f of jsonlFiles) {
643
+ const filePath = join(slugDir, f)
644
+ try {
645
+ const info = await stat(filePath)
646
+ if (info.mtimeMs >= cutoff) {
647
+ candidates.push({
648
+ filePath,
649
+ sessionId: f.replace('.jsonl', ''),
650
+ mtime: info.mtimeMs,
651
+ })
652
+ }
653
+ } catch {
654
+ // skip unreadable files
655
+ }
656
+ }
657
+ }
658
+
659
+ // Sort by most recent first, then take at most maxSessions
660
+ candidates.sort((a, b) => b.mtime - a.mtime)
661
+ const toProcess = candidates.slice(0, maxSessions)
662
+
663
+ await Promise.all(toProcess.map((c) => processFile(c.filePath, c.sessionId)))
664
+
665
+ // ── Filter, normalize, group, classify ─────────────────────────────────────
666
+
667
+ const totalExtracted = commands.size
668
+ let alreadyCovered = 0
669
+ let belowThreshold = 0
670
+
671
+ // Group raw commands by normalized pattern, tracking unique sessions per group.
672
+ // Normalize and group FIRST, then apply the min-count threshold to the grouped
673
+ // totals. This prevents many low-frequency variants of the same pattern from
674
+ // being individually discarded as noise when they collectively exceed the threshold.
675
+ const patternGroups = new Map()
676
+
677
+ for (const [command, data] of commands) {
678
+ if (isAllowed(command)) {
679
+ alreadyCovered++
680
+ continue
681
+ }
682
+
683
+ const pattern = `Bash(${normalize(command)})`
684
+ const { tier, reason } = classify(command)
685
+
686
+ const existing = patternGroups.get(pattern)
687
+ if (existing) {
688
+ existing.rawCommands.push({ command, count: data.count })
689
+ existing.totalCount += data.count
690
+ // Merge session sets to avoid overcounting
691
+ for (const s of data.sessions) existing.sessionSet.add(s)
692
+ // Escalation: highest tier wins
693
+ if (tier === 'red' && existing.tier !== 'red') {
694
+ existing.tier = 'red'
695
+ existing.reason = reason
696
+ } else if (tier === 'yellow' && existing.tier === 'green') {
697
+ existing.tier = 'yellow'
698
+ } else if (tier === 'unknown' && existing.tier === 'green') {
699
+ existing.tier = 'unknown'
700
+ }
701
+ } else {
702
+ patternGroups.set(pattern, {
703
+ rawCommands: [{ command, count: data.count }],
704
+ totalCount: data.count,
705
+ sessionSet: new Set(data.sessions),
706
+ tier,
707
+ reason: reason || null,
708
+ })
709
+ }
710
+ }
711
+
712
+ // Now filter by min-count on the GROUPED totals
713
+ for (const [pattern, data] of patternGroups) {
714
+ if (data.totalCount < minCount) {
715
+ belowThreshold += data.rawCommands.length
716
+ patternGroups.delete(pattern)
717
+ }
718
+ }
719
+
720
+ // Post-grouping safety check: normalization can broaden a safe command into an
721
+ // unsafe pattern (e.g., "node --version" is green, but normalizes to "node *"
722
+ // which would also match arbitrary code execution). Re-classify the normalized
723
+ // pattern itself and escalate if the broader form is riskier.
724
+ for (const [pattern, data] of patternGroups) {
725
+ if (data.tier !== 'green') continue
726
+ if (!pattern.includes('*')) continue
727
+ const cmd = pattern.replace(/^Bash\(|\)$/g, '')
728
+ const { tier, reason } = classify(cmd)
729
+ if (tier === 'red') {
730
+ data.tier = 'red'
731
+ data.reason = reason
732
+ } else if (tier === 'yellow') {
733
+ data.tier = 'yellow'
734
+ } else if (tier === 'unknown') {
735
+ data.tier = 'unknown'
736
+ }
737
+ }
738
+
739
+ // Only output green (safe) patterns. Yellow, red, and unknown are counted
740
+ // in stats for transparency but not included as arrays.
741
+ const green = []
742
+ let greenRawCount = 0 // unique raw commands covered by green patterns
743
+ let yellowCount = 0
744
+ const redBlocked = []
745
+ let unclassified = 0
746
+ const yellowNames = [] // brief list for the footnote
747
+
748
+ for (const [pattern, data] of patternGroups) {
749
+ switch (data.tier) {
750
+ case 'green':
751
+ green.push({
752
+ pattern,
753
+ count: data.totalCount,
754
+ sessions: data.sessionSet.size,
755
+ examples: data.rawCommands
756
+ .sort((a, b) => b.count - a.count)
757
+ .slice(0, 3)
758
+ .map((c) => c.command),
759
+ })
760
+ greenRawCount += data.rawCommands.length
761
+ break
762
+ case 'yellow':
763
+ yellowCount++
764
+ yellowNames.push(pattern.replace(/^Bash\(|\)$/g, '').replace(/ \*$/, ''))
765
+ break
766
+ case 'red':
767
+ redBlocked.push({
768
+ pattern: pattern.replace(/^Bash\(|\)$/g, ''),
769
+ reason: data.reason,
770
+ count: data.totalCount,
771
+ })
772
+ break
773
+ default:
774
+ unclassified++
775
+ }
776
+ }
777
+
778
+ green.sort((a, b) => b.count - a.count)
779
+ redBlocked.sort((a, b) => b.count - a.count)
780
+
781
+ const output = {
782
+ green,
783
+ redExamples: redBlocked.slice(0, 5),
784
+ yellowFootnote:
785
+ yellowNames.length > 0
786
+ ? `Also frequently used: ${yellowNames.join(', ')} (not classified as safe to auto-allow but may be worth reviewing)`
787
+ : null,
788
+ stats: {
789
+ totalExtracted,
790
+ alreadyCovered,
791
+ belowThreshold,
792
+ unclassified,
793
+ yellowSkipped: yellowCount,
794
+ redBlocked: redBlocked.length,
795
+ patternsReturned: green.length,
796
+ greenRawCount,
797
+ sessionsScanned: sessionsScanned.size,
798
+ filesScanned,
799
+ allowPatternsLoaded: allowPatterns.length,
800
+ daysWindow: days,
801
+ minCount,
802
+ },
803
+ }
804
+
805
+ console.log(JSON.stringify(output, null, 2))