@bongos/core 1.19.798 → 1.19.800
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 +30 -20
- package/.claude/skills/planning-session/SKILL.md +10 -0
- package/docs/module-api-changelog.md +4 -0
- package/migrations/core_223_blocker_resolve_respects_other_open_blockers.sql +10 -0
- package/migrations/core_245_restore_open_blocker_clause.sql +97 -0
- package/modules/lifecycle/db-claims.js +11 -4
- package/modules/lifecycle/db-deps-criteria.js +21 -4
- package/modules/lifecycle/dead-deps.js +108 -11
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/claude-materialize.js +46 -4
- package/scripts/gds/upgrade.js +14 -2
- package/src/module-api.js +1 -1
- package/tests/dead_deps.mjs +154 -1
- package/tests/task_unblocked_open_blocker.mjs +123 -0
- package/tests/upgrade_persist_pin.mjs +126 -0
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.800",
|
|
6
|
+
"core_contract": "1.19.800",
|
|
7
|
+
"source_commit": "61c8980e6b9f6fb0cbd611f41520cbcae4780f11",
|
|
8
8
|
"source_ref": "HEAD",
|
|
9
|
-
"built_at": "2026-09-
|
|
9
|
+
"built_at": "2026-09-17T02:01:42.605Z",
|
|
10
10
|
"redaction": {
|
|
11
11
|
"model": "docs-redacted+functional-verbatim",
|
|
12
12
|
"docs_redacted": 487,
|
|
13
13
|
"agent_docs_stubbed": 25,
|
|
14
|
-
"functional_verbatim":
|
|
14
|
+
"functional_verbatim": 2187,
|
|
15
15
|
"rules": 3,
|
|
16
16
|
"gate_literals": 3,
|
|
17
17
|
"gate": "passed"
|
|
18
18
|
},
|
|
19
|
-
"file_count":
|
|
20
|
-
"tree_sha256": "
|
|
19
|
+
"file_count": 2699,
|
|
20
|
+
"tree_sha256": "0d54a704f2ba5c26c517372981337570ff4a70da884edee06ef52c30e836fda3",
|
|
21
21
|
"files": [
|
|
22
22
|
{
|
|
23
23
|
"path": ".claude/skills/ask-for-help/SKILL.md",
|
|
@@ -207,7 +207,7 @@
|
|
|
207
207
|
{
|
|
208
208
|
"path": ".claude/skills/planning-session/SKILL.md",
|
|
209
209
|
"mode": "0000644",
|
|
210
|
-
"sha256": "
|
|
210
|
+
"sha256": "e05fd083784c0ff72eb54b6250dae4a58f464822f6b14347c524702b43a1f7ad"
|
|
211
211
|
},
|
|
212
212
|
{
|
|
213
213
|
"path": ".claude/skills/priority-session/SKILL.md",
|
|
@@ -2532,7 +2532,7 @@
|
|
|
2532
2532
|
{
|
|
2533
2533
|
"path": "docs/module-api-changelog.md",
|
|
2534
2534
|
"mode": "0000644",
|
|
2535
|
-
"sha256": "
|
|
2535
|
+
"sha256": "07a1c706c1ddc1d0c51da0737e861e8af59c0a50caa9119cfca43f1ad3fde6ba"
|
|
2536
2536
|
},
|
|
2537
2537
|
{
|
|
2538
2538
|
"path": "docs/modules-contract.md",
|
|
@@ -3507,7 +3507,7 @@
|
|
|
3507
3507
|
{
|
|
3508
3508
|
"path": "migrations/core_223_blocker_resolve_respects_other_open_blockers.sql",
|
|
3509
3509
|
"mode": "0000644",
|
|
3510
|
-
"sha256": "
|
|
3510
|
+
"sha256": "54eab3a0de0a3471649bd18a1cf3208e4857ef2d819811944843e89db3456ddf"
|
|
3511
3511
|
},
|
|
3512
3512
|
{
|
|
3513
3513
|
"path": "migrations/core_223_task_visual.sql",
|
|
@@ -3624,6 +3624,11 @@
|
|
|
3624
3624
|
"mode": "0000644",
|
|
3625
3625
|
"sha256": "e298a5b2e9ffade95fae0e68910a25396fab560e6e54dd745cfc122c0dfdf403"
|
|
3626
3626
|
},
|
|
3627
|
+
{
|
|
3628
|
+
"path": "migrations/core_245_restore_open_blocker_clause.sql",
|
|
3629
|
+
"mode": "0000644",
|
|
3630
|
+
"sha256": "b51a03436ede7269f32f6b1e9400070dcbe3895ab64088ca27da5406cbcf5eea"
|
|
3631
|
+
},
|
|
3627
3632
|
{
|
|
3628
3633
|
"path": "modules/agents/CLAUDE.md",
|
|
3629
3634
|
"mode": "0000644",
|
|
@@ -5507,12 +5512,12 @@
|
|
|
5507
5512
|
{
|
|
5508
5513
|
"path": "modules/lifecycle/db-claims.js",
|
|
5509
5514
|
"mode": "0000644",
|
|
5510
|
-
"sha256": "
|
|
5515
|
+
"sha256": "1c250de70282c19f66324f9203d0899eb874c8d59f14af940a004a235bf9d1b9"
|
|
5511
5516
|
},
|
|
5512
5517
|
{
|
|
5513
5518
|
"path": "modules/lifecycle/db-deps-criteria.js",
|
|
5514
5519
|
"mode": "0000644",
|
|
5515
|
-
"sha256": "
|
|
5520
|
+
"sha256": "76ffab3052ce0b48d0b78451a2e34d5cd3c5300410f0856b4a6667cbe708ba2b"
|
|
5516
5521
|
},
|
|
5517
5522
|
{
|
|
5518
5523
|
"path": "modules/lifecycle/db-goals.js",
|
|
@@ -5562,7 +5567,7 @@
|
|
|
5562
5567
|
{
|
|
5563
5568
|
"path": "modules/lifecycle/dead-deps.js",
|
|
5564
5569
|
"mode": "0000644",
|
|
5565
|
-
"sha256": "
|
|
5570
|
+
"sha256": "c9f7aa89a4b1e9a201189608ff8ebc4910ee6ca19be33ce681f2d32cedb0b39d"
|
|
5566
5571
|
},
|
|
5567
5572
|
{
|
|
5568
5573
|
"path": "modules/lifecycle/done-when.js",
|
|
@@ -7707,12 +7712,12 @@
|
|
|
7707
7712
|
{
|
|
7708
7713
|
"path": "package-lock.json",
|
|
7709
7714
|
"mode": "0000644",
|
|
7710
|
-
"sha256": "
|
|
7715
|
+
"sha256": "2fa689bfe81f60fd4b8ca8e0304b2eac417c172af397d23c1de3427e0a6544a5"
|
|
7711
7716
|
},
|
|
7712
7717
|
{
|
|
7713
7718
|
"path": "package.json",
|
|
7714
7719
|
"mode": "0000644",
|
|
7715
|
-
"sha256": "
|
|
7720
|
+
"sha256": "2e40419061babf39395a90a2b45c7c7409e1acce44e86984a3913dd6010a485a"
|
|
7716
7721
|
},
|
|
7717
7722
|
{
|
|
7718
7723
|
"path": "public-docs/index.html",
|
|
@@ -8002,7 +8007,7 @@
|
|
|
8002
8007
|
{
|
|
8003
8008
|
"path": "scripts/gds/claude-materialize.js",
|
|
8004
8009
|
"mode": "0000644",
|
|
8005
|
-
"sha256": "
|
|
8010
|
+
"sha256": "53411af3a058d5fb22029a2ae923ef259fb1a45b1e39c20fd28e004a8c4d369f"
|
|
8006
8011
|
},
|
|
8007
8012
|
{
|
|
8008
8013
|
"path": "scripts/gds/claude-md-template.js",
|
|
@@ -9157,7 +9162,7 @@
|
|
|
9157
9162
|
{
|
|
9158
9163
|
"path": "scripts/gds/upgrade.js",
|
|
9159
9164
|
"mode": "0000644",
|
|
9160
|
-
"sha256": "
|
|
9165
|
+
"sha256": "6fb938914f50a8273640ca2bad871b3a3077102e36877c861e344235338ebef2"
|
|
9161
9166
|
},
|
|
9162
9167
|
{
|
|
9163
9168
|
"path": "scripts/gds/validate-design.js",
|
|
@@ -9597,7 +9602,7 @@
|
|
|
9597
9602
|
{
|
|
9598
9603
|
"path": "src/module-api.js",
|
|
9599
9604
|
"mode": "0000644",
|
|
9600
|
-
"sha256": "
|
|
9605
|
+
"sha256": "0a1e5d3b5db82b4a63d3936fd70ff4e079a1eef0b81b8d7041fcb3f1aaffc109"
|
|
9601
9606
|
},
|
|
9602
9607
|
{
|
|
9603
9608
|
"path": "src/module-loader/catalog.js",
|
|
@@ -10497,7 +10502,7 @@
|
|
|
10497
10502
|
{
|
|
10498
10503
|
"path": "tests/dead_deps.mjs",
|
|
10499
10504
|
"mode": "0000644",
|
|
10500
|
-
"sha256": "
|
|
10505
|
+
"sha256": "65adddca46ac1ed3b4239e110a4018fd4b4ff113e1fbb1991742a7f4bcb28f16"
|
|
10501
10506
|
},
|
|
10502
10507
|
{
|
|
10503
10508
|
"path": "tests/dead_feature_claim.mjs",
|
|
@@ -13229,6 +13234,11 @@
|
|
|
13229
13234
|
"mode": "0000644",
|
|
13230
13235
|
"sha256": "1c5d35d58371cbde290a3e71e023febeebf350b54031d458c2ebe58f25962aa4"
|
|
13231
13236
|
},
|
|
13237
|
+
{
|
|
13238
|
+
"path": "tests/task_unblocked_open_blocker.mjs",
|
|
13239
|
+
"mode": "0000644",
|
|
13240
|
+
"sha256": "57a776dc56011bfe792a7a30ab29598bfc1b5b52f26ea0ef74faae5fbba26f6d"
|
|
13241
|
+
},
|
|
13232
13242
|
{
|
|
13233
13243
|
"path": "tests/task_visual_route_gate.mjs",
|
|
13234
13244
|
"mode": "0000644",
|
|
@@ -13352,7 +13362,7 @@
|
|
|
13352
13362
|
{
|
|
13353
13363
|
"path": "tests/upgrade_persist_pin.mjs",
|
|
13354
13364
|
"mode": "0000644",
|
|
13355
|
-
"sha256": "
|
|
13365
|
+
"sha256": "2e1fd181c580070c3625035d39d038c137dbf04c6c8e74132977b877c0d73c76"
|
|
13356
13366
|
},
|
|
13357
13367
|
{
|
|
13358
13368
|
"path": "tests/verify_task_guard.mjs",
|
|
@@ -89,6 +89,16 @@ This is where the user sees what they're shaping. Do not skip this — criteria
|
|
|
89
89
|
|
|
90
90
|
Surface the list to the user and get a per-goal carry/replace decision before writing criteria — the carry-forward goals shape this version's goal set.
|
|
91
91
|
|
|
92
|
+
5. **An `abandoned` task does NOT mean the work is missing — open it before you believe the status.** Whenever a prior goal you are about to depend on has abandoned tasks, read them. `abandoned` is overloaded across three situations a planning session has to treat differently:
|
|
93
|
+
|
|
94
|
+
- **dropped** — genuinely cut. A real gap; scope it again if it still matters.
|
|
95
|
+
- **superseded** — a different approach won. Maybe a gap; read what replaced it.
|
|
96
|
+
- **absorbed** — folded into a sibling task and shipped THERE. **Not a gap at all**, and putting an edge on it is actively wrong: it strands live work behind a prerequisite that already exists.
|
|
97
|
+
|
|
98
|
+
**The absorbed case has a marker you can check** — `merged_into_task_id`, which is what `POST /tasks/:id/merge` records (merge is "abandon, plus where it went"). It rides on the task row, on `blocked_by` entries from `GET /tasks?include=deps`, and on a claim refusal, which now names the absorbing task and tells you to RE-POINT the edge rather than restore anything (task 1003107). A row carrying that pointer is work that exists — go read the task it names.
|
|
99
|
+
|
|
100
|
+
**A row WITHOUT the pointer still needs its description read**, because the marker only exists from the day someone used merge instead of abandon-with-a-note, and the older rows are exactly the ones a rescope trips over. **Verify against the repo, not the row.** On 2026-08-16 a session read task 1003005 ("Rename modules/governance to modules/government") as `abandoned` and nearly put a false edge in a new goal's graph — the folder move had shipped under task 1003004, and only the prose said so. One `git log` over the paths the task named would have settled it.
|
|
101
|
+
|
|
92
102
|
### Phase 1.5 — Map the mechanism (process-change sessions only) — task 1003075
|
|
93
103
|
|
|
94
104
|
**Run this phase when the session is scoped to CHANGING how an existing mechanism already works** (a redesign, not a net-new feature with nothing live to replace) — skip it for a purely additive version with no prior mechanism in play. The precedent is the 2026-08-17 idea-routing session (goal 1000071): a subagent mapping the LIVE capture→triage→promote path before Phase 2 turned "route bugs to goals" into the capture-time-promotion design and resolved the bugs-vs-features question almost by itself — the session's own retro named it the highest-value step, and until now the skill only implied it.
|
|
@@ -2070,5 +2070,9 @@ is load-bearing: the script throws rather than guess if it is missing, and
|
|
|
2070
2070
|
landed since 1.19.796 with no explicit bump. run 35169694391. (task 1002620)
|
|
2071
2071
|
1.19.798 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
2072
2072
|
landed since 1.19.797 with no explicit bump. run 35170229509. (task 1002620)
|
|
2073
|
+
1.19.799 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
2074
|
+
landed since 1.19.798 with no explicit bump. run 35170980369. (task 1002620)
|
|
2075
|
+
1.19.800 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
2076
|
+
landed since 1.19.799 with no explicit bump. run 35172784518. (task 1002620)
|
|
2073
2077
|
---------------------------------------------------------------------------
|
|
2074
2078
|
```
|
|
@@ -18,6 +18,16 @@
|
|
|
18
18
|
-- release it, not a dep ship". But 009 releases on the first resolve, not the
|
|
19
19
|
-- last. One path fail-closes; the other did not. This aligns them.
|
|
20
20
|
--
|
|
21
|
+
-- CORRECTION (task 1003934, 2026-09-16): when this header was written, 176's
|
|
22
|
+
-- clause was ALREADY GONE from the live predicate, so the sentence above
|
|
23
|
+
-- described a symmetry that did not exist. 177 dropped the clause by whole-body
|
|
24
|
+
-- rewrite and 178 re-dropped it reverting to the pre-176 body; core_245 restores
|
|
25
|
+
-- it. Nothing in THIS migration depended on that premise — the trigger below
|
|
26
|
+
-- deliberately does not call task_is_fully_unblocked (the note further down says
|
|
27
|
+
-- why, and tests/blocker_resolve_guard.mjs pins it) — so this was a stale
|
|
28
|
+
-- comment, not a stale behaviour. Annotated rather than rewritten, because the
|
|
29
|
+
-- original reasoning is why the trigger exists.
|
|
30
|
+
--
|
|
21
31
|
-- Live at the time of writing: task 1000594 was blocked by BOTH blocker 1000065
|
|
22
32
|
-- and blocker 1000093 — duplicates covering the same two owner decisions — so
|
|
23
33
|
-- closing either as a duplicate would have released the task with the decision
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
-- core_245_restore_open_blocker_clause.sql — put migration 176's open-blocker
|
|
2
|
+
-- clause back into task_is_fully_unblocked (task 1003934).
|
|
3
|
+
--
|
|
4
|
+
-- WHAT WAS LOST, AND BY WHOM. The predicate is CREATE OR REPLACE'd five times
|
|
5
|
+
-- now. Each definition is a WHOLE BODY, not a patch, so a migration drops an
|
|
6
|
+
-- earlier clause simply by not restating it:
|
|
7
|
+
--
|
|
8
|
+
-- 163_polymorphic_dependencies.sql the original deps-doneness predicate
|
|
9
|
+
-- 176_promotion_respects_open_blockers.sql ADDS the open-blocker clause
|
|
10
|
+
-- 177_require_reward_before_workable.sql adds a reward clause — and does
|
|
11
|
+
-- NOT restate 176's. This is where
|
|
12
|
+
-- the clause died; grep -c
|
|
13
|
+
-- task_blockers on 177 returns 0.
|
|
14
|
+
-- 178_reward_gate_auto_assign_revert_autopromote.sql reverts to what its own
|
|
15
|
+
-- header calls "byte-identical to
|
|
16
|
+
-- the 163 body" — but 163 PREDATES
|
|
17
|
+
-- 176, so the revert re-dropped the
|
|
18
|
+
-- clause a second time and shipped
|
|
19
|
+
-- the loss forward.
|
|
20
|
+
-- this one restores it.
|
|
21
|
+
--
|
|
22
|
+
-- Neither 177 nor 178 said it was touching the blocker rule; both believed they
|
|
23
|
+
-- were editing the reward gate. core_223's header still asserts the clause is in
|
|
24
|
+
-- place. Verified on production 2026-09-15: pg_get_functiondef returns the
|
|
25
|
+
-- deps-doneness body only, with no task_blockers reference anywhere in it.
|
|
26
|
+
--
|
|
27
|
+
-- WHAT BREAKS WITHOUT IT. A task whose dependencies all ship auto-promotes
|
|
28
|
+
-- backlog -> ready even while an open blocker is linked to it. The by-id claim
|
|
29
|
+
-- path still refuses (modules/lifecycle/db-claims.js checks b.status = 'open',
|
|
30
|
+
-- task 1002650), so this is NOT a gate bypass — it is the QUEUE LYING. The task
|
|
31
|
+
-- advertises as ready in /builder-start and the claimable feed, and the server
|
|
32
|
+
-- refuses it when someone tries. "ready" stops meaning "workable", which is the
|
|
33
|
+
-- exact failure V4.R15 / task 953 filed migration 176 to fix.
|
|
34
|
+
--
|
|
35
|
+
-- WHY THE INNER JOIN, AND NOT THE LEFT JOIN core_239 USES. These are two
|
|
36
|
+
-- different questions and they are meant to answer the legacy-link case
|
|
37
|
+
-- differently. core_239 governs claimable_tasks — VISIBILITY — and uses
|
|
38
|
+
-- LEFT JOIN ... (b.id IS NULL OR b.status = 'open') so that a legacy
|
|
39
|
+
-- markdown-anchor link (task_blockers.blocker_ref with no blockers row) keeps a
|
|
40
|
+
-- task hidden, fail-closed. This predicate governs PROMOTION, where 176 decided
|
|
41
|
+
-- the opposite on purpose: its header records that legacy anchor links "are not
|
|
42
|
+
-- tracked here and behave exactly as before", so an unresolvable link must not
|
|
43
|
+
-- freeze a task in backlog forever with nothing able to release it. The
|
|
44
|
+
-- promotion sweep's PROMOTABLE_BACKLOG_WHERE (modules/lifecycle/db-tasks.js)
|
|
45
|
+
-- already made the same choice for the same reason. Restoring 176's INNER join
|
|
46
|
+
-- verbatim keeps the three consistent; "tidying" it to the LEFT form here would
|
|
47
|
+
-- silently change which tasks can ever be promoted.
|
|
48
|
+
--
|
|
49
|
+
-- SCOPE. This is 176's body exactly — 176 predates 177, so it never carried the
|
|
50
|
+
-- reward clause that 178 correctly removed. Nothing about the reward gate is
|
|
51
|
+
-- reinstated. Pure CREATE OR REPLACE, so it is idempotent; the migration-163
|
|
52
|
+
-- auto-promotion triggers resolve the function by name and pick up the new body
|
|
53
|
+
-- on their next fire. No data backfill and no demotion: rows already in 'ready'
|
|
54
|
+
-- are never re-evaluated, so this migration moves nothing by itself. The one
|
|
55
|
+
-- live row currently at 'ready' with an open blocker is reconciled separately,
|
|
56
|
+
-- deliberately, by a human looking at that row — not bulk-updated here.
|
|
57
|
+
|
|
58
|
+
BEGIN;
|
|
59
|
+
|
|
60
|
+
CREATE OR REPLACE FUNCTION task_is_fully_unblocked(p_task_id bigint)
|
|
61
|
+
RETURNS boolean AS $$
|
|
62
|
+
SELECT
|
|
63
|
+
-- (1) no gating dependency edge points at an un-done node. An orphaned `to`
|
|
64
|
+
-- yields no EXISTS match -> counts as un-done -> task stays blocked
|
|
65
|
+
-- (fail-closed). Unchanged from 163/178.
|
|
66
|
+
NOT EXISTS (
|
|
67
|
+
WITH gating AS (
|
|
68
|
+
SELECT d.to_kind, d.to_id
|
|
69
|
+
FROM dependencies d
|
|
70
|
+
JOIN tasks t ON t.id = p_task_id
|
|
71
|
+
WHERE (d.from_kind = 'task' AND d.from_id = t.id)
|
|
72
|
+
OR (d.from_kind = 'goal' AND d.from_id = t.goal_id)
|
|
73
|
+
OR (d.from_kind = 'criterion' AND d.from_id IN (
|
|
74
|
+
SELECT tc.criterion_id FROM task_criteria tc WHERE tc.task_id = t.id))
|
|
75
|
+
)
|
|
76
|
+
SELECT 1
|
|
77
|
+
FROM gating g
|
|
78
|
+
WHERE NOT (
|
|
79
|
+
(g.to_kind = 'task' AND EXISTS (SELECT 1 FROM tasks x WHERE x.id = g.to_id AND x.status = 'shipped'))
|
|
80
|
+
OR (g.to_kind = 'criterion' AND EXISTS (SELECT 1 FROM done_when_criteria x WHERE x.id = g.to_id AND x.satisfied))
|
|
81
|
+
OR (g.to_kind = 'goal' AND EXISTS (SELECT 1 FROM goals x WHERE x.id = g.to_id AND x.status = 'achieved'))
|
|
82
|
+
)
|
|
83
|
+
)
|
|
84
|
+
-- (2) … AND the task has no OPEN blocker linked via task_blockers (V4.R15 /
|
|
85
|
+
-- task 953). Restored from migration 176, verbatim. "Open" means
|
|
86
|
+
-- blockers.status = 'open', the same spelling core_223's resolve trigger and
|
|
87
|
+
-- db-claims.js use, so every gate agrees on the word.
|
|
88
|
+
AND NOT EXISTS (
|
|
89
|
+
SELECT 1
|
|
90
|
+
FROM task_blockers tb
|
|
91
|
+
JOIN blockers b ON b.id = tb.blocker_id
|
|
92
|
+
WHERE tb.task_id = p_task_id
|
|
93
|
+
AND b.status = 'open'
|
|
94
|
+
);
|
|
95
|
+
$$ LANGUAGE sql STABLE;
|
|
96
|
+
|
|
97
|
+
COMMIT;
|
|
@@ -23,6 +23,10 @@ const { touchesOverlap } = api.pathMatch;
|
|
|
23
23
|
const { CLAIM_ANY, CLAIM_NEWCOMER } = require('./claim-eligibility.js');
|
|
24
24
|
const { goalMembershipFailures } = require('./claim-feed.js');
|
|
25
25
|
const { unsatisfiedDeps } = require('./db-deps-criteria.js');
|
|
26
|
+
// depRefusalRow: the ONE shape a DEPS_NOT_SHIPPED refusal carries. All three
|
|
27
|
+
// claim paths below use it — hand-building the object here is what made task
|
|
28
|
+
// 1003107's merge pointer invisible on the live path (see its header).
|
|
29
|
+
const { depRefusalRow } = require('./dead-deps.js');
|
|
26
30
|
const { claimGate, claimPermissionsFor, disambiguateWorktreeName, foldWorktreeName } = require('./db-rank-authz.js');
|
|
27
31
|
const { sealSessionLog } = require('./db-shared.js');
|
|
28
32
|
const { needsRebaseTasksForBuilder, shouldRefuseClaimResolve } = require('./db-ship.js');
|
|
@@ -126,8 +130,11 @@ async function claimTask({ taskId, builderId, worktreeName, headSha, creatorSess
|
|
|
126
130
|
|
|
127
131
|
// task 1002650 (idea 1000644): the claimable FEED filters out ready tasks
|
|
128
132
|
// with an open blocker link, but the direct claim-by-id path never checked —
|
|
129
|
-
// the feed hid the trap and this path walked into it.
|
|
133
|
+
// the feed hid the trap and this path walked into it. Mirrors the
|
|
130
134
|
// task_is_fully_unblocked clause so both gates enforce the same rule.
|
|
135
|
+
// Redundant since core_245 (task 1003934) and kept anyway: the predicate lost
|
|
136
|
+
// that clause from 177 until core_245, and this query was the only gate still
|
|
137
|
+
// refusing. It is why a reverted predicate cost a lying queue, not a bypass.
|
|
131
138
|
const { rows: openBlockers } = await client.query(
|
|
132
139
|
`SELECT b.id, b.title
|
|
133
140
|
FROM task_blockers tb
|
|
@@ -213,7 +220,7 @@ async function claimTask({ taskId, builderId, worktreeName, headSha, creatorSess
|
|
|
213
220
|
if (unmetDeps.length > 0) {
|
|
214
221
|
throw {
|
|
215
222
|
code: 'DEPS_NOT_SHIPPED',
|
|
216
|
-
deps: unmetDeps.map(
|
|
223
|
+
deps: unmetDeps.map(depRefusalRow),
|
|
217
224
|
};
|
|
218
225
|
}
|
|
219
226
|
|
|
@@ -570,7 +577,7 @@ async function claimTasksBatch({ taskIds, builderId, worktreePrefix, creatorSess
|
|
|
570
577
|
failures.push({
|
|
571
578
|
task_id: t.id,
|
|
572
579
|
reason: 'DEPS_NOT_SHIPPED',
|
|
573
|
-
deps: external.map(
|
|
580
|
+
deps: external.map(depRefusalRow),
|
|
574
581
|
});
|
|
575
582
|
}
|
|
576
583
|
}
|
|
@@ -913,7 +920,7 @@ async function validateClaimBatch({ taskIds, builderId }) {
|
|
|
913
920
|
if (external.length > 0) {
|
|
914
921
|
push(id, {
|
|
915
922
|
reason: 'DEPS_NOT_SHIPPED',
|
|
916
|
-
deps: external.map(
|
|
923
|
+
deps: external.map(depRefusalRow),
|
|
917
924
|
});
|
|
918
925
|
}
|
|
919
926
|
}
|
|
@@ -73,7 +73,12 @@ async function attachTaskDepSummaries(tasks) {
|
|
|
73
73
|
`SELECT d.from_id AS task_id,
|
|
74
74
|
json_agg(json_build_object(
|
|
75
75
|
'id', t2.id, 'title', t2.title, 'status', t2.status,
|
|
76
|
-
'goal_id', t2.goal_id, 'satisfied', (t2.status = 'shipped')
|
|
76
|
+
'goal_id', t2.goal_id, 'satisfied', (t2.status = 'shipped'),
|
|
77
|
+
-- task 1003107: an abandoned dependency that was MERGED into a
|
|
78
|
+
-- sibling is not cut work, it is work that shipped somewhere
|
|
79
|
+
-- else. Carrying the pointer here is what lets dead-deps.js tell
|
|
80
|
+
-- the two apart; without it every reader re-derives it from prose.
|
|
81
|
+
'merged_into_task_id', t2.merged_into_task_id
|
|
77
82
|
) ORDER BY t2.id) AS blocked_by
|
|
78
83
|
FROM dependencies d
|
|
79
84
|
JOIN tasks t2 ON t2.id = d.to_id
|
|
@@ -452,16 +457,28 @@ async function unsatisfiedDeps(taskId, client = pool) {
|
|
|
452
457
|
OR (d.from_kind = 'criterion' AND d.from_id IN (
|
|
453
458
|
SELECT tc.criterion_id FROM task_criteria tc WHERE tc.task_id = t.id))
|
|
454
459
|
)
|
|
455
|
-
|
|
460
|
+
-- The three extra columns are the ABSORBED case (task 1003107): an
|
|
461
|
+
-- abandoned dependency carrying merged_into_task_id was not cut, it was
|
|
462
|
+
-- folded into a sibling — and if that sibling SHIPPED, the work the edge
|
|
463
|
+
-- is waiting for exists. The gate still refuses (owner ruling 2026-08-21,
|
|
464
|
+
-- see dead-deps.js), but the refusal can now name the right remedy instead
|
|
465
|
+
-- of telling someone to restore a task nobody wants back. Only the task
|
|
466
|
+
-- branch can be absorbed; the other two carry NULLs to keep the UNION
|
|
467
|
+
-- arity honest.
|
|
468
|
+
SELECT 'task' AS kind, x.id, x.title, x.status::text AS state,
|
|
469
|
+
x.merged_into_task_id, m.status::text AS merged_into_status, m.title AS merged_into_title
|
|
456
470
|
FROM gating g JOIN tasks x ON g.to_kind = 'task' AND x.id = g.to_id
|
|
471
|
+
LEFT JOIN tasks m ON m.id = x.merged_into_task_id
|
|
457
472
|
WHERE x.status <> 'shipped'
|
|
458
473
|
UNION ALL
|
|
459
474
|
SELECT 'criterion' AS kind, x.id, x.criterion_md AS title,
|
|
460
|
-
(CASE WHEN x.satisfied THEN 'satisfied' ELSE 'unsatisfied' END) AS state
|
|
475
|
+
(CASE WHEN x.satisfied THEN 'satisfied' ELSE 'unsatisfied' END) AS state,
|
|
476
|
+
NULL::bigint, NULL::text, NULL::text
|
|
461
477
|
FROM gating g JOIN done_when_criteria x ON g.to_kind = 'criterion' AND x.id = g.to_id
|
|
462
478
|
WHERE x.satisfied = false
|
|
463
479
|
UNION ALL
|
|
464
|
-
SELECT 'goal' AS kind, x.id, x.title, x.status::text AS state
|
|
480
|
+
SELECT 'goal' AS kind, x.id, x.title, x.status::text AS state,
|
|
481
|
+
NULL::bigint, NULL::text, NULL::text
|
|
465
482
|
FROM gating g JOIN goals x ON g.to_kind = 'goal' AND x.id = g.to_id
|
|
466
483
|
WHERE x.status <> 'achieved'`,
|
|
467
484
|
[taskId]
|
|
@@ -31,16 +31,80 @@
|
|
|
31
31
|
// is one edit, not a grep.
|
|
32
32
|
const DEAD_DEP_STATUSES = new Set(['abandoned']);
|
|
33
33
|
|
|
34
|
-
//
|
|
35
|
-
//
|
|
36
|
-
//
|
|
37
|
-
//
|
|
34
|
+
// ABSORBED — the dead edge that is not actually cut work (task 1003107).
|
|
35
|
+
//
|
|
36
|
+
// 'abandoned' is overloaded. It covers work genuinely dropped, and it covers
|
|
37
|
+
// work folded into a sibling task and SHIPPED THERE — the /tasks/:id/merge
|
|
38
|
+
// transition, which is `abandonTask` plus a target and records that target in
|
|
39
|
+
// `merged_into_task_id` (core_232). On status alone the two are identical, so a
|
|
40
|
+
// reader who trusts the status treats shipped work as a gap and puts a false
|
|
41
|
+
// edge in the graph. That really happened: task 1003005 read 'abandoned' while
|
|
42
|
+
// the folder move it described had shipped under 1003004, and only the
|
|
43
|
+
// description said so.
|
|
44
|
+
//
|
|
45
|
+
// This does NOT make an absorbed edge satisfy. The owner's 2026-08-21 ruling
|
|
46
|
+
// above is that a cut prerequisite must force a deliberate human re-scope, and
|
|
47
|
+
// an edge pointing at a task that no longer exists as work is exactly that —
|
|
48
|
+
// someone has to decide whether the dependent still needs anything. What
|
|
49
|
+
// changes is that the refusal stops SAYING the work was never built, and names
|
|
50
|
+
// the remedy that fits: re-point the edge at the task that absorbed it.
|
|
51
|
+
//
|
|
52
|
+
// Reads the pointer from either shape the callers have: `merged_into_task_id`
|
|
53
|
+
// on a blocked_by row or on an unsatisfiedDeps row.
|
|
54
|
+
function absorbedInto(dep) {
|
|
55
|
+
const t = dep && dep.merged_into_task_id;
|
|
56
|
+
return t == null || t === '' ? null : String(t);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Which of these unmet dependency rows are DEAD (never-satisfiable AS POINTED)
|
|
60
|
+
// vs merely PENDING? `deps` are the DEPS_NOT_SHIPPED rows: { kind, id, title,
|
|
61
|
+
// status }. Only task-kind edges can be dead — a criterion is
|
|
62
|
+
// unsatisfied-not-cut, and a goal is unachieved-not-cut, both of which can still
|
|
63
|
+
// resolve.
|
|
64
|
+
//
|
|
65
|
+
// `absorbed` is a SUBSET of `dead`, not a fourth bucket: an absorbed edge is
|
|
66
|
+
// still dead as written (it points at a row that will never ship), it just has a
|
|
67
|
+
// different and better remedy. Existing callers that read `dead` keep their
|
|
68
|
+
// exact behaviour.
|
|
38
69
|
function splitDeadDeps(deps) {
|
|
39
70
|
const all = Array.isArray(deps) ? deps : [];
|
|
40
71
|
const dead = all.filter((d) => d && (d.kind === 'task' || d.kind === undefined)
|
|
41
72
|
&& DEAD_DEP_STATUSES.has(String(d.status || d.state || '')));
|
|
42
73
|
const deadIds = new Set(dead.map((d) => String(d.id)));
|
|
43
|
-
return {
|
|
74
|
+
return {
|
|
75
|
+
dead,
|
|
76
|
+
absorbed: dead.filter((d) => absorbedInto(d) !== null),
|
|
77
|
+
pending: all.filter((d) => !deadIds.has(String(d && d.id))),
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// depRefusalRow — one `unsatisfiedDeps` row in the shape a DEPS_NOT_SHIPPED
|
|
82
|
+
// refusal carries.
|
|
83
|
+
//
|
|
84
|
+
// THIS EXISTS BECAUSE THE FIRST VERSION OF TASK 1003107 SHIPPED A DEAD FEATURE.
|
|
85
|
+
// Three claim paths in db-claims.js each built this object by hand, whitelisting
|
|
86
|
+
// four fields. Enriching the QUERY with the merge pointer therefore changed
|
|
87
|
+
// nothing on the real path: all three projections dropped the new columns, so
|
|
88
|
+
// `absorbedInto` saw `undefined` and the ABSORBED branch could never fire in
|
|
89
|
+
// production — while the unit tests, which fed `deadDepHint` hand-built rows,
|
|
90
|
+
// stayed green. A pure core with hand-copied callers is only as shared as its
|
|
91
|
+
// least-updated copy. One mapper is the fix; `tests/dead_deps.mjs` pins that all
|
|
92
|
+
// three sites use it rather than spelling the object again.
|
|
93
|
+
//
|
|
94
|
+
// The merge fields are added only when present, so a row for a task that was
|
|
95
|
+
// never merged keeps its exact previous shape.
|
|
96
|
+
function depRefusalRow(r) {
|
|
97
|
+
const row = { kind: r.kind, id: r.id, title: r.title, status: r.state };
|
|
98
|
+
if (r.merged_into_task_id != null) {
|
|
99
|
+
row.merged_into_task_id = r.merged_into_task_id;
|
|
100
|
+
if (r.merged_into_status != null) row.merged_into_status = r.merged_into_status;
|
|
101
|
+
// A task TITLE, author-supplied. It already flows to the same consumers via
|
|
102
|
+
// blocked_by[].title and dead_deps[].title, so this is not a new surface —
|
|
103
|
+
// but it is untrusted text in a string a UI may render, and any renderer
|
|
104
|
+
// must escape it exactly as it already escapes those.
|
|
105
|
+
if (r.merged_into_title != null) row.merged_into_title = r.merged_into_title;
|
|
106
|
+
}
|
|
107
|
+
return row;
|
|
44
108
|
}
|
|
45
109
|
|
|
46
110
|
// The way-forward line for a refusal that includes at least one dead edge.
|
|
@@ -55,17 +119,40 @@ function splitDeadDeps(deps) {
|
|
|
55
119
|
// `taskId` is the DEPENDENT (optional) so the drop remedy can be spelled out
|
|
56
120
|
// concretely rather than left as a shape to figure out.
|
|
57
121
|
function deadDepHint(deps, taskId = null) {
|
|
58
|
-
const { dead, pending } = splitDeadDeps(deps);
|
|
122
|
+
const { dead, absorbed, pending } = splitDeadDeps(deps);
|
|
59
123
|
if (dead.length === 0) return null;
|
|
60
124
|
const names = dead.map((d) => `task ${d.id}`).join(', ');
|
|
125
|
+
const tail = pending.length
|
|
126
|
+
? ` ${pending.length} other dependenc${pending.length === 1 ? 'y is' : 'ies are'} still genuinely pending.`
|
|
127
|
+
: '';
|
|
128
|
+
const edge = (to) => `{"from_kind":"task","from_id":${taskId ?? '<this task>'},"to_kind":"task","to_id":${to}}`;
|
|
129
|
+
|
|
130
|
+
// The ABSORBED case leads, because it is the one whose usual advice is wrong:
|
|
131
|
+
// nobody wants that task restored, and dropping the edge without re-pointing
|
|
132
|
+
// it loses the ordering that was real. Naming the target turns a judgement
|
|
133
|
+
// call into two concrete calls.
|
|
134
|
+
if (absorbed.length) {
|
|
135
|
+
const a = absorbed[0];
|
|
136
|
+
const into = absorbedInto(a);
|
|
137
|
+
const shipped = String(a.merged_into_status || '') === 'shipped';
|
|
138
|
+
return `${absorbed.length === 1 ? 'A dependency was ABSORBED' : `${absorbed.length} dependencies were ABSORBED`} into another task `
|
|
139
|
+
+ `(task ${a.id} → task ${into}${a.merged_into_title ? `, "${a.merged_into_title}"` : ''}${a.merged_into_status ? `, ${a.merged_into_status}` : ''}) — `
|
|
140
|
+
+ 'it was NOT cut work: it was folded into a sibling and '
|
|
141
|
+
+ (shipped ? 'SHIPPED there. ' : 'is being done there. ')
|
|
142
|
+
+ 'So do not restore it and do not treat the work as missing. '
|
|
143
|
+
+ `RE-POINT the edge: drop the stale one (DELETE /api/bongos/dependencies with body ${edge(a.id)}) `
|
|
144
|
+
+ `and add the real one (POST /api/bongos/dependencies with body ${edge(into)}).`
|
|
145
|
+
+ (dead.length > absorbed.length ? ` ${dead.length - absorbed.length} other dead edge(s) here were genuinely abandoned — see below.` : '')
|
|
146
|
+
+ tail;
|
|
147
|
+
}
|
|
148
|
+
|
|
61
149
|
const first = dead[0].id;
|
|
62
|
-
const tuple = `{"from_kind":"task","from_id":${taskId ?? '<this task>'},"to_kind":"task","to_id":${first}}`;
|
|
63
150
|
return `${dead.length === 1 ? 'A dependency was ABANDONED' : `${dead.length} dependencies were ABANDONED`} (${names}) — `
|
|
64
151
|
+ 'that edge can NEVER satisfy, so this task is STRANDED, not waiting. '
|
|
65
152
|
+ 'An abandoned dependency is a top-priority thing to resolve deliberately; do not wait for it. '
|
|
66
|
-
+ `Either DROP the dead edge (DELETE /api/bongos/dependencies with body ${
|
|
153
|
+
+ `Either DROP the dead edge (DELETE /api/bongos/dependencies with body ${edge(first)}) `
|
|
67
154
|
+ `or RESTORE the dependency (POST /api/bongos/tasks/${first}/promote returns an abandoned task to backlog).`
|
|
68
|
-
+
|
|
155
|
+
+ tail;
|
|
69
156
|
}
|
|
70
157
|
|
|
71
158
|
// Detector core (scripts/gds/audit-deps.js --stranded): which LIVE tasks are
|
|
@@ -90,11 +177,21 @@ function findStrandedTasks(tasks) {
|
|
|
90
177
|
if (!t || !LIVE_STATUSES.has(String(t.status))) continue;
|
|
91
178
|
const dead = (t.blocked_by || []).filter((d) => d && DEAD_DEP_STATUSES.has(String(d.status)));
|
|
92
179
|
if (dead.length) {
|
|
180
|
+
// absorbed_deps is a SUBSET of dead_deps, reported separately because the
|
|
181
|
+
// two need different work: an absorbed edge has a mechanical fix (re-point
|
|
182
|
+
// it at the task named right here), a genuinely abandoned one needs a
|
|
183
|
+
// human to decide whether the dependent still wants anything. A sweep that
|
|
184
|
+
// cannot tell them apart makes every row look like the expensive kind.
|
|
93
185
|
out.push({
|
|
94
186
|
task_id: t.id,
|
|
95
187
|
status: t.status,
|
|
96
188
|
title: t.title,
|
|
97
|
-
dead_deps: dead.map((d) => ({
|
|
189
|
+
dead_deps: dead.map((d) => ({
|
|
190
|
+
id: d.id, title: d.title, status: d.status,
|
|
191
|
+
merged_into_task_id: absorbedInto(d),
|
|
192
|
+
})),
|
|
193
|
+
absorbed_deps: dead.filter((d) => absorbedInto(d) !== null)
|
|
194
|
+
.map((d) => ({ id: d.id, title: d.title, merged_into_task_id: absorbedInto(d) })),
|
|
98
195
|
});
|
|
99
196
|
}
|
|
100
197
|
}
|
|
@@ -103,5 +200,5 @@ function findStrandedTasks(tasks) {
|
|
|
103
200
|
|
|
104
201
|
module.exports = {
|
|
105
202
|
DEAD_DEP_STATUSES, LIVE_STATUSES,
|
|
106
|
-
splitDeadDeps, deadDepHint, findStrandedTasks,
|
|
203
|
+
absorbedInto, depRefusalRow, splitDeadDeps, deadDepHint, findStrandedTasks,
|
|
107
204
|
};
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.800",
|
|
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.800",
|
|
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.800",
|
|
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",
|
|
@@ -257,6 +257,20 @@ function moduleSkillSources(opts) {
|
|
|
257
257
|
|
|
258
258
|
const TEXT_EXT = new Set(['.md', '.txt', '.sh']); // command-bearing text we rewrite (skills are SKILL.md)
|
|
259
259
|
|
|
260
|
+
// Every file under `dir`, as paths relative to it. Used to name a withdrawn skill dir's
|
|
261
|
+
// files before it is removed — a deletion git reports one file at a time, never the dir.
|
|
262
|
+
function listFilesRecursive(dir, base = '') {
|
|
263
|
+
const out = [];
|
|
264
|
+
let entries;
|
|
265
|
+
try { entries = fs.readdirSync(path.join(dir, base), { withFileTypes: true }); } catch { return out; }
|
|
266
|
+
for (const e of entries) {
|
|
267
|
+
const rel = path.join(base, e.name);
|
|
268
|
+
if (e.isDirectory()) out.push(...listFilesRecursive(dir, rel));
|
|
269
|
+
else out.push(rel);
|
|
270
|
+
}
|
|
271
|
+
return out;
|
|
272
|
+
}
|
|
273
|
+
|
|
260
274
|
// `overwrite` defaults true (greenfield init / upgrade — byte-identical to before).
|
|
261
275
|
// `overwrite: false` is the `bongos init --adopt` layering path (ADR 0121): a file
|
|
262
276
|
// that already exists at the destination is NEVER clobbered — it is skipped and
|
|
@@ -368,7 +382,26 @@ function transformSettings(srcPath) {
|
|
|
368
382
|
const HOOK_TEXT_EXT = new Set(['.js', '.mjs', '.cjs', '.json', '.sh', '.md', '.txt']); // hooks are .js — rebrand them too
|
|
369
383
|
|
|
370
384
|
function materializeClaude({ coreRoot, instanceDir, dryRun = false, overwrite = true, log = () => {} }) {
|
|
371
|
-
const summary = { skipped: false, skills: 0, moduleSkills: 0, settings: false, hooks: 0, scheduledTasks: 0, skippedFiles: [], excludedSkills: [], withdrawnSkills: [], rebrandRules: 0, claudeMd: false, notes: [] };
|
|
385
|
+
const summary = { skipped: false, skills: 0, moduleSkills: 0, settings: false, hooks: 0, scheduledTasks: 0, skippedFiles: [], excludedSkills: [], withdrawnSkills: [], rebrandRules: 0, claudeMd: false, writtenPaths: [], notes: [] };
|
|
386
|
+
// Every instance-relative path this run WROTE or REMOVED (task 1003957). `bongos upgrade`
|
|
387
|
+
// hands these to persistPin as artifactPaths, exactly as task 1003926 already does for
|
|
388
|
+
// regenerateApiArtifacts' output: committing only the pin left the materialized files dirty,
|
|
389
|
+
// so the NEXT sweep's clean-tree guard called them "a human's work" and skipped the instance
|
|
390
|
+
// FOREVER — an enrolled co-tenant upgraded exactly once (proven on `charter` 2026-09-16).
|
|
391
|
+
//
|
|
392
|
+
// Recording what we wrote is a STRONGER provenance than a static file list: every path here
|
|
393
|
+
// is one this very run produced. It cannot carry an owner's edit either, and not for the
|
|
394
|
+
// "generated end to end" reason API_ARTIFACT_FILES gives — for a sharper one. Under
|
|
395
|
+
// `overwrite` the copy ALREADY replaced whatever the file held before reaching this line, so
|
|
396
|
+
// the only choice left is commit that overwrite or strand the instance; leaving it dirty
|
|
397
|
+
// protects nothing and stops every future update. Under `overwrite: false` (the `init
|
|
398
|
+
// --adopt` path) nothing is clobbered — copyTreeTransformed reports those as `skipped` and
|
|
399
|
+
// addWritten ignores them — so an adopting instance records nothing here and commits nothing.
|
|
400
|
+
const rel = (p) => String(p).split(path.sep).join('/');
|
|
401
|
+
const addWritten = (p) => { summary.writtenPaths.push(rel(p)); };
|
|
402
|
+
// A copyTreeTransformed onFile that records real writes under `base` and ignores the
|
|
403
|
+
// leave-and-report entries adopt mode emits.
|
|
404
|
+
const recordUnder = (base) => (r, info) => { if (!info || !info.skipped) addWritten(path.join(base, r)); };
|
|
372
405
|
if (path.resolve(instanceDir) === path.resolve(coreRoot)) {
|
|
373
406
|
summary.skipped = true;
|
|
374
407
|
summary.notes.push('instance dir is the core root — nothing to materialize (byte-identical no-op).');
|
|
@@ -410,6 +443,7 @@ function materializeClaude({ coreRoot, instanceDir, dryRun = false, overwrite =
|
|
|
410
443
|
const skillsSrc = path.join(srcClaude, 'skills');
|
|
411
444
|
const skillsRes = copyTreeTransformed(skillsSrc, path.join(destClaude, 'skills'), {
|
|
412
445
|
dryRun, overwrite, transform: (t) => rebrand(bongosify(t)), skip: skillSkip,
|
|
446
|
+
onFile: recordUnder('.claude/skills'),
|
|
413
447
|
});
|
|
414
448
|
summary.skills = skillsRes.files;
|
|
415
449
|
summary.excludedSkills = skillsRes.excluded || [];
|
|
@@ -436,7 +470,7 @@ function materializeClaude({ coreRoot, instanceDir, dryRun = false, overwrite =
|
|
|
436
470
|
summary.notes.push(`module skill "${s.name}" (${s.module}) also exists under the core's .claude/skills/ — the core copy was materialized, the module copy ignored.`);
|
|
437
471
|
continue;
|
|
438
472
|
}
|
|
439
|
-
const r = copyTreeTransformed(s.dir, path.join(destClaude, 'skills', s.name), { dryRun, overwrite, transform: (t) => rebrand(bongosify(t)) });
|
|
473
|
+
const r = copyTreeTransformed(s.dir, path.join(destClaude, 'skills', s.name), { dryRun, overwrite, transform: (t) => rebrand(bongosify(t)), onFile: recordUnder(`.claude/skills/${s.name}`) });
|
|
440
474
|
summary.moduleSkills += r.files;
|
|
441
475
|
for (const rel of r.skipped || []) summary.skippedFiles.push(`skills/${s.name}/${rel}`);
|
|
442
476
|
}
|
|
@@ -500,6 +534,10 @@ function materializeClaude({ coreRoot, instanceDir, dryRun = false, overwrite =
|
|
|
500
534
|
for (const name of withdrawable.sort()) {
|
|
501
535
|
const dir = path.join(skillsDest, name);
|
|
502
536
|
if (!fs.existsSync(dir)) continue;
|
|
537
|
+
// A DELETION is a dirty path too, and `git status` names the individual files rather
|
|
538
|
+
// than the directory — so enumerate before removing, or the withdrawal is exactly the
|
|
539
|
+
// kind of leftover that wedges the next sweep (task 1003957).
|
|
540
|
+
for (const f of listFilesRecursive(dir)) addWritten(path.join('.claude/skills', name, f));
|
|
503
541
|
if (!dryRun) fs.rmSync(dir, { recursive: true, force: true });
|
|
504
542
|
summary.withdrawnSkills.push(name);
|
|
505
543
|
}
|
|
@@ -514,6 +552,7 @@ function materializeClaude({ coreRoot, instanceDir, dryRun = false, overwrite =
|
|
|
514
552
|
const manifest = { _doc: 'GENERATED by scripts/gds/claude-materialize.js (task 1003632): the module-declared skills a materialize landed in this instance. It is the provenance record that lets a later run WITHDRAW a skill whose module has since been disabled — a name absent from here is never deleted, so a hand-written skill is safe. Commit it; do not hand-edit.', skills: Object.fromEntries(names.map((n) => [n, landedNow[n]])) };
|
|
515
553
|
fs.mkdirSync(skillsDest, { recursive: true });
|
|
516
554
|
fs.writeFileSync(path.join(skillsDest, INSTANCE_SKILLS_MANIFEST), JSON.stringify(manifest, null, 2) + '\n');
|
|
555
|
+
addWritten(path.join('.claude/skills', INSTANCE_SKILLS_MANIFEST));
|
|
517
556
|
}
|
|
518
557
|
|
|
519
558
|
// 2. settings.json — topology; present in the checkout/core repo, absent in a package-only install.
|
|
@@ -528,6 +567,7 @@ function materializeClaude({ coreRoot, instanceDir, dryRun = false, overwrite =
|
|
|
528
567
|
fs.mkdirSync(destClaude, { recursive: true });
|
|
529
568
|
fs.writeFileSync(settingsDest, rebrand(transformSettings(settingsSrc)));
|
|
530
569
|
}
|
|
570
|
+
addWritten('.claude/settings.json');
|
|
531
571
|
summary.settings = true;
|
|
532
572
|
log(` ${dryRun ? '[dry-run] would write' : '✓ wrote'} .claude/settings.json (hook commands→bongos, identity rebranded)`);
|
|
533
573
|
}
|
|
@@ -547,6 +587,7 @@ function materializeClaude({ coreRoot, instanceDir, dryRun = false, overwrite =
|
|
|
547
587
|
fs.mkdirSync(destClaude, { recursive: true });
|
|
548
588
|
fs.writeFileSync(settingsDest, renderStarterSettings());
|
|
549
589
|
}
|
|
590
|
+
addWritten('.claude/settings.json');
|
|
550
591
|
summary.settings = true;
|
|
551
592
|
summary.notes.push(`.claude/settings.json written from the starter — ${STARTER_ALLOW.length} blessed command(s), permissions only.`);
|
|
552
593
|
log(` ${dryRun ? '[dry-run] would write' : '✓ wrote'} .claude/settings.json (starter: ${STARTER_ALLOW.length} blessed commands, no hooks)`);
|
|
@@ -557,7 +598,7 @@ function materializeClaude({ coreRoot, instanceDir, dryRun = false, overwrite =
|
|
|
557
598
|
// the founder origin/config-dir). Their core require()s self-resolve at runtime via
|
|
558
599
|
// the copied _core-resolve.js sibling (task 2139) — no path rewriting needed here.
|
|
559
600
|
const hooksRes = copyTreeTransformed(path.join(srcClaude, 'hooks'), path.join(destClaude, 'hooks'), {
|
|
560
|
-
dryRun, overwrite, transform: rebrand, textExt: HOOK_TEXT_EXT,
|
|
601
|
+
dryRun, overwrite, transform: rebrand, textExt: HOOK_TEXT_EXT, onFile: recordUnder('.claude/hooks'),
|
|
561
602
|
});
|
|
562
603
|
summary.hooks = hooksRes.files;
|
|
563
604
|
for (const rel of hooksRes.skipped || []) summary.skippedFiles.push(`hooks/${rel}`);
|
|
@@ -577,7 +618,7 @@ function materializeClaude({ coreRoot, instanceDir, dryRun = false, overwrite =
|
|
|
577
618
|
// paths: they resolve core require()s at runtime via the copied _core-resolve.js sibling
|
|
578
619
|
// (task 2139), and run-routine.js finds the routines under resolveInstanceRoot() post-split.
|
|
579
620
|
const routinesRes = copyTreeTransformed(path.join(srcClaude, 'scheduled-tasks'), path.join(destClaude, 'scheduled-tasks'), {
|
|
580
|
-
dryRun, overwrite, transform: (t) => rebrand(bongosify(t)), textExt: HOOK_TEXT_EXT,
|
|
621
|
+
dryRun, overwrite, transform: (t) => rebrand(bongosify(t)), textExt: HOOK_TEXT_EXT, onFile: recordUnder('.claude/scheduled-tasks'),
|
|
581
622
|
});
|
|
582
623
|
summary.scheduledTasks = routinesRes.files;
|
|
583
624
|
for (const rel of routinesRes.skipped || []) summary.skippedFiles.push(`scheduled-tasks/${rel}`);
|
|
@@ -601,6 +642,7 @@ function materializeClaude({ coreRoot, instanceDir, dryRun = false, overwrite =
|
|
|
601
642
|
fs.mkdirSync(instanceDir, { recursive: true });
|
|
602
643
|
fs.writeFileSync(claudeMdPath, renderInstanceClaudeMd(toBranding));
|
|
603
644
|
}
|
|
645
|
+
addWritten('CLAUDE.md');
|
|
604
646
|
summary.claudeMd = true;
|
|
605
647
|
log(` ${dryRun ? '[dry-run] would write' : '✓ wrote'} CLAUDE.md (starter, brand-substituted for this instance)`);
|
|
606
648
|
}
|
package/scripts/gds/upgrade.js
CHANGED
|
@@ -1228,9 +1228,17 @@ async function runUpgrade(opts, deps = {}) {
|
|
|
1228
1228
|
} else log(' • migrate skipped (--skip-migrate)');
|
|
1229
1229
|
|
|
1230
1230
|
// 5. materialize .claude/ from the freshly-installed core.
|
|
1231
|
+
//
|
|
1232
|
+
// Its output rides the pin commit for the same reason the regenerated API artifacts below
|
|
1233
|
+
// do (task 1003957 extending task 1003926): left dirty, the NEXT sweep's clean-tree guard
|
|
1234
|
+
// reads `.claude/skills/*` as a human's uncommitted work and skips the instance forever —
|
|
1235
|
+
// an enrolled co-tenant that upgrades exactly once. materializeClaude reports the paths it
|
|
1236
|
+
// actually wrote or withdrew, so this is what THIS run touched, not a static list.
|
|
1237
|
+
let materializedPaths = [];
|
|
1231
1238
|
if (!opts.skipMaterialize) {
|
|
1232
1239
|
const coreRoot = path.join(instanceDir, 'node_modules', CORE_PKG);
|
|
1233
1240
|
const sum = materialize({ coreRoot, instanceDir, log: (m) => log(m) });
|
|
1241
|
+
materializedPaths = sum.writtenPaths || [];
|
|
1234
1242
|
log(` ✓ materialized .claude/ (${sum.skills} skill file(s)${sum.settings ? ', settings.json' : ''}${sum.hooks ? `, ${sum.hooks} hook(s)` : ''})`);
|
|
1235
1243
|
} else log(' • materialize skipped (--skip-materialize)');
|
|
1236
1244
|
|
|
@@ -1355,8 +1363,12 @@ async function runUpgrade(opts, deps = {}) {
|
|
|
1355
1363
|
// actually coming: --commit-pin without --pin-no-push, not a dry run, and the pin
|
|
1356
1364
|
// files really are dirty. Nothing reads a credential it will not spend (task 1003081).
|
|
1357
1365
|
const remote = opts.gitRemote || 'origin';
|
|
1366
|
+
// Everything this upgrade generated into the instance's tree: the API artifacts (task
|
|
1367
|
+
// 1003926) and the materialized .claude/ (task 1003957). One list, so the push-credential
|
|
1368
|
+
// probe and persistPin agree on what "the upgrade rewrote" means.
|
|
1369
|
+
const generatedPaths = [...regenArtifacts, ...materializedPaths];
|
|
1358
1370
|
const pushComing = !!opts.commitPin && !opts.pinNoPush && !dryRun
|
|
1359
|
-
&& ((dirtyPinFiles(instanceDir, run) || []).length > 0 || (dirtyPaths(instanceDir,
|
|
1371
|
+
&& ((dirtyPinFiles(instanceDir, run) || []).length > 0 || (dirtyPaths(instanceDir, generatedPaths, run) || []).length > 0);
|
|
1360
1372
|
const pushAuth = pushComing ? await (deps.resolvePinPushAuth || resolvePinPushAuth)({ instanceDir, remote }, deps, run) : null;
|
|
1361
1373
|
const pin = persistPin({
|
|
1362
1374
|
instanceDir,
|
|
@@ -1367,7 +1379,7 @@ async function runUpgrade(opts, deps = {}) {
|
|
|
1367
1379
|
remote,
|
|
1368
1380
|
dryRun: opts.dryRun,
|
|
1369
1381
|
pushAuth,
|
|
1370
|
-
artifactPaths:
|
|
1382
|
+
artifactPaths: generatedPaths,
|
|
1371
1383
|
}, run, log, err);
|
|
1372
1384
|
|
|
1373
1385
|
const ok = versionOk && healthOk !== false && servedOk !== false;
|
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.800'; // 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');
|
package/tests/dead_deps.mjs
CHANGED
|
@@ -11,6 +11,15 @@
|
|
|
11
11
|
// 1. the refusal is HONEST (never "ship those first" for a task that can't ship)
|
|
12
12
|
// 2. the situation is OBSERVABLE (a detector, so it can't strand work silently)
|
|
13
13
|
//
|
|
14
|
+
// task 1003107 adds the third: 'abandoned' is OVERLOADED, and one of the things
|
|
15
|
+
// it covers is not cut work at all. `/tasks/:id/merge` is `abandonTask` plus a
|
|
16
|
+
// target, so a task folded into a sibling and shipped there ends up 'abandoned'
|
|
17
|
+
// carrying `merged_into_task_id`. On status alone it is indistinguishable from
|
|
18
|
+
// dropped work — which is how task 1003005 read as a gap while the rename it
|
|
19
|
+
// described had shipped under 1003004. These tests pin that the ABSORBED case is
|
|
20
|
+
// told apart and given the remedy that fits, WITHOUT relaxing the gate: the
|
|
21
|
+
// owner ruling above still holds, and an absorbed edge is still dead as written.
|
|
22
|
+
//
|
|
14
23
|
// Run: node tests/dead_deps.mjs
|
|
15
24
|
|
|
16
25
|
import { strict as assert } from 'node:assert';
|
|
@@ -20,7 +29,7 @@ import { makeRunner } from './helpers.mjs';
|
|
|
20
29
|
|
|
21
30
|
process.env.NODE_ENV = 'test';
|
|
22
31
|
const require = createRequire(import.meta.url);
|
|
23
|
-
const { splitDeadDeps, deadDepHint, findStrandedTasks, DEAD_DEP_STATUSES, LIVE_STATUSES } =
|
|
32
|
+
const { absorbedInto, depRefusalRow, splitDeadDeps, deadDepHint, findStrandedTasks, DEAD_DEP_STATUSES, LIVE_STATUSES } =
|
|
24
33
|
require('../modules/lifecycle/dead-deps.js');
|
|
25
34
|
const { formatClaimFailure } = require('../scripts/gds/claim.js');
|
|
26
35
|
const { test, summary } = makeRunner();
|
|
@@ -171,4 +180,148 @@ await test('audit-deps exposes the --stranded mode and abandon.js reports what a
|
|
|
171
180
|
assert.match(abandon, /LIVE_STATUSES/, 'and shares the live-status definition with the detector');
|
|
172
181
|
});
|
|
173
182
|
|
|
183
|
+
// ---- task 1003107: ABSORBED is not the same as dropped ---------------------
|
|
184
|
+
|
|
185
|
+
const ABSORBED_DEP = Object.freeze({
|
|
186
|
+
kind: 'task', id: '1003005', title: 'Rename modules/governance to modules/government',
|
|
187
|
+
status: 'abandoned', merged_into_task_id: '1003004',
|
|
188
|
+
merged_into_status: 'shipped', merged_into_title: 'The rename, absorbed',
|
|
189
|
+
});
|
|
190
|
+
const DROPPED_DEP = Object.freeze({ kind: 'task', id: '999', title: 'Cut work', status: 'abandoned' });
|
|
191
|
+
|
|
192
|
+
await test('an absorbed edge is still DEAD — the gate is not relaxed (owner ruling 2026-08-21)', () => {
|
|
193
|
+
const { dead, absorbed, pending } = splitDeadDeps([ABSORBED_DEP]);
|
|
194
|
+
assert.equal(dead.length, 1, 'still dead as written: it points at a row that will never ship');
|
|
195
|
+
assert.equal(absorbed.length, 1, 'and it is recognised as the absorbed kind');
|
|
196
|
+
assert.equal(pending.length, 0, 'it is not quietly reclassified as pending, which would release the dependent');
|
|
197
|
+
assert.equal(absorbed[0], dead[0], 'absorbed is a SUBSET of dead, not a fourth bucket');
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
await test('absorbed vs dropped are told apart, and neither is invented out of nothing', () => {
|
|
201
|
+
assert.equal(splitDeadDeps([DROPPED_DEP]).absorbed.length, 0, 'a plain abandon is not absorbed');
|
|
202
|
+
assert.equal(absorbedInto(DROPPED_DEP), null);
|
|
203
|
+
assert.equal(absorbedInto(ABSORBED_DEP), '1003004');
|
|
204
|
+
assert.equal(absorbedInto({ merged_into_task_id: null }), null);
|
|
205
|
+
assert.equal(absorbedInto({ merged_into_task_id: '' }), null, 'an empty string is not a task id');
|
|
206
|
+
assert.equal(absorbedInto(null), null);
|
|
207
|
+
assert.equal(absorbedInto({ merged_into_task_id: 1003004 }), '1003004', 'a numeric id normalises to a string');
|
|
208
|
+
|
|
209
|
+
const mixed = splitDeadDeps([DROPPED_DEP, ABSORBED_DEP]);
|
|
210
|
+
assert.equal(mixed.dead.length, 2);
|
|
211
|
+
assert.deepEqual(mixed.absorbed.map((d) => d.id), ['1003005']);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
await test('the hint for an absorbed edge names the target and the RE-POINT remedy, not restore', () => {
|
|
215
|
+
const hint = deadDepHint([ABSORBED_DEP], 1003107);
|
|
216
|
+
assert.match(hint, /ABSORBED/, 'it does not call absorbed work abandoned');
|
|
217
|
+
assert.match(hint, /task 1003004/, 'it names the task that absorbed it');
|
|
218
|
+
assert.match(hint, /SHIPPED there/, 'and says the work exists');
|
|
219
|
+
assert.match(hint, /RE-POINT/, 'the remedy is to move the edge');
|
|
220
|
+
assert.doesNotMatch(hint, /promote/, 'it must NOT advise restoring a task nobody wants back');
|
|
221
|
+
assert.doesNotMatch(hint, /can NEVER satisfy/, 'that wording is false here — the work shipped');
|
|
222
|
+
// Both halves of the re-point are spelled out, against the right ids.
|
|
223
|
+
assert.match(hint, /DELETE[\s\S]*"to_id":1003005/, 'drop the stale edge');
|
|
224
|
+
assert.match(hint, /POST[\s\S]*"to_id":1003004/, 'add the real one');
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
await test('an absorbing task that has NOT shipped is described honestly', () => {
|
|
228
|
+
const hint = deadDepHint([{ ...ABSORBED_DEP, merged_into_status: 'active', merged_into_title: 'still going' }], 7);
|
|
229
|
+
assert.match(hint, /is being done there/, 'not claimed as shipped');
|
|
230
|
+
assert.doesNotMatch(hint, /SHIPPED there/);
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
await test('a plain abandoned edge keeps its original wording exactly', () => {
|
|
234
|
+
const hint = deadDepHint([DROPPED_DEP], 1003107);
|
|
235
|
+
assert.match(hint, /was ABANDONED/);
|
|
236
|
+
assert.match(hint, /can NEVER satisfy/);
|
|
237
|
+
assert.match(hint, /promote/, 'restore is still offered where it IS the right remedy');
|
|
238
|
+
assert.doesNotMatch(hint, /ABSORBED/);
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
await test('a mixed refusal leads with the absorbed edge and still accounts for the rest', () => {
|
|
242
|
+
const hint = deadDepHint([DROPPED_DEP, ABSORBED_DEP, { kind: 'task', id: '3', status: 'ready' }], 1003107);
|
|
243
|
+
assert.match(hint, /ABSORBED/, 'the absorbed case leads — its usual advice is the wrong one');
|
|
244
|
+
assert.match(hint, /1 other dead edge/, 'the genuinely abandoned one is not swallowed');
|
|
245
|
+
assert.match(hint, /1 other dependency is still genuinely pending/, 'and pending edges are still counted');
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
await test('the detector reports absorbed strands separately, because they have a mechanical fix', () => {
|
|
249
|
+
const stranded = findStrandedTasks([{
|
|
250
|
+
id: 1001702, status: 'ready', title: 'a live dependent',
|
|
251
|
+
blocked_by: [
|
|
252
|
+
{ id: '1003005', title: 'absorbed one', status: 'abandoned', merged_into_task_id: '1003004' },
|
|
253
|
+
{ id: '999', title: 'dropped one', status: 'abandoned' },
|
|
254
|
+
],
|
|
255
|
+
}]);
|
|
256
|
+
assert.equal(stranded.length, 1);
|
|
257
|
+
assert.equal(stranded[0].dead_deps.length, 2, 'both are still strands — nothing is released');
|
|
258
|
+
assert.deepEqual(stranded[0].absorbed_deps.map((d) => d.id), ['1003005']);
|
|
259
|
+
assert.equal(stranded[0].absorbed_deps[0].merged_into_task_id, '1003004', 'carrying where to re-point');
|
|
260
|
+
assert.equal(stranded[0].dead_deps.find((d) => d.id === '999').merged_into_task_id, null);
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
await test('the merge pointer actually reaches these functions — both queries select it', () => {
|
|
264
|
+
const deps = readFileSync(new URL('../modules/lifecycle/db-deps-criteria.js', import.meta.url), 'utf8');
|
|
265
|
+
const hits = deps.match(/merged_into_task_id/g) || [];
|
|
266
|
+
// The count is the vacuity guard: this test is worthless if the field is
|
|
267
|
+
// absent from the SQL, and absence looks exactly like a passing regex.
|
|
268
|
+
assert.ok(hits.length >= 2, `expected the merge pointer in both dep queries, found ${hits.length}`);
|
|
269
|
+
assert.match(deps, /'merged_into_task_id', t2\.merged_into_task_id/, 'blocked_by carries it');
|
|
270
|
+
assert.match(deps, /x\.merged_into_task_id/, 'the claim-refusal rows carry it');
|
|
271
|
+
assert.match(deps, /LEFT JOIN tasks m ON m\.id = x\.merged_into_task_id/, 'and what became of the absorbing task');
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
// ---- the pointer has to survive the trip to the caller ---------------------
|
|
275
|
+
//
|
|
276
|
+
// The first version of task 1003107 enriched the QUERY and every test above
|
|
277
|
+
// still passed, because they all hand-build dep rows with the pointer already
|
|
278
|
+
// on them. On the real path three claim sites projected the row into a
|
|
279
|
+
// four-field object and dropped it, so the ABSORBED branch was unreachable in
|
|
280
|
+
// production. These are the tests that would have caught that.
|
|
281
|
+
|
|
282
|
+
// A row shaped the way unsatisfiedDeps actually returns one — 'state', not
|
|
283
|
+
// 'status', and the merge columns alongside.
|
|
284
|
+
const RAW_ABSORBED_ROW = Object.freeze({
|
|
285
|
+
kind: 'task', id: '1003005', title: 'Rename modules/governance to modules/government',
|
|
286
|
+
state: 'abandoned', merged_into_task_id: '1003004',
|
|
287
|
+
merged_into_status: 'shipped', merged_into_title: 'The rename, absorbed',
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
await test('depRefusalRow carries the merge pointer from a QUERY row into the refusal shape', () => {
|
|
291
|
+
const row = depRefusalRow(RAW_ABSORBED_ROW);
|
|
292
|
+
assert.equal(row.status, 'abandoned', "the query's 'state' becomes the refusal's 'status'");
|
|
293
|
+
assert.equal(row.merged_into_task_id, '1003004');
|
|
294
|
+
assert.equal(row.merged_into_status, 'shipped');
|
|
295
|
+
assert.equal(row.merged_into_title, 'The rename, absorbed');
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
await test('a row for a task that was never merged keeps its exact previous shape', () => {
|
|
299
|
+
const row = depRefusalRow({ kind: 'task', id: '999', title: 'Cut work', state: 'abandoned' });
|
|
300
|
+
assert.deepEqual(Object.keys(row).sort(), ['id', 'kind', 'status', 'title']);
|
|
301
|
+
assert.equal(absorbedInto(row), null);
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
await test('END TO END: a query row projected by depRefusalRow reaches the ABSORBED branch', () => {
|
|
305
|
+
// This is the composition the live path runs: unsatisfiedDeps row →
|
|
306
|
+
// depRefusalRow → err.deps → deadDepHint. Asserting it here is the difference
|
|
307
|
+
// between the feature working and the feature being unreachable.
|
|
308
|
+
const hint = deadDepHint([depRefusalRow(RAW_ABSORBED_ROW)], 1003107);
|
|
309
|
+
assert.match(hint, /ABSORBED/, 'the branch fires on a row that came through the projection');
|
|
310
|
+
assert.match(hint, /task 1003004/);
|
|
311
|
+
assert.doesNotMatch(hint, /promote/);
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
await test('all three DEPS_NOT_SHIPPED sites use the shared mapper — none spells the object again', () => {
|
|
315
|
+
const src = readFileSync(new URL('../modules/lifecycle/db-claims.js', import.meta.url), 'utf8');
|
|
316
|
+
const uses = (src.match(/\.map\(depRefusalRow\)/g) || []).length;
|
|
317
|
+
// The count IS the vacuity guard, and it is the exact number that was wrong
|
|
318
|
+
// before: three sites, all of which must be taught at once.
|
|
319
|
+
assert.equal(uses, 3, `expected all 3 claim paths to use depRefusalRow, found ${uses}`);
|
|
320
|
+
assert.doesNotMatch(
|
|
321
|
+
src, /status:\s*[rd]\.state/,
|
|
322
|
+
'a hand-built dep projection is back — it will silently drop whatever dead-deps.js learns next'
|
|
323
|
+
);
|
|
324
|
+
assert.match(src, /require\('\.\/dead-deps\.js'\)/, 'and it imports the mapper rather than copying it');
|
|
325
|
+
});
|
|
326
|
+
|
|
174
327
|
summary();
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
// tests/task_unblocked_open_blocker.mjs
|
|
2
|
+
//
|
|
3
|
+
// Task 1003934 — task_is_fully_unblocked must exclude a task with an OPEN
|
|
4
|
+
// blocker. Static shape guard for core_245, in the style of
|
|
5
|
+
// claimable_blocker_status.mjs (task 1003863): no live DB, assert the SQL TEXT,
|
|
6
|
+
// so a future whole-body rewrite cannot silently drop the clause again.
|
|
7
|
+
//
|
|
8
|
+
// THE BUG THIS PINS, AND WHY NOTHING CAUGHT IT FOR TWO MIGRATIONS. The predicate
|
|
9
|
+
// is CREATE OR REPLACE'd as a WHOLE BODY every time, so an earlier clause dies by
|
|
10
|
+
// not being restated. 177 added a reward clause and did not restate 176's blocker
|
|
11
|
+
// clause; 178 then reverted to what it called the "byte-identical" 163 body,
|
|
12
|
+
// which PREDATES 176, re-dropping it. Neither migration mentioned blockers.
|
|
13
|
+
//
|
|
14
|
+
// The existing coverage could not see any of that, and this is the lesson worth
|
|
15
|
+
// keeping: tests/dependencies_polymorphic.mjs asserts the clause against
|
|
16
|
+
// migrations/176's FILE, which still sits on disk unchanged and still contains
|
|
17
|
+
// it. That test passed green for the entire life of the outage. A test pinned to
|
|
18
|
+
// a historical artifact proves history, not the current state — hence the
|
|
19
|
+
// LAST-definition assertion below, which is the only shape that would have
|
|
20
|
+
// failed on the day 177 landed.
|
|
21
|
+
//
|
|
22
|
+
// Run: node tests/task_unblocked_open_blocker.mjs
|
|
23
|
+
|
|
24
|
+
import { strict as assert } from 'node:assert';
|
|
25
|
+
import { readFileSync, readdirSync } from 'node:fs';
|
|
26
|
+
import { fileURLToPath } from 'node:url';
|
|
27
|
+
import { dirname, join } from 'node:path';
|
|
28
|
+
import { makeRunner } from './helpers.mjs';
|
|
29
|
+
|
|
30
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
31
|
+
const repoRoot = join(__dirname, '..');
|
|
32
|
+
const read = (...p) => readFileSync(join(repoRoot, ...p), 'utf8');
|
|
33
|
+
|
|
34
|
+
const MIG = 'core_245_restore_open_blocker_clause.sql';
|
|
35
|
+
const DEFINES = /CREATE OR REPLACE FUNCTION task_is_fully_unblocked/;
|
|
36
|
+
// Comments carry the rationale; strip them before asserting on the executable
|
|
37
|
+
// body so prose can never stand in for the real SQL.
|
|
38
|
+
const body = read('migrations', MIG).replace(/^\s*--.*$/gm, '').replace(/\s+/g, ' ');
|
|
39
|
+
|
|
40
|
+
const { test, summary } = makeRunner();
|
|
41
|
+
|
|
42
|
+
await test('core_245 redefines the predicate', () => {
|
|
43
|
+
assert.match(body, DEFINES);
|
|
44
|
+
assert.match(body, /\$\$ LANGUAGE sql STABLE;/, 'same kind of function as every prior definition');
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
await test('the blocker clause is back, and filters on the blocker being OPEN', () => {
|
|
48
|
+
assert.match(body, /FROM task_blockers tb/, 'it looks at the link table');
|
|
49
|
+
assert.match(body, /JOIN blockers b ON b\.id = tb\.blocker_id/, 'and resolves the blocker itself');
|
|
50
|
+
assert.match(body, /tb\.task_id = p_task_id/, 'for THIS task');
|
|
51
|
+
assert.match(body, /b\.status = 'open'/, 'so a RESOLVED blocker stops blocking promotion');
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
await test('the join is the INNER form 176 chose, NOT core_239-s LEFT form', () => {
|
|
55
|
+
// These two answer the legacy-link case differently ON PURPOSE, so "tidying"
|
|
56
|
+
// one into the other silently changes behaviour with no error anywhere.
|
|
57
|
+
// core_239 governs claimable_tasks (VISIBILITY) and fails closed on a legacy
|
|
58
|
+
// markdown-anchor link. This predicate governs PROMOTION, where 176's header
|
|
59
|
+
// records that such links "are not tracked here and behave exactly as before"
|
|
60
|
+
// — an unresolvable link must not freeze a task in backlog with nothing able
|
|
61
|
+
// to release it. PROMOTABLE_BACKLOG_WHERE made the same call for the same
|
|
62
|
+
// reason; all three must agree.
|
|
63
|
+
assert.doesNotMatch(body, /LEFT JOIN blockers b/,
|
|
64
|
+
'a LEFT join here would let a legacy anchor link freeze a task in backlog forever');
|
|
65
|
+
assert.doesNotMatch(body, /b\.id IS NULL/, 'the fail-closed arm belongs to the VIEW, not the predicate');
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
await test('the deps-doneness clause SURVIVED the rewrite — the hazard runs both ways', () => {
|
|
69
|
+
// This migration could drop the dependency gate exactly as 177 dropped the
|
|
70
|
+
// blocker gate, and nothing would error. Both clauses, asserted together.
|
|
71
|
+
assert.match(body, /WITH gating AS/, 'the gating CTE is intact');
|
|
72
|
+
assert.match(body, /d\.from_kind = 'task' AND d\.from_id = t\.id/, 'own edges');
|
|
73
|
+
assert.match(body, /d\.from_kind = 'goal' AND d\.from_id = t\.goal_id/, 'goal-inherited edges');
|
|
74
|
+
assert.match(body, /d\.from_kind = 'criterion'/, 'criterion-inherited edges');
|
|
75
|
+
assert.match(body, /x\.status = 'shipped'/, 'a task dep is done when shipped');
|
|
76
|
+
assert.match(body, /x\.satisfied/, 'a criterion dep when satisfied');
|
|
77
|
+
assert.match(body, /x\.status = 'achieved'/, 'a goal dep when achieved');
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
await test('both clauses are ANDed — one without the other is half a predicate', () => {
|
|
81
|
+
// 177 kept a NOT EXISTS and still shipped the bug, so the presence of each
|
|
82
|
+
// clause is not enough; they have to be joined into one condition.
|
|
83
|
+
assert.match(body, /NOT EXISTS \(.*WITH gating AS.*\) AND NOT EXISTS \(.*FROM task_blockers tb/,
|
|
84
|
+
'deps-doneness AND no-open-blocker, in one predicate');
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
await test('core_245 is the LAST definition of task_is_fully_unblocked in migrations/', () => {
|
|
88
|
+
// THE ASSERTION THAT WOULD HAVE CAUGHT 177 ON THE DAY IT LANDED. A later
|
|
89
|
+
// whole-body redefinition inherits only what it restates, which is exactly how
|
|
90
|
+
// this clause was lost twice. Catch the next one at the source.
|
|
91
|
+
const defs = readdirSync(join(repoRoot, 'migrations'))
|
|
92
|
+
.filter((f) => f.endsWith('.sql'))
|
|
93
|
+
.filter((f) => DEFINES.test(read('migrations', f)))
|
|
94
|
+
.sort();
|
|
95
|
+
assert.ok(defs.includes(MIG), 'this migration defines the predicate');
|
|
96
|
+
assert.equal(defs[defs.length - 1], MIG,
|
|
97
|
+
`a later migration redefines task_is_fully_unblocked and must restate the open-blocker clause: ${defs.join(', ')}`);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
await test('every OTHER gate still spells "open" the same way', () => {
|
|
101
|
+
// The whole bug class is gates disagreeing. The predicate governs promotion,
|
|
102
|
+
// db-claims.js governs claim-by-id, and the sweep promotes in bulk; if they
|
|
103
|
+
// drift on what "open" means, one of them lies.
|
|
104
|
+
const code = (...p) => read(...p)
|
|
105
|
+
.replace(/\/\*[\s\S]*?\*\//g, ' ')
|
|
106
|
+
.replace(/^\s*\/\/.*$/gm, ' ')
|
|
107
|
+
.replace(/\s+/g, ' ');
|
|
108
|
+
|
|
109
|
+
assert.match(code('modules', 'lifecycle', 'db-claims.js'), /b\.status = 'open'/,
|
|
110
|
+
'the by-id claim path checks blocker STATUS');
|
|
111
|
+
|
|
112
|
+
// The sweep keeps its OWN guard deliberately (task 1003932). Now that the
|
|
113
|
+
// predicate is fixed that guard is belt-and-braces — and it stays, because a
|
|
114
|
+
// sweep that mass-promotes must not inherit a single point of failure from a
|
|
115
|
+
// predicate that has already been reverted twice.
|
|
116
|
+
assert.match(
|
|
117
|
+
code('modules', 'lifecycle', 'db-tasks.js'),
|
|
118
|
+
/PROMOTABLE_BACKLOG_WHERE = ` WHERE t\.status = 'backlog' AND task_is_fully_unblocked\(t\.id\) AND NOT EXISTS \( SELECT 1 FROM task_blockers tb JOIN blockers b ON b\.id = tb\.blocker_id WHERE tb\.task_id = t\.id AND b\.status = 'open'\)`/,
|
|
119
|
+
'the sweep still calls the predicate AND keeps its own open-blocker guard'
|
|
120
|
+
);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
summary();
|
|
@@ -351,3 +351,129 @@ test('real git: an owner edit outside the generated files is never swept into th
|
|
|
351
351
|
assert.equal(git(dir, 'status', '--porcelain').stdout, ' M config/branding.json\n');
|
|
352
352
|
assert.ok(!git(dir, 'show', '--name-only', '--format=', 'HEAD').stdout.includes('config/branding.json'));
|
|
353
353
|
});
|
|
354
|
+
|
|
355
|
+
// ── the MATERIALIZED .claude/ rides the pin commit too (task 1003957) ───────────
|
|
356
|
+
//
|
|
357
|
+
// Task 1003926 (above) made regenerateApiArtifacts' output ride the pin commit. The OTHER
|
|
358
|
+
// half of what an upgrade rewrites — materializeClaude's `.claude/skills|hooks|
|
|
359
|
+
// scheduled-tasks` + settings.json — did not, so the tree stayed dirty and the NEXT sweep's
|
|
360
|
+
// clean-tree guard read it as a human's work and skipped the instance. Proven on the
|
|
361
|
+
// co-tenant `charter` 2026-09-16: it upgraded 1.19.737 → 1.19.785 successfully, left 6 skill
|
|
362
|
+
// files dirty, and was skipped by every sweep afterwards.
|
|
363
|
+
//
|
|
364
|
+
// Both reported symptoms are this one cause. subscribe.js's post-upgrade commitPin REFUSES
|
|
365
|
+
// when anything but the pin is dirty, which is simultaneously the `failed=1` on a successful
|
|
366
|
+
// upgrade and the next sweep's permanent halt.
|
|
367
|
+
|
|
368
|
+
const { materializeClaude } = require('../scripts/gds/claude-materialize.js');
|
|
369
|
+
|
|
370
|
+
// A minimal core package: just the .claude/ tree materialize copies out of it.
|
|
371
|
+
function scratchCore(version) {
|
|
372
|
+
const dir = mkdtempSync(join(tmpdir(), 'upgrade-core-'));
|
|
373
|
+
put(dir, 'package.json', JSON.stringify({ name: '@bongos/core', version }));
|
|
374
|
+
put(dir, 'config/branding.json', JSON.stringify({ identity: {} }));
|
|
375
|
+
put(dir, '.claude/skills/builder-end/SKILL.md', `# builder-end (core ${version})\n`);
|
|
376
|
+
put(dir, '.claude/skills/builder-ship/SKILL.md', `# builder-ship (core ${version})\n`);
|
|
377
|
+
put(dir, '.claude/settings.json', JSON.stringify({ permissions: { allow: [version] } }, null, 2));
|
|
378
|
+
put(dir, '.claude/hooks/stop.js', `// hook for ${version}\n`);
|
|
379
|
+
return dir;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
// A scaffolded co-tenant that already TRACKS the .claude/ a previous materialize landed.
|
|
383
|
+
function scratchCoTenant(version = '1.19.737') {
|
|
384
|
+
const dir = mkdtempSync(join(tmpdir(), 'upgrade-cotenant-'));
|
|
385
|
+
git(dir, 'init', '-q');
|
|
386
|
+
git(dir, 'config', 'user.email', 'upgrade-test@example.invalid');
|
|
387
|
+
git(dir, 'config', 'user.name', 'upgrade test');
|
|
388
|
+
git(dir, 'config', 'core.autocrlf', 'false');
|
|
389
|
+
put(dir, 'package.json', JSON.stringify({ dependencies: { '@bongos/core': version } }));
|
|
390
|
+
put(dir, 'package-lock.json', `lock ${version}\n`);
|
|
391
|
+
put(dir, 'config/branding.json', JSON.stringify({ identity: {} }));
|
|
392
|
+
put(dir, 'CLAUDE.md', 'the instance owns this\n');
|
|
393
|
+
materializeClaude({ coreRoot: scratchCore(version), instanceDir: dir, log: quiet });
|
|
394
|
+
git(dir, 'add', '-A');
|
|
395
|
+
git(dir, 'commit', '-q', '-m', 'scaffold');
|
|
396
|
+
return dir;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// One `bongos upgrade`: move the pin, materialize the new core, persist both.
|
|
400
|
+
function sweep(dir, from, to, { artifactPaths } = {}) {
|
|
401
|
+
put(dir, 'package.json', JSON.stringify({ dependencies: { '@bongos/core': to } }));
|
|
402
|
+
put(dir, 'package-lock.json', `lock ${to}\n`);
|
|
403
|
+
const sum = materializeClaude({ coreRoot: scratchCore(to), instanceDir: dir, log: quiet });
|
|
404
|
+
const paths = artifactPaths === undefined ? sum.writtenPaths : artifactPaths;
|
|
405
|
+
const r = persistPin({ instanceDir: dir, fromVersion: from, toVersion: to, commitPin: true, push: false, artifactPaths: paths }, spawnSync, quiet, quiet);
|
|
406
|
+
return { sum, pin: r };
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
test('materializeClaude reports every path it wrote, as instance-relative POSIX paths', () => {
|
|
410
|
+
const dir = mkdtempSync(join(tmpdir(), 'upgrade-mat-'));
|
|
411
|
+
put(dir, 'config/branding.json', JSON.stringify({ identity: {} }));
|
|
412
|
+
const sum = materializeClaude({ coreRoot: scratchCore('1.19.785'), instanceDir: dir, log: quiet });
|
|
413
|
+
// 2 skills + settings.json + 1 hook + the provenance manifest + the starter CLAUDE.md.
|
|
414
|
+
assert.deepEqual([...sum.writtenPaths].sort(), [
|
|
415
|
+
'.claude/hooks/stop.js',
|
|
416
|
+
'.claude/settings.json',
|
|
417
|
+
'.claude/skills/.instance-skills.json',
|
|
418
|
+
'.claude/skills/builder-end/SKILL.md',
|
|
419
|
+
'.claude/skills/builder-ship/SKILL.md',
|
|
420
|
+
'CLAUDE.md',
|
|
421
|
+
]);
|
|
422
|
+
assert.ok(sum.writtenPaths.every((p) => !p.includes('\\')), 'git wants forward slashes on every platform');
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
test('real git: the done-when — two consecutive co-tenant sweeps, no human hands', () => {
|
|
426
|
+
const dir = scratchCoTenant();
|
|
427
|
+
for (const [from, to] of [['1.19.737', '1.19.785'], ['1.19.785', '1.19.786']]) {
|
|
428
|
+
assert.equal(gitTreeClean(dir).ok, true, `the clean-tree pre-flight passes before ${to}`);
|
|
429
|
+
const { pin } = sweep(dir, from, to);
|
|
430
|
+
assert.equal(pin.committed, true, `upgrade to ${to} committed`);
|
|
431
|
+
assert.equal(git(dir, 'status', '--porcelain').stdout, '', `upgrade to ${to} leaves NOTHING dirty`);
|
|
432
|
+
}
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
test('real git: WITHOUT the materialized paths the instance wedges after exactly one sweep', () => {
|
|
436
|
+
const dir = scratchCoTenant();
|
|
437
|
+
sweep(dir, '1.19.737', '1.19.785', { artifactPaths: [] }); // the pre-1003957 behaviour
|
|
438
|
+
const dirty = git(dir, 'status', '--porcelain').stdout;
|
|
439
|
+
assert.match(dirty, /\.claude\/skills\/builder-end\/SKILL\.md/, 'the materialized output is what stayed behind');
|
|
440
|
+
assert.equal(gitTreeClean(dir).ok, false, 'so the NEXT sweep halts here — forever');
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
test('real git: an owner edit is never swept into the pin commit, tracked or untracked', () => {
|
|
444
|
+
const dir = scratchCoTenant();
|
|
445
|
+
put(dir, 'config/branding.json', 'the owner was here\n');
|
|
446
|
+
put(dir, '.claude/skills/my-own-skill/SKILL.md', 'hand-written — no materialize ever landed this\n');
|
|
447
|
+
sweep(dir, '1.19.737', '1.19.785');
|
|
448
|
+
const committed = git(dir, 'show', '--name-only', '--format=', 'HEAD').stdout;
|
|
449
|
+
assert.ok(!committed.includes('config/branding.json'), 'a tracked owner edit stays the owner\'s');
|
|
450
|
+
assert.ok(!committed.includes('my-own-skill'), 'a hand-written skill is not materialize output, so it is not ours to commit');
|
|
451
|
+
assert.match(git(dir, 'status', '--porcelain').stdout, /config\/branding\.json/, 'and it is still there afterwards');
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
test('real git: a skill WITHDRAWN by the upgrade is committed as a deletion, not left dirty', () => {
|
|
455
|
+
const dir = scratchCoTenant();
|
|
456
|
+
// A skill a previous materialize landed and this core no longer ships.
|
|
457
|
+
const core = scratchCore('1.19.785');
|
|
458
|
+
put(dir, '.claude/skills/retired/SKILL.md', 'landed by an older core\n');
|
|
459
|
+
writeFileSync(join(dir, '.claude/skills/.instance-skills.json'),
|
|
460
|
+
JSON.stringify({ skills: { retired: { module: 'gone', source: 'modules/gone/skills/retired' } } }, null, 2) + '\n');
|
|
461
|
+
git(dir, 'add', '-A'); git(dir, 'commit', '-q', '-m', 'an older core');
|
|
462
|
+
const sum = materializeClaude({ coreRoot: core, instanceDir: dir, log: quiet });
|
|
463
|
+
assert.deepEqual(sum.withdrawnSkills, ['retired']);
|
|
464
|
+
assert.ok(sum.writtenPaths.includes('.claude/skills/retired/SKILL.md'), 'a deletion is a dirty path too');
|
|
465
|
+
persistPin({ instanceDir: dir, fromVersion: '1.19.737', toVersion: '1.19.785', commitPin: true, push: false, artifactPaths: sum.writtenPaths }, spawnSync, quiet, quiet);
|
|
466
|
+
assert.equal(git(dir, 'status', '--porcelain').stdout, '', 'the withdrawal is committed, so nothing wedges the next sweep');
|
|
467
|
+
});
|
|
468
|
+
|
|
469
|
+
test('adopt mode records nothing, so `init --adopt` never commits over what it refused to clobber', () => {
|
|
470
|
+
const dir = mkdtempSync(join(tmpdir(), 'upgrade-adopt-'));
|
|
471
|
+
put(dir, 'config/branding.json', JSON.stringify({ identity: {} }));
|
|
472
|
+
put(dir, '.claude/skills/builder-end/SKILL.md', 'the adopting project already had this\n');
|
|
473
|
+
put(dir, '.claude/settings.json', '{"mine":true}');
|
|
474
|
+
put(dir, 'CLAUDE.md', 'mine\n');
|
|
475
|
+
const sum = materializeClaude({ coreRoot: scratchCore('1.19.785'), instanceDir: dir, overwrite: false, log: quiet });
|
|
476
|
+
assert.ok(!sum.writtenPaths.includes('.claude/skills/builder-end/SKILL.md'), 'a file it declined to clobber was never written');
|
|
477
|
+
assert.ok(!sum.writtenPaths.includes('.claude/settings.json'));
|
|
478
|
+
assert.equal(readFileSync(join(dir, '.claude/skills/builder-end/SKILL.md'), 'utf8'), 'the adopting project already had this\n');
|
|
479
|
+
});
|