diff --git a/.brain/config.json b/.brain/config.json new file mode 100644 index 000000000..7a73f856d --- /dev/null +++ b/.brain/config.json @@ -0,0 +1,4 @@ +{ + "brainId": "brn_w06loz", + "vaultDir": "brain" +} diff --git a/.genie/INDEX.md b/.genie/INDEX.md index d297b4550..5b585c3a9 100644 --- a/.genie/INDEX.md +++ b/.genie/INDEX.md @@ -12,12 +12,12 @@ ## Simmering - - khal-app-kit-identity + genie-remote-ssh — **RELOCATED 2026-07-23** with the whole khal-native-desktop track to `khal-os/genie-desktop:.genie/` (branch `wish/khal-native-theme`; Felipe: desktop context lives in the desktop repo). Second and third of the Theme→Identity→SSH order, both WRS 60 simmering there - [genie-boards-ui](brainstorms/genie-boards-ui/DRAFT.md) — WRS 70; genie desktop boards module: ABSORB of the G4 kanban decided by Felipe; tab + split-toggle layout tentatively locked ("that looks good", confirm at crystallize); archive section + per-project .genie-commit checkboxes added to scope; roadmap shape (per-project board + groom-with-agent task) recommended, awaiting Felipe; depended on ui-bridge board-payload extension (protocol 1.1) + genie-ui-dash G5 (2026-07-21) — **both retired 2026-08-30** (#2834, Felipe: Orca integration is the only UI); this draft is superseded unless re-scoped onto Orca - [intent-to-wish-compiler](brainstorms/intent-to-wish-compiler/DRAFT.md) — **LIVE (re-verified 2026-07-21):** WRS 92; Demand→Patch/Standard/Program router + circuit-breaker (flex cuts autonomous, payout cuts human-only); invisible routing compiled from intent — program-scale, splits at pour time - [brainstorm-domain-map](brainstorms/brainstorm-domain-map/DRAFT.md) — **LIVE (re-verified 2026-07-21):** WRS 80; executable spec compiler (intent → requirement-ID → oracle-class → execution → proof-packet); deterministic gates, residual-risk review only; subjective-truth ownership still open (umbrella G8) ## Ready +- [dsh-genie-board — DESIGN](brainstorms/dsh-genie-board/DESIGN.md) → [WISH](wishes/dsh-genie-board/WISH.md) — **IN PROGRESS 2026-09-03** from approved plan `9c5ba2714` after direct Felipe implementation authorization; Group 1 is the sole active wave under WIP=1, while merge, stable release, deploy, topic publication, spend, and credential changes remain separately gated - [skills-everywhere-b — WISH](wishes/skills-everywhere-b/WISH.md) · [skills-everywhere-c — WISH](wishes/skills-everywhere-c/WISH.md) — **both APPROVED 2026-08-31** (B: plan SHIP round 3 `e86d3f12…` after two FIX-FIRST loops — the ~20k-line subtractive wish, 7 groups, wave-0 gates: Wish A stable + rebase ≥ 1b34d7d4b; C: plan SHIP round 2 `8e5611226…` — skills-lint tokens, CLAUDE.md/AGENTS.md/docs rewrite, depends-on B). Wish A flipped SHIPPED with the @ref-pin correction (skills@1.5.23 serves the default branch for any ref). - [skills-everywhere — DESIGN](brainstorms/skills-everywhere/DESIGN.md) · [WISH A](wishes/skills-everywhere/WISH.md) — **design SHIP 2026-08-30 (rev. 6, digest `fafaef24…`, re-stamped after rename from `codex-skill-installer`); Wish A SHIPPED 2026-08-31 (all 7 groups; @ref-pin correction recorded) — umbrella A→B→C, base `dev`:** everywhere except Orca, Genie = skills (skills.sh `npx skills add automagik-dev/genie@v --all --copy`) + CLI, nothing else — Codex/Claude/Kimi/Hermes/pi integrations, hooks, role agents, council stamp and `agent-sync` deleted; 4-platform Codex dogfood matrix → skills-install + update-path smokes diff --git a/.genie/brainstorms/dsh-genie-board/DESIGN.md b/.genie/brainstorms/dsh-genie-board/DESIGN.md new file mode 100644 index 000000000..886337901 --- /dev/null +++ b/.genie/brainstorms/dsh-genie-board/DESIGN.md @@ -0,0 +1,90 @@ +# Design: Genie board for DSH Web + +| Field | Value | +|-------|-------| +| **Slug** | `dsh-genie-board` | +| **Date** | 2026-09-03 | +| **WRS** | 100/100 | + +## Problem + +DSH users cannot inspect or operate the authoritative Genie board from DSH Web. The integration must render Genie's real lifecycle lanes without turning `genie.db` into a cross-repository API or reviving the retired `genie mcp` / `genie ui-bridge` surfaces. + +## Scope + +### IN + +- A dual-face DSH Web plugin at `plugins/dsh-genie-board/`, using `dsh-task-board` only as licensed UI, build, and plugin-loading schematics. +- Host-side execution of supported `genie board --json` and `genie task ...` commands in a DSH workspace selected by stable workspace id. +- A Genie-branded kanban that renders board-defined lanes, card metadata, and Host-confirmed mutations. +- Board selection plus task create, move, comment, block/unblock, checkout/release, and done actions supported by the current CLI. +- Fail-closed executable, workspace, argv, timeout, output-size, and JSON validation; same-origin Host routes; focused tests and a local linked-plugin smoke. +- Public installation documentation, reference attribution, release packaging, and the `dsh-plugin` GitHub topic after verification. + +### OUT + +- Direct reads or writes to `.genie/genie.db`. +- Reintroducing `genie mcp`, `genie ui-bridge`, a daemon, a plugin-owned ledger, or another Genie protocol. +- The reference plugin's cron scheduler, execution runner, continuation cards, handover bundles, or sleep inhibitor. +- Automatic task execution by DSH sessions, hard delete, task dependency editing, wish authoring, or promotion/deploy controls in v1. +- Homolog, production, stable release, or external announcement beyond the requested repository topic without a separate gate. + +## Approach + +Co-locate `@automagik/genie-dsh-board` under `plugins/dsh-genie-board/` in the public Genie repository. Its Host half receives a DSH workspace id, resolves that id through `workspaceRegistry`, requires a physical repository containing `.genie`, and invokes the installed `genie` executable with a fixed command allowlist, argv arrays, `shell: false`, a bounded environment, deadline, and output cap. Reads parse and validate `genie board list --json`, `genie board --board --json`, and `genie task status ` outputs; writes map a strict discriminated action union to current `genie task` commands and then return a fresh board snapshot. + +The browser half follows the reference plugin's Cordis manifest, client injection, sidebar mount, CSS, and kanban component patterns while keeping Genie authoritative. It discovers eligible DSH workspaces from a Host endpoint, lets the user choose a workspace and board, re-fetches on explicit refresh, successful mutation, and page visibility recovery, and never applies optimistic task state. Host errors are bounded and visible. + +Alternatives considered: `namastexlabs/dsh-plugins` would reuse DSH tooling but is private and publishing it would expose unrelated packages; a new public repository would add release/version-skew policy immediately; reviving MCP/ui-bridge contradicts their explicit retirement; direct SQLite access freezes a private schema. All lose to co-location plus the supported CLI. + +## Simplicity Case + +- **Simplest complete design:** one dual-face package, one in-memory request mapping, and the existing Genie CLI as the only state authority; browser state is replaced from confirmed snapshots. +- **Added machinery:** a Host route fence, strict action union, child-process deadline/output cap, and schema validation are required because a browser-triggered long-lived Host is crossing into repository mutations. +- **Deferred until measured:** SSE/file watchers, polling, caches, batch mutations, autonomous card execution, cron, and resumable requests stay out until explicit user demand or measured refresh latency makes manual/visibility refresh inadequate. +- **Complexity removed:** no new durable state, migration, socket, daemon, synchronization protocol, duplicate task ledger, or cross-repository compatibility matrix. + +## Decisions + +| # | Decision | Rationale | +|---|----------|-----------| +| 1 | Package lives in `automagik-dev/genie` at `plugins/dsh-genie-board/`. | The adapter and the CLI contract ship together; the existing public repository can carry the requested topic without exposing unrelated private code. | +| 2 | Only current public CLI commands cross the boundary. | `genie mcp` and `genie ui-bridge` are explicit refusal stubs; SQLite is private. | +| 3 | Resolve workspace ids through DSH and never accept arbitrary browser paths. | Keeps path authority on the Host and prevents traversal or mutation of an unselected repository. | +| 4 | Mutations are strict action-to-argv mappings and refresh before response. | Prevents command injection and ensures the browser never displays unconfirmed state. | +| 5 | Copy only MIT-licensed plugin/UI schematics with attribution. | Reuses proven DSH mount mechanics without importing the reference ledger, scheduler, runner, or security assumptions. | +| 6 | v1 is a human board surface, not an execution orchestrator. | Meets the requested Genie-board integration while avoiding a second agent lifecycle and permission model. | +| 7 | Add the `dsh-plugin` topic only after build, tests, and linked-profile smoke succeed. | The topic is an external discoverability claim and should point to a working install path. | + +## Risks & Assumptions + +| # | Risk | Severity | Mitigation | +|---|------|----------|------------| +| 1 | Current CLI output omits data or stable JSON for a desired action. | Medium | Freeze only observed documented JSON reads; make unsupported actions visible and add Genie-side JSON only when a concrete UI criterion requires it. | +| 2 | Browser-triggered CLI processes hang or emit excessive/malformed output. | High | Abort deadline, terminate child, cap stdout/stderr, validate JSON and exit code, and test every failure path. | +| 3 | DSH package APIs differ between local `0.1.1-rc.2` and the newer reference package. | Medium | Target and smoke the installed profile first; use only official injected services available locally and declare the proven engine floor. | +| 4 | Co-locating a DSH package silently breaks Genie's release payload. | Medium | Extend package/build/release manifests intentionally and run the repository's full check plus release verification for the affected platform contract. | +| 5 | Reference code is copied without attribution or drifts into unrelated behavior. | Medium | Preserve MIT attribution in package documentation/NOTICE and use a file-by-file provenance inventory in review. | +| 6 | Topic publication precedes a usable install. | Low | Topic mutation is the final, separately evidenced action after linked-plugin read-back. | + +## Success Criteria + +- [ ] In DSH Web, a user selects an eligible workspace and Genie board and sees lanes/cards semantically matching the same CLI JSON snapshot, including status, assignment, liveness, blocks, and comments where the CLI exposes them. +- [ ] Create and move round-trip through `genie task`, and every supported mutation returns a fresh confirmed snapshot with no optimistic browser state. +- [ ] An unknown workspace id, non-physical or non-Genie directory, missing executable, disallowed action, invalid input, timeout, oversized output, malformed JSON, or non-zero exit produces a bounded visible error and no out-of-scope mutation. +- [ ] The Host never invokes a shell, accepts an executable path or raw argv from the browser, reads SQLite directly, or persists a second task ledger. +- [ ] Package typecheck, unit tests, build, focused security tests, local `dsh plugin --profile web add link:...` smoke, and the Genie full gate pass against the final commit. +- [ ] Installation and compatibility are documented, copied reference portions are attributed, the plugin is included in the supported release payload, and `automagik-dev/genie` has the `dsh-plugin` topic. + +## Next Step + +After an independent design review returns SHIP, persist the evidence below and verify its content digest before running `wish`. + + +## Design Review Evidence + +- **Verdict:** SHIP +- **Reviewed content SHA-256:** `073cce9acfc9a256756a87440cf752559fc9fd9a1d31d8ca7c36615fcece4aaf` +- **Reviewer:** agent:steve:dashboard:0950bf4d-628a-4797-b387-2d2dcaf3273a +- **Reviewed at:** 2026-09-03T13:29:28.000Z + diff --git a/.genie/brainstorms/dsh-genie-board/DRAFT.md b/.genie/brainstorms/dsh-genie-board/DRAFT.md new file mode 100644 index 000000000..74eb9a2a2 --- /dev/null +++ b/.genie/brainstorms/dsh-genie-board/DRAFT.md @@ -0,0 +1,66 @@ +# Draft: Genie board for DSH Web + +| Field | Value | +|-------|-------| +| **Slug** | `dsh-genie-board` | +| **Date** | 2026-09-03 | +| **WRS** | 100/100 | + +## Problem + +DSH users cannot inspect or operate the authoritative Genie board from DSH Web. The integration must render Genie's real lifecycle lanes without turning `genie.db` into a cross-repository API or reviving the retired `genie mcp` / `genie ui-bridge` surfaces. + +## Scope + +### IN + +- A dual-face DSH Web plugin using the `dsh-task-board` package only as UI and plugin-loading schematics. +- Host-side execution of the supported `genie board --json` and `genie task ...` CLI contracts in an explicitly selected DSH workspace. +- A kanban UI that renders Genie's board-defined lanes and refreshes after confirmed Host mutations. +- Focused task actions supported by the current CLI: create, move, comment, block/unblock, checkout/release, and done. +- Fail-closed repository, executable, output-size, timeout, and argument validation. +- Install, build, test, and topic-based discovery documentation. + +### OUT + +- Direct reads or writes to `.genie/genie.db`. +- Reintroducing `genie mcp`, `genie ui-bridge`, a daemon, or a new Genie protocol. +- Copying the reference plugin's independent ledger, cron scheduler, execution runner, continuation cards, or sleep inhibitor. +- Automatic execution of cards by DSH agents in the first release. +- Homolog or production promotion before separate human approval. + +## Candidate Approaches + +1. **Co-located package in `automagik-dev/genie` (chosen).** Keep the DSH adapter next to the CLI contract it consumes, install from the public GitHub repository, and add the `dsh-plugin` topic to that repository. This avoids a second release authority and version-skew policy. +2. **Package in private `namastexlabs/dsh-plugins`.** Reuses its DSH monorepo tooling, but public discoverability would require publishing an existing private repository whose other contents were not placed in scope. +3. **New standalone public repository.** Gives the cleanest marketplace identity, but immediately creates cross-repository release/version-skew work and conflicts with the current registered-project list. + +## Simplest Complete Design + +The DSH Host resolves one workspace root, runs a fixed allowlist of `genie` argv arrays with `shell: false`, parses bounded JSON for reads, and returns confirmed snapshots/actions over same-origin Host routes. The browser mounts a Genie-branded board and re-fetches after each mutation. No plugin-owned task database and no background synchronization state are introduced. + +## Decisions + +- Settled: Genie CLI is the only task-state boundary; SQLite is private. +- Settled: DSH task-board code is a schematic/reference, not a state model to fork wholesale. +- Settled: v1 is human-operated board management, not scheduled autonomous execution. +- Settled: the package lives at `plugins/dsh-genie-board/` in `automagik-dev/genie`; the repository receives the `dsh-plugin` topic after the plugin is verified. + +## Risks + +- CLI JSON/exit contracts may be incomplete for some UI actions; freeze only current documented commands and surface unsupported actions rather than inventing writes. +- DSH Host is long-lived while Genie is zero-daemon; every child process needs timeout, output caps, abort cleanup, and no shell. +- A broad copy of `dsh-task-board` would import an unrelated ledger and security model; copy only mount/build/UI patterns. +- The installed DSH is `0.1.1-rc.2`, while the reference task-board targets newer alpha packages; compatibility must be proven against the local profile. + +## Success Criteria + +- A DSH Web user selects a Genie repository and sees the same boards, lanes, cards, status, assignment, liveness, block, and comment information represented by the CLI JSON output. +- Create and move actions round-trip through `genie task` and the UI shows only Host-confirmed state. +- Invalid workspace paths, missing Genie, timeouts, malformed/oversized output, and non-zero CLI exits produce bounded visible errors and no state mutation outside the selected repository. +- Package typecheck, unit tests, build, a local linked-plugin smoke, and the Genie repository's required validation gates pass. +- The public installation path is documented and its public GitHub repository has the `dsh-plugin` topic. + +## Next Step + +Crystallize the design, obtain an independent cross-family review, and only then scaffold the executable wish. diff --git a/.genie/roadmap.json b/.genie/roadmap.json index 315b13c84..751122801 100644 --- a/.genie/roadmap.json +++ b/.genie/roadmap.json @@ -4,6 +4,18 @@ { "key": "stage_log_backfill_v1", "value": "1784758341375" + }, + { + "key": "wish_base:skills-everywhere", + "value": "{\"branch\":\"wish/skills-everywhere\",\"base\":\"b52dfff2b3c5d5d06fd2b0a08b5dfb4cbd6453a9\",\"recordedAt\":1788109092518}" + }, + { + "key": "wish_base:skills-everywhere-b", + "value": "{\"branch\":\"wish/skills-everywhere-b\",\"base\":\"6b00a9807b14bb8cf1d6402ffe4d3e24c18849ba\",\"recordedAt\":1788200054235}" + }, + { + "key": "wish_base:skills-everywhere-c", + "value": "{\"branch\":\"wish/skills-everywhere-c\",\"base\":\"6b00a9807b14bb8cf1d6402ffe4d3e24c18849ba\",\"recordedAt\":1788229756107}" } ], "boards": [], @@ -17,6 +29,8 @@ "claimed_at": 1784623891565, "wish": "genie-ui", "group_name": "group-1", + "assigned_agent": null, + "assigned_reason": null, "created_at": 1784618699261, "updated_at": 1784625442704, "lane": null, @@ -24,9 +38,7 @@ "heartbeat_at": null, "blocked_by": null, "blocked_reason": null, - "block_kind": null, - "assigned_agent": null, - "assigned_reason": null + "block_kind": null }, { "id": "t_mrubw9bxa7f28dd8", @@ -37,6 +49,8 @@ "claimed_at": 1784626730518, "wish": "genie-ui", "group_name": "group-2", + "assigned_agent": null, + "assigned_reason": null, "created_at": 1784618699325, "updated_at": 1784627382271, "lane": null, @@ -44,9 +58,7 @@ "heartbeat_at": null, "blocked_by": null, "blocked_reason": null, - "block_kind": null, - "assigned_agent": null, - "assigned_reason": null + "block_kind": null }, { "id": "t_mrubw9do9fc563c4", @@ -57,6 +69,8 @@ "claimed_at": 1784629252019, "wish": "genie-ui", "group_name": "group-3", + "assigned_agent": null, + "assigned_reason": null, "created_at": 1784618699388, "updated_at": 1784629987524, "lane": null, @@ -64,9 +78,7 @@ "heartbeat_at": null, "blocked_by": null, "blocked_reason": null, - "block_kind": null, - "assigned_agent": null, - "assigned_reason": null + "block_kind": null }, { "id": "t_mrubw9ft4d20d980", @@ -77,6 +89,8 @@ "claimed_at": 1784630070055, "wish": "genie-ui", "group_name": "group-4", + "assigned_agent": null, + "assigned_reason": null, "created_at": 1784618699465, "updated_at": 1784630608759, "lane": null, @@ -84,9 +98,7 @@ "heartbeat_at": null, "blocked_by": null, "blocked_reason": null, - "block_kind": null, - "assigned_agent": null, - "assigned_reason": null + "block_kind": null }, { "id": "t_mruxc5gvd0c00325", @@ -97,6 +109,8 @@ "claimed_at": 1784660541667, "wish": "genie-ui-dash", "group_name": "group-1", + "assigned_agent": null, + "assigned_reason": null, "created_at": 1784654712751, "updated_at": 1784661266675, "lane": null, @@ -104,9 +118,7 @@ "heartbeat_at": null, "blocked_by": null, "blocked_reason": null, - "block_kind": null, - "assigned_agent": null, - "assigned_reason": null + "block_kind": null }, { "id": "t_mruxc5iv51303ef4", @@ -117,6 +129,8 @@ "claimed_at": 1784661307439, "wish": "genie-ui-dash", "group_name": "group-2", + "assigned_agent": null, + "assigned_reason": null, "created_at": 1784654712823, "updated_at": 1784664027865, "lane": null, @@ -124,9 +138,7 @@ "heartbeat_at": null, "blocked_by": null, "blocked_reason": null, - "block_kind": null, - "assigned_agent": null, - "assigned_reason": null + "block_kind": null }, { "id": "t_mruxc5l659685893", @@ -137,6 +149,8 @@ "claimed_at": 1784664068582, "wish": "genie-ui-dash", "group_name": "group-3", + "assigned_agent": null, + "assigned_reason": null, "created_at": 1784654712906, "updated_at": 1784666301881, "lane": null, @@ -144,9 +158,7 @@ "heartbeat_at": null, "blocked_by": null, "blocked_reason": null, - "block_kind": null, - "assigned_agent": null, - "assigned_reason": null + "block_kind": null }, { "id": "t_mruxc5mz0a9ee3f5", @@ -157,6 +169,8 @@ "claimed_at": 1784666347308, "wish": "genie-ui-dash", "group_name": "group-4", + "assigned_agent": null, + "assigned_reason": null, "created_at": 1784654712971, "updated_at": 1784669563300, "lane": null, @@ -164,9 +178,7 @@ "heartbeat_at": null, "blocked_by": null, "blocked_reason": null, - "block_kind": null, - "assigned_agent": null, - "assigned_reason": null + "block_kind": null }, { "id": "t_mruxc5ora3add7b8", @@ -177,6 +189,8 @@ "claimed_at": 1784669605355, "wish": "genie-ui-dash", "group_name": "group-5", + "assigned_agent": null, + "assigned_reason": null, "created_at": 1784654713035, "updated_at": 1784672089341, "lane": null, @@ -184,29 +198,7 @@ "heartbeat_at": null, "blocked_by": null, "blocked_reason": null, - "block_kind": null, - "assigned_agent": null, - "assigned_reason": null - }, - { - "id": "t_mruzpyww3e2a8b8b", - "board_id": null, - "title": "genie-ui-bridge — stdio bridge design (Ready)", - "status": "ready", - "claimed_by": null, - "claimed_at": null, - "wish": null, - "group_name": null, - "created_at": 1784658716672, - "updated_at": 1784658716672, - "lane": null, - "agent_kind": null, - "heartbeat_at": null, - "blocked_by": null, - "blocked_reason": null, - "block_kind": null, - "assigned_agent": null, - "assigned_reason": null + "block_kind": null }, { "id": "t_mrv087jca37acdd9", @@ -217,6 +209,8 @@ "claimed_at": 1784660523509, "wish": "genie-ui-bridge", "group_name": "group-1", + "assigned_agent": null, + "assigned_reason": null, "created_at": 1784659567656, "updated_at": 1784661184531, "lane": null, @@ -224,9 +218,7 @@ "heartbeat_at": null, "blocked_by": null, "blocked_reason": null, - "block_kind": null, - "assigned_agent": null, - "assigned_reason": null + "block_kind": null }, { "id": "t_mrv087ldf5bdb175", @@ -237,6 +229,8 @@ "claimed_at": 1784661223928, "wish": "genie-ui-bridge", "group_name": "group-2", + "assigned_agent": null, + "assigned_reason": null, "created_at": 1784659567729, "updated_at": 1784663117984, "lane": null, @@ -244,29 +238,7 @@ "heartbeat_at": null, "blocked_by": null, "blocked_reason": null, - "block_kind": null, - "assigned_agent": null, - "assigned_reason": null - }, - { - "id": "t_mrveq6oe95cb9141", - "board_id": null, - "title": "live-dev-loop — dual-channel iterate environment (Ready)", - "status": "ready", - "claimed_by": null, - "claimed_at": null, - "wish": null, - "group_name": null, - "created_at": 1784683920978, - "updated_at": 1784683920978, - "lane": null, - "agent_kind": null, - "heartbeat_at": null, - "blocked_by": null, - "blocked_reason": null, - "block_kind": null, - "assigned_agent": null, - "assigned_reason": null + "block_kind": null }, { "id": "t_mrvf2p7za43e792d", @@ -277,6 +249,8 @@ "claimed_at": 1784694196822, "wish": "live-dev-loop", "group_name": "group-1", + "assigned_agent": null, + "assigned_reason": null, "created_at": 1784684504879, "updated_at": 1784695589899, "lane": null, @@ -284,9 +258,7 @@ "heartbeat_at": null, "blocked_by": null, "blocked_reason": null, - "block_kind": null, - "assigned_agent": null, - "assigned_reason": null + "block_kind": null }, { "id": "t_mrvf2pam274f1741", @@ -297,6 +269,8 @@ "claimed_at": 1784697640859, "wish": "live-dev-loop", "group_name": "group-2", + "assigned_agent": null, + "assigned_reason": null, "created_at": 1784684504974, "updated_at": 1784698734359, "lane": null, @@ -304,9 +278,7 @@ "heartbeat_at": null, "blocked_by": null, "blocked_reason": null, - "block_kind": null, - "assigned_agent": null, - "assigned_reason": null + "block_kind": null }, { "id": "t_mrvf2pd39e9349c2", @@ -317,6 +289,8 @@ "claimed_at": 1784695629392, "wish": "live-dev-loop", "group_name": "group-3", + "assigned_agent": null, + "assigned_reason": null, "created_at": 1784684505063, "updated_at": 1784697606035, "lane": null, @@ -324,937 +298,992 @@ "heartbeat_at": null, "blocked_by": null, "blocked_reason": null, - "block_kind": null, - "assigned_agent": null, - "assigned_reason": null + "block_kind": null }, { - "id": "t_mrwwknwl195358a3", + "id": "t_mscebl889535b59e", "board_id": null, - "title": "Core identity — package.json, app name, publish target", - "status": "ready", - "claimed_by": null, - "claimed_at": null, - "wish": "khal-rebrand", + "title": "Bounded wait + graceful busy projection", + "status": "done", + "claimed_by": "engineer-group1", + "claimed_at": 1785712701024, + "wish": "lifecycle-lease-busy-grace", "group_name": "group-1", - "created_at": 1784774362629, - "updated_at": 1784774362629, + "assigned_agent": null, + "assigned_reason": null, + "created_at": 1785711164984, + "updated_at": 1785714161658, "lane": null, "agent_kind": null, "heartbeat_at": null, "blocked_by": null, "blocked_reason": null, - "block_kind": null, - "assigned_agent": null, - "assigned_reason": null + "block_kind": null }, { - "id": "t_mrwwknz76a0d4a64", + "id": "t_mscebla86e45ab77", "board_id": null, - "title": "Release feeds — update check, release notes, toast", - "status": "ready", - "claimed_by": null, - "claimed_at": null, - "wish": "khal-rebrand", + "title": "Busy-path regression tests", + "status": "done", + "claimed_by": "engineer-group2", + "claimed_at": 1785714210396, + "wish": "lifecycle-lease-busy-grace", "group_name": "group-2", - "created_at": 1784774362723, - "updated_at": 1784774362723, + "assigned_agent": null, + "assigned_reason": null, + "created_at": 1785711165056, + "updated_at": 1785716639547, "lane": null, "agent_kind": null, "heartbeat_at": null, "blocked_by": null, "blocked_reason": null, - "block_kind": null, - "assigned_agent": null, - "assigned_reason": null + "block_kind": null }, { - "id": "t_mrwwko1x1cae87af", + "id": "t_msi18po2a63fe11f", "board_id": null, - "title": "Per-project dir — .dash → .khal via one constant", - "status": "ready", - "claimed_by": null, - "claimed_at": null, - "wish": "khal-rebrand", - "group_name": "group-3", - "created_at": 1784774362821, - "updated_at": 1784774362821, + "title": "Land the ready fixes (gate zero + three commits + PR)", + "status": "done", + "claimed_by": "g1-engineer", + "claimed_at": 1786056837590, + "wish": "harness-audit-landing", + "group_name": "ready-fixes", + "assigned_agent": null, + "assigned_reason": null, + "created_at": 1786051992818, + "updated_at": 1786129435207, "lane": null, "agent_kind": null, "heartbeat_at": null, "blocked_by": null, "blocked_reason": null, - "block_kind": null, - "assigned_agent": null, - "assigned_reason": null + "block_kind": null }, { - "id": "t_mrwwko4i254384c6", + "id": "t_msi18prp35883ce7", "board_id": null, - "title": "User-facing Dash string sweep", - "status": "ready", - "claimed_by": null, - "claimed_at": null, - "wish": "khal-rebrand", - "group_name": "group-4", - "created_at": 1784774362914, - "updated_at": 1784774362914, + "title": "completeTask CAS fence with caller enumeration", + "status": "done", + "claimed_by": "g2-engineer", + "claimed_at": 1786057120710, + "wish": "harness-audit-landing", + "group_name": "completetask-fence", + "assigned_agent": null, + "assigned_reason": null, + "created_at": 1786051992949, + "updated_at": 1786129435338, "lane": null, "agent_kind": null, "heartbeat_at": null, "blocked_by": null, "blocked_reason": null, - "block_kind": null, - "assigned_agent": null, - "assigned_reason": null + "block_kind": null }, { - "id": "t_mrwwko6w4d889a11", + "id": "t_msi18pv9b29465c2", "board_id": null, - "title": "Docs, LICENSE, upstream detachment", - "status": "ready", - "claimed_by": null, - "claimed_at": null, - "wish": "khal-rebrand", - "group_name": "group-5", - "created_at": 1784774363000, - "updated_at": 1784774363000, + "title": "Delete dead wish-group machinery (asymmetric)", + "status": "done", + "claimed_by": "g3-engineer", + "claimed_at": 1786057120255, + "wish": "harness-audit-landing", + "group_name": "wishgroup-deletion", + "assigned_agent": null, + "assigned_reason": null, + "created_at": 1786051993077, + "updated_at": 1786129435459, "lane": null, "agent_kind": null, "heartbeat_at": null, "blocked_by": null, "blocked_reason": null, - "block_kind": null, - "assigned_agent": null, - "assigned_reason": null + "block_kind": null }, { - "id": "t_mrwwko9jc20398ca", + "id": "t_msiyp3ua4e038e63", "board_id": null, - "title": "Icon and visual assets (blocked-on-asset)", - "status": "ready", - "claimed_by": null, - "claimed_at": null, - "wish": "khal-rebrand", - "group_name": "group-6", - "created_at": 1784774363096, - "updated_at": 1784774363096, + "title": "block-model: block kind + board JSON blocked fields", + "status": "done", + "claimed_by": "block-model-eng", + "claimed_at": 1786118248009, + "wish": "remotty-board-asks", + "group_name": "block-model", + "assigned_agent": null, + "assigned_reason": null, + "created_at": 1786108185010, + "updated_at": 1786120597563, "lane": null, "agent_kind": null, "heartbeat_at": null, "blocked_by": null, "blocked_reason": null, - "block_kind": null, - "assigned_agent": null, - "assigned_reason": null + "block_kind": null }, { - "id": "t_mrxqzo6s8fbb27ec", + "id": "t_msiyp3x10655eab9", "board_id": null, - "title": "khal-native-theme — KHAL NATIVE re-skin brainstorm", - "status": "ready", - "claimed_by": null, - "claimed_at": null, - "wish": null, - "group_name": null, - "created_at": 1784825451316, - "updated_at": 1784827974072, - "lane": null, - "agent_kind": null, - "heartbeat_at": null, - "blocked_by": "claude-code", - "blocked_reason": "RELOCATED 2026-07-23: khal-native-theme ledger + tasks live in khal-os/genie-desktop .genie (Felipe: desktop context in the desktop repo). Do not checkout here.", - "block_kind": null, + "title": "index-lane-links: resolve link targets, broken state", + "status": "done", + "claimed_by": "index-lane-eng", + "claimed_at": 1786118261192, + "wish": "remotty-board-asks", + "group_name": "index-lane-links", "assigned_agent": null, - "assigned_reason": null - }, - { - "id": "t_mrxr9wzrda10d35b", - "board_id": null, - "title": "Registry + @khal-os/ui dependency + import smoke test", - "status": "ready", - "claimed_by": null, - "claimed_at": null, - "wish": "khal-native-theme", - "group_name": "group-1", - "created_at": 1784825929289, - "updated_at": 1784827974160, + "assigned_reason": null, + "created_at": 1786108185109, + "updated_at": 1786121037959, "lane": null, "agent_kind": null, "heartbeat_at": null, - "blocked_by": "claude-code", - "blocked_reason": "RELOCATED 2026-07-23: khal-native-theme ledger + tasks live in khal-os/genie-desktop .genie (Felipe: desktop context in the desktop repo). Do not checkout here.", - "block_kind": null, - "assigned_agent": null, - "assigned_reason": null + "blocked_by": null, + "blocked_reason": null, + "block_kind": null }, { - "id": "t_mrxr9x1pbce3a16b", + "id": "t_msiyp3zr6f65f0fc", "board_id": null, - "title": "Token bridge + dark-only consolidation", - "status": "ready", - "claimed_by": null, - "claimed_at": null, - "wish": "khal-native-theme", - "group_name": "group-2", - "created_at": 1784825929357, - "updated_at": 1784827974238, + "title": "task-wish-verb: attach wish/group to existing card", + "status": "done", + "claimed_by": "set-wish-eng", + "claimed_at": 1786121077747, + "wish": "remotty-board-asks", + "group_name": "task-wish-verb", + "assigned_agent": null, + "assigned_reason": null, + "created_at": 1786108185207, + "updated_at": 1786122063091, "lane": null, "agent_kind": null, "heartbeat_at": null, - "blocked_by": "claude-code", - "blocked_reason": "RELOCATED 2026-07-23: khal-native-theme ledger + tasks live in khal-os/genie-desktop .genie (Felipe: desktop context in the desktop repo). Do not checkout here.", - "block_kind": null, - "assigned_agent": null, - "assigned_reason": null + "blocked_by": null, + "blocked_reason": null, + "block_kind": null }, { - "id": "t_mrxr9x49eb19d0ad", + "id": "t_msiyp42ib277f60a", "board_id": null, - "title": "Typography (bundled Geist) + motion system", - "status": "ready", - "claimed_by": null, - "claimed_at": null, - "wish": "khal-native-theme", - "group_name": "group-3", - "created_at": 1784825929449, - "updated_at": 1784827974322, + "title": "task-delete: scoped delete with sync-safe removal", + "status": "done", + "claimed_by": "task-delete-eng", + "claimed_at": 1786121655148, + "wish": "remotty-board-asks", + "group_name": "task-delete", + "assigned_agent": null, + "assigned_reason": null, + "created_at": 1786108185306, + "updated_at": 1786122699029, "lane": null, "agent_kind": null, "heartbeat_at": null, - "blocked_by": "claude-code", - "blocked_reason": "RELOCATED 2026-07-23: khal-native-theme ledger + tasks live in khal-os/genie-desktop .genie (Felipe: desktop context in the desktop repo). Do not checkout here.", - "block_kind": null, - "assigned_agent": null, - "assigned_reason": null + "blocked_by": null, + "blocked_reason": null, + "block_kind": null }, { - "id": "t_mrxr9x6sb3dfb668", + "id": "t_msnw1wva78ca40b7", "board_id": null, - "title": "Element restyle sweep + status colors", - "status": "ready", + "title": "Cross-agent delegation — teammates on the board", + "status": "done", "claimed_by": null, "claimed_at": null, - "wish": "khal-native-theme", - "group_name": "group-4", - "created_at": 1784825929540, - "updated_at": 1784827974396, + "wish": "cross-agent-delegate", + "group_name": null, + "assigned_agent": null, + "assigned_reason": null, + "created_at": 1786406074534, + "updated_at": 1786413384910, "lane": null, "agent_kind": null, "heartbeat_at": null, - "blocked_by": "claude-code", - "blocked_reason": "RELOCATED 2026-07-23: khal-native-theme ledger + tasks live in khal-os/genie-desktop .genie (Felipe: desktop context in the desktop repo). Do not checkout here.", - "block_kind": null, - "assigned_agent": null, - "assigned_reason": null + "blocked_by": null, + "blocked_reason": null, + "block_kind": null }, { - "id": "t_mrxr9x8t711ce43e", + "id": "t_msnwyz4qff08ad29", "board_id": null, - "title": "K dot-matrix loader + KHAL terminal palette", - "status": "ready", - "claimed_by": null, - "claimed_at": null, - "wish": "khal-native-theme", - "group_name": "group-5", - "created_at": 1784825929613, - "updated_at": 1784827974462, + "title": "W1-B: lane-path board --json + byte-freeze regression", + "status": "done", + "claimed_by": "engineer-b", + "claimed_at": 1786411501612, + "wish": "cross-agent-delegate", + "group_name": "group-b", + "assigned_agent": null, + "assigned_reason": null, + "created_at": 1786407617114, + "updated_at": 1786412348073, "lane": null, "agent_kind": null, "heartbeat_at": null, - "blocked_by": "claude-code", - "blocked_reason": "RELOCATED 2026-07-23: khal-native-theme ledger + tasks live in khal-os/genie-desktop .genie (Felipe: desktop context in the desktop repo). Do not checkout here.", - "block_kind": null, - "assigned_agent": null, - "assigned_reason": null + "blocked_by": null, + "blocked_reason": null, + "block_kind": null }, { - "id": "t_mrxr9xbb010c4f81", + "id": "t_msnwyz7136c34cb2", "board_id": null, - "title": "QA evidence pack + Felipe live QA handoff", - "status": "ready", - "claimed_by": null, - "claimed_at": null, - "wish": "khal-native-theme", - "group_name": "group-6", - "created_at": 1784825929703, - "updated_at": 1784827974556, + "title": "W1-C: roadmap-sync lockstep + round-trip tests", + "status": "done", + "claimed_by": "engineer-c", + "claimed_at": 1786411499996, + "wish": "cross-agent-delegate", + "group_name": "group-c", + "assigned_agent": null, + "assigned_reason": null, + "created_at": 1786407617197, + "updated_at": 1786412796523, "lane": null, "agent_kind": null, "heartbeat_at": null, - "blocked_by": "claude-code", - "blocked_reason": "RELOCATED 2026-07-23: khal-native-theme ledger + tasks live in khal-os/genie-desktop .genie (Felipe: desktop context in the desktop repo). Do not checkout here.", - "block_kind": null, - "assigned_agent": null, - "assigned_reason": null + "blocked_by": null, + "blocked_reason": null, + "block_kind": null }, { - "id": "t_ms58w0yqa195fbf8", + "id": "t_msnx6kz8c52d4762", "board_id": null, - "title": "genie-official-roadmap — triage 36 wishes into the canonical board", - "status": "ready", - "claimed_by": null, - "claimed_at": null, - "wish": "genie-official-roadmap", - "group_name": null, - "created_at": 1785278777572, - "updated_at": 1785278777572, + "title": "W1-A1: schema core — columns, lockstep, roster", + "status": "done", + "claimed_by": "engineer-a1", + "claimed_at": 1786408694769, + "wish": "cross-agent-delegate", + "group_name": "group-a1", + "assigned_agent": null, + "assigned_reason": null, + "created_at": 1786407972020, + "updated_at": 1786411453387, "lane": null, "agent_kind": null, "heartbeat_at": null, "blocked_by": null, "blocked_reason": null, - "block_kind": null, + "block_kind": null + }, + { + "id": "t_msnx6l1k7cddfcc0", + "board_id": null, + "title": "W1-A2: CLI verbs, timeline notes, docs", + "status": "done", + "claimed_by": "engineer-a2", + "claimed_at": 1786411502191, + "wish": "cross-agent-delegate", + "group_name": "group-a2", "assigned_agent": null, - "assigned_reason": null + "assigned_reason": null, + "created_at": 1786407972104, + "updated_at": 1786412198104, + "lane": null, + "agent_kind": null, + "heartbeat_at": null, + "blocked_by": null, + "blocked_reason": null, + "block_kind": null }, { - "id": "t_ms58y58z0b45759f", + "id": "t_msoqsd8sdb8df48b", "board_id": null, - "title": "Archive move + link rewrites", - "status": "ready", - "claimed_by": null, - "claimed_at": null, - "wish": "genie-official-roadmap", + "title": "Write-capable server core", + "status": "done", + "claimed_by": "g1-server-core", + "claimed_at": 1786460670279, + "wish": "mcp-write-tools", "group_name": "group-1", - "created_at": 1785278876435, - "updated_at": 1785278876435, + "assigned_agent": null, + "assigned_reason": null, + "created_at": 1786457697292, + "updated_at": 1786558209313, "lane": null, "agent_kind": null, "heartbeat_at": null, "blocked_by": null, "blocked_reason": null, - "block_kind": null, - "assigned_agent": null, - "assigned_reason": null + "block_kind": null }, { - "id": "t_ms58y5bc8853df95", + "id": "t_msoqsdb5429d06e8", "board_id": null, - "title": "Board seed — 19 lifecycle cards", - "status": "ready", - "claimed_by": null, - "claimed_at": null, - "wish": "genie-official-roadmap", + "title": "Operative write tools", + "status": "done", + "claimed_by": "g2-write-tools", + "claimed_at": 1786558267434, + "wish": "mcp-write-tools", "group_name": "group-2", - "created_at": 1785278876520, - "updated_at": 1785278876520, + "assigned_agent": null, + "assigned_reason": null, + "created_at": 1786457697377, + "updated_at": 1786568148732, "lane": null, "agent_kind": null, "heartbeat_at": null, "blocked_by": null, "blocked_reason": null, - "block_kind": null, - "assigned_agent": null, - "assigned_reason": null + "block_kind": null }, { - "id": "t_ms58y5e19f6e9757", + "id": "t_msoqsdde40b31ee9", "board_id": null, - "title": "INDEX rewrite + polish brainstorm + final gates", - "status": "ready", - "claimed_by": null, - "claimed_at": null, - "wish": "genie-official-roadmap", + "title": "E2e proof + read-only wording sweep", + "status": "done", + "claimed_by": "g3-e2e-sweep", + "claimed_at": 1786568193561, + "wish": "mcp-write-tools", "group_name": "group-3", - "created_at": 1785278876617, - "updated_at": 1785278876617, + "assigned_agent": null, + "assigned_reason": null, + "created_at": 1786457697458, + "updated_at": 1786628093499, "lane": null, "agent_kind": null, "heartbeat_at": null, "blocked_by": null, "blocked_reason": null, - "block_kind": null, - "assigned_agent": null, - "assigned_reason": null + "block_kind": null }, { - "id": "t_mscebl889535b59e", + "id": "t_msp6a1qlaaa8acdc", "board_id": null, - "title": "Bounded wait + graceful busy projection", + "title": "delegate-bridge A: W1 handoff docs + status polish", "status": "done", - "claimed_by": "engineer-group1", - "claimed_at": 1785712701024, - "wish": "lifecycle-lease-busy-grace", - "group_name": "group-1", - "created_at": 1785711164984, - "updated_at": 1785714161658, + "claimed_by": "engineer-trivial-a", + "claimed_at": 1786554116904, + "wish": "delegate-bridge", + "group_name": "group-a", + "assigned_agent": null, + "assigned_reason": null, + "created_at": 1786483716429, + "updated_at": 1786554590852, "lane": null, "agent_kind": null, "heartbeat_at": null, "blocked_by": null, "blocked_reason": null, - "block_kind": null, - "assigned_agent": null, - "assigned_reason": null + "block_kind": null }, { - "id": "t_mscebla86e45ab77", + "id": "t_mtdb642cdb6f9f10", "board_id": null, - "title": "Busy-path regression tests", - "status": "done", - "claimed_by": "engineer-group2", - "claimed_at": 1785714210396, - "wish": "lifecycle-lease-busy-grace", - "group_name": "group-2", - "created_at": 1785711165056, - "updated_at": 1785716639547, + "title": "Genie cleanup release — standalone contained + native Orca plugin", + "status": "ready", + "claimed_by": null, + "claimed_at": null, + "wish": null, + "group_name": null, + "assigned_agent": null, + "assigned_reason": null, + "created_at": 1787943119124, + "updated_at": 1787943119124, "lane": null, "agent_kind": null, "heartbeat_at": null, "blocked_by": null, "blocked_reason": null, - "block_kind": null, - "assigned_agent": null, - "assigned_reason": null + "block_kind": null }, { - "id": "t_msi18po2a63fe11f", + "id": "t_mtdns9r623748cfd", "board_id": null, - "title": "Land the ready fixes (gate zero + three commits + PR)", + "title": "Build bundled /quick and daily delivery cadence", "status": "done", - "claimed_by": "g1-engineer", - "claimed_at": 1786056837590, - "wish": "harness-audit-landing", - "group_name": "ready-fixes", - "created_at": 1786051992818, - "updated_at": 1786129435207, + "claimed_by": null, + "claimed_at": null, + "wish": null, + "group_name": null, + "assigned_agent": null, + "assigned_reason": null, + "created_at": 1787964308322, + "updated_at": 1787965629293, "lane": null, "agent_kind": null, "heartbeat_at": null, "blocked_by": null, "blocked_reason": null, - "block_kind": null, - "assigned_agent": null, - "assigned_reason": null + "block_kind": null }, { - "id": "t_msi18prp35883ce7", + "id": "t_mtfzsu4ee1390c48", "board_id": null, - "title": "completeTask CAS fence with caller enumeration", + "title": "codex-skill-installer: skills via skills.sh, retire Codex+Claude plugin delivery", "status": "done", - "claimed_by": "g2-engineer", - "claimed_at": 1786057120710, - "wish": "harness-audit-landing", - "group_name": "completetask-fence", - "created_at": 1786051992949, - "updated_at": 1786129435338, + "claimed_by": null, + "claimed_at": null, + "wish": "skills-everywhere", + "group_name": null, + "assigned_agent": null, + "assigned_reason": null, + "created_at": 1788105422462, + "updated_at": 1788896649130, "lane": null, "agent_kind": null, "heartbeat_at": null, "blocked_by": null, "blocked_reason": null, - "block_kind": null, - "assigned_agent": null, - "assigned_reason": null + "block_kind": null }, { - "id": "t_msi18pv9b29465c2", + "id": "t_mtg14v70f2fb54ba", "board_id": null, - "title": "Delete dead wish-group machinery (asymmetric)", + "title": "Wish A G1: skills installer + install/update/uninstall wiring", "status": "done", - "claimed_by": "g3-engineer", - "claimed_at": 1786057120255, - "wish": "harness-audit-landing", - "group_name": "wishgroup-deletion", - "created_at": 1786051993077, - "updated_at": 1786129435459, + "claimed_by": "engineer-g1", + "claimed_at": 1788111374068, + "wish": "skills-everywhere", + "group_name": "group-1", + "assigned_agent": null, + "assigned_reason": null, + "created_at": 1788107663340, + "updated_at": 1788113339912, "lane": null, "agent_kind": null, "heartbeat_at": null, "blocked_by": null, "blocked_reason": null, - "block_kind": null, - "assigned_agent": null, - "assigned_reason": null + "block_kind": null }, { - "id": "t_msi2nv2gcf9c97af", + "id": "t_mtg14vc4e18dcc51", "board_id": null, - "title": "roadmap-truth post-release: run Group 1 live oracles (lane-divergence comparison + zz-sync-probe walk) against the shipped binary", - "status": "ready", - "claimed_by": null, - "claimed_at": null, - "wish": "roadmap-truth", - "group_name": null, - "created_at": 1786054379272, - "updated_at": 1786054379272, + "title": "Wish A G2: legacy integration retirement", + "status": "done", + "claimed_by": "engineer-g2", + "claimed_at": 1788113384174, + "wish": "skills-everywhere", + "group_name": "group-2", + "assigned_agent": null, + "assigned_reason": null, + "created_at": 1788107663524, + "updated_at": 1788118209073, "lane": null, "agent_kind": null, "heartbeat_at": null, "blocked_by": null, "blocked_reason": null, - "block_kind": null, - "assigned_agent": null, - "assigned_reason": null + "block_kind": null }, { - "id": "t_msiyp3ua4e038e63", + "id": "t_mtg14vhpa6d08b44", "board_id": null, - "title": "block-model: block kind + board JSON blocked fields", + "title": "Wish A G3: doctor skills + retirement surface", "status": "done", - "claimed_by": "block-model-eng", - "claimed_at": 1786118248009, - "wish": "remotty-board-asks", - "group_name": "block-model", - "created_at": 1786108185010, - "updated_at": 1786120597563, + "claimed_by": "engineer-g3", + "claimed_at": 1788113405613, + "wish": "skills-everywhere", + "group_name": "group-3", + "assigned_agent": null, + "assigned_reason": null, + "created_at": 1788107663725, + "updated_at": 1788116944772, "lane": null, "agent_kind": null, "heartbeat_at": null, "blocked_by": null, "blocked_reason": null, - "block_kind": null, - "assigned_agent": null, - "assigned_reason": null + "block_kind": null }, { - "id": "t_msiyp3x10655eab9", + "id": "t_mtg14vn5e0de4dc8", "board_id": null, - "title": "index-lane-links: resolve link targets, broken state", + "title": "Wish A G4: rehome lifecycle lease + atomic-fs primitives", "status": "done", - "claimed_by": "index-lane-eng", - "claimed_at": 1786118261192, - "wish": "remotty-board-asks", - "group_name": "index-lane-links", - "created_at": 1786108185109, - "updated_at": 1786121037959, + "claimed_by": "engineer-g4", + "claimed_at": 1788110157612, + "wish": "skills-everywhere", + "group_name": "group-4", + "assigned_agent": null, + "assigned_reason": null, + "created_at": 1788107663921, + "updated_at": 1788111941077, "lane": null, "agent_kind": null, "heartbeat_at": null, "blocked_by": null, "blocked_reason": null, - "block_kind": null, - "assigned_agent": null, - "assigned_reason": null + "block_kind": null }, { - "id": "t_msiyp3zr6f65f0fc", + "id": "t_mtg14vsi44350a36", "board_id": null, - "title": "task-wish-verb: attach wish/group to existing card", + "title": "Wish A G5: release and CI smokes", "status": "done", - "claimed_by": "set-wish-eng", - "claimed_at": 1786121077747, - "wish": "remotty-board-asks", - "group_name": "task-wish-verb", - "created_at": 1786108185207, - "updated_at": 1786122063091, + "claimed_by": "engineer-g5", + "claimed_at": 1788113433057, + "wish": "skills-everywhere", + "group_name": "group-5", + "assigned_agent": null, + "assigned_reason": null, + "created_at": 1788107664114, + "updated_at": 1788117468064, "lane": null, "agent_kind": null, "heartbeat_at": null, "blocked_by": null, "blocked_reason": null, - "block_kind": null, - "assigned_agent": null, - "assigned_reason": null + "block_kind": null }, { - "id": "t_msiyp42ib277f60a", + "id": "t_mtg14vxr224f5565", "board_id": null, - "title": "task-delete: scoped delete with sync-safe removal", + "title": "Wish A G6: supersession notes + install docs section", "status": "done", - "claimed_by": "task-delete-eng", - "claimed_at": 1786121655148, - "wish": "remotty-board-asks", - "group_name": "task-delete", - "created_at": 1786108185306, - "updated_at": 1786122699029, + "claimed_by": "engineer-g6", + "claimed_at": 1788110175133, + "wish": "skills-everywhere", + "group_name": "group-6", + "assigned_agent": null, + "assigned_reason": null, + "created_at": 1788107664303, + "updated_at": 1788113202418, "lane": null, "agent_kind": null, "heartbeat_at": null, "blocked_by": null, "blocked_reason": null, - "block_kind": null, - "assigned_agent": null, - "assigned_reason": null + "block_kind": null }, { - "id": "t_msnw1wva78ca40b7", + "id": "t_mtg14w35c3ea69d2", "board_id": null, - "title": "Cross-agent delegation — teammates on the board", + "title": "Wish A G7: real-host dogfood (C3)", "status": "done", "claimed_by": null, "claimed_at": null, - "wish": "cross-agent-delegate", - "group_name": null, - "created_at": 1786406074534, - "updated_at": 1786413384910, + "wish": "skills-everywhere", + "group_name": "group-7", + "assigned_agent": null, + "assigned_reason": null, + "created_at": 1788107664497, + "updated_at": 1788120549088, "lane": null, "agent_kind": null, "heartbeat_at": null, "blocked_by": null, "blocked_reason": null, - "block_kind": null, - "assigned_agent": null, - "assigned_reason": null + "block_kind": null }, { - "id": "t_msnwyz4qff08ad29", + "id": "t_mthed21ac8fc9045", "board_id": null, - "title": "W1-B: lane-path board --json + byte-freeze regression", + "title": "Wish B G1: honest, genuinely pinned skills install", "status": "done", - "claimed_by": "engineer-b", - "claimed_at": 1786411501612, - "wish": "cross-agent-delegate", - "group_name": "group-b", - "created_at": 1786407617114, - "updated_at": 1786412348073, + "claimed_by": "ultracode-g1", + "claimed_at": 1788200088877, + "wish": "skills-everywhere-b", + "group_name": "group-1", + "assigned_agent": null, + "assigned_reason": null, + "created_at": 1788190346639, + "updated_at": 1788203946197, "lane": null, "agent_kind": null, "heartbeat_at": null, "blocked_by": null, "blocked_reason": null, - "block_kind": null, - "assigned_agent": null, - "assigned_reason": null + "block_kind": null }, { - "id": "t_msnwyz7136c34cb2", + "id": "t_mthed27g8f0541d9", "board_id": null, - "title": "W1-C: roadmap-sync lockstep + round-trip tests", + "title": "Wish B G2: rehomes and retirement hardening", "status": "done", - "claimed_by": "engineer-c", - "claimed_at": 1786411499996, - "wish": "cross-agent-delegate", - "group_name": "group-c", - "created_at": 1786407617197, - "updated_at": 1786412796523, + "claimed_by": "ultracode-g2", + "claimed_at": 1788200089113, + "wish": "skills-everywhere-b", + "group_name": "group-2", + "assigned_agent": null, + "assigned_reason": null, + "created_at": 1788190346860, + "updated_at": 1788203946415, "lane": null, "agent_kind": null, "heartbeat_at": null, "blocked_by": null, "blocked_reason": null, - "block_kind": null, - "assigned_agent": null, - "assigned_reason": null + "block_kind": null }, { - "id": "t_msnx6kz8c52d4762", + "id": "t_mthed2dm1199bb26", "board_id": null, - "title": "W1-A1: schema core — columns, lockstep, roster", + "title": "Wish B G3: delete the Codex plugin subsystem; rewrite doctor and uninstall", "status": "done", - "claimed_by": "engineer-a1", - "claimed_at": 1786408694769, - "wish": "cross-agent-delegate", - "group_name": "group-a1", - "created_at": 1786407972020, - "updated_at": 1786411453387, + "claimed_by": "ultracode-g3", + "claimed_at": 1788203946620, + "wish": "skills-everywhere-b", + "group_name": "group-3", + "assigned_agent": null, + "assigned_reason": null, + "created_at": 1788190347082, + "updated_at": 1788211897150, "lane": null, "agent_kind": null, "heartbeat_at": null, "blocked_by": null, "blocked_reason": null, - "block_kind": null, - "assigned_agent": null, - "assigned_reason": null + "block_kind": null }, { - "id": "t_msnx6l1k7cddfcc0", + "id": "t_mthed2jhd2bf3239", "board_id": null, - "title": "W1-A2: CLI verbs, timeline notes, docs", + "title": "Wish B G4: delete the hook runtime and the Claude/Kimi plugin; remove six `check` gates", "status": "done", - "claimed_by": "engineer-a2", - "claimed_at": 1786411502191, - "wish": "cross-agent-delegate", - "group_name": "group-a2", - "created_at": 1786407972104, - "updated_at": 1786412198104, + "claimed_by": "ultracode-g4", + "claimed_at": 1788211897371, + "wish": "skills-everywhere-b", + "group_name": "group-4", + "assigned_agent": null, + "assigned_reason": null, + "created_at": 1788190347293, + "updated_at": 1788216237068, "lane": null, "agent_kind": null, "heartbeat_at": null, "blocked_by": null, "blocked_reason": null, - "block_kind": null, - "assigned_agent": null, - "assigned_reason": null + "block_kind": null }, { - "id": "t_msoqsd8sdb8df48b", + "id": "t_mthed2p7ae1bdd6f", "board_id": null, - "title": "Write-capable server core", + "title": "Wish B G5: delete Hermes, pi and `agent-sync.ts`; slim `runtime-integrations.ts`", "status": "done", - "claimed_by": "g1-server-core", - "claimed_at": 1786460670279, - "wish": "mcp-write-tools", - "group_name": "group-1", - "created_at": 1786457697292, - "updated_at": 1786558209313, + "claimed_by": "ultracode-g5", + "claimed_at": 1788216237278, + "wish": "skills-everywhere-b", + "group_name": "group-5", + "assigned_agent": null, + "assigned_reason": null, + "created_at": 1788190347499, + "updated_at": 1788221761403, "lane": null, "agent_kind": null, "heartbeat_at": null, "blocked_by": null, "blocked_reason": null, - "block_kind": null, - "assigned_agent": null, - "assigned_reason": null + "block_kind": null }, { - "id": "t_msoqsdb5429d06e8", + "id": "t_mthed2v47f8e9b80", "board_id": null, - "title": "Operative write tools", + "title": "Wish B G6: build/release toolchain and the release workflow (C11)", "status": "done", - "claimed_by": "g2-write-tools", - "claimed_at": 1786558267434, - "wish": "mcp-write-tools", - "group_name": "group-2", - "created_at": 1786457697377, - "updated_at": 1786568148732, + "claimed_by": "ultracode-g6", + "claimed_at": 1788221761631, + "wish": "skills-everywhere-b", + "group_name": "group-6", + "assigned_agent": null, + "assigned_reason": null, + "created_at": 1788190347712, + "updated_at": 1788223248026, "lane": null, "agent_kind": null, "heartbeat_at": null, "blocked_by": null, "blocked_reason": null, - "block_kind": null, - "assigned_agent": null, - "assigned_reason": null + "block_kind": null }, { - "id": "t_msoqsdde40b31ee9", + "id": "t_mthed317ecced05c", "board_id": null, - "title": "E2e proof + read-only wording sweep", + "title": "Wish B G7: proof", "status": "done", - "claimed_by": "g3-e2e-sweep", - "claimed_at": 1786568193561, - "wish": "mcp-write-tools", - "group_name": "group-3", - "created_at": 1786457697458, - "updated_at": 1786628093499, + "claimed_by": "ultracode-g7", + "claimed_at": 1788223248243, + "wish": "skills-everywhere-b", + "group_name": "group-7", + "assigned_agent": null, + "assigned_reason": null, + "created_at": 1788190347931, + "updated_at": 1788226216867, "lane": null, "agent_kind": null, "heartbeat_at": null, "blocked_by": null, "blocked_reason": null, - "block_kind": null, - "assigned_agent": null, - "assigned_reason": null + "block_kind": null }, { - "id": "t_msp4fp6ce714c8be", + "id": "t_mthed3786bb695ea", "board_id": null, - "title": "Delegate bridge — routed cards become agent turns (W2+W3)", - "status": "ready", - "claimed_by": null, - "claimed_at": null, - "wish": "delegate-bridge", - "group_name": null, - "created_at": 1786480620852, - "updated_at": 1786657262650, + "title": "Wish C G1: skills-lint rules + skill-content corrections", + "status": "done", + "claimed_by": "ultracode-c1", + "claimed_at": 1788229827970, + "wish": "skills-everywhere-c", + "group_name": "group-1", + "assigned_agent": null, + "assigned_reason": null, + "created_at": 1788190348148, + "updated_at": 1788232509762, "lane": null, "agent_kind": null, "heartbeat_at": null, - "blocked_by": "dream-scc-reversals", - "blocked_reason": "re-targeted by spawn-context-contract group reversals (2026-08-13): gating moves from headless-turn-open to herdr-swap (0.3) + engine-turn-verbs (remotty board card t_msqkkjt221792465); S-F turn transport re-bases from one-shot headless launches to live herdr sessions (agent start -> prompt --wait --until -> read -> report/release)", - "block_kind": "work", - "assigned_agent": null, - "assigned_reason": null + "blocked_by": null, + "blocked_reason": null, + "block_kind": null }, { - "id": "t_msp6a1qlaaa8acdc", + "id": "t_mthed3ddbcfe52f0", "board_id": null, - "title": "delegate-bridge A: W1 handoff docs + status polish", + "title": "Wish C G2: CLAUDE.md + AGENTS.md rewrite and drift guard", "status": "done", - "claimed_by": "engineer-trivial-a", - "claimed_at": 1786554116904, - "wish": "delegate-bridge", - "group_name": "group-a", - "created_at": 1786483716429, - "updated_at": 1786554590852, + "claimed_by": "ultracode-c2", + "claimed_at": 1788229828189, + "wish": "skills-everywhere-c", + "group_name": "group-2", + "assigned_agent": null, + "assigned_reason": null, + "created_at": 1788190348369, + "updated_at": 1788232509975, "lane": null, "agent_kind": null, "heartbeat_at": null, "blocked_by": null, "blocked_reason": null, - "block_kind": null, - "assigned_agent": null, - "assigned_reason": null + "block_kind": null }, { - "id": "t_msp6a1sz195f0ea9", + "id": "t_mthed3izf9e001fa", "board_id": null, - "title": "delegate-bridge S: seam spike (gate for B-F)", - "status": "ready", - "claimed_by": null, - "claimed_at": null, - "wish": "delegate-bridge", - "group_name": "group-s", - "created_at": 1786483716515, - "updated_at": 1786483716515, + "title": "Wish C G3: public docs rewrite + release-notes page (.docs-vendor submodule)", + "status": "done", + "claimed_by": "ultracode-c3", + "claimed_at": 1788229828413, + "wish": "skills-everywhere-c", + "group_name": "group-3", + "assigned_agent": null, + "assigned_reason": null, + "created_at": 1788190348571, + "updated_at": 1788236668018, "lane": null, "agent_kind": null, "heartbeat_at": null, "blocked_by": null, "blocked_reason": null, - "block_kind": null, - "assigned_agent": null, - "assigned_reason": null + "block_kind": null }, { - "id": "t_msp6a1vd3c103d09", + "id": "t_mthed3occ26b9f86", "board_id": null, - "title": "delegate-bridge B: adapter cards + brief writer", - "status": "ready", - "claimed_by": null, - "claimed_at": null, - "wish": "delegate-bridge", - "group_name": "group-b", - "created_at": 1786483716601, - "updated_at": 1786483716601, + "title": "Wish C G4: docs-lint coverage + final gate", + "status": "done", + "claimed_by": "ultracode-c4", + "claimed_at": 1788232510190, + "wish": "skills-everywhere-c", + "group_name": "group-4", + "assigned_agent": null, + "assigned_reason": null, + "created_at": 1788190348764, + "updated_at": 1788234612692, "lane": null, "agent_kind": null, "heartbeat_at": null, "blocked_by": null, "blocked_reason": null, - "block_kind": null, - "assigned_agent": null, - "assigned_reason": null + "block_kind": null }, { - "id": "t_msp6a1xzca62d364", + "id": "t_mtlkd9ad80ce9781", "board_id": null, - "title": "delegate-bridge C: bridge core — task delegate", - "status": "ready", - "claimed_by": null, - "claimed_at": null, - "wish": "delegate-bridge", - "group_name": "group-c", - "created_at": 1786483716695, - "updated_at": 1786483716695, + "title": "Freeze structured CLI contract for DSH board", + "status": "done", + "claimed_by": "engineer-standard", + "claimed_at": 1788467460493, + "wish": "dsh-genie-board", + "group_name": "structured-cli", + "assigned_agent": null, + "assigned_reason": null, + "created_at": 1788442298437, + "updated_at": 1788808522143, "lane": null, "agent_kind": null, "heartbeat_at": null, "blocked_by": null, "blocked_reason": null, - "block_kind": null, - "assigned_agent": null, - "assigned_reason": null + "block_kind": null }, { - "id": "t_msp6a20k0a1df8a8", + "id": "t_mtlkd9g2aae24594", "board_id": null, - "title": "delegate-bridge D: degradation + doctor orphan check", - "status": "ready", - "claimed_by": null, - "claimed_at": null, - "wish": "delegate-bridge", - "group_name": "group-d", - "created_at": 1786483716788, - "updated_at": 1786483716788, + "title": "Build DSH Host adapter and kanban", + "status": "done", + "claimed_by": "codex-g2", + "claimed_at": 1788808569102, + "wish": "dsh-genie-board", + "group_name": "dsh-plugin", + "assigned_agent": null, + "assigned_reason": null, + "created_at": 1788442298642, + "updated_at": 1788810253292, "lane": null, "agent_kind": null, "heartbeat_at": null, "blocked_by": null, "blocked_reason": null, - "block_kind": null, - "assigned_agent": null, - "assigned_reason": null + "block_kind": null }, { - "id": "t_msp6a23b3c681369", + "id": "t_mtlkd9ln2f533934", "board_id": null, - "title": "delegate-bridge E: task fan + ratification", - "status": "ready", - "claimed_by": null, - "claimed_at": null, - "wish": "delegate-bridge", - "group_name": "group-e", - "created_at": 1786483716887, - "updated_at": 1786483716887, + "title": "Package, smoke, and publish DSH discoverability", + "status": "in_progress", + "claimed_by": "g3_implementation", + "claimed_at": 1788810299062, + "wish": "dsh-genie-board", + "group_name": "release", + "assigned_agent": null, + "assigned_reason": null, + "created_at": 1788442298843, + "updated_at": 1788810299062, "lane": null, "agent_kind": null, "heartbeat_at": null, "blocked_by": null, "blocked_reason": null, - "block_kind": null, - "assigned_agent": null, - "assigned_reason": null + "block_kind": null }, { - "id": "t_msp6a25sef5b7e40", + "id": "t_mtroeuxf15d926b2", "board_id": null, - "title": "delegate-bridge F: adapter smoke matrix ×5", - "status": "ready", - "claimed_by": null, - "claimed_at": null, - "wish": "delegate-bridge", - "group_name": "group-f", - "created_at": 1786483716976, - "updated_at": 1786483716976, + "title": "Daily01: recover roadmap key-order sync fix", + "status": "done", + "claimed_by": "roadmap_implementation", + "claimed_at": 1788811924987, + "wish": null, + "group_name": null, + "assigned_agent": null, + "assigned_reason": null, + "created_at": 1788811888659, + "updated_at": 1788812675725, "lane": null, "agent_kind": null, "heartbeat_at": null, "blocked_by": null, "blocked_reason": null, - "block_kind": null, - "assigned_agent": null, - "assigned_reason": null - } - ], - "task_dependencies": [], - "stage_log": [], - "task_events": [ - { - "id": 1, - "task_id": "t_mrxqzo6s8fbb27ec", - "kind": "block", - "note": "RELOCATED 2026-07-23: khal-native-theme ledger + tasks live in khal-os/genie-desktop .genie (Felipe: desktop context in the desktop repo). Do not checkout here.", - "author_kind": "claude-code", - "author": null, - "created_at": 1784827974073 + "block_kind": null }, { - "id": 2, - "task_id": "t_mrxr9wzrda10d35b", - "kind": "block", - "note": "RELOCATED 2026-07-23: khal-native-theme ledger + tasks live in khal-os/genie-desktop .genie (Felipe: desktop context in the desktop repo). Do not checkout here.", - "author_kind": "claude-code", - "author": null, - "created_at": 1784827974160 + "id": "t_mtrovqgrfa3f8cd3", + "board_id": null, + "title": "Daily01: apply approved fix-loop budget guidance", + "status": "done", + "claimed_by": "a1_implementation", + "claimed_at": 1788812722547, + "wish": null, + "group_name": null, + "assigned_agent": null, + "assigned_reason": null, + "created_at": 1788812676027, + "updated_at": 1788813047619, + "lane": null, + "agent_kind": null, + "heartbeat_at": null, + "blocked_by": null, + "blocked_reason": null, + "block_kind": null }, { - "id": 3, - "task_id": "t_mrxr9x1pbce3a16b", - "kind": "block", - "note": "RELOCATED 2026-07-23: khal-native-theme ledger + tasks live in khal-os/genie-desktop .genie (Felipe: desktop context in the desktop repo). Do not checkout here.", - "author_kind": "claude-code", - "author": null, - "created_at": 1784827974238 + "id": "t_mtrp3pjybe0a523f", + "board_id": null, + "title": "Daily01: fix hireAgent return under concurrent unhire", + "status": "done", + "claimed_by": "b1_implementation", + "claimed_at": 1788813069094, + "wish": null, + "group_name": null, + "assigned_agent": null, + "assigned_reason": null, + "created_at": 1788813048094, + "updated_at": 1788814099607, + "lane": null, + "agent_kind": null, + "heartbeat_at": 1788813959927, + "blocked_by": null, + "blocked_reason": null, + "block_kind": null }, { - "id": 4, - "task_id": "t_mrxr9x49eb19d0ad", - "kind": "block", - "note": "RELOCATED 2026-07-23: khal-native-theme ledger + tasks live in khal-os/genie-desktop .genie (Felipe: desktop context in the desktop repo). Do not checkout here.", - "author_kind": "claude-code", - "author": null, - "created_at": 1784827974322 + "id": "t_mtrpq96zc09a9618", + "board_id": null, + "title": "Daily01: verify determinism and port approved fixture permissions", + "status": "done", + "claimed_by": "b3_implementation", + "claimed_at": 1788814161543, + "wish": null, + "group_name": null, + "assigned_agent": null, + "assigned_reason": null, + "created_at": 1788814099980, + "updated_at": 1788814467138, + "lane": null, + "agent_kind": null, + "heartbeat_at": null, + "blocked_by": null, + "blocked_reason": null, + "block_kind": null }, { - "id": 5, - "task_id": "t_mrxr9x6sb3dfb668", - "kind": "block", - "note": "RELOCATED 2026-07-23: khal-native-theme ledger + tasks live in khal-os/genie-desktop .genie (Felipe: desktop context in the desktop repo). Do not checkout here.", - "author_kind": "claude-code", - "author": null, - "created_at": 1784827974396 + "id": "t_mtrpy4u2ad2bb02a", + "board_id": null, + "title": "Daily01: review and integrate project Brain scaffold", + "status": "done", + "claimed_by": "brain_scaffold_implementation", + "claimed_at": 1788814502156, + "wish": null, + "group_name": null, + "assigned_agent": null, + "assigned_reason": null, + "created_at": 1788814467579, + "updated_at": 1788814870115, + "lane": null, + "agent_kind": null, + "heartbeat_at": null, + "blocked_by": null, + "blocked_reason": null, + "block_kind": null }, { - "id": 6, - "task_id": "t_mrxr9x8t711ce43e", - "kind": "block", - "note": "RELOCATED 2026-07-23: khal-native-theme ledger + tasks live in khal-os/genie-desktop .genie (Felipe: desktop context in the desktop repo). Do not checkout here.", - "author_kind": "claude-code", - "author": null, - "created_at": 1784827974462 + "id": "t_mtrr50zm1be98f68", + "board_id": null, + "title": "Daily01: reject Unicode controls in DSH board input", + "status": "done", + "claimed_by": "unicode_controls_implementation", + "claimed_at": 1788816507937, + "wish": null, + "group_name": null, + "assigned_agent": null, + "assigned_reason": null, + "created_at": 1788816468802, + "updated_at": 1788817997403, + "lane": null, + "agent_kind": null, + "heartbeat_at": null, + "blocked_by": null, + "blocked_reason": null, + "block_kind": null }, { - "id": 7, - "task_id": "t_mrxr9xbb010c4f81", - "kind": "block", - "note": "RELOCATED 2026-07-23: khal-native-theme ledger + tasks live in khal-os/genie-desktop .genie (Felipe: desktop context in the desktop repo). Do not checkout here.", - "author_kind": "claude-code", - "author": null, - "created_at": 1784827974557 - }, + "id": "t_mtrrebyk5f21272f", + "board_id": null, + "title": "Daily01: close remaining promotion review contract gaps", + "status": "done", + "claimed_by": "promotion_review_implementation", + "claimed_at": 1788816934672, + "wish": null, + "group_name": null, + "assigned_agent": null, + "assigned_reason": null, + "created_at": 1788816902924, + "updated_at": 1788817997948, + "lane": null, + "agent_kind": null, + "heartbeat_at": null, + "blocked_by": null, + "blocked_reason": null, + "block_kind": null + } + ], + "task_dependencies": [], + "stage_log": [], + "task_events": [ { "id": 38, "task_id": "t_mscebl889535b59e", @@ -1525,24 +1554,6 @@ "author": "cli", "created_at": 1786462438505 }, - { - "id": 76, - "task_id": "t_msp4fp6ce714c8be", - "kind": "wish", - "note": "(none)→delegate-bridge", - "author_kind": "claude-code", - "author": "cli", - "created_at": 1786480645159 - }, - { - "id": 77, - "task_id": "t_msp4fp6ce714c8be", - "kind": "block", - "note": "blocked on remotty headless-turn-open (cross-repo; design M2 mechanism)", - "author_kind": "claude-code", - "author": "cli", - "created_at": 1786480645240 - }, { "id": 78, "task_id": "t_msp6a1qlaaa8acdc", @@ -1607,76 +1618,715 @@ "created_at": 1786628093499 }, { - "id": 85, - "task_id": "t_msp4fp6ce714c8be", - "kind": "block", - "note": "re-targeted by spawn-context-contract group reversals (2026-08-13): gating moves from headless-turn-open to herdr-swap (0.3) + engine-turn-verbs (remotty board card t_msqkkjt221792465); S-F turn transport re-bases from one-shot headless launches to live herdr sessions (agent start -> prompt --wait --until -> read -> report/release)", - "author_kind": "prime-agent", - "author": "dream-scc-reversals", - "created_at": 1786657262650 + "id": 93, + "task_id": "t_mtdns9r623748cfd", + "kind": "report", + "note": "Implemented bundled skills/quick plus pm cadence. RED observed on missing skill; focused contract 1/1, release/docs 43/43, mirror/activation 24/24, fresh-install 18/18, skills lint, typecheck, wishes/complexity/council/hook/plugin gates passed. Full check remains red on pre-existing uuid dead-code and unrelated concurrent historical-fixture/time-budget failures. No commit, push, PR, deploy, homolog, or production mutation.", + "author_kind": "hermes", + "author": "cli", + "created_at": 1787965628968 }, { - "id": 86, - "task_id": "t_msp4fp6ce714c8be", - "kind": "comment", - "note": "Board-edge re-target recorded (spawn-context-contract group reversals, 2026-08-13): the enforced block on this card was re-placed via task block, never unblocked, so the live gating now reads herdr-swap (0.3) + engine-turn-verbs (remotty card t_msqkkjt221792465) instead of headless-turn-open. Groups S-F re-base their turn transport to live herdr sessions; group A (done, W1 scope) is unaffected.", - "author_kind": "prime-agent", - "author": "dream-scc-reversals", - "created_at": 1786657272200 + "id": 94, + "task_id": "t_mtdns9r623748cfd", + "kind": "release", + "note": "completed", + "author_kind": "hermes", + "author": "cli", + "created_at": 1787965629294 }, { - "id": 87, - "task_id": "t_msp6a1sz195f0ea9", - "kind": "comment", - "note": "Gating re-target recorded (spawn-context-contract group reversals, 2026-08-13): the seam spike now proves the live-herdr turn seam (agent start / prompt --wait --until / read) instead of the one-shot headless-launch seam. Gating: herdr-swap (0.3) + engine-turn-verbs (remotty card t_msqkkjt221792465) - formerly headless-turn-open.", - "author_kind": "prime-agent", - "author": "dream-scc-reversals", - "created_at": 1786657272357 + "id": 95, + "task_id": "t_mtfzsu4ee1390c48", + "kind": "wish", + "note": "(none)→skills-everywhere", + "author_kind": "claude-code", + "author": "cli", + "created_at": 1788108622794 }, { - "id": 88, - "task_id": "t_msp6a1vd3c103d09", - "kind": "comment", - "note": "Gating re-target recorded (spawn-context-contract group reversals, 2026-08-13): adapter cards + brief writer target live herdr sessions (id-free continue form via herdr agent verbs) instead of turn-per-launch headless argv. Gating: herdr-swap (0.3) + engine-turn-verbs (remotty card t_msqkkjt221792465) - formerly headless-turn-open.", - "author_kind": "prime-agent", - "author": "dream-scc-reversals", - "created_at": 1786657272489 + "id": 96, + "task_id": "t_mtg14v70f2fb54ba", + "kind": "wish", + "note": "codex-skill-installer#group-1→skills-everywhere", + "author_kind": "claude-code", + "author": "cli", + "created_at": 1788108622988 }, { - "id": 89, - "task_id": "t_msp6a1xzca62d364", - "kind": "comment", - "note": "Gating re-target recorded (spawn-context-contract group reversals, 2026-08-13): bridge core drives live herdr sessions per turn (agent prompt --wait --until + read hand-back) instead of one-shot headless turn launches. Gating: herdr-swap (0.3) + engine-turn-verbs (remotty card t_msqkkjt221792465) - formerly headless-turn-open.", - "author_kind": "prime-agent", - "author": "dream-scc-reversals", - "created_at": 1786657272621 + "id": 97, + "task_id": "t_mtg14vc4e18dcc51", + "kind": "wish", + "note": "codex-skill-installer#group-2→skills-everywhere", + "author_kind": "claude-code", + "author": "cli", + "created_at": 1788108623184 }, { - "id": 90, - "task_id": "t_msp6a20k0a1df8a8", - "kind": "comment", - "note": "Gating re-target recorded (spawn-context-contract group reversals, 2026-08-13): degradation paths now cover herdr session failures and release (not detached-launch refusals). Gating: herdr-swap (0.3) + engine-turn-verbs (remotty card t_msqkkjt221792465) - formerly headless-turn-open.", - "author_kind": "prime-agent", - "author": "dream-scc-reversals", - "created_at": 1786657272747 + "id": 98, + "task_id": "t_mtg14vhpa6d08b44", + "kind": "wish", + "note": "codex-skill-installer#group-3→skills-everywhere", + "author_kind": "claude-code", + "author": "cli", + "created_at": 1788108623380 }, { - "id": 91, - "task_id": "t_msp6a23b3c681369", - "kind": "comment", - "note": "Gating re-target recorded (spawn-context-contract group reversals, 2026-08-13): task fan + ratification transport re-bases to live herdr sessions (per-turn prompt --wait --until / read) instead of one-shot headless launches. Gating: herdr-swap (0.3) + engine-turn-verbs (remotty card t_msqkkjt221792465) - formerly headless-turn-open.", - "author_kind": "prime-agent", - "author": "dream-scc-reversals", - "created_at": 1786657272871 + "id": 99, + "task_id": "t_mtg14vn5e0de4dc8", + "kind": "wish", + "note": "codex-skill-installer#group-4→skills-everywhere", + "author_kind": "claude-code", + "author": "cli", + "created_at": 1788108623614 }, { - "id": 92, - "task_id": "t_msp6a25sef5b7e40", - "kind": "comment", - "note": "Gating re-target recorded (spawn-context-contract group reversals, 2026-08-13): the adapter smoke matrix exercises live-herdr-session turns instead of one-shot headless launches. Gating: herdr-swap (0.3) + engine-turn-verbs (remotty card t_msqkkjt221792465) - formerly headless-turn-open.", - "author_kind": "prime-agent", - "author": "dream-scc-reversals", - "created_at": 1786657273022 + "id": 100, + "task_id": "t_mtg14vsi44350a36", + "kind": "wish", + "note": "codex-skill-installer#group-5→skills-everywhere", + "author_kind": "claude-code", + "author": "cli", + "created_at": 1788108623808 + }, + { + "id": 101, + "task_id": "t_mtg14vxr224f5565", + "kind": "wish", + "note": "codex-skill-installer#group-6→skills-everywhere", + "author_kind": "claude-code", + "author": "cli", + "created_at": 1788108624005 + }, + { + "id": 102, + "task_id": "t_mtg14w35c3ea69d2", + "kind": "wish", + "note": "codex-skill-installer#group-7→skills-everywhere", + "author_kind": "claude-code", + "author": "cli", + "created_at": 1788108624202 + }, + { + "id": 103, + "task_id": "t_mtg14v70f2fb54ba", + "kind": "wish", + "note": "skills-everywhere→skills-everywhere#group-1", + "author_kind": "claude-code", + "author": "cli", + "created_at": 1788108651576 + }, + { + "id": 104, + "task_id": "t_mtg14vc4e18dcc51", + "kind": "wish", + "note": "skills-everywhere→skills-everywhere#group-2", + "author_kind": "claude-code", + "author": "cli", + "created_at": 1788108651778 + }, + { + "id": 105, + "task_id": "t_mtg14vhpa6d08b44", + "kind": "wish", + "note": "skills-everywhere→skills-everywhere#group-3", + "author_kind": "claude-code", + "author": "cli", + "created_at": 1788108651977 + }, + { + "id": 106, + "task_id": "t_mtg14vn5e0de4dc8", + "kind": "wish", + "note": "skills-everywhere→skills-everywhere#group-4", + "author_kind": "claude-code", + "author": "cli", + "created_at": 1788108652172 + }, + { + "id": 107, + "task_id": "t_mtg14vsi44350a36", + "kind": "wish", + "note": "skills-everywhere→skills-everywhere#group-5", + "author_kind": "claude-code", + "author": "cli", + "created_at": 1788108652368 + }, + { + "id": 108, + "task_id": "t_mtg14vxr224f5565", + "kind": "wish", + "note": "skills-everywhere→skills-everywhere#group-6", + "author_kind": "claude-code", + "author": "cli", + "created_at": 1788108652576 + }, + { + "id": 109, + "task_id": "t_mtg14w35c3ea69d2", + "kind": "wish", + "note": "skills-everywhere→skills-everywhere#group-7", + "author_kind": "claude-code", + "author": "cli", + "created_at": 1788108652779 + }, + { + "id": 110, + "task_id": "t_mtg14vn5e0de4dc8", + "kind": "claim", + "note": "claimed by engineer-g4", + "author_kind": "claude-code", + "author": "cli", + "created_at": 1788110157613 + }, + { + "id": 111, + "task_id": "t_mtg14vxr224f5565", + "kind": "claim", + "note": "claimed by engineer-g6", + "author_kind": "claude-code", + "author": "cli", + "created_at": 1788110175134 + }, + { + "id": 112, + "task_id": "t_mtg14v70f2fb54ba", + "kind": "claim", + "note": "claimed by engineer-g1", + "author_kind": "claude-code", + "author": "cli", + "created_at": 1788111374069 + }, + { + "id": 113, + "task_id": "t_mtg14vn5e0de4dc8", + "kind": "release", + "note": "completed", + "author_kind": "claude-code", + "author": "cli", + "created_at": 1788111941078 + }, + { + "id": 114, + "task_id": "t_mtg14vxr224f5565", + "kind": "release", + "note": "completed", + "author_kind": "claude-code", + "author": "cli", + "created_at": 1788113202419 + }, + { + "id": 115, + "task_id": "t_mtg14v70f2fb54ba", + "kind": "release", + "note": "completed", + "author_kind": "claude-code", + "author": "cli", + "created_at": 1788113339913 + }, + { + "id": 116, + "task_id": "t_mtg14vc4e18dcc51", + "kind": "claim", + "note": "claimed by engineer-g2", + "author_kind": "claude-code", + "author": "cli", + "created_at": 1788113384175 + }, + { + "id": 117, + "task_id": "t_mtg14vhpa6d08b44", + "kind": "claim", + "note": "claimed by engineer-g3", + "author_kind": "claude-code", + "author": "cli", + "created_at": 1788113405614 + }, + { + "id": 118, + "task_id": "t_mtg14vsi44350a36", + "kind": "claim", + "note": "claimed by engineer-g5", + "author_kind": "claude-code", + "author": "cli", + "created_at": 1788113433058 + }, + { + "id": 119, + "task_id": "t_mtg14vhpa6d08b44", + "kind": "release", + "note": "completed", + "author_kind": "claude-code", + "author": "cli", + "created_at": 1788116944773 + }, + { + "id": 120, + "task_id": "t_mtg14vsi44350a36", + "kind": "release", + "note": "completed", + "author_kind": "claude-code", + "author": "cli", + "created_at": 1788117468065 + }, + { + "id": 121, + "task_id": "t_mtg14vc4e18dcc51", + "kind": "release", + "note": "completed", + "author_kind": "claude-code", + "author": "cli", + "created_at": 1788118209074 + }, + { + "id": 122, + "task_id": "t_mtg14w35c3ea69d2", + "kind": "release", + "note": "completed", + "author_kind": "claude-code", + "author": "cli", + "created_at": 1788120549089 + }, + { + "id": 123, + "task_id": "t_mthed21ac8fc9045", + "kind": "claim", + "note": "claimed by ultracode-g1", + "author_kind": "claude-code", + "author": "cli", + "created_at": 1788200088878 + }, + { + "id": 124, + "task_id": "t_mthed27g8f0541d9", + "kind": "claim", + "note": "claimed by ultracode-g2", + "author_kind": "claude-code", + "author": "cli", + "created_at": 1788200089114 + }, + { + "id": 125, + "task_id": "t_mthed21ac8fc9045", + "kind": "release", + "note": "completed", + "author_kind": "claude-code", + "author": "cli", + "created_at": 1788203946198 + }, + { + "id": 126, + "task_id": "t_mthed27g8f0541d9", + "kind": "release", + "note": "completed", + "author_kind": "claude-code", + "author": "cli", + "created_at": 1788203946415 + }, + { + "id": 127, + "task_id": "t_mthed2dm1199bb26", + "kind": "claim", + "note": "claimed by ultracode-g3", + "author_kind": "claude-code", + "author": "cli", + "created_at": 1788203946620 + }, + { + "id": 128, + "task_id": "t_mthed2dm1199bb26", + "kind": "release", + "note": "completed", + "author_kind": "claude-code", + "author": "cli", + "created_at": 1788211897151 + }, + { + "id": 129, + "task_id": "t_mthed2jhd2bf3239", + "kind": "claim", + "note": "claimed by ultracode-g4", + "author_kind": "claude-code", + "author": "cli", + "created_at": 1788211897373 + }, + { + "id": 130, + "task_id": "t_mthed2jhd2bf3239", + "kind": "release", + "note": "completed", + "author_kind": "claude-code", + "author": "cli", + "created_at": 1788216237069 + }, + { + "id": 131, + "task_id": "t_mthed2p7ae1bdd6f", + "kind": "claim", + "note": "claimed by ultracode-g5", + "author_kind": "claude-code", + "author": "cli", + "created_at": 1788216237279 + }, + { + "id": 132, + "task_id": "t_mthed2p7ae1bdd6f", + "kind": "release", + "note": "completed", + "author_kind": "claude-code", + "author": "cli", + "created_at": 1788221761404 + }, + { + "id": 133, + "task_id": "t_mthed2v47f8e9b80", + "kind": "claim", + "note": "claimed by ultracode-g6", + "author_kind": "claude-code", + "author": "cli", + "created_at": 1788221761632 + }, + { + "id": 134, + "task_id": "t_mthed2v47f8e9b80", + "kind": "release", + "note": "completed", + "author_kind": "claude-code", + "author": "cli", + "created_at": 1788223248027 + }, + { + "id": 135, + "task_id": "t_mthed317ecced05c", + "kind": "claim", + "note": "claimed by ultracode-g7", + "author_kind": "claude-code", + "author": "cli", + "created_at": 1788223248244 + }, + { + "id": 136, + "task_id": "t_mthed317ecced05c", + "kind": "release", + "note": "completed", + "author_kind": "claude-code", + "author": "cli", + "created_at": 1788226216868 + }, + { + "id": 137, + "task_id": "t_mthed3786bb695ea", + "kind": "claim", + "note": "claimed by ultracode-c1", + "author_kind": "claude-code", + "author": "cli", + "created_at": 1788229827970 + }, + { + "id": 138, + "task_id": "t_mthed3ddbcfe52f0", + "kind": "claim", + "note": "claimed by ultracode-c2", + "author_kind": "claude-code", + "author": "cli", + "created_at": 1788229828190 + }, + { + "id": 139, + "task_id": "t_mthed3izf9e001fa", + "kind": "claim", + "note": "claimed by ultracode-c3", + "author_kind": "claude-code", + "author": "cli", + "created_at": 1788229828414 + }, + { + "id": 140, + "task_id": "t_mthed3786bb695ea", + "kind": "release", + "note": "completed", + "author_kind": "claude-code", + "author": "cli", + "created_at": 1788232509763 + }, + { + "id": 141, + "task_id": "t_mthed3ddbcfe52f0", + "kind": "release", + "note": "completed", + "author_kind": "claude-code", + "author": "cli", + "created_at": 1788232509976 + }, + { + "id": 142, + "task_id": "t_mthed3occ26b9f86", + "kind": "claim", + "note": "claimed by ultracode-c4", + "author_kind": "claude-code", + "author": "cli", + "created_at": 1788232510191 + }, + { + "id": 143, + "task_id": "t_mthed3occ26b9f86", + "kind": "release", + "note": "completed", + "author_kind": "claude-code", + "author": "cli", + "created_at": 1788234612693 + }, + { + "id": 144, + "task_id": "t_mthed3izf9e001fa", + "kind": "release", + "note": "completed", + "author_kind": "claude-code", + "author": "cli", + "created_at": 1788236668019 + }, + { + "id": 145, + "task_id": "t_mtlkd9ad80ce9781", + "kind": "claim", + "note": "claimed by engineer-standard", + "author_kind": "codex", + "author": "cli", + "created_at": 1788467460494 + }, + { + "id": 146, + "task_id": "t_mtlkd9ad80ce9781", + "kind": "release", + "note": "completed", + "author_kind": "codex", + "author": "cli", + "created_at": 1788808522145 + }, + { + "id": 147, + "task_id": "t_mtlkd9g2aae24594", + "kind": "claim", + "note": "claimed by codex-g2", + "author_kind": "codex", + "author": "cli", + "created_at": 1788808569103 + }, + { + "id": 148, + "task_id": "t_mtlkd9g2aae24594", + "kind": "release", + "note": "completed", + "author_kind": "codex", + "author": "cli", + "created_at": 1788810253296 + }, + { + "id": 149, + "task_id": "t_mtlkd9ln2f533934", + "kind": "claim", + "note": "claimed by g3_implementation", + "author_kind": "codex", + "author": "cli", + "created_at": 1788810299063 + }, + { + "id": 150, + "task_id": "t_mtlkd9ln2f533934", + "kind": "comment", + "note": "G3 implementation accepted for dev-to-main PR: independent SHIP, parent full gate 2024 pass / 1 skip / 0 fail, four-platform local artifact and extracted runtime-floor proof. Actual protected stable publication, published verification and topic remain pending after human main/release approval. Keep release task in progress.", + "author_kind": "codex", + "author": "cli", + "created_at": 1788811870438 + }, + { + "id": 151, + "task_id": "t_mtroeuxf15d926b2", + "kind": "claim", + "note": "claimed by roadmap_implementation", + "author_kind": "codex", + "author": "cli", + "created_at": 1788811924988 + }, + { + "id": 152, + "task_id": "t_mtroeuxf15d926b2", + "kind": "release", + "note": "completed", + "author_kind": "codex", + "author": "cli", + "created_at": 1788812675726 + }, + { + "id": 153, + "task_id": "t_mtrovqgrfa3f8cd3", + "kind": "claim", + "note": "claimed by a1_implementation", + "author_kind": "codex", + "author": "cli", + "created_at": 1788812722548 + }, + { + "id": 154, + "task_id": "t_mtrovqgrfa3f8cd3", + "kind": "release", + "note": "completed", + "author_kind": "codex", + "author": "cli", + "created_at": 1788813047621 + }, + { + "id": 155, + "task_id": "t_mtrp3pjybe0a523f", + "kind": "claim", + "note": "claimed by b1_implementation", + "author_kind": "codex", + "author": "cli", + "created_at": 1788813069096 + }, + { + "id": 156, + "task_id": "t_mtrp3pjybe0a523f", + "kind": "comment", + "note": "Accepted ce6fd7acc: independent SHIP, parent fullgate 2031 pass / 1 skip / 0 fail; real SQLite API and compiled CLI backup/import dogfood passed. Evidence: b1-acceptance.md. Internal retained API only; no new hire CLI.", + "author_kind": "codex", + "author": "cli", + "created_at": 1788814099274 + }, + { + "id": 157, + "task_id": "t_mtrp3pjybe0a523f", + "kind": "release", + "note": "completed", + "author_kind": "codex", + "author": "cli", + "created_at": 1788814099609 + }, + { + "id": 158, + "task_id": "t_mtrpq96zc09a9618", + "kind": "claim", + "note": "claimed by b3_implementation", + "author_kind": "codex", + "author": "cli", + "created_at": 1788814161544 + }, + { + "id": 159, + "task_id": "t_mtrpq96zc09a9618", + "kind": "comment", + "note": "B2 independently already-fixed on main. B3 accepted a1574d13e, independent SHIP; 12 tests/73 assertions pass at default0022 and027, exact surviving source-hunk dispositions recorded. Evidence b3-acceptance.md.", + "author_kind": "codex", + "author": "cli", + "created_at": 1788814466701 + }, + { + "id": 160, + "task_id": "t_mtrpq96zc09a9618", + "kind": "release", + "note": "completed", + "author_kind": "codex", + "author": "cli", + "created_at": 1788814467142 + }, + { + "id": 161, + "task_id": "t_mtrpy4u2ad2bb02a", + "kind": "claim", + "note": "claimed by brain_scaffold_implementation", + "author_kind": "codex", + "author": "cli", + "created_at": 1788814502158 + }, + { + "id": 162, + "task_id": "t_mtrpy4u2ad2bb02a", + "kind": "comment", + "note": "Accepted c06f05869: preserves original PR2893 commit, independent SHIP all13files; corrected note routing and template dates; static JSON/frontmatter/rendering/identity/reference checks and Biome pass. Static scaffold only; no live Brain integration claim. PR disposition reconciled after dev integration.", + "author_kind": "codex", + "author": "cli", + "created_at": 1788814869856 + }, + { + "id": 163, + "task_id": "t_mtrpy4u2ad2bb02a", + "kind": "release", + "note": "completed", + "author_kind": "codex", + "author": "cli", + "created_at": 1788814870117 + }, + { + "id": 164, + "task_id": "t_mtrr50zm1be98f68", + "kind": "claim", + "note": "claimed by unicode_controls_implementation", + "author_kind": "codex", + "author": "cli", + "created_at": 1788816507940 + }, + { + "id": 165, + "task_id": "t_mtrr50zm1be98f68", + "kind": "comment", + "note": "Unicode code correction committed5f97e6629 and independently SHIP:28tests461assertions,81 no-CLI rejection vectors. Final aggregate/dogfood acceptance remains pending until remaining validated promotion-review corrections are complete.", + "author_kind": "codex", + "author": "cli", + "created_at": 1788816902603 + }, + { + "id": 166, + "task_id": "t_mtrrebyk5f21272f", + "kind": "claim", + "note": "claimed by promotion_review_implementation", + "author_kind": "codex", + "author": "cli", + "created_at": 1788816934673 + }, + { + "id": 167, + "task_id": "t_mtrr50zm1be98f68", + "kind": "comment", + "note": "Accepted: Unicode fix 5f97e6629 independently SHIP; aggregate eeafaeace passed 2042 tests, 1 skip, 0 failures. Four .101 artifacts verified and native CLI plus real DSH dogfood passed. Included in correction PR #2897 to dev.", + "author_kind": "codex", + "author": "cli", + "created_at": 1788817997180 + }, + { + "id": 168, + "task_id": "t_mtrr50zm1be98f68", + "kind": "release", + "note": "completed", + "author_kind": "codex", + "author": "cli", + "created_at": 1788817997405 + }, + { + "id": 169, + "task_id": "t_mtrrebyk5f21272f", + "kind": "comment", + "note": "Accepted: five validated remote review gaps corrected in eeafaeace; independent SHIP, 2042 pass/1 skip/0 fail aggregate, four .101 artifact verification and native/DSH dogfood passed. Correction PR #2897 targets dev; promotion #2896 and post-main stable gates remain open.", + "author_kind": "codex", + "author": "cli", + "created_at": 1788817997619 + }, + { + "id": 170, + "task_id": "t_mtrrebyk5f21272f", + "kind": "release", + "note": "completed", + "author_kind": "codex", + "author": "cli", + "created_at": 1788817997949 + }, + { + "id": 171, + "task_id": "t_mtfzsu4ee1390c48", + "kind": "release", + "note": "completed", + "author_kind": "claude-code", + "author": "cli", + "created_at": 1788896649131 } ], "wish_groups": [], diff --git a/.genie/wishes/dsh-genie-board/WISH.md b/.genie/wishes/dsh-genie-board/WISH.md new file mode 100644 index 000000000..ab25ccedb --- /dev/null +++ b/.genie/wishes/dsh-genie-board/WISH.md @@ -0,0 +1,415 @@ +# Wish: Genie board for DSH Web + +| Field | Value | +|-------|-------| +| **Status** | IN_PROGRESS | +| **Slug** | `dsh-genie-board` | +| **Date** | 2026-09-03 | +| **Author** | Sofia with Felipe | +| **Appetite** | medium | +| **Branch** | `wish/dsh-genie-board` | +| **Repos touched** | genie | +| **Design** | [DESIGN.md](../../brainstorms/dsh-genie-board/DESIGN.md) | + +## Summary + +Ship a dual-face DSH Web plugin that displays and operates the authoritative Genie board through supported Genie CLI commands. The plugin lives with Genie, never opens `genie.db`, and keeps browser state subordinate to Host-confirmed snapshots. + +## Scope + +### IN + +- `plugins/dsh-genie-board/`: Host adapter, same-origin routes, browser kanban, DSH manifest, package build, tests, and install docs. +- Workspace/board discovery, board rendering, and supported create/move/comment/block/unblock/checkout/release/done mutations. +- Fixed executable and action-to-argv mappings with no shell, plus deadlines, aggregate subprocess/output budgets, JSON validation, and bounded errors. +- Release-payload integration, reference attribution, linked-profile smoke, and topic publication only after validation. + +### OUT + +- Direct SQLite access; revival of `genie mcp` or `genie ui-bridge`; plugin ledgers, watchers, polling, SSE, cron, or autonomous execution. +- Hard delete, dependency editing, wish authoring, deploy controls, production promotion, and announcements beyond the requested GitHub topic. + +## Decisions + +| # | Decision | Rationale | +|---|----------|-----------| +| 1 | Co-locate the package in public Genie. | Adapter and CLI compatibility ship together without exposing the private DSH monorepo. | +| 2 | Use only current CLI commands and validated JSON. | SQLite is private and former protocol surfaces are retired. | +| 3 | Resolve DSH workspace ids on the Host and map a strict action union to argv. | Browser input never becomes a path, executable, raw command, or arbitrary argv. | +| 4 | Make `genie board --board --json` the one complete aggregate read for lanes, cards, assignment, liveness, blocks, dependencies, timeline, and comments. | Every board response is complete and deterministic; there is no per-card, partial, or on-demand hydration path. | +| 5 | Derive one immutable candidate version before release build/sign/publish, then stamp the staged plugin version and minimum Genie version from that same candidate. | Group 2 implements and tests the comparator without depending on an already-published release; every shipped artifact binds compatibility to its own candidate. | +| 6 | Refresh the selected board with the complete aggregate after every mutation. | The browser never displays optimistic or partially hydrated state. | +| 7 | Use the authorized stable Release workflow and its protected human approval, then fail closed unless exactly four platform tarballs individually pass digest, signature, provenance, version, and plugin-member verification. | Publication and the GitHub topic cannot outrun release identity or artifact proof. | + +## Simplicity Case + +- **Simplest complete design:** one dual-face plugin, one strict Host adapter, and the existing Genie CLI as sole authority. +- **Added machinery:** route fencing, action validation, process/output caps, and one complete aggregate schema are required by browser-triggered mutations and requested card detail. +- **Deferred until measured:** polling, SSE, caches, deltas, batching, autonomous execution, cron, and resumable requests require explicit demand or measured refresh latency. +- **Complexity removed:** no durable state, daemon, socket, synchronization protocol, human-output parser, duplicate ledger, or configurable command surface. + +## Dependencies + +**depends-on:** none +**blocks:** none + +## Success Criteria + +- [ ] DSH Web selects an eligible workspace/board and renders lanes/cards from one complete aggregate CLI read, including structured assignment, block, liveness, dependency, timeline, and comment data. +- [ ] All supported mutations map to fixed Genie argv and return a fresh confirmed snapshot. +- [ ] Unsafe workspace/action/input, timeout, aggregate limit, malformed JSON, missing executable, and non-zero exit fail visibly without out-of-scope mutation. +- [ ] No shell, browser-provided path/executable/argv, direct SQLite access, human-output parser, plugin ledger, watcher, or daemon exists. +- [ ] Plugin tests/typecheck/build, security tests, linked-profile smoke, Genie full gate, and release verification pass. +- [ ] Install/compatibility docs and attribution are complete; only then the repository gains the `dsh-plugin` topic. + +## Execution Strategy + +### Wave 1 (sequential) + +| Group | Agent | Complexity | Model | Description | +|-------|-------|------------|-------|-------------| +| 1 | implementor | 3 — CLI contract plus multi-module surface | implementor-mid / high | Freeze structured reads and compatibility floor. | + +### Wave 2 (after Wave 1) + +| Group | Agent | Complexity | Model | Description | +|-------|-------|------------|-------|-------------| +| 2 | implementor | 5 — DSH integration plus subprocess/security boundaries | implementor-high / high | Build and test the dual-face plugin. | + +### Wave 3 (after Wave 2) + +| Group | Agent | Complexity | Model | Description | +|-------|-------|------------|-------|-------------| +| 3 | implementor | 3 — release payload and external topic gate | implementor-mid / high | Package, smoke, document, and publish discoverability. | + +## Execution Groups + +### Group 1: Complete aggregate CLI contract + +**Goal:** Freeze one complete, deterministic board JSON read without parsing human output or hydrating cards separately. + +**Deliverables:** +1. Extend `genie board --board --json` additively so one invocation returns `schemaVersion: 1`, board scope, ordered lanes, and every card's complete detail. Keep human board/task output unchanged and retain the exact `genie board list --json` contract (`id`, `name`, `laneCount`, `cardCount`). +2. Freeze this exact per-card aggregate shape and reject additional/missing keys and invalid nullability in fixtures: + + ```ts + { + id: string, boardId: string | null, title: string, + status: 'blocked' | 'ready' | 'in_progress' | 'done', + claimedBy: string | null, claimedAt: number | null, + wish: string | null, group: string | null, + assignedAgent: string | null, assignedReason: string | null, + createdAt: number, updatedAt: number, lane: string | null, + agentKind: string | null, heartbeatAt: number | null, + liveness: 'running' | 'idle' | 'stale' | null, + blockedBy: string | null, blockedReason: string | null, + enforcedBlock: { reason: string, kind: 'work' | 'hold' } | null, + dependencies: Array<{ + id: string, title: string, + status: 'blocked' | 'ready' | 'in_progress' | 'done' + }>, + timeline: Array<{ + id: number, kind: string, note: string | null, + authorKind: string | null, author: string | null, createdAt: number + }>, + comments: Array<{ + id: number, note: string, authorKind: string | null, + author: string | null, createdAt: number + }> + } + ``` + + Lanes keep their existing order; cards keep the board's existing order; dependencies sort by task id; timeline sorts by `createdAt` then `id`; comments are the ordered `kind === 'comment'` projection of that timeline with non-null text. `liveness` is null when `claimedBy` is null and otherwise derives from `heartbeatAt`. +3. Fetch and join the aggregate in one repository read transaction/query path so every card belongs to the same snapshot. Missing or malformed detail fails the entire command non-zero; the JSON contract has no `partial`, `truncated`, cursor, hydration, or on-demand state. Add success, unknown-board, empty-board, multi-card ordering, stderr/exit-code, exact-key, nullability, and idempotent-read tests. + +**Acceptance Criteria:** +- [ ] One `genie board --board --json` invocation returns every rendered card and all required assignment, block, liveness, dependency, timeline, and comment data from one complete snapshot. +- [ ] No human CLI output or per-card `task status` call is consumed; every aggregate key, ordering rule, and fail-closed case has an exact fixture/schema assertion. + +**Validation:** +```bash +bun test src/term-commands/v5-board.test.ts && bun run check +``` + +Full gate is required because this changes a shared aggregate CLI contract. + +**depends-on:** none +**blocks:** Group 2 + +--- + +### Group 2: DSH Host adapter and kanban + +**Goal:** Deliver the secure, Host-confirmed DSH Web board experience. + +**Deliverables:** +1. Create `plugins/dsh-genie-board/package.json`, `agent.cordis.yml`, `cordis.patch.yml`, `README.md`, `NOTICE`, TypeScript/build configuration, source/tests, and the package-local `build` script. Freeze `dist/index.js` as the Host bundle and `dist/client.js` as the browser bundle; add root `build:plugin` as `bun run --cwd plugins/dsh-genie-board build` (verified Bun invocation; the earlier flag ordering printed usage without building). +2. Implement a `minimumGenieVersion` plugin field and strict semver comparator without hard-coding a not-yet-published release. Source and linked-profile tests use the checkout root version; Group 3 stamps both the shipped plugin version and `minimumGenieVersion` from its already-derived immutable candidate. At Host startup run the fixed executable as `genie --no-interactive --version`; an older/unparseable version serves no board route. +3. Resolve a workspace id only through DSH `workspaceRegistry`; canonicalize the registry result with `realpath`, require a physical repository containing `.genie`, and use that canonical path as `cwd`. Never accept a browser path. Resolve the Genie executable once from the Host-owned installation, canonicalize it to an absolute executable regular file, and never search for or override it per request. +4. Spawn with `shell: false` and an exact Host-owned environment allowlist: `PATH`, `HOME`, `GENIE_HOME`, `NO_COLOR=1`, `GENIE_AGENT_NAME=`, and `GENIE_AGENT_KIND=dsh`; drop every other variable and accept no environment value from the browser. +5. Implement this normative action table; every argv vector includes `--no-interactive` and no action may synthesize another vector: + + | Action | Fixed executable argv | + |--------|-----------------------| + | List boards | `genie --no-interactive board list --json` | + | Read board | `genie --no-interactive board --board --json` | + | Create | `genie --no-interactive task create --title --board <ref>` | + | Move | `genie --no-interactive task move <id> --to <lane>` | + | Comment | `genie --no-interactive task comment -- <id> <text>` | + | Block | `genie --no-interactive task block <id> --reason <text> [--hold]` | + | Unblock | `genie --no-interactive task unblock <id>` | + | Checkout | `genie --no-interactive task checkout <id> --worker <host-derived-identity>` | + | Release | `genie --no-interactive task release <id>` | + | Done | `genie --no-interactive task done <id>` | + +6. Validate `workspaceId` by exact registry membership; accept `boardRef` only when it equals an id returned by validated board-list JSON; require task ids matching `^t_[a-z0-9]+$`; require lane to equal a lane name from the selected validated board; trim and bound title to 1–200 UTF-8 bytes, comment text to 1–4000, and block reason to 1–1000; reject NUL/control characters, unknown object keys, non-boolean `hold`, and all browser-supplied worker/path/executable/environment/command/argv fields. +7. Use exactly one Genie process for board list/load and at most two sequential processes for a mutation plus its complete aggregate refresh, with a 10-second aggregate deadline and 4 MiB aggregate stdout plus stderr. Kill the active child on timeout/limit/error; validate exit code and the closed aggregate schema before use; any missing/oversized/malformed detail fails the whole response. Test every bound deterministically, including hostile identifiers and command-injection attempts. +8. Implement browser selectors, lanes/cards/details/errors, mutations, refresh, and visibility recovery; each mutation response performs one complete board aggregate re-read and never applies optimistic or partially hydrated state. +9. Add `scripts/dsh-genie-board-smoke.ts`: create isolated fixture repo/profile state, run `dsh plugin --profile web add link:<absolute-plugin-dir>`, launch `dsh web --no-open --host 127.0.0.1 --port 0`, stop/relaunch it after install, prove `dsh plugin --profile web list --depth 0` reports `@automagik/genie-dsh-board`, read back the plugin health/compatibility route, perform board list/load plus reversible create/move through the Host route, and in `finally` stop the server, run `dsh plugin --profile web remove @automagik/genie-dsh-board`, and delete only the temporary profile/repository. + +**Execution clarification — bounded selection evidence (2026-09-07):** + +The fixed comment vector includes the standard `--` option terminator so valid comment text such as `--help` is stored as prose rather than interpreted as a CLI option. A real-CLI regression must verify the stored comment, not merely a successful exit or mocked argv. This is an owner-approved argument-boundary correction within the existing comment action. + +To satisfy the fixed subprocess budgets while validating selected identifiers, the Host may retain only validated board IDs and the task-ID/lane-name sets of one selected board per registered workspace. This is selection-validation evidence, not authentication or a response cache: no aggregate is stored or served, every returned board is freshly read, and there is no persistence, polling, TTL, queue or capability protocol. + +Bind evidence to workspace ID, canonical repository path and selected board ID. Allow one active operation per workspace (including list/load/mutation/refresh); reject overlaps with a retryable conflict. Replace evidence only after complete validation. Invalidate selected evidence on failures, registry removal, path rebinding and board changes. If mutation succeeds but refresh fails, report that the operation may have completed and require a new load; never retry automatically. Enforce verified DSH authentication and same-origin fencing before accessing evidence or spawning; reject missing/mismatched mutation Origin and unsupported content types. Membership reflects the latest confirmed selection; supported CLI mutations do not reassign tasks between boards, and concurrent state transitions retain CLI checks. + +Required tests cover concurrent board switches, mutation during load, invalidation after failures, workspace path rebinding, foreign task IDs, malformed refresh after successful mutation and cross-origin requests, with process-count assertions. This narrow allowance resolves the read-validation/process-budget tension; the prohibition on general caches and ledgers remains. + +Independent contract review: `/root/g1_review` required these exact concurrency/validity safeguards; owner incorporated them before implementation. Final G2 review must verify their implementation. Independent reread returned **SHIP** for the clarification, reviewed WISH SHA-256 `95f4cbb3cfe904117bece4d1b742591e7e2dc68514427b4200acd3d17b6db143` before this receipt was added; this is contract approval, not implementation acceptance. The installed DSH floor is now 0.1.2-rc.1 and must be proven by the real smoke. When no public sidebar extension exists, a labeled Genie launcher and accessible dialog mounted through public client apply/effect is an acceptable entry point; no DOM observer or private runtime API. + +**Acceptance Criteria:** +- [x] Rendering matches one complete fixture-backed Host aggregate; mutations use fixed argv and perform one complete refresh. +- [x] Every unsafe/failure case is bounded and cannot invoke an out-of-scope command. +- [x] The manifest and runtime reject every Genie version below the immutable candidate value stamped by Group 3, while source/linked tests prove the comparator against the checkout version without depending on a prior publication. +- [x] Package build and the linked-profile install/restart/read-back/board-operation/cleanup smoke pass against DSH `0.1.1-rc.2` or a newer explicitly proven floor. +- [x] No database access, persistence, watcher, poller, shell, or browser-supplied executable/path/argv exists. + +**Validation:** +```bash +bun run check && bun run build:plugin && bun test plugins/dsh-genie-board && bun scripts/dsh-genie-board-smoke.ts +``` + +Full gate plus plugin build covers runtime and trust-boundary risk. + +**depends-on:** Group 1 +**blocks:** Group 3 + +--- + +### Group 3: Immutable candidate, release proof, and discoverability + +**Goal:** Publish only a human-approved stable candidate whose four platform artifacts prove the complete plugin payload. + +**Deliverables:** +1. Add install/compatibility docs and provenance/NOTICE. Source and linked builds use the checkout root version; no source file guesses a future release number. +2. Extend `scripts/release-payload-version.ts` and its tests so the release workflow's already-resolved `VERSION` stamps and verifies all version-bearing staged and extracted members: root `VERSION`, existing Genie manifests, `plugins/dsh-genie-board/package.json.version`, and the DSH manifest's plugin version and `minimumGenieVersion`. Stamping happens before tarball creation, and any missing/divergent field fails the build. +3. Preserve the repository's authorized release identity sequence: + - `.github/workflows/version.yml` derives a single candidate `VERSION`, binds it to an immutable tag/source SHA and successful source CI before any release build, and never reuses that identity; + - the final stable release is started by a maintainer through `.github/workflows/release.yml` with that exact version/tag SHA/CI run; + - the protected `production` environment approval must succeed before `authorize`, build, sign/attest, or publish can run. + The same candidate value flows unchanged through build, signature, provenance, release asset names, plugin version, and `minimumGenieVersion`. +4. Add `scripts/verify-dsh-genie-board-release.ts` plus tests with two explicit modes: `--unsigned-artifact-dir` proves local tar inventory/member/version completeness, while `--signed-artifact-dir` and `--release` additionally require cryptographic sidecars and digest binding. Wire signed-artifact mode into `.github/workflows/release-publish.yml` after signed artifacts are downloaded but before draft reconciliation/publication. For the supplied candidate and channel, signed-artifact/release mode must fail closed unless: + - the tarball stem set is exactly `linux-x64-glibc`, `linux-x64-musl`, `linux-arm64`, and `darwin-arm64`, with one nonempty `.bundle` and `.intoto.jsonl` beside each; + - each tarball's recomputed SHA-256 equals its channel delivery descriptor's `artifactSha256`; + - `scripts/verify-release.sh --local <tarball>` passes independently for each tarball, proving its cosign identity and SLSA provenance; + - each extracted tarball contains every required plugin member: `package.json`, `agent.cordis.yml`, `cordis.patch.yml`, `README.md`, `NOTICE`, `dist/index.js`, and `dist/client.js`; + - each extracted root/plugin/manifest version and `minimumGenieVersion` equals the immutable candidate exactly. +5. After the stable release is published, run the same verifier in release-download mode against `v$VERSION` and read back the release tag/source binding. Only that green post-publication proof permits adding the `dsh-plugin` GitHub topic; read the topic back afterward. A dev release, local build, unsigned tarball, missing platform, OR-style member check, or approval from the release initiator does not satisfy this gate. + +**Acceptance Criteria:** +- [ ] Candidate version/tag/source/CI identity exists before build and flows unchanged through all four tarballs, plugin metadata, signatures, provenance, descriptors, and the published stable release. +- [ ] The protected human approval precedes build/sign/publish, and the pre-publication verifier rejects any missing/extra platform stem, digest mismatch, missing/invalid sidecar, missing required plugin member, or version mismatch. +- [ ] All four exact published tarballs independently pass SHA-256, cosign, SLSA, complete-member, and version/floor checks; the linked smoke from Group 2 changes no user repository. +- [ ] Topic publication occurs last and is verified by read-back. + +**Validation:** +```bash +bun install --frozen-lockfile +bun run check +bun run build:plugin +VERSION="$(jq -r .version package.json)" +for PLATFORM in linux-x64-glibc linux-x64-musl linux-arm64 darwin-arm64; do + bun run build:binary -- --platform "$PLATFORM" --version "$VERSION" +done +bun scripts/verify-dsh-genie-board-release.ts --unsigned-artifact-dir dist --version "$VERSION" +# Final gate after the separately approved stable Release workflow publishes: +bun scripts/verify-dsh-genie-board-release.ts --release "v$VERSION" --channel stable +``` + +The unsigned verifier mode proves only locally built inventory and member/version completeness and cannot authorize publication. Signed-artifact mode is mandatory inside the approved release workflow; the final release-download run is mandatory after publication and proves exactly four published platform tarballs individually against their digest, signature, provenance, complete plugin inventory, and immutable candidate identity before topic publication. + +**depends-on:** Group 2 +**blocks:** stable release/topic publication + +--- + +## QA Criteria + +- [ ] Fixture repository loads workspace, board, lanes, details, and errors correctly in DSH Web. +- [ ] Every supported mutation round-trips and only confirmed refreshed state renders. +- [ ] Adversarial route/action/path/identifier cases cannot escape fixed command/workspace boundaries. +- [ ] Existing Genie CLI output and non-DSH release behavior remain compatible. + +--- + +## Assumptions / Risks + +| Risk | Severity | Mitigation | +|------|----------|------------| +| DSH APIs drift from installed `0.1.1-rc.2`. | Medium | Use locally proven injections, linked smoke, and tested engine floor. | +| Complete aggregate detail increases one response's size. | Medium | Bound one snapshot by bytes/time and fail the whole response rather than expose partial state. | +| Additive JSON becomes a public contract. | Medium | Fixture every key and pin plugin compatibility floor. | +| Release payload omits co-located files. | High | Update manifests and verify final tarballs from a clean checkout. | + +--- + +## Review Results + +### Plan review round 2 — FIX-FIRST (2026-09-03T14:28:27Z) + +- **Reviewed commit:** `a886373a1cca05578ffd3c5ff503e89801f4ada9` +- **Reviewer:** `agent:steve:dashboard:8dc1921d-9f6b-41d0-9095-8ac7ee731afb` +- **Mode:** independent, read-only, detached snapshot +- **Validation:** `wishes:lint`, design-evidence verification, diff check, shell syntax, CLI/source contract checks all passed; snapshot remained clean. +- **Verdict:** **FIX-FIRST** — 0 CRITICAL, 3 HIGH. + +Remaining HIGH gaps after the second review round: + +1. **Detail hydration is contradictory.** The plan promises unconditional complete detail while also allowing partial/on-demand enrichment under a 20-process cap. Choose either one aggregate complete JSON read or a fully specified partial/on-demand contract, including deterministic ordering and mutation-budget accounting. +2. **Compatibility-floor sequencing is circular.** Group 2 depends on a “first released version” that Group 3 has not released. Define a candidate/version-stamping contract produced by the same release, then prove that exact version in Group 3, or add and reconcile an explicit earlier release gate. +3. **Published-release proof is incomplete.** The tar membership check can pass with only one required member; the verifier does not itself require the four named artifacts or inspect plugin contents, and no executable step creates or identifies the signed candidate and sidecars. Specify the authorized candidate workflow and independently assert every required member in each exact artifact before publication. + +Fix-loop budget is exhausted (`2/2`). Cause: `ambiguous-spec` for the hydration contract and `missing-context` for the release-candidate workflow. Owner: Sofia/Felipe. Next gate: resolve those product/release decisions, amend the plan, and obtain a fresh independent plan review. Implementation, release work, and external publication remain blocked. + +### Decision resolution — direct Felipe approval (2026-09-03) + +Felipe directly authorized the bounded plan amendment: complete aggregate views from one structured read; one immutable candidate version derived before build/sign/publish; and the authorized, human-approved stable workflow with fail-closed proof of exactly four platform artifacts and every required plugin member. The amended plan removes partial hydration, makes candidate stamping non-circular, and adds per-artifact digest/signature/provenance/member verification. No implementation, release, push, or topic publication was authorized by this amendment. + +### Plan review round 3 — SHIP (2026-09-03T19:48:26Z) + +- **Reviewed commit:** `9c5ba2714c52be97ad1742d7f2f3d1bd6c65a0c0` +- **Reviewer:** Steve, `juice/GLM-5.3` (full non-Flash GLM family; cross-family from Sofia/OpenAI GPT) +- **Mode:** independent, read-only, detached snapshot; runtime exposed no separate reasoning control, so maximum deliberation was required in the brief +- **Validation:** exact HEAD and detached state confirmed; `git status --porcelain` empty before/after; `wishes:lint` passed (86 files); amendment diff and live board/release interfaces inspected +- **Verdict:** **SHIP** — 0 CRITICAL, 0 HIGH; all three prior blockers closed + +Closure evidence: +1. Complete aggregate board detail is one deterministic read with no partial/on-demand hydration and whole-response failure on missing detail. +2. Candidate identity is derived and bound before build; Group 2 tests the comparator without a published-version dependency; Group 3 stamps the same candidate into every shipped compatibility/version field. +3. The protected stable approval precedes build/sign/publish; signed and post-publication verification require exactly four platform stems, per-artifact SHA-256/cosign/SLSA proof, explicit plugin members, and exact candidate versions. + +Non-blocking review notes: document that `minimumGenieVersion` intentionally equals the co-shipped plugin release, and record the exact DSH binary path/version in smoke output. Plan status advances to `APPROVED`; implementation and every release/publication gate remain separately authorized. + +### Group 1 execution review round 1 — FIX-FIRST (2026-09-03T20:44:39Z) + +- **Reviewed base:** `7c8b5afef` plus the uncommitted Group 1 diff +- **Diff SHA-256:** `5de022e12e2fbf51c4c99c96b5c0edb304e326598e04be5aa95c189e85c03300` +- **Reviewer:** independent native execution reviewer; read-only working-tree review +- **Validation:** `git diff --check` passed; focused board suite passed (50 tests); `bun run check` reached 1955 pass / 1 skip / 9 fail in untouched release/update/local-delivery tests. +- **Verdict:** **FIX-FIRST** — 0 CRITICAL, 2 HIGH, 2 MEDIUM. + +Blocking gaps: +1. Malformed persisted lane metadata could serialize an invalid lane object with exit 0 instead of failing the whole aggregate. +2. Tests did not prove the constant set-query/single-transaction snapshot contract or the required range of malformed/nullability failures. + +Non-blocking gaps: make equal-timestamp ordering and all liveness states discriminating, and strengthen byte-level compatibility fixtures for unchanged CLI surfaces. Fix loop 1 is active; the task remains `in_progress`. + +### Group 1 execution review round 2 — BLOCKED (2026-09-03T20:59:04Z) + +- **Reviewed base:** `7c8b5afef` plus the corrected uncommitted Group 1 diff +- **Diff SHA-256:** `a1b323b4073221ddb77eed59a8a64a7169837906b4fab66773d5f191498b003d` +- **Reviewer:** independent native execution reviewer; read-only working-tree review +- **Code verdict:** no remaining Group 1 findings; every round-one gap is closed. +- **Validation:** `git diff --check`, focused board suite (70 tests), typecheck, and scoped Biome passed. `bun run check` reached 1975 pass / 1 skip / 9 fail. +- **Verdict:** **BLOCKED** — the wish requires a green full gate, and the same nine release/update/local-delivery failures reproduce on untouched detached `HEAD`. + +Corrective route: resolve or formally clear the repository-baseline failures, then rerun `bun run check`. No further Group 1 code fix is indicated; task `t_mtlkd9ad80ce9781` remains `in_progress`. + + +### Group 1 execution review round 3 — code SHIP (2026-09-07) + +- **Reviewer:** independent Codex native reviewer `/root/g1_review`; not the original GLM implementation author. +- **Reviewed HEAD:** `fef77105405991b2f316626b664abdbcdeb7bd08` plus preserved G1 changes, replayed on current dev without conflict. +- **Full diff SHA-256 before this ledger entry:** `a422d2b135fe31ac9fb1507c8c523c95221cf142f4fb2e0735f2d5abdef3e554`. +- **Code/test diff SHA-256:** `57019e9daf27c7ca212000c8976ce41c4b604a363ab75fc088aac4260a61ae2c`. +- **Verdict:** code **SHIP**, no actionable findings. Snapshot consistency, indexed task-scoped JSON-set reads, 33k-card behavior, ordering/nullability, malformed-detail rejection, sanitized identifiers and unchanged output contracts reviewed. +- **Validation:** independent focused suite 76 pass / 0 fail, 382 assertions; diff check passed. Full repository gate is separate and remains pending recovery of reproduced release-test failures. No task-done or release claim. + +### Group 1 acceptance — full gate green (2026-09-07) + +- Baseline repairs independently reviewed **SHIP** by `/root/g1_review`; three-file diff digest `6c03b3e23eb59098b7554256dfd5d1cf741811a79e1a889204b389dc6cc57cf9`, committed as `4928e3988`. +- Root causes: release integration scenarios exceeded implicit test deadlines; Bun preserved a test-owned exit code when restored to undefined; a descendant-cleanup fixture could interpret empty stdout as PID zero. Assertions remain intact; subprocesses are bounded and cleanup validates a positive PID. +- **Full gate:** `bun run check` exited 0; **1990 pass / 1 skip / 0 fail**, 8407 assertions across 96 test files, 262.56 seconds. Frozen dependency install and build passed. +- **Dogfood:** built `dist/genie.js` against an isolated HOME and repository; board creation, task creation, comment, move and aggregate read returned the expected lane, comment and timeline. No personal profile or repository changed. +- G1 code review, full validation and built-CLI smoke are accepted. G2/G3 and stable publication remain pending; this is not whole-wish release acceptance. + +### G2 implementation review — 2026-09-07T19:33Z — FIX-FIRST, round 1 + +- Independent reviewer `/root/g2_review` inspected all 13 plugin files, smoke and root configuration against HEAD `9cef95e4ccc5e9c597d16fdf3d7dec1598b8640b`; reviewed content SHA-256 `a48a527744ebb5c02f8915aaa3085e7502ca3d31a052433b678dcdbcf2e05ae1`. +- P1: exact Host routes bypassed DSH's authenticated `/api` prefix. Require the installed public `connection.requestRejection(req)` browser-cookie check before every route, in addition to Origin/loopback fencing. The original unauthenticated smoke is not acceptance evidence. +- P2: option-shaped comment text could exit successfully without writing a comment. The owner approved the fixed `--` argument terminator above; regression must read back the literal stored text through the real CLI. +- Focused independent run: 13 pass, 161 assertions. The first browser attempt did not mount the launcher and supplies no visual acceptance. Engineer is correcting both findings and repeating authenticated smoke/rendered validation; final review and current full gate remain pending. + +### G2 implementation re-review — 2026-09-07 — SHIP, fix loop 1 + +- Independent reviewer `/root/g2_review` returned **SHIP**, no unresolved findings, on all 17 files in frozen source manifest SHA-256 `7395d3c0064d65fc8a568527df755859753bfe51336d4d8b978d6eadb328783a`, atop HEAD `9cef95e4ccc5e9c597d16fdf3d7dec1598b8640b`. Owner independently verified every manifest entry. +- P1 closed: every route applies public DSH connection authentication before workspace access or operations; real smoke exchanges the launch token and proves missing/invalid cookies return 401. P2 closed: real CLI writes the literal `--help` comment using the fixed option terminator; rendered details confirm it. +- Independent focused validation: **19 pass, 249 assertions, zero failures**, including regenerated browser factory. Reviewed fixed actions, selection/concurrency invalidation, valid canonical-path rebinding, refresh failure, process/output budgets and authentication boundaries. +- Real installed DSH **0.1.2-rc.1** smoke proves installation, restart, authenticated operations and cleanup. Reviewer and owner inspected wide/narrow/history images; browser evidence also proves card details, comment submission, horizontal lane scroll, history access, Escape and restored focus. +- Parent full gate is running on the frozen repaired files. This receipt accepts code and rendered behavior; group completion still requires that current full gate. + +### G2 owner acceptance — 2026-09-07 + +- Parent `bun run check` on the repaired frozen source exited **0**: **2009 pass / 1 skip / 0 fail**, 8653 assertions across 98 files, 270.63 seconds. Scope is the repository-mandated full gate for runtime, trust-boundary and build/configuration changes. +- Parent `bun run build:plugin`, focused plugin tests (**19 pass / 249 assertions**) and real `bun scripts/dsh-genie-board-smoke.ts` each exited **0**. The owner smoke separately proves authenticated install/list/restart/health/load/create/move and literal option-shaped comment, followed by plugin removal and temporary-state cleanup. +- Combined with independent code/security/quality and rendered **SHIP**, Group 2 implementation is accepted. The comparator is proven for source/linked builds; immutable candidate stamping and extracted runtime-floor proof remain explicitly owned by Group 3, so the combined future-artifact checkbox above remains open. + +### G3 implementation review — 2026-09-07 — SHIP + +- Independent reviewer `/root/g3_review` inspected all 12 changed/new release, build, runtime metadata, test and documentation files, including both publication paths and the updated contributor release contract. **SHIP**, no remaining findings. +- Frozen patch SHA-256 `8924c120504f9239e455b61472d583164d3356b41c9bc4cd4a2596d748b8b435` (33399 bytes): ten tracked-file diffs, followed by new verifier test and source diffs. Parent independently reproduced the hash before adding this ledger. +- Review closed P1 unauthenticated descriptor source binding by requiring pinned delivery attestation and exact predicate equality. It closed P2 false-positive trust tests with separate cosign/SLSA failure stages and all-four-platform invocation checks; missing executables do not satisfy these tests. +- Independent focused validation: **30 pass / 0 fail / 70 assertions**, including payload versions, existing trust helper, descriptor tampering, release tag mismatch, required members and unsafe archive links; diff check passed. +- Parent current full gate and final rebuilt artifact verification remain implementation-acceptance gates. Deterministic trust fixtures prove orchestration, not real signed publication. Protected stable publication, release-download proof and topic publication remain pending the human-approved release. + +### G3 live approval-policy correction and aggregate validation — 2026-09-07 + +- Owner read back the actual GitHub `production` environment: its two required reviewers were configured, but `prevent_self_review` was false. Independent reviewer confirmed this contradicted the approved non-initiator release gate. The owner enabled `prevent_self_review` under that approved requirement, preserving both reviewer IDs/types and deployment branch policy. Fresh environment and branch-policy reads confirm the setting is true, the same two reviewers remain, and `main` remains the sole allowed branch. No release or deployment was started. +- First parent full gate: **2023 pass / 1 skip / 1 fail / 1 error**. The one failure was the existing 13-subprocess roadmap round-trip exceeding Bun's default 5-second test timeout; teardown killed the final child, causing the secondary exit-143 assertion. The unchanged focused case passed in 3.04 seconds. +- Minimal repair gives only that 13-subprocess test a bounded 20-second budget with an explanatory comment; every behavior assertion remains unchanged. The focused canonical-sync group then passed **7 tests / 62 assertions**, affected case 2.96 seconds. Independent review and a fresh parent aggregate gate will close this repair. +- Final four locally built candidate artifacts passed owner extraction checks: all required plugin files, root/Orca/DSH version metadata, compiled Host floor `5.260907.99`, and current README/NOTICE. Native Linux glibc binary version also matched. Owner unsigned verifier and source/linked DSH smoke each exited 0; these remain local premerge proofs, not published signature evidence. +- Independent follow-up accepted the scoped timing repair: **SHIP**, diff SHA-256 `eeae081f438858a42c526b7686a244b18031c96be012a84de9b78efc8de74633`, all assertions retained. Reviewer also independently compared production environment before/after evidence and confirmed the only protection change was `prevent_self_review: false → true`; reviewers and the sole `main` policy are identical. + +### G3 premerge implementation acceptance — 2026-09-07 + +- Fresh parent `bun run check` exited **0**: **2024 pass / 1 skip / 0 fail**, 8692 assertions across 99 files, 284.78 seconds. Full gate covers release/CI, shared runtime metadata, build and test-fixture changes. +- Parent source plugin build, real authenticated DSH source/linked smoke, final four-artifact unsigned verifier and independent extraction/runtime-floor checks all exited **0**. Independent code/security review and the scoped fixture repair are **SHIP**. +- Group 3's implementation is accepted for the requested reviewed/dogfooded `dev → main` PR. Actual stable publication, published signature/provenance verification, and topic publication remain unperformed and open. The Group 3 release task and whole wish therefore remain in progress; this is not a released/SHIPPED wish. Other approved independent queue items may continue while that post-main gate awaits the human release. + +--- + +## Files to Create/Modify + +``` +plugins/dsh-genie-board/** +src/term-commands/v5-board.ts +src/term-commands/v5-board.test.ts +scripts/dsh-genie-board-smoke.ts +scripts/verify-dsh-genie-board-release.ts +scripts/verify-dsh-genie-board-release.test.ts +scripts/build-binary.sh +scripts/release-payload-version.ts +scripts/release-payload-version.test.ts +scripts/release-docs.test.ts +scripts/version-format.test.ts +scripts/version-ci-staging.test.ts +scripts/orca-manifest-parity.test.ts +.github/workflows/release-publish.yml +package.json +README.md +.genie/brainstorms/dsh-genie-board/** +.genie/wishes/dsh-genie-board/WISH.md +.genie/INDEX.md +``` diff --git a/.github/workflows/release-publish.yml b/.github/workflows/release-publish.yml index 553423723..eef23e4b9 100644 --- a/.github/workflows/release-publish.yml +++ b/.github/workflows/release-publish.yml @@ -1457,6 +1457,10 @@ jobs: - name: Install slsa-verifier for published-asset reuse verification uses: slsa-framework/slsa-verifier/actions/installer@ea584f4502babc6f60d9bc799dbbb13c1caa9ee6 # v2.7.1 + - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 + with: + bun-version: 1.3.11 + - name: Prepare draft and reconcile exact endorsed assets env: GH_TOKEN: ${{ github.token }} @@ -1475,6 +1479,7 @@ jobs: # byte; partial published releases and mismatches fail closed. The # repository setting is an externally verified cutover prerequisite: # GITHUB_TOKEN cannot read the Administration API that exposes it. + bun scripts/verify-dsh-genie-board-release.ts --signed-artifact-dir dist --version "$VERSION" --channel "$CHANNEL" bash scripts/reconcile-release-note.sh prepare bash scripts/reconcile-release-assets.sh @@ -1697,6 +1702,10 @@ jobs: - name: Install slsa-verifier for final remote verification uses: slsa-framework/slsa-verifier/actions/installer@ea584f4502babc6f60d9bc799dbbb13c1caa9ee6 # v2.7.1 + - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 + with: + bun-version: 1.3.11 + - name: Publish, lock, and reverify the complete remote inventory shell: bash env: @@ -1708,6 +1717,7 @@ jobs: CANDIDATE_MANIFEST_DIR: ${{ runner.temp }}/candidate-manifests run: | set -euo pipefail + bun scripts/verify-dsh-genie-board-release.ts --signed-artifact-dir dist --version "$VERSION" --channel "$CHANNEL" bash scripts/reconcile-release-note.sh finalize # Every channel owns a fresh tag. Publish and lock the exact verified # inventory before any public manifest can name it. diff --git a/AGENTS.md b/AGENTS.md index d0ecc9091..1043f3598 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -54,7 +54,7 @@ Biome enforces single quotes, two-space indentation, 120-column lines, and trail ## Release contract -Release tarballs contain the binary, the `plugins/genie` Orca payload, `skills/`, `templates/`, and `VERSION`. Three version files are stamped and must agree with `package.json`: `package.json`, `plugins/genie/package.json`, and `plugins/genie/orca-plugin.json`. The root `orca-marketplace.json` is a source-only, versionless index that no tarball carries. Stable is the default channel; dev requires explicit selection. Build and verify every supported release tarball before promotion. +Release tarballs contain the binary, the `plugins/genie` Orca payload, the `plugins/dsh-genie-board` DSH payload, `skills/`, `templates/`, and `VERSION`. Committed root and Orca package versions must agree. Staging stamps the immutable candidate into `VERSION`, both plugin packages, the Orca manifest, and the DSH package compatibility floor; the DSH Host bundle is built with the same candidate. Source/linked Host builds use the checkout root version. The root `orca-marketplace.json` is a source-only, versionless index that no tarball carries. Stable is the default channel; dev requires explicit selection. Build and verify every supported release tarball before promotion. ## Runtime-specific notes diff --git a/brain/.brain/config.json b/brain/.brain/config.json new file mode 100644 index 000000000..7a73f856d --- /dev/null +++ b/brain/.brain/config.json @@ -0,0 +1,4 @@ +{ + "brainId": "brn_w06loz", + "vaultDir": "brain" +} diff --git a/brain/.obsidian/app.json b/brain/.obsidian/app.json new file mode 100644 index 000000000..b7792336d --- /dev/null +++ b/brain/.obsidian/app.json @@ -0,0 +1,6 @@ +{ + "alwaysUpdateLinks": true, + "newFileLocation": "root", + "showUnsupportedFiles": false, + "useMarkdownLinks": false +} diff --git a/brain/.obsidian/daily-notes.json b/brain/.obsidian/daily-notes.json new file mode 100644 index 000000000..b3a4a293f --- /dev/null +++ b/brain/.obsidian/daily-notes.json @@ -0,0 +1,5 @@ +{ + "folder": "Daily", + "format": "YYYY-MM-DD", + "template": "_Templates/daily" +} diff --git a/brain/.obsidian/graph.json b/brain/.obsidian/graph.json new file mode 100644 index 000000000..58ef92f99 --- /dev/null +++ b/brain/.obsidian/graph.json @@ -0,0 +1,46 @@ +{ + "collapse_filter": false, + "search": "", + "showTags": true, + "showAttachments": false, + "showOrphans": true, + "colorGroups": [ + { + "query": "path:Daily", + "color": { + "a": 1, + "rgb": 5431424 + } + }, + { + "query": "path:Intelligence", + "color": { + "a": 1, + "rgb": 1474816 + } + }, + { + "query": "path:Playbooks", + "color": { + "a": 1, + "rgb": 26112 + } + }, + { + "query": "tag:#decision", + "color": { + "a": 1, + "rgb": 16744448 + } + } + ], + "showArrow": true, + "textFadeMultiplier": 0, + "nodeSizeMultiplier": 1, + "lineSizeMultiplier": 1, + "centerStrength": 0.5, + "repelStrength": 10, + "linkStrength": 1, + "linkDistance": 250, + "scale": 1 +} diff --git a/brain/.obsidian/templates.json b/brain/.obsidian/templates.json new file mode 100644 index 000000000..3656cafe6 --- /dev/null +++ b/brain/.obsidian/templates.json @@ -0,0 +1,3 @@ +{ + "folder": "_Templates" +} diff --git a/brain/_Templates/daily.md b/brain/_Templates/daily.md new file mode 100644 index 000000000..940e141d5 --- /dev/null +++ b/brain/_Templates/daily.md @@ -0,0 +1,13 @@ +--- +type: daily +created: "{{date:YYYY-MM-DD}}" +tags: [daily] +--- + +# {{date}} + +## Notes + +## Decisions + +## Next diff --git a/brain/_Templates/domain.md b/brain/_Templates/domain.md new file mode 100644 index 000000000..e6ac28aba --- /dev/null +++ b/brain/_Templates/domain.md @@ -0,0 +1,18 @@ +--- +title: "{{title}}" +type: domain +tags: [] +created: "{{date:YYYY-MM-DD}}" +updated: "{{date:YYYY-MM-DD}}" +confidence: medium +--- + +# {{title}} + +## Overview + +## Key Concepts + +## Open Questions + +## Signals to Watch diff --git a/brain/_Templates/entity.md b/brain/_Templates/entity.md new file mode 100644 index 000000000..9ffc6a376 --- /dev/null +++ b/brain/_Templates/entity.md @@ -0,0 +1,19 @@ +--- +title: "{{title}}" +type: entity +entity_type: +tags: [] +created: "{{date:YYYY-MM-DD}}" +updated: "{{date:YYYY-MM-DD}}" +confidence: medium +source_type: direct +aliases: [] +--- + +# {{title}} + +## Overview + +## Context + +## Relations diff --git a/brain/_Templates/intel.md b/brain/_Templates/intel.md new file mode 100644 index 000000000..771eeea7d --- /dev/null +++ b/brain/_Templates/intel.md @@ -0,0 +1,18 @@ +--- +title: "{{title}}" +type: intel +tags: [] +created: "{{date:YYYY-MM-DD}}" +updated: "{{date:YYYY-MM-DD}}" +confidence: medium +source: +source_type: direct +--- + +# {{title}} + +## Key Findings + +## Analysis + +## Open Questions diff --git a/brain/_Templates/playbook.md b/brain/_Templates/playbook.md new file mode 100644 index 000000000..642043465 --- /dev/null +++ b/brain/_Templates/playbook.md @@ -0,0 +1,16 @@ +--- +title: "{{title}}" +type: playbook +tags: [] +created: "{{date:YYYY-MM-DD}}" +updated: "{{date:YYYY-MM-DD}}" +confidence: high +--- + +# {{title}} + +## When to Use + +## Steps + +## Notes diff --git a/brain/_index.md b/brain/_index.md new file mode 100644 index 000000000..8b918d7ca --- /dev/null +++ b/brain/_index.md @@ -0,0 +1,16 @@ +--- +title: "genie" +type: moc +created: 2026-09-03 +updated: 2026-09-03 +tags: [moc, root] +--- + +# genie + +Welcome to the **genie** brain. This is the root Map of Content (MOC). + +## Folders + +- [[Daily]] — Daily notes and logs +- [[to_process]] — Raw content awaiting organization diff --git a/brain/brain.json b/brain/brain.json new file mode 100644 index 000000000..a8e2da704 --- /dev/null +++ b/brain/brain.json @@ -0,0 +1,29 @@ +{ + "id": "brn_w06loz", + "slug": "genie", + "name": "genie", + "type": "engineering", + "owner": { + "type": "agent", + "id": "local" + }, + "homePath": ".", + "strategy": { + "default": "rag", + "segments": [] + }, + "embeddings": { + "enabled": true, + "dims": 768 + }, + "extractor": { + "gateRules": { + "r1TemplateProtection": true, + "r2ConversasImmutable": true, + "r3PathWhitelist": true, + "r4CrossActorIdentity": true, + "r5DailyAppendOnly": true, + "actorChatIds": [] + } + } +} diff --git a/knip.json b/knip.json index e4a8267f8..64c7d3921 100644 --- a/knip.json +++ b/knip.json @@ -1,7 +1,14 @@ { "$schema": "https://unpkg.com/knip@6.29.0/schema.json", - "entry": ["skills/genie-orca-work/scripts/*.ts"], - "project": ["skills/genie-orca-work/scripts/**/*.ts", "src/**/*.ts"], + "entry": [ + "skills/genie-orca-work/scripts/*.ts", + "plugins/dsh-genie-board/src/index.ts", + "plugins/dsh-genie-board/src/client.ts", + "plugins/dsh-genie-board/build.ts", + "plugins/dsh-genie-board/src/*.test.ts", + "plugins/dsh-genie-board/build.test.ts" + ], + "project": ["skills/genie-orca-work/scripts/**/*.ts", "src/**/*.ts", "plugins/dsh-genie-board/src/**/*.ts"], "ignoreBinaries": ["ldd", "omni"], "ignoreExportsUsedInFile": true } diff --git a/package.json b/package.json index 45f2ad25e..b16966104 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@automagik/genie", - "version": "5.260901.3", - "description": "Collaborative terminal toolkit for human + AI workflows. NOTE: npm distribution discontinued 2026-05-09 — install via `curl -fsSL https://raw.githubusercontent.com/automagik-dev/genie/main/install.sh | bash` (cosign + SLSA verified). See https://automagik.dev/genie/release-process", + "version": "5.260909.1", + "description": "Collaborative terminal toolkit for human + AI workflows. NOTE: npm distribution discontinued 2026-05-09 \u2014 install via `curl -fsSL https://raw.githubusercontent.com/automagik-dev/genie/main/install.sh | bash` (cosign + SLSA verified). See https://automagik.dev/genie/release-process", "license": "MIT", "type": "module", "bin": { @@ -19,7 +19,7 @@ "lint:docs-markdown": "ls docs/incident-response/canisterworm.mdx docs/installation.mdx docs/release-notes.mdx > /dev/null && markdownlint-cli2 SECURITY.md docs/incident-response/canisterworm.mdx docs/installation.mdx docs/release-notes.mdx", "format": "biome format --write .", "test": "bun test", - "typecheck": "tsc --noEmit", + "typecheck": "tsc --noEmit && tsc --noEmit -p plugins/dsh-genie-board/tsconfig.json", "dead-code": "bunx knip", "skills:lint": "bun run scripts/skills-lint.ts", "wishes:lint": "bun run scripts/wishes-lint.ts", @@ -28,7 +28,8 @@ "lint:orca-bundle": "bun scripts/orca-bundle-parity.ts --check", "check": "bun run typecheck && bun run lint && bun run dead-code && bun run skills:lint && bun run wishes:lint && bun run lint:complexity-budget && bun run lint:orca-bundle && bun test", "check:fast": "bun run typecheck && bun run lint && bun run dead-code && bun run skills:lint && bun run wishes:lint && bun run lint:complexity-budget && bun run lint:orca-bundle", - "verify:release": "scripts/verify-release.sh" + "verify:release": "scripts/verify-release.sh", + "build:plugin": "bun run --cwd plugins/dsh-genie-board build" }, "dependencies": { "@inquirer/prompts": "7.10.1", diff --git a/plugins/dsh-genie-board/NOTICE b/plugins/dsh-genie-board/NOTICE new file mode 100644 index 000000000..fdb859cae --- /dev/null +++ b/plugins/dsh-genie-board/NOTICE @@ -0,0 +1,31 @@ +Genie DSH Board +Copyright 2026 Namastex Labs. Licensed under the repository MIT license. + +Original plugin implementation for the public Cordis lifecycle, DSH workspace +registry and DSH WebServer interfaces. DSH is developed by DeepSeek AI and is +not part of this package. No implementation from the separate dsh-genie project +is included. The Host bundle includes Zod (MIT), copyright Colin McDonnell. + +Zod 3.25.76 license text: + +MIT License + +Copyright (c) 2025 Colin McDonnell + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/plugins/dsh-genie-board/README.md b/plugins/dsh-genie-board/README.md new file mode 100644 index 000000000..4f9e98bbf --- /dev/null +++ b/plugins/dsh-genie-board/README.md @@ -0,0 +1,98 @@ +# Genie board for DSH Web + +Open **Genie board** from the button in DSH Web. Choose a registered repository +workspace and board to view its lanes, cards, owners, activity, blocks, dependencies, +comments, and history. Select a card to move, comment, block/hold, unblock, claim, +release, or complete it. Create adds a task to the selected board. Refresh and +returning to the visible tab read a complete board again; there is no polling. + +## Build and install + +Requires Bun (checkout builds), a Host-installed Genie CLI, and DSH +**0.1.2-rc.1 or newer**. This floor was exercised with the actual installed Host. + +```sh +bun run build:plugin +dsh plugin --profile web add link:/absolute/path/to/genie/plugins/dsh-genie-board +dsh plugin --profile web list --depth 0 +# Restart DSH after installation. +dsh web --no-open --host 127.0.0.1 --port 0 +``` + +The immutable artifacts are `dist/index.js` (Host) and `dist/client.js` (browser). +Source builds use the root checkout version as their compatibility floor. +Release packaging stamps `minimumGenieVersion` and package version together and +rebuilds the Host with that exact candidate without changing checkout metadata. Startup +checks strict SemVer using the fixed executable's version; incompatible or invalid +versions expose health only, with no board operation routes. + +## Trust and consistency + +The Host resolves Genie once from its installation PATH, canonicalizes the +executable, and runs only fixed argv with `shell: false`. Its environment is limited +to PATH, HOME, GENIE_HOME, NO_COLOR, GENIE_AGENT_NAME and GENIE_AGENT_KIND. The Host +identity is derived from its OS user and hostname. Browser requests contain only +registry IDs, selected board/task IDs and validated action fields. They never +supply paths, worker identity, executables, environment or commands. Positional +comment arguments use the standard `--` boundary so option-shaped text is literal. All routes +first apply DSH connection authentication (its signed browser-session cookie), then +require loopback and same-origin browser signals; mutations require exact Origin +and application/json. Remote/reverse-proxy operation is intentionally unsupported. + +The Host retains bounded **selection evidence**, not board content: registry ID, +canonical repository path, listed board IDs, and the selected board's task IDs and +lane names. Explicit list then load establishes this evidence. One request per +workspace may run at a time; concurrent selection/mutation requests reject. Every +list/load still invokes Genie once, and mutations invoke the fixed action then one +complete aggregate read. No previous aggregate is served or optimistic change +rendered. Selection errors, failed operations, workspace removal and path changes +invalidate evidence. A mutation followed by a failed refresh reports that the +operation may have completed and requires reload; never automatically retry it. +The selection authorizes the last confirmed board; direct external database edits +or task imports racing a request are outside this snapshot guarantee. + +CLI work has a shared 10-second deadline and 4 MiB combined stdout/stderr budget. +The active child is killed on timeout/output overflow/error. Closed schemas reject +incomplete or foreign aggregates. Request bodies are limited to 16 KiB. Laneless +legacy boards do not expose the required complete aggregate and fail explicitly. +The plugin has no database access, persistence, filesystem watcher, task poller, +or autonomous agent runner. + +DSH's installed sidebar package exposes no extension slot. The original client +uses its public apply/effect lifecycle to mount an accessible modal board button +without patching DSH sources or observing its DOM. + +## Validation + +```sh +bun run check +bun run build:plugin +bun test plugins/dsh-genie-board +bun scripts/dsh-genie-board-smoke.ts +``` + +The smoke installs into a disposable DSH_HOME, registers a disposable repository +through the real workspace registry, starts/stops/restarts DSH, verifies plugin +listing and compatibility, creates and moves a task through Host routes, then +removes the plugin and temporary state in `finally`. Personal profiles are not used. + +## Release verification + +The release payload includes this document, NOTICE, both Cordis manifests, +package metadata and both Host/browser bundles. The repository verifier requires +all seven members independently on all four supported platforms: + +```sh +bun scripts/verify-dsh-genie-board-release.ts --unsigned-artifact-dir dist --version VERSION +bun scripts/verify-dsh-genie-board-release.ts --signed-artifact-dir dist --version VERSION --channel stable +bun scripts/verify-dsh-genie-board-release.ts --release vVERSION --channel stable +``` + +Unsigned mode proves packaging only. Signed modes require `cosign`, +`slsa-verifier`, and `gh` in PATH. They verify descriptor digests, cosign identity, +SLSA provenance, and the signed delivery endorsement binding the descriptor to +its candidate/source identity for every artifact. Release mode +reads back the published tag/source binding. Stable publication still requires +the protected production approval in the existing Release workflow. Only after +that publication and the release-mode proof may a maintainer add the `dsh-plugin` +repository topic and read it back. No local build authorizes that action. diff --git a/plugins/dsh-genie-board/agent.cordis.yml b/plugins/dsh-genie-board/agent.cordis.yml new file mode 100644 index 000000000..bb351ac05 --- /dev/null +++ b/plugins/dsh-genie-board/agent.cordis.yml @@ -0,0 +1,2 @@ +- id: genie-dsh-board + name: '@automagik/genie-dsh-board' diff --git a/plugins/dsh-genie-board/build.test.ts b/plugins/dsh-genie-board/build.test.ts new file mode 100644 index 000000000..70f9675a2 --- /dev/null +++ b/plugins/dsh-genie-board/build.test.ts @@ -0,0 +1,97 @@ +import { expect, test } from 'bun:test'; +import { mkdtemp, readFile, readdir, rm, stat } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { runInNewContext } from 'node:vm'; + +test('valid repeated builds regenerate identical Host and lazy browser bundles', async () => { + const root = import.meta.dir; + const output = await mkdtemp(join(tmpdir(), 'genie-repeat-host-')); + const version = JSON.parse(await readFile(join(root, '../../package.json'), 'utf8')).version; + let previous: string[] | undefined; + try { + for (let attempt = 0; attempt < 2; attempt++) { + const proc = Bun.spawn(['bun', 'run', 'build', version, output], { + cwd: root, + stdout: 'pipe', + stderr: 'pipe', + }); + const [code, stderr] = await Promise.all([proc.exited, new Response(proc.stderr).text()]); + expect(code).toBe(0); + expect(stderr).not.toContain('error:'); + const host = join(output, 'index.js'); + const client = join(output, 'client.js'); + expect((await stat(host)).size).toBeGreaterThan(1000); + expect((await import(`${host}?attempt=${attempt}`)).minimumGenieVersion).toBe(version); + const bytes = [await readFile(host, 'utf8'), await readFile(client, 'utf8')]; + if (previous) expect(bytes).toEqual(previous); + previous = bytes; + let registration: { id: string; factory: (require: unknown) => { apply: unknown } } | undefined; + runInNewContext(bytes[1], { + window: { + __ModuleLoader__: { + load(value: typeof registration) { + registration = value; + }, + }, + }, + }); + expect(registration?.id).toBe('@automagik/genie-dsh-board'); + // No DOM is needed until Cordis activates the factory's apply method. + expect( + typeof registration?.factory(() => { + throw new Error('Unexpected browser dependency'); + }).apply, + ).toBe('function'); + } + } finally { + await rm(output, { recursive: true, force: true }); + } +}, 20_000); + +test('invalid build version exits with an error and writes no bundle', async () => { + const output = await mkdtemp(join(tmpdir(), 'genie-invalid-host-')); + try { + const proc = Bun.spawn(['bun', 'run', 'build', 'invalid version', output], { + cwd: import.meta.dir, + stdout: 'pipe', + stderr: 'pipe', + }); + const [code, stdout, stderr] = await Promise.all([ + proc.exited, + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + ]); + expect(code).toBe(1); + expect(stdout).toBe(''); + expect(stderr).toContain('invalid build version'); + expect(await readdir(output)).toEqual([]); + } finally { + await rm(output, { recursive: true, force: true }); + } +}, 20_000); + +test('candidate build embeds override floor without changing source metadata', async () => { + const root = import.meta.dir; + const output = await mkdtemp(join(tmpdir(), 'genie-candidate-host-')); + const before = await readFile(join(root, 'package.json'), 'utf8'); + const sourceVersion = JSON.parse(await readFile(join(root, '../../package.json'), 'utf8')).version; + const candidate = `${sourceVersion}-candidate-proof`; + try { + const process = Bun.spawn(['bun', 'run', 'build', candidate, output], { + cwd: root, + stdout: 'pipe', + stderr: 'pipe', + }); + const [code, stderr] = await Promise.all([process.exited, new Response(process.stderr).text()]); + expect(code).toBe(0); + expect(stderr).not.toContain('error:'); + const host = await import(join(output, 'index.js')); + expect(host.minimumGenieVersion).toBe(candidate); + expect(await readFile(join(root, 'package.json'), 'utf8')).toBe(before); + const source = await import('./src/index'); + expect(source.minimumGenieVersion).toBe(sourceVersion); + } finally { + await rm(output, { recursive: true, force: true }); + } +}, 20_000); diff --git a/plugins/dsh-genie-board/build.ts b/plugins/dsh-genie-board/build.ts new file mode 100644 index 000000000..2c84ff55a --- /dev/null +++ b/plugins/dsh-genie-board/build.ts @@ -0,0 +1,27 @@ +import { resolve } from 'node:path'; +import { build } from 'esbuild'; +import sourcePackage from '../../package.json'; +const [version = sourcePackage.version, output = 'dist', ...extra] = process.argv.slice(2); +if (extra.length || !/^[0-9A-Za-z][0-9A-Za-z.+-]{0,127}$/.test(version)) throw new Error('invalid build version'); +const outdir = resolve(output); +await build({ + entryPoints: ['src/index.ts'], + bundle: true, + platform: 'node', + format: 'esm', + target: 'node22', + outfile: resolve(outdir, 'index.js'), + define: { __GENIE_BUILD_VERSION__: JSON.stringify(version) }, +}); +await build({ + entryPoints: ['src/client.ts'], + bundle: true, + platform: 'browser', + format: 'cjs', + banner: { + js: 'window.__ModuleLoader__.load({ id: "@automagik/genie-dsh-board", factory: (require) => { const module = { exports: {} }; const exports = module.exports;', + }, + footer: { js: 'return module.exports; } });' }, + target: 'es2022', + outfile: resolve(outdir, 'client.js'), +}); diff --git a/plugins/dsh-genie-board/cordis.patch.yml b/plugins/dsh-genie-board/cordis.patch.yml new file mode 100644 index 000000000..85488f8e4 --- /dev/null +++ b/plugins/dsh-genie-board/cordis.patch.yml @@ -0,0 +1,3 @@ +- insert: + - id: genie-dsh-board + name: '@automagik/genie-dsh-board' diff --git a/plugins/dsh-genie-board/package.json b/plugins/dsh-genie-board/package.json new file mode 100644 index 000000000..ca86659e6 --- /dev/null +++ b/plugins/dsh-genie-board/package.json @@ -0,0 +1,16 @@ +{ + "name": "@automagik/genie-dsh-board", + "version": "5.260901.3", + "minimumGenieVersion": "5.260901.3", + "license": "MIT", + "type": "module", + "main": "dist/index.js", + "exports": { ".": "./dist/index.js", "./client": "./dist/client.js", "./package.json": "./package.json" }, + "files": ["dist", "agent.cordis.yml", "cordis.patch.yml", "README.md", "NOTICE"], + "scripts": { "build": "bun run build.ts", "typecheck": "tsc --noEmit -p tsconfig.json" }, + "dsh": { + "engines": { "dsh": ">=0.1.2-rc.1" }, + "bundle": { "patch": "./cordis.patch.yml" }, + "client": { "platform": "web" } + } +} diff --git a/plugins/dsh-genie-board/src/board.test.ts b/plugins/dsh-genie-board/src/board.test.ts new file mode 100644 index 000000000..3528d74dd --- /dev/null +++ b/plugins/dsh-genie-board/src/board.test.ts @@ -0,0 +1,442 @@ +import { afterEach, describe, expect, test } from 'bun:test'; +import { mkdir, mkdtemp, rm } from 'node:fs/promises'; +import type { IncomingMessage } from 'node:http'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { minimumGenieVersion, trusted } from './index'; +import { MAX_OUTPUT, compatible, execute, hostEnvironment } from './process'; +import { aggregateSchema, requestSchema } from './schema'; +import { BoardService, actionArgs } from './service'; + +const directories: string[] = []; +afterEach(async () => { + for (const directory of directories.splice(0)) await rm(directory, { recursive: true, force: true }); +}); +const card = { + id: 't_abc', + boardId: 'b_abc', + title: 'Task', + status: 'ready', + claimedBy: null, + claimedAt: null, + wish: null, + group: null, + assignedAgent: null, + assignedReason: null, + createdAt: 1, + updatedAt: 1, + lane: 'Ready', + enforcedBlock: null, + agentKind: null, + heartbeatAt: null, + blockedBy: null, + blockedReason: null, + liveness: null, + dependencies: [], + timeline: [], + comments: [], +}; +const aggregate = { + schemaVersion: 1, + scope: 'Board', + lanes: [ + { name: 'Ready', label: null, action: null, cards: [card] }, + { name: 'Done', label: null, action: null, cards: [] }, + ], +}; +const boards = [{ id: 'b_abc', name: 'Board', laneCount: 2, cardCount: 1 }]; +const selection = { workspaceId: 'workspace', boardRef: 'b_abc' }; +async function fixture() { + const path = await mkdtemp(join(tmpdir(), 'genie-plugin-test-')); + directories.push(path); + await mkdir(join(path, '.git')); + await mkdir(join(path, '.genie')); + const workspaces = [{ id: 'workspace', path, title: 'Workspace' }]; + const calls: string[][] = []; + let output: unknown = aggregate; + let listed = boards; + let failure = false; + const service = new BoardService({ list: () => workspaces }, '/fixed/genie', async (_binary, argv, cwd, env) => { + expect(cwd).toBe(path); + expect(env.GENIE_AGENT_KIND).toBe('dsh'); + calls.push(argv); + if (failure) throw new Error('failure'); + return JSON.stringify(argv[0] === 'board' && argv[1] === 'list' ? listed : output); + }); + const list = () => service.request({ action: 'list', workspaceId: 'workspace' }); + const load = () => service.request({ action: 'load', ...selection }); + return { + service, + calls, + workspaces, + list, + load, + setBoards(value: typeof boards) { + listed = value; + }, + setOutput(value: unknown) { + output = value; + }, + fail() { + failure = true; + }, + }; +} +describe('closed inputs and fixed argv', () => { + test('all mutation argv are fixed; title shell syntax remains a single argument', () => { + expect( + actionArgs( + requestSchema.parse({ action: 'create', ...selection, title: '$(touch /tmp/bad); hi' }) as never, + 'host', + ), + ).toEqual(['task', 'create', '--title', '$(touch /tmp/bad); hi', '--board', 'b_abc']); + const vectors = [ + ['move', { lane: 'Done' }, ['task', 'move', 't_abc', '--to', 'Done']], + ['comment', { text: 'hello' }, ['task', 'comment', '--', 't_abc', 'hello']], + ['block', { text: 'reason', hold: true }, ['task', 'block', 't_abc', '--reason', 'reason', '--hold']], + ['checkout', {}, ['task', 'checkout', 't_abc', '--worker', 'host']], + ...['unblock', 'release', 'done'].map((action) => [action, {}, ['task', action, 't_abc']]), + ]; + for (const [action, extra, argv] of vectors) + expect( + actionArgs(requestSchema.parse({ action, ...selection, id: 't_abc', ...(extra as object) }) as never, 'host'), + ).toEqual(argv as string[]); + }); + test('rejects injected authority, identifiers, hold types and controls', () => { + const input = { action: 'block', ...selection, id: 't_abc', text: 'reason' }; + for (const key of ['worker', 'path', 'executable', 'environment', 'command', 'argv', 'unknown']) + expect(requestSchema.safeParse({ ...input, [key]: 'evil' }).success).toBe(false); + for (const id of ['--help', 't_abc;evil', '../t_abc', 't_ABC', 't_abc\0']) + expect(requestSchema.safeParse({ ...input, id }).success).toBe(false); + for (const hold of ['true', 1, null]) expect(requestSchema.safeParse({ ...input, hold }).success).toBe(false); + for (const text of ['', ' ', 'x\0x', 'x\nx', 'x\tx', '\ntitle', 'title\n']) + expect(requestSchema.safeParse({ ...input, text }).success).toBe(false); + }); + test.each([ + ['C1 next line', '\u0085'], + ['C1 control sequence introducer', '\u009b'], + ['bidi override', '\u202e'], + ['bidi isolate', '\u2066'], + ['zero width space', '\u200b'], + ['byte order mark', '\ufeff'], + ['line separator', '\u2028'], + ['paragraph separator', '\u2029'], + ['supplementary format control', '\u{e0001}'], + ])('rejects %s at every text boundary before any CLI call', async (_name, control) => { + const f = await fixture(); + await f.list(); + await f.load(); + const before = f.calls.length; + for (const value of [`${control}text`, `te${control}xt`, `text${control}`]) { + for (const input of [ + { action: 'create', ...selection, title: value }, + { action: 'comment', ...selection, id: 't_abc', text: value }, + { action: 'block', ...selection, id: 't_abc', text: value }, + ]) { + await expect(f.service.request(input)).rejects.toThrow('Control characters are not allowed'); + expect(f.calls.length).toBe(before); + } + } + }); + test('ordinary Unicode text is trimmed and forwarded unchanged for all three actions', async () => { + const f = await fixture(); + await f.list(); + await f.load(); + const value = 'café 漢字 🙂 e\u0301'; + const padded = ` ${value} `; + const vectors = [ + [{ action: 'create', title: padded }, ['task', 'create', '--title', value, '--board', 'b_abc']], + [{ action: 'comment', id: 't_abc', text: padded }, ['task', 'comment', '--', 't_abc', value]], + [{ action: 'block', id: 't_abc', text: padded }, ['task', 'block', 't_abc', '--reason', value]], + ] as const; + for (const [input, argv] of vectors) { + const before = f.calls.length; + await f.service.request({ ...selection, ...input }); + expect(f.calls.slice(before)).toEqual([[...argv], ['board', '--board', 'b_abc', '--json']]); + } + }); + test('byte bounds for title/comment/reason', () => { + for (const [action, field, limit] of [ + ['create', 'title', 200], + ['comment', 'text', 4000], + ['block', 'text', 1000], + ] as const) { + const base = { action, ...selection, ...(action === 'create' ? {} : { id: 't_abc' }) }; + expect(requestSchema.safeParse({ ...base, [field]: 'é'.repeat(limit / 2) }).success).toBe(true); + expect(requestSchema.safeParse({ ...base, [field]: `${'é'.repeat(limit / 2)}x` }).success).toBe(false); + } + }); +}); +test('complete closed aggregate rejects missing detail, unknown keys, duplicate cards/lanes', () => { + expect(aggregateSchema.safeParse(aggregate).success).toBe(true); + for (const field of Object.keys(card)) { + const changed = { ...card }; + delete changed[field as keyof typeof changed]; + expect( + aggregateSchema.safeParse({ ...aggregate, lanes: [{ ...aggregate.lanes[0], cards: [changed] }] }).success, + ).toBe(false); + } + expect(aggregateSchema.safeParse({ ...aggregate, extra: true }).success).toBe(false); + expect(aggregateSchema.safeParse({ ...aggregate, lanes: [aggregate.lanes[0], aggregate.lanes[0]] }).success).toBe( + false, + ); +}); +test('list and load each use one process; all mutations use exactly mutation+aggregate', async () => { + const f = await fixture(); + await f.list(); + expect(f.calls.length).toBe(1); + await f.load(); + expect(f.calls.length).toBe(2); + for (const action of ['checkout', 'release', 'unblock', 'done']) { + const before = f.calls.length; + await f.service.request({ action, ...selection, id: 't_abc' }); + expect(f.calls.length - before).toBe(2); + expect(f.calls.at(-1)).toEqual(['board', '--board', 'b_abc', '--json']); + } +}); +test('foreign workspace, board, task and lane never execute', async () => { + for (const extra of [ + { workspaceId: 'foreign' }, + { boardRef: 'b_foreign' }, + { id: 't_foreign' }, + { lane: 'Foreign' }, + ]) { + const f = await fixture(); + await f.list(); + await f.load(); + const before = f.calls.length; + await expect( + f.service.request({ action: 'move', ...selection, id: 't_abc', lane: 'Done', ...extra }), + ).rejects.toThrow(); + expect(f.calls.length).toBe(before); + } +}); +test('failed mutation refresh invalidates selection and reports possible completion', async () => { + const f = await fixture(); + await f.list(); + await f.load(); + f.setOutput({}); + await expect(f.service.request({ action: 'done', ...selection, id: 't_abc' })).rejects.toThrow('may have completed'); + const before = f.calls.length; + await expect(f.service.request({ action: 'done', ...selection, id: 't_abc' })).rejects.toThrow(); + expect(f.calls.length).toBe(before); +}); +test('registry removal and path rebinding invalidate evidence', async () => { + const f = await fixture(); + await f.list(); + await f.load(); + f.workspaces[0].path = '/nonexistent'; + await expect(f.load()).rejects.toThrow(); + expect(f.calls.length).toBe(2); + f.workspaces.splice(0); + await expect(f.list()).rejects.toThrow('Unknown workspace'); +}); +test('overlapping load and mutation are rejected without spawning', async () => { + const f = await fixture(); + await f.list(); + await f.load(); + const load = f.load(); + await expect(f.service.request({ action: 'done', ...selection, id: 't_abc' })).rejects.toThrow('in progress'); + await load; + expect(f.calls.length).toBe(3); +}); +test('loopback and exact same-origin mutation fence', () => { + const req = { + method: 'POST', + socket: { remoteAddress: '127.0.0.1' }, + headers: { host: '127.0.0.1:1234', origin: 'http://127.0.0.1:1234' }, + }; + expect(trusted(req as IncomingMessage)).toBe(true); + for (const origin of [undefined, 'null', 'https://evil.test', 'http://127.0.0.1:1235']) + expect(trusted({ ...req, headers: { ...req.headers, origin } } as IncomingMessage)).toBe(false); + expect(trusted({ ...req, socket: { remoteAddress: '10.0.0.1' } } as IncomingMessage)).toBe(false); +}); +test('semver strict ordering includes prereleases and rejects malformed versions', () => { + for (const [actual, minimum, result] of [ + ['5.260901.3', '5.260901.3', true], + ['5.260901.2', '5.260901.3', false], + ['5.260901.4', '5.260901.3', true], + ['1.0.0-rc.2', '1.0.0-rc.1', true], + ['1.0.0-rc.2', '1.0.0', false], + ['1.0.0', '1.0.0-rc.2', true], + ['1.0.0-01', '1.0.0', false], + ['v1.0.0', '1.0.0', false], + ['01.0.0', '1.0.0', false], + ] as const) + expect(compatible(actual, minimum)).toBe(result); +}); +test('process environment is an exact allowlist', () => { + const env = hostEnvironment('host'); + expect(env.GENIE_AGENT_NAME).toBe('host'); + expect(env.GENIE_AGENT_KIND).toBe('dsh'); + expect(env.NO_COLOR).toBe('1'); + expect( + Object.keys(env).every((key) => + ['PATH', 'HOME', 'GENIE_HOME', 'NO_COLOR', 'GENIE_AGENT_NAME', 'GENIE_AGENT_KIND'].includes(key), + ), + ).toBe(true); +}); +test('aggregate deadline and output budgets kill child or reject before spawn', async () => { + await expect(execute('/missing', [], '.', {}, { expires: 0, bytes: 0 })).rejects.toThrow('deadline'); + // Node treats the mandatory Genie flag as invalid; a tiny executable fixture consumes it. + const path = await mkdtemp(join(tmpdir(), 'genie-process-test-')); + directories.push(path); + const { writeFile } = await import('node:fs/promises'); + const file = join(path, 'genie'); + await writeFile(file, '#!/bin/sh\nprintf 1234567890\n', { mode: 0o755 }); + await expect(execute(file, [], path, {}, { expires: Date.now() + 1000, bytes: MAX_OUTPUT - 5 })).rejects.toThrow( + 'output limit', + ); + await writeFile(file, '#!/bin/sh\nexec sleep 5\n', { mode: 0o755 }); + await expect( + execute(file, [], path, { PATH: process.env.PATH }, { expires: Date.now() + 20, bytes: 0 }), + ).rejects.toThrow('deadline'); + await expect(execute('/missing', [], path, {}, { expires: Date.now() + 1000, bytes: 0 })).rejects.toThrow(); +}); + +test('all action service vectors refresh once, including option-shaped comments', async () => { + const f = await fixture(); + await f.list(); + await f.load(); + const requests = [ + { action: 'create', title: 'New task' }, + { action: 'move', id: 't_abc', lane: 'Done' }, + { action: 'comment', id: 't_abc', text: '--help' }, + { action: 'block', id: 't_abc', text: 'reason', hold: true }, + ...['unblock', 'checkout', 'release', 'done'].map((action) => ({ action, id: 't_abc' })), + ]; + for (const input of requests) { + const before = f.calls.length; + await f.service.request({ ...selection, ...input }); + expect(f.calls.slice(before)).toEqual([ + actionArgs(requestSchema.parse({ ...selection, ...input }) as never, f.service.identity), + ['board', '--board', 'b_abc', '--json'], + ]); + } +}); +test('valid canonical directory rebinding rejects and concurrent selection switches cannot race', async () => { + const f = await fixture(); + const second = await fixture(); + await f.list(); + await f.load(); + f.workspaces[0].path = second.workspaces[0].path; + await expect(f.load()).rejects.toThrow('Workspace changed'); + expect(f.calls.length).toBe(2); + const g = await fixture(); + await g.list(); + const pending = g.load(); + await expect(g.list()).rejects.toThrow('in progress'); + await pending; + expect(g.calls.length).toBe(2); +}); +test('competing distinct board loads preserve only the winner task membership', async () => { + const f = await fixture(); + f.setBoards([...boards, { ...boards[0], id: 'b_def' }]); + await f.list(); + await f.load(); + f.setOutput({ + ...aggregate, + lanes: [{ ...aggregate.lanes[0], cards: [{ ...card, id: 't_def', boardId: 'b_def' }] }], + }); + const pending = f.service.request({ action: 'load', workspaceId: 'workspace', boardRef: 'b_def' }); + await expect(f.load()).rejects.toThrow('in progress'); + await pending; + await f.service.request({ action: 'done', workspaceId: 'workspace', boardRef: 'b_def', id: 't_def' }); + const before = f.calls.length; + await expect( + f.service.request({ action: 'done', workspaceId: 'workspace', boardRef: 'b_def', id: 't_abc' }), + ).rejects.toThrow('outside'); + expect(f.calls.length).toBe(before); +}); +test('stdout plus stderr share one budget across sequential processes', async () => { + const path = await mkdtemp(join(tmpdir(), 'genie-budget-test-')); + directories.push(path); + const { writeFile } = await import('node:fs/promises'); + const binary = join(path, 'genie'); + await writeFile(binary, '#!/bin/sh\nprintf 12345\nprintf 12345 >&2\n', { mode: 0o755 }); + const budget = { expires: Date.now() + 1000, bytes: MAX_OUTPUT - 15 }; + expect(await execute(binary, [], path, {}, budget)).toBe('12345'); + expect(budget.bytes).toBe(MAX_OUTPUT - 5); + await expect(execute(binary, [], path, {}, budget)).rejects.toThrow('output limit'); +}); +test('Host routes apply DSH authentication and Origin/content-type fences before reading workspaces or spawning', async () => { + const { apply } = await import('./index'); + const { createServer } = await import('node:http'); + const { writeFile, readFile } = await import('node:fs/promises'); + const path = await mkdtemp(join(tmpdir(), 'genie-auth-test-')); + directories.push(path); + const calls = join(path, 'calls'); + await writeFile(join(path, 'genie'), `#!/bin/sh\nprintf x >> '${calls}'\nprintf '${minimumGenieVersion}\\n'\n`, { + mode: 0o755, + }); + const routes = new Map<string, (req: IncomingMessage, res: import('node:http').ServerResponse) => Promise<void>>(); + let registryReads = 0; + const oldPath = process.env.PATH; + try { + process.env.PATH = path; + await apply({ + workspaceRegistry: { + list() { + registryReads++; + return []; + }, + }, + connection: { + requestRejection(req) { + return req.headers.cookie === 'session=valid' ? undefined : 401; + }, + }, + webServer: { + register(route) { + routes.set(route.path, route.handler); + return () => routes.delete(route.path); + }, + }, + effect(effect) { + effect(); + }, + }); + } finally { + process.env.PATH = oldPath; + } + const server = createServer((req, res) => { + const handler = routes.get(req.url ?? ''); + if (handler) void handler(req, res); + else { + res.writeHead(404); + res.end(); + } + }); + await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve)); + const address = server.address(); + if (!address || typeof address === 'string') throw new Error('No address'); + const origin = `http://127.0.0.1:${address.port}`; + try { + for (const path of ['health', 'workspaces', 'action']) { + for (const cookie of ['', 'session=invalid']) { + const response = await fetch(`${origin}/api/genie-board/${path}`, { + method: path === 'action' ? 'POST' : 'GET', + headers: { origin, cookie, 'content-type': 'application/json' }, + ...(path === 'action' ? { body: '{}' } : {}), + }); + expect(response.status).toBe(401); + } + } + for (const [headers, code] of [ + [{ origin: 'https://evil.test', 'content-type': 'application/json' }, 403], + [{ 'content-type': 'application/json' }, 403], + [{ origin, 'content-type': 'text/plain' }, 415], + ] as const) { + const response = await fetch(`${origin}/api/genie-board/action`, { + method: 'POST', + headers: { cookie: 'session=valid', ...headers }, + body: '{}', + }); + expect(response.status).toBe(code); + } + expect(registryReads).toBe(0); + expect(await readFile(calls, 'utf8')).toBe('x'); + } finally { + await new Promise<void>((resolve, reject) => server.close((error) => (error ? reject(error) : resolve()))); + } +}); diff --git a/plugins/dsh-genie-board/src/client.ts b/plugins/dsh-genie-board/src/client.ts new file mode 100644 index 000000000..33f80aa16 --- /dev/null +++ b/plugins/dsh-genie-board/src/client.ts @@ -0,0 +1,277 @@ +import type { Aggregate } from './schema'; + +interface ClientContext { + effect(effect: () => () => void, label?: string): void; +} +const css = ` +.genie-launch{position:fixed;right:20px;bottom:20px;z-index:9000;padding:12px 18px;background:#185a4e;color:white;border:1px solid #398774;border-radius:8px;font:600 15px system-ui;cursor:pointer} +.genie-board{box-sizing:border-box;width:min(1400px,96vw);height:90vh;padding:0;border:1px solid #687a74;border-radius:10px;color:#1d2925;background:#f5f7f6;font:15px system-ui}.genie-board::backdrop{background:#10251cb0}.genie-board *{box-sizing:border-box}.genie-board header{padding:20px 24px;border-bottom:1px solid #cdd8d2;display:flex;align-items:center;gap:14px;flex-wrap:wrap}.genie-board h1{font-size:22px;margin:0 auto 0 0}.genie-board h2{font-size:16px;margin:0 0 16px}.genie-board button,.genie-board select,.genie-board input,.genie-board textarea{font:inherit;border:1px solid #9bafa4;border-radius:5px;padding:8px;background:white;color:inherit}.genie-board button{cursor:pointer}.genie-board button:hover{background:#e4eee8}.genie-board :focus-visible{outline:3px solid #347a69;outline-offset:2px}.genie-board button:disabled{opacity:.55;cursor:wait}.genie-board label{display:flex;gap:6px;align-items:center}.genie-status{min-height:24px;padding:12px 24px}.genie-status[role=alert]{color:#9d2525}.genie-content{display:flex;min-height:60vh;overflow:auto}.genie-lanes{display:flex;gap:16px;padding:0 24px 24px;flex:1;overflow:auto;align-items:flex-start}.genie-lane{flex:1;min-width:220px}.genie-card{display:block;text-align:left;width:100%;margin:0 0 10px;border-left:3px solid #34816b!important;padding:14px!important}.genie-card small{display:block;color:#4c6258;margin-top:8px}.genie-detail{width:360px;flex-shrink:0;padding:0 24px 24px;border-left:1px solid #cdd8d2;overflow:auto}.genie-detail p{white-space:pre-wrap;overflow-wrap:anywhere}.genie-detail h3{font-size:15px;margin:22px 0 8px}.genie-detail textarea{width:100%;min-height:80px}.genie-actions{display:flex;gap:7px;flex-wrap:wrap;margin:10px 0}.genie-create{display:flex;gap:8px;padding:0 24px 20px}.genie-create input{flex:1;min-width:0}.genie-empty{color:#52665b;line-height:1.6}.genie-detail ol{padding-left:20px}.genie-detail li{margin-bottom:12px;overflow-wrap:anywhere}@media(max-width:720px){.genie-board{width:100vw;height:100dvh;max-height:none;border-radius:0}.genie-content{display:block}.genie-detail{width:100%;border-left:0;border-top:1px solid #cdd8d2;padding-top:20px}.genie-board header{padding:16px}.genie-board label{width:100%}.genie-board select{flex:1;min-width:0}.genie-lanes{padding-left:16px}.genie-lane{min-width:230px}.genie-launch{bottom:12px;right:12px}} +`; +function element<K extends keyof HTMLElementTagNameMap>(tag: K, text?: string): HTMLElementTagNameMap[K] { + const node = document.createElement(tag); + if (text !== undefined) node.textContent = text; + return node; +} +async function api(path: string, body?: unknown): Promise<unknown> { + const response = await fetch( + `/api/genie-board/${path}`, + body === undefined + ? {} + : { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) }, + ); + const value = await response.json(); + if (!response.ok) throw new Error(value.error ?? 'Request failed'); + return value; +} +export function apply(ctx: ClientContext): void { + ctx.effect(() => { + const style = element('style', css); + const launch = element('button', 'Genie board'); + launch.className = 'genie-launch'; + const dialog = element('dialog'); + dialog.className = 'genie-board'; + dialog.setAttribute('aria-label', 'Genie board'); + const header = element('header'); + header.append(element('h1', 'Genie board')); + const workspace = element('select'); + workspace.setAttribute('aria-label', 'Workspace'); + const board = element('select'); + board.setAttribute('aria-label', 'Board'); + for (const [name, control] of [ + ['Workspace', workspace], + ['Board', board], + ] as const) { + const label = element('label', name); + label.append(control); + header.append(label); + } + const refresh = element('button', 'Refresh'); + const close = element('button', 'Close'); + header.append(refresh, close); + const status = element('div'); + status.className = 'genie-status'; + status.setAttribute('role', 'status'); + status.setAttribute('aria-live', 'polite'); + const create = element('form'); + create.className = 'genie-create'; + const title = element('input'); + title.placeholder = 'New task title'; + title.setAttribute('aria-label', 'New task title'); + title.required = true; + const add = element('button', 'Create task'); + create.append(title, add); + const content = element('div'); + content.className = 'genie-content'; + const lanes = element('div'); + lanes.className = 'genie-lanes'; + const detail = element('aside'); + detail.className = 'genie-detail'; + content.append(lanes, detail); + dialog.append(header, status, create, content); + document.head.append(style); + document.body.append(launch, dialog); + let snapshot: Aggregate | undefined; + let selected: string | undefined; + let busy = false; + const showStatus = (message: string, error = false) => { + status.textContent = message; + status.setAttribute('role', error ? 'alert' : 'status'); + }; + const run = async (operation: () => Promise<void>) => { + if (busy) return; + busy = true; + for (const control of dialog.querySelectorAll<HTMLButtonElement | HTMLSelectElement>('button,select')) + control.disabled = true; + showStatus('Loading…'); + try { + await operation(); + showStatus('Board confirmed by Genie'); + } catch (error) { + snapshot = undefined; + lanes.replaceChildren(); + detail.replaceChildren(); + showStatus(error instanceof Error ? error.message : 'Request failed', true); + } finally { + busy = false; + for (const control of dialog.querySelectorAll<HTMLButtonElement | HTMLSelectElement>('button,select')) + control.disabled = false; + } + }; + const request = (action: string, extra = {}) => + api('action', { + action, + workspaceId: workspace.value, + ...(action === 'list' ? {} : { boardRef: board.value }), + ...extra, + }); + const mutate = (action: string, extra = {}) => + run(async () => { + snapshot = (await request(action, extra)) as Aggregate; + render(); + }); + const render = () => { + lanes.replaceChildren(); + detail.replaceChildren(); + for (const lane of snapshot?.lanes ?? []) { + const column = element('section'); + column.className = 'genie-lane'; + column.append(element('h2', `${lane.label ?? lane.name} · ${lane.cards.length}`)); + if (!lane.cards.length) { + const empty = element('p', 'No tasks'); + empty.className = 'genie-empty'; + column.append(empty); + } + for (const card of lane.cards) { + const button = element('button', card.title); + button.className = 'genie-card'; + button.append( + element( + 'small', + [ + card.status, + card.claimedBy, + card.liveness, + card.enforcedBlock ? `Blocked: ${card.enforcedBlock.reason}` : null, + ] + .filter(Boolean) + .join(' · '), + ), + ); + button.onclick = () => { + selected = card.id; + render(); + }; + column.append(button); + } + lanes.append(column); + } + const card = snapshot?.lanes.flatMap((lane) => lane.cards).find((entry) => entry.id === selected); + if (!card) { + detail.append(element('p', 'Select a task to see its details and history.')); + return; + } + detail.append( + element('h2', card.title), + element('small', card.id), + element('p', `${card.status} · ${card.liveness ?? 'Unclaimed'}`), + ); + if (card.claimedBy) detail.append(element('p', `Owner: ${card.claimedBy} (${card.agentKind ?? 'unknown'})`)); + if (card.assignedAgent) + detail.append(element('p', `Assigned: ${card.assignedAgent} — ${card.assignedReason ?? ''}`)); + if (card.enforcedBlock) + detail.append( + element('p', `${card.enforcedBlock.kind === 'hold' ? 'On hold' : 'Blocked'}: ${card.enforcedBlock.reason}`), + ); + const destination = element('select'); + destination.setAttribute('aria-label', 'Destination lane'); + for (const lane of snapshot?.lanes ?? []) { + const option = element('option', lane.label ?? lane.name); + option.value = lane.name; + option.selected = lane.name === card.lane; + destination.append(option); + } + const move = element('button', 'Move'); + move.onclick = () => void mutate('move', { id: card.id, lane: destination.value }); + const actions = element('div'); + actions.className = 'genie-actions'; + actions.append(destination, move); + for (const action of ['checkout', 'release', 'unblock', 'done']) { + const button = element( + 'button', + { checkout: 'Claim', release: 'Release', unblock: 'Unblock', done: 'Complete' }[action], + ); + button.onclick = () => void mutate(action, { id: card.id }); + actions.append(button); + } + detail.append(actions); + const note = element('textarea'); + note.setAttribute('aria-label', 'Comment or block reason'); + detail.append(note); + const notes = element('div'); + notes.className = 'genie-actions'; + for (const [label, action, hold] of [ + ['Comment', 'comment', false], + ['Block', 'block', false], + ['Hold', 'block', true], + ] as const) { + const button = element('button', label); + button.onclick = () => + void mutate(action, { id: card.id, text: note.value, ...(action === 'block' ? { hold } : {}) }); + notes.append(button); + } + detail.append(notes); + detail.append(element('h3', 'Dependencies')); + for (const dependency of card.dependencies) + detail.append(element('p', `${dependency.title} · ${dependency.status}`)); + if (!card.dependencies.length) detail.append(element('p', 'No dependencies')); + detail.append(element('h3', 'Comments')); + for (const comment of card.comments) + detail.append(element('p', `${comment.author ?? 'Unknown'}: ${comment.note}`)); + detail.append(element('h3', 'History')); + const history = element('ol'); + for (const event of card.timeline) + history.append( + element( + 'li', + `${new Date(event.createdAt).toLocaleString()} · ${event.kind} · ${event.author ?? 'Unknown'}${event.note ? ` — ${event.note}` : ''}`, + ), + ); + detail.append(history); + }; + const load = async () => { + if (!board.value) { + snapshot = undefined; + render(); + return; + } + snapshot = (await request('load')) as Aggregate; + render(); + }; + const list = async () => { + const previous = board.value; + const entries = (await request('list')) as { id: string; name: string }[]; + board.replaceChildren(); + for (const entry of entries) { + const option = element('option', entry.name); + option.value = entry.id; + board.append(option); + } + if (entries.some((entry) => entry.id === previous)) board.value = previous; + await load(); + }; + launch.onclick = () => { + dialog.showModal(); + void run(async () => { + const health = (await api('health')) as { compatible: boolean; error: string }; + if (!health.compatible) throw new Error(health.error); + const entries = (await api('workspaces')) as { id: string; title: string }[]; + workspace.replaceChildren(); + for (const entry of entries) { + const option = element('option', entry.title); + option.value = entry.id; + workspace.append(option); + } + if (!entries.length) throw new Error('Add a repository workspace in DSH to open its Genie board.'); + await list(); + }); + }; + close.onclick = () => dialog.close(); + workspace.onchange = () => void run(list); + board.onchange = () => void run(load); + refresh.onclick = () => void run(list); + create.onsubmit = (event) => { + event.preventDefault(); + void mutate('create', { title: title.value }); + }; + const visible = () => { + if (document.visibilityState === 'visible' && dialog.open) void run(list); + }; + document.addEventListener('visibilitychange', visible); + return () => { + document.removeEventListener('visibilitychange', visible); + style.remove(); + launch.remove(); + dialog.remove(); + }; + }, 'Genie board view'); +} diff --git a/plugins/dsh-genie-board/src/index.ts b/plugins/dsh-genie-board/src/index.ts new file mode 100644 index 000000000..5f98d7ce0 --- /dev/null +++ b/plugins/dsh-genie-board/src/index.ts @@ -0,0 +1,115 @@ +import type { IncomingMessage, ServerResponse } from 'node:http'; +import sourcePackage from '../../../package.json'; +declare const __GENIE_BUILD_VERSION__: string; +import { DEADLINE_MS, compatible, execute, hostEnvironment, resolveExecutable } from './process'; +import { BoardService, type Registry } from './service'; + +interface Context { + workspaceRegistry: Registry; + connection: { requestRejection(req: IncomingMessage): 401 | 403 | undefined }; + webServer: { + register(route: { + kind: 'exact'; + path: string; + handler: (req: IncomingMessage, res: ServerResponse) => Promise<void>; + }): () => void; + }; + effect(effect: () => () => void, label?: string): void; +} +export const inject = ['workspaceRegistry', 'webServer', 'connection']; +export const minimumGenieVersion = + typeof __GENIE_BUILD_VERSION__ === 'undefined' ? sourcePackage.version : __GENIE_BUILD_VERSION__; +export function trusted(req: IncomingMessage): boolean { + const address = req.socket.remoteAddress; + if (!['127.0.0.1', '::1', '::ffff:127.0.0.1'].includes(address ?? '')) return false; + const host = req.headers.host; + if (!host || !/^(127\.0\.0\.1|localhost|\[::1\])(?::\d+)?$/.test(host)) return false; + const origin = req.headers.origin; + if (origin !== undefined && origin !== `http://${host}`) return false; + if (req.headers['sec-fetch-site'] && req.headers['sec-fetch-site'] !== 'same-origin') return false; + return req.method === 'POST' + ? origin === `http://${host}` + : origin === `http://${host}` || req.headers['sec-fetch-site'] === 'same-origin'; +} +async function body(req: IncomingMessage): Promise<unknown> { + const chunks: Buffer[] = []; + let size = 0; + for await (const data of req) { + const chunk = Buffer.from(data); + size += chunk.length; + if (size > 16_384) throw new Error('Request body too large'); + chunks.push(chunk); + } + return JSON.parse(Buffer.concat(chunks).toString('utf8')); +} +function json(res: ServerResponse, code: number, value: unknown) { + res.writeHead(code, { + 'content-type': 'application/json', + 'cache-control': 'no-store', + 'x-content-type-options': 'nosniff', + }); + res.end(JSON.stringify(value)); +} +export async function apply(ctx: Context): Promise<void> { + let service: BoardService | undefined; + let version = ''; + let error = ''; + try { + const binary = resolveExecutable(); + version = ( + await execute(binary, ['--version'], process.cwd(), hostEnvironment('dsh-host'), { + expires: Date.now() + DEADLINE_MS, + bytes: 0, + }) + ).trim(); + if (!compatible(version, minimumGenieVersion)) throw new Error(`Genie ${minimumGenieVersion} or newer is required`); + service = new BoardService(ctx.workspaceRegistry, binary); + } catch (failure) { + error = failure instanceof Error ? failure.message : 'Genie unavailable'; + } + ctx.effect(() => { + const disposers: (() => void)[] = []; + const route = (path: string, method: string, handler: () => unknown) => { + disposers.push( + ctx.webServer.register({ + kind: 'exact', + path, + handler: async (req, res) => { + if (req.method !== method) return json(res, 405, { error: 'Method not allowed' }); + const rejection = ctx.connection.requestRejection(req); + if (rejection) return json(res, rejection, { error: 'DSH browser authentication required' }); + if (!trusted(req)) return json(res, 403, { error: 'Same-origin loopback request required' }); + json(res, 200, handler()); + }, + }), + ); + }; + route('/api/genie-board/health', 'GET', () => ({ compatible: !!service, version, minimumGenieVersion, error })); + if (service) { + route('/api/genie-board/workspaces', 'GET', () => service?.workspaces()); + disposers.push( + ctx.webServer.register({ + kind: 'exact', + path: '/api/genie-board/action', + handler: async (req, res) => { + if (req.method !== 'POST') return json(res, 405, { error: 'Method not allowed' }); + const rejection = ctx.connection.requestRejection(req); + if (rejection) return json(res, rejection, { error: 'DSH browser authentication required' }); + if (!trusted(req)) return json(res, 403, { error: 'Same-origin loopback request required' }); + if (req.headers['content-type'] !== 'application/json') + return json(res, 415, { error: 'application/json required' }); + req.setTimeout(DEADLINE_MS, () => req.destroy()); + try { + json(res, 200, await service?.request(await body(req))); + } catch (failure) { + json(res, 400, { error: failure instanceof Error ? failure.message : 'Board request failed' }); + } + }, + }), + ); + } + return () => { + for (const dispose of disposers) dispose(); + }; + }, 'Genie board routes'); +} diff --git a/plugins/dsh-genie-board/src/process.ts b/plugins/dsh-genie-board/src/process.ts new file mode 100644 index 000000000..1cb880ebd --- /dev/null +++ b/plugins/dsh-genie-board/src/process.ts @@ -0,0 +1,94 @@ +import { spawn } from 'node:child_process'; +import { constants, accessSync, realpathSync, statSync } from 'node:fs'; +import { delimiter, join } from 'node:path'; + +export const MAX_OUTPUT = 4 * 1024 * 1024; +export const DEADLINE_MS = 10_000; +export interface Budget { + expires: number; + bytes: number; +} +export function resolveExecutable(): string { + for (const directory of (process.env.PATH ?? '').split(delimiter)) { + if (!directory) continue; + try { + const path = realpathSync(join(directory, 'genie')); + if (!statSync(path).isFile()) continue; + accessSync(path, constants.X_OK); + return path; + } catch { + /* Continue the one startup-only lookup. */ + } + } + throw new Error('Genie executable is unavailable'); +} +export function hostEnvironment(identity: string): NodeJS.ProcessEnv { + const env: NodeJS.ProcessEnv = { NO_COLOR: '1', GENIE_AGENT_NAME: identity, GENIE_AGENT_KIND: 'dsh' }; + for (const key of ['PATH', 'HOME', 'GENIE_HOME']) if (process.env[key] !== undefined) env[key] = process.env[key]; + return env; +} +export function execute( + binary: string, + argv: string[], + cwd: string, + env: NodeJS.ProcessEnv, + budget: Budget, +): Promise<string> { + return new Promise((resolve, reject) => { + const remaining = budget.expires - Date.now(); + if (remaining <= 0) return reject(new Error('Genie deadline exceeded')); + const child = spawn(binary, ['--no-interactive', ...argv], { + cwd, + env, + shell: false, + stdio: ['ignore', 'pipe', 'pipe'], + }); + const chunks: Buffer[] = []; + let failure: Error | undefined; + const fail = (error: Error) => { + failure ??= error; + child.kill('SIGKILL'); + }; + const timer = setTimeout(() => fail(new Error('Genie deadline exceeded')), remaining); + const read = (chunk: Buffer, stdout: boolean) => { + budget.bytes += chunk.length; + if (budget.bytes > MAX_OUTPUT) return fail(new Error('Genie output limit exceeded')); + if (stdout) chunks.push(chunk); + }; + child.stdout.on('data', (chunk: Buffer) => read(chunk, true)); + child.stderr.on('data', (chunk: Buffer) => read(chunk, false)); + child.on('error', fail); + child.on('close', (code) => { + clearTimeout(timer); + if (failure) reject(failure); + else if (code !== 0) reject(new Error(`Genie exited with code ${code}`)); + else resolve(Buffer.concat(chunks).toString('utf8')); + }); + }); +} +export function compatible(actual: string, minimum: string): boolean { + const parse = (value: string) => + /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*)(?:\.(?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*))*))?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/.exec( + value, + ); + const a = parse(actual.trim()); + const b = parse(minimum); + if (!a || !b) return false; + for (let i = 1; i <= 3; i++) { + if (BigInt(a[i]) !== BigInt(b[i])) return BigInt(a[i]) > BigInt(b[i]); + } + if (a[4] === b[4]) return true; + if (!a[4]) return true; + if (!b[4]) return false; + const ap = a[4].split('.'); + const bp = b[4].split('.'); + for (let i = 0; i < Math.max(ap.length, bp.length); i++) { + if (ap[i] === bp[i]) continue; + if (ap[i] === undefined) return false; + if (bp[i] === undefined) return true; + const an = /^\d+$/.test(ap[i]); + const bn = /^\d+$/.test(bp[i]); + return an && bn ? BigInt(ap[i]) > BigInt(bp[i]) : an !== bn ? !an : ap[i] > bp[i]; + } + return true; +} diff --git a/plugins/dsh-genie-board/src/schema.ts b/plugins/dsh-genie-board/src/schema.ts new file mode 100644 index 000000000..efec3f0dd --- /dev/null +++ b/plugins/dsh-genie-board/src/schema.ts @@ -0,0 +1,81 @@ +import { z } from 'zod'; + +const text = z.string(); +const nullable = text.nullable(); +const time = z.number().finite().nonnegative(); +const id = text.regex(/^t_[a-z0-9]+$/); +const status = z.enum(['blocked', 'ready', 'in_progress', 'done']); +const event = z + .object({ id: time.int(), kind: text, note: nullable, authorKind: nullable, author: nullable, createdAt: time }) + .strict(); +export const cardSchema = z + .object({ + id, + boardId: nullable, + title: text, + status, + claimedBy: nullable, + claimedAt: time.nullable(), + wish: nullable, + group: nullable, + assignedAgent: nullable, + assignedReason: nullable, + createdAt: time, + updatedAt: time, + lane: nullable, + enforcedBlock: z + .object({ reason: text, kind: z.enum(['work', 'hold']) }) + .strict() + .nullable(), + agentKind: nullable, + heartbeatAt: time.nullable(), + blockedBy: nullable, + blockedReason: nullable, + liveness: z.enum(['running', 'idle', 'stale']).nullable(), + dependencies: z.array(z.object({ id, title: text, status }).strict()), + timeline: z.array(event), + comments: z.array( + event + .omit({ kind: true }) + .extend({ note: text.min(1) }) + .strict(), + ), + }) + .strict(); +export const aggregateSchema = z + .object({ + schemaVersion: z.literal(1), + scope: text, + lanes: z + .array(z.object({ name: text.min(1), label: nullable, action: nullable, cards: z.array(cardSchema) }).strict()) + .min(1), + }) + .strict() + .superRefine((board, ctx) => { + const names = board.lanes.map((lane) => lane.name); + const ids = board.lanes.flatMap((lane) => lane.cards.map((card) => card.id)); + if (new Set(names).size !== names.length || new Set(ids).size !== ids.length) + ctx.addIssue({ code: 'custom', message: 'Duplicate lane or card' }); + }); +export const boardsSchema = z.array( + z.object({ id: text.regex(/^b_[a-z0-9]+$/), name: text, laneCount: time.int(), cardCount: time.int() }).strict(), +); +const bounded = (max: number) => + text + .refine((value) => !/[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]/u.test(value), 'Control characters are not allowed') + .transform((value) => value.trim()) + .refine((value) => value.length > 0 && Buffer.byteLength(value) <= max, 'Invalid text'); +const base = { workspaceId: text.min(1).max(200), boardRef: text.regex(/^b_[a-z0-9]+$/) }; +export const requestSchema = z.discriminatedUnion('action', [ + z.object({ action: z.literal('list'), workspaceId: base.workspaceId }).strict(), + z.object({ action: z.literal('load'), ...base }).strict(), + z.object({ action: z.literal('create'), ...base, title: bounded(200) }).strict(), + z.object({ action: z.literal('move'), ...base, id, lane: text.min(1).max(200) }).strict(), + z.object({ action: z.literal('comment'), ...base, id, text: bounded(4000) }).strict(), + z.object({ action: z.literal('block'), ...base, id, text: bounded(1000), hold: z.boolean().optional() }).strict(), + ...(['unblock', 'checkout', 'release', 'done'] as const).map((action) => + z.object({ action: z.literal(action), ...base, id }).strict(), + ), +]); +export type Request = z.infer<typeof requestSchema>; +export type Aggregate = z.infer<typeof aggregateSchema>; diff --git a/plugins/dsh-genie-board/src/service.ts b/plugins/dsh-genie-board/src/service.ts new file mode 100644 index 000000000..1f0b8a89c --- /dev/null +++ b/plugins/dsh-genie-board/src/service.ts @@ -0,0 +1,109 @@ +import { realpath, stat } from 'node:fs/promises'; +import { hostname, userInfo } from 'node:os'; +import { join } from 'node:path'; +import { type Budget, DEADLINE_MS, execute, hostEnvironment } from './process'; +import { type Request, aggregateSchema, boardsSchema, requestSchema } from './schema'; + +export interface Workspace { + id: string; + path: string; + title: string; +} +export interface Registry { + list(): Workspace[]; +} +interface Selection { + path: string; + boards: Set<string>; + board?: string; + tasks?: Set<string>; + lanes?: Set<string>; +} +export function actionArgs(request: Exclude<Request, { action: 'list' | 'load' }>, identity: string): string[] { + switch (request.action) { + case 'create': + return ['task', 'create', '--title', request.title, '--board', request.boardRef]; + case 'move': + return ['task', 'move', request.id, '--to', request.lane]; + case 'comment': + return ['task', 'comment', '--', request.id, request.text]; + case 'block': + return ['task', 'block', request.id, '--reason', request.text, ...(request.hold ? ['--hold'] : [])]; + case 'checkout': + return ['task', 'checkout', request.id, '--worker', identity]; + default: + return ['task', request.action, request.id]; + } +} +export class BoardService { + private readonly selections = new Map<string, Selection>(); + private readonly active = new Set<string>(); + readonly identity = `dsh:${userInfo().username}@${hostname()}`; + private readonly environment = hostEnvironment(this.identity); + constructor( + private readonly registry: Registry, + private readonly binary: string, + private readonly run = execute, + ) {} + workspaces() { + const current = this.registry.list(); + const ids = new Set(current.map((workspace) => workspace.id)); + for (const id of this.selections.keys()) if (!ids.has(id)) this.selections.delete(id); + return current.map(({ id, title }) => ({ id, title })); + } + async request(input: unknown): Promise<unknown> { + const request = requestSchema.parse(input); + this.workspaces(); + if (this.active.has(request.workspaceId)) throw new Error('Workspace operation in progress; wait and reload'); + this.active.add(request.workspaceId); + let mutationCompleted = false; + try { + const workspace = this.registry.list().find((entry) => entry.id === request.workspaceId); + if (!workspace) throw new Error('Unknown workspace'); + const path = await realpath(workspace.path); + if (!(await stat(path)).isDirectory() || !(await stat(join(path, '.genie'))).isDirectory()) + throw new Error('Workspace must contain .genie'); + // Worktrees have a regular .git file; ordinary repositories have a directory. + const git = await stat(join(path, '.git')); + if (!git.isDirectory() && !git.isFile()) throw new Error('Workspace must be a physical repository'); + const previous = this.selections.get(workspace.id); + if (previous && previous.path !== path) { + this.selections.delete(workspace.id); + throw new Error('Workspace changed; list boards again'); + } + const budget: Budget = { expires: Date.now() + DEADLINE_MS, bytes: 0 }; + const run = (args: string[]) => this.run(this.binary, args, path, this.environment, budget); + if (request.action === 'list') { + const boards = boardsSchema.parse(JSON.parse(await run(['board', 'list', '--json']))); + this.selections.set(workspace.id, { path, boards: new Set(boards.map((board) => board.id)) }); + return boards; + } + if (!previous?.boards.has(request.boardRef)) throw new Error('Unknown board; list boards first'); + if (request.action !== 'load') { + if (previous.board !== request.boardRef) throw new Error('Board selection changed; reload'); + if ('id' in request && !previous.tasks?.has(request.id)) throw new Error('Task is outside the selected board'); + if (request.action === 'move' && !previous.lanes?.has(request.lane)) throw new Error('Unknown lane'); + await run(actionArgs(request, this.identity)); + mutationCompleted = true; + } + const aggregate = aggregateSchema.parse(JSON.parse(await run(['board', '--board', request.boardRef, '--json']))); + const cards = aggregate.lanes.flatMap((lane) => lane.cards); + if (cards.some((card) => card.boardId !== request.boardRef)) throw new Error('Foreign board card in aggregate'); + this.selections.set(workspace.id, { + path, + boards: previous.boards, + board: request.boardRef, + tasks: new Set(cards.map((card) => card.id)), + lanes: new Set(aggregate.lanes.map((lane) => lane.name)), + }); + return aggregate; + } catch (error) { + this.selections.delete(request.workspaceId); + if (mutationCompleted) + throw new Error('Operation may have completed, but refresh failed. Reload before making another change.'); + throw error; + } finally { + this.active.delete(request.workspaceId); + } + } +} diff --git a/plugins/dsh-genie-board/tsconfig.json b/plugins/dsh-genie-board/tsconfig.json new file mode 100644 index 000000000..60de6fec8 --- /dev/null +++ b/plugins/dsh-genie-board/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "resolveJsonModule": true, + "noEmit": true + }, + "include": ["src/**/*.ts", "build.ts", "build.test.ts"], + "exclude": ["dist"] +} diff --git a/plugins/genie/orca-plugin.json b/plugins/genie/orca-plugin.json index 4742e809e..f77e4d17c 100644 --- a/plugins/genie/orca-plugin.json +++ b/plugins/genie/orca-plugin.json @@ -3,7 +3,7 @@ "id": "genie", "publisher": "automagik", "name": "Genie", - "version": "5.260901.3", + "version": "5.260909.1", "description": "Genie workflows backed by Orca as the sole lifecycle authority.", "author": { "name": "Namastex Labs", diff --git a/plugins/genie/package.json b/plugins/genie/package.json index c8fa476ac..6f0d073a9 100644 --- a/plugins/genie/package.json +++ b/plugins/genie/package.json @@ -1,6 +1,6 @@ { "name": "genie-plugin", - "version": "5.260901.3", + "version": "5.260909.1", "private": true, "description": "Runtime dependencies for genie bundled CLIs", "license": "MIT", diff --git a/scripts/build-binary.sh b/scripts/build-binary.sh index 975d50e00..10275f7b4 100755 --- a/scripts/build-binary.sh +++ b/scripts/build-binary.sh @@ -68,6 +68,7 @@ bun build --compile \ --outfile "${STAGE}/genie" cp -R "${REPO_ROOT}/plugins" "${STAGE}/plugins" +bun run --cwd "${REPO_ROOT}/plugins/dsh-genie-board" build "${VERSION}" "${STAGE}/plugins/dsh-genie-board/dist" cp -R "${REPO_ROOT}/skills" "${STAGE}/skills" cp -R "${REPO_ROOT}/templates" "${STAGE}/templates" cp "${REPO_ROOT}/LICENSE" "${STAGE}/LICENSE" @@ -125,7 +126,14 @@ bun "${REPO_ROOT}/scripts/release-payload-version.ts" --stamp "${STAGE}" "${VERS for required in \ "LICENSE" \ "plugins/genie/orca-plugin.json" \ - "plugins/genie/orca-entrypoint.min.js"; do + "plugins/genie/orca-entrypoint.min.js" \ + "plugins/dsh-genie-board/package.json" \ + "plugins/dsh-genie-board/agent.cordis.yml" \ + "plugins/dsh-genie-board/cordis.patch.yml" \ + "plugins/dsh-genie-board/README.md" \ + "plugins/dsh-genie-board/NOTICE" \ + "plugins/dsh-genie-board/dist/index.js" \ + "plugins/dsh-genie-board/dist/client.js"; do [[ -f "${STAGE}/${required}" ]] || { echo "error: release payload missing ${required}" >&2; exit 1; } done diff --git a/scripts/dsh-genie-board-smoke.ts b/scripts/dsh-genie-board-smoke.ts new file mode 100644 index 000000000..2ee782911 --- /dev/null +++ b/scripts/dsh-genie-board-smoke.ts @@ -0,0 +1,179 @@ +import { type ChildProcess, spawn } from 'node:child_process'; +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { delimiter, join, resolve } from 'node:path'; + +const root = resolve(import.meta.dir, '..'); +const temporary = await mkdtemp(join(tmpdir(), 'genie-dsh-smoke-')); +const repo = join(temporary, 'repo'); +const home = join(temporary, 'dsh'); +const bin = join(temporary, 'bin'); +const env = { + ...process.env, + DSH_HOME: home, + GENIE_HOME: join(temporary, 'genie-home'), + HOME: join(temporary, 'home'), + PATH: `${bin}${delimiter}${process.env.PATH}`, +}; +let server: ChildProcess | undefined; +let installed = false; +async function command(binary: string, args: string[], cwd = root): Promise<string> { + const proc = Bun.spawn([binary, ...args], { + cwd, + env, + stdout: 'pipe', + stderr: 'pipe', + timeout: 120_000, + killSignal: 'SIGKILL', + }); + const [stdout, stderr, code] = await Promise.all([ + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + proc.exited, + ]); + if (code) throw new Error(`${binary} ${args.join(' ')} exited ${code}\n${stdout}\n${stderr}`); + console.log(`${binary} ${args.join(' ')}: OK`); + return stdout; +} +async function stop() { + const child = server; + server = undefined; + if (!child || child.exitCode !== null) return; + await new Promise<void>((resolveStop) => { + child.once('close', () => { + clearTimeout(timer); + resolveStop(); + }); + const timer = setTimeout(() => child.kill('SIGKILL'), 5000); + child.kill('SIGTERM'); + }); +} +async function start(): Promise<string> { + server = spawn( + 'dsh', + ['web', '--patch', join(temporary, 'fixture.patch.yml'), '--no-open', '--host', '127.0.0.1', '--port', '0'], + { cwd: repo, env, stdio: ['ignore', 'pipe', 'pipe'] }, + ); + const child = server; + return new Promise((resolveStart, reject) => { + let output = ''; + const timer = setTimeout(() => reject(new Error(`DSH startup timeout\n${output}`)), 45000); + const receive = (data: Buffer) => { + output += data.toString(); + const url = /http:\/\/127\.0\.0\.1:\d+\/\?token=[A-Za-z0-9_-]+/.exec(output)?.[0]; + if (url) { + clearTimeout(timer); + resolveStart(url); + } + }; + child.stdout?.on('data', receive); + child.stderr?.on('data', receive); + child.once('exit', (code) => { + clearTimeout(timer); + reject(new Error(`DSH exited ${code}\n${output}`)); + }); + }); +} +try { + for (const directory of [repo, bin, env.HOME]) await mkdir(directory, { recursive: true }); + await command('git', ['init', '--quiet'], repo); + await command('bun', ['run', 'build']); + await writeFile( + join(bin, 'genie'), + `#!/bin/sh\nexec '${process.execPath.replaceAll("'", "'\\''")}' '${join(root, 'dist/genie.js').replaceAll("'", "'\\''")}' "$@"\n`, + { mode: 0o755 }, + ); + await command('genie', ['--no-interactive', 'board', 'create', 'Smoke'], repo); + // Fixture-only Host plugin calls the actual installed registry's public API. + const fixture = join(temporary, 'fixture.mjs'); + await writeFile( + fixture, + `export const inject=['workspaceRegistry']; export async function apply(ctx){await ctx.workspaceRegistry.create(${JSON.stringify(repo)},'Smoke workspace');}`, + ); + await writeFile( + join(temporary, 'fixture.patch.yml'), + `- insert:\n - id: smoke-workspace\n name: ${JSON.stringify(fixture)}\n`, + ); + await command('dsh', ['plugin', '--profile', 'web', 'add', `link:${join(root, 'plugins/dsh-genie-board')}`]); + installed = true; + await start(); + console.log('First launch authenticated URL received'); + await stop(); + const listed = await command('dsh', ['plugin', '--profile', 'web', 'list', '--depth', '0']); + if (!listed.includes('@automagik/genie-dsh-board')) throw new Error('Installed plugin missing from list'); + const launchUrl = await start(); + const origin = new URL(launchUrl).origin; + const exchange = await fetch(launchUrl, { redirect: 'manual' }); + const cookie = exchange.headers.get('set-cookie')?.split(';')[0]; + if (!cookie) throw new Error('DSH token exchange did not set cookie'); + for (const invalidCookie of ['', 'dsh_session=invalid']) { + const denied = await fetch(`${origin}/api/genie-board/health`, { + headers: { origin, cookie: invalidCookie, 'sec-fetch-site': 'same-origin' }, + }); + if (denied.status !== 401) throw new Error('Missing/invalid DSH cookie was accepted'); + } + const request = async (path: string, data?: unknown) => { + const response = await fetch(`${origin}/api/genie-board/${path}`, { + method: data === undefined ? 'GET' : 'POST', + headers: { origin, cookie, 'content-type': 'application/json', 'sec-fetch-site': 'same-origin' }, + ...(data === undefined ? {} : { body: JSON.stringify(data) }), + }); + const result = await response.json(); + if (!response.ok) throw new Error(JSON.stringify(result)); + return result; + }; + let health: { compatible?: boolean } = {}; + for (let attempt = 0; attempt < 100; attempt++) { + try { + health = await request('health'); + break; + } catch { + await Bun.sleep(100); + } + } + if (!health.compatible) throw new Error(`Incompatible health: ${JSON.stringify(health)}`); + const workspaces = await request('workspaces'); + const workspaceId = workspaces[0]?.id; + const boards = await request('action', { action: 'list', workspaceId }); + const boardRef = boards[0]?.id; + await request('action', { action: 'load', workspaceId, boardRef }); + const created = await request('action', { action: 'create', workspaceId, boardRef, title: 'DSH smoke task' }); + const id = created.lanes + .flatMap((lane: { cards: { id: string; title: string }[] }) => lane.cards) + .find((card: { title: string }) => card.title === 'DSH smoke task')?.id; + if (!id) throw new Error('Create did not return complete task'); + const lane = created.lanes[1].name; + const moved = await request('action', { action: 'move', workspaceId, boardRef, id, lane }); + if ( + !moved.lanes + .find((entry: { name: string }) => entry.name === lane) + .cards.some((card: { id: string }) => card.id === id) + ) + throw new Error('Move not confirmed'); + const commented = await request('action', { action: 'comment', workspaceId, boardRef, id, text: '--help' }); + const commentCard = commented.lanes + .flatMap((entry: { cards: { id: string; comments: { note: string }[] }[] }) => entry.cards) + .find((entry: { id: string }) => entry.id === id); + if (!commentCard?.comments.some((entry: { note: string }) => entry.note === '--help')) + throw new Error('Option-shaped comment was not stored literally'); + console.log( + JSON.stringify({ + dsh: await command('dsh', ['--version']), + health, + origin, + workspaceId, + boardRef, + id, + lane, + result: 'PASS: install/list/restart/health/load/create/move', + }), + ); +} finally { + await stop(); + try { + if (installed) await command('dsh', ['plugin', '--profile', 'web', 'remove', '@automagik/genie-dsh-board']); + } finally { + await rm(temporary, { recursive: true, force: true }); + console.log('Temporary profile/repository removed'); + } +} diff --git a/scripts/reconcile-release-assets.test.ts b/scripts/reconcile-release-assets.test.ts index 698fdc333..e024f4ea0 100644 --- a/scripts/reconcile-release-assets.test.ts +++ b/scripts/reconcile-release-assets.test.ts @@ -7,6 +7,10 @@ import { join } from 'node:path'; const SCRIPT = join(import.meta.dir, 'reconcile-release-assets.sh'); const VERSION = '5.260714.3'; const CHANNEL = 'dev'; +// These fixtures execute the real shell pipeline with 20/28 assets and many +// verifier subprocesses. A dev run takes ~7s locally, beyond Bun's 5s default. +// Bound each pipeline itself, then allow grouped scenarios their own budget. +const PIPELINE_TIMEOUT_MS = 30_000; const PLATFORMS = ['linux-x64-glibc', 'linux-x64-musl', 'linux-arm64', 'darwin-arm64']; function namesFor(channel: 'stable' | 'dev'): string[] { const channels = channel === 'stable' ? ['stable', 'dev'] : ['dev']; @@ -299,6 +303,8 @@ save(); const result = Bun.spawnSync(['bash', SCRIPT], { cwd: root, + timeout: PIPELINE_TIMEOUT_MS, + killSignal: 'SIGKILL', env: { ...process.env, PATH: `${root}:${process.env.PATH ?? ''}`, @@ -337,7 +343,7 @@ describe('exact GitHub release asset reconciliation', () => { // One by-id POST per asset — finer resumption than the old batch upload. expect(uploadCalls(state)).toHaveLength(NAMES.length); expect(state.usedClobber).not.toBe(true); - }); + }, 60_000); test('expands exact descriptor inventory by selected-channel fanout', () => { for (const channel of ['dev', 'stable'] as const) { @@ -351,7 +357,7 @@ describe('exact GitHub release asset reconciliation', () => { // stable = 4 * (3 + 2*2) = 28 expect(namesFor('dev')).toHaveLength(20); expect(namesFor('stable')).toHaveLength(28); - }, 15_000); + }, 120_000); test('never mutates a published prerelease; channel promotions require fresh immutable tags', () => { const devAssets = localAssets('dev-release', 'dev'); @@ -360,7 +366,7 @@ describe('exact GitHub release asset reconciliation', () => { expect(stable.result.stderr.toString()).toContain('published immutable release'); expect(stable.state.assets).toEqual(devAssets); expect(uploadCalls(stable.state)).toHaveLength(0); - }); + }, 60_000); test('rejects missing and extra local inventory before any GitHub mutation', () => { const missing = run({ draft: true, assets: {} }, (dist) => rmSync(join(dist, NAMES[0]))); @@ -370,7 +376,7 @@ describe('exact GitHub release asset reconciliation', () => { const extra = run({ draft: true, assets: {} }, (dist) => writeFileSync(join(dist, 'unexpected'), 'x')); expect(extra.result.exitCode).toBe(3); expect(calls(extra.state, 'gh')).toHaveLength(0); - }); + }, 60_000); test('rejects empty, symlinked, and directory local assets before GitHub mutation', () => { const empty = run({ draft: true, assets: {} }, (dist) => writeFileSync(join(dist, NAMES[0]), '')); @@ -390,7 +396,7 @@ describe('exact GitHub release asset reconciliation', () => { }); expect(symlink.result.exitCode).toBe(3); expect(calls(symlink.state, 'gh')).toHaveLength(0); - }); + }, 60_000); test('resumes authenticated partial drafts and rejects a cryptographically inconsistent mix', () => { const local = localAssets(); @@ -404,14 +410,14 @@ describe('exact GitHub release asset reconciliation', () => { const mismatch = run({ draft: true, assets: { [NAMES[0]]: 'different' } }); expect(mismatch.result.exitCode).toBe(3); expect(uploadCalls(mismatch.state)).toHaveLength(0); - }); + }, 60_000); test('a complete authenticated draft reuses prior nondeterministic bundle bytes', () => { const draft = run({ draft: true, assets: localAssets('older-run') }); expect(draft.result.exitCode).toBe(0); expect(draft.result.stdout.toString()).toContain('preserves its complete authenticated draft inventory'); expect(uploadCalls(draft.state)).toHaveLength(0); - }); + }, 60_000); test('a retry rejects authenticated old descriptors bound to different candidate manifest bytes', () => { const stale = localAssets('older-run'); @@ -424,7 +430,7 @@ describe('exact GitHub release asset reconciliation', () => { const retry = run({ draft: true, assets: stale }); expect(retry.result.exitCode).toBe(3); expect(uploadCalls(retry.state)).toHaveLength(0); - }); + }, 60_000); test('an interrupted draft preserves prior bundle bytes while uploading only missing assets', () => { const current = localAssets(); @@ -441,7 +447,7 @@ describe('exact GitHub release asset reconciliation', () => { expect(resumed.state.assets[priorBundle]).toBe(partial[priorBundle]); expect(Object.keys(resumed.state.assets).sort()).toEqual([...NAMES].sort()); expect(uploadCalls(resumed.state)).toHaveLength(NAMES.length - Object.keys(partial).length); - }); + }, 60_000); test('reuses a complete published inventory only after pinned cryptographic verification', () => { const publishedAssets = localAssets('published'); @@ -465,7 +471,7 @@ describe('exact GitHub release asset reconciliation', () => { 'https://github.com/automagik-dev/genie/.github/workflows/release-publish.yml@refs/heads/main', ); } - }); + }, 60_000); test('selects our release predicate when GitHub also attests the immutable release', () => { const publishedAssets = localAssets('published'); @@ -477,7 +483,7 @@ describe('exact GitHub release asset reconciliation', () => { // answered with GitHub's immutable-release attestation ahead of ours. expect(state.attestationBatchSizes).toHaveLength(8); expect(state.attestationBatchSizes?.every((size) => size === 2)).toBe(true); - }); + }, 60_000); test('never repairs a partial published release or accepts remote extras', () => { const partial = run({ draft: false, prerelease: false, assets: { [NAMES[0]]: localAssets()[NAMES[0]] } }); @@ -489,7 +495,7 @@ describe('exact GitHub release asset reconciliation', () => { expect(extra.result.exitCode).toBe(3); expect(extra.result.stderr.toString()).toContain('unexpected assets'); expect(uploadCalls(extra.state)).toHaveLength(0); - }); + }, 60_000); test('rejects duplicate and malformed remote inventory before upload', () => { const duplicate = run({ @@ -506,7 +512,7 @@ describe('exact GitHub release asset reconciliation', () => { const malformed = run({ draft: true, assets: {}, remoteAssets: [{ name: 7 }] }); expect(malformed.result.exitCode).toBe(3); expect(uploadCalls(malformed.state)).toHaveLength(0); - }); + }, 60_000); test('rides out transient upload failures without duplicating or clobbering assets', () => { // One 502 on the first upload POST; the retry succeeds. Every asset lands @@ -516,7 +522,7 @@ describe('exact GitHub release asset reconciliation', () => { expect(Object.keys(state.assets).sort()).toEqual([...NAMES].sort()); expect(state.usedClobber).not.toBe(true); expect((state.calls ?? []).every((call) => !call.args.includes('DELETE'))).toBe(true); - }); + }, 60_000); test('an upload that landed but lost its response is skipped, and byte verification adjudicates', () => { // The first POST stores the bytes server-side but reports a transient @@ -532,7 +538,7 @@ describe('exact GitHub release asset reconciliation', () => { const corrupt = run({ draft: true, assets: {}, uploadLandThenFail: 1, uploadCorruptFirst: true }); expect(corrupt.result.exitCode).toBe(3); expect(corrupt.result.stderr.toString()).toContain('remote release asset verification failed after upload'); - }, 30_000); + }, 120_000); test('fails closed before any mutation when the release cannot be resolved', () => { const unknown = run({ draft: true, assets: {}, failTimes: { 'releases/tags': 99 } }); @@ -544,7 +550,7 @@ describe('exact GitHub release asset reconciliation', () => { expect(absent.result.exitCode).toBe(3); expect(absent.result.stderr.toString()).toContain('run prepare first'); expect(uploadCalls(absent.state)).toHaveLength(0); - }); + }, 60_000); test('propagates upload and verification failures', () => { const upload = run({ draft: true, assets: {}, failOn: 'uploads.github.com' }); @@ -572,5 +578,5 @@ describe('exact GitHub release asset reconciliation', () => { const nativePolicy = run({ draft: false, assets: localAssets('published'), invalidNative: true }); expect(nativePolicy.result.exitCode).not.toBe(0); expect(uploadCalls(nativePolicy.state)).toHaveLength(0); - }, 15_000); + }, 120_000); }); diff --git a/scripts/release-docs.test.ts b/scripts/release-docs.test.ts index abae40576..36e2110ed 100644 --- a/scripts/release-docs.test.ts +++ b/scripts/release-docs.test.ts @@ -982,10 +982,40 @@ describe('Group E release and documentation contracts', () => { expect(review).toContain('unjustified stateful machinery'); expect(review).toContain('a HIGH gap'); for (const lifecycleSkill of [review, fix, work]) expect(lifecycleSkill).toContain('`overdesigned-plan`'); - expect(fix).toContain('up to 2 loops'); + expect(fix).toContain('up to `B` loops'); expect(work).toContain('A user-approved simplification invalidates the superseded plan/review evidence'); }); + test('fix-loop budgets preserve explicit override authority and repair safeguards across lifecycle routes', () => { + for (const path of [ + 'skills/fix/SKILL.md', + 'skills/review/SKILL.md', + 'skills/work/SKILL.md', + 'skills/dream/SKILL.md', + 'skills/genie/reference/lifecycle.md', + 'skills/genie-orca-review/SKILL.md', + 'skills/genie-orca-work/SKILL.md', + ]) { + const skill = read(path); + expect(skill).toContain('budget `B` once per group: default 2'); + expect(skill).toContain('only an explicit higher-priority user/workspace instruction'); + expect(skill).toContain('another positive integer'); + expect(skill).toContain('Carry `B` and the attempts already used across handoffs'); + expect(skill).toContain( + 'Overrides never expand scope, permit unchanged retries, or skip diagnosis or independent re-review', + ); + expect(skill).not.toMatch(/(?:max|at most|up to|Cap) (?:2|\*\*2\*\*) (?:fix )?loops|after 2 (?:failed )?loops/); + } + for (const skill of ['fix', 'review', 'work']) { + expect(read(`skills/${skill}/SKILL.md`)).toContain('at most two escalation attempts per group'); + } + expect(read('skills/fix/SKILL.md')).toContain('effort_escalations=<used>/2'); + expect(read('skills/work/SKILL.md')).toContain('On FIX-FIRST, one fix loop.'); + expect(read('skills/work/SKILL.md')).toContain('This separate quality-pass cap is not expanded by `B`.'); + expect(read('skills/dream/SKILL.md')).toContain('max 3 attempts; poll CI status'); + expect(read('skills/genie-orca-work/SKILL.md')).toContain('re-dispatch one tier up, once.'); + }); + test('router pays Genie lifecycle cost only when it adds value', () => { const router = read('skills/genie/SKILL.md'); const lifecycle = read('skills/genie/reference/lifecycle.md'); diff --git a/scripts/release-payload-version.test.ts b/scripts/release-payload-version.test.ts index fa3b1effd..b8e419922 100644 --- a/scripts/release-payload-version.test.ts +++ b/scripts/release-payload-version.test.ts @@ -29,6 +29,10 @@ describe('release payload version contract', () => { for (const path of ['plugins/genie/package.json', 'plugins/genie/orca-plugin.json']) { writeJson(root, path, { name: 'genie', version: '5.000000.0' }); } + writeJson(root, 'plugins/dsh-genie-board/package.json', { + version: '5.000000.0', + minimumGenieVersion: '5.000000.0', + }); return root; } diff --git a/scripts/release-payload-version.ts b/scripts/release-payload-version.ts index cdccb06fb..de4d3f6fc 100644 --- a/scripts/release-payload-version.ts +++ b/scripts/release-payload-version.ts @@ -13,7 +13,11 @@ import { replaceTopLevelStringProperty } from './json-top-level-string.js'; const VERSION_PATTERN = /^[0-9A-Za-z][0-9A-Za-z.+-]{0,127}$/; -const TOP_LEVEL_VERSION_FILES = ['plugins/genie/package.json', 'plugins/genie/orca-plugin.json'] as const; +const TOP_LEVEL_VERSION_FILES = [ + 'plugins/genie/package.json', + 'plugins/genie/orca-plugin.json', + 'plugins/dsh-genie-board/package.json', +] as const; /** * Committed files whose version must already equal package.json before a @@ -82,12 +86,21 @@ export function stampReleasePayloadVersion(payloadRoot: string, version: string) replaceTopLevelVersion(join(payloadRoot, relativePath), version); } + const manifestPath = join(payloadRoot, 'plugins/dsh-genie-board/package.json'); + if (typeof readObject(manifestPath).minimumGenieVersion !== 'string') throw new Error('missing minimumGenieVersion'); + writeFileSync( + manifestPath, + replaceTopLevelStringProperty(readFileSync(manifestPath, 'utf8'), 'minimumGenieVersion', version), + ); writeFileSync(join(payloadRoot, 'VERSION'), `${version}\n`); } /** Fail closed if any copied release metadata disagrees with VERSION. */ export function verifyReleasePayloadVersion(payloadRoot: string, expectedVersion: string): void { assertVersion(expectedVersion); + const floor = readObject(join(payloadRoot, 'plugins/dsh-genie-board/package.json')).minimumGenieVersion; + if (floor !== expectedVersion) + throw new Error(`minimumGenieVersion mismatch: expected ${expectedVersion}, got ${floor}`); const stampPath = join(payloadRoot, 'VERSION'); if (!existsSync(stampPath)) throw new Error(`release payload metadata is missing: ${stampPath}`); const stamp = readFileSync(stampPath, 'utf8').trim(); diff --git a/scripts/verify-dsh-genie-board-release.test.ts b/scripts/verify-dsh-genie-board-release.test.ts new file mode 100644 index 000000000..8178ab6e1 --- /dev/null +++ b/scripts/verify-dsh-genie-board-release.test.ts @@ -0,0 +1,204 @@ +import { afterEach, expect, test } from 'bun:test'; +import { createHash } from 'node:crypto'; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { stampReleasePayloadVersion } from './release-payload-version'; +import { verifyArtifacts } from './verify-dsh-genie-board-release'; +const roots: string[] = []; +afterEach(() => { + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); +}); +const version = '5.260907.99'; +const platforms = ['linux-x64-glibc', 'linux-x64-musl', 'linux-arm64', 'darwin-arm64']; +function fixture() { + const root = mkdtempSync(join(tmpdir(), 'dsh-release-test-')); + roots.push(root); + const payload = join(root, 'payload'); + const artifacts = join(root, 'artifacts'); + mkdirSync(artifacts); + function put(path: string, data: string) { + mkdirSync(dirname(join(payload, path)), { recursive: true }); + writeFileSync(join(payload, path), data); + } + for (const path of [ + 'plugins/genie/package.json', + 'plugins/genie/orca-plugin.json', + 'plugins/dsh-genie-board/package.json', + ]) + put(path, JSON.stringify({ version, minimumGenieVersion: version })); + for (const path of ['agent.cordis.yml', 'cordis.patch.yml', 'README.md', 'NOTICE', 'dist/index.js', 'dist/client.js']) + put(`plugins/dsh-genie-board/${path}`, 'fixture'); + stampReleasePayloadVersion(payload, version); + function pack() { + for (const platform of platforms) { + const path = join(artifacts, `genie-${version}-${platform}.tar.gz`); + const process = Bun.spawnSync(['tar', '-czf', path, '-C', payload, '.']); + if (process.exitCode) throw new Error('tar failed'); + writeFileSync(`${path}.bundle`, 'invalid signature'); + writeFileSync(`${path}.intoto.jsonl`, 'invalid provenance'); + writeFileSync( + `${path}.stable.delivery.json`, + JSON.stringify({ + artifactSha256: createHash('sha256').update(readFileSync(path)).digest('hex'), + version, + channel: 'stable', + releaseName: `genie-${version}-${platform}.tar.gz`, + releaseTag: `v${version}`, + repository: 'automagik-dev/genie', + platformId: platform, + sourceSha: 'a'.repeat(40), + sourceBranch: 'main', + }), + ); + } + } + pack(); + return { root, payload, artifacts, pack, put }; +} +test('all four complete unsigned payloads pass; missing and extra platforms fail', () => { + const f = fixture(); + expect(() => verifyArtifacts(f.artifacts, version)).not.toThrow(); + writeFileSync(join(f.artifacts, 'extra.tar.gz'), 'x'); + expect(() => verifyArtifacts(f.artifacts, version)).toThrow('exactly four'); + rmSync(join(f.artifacts, 'extra.tar.gz')); + rmSync(join(f.artifacts, `genie-${version}-linux-arm64.tar.gz`)); + expect(() => verifyArtifacts(f.artifacts, version)).toThrow('exactly four'); +}); +for (const member of [ + 'package.json', + 'agent.cordis.yml', + 'cordis.patch.yml', + 'README.md', + 'NOTICE', + 'dist/index.js', + 'dist/client.js', +]) + test(`every platform requires ${member}`, () => { + const f = fixture(); + rmSync(join(f.payload, 'plugins/dsh-genie-board', member)); + f.pack(); + expect(() => verifyArtifacts(f.artifacts, version)).toThrow(); + }); +test('candidate floor and version drift fail independently', () => { + const f = fixture(); + f.put('plugins/dsh-genie-board/package.json', JSON.stringify({ version, minimumGenieVersion: '5.0.0' })); + f.pack(); + expect(() => verifyArtifacts(f.artifacts, version)).toThrow('minimumGenieVersion'); + f.put('plugins/dsh-genie-board/package.json', JSON.stringify({ version: '5.0.0', minimumGenieVersion: version })); + f.pack(); + expect(() => verifyArtifacts(f.artifacts, version)).toThrow('version mismatch'); +}); +test('signed mode rejects missing/empty sidecars, descriptor mismatch and invalid trust material', () => { + const f = fixture(); + const first = join(f.artifacts, `genie-${version}-darwin-arm64.tar.gz`); + writeFileSync(`${first}.bundle`, ''); + expect(() => verifyArtifacts(f.artifacts, version, 'stable')).toThrow('empty'); + f.pack(); + rmSync(`${first}.intoto.jsonl`); + expect(() => verifyArtifacts(f.artifacts, version, 'stable')).toThrow(); + f.pack(); + const descriptor = JSON.parse(readFileSync(`${first}.stable.delivery.json`, 'utf8')); + descriptor.artifactSha256 = '0'.repeat(64); + writeFileSync(`${first}.stable.delivery.json`, JSON.stringify(descriptor)); + expect(() => verifyArtifacts(f.artifacts, version, 'stable')).toThrow('descriptor'); + f.pack(); + const tools = join(f.root, 'tools'); + mkdirSync(tools); + writeFileSync(join(tools, 'cosign'), '#!/bin/sh\necho rejected-signature >&2\nexit 1\n', { mode: 0o755 }); + writeFileSync(join(tools, 'slsa-verifier'), '#!/bin/sh\nexit 0\n', { mode: 0o755 }); + const previousPath = process.env.PATH; + try { + process.env.PATH = `${tools}:${previousPath}`; + expect(() => verifyArtifacts(f.artifacts, version, 'stable')).toThrow('cosign signature verification failed'); + writeFileSync(join(tools, 'cosign'), '#!/bin/sh\nexit 0\n', { mode: 0o755 }); + writeFileSync(join(tools, 'slsa-verifier'), '#!/bin/sh\necho rejected-provenance >&2\nexit 1\n', { mode: 0o755 }); + expect(() => verifyArtifacts(f.artifacts, version, 'stable')).toThrow('SLSA provenance verification failed'); + } finally { + process.env.PATH = previousPath; + } +}); +test('both publication paths verify signed payloads before reconciliation', () => { + const workflow = readFileSync(join(import.meta.dir, '../.github/workflows/release-publish.yml'), 'utf8'); + expect(workflow.match(/verify-dsh-genie-board-release.ts --signed-artifact-dir/g)?.length).toBe(2); +}); + +test('signed orchestration verifies all four signatures, provenance and endorsed descriptors; rejects rewritten source', () => { + // These deterministic executables prove invocation and failure propagation, not cryptographic validity. + const f = fixture(); + const tools = join(f.root, 'tools'); + mkdirSync(tools); + const log = join(f.root, 'calls'); + for (const name of ['cosign', 'slsa-verifier']) + writeFileSync(join(tools, name), `#!/bin/sh\necho ${name} >> '${log}'\nexit 0\n`, { mode: 0o755 }); + writeFileSync( + join(tools, 'gh'), + `#!/usr/bin/env bun +import { readFileSync, appendFileSync, readdirSync, copyFileSync } from 'node:fs'; +const args = process.argv.slice(2); +if (args.includes('--help')) process.exit(1); +if (args[0] === 'release' && args[1] === 'download') { + const directory = args[args.indexOf('--dir') + 1]; + for (const name of readdirSync(${JSON.stringify(f.artifacts)})) copyFileSync(${JSON.stringify(f.artifacts)} + '/' + name, directory + '/' + name); + process.exit(0); +} +if (args[0] === 'release' && args[1] === 'view') { console.log(JSON.stringify({ tagName: 'v${version}', isDraft: false, isPrerelease: false })); process.exit(0); } +if (args[0] === 'api') { console.log(JSON.stringify({ sha: process.env.DSH_TEST_TAG_SHA || '${'a'.repeat(40)}' })); process.exit(0); } +appendFileSync(${JSON.stringify(log)}, 'descriptor\\n'); +const bundle = args[args.indexOf('--bundle') + 1]; +console.log(JSON.stringify([{ verificationResult: { statement: { predicate: JSON.parse(readFileSync(bundle, 'utf8')) } } }])); +`, + { mode: 0o755 }, + ); + for (const platform of platforms) { + const descriptor = join(f.artifacts, `genie-${version}-${platform}.tar.gz.stable.delivery.json`); + writeFileSync(`${descriptor}.sigstore.json`, readFileSync(descriptor)); + } + const previousPath = process.env.PATH; + try { + process.env.PATH = `${tools}:${previousPath}`; + expect(verifyArtifacts(f.artifacts, version, 'stable')).toBe('a'.repeat(40)); + const calls = readFileSync(log, 'utf8').trim().split('\n'); + for (const name of ['cosign', 'slsa-verifier', 'descriptor']) + expect(calls.filter((call) => call === name)).toHaveLength(4); + const cli = join(import.meta.dir, 'verify-dsh-genie-board-release.ts'); + const release = Bun.spawnSync(['bun', cli, '--release', `v${version}`, '--channel', 'stable'], { + env: { ...process.env }, + stdout: 'pipe', + stderr: 'pipe', + }); + expect(release.exitCode).toBe(0); + const wrongTag = Bun.spawnSync(['bun', cli, '--release', `v${version}`, '--channel', 'stable'], { + env: { ...process.env, DSH_TEST_TAG_SHA: 'b'.repeat(40) }, + stdout: 'pipe', + stderr: 'pipe', + }); + expect(wrongTag.exitCode).toBe(1); + expect(wrongTag.stderr.toString()).toContain('published tag/source binding mismatch'); + // Tamper all descriptors consistently; the cryptographically endorsed predicate still binds original source. + for (const platform of platforms) { + const descriptorPath = join(f.artifacts, `genie-${version}-${platform}.tar.gz.stable.delivery.json`); + const descriptor = JSON.parse(readFileSync(descriptorPath, 'utf8')); + descriptor.sourceSha = 'b'.repeat(40); + writeFileSync(descriptorPath, JSON.stringify(descriptor)); + } + expect(() => verifyArtifacts(f.artifacts, version, 'stable')).toThrow('verified delivery predicate'); + } finally { + process.env.PATH = previousPath; + } +}); + +test('a missing member on only the final platform fails after the other three complete', () => { + const f = fixture(); + rmSync(join(f.payload, 'plugins/dsh-genie-board/dist/client.js')); + const path = join(f.artifacts, `genie-${version}-linux-x64-musl.tar.gz`); + expect(Bun.spawnSync(['tar', '-czf', path, '-C', f.payload, '.']).exitCode).toBe(0); + expect(() => verifyArtifacts(f.artifacts, version)).toThrow(); +}); + +test('archive symlinks fail before extraction', () => { + const f = fixture(); + symlinkSync('/tmp', join(f.payload, 'outside')); + f.pack(); + expect(() => verifyArtifacts(f.artifacts, version)).toThrow('unsafe archive entry type'); +}); diff --git a/scripts/verify-dsh-genie-board-release.ts b/scripts/verify-dsh-genie-board-release.ts new file mode 100644 index 000000000..52b932eea --- /dev/null +++ b/scripts/verify-dsh-genie-board-release.ts @@ -0,0 +1,181 @@ +#!/usr/bin/env bun +import { createHash } from 'node:crypto'; +import { mkdtempSync, readFileSync, readdirSync, rmSync, statSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import { isDeepStrictEqual } from 'node:util'; +import { verifyReleasePayloadVersion } from './release-payload-version'; + +const platforms = ['linux-x64-glibc', 'linux-x64-musl', 'linux-arm64', 'darwin-arm64']; +const members = [ + 'package.json', + 'agent.cordis.yml', + 'cordis.patch.yml', + 'README.md', + 'NOTICE', + 'dist/index.js', + 'dist/client.js', +]; +const repository = 'automagik-dev/genie'; +function run(argv: string[]): string { + const result = Bun.spawnSync(argv, { stdout: 'pipe', stderr: 'pipe', timeout: 120_000, env: { ...process.env } }); + if (result.exitCode !== 0) throw new Error(`${argv[0]} failed: ${result.stderr.toString()}`); + return result.stdout.toString(); +} +function nonempty(path: string): void { + if (!statSync(path).isFile() || !statSync(path).size) throw new Error(`missing/empty regular file: ${path}`); +} +function verifyTarball(path: string, version: string): void { + // Reject links, special entries and escaping/duplicate names before extraction. + const names = run(['tar', '-tzf', path]).trim().split('\n'); + const seen = new Set<string>(); + for (const name of names) { + const normalized = name.replace(/^\.\//, '').replace(/\/$/, ''); + if (name.startsWith('/') || normalized.split('/').includes('..') || seen.has(normalized)) + throw new Error('unsafe archive path'); + seen.add(normalized); + } + if ( + run(['tar', '-tvzf', path]) + .trim() + .split('\n') + .some((line) => !['-', 'd'].includes(line[0])) + ) + throw new Error('unsafe archive entry type'); + const root = mkdtempSync(join(tmpdir(), 'genie-dsh-verify-')); + try { + run(['tar', '-xzf', path, '-C', root, '--no-same-owner']); + for (const member of members) nonempty(join(root, 'plugins/dsh-genie-board', member)); + verifyReleasePayloadVersion(root, version); + } finally { + rmSync(root, { recursive: true, force: true }); + } +} +export function verifyArtifacts(directory: string, version: string, channel?: 'stable' | 'dev'): string | undefined { + if (!/^[0-9A-Za-z][0-9A-Za-z.+-]{0,127}$/.test(version)) throw new Error('invalid version'); + const expected = platforms.map((platform) => `genie-${version}-${platform}.tar.gz`).sort(); + const actual = readdirSync(directory) + .filter((name) => name.endsWith('.tar.gz')) + .sort(); + if (JSON.stringify(actual) !== JSON.stringify(expected)) + throw new Error('expected exactly four candidate platform tarballs'); + let sourceSha: string | undefined; + for (const [index, name] of expected.entries()) { + const path = resolve(directory, name); + nonempty(path); + if (channel) { + nonempty(`${path}.bundle`); + nonempty(`${path}.intoto.jsonl`); + const descriptor = JSON.parse(readFileSync(`${path}.${channel}.delivery.json`, 'utf8')); + const platform = platforms.find((value) => name === `genie-${version}-${value}.tar.gz`); + const digest = createHash('sha256').update(readFileSync(path)).digest('hex'); + if ( + descriptor.artifactSha256 !== digest || + descriptor.version !== version || + descriptor.channel !== channel || + descriptor.releaseName !== name || + descriptor.releaseTag !== `v${version}` || + descriptor.repository !== repository || + descriptor.platformId !== platform || + !/^[a-f0-9]{40}$/.test(descriptor.sourceSha) || + (channel === 'stable' && descriptor.sourceBranch !== 'main') + ) + throw new Error(`descriptor binding mismatch: ${name}`); + if (index && sourceSha !== descriptor.sourceSha) throw new Error('candidate source mismatch'); + sourceSha = descriptor.sourceSha; + run(['bash', join(import.meta.dir, 'verify-release.sh'), '--local', path]); + const descriptorPath = `${path}.${channel}.delivery.json`; + nonempty(`${descriptorPath}.sigstore.json`); + const verified = JSON.parse( + run([ + 'gh', + 'attestation', + 'verify', + descriptorPath, + '--bundle', + `${descriptorPath}.sigstore.json`, + '--repo', + repository, + '--predicate-type', + `https://github.com/${repository}/delivery-evidence/v1`, + '--cert-identity', + `https://github.com/${repository}/.github/workflows/release-publish.yml@refs/heads/main`, + '--source-ref', + 'refs/heads/main', + '--format', + 'json', + ]), + ); + if ( + !Array.isArray(verified) || + verified.length !== 1 || + !isDeepStrictEqual(verified[0]?.verificationResult?.statement?.predicate, descriptor) + ) { + throw new Error('verified delivery predicate does not equal descriptor'); + } + } + verifyTarball(path, version); + } + return sourceSha; +} +function main(): void { + const args = process.argv.slice(2); + const options = new Map<string, string>(); + for (let i = 0; i < args.length; i += 2) { + const key = args[i]; + const value = args[i + 1]; + if ( + !['--unsigned-artifact-dir', '--signed-artifact-dir', '--release', '--version', '--channel'].includes(key) || + !value || + options.has(key) + ) + throw new Error('invalid arguments'); + options.set(key, value); + } + const modes = ['--unsigned-artifact-dir', '--signed-artifact-dir', '--release'].filter((key) => options.has(key)); + if (modes.length !== 1) throw new Error('choose exactly one artifact/release mode'); + const mode = modes[0]; + const input = options.get(mode) as string; + const version = mode === '--release' ? input.replace(/^v/, '') : options.get('--version'); + const channel = options.get('--channel') ?? 'stable'; + if ( + !version || + !['stable', 'dev'].includes(channel) || + (mode === '--release' && input !== `v${version}`) || + (options.has('--version') && options.get('--version') !== version) + ) + throw new Error('invalid candidate/channel'); + const directory = mode === '--release' ? mkdtempSync(join(tmpdir(), 'genie-dsh-release-')) : resolve(input); + try { + if (mode === '--release') run(['gh', 'release', 'download', input, '--repo', repository, '--dir', directory]); + const source = verifyArtifacts( + directory, + version, + mode === '--unsigned-artifact-dir' ? undefined : (channel as 'stable' | 'dev'), + ); + if (mode === '--release') { + const release = JSON.parse( + run(['gh', 'release', 'view', input, '--repo', repository, '--json', 'tagName,isDraft,isPrerelease']), + ); + const commit = JSON.parse(run(['gh', 'api', `repos/${repository}/commits/${input}`])); + if ( + release.tagName !== input || + release.isDraft || + (channel === 'stable' && release.isPrerelease) || + commit.sha !== source + ) + throw new Error('published tag/source binding mismatch'); + } + console.log(`DSH payload verified (${mode.slice(2)}, ${version}, four platforms)`); + } finally { + if (mode === '--release') rmSync(directory, { recursive: true, force: true }); + } +} +if (import.meta.main) { + try { + main(); + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + process.exitCode = 1; + } +} diff --git a/skills/dream/SKILL.md b/skills/dream/SKILL.md index 7c4b2f114..79f857861 100644 --- a/skills/dream/SKILL.md +++ b/skills/dream/SKILL.md @@ -57,11 +57,13 @@ Each worker, independently: ## Phase 2: Review + PR +Resolve the fix-loop budget `B` once per group: default 2; only an explicit higher-priority user/workspace instruction may set another positive integer. Carry `B` and the attempts already used across handoffs; do not reset the budget when switching skills. Overrides never expand scope, permit unchanged retries, or skip diagnosis or independent re-review. + **Trigger:** all workers in the layer reported done or blocked. 1. Dispatch one reviewer subagent per PR via the native delegation surface (reviewer ≠ worker) to run `review` against the wish's acceptance criteria. 2. Read bot comments critically — never blindly accept automated findings. -3. On FIX-FIRST: diagnose first; return an overdesigned plan to wish/design review, otherwise dispatch `fix` for valid gaps (max 2 loops per PR). On another architectural issue: escalate in the report, no fix attempt. +3. On FIX-FIRST: diagnose first; return an overdesigned plan to wish/design review, otherwise dispatch `fix` for valid gaps (at most `B` loops per group, including attempts already used during execution). On another architectural issue: escalate in the report, no fix attempt. 4. CI must be green before proceeding — poll status, do not sleep. 5. On SHIP: mark the PR review-complete. diff --git a/skills/fix/SKILL.md b/skills/fix/SKILL.md index 8832fbb37..0f67f97cd 100644 --- a/skills/fix/SKILL.md +++ b/skills/fix/SKILL.md @@ -1,13 +1,15 @@ --- name: fix -description: "Dispatch fix subagent for FIX-FIRST gaps from review, re-review, then diagnose unresolved failures after 2 loops." +description: "Dispatch fix subagent for FIX-FIRST gaps from review, re-review, then diagnose unresolved failures at the resolved budget (default 2)." --- # fix — Fix-Review Loop **Runtime syntax:** invoke the plugin copy through the active runtime's owner-qualified skill selector; use a bare selector only when intentionally selecting a user-tier copy (a separately installed personal copy; Genie no longer seeds this tier). Cross-skill prose below uses bare names as portable semantic routes; the orchestrator resolves the selector for the active runtime. -Resolve FIX-FIRST gaps from `review`: dispatch a fix subagent, re-review, repeat up to 2 loops, then diagnose and route any unresolved failure. +Resolve FIX-FIRST gaps from `review`: dispatch a fix subagent, re-review, repeat up to `B` loops, then diagnose and route any unresolved failure. + +Resolve the fix-loop budget `B` once per group: default 2; only an explicit higher-priority user/workspace instruction may set another positive integer. Carry `B` and the attempts already used across handoffs; do not reset the budget when switching skills. Overrides never expand scope, permit unchanged retries, or skip diagnosis or independent re-review. ## When to Use - `review` returned a **FIX-FIRST** verdict with CRITICAL or HIGH gaps @@ -22,8 +24,8 @@ Resolve FIX-FIRST gaps from `review`: dispatch a fix subagent, re-review, repeat | Verdict | Condition | Action | |---------|-----------|--------| | SHIP | — | Done. Return to orchestrator. | -| FIX-FIRST | loop < 2 | Increment loop, go to step 2. | -| FIX-FIRST | loop = 2 | Stop fixing and run Escalation Diagnosis; max loops reached. | +| FIX-FIRST | loop < B | Increment loop, go to step 2. | +| FIX-FIRST | loop = B | Stop fixing and run Escalation Diagnosis; max loops reached. | | BLOCKED | — | Run Escalation Diagnosis and take the cause-specific route. | 5. **Route the diagnosis:** report the remaining gaps with exact files, failing checks, cause class, and corrective route; the group's task stays `in_progress`. @@ -59,14 +61,14 @@ The fix loop never mutates task state. The group's task stays `in_progress` thro ## Diagnosis / Appeal Format ``` -Fix loop exhausted (2/2). Group remains in progress. +Fix loop exhausted (<used>/B). Group remains in progress. Remaining gaps: - [CRITICAL] <gap description> — <file> - [HIGH] <gap description> — <file> Cause: <model-capacity|missing-context|ambiguous-spec|env-tool-failure|overdesigned-plan> New evidence: <new output/diagnosis, or "none — model/effort escalation prohibited"> Corrective route: <one cause-specific next step> -Budget: attempts=<used>/2; effort_escalations=<used>/2 +Budget: attempts=<used>/B; effort_escalations=<used>/2 Appeal: <reviewer/final-gate disagreement record, or "none"> ``` @@ -79,12 +81,12 @@ Appeal: <reviewer/final-gate disagreement record, or "none"> - [HIGH] sendMessage result not checked — dispatch.ts:541 ``` -Loop 1: native delegation surface → fixer briefed with both gaps, the wish criteria, and `bun test` as validation. The fixer edits, runs the validation, reports its changes with outcomes, and ends `done`. Then native delegation surface → a fresh reviewer briefed to re-run `review` against the same criteria. SHIP → report success to the orchestrator. FIX-FIRST again → loop 2; after that, classify the cause and take its corrective route. A model or effort raise is permitted only for evidenced `model-capacity` within both caps. An `overdesigned-plan` diagnosis stops immediately and returns to planning instead. +With the default `B = 2`, loop 1: native delegation surface → fixer briefed with both gaps, the wish criteria, and `bun test` as validation. The fixer edits, runs the validation, reports its changes with outcomes, and ends `done`. Then native delegation surface → a fresh reviewer briefed to re-run `review` against the same criteria. SHIP → report success to the orchestrator. FIX-FIRST again → loop 2; after that, classify the cause and take its corrective route. A model or effort raise is permitted only for evidenced `model-capacity` within both caps. An `overdesigned-plan` diagnosis stops immediately and returns to planning instead. ## Rules - Tight scope: fix exactly the tagged gaps — no unrequested refactors, features, or drive-by cleanups. - Never fix and review in the same session — always separate subagents. -- Never exceed 2 fix loops — stop, diagnose, and take the cause-specific route. +- Never exceed `B` fix loops — stop, diagnose, and take the cause-specific route. - Never use a fix loop to preserve optional machinery when a simpler plan satisfies the user stories. - Include the original wish criteria in every fix dispatch. - Identical gaps across loops = no progress; classify the cause. Repetition is not new evidence and never authorizes a model or effort raise. diff --git a/skills/genie-orca-review/SKILL.md b/skills/genie-orca-review/SKILL.md index 9c5e553d3..8b077440d 100644 --- a/skills/genie-orca-review/SKILL.md +++ b/skills/genie-orca-review/SKILL.md @@ -29,7 +29,9 @@ A reviewer is a **read-only worker dispatched by the coordinator**, never the en ## Fix loop -Coordinator re-dispatches a **fast** worker into the same worktree with the findings quoted verbatim and "apply exactly this, nothing else". Cap 2 loops per group; the coordinator may verify a trivial delta itself instead of a second review. After the cap → human gate. +Resolve the fix-loop budget `B` once per group: default 2; only an explicit higher-priority user/workspace instruction may set another positive integer. Carry `B` and the attempts already used across handoffs; do not reset the budget when switching skills. Overrides never expand scope, permit unchanged retries, or skip diagnosis or independent re-review. + +Coordinator re-dispatches a **fast** worker into the same worktree with the findings quoted verbatim and "apply exactly this, nothing else". Cap `B` loops per group. After every fix, dispatch an independent re-reviewer who is not the fixer. If the re-review is still not SHIP after `B` attempts, stop fixing and follow [Escalation Diagnosis](../fix/SKILL.md#escalation-diagnosis), then its cause-specific route or human gate as applicable. Diagnosis does not reset the budget or grant additional fix attempts. ## What the integrated gate catches that group review does not diff --git a/skills/genie-orca-work/SKILL.md b/skills/genie-orca-work/SKILL.md index e8c3e06f9..d57594d08 100644 --- a/skills/genie-orca-work/SKILL.md +++ b/skills/genie-orca-work/SKILL.md @@ -19,6 +19,8 @@ description: "Coordinator loop for an approved wish on Orca — one Run per wish ## The loop (verbatim command shapes) +Resolve the fix-loop budget `B` once per group: default 2; only an explicit higher-priority user/workspace instruction may set another positive integer. Carry `B` and the attempts already used across handoffs; do not reset the budget when switching skills. Overrides never expand scope, permit unchanged retries, or skip diagnosis or independent re-review. + ```bash ORCA orchestration run-create --objective "<wish slug> (<LINEAR-ID>): <one line>" --json # one task per group; spec = self-contained engineer brief (template below); deps = the wish's depends_on @@ -36,7 +38,7 @@ Per message in a Delivery: - `worker_done` (payload carries taskId/dispatchId/outcome/filesModified): 1. `ORCA orchestration worker-release --dispatch <id> --json` (always, success or failure; keep live only on explicit user request via `worker-retain`). 2. **Review**: `task-create` a read-only review brief (template below), `worker-start --task <review> --worktree name:<slug>-g<n> --agent <reviewer>` — a different agent/model than the engineer. - 3. On review `worker_done`: parse `VERDICT:` — `SHIP` → mark group done in your head and in Linear (below); `FIX-FIRST` → `task-create` a fix brief quoting the findings, dispatch a fast worker into the same worktree (`--terminal <engineer handle>` if still live, else `--worktree name:…`), max **2** loops, then escalate to the human gate; `BLOCKED` → stop the group, post the blocker on the Linear child, continue other groups. + 3. On review `worker_done`: parse `VERDICT:` — `SHIP` → mark group done in your head and in Linear (below); `FIX-FIRST` → `task-create` a fix brief quoting the findings, dispatch a fast worker into the same worktree (`--terminal <engineer handle>` if still live, else `--worktree name:…`), at most `B` loops per group, then escalate to the human gate; `BLOCKED` → stop the group, post the blocker on the Linear child, continue other groups. - Acknowledge only after every message is handled: `ORCA orchestration check --ack <delivery_id> --wait … --json`. Dependent groups become `ready` automatically when their deps complete; start them on the next sweep (`task-list --ready --brief --json`). An integrator group (full gate, docs, tripwires) runs last on the integrated wish branch: merge each group branch into the wish branch **yourself** (coordinator-owned git), then dispatch. diff --git a/skills/genie/reference/lifecycle.md b/skills/genie/reference/lifecycle.md index 5401ccc69..8d61b24a8 100644 --- a/skills/genie/reference/lifecycle.md +++ b/skills/genie/reference/lifecycle.md @@ -46,6 +46,8 @@ external blocker is recorded; authorized merge plus required QA → `SHIPPED`. The reviewer remains read-only and never edits WISH.md or task state. A chat verdict that was not persisted does not advance the lifecycle. +Resolve the fix-loop budget `B` once per group: default 2; only an explicit higher-priority user/workspace instruction may set another positive integer. Carry `B` and the attempts already used across handoffs; do not reset the budget when switching skills. Overrides never expand scope, permit unchanged retries, or skip diagnosis or independent re-review. + ## Skill Catalog | Skill | Purpose | When to use | @@ -54,7 +56,7 @@ verdict that was not persisted does not advance the lifecycle. | `wish` | Convert a design into a structured plan at `.genie/wishes/<slug>/WISH.md` — scope, execution groups, acceptance criteria, validation | Idea is concrete, needs a plan | | `review` | Genie criteria gate — SHIP / FIX-FIRST / BLOCKED with severity-tagged gaps | Before and after `work`, or any plan/PR | | `work` | Execute an approved wish — dispatch native subagents per group in waves, fix loops, validation | Wish is SHIP-approved | -| `fix` | Resolve FIX-FIRST gaps, re-review, escalate after 2 failed loops | Review returned FIX-FIRST | +| `fix` | Resolve FIX-FIRST gaps, re-review, diagnose unresolved gaps after `B` loops (default 2) | Review returned FIX-FIRST | | `council` | Multi-perspective deliberation with specialist viewpoints | Major design decisions, tradeoffs | | `refine` | Transform a brief into a production-ready prompt | Prompt needs sharpening | | `report` | Investigate bugs — trace, capture evidence, open a GitHub issue with confirmation | Bug reports | diff --git a/skills/review/SKILL.md b/skills/review/SKILL.md index 5c63f582c..e44a70765 100644 --- a/skills/review/SKILL.md +++ b/skills/review/SKILL.md @@ -140,10 +140,13 @@ write. Never edit WISH.md, the brainstorm jar, or task state as the reviewer. | PR review (before merge) | Merge to `dev` (agents) or approve for human merge | ### FIX-FIRST loop + +Resolve the fix-loop budget `B` once per group: default 2; only an explicit higher-priority user/workspace instruction may set another positive integer. Carry `B` and the attempts already used across handoffs; do not reset the budget when switching skills. Overrides never expand scope, permit unchanged retries, or skip diagnosis or independent re-review. + 1. Diagnose first. For `overdesigned-plan`, return to `brainstorm`/`wish` without consuming a fix attempt. 2. Otherwise auto-invoke `fix` with the severity-tagged gap list. -3. After `fix` completes, re-run `review` (max 2 fix loops). -4. Still FIX-FIRST after 2 loops → return BLOCKED with an Escalation Diagnosis; never raise model or effort automatically. +3. After `fix` completes, re-run `review` (at most `B` fix loops per group). +4. Still FIX-FIRST after `B` loops → return BLOCKED with an Escalation Diagnosis; never raise model or effort automatically. When a failure's root cause is unclear, invoke `trace` before dispatching `fix` — `fix` then applies the cause-specific correction from the trace report. An unclear cause is not evidence of `model-capacity`. diff --git a/skills/work/SKILL.md b/skills/work/SKILL.md index 7329cd901..383005b21 100644 --- a/skills/work/SKILL.md +++ b/skills/work/SKILL.md @@ -17,6 +17,8 @@ When you are spawned as a subagent for a group, your dispatch prompt carries the - An approved wish exists and `review` returned SHIP on the plan - Orchestrator needs to dispatch implementation to subagents +Resolve the fix-loop budget `B` once per group: default 2; only an explicit higher-priority user/workspace instruction may set another positive integer. Carry `B` and the attempts already used across handoffs; do not reset the budget when switching skills. Overrides never expand scope, permit unchanged retries, or skip diagnosis or independent re-review. + ## Flow 1. **Load and enter execution:** read `.genie/wishes/<slug>/WISH.md` and require persisted status `APPROVED` (or `IN_PROGRESS` when resuming). Before the first dispatch, the orchestrator sets `APPROVED` → `IN_PROGRESS`; read group state with `genie task list --wish <slug>` (or `genie board --wish <slug>`). 2. **Pick the wave:** every group whose `depends-on` groups are done, per the wish's Execution Strategy. @@ -26,8 +28,8 @@ When you are spawned as a subagent for a group, your dispatch prompt carries the ``` If two agents race one task, exactly one wins; the loser gets a conflict error and stands down. 4. **Await completion — never poll:** background subagents notify you when they finish. Inspect `genie board --wish <slug>` on demand; completion is push, not poll. -5. **Local review:** per finished group, dispatch a reviewer subagent (reviewer ≠ engineer) to run `review` against that group's acceptance criteria. The orchestrator appends each returned evidence block under `## Review Results`; the reviewer never edits it. Diagnose before fixing: `overdesigned-plan` returns to wish/design review without consuming a fix attempt; other FIX-FIRST gaps may use at most 2 fix loops. -6. **Quality review:** dispatch a reviewer for a quality pass (security, maintainability, perf). On FIX-FIRST, one fix loop. +5. **Local review:** per finished group, dispatch a reviewer subagent (reviewer ≠ engineer) to run `review` against that group's acceptance criteria. The orchestrator appends each returned evidence block under `## Review Results`; the reviewer never edits it. Diagnose before fixing: `overdesigned-plan` returns to wish/design review without consuming a fix attempt; other FIX-FIRST gaps may use at most `B` fix loops. +6. **Quality review:** dispatch a reviewer for a quality pass (security, maintainability, perf). On FIX-FIRST, one fix loop. This separate quality-pass cap is not expanded by `B`. 7. **Validate:** run the group's validation command yourself through the active runtime's shell surface; record the output and the scope rationale as evidence. Confirm it remains proportional to the actual diff, widening it when implementation reached beyond the plan: documentation-only changes, including deterministic generated documentation or plugin skill mirrors, use diff --git a/src/genie-commands/__tests__/update-command-publication.test.ts b/src/genie-commands/__tests__/update-command-publication.test.ts index 4f4eb3be7..cf68d2573 100644 --- a/src/genie-commands/__tests__/update-command-publication.test.ts +++ b/src/genie-commands/__tests__/update-command-publication.test.ts @@ -46,13 +46,13 @@ function buildReleasePayload( } { const payload = join(root, 'payload'); for (const directory of ['.agents', '.claude-plugin', 'plugins/genie', 'skills/review', 'templates']) { - mkdirSync(join(payload, directory), { recursive: true }); + mkdirSync(join(payload, directory), { recursive: true, mode: 0o755 }); } - writeFileSync(join(payload, 'LICENSE'), 'test fixture\n'); - writeFileSync(join(payload, 'VERSION'), `${version}\n`); - writeFileSync(join(payload, 'plugins', 'genie', 'plugin.txt'), 'authenticated plugin payload\n'); - writeFileSync(join(payload, 'skills', 'review', 'SKILL.md'), '# Review\n'); - writeFileSync(join(payload, 'templates', 'template.txt'), 'template\n'); + writeFileSync(join(payload, 'LICENSE'), 'test fixture\n', { mode: 0o644 }); + writeFileSync(join(payload, 'VERSION'), `${version}\n`, { mode: 0o644 }); + writeFileSync(join(payload, 'plugins', 'genie', 'plugin.txt'), 'authenticated plugin payload\n', { mode: 0o644 }); + writeFileSync(join(payload, 'skills', 'review', 'SKILL.md'), '# Review\n', { mode: 0o644 }); + writeFileSync(join(payload, 'templates', 'template.txt'), 'template\n', { mode: 0o644 }); writeExecutable( join(payload, 'genie'), `#!/bin/sh\nif [ "\${1:-}" = "--version" ]; then printf 'genie ${version}\\n'; exit 0; fi\nexit 0\n`, @@ -75,7 +75,7 @@ describe('updateCommand publication boundary', () => { const bin = join(genieHome, 'bin'); const fakeBin = join(root, 'fake-bin'); const fixture = join(root, 'fixture'); - mkdirSync(bin, { recursive: true }); + mkdirSync(bin, { recursive: true, mode: 0o755 }); mkdirSync(fakeBin); mkdirSync(fixture); diff --git a/src/genie-commands/__tests__/update.test.ts b/src/genie-commands/__tests__/update.test.ts index 182d5fdf3..b8a95a40c 100644 --- a/src/genie-commands/__tests__/update.test.ts +++ b/src/genie-commands/__tests__/update.test.ts @@ -561,7 +561,7 @@ describe('updateCommand wiring', () => { } finally { logSpy.mockRestore(); errorSpy.mockRestore(); - process.exitCode = priorExitCode; + process.exitCode = priorExitCode ?? 0; if (priorWait === undefined) Reflect.deleteProperty(process.env, 'GENIE_LIFECYCLE_LEASE_WAIT_MS'); else process.env.GENIE_LIFECYCLE_LEASE_WAIT_MS = priorWait; } @@ -2207,7 +2207,7 @@ describe('skills.sh channel in the post-delivery convergence (wish skills-everyw }); afterEach(() => { - process.exitCode = previousExitCode; + process.exitCode = previousExitCode ?? 0; }); test('installs skills BEFORE the plugin-era retirement (decision 2 ordering)', () => { diff --git a/src/genie-commands/local-delivery-repair.test.ts b/src/genie-commands/local-delivery-repair.test.ts index 05c3da7cc..0a9dace72 100644 --- a/src/genie-commands/local-delivery-repair.test.ts +++ b/src/genie-commands/local-delivery-repair.test.ts @@ -112,7 +112,7 @@ function isolatedEnv(root: string, overrides: Record<string, string> = {}): Reco const genieHome = join(root, 'genie-home'); const codexHome = join(root, 'codex-home'); const temp = join(root, 'tmp'); - for (const path of [home, genieHome, codexHome, temp]) mkdirSync(path, { recursive: true }); + for (const path of [home, genieHome, codexHome, temp]) mkdirSync(path, { recursive: true, mode: 0o700 }); return { ...env, HOME: home, diff --git a/src/lib/runtime-integrations.test.ts b/src/lib/runtime-integrations.test.ts index 18630c671..5358ee97c 100644 --- a/src/lib/runtime-integrations.test.ts +++ b/src/lib/runtime-integrations.test.ts @@ -56,15 +56,18 @@ describe('bounded integration subprocess and Codex plugin state', () => { '-e', [ 'const { spawn } = require("node:child_process");', - 'const child = spawn(process.execPath, ["-e", "process.on(\\"SIGTERM\\",()=>{});setInterval(()=>{},1000)"], { stdio: "ignore" });', - 'process.stdout.write(String(child.pid));', + `const child = spawn(process.execPath, ["-e", ${JSON.stringify('process.on("SIGTERM",()=>{});process.stdout.write("ready");setInterval(()=>{},1000)')}], { stdio: ["ignore", "pipe", "ignore"] });`, + 'child.stdout.once("data", () => process.stdout.write(String(child.pid)));', 'process.on("SIGTERM",()=>{});', 'setInterval(()=>{},1000);', ].join(''), ], - { timeoutMs: 50, maxOutputBytes: 1_024, killGraceMs: 30 }, + // Allow both processes to start; stdout acknowledges the descendant's TERM handler. + { timeoutMs: 1_000, maxOutputBytes: 1_024, killGraceMs: 30 }, ); expect(result.timedOut).toBe(true); + // Empty output must never become PID 0 (our entire process group). + expect(result.stdout).toMatch(/^[1-9][0-9]*$/); const descendantPid = Number(result.stdout); expect(Number.isSafeInteger(descendantPid)).toBe(true); let alive = true; diff --git a/src/lib/v5/roadmap-sync.ts b/src/lib/v5/roadmap-sync.ts index 5e1c0a449..7f6f1aa8b 100644 --- a/src/lib/v5/roadmap-sync.ts +++ b/src/lib/v5/roadmap-sync.ts @@ -40,9 +40,17 @@ export function resolveSyncMarkerPath(cwd?: string): string { return join(resolveRepoRoot(cwd), '.genie', 'roadmap-sync'); } -/** Content hash over the canonical (whitespace-independent) JSON form. */ +/** Content hash over the canonical JSON form, independent of whitespace and object-key order. */ function canonicalHash(value: unknown): string { - return createHash('sha256').update(JSON.stringify(value)).digest('hex'); + const canonical = JSON.stringify(value, (_key, current: unknown) => { + if (current === null || Array.isArray(current) || typeof current !== 'object') return current; + return Object.fromEntries( + Object.keys(current) + .sort() + .map((key) => [key, (current as Record<string, unknown>)[key]]), + ); + }); + return createHash('sha256').update(canonical).digest('hex'); } /** diff --git a/src/lib/v5/task-state.test.ts b/src/lib/v5/task-state.test.ts index d90a067e6..076be19d6 100644 --- a/src/lib/v5/task-state.test.ts +++ b/src/lib/v5/task-state.test.ts @@ -1061,11 +1061,62 @@ describe('declared routing — roster allowlist + assignment state API (W1)', () describe('declared routing — roadmap snapshot round-trip (roadmap-sync lockstep)', () => { // Mirrors roadmap-sync's canonicalHash: sha256 over the parsed JSON form, so - // whitespace/formatting differences never count as content changes. + // whitespace and object-key order never count as content changes. function canonicalHash(value: unknown): string { - return createHash('sha256').update(JSON.stringify(value)).digest('hex'); + const canonical = JSON.stringify(value, (_key, current: unknown) => { + if (current === null || Array.isArray(current) || typeof current !== 'object') return current; + return Object.fromEntries( + Object.keys(current) + .sort() + .map((key) => [key, (current as Record<string, unknown>)[key]]), + ); + }); + return createHash('sha256').update(canonical).digest('hex'); } + test('an equal file/db pair refreshes an old order-sensitive marker without rewriting the snapshot', () => { + const repo = join(dir, 'hash-upgrade'); + mkdirSync(join(repo, '.genie'), { recursive: true }); + createTask(db, { title: 'existing card' }); + const snapshot = roadmapSnapshot(db); + const legacyHash = createHash('sha256').update(JSON.stringify(snapshot)).digest('hex'); + const filePath = join(repo, '.genie', 'roadmap.json'); + const markerPath = join(repo, '.genie', 'roadmap-sync'); + const content = `${JSON.stringify(snapshot, null, 2)}\n`; + writeFileSync(filePath, content); + writeFileSync(markerPath, JSON.stringify({ fileHash: legacyHash, dbHash: legacyHash })); + + expect(syncRoadmap(db, repo).action).toBe('none'); + expect(readFileSync(filePath, 'utf-8')).toBe(content); + const marker = JSON.parse(readFileSync(markerPath, 'utf-8')); + expect(marker.fileHash).not.toBe(legacyHash); + expect(marker.fileHash).toBe(marker.dbHash); + expect(syncRoadmap(db, repo).action).toBe('none'); + }); + + test('an old order-sensitive marker with pending edits refuses to overwrite either side', () => { + const repo = join(dir, 'hash-upgrade-pending'); + mkdirSync(join(repo, '.genie'), { recursive: true }); + createTask(db, { title: 'existing card' }); + const snapshot = roadmapSnapshot(db); + const legacyHash = createHash('sha256').update(JSON.stringify(snapshot)).digest('hex'); + const filePath = join(repo, '.genie', 'roadmap.json'); + const markerPath = join(repo, '.genie', 'roadmap-sync'); + const content = `${JSON.stringify(snapshot, null, 2)}\n`; + const marker = JSON.stringify({ fileHash: legacyHash, dbHash: legacyHash }); + writeFileSync(filePath, content); + writeFileSync(markerPath, marker); + const pending = createTask(db, { title: 'unpublished card' }); + + const result = syncRoadmap(db, repo); + expect(result.action).toBe('diverged'); + expect(result.message).toContain('genie task import --replace'); + expect(result.message).toContain('genie task export --write'); + expect(readFileSync(filePath, 'utf-8')).toBe(content); + expect(readFileSync(markerPath, 'utf-8')).toBe(marker); + expect(getTask(db, pending.id)?.title).toBe('unpublished card'); + }); + test('export carries assigned_agent/assigned_reason (SELECT *) and round-trips them through import', () => { const a = createTask(db, { title: 'a', assignedAgent: 'codex', assignedReason: 'dissent on parser' }); const snapshot = exportState(db); @@ -1332,6 +1383,89 @@ describe('hire roster (single-row upsert / delete)', () => { }); }); +describe('multi-process hire/unhire return race', () => { + test('every hire returns its complete row even when another process unhires it', async () => { + const dbPath = join(dir, 'hire-unhire.db'); + const seed = openDb({ path: dbPath }); + hireAgent(seed, { wish: 'race', agentAdapterId: 'adapter', worktree: '/wt/seed' }); + seed.close(); + const workerPath = join(dir, 'hire-unhire-worker.ts'); + writeFileSync( + workerPath, + ` +import { openDb } from ${JSON.stringify(join(import.meta.dir, 'genie-db.ts'))}; +import { hireAgent, unhireAgent } from ${JSON.stringify(join(import.meta.dir, 'task-state.ts'))}; +const [dbPath, op] = process.argv.slice(2); +const db = openDb({ path: dbPath }); +process.stdout.write('ready'); +await Bun.stdin.text(); +let invalid = 0; +let removed = 0; +try { + for (let i = 0; i < 3000; i++) { + if (op === 'unhire') { + if (unhireAgent(db, 'race', 'adapter')) removed++; + } else { + const row = hireAgent(db, { + wish: 'race', agentAdapterId: 'adapter', profile: 'profile-' + i, + worktree: '/wt/' + i, state: 'active', + }); + if (!row || row.wish !== 'race' || row.agentAdapterId !== 'adapter' || + row.profile !== 'profile-' + i || row.worktree !== '/wt/' + i || + row.state !== 'active' || !Number.isInteger(row.hiredAt) || row.hiredAt <= 0) invalid++; + } + } + process.stdout.write(JSON.stringify({ invalid, removed })); +} finally { + db.close(); +} +`, + ); + const workers = ['hire', 'unhire'].map((op) => + Bun.spawn(['bun', 'run', workerPath, dbPath, op], { + stdin: 'pipe', + stdout: 'pipe', + stderr: 'pipe', + env: { ...process.env, HOME: dir, GENIE_HOME: join(dir, '.genie') }, + }), + ); + try { + // Both handles are open before either loop begins, so startup cannot + // serialize away the race. The child waits for stdin EOF after readiness. + await Promise.all( + workers.map(async (worker) => { + const reader = worker.stdout.getReader(); + const ready = await reader.read(); + reader.releaseLock(); + expect(new TextDecoder().decode(ready.value)).toBe('ready'); + }), + ); + for (const worker of workers) worker.stdin.end(); + const results = await Promise.all( + workers.map(async (worker) => { + const reader = worker.stdout.getReader(); + let output = ''; + while (true) { + const { value, done } = await reader.read(); + if (done) break; + output += new TextDecoder().decode(value); + } + return { output, stderr: await new Response(worker.stderr).text(), code: await worker.exited }; + }), + ); + for (const result of results) { + expect(result.code).toBe(0); + expect(result.stderr).toBe(''); + } + expect(JSON.parse(results[0].output).invalid).toBe(0); + expect(JSON.parse(results[1].output).removed).toBeGreaterThan(0); + } finally { + for (const worker of workers) worker.kill(); + await Promise.all(workers.map((worker) => worker.exited)); + } + }, 30_000); +}); + // --------------------------------------------------------------------------- // Multi-PROCESS roster write vs task-create race: concurrent bun processes hire // agents and create tasks against the same on-disk WAL database. Every writer diff --git a/src/lib/v5/task-state.ts b/src/lib/v5/task-state.ts index a036d1a9f..2a68447d5 100644 --- a/src/lib/v5/task-state.ts +++ b/src/lib/v5/task-state.ts @@ -102,6 +102,8 @@ export interface BoardRow { name: string; /** Ordered lifecycle lanes, or null for a laneless (execution-status) board. */ lanes: Lane[] | null; + /** True only when a non-null stored lane definition is not JSON-array data. */ + laneMetadataMalformed: boolean; createdAt: number; } @@ -205,6 +207,34 @@ export interface TaskEvent { createdAt: number; } +/** A dependency summary embedded in the complete board JSON snapshot. */ +export interface BoardTaskDependency { + id: string; + title: string; + status: TaskStatus; +} + +/** A non-empty comment projection embedded in the complete board JSON snapshot. */ +export interface BoardTaskComment { + id: number; + note: string; + authorKind: string | null; + author: string | null; + createdAt: number; +} + +/** + * Complete, closed card contract for one scoped board JSON snapshot. Unlike + * TaskRow and the human projections, this type deliberately includes every + * detail needed by an external board client. + */ +export interface BoardTaskAggregate extends TaskCardRow { + liveness: Liveness | null; + dependencies: BoardTaskDependency[]; + timeline: Array<Omit<TaskEvent, 'taskId'>>; + comments: BoardTaskComment[]; +} + export interface AppendEventInput { kind: string; note?: string; @@ -555,19 +585,26 @@ interface RawBoardRow { created_at: number; } -/** Parse the stored lanes JSON back into `Lane[]`, tolerating malformed data. */ -function parseLanes(raw: string | null): Lane[] | null { - if (raw == null) return null; +/** Parse stored lane JSON while retaining whether non-null metadata was malformed. */ +function parseLanes(raw: string | null): { lanes: Lane[] | null; malformed: boolean } { + if (raw == null) return { lanes: null, malformed: false }; try { const parsed = JSON.parse(raw); - return Array.isArray(parsed) ? (parsed as Lane[]) : null; + return Array.isArray(parsed) ? { lanes: parsed as Lane[], malformed: false } : { lanes: null, malformed: true }; } catch { - return null; + return { lanes: null, malformed: true }; } } function mapBoard(row: RawBoardRow): BoardRow { - return { id: row.id, name: row.name, lanes: parseLanes(row.lanes), createdAt: row.created_at }; + const parsed = parseLanes(row.lanes); + return { + id: row.id, + name: row.name, + lanes: parsed.lanes, + laneMetadataMalformed: parsed.malformed, + createdAt: row.created_at, + }; } /** @@ -583,7 +620,7 @@ export function createBoard(db: Database, name: string, lanes?: Lane[]): BoardRo const normalizedLanes = lanes && lanes.length > 0 ? lanes : null; const lanesJson = normalizedLanes ? JSON.stringify(normalizedLanes) : null; db.query('INSERT INTO boards (id, name, lanes, created_at) VALUES (?, ?, ?, ?)').run(id, name, lanesJson, createdAt); - return { id, name, lanes: normalizedLanes, createdAt }; + return { id, name, lanes: normalizedLanes, laneMetadataMalformed: false, createdAt }; } export function getBoard(db: Database, id: string): BoardRow | null { @@ -1348,6 +1385,161 @@ export function commentCounts(db: Database): Map<string, number> { return new Map(rows.map((r) => [r.task_id, r.n])); } +/** Bounded display-only identifier; persisted values and payloads stay unchanged. */ +export function boardDetailIdentifier(value: string | number): string { + const text = String(value).replace(/[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]/gu, '?'); + return text.length > 48 ? `${text.slice(0, 45)}...` : text; +} + +interface RawBoardDependency { + task_id: string; + id: string | null; + title: string | null; + status: string | null; +} + +function requireTaskStatus(value: string | null, context: string): TaskStatus { + if (value === 'blocked' || value === 'ready' || value === 'in_progress' || value === 'done') return value; + throw new Error(`Malformed board detail: ${context} has invalid status.`); +} + +function requireString(value: unknown, context: string): string { + if (typeof value === 'string') return value; + throw new Error(`Malformed board detail: ${context} must be a string.`); +} + +function requireNullableString(value: unknown, context: string): string | null { + if (value === null || typeof value === 'string') return value; + throw new Error(`Malformed board detail: ${context} must be a string or null.`); +} + +function requireNumber(value: unknown, context: string): number { + if (typeof value === 'number' && Number.isFinite(value)) return value; + throw new Error(`Malformed board detail: ${context} must be a finite number.`); +} + +function mapAggregateTask(row: RawTask): TaskCardRow { + const id = requireString(row.id, 'task id'); + const blockedBy = requireNullableString(row.blocked_by, `task ${boardDetailIdentifier(id)} blockedBy`); + const blockedReason = requireNullableString(row.blocked_reason, `task ${boardDetailIdentifier(id)} blockedReason`); + const blockKind = requireNullableString(row.block_kind, `task ${boardDetailIdentifier(id)} block kind`); + if (blockKind !== null && blockKind !== 'work' && blockKind !== 'hold') { + throw new Error(`Malformed board detail: task ${boardDetailIdentifier(id)} has invalid block kind.`); + } + return { + id, + boardId: requireNullableString(row.board_id, `task ${boardDetailIdentifier(id)} boardId`), + title: requireString(row.title, `task ${boardDetailIdentifier(id)} title`), + status: requireTaskStatus(row.status, `task ${boardDetailIdentifier(id)}`), + claimedBy: requireNullableString(row.claimed_by, `task ${boardDetailIdentifier(id)} claimedBy`), + claimedAt: + row.claimed_at === null ? null : requireNumber(row.claimed_at, `task ${boardDetailIdentifier(id)} claimedAt`), + wish: requireNullableString(row.wish, `task ${boardDetailIdentifier(id)} wish`), + group: requireNullableString(row.group_name, `task ${boardDetailIdentifier(id)} group`), + assignedAgent: requireNullableString(row.assigned_agent, `task ${boardDetailIdentifier(id)} assignedAgent`), + assignedReason: requireNullableString(row.assigned_reason, `task ${boardDetailIdentifier(id)} assignedReason`), + createdAt: requireNumber(row.created_at, `task ${boardDetailIdentifier(id)} createdAt`), + updatedAt: requireNumber(row.updated_at, `task ${boardDetailIdentifier(id)} updatedAt`), + lane: requireNullableString(row.lane, `task ${boardDetailIdentifier(id)} lane`), + agentKind: requireNullableString(row.agent_kind, `task ${boardDetailIdentifier(id)} agentKind`), + heartbeatAt: + row.heartbeat_at === null + ? null + : requireNumber(row.heartbeat_at, `task ${boardDetailIdentifier(id)} heartbeatAt`), + blockedBy, + blockedReason, + enforcedBlock: blockedBy === null ? null : { reason: blockedReason ?? '', kind: blockKind ?? 'work' }, + }; +} + +/** + * Read every detail for a board's cards from one SQLite snapshot. All rows are + * fetched in three set queries inside one deferred read transaction: cards, + * their dependency summaries, and their timelines. No caller hydrates cards + * individually, and any malformed joined detail aborts the whole snapshot. + */ +export function readBoardTaskSnapshot( + db: Database, + boardId: string, + filter: Pick<TaskFilter, 'wish'> = {}, + now = Date.now(), +): BoardTaskAggregate[] { + const read = db.transaction((): BoardTaskAggregate[] => { + const params = filter.wish ? [boardId, filter.wish] : [boardId]; + const tasks = db + .query(`SELECT * FROM tasks WHERE board_id = ?${filter.wish ? ' AND wish = ?' : ''} ORDER BY created_at, rowid`) + .all(...params) as RawTask[]; + if (tasks.length === 0) return []; + // One JSON binding avoids variable limits while retaining task_id index probes. + const taskIds = JSON.stringify(tasks.map((row) => requireString(row.id, 'task id'))); + const dependencies = db + .query( + `SELECT td.task_id, dep.id, dep.title, dep.status + FROM task_dependencies td + LEFT JOIN tasks dep ON dep.id = td.depends_on_id + WHERE td.task_id IN (SELECT value FROM json_each(?)) + ORDER BY td.task_id, dep.id`, + ) + .all(taskIds) as RawBoardDependency[]; + const events = db + .query( + `SELECT e.* FROM task_events e + WHERE e.task_id IN (SELECT value FROM json_each(?)) + ORDER BY e.task_id, e.created_at, e.id`, + ) + .all(taskIds) as RawTaskEvent[]; + + const dependenciesByTask = new Map<string, BoardTaskDependency[]>(); + for (const dependency of dependencies) { + const taskId = requireString(dependency.task_id, 'dependency owner id'); + const id = requireString(dependency.id, `task ${boardDetailIdentifier(taskId)} dependency id`); + const summaries = dependenciesByTask.get(taskId) ?? []; + summaries.push({ + id, + title: requireString(dependency.title, `dependency ${boardDetailIdentifier(id)} title`), + status: requireTaskStatus(dependency.status, `dependency ${boardDetailIdentifier(id)}`), + }); + dependenciesByTask.set(taskId, summaries); + } + + const timelineByTask = new Map<string, Array<Omit<TaskEvent, 'taskId'>>>(); + for (const row of events) { + const taskId = requireString(row.task_id, 'timeline task id'); + const event = { + id: requireNumber(row.id, `task ${boardDetailIdentifier(taskId)} event id`), + kind: requireString(row.kind, `task ${boardDetailIdentifier(taskId)} event kind`), + note: requireNullableString(row.note, `task ${boardDetailIdentifier(taskId)} event note`), + authorKind: requireNullableString(row.author_kind, `task ${boardDetailIdentifier(taskId)} event authorKind`), + author: requireNullableString(row.author, `task ${boardDetailIdentifier(taskId)} event author`), + createdAt: requireNumber(row.created_at, `task ${boardDetailIdentifier(taskId)} event createdAt`), + }; + if (event.kind === 'comment' && event.note === null) { + throw new Error( + `Malformed board detail: task ${boardDetailIdentifier(taskId)} comment ${boardDetailIdentifier(event.id)} has null text.`, + ); + } + const timeline = timelineByTask.get(taskId) ?? []; + timeline.push(event); + timelineByTask.set(taskId, timeline); + } + + return tasks.map((row) => { + const card = mapAggregateTask(row); + const timeline = timelineByTask.get(card.id) ?? []; + return { + ...card, + liveness: card.claimedBy === null ? null : livenessFromHeartbeat(card.heartbeatAt, now), + dependencies: dependenciesByTask.get(card.id) ?? [], + timeline, + comments: timeline + .filter((event): event is typeof event & { note: string } => event.kind === 'comment' && event.note !== null) + .map(({ id, note, authorKind, author, createdAt }) => ({ id, note, authorKind, author, createdAt })), + }; + }); + }); + return read.deferred() as BoardTaskAggregate[]; +} + // ============================================================================ // Lane moves // ============================================================================ @@ -1520,22 +1712,26 @@ function mapHire(row: RawHire): HireRosterRow { * `(wish, agent_adapter_id)`: a re-hire refreshes profile/worktree/state but * preserves the original `hired_at` by OMITTING `hired_at` from the `ON CONFLICT * DO UPDATE SET` list — an unset column keeps its stored value, so the first - * hire's timestamp survives every re-hire and the call converges on one row. A - * single statement is atomic on its own; the WAL + busy_timeout the handle - * carries (see sqlite-open.ts) serializes it against concurrent writers. + * hire's timestamp survives every re-hire and the call converges on one row. + * RETURNING captures the result inside that same write statement, so a racing + * unhire cannot remove the row between the upsert and a separate result read. + * WAL + busy_timeout (see sqlite-open.ts) serializes concurrent writers. */ export function hireAgent(db: Database, input: HireAgentInput): HireRosterRow { const now = Date.now(); const state = input.state ?? 'hired'; - db.query( - `INSERT INTO hire_roster (wish, agent_adapter_id, profile, worktree, hired_at, state) + const row = db + .query( + `INSERT INTO hire_roster (wish, agent_adapter_id, profile, worktree, hired_at, state) VALUES (?, ?, ?, ?, ?, ?) ON CONFLICT(wish, agent_adapter_id) DO UPDATE SET profile = excluded.profile, worktree = excluded.worktree, - state = excluded.state`, - ).run(input.wish, input.agentAdapterId, input.profile ?? null, input.worktree, now, state); - return getHire(db, input.wish, input.agentAdapterId) as HireRosterRow; + state = excluded.state + RETURNING *`, + ) + .get(input.wish, input.agentAdapterId, input.profile ?? null, input.worktree, now, state) as RawHire; + return mapHire(row); } /** diff --git a/src/term-commands/v5-board.test.ts b/src/term-commands/v5-board.test.ts index 1e19bc75e..a110ca6e1 100644 --- a/src/term-commands/v5-board.test.ts +++ b/src/term-commands/v5-board.test.ts @@ -4,6 +4,7 @@ * on the next render with nothing persisted. Exit codes AND stderr are checked. */ +import type { Database, SQLQueryBindings } from 'bun:sqlite'; import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; import { execFileSync } from 'node:child_process'; import { @@ -23,6 +24,7 @@ import { DEFAULT_LIFECYCLE_LANES, LIVENESS_RUNNING_MS, LIVENESS_STALE_MS, + addDependency, appendTaskEvent, blockTask, claimTask, @@ -32,6 +34,7 @@ import { getTaskEvents, getTaskLane, moveTask, + readBoardTaskSnapshot, recordHeartbeat, } from '../lib/v5/task-state.js'; @@ -76,6 +79,13 @@ async function board(cwd: string, ...args: string[]): Promise<CliResult> { return boardWithEnv(cwd, {}, ...args); } +function expectMalformedBoard(result: CliResult): void { + expect(result.code).toBe(1); + expect(result.stdout).toBe(''); + expect(result.stderr).toMatch(/^Error: Malformed board detail: [^\n]+\n$/); + expect(result.stderr.length).toBeLessThan(240); +} + async function manualTask(cwd: string, ...args: string[]): Promise<CliResult> { const proc = Bun.spawn(['bun', GENIE, 'task', ...args], { cwd, @@ -161,18 +171,35 @@ describe('board render', () => { test('--json emits columns keyed by status', async () => { const db = openDb({ cwd: repo }); - createTask(db, { title: 'ready-1' }); + const task = createTask(db, { title: 'ready-1' }); db.close(); const r = await board(repo, '--json'); expect(r.code).toBe(0); - const payload = JSON.parse(r.stdout) as { - scope: string; - columns: Record<string, Array<{ title: string }>>; + expect(r.stderr).toBe(''); + const expected = { + scope: 'all tasks', + columns: { + blocked: [], + ready: [ + { + id: task.id, + boardId: null, + title: 'ready-1', + status: 'ready', + claimedBy: null, + claimedAt: null, + wish: null, + group: null, + createdAt: task.createdAt, + updatedAt: task.updatedAt, + }, + ], + in_progress: [], + done: [], + }, }; - expect(payload.columns.ready).toHaveLength(1); - expect(payload.columns.ready[0].title).toBe('ready-1'); - expect(payload.columns.blocked).toHaveLength(0); + expect(r.stdout).toBe(`${JSON.stringify(expected, null, 2)}\n`); }); }); @@ -241,8 +268,10 @@ describe('board list', () => { test('reports lane count and card count per board', async () => { const db = openDb({ cwd: repo }); const road = createBoard(db, 'roadmap', DEFAULT_LIFECYCLE_LANES); - createBoard(db, 'plain'); + const plain = createBoard(db, 'plain'); createTask(db, { title: 'c1', boardId: road.id }); + db.query('UPDATE boards SET created_at = 10 WHERE id = ?').run(road.id); + db.query('UPDATE boards SET created_at = 20 WHERE id = ?').run(plain.id); db.close(); const r = await board(repo, 'list'); @@ -252,11 +281,18 @@ describe('board list', () => { expect(r.stdout).toContain('2 boards'); const j = await board(repo, 'list', '--json'); - const rows = JSON.parse(j.stdout) as Array<{ name: string; laneCount: number; cardCount: number }>; - const road2 = rows.find((x) => x.name === 'roadmap'); - expect(road2?.laneCount).toBe(6); - expect(road2?.cardCount).toBe(1); - expect(rows.find((x) => x.name === 'plain')?.laneCount).toBe(0); + expect(j.code).toBe(0); + expect(j.stderr).toBe(''); + expect(j.stdout).toBe( + `${JSON.stringify( + [ + { id: road.id, name: 'roadmap', laneCount: 6, cardCount: 1 }, + { id: plain.id, name: 'plain', laneCount: 0, cardCount: 0 }, + ], + null, + 2, + )}\n`, + ); }); test('reports "No boards found." on an empty repo', async () => { @@ -267,6 +303,44 @@ describe('board list', () => { }); describe('lane-grouped render', () => { + test('keeps the human empty-lane output byte-exact', async () => { + const db = openDb({ cwd: repo }); + createBoard(db, 'roadmap', DEFAULT_LIFECYCLE_LANES); + db.close(); + + const result = await board(repo, '--board', 'roadmap'); + expect(result).toEqual({ + code: 0, + stderr: '', + stdout: [ + '', + 'Board — board "roadmap"', + '═'.repeat(56), + ' Idea: 0 Brainstorm: 0 Wish: 0 Work: 0 Review: 0 Done: 0', + '', + '── Idea → /brainstorm (0 cards) ──', + ' (empty)', + '', + '── Brainstorm → /wish (0 cards) ──', + ' (empty)', + '', + '── Wish → /work (0 cards) ──', + ' (empty)', + '', + '── Work → /review (0 cards) ──', + ' (empty)', + '', + '── Review (0 cards) ──', + ' (empty)', + '', + '── Done (0 cards) ──', + ' (empty)', + '', + '', + ].join('\n'), + }); + }); + test('groups by lane and prints action hints; a moved card lands in its lane', async () => { const db = openDb({ cwd: repo }); const road = createBoard(db, 'roadmap', DEFAULT_LIFECYCLE_LANES); @@ -319,7 +393,7 @@ describe('lane-grouped render', () => { expect(payload.lanes.map((l) => l.name)).toEqual(['Idea', 'Brainstorm', 'Wish', 'Work', 'Review', 'Done']); }); - test('--json carries enforcedBlock and the declared routing on every lane card and nothing else from the runtime layer', async () => { + test('--json carries the complete exact card aggregate and explicit nullability', async () => { const db = openDb({ cwd: repo }); const road = createBoard(db, 'roadmap', DEFAULT_LIFECYCLE_LANES); createTask(db, { title: 'open card', boardId: road.id, lane: 'Idea' }); @@ -334,7 +408,7 @@ describe('lane-grouped render', () => { }); blockTask(db, held.id, 'parked until Q3', { author: 'felipe', authorKind: 'human' }, 'hold'); blockTask(db, broken.id, 'awaiting a decision', { author: 'felipe', authorKind: 'human' }); - recordHeartbeat(db, held.id); // a runtime field that must NOT reach this shape + recordHeartbeat(db, held.id); db.close(); const r = await board(repo, '--board', 'roadmap', '--json'); @@ -356,23 +430,48 @@ describe('lane-grouped render', () => { expect(cards.get('assigned card')?.assignedAgent).toBe('codex'); expect(cards.get('assigned card')?.assignedReason).toBe('declared routing'); - // The lane shape carries the two declared-routing fields plus exactly one - // runtime field — provenance, identity, and heartbeat siblings stay off it. - for (const leaked of ['agentKind', 'heartbeatAt', 'blockedBy', 'blockedReason']) { - expect(leaked in (cards.get('held card') as Record<string, unknown>)).toBe(false); - } + expect(cards.get('open card')).toMatchObject({ + claimedBy: null, + claimedAt: null, + wish: null, + group: null, + lane: 'Idea', + agentKind: null, + heartbeatAt: null, + liveness: null, + blockedBy: null, + blockedReason: null, + enforcedBlock: null, + dependencies: [], + timeline: [], + comments: [], + }); + expect(cards.get('held card')).toMatchObject({ + blockedBy: 'felipe', + blockedReason: 'parked until Q3', + enforcedBlock: { reason: 'parked until Q3', kind: 'hold' }, + liveness: null, + }); expect(Object.keys(cards.get('held card') as Record<string, unknown>).sort()).toEqual([ + 'agentKind', 'assignedAgent', 'assignedReason', + 'blockedBy', + 'blockedReason', 'boardId', 'claimedAt', 'claimedBy', + 'comments', 'createdAt', + 'dependencies', 'enforcedBlock', 'group', + 'heartbeatAt', 'id', 'lane', + 'liveness', 'status', + 'timeline', 'title', 'updatedAt', 'wish', @@ -380,6 +479,501 @@ describe('lane-grouped render', () => { }); }); +describe('scoped board JSON aggregate v1', () => { + test('freezes the exact outer shape and returns every lane for an empty board', async () => { + const db = openDb({ cwd: repo }); + createBoard(db, 'empty', DEFAULT_LIFECYCLE_LANES); + db.close(); + + const result = await board(repo, '--board', 'empty', '--json'); + expect(result.code).toBe(0); + expect(result.stderr).toBe(''); + const payload = JSON.parse(result.stdout) as Record<string, unknown> & { + lanes: Array<{ name: string; label: string | null; action: string | null; cards: unknown[] }>; + }; + expect(Object.keys(payload)).toEqual(['schemaVersion', 'scope', 'lanes']); + expect(payload.schemaVersion).toBe(1); + expect(payload.scope).toBe('board "empty"'); + expect(payload.lanes.map((lane) => lane.name)).toEqual(['Idea', 'Brainstorm', 'Wish', 'Work', 'Review', 'Done']); + for (const lane of payload.lanes) { + expect(Object.keys(lane)).toEqual(['name', 'label', 'action', 'cards']); + expect(lane.cards).toEqual([]); + } + }); + + test('orders cards, dependencies, timeline, and comment projection deterministically', async () => { + const db = openDb({ cwd: repo }); + const roadmap = createBoard(db, 'roadmap', DEFAULT_LIFECYCLE_LANES); + const depZ = createTask(db, { title: 'dependency z', boardId: roadmap.id, lane: 'Idea' }); + const depA = createTask(db, { title: 'dependency a', boardId: roadmap.id, lane: 'Idea' }); + const cardB = createTask(db, { title: 'card b', boardId: roadmap.id, lane: 'Work' }); + const cardA = createTask(db, { title: 'card a', boardId: roadmap.id, lane: 'Work' }); + // Equal timestamps deliberately oppose lexical id order: existing board + // readers preserve insertion order, so z-card must remain before a-card. + db.query('UPDATE tasks SET id = ? WHERE id = ?').run('z-dependency', depZ.id); + db.query('UPDATE tasks SET id = ? WHERE id = ?').run('a-dependency', depA.id); + db.query('UPDATE tasks SET id = ?, created_at = 10 WHERE id = ?').run('z-card', cardB.id); + db.query('UPDATE tasks SET id = ?, created_at = 10 WHERE id = ?').run('a-card', cardA.id); + // This access path sorts equal timestamps by id unless the aggregate + // explicitly requests its insertion-order tie-break. + db.run('CREATE INDEX test_board_created_id ON tasks(board_id, created_at, id)'); + addDependency(db, 'a-card', 'z-dependency'); + addDependency(db, 'a-card', 'a-dependency'); + // Insert same-time ids in descending order. Timeline and its comment + // projection must sort by createdAt then id, independently of insertion. + db.query( + `INSERT INTO task_events (id, task_id, kind, note, author_kind, author, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + ).run(90, 'a-card', 'comment', 'second comment', 'human', 'felipe', 20); + db.query( + `INSERT INTO task_events (id, task_id, kind, note, author_kind, author, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + ).run(40, 'a-card', 'move', 'Idea→Work', null, null, 20); + db.query( + `INSERT INTO task_events (id, task_id, kind, note, author_kind, author, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + ).run(10, 'a-card', 'comment', 'first comment', null, null, 20); + db.close(); + + const result = await board(repo, '--board', 'roadmap', '--json'); + expect(result.code).toBe(0); + expect(result.stderr).toBe(''); + const payload = JSON.parse(result.stdout) as { + lanes: Array<{ + name: string; + cards: Array< + Record<string, unknown> & { + id: string; + dependencies: Array<{ id: string; title: string; status: string }>; + timeline: Array<{ id: number; kind: string; note: string | null; createdAt: number }>; + comments: Array<{ + id: number; + note: string; + authorKind: string | null; + author: string | null; + createdAt: number; + }>; + } + >; + }>; + }; + const work = payload.lanes.find((lane) => lane.name === 'Work'); + expect(work?.cards.map((card) => card.id)).toEqual(['z-card', 'a-card']); + const card = work?.cards.find((candidate) => candidate.id === 'a-card'); + expect(card).toBeDefined(); + if (!card) throw new Error('expected card a in Work lane'); + expect(card.dependencies.map((dependency) => dependency.id)).toEqual(['a-dependency', 'z-dependency']); + for (const dependency of card.dependencies) { + expect(Object.keys(dependency)).toEqual(['id', 'title', 'status']); + } + expect(card.timeline.map((event) => event.id)).toEqual([10, 40, 90]); + for (const event of card.timeline) { + expect(Object.keys(event)).toEqual(['id', 'kind', 'note', 'authorKind', 'author', 'createdAt']); + } + expect(card.comments).toEqual([ + { id: 10, note: 'first comment', authorKind: null, author: null, createdAt: 20 }, + { + id: 90, + note: 'second comment', + authorKind: 'human', + author: 'felipe', + createdAt: 20, + }, + ]); + }); + + test('derives running, idle, stale, missing-heartbeat, and unclaimed liveness exactly', async () => { + const db = openDb({ cwd: repo }); + const roadmap = createBoard(db, 'roadmap', DEFAULT_LIFECYCLE_LANES); + const now = Date.now(); + const running = createTask(db, { title: 'running', boardId: roadmap.id, lane: 'Work' }); + const idle = createTask(db, { title: 'idle', boardId: roadmap.id, lane: 'Work' }); + const stale = createTask(db, { title: 'stale', boardId: roadmap.id, lane: 'Work' }); + const missing = createTask(db, { title: 'missing heartbeat', boardId: roadmap.id, lane: 'Work' }); + const open = createTask(db, { title: 'open with heartbeat', boardId: roadmap.id, lane: 'Work' }); + for (const task of [running, idle, stale, missing]) claimTask(db, task.id, 'worker'); + recordHeartbeat(db, running.id, now); + recordHeartbeat(db, idle.id, now - LIVENESS_RUNNING_MS - 60_000); + recordHeartbeat(db, stale.id, now - LIVENESS_STALE_MS - 60_000); + recordHeartbeat(db, open.id, now); + db.close(); + + const result = await board(repo, '--board', 'roadmap', '--json'); + const cards = ( + JSON.parse(result.stdout) as { lanes: Array<{ cards: Array<Record<string, unknown>> }> } + ).lanes.flatMap((lane) => lane.cards); + expect(cards.find((card) => card.id === running.id)?.liveness).toBe('running'); + expect(cards.find((card) => card.id === idle.id)?.liveness).toBe('idle'); + expect(cards.find((card) => card.id === stale.id)?.liveness).toBe('stale'); + expect(cards.find((card) => card.id === missing.id)?.liveness).toBe('stale'); + expect(cards.find((card) => card.id === open.id)?.liveness).toBeNull(); + }); + + test('is byte-idempotent after the complete snapshot has been established', async () => { + const db = openDb({ cwd: repo }); + const roadmap = createBoard(db, 'roadmap', DEFAULT_LIFECYCLE_LANES); + createTask(db, { title: 'stable', boardId: roadmap.id, lane: 'Idea' }); + db.close(); + + const first = await board(repo, '--board', 'roadmap', '--json'); + const second = await board(repo, '--board', 'roadmap', '--json'); + expect(first).toEqual({ code: 0, stderr: '', stdout: second.stdout }); + expect(second.code).toBe(0); + expect(second.stderr).toBe(''); + }); + + const corruptions: Array<{ + name: string; + corrupt: (db: Database, boardId: string, taskId: string) => void; + }> = [ + { + name: 'card title scalar', + corrupt: (db, _boardId, taskId) => db.query("UPDATE tasks SET title = X'01' WHERE id = ?").run(taskId), + }, + { + name: 'card status enum', + corrupt: (db, _boardId, taskId) => { + db.exec('PRAGMA ignore_check_constraints = ON'); + db.query("UPDATE tasks SET status = 'unknown' WHERE id = ?").run(taskId); + }, + }, + { + name: 'nullable card claimant scalar', + corrupt: (db, _boardId, taskId) => db.query("UPDATE tasks SET claimed_by = X'01' WHERE id = ?").run(taskId), + }, + { + name: 'dependency title scalar', + corrupt: (db, _boardId, taskId) => { + const dependency = createTask(db, { title: 'outside dependency' }); + addDependency(db, taskId, dependency.id); + db.query("UPDATE tasks SET title = X'01' WHERE id = ?").run(dependency.id); + }, + }, + { + name: 'dependency status enum', + corrupt: (db, _boardId, taskId) => { + const dependency = createTask(db, { title: 'outside dependency' }); + addDependency(db, taskId, dependency.id); + db.exec('PRAGMA ignore_check_constraints = ON'); + db.query("UPDATE tasks SET status = 'unknown' WHERE id = ?").run(dependency.id); + }, + }, + { + name: 'orphan dependency', + corrupt: (db, _boardId, taskId) => { + db.exec('PRAGMA foreign_keys = OFF'); + db.query('INSERT INTO task_dependencies (task_id, depends_on_id) VALUES (?, ?)').run(taskId, 'missing-task'); + }, + }, + { + name: 'timeline kind scalar', + corrupt: (db, _boardId, taskId) => { + const event = appendTaskEvent(db, taskId, { kind: 'move' }); + db.query("UPDATE task_events SET kind = X'01' WHERE id = ?").run(event.id); + }, + }, + { + name: 'nullable timeline author scalar', + corrupt: (db, _boardId, taskId) => { + const event = appendTaskEvent(db, taskId, { kind: 'move' }); + db.query("UPDATE task_events SET author = X'01' WHERE id = ?").run(event.id); + }, + }, + { + name: 'timeline timestamp scalar', + corrupt: (db, _boardId, taskId) => { + const event = appendTaskEvent(db, taskId, { kind: 'move' }); + db.query("UPDATE task_events SET created_at = X'01' WHERE id = ?").run(event.id); + }, + }, + { + name: 'null comment text', + corrupt: (db, _boardId, taskId) => { + appendTaskEvent(db, taskId, { kind: 'comment' }); + }, + }, + ]; + + for (const fixture of corruptions) { + test(`fails closed for malformed ${fixture.name}`, async () => { + const db = openDb({ cwd: repo }); + const roadmap = createBoard(db, 'roadmap', DEFAULT_LIFECYCLE_LANES); + const task = createTask(db, { title: 'corrupt target', boardId: roadmap.id, lane: 'Idea' }); + fixture.corrupt(db, roadmap.id, task.id); + db.close(); + + expectMalformedBoard(await board(repo, '--board', 'roadmap', '--json')); + }); + } + + const malformedLanes = [ + ['invalid JSON', '{'], + ['non-array JSON', '{}'], + ['non-object entry', '[null]'], + ['missing name', '[{}]'], + ['wrong name', '[{"name":7}]'], + ['wrong label', '[{"name":"Idea","label":7}]'], + ['null label', '[{"name":"Idea","label":null}]'], + ['wrong action', '[{"name":"Idea","action":7}]'], + ['null action', '[{"name":"Idea","action":null}]'], + ] as const; + + for (const [name, lanes] of malformedLanes) { + test(`fails closed for ${name} lane metadata`, async () => { + const db = openDb({ cwd: repo }); + const roadmap = createBoard(db, 'roadmap', DEFAULT_LIFECYCLE_LANES); + db.query('UPDATE boards SET lanes = ? WHERE id = ?').run(lanes, roadmap.id); + db.close(); + + expectMalformedBoard(await board(repo, '--board', 'roadmap', '--json')); + }); + } + + test('an unknown board JSON read fails with exit 1, empty stdout, and clear stderr', async () => { + const result = await board(repo, '--board', 'ghost', '--json'); + expect(result).toEqual({ stdout: '', stderr: 'Error: Board not found: ghost\n', code: 1 }); + }); +}); + +describe('board aggregate repository snapshot', () => { + function instrumentReader( + reader: Database, + afterFirstSetRead?: () => void, + ): { + counts: { queries: number; transactions: number; deferred: number; outsideTransaction: number }; + } { + const counts = { queries: 0, transactions: 0, deferred: 0, outsideTransaction: 0 }; + const originalQuery = reader.query.bind(reader); + const originalTransaction = reader.transaction.bind(reader); + + Object.defineProperty(reader, 'query', { + configurable: true, + value: (sql: string) => { + counts.queries += 1; + if (!reader.inTransaction) counts.outsideTransaction += 1; + const queryNumber = counts.queries; + const statement = originalQuery(sql); + return new Proxy(statement, { + get(target, property) { + const value = Reflect.get(target, property, target); + if (property === 'all') { + return (...args: unknown[]) => { + const rows = Reflect.apply(value as (...values: unknown[]) => unknown, target, args); + if (queryNumber === 1) afterFirstSetRead?.(); + return rows; + }; + } + return typeof value === 'function' ? value.bind(target) : value; + }, + }); + }, + }); + Object.defineProperty(reader, 'transaction', { + configurable: true, + value: (callback: () => unknown) => { + counts.transactions += 1; + const transaction = originalTransaction(callback); + return { + deferred: () => { + counts.deferred += 1; + return transaction.deferred(); + }, + }; + }, + }); + return { counts }; + } + + test('uses three constant set queries in one deferred transaction regardless of card count', () => { + const writer = openDb({ cwd: repo }); + const roadmap = createBoard(writer, 'roadmap', DEFAULT_LIFECYCLE_LANES); + createTask(writer, { title: 'first', boardId: roadmap.id, lane: 'Idea' }); + const reader = openDb({ cwd: repo }); + const { counts } = instrumentReader(reader); + + expect(readBoardTaskSnapshot(reader, roadmap.id)).toHaveLength(1); + expect(counts).toEqual({ queries: 3, transactions: 1, deferred: 1, outsideTransaction: 0 }); + + for (let index = 0; index < 24; index += 1) { + createTask(writer, { title: `card ${index}`, boardId: roadmap.id, lane: 'Idea' }); + } + expect(readBoardTaskSnapshot(reader, roadmap.id)).toHaveLength(25); + expect(counts).toEqual({ queries: 6, transactions: 2, deferred: 2, outsideTransaction: 0 }); + reader.close(); + writer.close(); + }); + + test('retains one SQLite snapshot when a peer writes between set reads', () => { + const writer = openDb({ cwd: repo }); + const roadmap = createBoard(writer, 'roadmap', DEFAULT_LIFECYCLE_LANES); + const dependency = createTask(writer, { title: 'before peer write' }); + const card = createTask(writer, { title: 'snapshot card', boardId: roadmap.id, lane: 'Work' }); + addDependency(writer, card.id, dependency.id); + const reader = openDb({ cwd: repo }); + const { counts } = instrumentReader(reader, () => { + writer.query('UPDATE tasks SET title = ? WHERE id = ?').run('after peer write', dependency.id); + appendTaskEvent(writer, card.id, { kind: 'comment', note: 'after peer write' }); + }); + + const [snapshot] = readBoardTaskSnapshot(reader, roadmap.id); + expect(snapshot.dependencies).toEqual([{ id: dependency.id, title: 'before peer write', status: 'ready' }]); + expect(snapshot.timeline).toEqual([]); + expect(snapshot.comments).toEqual([]); + expect(counts).toEqual({ queries: 3, transactions: 1, deferred: 1, outsideTransaction: 0 }); + expect(writer.query('SELECT title FROM tasks WHERE id = ?').get(dependency.id) as { title: string }).toEqual({ + title: 'after peer write', + }); + expect(getTaskEvents(writer, card.id)).toHaveLength(1); + reader.close(); + writer.close(); + }); + + function captureDetailReads( + reader: Database, + ): Array<{ sql: string; bindings: SQLQueryBindings[]; plan: string[]; inTransaction: boolean }> { + const reads: Array<{ sql: string; bindings: SQLQueryBindings[]; plan: string[]; inTransaction: boolean }> = []; + const originalQuery = reader.query.bind(reader); + Object.defineProperty(reader, 'query', { + configurable: true, + value: (sql: string) => { + const statement = originalQuery(sql); + return { + all: (...bindings: SQLQueryBindings[]) => { + if (sql.startsWith('SELECT')) { + const plan = originalQuery(`EXPLAIN QUERY PLAN ${sql}`).all(...bindings) as Array<{ + detail: string; + }>; + reads.push({ sql, bindings, plan: plan.map((row) => row.detail), inTransaction: reader.inTransaction }); + } + return statement.all(...bindings); + }, + }; + }, + }); + return reads; + } + + test('detail reads probe task_id indexes scoped to the selected card ids', () => { + const writer = openDb({ cwd: repo }); + const selected = createBoard(writer, 'selected', DEFAULT_LIFECYCLE_LANES); + const other = createBoard(writer, 'other', DEFAULT_LIFECYCLE_LANES); + const cardA = createTask(writer, { title: 'card a', boardId: selected.id, lane: 'Idea' }); + const cardB = createTask(writer, { title: 'card b', boardId: selected.id, lane: 'Idea' }); + const dependency = createTask(writer, { title: 'cross-board dependency', boardId: other.id, lane: 'Idea' }); + const foreign = createTask(writer, { title: 'foreign card', boardId: other.id, lane: 'Idea' }); + const otherWish = createTask(writer, { title: 'other wish', boardId: selected.id, lane: 'Idea' }); + writer.query('UPDATE tasks SET wish = ? WHERE id IN (?, ?)').run('slice', cardA.id, cardB.id); + writer.query('UPDATE tasks SET created_at = 1 WHERE id = ?').run(cardA.id); + writer.query('UPDATE tasks SET created_at = 2 WHERE id = ?').run(cardB.id); + addDependency(writer, cardA.id, dependency.id); + // Out-of-scope history: a foreign-board event and a filtered-wish comment + // must never surface in — or fail — the selected board's snapshot. + writer + .query("INSERT INTO task_events (task_id, kind, note, created_at) VALUES (?, 'comment', NULL, 1)") + .run(foreign.id); + writer + .query("INSERT INTO task_events (task_id, kind, note, created_at) VALUES (?, 'comment', NULL, 1)") + .run(otherWish.id); + writer + .query("INSERT INTO task_events (task_id, kind, note, created_at) VALUES (?, 'comment', 'later', 2)") + .run(cardA.id); + writer + .query("INSERT INTO task_events (task_id, kind, note, created_at) VALUES (?, 'comment', 'earlier', 1)") + .run(cardA.id); + writer.close(); + + const reader = openDb({ cwd: repo }); + const reads = captureDetailReads(reader); + const snapshot = readBoardTaskSnapshot(reader, selected.id, { wish: 'slice' }); + expect(snapshot.map((card) => card.id)).toEqual([cardA.id, cardB.id]); + expect(snapshot[0]?.dependencies.map((row) => row.id)).toEqual([dependency.id]); + expect(snapshot[0]?.timeline.map((event) => event.note)).toEqual(['earlier', 'later']); + expect(reads).toHaveLength(3); + for (const read of reads) { + expect(read.inTransaction).toBe(true); + } + expect(reads[1]?.sql).toContain('FROM task_dependencies'); + expect(reads[1]?.bindings).toEqual([JSON.stringify([cardA.id, cardB.id])]); + expect(reads[1]?.plan.join('\n')).toMatch(/SEARCH td .*INDEX.*task_id=\?/); + expect(reads[2]?.sql).toContain('FROM task_events'); + expect(reads[2]?.bindings).toEqual([JSON.stringify([cardA.id, cardB.id])]); + expect(reads[2]?.plan.join('\n')).toMatch(/SEARCH e .*INDEX.*task_id=\?/); + expect( + reads + .slice(1) + .flatMap((read) => read.plan) + .join('\n'), + ).not.toMatch(/SCAN (td|e)(?: |$)/); + reader.close(); + }); + + test('an empty card selection returns before querying detail tables', () => { + const writer = openDb({ cwd: repo }); + const empty = createBoard(writer, 'empty-board', DEFAULT_LIFECYCLE_LANES); + writer.close(); + + const reader = openDb({ cwd: repo }); + const reads = captureDetailReads(reader); + expect(readBoardTaskSnapshot(reader, empty.id)).toEqual([]); + expect(reads).toHaveLength(1); + expect(reads[0]?.inTransaction).toBe(true); + reader.close(); + }); + + const hostileIdentifier = 'bad\n\r\t\x1b\u0085\u2028\u2029\u202e'.concat('x'.repeat(2000)); + for (const kind of ['task', 'dependency', 'event'] as const) { + test(`a malformed ${kind} identifier cannot create multiline or oversized diagnostics`, () => { + const db = openDb({ cwd: repo }); + const roadmap = createBoard(db, 'roadmap', DEFAULT_LIFECYCLE_LANES); + const hostile = createTask(db, { title: 'hostile id', boardId: roadmap.id, lane: 'Idea' }); + const owner = createTask(db, { title: 'owner', boardId: roadmap.id, lane: 'Idea' }); + db.query('UPDATE tasks SET id = ? WHERE id = ?').run(hostileIdentifier, hostile.id); + if (kind === 'event') { + db.query("INSERT INTO task_events (task_id, kind, note, created_at) VALUES (?, 'comment', NULL, 1)").run( + hostileIdentifier, + ); + } else { + if (kind === 'dependency') addDependency(db, owner.id, hostileIdentifier); + db.exec('PRAGMA ignore_check_constraints = ON'); + db.query("UPDATE tasks SET status = 'unknown' WHERE id = ?").run(hostileIdentifier); + } + db.close(); + + const reader = openDb({ cwd: repo }); + let message = ''; + try { + readBoardTaskSnapshot(reader, roadmap.id); + } catch (error) { + message = (error as Error).message; + } + reader.close(); + expect(message.startsWith('Malformed board detail:')).toBe(true); + expect(message).not.toMatch(/[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]/u); + expect(message.length).toBeLessThan(200); + }); + } + + test('a large board stays under SQLite bind-variable limits with identifiers intact', () => { + const writer = openDb({ cwd: repo }); + const wide = createBoard(writer, 'wide', DEFAULT_LIFECYCLE_LANES); + const insert = writer.prepare( + "INSERT INTO tasks (id, board_id, title, status, created_at, updated_at) VALUES (?, ?, 'bulk', 'ready', 1, 1)", + ); + writer.transaction(() => { + for (let index = 0; index < 33_000; index += 1) insert.run(`task-${index}`, wide.id); + })(); + writer.close(); + + const reader = openDb({ cwd: repo }); + const snapshot = readBoardTaskSnapshot(reader, wide.id); + reader.close(); + expect(snapshot).toHaveLength(33_000); + expect(new Set(snapshot.map((card) => card.id)).size).toBe(33_000); + expect(snapshot.find((card) => card.id === 'task-0')).toBeDefined(); + expect(snapshot.find((card) => card.id === 'task-32999')).toBeDefined(); + }); +}); + describe('wish-status lane reconciliation on CLI JSON reads', () => { test('maps every ordered status prefix and leaves other untouched', async () => { const cases: Array<[status: string, destination: string]> = [ diff --git a/src/term-commands/v5-board.ts b/src/term-commands/v5-board.ts index dcdc4604c..bcf214b5f 100644 --- a/src/term-commands/v5-board.ts +++ b/src/term-commands/v5-board.ts @@ -16,6 +16,7 @@ import { cardBadges } from '../lib/v5/card-render.js'; import { openDb, resolveRepoRoot } from '../lib/v5/genie-db.js'; import { type BoardRow, + type BoardTaskAggregate, DEFAULT_LIFECYCLE_LANES, type Lane, type LaneTaskRow, @@ -23,6 +24,7 @@ import { type TaskFilter, type TaskRow, type TaskStatus, + boardDetailIdentifier, commentCounts, countBoardTasks, createBoard, @@ -31,6 +33,7 @@ import { listTasks, listTasksWithLane, moveTask, + readBoardTaskSnapshot, resolveBoard, } from '../lib/v5/task-state.js'; import { WISH_SLUG_PATTERN, extractStatusCell, readBoundedWishFile } from '../lib/wish-status.js'; @@ -281,6 +284,13 @@ function handleBoardWithDb(opts: BoardOptions): void { scopeLabel = opts.board ? `${scopeLabel}, wish "${opts.wish}"` : `wish "${opts.wish}"`; } + if (opts.json && board?.laneMetadataMalformed) { + throw new Error( + `Malformed board detail: board ${boardDetailIdentifier(board.id)} lanes must be an array or null.`, + ); + } + if (opts.json && board?.lanes) requireAggregateLanes(board.lanes); + // A scoped board that defines lanes renders on the lifecycle axis. Every // other scope (no board, or a laneless board) falls through to the frozen // status render below — kept byte-identical (Group B owns any rework). @@ -350,49 +360,38 @@ function groupByLane<T extends LaneTaskRow>(lanes: Lane[], tasks: T[]): Map<stri return byLane; } -/** - * One card on the additive lane `--json` path — the frozen ten TaskRow keys - * plus the two declared-routing fields and `lane` + `enforcedBlock`, picked - * explicitly so the lane shape states exactly what it serializes. Key order - * matches the pre-assignment spread, so lane output changes by exactly the two - * added fields; the TaskCardRow runtime layer (identity, heartbeat, block - * provenance) stays off this path. - */ -function toLaneJsonCard(t: LaneTaskRow): LaneTaskRow { - return { - id: t.id, - boardId: t.boardId, - title: t.title, - status: t.status, - claimedBy: t.claimedBy, - claimedAt: t.claimedAt, - wish: t.wish, - group: t.group, - assignedAgent: t.assignedAgent, - assignedReason: t.assignedReason, - createdAt: t.createdAt, - updatedAt: t.updatedAt, - lane: t.lane, - enforcedBlock: t.enforcedBlock, - }; +/** Validate persisted lane metadata at the scoped aggregate serialization boundary. */ +function requireAggregateLanes(lanes: Lane[]): void { + for (const [index, lane] of lanes.entries()) { + if (lane === null || typeof lane !== 'object' || Array.isArray(lane)) { + throw new Error(`Malformed board detail: lane ${index} must be an object.`); + } + if (typeof lane.name !== 'string') { + throw new Error(`Malformed board detail: lane ${index} name must be a string.`); + } + if (lane.label !== undefined && typeof lane.label !== 'string') { + throw new Error(`Malformed board detail: lane ${index} label must be a string when present.`); + } + if (lane.action !== undefined && typeof lane.action !== 'string') { + throw new Error(`Malformed board detail: lane ${index} action must be a string when present.`); + } + } } function renderLaneBoard(db: Database, lanes: Lane[], filter: TaskFilter, scopeLabel: string, json: boolean): void { - // `--json` keeps the additive lane shape. Its cards carry the two declared- - // routing fields (`assignedAgent`/`assignedReason`) plus exactly one runtime - // field beyond the frozen TaskRow — `enforcedBlock` (null when unblocked), so - // a lane consumer can tell a parked card from a live one and read who it is - // routed to. Identity, heartbeat, and block provenance stay off this path, - // and the frozen laneless `--json` remains byte-identical. + // A scoped lane board is the complete v1 aggregate contract. The repository + // reader returns all cards, dependencies, and events from one SQLite read + // transaction; grouping here only preserves the board's declared lane order. if (json) { - const byLane = groupByLane(lanes, listTasksWithLane(db, filter)); + if (!filter.boardId) throw new Error('A board id is required for aggregate JSON output.'); + const byLane = groupByLane<BoardTaskAggregate>(lanes, readBoardTaskSnapshot(db, filter.boardId, filter)); const laneGroups = lanes.map((l) => ({ name: l.name, label: l.label ?? null, action: l.action ?? null, - cards: (byLane.get(l.name) ?? []).map(toLaneJsonCard), + cards: byLane.get(l.name) ?? [], })); - out(JSON.stringify({ scope: scopeLabel, lanes: laneGroups }, null, 2)); + out(JSON.stringify({ schemaVersion: 1, scope: scopeLabel, lanes: laneGroups }, null, 2)); return; } diff --git a/src/term-commands/v5-task.test.ts b/src/term-commands/v5-task.test.ts index 6987a3562..4eb6b1169 100644 --- a/src/term-commands/v5-task.test.ts +++ b/src/term-commands/v5-task.test.ts @@ -1215,6 +1215,65 @@ describe('roadmap.json canonical sync', () => { expect(settled.code).toBe(0); }); + test('imported snapshots with reordered object keys remain in sync', async () => { + const db = openDb({ cwd: repo }); + createTask(db, { title: 'canonical card' }); + db.close(); + + const published = await cli(repo, 'export', '--write'); + expect(published.stderr).toBe(''); + expect(published.code).toBe(0); + const snapshotPath = join(repo, '.genie', 'roadmap.json'); + const snapshot = JSON.parse(readFileSync(snapshotPath, 'utf-8')) as unknown; + const reorderKeys = (value: unknown): unknown => { + if (Array.isArray(value)) return value.map(reorderKeys); + if (value !== null && typeof value === 'object') { + return Object.fromEntries( + Object.entries(value as Record<string, unknown>) + .reverse() + .map(([key, child]) => [key, reorderKeys(child)]), + ); + } + return value; + }; + writeFileSync(snapshotPath, `${JSON.stringify(reorderKeys(snapshot), null, 2)}\n`); + + const imported = await cli(repo, 'import', '--replace'); + expect(imported.code).toBe(0); + expect(imported.stderr).toBe(''); + const settled = await cli(repo, 'sync'); + expect(settled.code).toBe(0); + expect(settled.stdout).toContain('in sync (none)'); + expect(settled.stderr).toBe(''); + }); + + test.each(['content', 'array order'])('sync detects changed %s after a canonical baseline', async (change) => { + const db = openDb({ cwd: repo }); + createTask(db, { title: 'first card' }); + createTask(db, { title: 'second card' }); + db.close(); + const published = await cli(repo, 'export', '--write'); + expect(published.code).toBe(0); + expect(published.stderr).toBe(''); + + const snapshotPath = join(repo, '.genie', 'roadmap.json'); + const snapshot = JSON.parse(readFileSync(snapshotPath, 'utf-8')) as StateExport; + if (change === 'content') snapshot.tasks[0].title = 'changed card'; + else snapshot.tasks.reverse(); + writeFileSync(snapshotPath, `${JSON.stringify(snapshot, null, 2)}\n`); + + const synced = await cli(repo, 'sync'); + expect(synced.code).toBe(0); + expect(synced.stderr).toBe(''); + expect(synced.stdout).toContain('Board refreshed'); + const imported = openDb({ cwd: repo }); + try { + expect(getTask(imported, snapshot.tasks[0].id)?.title).toBe(snapshot.tasks[0].title); + } finally { + imported.close(); + } + }); + test('a subdirectory spelling of roadmap.json is roadmap-sliced, and is not the canonical baseline', async () => { const db = openDb({ cwd: repo }); createTask(db, { title: 'card' }); @@ -1277,6 +1336,8 @@ describe('roadmap.json canonical sync', () => { expect(listed.stdout).toContain('keeper'); }); + // This round-trip intentionally runs 13 real CLI subprocesses. Their startup + // cost exceeds Bun's default 5s under the full suite; keep a bounded 20s gate. test('pulled snapshot imports on sync; local mutation exports; divergence is refused then resolvable', async () => { // Machine A (repo): publish F1, then F2 with one more card. const db = openDb({ cwd: repo }); @@ -1336,7 +1397,7 @@ describe('roadmap.json canonical sync', () => { } finally { rmSync(clone, { recursive: true, force: true }); } - }); + }, 20_000); }); // Same subprocess invocation as `cli`, but with extra env vars layered on — used diff --git a/tests/support/update-current-boundary-runner.ts b/tests/support/update-current-boundary-runner.ts index f62cfa8a0..4d6a41207 100644 --- a/tests/support/update-current-boundary-runner.ts +++ b/tests/support/update-current-boundary-runner.ts @@ -12,7 +12,7 @@ if (genieHome === undefined || scenario !== 'already-current') { const bin = join(genieHome, 'bin'); for (const directory of ['.agents', '.claude-plugin', 'plugins/genie', 'skills/review', 'templates']) { - mkdirSync(join(bin, directory), { recursive: true }); + mkdirSync(join(bin, directory), { recursive: true, mode: 0o755 }); } writeFileSync(join(bin, 'LICENSE'), 'fixture\n'); writeFileSync(join(bin, 'VERSION'), `${VERSION}\n`); @@ -22,8 +22,8 @@ writeFileSync(join(bin, 'templates', 'fixture.txt'), 'fixture\n'); const executable = join(bin, 'genie'); writeFileSync(executable, `#!/bin/sh\nif [ "\${1:-}" = "--version" ]; then printf 'genie ${VERSION}\\n'; fi\nexit 0\n`); chmodSync(executable, 0o755); -mkdirSync(process.env.HOME as string, { recursive: true }); -mkdirSync(process.env.CODEX_HOME as string, { recursive: true }); +mkdirSync(process.env.HOME as string, { recursive: true, mode: 0o755 }); +mkdirSync(process.env.CODEX_HOME as string, { recursive: true, mode: 0o755 }); const marker = join(genieHome, '.install-version'); writeFileSync(marker, 'prior-marker\n');