Explorar el Código

HANDOFF.md: mark Phases 8–11 shipped + codify the maintenance rule

§9: Phases 8 (dd158f3), 9 (f7f5db5), 10 (c35a934), 11 (ab9430b) all
moved Upcoming → Shipped with their commit SHAs. The "Upcoming" list
is now empty and the section says so explicitly.

§3: directory layout picks up assets/css/input.css, package.json /
package-lock.json / tailwind.config.js, public/assets/css/app.css
(generated, gitignored), public/assets/js/app.js, .dockerignore,
views/users/, src/Controllers/UserController, tests/Controllers/ +
tests/Cascade/.

§4: cascade table footnote noting Phase 8 closed the audit-integrity
gap.

§7: cross-references for the three controller methods that snapshot
children before the FK cascade fires.

§11: test count bumped to 74/138; new "Local CSS dev without Docker"
note for `npm run watch:css`.

§12: resume prompt rewritten — there is no scheduled coding work;
all 11 phases are shipped; new sessions should follow §14 if asked
to do anything substantive.

§13: git history extended.

§14 (formerly the Phases 8–11 plan, now codifies the rule that
produced this file):

  > After every commit that completes a phase or substantive change,
  > update §9, §13, §3, §11, and the resume prompt as needed, and
  > commit the HANDOFF.md update separately.

The user explicitly asked to "add this task to it to not forget it on
a context reset" — the maintenance contract is that task, written so
the next session reads it as a load-bearing instruction.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
achiappa hace 2 semanas
padre
commit
a1a1266425
Se han modificado 1 ficheros con 106 adiciones y 257 borrados
  1. 106 257
      HANDOFF.md

+ 106 - 257
HANDOFF.md

@@ -3,6 +3,12 @@
 Single source of truth to resume work in a fresh session. Keep this file in
 sync with the code; if something here disagrees with the repo, trust the repo.
 
+> **Maintenance rule (read first, then keep doing it).**
+> After every commit that completes a phase or substantive change, update
+> §9 (move the entry from Upcoming → Shipped with the SHA) and §13 (git
+> history). If new files were added, refresh §3. Commit the HANDOFF
+> update separately. See §14.
+
 ## 1. What this is
 
 A web replacement for an Excel sprint-planning workbook used by a ~15-person
@@ -18,36 +24,45 @@ per-cell audit trail.
 
 ## 2. Tech stack (non-negotiable)
 
-- Runtime: Docker, single container, `php:8.3-apache` base.
+- Runtime: Docker, two-stage build, `node:20-alpine` for CSS + `php:8.3-apache` for runtime.
 - Language: PHP 8.3, strict types, PSR-12.
 - Database: SQLite via PDO, file at `/var/www/data/app.sqlite` (mounted volume).
-- Front end: server-rendered PHP templates + Tailwind (Play CDN, slated for
-  vendoring in Phase 11) + jQuery 3.x + jQuery UI 1.13 (CDN). No build step yet.
+- Front end: server-rendered PHP templates + Tailwind CSS (vendored, compiled
+  by the Node stage) + jQuery 3.x + jQuery UI 1.13 (from code.jquery.com CDN).
 - Auth: Microsoft Entra ID via OpenID Connect (Authorization Code + PKCE),
   plus an optional env-configured "local admin" fallback for dev / on-prem.
 - Composer deps: `jumbojett/openid-connect-php`, `vlucas/phpdotenv`,
   `phpunit/phpunit` (dev).
