toga-ai 1.0.188 → 1.0.190

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.
@@ -30,10 +30,15 @@ related:
30
30
  The Cart page (`src/pages/Cart/`) is the most config-heavy page in `toga2-commerce`. As of
31
31
  `_production` it is **config-driven at the field level but hardcoded at the section level**, and it
32
32
  renders the cart through **two near-duplicate dual-mode forms** (cart-checkout vs edit-order). A
33
- config-driven refactor is **planned but not yet implemented** (see *Planned refactor* below). This
34
- doc records both the current architecture and the agreed target so the work can be picked up without
35
- re-deriving it. The cart consumes the per-tenant `CARTPAGE.ts` config resolved through
36
- `useAssignClientFields` — see [client-fields](client-fields.md) for that resolution model.
33
+ config-driven refactor to the toga2.5 (`toga25-supply`) pattern is **planned but not yet
34
+ implemented** (see *Planned refactor*). This doc is written to be **self-sufficient** — a teammate
35
+ can pick the work up from here without re-deriving it. The cart consumes the per-tenant
36
+ `CARTPAGE.ts` config resolved through `useAssignClientFields` — see
37
+ [client-fields](client-fields.md) for that resolution model.
38
+
39
+ > A longer-form plan with the same content also lives in the toga2-commerce repo at
40
+ > `docs/reviews/implement-config-cart-refactor-PLAN.md` (not distributed in the KB bundle). This doc
41
+ > is the authoritative copy for teammates who only have the knowledge base.
37
42
 
38
43
  ## Current architecture (`_production`)
39
44
 
@@ -71,8 +76,8 @@ based on `inEditMode`. `CartFormRenderer.tsx` exists only on the `implement-conf
71
76
  - **Config-key naming drift.** `CARTPAGE.ts` uses `inputs:` for the `orderFor` section but
72
77
  `formFields:` for `shippingAddress` / `costCenter` / `notifications`, while `CartFormSection`
73
78
  reads a `fields` prop. Three names for one concept; `CartForm` hand-maps each section's array into
74
- the `fields` prop. This pre-existing drift is what tripped the prior spike. (Target: rename all to
75
- a single `fields` key.)
79
+ the `fields` prop. **This pre-existing drift is what tripped the prior spike.** (Target: rename all
80
+ to a single `fields` key.)
76
81
  - **`useForm` is seeded with a hardcoded union of every client's fields.** `CartPage.tsx`
77
82
  (~lines 230–258) calls `useForm` with one static `defaultValues` object that is the **union** of
78
83
  all clients' fields, with the **COMPASS-only magic shipping-method UUID `087a9d0b-…` baked in**.
@@ -82,74 +87,201 @@ based on `inEditMode`. `CartFormRenderer.tsx` exists only on the `implement-conf
82
87
  - **Client differences are hardcoded, not config.** Examples observed: COMPASS renders
83
88
  `DuplicateKitGuardrail`, QUAD does not; COMPASS seeds the cart form (cost center / manager / emails)
84
89
  from the order-for user, QUAD does not. These are per-client conditionals in cart code today.
90
+ - **Standards debt in the dual forms** (fold in as each file is touched): silent error swallows
91
+ (e.g. the cost-center `onError` that never reaches Sentry), the magic Standard-Ground shipping
92
+ UUID, and a 13-arg `checkIsProceedToCheckoutEnabled` helper (violates the 4+ params rule).
85
93
  - `useAssignClientFields.ts` returns `FIELDS?.[clientName]?.[language]?.[role]` with no transform —
86
94
  there is no fallback (a missing combo renders empty). See [client-fields](client-fields.md).
87
95
 
