great-cto 3.60.0 → 3.62.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 (43) hide show
  1. package/board/.claude-plugin/plugin.json +23 -1
  2. package/board/packages/board/lib/config.mjs +11 -4
  3. package/board/packages/board/lib/data-readers.mjs +4 -6
  4. package/board/packages/board/lib/projects.mjs +48 -17
  5. package/board/packages/board/lib/routes.mjs +2 -1
  6. package/board/packages/board/push-adapter.mjs +3 -3
  7. package/board/packages/cli/src/private-state.mjs +43 -0
  8. package/board/packages/cli/src/state-home.mjs +15 -0
  9. package/board/scripts/codex-pipeline.mjs +34 -5
  10. package/board/scripts/hooks/auto-attach-reviewers.mjs +449 -0
  11. package/board/scripts/lib/adaptive-benchmark-collector.mjs +202 -0
  12. package/board/scripts/lib/adaptive-benchmark-protocol.mjs +75 -0
  13. package/board/scripts/lib/agent-execution-budget.mjs +143 -0
  14. package/board/scripts/lib/approval-level.mjs +174 -0
  15. package/board/scripts/lib/benchmark-review-dispatch.mjs +52 -0
  16. package/board/scripts/lib/benchmark-scorer-signature.mjs +64 -0
  17. package/board/scripts/lib/claude-exec.mjs +11 -3
  18. package/board/scripts/lib/codex-checks.mjs +77 -6
  19. package/board/scripts/lib/codex-exec.mjs +13 -2
  20. package/board/scripts/lib/codex-host-state.mjs +2 -0
  21. package/board/scripts/lib/codex-pipeline.mjs +281 -38
  22. package/board/scripts/lib/codex-release.mjs +9 -5
  23. package/board/scripts/lib/codex-role-profiles.mjs +46 -1
  24. package/board/scripts/lib/controlled-specialists.mjs +150 -0
  25. package/board/scripts/lib/controller-dispatch-evidence.mjs +60 -0
  26. package/board/scripts/lib/pinned-benchmark-scorer.mjs +132 -0
  27. package/board/scripts/lib/receipt.mjs +31 -6
  28. package/board/scripts/lib/review-graph-floor.mjs +54 -0
  29. package/board/scripts/lib/runtime-gate-policy.mjs +84 -0
  30. package/board/scripts/lib/scoped-review-reuse.mjs +154 -0
  31. package/board/scripts/lib/specialist-plan.mjs +102 -0
  32. package/board/scripts/lib/turn-snapshot.mjs +24 -4
  33. package/dist/codex-host.js +1 -1
  34. package/dist/private-state.mjs +53 -0
  35. package/dist/serve.js +10 -5
  36. package/dist/state-home.mjs +15 -0
  37. package/dist/task-queue.js +2 -2
  38. package/dist/update-check.js +2 -5
  39. package/dist/webhook-cli.js +2 -2
  40. package/dist/webhook-config.js +18 -14
  41. package/dist/webhook-dispatch.js +5 -8
  42. package/dist/worker.js +3 -2
  43. package/package.json +1 -1