+- npm deps: `tailwindcss` (build-time only).
 
 ## 3. Directory layout
 
 ```
-├── Dockerfile
+├── Dockerfile                  # multi-stage: css-builder + php:8.3-apache
 ├── docker-compose.yml
+├── .dockerignore
 ├── .env.example
 ├── composer.json / composer.lock
+├── package.json / package-lock.json
+├── tailwind.config.js
 ├── phpunit.xml
-├── ACCEPTANCE.md              # §10 manual checklist walkthrough
+├── ACCEPTANCE.md               # spec §10 manual checklist walkthrough
+├── HANDOFF.md                  # this file
+├── assets/
+│   └── css/input.css           # Tailwind entry, compiled into public/assets/css/app.css
 ├── public/
-│   ├── index.php              # front controller + router wiring + security headers
-│   ├── .htaccess              # belt-and-suspenders rewrite
+│   ├── index.php               # front controller + router wiring + security headers
+│   ├── .htaccess               # belt-and-suspenders rewrite
 │   └── assets/
+│       ├── css/app.css         # GENERATED at image-build time (gitignored)
 │       └── js/
-│           ├── sprint-planner.js    # /sprints/{id} Arbeitstage + task list
-│           └── sprint-settings.js   # /sprints/{id}/settings
+│           ├── app.js              # site-wide; data-href click handler
+│           ├── sprint-planner.js   # /sprints/{id} Arbeitstage + task list
+│           └── sprint-settings.js  # /sprints/{id}/settings
 ├── src/
 │   ├── Auth/            LocalAdmin, OidcClient, SessionGuard
 │   ├── Controllers/     AuthController, WorkerController, SprintController,
-│   │                    TaskController, AuditController
+│   │                    TaskController, AuditController, UserController
 │   ├── Db/              Connection, Migrator
 │   ├── Domain/          User, Worker, Sprint, SprintWeek, SprintWorker,
 │   │                    SprintWorkerDay, Task, TaskAssignment
@@ -59,10 +74,10 @@ per-cell audit trail.
 │   └── Services/        AuditLogger, CapacityCalculator
 ├── migrations/          001_init.sql  (full schema per spec §3)
 ├── views/               layout.php, home.php, auth/local.php,
-│                        workers/index.php,
+│                        workers/index.php, users/index.php,
 │                        sprints/{new,show,settings}.php,
 │                        audit/index.php
-├── tests/               TestCase.php + Services/ + Repositories/
+├── tests/               TestCase + Services/ + Repositories/ + Controllers/ + Cascade/
 └── data/                SQLite + sessions directory (volume-mounted, gitignored)
 ```
 
@@ -81,7 +96,8 @@ Value constraints enforced in PHP (not SQL):
 - `task_assignments.days` ≥ 0, no hard upper bound.
 - `reserve_fraction`, `rtb` ∈ [0, 1].
 
-FK cascades (important for audit-integrity work in Phase 8):
+FK cascades (every cascade path now snapshot-audits before the parent delete
+runs — Phase 8):
 - `sprint_weeks.sprint_id → sprints(id)` ON DELETE CASCADE
 - `sprint_workers.sprint_id → sprints(id)` ON DELETE CASCADE
 - `sprint_workers.worker_id → workers(id)` ON DELETE RESTRICT
@@ -123,6 +139,8 @@ Pages (HTML):
 | GET    | `/workers`                  | admin          |
 | POST   | `/workers`                  | admin          |
 | POST   | `/workers/{id}`             | admin          |
+| GET    | `/users`                    | admin          |
+| POST   | `/users/{id}`               | admin          |
 | GET    | `/sprints/new`              | admin          |
 | POST   | `/sprints`                  | admin          |
 | GET    | `/sprints/{id}`             | signed-in      |
@@ -135,7 +153,7 @@ JSON (admin-only, CSRF via `X-CSRF-Token` header; envelope per spec §7):
 | PATCH  | `/sprints/{id}`                              | name/dates/reserve |
 | POST   | `/sprints/{id}/weeks`                        | resize week set |
 | POST   | `/sprints/{id}/workers`                      | add sprint worker |
-| DELETE | `/sprints/{id}/workers/{sw_id}`              | remove sprint worker |
+| DELETE | `/sprints/{id}/workers/{sw_id}`              | remove sprint worker (audits cascaded children) |
 | POST   | `/sprints/{id}/workers/reorder`              | reorder sprint workers |
 | PATCH  | `/sprints/{id}/workers/{sw_id}`              | set rtb |
 | PATCH  | `/sprints/{id}/week-cells`                   | batch day cells |
@@ -161,7 +179,10 @@ is called inside the same transaction as the DB change. Controllers prefer
 - Bulk operations (batch cell save) → one row per changed cell.
 - A no-op UPDATE (canonical-JSON-equal before/after) → no row.
 - FK-cascading deletes must be audited by the controller BEFORE calling the
-  cascading delete. See `TaskController::delete()` as the reference.
+  cascading delete. References:
+    - `TaskController::delete()` — task → task_assignments
+    - `SprintController::removeWorker()` — sprint_worker → sprint_worker_days + task_assignments
+    - `SprintController::replaceWeeks()` — sprint_week → sprint_worker_days (on shrink)
 - Non-mutation events (LOGIN, LOGOUT, LOGIN_FAILED, BOOTSTRAP_ADMIN) → always
   one row.
 