96
+ ## Target: the toga2.5 pattern (what "done" looks like)
97
+
98
+ Seven conventions, each already demonstrated in `toga25-supply` (read these as the reference
99
+ implementation):
100
+
101
+ | # | Convention | toga2.5 reference file |
102
+ |---|---|---|
103
+ | **C1** | One key name for a section's input array — toga2.5 uses **`fields`** everywhere. | `orderViewFields.json`, `itemRecordCreateFields.json` |
104
+ | **C2** | A section declares its renderer via a **`type`** discriminator; a `Record<type, fn>` map dispatches it. No hardcoded section list. | `SalesOrderSummaryGrid.tsx` (`sectionRenderers[section.type]`) |
105
+ | **C3** | Visibility is `isVisible` filtered at render; **runtime** visibility is computed in the view-model and **patched into config** (memo that rewrites the section), not branched in JSX. | `useSalesOrderRecordModalViewModel` (`patchedTenantFields`) |
106
+ | **C4** | Config stays **serializable**; behavior (handlers, predicates) is a **string key hydrated → function once** in a memo. | `ItemRecordCreate.tsx` (`hydrateField`, `SECOND_VALUE_VISIBILITY`) |
107
+ | **C5** | Generic fields via one `renderField` + generic input component; specialized layout (totals, address) is a standalone component that **still consumes config `fields`**. | `renderField.tsx`, `OrderSectionCard`, `BaseTotals` |
108
+ | **C6** | **Client-specific behavior is declared in config and evaluated generically — never `if (client === "COMPASS")` in code.** Capabilities are flags; conditional logic is a **named rule** (`{ type: "..." }`) evaluated by a small pure evaluator in the view-model. | `approvalActionFields.json` (`approvalActionStatuses`, `approveButtonRule`) → `evaluateApproveRule()` in `useApprovalModalViewModel.tsx` |
109
+ | **C7** | **Form `defaultValues` are derived from the same config that renders the fields** so each client's defaults match exactly the fields it collects. (Cart-specific extension — see below.) | toga2.5 uses a static `emptyItemDefaults` (single-shape form); cart needs config-derived |
110
+
111
+ Applied to the cart, "done" = `CartPage` owns state → a `CartFormContext` distributes it → a
112
+ `CartFormRenderer` walks the section config and dispatches each section (generic `CartFormSection`
113
+ or a standalone like Summary/Confirm/Email) → **one** code path serves both cart and edit-order
114
+ modes via an `inEditMode` flag in context + `visibleWhen: { context: "inEditMode" }` in config.
115
+
116
+ ## Gap: `_production` → toga2.5 (the work to close each)
117
+
118
+ | Std | `_production` today | Work to close it |
119
+ |---|---|---|
120
+ | **C1** one key (`fields`) | `inputs` (orderFor) + `formFields` (others); `CartFormSection` wants `fields` | Rename all section arrays to **`fields`** in every `CARTPAGE.ts`; drop the hand-mapping. |
121
+ | **C2** section `type` + dispatch map | section layout hardcoded in `CartForm.tsx`, built twice | Add `type` (+ `order`/`isVisible`) to each section in config; build `CartFormRenderer` mapping `type → component`. Delete hardcoded layout. |
122
+ | **C3** runtime visibility patched in VM | `isHiddenDuringEditMode`/`inEditMode` branches scattered | Express edit-order differences as `visibleWhen: { context: "inEditMode" }`; resolve once via context. |
123
+ | **C4** string keys hydrated to fns | `labelButton.onClick:"cantFindUser"`, `inputButtons[].valueKey` are strings but `handleModalOpen` is wired ad-hoc + drilled | One `hydrateCartConfig` memo mapping config strings → handlers from context. |
124
+ | **C5** standalone sections consume config | Summary / email / confirm are inline JSX in `CartForm` | Extract to `SummarySection` / `CartEditEmailSection` / `ConfirmSection`, each reading its config slice. |
125
+ | **(state)** context, not props | ~30 props drilled through 4 levels | `CartFormContext` owns watched values + handlers + `inEditMode`; components read it. |
126
+ | **(VMs)** consolidated | `useEditCartViewModel` + `useEditOrderViewModel` + dispatcher, duplicated effects | Merge into `useCartFormViewModel` (data) + `useCartFormSync` (mount / orderFor-cascade / form→store, mode-branched once). |
127
+ | **C6** client behavior in config | client differences hardcoded (DuplicateKitGuardrail, cost-center seeding) | Move each to a config capability flag or named rule (below). |
128
+
88
129
  ## Planned refactor (DECIDED 2026-06-24 — plan only, not implemented)
