@bongos/core 1.19.799 → 1.19.801

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,11 +2,11 @@
2
2
  "artifact": "bongos-core",
3
3
  "manifest_schema": 1,
4
4
  "generator": "scripts/gds/package-core.js",
5
- "core_version": "1.19.799",
6
- "core_contract": "1.19.799",
7
- "source_commit": "85b2cf74b5fa2c392c59c47b0b877e9b912503ac",
5
+ "core_version": "1.19.801",
6
+ "core_contract": "1.19.801",
7
+ "source_commit": "9cb40ed355c3f8bb4d4b67fbc6e6ba939793827a",
8
8
  "source_ref": "HEAD",
9
- "built_at": "2026-09-17T01:33:26.348Z",
9
+ "built_at": "2026-09-17T02:11:49.566Z",
10
10
  "redaction": {
11
11
  "model": "docs-redacted+functional-verbatim",
12
12
  "docs_redacted": 487,
@@ -17,7 +17,7 @@
17
17
  "gate": "passed"
18
18
  },
19
19
  "file_count": 2699,
20
- "tree_sha256": "6435ea324232356a39b076f0f83fad40beaa96485c1f9e9b3a38fea29feda830",
20
+ "tree_sha256": "2587f0458e02facb77f933eab7710e8b69181347c171c7cc65600840dc02b6ce",
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": "9320b9a04d36d3169e5a01e4fb5dc595fe84ef292541307fd17974a283b4c509"
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": "4ad2853bc510a9ce3bfa2264734afb42eb41f75a28c0a51f639d48038e62e440"
2535
+ "sha256": "3b499ff68308c4d7cab67302718a4c853209e02543f016b4ff710c94436e98a2"
2536
2536
  },