@@ -208,23 +229,32 @@ with a `BOOTSTRAP_ADMIN` audit row.
 - [x] **Phase 7 — Audit viewer + polish** (`21d0c4a`): `/audit` admin page
       with filters + pagination + collapsible diffs, security headers +
       strict-ish CSP, CSRF audit (18/18 mutations), PHPUnit harness with
-      59 tests covering CapacityCalculator, day-value validation,
-      AuditLogger, and OIDC bootstrap. [ACCEPTANCE.md](ACCEPTANCE.md)
-      captures the spec §10 manual walkthrough.
+      59 tests. [ACCEPTANCE.md](ACCEPTANCE.md) captures the spec §10
+      manual walkthrough.
+- [x] **Phase 8 — Cascade audit integrity** (`dd158f3`): three FK cascade
+      paths (sprint_worker → sprint_worker_days, sprint_worker →
+      task_assignments, sprint_week → sprint_worker_days on shrink) now
+      snapshot-audit before the parent delete fires. +4 tests, +2 repo
+      lookup methods.
+- [x] **Phase 9 — Users management** (`f7f5db5`): `GET /users` + `POST
+      /users/{id}` with self-demote and last-admin guardrails. Pure static
+      `UserController::demoteGuardrail` is testable with no PDO/session
+      setup. +6 tests.
+- [x] **Phase 10 — Task list polish** (`c35a934`): multi-select owner
+      filter (checkbox dropdown) and column-visibility toggle, both
+      pure client-side with localStorage persistence per sprint.
+- [x] **Phase 11 — CSP hardening** (`ab9430b`): vendored Tailwind via
+      a Node css-builder Docker stage; inline onclick replaced by
+      `data-href` + `app.js`; CSP dropped `'unsafe-inline'` and the
+      Tailwind CDN host. Strict CSP now in effect.
 
 ### Upcoming
 
-Planned in §14. Short version:
-
-- [ ] **Phase 8 — Cascade audit integrity**
-- [ ] **Phase 9 — Users management**
-- [ ] **Phase 10 — Task list polish** (column visibility + multi-select owner filter)
-- [ ] **Phase 11 — CSP hardening** (vendor Tailwind + drop inline onclick)
+Nothing scheduled. The next time real work lands, follow the maintenance
+rule (§14) and add the entry here.
 
 ## 10. Residual known gaps / deferred items
 
