@bongos/core 1.19.283 → 1.19.285
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 +28 -18
- package/.claude/skills/builder-claim/SKILL.md +1 -1
- package/.claude/skills/builder-ship/SKILL.md +7 -5
- package/modules/lifecycle/dead-deps.js +99 -0
- package/modules/lifecycle/routes/claims.js +17 -0
- package/modules/lifecycle/ship-card.js +55 -1
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/abandon.js +25 -0
- package/scripts/gds/audit-deps.js +51 -1
- package/scripts/gds/claim.js +18 -3
- package/src/module-api.js +5 -1
- package/tests/dead_deps.mjs +174 -0
- package/tests/ship_card.mjs +79 -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.285",
|
|
6
|
+
"core_contract": "1.19.285",
|
|
7
|
+
"source_commit": "81e2e3e6b0bcdad21011218f05f70fd10f3743e4",
|
|
8
8
|
"source_ref": "HEAD",
|
|
9
|
-
"built_at": "2026-08-
|
|
9
|
+
"built_at": "2026-08-21T22:12:55.612Z",
|
|
10
10
|
"redaction": {
|
|
11
11
|
"model": "docs-redacted+functional-verbatim",
|
|
12
12
|
"docs_redacted": 319,
|
|
13
13
|
"agent_docs_stubbed": 22,
|
|
14
|
-
"functional_verbatim":
|
|
14
|
+
"functional_verbatim": 1382,
|
|
15
15
|
"rules": 3,
|
|
16
16
|
"gate_literals": 3,
|
|
17
17
|
"gate": "passed"
|
|
18
18
|
},
|
|
19
|
-
"file_count":
|
|
20
|
-
"tree_sha256": "
|
|
19
|
+
"file_count": 1723,
|
|
20
|
+
"tree_sha256": "c9778ab2cefac816ecfa4b07061af10f283ac6c42db6bc8b76c7321acc447baa",
|
|
21
21
|
"files": [
|
|
22
22
|
{
|
|
23
23
|
"path": ".claude/skills/blocker-review/SKILL.md",
|
|
@@ -42,7 +42,7 @@
|
|
|
42
42
|
{
|
|
43
43
|
"path": ".claude/skills/builder-claim/SKILL.md",
|
|
44
44
|
"mode": "0000644",
|
|
45
|
-
"sha256": "
|
|
45
|
+
"sha256": "4e6aeafd02b609df2a7651e11238a1b4c2f45b61f8a8f4122200c530aae81b25"
|
|
46
46
|
},
|
|
47
47
|
{
|
|
48
48
|
"path": ".claude/skills/builder-connect/SKILL.md",
|
|
@@ -97,7 +97,7 @@
|
|
|
97
97
|
{
|
|
98
98
|
"path": ".claude/skills/builder-ship/SKILL.md",
|
|
99
99
|
"mode": "0000644",
|
|
100
|
-
"sha256": "
|
|
100
|
+
"sha256": "d3ef4970dd1d3806dc43cd21a0d66c758cb364b538437ae365542d8ad2be9d96"
|
|
101
101
|
},
|
|
102
102
|
{
|
|
103
103
|
"path": ".claude/skills/builder-stage/SKILL.md",
|
|
@@ -3769,6 +3769,11 @@
|
|
|
3769
3769
|
"mode": "0000644",
|
|
3770
3770
|
"sha256": "74eedb9ee1c423c9ecdea6f09d8555fa3200ac9e3a86a194182774cb72f0cd0b"
|
|
3771
3771
|
},
|
|
3772
|
+
{
|
|
3773
|
+
"path": "modules/lifecycle/dead-deps.js",
|
|
3774
|
+
"mode": "0000644",
|
|
3775
|
+
"sha256": "e083c4849b4f2e9e7fe25d6c0e7857c15412b7308bca7f5a8f74e71b5c36df84"
|
|
3776
|
+
},
|
|
3772
3777
|
{
|
|
3773
3778
|
"path": "modules/lifecycle/done-when.js",
|
|
3774
3779
|
"mode": "0000644",
|
|
@@ -3847,7 +3852,7 @@
|
|
|
3847
3852
|
{
|
|
3848
3853
|
"path": "modules/lifecycle/routes/claims.js",
|
|
3849
3854
|
"mode": "0000644",
|
|
3850
|
-
"sha256": "
|
|
3855
|
+
"sha256": "b67f879dea205dd442685df384b8e8060a01b8caa34efc74e31253e556acfb25"
|
|
3851
3856
|
},
|
|
3852
3857
|
{
|
|
3853
3858
|
"path": "modules/lifecycle/routes/dependencies.js",
|
|
@@ -3887,7 +3892,7 @@
|
|
|
3887
3892
|
{
|
|
3888
3893
|
"path": "modules/lifecycle/ship-card.js",
|
|
3889
3894
|
"mode": "0000644",
|
|
3890
|
-
"sha256": "
|
|
3895
|
+
"sha256": "2a0485ae7a846e8cb4673873fc1879f7ae354e5375af01f6a30f4bd7e6882f91"
|
|
3891
3896
|
},
|
|
3892
3897
|
{
|
|
3893
3898
|
"path": "modules/lifecycle/ship-preflight.js",
|
|
@@ -4502,12 +4507,12 @@
|
|
|
4502
4507
|
{
|
|
4503
4508
|
"path": "package-lock.json",
|
|
4504
4509
|
"mode": "0000644",
|
|
4505
|
-
"sha256": "
|
|
4510
|
+
"sha256": "d9c3039ff1ab07335c611517b4baaf896c77f88b1d5d1ddd72a2c0f62cb6efff"
|
|
4506
4511
|
},
|
|
4507
4512
|
{
|
|
4508
4513
|
"path": "package.json",
|
|
4509
4514
|
"mode": "0000644",
|
|
4510
|
-
"sha256": "
|
|
4515
|
+
"sha256": "ddea3b764a2952a78e139d4c6d1439a6b1972623ffa166bcc977dcf808e2c606"
|
|
4511
4516
|
},
|
|
4512
4517
|
{
|
|
4513
4518
|
"path": "public-docs/index.html",
|
|
@@ -4832,7 +4837,7 @@
|
|
|
4832
4837
|
{
|
|
4833
4838
|
"path": "scripts/gds/abandon.js",
|
|
4834
4839
|
"mode": "0000644",
|
|
4835
|
-
"sha256": "
|
|
4840
|
+
"sha256": "67e9a8dead2b0ea75f6011fd687a3db56a12df945a77f430eec2602a0ecac07c"
|
|
4836
4841
|
},
|
|
4837
4842
|
{
|
|
4838
4843
|
"path": "scripts/gds/adopt-detect.js",
|
|
@@ -4877,7 +4882,7 @@
|
|
|
4877
4882
|
{
|
|
4878
4883
|
"path": "scripts/gds/audit-deps.js",
|
|
4879
4884
|
"mode": "0000644",
|
|
4880
|
-
"sha256": "
|
|
4885
|
+
"sha256": "84275f8b3c8981717c1648f346954a55742a0fecd2f6617257c4f77894609e12"
|
|
4881
4886
|
},
|
|
4882
4887
|
{
|
|
4883
4888
|
"path": "scripts/gds/audit-exports.js",
|
|
@@ -4982,7 +4987,7 @@
|
|
|
4982
4987
|
{
|
|
4983
4988
|
"path": "scripts/gds/claim.js",
|
|
4984
4989
|
"mode": "0000644",
|
|
4985
|
-
"sha256": "
|
|
4990
|
+
"sha256": "528e0fd4a91933cab751524f4a0c930dd0f23945801538182343faacf02d9ef7"
|
|
4986
4991
|
},
|
|
4987
4992
|
{
|
|
4988
4993
|
"path": "scripts/gds/classify-discipline.js",
|
|
@@ -6152,7 +6157,7 @@
|
|
|
6152
6157
|
{
|
|
6153
6158
|
"path": "src/module-api.js",
|
|
6154
6159
|
"mode": "0000644",
|
|
6155
|
-
"sha256": "
|
|
6160
|
+
"sha256": "fa343e5d3af72e2687d2bcf563cb33d1e374f8e36d9a89539831b1cc8cc3f6fa"
|
|
6156
6161
|
},
|
|
6157
6162
|
{
|
|
6158
6163
|
"path": "src/module-loader/catalog.js",
|
|
@@ -6769,6 +6774,11 @@
|
|
|
6769
6774
|
"mode": "0000644",
|
|
6770
6775
|
"sha256": "4555a337ce7d795fbda7e463f4a33774583dd17c8c81af83b928cf0057823427"
|
|
6771
6776
|
},
|
|
6777
|
+
{
|
|
6778
|
+
"path": "tests/dead_deps.mjs",
|
|
6779
|
+
"mode": "0000644",
|
|
6780
|
+
"sha256": "07d2825c812a10cd1d39182883652dec15d360d3645dd38023a00a0593d36ddc"
|
|
6781
|
+
},
|
|
6772
6782
|
{
|
|
6773
6783
|
"path": "tests/dependencies_migration.mjs",
|
|
6774
6784
|
"mode": "0000644",
|
|
@@ -8322,7 +8332,7 @@
|
|
|
8322
8332
|
{
|
|
8323
8333
|
"path": "tests/ship_card.mjs",
|
|
8324
8334
|
"mode": "0000644",
|
|
8325
|
-
"sha256": "
|
|
8335
|
+
"sha256": "9fa0cd147650022ada782721f5a79ca19f9b24810c7ac16b2c11a46b585f0caf"
|
|
8326
8336
|
},
|
|
8327
8337
|
{
|
|
8328
8338
|
"path": "tests/ship_ci_deploy.mjs",
|
|
@@ -50,7 +50,7 @@ The server now **enforces** the one-claim half (task 1642): `POST /api/gds/claim
|
|
|
50
50
|
|
|
51
51
|
4. **On success**, relay the claim id, task title, version, priority, credit reward, and the `touches[]` to the user. Treat `touches[]` as an **advisory hint** at the expected blast radius — NOT an editing fence. Code legitimately wanders into files nobody predicted; that is fine and no longer ship-blocking (the grader reads the real committed diff, and `git merge` is the authoritative collision detector). If the claim returned an `overlap_warning`, relay it too — it just means another active claim declares overlapping files; the user self-serializes only if it's genuinely the same code.
|
|
52
52
|
|
|
53
|
-
**Render the claimed card (task 1275).** _As of task 1288 the lifecycle-card `PostToolUse` hook (`.claude/hooks/lifecycle-card.js`) reads this card and injects a one-step render directive right after `claim.js` runs — normally just follow that injected directive and render **once**; the detail below is the fallback if no directive appears._ `claim.js` prints the deterministic lifecycle card for the `claimed` stage — a monospace card plus, when the builder has widgets on (the default), an `[otb-card-html] <path>` marker. If that marker is present **and** an in-feed visualization tool is available (e.g. `show_widget`), **Read that file and pass its exact contents** to the tool as `widget_code` (title like `task_<id>_claimed`); it shows the scope (priority / estimate / touches), the brief in its dropdown, and an "Open the work" action routed to the task's discipline session. **Author nothing yourself and don't restate the claim in prose** — the card is the summary. If instead you see an `[otb-card] widgets are off …` line (the builder disabled widgets via Settings → Rendering, task 1279) — or no visualization tool is available — the monospace card already printed IS the summary; relay it as-is and do not call show_widget.
|
|
53
|
+
**Render the claimed card (task 1275).** _As of task 1288 the lifecycle-card `PostToolUse` hook (`.claude/hooks/lifecycle-card.js`) reads this card and injects a one-step render directive right after `claim.js` runs — normally just follow that injected directive and render **once**; the detail below is the fallback if no directive appears._ `claim.js` prints the deterministic lifecycle card for the `claimed` stage — a monospace card plus, when the builder has widgets on (the default), an `[otb-card-html] <path>` marker. If that marker is present **and** an in-feed visualization tool is available (e.g. `show_widget`), **Read that file and pass its exact contents** to the tool as `widget_code` (title like `task_<id>_claimed`); it shows the scope (priority / estimate / touches), the brief in its dropdown, and an "Open the work" action routed to the task's discipline session. **Author nothing yourself and don't restate the claim in prose** — the card is the summary. If instead you see an `[otb-card] widgets are off …` line (the builder disabled widgets via Settings → Rendering, task 1279) — or no visualization tool is available — the monospace card already printed IS the summary; relay it as-is and do not call show_widget. **One exception, always: relay the `[otb-confirm]` line as text** (task 1001730). `show_widget` reports success whether or not the client actually painted, and CLI stdout is not reliably shown to the user either — so that single line is the only confirmation channel that cannot silently vanish. Verbatim, one line, in addition to the card.
|
|
54
54
|
|
|
55
55
|
5. **On `ALREADY_CLAIMED`**: another session beat us to it. Run `/builder-start` again to get a fresh list and let the user pick something else.
|
|
56
56
|
|
|
@@ -65,16 +65,18 @@ The CLI drives a **three-state lifecycle** with a subagent grader between states
|
|
|
65
65
|
**Never merge a task branch to main before running `ship.js`.** `ship.js` diffs the claim branch against `main`; an already-merged branch yields an empty diff, and the only exits are `--allow-empty` (dead-ends the task at `completed`, requiring an Archon-only confirm) or reverting the merge. Ship from the branch, then let `ship.js` land it. (This trap was hit on tasks 1002417 and 1002419, producing blockers 1000081/1000082 and an ~8.5-hour stall.)
|
|
66
66
|
|
|
67
67
|
5. **Render the completion card — it IS the summary; do NOT write a prose recap (task 1270).**
|
|
68
|
-
|
|
68
|
+
_(Task 1288's lifecycle-card `PostToolUse` hook is no longer part of the core — `.claude/hooks/` ships only `secret-gate.js`, verified task 1001730. If an instance installs such a hook and it injects a render directive, follow it and render **once**; otherwise the steps below ARE the procedure, not a fallback.)_
|
|
69
69
|
On every ship, `ship.js` prints ONE deterministic completion card — the same shape every time, only the data changes. It always prints a monospace card to stdout, and for a shipped / confirmed / grade-failed outcome it ALSO writes a branded HTML card to a temp file and prints two marker lines:
|
|
70
70
|
```
|
|
71
|
+
[otb-confirm] shipped: task <id> · commit <sha> · credits +N — <title>
|
|
71
72
|
[otb-card-html] /tmp/otb-ship-card-<id>.html
|
|
72
|
-
[otb-card] builder-ship: render the HTML ...
|
|
73
|
+
[otb-card] builder-ship: render the HTML ... AND relay this one line as text ...
|
|
73
74
|
```
|
|
74
|
-
-
|
|
75
|
+
- **ALWAYS relay the `[otb-confirm]` line as text in your message** (task 1001730). This is the *guaranteed* confirmation and it is not optional. `show_widget` returns "Content rendered and shown to the user" whether or not the client actually painted the frame — so a widget can silently fail to appear and neither you nor the tool can tell. The owner then gets **no** ship confirmation at all and has to ask "did it land?" (owner, 2026-07-02: *"why didn't it show the shipped card then?"*). One line of text cannot be silently dropped, so it is the floor beneath the card. Relay it verbatim; do not reword or expand it.
|
|
76
|
+
- If the `[otb-card-html] <path>` line is present **and** an in-feed visualization tool is available (e.g. `show_widget`): **Read that file and pass its exact contents** to the tool as `widget_code` (title like `task_<id>_shipped`), *in addition to* the `[otb-confirm]` line. The card renders in the chat feed. **Author nothing yourself** — the HTML is generated, so the card is byte-identical every ship; that determinism is the whole point.
|
|
75
77
|
- If instead you see an **`[otb-card] widgets are off …`** line (the builder disabled widgets via Settings → Rendering — task 1279), there is no HTML file by design: **relay the monospace card as plain text and do NOT call show_widget.** Don't try to "restore" the widget — the absence is the saving.
|
|
76
|
-
- If no visualization tool is available at all (headless / cron / a session without it), the **monospace card already printed IS the summary** — stop there.
|
|
77
|
-
- **
|
|
78
|
+
- If no visualization tool is available at all (headless / cron / a session without it), the **monospace card already printed IS the summary** — relay the `[otb-confirm]` line and stop there.
|
|
79
|
+
- **Beyond that one line: do NOT add a free-form recap** of what shipped, credits, files, cost, or next steps. The card already carries all of it, and its built-in action ("Start next task" / "Land it" / "Fix & re-grade") is the next-step prompt — you don't need to restate it. This replaces the old per-ship prose summary the owner asked us to retire. (The `[otb-confirm]` line is the deliberate exception, and it is *one line* — that is what keeps the redundancy trivial when the card does paint.)
|
|
78
80
|
- The ONLY thing you may add is a single **required** action the card can't express — e.g. a sandbox `🔍 REVIEW REQUIRED` URL from a review pause (see step 1), or a blocker that needs the owner. One line, then stop.
|
|
79
81
|
- A Hero sound + Discord broadcast may still fire on a milestone (CLAUDE.md §2); that's automatic — no need to narrate it.
|
|
80
82
|
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// modules/lifecycle/dead-deps.js — a DEAD dependency edge: one that can never
|
|
4
|
+
// satisfy, because the task it points at was abandoned (task 1003153).
|
|
5
|
+
//
|
|
6
|
+
// THE DEFECT THIS EXISTS FOR. Both dependency gates treat 'abandoned' as
|
|
7
|
+
// unfinished work rather than cut work:
|
|
8
|
+
// - unsatisfiedDeps (db.js) — `WHERE x.status <> 'shipped'` — the
|
|
9
|
+
// DEPS_NOT_SHIPPED claim refusal, on all three claim paths.
|
|
10
|
+
// - task_is_fully_unblocked (migrations/163, replaced by 178) —
|
|
11
|
+
// `AND x.status = 'shipped'` — the auto-promotion trigger predicate.
|
|
12
|
+
// So an abandoned dependency strands its dependents permanently: they never
|
|
13
|
+
// auto-promote out of backlog, and a hand-promoted one cannot be claimed.
|
|
14
|
+
//
|
|
15
|
+
// OWNER RULING (2026-08-21): that strictness STAYS. A cut prerequisite must force
|
|
16
|
+
// a deliberate human re-scope of the edge, not silently let dependent work
|
|
17
|
+
// proceed on something that was never built. This file does NOT relax either
|
|
18
|
+
// gate — it makes the refusal HONEST and the situation OBSERVABLE, which is the
|
|
19
|
+
// actual bug: the CLI used to answer a dead edge with "Ship those first (or
|
|
20
|
+
// release them) and try again", advice that can never be followed.
|
|
21
|
+
//
|
|
22
|
+
// Pure + DB-free so the CLI (scripts/gds/claim.js), the route
|
|
23
|
+
// (modules/lifecycle/routes/claims.js) and the detector (scripts/gds/audit-deps.js)
|
|
24
|
+
// all speak from ONE definition — the goal-authz.js precedent.
|
|
25
|
+
|
|
26
|
+
// The one terminal task status that can gate a dependency edge. 'shipped' is the
|
|
27
|
+
// satisfying end; 'abandoned' is the OTHER terminal end (migration 017's family:
|
|
28
|
+
// "abandoned is a terminal state (deliberately cut), not pending work"), and it
|
|
29
|
+
// is the one that can never satisfy. Kept as a set so a future terminal status
|
|
30
|
+
// (none today — the tasks.status CHECK in migration 012 has exactly these eight)
|
|
31
|
+
// is one edit, not a grep.
|
|
32
|
+
const DEAD_DEP_STATUSES = new Set(['abandoned']);
|
|
33
|
+
|
|
34
|
+
// Which of these unmet dependency rows are DEAD (never-satisfiable) vs merely
|
|
35
|
+
// PENDING? `deps` are the DEPS_NOT_SHIPPED rows: { kind, id, title, status }.
|
|
36
|
+
// Only task-kind edges can be dead — a criterion is unsatisfied-not-cut, and a
|
|
37
|
+
// goal is unachieved-not-cut, both of which can still resolve.
|
|
38
|
+
function splitDeadDeps(deps) {
|
|
39
|
+
const all = Array.isArray(deps) ? deps : [];
|
|
40
|
+
const dead = all.filter((d) => d && (d.kind === 'task' || d.kind === undefined)
|
|
41
|
+
&& DEAD_DEP_STATUSES.has(String(d.status || d.state || '')));
|
|
42
|
+
const deadIds = new Set(dead.map((d) => String(d.id)));
|
|
43
|
+
return { dead, pending: all.filter((d) => !deadIds.has(String(d && d.id))) };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// The way-forward line for a refusal that includes at least one dead edge.
|
|
47
|
+
// Names the TWO remedies that actually exist, and never tells anyone to ship an
|
|
48
|
+
// abandoned task:
|
|
49
|
+
// - DROP the dead edge — DELETE /dependencies with the 4-tuple body
|
|
50
|
+
// { from_kind:'task', from_id:<this task>, to_kind:'task', to_id:<dead dep> }
|
|
51
|
+
// (routes/dependencies.js:69; needs dependency.cross_tier.manage).
|
|
52
|
+
// - RESTORE the dependency — POST /tasks/:id/promote, whose restore branch
|
|
53
|
+
// (db.restoreTask) returns an abandoned task to backlog at the same rank.
|
|
54
|
+
// Returns null when nothing is dead, so callers keep their existing wording.
|
|
55
|
+
// `taskId` is the DEPENDENT (optional) so the drop remedy can be spelled out
|
|
56
|
+
// concretely rather than left as a shape to figure out.
|
|
57
|
+
function deadDepHint(deps, taskId = null) {
|
|
58
|
+
const { dead, pending } = splitDeadDeps(deps);
|
|
59
|
+
if (dead.length === 0) return null;
|
|
60
|
+
const names = dead.map((d) => `task ${d.id}`).join(', ');
|
|
61
|
+
const first = dead[0].id;
|
|
62
|
+
const tuple = `{"from_kind":"task","from_id":${taskId ?? '<this task>'},"to_kind":"task","to_id":${first}}`;
|
|
63
|
+
return `${dead.length === 1 ? 'A dependency was ABANDONED' : `${dead.length} dependencies were ABANDONED`} (${names}) — `
|
|
64
|
+
+ 'that edge can NEVER satisfy, so this task is STRANDED, not waiting. '
|
|
65
|
+
+ '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 ${tuple}) `
|
|
67
|
+
+ `or RESTORE the dependency (POST /api/bongos/tasks/${first}/promote returns an abandoned task to backlog).`
|
|
68
|
+
+ (pending.length ? ` ${pending.length} other dependenc${pending.length === 1 ? 'y is' : 'ies are'} still genuinely pending.` : '');
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Detector core (scripts/gds/audit-deps.js --stranded): which LIVE tasks are
|
|
72
|
+
// gated by an abandoned dependency? `tasks` are rows carrying { id, status,
|
|
73
|
+
// title, blocked_by: [{ id, status, title }] } — the GET /tasks?include=deps
|
|
74
|
+
// shape. A dependent that is itself terminal is NOT stranded (nothing is
|
|
75
|
+
// waiting), which is why the live corpus reports zero today even though 17
|
|
76
|
+
// abandoned tasks still carry dependents. Pure + exported for tests.
|
|
77
|
+
const LIVE_STATUSES = new Set(['backlog', 'ready', 'active', 'blocked']);
|
|
78
|
+
|
|
79
|
+
function findStrandedTasks(tasks) {
|
|
80
|
+
const out = [];
|
|
81
|
+
for (const t of Array.isArray(tasks) ? tasks : []) {
|
|
82
|
+
if (!t || !LIVE_STATUSES.has(String(t.status))) continue;
|
|
83
|
+
const dead = (t.blocked_by || []).filter((d) => d && DEAD_DEP_STATUSES.has(String(d.status)));
|
|
84
|
+
if (dead.length) {
|
|
85
|
+
out.push({
|
|
86
|
+
task_id: t.id,
|
|
87
|
+
status: t.status,
|
|
88
|
+
title: t.title,
|
|
89
|
+
dead_deps: dead.map((d) => ({ id: d.id, title: d.title, status: d.status })),
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return out;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
module.exports = {
|
|
97
|
+
DEAD_DEP_STATUSES, LIVE_STATUSES,
|
|
98
|
+
splitDeadDeps, deadDepHint, findStrandedTasks,
|
|
99
|
+
};
|
|
@@ -9,6 +9,9 @@ const db = require('../db');
|
|
|
9
9
|
// never a direct require of a module file. The doorway re-exports the seam fns.
|
|
10
10
|
const seams = api;
|
|
11
11
|
const { validateOrRespond, LIMITS, parseId } = api;
|
|
12
|
+
// task 1003153: the DEAD dependency edge (a dep that was abandoned, so can never
|
|
13
|
+
// satisfy) — one pure definition shared with the CLI + the detector.
|
|
14
|
+
const { deadDepHint, splitDeadDeps } = require('../dead-deps.js');
|
|
12
15
|
|
|
13
16
|
// V3.R33 (#254): input validation guards. Length caps + shape checks at the
|
|
14
17
|
// top of every write handler. Schemas describe KNOWN-field constraints only —
|
|
@@ -325,6 +328,20 @@ module.exports = function buildClaimsRouter() {
|
|
|
325
328
|
const names = (err.blockers || []).map((b) => `blocker ${b.id} ("${b.title}")`);
|
|
326
329
|
err.hint = `This task is waiting on ${names.join(', ') || 'an open blocker'}. Resolve it first (/blocker-solve N drives one to done; resolving auto-promotes its linked tasks) — or pick another task from /builder-start.`;
|
|
327
330
|
}
|
|
331
|
+
// task 1003153: a DEPS_NOT_SHIPPED whose unmet set includes an ABANDONED
|
|
332
|
+
// task is not "waiting" — that edge can never satisfy, so the task is
|
|
333
|
+
// stranded. Say so and name the two real remedies (drop the edge, or
|
|
334
|
+
// restore the dep); the old silence let the CLI advise "ship those
|
|
335
|
+
// first", which is impossible for a cut task. The gate itself stays
|
|
336
|
+
// strict (owner ruling) — this only makes the refusal honest. `dead_deps`
|
|
337
|
+
// rides the body so the hall and any other client can render it too.
|
|
338
|
+
if (err.code === 'DEPS_NOT_SHIPPED') {
|
|
339
|
+
const hint = deadDepHint(err.deps, Number(taskId));
|
|
340
|
+
if (hint) {
|
|
341
|
+
err.hint = hint;
|
|
342
|
+
err.dead_deps = splitDeadDeps(err.deps).dead;
|
|
343
|
+
}
|
|
344
|
+
}
|
|
328
345
|
return res.fail(err.code, status, { ...err });
|
|
329
346
|
}
|
|
330
347
|
console.error('[gds] POST /claims', err);
|
|
@@ -577,11 +577,57 @@ function renderCardHtml(model) {
|
|
|
577
577
|
// and NOT call show_widget.
|
|
578
578
|
//
|
|
579
579
|
// Strictly best-effort — a summary card must never fail or undo a CLI action.
|
|
580
|
+
// task 1001730 — THE GUARANTEED CONFIRMATION. One line, plain text, always
|
|
581
|
+
// emitted, carrying the facts the owner actually asked for ("did it land?"):
|
|
582
|
+
// stage, task id, and the stage's own key facts (commit / credits / grade).
|
|
583
|
+
//
|
|
584
|
+
// WHY a line and not the card: show_widget is best-effort in a direction the
|
|
585
|
+
// agent cannot see. The tool answers "Content rendered and shown to the user"
|
|
586
|
+
// whether or not the client actually painted the frame, so no amount of care on
|
|
587
|
+
// this side can make the WIDGET deterministic. Determinism has to come from a
|
|
588
|
+
// channel that cannot silently drop — the conversation text itself. The card
|
|
589
|
+
// stays the rich confirmation; this line is the floor beneath it.
|
|
590
|
+
//
|
|
591
|
+
// Deliberately ONE line: it is redundant with the card on every successful
|
|
592
|
+
// paint, so it has to be cheap enough that the redundancy costs nothing.
|
|
593
|
+
// Pure + exported so every stage's wording is pinned by a test.
|
|
594
|
+
function confirmLine(model) {
|
|
595
|
+
const m = model || {};
|
|
596
|
+
const id = m.taskId != null ? `task ${m.taskId}` : 'task';
|
|
597
|
+
const word = (m.cfg && m.cfg.statusWord) ? String(m.cfg.statusWord).toLowerCase() : String(m.stage || 'done');
|
|
598
|
+
// The stage's own rows already hold exactly the facts that stage cares about
|
|
599
|
+
// (buildCardModel picks them per stage); reuse them rather than re-deriving,
|
|
600
|
+
// so a new stage fact shows up here for free instead of drifting. The live row
|
|
601
|
+
// keys are Changed · Grade · Credits · Usage · Deploy — `Deploy` is where the
|
|
602
|
+
// commit rides ("live · ea509d5"), which is why there is no Commit key to keep.
|
|
603
|
+
// Usage (token spend) is deliberately dropped: it is not part of "did it land".
|
|
604
|
+
const keep = ['Changed', 'Grade', 'Credits', 'Deploy', 'Status'];
|
|
605
|
+
const byKey = new Map((Array.isArray(m.rows) ? m.rows : []).filter((r) => r && r.k).map((r) => [r.k, r.v]));
|
|
606
|
+
const bits = [];
|
|
607
|
+
for (const k of keep) {
|
|
608
|
+
if (!byKey.has(k)) continue;
|
|
609
|
+
const v = String(byKey.get(k));
|
|
610
|
+
// Skip the key prefix when the value already says the word ("+38 credits"),
|
|
611
|
+
// so the line never reads "credits +38 credits".
|
|
612
|
+
bits.push(v.toLowerCase().includes(k.toLowerCase()) ? v : `${k.toLowerCase()} ${v}`);
|
|
613
|
+
}
|
|
614
|
+
const title = m.title ? ` — ${String(m.title).slice(0, 60)}` : '';
|
|
615
|
+
return `${word}: ${id}${bits.length ? ' · ' + bits.join(' · ') : ''}${title}`;
|
|
616
|
+
}
|
|
617
|
+
|
|
580
618
|
function emitCard(model, taskId, { log = console.log, widgetsEnabled = true } = {}) {
|
|
581
619
|
try {
|
|
582
620
|
log('');
|
|
583
621
|
log(renderMonospaceCard(model));
|
|
584
622
|
} catch (_) { return null; }
|
|
623
|
+
// task 1001730: emitted BEFORE the widget branch and on EVERY path, so the
|
|
624
|
+
// owner has a confirmation even if everything downstream of here fails.
|
|
625
|
+
let confirm = null;
|
|
626
|
+
try {
|
|
627
|
+
confirm = confirmLine(model);
|
|
628
|
+
log('');
|
|
629
|
+
log(`[otb-confirm] ${confirm}`);
|
|
630
|
+
} catch (_) { /* best-effort; never break a CLI action over a summary */ }
|
|
585
631
|
if (!widgetsEnabled) {
|
|
586
632
|
try {
|
|
587
633
|
log('');
|
|
@@ -594,7 +640,13 @@ function emitCard(model, taskId, { log = console.log, widgetsEnabled = true } =
|
|
|
594
640
|
fs.writeFileSync(file, renderCardHtml(model), 'utf8');
|
|
595
641
|
log('');
|
|
596
642
|
log(`[otb-card-html] ${file}`);
|
|
597
|
-
|
|
643
|
+
// task 1001730: the old directive ended "then stop — no prose recap", which
|
|
644
|
+
// is precisely what made a silent non-paint fatal — the agent was told to
|
|
645
|
+
// say nothing, so a dropped widget frame left the owner with NO confirmation
|
|
646
|
+
// at all (owner, 2026-07-02: "why didn't it show the shipped card then?").
|
|
647
|
+
// Now the [otb-confirm] line above is ALWAYS relayed in text as well. Still
|
|
648
|
+
// no long recap — one line, so the redundancy is trivial when the card paints.
|
|
649
|
+
log(`[otb-card] render the HTML at the path above in the chat feed via the visualization tool (show_widget) AND relay this one line as text so the confirmation cannot be lost if the widget does not paint: "${confirm || ''}". No other prose recap. If the tool is unavailable, the card above IS the summary.`);
|
|
598
650
|
return file;
|
|
599
651
|
} catch (_) {
|
|
600
652
|
return null;
|
|
@@ -603,6 +655,8 @@ function emitCard(model, taskId, { log = console.log, widgetsEnabled = true } =
|
|
|
603
655
|
|
|
604
656
|
module.exports = {
|
|
605
657
|
STAGES, TONE, stageFor, buildCardModel, renderMonospaceCard, renderCardHtml, emitCard,
|
|
658
|
+
// task 1001730 — the guaranteed one-line confirmation (pure; every stage pinned).
|
|
659
|
+
confirmLine,
|
|
606
660
|
// exported for unit tests:
|
|
607
661
|
fmtTokens, fmtUsd, fmtInt, fmtMins, nextActionFor, esc, wrap, changedText,
|
|
608
662
|
// task 1003150 — the session-boundary nudge, pure so every branch is pinned.
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.285",
|
|
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.285",
|
|
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.285",
|
|
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",
|
package/scripts/gds/abandon.js
CHANGED
|
@@ -18,6 +18,9 @@
|
|
|
18
18
|
// restores it to backlog at the same rank (db.restoreTask) — no Archon SQL needed.
|
|
19
19
|
|
|
20
20
|
const { cliClient, requireSession, arg } = require('./cli-lib');
|
|
21
|
+
// task 1003153 — the live-status set, shared with the dead-edge detector so
|
|
22
|
+
// "who did this cut strand" means the same thing in both places.
|
|
23
|
+
const { LIVE_STATUSES } = require('../../modules/lifecycle/dead-deps.js');
|
|
21
24
|
|
|
22
25
|
|
|
23
26
|
(async () => {
|
|
@@ -54,6 +57,28 @@ const { cliClient, requireSession, arg } = require('./cli-lib');
|
|
|
54
57
|
console.log(`Abandoned task #${r.data.taskId}: ${r.data.taskTitle}`);
|
|
55
58
|
console.log(`Status: ${r.data.previousStatus} -> abandoned.`);
|
|
56
59
|
}
|
|
60
|
+
|
|
61
|
+
// task 1003153 — REPORT what this cut just stranded. An abandoned task can
|
|
62
|
+
// never ship, so every LIVE task depending on it is now permanently gated:
|
|
63
|
+
// it will not auto-promote (task_is_fully_unblocked wants status='shipped')
|
|
64
|
+
// and cannot be claimed (DEPS_NOT_SHIPPED). Per the owner ruling the gate
|
|
65
|
+
// stays strict, which makes surfacing this at cut time the whole remedy —
|
|
66
|
+
// the edge needs a deliberate decision, and the person cutting the task is
|
|
67
|
+
// the one who knows. Best-effort: never fail an abandon over the report.
|
|
68
|
+
try {
|
|
69
|
+
const dep = await api.tasks.getTasksId({ id: taskId, query: { include: 'deps' } });
|
|
70
|
+
const dependents = (dep?.data?.task?.blocks || []).filter((b) => LIVE_STATUSES.has(String(b.status)));
|
|
71
|
+
if (dependents.length) {
|
|
72
|
+
console.log('');
|
|
73
|
+
console.log(`⚠ TOP PRIORITY — this cut just STRANDED ${dependents.length} live task(s) that depend on #${taskId}:`);
|
|
74
|
+
for (const b of dependents) console.log(` #${b.id} [${b.status}] ${b.title}`);
|
|
75
|
+
console.log(' They can never auto-promote or be claimed while this edge stands, because an');
|
|
76
|
+
console.log(' abandoned task can never ship. Resolve each edge deliberately — do not leave it:');
|
|
77
|
+
console.log(` drop the edge: node scripts/gds/api.js DELETE /api/bongos/dependencies --body '{"from_kind":"task","from_id":<dependent>,"to_kind":"task","to_id":${taskId}}'`);
|
|
78
|
+
console.log(` or restore this: node scripts/gds/api.js POST /api/bongos/tasks/${taskId}/promote`);
|
|
79
|
+
console.log(' Sweep for any you miss: node scripts/gds/audit-deps.js --stranded');
|
|
80
|
+
}
|
|
81
|
+
} catch (_) { /* the abandon already landed — the report is advisory */ }
|
|
57
82
|
})().catch((err) => {
|
|
58
83
|
console.error('fatal:', err);
|
|
59
84
|
process.exit(1);
|
|
@@ -17,9 +17,13 @@
|
|
|
17
17
|
// gate's notion of "overlap".
|
|
18
18
|
//
|
|
19
19
|
// node scripts/gds/audit-deps.js [--version GDS-V3] [--min high|medium|low] [--json]
|
|
20
|
+
// node scripts/gds/audit-deps.js --stranded [--json] # the INVERSE: dead edges (task 1003153)
|
|
20
21
|
|
|
21
22
|
const { touchesOverlap } = require('../../src/bongos/path-match.js');
|
|
22
23
|
const { cliClient } = require('./cli-lib');
|
|
24
|
+
// task 1003153 — the DEAD-edge detector core, shared with the claim refusal so
|
|
25
|
+
// "abandoned dependency" means one thing across the CLI, the route and this audit.
|
|
26
|
+
const { findStrandedTasks } = require('../../modules/lifecycle/dead-deps.js');
|
|
23
27
|
|
|
24
28
|
// Lifecycle statuses that count as "open" (auditable) vs "an unshipped blocker".
|
|
25
29
|
const OPEN = new Set(['ready', 'backlog', 'active', 'blocked']);
|
|
@@ -76,8 +80,54 @@ function findMissingDeps(tasks, { open = OPEN, isBlocker = isUnshippedBlocker }
|
|
|
76
80
|
return out;
|
|
77
81
|
}
|
|
78
82
|
|
|
83
|
+
// --stranded (task 1003153): the INVERSE audit. findMissingDeps asks "which edge
|
|
84
|
+
// is MISSING"; this asks "which existing edge is DEAD" — a live task gated by an
|
|
85
|
+
// ABANDONED dependency. That edge can never satisfy (an abandoned task can never
|
|
86
|
+
// ship), so the dependent never auto-promotes and can never be claimed: stranded,
|
|
87
|
+
// not waiting. Per the owner ruling (2026-08-21) the gates stay strict, which
|
|
88
|
+
// makes this detector the remedy — an abandoned dependency must be REPORTED as a
|
|
89
|
+
// top-priority thing to resolve rather than blocking silently and forever.
|
|
90
|
+
// Exits 1 when anything is stranded, so a sweep or CI leg can gate on it.
|
|
91
|
+
async function reportStranded({ asJson }) {
|
|
92
|
+
const api = await cliClient();
|
|
93
|
+
const r = await api.tasks.getTasks({ query: { include: 'deps', limit: 3000 } });
|
|
94
|
+
if (!r.ok) { console.error(`audit-deps: could not list tasks (${r.status})`); process.exit(1); }
|
|
95
|
+
const all = r.data.tasks || [];
|
|
96
|
+
const stranded = findStrandedTasks(all);
|
|
97
|
+
|
|
98
|
+
// Context worth stating even at zero: abandoned tasks that still CARRY
|
|
99
|
+
// dependents are the latent trap. They report clean only because every
|
|
100
|
+
// dependent happens to be terminal too — one promote would light them up.
|
|
101
|
+
const gatingAbandoned = new Set();
|
|
102
|
+
for (const t of all) for (const d of (t.blocked_by || [])) {
|
|
103
|
+
if (String(d.status) === 'abandoned') gatingAbandoned.add(String(d.id));
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (asJson) {
|
|
107
|
+
console.log(JSON.stringify({ stranded, abandoned_deps_with_dependents: [...gatingAbandoned] }, null, 2));
|
|
108
|
+
return stranded.length ? 1 : 0;
|
|
109
|
+
}
|
|
110
|
+
console.log(`audit-deps --stranded: ${all.length} task(s) scanned — ${stranded.length} LIVE task(s) stranded by an abandoned dependency\n`);
|
|
111
|
+
for (const s of stranded) {
|
|
112
|
+
console.log(`[STRANDED] #${s.task_id} [${s.status}] ${(s.title || '').slice(0, 70)}`);
|
|
113
|
+
for (const d of s.dead_deps) console.log(` dead edge → #${d.id} [${d.status}] ${(d.title || '').slice(0, 60)}`);
|
|
114
|
+
console.log(` fix: drop the edge (DELETE /dependencies {"from_kind":"task","from_id":${s.task_id},"to_kind":"task","to_id":${s.dead_deps[0].id}})`);
|
|
115
|
+
console.log(` or restore the dep (POST /tasks/${s.dead_deps[0].id}/promote)\n`);
|
|
116
|
+
}
|
|
117
|
+
if (!stranded.length) {
|
|
118
|
+
console.log('Nothing stranded — no live task is gated by an abandoned dependency.');
|
|
119
|
+
console.log(`Latent exposure: ${gatingAbandoned.size} abandoned task(s) still carry dependents; they read clean only`);
|
|
120
|
+
console.log('because those dependents are themselves terminal. Promoting one would strand it immediately.');
|
|
121
|
+
}
|
|
122
|
+
return stranded.length ? 1 : 0;
|
|
123
|
+
}
|
|
124
|
+
|
|
79
125
|
async function main() {
|
|
80
126
|
const argv = process.argv.slice(2);
|
|
127
|
+
if (argv.includes('--stranded')) {
|
|
128
|
+
process.exitCode = await reportStranded({ asJson: argv.includes('--json') });
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
81
131
|
const ver = argv.includes('--version') ? argv[argv.indexOf('--version') + 1] : 'GDS-V3';
|
|
82
132
|
const minRank = argv.includes('--min') ? argv[argv.indexOf('--min') + 1] : 'low';
|
|
83
133
|
const asJson = argv.includes('--json');
|
|
@@ -114,7 +164,7 @@ async function main() {
|
|
|
114
164
|
console.log('NB: advisory only. Backfill `high` edges by hand (POST /tasks/:id/dependencies) after reading both tasks; a wrong edge gates the dependent.');
|
|
115
165
|
}
|
|
116
166
|
|
|
117
|
-
module.exports = { findMissingDeps, classify, OPEN, isUnshippedBlocker, HOTSPOTS };
|
|
167
|
+
module.exports = { findMissingDeps, classify, OPEN, isUnshippedBlocker, HOTSPOTS, reportStranded };
|
|
118
168
|
|
|
119
169
|
if (require.main === module) {
|
|
120
170
|
main().catch((e) => { console.error('audit-deps:', e.message); process.exit(1); });
|
package/scripts/gds/claim.js
CHANGED
|
@@ -15,6 +15,10 @@ const { nextNumbers, padAdr, padMig } = require('./next-number');
|
|
|
15
15
|
const { CORE_NS } = require('./migration-namespace');
|
|
16
16
|
const { contextPack, formatContextPack } = require('./context-pack');
|
|
17
17
|
const { printClaimRecall } = require('./claim-recall');
|
|
18
|
+
// task 1003153 — the DEAD dependency edge, one definition shared with the route
|
|
19
|
+
// and the detector. A lifecycle CLI may require the lifecycle module directly
|
|
20
|
+
// (the documented exception in modules/lifecycle/CLAUDE.md).
|
|
21
|
+
const { splitDeadDeps, deadDepHint } = require('../../modules/lifecycle/dead-deps.js');
|
|
18
22
|
|
|
19
23
|
// Fetch up to 3 learnings relevant to the task being claimed. touches[] cleanup
|
|
20
24
|
// [3/8] (task 878): key off the task's discipline + title (always present) rather
|
|
@@ -260,9 +264,20 @@ function formatClaimFailure(r, taskId) {
|
|
|
260
264
|
return { lines, exit: 1 };
|
|
261
265
|
}
|
|
262
266
|
if (code === 'DEPS_NOT_SHIPPED') {
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
267
|
+
// task 1003153: an ABANDONED dependency can never ship, so "ship those
|
|
268
|
+
// first" was impossible advice — the task is STRANDED, not waiting. Mark the
|
|
269
|
+
// dead edges in the list and relay the server's hint (deadDepHint), which
|
|
270
|
+
// names the two real remedies. Falls back to the original wording when every
|
|
271
|
+
// unmet dep is genuinely still pending.
|
|
272
|
+
const dead = new Set(splitDeadDeps(d.deps).dead.map((x) => String(x.id)));
|
|
273
|
+
lines.push(dead.size
|
|
274
|
+
? `Task #${taskId} is STRANDED — a dependency was abandoned and can never satisfy:`
|
|
275
|
+
: `Task #${taskId} can't be claimed yet — these dependencies are not shipped:`);
|
|
276
|
+
for (const dep of d.deps || []) {
|
|
277
|
+
const flag = dead.has(String(dep.id)) ? ' ← ABANDONED: this edge will never satisfy' : '';
|
|
278
|
+
lines.push(` - #${dep.id} [${dep.status}] ${dep.title}${flag}`);
|
|
279
|
+
}
|
|
280
|
+
lines.push(d.hint || deadDepHint(d.deps, taskId) || 'Ship those first (or release them) and try again.');
|
|
266
281
|
return { lines, exit: 1 };
|
|
267
282
|
}
|
|
268
283
|
if (code === 'OPEN_BLOCKER') {
|
package/src/module-api.js
CHANGED
|
@@ -993,8 +993,12 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
|
|
|
993
993
|
// landed since 1.19.281 with no explicit bump. run 32526392955. (task 1002620)
|
|
994
994
|
// 1.19.283 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
995
995
|
// landed since 1.19.282 with no explicit bump. run 32527313879. (task 1002620)
|
|
996
|
+
// 1.19.284 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
997
|
+
// landed since 1.19.283 with no explicit bump. run 32531111199. (task 1002620)
|
|
998
|
+
// 1.19.285 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
999
|
+
// landed since 1.19.284 with no explicit bump. run 32531963073. (task 1002620)
|
|
996
1000
|
// ---------------------------------------------------------------------------
|
|
997
|
-
const CORE_VERSION = '1.19.
|
|
1001
|
+
const CORE_VERSION = '1.19.285'; // CI auto-patch carrier (ADR 0161); see changelog above
|
|
998
1002
|
|
|
999
1003
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
1000
1004
|
// Usage: const log = api.logger('dev-box'); log.info('mounted');
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
// tests/dead_deps.mjs — the DEAD dependency edge (task 1003153).
|
|
2
|
+
//
|
|
3
|
+
// A dependency on an ABANDONED task can never satisfy, because an abandoned task
|
|
4
|
+
// can never ship. Both gates treat it as pending anyway (unsatisfiedDeps'
|
|
5
|
+
// `<> 'shipped'`, and task_is_fully_unblocked's `= 'shipped'`), so the dependent
|
|
6
|
+
// is STRANDED: it never auto-promotes and can never be claimed.
|
|
7
|
+
//
|
|
8
|
+
// OWNER RULING (2026-08-21): the gates STAY strict — a cut prerequisite must force
|
|
9
|
+
// a deliberate re-scope, not silently release dependent work. So these tests pin
|
|
10
|
+
// the two properties that ruling makes load-bearing:
|
|
11
|
+
// 1. the refusal is HONEST (never "ship those first" for a task that can't ship)
|
|
12
|
+
// 2. the situation is OBSERVABLE (a detector, so it can't strand work silently)
|
|
13
|
+
//
|
|
14
|
+
// Run: node tests/dead_deps.mjs
|
|
15
|
+
|
|
16
|
+
import { strict as assert } from 'node:assert';
|
|
17
|
+
import { createRequire } from 'node:module';
|
|
18
|
+
import { readFileSync } from 'node:fs';
|
|
19
|
+
import { makeRunner } from './helpers.mjs';
|
|
20
|
+
|
|
21
|
+
process.env.NODE_ENV = 'test';
|
|
22
|
+
const require = createRequire(import.meta.url);
|
|
23
|
+
const { splitDeadDeps, deadDepHint, findStrandedTasks, DEAD_DEP_STATUSES, LIVE_STATUSES } =
|
|
24
|
+
require('../modules/lifecycle/dead-deps.js');
|
|
25
|
+
const { formatClaimFailure } = require('../scripts/gds/claim.js');
|
|
26
|
+
const { test, summary } = makeRunner();
|
|
27
|
+
|
|
28
|
+
const dep = (id, status, kind = 'task') => ({ kind, id, title: `t${id}`, status });
|
|
29
|
+
|
|
30
|
+
// ---- the split: only a task-kind edge can be DEAD ----
|
|
31
|
+
|
|
32
|
+
await test('splitDeadDeps: an abandoned TASK edge is dead; a pending one is not', () => {
|
|
33
|
+
const { dead, pending } = splitDeadDeps([dep(1, 'abandoned'), dep(2, 'ready'), dep(3, 'active')]);
|
|
34
|
+
assert.deepEqual(dead.map((d) => d.id), [1]);
|
|
35
|
+
assert.deepEqual(pending.map((d) => d.id), [2, 3]);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
await test('splitDeadDeps: an unsatisfied CRITERION or unachieved GOAL is pending, never dead', () => {
|
|
39
|
+
// Those can still resolve — only a cut task is terminal-unsatisfiable.
|
|
40
|
+
const { dead } = splitDeadDeps([
|
|
41
|
+
{ kind: 'criterion', id: 9, status: 'unsatisfied' },
|
|
42
|
+
{ kind: 'goal', id: 8, status: 'open' },
|
|
43
|
+
]);
|
|
44
|
+
assert.equal(dead.length, 0);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
await test('splitDeadDeps: tolerates the shapes the two callers actually pass', () => {
|
|
48
|
+
assert.equal(splitDeadDeps(null).dead.length, 0, 'null is not a crash');
|
|
49
|
+
assert.equal(splitDeadDeps([]).dead.length, 0);
|
|
50
|
+
// unsatisfiedDeps names the column `state`; the route body names it `status`.
|
|
51
|
+
assert.equal(splitDeadDeps([{ kind: 'task', id: 1, state: 'abandoned' }]).dead.length, 1);
|
|
52
|
+
// a kind-less row (a bare task dep) still classifies
|
|
53
|
+
assert.equal(splitDeadDeps([{ id: 1, status: 'abandoned' }]).dead.length, 1);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
await test('exactly one status is dead today, and it matches the tasks.status CHECK', () => {
|
|
57
|
+
assert.deepEqual([...DEAD_DEP_STATUSES], ['abandoned'],
|
|
58
|
+
"shipped is the satisfying end; abandoned is the other terminal end. A new terminal status must be added here deliberately.");
|
|
59
|
+
assert.ok(!DEAD_DEP_STATUSES.has('dropped'), "'dropped' is NOT a valid tasks.status (migration 012) — do not add it");
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
// ---- the hint: never impossible advice ----
|
|
63
|
+
|
|
64
|
+
await test('deadDepHint: null when every unmet dep is genuinely pending (callers keep their wording)', () => {
|
|
65
|
+
assert.equal(deadDepHint([dep(2, 'ready'), dep(3, 'backlog')]), null);
|
|
66
|
+
assert.equal(deadDepHint([]), null);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
await test('deadDepHint: names the dead edge, calls it stranded, and never says "ship"', () => {
|
|
70
|
+
const h = deadDepHint([dep(77, 'abandoned')], 42);
|
|
71
|
+
assert.match(h, /task 77/, 'names the dead dependency');
|
|
72
|
+
assert.match(h, /ABANDONED/, 'names the reason in the open');
|
|
73
|
+
assert.match(h, /STRANDED/, 'says stranded, not waiting');
|
|
74
|
+
assert.match(h, /top-priority/i, 'the owner ruling: report it as top priority to resolve');
|
|
75
|
+
assert.ok(!/\bship\b/i.test(h), 'must never advise shipping a task that can never ship');
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
await test('deadDepHint: gives BOTH real remedies, concretely — drop the edge or restore the dep', () => {
|
|
79
|
+
const h = deadDepHint([dep(77, 'abandoned')], 42);
|
|
80
|
+
assert.match(h, /DELETE \/api\/bongos\/dependencies/, 'the drop remedy names the real route');
|
|
81
|
+
assert.match(h, /"from_id":42/, 'the 4-tuple carries the DEPENDENT id');
|
|
82
|
+
assert.match(h, /"to_id":77/, 'and the dead dep id');
|
|
83
|
+
assert.match(h, /POST \/api\/bongos\/tasks\/77\/promote/, 'the restore remedy is the promote route (db.restoreTask)');
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
await test('deadDepHint: a mixed set reports the dead edge AND still accounts for genuine pending work', () => {
|
|
87
|
+
const h = deadDepHint([dep(77, 'abandoned'), dep(88, 'ready'), dep(99, 'active')], 42);
|
|
88
|
+
assert.match(h, /task 77/);
|
|
89
|
+
assert.match(h, /2 other dependencies are still genuinely pending/);
|
|
90
|
+
assert.ok(!/task 88/.test(h), 'pending deps are counted, not re-listed (the list is already printed)');
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
// ---- the CLI: the exact string this task exists to kill ----
|
|
94
|
+
|
|
95
|
+
await test('claim.js DEPS_NOT_SHIPPED: an abandoned dep no longer gets "Ship those first"', () => {
|
|
96
|
+
const r = formatClaimFailure({
|
|
97
|
+
status: 409,
|
|
98
|
+
data: { error: { code: 'DEPS_NOT_SHIPPED', message: 'deps', details: { deps: [dep(77, 'abandoned')] } } },
|
|
99
|
+
}, 42);
|
|
100
|
+
const out = r.lines.join('\n');
|
|
101
|
+
assert.equal(r.exit, 1);
|
|
102
|
+
assert.ok(!/Ship those first/.test(out), 'THE REGRESSION: impossible advice for a task that can never ship');
|
|
103
|
+
assert.match(out, /STRANDED/, 'the header states the real situation');
|
|
104
|
+
assert.match(out, /← ABANDONED: this edge will never satisfy/, 'the dead row is flagged inline');
|
|
105
|
+
assert.match(out, /#77/, 'still lists the dep');
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
await test('claim.js DEPS_NOT_SHIPPED: an all-pending set keeps the original wording', () => {
|
|
109
|
+
const out = formatClaimFailure({
|
|
110
|
+
status: 409,
|
|
111
|
+
data: { error: { code: 'DEPS_NOT_SHIPPED', message: 'deps', details: { deps: [dep(88, 'ready')] } } },
|
|
112
|
+
}, 42).lines.join('\n');
|
|
113
|
+
assert.match(out, /can't be claimed yet — these dependencies are not shipped/);
|
|
114
|
+
assert.match(out, /Ship those first/, 'correct advice when the dep genuinely can still ship');
|
|
115
|
+
assert.ok(!/ABANDONED/.test(out));
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
await test('claim.js prefers the SERVER hint when present (one wording, not two)', () => {
|
|
119
|
+
const out = formatClaimFailure({
|
|
120
|
+
status: 409,
|
|
121
|
+
data: { error: { code: 'DEPS_NOT_SHIPPED', message: 'deps', details: { deps: [dep(77, 'abandoned')], hint: 'SERVER-SAID-THIS' } } },
|
|
122
|
+
}, 42).lines.join('\n');
|
|
123
|
+
assert.match(out, /SERVER-SAID-THIS/);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
// ---- the route: the same decision, wired ----
|
|
127
|
+
|
|
128
|
+
await test('routes/claims.js wires the shared hint onto DEPS_NOT_SHIPPED and rides dead_deps in the body', () => {
|
|
129
|
+
const src = readFileSync(new URL('../modules/lifecycle/routes/claims.js', import.meta.url), 'utf8');
|
|
130
|
+
assert.match(src, /require\('\.\.\/dead-deps\.js'\)/, 'the route consumes the ONE definition');
|
|
131
|
+
assert.match(src, /err\.code === 'DEPS_NOT_SHIPPED'\) \{[\s\S]{0,220}deadDepHint\(err\.deps/, 'the hint is attached on that branch');
|
|
132
|
+
assert.match(src, /err\.dead_deps = splitDeadDeps/, 'the machine-readable set rides the body for other clients');
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
// ---- the detector: observable, so it can never strand silently again ----
|
|
136
|
+
|
|
137
|
+
await test('findStrandedTasks: a LIVE task gated by an abandoned dep is stranded', () => {
|
|
138
|
+
const out = findStrandedTasks([
|
|
139
|
+
{ id: 5, status: 'ready', title: 'live one', blocked_by: [dep(77, 'abandoned')] },
|
|
140
|
+
]);
|
|
141
|
+
assert.equal(out.length, 1);
|
|
142
|
+
assert.equal(out[0].task_id, 5);
|
|
143
|
+
assert.deepEqual(out[0].dead_deps.map((d) => d.id), [77]);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
await test('findStrandedTasks: a TERMINAL dependent is NOT stranded — nothing is waiting', () => {
|
|
147
|
+
// This is exactly why the live corpus reports zero while 17 abandoned tasks
|
|
148
|
+
// still carry dependents: whole chains were cut together.
|
|
149
|
+
const out = findStrandedTasks([
|
|
150
|
+
{ id: 6, status: 'abandoned', blocked_by: [dep(77, 'abandoned')] },
|
|
151
|
+
{ id: 7, status: 'shipped', blocked_by: [dep(77, 'abandoned')] },
|
|
152
|
+
]);
|
|
153
|
+
assert.equal(out.length, 0);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
await test('findStrandedTasks: every live status counts, and a healthy dep never flags', () => {
|
|
157
|
+
for (const s of LIVE_STATUSES) {
|
|
158
|
+
assert.equal(findStrandedTasks([{ id: 1, status: s, blocked_by: [dep(77, 'abandoned')] }]).length, 1, `${s} must count as live`);
|
|
159
|
+
}
|
|
160
|
+
assert.equal(findStrandedTasks([{ id: 1, status: 'ready', blocked_by: [dep(77, 'shipped')] }]).length, 0);
|
|
161
|
+
assert.equal(findStrandedTasks([{ id: 1, status: 'ready', blocked_by: [] }]).length, 0);
|
|
162
|
+
assert.equal(findStrandedTasks(null).length, 0, 'null is not a crash');
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
await test('audit-deps exposes the --stranded mode and abandon.js reports what a cut strands', () => {
|
|
166
|
+
const audit = readFileSync(new URL('../scripts/gds/audit-deps.js', import.meta.url), 'utf8');
|
|
167
|
+
assert.match(audit, /--stranded/, 'the mode is documented in the usage header');
|
|
168
|
+
assert.match(audit, /findStrandedTasks/, 'and uses the shared core, not a second copy');
|
|
169
|
+
const abandon = readFileSync(new URL('../scripts/gds/abandon.js', import.meta.url), 'utf8');
|
|
170
|
+
assert.match(abandon, /TOP PRIORITY/, 'cutting a depended-on task reports the strand at cut time');
|
|
171
|
+
assert.match(abandon, /LIVE_STATUSES/, 'and shares the live-status definition with the detector');
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
summary();
|
package/tests/ship_card.mjs
CHANGED
|
@@ -342,5 +342,84 @@ test('html ribbon: confirmed shows shipping bold-current + confirmed checked-don
|
|
|
342
342
|
assert.match(html, /ti-check[^<]*<\/i> completed<\/span>/); // a done step, checked
|
|
343
343
|
});
|
|
344
344
|
|
|
345
|
+
// ---- task 1001730: the GUARANTEED one-line confirmation ----------------------
|
|
346
|
+
// show_widget answers "Content rendered and shown to the user" whether or not the
|
|
347
|
+
// client actually painted, and CLI stdout is not reliably shown to the user
|
|
348
|
+
// either — so neither the widget nor the monospace card is a deterministic
|
|
349
|
+
// confirmation. The [otb-confirm] line, relayed as message text, is the floor.
|
|
350
|
+
// Owner-reported 2026-07-02: a ship whose card silently failed to paint left them
|
|
351
|
+
// asking "did it land?" on work that was already shipped + merged.
|
|
352
|
+
|
|
353
|
+
test('confirmLine: every stage yields one line naming the stage and the task', () => {
|
|
354
|
+
for (const stage of ['claimed', 'review', 'confirmed', 'shipped', 'failed']) {
|
|
355
|
+
const line = card.confirmLine(card.buildCardModel({ ...shippedInput, stage, postGradeStatus: undefined }));
|
|
356
|
+
assert.ok(!line.includes('\n'), `${stage} must be exactly one line`);
|
|
357
|
+
assert.match(line, /task 1266/, `${stage} names the task id`);
|
|
358
|
+
assert.ok(line.length > 10 && line.length < 260, `${stage} stays a glanceable line (got ${line.length})`);
|
|
359
|
+
}
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
test('confirmLine: a shipped line carries the facts the owner asks for ("did it land?")', () => {
|
|
363
|
+
const line = card.confirmLine(card.buildCardModel(shippedInput));
|
|
364
|
+
assert.match(line, /^shipped: task 1266/, 'leads with the outcome, not chrome');
|
|
365
|
+
assert.match(line, /deploy live/, 'says it actually deployed — the commit rides in this row');
|
|
366
|
+
assert.match(line, /credits/, 'carries the credit outcome');
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
test('confirmLine: never repeats a word already inside the value ("credits +38 credits")', () => {
|
|
370
|
+
const line = card.confirmLine(card.buildCardModel(shippedInput));
|
|
371
|
+
assert.ok(!/credits\s+\+?\d[^·]*credits/.test(line), `duplicated key word: ${line}`);
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
test('confirmLine: token usage is deliberately excluded — it is not part of did-it-land', () => {
|
|
375
|
+
const line = card.confirmLine(card.buildCardModel(shippedInput));
|
|
376
|
+
assert.ok(!/usage|\$/i.test(line), `spend must not ride the confirmation line: ${line}`);
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
test('confirmLine: a failed grade is stated as such, never dressed up as success', () => {
|
|
380
|
+
const line = card.confirmLine(card.buildCardModel({ ...shippedInput, postGradeStatus: 'completed' }));
|
|
381
|
+
assert.match(line, /needs fixes/);
|
|
382
|
+
assert.ok(!/^shipped/.test(line));
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
test('confirmLine: degenerate input never throws (a summary must not break a CLI action)', () => {
|
|
386
|
+
assert.equal(typeof card.confirmLine(null), 'string');
|
|
387
|
+
assert.equal(typeof card.confirmLine({}), 'string');
|
|
388
|
+
assert.equal(typeof card.confirmLine({ rows: null, taskId: 5 }), 'string');
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
test('emitCard: ALWAYS emits [otb-confirm] — widgets on, widgets off, and before the widget', () => {
|
|
392
|
+
const model = card.buildCardModel(shippedInput);
|
|
393
|
+
for (const widgetsEnabled of [true, false]) {
|
|
394
|
+
const lines = [];
|
|
395
|
+
card.emitCard(model, 1266, { log: (s) => lines.push(String(s)), widgetsEnabled });
|
|
396
|
+
const out = lines.join('\n');
|
|
397
|
+
assert.match(out, /\[otb-confirm\] shipped: task 1266/, `widgetsEnabled=${widgetsEnabled} must still confirm`);
|
|
398
|
+
if (widgetsEnabled) {
|
|
399
|
+
const ci = out.indexOf('[otb-confirm]');
|
|
400
|
+
const hi = out.indexOf('[otb-card-html]');
|
|
401
|
+
assert.ok(ci >= 0 && hi >= 0 && ci < hi, 'the confirmation is emitted BEFORE the widget marker, so a later failure cannot lose it');
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
test('emitCard: the widget directive no longer tells the agent to go silent', () => {
|
|
407
|
+
const lines = [];
|
|
408
|
+
card.emitCard(card.buildCardModel(shippedInput), 1266, { log: (s) => lines.push(String(s)) });
|
|
409
|
+
const directive = lines.find((l) => l.startsWith('[otb-card] render'));
|
|
410
|
+
assert.ok(directive, 'the render directive is still emitted');
|
|
411
|
+
assert.ok(!/then stop — no prose recap/.test(directive),
|
|
412
|
+
'THE REGRESSION: "then stop — no prose recap" is what made a silent non-paint leave the owner with nothing');
|
|
413
|
+
assert.match(directive, /AND relay this one line as text/, 'it now requires the text confirmation too');
|
|
414
|
+
assert.match(directive, /No other prose recap/, 'while still forbidding a full recap');
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
test('the ship + claim skills both instruct relaying [otb-confirm]', () => {
|
|
418
|
+
for (const p of ['../.claude/skills/builder-ship/SKILL.md', '../.claude/skills/builder-claim/SKILL.md']) {
|
|
419
|
+
const md = fs.readFileSync(new URL(p, import.meta.url), 'utf8');
|
|
420
|
+
assert.match(md, /otb-confirm/, `${p} must teach the guaranteed line`);
|
|
421
|
+
}
|
|
422
|
+
});
|
|
423
|
+
|
|
345
424
|
console.log(`\nship_card: ${passed} passed, ${failed} failed`);
|
|
346
425
|
process.exit(failed ? 1 : 0);
|