toga-ai 1.0.964 → 1.0.966

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.
@@ -172,6 +172,16 @@
172
172
  "timeout": 3000
173
173
  }
174
174
  ]
175
+ },
176
+ {
177
+ "matcher": "Write|Edit|MultiEdit",
178
+ "hooks": [
179
+ {
180
+ "type": "command",
181
+ "command": "node \"$CLAUDE_PROJECT_DIR/.claude/hooks/toga/memory-scope-guard.js\"",
182
+ "timeout": 3000
183
+ }
184
+ ]
175
185
  }
176
186
  ],
177
187
  "SessionStart": [
@@ -158,19 +158,16 @@ plain thrown warning instead.
158
158
  (`WHERE name LIKE 'NETSUITE_EXECUTION_MODE_%'`). Wrap text columns in
159
159
  `CONVERT(... USING utf8mb4) COLLATE utf8mb4_general_ci` or the UNION fails on mixed collations.
160
160
 
161
- #### Manual reset after the fix: `864000-IDLE` — get both halves right (2026-09-08)
161
+ #### Never hand-reset `NETSUITE_EXECUTION_MODE_*` (developer rule, 2026-10-06)
162
162
 
163
- A section self-heals from `1-RUNNING` on its own, but climbing ×3 from a 1-second window back to the
164
- cap takes many runs, so a backlog drains very slowly. Setting the parameter by hand skips that climb.
165
- **Only do it after the real error is fixed** — otherwise the next run just throws and shrinks again.
163
+ **Do not set the parameter by hand — not even to `864000-IDLE` after a fix.** A correct fix makes the
164
+ section climb back to the cap and settle at `IDLE` on its own; a hand reset hides whether the fix
165
+ actually worked. If a section does not recover by itself, the fix is incomplete — keep fixing the
166
+ root cause. (Replaces the 2026-09-08 "manual reset after the fix" advice.)
166
167
 
167
- The correct value is **`864000-IDLE`**. Both halves are easy to get wrong, and both were nearly
168
- mis-set during the 2026-09-08 Elite incident:
169
-
170
- - **State must be `IDLE`, not `RUNNING`.** `RUNNING` is the "prior run died / run in progress" flag.
171
- Writing `RUNNING` tells the next run to shrink the window.
172
- - **The cap is `864000` (10 days), not `86400` (1 day).** One zero short is a silent 10× smaller
173
- window, not an error.
168
+ Reading the value: the healthy resting value is **`864000-IDLE`** (cap + `IDLE`). `RUNNING` is the
169
+ "prior run died / run in progress" flag, and a window below the cap (e.g. `86400`, `1`) means recent
170
+ failures shrank it — see "Reading `NETSUITE_EXECUTION_MODE_*`" below.
174
171
 
175
172
  (`MAX_TIME_WINDOW_TO_FETCH_FROM_NETSUITE_SECONDS` is per-client overridable, but as of 2026-09-15
176
173
  **no wrapper overrides it** — Elite's old 2592000 is gone. So `864000` is the right value for every
@@ -626,11 +623,12 @@ database's `Parameters` table:
626
623
  sorts below it. Always write `'2026-01-01 00:00:00|0'`, never carry the old id back.
627
624
  2. **The datetime is on the Eastern clock** (`America/New_York`), not UTC and not the server's. See
628
625
  the wrong-clock bugs above — this is the same clock they settled on.
629
- 3. **Reset the paired execution-mode key at the same time.** `NETSUITE_EXECUTION_MODE_<SECTION>`
626
+ 3. **Check the paired execution-mode key first — but never hand-reset it.** `NETSUITE_EXECUTION_MODE_<SECTION>`
630
627
  holds `<seconds>-<STATE>`; a prior crash leaves it `RUNNING`, and the next run then **shrinks**
631
628
  the window ÷3 instead of using the full cap. Observed live 2026-09-15: Elite's
632
- `NETSUITE_EXECUTION_MODE_SALES_ORDERS` was stuck at `864000-RUNNING`. Set it to
633
- `<the client's own cap>-IDLE` — see the manual-reset section above for both halves.
629
+ `NETSUITE_EXECUTION_MODE_SALES_ORDERS` was stuck at `864000-RUNNING`. A stuck `RUNNING` means the
630
+ crash's root cause is not fixed yet — fix that before rewinding; the mode climbs back to
631
+ `<cap>-IDLE` on its own (see "Never hand-reset" above).
634
632
  4. **One run only advances `MAX_TIME_WINDOW_TO_FETCH_FROM_NETSUITE_SECONDS`** — 864000s (10 days) by
635
633
  default, and **Elite does not override this one**. So a rewind to 1 Jan 2026 needs **~26 runs** to
636
634
  catch up. On the 5-minute cron that is fine; on a daily cron it would take ~26 days. **Run the
@@ -1160,6 +1158,16 @@ same `Parameters` cursors: whoever writes last wins and the other's window is si
1160
1158
  (and windows shrink). Any manual backfill must either pass the cron's filename to
1161
1159
  `isProcessRunning()` explicitly or be run with the cron's schedule entry disabled.
1162
1160
 
1161
+ ## Fix rules (team directives for any sync/reconcile change)
1162
+
1163
+ 1. **Self-heal to match NetSuite.** Re-processing a record must leave the DB matching the source, including deleting stale/duplicate rows. A one-time drain is allowed only after the code fix.
1164
+ 2. **Repair by code, never SQL.** Wrong sync data is fixed in sync code (self-heals on re-run), then cursors are rewound to replay. Also ship a single-order runner keyed by the NetSuite order number / internal id (not the Toga id). No data-repair or backfill SQL. Schema/metadata/ACL SQL in dbchanges2 is fine.
1165
+ 3. **Fail loud, fix upstream.** No `error_log()`-and-`continue` skips (worker stdout is discarded): throw so the section stays RUNNING and lands in `Logs.Event`. On a failed match, look higher up for the real cause (e.g. the REST shim returning bad lines) before adding any skip.
1166
+ 4. **Dependencies import first.** Import a record's parents before it (SO before IF/Invoice, PO before ItemReceipt, IF before InventoryAdjustment). If the NetSuite record declares a parent and we end with no real linked Toga parent, throw before creating. Never skip, orphan, or advance the cursor past it.
1167
+ 5. **Never step the cursor forward past a poison record.** A section frozen on a throwing record is the correct state; root-cause that record. Rewinding to re-import is fine; a forward skip is never acceptable (silent data loss).
1168
+ 6. **Traceability/bridge links are accurate data.** Never drop a valid link and never log-and-skip one that should exist; fix the match so the link is made. Skip-and-log only when there is genuinely nothing to link (e.g. a service/non-shipped line with no fulfillment counterpart). Prove the current match rule is wrong (NetSuite + toga-db + prod Logs) before changing it.
1169
+ 7. **Compass reconcile boundary.** `syncSalesOrderFromNetsuite` on `Client_Compass` may delete/alter Agilant orders, POs and link/bridge rows so the Agilant SO matches NetSuite. It must NEVER touch the Compass (customer) order or customer PO. Compass has two opposite-direction bridges (UPSTREAM `PurchaseOrders_SalesOrders`/`PurchaseOrderItems_SalesOrderItems`; DOWNSTREAM `SalesOrders_PurchaseOrders`/`SalesOrderItems_PurchaseOrderItems`) plus a multi-tier SO-PO-SO-PO chain: establish which records are Agilant vs customer before writing delete logic, and confirm the boundary with the developer before any prod delete. See [bridge direction](../../../2.0/apps/_underscore/features/sales-order-purchase-order-bridge-direction.md).
1170
+
1163
1171
  ## Data model
1164
1172
 
1165
1173
  Per-client `Parameters` table (2.0 client DB, e.g. `Client_Canon.Parameters`; columns
@@ -1253,6 +1261,8 @@ library (or vice versa) crashes GroWrk and Adyen on their next sync run.
1253
1261
 
1254
1262
  ## Gotchas / known issues
1255
1263
 
1264
+ - **Ops fix steps: verify current values first.** Before telling Ops to change a NetSuite field, query the record's current values. Name the exact record (order #, adjustment #), write "currently X → change to Y", and omit any field already correct. Never write a generic "find the order and set…" when the data can identify it.
1265
+
1256
1266
  - **The 2026-09-17 transient-read retry covers NetSuite reads only** (`App_Api_Netsuite_Rest::send`), **not** api2 calls via `App_Api_Toga2::send`. An api2 blip (e.g. "cURL error with API transaction: Empty response body from API (HTTP 200)") still aborts the section for that run and drops the window to `288000-IDLE`; it self-heals next run. One such failure is not a collapse — check the window grows back before acting. Seen NYCHH ITEM_FULFILLMENTS 2026-09-25, issue 873 (`common_sync_togasupply.php:1287` → `toga2.php:6630`).
1257
1267
 
1258
1268
  - **⚠ Sales-order downstream LINE links were deleted on every PO re-sync (fixed 2026-09-24).** In
@@ -2116,6 +2126,7 @@ library (or vice versa) crashes GroWrk and Adyen on their next sync run.
2116
2126
  [NetSuite Sync Alert Monitor](../../library/features/netsuite-sync-alert-monitor.md).
2117
2127
 
2118
2128
  ## Change history
2129
+ - 2026-10-06 — Added "Fix rules" section: self-heal, repair-by-code, fail-loud, dependencies-first, no forward cursor skip, links-are-data, Compass delete boundary. (jcardinal)
2119
2130
  - 2026-10-06 — NYCHH ITEM_FULFILLMENTS frozen 09-30→10-06 (Event issue 483, FK 1451 on IA→IF line link) fixed: reverse retire TO→SO + IA-link move before every IF-line delete (library only). Corrected "NYCHH once a day" — it runs every 5 min. (jcardinal)
2120
2131
  - 2026-10-05 — Closed-order delete now per-client SO scoped (Compass dual-SO freeze, Issue 940); details in [closed-order rule](../../library/features/netsuite-closed-order-rule.md). Library only. (jcardinal)
2121
2132
  - 2026-10-01 — `Items.description` now from the NetSuite item record, refreshed every touch ([doc](../../library/features/netsuite-item-description-sync.md); library + worker deploy together). Gotchas: invoice billing an SO older than the SO cursor (Adyen); receipt lines never rebuilt on an existing receipt (Elite). (rgirish)
@@ -6,7 +6,7 @@ project: Worker
6
6
  client: shared
7
7
  type: workflow
8
8
  status: active
9
- updated: 2026-10-02
9
+ updated: 2026-10-06
10
10
  owners: ["jcardinal", "rgirish"]
11
11
  files:
12
12
  - worker/crons/toga2/netsuite/process_netsuite_order.php
@@ -69,6 +69,7 @@ php crons/toga2/netsuite/process_netsuite_inventory_adjustment.php <client> --id
69
69
  - `process_netsuite_item_fulfillment.php` overlaps `process_netsuite_records.php --fulfillment` (built in parallel).
70
70
 
71
71
  ## Gotchas
72
+ - **Convention:** every single-record `process_*` CLI tool takes `--id=<id>[,<id>...]` from the start: validate all ids up front, dedupe, run in order, stop at the first error and report how many were done.
72
73
  - A TO skipped by the sync (out-of-scope customer) prints `NOT imported` — read the error log, not a script bug.
73
74
  - Needs `IS_ENABLED_INTEGRATION_TRANSFER_ORDERS` true in the wrapper for TO routing; otherwise the order syncs as a sales order.
74
75
 
@@ -5,7 +5,7 @@ project: Library
5
5
  client: shared
6
6
  type: standard
7
7
  status: active
8
- updated: 2026-09-18
8
+ updated: 2026-10-06
9
9
  owners: [jcardinal, rgirish, mhammontree, ajean]
10
10
  files: []
11
11
  related:
@@ -553,6 +553,10 @@ Note: the 2.0 metadata-driven API features (Record Scripts, API Payload Intercep
553
553
  - **Remove all `debugVar()` calls before merging.**
554
554
  - Don't extend `DOA_`-prefixed code; plan its removal when you touch the area and dependencies allow.
555
555
 
556
+ ### Changing a shared `library` function signature: grep ALL repos for callers
557
+
558
+ Grep the function name across every repo (all `*.php` in `library` AND `worker`, plus any other consumer), never just the edited file. `worker` crons call `library` classes through the autoloader with no `require`, so an import search misses them; only a name grep finds them. A missed direct caller fails at runtime ("Too few arguments") and can freeze a sync after deploy. Tell reviewers the caller search spans repos. Worked case: [per-client sync](../apps/worker/features/netsuite-togasupply-per-client-sync.md) (three call sites, one a direct cron).
559
+
556
560
  ## Binary downloads: build the whole file, then send headers
557
561
 
558
562
  Never stream a generated file straight to the browser. `App_Error::handleError` promotes every
@@ -6,7 +6,7 @@ project: _Underscore
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-09-21
9
+ updated: 2026-10-06
10
10
  owners: ["jcardinal", "mhammontree", "tcox", "bala", "apeterson", "rgirish"]
11
11
  files:
12
12
  - api2/Component/Api/V2/V2.php
@@ -407,6 +407,15 @@ with **no** grant is not automatically a defect; compare against a healthy clien
407
407
  first (see
408
408
  [client schema-drift audit](../../dbchanges2/workflows/client-schema-drift-audit.md)).
409
409
 
410
+ ## Role model: two role spaces, no inheritance, Public baseline
411
+
412
+ - **Two separate role spaces with different ids and names:** Core roles (`Core.Roles`: Public, Integrations, Super User, Base) and per-tenant client roles (`Client_<X>.Roles`). Ids are not comparable across spaces.
413
+ - `AclRecordPermissions.roleId` / `AclFieldPermissions.roleId` are read in the record's `aclDatabase` role space: CORE record → Core role id; CLIENT record → client role id. `roleId=1` is Core Public on a Core record but a client role on a client record — always filter by the record's `aclDatabase`.
414
+ - **Link:** `Client.Roles.coreRoleId`. At login the user's client roles map to Core roles through their distinct non-null `coreRoleId`s, set as `id.core.roles`. CORE records filter on the user's Core roles; CLIENT records on their client roles.
415
+ - **No inheritance:** no parent role / hierarchy; roles are used verbatim by the resolver.
416
+ - **Public = read-only baseline every authenticated user inherits.** Public is MERGED (additively) into the effective core roles at token build, `processRoutePairs`, and `buildLookups`; without the merge the mapped `coreRoleId`s overwrite it and a role like Core Base cannot read Core metadata (e.g. `Core.Clients.uuid`). Keep Public read-only: never grant it write on Core metadata.
417
+ - Related: no backend ACL cache in 2.0 — a stale 403 is the UI cache or ACL logic, never a backend cache.
418
+
410
419
  ## Where ACL rows live: Core vs. Client database
411
420
 
412
421
  `Core.Records.aclDatabase` decides which database holds the ACL rows for that record:
@@ -248,6 +248,16 @@ This lifecycle is automated by a local browser tool — see the [TOGa 2.0 Client
248
248
  8. **Never reference a database other than the folder's own target.** No `Core.Table` in a `Client_<Name>/` file, no `Client_X.Table` in a `Core/` file, and **no database qualifier at all** in the fan-out folders (`Client/`, `Logs_Client/`, `_modules/`). These databases are on **separate production clusters**; the query works locally and dies in production. Resolve foreign ids with a hardcoded v4 UUID literal or an in-database slug/natural key, or split the work into one file per database folder. See *Database isolation* above — enforced by the `dbchanges2-cluster-isolation` hook.
249
249
  9. **Inserting into `Core.Records` or `Core.RecordFields`? ASK the developer for the next `id`** (the developer takes it in Tools → Developers → Central IDs — the source of truth; never guess from MAX(id), branches hold unreleased ids). These are the **only two tables on the platform with team-maintained primary keys**. Write the assigned `id` as an explicit literal — **never** let `AUTO_INCREMENT` assign it. Conversely, **always hardcode** these `id`s where they are referenced, including in foreign keys (`recordId`, `recordFieldId`) from other databases. See *`Core.Records` / `Core.RecordFields`* above.
250
250
  10. **A fan-out `Client/` file must never reference a `customRecordFieldId` or a client-specific label.** Unlike `Core.RecordFields` ids, `CustomRecordFields` ids are **per-client `AUTO_INCREMENT`** — the same numeric id names a **different field in every client database**. A `Client/` file that hardcodes one silently wires the wrong field for 29 of 30 tenants, and there is no error. The same applies to `ClientCustomRecordFieldSettings` label rows. Move anything touching a custom field into the client's own `Client_<Name>/` file, even when it is "the same change" for several clients. Worked example: the all-clients `inventory_units` table view shipped in `Client/2026-08-11a`, but Elite's four custom columns (`grade`, `chargingBrick`, `chargingCable`, `wipeStatus` via `c_deviceStatus`) and their labels shipped separately in `Client_Elite/`.
251
+ 11. **`ALTER TABLE … ADD COLUMN` appends at the END of the table — always add `AFTER <predecessor>` (or `FIRST`)** so the column lands in its slot per the field-order standard ([backend-php → Order of Fields](../../standards/backend-php.md)): FKs go immediately after `uuid`. Fix a misplaced column with `MODIFY COLUMN … AFTER <col>` (keeps the FK constraint). Keep the `_Model` field declaration order consistent with the column order.
252
+ 12. **UUID literals are fresh random v4, never patterned; `Core.RecordFields` inserts set `isIdentifier` (`1` for `uuid`, `0` otherwise).** See [UUIDs](../../standards/backend-php.md) and rule 6.
253
+ 13. **Fan-out lookup seed (seeding baseline rows into every `Client_*` DB) = one guarded, re-runnable batched insert:** `INSERT INTO t (…) SELECT … FROM (SELECT <lits> UNION ALL SELECT …) AS src LEFT JOIN t existing ON existing.<naturalKey> = src.<naturalKey> WHERE existing.id IS NULL;`
254
+ - Guard on the natural key (slug/name) backed by a real UNIQUE key: re-run is a no-op and a tenant that already has the rows keeps its own uuids.
255
+ - Resolve a child FK with `INNER JOIN` on the parent's natural key, not a scalar sub-select (a miss writes NULL silently; the join drops the row). Seed the parent table first in the same file.
256
+ - `WHERE NOT EXISTS(…)` with no FROM is a 1064 syntax error; the UNION ALL derived table is the source.
257
+ - Reusing the same literal uuid across tenants is OK only for a per-tenant lookup table not in the cross-tenant scatter (scatter identity = uuid).
258
+ - No prose preamble in a migration file: one short line per statement; narrative goes to the ticket.
259
+ - Before running on prod, confirm each tenant's existing natural-key values match EXACTLY, or a mismatch inserts a duplicate instead of a no-op.
260
+ 14. **Never assume a migration has or has not run — verify (ask, or SELECT the live schema) before claiming state or editing a file.** An applied file is immutable: to undo it, RESTORE the original file to match what ran, then add a NEW dated forward-only file that drops/changes it (fresh envs run both and converge; run envs run only the new one). MySQL has no `DROP COLUMN IF EXISTS`, so restoring the add is what makes the later drop safe everywhere.
251
261
 
252
262
  ## Bulk data loads — batch, and stage large sets in a temp table
253
263
 
@@ -6,7 +6,7 @@ project: SAML SSO Gateway
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-09-16
9
+ updated: 2026-10-06
10
10
  owners: ["jcardinal"]
11
11
  files:
12
12
  - saml/Controller/Index.php
@@ -55,6 +55,7 @@ Probe reuses the **internal TOGA client** (`clientIdentifier "True"`, the test t
55
55
 
56
56
  ## Gotchas
57
57
 
58
+ - **Critical-path rule: prove auth edits with a real run, not `php -l`.** Lint hid a blank-page prod outage twice on `/acs` (`display_errors=0`, so any fatal is a white page): a `$domain`/`$client` reload placed after the client-DB connect, and `empty($user->id)` on a magic field. Keep monitors additive and self-contained (`diff` vs deployed shows 0 removals on the live path); never refactor the live path to share code. Verify with a real login or the token-gated `/healthcheck` before prod, and keep a temporary in-body error string only while diagnosing, then strip it.
58
59
  - **Do not use `empty()`/`isset()` on the resolved user's magic fields.** `getAuthenticatedSsoUser()` returns a fully-loaded `_Model` user, but `!empty($user->id)` reads **false** even when `$user->id` is a real value (this session: id 3264 read as "no id"). Use a plain truthy check `if ($user->id)` (what `/acs` itself does) or copy to a local first. Full rule: [_Model magic-field access](../../_underscore/features/model-magic-field-access.md).
59
60
  - **The token is a credential** — it lives only as the EB env property `SAML_HEALTHCHECK_TOKEN`, never committed and never written into a doc.
60
61
  - **Inbound IdP SAML signature verification is still commented out** (see architecture) — re-enabling is per-client, needs each IdP signing cert, and must not break live logins (tracked separately).
@@ -6,7 +6,7 @@ project: Worker
6
6
  client: shared
7
7
  type: architecture
8
8
  status: active
9
- updated: 2026-09-28
9
+ updated: 2026-10-06
10
10
  owners: [jcardinal, ajean, rgirish]
11
11
  files:
12
12
  - worker2/Controller/Index.php
@@ -151,6 +151,7 @@ For **programmatic** enqueue via `_Worker::runTask()`, the MySQL-first rule was
151
151
 
152
152
  ## Gotchas
153
153
 
154
+ - **Never give `?controller=` URL test steps for worker2.** `_.php` sets `DEFAULT_CONTROLLER_METHOD` to `_Controller_Index::worker()`, so every URL routes there and shows a blank page; the "Access via: ?controller=…" comments in `Controller/*Test.php` are misleading. Prove pure logic with a CLI runner (stub `_Component`/`_Controller`, require the file, call `run()`) or a worker action POSTed as `{action, parameters}`.
154
155
  - **Shrinking `WorkerJobs.output` MEDIUMTEXT → TEXT (planned, not yet done).** TEXT holds 65,535 **bytes** (utf8mb4 = up to 4 bytes/char), not chars. Trim byte-safe before the ALTER, repeating until 0 rows: `UPDATE WorkerJobs SET output = LEFT(output, CHAR_LENGTH(output) - (LENGTH(output) - 65535)) WHERE LENGTH(output) > 65535 LIMIT 1000`. After the change, every producer must truncate first or the result UPDATE fails with 1406 Data too long (strict mode) and the job result is lost: PHP check-out (both paths in `Controller/Index.php`) and the JobScheduler watchdog Lambda UPDATE (repackage + redeploy it). The `MODIFY` must restate the column COMMENT. On MySQL 8 the type change rebuilds the table.
155
156
 
156
157
  - **⚠ OBSERVED `Core.WorkerJobs` retention is ~30 days, not 90 — unexplained.** Measured 2026-09-15 in prod: `MIN(dtCreated)` across the **whole** table is **2026-08-16 02:00:46** (790,597 rows). Either the cleanup cron does not behave as described, or something else prunes the table. **Not investigated** — no cause is asserted.
@@ -6,7 +6,7 @@ project: Worker
6
6
  client: shared
7
7
  type: feature
8
8
  status: draft
9
- updated: 2026-09-16
9
+ updated: 2026-10-06
10
10
  owners: [jcardinal]
11
11
  files:
12
12
  - worker2/Worker/Team/Github.php
@@ -92,6 +92,16 @@ approved Task/Hotfix/Feature deploys through `MergeTaskToProduction` — see
92
92
  non-mirror `_production` merge, so a stray queue message can't deploy. Re-fires are stopped by the
93
93
  per-repo `GET_LOCK` + the persisted deploy-claim (`EnvironmentBranchMerges` `branch='_production'`).
94
94
 
95
+ ## Built / pending
96
+
97
+ Whole QA/QC automation build is built and reviewed: Task + Hotfix + Stage + Feature flows, plus BE auto-approve from GitHub PR approvals (`_Worker_Clickup_BeReview::FromPr`, all of a task's `_production` PRs approved).
98
+
99
+ ### Pending / deferred
100
+ - Real unit tests: `runUnitTests()` is a stub; developer excluded it for now.
101
+ - Post-merge Stage "shipped-vs-held" summary: developer said skip.
102
+ - 300s budget: `finalizeFeatureSubtasks` and the QC-entry subtask loops make O(N) ClickUp calls in one job. Dispatch per subtask if features get many subtasks.
103
+ - Helper duplication (`fieldById`/`dropdownName`/`optionIdByName`/`isChecked` and similar) across QaReview/QcReview/QcBatch/StageBatch/FeatureReview/`_Worker_Clickup`: extract to one shared place.
104
+
95
105
  ## Related
96
106
 
97
107
  - [QA/QC review pipeline](./qa-qc-review-pipeline.md)
@@ -5,7 +5,7 @@ project: _Underscore
5
5
  client: shared
6
6
  type: standard
7
7
  status: active
8
- updated: 2026-09-16
8
+ updated: 2026-10-06
9
9
  owners: [jcardinal, mhammontree, dfranks, bala, ajean]
10
10
  files: []
11
11
  related:
@@ -55,6 +55,8 @@ conventions.
55
55
 
56
56
  * All records inserted in the 2.0 database must be given a random UUID in the second column of each table called `uuid`
57
57
  * Do not use the built-in `UUID()` function provided by MySQL because they are not fully random and instead are time-based which causes UUIDs to be nearly identical, making it hard for developers to read.
58
+ * **UUID literals in seeds/migrations** (cannot call PHP) are fresh random v4 values, one distinct value per row, hardcoded. Never patterned/incremented (`…0001`, `…0002`), never a one-nibble variation, never `UUID()`. Generate with a real v4 generator (`crypto.randomUUID()`).
59
+ * `Core.RecordFields` inserts must set `isIdentifier`: `1` for the `uuid` field, `0` for every other field. Never omit the column.
58
60
  * Instead, generate a fully-random UUID in PHP with **`_String::generateUuid()`** (defined in `_underscore/String.php`). It builds a random 32-hex value formatted as a standard UUID. Do not rely on a MySQL-side function for this.
59
61
 
60
62
  #### **Custom Fields**
@@ -399,6 +401,11 @@ Authors_Posts
399
401
 
400
402
  * In code you rarely write these raw types directly. A `_Model` declares each column as a `FIELD_*` constant that maps to the underlying SQL type — e.g. `FIELD_PRIMARYKEY` / `FIELD_FOREIGNKEY` (unsigned int keys), `FIELD_INTEGER`, `FIELD_BOOLEAN` (the `is*` tinyint flag), `FIELD_DECIMAL`, `FIELD_CHAR` / `FIELD_CHAR_UUID`, `FIELD_LIST` (ENUM), `FIELD_DATETIME_CREATED` / `FIELD_DATETIME_UPDATED` (auto-managed timestamps), `FIELD_BLOB`, `FIELD_STORAGE`, and `FIELD_SQL` (calculated). The SQL types below are the storage targets these constants resolve to; follow both this section and the model field-type conventions when defining columns.
401
403
 
404
+ **ENUM / `FIELD_LIST`**
405
+
406
+ * Values are `CAPITAL_SNAKE_CASE`: `ENUM('TICKET','SERVICE_REQUEST')`, `ENUM('OPEN','IN_PROGRESS')`. Never lowercase-hyphen.
407
+ * Slug / natural-key string columns (`slug='draft'`, `new-ticket`) are not enums: they stay lowercase-hyphen.
408
+
402
409
  **Flags/Booleans**
403
410
 
404
411
  * Flag fields should begin with `is`, for example: `isVisible`
@@ -19,7 +19,7 @@ related:
19
19
  - framework-rules-cases.md
20
20
  ---
21
21
 
22
- Core 2.0 `_underscore` framework rules — class naming, the worker contract & dispatch, the api2 `{success,data,errors}` envelope, dbchanges2 schema/isolation rules, and `c_` columns (dynamic in /v2, declared for direct `_Model` use); open when writing/reviewing any 2.0 code.
22
+ Core 2.0 `_underscore` framework rules — class naming, the worker contract & dispatch, the api2 `{success,data,errors}` envelope, dbchanges2 schema/isolation rules, and `c_` columns (/v2 exposes one only when the client model declares it, plus ACL rows); open when writing/reviewing any 2.0 code.
23
23
 
24
24
  These rules apply to all 2.0 repos: `_underscore` (core), `worker2`, and `api2`. They are
25
25
  loaded on-demand by `/kickoff` when a 2.0 repo is in scope (not always-on) — see the
@@ -174,6 +174,9 @@ Every fact has exactly one home — never duplicate it into a second place.
174
174
  they're copied elsewhere. Kickoff/capture may be told to *read* one; never to *paste* it in.
175
175
  - **Everything a person must know lives in the team KB** — decisions, rules, gotchas,
176
176
  architecture. Not a generated file, not a stale wiki page.
177
+ - **Never only in local Claude memory.** Memory is private to one machine: it holds talking
178
+ style, local paths, and personal habits only. A team rule goes here (or the platform apps'
179
+ docs) and memory keeps at most `Lives in: <where>` — see `rules/common/memory-scope.md`.
177
180
  - **App docs lifecycle (pre-MVP → launch → post-launch):**
178
181
  1. **Before MVP**, an in-development app's knowledge lives in the app repo's own `docs/`.
179
182
  The KB carries only a `registry.json` entry + a small `architecture.md` **stub**
@@ -45,7 +45,7 @@ _Auto-generated by `knowledge.js index`. Do not hand-edit._
45
45
  - **togatech** (TOGA Technology Website) — 8 doc(s) → [standalone/apps/togatech/INDEX.md](standalone/apps/togatech/INDEX.md)
46
46
  - **websocket** (WebSocket Server) — 2 doc(s) → [standalone/apps/websocket/INDEX.md](standalone/apps/websocket/INDEX.md)
47
47
  - **forward** (Forwarder) — 3 doc(s) → [standalone/apps/forward/INDEX.md](standalone/apps/forward/INDEX.md)
48
- - **claude** (Claude Harness) — 8 doc(s) → [standalone/apps/claude/INDEX.md](standalone/apps/claude/INDEX.md)
48
+ - **claude** (Claude Harness) — 9 doc(s) → [standalone/apps/claude/INDEX.md](standalone/apps/claude/INDEX.md)
49
49
  - **powerbi** (Power BI) — 0 doc(s) → [standalone/apps/powerbi/INDEX.md](standalone/apps/powerbi/INDEX.md)
50
50
 
51
51
  ## Clients
@@ -2,6 +2,7 @@
2
2
 
3
3
  | Doc | Summary |
4
4
  |-----|---------|
5
+ | [Memory Scope Guard — Keep Team Rules Out of Local Claude Memory](features/memory-scope-guard.md) | Rule + PostToolUse warn hook + `/capture` sweep that keep team build rules in the KB, not in one developer's private Claude memory. |
5
6
  | [Review-Enforcement Hooks — Mandatory sql-reviewer / php-reviewer Routing on Write](features/review-enforcement-hooks.md) | PostToolUse hooks that force a specialist review (`sql-reviewer` / `php-reviewer`) after SQL/PHP writes; open before turning either into a pre-write gate. |
6
7
  | [Harness Distribution — How a knowledge.js Fix Reaches Teammates](workflows/harness-distribution.md) | How a `knowledge.js` fix reaches teammates — two copies exist, only the git clone matters; `npx toga-ai` pulls it. |
7
8
  | [Knowledge Base Publish / Push Pipeline](workflows/knowledge-publish-pipeline.md) | How `/capture` and `/session-save` push knowledge docs to `agilantsolutions/claude` `_main` — the git sequence, retry, and the `47fbdc4` PUSH_FAILED fix. |
@@ -0,0 +1,54 @@
1
+ ---
2
+ title: Memory Scope Guard — Keep Team Rules Out of Local Claude Memory
3
+ framework: "standalone"
4
+ repo: claude
5
+ project: Claude Harness
6
+ client: shared
7
+ type: feature
8
+ status: active
9
+ updated: 2026-10-06
10
+ owners: ["jcardinal"]
11
+ files:
12
+ - claude/rules/common/memory-scope.md
13
+ - claude/scripts/hooks/memory-scope-guard.js
14
+ - claude/skills/capture/SKILL.md
15
+ - claude/.claude/settings.json
16
+ related:
17
+ - standalone/apps/claude/features/review-enforcement-hooks.md
18
+ ---
19
+
20
+ Rule + PostToolUse warn hook + `/capture` sweep that keep team build rules in the KB, not in one developer's private Claude memory. Open when the guard fires or a rule "only one dev knows".
21
+
22
+ ## Summary
23
+
24
+ Local Claude memory (`~/.claude/projects/<project>/memory/*.md`, index `MEMORY.md`) is private to one machine. A scripted-API rule and ~80 build rules once lived only there, invisible to teammates. Three parts stop that:
25
+
26
+ 1. **Rule** `rules/common/memory-scope.md` (always-on, installed to `.claude/rules/toga/common/`): memory holds only personal things — talking style, local paths, personal habits (`type: user`). Any rule about how we build goes to the KB (platform-app rules: the architecture docs of every platform app — `toga25-desk`, `toga25-iq`, `toga2-template`) in the same change; memory keeps at most `Lives in: <where>`.
27
+ 2. **Hook** `scripts/hooks/memory-scope-guard.js` — warns, never blocks.
28
+ 3. **`/capture` Step 2b** — sweeps this session's memory notes, moves team rules into the KB, shrinks each note to its `Lives in:` pointer.
29
+
30
+ `communication.md`'s per-person profile still lives in memory — the rule names it as personal.
31
+
32
+ ## How the hook works
33
+
34
+ - Wired in the template `.claude/settings.json` → `PostToolUse`, matcher `Write|Edit|MultiEdit`.
35
+ - Fires only when the target path is inside `.claude/projects/<x>/memory/` or `.claude/memory/` (either slash style) and ends `.md`.
36
+ - Reads the note from disk (falls back to the written text) and warns when **one sentence or clause states a build rule**: a directive word (never, always, must, do not, don't, should, required) **and** a build term (SQL, dbchanges2, api2, _underscore, interceptor, ACL, RecordFields, Surface — capitalised only, blox, migration, endpoint, scripted, worker2, NetSuite, schema, component). Any `type:` except `user`.
37
+ - Ignored text: `**Why:**` lines (they quote the reason), `(...)` asides, `[[note-name]]` links.
38
+ - Never warns on: `type: user`; any note with a `Lives in:` line; names `user-*`, `repo-path-*`, `habit-*`, `*-profile`, or with `path|location|dir|folder` in the name. Lookup notes (paths, URLs, profiles, registries) and status/reminder notes stay quiet because they rarely pair a directive with a build term.
39
+ - `MEMORY.md`: only added lines that are not `- [name](file.md)` pointers and name a build term.
40
+ - Output: JSON `hookSpecificOutput.additionalContext` (the PostToolUse channel that reaches the model). Once per file per session (state in OS temp, `toga-memory-scope-<session>.json`). Always exit 0, fail-open.
41
+ - Off switch: `TOGA_MEMORY_SCOPE_GUARD_DISABLED=1`.
42
+
43
+ ## Gotchas
44
+
45
+ - Heuristic, not exact. It misses a rule worded without a directive word ("lock it into the app docs"). A personal habit saved as `type: feedback` that says "always ... SQL" will warn — save habits as `type: user` or name them `habit-*`.
46
+ - Tuned 2026-10-06 against one developer's 140 notes: the first version fired on 14 (lookups and status notes); the current one fires on 0.
47
+ - Plain stdout from a PostToolUse hook is not reliably shown to the model; this hook uses `additionalContext` on purpose.
48
+
49
+ ## Change history
50
+ - 2026-10-06 — Created: memory-scope rule, guard hook, `/capture` Step 2b sweep; heuristic tuned to "directive + build term" to cut false positives (jcardinal)
51
+
52
+ ## Related
53
+
54
+ - [Review-Enforcement Hooks](review-enforcement-hooks.md)
@@ -6,7 +6,7 @@ project: Claude Harness
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-09-16
9
+ updated: 2026-10-06
10
10
  owners: ["jcardinal"]
11
11
  files:
12
12
  - claude/scripts/hooks/remind-sql-review.js
@@ -38,6 +38,8 @@ The harness ships PostToolUse hooks that force a specialist review after certain
38
38
  - The hook only nudges; enforcement depends on Claude honouring the directive. It is a routing reminder, not a mechanical guarantee that the reviewer ran.
39
39
  - Signals match on *newly-written* text only. An edit that touches a SQL file/PHP query without the matched keywords in the new chunk will not fire.
40
40
 
41
+ - **PreToolUse deny = exit 2 + reason on STDERR (`console.error`).** The harness reads the deny reason from stderr only; a reason printed to stdout still blocks but shows the opaque `hook error: No stderr output`, so nobody sees why. UserPromptSubmit is the opposite: its stdout is injected into context. Payload arrives as JSON on stdin (`tool_name`, `tool_input.file_path`, `tool_input.command`); env has `CLAUDE_PROJECT_DIR`. Anchor per-project lock files on `CLAUDE_PROJECT_DIR`, not `process.cwd()`.
42
+
41
43
  ## Related
42
44
 
43
45
  - [Harness Distribution](../workflows/harness-distribution.md)
@@ -6,7 +6,7 @@ project: Claude Harness
6
6
  client: shared
7
7
  type: workflow
8
8
  status: active
9
- updated: 2026-09-16
9
+ updated: 2026-10-06
10
10
  owners: ["jcardinal"]
11
11
  files:
12
12
  - claude/scripts/install.js
@@ -41,6 +41,11 @@ There are **two copies** of `knowledge.js` on a developer's machine, and the ski
41
41
  - **`npx toga-ai` can silently fail to update the knowledge repo** (open issue, not yet fixed). On a non-zero `git pull --ff-only` exit, `tryGitPull()` returns `{ pulled: false, newDocs: 0, message: null }` — the git stderr is discarded and **nothing is printed to the developer**. A teammate whose pull is refused (e.g. local edits to a tracked file the incoming commits also touch — `.claude/settings.json` is the usual culprit) will believe they are up to date while still running old tooling. Suggested fix, not applied: surface the git stderr in the `pulled: false` path so a refused pull is visible. Same defect class as the swallowed-stderr bug in the publish pipeline.
42
42
  - Until that is fixed, if a harness fix "didn't take", check the pull manually rather than trusting `npx toga-ai`'s output.
43
43
 
44
+ - **The harness `.claude` lives only at the workspace root, never in a sub-repo.** Hook commands must anchor on `$CLAUDE_PROJECT_DIR` (`node "$CLAUDE_PROJECT_DIR/.claude/hooks/toga/X.js"`), not relative paths: relative paths resolve against cwd, so a sub-repo cwd gives MODULE_NOT_FOUND. A hook error naming a sub-repo `.claude` path is a path-resolution bug, not a folder to create. The settings file ships from the bundle, so the fix must live upstream or `npx toga-ai` reverts it.
45
+ - **Publish Action (`.github/workflows/publish.yml`, on push to `_main`: bump patch, `npm publish`).** Devs get a change only after a version bump (kickoff runs `npx toga-ai@latest` when installed != latest).
46
+ - `npm publish` **E404** ("could not be found or you do not have permission") = auth failure, not a missing package: the `NPM_TOKEN` secret in the repo's **Actions environment** expired. Fix: new npm Automation token, update the secret, re-run only the latest failed run.
47
+ - A publish ships the whole tip of `_main`, so earlier failed runs are not lost. Do not retry old runs (they hit "version already published").
48
+
44
49
  ## Related
45
50
 
46
51
  - [Knowledge Base Publish / Push Pipeline](knowledge-publish-pipeline.md)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "toga-ai",
3
- "version": "1.0.964",
3
+ "version": "1.0.966",
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",
@@ -142,3 +142,6 @@ How to keep it:
142
142
  the developer's known style and habits before you write a word.
143
143
  - **This profile is private and per-person. Never put it in the team knowledge base** — it lives
144
144
  only in that developer's local memory, on their machine.
145
+ - **A personal habit is not a team rule.** "Run php -l for me" is a habit (memory, `type: user`).
146
+ "Record hooks go in api2 interceptors" is how we build (team KB). See
147
+ [memory-scope.md](memory-scope.md).
@@ -0,0 +1,46 @@
1
+ # Memory Scope — what goes in local memory vs the team KB
2
+
3
+ A developer's local Claude memory (`~/.claude/projects/<project>/memory/*.md`, index
4
+ `MEMORY.md`) is private to one machine. Teammates never see it. So it holds **only personal
5
+ things**. Team rules go where the team can read them.
6
+
7
+ ## Local memory — personal only
8
+
9
+ 1. How this developer likes to be talked to (`user-communication-profile`).
10
+ 2. Their local machine paths (`repo-path-<repo>`, `team-repo-path`).
11
+ 3. Their personal work habits ("run php -l for me", "don't commit unless I say so").
12
+
13
+ Save habit and profile notes as `type: user` (or name them `user-*`).
14
+
15
+ ## Team KB — every rule about HOW we build
16
+
17
+ Anything about how we build goes into the team KB in the **same change** — never only into
18
+ memory. That includes:
19
+
20
+ - architecture and where code lives,
21
+ - API shape (api2 endpoints, interceptors, scripted vs table APIs),
22
+ - SQL and `dbchanges2`,
23
+ - ACL and roles,
24
+ - data model (Records, RecordFields, Surface/settings),
25
+ - UI and design process, reuse (blox),
26
+ - sync and integration behavior (NetSuite, worker2),
27
+ - the harness itself (skills, hooks, rules).
28
+
29
+ **Platform apps** (`toga25-desk`, `toga25-iq`, `toga2-template`): an app-build rule goes into
30
+ the architecture docs of **every** platform app, not just the one you are in.
31
+
32
+ ## The rules
33
+
34
+ 1. **When a developer states a team rule, write it to the KB / app docs first.** Then, if
35
+ useful, keep a one-line pointer in memory.
36
+ 2. **Memory may keep at most a pointer** for a team rule:
37
+ `Lives in: <KB doc path or app doc § section>`. No copy of the rule body.
38
+ 3. **Unsure?** Ask: "would a teammate building the same thing need this?" Yes → KB.
39
+ 4. **At `/capture`**, move any team rule found in this session's memory notes into the KB and
40
+ shrink the note to its `Lives in:` pointer.
41
+
42
+ ## How this is enforced
43
+
44
+ `scripts/hooks/memory-scope-guard.js` runs after every Write/Edit into a memory folder. If the
45
+ note looks like a team rule, it warns (never blocks). Silence it with
46
+ `TOGA_MEMORY_SCOPE_GUARD_DISABLED=1`.
@@ -0,0 +1,220 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ /*
5
+ * memory-scope-guard.js — WARN when a team rule is being written into a developer's
6
+ * LOCAL Claude memory instead of the team knowledge base.
7
+ *
8
+ * Why this exists: local memory (~/.claude/projects/<project>/memory/*.md, index
9
+ * MEMORY.md) is private to one machine. Teammates never see it. A scripted-API rule and
10
+ * ~80 build rules once lived only in one developer's memory. The rule
11
+ * (rules/common/memory-scope.md) says memory holds ONLY personal things — talking style,
12
+ * local paths, personal habits. Team rules go to the KB (or a platform app's docs), and
13
+ * memory keeps at most a one-line "Lives in:" pointer.
14
+ *
15
+ * How it works: registered on PostToolUse for Write|Edit|MultiEdit. It fires only when
16
+ * the target sits inside a Claude memory folder (`.claude/projects/<x>/memory/` or
17
+ * `.claude/memory/`, either slash style). It reads the note as written to disk and warns
18
+ * when the note STATES A BUILD RULE: one sentence holds both a directive word (never /
19
+ * always / must / do not / should / required) and a build term (SQL, api2, ACL, ...).
20
+ * Lookup notes (paths, URLs, profiles, registries) and status/reminder notes rarely pair
21
+ * the two, so they stay quiet. "Why:" lines are ignored (they quote the reason).
22
+ * Skipped: `type: user`; any note with a "Lives in:" pointer line; notes named user-*,
23
+ * repo-path-*, habit-*, *-profile, or with path/location/dir/folder in the name.
24
+ * MEMORY.md (the index) is checked only for added lines that are not "- [name](file.md)"
25
+ * pointers and that name build terms.
26
+ * The warning goes through the PostToolUse additionalContext channel. Once per file per
27
+ * session (state file in the OS temp dir, keyed on session id).
28
+ *
29
+ * It never blocks. It always exits 0 and fails open on any error.
30
+ *
31
+ * Escape hatch: set TOGA_MEMORY_SCOPE_GUARD_DISABLED=1 to silence the warning.
32
+ */
33
+
34
+ const fs = require('fs');
35
+ const os = require('os');
36
+ const path = require('path');
37
+
38
+ const RULE_DOC = 'rules/common/memory-scope.md';
39
+ const MEMORY_INDEX_NAME = 'memory.md';
40
+ const MAX_SEEN_PATHS = 500;
41
+
42
+ const MEMORY_DIR_RE = /(^|\/)\.claude\/(projects\/[^/]+\/)?memory\//i;
43
+ const PERSONAL_NAME_RE = /^(user-.*|repo-path-.*|habit-.*|.*-profile)$/i;
44
+ const LOCATION_NAME_RE = /(^|-)(path|paths|location|dir|folder)(-|$)/i;
45
+ const DIRECTIVE_RE = /\b(never|always|must(?: not)?|do not|don't|should(?: not)?|required)\b/i;
46
+ const WHY_LINE_RE = /^\s*\**why\**\s*:/i;
47
+ // Sentence or clause: split on . ! ? ; and line breaks.
48
+ const SENTENCE_SPLIT_RE = /(?<=[.!?;])\s+|\r?\n/;
49
+ // Asides in (...) describe, they do not set rules — e.g. "(so don't break it)".
50
+ const PARENTHETICAL_RE = /\([^()]*\)/g;
51
+ // [[note-name]] links name other notes; their words are not this note's rule.
52
+ const WIKI_LINK_RE = /\[\[[^\]]*\]\]/g;
53
+ const LIVES_IN_RE = /^\s*\**lives in\**\s*:/im;
54
+ const INDEX_POINTER_RE = /^\s*-\s*\[[^\]]+\]\([^)]+\)/;
55
+ const BUILD_TERMS_RE = new RegExp(
56
+ '\\b(' + [
57
+ 'SQL', 'dbchanges2', 'api2', '_underscore', 'interceptors?', 'ACL', 'RecordFields',
58
+ 'blox', 'migrations?', 'endpoints?', 'scripted', 'worker2', 'NetSuite',
59
+ 'schema', 'components?',
60
+ ].join('|') + ')\\b',
61
+ 'i'
62
+ );
63
+ // "surface" is also a common verb; only the capitalised platform layer name counts.
64
+ const SURFACE_TERM_RE = /\b(Surface)\b/;
65
+
66
+ function findBuildTerm(text) {
67
+ return text.match(BUILD_TERMS_RE) || text.match(SURFACE_TERM_RE);
68
+ }
69
+
70
+ function readPayload() {
71
+ let raw = '';
72
+ try { raw = fs.readFileSync(0, 'utf8'); } catch (e) { /* no stdin */ }
73
+ let data = {};
74
+ if (raw && raw.trim()) {
75
+ try { data = JSON.parse(raw); } catch (e) { data = {}; }
76
+ }
77
+ if (!data.tool_name && process.env.CLAUDE_TOOL_NAME) data.tool_name = process.env.CLAUDE_TOOL_NAME;
78
+ if (!data.tool_input && process.env.CLAUDE_TOOL_INPUT) {
79
+ try { data.tool_input = JSON.parse(process.env.CLAUDE_TOOL_INPUT); } catch (e) { /* ignore */ }
80
+ }
81
+ if (!data.session_id) data.session_id = process.env.CLAUDE_SESSION_ID || process.env.CLAUDE_CODE_SESSION_ID;
82
+ return data;
83
+ }
84
+
85
+ /* Text this tool call added: Write content, Edit new_string, MultiEdit edits[].new_string. */
86
+ function collectNewText(input) {
87
+ const parts = [];
88
+ if (typeof input.content === 'string') parts.push(input.content);
89
+ if (typeof input.new_string === 'string') parts.push(input.new_string);
90
+ if (Array.isArray(input.edits)) {
91
+ for (const e of input.edits) if (e && typeof e.new_string === 'string') parts.push(e.new_string);
92
+ }
93
+ return parts.join('\n');
94
+ }
95
+
96
+ /* Split a note into { front, body }. `front` is the raw frontmatter text ('' if none). */
97
+ function splitFrontmatter(text) {
98
+ const m = text.match(/^\uFEFF?---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/);
99
+ return m ? { front: m[1], body: m[2] } : { front: '', body: text };
100
+ }
101
+
102
+ /* `type:` may sit at the top level or nested under `metadata:`. */
103
+ function frontmatterValue(front, key) {
104
+ const m = front.match(new RegExp('^\\s*' + key + '\\s*:\\s*["\']?([^"\'\\r\\n]*)', 'im'));
105
+ return m ? m[1].trim() : '';
106
+ }
107
+
108
+ /* First sentence that states a rule about build work: a directive word (never / always /
109
+ * must / do not ...) AND a build term in the same sentence. Returns { directive, term,
110
+ * sentence } or null. Lookups (paths, profiles, registries) and status/reminder notes
111
+ * rarely pair the two, so they stay quiet. "Why:" lines quote the reason, not the rule. */
112
+ function findRuleSentence(body) {
113
+ const lines = body.split(/\r?\n/).filter((l) => !WHY_LINE_RE.test(l));
114
+ const text = lines.join('\n').replace(WIKI_LINK_RE, ' ').replace(PARENTHETICAL_RE, ' ');
115
+ for (const sentence of text.split(SENTENCE_SPLIT_RE)) {
116
+ const directive = sentence.match(DIRECTIVE_RE);
117
+ if (!directive) continue;
118
+ const term = findBuildTerm(sentence);
119
+ if (term) return { directive: directive[1], term: term[1], sentence: sentence.trim() };
120
+ }
121
+ return null;
122
+ }
123
+
124
+ /* Returns a short reason string when the note looks like a team rule, else ''. */
125
+ function teamRuleReason(fileText, fileBase) {
126
+ const { front, body } = splitFrontmatter(fileText);
127
+ const name = frontmatterValue(front, 'name') || fileBase;
128
+ if (PERSONAL_NAME_RE.test(name) || PERSONAL_NAME_RE.test(fileBase)) return '';
129
+ if (LOCATION_NAME_RE.test(name) || LOCATION_NAME_RE.test(fileBase)) return '';
130
+
131
+ const type = frontmatterValue(front, 'type').toLowerCase();
132
+ if (type === 'user') return '';
133
+ if (LIVES_IN_RE.test(body)) return '';
134
+
135
+ const hit = findRuleSentence(body);
136
+ if (!hit) return '';
137
+ return '`type: ' + (type || 'none') + '` note states a build rule ("' + hit.directive +
138
+ '" + "' + hit.term + '")';
139
+ }
140
+
141
+ /* MEMORY.md: warn only for added lines that are not index pointers and name build terms. */
142
+ function indexRuleReason(newText) {
143
+ for (const line of newText.split(/\r?\n/)) {
144
+ const t = line.trim();
145
+ if (!t || t.startsWith('#') || INDEX_POINTER_RE.test(t)) continue;
146
+ const term = findBuildTerm(t);
147
+ if (term) return 'MEMORY.md line that reads like a rule, not a pointer ("' + term[1] + '")';
148
+ }
149
+ return '';
150
+ }
151
+
152
+ function sessionKey(data) {
153
+ const sid = (data && data.session_id) || String(process.ppid || process.pid);
154
+ return String(sid).replace(/[^A-Za-z0-9_-]/g, '').slice(0, 64) || 'x';
155
+ }
156
+
157
+ /* True the first time this path is seen this session; records it. */
158
+ function firstTimeThisSession(data, normPath) {
159
+ const sf = path.join(os.tmpdir(), 'toga-memory-scope-' + sessionKey(data) + '.json');
160
+ let seen = [];
161
+ try { seen = JSON.parse(fs.readFileSync(sf, 'utf8')) || []; } catch (e) { seen = []; }
162
+ if (!Array.isArray(seen)) seen = [];
163
+ if (seen.includes(normPath)) return false;
164
+ seen.push(normPath);
165
+ try { fs.writeFileSync(sf, JSON.stringify(seen.slice(-MAX_SEEN_PATHS))); } catch (e) { /* non-fatal */ }
166
+ return true;
167
+ }
168
+
169
+ function warning(filePath, reason) {
170
+ return [
171
+ '<system-reminder>',
172
+ 'MEMORY SCOPE: ' + path.basename(filePath) + ' looks like a TEAM RULE (' + reason + '),',
173
+ 'but local memory is private to this machine — teammates never see it.',
174
+ 'Put the rule in the team KB (or, for a platform app, its architecture docs in every',
175
+ 'platform app: toga25-desk, toga25-iq, toga2-template) in this same change.',
176
+ 'Keep only a one-line pointer in memory, e.g. "Lives in: <KB doc or app doc § section>".',
177
+ 'If this really is personal (talking style, local path, personal habit), ignore this.',
178
+ 'Rule: ' + RULE_DOC + '.',
179
+ '</system-reminder>',
180
+ ].join('\n');
181
+ }
182
+
183
+ function main() {
184
+ if (process.env.TOGA_MEMORY_SCOPE_GUARD_DISABLED === '1') return;
185
+
186
+ const data = readPayload();
187
+ if (!/^(Write|Edit|MultiEdit)$/.test(data.tool_name || '')) return;
188
+
189
+ const input = data.tool_input || {};
190
+ const filePath = typeof input.file_path === 'string' ? input.file_path : '';
191
+ if (!filePath) return;
192
+
193
+ const norm = filePath.replace(/\\/g, '/');
194
+ if (!MEMORY_DIR_RE.test(norm) || !/\.md$/i.test(norm)) return;
195
+
196
+ const base = path.basename(norm);
197
+ let reason;
198
+ if (base.toLowerCase() === MEMORY_INDEX_NAME) {
199
+ reason = indexRuleReason(collectNewText(input));
200
+ } else {
201
+ let text = '';
202
+ try { text = fs.readFileSync(path.resolve(data.cwd || process.cwd(), filePath), 'utf8'); } catch (e) {
203
+ text = collectNewText(input); // file unreadable — judge what was written
204
+ }
205
+ reason = teamRuleReason(text, base.replace(/\.md$/i, ''));
206
+ }
207
+ if (!reason) return;
208
+
209
+ if (!firstTimeThisSession(data, norm.toLowerCase())) return;
210
+
211
+ console.log(JSON.stringify({
212
+ hookSpecificOutput: {
213
+ hookEventName: 'PostToolUse',
214
+ additionalContext: warning(filePath, reason),
215
+ },
216
+ }));
217
+ }
218
+
219
+ try { main(); } catch (e) { /* fail-open: a warning hook must never brick a session */ }
220
+ process.exitCode = 0; // always 0; exitCode (not exit()) lets stdout flush on Windows pipes
@@ -121,6 +121,26 @@ That is the entire main-thread analysis job: summarize, do not investigate the K
121
121
  Never write `toga25-blox`'s inventory contents into the KB — it is generated-from-code and
122
122
  lives only in the repo (see CONVENTIONS → "Where knowledge lives").
123
123
 
124
+ ## Step 2b — Sweep this session's memory notes for team rules (main thread)
125
+
126
+ Local memory is private; team rules belong in the KB (see `rules/common/memory-scope.md`).
127
+
128
+ 1. List the memory notes added or changed **this session**: files in the project's memory
129
+ folder (`~/.claude/projects/<project>/memory/*.md`) whose frontmatter `originSessionId`
130
+ matches this session, or whose `modified` time / file time is inside this session.
131
+ Skip `MEMORY.md`, `type: user` notes, and `user-*` / `repo-path-*` / `*-profile` notes.
132
+ 2. For each note that holds a **team rule** (how we build: architecture, API shape,
133
+ SQL/dbchanges2, ACL, data model, UI/design process, reuse, sync, harness) and has no
134
+ `Lives in:` line, add it to the digest's `changes[]` as **Decided**, with the rule text and
135
+ `files:` = the code/doc area it governs. For a platform-app rule, say it must go into the
136
+ architecture docs of every platform app (`toga25-desk`, `toga25-iq`, `toga2-template`) and
137
+ write those app docs yourself in this step (they live in the app repos, not the KB).
138
+ 3. **After Step 5** (once you know where each rule landed), shrink each of those memory notes
139
+ to its frontmatter plus one line: `Lives in: <KB doc path or app doc § section>`. Keep the
140
+ `MEMORY.md` pointer line.
141
+
142
+ If no memory note this session holds a team rule, skip this step.
143
+
124
144
  ## Step 3 — Delegate to the `session-capture` subagent
125
145
 
126
146
  Spawn the subagent via the **Agent tool** with `subagent_type: session-capture`, passing the
@@ -177,7 +197,8 @@ person across sessions" in `rules/common/communication.md`.)
177
197
  unless I say so", "investigate before asking me", "run the DB query yourself").
178
198
  2. Update their profile in **local Claude memory** — talking style in a `user`-type note named
179
199
  `user-communication-profile`; each working habit as its own memory note (with its `MEMORY.md`
180
- pointer). Add any clear new signal; don't duplicate what's already there.
200
+ pointer). Add any clear new signal; don't duplicate what's already there. Save these as
201
+ `type: user`. A rule about how we build is **not** a habit — it goes to the KB (Step 2b).
181
202
  3. **Only record clear signals** — prefer what they said directly over what you infer. Keep the
182
203
  notes short. If nothing new came up, do nothing.
183
204