@bongos/core 1.19.289 → 1.19.290

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.289",
6
- "core_contract": "1.19.289",
7
- "source_commit": "7fcb844542a0043f5c5beff9a14978d08dba62a9",
5
+ "core_version": "1.19.290",
6
+ "core_contract": "1.19.290",
7
+ "source_commit": "a983fae1fa7615fda22b12d59ff393255fd81a19",
8
8
  "source_ref": "HEAD",
9
- "built_at": "2026-08-22T03:47:31.708Z",
9
+ "built_at": "2026-08-22T04:46:05.299Z",
10
10
  "redaction": {
11
11
  "model": "docs-redacted+functional-verbatim",
12
12
  "docs_redacted": 320,
13
13
  "agent_docs_stubbed": 22,
14
- "functional_verbatim": 1388,
14
+ "functional_verbatim": 1389,
15
15
  "rules": 3,
16
16
  "gate_literals": 3,
17
17
  "gate": "passed"
18
18
  },
19
- "file_count": 1730,
20
- "tree_sha256": "08c303ce54af46b2a131d468064b1ec877f3120d3d6df123a8a5e4c88be18af6",
19
+ "file_count": 1731,
20
+ "tree_sha256": "f3100f6df33ff1d5f9e4da0d536a0cc792b0c7ad9e3c945c87324554b34105db",
21
21
  "files": [
22
22
  {
23
23
  "path": ".claude/skills/blocker-review/SKILL.md",
@@ -172,7 +172,7 @@
172
172
  {
173
173
  "path": ".claude/skills/idea-triage/SKILL.md",
174
174
  "mode": "0000644",
175
- "sha256": "fa61787e4ab5aafe6703fa94dc1dedb65c0554d1cc0362f6404801dd2c33fdb2"
175
+ "sha256": "c74c93659c4576b32d829ebb2774feb8771125b31b677e070841f4cc25ed0232"
176
176
  },
177
177
  {
178
178
  "path": ".claude/skills/ideate/SKILL.md",
@@ -1437,12 +1437,12 @@
1437
1437
  {
1438
1438
  "path": "docs/copy-inventory.md",
1439
1439
  "mode": "0000644",
1440
- "sha256": "0738a5765a16d7e63dd103d75195f972f2a6a863ae22271f5fdb74e7ac171467"
1440
+ "sha256": "8cb5bb088aae803e6889571712cc4439fc870c88e104b1bb81dbd5fd1f9b9bd3"
1441
1441
  },
1442
1442
  {
1443
1443
  "path": "docs/copy-registry.json",
1444
1444
  "mode": "0000644",
1445
- "sha256": "5271f093cccfded7a3461ae0a83781f6fc41b5d7f78b0084165e463ed2b09b7d"
1445
+ "sha256": "18599cab2b3de43e33513c20cfaa241d3fe0545b861a27942d7f63d11f646750"
1446
1446
  },
1447
1447
  {
1448
1448
  "path": "docs/design-contract.md",
@@ -3632,7 +3632,7 @@
3632
3632
  {
3633
3633
  "path": "modules/hall-ui/public/task-detail.js",
3634
3634
  "mode": "0000644",
3635
- "sha256": "fc9224e79a1003434f7cb35925b3264b26bb4aea1d32c1b24723de4918530bef"
3635
+ "sha256": "e2c0702ec2eaa2c2d63de4d181f6118295a28e743f0df52722e5e717c3e96b72"
3636
3636
  },
3637
3637
  {
3638
3638
  "path": "modules/hall-ui/public/task-lib.js",
@@ -4522,12 +4522,12 @@
4522
4522
  {
4523
4523
  "path": "package-lock.json",
4524
4524
  "mode": "0000644",
4525
- "sha256": "4a910e78786de976213abedacfe08553775d9651b46579e1388941d416be82db"
4525
+ "sha256": "e96dcf6ef882c61be154cfcb5d902942a1cad182173d11b63c824fdfb87f9879"
4526
4526
  },
4527
4527
  {
4528
4528
  "path": "package.json",
4529
4529
  "mode": "0000644",
4530
- "sha256": "9919d125bfcfaccf90bc06727eeec3f410882a6e1c83481e63f79f7a5d2d7a0b"
4530
+ "sha256": "b1b86eeccda0d20d4c9b739016bd727f4867fa59de6a9dc5526b24ba5a6c92dd"
4531
4531
  },
4532
4532
  {
4533
4533
  "path": "public-docs/index.html",
@@ -5782,7 +5782,7 @@
5782
5782
  {
5783
5783
  "path": "scripts/gds/triage.js",
5784
5784
  "mode": "0000644",
5785
- "sha256": "8cca4ccf9b5cb86bb9c00ea925ef4bd8790bdb6bb39c4d3afaba85cf072e9760"
5785
+ "sha256": "1c43db4e69ff4966addd91314638b3b2a0c3f7c91f832f09e4a162e5fd0789aa"
5786
5786
  },
5787
5787
  {
5788
5788
  "path": "scripts/gds/update-channel.js",
@@ -6172,7 +6172,7 @@
6172
6172
  {
6173
6173
  "path": "src/module-api.js",
6174
6174
  "mode": "0000644",
6175
- "sha256": "fbb190fb4b0b50ef8374412a009f1a4799ecbdb15b37caabac3c8a138ca29622"
6175
+ "sha256": "6d275b0dea8e96343483360f9429816f5b6b3a754d71e9ae0a3700481a22e0aa"
6176
6176
  },
6177
6177
  {
6178
6178
  "path": "src/module-loader/catalog.js",
@@ -7964,6 +7964,11 @@
7964
7964
  "mode": "0000644",
7965
7965
  "sha256": "211aa868b86a2b663f5525c3d2ddfa406498d608363d28c0786219d3c0b6e249"
7966
7966
  },
7967
+ {
7968
+ "path": "tests/promote_goal_id.mjs",
7969
+ "mode": "0000644",
7970
+ "sha256": "24471b652054a82e985b86fdbbe2dc281114cf9829aed6c9b91348217e7f2460"
7971
+ },
7967
7972
  {
7968
7973
  "path": "tests/prose_edits.mjs",
7969
7974
  "mode": "0000644",
@@ -1,10 +1,40 @@
1
1
  ---
2
2
  name: idea-triage
3
- description: Daily walk through the Bongos idea_inbox — for each open idea, prompt to promote (→ task), discard, or merge with another idea. Triggers when the user says "/idea-triage", "triage ideas", "review the inbox", "walk the idea inbox", or runs as a scheduled daily task. Metic+ rank only (Metic and Archon; not Xenos).
3
+ description: Daily walk through the Bongos idea_inbox, which since task 1003071 holds only HOMELESS work — bugs and goal-bound captures now route to a task at filing time and never reach the inbox. For each open idea, prompt to promote (→ task, naming a goal), discard, or merge with another idea. Triggers when the user says "/idea-triage", "triage ideas", "review the inbox", "walk the idea inbox", or runs as a scheduled daily task. Metic+ rank only (Metic and Archon; not Xenos).
4
4
  ---
5
5
 
6
6
  You are running the daily idea-triage session for Example. Open ideas in the Bongos `idea_inbox` table get walked one-at-a-time and verdicted, so the inbox doesn't accumulate stale entries.
7
7
 
8
+ ## The inbox is HOMELESS-ONLY now (task 1003071 / R13, goal 1000071)
9
+
10
+ **What changed.** Since R01 (task 1003065) a bug or goal-bound capture ROUTES at
11
+ filing time — it becomes a task in its goal, in one transaction, and never appears
12
+ here. The inbox is therefore no longer "everything anyone filed"; it holds only
13
+ **work with no home yet**. Expect it to be smaller, and read what IS in it as
14
+ work that genuinely needs a decision rather than a queue to clear.
15
+
16
+ **Two consequences for how you run this skill:**
17
+
18
+ 1. **Every promote names a goal.** The promote step now asks for one, defaulted
19
+ for you, and `-` opts out explicitly. Take the default unless you disagree with
20
+ it. Opting out is legal but rarely right: a promote with no goal is **not**
21
+ goal-less — the server files it under the version's catch-all `<version> —
22
+ general` goal — and that bucket carries no done-when criteria, so since
23
+ [ADR 0183](../../../docs/adr/<redacted>.md) made criteria
24
+ close themselves off LINKED tasks, work parked there can never advance
25
+ anything. The general bucket used to be the silent default; it is now a choice
26
+ you type.
27
+ 2. **The default is inherited, not guessed, when a hint exists.** Precedence is
28
+ the stored `suggested_goal_id` (written at capture precisely so triage inherits
29
+ the guess) FIRST, then the R07 suggester (`GET /goals/suggestions`). When the
30
+ suggester supplies it you are shown the score and the matched terms — check
31
+ them: token overlap can match an incidental word, so a low score with one
32
+ matched term is weak evidence, not a recommendation.
33
+
34
+ **If an idea should have routed at filing and didn't**, that is worth noticing
35
+ rather than just promoting past: it usually means the filer named no goal, and the
36
+ capture-time suggester (R08/R09/R11 surfaces) is where the fix belongs.
37
+
8
38
  > **This skill is no longer the only way to triage.** Since task 1002447 a Metic+ builder can promote, discard and merge from the **hall** — the Home "Needs your attention" card's idea rows each carry a *Triage* verb that opens the record overlay, where all three forms live. No Claude session involved. What this skill adds over the hall is the **parallel development pass** (Step 1.5) — an idea fleshed out into open questions/risks before the verdict — so reach for it when the queue needs judgment, and use the hall when it just needs clearing.
9
39
 
10
40
  ## Rank gate — Metic+ only
@@ -70,7 +100,7 @@ node scripts/gds/triage.js --development "${TMPDIR:-/tmp}/otb-idea-development.j
70
100
 
71
101
  `triage.js` prints each idea's developed block above its verdict prompt. It never *generates* the analysis (that's this skill's subagent fan-out) — it only displays what the cache holds, and runs exactly as before when no cache is present.
72
102
 
73
- > **Not in this cut (follow-up leg).** The next step is to let the Archon push the developed `open_questions` *back to the submitter* — a round-trip in the Discord `#ideas` thread / DM before promotion — so the submitter says how they'd want it integrated. That needs the Discord bot interaction + idea↔thread linking (idea #216 / [ADR 0033](../../../docs/adr/<redacted>.md)) and is tracked separately. For now the open questions are answered by the Archon in-session.
103
+ > **Not in this cut (follow-up leg).** The next step is to let the Archon push the developed `open_questions` *back to the submitter* — a round-trip in the Discord `#ideas` thread / DM before promotion — so the submitter says how they'd want it integrated. That needs the Discord bot interaction + idea↔thread linking (idea `#216` / [ADR 0033](../../../docs/adr/<redacted>.md)) and is tracked separately. For now the open questions are answered by the Archon in-session.
74
104
 
75
105
  **Step 2 — walk the list, idea by idea.**
76
106
 
@@ -4,13 +4,13 @@
4
4
  > Deterministic on a given tree — regenerate and commit rather than hand-editing.
5
5
  > Machine-readable twin: [`copy-registry.json`](copy-registry.json).
6
6
 
7
- **1337 strings** across 3 surfaces · 1186 distinct pieces of copy · 1337 certain, 0 heuristic · 121 shown in more than one place.
7
+ **1339 strings** across 3 surfaces · 1188 distinct pieces of copy · 1339 certain, 0 heuristic · 121 shown in more than one place.
8
8
 
9
9
  ## Surfaces
10
10
 
11
11
  | surface | lives at | files | strings |
12
12
  |---|---|---|---|
13
- | `builders-hall` | `modules/hall-ui/public` | 61 | 1053 |
13
+ | `builders-hall` | `modules/hall-ui/public` | 61 | 1055 |
14
14
  | `status-dashboard` | `modules/status-ui/public` | 4 | 98 |
15
15
  | `landing` | `public-landing` | 2 | 186 |
16
16
 
@@ -78,7 +78,7 @@ Identical copy within one surface. Sometimes right (a repeated button), sometime
78
78
  | `7775fec0fa85` | 2x | Could not load sessions. | modules/hall-ui/public/sessions.js:168, modules/hall-ui/public/sessions.js:207 |
79
79
  | … | | _81 more in the JSON registry_ | |
80
80
 
81
- ## `builders-hall` — 1053 strings
81
+ ## `builders-hall` — 1055 strings
82
82
 
83
83
  | id | conf | text | where |
84
84
  |---|---|---|---|
@@ -326,7 +326,7 @@ Identical copy within one surface. Sometimes right (a repeated button), sometime
326
326
  | `7f600651bbf1` | certain | Couldn’t load your projects right now. | `modules/hall-ui/public/my-projects.js:115` |
327
327
  | `38827a954f3b` | certain | Create a custom rank first. | `modules/hall-ui/public/government.js:397` |
328
328
  | `dd8ead7adc9a` | certain | Create rank | `modules/hall-ui/public/government.html:62` |
329
- | `d7beb841395e` | certain | Create task | `modules/hall-ui/public/task-detail.js:300` |
329
+ | `d7beb841395e` | certain | Create task | `modules/hall-ui/public/task-detail.js:354` |
330
330
  | `533f24219487` | certain | Create your | `modules/hall-ui/public/builders.js:984` |
331
331
  | `e91a4d2e3e1c` | certain | Created rank {…}. | `modules/hall-ui/public/government.js:380` |
332
332
  | `5968d13a9dbe` | certain | Criteria met — awaiting review | `modules/hall-ui/public/goals-page.js:341` |
@@ -364,7 +364,7 @@ Identical copy within one surface. Sometimes right (a repeated button), sometime
364
364
  | `854a21d2eb39` | certain | Eight achievements recognize sustained contribution. Earned ones are highlighted; the rest show what’s left to | `modules/hall-ui/public/hall-widgets.js:197` |
365
365
  | `58d381535285` | certain | Engagement reports | `modules/hall-ui/public/watch.html:120` |
366
366
  | `35d1ae09a6b3` | certain | Enter a project URL. | `modules/hall-ui/public/my-projects.js:122` |
367
- | `bd7a40639a1c` | certain | Estimate (min) | `modules/hall-ui/public/task-detail.js:295` |
367
+ | `bd7a40639a1c` | certain | Estimate (min) | `modules/hall-ui/public/task-detail.js:349` |
368
368
  | `c15d1f0ddfb6` | certain | Estimation accuracy | `modules/hall-ui/public/watch.html:133` |
369
369
  | `44f928d13699` | certain | Event sounds | `modules/hall-ui/public/settings.html:282` |
370
370
  | `8690c6424585` | certain | Every builder in {{worldName}} — rank, {{currency}}, karma, and active claims. | `modules/hall-ui/public/watch.html:61` |
@@ -387,7 +387,7 @@ Identical copy within one surface. Sometimes right (a repeated button), sometime
387
387
  | `b9267033b1f1` | certain | Flag this copy | `modules/hall-ui/public/copy-desk.html:87` |
388
388
  | `448d00771dc8` | certain | Flagged. It is in the queue. | `modules/hall-ui/public/copy-desk.js:281` |
389
389
  | `617f13fa3fdb` | certain | Flip the switch. | `modules/hall-ui/public/builders.js:984` |
390
- | `42cee5cb3584` | certain | Fold this idea into # | `modules/hall-ui/public/task-detail.js:369` |
390
+ | `42cee5cb3584` | certain | Fold this idea into # | `modules/hall-ui/public/task-detail.js:444` |
391
391
  | `647bcfcfdb03` | certain | Fresh CLI token issued. Paste it in your terminal: | `modules/hall-ui/public/settings.js:885` |
392
392
  | `55c71d76a94c` | certain | From creation to production — and the exact step where {{currency}} are awarded (at | `modules/hall-ui/public/diagrams.html:61` |
393
393
  | `a025080147a1` | certain | Full Idea | `modules/hall-ui/public/government.js:669` |
@@ -490,7 +490,7 @@ Identical copy within one surface. Sometimes right (a repeated button), sometime
490
490
  | `0f9f86c556a2` | certain | Loading the primer… | `modules/hall-ui/public/primer.html:55` |
491
491
  | `ceec2218efe3` | certain | Loading the roadmap… | `modules/hall-ui/public/roadmap.html:24` |
492
492
  | `ceec2218efe3` | certain | Loading the roadmap… | `modules/hall-ui/public/roadmap.html:29` |
493
- | `d28d321e8a1e` | certain | Loading versions… | `modules/hall-ui/public/task-detail.js:258` |
493
+ | `d28d321e8a1e` | certain | Loading versions… | `modules/hall-ui/public/task-detail.js:307` |
494
494
  | `3c43f157035e` | certain | Loading your goals… | `modules/hall-ui/public/goals.js:95` |
495
495
  | `7a239a17afc8` | certain | Loading your preferences… | `modules/hall-ui/public/settings.html:26` |
496
496
  | `04df1bd30129` | certain | Loading your rank… | `modules/hall-ui/public/ranks.html:23` |
@@ -564,7 +564,7 @@ Identical copy within one surface. Sometimes right (a repeated button), sometime
564
564
  | `2c19f41704b9` | certain | Not yet available — {{worldName}} | `modules/hall-ui/public/not-ready.html:6` |
565
565
  | `2c19f41704b9` | certain | Not yet available — {{worldName}} | `modules/hall-ui/public/not-ready.html:6` |
566
566
  | `571b9b22dbda` | certain | Not yet ready — blocked by dependencies or awaiting promotion. These become claimable when their blockers ship | `modules/hall-ui/public/work.html:87` |
567
- | `0c59d118db9b` | certain | Note (optional) | `modules/hall-ui/public/task-detail.js:372` |
567
+ | `0c59d118db9b` | certain | Note (optional) | `modules/hall-ui/public/task-detail.js:447` |
568
568
  | `95f8f8a4b288` | certain | Nothing flagged here. That is either good news or an unread queue — the duplicates below are the cheapest plac | `modules/hall-ui/public/copy-desk.js:172` |
569
569
  | `fec5d78497e8` | certain | Nothing in the backlog right now. | `modules/hall-ui/public/work.js:953` |
570
570
  | `18ce0723be0d` | certain | Nothing is waiting — no held changes need your approval. | `modules/hall-ui/public/gate.js:188` |
@@ -642,7 +642,7 @@ Identical copy within one surface. Sometimes right (a repeated button), sometime
642
642
  | `26e6c9879035` | certain | Reading the diagrams | `modules/hall-ui/public/diagrams.html:48` |
643
643
  | `81cd2cdf954d` | certain | Reading the request… | `modules/hall-ui/public/pair.html:23` |
644
644
  | `5b581cca590f` | certain | Ready — tasks to do | `modules/hall-ui/public/work.html:53` |
645
- | `9ea556e7b78b` | certain | Reason (optional) | `modules/hall-ui/public/task-detail.js:423` |
645
+ | `9ea556e7b78b` | certain | Reason (optional) | `modules/hall-ui/public/task-detail.js:498` |
646
646
  | `7df3fd4ebf41` | certain | Reason (shown to the builder) | `modules/hall-ui/public/harbor.js:146` |
647
647
  | `06cbba1440b1` | certain | Rebase owed — new claims paused | `modules/hall-ui/public/builders.js:752` |
648
648
  | `58fb6fe4b47e` | certain | Recent history could not be loaded right now. | `modules/hall-ui/public/work.js:1052` |
@@ -974,7 +974,7 @@ Identical copy within one surface. Sometimes right (a repeated button), sometime
974
974
  | `06aab18fc3b5` | certain | holds the repo. Follow the numbered path to see how a credential-less box gets its work to prod. This is the l | `modules/hall-ui/public/diagrams.html:83` |
975
975
  | `17ab8db79e1f` | certain | holds the worker, the code, and your local memory; the | `modules/hall-ui/public/diagrams.html:83` |
976
976
  | `7d5cbe3d81e4` | certain | how long a vote stays open | `modules/hall-ui/public/government.html:40` |
977
- | `dacb901c4f10` | certain | idea number | `modules/hall-ui/public/task-detail.js:370` |
977
+ | `dacb901c4f10` | certain | idea number | `modules/hall-ui/public/task-detail.js:445` |
978
978
  | `b2b399fdd6a4` | certain | in progress | `modules/hall-ui/public/goals-page.js:423` |
979
979
  | `b2b399fdd6a4` | certain | in progress | `modules/hall-ui/public/roadmap.js:108` |
980
980
  | `b2b399fdd6a4` | certain | in progress | `modules/hall-ui/public/roadmap.js:194` |
@@ -987,6 +987,7 @@ Identical copy within one surface. Sometimes right (a repeated button), sometime
987
987
  | `c767b3e07826` | certain | is only the screen; the | `modules/hall-ui/public/diagrams.html:83` |
988
988
  | `aa530bd15088` | certain | itself). The registry stays the single source of truth for what the hall shows; a fitness check (scripts/gds/f | `modules/hall-ui/public/index.html:44` |
989
989
  | `d46875348b9c` | certain | ladder · read-only | `modules/hall-ui/public/government.js:116` |
990
+ | `2ce7738b1eb0` | certain | loading goals… | `modules/hall-ui/public/task-detail.js:371` |
990
991
  | `8c22f0ee5edb` | certain | looked at | `modules/hall-ui/public/copy-desk.js:114` |
991
992
  | `754bdec368ed` | certain | lowest credits | `modules/hall-ui/public/hall-widgets.js:136` |
992
993
  | `cffba8409070` | certain | manage its visibility in Settings → Cross-project record | `modules/hall-ui/public/profile.js:200` |
@@ -1053,8 +1054,8 @@ Identical copy within one surface. Sometimes right (a repeated button), sometime
1053
1054
  | `1651cb1876f5` | certain | who sits on the board | `modules/hall-ui/public/government.html:40` |
1054
1055
  | `4109de2e0bb9` | certain | whole surface | `modules/hall-ui/public/copy-desk.js:88` |
1055
1056
  | `4109de2e0bb9` | certain | whole surface | `modules/hall-ui/public/copy-desk.js:251` |
1056
- | `143f77e2b782` | certain | why these are the same | `modules/hall-ui/public/task-detail.js:373` |
1057
- | `ec7c228cb4ce` | certain | why this isn’t worth doing | `modules/hall-ui/public/task-detail.js:424` |
1057
+ | `143f77e2b782` | certain | why these are the same | `modules/hall-ui/public/task-detail.js:448` |
1058
+ | `ec7c228cb4ce` | certain | why this isn’t worth doing | `modules/hall-ui/public/task-detail.js:499` |
1058
1059
  | `66d979d0db89` | certain | your vote | `modules/hall-ui/public/government.js:691` |
1059
1060
  | `7e2f2aac0645` | certain | {…} You always see your own numbers here. | `modules/hall-ui/public/settings.js:580` |
1060
1061
  | `43b99dd1b755` | certain | {…} You can also ask in Discord #support. | `modules/hall-ui/public/box-panel.js:358` |
@@ -1104,6 +1105,7 @@ Identical copy within one surface. Sometimes right (a repeated button), sometime
1104
1105
  | `cbd220dd9184` | certain | — a later push must be re-approved. | `modules/hall-ui/public/gate.js:87` |
1105
1106
  | `a6ae9cbdee30` | certain | — all builders — | `modules/hall-ui/public/builders.js:1692` |
1106
1107
  | `729d4ce22b99` | certain | — any — | `modules/hall-ui/public/hall-widgets.js:132` |
1108
+ | `9ac518dfa95e` | certain | — none (the version's general bucket) — | `modules/hall-ui/public/task-detail.js:302` |
1107
1109
  | `da9962dc1729` | certain | — or open any diagram fullscreen. | `modules/hall-ui/public/diagrams.html:42` |
1108
1110
  | `d4ff85363f89` | certain | — so if Claude Desktop then says | `modules/hall-ui/public/box-panel.js:160` |
1109
1111
  | `e5c5e94152b3` | certain | — that's all the tooling you need up front. | `modules/hall-ui/public/builders.js:1051` |
@@ -1122,7 +1124,7 @@ Identical copy within one surface. Sometimes right (a repeated button), sometime
1122
1124
  | `7926e30955d2` | certain | ← Back to goal | `modules/hall-ui/public/goals.html:163` |
1123
1125
  | `7926e30955d2` | certain | ← Back to goal | `modules/hall-ui/public/goals.html:175` |
1124
1126
  | `1e7cf7b1e64e` | certain | ← Newer | `modules/hall-ui/public/sessions.html:56` |
1125
- | `2180e0ecf138` | certain | → no idea # | `modules/hall-ui/public/task-detail.js:391` |
1127
+ | `2180e0ecf138` | certain | → no idea # | `modules/hall-ui/public/task-detail.js:466` |
1126
1128
  | `d5d76f1c0dfe` | certain | ○ reserved | `modules/hall-ui/public/diagrams.html:53` |
1127
1129
  | `97b0773e72a5` | certain | ● LIVE | `modules/hall-ui/public/diagrams.html:51` |
1128
1130
  | `1375334c77f6` | certain | ⚠ This box is running | `modules/hall-ui/public/box-panel.js:202` |
@@ -3,9 +3,9 @@
3
3
  "generator": "scripts/gds/copy-inventory.js",
4
4
  "goal": 1000074,
5
5
  "counts": {
6
- "strings": 1337,
7
- "unique_ids": 1186,
8
- "certain": 1337,
6
+ "strings": 1339,
7
+ "unique_ids": 1188,
8
+ "certain": 1339,
9
9
  "heuristic": 0,
10
10
  "duplicate_ids": 121
11
11
  },
@@ -13,7 +13,7 @@
13
13
  "builders-hall": {
14
14
  "at": "modules/hall-ui/public",
15
15
  "files": 61,
16
- "strings": 1053
16
+ "strings": 1055
17
17
  },
18
18
  "status-dashboard": {
19
19
  "at": "modules/status-ui/public",
@@ -3481,7 +3481,7 @@
3481
3481
  "surface": "builders-hall",
3482
3482
  "text": "Create task",
3483
3483
  "file": "modules/hall-ui/public/task-detail.js",
3484
- "line": 300,
3484
+ "line": 354,
3485
3485
  "origin": "js-markup",
3486
3486
  "confidence": "certain"
3487
3487
  },
@@ -3823,7 +3823,7 @@
3823
3823
  "surface": "builders-hall",
3824
3824
  "text": "Estimate (min)",
3825
3825
  "file": "modules/hall-ui/public/task-detail.js",
3826
- "line": 295,
3826
+ "line": 349,
3827
3827
  "origin": "js-markup",
3828
3828
  "confidence": "certain"
3829
3829
  },
@@ -4030,7 +4030,7 @@
4030
4030
  "surface": "builders-hall",
4031
4031
  "text": "Fold this idea into #",
4032
4032
  "file": "modules/hall-ui/public/task-detail.js",
4033
- "line": 369,
4033
+ "line": 444,
4034
4034
  "origin": "js-markup",
4035
4035
  "confidence": "certain"
4036
4036
  },
@@ -4957,7 +4957,7 @@
4957
4957
  "surface": "builders-hall",
4958
4958
  "text": "Loading versions…",
4959
4959
  "file": "modules/hall-ui/public/task-detail.js",
4960
- "line": 258,
4960
+ "line": 307,
4961
4961
  "origin": "js-markup",
4962
4962
  "confidence": "certain"
4963
4963
  },
@@ -5623,7 +5623,7 @@
5623
5623
  "surface": "builders-hall",
5624
5624
  "text": "Note (optional)",
5625
5625
  "file": "modules/hall-ui/public/task-detail.js",
5626
- "line": 372,
5626
+ "line": 447,
5627
5627
  "origin": "js-markup",
5628
5628
  "confidence": "certain"
5629
5629
  },
@@ -6325,7 +6325,7 @@
6325
6325
  "surface": "builders-hall",
6326
6326
  "text": "Reason (optional)",
6327
6327
  "file": "modules/hall-ui/public/task-detail.js",
6328
- "line": 423,
6328
+ "line": 498,
6329
6329
  "origin": "js-markup",
6330
6330
  "confidence": "certain"
6331
6331
  },
@@ -9313,7 +9313,7 @@
9313
9313
  "surface": "builders-hall",
9314
9314
  "text": "idea number",
9315
9315
  "file": "modules/hall-ui/public/task-detail.js",
9316
- "line": 370,
9316
+ "line": 445,
9317
9317
  "origin": "js-markup",
9318
9318
  "confidence": "certain"
9319
9319
  },
@@ -9425,6 +9425,15 @@
9425
9425
  "origin": "js-markup",
9426
9426
  "confidence": "certain"
9427
9427
  },
9428
+ {
9429
+ "id": "2ce7738b1eb0",
9430
+ "surface": "builders-hall",
9431
+ "text": "loading goals…",
9432
+ "file": "modules/hall-ui/public/task-detail.js",
9433
+ "line": 371,
9434
+ "origin": "js-markup",
9435
+ "confidence": "certain"
9436
+ },
9428
9437
  {
9429
9438
  "id": "8c22f0ee5edb",
9430
9439
  "surface": "builders-hall",
@@ -10024,7 +10033,7 @@
10024
10033
  "surface": "builders-hall",
10025
10034
  "text": "why these are the same",
10026
10035
  "file": "modules/hall-ui/public/task-detail.js",
10027
- "line": 373,
10036
+ "line": 448,
10028
10037
  "origin": "js-markup",
10029
10038
  "confidence": "certain"
10030
10039
  },
@@ -10033,7 +10042,7 @@
10033
10042
  "surface": "builders-hall",
10034
10043
  "text": "why this isn’t worth doing",
10035
10044
  "file": "modules/hall-ui/public/task-detail.js",
10036
- "line": 424,
10045
+ "line": 499,
10037
10046
  "origin": "js-markup",
10038
10047
  "confidence": "certain"
10039
10048
  },
@@ -10478,6 +10487,15 @@
10478
10487
  "origin": "js-markup",
10479
10488
  "confidence": "certain"
10480
10489
  },
10490
+ {
10491
+ "id": "9ac518dfa95e",
10492
+ "surface": "builders-hall",
10493
+ "text": "— none (the version's general bucket) —",
10494
+ "file": "modules/hall-ui/public/task-detail.js",
10495
+ "line": 302,
10496
+ "origin": "js-markup",
10497
+ "confidence": "certain"
10498
+ },
10481
10499
  {
10482
10500
  "id": "da9962dc1729",
10483
10501
  "surface": "builders-hall",
@@ -10645,7 +10663,7 @@
10645
10663
  "surface": "builders-hall",
10646
10664
  "text": "→ no idea #",
10647
10665
  "file": "modules/hall-ui/public/task-detail.js",
10648
- "line": 391,
10666
+ "line": 466,
10649
10667
  "origin": "js-text-assign",
10650
10668
  "confidence": "certain"
10651
10669
  },
@@ -254,6 +254,55 @@
254
254
  + '</div>';
255
255
  }
