@fro.bot/systematic 2.33.2 → 3.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 (112) hide show
  1. package/ATTRIBUTIONS.md +1 -1
  2. package/agents/design/design-iterator.md +1 -0
  3. package/agents/research/best-practices-researcher.md +6 -8
  4. package/agents/review/pattern-recognition-specialist.md +1 -1
  5. package/agents/workflow/systematic-implementer.md +1 -0
  6. package/dist/cli.js +348 -53
  7. package/dist/{index-vyzhzvap.js → index-yskzf7vh.js} +587 -386
  8. package/dist/index.js +100 -155
  9. package/dist/lib/agent-overlays.d.ts +0 -1
  10. package/dist/lib/agent-resolver.d.ts +28 -0
  11. package/dist/lib/bootstrap.d.ts +8 -1
  12. package/dist/lib/bundled-names.d.ts +3 -3
  13. package/dist/lib/pi-delegate-session.d.ts +31 -0
  14. package/dist/lib/pi-delegate-tool.d.ts +42 -0
  15. package/dist/lib/removed-names.d.ts +3 -3
  16. package/dist/lib/setup.d.ts +36 -0
  17. package/dist/lib/skill-loader.d.ts +1 -2
  18. package/dist/lib/skill-resolver.d.ts +39 -0
  19. package/dist/lib/skill-tool.d.ts +2 -11
  20. package/dist/lib/skills.d.ts +1 -9
  21. package/dist/lib/source-model-defaults.d.ts +1 -1
  22. package/dist/pi.d.ts +7 -0
  23. package/dist/pi.js +3641 -0
  24. package/dist/schemas/systematic-config.schema.json +47 -242
  25. package/package.json +31 -8
  26. package/skills/ce-brainstorm/references/handoff.md +5 -26
  27. package/skills/ce-brainstorm/references/universal-brainstorming.md +0 -1
  28. package/skills/ce-compound/SKILL.md +8 -12
  29. package/skills/ce-ideate/references/post-ideation-workflow.md +21 -85
  30. package/skills/ce-plan/SKILL.md +6 -6
  31. package/skills/ce-plan/references/deepening-workflow.md +6 -7
  32. package/skills/ce-plan/references/plan-handoff.md +1 -17
  33. package/skills/ce-plan/references/universal-planning.md +0 -4
  34. package/skills/ce-review/SKILL.md +10 -19
  35. package/skills/ce-review/references/persona-catalog.md +2 -7
  36. package/skills/ce-review/references/review-output-template.md +0 -1
  37. package/skills/ce-work/SKILL.md +1 -1
  38. package/skills/deepen-plan/SKILL.md +6 -7
  39. package/skills/document-review/SKILL.md +2 -2
  40. package/skills/git-commit-push-pr/SKILL.md +1 -2
  41. package/skills/lfg/SKILL.md +1 -1
  42. package/skills/onboarding/SKILL.md +1 -12
  43. package/skills/orchestrating-subagents/SKILL.md +2 -2
  44. package/skills/slfg/SKILL.md +2 -3
  45. package/skills/test-browser/SKILL.md +2 -2
  46. package/skills/todos/SKILL.md +239 -0
  47. package/skills/writing-skills/SKILL.md +102 -1
  48. package/skills/{writing-systematic-skills → writing-skills}/references/foundation-conventions.md +5 -5
  49. package/agents/design/design-implementation-reviewer.md +0 -95
  50. package/agents/design/figma-design-sync.md +0 -173
  51. package/agents/docs/ankane-readme-writer.md +0 -51
  52. package/agents/review/cli-agent-readiness-reviewer.md +0 -418
  53. package/agents/review/data-integrity-guardian.md +0 -72
  54. package/agents/review/data-migration-expert.md +0 -99
  55. package/agents/review/dhh-rails-reviewer.md +0 -46
  56. package/agents/review/julik-frontend-races-reviewer.md +0 -49
  57. package/agents/review/kieran-python-reviewer.md +0 -47
  58. package/agents/review/kieran-rails-reviewer.md +0 -47
  59. package/agents/review/performance-oracle.md +0 -112
  60. package/agents/review/schema-drift-detector.md +0 -143
  61. package/agents/review/security-sentinel.md +0 -95
  62. package/agents/workflow/lint.md +0 -17
  63. package/dist/lib/converter.d.ts +0 -14
  64. package/skills/andrew-kane-gem-writer/SKILL.md +0 -184
  65. package/skills/andrew-kane-gem-writer/references/database-adapters.md +0 -231
  66. package/skills/andrew-kane-gem-writer/references/module-organization.md +0 -121
  67. package/skills/andrew-kane-gem-writer/references/rails-integration.md +0 -183
  68. package/skills/andrew-kane-gem-writer/references/resources.md +0 -119
  69. package/skills/andrew-kane-gem-writer/references/testing-patterns.md +0 -261
  70. package/skills/changelog/SKILL.md +0 -139
  71. package/skills/claude-permissions-optimizer/SKILL.md +0 -166
  72. package/skills/claude-permissions-optimizer/scripts/extract-commands.mjs +0 -655
  73. package/skills/claude-permissions-optimizer/scripts/normalize.mjs +0 -151
  74. package/skills/dhh-rails-style/SKILL.md +0 -186
  75. package/skills/dhh-rails-style/references/architecture.md +0 -653
  76. package/skills/dhh-rails-style/references/controllers.md +0 -303
  77. package/skills/dhh-rails-style/references/frontend.md +0 -510
  78. package/skills/dhh-rails-style/references/gems.md +0 -266
  79. package/skills/dhh-rails-style/references/models.md +0 -359
  80. package/skills/dhh-rails-style/references/testing.md +0 -338
  81. package/skills/dspy-ruby/SKILL.md +0 -737
  82. package/skills/dspy-ruby/assets/config-template.rb +0 -187
  83. package/skills/dspy-ruby/assets/module-template.rb +0 -300
  84. package/skills/dspy-ruby/assets/signature-template.rb +0 -221
  85. package/skills/dspy-ruby/references/core-concepts.md +0 -674
  86. package/skills/dspy-ruby/references/observability.md +0 -366
  87. package/skills/dspy-ruby/references/optimization.md +0 -603
  88. package/skills/dspy-ruby/references/providers.md +0 -418
  89. package/skills/dspy-ruby/references/toolsets.md +0 -502
  90. package/skills/every-style-editor/SKILL.md +0 -135
  91. package/skills/every-style-editor/references/EVERY_WRITE_STYLE.md +0 -529
  92. package/skills/feature-video/SKILL.md +0 -383
  93. package/skills/gemini-imagegen/SKILL.md +0 -237
  94. package/skills/gemini-imagegen/requirements.txt +0 -2
  95. package/skills/gemini-imagegen/scripts/compose_images.py +0 -157
  96. package/skills/gemini-imagegen/scripts/edit_image.py +0 -144
  97. package/skills/gemini-imagegen/scripts/gemini_images.py +0 -263
  98. package/skills/gemini-imagegen/scripts/generate_image.py +0 -133
  99. package/skills/gemini-imagegen/scripts/multi_turn_chat.py +0 -216
  100. package/skills/generate_command/SKILL.md +0 -164
  101. package/skills/orchestrating-swarms/SKILL.md +0 -1723
  102. package/skills/proof/SKILL.md +0 -291
  103. package/skills/proof/references/hitl-review.md +0 -368
  104. package/skills/rclone/SKILL.md +0 -150
  105. package/skills/rclone/scripts/check_setup.sh +0 -60
  106. package/skills/setup/SKILL.md +0 -22
  107. package/skills/test-xcode/SKILL.md +0 -214
  108. package/skills/todo-create/SKILL.md +0 -109
  109. package/skills/todo-resolve/SKILL.md +0 -68
  110. package/skills/todo-triage/SKILL.md +0 -70
  111. package/skills/writing-systematic-skills/SKILL.md +0 -119
  112. /package/skills/{todo-create → todos}/assets/todo-template.md +0 -0
