@bongos/core 1.19.282 → 1.19.284

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/.bongos-core.json CHANGED
@@ -2,22 +2,22 @@
2
2
  "artifact": "bongos-core",
3
3
  "manifest_schema": 1,
4
4
  "generator": "scripts/gds/package-core.js",
5
- "core_version": "1.19.282",
6
- "core_contract": "1.19.282",
7
- "source_commit": "06b7fb133710f33da97b1acf2ae417fe5ef6dfdf",
5
+ "core_version": "1.19.284",
6
+ "core_contract": "1.19.284",
7
+ "source_commit": "89eca5541c7cbfd95d598c46ba3a46f3ddc20827",
8
8
  "source_ref": "HEAD",
9
- "built_at": "2026-08-21T21:00:51.271Z",
9
+ "built_at": "2026-08-21T22:01:22.829Z",
10
10
  "redaction": {
11
11
  "model": "docs-redacted+functional-verbatim",
12
12
  "docs_redacted": 319,
13
13
  "agent_docs_stubbed": 22,
14
- "functional_verbatim": 1380,
14
+ "functional_verbatim": 1382,
15
15
  "rules": 3,
16
16
  "gate_literals": 3,
17
17
  "gate": "passed"
18
18
  },
19
- "file_count": 1721,
20
- "tree_sha256": "beb62a53ae222fa7eef0b86c08c3ab454e41a66cfee904bd98c8a92ac8518d2e",
19
+ "file_count": 1723,
20
+ "tree_sha256": "271e5d408254a1834d4f24731b48a02ae7867dcfa154fbef7e3f1c7a98581bc1",
21
21
  "files": [
22
22
  {
23
23
  "path": ".claude/skills/blocker-review/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": "e02abac52a82fb02603dd92074d4ebd060ec03195803d704b021614806ef00fe"
3855
+ "sha256": "b67f879dea205dd442685df384b8e8060a01b8caa34efc74e31253e556acfb25"
3851
3856
  },
3852
3857
  {
3853
3858
  "path": "modules/lifecycle/routes/dependencies.js",
@@ -4502,12 +4507,12 @@
4502
4507
  {
4503
4508
  "path": "package-lock.json",
4504
4509
  "mode": "0000644",
4505
- "sha256": "c81721f73d06dd3daab990c030c868a4cc63b463e4bf53722f82f7e7bd64e4ef"
4510
+ "sha256": "d95061f94ca54af0ae3fb5631e87f8df10516b82c5f79c9214adb741ad6b6e02"
4506
4511
  },
4507
4512
  {
4508
4513
  "path": "package.json",
4509
4514
  "mode": "0000644",
4510
- "sha256": "7be55d325cccc29714cdfc5e19acf3cd463e2dc58cdee6b3e8c8c7c4e08bece3"
4515
+ "sha256": "958156249f9977ba6e0fa7584290663dc05f01627651b985050c030c42e37b9f"
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": "226e266390bd7eac3c10526557427a3b1e7916749ad64dda2a7163c4fac43a74"
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": "6392f1b445685b1ec8de134191301ab475ee7d4fc3a9557494a9723d18fa3bcf"
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": "3e830a3e319c4b67d87547ae4ad2d647ee3ce74725c84d7f64788c094f0974c7"
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": "b19f1a794e05fa5029e67696eed1cf9f84e80453702ad9db3902e4080d40e877"
6160
+ "sha256": "3408611b1d5426f7cfa78a23db5309455664b4ac644804932e0524b9f29097a5"
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",
@@ -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);
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.282",
3
+ "version": "1.19.284",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.282",
9
+ "version": "1.19.284",
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.282",
3
+ "version": "1.19.284",
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",
@@ -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); });
@@ -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
- lines.push(`Task #${taskId} can't be claimed yet — these dependencies are not shipped:`);
264
- for (const dep of d.deps || []) lines.push(` - #${dep.id} [${dep.status}] ${dep.title}`);
265
- lines.push('Ship those first (or release them) and try again.');
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
@@ -991,8 +991,12 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
991
991
  // landed since 1.19.280 with no explicit bump. run 32525071763. (task 1002620)
992
992
  // 1.19.282 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
993
993
  // landed since 1.19.281 with no explicit bump. run 32526392955. (task 1002620)
994
+ // 1.19.283 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
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)
994
998
  // ---------------------------------------------------------------------------
995
- const CORE_VERSION = '1.19.282'; // CI auto-patch carrier (ADR 0161); see changelog above
999
+ const CORE_VERSION = '1.19.284'; // CI auto-patch carrier (ADR 0161); see changelog above
996
1000
 
997
1001
  // A namespaced logger so a module's log lines are attributable + consistent.
998
1002
  // 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();