@bongos/core 1.19.841 → 1.19.843

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.841",
6
- "core_contract": "1.19.841",
7
- "source_commit": "fd701dc457a5344486573c53ca1f4efcc1948007",
5
+ "core_version": "1.19.843",
6
+ "core_contract": "1.19.843",
7
+ "source_commit": "7c57c44f66a63fda88681f46ac67857f8c8ed1e7",
8
8
  "source_ref": "HEAD",
9
- "built_at": "2026-09-17T18:13:18.546Z",
9
+ "built_at": "2026-09-17T18:30:58.977Z",
10
10
  "redaction": {
11
11
  "model": "docs-redacted+functional-verbatim",
12
12
  "docs_redacted": 495,
13
13
  "agent_docs_stubbed": 26,
14
- "functional_verbatim": 2265,
14
+ "functional_verbatim": 2270,
15
15
  "rules": 3,
16
16
  "gate_literals": 3,
17
17
  "gate": "passed"
18
18
  },
19
- "file_count": 2786,
20
- "tree_sha256": "a54472eaf1c55f1c7f905fb6fd2712a9ab44e8e9f3a731ffba11037c3d9f5b69",
19
+ "file_count": 2791,
20
+ "tree_sha256": "9f6bfba9532caa1edab6600efaf86e006add511e45378f5cf2ccf8ad81d81231",
21
21
  "files": [
22
22
  {
23
23
  "path": ".claude/skills/ask-for-help/SKILL.md",
@@ -2577,7 +2577,7 @@
2577
2577
  {
2578
2578
  "path": "docs/module-api-changelog.md",
2579
2579
  "mode": "0000644",
2580
- "sha256": "79b20e6155f002dd63671b994cbbb8e2f269c3634cd213b5695c0de8bb031cc7"
2580
+ "sha256": "e2083366af3229b576d6d034f1d2e1c6e7e4fa61fba384ba0d0fca9b7911087f"
2581
2581
  },
2582
2582
  {
2583
2583
  "path": "docs/modules-contract.md",
@@ -3679,6 +3679,11 @@
3679
3679
  "mode": "0000644",
3680
3680
  "sha256": "0481e0b8205c707ed002f404c801d3f2059b2b0e0fcfa9bc436968f6665a296f"
3681
3681
  },
3682
+ {
3683
+ "path": "migrations/core_247_discipline_required.sql",
3684
+ "mode": "0000644",
3685
+ "sha256": "e8d3e3f764b61ea559df7f997758e639f8778ee24da4293908e9d45ca30a425b"
3686
+ },
3682
3687
  {
3683
3688
  "path": "modules/agents/CLAUDE.md",
3684
3689
  "mode": "0000644",
@@ -3882,7 +3887,7 @@
3882
3887
  {
3883
3888
  "path": "modules/copy-desk/routes/copy-desk.js",
3884
3889
  "mode": "0000644",
3885
- "sha256": "63ed245f47d15758ef0926a489ad1fc7ef56d8539e836ed9e319e09b99a4a38e"
3890
+ "sha256": "4839f077aee504947a3bada77fd9c3df882f9005f571ed2e2367b615c111b76b"
3886
3891
  },
3887
3892
  {
3888
3893
  "path": "modules/copy-desk/tests/copy_flags.mjs",
@@ -5477,7 +5482,7 @@
5477
5482
  {
5478
5483
  "path": "modules/ideas/inbox.js",
5479
5484
  "mode": "0000644",
5480
- "sha256": "683e2234893c9814c2ecec490099c140e273e764e993707bb1cc4c5c9d1baed3"
5485
+ "sha256": "ff6c0346215d1b88309c4c9360cd60c59299b21b45e27cce09c4b748bf3411e1"
5481
5486
  },
5482
5487
  {
5483
5488
  "path": "modules/ideas/migrations/ideas_001_grades.sql",
@@ -5657,7 +5662,7 @@
5657
5662
  {
5658
5663
  "path": "modules/lifecycle/db-tasks.js",
5659
5664
  "mode": "0000644",
5660
- "sha256": "87b075187493e8026f99912dceb7195d09564bfe3f969f344141661c60ef6558"
5665
+ "sha256": "5d17541d837a06903be832814a74218c9fee8b9119dd870c88273743be5a5efa"
5661
5666
  },
5662
5667
  {
5663
5668
  "path": "modules/lifecycle/db-versions.js",
@@ -5752,7 +5757,7 @@
5752
5757
  {
5753
5758
  "path": "modules/lifecycle/lifecycle.js",
5754
5759
  "mode": "0000644",
5755
- "sha256": "bdc23f748853a4a39ae416f731052bd29e4fe5788684cb83a166243d9240a364"
5760
+ "sha256": "41ef55efff7f334ee8b19490f03944ca25f22454c718dfb81445539bc6a77aa4"
5756
5761
  },
5757
5762
  {
5758
5763
  "path": "modules/lifecycle/merge-lock.js",
@@ -5877,7 +5882,7 @@
5877
5882
  {
5878
5883
  "path": "modules/lifecycle/routes/goals.js",
5879
5884
  "mode": "0000644",
5880
- "sha256": "eb3137c30f6471aa0900f98f0fa45752808609e4e1e99f9ecb1f6df0d3e7c5ab"
5885
+ "sha256": "17f7c0ccfc87eec24c662bf8d5599223562e773430802cd054b27a3ddadab46e"
5881
5886
  },
5882
5887
  {
5883
5888
  "path": "modules/lifecycle/routes/help-requests.js",
@@ -5922,7 +5927,7 @@
5922
5927
  {
5923
5928
  "path": "modules/lifecycle/routes/task-write-routes.js",
5924
5929
  "mode": "0000644",
5925
- "sha256": "9cff3bcd27467dc413aa975cd20262b47e8595a50a845d93b557a24828719bbf"
5930
+ "sha256": "bb172485d8d5f84d96cdb611b0d57cd5f69ee768bdceddeb1eea42dc3f5676c1"
5926
5931
  },
5927
5932
  {
5928
5933
  "path": "modules/lifecycle/routes/tasks.js",
@@ -5962,7 +5967,7 @@
5962
5967
  {
5963
5968
  "path": "modules/lifecycle/task-classifier.js",
5964
5969
  "mode": "0000644",
5965
- "sha256": "9299590b71b194087174ff0212dc70f5f28fbafd5678923c81a3db13c6b78db5"
5970
+ "sha256": "39047afd6440d589cee978887ce4b5fa285b00d55ed2b023546fce41e59bdc9c"
5966
5971
  },
5967
5972
  {
5968
5973
  "path": "modules/lifecycle/task-list-limits.js",
@@ -7892,12 +7897,12 @@
7892
7897
  {
7893
7898
  "path": "package-lock.json",
7894
7899
  "mode": "0000644",
7895
- "sha256": "d507a842a2ad14e85f0e5564998ab1288afad232febc358f932736b51fd12bd7"
7900
+ "sha256": "090497aae4ab83f6ab3f821417ece5fcc50b86a09e026b3e4644aa660333d191"
7896
7901
  },
7897
7902
  {
7898
7903
  "path": "package.json",
7899
7904
  "mode": "0000644",
7900
- "sha256": "81e581275d29c80dc0c2496d83b2a5317da29e43d65aabf7a49309aed72b1003"
7905
+ "sha256": "e7d47a1c39751137250a2cc087fb288d3c349b44d8faebc38f70e841f6475560"
7901
7906
  },
7902
7907
  {
7903
7908
  "path": "public-docs/index.html",
@@ -8064,6 +8069,11 @@
8064
8069
  "mode": "0000644",
8065
8070
  "sha256": "9c03bf914c4a1a6f5d9936bb5542a4e3952873de65b0518254808bfbd1901b40"
8066
8071
  },
8072
+ {
8073
+ "path": "scripts/gds/backfill-disciplines.js",
8074
+ "mode": "0000644",
8075
+ "sha256": "cc9bd2d437e6a7d6eb2b48504cc7d7c44a674403a0d00625a0526719825f88d2"
8076
+ },
8067
8077
  {
8068
8078
  "path": "scripts/gds/backfill-session-earnings.js",
8069
8079
  "mode": "0000644",
@@ -8432,7 +8442,7 @@
8432
8442
  {
8433
8443
  "path": "scripts/gds/fitness-ratchets.js",
8434
8444
  "mode": "0000644",
8435
- "sha256": "b3f4092fffd30e50cb145b524beeed5d483132f52e77e37a7ab9c18bbf300bc7"
8445
+ "sha256": "178d2cfb91c97c107d60cc3895ba1b3eff74c45d383646e7a64621d018c21b47"
8436
8446
  },
8437
8447
  {
8438
8448
  "path": "scripts/gds/fitness.js",
@@ -8577,7 +8587,7 @@
8577
8587
  {
8578
8588
  "path": "scripts/gds/init.js",
8579
8589
  "mode": "0000644",
8580
- "sha256": "9f0b3c283caf637ff6e3b514a417ae3e42572b323a14c83cd89beda270c49c37"
8590
+ "sha256": "e1fbe45a7619cd5000b5bb34c80bbd63c2591dcc78a64188870847e5da049448"
8581
8591
  },
8582
8592
  {
8583
8593
  "path": "scripts/gds/install-git-hooks.js",
@@ -9367,7 +9377,7 @@
9367
9377
  {
9368
9378
  "path": "scripts/gds/triage.js",
9369
9379
  "mode": "0000644",
9370
- "sha256": "de0673313c1eb5e052f993138cd2d5686f9c1de1f9acd00f8a5fca73ed361db2"
9380
+ "sha256": "a4af12591b829f9b89a5768858e8a2ba02a44d7e7ebd848e945e77f95fc55da7"
9371
9381
  },
9372
9382
  {
9373
9383
  "path": "scripts/gds/turn-end-sound.js",
@@ -9827,7 +9837,7 @@
9827
9837
  {
9828
9838
  "path": "src/module-api.js",
9829
9839
  "mode": "0000644",
9830
- "sha256": "d01a900eabeae11f0d03aa7aff1ea89af23f58a3e01c07fc79a4e4ea8b68f4a4"
9840
+ "sha256": "a24a8e5c67584fff7c871ad60afaa3bc9f81a2c77189629597dd4fd63cd43c31"
9831
9841
  },
9832
9842
  {
9833
9843
  "path": "src/module-loader/catalog.js",
@@ -9969,6 +9979,11 @@
9969
9979
  "mode": "0000644",
9970
9980
  "sha256": "919ca0ea3fe5f78a194781f6b634b9da23212073cf0c7cbe8ae36a65cc3fecdb"
9971
9981
  },
9982
+ {
9983
+ "path": "tests/agents_roster.mjs",
9984
+ "mode": "0000644",
9985
+ "sha256": "acc25f4d72cb22719d6977f939b41f9f17e0d102880976699b6ebcda1c64c523"
9986
+ },
9972
9987
  {
9973
9988
  "path": "tests/agents_routes.mjs",
9974
9989
  "mode": "0000644",
@@ -11984,6 +11999,11 @@
11984
11999
  "mode": "0000644",
11985
12000
  "sha256": "416d413757a13fbddb5841ea8a2c503077d7e6067bc1c58ce1f4d416b8d8487f"
11986
12001
  },
12002
+ {
12003
+ "path": "tests/knip_ratchet_ambient.mjs",
12004
+ "mode": "0000644",
12005
+ "sha256": "b48d7f46c4518ef7da8e252d8009edd25ec5771f7be320f889f437762af72b54"
12006
+ },
11987
12007
  {
11988
12008
  "path": "tests/known_failing_ratchet.mjs",
11989
12009
  "mode": "0000644",
@@ -12857,7 +12877,7 @@
12857
12877
  {
12858
12878
  "path": "tests/rank_floor_wiring.mjs",
12859
12879
  "mode": "0000644",
12860
- "sha256": "f81f559b5428497b2246580c7921ecd1ba5a2f1034cf29eb5e5c21b04675f216"
12880
+ "sha256": "973fc0768bd52f3e25c98306c21cc6a9c6268daae033e1329c68620a847bc0a9"
12861
12881
  },
12862
12882
  {
12863
12883
  "path": "tests/rank_gate.mjs",
@@ -12967,7 +12987,7 @@
12967
12987
  {
12968
12988
  "path": "tests/reward_gate.mjs",
12969
12989
  "mode": "0000644",
12970
- "sha256": "1ed242c879472dd872c1327c8e7c91de61886dcb108ece3fa455ae6be4b4e025"
12990
+ "sha256": "0cd9f335da162c3562c099a2dbe4375b009f5a8a8321f4ef84b7a577be816bdc"
12971
12991
  },
12972
12992
  {
12973
12993
  "path": "tests/reward_mode_cost_plus_only.mjs",
@@ -13614,6 +13634,11 @@
13614
13634
  "mode": "0000644",
13615
13635
  "sha256": "0bc0d93696b5ce80fb4307b7991bff640316e46fd6fcdebf0ba5a62397ffbe8f"
13616
13636
  },
13637
+ {
13638
+ "path": "tests/task_discipline_required.mjs",
13639
+ "mode": "0000644",
13640
+ "sha256": "2fda6816614259699227a21f97b81d816f20fdd654c861e24a282c1d178ddff4"
13641
+ },
13617
13642
  {
13618
13643
  "path": "tests/task_field_bounds_match_db.mjs",
13619
13644
  "mode": "0000644",
@@ -13632,7 +13657,7 @@
13632
13657
  {
13633
13658
  "path": "tests/task_goal_required.mjs",
13634
13659
  "mode": "0000644",
13635
- "sha256": "04e7a36f7c2f5a94f847fcb4b77f017f6399fc96041cdaee1123c9a6fecb3d5d"
13660
+ "sha256": "073581d9a97095666ee2ed7ebb0a6b280f3e8ea1efc53015c2f77eb5d08c4975"
13636
13661
  },
13637
13662
  {
13638
13663
  "path": "tests/task_lib.mjs",
@@ -2156,5 +2156,9 @@ is load-bearing: the script throws rather than guess if it is missing, and
2156
2156
  landed since 1.19.839 with no explicit bump. run 35254616836. (task 1002620)
2157
2157
  1.19.841 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
2158
2158
  landed since 1.19.840 with no explicit bump. run 35257419225. (task 1002620)
2159
+ 1.19.842 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
2160
+ landed since 1.19.841 with no explicit bump. run 35258288930. (task 1002620)
2161
+ 1.19.843 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
2162
+ landed since 1.19.842 with no explicit bump. run 35259228465. (task 1002620)
2159
2163
  ---------------------------------------------------------------------------
2160
2164
  ```
@@ -0,0 +1,58 @@
1
+ -- core_247_discipline_required.sql — task 1002988: every task row carries a craft.
2
+ --
3
+ -- `unclassified` is a triage state, not a craft. It was the DEFAULT on every create
4
+ -- vector, so it accumulated: 1,189 rows on this instance as of 2026-09-17 (1,025
5
+ -- real + 164 __smoke__ test rows), of which only 208 were live work. A row nobody's
6
+ -- craft owns is a row nobody picks up.
7
+ --
8
+ -- THE ACCEPTED SET IS FOUR, NOT THREE. The task was written on 2026-08-16 naming
9
+ -- {engineer, artist, ideator}. ADR 0272 (2026-09-09) postdates it and KEEPS `ui` as
10
+ -- a module-contributed discipline, and the owner reconfirmed that on 2026-09-17 when
11
+ -- abandoning the sibling task 1002987 ("Delete the ui role across the platform") for
12
+ -- exactly this reason. Narrowing to three would break 50+ live `ui` rows and
13
+ -- contradict a live ADR, so migration 176's `ui` entry survives untouched here.
14
+ --
15
+ -- WHY THE BACKFILL IS IN THE MIGRATION. The good classification is done by
16
+ -- scripts/gds/backfill-disciplines.js, which runs the real JS heuristic
17
+ -- (classifyTaskDiscipline) and cannot be expressed in SQL. But a migration that
18
+ -- assumes a script was run by hand is a migration that breaks the next fresh
19
+ -- install, the next instance, and any checkout where nobody remembered. So the
20
+ -- UPDATE below is the LAST-RESORT NET, not the plan: it catches whatever the script
21
+ -- left (or everything, if the script never ran) and parks it on 'engineer' — the
22
+ -- value ~99% of confidently-classified rows resolve to.
23
+ --
24
+ -- That fallback is a GUESS and is recorded as one. The owner chose it on 2026-09-17
25
+ -- over the alternative (backfill live work only, leave history alone), accepting
26
+ -- that the ~273 rows the classifier refuses get a best guess rather than a known
27
+ -- answer. Do not read a post-migration `discipline` on an old row as evidence that
28
+ -- anyone determined it.
29
+ --
30
+ -- Idempotent: safe to re-run. The UPDATE matches nothing on a second pass, and the
31
+ -- constraint is dropped before being re-added (the pattern of migrations 086/176).
32
+ -- Forward-safe: the column is unchanged and the previous release wrote only values
33
+ -- this constraint still accepts, EXCEPT 'unclassified' — a rolled-back deploy whose
34
+ -- code still defaults to 'unclassified' would have its INSERTs rejected. That is the
35
+ -- intended behaviour (it is the hole being closed), and it is why the application
36
+ -- side of this task resolves the discipline at db-tasks.createTask, the single
37
+ -- chokepoint every create vector passes through, rather than only at the two routes.
38
+
39
+ BEGIN;
40
+
41
+ -- 1. The last-resort net (see header). Everything the classifier could call should
42
+ -- already have been PATCHed by backfill-disciplines.js before this runs.
43
+ UPDATE tasks SET discipline = 'engineer' WHERE discipline = 'unclassified' OR discipline IS NULL;
44
+
45
+ -- 2. Narrow the accepted set: drop 'unclassified', keep 'ui' (ADR 0272).
46
+ ALTER TABLE tasks DROP CONSTRAINT IF EXISTS tasks_discipline_check;
47
+ ALTER TABLE tasks ADD CONSTRAINT tasks_discipline_check
48
+ CHECK (discipline IN ('engineer', 'artist', 'ideator', 'ui'));
49
+
50
+ -- builders.preferred_disciplines already excludes 'unclassified' (migration 176 —
51
+ -- "Never carries 'unclassified' — that's a task state, not an interest area"), so it
52
+ -- needs no change here. Named rather than silently skipped, so the next reader does
53
+ -- not have to re-check.
54
+
55
+ INSERT INTO schema_migrations (version) VALUES ('core_247_discipline_required')
56
+ ON CONFLICT (version) DO NOTHING;
57
+
58
+ COMMIT;
@@ -429,6 +429,12 @@ module.exports = function buildCopyDeskRouter() {
429
429
  // opinion mint claimable work.
430
430
  status: 'backlog',
431
431
  kind: 'feature',
432
+ // task 1002988: named explicitly rather than inferred. A copy patch is
433
+ // reader-facing wording, which is artist work — cascade.js already treats
434
+ // `source: 'copy-proposal'` as an artist surface (hasCopyOrVisualWork), so
435
+ // inferring it from the patch text would risk the two disagreeing about
436
+ // the same row.
437
+ discipline: 'artist',
432
438
  priority: 4,
433
439
  touches: [patch.file],
434
440
  source: 'copy-proposal',
@@ -405,9 +405,11 @@ async function routeIdeaToGoal({
405
405
  // own floor and takes the HIGHER of the two, so this can raise the bar and
406
406
  // never lower it. Deliberately NOT passed at all: `creditsReward` (server
407
407
  // default — createTask auto-assigns the capped suggestion) and
408
- // `discipline` (left 'unclassified', exactly as the other non-authored
409
- // create vectors leave it).
408
+ // `discipline` — NO LONGER 'unclassified' (task 1002988). createTask refuses a
409
+ // craft it cannot infer; refusing a filer is the wrong trade for an INBOX, so we
410
+ // ask the same question and fall back to 'engineer' (free to re-triage; a lost capture is not).
410
411
  const task = await lifecycle.createTask({
412
+ discipline: (lifecycle.resolveWriteDiscipline?.({ title, description: bodyMd, touches: declaredTouches })?.discipline) || 'engineer',
411
413
  versionId: goal.version_id,
412
414
  title,
413
415
  description: bodyMd,
@@ -18,6 +18,7 @@ const { matchProtected } = api.permissionPathCheck;
18
18
  const { decorateGoalMembership, capNewcomerTasks } = require('./claim-feed.js');
19
19
  const { deriveRequiredRank, highestRank } = require('./db-rank-authz.js');
20
20
  const { executorFor } = require('./db-executor.js');
21
+ const { resolveWriteDiscipline, WRITABLE_DISCIPLINES } = require('./task-classifier.js');
21
22
 
22
23
  // -------------------------------------------------------------------------
23
24
  // Tasks
@@ -123,7 +124,13 @@ async function createTask({
123
124
  sourceRef = null,
124
125
  valueSummary = null,
125
126
  kind = 'unclassified',
126
- discipline = 'unclassified',
127
+ // task 1002988 — NOT defaulted to 'unclassified' any more. This is the single
128
+ // chokepoint every create vector passes through, including the ones that never
129
+ // touch an HTTP route (idea promotion, copy-desk, newcomer-restock, kickoff-seed,
130
+ // cascade), so resolving here is what makes "no row is born role-less" true of
131
+ // the whole path rather than of the two routes. Resolved just below; `undefined`
132
+ // means "work it out from the text".
133
+ discipline = undefined,
127
134
  parallelSafe = null,
128
135
  newcomerFriendly = false,
129
136
  needsMigration = false,
@@ -198,6 +205,25 @@ async function createTask({
198
205
  }
199
206
  goalIdEffective = await generalGoalIdForVersion(versionId);
200
207
  }
208
+
209
+ // task 1002988 (owner decision 2026-09-17): every row is born with a craft.
210
+ // An absent or 'unclassified' discipline is INFERRED from title + description +
211
+ // touches; a task whose text says nothing about its craft is REFUSED rather than
212
+ // filed under a word that means "nobody looked". Throwing here rather than
213
+ // silently defaulting is the point — 1,189 role-less rows accumulated precisely
214
+ // because the default was quiet. A caller that cannot tolerate a refusal passes
215
+ // an explicit discipline, which is then visible at its call site instead of
216
+ // hidden in this signature.
217
+ const resolvedDiscipline = resolveWriteDiscipline({ discipline, title, description, touches });
218
+ if (resolvedDiscipline.error) {
219
+ const err = new Error(resolvedDiscipline.unclassifiable
220
+ ? `Cannot infer a discipline for "${String(title).slice(0, 80)}" from its title and description. Pass discipline explicitly: ${Array.from(WRITABLE_DISCIPLINES).join(' | ')}.`
221
+ : `discipline must be one of ${Array.from(WRITABLE_DISCIPLINES).join(' | ')} (got ${JSON.stringify(discipline)}).`);
222
+ err.code = 'BAD_DISCIPLINE';
223
+ err.unclassifiable = resolvedDiscipline.unclassifiable === true;
224
+ throw err;
225
+ }
226
+ const disciplineEffective = resolvedDiscipline.discipline;
201
227
  // task 1498 / ADR 0084: every task carries a deliberate rank floor. Derive it,
202
228
  // then let an explicit requiresRank override only UPWARD (highestRank) — a
203
229
  // requested rank below the derived floor is clamped up to the floor
@@ -247,7 +273,7 @@ async function createTask({
247
273
  sourceRef,
248
274
  valueSummary,
249
275
  kind,
250
- discipline,
276
+ disciplineEffective,
251
277
  parallelSafe,
252
278
  newcomerFriendly === true,
253
279
  needsMigration === true,
@@ -37,6 +37,12 @@ module.exports = {
37
37
  // (consumer: core cascade-dispatch.js, the dormant System-3 lib).
38
38
  get VOTABLE_TASK_STATUSES() { return classifier.VOTABLE_TASK_STATUSES; },
39
39
  classifyTaskKind: (...a) => classifier.classifyTaskKind(...a),
40
+ // task 1002988: createTask now REFUSES a task whose craft it cannot work out, so
41
+ // a consumer that must not fail (idea capture, copy-desk) needs to ask the same
42
+ // question first and name its own fallback. Exposed on the port because a module
43
+ // may not require another module — the boundary rule — and a second copy of this
44
+ // heuristic living in modules/ideas is exactly the drift this port exists to stop.
45
+ resolveWriteDiscipline: (...a) => classifier.resolveWriteDiscipline(...a),
40
46
  // TASK_KINDS: the kind vocabulary, as an ordered array (consumer: GET /me,
41
47
  // which hands it to the hall so a filter can be BUILT from it instead of
42
48
  // retyped — task 1003453). VALID_KINDS is a Set and Sets do not survive
@@ -31,7 +31,7 @@ const goalEdits = require('../goal-edits.js'); // task 1003102 — goal self-edi
31
31
  const goalCategory = require('../goal-category.js'); // task 1003275 — the ADVISORY human category (gates nothing)
32
32
  const goalRollup = require('../goal-rollup.js'); // task 1003278 — per-goal rollups computed in SQL, not in the browser
33
33
  const { suggestCriteria } = require('../criterion-suggest');
34
- const { classifyTaskKind, VALID_KINDS, VALID_DISCIPLINES, TASK_FIELD_BOUNDS } = require('../task-classifier');
34
+ const { classifyTaskKind, VALID_KINDS, WRITABLE_DISCIPLINES, resolveWriteDiscipline, TASK_FIELD_BOUNDS } = require('../task-classifier');
35
35
  const { validateOrRespond, parseId, LIMITS } = api;
36
36
  const { isModuleKey, scopeIncludesProtected, moduleKeys } = api.moduleScopeMap;
37
37
  // goalScopeCheck — the PURE scope wall (BV1.R59 / ADR 0086 §3), reached through
@@ -1388,10 +1388,23 @@ module.exports = function buildGoalsRouter() {
1388
1388
  const result = classifyTaskKind(body.title, body.description || '');
1389
1389
  kind = result.confidence >= 0.5 ? result.kind : 'unclassified';
1390
1390
  }
1391
- const discipline = body.discipline ?? 'unclassified';
1392
- if (!VALID_DISCIPLINES.has(discipline)) {
1393
- return res.fail('bad_discipline', 400, { valid: Array.from(VALID_DISCIPLINES) });
1391
+ // task 1002988 — the SECOND create path. It carried the same
1392
+ // `body.discipline ?? 'unclassified'` line as POST /tasks, so a fix applied
1393
+ // only there would have left this one minting the rows the task exists to
1394
+ // remove. Both now share resolveWriteDiscipline().
1395
+ const resolvedDiscipline = resolveWriteDiscipline({
1396
+ discipline: body.discipline, title: body.title, description: body.description, touches: body.touches,
1397
+ });
1398
+ if (resolvedDiscipline.error) {
1399
+ return res.fail('bad_discipline', {
1400
+ status: 400,
1401
+ message: resolvedDiscipline.unclassifiable
1402
+ ? `discipline was not supplied and could not be inferred from the title and description. Pass one of ${Array.from(WRITABLE_DISCIPLINES).join(' | ')}.`
1403
+ : `discipline must be one of ${Array.from(WRITABLE_DISCIPLINES).join(' | ')}.`,
1404
+ details: { valid: Array.from(WRITABLE_DISCIPLINES) },
1405
+ });
1394
1406
  }
1407
+ const discipline = resolvedDiscipline.discipline;
1395
1408
 
1396
1409
  // Create. version = the goal's (not client-supplied); status 'ready' so the
1397
1410
  // author can claim it immediately; requiresRank OMITTED → auto-derived
@@ -22,7 +22,7 @@ const db = require('../db');
22
22
  const hierarchy = require('../hierarchy');
23
23
  const { suggestCriteria } = require('../criterion-suggest');
24
24
  const { buildGoalAdvisory } = require('../goal-advisory');
25
- const { classifyTaskKind, VALID_KINDS, VALID_DISCIPLINES, VOTABLE_TASK_STATUSES, TASK_FIELD_BOUNDS } = require('../task-classifier');
25
+ const { classifyTaskKind, VALID_KINDS, WRITABLE_DISCIPLINES, resolveWriteDiscipline, VOTABLE_TASK_STATUSES, TASK_FIELD_BOUNDS } = require('../task-classifier');
26
26
  const { isModuleKey, moduleKeys } = api.moduleScopeMap;
27
27
  const { validateOrRespond, LIMITS, parseId } = api;
28
28
  const { badKind, detectsMigration } = require('./task-route-helpers.js');
@@ -141,10 +141,22 @@ module.exports = function registerTaskWriteRoutes(router) {
141
141
  const result = classifyTaskKind(body.title, body.description || '');
142
142
  kind = result.confidence >= 0.5 ? result.kind : 'unclassified';
143
143
  }
144
- const discipline = body.discipline ?? 'unclassified';
145
- if (!VALID_DISCIPLINES.has(discipline)) {
146
- return res.fail('bad_discipline', 400, { valid: Array.from(VALID_DISCIPLINES) });
144
+ // task 1002988: a create must never mint a role-less row. An omitted (or
145
+ // explicitly 'unclassified') discipline is INFERRED from the text; only a
146
+ // genuinely unclassifiable task is refused.
147
+ const resolved = resolveWriteDiscipline({
148
+ discipline: body.discipline, title: body.title, description: body.description, touches: body.touches,
149
+ });
150
+ if (resolved.error) {
151
+ return res.fail('bad_discipline', {
152
+ status: 400,
153
+ message: resolved.unclassifiable
154
+ ? `discipline was not supplied and could not be inferred from the title and description. Pass one of ${Array.from(WRITABLE_DISCIPLINES).join(' | ')}.`
155
+ : `discipline must be one of ${Array.from(WRITABLE_DISCIPLINES).join(' | ')}.`,
156
+ details: { valid: Array.from(WRITABLE_DISCIPLINES), inferred: false },
157
+ });
147
158
  }
159
+ const discipline = resolved.discipline;
148
160
  // task 1498 / ADR 0084: an explicit requires_rank must be a live rank. The
149
161
  // floor auto-derives from touches + security_sensitive; createTask clamps
150
162
  // any explicit value UP to that floor (never below), so this only rejects a
@@ -496,8 +508,15 @@ module.exports = function registerTaskWriteRoutes(router) {
496
508
  if (hasKind && !VALID_KINDS.has(body.kind)) {
497
509
  return badKind(res);
498
510
  }
499
- if (hasDiscipline && !VALID_DISCIPLINES.has(body.discipline)) {
500
- return res.fail('bad_discipline', 400, { valid: Array.from(VALID_DISCIPLINES) });
511
+ // task 1002988: a PATCH may not push a row BACK to 'unclassified' — that is a
512
+ // triage state, not a craft, and letting an edit re-create one would reopen the
513
+ // hole the create path above just closed.
514
+ if (hasDiscipline && !WRITABLE_DISCIPLINES.has(body.discipline)) {
515
+ return res.fail('bad_discipline', {
516
+ status: 400,
517
+ message: `discipline must be one of ${Array.from(WRITABLE_DISCIPLINES).join(' | ')}.`,
518
+ details: { valid: Array.from(WRITABLE_DISCIPLINES) },
519
+ });
501
520
  }
502
521
  if (hasNewcomerOrXenos) {
503
522
  // V3.R15 #239: prefer newcomer_friendly (canonical); fall back to
@@ -163,6 +163,42 @@ function classifyTaskKind(title, description) {
163
163
  // /design work-mode (discipline-modes.json) instead of /paint.
164
164
  const VALID_DISCIPLINES = new Set(['engineer', 'artist', 'ideator', 'ui', 'unclassified']);
165
165
 
166
+ // The disciplines a WRITE may set (task 1002988). 'unclassified' is deliberately
167
+ // absent: it is a triage state, not a craft, and a create that lands one produces
168
+ // a row no craft is accountable for — which is how 1,189 of them accumulated.
169
+ // VALID_DISCIPLINES above stays the READ-side set so existing rows and the
170
+ // ?discipline=unclassified filter keep resolving while the backfill runs.
171
+ // 'ui' stays: ADR 0272 keeps it as a module-contributed discipline, and the owner
172
+ // reconfirmed that on 2026-09-17 when abandoning task 1002987.
173
+ const WRITABLE_DISCIPLINES = new Set(['engineer', 'artist', 'ideator', 'ui']);
174
+
175
+ // Mirrors the `kind` branch in the create routes: act on the classifier only when
176
+ // it is at least half sure, otherwise treat it as no answer at all.
177
+ const DISCIPLINE_CONFIDENCE_FLOOR = 0.5;
178
+
179
+ // Resolve the discipline for a task WRITE. Honours an explicit valid value,
180
+ // otherwise INFERS from title + description + touches, and reports failure so the
181
+ // caller can 400 — per the owner decision of 2026-09-17, a bare 400 on a missing
182
+ // discipline was rejected because four live paths (idea capture, idea promotion,
183
+ // adopt-import and the new-instance scaffold) create tasks without one.
184
+ //
185
+ // Shared deliberately: POST /tasks and POST /goals/:id/tasks BOTH carried the
186
+ // `body.discipline ?? 'unclassified'` line, so a fix applied to one of them would
187
+ // have left the other quietly minting the very rows this task exists to remove.
188
+ // Returns { discipline, inferred?, confidence? } or { error, unclassifiable? }.
189
+ function resolveWriteDiscipline({ discipline, title, description, touches } = {}) {
190
+ if (discipline != null && discipline !== 'unclassified') {
191
+ return WRITABLE_DISCIPLINES.has(discipline) ? { discipline } : { error: 'bad_discipline' };
192
+ }
193
+ // Absent, null, or an explicit 'unclassified' (what modules/ideas/inbox.js sends
194
+ // on promotion) all mean the same thing: nobody told us, so work it out.
195
+ const guess = classifyTaskDiscipline(title, description || '', touches);
196
+ if (guess.confidence >= DISCIPLINE_CONFIDENCE_FLOOR && WRITABLE_DISCIPLINES.has(guess.discipline)) {
197
+ return { discipline: guess.discipline, inferred: true, confidence: guess.confidence };
198
+ }
199
+ return { error: 'bad_discipline', unclassifiable: true };
200
+ }
201
+
166
202
  // Numeric field bounds for task creation (POST /tasks). The single source of
167
203
  // truth: the route schema in routes/tasks.js references these so the validator
168
204
  // and the claim-time context pack (V4.R04 / sessions-start-knowing) can never
@@ -516,6 +552,9 @@ module.exports = {
516
552
  _resetVendorCacheForTests,
517
553
  VALID_KINDS,
518
554
  VALID_DISCIPLINES,
555
+ WRITABLE_DISCIPLINES,
556
+ DISCIPLINE_CONFIDENCE_FLOOR,
557
+ resolveWriteDiscipline,
519
558
  VOTABLE_TASK_STATUSES,
520
559
  TASK_FIELD_BOUNDS,
521
560
  };
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.841",
3
+ "version": "1.19.843",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.841",
9
+ "version": "1.19.843",
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.841",
3
+ "version": "1.19.843",
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",