@@ -0,0 +1,449 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * SessionStart hook — auto-attach reviewers.
4
+ *
5
+ * Scans git diff since the last session and emits a list of reviewers that
6
+ * should be invoked for the changed files. Writes a short markdown block to
7
+ * stdout that the SessionStart bash hook pipes into Claude's context.
8
+ *
9
+ * Why this exists
10
+ * ---------------
11
+ * In real sessions, users dispatch Agent calls with
12
+ * `subagent_type: general-purpose` and never invoke the 24 specialist
13
+ * reviewers — even when migrations / auth / payment code changed.
14
+ * This hook makes the right reviewers visible at session start, so Claude
15
+ * picks the matching `subagent_type` proactively without `/start`.
16
+ *
17
+ * Pattern-to-reviewer map below. Add patterns conservatively — over-firing
18
+ * burns context. Default: emit only if diff > 0 files match a pattern.
19
+ *
20
+ * Exit codes: 0 always. Silent if no matches or no .great_cto dir.
21
+ */
22
+
23
+ import { execFileSync } from "node:child_process";
24
+ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
25
+ import { join } from "node:path";
26
+ import { adaptiveSpecialistPlan, planNotice } from '../lib/specialist-plan.mjs';
27
+
28
+ const cwd = process.cwd();
29
+ const greatCtoDir = join(cwd, ".great_cto");
30
+
31
+ // Imports expose rules without exiting the caller outside a project.
32
+
33
+ // Pattern → reviewer mapping. Ordered specific-first.
34
+ // Each pattern is matched against the changed-file paths via simple substring.
35
+ // `match` may be a function returning a count of matched files.
36
+ // EXCLUDE (below) drops every `.md` path, so a rule token that can only match a
37
+ // markdown file can never fire. Three did: `EVAL-.*\.md$`, `ssp\.md`, and an
38
+ // `.api.md` token — removed rather than left as rules that read as coverage.
39
+ // `tests/eval/` already routes the eval files that are not excluded for another
40
+ // reason. The test `no rule can only match an excluded path` keeps this true.
41
+ export const RULES = [
42
+ { reviewer: "db-migration-reviewer", pattern: /(migrations\/|schema\.sql$|alembic\/|knex\/migrations|prisma\/migrations|\.room\.|Migration\d+\.kt$|Migration\d+\.swift$)/i,
43
+ cases: {
44
+ match: ["db/migrations/0007_add_index.sql", "prisma/migrations/20260801_init/migration.sql", "app/Migration12.kt"],
45
+ noMatch: ["src/db/query.ts", "docs/schema-notes.txt"],
46
+ } },
47
+ { reviewer: "pci-reviewer", pattern: /(stripe|webhook.*sig|payment|refund|paypal|adyen|braintree|saq-a|pci-dss)/i,
48
+ cases: {
49
+ match: ["src/billing/stripe-webhook.ts", "api/refund.ts", "lib/adyen-client.ts"],
50
+ noMatch: ["src/user/profile.ts", "tests/fixtures/cart.json"],
51
+ } },
52
+ { reviewer: "security-officer", pattern: /(auth\/|oauth|saml|jwt|password|login|session|csrf|cors\.|crypto\.|secret)/i,
53
+ cases: {
54
+ match: ["src/auth/session.ts", "api/oauth-callback.ts", "lib/jwt-verify.ts"],
55
+ noMatch: ["src/ui/button.tsx", "docs/onboarding.txt"],
56
+ } },
57
+ { reviewer: "ai-security-reviewer", pattern: /(prompts?\/|system_prompt|tool_definitions|rag|embeddings?\/|jailbreak)/i,
58
+ cases: {
59
+ match: ["prompts/system.txt", "src/rag/retriever.ts", "lib/embeddings/index.ts"],
60
+ noMatch: ["src/api/users.ts"],
61
+ } },
62
+ { reviewer: "ai-eval-engineer", pattern: /(tests\/eval\/|golden_set|prompt_regression)/i,
63
+ cases: {
64
+ match: ["tests/eval/runner.mjs", "data/golden_set.jsonl"],
65
+ noMatch: ["tests/unit/parser.test.ts"],
66
+ } },
67
+ { reviewer: "mobile-store-reviewer", pattern: /(play[_-]?store|app[_-]?store|fastlane|iap|in_app_purchase|store_listing|aab$|ipa$)/i,
68
+ cases: {
69
+ match: ["fastlane/Fastfile", "ios/iap-manager.swift", "build/app.aab"],
70
+ noMatch: ["src/api/client.ts"],
71
+ } },
72
+ { reviewer: "api-platform-reviewer", pattern: /(openapi\.|swagger|graphql\.schema|webhooks?\.|rfc7807|sunset_header)/i,
73
+ cases: {
74
+ match: ["api/openapi.yaml", "src/webhooks.ts", "schema/graphql.schema"],
75
+ noMatch: ["src/db/pool.ts"],
76
+ } },
77
+ { reviewer: "voice-ai-reviewer", pattern: /(twilio|vonage|livekit|deepgram|elevenlabs|ivr|tcpa|stir.shaken)/i,
78
+ cases: {
79
+ match: ["integrations/twilio-handler.ts", "src/ivr/menu.ts", "lib/deepgram-stream.ts"],
80
+ noMatch: ["src/audio/player.ts"],
81
+ } },
82
+ { reviewer: "hr-ai-reviewer", pattern: /(aedt|ny.?city.?ll.?144|resume_screen|hiring|interview_ai)/i,
83
+ cases: {
84
+ match: ["src/hiring/rank.ts", "lib/resume_screen.py"],
85
+ noMatch: ["src/hr/payroll.ts"],
86
+ } },
87
+ { reviewer: "edtech-reviewer", pattern: /(coppa|ferpa|student[_-]?(data|pii)|sopipa)/i,
88
+ cases: {
89
+ match: ["src/coppa-consent.ts", "lib/student_data.py"],
90
+ noMatch: ["src/course/player.tsx"],
91
+ } },
92
+ { reviewer: "gov-reviewer", pattern: /(fedramp|nist.?800.?53|cjis|fips.?140|fisma|vpat)/i,
93
+ cases: {
94
+ match: ["config/fedramp-boundary.yaml", "src/fisma-controls.ts"],
95
+ noMatch: ["src/gov/ui.tsx"],
96
+ } },
97
+ { reviewer: "game-reviewer", pattern: /(loot[_-]?box|esrb|pegi|iarc|gacha)/i,
98
+ cases: {
99
+ match: ["src/loot_box.ts", "config/esrb.json"],
100
+ noMatch: ["src/game/physics.ts"],
101
+ } },
102
+ // general.?ledger / \bgaap\b appended for accounting-vertical coverage — there is no
103
+ // dedicated accounting-reviewer agent (only project-auditor, which is unrelated), so
104
+ // core accounting-controls signals route to the SOX-ITGC surface enterprise-saas-reviewer
105
+ // already owns. "1099"/"e-file" deliberately excluded: too loose as bare substrings
106
+ // (collide with port numbers, filenames, version strings) without a path anchor.
107
+ { reviewer: "enterprise-saas-reviewer",pattern: /(scim|tenant_id|row.?level.?security|sso\/|saml\/|sox.itgc)/i,
108
+ cases: {
109
+ match: ["src/scim/provision.ts", "db/row-level-security.sql", "src/sso/handler.ts"],
110
+ noMatch: ["src/ui/table.tsx"],
111
+ } },
112
+ // procurement-reviewer: purchasing / source-to-pay tokens only — deliberately avoids
113
+ // bare "purchase"/"order"/"vendor" (each collides with e-commerce/checkout code).
114
+ // Each token below is a procurement-specific compound or acronym:
115
+ // purchase.?order — the PO artifact itself, requires the compound
116
+ // three.?way.?match — the PO/receipt/invoice reconciliation control, unambiguous
117
+ // \brfp\b — Request for Proposal (word-boundaried: short acronym)
118
+ // \bofac\b — sanctions-screening authority (word-boundaried)
119
+ // punchout — e-procurement catalog-launch protocol, unambiguous
120
+ // cxml — the punchout XML dialect name, unambiguous
121
+ // requisition — procurement-specific request artifact (not generic "request")
122
+ { reviewer: "procurement-reviewer", pattern: /(purchase.?order|three.?way.?match|\brfp\b|\bofac\b|punchout|cxml|requisition)/i,
123
+ cases: {
124
+ match: ["src/purchase-order.ts", "lib/punchout.ts"],
125
+ noMatch: ["src/store/cart.ts"],
126
+ } },
127
+ // accounting-reviewer: bookkeeping / GL / close-cycle tokens — MOVED from
128
+ // enterprise-saas-reviewer (was a stop-gap before this reviewer existed, see
129
+ // great_cto-k0uf). general.?ledger and \bgaap\b now route here exclusively so
130
+ // accounting signals hit the domain-correct reviewer instead of the generic
131
+ // enterprise-controls one.
132
+ { reviewer: "accounting-reviewer", pattern: /(asc.?606|journal.?entry|general.?ledger|\bgaap\b|1099|month.?end.?close|chart.?of.?accounts)/i,
133
+ cases: {
134
+ match: ["src/journal-entry.ts", "lib/general-ledger.ts"],
135
+ noMatch: ["src/reports/chart.tsx"],
136
+ } },
137
+ // msp-reviewer: managed-service-provider / RMM-PSA tokens only — deliberately avoids
138
+ // bare "tenant"/"service" (both collide with generic SaaS/microservice code; tenant_id
139
+ // already belongs to enterprise-saas-reviewer above). Each token below is MSP-specific:
140
+ // \bmsa\b / \bsla\b — Master Service Agreement / Service Level Agreement acronyms
141
+ // (word-boundaried: both are short and common as substrings)
142
+ // \brmm\b / \bpsa\b — Remote Monitoring & Management / Professional Services
143
+ // Automation, the two dominant MSP tool categories
144
+ // multi.?tenant — MSP cross-client blast-radius framing (distinct from
145
+ // enterprise-saas-reviewer's bare "tenant_id" data-isolation signal)
146
+ // managed.?service — "managed service provider" / "managed services", unambiguous
147
+ // credential.?vault — MSP's core liability surface (client credential storage)
148
+ { reviewer: "msp-reviewer", pattern: /(\bmsa\b|\bsla\b|\brmm\b|\bpsa\b|multi.?tenant|managed.?service|credential.?vault)/i,
149
+ cases: {
150
+ match: ["src/rmm-agent.ts", "lib/credential-vault.ts"],
151
+ noMatch: ["src/ui/nav.tsx"],
152
+ } },
153
+ // tax-reviewer: tax-prep / IRS e-file tokens only — deliberately avoids bare "tax"
154
+ // (collides with sales-tax/tax-rate code in commerce/pci contexts) and bare "irs"
155
+ // alone as unanchored substring was considered too loose; each token below is a
156
+ // tax-prep-specific compound or acronym:
157
+ // \bptin\b — Preparer Tax Identification Number (word-boundaried)
158
+ // circular.?230 — IRS preparer-practice regulations, unambiguous
159
+ // form.?8879 — the e-file signature authorization form number
160
+ // \bmef\b — Modernized e-File, the IRS e-file system (word-boundaried:
161
+ // short acronym)
162
+ // pub.?4557 — IRS Publication 4557 (taxpayer data safeguards)
163
+ // section.?7216 — IRC §7216 consent-to-disclose statute
164
+ // tax.?prep — "tax prep"/"tax preparation", the domain itself
165
+ { reviewer: "tax-reviewer", pattern: /(\bptin\b|circular.?230|form.?8879|\bmef\b|pub.?4557|section.?7216|tax.?prep)/i,
166
+ cases: {
167
+ match: ["src/form-8879.ts", "lib/tax-prep.py"],
168
+ noMatch: ["src/finance/invoice.ts"],
169
+ } },
170
+ { reviewer: "insurance-reviewer", pattern: /(naic|solvency|ifrs.?17|acord|actuarial)/i,
171
+ cases: {
172
+ match: ["src/naic-filing.ts", "lib/actuarial-model.py"],
173
+ noMatch: ["src/policy/ui.tsx"],
174
+ } },
175
+ // legal-reviewer: legal-services / legal-tech domain tokens only — deliberately
176
+ // avoids bare "legal"/"law"/"case"/"trust" (each collides with generic code:
177
+ // "legal" appears in license headers, "law" in unrelated words, "case" in
178
+ // switch/test-case terminology, "trust" in security/crypto trust-boundary code).
179
+ // Each token below is a domain-specific signal:
180
+ // iolta — Interest on Lawyers' Trust Accounts, the client-trust
181
+ // accounting regime; unambiguous legal-practice term
182
+ // trust.?account — trust-accounting artifact, requires the compound so
183
+ // bare "trust" (crypto/security) doesn't match
184
+ // matter.?number — legal "matter" identifier; requires the compound so
185
+ // bare "matter" (generic English word) doesn't match
186
+ // conflict.?check — conflict-of-interest screening, legal-ethics specific
187
+ // \bpacer\b — federal court public e-filing system (word-boundaried:
188
+ // avoids matching inside unrelated identifiers)
189
+ // \becf\b — Electronic Case Filing (word-boundaried: "ecf" alone
190
+ // is short enough to risk false positives without \b)
191
+ // \bcm\/ecf\b — CM/ECF, the federal court e-filing platform name
192
+ // retainer.?agreement — engagement/retainer contract, legal-practice specific
193
+ // attorney.?client — attorney-client privilege, unambiguous legal-ethics term
194
+ // \bupl\b — Unauthorized Practice of Law, word-boundaried (short token)
195
+ // docket — court docket / case-management term, legal-specific
196
+ { reviewer: "legal-reviewer", pattern: /(iolta|trust.?account|matter.?number|conflict.?check|\bpacer\b|\becf\b|\bcm\/ecf\b|retainer.?agreement|attorney.?client|\bupl\b|docket)/i,
197
+ cases: {
198
+ match: ["src/iolta-ledger.ts", "lib/conflict-check.ts", "src/docket-sync.ts"],
199
+ noMatch: ["src/legal/ui.tsx"],
200
+ } },
201
+ // healthcare-reviewer: HIPAA/PHI/clinical-transport tokens only — deliberately
202
+ // avoids bare "health"/"care"/"claim" (collide with insurance, wellness apps,
203
+ // generic support-ticket code). Each token below is a domain-specific signal:
204
+ // hipaa — the statute itself
205
+ // phi[_-] — Protected Health Information as an identifier/var prefix
206
+ // (require the separator so it doesn't match unrelated "phi" substrings)
207
+ // hl7 / fhir — the two dominant clinical-data transport standards
208
+ // hitech — HIPAA's breach-notification amendment
209
+ // \bbaa\b — Business Associate Agreement (word-boundaried: "baa" alone
210
+ // is a common false-positive token otherwise)
211
+ // (^|\/)ehr\/ — electronic-health-record integration directories (Epic/Cerner/
212
+ // athenahealth); path-anchored so it doesn't match inside unrelated
213
+ // words like "behr" or "cheerful"
214
+ // superbill — clinical billing artifact, healthcare-specific (not "invoice")
215
+ // icd-?10 — the diagnosis coding standard
216
+ // soap.?note — clinical documentation format (Subjective/Objective/Assessment/Plan)
217
+ // cdt-?code — Current Dental Terminology billing code (dental-specific,
218
+ // parallels icd-?10 for the dental vertical)
219
+ // odontogram — dental charting artifact (tooth-by-tooth condition map),
220
+ // unambiguous dental-clinical term
221
+ // perio-chart — periodontal charting artifact, dental-specific
222
+ // dental.?claim — dental insurance claim, requires the compound so bare
223
+ // "claim" (generic/insurance-collision) doesn't match
224
+ { reviewer: "healthcare-reviewer", pattern: /(hipaa|phi[_-]|hl7|fhir|hitech|\bbaa\b|(^|\/)ehr\/|superbill|icd-?10|soap.?note|cdt-?code|odontogram|perio-chart|dental.?claim)/i,
225
+ cases: {
226
+ match: ["src/hipaa-audit.ts", "integrations/fhir/client.ts", "src/soap-note.ts"],
227
+ noMatch: ["src/patient/avatar.tsx"],
228
+ } },
229
+ // rcm-reviewer: medical-billing / revenue-cycle tokens only — deliberately excludes
230
+ // bare "claim" (generic/insurance-collision, already avoided by healthcare-reviewer's
231
+ // "dental.?claim" compound) and bare "modifier"/"denial" (too generic on their own).
232
+ // "icd-?10" and "superbill" stay on healthcare-reviewer (clinical/chart surface);
233
+ // the tokens below are billing/claims-submission specific:
234
+ // cms-?1500 / ub-?04 — the professional/institutional claim form names
235
+ // hcpcs — HCPCS Level II billing code set
236
+ // \b835\b — ERA remittance transaction number (word-boundaried:
237
+ // short numeric token needs a boundary to avoid false hits)
238
+ // remittance.?advice — the 835's plain-English name
239
+ // prior.?auth — prior-authorization workflow, billing-specific compound
240
+ // denial.?code — CARC/RARC denial-code workflow, requires the compound so
241
+ // bare "denial" doesn't fire on unrelated rejection/reject code
242
+ // npi\b — National Provider Identifier (word-boundaried)
243
+ { reviewer: "rcm-reviewer", pattern: /(cms-?1500|ub-?04|hcpcs|\b835\b|remittance.?advice|prior.?auth|denial.?code|npi\b)/i,
244
+ cases: {
245
+ match: ["src/cms-1500.ts", "lib/prior-auth.ts", "src/denial-code-map.ts"],
246
+ noMatch: ["src/billing/ui.tsx"],
247
+ } },
248
+ // regulated-reviewer: DORA/NIS2/ISO27001 only — its other two frameworks (SOX ITGC,
249
+ // HIPAA) are already claimed by enterprise-saas-reviewer's `sox.itgc` and the
250
+ // healthcare-reviewer pattern above respectively, so re-adding those tokens here
251
+ // would double-attach two reviewers on the same file for no added signal. DORA/NIS2/
252
+ // ISO27001 are regulated-reviewer's exclusive surface — no other rule covers them.
253
+ { reviewer: "regulated-reviewer", pattern: /(\bdora\b|dora.?ict|nis2|iso.?27001)/i,
254
+ cases: {
255
+ match: ["config/dora-ict-register.yaml", "src/nis2-controls.ts"],
256
+ noMatch: ["src/app/main.ts"],
257
+ } },
258
+ { reviewer: "infra-reviewer", pattern: /(\.tf$|terraform\/|helm\/|kustomize\/|cdk\/|pulumi\/|\.bicep$)/i,
259
+ cases: {
260
+ match: ["infra/main.tf", "helm/values.yaml", "cdk/stack.ts"],
261
+ noMatch: ["src/server.ts"],
262
+ } },
263
+ { reviewer: "web-store-reviewer", pattern: /(manifest\.json$|mv3|chrome_extension|firefox_addon)/i,
264
+ cases: {
265
+ match: ["extension/manifest.json", "src/mv3-worker.ts"],
266
+ noMatch: ["package.json"],
267
+ } },
268
+ { reviewer: "performance-engineer", pattern: /(perf|p99|latency_budget|k6\/|locust\/|gatling\/|benchmark)/i,
269
+ cases: {
270
+ match: ["tests/k6/load.js", "src/perf-budget.ts", "bench/benchmark.ts"],
271
+ noMatch: ["src/ui/form.tsx"],
272
+ } },
273
+ // This rule never fired. It required a manifest name AND the word "version" or
274
+ // "public api" in the SAME string, but the string it is tested against is a
275
+ // FILE PATH — no path contains both, so library-reviewer was never attached to
276
+ // anything since the rule was written. The `version` half was written as if it
277
+ // would see the diff.
278
+ //
279
+ // Bare `package.json` is deliberately not a trigger: every project has one, and
280
+ // a reviewer that attaches to everything is one people learn to ignore. What is
281
+ // matched instead are the manifests and artifacts that only a PUBLISHED library
282
+ // has — a declared public API surface, or a packaging manifest for an ecosystem
283
+ // where the file's presence already implies distribution.
284
+ { reviewer: "library-reviewer", pattern: /(api-extractor\.json|(^|\/)(pyproject\.toml|Cargo\.toml|setup\.py|go\.mod)$|\.npmignore$)/i,
285
+ cases: {
286
+ match: ["Cargo.toml", "pyproject.toml", "sdk/api-extractor.json", "go.mod"],
287
+ noMatch: ["package.json", "src/index.ts"],
288
+ } },
289
+ { reviewer: "cli-reviewer", pattern: /(bin\/|cli\/main|argv|exit.?code)/i,
290
+ cases: {
291
+ match: ["bin/great-cto", "src/cli/main.ts", "lib/argv-parse.ts"],
292
+ noMatch: ["src/server/routes.ts"],
293
+ } },
294
+ // Building an MCP server, not consuming one: `.mcp.json` and `.playwright-mcp/`
295
+ // are what USING a server leaves behind, and neither is ours to review.
296
+ { reviewer: "mcp-server-reviewer", pattern: /(mcp[-_]servers?[/.]|[/.]mcp[-_]server\.|(^|\/)mcp\.(ts|js|mjs|py)$|modelcontextprotocol)/i,
297
+ cases: {
298
+ match: ["mcp-servers/llm-router/server.py", "src/mcp.ts", "lib/modelcontextprotocol-adapter.ts"],
299
+ noMatch: [".mcp.json", ".playwright-mcp/session.json"],
300
+ } },
301
+ ];
302
+
303
+ // Known limit, found by giving every rule its own cases: `^docs/` and `\.md$`
304
+ // are excluded wholesale, so a compliance artefact that lives as a document —
305
+ // a FedRAMP boundary, a DORA ICT register, a system prompt in markdown — never
306
+ // reaches gov-reviewer, regulated-reviewer or ai-security-reviewer. Those rules
307
+ // fire only on config and source. Widening EXCLUDE would make every rule fire on
308
+ // every doc, so it is recorded here rather than changed in passing.
309
+ //
310
+ // Files we deliberately ignore for reviewer routing — these are metadata,
311
+ // templates, or test fixtures whose path matches a pattern by accident but
312
+ // they're not actual product code that needs review.
313
+ const EXCLUDE = [
314
+ /^tests\/fixtures\//, // fixtures for our own tests
315
+ /^docs\//, // analysis docs, ADRs, marketing
316
+ /^commands\//, // slash-command definitions
317
+ /^agents\//, // agent prompt files (great_cto-repo only)
318
+ /^skills\/great_cto\//, // plugin skill files
319
+ /^\.claude\//, // claude config
320
+ /\.md$/, // any markdown — docs talking about X ≠ doing X
321
+ /^node_modules\//,
322
+ /^\.git\//,
323
+ // What USING an MCP server leaves behind, not code we wrote. The
324
+ // mcp-server-reviewer rule already says these are not ours to review; they
325
+ // were still reaching every OTHER rule, and `.playwright-mcp/session.json`
326
+ // matched security-officer's `session` token on every run that used the
327
+ // browser. A reviewer flagged by a tool's own cache file is noise, and noise
328
+ // is what teaches people to skim the reviewer list.
329
+ /^\.playwright-mcp\//,
330
+ /^\.mcp\.json$/,
331
+ ];
332
+
333
+ export function shouldExclude(path) {
334
+ return EXCLUDE.some(rx => rx.test(path));
335
+ }
336
+
337
+ // Get changed files since last session.
338
+ // Strategy: files touched in the last 7 days (broader than `since last commit`,
339
+ // captures uncommitted edits + recent work). Falls back to last 50 commits.
340
+ function getChangedFiles() {
341
+ try {
342
+ // execFileSync, not execSync: no shell at all. Nothing here is interpolated
343
+ // today, but this runs on EVERY tool use, and a hook that builds a command
344
+ // as a string is one careless edit away from running whatever a path
345
+ // contains. `sort -u` was the only reason a shell was needed; a Set does it.
346
+ const recent = execFileSync(
347
+ "git", ["log", "--since=@{7.days.ago}", "--name-only", "--pretty=format:"],
348
+ { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }
349
+ ).split("\n").filter(Boolean);
350
+ // Plus currently uncommitted
351
+ const uncommitted = execFileSync("git", ["status", "--porcelain"], {
352
+ cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"]
353
+ }).split("\n").map(l => l.slice(3)).filter(Boolean);
354
+ return [...new Set([...recent, ...uncommitted])];
355
+ } catch {
356
+ return [];
357
+ }
358
+ }
359
+
360
+ // Read verdicts log to skip reviewers that have ALREADY run recently on these files.
361
+ // Conservative: dedupe by reviewer name + day. If a reviewer ran in the last 24h,
362
+ // don't re-flag it.
363
+ function recentlyInvokedReviewers() {
364
+ try {
365
+ // log-verdict.sh writes to the PROJECT-local .great_cto/verdicts/ — read
366
+ // that first; the HOME-level dir only exists if a global aggregator syncs
367
+ // logs there. Reading only HOME meant the dedupe never fired (bug).
368
+ const candidateDirs = [
369
+ join(greatCtoDir, "verdicts"),
370
+ join(process.env.HOME || "~", ".great_cto", "verdicts"),
371
+ ];
372
+ const day24Ago = Date.now() - 24 * 3600 * 1000;
373
+ const seen = new Set();
374
+ for (const verdictDir of candidateDirs) {
375
+ if (!existsSync(verdictDir)) continue;
376
+ scanVerdictDir(verdictDir, day24Ago, seen);
377
+ }
378
+ return seen;
379
+ } catch {
380
+ return new Set();
381
+ }
382
+ }
383
+
384
+ function scanVerdictDir(verdictDir, day24Ago, seen) {
385
+ for (const f of readdirSync(verdictDir)) {
386
+ const fp = join(verdictDir, f);
387
+ try {
388
+ const stat = statSync(fp);
389
+ if (stat.mtimeMs < day24Ago) continue;
390
+ const lines = readFileSync(fp, "utf8").split("\n").slice(-30);
391
+ for (const line of lines) {
392
+ const m = line.match(/\|?\s*([\w-]+-reviewer|security-officer|qa-engineer|architect|pm|senior-dev|devops|l3-support|ai-eval-engineer|ai-prompt-architect)\s*\|/);
393
+ if (!m) continue;
394
+ // Lines start with an ISO-8601 UTC timestamp (verdict-format.md).
395
+ // Compare it against the 24h window instead of matching a calendar
396
+ // date string (the old check only matched yesterday's date, so
397
+ // verdicts written today were never deduped).
398
+ const ts = Date.parse(line.split(" ")[0]);
399
+ if (!Number.isNaN(ts) && ts >= day24Ago) seen.add(m[1]);
400
+ }
401
+ } catch { /* skip unreadable */ }
402
+ }
403
+ }
404
+
405
+ function main() {
406
+ if (!existsSync(greatCtoDir)) process.exit(0);
407
+ const plan = adaptiveSpecialistPlan(cwd, RULES);
408
+ if (plan) { console.log(planNotice(plan)); return; }
409
+ const files = getChangedFiles();
410
+ if (files.length === 0) process.exit(0);
411
+
412
+ const recent = recentlyInvokedReviewers();
413
+ const matches = new Map(); // reviewer → [matched files]
414
+
415
+ // Filter out fixtures/docs/templates — they accidentally match patterns
416
+ // but don't represent product code that needs review.
417
+ const productFiles = files.filter(f => !shouldExclude(f));
418
+ if (productFiles.length === 0) process.exit(0);
419
+
420
+ for (const rule of RULES) {
421
+ if (recent.has(rule.reviewer)) continue;
422
+ const matchedFiles = productFiles.filter(f => rule.pattern.test(f));
423
+ if (matchedFiles.length === 0) continue;
424
+ matches.set(rule.reviewer, matchedFiles);
425
+ }
426
+
427
+ if (matches.size === 0) process.exit(0);
428
+
429
+ // Cap output: emit at most 5 reviewers (most-matched first), summarize rest.
430
+ const sorted = [...matches.entries()].sort((a, b) => b[1].length - a[1].length);
431
+ const top = sorted.slice(0, 5);
432
+ const overflow = sorted.slice(5);
433
+
434
+ console.log("\n📋 REVIEWERS FLAGGED — match `subagent_type` to changed files");
435
+ for (const [reviewer, mf] of top) {
436
+ const preview = mf.slice(0, 2).join(", ");
437
+ const extra = mf.length > 2 ? ` (+${mf.length - 2})` : "";
438
+ console.log(` • ${reviewer} — ${preview}${extra}`);
439
+ }
440
+ if (overflow.length > 0) {
441
+ console.log(` • +${overflow.length} more: ${overflow.map(([r]) => r).join(", ")}`);
442
+ }
443
+ console.log(` Use: Agent({ subagent_type: "<reviewer>", description, prompt })`);
444
+ }
445
+
446
+ // Guarded so reviewer-nudge.mjs can import RULES/shouldExclude without running the scan.
447
+ import { fileURLToPath } from "node:url";
448
+ const isMain = process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1];
449
+ if (isMain) main();
@@ -0,0 +1,202 @@
1
+ /** Read-only operator-pinned observation collection, not provider attestation. */
2
+ import { lstatSync, realpathSync, openSync, fstatSync, readSync, closeSync, constants } from 'node:fs';
3
+ import { resolve, relative, isAbsolute, sep } from 'node:path';
4
+ import { createHash } from 'node:crypto';
5
+ import { describeMatchedObservations } from './adaptive-benchmark-protocol.mjs';
6
+ import { dispatchEvidenceSummary } from './controller-dispatch-evidence.mjs';
7
+ import { treeReceipt } from './receipt.mjs';
8
+ import { scorerAuthority, verifyScorerReport } from './benchmark-scorer-signature.mjs';
9
+ import { baselineInputDigest, readPinnedScorerOracle } from './pinned-benchmark-scorer.mjs';
10
+ import { assertBenchmarkReviewDispatches } from './benchmark-review-dispatch.mjs';
11
+
12
+ const hash = bytes => createHash('sha256').update(bytes).digest('hex');
13
+ const digest = value => hash(JSON.stringify(value));
14
+ const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
15
+ const mandatory = ['code-reviewer', 'qa-engineer', 'security-officer'];
16
+ const hex = value => typeof value === 'string' && /^[a-f0-9]{64}$/.test(value);
17
+ function document(raw, label) {
18
+ try { return JSON.parse(raw); }
19
+ catch { throw Error(`invalid benchmark ${label} JSON`); } // Never echo private input bytes.
20
+ }
21
+
22
+ export function benchmarkPolicySnapshot(state, { entry = state.queue[0] } = {}) {
23
+ // Stand-down audit grows during execution; it is not a policy configuration.
24
+ const gatePolicy = state.gatePolicy ? { ...state.gatePolicy } : null;
25
+ if (gatePolicy) delete gatePolicy.skipped;
26
+ return structuredClone({ graphHash: state.graphHash, allowed: state.allowed, intent: state.intent,
27
+ entry, hostRoutes: state.hostRoutes || {}, maxAttempts: state.maxAttempts,
28
+ checkPolicy: state.checkPolicy || null, gatePolicy, executionBudget: state.executionBudget || null,
29
+ specialistPolicy: state.specialistPolicy || null });
30
+ }
31
+
32
+ function registrationIdentity(registration) {
33
+ const { protocol, trial } = registration || {};
34
+ describeMatchedObservations(protocol, []); // Checks the entire registered protocol digest.
35
+ const block = protocol.blocks.find(b => b.task === trial?.task && b.repetition === trial?.repetition);
36
+ if (registration.version !== 1 || !block || !['legacy', 'adaptive'].includes(trial.arm)
37
+ || typeof registration.scorer?.id !== 'string' || !registration.scorer.id.trim() || !hex(registration.scorer.sha256)
38
+ || !Array.isArray(registration.requiredRoles) || registration.requiredRoles.some(r => typeof r !== 'string' || !r)
39
+ || new Set(registration.requiredRoles).size !== registration.requiredRoles.length
40
+ || mandatory.some(r => !registration.requiredRoles.includes(r))) throw Error('invalid benchmark trial registration or mandatory roles');
41
+ const scenario = protocol.scenarios.find(s => s.id === block.task);
42
+ scorerAuthority(registration.scorer);
43
+ return { registrationDigest: digest(registration), protocolDigest: protocol.digest,
44
+ task: block.task, repetition: block.repetition, arm: trial.arm,
45
+ specificationDigest: block.specificationDigest, acceptanceDigest: block.acceptanceDigest,
46
+ artifact: protocol.artifacts[trial.arm], policyDigest: digest(protocol.policies[trial.arm]),
47
+ scorer: registration.scorer, requiredRoles: registration.requiredRoles, scenario };
48
+ }
49
+
50
+ /** Called only on a newly constructed controller state, before its first dispatch. */
51
+ export function bindBenchmarkTrial(state, registration) {
52
+ if (state.benchmarkBinding || state.status !== 'ready' || state.active || state.steps !== 0
53
+ || state.attempts.length || state.approvals.length || state.dispatchEvidence?.records.length
54
+ || state.releasePolicy) throw Error('benchmark binding requires a fresh no-release run');
55
+ const identity = registrationIdentity(registration);
56
+ if (state.prompt !== identity.scenario.task || !same(state.acceptance, identity.scenario.checks)
57
+ || digest(benchmarkPolicySnapshot(state)) !== identity.policyDigest) throw Error('benchmark task, criteria or policy differs from registration');
58
+ const initialReceipt = treeReceipt(state.root);
59
+ if (!initialReceipt || initialReceipt.truncated) throw Error('benchmark requires a readable complete initial Git receipt');
60
+ state.benchmarkBinding = structuredClone({ ...identity, initialReceipt });
61
+ return state.benchmarkBinding;
62
+ }
63
+
64
+ function outside(root, file) {
65
+ const rel = relative(root, realpathSync(file));
66
+ if (!rel || (rel !== '..' && !rel.startsWith(`..${sep}`) && !isAbsolute(rel))) throw Error('benchmark evidence must be external to the worker project');
67
+ }
68
+
69
+ function boundedFile(file, { privateFile = true, maxBytes = 65536 } = {}) {
70
+ const path = resolve(file);
71
+ if (realpathSync(path) !== path || lstatSync(path).isSymbolicLink()) throw Error('unsafe noncanonical benchmark evidence path');
72
+ const fd = openSync(path, constants.O_RDONLY | (constants.O_NOFOLLOW || 0) | (constants.O_NONBLOCK || 0));
73
+ try {
74
+ const info = fstatSync(fd);
75
+ if (!info.isFile() || (privateFile && (info.mode & 0o077)) || info.size > maxBytes) throw Error('unsafe or oversized bounded benchmark evidence file');
76
+ // Allocate only the observed bounded size; one extra byte detects growth.
77
+ const bytes = Buffer.alloc(info.size + 1); let used = 0, count;
78
+ while (used < bytes.length && (count = readSync(fd, bytes, used, bytes.length - used, null)) > 0) used += count;
79
+ if (used > info.size) throw Error('benchmark evidence grew during bounded read');
80
+ const raw = bytes.subarray(0, used);
81
+ return { raw, sha256: hash(raw) };
82
+ } finally { closeSync(fd); }
83
+ }
84
+
85
+ function externalFile(root, file, options) {
86
+ outside(root, file);
87
+ return boundedFile(file, options);
88
+ }
89
+ export function privateBenchmarkEvidence(root, file) { return externalFile(root, file); }
90
+
91
+ export function readBenchmarkRegistration(root, file) {
92
+ const evidence = externalFile(realpathSync(root), file);
93
+ const registration = document(evidence.raw, 'registration');
94
+ registrationIdentity(registration);
95
+ return registration;
96
+ }
97
+
98
+ function assertArtifacts(state) {
99
+ for (const [name, expected] of Object.entries(state.writes || {})) {
100
+ if (!name || isAbsolute(name) || name.includes('\\') || name.split('/').some(p => !p || p === '..' || p === '.')) throw Error('invalid benchmark artifact path');
101
+ const path = resolve(state.root, name), actual = realpathSync(path), info = lstatSync(path);
102
+ if (actual !== path || !info.isFile() || !state.allowed.some(p => name === p || name.startsWith(`${p}/`))
103
+ || boundedFile(path, { privateFile: false, maxBytes: 1024 * 1024 }).sha256 !== expected) throw Error('benchmark artifact drift or unsafe path');
104
+ }
105
+ }
106
+
107
+ function assertScorable(state, registration, receipt) {
108
+ if (state.status !== 'done' || state.active || state.pending || state.wave || state.rework || state.queue.length
109
+ || state.releasePolicy || !receipt || receipt.truncated || !state.attempts.length || !same(state.attempts.at(-1).receipt, receipt)) throw Error('controller is not settled on the scored receipt');
110
+ assertArtifacts(state);
111
+ if (state.dispatchEvidence?.records.some(r => !r || r.outcome === 'intent' || !r.finishedAt)) throw Error('unfinished controller invocation cannot be assessed as settled');
112
+ const required = new Set(registration.requiredRoles);
113
+ // Do not omit reviewers that the actual adaptive controller selected.
114
+ for (const role of state.specialistReview?.roles || []) required.add(role);
115
+ for (const role of required) {
116
+ const result = state.results[role], attempt = state.attempts.find(a => a.id === result?.attemptId);
117
+ const observed = state.dispatchEvidence?.records.find(r => r.id === `${attempt?.id}:verifier`);
118
+ if (!result || !attempt || attempt.role !== role || attempt.status !== 'verified'
119
+ || result.verification?.state !== 'verified' || !result.verification.checks?.length
120
+ || attempt.verification?.state !== 'verified' || !same(result.verification, attempt.verification)
121
+ || observed?.kind !== 'verifier' || observed.outcome !== 'returned') throw Error('missing independently verified required role');
122
+ }
123
+ for (const approval of state.approvals) {
124
+ if (state.results[approval.role]?.digest !== approval.result) throw Error('approval refers to stale result');
125
+ }
126
+ assertBenchmarkReviewDispatches(state, [...required]);
127
+ }
128
+
129
+ /** Shared read-only pre/post execution context; not proof of executing this package. */
130
+ export function benchmarkScoringContext({ registrationFile, stateFile, stateSha256, artifactFile,
131
+ scorerFile, oracleFile = null }, { settled = false } = {}) {
132
+ if (!hex(stateSha256)) throw Error('exact state byte pin required');
133
+ // Root is not trusted until the bounded private state has been parsed and checked.
134
+ const evidence = boundedFile(stateFile, { maxBytes: 8 * 1024 * 1024 });
135
+ if (evidence.sha256 !== stateSha256) throw Error('controller state byte pin mismatch');
136
+ const state = document(evidence.raw, 'state'), root = realpathSync(state.root);
137
+ if (state.version !== 1 || !state.id || state.root !== root) throw Error('invalid benchmark controller identity');
138
+ outside(root, stateFile);
139
+ const registration = readBenchmarkRegistration(root, registrationFile), identity = registrationIdentity(registration);
140
+ const binding = state.benchmarkBinding;
141
+ if (!binding || !same({ ...binding, initialReceipt: undefined }, { ...identity, initialReceipt: undefined })
142
+ || !binding.initialReceipt || state.prompt !== identity.scenario.task || !same(state.acceptance, identity.scenario.checks)) throw Error('controller trial binding mismatch or absent pre-dispatch binding');
143
+ if (digest(benchmarkPolicySnapshot(state, { entry: registration.protocol.policies[identity.arm].entry })) !== identity.policyDigest) throw Error('controller execution policy drift');
144
+ const artifact = externalFile(root, artifactFile, { privateFile: false, maxBytes: 100 * 1024 * 1024 });
145
+ const scorer = externalFile(root, scorerFile, { privateFile: false, maxBytes: 1024 * 1024 });
146
+ if (artifact.sha256 !== identity.artifact.artifactSha256 || scorer.sha256 !== identity.scorer.sha256) throw Error('package or scorer code pin mismatch');
147
+ const receipt = treeReceipt(root);
148
+ let oracle = null;
149
+ if (identity.scorer.authority) {
150
+ if (!oracleFile) throw Error('registered signed scorer requires pinned oracle evidence');
151
+ oracle = readPinnedScorerOracle(root, oracleFile, identity.scorer.oracleSha256);
152
+ if (oracle.scenario !== identity.task) throw Error('scorer oracle task differs from registered trial');
153
+ }
154
+ if (settled) assertScorable(state, registration, receipt);
155
+ return { state, root, registration, identity, artifact, scorer, receipt, oracle };
156
+ }
157
+
158
+ /** Binds raw state bytes, package bytes and pinned scorer code to the supplied report. */
159
+ export function collectBenchmarkObservation(options) {
160
+ const { stateSha256, scoreFile = null, scoreSha256 = null } = options;
161
+ if (!hex(stateSha256) || (scoreFile && !hex(scoreSha256)) || (!scoreFile && scoreSha256)) throw Error('exact state/score byte pins required');
162
+ const { state, root, registration, identity, artifact, scorer, receipt, oracle } = benchmarkScoringContext(options);
163
+ const telemetry = dispatchEvidenceSummary(state);
164
+ let status = ['blocked', 'cancelled'].includes(state.status) ? (state.status === 'blocked' ? 'blocked' : 'failed') : 'unassessed';
165
+ let accepted = null, scoreDigest = null, trustedScorerProcessSignatureVerified = false;
166
+ if (scoreFile) {
167
+ assertScorable(state, registration, receipt);
168
+ const score = externalFile(root, scoreFile);
169
+ if (score.sha256 !== scoreSha256) throw Error('score byte pin mismatch');
170
+ let report = document(score.raw, 'score');
171
+ if (identity.scorer.authority) {
172
+ const payload = verifyScorerReport(report, identity.scorer);
173
+ if (payload.artifactSha256 !== artifact.sha256 || payload.scorerSha256 !== scorer.sha256
174
+ || payload.oracleSha256 !== identity.scorer.oracleSha256
175
+ || payload.candidateInputDigest !== baselineInputDigest(root, Object.keys(oracle.baseline).sort())) throw Error('signed scorer artifact/oracle/input binding mismatch');
176
+ report = { ...payload, version: 1, source: 'operator-attestation', scorer: identity.scorer };
177
+ trustedScorerProcessSignatureVerified = true;
178
+ }
179
+ if (report.version !== 1 || report.source !== 'operator-attestation' || report.runId !== state.id
180
+ || report.stateSha256 !== stateSha256 || report.registrationDigest !== identity.registrationDigest
181
+ || !same(report.scorer, identity.scorer) || !same(report.receipt, receipt)
182
+ || !Array.isArray(report.criteria) || report.criteria.length !== state.acceptance.length
183
+ || report.criteria.some((c, i) => c?.text !== state.acceptance[i] || !['passed', 'failed'].includes(c.state)
184
+ || typeof c.evidence !== 'string' || !c.evidence.trim())) throw Error('independent score report identity/criteria mismatch');
185
+ status = 'completed'; accepted = report.criteria.every(c => c.state === 'passed'); scoreDigest = score.sha256;
186
+ }
187
+ const row = { task: identity.task, repetition: identity.repetition, arm: identity.arm,
188
+ protocolDigest: identity.protocolDigest, specificationDigest: identity.specificationDigest,
189
+ acceptanceDigest: identity.acceptanceDigest, artifactSha256: artifact.sha256, status, accepted,
190
+ activeMs: telemetry.activeMs, workerCalls: telemetry.workerCalls, verifierCalls: telemetry.verifierCalls,
191
+ humanPauses: null, actionableFindings: null, escapedDefects: null, actualCostUsd: null,
192
+ evidence: { level: trustedScorerProcessSignatureVerified
193
+ ? 'trusted-launcher-signed-scorer-and-pinned-controller-not-provider-attested'
194
+ : 'operator-pinned-controller-and-score-not-provider-attested', runId: state.id,
195
+ controllerStatus: state.status, stateSha256, registrationDigest: identity.registrationDigest,
196
+ scorerSha256: scorer.sha256, scoreSha256: scoreDigest, receipt,
197
+ trustedScorerProcessSignatureVerified,
198
+ graphFloorCoverageVerified: false, executionArtifactProvenanceVerified: false,
199
+ benchmarkEligible: false } };
200
+ describeMatchedObservations(registration.protocol, [row]);
201
+ return row;
202
+ }