@bongos/core 1.19.510 → 1.19.511

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.510",
6
- "core_contract": "1.19.510",
7
- "source_commit": "bf4b829a050e1e59d80f4669559d33aff1ca021e",
5
+ "core_version": "1.19.511",
6
+ "core_contract": "1.19.511",
7
+ "source_commit": "3a3d1626dee489511d9d444d6cd3c78620a5fffd",
8
8
  "source_ref": "HEAD",
9
- "built_at": "2026-09-03T18:34:17.437Z",
9
+ "built_at": "2026-09-03T18:50:51.590Z",
10
10
  "redaction": {
11
11
  "model": "docs-redacted+functional-verbatim",
12
12
  "docs_redacted": 428,
@@ -17,7 +17,7 @@
17
17
  "gate": "passed"
18
18
  },
19
19
  "file_count": 2403,
20
- "tree_sha256": "50673e10faee37607acac515c28140b876d8838c656e49115440b31f3d2ae42a",
20
+ "tree_sha256": "61291b79de3b99190857d7ed49c124e41ac765c57c9ad792cf8cfa1e62f002cb",
21
21
  "files": [
22
22
  {
23
23
  "path": ".claude/skills/blocker-review/SKILL.md",
@@ -1672,7 +1672,7 @@
1672
1672
  {
1673
1673
  "path": "docs/adr/0233-a-copy-proposal-is-a-task-carrying-a-patch.md",
1674
1674
  "mode": "0000644",
1675
- "sha256": "69db7f69b30eb87a8b8e921ea664344faf7b953c7a85ce9d788282a0e181dbd1"
1675
+ "sha256": "ab2970017b4776ee7d0be1ee22933d86123f57b8935a1f1bb0844e5f367fae07"
1676
1676
  },
1677
1677
  {
1678
1678
  "path": "docs/adr/0234-idea-routing-capture-time-promotion-landing-matrix-homeless-inbox.md",
@@ -2582,7 +2582,7 @@
2582
2582
  {
2583
2583
  "path": "docs/module-api-changelog.md",
2584
2584
  "mode": "0000644",
2585
- "sha256": "8a8c8b3fa575b33ef7221af26b78a3cbe649f3cbf6b23e398da4d44e2a826096"
2585
+ "sha256": "2e35f3f9c7c2c961b5c8274d20202664564cb33d59b046ef4d2e190c15c05afe"
2586
2586
  },
2587
2587
  {
2588
2588
  "path": "docs/modules-contract.md",
@@ -3697,7 +3697,7 @@
3697
3697
  {
3698
3698
  "path": "modules/copy-desk/proposals.js",
3699
3699
  "mode": "0000644",
3700
- "sha256": "b05aefb7fb7f965b92389986b6e53991823abb8c1e8a03e88637ef3d065100c0"
3700
+ "sha256": "de674674cf2f80fb36569cad67be4f1ba3a94e938b690b8f1d0c96446df346bf"
3701
3701
  },
3702
3702
  {
3703
3703
  "path": "modules/copy-desk/registry.js",
@@ -3707,7 +3707,7 @@
3707
3707
  {
3708
3708
  "path": "modules/copy-desk/routes/copy-desk.js",
3709
3709
  "mode": "0000644",
3710
- "sha256": "1d371bc5e850f12a1e261eb1d4409cb4462943ad0020ae629a3858d2f7063235"
3710
+ "sha256": "7231f021bb49bd78cb247a6d8e8a0f6e9256ade52f2f9bb1438b54b6ddf400f8"
3711
3711
  },
3712
3712
  {
3713
3713
  "path": "modules/copy-desk/tests/copy_flags.mjs",
@@ -3722,7 +3722,7 @@
3722
3722
  {
3723
3723
  "path": "modules/copy-desk/tests/copy_proposals.mjs",
3724
3724
  "mode": "0000644",
3725
- "sha256": "86b7f54494e94407745e46290263ed4463fdb10e5d320276bcb199667ca52097"
3725
+ "sha256": "e435042dea0436e0f4fa7f2a95b16b8647ff0723290741e26776e76c9d1c6973"
3726
3726
  },
3727
3727
  {
3728
3728
  "path": "modules/copy-desk/tests/copy_queue.mjs",
@@ -5237,7 +5237,7 @@
5237
5237
  {
5238
5238
  "path": "modules/lifecycle/conflict-resolve.js",
5239
5239
  "mode": "0000644",
5240
- "sha256": "b9e3c44a7f68ec728bb6a59122dd43f87d64931b776bab3cbbba9aad2be78f28"
5240
+ "sha256": "1a281a4233b3805550a05ed6bd38b163ab18db658d48675f055b90d2ba722a16"
5241
5241
  },
5242
5242
  {
5243
5243
  "path": "modules/lifecycle/criterion-suggest.js",
@@ -6237,7 +6237,7 @@
6237
6237
  {
6238
6238
  "path": "modules/public-landing/public/assets/cosmos.css",
6239
6239
  "mode": "0000644",
6240
- "sha256": "40013b1065a87cbe6c5916bf20c4971ce8bac5811d22993a70b322cf9baf2d36"
6240
+ "sha256": "bb2fa6d0a1c87aeb3e08038faf1d3ae0f06a1a41f9fe6d83f357146bc7e73045"
6241
6241
  },
6242
6242
  {
6243
6243
  "path": "modules/public-landing/public/assets/verro-medallion.png",
@@ -7242,12 +7242,12 @@
7242
7242
  {
7243
7243
  "path": "package-lock.json",
7244
7244
  "mode": "0000644",
7245
- "sha256": "473153ee377e541a11f94f2b68604221d7cc56b75ce228745a71c094a6db32be"
7245
+ "sha256": "0c5b45784b5275f6c58d456a275a390b6ef111efbcc1fb7653750120a8a84981"
7246
7246
  },
7247
7247
  {
7248
7248
  "path": "package.json",
7249
7249
  "mode": "0000644",
7250
- "sha256": "24b60ab686b6e4edfefbbaecd284c9916d5090f8754a6020cba487a66e9c1441"
7250
+ "sha256": "2605b735d7f5d07c4eb993212d4901e7043138693d4d2bb56492f9fb18c0baf0"
7251
7251
  },
7252
7252
  {
7253
7253
  "path": "public-docs/index.html",
@@ -7532,7 +7532,7 @@
7532
7532
  {
7533
7533
  "path": "scripts/gds/copy-apply.js",
7534
7534
  "mode": "0000644",
7535
- "sha256": "4b99eabcfcb8c6c472a2161427fc22a43b3208b5fbfc1ffe097e8f1926a44216"
7535
+ "sha256": "05326c8016794742acc10a95ae0f1c7b630581231a7f0f86aef449a8c17c2c40"
7536
7536
  },
7537
7537
  {
7538
7538
  "path": "scripts/gds/copy-inventory.js",
@@ -7762,7 +7762,7 @@
7762
7762
  {
7763
7763
  "path": "scripts/gds/git-merge-regen.js",
7764
7764
  "mode": "0000644",
7765
- "sha256": "f9a391ac6f32b2d5ca939b48186b6d9cb7aa171554652a5b5afbc4a408aae203"
7765
+ "sha256": "f6f9c93faeb589d07105027655e42bd842d8d766119812b16bd534d58aa50816"
7766
7766
  },
7767
7767
  {
7768
7768
  "path": "scripts/gds/go-live.js",
@@ -8917,7 +8917,7 @@
8917
8917
  {
8918
8918
  "path": "src/module-api.js",
8919
8919
  "mode": "0000644",
8920
- "sha256": "27eb92e570f6572c875f3c0ab7cffee29fca5c49395167fe4ed7a1d8e466a32e"
8920
+ "sha256": "ac752bde3af7292c3b4a1857045652f7d3badf0d1bbdf02cf3fd47f2ac8c40fc"
8921
8921
  },
8922
8922
  {
8923
8923
  "path": "src/module-loader/catalog.js",
@@ -9512,7 +9512,7 @@
9512
9512
  {
9513
9513
  "path": "tests/conflict_resolve.mjs",
9514
9514
  "mode": "0000644",
9515
- "sha256": "69f4e40c66e49e4d2c58f1f41ec1c515b2cb44891e40b6192ed898484d10671d"
9515
+ "sha256": "7ad7be450b9350e7888f075944888ddf2a0e2d57a23568f410a9ff17bd340dec"
9516
9516
  },
9517
9517
  {
9518
9518
  "path": "tests/connections_api.mjs",
@@ -9907,7 +9907,7 @@
9907
9907
  {
9908
9908
  "path": "tests/git_merge_regen.mjs",
9909
9909
  "mode": "0000644",
9910
- "sha256": "2d3574914c1f9c843396f498a8e68c2fd8ec2a5bc574c98f64dc2505aa1ccba8"
9910
+ "sha256": "f4e7ab5eec2d13404080c408c0a70819c4b398b38fa61474b9c4cc57069d119b"
9911
9911
  },
9912
9912
  {
9913
9913
  "path": "tests/github_push_ancestry.mjs",
@@ -11112,7 +11112,7 @@
11112
11112
  {
11113
11113
  "path": "tests/projects_hub.mjs",
11114
11114
  "mode": "0000644",
11115
- "sha256": "2cdad6b12240d3556614aa0e77874f41651e59884f1b07bd9af664334eb90c20"
11115
+ "sha256": "712e02763f7e9f3e6f4acc17a5a757a4aee11e9234909dcf50923b85679e09e6"
11116
11116
  },
11117
11117
  {
11118
11118
  "path": "tests/projects_hub_pre_uat.mjs",
@@ -114,7 +114,12 @@ afterwards buys a port widening and a failure mode for a string.
114
114
  So the bare call, `node scripts/gds/copy-apply.js`, acts on **the task the caller is holding**.
115
115
  Holding no claim is a named refusal that says to claim it first. The command may only run under a
116
116
  claim — that is the whole feature — and a claim already names a task, so the constraint and the
117
- ergonomics are the same fact. `--task <id>` remains for someone holding several.
117
+ ergonomics are the same fact. `--task <id>` remains for someone holding several — and it is *checked*
118
+ against the same claim list the bare call reads, refusing `claim_not_held` for a task the caller does
119
+ not hold. It shipped unchecked (task 1003493 fixed it): a rule stated in this ADR, in the module's
120
+ `CLAUDE.md` and in the script's own header, enforced on one of its two paths, is the kind of claim
121
+ that rots. The one path with no claim to prove is `--patch-file`, a local JSON patch used by the
122
+ tests, which lands nothing a proposal task authorised.
118
123
 
119
124
  ### 7. Proposing is open to every rank, and lands in `backlog`
120
125
 
@@ -130,7 +135,16 @@ before anyone can claim it. Landing it `ready` would let anyone with an opinion
130
135
  ## Consequences
131
136
 
132
137
  - **The write surface of `copy-desk` is now three verbs, not two**, and `tests/copy_no_cms.mjs` says so
133
- in a diff a reviewer can see — which is exactly what R02 built that list for.
138
+ in a diff a reviewer can see — which is exactly what R02 built that list for. To be exact about what
139
+ that test proves (task 1003493): it pins that no route **in this module** can write a rendered
140
+ string, which is narrower than "no route can write a rendered string to production directly". That
141
+ is the right scope for the feature — the module is the write surface this ADR adds — but the wider
142
+ claim is not something a module-scoped test can carry, and reading it as if it were is how a
143
+ no-CMS guarantee quietly becomes an assumption.
144
+ - **A proposal lands only in the version being BUILT.** `proposals.resolveTargetVersion` returns the
145
+ `building` version or `null`, and the route refuses `no_open_version` — there is deliberately no
146
+ "first version in the list" fallback (task 1003493), because that row can be a shipped version, and
147
+ a proposal landing on sealed history is worse than a proposal that was not accepted.
134
148
  - **`scripts/gds/copy-apply.js` is the only thing in this feature that writes a surface file**, it is a
135
149
  command rather than a route, and the test asserts no file in the module's request path imports it.
136
150
  Naming it is fine and required: the task tells its claimer the exact line to run.
@@ -1457,5 +1457,7 @@ is load-bearing: the script throws rather than guess if it is missing, and
1457
1457
  landed since 1.19.508 with no explicit bump. run 33790542026. (task 1002620)
1458
1458
  1.19.510 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1459
1459
  landed since 1.19.509 with no explicit bump. run 33791237400. (task 1002620)
1460
+ 1.19.511 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1461
+ landed since 1.19.510 with no explicit bump. run 33792868990. (task 1002620)
1460
1462
  ---------------------------------------------------------------------------
1461
1463
  ```
@@ -260,8 +260,21 @@ function proposalSourceRef(patch) {
260
260
  return `copy-proposal-${patch.string_id}-${hash.toString(36)}`;
261
261
  }
262
262
 
263
+ // Which version a proposal's task lands in — the version currently BEING BUILT,
264
+ // and nothing else (task 1003493). There is deliberately no fallback: the route
265
+ // used to take `versions[0]` when nothing was building, which is whatever row
266
+ // the query ordered first and may be a shipped version — sealed history that
267
+ // cannot take new tasks. A proposal that lands on a closed version is worse than
268
+ // one that was not accepted, so the caller's answer here is a refusal, not a
269
+ // guess. Pure, so the rule is pinned in copy_proposals.mjs rather than only
270
+ // reachable through a live route.
271
+ function resolveTargetVersion(versions) {
272
+ return (versions || []).find((v) => v && v.status === 'building') || null;
273
+ }
274
+
263
275
  module.exports = {
264
276
  PROPOSAL_SCHEMA,
277
+ resolveTargetVersion,
265
278
  FENCE,
266
279
  PLACEHOLDER,
267
280
  MIN_WHY_CHARS,
@@ -391,9 +391,16 @@ module.exports = function buildCopyDeskRouter() {
391
391
  // how every other unattributed create-vector picks. `createTask` then
392
392
  // defaults the goal to that version's catch-all, so a copy change is
393
393
  // ordinary work on the board rather than a special case.
394
+ // No `|| versions[0]` fallback (task 1003493) — see resolveTargetVersion:
395
+ // with nothing building, the first row back is whatever the query ordered
396
+ // first, possibly a shipped version, which is sealed history.
394
397
  const versions = await lifecycle.listVersions();
395
- const building = (versions || []).find((v) => v.status === 'building') || (versions || [])[0];
396
- if (!building) return res.fail('no_open_version', 503);
398
+ const building = proposals.resolveTargetVersion(versions);
399
+ if (!building) {
400
+ return res.fail('no_open_version', 503, {
401
+ what_this_means: 'No version is currently being built, so there is nowhere for a copy change to land. Nothing was recorded.',
402
+ });
403
+ }
397
404
 
398
405
  const sourceRef = proposals.proposalSourceRef(patch);
399
406
  const existing = lifecycle.findTaskBySourceRef
@@ -275,5 +275,46 @@ await test('the applier re-checks the placeholder arithmetic against the real so
275
275
  assert.equal(r.code, 'placeholder_mismatch');
276
276
  });
277
277
 
278
+ // --- task 1003493: the two rules the grader found stated but not enforced ----
279
+
280
+ await test('--task proves the claim it names instead of standing in for one', () => {
281
+ const held = [{ task_id: '1003114' }, { task_id: 1003493 }];
282
+
283
+ const wrong = applier.claimGateFor(1003112, held);
284
+ assert.equal(wrong.ok, false);
285
+ assert.equal(wrong.code, 'claim_not_held');
286
+ assert.deepEqual(wrong.detail.held, ['1003114', '1003493'], 'the refusal names the claims you DO hold');
287
+ assert.ok(/claim.js 1003112/.test(wrong.detail.what_this_means), 'and the line that fixes it');
288
+
289
+ // A held claim passes, and an id given as a number matches a claim stored as
290
+ // a string — the ids arrive as both, and a type mismatch here would refuse
291
+ // the legitimate call, which is the failure mode that gets a check removed.
292
+ assert.equal(applier.claimGateFor(1003493, held).ok, true);
293
+ assert.equal(applier.claimGateFor('1003114', held).ok, true);
294
+
295
+ // Holding nothing at all cannot pass, whatever id is named.
296
+ assert.equal(applier.claimGateFor(1003114, []).ok, false);
297
+ assert.equal(applier.claimGateFor(1003114, undefined).ok, false);
298
+ });
299
+
300
+ await test('a proposal refuses rather than landing on an arbitrary version', () => {
301
+ const shipped = { id: 'BONGOS-V0', status: 'shipped' };
302
+ const building = { id: 'BONGOS-V1', status: 'building' };
303
+ const planning = { id: 'BONGOS-V2', status: 'planning' };
304
+
305
+ // The version being built wins wherever it sits in the list.
306
+ assert.equal(proposals.resolveTargetVersion([shipped, building, planning]).id, 'BONGOS-V1');
307
+ assert.equal(proposals.resolveTargetVersion([building]).id, 'BONGOS-V1');
308
+
309
+ // With nothing building there is NO fallback — not the first row, not a
310
+ // shipped version, not a planning one. null is what makes the route's
311
+ // no_open_version refusal reachable.
312
+ assert.equal(proposals.resolveTargetVersion([shipped, planning]), null);
313
+ assert.equal(proposals.resolveTargetVersion([shipped]), null);
314
+ assert.equal(proposals.resolveTargetVersion([]), null);
315
+ assert.equal(proposals.resolveTargetVersion(null), null);
316
+ assert.equal(proposals.resolveTargetVersion([null, undefined]), null);
317
+ });
318
+
278
319
  console.log(`\n${passed} passed, ${failed} failed`);
279
320
  process.exit(failed ? 1 : 0);
@@ -138,11 +138,13 @@ async function hasConflictMarkers(gitIn) {
138
138
  // resolveBranchConflicts — attempt to auto-resolve a generated-file / append-index
139
139
  // merge conflict on `branch` against origin/main, server-side. Returns a structured
140
140
  // result (never throws for the expected outcomes; throws only on misconfiguration):
141
- // { resolved: true, head_sha, branch } — merged + pushed
141
+ // { resolved: true, head_sha, branch, regenerated } — merged + pushed
142
142
  // { resolved: false, skipped: '<why>' [, conflicted: [...] ] } — left as-is
143
143
  // skipped reasons: disabled | unconfigured | already_up_to_date | clone_failed |
144
144
  // fetch_failed | checkout_failed | markers_remain | push_failed |
145
- // unresolvable_conflict (with conflicted[]).
145
+ // unresolvable_conflict (with conflicted[]) | generator_failed | commit_failed.
146
+ // `regenerated` says whether step 7b had to commit post-merge generator drift on top
147
+ // of the merge (task 1003546) — head_sha is that commit when it did.
146
148
  async function resolveBranchConflicts({ branch } = {}, deps = {}) {
147
149
  // Kill-switch: deps.disabled wins for tests; otherwise the env flag (matches the
148
150
  // publish-reconciler's PUBLISH_RECONCILER_DISABLED idiom).
@@ -163,6 +165,7 @@ async function resolveBranchConflicts({ branch } = {}, deps = {}) {
163
165
  const exec = deps.exec || defaultExec;
164
166
  const repoRoot = deps.repoRoot || REPO_ROOT;
165
167
  const register = deps.registerMergeDriver || registerMergeDriver;
168
+ const runGenerators = deps.runGenerators || defaultRunGenerators;
166
169
  const tmp = (deps.mkdtemp || (() => fs.mkdtempSync(path.join(os.tmpdir(), 'otb-resolve-'))))();
167
170
  const work = path.join(tmp, 'work');
168
171
  const git = (args, opts) => exec(['-C', work, ...args], opts);
@@ -225,6 +228,49 @@ async function resolveBranchConflicts({ branch } = {}, deps = {}) {
225
228
  // not our case (mergeIfGreen reported a conflict), so report a no-op.
226
229
  const after = (await git(['rev-parse', 'HEAD'])).stdout.trim();
227
230
  if (after === before) return { resolved: false, skipped: 'already_up_to_date' };
231
+ let head = after;
232
+
233
+ // 7b. REGENERATE ON THE MERGED TREE — the step that makes step 6 trustworthy
234
+ // (task 1003546). The `otb-regen` driver is invoked BY git, DURING the merge,
235
+ // and every artifact it rebuilds is produced by SCANNING THE WORKING TREE
236
+ // (copy-inventory.js, gen-repo-map.js, gen-file-map.js). At driver time that
237
+ // tree is still the PRE-merge (ours) tree, so the driver writes an artifact
238
+ // describing the branch, git then completes the merge underneath it, and the
239
+ // result is a clean merge — exit 0, no unmerged paths, no markers — carrying a
240
+ // generated file that matches NEITHER side nor the merged tree. Nothing
241
+ // downstream can catch it: it is only detectable by regenerating again.
242
+ //
243
+ // Measured on the real repo (task 1003546): two branches each shifting lines
244
+ // in modules/hall-ui/public/task-detail.js merge clean, and the merge commit's
245
+ // docs/copy-registry.json is byte-identical to ours — the other side's line
246
+ // numbers are silently DISCARDED (`"line": 464` where the merged tree says
247
+ // 466). That is the PR #536 / task 1003544 strand exactly. Task 1003543 had
248
+ // given these files the driver, which converted the loud CONFLICT they used to
249
+ // throw into this silent staleness — strictly harder to see.
250
+ //
251
+ // A line-numbered or aggregate-carrying artifact cannot be resolved from the
252
+ // three merge sides by construction; the only correct value is a fresh scan of
253
+ // the FINAL tree. So we rebuild here, after the merge commit exists, and
254
+ // commit any drift on top. Ordinary case is a no-op (the driver's guess was
255
+ // right, or nothing generated moved) and we push the merge alone.
256
+ const gen = await runGenerators({ work });
257
+ if (gen && gen.code !== 0) {
258
+ // A generator hard-error / a missing hand-written note (gen-file-map exit 2).
259
+ // We cannot certify the merged tree, and pushing an uncertifiable one is what
260
+ // stranded #536 — leave it, exactly the module's "any failure is a no-op for
261
+ // the strand" posture. The reconciler's attempt cap still escalates to a human.
262
+ return { resolved: false, skipped: 'generator_failed' };
263
+ }
264
+ const drift = (await git(['status', '--porcelain'])).stdout.trim();
265
+ if (drift) {
266
+ await git(['add', '-A']);
267
+ const c = await git([
268
+ 'commit', '-m',
269
+ 'chore(gds): regenerate generated artifacts from the merged tree (conflict-resolve self-heal, task 1003546)',
270
+ ]);
271
+ if (c.code !== 0) return { resolved: false, skipped: 'commit_failed' };
272
+ head = (await git(['rev-parse', 'HEAD'])).stdout.trim();
273
+ }
228
274
 
229
275
  // 8. Belt-and-braces: a clean `git merge` already implies no unmerged paths and
230
276
  // no markers, but assert both before we push anything.
@@ -238,7 +284,7 @@ async function resolveBranchConflicts({ branch } = {}, deps = {}) {
238
284
  const p = await git(['push', authUrl, `HEAD:refs/heads/${branch}`]);
239
285
  if (p.code !== 0) return { resolved: false, skipped: 'push_failed' };
240
286
 
241
- return { resolved: true, head_sha: after, branch };
287
+ return { resolved: true, head_sha: head, branch, regenerated: Boolean(drift) };
242
288
  } finally {
243
289
  try {
244
290
  (deps.rmtmp || ((d) => fs.rmSync(d, { recursive: true, force: true })))(tmp);
@@ -255,6 +301,16 @@ const GENERATOR_SCRIPTS = [
255
301
  'scripts/gds/gen-repo-map.js',
256
302
  'scripts/gds/gen-file-map.js',
257
303
  'scripts/gds/gen-session-index.js',
304
+ // task 1003546: copy-inventory.js writes docs/copy-registry.json +
305
+ // docs/copy-inventory.md, and its rows carry a `line` per copy string — so ANY
306
+ // merge that shifts lines in a scanned UI file re-stales it and `unit`
307
+ // (tests/copy_inventory.mjs) goes red on the merge ref. Its absence here did not
308
+ // merely fail to heal that: with the other three producing no diff, the caller
309
+ // returns `no_regen_diff`, whose documented meaning is "a REAL check failure, not
310
+ // staleness" — so copy-registry staleness was MISDIAGNOSED as a genuine failure,
311
+ // burned the attempt cap, and announceStrand flagged the task needs_rebase
312
+ // (PR #536 / task 1003544).
313
+ 'scripts/gds/copy-inventory.js',
258
314
  ];
259
315
 
260
316
  // Run the CLONE's generator scripts in WRITE mode against the merged working tree.
@@ -1501,7 +1501,9 @@ p.sec-p{font-size:var(--fs-lede);font-weight:500;color:var(--ink-soft);line-heig
1501
1501
  #view-new #wizSignin:hover{background:color-mix(in srgb,var(--ink) 88%,var(--bg));}
1502
1502
  /* the error alert: the error ink between two hairlines, no plate */
1503
1503
  #view-new .alert{background:none;border:0;border-top:1px solid var(--err);border-bottom:1px solid var(--err);border-radius:0;padding:12px 0;font-weight:400;}
1504
- @media (max-width:900px){
1504
+ /* one fold for the whole wizard: 840 is where the page's bands and ledger stack
1505
+ (cosmos's createSplit collapse), so the rail folds with them, not before them */
1506
+ @media (max-width:840px){
1505
1507
  #view-new .steps{grid-template-columns:repeat(3,1fr);gap:18px 10px;margin-bottom:30px;}
1506
1508
  #view-new .steps .snum{font-size:18px;}
1507
1509
  }
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.510",
3
+ "version": "1.19.511",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.510",
9
+ "version": "1.19.511",
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.510",
3
+ "version": "1.19.511",
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",
@@ -32,7 +32,7 @@
32
32
  //
33
33
  // Run: node scripts/gds/copy-apply.js (the task you are HOLDING — the normal call)
34
34
  // node scripts/gds/copy-apply.js --dry-run (say what would change, write nothing)
35
- // node scripts/gds/copy-apply.js --task 1234 (a named task, when you hold more than one)
35
+ // node scripts/gds/copy-apply.js --task 1234 (a claim you hold, when you hold more than one)
36
36
  // node scripts/gds/copy-apply.js --patch-file p.json (a local patch — used by the tests)
37
37
  //
38
38
  // WHY THE BARE FORM DEFAULTS TO YOUR CLAIM rather than taking an id. The task a
@@ -43,6 +43,13 @@
43
43
  // task. So the bare call reads the claim, and holding no claim is a refusal that
44
44
  // says to go and claim the task — the methodology's own rule, enforced by the
45
45
  // command instead of restated in its output.
46
+ //
47
+ // --task DOES NOT OPT OUT OF THAT (task 1003493). It selects which of your
48
+ // claims to act under when you hold several; it is checked against the same
49
+ // /me claim list the bare call reads, and refuses `claim_not_held` for a task
50
+ // you do not hold. The one path that never touches a task is --patch-file,
51
+ // which reads a local JSON patch and is the tests' entry point — it lands
52
+ // nothing a proposal task authorised, so there is no claim for it to prove.
46
53
 
47
54
  const fs = require('node:fs');
48
55
  const path = require('node:path');
@@ -208,13 +215,50 @@ function refuse(result) {
208
215
  return 2;
209
216
  }
210
217
 
218
+ // Every claim this session holds. One reader, so the bare call and --task are
219
+ // answered from the same source of truth — the shape of bug where a rule is
220
+ // enforced on one path and merely stated on the other (task 1003493).
221
+ async function activeClaims() {
222
+ const me = await apiCall('GET', '/api/gds/me');
223
+ if (!me.ok) return { ok: false, code: 'no_session', detail: { status: me.status, run: 'node scripts/gds/setup.js' } };
224
+ return { ok: true, claims: me.data.active_claims || (me.data.active_claim ? [me.data.active_claim] : []) };
225
+ }
226
+
227
+ // --task <id> names a task; this proves the caller actually HOLDS it. Without
228
+ // this the header's "may only be run under a claim" was true of the bare call
229
+ // and false of the named one — a rule stated three times in prose (here, ADR
230
+ // 0233, modules/copy-desk/CLAUDE.md) and enforced on one of two paths. The
231
+ // refusal names the claims you do hold, because the usual cause is a typo'd id.
232
+ // Pure so the gate is testable without a session: given the id asked for and
233
+ // the claims held, either accept or produce the named refusal.
234
+ function claimGateFor(taskId, claims) {
235
+ const wanted = String(taskId);
236
+ const held = (claims || []).map((c) => String(c.task_id));
237
+ if (held.includes(wanted)) return { ok: true, taskId: wanted };
238
+ return {
239
+ ok: false,
240
+ code: 'claim_not_held',
241
+ detail: {
242
+ task: wanted,
243
+ held,
244
+ what_this_means: `A copy change lands under a claim like every other change, and you do not hold one on #${wanted}. Claim it first (node scripts/gds/claim.js ${wanted}), then run this again.`,
245
+ },
246
+ };
247
+ }
248
+
249
+ async function claimOnTask(taskId) {
250
+ const held = await activeClaims();
251
+ if (!held.ok) return held;
252
+ return claimGateFor(taskId, held.claims);
253
+ }
254
+
211
255
  // The task this session is holding. Returns a named refusal rather than an id
212
256
  // when there is nothing to act under — "claim it first" is the correct answer,
213
257
  // not an inconvenience to route around.
214
258
  async function claimedTaskId() {
215
- const me = await apiCall('GET', '/api/gds/me');
216
- if (!me.ok) return { ok: false, code: 'no_session', detail: { status: me.status, run: 'node scripts/gds/setup.js' } };
217
- const claims = me.data.active_claims || (me.data.active_claim ? [me.data.active_claim] : []);
259
+ const held = await activeClaims();
260
+ if (!held.ok) return held;
261
+ const claims = held.claims;
218
262
  if (!claims.length) {
219
263
  return {
220
264
  ok: false,
@@ -271,6 +315,11 @@ async function main() {
271
315
  if (!held.ok) return cliExit(refuse(held));
272
316
  taskId = held.taskId;
273
317
  console.log(`copy-apply: the task you are holding — #${taskId}`);
318
+ } else if (taskId) {
319
+ // --task picks WHICH claim to act under; it never substitutes for holding one.
320
+ const held = await claimOnTask(taskId);
321
+ if (!held.ok) return cliExit(refuse(held));
322
+ taskId = held.taskId;
274
323
  }
275
324
 
276
325
  const loaded = await loadPatch({ taskId, patchFile });
@@ -335,4 +384,5 @@ module.exports = {
335
384
  locateOccurrence,
336
385
  reinjectInterpolations,
337
386
  applyPatchToSource,
387
+ claimGateFor,
338
388
  };
@@ -20,6 +20,24 @@
20
20
  // parts of nested docs; it succeeds ONLY if no conflict markers remain. A genuine
21
21
  // hand-written conflict falls through to a normal git conflict — it never invents a
22
22
  // resolution or clobbers a real edit.
23
+ //
24
+ // ⚠ THE DRIVER'S OUTPUT IS PROVISIONAL FOR ANY TREE-SCANNING ARTIFACT (task 1003546).
25
+ // Every rule below that rebuilds by SCANNING THE WORKING TREE — copy-inventory.js's
26
+ // registry pair, gen-repo-map's map + nested blocks, gen-file-map's sections — is
27
+ // invoked BY git, DURING the merge, when that tree is still the PRE-merge (ours)
28
+ // tree. So the driver writes an artifact describing the branch, git completes the
29
+ // merge underneath it, and the merge lands CLEAN (exit 0, no unmerged paths, no
30
+ // markers) carrying content that matches neither side nor the merged tree. Measured:
31
+ // two branches each shifting lines in one scanned UI file merge clean and the merge
32
+ // commit's docs/copy-registry.json is byte-identical to ours, silently dropping the
33
+ // other side's line numbers (PR #536 / task 1003544).
34
+ // It is still worth running — it clears the markers so the merge completes at all —
35
+ // but it is NOT the authority. Whoever OWNS the merge must regenerate afterwards, on
36
+ // the final tree, and commit the drift: the server does it in conflict-resolve.js
37
+ // step 7b, and a post-merge `resolveInWorktree` call (ship-preflight-steps.js) is
38
+ // correct for the same reason — by then git has written every cleanly-merged path.
39
+ // Do NOT add a rule here and assume the merge is thereby correct; a line-numbered or
40
+ // aggregate-carrying artifact cannot be resolved from the three merge sides at all.
23
41
 
24
42
  const fs = require('node:fs');
25
43
  const os = require('node:os');
package/src/module-api.js CHANGED
@@ -45,7 +45,7 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
45
45
  // there. scripts/gds/bump-version.js still rewrites the literal below; it appends
46
46
  // the entry to that file. Look for a version's history there, not here.
47
47
  // ---------------------------------------------------------------------------
48
- const CORE_VERSION = '1.19.510'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
48
+ const CORE_VERSION = '1.19.511'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
49
49
 
50
50
  // A namespaced logger so a module's log lines are attributable + consistent.
51
51
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
@@ -52,9 +52,21 @@ function mkExec(scn = {}) {
52
52
  if (cmd === 'checkout') return ret(scn.checkout ?? 0);
53
53
  if (cmd === 'rev-parse') {
54
54
  revCount += 1;
55
- const sha = revCount === 1 ? (scn.before ?? 'aaaaaaa') : (scn.after ?? 'bbbbbbb');
56
- return { code: 0, stdout: `${sha}\n`, stderr: '' };
55
+ // 1st = the pre-merge tip, 2nd = the merge commit, 3rd = the post-merge
56
+ // regeneration commit (task 1003546 step 7b), when there was drift to commit.
57
+ if (revCount === 1) return { code: 0, stdout: `${scn.before ?? 'aaaaaaa'}\n`, stderr: '' };
58
+ if (revCount === 2) return { code: 0, stdout: `${scn.after ?? 'bbbbbbb'}\n`, stderr: '' };
59
+ return { code: 0, stdout: `${scn.regenSha ?? scn.after ?? 'bbbbbbb'}\n`, stderr: '' };
57
60
  }
61
+ if (cmd === 'status') {
62
+ // --porcelain after step 7b's regeneration: a non-empty body = the merged tree
63
+ // disagreed with the generated artifacts the merge produced. Default OFF so the
64
+ // pre-1003546 cases keep asserting the plain merge-and-push path.
65
+ const drift = scn.drift ?? false;
66
+ return { code: 0, stdout: drift ? ' M docs/copy-registry.json\n' : '', stderr: '' };
67
+ }
68
+ if (cmd === 'add') return ret(0);
69
+ if (cmd === 'commit') return ret(scn.commit ?? 0);
58
70
  if (cmd === 'merge' && a[1] === '--abort') return ret(0);
59
71
  if (cmd === 'merge') {
60
72
  if (scn.merge === 'conflict') { mergeOutcome = 'conflict'; return { code: 1, stdout: 'CONFLICT (content)', stderr: '' }; }
@@ -79,8 +91,13 @@ function mkExec(scn = {}) {
79
91
  function mkDeps(scn = {}, over = {}) {
80
92
  const reg = [];
81
93
  const exec = mkExec(scn);
94
+ const genCalls = [];
82
95
  return {
83
96
  exec,
97
+ // task 1003546: resolveBranchConflicts regenerates on the MERGED tree before it
98
+ // pushes, so it takes the same runGenerators seam refreshStaleGenerated does.
99
+ // Stubbed for every case — the real one would spawn node against a fake clone.
100
+ runGenerators: over.runGenerators || (async (opts) => { genCalls.push(opts); return { code: scn.genCode ?? 0 }; }),
84
101
  githubPush: {
85
102
  isConfigured: () => (over.configured === undefined ? true : over.configured),
86
103
  isSafeBranch: (b) => (over.safeBranch === undefined ? (typeof b === 'string' && b.length > 0 && !b.startsWith('-')) : over.safeBranch),
@@ -94,6 +111,7 @@ function mkDeps(scn = {}, over = {}) {
94
111
  rmtmp: () => {},
95
112
  _exec: exec,
96
113
  _reg: reg,
114
+ _gen: genCalls,
97
115
  };
98
116
  }
99
117
 
@@ -377,7 +395,11 @@ test('1915 defaultRunGenerators strips *_INSTANCE_ROOT from the child env and pi
377
395
  const leaked = Object.keys(call.opts.env || {}).filter((k) => /_INSTANCE_ROOT$/.test(k));
378
396
  assert.deepEqual(leaked, [], 'no *_INSTANCE_ROOT key survives into the generator child env');
379
397
  assert.ok(call.opts.env.PATH, 'benign env (PATH) is preserved so node still resolves');
380
- assert.ok(String(call.argv[0]).startsWith('/tmp/clone-xyz'), 'the generator script path is under the clone');
398
+ // Separator-normalised: defaultRunGenerators builds this with path.join, which
399
+ // emits backslashes on win32 — the bare startsWith made this assertion fail on
400
+ // every Windows builder for a reason unrelated to what it tests (task 1003546).
401
+ assert.ok(String(call.argv[0]).replace(/\\/g, '/').startsWith('/tmp/clone-xyz'),
402
+ 'the generator script path is under the clone');
381
403
  }
382
404
  } finally {
383
405
  if (prev.g === undefined) delete process.env.GDS_INSTANCE_ROOT; else process.env.GDS_INSTANCE_ROOT = prev.g;
@@ -455,8 +477,100 @@ test('1915 defaultRunGenerators reports the worst child exit code (0 all-clean;
455
477
  assert.equal(badGen.code, 2, 'a single failing generator surfaces its non-zero code');
456
478
  });
457
479
 
480
+ // ── task 1003546: the merged tree is regenerated BEFORE the push ────────────────
481
+ // The `otb-regen` driver runs DURING the merge and rebuilds by scanning the working
482
+ // tree, which at that moment is still the PRE-merge tree. So a merge can come out
483
+ // clean — exit 0, no unmerged paths, no markers — carrying a generated artifact that
484
+ // describes neither side nor the merged tree, and nothing downstream can see it.
485
+ // resolveBranchConflicts must therefore regenerate on the FINAL tree and commit the
486
+ // drift before pushing (PR #536 / task 1003544: the registry kept the branch's line
487
+ // numbers and `unit` stayed red through the heal).
488
+
489
+ test('1003546 clean merge → generators run on the merged tree, drift committed, head_sha is the regen commit', async () => {
490
+ const deps = mkDeps({ merge: 'clean', before: 'oldsha1', after: 'mergesha', drift: true, regenSha: 'regensha' });
491
+ const r = await cr.resolveBranchConflicts({ branch: 'claude/wt-cr1' }, deps);
492
+ assert.equal(r.resolved, true);
493
+ assert.equal(deps._gen.length, 1, 'generators ran exactly once against the merged tree');
494
+ assert.ok(ran(deps._exec, 'commit'), 'post-merge generator drift is committed');
495
+ assert.equal(r.regenerated, true, 'the result reports that a regeneration was needed');
496
+ assert.equal(r.head_sha, 'regensha', 'head_sha is the regen commit, not the bare merge commit');
497
+ });
498
+
499
+ test('1003546 the regeneration happens AFTER the merge and its commit BEFORE the push (ordering is the whole point)', async () => {
500
+ const order = [];
501
+ const deps = mkDeps({ merge: 'clean', drift: true });
502
+ const baseExec = deps.exec;
503
+ deps.exec = async (args) => {
504
+ const a = args[0] === '-C' ? args.slice(2) : args;
505
+ if (['merge', 'commit', 'push'].includes(a[0])) order.push(a[0]);
506
+ return baseExec(args);
507
+ };
508
+ deps.runGenerators = async () => { order.push('__generators__'); return { code: 0 }; };
509
+ const r = await cr.resolveBranchConflicts({ branch: 'claude/wt-cr2' }, deps);
510
+ assert.equal(r.resolved, true);
511
+ assert.deepEqual(order, ['merge', '__generators__', 'commit', 'push'],
512
+ 'a pre-merge regeneration (or a push before the regen commit) is exactly the bug');
513
+ });
514
+
515
+ test('1003546 no post-merge drift → the merge alone is pushed, nothing extra committed', async () => {
516
+ const deps = mkDeps({ merge: 'clean', after: 'mergesha', drift: false });
517
+ const r = await cr.resolveBranchConflicts({ branch: 'claude/wt-cr3' }, deps);
518
+ assert.equal(r.resolved, true);
519
+ assert.equal(r.regenerated, false);
520
+ assert.equal(r.head_sha, 'mergesha', 'the merge commit is the head when regen is a no-op');
521
+ assert.ok(!ran(deps._exec, 'commit'), 'nothing is committed when the merge was already correct');
522
+ assert.ok(ran(deps._exec, 'push'), 'the resolved branch still lands');
523
+ });
524
+
525
+ test('1003546 a failing generator never pushes an uncertifiable merged tree', async () => {
526
+ const deps = mkDeps({ merge: 'clean', drift: true, genCode: 2 });
527
+ const r = await cr.resolveBranchConflicts({ branch: 'claude/wt-cr4' }, deps);
528
+ assert.equal(r.resolved, false);
529
+ assert.equal(r.skipped, 'generator_failed');
530
+ assert.ok(!ran(deps._exec, 'push'), 'a tree we cannot certify is left for a human, never pushed');
531
+ });
532
+
533
+ test('1003546 a failing post-merge commit is reported, not pushed', async () => {
534
+ const deps = mkDeps({ merge: 'clean', drift: true, commit: 1 });
535
+ const r = await cr.resolveBranchConflicts({ branch: 'claude/wt-cr5' }, deps);
536
+ assert.equal(r.resolved, false);
537
+ assert.equal(r.skipped, 'commit_failed');
538
+ assert.ok(!ran(deps._exec, 'push'), 'no push without the regeneration commit');
539
+ });
540
+
541
+ // THE INVARIANT THAT WOULD HAVE CAUGHT #536 ON DAY ONE. Two hand-maintained lists
542
+ // name the same artifacts: REGENERABLE (what the merge driver claims to heal) and
543
+ // GENERATOR_SCRIPTS (what the server actually re-runs). copy-inventory.js was added
544
+ // to the first and not the second, so the copy registry was "covered" by the driver
545
+ // and regenerated by nobody. Pin them as one set, via each artifact's PRODUCER.
546
+ test('1003546 every REGENERABLE artifact has a producer, and every producer is in GENERATOR_SCRIPTS', async () => {
547
+ const regen = require('../scripts/gds/git-merge-regen.js');
548
+ const G = require('../scripts/gds/gen-repo-map.js');
549
+ const CI = require('../scripts/gds/copy-inventory.js');
550
+
551
+ // Derived from each generator's own exports where it publishes them, so this map
552
+ // cannot silently drift from the generator it names.
553
+ const PRODUCED_BY = new Map([
554
+ ['docs/repo-map.md', 'scripts/gds/gen-repo-map.js'],
555
+ ['docs/file-map.md', 'scripts/gds/gen-file-map.js'],
556
+ ['docs/session-log-index.md', 'scripts/gds/gen-session-index.js'],
557
+ [CI.REGISTRY_REL, 'scripts/gds/copy-inventory.js'],
558
+ [CI.REPORT_REL, 'scripts/gds/copy-inventory.js'],
559
+ ...G.NESTED_DIRS.map((d) => [`${d}/CLAUDE.md`, 'scripts/gds/gen-repo-map.js']),
560
+ ]);
561
+
562
+ for (const p of regen.REGENERABLE) {
563
+ const producer = PRODUCED_BY.get(p);
564
+ assert.ok(producer, `REGENERABLE knows ${p} but this test has no producer for it — name the generator that writes it`);
565
+ assert.ok(
566
+ cr.GENERATOR_SCRIPTS.includes(producer),
567
+ `${p} is healed by the merge driver but ${producer} is not in GENERATOR_SCRIPTS, so the server never re-runs it on the merged tree (the task 1003544 strand)`
568
+ );
569
+ }
570
+ });
571
+
458
572
  // Drain the microtask queue, then report + exit with the right code.
459
573
  setTimeout(() => {
460
- console.log(`\nconflict-resolve (task 1385 + 1915) tests: ${passed} passed, ${failed} failed`);
574
+ console.log(`\nconflict-resolve (task 1385 + 1915 + 1003546) tests: ${passed} passed, ${failed} failed`);
461
575
  process.exit(failed > 0 ? 1 : 0);
462
576
  }, 100);
@@ -22,6 +22,12 @@
22
22
  // 9. END-TO-END for docs/copy-registry.json: two branches that each regenerate
23
23
  // it differently merge without conflict markers via the real .gitattributes +
24
24
  // git-config wiring, and the result equals a fresh regeneration.
25
+ // 10. REGRESSION (task 1003546) — the driver runs MID-merge, so a tree-scanning
26
+ // artifact heals to the PRE-merge tree: two branches shifting line numbers in
27
+ // the same scanned UI file merge CLEAN and leave the registry STALE. Pins why
28
+ // the merge's OWNER must regenerate afterwards (conflict-resolve.js step 7b).
29
+ // Needs a real clone (the driver's __dirname must BE the merging tree), so it
30
+ // skips where the environment cannot host that.
25
31
  //
26
32
  // Run: node tests/git_merge_regen.mjs
27
33
 
@@ -135,7 +141,15 @@ function withBlockBody(src, body) {
135
141
  try {
136
142
  g('init', '-q');
137
143
  g('config', 'user.email', 't@t'); g('config', 'user.name', 't');
138
- g('config', 'merge.otb-regen.driver', `node ${driver} %O %A %B %P`);
144
+ // The fixture repo carries none of the repo's eol rules, so on a Windows box
145
+ // with the usual global core.autocrlf=true git would check the merge result out
146
+ // as CRLF while the generators render LF — a byte comparison below would fail on
147
+ // line endings alone. Pin it so the fixture is platform-neutral (task 1003546).
148
+ g('config', 'core.autocrlf', 'false');
149
+ // Forward slashes: `git config` strips backslashes from the value, so a win32
150
+ // path.join() driver path reaches node with its separators gone and the driver
151
+ // never loads — this fixture was silently dead on Windows (task 1003546).
152
+ g('config', 'merge.otb-regen.driver', `node ${driver.replace(/\\/g, '/')} %O %A %B %P`);
139
153
  fs.writeFileSync(path.join(tmp, '.gitattributes'), 'docs/repo-map.md merge=otb-regen\n');
140
154
  fs.mkdirSync(path.join(tmp, 'docs'));
141
155
  writeMap('base\n'); g('add', '-A'); g('commit', '-qm', 'base');
@@ -213,7 +227,15 @@ function withBlockBody(src, body) {
213
227
  try {
214
228
  g('init', '-q');
215
229
  g('config', 'user.email', 't@t'); g('config', 'user.name', 't');
216
- g('config', 'merge.otb-regen.driver', `node ${driver} %O %A %B %P`);
230
+ // The fixture repo carries none of the repo's eol rules, so on a Windows box
231
+ // with the usual global core.autocrlf=true git would check the merge result out
232
+ // as CRLF while the generators render LF — a byte comparison below would fail on
233
+ // line endings alone. Pin it so the fixture is platform-neutral (task 1003546).
234
+ g('config', 'core.autocrlf', 'false');
235
+ // Forward slashes: `git config` strips backslashes from the value, so a win32
236
+ // path.join() driver path reaches node with its separators gone and the driver
237
+ // never loads — this fixture was silently dead on Windows (task 1003546).
238
+ g('config', 'merge.otb-regen.driver', `node ${driver.replace(/\\/g, '/')} %O %A %B %P`);
217
239
  fs.writeFileSync(path.join(tmp, '.gitattributes'), 'docs/copy-registry.json merge=otb-regen\n');
218
240
  fs.mkdirSync(path.join(tmp, 'docs'));
219
241
  writeReg('{"schema":0}\n'); g('add', '-A'); g('commit', '-qm', 'base');
@@ -235,4 +257,108 @@ function withBlockBody(src, body) {
235
257
  }
236
258
  }
237
259
 
260
+ // ── 10) REGRESSION (task 1003546): the driver runs MID-merge, so a tree-scanning ──
261
+ // artifact heals to the PRE-merge tree and the merge lands clean but STALE.
262
+ //
263
+ // Cases 8 + 9 prove the copy-registry rule regenerates and that a real merge comes
264
+ // out marker-free — but their fixture repos contain no scanned SOURCE, so the scan
265
+ // returns the same bytes whichever tree it reads and they are blind to WHEN it runs.
266
+ // This case supplies the missing half: two branches that each shift line numbers in
267
+ // the SAME scanned UI file, in a real clone where the driver's own `__dirname` is
268
+ // the merging tree. The merge exits 0 with no unmerged paths and no markers — and
269
+ // keeps OUR registry verbatim, silently discarding the other side's line numbers.
270
+ // Measured before the fix: `"line": 464` where the merged tree says 466; that is the
271
+ // PR #536 / task 1003544 strand, and the reason conflict-resolve.js regenerates
272
+ // AFTER the merge (step 7b) instead of trusting the driver.
273
+ //
274
+ // It SKIPS rather than fails where the environment cannot host the fixture (no local
275
+ // clone, the scanned file moved, or a base whose registry is already stale) — a
276
+ // characterization test must never red for a reason it cannot attribute.
277
+ {
278
+ const SCANNED = 'modules/hall-ui/public/task-detail.js';
279
+ const REGEN_PATHS = ['docs/copy-registry.json', 'docs/copy-inventory.md'];
280
+ // Short clone path: a deep temp dir blows Windows' MAX_PATH on the clone's
281
+ // .git/objects/info/commit-graphs entries.
282
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'otb-regen-e2e-'));
283
+ const work = path.join(root, 'w');
284
+ const g = (...a) => spawnSync('git', a, { cwd: work, encoding: 'utf8' });
285
+ const gen = (...a) => spawnSync('node', [path.join(work, 'scripts/gds/copy-inventory.js'), ...a],
286
+ { cwd: work, encoding: 'utf8' });
287
+ // "Does regeneration change tracked CONTENT?" — `git diff` applies .gitattributes
288
+ // (`*.json text eol=lf`), so it ignores the worktree line endings that make a raw
289
+ // byte comparison stale on a Windows checkout.
290
+ const regenDirty = () => { gen(); return g('diff', '--quiet', '--', ...REGEN_PATHS).status !== 0; };
291
+ const scannedFile = () => path.join(work, SCANNED);
292
+
293
+ let skip = null;
294
+ try {
295
+ if (spawnSync('git', ['clone', '--local', '-q', repo, work], { encoding: 'utf8' }).status !== 0) {
296
+ skip = 'the repo could not be locally cloned';
297
+ }
298
+ if (!skip) {
299
+ g('config', 'user.email', 't@t');
300
+ g('config', 'user.name', 't');
301
+ g('config', 'core.autocrlf', 'false');
302
+ g('config', 'merge.otb-regen.name', 'regen');
303
+ g('config', 'merge.otb-regen.driver',
304
+ `node ${path.join(work, 'scripts/gds/git-merge-regen.js').replace(/\\/g, '/')} %O %A %B %P`);
305
+
306
+ if (!fs.existsSync(scannedFile())) skip = `${SCANNED} is gone — repoint this fixture at another scanned surface`;
307
+ else if (fs.readFileSync(scannedFile(), 'utf8').split('\n').length < 420) skip = `${SCANNED} is too short for two distant edits`;
308
+ }
309
+ if (!skip && regenDirty()) {
310
+ skip = 'the committed copy registry is already stale at this base — nothing could be attributed to the merge';
311
+ }
312
+
313
+ if (!skip) {
314
+ g('checkout', '--', ...REGEN_PATHS);
315
+ const pad = (atLine, tag) => {
316
+ const lines = fs.readFileSync(scannedFile(), 'utf8').split('\n');
317
+ lines.splice(atLine, 0, `// pad ${tag}`, `// pad ${tag}`);
318
+ fs.writeFileSync(scannedFile(), lines.join('\n'));
319
+ };
320
+
321
+ // Base is HEAD itself — never a branch NAME, which does not exist in a CI
322
+ // detached checkout.
323
+ const base = g('rev-parse', 'HEAD').stdout.trim();
324
+ g('checkout', '-q', '-B', 'fixture-ours', base);
325
+ pad(10, 'OURS');
326
+ gen();
327
+ g('add', '-A'); g('commit', '-qm', 'ours: shift copy lines near the top + regen');
328
+
329
+ g('checkout', '-q', '-B', 'fixture-theirs', base);
330
+ pad(400, 'THEIRS');
331
+ gen();
332
+ g('add', '-A'); g('commit', '-qm', 'theirs: shift copy lines far below + regen');
333
+
334
+ g('checkout', '-q', 'fixture-ours');
335
+ const m = g('merge', '--no-edit', 'fixture-theirs');
336
+
337
+ // 1. The merge lands CLEAN — the driver did clear the textual conflict.
338
+ assert.equal(m.status, 0, `the driver must resolve the registry conflict; got ${m.status} :: ${m.stdout}${m.stderr}`);
339
+ assert.equal(g('diff', '--name-only', '--diff-filter=U').stdout.trim(), '', 'no path may be left unmerged');
340
+ const healed = fs.readFileSync(path.join(work, 'docs', 'copy-registry.json'), 'utf8');
341
+ assert.ok(!MARKERS.test(healed), 'the merged registry must carry no conflict markers');
342
+
343
+ // 2. THE HAZARD: it kept OURS verbatim, because the scan ran before the merge.
344
+ assert.equal(
345
+ g('diff', '--quiet', 'HEAD^1', 'HEAD', '--', 'docs/copy-registry.json').status, 0,
346
+ 'expected the merge to keep ours byte-for-byte (the mid-merge scan)'
347
+ );
348
+ assert.equal(regenDirty(), true,
349
+ 'expected the merged tree to be STALE — the driver cannot see the merged tree, which is WHY '
350
+ + 'conflict-resolve.js regenerates after the merge (step 7b). If this assertion now fails, git has '
351
+ + 'begun running merge drivers against the merged tree: re-read step 7b, it may be simplifiable.');
352
+
353
+ // 3. THE CURE step 7b relies on: a post-merge regeneration is sufficient.
354
+ g('add', '-A'); g('commit', '-qm', 'regenerate from the merged tree');
355
+ assert.equal(regenDirty(), false, 'a post-merge regeneration must leave the registry fresh');
356
+ } else {
357
+ console.log(`git_merge_regen: case 10 SKIPPED — ${skip}`);
358
+ }
359
+ } finally {
360
+ try { fs.rmSync(root, { recursive: true, force: true }); } catch (_) { /* best effort */ }
361
+ }
362
+ }
363
+
238
364
  console.log('git_merge_regen: all assertions passed');
@@ -1995,6 +1995,19 @@ test('the wizard takes the world by scope: no card ground, the six-step strip as
1995
1995
  assert.doesNotMatch(block, /#[0-9a-f]{3,8}\b/i, 'every colour is a token');
1996
1996
  });
1997
1997
 
1998
+ /* ── one fold for the whole wizard (task 1003538, a task 1003346 follow-up). The rail folded
1999
+ at the sheet's 900 while the wizard's fields, choice bands and review ledger folded at the
2000
+ page's 840 (cosmos's createSplit collapse), so between the two the rail was already three-up
2001
+ beside full-width bands. Both sheets now name the same width. ── */
2002
+ test('the wizard folds at one width: the rail\'s media rule sits at 840px with the bands, and no 900px rule is left in the #view-new block (task 1003538)', () => {
2003
+ const at = COSMOS.indexOf('THE CREATE WIZARD, IN THE WORLD');
2004
+ assert.ok(at > -1, 'the #view-new block exists in cosmos.css');
2005
+ const block = COSMOS.slice(at);
2006
+ assert.match(block, /@media \(max-width:840px\)\{\s*#view-new \.steps\{grid-template-columns:repeat\(3,1fr\);/, 'the rail folds to three columns at 840, the width the bands fold at');
2007
+ assert.doesNotMatch(block, /@media \(max-width:900px\)/, 'no 900px rule is left in the wizard block');
2008
+ assert.match(STYLE, /@media \(max-width:840px\)\{[\s\S]*?\.review\{grid-template-columns:1fr;/, 'the page\'s own wizard block still folds its bands and ledger at that same 840');
2009
+ });
2010
+
1998
2011
  test('the wizard\'s own structures wear the world: hairline fields, cells of one band, the review as a ledger, the human steps as a numbered ledger, the standup strip as the rail (task 1003346)', () => {
1999
2012
  const at = STYLE.indexOf('the create wizard (task 1002763');
2000
2013
  assert.ok(at > -1, 'the wizard block is still the page\'s own');