crewforth 3.2.0-rc.1 → 3.2.0-rc.3

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 (69) hide show
  1. package/CHANGELOG.md +133 -3
  2. package/README.md +2 -0
  3. package/VERSION +1 -1
  4. package/adopt.sh +55 -6
  5. package/bin/cli.js +3 -2
  6. package/kit/CLAUDE.md +6 -6
  7. package/kit/agents/crew-review-agent.md +2 -0
  8. package/kit/eval/doctor.sh +37 -3
  9. package/kit/eval/gate-report.sh +2 -0
  10. package/kit/eval/lib/crew-map/narrative.mjs +42 -8
  11. package/kit/eval/lib/crew-map/project.mjs +3 -2
  12. package/kit/eval/lib/crew-map/rules/tech.json +35 -0
  13. package/kit/eval/lib/crew-map/scan.mjs +56 -4
  14. package/kit/eval/lib/crew-map/stacks.mjs +114 -8
  15. package/kit/eval/lib/crew-map/surface.mjs +62 -1
  16. package/kit/eval/lib/gate-log.sh +52 -0
  17. package/kit/eval/lib/json-string.awk +27 -0
  18. package/kit/eval/lib/preset.sh +109 -0
  19. package/kit/eval/lib/presets/default.json +7 -0
  20. package/kit/eval/lib/presets/solo.json +7 -0
  21. package/kit/eval/lib/presets/strict.json +11 -0
  22. package/kit/eval/lib/preview.sh +150 -0
  23. package/kit/eval/lib/review-bench/bench.mjs +273 -0
  24. package/kit/eval/lib/review-prepass/prepass.mjs +203 -0
  25. package/kit/eval/lib/review-prepass/prepass.sh +23 -0
  26. package/kit/eval/lib/settings-default.sh +36 -0
  27. package/kit/eval/lib/settings-json.awk +14 -4
  28. package/kit/eval/lib/statusline.sh +4 -19
  29. package/kit/eval/lib/verify-cmd.sh +17 -0
  30. package/kit/eval/lib/version.sh +41 -0
  31. package/kit/eval/review-bench.sh +26 -0
  32. package/kit/eval/smoke-test.sh +251 -21
  33. package/kit/hooks/agent-outcome.sh +20 -9
  34. package/kit/hooks/board-sync.sh +2 -23
  35. package/kit/hooks/guard-agent-model.sh +114 -15
  36. package/kit/hooks/guard-bash.sh +36 -21
  37. package/kit/hooks/guard-write.sh +3 -7
  38. package/kit/hooks/precompact-snapshot.sh +75 -0
  39. package/kit/hooks/session-rehydrate.sh +25 -2
  40. package/kit/hooks/session-update-check.sh +9 -8
  41. package/kit/settings.json +12 -0
  42. package/kit/skills/crew-code-review/SKILL.md +16 -0
  43. package/kit/skills/crew-map/SKILL.md +16 -5
  44. package/kit/skills/crew-review/SKILL.md +2 -1
  45. package/kit/studio/README.md +21 -10
  46. package/kit/studio/server/index.js +85 -22
  47. package/kit/studio/server/lib/code-stamp.js +35 -0
  48. package/kit/studio/server/lib/graph.js +9 -1
  49. package/kit/studio/server/lib/kit-telemetry.js +30 -5
  50. package/kit/studio/server/lib/lan.js +87 -0
  51. package/kit/studio/server/lib/owned-stream.js +56 -0
  52. package/kit/studio/server/lib/projects.js +11 -4
  53. package/kit/studio/server/lib/redact.js +60 -0
  54. package/kit/studio/server/lib/remote.js +104 -18
  55. package/kit/studio/server/lib/session.js +1 -1
  56. package/kit/studio/server/lib/terminal.js +51 -13
  57. package/kit/studio/server/lib/transcript.js +32 -0
  58. package/kit/studio/web/app.js +79 -11
  59. package/kit/studio/web/canvas.js +14 -2
  60. package/kit/studio/web/chat.js +114 -24
  61. package/kit/studio/web/graph-plan.js +3 -2
  62. package/kit/studio/web/liveness.js +14 -0
  63. package/kit/studio/web/map-plan.js +72 -3
  64. package/kit/studio/web/map.js +103 -10
  65. package/kit/studio/web/qr.js +200 -0
  66. package/kit/studio/web/style.css +40 -0
  67. package/kit/studio/web/tokens.css +6 -0
  68. package/package.json +1 -1
  69. package/start.sh +14 -1
@@ -24,15 +24,15 @@
24
24
  import fs from 'node:fs';
25
25
  import path from 'node:path';
26
26
  import { fileURLToPath } from 'node:url';
27
- import { openProject, loadSecretPatterns, makeHostFilter, sortEv, bearsValues, risky, spawned, isTestPath } from './project.mjs';
28
- import { scanStacks } from './stacks.mjs';
29
- import { externals, configuration, codeUrls, endpoints, containers } from './surface.mjs';
27
+ import { openProject, loadSecretPatterns, makeHostFilter, sortEv, bearsValues, risky, spawned, isTestPath, ev } from './project.mjs';
28
+ import { scanStacks, applyTech } from './stacks.mjs';
29
+ import { externals, configuration, codeUrls, servedCalls, endpoints, containers } from './surface.mjs';
30
30
  import { detect } from './detect.mjs';
31
31
  import { stamp, write, serialize, validate, OUT_DIR } from '../findings/findings.mjs';
32
32
  import { carry, strip, validate as validateNarrative } from './narrative.mjs';
33
33
 
34
34
  // Languages with source the scanner has no reader for, by extension.