89
130
 
90
131
  **Approach (Route B): rebuild the config-driven cart from the working `_production` branch**, using
91
132
  the broken `implement-config` spike branch only as a *reference blueprint* (its target-shaped files
92
133
  — `CartFormContext`, `CartFormRenderer`, `useCartFormViewModel`, `useCartFormSync`, standalone
93
134
  sections — are architecturally sound but mid-rename and non-functional; lift code where correct,
94
- do not finish that branch). Rationale: the spike's −3,940-line deletion silently dropped real
135
+ do not finish that branch). **Rationale:** the spike's −3,940-line deletion silently dropped real
95
136
  behavior (email seeding, duplicate-email validation, edit-order cost-center/address). The risk is
96
137
  **behavior parity, not architecture**, so migrate in verifiable slices against the running
97
- `_production` app as the oracle. Target pattern = toga2.5 (`toga25-supply`): one form, section
98
- layout from config, state via context, consolidated sync effects.
138
+ `_production` app as the oracle.
139
+
140
+ ### Phases
141
+
142
+ **Phase 0 — Branch + safety net.** Branch `feature/cart-config-driven-form` off `_production`.
143
+ **Pin a behavior oracle:** no tests cover the cart today, so first document both flows from the
144
+ running app (sections shown, fields, buttons, validation messages, email auto-population, edit-order
145
+ original address) and add RTL smoke tests rendering the cart for one tenant/role in **cart** and
146
+ **edit-order** mode — these guard against the "renders nothing" failure the spike hit. **Inventory
147
+ the hardcoded client branches (C6):** grep `src/pages/Cart` for `clientSlug`/client-name comparisons
148
+ and per-client conditionals (start with `DuplicateKitGuardrail` and the order-for
149
+ cost-center/manager/email seeding) — this list is the C6 work-list for Phases 3–4.
150
+
151
+ **Phase 1 — Normalize config to one key (C1). 🔴 Do first, standalone, behavior-neutral.**
152
+ In every `CARTPAGE.ts` (COMPASS / COMPASSCANADA EN+FR / QUAD × all roles) rename each section's
153
+ input array to **`fields`** (`inputs → fields`, `formFields → fields`). Update `CartForm.tsx`'s
154
+ per-section hand-mapping to read `section.fields` uniformly. Add a `CartSectionConfig` /
155
+ `CartFieldConfig` type so the key is compiler-enforced (kills the untyped `any` that allowed the
156
+ drift). Grep to prove zero `inputs:` / `formFields:` remain. **Ships as its own PR.**
157
+
158
+ **Phase 2 — State into context + config-derived defaults.** Add `CartFormContext` (lift from the
159
+ branch) owning watched values, handlers (`handleModalOpen`, email handlers, cost-center search),
160
+ `formMethods`, `advancedSelectConfig`, `managerName`, and **`inEditMode`**. `CartPage` builds the
161
+ context once; `CartForm`/`CartFormSection` read context instead of props. Replace the hardcoded
162
+ union `defaultValues` (CartPage.tsx ~230–258) and the two `reset()` effects (~267, ~282) with the
163
+ config-derived builder (below).
164
+
165
+ **Phase 3 — Consolidate view-models + sync effects.** Merge `useEditCartViewModel` +
166
+ `useEditOrderViewModel` + the dispatcher into **`useCartFormViewModel`** (data). Move the three
167
+ duplicated effects into **`useCartFormSync`**: (a) on-mount populate from `salesOrder` (use a
168
+ `hasSyncedOnMount` ref for run-once — the branch did this correctly), (b) orderFor-change cascade,
169
+ (c) form→store sync — branching on `inEditMode` *once* inside each effect, not across two files.
170
+ Drive client-specific seeding by **rule (C6.2)**, not client name.
171
+ > **⚠ Parity gates — this is exactly where the prior spike silently regressed. Verify each against the Phase 0 oracle:**
172
+ > - email auto-population (contact / supervisor / supported-by / current user) for **both** modes;
173
+ > - duplicate-email path still surfaces `duplicateEmailAddressError` to the user (dedupe must not swallow it);
174
+ > - edit-order cost-center "prefer fresh user data over stale salesOrder" logic;
175
+ > - effect dependency arrays — no stale closures; justify every `eslint-disable`.
99
176
 