256
256
 
257
+ // task 1003071 (R13) — the goal picker's options, same precedence as the CLI
258
+ // triage path: the STORED hint (idea.suggested_goal_id, written by
259
+ // inbox.captureIdea precisely so triage inherits the guess) wins; the R07
260
+ // suggester only fills in when there is no hint. Returns option HTML.
261
+ async function goalOptionsFor(versionId) {
262
+ // Number() so a junk stored value cannot reach the DOM as value="NaN" and
263
+ // then be POSTed as a goal id.
264
+ var raw = Number(idea.suggested_goal_id);
265
+ var hint = Number.isFinite(raw) && raw > 0 ? raw : null;
266
+ var suggestions = [];
267
+ // The suggester is a convenience, so an outage degrades to "no suggestions"
268
+ // and must not stop a triage verdict — the hint and the opt-out below are
269
+ // both still reachable without it.
270
+ try {
271
+ var q = '?version=' + encodeURIComponent(versionId)
272
+ + '&title=' + encodeURIComponent(idea.title || '')
273
+ + '&body=' + encodeURIComponent(String(idea.body_md || '').slice(0, 2000))
274
+ + '&limit=5';
275
+ // softAuth: a cosmetic suggestion list must not navigate the builder away
276
+ // from a half-filled promote form. The submit re-auths if the session died.
277
+ var res = await getJSON(API + '/goals/suggestions' + q, { softAuth: true });
278
+ suggestions = (res && res.suggestions) || [];
279
+ } catch (_) { suggestions = []; }
280
+ var opts = [];
281
+ var seen = {};
282
+ if (hint != null) {
283
+ seen[hint] = true;
284
+ opts.push('<option value="' + hint + '" selected>goal ' + hint + ' — carried from the filing</option>');
285
+ }
286
+ suggestions.forEach(function (g, i) {
287
+ if (seen[g.id]) return;
288
+ seen[g.id] = true;
289
+ var why = (g.matched_terms || []).join(', ');
290
+ // The score + matched terms are shown on purpose: token overlap can match
291
+ // an incidental word, so the operator judges the suggestion rather than
292
+ // trusting it (task 1003062's stated limit).
293
+ opts.push('<option value="' + esc(String(g.id)) + '"' + (hint == null && i === 0 ? ' selected' : '') + '>'
294
+ + esc(String(g.id)) + ' — ' + esc(g.title || '') + (why ? ' (matched: ' + esc(why) + ')' : '') + '</option>');
295
+ });
296
+ // Always offer the opt-out, and make it the fallback when nothing was found.
297
+ // Sending no goal_id does not create a goal-less task — db.createTask files
298
+ // it under the version's catch-all "general" goal — so the label names where
299
+ // it actually lands. That bucket carries no done-when criteria, which is why
300
+ // it should be a choice rather than the silent default it used to be.
301
+ opts.push('<option value=""' + (opts.length === 0 ? ' selected' : '')
302
+ + '>— none (the version\'s general bucket) —</option>');
303
+ return opts.join('');
304
+ }
305
+
257
306
  async function renderPromote() {
258
307
  box.innerHTML = '<p class="deeplink-card__note">Loading versions…</p>';
259
308
  // Both dead ends offer the way back through the same delegated [data-go]
@@ -283,6 +332,10 @@
283
332
  // CHECKed 1-5, so clamp rather than let an out-of-range value match no
284
333
  // option and leave the browser silently defaulting to P1.
285
334
  var pri = Math.min(5, Math.max(1, Number(idea.suggested_priority) || 3));
335
+ // Fetched for the PREFERRED version; the change listener below re-fetches,
336
+ // because goals belong to a version and showing another version's goals
337
+ // would offer a home the server would reject.
338
+ var goalOptions = await goalOptionsFor(preferred);
286
339
  var priOptions = [1, 2, 3, 4, 5].map(function (p) {
287
340
  return '<option value="' + p + '"' + (p === pri ? ' selected' : '') + '>P' + p + '</option>';
288
341
  }).join('');
@@ -291,6 +344,7 @@
291
344
  + '<label class="td-field td-field--wide"><span>Title</span>'
292
345
  + '<input type="text" name="title" maxlength="' + TITLE_MAX + '" required value="' + esc(idea.title || '') + '"></label>'
293
346
  + '<label class="td-field td-field--wide"><span>Version</span><select name="version">' + options + '</select></label>'
347
+ + '<label class="td-field td-field--wide"><span>Goal</span><select name="goal" data-goal-select>' + goalOptions + '</select></label>'
294
348
  + '<label class="td-field"><span>Priority</span><select name="priority">' + priOptions + '</select></label>'
295
349
  + '<label class="td-field"><span>Estimate (min)</span>'
296
350
  + '<input type="number" name="est" min="0" max="100000" step="5" placeholder="—"></label>'
@@ -304,6 +358,23 @@
304
358
  ev.preventDefault();
305
359
  submitPromote(ev.target);
306
360
  });