35
- const UNREAD = { dart: 'Dart', swift: 'Swift', rb: 'Ruby', php: 'PHP', rs: 'Rust', scala: 'Scala', ex: 'Elixir', exs: 'Elixir',
35
+ const UNREAD = { swift: 'Swift', rb: 'Ruby', php: 'PHP', rs: 'Rust', scala: 'Scala', ex: 'Elixir', exs: 'Elixir',
36
36
  c: 'C', cc: 'C++', cpp: 'C++', m: 'Objective-C', mm: 'Objective-C', lua: 'Lua', clj: 'Clojure', hs: 'Haskell', erl: 'Erlang' };
37
37
 
38
38
  const SELF = fileURLToPath(import.meta.url);
@@ -43,6 +43,50 @@ const MAX_EV = 5;
43
43
  const load = (f) => JSON.parse(fs.readFileSync(path.join(HERE, 'rules', f), 'utf8'));
44
44
  const cmp = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
45
45
 
46
+ /**
47
+ * What is inside a module, for a reader who has not opened it: its folders and how many source files each holds.
48
+ * A folder holding nearly everything (lib/ of a Flutter app, src/ of a package) is opened one level, at most twice,
49
+ * so the rows say lib/data and lib/features and not just lib. Tests are counted apart. Twelve rows at most.
50
+ */
51
+ const MAX_ROWS = 12;
52
+
53
+ /**
54
+ * What a module says about itself: the first line of prose in its own README (a heading, a badge, an image, an
55
+ * HTML tag or a code fence is not prose). Kept apart from the evidence, which is cut at five lines, so a
56
+ * description always has a line to rest on that says what the module is for. Measured on a real project: four of
57
+ * ten modules were left undescribed because the only line the scan cited for them was the manifest's name.
58
+ */
59
+ function aboutOf(pj, m) {
60
+ const base = m.dir === '.' ? '' : `${m.dir}/`;
61
+ const f = pj.files.find((x) => x.startsWith(base) && /^readme(\.(md|markdown|txt|rst))?$/i.test(x.slice(base.length)));
62
+ const src = f && pj.read(f); if (!src) return null;
63
+ let fence = false;
64
+ for (const [i, raw] of src.lines.entries()) {
65
+ const l = raw.trim();
66
+ if (/^(```|~~~)/.test(l)) { fence = !fence; continue; }
67
+ if (fence || !l || /^(#|=+$|-+$|\[!|!\[|<|\||>\s*\[!)/.test(l)) continue;
68
+ return { evidence: [ev(f, i + 1, l.slice(0, 60))] };
69
+ }
70
+ return null;
71
+ }
72
+ function structure(m) {
73
+ const base = m.dir === '.' ? '' : `${m.dir}/`;
74
+ let rel = m.files.filter((f) => !isTestPath(f)).map((f) => f.slice(base.length));
75
+ const tests = m.files.length - rel.length;
76
+ let at = '';
77
+ const group = (list) => { const g = new Map(); for (const r of list) { const k = r.includes('/') ? r.split('/')[0] : ''; g.set(k, (g.get(k) ?? 0) + 1); } return g; };
78
+ for (let d = 0; d < 2 && rel.length; d++) {
79
+ const [top, n] = [...group(rel)].sort((a, b) => b[1] - a[1] || cmp(a[0], b[0]))[0];
80
+ if (!top || n / rel.length < 0.8 || !rel.some((r) => r.startsWith(`${top}/`) && r.split('/').length > 2)) break;
81
+ at += `${top}/`; rel = rel.filter((r) => r.startsWith(`${top}/`)).map((r) => r.slice(top.length + 1));
82
+ }
83
+ const rows = [...group(rel)].map(([k, n]) => ({ folder: k ? `${at}${k}` : (at.replace(/\/$/, '') || '.'), files: n }))
84
+ .sort((a, b) => b.files - a.files || cmp(a.folder, b.folder));
85
+ const shown = rows.slice(0, MAX_ROWS);
86
+ const rest = rows.slice(MAX_ROWS).reduce((s, r) => s + r.files, 0);
87
+ return { folders: shown, ...(rest ? { otherFiles: rest } : {}), testFiles: tests };
88
+ }
89
+
46
90
  // opts.hostFilter replaces the masking: ONLY the gate's must-fail twin uses it. No command-line flag reaches it.
47
91
  export function scan(root = '.', opts = {}) {
48
92
  const pj = openProject(root);
@@ -51,10 +95,12 @@ export function scan(root = '.', opts = {}) {
51
95
  const rules = load('packages.json'); const names = load('names.json');
52
96
  const stdlib = new Set(load('python-stdlib.json').modules);
53
97
  const st = scanStacks(pj);
98
+ applyTech(st, pj, load('tech.json'));
54
99
  const out = { edges: [], notMeasured: st.notMeasured, configUrls: [], codeHosts: new Set(), patterns };
55
100
  const ex = externals(pj, st, rules, hostOf, out);
56
101
  const cfg = configuration(pj, st, rules, hostOf, ex, out);
57
102
  codeUrls(pj, st, hostOf, ex, out);
103
+ servedCalls(pj, st, ex, out);
58
104
  const eps = endpoints(pj, st, out);
59
105
  const services = containers(pj, st, rules, hostOf, ex, cfg, out);
60
106
  const findings = detect({ st, cfg, eps, names, stdlib, notMeasured: out.notMeasured, configUrls: out.configUrls, codeHosts: out.codeHosts });
@@ -73,6 +119,9 @@ export function scan(root = '.', opts = {}) {
73
119
  const lang = UNREAD[/\.([a-z]+)$/i.exec(f)?.[1]?.toLowerCase()];
74
120
  if (lang && !isTestPath(f)) unread.set(lang, (unread.get(lang) ?? 0) + 1);
75
121
  }
122
+ // Dart is read through its pubspec.yaml: a .dart file no package owns has no module to be drawn in, so it is said.
123
+ const loose = pj.files.filter((f) => /\.dart$/.test(f) && !isTestPath(f) && !st.modules.some((m) => m.stack === 'dart' && m.files.includes(f))).length;
124
+ if (loose) out.notMeasured.push({ what: `Dart source outside any pubspec.yaml (${loose} file${loose === 1 ? '' : 's'})`, reason: 'no package owns it: it is not on the map' });
76
125
  for (const [lang, n] of unread) out.notMeasured.push({ what: `${lang} source (${n} file${n === 1 ? '' : 's'})`, reason: 'no reader for this language: its modules, dependencies and endpoints are not on the map' });
77
126
 
78
127
  // ---- one edge per (from, to, kind, confidence), its evidence merged ----
@@ -94,6 +143,8 @@ export function scan(root = '.', opts = {}) {
94
143
  // line to cite: the entry is then the file, and it says so.
95
144
  ...(m.entry ? { entryPoint: m.entryEv ? { evidence: [m.entryEv] } : { file: m.entryFile, evidence: [] } } : {}),
96
145
  dependencies: m.deps.length,
146
+ structure: structure(m),
147
+ ...((() => { const a = aboutOf(pj, m); return a ? { about: a } : {}; })()),
97
148
  ...capped(m.evidence), narrative: null })),
98
149
  ...services.map((s) => ({ id: s.id, kind: 'service', name: s.name, ...(s.image ? { image: s.image } : {}), ...capped(s.evidence), narrative: null })),
99
150
  ...[...ex.ext.values()].map((n) => ({ id: n.id, kind: 'external', resource: n.resource, label: n.label, hosts: [...n.hosts].sort(), ...capped(n.evidence), narrative: null })),
@@ -122,6 +173,7 @@ export function scan(root = '.', opts = {}) {
122
173
  findings,
123
174
  notMeasured: [...nm.values()].sort((a, b) => cmp(`${a.what}\u0000${a.reason}`, `${b.what}\u0000${b.reason}`)),
124
175
  overview: null,
176
+ tour: null,
125
177
  };
126
178
  // The last net, whatever cited it: a quote is cut before anything that looks like a credential, and a line that
127
179
  // carries values (by its file, or by what is on it) is never hashed.
@@ -1,5 +1,5 @@
1
1
  // stacks.mjs — modules, their declared dependencies, their imports and their references to each other, for the
2
- // five stacks. Every fact carries the line it was read from; what cannot be read is returned as notMeasured.
2
+ // six stacks. Every fact carries the line it was read from; what cannot be read is returned as notMeasured.
3
3
  //
4
4
  // A module is { id, name, stack, dir, manifest, tech[], evidence[], deps[], refs[], imports[], entry, entryEv,
5
5
  // aliases[], selfNames[], files[] }.
@@ -10,7 +10,7 @@
10
10
  // (a namespace prefix, a Gradle line read by pattern).
11
11
 
12
12
  import path from 'node:path';
13
- import { parseJsonc, parseToml, parseXml, xKids, xKid, xText, xLine, xFind, Unsupported } from './parse.mjs';
13
+ import { parseJsonc, parseToml, parseXml, parseYaml, yGet, xKids, xKid, xText, xLine, xFind, Unsupported } from './parse.mjs';
14
14
  import { ev, evFirst, lineOf, isTestPath } from './project.mjs';
15
15
 
16
16
  const P = path.posix;
@@ -20,7 +20,7 @@ const esc = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
20
20
  const isComment = (l) => /^\s*(\/\/|#|\*|\/\*|<!--)/.test(l);
21
21
 
22
22
  export const SOURCE_EXT = {
23
- node: /\.(m?[jt]sx?|cjs|cts|mts)$/, python: /\.py$/, go: /\.go$/, dotnet: /\.cs$/, java: /\.(java|kt)$/,
23
+ node: /\.(m?[jt]sx?|cjs|cts|mts)$/, python: /\.py$/, go: /\.go$/, dotnet: /\.cs$/, java: /\.(java|kt)$/, dart: /\.dart$/,
24
24
  };
25
25
 
26
26
  function mod(stack, name, manifest, dir) {
@@ -28,6 +28,22 @@ function mod(stack, name, manifest, dir) {
28
28
  imports: [], entry: false, entryEv: null, aliases: [], selfNames: [name], files: [] };
29
29
  }
30
30
 
31
+ /**
32
+ * What each module is built with, from rules/tech.json: a package its manifest declares, or a file at its root.
33
+ * Labels are words a newcomer reads (Hono, Cloudflare Workers, Flutter), not versions; frameworks before tools, in
34
+ * the order of the rules. Before, only nine npm frameworks were named and a Node module read as "node".
35
+ */
36
+ export function applyTech(st, pj, rules) {
37
+ const has = new Set(pj.files);
38
+ for (const m of st.modules) {
39
+ const deps = new Set(m.deps.map((d) => `${d.eco}\t${d.name}`));
40
+ for (const [eco, list] of Object.entries(rules?.packages ?? {})) for (const [name, label] of list) if (deps.has(`${eco}\t${name}`)) m.tech.add(label);
41
+ const base = m.dir === '.' ? '' : `${m.dir}/`;
42
+ for (const [file, label] of rules?.files ?? []) if (has.has(`${base}${file}`)) m.tech.add(label);
43
+ if (m.stack === 'node' && !m.tech.has('TypeScript') && m.files.some((f) => /\.(ts|tsx|mts|cts)$/.test(f))) m.tech.add('TypeScript');
44
+ }
45
+ }
46
+
31
47
  // A version the map may show. A dependency can be declared by URL (git+https://token@host/repo.git#v1), and a URL
32
48
  // is never copied out: it reads as 'url', which no detector compares.
33
49
  export function cleanVersion(v) {
@@ -48,7 +64,7 @@ function runnable(m) {
48
64
 
49
65
  // Hand each source file to the deepest module of its stack whose directory holds it.
50
66
  // A file under a sample project's own manifest (left out of discovery) belongs to that sample, so to nobody here.
51
- const MANIFEST = /(^|\/)(package\.json|pyproject\.toml|setup\.cfg|requirements[^/]*\.txt|go\.mod|[^/]+\.(cs|fs|vb)proj|pom\.xml|build\.gradle(\.kts)?|settings\.gradle(\.kts)?)$/;
67
+ const MANIFEST = /(^|\/)(package\.json|pyproject\.toml|setup\.cfg|requirements[^/]*\.txt|go\.mod|[^/]+\.(cs|fs|vb)proj|pom\.xml|build\.gradle(\.kts)?|settings\.gradle(\.kts)?|pubspec\.yaml)$/;
52
68
  function own(modules, files, stack) {
53
69
  const mine = modules.filter((m) => m.stack === stack).sort((a, b) => b.dir.length - a.dir.length);
54
70
  const samples = files.filter((f) => MANIFEST.test(f) && isTestPath(f)).map(dirOf);
@@ -76,9 +92,6 @@ function nodeStack(pj, out) {
76
92
  if (!v[sect] || typeof v[sect] !== 'object') continue;
77
93
  for (const [dn, ver] of Object.entries(v[sect])) m.deps.push({ eco: 'npm', name: dn, version: cleanVersion(ver), dev, peer: sect === 'peerDependencies', ev: ev(f, L([sect, dn]), dn) });
78
94
  }
79
- for (const fw of ['next', 'react', 'vue', 'svelte', '@angular/core', 'express', 'fastify', '@nestjs/core', 'vite']) {
80
- const d = m.deps.find((x) => x.name === fw && x.version !== 'url'); if (d) m.tech.add(`${fw}@${d.version.replace(/^[\^~>=<\s]+/, '')}`);
81
- }
82
95
  if (v.bin) { m.entry = true; m.entryEv = ev(f, L(['bin']), 'bin'); }
83
96
  else if (v.scripts && (v.scripts.start || v.scripts.dev)) { const k = v.scripts.start ? 'start' : 'dev'; m.entry = true; m.entryEv = ev(f, L(['scripts', k]), k); }
84
97
  else if (v.workspaces) { m.entry = true; m.entryEv = ev(f, L(['workspaces']), 'workspaces'); }
@@ -265,6 +278,98 @@ function goStack(pj, out) {
265
278
  }
266
279
  }
267
280
 
281
+ // ---- Dart / Flutter -------------------------------------------------------------------------------------------
282
+ // One module per pubspec.yaml. A `path:` dependency names another module of this repository (a reference); a
283
+ // `sdk: flutter` dependency makes it a Flutter module. Its .dart files import `package:<name>/…`: another module's
284
+ // name is a reference, anything else a package it uses; `dart:` is the SDK. lib/main.dart or bin/ is an entry, and
285
+ // so is a tool/ script with a main() (`dart run tool/x.dart` is how a package's own command is run).
286
+ // Before this reader, a Flutter repository's app and its packages were not on the map at all (measured on a real
287
+ // project: 5 Dart modules, 949 .dart files, none drawn).
288
+ function dartStack(pj, out) {
289
+ const made = [];
290
+ for (const f of pj.manifests.filter((x) => P.basename(x) === 'pubspec.yaml')) {
291
+ const src = pj.read(f); if (!src) continue;
292
+ let y; try { y = parseYaml(src.text); } catch (e) { out.notMeasured.push({ what: f, reason: `pubspec.yaml outside the subset this scanner reads: ${e.message}` }); continue; }
293
+ const nm = yGet(y, 'name');
294
+ if (!nm || nm.v.t !== 'str' || !nm.v.v) { out.notMeasured.push({ what: f, reason: 'pubspec.yaml has no name' }); continue; }
295
+ const m = mod('dart', nm.v.v, f, dirOf(f));
296
+ m.evidence.push(ev(f, nm.line, nm.v.v));
297
+ const paths = [];
298
+ for (const [key, dev] of [['dependencies', false], ['dev_dependencies', true]]) {
299
+ const block = yGet(y, key); if (!block || block.v.t !== 'map') continue;
300
+ for (const d of block.v.e) {
301
+ const e = ev(f, d.line, d.k);
302
+ if (d.v.t === 'map') {
303
+ const sdk = yGet(d.v, 'sdk'); const pth = yGet(d.v, 'path');
304
+ if (sdk && sdk.v.t === 'str') { if (sdk.v.v === 'flutter' && d.k === 'flutter') m.tech.add('Flutter'); continue; }
305
+ if (pth && pth.v.t === 'str') { paths.push({ to: P.normalize(P.join(m.dir === '.' ? '' : m.dir, pth.v.v)), name: d.k, ev: e }); continue; }
306
+ const v = yGet(d.v, 'version'); m.deps.push({ eco: 'pub', name: d.k, version: v && v.v.t === 'str' ? cleanVersion(v.v.v) : '', dev, ev: e });
307
+ } else {
308
+ m.deps.push({ eco: 'pub', name: d.k, version: d.v.t === 'str' ? cleanVersion(d.v.v) : '', dev, ev: e });
309
+ }
310
+ }
311
+ }
312
+ if (yGet(y, 'flutter')) m.tech.add('Flutter');
313
+ m._paths = paths;
314
+ out.modules.push(m); made.push(m);
315
+ }
316
+ if (!made.length) return;
317
+ own(out.modules, pj.files, 'dart');
318
+ const byName = new Map(made.map((m) => [m.name, m]));
319
+ for (const m of made) {
320
+ for (const p of m._paths) {
321
+ const t = made.find((o) => o.dir === p.to) ?? byName.get(p.name);
322
+ if (t && t !== m) m.refs.push({ to: t.id, toMod: t, ev: p.ev, confidence: 'evidence' });
323
+ }
324
+ delete m._paths;
325
+ for (const f of m.files) {
326
+ if (/(^|\/)(lib\/main\.dart|bin\/[^/]+\.dart)$/.test(f.slice(m.dir === '.' ? 0 : m.dir.length + 1)) && !m.entry) {
327
+ m.entry = true; m.entryEv = evFirst(f, pj.read(f)?.lines ?? []);
328
+ }
329
+ if (!m.entry && /^tool\/[^/]+\.dart$/.test(f.slice(m.dir === '.' ? 0 : m.dir.length + 1))) {
330
+ const i = (pj.read(f)?.lines ?? []).findIndex((l) => /^\s*(?:Future<void>|void)\s+main\s*\(/.test(l));
331
+ if (i >= 0) { m.entry = true; m.entryEv = ev(f, i + 1, 'main'); }
332
+ }
333
+ const src = pj.read(f); if (!src) continue;
334
+ src.lines.forEach((l, i) => {
335
+ const x = /^\s*(?:import|export)\s+['"]package:([A-Za-z0-9_]+)\/[^'"]*['"]/.exec(l);
336
+ if (!x) return;
337
+ const t = byName.get(x[1]);
338
+ if (t) { if (t !== m && !m.refs.some((r) => r.toMod === t)) m.refs.push({ to: t.id, toMod: t, ev: ev(f, i + 1, x[1]), confidence: 'evidence' }); return; }
339
+ if (x[1] === m.name) return;
340
+ m.imports.push({ spec: x[1], root: x[1], ev: ev(f, i + 1, x[1]), file: f });
341
+ });
342
+ }
343
+ }
344
+ // Flutter declares packages nothing imports (cupertino_icons is a font, flutter_lints a lint set, build_runner a
345
+ // command), so an unused-package rule would mostly be wrong here: it is not run, and that is said.
346
+ if (out.modules.some((m) => m.stack === 'dart')) out.notMeasured.push({ what: 'Dart package usage (dep-unused, dep-undeclared)', reason: 'Flutter declares packages that no file imports (fonts, lint sets, code generators), so a declared package is not proven unused' });
347
+ }
348
+
349
+ // A Flutter app holds its platforms as folders of their own: android/ is a Gradle project, ios/ an Xcode one. They
350
+ // are how the app is built for each platform, not modules of the repository, so they are folded into the app: its
351
+ // tech says Android / iOS, and the Gradle modules under it are not drawn on their own (measured on a real project:
352
+ // two "unplaced" Gradle modules beside a Flutter app the map did not show).
353
+ function foldPlatforms(pj, out) {
354
+ const apps = out.modules.filter((m) => m.stack === 'dart' && m.tech.has('Flutter'));
355
+ if (!apps.length) return;
356
+ const drop = new Set();
357
+ for (const a of apps) {
358
+ const base = a.dir === '.' ? '' : `${a.dir}/`;
359
+ for (const m of out.modules) {
360
+ if (m === a || m.stack !== 'java') continue;
361
+ if (m.dir === `${base}android` || m.dir.startsWith(`${base}android/`)) { drop.add(m); a.tech.add('Android'); }
362
+ }
363
+ if (pj.files.some((f) => f.startsWith(`${base}ios/`))) a.tech.add('iOS');
364
+ }
365
+ if (!drop.size) return;
366
+ out.modules = out.modules.filter((m) => !drop.has(m));
367
+ for (const m of out.modules) m.refs = m.refs.filter((r) => !drop.has(r.toMod));
368
+ // The Gradle notes were about those folders; with no Java module left on the map they speak of nothing.
369
+ if (!out.modules.some((m) => m.stack === 'java')) out.notMeasured = out.notMeasured.filter((x) => !JAVA_NOTES.has(x.what));
370
+ }
371
+ const JAVA_NOTES = new Set(['Gradle build logic', 'Java/Kotlin package usage (dep-unused, dep-undeclared)']);
372
+
268
373
  // ---- .NET ---------------------------------------------------------------------------------------------------
269
374
  function dotnetStack(pj, out) {
270
375
  const central = new Map();
@@ -425,12 +530,13 @@ export function scanStacks(pj0) {
425
530
  // Its source files still belong to the module that holds them (a test imports that module's dependencies).
426
531
  const pj = { ...pj0, manifests: pj0.files.filter((f) => !isTestPath(f)) };
427
532
  const out = { modules: [], notMeasured: [], globalAliases: [] };
428
- for (const fn of [nodeStack, pythonStack, goStack, dotnetStack, javaStack]) {
533
+ for (const fn of [nodeStack, pythonStack, goStack, dartStack, dotnetStack, javaStack]) {
429
534
  try { fn(pj, out); } catch (e) {
430
535
  if (!(e instanceof Unsupported)) throw e;
431
536
  out.notMeasured.push({ what: fn.name, reason: e.message });
432
537
  }
433
538
  }
539
+ foldPlatforms(pj, out);
434
540
  // Two manifests with one name (a copy, a fork in a subdirectory) would share an id: the directory tells them apart.
435
541
  const count = new Map();
436
542
  for (const m of out.modules) count.set(m.id, (count.get(m.id) || 0) + 1);
@@ -117,7 +117,7 @@ export function configuration(pj, st, rules, hostOf, ex, out) {
117
117
  src.lines.forEach((l, i) => {
118
118
  const m = /^\s*(ENV|ARG)\s+([A-Za-z_][A-Za-z0-9_]*)/i.exec(l); if (m) def(m[2], f, i + 1, 'dockerfile');
119
119
  const from = /^\s*FROM\s+(?:--platform=\S+\s+)?(\S+)/i.exec(l);
120
- if (from) { const o = ownerOf(st.modules, f); if (o) o.tech.add(`image:${from[1]}`); }
120
+ if (from) { const o = ownerOf(st.modules, f); if (o) o.tech.add('Docker'); } // the image itself is the line's evidence
121
121
  });
122
122
  }
123
123
  }
@@ -186,6 +186,52 @@ export function codeUrls(pj, st, hostOf, ex, out) {
186
186
  }
187
187
  }
188
188
 
189
+ // ---- one module calling another at the address it is served on -----------------------------------------------
190
+ // A module says where it is served in its own deployment file: wrangler routes and custom domains (Cloudflare
191
+ // Workers), the app of fly.toml (<app>.fly.dev). Another module that writes that host, quoted on its own or as the
192
+ // host of a URL, calls it: an app that builds Uri(host: 'api.x.app') never spells a URL, and before this a call
193
+ // to the project's own server was drawn as an external API, or not at all.
194
+ const SERVED_FILES = /(^|\/)(wrangler\.toml|wrangler\.jsonc?|fly\.toml)$/;
195
+ export function servedCalls(pj, st, ex, out) {
196
+ const served = new Map(); // host -> module
197
+ for (const m of st.modules) {
198
+ for (const f of pj.files.filter((x) => SERVED_FILES.test(x) && dirOf(x) === m.dir)) {
199
+ const src = pj.read(f); if (!src) continue;
200
+ src.lines.forEach((l, i) => {
201
+ if (isComment(l)) return;
202
+ const hosts = [];
203
+ for (const x of l.matchAll(/["']?(?:pattern|route)["']?\s*[=:]\s*["']([^"']+)["']/g)) hosts.push(x[1]);
204
+ if (/fly\.toml$/.test(f)) { const a = /^\s*app\s*=\s*["']([a-z0-9-]+)["']/.exec(l); if (a) hosts.push(`${a[1]}.fly.dev`); }
205
+ for (const raw of hosts) {
206
+ const h = raw.replace(/^https?:\/\//, '').replace(/^\*\.?/, '').split('/')[0].toLowerCase();
207
+ if (!/^[a-z0-9-]+(\.[a-z0-9-]+)+$/.test(h) || isLocalHost(h) || served.has(h)) continue;
208
+ served.set(h, m); m.evidence.push(ev(f, i + 1, raw));
209
+ }
210
+ });
211
+ }
212
+ }
213
+ out.served = served;
214
+ if (!served.size) return;
215
+ for (const [h, to] of served) {
216
+ const q = h.replace(/[.]/g, '\\.');
217
+ const re = new RegExp(`(["'\`])${q}\\1|://${q}(?=[/:"'\`]|$)`);
218
+ for (const m of st.modules) {
219
+ if (m === to) continue;
220
+ for (const f of m.files) {
221
+ if (isTestPath(f)) continue;
222
+ const src = pj.read(f); if (!src) continue;
223
+ src.lines.forEach((l, i) => { if (!isComment(l) && re.test(l)) out.edges.push({ from: m.id, to: to.id, kind: 'calls', confidence: 'evidence', via: 'code', evidence: [ev(f, i + 1, h)] }); });
224
+ }
225
+ }
226
+ // The host was read as an external API: it is the project's own module, so its edges go there and the node goes.
227
+ const id = `ext:http-api:${h}`;
228
+ if (!ex.ext.has(id)) continue;
229
+ for (const e of out.edges) if (e.to === id) { e.to = to.id; e.kind = 'calls'; }
230
+ out.edges = out.edges.filter((e) => e.from !== e.to);
231
+ ex.ext.delete(id);
232
+ }
233
+ }
234
+
189
235
  // ---- endpoints ----------------------------------------------------------------------------------------------
190
236
  // A path is compared in one spelling: lower case, one leading slash, no trailing slash, every parameter ({id},
191
237
  // :id, ${id}, <int:id>, {id:int}) as {}. A path whose start was built at run time (a variable before the first
@@ -322,6 +368,21 @@ export function endpoints(pj, st, out) {
322
368
  if (axa && axa.s && !/^https?:/.test(axa.s)) add(consumes, m, ax[2], axa.s, f, axa.at, axa.s);
323
369
  });
324
370
  }
371
+ // Dart consumers: a Dio client's relative path, and a full URL (http.get(Uri.parse(…)), Uri.https(host, path))
372
+ // only when its host is one this project serves; any other host is someone else's API. `$id` is a parameter.
373
+ if (/\.dart$/.test(f)) {
374
+ const ours = (h) => out.served?.has(h.toLowerCase());
375
+ const dp = (s) => s.replace(/\$\{[^}]*\}|\$\w+/g, '{x}');
376
+ L.forEach((l, i) => {
377
+ if (isComment(l)) return;
378
+ const dio = /\b(\w*[dD]io\w*|_?client|_?api)\.(get|post|put|patch|delete)(?:<[^()]*?>)?\(\s*(['"])(\/[^'"]*)\3/.exec(l);
379
+ if (dio) add(consumes, m, dio[2], dp(dio[4]), f, i, dio[4]);
380
+ const hp = /\.(get|post|put|patch|delete)\(\s*Uri\.parse\(\s*(['"])https?:\/\/([^/'"$]+)(\/[^'"]*)\2/.exec(l);
381
+ if (hp && ours(hp[3])) add(consumes, m, hp[1], dp(hp[4]), f, i, hp[4]);
382
+ const uh = /\bUri\.https?\(\s*(['"])([^'"$]+)\1\s*,\s*(['"])(\/[^'"]*)\3/.exec(l);
383
+ if (uh && ours(uh[2])) add(consumes, m, 'GET', dp(uh[4]), f, i, uh[4]);
384
+ });
385
+ }
325
386
  // Python: FastAPI / Flask / Django providers; requests / httpx consumers
326
387
  if (/\.py$/.test(f)) {
327
388
  const pre = new Map();
@@ -0,0 +1,52 @@
1
+ # Sourced, not run. The ONE rule for where the gate log is, for the gates that write it and everything that reads it
2
+ # (the status line, gate-report, doctor). Studio's reader (server/lib/kit-telemetry.js) follows the same order.
3
+ #
4
+ # 1. CREW_GATE_LOG, when set: used as given (/dev/null turns recording off).
5
+ # 2. .claude/gate-log.tsv, outside a git repository or where git ignores it (a full install ignores .claude/).
6
+ # 3. Otherwise <git dir>/crewforth-gate-log.tsv: inside the repository's own .git, which is never committed.
7
+ # Before 3.2.0 the log was simply not written there, so a project that tracks .claude/ had no record of a
8
+ # refusal, no 🛡 on its status line and no note of a model the gate lowered (seen on a real project).
9
+ #
10
+ # Builtins only on the reading side, which the status line runs every few seconds. The writing side asks git
11
+ # (one or two processes), and only when a decision is being logged.
12
+
13
+ # The repository's git directory, from the project root up, without a process: _CREW_GD, or rc 1.
14
+ crew_git_dir(){
15
+ local d="${1:-.}" n=0 l
16
+ _CREW_GD=""
17
+ while [ "$n" -lt 12 ]; do
18
+ if [ -d "$d/.git" ]; then _CREW_GD="$d/.git"; return 0; fi
19
+ if [ -f "$d/.git" ]; then
20
+ IFS= read -r l < "$d/.git" 2>/dev/null || l=""
21
+ case "$l" in "gitdir: "*) l="${l#gitdir: }"; l="${l%$'\r'}"
22
+ case "$l" in /*|[A-Za-z]:*) ;; *) l="$d/$l" ;; esac
23
+ [ -d "$l" ] && { _CREW_GD="$l"; return 0; } ;; esac
24
+ return 1
25
+ fi
26
+ [ "$d" = / ] && return 1
27
+ d="$d/.."; n=$((n+1))
28
+ done
29
+ return 1
30
+ }
31
+
32
+ # Where a gate writes: _CREW_GL ("" = nowhere). Run from the project root.
33
+ crew_gate_log_write(){
34
+ _CREW_GL=""
35
+ if [ -n "${CREW_GATE_LOG:-}" ]; then _CREW_GL="$CREW_GATE_LOG"; return 0; fi
36
+ [ -d .claude ] || return 0
37
+ if ! git rev-parse --is-inside-work-tree >/dev/null 2>&1 || git check-ignore -q .claude/gate-log.tsv 2>/dev/null; then
38
+ _CREW_GL=.claude/gate-log.tsv; return 0
39
+ fi
40
+ local g; g="$(git rev-parse --absolute-git-dir 2>/dev/null)" || g=""
41
+ [ -n "$g" ] && [ -d "$g" ] && _CREW_GL="$g/crewforth-gate-log.tsv"
42
+ return 0
43
+ }
44
+
45
+ # Where a reader finds it: _CREW_GL ("" = none yet). No process.
46
+ crew_gate_log_read(){
47
+ _CREW_GL=""
48
+ if [ -n "${CREW_GATE_LOG:-}" ]; then _CREW_GL="$CREW_GATE_LOG"; return 0; fi
49
+ if [ -f .claude/gate-log.tsv ]; then _CREW_GL=.claude/gate-log.tsv; return 0; fi
50
+ crew_git_dir . && [ -f "$_CREW_GD/crewforth-gate-log.tsv" ] && _CREW_GL="$_CREW_GD/crewforth-gate-log.tsv"
51
+ return 0
52
+ }
@@ -0,0 +1,27 @@
1
+ # json-string.awk — the text on stdin as the inside of a JSON string, printed with no quotes around it and no newline
2
+ # after it. One escaper for every hook that puts dynamic text into its JSON (board-sync.sh, session-rehydrate.sh).
3
+ #
4
+ # printf '%s\n' "$MSG" | LC_ALL=C awk -f json-string.awk
5
+ #
6
+ # jq-identical, measured on 15 inputs (quote, backslash, tab, CR, C0 controls, DEL, UTF-8, leading/trailing
7
+ # newlines, 5000 chars): 15/15 equal to `jq -cn --arg m`. The one it replaced escaped only the quote, the backslash
8
+ # and the newline, so a tab in a board item produced JSON the CLI cannot parse (jq rc=5) and the board silently
9
+ # vanished from the session. The trailing newline the caller prints closes the last record, so a value ending in a
10
+ # newline keeps it. A CR that ENDS a line is dropped on purpose: it is a CRLF line ending, not content, and MSYS gawk
11
+ # already drops it on read (text mode) while BSD awk keeps it, measured on Windows; stripping it here makes every OS
12
+ # emit the same bytes. A CR inside a line is still escaped as \r.
13
+ BEGIN { ORS = ""
14
+ for (i = 1; i < 32; i++) ctl[i] = sprintf("%c", i)
15
+ nm[8] = "\\b"; nm[9] = "\\t"; nm[12] = "\\f"; nm[13] = "\\r" }
16
+ NR > 1 { print "\\n" }
17
+ { s = $0; sub(/\r$/, "", s)
18
+ gsub(/\\/, "\\\\", s); gsub(/"/, "\\\"", s)
19
+ for (i = 1; i < 32; i++) {
20
+ if (i == 10 || !index(s, ctl[i])) continue
21
+ r = (i in nm) ? nm[i] : sprintf("\\u%04x", i)
22
+ out = ""; while ((p = index(s, ctl[i])) > 0) { out = out substr(s, 1, p - 1) r; s = substr(s, p + 1) }
23
+ s = out s
24
+ }
25
+ d = sprintf("%c", 127)
26
+ if (index(s, d)) { out = ""; while ((p = index(s, d)) > 0) { out = out substr(s, 1, p - 1) "\\u007f"; s = substr(s, p + 1) }; s = out s }
27
+ print s }
@@ -0,0 +1,109 @@
1
+ #!/usr/bin/env bash
2
+ # preset.sh — an organisation's settings for Crewforth in one file, applied to a project.
3
+ #
4
+ # bash preset.sh --apply <name|file.json> from the project root; a name is one of eval/lib/presets/
5
+ # bash preset.sh --check <name|file.json> is it a valid preset? Nothing is written (the installers ask first)
6
+ # bash preset.sh --show the preset in force here, or "none"
7
+ # bash preset.sh --list the presets that ship
8
+ #
9
+ # A preset is plain JSON (see presets/default.json for every key):
10
+ # critical.add / critical.remove paths made critical, or not (globs, as `+ glob` / `- glob`)
11
+ # floors [agent, change, risk, model]: a model a class of work runs on at least
12
+ # verify commands the gate may run as a verify command
13
+ # lang en | tr, the language of what Crewforth says (only when the project has none)
14
+ #
15
+ # Where it goes: .claude/crew-model-rules, the file every reader of these rules already reads (the model gate,
16
+ # agent-outcome's scan, doctor, Studio), as one marked block at the top. The project's own lines stay below it, so
17
+ # the rule is the file's own: for a critical path the last line that matches wins, so a project's `- glob` undoes a
18
+ # preset's `+ glob`; a floor only ever raises, so a preset's floor is a minimum a project can raise and not lower.
19
+ # Applying again replaces the block and nothing else; the same preset twice gives the same bytes.
20
+ #
21
+ # rc 0 = applied (or shown) · 1 = the preset is not valid, or names nothing; nothing was written · 2 = usage
22
+ # Every JSON read is Crewforth's own reader (settings-json.awk): no jq, no python.
23
+ set -u
24
+ _here="${BASH_SOURCE%[/\\]*}"; [ "$_here" = "${BASH_SOURCE}" ] && _here=.
25
+ SJ="$_here/settings-json.awk"; DIR="$_here/presets"
26
+ RULES=.claude/crew-model-rules; CONF=.claude/kit.conf
27
+ BEGIN_MARK='# ---- preset:'; END_MARK='# ---- end of preset (lines below are this project'"'"'s own and win over it, see preset.sh) ----'
28
+
29
+ die(){ echo "preset: $*" >&2; exit 1; }
30
+ jget(){ awk -v op=get -v path="$1" -f "$SJ" "$2" 2>/dev/null; }
31
+ jstrings(){ awk -v op=strings -v path="$1" -f "$SJ" "$2" 2>/dev/null; }
32
+ jlen(){ local n; n="$(awk -v op=len -v path="$1" -f "$SJ" "$2" 2>/dev/null)"; printf '%s' "${n:-0}"; }
33
+
34
+ case "${1:-}" in
35
+ --list) for f in "$DIR"/*.json; do [ -f "$f" ] || continue; n="${f##*/}"; printf '%s\n' "${n%.json}"; done; exit 0 ;;
36
+ --show)
37
+ [ -f "$RULES" ] || { echo none; exit 0; }
38
+ while IFS= read -r l || [ -n "$l" ]; do case "$l" in "$BEGIN_MARK "*) l="${l#"$BEGIN_MARK "}"; echo "${l%% *}"; exit 0 ;; esac; done < "$RULES"
39
+ echo none; exit 0 ;;
40
+ --apply|--check) [ -n "${2:-}" ] || { echo "usage: preset.sh --apply|--check <name|file.json> | --show | --list" >&2; exit 2; } ;;
41
+ *) echo "usage: preset.sh --apply|--check <name|file.json> | --show | --list" >&2; exit 2 ;;
42
+ esac
43
+ MODE="$1"
44
+ [ "$MODE" = --check ] || [ -d .claude ] || die "no .claude/ here: run it from the root of a project Crewforth is installed in"
45
+
46
+ # ---- which file ----
47
+ P="$2"
48
+ case "$P" in *.json|*/*) ;; *) P="$DIR/$P.json" ;; esac
49
+ [ -f "$P" ] || die "no preset at $P (the ones that ship: $(bash "$0" --list 2>/dev/null | tr '\n' ' '))"
50
+ awk -v op=validate -f "$SJ" "$P" 2>/dev/null || die "$P is not a JSON object"
51
+ NAME="$(jget name "$P")"; NAME="${NAME#\"}"; NAME="${NAME%\"}"
52
+ case "$NAME" in ''|*[!a-z0-9-]*) die "$P: name must be lower case letters, digits and -" ;; esac
53
+
54
+ # ---- what it says, checked before anything is written ----
55
+ LINES=()
56
+ for k in add remove; do
57
+ sign='+'; [ "$k" = remove ] && sign='-'
58
+ while IFS= read -r g; do
59
+ [ -n "$g" ] || continue
60
+ case "$g" in *[[:space:]\#]*) die "$P: critical.$k holds '$g', which has a space or a #" ;; esac
61
+ LINES+=("$sign $g")
62
+ done < <(jstrings "critical.$k" "$P")
63
+ done
64
+ nf="$(jlen floors "$P")"; i=0
65
+ while [ "$i" -lt "$nf" ]; do
66
+ f=(); while IFS= read -r w; do f+=("$w"); done < <(jstrings "floors.$i" "$P")
67
+ [ "${#f[@]}" = 4 ] || die "$P: floors[$i] is not [agent, change, risk, model]"
68
+ case "${f[3]}" in haiku|sonnet|opus) ;; *) die "$P: floors[$i] names the model '${f[3]}', not haiku, sonnet or opus" ;; esac
69
+ for w in "${f[@]}"; do case "$w" in ''|*[[:space:]\#]*) die "$P: floors[$i] has an empty word, a space or a #" ;; esac; done
70
+ LINES+=("floor ${f[0]} ${f[1]} ${f[2]} ${f[3]}"); i=$((i+1))
71
+ done
72
+ while IFS= read -r c; do
73
+ [ -n "$c" ] || continue
74
+ case "$c" in *[\;\&\|\<\>\`\$\#]*) die "$P: verify '$c' has a shell operator or a #; the gate runs a verify command as one command" ;; esac
75
+ LINES+=("verify $c")
76
+ done < <(jstrings verify "$P")
77
+ LANGV="$(jget lang "$P")"; LANGV="${LANGV#\"}"; LANGV="${LANGV%\"}"
78
+ case "$LANGV" in ''|null|en|tr) ;; *) die "$P: lang is '$LANGV', not en or tr" ;; esac
79
+ for k in $(awk -v op=keys -f "$SJ" "$P" 2>/dev/null); do
80
+ case "$k" in about|name|critical|floors|verify|lang) ;; *) die "$P: unknown key '$k' (about, name, critical, floors, verify, lang)" ;; esac
81
+ done
82
+
83
+ [ "$MODE" = --check ] && { echo "preset: $NAME is valid — ${#LINES[@]} line(s)"; exit 0; }
84
+
85
+ # ---- the block, at the top of the rules file; the project's own lines kept below it ----
86
+ OWN=""
87
+ if [ -f "$RULES" ]; then
88
+ skip=0
89
+ while IFS= read -r l || [ -n "$l" ]; do
90
+ case "$l" in "$BEGIN_MARK "*) skip=1; continue ;; "$END_MARK") skip=0; continue ;; esac
91
+ [ "$skip" = 1 ] || OWN+="$l"$'\n'
92
+ done < "$RULES"
93
+ fi
94
+ T="$RULES.preset.$$"
95
+ {
96
+ printf '%s %s (written by preset.sh; apply again to change it, edit your own lines below) ----\n' "$BEGIN_MARK" "$NAME"
97
+ for l in ${LINES[@]+"${LINES[@]}"}; do printf '%s\n' "$l"; done
98
+ printf '%s\n' "$END_MARK"
99
+ printf '%s' "$OWN"
100
+ } > "$T" && mv -f "$T" "$RULES" || { rm -f "$T"; die "could not write $RULES"; }
101
+
102
+ # ---- kit.conf: the preset's language, only where the project has none. Which preset is in force is said once, by
103
+ # the block's own first line (--show reads it): kit.conf is rewritten by every update, the rules file is not.
104
+ if [ -f "$CONF" ] && case "$LANGV" in en|tr) true ;; *) false ;; esac; then
105
+ hadlang=0; while IFS= read -r l || [ -n "$l" ]; do case "$l" in lang=*) hadlang=1 ;; esac; done < "$CONF"
106
+ [ "$hadlang" = 0 ] && printf 'lang=%s\n' "$LANGV" >> "$CONF"
107
+ fi
108
+ echo "preset: $NAME applied — ${#LINES[@]} line(s) in $RULES; your own lines below the block were kept"
109
+ exit 0
@@ -0,0 +1,7 @@
1
+ {
2
+ "about": "Crewforth as it ships: no line of its own. Every key a preset may hold is here, empty. critical.add and critical.remove are globs made critical or not (a critical path runs on opus and only an agent on opus writes there); floors are [agent, change, risk, model] with * for any, a model a class of work runs on at least; verify are commands the model gate may run as a card's verify command; lang (en or tr) is used only where the project has no language yet.",
3
+ "name": "default",
4
+ "critical": { "add": [], "remove": [] },
5
+ "floors": [],
6
+ "verify": []
7
+ }
@@ -0,0 +1,7 @@
1
+ {
2
+ "about": "For one person on their own project: the common test commands may run as a card's verify command, so cheap work can be checked and run on haiku; no path is added.",
3
+ "name": "solo",
4
+ "critical": { "add": [], "remove": [] },
5
+ "floors": [],
6
+ "verify": ["npm test", "pnpm test", "yarn test", "pytest", "go test ./...", "cargo test", "dotnet test", "flutter test"]
7
+ }
@@ -0,0 +1,11 @@
1
+ {
2
+ "about": "For a team that ships to production from this repository: what deploys it is critical too, and the referees of the work run on opus whatever the risk.",
3
+ "name": "strict",
4
+ "critical": { "add": ["infra", "deploy", "terraform", "k8s", "helm", ".github/workflows"], "remove": [] },
5
+ "floors": [
6
+ ["crew-review-agent", "*", "*", "opus"],
7
+ ["*", "test-write", "*", "opus"],
8
+ ["*", "audit", "*", "opus"]
9
+ ],
10
+ "verify": []
11
+ }