100
- Plan document (in the toga2-commerce repo, not in the KB):
101
- `docs/reviews/implement-config-cart-refactor-PLAN.md`.
177
+ **Phase 4 — Section layout from config (C2/C3/C5). 🔴 The core of the refactor.** Add
178
+ `type` + `order` + `settings.isVisible` to each section in config. Build **`CartFormRenderer`**:
179
+ order sections, dispatch `SECTION_RENDERERS[section.type]`, with an **explicit fallback (log + skip)**
180
+ for an unknown/missing type so a section can't silently vanish. Extract inline Summary / email /
181
+ Confirm JSX into `SummarySection` / `CartEditEmailSection` / `ConfirmSection`, each consuming its
182
+ config slice via context. Express edit-order-only pieces (original-address block) as
183
+ `visibleWhen: { context: "inEditMode", value: true }` — or confirm with product they're dropped.
184
+ Gate client-optional UI on **capability flags (C6.1)**. **Delete `CartForm.tsx`, `EditCart.tsx`,
185
+ `EditOrder.tsx`** once the renderer covers both modes and the oracle passes.
102
186
 
103
- Phases: **P0** pin a behavior oracle + inventory hardcoded client branches · **P1** behavior-neutral
104
- config-key rename (`inputs`/`formFields` → single `fields`) · **P2** `CartFormContext` to kill
105
- prop-drilling + config-derived form defaults · **P3** consolidate the dual view-models and the three
106
- sync effects into `useCartFormViewModel` + `useCartFormSync`, branching on `inEditMode` once · **P4**
107
- drive section layout from config (`type` + dispatch map with explicit fallback) + capability flags,
108
- then delete `CartForm`/`EditCart`/`EditOrder` · **P5** hydration memo + standards sweep (magic UUID
109
- removed from code, lives in config).
187
+ **Phase 5 — Hydration + standards cleanup (C4).** One `hydrateCartConfig` memo mapping
188
+ `labelButton.onClick` / `inputButtons[].valueKey` / `visibleWhen.context` strings → handlers &
189
+ predicates from context. Standards sweep on every file touched: no commented-out code, no
190
+ `console.log`; restore the cost-center `onError` Sentry path; convert `checkIsProceedToCheckoutEnabled`
191
+ to an options object (4+ params rule); replace remaining `any` (`UseFormReturn<any>`, untyped
192
+ `fields`); the magic shipping UUID is removed from code entirely — it now lives as a config
193
+ `defaultValue` (C7, Phase 2).
110
194
 
111
- ### Key principle established — client behavior comes from config, never `if (client === ...)`
195
+ ### Key principle — client behavior comes from config, never `if (client === ...)` (C6)
112
196
 
113
- A new client must be onboarded by **editing `CARTPAGE.ts`**, not by adding `clientSlug` branches to
114
- cart code. Two flavors, modeled exactly on toga2.5's SalesOrder approval pattern (config carries
115
- `approvalActionStatuses` + `approveButtonRule:{type:"requireStepTwoAssigned"}`; a pure
116
- `evaluateApproveRule(rule, ctx)` switch over a discriminated union in `useApprovalModalViewModel.tsx`
117
- interprets it — the component never knows it is COMPASS):
197
+ A new client must be onboarded by **editing `CARTPAGE.ts`**, not by adding `clientSlug` branches.
198
+ Modeled exactly on toga2.5's SalesOrder approval pattern: config carries `approvalActionStatuses`
199
+ + `approveButtonRule:{type:"requireStepTwoAssigned"}`; a pure `evaluateApproveRule(rule, ctx)`
200
+ switch over a discriminated union in `useApprovalModalViewModel.tsx` interprets it — the component
201
+ never knows it is COMPASS. Two flavors:
118
202
 
