@bongos/core 1.19.820 → 1.19.822

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/.bongos-core.json CHANGED
@@ -2,22 +2,22 @@
2
2
  "artifact": "bongos-core",
3
3
  "manifest_schema": 1,
4
4
  "generator": "scripts/gds/package-core.js",
5
- "core_version": "1.19.820",
6
- "core_contract": "1.19.820",
7
- "source_commit": "c4a01fc04756f55d2459a179bd38a808b5950c1c",
5
+ "core_version": "1.19.822",
6
+ "core_contract": "1.19.822",
7
+ "source_commit": "5a9c009cff17c2a500ff23f5d0de66a1b7f00477",
8
8
  "source_ref": "HEAD",
9
- "built_at": "2026-09-17T10:19:46.189Z",
9
+ "built_at": "2026-09-17T11:05:47.254Z",
10
10
  "redaction": {
11
11
  "model": "docs-redacted+functional-verbatim",
12
12
  "docs_redacted": 490,
13
13
  "agent_docs_stubbed": 26,
14
- "functional_verbatim": 2227,
14
+ "functional_verbatim": 2228,
15
15
  "rules": 3,
16
16
  "gate_literals": 3,
17
17
  "gate": "passed"
18
18
  },
19
- "file_count": 2743,
20
- "tree_sha256": "8f93538017b17fbc142d0e8bf95cc5f666a32ea5295ba9be2dbca5eb19acd100",
19
+ "file_count": 2744,
20
+ "tree_sha256": "acbc13ab96c308bd0c0aa200c9dd1f6af1013ed6771c70730a797f7a1fb4a697",
21
21
  "files": [
22
22
  {
23
23
  "path": ".claude/skills/ask-for-help/SKILL.md",
@@ -62,7 +62,7 @@
62
62
  {
63
63
  "path": ".claude/skills/builder-end/SKILL.md",
64
64
  "mode": "0000644",
65
- "sha256": "60573c4e129df5bb386e7588989fbb407dfd1b88b4b9d567b2415d3e597f54da"
65
+ "sha256": "6f410236fa6fed591838b52a1d962210ed3c376e1feb439755c39930edd96ede"
66
66
  },
67
67
  {
68
68
  "path": ".claude/skills/builder-exit/SKILL.md",
@@ -517,7 +517,7 @@
517
517
  {
518
518
  "path": "docs/adr/0021-per-builder-skill-model-preferences.md",
519
519
  "mode": "0000644",
520
- "sha256": "bcd0b55d7fd83e3e8877c55dbddbc9c101a2627f1a7be29207ab3e3bf8732e71"
520
+ "sha256": "12ee736d317c0076261f469c7fa010f71625329d81ee15bf2f069af80850826a"
521
521
  },
522
522
  {
523
523
  "path": "docs/adr/0022-secrets-policy.md",
@@ -537,7 +537,7 @@
537
537
  {
538
538
  "path": "docs/adr/0024-multi-agent-system-architecture.md",
539
539
  "mode": "0000644",
540
- "sha256": "0aa00a713d3c15f456d535c15968547141df4f76f6b351a86c4c50132bad937c"
540
+ "sha256": "f446e8a7b535cd9b35a7255c2e78179ca287c16985f4b3e294c4282c5b1cfab4"
541
541
  },
542
542
  {
543
543
  "path": "docs/adr/0025-offsite-backup-vendor-digitalocean-spaces.md",
@@ -552,7 +552,7 @@
552
552
  {
553
553
  "path": "docs/adr/0026-bfg-memory-hygiene-and-cross-builder-learning.md",
554
554
  "mode": "0000644",
555
- "sha256": "4e337e424c15df34f411877ecc371ad2e8f111c9922f9898eedc648d5d9bb1fe"
555
+ "sha256": "50d275eeea0cac619c5806583db070a3b95ab4e350a89e2b99e9d6fd4022332d"
556
556
  },
557
557
  {
558
558
  "path": "docs/adr/0027-bfg-session-inefficiency-evaluator.md",
@@ -562,7 +562,7 @@
562
562
  {
563
563
  "path": "docs/adr/0028-honest-llm-spend-accounting.md",
564
564
  "mode": "0000644",
565
- "sha256": "31a0378984e53c9cb08370f70f3dca296cf532c1bf7e55a39c898683b1cb0c22"
565
+ "sha256": "7d8e8a3de946d3ddd2b9ee2d717b067fdcf7ecaa66bad814fd4567846b4c749e"
566
566
  },
567
567
  {
568
568
  "path": "docs/adr/0029-status-page-outage-resilience.md",
@@ -597,7 +597,7 @@
597
597
  {
598
598
  "path": "docs/adr/0035-builder-onboarding-three-paths.md",
599
599
  "mode": "0000644",
600
- "sha256": "e411de75d8fa21b6870d5ca85052e3608b7fc59505990fd19bd23ac4c474b0e9"
600
+ "sha256": "eefc4f6a5d9090c853bcb45d03e1b5fb410910a003b08331dbf16a3d50836e80"
601
601
  },
602
602
  {
603
603
  "path": "docs/adr/0036-builders-subdomain-and-ui-reorg.md",
@@ -702,7 +702,7 @@
702
702
  {
703
703
  "path": "docs/adr/0055-server-mediated-branch-publish.md",
704
704
  "mode": "0000644",
705
- "sha256": "4b2b10b4ae53c2b5c33d1220563b3021822c3ddc5e1a5034afc04a0d286f7bec"
705
+ "sha256": "09b32b0cf2f01db37797c6f99262b37dd5be51a47a57cb5005c49e9058c06c81"
706
706
  },
707
707
  {
708
708
  "path": "docs/adr/0056-prod-deploy-script-mirror-and-healthcheck-retry.md",
@@ -722,17 +722,17 @@
722
722
  {
723
723
  "path": "docs/adr/0058-automatic-gate-surface-gatekeeper.md",
724
724
  "mode": "0000644",
725
- "sha256": "308a1342c514ef4028da846a8b3963df4a3908e72e1ed3a688fe1c1e51c67ec9"
725
+ "sha256": "35b2bfccd503b150c3e6587564c1e1779400bdbf1899ee2346c31eb19d8b2681"
726
726
  },
727
727
  {
728
728
  "path": "docs/adr/0059-single-approval-remove-devbox-approval-gate.md",
729
729
  "mode": "0000644",
730
- "sha256": "adb7a32e2afc71212a680fd59443e4435a884758053f78509ec6bc7b8f738e23"
730
+ "sha256": "6b425c8416bf8695c5a85b93d0f24e3db9d1a43ee9fa3032aa38a9178fbb740a"
731
731
  },
732
732
  {
733
733
  "path": "docs/adr/0060-gds-retrieval-layer.md",
734
734
  "mode": "0000644",
735
- "sha256": "9d0546a4d24f38f1632aa94789c3d599777ff9dbf9664929993934f6b2ffcd1f"
735
+ "sha256": "9f965ad0eb78ea5e5dd83f46426129a593335bb35e312209d1cfe3a240d53be3"
736
736
  },
737
737
  {
738
738
  "path": "docs/adr/0061-context-layer-decomposition.md",
@@ -782,7 +782,7 @@
782
782
  {
783
783
  "path": "docs/adr/0070-deterministic-session-card-delivery.md",
784
784
  "mode": "0000644",
785
- "sha256": "b8ecbe554ac874f415bca4e31198e77f574fdba7fc0d2a6f9491701094203062"
785
+ "sha256": "f215a47f3432a8efce4b9fd7a9eb90dd3221ca314b8fa8ca35486464f3027769"
786
786
  },
787
787
  {
788
788
  "path": "docs/adr/0071-box-confirm-before-destroyed-and-drift-reconcile.md",
@@ -802,7 +802,7 @@
802
802
  {
803
803
  "path": "docs/adr/0073-builder-needs-signal-and-byok-gemini-key.md",
804
804
  "mode": "0000644",
805
- "sha256": "b7e794e23d06b44d7e4cbdc4985275aa4c26b5b88aa5a5c019881ac0b88bfaa7"
805
+ "sha256": "cf5981d3c27dfed8e2f02b07a77c9de08a8df6bda0a746ecd3bc0943d30d64da"
806
806
  },
807
807
  {
808
808
  "path": "docs/adr/0073-secrets-scan-exclude-uri-detector.md",
@@ -847,7 +847,7 @@
847
847
  {
848
848
  "path": "docs/adr/0081-tool-agnostic-design-layer.md",
849
849
  "mode": "0000644",
850
- "sha256": "6fbbd598f08196d32253f15a797dd20bdc28cb7c38930e10f9dcf5467e6dbf97"
850
+ "sha256": "d2f5de4793bf1e9cd184a4bfc2cddb9dd44c560e42e3ef8d0f0e6668fd84d69a"
851
851
  },
852
852
  {
853
853
  "path": "docs/adr/0082-productized-key-storage-provisioning.md",
@@ -877,7 +877,7 @@
877
877
  {
878
878
  "path": "docs/adr/0086-goal-scoped-work-hierarchy.md",
879
879
  "mode": "0000644",
880
- "sha256": "db082db26afc1fefd429c826ba934e99ff1e127cd0b099810de1b9e83ef8514f"
880
+ "sha256": "15c590ed8bb381435564f4ba797f8933b2332620da6178e2f2b47c22feedc15b"
881
881
  },
882
882
  {
883
883
  "path": "docs/adr/0087-bongos-app-architecture-and-handoff-contract.md",
@@ -1052,7 +1052,7 @@
1052
1052
  {
1053
1053
  "path": "docs/adr/0117-api-versioning-and-deprecation-policy.md",
1054
1054
  "mode": "0000644",
1055
- "sha256": "738b956bbf896aabdfd28b7f885b47e4b44cce6406d9ac9a5b67a23f3b0cac5d"
1055
+ "sha256": "6a9f4f42867cfd07f7ad85b2d7fc8dab58f6e6ce0c7e0145cb7d58aa16955619"
1056
1056
  },
1057
1057
  {
1058
1058
  "path": "docs/adr/0118-api-contract-source-of-truth-code-first.md",
@@ -2547,7 +2547,7 @@
2547
2547
  {
2548
2548
  "path": "docs/module-api-changelog.md",
2549
2549
  "mode": "0000644",
2550
- "sha256": "54570238fac2a9b36678a3fa293f7ea17cc53d3c8aeb29070132a5eb35f31334"
2550
+ "sha256": "9182a3b88e6092a231f59db4220628cb8579244c8289350406f07187c959e644"
2551
2551
  },
2552
2552
  {
2553
2553
  "path": "docs/modules-contract.md",
@@ -2732,7 +2732,7 @@
2732
2732
  {
2733
2733
  "path": "docs/recipes/multi-builder-merge.md",
2734
2734
  "mode": "0000644",
2735
- "sha256": "048875fdcbd2e6637f19b96e3b6660434e4ceb68b5ee171580a6d6a13b39242b"
2735
+ "sha256": "368b0acc045087c8aff34b3a551ced958e6e87987afcf6c3210eff093c8a9eea"
2736
2736
  },
2737
2737
  {
2738
2738
  "path": "docs/recipes/ops-gotchas.md",
@@ -2772,7 +2772,7 @@
2772
2772
  {
2773
2773
  "path": "docs/recipes/search-index-ingestion.md",
2774
2774
  "mode": "0000644",
2775
- "sha256": "70989ad134f5aaffc08007a180bfa5f9cccceef780346a965fcade293490dc71"
2775
+ "sha256": "dd333b764d0ed25162b6bee7f02cb5231d7384918abb30a05ad3a18aa099f4dd"
2776
2776
  },
2777
2777
  {
2778
2778
  "path": "docs/recipes/self-host.md",
@@ -5612,7 +5612,7 @@
5612
5612
  {
5613
5613
  "path": "modules/lifecycle/dead-deps.js",
5614
5614
  "mode": "0000644",
5615
- "sha256": "c9f7aa89a4b1e9a201189608ff8ebc4910ee6ca19be33ce681f2d32cedb0b39d"
5615
+ "sha256": "4738131a48e85dd7bf0644ddd3686c85d0d1d0da55ee623b1834d048c614b91c"
5616
5616
  },
5617
5617
  {
5618
5618
  "path": "modules/lifecycle/done-when.js",
@@ -7812,12 +7812,12 @@
7812
7812
  {
7813
7813
  "path": "package-lock.json",
7814
7814
  "mode": "0000644",
7815
- "sha256": "e520824e675ebbb739a1f6ea7e2bc6ff857e1b6a396a61e22f20fd31b8b9a502"
7815
+ "sha256": "d13165aac1aea3612a3c5af1b5b14267acdde35f2a8d99482310c7a749f9761c"
7816
7816
  },
7817
7817
  {
7818
7818
  "path": "package.json",
7819
7819
  "mode": "0000644",
7820
- "sha256": "39eb085e8df8abd30ffe2826215c5c7eb6969b2a3990f3f7d0f12cd303cd0aaf"
7820
+ "sha256": "0f9f0baab592f70e890e2a39e61dcf21c5170841f97b8c82cb16703c2a959756"
7821
7821
  },
7822
7822
  {
7823
7823
  "path": "public-docs/index.html",
@@ -7947,7 +7947,7 @@
7947
7947
  {
7948
7948
  "path": "scripts/gds/audit-deps.js",
7949
7949
  "mode": "0000644",
7950
- "sha256": "891aadcbea8ef257f68f20f140fa988c45ac06d4caa19802136c372d403db7ba"
7950
+ "sha256": "ff7ccdf15ae2c215d3330e957d6a36adc7d48f5d9bd5042b5d61bb228b878073"
7951
7951
  },
7952
7952
  {
7953
7953
  "path": "scripts/gds/audit-exports.js",
@@ -9722,7 +9722,7 @@
9722
9722
  {
9723
9723
  "path": "src/module-api.js",
9724
9724
  "mode": "0000644",
9725
- "sha256": "8285f22119e40dc42cf2826696104fea2321fb9da9ad5c6cbd8eb5106382807f"
9725
+ "sha256": "782160b1ae7c1483c92337a589f92d8099a8bbee37005ea744c78bd4fcb5803c"
9726
9726
  },
9727
9727
  {
9728
9728
  "path": "src/module-loader/catalog.js",
@@ -12494,6 +12494,11 @@
12494
12494
  "mode": "0000644",
12495
12495
  "sha256": "a36acb19e59db1f9eb885ab6497a43d1c8ce0ba6198b0f5cc98f33bf08837fdf"
12496
12496
  },
12497
+ {
12498
+ "path": "tests/prose_gated_deps.mjs",
12499
+ "mode": "0000644",
12500
+ "sha256": "5d85d13a0cf1feb3c0aa809841126f4f9e9effe66fc57b5064b612627ce4916f"
12501
+ },
12497
12502
  {
12498
12503
  "path": "tests/provision.mjs",
12499
12504
  "mode": "0000644",
@@ -13,7 +13,7 @@ This is **not** `/builder-exit` — that deactivates an entire builder (releases
13
13
  Composes operations that already exist, in a safe order, with the destructive ones gated last:
14
14
 
15
15
  1. **Resolve the claim** — `/builder-ship` when there's committed, shippable work; `/builder-release` when there isn't. (It calls the sibling skills; it does not re-implement shipping.)
16
- 2. **Clean-git verify** — refuse to go further if any *tracked* file is uncommitted. This is the same guard `scripts/gds/ship.js` enforces (`trackedDirty` in [`src/bongos/ship-preflight.js`](../../../src/bongos/ship-preflight.js)): untracked scratch is ignored, but a modified/staged/deleted tracked file means real work isn't committed — stop before anything is lost.
16
+ 2. **Clean-git verify** — refuse to go further if any *tracked* file is uncommitted. This is the same guard `scripts/gds/ship.js` enforces (`trackedDirty` in [`modules/lifecycle/ship-preflight.js`](../../../modules/lifecycle/ship-preflight.js)): untracked scratch is ignored, but a modified/staged/deleted tracked file means real work isn't committed — stop before anything is lost.
17
17
  3. **Worktree teardown** — from the main checkout, remove this session's linked worktree via `node scripts/gds/worktree.js remove` (junction-aware), `prune`, and delete the now-merged `claude/<name>` branch.
18
18
 
19
19
  It never pushes to `main`, never `ssh`-es, never runs a deploy — landing is `/builder-ship`'s job (server-mediated). It only ever touches the caller's **own** claim and **own** worktree.
@@ -93,7 +93,7 @@ If V4 retires per-skill defaults in favor of per-skill-*kind* defaults (the broa
93
93
  ## Cross-references
94
94
 
95
95
  - [Migration 037](../../migrations/037_builder_skill_model_prefs.sql) — the schema change.
96
- - [`src/bongos/skill-prefs.js`](../../src/bongos/skill-prefs.js) — the helper module and source of truth for defaults.
96
+ - [`modules/builder-settings/skill-prefs.js`](../../modules/builder-settings/skill-prefs.js) — the helper module and source of truth for defaults.
97
97
  - [`tests/skill_prefs.mjs`](../../tests/skill_prefs.mjs) — unit coverage for the pure helpers.
98
98
  - [ADR 0017](0017-code-simplification-and-repo-cleanliness.md) — same spirit: keep judgment work intentional, mechanism work cheap.
99
99
  - CLAUDE.md §2 — the project-wide subagent routing policy this ADR sits beside (not replaces — the policy is the default the matrix opens with).
@@ -10,7 +10,7 @@
10
10
 
11
11
  As the GDS opens to multiple builders, three structural risks scale faster than headcount:
12
12
 
13
- 1. **The ship-time grader is lenient by construction.** [`src/bongos/grader.js`](../../src/bongos/grader.js) v2 (V3.R18 / [#209](https://example.com/builders#/task/209)) runs a *single* Opus subagent against the diff. Opus wrote the code; Opus grades it. Academic LLM-as-judge work (Zheng et al. 2023, Chiang & Lee 2023) consistently documents ~10–15% same-family inflation. The prompt asks "what score?" rather than "find what's wrong, then decide if it passes" — the default-to-pass framing is the bias. The recorded 14-day passing rate (65/75 = 87%) is suspiciously high for un-calibrated work.
13
+ 1. **The ship-time grader is lenient by construction.** [`modules/grading/grader.js`](../../modules/grading/grader.js) v2 (V3.R18 / [#209](https://example.com/builders#/task/209)) runs a *single* Opus subagent against the diff. Opus wrote the code; Opus grades it. Academic LLM-as-judge work (Zheng et al. 2023, Chiang & Lee 2023) consistently documents ~10–15% same-family inflation. The prompt asks "what score?" rather than "find what's wrong, then decide if it passes" — the default-to-pass framing is the bias. The recorded 14-day passing rate (65/75 = 87%) is suspiciously high for un-calibrated work.
14
14
 
15
15
  2. **Methodology hygiene is reactive.** Documentation drift, dead code, and architectural sprawl are caught (if at all) by `overnight-code-review` and `repo-simplify-sweep` — both downstream of merge, both advisory, both already overfilling. No upstream gate looks at the *system* the way the grader looks at the diff.
16
16
 
@@ -22,7 +22,7 @@ This ADR records the architecture and, critically, the **boundaries** that turn
22
22
 
23
23
  ### What the substrate already gives us
24
24
 
25
- - **Builder memory is server-canonical and per-builder isolated** — [`builder_memory`](../../src/bongos/routes/memory.js), `PRIMARY KEY (builder_id, path)`. The *only* cross-builder access that exists today is an **Archon-only, audit-logged, read-only** forensic path. **There is no cross-builder *write* path at all** — that was a conscious decision ([ADR 0016](0016-trust-boundary-server-enforced-permissions.md), [ADR 0024-memory](0024-cloneable-repo-local-first-memory.md) §4).
25
+ - **Builder memory is server-canonical and per-builder isolated** — [`builder_memory`](../../modules/memory/routes/memory.js), `PRIMARY KEY (builder_id, path)`. The *only* cross-builder access that exists today is an **Archon-only, audit-logged, read-only** forensic path. **There is no cross-builder *write* path at all** — that was a conscious decision ([ADR 0016](0016-trust-boundary-server-enforced-permissions.md), [ADR 0024-memory](0024-cloneable-repo-local-first-memory.md) §4).
26
26
  - **Karma** ([`karma_log`](../../migrations/040_builder_karma.sql)) is the existing good-citizenship reputation ledger (append-only, `granted_by` records who drove a delta, `NULL` = system).
27
27
  - **Cron roles** live as `SKILL.md` files under [`.claude/scheduled-tasks/`](../../.claude/scheduled-tasks/); the read-only `overnight-code-review` routine is the closest existing pattern.
28
28
 
@@ -22,7 +22,7 @@ Capture the two sources at **two different seams**, because their cost data live
22
22
 
23
23
  ### 1. Grader cost — read it from the subprocess envelope (client-side, exact)
24
24
 
25
- `grader.runSubagent` spawns each worker as `claude -p --print --output-format json` ([src/bongos/grader.js](../../src/bongos/grader.js)). With `--output-format json`, the CLI returns a result envelope that includes **`total_cost_usd`** (and a `usage` block) — *verified* on a real call: `{type, …, total_cost_usd: 0.198, usage, modelUsage}`. The old `extractAssistantResult` parsed only `env.result` and **discarded the cost**.
25
+ `grader.runSubagent` spawns each worker as `claude -p --print --output-format json` ([modules/grading/grader.js](../../modules/grading/grader.js)). With `--output-format json`, the CLI returns a result envelope that includes **`total_cost_usd`** (and a `usage` block) — *verified* on a real call: `{type, …, total_cost_usd: 0.198, usage, modelUsage}`. The old `extractAssistantResult` parsed only `env.result` and **discarded the cost**.
26
26
 
27
27
  > **Correction (made during [#486](https://example.com/builders#/task/486) implementation):** the panel does **not** run server-side. `gradeSubagent` is invoked **client-side in `scripts/gds/ship.js`** (and `conductor-dispatch.js`); the server's `/tasks/:id/grade` route only *validates/scores* the result ship.js POSTs. So there is no in-process DB handle at grade time — the cost is logged over HTTP, exactly like the session path. The substance below (exact envelope cost, no pricing table) is unchanged; only the seam moved from `db.logCost` to `POST /api/gds/cost`.
28
28
 
@@ -13,7 +13,7 @@ A new builder can reach Example three different ways, but the project has never
13
13
 
14
14
  1. **The live onboarding contradicts the intended model.** Today the *only* fully-working setup path is the one documented in [`docs/onboarding/primer.md`](../onboarding/primer.md) "Get set up": **accept a GitHub collaborator invite and clone the private repo onto your own disk** — open to *every* rank, including a brand-new Xenos. ADR 0031 §6 says source access should be a *rank-gated, revocable, box-held* boundary. So the live behavior is the exact opposite of the locked design: an unproven newcomer currently gets a permanent local copy of the whole repo. This must be reconciled.
15
15
  2. **The box lane is built but has no front door.** ADR 0031 + [#701](https://example.com/builders#/task/701) shipped the whole provisioning spine — `POST /box/ensure`, the intent queue, the control-plane runner, the pairing-link path. But there is no *guided arrival*: nothing tells a non-technical or Chromebook builder, in plain language, "here is your first session, here is what to type, here is how you wait for your box." The capability exists; the on-ramp doesn't.
16
- 3. **Cost exposure is unbounded at the front door.** Anyone with a GitHub account can complete Device-Flow auth and become a Xenos, and a Xenos is above the box provisioning floor (`DEFAULT_PROVISION_FLOOR = 'xenos'`, [`src/bongos/boxes.js`](../../src/bongos/boxes.js)). With [#701](https://example.com/builders#/task/701)'s auto-draining intent queue, a signup could cause a paid droplet (~$24/mo, ~$5/active after auto-park) to be created with **zero human in the loop**. On a $5,000/yr budget that's a real hole.
16
+ 3. **Cost exposure is unbounded at the front door.** Anyone with a GitHub account can complete Device-Flow auth and become a Xenos, and a Xenos is above the box provisioning floor (`DEFAULT_PROVISION_FLOOR = 'xenos'`, [`modules/dev-box/boxes.js`](../../modules/dev-box/boxes.js)). With [#701](https://example.com/builders#/task/701)'s auto-draining intent queue, a signup could cause a paid droplet (~$24/mo, ~$5/active after auto-park) to be created with **zero human in the loop**. On a $5,000/yr budget that's a real hole.
17
17
 
18
18
  ## The three paths (what each *is*, who it's for, how it's gated)
19
19
 
@@ -26,7 +26,7 @@ A box-scoped token still cannot ship. The builder runs **`/builder-reauth`** (th
26
26
 
27
27
  - `POST /api/gds/tasks/:id/publish-branch` — `auth.requireBuilder` + `gateTaskOwnership` (the same [#871](https://example.com/builders#/task/871) gate as `/grade`/`/confirm`/`/ship`), **deliberately without `auth.allowBoxScope`**, so a box-scoped session is `403 box_scope`. Accepts the branch as a **thin git bundle** (base64, `origin/main..HEAD`), `head_sha`, and `value_summary`. The server: writes the bundle to a per-request temp dir, fast `git clone --bare --local` of its own checkout for `main`'s objects (no network, no worktree touch → never races the `deploy-prod.yml` reset), unbundles, **verifies the bundle tip == the claimed `head_sha`** (binds the pushed code to the already-graded sha), then `git push` to GitHub via a **one-shot `x-access-token:<redacted> URL** (never persisted, never logged — the `box-source-fetch.sh` pattern), opens the PR, and enables auto-merge.
28
28
  - `GET /api/gds/tasks/:id/publish-status?branch=…` — ownership-gated; the server derives PR + deploy state live from GitHub so `ship.js` confirms the land WITHOUT `gh` on the box. No DB column added.
29
- - Implementation: [`src/bongos/github-push.js`](../../src/bongos/github-push.js) (push/PR/status via `execFile` arg-arrays + raw `fetch`, all behind test seams), routes in [`src/bongos/routes/tasks.js`](../../src/bongos/routes/tasks.js), the bundle body bypasses the global 64 KB JSON cap via the skip-list in [`src/bongos/routes.js`](../../src/bongos/routes.js) (route-scoped 32 MB parser + a 20 MB decoded-byte cap).
29
+ - Implementation: [`modules/lifecycle/github-push.js`](../../modules/lifecycle/github-push.js) (push/PR/status via `execFile` arg-arrays + raw `fetch`, all behind test seams), routes in [`modules/lifecycle/routes/tasks.js`](../../modules/lifecycle/routes/tasks.js), the bundle body bypasses the global 64 KB JSON cap via the skip-list in [`src/bongos/routes.js`](../../src/bongos/routes.js) (route-scoped 32 MB parser + a 20 MB decoded-byte cap).
30
30
 
31
31
  ### 3. `ship.js` chooses the path automatically
32
32
 
@@ -46,7 +46,7 @@ The hard floor is the irreducible root of trust: a compromised or prompt-injecte
46
46
 
47
47
  ## Update — enforcing, and the two server-attestation vectors (task 1142, task 1169)
48
48
 
49
- The gatekeeper went **ENFORCING** (task 1142): `gate-review.yml` no longer sets `GATE_REPORT_ONLY`, and `gate-review` is a required check. The `GATE_AUTHOR_TRUSTED` signal is the `gate-author-trust` **commit status** — set only by the server (which alone holds the authenticated builder's server-resolved rank + the App/PAT credential), so CI reads it but cannot forge it. The server posts it via `githubPush.setCommitStatus` from **two** ship paths, sharing one helper (`postGateAuthorTrust` in [`src/bongos/routes/tasks.js`](../../src/bongos/routes/tasks.js)):
49
+ The gatekeeper went **ENFORCING** (task 1142): `gate-review.yml` no longer sets `GATE_REPORT_ONLY`, and `gate-review` is a required check. The `GATE_AUTHOR_TRUSTED` signal is the `gate-author-trust` **commit status** — set only by the server (which alone holds the authenticated builder's server-resolved rank + the App/PAT credential), so CI reads it but cannot forge it. The server posts it via `githubPush.setCommitStatus` from **two** ship paths, sharing one helper (`postGateAuthorTrust` in [`modules/lifecycle/routes/tasks.js`](../../modules/lifecycle/routes/tasks.js)):
50
50
 
51
51
  1. **Server-mediated publish** ([ADR 0055](0055-server-mediated-branch-publish.md), dev box / `pushVia 'server'`): `POST /tasks/:id/publish-branch` attests the pushed tip after opening the PR.
52
52
  2. **Laptop `ci`-land** (`pushVia 'local'` — task 1169): `ship.js` pushes the branch + opens the PR with the laptop's own `gh` credential, which **cannot** forge the attestation. So `ciLandLocal` calls `POST /tasks/:id/attest-gate` (`requireBuilder` + ownership, deliberately **no** `allowBoxScope`) right **after the push and before opening the PR** — race-free for a fresh PR, since the PR-open is what fires `gate-review` and it runs only after the status is already present. Before task 1169 the laptop path posted *nothing*, so a Metic+ author's gate-surface PR from a laptop was BLOCKED and they could not self-approve their own PR (hit live on task 1162 / PR #131).
@@ -25,7 +25,7 @@ This **supersedes** [ADR 0035](0035-builder-onboarding-three-paths.md) §2 (Deci
25
25
  ## Consequences
26
26
 
27
27
  - **The member invite becomes the SOLE human gate before a paid (~$24/mo) box is created.** An admitted Xenos can provision their first box hands-off — the spend is bounded by the fact that a human already chose to admit them, not by a second per-box click.
28
- - **The box provisioning RANK FLOOR still bounds eligibility.** `BOX_PROVISION_MIN_RANK` ([`src/bongos/boxes.js`](../../src/bongos/boxes.js)) is unchanged: the floor is still the rank-side control on who may have a box at all, and the per-builder "one box at a time" invariant (`box_intents` partial unique index, one `builder_boxes` row) is untouched. Removing the *first-box* gate does not widen *who* qualifies; it only removes the second human click for someone already admitted and already above the floor.
28
+ - **The box provisioning RANK FLOOR still bounds eligibility.** `BOX_PROVISION_MIN_RANK` ([`modules/dev-box/boxes.js`](../../modules/dev-box/boxes.js)) is unchanged: the floor is still the rank-side control on who may have a box at all, and the per-builder "one box at a time" invariant (`box_intents` partial unique index, one `builder_boxes` row) is untouched. Removing the *first-box* gate does not widen *who* qualifies; it only removes the second human click for someone already admitted and already above the floor.
29
29
  - **Migration 102 reverts the schema** introduced by migration 071 — the `pending_approval` box-intent state (and any approval-tracking column) is removed; in-flight first-box intents are migrated to `pending` so the runner drains them.
30
30
  - **The box-panel "awaiting approval" UI state is removed** — `/builder-box` and the hall box panel no longer surface "your box is awaiting approval," because no box waits.
31
31
  - **The `#box-approvals` Discord channel + its box-intent approve/deny wiring are removed** (the box half of [ADR 0057](0057-discord-archon-approval-channels.md)). `#member-approvals` and the admission approve/dismiss wiring stay — admission is still the gate.
@@ -67,7 +67,7 @@ Results are fused with **Reciprocal Rank Fusion** (RRF: `score = Σ 1/(k + rank_
67
67
 
68
68
  ### 6. Scoping — per-chunk `min_rank` + owner, server-enforced (V4.R12 / [#950](https://example.com/builders#/task/950))
69
69
 
70
- This is the load-bearing security property, mirroring [`src/bongos/routes/memory.js`](../../src/bongos/routes/memory.js)'s posture:
70
+ This is the load-bearing security property, mirroring [`modules/memory/routes/memory.js`](../../modules/memory/routes/memory.js)'s posture:
71
71
 
72
72
  - Every query is filtered **in the SQL WHERE clause** (not post-hoc in JS) by the caller's **live, server-resolved rank** (`req.builder.rank`, read fresh per request — never from input): `min_rank` must be ≤ the caller's rank in the ladder.
73
73
  - A chunk with a non-null `owner_builder_id` (a per-builder private chunk, e.g. memory) is returned **only** to that owner — `owner_builder_id IS NULL OR owner_builder_id = req.builder.id`. There is no parameter to name another builder; the owner is always the authenticated session.
@@ -25,7 +25,7 @@ Make card **delivery** deterministic via hooks, and split the vehicle by whether
25
25
 
26
26
  1. **Read-only cards** (`/builder-start`, `/status`; future: `/recall`) → **[`session-cards.js`](../../.claude/hooks/session-cards.js)**, a `UserPromptSubmit` hook. On a matching intent it runs the script's `--hook` mode, which emits a one-fetch JSON envelope `{widgets_enabled, html, markdown, warnings}`, and injects the finished card + a one-step "render this once" directive. Registry-driven: adding a read-only card is one `CARDS` entry (a matcher, a script, an arg-extractor). Safe to pre-run because these scripts only fetch + render.
27
27
 
28
- 2. **Mutating / lifecycle cards** (`/builder-claim`, `/builder-ship`; `release` matched, emits none today) → **[`lifecycle-card.js`](../../.claude/hooks/lifecycle-card.js)**, a `PostToolUse`/Bash hook. These **must never be pre-run** — claiming or shipping as a side effect of merely typing would be catastrophic. Instead the model runs the script itself; the hook fires immediately after, reads the `[otb-card-html] <path>` marker the shared emitter ([`src/bongos/ship-card.js`](../../src/bongos/ship-card.js)) wrote, reads that file, and injects the card + directive next to the tool result.
28
+ 2. **Mutating / lifecycle cards** (`/builder-claim`, `/builder-ship`; `release` matched, emits none today) → **[`lifecycle-card.js`](../../.claude/hooks/lifecycle-card.js)**, a `PostToolUse`/Bash hook. These **must never be pre-run** — claiming or shipping as a side effect of merely typing would be catastrophic. Instead the model runs the script itself; the hook fires immediately after, reads the `[otb-card-html] <path>` marker the shared emitter ([`modules/lifecycle/ship-card.js`](../../modules/lifecycle/ship-card.js)) wrote, reads that file, and injects the card + directive next to the tool result.
29
29
 
30
30
  Both hooks honor the task-1279 widgets knob (OFF ⇒ no HTML injected, the plain-text card stands), carry **exactly two** fence markers around each payload (no extraction ambiguity), and follow the standard hook discipline: silent-fail (any miss/parse/IO error → exit 0, no output), zero model calls, a kill-switch (`OTB_SESSION_CARDS_OFF`) and the `OTB_SUBAGENT` guard. The skills (`builder-start`, `status`, `builder-claim`, `builder-ship`) are slimmed to **defer to the injected directive**, keeping their old run-the-script logic as the fallback when no directive appears (hook disabled, an older checkout, or a silent failure).
31
31
 
@@ -23,7 +23,7 @@ Two coupled pieces: a **reusable signal** (the general mechanism the owner asked
23
23
 
24
24
  ### 1. The `needs` signal — one consistent way to flag a required input
25
25
 
26
- A new pure module, [`src/bongos/builder-needs.js`](../../src/bongos/builder-needs.js), is a small **registry**. Each thing the system needs from a builder to run a capability — today the Gemini image key; tomorrow a Stripe key, a first-party Anthropic link, … — is one `(ctx) => need|null` entry. The server computes the live list on `GET /api/gds/me` (`needs: { items, action_needed_count, has_action_needed }`); every surface renders the **same** shape. Adding a future input is one registry entry — the renderers don't change.
26
+ A new pure module, [`modules/builder-settings/builder-needs.js`](../../modules/builder-settings/builder-needs.js), is a small **registry**. Each thing the system needs from a builder to run a capability — today the Gemini image key; tomorrow a Stripe key, a first-party Anthropic link, … — is one `(ctx) => need|null` entry. The server computes the live list on `GET /api/gds/me` (`needs: { items, action_needed_count, has_action_needed }`); every surface renders the **same** shape. Adding a future input is one registry entry — the renderers don't change.
27
27
 
28
28
  A need is always in exactly one of three states:
29
29
 
@@ -9,7 +9,7 @@ Developers collaborate through Git because code is text: it diffs line-by-line a
9
9
  Three forces made this worth deciding now:
10
10
 
11
11
  1. **UI is becoming real work.** Today the GDS has three disciplines — `engineer | artist | ideator` — and "design/figma/UI" tasks fall into `artist`, the pixel-art lane. UI (HUD, menus, the builders' hall, status dashboard, landing pages) is a different problem from world tiles and deserves its own lane.
12
- 2. **The grader is secretly two systems.** The GDS ship pipeline (`confirm → grade → merge → deploy → credits`) does **not** branch by discipline — every task rides the same rails. But the *grade* step is the code panel [`grader.js`](../../src/bongos/grader.js) (reads a git diff; scores code quality), while pixel art is really gated by a *separate* Gemini vision pipeline ([`art/pipeline/review.py`](../../art/pipeline/review.py)) that runs **upstream** in `/paint`, not at ship. Art passes the ship-time grader as a near-rubber-stamp because a text-only panel can't see pixels. Two grader codebases is a maintenance trap.
12
+ 2. **The grader is secretly two systems.** The GDS ship pipeline (`confirm → grade → merge → deploy → credits`) does **not** branch by discipline — every task rides the same rails. But the *grade* step is the code panel [`grader.js`](../../modules/grading/grader.js) (reads a git diff; scores code quality), while pixel art is really gated by a *separate* Gemini vision pipeline ([`art/pipeline/review.py`](../../art/pipeline/review.py)) that runs **upstream** in `/paint`, not at ship. Art passes the ship-time grader as a near-rubber-stamp because a text-only panel can't see pixels. Two grader codebases is a maintenance trap.
13
13
  3. **Claude Design changed the calculus.** [Claude Design](https://www.anthropic.com/news/claude-design-anthropic-labs) (Anthropic Labs, beta, 2026) **writes code and renders it** — it has no vector canvas. Its output is HTML/CSS/JS, and its June 2026 update added tight Claude Code sync (`/design-sync` imports a repo's design system in; a finished design hands off to Claude Code "where the designer left off"). So for Claude Design, *design is code* — it merges like any other code.
14
14
 
15
15
  Owner (Lars) requirements, reached in the 2026-06-21 design conversation:
@@ -40,7 +40,7 @@ Owner (Lars) requirements, reached in the 2026-06-21 design conversation:
40
40
  - **Designers keep Figma.** Manual Figma edits are first-class for structured/design-system changes and round-trip into the repo via a normal GDS task + the same grader. The honest limit: the Figma→code round-trip is lossy for arbitrary canvas art — that work becomes a committed asset, not round-tripped code.
41
41
  - **One grader to maintain, not two.** The standalone Gemini review path is retired into the unified engine. Code doesn't auto-regen today (a failed grade = human fixes + re-ships); unifying gives every type the same generate→grade→regenerate loop.
42
42
  - **Graphics is unchanged.** World pixel art stays on the Claude Code + Gemini pipeline; Figma stays its viewer ([`otb-figma-sync`](../../.claude/skills/otb-figma-sync/SKILL.md)). This ADR adds the `ui` lane beside it, it does not disturb the art pipeline.
43
- - **Gate-review floor applies.** [#1380](https://example.com/builders#/task/1380) touches [`grader.js`](../../src/bongos/grader.js) + [`ship.js`](../../scripts/gds/ship.js) — the gate-review **hard floor** ([ADR 0043](0043-git-ssh-trust-boundary-and-rank-floor-on-permission-paths.md)). It needs owner Gate approval even for Archon, and the multimodal call path wants a security review.
43
+ - **Gate-review floor applies.** [#1380](https://example.com/builders#/task/1380) touches [`grader.js`](../../modules/grading/grader.js) + [`ship.js`](../../scripts/gds/ship.js) — the gate-review **hard floor** ([ADR 0043](0043-git-ssh-trust-boundary-and-rank-floor-on-permission-paths.md)). It needs owner Gate approval even for Archon, and the multimodal call path wants a security review.
44
44
 
45
45
  ## Rejected alternatives
46
46
 
@@ -12,7 +12,7 @@
12
12
 
13
13
  ## Context
14
14
 
15
- Today only the Archon can create or edit tasks — `POST /tasks` and `PATCH /tasks/:id` are both `requireRank('archon')` ([`src/bongos/routes/tasks.js`](../../src/bongos/routes/tasks.js)). Lower-ranking builders (`xenos`, `thetes`) can only **file ideas** into `idea_inbox`; they have no ability to shape their own work toward an objective.
15
+ Today only the Archon can create or edit tasks — `POST /tasks` and `PATCH /tasks/:id` are both `requireRank('archon')` ([`modules/lifecycle/routes/tasks.js`](../../modules/lifecycle/routes/tasks.js)). Lower-ranking builders (`xenos`, `thetes`) can only **file ideas** into `idea_inbox`; they have no ability to shape their own work toward an objective.
16
16
 
17
17
  > **Update (2026-06-26, [ADR 0090](0090-metic-task-authoring.md)):** `POST /tasks` / `PATCH /tasks/:id` are now `requireRank('metic','archon')` — a *Metic* may author tasks directly. This ADR's goal-scoped, module-fenced authoring path (`POST /goals/:id/tasks`) is now the mechanism for **sub-Metic** ranks (`xenos`/`thetes`) specifically; everything below about the scope-subset wall and protected-path block is unchanged and still governs that path. Every task must be hand-authored by the owner first — a delegation bottleneck that wastes capable builders' judgment, and the opposite of the AI-first norm where agents do the building.
18
18
 
@@ -54,7 +54,7 @@ The tier set is host-configured, not hardcoded — the [ADR 0062](0062-medusa-in
54
54
  ### 3. Bounded task authoring (membership grants it; the create-time gate)
55
55
 
56
56
  - **`POST /goals`** — `requireRank('metic','archon')`; **Archon-only when `scope_modules` includes a `protected` module.**
57
- - **`POST /goals/:id/join` / `leave`** — open join for normal goals; **joining a sensitive-scope goal needs Archon approval** (reuse the access-request pattern, [`access-requests.js`](../../src/bongos/routes/access-requests.js)).
57
+ - **`POST /goals/:id/join` / `leave`** — open join for normal goals; **joining a sensitive-scope goal needs Archon approval** (reuse the access-request pattern, [`access-requests.js`](../../modules/onboarding/routes/access-requests.js)).
58
58
  - **`POST /goals/:id/tasks`** — `requireBuilder` + an in-handler gate (the maneuverability grant), ordered + fail-closed: goal `open` and **caller is a member** (or Archon); version guards reused from `POST /tasks`; **scope subset** — every `touches[]` element covered by the goal's `scope_modules` globs (`touchesOverlap`, [`path-match.js`](../../src/bongos/path-match.js)); **protected-path block** — `checkPermissionPaths` ([`permission-path-check.js`](../../src/bongos/permission-path-check.js)) so a sub-Metic member cannot author a task touching the protected core; then `db.createTask` stamping `goal_id`. `POST /tasks` / `PATCH /tasks/:id` stay byte-identical (Archon-only) for ungoverned creation.
59
59
  - **Rank floor is auto-derived, not a bespoke cap.** Per [ADR 0084](0084-auto-derived-per-task-rank-floor.md), a created task's `requires_rank` is auto-derived from its touches + sensitivity (`deriveRequiredRank` → `metic` for protected/sensitive, else `xenos`) and can only be raised. So a goal member need not (and cannot) self-set a rank that escapes their own gate — and a sensitive task auto-floors to `metic`, which a sub-Metic author could not claim. No new cap mechanism; this composes with the one that already exists.
60
60
 
@@ -78,7 +78,7 @@ The tier set is host-configured, not hardcoded — the [ADR 0062](0062-medusa-in
78
78
 
79
79
  ### 7. How the boundary holds (reuse + defense-in-depth)
80
80
 
81
- The create-time gate sees only *declared* touches (advisory — git merge is the real collision detector, [ADR 0049](0049-split-parallel-safety-contract.md)), so it is the first filter, **not** the enforcement boundary. The existing **ship-time gates remain authoritative on the real diff**: the grader's `permissionPathPrePass`/`routeRankPrePass` + `serverSideGuards` ([`grader.js`](../../src/bongos/grader.js)), the `gate-review` ESCALATE floor, and the post-push `main-audit`. A mis-declared task still cannot *merge* if its real diff hits protected paths. Two CI fitness checks ([`fitness.js`](../../scripts/gds/fitness.js)) guard drift: goal routes are pinned in `route-rank-check.EXPECTED_RANKS`, and the scope code must *import* `PROTECTED_GLOBS`, never re-declare it.
81
+ The create-time gate sees only *declared* touches (advisory — git merge is the real collision detector, [ADR 0049](0049-split-parallel-safety-contract.md)), so it is the first filter, **not** the enforcement boundary. The existing **ship-time gates remain authoritative on the real diff**: the grader's `permissionPathPrePass`/`routeRankPrePass` + `serverSideGuards` ([`grader.js`](../../modules/grading/grader.js)), the `gate-review` ESCALATE floor, and the post-push `main-audit`. A mis-declared task still cannot *merge* if its real diff hits protected paths. Two CI fitness checks ([`fitness.js`](../../scripts/gds/fitness.js)) guard drift: goal routes are pinned in `route-rank-check.EXPECTED_RANKS`, and the scope code must *import* `PROTECTED_GLOBS`, never re-declare it.
82
82
 
83
83
  ### Deferred (explicit non-decisions)
84
84
 
@@ -104,7 +104,7 @@ Each phase ships independently; implementing tasks are created via `/planning-se
104
104
 
105
105
  ## Alternatives considered
106
106
 
107
- - **Reuse `parent_task_id` for goals (a goal = a parent task) — rejected.** One-level cap ([`hierarchy.js`](../../src/bongos/hierarchy.js)), wrong lifecycle (a goal is a standing grant, not shippable work that earns credit on ship), and a task has no place for scope or membership.
107
+ - **Reuse `parent_task_id` for goals (a goal = a parent task) — rejected.** One-level cap ([`hierarchy.js`](../../modules/lifecycle/hierarchy.js)), wrong lifecycle (a goal is a standing grant, not shippable work that earns credit on ship), and a task has no place for scope or membership.
108
108
  - **Reuse `done_when_criteria` as goals — rejected.** That table *is* the criteria tier; overloading it with membership + a creation grant conflates "definition of done" with "who may do work." A goal *links to* criteria; it is not one.
109
109
  - **Single-owner goals — rejected** (Lars). Goals are shared workspaces; a single assignee would force mega-claims and block collaboration. Membership grants the authority; contribution (not membership alone) is what any future reward weights on.
110
110
  - **A bespoke per-task rank cap at goal-create — rejected in favour of [ADR 0084](0084-auto-derived-per-task-rank-floor.md).** The auto-derived floor already prevents a member minting a task that escapes their gate; a second mechanism would be a second list that drifts (the SEC [#863](https://example.com/builders#/task/863) failure mode).
@@ -2,7 +2,7 @@
2
2
 
3
3
  **Date:** 2026-07-04
4
4
  **Status:** Accepted (policy; R05 of goal 36).
5
- **Context:** [task 1992](https://builders.example.com#/task/1992) (BONGOS-V1, goal 36 "Robust, versioned, easy-to-call public API", criterion `api-versioning`). This is the **policy**; the mount + aliasing that implements it is R06 ([task 1993](https://builders.example.com#/task/1993)). Builds on the API-prefix chokepoint ([`src/gds/api-prefix.js`](../../src/gds/api-prefix.js), task 1918/1919), the self-describing OpenAPI + hosted `/docs` ([ADR 0109](0109-self-describing-openapi-and-hosted-docs.md)), and the docs-discovery contract ([ADR 0114](0114-instance-docs-discovery-contract.md)).
5
+ **Context:** [task 1992](https://builders.example.com#/task/1992) (BONGOS-V1, goal 36 "Robust, versioned, easy-to-call public API", criterion `api-versioning`). This is the **policy**; the mount + aliasing that implements it is R06 ([task 1993](https://builders.example.com#/task/1993)). Builds on the API-prefix chokepoint ([`src/bongos/api-prefix.js`](../../src/bongos/api-prefix.js), task 1918/1919), the self-describing OpenAPI + hosted `/docs` ([ADR 0109](0109-self-describing-openapi-and-hosted-docs.md)), and the docs-discovery contract ([ADR 0114](0114-instance-docs-discovery-contract.md)).
6
6
 
7
7
  ## Problem
8
8
 
@@ -2114,5 +2114,9 @@ is load-bearing: the script throws rather than guess if it is missing, and
2114
2114
  landed since 1.19.818 with no explicit bump. run 35206448426. (task 1002620)
2115
2115
  1.19.820 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
2116
2116
  landed since 1.19.819 with no explicit bump. run 35209838815. (task 1002620)
2117
+ 1.19.821 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
2118
+ landed since 1.19.820 with no explicit bump. run 35212866365. (task 1002620)
2119
+ 1.19.822 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
2120
+ landed since 1.19.821 with no explicit bump. run 35213847140. (task 1002620)
2117
2121
  ---------------------------------------------------------------------------
2118
2122
  ```
@@ -14,7 +14,7 @@ This recipe is the audit deliverable for [#269](https://example.com/builders#/ta
14
14
  - **Claim side is already deterministic.** `claimTask()` runs in a single transaction with `SELECT … FOR UPDATE` on the task row, a touches-overlap check, and the `uniq_active_claim_per_task` partial unique index. A lost race surfaces as `ALREADY_CLAIMED` (Postgres `23505`), never as corruption. No change needed.
15
15
  - **Merge side is the risk, and it has ONE shared resource:** the single `main` worktree (`REPO_ROOT`) on the build machine. Every ship's auto-merge and every `/merge-mode` run checks out `main`, fetches, merges, pushes, and deploys *there*.
16
16
  - **[#462](https://example.com/builders#/task/462) (criterion C4)** added a lockfile so two concurrent **ships** couldn't collide — but the lock lived *inside* `ship.js`, so `/merge-mode` (which mutates the same tree by hand) never took it. That left two races open: **ship-vs-merge-mode** and **merge-mode-vs-merge-mode**.
17
- - **[#269](https://example.com/builders#/task/269) (this task)** extracts the lock into [`src/bongos/merge-lock.js`](../../src/bongos/merge-lock.js) and has **both** surfaces acquire the **same** mutex. `/merge-mode` takes it via the [`scripts/gds/merge-lock.js`](../../scripts/gds/merge-lock.js) CLI before it touches `main`, and releases it after deploy.
17
+ - **[#269](https://example.com/builders#/task/269) (this task)** extracts the lock into [`modules/lifecycle/merge-lock.js`](../../modules/lifecycle/merge-lock.js) and has **both** surfaces acquire the **same** mutex. `/merge-mode` takes it via the [`scripts/gds/merge-lock.js`](../../scripts/gds/merge-lock.js) CLI before it touches `main`, and releases it after deploy.
18
18
 
19
19
  ---
20
20
 
@@ -48,7 +48,7 @@ This recipe is the audit deliverable for [#269](https://example.com/builders#/ta
48
48
 
49
49
  ## The mutex, precisely
50
50
 
51
- [`src/bongos/merge-lock.js`](../../src/bongos/merge-lock.js) is a portable, advisory file lock at `<REPO_ROOT>/.git/otb-automerge.lock`. **Not `flock(2)`** — ships run on macOS over an iCloud-synced filesystem where `flock` is unreliable, and the manual `/merge-mode` holder is a short-lived CLI process with no long-lived owner.
51
+ [`modules/lifecycle/merge-lock.js`](../../modules/lifecycle/merge-lock.js) is a portable, advisory file lock at `<REPO_ROOT>/.git/otb-automerge.lock`. **Not `flock(2)`** — ships run on macOS over an iCloud-synced filesystem where `flock` is unreliable, and the manual `/merge-mode` holder is a short-lived CLI process with no long-lived owner.
52
52
 
53
53
  Lockfile format (3 lines): `pid` · `epoch-ms timestamp` · `mode` (`auto` | `manual`).
54
54
 
@@ -2,11 +2,11 @@
2
2
 
3
3
  How the `search_chunks` index (the recall layer, [ADR 0060](../adr/0060-gds-retrieval-layer.md)) gets populated from repo markdown, and how to operate it. Read before touching `scripts/gds/search-ingest-md.js` or the deploy hook.
4
4
 
5
- > The query side is [`src/bongos/search.js`](../../src/bongos/search.js) + [`POST /api/gds/search`](../../src/bongos/routes/search.js) ([#949](https://example.com/builders#/task/949)); rank/owner scoping + its isolation suite are [#950](https://example.com/builders#/task/950). Ingestion has two halves: **repo markdown** (this recipe's first sections — [#947](https://example.com/builders#/task/947), the deploy hook) and **DB prose** (the [DB-prose nightly](#db-prose-nightly-timer) section below — [#948](https://example.com/builders#/task/948), a systemd timer).
5
+ > The query side is [`modules/memory/search.js`](../../modules/memory/search.js) + [`POST /api/gds/search`](../../modules/memory/routes/search.js) ([#949](https://example.com/builders#/task/949)); rank/owner scoping + its isolation suite are [#950](https://example.com/builders#/task/950). Ingestion has two halves: **repo markdown** (this recipe's first sections — [#947](https://example.com/builders#/task/947), the deploy hook) and **DB prose** (the [DB-prose nightly](#db-prose-nightly-timer) section below — [#948](https://example.com/builders#/task/948), a systemd timer).
6
6
 
7
7
  ## What it does
8
8
 
9
- `node scripts/gds/search-ingest-md.js` walks the repo's markdown (~226 files), chunks each file via the heading-aware chunker ([`src/bongos/search-chunker.js`](../../src/bongos/search-chunker.js)), and upserts the chunks into `search_chunks`:
9
+ `node scripts/gds/search-ingest-md.js` walks the repo's markdown (~226 files), chunks each file via the heading-aware chunker ([`modules/memory/search-chunker.js`](../../modules/memory/search-chunker.js)), and upserts the chunks into `search_chunks`:
10
10
 
11
11
  - **Delta-driven.** Each chunk's `content_hash` (sha256 of its body) is the change key. The upsert's `ON CONFLICT … DO UPDATE … WHERE content_hash IS DISTINCT FROM EXCLUDED.content_hash` makes an unchanged chunk a **no-op** (no row rewrite). A re-run over an unchanged tree writes nothing.
12
12
  - **Prunes shrinkage.** If a file loses sections, chunks beyond the new count are deleted; if a file is removed from the tree entirely, all its chunks are deleted (`source_kind='doc' AND source_ref NOT IN (present)`).
@@ -198,7 +198,148 @@ function findStrandedTasks(tasks) {
198
198
  return out;
199
199
  }
200
200
 
201
+ // ---- PROSE-DECLARED GATES (task 1003857) -----------------------------------
202
+ //
203
+ // Everything above reads the STRUCTURED graph — blocked_by edges. A gate written
204
+ // in a task's DESCRIPTION is entirely unpoliced, and they go stale the same way:
205
+ // task 1002839 (ready) names "existing open task 1001380" as a precondition, and
206
+ // 1001380 was abandoned on 2026-08-18, superseded by ADR 0089. A builder opening
207
+ // 1002839 is told to coordinate with a task that does not exist, and told it is open.
208
+ //
209
+ // ADVISORY, NEVER A GATE. A prose match is weaker evidence than an edge: the text
210
+ // may be history ("this replaces task N"), a citation, or simply wrong. It reports
211
+ // candidates for a human to read and must never refuse a claim — that is the
212
+ // difference between this and DEAD_DEP_STATUSES above, and it is deliberate.
213
+ //
214
+ // PRECISION COMES FROM THE VERB, NOT THE ID. Matching "any abandoned id in the
215
+ // body" is the mistake that made the sibling supersession scan unusable (idea
216
+ // 1000807: ~44 hits, nearly all citations). So an id counts only when a GATE PHRASE
217
+ // governs it within a short window — needs / depends on / blocked by / gated on /
218
+ // requires / after / prereq — and the window is small enough that a paragraph
219
+ // mentioning a gate and, separately, an id does not join them up.
220
+ // TWO PHRASINGS, because the live evidence has both — and the second is the one the
221
+ // filing task's own example actually uses.
222
+ //
223
+ // A GATE: 'needs task N', 'blocked by N', 'depends on N'.
224
+ // A LIVENESS ASSERTION: 'existing OPEN task N', 'task N is ready to claim'.
225
+ //
226
+ // Task 1002839 — the live instance the task names — reads "and existing open task
227
+ // 1001380 covers overlapping ground". That is not a dependency; it is a claim that
228
+ // 1001380 is OPEN, and 1001380 was abandoned on 2026-08-18. A gate-only matcher
229
+ // missed it, which I found by measuring rather than by reading the spec: the first
230
+ // version returned it only because a 'needs' in the PREVIOUS clause leaked across a
231
+ // semicolon, and tightening that correctly dropped it.
232
+ //
233
+ // Both shapes send a builder to a task that is not there, which is the harm. Only
234
+ // the liveness one also states something false on its face.
235
+ const GATE_PHRASE_RE = /\b(?:needs?|depends?\s+on|dependent\s+on|blocked\s+by|blocks?|gated\s+on|gates?|requires?|required\s+by|prereq(?:uisite)?s?|precondition|waits?\s+on|waiting\s+on|after)\b/i;
236
+
237
+ // 'open task N', 'existing open task N', 'still-open task N', 'the open task N'.
238
+ // Anchored to the id's immediate left so a paragraph that says 'open' somewhere does
239
+ // not qualify every id in it.
240
+ const LIVENESS_BEFORE_RE = /\b(?:still[-\s]+)?open\s+(?:task\s+)?#?$/i;
241
+ // 'task N is ready to claim', 'task N is still open', 'task N remains open'.
242
+ const LIVENESS_AFTER_RE = /^\s*(?:\([^)]{0,80}\)\s*)?(?:is|remains|stays)\s+(?:still\s+)?(?:open|ready\s+to\s+claim|unclaimed|in\s+flight)\b/i;
243
+
244
+ // How far after a gate phrase an id still counts as governed by it. Sized to span
245
+ // 'needs the existing open task 1001380 to land first', not a whole paragraph.
246
+ const GATE_WINDOW = 80;
247
+
248
+ // A task id in prose. Two forms, and the second is the trap.
249
+ //
250
+ // LIVE form — 6-7 digits: 'existing open task 1001380'.
251
+ // SHORT form — the PRE-OFFSET ref this project used before the import, written as
252
+ // `#528` or 'task 528'. Task 1000528's own gate is written that way,
253
+ // and it is the case that motivated the original row — so a live-id
254
+ // match misses precisely the instance this exists to find.
255
+ //
256
+ // The short form is only recognised with an explicit `task ` / `#` prefix. A bare
257
+ // 3-digit number in prose is a count, a line number or a year far more often than it
258
+ // is a task, and this runs over every description on the board.
259
+ const ID_RE = /(?:\btask\s+|#)(\d{1,5})\b|(?:\btask\s+|#)?(\d{6,7})\b/g;
260
+
261
+ // The id spaces to probe, newest first. Same precedent as diagram-facts.js: the import
262
+ // shifted every id by 1,000,000, so a pre-offset ref resolves by adding the offset.
263
+ const IMPORT_ID_OFFSETS = [0, 1_000_000];
264
+
265
+ // PURE — every id a single textual ref could mean, most likely first. A short ref
266
+ // yields the offset candidate too; a live-width ref is already resolved.
267
+ function idCandidates(raw) {
268
+ const n = Number(raw);
269
+ if (!Number.isInteger(n) || n <= 0) return [];
270
+ // A ref already in the LIVE width is resolved - probing an offset on it would invent
271
+ // a second id that is not what the text says (1001380 + 1e6 = 2001380, a row nobody
272
+ // wrote). The offset probe exists for the SHORT pre-offset form only.
273
+ if (String(n).length >= 6) return [String(n)];
274
+ const out = [];
275
+ for (const off of IMPORT_ID_OFFSETS) {
276
+ const cand = String(n + off);
277
+ // Only offer a candidate that could BE an id: the live space is 6-7 digits.
278
+ if (cand.length >= 6 && cand.length <= 7 && !out.includes(cand)) out.push(cand);
279
+ }
280
+ return out;
281
+ }
282
+
283
+ // Ids that share the integer space but are NOT tasks. Measured, not guessed: the
284
+ // first run of this over the live board returned two hits and one was task 1002844's
285
+ // 'Source: idea 1000454' — an idea citation, whose id happens to look like a task's.
286
+ // Reporting it would have sent a human to look up a task that does not exist, which
287
+ // is the very failure this detector exists to stop.
288
+ const OTHER_ENTITY_RE = /\b(?:idea|blocker|report|learning|claim|builder|goal|version|PR|pull\s+request)\s+#?$/i;
289
+
290
+ // PURE — the ids this text appears to declare a GATE on. Exported so the rule is
291
+ // testable without a DB: the risk here is a matcher that quietly matches nothing (a
292
+ // clean report) or everything (a report nobody reads).
293
+ function proseGatedIds(text) {
294
+ const src = String(text || '');
295
+ const out = new Set();
296
+ for (const m of src.matchAll(ID_RE)) {
297
+ const rawId = m[1] || m[2];
298
+ const before = src.slice(Math.max(0, m.index - GATE_WINDOW), m.index);
299
+ // An id belonging to another entity is not a task gate, whatever governs it.
300
+ if (OTHER_ENTITY_RE.test(before)) continue;
301
+ // The window stops at a SENTENCE BOUNDARY. Also measured: the same false
302
+ // positive had 'needs' in the preceding sentence ('…the first thing that task
303
+ // needs. Source: idea 1000454'), so a fixed-width window let a verb govern across
304
+ // a full stop. A gate and its id belong to one sentence.
305
+ // A liveness assertion binds TIGHTLY to the id on either side, so it needs no
306
+ // window and no sentence rule.
307
+ const after = src.slice(m.index + m[0].length, m.index + m[0].length + 60);
308
+ if (LIVENESS_BEFORE_RE.test(before) || LIVENESS_AFTER_RE.test(after)) { for (const c of idCandidates(rawId)) out.add(c); continue; }
309
+ // A GATE may be a few words back, but the window stops at a SENTENCE BOUNDARY:
310
+ // measured, a fixed-width window let 'needs' govern across a full stop and a
311
+ // semicolon into an unrelated clause.
312
+ const lastStop = Math.max(before.lastIndexOf('. '), before.lastIndexOf('\n'), before.lastIndexOf('; '));
313
+ const governing = lastStop === -1 ? before : before.slice(lastStop + 1);
314
+ // Only the text BEFORE the id, so 'task 1001380 needs review' is not read as a
315
+ // gate ON 1001380 — the verb has to govern the id, not follow it.
316
+ if (GATE_PHRASE_RE.test(governing)) for (const c of idCandidates(rawId)) out.add(c);
317
+ }
318
+ return out;
319
+ }
320
+
321
+ // PURE — live tasks whose PROSE names a dead task as a gate. `tasks` are rows with
322
+ // { id, status, description }; `statusById` answers what an id's status is.
323
+ // Deliberately mirrors findStrandedTasks' shape so audit-deps can print one answer
324
+ // to "what is stranded" rather than two.
325
+ function findProseGatedTasks(tasks, statusById) {
326
+ const out = [];
327
+ for (const t of Array.isArray(tasks) ? tasks : []) {
328
+ if (!t || !LIVE_STATUSES.has(String(t.status))) continue;
329
+ const dead = [];
330
+ for (const id of proseGatedIds(t.description)) {
331
+ if (String(id) === String(t.id)) continue; // a task citing itself is not a gate
332
+ const st = statusById(id);
333
+ if (st && DEAD_DEP_STATUSES.has(String(st))) dead.push({ id: String(id), status: String(st) });
334
+ }
335
+ if (dead.length) out.push({ task_id: t.id, status: t.status, title: t.title, prose_dead_deps: dead.sort((a, b) => Number(a.id) - Number(b.id)) });
336
+ }
337
+ return out;
338
+ }
339
+
201
340
  module.exports = {
202
341
  DEAD_DEP_STATUSES, LIVE_STATUSES,
203
342
  absorbedInto, depRefusalRow, splitDeadDeps, deadDepHint, findStrandedTasks,
343
+ proseGatedIds, findProseGatedTasks, idCandidates, GATE_PHRASE_RE, GATE_WINDOW, OTHER_ENTITY_RE,
344
+ LIVENESS_BEFORE_RE, LIVENESS_AFTER_RE, IMPORT_ID_OFFSETS,
204
345
  };
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.820",
3
+ "version": "1.19.822",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.820",
9
+ "version": "1.19.822",
10
10
  "license": "AGPL-3.0-or-later",
11
11
  "dependencies": {
12
12
  "express": "^4.21.2",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.820",
3
+ "version": "1.19.822",
4
4
  "description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
5
5
  "license": "AGPL-3.0-or-later",
6
6
  "main": "src/platform-server.js",
@@ -17,14 +17,15 @@
17
17
  // gate's notion of "overlap".
18
18
  //
19
19
  // node scripts/gds/audit-deps.js [--version GDS-V3] [--min high|medium|low] [--json]
20
- // node scripts/gds/audit-deps.js --stranded [--json] # the INVERSE: dead edges (task 1003153)
20
+ // node scripts/gds/audit-deps.js --stranded [--json] # the INVERSE: dead edges (task 1003153),
21
+ // # plus prose-declared gates naming a dead task (task 1003857, advisory)
21
22
 
22
23
  const { touchesOverlap } = require('../../src/bongos/path-match.js');
23
24
  const { cliClient } = require('./cli-lib');
24
25
  const { resolveDefaultVersion } = require('./version-select.js');
25
26
  // task 1003153 — the DEAD-edge detector core, shared with the claim refusal so
26
27
  // "abandoned dependency" means one thing across the CLI, the route and this audit.
27
- const { findStrandedTasks } = require('../../modules/lifecycle/dead-deps.js');
28
+ const { findStrandedTasks, findProseGatedTasks } = require('../../modules/lifecycle/dead-deps.js');
28
29
  // task 1003752 — the route's page bound + the first-page offset rule, declared once.
29
30
  const { TASK_LIST_MAX_LIMIT, buildTaskPageQuery } = require('../../modules/lifecycle/task-list-limits.js');
30
31
 
@@ -129,6 +130,16 @@ async function reportStranded({ asJson }) {
129
130
  process.exit(1);
130
131
  }
131
132
  const stranded = findStrandedTasks(all);
133
+ // task 1003857: the same question asked of PROSE. The structured graph above
134
+ // reads blocked_by edges; a gate written in a description is unpoliced, and stales
135
+ // identically — task 1002839 told a builder to coordinate with "existing open task
136
+ // 1001380", abandoned since 2026-08-18. Reported HERE rather than in a second tool
137
+ // so there is one answer to "what is stranded".
138
+ const statusById = (() => {
139
+ const byId = new Map(all.map((t) => [String(t.id), String(t.status)]));
140
+ return (id) => byId.get(String(id)) || null;
141
+ })();
142
+ const proseGated = findProseGatedTasks(all, statusById);
132
143
 
133
144
  // Context worth stating even at zero: abandoned tasks that still CARRY
134
145
  // dependents are the latent trap. They report clean only because every
@@ -139,7 +150,10 @@ async function reportStranded({ asJson }) {
139
150
  }
140
151
 
141
152
  if (asJson) {
142
- console.log(JSON.stringify({ stranded, abandoned_deps_with_dependents: [...gatingAbandoned] }, null, 2));
153
+ console.log(JSON.stringify({ stranded, prose_gated: proseGated, abandoned_deps_with_dependents: [...gatingAbandoned] }, null, 2));
154
+ // ADVISORY: a prose match never changes the exit code. It is weaker evidence than
155
+ // an edge — the text may be history, a citation, or simply wrong — so it reports
156
+ // candidates for a human and must not gate a sweep or a CI leg.
143
157
  return stranded.length ? 1 : 0;
144
158
  }
145
159
  console.log(`audit-deps --stranded: ${all.length} task(s) scanned — ${stranded.length} LIVE task(s) stranded by an abandoned dependency\n`);
@@ -149,6 +163,14 @@ async function reportStranded({ asJson }) {
149
163
  console.log(` fix: drop the edge (DELETE /dependencies {"from_kind":"task","from_id":${s.task_id},"to_kind":"task","to_id":${s.dead_deps[0].id}})`);
150
164
  console.log(` or restore the dep (POST /tasks/${s.dead_deps[0].id}/promote)\n`);
151
165
  }
166
+ if (proseGated.length) {
167
+ console.log(`Prose-declared gates naming a dead task: ${proseGated.length} (ADVISORY — a human reads these; they never gate a claim)\n`);
168
+ for (const p of proseGated) {
169
+ console.log(`[PROSE] #${p.task_id} [${p.status}] ${(p.title || '').slice(0, 70)}`);
170
+ for (const d of p.prose_dead_deps) console.log(` body names → #${d.id} [${d.status}]`);
171
+ console.log(' fix: correct the sentence (PATCH /tasks/' + p.task_id + ' {"description":…}), or promote the named task if the gate is real\n');
172
+ }
173
+ }
152
174
  if (!stranded.length) {
153
175
  console.log('Nothing stranded — no live task is gated by an abandoned dependency.');
154
176
  console.log(`Latent exposure: ${gatingAbandoned.size} abandoned task(s) still carry dependents; they read clean only`);
package/src/module-api.js CHANGED
@@ -71,7 +71,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
71
71
  // there. scripts/gds/bump-version.js still rewrites the literal below; it appends
72
72
  // the entry to that file. Look for a version's history there, not here.
73
73
  // ---------------------------------------------------------------------------
74
- const CORE_VERSION = '1.19.820'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
74
+ const CORE_VERSION = '1.19.822'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
75
75
 
76
76
  // A namespaced logger so a module's log lines are attributable + consistent.
77
77
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
@@ -0,0 +1,139 @@
1
+ // tests/prose_gated_deps.mjs — a gate written in a task's DESCRIPTION goes stale the
2
+ // same way an edge does, and nothing was watching (task 1003857).
3
+ //
4
+ // The structured detector (task 1003153) reads blocked_by edges and never touches a
5
+ // description. Task 1002839 sat at `ready` telling a builder to coordinate with
6
+ // "existing open task 1001380" — abandoned since 2026-08-18, superseded by ADR 0089.
7
+ //
8
+ // EVERY REGEX HERE WAS SIZED BY MEASUREMENT, not by reading the spec, and the cases
9
+ // below are the measurements. The first cut over the live board (3,647 tasks) returned
10
+ // two hits and BOTH taught something: one was an `idea` id wearing a task-shaped
11
+ // number, and the true positive only matched because a verb leaked across a semicolon.
12
+ // Tightening that correctly dropped the true positive too — which is how the liveness
13
+ // phrasing was found, because the real sentence is not a dependency at all.
14
+
15
+ import { test } from 'node:test';
16
+ import assert from 'node:assert/strict';
17
+ import { createRequire } from 'node:module';
18
+
19
+ const require = createRequire(import.meta.url);
20
+ const { proseGatedIds, findProseGatedTasks, idCandidates, LIVE_STATUSES } = require('../modules/lifecycle/dead-deps.js');
21
+
22
+ const ids = (t) => [...proseGatedIds(t)];
23
+
24
+ test('the LIVE sentence from task 1002839 is caught', () => {
25
+ // Verbatim. This is a liveness assertion, not a dependency — "existing open task N".
26
+ assert.deepEqual(
27
+ ids('this is a gate-review HARD FLOOR surface under ADR 0043 and needs owner approval even for an Archon; and existing open task 1001380 covers overlapping ground with stale touches[]'),
28
+ ['1001380'],
29
+ );
30
+ });
31
+
32
+ test('a liveness assertion AFTER the id is caught too', () => {
33
+ assert.deepEqual(ids('task 1001380 (Unify the grader: one multimodal engine) is ready to claim'), ['1001380']);
34
+ assert.deepEqual(ids('task 1001380 remains open'), ['1001380']);
35
+ });
36
+
37
+ test('an ordinary dependency phrasing is caught', () => {
38
+ assert.deepEqual(ids('needs task 1001380 to land first'), ['1001380']);
39
+ assert.deepEqual(ids('depends on #1001380'), ['1001380']);
40
+ assert.deepEqual(ids('blocked by 1001380'), ['1001380']);
41
+ });
42
+
43
+ test('an ANOTHER-ENTITY id is not a task gate', () => {
44
+ // Measured: 'Source: idea 1000454' was one of two hits on the first live run.
45
+ // Reporting it sends a human to look up a task that does not exist — the exact
46
+ // failure this detector exists to prevent.
47
+ assert.deepEqual(ids('the first thing that task needs. Source: idea 1000454'), []);
48
+ assert.deepEqual(ids('needs idea 1000454'), []);
49
+ assert.deepEqual(ids('blocked by blocker 1000082'), []);
50
+ assert.deepEqual(ids('after PR 1000454 lands'), []);
51
+ });
52
+
53
+ test('a gate verb does NOT reach across a sentence or clause boundary', () => {
54
+ // Measured: a fixed-width window let 'needs' govern an id in the next clause.
55
+ assert.deepEqual(ids('this needs owner approval; see 1001380 for history'), []);
56
+ assert.deepEqual(ids('this needs approval. Background in 1001380.'), []);
57
+ });
58
+
59
+ test('history and citation are not gates', () => {
60
+ assert.deepEqual(ids('this supersedes task 1001380'), []);
61
+ assert.deepEqual(ids('task 1001380 needs review'), [], 'the verb must govern the id, not follow it');
62
+ assert.deepEqual(ids('open questions remain; see 1001380'), []);
63
+ });
64
+
65
+ test('a bare number that is not a task id is ignored', () => {
66
+ assert.deepEqual(ids('needs the 2026 rollout and 4784 commits'), []);
67
+ });
68
+
69
+ // ---- OFFSET-AWARENESS (task 1003857) ---------------------------------------
70
+ //
71
+ // THE TRAP THE TASK NAMES, and which I walked straight into by reading only the first
72
+ // part of the description. Task 1000528's own gate is written as a bare PRE-OFFSET
73
+ // short-form ref (`#513`, `task 506`), not the live id — so a live-id-only matcher
74
+ // misses the very case that motivated the original row. The import shifted every id by
75
+ // 1,000,000; diagram-facts.js's IMPORT_ID_OFFSETS is the existing precedent for probing
76
+ // both spaces.
77
+
78
+ test('a SHORT pre-offset ref resolves to its live id', () => {
79
+ assert.deepEqual(ids('needs task 528 to land first'), ['1000528']);
80
+ assert.deepEqual(ids('blocked by #528'), ['1000528']);
81
+ });
82
+
83
+ test('a LIVE-width ref is NOT offset a second time', () => {
84
+ // 1001380 + 1e6 = 2001380, a row nobody ever wrote. Probing an already-resolved id
85
+ // invents a second candidate that the text does not say.
86
+ assert.deepEqual(ids('and existing open task 1001380 covers overlapping ground'), ['1001380']);
87
+ assert.deepEqual(idCandidates('1001380'), ['1001380']);
88
+ assert.deepEqual(idCandidates('528'), ['1000528']);
89
+ });
90
+
91
+ test('a short ref needs an explicit task/# prefix — a bare small number is not an id', () => {
92
+ // This runs over every description on the board, where a 3-digit number is a count, a
93
+ // line number or a year far more often than it is a task.
94
+ assert.deepEqual(ids('needs 3 more reviews'), []);
95
+ assert.deepEqual(ids('needs 528 lines changed'), []);
96
+ assert.deepEqual(ids('needs task 528'), ['1000528'], 'the prefix is what makes it a ref');
97
+ });
98
+
99
+ test('the offset probe still respects the gate/liveness rules', () => {
100
+ assert.deepEqual(ids('this supersedes task 528'), [], 'history is not a gate, at either width');
101
+ assert.deepEqual(ids('needs idea 454'), [], 'another entity is not a task, at either width');
102
+ assert.deepEqual(ids('existing open task 528 covers this'), ['1000528'], 'liveness works at short width too');
103
+ });
104
+
105
+ test('findProseGatedTasks reports only LIVE rows gated on a DEAD one', () => {
106
+ const statusById = (id) => ({ 1001380: 'abandoned', 1001381: 'shipped' })[id] || null;
107
+ const rows = [
108
+ { id: '1', status: 'ready', title: 'a', description: 'needs task 1001380' },
109
+ { id: '2', status: 'ready', title: 'b', description: 'needs task 1001381' }, // dep alive
110
+ { id: '3', status: 'shipped', title: 'c', description: 'needs task 1001380' }, // row not live
111
+ ];
112
+ const out = findProseGatedTasks(rows, statusById);
113
+ assert.equal(out.length, 1);
114
+ assert.equal(out[0].task_id, '1');
115
+ assert.deepEqual(out[0].prose_dead_deps, [{ id: '1001380', status: 'abandoned' }]);
116
+ });
117
+
118
+ test('a task citing ITSELF is not gated on itself', () => {
119
+ const out = findProseGatedTasks(
120
+ [{ id: '1001380', status: 'ready', title: 'x', description: 'needs task 1001380' }],
121
+ () => 'abandoned',
122
+ );
123
+ assert.deepEqual(out, []);
124
+ });
125
+
126
+ test('an unknown id is not reported — absence of a row is not death', () => {
127
+ const out = findProseGatedTasks(
128
+ [{ id: '1', status: 'ready', title: 'x', description: 'needs task 1009999' }],
129
+ () => null,
130
+ );
131
+ assert.deepEqual(out, [], 'a task the caller cannot see may simply be outside the page it fetched');
132
+ });
133
+
134
+ test('LIVE_STATUSES is the shared notion, not a second copy', () => {
135
+ // If this detector grew its own status set, the two halves of --stranded would
136
+ // disagree about what "live" means and nobody would notice.
137
+ assert.ok(LIVE_STATUSES.has('ready') && LIVE_STATUSES.has('backlog'));
138
+ assert.ok(!LIVE_STATUSES.has('shipped') && !LIVE_STATUSES.has('abandoned'));
139
+ });