From 72f740af5526f8e1d03aebf646aae5d91ab32d45 Mon Sep 17 00:00:00 2001 From: Tom Kaczocha Date: Fri, 31 Jul 2026 21:05:03 +1000 Subject: [PATCH 01/17] feat(ORI-15): product_project resolve chain + seed project.name Issue: ORI-15 UR: UR-002 Output: agents/config.md --- agents/config.md | 37 +++++++++++++++++++++++++++++++------ 1 file changed, 31 insertions(+), 6 deletions(-) diff --git a/agents/config.md b/agents/config.md index d410fee..207fe2a 100644 --- a/agents/config.md +++ b/agents/config.md @@ -114,7 +114,10 @@ tracker: team_key: "" # optional alternate resolve (e.g. team key string) default_assignee_id: "" # human operator; set on issue create when configured # Shared Linear Project that holds all UR milestones + Issues (not one Project per UR). - product_project: "do-work" # name or UUID; default "do-work" (or project.name when set) + # Empty default — NOT the skill name. Resolve when backend=linear (Load Config step 8): + # explicit product_project (name|UUID) wins; if empty → project.name; if that empty → + # git-root directory basename; ensure_product_container create-if-missing; persist UUID. + product_project: "" # Human-facing Project Milestone name for each UR. ur_milestone_name_pattern: "{ur_id}: {title}" # Deprecated aliases (still accepted if new keys missing): @@ -177,11 +180,18 @@ routing: [] - If a **top-level section is entirely missing** from the file (e.g. `next_steps:` does not appear), append the full section block — including all keys, default values, and inline comments — to the end of the file. - If a **top-level section exists but is missing individual keys** (e.g. `log:` exists but `batch_size` is absent), append the missing keys with their default values to that section. This applies to nested-map keys too — e.g. if `log:` exists but `log.max_chars` is absent, append it with its default map (`{x: 280, linkedin: 1300}`) and inline comment. - - **Never overwrite existing values.** If a key exists in the file, keep the user's value regardless of what the default says. For nested maps, treat presence of the parent key as "existing" — if `log.max_chars:` is present, do not overwrite any of its entries or add missing platform entries, even if the default template has more. + - **Never overwrite existing values.** If a key exists in the file, keep the user's value regardless of what the default says. For nested maps, treat presence of the parent key as "existing" — if `log.max_chars:` is present, do not overwrite any of its entries or add missing platform entries, even if the default template has more. In particular, never replace a non-empty `tracker.linear.product_project` (name or UUID) with the template empty default or with the skill name `do-work`. - If **no keys are missing**, do not write to the file. Skip this step silently. - If keys were added, report: `Config updated: added [list of added keys/sections]` -5. Keep the final merged values (file values + defaults for anything still missing) in context for subsequent steps. +4b. **Seed `project.name` from directory basename when empty (install / first load).** After create (step 3) or migrate (step 4): + + - Let `name` = current `project.name` (treat missing, null, empty, or whitespace-only as empty). + - If `name` is **non-empty** → leave it alone; do **not** overwrite. + - If `name` is **empty** → set `project.name` to the **git-root directory basename** (the basename of the detected project root from startup) and **write** that value to `{project}/.do-work/config.yml`. + - This runs on create and on every load where `project.name` is still blank (e.g. operator cleared it, or an older template left it empty). It never replaces a deliberate non-empty name. + +5. Keep the final merged values (file values + defaults for anything still missing, plus any seed from step 4b) in context for subsequent steps. 6. **Resolve tracker backend (markdown-default).** After the merged config is in context, set the effective work-item backend: @@ -208,9 +218,22 @@ routing: [] When the effective backend is **`linear`**: load `agents/tracker/port.md` then `agents/tracker/linear.md` for work-item ops after the validations above pass. If `agents/tracker/linear.md` is **missing or unreadable**, **hard-stop** with setup instructions (restore the backend doc from the skill install / Linear skill setup) — **never** fall through to `markdown.md` or invent Linear tool sequences. +8. **Resolve and bind `tracker.linear.product_project` when effective backend is `linear`.** Run after step 7 validations pass and the Linear backend docs are loaded — **before** any work-item CRUD that needs the product Project. When effective backend is **`markdown`**, skip this step entirely (`product_project` is inert). + + **Resolve order (name/lookup key only — does not rewrite an already-set value):** + + 1. Let `pp` = current `tracker.linear.product_project` (missing, null, or whitespace-only → treat as empty). + 2. If `pp` is **non-empty** (name **or** UUID) → **lookup key = `pp`**. Explicit config wins. Do **not** replace it with `project.name`, the git-root basename, or the skill name `do-work`. Existing product UUIDs (and explicit names) are left untouched by this fallback chain. + 3. If `pp` is **empty** → lookup key = `project.name` when that is non-empty; else the **git-root directory basename** (same basename as step 4b). Never fall back to a hard-coded skill name. + 4. Call port op **`ensure_product_container`** with that lookup key: resolve the Linear Project by name or UUID; **create-if-missing** when the key is a name and no Project matches. + 5. On success, **always persist** the resolved Project **UUID** back to `tracker.linear.product_project` in `{project}/.do-work/config.yml` and in the in-memory config. If the file already stores that same UUID, skip the write (idempotent). + 6. On failure (unresolved after create attempt, MCP missing, permission error) → **hard-stop** with operator instructions; never invent a product Project and never silent-fallback to markdown. + + **Rewrite rules:** The empty → `project.name` → basename chain runs **only** when `product_project` is truly empty. It must never overwrite an explicit existing value. The only write after a non-empty start is ensure's **UUID bind** (e.g. name → UUID once resolved). After a true empty state, ensure binds and step 5 persists the UUID so subsequent loads take the explicit-UUID path. + **Phase-agent contract:** every phase agent that touches work items follows the **Tracker load path** (config → resolve `tracker.backend` → `port.md` → `agents/tracker/.md` → only named port ops). The shared load path is defined once here and in `agents/tracker/port.md`; each phase agent restates a short copy so a missing wire cannot cause split-brain storage. -**Never fail or stop because of a missing or incomplete config file** (steps 1–5). If config creation or migration fails for any reason, proceed with in-memory defaults (including `tracker.backend: markdown`). **Exception:** step 7 Linear validation (and missing `linear.md`) is a deliberate hard-stop when the operator has opted into `backend: linear` — that is not a config-file completeness problem. +**Never fail or stop because of a missing or incomplete config file** (steps 1–5, including 4b seed). If config creation or migration fails for any reason, proceed with in-memory defaults (including `tracker.backend: markdown`). **Exception:** step 7 Linear validation, missing `linear.md`, and step 8 product_project ensure/bind are deliberate hard-stops when the operator has opted into `backend: linear` — those are not config-file completeness problems. --- @@ -218,7 +241,7 @@ routing: [] | Key | Type | Default | Description | |-----|------|---------|-------------| -| `project.name` | string | `""` | Project display name | +| `project.name` | string | `""` (seeded from git-root directory basename when empty on create/first load — Load Config step 4b; never overwrites a non-empty name) | Project display name. Also the preferred empty-`product_project` fallback when `backend: linear` (Load Config step 8). | | `layers` | list of strings | `[]` | Project's declared layers for gap-aware capture. Capture and verify check that REQs cover each declared layer. Empty = opt out (feature briefs will halt until declared or `--no-layers` is passed). | | `log.enabled` | boolean | `true` | Whether the log step runs after Go | | `log.platforms` | list | `[]` | Platforms to generate draft posts for (e.g. `[x, linkedin]`) | @@ -254,7 +277,9 @@ routing: [] | `tracker.linear.team_id` | string | `""` | Linear team UUID. **Required when `backend: linear`** unless `team_key` alone resolves the team. Empty + unresolvable team_key → hard-fail (do not guess). Consumers: `agents/tracker/linear.md`, Load Config step 7. | | `tracker.linear.team_key` | string | `""` | Optional alternate team resolve (Linear team key string). Used when `team_id` is empty. Consumers: `agents/tracker/linear.md`, Load Config step 7. | | `tracker.linear.default_assignee_id` | string | `""` | Human operator Linear user id set as issue **assignee** on create when non-empty. Agents claim via workflow status + claim comments — they do not steal assignee. Consumers: `agents/tracker/linear.md` create/claim ops. | -| `tracker.linear.project_name_pattern` | string | `"do-work/{ur_id}"` | Pattern for per-UR Linear Project name. `{ur_id}` is the sequential UR slug (e.g. `UR-007`). Consumers: `agents/tracker/linear.md` intake/list. | +| `tracker.linear.product_project` | string | `""` | Shared Linear Project (**name or UUID**) that holds all UR Project Milestones + Issues — **not** one Project per UR. **Default is empty**, not the skill name `do-work`. When `backend: linear`, Load Config step 8 resolve order: (1) explicit non-empty `product_project` (name\|UUID) wins and is never replaced by the empty-fallback chain; (2) if empty/missing → `project.name`; (3) if that empty → git-root directory basename; (4) `ensure_product_container` create-if-missing; (5) **always persist** the resolved Project **UUID** back to this key. Explicit existing values (including a bound UUID) are left alone by the fallback chain; only ensure's UUID bind may update the field after a true empty (or name→UUID bind). Consumers: `agents/tracker/linear.md`, `ensure_product_container`, intake/`create_ur`. | +| `tracker.linear.ur_milestone_name_pattern` | string | `"{ur_id}: {title}"` | Human-facing Project Milestone name pattern for each UR. `{ur_id}` is the sequential UR slug; `{title}` is the brief title. Consumers: `agents/tracker/linear.md` create_ur / list_urs. | +| `tracker.linear.project_name_pattern` | string | `"do-work/{ur_id}"` | **Deprecated** pattern for per-UR Linear Project name (ignored for UR home; URs are Project Milestones on `product_project`). Kept for migrate compatibility. Consumers: legacy notes only. | | `tracker.linear.initiative_title_pattern` | string | `"{ur_id}: {title}"` | Pattern for Initiative title. `{title}` is the human-facing brief title. Consumers: `agents/tracker/linear.md` intake. | | `tracker.linear.status_map.backlog` | string | `"Todo"` | Team workflow state name for unclaimed/backlog REQs. **Hard-fail** if this state is missing on the team when `backend: linear` — rename the team state or override this key. Consumers: claim/list/status ops in `agents/tracker/linear.md`. | | `tracker.linear.status_map.in_progress` | string | `"In Progress"` | Team workflow state for claimed/in-progress REQs. Same missing-state hard-fail as other status_map keys. Consumers: claim/heartbeat/resume. | From cc7919711938f4af2293db19ca2ab47e5993908d Mon Sep 17 00:00:00 2001 From: Tom Kaczocha Date: Fri, 31 Jul 2026 21:11:45 +1000 Subject: [PATCH 02/17] feat(ORI-16): ensure_product_container per-product create+persist Issue: ORI-16 UR: UR-002 Output: agents/tracker/linear.md --- agents/tracker/linear.md | 22 ++++++++++++++----- agents/tracker/port.md | 6 ++--- references/linear-ops.md | 47 +++++++++++++++++++++++++++++++--------- 3 files changed, 56 insertions(+), 19 deletions(-) diff --git a/agents/tracker/linear.md b/agents/tracker/linear.md index c68cb1e..9261ce0 100644 --- a/agents/tracker/linear.md +++ b/agents/tracker/linear.md @@ -26,7 +26,7 @@ Do **not** load this file for ordinary work-item ops when backend is `markdown` ``` Team (config) -└── Project product_project (default "do-work") — shared for all URs +└── Project product_project — one shared product Project per local product (not per UR) ├── Project Milestone (UR) — §9.1 └── Issue (REQ) — attached to that UR milestone └── Sub-issue (layer child) @@ -34,7 +34,7 @@ Team (config) | Entity | Naming / config | |--------|-----------------| -| Product Project | `tracker.linear.product_project` (default `do-work`) — **shared** | +| Product Project | `tracker.linear.product_project` — **shared** name or UUID; **default empty**. Resolve via config chain (explicit `product_project` → `project.name` → git-root basename); `ensure_product_container` create-if-missing + **always persist UUID**. Never fall through to skill name `do-work` for empty config. Example for this skill repo only: name `do-work`. | | UR | **Project Milestone** on that project; name `ur_milestone_name_pattern` (default `{ur_id}: {title}`) | | REQ | **Linear issue id only** (e.g. `ENG-123`) — no parallel `REQ-NNN` | | Issue scope | product Project + UR Project Milestone membership | @@ -42,7 +42,7 @@ Team (config) ### Hard rules (hierarchy) 1. **No Initiative-as-UR** — MCP has no reliable Initiative create path; URs are Project Milestones. -2. **`product_project` is shared** — do not create `do-work/{UR-id}` Projects per UR as the UR container. +2. **`product_project` is shared per local product** — do not create per-UR Projects (including `do-work/{UR-id}` patterns) as the UR container. 3. **Atomic `create_ur`** — product Project ensure + milestone create; no partial UR; hard-stop on failure. 4. **Rediscover, never invent** — every op begins with `search_tool`; hard-stop if tools missing. 5. **No dual-write** — Linear is sole work-item store while `backend: linear`. @@ -142,7 +142,7 @@ HARD STOP: Linear tracker backend is configured but Linear MCP is not usable. do-work will not fall back to markdown work-item storage while tracker.backend is "linear". No issues, Initiative-as-UR entities, or local REQ/UR substitutes were invented. -What failed: +What failed: Fix — connect Linear MCP (from Linear skill setup): @@ -164,9 +164,17 @@ Fix — connect Linear MCP (from Linear skill setup): - grok mcp enable linear - grok mcp doctor linear -4. Team config (when MCP works but team fails): +4. Team + product Project config (when MCP works but team/project fails): - Set tracker.linear.team_id (UUID) and/or tracker.linear.team_key in .do-work/config.yml - - Set tracker.linear.product_project (default name `do-work`) when the shared project is not yet resolved + - product_project resolve (Load Config step 8 / ensure_product_container): explicit + tracker.linear.product_project (name|UUID) if set; else project.name; else git-root + directory basename. Default product_project is empty — never invent skill name `do-work` + for empty config. ensure_product_container create-if-missing (name path) and always + persists the Project UUID back to tracker.linear.product_project. + - Empty-name failure: product_project, project.name, and basename all empty/unusable → + set project.name or product_project explicitly; do not invent a name; do not markdown-fallback + - Multi-match failure: more than one team Project shares the target name → set + tracker.linear.product_project to the desired Project UUID (names are ambiguous) - Do not guess a team 5. status_map (when team loads but a workflow state name is missing): @@ -186,6 +194,8 @@ use /do-work resume or unblock after MCP recovers (port: leave claimed). | `search_tool` returns no Linear tools | Hard stop + setup steps above | | MCP offline / unauthenticated mid-session | Hard stop; if already claimed → leave claimed | | Team id/key unresolved | Hard stop; do not guess | +| `product_project` empty-name after resolve chain | Hard stop; set `project.name` or `product_project`; **no** skill-name invent; **no** markdown fallback | +| `product_project` multi-match by name on team | Hard stop; require UUID in `tracker.linear.product_project` | | `product_project` unresolved / uncreatable | Hard stop | | Any `status_map` value missing on team workflow | Hard stop + rename / override instructions | | Milestone / issue create tools missing for `create_ur` / `create_req` | Hard stop; **no** Initiative-as-UR substitute; **no** markdown dual-write | diff --git a/agents/tracker/port.md b/agents/tracker/port.md index c2bf8c4..6b20667 100644 --- a/agents/tracker/port.md +++ b/agents/tracker/port.md @@ -196,7 +196,7 @@ Names freeze intent. Exact field shapes and store sequences live in each backend | Op | Intent | |----|--------| -| `ensure_product_container` | Team/product labeling ready; no single product Project required | +| `ensure_product_container` | Product/team container ready (markdown: dirs; Linear: shared product Project create/bind + persist UUID) | | `create_ur` | Record intake brief | | `read_ur` | Load brief (+ ideate if present) | | `list_urs` | Enumerate URs for prompts/status | @@ -232,9 +232,9 @@ Each op lists **intent**, **preconditions**, and **notes**. Inputs/outputs are c | | | |---|---| -| **Intent** | Ensure the product/team container for work items is ready (markdown: `.do-work/` dirs; Linear: team resolvable / labels ready — **no** single long-lived product Project required). | +| **Intent** | Ensure the product/team container for work items is ready. **Markdown:** local `.do-work/` dirs. **Linear:** team resolvable; **create or bind** the shared product Project when missing (`product_project` resolve chain + list/create + **persist UUID**); optional labels ready. | | **Preconditions** | Config loaded; backend resolved. For Linear: team resolvable or hard-stop. | -| **Notes** | Idempotent. Does not create a UR or REQ. | +| **Notes** | Idempotent. Does not create a UR or REQ. Linear never falls through to skill name `do-work` for empty `product_project`. Multi-match by name and empty-name failures hard-stop (no markdown substitute store). | #### `create_ur` diff --git a/references/linear-ops.md b/references/linear-ops.md index 138c7f2..11df705 100644 --- a/references/linear-ops.md +++ b/references/linear-ops.md @@ -17,12 +17,14 @@ One hop from [`agents/tracker/linear.md`](../agents/tracker/linear.md). Load whe ``` Team (config) -└── Project product_project (default "do-work") — shared for all URs +└── Project product_project — one shared product Project per local product (not per UR) ├── Project Milestone (UR) — §9.1 ; brief, ideate, verify, close └── Issue (REQ) — on product Project, attached to UR milestone └── Sub-issue (layer child) ``` +**Product Project naming:** `tracker.linear.product_project` defaults to **empty** (not skill name `do-work`). Resolve order is documented under `ensure_product_container` / `agents/config.md` Load Config step 8. Example for the do-work skill repo itself may still use name `do-work`. + **No Initiative-as-UR.** Path-milestone mode (M1/M2) is a *cursor + Issue markers* on the UR milestone — see [linear-path-milestones.md](linear-path-milestones.md). --- @@ -75,8 +77,9 @@ On **read/update**: if the marker is missing, treat as template parse failure | `**UR-id:**` | Sequential `UR-NNN` slug only (not a Linear entity id) | Resolve UR; `list_urs` | | `**Class:**` | Intake classification (feature / …) | Capture, status | | `**Created:**` | ISO date `YYYY-MM-DD` at create | Display | -| `**Product-project:**` | Shared product Project name (`product_project`, default `do-work`) | Resolve product Project | +| `**Product-project:**` | Shared product Project **display name** after ensure (from bound Project; not a hard-coded skill default) | Display; prefer id for resolve | | `**Product-project-id:**` / `**Milestone-id:**` | Linear UUIDs after ensure + milestone create | Prefer ids over names | + | `## Brief` | **Verbatim** intake — never overwrite on ideate/question | `read_ur` | | `## Clarifications` | `append_clarifications` appends Q&A; does not create REQs | Question, capture | | `## Ideate` | `append_ideate` writes/appends ideate body | Ideate, capture | @@ -204,9 +207,10 @@ When label tools are discoverable (create/list/attach), agents **must** keep lab |--------|---------| | UR slug | Sequential `UR-NNN` (UR Project Milestone metadata only) | | REQ | **Linear issue identifier only** (e.g. `ENG-123`) — never allocate `REQ-NNN` under Linear backend | -| Product Project | `tracker.linear.product_project` (default `do-work`) — shared for all URs | +| Product Project | `tracker.linear.product_project` — shared for all URs on this local product; **name or UUID**; empty default; resolve chain + ensure persist UUID (never invent skill name `do-work`) | | UR milestone name | `tracker.linear.ur_milestone_name_pattern` (default `{ur_id}: {title}`) | + ### Preflight (before first CRUD op in a session) 1. Config effective backend is `linear` (else do not use this file). @@ -219,34 +223,57 @@ When label tools are discoverable (create/list/attach), agents **must** keep lab | | | |---|---| -| **Intent** | Team resolvable; ensure shared **product Project** (`product_project`, default `do-work`); optional labels ready. | -| **Sequence** | Preflight steps 2–4. Resolve or create product Project by name/id from `tracker.linear.product_project` (default `do-work`). Optionally pre-create labels when tools exist. | -| **Failure** | Hard-stop; never create markdown `.do-work/` as substitute product container. Never invent Initiatives as UR containers. | +| **Intent** | Team resolvable; ensure the shared **product Project** for this local product (not per UR); optional labels ready. Create/bind when missing; **always persist** Project UUID to `tracker.linear.product_project`. | +| **Preconditions** | Preflight steps 2–4 done (MCP tools, team resolved, `status_map` validated). Config loaded (`agents/config.md`). | +| **Failure** | Hard-stop on empty-name, multi-match, tool missing, create/get failure. **Never** create markdown `.do-work/` as substitute product container. **Never** invent Initiatives as UR containers. **Never** fall through to skill name `do-work` for empty `product_project`. | + +**Agent sequence (executable):** + +1. **Resolve target name/id (lookup key)** — same chain as `agents/config.md` Load Config step 8; do **not** invent a different order: + 1. Let `pp` = `tracker.linear.product_project` (missing / null / whitespace-only → empty). + 2. If `pp` is **non-empty** (name **or** UUID) → **lookup key = `pp`**. Explicit config wins; do not replace with `project.name`, basename, or skill name `do-work`. + 3. If `pp` is **empty** → lookup key = `project.name` when non-empty; else **git-root directory basename**. + 4. If the final lookup key is still empty/whitespace → **hard-stop** (**empty-name**): instruct operator to set `project.name` or `tracker.linear.product_project` in `{project}/.do-work/config.yml`. Do **not** invent a name. Do **not** markdown-fallback. +2. **Rediscover project tools** — `search_tool` for Linear project surfaces (`"linear project"`, `"linear list projects"`, `"linear save project"` / create-project). Map hits to list/get/create. If list/get (and, for name create path, create/`save_project`) tools are undiscoverable → **hard-stop** (setup block). Never hard-code tool names; use qualified names + `input_schema` from search. +3. **Resolve or create on the team** + - **UUID path** (lookup key is already a Project UUID / id form): `use_tool` get-project (or list filtered by id). Found → use that Project. Not found → **hard-stop** (do not invent; do not create a Project whose name is the UUID string). + - **Name path** (lookup key is a display name): + 1. `use_tool` list-projects scoped to the **resolved team** (team id/key from preflight). Prefer `query` / name filter when the schema supports it; otherwise list and filter client-side. + 2. Keep **exact name matches** (case-sensitive unless the live tool documents otherwise) on that team. + 3. **Match count:** + - **0** → **create** via rediscovered create/save-project surface (`save_project` when discovered): `name` = lookup key; attach team with `addTeams` **or** `setTeams` = resolved team (schema requires at least one team). Capture returned Project UUID. + - **1** → **use** that Project (id + name). + - **>1** → **hard-stop** (**multi-match**): list matching ids/names; require operator to set `tracker.linear.product_project` to the desired **UUID**. Do not pick “first”; do not invent; no markdown fallback. +4. **Persist UUID** — **always** write the resolved Project **UUID** to `tracker.linear.product_project` in `{project}/.do-work/config.yml` and in-memory config. If the file already stores that same UUID, skip the write (idempotent). Prefer UUID over name for all subsequent ops in the session. +5. **Optional labels** — when create/list label tools exist, pre-create common labels (`labels.layer_prefix`, `size_prefix`, `path_unit`) for the team. Label failure is non-fatal for container ensure (body headers remain source of truth); project ensure itself must already have succeeded. +6. **Return** product Project **id (UUID)** + **name**. Cache for the session. + +**Does not:** create a UR or REQ; create a per-UR Linear Project; create Initiatives; write local UR/REQ markdown as the store. ### `create_ur` | | | |---|---| -| **Intent** | Record intake brief as a **UR Project Milestone** on the shared **product Project**. Does **not** create REQs. **Not** Initiative-as-UR. | +| **Intent** | Record intake brief as a **UR Project Milestone** on the shared **product Project**. Does **not** create REQs. **Not** Initiative-as-UR. **Not** a new Linear Project per UR. | | **Preconditions** | Preflight passed; `ensure_product_container` done; next `UR-NNN` slug allocatable. | | **Atomicity** | Product Project resolvable + Project Milestone create must succeed as one logical unit. **No partial UR.** | **Agent sequence:** -1. **Ensure product Project** — call **`ensure_product_container`** (resolve/create `tracker.linear.product_project`, default `do-work`). +1. **Ensure product Project** — call **`ensure_product_container`** first (resolve chain + list/create/bind + persist UUID). Do **not** restate a hard-coded product name here; do **not** create a per-UR Project. 2. **Allocate next `UR-NNN` slug** - `search_tool` for project milestones list tools (`"linear milestones"`, `"linear project milestones"`). - List milestones on the product Project; scan names / descriptions for `UR-*` / `**UR-id:** UR-*` / ``. - Pick next free sequential `UR-NNN`. 3. **Build body** - Milestone name: apply `tracker.linear.ur_milestone_name_pattern` (default `{ur_id}: {title}`, e.g. `UR-007: Add SSO`). - - Description: §9.1 template with verbatim brief; `**Product-project:**` + product project id; leave `**Milestone-id:**` empty until create returns it. + - Description: §9.1 template with verbatim brief; `**Product-project:**` + product project name; `**Product-project-id:**` from ensure; leave `**Milestone-id:**` empty until create returns it. 4. **Create Project Milestone** on the product Project - `search_tool "linear milestone"` / create-milestone surface. - If **no** milestone create tool is discovered → **hard-stop** (do **not** invent Initiative-as-UR; do **not** create a per-UR Project as a fake UR). - `use_tool` create with discovered schema (project id + name + description as required). - Record milestone id; patch `**Milestone-id:**` if update tools allow. -5. **Return** UR slug, product project id/name, milestone id/name. **Do not** write `.do-work/user-requests/UR-NNN/`. **Do not** create Linear Initiatives. +5. **Return** UR slug, product project id/name, milestone id/name. **Do not** write `.do-work/user-requests/UR-NNN/`. **Do not** create Linear Initiatives. **Do not** create a Linear Project per UR. ### `read_ur` From d000478f1c2ec4de2e0d662364b311c180d8aab2 Mon Sep 17 00:00:00 2001 From: Tom Kaczocha Date: Fri, 31 Jul 2026 21:12:05 +1000 Subject: [PATCH 03/17] feat(ORI-17): install bootstrap seeds project.name from basename Issue: ORI-17 UR: UR-002 Output: references/commands.md --- references/commands.md | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/references/commands.md b/references/commands.md index 9aa630b..8246ffb 100644 --- a/references/commands.md +++ b/references/commands.md @@ -21,15 +21,18 @@ Create the do-work folder structure. Idempotent — safe to run multiple times. - `{project}/.do-work/archive/` - `{project}/.do-work/logs/` - `{project}/.do-work/state/` -3. Create `{project}/.do-work/config.yml` if it does not already exist, using the bootstrap template below. This template contains the keys most commonly customised at install time. The full default template — including all sections, defaults, and inline documentation — is the canonical template in `agents/config.md`. On first agent run, the config loader in `agents/config.md` migrates any missing sections and keys from that canonical template automatically, so new installs receive all defaults without needing the full template written to disk by install. +3. Create `{project}/.do-work/config.yml` if it does not already exist, using the bootstrap template below. **Seed `project.name`:** when writing the file, if `project.name` would otherwise be empty (missing, null, or whitespace-only), set it to the **basename of `{project}`** (git-root directory name, e.g. `/path/to/my-app` → `my-app`). Never leave a brand-new install with a blank `project.name` when a basename is available. Do **not** write `tracker.linear.product_project: "do-work"` (or any skill-name default) — omit `product_project` or leave it empty; it remains empty until the first Linear ensure binds a Project UUID. Full `tracker.linear` resolve chain (empty → `project.name` → basename → `ensure_product_container` → persist UUID): `agents/config.md` Load Config step 8. This template contains the keys most commonly customised at install time. The full default template — including all sections, defaults, and inline documentation — is the canonical template in `agents/config.md`. On first agent run, the config loader in `agents/config.md` migrates any missing sections and keys from that canonical template automatically (and Load Config step 4b re-seeds `project.name` from basename if still blank), so new installs receive all defaults without needing the full template written to disk by install. ```yaml # do-work configuration # Edit this file to customize agent behavior. # Full schema and defaults: agents/config.md (canonical template) +# product_project (tracker.linear): remains empty until first Linear ensure +# persists UUID — never seed as "do-work". Resolve chain: agents/config.md +# Load Config step 8 (empty → project.name → basename → ensure → UUID). project: - name: "" + name: "{project-basename}" # install seeds from git-root directory basename when empty # Declare your project's layers, e.g. [frontend, backend] for a web app, # [commands, core, output] for a CLI, [agents, commands, templates] for do-work. @@ -41,12 +44,14 @@ test: suite_command: "" # e.g. "./vendor/bin/pest", "npx vitest run", "npm test" # Work-item store. Unset/empty tracker.backend also means markdown (default). -# Full tracker.linear.* schema: agents/config.md (design §7). +# Full tracker.linear.* schema + product_project resolve: agents/config.md +# (Load Config step 8; design §7). Do not bootstrap product_project as "do-work". # tracker: # backend: markdown # markdown | linear # linear: # team_id: "" # team_key: "" +# product_project: "" # empty until ensure binds UUID — never "do-work" # status_map: # backlog: "Todo" # in_progress: "In Progress" From e9cc5d0d2b99f6bef67bccb0bb7a9dd663c60fea Mon Sep 17 00:00:00 2001 From: Tom Kaczocha Date: Fri, 31 Jul 2026 21:15:54 +1000 Subject: [PATCH 04/17] feat(ORI-18): docs per-product Linear Project (not default do-work) Issue: ORI-18 UR: UR-002 Output: SKILL.md --- SKILL.md | 2 +- docs/HOW-IT-WORKS.md | 4 +++- docs/getting-started.md | 2 ++ docs/troubleshooting.md | 2 +- references/tracker.md | 4 ++-- 5 files changed, 9 insertions(+), 5 deletions(-) diff --git a/SKILL.md b/SKILL.md index 06c906a..8fc7cd2 100644 --- a/SKILL.md +++ b/SKILL.md @@ -102,7 +102,7 @@ Full multi-backend deep dive: [references/tracker.md](references/tracker.md). **No dual-write.** With `tracker.backend: linear`, Linear is the **only** work-item store. Agents must not mirror URs/REQs into local markdown as a second source of truth, and must not fall back to markdown when Linear fails (hard-stop instead). After idle migration (`/do-work upgrade migrate`), historical `.do-work/user-requests/` and `archive/` trees remain on disk as **read-only history** — work-item ops ignore them. -**Linear hierarchy:** **UR = Project Milestone** on shared `product_project` (default `do-work`); REQs = Issues with that milestone. Not Initiatives (MCP has no Initiative create tools). +**Linear hierarchy:** **UR = Project Milestone** on a **shared product Project** per local product (`tracker.linear.product_project` — name or UUID; **default empty**). Resolve: explicit `product_project` → `project.name` → git-root basename; `ensure_product_container` create-if-missing + **always persist UUID**. Never invent skill name `do-work` for empty config (example name for this skill repo only). REQs = Issues with that milestone. Not Initiatives (MCP has no Initiative create tools). **Linear commit / branch** (when `backend: linear`): subject uses Linear issue id only (`feat(ENG-123): …`); footer `Issue:` / `UR:` / `Output:`; branch/worktree `req/` (dir hard-defaults lowercase). Markdown backend still uses `feat(REQ-NNN): …` with `REQ:` / `UR:` archive paths — see [references/concepts.md](references/concepts.md#commit-convention). diff --git a/docs/HOW-IT-WORKS.md b/docs/HOW-IT-WORKS.md index 1fc4456..e5e5c4e 100644 --- a/docs/HOW-IT-WORKS.md +++ b/docs/HOW-IT-WORKS.md @@ -49,13 +49,15 @@ Work items (URs, REQs, decisions, verify/close reports, run notes) go through a ``` Team (config team_id / team_key) -└── Product Project (tracker.linear.product_project, default "do-work") +└── Product Project (tracker.linear.product_project — one shared Project per local product) ├── Project Milestone (UR brief / ideate / verify / close) │ └── Issue (REQ / path-unit) ± sub-issues (layer children) └── Project Milestone (next UR) └── Issue … ``` +**`product_project` resolve (default empty):** explicit `tracker.linear.product_project` (name|UUID) if set; else `project.name`; else git-root directory basename. Then `ensure_product_container` create-if-missing and **always persists** the Project UUID back to config. Empty config never falls through to the skill name `do-work` — that name is only an example when this skill's own repo is the local product. + **Why milestones, not Initiatives:** official Linear MCP exposes Project Milestone create/list/get, but not Initiative create/list. do-work therefore homes each UR on a **Project Milestone**. REQs use **Linear issue ids** only (e.g. `ENG-123`). `UR-NNN` remains the UR-milestone slug. diff --git a/docs/getting-started.md b/docs/getting-started.md index 5d3697f..c927919 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -116,6 +116,7 @@ tracker: linear: team_id: "" # required UUID — or set team_key team_key: "" # optional alternate team resolve + # product_project: "" # empty by default — resolve → project.name → basename; ensure binds UUID # status_map / labels / claim marker: defaults in agents/config.md ``` @@ -124,6 +125,7 @@ tracker: 1. Connect **Linear MCP** in your agent host (API key preferred: `LINEAR_API_KEY` + MCP URL `https://mcp.linear.app/mcp`). Details: [Troubleshooting → Linear tracker backend](troubleshooting.md#linear-tracker-backend). 2. Set a real `team_id` or `team_key` — agents hard-stop if the team cannot be resolved (they never guess). 3. Confirm team workflow states match `tracker.linear.status_map` defaults (`Todo` / `In Progress` / `Canceled` / `Done`) or override the map. +4. Product Project is **per local product**, not a universal Project named `do-work`. Leave `product_project` empty (default) to resolve via `project.name` → git-root basename, or set a name/UUID explicitly. First ensure create-if-missing and **persists the UUID**. **Rules that matter day one:** diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index a849942..0aaf277 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -313,7 +313,7 @@ Use dry-run first when offered. After cutover: no dual-write; historical markdow **Cause:** Older skill text required Initiative + per-UR Project. Current hierarchy uses **Project Milestones** for URs (Linear MCP has milestone CRUD, not Initiative create). -**Fix:** Use skill version with Milestone-as-UR (`agents/tracker/linear.md` § Hierarchy). Ensure `tracker.linear.product_project` is set (default `do-work`) and milestone tools appear in `search_tool "linear milestone"`. +**Fix:** Use skill version with Milestone-as-UR (`agents/tracker/linear.md` § Hierarchy). Ensure the shared product Project resolves: set `tracker.linear.product_project` (name|UUID) **or** leave it empty so resolve uses `project.name` → git-root basename, then `ensure_product_container` create-if-missing + persists UUID. Do **not** expect a universal default Project named `do-work` (that name is only an example for this skill repo). Confirm milestone tools appear in `search_tool "linear milestone"`. --- diff --git a/references/tracker.md b/references/tracker.md index 4305802..3fab85d 100644 --- a/references/tracker.md +++ b/references/tracker.md @@ -20,8 +20,8 @@ Work items (URs, REQs, decisions, verify/close reports, run notes) are stored th |----------|------------------| | Team | `team_id` and/or `team_key` — **hard-fail** if neither resolves | | MCP | Linear MCP tools must be discoverable — **hard-fail** with skill setup instructions if not | -| Hierarchy | **UR = Project Milestone** on shared `product_project` (default `do-work`); REQs = Issues with that milestone. Not Initiatives (MCP has no Initiative create tools). | -| `product_project` | Shared Linear Project name/id for all URs (default `do-work`) | +| Hierarchy | **UR = Project Milestone** on shared product Project per local product; REQs = Issues with that milestone. Not Initiatives (MCP has no Initiative create tools). | +| `product_project` | Shared Linear Project (**name or UUID**) for all URs on this local product — **default empty** (not skill name `do-work`). Resolve: explicit `product_project` → `project.name` → git-root basename; `ensure_product_container` create-if-missing + **always persist UUID**. Example for this skill repo only: name `do-work`. | | `ur_milestone_name_pattern` | Default `{ur_id}: {title}` | | `status_map` | `backlog→Todo`, `in_progress→In Progress`, `stopped→Canceled`, `done→Done` — **hard-fail** if a mapped state is missing on the team (rename team state or override the map key) | | Labels | `Layer/`, `path-unit`, `Size/` prefixes | From 61c283ed96268c91e2659ffad554c236d2a88905 Mon Sep 17 00:00:00 2001 From: Tom Kaczocha Date: Fri, 31 Jul 2026 21:19:11 +1000 Subject: [PATCH 05/17] feat(ORI-14): path close fresh Linear product Project per local product Issue: ORI-14 UR: UR-002 Output: agents/tracker/linear.md --- agents/tracker/linear.md | 1 + 1 file changed, 1 insertion(+) diff --git a/agents/tracker/linear.md b/agents/tracker/linear.md index 9261ce0..d2da673 100644 --- a/agents/tracker/linear.md +++ b/agents/tracker/linear.md @@ -41,6 +41,7 @@ Team (config) ### Hard rules (hierarchy) + 1. **No Initiative-as-UR** — MCP has no reliable Initiative create path; URs are Project Milestones. 2. **`product_project` is shared per local product** — do not create per-UR Projects (including `do-work/{UR-id}` patterns) as the UR container. 3. **Atomic `create_ur`** — product Project ensure + milestone create; no partial UR; hard-stop on failure. From 900f955e32e826c9376737c470b5898d719998cb Mon Sep 17 00:00:00 2001 From: Tom Kaczocha Date: Fri, 7 Aug 2026 13:14:07 +1000 Subject: [PATCH 06/17] fix(ux): scannable status, accurate help next steps, copy-paste go Operator command surface friction: status dumped full archive and stale in-progress headers; bare /do-work suggested capture on drained projects; capture/go fallbacks used non-actionable next-step copy; help opened on a 25-row command wall without a primary loop. --- SKILL.md | 13 +++++++ agents/capture.md | 2 +- agents/go.md | 2 +- agents/help.md | 28 ++++++++++++--- agents/status.md | 2 ++ docs/commands.md | 2 +- docs/getting-started.md | 2 +- lib/synth-status.sh | 57 ++++++++++++++++++++++++------ lib/tests/synth-status.test.sh | 64 ++++++++++++++++++++++++++++++++++ 9 files changed, 154 insertions(+), 18 deletions(-) diff --git a/SKILL.md b/SKILL.md index 3b6528b..b5b70e6 100644 --- a/SKILL.md +++ b/SKILL.md @@ -18,6 +18,19 @@ Start → Go. (Or granular: Intake → Capture → Verify → Run.) Work-item storage is pluggable (`tracker.backend`: **markdown** default, or **linear**). Runtime/git (worktrees, merges, state locks, `config.yml`) always stay local. +## Primary loop + +Most days you only need these: + +| Command | What it does | +|---------|-------------| +| `/do-work start [brief]` | Record a brief and build the REQ backlog (ideate on by default; auto-installs). | +| `/do-work go [UR-NNN]` | Verify coverage, then audit + run when confidence ≥ threshold (default 90%). | +| `/do-work status [UR-NNN]` | Live situation room: in-flight, backlog, recent done, coverage. | +| `/do-work` | Help + suggested next steps for this project. | + +Flags for start/go (`--no-ideate`, `--force`, `--auto-fix`, …) are in the full table below. + ## Quick Reference | Command | What it does | diff --git a/agents/capture.md b/agents/capture.md index 28a60e1..e85385c 100644 --- a/agents/capture.md +++ b/agents/capture.md @@ -849,7 +849,7 @@ If `config.next_steps.enabled` is `true` **and** this agent is running standalon 2. **"Run Go"** — Skip to verify + run in one shot 3. **"Skip"** — End the interaction -If `config.next_steps.enabled` is `false`, missing, or this agent is running as a delegate inside start: output "Next step: run verify to check coverage, or run the loop to start executing." and stop. +If `config.next_steps.enabled` is `false`, missing, or this agent is running as a delegate inside start: output `Next step: /do-work go UR-NNN to verify coverage and run.` (substitute the real UR number) and stop. --- diff --git a/agents/go.md b/agents/go.md index e164fa8..ec42e0d 100644 --- a/agents/go.md +++ b/agents/go.md @@ -161,7 +161,7 @@ If `config.next_steps.enabled` is `true`: The go agent is a top-level orchestrator — it is never a delegate, so no suppression logic is needed. Sub-agents (verify, run, log) must suppress their own AskUserQuestion prompts when running inside go. -If `config.next_steps.enabled` is `false` or missing: skip the AskUserQuestion and stop. +If `config.next_steps.enabled` is `false` or missing: output `Next step: /do-work status` (or `/do-work start "…" for new work) and stop. --- diff --git a/agents/help.md b/agents/help.md index 65c4a46..b4d5cd2 100644 --- a/agents/help.md +++ b/agents/help.md @@ -88,14 +88,34 @@ path-unit backlog detected: top-level path REQs define reachable flows; child RE **If URs exist but backlog is empty:** +Do **not** treat every empty-backlog project the same. Distinguish URs that still need decomposition from a drained project whose REQs already live in `archive/` (or Linear done): + +1. Find the **most recent** `UR-NNN` under `user-requests/` (highest N). +2. Check whether **any** REQ for that UR exists in backlog, `working/`, or `archive/` (markdown: `**UR:** UR-NNN` on REQ files; Linear: `list_reqs_for_ur`). +3. Also scan older open URs for any with **zero** REQs anywhere — those still need capture. + +**A — Latest UR has no REQs yet (or any open UR has zero REQs):** + +``` +Suggested next steps: + /do-work capture UR-NNN — Decompose the request into tasks + /do-work go UR-NNN — Verify and run after capture + /do-work start "describe your feature or task" — Record a new brief instead +``` + +Prefer the **oldest** zero-REQ open UR for the capture line when more than one exists; otherwise use the latest UR. Replace `UR-NNN` with that real number. + +**B — Backlog empty and all open URs already have REQs (drained / archive-only):** + ``` Suggested next steps: - /do-work capture UR-NNN — Decompose the latest request into tasks - /do-work go UR-NNN — Verify and run for a specific request /do-work start "describe your feature or task" — Record a new brief + /do-work status — Review the situation room ``` -Replace `UR-NNN` with the most recent UR number. +Do **not** suggest `capture` for a UR that already has REQs in archive — that re-decomposes finished work and confuses operators. + +Then (when the 4-suggestion cap allows) still add retro / close from the heuristics below. **If `runs/` has entries but `.do-work/state/calibration.md` does not exist:** @@ -107,7 +127,7 @@ Suggest retro alongside other applicable suggestions (do not replace them — ad **If archived path-unit REQs exist for a UR but that UR has no `closure.md`:** -Suggest close alongside other applicable suggestions (add it when this condition is true and the 4-suggestion cap allows). Use the most recently completed UR: +Suggest close alongside other applicable suggestions (add it when this condition is true and the 4-suggestion cap allows). Use the most recently completed UR that still needs closure: ``` /do-work close UR-NNN — Walk path-unit entry points end-to-end and write the UR closure report diff --git a/agents/status.md b/agents/status.md index 305a788..4339626 100644 --- a/agents/status.md +++ b/agents/status.md @@ -54,6 +54,8 @@ bash {skill-root}/lib/synth-status.sh [UR-NNN] # passes the optional scope Print stdout verbatim to the user. +Unscoped output prioritizes live work: backlog + working list fully; archive is capped to recent completed rows with a note when more exist. Scope with `UR-NNN` to list every matching archived REQ. Archive rows always show Status `done` even if a file header is stale. + If `$SKILL_ROOT/lib/synth-status.sh` is missing, report `"$SKILL_ROOT/lib/synth-status.sh not found — cannot render status."` and stop. Then render a proof-backed status view. Glob REQ files in backlog, `working/`, and `archive/` (respecting `UR-NNN` scope when provided), and run: diff --git a/docs/commands.md b/docs/commands.md index 95ccb53..e2c83ad 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -163,7 +163,7 @@ Does **not** run the verify confidence gate (unlike `go`). ### `/do-work status [UR-NNN]` -Read-only situation room: REQs, claimers (`hostname.pid`), heartbeats, deadlock warnings, coverage rollup. Optional UR scope. +Read-only situation room: live REQs first (working + backlog), claimers (`hostname.pid`), heartbeats, deadlock warnings, coverage rollup. Unscoped archive is capped to recent completed rows; pass `UR-NNN` to list every matching archived REQ. Use whenever something looks stuck or you are running parallel workers. diff --git a/docs/getting-started.md b/docs/getting-started.md index c927919..0946a20 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -163,7 +163,7 @@ Flags: /do-work status UR-001 ``` -Read-only situation room: backlog vs working vs archive, claimers, heartbeats, deadlock warnings, coverage rollup. +Read-only situation room: backlog vs working first, recent completed (archive capped when unscoped), claimers, heartbeats, deadlock warnings, coverage rollup. ## How you know it worked diff --git a/lib/synth-status.sh b/lib/synth-status.sh index c11bd24..126b111 100755 --- a/lib/synth-status.sh +++ b/lib/synth-status.sh @@ -13,9 +13,9 @@ # Sources: # - `.do-work/REQ-*.md` (Status: backlog) # - `.do-work/working/REQ-*.md` (Status: in-progress / stopped) -# - `.do-work/archive/REQ-*.md` (Status: done; only shown when -# present so the snapshot reflects -# completed work in this run) +# - `.do-work/archive/REQ-*.md` (Status: always rendered as done; +# unscoped runs cap archive rows so +# the situation room stays scannable) # 3. Empty case: a single "no REQs" message in place of the table when there # are zero REQs across all three buckets. # 4. Footer: deadlock warnings from `lib/deadlock-check.sh` if executable; @@ -51,6 +51,9 @@ ARCHIVE_DIR="$DOWORK/archive" CONFIG="$DOWORK/config.yml" DEFAULT_THRESHOLD=300 FOOTPRINT_MAX=60 +# Unscoped archive rows shown in the table (totals still count all archived). +# Scoped runs (`synth-status.sh UR-NNN`) list every matching archive row. +ARCHIVE_CAP=15 # Resolve sibling lib scripts via $0's directory so the script works from any # cwd inside the project. @@ -238,7 +241,8 @@ md_cell() { # Bucket: backlog | working | archive # Status column is derived from the file's own `**Status:**` field so that # `stopped` is rendered correctly when a worker has marked a working slot -# stopped. +# stopped — except archive rows, which always display as `done` (location is +# authoritative; stale in-progress headers in archive used to confuse operators). render_row() { local path="$1" local bucket="$2" @@ -259,8 +263,12 @@ render_row() { return 0 fi - status="$F_STATUS" - [ -z "$status" ] && status="—" + if [ "$bucket" = "archive" ]; then + status="done" + else + status="$F_STATUS" + [ -z "$status" ] && status="—" + fi layer="$F_LAYER" [ -z "$layer" ] && layer="—" @@ -383,8 +391,13 @@ fi printf '**Totals:** backlog=%d, working=%d, archived=%d\n\n' \ "$BACKLOG_N" "$WORKING_N" "$ARCHIVE_N" +# Idle project with history (unscoped only): no live work. Lead with a scan cue. +if [ -z "$UR_FILTER" ] && [ "$BACKLOG_N" -eq 0 ] && [ "$WORKING_N" -eq 0 ] && [ "$ARCHIVE_N" -gt 0 ]; then + printf '_No live work (backlog empty, nothing in-flight). Showing recent completed REQs only._\n\n' +fi + if [ "$TOTAL_N" -eq 0 ]; then - printf '_no REQs found in backlog, working, or archive._\n' + printf '_no REQs found in backlog, working, or archive. Next: `/do-work start "your brief"`._\n' else # Table header. printf '| REQ | UR | Status | Layer | Claimer | Heartbeat-age | Deps-status | Footprint |\n' @@ -403,9 +416,33 @@ else done fi if [ "$ARCHIVE_N" -gt 0 ]; then - for f in "${ARCHIVE_FILES[@]}"; do - render_row "$f" "archive" - done + # Unscoped: cap archive rows (newest REQ ids last in glob → take tail). + # Scoped (UR-NNN): list every matching archive row — the filter already + # narrows the set and operators asked for that UR deliberately. + if [ -n "$UR_FILTER" ]; then + for f in "${ARCHIVE_FILES[@]}"; do + render_row "$f" "archive" + done + else + archive_shown=0 + archive_start=0 + if [ "$ARCHIVE_N" -gt "$ARCHIVE_CAP" ]; then + archive_start=$(( ARCHIVE_N - ARCHIVE_CAP )) + fi + i=0 + for f in "${ARCHIVE_FILES[@]}"; do + if [ "$i" -ge "$archive_start" ]; then + render_row "$f" "archive" + archive_shown=$(( archive_shown + 1 )) + fi + i=$(( i + 1 )) + done + if [ "$ARCHIVE_N" -gt "$ARCHIVE_CAP" ]; then + hidden=$(( ARCHIVE_N - archive_shown )) + printf '\n_… and %d more archived. Scope with `/do-work status UR-NNN`, or inspect `.do-work/archive/`._\n' \ + "$hidden" + fi + fi fi fi diff --git a/lib/tests/synth-status.test.sh b/lib/tests/synth-status.test.sh index 2a033e6..283444f 100755 --- a/lib/tests/synth-status.test.sh +++ b/lib/tests/synth-status.test.sh @@ -282,6 +282,70 @@ if [ "$ELAPSED" -gt 10 ]; then fi teardown_fixture +# ---------------------------------------------------------------------- +# Case 8: unscoped archive is capped; totals still count all archived. +# ---------------------------------------------------------------------- +CURRENT_CASE="archive-cap" +CASES=$((CASES + 1)) +setup_fixture +i=1 +while [ "$i" -le 20 ]; do + # Zero-pad so glob order matches numeric order (REQ-01 … REQ-20). + id="$(printf 'REQ-%03d' "$i")" + write_archive_req "$TMP/.do-work/archive/${id}-a.md" "$id" "UR-020" + i=$((i + 1)) +done +run_synth +assert_eq "0" "$RC" "$CURRENT_CASE rc=0" +assert_contains "archived=20" "$STDOUT" "$CURRENT_CASE totals count all 20" +assert_contains "No live work" "$STDOUT" "$CURRENT_CASE idle cue" +assert_contains "and 5 more archived" "$STDOUT" "$CURRENT_CASE cap note" +assert_contains "REQ-020" "$STDOUT" "$CURRENT_CASE newest archive row shown" +assert_not_contains "REQ-001" "$STDOUT" "$CURRENT_CASE oldest archive row hidden" +teardown_fixture + +# ---------------------------------------------------------------------- +# Case 9: archive bucket always shows status done (stale header ignored). +# ---------------------------------------------------------------------- +CURRENT_CASE="archive-status-done" +CASES=$((CASES + 1)) +setup_fixture +cat > "$TMP/.do-work/archive/REQ-900-stale-header.md" < Date: Fri, 7 Aug 2026 17:02:51 +1000 Subject: [PATCH 07/17] =?UTF-8?q?branch-ship-loop:=20READY=20=E2=80=94=20d?= =?UTF-8?q?ocument=20status/help=20UX=20contract=20in=20CHANGELOG?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Name unscoped archive cap, forced archive Status=done, and drained-help next-step changes with consumer impact for skill integrators. --- CHANGELOG.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index ebef729..9cf749f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,15 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## Unreleased +**Operator status / help UX (scannable situation room)** + +**Changed** +- `/do-work status` (unscoped): archive rows in the situation-room table are capped to the **15 newest** REQ ids (lexicographic / zero-padded `REQ-NNN` order). Totals still count every archived REQ. When more exist, output includes `_… and N more archived_` and points operators at `status UR-NNN` or `.do-work/archive/`. **Consumer impact:** scripts that scrape unscoped status for a full archive inventory will miss older rows — scope with `UR-NNN` or read `archive/` directly. +- Archive rows always render Status **`done`** (bucket location is authoritative; stale `**Status:**` headers in archive files are ignored in the table). **Consumer impact:** parsers that expected archive-row status to mirror the file header will now always see `done`. +- Idle unscoped projects (backlog=0, working=0, archive>0) print a short “no live work” scan cue above the table; empty projects suggest `/do-work start "…"`. +- `/do-work` help next steps: empty backlog no longer suggests `capture` for URs whose REQs already live in `archive/` (drained project); capture is only suggested for open URs with zero REQs anywhere. +- Agent/docs copy for status and primary-loop commands updated (`agents/status.md`, `agents/help.md`, `docs/commands.md`, `docs/getting-started.md`, `SKILL.md`). + **Hub-only skill install (UR-044 / REQ-278)** **Changed** From e1daf9a7679783a46c6c5d4576513458de3feda3 Mon Sep 17 00:00:00 2001 From: Tom Kaczocha Date: Sat, 8 Aug 2026 16:50:31 +1000 Subject: [PATCH 08/17] feat(ORI-1006): add ensure-integration-base helper Issue: ORI-1006 UR: UR-005 Output: lib/ensure-integration-base.sh --- lib/ensure-integration-base.sh | 125 +++++++++++ lib/tests/ensure-integration-base.test.sh | 258 ++++++++++++++++++++++ 2 files changed, 383 insertions(+) create mode 100755 lib/ensure-integration-base.sh create mode 100755 lib/tests/ensure-integration-base.test.sh diff --git a/lib/ensure-integration-base.sh b/lib/ensure-integration-base.sh new file mode 100755 index 0000000..d0b4c89 --- /dev/null +++ b/lib/ensure-integration-base.sh @@ -0,0 +1,125 @@ +#!/usr/bin/env bash +# ensure-integration-base.sh — leave protected default branches before workers run. +# +# Usage: ensure-integration-base.sh [UR-NNN] +# +# Operates on the git repository at CWD (same contract as other lib scripts). +# Ensures the orchestrator checkout is not on a protected default branch +# (main, master, and the remote HEAD short name when resolvable) before +# workers are provisioned. +# +# Behaviour: +# - Detached HEAD → hard-stop non-zero (no branch create). +# - Current branch not protected → print branch name, exit 0 (skip). +# - On protected default + dirty tree → hard-stop non-zero; no branch change, +# no stash. Message on stderr mentions dirty working tree. +# - On protected default + clean + UR-NNN arg → create-if-missing and +# checkout ur/UR-NNN from current HEAD; print final branch; exit 0. +# - On protected default + clean + no UR arg → create/checkout +# work/; print final branch; exit 0. +# +# Compatible with macOS bash 3.2. set -u. Pure bash + git. + +set -u + +# --- must be inside a git work tree ----------------------------------------- + +if ! git rev-parse --is-inside-work-tree >/dev/null 2>&1; then + echo "ensure-integration-base.sh: not a git repository (cwd: $(pwd))" >&2 + exit 1 +fi + +# --- current branch / detached ---------------------------------------------- + +CURRENT="$(git branch --show-current 2>/dev/null || true)" +if [ -z "$CURRENT" ]; then + echo "ensure-integration-base.sh: detached HEAD — checkout a branch before continuing" >&2 + exit 1 +fi + +# --- protected defaults: main, master, + remote HEAD short name ------------- + +# Space-separated list of protected short branch names. +PROTECTED="main master" + +remote_head="" +# Prefer symbolic-ref (exact); fall back to rev-parse --abbrev-ref. +if remote_sym="$(git symbolic-ref -q refs/remotes/origin/HEAD 2>/dev/null)"; then + # refs/remotes/origin/main → main + remote_head="${remote_sym#refs/remotes/origin/}" +elif remote_abr="$(git rev-parse --abbrev-ref origin/HEAD 2>/dev/null)"; then + # origin/main → main + remote_head="${remote_abr#origin/}" +fi + +# Only add a real short name; never invent when missing/empty/HEAD. +if [ -n "$remote_head" ] && [ "$remote_head" != "HEAD" ] && [ "$remote_head" != "origin" ]; then + case " $PROTECTED " in + *" $remote_head "*) : ;; + *) PROTECTED="$PROTECTED $remote_head" ;; + esac +fi + +is_protected() { + local b="$1" + case " $PROTECTED " in + *" $b "*) return 0 ;; + *) return 1 ;; + esac +} + +# --- skip when already off a protected default ------------------------------ + +if ! is_protected "$CURRENT"; then + printf '%s\n' "$CURRENT" + exit 0 +fi + +# --- on protected default: dirty tree is a hard-stop ------------------------ + +if [ -n "$(git status --porcelain 2>/dev/null)" ]; then + echo "ensure-integration-base.sh: working tree is dirty on protected branch '$CURRENT' — clean the tree (commit or discard) before creating an integration base; refusing to stash or switch" >&2 + exit 1 +fi + +# --- leave default: ur/UR-NNN or work/ ----------------------- + +TARGET="" +UR_ARG="${1:-}" + +if [ -n "$UR_ARG" ]; then + # Accept UR-NNN style (UR- + digits). Reject other shapes. + case "$UR_ARG" in + UR-[0-9]*) + # require full match: UR- then only digits + if printf '%s' "$UR_ARG" | grep -Eq '^UR-[0-9]+$'; then + TARGET="ur/$UR_ARG" + else + echo "ensure-integration-base.sh: invalid UR slug '$UR_ARG' (expected UR-NNN)" >&2 + exit 1 + fi + ;; + *) + echo "ensure-integration-base.sh: invalid UR slug '$UR_ARG' (expected UR-NNN)" >&2 + exit 1 + ;; + esac +else + ts="$(date -u +%Y%m%dT%H%M%SZ)" + TARGET="work/$ts" +fi + +if git show-ref --verify --quiet "refs/heads/$TARGET" 2>/dev/null; then + if ! git checkout -q "$TARGET"; then + echo "ensure-integration-base.sh: failed to checkout existing branch '$TARGET'" >&2 + exit 1 + fi +else + if ! git checkout -q -b "$TARGET"; then + echo "ensure-integration-base.sh: failed to create branch '$TARGET'" >&2 + exit 1 + fi +fi + +printf '%s\n' "$TARGET" +exit 0 diff --git a/lib/tests/ensure-integration-base.test.sh b/lib/tests/ensure-integration-base.test.sh new file mode 100755 index 0000000..a64108b --- /dev/null +++ b/lib/tests/ensure-integration-base.test.sh @@ -0,0 +1,258 @@ +#!/usr/bin/env bash +# Tests for lib/ensure-integration-base.sh +# Plain bash (no bats dependency). Exit non-zero on failure. +# Compatible with macOS bash 3.2. + +set -u + +SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" +LIB_DIR="$( cd "$SCRIPT_DIR/.." && pwd )" +HELPER="$LIB_DIR/ensure-integration-base.sh" + +FAILED=0 +CASES=0 +CURRENT_CASE="" + +fail() { + echo "FAIL [$CURRENT_CASE]: $*" >&2 + FAILED=$((FAILED + 1)) +} + +assert_eq() { + local expected="$1" + local actual="$2" + local label="$3" + if [ "$expected" != "$actual" ]; then + fail "$label: expected '$expected', got '$actual'" + fi +} + +assert_contains() { + local needle="$1" + local haystack="$2" + local label="$3" + case "$haystack" in + *"$needle"*) : ;; + *) fail "$label: expected substring '$needle' in '$haystack'" ;; + esac +} + +assert_match() { + local regex="$1" + local actual="$2" + local label="$3" + if ! printf '%s' "$actual" | grep -Eq "$regex"; then + fail "$label: expected match /$regex/, got '$actual'" + fi +} + +# Minimal git repo on the given default branch name (main or master). +# Optional: with_remote=1 creates origin and sets origin/HEAD → origin/. +# Bare remote lives as a sibling of the fixture so it does not dirty the tree. +setup_repo() { + local default_branch="$1" + local with_remote="${2:-0}" + ROOT="$(mktemp -d -t ensure-integration-base.XXXXXX)" + TMP="$ROOT/repo" + REMOTE="$ROOT/remote.git" + mkdir -p "$TMP" + ( + cd "$TMP" + git init -q -b "$default_branch" + git config user.email "test@example.com" + git config user.name "Test" + git config commit.gpgsign false + echo "init" > README + git add README + git commit -q -m "init" + if [ "$with_remote" = "1" ]; then + git init -q --bare "$REMOTE" + git remote add origin "$REMOTE" + git push -q -u origin "$default_branch" >/dev/null 2>&1 + # Point origin/HEAD at the default branch (like GitHub does). + git -C "$REMOTE" symbolic-ref HEAD "refs/heads/$default_branch" + git remote set-head origin -a >/dev/null 2>&1 || \ + git symbolic-ref refs/remotes/origin/HEAD "refs/remotes/origin/$default_branch" + fi + ) +} + +teardown_fixture() { + if [ -n "${ROOT:-}" ] && [ -d "$ROOT" ]; then + rm -rf "$ROOT" + elif [ -n "${TMP:-}" ] && [ -d "$TMP" ]; then + rm -rf "$TMP" + fi + ROOT="" + TMP="" + REMOTE="" +} + +# Run helper inside $TMP. Sets RUN_RC, RUN_STDOUT, RUN_STDERR. +# Capture files live outside the fixture so they do not dirty the tree. +run_helper() { + local cap + cap="$(mktemp -d -t eib-cap.XXXXXX)" + local err_file="$cap/stderr" + local out_file="$cap/stdout" + ( + cd "$TMP" + if [ "$#" -gt 0 ]; then + bash "$HELPER" "$@" + else + bash "$HELPER" + fi + ) > "$out_file" 2> "$err_file" + RUN_RC=$? + RUN_STDOUT="$(cat "$out_file" 2>/dev/null || true)" + # trim trailing newline for equality checks + RUN_STDOUT="${RUN_STDOUT%"${RUN_STDOUT##*[![:space:]]}"}" + RUN_STDERR="$(cat "$err_file" 2>/dev/null || true)" + rm -rf "$cap" +} + +current_branch() { + ( cd "$TMP" && git branch --show-current ) +} + +# ---------------------------------------------------------------------- +# Case 1: skip when already on a non-default (feature) branch +# ---------------------------------------------------------------------- +CURRENT_CASE="skip-on-feature-branch" +CASES=$((CASES + 1)) +setup_repo "main" 0 +( + cd "$TMP" + git checkout -q -b feat/something +) +run_helper +assert_eq "0" "$RUN_RC" "$CURRENT_CASE rc" +assert_eq "feat/something" "$RUN_STDOUT" "$CURRENT_CASE stdout" +assert_eq "feat/something" "$(current_branch)" "$CURRENT_CASE still on feature" +teardown_fixture + +# ---------------------------------------------------------------------- +# Case 2: create ur/UR-001 when on main, clean tree, UR arg given +# ---------------------------------------------------------------------- +CURRENT_CASE="create-ur-branch" +CASES=$((CASES + 1)) +setup_repo "main" 0 +run_helper "UR-001" +assert_eq "0" "$RUN_RC" "$CURRENT_CASE rc" +assert_eq "ur/UR-001" "$RUN_STDOUT" "$CURRENT_CASE stdout" +assert_eq "ur/UR-001" "$(current_branch)" "$CURRENT_CASE checked out" +# create-if-missing: second run should skip (already non-default) and print same +run_helper "UR-001" +assert_eq "0" "$RUN_RC" "$CURRENT_CASE re-run rc" +assert_eq "ur/UR-001" "$RUN_STDOUT" "$CURRENT_CASE re-run stdout" +teardown_fixture + +# ---------------------------------------------------------------------- +# Case 3: create work/* when on main, clean, no UR arg +# ---------------------------------------------------------------------- +CURRENT_CASE="create-work-branch" +CASES=$((CASES + 1)) +setup_repo "main" 0 +run_helper +assert_eq "0" "$RUN_RC" "$CURRENT_CASE rc" +assert_match '^work/[0-9]{8}T[0-9]{6}Z$' "$RUN_STDOUT" "$CURRENT_CASE stdout pattern" +assert_eq "$RUN_STDOUT" "$(current_branch)" "$CURRENT_CASE checked out work/*" +teardown_fixture + +# ---------------------------------------------------------------------- +# Case 4: dirty hard-stop stays on main, no stash, no branch change +# ---------------------------------------------------------------------- +CURRENT_CASE="dirty-hard-stop" +CASES=$((CASES + 1)) +setup_repo "main" 0 +( + cd "$TMP" + echo "dirty" > dirty.txt + # untracked dirt is enough for porcelain non-empty +) +run_helper "UR-002" +assert_eq "0" "$( [ "$RUN_RC" -ne 0 ] && echo 0 || echo 1 )" "$CURRENT_CASE non-zero exit (got $RUN_RC)" +# clearer: +if [ "$RUN_RC" -eq 0 ]; then + fail "$CURRENT_CASE: expected non-zero exit, got 0" +fi +assert_contains "dirty" "$RUN_STDERR" "$CURRENT_CASE stderr mentions dirty" +assert_eq "main" "$(current_branch)" "$CURRENT_CASE stayed on main" +# no stash created +stash_count="$(cd "$TMP" && git stash list | wc -l | tr -d ' ')" +assert_eq "0" "$stash_count" "$CURRENT_CASE no stash" +# ur branch must not exist +if ( cd "$TMP" && git show-ref --verify --quiet refs/heads/ur/UR-002 ); then + fail "$CURRENT_CASE: ur/UR-002 should not have been created" +fi +teardown_fixture + +# ---------------------------------------------------------------------- +# Case 5: master is protected the same as main +# ---------------------------------------------------------------------- +CURRENT_CASE="master-protected" +CASES=$((CASES + 1)) +setup_repo "master" 0 +run_helper "UR-003" +assert_eq "0" "$RUN_RC" "$CURRENT_CASE rc" +assert_eq "ur/UR-003" "$RUN_STDOUT" "$CURRENT_CASE stdout" +assert_eq "ur/UR-003" "$(current_branch)" "$CURRENT_CASE left master" +teardown_fixture + +# ---------------------------------------------------------------------- +# Case 6: remote HEAD missing still protects main/master +# ---------------------------------------------------------------------- +CURRENT_CASE="remote-head-missing-protects-main" +CASES=$((CASES + 1)) +setup_repo "main" 0 +# Confirm no origin remote / no origin/HEAD +if ( cd "$TMP" && git rev-parse --abbrev-ref origin/HEAD >/dev/null 2>&1 ); then + fail "$CURRENT_CASE: fixture unexpectedly has origin/HEAD" +fi +run_helper +assert_eq "0" "$RUN_RC" "$CURRENT_CASE rc" +assert_match '^work/' "$RUN_STDOUT" "$CURRENT_CASE left main via work/*" +assert_match '^work/' "$(current_branch)" "$CURRENT_CASE not on main" +teardown_fixture + +# ---------------------------------------------------------------------- +# Case 7 (extra AC): remote HEAD short name is also protected when present +# ---------------------------------------------------------------------- +CURRENT_CASE="remote-head-protected" +CASES=$((CASES + 1)) +setup_repo "develop" 1 +# On develop with origin/HEAD → develop; should leave default +run_helper "UR-004" +assert_eq "0" "$RUN_RC" "$CURRENT_CASE rc" +assert_eq "ur/UR-004" "$RUN_STDOUT" "$CURRENT_CASE stdout" +assert_eq "ur/UR-004" "$(current_branch)" "$CURRENT_CASE left develop" +teardown_fixture + +# ---------------------------------------------------------------------- +# Case 8: detached HEAD hard-stop (no branch create) +# ---------------------------------------------------------------------- +CURRENT_CASE="detached-hard-stop" +CASES=$((CASES + 1)) +setup_repo "main" 0 +( + cd "$TMP" + git checkout -q --detach HEAD +) +run_helper +if [ "$RUN_RC" -eq 0 ]; then + fail "$CURRENT_CASE: expected non-zero exit on detached HEAD, got 0" +fi +assert_contains "detached" "$RUN_STDERR" "$CURRENT_CASE stderr mentions detached" +# still detached +show_cur="$(cd "$TMP" && git branch --show-current)" +assert_eq "" "$show_cur" "$CURRENT_CASE still detached (empty show-current)" +teardown_fixture + +# ---------------------------------------------------------------------- +echo "-----------------------------------------" +if [ "$FAILED" -ne 0 ]; then + echo "FAILED: $FAILED assertion(s) across $CASES case(s)" >&2 + exit 1 +fi +echo "OK: $CASES case(s) passed" +exit 0 From df127634f27a34fedbab573aafa28feeacf97796 Mon Sep 17 00:00:00 2001 From: Tom Kaczocha Date: Sat, 8 Aug 2026 16:55:16 +1000 Subject: [PATCH 09/17] feat(ORI-1007): wire ensure-integration-base into go/run preflight Issue: ORI-1007 UR: UR-005 Output: agents/go.md --- agents/go.md | 14 ++++++++++++++ agents/run-worker.md | 3 ++- agents/run.md | 11 ++++++++--- references/run-loop.md | 20 ++++++++++++++++++++ 4 files changed, 44 insertions(+), 4 deletions(-) diff --git a/agents/go.md b/agents/go.md index ec42e0d..0236490 100644 --- a/agents/go.md +++ b/agents/go.md @@ -45,6 +45,20 @@ Before delegating to any sub-agent, confirm the UR directory exists: - Check if `{project}/.do-work/user-requests/UR-NNN/input.md` exists - If it does not exist, report: "UR-NNN not found at {project}/.do-work/user-requests/UR-NNN/. Check the UR number and try again." and stop. +### 0c. Ensure integration base + +After Load Config resolves `{skill-root}` / `$SKILL_ROOT` (step 8) and **before** any verify, run, or worker dispatch, leave protected default branches: + +```bash +# Go is always UR-scoped — pass the UR slug from When Invoked. +bash {skill-root}/lib/ensure-integration-base.sh UR-NNN +``` + +- **Non-zero exit:** hard-stop the go phase. Surface the script's stderr to the user. Do **not** dispatch verify, audit, run, or workers. +- **Success (exit 0):** the script prints the integration-base branch name on stdout. Record it as the run's integration base (workers later inherit it via the orchestrator checkout's current branch at worktree create — see [run-worker.md](run-worker.md) W1). + +`/do-work start` must **not** call this helper — only go and run enforce the guard. + ### 1. Run Verify Read and follow [verify.md](verify.md) in full. diff --git a/agents/run-worker.md b/agents/run-worker.md index ecd7dab..f86b716 100644 --- a/agents/run-worker.md +++ b/agents/run-worker.md @@ -53,9 +53,10 @@ Execute these steps in order before proceeding to the normal `## Steps`. **This ```bash git rev-parse --abbrev-ref HEAD +# equivalent: git branch --show-current ``` -Record the output as `` (typically `main`). All subsequent merge and teardown steps reference this value. +Record the output as ``. This is the orchestrator checkout's **current branch after** `ensure-integration-base` has already run in go/run pre-flight — not necessarily `main`/`master`. Workers **do not** call `ensure-integration-base` themselves; they inherit the post-ensure base by reading `HEAD` at worktree create. All subsequent merge and teardown steps (orchestrator Step 4, both `delivery.mode: merge` and `pr`) reference this same value. ### W2. Create the worktree + feature branch diff --git a/agents/run.md b/agents/run.md index 02ddcba..74df3e8 100644 --- a/agents/run.md +++ b/agents/run.md @@ -102,9 +102,14 @@ Full stamp lifecycle: [run-loop.md](../references/run-loop.md) § Agent Identity 1. Branch + working-directory checks; `mkdir -p {project}/.do-work/state`. 2. Resolve `AGENT_ID`; resolve `{skill-root}` / `$SKILL_ROOT` via **Load Config step 8** (`agents/config.md` — single home; hard-stop if unknown); refresh context pack. -3. Scan/classify working slots (markdown) or in-flight Linear claims — mine / sibling / stale buckets. -4. Resume any `mine` slot. -5. Try backlog (primary); else evaluate working set; else empty-backlog path. +3. **Ensure integration base** — after skill-root resolve, **before** claim / worker dispatch / worktree provision: + ```bash + bash {skill-root}/lib/ensure-integration-base.sh [UR-NNN] + ``` + Pass the UR slug when this run is scoped (`/do-work run UR-NNN`); omit the arg when unscoped. Non-zero exit → hard-stop the run (surface stderr); do **not** claim or dispatch workers. Success → record printed branch as integration base (same base for `delivery.mode` merge and pr). Full sequence: [run-loop.md](../references/run-loop.md) § Pre-flight Check. +4. Scan/classify working slots (markdown) or in-flight Linear claims — mine / sibling / stale buckets. +5. Resume any `mine` slot. +6. Try backlog (primary); else evaluate working set; else empty-backlog path. Full pre-flight sequences: [run-loop.md](../references/run-loop.md) § Pre-flight Check. diff --git a/references/run-loop.md b/references/run-loop.md index 2cf5856..7ffbcaf 100644 --- a/references/run-loop.md +++ b/references/run-loop.md @@ -87,6 +87,24 @@ AGENT_ID="$(hostname).$$" `{skill-root}` is the absolute skill install root (directory containing `agents/`, `lib/`, `SKILL.md`). Lib invocations throughout this file (`{skill-root}/lib/scan-stale.sh`, etc.) and in `agents/run-worker.md` (heartbeat, file-feedback) only resolve when `{skill-root}` is a real absolute path. A worker `cd`'d into a consumer project's worktree has no `lib/` of its own, so the orchestrator substitutes `$SKILL_ROOT` into every `{skill-root}/lib/...` call it makes and passes that same absolute value as the worker **Skill root** input (Step 2 dispatch). +### 2a.1 Ensure integration base (before claim / dispatch / worktree provision) + +After skill-root is resolved and **before** any claim, worker dispatch, or worktree provision, ensure the orchestrator checkout is not on a protected default branch: + +```bash +# When this run is scoped to a UR (`/do-work run UR-NNN` or go-delegated run for UR-NNN): +bash {skill-root}/lib/ensure-integration-base.sh UR-NNN +# When unscoped (`/do-work run` with no UR): +# bash {skill-root}/lib/ensure-integration-base.sh +``` + +| Outcome | Action | +|---------|--------| +| **Exit non-zero** | **Hard-stop** the run phase. Surface the script's stderr (dirty tree, detached HEAD, invalid UR slug, checkout failure, etc.). Do **not** claim REQs, dispatch workers, or provision worktrees. | +| **Exit 0** | Script prints the final branch name on stdout. Record it as the run's **integration base**. Subsequent worker W1 reads this branch via `git rev-parse --abbrev-ref HEAD` (or `git branch --show-current`) on the orchestrator checkout at worktree create — workers do not call ensure themselves. | + +`/do-work start` must **not** invoke this helper (start is git-read-only for branch switching). Only go and run pre-flight enforce the guard. + ### 2b. Generate or refresh the project context pack Workers run context-starved by design (their "When Invoked" rule). To raise implementation quality without making each worker re-explore the repo, the orchestrator maintains **one** project context pack at `{project}/.do-work/state/context-pack.md` and passes its path to every worker. One orchestrator-level scan amortises across every worker in every run. @@ -734,6 +752,8 @@ Reached only when `status: done` and both acceptance evidence validation and pos The guards in 4b and 4-pr.4 (path-unit closure and non-empty closure proof) and the closure-proof model are identical in both delivery modes — only the delivery vehicle differs. Whichever path runs, proceed to Step 7 when it completes. +**Integration base (both modes):** `` is the post-`ensure-integration-base` orchestrator checkout (Pre-flight §2a.1 / worker W1). Merge mode merges the feature branch into that base; PR mode uses the same value for `--base` / integration-branch ancestry. Do **not** invent a second naming scheme for merge vs pr. + #### 4a. Merge the feature branch From the orchestrator's checkout (the main working tree, NOT the worktree). Branch name is backend-specific: From 2aab396380e3bdb079ab5467de6dbaf9d28cbaf1 Mon Sep 17 00:00:00 2001 From: Tom Kaczocha Date: Sat, 8 Aug 2026 16:59:57 +1000 Subject: [PATCH 10/17] feat(ORI-1008): document non-default integration base Issue: ORI-1008 UR: UR-005 Output: docs/HOW-IT-WORKS.md --- README.md | 6 ++++-- agents/config.md | 8 ++++---- docs/HOW-IT-WORKS.md | 24 +++++++++++++++++++++--- docs/troubleshooting.md | 16 ++++++++++++++++ 4 files changed, 45 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index d52cda4..c37fc92 100644 --- a/README.md +++ b/README.md @@ -134,7 +134,9 @@ Flags: `start` = intake + ideate (with gate) + capture. `go` = verify + audit + run. -**Delivery mode.** How a passing REQ is delivered at the integration step is configurable via `delivery.mode` in `.do-work/config.yml`. The default `merge` mode merges each REQ's `req/REQ-NNN` branch into the base branch locally, archives, tears down the worktree, and deletes the branch — the original behaviour. Set `delivery.mode: pr` for team repos with CI and human review: instead of merging, the orchestrator pushes the branch and opens a GitHub PR via `gh`, records the PR URL in the archived REQ's `## Outputs` and the run ledger, and leaves the branch alive (the PR owns it). `delivery.pr.granularity: req` (default) opens one PR per REQ; `ur` accumulates every REQ of a UR onto a shared `ur/UR-NNN` branch and opens a single PR when that UR's backlog drains. PR mode requires a configured git remote and the `gh` CLI — if either is missing the run stops with a `missing-creds` stopper and the REQ stays in `working/`; it never silently falls back to merging. The closure-proof model is unchanged in both modes — evidence still gates archive; the PR is only the delivery vehicle. +**Integration base.** `/do-work go` and `/do-work run` never merge worker branches into `main`, `master`, or the remote HEAD short name. Pre-flight calls `lib/ensure-integration-base.sh`: if the orchestrator is already off a protected default, that branch is the base; if on a protected default with a **clean** tree, scoped runs (`go` / `run UR-NNN`) create-or-checkout `ur/UR-NNN`, and unscoped `run` creates `work/`. A **dirty** tree on a protected default is a hard-stop (no stash, no switch). `/do-work start` does **not** call this helper and never switches branches. The `ur/UR-NNN` name is the same branch used when `delivery.pr.granularity: ur` accumulates a UR PR — merge mode reuses that name without requiring `delivery.mode: pr`. + +**Delivery mode.** How a passing REQ is delivered at the integration step is configurable via `delivery.mode` in `.do-work/config.yml`. The default `merge` mode merges each REQ's `req/REQ-NNN` branch into the **integration base** (not `main`/`master` — see above) locally, archives, tears down the worktree, and deletes the branch. Set `delivery.mode: pr` for team repos with CI and human review: instead of merging, the orchestrator pushes the branch and opens a GitHub PR via `gh`, records the PR URL in the archived REQ's `## Outputs` and the run ledger, and leaves the branch alive (the PR owns it). `delivery.pr.granularity: req` (default) opens one PR per REQ; `ur` accumulates every REQ of a UR onto a shared `ur/UR-NNN` branch and opens a single PR when that UR's backlog drains. PR mode requires a configured git remote and the `gh` CLI — if either is missing the run stops with a `missing-creds` stopper and the REQ stays in `working/`; it never silently falls back to merging. The closure-proof model is unchanged in both modes — evidence still gates archive; the PR is only the delivery vehicle. Normal completion is proof-backed. Capture may mark generated criteria as `agent-drafted`, but that provenance does not block execution. If a REQ exists in the backlog, it should run unless dependencies, footprint, policy, tests, verification, review, or genuinely ambiguous criteria stop it. Workers return checkpointed evidence and per-criterion acceptance evidence; the orchestrator validates that evidence, runs policy checks for blocked paths or commands, invokes post-build review, writes `**Closure proof:**`, derives `proven` / `unproven`, and records `.do-work/runs/RUN-NNN.yml` when the ledger is enabled. A worker report is only an input to completion, not the archive/proof decision. @@ -282,7 +284,7 @@ Three guarantees keep the parallel terminals from stepping on each other: **When parallel mode shines.** Backlogs of 5+ independent REQs — the work-sharing payoff grows with the backlog size. For single-REQ work, tightly-coupled REQs, or milestone deploy gates (which stay single-agent by design), the simplicity of one terminal is often the better trade-off. -**Isolation per REQ.** Every REQ runs in a dedicated `git worktree` on a `req/REQ-NNN` branch. The orchestrator creates the worktree before dispatch and tears it down after merging — no REQ ever touches the base branch directly. Dependency directories (`vendor`, `node_modules`, `.venv`) are symlinked from the main checkout into each worktree automatically; use `worktree.link_paths` and `worktree.setup_command` in config for monorepo layouts the auto-detection misses. +**Isolation per REQ.** Every REQ runs in a dedicated `git worktree` on a `req/REQ-NNN` branch forked from the integration base (`ur/UR-NNN` or `work/`, never protected defaults — see **Integration base** above). The orchestrator creates the worktree before dispatch and tears it down after merging into that base — no REQ ever touches `main`/`master`/remote HEAD directly. Dependency directories (`vendor`, `node_modules`, `.venv`) are symlinked from the main checkout into each worktree automatically; use `worktree.link_paths` and `worktree.setup_command` in config for monorepo layouts the auto-detection misses. See `SKILL.md` `## Parallel Execution` for the full behavioural reference, including state files (`gate-owner.md`, `final-suite-running.md`). diff --git a/agents/config.md b/agents/config.md index f0acc99..5d7336c 100644 --- a/agents/config.md +++ b/agents/config.md @@ -91,9 +91,9 @@ ledger: enabled: true # write .do-work/runs/RUN-NNN.yml records delivery: - mode: merge # how run.md Step 4 delivers a passing REQ: "merge" (default — merge req/REQ-NNN into base locally, as today) or "pr" (push the branch and open a GitHub PR instead of merging). pr mode requires a configured git remote and the `gh` CLI; missing either stops with a missing-creds stopper — it NEVER silently falls back to merge. + mode: merge # how run.md Step 4 delivers a passing REQ: "merge" (default — merge req/REQ-NNN into the integration base from lib/ensure-integration-base.sh, never main/master/remote HEAD) or "pr" (push the branch and open a GitHub PR instead of merging). pr mode requires a configured git remote and the `gh` CLI; missing either stops with a missing-creds stopper — it NEVER silently falls back to merge. pr: - granularity: req # only consulted when mode: pr. "req" (default) opens one PR per REQ off req/REQ-NNN; "ur" accumulates each REQ branch onto a shared ur/UR-NNN branch and opens a single PR when that UR's backlog drains. + granularity: req # only consulted when mode: pr. "req" (default) opens one PR per REQ off req/REQ-NNN; "ur" accumulates each REQ branch onto a shared ur/UR-NNN branch and opens a single PR when that UR's backlog drains. The ur/UR-NNN name is the same branch ensure-integration-base creates for scoped go/run — merge mode may accumulate on ur/ without setting mode: pr. worktree: link_paths: [] # extra dependency dirs to symlink from the main checkout into each @@ -326,8 +326,8 @@ routing: [] | `model.escalation` | string | `opus` | Escalation model for high-risk or failed REQs. Consumers: `agents/run.md`. | | `cost.budget` | string | `""` | Optional user-defined model/cost budget. Empty means no configured budget. Consumers: `agents/run.md`, `lib/run-ledger.sh`. | | `ledger.enabled` | boolean | `true` | When true, write structured run records under `.do-work/runs/`. Consumers: `lib/run-ledger.sh`, `agents/run.md`. | -| `delivery.mode` | string | `merge` | How `agents/run.md` Step 4 delivers a passing REQ. `merge` (default) reproduces today's behaviour byte-for-byte: merge `req/REQ-NNN` into the base branch locally, archive, tear down the worktree, delete the branch. `pr` replaces the local merge with a GitHub PR: push the branch, open a PR via `gh pr create`, record the PR URL in the archived REQ's `## Outputs` and the ledger entry, archive, tear down the worktree — but leave the branch alive (the PR owns it). `pr` mode requires a configured git remote and the `gh` CLI; if either is missing the run stops with a `missing-creds` stopper and the REQ stays in `working/` — it **never** silently falls back to `merge`. Consumers: `agents/run.md`. | -| `delivery.pr.granularity` | string | `req` | Only consulted when `delivery.mode` is `pr`. `req` (default) opens one PR per REQ directly off `req/REQ-NNN`. `ur` accumulates each completed REQ branch onto a shared `ur/UR-NNN` integration branch and opens a single PR when that UR's backlog drains, so a whole UR ships as one reviewable PR. Consumers: `agents/run.md`. | +| `delivery.mode` | string | `merge` | How `agents/run.md` Step 4 delivers a passing REQ. `merge` (default): merge `req/REQ-NNN` into the **integration base** from go/run pre-flight (`lib/ensure-integration-base.sh` — never `main`/`master`/remote HEAD), archive, tear down the worktree, delete the branch. `pr` replaces the local merge with a GitHub PR: push the branch, open a PR via `gh pr create`, record the PR URL in the archived REQ's `## Outputs` and the ledger entry, archive, tear down the worktree — but leave the branch alive (the PR owns it). `pr` mode requires a configured git remote and the `gh` CLI; if either is missing the run stops with a `missing-creds` stopper and the REQ stays in `working/` — it **never** silently falls back to `merge`. Consumers: `agents/run.md`, `lib/ensure-integration-base.sh`. | +| `delivery.pr.granularity` | string | `req` | Only consulted when `delivery.mode` is `pr`. `req` (default) opens one PR per REQ directly off `req/REQ-NNN`. `ur` accumulates each completed REQ branch onto a shared `ur/UR-NNN` integration branch and opens a single PR when that UR's backlog drains, so a whole UR ships as one reviewable PR. **Naming note:** scoped go/run already create-or-checkout `ur/UR-NNN` via `ensure-integration-base` even when `delivery.mode` is `merge` — the `ur/` branch name does **not** require pr mode; `granularity: ur` only controls PR open/accumulate behaviour when mode is `pr`. Consumers: `agents/run.md`, `lib/ensure-integration-base.sh`. | | `verify.threshold` | integer | `90` | Minimum confidence score (0-100) that `agents/go.md` requires before auto-running without `--force`. Consumers: `agents/verify.md`, `agents/go.md`. | | `routing` | list of `{match, agent}` maps | `[]` | Ordered subagent-routing rules for the run orchestrator's REQ classification. Each entry is `{match: , agent: }`. The classifier scans rules top-to-bottom (first match wins) and dispatches the matching `agent`; if no rule matches — or the list is empty — it falls back to `general-purpose` silently. Ships empty so the stock skill is portable (no machine-specific agents). A commented example block in the template above reproduces the original specialist table for users who want to restore it. Consumers: `agents/run.md`, `agents/resume.md`. | | `worktree.link_paths` | list of strings | `[]` | Extra dependency directories to symlink from the main checkout into each worker worktree (e.g. `[server/vendor, web/node_modules]`). Additive to auto-detected dirs: `composer.json` → `vendor`, `package.json` → `node_modules`, `pyproject.toml` / `requirements.txt` → `.venv`. Use this for monorepo or subdir layouts where the auto-detection misses a directory. Consumers: `lib/provision-worktree.sh`. | diff --git a/docs/HOW-IT-WORKS.md b/docs/HOW-IT-WORKS.md index e5e5c4e..04bafe4 100644 --- a/docs/HOW-IT-WORKS.md +++ b/docs/HOW-IT-WORKS.md @@ -244,11 +244,29 @@ Triggered by `/do-work run` (or implicitly by `/do-work go` when verify passes). Executes the backlog autonomously, one REQ at a time, until empty or a stopper is hit. +#### Integration base (pre-flight) + +Before any claim, worker dispatch, or worktree provision, go and run call `lib/ensure-integration-base.sh` so workers never integrate into a protected default branch (`main`, `master`, or the remote HEAD short name when resolvable): + +| Orchestrator checkout | Behaviour | +|-----------------------|-----------| +| Already off a protected default | Keep that branch; it is the integration base | +| On protected default + **clean** tree + scoped (`go` / `run UR-NNN`) | Create-or-checkout `ur/UR-NNN` from current HEAD | +| On protected default + **clean** tree + unscoped (`run` with no UR) | Create `work/` (e.g. `work/20260808T143022Z`) | +| On protected default + **dirty** tree | **Hard-stop** — no stash, no switch; clean the tree first | +| Detached HEAD | **Hard-stop** | + +`/do-work start` does **not** call this helper and does not switch branches — only go/run enforce the guard. + +The `ur/UR-NNN` name is shared with `delivery.pr.granularity: ur` (one PR per UR). Merge mode reuses the same branch name for accumulation; using `ur/` does **not** require `delivery.mode: pr`. + +**Why leave the default:** Landing REQ merges on `main`/`master` while agents run makes it easy to ship half-finished work or fight over the shared default. An integration base keeps the default clean until the operator promotes deliberately. + #### The TDD loop (per REQ) The orchestrator claims the REQ (atomic `git mv` from backlog root into `working/`, plus a claim stamp written to the file via `lib/claim-req.sh`), then dispatches a **fresh worker subagent** for each REQ (see `agents/run-worker.md`). The worker: -1. Creates a git worktree at `{project}/.worktrees/req-NNN` on a `req/REQ-NNN` branch +1. Creates a git worktree at `{project}/.worktrees/req-NNN` on a `req/REQ-NNN` branch (from the post-ensure integration base) 2. Writes a failing test for the first acceptance criterion 3. Implements until the test passes 4. Repeats for remaining criteria @@ -256,7 +274,7 @@ The orchestrator claims the REQ (atomic `git mv` from backlog root into `working 6. Commits with `feat(REQ-NNN): short title` and a body pointing back to the REQ, UR, and primary output 7. Returns a structured YAML report to the orchestrator -The orchestrator then validates the report, merges the worktree branch, moves the REQ from `working/` to `archive/` with `Status: done`, tears down the worktree, and records the ledger entry. +The orchestrator then validates the report, merges the worktree branch into the **integration base** (not `main`/`master`), moves the REQ from `working/` to `archive/` with `Status: done`, tears down the worktree, and records the ledger entry. **Why a fresh subagent per REQ:** Context isolation. A worker that just finished implementing REQ-007 carries 30k tokens of context that are irrelevant — and often actively misleading — for REQ-008. Spawning a fresh subagent enforces a clean room per REQ. The orchestrator (the parent) only sees structured return reports, not the worker's internal monologue, so the parent's context stays small even across hundreds of REQs. @@ -266,7 +284,7 @@ The orchestrator then validates the report, merges the worktree branch, moves th #### Isolation mode -Every REQ runs in a dedicated git worktree at `{project}/.worktrees/req-NNN` on a `req/REQ-NNN` branch. The orchestrator creates the worktree before dispatch and tears it down after merging the worker's commit. Worktree-always is the canonical mode — same-branch execution is retired. +Every REQ runs in a dedicated git worktree at `{project}/.worktrees/req-NNN` on a `req/REQ-NNN` branch forked from the integration base (`ur/UR-NNN` or `work/`). The orchestrator creates the worktree before dispatch and tears it down after merging the worker's commit into that base. Worktree-always is the canonical mode — same-branch execution is retired. **Why worktree-always:** Uniform isolation removes a per-REQ judgment call and eliminates the class of conflicts that arises when two workers touch overlapping files on the same branch. The worktree overhead is negligible compared to the TDD loop. diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 0aaf277..c730388 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -209,6 +209,22 @@ Fix dependency declarations in backlog REQs if the graph is wrong. Capture-time **Fix:** Configure remote + authenticated `gh`, or set `delivery.mode: merge` in `.do-work/config.yml` if local merge is intended. +### Integration base: dirty tree on `main` / `master` (hard-stop) + +**Symptom:** `/do-work go` or `/do-work run` stops immediately with stderr from `ensure-integration-base.sh` about a dirty working tree on a protected branch; no workers start. + +**Cause:** Pre-flight will not create `ur/UR-NNN` or `work/` while the orchestrator is on a protected default (`main`, `master`, or remote HEAD) with uncommitted changes. It never stashes and never switches over dirt. + +**Fix:** Commit or discard local changes on that branch, then re-run go/run. If you already intend a non-default base, check out that branch yourself first (clean or dirty is fine once off the protected default — ensure only auto-switches when leaving the default). + +### Integration base: auto-switched off the default branch + +**Symptom:** After go/run, the main checkout is on `ur/UR-NNN` (scoped) or `work/` (unscoped), not `main`/`master`. + +**Cause:** Expected. `lib/ensure-integration-base.sh` leaves protected defaults so REQ merges never land on remote HEAD. Scoped runs reuse `ur/UR-NNN` (same name as `delivery.pr.granularity: ur`; pr mode is not required). `/do-work start` does not switch branches. + +**Fix:** None required for the run. When you want to promote, merge or open a PR from the integration base to the default branch yourself. To stay on a long-lived feature base, check it out before go/run so ensure is a no-op. + ### Review or evidence gate failed **Cause:** Worker finished coding but orchestrator rejected evidence, policy, or post-build review. From 49da2cb737877b0a9c1c71762234c595a18357a2 Mon Sep 17 00:00:00 2001 From: Tom Kaczocha Date: Sat, 8 Aug 2026 17:01:54 +1000 Subject: [PATCH 11/17] feat(ORI-1005): close path-unit integration base Issue: ORI-1005 UR: UR-005 Output: lib/ensure-integration-base.sh Path-unit verification: ensure tests + wire-up + start absent. Children ORI-1006/1007/1008 Done. From bb0303ae1626dba9e57aaf429dee5bc76a9cb8c1 Mon Sep 17 00:00:00 2001 From: Tom Kaczocha Date: Sat, 8 Aug 2026 17:16:50 +1000 Subject: [PATCH 12/17] =?UTF-8?q?fix(UR-005):=20bind=20ensure=20skip=20?= =?UTF-8?q?=E2=80=94=20agents=20must=20not=20invent=20branch=20switches?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When already off main/master/remote HEAD, ensure-integration-base prints the current branch and exits 0. Document that agents must not follow up with git checkout to ur/* or work/* "for consistency." --- agents/go.md | 18 ++++++++++++++---- agents/run.md | 2 +- lib/ensure-integration-base.sh | 2 ++ references/run-loop.md | 7 +++++++ 4 files changed, 24 insertions(+), 5 deletions(-) diff --git a/agents/go.md b/agents/go.md index 0236490..74f235f 100644 --- a/agents/go.md +++ b/agents/go.md @@ -47,15 +47,25 @@ Before delegating to any sub-agent, confirm the UR directory exists: ### 0c. Ensure integration base -After Load Config resolves `{skill-root}` / `$SKILL_ROOT` (step 8) and **before** any verify, run, or worker dispatch, leave protected default branches: +After Load Config resolves `{skill-root}` / `$SKILL_ROOT` (step 8) and **before** any verify, run, or worker dispatch, leave protected default branches **only**: ```bash -# Go is always UR-scoped — pass the UR slug from When Invoked. +# When go is scoped to a UR, pass the slug. Unscoped go (no UR-NNN): omit the arg. bash {skill-root}/lib/ensure-integration-base.sh UR-NNN +# unscoped: bash {skill-root}/lib/ensure-integration-base.sh ``` -- **Non-zero exit:** hard-stop the go phase. Surface the script's stderr to the user. Do **not** dispatch verify, audit, run, or workers. -- **Success (exit 0):** the script prints the integration-base branch name on stdout. Record it as the run's integration base (workers later inherit it via the orchestrator checkout's current branch at worktree create — see [run-worker.md](run-worker.md) W1). +| Outcome | Action | +|---------|--------| +| **Non-zero exit** | Hard-stop the go phase. Surface the script's stderr. Do **not** dispatch verify, audit, run, or workers. | +| **Exit 0** | Script prints the integration-base branch name on stdout. Record it. **That printed name is authoritative.** | + +**Hard rules (agents must not invent branch switches):** + +1. **Only the script may create/checkout `ur/*` or `work/*`.** Never run `git checkout`, `git switch`, or `git checkout -b` yourself to "set up" an integration base. +2. **Already on a non-default branch (feature, `ur/*`, `req/*`, etc.) → skip.** The script prints the current branch and does not switch. Stay on that branch for the entire go/run — do **not** move to `ur/UR-NNN` "for consistency" or because a sibling session used a different base. +3. **Switch only when the script already switched** — i.e. stdout is a new `ur/UR-NNN` or `work/*` you were not on before; after the script returns, `git branch --show-current` must already equal the printed name. If they differ, hard-stop (do not fix with another checkout). +4. Worker `req/*` worktrees are separate checkouts; they must not change the orchestrator main checkout's branch. `/do-work start` must **not** call this helper — only go and run enforce the guard. diff --git a/agents/run.md b/agents/run.md index 74df3e8..4c37f69 100644 --- a/agents/run.md +++ b/agents/run.md @@ -106,7 +106,7 @@ Full stamp lifecycle: [run-loop.md](../references/run-loop.md) § Agent Identity ```bash bash {skill-root}/lib/ensure-integration-base.sh [UR-NNN] ``` - Pass the UR slug when this run is scoped (`/do-work run UR-NNN`); omit the arg when unscoped. Non-zero exit → hard-stop the run (surface stderr); do **not** claim or dispatch workers. Success → record printed branch as integration base (same base for `delivery.mode` merge and pr). Full sequence: [run-loop.md](../references/run-loop.md) § Pre-flight Check. + Pass the UR slug when this run is scoped (`/do-work run UR-NNN`); omit the arg when unscoped. Non-zero exit → hard-stop the run (surface stderr); do **not** claim or dispatch workers. Success → record printed branch as integration base (same base for `delivery.mode` merge and pr). **If already on a non-default branch, the script skips (prints current branch) — do not invent a further `git checkout` to `ur/*` or `work/*`.** Only the script may switch the orchestrator checkout. Full sequence + hard rules: [run-loop.md](../references/run-loop.md) § Pre-flight Check §2a.1. 4. Scan/classify working slots (markdown) or in-flight Linear claims — mine / sibling / stale buckets. 5. Resume any `mine` slot. 6. Try backlog (primary); else evaluate working set; else empty-backlog path. diff --git a/lib/ensure-integration-base.sh b/lib/ensure-integration-base.sh index d0b4c89..ee66411 100755 --- a/lib/ensure-integration-base.sh +++ b/lib/ensure-integration-base.sh @@ -69,6 +69,8 @@ is_protected() { } # --- skip when already off a protected default ------------------------------ +# Binding skip: callers must NOT invent ur/* or work/* checkouts when we skip. +# Printing the current branch name is the only success signal for this path. if ! is_protected "$CURRENT"; then printf '%s\n' "$CURRENT" diff --git a/references/run-loop.md b/references/run-loop.md index 7ffbcaf..fb043f9 100644 --- a/references/run-loop.md +++ b/references/run-loop.md @@ -103,6 +103,13 @@ bash {skill-root}/lib/ensure-integration-base.sh UR-NNN | **Exit non-zero** | **Hard-stop** the run phase. Surface the script's stderr (dirty tree, detached HEAD, invalid UR slug, checkout failure, etc.). Do **not** claim REQs, dispatch workers, or provision worktrees. | | **Exit 0** | Script prints the final branch name on stdout. Record it as the run's **integration base**. Subsequent worker W1 reads this branch via `git rev-parse --abbrev-ref HEAD` (or `git branch --show-current`) on the orchestrator checkout at worktree create — workers do not call ensure themselves. | +**Hard rules (do not invent branch switches):** + +1. **Only `ensure-integration-base.sh` may create or checkout `ur/*` / `work/*` on the orchestrator checkout.** Agents must not run `git checkout` / `git switch` / `git checkout -b` to force an integration base. +2. **Skip is binding.** If the orchestrator was already on a non-protected branch (any feature branch, existing `ur/*`, etc.), the script prints that branch and **does not switch**. Stay there for the whole run — do **not** hop to `ur/UR-NNN` because the run is UR-scoped or because another agent used a different base. +3. After exit 0, `git branch --show-current` must equal the printed name. If not, hard-stop; do not "repair" with another checkout. +4. Worker worktrees on `req/*` are isolated; they never retarget the orchestrator's branch. + `/do-work start` must **not** invoke this helper (start is git-read-only for branch switching). Only go and run pre-flight enforce the guard. ### 2b. Generate or refresh the project context pack From 768716d195644b77d775229edebe9e39f5d3011b Mon Sep 17 00:00:00 2001 From: Tom Kaczocha Date: Sat, 8 Aug 2026 19:34:00 +1000 Subject: [PATCH 13/17] feat(ORI-1046): leave-default uses new-work with merge-from-main Fixed integration branch is always new-work (scoped/unscoped). Existing new-work is checked out and merged from the protected tip; dirty trees carry onto new-work instead of hard-stopping. --- lib/ensure-integration-base.sh | 42 +++++------ lib/tests/ensure-integration-base.test.sh | 90 +++++++++++++---------- 2 files changed, 71 insertions(+), 61 deletions(-) diff --git a/lib/ensure-integration-base.sh b/lib/ensure-integration-base.sh index ee66411..76eae15 100755 --- a/lib/ensure-integration-base.sh +++ b/lib/ensure-integration-base.sh @@ -11,12 +11,12 @@ # Behaviour: # - Detached HEAD → hard-stop non-zero (no branch create). # - Current branch not protected → print branch name, exit 0 (skip). -# - On protected default + dirty tree → hard-stop non-zero; no branch change, -# no stash. Message on stderr mentions dirty working tree. -# - On protected default + clean + UR-NNN arg → create-if-missing and -# checkout ur/UR-NNN from current HEAD; print final branch; exit 0. -# - On protected default + clean + no UR arg → create/checkout -# work/; print final branch; exit 0. +# - On protected default → leave for fixed branch `new-work`: +# * create from current tip if missing +# * if present: checkout and merge the protected tip just left into new-work +# * dirty tree is allowed; uncommitted changes carry onto new-work +# UR-NNN arg is accepted for CLI compatibility but does not change the name. +# - Print final branch name on stdout; exit 0. # # Compatible with macOS bash 3.2. set -u. Pure bash + git. @@ -69,7 +69,7 @@ is_protected() { } # --- skip when already off a protected default ------------------------------ -# Binding skip: callers must NOT invent ur/* or work/* checkouts when we skip. +# Binding skip: callers must NOT invent new-work checkouts when we skip. # Printing the current branch name is the only success signal for this path. if ! is_protected "$CURRENT"; then @@ -77,26 +77,18 @@ if ! is_protected "$CURRENT"; then exit 0 fi -# --- on protected default: dirty tree is a hard-stop ------------------------ +# --- leave default: always fixed integration branch new-work ---------------- +# Remember the protected tip we are leaving so we can merge it into new-work. -if [ -n "$(git status --porcelain 2>/dev/null)" ]; then - echo "ensure-integration-base.sh: working tree is dirty on protected branch '$CURRENT' — clean the tree (commit or discard) before creating an integration base; refusing to stash or switch" >&2 - exit 1 -fi +PROTECTED_TIP="$CURRENT" +TARGET="new-work" -# --- leave default: ur/UR-NNN or work/ ----------------------- - -TARGET="" +# Optional UR-NNN arg: accept for CLI compatibility; ignore for naming. UR_ARG="${1:-}" - if [ -n "$UR_ARG" ]; then - # Accept UR-NNN style (UR- + digits). Reject other shapes. case "$UR_ARG" in UR-[0-9]*) - # require full match: UR- then only digits - if printf '%s' "$UR_ARG" | grep -Eq '^UR-[0-9]+$'; then - TARGET="ur/$UR_ARG" - else + if ! printf '%s' "$UR_ARG" | grep -Eq '^UR-[0-9]+$'; then echo "ensure-integration-base.sh: invalid UR slug '$UR_ARG' (expected UR-NNN)" >&2 exit 1 fi @@ -106,9 +98,6 @@ if [ -n "$UR_ARG" ]; then exit 1 ;; esac -else - ts="$(date -u +%Y%m%dT%H%M%SZ)" - TARGET="work/$ts" fi if git show-ref --verify --quiet "refs/heads/$TARGET" 2>/dev/null; then @@ -116,6 +105,11 @@ if git show-ref --verify --quiet "refs/heads/$TARGET" 2>/dev/null; then echo "ensure-integration-base.sh: failed to checkout existing branch '$TARGET'" >&2 exit 1 fi + # Update from the protected tip we left (merge main/master/… into new-work). + if ! git merge -q --no-edit "$PROTECTED_TIP"; then + echo "ensure-integration-base.sh: failed to merge '$PROTECTED_TIP' into '$TARGET'" >&2 + exit 1 + fi else if ! git checkout -q -b "$TARGET"; then echo "ensure-integration-base.sh: failed to create branch '$TARGET'" >&2 diff --git a/lib/tests/ensure-integration-base.test.sh b/lib/tests/ensure-integration-base.test.sh index a64108b..4b43e7b 100755 --- a/lib/tests/ensure-integration-base.test.sh +++ b/lib/tests/ensure-integration-base.test.sh @@ -132,100 +132,90 @@ assert_eq "feat/something" "$(current_branch)" "$CURRENT_CASE still on feature" teardown_fixture # ---------------------------------------------------------------------- -# Case 2: create ur/UR-001 when on main, clean tree, UR arg given +# Case 2: create new-work when on main, clean tree, UR arg given (ignored for name) # ---------------------------------------------------------------------- -CURRENT_CASE="create-ur-branch" +CURRENT_CASE="create-new-work-scoped" CASES=$((CASES + 1)) setup_repo "main" 0 run_helper "UR-001" assert_eq "0" "$RUN_RC" "$CURRENT_CASE rc" -assert_eq "ur/UR-001" "$RUN_STDOUT" "$CURRENT_CASE stdout" -assert_eq "ur/UR-001" "$(current_branch)" "$CURRENT_CASE checked out" -# create-if-missing: second run should skip (already non-default) and print same +assert_eq "new-work" "$RUN_STDOUT" "$CURRENT_CASE stdout" +assert_eq "new-work" "$(current_branch)" "$CURRENT_CASE checked out" +# second run is already off protected → skip, print new-work run_helper "UR-001" assert_eq "0" "$RUN_RC" "$CURRENT_CASE re-run rc" -assert_eq "ur/UR-001" "$RUN_STDOUT" "$CURRENT_CASE re-run stdout" +assert_eq "new-work" "$RUN_STDOUT" "$CURRENT_CASE re-run stdout" teardown_fixture # ---------------------------------------------------------------------- -# Case 3: create work/* when on main, clean, no UR arg +# Case 3: create new-work when on main, clean, no UR arg # ---------------------------------------------------------------------- -CURRENT_CASE="create-work-branch" +CURRENT_CASE="create-new-work-unscoped" CASES=$((CASES + 1)) setup_repo "main" 0 run_helper assert_eq "0" "$RUN_RC" "$CURRENT_CASE rc" -assert_match '^work/[0-9]{8}T[0-9]{6}Z$' "$RUN_STDOUT" "$CURRENT_CASE stdout pattern" -assert_eq "$RUN_STDOUT" "$(current_branch)" "$CURRENT_CASE checked out work/*" +assert_eq "new-work" "$RUN_STDOUT" "$CURRENT_CASE stdout" +assert_eq "new-work" "$(current_branch)" "$CURRENT_CASE checked out" teardown_fixture # ---------------------------------------------------------------------- -# Case 4: dirty hard-stop stays on main, no stash, no branch change +# Case 4: dirty tree carries onto new-work (no hard-stop) # ---------------------------------------------------------------------- -CURRENT_CASE="dirty-hard-stop" +CURRENT_CASE="dirty-carry" CASES=$((CASES + 1)) setup_repo "main" 0 ( cd "$TMP" echo "dirty" > dirty.txt - # untracked dirt is enough for porcelain non-empty ) run_helper "UR-002" -assert_eq "0" "$( [ "$RUN_RC" -ne 0 ] && echo 0 || echo 1 )" "$CURRENT_CASE non-zero exit (got $RUN_RC)" -# clearer: -if [ "$RUN_RC" -eq 0 ]; then - fail "$CURRENT_CASE: expected non-zero exit, got 0" -fi -assert_contains "dirty" "$RUN_STDERR" "$CURRENT_CASE stderr mentions dirty" -assert_eq "main" "$(current_branch)" "$CURRENT_CASE stayed on main" -# no stash created +assert_eq "0" "$RUN_RC" "$CURRENT_CASE rc" +assert_eq "new-work" "$RUN_STDOUT" "$CURRENT_CASE stdout" +assert_eq "new-work" "$(current_branch)" "$CURRENT_CASE on new-work" +porcelain="$(cd "$TMP" && git status --porcelain)" +assert_contains "dirty.txt" "$porcelain" "$CURRENT_CASE dirt preserved" stash_count="$(cd "$TMP" && git stash list | wc -l | tr -d ' ')" assert_eq "0" "$stash_count" "$CURRENT_CASE no stash" -# ur branch must not exist -if ( cd "$TMP" && git show-ref --verify --quiet refs/heads/ur/UR-002 ); then - fail "$CURRENT_CASE: ur/UR-002 should not have been created" -fi teardown_fixture # ---------------------------------------------------------------------- -# Case 5: master is protected the same as main +# Case 5: master is protected the same as main → new-work # ---------------------------------------------------------------------- CURRENT_CASE="master-protected" CASES=$((CASES + 1)) setup_repo "master" 0 run_helper "UR-003" assert_eq "0" "$RUN_RC" "$CURRENT_CASE rc" -assert_eq "ur/UR-003" "$RUN_STDOUT" "$CURRENT_CASE stdout" -assert_eq "ur/UR-003" "$(current_branch)" "$CURRENT_CASE left master" +assert_eq "new-work" "$RUN_STDOUT" "$CURRENT_CASE stdout" +assert_eq "new-work" "$(current_branch)" "$CURRENT_CASE left master" teardown_fixture # ---------------------------------------------------------------------- -# Case 6: remote HEAD missing still protects main/master +# Case 6: remote HEAD missing still protects main → new-work # ---------------------------------------------------------------------- CURRENT_CASE="remote-head-missing-protects-main" CASES=$((CASES + 1)) setup_repo "main" 0 -# Confirm no origin remote / no origin/HEAD if ( cd "$TMP" && git rev-parse --abbrev-ref origin/HEAD >/dev/null 2>&1 ); then fail "$CURRENT_CASE: fixture unexpectedly has origin/HEAD" fi run_helper assert_eq "0" "$RUN_RC" "$CURRENT_CASE rc" -assert_match '^work/' "$RUN_STDOUT" "$CURRENT_CASE left main via work/*" -assert_match '^work/' "$(current_branch)" "$CURRENT_CASE not on main" +assert_eq "new-work" "$RUN_STDOUT" "$CURRENT_CASE left main via new-work" +assert_eq "new-work" "$(current_branch)" "$CURRENT_CASE not on main" teardown_fixture # ---------------------------------------------------------------------- -# Case 7 (extra AC): remote HEAD short name is also protected when present +# Case 7: remote HEAD short name is also protected when present # ---------------------------------------------------------------------- CURRENT_CASE="remote-head-protected" CASES=$((CASES + 1)) setup_repo "develop" 1 -# On develop with origin/HEAD → develop; should leave default run_helper "UR-004" assert_eq "0" "$RUN_RC" "$CURRENT_CASE rc" -assert_eq "ur/UR-004" "$RUN_STDOUT" "$CURRENT_CASE stdout" -assert_eq "ur/UR-004" "$(current_branch)" "$CURRENT_CASE left develop" +assert_eq "new-work" "$RUN_STDOUT" "$CURRENT_CASE stdout" +assert_eq "new-work" "$(current_branch)" "$CURRENT_CASE left develop" teardown_fixture # ---------------------------------------------------------------------- @@ -243,11 +233,37 @@ if [ "$RUN_RC" -eq 0 ]; then fail "$CURRENT_CASE: expected non-zero exit on detached HEAD, got 0" fi assert_contains "detached" "$RUN_STDERR" "$CURRENT_CASE stderr mentions detached" -# still detached show_cur="$(cd "$TMP" && git branch --show-current)" assert_eq "" "$show_cur" "$CURRENT_CASE still detached (empty show-current)" teardown_fixture +# ---------------------------------------------------------------------- +# Case 9: existing stale new-work is checked out and merged from main +# ---------------------------------------------------------------------- +CURRENT_CASE="merge-stale-new-work" +CASES=$((CASES + 1)) +setup_repo "main" 0 +( + cd "$TMP" + # Point new-work at first commit, then advance main + git branch new-work + echo "second" > second.txt + git add second.txt + git commit -q -m "second on main" +) +run_helper "UR-005" +assert_eq "0" "$RUN_RC" "$CURRENT_CASE rc" +assert_eq "new-work" "$RUN_STDOUT" "$CURRENT_CASE stdout" +assert_eq "new-work" "$(current_branch)" "$CURRENT_CASE on new-work" +# main tip must be reachable from new-work after merge +if ! ( cd "$TMP" && git merge-base --is-ancestor main HEAD ); then + fail "$CURRENT_CASE: main tip is not ancestor of new-work after merge" +fi +if ! ( cd "$TMP" && test -f second.txt ); then + fail "$CURRENT_CASE: second.txt from main missing on new-work" +fi +teardown_fixture + # ---------------------------------------------------------------------- echo "-----------------------------------------" if [ "$FAILED" -ne 0 ]; then From ffcb56e0cd9100041400237dfe0e03dcf8bbf453 Mon Sep 17 00:00:00 2001 From: Tom Kaczocha Date: Sat, 8 Aug 2026 19:35:22 +1000 Subject: [PATCH 14/17] feat(ORI-1047,ORI-1048): document leave-default as new-work Agent hard-rules, PR-ur accumulate path, and user-facing docs now match ensure-integration-base: fixed new-work, merge-from-main, dirty carry. --- README.md | 6 +++--- agents/config.md | 4 ++-- agents/go.md | 6 +++--- agents/run.md | 2 +- docs/HOW-IT-WORKS.md | 9 ++++----- docs/troubleshooting.md | 14 +++++++------- references/run-loop.md | 16 ++++++++-------- 7 files changed, 28 insertions(+), 29 deletions(-) diff --git a/README.md b/README.md index c37fc92..27f10f0 100644 --- a/README.md +++ b/README.md @@ -134,9 +134,9 @@ Flags: `start` = intake + ideate (with gate) + capture. `go` = verify + audit + run. -**Integration base.** `/do-work go` and `/do-work run` never merge worker branches into `main`, `master`, or the remote HEAD short name. Pre-flight calls `lib/ensure-integration-base.sh`: if the orchestrator is already off a protected default, that branch is the base; if on a protected default with a **clean** tree, scoped runs (`go` / `run UR-NNN`) create-or-checkout `ur/UR-NNN`, and unscoped `run` creates `work/`. A **dirty** tree on a protected default is a hard-stop (no stash, no switch). `/do-work start` does **not** call this helper and never switches branches. The `ur/UR-NNN` name is the same branch used when `delivery.pr.granularity: ur` accumulates a UR PR — merge mode reuses that name without requiring `delivery.mode: pr`. +**Integration base.** `/do-work go` and `/do-work run` never merge worker branches into `main`, `master`, or the remote HEAD short name. Pre-flight calls `lib/ensure-integration-base.sh`: if the orchestrator is already off a protected default, that branch is the base; if on a protected default, leave-default always uses the fixed branch `new-work` (scoped and unscoped; create-if-missing). When `new-work` already exists, the script checkouts it and **merges** the protected tip just left (so it is updated from main). Dirty trees on a protected default **carry** onto `new-work` (no dirt-only hard-stop). `/do-work start` does **not** call this helper and never switches branches. The same `new-work` name is used when `delivery.pr.granularity: ur` accumulates a UR PR — merge mode reuses that name without requiring `delivery.mode: pr`. -**Delivery mode.** How a passing REQ is delivered at the integration step is configurable via `delivery.mode` in `.do-work/config.yml`. The default `merge` mode merges each REQ's `req/REQ-NNN` branch into the **integration base** (not `main`/`master` — see above) locally, archives, tears down the worktree, and deletes the branch. Set `delivery.mode: pr` for team repos with CI and human review: instead of merging, the orchestrator pushes the branch and opens a GitHub PR via `gh`, records the PR URL in the archived REQ's `## Outputs` and the run ledger, and leaves the branch alive (the PR owns it). `delivery.pr.granularity: req` (default) opens one PR per REQ; `ur` accumulates every REQ of a UR onto a shared `ur/UR-NNN` branch and opens a single PR when that UR's backlog drains. PR mode requires a configured git remote and the `gh` CLI — if either is missing the run stops with a `missing-creds` stopper and the REQ stays in `working/`; it never silently falls back to merging. The closure-proof model is unchanged in both modes — evidence still gates archive; the PR is only the delivery vehicle. +**Delivery mode.** How a passing REQ is delivered at the integration step is configurable via `delivery.mode` in `.do-work/config.yml`. The default `merge` mode merges each REQ's `req/REQ-NNN` branch into the **integration base** (not `main`/`master` — see above) locally, archives, tears down the worktree, and deletes the branch. Set `delivery.mode: pr` for team repos with CI and human review: instead of merging, the orchestrator pushes the branch and opens a GitHub PR via `gh`, records the PR URL in the archived REQ's `## Outputs` and the run ledger, and leaves the branch alive (the PR owns it). `delivery.pr.granularity: req` (default) opens one PR per REQ; `ur` accumulates every REQ of a UR onto the shared `new-work` branch and opens a single PR when that UR's backlog drains. PR mode requires a configured git remote and the `gh` CLI — if either is missing the run stops with a `missing-creds` stopper and the REQ stays in `working/`; it never silently falls back to merging. The closure-proof model is unchanged in both modes — evidence still gates archive; the PR is only the delivery vehicle. Normal completion is proof-backed. Capture may mark generated criteria as `agent-drafted`, but that provenance does not block execution. If a REQ exists in the backlog, it should run unless dependencies, footprint, policy, tests, verification, review, or genuinely ambiguous criteria stop it. Workers return checkpointed evidence and per-criterion acceptance evidence; the orchestrator validates that evidence, runs policy checks for blocked paths or commands, invokes post-build review, writes `**Closure proof:**`, derives `proven` / `unproven`, and records `.do-work/runs/RUN-NNN.yml` when the ledger is enabled. A worker report is only an input to completion, not the archive/proof decision. @@ -284,7 +284,7 @@ Three guarantees keep the parallel terminals from stepping on each other: **When parallel mode shines.** Backlogs of 5+ independent REQs — the work-sharing payoff grows with the backlog size. For single-REQ work, tightly-coupled REQs, or milestone deploy gates (which stay single-agent by design), the simplicity of one terminal is often the better trade-off. -**Isolation per REQ.** Every REQ runs in a dedicated `git worktree` on a `req/REQ-NNN` branch forked from the integration base (`ur/UR-NNN` or `work/`, never protected defaults — see **Integration base** above). The orchestrator creates the worktree before dispatch and tears it down after merging into that base — no REQ ever touches `main`/`master`/remote HEAD directly. Dependency directories (`vendor`, `node_modules`, `.venv`) are symlinked from the main checkout into each worktree automatically; use `worktree.link_paths` and `worktree.setup_command` in config for monorepo layouts the auto-detection misses. +**Isolation per REQ.** Every REQ runs in a dedicated `git worktree` on a `req/REQ-NNN` branch forked from the integration base (`new-work` when leave-default ran, or the non-protected branch you were already on — never protected defaults; see **Integration base** above). The orchestrator creates the worktree before dispatch and tears it down after merging into that base — no REQ ever touches `main`/`master`/remote HEAD directly. Dependency directories (`vendor`, `node_modules`, `.venv`) are symlinked from the main checkout into each worktree automatically; use `worktree.link_paths` and `worktree.setup_command` in config for monorepo layouts the auto-detection misses. See `SKILL.md` `## Parallel Execution` for the full behavioural reference, including state files (`gate-owner.md`, `final-suite-running.md`). diff --git a/agents/config.md b/agents/config.md index 5d7336c..92cf86b 100644 --- a/agents/config.md +++ b/agents/config.md @@ -93,7 +93,7 @@ ledger: delivery: mode: merge # how run.md Step 4 delivers a passing REQ: "merge" (default — merge req/REQ-NNN into the integration base from lib/ensure-integration-base.sh, never main/master/remote HEAD) or "pr" (push the branch and open a GitHub PR instead of merging). pr mode requires a configured git remote and the `gh` CLI; missing either stops with a missing-creds stopper — it NEVER silently falls back to merge. pr: - granularity: req # only consulted when mode: pr. "req" (default) opens one PR per REQ off req/REQ-NNN; "ur" accumulates each REQ branch onto a shared ur/UR-NNN branch and opens a single PR when that UR's backlog drains. The ur/UR-NNN name is the same branch ensure-integration-base creates for scoped go/run — merge mode may accumulate on ur/ without setting mode: pr. + granularity: req # only consulted when mode: pr. "req" (default) opens one PR per REQ off req/REQ-NNN; "ur" accumulates each REQ branch onto the shared new-work branch and opens a single PR when that UR's backlog drains. ensure-integration-base leave-default always uses new-work (scoped and unscoped); granularity: ur only controls PR open/accumulate behaviour when mode is pr. worktree: link_paths: [] # extra dependency dirs to symlink from the main checkout into each @@ -327,7 +327,7 @@ routing: [] | `cost.budget` | string | `""` | Optional user-defined model/cost budget. Empty means no configured budget. Consumers: `agents/run.md`, `lib/run-ledger.sh`. | | `ledger.enabled` | boolean | `true` | When true, write structured run records under `.do-work/runs/`. Consumers: `lib/run-ledger.sh`, `agents/run.md`. | | `delivery.mode` | string | `merge` | How `agents/run.md` Step 4 delivers a passing REQ. `merge` (default): merge `req/REQ-NNN` into the **integration base** from go/run pre-flight (`lib/ensure-integration-base.sh` — never `main`/`master`/remote HEAD), archive, tear down the worktree, delete the branch. `pr` replaces the local merge with a GitHub PR: push the branch, open a PR via `gh pr create`, record the PR URL in the archived REQ's `## Outputs` and the ledger entry, archive, tear down the worktree — but leave the branch alive (the PR owns it). `pr` mode requires a configured git remote and the `gh` CLI; if either is missing the run stops with a `missing-creds` stopper and the REQ stays in `working/` — it **never** silently falls back to `merge`. Consumers: `agents/run.md`, `lib/ensure-integration-base.sh`. | -| `delivery.pr.granularity` | string | `req` | Only consulted when `delivery.mode` is `pr`. `req` (default) opens one PR per REQ directly off `req/REQ-NNN`. `ur` accumulates each completed REQ branch onto a shared `ur/UR-NNN` integration branch and opens a single PR when that UR's backlog drains, so a whole UR ships as one reviewable PR. **Naming note:** scoped go/run already create-or-checkout `ur/UR-NNN` via `ensure-integration-base` even when `delivery.mode` is `merge` — the `ur/` branch name does **not** require pr mode; `granularity: ur` only controls PR open/accumulate behaviour when mode is `pr`. Consumers: `agents/run.md`, `lib/ensure-integration-base.sh`. | +| `delivery.pr.granularity` | string | `req` | Only consulted when `delivery.mode` is `pr`. `req` (default) opens one PR per REQ directly off `req/REQ-NNN`. `ur` accumulates each completed REQ branch onto the shared `new-work` integration branch and opens a single PR when that UR's backlog drains, so a whole UR ships as one reviewable PR. **Naming note:** go/run leave-default already create-or-checkout `new-work` via `ensure-integration-base` (and merge the protected tip when reusing) even when `delivery.mode` is `merge` — the `new-work` name does **not** require pr mode; `granularity: ur` only controls PR open/accumulate behaviour when mode is `pr`. Consumers: `agents/run.md`, `lib/ensure-integration-base.sh`. | | `verify.threshold` | integer | `90` | Minimum confidence score (0-100) that `agents/go.md` requires before auto-running without `--force`. Consumers: `agents/verify.md`, `agents/go.md`. | | `routing` | list of `{match, agent}` maps | `[]` | Ordered subagent-routing rules for the run orchestrator's REQ classification. Each entry is `{match: , agent: }`. The classifier scans rules top-to-bottom (first match wins) and dispatches the matching `agent`; if no rule matches — or the list is empty — it falls back to `general-purpose` silently. Ships empty so the stock skill is portable (no machine-specific agents). A commented example block in the template above reproduces the original specialist table for users who want to restore it. Consumers: `agents/run.md`, `agents/resume.md`. | | `worktree.link_paths` | list of strings | `[]` | Extra dependency directories to symlink from the main checkout into each worker worktree (e.g. `[server/vendor, web/node_modules]`). Additive to auto-detected dirs: `composer.json` → `vendor`, `package.json` → `node_modules`, `pyproject.toml` / `requirements.txt` → `.venv`. Use this for monorepo or subdir layouts where the auto-detection misses a directory. Consumers: `lib/provision-worktree.sh`. | diff --git a/agents/go.md b/agents/go.md index 74f235f..4aaa1fa 100644 --- a/agents/go.md +++ b/agents/go.md @@ -62,9 +62,9 @@ bash {skill-root}/lib/ensure-integration-base.sh UR-NNN **Hard rules (agents must not invent branch switches):** -1. **Only the script may create/checkout `ur/*` or `work/*`.** Never run `git checkout`, `git switch`, or `git checkout -b` yourself to "set up" an integration base. -2. **Already on a non-default branch (feature, `ur/*`, `req/*`, etc.) → skip.** The script prints the current branch and does not switch. Stay on that branch for the entire go/run — do **not** move to `ur/UR-NNN` "for consistency" or because a sibling session used a different base. -3. **Switch only when the script already switched** — i.e. stdout is a new `ur/UR-NNN` or `work/*` you were not on before; after the script returns, `git branch --show-current` must already equal the printed name. If they differ, hard-stop (do not fix with another checkout). +1. **Only the script may create/checkout `new-work` (leave-default target).** Never run `git checkout`, `git switch`, or `git checkout -b` yourself to "set up" an integration base. +2. **Already on a non-default branch (feature, `new-work`, `req/*`, etc.) → skip.** The script prints the current branch and does not switch. Stay on that branch for the entire go/run — do **not** force `new-work` "for consistency" or because a sibling session used a different base. +3. **Switch only when the script already switched** — i.e. stdout is `new-work` you were not on before; after the script returns, `git branch --show-current` must already equal the printed name. If they differ, hard-stop (do not fix with another checkout). Leave-default creates `new-work` if missing, or checkouts existing `new-work` and **merges** the protected tip left into it; dirty trees carry onto `new-work`. 4. Worker `req/*` worktrees are separate checkouts; they must not change the orchestrator main checkout's branch. `/do-work start` must **not** call this helper — only go and run enforce the guard. diff --git a/agents/run.md b/agents/run.md index 4c37f69..3d36221 100644 --- a/agents/run.md +++ b/agents/run.md @@ -106,7 +106,7 @@ Full stamp lifecycle: [run-loop.md](../references/run-loop.md) § Agent Identity ```bash bash {skill-root}/lib/ensure-integration-base.sh [UR-NNN] ``` - Pass the UR slug when this run is scoped (`/do-work run UR-NNN`); omit the arg when unscoped. Non-zero exit → hard-stop the run (surface stderr); do **not** claim or dispatch workers. Success → record printed branch as integration base (same base for `delivery.mode` merge and pr). **If already on a non-default branch, the script skips (prints current branch) — do not invent a further `git checkout` to `ur/*` or `work/*`.** Only the script may switch the orchestrator checkout. Full sequence + hard rules: [run-loop.md](../references/run-loop.md) § Pre-flight Check §2a.1. + Pass the UR slug when this run is scoped (`/do-work run UR-NNN`); omit the arg when unscoped (UR arg does not change the leave-default branch name). Non-zero exit → hard-stop the run (surface stderr); do **not** claim or dispatch workers. Success → record printed branch as integration base (same base for `delivery.mode` merge and pr). **If already on a non-default branch, the script skips (prints current branch) — do not invent a further `git checkout` to `new-work`.** Only the script may switch the orchestrator checkout. Leave-default target is always `new-work` (create-if-missing; existing → checkout + merge protected tip; dirty carry). Full sequence + hard rules: [run-loop.md](../references/run-loop.md) § Pre-flight Check §2a.1. 4. Scan/classify working slots (markdown) or in-flight Linear claims — mine / sibling / stale buckets. 5. Resume any `mine` slot. 6. Try backlog (primary); else evaluate working set; else empty-backlog path. diff --git a/docs/HOW-IT-WORKS.md b/docs/HOW-IT-WORKS.md index 04bafe4..87197de 100644 --- a/docs/HOW-IT-WORKS.md +++ b/docs/HOW-IT-WORKS.md @@ -251,14 +251,13 @@ Before any claim, worker dispatch, or worktree provision, go and run call `lib/e | Orchestrator checkout | Behaviour | |-----------------------|-----------| | Already off a protected default | Keep that branch; it is the integration base | -| On protected default + **clean** tree + scoped (`go` / `run UR-NNN`) | Create-or-checkout `ur/UR-NNN` from current HEAD | -| On protected default + **clean** tree + unscoped (`run` with no UR) | Create `work/` (e.g. `work/20260808T143022Z`) | -| On protected default + **dirty** tree | **Hard-stop** — no stash, no switch; clean the tree first | +| On protected default (scoped or unscoped) | Create-or-checkout fixed branch `new-work`; if it already exists, checkout and **merge** the protected tip just left into `new-work` | +| On protected default + **dirty** tree | Allowed — uncommitted changes **carry** onto `new-work` (no dirt-only hard-stop) | | Detached HEAD | **Hard-stop** | `/do-work start` does **not** call this helper and does not switch branches — only go/run enforce the guard. -The `ur/UR-NNN` name is shared with `delivery.pr.granularity: ur` (one PR per UR). Merge mode reuses the same branch name for accumulation; using `ur/` does **not** require `delivery.mode: pr`. +The same `new-work` name is shared with `delivery.pr.granularity: ur` (one PR per UR). Merge mode reuses that branch for accumulation; using `new-work` does **not** require `delivery.mode: pr`. **Why leave the default:** Landing REQ merges on `main`/`master` while agents run makes it easy to ship half-finished work or fight over the shared default. An integration base keeps the default clean until the operator promotes deliberately. @@ -284,7 +283,7 @@ The orchestrator then validates the report, merges the worktree branch into the #### Isolation mode -Every REQ runs in a dedicated git worktree at `{project}/.worktrees/req-NNN` on a `req/REQ-NNN` branch forked from the integration base (`ur/UR-NNN` or `work/`). The orchestrator creates the worktree before dispatch and tears it down after merging the worker's commit into that base. Worktree-always is the canonical mode — same-branch execution is retired. +Every REQ runs in a dedicated git worktree at `{project}/.worktrees/req-NNN` on a `req/REQ-NNN` branch forked from the integration base (`new-work` after leave-default, or the non-protected branch already checked out). The orchestrator creates the worktree before dispatch and tears it down after merging the worker's commit into that base. Worktree-always is the canonical mode — same-branch execution is retired. **Why worktree-always:** Uniform isolation removes a per-REQ judgment call and eliminates the class of conflicts that arises when two workers touch overlapping files on the same branch. The worktree overhead is negligible compared to the TDD loop. diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index c730388..652171d 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -209,21 +209,21 @@ Fix dependency declarations in backlog REQs if the graph is wrong. Capture-time **Fix:** Configure remote + authenticated `gh`, or set `delivery.mode: merge` in `.do-work/config.yml` if local merge is intended. -### Integration base: dirty tree on `main` / `master` (hard-stop) +### Integration base: dirty tree on `main` / `master` -**Symptom:** `/do-work go` or `/do-work run` stops immediately with stderr from `ensure-integration-base.sh` about a dirty working tree on a protected branch; no workers start. +**Symptom:** You have uncommitted changes on a protected default and run go/run. -**Cause:** Pre-flight will not create `ur/UR-NNN` or `work/` while the orchestrator is on a protected default (`main`, `master`, or remote HEAD) with uncommitted changes. It never stashes and never switches over dirt. +**Cause (current behaviour):** Leave-default still runs. Uncommitted changes **carry** onto `new-work` (create-if-missing, or checkout existing + merge the protected tip). There is no dirt-only hard-stop. -**Fix:** Commit or discard local changes on that branch, then re-run go/run. If you already intend a non-default base, check out that branch yourself first (clean or dirty is fine once off the protected default — ensure only auto-switches when leaving the default). +**If something fails:** Merge conflicts when updating an existing stale `new-work`, detached HEAD, or checkout failure still hard-stop — resolve those, then re-run. To avoid carrying dirt, commit or stash before go/run. ### Integration base: auto-switched off the default branch -**Symptom:** After go/run, the main checkout is on `ur/UR-NNN` (scoped) or `work/` (unscoped), not `main`/`master`. +**Symptom:** After go/run, the main checkout is on `new-work`, not `main`/`master`. -**Cause:** Expected. `lib/ensure-integration-base.sh` leaves protected defaults so REQ merges never land on remote HEAD. Scoped runs reuse `ur/UR-NNN` (same name as `delivery.pr.granularity: ur`; pr mode is not required). `/do-work start` does not switch branches. +**Cause:** Expected. `lib/ensure-integration-base.sh` leaves protected defaults so REQ merges never land on remote HEAD. Leave-default always uses fixed branch `new-work` (same name as `delivery.pr.granularity: ur` accumulation; pr mode is not required). Existing `new-work` is updated by merging the protected tip. `/do-work start` does not switch branches. -**Fix:** None required for the run. When you want to promote, merge or open a PR from the integration base to the default branch yourself. To stay on a long-lived feature base, check it out before go/run so ensure is a no-op. +**Fix:** None required for the run. When you want to promote, merge or open a PR from `new-work` to the default branch yourself. To stay on a long-lived feature base, check it out before go/run so ensure is a no-op. ### Review or evidence gate failed diff --git a/references/run-loop.md b/references/run-loop.md index fb043f9..2a78768 100644 --- a/references/run-loop.md +++ b/references/run-loop.md @@ -100,13 +100,13 @@ bash {skill-root}/lib/ensure-integration-base.sh UR-NNN | Outcome | Action | |---------|--------| -| **Exit non-zero** | **Hard-stop** the run phase. Surface the script's stderr (dirty tree, detached HEAD, invalid UR slug, checkout failure, etc.). Do **not** claim REQs, dispatch workers, or provision worktrees. | +| **Exit non-zero** | **Hard-stop** the run phase. Surface the script's stderr (detached HEAD, invalid UR slug, checkout/merge failure, etc.). Do **not** claim REQs, dispatch workers, or provision worktrees. Dirty trees on a protected default are allowed — uncommitted changes carry onto `new-work`. | | **Exit 0** | Script prints the final branch name on stdout. Record it as the run's **integration base**. Subsequent worker W1 reads this branch via `git rev-parse --abbrev-ref HEAD` (or `git branch --show-current`) on the orchestrator checkout at worktree create — workers do not call ensure themselves. | **Hard rules (do not invent branch switches):** -1. **Only `ensure-integration-base.sh` may create or checkout `ur/*` / `work/*` on the orchestrator checkout.** Agents must not run `git checkout` / `git switch` / `git checkout -b` to force an integration base. -2. **Skip is binding.** If the orchestrator was already on a non-protected branch (any feature branch, existing `ur/*`, etc.), the script prints that branch and **does not switch**. Stay there for the whole run — do **not** hop to `ur/UR-NNN` because the run is UR-scoped or because another agent used a different base. +1. **Only `ensure-integration-base.sh` may create or checkout `new-work` on the orchestrator checkout.** Leave-default always targets `new-work` (UR arg ignored for naming). If `new-work` exists, the script checkouts it and **merges** the protected tip just left; dirty trees carry. Agents must not run `git checkout` / `git switch` / `git checkout -b` to force an integration base. +2. **Skip is binding.** If the orchestrator was already on a non-protected branch (any feature branch, existing `new-work`, etc.), the script prints that branch and **does not switch**. Stay there for the whole run — do **not** hop to `new-work` because the run is UR-scoped or because another agent used a different base. 3. After exit 0, `git branch --show-current` must equal the printed name. If not, hard-stop; do not "repair" with another checkout. 4. Worker worktrees on `req/*` are isolated; they never retarget the orchestrator's branch. @@ -873,10 +873,10 @@ git push -u origin req/REQ-NNN - **`req`** (default) — open the PR immediately, from `req/REQ-NNN` into the base branch. Continue to 4-pr.3. - **`ur`** — do NOT open a per-REQ PR. Instead accumulate this REQ onto the UR's shared integration branch: - 1. Resolve the UR id from the REQ's `**UR:**` field → integration branch `ur/UR-NNN`. - 2. If `ur/UR-NNN` does not yet exist on the remote, create it from the base branch and push it. - 3. Merge `req/REQ-NNN` into `ur/UR-NNN` (`git merge --no-ff`, applying the same conflict/retry policy as 4a) and push `ur/UR-NNN`. - 4. Archive this REQ now (4-pr.4) recording the `ur/UR-NNN` branch, but **defer PR creation**: the single PR opens at UR drain. After the last REQ for this UR archives and the UR's backlog is empty (see `## When the Backlog is Empty` drain check), open one PR from `ur/UR-NNN` into the base branch using the same title/body shape as 4-pr.3 (title/body keyed to the UR rather than a single REQ; the body links the UR and lists each integrated REQ). Record that PR's URL on the UR. Then continue past 4-pr.5 to Step 7. + 1. Integration branch for `granularity: ur` is always `new-work` (same leave-default name as `ensure-integration-base`). + 2. If `new-work` does not yet exist on the remote, create it from the base branch and push it. + 3. Merge `req/REQ-NNN` into `new-work` (`git merge --no-ff`, applying the same conflict/retry policy as 4a) and push `new-work`. + 4. Archive this REQ now (4-pr.4) recording the `new-work` branch, but **defer PR creation**: the single PR opens at UR drain. After the last REQ for this UR archives and the UR's backlog is empty (see `## When the Backlog is Empty` drain check), open one PR from `new-work` into the base branch using the same title/body shape as 4-pr.3 (title/body keyed to the UR rather than a single REQ; the body links the UR and lists each integrated REQ). Record that PR's URL on the UR. Then continue past 4-pr.5 to Step 7. **4-pr.3 Open the PR (`req` granularity, or the single UR-drain PR).** @@ -906,7 +906,7 @@ Capture the PR URL printed by `gh pr create`. ```bash git worktree remove {project}/.worktrees/req-NNN -# NO `git branch -d` — the open PR owns req/REQ-NNN (or it lives on in ur/UR-NNN). +# NO `git branch -d` — the open PR owns req/REQ-NNN (or it lives on in new-work). ``` **4-pr.6 Record the PR URL in the ledger.** When `ledger.enabled` is true, pass the captured URL to the ledger via `--pr` (see Step 3b) so the run record's `pr_url` field carries it. If the metadata commit (4d-equivalent) runs for a tracked `.do-work/`, stage and commit the archive move per 4d — `chore(REQ-NNN): archive`. From e074ebd6a6edf696be52f5e5aa00dad5041aaf7f Mon Sep 17 00:00:00 2001 From: Tom Kaczocha Date: Tue, 11 Aug 2026 10:19:27 +1000 Subject: [PATCH 15/17] cleanup --- SKILL.md | 1 + references/field-lessons.md | 1118 +++++++++++++++++++++++++++++++++++ 2 files changed, 1119 insertions(+) create mode 100644 references/field-lessons.md diff --git a/SKILL.md b/SKILL.md index b5b70e6..ee1d1a2 100644 --- a/SKILL.md +++ b/SKILL.md @@ -216,5 +216,6 @@ Load only when the active task needs them (one hop from this file): | [references/commands.md](references/commands.md) | Executing a subcommand; install bootstrap YAML; full step stubs | | [references/tracker.md](references/tracker.md) | Configuring or debugging multi-tracker / Linear keys, claims, commits | | [references/concepts.md](references/concepts.md) | Naming, milestone mode, parallel coordination, layers, path-units, decisions, REQ header schema, commit convention, checkpointed verification | +| [references/field-lessons.md](references/field-lessons.md) | Start of a skill run (read); end of run when a portable lesson exists (append). Always read before acting if present. | Recovery (stuck / stopped / deadlock): `/do-work unblock`, `/do-work resume`, `/do-work status` — see agent files and [references/concepts.md](references/concepts.md#recovery-commands). diff --git a/references/field-lessons.md b/references/field-lessons.md new file mode 100644 index 0000000..25d5110 --- /dev/null +++ b/references/field-lessons.md @@ -0,0 +1,1118 @@ +# do-work field lessons + +Portable patterns for future do-work runs (any project). Read at skill start when present; append at end of runs that teach something transferable. + +--- + +## 1. Linear `save_milestone` patch vs full replace + +| Symptom | Likely cause | Default action | +|---------|--------------|----------------| +| MCP `save_milestone` with `patch` fails: `Invalid arguments` / validation error | Host or schema rejects nested `patch` ops (or anchors don’t match Linear’s normalized markdown) | Prefer **full `description` rewrite** (read → merge sections → write). Never dual-write local `user-requests/` | +| Partial section update needed (`## Ideate`, `## Clarifications`, `## Verify`) | Same | Rebuild description: keep `## Brief` verbatim; replace only the target section; write full body | +| **`## Brief` / Capture summary vanish after verify** | Agent passed **only** the new `## Verify` block as `description` — Linear **replaces** the whole field | **Always** read current description → splice section → write **full** body. Size spill: short `## Verify` pointer in description + full report as **milestone comment** (``). If wipe already happened, restore from session MCP dump / GraphQL immediately | +| MCP write flaky mid-session | Transport / size | Retry full replace once; GraphQL `projectMilestoneUpdate` only if MCP remains unusable **and** operator env already has Linear auth — still no markdown store fallback | + +--- + +## 2. Linear deps: relations API, not `IssueUpdateInput` + +| Symptom | Likely cause | Default action | +|---------|--------------|----------------| +| Cannot set `blockedBy` on issue update | `IssueUpdateInput` has **no** blocks fields | Use `issueRelationCreate` with `type: blocks`, `issueId` = **blocker**, `relatedIssueId` = **blocked** | +| Claim eligibility wrong after capture | Only body `**Depends on:**` written | Dual-write: relations authoritative + body mirror (port rule) | +| Path-unit never claimable | Parent not blocked by children (or reverse) | Path-unit Issue **blocked by** layer children; leaf deps as hard edges only | + +--- + +## 3. “Is the dependency already done?” — verify before re-asking + +| Symptom | Likely cause | Default action | +|---------|--------------|----------------| +| Grill asks hard-dep on a side-track; operator says “I thought that was done” | Brief still says “recommended” / “depends on” after the work shipped | **Check Linear** (UR milestone / Issues → Done) **and** repo evidence (release workflow, tags, install script) before asking again | +| Confirmed done | Side-track complete | Record clarification: treat as **existing infrastructure**, not a hard wait; still note residual gaps (e.g. extra GOOS in matrix) | + +Do not invent “still open” from plan prose alone when the product Project shows Done. + +--- + +## 4. Multi-stream phase plans — grill the decomposition forks + +For large multi-workstream briefs (Windows + billing + updates + …), ideate gaps are high-impact. Prefer **Grill** questions that change REQ graph shape, not polish: + +| Fork | Why it changes capture | +|------|-------------------------| +| Billable entity (workspace vs user) | Migrations, Cashier model, portal ownership | +| Runtime acceptance bar (unit/compile vs CI vs manual smoke) | Verification Steps + done criteria | +| Control-loop thrash policy (hysteresis, cooldown, manual resume) | Domain AC + agent loop REQs | +| Placeholder vs real integration (SSO hook depth) | Scope of leaf REQs | + +Self-answer pass still batches confident codebase inferences first (composer packages present, existing columns, heartbeat fields). + +--- + +## 5. Bulk Linear `create_req` under a large plan + +| Symptom | Likely cause | Default action | +|---------|--------------|----------------| +| Capture of 15–25 Issues is slow / times out | Serial MCP + full bodies | Batch creates with stable §9.2 bodies; set labels (`Layer/*`, `Size/*`, `path-unit`); **then** a second pass for relations + `**Depends on:**` mirrors | +| Milestone description huge | Full plan pasted into `## Brief` | Intake may keep full plan; subsequent section updates should not truncate Brief when editing Ideate/Capture — merge carefully | +| cycle-check.sh fails under Linear | Script is markdown-path | Under `backend: linear`, treat relation graph as authority; don’t halt solely because local `REQ-*.md` cycle-check finds nothing | + +--- + +## 6. Field-lessons write exception + +Skill install trees are often “read-only at runtime” for product work. **Exception:** appending or creating this file (`references/field-lessons.md`) under the do-work skill root **is allowed** when a run produced portable lessons — do not skip capture because of the generic skill-dir rule. + +--- + +## 7. Monorepo worktrees: `vendor` symlink depth + Laravel base path + +| Symptom | Likely cause | Default action | +|---------|--------------|----------------| +| `provision-worktree.sh` leaves `packages/*/vendor` unprovisioned | Auto-detect only walks **depth ≤ 1** from worktree root; `packages/server/composer.json` is depth 2 | Configure `worktree.link_paths: [packages/server/vendor, …]` **or** run `composer install` inside the package worktree path before tests | +| Symlinked `vendor` → main checkout: `config()` / app boot hits wrong tree; new worktree classes “not found” or tests pass against main `app/` | Laravel `Application::inferBasePath()` uses Composer ClassLoader root (= main `packages/server` when vendor is a symlink) | Prefer **real `composer install` in the worktree package** for PHP monorepos. Alternative: set `APP_BASE_PATH` to the worktree package root for test runs — do not assume symlink vendor is safe for PSR-4 load of worktree code | + +Portable rule: for Laravel packages under `packages//`, treat symlink-vendor as a **smoke shortcut only**; TDD that adds classes under the worktree needs a worktree-local autoload (install or path fix). + +Same depth trap for **`packages/web/node_modules`**: empty `worktree.link_paths` + depth-1 auto-detect → worktree has no JS deps. Symlink `packages/web/node_modules` from the main checkout (or set `link_paths`) before `npm test`. + +| Symptom | Likely cause | Default action | +|---------|--------------|----------------| +| `node_modules/vitest: Too many levels of symbolic links` after manual `ln -s` | From worktree `packages/web`, relative `../../packages/web/node_modules` resolves **into itself** (worktree root is only two `..` up), not the main checkout | Symlink with an **absolute** main-checkout path, or count parents to the monorepo root (`../../../../packages/web/node_modules` when worktree is `{repo}/.worktrees//packages/web`). Prefer `worktree.link_paths` so provision does this correctly. | + +--- + +## 8. UI evidence for auth-gated SPA forms + +| Symptom | Likely cause | Default action | +|---------|--------------|----------------| +| `ui` step needs “form visible for owner” but Playwright only reaches login | Real SPA route requires session; worker has no seed credentials | Serve a **temporary** root HTML entry in the package (`evidence-*.html`) that mounts the real component with props (`canEdit: true`) and a **mocked `fetch`** for the settings API; screenshot that; **delete the HTML before commit** | +| Main checkout Vite already on :5173 | Parallel / stale main dev server | Start worktree Vite on a free port (`--port 5199 --strictPort`); do not assume the main-checkout server has worktree code | + +Still vision-assert the PNG (enabled + thresholds + locked weights / save). Do not commit evidence HTML or pass on a11y-only scrapes. + +--- + +## 9. Parallel web REQs sharing one view file + +| Symptom | Likely cause | Default action | +|---------|--------------|----------------| +| Two workers both claim `SettingsView.vue` (or similar shell) | Footprint lists component dirs only, not the shared mount view | Expand `**Files:**` to include every shell/layout file the worker will edit; at claim time treat view mounts as exclusive if both REQs name the same path | +| Auto-merge succeeds but double-mounts UI | Both REQs added adjacent sections | Prefer path-unit parent to do mount wiring after leaf components land, or claim shell view serially | + +--- + +## 10. REQ AC / task title beats plan step comments + +| Symptom | Likely cause | Default action | +|---------|--------------|----------------| +| Plan Task N says “v1 no-X” but Issue Task/AC requires X | Capture applied a clarification override; plan doc not rewritten | Implement **Issue AC + Task**, not stale plan comments. Cross-check design § clarifications when titles diverge (e.g. prefer-cmux dedup vs “v1 no dedup”) | +| Worker ships plan-literal no-op and fails AC | Read plan before Issue body | Always treat Linear Issue (or markdown REQ) as the closure oracle; plan is scaffolding | + +--- + +## 11. Child-resource payloads need parent link diagnostics + +| Symptom | Likely cause | Default action | +|---------|--------------|----------------| +| UI pane/session view cannot show agent online / hb without machines store | Child list/get payload only maps the child row | Extend the **shared payload helper** (not controller + cap twice) with parent-link fields (`last_heartbeat_at`, `agent_version`, optional derived `agent_online`) | +| List endpoint N+1 on link lookup | Payload always re-queries per row | Optional preloaded link arg; **batch-load** links in list authority (unique by machine/host key), pass through | +| Online bool drifts from client filter | Server invents a different stale window | Mirror the UI threshold constant (document the match); prefer raw ISO heartbeat + bool both when chips need either | + +Do not invent stream-persistence columns for “honest empty stream” when client already owns WS/chunk evidence — only expose server-known diagnostics. + +--- + +## 12. Live chip must require stream evidence (not transport-up) + +| Symptom | Likely cause | Default action | +|---------|--------------|----------------| +| Empty terminal shows green “live” / header “Live” | Status machine maps WS `connected` → live; header reuses “Live” for transport | Gate **live** on chunk/output evidence only; `connected` → **waiting**; silence → **idle**; offline distinct. Rename header transport chip to “Stream on/off” so it never collides with pane live | +| Blank pane with no “why” | Empty copy ignores agent heartbeat | When silent, append agent online/offline + last-hb age from parent-link diagnostics (see §10) — pure helpers, unit-tested | +| Dual status chrome confuses users | App WS chip vs pane stream chip use same word | Keep transport wording separate from pane stream evidence wording | + +Portable rule: **transport connected ≠ product live**. Chips and empty-state copy must say waiting/idle/offline until evidence arrives. + +--- + +## 13. GoReleaser GOOS vs self-update asset contract + +| Symptom | Likely cause | Default action | +|---------|--------------|----------------| +| Windows release added as zip; agent update fails | Self-update client hardcodes `AssetName` → `*.tar.gz` and `ExtractBinary` only understands tar.gz | Keep **tar.gz for every GOOS** until the update client is taught zip; document the contract next to `.goreleaser.yaml` | +| Matrix builds unwanted `windows/arm64` | `goos × goarch` cartesian product | Use `ignore:` for unsupported pairs; ship only the arch the AC names (e.g. windows/amd64) | +| Docs say “or defer” but compile is CGO-free | Untested assumption that Windows needs CGO | Cross-compile smoke (`GOOS=windows GOARCH=amd64 go build`) before deferring the matrix entry | + + +--- + +## 14. Agent dual-loop: share one stream Registry + +| Symptom | Likely cause | Default action | +|---------|--------------|----------------| +| Heartbeat Converge and command Start each create a Registry | Run helpers construct their own lifecycle maps | Wire **one** Registry in main; pass into both loops (heartbeat Converge + create_pane Start) | +| Double stream POST for same pane_id | Independent cancel maps; Converge cannot stop command-started streamers | Shared map is the fix — not “stop all on converge” hacks | +| Wiring hard to unit-test | Run hardcodes HTTP client | Extract `RunLoop(ctx, Heartbeater, Converger)` with interfaces; fake Heartbeat response + recording Converge | + +Portable rule: when two concurrent loops own the same supervisor resource (streamers, connections), construct once at the process root and inject — never let each loop default-construct its own. + +--- + +## 15. Hysteresis demotion tests — clock from challenger_since + +| Symptom | Likely cause | Default action | +|---------|--------------|----------------| +| Demotion never fires at “T+30s from first beat” | Challenger timer starts only when the higher-ranked key appears; first beat has no challenger | Advance time **≥ hysteresis window after `rank_challenger_since`**, not after the first select beat | +| Mid-beat keeps the lower streamer | Correct hold under hysteresis | Assert challenger_key/since set and ideal key still `available` until elapsed | + +Portable rule for control-loop thrash tests: freeze time (`setTestNow`), inject `now` into the authority, and measure hold/release against the persisted challenger timestamp. + +--- + +## 16. Multi-UR concurrent merges on shared integration branch + +| Symptom | Likely cause | Default action | +|---------|--------------|----------------| +| Stage B merge conflicts in shared shell views (`PaneView.vue`, stores, API clients) though REQ footprints were disjoint | Multiple URs land on the same integration branch (`new-work`) in parallel; footprints only exclude **in-flight** claims, not already-merged sibling URs | Resolve conflict by **keeping both** feature sets (do not drop either side’s fields). Prefer combine-not-choose for additive UI. Re-run package tests after conflict fix before archive | +| Footprint looked free at claim | Other UR merged between claim and integrate | Same — Stage B conflict path is expected under concurrent go runs | + + +--- + +## 17. Concurrent integration-base advance mid-parallel wave + +| Symptom | Likely cause | Default action | +|---------|--------------|----------------| +| Worker branch merges with content conflict on shared core (e.g. stream Registry) | Sibling UR/session merged into `new-work` while workers ran on older tip | Rebase/resolve onto **current** base before archive; re-run package tests for the conflicted package | +| `Stop`/`Start` API shape drifted (map of cancels vs entries) | Another REQ refactored the same type | Port the new method onto the **new** structure; do not force-old map over newer Converge design | +| Path-unit still Todo while all layer children Done | Relations never blocked path-unit on children | After last leaf archives, **close path-unit** with run-note (children Done) so dep chains (e.g. Upgrade path blocked by free-slot path) unlock | + +Portable rule: parallel waves assume disjoint **Files:** but not a frozen integration tip — always merge against live base and treat path-unit Done as a graph unlock, not a second implementation pass. + +--- + +## 18. Hard-cut route keys — fake side effects on the reject path + +| Symptom | Likely cause | Default action | +|---------|--------------|----------------| +| “Integer/old key → 404” feature test gets **500** (e.g. Pusher/broadcast) while red | Old key still binds; handler runs and hits real side effects | `Event::fake()` (or equivalent) on **both** ULID-ok and reject cases so a binding regression fails as 200/≠404, not infra 500 | +| Only put binding on one model | Routes type-hint several public models | Prefer `getRouteKeyName` + `resolveRouteBinding` on the shared public-id trait (`Str::isUlid` guard) so agent + HTTP surfaces stay aligned | + +--- + +## 19. Public ULID hard-cut: one resolver, reject digits + +| Symptom | Likely cause | Default action | +|---------|--------------|----------------| +| Caps still authorize when client sends `"1"` / integer PK as string | Lookup uses `find($id)` or `where('ulid', $id)` without rejecting pure digits | Central **`PublicId::find/findOrFail`**: require `Str::isUlid` **and** `!ctype_digit`; never fall through to PK | +| Payload helpers emit mixed int/string ids | Mapped `$model->id` in list/detail helpers | Emit only `PublicId::require($model)` for `id` and related `*_id`; load parent relations for FK public ids | +| Input DTOs still typed `int $workspace_id` | Schema lag after column migration | Change capability Input/Result id fields to `string`; resolve once at authorize/run edge; keep domain authorities on internal int PKs | + +Portable rule: **external surface = ULID only; internal domain = bigint**. Reject digit-only public keys at the boundary so PK leaks cannot authorize or round-trip. + +Agent/device wire is external too — not just HTTP/caps: + +| Surface | Must emit public ULID | +|---------|------------------------| +| Claim complete `machine_id` | Device identity after claim | +| Heartbeat `desired_streams[].pane_id` | Stream targets for agent | +| Command poll wire `command_id` / `machine_id` + payload `pane_id` | Long-poll + create/destroy/send_keys | +| Agent attach/state response `pane_id` | Agent→server ack | + +Do not leave `$model->id` in those paths after a hard-cut; audit `toWire` / DTO `toArray` / enqueue payloads in the same REQ as route binding. + +--- + +## 20. SPA public-id hard-cut: fixtures + exceptions + +| Symptom | Likely cause | Default action | +|---------|--------------|----------------| +| Suite fails only on `toEqual([1])` / URL `/workspaces/3/` after types are already `string` | Bulk-replaced object fields but not **call args** and **expected values** | When converting entity ids to ULID strings, rewrite fixtures, `toHaveBeenCalledWith`, URL regexes, and chip `value:` expectations in the same pass | +| Accidental conversion of PAT / Sanctum token `id: number` | “All `id: number`” codemod | Leave third-party / Sanctum token ids numeric until that surface emits public ULIDs; domain entities only (workspace/machine/pane/user) | +| Route/query still accepts `"12"` after cut | Client parser uses `Number()` / positive-int | Shared `parsePublicId`: ULID shape + reject pure digits; null out legacy localStorage int keys | + +Portable rule: hard-cut client + tests as one unit; exclude non-public-id surfaces explicitly. + +--- + +## 21. Go agent ULID hard-cut — empty sentinel, not zero + +| Symptom | Likely cause | Default action | +|---------|--------------|----------------| +| Client parses claim/desired_streams but rest of package still `int64` | Only DTO fields flipped | Cascade **all** pane/machine/command id types in one REQ: config, commands, stream Registry maps, SessionName/ParseSessionName, attach/stream URL segments | +| Tests still skip on `paneID == 0` / `IsActive(0)` | Zero was the missing sentinel for int | Use **`""` empty string** as missing; `"0"` is a valid (legacy) id string and must not no-op Start/Stop | +| Resume ignores `ac-{ulid}` | ParseSessionName still requires digits | Accept non-empty suffix after `ac-`; reject only empty / wrong prefix | +| JSON number in old config fails Load | Hard cut: config `machine_id` is string | New claim rewrites config; fixtures use ULID strings — no silent int fallback | + +Portable rule: external wire + local config + map keys + path segments change together; missing-id checks become empty-string, not zero. + +--- + +## 22. Hard-cut identity: path-unit residual is a full suite, not a checklist + +| Symptom | Likely cause | Default action | +|---------|--------------|----------------| +| Leaves green in isolation; path-unit `php artisan test` has dozens of 404/`validation_failed` | Feature/cap tests still pass **integer PKs** as route keys and capability `*_id` inputs | Path-unit owns a **full-package suite** pass after the last leaf; budget a residual “align tests to public id” pass (same UR) | +| Bulk `->id` → `->ulid` rewrites break Eloquent creates (FK constraint) | Codemod rewrote mass-assign `workspace_id`/`machine_id` on `Model::create` | Split surfaces: **external** (routes, invoke inputs, assertJson) use ULID; **internal FKs** stay bigint. Never codemod `create([...])` FK fields | +| `findOrFail($result->data->pane_id)` fails after result becomes ULID | Still treating result id as PK | `where('ulid', $publicId)->firstOrFail()` (or `PublicId::findOrFail`) | + +Portable rule: hard-cut leaves may ship production code green with thin tests; the **path-unit gate** is the whole suite. Prefer targeted rewrites (URL strings + invoke arrays + JSON assertions) over global `->id` replace. + +--- + +## 23. Duplicate UR-NNN milestones on one product Project + +| Symptom | Likely cause | Default action | +|---------|--------------|----------------| +| `list_milestones` returns two `UR-018: …` names | Parallel intake / ship-loop used same next slug | Prefer milestone with open REQs + `Status: captured`; treat 100% progress / all Done as the closed twin. Do not dual-run both | +| `go UR-NNN` ambiguous | Same | Resolve by milestone id in run notes; consider renaming closed twin to avoid future collision | + + +--- + +## 24. Privileged PAT profile vs product profile name lists + +| Symptom | Likely cause | Default action | +|---------|--------------|----------------| +| Adding a system/platform PAT profile breaks MCP/catalog parity tests that iterate `profileNames()` | Product mounts and privileged profiles were one list; tests expect every profile key in product MCP config | Split **productProfileNames()** (product mounts) from **profileNames()** (all mintable). MCP/agent config + parity tests iterate product only; mint validation uses all | +| Product tokens can call privileged caps | Scope gate treats SPA `*` as full power for every capability name | For privileged capability prefixes, deny `*` and product expansions; require explicit privileged profile allowlist | + +Portable rule: when a new PAT profile must not appear on product MCP/CLI mounts, never fold it into the list parity tests treat as “must be mounted.” Gate privileged capability names separately from product `*` bypass. + +--- + +## 25. Admin/SPA list UI evidence without temporary HTML + +| Symptom | Likely cause | Default action | +|---------|--------------|----------------| +| Auth-gated list route exists but API is missing | Parallel server REQ not done | Playwright: seed `localStorage` session keys the SPA already uses, `page.route` mock list JSON, navigate to real route, screenshot | +| Evidence HTML would duplicate shell chrome | Route already mounts real view under shell layout | Prefer real route + mocks over a throwaway HTML mount; still vision-assert the PNG | + +Portable rule: when the shell + route already land, inject session storage + intercept network instead of a temporary `evidence-*.html` (still delete any temp files if used). + +--- + +## 26. Privileged capability catalog vs product surface-parity matrix + +| Symptom | Likely cause | Default action | +|---------|--------------|----------------| +| Registering `system.*` (or other privileged) caps fails product surface parity that requires `agent` on every registry name | Privileged mount is http/mcp/cli only; product Manager needs agent | Split required surfaces: product = full set; privileged prefix = declared subset (and assert **not** on product agent mounts) | +| Catalog gap matrix regex only matches product `workspace\|machine\|pane.*` | Matrix was product-only | Add a **second matrix** for privileged names sourced from the PAT profile expansion; never fold privileged rows into the product matrix | +| Cap CRUD names exist but model is a sibling REQ | Catalog registration needs something to call | Ship **minimal** model + domain authority with the catalog cap REQ; leave product-side integration (quota waiver, HTTP controllers) to siblings | + +Portable rule: privileged catalogs share discovery path but **not** product profile allowlists or product surface requirements — isolate in tests and PAT expansions (see §18). + +--- + +## 27. Laravel monorepo worktree: `.env` for clean Pest + +| Symptom | Likely cause | Default action | +|---------|--------------|----------------| +| Every Pest test is `WARN` with `file_get_contents(.../.env): Failed to open stream` | Real `composer install` in worktree package but no `.env` (gitignored; provisioner only links `vendor`) | Symlink or copy main `packages/server/.env` into the worktree package root before `php artisan test`. Assertions still pass (exit 0) without it — but WARN noise hides real failures; treat missing `.env` as part of Laravel worktree provision, not suite failure. | + +Portable rule: for Laravel package worktrees, provision **autoload (vendor)** and **runtime env (`.env`)**; vendor alone is not enough for quiet green. + + +--- + +## 28. Cache store that normalizes on put — pass raw, wire the bound + +| Symptom | Likely cause | Default action | +|---------|--------------|----------------| +| Store `truncated: false` though content was oversize | Controller pre-normalized then passed already-bound bytes into `put()`, which re-normalizes and sees size ≤ MAX | Pass **raw** content into the store (store owns normalize + truncated); assign **normalized** only to broadcast/response body | +| Double-work normalize looks “fine” in green tests that only assert string equality | Truncation flag / audit fields not asserted | Always assert `truncated` (or equivalent metadata) when the AC cares about bound storage | + +Portable rule: one owner for normalize+metadata (the store); consumers that also need the bound payload call the same pure normalize helper for the wire, not the store’s return side-effect. + + +--- + +## 29. Integration-base checkout mid-parallel (Linear monorepo) + +| Symptom | Likely cause | Default action | +|---------|--------------|----------------| +| Merges land on unexpected branch (`ux/…`) while go started on `new-work` | Concurrent worktree holds `new-work`; shell checkout jumps; later Stage B merges follow current branch | Before every Stage B merge: assert `git branch --show-current` equals recorded ensure-integration-base tip. If another worktree owns the base, merge via that worktree (`git -C .worktrees/… merge`) or cherry-pick onto the base tip — never invent a new integration branch mid-UR | +| Sibling REQ UI disappeared after next packages/admin merge | Second worker branch started from pre-first-UI tip or overwrote shared router/nav | After each packages/* shell merge, `git log -1 --name-only` + re-check prior leaf files exist; if missing, restore from the prior REQ commit before archive | +| `php artisan test` in worktree fails facade root / wrong classes | Symlinked `vendor` from main checkout | Prefer real `composer install` in worktree package (field lesson §7); treat symlink as smoke-only | + + +--- + +## 30. Laravel monorepo worktree needs `.env` as well as vendor + +| Symptom | Likely cause | Default action | +|---------|--------------|----------------| +| Pest tests WARN `file_get_contents(.../.env): Failed to open stream` after vendor install | Worktree has `composer install` but no `.env` / APP_KEY | Copy main checkout `packages/server/.env` (or `.env.example` + `key:generate`) into the worktree package before `php artisan test` | +| Symlinked vendor still boots wrong tree | ClassLoader base path | Prefer real `composer install` in worktree package (see §7); still pair with worktree-local `.env` | + +Portable rule: provision monorepo PHP worktrees with **autoload + app env**, not vendor alone. + +--- + +## 31. Privileged MCP mount isolation + tools/list pagination + +| Symptom | Likely cause | Default action | +|---------|--------------|----------------| +| Product MCP parity tests fail after adding a system/privileged profile | Privileged profile was folded into `surfaces.mcp.profiles` (auto-register product mounts) | Keep product profiles list product-only; mount privileged path via a **sibling config key** (e.g. `system_mount`) + dedicated registrar after product boot | +| System PAT can list product tools (or product PAT hits system path) | Only tool allowlists differ; auth middleware is shared | Dual middleware: product mounts reject pure privileged tokens; system mount requires privileged PAT + admin flag (stricter than SPA `*` HTTP admin if AC says “system PAT only”) | +| Feature test “lists every system tool” fails though registry has all tools | laravel/mcp **paginates** `tools/list` (`nextCursor`) when allowlist > page size | Follow `result.nextCursor` until empty before asserting full name set | + +Portable rule: privileged MCP = separate path + separate registrar + bidirectional token rejection; full-catalog assertions must paginate `tools/list`. + +--- + +## 32. Playwright `page.route('**/api/**')` breaks Vite SPA module loads + +| Symptom | Likely cause | Default action | +|---------|--------------|----------------| +| UI evidence: blank app / MIME error loading `src/api/*.ts` as `application/json` | Glob `**/api/**` matches **Vite source paths** (`/src/api/client.ts`) as well as HTTP `/api/...` | Match only request **pathname** with `pathname.startsWith('/api/')` (or host+path), never a bare `**/api/**` substring | +| `addInitScript` + real route works after fix | Session seed was fine; route was intercepting ESM | Keep field lesson §19 session+route pattern; tighten the URL predicate | + +Portable rule: in Vite monorepos, package folders named `api` live under `/src/api/` — Playwright network mocks must not treat those as backend routes. + +--- + +## 33. Parallel `--parallel N` underused on shared HTTP footprint + +| Symptom | Likely cause | Default action | +|---------|--------------|----------------| +| `--parallel 3` fills only 1–2 slots on a multi-CRUD admin UR | Every server leaf lists the same `routes/api.php` + `app/Http` + `tests` tree in `**Files:**` | Treat shared route/register files as exclusive: at most **one** in-flight server leaf; pair free slots with a **disjoint package** (e.g. admin SPA) | +| Path-unit residual blocks the whole package for a full worker | Path-unit `**Files:**` is broad (server + SPA) after children already Done | Residual-close with suite evidence + run-note (children Done) **before** leaf fan-out so pick order reaches implementable leaves | +| Claimable leaf already green on integration base | Prior residual / sibling landed domain+HTTP under a different issue id | Run the REQ’s verification filter first; if green and AC met, residual-archive — do not re-dispatch a full implement worker | + +Portable rule: parallel width is limited by **declared footprint intersection**, not by N. Capture should keep shared choke files honest; run should residual-close ready path-units and already-landed leaves quickly, then fill N with non-overlapping packages. + +--- + +## 34. Admin HTTP destroy ≠ product soft-end authority + +| Symptom | Likely cause | Default action | +|---------|--------------|----------------| +| Guidance says “reuse DestroyX where safe” but admin DELETE 403s / RuntimeException | Product destroy checks controller/owner role (`canControl`) or only soft-ends state | Keep **Admin\*::delete** as hard-delete + audit (mirror sibling AdminMachine/AdminUser pattern); document why product soft-end is unsafe for platform admin | +| Admin list payload missing parent names | Shared payload only emits parent public ids | Extend **SystemXPayload** once with `workspace_name` / host context fields the admin SPA already types | + +Portable rule: “reuse domain where safe” means role checks and lifecycle semantics must fit platform admin; when they don’t, extend the admin authority (audit + hard delete) rather than bypassing product access checks. + +--- + +## 35. Vue evidence mounts: runtime-only build rejects `template:` strings + +| Symptom | Likely cause | Default action | +|---------|--------------|----------------| +| Evidence page blank; console: "runtime compilation is not supported" / "alias vue to vue.esm-bundler.js" | Vite ships **runtime-only** Vue; `createApp({ template: '...' })` needs the full compiler build | Prefer **`h()` render functions** (or a real `.vue` SFC) for temporary evidence mounts — do not flip the whole app alias just for evidence | +| Playwright `wait-for-selector` times out on chip testid | App never mounted because of the template warning | Fix the mount first; re-screenshot only after DOM has the testid | + +Portable rule: temporary UI evidence under Vite + Vue must use SFC or `h()`, not inline `template` strings, unless the project already aliases `vue` → `vue/dist/vue.esm-bundler.js`. + +--- + +## 36. SPA UI evidence: mock capability + broadcasting paths (not only `/api/`) + +| Symptom | Likely cause | Default action | +|---------|--------------|----------------| +| Seeded auth + `/api/*` mocks still bounce to login | TerminalView / Echo call **`/capabilities/*`** or **`/broadcasting/auth`**; Vite proxies those to Laravel → **401** → `setUnauthorizedHandler` clears session | Intercept with `pathname.startsWith('/api/') \|\| …('/capabilities') \|\| …('/broadcasting') \|\| …('/sanctum')` and fulfill JSON; keep field lesson §24 (never `**/api/**`) | +| Board/grid mounts then evaporates mid-screenshot | Race: panes load OK, then snapshot capability 401 fires logout | Mock capability invoke success (`{ ok: true, data: … }`) before navigation settles | + +Portable rule: auth-gated SPA evidence must mock **every same-origin backend prefix the shell uses**, not only `/api/`. + +--- + +## 37. Overlay shell roots need a real layout box (not only fixed children) + +| Symptom | Likely cause | Default action | +|---------|--------------|----------------| +| Playwright: `waitForSelector('[data-testid=shell]')` times out with “resolved to hidden” though DOM has `data-open=true` | Root node has **zero layout size** — only children use `position: fixed`; Playwright treats zero-box elements as hidden | Give the shell root `position: fixed; inset: 0` (and put backdrop/panel as absolute children); keep `pointer-events: none` on the root with `auto` on interactive children | +| Drawer works visually in a browser but automated ui step fails | Manual look uses painted fixed children; visibility API does not | Same fix — root must own the viewport box for evidence and a11y hit-testing consistency | + +Portable rule: for overlay/drawer chrome, the **test-id root** must have a non-zero box, not only absolutely/fixed descendants. + +--- + +## 38. Limit-recovery UI must not disable the trigger + +| Symptom | Likely cause | Default action | +|---------|--------------|----------------| +| Product wants a “drop/free a slot then continue” modal at plan limit, but users never see it | Primary action is `disabled` when `at_limit` (tooltip only) | Keep the action **enabled**; open the recovery modal on click (and still handle API 422 if client usage is stale) | +| Modal lists nothing useful | Candidates filtered to wrong set (e.g. same machine only) | Prefer workspace-wide concurrent-quota consumers the user can free | + +Portable rule: if the happy path at limit is a recovery dialog, **do not** hard-disable the control that starts that path; use busy/permission disables only. + +--- + +## 39. Linear claim heartbeat: update existing comment only + +| Symptom | Likely cause | Default action | +|---------|--------------|----------------| +| Two `` top-level comments on one Issue | Worker used `save_comment` **create** for heartbeat instead of patching the claim id | **Always** `list_comments` → find claim body with matching `agent_id` / marker → `save_comment` with that comment `id`. Never post a second claim root | +| Stale-slot / claim protocol confused | Dual claim stamps | Delete accidental duplicate claim comments immediately after noticing | + +Portable rule: claim protocol is one active claim comment per Issue; heartbeat is an in-place edit of that comment’s `heartbeat:` line. + +--- + +## 40. Live terminal streams: convertEol + mid-cut wire garbage + +| Symptom | Likely cause | Default action | +|---------|--------------|----------------| +| SPA paints solid black mid-line blocks (U+2588) / U+FFFD; TUI option lists unreadable | Server `substr(-$max)` starts mid-UTF-8 or half-CSI; paint path appends corrupt intermediate; mis-flagged redraw deltas concat two full screens | **(1)** Shared trailing bound that drops leading UTF-8 continuations + orphan CSI before wire/store. **(2)** Client paint sanitize for U+2588 runs + U+FFFD only (not blanket ANSI strip). **(3)** Agent Diff: non-prefix → `is_full`. Client safety net: **clear-screen only** (`ESC[2J` / `ESC[3J`) → replace buffer + `paintFull` | +| **All panes** staircase / diagonal text / sparse dots after a “TUI formatting” fix | Worker set xterm **`convertEol: false`** because capture has CSI | **Keep `convertEol: true`**. capture-pane `-p` emits LF without CR; convertEol maps LF→CRLF so progressive shells start at column 0. Disabling it breaks *every* non-TUI stream. Do **not** “fix” TUI by turning convertEol off | +| Blank / wiped panes after TUI “safety net” | Client treated bare **`ESC[H`** (cursor home) as full-screen redraw → `paintFull`/reset on mid-frame updates | Full-screen detect = **clear only** (`2J`/`3J`). Bare cursor-home is normal mid-TUI traffic — append, do not reset | +| “Fix” by stripping all ANSI | Kills legitimate color/cursor on healthy TUIs | Never strip SGR/cursor as the primary fix; only remove corrupt paint artifacts | + +Portable rule: diagnose agent Diff → server bound → SPA paint **end-to-end**. Prefer bound + sanitize + clear-screen replace. **Never** set `convertEol: false` for capture-pane LF streams. Do not add cols/rows negotiation unless width mismatch is proven; do not strip all ANSI. + +--- + +## 41. Layout thrash: RO on self-mutating host + compact stuck on expand + +| Symptom | Likely cause | Default action | +|---------|--------------|----------------| +| Expand/resize: high-rate flicker + growing empty band under terminal | ResizeObserver watches the host that `applyHostLayout` / fit mutates → re-entry loop | Observe **stable parent** (clip box); skip refit when paint box same-size (epsilon); force refit only on real mode/layout change | +| Expand still thrash after thrash-guards | Shell hard-codes `compact` on every cell including expanded | Expanded mount = **non-compact** (full) fit; grid tiles stay compact; compact prop watch re-fits without buffer reset | +| convertEol “fix” for TUI | Turning `convertEol: false` to calm CSI | Never — see §30; thrash is layout/RO, not convertEol | + +Portable rule: **do not RO-observe an element your fit path mutates**. Mode flips (compact/full) force one refit; size-stable frames no-op. + +--- + +## 42. Hard-rename residual specs must not embed forbidden tokens + +| Symptom | Likely cause | Default action | +|---------|--------------|----------------| +| Residual `rg 'OldName\|/old/'` fails only on the residual test file itself | Audit file lists forbidden patterns as contiguous string literals | Build patterns from **parts** (`re('Old', 'Name')` / `parts.join('')`) so the audit source never contains the product token as a continuous match | +| Verification allows “intentional historical comments” only | Negative route-guard tests still need the old path string | Keep legacy guards in **one** file (e.g. router.spec); residual suite skips that file; product sources must be clean | + +Portable rule: residual greps are part of acceptance — residual **tests** must not be the only residual hits. + +--- + +## 43. Evidence mounts: register named routes used by child RouterLinks + +| Symptom | Likely cause | Default action | +|---------|--------------|----------------| +| Temp evidence page throws `No match for { name: '…' }`; shell never mounts | Evidence app uses `createMemoryHistory` with only `/`; child components render `RouterLink :to="{ name: 'machines' }"` (etc.) | Register every **named** route the tree resolves at setup time — even stub components | +| Shell root selector times out after PAGEERROR | Setup aborted mid-tree | Fix router first; re-check console pageerror before waiting on testids | + +Portable rule: evidence routers must satisfy child `RouterLink` / `router.push` name lookups, not only the top-level path under test. + +--- + +## 44. Dual-loop Registry: Start injects must register Lookup targets + +| Symptom | Likely cause | Default action | +|---------|--------------|----------------| +| Stream open (Start after create) but send_keys uses payload/stale local_key | `StartFn` override returned without writing cancel-map entry; `Lookup` miss | Mirror `StartDesiredFn`: always register `{cancel, localKey}` (host optional) before/with inject | +| First heartbeat Converge thrash-restarts streamer after create_pane Start | Start left `host==""`; Converge treated host change as replace | If `localKey` matches and entry host empty, **fill host** — do not cancel/restart | +| Missing id sentinel | Int zero vs empty string after ULID hard-cut | Empty string not zero for missing pane id (see §15) | + +Portable rule: inject hooks own **side effects only**; the supervisor map (streamers, connections) is still owned by the shared Registry so write-path Lookup and lifecycle Converge stay consistent. + +--- + +## 45. Dual-gate terminal input + capability unwrap must not silent-succeed + +| Symptom | Likely cause | Default action | +|---------|--------------|----------------| +| Role chip shows controlling but typing does nothing (no error) | `onData` gated only on permission (`canControl`) while board Focus / `keyboardActive` is a second gate; handlers half-bound when ownership flips | Single `inputEnabled = canControl && keyboardActive`; bind **and** dispose `onData` on that flag; wire flush uses the same predicate | +| Invoke returns 200 `{ ok: false, message }` and UI treats success | Unwrap only threw when `data` was present | **Always throw on `ok === false`** before reading `data`; surface humanized error on the action chrome with a testid | +| Evidence blank / keys not asserted | Stream path “fixed” by flipping convertEol | Do not touch stream paint for key-path REQs; keep `convertEol: true` (see §30) | + +Portable rule: dual ownership gates (permission + focus) must share one enable flag for bind/send/dispose; capability envelopes never resolve `ok: false` as success. + +--- + +## 46. Board / streaming list UI evidence: use product streaming states + +| Symptom | Likely cause | Default action | +|---------|--------------|----------------| +| Seeded panes load but board shows empty (“0 streaming”) | List filter uses product **streaming** states (`active` / `idle` / `paused` / `waiting_for_input`), not labels like `running` | Mock pane `state` from the product streaming set before screenshotting board/grid chrome | +| Cell chrome never mounts for evidence | Same | Assert board-cell testid after mock; do not screenshot empty-state as chrome proof | + +Portable rule: when a view partitions list rows by lifecycle state, evidence fixtures must use those exact state tokens — not colloquial synonyms. + +--- + +## 47. Capability Result `array` ≠ JSON object map + +| Symptom | Likely cause | Default action | +|---------|--------------|----------------| +| Cap invoke / MCP returns `output_invalid` / “unexpected format” though `run()` works in unit tests | Result DTO property typed PHP `array` → JSON Schema **`type: array` (list only)**; payload is an associative map (JSON **object**) | Type maps as nested SchemaProvider (`#[Field(of: …)]`) or override schema to `object` / `object\|null`; never rely on bare `array` for key-value payloads | +| Same after first agent heartbeat, null metrics worked | Null passes list-or-null; non-empty metrics map fails list check | Contract-test **both** empty/null **and** object snapshot through **OutputValidator**, not only direct `run()` | +| Nested Carbon / models in map | `exportValue` does not stringify Carbon | Wire-safe payload helper: ISO strings (or null) for all date fields before Result construction | + +Portable rule: **PHP `array` in CapabilityData = JSON list**. Associative domain maps need object schemas + JSON-safe scalars. Prove with pipeline tests that call OutputValidator. + +--- + +## 48. Shell generators for CI must run on bash 3.2 + +| Symptom | Likely cause | Default action | +|---------|--------------|----------------| +| Fixture test fails on macOS with `declare: -A: invalid option` | Script used bash 4+ associative arrays; `/bin/bash` on macOS is 3.2 | Parse checksums with a linear scan/`lookup` function; avoid `declare -A`, mapfile, and other bash-4 features | +| Works in CI (ubuntu bash 5) but fails locally | Same | Author + fixture-test with `#!/usr/bin/env bash` under macOS default bash before merge | + +Portable rule: monorepo release/helper scripts maintainers run on Mac must be bash 3.2-clean; prove with a committed fixture test, not only Linux CI. + + +--- + +## 49. Sanctum logout tests: forgetGuards after token delete + +| Symptom | Likely cause | Default action | +|---------|--------------|----------------| +| Feature test: token row deleted but next `$this->withToken($plain)->getJson(...)` still **200** | Same PHP process keeps auth guards resolved; Sanctum may reuse the already-authenticated user without re-looking up the deleted PAT | Call `Auth::forgetGuards()` before the post-logout request that asserts **401** | +| Production still correct | One request per PHP-FPM worker | Do **not** change product logout for the test; only clear guards in the test | + +Portable rule: when asserting “revoked Bearer is unusable” in Laravel HTTP tests, clear guards between the revoke call and the re-auth attempt (and still assert the token row is gone). + +--- + +## 50. SPA logout API + unauthorized handler re-entry + +| Symptom | Likely cause | Default action | +|---------|--------------|----------------| +| `logout()` POSTs `/api/logout`, 401/403 fires `setUnauthorizedHandler` → calls `logout()` again; boolean `loggingOut` early-return skips `clearLocalSession` for awaiters | Re-entrancy flag returns without joining the in-flight promise | Share **one** `logoutPromise`; concurrent callers `return logoutPromise` so they await the same finally-clear | +| Local session stuck “logged in” after failed server revoke | Clear only on success | Always clear local storage in `finally` after best-effort POST (even network failure) | +| Unit tests assert no Authorization header | Token only on `tokenGetter`; store not wired | Pass `token: bearer` explicitly into `apiRequest` for logout/revoke paths | + +Portable rule: intentional SPA sign-out = best-effort server revoke **then always local clear**; re-entry must join the shared promise, not a bare boolean skip. + +--- + +## 51. Laravel `lockForUpdate` under sqlite Pest — prove atomicity, not SQL + +| Symptom | Likely cause | Default action | +|---------|--------------|----------------| +| `DB::listen` never sees `for update` though code calls `lockForUpdate()` | `SQLiteGrammar::compileLock` is a no-op; phpunit often uses `:memory:` sqlite | Do **not** assert lock SQL on sqlite. Prove concurrent-safe intent with (1) sequential double-complete fails closed, (2) forced mid-write failure (model `updating` hook) so `token_hash`/`completed_at` (or equivalent pair) both roll back only when wrapped in `DB::transaction` | +| True multi-connection race | Needs MySQL/Postgres | Optional MySQL feature only when AC requires; default suite stays sqlite-friendly | + +Portable rule: for transactional + row-lock domain writes, acceptance on sqlite = **atomicity rollback + sequential double-op**; document that production MySQL/Postgres gets real `FOR UPDATE`. + +--- + +## 52. Sanctum feature tests after logout need guard reset + +| Symptom | Likely cause | Default action | +|---------|--------------|----------------| +| Second request with deleted token still 200 / same user | `Auth::` / Sanctum guard caches the user from the first actingAs/Bearer call | Call `Auth::forgetGuards()` (or fresh HTTP client) between logout and the 401 assert | +| revoke-all tests flaky with multiple tokens | Same | Resolve `currentAccessToken()` once; assert token row deleted via DB, not only response | + +Portable rule: after deleting the current PAT in a Pest feature test, **forget guards** before the next `$this->withToken(...)->getJson` so 401 is real. + +--- + +## 53. SPA logout: single-flight + always clear local + +| Symptom | Likely cause | Default action | +|---------|--------------|----------------| +| Double Sign out races two POSTs / leaves token | `logout()` fire-and-forget without shared promise | Share one in-flight logout promise; await from nav + 401 handler | +| Network fail leaves stolen token usable | Clear only after 2xx | `try { POST /api/logout } finally { clear localStorage }` | + +Portable rule: server revoke is best-effort; **local clear is mandatory**. + +--- + +## 54. Local-echo compose hold vs host slash/skill palettes + +| Symptom | Likely cause | Default action | +|---------|--------------|----------------| +| After `/`, local chars show but host skill/command list never filters | Compose mode holds host paints until Enter; first `/` clears hold once, then later printables re-assert local-echo hold; overlay heuristic misses small filter rewrites | Enter sticky **slash mode** on `/` that keeps painting host frames through filter keys; exit on Enter (skill run). Keep normal compose hold for non-slash lines | +| Overlay detection only fires on big menus | `+N non-empty lines` / large byte delta thresholds | Treat in-slash-mode frames as paint-through even for small deltas; unit-test filter rewrites | +| Fix by always painting host while typing | Drops local-echo contract; mid-type lag corruption returns | Dual policy: slash mode live host; non-slash hold until after Enter | + +Portable rule: **host-drawn progressive UIs (slash palettes, completion menus) need sticky host-paint mode**, not a one-shot release on the first special key. + +--- + +## 55. Email-verify gates need grandfathering + resend throttle + client signal + +| Symptom | Likely cause | Default action | +|---------|--------------|----------------| +| Shipping `MustVerifyEmail` + invite/billing gates locks out every existing account | Historical signups have `email_verified_at` null; no migration | Deploy migration: set `email_verified_at = COALESCE(created_at, now())` for existing nulls; new signups stay unverified until link | +| Resend endpoint mail-bombs | Only `auth:sanctum` on verification resend | `throttle:6,1` (Breeze default) on resend route | +| SPA cannot prompt verify after 403 | Login/register payload omits verification state | Emit `email_verified_at` (ISO or null) on auth payloads / shared AuthUserPayload | +| SPA still drops the chip at runtime | Client `AuthUser` type / cast omits field; no normalize on persist | **Also** type `email_verified_at`, normalize on load/persist (null for legacy localStorage); tests need valid public-id fixtures if normalize calls `parsePublicId` | + +Portable rule: when a new high-risk gate depends on a previously unused verification flag, **grandfather existing rows**, rate-limit resend, and **surface the flag on login** so clients can recover without opaque 403s. + +--- + +## 56. Capacitor WebView CORS — always-merge shell origins + +| Symptom | Likely cause | Default action | +|---------|--------------|----------------| +| Native shell login preflight fails though SPA uses Bearer tokens | CORS allowlist only lists Vite/Herd hosts; WebView Origin is `capacitor://localhost` or `https://localhost` | Always **merge** fixed Capacitor origins into `cors.allowed_origins` (env alone replaces defaults and drops them) | +| Operator “fixed” defaults in cors.php but prod/local still broken | `CORS_ALLOWED_ORIGINS` env fully overrides the default string | Prefer code-level merge of shell origins; document livepane.io / admin still in env for browser SPA | +| Touched `SANCTUM_STATEFUL_DOMAINS` for Capacitor | Assumed cookie SPA | Token-only + no `statefulApi()` → document Bearer path; CORS is the real gate | + +Portable rule: for Bearer SPA shells, **CORS origin allowlist** is the native blocker; **always-merge** fixed WebView origins rather than relying on env defaults alone. + + +--- + +## 57. Worktree teardown can poison main package autoload + +| Symptom | Likely cause | Default action | +|---------|--------------|----------------| +| Main `php artisan test` fails with class not found under `.worktrees/req-…` path after workers finish | Composer ClassLoader map still points at a removed worktree (symlink vendor / path repo dump during worktree install) | After Stage B teardown: `composer dump-autoload` (or reinstall) in the **main** package root before residual suites | +| `cap:sync` fails `Unable to find node_modules/@capacitor/android` on integration branch after package.json merge | Lock/deps merged but main checkout never ran `npm ci` | Run `npm ci` in `packages/web` on the integration base before residual `cap:sync` / path-unit close | + +Portable rule: parallel worktree merges land files; **main** still needs a fresh autoload/deps install before integration residual gates. + +--- + +## 58. Multi-server agent: Registry **per server**, not one shared process map + +| Symptom | Likely cause | Default action | +|---------|--------------|----------------| +| desired_streams / create_pane from server A hit server B’s token or cancel map | One process-wide stream Registry + client shared across multi-server stacks | **One client + Registry per ServerEntry**; share SessionHost plugins only (local discovery) | +| One control plane 5xx kills the whole agent | Main `errCh` exits on first loop error; heartbeat treats initial fail as fatal | Per-stack log-and-retry; root ctx cancel only on signal; initial heartbeat error is non-fatal | +| Dual-loop “share one Registry” lesson applied across servers | §11 meant share within one server (heartbeat Converge + command Start) | Keep §11 **within** a stack; multi-server multiplies full stacks | + +Portable rule: multi-endpoint agents = N independent control stacks (client + heartbeat + commands + Registry). Dual-loop sharing is **intra-server**; isolation is **inter-server**. + +--- + +## 59. Multi-server agent fan-out: Registry per endpoint + name-keyed claim + +| Symptom | Likely cause | Default action | +|---------|--------------|----------------| +| One control plane's 5xx kills the whole agent | Single shared errCh / first-fatal exit across servers | Per-server stack (client + heartbeat + commands + stream Registry); root cancel only on OS signal; remote errors retry in that stack only | +| Claim overwrites production when adding staging | Single-object config Save | Multi-server `Servers[]`; `UpsertServer` by free-form name; legacy single-object auto-migrates on Load | +| Parallel claim-merge + run-loop REQs conflict | Both list `cmd/.../main.go` | Serial those leaves; pair only disjoint packages in the same wave | + +Portable rule: **N endpoints → N isolated supervisor stacks**; identity of a stack is config **name**, not a fixed enum of env keys. + +--- + +## 60. Fail-closed quota earlier breaks “create then enforce on accept” fixtures + +| Symptom | Likely cause | Default action | +|---------|--------------|----------------| +| Happy-path invite/create tests fail after adding assert-at-create | Free plan already at limit (owner fills max seats=1); fixtures still assume free can create pending rows | Happy-path fixtures need **seat room** (paid plan / higher limit). At-limit unit asserts `QuotaExceeded` on **create** | +| “Accept still enforces” tests die at invite step | Same — create now fail-closes | For defense-in-depth accept tests: **create under room**, then **downgrade / fill seats**, then accept; or insert the pending row directly | +| Feature cap invoke on free org `assertOk` fails with `domain_error` | Cap surfaces domain `QuotaExceeded` as `domain_error` | Assert `ok=false` + `domain_error` at create; keep a separate pro→free accept 422 path | + +Portable rule: when moving quota fail-closed earlier in a multi-step flow, rewrite fixtures into (1) room for happy path, (2) at-limit on the new gate, (3) downgrade/fill for residual later-gate defense-in-depth. + +--- + +## 61. At-limit list cards: free-slot primary chrome, Upgrade secondary + +| Symptom | Likely cause | Default action | +|---------|--------------|----------------| +| Sessions at quota; Upgrade banner dominates; card End/Stop stay ghost outline | Free-slot recovery is demoted visually to secondary chrome | At `at_limit`, style free-slot actions (**End session** / **Stop watching**) as **primary** filled buttons; keep **Upgrade** outline secondary on the card | +| One label for both destroy and soft-unwatch | Shared “End” / “Drop” copy | Distinct labels: **DISCOVERED** concurrent → Stop watching; controller live (created) → End session | +| Free-slot control disabled when at limit | `disabled = busy \|\| at_limit` | Busy-only disable (see §27) | + +Portable rule: when the product recovery path at a cap is free-a-slot, **list free-slot controls lead the card** (primary + enabled); billing Upgrade is optional secondary, never the only visible path. + +--- + +## 62. Partial-confidence leaves sink verify without blocking shippable UX + +| Symptom | Likely cause | Default action | +|---------|--------------|----------------| +| `go` stops at ~85% with full brief coverage (10/10 ranks) | Capture marked legal/ops/deep-integration leaves `partial`; each unacknowledged partial is −3 (cap −15) | Before go: either upgrade Integration to **high**, or put those ids in **acknowledged_partials** when partial is intentional (approval-gated / env-unknown) | +| Operator re-captures to chase score | Partials are not coverage gaps | Do **not** invent REQs — score math is partial-conf only | +| Force-run of a large multi-rank UR | Threshold gate is soft under `--force` | Prefer **force** (or acknowledge) then ship high-priority UX leaves first; residual-close path-units as children Done; leave ops/legal leaves for later waves | + +Portable rule: **partial ≠ missing**. Approval-gated workstreams may stay partial forever; acknowledge them so go can auto-run, or use `--force` and pick implementable leaves by footprint. + +--- + +## 63. Ops TLS/SNI already green on an ops REQ + +| Symptom | Likely cause | Default action | +|---------|--------------|----------------| +| AC is `curl -fsSIL https://host/` → 200 but journey report still said SSL broken | Cert issued after capture; LE Active between intake and worker | Re-probe runtime first; if green, **document repair + package a smoke script** — do not invent a failure or mutate Forge SSL from the worker | +| Deep links 200 but wrong body (home shell) | Separate from TLS: SPA `try_files` / stale deploy without postbuild mirrors | Keep content checks **optional** (`VERIFY_CONTENT=1`) unless AC requires titles/H1; note redeploy as residual, not `verification-failing` when AC is TLS-only | +| Worker wants to `forge deploy` to finish content residual | Deploy gate non-delegable | Document § nginx + postbuild; leave deploy to human/orchestrator | + +Portable rule: for ops SSL REQs, **TLS probe is the AC**; process docs + smoke are the in-repo deliverable when production is already green. + +--- + +## 64. Public release-only repo vs private monorepo install URLs + +| Symptom | Likely cause | Default action | +|---------|--------------|----------------| +| Docs still advertise `raw.githubusercontent.com//…/install.sh` for anonymous install | Ship status was “assets may be private”; later only a release-only public repo went live | **Prove with `curl -fsSIL` (no auth)** against the public asset host first. Point brew + curl install at the **public release repo**; document monorepo private residual as expected 404 | +| curl\|bash 404 though tarballs are public | Install script only lives in private monorepo tree | Attach install script as GoReleaser `extra_files` (and bootstrap current tag with `gh release upload` when needed) | +| Probe script reports 404 on browser download URL | Sent `Accept: application/vnd.github+json` on github.com/raw URLs | Use API Accept only on `api.github.com`; bare follow-redirects for asset URLs | + +Portable rule: **anonymous install surface = public release host + public tap only**. Never teach monorepo raw URLs as the primary path when source stays private. + +--- + +## 65. Policy `blocked_paths: .env.*` catches `.env.example` + +| Symptom | Likely cause | Default action | +|---------|--------------|----------------| +| `check-policy` exit 1 `blocked_path: …/.env.example matches .env.*` though no secrets | Config glob treats example env templates as blocked | Prefer documenting new env keys in `config/*.php` defaults + docs; if example must change, **exclude** `.env.example` from the worker commit before Stage B, or narrow `security.blocked_paths` to exact `.env` / `.env.local` (project config) | +| Reviewer passes; archive still blocked | Orchestrator treats exit 1 as hard block | Fix branch before `archive_req` — never skip policy | + +Portable rule: Laravel monorepos almost always commit `.env.example`; token-boundary blocked_paths should not use a bare `.env.*` glob unless the project truly forbids example files. + +--- + +## 66. Stage B merge blocked by native-shell rename + symlinks on integration tree + +| Symptom | Likely cause | Default action | +|---------|--------------|----------------| +| `git merge` fails: `…/App.xcodeproj/project.pbxproj is beyond a symbolic link` / `Cannot save the current worktree state` | Concurrent Capacitor/iOS WIP on the integration checkout: tracked rename to `LivePane.*` plus `App.*` → symlink; git autostash cannot walk the path | Before Stage B: isolate foreign WIP (`stash` or move aside + `git checkout HEAD -- packages/web/ios`), merge worker branches, then restore WIP | +| Autostash keeps firing even with clean feature branches | Dirty index still holds the rename/symlink hybrid | Reset only the package path that is not in the REQ footprint — do not discard unrelated operator work without a side path (`/tmp/…` or named stash) | + +Portable rule: **integration-base dirt from mobile/native renames blocks every merge**, not only iOS REQs. Orchestrator Stage B assumes a mergeable index; clear symlink-rename hybrids first. + +--- + +## 67. Host inventory fields vs user-owned display labels + +| Symptom | Likely cause | Default action | +|---------|--------------|----------------| +| User renames a discovered/list item; next heartbeat restores host name (`42`, workspace id, …) | Reconcile/adopt **always writes** host `title` (or equivalent) onto the same column the UI shows | Split **host label** vs **user display**: set a `*_user_set` (or `custom_*`) flag on user edit; adopt/reconcile updates host fields but **never** overwrites display when the flag is set | +| List shows meaningless host ids (digits, opaque keys) as the primary name | UI prefers host session title over cwd/path/context | Display order: **user title if set → path/cwd (or other location) → host title → command → Untitled**; unit-test the helper | +| Path default “works in UI” but cards stay numeric | Agent inventory never sends cwd/path; only host title | Agent layer: report location (e.g. pane current path) on discovery rows; server stores it; SPA reads it | + +Portable rule: **discovery reconcile owns host facts; user rename owns display identity**. One column for both without a user-set guard will thrash. + +--- + +## 68. List-card identity UX — grill forks before capture + +For briefs that rework **list/card identity** (name placement, chips, rename, default label), ideate gaps that **change the REQ graph** (not polish): + +| Fork | Why it changes capture | +|------|------------------------| +| Who may rename (controller / owner / any viewer) | Authz domain + HTTP 403 cases | +| Default label source (host title vs path/cwd vs always-path-until-rename) | Display helper + agent inventory + server store | +| Edit gesture (pencil always-on vs click vs long-press mobile) | Component interaction tests + stop-propagation vs open-card | +| Surfaces (one list vs every card that shows the name) | Footprint: one shared helper + N mounts, or path-unit residual | + +Self-answer pass may infer “no product PATCH yet” from routes — still **ask** permission + default + gesture; those are product calls, not codebase facts. + +Portable rule: card-identity work is usually **server (user-set + API) + agent (location) + web (layout + edit)** under one path-unit; grill the four forks before splitting leaves. + +--- + +## 69. Harness worktree path ≠ project `req/*` branch for Stage B + +| Symptom | Likely cause | Default action | +|---------|--------------|----------------| +| Worker reports `done` + commit, but orchestrator looks under `{project}/.worktrees/req-…` and finds nothing / wrong tip | Host spawn with `isolation: worktree` puts the child under a **harness path** (e.g. `~/.grok/worktrees/…/subagent-`) while do-work still creates/uses **`req/`** on the shared repo | Stage B: resolve **`git log new-work..req/`** (or Linear sanitize id) and **`git merge --no-ff req/`** from the **recorded integration base**. Do not require the harness worktree path | +| Two worktree trees for one REQ | Worker also ran `git worktree add .worktrees/req-…` per run-worker.md | Teardown both after archive; prune; `git branch -d` only after no worktree holds the branch | +| Heartbeat/claim still on Linear | Correct — claim is not filesystem under Linear | Archive via Linear; git isolation is local | + +Portable rule: **feature branch name is the merge handle**; harness worktree paths are ephemeral execution sandboxes. Always assert `git branch --show-current` equals the go/run integration base before merge (see §18). + +--- + +## 70. Integration-base dirt: partial same-feature WIP blocks Stage B + +| Symptom | Likely cause | Default action | +|---------|--------------|----------------| +| `git merge` aborts: local changes to `sessionList.ts` / API client would be overwritten | Main/integration checkout has **incomplete** uncommitted edits of the **same** files the worker branch completed | Prefer the **worker branch as truth**. `git checkout -- ` or stash **only those paths**, merge, then drop the stash if it was half-done sibling WIP — do not merge on top of dirty same-path dirt | +| After `git stash` HEAD is a foreign branch | Stash/checkout in a multi-worktree monorepo switched the main shell off `new-work` | Immediately re-assert integration base (`git checkout `); never Stage B from a surprise branch (see §18) | +| Partial WIP looked “helpful” | Prior agent or human started the same REQ on the integration tree | Integration base should stay merge-clean; implementation lives on `req/*` only | + +Portable rule: **dirty same-footprint files on the integration tip are not a second feature branch** — clear them, merge the REQ branch, re-run package filters. + +--- + +## 71. Audit footprint to unlock package-parallel leaves + +| Symptom | Likely cause | Default action | +|---------|--------------|----------------| +| `--parallel N` still serialises agent + server though packages differ | Agent REQ `**Files:**` includes `packages/server/app/Domain/Sessions` “for cwd persist” while a server leaf already owns Adopt/Reconcile | At **audit** (or capture): put **wire/inventory** on agent footprint only; put **persist/API/flag** on server; AC text must match ownership so workers do not both edit server | +| Path-unit still lists every package | Residual-close after children Done — do not re-implement | §25 residual-close first | + +Portable rule: **AC ownership drives footprint**. If two leaves share a package path only because of a cross-layer note, move the note to Integration/Depends and keep `**Files:**` to the package that will commit changes — that is what pick/overlap uses. + +--- + +## 72. Capacitor iOS: env(safe-area-inset-*) = 0 under edge-to-edge WebView + +| Symptom | Likely cause | Default action | +|---------|--------------|----------------| +| SPA content draws under the iOS status bar / Dynamic Island though `viewport-fit=cover` and `--safe-area-top: env(...)` exist | Cap WKWebView paints full-bleed but reports `env(safe-area-inset-top)=0` (token cascade is fine; the env value is the lie) | Floor insets on native iOS only: `html.native-ios { --safe-area-*: max(env(...), Npx) }` toggled from a tiny pure probe (globalThis.Capacitor — **no** StatusBar-plugin-only). Keep real env when non-zero | +| Login/register padded with direct env() “look fine” while authenticated shell still clips | Shell uses CSS vars that resolve to 0; auth views use the same env and also get 0 — or shell padding is escaped by fixed chrome | Apply the floor to the **shared tokens** so shell, sticky top, fixed bottom-nav, toasts all inherit; dual-write env() then `var(--safe-area-*)` on `.app-shell` so custom-prop capture is not the only path | +| Desktop / Android over-padded after the floor | Root class applied on every platform | Gate class with `isNative && platform === 'ios'` only; desktop media queries stay untouched | +| UI evidence “passes” without proving the floor | Headless env is always 0 | Mock `html.native-ios` + force tokens to 0, overlay a status-bar band, vision-assert title/app-top **below** the band (screenshot under UR ui-evidence) | + +Portable rule: **CSS env alone is not enough when Cap lies with 0** — keep viewport-fit=cover, prefer CSS over StatusBar-only, and floor shared safe-area tokens on native iOS so shell chrome cannot paint under system bands. + + +--- + +## 73. Fluid presence lists — grill holding pen + revive-on-return + +For products where **host resources come and go** (discovered sessions, agents, devices) and the UI buckets live vs available: + +| Fork | Why it changes capture | +|------|------------------------| +| Holding pen vs vanish | SPA often **omits** terminal states; product may need a third group so churn is visible | +| Membership (which states) | `gone` only vs completed/all non-live — changes partition + AC | +| Retention window + owner | SPA filter on `updated_at` vs server purge vs no window — changes server REQs | +| Temporary return | Inventory reappear must **revive** same identity (not skip terminal + insert duplicate) | +| Deliberate unwatch vs miss | Durable exclude ≠ temporary inventory gap — separate paths in AC | +| Surfaces | List-only vs list+nav vs board — footprint split | + +| Symptom | Likely cause | Default action | +|---------|--------------|----------------| +| Terminal state “disappears” from UI | Partition only keeps live buckets | Add holding-pen bucket; unit-test partition | +| Host returns but list shows two rows or stays terminal | Adopt/match query **excludes** terminal rows | Revive non-excluded terminal rows on re-inventory; never create a second live row for the same host key | +| “Unavailable forever” after brief miss | Soft-unwatch/exclude conflated with inventory miss | Mark miss as terminal **without** durable exclude; exclude only on explicit user unwatch | +| Partition has holding pen but list/nav still two-bucket | Wire incomplete: only `partition` unit green | Same REQ: (1) list third group + testids, (2) prev/next ordered ids append holding pen after available, (3) empty-state when **all** buckets empty, (4) board/grid stays live-only. Holding-pen rows: identity chrome, **no** primary free-slot / Watch, prefer non-openable | + +Portable rule: **fluid lists need three contracts** — (1) which states enter the holding pen, (2) how long they stay visible, (3) revive-on-return without duplicate identity. Grill those before splitting server/web leaves; agent inventory often already emits presence. **UI wire is a fourth surface** after partition exists: list group + nav order + empty gate + board exclusion. + +--- + +## 74. Capacitor Stream off — bake Reverb + native loopback rewrite + +| Symptom | Likely cause | Default action | +|---------|--------------|----------------| +| Header **Stream off** on device after login while REST works | `cap:sync` / prod native build only bakes API URL; `VITE_REVERB_*` stays loopback (`127.0.0.1:8080`) | Bake `VITE_REVERB_HOST` / `PORT` / `SCHEME` / `APP_KEY` in the same script as `VITE_API_URL` (match browser deploy contract, e.g. host:443 https) | +| Bake fixed but chip still off | `resolveReverbConnection` only rewrites loopback for **https:** pages; Cap origin is `capacitor://` / `ionic://` or `native=true` | Pure planner: **native or Cap protocol + loopback env → public WSS host** (forceTLS). CapacitorHttp does **not** patch WebSockets | +| Auth `/broadcasting/auth` 401 while WS host is correct | Separate from WS host — use absolute `apiBase()` bearer auth (already native-safe when API base is baked) | Do not “fix” Stream by only touching CORS; prove WSS host + key match server Reverb app | +| Local browser Herd/Vite still needs loopback | Over-eager native rewrite | Keep existing http-page + loopback behaviour; only rewrite when native/Cap context | +| Host/PORT/SCHEME baked but device still Stream off | `VITE_REVERB_APP_KEY` silently defaulted to `local-reverb-key` in cap:sync | **Fail closed** preflight on missing/empty/placeholder before native bake; require export of prod key (match API `REVERB_APP_KEY`). Never `:-local-reverb-key` for Cap dist | + +Portable rule: **native shell = bake public Reverb at build + planner treats Cap/native like a non-HTTPS mixed-content case.** REST CORS/API base green does not imply Echo/Reverb green. **App key is part of the bake contract** — fail closed rather than ship a local placeholder. + + +--- + +## 75. `check-policy` exit codes: 1 blocks, 2 only requires review + +| Symptom | Likely cause | Default action | +|---------|--------------|----------------| +| Orchestrator treats `check-policy` exit **2** as Stage B hard-stop | Confused with exit **1** (security block) | Exit map: **0** clear → continue; **1** blocked path/command → do **not** archive until fixed; **2** `review_required` (e.g. `files_changed_over`, `acceptance_criteria_over`, auth/billing risk tags) → **dispatch/pass post-build review**, then archive. Exit 2 is **not** a merge/archive veto once review returns `passed` | +| Large UI leaf (specs + views) always hits `files_changed_over: 8` | Risk threshold working as designed | Keep the threshold; ensure review notes the signal and still scopes the diff — do not raise the cap to silence exit 2 | + +Portable rule: **exit 1 = security stop; exit 2 = review mandatory context**. Never skip review on exit 2 when `review.required: true`; never treat exit 2 like a blocked path. + +--- + +## 76. Transport stream-off must not paint fatal session chrome + +| Symptom | Likely cause | Default action | +|---------|--------------|----------------| +| Session body shows fatal error empty state while header is **Stream off** and REST/agent healthy | WS `failed` / channel `subscribe_error` mapped to status **`error`** (red chip + error body) instead of recoverable **`offline`** | Pure status reducer: transport failures → **offline** (clear errorMessage); keep **error** only for hard identity/config (e.g. invalid public id) | +| Empty copy still reads like a dead-end / “Something went wrong” | Offline empty reused hard-error language or API humanize fallback | Offline empty = Stream on/off product language + reconnect; unit-test never matches `/something went wrong/i` for offline/connecting | +| Agent help missing when stream is off | Empty helper skips agent line on error-class paths | Stream off ≠ agent offline — still append agent online/hb help when known (see §10b) | +| Bake silent `local-reverb-key` (Cap Stream off forever) | cap:sync defaulted placeholder APP_KEY | Fail-closed preflight before native bake (missing/empty/placeholder); export prod key matching server `REVERB_APP_KEY` (see §58) | + +Portable rule: **transport down is recoverable stream chrome, not a session error page.** Align pane chip with header Stream off; reserve hard error for non-transport failures. Pair with fail-closed native Reverb key bake when Cap shells are in scope. + +--- + +## 77. Monorepo brief packages vs `layers:` + Linear `Layer/*` labels + +| Symptom | Likely cause | Default action | +|---------|--------------|----------------| +| Brief names a package (`admin`) but capture only tags `server`/`agent`/`web` | `.do-work/config.yml` `layers:` lag the monorepo packages table | At intake/capture for multi-package briefs: **expand `layers:`** (or record an explicit layer decision) so undeclared packages are not silently out of scope | +| `save_issue` fails: `Could not resolve label(s): "Layer/admin"` | Label never created on the team; body `**Layer:** admin` alone is not enough for Linear | **Create** `Layer/{name}` via `create_issue_label` (team-scoped) **before** first Issue that needs it; body header remains required either way | +| Worker/run treats admin SPA as `Layer/web` | Wrong footprint merge with product SPA | Keep **package-aligned** layers when packages are separate apps (`packages/admin` ≠ `packages/web`); Cap/iOS/Android shells that **build from web** stay **web**, not a fifth layer | + +Portable rule: **brief packages ∩ monorepo packages must be in `layers:` (or deliberate opt-out).** New layer names need Linear labels pre-created under `backend: linear` before bulk `create_req`. + + +--- + +## 78. Fail-open analytics never holds a hot-path mutex + +| Symptom | Likely cause | Default action | +|---------|--------------|----------------| +| Stream/registry/control loop stalls when analytics is enabled | Lifecycle hooks call capture **synchronously under a lock** while HTTP transport blocks | Emit analytics **after unlock** or via **goroutine** (`go client.Capture(...)`); unit-test with a recording transport + short wait when async | +| Missing key / 5xx breaks run | Analytics errors not fail-open | Empty key = no-op; swallow transport errors; never panic the product loop | +| PII leakage on agent events | Free-form props (cwd, title, command, path) | Strict **event + property allowlists**; force `app=`; distinct_id = public machine/user id only | + +Portable rule: optional product analytics is **fire-and-forget + allowlist**; hot supervisors must not wait on the analytics network hop. + +--- + +## 79. PHP PostHog dual-write tests: mutable log object, not `return [&$sent]` + +| Symptom | Likely cause | Default action | +|---------|--------------|----------------| +| Recording transport "sent 0 events" though capture ran | Helper returns `[$client, &$sent]`; list-assignment **copies** the array and drops the reference | Hold rows on a **mutable object** (`$log->rows[] = …`) shared with the transport | +| Claim lifecycle analytics never fire in test though production path is wired | `app(CompleteX)` resolved before `instance(Analytics::class)` bind | Bind `PostHogClient` **and** the lifecycle wrapper **before** resolving the domain authority | +| Analytics inside `DB::transaction` rolls back with claim | Capture before commit | Emit **after** successful transaction return; keep fail-open so transport throw cannot undo claim | + +Portable rule: **dual-write product-truth after commit**; unit tests use object logs for capture spies; bind both client and domain analytics before `app(Authority)`. + +--- + +## 80. XcodeGen `project.pbxproj` multi-REQ merge conflicts + +| Symptom | Likely cause | Default action | +|---------|--------------|----------------| +| Parallel/serial ios leaves all touch `LivePane.xcodeproj/project.pbxproj` | XcodeGen regenerates the whole project file whenever sources are added; two branches diverge the same UUID blob | Prefer **source files + `project.yml` as truth**. On Stage B conflict in `*.xcodeproj/project.pbxproj`: keep all Swift sources from both sides, run `xcodegen generate`, commit regenerated project | +| Integration tip dirt blocks merge after `make ios-test` | Local `xcodegen generate` dirty the committed pbxproj without a commit | `git checkout -- packages/ios/LivePane.xcodeproj` before Stage B merge, or always commit regen as part of the REQ | +| Footprint lists only sources but pbxproj still conflicts | Implicit regen artifact | Document in REQ Files that pbxproj may change; regenerate rather than hand-merge conflict markers | +| `--parallel N` serialises every ios leaf | Every REQ lists `…/LivePaneTests` (or equivalent) as a **directory** | Narrow `**Files:**` to concrete `*Tests.swift` paths per leaf before claim (audit Step footprint) | + +Portable rule: **never hand-merge XcodeGen pbxproj** — merge sources, regenerate, re-test. **Parallel width for Xcode packages is limited by shared tests-dir footprints + pbxproj**, not by N alone. + +**SPM first-build (SwiftTerm / Metal):** if `xcodebuild` fails with missing Metal Toolchain while resolving SwiftTerm, run `xcodebuild -downloadComponent MetalToolchain` once on the machine before treating the REQ as verification-failing. Document in package README for agent CI hosts. + +**Simulator DESTINATION:** do not hardcode a single device name (`iPhone 16`) as the only path — discover an available iPhone Simulator or accept `DESTINATION=` override (macOS agent images lag plan prose). + +**Native UI path-unit close:** product entry points (launch login, open session pane) are often **human** on Simulator; closure can be `degraded:evidence-by-test` via package suite + source presence when device/TestFlight is residual-by-clarification. + +--- + +## 81. Cap (WebView shell) strip + PWA — grill package forks before capture + +For briefs that **remove Capacitor/Cordova/hybrid shells** and/or **add PWA**, ideate gaps change the REQ graph. Grill before bulk `create_req` (same spirit as §52 card-identity / §57 fluid lists): + +| Fork | Why it changes capture | +|------|------------------------| +| Pure native sibling package vs Cap under SPA package | Cap often lives under `packages/web` (config, ios/, android/, `@capacitor/*`); pure SwiftUI/Kotlin may be a **separate** package. “Remove Cap” ≠ delete pure native | +| “Android web” / “mobile web” | Often means **browser/PWA install** for phone users, or **product web + admin** packages — not Cap Android product work / TWA this UR | +| Strip depth | Shell dirs only vs full surface: client Cap branches (platform, push, safe-area floors, Reverb native rewrite) + **server** always-merge WebView CORS origins + Cap-only tests | +| PWA depth | Installable **online-first** (manifest + SW network-first) vs full offline Workbox shell (stale API risk) | +| Push | Cap push drop this UR vs Web Push in-scope | +| Layers | SPA package + admin SPA + server; agent usually no; pure native package often **keep / no REQ** | + +| Symptom | Likely cause | Default action | +|---------|--------------|----------------| +| Capture deletes pure native or freezes wrong package | Brief said “native” / “Cap” loosely | Grill pure-native keep/drop; footprint Cap paths only under SPA package | +| Cap strip + PWA both claim `package.json` | Parallel leaves same footprint | **Serial**: strip Cap deps first, then add `vite-plugin-pwa` (or equivalent); path-unit blocked by both | +| Server still green on Cap CORS tests after Cap gone | Strip was client-only | Full surface strip includes `cors` always-merge + Cap origin tests; keep Bearer browser origins | +| Offline SW ships against live API/Reverb | “Set up PWA” without depth grill | Default online-first unless brief claims offline | + +Portable rule: **dual-shell monorepos need package forks in clarifications**; Cap lessons in field-lessons (CORS, safe-area, Stream bake) stay as **historical reverse-migration** maps after Cap is removed — do not invent product Cap ops after strip. + + +--- + +## 82. Cap strip residual greps: product vs negative tests + +| Symptom | Likely cause | Default action | +|---------|--------------|----------------| +| `rg 'capacitor://\|ionic://' packages/server` still hits after Cap CORS strip | Unit/feature tests use the scheme strings as **negative** expected values | Treat **product** residual as config/bootstrap/app only (`--glob '!**/tests/**'`). Keep deliberate negative tests; do not invent Cap origins in defaults | +| Always-merge still present after "removing comments" | `$capacitorWebViewOrigins` + `array_merge` left in `cors.php` | Delete the merge array entirely; defaults = browser Vite only from env/default string | +| Symlink `vendor` → TestCase / `configPath` missing in worktree | Composer ClassLoader base = main package | Real `composer install` in worktree package (see §7) + symlink `.env` (§21) | + +Portable rule: **Cap strip residual = product surface clean**; negative tests may still name forbidden origins. Prefer full-tree product path globs over zero absolute hits. + + +--- + +## 83. SwiftUI: ObservableObject VMs over @Observable stores need published mirrors + +| Symptom | Likely cause | Default action | +|---------|--------------|----------------| +| List UI stays empty after `await store.fetch()` though store has rows | VM is `ObservableObject`; domain store is `@Observable` / Observation. Reading `store.machines` in a computed property does **not** fire `objectWillChange` | Mirror list/loading/error into `@Published` properties on the VM after each refresh/rename; or observe the store directly in the View with `@Bindable` / Observation | +| Tests pass (assert store/VM arrays) but Simulator list blank until force-redraw | Same | Same mirror pattern; unit-test the published surface, not only store internals | +| Two observation systems mixed without a rule | Login VMs already use Combine; domain uses `@Observable` | Prefer thin Combine VMs for list screens that need explicit refresh; keep pure domain helpers as free functions | + +Portable rule: **one observation owner per view tree level**. If the View holds a Combine VM, the VM must publish every field the body reads after async work — do not rely on nested Observation notifications through an ObservableObject. + +--- + +## 84. Online-first PWA (vite-plugin-pwa): pure config + NetworkFirst + nodenext imports + +| Symptom | Likely cause | Default action | +|---------|--------------|----------------| +| AC only “add PWA” but product is live API/auth SPA | Default Workbox Offline-first / CacheFirst shell serves stale admin/API chrome | Ship **installable online-first**: `NetworkFirst` navigations + short timeout; **`NetworkOnly` for `/api` `/sanctum` `/capabilities`**; denylist those on `navigateFallback`; description must not claim offline | +| Multi-app monorepo reuses one product name | Shared “LivePane” manifest | Distinct `name` / `short_name` / `id` per SPA (e.g. LivePane Admin / LP Admin / livepane-admin) | +| `vue-tsc -b` fails on `vite.config.ts` import of `./src/lib/pwaConfig` under `module: nodenext` | Extensionless relative import | Import `./src/lib/pwaConfig.ts` (and include that file in `tsconfig.node.json`); keep strategy as **pure module** unit-tested — do not assert only via dist smoke | +| Suite cannot import `virtual:pwa-register` | `injectRegister: 'auto'` or main always loads virtual module in unit env | Prefer `injectRegister: false` + explicit `registerSW` in `main.ts`; unit-test pure config, not the virtual module | + +Portable rule: **online-first PWA = unit-tested manifest identity + NetworkFirst shell + NetworkOnly for auth/API**. Prove with `dist/manifest.webmanifest` + `dist/sw.js` on build, not offline demos. **Installability ≠ offline app.** Cap strip + PWA both claiming `package.json` → serial (see §65). + +--- + +## 85. Cap/deps strip: regenerate lock without symlinked node_modules + +| Symptom | Likely cause | Default action | +|---------|--------------|----------------| +| After removing `@capacitor/*` (or similar) from package.json, lock still lists them under a nested `node_modules/@…` path | Worktree `node_modules` is a **symlink** to a main tree that still has the old packages; `npm install --package-lock-only` walks the linked tree | **Remove the symlink** (or use a temp empty dir), run `rm package-lock.json && npm install --package-lock-only`, then restore an **absolute** `node_modules` symlink for tests | +| Residual `rg` still hits lock after package.json is clean | Same | Verify lock root `packages[""].dependencies` has no stripped deps; regenerate as above before commit | + +Portable rule: **lockfile generation must not see a foreign/stale dependency tree via symlink** when proving a dep strip. + +--- + +## 86. Integration tip after package.json merges: install before close/build + +| Symptom | Likely cause | Default action | +|---------|--------------|----------------| +| Close/path residual: `vue-tsc` / Vite fails `Cannot find module 'vite-plugin-pwa'` / `virtual:pwa-register` though worker builds were green | Workers installed deps **inside worktrees**; Stage B merges only files — **main** `node_modules` still pre-merge | On integration base before residual close or path-unit build gates: `npm install` (or `npm ci`) in each package whose lock/deps changed | +| Same class as Cap residual `npm ci` for native bake | §42 | Extend to any new build-time plugin (PWA, etc.), not only Cap | + +Portable rule: **merge lands sources; tip install lands tools.** Path-unit / close that claims “build emits SW” must install on the **merged** checkout first (see also §42 main autoload after worktree teardown). + +--- + +## 87. Residual greps must catch uncommitted secrets on the integration tip + +| Symptom | Likely cause | Default action | +|---------|--------------|----------------| +| Residual `rg 'phc_\|sk-\|AKIA'` hits after all leaves archived “no secrets” | Worker committed empty placeholders; **local uncommitted dirt** on integration tip re-introduced a real key (dogfood / paste) between Stage B and residual | Before residual-close **and** before any metadata commit: run secret-shaped greps on the **working tree** (not only `git show HEAD`). If a match is **uncommitted**, `git checkout -- ` (or restore empty placeholder) — do not stage it. If a match is **committed**, treat as Stage B / policy failure and strip in a fix commit | +| Worker AC “Release.xcconfig has no phc_” was green | True at worker commit time | Residual owns tip honesty; worker evidence does not cover later tip dirt | + +Portable rule: **archive evidence is historical; residual is tip-truth.** Secret-shaped residual greps run against the live integration checkout (tracked + dirty) before path-unit Done. + +--- + +## 88. Native SDK bootstrap graph — serialize shared project.yml + +| Symptom | Likely cause | Default action | +|---------|--------------|----------------| +| `--parallel N` underfills on “SPM package” + “AppConfig / Info.plist keys” leaves | Both declare `project.yml` (and often regenerated `pbxproj`) | Capture graph: **SPM leaf first**, then **config/plist leaf**, then facade, then wire ∥ docs. Do not invent parallel width across those two | +| Facade leaf also lists whole `Support/` + `Tests/` dirs | Overlaps prior AppConfigTests / Support files if too broad | Narrow facade **Files:** to concrete new sources (`ProductAnalytics.swift`, matching `*Tests.swift`); leave AppConfig footprint to the config leaf | +| Path-unit residual re-implements wiring | Children already Done | Residual-close with package suite + secret greps (§71) + docs presence only | + +Portable rule for native analytics/SDK URs: **package → config/fail-open → injectable facade → app wire → docs**, with **one choke file (`project.yml` / pbxproj regen) owned by at most one in-flight leaf**. Pair only docs with wire when footprints are truly disjoint (AGENTS/README vs App/Domain only). + + +--- + +## 89. Native cold restore must not wipe session on transport failure + +| Symptom | Likely cause | Default action | +|---------|--------------|----------------| +| User logs in; force-quit; reopen shows login though they never signed out | Cold-start restore validates token via network; **any** error (offline, TLS, 5xx, timeout) clears Keychain/local token | Clear durable token only on **definitive auth failure** (typically **401/403**) or explicit logout. On network/5xx: keep token, mark session ready (shell may show offline list errors) | +| “Auth persistence missing” when Keychain + restore already exist | Fail-closed wipe looks like missing storage | Audit restore **policy** before building a second store | +| Double clear / race on 401 | API client unauthorized hook + restore catch both clear | Prefer restore-owned clear for the probe, or suppress unauthorized fire during restore | +| Service keep-session green but app still boots login | Only `AuthService.restore` tested; root composition / splash-owned restore never maps session → shell root | Assert **composition root** after `bootstrap` (or equivalent long-lived owner): offline/5xx keep → authenticated root + token present; 401/403 + logout/clearLocal → unauthenticated + login. Bootstrap must live on a long-lived owner (app `.task`), not only a dismissible splash | + +Portable rule: **token persistence ≠ online validate success.** Offline-friendly products keep the bearer across process death unless the server rejects it or the user signs out. Prove at **composition root**, not only the restore service. + +--- + +## 90. Triple status chrome + session liveness — grill before capture + +When a host session list shows **Available** but open chrome says **Agent offline** / **Waiting**, treat three **independent** signals (do not collapse them in copy or AC): + +| Signal | Typical source | Means | +|--------|----------------|--------| +| List bucket (Available / streaming / Unavailable) | Resource **lifecycle state** on the server | Membership of list groups | +| Stream phase (Waiting / Live / Stream off) | Client ↔ realtime transport | Channel / bytes, not host process | +| Agent online | Parent link **heartbeat** for **this** workspace | Host agent for that claim only | + +| Symptom | Likely cause | Default action | +|---------|--------------|----------------| +| Open blames agent while host agent is fine | Empty canvas **leads with** agent_offline over stream/session-dead | Empty hierarchy: checking spinner first → true agent offline → session gone on host → stream wait/idle. Never paint “agent offline” when parent-link online and only the child session is dead | +| Dead rows linger in Available | Inventory still “live” or only passive stale; no **session-alive probe** | Grill: passive TTL vs **server→agent ping** (exists?); on definitive no → terminal holding pen immediately; fail-open on timeout (no false-gone) | +| User confuses 15m with “no activity 15m” | Holding-pen retention vs idle/stream silence | Document separately: **hide window** for terminal rows vs **liveness probe** vs stream idle | + +**Ideate forks that change the REQ graph** (same spirit as §57 fluid lists / §52 card identity): + +| Fork | Why it changes capture | +|------|------------------------| +| Passive stale vs active ping while agent online | Server MarkUnreachable vs new agent command + domain | +| When to probe (list / open / idle N) | Client leaves + API surface | +| Empty copy when agent online + session dead | Pure chrome helpers + tests on every client surface | +| Checking spinner while probe/stream settle | Open path UX; suppress fatal empty until settle | +| Surfaces (native / SPA / admin residual) | Layer fan-out; admin often list-only residual | + +Portable rule: **list membership ≠ agent heartbeat ≠ stream phase**. Capture must split server/agent probe work from client honesty chrome; grill liveness authority before implementing only UI labels. + From 05dfea6cc05a1133b08de55df89d4ed3e107a878 Mon Sep 17 00:00:00 2001 From: Tom Kaczocha Date: Tue, 11 Aug 2026 10:20:44 +1000 Subject: [PATCH 16/17] cleanup --- references/field-lessons.md | 1118 ----------------------------------- 1 file changed, 1118 deletions(-) delete mode 100644 references/field-lessons.md diff --git a/references/field-lessons.md b/references/field-lessons.md deleted file mode 100644 index 25d5110..0000000 --- a/references/field-lessons.md +++ /dev/null @@ -1,1118 +0,0 @@ -# do-work field lessons - -Portable patterns for future do-work runs (any project). Read at skill start when present; append at end of runs that teach something transferable. - ---- - -## 1. Linear `save_milestone` patch vs full replace - -| Symptom | Likely cause | Default action | -|---------|--------------|----------------| -| MCP `save_milestone` with `patch` fails: `Invalid arguments` / validation error | Host or schema rejects nested `patch` ops (or anchors don’t match Linear’s normalized markdown) | Prefer **full `description` rewrite** (read → merge sections → write). Never dual-write local `user-requests/` | -| Partial section update needed (`## Ideate`, `## Clarifications`, `## Verify`) | Same | Rebuild description: keep `## Brief` verbatim; replace only the target section; write full body | -| **`## Brief` / Capture summary vanish after verify** | Agent passed **only** the new `## Verify` block as `description` — Linear **replaces** the whole field | **Always** read current description → splice section → write **full** body. Size spill: short `## Verify` pointer in description + full report as **milestone comment** (``). If wipe already happened, restore from session MCP dump / GraphQL immediately | -| MCP write flaky mid-session | Transport / size | Retry full replace once; GraphQL `projectMilestoneUpdate` only if MCP remains unusable **and** operator env already has Linear auth — still no markdown store fallback | - ---- - -## 2. Linear deps: relations API, not `IssueUpdateInput` - -| Symptom | Likely cause | Default action | -|---------|--------------|----------------| -| Cannot set `blockedBy` on issue update | `IssueUpdateInput` has **no** blocks fields | Use `issueRelationCreate` with `type: blocks`, `issueId` = **blocker**, `relatedIssueId` = **blocked** | -| Claim eligibility wrong after capture | Only body `**Depends on:**` written | Dual-write: relations authoritative + body mirror (port rule) | -| Path-unit never claimable | Parent not blocked by children (or reverse) | Path-unit Issue **blocked by** layer children; leaf deps as hard edges only | - ---- - -## 3. “Is the dependency already done?” — verify before re-asking - -| Symptom | Likely cause | Default action | -|---------|--------------|----------------| -| Grill asks hard-dep on a side-track; operator says “I thought that was done” | Brief still says “recommended” / “depends on” after the work shipped | **Check Linear** (UR milestone / Issues → Done) **and** repo evidence (release workflow, tags, install script) before asking again | -| Confirmed done | Side-track complete | Record clarification: treat as **existing infrastructure**, not a hard wait; still note residual gaps (e.g. extra GOOS in matrix) | - -Do not invent “still open” from plan prose alone when the product Project shows Done. - ---- - -## 4. Multi-stream phase plans — grill the decomposition forks - -For large multi-workstream briefs (Windows + billing + updates + …), ideate gaps are high-impact. Prefer **Grill** questions that change REQ graph shape, not polish: - -| Fork | Why it changes capture | -|------|-------------------------| -| Billable entity (workspace vs user) | Migrations, Cashier model, portal ownership | -| Runtime acceptance bar (unit/compile vs CI vs manual smoke) | Verification Steps + done criteria | -| Control-loop thrash policy (hysteresis, cooldown, manual resume) | Domain AC + agent loop REQs | -| Placeholder vs real integration (SSO hook depth) | Scope of leaf REQs | - -Self-answer pass still batches confident codebase inferences first (composer packages present, existing columns, heartbeat fields). - ---- - -## 5. Bulk Linear `create_req` under a large plan - -| Symptom | Likely cause | Default action | -|---------|--------------|----------------| -| Capture of 15–25 Issues is slow / times out | Serial MCP + full bodies | Batch creates with stable §9.2 bodies; set labels (`Layer/*`, `Size/*`, `path-unit`); **then** a second pass for relations + `**Depends on:**` mirrors | -| Milestone description huge | Full plan pasted into `## Brief` | Intake may keep full plan; subsequent section updates should not truncate Brief when editing Ideate/Capture — merge carefully | -| cycle-check.sh fails under Linear | Script is markdown-path | Under `backend: linear`, treat relation graph as authority; don’t halt solely because local `REQ-*.md` cycle-check finds nothing | - ---- - -## 6. Field-lessons write exception - -Skill install trees are often “read-only at runtime” for product work. **Exception:** appending or creating this file (`references/field-lessons.md`) under the do-work skill root **is allowed** when a run produced portable lessons — do not skip capture because of the generic skill-dir rule. - ---- - -## 7. Monorepo worktrees: `vendor` symlink depth + Laravel base path - -| Symptom | Likely cause | Default action | -|---------|--------------|----------------| -| `provision-worktree.sh` leaves `packages/*/vendor` unprovisioned | Auto-detect only walks **depth ≤ 1** from worktree root; `packages/server/composer.json` is depth 2 | Configure `worktree.link_paths: [packages/server/vendor, …]` **or** run `composer install` inside the package worktree path before tests | -| Symlinked `vendor` → main checkout: `config()` / app boot hits wrong tree; new worktree classes “not found” or tests pass against main `app/` | Laravel `Application::inferBasePath()` uses Composer ClassLoader root (= main `packages/server` when vendor is a symlink) | Prefer **real `composer install` in the worktree package** for PHP monorepos. Alternative: set `APP_BASE_PATH` to the worktree package root for test runs — do not assume symlink vendor is safe for PSR-4 load of worktree code | - -Portable rule: for Laravel packages under `packages//`, treat symlink-vendor as a **smoke shortcut only**; TDD that adds classes under the worktree needs a worktree-local autoload (install or path fix). - -Same depth trap for **`packages/web/node_modules`**: empty `worktree.link_paths` + depth-1 auto-detect → worktree has no JS deps. Symlink `packages/web/node_modules` from the main checkout (or set `link_paths`) before `npm test`. - -| Symptom | Likely cause | Default action | -|---------|--------------|----------------| -| `node_modules/vitest: Too many levels of symbolic links` after manual `ln -s` | From worktree `packages/web`, relative `../../packages/web/node_modules` resolves **into itself** (worktree root is only two `..` up), not the main checkout | Symlink with an **absolute** main-checkout path, or count parents to the monorepo root (`../../../../packages/web/node_modules` when worktree is `{repo}/.worktrees//packages/web`). Prefer `worktree.link_paths` so provision does this correctly. | - ---- - -## 8. UI evidence for auth-gated SPA forms - -| Symptom | Likely cause | Default action | -|---------|--------------|----------------| -| `ui` step needs “form visible for owner” but Playwright only reaches login | Real SPA route requires session; worker has no seed credentials | Serve a **temporary** root HTML entry in the package (`evidence-*.html`) that mounts the real component with props (`canEdit: true`) and a **mocked `fetch`** for the settings API; screenshot that; **delete the HTML before commit** | -| Main checkout Vite already on :5173 | Parallel / stale main dev server | Start worktree Vite on a free port (`--port 5199 --strictPort`); do not assume the main-checkout server has worktree code | - -Still vision-assert the PNG (enabled + thresholds + locked weights / save). Do not commit evidence HTML or pass on a11y-only scrapes. - ---- - -## 9. Parallel web REQs sharing one view file - -| Symptom | Likely cause | Default action | -|---------|--------------|----------------| -| Two workers both claim `SettingsView.vue` (or similar shell) | Footprint lists component dirs only, not the shared mount view | Expand `**Files:**` to include every shell/layout file the worker will edit; at claim time treat view mounts as exclusive if both REQs name the same path | -| Auto-merge succeeds but double-mounts UI | Both REQs added adjacent sections | Prefer path-unit parent to do mount wiring after leaf components land, or claim shell view serially | - ---- - -## 10. REQ AC / task title beats plan step comments - -| Symptom | Likely cause | Default action | -|---------|--------------|----------------| -| Plan Task N says “v1 no-X” but Issue Task/AC requires X | Capture applied a clarification override; plan doc not rewritten | Implement **Issue AC + Task**, not stale plan comments. Cross-check design § clarifications when titles diverge (e.g. prefer-cmux dedup vs “v1 no dedup”) | -| Worker ships plan-literal no-op and fails AC | Read plan before Issue body | Always treat Linear Issue (or markdown REQ) as the closure oracle; plan is scaffolding | - ---- - -## 11. Child-resource payloads need parent link diagnostics - -| Symptom | Likely cause | Default action | -|---------|--------------|----------------| -| UI pane/session view cannot show agent online / hb without machines store | Child list/get payload only maps the child row | Extend the **shared payload helper** (not controller + cap twice) with parent-link fields (`last_heartbeat_at`, `agent_version`, optional derived `agent_online`) | -| List endpoint N+1 on link lookup | Payload always re-queries per row | Optional preloaded link arg; **batch-load** links in list authority (unique by machine/host key), pass through | -| Online bool drifts from client filter | Server invents a different stale window | Mirror the UI threshold constant (document the match); prefer raw ISO heartbeat + bool both when chips need either | - -Do not invent stream-persistence columns for “honest empty stream” when client already owns WS/chunk evidence — only expose server-known diagnostics. - ---- - -## 12. Live chip must require stream evidence (not transport-up) - -| Symptom | Likely cause | Default action | -|---------|--------------|----------------| -| Empty terminal shows green “live” / header “Live” | Status machine maps WS `connected` → live; header reuses “Live” for transport | Gate **live** on chunk/output evidence only; `connected` → **waiting**; silence → **idle**; offline distinct. Rename header transport chip to “Stream on/off” so it never collides with pane live | -| Blank pane with no “why” | Empty copy ignores agent heartbeat | When silent, append agent online/offline + last-hb age from parent-link diagnostics (see §10) — pure helpers, unit-tested | -| Dual status chrome confuses users | App WS chip vs pane stream chip use same word | Keep transport wording separate from pane stream evidence wording | - -Portable rule: **transport connected ≠ product live**. Chips and empty-state copy must say waiting/idle/offline until evidence arrives. - ---- - -## 13. GoReleaser GOOS vs self-update asset contract - -| Symptom | Likely cause | Default action | -|---------|--------------|----------------| -| Windows release added as zip; agent update fails | Self-update client hardcodes `AssetName` → `*.tar.gz` and `ExtractBinary` only understands tar.gz | Keep **tar.gz for every GOOS** until the update client is taught zip; document the contract next to `.goreleaser.yaml` | -| Matrix builds unwanted `windows/arm64` | `goos × goarch` cartesian product | Use `ignore:` for unsupported pairs; ship only the arch the AC names (e.g. windows/amd64) | -| Docs say “or defer” but compile is CGO-free | Untested assumption that Windows needs CGO | Cross-compile smoke (`GOOS=windows GOARCH=amd64 go build`) before deferring the matrix entry | - - ---- - -## 14. Agent dual-loop: share one stream Registry - -| Symptom | Likely cause | Default action | -|---------|--------------|----------------| -| Heartbeat Converge and command Start each create a Registry | Run helpers construct their own lifecycle maps | Wire **one** Registry in main; pass into both loops (heartbeat Converge + create_pane Start) | -| Double stream POST for same pane_id | Independent cancel maps; Converge cannot stop command-started streamers | Shared map is the fix — not “stop all on converge” hacks | -| Wiring hard to unit-test | Run hardcodes HTTP client | Extract `RunLoop(ctx, Heartbeater, Converger)` with interfaces; fake Heartbeat response + recording Converge | - -Portable rule: when two concurrent loops own the same supervisor resource (streamers, connections), construct once at the process root and inject — never let each loop default-construct its own. - ---- - -## 15. Hysteresis demotion tests — clock from challenger_since - -| Symptom | Likely cause | Default action | -|---------|--------------|----------------| -| Demotion never fires at “T+30s from first beat” | Challenger timer starts only when the higher-ranked key appears; first beat has no challenger | Advance time **≥ hysteresis window after `rank_challenger_since`**, not after the first select beat | -| Mid-beat keeps the lower streamer | Correct hold under hysteresis | Assert challenger_key/since set and ideal key still `available` until elapsed | - -Portable rule for control-loop thrash tests: freeze time (`setTestNow`), inject `now` into the authority, and measure hold/release against the persisted challenger timestamp. - ---- - -## 16. Multi-UR concurrent merges on shared integration branch - -| Symptom | Likely cause | Default action | -|---------|--------------|----------------| -| Stage B merge conflicts in shared shell views (`PaneView.vue`, stores, API clients) though REQ footprints were disjoint | Multiple URs land on the same integration branch (`new-work`) in parallel; footprints only exclude **in-flight** claims, not already-merged sibling URs | Resolve conflict by **keeping both** feature sets (do not drop either side’s fields). Prefer combine-not-choose for additive UI. Re-run package tests after conflict fix before archive | -| Footprint looked free at claim | Other UR merged between claim and integrate | Same — Stage B conflict path is expected under concurrent go runs | - - ---- - -## 17. Concurrent integration-base advance mid-parallel wave - -| Symptom | Likely cause | Default action | -|---------|--------------|----------------| -| Worker branch merges with content conflict on shared core (e.g. stream Registry) | Sibling UR/session merged into `new-work` while workers ran on older tip | Rebase/resolve onto **current** base before archive; re-run package tests for the conflicted package | -| `Stop`/`Start` API shape drifted (map of cancels vs entries) | Another REQ refactored the same type | Port the new method onto the **new** structure; do not force-old map over newer Converge design | -| Path-unit still Todo while all layer children Done | Relations never blocked path-unit on children | After last leaf archives, **close path-unit** with run-note (children Done) so dep chains (e.g. Upgrade path blocked by free-slot path) unlock | - -Portable rule: parallel waves assume disjoint **Files:** but not a frozen integration tip — always merge against live base and treat path-unit Done as a graph unlock, not a second implementation pass. - ---- - -## 18. Hard-cut route keys — fake side effects on the reject path - -| Symptom | Likely cause | Default action | -|---------|--------------|----------------| -| “Integer/old key → 404” feature test gets **500** (e.g. Pusher/broadcast) while red | Old key still binds; handler runs and hits real side effects | `Event::fake()` (or equivalent) on **both** ULID-ok and reject cases so a binding regression fails as 200/≠404, not infra 500 | -| Only put binding on one model | Routes type-hint several public models | Prefer `getRouteKeyName` + `resolveRouteBinding` on the shared public-id trait (`Str::isUlid` guard) so agent + HTTP surfaces stay aligned | - ---- - -## 19. Public ULID hard-cut: one resolver, reject digits - -| Symptom | Likely cause | Default action | -|---------|--------------|----------------| -| Caps still authorize when client sends `"1"` / integer PK as string | Lookup uses `find($id)` or `where('ulid', $id)` without rejecting pure digits | Central **`PublicId::find/findOrFail`**: require `Str::isUlid` **and** `!ctype_digit`; never fall through to PK | -| Payload helpers emit mixed int/string ids | Mapped `$model->id` in list/detail helpers | Emit only `PublicId::require($model)` for `id` and related `*_id`; load parent relations for FK public ids | -| Input DTOs still typed `int $workspace_id` | Schema lag after column migration | Change capability Input/Result id fields to `string`; resolve once at authorize/run edge; keep domain authorities on internal int PKs | - -Portable rule: **external surface = ULID only; internal domain = bigint**. Reject digit-only public keys at the boundary so PK leaks cannot authorize or round-trip. - -Agent/device wire is external too — not just HTTP/caps: - -| Surface | Must emit public ULID | -|---------|------------------------| -| Claim complete `machine_id` | Device identity after claim | -| Heartbeat `desired_streams[].pane_id` | Stream targets for agent | -| Command poll wire `command_id` / `machine_id` + payload `pane_id` | Long-poll + create/destroy/send_keys | -| Agent attach/state response `pane_id` | Agent→server ack | - -Do not leave `$model->id` in those paths after a hard-cut; audit `toWire` / DTO `toArray` / enqueue payloads in the same REQ as route binding. - ---- - -## 20. SPA public-id hard-cut: fixtures + exceptions - -| Symptom | Likely cause | Default action | -|---------|--------------|----------------| -| Suite fails only on `toEqual([1])` / URL `/workspaces/3/` after types are already `string` | Bulk-replaced object fields but not **call args** and **expected values** | When converting entity ids to ULID strings, rewrite fixtures, `toHaveBeenCalledWith`, URL regexes, and chip `value:` expectations in the same pass | -| Accidental conversion of PAT / Sanctum token `id: number` | “All `id: number`” codemod | Leave third-party / Sanctum token ids numeric until that surface emits public ULIDs; domain entities only (workspace/machine/pane/user) | -| Route/query still accepts `"12"` after cut | Client parser uses `Number()` / positive-int | Shared `parsePublicId`: ULID shape + reject pure digits; null out legacy localStorage int keys | - -Portable rule: hard-cut client + tests as one unit; exclude non-public-id surfaces explicitly. - ---- - -## 21. Go agent ULID hard-cut — empty sentinel, not zero - -| Symptom | Likely cause | Default action | -|---------|--------------|----------------| -| Client parses claim/desired_streams but rest of package still `int64` | Only DTO fields flipped | Cascade **all** pane/machine/command id types in one REQ: config, commands, stream Registry maps, SessionName/ParseSessionName, attach/stream URL segments | -| Tests still skip on `paneID == 0` / `IsActive(0)` | Zero was the missing sentinel for int | Use **`""` empty string** as missing; `"0"` is a valid (legacy) id string and must not no-op Start/Stop | -| Resume ignores `ac-{ulid}` | ParseSessionName still requires digits | Accept non-empty suffix after `ac-`; reject only empty / wrong prefix | -| JSON number in old config fails Load | Hard cut: config `machine_id` is string | New claim rewrites config; fixtures use ULID strings — no silent int fallback | - -Portable rule: external wire + local config + map keys + path segments change together; missing-id checks become empty-string, not zero. - ---- - -## 22. Hard-cut identity: path-unit residual is a full suite, not a checklist - -| Symptom | Likely cause | Default action | -|---------|--------------|----------------| -| Leaves green in isolation; path-unit `php artisan test` has dozens of 404/`validation_failed` | Feature/cap tests still pass **integer PKs** as route keys and capability `*_id` inputs | Path-unit owns a **full-package suite** pass after the last leaf; budget a residual “align tests to public id” pass (same UR) | -| Bulk `->id` → `->ulid` rewrites break Eloquent creates (FK constraint) | Codemod rewrote mass-assign `workspace_id`/`machine_id` on `Model::create` | Split surfaces: **external** (routes, invoke inputs, assertJson) use ULID; **internal FKs** stay bigint. Never codemod `create([...])` FK fields | -| `findOrFail($result->data->pane_id)` fails after result becomes ULID | Still treating result id as PK | `where('ulid', $publicId)->firstOrFail()` (or `PublicId::findOrFail`) | - -Portable rule: hard-cut leaves may ship production code green with thin tests; the **path-unit gate** is the whole suite. Prefer targeted rewrites (URL strings + invoke arrays + JSON assertions) over global `->id` replace. - ---- - -## 23. Duplicate UR-NNN milestones on one product Project - -| Symptom | Likely cause | Default action | -|---------|--------------|----------------| -| `list_milestones` returns two `UR-018: …` names | Parallel intake / ship-loop used same next slug | Prefer milestone with open REQs + `Status: captured`; treat 100% progress / all Done as the closed twin. Do not dual-run both | -| `go UR-NNN` ambiguous | Same | Resolve by milestone id in run notes; consider renaming closed twin to avoid future collision | - - ---- - -## 24. Privileged PAT profile vs product profile name lists - -| Symptom | Likely cause | Default action | -|---------|--------------|----------------| -| Adding a system/platform PAT profile breaks MCP/catalog parity tests that iterate `profileNames()` | Product mounts and privileged profiles were one list; tests expect every profile key in product MCP config | Split **productProfileNames()** (product mounts) from **profileNames()** (all mintable). MCP/agent config + parity tests iterate product only; mint validation uses all | -| Product tokens can call privileged caps | Scope gate treats SPA `*` as full power for every capability name | For privileged capability prefixes, deny `*` and product expansions; require explicit privileged profile allowlist | - -Portable rule: when a new PAT profile must not appear on product MCP/CLI mounts, never fold it into the list parity tests treat as “must be mounted.” Gate privileged capability names separately from product `*` bypass. - ---- - -## 25. Admin/SPA list UI evidence without temporary HTML - -| Symptom | Likely cause | Default action | -|---------|--------------|----------------| -| Auth-gated list route exists but API is missing | Parallel server REQ not done | Playwright: seed `localStorage` session keys the SPA already uses, `page.route` mock list JSON, navigate to real route, screenshot | -| Evidence HTML would duplicate shell chrome | Route already mounts real view under shell layout | Prefer real route + mocks over a throwaway HTML mount; still vision-assert the PNG | - -Portable rule: when the shell + route already land, inject session storage + intercept network instead of a temporary `evidence-*.html` (still delete any temp files if used). - ---- - -## 26. Privileged capability catalog vs product surface-parity matrix - -| Symptom | Likely cause | Default action | -|---------|--------------|----------------| -| Registering `system.*` (or other privileged) caps fails product surface parity that requires `agent` on every registry name | Privileged mount is http/mcp/cli only; product Manager needs agent | Split required surfaces: product = full set; privileged prefix = declared subset (and assert **not** on product agent mounts) | -| Catalog gap matrix regex only matches product `workspace\|machine\|pane.*` | Matrix was product-only | Add a **second matrix** for privileged names sourced from the PAT profile expansion; never fold privileged rows into the product matrix | -| Cap CRUD names exist but model is a sibling REQ | Catalog registration needs something to call | Ship **minimal** model + domain authority with the catalog cap REQ; leave product-side integration (quota waiver, HTTP controllers) to siblings | - -Portable rule: privileged catalogs share discovery path but **not** product profile allowlists or product surface requirements — isolate in tests and PAT expansions (see §18). - ---- - -## 27. Laravel monorepo worktree: `.env` for clean Pest - -| Symptom | Likely cause | Default action | -|---------|--------------|----------------| -| Every Pest test is `WARN` with `file_get_contents(.../.env): Failed to open stream` | Real `composer install` in worktree package but no `.env` (gitignored; provisioner only links `vendor`) | Symlink or copy main `packages/server/.env` into the worktree package root before `php artisan test`. Assertions still pass (exit 0) without it — but WARN noise hides real failures; treat missing `.env` as part of Laravel worktree provision, not suite failure. | - -Portable rule: for Laravel package worktrees, provision **autoload (vendor)** and **runtime env (`.env`)**; vendor alone is not enough for quiet green. - - ---- - -## 28. Cache store that normalizes on put — pass raw, wire the bound - -| Symptom | Likely cause | Default action | -|---------|--------------|----------------| -| Store `truncated: false` though content was oversize | Controller pre-normalized then passed already-bound bytes into `put()`, which re-normalizes and sees size ≤ MAX | Pass **raw** content into the store (store owns normalize + truncated); assign **normalized** only to broadcast/response body | -| Double-work normalize looks “fine” in green tests that only assert string equality | Truncation flag / audit fields not asserted | Always assert `truncated` (or equivalent metadata) when the AC cares about bound storage | - -Portable rule: one owner for normalize+metadata (the store); consumers that also need the bound payload call the same pure normalize helper for the wire, not the store’s return side-effect. - - ---- - -## 29. Integration-base checkout mid-parallel (Linear monorepo) - -| Symptom | Likely cause | Default action | -|---------|--------------|----------------| -| Merges land on unexpected branch (`ux/…`) while go started on `new-work` | Concurrent worktree holds `new-work`; shell checkout jumps; later Stage B merges follow current branch | Before every Stage B merge: assert `git branch --show-current` equals recorded ensure-integration-base tip. If another worktree owns the base, merge via that worktree (`git -C .worktrees/… merge`) or cherry-pick onto the base tip — never invent a new integration branch mid-UR | -| Sibling REQ UI disappeared after next packages/admin merge | Second worker branch started from pre-first-UI tip or overwrote shared router/nav | After each packages/* shell merge, `git log -1 --name-only` + re-check prior leaf files exist; if missing, restore from the prior REQ commit before archive | -| `php artisan test` in worktree fails facade root / wrong classes | Symlinked `vendor` from main checkout | Prefer real `composer install` in worktree package (field lesson §7); treat symlink as smoke-only | - - ---- - -## 30. Laravel monorepo worktree needs `.env` as well as vendor - -| Symptom | Likely cause | Default action | -|---------|--------------|----------------| -| Pest tests WARN `file_get_contents(.../.env): Failed to open stream` after vendor install | Worktree has `composer install` but no `.env` / APP_KEY | Copy main checkout `packages/server/.env` (or `.env.example` + `key:generate`) into the worktree package before `php artisan test` | -| Symlinked vendor still boots wrong tree | ClassLoader base path | Prefer real `composer install` in worktree package (see §7); still pair with worktree-local `.env` | - -Portable rule: provision monorepo PHP worktrees with **autoload + app env**, not vendor alone. - ---- - -## 31. Privileged MCP mount isolation + tools/list pagination - -| Symptom | Likely cause | Default action | -|---------|--------------|----------------| -| Product MCP parity tests fail after adding a system/privileged profile | Privileged profile was folded into `surfaces.mcp.profiles` (auto-register product mounts) | Keep product profiles list product-only; mount privileged path via a **sibling config key** (e.g. `system_mount`) + dedicated registrar after product boot | -| System PAT can list product tools (or product PAT hits system path) | Only tool allowlists differ; auth middleware is shared | Dual middleware: product mounts reject pure privileged tokens; system mount requires privileged PAT + admin flag (stricter than SPA `*` HTTP admin if AC says “system PAT only”) | -| Feature test “lists every system tool” fails though registry has all tools | laravel/mcp **paginates** `tools/list` (`nextCursor`) when allowlist > page size | Follow `result.nextCursor` until empty before asserting full name set | - -Portable rule: privileged MCP = separate path + separate registrar + bidirectional token rejection; full-catalog assertions must paginate `tools/list`. - ---- - -## 32. Playwright `page.route('**/api/**')` breaks Vite SPA module loads - -| Symptom | Likely cause | Default action | -|---------|--------------|----------------| -| UI evidence: blank app / MIME error loading `src/api/*.ts` as `application/json` | Glob `**/api/**` matches **Vite source paths** (`/src/api/client.ts`) as well as HTTP `/api/...` | Match only request **pathname** with `pathname.startsWith('/api/')` (or host+path), never a bare `**/api/**` substring | -| `addInitScript` + real route works after fix | Session seed was fine; route was intercepting ESM | Keep field lesson §19 session+route pattern; tighten the URL predicate | - -Portable rule: in Vite monorepos, package folders named `api` live under `/src/api/` — Playwright network mocks must not treat those as backend routes. - ---- - -## 33. Parallel `--parallel N` underused on shared HTTP footprint - -| Symptom | Likely cause | Default action | -|---------|--------------|----------------| -| `--parallel 3` fills only 1–2 slots on a multi-CRUD admin UR | Every server leaf lists the same `routes/api.php` + `app/Http` + `tests` tree in `**Files:**` | Treat shared route/register files as exclusive: at most **one** in-flight server leaf; pair free slots with a **disjoint package** (e.g. admin SPA) | -| Path-unit residual blocks the whole package for a full worker | Path-unit `**Files:**` is broad (server + SPA) after children already Done | Residual-close with suite evidence + run-note (children Done) **before** leaf fan-out so pick order reaches implementable leaves | -| Claimable leaf already green on integration base | Prior residual / sibling landed domain+HTTP under a different issue id | Run the REQ’s verification filter first; if green and AC met, residual-archive — do not re-dispatch a full implement worker | - -Portable rule: parallel width is limited by **declared footprint intersection**, not by N. Capture should keep shared choke files honest; run should residual-close ready path-units and already-landed leaves quickly, then fill N with non-overlapping packages. - ---- - -## 34. Admin HTTP destroy ≠ product soft-end authority - -| Symptom | Likely cause | Default action | -|---------|--------------|----------------| -| Guidance says “reuse DestroyX where safe” but admin DELETE 403s / RuntimeException | Product destroy checks controller/owner role (`canControl`) or only soft-ends state | Keep **Admin\*::delete** as hard-delete + audit (mirror sibling AdminMachine/AdminUser pattern); document why product soft-end is unsafe for platform admin | -| Admin list payload missing parent names | Shared payload only emits parent public ids | Extend **SystemXPayload** once with `workspace_name` / host context fields the admin SPA already types | - -Portable rule: “reuse domain where safe” means role checks and lifecycle semantics must fit platform admin; when they don’t, extend the admin authority (audit + hard delete) rather than bypassing product access checks. - ---- - -## 35. Vue evidence mounts: runtime-only build rejects `template:` strings - -| Symptom | Likely cause | Default action | -|---------|--------------|----------------| -| Evidence page blank; console: "runtime compilation is not supported" / "alias vue to vue.esm-bundler.js" | Vite ships **runtime-only** Vue; `createApp({ template: '...' })` needs the full compiler build | Prefer **`h()` render functions** (or a real `.vue` SFC) for temporary evidence mounts — do not flip the whole app alias just for evidence | -| Playwright `wait-for-selector` times out on chip testid | App never mounted because of the template warning | Fix the mount first; re-screenshot only after DOM has the testid | - -Portable rule: temporary UI evidence under Vite + Vue must use SFC or `h()`, not inline `template` strings, unless the project already aliases `vue` → `vue/dist/vue.esm-bundler.js`. - ---- - -## 36. SPA UI evidence: mock capability + broadcasting paths (not only `/api/`) - -| Symptom | Likely cause | Default action | -|---------|--------------|----------------| -| Seeded auth + `/api/*` mocks still bounce to login | TerminalView / Echo call **`/capabilities/*`** or **`/broadcasting/auth`**; Vite proxies those to Laravel → **401** → `setUnauthorizedHandler` clears session | Intercept with `pathname.startsWith('/api/') \|\| …('/capabilities') \|\| …('/broadcasting') \|\| …('/sanctum')` and fulfill JSON; keep field lesson §24 (never `**/api/**`) | -| Board/grid mounts then evaporates mid-screenshot | Race: panes load OK, then snapshot capability 401 fires logout | Mock capability invoke success (`{ ok: true, data: … }`) before navigation settles | - -Portable rule: auth-gated SPA evidence must mock **every same-origin backend prefix the shell uses**, not only `/api/`. - ---- - -## 37. Overlay shell roots need a real layout box (not only fixed children) - -| Symptom | Likely cause | Default action | -|---------|--------------|----------------| -| Playwright: `waitForSelector('[data-testid=shell]')` times out with “resolved to hidden” though DOM has `data-open=true` | Root node has **zero layout size** — only children use `position: fixed`; Playwright treats zero-box elements as hidden | Give the shell root `position: fixed; inset: 0` (and put backdrop/panel as absolute children); keep `pointer-events: none` on the root with `auto` on interactive children | -| Drawer works visually in a browser but automated ui step fails | Manual look uses painted fixed children; visibility API does not | Same fix — root must own the viewport box for evidence and a11y hit-testing consistency | - -Portable rule: for overlay/drawer chrome, the **test-id root** must have a non-zero box, not only absolutely/fixed descendants. - ---- - -## 38. Limit-recovery UI must not disable the trigger - -| Symptom | Likely cause | Default action | -|---------|--------------|----------------| -| Product wants a “drop/free a slot then continue” modal at plan limit, but users never see it | Primary action is `disabled` when `at_limit` (tooltip only) | Keep the action **enabled**; open the recovery modal on click (and still handle API 422 if client usage is stale) | -| Modal lists nothing useful | Candidates filtered to wrong set (e.g. same machine only) | Prefer workspace-wide concurrent-quota consumers the user can free | - -Portable rule: if the happy path at limit is a recovery dialog, **do not** hard-disable the control that starts that path; use busy/permission disables only. - ---- - -## 39. Linear claim heartbeat: update existing comment only - -| Symptom | Likely cause | Default action | -|---------|--------------|----------------| -| Two `` top-level comments on one Issue | Worker used `save_comment` **create** for heartbeat instead of patching the claim id | **Always** `list_comments` → find claim body with matching `agent_id` / marker → `save_comment` with that comment `id`. Never post a second claim root | -| Stale-slot / claim protocol confused | Dual claim stamps | Delete accidental duplicate claim comments immediately after noticing | - -Portable rule: claim protocol is one active claim comment per Issue; heartbeat is an in-place edit of that comment’s `heartbeat:` line. - ---- - -## 40. Live terminal streams: convertEol + mid-cut wire garbage - -| Symptom | Likely cause | Default action | -|---------|--------------|----------------| -| SPA paints solid black mid-line blocks (U+2588) / U+FFFD; TUI option lists unreadable | Server `substr(-$max)` starts mid-UTF-8 or half-CSI; paint path appends corrupt intermediate; mis-flagged redraw deltas concat two full screens | **(1)** Shared trailing bound that drops leading UTF-8 continuations + orphan CSI before wire/store. **(2)** Client paint sanitize for U+2588 runs + U+FFFD only (not blanket ANSI strip). **(3)** Agent Diff: non-prefix → `is_full`. Client safety net: **clear-screen only** (`ESC[2J` / `ESC[3J`) → replace buffer + `paintFull` | -| **All panes** staircase / diagonal text / sparse dots after a “TUI formatting” fix | Worker set xterm **`convertEol: false`** because capture has CSI | **Keep `convertEol: true`**. capture-pane `-p` emits LF without CR; convertEol maps LF→CRLF so progressive shells start at column 0. Disabling it breaks *every* non-TUI stream. Do **not** “fix” TUI by turning convertEol off | -| Blank / wiped panes after TUI “safety net” | Client treated bare **`ESC[H`** (cursor home) as full-screen redraw → `paintFull`/reset on mid-frame updates | Full-screen detect = **clear only** (`2J`/`3J`). Bare cursor-home is normal mid-TUI traffic — append, do not reset | -| “Fix” by stripping all ANSI | Kills legitimate color/cursor on healthy TUIs | Never strip SGR/cursor as the primary fix; only remove corrupt paint artifacts | - -Portable rule: diagnose agent Diff → server bound → SPA paint **end-to-end**. Prefer bound + sanitize + clear-screen replace. **Never** set `convertEol: false` for capture-pane LF streams. Do not add cols/rows negotiation unless width mismatch is proven; do not strip all ANSI. - ---- - -## 41. Layout thrash: RO on self-mutating host + compact stuck on expand - -| Symptom | Likely cause | Default action | -|---------|--------------|----------------| -| Expand/resize: high-rate flicker + growing empty band under terminal | ResizeObserver watches the host that `applyHostLayout` / fit mutates → re-entry loop | Observe **stable parent** (clip box); skip refit when paint box same-size (epsilon); force refit only on real mode/layout change | -| Expand still thrash after thrash-guards | Shell hard-codes `compact` on every cell including expanded | Expanded mount = **non-compact** (full) fit; grid tiles stay compact; compact prop watch re-fits without buffer reset | -| convertEol “fix” for TUI | Turning `convertEol: false` to calm CSI | Never — see §30; thrash is layout/RO, not convertEol | - -Portable rule: **do not RO-observe an element your fit path mutates**. Mode flips (compact/full) force one refit; size-stable frames no-op. - ---- - -## 42. Hard-rename residual specs must not embed forbidden tokens - -| Symptom | Likely cause | Default action | -|---------|--------------|----------------| -| Residual `rg 'OldName\|/old/'` fails only on the residual test file itself | Audit file lists forbidden patterns as contiguous string literals | Build patterns from **parts** (`re('Old', 'Name')` / `parts.join('')`) so the audit source never contains the product token as a continuous match | -| Verification allows “intentional historical comments” only | Negative route-guard tests still need the old path string | Keep legacy guards in **one** file (e.g. router.spec); residual suite skips that file; product sources must be clean | - -Portable rule: residual greps are part of acceptance — residual **tests** must not be the only residual hits. - ---- - -## 43. Evidence mounts: register named routes used by child RouterLinks - -| Symptom | Likely cause | Default action | -|---------|--------------|----------------| -| Temp evidence page throws `No match for { name: '…' }`; shell never mounts | Evidence app uses `createMemoryHistory` with only `/`; child components render `RouterLink :to="{ name: 'machines' }"` (etc.) | Register every **named** route the tree resolves at setup time — even stub components | -| Shell root selector times out after PAGEERROR | Setup aborted mid-tree | Fix router first; re-check console pageerror before waiting on testids | - -Portable rule: evidence routers must satisfy child `RouterLink` / `router.push` name lookups, not only the top-level path under test. - ---- - -## 44. Dual-loop Registry: Start injects must register Lookup targets - -| Symptom | Likely cause | Default action | -|---------|--------------|----------------| -| Stream open (Start after create) but send_keys uses payload/stale local_key | `StartFn` override returned without writing cancel-map entry; `Lookup` miss | Mirror `StartDesiredFn`: always register `{cancel, localKey}` (host optional) before/with inject | -| First heartbeat Converge thrash-restarts streamer after create_pane Start | Start left `host==""`; Converge treated host change as replace | If `localKey` matches and entry host empty, **fill host** — do not cancel/restart | -| Missing id sentinel | Int zero vs empty string after ULID hard-cut | Empty string not zero for missing pane id (see §15) | - -Portable rule: inject hooks own **side effects only**; the supervisor map (streamers, connections) is still owned by the shared Registry so write-path Lookup and lifecycle Converge stay consistent. - ---- - -## 45. Dual-gate terminal input + capability unwrap must not silent-succeed - -| Symptom | Likely cause | Default action | -|---------|--------------|----------------| -| Role chip shows controlling but typing does nothing (no error) | `onData` gated only on permission (`canControl`) while board Focus / `keyboardActive` is a second gate; handlers half-bound when ownership flips | Single `inputEnabled = canControl && keyboardActive`; bind **and** dispose `onData` on that flag; wire flush uses the same predicate | -| Invoke returns 200 `{ ok: false, message }` and UI treats success | Unwrap only threw when `data` was present | **Always throw on `ok === false`** before reading `data`; surface humanized error on the action chrome with a testid | -| Evidence blank / keys not asserted | Stream path “fixed” by flipping convertEol | Do not touch stream paint for key-path REQs; keep `convertEol: true` (see §30) | - -Portable rule: dual ownership gates (permission + focus) must share one enable flag for bind/send/dispose; capability envelopes never resolve `ok: false` as success. - ---- - -## 46. Board / streaming list UI evidence: use product streaming states - -| Symptom | Likely cause | Default action | -|---------|--------------|----------------| -| Seeded panes load but board shows empty (“0 streaming”) | List filter uses product **streaming** states (`active` / `idle` / `paused` / `waiting_for_input`), not labels like `running` | Mock pane `state` from the product streaming set before screenshotting board/grid chrome | -| Cell chrome never mounts for evidence | Same | Assert board-cell testid after mock; do not screenshot empty-state as chrome proof | - -Portable rule: when a view partitions list rows by lifecycle state, evidence fixtures must use those exact state tokens — not colloquial synonyms. - ---- - -## 47. Capability Result `array` ≠ JSON object map - -| Symptom | Likely cause | Default action | -|---------|--------------|----------------| -| Cap invoke / MCP returns `output_invalid` / “unexpected format” though `run()` works in unit tests | Result DTO property typed PHP `array` → JSON Schema **`type: array` (list only)**; payload is an associative map (JSON **object**) | Type maps as nested SchemaProvider (`#[Field(of: …)]`) or override schema to `object` / `object\|null`; never rely on bare `array` for key-value payloads | -| Same after first agent heartbeat, null metrics worked | Null passes list-or-null; non-empty metrics map fails list check | Contract-test **both** empty/null **and** object snapshot through **OutputValidator**, not only direct `run()` | -| Nested Carbon / models in map | `exportValue` does not stringify Carbon | Wire-safe payload helper: ISO strings (or null) for all date fields before Result construction | - -Portable rule: **PHP `array` in CapabilityData = JSON list**. Associative domain maps need object schemas + JSON-safe scalars. Prove with pipeline tests that call OutputValidator. - ---- - -## 48. Shell generators for CI must run on bash 3.2 - -| Symptom | Likely cause | Default action | -|---------|--------------|----------------| -| Fixture test fails on macOS with `declare: -A: invalid option` | Script used bash 4+ associative arrays; `/bin/bash` on macOS is 3.2 | Parse checksums with a linear scan/`lookup` function; avoid `declare -A`, mapfile, and other bash-4 features | -| Works in CI (ubuntu bash 5) but fails locally | Same | Author + fixture-test with `#!/usr/bin/env bash` under macOS default bash before merge | - -Portable rule: monorepo release/helper scripts maintainers run on Mac must be bash 3.2-clean; prove with a committed fixture test, not only Linux CI. - - ---- - -## 49. Sanctum logout tests: forgetGuards after token delete - -| Symptom | Likely cause | Default action | -|---------|--------------|----------------| -| Feature test: token row deleted but next `$this->withToken($plain)->getJson(...)` still **200** | Same PHP process keeps auth guards resolved; Sanctum may reuse the already-authenticated user without re-looking up the deleted PAT | Call `Auth::forgetGuards()` before the post-logout request that asserts **401** | -| Production still correct | One request per PHP-FPM worker | Do **not** change product logout for the test; only clear guards in the test | - -Portable rule: when asserting “revoked Bearer is unusable” in Laravel HTTP tests, clear guards between the revoke call and the re-auth attempt (and still assert the token row is gone). - ---- - -## 50. SPA logout API + unauthorized handler re-entry - -| Symptom | Likely cause | Default action | -|---------|--------------|----------------| -| `logout()` POSTs `/api/logout`, 401/403 fires `setUnauthorizedHandler` → calls `logout()` again; boolean `loggingOut` early-return skips `clearLocalSession` for awaiters | Re-entrancy flag returns without joining the in-flight promise | Share **one** `logoutPromise`; concurrent callers `return logoutPromise` so they await the same finally-clear | -| Local session stuck “logged in” after failed server revoke | Clear only on success | Always clear local storage in `finally` after best-effort POST (even network failure) | -| Unit tests assert no Authorization header | Token only on `tokenGetter`; store not wired | Pass `token: bearer` explicitly into `apiRequest` for logout/revoke paths | - -Portable rule: intentional SPA sign-out = best-effort server revoke **then always local clear**; re-entry must join the shared promise, not a bare boolean skip. - ---- - -## 51. Laravel `lockForUpdate` under sqlite Pest — prove atomicity, not SQL - -| Symptom | Likely cause | Default action | -|---------|--------------|----------------| -| `DB::listen` never sees `for update` though code calls `lockForUpdate()` | `SQLiteGrammar::compileLock` is a no-op; phpunit often uses `:memory:` sqlite | Do **not** assert lock SQL on sqlite. Prove concurrent-safe intent with (1) sequential double-complete fails closed, (2) forced mid-write failure (model `updating` hook) so `token_hash`/`completed_at` (or equivalent pair) both roll back only when wrapped in `DB::transaction` | -| True multi-connection race | Needs MySQL/Postgres | Optional MySQL feature only when AC requires; default suite stays sqlite-friendly | - -Portable rule: for transactional + row-lock domain writes, acceptance on sqlite = **atomicity rollback + sequential double-op**; document that production MySQL/Postgres gets real `FOR UPDATE`. - ---- - -## 52. Sanctum feature tests after logout need guard reset - -| Symptom | Likely cause | Default action | -|---------|--------------|----------------| -| Second request with deleted token still 200 / same user | `Auth::` / Sanctum guard caches the user from the first actingAs/Bearer call | Call `Auth::forgetGuards()` (or fresh HTTP client) between logout and the 401 assert | -| revoke-all tests flaky with multiple tokens | Same | Resolve `currentAccessToken()` once; assert token row deleted via DB, not only response | - -Portable rule: after deleting the current PAT in a Pest feature test, **forget guards** before the next `$this->withToken(...)->getJson` so 401 is real. - ---- - -## 53. SPA logout: single-flight + always clear local - -| Symptom | Likely cause | Default action | -|---------|--------------|----------------| -| Double Sign out races two POSTs / leaves token | `logout()` fire-and-forget without shared promise | Share one in-flight logout promise; await from nav + 401 handler | -| Network fail leaves stolen token usable | Clear only after 2xx | `try { POST /api/logout } finally { clear localStorage }` | - -Portable rule: server revoke is best-effort; **local clear is mandatory**. - ---- - -## 54. Local-echo compose hold vs host slash/skill palettes - -| Symptom | Likely cause | Default action | -|---------|--------------|----------------| -| After `/`, local chars show but host skill/command list never filters | Compose mode holds host paints until Enter; first `/` clears hold once, then later printables re-assert local-echo hold; overlay heuristic misses small filter rewrites | Enter sticky **slash mode** on `/` that keeps painting host frames through filter keys; exit on Enter (skill run). Keep normal compose hold for non-slash lines | -| Overlay detection only fires on big menus | `+N non-empty lines` / large byte delta thresholds | Treat in-slash-mode frames as paint-through even for small deltas; unit-test filter rewrites | -| Fix by always painting host while typing | Drops local-echo contract; mid-type lag corruption returns | Dual policy: slash mode live host; non-slash hold until after Enter | - -Portable rule: **host-drawn progressive UIs (slash palettes, completion menus) need sticky host-paint mode**, not a one-shot release on the first special key. - ---- - -## 55. Email-verify gates need grandfathering + resend throttle + client signal - -| Symptom | Likely cause | Default action | -|---------|--------------|----------------| -| Shipping `MustVerifyEmail` + invite/billing gates locks out every existing account | Historical signups have `email_verified_at` null; no migration | Deploy migration: set `email_verified_at = COALESCE(created_at, now())` for existing nulls; new signups stay unverified until link | -| Resend endpoint mail-bombs | Only `auth:sanctum` on verification resend | `throttle:6,1` (Breeze default) on resend route | -| SPA cannot prompt verify after 403 | Login/register payload omits verification state | Emit `email_verified_at` (ISO or null) on auth payloads / shared AuthUserPayload | -| SPA still drops the chip at runtime | Client `AuthUser` type / cast omits field; no normalize on persist | **Also** type `email_verified_at`, normalize on load/persist (null for legacy localStorage); tests need valid public-id fixtures if normalize calls `parsePublicId` | - -Portable rule: when a new high-risk gate depends on a previously unused verification flag, **grandfather existing rows**, rate-limit resend, and **surface the flag on login** so clients can recover without opaque 403s. - ---- - -## 56. Capacitor WebView CORS — always-merge shell origins - -| Symptom | Likely cause | Default action | -|---------|--------------|----------------| -| Native shell login preflight fails though SPA uses Bearer tokens | CORS allowlist only lists Vite/Herd hosts; WebView Origin is `capacitor://localhost` or `https://localhost` | Always **merge** fixed Capacitor origins into `cors.allowed_origins` (env alone replaces defaults and drops them) | -| Operator “fixed” defaults in cors.php but prod/local still broken | `CORS_ALLOWED_ORIGINS` env fully overrides the default string | Prefer code-level merge of shell origins; document livepane.io / admin still in env for browser SPA | -| Touched `SANCTUM_STATEFUL_DOMAINS` for Capacitor | Assumed cookie SPA | Token-only + no `statefulApi()` → document Bearer path; CORS is the real gate | - -Portable rule: for Bearer SPA shells, **CORS origin allowlist** is the native blocker; **always-merge** fixed WebView origins rather than relying on env defaults alone. - - ---- - -## 57. Worktree teardown can poison main package autoload - -| Symptom | Likely cause | Default action | -|---------|--------------|----------------| -| Main `php artisan test` fails with class not found under `.worktrees/req-…` path after workers finish | Composer ClassLoader map still points at a removed worktree (symlink vendor / path repo dump during worktree install) | After Stage B teardown: `composer dump-autoload` (or reinstall) in the **main** package root before residual suites | -| `cap:sync` fails `Unable to find node_modules/@capacitor/android` on integration branch after package.json merge | Lock/deps merged but main checkout never ran `npm ci` | Run `npm ci` in `packages/web` on the integration base before residual `cap:sync` / path-unit close | - -Portable rule: parallel worktree merges land files; **main** still needs a fresh autoload/deps install before integration residual gates. - ---- - -## 58. Multi-server agent: Registry **per server**, not one shared process map - -| Symptom | Likely cause | Default action | -|---------|--------------|----------------| -| desired_streams / create_pane from server A hit server B’s token or cancel map | One process-wide stream Registry + client shared across multi-server stacks | **One client + Registry per ServerEntry**; share SessionHost plugins only (local discovery) | -| One control plane 5xx kills the whole agent | Main `errCh` exits on first loop error; heartbeat treats initial fail as fatal | Per-stack log-and-retry; root ctx cancel only on signal; initial heartbeat error is non-fatal | -| Dual-loop “share one Registry” lesson applied across servers | §11 meant share within one server (heartbeat Converge + command Start) | Keep §11 **within** a stack; multi-server multiplies full stacks | - -Portable rule: multi-endpoint agents = N independent control stacks (client + heartbeat + commands + Registry). Dual-loop sharing is **intra-server**; isolation is **inter-server**. - ---- - -## 59. Multi-server agent fan-out: Registry per endpoint + name-keyed claim - -| Symptom | Likely cause | Default action | -|---------|--------------|----------------| -| One control plane's 5xx kills the whole agent | Single shared errCh / first-fatal exit across servers | Per-server stack (client + heartbeat + commands + stream Registry); root cancel only on OS signal; remote errors retry in that stack only | -| Claim overwrites production when adding staging | Single-object config Save | Multi-server `Servers[]`; `UpsertServer` by free-form name; legacy single-object auto-migrates on Load | -| Parallel claim-merge + run-loop REQs conflict | Both list `cmd/.../main.go` | Serial those leaves; pair only disjoint packages in the same wave | - -Portable rule: **N endpoints → N isolated supervisor stacks**; identity of a stack is config **name**, not a fixed enum of env keys. - ---- - -## 60. Fail-closed quota earlier breaks “create then enforce on accept” fixtures - -| Symptom | Likely cause | Default action | -|---------|--------------|----------------| -| Happy-path invite/create tests fail after adding assert-at-create | Free plan already at limit (owner fills max seats=1); fixtures still assume free can create pending rows | Happy-path fixtures need **seat room** (paid plan / higher limit). At-limit unit asserts `QuotaExceeded` on **create** | -| “Accept still enforces” tests die at invite step | Same — create now fail-closes | For defense-in-depth accept tests: **create under room**, then **downgrade / fill seats**, then accept; or insert the pending row directly | -| Feature cap invoke on free org `assertOk` fails with `domain_error` | Cap surfaces domain `QuotaExceeded` as `domain_error` | Assert `ok=false` + `domain_error` at create; keep a separate pro→free accept 422 path | - -Portable rule: when moving quota fail-closed earlier in a multi-step flow, rewrite fixtures into (1) room for happy path, (2) at-limit on the new gate, (3) downgrade/fill for residual later-gate defense-in-depth. - ---- - -## 61. At-limit list cards: free-slot primary chrome, Upgrade secondary - -| Symptom | Likely cause | Default action | -|---------|--------------|----------------| -| Sessions at quota; Upgrade banner dominates; card End/Stop stay ghost outline | Free-slot recovery is demoted visually to secondary chrome | At `at_limit`, style free-slot actions (**End session** / **Stop watching**) as **primary** filled buttons; keep **Upgrade** outline secondary on the card | -| One label for both destroy and soft-unwatch | Shared “End” / “Drop” copy | Distinct labels: **DISCOVERED** concurrent → Stop watching; controller live (created) → End session | -| Free-slot control disabled when at limit | `disabled = busy \|\| at_limit` | Busy-only disable (see §27) | - -Portable rule: when the product recovery path at a cap is free-a-slot, **list free-slot controls lead the card** (primary + enabled); billing Upgrade is optional secondary, never the only visible path. - ---- - -## 62. Partial-confidence leaves sink verify without blocking shippable UX - -| Symptom | Likely cause | Default action | -|---------|--------------|----------------| -| `go` stops at ~85% with full brief coverage (10/10 ranks) | Capture marked legal/ops/deep-integration leaves `partial`; each unacknowledged partial is −3 (cap −15) | Before go: either upgrade Integration to **high**, or put those ids in **acknowledged_partials** when partial is intentional (approval-gated / env-unknown) | -| Operator re-captures to chase score | Partials are not coverage gaps | Do **not** invent REQs — score math is partial-conf only | -| Force-run of a large multi-rank UR | Threshold gate is soft under `--force` | Prefer **force** (or acknowledge) then ship high-priority UX leaves first; residual-close path-units as children Done; leave ops/legal leaves for later waves | - -Portable rule: **partial ≠ missing**. Approval-gated workstreams may stay partial forever; acknowledge them so go can auto-run, or use `--force` and pick implementable leaves by footprint. - ---- - -## 63. Ops TLS/SNI already green on an ops REQ - -| Symptom | Likely cause | Default action | -|---------|--------------|----------------| -| AC is `curl -fsSIL https://host/` → 200 but journey report still said SSL broken | Cert issued after capture; LE Active between intake and worker | Re-probe runtime first; if green, **document repair + package a smoke script** — do not invent a failure or mutate Forge SSL from the worker | -| Deep links 200 but wrong body (home shell) | Separate from TLS: SPA `try_files` / stale deploy without postbuild mirrors | Keep content checks **optional** (`VERIFY_CONTENT=1`) unless AC requires titles/H1; note redeploy as residual, not `verification-failing` when AC is TLS-only | -| Worker wants to `forge deploy` to finish content residual | Deploy gate non-delegable | Document § nginx + postbuild; leave deploy to human/orchestrator | - -Portable rule: for ops SSL REQs, **TLS probe is the AC**; process docs + smoke are the in-repo deliverable when production is already green. - ---- - -## 64. Public release-only repo vs private monorepo install URLs - -| Symptom | Likely cause | Default action | -|---------|--------------|----------------| -| Docs still advertise `raw.githubusercontent.com//…/install.sh` for anonymous install | Ship status was “assets may be private”; later only a release-only public repo went live | **Prove with `curl -fsSIL` (no auth)** against the public asset host first. Point brew + curl install at the **public release repo**; document monorepo private residual as expected 404 | -| curl\|bash 404 though tarballs are public | Install script only lives in private monorepo tree | Attach install script as GoReleaser `extra_files` (and bootstrap current tag with `gh release upload` when needed) | -| Probe script reports 404 on browser download URL | Sent `Accept: application/vnd.github+json` on github.com/raw URLs | Use API Accept only on `api.github.com`; bare follow-redirects for asset URLs | - -Portable rule: **anonymous install surface = public release host + public tap only**. Never teach monorepo raw URLs as the primary path when source stays private. - ---- - -## 65. Policy `blocked_paths: .env.*` catches `.env.example` - -| Symptom | Likely cause | Default action | -|---------|--------------|----------------| -| `check-policy` exit 1 `blocked_path: …/.env.example matches .env.*` though no secrets | Config glob treats example env templates as blocked | Prefer documenting new env keys in `config/*.php` defaults + docs; if example must change, **exclude** `.env.example` from the worker commit before Stage B, or narrow `security.blocked_paths` to exact `.env` / `.env.local` (project config) | -| Reviewer passes; archive still blocked | Orchestrator treats exit 1 as hard block | Fix branch before `archive_req` — never skip policy | - -Portable rule: Laravel monorepos almost always commit `.env.example`; token-boundary blocked_paths should not use a bare `.env.*` glob unless the project truly forbids example files. - ---- - -## 66. Stage B merge blocked by native-shell rename + symlinks on integration tree - -| Symptom | Likely cause | Default action | -|---------|--------------|----------------| -| `git merge` fails: `…/App.xcodeproj/project.pbxproj is beyond a symbolic link` / `Cannot save the current worktree state` | Concurrent Capacitor/iOS WIP on the integration checkout: tracked rename to `LivePane.*` plus `App.*` → symlink; git autostash cannot walk the path | Before Stage B: isolate foreign WIP (`stash` or move aside + `git checkout HEAD -- packages/web/ios`), merge worker branches, then restore WIP | -| Autostash keeps firing even with clean feature branches | Dirty index still holds the rename/symlink hybrid | Reset only the package path that is not in the REQ footprint — do not discard unrelated operator work without a side path (`/tmp/…` or named stash) | - -Portable rule: **integration-base dirt from mobile/native renames blocks every merge**, not only iOS REQs. Orchestrator Stage B assumes a mergeable index; clear symlink-rename hybrids first. - ---- - -## 67. Host inventory fields vs user-owned display labels - -| Symptom | Likely cause | Default action | -|---------|--------------|----------------| -| User renames a discovered/list item; next heartbeat restores host name (`42`, workspace id, …) | Reconcile/adopt **always writes** host `title` (or equivalent) onto the same column the UI shows | Split **host label** vs **user display**: set a `*_user_set` (or `custom_*`) flag on user edit; adopt/reconcile updates host fields but **never** overwrites display when the flag is set | -| List shows meaningless host ids (digits, opaque keys) as the primary name | UI prefers host session title over cwd/path/context | Display order: **user title if set → path/cwd (or other location) → host title → command → Untitled**; unit-test the helper | -| Path default “works in UI” but cards stay numeric | Agent inventory never sends cwd/path; only host title | Agent layer: report location (e.g. pane current path) on discovery rows; server stores it; SPA reads it | - -Portable rule: **discovery reconcile owns host facts; user rename owns display identity**. One column for both without a user-set guard will thrash. - ---- - -## 68. List-card identity UX — grill forks before capture - -For briefs that rework **list/card identity** (name placement, chips, rename, default label), ideate gaps that **change the REQ graph** (not polish): - -| Fork | Why it changes capture | -|------|------------------------| -| Who may rename (controller / owner / any viewer) | Authz domain + HTTP 403 cases | -| Default label source (host title vs path/cwd vs always-path-until-rename) | Display helper + agent inventory + server store | -| Edit gesture (pencil always-on vs click vs long-press mobile) | Component interaction tests + stop-propagation vs open-card | -| Surfaces (one list vs every card that shows the name) | Footprint: one shared helper + N mounts, or path-unit residual | - -Self-answer pass may infer “no product PATCH yet” from routes — still **ask** permission + default + gesture; those are product calls, not codebase facts. - -Portable rule: card-identity work is usually **server (user-set + API) + agent (location) + web (layout + edit)** under one path-unit; grill the four forks before splitting leaves. - ---- - -## 69. Harness worktree path ≠ project `req/*` branch for Stage B - -| Symptom | Likely cause | Default action | -|---------|--------------|----------------| -| Worker reports `done` + commit, but orchestrator looks under `{project}/.worktrees/req-…` and finds nothing / wrong tip | Host spawn with `isolation: worktree` puts the child under a **harness path** (e.g. `~/.grok/worktrees/…/subagent-`) while do-work still creates/uses **`req/`** on the shared repo | Stage B: resolve **`git log new-work..req/`** (or Linear sanitize id) and **`git merge --no-ff req/`** from the **recorded integration base**. Do not require the harness worktree path | -| Two worktree trees for one REQ | Worker also ran `git worktree add .worktrees/req-…` per run-worker.md | Teardown both after archive; prune; `git branch -d` only after no worktree holds the branch | -| Heartbeat/claim still on Linear | Correct — claim is not filesystem under Linear | Archive via Linear; git isolation is local | - -Portable rule: **feature branch name is the merge handle**; harness worktree paths are ephemeral execution sandboxes. Always assert `git branch --show-current` equals the go/run integration base before merge (see §18). - ---- - -## 70. Integration-base dirt: partial same-feature WIP blocks Stage B - -| Symptom | Likely cause | Default action | -|---------|--------------|----------------| -| `git merge` aborts: local changes to `sessionList.ts` / API client would be overwritten | Main/integration checkout has **incomplete** uncommitted edits of the **same** files the worker branch completed | Prefer the **worker branch as truth**. `git checkout -- ` or stash **only those paths**, merge, then drop the stash if it was half-done sibling WIP — do not merge on top of dirty same-path dirt | -| After `git stash` HEAD is a foreign branch | Stash/checkout in a multi-worktree monorepo switched the main shell off `new-work` | Immediately re-assert integration base (`git checkout `); never Stage B from a surprise branch (see §18) | -| Partial WIP looked “helpful” | Prior agent or human started the same REQ on the integration tree | Integration base should stay merge-clean; implementation lives on `req/*` only | - -Portable rule: **dirty same-footprint files on the integration tip are not a second feature branch** — clear them, merge the REQ branch, re-run package filters. - ---- - -## 71. Audit footprint to unlock package-parallel leaves - -| Symptom | Likely cause | Default action | -|---------|--------------|----------------| -| `--parallel N` still serialises agent + server though packages differ | Agent REQ `**Files:**` includes `packages/server/app/Domain/Sessions` “for cwd persist” while a server leaf already owns Adopt/Reconcile | At **audit** (or capture): put **wire/inventory** on agent footprint only; put **persist/API/flag** on server; AC text must match ownership so workers do not both edit server | -| Path-unit still lists every package | Residual-close after children Done — do not re-implement | §25 residual-close first | - -Portable rule: **AC ownership drives footprint**. If two leaves share a package path only because of a cross-layer note, move the note to Integration/Depends and keep `**Files:**` to the package that will commit changes — that is what pick/overlap uses. - ---- - -## 72. Capacitor iOS: env(safe-area-inset-*) = 0 under edge-to-edge WebView - -| Symptom | Likely cause | Default action | -|---------|--------------|----------------| -| SPA content draws under the iOS status bar / Dynamic Island though `viewport-fit=cover` and `--safe-area-top: env(...)` exist | Cap WKWebView paints full-bleed but reports `env(safe-area-inset-top)=0` (token cascade is fine; the env value is the lie) | Floor insets on native iOS only: `html.native-ios { --safe-area-*: max(env(...), Npx) }` toggled from a tiny pure probe (globalThis.Capacitor — **no** StatusBar-plugin-only). Keep real env when non-zero | -| Login/register padded with direct env() “look fine” while authenticated shell still clips | Shell uses CSS vars that resolve to 0; auth views use the same env and also get 0 — or shell padding is escaped by fixed chrome | Apply the floor to the **shared tokens** so shell, sticky top, fixed bottom-nav, toasts all inherit; dual-write env() then `var(--safe-area-*)` on `.app-shell` so custom-prop capture is not the only path | -| Desktop / Android over-padded after the floor | Root class applied on every platform | Gate class with `isNative && platform === 'ios'` only; desktop media queries stay untouched | -| UI evidence “passes” without proving the floor | Headless env is always 0 | Mock `html.native-ios` + force tokens to 0, overlay a status-bar band, vision-assert title/app-top **below** the band (screenshot under UR ui-evidence) | - -Portable rule: **CSS env alone is not enough when Cap lies with 0** — keep viewport-fit=cover, prefer CSS over StatusBar-only, and floor shared safe-area tokens on native iOS so shell chrome cannot paint under system bands. - - ---- - -## 73. Fluid presence lists — grill holding pen + revive-on-return - -For products where **host resources come and go** (discovered sessions, agents, devices) and the UI buckets live vs available: - -| Fork | Why it changes capture | -|------|------------------------| -| Holding pen vs vanish | SPA often **omits** terminal states; product may need a third group so churn is visible | -| Membership (which states) | `gone` only vs completed/all non-live — changes partition + AC | -| Retention window + owner | SPA filter on `updated_at` vs server purge vs no window — changes server REQs | -| Temporary return | Inventory reappear must **revive** same identity (not skip terminal + insert duplicate) | -| Deliberate unwatch vs miss | Durable exclude ≠ temporary inventory gap — separate paths in AC | -| Surfaces | List-only vs list+nav vs board — footprint split | - -| Symptom | Likely cause | Default action | -|---------|--------------|----------------| -| Terminal state “disappears” from UI | Partition only keeps live buckets | Add holding-pen bucket; unit-test partition | -| Host returns but list shows two rows or stays terminal | Adopt/match query **excludes** terminal rows | Revive non-excluded terminal rows on re-inventory; never create a second live row for the same host key | -| “Unavailable forever” after brief miss | Soft-unwatch/exclude conflated with inventory miss | Mark miss as terminal **without** durable exclude; exclude only on explicit user unwatch | -| Partition has holding pen but list/nav still two-bucket | Wire incomplete: only `partition` unit green | Same REQ: (1) list third group + testids, (2) prev/next ordered ids append holding pen after available, (3) empty-state when **all** buckets empty, (4) board/grid stays live-only. Holding-pen rows: identity chrome, **no** primary free-slot / Watch, prefer non-openable | - -Portable rule: **fluid lists need three contracts** — (1) which states enter the holding pen, (2) how long they stay visible, (3) revive-on-return without duplicate identity. Grill those before splitting server/web leaves; agent inventory often already emits presence. **UI wire is a fourth surface** after partition exists: list group + nav order + empty gate + board exclusion. - ---- - -## 74. Capacitor Stream off — bake Reverb + native loopback rewrite - -| Symptom | Likely cause | Default action | -|---------|--------------|----------------| -| Header **Stream off** on device after login while REST works | `cap:sync` / prod native build only bakes API URL; `VITE_REVERB_*` stays loopback (`127.0.0.1:8080`) | Bake `VITE_REVERB_HOST` / `PORT` / `SCHEME` / `APP_KEY` in the same script as `VITE_API_URL` (match browser deploy contract, e.g. host:443 https) | -| Bake fixed but chip still off | `resolveReverbConnection` only rewrites loopback for **https:** pages; Cap origin is `capacitor://` / `ionic://` or `native=true` | Pure planner: **native or Cap protocol + loopback env → public WSS host** (forceTLS). CapacitorHttp does **not** patch WebSockets | -| Auth `/broadcasting/auth` 401 while WS host is correct | Separate from WS host — use absolute `apiBase()` bearer auth (already native-safe when API base is baked) | Do not “fix” Stream by only touching CORS; prove WSS host + key match server Reverb app | -| Local browser Herd/Vite still needs loopback | Over-eager native rewrite | Keep existing http-page + loopback behaviour; only rewrite when native/Cap context | -| Host/PORT/SCHEME baked but device still Stream off | `VITE_REVERB_APP_KEY` silently defaulted to `local-reverb-key` in cap:sync | **Fail closed** preflight on missing/empty/placeholder before native bake; require export of prod key (match API `REVERB_APP_KEY`). Never `:-local-reverb-key` for Cap dist | - -Portable rule: **native shell = bake public Reverb at build + planner treats Cap/native like a non-HTTPS mixed-content case.** REST CORS/API base green does not imply Echo/Reverb green. **App key is part of the bake contract** — fail closed rather than ship a local placeholder. - - ---- - -## 75. `check-policy` exit codes: 1 blocks, 2 only requires review - -| Symptom | Likely cause | Default action | -|---------|--------------|----------------| -| Orchestrator treats `check-policy` exit **2** as Stage B hard-stop | Confused with exit **1** (security block) | Exit map: **0** clear → continue; **1** blocked path/command → do **not** archive until fixed; **2** `review_required` (e.g. `files_changed_over`, `acceptance_criteria_over`, auth/billing risk tags) → **dispatch/pass post-build review**, then archive. Exit 2 is **not** a merge/archive veto once review returns `passed` | -| Large UI leaf (specs + views) always hits `files_changed_over: 8` | Risk threshold working as designed | Keep the threshold; ensure review notes the signal and still scopes the diff — do not raise the cap to silence exit 2 | - -Portable rule: **exit 1 = security stop; exit 2 = review mandatory context**. Never skip review on exit 2 when `review.required: true`; never treat exit 2 like a blocked path. - ---- - -## 76. Transport stream-off must not paint fatal session chrome - -| Symptom | Likely cause | Default action | -|---------|--------------|----------------| -| Session body shows fatal error empty state while header is **Stream off** and REST/agent healthy | WS `failed` / channel `subscribe_error` mapped to status **`error`** (red chip + error body) instead of recoverable **`offline`** | Pure status reducer: transport failures → **offline** (clear errorMessage); keep **error** only for hard identity/config (e.g. invalid public id) | -| Empty copy still reads like a dead-end / “Something went wrong” | Offline empty reused hard-error language or API humanize fallback | Offline empty = Stream on/off product language + reconnect; unit-test never matches `/something went wrong/i` for offline/connecting | -| Agent help missing when stream is off | Empty helper skips agent line on error-class paths | Stream off ≠ agent offline — still append agent online/hb help when known (see §10b) | -| Bake silent `local-reverb-key` (Cap Stream off forever) | cap:sync defaulted placeholder APP_KEY | Fail-closed preflight before native bake (missing/empty/placeholder); export prod key matching server `REVERB_APP_KEY` (see §58) | - -Portable rule: **transport down is recoverable stream chrome, not a session error page.** Align pane chip with header Stream off; reserve hard error for non-transport failures. Pair with fail-closed native Reverb key bake when Cap shells are in scope. - ---- - -## 77. Monorepo brief packages vs `layers:` + Linear `Layer/*` labels - -| Symptom | Likely cause | Default action | -|---------|--------------|----------------| -| Brief names a package (`admin`) but capture only tags `server`/`agent`/`web` | `.do-work/config.yml` `layers:` lag the monorepo packages table | At intake/capture for multi-package briefs: **expand `layers:`** (or record an explicit layer decision) so undeclared packages are not silently out of scope | -| `save_issue` fails: `Could not resolve label(s): "Layer/admin"` | Label never created on the team; body `**Layer:** admin` alone is not enough for Linear | **Create** `Layer/{name}` via `create_issue_label` (team-scoped) **before** first Issue that needs it; body header remains required either way | -| Worker/run treats admin SPA as `Layer/web` | Wrong footprint merge with product SPA | Keep **package-aligned** layers when packages are separate apps (`packages/admin` ≠ `packages/web`); Cap/iOS/Android shells that **build from web** stay **web**, not a fifth layer | - -Portable rule: **brief packages ∩ monorepo packages must be in `layers:` (or deliberate opt-out).** New layer names need Linear labels pre-created under `backend: linear` before bulk `create_req`. - - ---- - -## 78. Fail-open analytics never holds a hot-path mutex - -| Symptom | Likely cause | Default action | -|---------|--------------|----------------| -| Stream/registry/control loop stalls when analytics is enabled | Lifecycle hooks call capture **synchronously under a lock** while HTTP transport blocks | Emit analytics **after unlock** or via **goroutine** (`go client.Capture(...)`); unit-test with a recording transport + short wait when async | -| Missing key / 5xx breaks run | Analytics errors not fail-open | Empty key = no-op; swallow transport errors; never panic the product loop | -| PII leakage on agent events | Free-form props (cwd, title, command, path) | Strict **event + property allowlists**; force `app=`; distinct_id = public machine/user id only | - -Portable rule: optional product analytics is **fire-and-forget + allowlist**; hot supervisors must not wait on the analytics network hop. - ---- - -## 79. PHP PostHog dual-write tests: mutable log object, not `return [&$sent]` - -| Symptom | Likely cause | Default action | -|---------|--------------|----------------| -| Recording transport "sent 0 events" though capture ran | Helper returns `[$client, &$sent]`; list-assignment **copies** the array and drops the reference | Hold rows on a **mutable object** (`$log->rows[] = …`) shared with the transport | -| Claim lifecycle analytics never fire in test though production path is wired | `app(CompleteX)` resolved before `instance(Analytics::class)` bind | Bind `PostHogClient` **and** the lifecycle wrapper **before** resolving the domain authority | -| Analytics inside `DB::transaction` rolls back with claim | Capture before commit | Emit **after** successful transaction return; keep fail-open so transport throw cannot undo claim | - -Portable rule: **dual-write product-truth after commit**; unit tests use object logs for capture spies; bind both client and domain analytics before `app(Authority)`. - ---- - -## 80. XcodeGen `project.pbxproj` multi-REQ merge conflicts - -| Symptom | Likely cause | Default action | -|---------|--------------|----------------| -| Parallel/serial ios leaves all touch `LivePane.xcodeproj/project.pbxproj` | XcodeGen regenerates the whole project file whenever sources are added; two branches diverge the same UUID blob | Prefer **source files + `project.yml` as truth**. On Stage B conflict in `*.xcodeproj/project.pbxproj`: keep all Swift sources from both sides, run `xcodegen generate`, commit regenerated project | -| Integration tip dirt blocks merge after `make ios-test` | Local `xcodegen generate` dirty the committed pbxproj without a commit | `git checkout -- packages/ios/LivePane.xcodeproj` before Stage B merge, or always commit regen as part of the REQ | -| Footprint lists only sources but pbxproj still conflicts | Implicit regen artifact | Document in REQ Files that pbxproj may change; regenerate rather than hand-merge conflict markers | -| `--parallel N` serialises every ios leaf | Every REQ lists `…/LivePaneTests` (or equivalent) as a **directory** | Narrow `**Files:**` to concrete `*Tests.swift` paths per leaf before claim (audit Step footprint) | - -Portable rule: **never hand-merge XcodeGen pbxproj** — merge sources, regenerate, re-test. **Parallel width for Xcode packages is limited by shared tests-dir footprints + pbxproj**, not by N alone. - -**SPM first-build (SwiftTerm / Metal):** if `xcodebuild` fails with missing Metal Toolchain while resolving SwiftTerm, run `xcodebuild -downloadComponent MetalToolchain` once on the machine before treating the REQ as verification-failing. Document in package README for agent CI hosts. - -**Simulator DESTINATION:** do not hardcode a single device name (`iPhone 16`) as the only path — discover an available iPhone Simulator or accept `DESTINATION=` override (macOS agent images lag plan prose). - -**Native UI path-unit close:** product entry points (launch login, open session pane) are often **human** on Simulator; closure can be `degraded:evidence-by-test` via package suite + source presence when device/TestFlight is residual-by-clarification. - ---- - -## 81. Cap (WebView shell) strip + PWA — grill package forks before capture - -For briefs that **remove Capacitor/Cordova/hybrid shells** and/or **add PWA**, ideate gaps change the REQ graph. Grill before bulk `create_req` (same spirit as §52 card-identity / §57 fluid lists): - -| Fork | Why it changes capture | -|------|------------------------| -| Pure native sibling package vs Cap under SPA package | Cap often lives under `packages/web` (config, ios/, android/, `@capacitor/*`); pure SwiftUI/Kotlin may be a **separate** package. “Remove Cap” ≠ delete pure native | -| “Android web” / “mobile web” | Often means **browser/PWA install** for phone users, or **product web + admin** packages — not Cap Android product work / TWA this UR | -| Strip depth | Shell dirs only vs full surface: client Cap branches (platform, push, safe-area floors, Reverb native rewrite) + **server** always-merge WebView CORS origins + Cap-only tests | -| PWA depth | Installable **online-first** (manifest + SW network-first) vs full offline Workbox shell (stale API risk) | -| Push | Cap push drop this UR vs Web Push in-scope | -| Layers | SPA package + admin SPA + server; agent usually no; pure native package often **keep / no REQ** | - -| Symptom | Likely cause | Default action | -|---------|--------------|----------------| -| Capture deletes pure native or freezes wrong package | Brief said “native” / “Cap” loosely | Grill pure-native keep/drop; footprint Cap paths only under SPA package | -| Cap strip + PWA both claim `package.json` | Parallel leaves same footprint | **Serial**: strip Cap deps first, then add `vite-plugin-pwa` (or equivalent); path-unit blocked by both | -| Server still green on Cap CORS tests after Cap gone | Strip was client-only | Full surface strip includes `cors` always-merge + Cap origin tests; keep Bearer browser origins | -| Offline SW ships against live API/Reverb | “Set up PWA” without depth grill | Default online-first unless brief claims offline | - -Portable rule: **dual-shell monorepos need package forks in clarifications**; Cap lessons in field-lessons (CORS, safe-area, Stream bake) stay as **historical reverse-migration** maps after Cap is removed — do not invent product Cap ops after strip. - - ---- - -## 82. Cap strip residual greps: product vs negative tests - -| Symptom | Likely cause | Default action | -|---------|--------------|----------------| -| `rg 'capacitor://\|ionic://' packages/server` still hits after Cap CORS strip | Unit/feature tests use the scheme strings as **negative** expected values | Treat **product** residual as config/bootstrap/app only (`--glob '!**/tests/**'`). Keep deliberate negative tests; do not invent Cap origins in defaults | -| Always-merge still present after "removing comments" | `$capacitorWebViewOrigins` + `array_merge` left in `cors.php` | Delete the merge array entirely; defaults = browser Vite only from env/default string | -| Symlink `vendor` → TestCase / `configPath` missing in worktree | Composer ClassLoader base = main package | Real `composer install` in worktree package (see §7) + symlink `.env` (§21) | - -Portable rule: **Cap strip residual = product surface clean**; negative tests may still name forbidden origins. Prefer full-tree product path globs over zero absolute hits. - - ---- - -## 83. SwiftUI: ObservableObject VMs over @Observable stores need published mirrors - -| Symptom | Likely cause | Default action | -|---------|--------------|----------------| -| List UI stays empty after `await store.fetch()` though store has rows | VM is `ObservableObject`; domain store is `@Observable` / Observation. Reading `store.machines` in a computed property does **not** fire `objectWillChange` | Mirror list/loading/error into `@Published` properties on the VM after each refresh/rename; or observe the store directly in the View with `@Bindable` / Observation | -| Tests pass (assert store/VM arrays) but Simulator list blank until force-redraw | Same | Same mirror pattern; unit-test the published surface, not only store internals | -| Two observation systems mixed without a rule | Login VMs already use Combine; domain uses `@Observable` | Prefer thin Combine VMs for list screens that need explicit refresh; keep pure domain helpers as free functions | - -Portable rule: **one observation owner per view tree level**. If the View holds a Combine VM, the VM must publish every field the body reads after async work — do not rely on nested Observation notifications through an ObservableObject. - ---- - -## 84. Online-first PWA (vite-plugin-pwa): pure config + NetworkFirst + nodenext imports - -| Symptom | Likely cause | Default action | -|---------|--------------|----------------| -| AC only “add PWA” but product is live API/auth SPA | Default Workbox Offline-first / CacheFirst shell serves stale admin/API chrome | Ship **installable online-first**: `NetworkFirst` navigations + short timeout; **`NetworkOnly` for `/api` `/sanctum` `/capabilities`**; denylist those on `navigateFallback`; description must not claim offline | -| Multi-app monorepo reuses one product name | Shared “LivePane” manifest | Distinct `name` / `short_name` / `id` per SPA (e.g. LivePane Admin / LP Admin / livepane-admin) | -| `vue-tsc -b` fails on `vite.config.ts` import of `./src/lib/pwaConfig` under `module: nodenext` | Extensionless relative import | Import `./src/lib/pwaConfig.ts` (and include that file in `tsconfig.node.json`); keep strategy as **pure module** unit-tested — do not assert only via dist smoke | -| Suite cannot import `virtual:pwa-register` | `injectRegister: 'auto'` or main always loads virtual module in unit env | Prefer `injectRegister: false` + explicit `registerSW` in `main.ts`; unit-test pure config, not the virtual module | - -Portable rule: **online-first PWA = unit-tested manifest identity + NetworkFirst shell + NetworkOnly for auth/API**. Prove with `dist/manifest.webmanifest` + `dist/sw.js` on build, not offline demos. **Installability ≠ offline app.** Cap strip + PWA both claiming `package.json` → serial (see §65). - ---- - -## 85. Cap/deps strip: regenerate lock without symlinked node_modules - -| Symptom | Likely cause | Default action | -|---------|--------------|----------------| -| After removing `@capacitor/*` (or similar) from package.json, lock still lists them under a nested `node_modules/@…` path | Worktree `node_modules` is a **symlink** to a main tree that still has the old packages; `npm install --package-lock-only` walks the linked tree | **Remove the symlink** (or use a temp empty dir), run `rm package-lock.json && npm install --package-lock-only`, then restore an **absolute** `node_modules` symlink for tests | -| Residual `rg` still hits lock after package.json is clean | Same | Verify lock root `packages[""].dependencies` has no stripped deps; regenerate as above before commit | - -Portable rule: **lockfile generation must not see a foreign/stale dependency tree via symlink** when proving a dep strip. - ---- - -## 86. Integration tip after package.json merges: install before close/build - -| Symptom | Likely cause | Default action | -|---------|--------------|----------------| -| Close/path residual: `vue-tsc` / Vite fails `Cannot find module 'vite-plugin-pwa'` / `virtual:pwa-register` though worker builds were green | Workers installed deps **inside worktrees**; Stage B merges only files — **main** `node_modules` still pre-merge | On integration base before residual close or path-unit build gates: `npm install` (or `npm ci`) in each package whose lock/deps changed | -| Same class as Cap residual `npm ci` for native bake | §42 | Extend to any new build-time plugin (PWA, etc.), not only Cap | - -Portable rule: **merge lands sources; tip install lands tools.** Path-unit / close that claims “build emits SW” must install on the **merged** checkout first (see also §42 main autoload after worktree teardown). - ---- - -## 87. Residual greps must catch uncommitted secrets on the integration tip - -| Symptom | Likely cause | Default action | -|---------|--------------|----------------| -| Residual `rg 'phc_\|sk-\|AKIA'` hits after all leaves archived “no secrets” | Worker committed empty placeholders; **local uncommitted dirt** on integration tip re-introduced a real key (dogfood / paste) between Stage B and residual | Before residual-close **and** before any metadata commit: run secret-shaped greps on the **working tree** (not only `git show HEAD`). If a match is **uncommitted**, `git checkout -- ` (or restore empty placeholder) — do not stage it. If a match is **committed**, treat as Stage B / policy failure and strip in a fix commit | -| Worker AC “Release.xcconfig has no phc_” was green | True at worker commit time | Residual owns tip honesty; worker evidence does not cover later tip dirt | - -Portable rule: **archive evidence is historical; residual is tip-truth.** Secret-shaped residual greps run against the live integration checkout (tracked + dirty) before path-unit Done. - ---- - -## 88. Native SDK bootstrap graph — serialize shared project.yml - -| Symptom | Likely cause | Default action | -|---------|--------------|----------------| -| `--parallel N` underfills on “SPM package” + “AppConfig / Info.plist keys” leaves | Both declare `project.yml` (and often regenerated `pbxproj`) | Capture graph: **SPM leaf first**, then **config/plist leaf**, then facade, then wire ∥ docs. Do not invent parallel width across those two | -| Facade leaf also lists whole `Support/` + `Tests/` dirs | Overlaps prior AppConfigTests / Support files if too broad | Narrow facade **Files:** to concrete new sources (`ProductAnalytics.swift`, matching `*Tests.swift`); leave AppConfig footprint to the config leaf | -| Path-unit residual re-implements wiring | Children already Done | Residual-close with package suite + secret greps (§71) + docs presence only | - -Portable rule for native analytics/SDK URs: **package → config/fail-open → injectable facade → app wire → docs**, with **one choke file (`project.yml` / pbxproj regen) owned by at most one in-flight leaf**. Pair only docs with wire when footprints are truly disjoint (AGENTS/README vs App/Domain only). - - ---- - -## 89. Native cold restore must not wipe session on transport failure - -| Symptom | Likely cause | Default action | -|---------|--------------|----------------| -| User logs in; force-quit; reopen shows login though they never signed out | Cold-start restore validates token via network; **any** error (offline, TLS, 5xx, timeout) clears Keychain/local token | Clear durable token only on **definitive auth failure** (typically **401/403**) or explicit logout. On network/5xx: keep token, mark session ready (shell may show offline list errors) | -| “Auth persistence missing” when Keychain + restore already exist | Fail-closed wipe looks like missing storage | Audit restore **policy** before building a second store | -| Double clear / race on 401 | API client unauthorized hook + restore catch both clear | Prefer restore-owned clear for the probe, or suppress unauthorized fire during restore | -| Service keep-session green but app still boots login | Only `AuthService.restore` tested; root composition / splash-owned restore never maps session → shell root | Assert **composition root** after `bootstrap` (or equivalent long-lived owner): offline/5xx keep → authenticated root + token present; 401/403 + logout/clearLocal → unauthenticated + login. Bootstrap must live on a long-lived owner (app `.task`), not only a dismissible splash | - -Portable rule: **token persistence ≠ online validate success.** Offline-friendly products keep the bearer across process death unless the server rejects it or the user signs out. Prove at **composition root**, not only the restore service. - ---- - -## 90. Triple status chrome + session liveness — grill before capture - -When a host session list shows **Available** but open chrome says **Agent offline** / **Waiting**, treat three **independent** signals (do not collapse them in copy or AC): - -| Signal | Typical source | Means | -|--------|----------------|--------| -| List bucket (Available / streaming / Unavailable) | Resource **lifecycle state** on the server | Membership of list groups | -| Stream phase (Waiting / Live / Stream off) | Client ↔ realtime transport | Channel / bytes, not host process | -| Agent online | Parent link **heartbeat** for **this** workspace | Host agent for that claim only | - -| Symptom | Likely cause | Default action | -|---------|--------------|----------------| -| Open blames agent while host agent is fine | Empty canvas **leads with** agent_offline over stream/session-dead | Empty hierarchy: checking spinner first → true agent offline → session gone on host → stream wait/idle. Never paint “agent offline” when parent-link online and only the child session is dead | -| Dead rows linger in Available | Inventory still “live” or only passive stale; no **session-alive probe** | Grill: passive TTL vs **server→agent ping** (exists?); on definitive no → terminal holding pen immediately; fail-open on timeout (no false-gone) | -| User confuses 15m with “no activity 15m” | Holding-pen retention vs idle/stream silence | Document separately: **hide window** for terminal rows vs **liveness probe** vs stream idle | - -**Ideate forks that change the REQ graph** (same spirit as §57 fluid lists / §52 card identity): - -| Fork | Why it changes capture | -|------|------------------------| -| Passive stale vs active ping while agent online | Server MarkUnreachable vs new agent command + domain | -| When to probe (list / open / idle N) | Client leaves + API surface | -| Empty copy when agent online + session dead | Pure chrome helpers + tests on every client surface | -| Checking spinner while probe/stream settle | Open path UX; suppress fatal empty until settle | -| Surfaces (native / SPA / admin residual) | Layer fan-out; admin often list-only residual | - -Portable rule: **list membership ≠ agent heartbeat ≠ stream phase**. Capture must split server/agent probe work from client honesty chrome; grill liveness authority before implementing only UI labels. - From 41f4ce1c5be1b8a59090b087643356ca1cfdd01f Mon Sep 17 00:00:00 2001 From: Tom Kaczocha Date: Tue, 11 Aug 2026 10:48:54 +1000 Subject: [PATCH 17/17] docs: gate field-lessons to skill-process only Keep only lessons that improve the next do-work run; drop product and generic coding notes. Document the "Will this improve do-work?" append gate. --- SKILL.md | 2 +- references/field-lessons.md | 44 +++++++++++++++++++++++++++++++++++++ 2 files changed, 45 insertions(+), 1 deletion(-) create mode 100644 references/field-lessons.md diff --git a/SKILL.md b/SKILL.md index ee1d1a2..ce8171d 100644 --- a/SKILL.md +++ b/SKILL.md @@ -216,6 +216,6 @@ Load only when the active task needs them (one hop from this file): | [references/commands.md](references/commands.md) | Executing a subcommand; install bootstrap YAML; full step stubs | | [references/tracker.md](references/tracker.md) | Configuring or debugging multi-tracker / Linear keys, claims, commits | | [references/concepts.md](references/concepts.md) | Naming, milestone mode, parallel coordination, layers, path-units, decisions, REQ header schema, commit convention, checkpointed verification | -| [references/field-lessons.md](references/field-lessons.md) | Start of a skill run (read); end of run when a portable lesson exists (append). Always read before acting if present. | +| [references/field-lessons.md](references/field-lessons.md) | Start of a skill run (read); end of run append **only** if **“Will this improve do-work?”** is Yes (skill process for the next run). Product/repo lessons → project via session-capture, never here. Always read before acting if present. | Recovery (stuck / stopped / deadlock): `/do-work unblock`, `/do-work resume`, `/do-work status` — see agent files and [references/concepts.md](references/concepts.md#recovery-commands). diff --git a/references/field-lessons.md b/references/field-lessons.md new file mode 100644 index 0000000..462bb47 --- /dev/null +++ b/references/field-lessons.md @@ -0,0 +1,44 @@ +# do-work field lessons + +**Only lessons that improve the next do-work run.** Gate before every append: +**“Will this improve do-work?”** — Yes → here (skill process: claim, worktree, +verify, merge, tracker, recovery). No → project `AGENTS.md` / docs (session-capture), +not this file. No product names/ticket ids as substance. + +## 1. Laravel Pest in git worktrees — real vendor, not symlink + +| Symptom | Likely cause | Default action | +|---------|--------------|----------------| +| Pest fails all tests with `A facade root has not been set` / factories blow up | `vendor/` was **symlinked** from the main checkout into the worktree | **Do not** rely on a vendor symlink for Laravel. In the worktree app root (or package that owns `composer.json`): remove the symlink, run **real** `composer install`, keep `.env` linked/copied from main | +| `php artisan` boots but `php artisan test` / Pest does not | Same path mismatch: Pest/PHPUnit resolve base path via vendor location | Real vendor tree under the worktree package | + +`provision-worktree.sh` `linked: vendor` is fine for many stacks; for **Laravel + Pest** treat vendor as unprovisionable-by-symlink and install for real. + +## 2. Linear claim heartbeat in-place + +| Symptom | Likely cause | Default action | +|---------|--------------|----------------| +| Orchestrator passed a **claim comment id** and said update in-place only | Multi-agent claim protocol | Use `save_comment` with that `id` + refreshed `heartbeat` ISO; do **not** post a second active claim comment unless update tools are missing | + +## 3. Monorepo / nested package provision (depth) + +| Symptom | Likely cause | Default action | +|---------|--------------|----------------| +| provision-worktree exits 0 but nested package deps missing; tests cannot boot | Auto-detect is depth ≤ 1 (root + immediate children only); nested `package.json` / `composer.json` not seen | `worktree.link_paths` for monorepo dep dirs, or symlink them after provision before verification | +| Vitest / package tests cannot resolve deps in worktree | Nested package `node_modules` not linked (same depth limit) | Symlink **absolute** path from main checkout’s package `node_modules` into the worktree package before test | + +## 4. Path-unit close when children already shipped + +| Symptom | Likely cause | Default action | +|---------|--------------|----------------| +| Path-unit worker has nothing to implement; need branch evidence for merge | Layer children already on integration base | Do **not** re-implement. Re-run path verification (tests + ui re-vision + docs). If `.do-work/` is gitignored, write a local path-closure note under the UR dir for humans, and land an **empty commit** (`--allow-empty`) on `req/` so the orchestrator has a merge tip. Reuse prior `ui-evidence` PNG only after **re-vision-assert**; copy under the path-unit step name when useful | +| Worktree base wrong | Checkout HEAD ≠ named integration base | Create worktree from the **integration base named in the dispatch**, not from a dirty/other feature branch on the main checkout | + +## 5. Stage B: assert integration base before every merge + +| Symptom | Likely cause | Default action | +|---------|--------------|----------------| +| REQs merge into a random feature branch while go recorded a different integration base | Orchestrator shell checked out another branch mid-run (or never re-asserted base after sibling activity); Stage B used current HEAD | Before **every** Stage B `git merge`, run `git branch --show-current` and require equality with the recorded ensure-integration-base tip. If mismatch: `git checkout ` only when safe, or merge via worktree that holds the base — never merge “wherever HEAD is” | +| Feature commits exist but integration tip missing files | Merged onto wrong branch first | Cherry-pick / re-merge feature commits onto the recorded base; leave foreign branch as-is or reset only if operator owns that WIP | + +**Rule:** integration base is a **recorded name**, not “whatever the shell is on.” Parallel waves + multi-branch monorepos make silent checkout drift common.