119
203
  1. **Capability flags** — pure on/off presence, no runtime data:
120
- `capabilities.duplicateKitGuardrail.isEnabled` (COMPASS true, QUAD omits). The renderer reads the
121
- flag to decide whether to mount `DuplicateKitGuardrail`.
204
+ `capabilities.duplicateKitGuardrail.isEnabled` (COMPASS `true`, QUAD omits → safe-off default).
205
+ The renderer reads the flag to decide whether to mount `DuplicateKitGuardrail`.
122
206
  2. **Named rules** — conditional logic depending on runtime data, declared as `{ type }` and
123
- evaluated generically: e.g. `orderForSeedRules: [{type:"seedCostCenterFromUser"}, …]` iterated by
207
+ evaluated generically. e.g. `orderForSeedRules: [{type:"seedCostCenterFromUser"}, …]` iterated by
124
208
  an `applySeedRule` evaluator in `useCartFormSync` (mirrors `evaluateApproveRule`). QUAD omits the
125
209
  rules → seeds nothing, no branch.
126
210
 
127
- ### Config-derived form defaults (cart-specific extension of the toga2.5 pattern)
211
+ **Rule of thumb:** if a behavior differs by client it must be reachable from `CARTPAGE.ts` as a flag
212
+ or a `{ type }` rule. No `clientSlug` comparisons anywhere in `src/pages/Cart`.
213
+
214
+ ### Config-derived form defaults (C7 — cart-specific extension of toga2.5)
128
215
 
129
216
  The config already enumerates exactly the fields a client renders, so derive `useForm`
130
- `defaultValues` from it via a `buildCartDefaultValues(fields, ctx)` builder with three sources:
131
- - per-field `defaultValue` in config (where the COMPASS shipping UUID **moves to**; QUAD omits it);
132
- - type-derived empties (`text`/`textArea`→`""`, `checkbox`→`false`,
133
- `select`/`advancedSelect`→`{uuid:"",name:""}`);
134
- - dynamic `defaultValueRules` reusing the same `{type}` rule shape
135
- (e.g. `currentUserWhenNotEditable`, `fromSalesOrderErpEntity`).
136
-
137
- The two mode-change `reset()` effects call the **same builder** so defaults and rendered inputs can
138
- never drift. Noted honestly: toga2.5 uses a *static* `emptyItemDefaults` because its item-create form
139
- is a single shape; the cart is genuinely multi-shape per client, so config-derived defaults is a
140
- **cart-specific extension** of toga2.5's philosophy, not a literal copy.
217
+ `defaultValues` from it via `buildCartDefaultValues(fields, ctx)` (`ctx = { user, salesOrder, emails,
218
+ inEditMode }`) with three sources, in precedence order per field:
219
+
220
+ 1. **per-field `defaultValue` in config** — where the COMPASS shipping UUID **moves to**; QUAD omits
221
+ it and falls through;
222
+ 2. **dynamic `defaultValueRules`** reusing the C6.2 `{type}` rule shape for state-dependent values
223
+ (e.g. `{valueKey:"orderFor", type:"currentUserWhenNotEditable"}`,
224
+ `{valueKey:"costCenter", type:"fromSalesOrderErpEntity"}`);
225
+ 3. **type-derived empties** otherwise (`text`/`textArea`→`""`, `checkbox`→`false`,
226
+ `select`/`advancedSelect`→`{uuid:"",name:""}`).
227
+
228
+ ```ts
229
+ const defaultValues = useMemo(() => buildCartDefaultValues(fields, ctx), [fields, /* … */]);
230
+ const formMethods = useForm({ mode: "all", defaultValues });
231
+ // both mode-change reset()s call the SAME builder: reset(buildCartDefaultValues(fields, ctx))
232
+ ```
233
+
234
+ Result: `useForm` registers **exactly** the fields the client collects — no union object, no orphan
235
+ defaults, no controlled/uncontrolled warnings; resets stay config-consistent; the shipping UUID and
236
+ any client default live in `CARTPAGE.ts`; new-client onboarding needs no `CartPage` edit. Noted
237
+ honestly: toga2.5 uses a *static* `emptyItemDefaults` because its item-create form is single-shape;
238
+ the cart is genuinely multi-shape per client, so config-derived defaults is a **cart-specific
239
+ extension** of toga2.5's philosophy, not a literal copy.
141
240
 
