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.
- package/.claude/settings.json +10 -0
- package/agents/backend-lead.md +21 -1
- package/agents/cso.md +23 -8
- package/agents/cto.md +11 -3
- package/agents/database-architect.md +34 -3
- package/agents/framework-pattern-checker.md +8 -6
- package/agents/frontend-lead.md +24 -0
- package/agents/knowledge-writer.md +6 -1
- package/agents/php-reviewer.md +18 -5
- package/agents/planner.md +7 -5
- package/agents/qa-engineer.md +10 -0
- package/agents/sql-reviewer.md +10 -0
- package/agents/system-architect.md +3 -0
- package/knowledge/clients/nychh/features/netsuite-transfer-order-import.md +97 -4
- package/package.json +1 -1
- package/scripts/hooks/block-destructive.js +20 -2
- package/scripts/hooks/remind-frontend-review.js +5 -0
- package/scripts/hooks/remind-php-lint.js +16 -1
- package/scripts/hooks/remind-php-review.js +8 -1
- package/scripts/hooks/remind-sql-review.js +5 -0
- package/scripts/hooks/sql-file-location.js +71 -0
package/.claude/settings.json
CHANGED
|
@@ -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": [
|
package/agents/backend-lead.md
CHANGED
|
@@ -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** —
|
|
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
|
|
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
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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 ✓/✗ ·
|
|
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** —
|
|
53
|
-
|
|
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
|
-
-
|
|
24
|
-
|
|
25
|
-
|
|
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
|
|
44
|
-
- Worker
|
|
45
|
-
- No direct
|
|
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 `
|
|
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
|
|
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:
|
|
74
|
+
- Line 45: References a non-existent `_Db` class — use `_Model` / `_Database` / `_Query`
|
|
73
75
|
```
|
package/agents/frontend-lead.md
CHANGED
|
@@ -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:
|
package/agents/php-reviewer.md
CHANGED
|
@@ -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
|
|
50
|
-
- Workers dispatched via `_Queue::dispatch()
|
|
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
|
|
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
|
|
66
|
-
| Foo.php:34 | CRITICAL | SQL-INJECTION |
|
|
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
|
-
-
|
|
45
|
-
- DB access via _Db
|
|
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
|
-
- `
|
|
78
|
-
Follow pattern from
|
|
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: `
|
|
84
|
+
Use: `_Worker::runTask('Order/Batch/Process', ['orderIds' => $ids])`
|
|
83
85
|
|
|
84
86
|
**Verify:** [concrete test steps]
|
|
85
87
|
|
package/agents/qa-engineer.md
CHANGED
|
@@ -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
|
|
package/agents/sql-reviewer.md
CHANGED
|
@@ -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-
|
|
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/
|
|
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,7 +1,25 @@
|
|
|
1
1
|
'use strict';
|
|
2
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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 */ }
|