361
+ // Re-fetch the goal list when the version changes (task 1003071). Two quick
362
+ // changes race — the fetches can resolve out of order — so only the newest
363
+ // request is allowed to write, and only it re-enables the control.
364
+ var vsel = box.querySelector('select[name="version"]');
365
+ var gsel = box.querySelector('[data-goal-select]');
366
+ if (vsel && gsel) {
367
+ var goalFetch = 0;
368
+ vsel.addEventListener('change', async function () {
369
+ var mine = ++goalFetch;
370
+ gsel.disabled = true;
371
+ gsel.innerHTML = '<option value="">loading goals…</option>';
372
+ var html = await goalOptionsFor(vsel.value);
373
+ if (mine !== goalFetch) return;
374
+ gsel.innerHTML = html;
375
+ gsel.disabled = false;
376
+ });
377
+ }
307
378
  }
308
379
 
309
380
  async function submitPromote(form) {
@@ -325,6 +396,10 @@
325
396
  source: 'idea_inbox',
326
397
  source_ref: 'idea:' + idea.id,
327
398
  };
399
+ // task 1003071 (R13): the whole point — a triaged promotion NAMES its goal.
400
+ // Empty means the operator chose the version's general bucket, which the
401
+ // server applies itself when goal_id is absent.
402
+ if (form.goal && form.goal.value !== '') payload.goal_id = Number(form.goal.value);
328
403
  if (form.est.value !== '') payload.est_minutes = Number(form.est.value);