142
241
  > The general "no `if (client === ...)` — drive client behavior from config via capability flags and
143
- > named rules evaluated by a generic evaluator" rule, applied across 2.0 React frontends beyond just
144
- > the cart, is being proposed as a team **standard** separately (senior-owned / pending approval).
242
+ > named rules" rule, applied across 2.0 React frontends beyond the cart, was proposed as a team
243
+ > standard separately and **declined** for now (the principle stays scoped to this cart doc).
244
+
245
+ ## Definition of Done
246
+
247
+ - [ ] Single key name (`fields`) across `src/pages/Cart`; `CartForm`/`EditCart`/`EditOrder` deleted.
248
+ - [ ] One render path serves cart **and** edit-order; renderer has a fallback for unknown section types.
249
+ - [ ] Parity verified against the Phase 0 oracle: all sections render & submit; validation messages
250
+ fire; email seeding + duplicate-email error reproduced in **both** modes.
251
+ - [ ] No prop-drilling (context); VMs and sync effects consolidated; no stale-closure deps.
252
+ - [ ] **No `clientSlug`/client-name comparisons in `src/pages/Cart` (C6)** — every client difference
253
+ (DuplicateKitGuardrail, order-for seeding, tax/shipping calc) is a capability flag or named
254
+ rule; a client that omits a key gets the safe default.
255
+ - [ ] **Form `defaultValues` built from config (C7)** — registers exactly the fields the client
256
+ collects; no hardcoded union object; shipping-method UUID lives in config; resets use the builder.
257
+ - [ ] Standards clean: no dead code / `console.log`, cost-center error reaches Sentry, no new `any`.
258
+ - [ ] `tsc` clean; smoke tests green; PR body documents both test flows + the
259
+ `inputs/formFields → fields` migration.
260
+
261
+ ## Sequencing & risk
262
+
263
+ - **Phase 1 ships independently** (pure rename, behavior-neutral) — land it first to de-risk the rest.
264
+ - **Phase 4 is the heart**; it depends on Phases 2–3 so the renderer has context + consolidated data.
265
+ - **Biggest risk is behavior parity, not rendering.** The dual forms hold real logic (email seeding,
266
+ duplicate validation, edit-order cost-center/address). Phase 0's oracle and Phase 3's parity gates
267
+ exist specifically because the prior spike deleted −3,940 lines and lost behavior static reading
268
+ could not catch. Migrate in verifiable slices against a running `_production`.
269
+
270
+ ## Open questions for kickoff
271
+
272
+ *(Approach is settled: rebuild from `_production` with the branch as reference.)*
273
+ 1. **Config key name** — `fields` (toga2.5 standard, recommended) vs keep per-section names.
274
+ 2. **Edit-order original-address block** — still required, or intentionally dropped?
275
+ 3. **Edit-order duplicate-user guard** — the prior spike made it "always applied"; intended for
276
+ edit-order mode?
145
277
 
146
278
  ## Change history
