@bongos/core 1.20.7 → 1.20.9

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.20.7",
6
- "core_contract": "1.20.7",
7
- "source_commit": "9a8e336459336ffc6beebeac8b6496960dec3bdd",
5
+ "core_version": "1.20.9",
6
+ "core_contract": "1.20.9",
7
+ "source_commit": "2b36b101931a77f890370e4ca5bfe3c52380aa86",
8
8
  "source_ref": "HEAD",
9
- "built_at": "2026-09-30T04:20:20.175Z",
9
+ "built_at": "2026-09-30T04:43:40.633Z",
10
10
  "redaction": {
11
11
  "model": "docs-redacted+functional-verbatim",
12
12
  "docs_redacted": 556,
13
13
  "agent_docs_stubbed": 25,
14
- "functional_verbatim": 2621,
14
+ "functional_verbatim": 2624,
15
15
  "rules": 3,
16
16
  "gate_literals": 3,
17
17
  "gate": "passed"
18
18
  },
19
- "file_count": 3203,
20
- "tree_sha256": "dcbd562f300693a4aac156ab9d3d09aa65f2ec33fe5f0b469846f0eaca1a6005",
19
+ "file_count": 3206,
20
+ "tree_sha256": "da9805685f777ab35786f149befb6dbcacca93c0a0e53c2ffee30b11544aa62d",
21
21
  "files": [
22
22
  {
23
23
  "path": ".claude/skills/ask-for-help/SKILL.md",
@@ -1842,7 +1842,7 @@
1842
1842
  {
1843
1843
  "path": "docs/adr/0274-one-kernel-three-role-packs.md",
1844
1844
  "mode": "0000644",
1845
- "sha256": "d37cbce118162e144969efde564d92778bb6175c3af0d4e1502195aea18aa004"
1845
+ "sha256": "4164f1f1fb3cf0317ee19e168420aef85d7c529a5300d3fc46566010f42d9fbf"
1846
1846
  },
1847
1847
  {
1848
1848
  "path": "docs/adr/0275-one-source-for-a-written-role-responsibility.md",
@@ -2762,7 +2762,7 @@
2762
2762
  {
2763
2763
  "path": "docs/file-map.md",
2764
2764
  "mode": "0000644",
2765
- "sha256": "803f616840f604487828796195cdc74a3f834dc5f5117309ef01725b9d13b7aa"
2765
+ "sha256": "fd6ec16a880ab7b3ef0e31674c51ab9733ffda14855043aa6cc5afaddb20c03e"
2766
2766
  },
2767
2767
  {
2768
2768
  "path": "docs/handoff-template.md",
@@ -2772,7 +2772,7 @@
2772
2772
  {
2773
2773
  "path": "docs/module-api-changelog.md",
2774
2774
  "mode": "0000644",
2775
- "sha256": "20bca63270af7e3ccc11037fc0faf76c5d7ed75d8bde9b115c96b1b6f4e985a2"
2775
+ "sha256": "030b3fb4cd9862ccb5e1d96644271f79485912b6b6abba869dafde16715c4d84"
2776
2776
  },
2777
2777
  {
2778
2778
  "path": "docs/modules-contract.md",
@@ -2837,22 +2837,22 @@
2837
2837
  {
2838
2838
  "path": "docs/onboarding/slash-commands.md",
2839
2839
  "mode": "0000644",
2840
- "sha256": "92ce9c4de763330c4ac877a1ba79d0b11d88797c711b3517adebe8604bdf59ea"
2840
+ "sha256": "cc792eb9391d147e443ba646f991f99f88245fbb96adeda7bf96364fa9bc7a85"
2841
2841
  },
2842
2842
  {
2843
2843
  "path": "docs/packs/artist.md",
2844
2844
  "mode": "0000644",
2845
- "sha256": "9d317443b3676bf68b7818473e75448e26a16c6316cad7298de7a4037d73cf5c"
2845
+ "sha256": "d9a0b37fb3e03ab2e0f874abf3db40fed123cae5c0f6443a0e176adcdfc30b1b"
2846
2846
  },
2847
2847
  {
2848
2848
  "path": "docs/packs/engineer.md",
2849
2849
  "mode": "0000644",
2850
- "sha256": "416aa7f4303a0148d52ab5c0e5720a327fa260a034a9558774df125e8876eee2"
2850
+ "sha256": "30e4d2363f1fea52cb347ba8a642277fe5046249f77858e947b4434150ff5746"
2851
2851
  },
2852
2852
  {
2853
2853
  "path": "docs/packs/ideator.md",
2854
2854
  "mode": "0000644",
2855
- "sha256": "bcb98ba4302f46c2001eb7081684300150966a7b05cc43691199fa1573292f90"
2855
+ "sha256": "e09e80d1bd99951f9edde09a743ef4e597b26810fca26c37045c2d1f9dc8b2f6"
2856
2856
  },
2857
2857
  {
2858
2858
  "path": "docs/page-inventory.json",
@@ -3939,6 +3939,11 @@
3939
3939
  "mode": "0000644",
3940
3940
  "sha256": "b80e47e3a9d27909521d8a9adca0376381705fc6618435b91c9f5ee585135afa"
3941
3941
  },
3942
+ {
3943
+ "path": "migrations/core_263_closeness_history_indexes.sql",
3944
+ "mode": "0000644",
3945
+ "sha256": "6f502903c02f48a65370d2ca167b42a5ea514bed38bb9862d34465ea627191f2"
3946
+ },
3942
3947
  {
3943
3948
  "path": "modules/agents/CLAUDE.md",
3944
3949
  "mode": "0000644",
@@ -6042,7 +6047,7 @@
6042
6047
  {
6043
6048
  "path": "modules/lifecycle/closeness.js",
6044
6049
  "mode": "0000644",
6045
- "sha256": "4ea4fe1c93ffe6d6f42af31d96dfe402d7dbccb4a5825b71c571024548a87c25"
6050
+ "sha256": "d464e808e8854e68f4796cd92be96a326104375bb7b82c570291939910e48c39"
6046
6051
  },
6047
6052
  {
6048
6053
  "path": "modules/lifecycle/collab-settle.js",
@@ -6567,7 +6572,7 @@
6567
6572
  {
6568
6573
  "path": "modules/lifecycle/ship-card.js",
6569
6574
  "mode": "0000644",
6570
- "sha256": "a055854c667a30b46465db35e8d88dd92b0d6438a5609281e1c49e1ae66f3a7f"
6575
+ "sha256": "1935e6f0a3fb9089c2aacd9bbdf1ff1296487065c8c5a25beaf96306d88a9f5b"
6571
6576
  },
6572
6577
  {
6573
6578
  "path": "modules/lifecycle/ship-files.js",
@@ -8852,12 +8857,12 @@
8852
8857
  {
8853
8858
  "path": "package-lock.json",
8854
8859
  "mode": "0000644",
8855
- "sha256": "1822db240575bf86013d014572456d1f007004bfdc29209929938288ba43e3b2"
8860
+ "sha256": "51e46a6ba6eb82e2c829d8c5a220cdc835fe959e0b73f12dcffa69b61de3325c"
8856
8861
  },
8857
8862
  {
8858
8863
  "path": "package.json",
8859
8864
  "mode": "0000644",
8860
- "sha256": "1b1b7816c342fed258f9eaa453ce64588f26fe28beb4ab3eb1dfc06ec8f4529e"
8865
+ "sha256": "131c787425281094366c5871f082c5b8340e593eaa7c5d9aaf779ceb906da786"
8861
8866
  },
8862
8867
  {
8863
8868
  "path": "public-docs/index.html",
@@ -8877,7 +8882,7 @@
8877
8882
  {
8878
8883
  "path": "release-notes.json",
8879
8884
  "mode": "0000644",
8880
- "sha256": "59dcfa061d238c013470c67e6a82249f559fdbc4509c0c1d4ba8c29c03cc68ab"
8885
+ "sha256": "7c9e016bd4bcc8e7f702fb3dc2aa9112cf6b7c27f27ed638b2d357a906f30088"
8881
8886
  },
8882
8887
  {
8883
8888
  "path": "scripts/bongos-mcp.js",
@@ -9172,7 +9177,7 @@
9172
9177
  {
9173
9178
  "path": "scripts/gds/claim.js",
9174
9179
  "mode": "0000644",
9175
- "sha256": "7c1917a46bad730419a57047cc34ab21ff9fb2268d4c090a18effa0625e9678a"
9180
+ "sha256": "78a7e407218051892c1aa0934474084deec94768fdd28fe2c3fa447fe48905fd"
9176
9181
  },
9177
9182
  {
9178
9183
  "path": "scripts/gds/classify-discipline.js",
@@ -9327,7 +9332,7 @@
9327
9332
  {
9328
9333
  "path": "scripts/gds/discipline-modes.json",
9329
9334
  "mode": "0000644",
9330
- "sha256": "a62a714209198f02ca4f964ecfd1211dd3712b946f049843a7ec921b5907f1d0"
9335
+ "sha256": "467b66fbb778b9f8eeb03989b18fc085e4c4ccb6b397ca2777cdd82360675c42"
9331
9336
  },
9332
9337
  {
9333
9338
  "path": "scripts/gds/discord-channels.js",
@@ -10017,7 +10022,7 @@
10017
10022
  {
10018
10023
  "path": "scripts/gds/role-pack-guard.js",
10019
10024
  "mode": "0000644",
10020
- "sha256": "7ac55428d01852cd169bd5cb319af306a8f7798489fa89780921f4119c2f552f"
10025
+ "sha256": "34edfb2553f3441387b8bca2f0608b95b089f9d3efd7cd370e83fd9bc3652296"
10021
10026
  },
10022
10027
  {
10023
10028
  "path": "scripts/gds/route-shadow-guard.js",
@@ -10699,6 +10704,11 @@
10699
10704
  "mode": "0000644",
10700
10705
  "sha256": "27630af327d56e6eea77eb7e402086ec695f9fd02f419e48af463ed1adbfab8c"
10701
10706
  },
10707
+ {
10708
+ "path": "src/bongos/craft-mode.js",
10709
+ "mode": "0000644",
10710
+ "sha256": "6df8bd98fbd15d3be903465390ab8f300aa029b1847c42cbe0e10c7af2e62a74"
10711
+ },
10702
10712
  {
10703
10713
  "path": "src/bongos/db-kernel.js",
10704
10714
  "mode": "0000644",
@@ -10912,7 +10922,7 @@
10912
10922
  {
10913
10923
  "path": "src/bongos/routes/me.js",
10914
10924
  "mode": "0000644",
10915
- "sha256": "375859169dde72844541a62a1ff0e97b97321faeed058a287ad7b9c31cdc3a82"
10925
+ "sha256": "989cb7aeb70d956dcd6e93f9fea3acf27fca85f17c5fd2f185b6a99541ede0ae"
10916
10926
  },
10917
10927
  {
10918
10928
  "path": "src/bongos/routes/modules.js",
@@ -10987,7 +10997,7 @@
10987
10997
  {
10988
10998
  "path": "src/module-api.js",
10989
10999
  "mode": "0000644",
10990
- "sha256": "37545c18b037b0de0faaba69a716f67b779a48815d0f5e8937a7202ac7bb8f65"
11000
+ "sha256": "154bd9ea1d7f6ea701c30fcfc8b2c4f26133710f2c2b30f0a1942894fb07c357"
10991
11001
  },
10992
11002
  {
10993
11003
  "path": "src/module-loader/catalog.js",
@@ -11762,7 +11772,7 @@
11762
11772
  {
11763
11773
  "path": "tests/closeness.mjs",
11764
11774
  "mode": "0000644",
11765
- "sha256": "be10e63b6a424914ba71537dbfa653dcb247b28b3e6a9a8caf4696268e4500cf"
11775
+ "sha256": "a1d1e2a77e0ad6b1a00c187775859dfcd646389959062b8b78173fc27c2c02ed"
11766
11776
  },
11767
11777
  {
11768
11778
  "path": "tests/cloud_bongos_pack.mjs",
@@ -11807,7 +11817,7 @@
11807
11817
  {
11808
11818
  "path": "tests/conductor_main_e2e.mjs",
11809
11819
  "mode": "0000644",
11810
- "sha256": "c3b01032ebd3d2c4093bfc1101c2a445c93c52ff520341d6f39a5f582a1e2b97"
11820
+ "sha256": "b81e385b1c1cc3a7b6981b7c3ee4cbe169d24384404fd0e9681560e85a6cf9a1"
11811
11821
  },
11812
11822
  {
11813
11823
  "path": "tests/confirm_attribution.mjs",
@@ -11959,6 +11969,11 @@
11959
11969
  "mode": "0000644",
11960
11970
  "sha256": "50f195ef2060df92016b58f541a28e7492dd95ad4da8bd0781bdc07007193338"
11961
11971
  },
11972
+ {
11973
+ "path": "tests/craft_mode.mjs",
11974
+ "mode": "0000644",
11975
+ "sha256": "26e499e01ee9ec71094e5d2b3b0a0cf367513b9f25a54d1bd716b0d8e318f91d"
11976
+ },
11962
11977
  {
11963
11978
  "path": "tests/credential_presence.mjs",
11964
11979
  "mode": "0000644",
@@ -15947,7 +15962,7 @@
15947
15962
  {
15948
15963
  "path": "tests/visual_review.mjs",
15949
15964
  "mode": "0000644",
15950
- "sha256": "102e80ed4c6c61583d9dfeca23db321be9b871cab3d8f47e4594674b7ecd3cf5"
15965
+ "sha256": "c6f6846bfcfd73ec9e7c47062d26a4ff0c38b6bf5ade1eab6293f458011de8c9"
15951
15966
  },
15952
15967
  {
15953
15968
  "path": "tests/wandering_prefs.mjs",
@@ -22,7 +22,7 @@ Meanwhile the craft-specific material that *did* exist lived in three slash-comm
22
22
 
23
23
  3. **`/dev`, `/ideate` and `/paint` are deleted.** The skill listing drops to 25,624 chars and 17 over-length descriptions.
24
24
 
25
- 4. **`scripts/gds/discipline-modes.json` becomes the role registry.** A core craft names a `pack` (a file to *read*); a module-contributed discipline may still name a `skill` (a command to *invoke*), which is how `ui` → `/design` survives unchanged, as ADR 0272 requires. `claim.js` prints whichever the entry carries, with the right verb.
25
+ 4. **`scripts/gds/discipline-modes.json` becomes the role registry.** A core craft names a `pack` (a file to *read*); a module-contributed discipline may still name a `skill` (a command to *invoke*), which is how `ui` → `/design` survives unchanged, as ADR 0272 requires. `claim.js` prints whichever the entry carries, with the right verb. *(Since task 1002992 the full pointer is injected by the Conductor from GET /me and `claim.js` prints only the one role line.)*
26
26
 
27
27
  5. **`scripts/gds/role-pack-guard.js` hard-fails CI** when an entry names a pack that is not in the tree, when an entry names neither pointer, when fewer than three crafts carry a pack, or when a pack exceeds its character budget (16,000 hard / 13,000 target, ratcheted just above the packs as authored).
28
28
 
package/docs/file-map.md CHANGED
@@ -143,7 +143,7 @@
143
143
  │ ├── discord-channels.js ← Archon CLI: plan/apply the Discord channel layout via the API (ADR 0036, #727)
144
144
  │ ├── setup.js ← one-time GitHub Device Flow → ~/.config/otb/gds-session.json
145
145
  │ ├── start.js ← list claimable tasks + active claims (`/builder-start`)
146
- │ ├── claim.js ← atomic POST /api/bongos/claims (`/builder-claim`); runs the worktree-per-claim guard (worktree-claim-guard.js) BEFORE the session check and the POST; prints the claim-time context pack incl. next-free ADR/migration numbers (V4.R26); printDisciplineMode() reads discipline-modes.json to route a claimed task to its playbook skill
146
+ │ ├── claim.js ← atomic POST /api/bongos/claims (`/builder-claim`); runs the worktree-per-claim guard (worktree-claim-guard.js) BEFORE the session check and the POST; prints the claim-time context pack incl. next-free ADR/migration numbers (V4.R26); printRoleLine() prints the claimed craft's one role line from discipline-modes.json and lands a ui claim's module-owned skills (the full pack pointer is injected by the Conductor from GET /me, task 1002992)
147
147
  │ ├── discipline-modes.json ← the ROLE REGISTRY read by claim.js: engineer/artist/ideator → their role pack under docs/packs/, module-contributed ui → the /design skill; absence of an entry = the default build-and-ship loop, fail-open at claim time — but scripts/gds/role-pack-guard.js hard-fails CI if an entry names a pack that is not in the tree ([#1267](https://example.com/builders#/task/1267))
148
148
  │ ├── next-number.js ← deterministic next-free ADR + migration number from origin/main ∪ local working tree; supersedes the drift-prone claim-time DB counter (V4.R26, [#964](https://example.com/builders#/task/964)). `node scripts/gds/next-number.js [--json]`
149
149
  │ ├── gen-repo-map.js ← Path B (ADR 0063, [#1224](https://example.com/builders#/task/1224)): zero-dep, deterministic symbol-skeleton generator → docs/repo-map.md + the generated factual block in each nested CLAUDE.md; ranks exported symbols by reference frequency. `--check` = CI gate; regenerated on deploy by ship.js regenerateRepoMap()
@@ -2671,5 +2671,9 @@ is load-bearing: the script throws rather than guess if it is missing, and
2671
2671
  landed since 1.20.5 with no explicit bump. run 36667238912. (task 1002620)
2672
2672
  1.20.7 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
2673
2673
  landed since 1.20.6 with no explicit bump. run 36668328505. (task 1002620)
2674
+ 1.20.8 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
2675
+ landed since 1.20.7 with no explicit bump. run 36669272909. (task 1002620)
2676
+ 1.20.9 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
2677
+ landed since 1.20.8 with no explicit bump. run 36669791458. (task 1002620)
2674
2678
  ---------------------------------------------------------------------------
2675
2679
  ```
@@ -60,7 +60,7 @@ Every one of these talks to the build system. They're grouped by when you'd reac
60
60
 
61
61
  ### Discipline modes — not slash commands any more
62
62
 
63
- The three core crafts no longer have a slash command. `/dev`, `/paint` and `/ideate` were **deleted** when the root instructions split into a role-neutral kernel (`CLAUDE.md`) plus one **role pack** per craft: `docs/packs/engineer.md`, `docs/packs/artist.md`, `docs/packs/ideator.md`. A claimed task's discipline routes you to your pack — `claim.js` prints its path — and you read it. A module-contributed discipline can still ship a skill, which is how `ui` → `/design` below works.
63
+ The three core crafts no longer have a slash command. `/dev`, `/paint` and `/ideate` were **deleted** when the root instructions split into a role-neutral kernel (`CLAUDE.md`) plus one **role pack** per craft: `docs/packs/engineer.md`, `docs/packs/artist.md`, `docs/packs/ideator.md`. A claimed task's discipline routes you to your pack — the Conductor injects its path into your session on the next prompt, and `claim.js` prints a one-line reminder — and you read it. A module-contributed discipline can still ship a skill, which is how `ui` → `/design` below works.
64
64
 
65
65
  ### Design and art pipeline
66
66
 
@@ -1,6 +1,6 @@
1
1
  # Artist pack
2
2
 
3
- > **The role pack for `discipline = artist`.** The kernel ([`CLAUDE.md`](../../CLAUDE.md)) is what every session reads whatever its craft; this file is the half that is only true for artists. Read the whole thing before acting — it changes how you *communicate* for the rest of the session. Registered in [`scripts/gds/discipline-modes.json`](../../scripts/gds/discipline-modes.json), which `claim.js` prints on a claim.
3
+ > **The role pack for `discipline = artist`.** The kernel ([`CLAUDE.md`](../../CLAUDE.md)) is what every session reads whatever its craft; this file is the half that is only true for artists. Read the whole thing before acting — it changes how you *communicate* for the rest of the session. Registered in [`scripts/gds/discipline-modes.json`](../../scripts/gds/discipline-modes.json); the Conductor injects this pointer into a session holding a claim of this craft (task 1002992).
4
4
 
5
5
  An art session is not the engineer's text-heavy build loop. The artist's subject is how the project **looks and reads** — its visuals, its copy, its voice, the feeling of the thing. A craft is an identity, not a toolchain ([ADR 0272](../adr/0272-a-craft-is-an-identity-not-a-toolchain.md)): an instance has artists whether or not it ships an art pipeline.
6
6
 
@@ -1,6 +1,6 @@
1
1
  # Engineer pack
2
2
 
3
- > **The role pack for `discipline = engineer`.** The kernel ([`CLAUDE.md`](../../CLAUDE.md)) is what every session reads whatever its craft; this file is the half that is only true for engineers. Read it once at the top of an engineering session. Registered in [`scripts/gds/discipline-modes.json`](../../scripts/gds/discipline-modes.json), which `claim.js` prints on a claim.
3
+ > **The role pack for `discipline = engineer`.** The kernel ([`CLAUDE.md`](../../CLAUDE.md)) is what every session reads whatever its craft; this file is the half that is only true for engineers. Read it once at the top of an engineering session. Registered in [`scripts/gds/discipline-modes.json`](../../scripts/gds/discipline-modes.json); the Conductor injects this pointer into a session holding a claim of this craft (task 1002992).
4
4
 
5
5
  Engineering is the project's **default loop**, so this pack introduces no new dynamic — its job is to make the standard explicit and keep you on it. Lean on it hardest when you are new here or running autonomously. (The Ideator pack flips *who drives*; the Artist pack flips *the medium*. This one is the baseline they invert from.)
6
6
 
@@ -1,6 +1,6 @@
1
1
  # Ideator pack
2
2
 
3
- > **The role pack for `discipline = ideator`.** The kernel ([`CLAUDE.md`](../../CLAUDE.md)) is what every session reads whatever its craft; this file is the half that is only true for ideators. Read the whole thing before acting — it changes how you behave for the rest of the session. Registered in [`scripts/gds/discipline-modes.json`](../../scripts/gds/discipline-modes.json), which `claim.js` prints on a claim.
3
+ > **The role pack for `discipline = ideator`.** The kernel ([`CLAUDE.md`](../../CLAUDE.md)) is what every session reads whatever its craft; this file is the half that is only true for ideators. Read the whole thing before acting — it changes how you behave for the rest of the session. Registered in [`scripts/gds/discipline-modes.json`](../../scripts/gds/discipline-modes.json); the Conductor injects this pointer into a session holding a claim of this craft (task 1002992).
4
4
 
5
5
  An ideation session is not the build-and-ship loop, and **it needs no claim**. "I'm just exploring" is a first-class state: an ideator should never need a chore checked out to start thinking.
6
6
 
@@ -0,0 +1,56 @@
1
+ -- core_263 — index the history the closeness signals join on (task 1003268)
2
+ --
3
+ -- modules/lifecycle/closeness.js builds a builder's "who have I worked near" graph
4
+ -- from three self-joins (task 1003174): overlapping CLAIMS in a shared goal,
5
+ -- SESSION LOGS on a shared goal's tasks, and SHIPS in a shared goal. The grader of
6
+ -- 1003174 flagged that the join keys might be unindexed, which would make each
7
+ -- query scale with the whole table instead of with one builder's goals. Checked
8
+ -- against the tree, key by key:
9
+ --
10
+ -- tasks.goal_id — ALREADY COVERED. The task was filed before core_228 added
11
+ -- idx_tasks_goal ON tasks (goal_id). A second index on the
12
+ -- same column would only cost writes, so none is added.
13
+ -- claims.builder_id — only idx_claims_builder_active (003), partial on
14
+ -- released_at IS NULL. Closeness reads SETTLED claims
15
+ -- (`mine.builder_id = $1 AND mine.claimed_at > now() - …`),
16
+ -- which a partial-on-active index cannot serve. Added below,
17
+ -- with claimed_at second so the time window is a range scan.
18
+ -- claims.task_id — only the partial UNIQUE on active claims (003). The join
19
+ -- `other.task_id = t_other.id` walks every claim, settled
20
+ -- ones included. Added below.
21
+ -- tasks.shipped_by — idx_tasks_needs_rebase (core_193) is partial on
22
+ -- needs_rebase, and idx_tasks_shipped_by_shipped_at
23
+ -- (core_228) is partial on status = 'shipped'. The ship
24
+ -- signal filters `shipped_by = $1 AND goal_id IS NOT NULL`
25
+ -- with no status predicate, so the planner can use neither.
26
+ -- Added below, partial on exactly that query's predicates.
27
+ -- session_logs — covered by idx_session_logs_task / _builder (003).
28
+ --
29
+ -- Not urgent at today's size (a few thousand tasks, fifteen builders); this closes
30
+ -- the gap before it grows rather than after it hurts.
31
+ --
32
+ -- Additive and idempotent: indexes only, no column, no constraint, no data. Safe
33
+ -- to run forward against a live database, and an older release running against it
34
+ -- just has unused indexes. Rollback is DROP INDEX.
35
+ --
36
+ -- NOT a lifecycle_* module migration: `tasks` and `claims` are CORE tables, and a
37
+ -- module migration may only touch its own namespaced tables (fitness.js
38
+ -- checkModuleMigrationsAdditive, ADR 0083 Decision #5). That boundary is why this
39
+ -- was split out of task 1003174 in the first place.
40
+
41
+ CREATE INDEX IF NOT EXISTS idx_claims_builder_history
42
+ ON claims (builder_id, claimed_at);
43
+
44
+ CREATE INDEX IF NOT EXISTS idx_claims_task_all
45
+ ON claims (task_id);
46
+
47
+ CREATE INDEX IF NOT EXISTS idx_tasks_shipped_by_goal
48
+ ON tasks (shipped_by, goal_id)
49
+ WHERE shipped_by IS NOT NULL AND goal_id IS NOT NULL;
50
+
51
+ COMMENT ON INDEX idx_claims_builder_history IS
52
+ 'Closeness (modules/lifecycle/closeness.js): a builder''s settled claims in a time window. idx_claims_builder_active is partial on active claims and cannot serve history.';
53
+ COMMENT ON INDEX idx_claims_task_all IS
54
+ 'Closeness: joins every claim (settled too) on task_id. The 003 unique index covers active claims only.';
55
+ COMMENT ON INDEX idx_tasks_shipped_by_goal IS
56
+ 'Closeness ship signal: shipped_by = $1 AND goal_id IS NOT NULL, no status predicate, so core_228''s status-partial index does not apply.';
@@ -37,13 +37,13 @@
37
37
  // The gate FAILS CLOSED: a consent read that throws or returns null yields the
38
38
  // empty set, and so the empty graph.
39
39
  //
40
- // A KNOWN PERFORMANCE GAP, measured rather than assumed. The signal joins key on
41
- // tasks.goal_id (NO index), claims.builder_id and claims.task_id (indexed only
42
- // partially, on ACTIVE claims — closeness reads history) and tasks.shipped_by
43
- // (partial too). At today's size that is fine; it scales with total table size
44
- // rather than goal size. The indexes belong in a core_ migration, not here — a
45
- // module migration may only touch its own lifecycle_ tables (ADR 0083 §5), and
46
- // the fitness check enforces it. Filed with the SQL as task 1003268.
40
+ // INDEXES. The signal joins key on tasks.goal_id (idx_tasks_goal, core_228),
41
+ // claims.builder_id + claimed_at and claims.task_id over HISTORY, not just active
42
+ // claims, and tasks.shipped_by + goal_id with no status predicate. The last three
43
+ // were unindexed for this shape and are added by migrations/core_263 (from
44
+ // task 1003268). They live in a core_ migration, not here — a module migration may only
45
+ // touch its own lifecycle_ tables (ADR 0083 §5), and the fitness check enforces it.
46
+ // Change a join key or predicate here and re-check that migration still serves it.
47
47
  //
48
48
  // Reaches core only through the doorway (ADR 0083). The core tables it reads
49
49
  // (claims, tasks, session_logs, builders) are read-only here.
@@ -198,7 +198,7 @@ function fmtMins(n) {
198
198
  return m ? `~${h}h${m}m` : `~${h}h`;
199
199
  }
200
200
 
201
- // Mirrors scripts/gds/discipline-modes.json, which is what claim.js prints. 'ui'
201
+ // Mirrors scripts/gds/discipline-modes.json, which routes a claim to its playbook. 'ui'
202
202
  // was missing here, so a UI task's card sent the builder to /dev while the claim
203
203
  // had just sent them to /design (task 1003487).
204
204
  //
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.20.7",
3
+ "version": "1.20.9",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.20.7",
9
+ "version": "1.20.9",
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.20.7",
3
+ "version": "1.20.9",
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",
@@ -8094,5 +8094,17 @@
8094
8094
  "id": "1002458",
8095
8095
  "text": "The starter task that asks a new builder to add a test now finds its own targets from the current code, so it no longer goes out of date every time someone completes it."
8096
8096
  }
8097
+ ],
8098
+ "1.20.8": [
8099
+ {
8100
+ "id": "1002992",
8101
+ "text": "When you claim a task, your assistant is now told on its own which playbook to work from: engineer, artist, ideator or UI. Before, the instructions only reached it if someone read the claim output. Each workspace gets only its"
8102
+ }
8103
+ ],
8104
+ "1.20.9": [
8105
+ {
8106
+ "id": "1003268",
8107
+ "text": "Added three database lookups so the 'who have I worked near' view stays fast as the project grows. Nothing changes on screen today."
8108
+ }
8097
8109
  ]
8098
8110
  }
@@ -8,7 +8,6 @@
8
8
  // builder knows the parallel-safety boundary they're working within.
9
9
 
10
10
  const { execSync } = require('node:child_process');
11
- const fs = require('node:fs');
12
11
  const path = require('node:path');
13
12
  const { cliClient, requireSession, fmtTouches, printOnboardingNudge, rewardPolicy, costPlusOnlyNote, detectWorktreeName, cliExit } = require('./cli-lib');
14
13
  const { nextNumbers, padAdr, padMig } = require('./next-number');
@@ -60,40 +59,30 @@ async function printRelevantLearnings(task) {
60
59
  }
61
60
  }
62
61
 
63
- // Discipline-fork at claim (task 1267; pack-aware since task 1002990). If the
64
- // claimed task's `discipline` has an entry in the role registry
65
- // (discipline-modes.json), print a directive so the session opens that craft's
66
- // playbook instead of working the task the default way. Since the kernel/pack
67
- // split a CORE craft names a `pack` — a markdown role pack under docs/packs/
68
- // carrying what the role-neutral root CLAUDE.md deliberately leaves out — while a
69
- // module-contributed discipline may still name a `skill` slash-command (`ui` ->
70
- // /design). This IS the whole fork — one data lookup on the claim the builder
71
- // already runs; no hook, the Conductor untouched. `discipline` is already a
72
- // column, so there is no classification step here. Fail-open in every branch: a
73
- // missing or invalid map, or a discipline with no entry, prints nothing and never
74
- // breaks a successful claim. (The registry itself is NOT trusted to be intact by
75
- // luck: fitness.js hard-fails CI when an entry names a pack that is not in the
76
- // tree, so this fail-open path cannot silently swallow a broken registry.)
77
- function printDisciplineMode(task) {
62
+ // The claimed task's craft (task 1267; pack-aware since 1002990; INJECTED since
63
+ // 1002992). The directive itself — which role pack or skill this craft works
64
+ // from — no longer prints here: GET /me carries it on the active claim and the
65
+ // Conductor relays it on the session's next prompt (src/bongos/craft-mode.js), so
66
+ // it reaches the session whether or not anyone read the claim output. What stays
67
+ // is ONE role indicator line, and the side effect that must happen where the claim
68
+ // happens: landing the mode's module-owned skills (materializeModeSkills below).
69
+ //
70
+ // Reads the registry beside this CLI (the file fitness.js hard-fails on when a
71
+ // pack is missing), through the same helper the server uses. Fail-open in every
72
+ // branch: a missing or invalid map, or a discipline with no entry, prints nothing
73
+ // and never breaks a successful claim.
74
+ function printRoleLine(task) {
78
75
  let mode = null;
79
76
  try {
80
77
  const discipline = task && task.discipline;
81
78
  if (!discipline) return;
82
- const raw = fs.readFileSync(path.join(__dirname, 'discipline-modes.json'), 'utf8');
83
- const modes = (JSON.parse(raw) || {}).modes || {};
84
- mode = modes[discipline];
79
+ const { readModes, roleLine } = require('../../src/bongos/craft-mode');
80
+ mode = readModes(path.join(__dirname, 'discipline-modes.json'))[discipline] || null;
85
81
  if (!mode || !mode.directive) return;
86
- // The pointer a session actually follows. A pack is a file to READ (there is no
87
- // slash command behind it any more); a skill is one to INVOKE. Naming the wrong
88
- // verb sends the session hunting for a command that was deleted, so the two
89
- // shapes print differently and an entry with neither prints only the label.
90
- const opener = mode.pack ? `read ${mode.pack}` : (mode.skill ? `/${mode.skill}` : null);
91
82
  console.log('');
92
- console.log(`▶ ${mode.label || 'Working mode'}${opener ? ` — ${opener}` : ''}`);
93
- console.log(` ${mode.directive}`);
94
- if (mode.pack) console.log(` Your pack: ${mode.pack} (the kernel is CLAUDE.md; this is the half that is only true for your craft)`);
83
+ console.log(roleLine(mode));
95
84
  } catch (_) {
96
- // never let the mode hint break a successful claim
85
+ // never let the role line break a successful claim
97
86
  return;
98
87
  }
99
88
  materializeModeSkills(mode);
@@ -719,10 +708,10 @@ async function main() {
719
708
  console.log('');
720
709
  console.log(task.description);
721
710
  }
722
- // Discipline-fork: route ideator (and any future discipline with a mapped
723
- // playbook) into its skill. Printed right after the description so it's the
724
- // last thing the builder reads before deciding how to work — task 1267.
725
- printDisciplineMode(task);
711
+ // The craft's role line (and, for a mode that opts in, its module-owned
712
+ // skills). The full directive reaches the session through the Conductor —
713
+ // task 1002992.
714
+ printRoleLine(task);
726
715
  // task 1004202: a task that changes a page is told to use /design and get its
727
716
  // human's OK on the before/after. A nudge, never a gate: gating on that OK would
728
717
  // put a person's approval in the land path, which ADR 0162 retired.
@@ -782,4 +771,4 @@ if (require.main === module) {
782
771
  });
783
772
  }
784
773
 
785
- module.exports = { formatClaimFailure, claimErrorInfo, goalMembershipOfferPlan, materializeModeSkills, worktreeDetectionNote };
774
+ module.exports = { formatClaimFailure, claimErrorInfo, goalMembershipOfferPlan, materializeModeSkills, printRoleLine, worktreeDetectionNote };
@@ -1,5 +1,5 @@
1
1
  {
2
- "_doc": "The ROLE REGISTRY: discipline -> the operating playbook that craft works from (task 1267; reshaped by task 1002990). Since the kernel/pack split, a CORE craft names a `pack` — a markdown role pack under docs/packs/ that carries everything the root CLAUDE.md kernel deliberately does not, because it is true for one craft only. A module-contributed discipline may instead name a `skill` (a slash-command playbook under .claude/skills/), which is how `ui` -> /design still works (ADR 0272 keeps ui-design's contribution unchanged). An entry may carry both; it must carry at least one. claim.js reads this after a successful claim and prints the entry's `directive` plus its pack path, so the session opens the right playbook. There is NO classification step at claim time — `discipline` is already a column on the task. One carrier (the claim the builder already runs), one data lookup, no hooks, the Conductor untouched. A missing or invalid version of this file must never break a claim (claim.js fails open) — but fitness.js DOES hard-fail CI when an entry names a pack that is not in the tree, so the fail-open runtime cannot hide a broken registry from the build. GOVERNOR IS DEFERRED: three packs, not four, until the owner decides (criterion wa6-kernel-and-packs). A mode entry may also set \"materializeModuleSkills\": true to have the claim land that mode's module-owned skills into this checkout's .claude/skills/ (task 1003486) — opt-in per discipline, core-checkout only, and fail-open like the hint itself.",
2
+ "_doc": "The ROLE REGISTRY: discipline -> the operating playbook that craft works from (task 1267; reshaped by task 1002990). Since the kernel/pack split, a CORE craft names a `pack` — a markdown role pack under docs/packs/ that carries everything the root CLAUDE.md kernel deliberately does not, because it is true for one craft only. A module-contributed discipline may instead name a `skill` (a slash-command playbook under .claude/skills/), which is how `ui` -> /design still works (ADR 0272 keeps ui-design's contribution unchanged). An entry may carry both; it must carry at least one. GET /me reads this (src/bongos/craft-mode.js) and puts each active claim's entry — `directive` plus pack path — on the claim as `craft_mode`; the Conductor hook injects the one for the claim bound to the session's worktree, so the session opens the right playbook without anyone reading terminal output (task 1002992). claim.js reads it too, printing only the one role line and running `materializeModuleSkills`. There is NO classification step — `discipline` is already a column on the task. One registry, never a server-side copy. A missing or invalid version of this file must never break a claim (claim.js fails open) — but fitness.js DOES hard-fail CI when an entry names a pack that is not in the tree, so the fail-open runtime cannot hide a broken registry from the build. GOVERNOR IS DEFERRED: three packs, not four, until the owner decides (criterion wa6-kernel-and-packs). A mode entry may also set \"materializeModuleSkills\": true to have the claim land that mode's module-owned skills into this checkout's .claude/skills/ (task 1003486) — opt-in per discipline, core-checkout only, and fail-open like the hint itself.",
3
3
  "modes": {
4
4
  "engineer": {
5
5
  "pack": "docs/packs/engineer.md",
@@ -72,8 +72,8 @@ function checkRolePacks(fsImpl = fs) {
72
72
  violations.push(`role registry entry '${discipline}' is not an object — every entry must carry a directive plus a pack or a skill.`);
73
73
  continue;
74
74
  }
75
- // An entry with neither pointer routes a claim nowhere: claim.js prints the
76
- // label and the session is left to guess its own playbook.
75
+ // An entry with neither pointer routes a claim nowhere: the session is told the
76
+ // label only and is left to guess its own playbook.
77
77
  if (!mode.pack && !mode.skill) {
78
78
  violations.push(`role registry entry '${discipline}' names neither a 'pack' (docs/packs/<craft>.md) nor a 'skill' — a claim of this discipline would be routed to no playbook at all.`);
79
79
  }
@@ -0,0 +1,87 @@
1
+ // src/bongos/craft-mode.js — a claimed task's craft, as the pointer a session
2
+ // follows to its playbook (task 1002992).
3
+ //
4
+ // WHY. The role registry (scripts/gds/discipline-modes.json) says which role
5
+ // pack or skill each craft works from. claim.js used to PRINT that as a directive
6
+ // block, which reaches the session only if someone reads the claim scroll by —
7
+ // and a role pack skipped that way is invisible. This turns an entry into the same
8
+ // { inject, contract } shape the interaction profile and the speciality contract
9
+ // use, so GET /me can carry it on each active claim and the Conductor can relay it
10
+ // on the next prompt, exactly as it relays those.
11
+ //
12
+ // ONE REGISTRY. This reads the file claim.js and fitness.js (role-pack-guard.js)
13
+ // read — never a server-side copy of the map — so CI's hard-fail on a pack that is
14
+ // not in the tree covers what the session is told, too.
15
+ //
16
+ // Fail-open throughout: an unreadable or invalid registry, or a craft with no
17
+ // entry, yields nothing, never an error, so /me and a claim behave as before.
18
+ 'use strict';
19
+
20
+ const fs = require('fs');
21
+ const path = require('path');
22
+ const { resolveCoreRoot } = require('../instance-config');
23
+
24
+ function registryPath() {
25
+ return path.join(resolveCoreRoot(), 'scripts', 'gds', 'discipline-modes.json');
26
+ }
27
+
28
+ // The `modes` map, or {} for a missing / unparsable file.
29
+ //
30
+ // The DEFAULT registry is read once per process and memoised: GET /me calls this
31
+ // on every request, and the file ships with the core, so it changes only on a
32
+ // deploy, which restarts the process. A failed read is NOT memoised, so a
33
+ // transient error does not pin {} for the life of the server. An explicit `file`
34
+ // (claim.js, tests) is always read fresh.
35
+ let memo = null;
36
+ function readModes(file) {
37
+ if (file === undefined) {
38
+ if (!memo) {
39
+ const modes = readModesFile(registryPath());
40
+ if (Object.keys(modes).length) memo = modes;
41
+ return modes;
42
+ }
43
+ return memo;
44
+ }
45
+ return readModesFile(file);
46
+ }
47
+
48
+ function readModesFile(file) {
49
+ try {
50
+ const parsed = JSON.parse(fs.readFileSync(file, 'utf8'));
51
+ const modes = parsed && parsed.modes;
52
+ return modes && typeof modes === 'object' ? modes : {};
53
+ } catch (_) {
54
+ return {};
55
+ }
56
+ }
57
+
58
+ // The opener a session acts on. A pack is a file to READ (no slash command sits
59
+ // behind it); a skill is one to INVOKE. The wrong verb sends a session hunting for
60
+ // a command that does not exist, so the two shapes stay distinct.
61
+ function opener(mode) {
62
+ if (!mode) return null;
63
+ if (mode.pack) return `read ${mode.pack}`;
64
+ if (mode.skill) return `/${mode.skill}`;
65
+ return null;
66
+ }
67
+
68
+ // The one-line role indicator claim.js prints, or null.
69
+ function roleLine(mode) {
70
+ if (!mode) return null;
71
+ const o = opener(mode);
72
+ return `▶ ${mode.label || 'Working mode'}${o ? ` — ${o}` : ''}`;
73
+ }
74
+
75
+ // The injected fragment for one craft, or null when the registry has no entry
76
+ // for it. Static registry text only — no task or builder text is interpolated —
77
+ // so this adds no prompt-injection surface to the session.
78
+ function describeCraftMode(discipline, modes) {
79
+ if (!discipline || !modes) return null;
80
+ const mode = modes[discipline];
81
+ if (!mode || typeof mode !== 'object' || !mode.directive) return null;
82
+ const parts = [`${roleLine(mode)} (the craft of the task claimed in this worktree).`, String(mode.directive).trim()];
83
+ if (mode.pack) parts.push(`Your pack: ${mode.pack} (the kernel is CLAUDE.md; this is the half that is only true for your craft).`);
84
+ return { discipline, inject: true, contract: parts.join('\n') };
85
+ }
86
+
87
+ module.exports = { readModes, roleLine, describeCraftMode };
@@ -33,6 +33,7 @@ const db = require('../db');
33
33
  // provider is default-on (present in prod); resolveOptional degrades on a
34
34
  // prefs-module-less instance so the slices simply render their unset/default shape.
35
35
  const { validateOrRespond } = require('./_helpers');
36
+ const { readModes: readCraftModes, describeCraftMode } = require('../craft-mode'); // task 1002992
36
37
  const { branding } = require('../../branding'); // task 2011: instance model-allocation default
37
38
  // The onboarding state machine carved into modules/onboarding/ (BV1.R81 / ADR
38
39
  // 0093 §1): the GET /me aggregator + PATCH /me/disciplines resolve the
@@ -333,10 +334,15 @@ module.exports = function buildMeRouter() {
333
334
  };
334
335
  // For each active claim, compute the streak preview (cheap; pool query).
335
336
  // Lets the CLI render "shipping this would extend streak to N days" hints.
337
+ // task 1002992: each claim also carries its craft's pack pointer
338
+ // (craft_mode: { discipline, inject, contract } or null), read from the one
339
+ // role registry, so the Conductor can tell the session which playbook to open
340
+ // for the claim in ITS worktree. Read once per request; fail-open to null.
341
+ const craftModes = readCraftModes();
336
342
  const activeClaimsWithPreview = await Promise.all(
337
343
  activeClaims.map(async (c) => {
338
344
  const preview = await reward.wouldExtendStreak(req.builder.id, c.task_id);
339
- return { ...c, streak_preview: preview };
345
+ return { ...c, streak_preview: preview, craft_mode: describeCraftMode(c.discipline, craftModes) };
340
346
  })
341
347
  );
342
348
  const payload = {
package/src/module-api.js CHANGED
@@ -75,7 +75,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
75
75
  // MAJOR (see allowBoxScope below): passes the request through untouched.
76
76
  function deprecatedNoopMiddleware(_req, _res, next) { next(); }
77
77
 
78
- const CORE_VERSION = '1.20.7'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
78
+ const CORE_VERSION = '1.20.9'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
79
79
 
80
80
  // A namespaced logger so a module's log lines are attributable + consistent.
81
81
  // Usage: const log = api.logger('discord'); log.info('mounted');
@@ -434,10 +434,25 @@ test('the index gap the grader raised is ANSWERED, not left open', () => {
434
434
  // and these are core tables. That makes it a core_ migration, which needs a
435
435
  // reserved number and human eyes. Filed as task 1003268 with the SQL.
436
436
  //
437
- // This test pins the REFERENCE so the finding cannot be quietly dropped: if
438
- // someone lands the indexes and deletes the note, they should delete this too.
437
+ // Task 1003268 then landed them as migrations/core_263 — minus a goal_id index,
438
+ // because core_228 had already added idx_tasks_goal. This test now pins that the
439
+ // source still points at the migration and that the migration carries every
440
+ // index the three signals need, so a later edit cannot drop one quietly.
439
441
  assert.match(SRC, /task 1003268/,
440
- 'the closeness source cites the follow-up that carries the indexes');
442
+ 'the closeness source cites the task that carries the indexes');
443
+ assert.match(SRC, /core_263/, 'and names the migration that serves its joins');
444
+ const core = fs.readFileSync(path.join(ROOT, 'migrations', 'core_263_closeness_history_indexes.sql'), 'utf8');
445
+ for (const [name, shape] of [
446
+ ['idx_claims_builder_history', /ON claims \(builder_id, claimed_at\)/],
447
+ ['idx_claims_task_all', /ON claims \(task_id\)/],
448
+ ['idx_tasks_shipped_by_goal', /ON tasks \(shipped_by, goal_id\)/],
449
+ ]) {
450
+ assert.match(core, new RegExp(`CREATE INDEX IF NOT EXISTS ${name}\\b`), `${name} is created`);
451
+ assert.match(core, shape, `${name} has the shape the closeness join needs`);
452
+ }
453
+ const goalIdx = fs.readFileSync(path.join(ROOT, 'migrations', 'core_228_idea_inbox_captured_by_idx.sql'), 'utf8');
454
+ assert.match(goalIdx, /CREATE INDEX IF NOT EXISTS idx_tasks_goal ON tasks \(goal_id\)/,
455
+ 'tasks.goal_id is served by core_228, which is why core_263 does not repeat it');
441
456
  const files = fs.readdirSync(path.join(ROOT, 'modules', 'lifecycle', 'migrations'));
442
457
  assert.ok(!files.some((f) => /closeness/i.test(f)),
443
458
  'and no module migration tries to index a core table again');
@@ -14,7 +14,7 @@
14
14
 
15
15
  import { strict as assert } from 'node:assert';
16
16
  import { createRequire } from 'node:module';
17
- import { spawn } from 'node:child_process';
17
+ import { spawn, execFileSync } from 'node:child_process';
18
18
  import http from 'node:http';
19
19
  import fs from 'node:fs';
20
20
  import os from 'node:os';
@@ -47,7 +47,9 @@ function mePayload({ discipline = 'engineer' } = {}) {
47
47
  }
48
48
 
49
49
  // Run the hook once in a throwaway HOME whose session points at `base`.
50
- async function runHook(me) {
50
+ // `worktree` names a git repo created under the fake home for the hook to stand
51
+ // in — its basename is what binds a claim to "this" tree (task 1002992).
52
+ async function runHook(me, { worktree = null } = {}) {
51
53
  const server = http.createServer((req, res) => {
52
54
  if (req.url.startsWith('/api/bongos/me')) { res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(me)); return; }
53
55
  res.writeHead(404); res.end('{}');
@@ -62,12 +64,18 @@ async function runHook(me) {
62
64
  fs.writeFileSync(sessionPath, JSON.stringify({ token: 'test-token', api_base: base, builder: { id: 4242 } }));
63
65
  const env = { ...process.env, HOME: home, USERPROFILE: home };
64
66
  for (const k of Object.keys(env)) if (/_API_BASE$/.test(k) || k === 'OTB_CONDUCTOR_OFF' || k === 'OTB_SUBAGENT') delete env[k];
67
+ let cwd = home;
68
+ if (worktree) {
69
+ cwd = path.join(home, worktree);
70
+ fs.mkdirSync(cwd, { recursive: true });
71
+ execFileSync('git', ['init', '-q'], { cwd, stdio: 'ignore' });
72
+ }
65
73
  try {
66
- const child = spawn(process.execPath, [HOOK], { env, cwd: home, stdio: ['pipe', 'pipe', 'pipe'] });
74
+ const child = spawn(process.execPath, [HOOK], { env, cwd, stdio: ['pipe', 'pipe', 'pipe'] });
67
75
  let out = ''; let err = '';
68
76
  child.stdout.on('data', (d) => { out += d; });
69
77
  child.stderr.on('data', (d) => { err += d; });
70
- child.stdin.end(JSON.stringify({ session_id: 'e2e-' + Date.now(), prompt: 'hello', cwd: home }));
78
+ child.stdin.end(JSON.stringify({ session_id: 'e2e-' + Date.now(), prompt: 'hello', cwd }));
71
79
  const code = await new Promise((r) => child.on('close', r));
72
80
  return { out, err, code };
73
81
  } finally {
@@ -94,4 +102,30 @@ await test('with an artist claim, the artist contract is the one injected', asyn
94
102
  assert.ok(!r.out.includes(SPECIALITY));
95
103
  });
96
104
 
105
+ // task 1002992: the craft's pack pointer, built by the SERVER's own helper from
106
+ // the real registry (what GET /me puts on each claim), run through the real hook.
107
+ const craftMode = require('../src/bongos/craft-mode.js');
108
+ const MODES = craftMode.readModes();
109
+ function twoTreePayload() {
110
+ const claim = (id, discipline) => ({ task_id: id, title: 't', touches: [], worktree_name: `task-${id}`, discipline, craft_mode: craftMode.describeCraftMode(discipline, MODES) });
111
+ return { ...mePayload(), specialities: [], active_claims: [claim(77, 'engineer'), claim(88, 'ideator')] };
112
+ }
113
+
114
+ await test('a session in a claimed worktree is told its craft\'s pack on the first prompt, and not the other tree\'s', async () => {
115
+ const r = await runHook(twoTreePayload(), { worktree: 'task-77' });
116
+ assert.equal(r.code, 0);
117
+ assert.ok(r.out.includes('docs/packs/engineer.md'), `the engineer pack pointer reaches the session; stdout was: ${JSON.stringify(r.out.slice(0, 400))}`);
118
+ assert.ok(!r.out.includes('docs/packs/ideator.md'), 'worktree task-88\'s ideator craft does not');
119
+ const other = await runHook(twoTreePayload(), { worktree: 'task-88' });
120
+ assert.ok(other.out.includes('docs/packs/ideator.md'));
121
+ assert.ok(!other.out.includes('docs/packs/engineer.md'));
122
+ });
123
+
124
+ await test('outside any claimed worktree no craft is guessed', async () => {
125
+ const r = await runHook(twoTreePayload());
126
+ assert.equal(r.code, 0);
127
+ assert.ok(!r.out.includes('docs/packs/'), 'no tree, no craft — silent rather than pooled');
128
+ assert.ok(r.out.includes(INTERACTION), 'and the rest of the hook still runs');
129
+ });
130
+
97
131
  summary();
@@ -0,0 +1,141 @@
1
+ // tests/craft_mode.mjs — the claimed craft's pack pointer is INJECTED, not
2
+ // printed (task 1002992).
3
+ //
4
+ // Four altitudes: the server helper over the real registry, the /me wiring, the
5
+ // Conductor's worktree-scoped relay, and claim.js — which now prints one role line
6
+ // but must still land a ui claim's module-owned skills (the regression this task
7
+ // was most likely to ship). The hook itself is run end to end, against the
8
+ // helper's real output, in tests/conductor_main_e2e.mjs.
9
+ //
10
+ // Run: node tests/craft_mode.mjs
11
+
12
+ import { strict as assert } from 'node:assert';
13
+ import { createRequire } from 'node:module';
14
+ import fs from 'node:fs';
15
+ import os from 'node:os';
16
+ import path from 'node:path';
17
+ import { fileURLToPath } from 'node:url';
18
+ import { makeRunner } from './helpers.mjs';
19
+
20
+ const require = createRequire(import.meta.url);
21
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
22
+ const craft = require('../src/bongos/craft-mode.js');
23
+ const conductor = require('../.claude/hooks/conductor.js');
24
+ const { test, summary } = makeRunner();
25
+
26
+ const REGISTRY = path.join(ROOT, 'scripts', 'gds', 'discipline-modes.json');
27
+ const MODES = craft.readModes();
28
+
29
+ // ---- the helper, over the ONE registry ---------------------------------------
30
+ await test('the server reads the same registry file claim.js and fitness read', () => {
31
+ assert.deepEqual(MODES, JSON.parse(fs.readFileSync(REGISTRY, 'utf8')).modes);
32
+ });
33
+
34
+ await test('every registry craft yields a contract naming its pack or skill, with its directive', () => {
35
+ const names = Object.keys(MODES);
36
+ assert.ok(names.length >= 3, 'the registry was read at all');
37
+ for (const d of names) {
38
+ const m = craft.describeCraftMode(d, MODES);
39
+ assert.ok(m && m.inject, `${d} injects`);
40
+ assert.equal(m.discipline, d);
41
+ const pointer = MODES[d].pack || `/${MODES[d].skill}`;
42
+ assert.ok(m.contract.includes(pointer), `${d} names ${pointer}`);
43
+ assert.ok(m.contract.includes(MODES[d].directive.slice(0, 40)), `${d} carries its directive`);
44
+ }
45
+ assert.match(craft.describeCraftMode('engineer', MODES).contract, /read docs\/packs\/engineer\.md/);
46
+ assert.match(craft.describeCraftMode('ui', MODES).contract, /— \/design/, 'a skill is a command to invoke, not a file to read');
47
+ });
48
+
49
+ await test('fail-open: no craft, an unknown craft, or an unreadable registry yields nothing', () => {
50
+ assert.equal(craft.describeCraftMode(null, MODES), null);
51
+ assert.equal(craft.describeCraftMode('governor', MODES), null);
52
+ assert.equal(craft.describeCraftMode('engineer', null), null);
53
+ assert.deepEqual(craft.readModes(path.join(os.tmpdir(), 'no-such-registry.json')), {});
54
+ const bad = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'craft-')), 'modes.json');
55
+ fs.writeFileSync(bad, '{ not json');
56
+ assert.deepEqual(craft.readModes(bad), {});
57
+ });
58
+
59
+ await test('the default registry is read once per process; an explicit file is read fresh', () => {
60
+ assert.equal(craft.readModes(), craft.readModes(), 'GET /me does not re-read and re-parse per request');
61
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'craft-'));
62
+ const f = path.join(dir, 'modes.json');
63
+ fs.writeFileSync(f, JSON.stringify({ modes: { a: { directive: 'x' } } }));
64
+ assert.deepEqual(Object.keys(craft.readModes(f)), ['a']);
65
+ fs.writeFileSync(f, JSON.stringify({ modes: { b: { directive: 'y' } } }));
66
+ assert.deepEqual(Object.keys(craft.readModes(f)), ['b'], 'claim.js always sees the file as it is now');
67
+ });
68
+
69
+ // ---- /me carries it on each claim ---------------------------------------------
70
+ await test('GET /me puts each claim\'s craft_mode on the claim, from the helper', () => {
71
+ const me = fs.readFileSync(path.join(ROOT, 'src/bongos/routes/me.js'), 'utf8');
72
+ assert.match(me, /require\('\.\.\/craft-mode'\)/);
73
+ assert.match(me, /craft_mode: describeCraftMode\(c\.discipline, craftModes\)/);
74
+ assert.doesNotMatch(me, /"modes"\s*:/, 'no second copy of the registry in the route');
75
+ });
76
+
77
+ // ---- the Conductor relays THIS worktree's craft only ---------------------------
78
+ const claim = (id, discipline) => ({ task_id: String(id), title: 't', touches: [], worktree_name: `task-${id}`, discipline, craft_mode: craft.describeCraftMode(discipline, MODES) });
79
+ const craftIds = (out) => out.filter((a) => String(a.id).startsWith('craft:')).map((a) => a.id);
80
+
81
+ await test('the claim bound to this worktree gets its craft; the other tree\'s does not', () => {
82
+ const claims = [claim(1, 'engineer'), claim(2, 'ideator')];
83
+ assert.deepEqual(craftIds(conductor.evaluateTriggers({ activeClaims: claims, worktree: 'task-1' })), ['craft:engineer']);
84
+ assert.deepEqual(craftIds(conductor.evaluateTriggers({ activeClaims: claims, worktree: 'task-2' })), ['craft:ideator']);
85
+ });
86
+
87
+ await test('no bound claim, no session, or no registry entry: nothing is injected', () => {
88
+ const claims = [claim(1, 'engineer')];
89
+ assert.deepEqual(craftIds(conductor.evaluateTriggers({ activeClaims: claims, worktree: 'task-9' })), [], 'an unrelated tree');
90
+ assert.deepEqual(craftIds(conductor.evaluateTriggers({ activeClaims: claims, worktree: null })), [], 'not in a worktree');
91
+ assert.deepEqual(craftIds(conductor.evaluateTriggers({ activeClaims: null, worktree: 'task-1' })), [], 'no session / no network');
92
+ assert.deepEqual(craftIds(conductor.evaluateTriggers({ activeClaims: [{ ...claim(1, 'governor') }], worktree: 'task-1' })), [], 'a craft with no entry');
93
+ assert.deepEqual(craftIds(conductor.evaluateTriggers({ activeClaims: [{ ...claim(1, 'engineer'), craft_mode: { inject: true, contract: '' } }], worktree: 'task-1' })), [], 'an empty contract');
94
+ });
95
+
96
+ // ---- claim.js: one role line, and the ui skills still land ---------------------
97
+ const claimCli = require('../scripts/gds/claim.js');
98
+ const materialize = require('../scripts/gds/claude-materialize.js');
99
+
100
+ function capture(fn) {
101
+ const lines = [];
102
+ const orig = console.log;
103
+ console.log = (...a) => { lines.push(a.join(' ')); };
104
+ try { fn(); } finally { console.log = orig; }
105
+ return lines.filter((l) => l !== '');
106
+ }
107
+
108
+ await test('claim.js prints exactly one role line and no directive block', () => {
109
+ const lines = capture(() => claimCli.printRoleLine({ discipline: 'engineer' }));
110
+ assert.deepEqual(lines, ['▶ Engineering session — read docs/packs/engineer.md']);
111
+ const src = fs.readFileSync(path.join(ROOT, 'scripts/gds/claim.js'), 'utf8');
112
+ assert.doesNotMatch(src, /mode\.directive\}/, 'the directive is no longer printed');
113
+ assert.doesNotMatch(src, /Your pack:/);
114
+ });
115
+
116
+ await test('claiming a ui task STILL materialises its module-owned skills', () => {
117
+ const saved = { into: materialize.materializeModuleSkillsIntoCore, root: materialize.isCoreCheckoutRoot };
118
+ const calls = [];
119
+ materialize.isCoreCheckoutRoot = () => true;
120
+ materialize.materializeModuleSkillsIntoCore = (opts) => { calls.push(opts); return { materialized: ['impeccable', 'minimalist-ui'] }; };
121
+ try {
122
+ const lines = capture(() => claimCli.printRoleLine({ discipline: 'ui' }));
123
+ assert.equal(calls.length, 1, 'the ui claim landed its skills');
124
+ assert.equal(lines[0], '▶ UI design session — /design');
125
+ assert.match(lines[1], /2 module-owned skill\(s\)/);
126
+ calls.length = 0;
127
+ capture(() => claimCli.printRoleLine({ discipline: 'engineer' }));
128
+ assert.equal(calls.length, 0, 'a craft that did not opt in lands nothing');
129
+ } finally {
130
+ materialize.materializeModuleSkillsIntoCore = saved.into;
131
+ materialize.isCoreCheckoutRoot = saved.root;
132
+ }
133
+ });
134
+
135
+ await test('claim.js fails open: no craft or an unknown craft prints nothing and never throws', () => {
136
+ assert.deepEqual(capture(() => claimCli.printRoleLine({})), []);
137
+ assert.deepEqual(capture(() => claimCli.printRoleLine({ discipline: 'governor' })), []);
138
+ assert.deepEqual(capture(() => claimCli.printRoleLine(null)), []);
139
+ });
140
+
141
+ summary();
@@ -90,10 +90,12 @@ await test('a backend task is not nudged — no noise on work that renders nothi
90
90
  assert.equal(visualReviewNudge(task()), null);
91
91
  });
92
92
 
93
- await test('claim.js prints the nudge after the craft directive, and cannot fail a claim with it', () => {
93
+ await test('claim.js prints the nudge after the craft role line, and cannot fail a claim with it', () => {
94
94
  const src = read('scripts/gds/claim.js');
95
95
  const at = src.indexOf('visualReviewNudge(task)');
96
- assert.ok(at > src.indexOf('printDisciplineMode(task);'), 'right after the craft directive');
96
+ const role = src.indexOf('printRoleLine(task);');
97
+ assert.ok(role > -1, 'the role line call exists (task 1002992 renamed it from printDisciplineMode)');
98
+ assert.ok(at > role, 'right after the craft role line');
97
99
  assert.match(src.slice(at - 200, at + 300), /catch \(_\)/, 'wrapped, so a hint can never break a successful claim');
98
100
  });
99
101