-Items moved into Phases 8–11 are NOT listed here — see §14.
-
 - **Worker reorder on `/sprints/{id}` reloads the page** after drag so the
   task list's worker columns stay in sync. Acceptable, but noisy if the
   user has a lot of edits in flight (they're all saved by then). Not
@@ -237,7 +267,7 @@ Items moved into Phases 8–11 are NOT listed here — see §14.
   release.
 - **Manual acceptance walkthrough** ([ACCEPTANCE.md](ACCEPTANCE.md))
   hasn't been executed end-to-end by a human yet — it's a documentary
-  follow-up once Phase 8 lands.
+  follow-up that should happen in the running container.
 
 ## 11. Running locally
 
@@ -248,11 +278,17 @@ docker compose up --build
 # open http://localhost:8080
 ```
 
-Rebuild when the Dockerfile / composer manifest changes:
+Rebuild when the Dockerfile / composer manifest / Tailwind sources change:
 ```bash
 docker compose build --no-cache && docker compose up
 ```
 
+For local CSS dev without Docker:
+```bash
+npm install
+npm run watch:css   # rebuilds public/assets/css/app.css on change
+```
+
 The SQLite file lives at `./data/app.sqlite` on the host; nuking it resets
 the app to a blank slate (migrations run on the next request).
 
@@ -264,7 +300,7 @@ for f in $(git ls-files '*.php'); do php -l "$f" | tail -1 | sed "s|^|$f: |"; do
 Run the test suite:
 ```bash
 vendor/bin/phpunit
-# → OK (59 tests, 90 assertions)
+# → OK (74 tests, 138 assertions)
 ```
 
 ## 12. How to resume in a fresh Claude session
@@ -272,11 +308,12 @@ vendor/bin/phpunit
 Tell Claude:
 
 > Working on `/Users/achiappa/Development/claude_code_private/sprint_planer_web`.
-> Read `HANDOFF.md`, the git log, and `ACCEPTANCE.md`. Phases 1–7 are
-> shipped (see §9). Start **Phase 8 — Cascade audit integrity**. Follow
-> §14 for the full plan of Phases 8–11. Do one phase at a time, commit
-> after each, and update `HANDOFF.md` §9 to move each phase from
-> "Upcoming" to "Shipped" with its commit SHA.
+> Read `HANDOFF.md`, the git log, and `ACCEPTANCE.md`. All 11 phases are
+> shipped (see §9). There is no scheduled coding work. The only
+> outstanding items are in §10 (mostly a manual acceptance walkthrough
+> in the running container). If I ask for new work, follow the
+> maintenance rule in §14 — commit code, then commit a HANDOFF.md
+> update separately.
 
 Claude should verify what's described here against actual repo state
 before acting — nothing here is load-bearing once it grows stale.
@@ -284,6 +321,11 @@ before acting — nothing here is load-bearing once it grows stale.
 ## 13. Git history (as of this handoff)
 
 ```
+ab9430b Phase 11: vendor Tailwind + drop inline onclick + tighten CSP
+c35a934 Phase 10: multi-select owner filter + column visibility toggle
+f7f5db5 Phase 9: users management page (promote / demote admin)
+dd158f3 Phase 8: audit rows for FK-cascaded deletes
+8537fc2 Plan Phases 8–11 in HANDOFF.md
 afa9e4f Ignore PHPUnit cache directory
 21d0c4a Phase 7: audit viewer + security headers + PHPUnit
 09b67f3 Add HANDOFF.md for resuming work in a fresh session
@@ -304,226 +346,33 @@ summary here.
 
 ---
 
-## 14. Upcoming phases (plan)
-
-Four phases close out every remaining item from the original §10 "Known
-gaps". Ordered by value and independent enough to ship one at a time —
-**do not batch them into one commit**.
-
-### Phase 8 — Cascade audit integrity
-
-**Why.** `task_assignments` deletes correctly snapshot-audit their
-children before the FK cascade (see `TaskController::delete`). Three
-other cascade paths don't:
-
-| Parent delete                       | Cascaded rows              | Where gap lives               |
-|-------------------------------------|----------------------------|-------------------------------|
-| `sprint_workers` row removed        | `sprint_worker_days`       | `SprintController::removeWorker` |
-| `sprint_workers` row removed        | `task_assignments`         | `SprintController::removeWorker` |
-| `sprint_weeks` row removed (shrink) | `sprint_worker_days`       | `SprintController::replaceWeeks` / `SprintWeekRepository::syncCount` |
-
-Spec §5 is explicit: every DELETE on a domain table produces exactly one
-audit row. Cascades violate that.
-
-**Deliverables.**
-- `SprintWorkerDayRepository::allForSprintWorker(int $swId): list<SprintWorkerDay>`
-- `SprintWorkerDayRepository::allForSprintWeek(int $weekId): list<SprintWorkerDay>`
-- `TaskAssignmentRepository::allForSprintWorker(int $swId): list<TaskAssignment>`
-- `SprintController::removeWorker` — inside the tx, fetch both child sets
-  for the target `sprint_worker`, emit DELETE audit rows for each, THEN
-  call `$sprintWorkers->remove($swId)` so the FK cascade runs cleanly.
-- `SprintController::replaceWeeks` — for each week in
-  `$weeks->syncCount(...)->removed`, fetch `sprint_worker_days` for that
-  week and emit DELETE audit rows BEFORE the inner DELETE (move the
-  fetch into the repo or do it in the controller before calling
-  `syncCount`). Simpler shape: change `syncCount` to return the
-  orphaned `sprint_worker_days` alongside `removed` weeks.
-- PHPUnit tests covering each of the three paths: set up a tiny sprint
-  with one worker + one week + one day cell + one task + one assignment,
-  trigger the delete, assert one audit row per cascaded child.
-
-**Commit message stub.**
-> `Phase 8: audit rows for FK-cascaded deletes`
-> - Three cascade paths now emit DELETE audit rows before the parent
->   delete runs, matching the TaskController::delete pattern from Phase 6.
-> - Repos gain by-parent lookup helpers.
-> - Regression tests.
-
-**Estimated size.** ~150 LOC + 3 test classes.
-
----
-
-### Phase 9 — Users management
-
-**Why.** The first login promotes a single user to admin. If that admin
-demotes themselves or wants to grant admin to someone else, there is no
-UI — they have to hand-edit SQLite or re-log in via local admin.
-
-**Design.**
-- New page `GET /users` (admin-only): table of every user with columns
-  email, display name, is_admin (checkbox), last_login_at.
-- New endpoint `POST /users/{id}` (admin-only form, CSRF) that toggles
-  `is_admin`. Form-based not JSON, to match `/workers/{id}`.
-- Guardrails (both enforced server-side with 422 on violation):
-  - You cannot demote yourself.
-  - You cannot demote the last remaining admin.
-- Every toggle writes an UPDATE audit row on `user`.
-
-**Deliverables.**
-- `UserRepository`:
-  - `all(): list<User>` — sorted by email.
-  - `countAdmins(): int`.
-  - `setAdmin(int $id, bool $isAdmin): array{before: User, after: User}`.
-- `UserController` (new):
-  - `index(Request): Response` — renders `views/users/index.php`.
-  - `updateAdmin(Request, array $params): Response` — form POST handler.
-- `views/users/index.php` — table + inline toggle forms + flash banner.
-- Route wiring + admin nav link ("Users" next to Workers / New sprint /
-  Audit log).
-- PHPUnit:
-  - Cannot demote self (return 422, no DB change, no audit).
-  - Cannot demote last admin (same).
-  - Admin→non-admin and back: UPDATE audit written with proper
-    before/after.
-
-**Commit message stub.**
-> `Phase 9: users management page (promote / demote admin)`
-
-**Estimated size.** ~200 LOC + tests + 1 view.
-
----
-
-### Phase 10 — Task list polish
-
-**Why.** Spec §6.4 calls out a column-visibility toggle and a
-multi-select owner filter; Phase 6 shipped the task list with a
-single-select filter and no visibility toggle.
-
-**Deliverables — purely client-side.**
-
-1. **Multi-select owner filter** in the task toolbar.
-   - Replace the `<select data-owner-filter>` with a "trigger button →
-     checkbox dropdown" pattern. Options: `(No owner)` + every worker.
-   - State is a `Set<string>` of selected values. Empty set == show all.
-   - Click outside closes the dropdown.
-   - Filter logic: row visible iff the set is empty OR the row's owner
-     id is in the set.
-   - Persist in `localStorage` keyed by `sprint-{id}-owner-filter`.
-
-2. **Column-visibility toggle** in the task toolbar.
-   - "Columns" button opens a checkbox list: Owner, Prio, Tot, and one
-     entry per sprint worker column. Task column is always visible.
-   - Hidden columns get a `hidden` class on BOTH the `<th>` and every
-     corresponding `<td>`.
-   - Persist in `localStorage` keyed by `sprint-{id}-column-visibility`.
-   - Restoring a hidden state on page load runs BEFORE first sort /
-     filter so layout doesn't flash.
-
-3. Wire `recomputeAllCapacity()` to still work when some columns are
-   hidden (it reads from DOM, so values stay correct — but verify).
-
-4. Light "sort/filter don't touch hidden rows differently" check: sort
-   sees hidden rows the same as visible rows (correct). Filter and
-   visibility are independent dimensions.
-
-**Deliverables — no backend changes.** No repo, no controller, no route,
-no audit. All in `sprint-planner.js` + `views/sprints/show.php`
-(replace the `<select>`, add a `"Columns"` button, add the dropdown
-containers).
-
-**Commit message stub.**
-> `Phase 10: multi-select owner filter + column visibility toggle`
-
-**Estimated size.** ~250 LOC of JS + small view changes. No tests
-required (behaviour is pure DOM manipulation); manual acceptance
-covers it.
-
----
-
-### Phase 11 — CSP hardening (vendor Tailwind + drop inline onclick)
-
-**Why.** Current CSP:
-```
-script-src 'self' https://cdn.tailwindcss.com https://code.jquery.com 'unsafe-inline'
-style-src 'self' https://code.jquery.com 'unsafe-inline'
-```
-
-- `cdn.tailwindcss.com` is a dev-only CDN that runs a JIT compiler in
-  the browser — not acceptable for production.
-- `'unsafe-inline'` in script-src is there for the one inline
-  `onclick="location.href=…"` in `views/home.php`.
-- `'unsafe-inline'` in style-src is there because Tailwind's runtime JIT
-  injects `<style>` blocks.
-
-Once Tailwind is pre-compiled and the inline onclick is gone, we can
-drop both `'unsafe-inline'` directives.
-
-**Deliverables.**
-
-1. **Vendor Tailwind via a multi-stage Docker build.**
-   - `package.json` with `tailwindcss@^3` (v3 — simpler than v4).
-   - `tailwind.config.js` scanning `views/**/*.php`, `src/**/*.php`,
-     `public/assets/js/**/*.js`.
-   - `assets/css/input.css` (NOT under `public/`) with `@tailwind base;
-     components; utilities;`.
-   - `Dockerfile` gains a build stage:
-     ```
-     FROM node:20-alpine AS css-builder
-     WORKDIR /build
-     COPY package.json package-lock.json* ./
-     RUN npm ci
-     COPY tailwind.config.js ./
-     COPY assets/css/input.css ./assets/css/input.css
-     COPY views/ ./views/
-     COPY src/ ./src/
-     COPY public/assets/js/ ./public/assets/js/
-     RUN npx tailwindcss -i ./assets/css/input.css -o /build/app.css --minify
-     ```
-     Final stage `COPY --from=css-builder /build/app.css
-     /var/www/html/public/assets/css/app.css`.
-   - `.gitignore` gains `/node_modules/` (already there).
-   - `views/layout.php`: replace `<script src="https://cdn.tailwindcss.com">`
-     with `<link rel="stylesheet" href="/assets/css/app.css">`.
-
-2. **Move the inline `onclick` out of `home.php`.**
-   - Change each sprint-list row to `data-href="/sprints/{id}"` and
-     drop the `onclick` attribute.
-   - New tiny `public/assets/js/app.js` (loaded in `layout.php`) wires a
-     delegated `click` handler on any `[data-href]` and navigates.
-
-3. **Tighten CSP in `public/index.php`.**
-   ```
-   default-src 'self';
-   script-src 'self' https://code.jquery.com;
-   style-src  'self' https://code.jquery.com;
-   img-src 'self' data:;
-   font-src 'self' data: https://code.jquery.com;
-   connect-src 'self';
-   frame-ancestors 'none';
-   base-uri 'self';
-   form-action 'self' https://login.microsoftonline.com;
-   ```
-   No more `'unsafe-inline'`, no more `cdn.tailwindcss.com`.
-
-4. **Verification checklist (manual).**
-   - Pages render correctly after the rebuild (`docker compose build
-     --no-cache && docker compose up`).
-   - Browser DevTools → Console: zero CSP violations on every page
-     (home, sprint, settings, workers, audit, local sign-in form).
-   - `curl -I http://localhost:8080/` shows the tightened CSP.
-   - Sprint-row click on the home page still navigates.
-
-**Commit message stub.**
-> `Phase 11: vendor Tailwind + drop inline onclick + tighten CSP`
-> - Multi-stage Docker build produces public/assets/css/app.css.
-> - home.php uses data-href instead of inline onclick; app.js handles nav.
-> - CSP drops 'unsafe-inline' and the Tailwind CDN.
-
-**Estimated size.** Infrastructure changes (Dockerfile, package.json,
-tailwind.config.js, input.css, app.js, layout.php, index.php header
-builder). ~120 LOC net + the dep manifest. Manual verification
-dominates the cost.
-
-**Rollback plan.** If Tailwind build breaks production, reverting this
-commit is a single `git revert` and the app returns to the Play-CDN
-state. No schema or data touched.
+## 14. Maintenance contract
+
+The previous §14 was the plan for Phases 8–11. All four shipped, so the
+plan moved into §9. This section now codifies the rule that produced
+this file in the first place — **don't lose it on a context reset.**
+
+After every commit that completes a phase or substantive change:
+
+1. **Commit the code first.** A commit message that captures *what*
+   changed, *why*, and *how it was verified* is the canonical record.
+2. **Update §9.** If the work matched a planned phase, move it from
+   Upcoming → Shipped with the SHA. If it didn't match a planned phase
+   (hotfix, infra fix, etc.), insert it inline with the SHA.
+3. **Update §13.** Append the new SHA to the git history block.
+4. **If new files / directories were added or moved, refresh §3.**
+5. **If the test count changed, update §11's expected count.**
+6. **Commit the HANDOFF.md update as its own commit.** Keeping it
+   separate means a `git revert` of the code revert leaves the doc
+   honest by reverting alongside.
+7. If the change affects the resume prompt in §12 (e.g. a new "next
+   phase" or a deferred-work item gets resolved), update that too.
+
+Why: a fresh Claude session starts by reading this file. Stale status
+here is the single biggest source of duplicated or wrong work. Keeping
+the file truthful costs ~2 minutes per phase; recovering from a stale
+file costs more.
+
+If you skip these steps, the next session will eventually notice and
+have to rebuild the picture from `git log` and the codebase. That's
+recoverable, but a regression from why this file exists.