147
279
  - 2026-06-24 — Initial: documented the current `_production` Cart architecture (field-level config
148
280
  via `CartFormSection`, hardcoded section layout in `CartForm`, dual `EditCart`/`EditOrder` forms +
149
- dispatcher VM, prop-drilling), its gotchas (config-key drift `inputs`/`formFields`/`fields`,
150
- hardcoded union `defaultValues` with the COMPASS magic shipping UUID, hardcoded per-client
151
- branches), and the DECIDED config-driven refactor approach (rebuild from `_production`, branch as
152
- blueprint; capability flags + named rules à la toga2.5 approval; config-derived
153
- `buildCartDefaultValues`). Plan only — not implemented (apeterson)
154
- </content>
155
- </invoke>
281
+ dispatcher VM, prop-drilling), its gotchas (config-key drift, hardcoded union `defaultValues` with
282
+ the COMPASS magic shipping UUID, hardcoded per-client branches), and the DECIDED config-driven
283
+ refactor approach. (apeterson)
284
+ - 2026-06-24 — Expanded to be self-sufficient for handoff: added the C1–C7 target conventions table
285
+ with toga2.5 reference files, the gap→work table, the full phased plan (incl. Phase 0 oracle +
286
+ smoke tests and Phase 3 parity gates), Definition of Done, sequencing/risk, and open questions.
287
+ Fixed stray closing tags accidentally written into the file. (apeterson)
@@ -193,9 +193,30 @@ backfills `workTypeAtLock` the first time a task is seen).
193
193
  and the committed values should be rotated. See team coding/security standards.
194
194
  - **Worker contract.** Like all 2.0 workers this must return cleanly and commit writes via
195
195
  the `_Db`/transaction layer to be durable — uncommitted captures can be silently dropped.
196
+ - **ClickUp pagination returns duplicate tasks — the sync loop must be idempotent.** ClickUp's
197
+ page-based task API (`/list/{id}/task?page=N`) can return the **same task on more than one
198
+ page**, especially on large/long runs (a real failure ran ~418s across many pages). If the
199
+ sync loop treats a re-seen task as new, it builds a second `_Model_Team_Task` (id=NULL) and
200
+ `save()`s it → a second INSERT → `Duplicate entry '<sprintId>-<clickupId>' for key
201
+ 'Tasks.sprintId_clickupIdentifier'` and the whole run aborts. **Design rule (the two-map
202
+ pattern):** keep two separate maps keyed by `clickupIdentifier`. `$lookupTaskByClickUpId`
203
+ drives update-vs-insert and **must stay current for the entire run** — register every
204
+ newly-created task into it immediately and never `unset()` it mid-loop, so a duplicate page
205
+ row resolves to the same model and does an UPDATE. A separate `$tasksToDelete` map (seeded
206
+ from existing tasks) has entries removed as each task is seen; whatever remains after the
207
+ loop is deleted (SprintLock) / archived to `statusNow = ARCHIVED` (the Capture methods).
208
+ Do **not** overload one map for both jobs. This pattern is replicated identically in
209
+ `SprintLock()`, `CaptureSprintEnd()`, and `CaptureSprintDaily()` — fix all three together.
196
210
 
197
211
  ## Change history
198
212
 
213
+ - 2026-06-24 — Fixed a duplicate-key crash (`Tasks.sprintId_clickupIdentifier`) in the
214
+ ClickUp→DB sync: ClickUp pagination can return the same task on multiple pages, and the
215
+ loop was overloading one map (`$lookupTaskByClickUpId`) for both update-vs-insert and
216
+ deletion tracking, `unset()`ing entries mid-loop so a re-seen task was treated as new and
217
+ re-INSERTed. Decoupled into two maps — a persistent `$lookupTaskByClickUpId` (new tasks
218
+ registered immediately, never unset) and a separate `$tasksToDelete` — making re-processing
219
+ idempotent. Applied to `SprintLock()`, `CaptureSprintEnd()`, and `CaptureSprintDaily()`. (jcardinal)
199
220
  - 2026-06-24 — Initial documentation of `_Worker_Team_Sprint` (`Worker/Team/Sprint.php`):
200
221
  the sprint lifecycle (warnings → lock → daily → end → release notes), the four-metric
201
222
  developer scoring model (reliability/quality/hygiene/integrity with weights, team-relative
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "toga-ai",
3
- "version": "1.0.188",
3
+ "version": "1.0.190",
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",