toga-ai 1.0.826 → 1.0.828

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.
@@ -71,6 +71,16 @@
71
71
  }
72
72
  ]
73
73
  },
74
+ {
75
+ "matcher": "Write|Edit|MultiEdit",
76
+ "hooks": [
77
+ {
78
+ "type": "command",
79
+ "command": "node \"$CLAUDE_PROJECT_DIR/.claude/hooks/toga/sql-file-location.js\"",
80
+ "timeout": 3000
81
+ }
82
+ ]
83
+ },
74
84
  {
75
85
  "matcher": "Edit|Write|MultiEdit",
76
86
  "hooks": [
@@ -22,7 +22,27 @@ You are the maker; you never sign off your own work.
22
22
  - **Never** join two DB families (`Core` + `Client_<Tenant>`) in one query — assemble in
23
23
  PHP across two connections.
24
24
  - **api2** — every response uses the envelope `{success, data, errors}`.
25
- - **Queues** — background work dispatched via `_Queue`, never instantiated directly.
25
+ - **Queues** — a worker is an `abstract class _Worker_<Name>` with `public static` entry
26
+ methods (no `extends _Worker`, no `run()`). Dispatch **only** via
27
+ `_Worker::runTask('Category/Sub/File/Method', $params)` — never call the entry method
28
+ directly; there is **no** `_Queue` class. Params are spread as named args, so a param name
29
+ is the job contract — renaming one breaks in-flight jobs; give defaults.
30
+ - **Schema** — no standalone `.sql` file ever goes in an app repo. All schema/seed changes go in
31
+ `dbchanges2` (2.0) or `dbchanges` (1.0); app repos hold only SQL strings inside PHP.
32
+ - **Shared core** — solve it in the app before touching `library` / `_underscore`. Before changing
33
+ a shared core method signature, **grep every repo for callers** (worker crons call via the
34
+ autoloader, no `require`).
35
+ - **Exceptions route (2.0)** — `_Exception_Validation` = 400, `_Exception_Business` = 500 + Issue,
36
+ plain `Exception` = 500 + Issue. **Never `throw new _Exception(...)`** (5-arg constructor →
37
+ `ArgumentCountError`).
38
+ - **Import/sync/cron loops fail loud** — never catch-and-continue / log-and-skip a record; a mid-loop
39
+ failure throws and aborts, leaving the checkpoint unmoved.
40
+ - **`LIMIT 1` / `fetchRow()` on a non-unique key** needs a deterministic `ORDER BY … , id`.
41
+ - **Overrides** — a `_Model_<Client>_*` override must not add a param type the parent lacks (fatal at
42
+ class load; `php -l` misses it). Commit a write with `_Database::transactionCommit()` — a lazy
43
+ transaction that never commits is silently rolled back.
44
+ - **1.0 runtime is PHP 7.2** — no typed properties (use `@var`), positional args (not named), and any
45
+ PHP warning `exit()`s the request (guard with `isset()`/`getVarIfSet()`, not `try/catch`).
26
46
  - **Coding style** — typed params + return types; guard clauses; no bare `catch`; no `@`
27
47
  suppression; named constants over magic numbers; functions with 4+ params take an array.
28
48
 
package/agents/cso.md CHANGED
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: cso
3
- description: TOGA Chief Security Officer — audits changes and gives an independent security verdict on proposed approaches before code ships. Wraps ecc:security-reviewer with TOGA-specific concerns (credential handling, multi-tenant client data isolation, SQL injection through the App_Db/_Db layer, queue/worker input validation, auth, OWASP) and the repo's secret-scan rule.
3
+ description: TOGA Chief Security Officer — audits changes and gives an independent security verdict on proposed approaches before code ships. Wraps ecc:security-reviewer with TOGA-specific concerns (credential handling, multi-tenant client data isolation, SQL injection via the escaping layer `_Database::escape` / `App_Database::sqlEscape`, queue/worker input validation, auth, OWASP) and the repo's secret-scan rule.
4
4
  model: opus
5
5
  tools: Read, Grep, Glob, Bash, Agent
6
6
  ---
@@ -33,14 +33,29 @@ report; own the synthesis.
33
33
  1. **Credentials** — no secret is ever written as a value anywhere (code, config, knowledge
34
34
  doc, this report). Credentials are documented by *location* only. Run
35
35
  `node knowledge.js validate` if knowledge files are touched; its secret-scan must pass.
36
+ **Config absence must throw, never fall back to a baked-in value** — a hardcoded-credential
37
+ "production fallback" is a committed secret (the live FedEx key incident). A committed
38
+ secret (a value in git, a `.npmrc` registry token) is compromised → rotate it.
36
39
  2. **Multi-tenant isolation** — no cross-tenant data access; every query is scoped to the
37
40
  correct `Client_<Name>` database; a tenant identifier coming from untrusted input is
38
- validated/authorized before it selects a database or filters rows.
39
- 3. **SQL injection** — all queries parameterized through `App_Db` / `_Db`; no string-built
40
- SQL, no untrusted input concatenated into a statement.
41
- 4. **Worker / queue payloads** — `_Queue` payloads are validated on the worker side; nothing
42
- trusts unvalidated input; payloads carry IDs (re-fetched server-side), not full rows that
43
- could be tampered with in transit.
41
+ validated/authorized before it selects a database or filters rows. **Never join two DB
42
+ families in one query** (`Core` + `Client_<Tenant>`) — separate prod clusters; this is the
43
+ physical form of tenant isolation. On the front end, **field-limit every request** so an
44
+ FK-expansion can't pull a bcrypt hash / LDAP DN into the client.
45
+ 2b. **Web-root exposure (1.0)** — a front controller guarded only by `RewriteCond !-f` is **not**
46
+ access control; config/secret files under the web root are served verbatim on a direct GET.
47
+ Require explicit `FilesMatch` deny blocks and secrets kept outside the root.
48
+ 3. **SQL injection** — there are **no prepared statements** in either framework; escaping/casting
49
+ **is** the control. Every interpolated value is cast (`(int)`) or escaped (`_Database::escape()`
50
+ in 2.0, `App_Database::sqlEscape()` in 1.0), or the query is built by the model layer
51
+ (`_Model` / `App_Model`). Flag: any raw untrusted value concatenated unescaped; any reference to a
52
+ non-existent `_Db` / `App_Db` class; `_Database::protect()` / `sqlProtect()` used as if it were
53
+ escaping (it **strips** characters — lossy filtering, data corruption); and any **identifier**
54
+ (table/column/`ORDER BY`/`LIMIT`) taken from input rather than a hardcoded allowlist (escaping does
55
+ not protect identifiers).
56
+ 4. **Worker / queue payloads** — worker jobs (dispatched via `_Worker::runTask`) validate input on the
57
+ worker side; nothing trusts unvalidated input; payloads carry IDs (re-fetched server-side), not full
58
+ rows that could be tampered with in transit.
44
59
  5. **Auth / authorization** — every new endpoint checks authentication *and* that the caller
45
60
  is permitted to act on that tenant's resource.
46
61
 
@@ -70,7 +85,7 @@ Findings
70
85
 
71
86
  (if none in a band, omit it; if no findings at all, say "No findings.")
72
87
 
73
- TOGA overlay: credentials ✓/✗ · tenant isolation ✓/✗ · App_Db/_Db params ✓/✗ ·
88
+ TOGA overlay: credentials ✓/✗ · tenant isolation ✓/✗ · SQL escaping (no `_Db`/prepared) ✓/✗ ·
74
89
  queue validation ✓/✗ · auth ✓/✗
75
90
 
76
91
  Verdict: SAFE TO SHIP | FIX REQUIRED | BLOCK — <one line>
package/agents/cto.md CHANGED
@@ -49,13 +49,21 @@ Confirm the leading choice respects TOGA's hard constraints. Flag any violation
49
49
  blocker, not a footnote:
50
50
 
51
51
  - **Naming** — new classes carry the right framework prefix (`App_` or `_`).
52
- - **DB layer** — all access through `App_Db` / `_Db`; no raw PDO in business logic.
53
- - **Queue/workers** — background work dispatched via `_Queue`, never instantiated directly.
52
+ - **DB layer** — access through `_Model` / `_Database` / `_Query` (2.0) or `App_Model` /
53
+ `App_Database` (1.0). There is **no** `_Db` / `App_Db` class and **no** prepared statements;
54
+ injection control is `_Database::escape()` / `(int)` cast (2.0) or `App_Database::sqlEscape()` (1.0).
55
+ - **Queue/workers** — a worker is an `abstract class _Worker_<Name>` with `public static` entry
56
+ methods; dispatch **only** via `_Worker::runTask('Category/Sub/File/Method', $params)`. There is
57
+ **no** `_Queue` class, and entry methods are never called directly.
54
58
  - **API envelope** — responses conform to `{success, data, errors}`.
55
59
  - **Multi-tenant isolation** — work is scoped to the correct `Client_<Name>` database; no
56
- design that lets one tenant's request touch another's data.
60
+ design that lets one tenant's request touch another's data. **Never join two DB families
61
+ (`Core` + `Client_<Tenant>`) in one query** — they are on separate prod clusters; assemble in PHP.
57
62
  - **Shared core** — does it change `library` / `_underscore`? If so, what depends on it and
58
63
  what breaks? Prefer app-level solutions over core edits unless core is the right home.
64
+ - **Standard vs custom** — if the design adds a custom field, flag that the standard-vs-custom
65
+ discussion must happen first. Schema/contract/core/migration choices are hard to reverse — that is
66
+ exactly what this gate exists to catch before code.
59
67
 
60
68
  ## Step 3 — Verdict
61
69
 
@@ -20,13 +20,44 @@ write the change.
20
20
  - **dbchanges2 (2.0 schema):**
21
21
  - Daily files use **today's** date, suffix `YYYY-MM-DD<letter>` — never future-date
22
22
  (future dates are reserved launch-later files).
23
- - `ALTER ADD COLUMN` appends at the end — always use `AFTER` to honor field order.
24
- - UUIDs must be fresh **random v4** — never patterned, incremented, or `UUID()`.
25
- `RecordFields` need `isIdentifier`.
23
+ - **One file per realm per turn** — consolidate same-database changes into one dated file;
24
+ different realms (folders) always split. A `.sql` file references only the ONE database its
25
+ folder targets, unqualified (the cluster-isolation hook hard-blocks a cross-database ref).
26
+ - **Column order:** `id`, `uuid`, then `dtCreated` immediately after `uuid`, then `dtUpdated`
27
+ immediately after `dtCreated`, then business columns. `ALTER ADD COLUMN` appends at the end —
28
+ always use `AFTER` to honor this order.
29
+ - UUIDs must be fresh **random v4** — never patterned, incremented, or `UUID()` (even in bulk;
30
+ bulk uses the inline `RANDOM_BYTES()` v4 expression). `RecordFields` need `isIdentifier`.
31
+ - **`Core.Records` / `Core.RecordFields` are the ONLY tables with team-maintained `id`s — ASK
32
+ the developer for the next value; never let `AUTO_INCREMENT` assign it.** The next free id
33
+ **differs per environment**, so a change that creates a new Core row and references it from
34
+ client rows may need a per-environment literal — say so in a header comment. Reference these
35
+ ids as hardcoded literals (including foreign keys), with a comment naming the field.
36
+ - **A fan-out `Client/` file must never reference a `customRecordFieldId` or a client-specific
37
+ label** — `CustomRecordFields` ids are per-client `AUTO_INCREMENT`, so the same number names a
38
+ different field in every tenant. Put anything touching a custom field in that client's own
39
+ `Client_<Name>/` file.
26
40
  - ENUM / `FIELD_LIST` values are `CAPITAL_SNAKE_CASE`; slugs stay lowercase-hyphen.
41
+ - **Guard every registration/seed insert with `NOT EXISTS` (or `INSERT IGNORE`)** so a re-run or
42
+ a parallel migration can't double-register (a double interceptor fires twice). Exception: for the
43
+ Surface reserved-id blocks use a **plain `INSERT`, never `INSERT IGNORE`** — a PK collision there
44
+ must halt, not silently mis-wire.
27
45
  - Seed baseline lookups into every `Client_*` DB with a re-runnable, guarded UNION-ALL
28
46
  anti-join INSERT (natural-key guard, INNER JOIN the FK).
47
+ - **Staging temp table must pin `COLLATE`** to the column it joins against (connection default is
48
+ often `utf8mb4_general_ci`, tenant tables `utf8mb4_unicode_ci` → error 1267). Stage **large**
49
+ loads via a temp table with a flat multi-row `VALUES`, not a long `UNION ALL` chain (parser
50
+ thread-stack overrun). A self-referencing `DELETE`/`INSERT…SELECT` guard must wrap its subquery
51
+ in a derived table (error 1093 / guard flips mid-statement).
52
+ - The blank `Client` DB must carry the **baseline data seed** (roles, full ACL, lookups, UI
53
+ config), not schema-only — a schema-only blank cannot authorize.
54
+ - New 2.0 tables: `InnoDB`, `utf8mb4` / `utf8mb4_0900_ai_ci`, and **table + field comments are
55
+ required** (`id`/`uuid`/FK have mandated comment formats; don't begin with "The").
29
56
  - Standardize machine keys on `slug` (deprecating `code`).
57
+ - **A committed migration records INTENT, not deployed state.** There is no automated executor and
58
+ operators hand-transpose Core ids at run time without always writing them back. Never infer what an
59
+ environment contains by reading `dbchanges2` — establish current state with read-only `SELECT`s
60
+ against the target's read replicas before authoring anything whose correctness depends on it.
30
61
  - **Custom vs standard field** — before adding any custom field, force the
31
62
  standard-vs-custom discussion with the developer first.
32
63
 
@@ -40,12 +40,13 @@ Read the framework standards file. Pull out: required naming, required parent cl
40
40
 
41
41
  **Framework 2.0 (_underscore) checklist:**
42
42
  - Worker class starts with `_Worker_` — CRITICAL if not
43
- - Worker extends `_Worker` — CRITICAL if not
44
- - Worker has `public function run(): void` — CRITICAL if missing
45
- - No direct `$worker->run()` call in other files — CRITICAL (use `_Queue::dispatch()`)
43
+ - Worker is `abstract class` with `public static` entry methods — CRITICAL if it `extends _Worker` or defines a `run()` method (that shape never dispatches)
44
+ - Worker entry method **parameter names are the public contract** — they are spread as named args at dispatch; renaming one breaks every in-flight job. Give a default where a sensible one exists — WARNING if a new required param has no default
45
+ - No direct call of a worker entry method from other files — CRITICAL (dispatch via `_Worker::runTask('Category/Sub/File/Method', $params)`; there is **no** `_Queue::dispatch()`)
46
46
  - Controller starts with `_Controller_` — CRITICAL if not
47
47
  - api2 actions return `{success, data, errors}` envelope — CRITICAL if not
48
- - DB access via `_Db::` only — WARNING if raw PDO in controllers/workers
48
+ - DB access via `_Model` / `_Database` / `_Query` — WARNING if it names a non-existent `_Db` class or reaches for PDO/prepared statements (neither exists)
49
+ - A `_Model_<Client>_*` override must **not** add a parameter type the parent lacks — CRITICAL (param types are contravariant, fatal at class load; `php -l` does **not** catch it)
49
50
 
50
51
  ## Step 4 — Check knowledge base for repo-specific rules
51
52
 
@@ -66,8 +67,9 @@ Framework: <1.0 (App_) | 2.0 (_underscore)>
66
67
 
67
68
  CRITICAL (will cause runtime failures):
68
69
  - Line 1: Class `OrderProcessor` must start with `_Worker_` → rename to `_Worker_OrderProcessor`
69
- - Line 8: Missing `run()` method — _Worker subclasses must implement run(): void
70
+ - Line 3: Worker declared `class ... extends _Worker` with a `run()` method — must be an `abstract class` with `public static` entry methods, or it never dispatches
71
+ - Line 20: Worker entry method called directly → dispatch via `_Worker::runTask('Orders/Process', $params)`
70
72
 
71
73
  WARNING:
72
- - Line 45: Raw `new _Db()` — use `_Db::select()` static method
74
+ - Line 45: References a non-existent `_Db` class — use `_Model` / `_Database` / `_Query`
73
75
  ```
@@ -27,6 +27,30 @@ reviewer checks your work, not you.
27
27
  TableView flags set — never listing-only.
28
28
  - **Desk is local-dev only.** Never plan or mention deploying it to qa-alpha or any env.
29
29
 
30
+ ## Platform rules (2.0 React — from `2.0/standards/frontend.md`)
31
+
32
+ - **Three state homes, no overlap:** React Query = all server/async state; Zustand = a minimal
33
+ identity/session slice (user, tenant, theme); `localStorage` = losable pure-UI prefs only. Server
34
+ data never lives in Zustand.
35
+ - **Reset on tenant/auth change.** On login/logout/tenant switch, clear the React Query cache **and**
36
+ tenant-scoped `localStorage` — one tenant's data must never bleed into another's session.
37
+ - **URL is the source of truth for table view state** (page, sort, filters, columns). Any param that
38
+ changes which rows the server returns must **reset the page to 1 in the same `setSearchParams`
39
+ updater** (one shared helper, row-click excluded) — miss it and "search returns nothing".
40
+ - **Consume blox correctly:** pin an **exact** published version (never a caret); **dedupe** `react`,
41
+ `react-dom`, and every peer blox provides hooks/context for (`react-hook-form`, `@tanstack/*`);
42
+ define the full host `--*` token set (blox ships zero token values); import `./api/api` (create → set
43
+ axios instance) **before** the router mounts; provider nesting is QueryClient → Auth → Toaster → theme
44
+ → router.
45
+ - **Field-limit every request** and send an explicit **`recordsPerPage`** — an unpaginated V2 list GET
46
+ silently truncates at 25 with no error and no guaranteed order.
47
+ - **Type-check with `tsc -p tsconfig.app.json` (or `tsc -b`), never bare `tsc --noEmit`** — the root
48
+ config is references-only and always exits 0 (a false green).
49
+ - **Tests:** the tenant×role×feature matrix goes in Cypress **component** tests, not e2e; **stub the
50
+ Surface bundles** (never hit a real backend — it can write real data); every new test is **seen to
51
+ fail** before it passes. Green FE tests do **not** enable a feature — that is a role-scoped
52
+ `SurfaceOverrides IS_VISIBLE` row in `dbchanges2`.
53
+
30
54
  ## Steps
31
55
 
32
56
  1. **Read** the design source and the components you'll reuse. Search the KB for the area.
@@ -30,7 +30,7 @@ Always read `.claude/knowledge/CONVENTIONS.md`. It is the contract — frontmatt
30
30
  Check `.claude/knowledge/registry.json` for canonical `project` and `framework`. Required fields:
31
31
  ```yaml
32
32
  title: <human-readable title>
33
- framework: "<1.0 | 2.0>"
33
+ framework: "<1.0 | 2.0 | standalone>"
34
34
  repo: <repo-name from registry>
35
35
  project: <project-name from registry>
36
36
  client: <client-slug | "shared">
@@ -55,6 +55,11 @@ Search first: `node .claude/knowledge.js search --repo=<repo> --q=<keywords>`
55
55
 
56
56
  Structure: YAML frontmatter → one-paragraph overview → main content → code examples (correct framework prefix).
57
57
 
58
+ **On an UPDATE, append to `## Change history`, don't silently overwrite.** Every standard/architecture
59
+ doc ends with a dated, attributed change-history list. Add a new line —
60
+ `- YYYY-MM-DD — <what changed and why>. (<git-username>)` — and bump `updated:` in the frontmatter.
61
+ Prefer a small UPDATE to an existing doc over a new near-duplicate.
62
+
58
63
  ## Step 6 — Validate and re-index
59
64
 
60
65
  Always run after writing:
@@ -46,10 +46,23 @@ After ECC's findings, run these TOGA-specific checks:
46
46
  - Public methods in `camelCase`
47
47
 
48
48
  **Framework 2.0 (_underscore):**
49
- - Workers extend `_Worker` and implement `run()`
50
- - Workers dispatched via `_Queue::dispatch()`, never instantiated directly
49
+ - Workers are `abstract class _Worker_<Name>` with `public static` entry methods — they do **not** extend `_Worker` and have **no** `run()` method
50
+ - Workers dispatched **only** via `_Worker::runTask('Category/Sub/File/Method', $params)` — never call an entry method directly, and there is **no** `_Queue::dispatch()` (`_Queue` does not exist)
51
51
  - `api2` actions return `{success: bool, data: mixed, errors: array}` envelope
52
- - DB access via `_Db::` methods only in business logic
52
+ - DB access via `_Model` / `_Database` / `_Query` — there is **no** `_Db` class. SQL-injection control is the model layer, else `_Database::escape()` / `(int)` cast; **no prepared statements exist** in either framework (verified 2026-07-30)
53
+
54
+ **Both frameworks — high-signal checks:**
55
+ - **`LIMIT 1` / `fetchRow()` on a non-unique key needs a deterministic `ORDER BY … , id`** — an unordered single-row read returns an arbitrary row (a real 6-day approval bug).
56
+ - **Import/sync/cron loops must fail loud** — no catch-and-continue / log-and-skip a record; a mid-loop failure throws and aborts so the checkpoint/cursor stays put. (Per-entity request/response code is the *only* place a catch-and-return is correct.)
57
+ - **A `_Model_<Client>_*` override must not add a parameter type the parent lacks** — param types are contravariant, fatal at class load, and `php -l` does **not** catch it.
58
+
59
+ **Framework 2.0 — exceptions route, they aren't style:**
60
+ - `_Exception_Validation` = 400 (bad client input, no Issue) · `_Exception_Business` = 500 + `Logs.Issue` (business user can act) · plain `Exception` = 500 + Issue (code defect). **Never `throw new _Exception(...)`** — its constructor needs 5 args and raises `ArgumentCountError` before your exception exists. Never "upgrade" a client-input rejection to `_Exception_Business` just to log it.
61
+
62
+ **Framework 1.0 (App_) — runtime is PHP 7.2, and warnings kill the request:**
63
+ - **No typed properties** (`private ?string $x` is a fatal); use an untyped property + `@var` docblock. Param/return types are fine.
64
+ - **Positional args only** — do **not** use PHP 8 named arguments (that is the 2.0 convention).
65
+ - **Any PHP warning `exit()`s the request** (`App_Error::handleError` → `exit`) — a `try/catch` can't contain it; guard optional data with `isset()`/`getVarIfSet()` first. `App_Page::getVar()` **throws** on a missing field; use `getVarIfSet()` for optional fields.
53
66
 
54
67
  ## Step 5 — Merge and output
55
68
 
@@ -62,8 +75,8 @@ ECC analysis: deep PSR-12, security, type system | TOGA overlay: framework patte
62
75
 
63
76
  | File:Line | Severity | Category | Issue | Fix |
64
77
  |-----------|----------|----------|-------|-----|
65
- | Foo.php:67 | CRITICAL | TOGA-PATTERN | Worker instantiated with `new _Worker_Notify` | Use `_Queue::dispatch('_Worker_Notify', $payload)` |
66
- | Foo.php:34 | CRITICAL | SQL-INJECTION | $_POST['id'] concatenated into query | PDO prepared statement |
78
+ | Foo.php:67 | CRITICAL | TOGA-PATTERN | Worker entry method called directly | Dispatch via `_Worker::runTask('Platform/Cache/Clean', ['graceSeconds' => 120])` |
79
+ | Foo.php:34 | CRITICAL | SQL-INJECTION | `$_POST['id']` concatenated into query | Cast `(int) $_POST['id']`, or `_Database::escape()` for a string, or use the `_Model` layer (no prepared statements) |
67
80
  | Foo.php:89 | WARNING | PSR-12 | Missing return type on public method | Add `: array` return type |
68
81
 
69
82
  **Summary:** 2 critical (1 TOGA-pattern, 1 security), 1 warning
package/agents/planner.md CHANGED
@@ -41,8 +41,10 @@ Known patterns for this feature type:
41
41
  [paste any matching feature doc content]
42
42
 
43
43
  TOGA-SPECIFIC CONSTRAINTS:
44
- - Workers dispatched via _Queue::dispatch(), never instantiated directly
45
- - DB access via _Db:: / App_Db:: only (no raw PDO in business logic)
44
+ - A worker is an abstract class _Worker_<Name> with public static entry methods (no extends _Worker, no run()); dispatch only via _Worker::runTask('Category/Sub/File/Method', $params). There is no _Queue class.
45
+ - DB access via _Model / _Database / _Query (2.0) or App_Model / App_Database (1.0). There is no _Db / App_Db class and no prepared statements — injection control is _Database::escape() / (int) cast, or App_Database::sqlEscape().
46
+ - Never join two DB families (Core + Client_<Tenant>) in one query — assemble in PHP across two connections. Schema changes go in dbchanges2 (2.0) / dbchanges (1.0), never in an app repo.
47
+ - Solve it in the app before touching shared core (library / _underscore); grep all repos for callers before changing a shared core signature.
46
48
  - API responses must follow {success, data, errors} envelope (api2)
47
49
  - New classes must follow framework naming prefix (App_ or _)
48
50
  - No local repo paths in any committed file
@@ -74,12 +76,12 @@ Repo: <repo> | Framework: <fw> | Architecture loaded: ✓ | ECC planning: ✓
74
76
  Goal: [what is testable after this phase]
75
77
 
76
78
  **Create:**
77
- - `src/Workers/BatchOrderWorker.php` — extends _Worker, implements run()
78
- Follow pattern from: _underscore/src/Workers/FulfillmentWorker.php
79
+ - `Worker/Order/Batch.php` — `abstract class _Worker_Order_Batch` with a `public static` entry method (no `extends _Worker`, no `run()`)
80
+ Follow pattern from an existing worker2 `Worker/*/*.php` class
79
81
 
80
82
  **Modify:**
81
83
  - `src/Controllers/OrderController.php` — add dispatchBatch() after createOrder() (~line 87)
82
- Use: `_Queue::dispatch('_Worker_BatchOrder', ['order_ids' => $ids])`
84
+ Use: `_Worker::runTask('Order/Batch/Process', ['orderIds' => $ids])`
83
85
 
84
86
  **Verify:** [concrete test steps]
85
87
 
@@ -25,6 +25,16 @@ worked. You did not write the code — that separation is the point.
25
25
  a test pass. A failing test is information.
26
26
  - **Critical path** (auth, payments, sync, anything hard to undo) — prove it with a real
27
27
  run and real evidence, not just `php -l`.
28
+ - **Non-prod lies to you — a green pre-prod run is often no evidence.** Know these traps:
29
+ - **Cross-DB-family SQL and `dbchanges2` migrations** run fine locally/on beta (every non-prod is
30
+ all-in-one) and fail only in production (families are on separate clusters). Also `COLLATE` /
31
+ `sql_mode` / `thread_stack` vary per machine — a local pass isn't proof of portability.
32
+ - **Queued email** sends **inline** outside production (`test_mode` on in every non-prod), so a
33
+ queue-path bug passes on alpha/beta while prod stays broken. Verify queue changes in prod or a
34
+ serialize→drain harness.
35
+ - **Green front-end tests do NOT mean a client has the feature** — the fixture only simulates the
36
+ grant; real enablement is a role-scoped `SurfaceOverrides IS_VISIBLE` row in `dbchanges2`. Never
37
+ close an enablement ticket on a passing FE suite.
28
38
 
29
39
  ## Steps
30
40
 
@@ -105,6 +105,16 @@ Maker ≠ checker: do not let the agent that wrote the SQL review it.
105
105
  - Long-running/background DB work inline in a request instead of dispatched via `_Worker::runTask()` → **WARNING**.
106
106
  - Reliance on stale cached reads immediately after a write without `_Database::useQueryCache(false)` → **WARNING**.
107
107
 
108
+ ### 3g. Cross-cluster, determinism & platform ids (2.0 + shared)
109
+ - **Never join or sub-select across two DB families in one query** (`Core` + `Client_<Tenant>`, `Client_` + `Archive_`, `Client_` + `Logs_`, etc.) → **CRITICAL**. In production these families are on **separate clusters**, so the query cannot resolve there — it works locally only. Assemble across families in PHP over two `_Database` connections, referencing the other side by id/uuid/slug. (A local/beta pass is **no** evidence it is cluster-safe.)
110
+ - **`LIMIT 1` / `fetchRow()` on a non-unique key needs a deterministic `ORDER BY` ending in a unique column** (`ORDER BY isActive DESC, id DESC`) → **WARNING**. An unordered `LIMIT 1` returns an arbitrary row; the same bug hides in `fetchRow()` with no `LIMIT 1` to grep for. **Order, don't filter**, when "no row" is worse than "a stale row".
111
+ - **Escaping protects values, not identifiers.** A table/column name, sort direction, or `LIMIT` taken from input must come from a **hardcoded allowlist** — `ORDER BY " . _Database::escape($_GET['sort'])` is still injectable → **CRITICAL**.
112
+ - **`Core.Records` / `Core.RecordFields` are the only tables with team-maintained `id`s.** A `recordId`/`recordFieldId` referenced from SQL should be a **hardcoded literal with a comment naming the field** — flag a cross-family `JOIN` to `Core` used only to resolve one of these ids.
113
+ - **2.0 leading column order:** `id`, `uuid`, then `dtCreated` immediately after `uuid`, then `dtUpdated` immediately after `dtCreated`, then business columns → **WARNING** if out of order. On `ALTER TABLE ADD COLUMN` timestamps, require `AFTER` (a bare `ADD COLUMN` appends to the end).
114
+ - **`Logs` cluster tables are SINGULAR** (`Issue`, `Event`, not `Issues`) — the one exception to the plural-table rule. Every other database is plural.
115
+ - **Validate `FIELD_LIST` (ENUM) values in PHP before the query** — `FIELD_LIST` does no validation and turns a falsy/unknown value into SQL `NULL` silently → **WARNING**.
116
+ - **1.0 only:** if a change removes `sqlProtect()` from a `LIKE` comparison, `LIKE` must become `=` in the same edit (the strip was the only thing stopping a `%` in user data acting as a wildcard) → **WARNING**.
117
+
108
118
  ## Output format
109
119
 
110
120
  ```
@@ -32,6 +32,9 @@ Decide and state explicitly:
32
32
  `{success, data, errors}` on the way out.
33
33
  - **Reuse** — which existing components/data layers to reuse before making new ones
34
34
  (blox components, TableView columns, Surface/settings, existing models).
35
+ - **Data-drive the UI** — labels, colors, icons, options come from Surface/settings and
36
+ `labelRecordFieldId`, not hardcoded. If the design needs a **custom field**, flag that the
37
+ standard-vs-custom discussion must happen with the developer before it is added.
35
38
  - **Multi-tenant** — which `Client_<Tenant>` scope; confirm no cross-cluster query
36
39
  (never join two DB families in one statement — assemble in PHP across two connections).
37
40
 
@@ -6,7 +6,7 @@ project: Library
6
6
  client: nychh
7
7
  type: client-feature
8
8
  status: active
9
- updated: 2026-09-14
9
+ updated: 2026-09-16
10
10
  owners: [jcardinal, bala, rgirish]
11
11
  files:
12
12
  - library/app/api/toga2.php
@@ -15,11 +15,11 @@ files:
15
15
  - worker/crons/toga2/netsuite/sync_togasupply_hh.php
16
16
  - worker/crons/toga2/netsuite/common_sync_togasupply.php
17
17
  - worker/crons/toga2/netsuite/diagnose_hh_stuck_transfer_order.php
18
+ - worker/crons/toga2/netsuite/probe_import_single_order_hh.php
19
+ - worker/crons/toga2/netsuite/relink_transfer_order_pos_hh.php
18
20
  - dbchanges2/Core/2026-08-27a - TransferOrderUuidSearchableIdentifier.sql
19
21
  - dbchanges2/Client/2026-08-28a - TransferOrderItemsTimestampColumns.sql
20
- - dbchanges2/Client/2026-08-28b - TransferOrderItemsTimestampColumnsReposition.sql
21
- - dbchanges2/Client/2026-08-29 - ItemReceiptTransferOrderIdFieldPermission.sql
22
- - dbchanges2/Client/2026-08-29b - ItemReceiptItemTransferOrderItemIdFieldPermission.sql
22
+ - dbchanges2/Client_Nychh/2026-09-16a - TransferOrderBridgeAcl.sql
23
23
  related:
24
24
  - ../../../1.0/apps/worker/features/netsuite-togasupply-per-client-sync.md
25
25
  - ../../../1.0/apps/library/features/toga2-api-client-and-bridge.md
@@ -92,6 +92,80 @@ sales-order linker. See the shared
92
92
  [SO↔PO bridge direction](../../../2.0/apps/_underscore/features/sales-order-purchase-order-bridge-direction.md)
93
93
  doc for the bridge naming convention.
94
94
 
95
+ ### ⚠ The bridge links were never being created — three stacked causes, fixed + prod-verified 2026-09-16
96
+
97
+ The PO↔TO bridge links (header **and** line) were silently never forming for NYCHH. Three
98
+ independent faults, peeled in order; the last was the real blocker:
99
+
100
+ 1. **Linking only ever ran from the `PURCHASE_ORDERS` section, which never runs for NYCHH.** The
101
+ links depended on the PO section, so a transfer-order import created none. **Fix:** a new **public**
102
+ `App_Api_Toga2::linkTransferOrderPurchaseOrders()` now runs on **every** transfer-order
103
+ import/retry from inside `syncTransferOrderFromNetsuite()`. It is **idempotent** — re-running an
104
+ import repairs any missing link. In `TRANSFER_ORDER_DETECTION_MODE__STOCKING_FLAG` mode it finds
105
+ the customer PO by the NetSuite **PO #** (`otherRefNum`, split on `' + '`) under the
106
+ `clientConfiguration['importCustomerPurchaseOrdersOnVendor']` vendor (the synthetic **Agilant**
107
+ vendor), then calls `linkPurchaseOrderToTransferOrder` for the header
108
+ (`PurchaseOrders_TransferOrders`) and line (`PurchaseOrderItems_TransferOrderItems`) links.
109
+ 2. **`linkPurchaseOrderToTransferOrder` read the TO line items from the wrong place, so line links
110
+ never formed.** It read `$transferOrder->transferOrderItems`, but **api2 NEVER inlines that
111
+ collection at any depth** (`transfer-orders` has no `InherentRecordChildren` row — same root cause
112
+ as the idempotency bug), so it was always empty. **Fix:** read TO items via
113
+ `getTransferOrderItemsByUuid` (the reliable direct `GET /transfer-order-items` query). Also lowered
114
+ the TO existence GET to `depth=1` and added `recordsPerPage=1000` to the PO-items GET.
115
+ 3. **ROOT CAUSE — an ACL gap, not code.** The NYCHH **API** role had **no write ACL** on the two
116
+ bridge records **325** (`PurchaseOrders_TransferOrders`) and **329**
117
+ (`PurchaseOrderItems_TransferOrderItems`), so the link POST failed api2 **EZ-3 / WZ-2** ("no
118
+ fields in which you are authorized to write"). The working **sales-order** bridge (record **276**,
119
+ `PurchaseOrders_SalesOrders`) already had the grant, which is why SO links worked and TO links did
120
+ not. **Also key: on CREATE, api2 `isPermitted()` DENIES a record permission that has no
121
+ `AclLogicGroups` chain — a bare `AclRecordPermissions` row is inert** (the standard four-table
122
+ chain rule — see [ACL permission chain](../../../2.0/apps/_underscore/features/acl-permission-chain.md)).
123
+
124
+ **Fix migration:** `dbchanges2/Client_Nychh/2026-09-16a - TransferOrderBridgeAcl.sql` seeds the full
125
+ four-table chain (`AclRecordExpressions` `'all'` → `AclRecordPermissions` full CRUD → `AclLogicGroups`
126
+ AND → `AclLogicGroupExpressions`) **plus** `AclFieldPermissions isWritable = 1`, for roles **Base**
127
+ and **API**, on records **325** and **329**, mirroring the working record 276. Guarded/re-runnable,
128
+ roles resolved by name, fresh random v4 uuids, `Core` ids hardcoded (no `Client_*`→`Core` JOIN — the
129
+ cross-cluster rule). The only writable fields a bridge POST touches are the row `uuid` + the two FK
130
+ columns. **Data facts:** Core bridge record ids **276**=`PurchaseOrders_SalesOrders`,
131
+ **325**=`PurchaseOrders_TransferOrders`, **329**=`PurchaseOrderItems_TransferOrderItems`; Core
132
+ `RecordFields` **2210**=uuid/**2211**=purchaseOrderId/**2212**=transferOrderId (rec 325),
133
+ **2226**=uuid/**2227**=purchaseOrderItemId/**2228**=transferOrderItemId (rec 329); `Client_Nychh`
134
+ roles **1**=Base, **2**=Developer, **3**=API, **4**=Public. Reviews: `cto` AGREE (four-table chain),
135
+ `sql-reviewer` SAFE TO MERGE.
136
+
137
+ **ACL failures are now LOUD in the linker.** Both bridge existence checks **THROW** when their GET
138
+ returns no `meta->totalRecordCount` (an ACL **read** gap) instead of silently skipping the create;
139
+ the create POST already throws on a write-fail (EZ-3/WZ-2). So any future ACL gap stops the run
140
+ rather than quietly producing zero links.
141
+
142
+ **Verified in prod:** link `PurchaseOrderItems.id = 589` ↔ `TransferOrderItems.id = 7155` created for
143
+ PO **EIT0012548** ↔ TO **284624** (NetSuite SO **7326440**).
144
+
145
+ ### PO discovery for a NYCHH transfer order — `otherRefNum` + Agilant vendor, NEVER `createdPo`
146
+
147
+ For NYCHH the SO **line-level `createdPo` is NULL**; the PurchaseOrder is the **customer PO**
148
+ (`otherRefNum`, the NetSuite "PO #" field) imported against the synthetic **Agilant** vendor. So PO
149
+ discovery from a transfer order must use `otherRefNum` + the Agilant vendor — never `createdPo`.
150
+ NetSuite proof: SO **7326440** `tranid` 284624, `otherrefnum` EIT0012548; its lines' `createdpo` are
151
+ all null; in NetSuite it links only to an invoice + item fulfillment (there is **no** PurchaseOrder
152
+ record on the SO itself).
153
+
154
+ ### Operator tools for this path (manual CLI, NOT scheduled)
155
+
156
+ - **`worker/crons/toga2/netsuite/probe_import_single_order_hh.php`** — imports/relinks **one**
157
+ NetSuite order by internal id for NYCHH. Builds lookups by requiring `common_sync_togasupply.php`
158
+ with all `IS_ENABLED_INTEGRATION_*` false, fetches via `fetchSalesOrderByIdFull`, routes via
159
+ `isTransferOrder`, runs `syncTransferOrderFromNetsuite`, then prints diagnostics (`[diag]` PO
160
+ lookup, `[trace]` header-link POST response) and verifies header + line links. No cursor writes /
161
+ no process lock.
162
+ - **`worker/crons/toga2/netsuite/relink_transfer_order_pos_hh.php`** — backfills links for **all**
163
+ existing `Client_Nychh` transfer orders. Pages every `TransferOrders` row, fetches each NetSuite
164
+ order for its PO #, runs only the link step (`App_Api_Toga2::linkTransferOrderPurchaseOrders` — no
165
+ re-import), idempotent, per-order `try/catch` that logs + collects failures (a sanctioned
166
+ operator-tool exception to fail-loud), with a live progress bar + counts + end summary. Optional
167
+ first arg = limit.
168
+
95
169
  ## Transfer-order origin/destination location resolution
96
170
 
97
171
  `TransferOrders.originLocationId` is **NOT NULL**, so a missing origin fails the V2 insert with
@@ -384,6 +458,25 @@ item receipts replays about two years unless it is rewound forward first.
384
458
 
385
459
  ## Change history
386
460
 
461
+ - 2026-09-16 - **RESOLVED: the PO↔TO bridge links were never being created** (fixed end-to-end,
462
+ prod-verified: PO EIT0012548 ↔ TO 284624 / SO 7326440, link `PurchaseOrderItems.id=589` ↔
463
+ `TransferOrderItems.id=7155`). Three stacked causes: (1) linking only ran from the `PURCHASE_ORDERS`
464
+ section, which never runs for NYCHH → new **public** `linkTransferOrderPurchaseOrders()` now runs on
465
+ every TO import/retry, idempotent, finds the customer PO by `otherRefNum` (split on `' + '`) on the
466
+ Agilant vendor (`clientConfiguration['importCustomerPurchaseOrdersOnVendor']`); (2)
467
+ `linkPurchaseOrderToTransferOrder` read TO items from `$transferOrder->transferOrderItems`, which
468
+ api2 NEVER inlines → now reads via `getTransferOrderItemsByUuid` (also lowered the TO GET to depth 1,
469
+ added `recordsPerPage=1000` to the PO-items GET); (3) **ROOT CAUSE** — the NYCHH API role had no
470
+ write ACL on bridge records **325** (`PurchaseOrders_TransferOrders`) / **329**
471
+ (`PurchaseOrderItems_TransferOrderItems`), so the POST failed **EZ-3/WZ-2**; the working SO bridge
472
+ (rec 276) had it. Fix migration `dbchanges2/Client_Nychh/2026-09-16a - TransferOrderBridgeAcl.sql`
473
+ seeds the full four-table chain + `AclFieldPermissions` for roles Base + API on 325/329, mirroring
474
+ 276 (guarded, roles by name, random v4 uuids, Core ids hardcoded). Also made ACL failures loud in
475
+ the linker (both existence GETs THROW on no `totalRecordCount`). Recorded the NYCHH PO data model:
476
+ `createdPo` is NULL, the PO is the customer PO (`otherRefNum`) on the Agilant vendor. Added two
477
+ manual operator tools (`probe_import_single_order_hh.php`, `relink_transfer_order_pos_hh.php`).
478
+ `cto` AGREE, `sql-reviewer` SAFE TO MERGE. **Deploy: library + worker together (via a WORKER
479
+ deploy — the worker box runs its own `/var/www/library` clone).** (jcardinal)
387
480
  - 2026-09-14 - **RESOLVED: the INVENTORY_ADJUSTMENTS destination wedge.** Root cause (prod): TO 277871
388
481
  (SO 7102750) has customer **3997 "1896 Laurel Hill Management, LLC"** — not a NYCHH customer and with
389
482
  **no `Client_Nychh.Locations` row**, so the destination never resolved and the NOT NULL insert 1048'd
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "toga-ai",
3
- "version": "1.0.826",
3
+ "version": "1.0.828",
4
4
  "description": "TOGA Technology Team Claude Knowledge System — shared AI coding harness with skills, knowledge base CLI, and project installer for Claude Code.",
5
5
  "keywords": [
6
6
  "claude",
@@ -1,7 +1,25 @@
1
1
  'use strict';
2
- const input = (() => { try { return JSON.parse(process.env.CLAUDE_TOOL_INPUT || '{}'); } catch { return {}; } })();
2
+ const fs = require('fs');
3
+
4
+ /* Read the tool payload from stdin (how this harness delivers it) first, then
5
+ * fall back to the CLAUDE_TOOL_INPUT env var. Reading env only silently misses
6
+ * every call when the harness pipes JSON on stdin. */
7
+ function readToolInput() {
8
+ let raw = '';
9
+ try { raw = fs.readFileSync(0, 'utf8'); } catch (e) { /* no stdin */ }
10
+ if (raw && raw.trim()) {
11
+ try {
12
+ const d = JSON.parse(raw);
13
+ if (d && d.tool_input) return d.tool_input;
14
+ } catch (e) { /* fall through */ }
15
+ }
16
+ try { return JSON.parse(process.env.CLAUDE_TOOL_INPUT || '{}'); } catch (e) { return {}; }
17
+ }
18
+
19
+ const input = readToolInput();
3
20
  const command = typeof input.command === 'string' ? input.command : '';
4
21
  if (/rm\s+-rf|DROP\s+TABLE|DROP\s+DATABASE/i.test(command)) {
5
- console.log('BLOCKED: Destructive command detected. If intentional, run it manually outside Claude Code.');
22
+ // exit 2 surfaces the reason from STDERR; stdout shows an opaque "No stderr output".
23
+ process.stderr.write('BLOCKED: destructive command detected (rm -rf / DROP TABLE / DROP DATABASE). If intentional, run it manually outside Claude Code.\n');
6
24
  process.exit(2);
7
25
  }
@@ -18,7 +18,12 @@
18
18
 
19
19
  if (process.env.TOGA_FRONTEND_REVIEW_DISABLED === '1') process.exit(0);
20
20
 
21
+ const fs = require('fs');
22
+ // Read the tool payload from stdin first (this harness delivers it there), then env.
21
23
  const input = (() => {
24
+ let raw = '';
25
+ try { raw = fs.readFileSync(0, 'utf8'); } catch (e) {}
26
+ if (raw && raw.trim()) { try { const d = JSON.parse(raw); if (d && d.tool_input) return d.tool_input; } catch (e) {} }
22
27
  try { return JSON.parse(process.env.CLAUDE_TOOL_INPUT || '{}'); } catch { return {}; }
23
28
  })();
24
29
 
@@ -2,7 +2,22 @@
2
2
  const os = require('os');
3
3
  const fs = require('fs');
4
4
  const path = require('path');
5
- const scopeFile = path.join(os.tmpdir(), 'toga-scope-' + (process.ppid || process.pid) + '.count');
5
+
6
+ /* Key the scope file on the CC session id — MUST match scope-counter.js, which
7
+ * writes the same file. It previously keyed on process.ppid while scope-counter
8
+ * keyed on session_id, so this hook read a different (empty) file and never fired. */
9
+ function sessionKey() {
10
+ let sid = '';
11
+ try {
12
+ const raw = fs.readFileSync(0, 'utf8');
13
+ if (raw && raw.trim()) sid = JSON.parse(raw).session_id || '';
14
+ } catch (e) { /* no stdin */ }
15
+ sid = sid || process.env.CLAUDE_SESSION_ID || process.env.CLAUDE_CODE_SESSION_ID ||
16
+ String(process.ppid || process.pid);
17
+ return sid.replace(/[^A-Za-z0-9_-]/g, '').slice(0, 64) || 'x';
18
+ }
19
+
20
+ const scopeFile = path.join(os.tmpdir(), 'toga-scope-' + sessionKey() + '.count');
6
21
  try {
7
22
  const count = parseInt(fs.readFileSync(scopeFile, 'utf8').trim(), 10) || 0;
8
23
  if (count > 0) {
@@ -1,5 +1,12 @@
1
1
  'use strict';
2
- const input = (() => { try { return JSON.parse(process.env.CLAUDE_TOOL_INPUT || '{}'); } catch { return {}; } })();
2
+ const fs = require('fs');
3
+ // Read the tool payload from stdin first (this harness delivers it there), then env.
4
+ const input = (() => {
5
+ let raw = '';
6
+ try { raw = fs.readFileSync(0, 'utf8'); } catch (e) {}
7
+ if (raw && raw.trim()) { try { const d = JSON.parse(raw); if (d && d.tool_input) return d.tool_input; } catch (e) {} }
8
+ try { return JSON.parse(process.env.CLAUDE_TOOL_INPUT || '{}'); } catch { return {}; }
9
+ })();
3
10
  const filePath = typeof input.file_path === 'string' ? input.file_path : '';
4
11
  if (/\.php$/i.test(filePath)) {
5
12
  console.log('PHP file edited. Consider running /code-review, or use the php-reviewer agent for security and framework pattern checks.');
@@ -11,7 +11,12 @@
11
11
  * into a deployment that a SQL review would have caught.
12
12
  */
13
13
 
14
+ const fs = require('fs');
15
+ // Read the tool payload from stdin first (this harness delivers it there), then env.
14
16
  const input = (() => {
17
+ let raw = '';
18
+ try { raw = fs.readFileSync(0, 'utf8'); } catch (e) {}
19
+ if (raw && raw.trim()) { try { const d = JSON.parse(raw); if (d && d.tool_input) return d.tool_input; } catch (e) {} }
15
20
  try { return JSON.parse(process.env.CLAUDE_TOOL_INPUT || '{}'); } catch { return {}; }
16
21
  })();
17
22
 
@@ -0,0 +1,71 @@
1
+ 'use strict';
2
+ const fs = require('fs');
3
+
4
+ /*
5
+ * sql-file-location.js — DENY creating a standalone `.sql` file anywhere but the
6
+ * migration repos.
7
+ *
8
+ * WHY: the team standard is absolute — schema/seed SQL lives ONLY in `dbchanges2`
9
+ * (2.0) or `dbchanges` (1.0). Application/core repos (`worker2`, `api2`,
10
+ * `_underscore`, `worker`, `library`, …) contain SQL only as query strings inside
11
+ * PHP — never a standalone `.sql` migration file. A `.sql` created in an app repo
12
+ * is invisible to the migration executor and never runs.
13
+ * See 2.0/standards/framework-rules.md + 1.0/standards/framework-rules.md
14
+ * → "Schema migrations — always in dbchanges(2)".
15
+ *
16
+ * A `.sql` under a `dbchanges2/` or `dbchanges/` path (including its `HISTORIC/`)
17
+ * is allowed. Vendored/third-party trees are skipped to avoid false positives.
18
+ *
19
+ * PreToolUse (Write|Edit|MultiEdit). Deny = stderr + exit 2.
20
+ * Escape hatch (false positives ONLY): TOGA_SQL_LOCATION_DISABLED=1
21
+ * Fail-open on any internal error.
22
+ */
23
+
24
+ function readToolInput() {
25
+ let raw = '';
26
+ try { raw = fs.readFileSync(0, 'utf8'); } catch (e) { /* no stdin */ }
27
+ if (raw && raw.trim()) {
28
+ try {
29
+ const d = JSON.parse(raw);
30
+ if (d && d.tool_input) return d.tool_input;
31
+ } catch (e) { /* fall through */ }
32
+ }
33
+ try { return JSON.parse(process.env.CLAUDE_TOOL_INPUT || '{}'); } catch (e) { return {}; }
34
+ }
35
+
36
+ function main() {
37
+ if (process.env.TOGA_SQL_LOCATION_DISABLED === '1') return;
38
+
39
+ const input = readToolInput();
40
+ const filePath = typeof input.file_path === 'string' ? input.file_path : '';
41
+ if (!/\.sql$/i.test(filePath)) return;
42
+
43
+ const norm = filePath.replace(/\\/g, '/').toLowerCase();
44
+
45
+ // Allowed home: the migration repos (and their HISTORIC archive).
46
+ if (/(^|\/)dbchanges2?\//.test(norm)) return;
47
+
48
+ // Skip vendored / generated trees — not our code to police.
49
+ if (/(^|\/)(vendor|node_modules|\.git)\//.test(norm)) return;
50
+
51
+ const out = [
52
+ 'BLOCKED: a standalone `.sql` file may only live in a migration repo.',
53
+ '',
54
+ ' File: ' + filePath,
55
+ '',
56
+ 'Team standard: all schema/seed SQL goes in `dbchanges2` (2.0) or `dbchanges` (1.0).',
57
+ 'Application and core repos (worker2, api2, _underscore, worker, library, …) hold SQL',
58
+ 'ONLY as query strings inside PHP — never a standalone `.sql` migration file. A `.sql`',
59
+ 'created here is invisible to the migration executor and will never run.',
60
+ '',
61
+ 'FIX: create the file under the matching `dbchanges2/<folder>/` (2.0) or `dbchanges/`',
62
+ '(1.0) path instead, named `YYYY-MM-DD<letter> - <Description>.sql`.',
63
+ 'See 2.0/standards/framework-rules.md → "Schema migrations — always in dbchanges2".',
64
+ '',
65
+ '(False positive? Set TOGA_SQL_LOCATION_DISABLED=1 for this one case.)',
66
+ ];
67
+ process.stderr.write(out.join('\n') + '\n');
68
+ process.exit(2);
69
+ }
70
+
71
+ try { main(); } catch (e) { /* fail-open */ }