2537
2537
  {
2538
2538
  "path": "docs/modules-contract.md",
@@ -5512,12 +5512,12 @@
5512
5512
  {
5513
5513
  "path": "modules/lifecycle/db-claims.js",
5514
5514
  "mode": "0000644",
5515
- "sha256": "e2b9e236090bc1936bed98a2487e0db9ab10e3c543597ababd68e664a396d8f3"
5515
+ "sha256": "1c250de70282c19f66324f9203d0899eb874c8d59f14af940a004a235bf9d1b9"
5516
5516
  },
5517
5517
  {
5518
5518
  "path": "modules/lifecycle/db-deps-criteria.js",
5519
5519
  "mode": "0000644",
5520
- "sha256": "2fefa2efa05b2a63ee510345180c04d211d5068c8bc82c050392dac3d02cfac2"
5520
+ "sha256": "76ffab3052ce0b48d0b78451a2e34d5cd3c5300410f0856b4a6667cbe708ba2b"
5521
5521
  },
5522
5522
  {
5523
5523
  "path": "modules/lifecycle/db-goals.js",
@@ -5567,7 +5567,7 @@
5567
5567
  {
5568
5568
  "path": "modules/lifecycle/dead-deps.js",
5569
5569
  "mode": "0000644",
5570
- "sha256": "e379969ef290aab09abeef50f2e4996e2df2fa16e6d4ea9fbd268e23518b3147"
5570
+ "sha256": "c9f7aa89a4b1e9a201189608ff8ebc4910ee6ca19be33ce681f2d32cedb0b39d"
5571
5571
  },
5572
5572
  {
5573
5573
  "path": "modules/lifecycle/done-when.js",
@@ -7712,12 +7712,12 @@
7712
7712
  {
7713
7713
  "path": "package-lock.json",
7714
7714
  "mode": "0000644",
7715
- "sha256": "1b8fa8ffcdeac7490e95f849daa69ef5580ffce9da1eed6d3434990e29dc957d"
7715
+ "sha256": "c276ff38b81a024e50fce58a6f52594a7927a2dfddd50665cd6dd75df09ad73c"
7716
7716
  },
7717
7717
  {
7718
7718
  "path": "package.json",
7719
7719
  "mode": "0000644",
7720
- "sha256": "4dd1a1bfaad4b7ff8382bd13fdd0331f54536a84fdc928f7f9b97ad2b88e60ac"
7720
+ "sha256": "e2e2a9e07886e3e8a7c77787d04ed7e8160fd682b1d4aace12914f93a93b358f"
7721
7721
  },
7722
7722
  {
7723
7723
  "path": "public-docs/index.html",
@@ -8007,7 +8007,7 @@
8007
8007
  {
8008
8008
  "path": "scripts/gds/claude-materialize.js",
8009
8009
  "mode": "0000644",
8010
- "sha256": "8cf425aed2764fc043c8a8757f178f7514a907567d559e7f0b11e091f33b66e5"
8010
+ "sha256": "53411af3a058d5fb22029a2ae923ef259fb1a45b1e39c20fd28e004a8c4d369f"
8011
8011
  },
8012
8012
  {
8013
8013
  "path": "scripts/gds/claude-md-template.js",
@@ -8977,7 +8977,7 @@
8977
8977
  {
8978
8978
  "path": "scripts/gds/ship-honesty.js",
8979
8979
  "mode": "0000644",
8980
- "sha256": "3ab4816fdd43d589cda73698a514f8e23103be166c1650fd24beeee9f529bb5b"
8980
+ "sha256": "f9cafe9726d6cedc398dc9898e33a9d2d5eca32354661b58d7f820e195d59137"
8981
8981
  },
8982
8982
  {
8983
8983
  "path": "scripts/gds/ship-io.js",
@@ -8997,7 +8997,7 @@
8997
8997
  {
8998
8998
  "path": "scripts/gds/ship-merge.js",
8999
8999
  "mode": "0000644",
9000
- "sha256": "b602b4cf8f0e66610991237e1cb7ade9cd5c14659b155ce3512268d40ae670eb"
9000
+ "sha256": "99401464922cc545681786e59ebdc5ad35ec5578f7562c7986e458af797e85ab"
9001
9001
  },
9002
9002
  {
9003
9003
  "path": "scripts/gds/ship-preflight-steps.js",
@@ -9162,7 +9162,7 @@
9162
9162
  {
9163
9163
  "path": "scripts/gds/upgrade.js",
9164
9164
  "mode": "0000644",
9165
- "sha256": "eba999956e81e6637af527334825d2c1b5ad9b4af165320eb011e28718c5df99"
9165
+ "sha256": "6fb938914f50a8273640ca2bad871b3a3077102e36877c861e344235338ebef2"
9166
9166
  },
9167
9167
  {
9168
9168
  "path": "scripts/gds/validate-design.js",
@@ -9602,7 +9602,7 @@
9602
9602
  {
9603
9603
  "path": "src/module-api.js",
9604
9604
  "mode": "0000644",
9605
- "sha256": "4ad4e006b2f2916be9183776e9c33d49839b632b6a199180f23f99fb683f8e09"
9605
+ "sha256": "de45933f1b58f0a41a36b2de49fc55f758c34e42b48c2599d2dd4d1afad9a88d"
9606
9606
  },
9607
9607
  {
9608
9608
  "path": "src/module-loader/catalog.js",
@@ -10502,7 +10502,7 @@
10502
10502
  {
10503
10503
  "path": "tests/dead_deps.mjs",
10504
10504
  "mode": "0000644",
10505
- "sha256": "07d2825c812a10cd1d39182883652dec15d360d3645dd38023a00a0593d36ddc"
10505
+ "sha256": "65adddca46ac1ed3b4239e110a4018fd4b4ff113e1fbb1991742a7f4bcb28f16"
10506
10506
  },
10507
10507
  {
10508
10508
  "path": "tests/dead_feature_claim.mjs",
@@ -12927,7 +12927,7 @@
12927
12927
  {
12928
12928
  "path": "tests/ship_cannot_lie.mjs",
12929
12929
  "mode": "0000644",
12930
- "sha256": "55e94ed59bc1bd19e4a3dd0e9a9a415dde659c5d71af0dd827d1b288e66de3c7"
12930
+ "sha256": "55adaf198cd21d4f267f1f50c8e31e176d4b2f037a87abc5a7d10e5df0d123d2"
12931
12931
  },
12932
12932
  {
12933
12933
  "path": "tests/ship_card.mjs",
@@ -13362,7 +13362,7 @@
13362
13362
  {
13363
13363
  "path": "tests/upgrade_persist_pin.mjs",
13364
13364
  "mode": "0000644",
13365
- "sha256": "d30debaa35407998dcdd1949f9bcbf45c09b4a001d22cfdea9541b5bdbaa1274"
13365
+ "sha256": "2e1fd181c580070c3625035d39d038c137dbf04c6c8e74132977b877c0d73c76"
13366
13366
  },
13367
13367
  {
13368
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.
@@ -2072,5 +2072,9 @@ is load-bearing: the script throws rather than guess if it is missing, and
2072
2072
  landed since 1.19.797 with no explicit bump. run 35170229509. (task 1002620)
2073
2073
  1.19.799 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
2074
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)
2077
+ 1.19.801 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
2078
+ landed since 1.19.800 with no explicit bump. run 35173442594. (task 1002620)
2075
2079
  ---------------------------------------------------------------------------
2076
2080
  ```
@@ -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');
@@ -216,7 +220,7 @@ async function claimTask({ taskId, builderId, worktreeName, headSha, creatorSess
216
220
  if (unmetDeps.length > 0) {
217
221
  throw {
218
222
  code: 'DEPS_NOT_SHIPPED',
219
- deps: unmetDeps.map((r) => ({ kind: r.kind, id: r.id, title: r.title, status: r.state })),
223
+ deps: unmetDeps.map(depRefusalRow),
220
224
  };
221
225
  }
222
226
 
@@ -573,7 +577,7 @@ async function claimTasksBatch({ taskIds, builderId, worktreePrefix, creatorSess
573
577
  failures.push({
574
578
  task_id: t.id,
575
579
  reason: 'DEPS_NOT_SHIPPED',
576
- deps: external.map((d) => ({ kind: d.kind, id: d.id, title: d.title, status: d.state })),
580
+ deps: external.map(depRefusalRow),
577
581
  });
578
582
  }
579
583
  }
@@ -916,7 +920,7 @@ async function validateClaimBatch({ taskIds, builderId }) {
916
920
  if (external.length > 0) {
917
921
  push(id, {
918
922
  reason: 'DEPS_NOT_SHIPPED',
919
- deps: external.map((d) => ({ kind: d.kind, id: d.id, title: d.title, status: d.state })),
923
+ deps: external.map(depRefusalRow),
920
924
  });
921
925
  }
922
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
- SELECT 'task' AS kind, x.id, x.title, x.status::text AS state
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
- // 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.
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 { dead, pending: all.filter((d) => !deadIds.has(String(d && d.id))) };
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 ${tuple}) `
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
- + (pending.length ? ` ${pending.length} other dependenc${pending.length === 1 ? 'y is' : 'ies are'} still genuinely pending.` : '');
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) => ({ id: d.id, title: d.title, status: d.status })),
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.799",
3
+ "version": "1.19.801",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.799",
9
+ "version": "1.19.801",
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.799",
3
+ "version": "1.19.801",
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
  }
@@ -249,9 +249,66 @@ function staleSummaryNotice(taskId, storedSummary) {
249
249
  ];
250
250
  }
251
251
 
252
+ // ---- a --db-only that cannot apply must SAY so (task 1003959) ---------------
253
+ //
254
+ // THE FIFTH SILENCE, and the only one that is not about prose: a flag the
255
+ // operator typed, dropped without a word. zeroCommitVerdict(commitsAhead, dbOnly)
256
+ // consults dbOnly ONLY at zero commits ahead — with commits ahead the verdict is
257
+ // 'has-commits' and the run falls through to the ordinary merge/PR path, which
258
+ // never receives dbOnly at all.
259
+ //
260
+ // The BEHAVIOUR is right and stays: a branch carrying real commits must be
261
+ // merged, not waved through as an empty ship. What was missing is the sentence
262
+ // saying so, and its absence is expensive out of proportion to its size — task
263
+ // 1003229 was filed against --db-only and survived for days on the strength of
264
+ // it. The reporter's flag was dropped here, their PR then failed to land for an
265
+ // unrelated reason (the reconciler, task 1002723), and with nothing in the
266
+ // output distinguishing an honoured flag from an ignored one, every visible sign
267
+ // pointed at --db-only.
268
+ //
269
+ // So: a notice, not a refusal and not a behaviour change — the same posture as
270
+ // staleSummaryNotice above, and testable for the same reason. It names the path
271
+ // actually being taken, because "your flag was ignored" without "and here is
272
+ // what is happening instead" only moves the confusion.
273
+ //
274
+ // Returns [] when the flag was HONOURED (zero commits ahead) or never passed —
275
+ // a notice that fires on the happy path is noise.
276
+ function dbOnlyIgnoredNotice(taskId, { dbOnly = false, commitsAhead = '0', branch = '', deployMode = 'laptop' } = {}) {
277
+ if (!dbOnly) return [];
278
+ // The one case the flag really covers. gitOk returns null when the count could
279
+ // not be read, which zeroCommitVerdict already treats as 'has-commits' — keep
280
+ // that reading rather than inventing a second opinion about the same branch.
281
+ if (String(commitsAhead) === '0') return [];
282
+
283
+ // Branch names and a git count both reach a TERMINAL here. git forbids control
284
+ // characters in a refname, so this strips nothing in practice — but the notice
285
+ // exists to be believed, and one that could be repainted from below by its own
286
+ // subject would be the wrong thing to have built.
287
+ const clean = (s) => String(s == null ? '' : s).replace(/[\u0000-\u001f\u007f-\u009f]/g, ' ').replace(/\s+/g, ' ').trim();
288
+ const head = clean(branch) || 'this branch';
289
+ const n = Number(clean(commitsAhead));
290
+ const ahead = Number.isInteger(n) && n > 0
291
+ ? `${n} commit${n === 1 ? '' : 's'} ahead of origin/main`
292
+ : 'ahead of origin/main';
293
+ // Name the real path, not a generic one — 'ci' lands by PR and never touches
294
+ // the operator's main worktree; 'laptop' merges and deploys from this machine.
295
+ const instead = deployMode === 'ci'
296
+ ? `pushing ${head} and landing it by PR, with CI deploying`
297
+ : `pushing ${head}, merging it into main here and deploying`;
298
+
299
+ return [
300
+ `⚠ --db-only was passed, but ${head} is ${ahead} — the flag does not apply and is being IGNORED.`,
301
+ ` Shipping the normal way instead: ${instead}. Nothing is skipped and nothing is stranded.`,
302
+ ' --db-only only covers a ship with NOTHING to merge — work written straight to the prod DB.',
303
+ ' If you expected a zero-commit ship, this is the branch disagreeing; see what is on it:',
304
+ ' git log --oneline origin/main..HEAD',
305
+ ` Then re-ship when the branch is what you meant: node scripts/gds/ship.js ${taskId}`,
306
+ ];
307
+ }
308
+
252
309
  module.exports = {
253
310
  resolveProse, markShipProgress, fatalRecoveryLines, smokeGateMessages,
254
- proseFlagPresent, regradeNotesRefusal, staleSummaryNotice,
311
+ proseFlagPresent, regradeNotesRefusal, staleSummaryNotice, dbOnlyIgnoredNotice,
255
312
  _resetForTest: () => { shipProgress = { stage: 'preflight', taskId: null }; },
256
313
  _peekForTest: () => shipProgress,
257
314
  };
@@ -26,9 +26,15 @@ const {
26
26
  landed,
27
27
  } = require('./ship-land.js');
28
28
  const { runSmoke } = require('./ship-grade.js');
29
+ const { dbOnlyIgnoredNotice } = require('./ship-honesty.js');
29
30
  const { currentBranch, mainWorktreeBusy, regenerateApiClient, regenerateApiDocs, regenerateDiagrams, regenerateFileMap, regenerateRepoMap, regenerateSessionIndex } = require('./ship-regen.js');
30
31
  const { REPO_ROOT } = require('./ship-state.js');
31
32
 
33
+ // zeroCommitVerdict — dbOnly is deliberately consulted ONLY at zero commits
34
+ // ahead: a branch carrying real commits must be merged, whatever flag was typed.
35
+ // That asymmetry is correct and is not a bug — but it means an explicitly-passed
36
+ // --db-only can be DROPPED, so mergeLocally pairs this with dbOnlyIgnoredNotice
37
+ // (task 1003959) rather than letting the drop happen in silence.
32
38
  function zeroCommitVerdict(commitsAhead, dbOnly) {
33
39
  if (commitsAhead !== '0') return 'has-commits';
34
40
  return dbOnly ? 'db-only' : 'refuse';
@@ -141,6 +147,11 @@ async function mergeLocally(taskId, valueSummary, { dbOnly = false } = {}) {
141
147
  }
142
148
  const commitsAhead = gitOk('git rev-list origin/main..HEAD --count');
143
149
  const verdict = zeroCommitVerdict(commitsAhead, dbOnly);
150
+ // task 1003959: the verdict above drops an explicitly-passed --db-only when the
151
+ // branch has commits. Say so HERE — before the push, the merge and the deploy —
152
+ // so the operator learns their flag did not apply while the run can still be
153
+ // stopped, not from a PR that landed differently than they expected.
154
+ for (const line of dbOnlyIgnoredNotice(taskId, { dbOnly, commitsAhead, branch, deployMode })) console.log(line);
144
155
  if (verdict === 'refuse') {
145
156
  // task 391 (idea #37): the old code short-circuited ANY zero-commit branch
146
157
  // straight to shipped — silently advancing a claim with empty/wrong touches[]
@@ -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, regenArtifacts, run) || []).length > 0);
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: regenArtifacts,
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.799'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
74
+ const CORE_VERSION = '1.19.801'; // 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');
@@ -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();
@@ -21,6 +21,10 @@
21
21
  // (4) --regrade must not silently REUSE the failed attempt's summary. The same
22
22
  // silence as (3) from the other side: prose the builder never handed over,
23
23
  // published as though they had (task 1003693).
24
+ //
25
+ // (5) An explicitly-passed --db-only that cannot apply must say so. The same
26
+ // silence again, and the first one that is not about prose: a flag the
27
+ // operator typed, dropped by the merge leg without a word (task 1003959).
24
28
  import { strict as assert } from 'node:assert';
25
29
  import { test } from 'node:test';
26
30
  import { createRequire } from 'node:module';
@@ -30,7 +34,7 @@ const require = createRequire(import.meta.url);
30
34
  const { argText } = require('../scripts/gds/cli-lib.js');
31
35
  const {
32
36
  fatalRecoveryLines, markShipProgress, _peekForTest,
33
- proseFlagPresent, regradeNotesRefusal, staleSummaryNotice,
37
+ proseFlagPresent, regradeNotesRefusal, staleSummaryNotice, dbOnlyIgnoredNotice,
34
38
  } = require('../scripts/gds/ship-honesty.js');
35
39
 
36
40
  // Injected IO so no case touches the filesystem.
@@ -371,3 +375,88 @@ test('the notice is wired into the regrade path, before any grade spend', () =>
371
375
  const preflight = code.indexOf('preflight(claimLike');
372
376
  assert.ok(preflight !== -1 && call < preflight, 'and prints before the preflight and the panel');
373
377
  });
378
+ // ── (5) a --db-only that cannot apply says so (task 1003959) ─────────────────
379
+ //
380
+ // The same silence as (3) and (4) from a third side, and the only one that is
381
+ // not about prose: a flag the operator TYPED, dropped without a word.
382
+ // zeroCommitVerdict consults dbOnly only at zero commits ahead, so with commits
383
+ // ahead the ordinary merge/PR path runs and never sees the flag. That path is
384
+ // correct; the silence is what let task 1003229 sit open against --db-only while
385
+ // the real stall was elsewhere (task 1002723).
386
+
387
+ test('the dropped --db-only notice names the flag, the branch and the path taken instead', () => {
388
+ const lines = dbOnlyIgnoredNotice(1003959, {
389
+ dbOnly: true, commitsAhead: '3', branch: 'task-1003959', deployMode: 'ci',
390
+ });
391
+ const text = lines.join('\n');
392
+ // The whole deliverable: the operator must be able to tell an honoured flag
393
+ // from an ignored one by reading the output.
394
+ assert.match(text, /--db-only/, 'names the flag that was dropped');
395
+ assert.match(text, /IGNORED/, 'and says plainly that it was');
396
+ assert.match(text, /3 commits ahead of origin\/main/, 'names why it does not apply');
397
+ assert.match(text, /task-1003959/, 'and which branch disagreed');
398
+ // "your flag was ignored" without "and here is what happens instead" only
399
+ // moves the confusion one step.
400
+ assert.match(text, /landing it by PR/, 'names the path actually being taken');
401
+ assert.match(text, /nothing is stranded/i, 'and says the ship is not in danger');
402
+ // A notice that names no next step is noise with a warning sign on it.
403
+ assert.match(text, /git log --oneline origin\/main\.\.HEAD/, 'names a runnable way to check the branch');
404
+ assert.match(text, /ship\.js 1003959/, 'and the re-ship with the real task id');
405
+ });
406
+
407
+ test('the notice names the LAPTOP path when that is the one being taken', () => {
408
+ // Naming a PR land on a box that is about to merge and deploy locally would be
409
+ // a second untruth told while correcting the first.
410
+ const laptop = dbOnlyIgnoredNotice(7, { dbOnly: true, commitsAhead: '1', branch: 'task-7', deployMode: 'laptop' }).join('\n');
411
+ assert.match(laptop, /merging it into main here and deploying/, 'the local merge chain');
412
+ assert.ok(!/by PR/.test(laptop), 'and not the PR path it is not taking');
413
+ assert.match(laptop, /1 commit ahead/, 'singular reads as English');
414
+ });
415
+
416
+ test('the notice is silent when the flag was honoured, or never passed', () => {
417
+ // Zero commits ahead IS the case --db-only covers: the flag applied, nothing
418
+ // was dropped, and warning there would train the builder to skip the notice.
419
+ assert.deepEqual(dbOnlyIgnoredNotice(1, { dbOnly: true, commitsAhead: '0', branch: 'task-1' }), []);
420
+ for (const ahead of ['0', '4', null, undefined]) {
421
+ assert.deepEqual(dbOnlyIgnoredNotice(1, { dbOnly: false, commitsAhead: ahead }), [], 'no flag, no notice');
422
+ }
423
+ assert.deepEqual(dbOnlyIgnoredNotice(1), [], 'and a bare call cannot fire it');
424
+ });
425
+
426
+ test('an unreadable commit count still produces a truthful notice', () => {
427
+ // gitOk returns null when the count cannot be read, and zeroCommitVerdict
428
+ // already reads that as 'has-commits'. The notice must not invent "null
429
+ // commits ahead" — it drops the number and keeps the claim.
430
+ const lines = dbOnlyIgnoredNotice(9, { dbOnly: true, commitsAhead: null, branch: null });
431
+ const text = lines.join('\n');
432
+ assert.ok(lines.length > 0, 'an unreadable count is still an ignored flag');
433
+ assert.ok(!/null|NaN|undefined/.test(text), 'and never says so out loud');
434
+ assert.match(text, /this branch is ahead of origin\/main/, 'falls back to the claim without a count');
435
+
436
+ // The one place a branch name reaches a TERMINAL. git forbids control
437
+ // characters in a refname, so this strips nothing in practice — but a notice
438
+ // that could be repainted by its own subject is the wrong thing to have built.
439
+ const esc = dbOnlyIgnoredNotice(11, { dbOnly: true, commitsAhead: '2', branch: 'task\u001b[31m-11\u0007' }).join(' ');
440
+ assert.ok(!/[\u0000-\u001f\u007f-\u009f]/.test(esc), 'terminal control characters are stripped before display');
441
+ });
442
+
443
+ test('the notice is wired into the merge leg, before the push', () => {
444
+ // The (1) lesson again: a helper nothing calls is not a fix. And the ORDER is
445
+ // load-bearing for the same reason the --notes refusal's is — a warning read
446
+ // after the branch has already been pushed and merged cannot stop that run.
447
+ const src = readFileSync(new URL('../scripts/gds/ship-merge.js', import.meta.url), 'utf8');
448
+ assert.match(src, /require\('\.\/ship-honesty\.js'\)/, 'ship-merge imports the notice');
449
+ const merge = src.slice(src.indexOf('async function mergeLocally'));
450
+ const code = merge.replace(/\/\/[^\n]*/g, ''); // comments describe, they do not print
451
+
452
+ const call = code.indexOf('dbOnlyIgnoredNotice(taskId,');
453
+ assert.ok(call !== -1, 'mergeLocally emits the notice');
454
+ // It must be fed the real values, not a re-derived opinion about the branch.
455
+ assert.match(code.slice(call, call + 200), /dbOnly, commitsAhead, branch, deployMode/,
456
+ 'from the same values the verdict was computed from');
457
+ // Every path out of the verdict is downstream of it: the refusal, the empty
458
+ // ship, the ci land and the laptop push.
459
+ for (const after of ["=== 'refuse'", "=== 'ci'", "gitArgvOk(['push'"]) {
460
+ assert.ok(call < code.indexOf(after), `prints before ${after}`);
461
+ }
462
+ });
@@ -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
+ });