329
404
  if (form.credits.value !== '') payload.credits_reward = Number(form.credits.value);
330
405
  // 'criterion-proposal' is an idea-only kind; everything else is shared with
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.289",
3
+ "version": "1.19.290",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.289",
9
+ "version": "1.19.290",
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.289",
3
+ "version": "1.19.290",
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",
@@ -151,11 +151,80 @@ async function patchIdea(id, patch) {
151
151
  return r.data?.idea;
152
152
  }
153
153
 
154
- async function createTask({ versionId, title, description, priority, sourceRef }) {
155
- // NOTE: POST /api/gds/tasks doesn't currently accept 'kind' — tasks default
156
- // to 'unclassified' and the server-side classifier (B1) runs separately.
157
- // The idea's auto-classified kind is therefore informational only at this
158
- // step; the task description preserves the body for re-classification.
154
+ // resolveGoalDefault — what goal should a triaged promotion land in? (task
155
+ // 1003071 / R13.)
156
+ //
157
+ // PRECEDENCE IS DELIBERATE:
158
+ // 1. `idea.suggested_goal_id` — the STORED hint. inbox.captureIdea writes it
159
+ // even when the matrix declined to route, precisely so "/idea-triage
160
+ // inherits the guess instead of re-deriving it" (core_222, its own comment).
161
+ // A human or a filing surface already named this goal; a heuristic must not
162
+ // overrule that.
163
+ // 2. the R07 suggester (GET /goals/suggestions) — only when there is no hint.
164
+ // Best-scoring candidate, and its score/matched_terms are shown so the
165
+ // operator can see WHY before accepting.
166
+ // Returns { goalId, label, source } — goalId null when neither yields anything.
167
+ // The caller prints `source` as provenance, because "the suggester guessed this"
168
+ // and "the filing already named this" deserve different trust. Never throws: a
169
+ // suggester outage degrades to "no default", it does not block a triage verdict.
170
+ //
171
+ // `client` is injectable so the precedence can be driven end-to-end in a test
172
+ // rather than pattern-matched in the source. (Task 1003102 shipped a handler
173
+ // whose collaborators were not injectable; its tests read the source, passed,
174
+ // and the route 500'd on every call. Not repeating that.)
175
+ async function resolveGoalDefault(idea, versionId, { client = cliClient } = {}) {
176
+ // Guarded: a junk stored value must not become goalId NaN and then a goal_id
177
+ // the server rejects with 400 on every retry.
178
+ const hint = Number(idea.suggested_goal_id);
179
+ if (Number.isInteger(hint) && hint > 0) {
180
+ return { goalId: hint, label: `goal ${hint}`, source: 'hint' };
181
+ }
182
+ try {
183
+ const api = await client();
184
+ // The GENERATED typed method (regenerated when R07 landed), not the generic
185
+ // request() escape hatch — so a path or param rename reds the client build.
186
+ const r = await api.goals.getGoalsSuggestions({
187
+ query: { version: versionId, title: idea.title || '', body: (idea.body_md || '').slice(0, 2000), limit: 3 },
188
+ });
189
+ const top = r.ok && r.data && Array.isArray(r.data.suggestions) ? r.data.suggestions[0] : null;
190
+ if (!top) return { goalId: null, label: null, source: 'none' };
191
+ const why = (top.matched_terms || []).join('/');
192
+ return { goalId: Number(top.id), label: `${top.id} ${trim(top.title, 44)} (score ${top.score}${why ? ' · ' + why : ''})`, source: 'suggester' };
193
+ } catch (_) {
194
+ return { goalId: null, label: null, source: 'unavailable' };
195
+ }
196
+ }
197
+
198
+ // What the operator's answer to the goal prompt means. Blank ACCEPTS the shown
199
+ // default — declining has to be said ('-' or 'none'), because silently dropping a
200
+ // real default on a bare Enter is how work ends up in the general bucket by
201
+ // accident. Declining is legal: the server then applies the catch-all itself.
202
+ function applyGoalAnswer(defaultGoalId, rawAnswer) {
203
+ const answer = String(rawAnswer == null ? '' : rawAnswer).trim();
204
+ if (answer === '') return { goalId: defaultGoalId, warn: null };
205
+ if (answer === '-' || answer.toLowerCase() === 'none') return { goalId: null, warn: null };
206
+ const parsed = Number(answer);
207
+ if (!Number.isInteger(parsed) || parsed <= 0) {
208
+ return { goalId: null, warn: "not a goal id — falling back to the version's general bucket" };
209
+ }
210
+ return { goalId: parsed, warn: null };
211
+ }
212
+
213
+ async function createTask({ versionId, title, description, priority, sourceRef, goalId = null }) {
214
+ // (The old note here said POST /api/gds/tasks does not accept 'kind'. That is
215
+ // no longer true — routes/tasks.js takes it, and the hall promote path already
216
+ // sends it. Left un-sent from THIS path deliberately: changing what kind a
217
+ // triaged promotion lands with is a behaviour change task R13 was not asked to
218
+ // make, and the server classifier still runs. Corrected rather than deleted so
219
+ // the next reader does not re-derive it.)
220
+ //
221
+ // task 1003071 (R13): goal_id is the point of this task. Omitting it does NOT
222
+ // leave the task goal-less — db.createTask (modules/lifecycle/db.js) defaults a
223
+ // goal-less create into the version's catch-all "<version> — general" goal. That
224
+ // bucket is the actual defect: it carries no done-when criteria, so since ADR
225
+ // 0183 made criteria close themselves off LINKED tasks, work parked there can
226
+ // never advance anything, and it does not read as real goal work on the board.
227
+ // Naming the goal here is what stops a triaged promotion falling into it.
159
228
  const body = {
160
229
  version_id: versionId,
161
230
  title,
@@ -165,6 +234,7 @@ async function createTask({ versionId, title, description, priority, sourceRef }
165
234
  source: 'idea_inbox',
166
235
  source_ref: sourceRef ?? null,
167
236
  };
237
+ if (goalId != null) body.goal_id = Number(goalId);
168
238
  const api = await cliClient();
169
239
  const r = await api.tasks.postTasks({ body });
170
240
  if (!r.ok) {
@@ -296,7 +366,7 @@ async function main() {
296
366
  }
297
367
 
298
368
  if (ans === 'p' || ans === 'promote') {
299
- // Promote: ask for version + priority (defaulted from idea).
369
+ // Promote: version, priority, title, goal — each defaulted from the idea.
300
370
  const defaultVersion = idea.suggested_version || 'PMS-V2';
301
371
  const versionAns = (await prompt(rl, ` version [${defaultVersion}]: `)).trim();
302
372
  const versionId = versionAns || defaultVersion;
@@ -308,6 +378,23 @@ async function main() {
308
378
  const titleAns = (await prompt(rl, ` title [${trim(idea.title, 60)}]: `)).trim();
309
379
  const title = titleAns || idea.title;
310
380
 
381
+ // task 1003071 (R13): a triaged promotion NAMES its goal. The default
382
+ // comes from the stored hint, else the R07 suggester; '-' opts out
383
+ // explicitly, which falls through to the version's catch-all "general"
384
+ // bucket — now a choice someone typed rather than the silent default.
385
+ // The EFFECTIVE title, not idea.title: the suggester matches on title
386
+ // terms, and the operator may just have retitled this.
387
+ const gd = await resolveGoalDefault({ ...idea, title }, versionId);
388
+ if (gd.source === 'suggester') console.log(` suggested goal: ${gd.label}`);
389
+ if (gd.source === 'unavailable') console.log(' (goal suggester unreachable — enter a goal id, or "-" to accept the general bucket)');
390
+ const goalPrompt = gd.goalId != null
391
+ ? ` goal [${gd.goalId}] ("-" for the version's general bucket): `
392
+ : ' goal (blank/"-" for the version\'s general bucket): ';
393
+ const goalAns = (await prompt(rl, goalPrompt)).trim();
394
+ const picked = applyGoalAnswer(gd.goalId, goalAns);
395
+ if (picked.warn) console.log(` ! ${picked.warn}`);
396
+ const goalId = picked.goalId;
397
+
311
398
  try {
312
399
  const task = await createTask({
313
400
  versionId,
@@ -315,6 +402,7 @@ async function main() {
315
402
  description: idea.body_md || '',
316
403
  priority: Number.isFinite(priority) ? priority : null,
317
404
  sourceRef: `idea:${idea.id}`,
405
+ goalId,
318
406
  });
319
407
  await patchIdea(idea.id, {
320
408
  status: 'promoted',
@@ -323,7 +411,7 @@ async function main() {
323
411
  });
324
412
  summary.promoted++;
325
413
  promotedIds.push({ ideaId: idea.id, taskId: task.id });
326
- console.log(` ✓ promoted #${idea.id} → task #${task.id} (${versionId}, P${priority})`);
414
+ console.log(` ✓ promoted #${idea.id} → task #${task.id} (${versionId}, P${priority}${goalId != null ? `, goal ${goalId}` : ', → general bucket'})`);
327
415
  } catch (err) {
328
416
  console.error(` ! ${err.message}`);
329
417
  }
@@ -360,6 +448,9 @@ if (require.main === module) {
360
448
  }
361
449
 
362
450
  module.exports = {
451
+ resolveGoalDefault,
452
+ applyGoalAnswer,
453
+ createTask,
363
454
  resolveDevelopmentPath,
364
455
  parseDevelopment,
365
456
  loadDevelopment,
package/src/module-api.js CHANGED
@@ -1005,8 +1005,10 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
1005
1005
  // landed since 1.19.287 with no explicit bump. run 32546561232. (task 1002620)
1006
1006
  // 1.19.289 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1007
1007
  // landed since 1.19.288 with no explicit bump. run 32549912116. (task 1002620)
1008
+ // 1.19.290 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1009
+ // landed since 1.19.289 with no explicit bump. run 32552538814. (task 1002620)
1008
1010
  // ---------------------------------------------------------------------------
1009
- const CORE_VERSION = '1.19.289'; // CI auto-patch carrier (ADR 0161); see changelog above
1011
+ const CORE_VERSION = '1.19.290'; // CI auto-patch carrier (ADR 0161); see changelog above
1010
1012
 
1011
1013
  // A namespaced logger so a module's log lines are attributable + consistent.
1012
1014
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
@@ -0,0 +1,316 @@
1
+ // tests/promote_goal_id.mjs — a triaged promotion lands IN A GOAL (task 1003071 / R13).
2
+ //
3
+ // Before this, both promote paths — the hall triage overlay and the /idea-triage
4
+ // CLI — created a task with no goal_id at all. That does NOT mean the task came
5
+ // out goal-less: db.createTask (modules/lifecycle/db.js) defaults a goal-less
6
+ // create into the version's catch-all "<version> — general" goal. That bucket IS
7
+ // the defect. It carries no done-when criteria, so since ADR 0183 made criteria
8
+ // close themselves off LINKED tasks, work parked there can never advance
9
+ // anything — and it does not read as real goal work on the board. Falling into it
10
+ // had to stop being the silent default.
11
+ //
12
+ // What these pin:
13
+ // 1. PRECEDENCE. The stored `suggested_goal_id` hint beats the R07 suggester.
14
+ // inbox.captureIdea writes that hint even when the matrix declined to route,
15
+ // specifically so triage "inherits the guess instead of re-deriving it"
16
+ // (core_222's own comment). A heuristic must never overrule a named goal.
17
+ // 2. OPT-OUT IS EXPLICIT. Taking the general bucket is still allowed — it just
18
+ // has to be chosen, not defaulted into.
19
+ // 3. DEGRADES, NEVER BLOCKS. A suggester outage must not stop a triage verdict.
20
+ // 4. BOTH PATHS send goal_id. Two surfaces, one contract — the same class of
21
+ // miss as the two claim-insert sites (task 1003137).
22
+ //
23
+ // Run: node tests/promote_goal_id.mjs
24
+
25
+ import { strict as assert } from 'node:assert';
26
+ import { createRequire } from 'node:module';
27
+ import { readFileSync } from 'node:fs';
28
+ import { makeRunner } from './helpers.mjs';
29
+
30
+ process.env.NODE_ENV = 'test';
31
+ const require = createRequire(import.meta.url);
32
+ const { test, summary } = makeRunner();
33
+
34
+ // triage.js captures `cliClient` by destructuring at require time, so a stub has
35
+ // to be in place BEFORE the module loads. Dropping both from the require cache
36
+ // and re-requiring is what makes that possible per-test.
37
+ function freshTriage() {
38
+ delete require.cache[require.resolve('../scripts/gds/triage.js')];
39
+ return require('../scripts/gds/triage.js');
40
+ }
41
+
42
+ const TRIAGE = readFileSync(new URL('../scripts/gds/triage.js', import.meta.url), 'utf8');
43
+ const HALL = readFileSync(new URL('../modules/hall-ui/public/task-detail.js', import.meta.url), 'utf8');
44
+ const SKILL = readFileSync(new URL('../.claude/skills/idea-triage/SKILL.md', import.meta.url), 'utf8');
45
+
46
+ // ==========================================================================
47
+ // BEHAVIOURAL — these INVOKE the code. The static assertions further down pin
48
+ // the wiring a unit test cannot reach (the DOM strings, the skill doc); they do
49
+ // not stand in for actually running it.
50
+ // ==========================================================================
51
+
52
+ const triage = require('../scripts/gds/triage.js');
53
+
54
+ // A stub of the generated typed client: records the call, answers from a script.
55
+ function stubClient(answer) {
56
+ const calls = [];
57
+ const client = async () => ({
58
+ goals: {
59
+ getGoalsSuggestions: async (req) => {
60
+ calls.push(req);
61
+ if (answer instanceof Error) throw answer;
62
+ return answer;
63
+ },
64
+ },
65
+ });
66
+ return { client, calls };
67
+ }
68
+
69
+ const SUGGESTED = {
70
+ ok: true,
71
+ data: { suggestions: [{ id: 1000071, title: 'Idea routing', score: 7, matched_terms: ['triage', 'goal'] }] },
72
+ };
73
+
74
+ await test('BEHAVIOUR: a junk stored hint is ignored, not turned into goalId NaN', async () => {
75
+ for (const junk of ['not-an-id', '', '0', '-3', '4.5', {}]) {
76
+ const { client, calls } = stubClient(SUGGESTED);
77
+ const r = await triage.resolveGoalDefault({ suggested_goal_id: junk, title: 't' }, 'BV1', { client });
78
+ assert.notEqual(r.source, 'hint', `${JSON.stringify(junk)} is not a usable hint`);
79
+ assert.equal(calls.length, 1, 'and it falls through to the suggester rather than dead-ending');
80
+ }
81
+ });
82
+
83
+ await test('BEHAVIOUR: a stored hint wins and the suggester is never even called', async () => {
84
+ const { client, calls } = stubClient(SUGGESTED);
85
+ const r = await triage.resolveGoalDefault({ suggested_goal_id: 1000058, title: 'x' }, 'BV1', { client });
86
+ assert.equal(r.goalId, 1000058);
87
+ assert.equal(r.source, 'hint');
88
+ assert.equal(calls.length, 0, 'a named goal must not be re-derived — that is the whole point of the column');
89
+ });
90
+
91
+ await test('BEHAVIOUR: with no hint, the top suggestion becomes the default, labelled with its score', async () => {
92
+ const { client, calls } = stubClient(SUGGESTED);
93
+ const r = await triage.resolveGoalDefault({ title: 'goal triage routing', body_md: 'b' }, 'BV1', { client });
94
+ assert.equal(r.goalId, 1000071);
95
+ assert.equal(r.source, 'suggester');
96
+ assert.match(r.label, /score 7/);
97
+ assert.match(r.label, /triage\/goal/, 'the matched terms are what let the operator judge it');
98
+ assert.equal(calls.length, 1);
99
+ assert.equal(calls[0].query.version, 'BV1', 'goals are per-version — the wrong version offers an invalid home');
100
+ assert.equal(calls[0].query.limit, 3);
101
+ });
102
+
103
+ await test('BEHAVIOUR: the body is capped before it goes on a query string', async () => {
104
+ const { client, calls } = stubClient(SUGGESTED);
105
+ await triage.resolveGoalDefault({ title: 't', body_md: 'x'.repeat(9000) }, 'BV1', { client });
106
+ assert.equal(calls[0].query.body.length, 2000, 'an un-capped body would blow the URL length');
107
+ });
108
+
109
+ await test('BEHAVIOUR: a suggester THROW degrades to no default, it does not propagate', async () => {
110
+ const { client } = stubClient(new Error('ECONNREFUSED'));
111
+ const r = await triage.resolveGoalDefault({ title: 't' }, 'BV1', { client });
112
+ assert.deepEqual(r, { goalId: null, label: null, source: 'unavailable' },
113
+ 'a triage verdict must survive the suggester being down');
114
+ });
115
+
116
+ await test('BEHAVIOUR: a non-ok or empty response is "none", distinct from "unavailable"', async () => {
117
+ const empty = await triage.resolveGoalDefault({ title: 't' }, 'BV1',
118
+ { client: stubClient({ ok: true, data: { suggestions: [] } }).client });
119
+ assert.equal(empty.source, 'none');
120
+ const notOk = await triage.resolveGoalDefault({ title: 't' }, 'BV1',
121
+ { client: stubClient({ ok: false, status: 500, data: null }).client });
122
+ assert.equal(notOk.source, 'none', 'the two are reported differently because the operator advice differs');
123
+ });
124
+
125
+ await test('BEHAVIOUR: blank ACCEPTS the default; only "-"/"none" declines', () => {
126
+ assert.deepEqual(triage.applyGoalAnswer(1000071, ''), { goalId: 1000071, warn: null });
127
+ assert.deepEqual(triage.applyGoalAnswer(1000071, ' '), { goalId: 1000071, warn: null },
128
+ 'whitespace is a bare Enter, not a decision');
129
+ assert.deepEqual(triage.applyGoalAnswer(1000071, '-'), { goalId: null, warn: null });
130
+ assert.deepEqual(triage.applyGoalAnswer(1000071, 'none'), { goalId: null, warn: null });
131
+ assert.deepEqual(triage.applyGoalAnswer(1000071, 'NONE'), { goalId: null, warn: null });
132
+ assert.deepEqual(triage.applyGoalAnswer(1000071, '1000058'), { goalId: 1000058, warn: null },
133
+ 'an explicit id overrides the default');
134
+ });
135
+
136
+ await test('BEHAVIOUR: junk clears the goal and SAYS SO rather than landing on a wrong one', () => {
137
+ for (const junk of ['abc', '12.5', '-4', '0', 'goal 7']) {
138
+ const r = triage.applyGoalAnswer(1000071, junk);
139
+ assert.equal(r.goalId, null, `"${junk}" must not resolve to a goal`);
140
+ assert.match(r.warn, /general bucket/, `"${junk}" must say where it will land instead`);
141
+ }
142
+ // Number('') is 0 and Number(' ') is 0 — both would silently become goal 0
143
+ // without the blank check above running first. Guarding the pair together.
144
+ assert.equal(triage.applyGoalAnswer(9, '').goalId, 9);
145
+ });
146
+
147
+ await test('BEHAVIOUR: createTask puts goal_id on the body, and omits it entirely when null', async () => {
148
+ const seen = [];
149
+ const fakeClient = {
150
+ tasks: { postTasks: async (req) => { seen.push(req.body); return { ok: true, data: { task: { id: 7 } } }; } },
151
+ };
152
+ const cliLib = require('../scripts/gds/cli-lib.js');
153
+ const realCliClient = cliLib.cliClient;
154
+ cliLib.cliClient = async () => fakeClient;
155
+ try {
156
+ // triage.js destructured cliClient at require time, so patching the module
157
+ // object cannot reach it — re-require in a fresh registry instead.
158
+ const fresh = freshTriage();
159
+ await fresh.createTask({ versionId: 'BV1', title: 't', description: 'd', priority: 3, sourceRef: 'idea:1', goalId: 1000071 });
160
+ await fresh.createTask({ versionId: 'BV1', title: 't', description: 'd', priority: 3, sourceRef: 'idea:2' });
161
+ assert.equal(seen[0].goal_id, 1000071, 'the goal must reach the server');
162
+ assert.ok(!('goal_id' in seen[1]),
163
+ 'and opting out must send NO goal_id key — the server\'s catch-all only applies on absent');
164
+ assert.equal(seen[0].source, 'idea_inbox', 'provenance still travels');
165
+ assert.equal(seen[0].status, 'backlog');
166
+ } finally {
167
+ cliLib.cliClient = realCliClient;
168
+ }
169
+ });
170
+
171
+ await test('BEHAVIOUR: a failed create throws with the status, it does not return a phantom task', async () => {
172
+ const cliLib = require('../scripts/gds/cli-lib.js');
173
+ const realCliClient = cliLib.cliClient;
174
+ cliLib.cliClient = async () => ({
175
+ tasks: { postTasks: async () => ({ ok: false, status: 422, data: { error: 'NO_SUCH_GOAL' } }) },
176
+ });
177
+ try {
178
+ const fresh = freshTriage();
179
+ await assert.rejects(
180
+ () => fresh.createTask({ versionId: 'BV1', title: 't', description: 'd', goalId: 999999999 }),
181
+ /422/,
182
+ );
183
+ } finally {
184
+ cliLib.cliClient = realCliClient;
185
+ }
186
+ });
187
+
188
+ // ==========================================================================
189
+ // STATIC — the surfaces a unit test cannot invoke (browser DOM strings, docs).
190
+ // ==========================================================================
191
+
192
+ // ---- 4. both paths send goal_id -------------------------------------------
193
+
194
+ await test('BOTH PATHS: the CLI createTask and the hall payload each send goal_id', () => {
195
+ assert.match(TRIAGE, /if \(goalId != null\) body\.goal_id = Number\(goalId\);/,
196
+ 'CLI: goal_id must reach the create body');
197
+ assert.match(TRIAGE, /async function createTask\(\{[^}]*goalId/,
198
+ 'CLI: createTask must accept it rather than ignore the caller');
199
+ assert.match(HALL, /payload\.goal_id = Number\(form\.goal\.value\)/,
200
+ 'hall: goal_id must reach the create payload');
201
+ assert.match(HALL, /data-goal-select/, 'hall: and there must be a picker to choose it from');
202
+ });
203
+
204
+ await test('BOTH PATHS: the stale "does not accept kind" note is corrected, not left to mislead', () => {
205
+ assert.ok(!/doesn't currently accept 'kind'/.test(TRIAGE),
206
+ 'that claim was false — routes/tasks.js takes kind and the hall path sends it');
207
+ assert.match(TRIAGE, /no longer true/, 'and the correction says so explicitly');
208
+ });
209
+
210
+ // ---- 1. precedence: the stored hint wins ---------------------------------
211
+
212
+ // (The CLI's precedence is proven by the BEHAVIOUR block above — it drives the
213
+ // real function and asserts the suggester is never called on a hint. A source-shape
214
+ // assertion here would only restate that, less reliably.)
215
+
216
+ await test('PRECEDENCE: the hall picker marks the hint selected, and only auto-selects a suggestion without one', () => {
217
+ const fn = HALL.slice(HALL.indexOf('async function goalOptionsFor'), HALL.indexOf('async function renderPromote'));
218
+ assert.match(fn, /idea\.suggested_goal_id/, 'the hint is read');
219
+ assert.match(fn, /carried from the filing/, 'and labelled as inherited, not guessed');
220
+ assert.match(fn, /hint == null && i === 0 \? ' selected' : ''/,
221
+ 'a suggestion is pre-selected ONLY when there is no hint to honour');
222
+ });
223
+
224
+ // ---- the suggester's weak-evidence signal is shown, not hidden -----------
225
+
226
+ await test('the suggester default shows score + matched terms so it can be judged', () => {
227
+ assert.match(TRIAGE, /score \$\{top\.score\}/, 'CLI shows the score');
228
+ assert.match(TRIAGE, /matched_terms/, 'CLI shows why it matched');
229
+ assert.match(HALL, /matched: /, 'hall shows why it matched');
230
+ // task 1003062 documented that token overlap can match an incidental word; the
231
+ // visible score is what stops that being an invisible wrong default.
232
+ assert.match(HALL, /incidental word/, 'and the reason it is shown is written down');
233
+ });
234
+
235
+ // ---- 2. opting out is explicit ------------------------------------------
236
+
237
+ await test('OPT-OUT: the prompt itself offers the way out, and the branch delegates the meaning', () => {
238
+ const branch = TRIAGE.slice(TRIAGE.indexOf('const gd = await resolveGoalDefault'), TRIAGE.indexOf('const task = await createTask'));
239
+ assert.match(branch, /general bucket/, 'the prompt has to say where opting out lands');
240
+ assert.match(branch, /applyGoalAnswer\(gd\.goalId, goalAns\)/,
241
+ 'the branch must delegate to the tested helper, not re-implement the parsing inline');
242
+ assert.match(branch, /picked\.warn/, 'and surface its warning');
243
+ assert.match(branch, /resolveGoalDefault\(\{ \.\.\.idea, title \}, versionId\)/,
244
+ 'the suggester must see the title the operator just confirmed, not the filed one');
245
+ // The semantics themselves are covered by the BEHAVIOUR block above — this
246
+ // only pins that main() is actually wired to them.
247
+ });
248
+
249
+ await test('OPT-OUT: the hall names where opting out actually lands, and only defaults to it last', () => {
250
+ const fn = HALL.slice(HALL.indexOf('async function goalOptionsFor'), HALL.indexOf('async function renderPromote'));
251
+ // "— no goal —" would be a lie: the server files a goal_id-less create under
252
+ // the version's catch-all. The label has to say so.
253
+ assert.match(fn, /general bucket/);
254
+ assert.ok(!/— no goal —/.test(fn), 'the honest label replaced the misleading one');
255
+ assert.match(fn, /opts\.length === 0 \? ' selected' : ''/, 'the opt-out is the fallback, not the default');
256
+ assert.match(fn, /a choice rather than the silent default/, 'and the intent is stated');
257
+ });
258
+
259
+ await test('the CLI result line names the goal, or says the task fell into the general bucket', () => {
260
+ assert.match(TRIAGE, /goal \$\{goalId\}` : ', → general bucket'/,
261
+ 'where it landed must be visible in the output, both ways');
262
+ });
263
+
264
+ // ---- 3. degrades, never blocks -----------------------------------------
265
+
266
+ await test('DEGRADES: a suggester outage yields no default rather than throwing', () => {
267
+ const fn = TRIAGE.slice(TRIAGE.indexOf('async function resolveGoalDefault'), TRIAGE.indexOf('async function createTask'));
268
+ assert.match(fn, /catch \(_\) \{\s*return \{ goalId: null, label: null, source: 'unavailable' \}/,
269
+ 'the CLI must swallow it and report unavailable');
270
+ assert.match(TRIAGE, /goal suggester unreachable/, 'and tell the operator what happened');
271
+ const hallFn = HALL.slice(HALL.indexOf('async function goalOptionsFor'), HALL.indexOf('async function renderPromote'));
272
+ assert.match(hallFn, /catch \(_\) \{ suggestions = \[\]; \}/, 'the hall must degrade to no suggestions');
273
+ assert.match(hallFn, /must not stop a triage verdict/, 'the reasoning is recorded');
274
+ });
275
+
276
+ await test('the hall guards the stored hint against a junk value reaching the DOM', () => {
277
+ const fn = HALL.slice(HALL.indexOf('async function goalOptionsFor'), HALL.indexOf('async function renderPromote'));
278
+ assert.match(fn, /Number\.isFinite\(raw\) && raw > 0 \? raw : null/,
279
+ 'value="NaN" would be POSTed as a goal id');
280
+ });
281
+
282
+ await test('the hall degrades a DEAD SESSION to no suggestions instead of navigating away', () => {
283
+ const fn = HALL.slice(HALL.indexOf('async function goalOptionsFor'), HALL.indexOf('async function renderPromote'));
284
+ // getJSON without softAuth calls location.assign on a 401 — from a background
285
+ // fetch that would discard whatever the builder had typed into the form.
286
+ assert.match(fn, /softAuth: true/);
287
+ });
288
+
289
+ await test('DEGRADES: the hall re-fetches goals when the version changes, newest-wins', () => {
290
+ // Two quick version changes race; an out-of-order resolve would paint another
291
+ // version's goals, all of which the server would reject.
292
+ assert.match(HALL, /var mine = \+\+goalFetch;/);
293
+ assert.match(HALL, /if \(mine !== goalFetch\) return;/);
294
+ // Goals belong to a version; keeping stale options would offer a home the
295
+ // server would reject.
296
+ assert.match(HALL, /vsel\.addEventListener\('change'/);
297
+ assert.match(HALL, /await goalOptionsFor\(vsel\.value\)/);
298
+ });
299
+
300
+ await test('the CLI uses the GENERATED typed client method, not the raw escape hatch', () => {
301
+ assert.match(TRIAGE, /api\.goals\.getGoalsSuggestions\(/,
302
+ 'so a path or param rename reds the client build instead of failing at runtime');
303
+ });
304
+
305
+ // ---- the skill contract ------------------------------------------------
306
+
307
+ await test('SKILL DOC: the inbox is declared homeless-only, with both consequences spelled out', () => {
308
+ assert.match(SKILL, /HOMELESS-ONLY/, 'the contract is stated');
309
+ assert.match(SKILL, /Every promote names a goal/, 'consequence 1');
310
+ assert.match(SKILL, /general` goal/, 'and it is honest about what opting out means');
311
+ assert.match(SKILL, /inherited, not guessed/, 'consequence 2 — precedence');
312
+ assert.match(SKILL, /0183/, 'and why a goal-less task now matters more than it used to');
313
+ assert.match(SKILL, /HOMELESS/i, 'the description line carries it too');
314
+ });
315
+
316
+ summary();