@bongos/core 1.19.842 → 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 +44 -24
- package/docs/module-api-changelog.md +2 -0
- package/migrations/core_247_discipline_required.sql +58 -0
- package/modules/copy-desk/routes/copy-desk.js +6 -0
- package/modules/ideas/inbox.js +4 -2
- package/modules/lifecycle/db-tasks.js +28 -2
- package/modules/lifecycle/lifecycle.js +6 -0
- package/modules/lifecycle/routes/goals.js +17 -4
- package/modules/lifecycle/routes/task-write-routes.js +25 -6
- package/modules/lifecycle/task-classifier.js +39 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/backfill-disciplines.js +144 -0
- package/scripts/gds/fitness-ratchets.js +59 -2
- package/scripts/gds/init.js +6 -1
- package/scripts/gds/triage.js +7 -0
- package/src/module-api.js +1 -1
- package/tests/knip_ratchet_ambient.mjs +101 -0
- package/tests/rank_floor_wiring.mjs +4 -0
- package/tests/reward_gate.mjs +4 -4
- package/tests/task_discipline_required.mjs +163 -0
- package/tests/task_goal_required.mjs +4 -4
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.
|
|
6
|
-
"core_contract": "1.19.
|
|
7
|
-
"source_commit": "
|
|
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:
|
|
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":
|
|
14
|
+
"functional_verbatim": 2270,
|
|
15
15
|
"rules": 3,
|
|
16
16
|
"gate_literals": 3,
|
|
17
17
|
"gate": "passed"
|
|
18
18
|
},
|
|
19
|
-
"file_count":
|
|
20
|
-
"tree_sha256": "
|
|
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": "
|
|
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": "
|
|
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": "
|
|
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": "
|
|
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": "
|
|
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": "
|
|
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": "
|
|
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": "
|
|
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": "
|
|
7900
|
+
"sha256": "090497aae4ab83f6ab3f821417ece5fcc50b86a09e026b3e4644aa660333d191"
|
|
7896
7901
|
},
|
|
7897
7902
|
{
|
|
7898
7903
|
"path": "package.json",
|
|
7899
7904
|
"mode": "0000644",
|
|
7900
|
-
"sha256": "
|
|
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": "
|
|
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": "
|
|
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": "
|
|
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": "
|
|
9840
|
+
"sha256": "a24a8e5c67584fff7c871ad60afaa3bc9f81a2c77189629597dd4fd63cd43c31"
|
|
9831
9841
|
},
|
|
9832
9842
|
{
|
|
9833
9843
|
"path": "src/module-loader/catalog.js",
|
|
@@ -11989,6 +11999,11 @@
|
|
|
11989
11999
|
"mode": "0000644",
|
|
11990
12000
|
"sha256": "416d413757a13fbddb5841ea8a2c503077d7e6067bc1c58ce1f4d416b8d8487f"
|
|
11991
12001
|
},
|
|
12002
|
+
{
|
|
12003
|
+
"path": "tests/knip_ratchet_ambient.mjs",
|
|
12004
|
+
"mode": "0000644",
|
|
12005
|
+
"sha256": "b48d7f46c4518ef7da8e252d8009edd25ec5771f7be320f889f437762af72b54"
|
|
12006
|
+
},
|
|
11992
12007
|
{
|
|
11993
12008
|
"path": "tests/known_failing_ratchet.mjs",
|
|
11994
12009
|
"mode": "0000644",
|
|
@@ -12862,7 +12877,7 @@
|
|
|
12862
12877
|
{
|
|
12863
12878
|
"path": "tests/rank_floor_wiring.mjs",
|
|
12864
12879
|
"mode": "0000644",
|
|
12865
|
-
"sha256": "
|
|
12880
|
+
"sha256": "973fc0768bd52f3e25c98306c21cc6a9c6268daae033e1329c68620a847bc0a9"
|
|
12866
12881
|
},
|
|
12867
12882
|
{
|
|
12868
12883
|
"path": "tests/rank_gate.mjs",
|
|
@@ -12972,7 +12987,7 @@
|
|
|
12972
12987
|
{
|
|
12973
12988
|
"path": "tests/reward_gate.mjs",
|
|
12974
12989
|
"mode": "0000644",
|
|
12975
|
-
"sha256": "
|
|
12990
|
+
"sha256": "0cd9f335da162c3562c099a2dbe4375b009f5a8a8321f4ef84b7a577be816bdc"
|
|
12976
12991
|
},
|
|
12977
12992
|
{
|
|
12978
12993
|
"path": "tests/reward_mode_cost_plus_only.mjs",
|
|
@@ -13619,6 +13634,11 @@
|
|
|
13619
13634
|
"mode": "0000644",
|
|
13620
13635
|
"sha256": "0bc0d93696b5ce80fb4307b7991bff640316e46fd6fcdebf0ba5a62397ffbe8f"
|
|
13621
13636
|
},
|
|
13637
|
+
{
|
|
13638
|
+
"path": "tests/task_discipline_required.mjs",
|
|
13639
|
+
"mode": "0000644",
|
|
13640
|
+
"sha256": "2fda6816614259699227a21f97b81d816f20fdd654c861e24a282c1d178ddff4"
|
|
13641
|
+
},
|
|
13622
13642
|
{
|
|
13623
13643
|
"path": "tests/task_field_bounds_match_db.mjs",
|
|
13624
13644
|
"mode": "0000644",
|
|
@@ -13637,7 +13657,7 @@
|
|
|
13637
13657
|
{
|
|
13638
13658
|
"path": "tests/task_goal_required.mjs",
|
|
13639
13659
|
"mode": "0000644",
|
|
13640
|
-
"sha256": "
|
|
13660
|
+
"sha256": "073581d9a97095666ee2ed7ebb0a6b280f3e8ea1efc53015c2f77eb5d08c4975"
|
|
13641
13661
|
},
|
|
13642
13662
|
{
|
|
13643
13663
|
"path": "tests/task_lib.mjs",
|
|
@@ -2158,5 +2158,7 @@ is load-bearing: the script throws rather than guess if it is missing, and
|
|
|
2158
2158
|
landed since 1.19.840 with no explicit bump. run 35257419225. (task 1002620)
|
|
2159
2159
|
1.19.842 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
2160
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)
|
|
2161
2163
|
---------------------------------------------------------------------------
|
|
2162
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',
|
package/modules/ideas/inbox.js
CHANGED
|
@@ -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`
|
|
409
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
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,
|
|
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
|
-
|
|
145
|
-
|
|
146
|
-
|
|
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
|
-
|
|
500
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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",
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// scripts/gds/backfill-disciplines.js — task 1002988: give every role-less task a craft.
|
|
3
|
+
//
|
|
4
|
+
// WHY THIS EXISTS SEPARATELY FROM THE MIGRATION. core_247 narrows
|
|
5
|
+
// tasks_discipline_check to {engineer, artist, ideator, ui} and carries a
|
|
6
|
+
// last-resort `UPDATE ... SET discipline='engineer'` so a fresh install can never
|
|
7
|
+
// be blocked on a human remembering to run a script. But that net is a blunt
|
|
8
|
+
// guess. The real classification is classifyTaskDiscipline() — a JS heuristic over
|
|
9
|
+
// title + description + touches that SQL cannot express — so the good pass runs
|
|
10
|
+
// here, first, and the migration only catches what is left.
|
|
11
|
+
//
|
|
12
|
+
// Run this BEFORE deploying core_247. Order matters: rows this script resolves keep
|
|
13
|
+
// a reasoned craft; rows it does not are parked on 'engineer' by the migration.
|
|
14
|
+
//
|
|
15
|
+
// USAGE
|
|
16
|
+
// node scripts/gds/backfill-disciplines.js # dry run — the spot-check list
|
|
17
|
+
// node scripts/gds/backfill-disciplines.js --apply # commit the PATCHes
|
|
18
|
+
// node scripts/gds/backfill-disciplines.js --sample 40 # widen the printed sample
|
|
19
|
+
//
|
|
20
|
+
// The dry run is the default on purpose: the task required a spot-check list for the
|
|
21
|
+
// owner BEFORE anything was applied, and 1,189 rows is not a diff anyone reviews in
|
|
22
|
+
// their head.
|
|
23
|
+
|
|
24
|
+
'use strict';
|
|
25
|
+
|
|
26
|
+
const { cliClient, requireSession } = require('./cli-lib');
|
|
27
|
+
const { resolveWriteDiscipline, WRITABLE_DISCIPLINES } = require('../../modules/lifecycle/task-classifier.js');
|
|
28
|
+
|
|
29
|
+
// Rows whose title marks them as test debris. The classifier already skips these
|
|
30
|
+
// (they are not real work), but they still carry 'unclassified' and therefore still
|
|
31
|
+
// violate the narrowed constraint — so they must be resolved, not skipped, or the
|
|
32
|
+
// migration's net silently becomes the thing that handles 164 rows.
|
|
33
|
+
const SMOKE_RE = /__smoke__/;
|
|
34
|
+
|
|
35
|
+
// The fallback for anything the heuristic refuses. Stated as a constant with its
|
|
36
|
+
// reasoning rather than inlined, because this value is a GUESS and every report
|
|
37
|
+
// below labels it as one.
|
|
38
|
+
const FALLBACK = 'engineer';
|
|
39
|
+
|
|
40
|
+
function classify(task) {
|
|
41
|
+
const resolved = resolveWriteDiscipline({
|
|
42
|
+
title: task.title, description: task.description, touches: task.touches,
|
|
43
|
+
});
|
|
44
|
+
if (resolved.discipline) {
|
|
45
|
+
return { discipline: resolved.discipline, reasoned: true, confidence: resolved.confidence ?? null };
|
|
46
|
+
}
|
|
47
|
+
return { discipline: FALLBACK, reasoned: false, confidence: null };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
51
|
+
|
|
52
|
+
// PATCH one row, WAITING OUT the per-session rate limit rather than counting it as a
|
|
53
|
+
// failure. The first run of this backfill treated a 429 as a failed row and stopped
|
|
54
|
+
// having written 120 of 1,189 — the limiter was doing its job and the script was
|
|
55
|
+
// reading "slow down" as "no". The server tells us how long to wait; honour it.
|
|
56
|
+
// Re-running is safe either way: the query only returns rows still unclassified.
|
|
57
|
+
async function patchWithBackoff(api, id, discipline, maxAttempts = 6) {
|
|
58
|
+
for (let attempt = 1; ; attempt += 1) {
|
|
59
|
+
const res = await api.tasks.patchTasksId({ id, body: { discipline } });
|
|
60
|
+
if (res.status !== 429 || attempt >= maxAttempts) return res;
|
|
61
|
+
const retryAfter = Number(res.data?.error?.details?.retry_after_seconds);
|
|
62
|
+
const waitMs = (Number.isFinite(retryAfter) && retryAfter > 0 ? retryAfter : 2 ** attempt) * 1000;
|
|
63
|
+
console.log(` rate limited — waiting ${Math.round(waitMs / 1000)}s (attempt ${attempt}/${maxAttempts - 1})`);
|
|
64
|
+
await sleep(waitMs + 500);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function main() {
|
|
69
|
+
const args = process.argv.slice(2);
|
|
70
|
+
const apply = args.includes('--apply');
|
|
71
|
+
const sampleIdx = args.indexOf('--sample');
|
|
72
|
+
const sampleSize = sampleIdx >= 0 ? Number(args[sampleIdx + 1]) || 20 : 20;
|
|
73
|
+
|
|
74
|
+
await requireSession();
|
|
75
|
+
const api = await cliClient();
|
|
76
|
+
|
|
77
|
+
const r = await api.tasks.getTasks({ query: { discipline: 'unclassified' } });
|
|
78
|
+
if (!r.ok) {
|
|
79
|
+
console.error(`failed to read tasks (${r.status}):`, r.data);
|
|
80
|
+
process.exit(1);
|
|
81
|
+
}
|
|
82
|
+
const tasks = r.data?.tasks || [];
|
|
83
|
+
if (!tasks.length) {
|
|
84
|
+
console.log('Nothing to do — no task carries discipline=unclassified.');
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const plan = tasks.map((t) => ({ task: t, ...classify(t), smoke: SMOKE_RE.test(t.title || '') }));
|
|
89
|
+
const reasoned = plan.filter((p) => p.reasoned);
|
|
90
|
+
const guessed = plan.filter((p) => !p.reasoned);
|
|
91
|
+
const byDiscipline = {};
|
|
92
|
+
for (const p of plan) byDiscipline[p.discipline] = (byDiscipline[p.discipline] || 0) + 1;
|
|
93
|
+
|
|
94
|
+
console.log(`\n${apply ? 'Applying' : 'DRY RUN —'} discipline backfill over ${plan.length} role-less task(s)\n`);
|
|
95
|
+
console.log(' Resolved by the classifier (a reasoned answer):');
|
|
96
|
+
for (const [d, n] of Object.entries(byDiscipline).sort((a, b) => b[1] - a[1])) {
|
|
97
|
+
const reasonedN = reasoned.filter((p) => p.discipline === d).length;
|
|
98
|
+
console.log(` ${d.padEnd(12)} ${String(reasonedN).padStart(5)}`);
|
|
99
|
+
}
|
|
100
|
+
console.log(`\n BEST GUESS (classifier refused → ${FALLBACK}): ${guessed.length}`);
|
|
101
|
+
console.log(` of which __smoke__ test debris: ${guessed.filter((p) => p.smoke).length}`);
|
|
102
|
+
console.log(' These are NOT determined answers. A later reader must not treat');
|
|
103
|
+
console.log(' them as evidence that anyone classified the row (owner decision 2026-09-17).\n');
|
|
104
|
+
|
|
105
|
+
console.log(` Spot-check sample — reasoned (first ${sampleSize}):`);
|
|
106
|
+
for (const p of reasoned.slice(0, sampleSize)) {
|
|
107
|
+
console.log(` ${p.discipline.padEnd(9)} ${p.confidence != null ? `(${p.confidence.toFixed(2)})` : ' '} ${String(p.task.title).slice(0, 72)}`);
|
|
108
|
+
}
|
|
109
|
+
console.log(`\n Spot-check sample — GUESSED (first ${sampleSize}):`);
|
|
110
|
+
for (const p of guessed.filter((x) => !x.smoke).slice(0, sampleSize)) {
|
|
111
|
+
console.log(` ${p.discipline.padEnd(9)} ${String(p.task.title).slice(0, 72)}`);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (!apply) {
|
|
115
|
+
console.log(`\nDry run — nothing was written. Re-run with --apply to commit.\n`);
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
let ok = 0;
|
|
120
|
+
const failures = [];
|
|
121
|
+
for (const p of plan) {
|
|
122
|
+
const res = await patchWithBackoff(api, p.task.id, p.discipline);
|
|
123
|
+
if (res.ok) ok += 1;
|
|
124
|
+
else failures.push({ id: p.task.id, status: res.status, detail: JSON.stringify(res.data).slice(0, 160) });
|
|
125
|
+
if (ok % 100 === 0 && res.ok) console.log(` … ${ok}/${plan.length}`);
|
|
126
|
+
}
|
|
127
|
+
console.log(`\nApplied: ${ok}/${plan.length}`);
|
|
128
|
+
if (failures.length) {
|
|
129
|
+
console.log(`Failed: ${failures.length}`);
|
|
130
|
+
for (const f of failures.slice(0, 20)) console.log(` #${f.id} → ${f.status} ${f.detail}`);
|
|
131
|
+
process.exitCode = 1;
|
|
132
|
+
}
|
|
133
|
+
// Report the residue against the same query, so the number quoted afterwards is
|
|
134
|
+
// measured rather than assumed.
|
|
135
|
+
const after = await api.tasks.getTasks({ query: { discipline: 'unclassified' } });
|
|
136
|
+
const left = after.ok ? (after.data?.tasks || []).length : null;
|
|
137
|
+
console.log(`Remaining unclassified: ${left == null ? '(re-read failed)' : left}`);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
if (require.main === module) {
|
|
141
|
+
main().catch((e) => { console.error(e && e.message ? e.message : e); process.exit(1); });
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
module.exports = { classify, FALLBACK, SMOKE_RE };
|
|
@@ -197,14 +197,65 @@ function docMetrics() {
|
|
|
197
197
|
|
|
198
198
|
// ---------- knip (CI-only, fail-open on infra) ----------
|
|
199
199
|
|
|
200
|
+
// WHY A LOCAL knip IS REQUIRED BEFORE SCORING (task 1003987).
|
|
201
|
+
//
|
|
202
|
+
// `npx --no-install` does not only look in THIS project: it walks up the directory
|
|
203
|
+
// tree. A git worktree has no node_modules of its own, so the lookup climbs out and
|
|
204
|
+
// finds the main checkout's copy — the command succeeds, and the number it produces is
|
|
205
|
+
// wrong in a specific, repeatable way.
|
|
206
|
+
//
|
|
207
|
+
// MEASURED, not inferred, on 2026-09-17 against one tree:
|
|
208
|
+
// main checkout (knip local) -> 221
|
|
209
|
+
// worktree (knip ambient) -> 222, the extra being `binaries: package.json knip`
|
|
210
|
+
// Diffed by name, the exports lists were IDENTICAL (221 both sides). knip was
|
|
211
|
+
// reporting ITSELF as an unlisted binary, because from the worktree's point of view it
|
|
212
|
+
// is not a declared dependency of anything resolvable.
|
|
213
|
+
//
|
|
214
|
+
// So the ratchet failed CLOSED on a phantom: 222 > baseline 221, on a tree whose real
|
|
215
|
+
// findings had not moved. That cost a full elimination pass on task 1003848, where the
|
|
216
|
+
// count was the loudest signal pointing away from the actual defect.
|
|
217
|
+
//
|
|
218
|
+
// This module's contract is fail-OPEN on infrastructure and fail-CLOSED on findings.
|
|
219
|
+
// A count measured through an ambient knip is an infrastructure artefact, not a
|
|
220
|
+
// finding, so it must not be scored at all. CI is unaffected: `npm ci` puts knip in
|
|
221
|
+
// the project's own node_modules, which is exactly what this checks for.
|
|
222
|
+
//
|
|
223
|
+
// The row also offered "compare findings by NAME" as the fuller cure. That is a
|
|
224
|
+
// baseline-format change (one integer today) and is deliberately NOT done here; naming
|
|
225
|
+
// the unresolvable case is the "at minimum" it asked for, and it removes the false
|
|
226
|
+
// failure without pretending the count is now environment-proof.
|
|
227
|
+
function localKnipPath() {
|
|
228
|
+
for (const rel of ['node_modules/knip/package.json', 'node_modules/.bin/knip']) {
|
|
229
|
+
const p = path.join(ROOT, rel);
|
|
230
|
+
try { if (fs.existsSync(p)) return p; } catch { /* unreadable → treat as absent */ }
|
|
231
|
+
}
|
|
232
|
+
return null;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// Why the last knip score was skipped, or null when it was scored. Module-scoped so
|
|
236
|
+
// the caller can NAME the cause instead of printing one generic line for every
|
|
237
|
+
// unrelated reason knip might not run.
|
|
238
|
+
let knipSkipReason = null;
|
|
239
|
+
function lastKnipSkipReason() { return knipSkipReason; }
|
|
240
|
+
|
|
200
241
|
function knipIssueCount() {
|
|
242
|
+
knipSkipReason = null;
|
|
243
|
+
if (!localKnipPath()) {
|
|
244
|
+
// Not "knip is missing" — an ambient one may well run. The point is that its
|
|
245
|
+
// answer is not comparable to the baseline, which was measured with a local one.
|
|
246
|
+
knipSkipReason = `no knip in ${path.join(ROOT, 'node_modules')} — `
|
|
247
|
+
+ 'npx would resolve one from outside this tree and report it as an unlisted '
|
|
248
|
+
+ 'binary, scoring one higher than the same code scores in CI (task 1003987)';
|
|
249
|
+
return null;
|
|
250
|
+
}
|
|
201
251
|
let raw;
|
|
202
252
|
try {
|
|
203
253
|
raw = execSync('npx --no-install knip --reporter json --no-exit-code', {
|
|
204
254
|
cwd: ROOT, encoding: 'utf8', maxBuffer: 32 * 1024 * 1024, stdio: ['ignore', 'pipe', 'pipe'],
|
|
205
255
|
});
|
|
206
256
|
} catch (e) {
|
|
207
|
-
if (e && e.stdout) raw = e.stdout.toString();
|
|
257
|
+
if (e && e.stdout) { raw = e.stdout.toString(); }
|
|
258
|
+
else { knipSkipReason = `npx knip failed to run in ${ROOT}`; return null; }
|
|
208
259
|
}
|
|
209
260
|
try {
|
|
210
261
|
const j = JSON.parse(raw);
|
|
@@ -219,6 +270,7 @@ function knipIssueCount() {
|
|
|
219
270
|
}
|
|
220
271
|
return files + items;
|
|
221
272
|
} catch {
|
|
273
|
+
knipSkipReason = 'knip produced output this script could not parse as JSON';
|
|
222
274
|
return null;
|
|
223
275
|
}
|
|
224
276
|
}
|
|
@@ -426,7 +478,10 @@ function main() {
|
|
|
426
478
|
if (knipOnly) {
|
|
427
479
|
const value = knipIssueCount();
|
|
428
480
|
if (value === null) {
|
|
429
|
-
|
|
481
|
+
// Name WHY (task 1003987). "not runnable here" sent readers looking for a
|
|
482
|
+
// broken install when the real answer was that the number would have been
|
|
483
|
+
// wrong — a different problem with a different fix.
|
|
484
|
+
console.warn(`fitness-ratchets: WARN — knip not scored: ${lastKnipSkipReason() || 'knip unavailable'}; failing OPEN.`);
|
|
430
485
|
process.exit(0);
|
|
431
486
|
}
|
|
432
487
|
const { metrics: knipBaselines, error: knipBaselineError } = readBaselines();
|
|
@@ -466,6 +521,8 @@ module.exports = {
|
|
|
466
521
|
consoleCallCount,
|
|
467
522
|
isServerPath,
|
|
468
523
|
knipIssueCount,
|
|
524
|
+
localKnipPath,
|
|
525
|
+
lastKnipSkipReason,
|
|
469
526
|
OVERSIZE_LINES,
|
|
470
527
|
WINDOW,
|
|
471
528
|
BASELINE_PATH,
|
package/scripts/gds/init.js
CHANGED
|
@@ -763,7 +763,12 @@ async function seedViaApi(spec, { dryRun, log = console.log, api = apiCall } = {
|
|
|
763
763
|
// 2. starter task(s) — linked to the first goal so /status rolls them up
|
|
764
764
|
let created = 0;
|
|
765
765
|
for (const t of starters) {
|
|
766
|
-
|
|
766
|
+
// task 1002988: `discipline: undefined` used to sit in this extras object, and
|
|
767
|
+
// because `...extra` spreads AFTER postTask's own `t.discipline || 'engineer'`
|
|
768
|
+
// it clobbered that default back to nothing — which is why a freshly scaffolded
|
|
769
|
+
// instance was born with role-less starter tasks. Dropped, so starters inherit
|
|
770
|
+
// the same default every other seeded task already got.
|
|
771
|
+
if (await postTask(t, { kind: t.kind || 'unclassified', criterion_ids: [criterionId] })) created++;
|
|
767
772
|
}
|
|
768
773
|
// 3. kickoff steps as tracked tasks
|
|
769
774
|
for (const t of kickoff) {
|
package/scripts/gds/triage.js
CHANGED
|
@@ -59,6 +59,7 @@ const readline = require('node:readline');
|
|
|
59
59
|
const fs = require('node:fs');
|
|
60
60
|
const { cliClient, requireSession } = require('./cli-lib');
|
|
61
61
|
const { resolveDefaultVersion } = require('./version-select.js');
|
|
62
|
+
const { resolveWriteDiscipline } = require('../../modules/lifecycle/task-classifier.js');
|
|
62
63
|
|
|
63
64
|
function prompt(rl, question) {
|
|
64
65
|
return new Promise((resolve) => rl.question(question, (a) => resolve(a)));
|
|
@@ -276,6 +277,12 @@ async function createTask({ versionId, title, description, priority, sourceRef,
|
|
|
276
277
|
priority: priority ?? null,
|
|
277
278
|
source: 'idea_inbox',
|
|
278
279
|
source_ref: sourceRef ?? null,
|
|
280
|
+
// task 1002988: the server now refuses a task whose craft it cannot infer, and
|
|
281
|
+
// a 400 in the middle of an interactive triage loop would cost the operator the
|
|
282
|
+
// promotion they just decided on. So resolve it here with the SAME function the
|
|
283
|
+
// server uses, and name a fallback rather than letting the loop break — the
|
|
284
|
+
// identical trade modules/ideas/inbox.js makes for capture.
|
|
285
|
+
discipline: resolveWriteDiscipline({ title, description }).discipline || 'engineer',
|
|
279
286
|
};
|
|
280
287
|
if (goalId == null) {
|
|
281
288
|
throw new Error('createTask: goal_id is required — every task belongs to a goal (task 1003598 deleted the catch-all fallback).');
|
package/src/module-api.js
CHANGED
|
@@ -71,7 +71,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
|
|
|
71
71
|
// there. scripts/gds/bump-version.js still rewrites the literal below; it appends
|
|
72
72
|
// the entry to that file. Look for a version's history there, not here.
|
|
73
73
|
// ---------------------------------------------------------------------------
|
|
74
|
-
const CORE_VERSION = '1.19.
|
|
74
|
+
const CORE_VERSION = '1.19.843'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
|
|
75
75
|
|
|
76
76
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
77
77
|
// Usage: const log = api.logger('dev-box'); log.info('mounted');
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
// tests/knip_ratchet_ambient.mjs — the knip ratchet refuses to score through an
|
|
2
|
+
// ambient knip, instead of failing CLOSED on a number it cannot compare (task 1003987).
|
|
3
|
+
//
|
|
4
|
+
// THE DEFECT. `npx --no-install` walks UP the directory tree. A git worktree has no
|
|
5
|
+
// node_modules of its own, so the lookup climbs out to the main checkout and succeeds —
|
|
6
|
+
// and knip, unable to resolve itself as a declared dependency of the tree it is
|
|
7
|
+
// scanning, reports ITSELF as an unlisted binary.
|
|
8
|
+
//
|
|
9
|
+
// MEASURED on one tree, 2026-09-17:
|
|
10
|
+
// main checkout (knip local) -> 221, PASS
|
|
11
|
+
// worktree (knip ambient) -> 222, FAIL against baseline 221
|
|
12
|
+
// The extra was `binaries: package.json knip`. Diffed BY NAME the exports lists were
|
|
13
|
+
// identical — 221 both sides. Nothing about the code had moved.
|
|
14
|
+
//
|
|
15
|
+
// That false red cost a full elimination pass on task 1003848: the count was the
|
|
16
|
+
// loudest signal on the board and it pointed away from the real defect.
|
|
17
|
+
//
|
|
18
|
+
// The module's contract is fail-OPEN on infrastructure, fail-CLOSED on findings. A
|
|
19
|
+
// count taken through an ambient knip is an infrastructure artefact, so it must not be
|
|
20
|
+
// scored. CI is unaffected — `npm ci` puts knip in the project's own node_modules.
|
|
21
|
+
//
|
|
22
|
+
// Run: node --test tests/knip_ratchet_ambient.mjs
|
|
23
|
+
|
|
24
|
+
import { test } from 'node:test';
|
|
25
|
+
import assert from 'node:assert/strict';
|
|
26
|
+
import fs from 'node:fs';
|
|
27
|
+
import os from 'node:os';
|
|
28
|
+
import path from 'node:path';
|
|
29
|
+
import { readFileSync } from 'node:fs';
|
|
30
|
+
import { fileURLToPath } from 'node:url';
|
|
31
|
+
import { createRequire } from 'node:module';
|
|
32
|
+
|
|
33
|
+
const require = createRequire(import.meta.url);
|
|
34
|
+
const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
35
|
+
const ratchets = require('../scripts/gds/fitness-ratchets.js');
|
|
36
|
+
const SRC = readFileSync(path.join(REPO_ROOT, 'scripts/gds/fitness-ratchets.js'), 'utf8');
|
|
37
|
+
|
|
38
|
+
test('the local-knip check runs BEFORE npx, so an ambient knip is never scored', () => {
|
|
39
|
+
const fn = SRC.slice(SRC.indexOf('function knipIssueCount()'));
|
|
40
|
+
const body = fn.slice(0, fn.indexOf('\n}\n'));
|
|
41
|
+
const guard = body.indexOf('localKnipPath()');
|
|
42
|
+
const spawn = body.indexOf('execSync(');
|
|
43
|
+
assert.ok(guard > 0 && spawn > 0, 'both the guard and the spawn must be present');
|
|
44
|
+
assert.ok(guard < spawn,
|
|
45
|
+
'checking after spawning would score the ambient count first — the whole bug');
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test('it returns null (skip) rather than a number when knip is not local', () => {
|
|
49
|
+
// The fail direction matters: a number here is compared against a baseline measured
|
|
50
|
+
// with a LOCAL knip, so returning one is worse than returning nothing.
|
|
51
|
+
const fn = SRC.slice(SRC.indexOf('function knipIssueCount()'));
|
|
52
|
+
const body = fn.slice(0, fn.indexOf('\n}\n'));
|
|
53
|
+
assert.match(body, /if \(!localKnipPath\(\)\)[\s\S]{0,600}return null;/,
|
|
54
|
+
'the unresolvable case must skip, not score');
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test('the skip NAMES its cause — a generic line sent readers hunting a broken install', () => {
|
|
58
|
+
assert.equal(typeof ratchets.lastKnipSkipReason, 'function');
|
|
59
|
+
ratchets.knipIssueCount();
|
|
60
|
+
const reason = ratchets.lastKnipSkipReason();
|
|
61
|
+
// In this repo the test runs where knip IS local (CI, main checkout), so the reason
|
|
62
|
+
// is null; where it is NOT local the reason must be specific. Assert whichever case
|
|
63
|
+
// this machine is in, so the test is meaningful on both.
|
|
64
|
+
if (ratchets.localKnipPath()) {
|
|
65
|
+
assert.equal(reason, null, 'a scored run must not claim it was skipped');
|
|
66
|
+
} else {
|
|
67
|
+
assert.ok(reason && /knip/i.test(reason), 'the skip must say what was missing');
|
|
68
|
+
assert.match(reason, /node_modules/, 'and where it looked');
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test('localKnipPath finds a knip that IS local, and reports none when it is not', () => {
|
|
73
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'knipprobe-'));
|
|
74
|
+
try {
|
|
75
|
+
// The predicate is about THIS repo's root, so prove it against the real answer
|
|
76
|
+
// rather than a reimplementation: it must agree with the filesystem.
|
|
77
|
+
const expected = ['node_modules/knip/package.json', 'node_modules/.bin/knip']
|
|
78
|
+
.some((rel) => fs.existsSync(path.join(REPO_ROOT, rel)));
|
|
79
|
+
assert.equal(!!ratchets.localKnipPath(), expected,
|
|
80
|
+
'localKnipPath must agree with what is actually on disk');
|
|
81
|
+
} finally {
|
|
82
|
+
fs.rmSync(tmp, { recursive: true, force: true });
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test('the caller fails OPEN on a skip — an unscorable metric is not a violation', () => {
|
|
87
|
+
assert.match(SRC, /knip not scored: \$\{lastKnipSkipReason\(\)/,
|
|
88
|
+
'the warning must carry the specific reason');
|
|
89
|
+
const warnIdx = SRC.indexOf('knip not scored:');
|
|
90
|
+
const after = SRC.slice(warnIdx, warnIdx + 300);
|
|
91
|
+
assert.match(after, /process\.exit\(0\)/,
|
|
92
|
+
'a skip must exit 0 — failing closed here is what this task removed');
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test('a REAL regression still fails closed — the fix narrows nothing', () => {
|
|
96
|
+
// Guard against "fixed" by making the whole metric advisory.
|
|
97
|
+
const knipOnly = SRC.slice(SRC.indexOf('if (knipOnly)'));
|
|
98
|
+
const block = knipOnly.slice(0, knipOnly.indexOf('return;'));
|
|
99
|
+
assert.match(block, /violations\.length/, 'violations must still be evaluated');
|
|
100
|
+
assert.match(block, /process\.exit\(1\)/, 'and still fail the gate when present');
|
|
101
|
+
});
|
|
@@ -52,6 +52,10 @@ async function createdFloor({ scopeModules, needsMigration = false, requiresRank
|
|
|
52
52
|
await createTask({
|
|
53
53
|
versionId: 'BONGOS-V2',
|
|
54
54
|
title: 'a task',
|
|
55
|
+
// Stated rather than inferred (task 1002988): createTask now refuses a craft it
|
|
56
|
+
// cannot work out, and "a task" says nothing. This fixture is about the RANK
|
|
57
|
+
// FLOOR, so it should not be at the mercy of the discipline heuristic.
|
|
58
|
+
discipline: 'engineer',
|
|
55
59
|
goalId: '1000111',
|
|
56
60
|
needsMigration,
|
|
57
61
|
requiresRank,
|
package/tests/reward_gate.mjs
CHANGED
|
@@ -210,7 +210,7 @@ async function withStubbedCreatePool(run) {
|
|
|
210
210
|
|
|
211
211
|
t('a task CREATED with NO reward is born with a positive, capped value', async () => {
|
|
212
212
|
await withStubbedCreatePool(async (captured) => {
|
|
213
|
-
const row = await db.createTask({ versionId: 1, goalId: 77, title: 't', kind: 'feature', estMinutes: 40 });
|
|
213
|
+
const row = await db.createTask({ versionId: 1, goalId: 77, title: 't', discipline: 'engineer', kind: 'feature', estMinutes: 40 });
|
|
214
214
|
assert.ok(captured.creditsReward > 0, 'INSERT must bind a positive credits_reward');
|
|
215
215
|
assert.equal(captured.creditsReward, 40, 'feature @40min -> 40 drachmae');
|
|
216
216
|
assert.ok(captured.creditsReward <= db.REWARD_SUGGESTION_CAP, 'must respect the cap');
|
|
@@ -220,7 +220,7 @@ t('a task CREATED with NO reward is born with a positive, capped value', async (
|
|
|
220
220
|
|
|
221
221
|
t('a task CREATED with credits_reward 0 (the route default) is auto-assigned', async () => {
|
|
222
222
|
await withStubbedCreatePool(async (captured) => {
|
|
223
|
-
await db.createTask({ versionId: 1, goalId: 77, title: 't', creditsReward: 0, kind: 'bug', estMinutes: 40 });
|
|
223
|
+
await db.createTask({ versionId: 1, goalId: 77, title: 't', discipline: 'engineer', creditsReward: 0, kind: 'bug', estMinutes: 40 });
|
|
224
224
|
// bug weight 1.25 -> 50; 0 is treated as "unset" so the default kicks in.
|
|
225
225
|
assert.equal(captured.creditsReward, 50);
|
|
226
226
|
});
|
|
@@ -228,14 +228,14 @@ t('a task CREATED with credits_reward 0 (the route default) is auto-assigned', a
|
|
|
228
228
|
|
|
229
229
|
t('a huge-est new task is CAPPED at creation, not ballooned', async () => {
|
|
230
230
|
await withStubbedCreatePool(async (captured) => {
|
|
231
|
-
await db.createTask({ versionId: 1, goalId: 77, title: 't', kind: 'feature', estMinutes: 9999 });
|
|
231
|
+
await db.createTask({ versionId: 1, goalId: 77, title: 't', discipline: 'engineer', kind: 'feature', estMinutes: 9999 });
|
|
232
232
|
assert.equal(captured.creditsReward, db.REWARD_SUGGESTION_CAP);
|
|
233
233
|
});
|
|
234
234
|
});
|
|
235
235
|
|
|
236
236
|
t('an EXPLICIT positive credits_reward is PRESERVED (never overridden)', async () => {
|
|
237
237
|
await withStubbedCreatePool(async (captured) => {
|
|
238
|
-
await db.createTask({ versionId: 1, goalId: 77, title: 't', creditsReward: 17, kind: 'feature', estMinutes: 40 });
|
|
238
|
+
await db.createTask({ versionId: 1, goalId: 77, title: 't', discipline: 'engineer', creditsReward: 17, kind: 'feature', estMinutes: 40 });
|
|
239
239
|
assert.equal(captured.creditsReward, 17, 'author-set positive value must survive untouched');
|
|
240
240
|
});
|
|
241
241
|
});
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
// tests/task_discipline_required.mjs — task 1002988: every task row carries a craft.
|
|
2
|
+
//
|
|
3
|
+
// The thing under test is mostly an ABSENCE: that 'unclassified' can no longer be
|
|
4
|
+
// written, by any vector, and that the DB constraint no longer accepts it. A suite
|
|
5
|
+
// that only proved the four valid values work would have passed before this task and
|
|
6
|
+
// will pass again once the guard rots, so each block below pins the refusal.
|
|
7
|
+
import assert from 'node:assert/strict';
|
|
8
|
+
import { test } from 'node:test';
|
|
9
|
+
import { createRequire } from 'node:module';
|
|
10
|
+
import fs from 'node:fs';
|
|
11
|
+
import path from 'node:path';
|
|
12
|
+
import { fileURLToPath } from 'node:url';
|
|
13
|
+
|
|
14
|
+
const require = createRequire(import.meta.url);
|
|
15
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
16
|
+
const classifier = require(path.join(ROOT, 'modules', 'lifecycle', 'task-classifier.js'));
|
|
17
|
+
const { resolveWriteDiscipline, WRITABLE_DISCIPLINES, VALID_DISCIPLINES } = classifier;
|
|
18
|
+
|
|
19
|
+
// --- the writable set -------------------------------------------------------
|
|
20
|
+
|
|
21
|
+
test('the writable set is the four crafts — ui included, unclassified excluded', () => {
|
|
22
|
+
// FOUR, not three. The task was written 2026-08-16 naming three; ADR 0272
|
|
23
|
+
// (2026-09-09) keeps `ui` as a module-contributed discipline and the owner
|
|
24
|
+
// reconfirmed it on 2026-09-17 when abandoning task 1002987. Narrowing to three
|
|
25
|
+
// would break live ui rows — the backfill found 3 of them by classification alone.
|
|
26
|
+
assert.deepEqual([...WRITABLE_DISCIPLINES].sort(), ['artist', 'engineer', 'ideator', 'ui']);
|
|
27
|
+
assert.ok(!WRITABLE_DISCIPLINES.has('unclassified'), 'unclassified is a triage state, not a craft');
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test('the READ set still carries unclassified, so old rows and filters resolve', () => {
|
|
31
|
+
// The two sets are deliberately different. Collapsing them would break the
|
|
32
|
+
// ?discipline=unclassified query the backfill itself reads through.
|
|
33
|
+
assert.ok(VALID_DISCIPLINES.has('unclassified'));
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
// --- resolveWriteDiscipline -------------------------------------------------
|
|
37
|
+
|
|
38
|
+
test('an explicit valid discipline is honoured verbatim', () => {
|
|
39
|
+
for (const d of WRITABLE_DISCIPLINES) {
|
|
40
|
+
assert.deepEqual(resolveWriteDiscipline({ discipline: d, title: 'anything' }), { discipline: d });
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test('an omitted discipline is INFERRED, and reports that it was', () => {
|
|
45
|
+
const r = resolveWriteDiscipline({ title: 'Refactor the database migration script' });
|
|
46
|
+
assert.equal(r.discipline, 'engineer');
|
|
47
|
+
assert.equal(r.inferred, true, 'the caller must be able to tell a guess from a statement');
|
|
48
|
+
assert.ok(r.confidence >= 0.5);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test('an explicit "unclassified" is treated as no answer at all, not as a value', () => {
|
|
52
|
+
// modules/ideas/inbox.js sent this literal string on every promotion. If it were
|
|
53
|
+
// honoured as a value, the create path would still mint the rows this task removes.
|
|
54
|
+
const r = resolveWriteDiscipline({ discipline: 'unclassified', title: 'Refactor the database migration script' });
|
|
55
|
+
assert.equal(r.discipline, 'engineer');
|
|
56
|
+
assert.equal(r.inferred, true);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test('an unclassifiable task is REFUSED, not filed under a filler value', () => {
|
|
60
|
+
const r = resolveWriteDiscipline({ title: 'asdf' });
|
|
61
|
+
assert.equal(r.discipline, undefined);
|
|
62
|
+
assert.equal(r.error, 'bad_discipline');
|
|
63
|
+
assert.equal(r.unclassifiable, true, 'the caller must be able to say WHY, to write a useful message');
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test('a bogus discipline is refused, and is NOT reported as unclassifiable', () => {
|
|
67
|
+
const r = resolveWriteDiscipline({ discipline: 'wizard', title: 'Refactor the database migration script' });
|
|
68
|
+
assert.equal(r.error, 'bad_discipline');
|
|
69
|
+
assert.ok(!r.unclassifiable, 'the text was fine — the caller named a craft that does not exist');
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
// --- createTask: the single chokepoint --------------------------------------
|
|
73
|
+
// Every create vector passes through here, including the ones that never touch an
|
|
74
|
+
// HTTP route (idea promotion, copy-desk, newcomer-restock, kickoff-seed, cascade).
|
|
75
|
+
// Fixing only the routes would have left those minting role-less rows.
|
|
76
|
+
|
|
77
|
+
function recordingPool(captured) {
|
|
78
|
+
return {
|
|
79
|
+
query: async (text, params) => {
|
|
80
|
+
if (/^\s*INSERT INTO tasks/i.test(text)) {
|
|
81
|
+
captured.push({ text, params });
|
|
82
|
+
return { rows: [{ id: 1, discipline: params[params.findIndex(() => false)] }] };
|
|
83
|
+
}
|
|
84
|
+
return { rows: [{ id: 1 }] };
|
|
85
|
+
},
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
test('createTask REFUSES a task whose craft it cannot infer', async () => {
|
|
90
|
+
process.env.NODE_ENV = 'test';
|
|
91
|
+
const { createTask } = require(path.join(ROOT, 'modules', 'lifecycle', 'db-tasks.js'));
|
|
92
|
+
const captured = [];
|
|
93
|
+
await assert.rejects(
|
|
94
|
+
() => createTask({ versionId: 'V1', title: 'asdf', goalId: 1 }, { pool: recordingPool(captured) }),
|
|
95
|
+
(e) => e.code === 'BAD_DISCIPLINE' && e.unclassifiable === true,
|
|
96
|
+
'an unclassifiable create must throw, not default',
|
|
97
|
+
);
|
|
98
|
+
assert.equal(captured.length, 0, 'nothing may be inserted when the craft is unknown');
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test('createTask no longer has an "unclassified" default to fall back on', () => {
|
|
102
|
+
// The default was the whole mechanism: 1,189 rows accumulated because it was quiet.
|
|
103
|
+
// Pinned as source text because the behavioural test above can only prove the
|
|
104
|
+
// refusal, not that the default is gone rather than merely unreachable.
|
|
105
|
+
const src = fs.readFileSync(path.join(ROOT, 'modules', 'lifecycle', 'db-tasks.js'), 'utf8');
|
|
106
|
+
assert.ok(!/^\s*discipline = 'unclassified',\s*$/m.test(src),
|
|
107
|
+
"createTask must not default discipline to 'unclassified'");
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
// --- the migration: the absence is the thing under test ---------------------
|
|
111
|
+
|
|
112
|
+
test('core_247 narrows the CHECK to the four crafts and REJECTS unclassified', () => {
|
|
113
|
+
const sql = fs.readFileSync(path.join(ROOT, 'migrations', 'core_247_discipline_required.sql'), 'utf8');
|
|
114
|
+
const m = sql.match(/CHECK \(discipline IN \(([^)]*)\)\)/);
|
|
115
|
+
assert.ok(m, 'the migration must re-add a discipline CHECK constraint');
|
|
116
|
+
const accepted = m[1].split(',').map((s) => s.trim().replace(/'/g, '')).sort();
|
|
117
|
+
assert.deepEqual(accepted, ['artist', 'engineer', 'ideator', 'ui'],
|
|
118
|
+
'the accepted set must match WRITABLE_DISCIPLINES exactly');
|
|
119
|
+
assert.ok(!accepted.includes('unclassified'), 'the whole point: the constraint rejects it');
|
|
120
|
+
// The net that makes the migration safe on a checkout where nobody ran the script.
|
|
121
|
+
assert.match(sql, /UPDATE tasks SET discipline = 'engineer'[\s\S]*?WHERE discipline = 'unclassified'/,
|
|
122
|
+
'the migration must resolve residual rows itself, not assume a script was run');
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test('the migration constraint and the code agree on the accepted set', () => {
|
|
126
|
+
// Two places state the vocabulary; a drift between them is a 500 at write time
|
|
127
|
+
// (code accepts a value the DB rejects) or dead validation (the reverse).
|
|
128
|
+
const sql = fs.readFileSync(path.join(ROOT, 'migrations', 'core_247_discipline_required.sql'), 'utf8');
|
|
129
|
+
const accepted = sql.match(/CHECK \(discipline IN \(([^)]*)\)\)/)[1]
|
|
130
|
+
.split(',').map((s) => s.trim().replace(/'/g, '')).sort();
|
|
131
|
+
assert.deepEqual(accepted, [...WRITABLE_DISCIPLINES].sort());
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
// --- the create vectors that must keep working ------------------------------
|
|
135
|
+
// Acceptance 6: prove it per path, not in aggregate.
|
|
136
|
+
|
|
137
|
+
test('every direct createTask call site names a discipline or resolves one', () => {
|
|
138
|
+
const sites = [
|
|
139
|
+
['modules/ideas/inbox.js', /discipline: \(lifecycle\.resolveWriteDiscipline/],
|
|
140
|
+
['modules/copy-desk/routes/copy-desk.js', /discipline: 'artist'/],
|
|
141
|
+
['modules/onboarding/newcomer-restock.js', /^\s*discipline,\s*$/m],
|
|
142
|
+
['modules/lifecycle/kickoff-seed.js', /discipline: t\.discipline \|\| 'engineer'/],
|
|
143
|
+
['modules/lifecycle/cascade.js', /discipline: rule\.discipline/],
|
|
144
|
+
['scripts/gds/triage.js', /discipline: resolveWriteDiscipline\(/],
|
|
145
|
+
];
|
|
146
|
+
for (const [rel, re] of sites) {
|
|
147
|
+
const src = fs.readFileSync(path.join(ROOT, rel), 'utf8');
|
|
148
|
+
assert.match(src, re, `${rel} must supply a discipline — createTask now throws without one`);
|
|
149
|
+
}
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test('init.js no longer clobbers its own discipline default with undefined', () => {
|
|
153
|
+
// The spread ran AFTER `t.discipline || 'engineer'`, so `discipline: undefined` in
|
|
154
|
+
// the extras object reset it to nothing — which is why a freshly scaffolded
|
|
155
|
+
// instance was born with role-less starter tasks.
|
|
156
|
+
// Comment lines are stripped first: the fix's own explanation names the old
|
|
157
|
+
// expression, and a scan that cannot tell code from prose would either fail here
|
|
158
|
+
// forever or be "fixed" by deleting the explanation.
|
|
159
|
+
const src = fs.readFileSync(path.join(ROOT, 'scripts', 'gds', 'init.js'), 'utf8')
|
|
160
|
+
.split('\n').filter((l) => !/^\s*(\/\/|\*|\/\*)/.test(l)).join('\n');
|
|
161
|
+
assert.ok(!/discipline: undefined/.test(src), 'a later spread must not undo the default');
|
|
162
|
+
assert.match(src, /discipline: t\.discipline \|\| 'engineer'/);
|
|
163
|
+
});
|
|
@@ -47,13 +47,13 @@ const permissivePool = {
|
|
|
47
47
|
|
|
48
48
|
test('a create with no goalId THROWS instead of quietly filing into a bucket', async () => {
|
|
49
49
|
await assert.rejects(
|
|
50
|
-
() => createTask({ versionId: 'BONGOS-V1', title: 't' }, { pool: permissivePool }),
|
|
50
|
+
() => createTask({ versionId: 'BONGOS-V1', title: 't', discipline: 'engineer' }, { pool: permissivePool }),
|
|
51
51
|
/goalId is required/,
|
|
52
52
|
'the old behaviour was to succeed and say nothing');
|
|
53
53
|
});
|
|
54
54
|
|
|
55
55
|
test('the error tells the caller both ways out', async () => {
|
|
56
|
-
const err = await createTask({ versionId: 'BONGOS-V1', title: 't' }, { pool: permissivePool })
|
|
56
|
+
const err = await createTask({ versionId: 'BONGOS-V1', title: 't', discipline: 'engineer' }, { pool: permissivePool })
|
|
57
57
|
.then(() => null, (e) => e);
|
|
58
58
|
assert.ok(err, 'must reject');
|
|
59
59
|
assert.match(err.message, /BV1\.R11/, 'names the rule, so the reason is findable');
|
|
@@ -63,7 +63,7 @@ test('the error tells the caller both ways out', async () => {
|
|
|
63
63
|
test('only a literal true opts in — a truthy value still throws', async () => {
|
|
64
64
|
for (const v of [1, 'yes', {}, 'true']) {
|
|
65
65
|
await assert.rejects(
|
|
66
|
-
() => createTask({ versionId: 'BONGOS-V1', title: 't', allowCatchAll: v }, { pool: permissivePool }),
|
|
66
|
+
() => createTask({ versionId: 'BONGOS-V1', title: 't', discipline: 'engineer', allowCatchAll: v }, { pool: permissivePool }),
|
|
67
67
|
/goalId is required/,
|
|
68
68
|
`${JSON.stringify(v)} must not open the hatch`);
|
|
69
69
|
}
|
|
@@ -73,7 +73,7 @@ test('naming a goal needs no flag at all', async () => {
|
|
|
73
73
|
// The ordinary path must stay ordinary: a caller with a goal never learns this
|
|
74
74
|
// parameter exists.
|
|
75
75
|
await createTask(
|
|
76
|
-
{ versionId: 'BONGOS-V1', title: 't', goalId: 77 },
|
|
76
|
+
{ versionId: 'BONGOS-V1', title: 't', discipline: 'engineer', goalId: 77 },
|
|
77
77
|
{ pool: permissivePool, client: permissivePool },
|
|
78
78
|
);
|
|
79
79
|
});
|