@@ -1,655 +0,0 @@
1
- #!/usr/bin/env node
2
-
3
- // Extracts, normalizes, and pre-classifies Bash commands from Claude Code 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
- import { normalize } from './normalize.mjs'
19
-
20
- const args = process.argv.slice(2)
21
-
22
- function flag(name, fallback) {
23
- const i = args.indexOf(`--${name}`)
24
- return i !== -1 && args[i + 1] ? args[i + 1] : fallback
25
- }
26
-
27
- function flagAll(name) {
28
- const results = []
29
- let i = 0
30
- while (i < args.length) {
31
- if (args[i] === `--${name}` && args[i + 1]) {
32
- results.push(args[i + 1])
33
- i += 2
34
- } else {
35
- i++
36
- }
37
- }
38
- return results
39
- }
40
-
41
- const days = parseInt(flag('days', '30'), 10)
42
- const maxSessions = parseInt(flag('max-sessions', '500'), 10)
43
- const minCount = parseInt(flag('min-count', '5'), 10)
44
- const projectSlugFilter = flag('project-slug', null)
45
- const settingsPaths = flagAll('settings')
46
- const claudeDir = process.env.CLAUDE_CONFIG_DIR || join(homedir(), '.claude')
47
- const projectsDir = join(claudeDir, '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(claudeDir, 'settings.json'))
74
- settingsPaths.push(join(process.cwd(), '.claude', 'settings.json'))
75
- settingsPaths.push(join(process.cwd(), '.claude', '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 Claude Code'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 (see ./normalize.mjs) ────────────────────────────────────
414
-
415
- // ── Session file scanning ──────────────────────────────────────────────────
416
-
417
- const commands = new Map()
418
- let filesScanned = 0
419
- const sessionsScanned = new Set()
420
-
421
- async function listDirs(dir) {
422
- try {
423
- const entries = await readdir(dir, { withFileTypes: true })
424
- return entries.filter((e) => e.isDirectory()).map((e) => e.name)
425
- } catch {
426
- return []
427
- }
428
- }
429
-
430
- async function listJsonlFiles(dir) {
431
- try {
432
- const entries = await readdir(dir, { withFileTypes: true })
433
- return entries
434
- .filter((e) => e.isFile() && e.name.endsWith('.jsonl'))
435
- .map((e) => e.name)
436
- } catch {
437
- return []
438
- }
439
- }
440
-
441
- async function processFile(filePath, sessionId) {
442
- try {
443
- filesScanned++
444
- sessionsScanned.add(sessionId)
445
-
446
- const content = await readFile(filePath, 'utf-8')
447
- for (const line of content.split('\n')) {
448
- if (!line.includes('"Bash"')) continue
449
- try {
450
- const record = JSON.parse(line)
451
- if (record.type !== 'assistant') continue
452
- const blocks = record.message?.content
453
- if (!Array.isArray(blocks)) continue
454
- for (const block of blocks) {
455
- if (block.type !== 'tool_use' || block.name !== 'Bash') continue
456
- const cmd = block.input?.command
457
- if (!cmd) continue
458
- const ts = record.timestamp
459
- ? new Date(record.timestamp).getTime()
460
- : info.mtimeMs
461
- const existing = commands.get(cmd)
462
- if (existing) {
463
- existing.count++
464
- existing.sessions.add(sessionId)
465
- existing.firstSeen = Math.min(existing.firstSeen, ts)
466
- existing.lastSeen = Math.max(existing.lastSeen, ts)
467
- } else {
468
- commands.set(cmd, {
469
- count: 1,
470
- sessions: new Set([sessionId]),
471
- firstSeen: ts,
472
- lastSeen: ts,
473
- })
474
- }
475
- }
476
- } catch {
477
- // skip malformed lines
478
- }
479
- }
480
- } catch {
481
- // skip unreadable files
482
- }
483
- }
484
-
485
- // Collect all candidate session files, then sort by recency and limit
486
- const candidates = []
487
- const projectSlugs = await listDirs(projectsDir)
488
- for (const slug of projectSlugs) {
489
- if (projectSlugFilter && slug !== projectSlugFilter) continue
490
- const slugDir = join(projectsDir, slug)
491
- const jsonlFiles = await listJsonlFiles(slugDir)
492
- for (const f of jsonlFiles) {
493
- const filePath = join(slugDir, f)
494
- try {
495
- const info = await stat(filePath)
496
- if (info.mtimeMs >= cutoff) {
497
- candidates.push({
498
- filePath,
499
- sessionId: f.replace('.jsonl', ''),
500
- mtime: info.mtimeMs,
501
- })
502
- }
503
- } catch {
504
- // skip unreadable files
505
- }
506
- }
507
- }
508
-
509
- // Sort by most recent first, then take at most maxSessions
510
- candidates.sort((a, b) => b.mtime - a.mtime)
511
- const toProcess = candidates.slice(0, maxSessions)
512
-
513
- await Promise.all(toProcess.map((c) => processFile(c.filePath, c.sessionId)))
514
-
515
- // ── Filter, normalize, group, classify ─────────────────────────────────────
516
-
517
- const totalExtracted = commands.size
518
- let alreadyCovered = 0
519
- let belowThreshold = 0
520
-
521
- // Group raw commands by normalized pattern, tracking unique sessions per group.
522
- // Normalize and group FIRST, then apply the min-count threshold to the grouped
523
- // totals. This prevents many low-frequency variants of the same pattern from
524
- // being individually discarded as noise when they collectively exceed the threshold.
525
- const patternGroups = new Map()
526
-
527
- for (const [command, data] of commands) {
528
- if (isAllowed(command)) {
529
- alreadyCovered++
530
- continue
531
- }
532
-
533
- const pattern = `Bash(${normalize(command)})`
534
- const { tier, reason } = classify(command)
535
-
536
- const existing = patternGroups.get(pattern)
537
- if (existing) {
538
- existing.rawCommands.push({ command, count: data.count })
539
- existing.totalCount += data.count
540
- // Merge session sets to avoid overcounting
541
- for (const s of data.sessions) existing.sessionSet.add(s)
542
- // Escalation: highest tier wins
543
- if (tier === 'red' && existing.tier !== 'red') {
544
- existing.tier = 'red'
545
- existing.reason = reason
546
- } else if (tier === 'yellow' && existing.tier === 'green') {
547
- existing.tier = 'yellow'
548
- } else if (tier === 'unknown' && existing.tier === 'green') {
549
- existing.tier = 'unknown'
550
- }
551
- } else {
552
- patternGroups.set(pattern, {
553
- rawCommands: [{ command, count: data.count }],
554
- totalCount: data.count,
555
- sessionSet: new Set(data.sessions),
556
- tier,
557
- reason: reason || null,
558
- })
559
- }
560
- }
561
-
562
- // Now filter by min-count on the GROUPED totals
563
- for (const [pattern, data] of patternGroups) {
564
- if (data.totalCount < minCount) {
565
- belowThreshold += data.rawCommands.length
566
- patternGroups.delete(pattern)
567
- }
568
- }
569
-
570
- // Post-grouping safety check: normalization can broaden a safe command into an
571
- // unsafe pattern (e.g., "node --version" is green, but normalizes to "node *"
572
- // which would also match arbitrary code execution). Re-classify the normalized
573
- // pattern itself and escalate if the broader form is riskier.
574
- for (const [pattern, data] of patternGroups) {
575
- if (data.tier !== 'green') continue
576
- if (!pattern.includes('*')) continue
577
- const cmd = pattern.replace(/^Bash\(|\)$/g, '')
578
- const { tier, reason } = classify(cmd)
579
- if (tier === 'red') {
580
- data.tier = 'red'
581
- data.reason = reason
582
- } else if (tier === 'yellow') {
583
- data.tier = 'yellow'
584
- } else if (tier === 'unknown') {
585
- data.tier = 'unknown'
586
- }
587
- }
588
-
589
- // Only output green (safe) patterns. Yellow, red, and unknown are counted
590
- // in stats for transparency but not included as arrays.
591
- const green = []
592
- let greenRawCount = 0 // unique raw commands covered by green patterns
593
- let yellowCount = 0
594
- const redBlocked = []
595
- let unclassified = 0
596
- const yellowNames = [] // brief list for the footnote
597
-
598
- for (const [pattern, data] of patternGroups) {
599
- switch (data.tier) {
600
- case 'green':
601
- green.push({
602
- pattern,
603
- count: data.totalCount,
604
- sessions: data.sessionSet.size,
605
- examples: data.rawCommands
606
- .sort((a, b) => b.count - a.count)
607
- .slice(0, 3)
608
- .map((c) => c.command),
609
- })
610
- greenRawCount += data.rawCommands.length
611
- break
612
- case 'yellow':
613
- yellowCount++
614
- yellowNames.push(pattern.replace(/^Bash\(|\)$/g, '').replace(/ \*$/, ''))
615
- break
616
- case 'red':
617
- redBlocked.push({
618
- pattern: pattern.replace(/^Bash\(|\)$/g, ''),
619
- reason: data.reason,
620
- count: data.totalCount,
621
- })
622
- break
623
- default:
624
- unclassified++
625
- }
626
- }
627
-
628
- green.sort((a, b) => b.count - a.count)
629
- redBlocked.sort((a, b) => b.count - a.count)
630
-
631
- const output = {
632
- green,
633
- redExamples: redBlocked.slice(0, 5),
634
- yellowFootnote:
635
- yellowNames.length > 0
636
- ? `Also frequently used: ${yellowNames.join(', ')} (not classified as safe to auto-allow but may be worth reviewing)`
637
- : null,
638
- stats: {
639
- totalExtracted,
640
- alreadyCovered,
641
- belowThreshold,
642
- unclassified,
643
- yellowSkipped: yellowCount,
644
- redBlocked: redBlocked.length,
645
- patternsReturned: green.length,
646
- greenRawCount,
647
- sessionsScanned: sessionsScanned.size,
648
- filesScanned,
649
- allowPatternsLoaded: allowPatterns.length,
650
- daysWindow: days,
651
- minCount,
652
- },
653
- }
654
-
655
- console.log(JSON.stringify(output, null, 2))