@bongos/core 1.21.61 → 1.21.62

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/.bongos-core.json CHANGED
@@ -2,22 +2,22 @@
2
2
  "artifact": "bongos-core",
3
3
  "manifest_schema": 1,
4
4
  "generator": "scripts/gds/package-core.js",
5
- "core_version": "1.21.61",
6
- "core_contract": "1.21.61",
7
- "source_commit": "af0a03f180373b14387c419d584201ee10f7a5ab",
5
+ "core_version": "1.21.62",
6
+ "core_contract": "1.21.62",
7
+ "source_commit": "105061dd47a96f0d628fd6ef926fd3f325d76ac3",
8
8
  "source_ref": "HEAD",
9
- "built_at": "2026-10-05T10:43:45.436Z",
9
+ "built_at": "2026-10-05T21:53:01.352Z",
10
10
  "redaction": {
11
11
  "model": "docs-redacted+functional-verbatim",
12
- "docs_redacted": 585,
12
+ "docs_redacted": 586,
13
13
  "agent_docs_stubbed": 28,
14
- "functional_verbatim": 2948,
14
+ "functional_verbatim": 2949,
15
15
  "rules": 3,
16
16
  "gate_literals": 3,
17
17
  "gate": "passed"
18
18
  },
19
- "file_count": 3562,
20
- "tree_sha256": "0537fa5681807a5c2c7bd9b89d503ede191d00215928234a666c9795d20de7eb",
19
+ "file_count": 3564,
20
+ "tree_sha256": "293671547d32416cc06202a1b95675be164a603b599e65c19cc5df0e8d577288",
21
21
  "files": [
22
22
  {
23
23
  "path": ".claude/skills/ask-for-help/SKILL.md",
@@ -2284,10 +2284,15 @@
2284
2284
  "mode": "0000644",
2285
2285
  "sha256": "93c863a7f961cd91c052eee2188b16b9f360cec26478dde6225b304ee0e59f7d"
2286
2286
  },
2287
+ {
2288
+ "path": "docs/adr/0366-hall-planning-is-forms-and-a-goal-made-there-carries-its-own-spec-task.md",
2289
+ "mode": "0000644",
2290
+ "sha256": "29c841d193efa7b6f6479e14a6056057138574fcd7b526f667da6f219dece5a7"
2291
+ },
2287
2292
  {
2288
2293
  "path": "docs/adr/README.md",
2289
2294
  "mode": "0000644",
2290
- "sha256": "a81d1eeeb26ce4fde1731485e943d82ddb320bc60a5f1782f7a207b94da5fff5"
2295
+ "sha256": "08f66fecefc58d9c09e45841dd9d47514e807934cfcd4621a8bfeaa390afb334"
2291
2296
  },
2292
2297
  {
2293
2298
  "path": "docs/api-reference.md",
@@ -2837,7 +2842,7 @@
2837
2842
  {
2838
2843
  "path": "docs/module-api-changelog.md",
2839
2844
  "mode": "0000644",
2840
- "sha256": "fe852dba06c424e3ff2b7b84eb4cc4670690525201f2d1929e9fd5a0340ab522"
2845
+ "sha256": "469982519512666ef5e4f36e65446397b1305c6367446c2fd177c2942d70e334"
2841
2846
  },
2842
2847
  {
2843
2848
  "path": "docs/modules-contract.md",
@@ -2877,7 +2882,7 @@
2877
2882
  {
2878
2883
  "path": "docs/onboarding/diagrams/assertions.json",
2879
2884
  "mode": "0000644",
2880
- "sha256": "b2e8855cbc2acc539dbd060093a15bdd0a9d073fe8006736552d07676f3d6899"
2885
+ "sha256": "1cda1da05b0a74bc5532e478acbf3d625c49d1a1fac3b75ed967626d95a022bc"
2881
2886
  },
2882
2887
  {
2883
2888
  "path": "docs/onboarding/diagrams/puppeteer-config.json",
@@ -7047,7 +7052,7 @@
7047
7052
  {
7048
7053
  "path": "modules/lifecycle/merge-lock.js",
7049
7054
  "mode": "0000644",
7050
- "sha256": "aaa5171275b156b67ed9767c341fda462fd6e6e44e4591d9fd21ba1aece996ac"
7055
+ "sha256": "16667b27f462627277da092dd0cf8905cf7144f1a959fdaf3174a9fb41a91da4"
7051
7056
  },
7052
7057
  {
7053
7058
  "path": "modules/lifecycle/migrations/lifecycle_001_goal_first_leads.sql",
@@ -7367,7 +7372,7 @@
7367
7372
  {
7368
7373
  "path": "modules/lifecycle/ship-preflight.js",
7369
7374
  "mode": "0000644",
7370
- "sha256": "3b08535b9a680b6f0eaf0ab2d5e38f8ae77ff5563b517a97c1ab34402f5c7a06"
7375
+ "sha256": "99de217d003dd0a837e8604a7a6fedc28be8e5e589c0e4d70b7f46eca1b4432e"
7371
7376
  },
7372
7377
  {
7373
7378
  "path": "modules/lifecycle/sky.js",
@@ -9957,12 +9962,12 @@
9957
9962
  {
9958
9963
  "path": "package-lock.json",
9959
9964
  "mode": "0000644",
9960
- "sha256": "b9418adf527e06a17607eff5caea767b391e41fbe70b95a429c493dd72c0e443"
9965
+ "sha256": "32077bf55d5b87cb987c0d64e579df513a72248dc79d719f878d1cfb41f4090d"
9961
9966
  },
9962
9967
  {
9963
9968
  "path": "package.json",
9964
9969
  "mode": "0000644",
9965
- "sha256": "1be4723fd1f70d91d04a5f8ca95237fd864b704ec61e978e25d3169f6eb29f31"
9970
+ "sha256": "a33111b7630688dd0840cf123940fc0c7efdb751056b554d6cf439831f9813c9"
9966
9971
  },
9967
9972
  {
9968
9973
  "path": "public-docs/index.html",
@@ -9982,7 +9987,7 @@
9982
9987
  {
9983
9988
  "path": "release-notes.json",
9984
9989
  "mode": "0000644",
9985
- "sha256": "6921afa5e5ab728375f7dbfe4f8a0b024d4006d65912fa52601145cba74a5c86"
9990
+ "sha256": "68623d6dac838274578a99dd42d1a684e1a6369e2fd7bcb53a2af3778f3b2b68"
9986
9991
  },
9987
9992
  {
9988
9993
  "path": "scripts/cron/style_guide_audit.sh",
@@ -11457,7 +11462,7 @@
11457
11462
  {
11458
11463
  "path": "scripts/gds/ship-grade.js",
11459
11464
  "mode": "0000644",
11460
- "sha256": "eb0740f3cdc3dc91038320dd9044dc81c05e31212059d772d5c8bf6e9f44b474"
11465
+ "sha256": "5fd40df43c942eb3fcbd24a22c22743e63d2b32efba3c612c7e2fa791ad0f087"
11461
11466
  },
11462
11467
  {
11463
11468
  "path": "scripts/gds/ship-honesty.js",
@@ -11482,12 +11487,12 @@
11482
11487
  {
11483
11488
  "path": "scripts/gds/ship-merge-gate.js",
11484
11489
  "mode": "0000644",
11485
- "sha256": "34c28500eb2c4324f44c5b3604049c305d7b7921b57557ee325ece88b6293252"
11490
+ "sha256": "fba341724c94b1ab9a56b1b43cda608eaa477f50c6f8e65ec287632367d603a9"
11486
11491
  },
11487
11492
  {
11488
11493
  "path": "scripts/gds/ship-merge.js",
11489
11494
  "mode": "0000644",
11490
- "sha256": "7b877a3a3cb63c41923b9e6410f042b13263afe88429ade6b32f6d4e90d4fadd"
11495
+ "sha256": "8d37d5c6cbaa1ca0e8363fab6c34159d1e16922fdae11d4ffc17b4d9e2c7f795"
11491
11496
  },
11492
11497
  {
11493
11498
  "path": "scripts/gds/ship-preflight-steps.js",
@@ -11512,7 +11517,7 @@
11512
11517
  {
11513
11518
  "path": "scripts/gds/ship-state.js",
11514
11519
  "mode": "0000644",
11515
- "sha256": "a22377162ea9449249ee338e434c2b595a4de88743e6dacc290138f0ea9799dc"
11520
+ "sha256": "e465cb391d4518258290d6f53be3a31353e701fb21935f8e1f9ff7b4f38e00a6"
11516
11521
  },
11517
11522
  {
11518
11523
  "path": "scripts/gds/ship-surprise.js",
@@ -12202,7 +12207,7 @@
12202
12207
  {
12203
12208
  "path": "src/module-api.js",
12204
12209
  "mode": "0000644",
12205
- "sha256": "27af8869982707a3bbb16a65d9d2b0ccca2a40a579448aa9cc8fd10045e5c93d"
12210
+ "sha256": "b5dc083bafe5265707463d0de6a151f21dd83a2849d7e8eeded08215ddce59cb"
12206
12211
  },
12207
12212
  {
12208
12213
  "path": "src/module-loader/catalog.js",
@@ -15332,7 +15337,7 @@
15332
15337
  {
15333
15338
  "path": "tests/merge_lock.mjs",
15334
15339
  "mode": "0000644",
15335
- "sha256": "5617e46b1e07ceb5fd537ae1192a27c88e8ce5d1fd06f219190f90aaebd64a5a"
15340
+ "sha256": "03888ba034c0af2af764575b4b6c2b37e1f1eb54c7dc02b39ce4590e5a4b5451"
15336
15341
  },
15337
15342
  {
15338
15343
  "path": "tests/metrics.mjs",
@@ -17022,7 +17027,7 @@
17022
17027
  {
17023
17028
  "path": "tests/ship_merge_gate.mjs",
17024
17029
  "mode": "0000644",
17025
- "sha256": "b498d2820f6e17948cb8a86637d16b248a3349ebd9933343dd4b335a239497b7"
17030
+ "sha256": "28ad67a5ee8be6807f8d16149ea42cb837b7bc4e4084068b74d8e944db09d291"
17026
17031
  },
17027
17032
  {
17028
17033
  "path": "tests/ship_post_land_verify.mjs",
@@ -17032,7 +17037,7 @@
17032
17037
  {
17033
17038
  "path": "tests/ship_preflight.mjs",
17034
17039
  "mode": "0000644",
17035
- "sha256": "b86e826355cf786328a7d1301abebd9c7c8b5c5b7ad82b9d2ebcb3d6e35285a9"
17040
+ "sha256": "325980877dc43ea902de03cf938a85f29700f0443ca040efe75873b5f4f210b9"
17036
17041
  },
17037
17042
  {
17038
17043
  "path": "tests/ship_premerge.mjs",
@@ -17074,6 +17079,11 @@
17074
17079
  "mode": "0000644",
17075
17080
  "sha256": "46856ed6c15ec0cca70e9d2541b4e17331de3c2336eb45dc1e36de1241b5440a"
17076
17081
  },
17082
+ {
17083
+ "path": "tests/ship_vendored_instance.mjs",
17084
+ "mode": "0000644",
17085
+ "sha256": "75aa59accad12d197202457ed36281e5d3eca1040762a5045972c4b10cf09006"
17086
+ },
17077
17087
  {
17078
17088
  "path": "tests/ship_zero_commit.mjs",
17079
17089
  "mode": "0000644",
@@ -0,0 +1,85 @@
1
+ # ADR 0366 — Hall planning is forms; a goal made there carries its own spec task, and the interviews stay in the CLI
2
+
3
+ - **Status:** accepted
4
+ - **Date:** 2026-10-05
5
+ - **Task:** [task 1003461](https://cloudbongos.com/builders#/task/1003461) (spike, goal 1000089 — Working area 3, BONGOS-V2)
6
+ - **Builds on:** [ADR 0086](0086-goal-scoped-work-hierarchy.md) (goals and the scope wall), [ADR 0154](0154-goal-owner-criterion-authoring.md) (a goal's owner authors its criteria), [ADR 0264](0264-the-ten-working-areas.md) §1 (working area 3: "users edit, scope and manage work without ever prompting Claude")
7
+ - **Decided with:** the owner, 2026-10-05 — two questions; the scope answer was the owner's own (D3), the hand-off answer was the recommended option (D4).
8
+
9
+ ## Context
10
+
11
+ Goal 1000089 wants the hall to plan work: create a task, a goal, a criterion. Two CLI flows do this today, and both are **interviews**, not forms:
12
+
13
+ - **`/goal-create`** (`.claude/skills/goal-create/SKILL.md`). Phases 1–3 are marked *"autonomous; ask nothing"*: Claude picks the version, decides **reuse vs create** against the live goal board, infers the **scope wall** from the planned work's file paths and checks it against the live module roster, drafts 2–4 criteria and 3–8 seed tasks with dependencies. Phase 4 asks the human only the grey areas (at most 4 questions) and one approval. Phase 5 writes goal → criteria → tasks.
14
+ - **`/planning-session`** (`.claude/skills/planning-session/SKILL.md`). A whole version: a written spec file under `docs/specs/`, a multi-round interview that runs *"until nothing material is left"*, then a migration, a seed script, dependency ranking and promotion. Creating a version is Archon-only (ADR 0250 §6).
15
+
16
+ The spike's question: which of these steps are form-shaped, which must stay interviews (named, so nobody half-builds them), and whether anything wants a third shape. The constraint: every write already exists behind a server rank floor (`task.create`, `goal.create`, `criterion.create`, the Archon wall on a protected scope); the spike decides **surface only**.
17
+
18
+ ### The evidence
19
+
20
+ Read the two flows for which inputs a person can **know** versus which Claude **infers**:
21
+
22
+ | Input | Who supplies it today | Shape |
23
+ |---|---|---|
24
+ | A task's title, description, goal | the person | form — and the goal picker already exists: `GET /goals/suggestions` feeds the hall composer (`sky-composer.js`, task 1003069) |
25
+ | A task's kind, criterion link | the server already infers it: `POST /tasks` auto-classifies kind; `GET /goals/:id/suggest-criteria` ranks criteria | form, with the server's suggestion shown |
26
+ | A goal's name, subtitle, description, version, category | the person (goal-create *drafts* them, but they are statements of intent, and `GET /work-categories` is a plain list) | form |
27
+ | A criterion's outcome text | the person, for a goal they own (ADR 0154) | form — the slug `criterion_id` is derivable from the text, and a 409 collision is a sentence, not a judgement |
28
+ | Satisfying a criterion | already in the hall (`goals-page.js`) | form (exists) |
29
+ | Reuse vs create a goal | Claude reads the board and judges | **interview** — though a form can *show* the suggester's candidates and let the person choose |
30
+ | A goal's **scope wall** | Claude infers from file paths, checks the roster | **interview** — and the roster is not readable by the hall at all (`module-scope-map` is server-only; the hall gets only a derived `scope_protected` flag, `goal-route-authz.js` `decorateGoalScope`) |
31
+ | Criteria drafted from a description; seed tasks and their dependency edges | Claude | **interview** |
32
+ | A version: spec, multi-round interview, migration, seed script, ranking | Claude with the owner, Archon-only to create | **interview** |
33
+
34
+ And two precedents show the in-between shape already exists in the hall: the idea composer asks a **server-defined question sequence** with skip and cancel and no Claude (`sky-composer.js`, the base-v2 template), and the hall already **composes a prompt for a session to finish** (`task-lib.js` `buildPromptForTask`, the bulk toolbar's sequential and parallel prompts, collab's "Copy for Session Start").
35
+
36
+ ## Decision
37
+
38
+ **D1 — Form-shaped; build in the hall.** Every step whose inputs the person knows:
39
+ 1. Create a task under a goal (title, description, goal via the existing suggester; kind and criterion offered from the server's own suggestions). → [task 1003462](https://cloudbongos.com/builders#/task/1003462).
40
+ 2. Create a goal: a **name** field and a separate **subtitle/description** (goal 1000072's convention — a title is a name, not its own explanation), version, category. **No scope field** (D3). Before the name is submitted the form shows the suggester's matching open goals, so "reuse instead" is a click and the judgement stays the person's. → [task 1003463](https://cloudbongos.com/builders#/task/1003463).
41
+ 3. Attach a criterion to a goal the person owns or manages: the outcome text; the slug is derived from it. → task 1003463.
42
+
43
+ Every refusal the server already makes — `task.create` / `goal.create` / `criterion.create` floors, the Archon wall on a protected scope, ADR 0154's `bad_goal_id` for a goal the actor does not hold — is rendered as copy. The forms never pre-filter themselves into a shape that hides one (1003463's own rule).
44
+
45
+ **D2 — Interview-shaped; stays in the CLI, deliberately absent from the hall.** Named so nobody half-builds them:
46
+ - planning a **version** (`/planning-session`) — the spec file, the multi-round interview, the migration, the seed script, the ranking, and creating a version at all;
47
+ - **inferring a goal's scope wall** from the work it will do;
48
+ - **drafting criteria** from a description, and **decomposing a goal into seed tasks** with dependency edges;
49
+ - **judging reuse vs create** beyond showing the suggester's candidates;
50
+ - **carrying achieved goals forward** across a version boundary (the planning session's rescope).
51
+
52
+ A hall form for any of these would be the title-field-and-textarea the spike warned against: worse than the CLI it replaces, because the CLI's value *is* the inference.
53
+
54
+ **D3 — The third shape: a goal made in the hall carries its own spec task (the owner's answer).** A goal created from the hall form is created with a task inside it — **"Spec goal N: its scope, criteria and first tasks"** — and the goal's other work waits behind that task until it ships. Someone claims it like any task and does the interview half in a session (`/goal-create`, finishing an existing goal rather than creating one). This is the "draft the hall composes and a session finishes" shape, but recorded in the ledger as a claimable, creditable task rather than a prompt on someone's clipboard. Concretely:
55
+ - `POST /goals` grows an opt-in `spec_task: true` (the hall form sends it; `/goal-create`, which specs inline, does not). The server creates the spec task in the same request, `source_ref = goal-<id>-spec` so a retry is idempotent, promoted to `ready` so it can be claimed.
56
+ - While that task is unshipped, every task created in the goal gets a **dependency** on it, so a claim is refused `DEPS_NOT_SHIPPED` — the existing gate, not a new one. Once it ships, the dependents auto-promote as they do today.
57
+ - The walls do not move. Setting the scope uses the existing `POST /goals/:id/scope` (`goal.scope.widen`, owner or manager, Archon for a protected key) and criteria use `criterion.create` under ADR 0154. A claimer who does not hold the goal drafts the scope and criteria and hands them to the owner — `/goal-create`'s existing off-ramp — rather than gaining anything.
58
+
59
+ No new permission and no migration: the spec task is identified by its `source_ref`, and blocking is an ordinary dependency.
60
+
61
+ **D4 — An optional hand-off button.** On a goal's page, a **"Plan with Claude"** button copies a prompt that continues from that goal (its open spec task when there is one, else `/goal-create` against the goal for seed tasks). It is an extra, never a requirement: every D1 step works without it, so ADR 0264's "without ever prompting Claude" holds.
62
+
63
+ ## Consequences
64
+
65
+ - Goal 1000089's remaining planning work is five tasks (below; the three new ones were filed by this spike as tasks 1004585, 1004586 and 1004587). The CLI flows are unchanged except that `/goal-create` learns to finish an existing goal's spec task.
66
+ - A hall-made goal is visibly **unspecced** until its spec task ships, and its work cannot be claimed early. That is the intent: a goal with no wall and no criteria is the state `/goal-create` exists to prevent, and D3 makes the gap a task instead of a silence.
67
+ - The hall never needs the module roster, so the protected list stays server-only.
68
+ - Risk: a spec task nobody claims stalls its goal. It is an ordinary ready task, visible on the board and in the goal; if this proves common, an age nudge is the follow-up, not a softer block.
69
+
70
+ ## The task list for the rest of the criterion
71
+
72
+ | Task | Shape | Depends on |
73
+ |---|---|---|
74
+ | [task 1003462](https://cloudbongos.com/builders#/task/1003462) — Create a task from the hall, attached to its goal | form (D1.1) | — |
75
+ | [task 1004585](https://cloudbongos.com/builders#/task/1004585) — A goal made in the hall carries a spec task that gates its other work (server: `spec_task`, the dependency) | D3 | — |
76
+ | [task 1003463](https://cloudbongos.com/builders#/task/1003463) — Author a goal and a done-when criterion from the hall | form (D1.2, D1.3) | task 1004585 |
77
+ | [task 1004586](https://cloudbongos.com/builders#/task/1004586) — `/goal-create` finishes an existing goal's spec task | D3, CLI | task 1004585 |
78
+ | [task 1004587](https://cloudbongos.com/builders#/task/1004587) — "Plan with Claude" hand-off button on a goal | D4 | task 1004585 |
79
+
80
+ ## Alternatives considered
81
+
82
+ - **A scope picker in the goal form** (checkboxes of code areas, protected ones marked). Rejected in favour of D3 by the owner: scope is inferred from the work, and a person ticking boxes before the work is understood is the half-built interview D2 forbids. It would also have needed a new roster read.
83
+ - **No scope at all for hall goals.** Rejected: an open wall with no criteria and nobody responsible for fixing it is the catch-all failure ADR 0086 retired.
84
+ - **Port `/goal-create` into the hall as a guided form** (the idea composer's question sequence). Rejected: the composer's questions are fixed; goal-create's are decided by what it reads, which is exactly the part a script cannot do.
85
+ - **The hand-off as the only path** (every hall planning action copies a prompt). Rejected: it breaks working area 3's thesis outright.
@@ -484,3 +484,4 @@ These 20 numbers are each shared by exactly two files. They are **accepted histo
484
484
  | 0363 | [**Who decides what is a per-project table, changed only by a passed amendment** ([task 1004529](https://cloudbongos.com/builders#/task/1004529), goal 1000125). **D1:** `government_decision_rules`, one row per building: `gate` (as today), `board` (the charter only), `nobody` (vacant) or a membership predicate with decide / first / consent / majority / unanimous / sign-off. **D2:** the seeded defaults are today, and a malformed row reads as its default. **D3:** written only inside the transaction that closes a `decision_rules` amendment as passed; JS guard + SQL condition + trigger; no settings route. **D4:** eight building matters join the board's three subjects, openable only where a row names a sitting; sign-off is the author rule. **D5:** People's rank assignments defer to People's row (`checkActAlone`); a passed People matter acts. **D6:** the docket's buildings carry who decides.](0363-who-decides-what-is-a-table-changed-only-by-amendment.md) | government / Governor City |
485
485
  | 0364 | [**Repo access is asked at sign-in on the hub, and held for as long as the builder stays signed in** ([task 1004549](https://cloudbongos.com/builders#/task/1004549)). Amends ADR 0143 and 0155. **D1:** every sign-in door on the hub's own pages carries `repos=1`; the hall and CLI stay identity-only. **D2:** the repo token's TTL is the web session's remaining life; a web sign-out deletes it, a CLI logout does not. **D3:** a draft at a wizard step beside a stored create keeps its place on a reload or a GitHub round-trip.](0364-repo-access-is-asked-at-sign-in-and-held-for-the-session.md) | auth / GitHub / create wizard |
486
486
  | 0365 | [**A guild engages a project through each member's own door** ([task 1002303](https://cloudbongos.com/builders#/task/1002303)). Amends ADR 0336 D7's invite half after ADR 0353. **D1:** the engagement row as D7 specified, plus `guild_id` on the echo (the latest application decides the label). **D2:** a guild applies = each member applies as themselves through the relay, naming the engagement. **D3:** a project invites a guild = the hub records it and returns one hall hand-off link per public-roster member; no hub-written invites, the one-step relay stays deferred. **D4:** a project is named only when on the map and public, for both kinds. **D5:** the label rides the applicant-profile read; the queue is R28's. **D6:** the D4.4 guild cards are their own task.](0365-a-guild-engages-a-project-through-each-members-own-door.md) | guilds / invites / applications |
487
+ | 0366 | [**Hall planning is forms; a goal made there carries its own spec task, and the interviews stay in the CLI** ([task 1003461](https://cloudbongos.com/builders#/task/1003461), goal 1000089). Spike answer. **D1:** form-shaped, build in the hall: create a task under a goal, create a goal (name + subtitle, version, category, no scope field), attach a criterion to a goal you hold; every server refusal rendered as copy. **D2:** interview-shaped, deliberately absent from the hall: version planning, scope inference, drafting criteria and seed tasks, reuse-vs-create judgement, carry-forward. **D3 (owner):** a hall-made goal is created with a "Spec goal N" task (`POST /goals` `spec_task: true`, `source_ref goal-<id>-spec`) and every task in the goal depends on it until it ships; no new permission, no migration. **D4:** an optional "Plan with Claude" hand-off button.](0366-hall-planning-is-forms-and-a-goal-made-there-carries-its-own-spec-task.md) | lifecycle / planning |
@@ -2949,5 +2949,7 @@ is load-bearing: the script throws rather than guess if it is missing, and
2949
2949
  landed since 1.21.59 with no explicit bump. run 37294675301. (task 1002620)
2950
2950
  1.21.61 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
2951
2951
  landed since 1.21.60 with no explicit bump. run 37298331673. (task 1002620)
2952
+ 1.21.62 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
2953
+ landed since 1.21.61 with no explicit bump. run 37378745989. (task 1002620)
2952
2954
  ---------------------------------------------------------------------------
2953
2955
  ```
@@ -22,8 +22,8 @@
22
22
  ],
23
23
  "default_rank": "xenos",
24
24
  "wired_ranks": [
25
- "xenos",
26
25
  "archon",
26
+ "xenos",
27
27
  "thetes",
28
28
  "metic"
29
29
  ],
@@ -10,7 +10,7 @@
10
10
  // ship-vs-merge-mode, and merge-mode-vs-merge-mode are all serialized.
11
11
  // (V3.R50 / #269, criterion C6.)
12
12
  //
13
- // The lock is a plain file at <repoRoot>/.git/otb-automerge.lock. We don't use
13
+ // The lock is a plain file at <common git dir>/otb-automerge.lock (see lockPath). We don't use
14
14
  // flock(2): ships and merge runs happen on macOS, where flock over an
15
15
  // iCloud-synced filesystem is unreliable, and the holder may be a short-lived
16
16
  // CLI process (the manual /merge-mode case has no long-lived owner). The file
@@ -43,14 +43,54 @@
43
43
 
44
44
  const fs = require('node:fs');
45
45
  const path = require('node:path');
46
- const { execSync } = require('node:child_process');
46
+ const { execSync, execFileSync } = require('node:child_process');
47
47
 
48
48
  const AUTO_STALE_MS = 10 * 60 * 1000; // crashed automated ship
49
49
  const MANUAL_STALE_MS = 30 * 60 * 1000; // operator walked away mid-merge-mode
50
50
  const INDEX_LOCK_GRACE_MS = 60 * 1000; // a git index.lock younger than this may be a LIVE op
51
51
 
52
+ // lockPath — the lockfile lives in the repo's COMMON git dir (task 1004584).
53
+ // When `main` is checked out in a LINKED worktree, <root>/.git is a FILE (a
54
+ // `gitdir:` pointer), so the old <root>/.git/otb-automerge.lock was an ENOENT
55
+ // on every acquire. The common dir is the same folder from every worktree, so
56
+ // a ship from any of them and the merge-lock CLI all contend for ONE file.
57
+ // A primary checkout (.git is a directory) short-circuits without spawning git;
58
+ // when git cannot answer, the historical path is kept.
52
59
  function lockPath(repoRoot) {
53
- return path.join(repoRoot, '.git', 'otb-automerge.lock');
60
+ return path.join(commonGitDir(repoRoot), 'otb-automerge.lock');
61
+ }
62
+
63
+ function isDir(p) {
64
+ try { return fs.statSync(p).isDirectory(); } catch (_) { return false; }
65
+ }
66
+
67
+ // Ask git for a path inside the repo's git dirs, absolute. `args` is the
68
+ // rev-parse tail (['--git-common-dir'] or ['--git-path', 'index.lock']). Null
69
+ // when the root is not a repo or git is unavailable.
70
+ function gitRevParsePath(repoRoot, args) {
71
+ try {
72
+ const out = execFileSync('git', ['rev-parse', ...args], {
73
+ cwd: repoRoot, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'],
74
+ }).trim();
75
+ if (!out) return null;
76
+ return path.isAbsolute(out) ? out : path.resolve(repoRoot, out);
77
+ } catch (_) {
78
+ return null;
79
+ }
80
+ }
81
+
82
+ function commonGitDir(repoRoot) {
83
+ const dotGit = path.join(repoRoot, '.git');
84
+ if (isDir(dotGit)) return dotGit;
85
+ return gitRevParsePath(repoRoot, ['--git-common-dir']) || dotGit;
86
+ }
87
+
88
+ // The worktree's OWN index.lock — per-worktree, so a linked main keeps it in
89
+ // .git/worktrees/<name>/, not under the common dir.
90
+ function indexLockPath(repoRoot) {
91
+ const dotGit = path.join(repoRoot, '.git');
92
+ if (isDir(dotGit)) return path.join(dotGit, 'index.lock');
93
+ return gitRevParsePath(repoRoot, ['--git-path', 'index.lock']) || path.join(dotGit, 'index.lock');
54
94
  }
55
95
 
56
96
  // Synchronous sleep without spawning a process (ship.js is execSync-style and
@@ -76,9 +116,11 @@ function pidAlive(pid) {
76
116
  }
77
117
 
78
118
  // Parse the lockfile into { pid, ts, mode } or null if absent/unreadable.
79
- function readLock(repoRoot) {
119
+ // `file` lets a caller that already resolved lockPath skip re-resolving it (for
120
+ // a linked worktree that is a git spawn per call).
121
+ function readLock(repoRoot, file = lockPath(repoRoot)) {
80
122
  try {
81
- const [p, t, m] = fs.readFileSync(lockPath(repoRoot), 'utf8').split('\n');
123
+ const [p, t, m] = fs.readFileSync(file, 'utf8').split('\n');
82
124
  const pid = parseInt(p, 10);
83
125
  const ts = parseInt(t, 10) || 0;
84
126
  const mode = m && m.trim() === 'manual' ? 'manual' : 'auto';
@@ -118,7 +160,7 @@ function recoverWorktree(repoRoot, { now = Date.now, runner = execSync } = {}) {
118
160
  // live git process could still own it. A fresh lock means a live op
119
161
  // (possibly a holder that just acquired after us); never yank that.
120
162
  try {
121
- const lk = path.join(repoRoot, '.git', 'index.lock');
163
+ const lk = indexLockPath(repoRoot);
122
164
  const st = fs.statSync(lk); // throws ENOENT when absent → nothing to clear
123
165
  if (now() - st.mtimeMs > INDEX_LOCK_GRACE_MS) fs.unlinkSync(lk);
124
166
  } catch (_) {
@@ -176,7 +218,7 @@ function acquire(repoRoot, opts = {}) {
176
218
  return true;
177
219
  } catch (e) {
178
220
  if (e.code !== 'EEXIST') throw e;
179
- const rec = readLock(repoRoot);
221
+ const rec = readLock(repoRoot, p); // p resolved once, before the loop
180
222
  if (isStale(rec, now())) {
181
223
  let reclaimed = false;
182
224
  try {
@@ -206,10 +248,11 @@ function acquire(repoRoot, opts = {}) {
206
248
  function release(repoRoot, opts = {}) {
207
249
  const { pid = process.pid, force = false } = opts;
208
250
  try {
209
- const rec = readLock(repoRoot);
251
+ const file = lockPath(repoRoot);
252
+ const rec = readLock(repoRoot, file);
210
253
  if (!rec) return;
211
254
  if (force || rec.mode === 'manual' || rec.pid === pid) {
212
- fs.unlinkSync(lockPath(repoRoot));
255
+ fs.unlinkSync(file);
213
256
  }
214
257
  } catch (_) {
215
258
  /* already gone */
@@ -251,6 +294,7 @@ module.exports = {
251
294
  MANUAL_STALE_MS,
252
295
  INDEX_LOCK_GRACE_MS,
253
296
  lockPath,
297
+ indexLockPath,
254
298
  pidAlive,
255
299
  readLock,
256
300
  isStale,
@@ -245,6 +245,31 @@ function cleanupMacFinderDupRefs(cwd) {
245
245
  return deleted;
246
246
  }
247
247
 
248
+ // worktreeBlocks — `git worktree list --porcelain` as [{ dir, branch, bare }], in
249
+ // git's order (the primary checkout first). Null when git can't answer. The one
250
+ // parser behind findMainWorktree and findPrimaryWorktree.
251
+ function worktreeBlocks(cwd) {
252
+ try {
253
+ const out = execSync('git worktree list --porcelain', {
254
+ cwd,
255
+ encoding: 'utf8',
256
+ stdio: ['ignore', 'pipe', 'pipe'],
257
+ });
258
+ return out.split('\n\n').filter(Boolean).map((block) => {
259
+ const wt = { dir: null, branch: null, bare: false };
260
+ for (const raw of block.split('\n')) {
261
+ const line = raw.trim();
262
+ if (line.startsWith('worktree ')) wt.dir = line.slice('worktree '.length);
263
+ else if (line.startsWith('branch refs/heads/')) wt.branch = line.slice('branch refs/heads/'.length);
264
+ else if (line === 'bare') wt.bare = true;
265
+ }
266
+ return wt;
267
+ });
268
+ } catch (_) {
269
+ return null; // not a git repo, or git not available — caller falls back
270
+ }
271
+ }
272
+
248
273
  function findMainWorktree(cwd) {
249
274
  // The auto-merge in ship.js runs git on the worktree that has `main`
250
275
  // checked out. Feature work happens in linked worktrees under
@@ -260,28 +285,20 @@ function findMainWorktree(cwd) {
260
285
  // branch refs/heads/claude/feature
261
286
  //
262
287
  // Returns the worktree path whose branch is `main`, or null if none.
263
- try {
264
- const out = execSync('git worktree list --porcelain', {
265
- cwd,
266
- encoding: 'utf8',
267
- stdio: ['ignore', 'pipe', 'pipe'],
268
- });
269
- const blocks = out.split('\n\n').filter(Boolean);
270
- for (const block of blocks) {
271
- let dir = null;
272
- let branch = null;
273
- for (const line of block.split('\n')) {
274
- if (line.startsWith('worktree ')) dir = line.slice('worktree '.length);
275
- else if (line.startsWith('branch refs/heads/')) {
276
- branch = line.slice('branch refs/heads/'.length);
277
- }
278
- }
279
- if (branch === 'main' && dir) return dir;
280
- }
281
- } catch (_) {
282
- // Not a git repo or git not available — caller will fall back.
283
- }
284
- return null;
288
+ const main = (worktreeBlocks(cwd) || []).find((wt) => wt.branch === 'main' && wt.dir);
289
+ return main ? main.dir : null;
290
+ }
291
+
292
+ // findPrimaryWorktree — the repo's primary checkout (the first block of
293
+ // `git worktree list --porcelain`), whatever branch it is on. The fallback merge
294
+ // target when NO worktree has `main` checked out (task 1004584): it is still the
295
+ // instance's own checkout, so ship can say "this is on X, not main" instead of
296
+ // falling back to the core package folder — which in a vendored instance is
297
+ // node_modules/@bongos/core, not a repo at all. Null for a bare primary or when
298
+ // git can't answer.
299
+ function findPrimaryWorktree(cwd) {
300
+ const first = (worktreeBlocks(cwd) || [])[0];
301
+ return first && !first.bare && first.dir ? first.dir : null;
285
302
  }
286
303
 
287
304
  // task 932: post-ship working-tree CLOSE-OUT plan (pure). The mirror image of
@@ -308,5 +325,6 @@ module.exports = {
308
325
  cleanupMacFinderDupRefs,
309
326
  gitDirFor,
310
327
  findMainWorktree,
328
+ findPrimaryWorktree,
311
329
  closeoutPlan,
312
330
  };
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.21.61",
3
+ "version": "1.21.62",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.21.61",
9
+ "version": "1.21.62",
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.21.61",
3
+ "version": "1.21.62",
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",
@@ -9138,5 +9138,15 @@
9138
9138
  "id": "1004570",
9139
9139
  "text": "A project owner now sees a Danger zone in their project's settings, with links straight to the place where they can take the project offline or delete it. Nobody else sees it, and self-hosted projects don't show it."
9140
9140
  }
9141
+ ],
9142
+ "1.21.62": [
9143
+ {
9144
+ "id": "1003461",
9145
+ "text": "Decided how planning works in the hall: simple forms for creating tasks, goals and criteria, while version planning stays a guided Claude session. A goal made in the hall now comes with its own 'spec this goal' task that the r"
9146
+ },
9147
+ {
9148
+ "id": "1004584",
9149
+ "text": "Shipping finished tasks now works for projects that install Bongos as a package, so their work lands instead of getting stuck halfway."
9150
+ }
9141
9151
  ]
9142
9152
  }
@@ -9,6 +9,7 @@
9
9
  'use strict';
10
10
 
11
11
  const crypto = require('node:crypto');
12
+ const path = require('node:path');
12
13
  const { spawn } = require('node:child_process');
13
14
  const { cliClient } = require('./cli-lib');
14
15
  const grader = require('../../modules/grading/grader');
@@ -22,7 +23,10 @@ const grader = require('../../modules/grading/grader');
22
23
  const { hasCopyOrVisualWork } = require('../../modules/lifecycle/cascade.js');
23
24
  const { resolveBuilderModel } = require('./ship-memory-sync.js');
24
25
  const { argvOut, gitArgvOk, gitOk, shellOk } = require('./ship-git.js');
25
- const { GRADER_DIFF_CHAR_CAP, GRADER_TIMEOUT_MS, REPO_ROOT, noteServerReward } = require('./ship-state.js');
26
+ const { GRADER_DIFF_CHAR_CAP, GRADER_TIMEOUT_MS, CORE_ROOT, REPO_ROOT, noteServerReward } = require('./ship-state.js');
27
+
28
+ // The core's smoke suite, by absolute path from the package dir (task 1004584).
29
+ const SMOKE_SCRIPT = path.join(CORE_ROOT, 'scripts', 'gds', 'smoke-gds.js');
26
30
 
27
31
  // ---------- Step 1+2: local verification ----------
28
32
 
@@ -380,10 +384,13 @@ async function runNoArtifactGrader({ task, valueSummary, notes, runOpts: extraOp
380
384
  }
381
385
 
382
386
  function runSmoke() {
383
- // `cwd: REPO_ROOT` + a relative script path is robust to spaces or other
384
- // shell metacharacters that may appear in the absolute repo path on some
385
- // machines.
386
- const okPms = shellOk(`"${process.execPath}" scripts/gds/smoke-gds.js`, { cwd: REPO_ROOT });
387
+ // The script is addressed from CORE_ROOT, never cwd-relative: in a vendored
388
+ // instance it lives under node_modules/@bongos/core, not under the instance
389
+ // checkout, and the relative form died MODULE_NOT_FOUND after a clean merge —
390
+ // which then reset main and stranded the task at confirmed (task 1004584).
391
+ // cwd stays the instance checkout so instance config resolves as before; the
392
+ // JSON.stringify quoting survives spaces in either path.
393
+ const okPms = shellOk(`"${process.execPath}" ${JSON.stringify(SMOKE_SCRIPT)}`, { cwd: REPO_ROOT });
387
394
  return { ran: true, clean: okPms };
388
395
  }
389
396
 
@@ -394,7 +401,7 @@ function runSmoke() {
394
401
  // post-merge smoke call keeps the sync form (nothing overlaps there).
395
402
  function runSmokeAsync() {
396
403
  return new Promise((resolve) => {
397
- const child = spawn(process.execPath, ['scripts/gds/smoke-gds.js'], {
404
+ const child = spawn(process.execPath, [SMOKE_SCRIPT], {
398
405
  cwd: REPO_ROOT, stdio: 'inherit', windowsHide: true,
399
406
  });
400
407
  child.on('error', () => resolve({ ran: true, clean: false }));
@@ -168,7 +168,32 @@ function removeProbe(repoRoot, dir) {
168
168
  // tip as it stood BEFORE the branch was merged in (in ship-merge.js that is
169
169
  // origin/main, since the ff-only step immediately above it guarantees the two
170
170
  // are the same commit).
171
+ // gateApplies — does this repo carry the fitness gate at all? A VENDORED
172
+ // instance (core under node_modules/@bongos/core) has no scripts/gds/fitness.js
173
+ // of its own — fitness is the core's architecture gate, and the core ran it in
174
+ // its own CI before it was published. Without this the gate read the missing
175
+ // file as 'unmeasurable' on BOTH sides and blocked every vendored land
176
+ // (task 1004584). It only stands down when the pre-merge base lacks the file
177
+ // too, so a branch that DELETES fitness.js from the core is still measured
178
+ // (and blocked as unmeasurable) rather than waved through.
179
+ function gateApplies(repoRoot, baseSha) {
180
+ if (fs.existsSync(path.join(repoRoot, 'scripts', 'gds', 'fitness.js'))) return true;
181
+ if (!baseSha) return true;
182
+ try {
183
+ execFileSync('git', ['cat-file', '-e', `${baseSha}:scripts/gds/fitness.js`], {
184
+ cwd: repoRoot, stdio: 'ignore', shell: false,
185
+ });
186
+ return true; // the base had it — this merge removed it; measure (and block)
187
+ } catch (_) {
188
+ return false; // neither side carries the gate — nothing to run
189
+ }
190
+ }
191
+
171
192
  function gateMergeResult({ repoRoot, baseSha, log = console.log }) {
193
+ if (!gateApplies(repoRoot, baseSha)) {
194
+ log(' fitness: this repo carries no scripts/gds/fitness.js (a vendored instance) — gate not applicable');
195
+ return { block: false, reason: 'not-applicable' };
196
+ }
172
197
  log(' fitness against post-merge state...');
173
198
  const mergeClean = runFitnessAt(repoRoot);
174
199
  const baseClean = mergeClean ? true : probeBaseClean(repoRoot, baseSha);
@@ -179,6 +204,7 @@ function gateMergeResult({ repoRoot, baseSha, log = console.log }) {
179
204
 
180
205
  module.exports = {
181
206
  fitnessOutcome,
207
+ gateApplies,
182
208
  gateMergeResult,
183
209
  gateMessage,
184
210
  mergeGateVerdict,
@@ -31,7 +31,7 @@ const { runSmoke } = require('./ship-grade.js');
31
31
  const { gateMergeResult } = require('./ship-merge-gate.js');
32
32
  const { dbOnlyIgnoredNotice } = require('./ship-honesty.js');
33
33
  const { currentBranch, mainWorktreeBusy, regenerateApiClient, regenerateApiDocs, regenerateCopyInventory, regenerateDiagrams, regenerateFileMap, regenerateRepoMap, regenerateSessionIndex } = require('./ship-regen.js');
34
- const { REPO_ROOT } = require('./ship-state.js');
34
+ const { MAIN_CHECKED_OUT, REPO_ROOT } = require('./ship-state.js');
35
35
  const { envPrefix } = require('../../src/instance-config');
36
36
 
37
37
  // zeroCommitVerdict — dbOnly is deliberately consulted ONLY at zero commits
@@ -265,9 +265,14 @@ async function mergeLocally(taskId, valueSummary, { dbOnly = false } = {}) {
265
265
  // — the branch lands fine via a PR. Tagged PR-recoverable so autoMerge falls
266
266
  // back instead of parking the task at 'confirmed' unpaid. The reason +
267
267
  // nextStep are kept for the case where the fallback itself bails.
268
+ // task 1004584: when NO worktree holds main at all, say exactly that and
269
+ // how to fix it — REPO_ROOT is then the primary checkout by fallback, and
270
+ // "work in your own worktree" is advice the builder already followed.
268
271
  return landBailed(
269
- `the main checkout is on ${onMain}, not main — work a task in its own worktree so the main checkout stays on main`,
270
- '/merge-mode',
272
+ MAIN_CHECKED_OUT
273
+ ? `the main checkout is on ${onMain}, not main — work a task in its own worktree so the main checkout stays on main`
274
+ : `no worktree of this repo has main checked out (the main checkout ${REPO_ROOT} is on ${onMain}) — run \`git checkout main\` there, then re-ship`,
275
+ MAIN_CHECKED_OUT ? '/merge-mode' : `git -C "${REPO_ROOT}" checkout main && node scripts/gds/ship.js ${taskId}`,
271
276
  LAND_BAIL_MAIN_CHECKOUT_ON_BRANCH,
272
277
  );
273
278
  }
@@ -448,6 +453,10 @@ async function mergeLocally(taskId, valueSummary, { dbOnly = false } = {}) {
448
453
 
449
454
  module.exports = {
450
455
  autoMerge,
456
+ // task 1004584: the local merge leg on its own, so the vendored-instance
457
+ // regression test can read a bail's reason without the PR fallback (which
458
+ // needs an authed gh) swallowing it.
459
+ mergeLocally,
451
460
  // task 1004322: the held page tweak's publish-only path.
452
461
  publishHeld,
453
462
  // task 1002572 (PART 2): the pure recovery command for a ship run from main —
@@ -18,7 +18,7 @@
18
18
  'use strict';
19
19
 
20
20
  const path = require('node:path');
21
- const { findMainWorktree } = require('../../modules/lifecycle/ship-preflight');
21
+ const { findMainWorktree, findPrimaryWorktree } = require('../../modules/lifecycle/ship-preflight');
22
22
 
23
23
  // The instance's reward policy, as reported by the server on GET /me (ADR 0146 /
24
24
  // task 1002469). Cached module-level because the credits print + the completion
@@ -43,10 +43,43 @@ function getServerReward() {
43
43
  const GRADER_DIFF_CHAR_CAP = 250_000;
44
44
  const GRADER_TIMEOUT_MS = 240_000; // 4 min — Opus on a big diff with Read tool calls
45
45
 
46
- // Where auto-merge runs — the worktree that has `main` checked out, not the
47
- // feature worktree this script lives in. Falls back to two-levels-up if we
48
- // can't ask git (e.g. fresh clone with no `main` worktree set up yet).
49
- const REPO_ROOT = findMainWorktree(__dirname) || path.resolve(__dirname, '..', '..');
46
+ // TWO ROOTS, not one (task 1004584). In the core's own repo they are the same
47
+ // folder, which is how a single REPO_ROOT served both jobs for so long. In a
48
+ // VENDORED instance — the core installed as node_modules/@bongos/core — they are
49
+ // not, and conflating them broke every land (mercury, core 1.21.20):
50
+ // CORE_ROOT — where the core's own scripts live (the package dir). Every
51
+ // core script ship spawns (smoke-gds.js, …) is addressed from
52
+ // here, never by a cwd-relative path.
53
+ // INSTANCE_ROOT — the instance repo's checkout that auto-merge runs in: the
54
+ // worktree with `main` checked out (primary OR linked). With no
55
+ // `main` anywhere it is the primary checkout, so the merge leg
56
+ // bails with "on X, not main" — never the package dir, which is
57
+ // not a repo and turned the bail into an ENOENT on the lock.
58
+ // Git is asked from __dirname first (unchanged for the core repo), then from the
59
+ // process cwd (the feature worktree ship is run from) as a backstop.
60
+ const CORE_ROOT = path.resolve(__dirname, '..', '..');
61
+
62
+ // resolveRoots — the pure resolution, with git injected so the vendored layouts
63
+ // are unit-testable. `mainCheckedOut` is false when no worktree holds `main`;
64
+ // ship-merge names that case in its bail instead of a generic "on X".
65
+ function resolveRoots({
66
+ dirname = __dirname,
67
+ cwd = process.cwd(),
68
+ findMain = findMainWorktree,
69
+ findPrimary = findPrimaryWorktree,
70
+ } = {}) {
71
+ const core = path.resolve(dirname, '..', '..');
72
+ const main = findMain(dirname) || findMain(cwd);
73
+ if (main) return { coreRoot: core, instanceRoot: main, mainCheckedOut: true };
74
+ const primary = findPrimary(dirname) || findPrimary(cwd);
75
+ return { coreRoot: core, instanceRoot: primary || core, mainCheckedOut: false };
76
+ }
77
+
78
+ const { instanceRoot: INSTANCE_ROOT, mainCheckedOut: MAIN_CHECKED_OUT } = resolveRoots();
79
+ // The historical name every ship phase imports. It always meant "the checkout
80
+ // auto-merge runs in", i.e. the instance root — kept as an alias so the phases
81
+ // that only ever wanted that do not churn.
82
+ const REPO_ROOT = INSTANCE_ROOT;
50
83
 
51
84
  // Sound playback is COSMETIC — the ship pipeline must NEVER depend on it. Guard
52
85
  // the require so a missing/broken player (e.g. .claude/sounds/ absent on a fresh
@@ -68,6 +101,10 @@ module.exports = {
68
101
  GRADER_DIFF_CHAR_CAP,
69
102
  GRADER_TIMEOUT_MS,
70
103
  REPO_ROOT,
104
+ CORE_ROOT,
105
+ INSTANCE_ROOT,
106
+ MAIN_CHECKED_OUT,
107
+ resolveRoots,
71
108
  playSound,
72
109
  BEHIND_MAIN_LIMIT,
73
110
  };
package/src/module-api.js CHANGED
@@ -75,7 +75,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
75
75
  // MAJOR (see allowBoxScope below): passes the request through untouched.
76
76
  function deprecatedNoopMiddleware(_req, _res, next) { next(); }
77
77
 
78
- const CORE_VERSION = '1.21.61'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
78
+ const CORE_VERSION = '1.21.62'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
79
79
 
80
80
  // A namespaced logger so a module's log lines are attributable + consistent.
81
81
  // Usage: const log = api.logger('discord'); log.info('mounted');
@@ -9,6 +9,7 @@
9
9
 
10
10
  import assert from 'node:assert/strict';
11
11
  import { createRequire } from 'node:module';
12
+ import { execSync } from 'node:child_process';
12
13
  import fs from 'node:fs';
13
14
  import os from 'node:os';
14
15
  import path from 'node:path';
@@ -292,6 +293,63 @@ test('recoverWorktree: clean reclaim → no churn (no abort, no reset)', () => {
292
293
  }
293
294
  });
294
295
 
296
+ // ---- linked worktrees (task 1004584) ----
297
+ // When `main` is checked out in a LINKED worktree, <root>/.git is a FILE, so the
298
+ // old <root>/.git/otb-automerge.lock was ENOENT on every acquire. The lock now
299
+ // lives in the common git dir — the same file from every worktree.
300
+
301
+ function realRepoWithLinkedWorktree() {
302
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'otb-mergelock-linked-'));
303
+ const run = (cmd) => execSync(cmd, { cwd: dir, stdio: 'ignore' });
304
+ run('git init -q -b trunk');
305
+ run('git config user.email t@t.t');
306
+ run('git config user.name t');
307
+ fs.writeFileSync(path.join(dir, 'a.txt'), 'x\n');
308
+ run('git add a.txt');
309
+ run('git commit -q -m init');
310
+ const linked = path.join(dir, 'wt-main');
311
+ run(`git worktree add -q -b main "${linked}"`);
312
+ return { dir, linked };
313
+ }
314
+
315
+ test('lockPath: from a linked worktree, resolves to the COMMON git dir (same file as the primary)', () => {
316
+ const { dir, linked } = realRepoWithLinkedWorktree();
317
+ try {
318
+ assert.ok(fs.statSync(path.join(linked, '.git')).isFile(), 'precondition: .git is a gitdir pointer file');
319
+ assert.equal(
320
+ fs.realpathSync.native(path.dirname(lock.lockPath(linked))),
321
+ fs.realpathSync.native(path.join(dir, '.git')),
322
+ );
323
+ assert.equal(path.basename(lock.lockPath(linked)), 'otb-automerge.lock');
324
+ } finally {
325
+ cleanup(dir);
326
+ }
327
+ });
328
+
329
+ test('acquire/release: work from a linked main worktree, and the primary sees the hold', () => {
330
+ const { dir, linked } = realRepoWithLinkedWorktree();
331
+ try {
332
+ assert.equal(lock.acquire(linked, { waitMs: 0 }), true);
333
+ assert.equal(lock.readLock(dir).pid, process.pid, 'the primary must see the hold the linked worktree took');
334
+ assert.equal(lock.acquire(dir, { waitMs: 0, sleep: () => {} }), false, 'a second holder from the primary must be refused');
335
+ lock.release(linked);
336
+ assert.equal(lock.readLock(dir), null);
337
+ } finally {
338
+ cleanup(dir);
339
+ }
340
+ });
341
+
342
+ test('indexLockPath: a linked worktree keeps its index.lock in its OWN git dir', () => {
343
+ const { dir, linked } = realRepoWithLinkedWorktree();
344
+ try {
345
+ const p = lock.indexLockPath(linked);
346
+ assert.equal(path.basename(p), 'index.lock');
347
+ assert.match(p.replace(/\\/g, '/'), /\.git\/worktrees\/wt-main\/index\.lock$/);
348
+ } finally {
349
+ cleanup(dir);
350
+ }
351
+ });
352
+
295
353
  // ---- runner ----
296
354
 
297
355
  let pass = 0;
@@ -17,6 +17,7 @@
17
17
  import assert from 'node:assert/strict';
18
18
  import { test } from 'node:test';
19
19
  import { createRequire } from 'node:module';
20
+ import { execSync } from 'node:child_process';
20
21
  import fs from 'node:fs';
21
22
  import os from 'node:os';
22
23
  import path from 'node:path';
@@ -170,3 +171,65 @@ test('an unmeasurable base blocks rather than waving the ship through', () => {
170
171
  // it could not measure, and null blocks.
171
172
  assert.equal(gate.mergeGateVerdict({ mergeClean: false, baseClean: null }).block, true);
172
173
  });
174
+
175
+ // ---- gateApplies (task 1004584) ----
176
+ // A VENDORED instance (core under node_modules/@bongos/core) carries no
177
+ // scripts/gds/fitness.js of its own, so both sides read 'unmeasurable' and every
178
+ // vendored land was blocked. The gate stands down ONLY when the base lacked the
179
+ // file too — a branch that deletes fitness.js from the core is still measured.
180
+ function gitRepo(withFitness) {
181
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'merge-gate-applies-'));
182
+ const run = (cmd) => execSync(cmd, { cwd: dir, stdio: 'ignore' });
183
+ run('git init -q -b main');
184
+ run('git config user.email t@t.t');
185
+ run('git config user.name t');
186
+ fs.writeFileSync(path.join(dir, 'README.md'), 'x\n');
187
+ if (withFitness) {
188
+ fs.mkdirSync(path.join(dir, 'scripts', 'gds'), { recursive: true });
189
+ fs.writeFileSync(path.join(dir, 'scripts', 'gds', 'fitness.js'), 'process.exit(0);\n');
190
+ }
191
+ run('git add -A');
192
+ run('git commit -q -m base');
193
+ const baseSha = execSync('git rev-parse HEAD', { cwd: dir, encoding: 'utf8' }).trim();
194
+ return { dir, baseSha };
195
+ }
196
+
197
+ test('gateApplies: a repo that carries fitness.js is always gated', () => {
198
+ const { dir, baseSha } = gitRepo(true);
199
+ try {
200
+ assert.equal(gate.gateApplies(dir, baseSha), true);
201
+ } finally {
202
+ fs.rmSync(dir, { recursive: true, force: true });
203
+ }
204
+ });
205
+
206
+ test('gateApplies: a vendored instance (no fitness.js on either side) stands the gate down', () => {
207
+ const { dir, baseSha } = gitRepo(false);
208
+ try {
209
+ assert.equal(gate.gateApplies(dir, baseSha), false);
210
+ const verdict = gate.gateMergeResult({ repoRoot: dir, baseSha, log: () => {} });
211
+ assert.deepEqual(verdict, { block: false, reason: 'not-applicable' });
212
+ } finally {
213
+ fs.rmSync(dir, { recursive: true, force: true });
214
+ }
215
+ });
216
+
217
+ test('gateApplies: a merge that DELETES fitness.js from a gated base is still measured (and blocked)', () => {
218
+ const { dir, baseSha } = gitRepo(true);
219
+ try {
220
+ fs.rmSync(path.join(dir, 'scripts', 'gds', 'fitness.js'));
221
+ execSync('git commit -q -am "drop the gate"', { cwd: dir, stdio: 'ignore' });
222
+ assert.equal(gate.gateApplies(dir, baseSha), true, 'the base had the gate, so its removal must not wave the merge through');
223
+ } finally {
224
+ fs.rmSync(dir, { recursive: true, force: true });
225
+ }
226
+ });
227
+
228
+ test('gateApplies: with no base sha it cannot prove the gate was never there → gated', () => {
229
+ const { dir } = gitRepo(false);
230
+ try {
231
+ assert.equal(gate.gateApplies(dir, null), true);
232
+ } finally {
233
+ fs.rmSync(dir, { recursive: true, force: true });
234
+ }
235
+ });
@@ -13,7 +13,7 @@ import path from 'node:path';
13
13
 
14
14
  const require = createRequire(import.meta.url);
15
15
  const {
16
- parsePorcelain, trackedDirty, classifyEmptyShip, cleanupMacFinderDupRefs, findMainWorktree, closeoutPlan,
16
+ parsePorcelain, trackedDirty, classifyEmptyShip, cleanupMacFinderDupRefs, findMainWorktree, findPrimaryWorktree, closeoutPlan,
17
17
  untrackedPaths, untrackedLeftBehind, untrackedWarningLines,
18
18
  } = require('../modules/lifecycle/ship-preflight.js');
19
19
  // task 1001436: the preflight step itself + the ship-input resolver it is handed.
@@ -318,6 +318,51 @@ test('findMainWorktree: returns null outside any git repo', () => {
318
318
  }
319
319
  });
320
320
 
321
+ // ---- findPrimaryWorktree (task 1004584) ----
322
+ // The merge target when NO worktree has main: the primary checkout, whatever it
323
+ // is on — so ship can say "check out main there" instead of falling back to the
324
+ // core package dir (a vendored instance's node_modules/@bongos/core).
325
+
326
+ test('findPrimaryWorktree: from a linked worktree, returns the primary checkout even when it is off main', () => {
327
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'otb-primarywt-'));
328
+ try {
329
+ execSync('git init -q -b trunk', { cwd: tmp });
330
+ execSync('git config user.email t@t.t && git config user.name t', { cwd: tmp });
331
+ fs.writeFileSync(path.join(tmp, 'a.txt'), 'x');
332
+ execSync('git add a.txt && git commit -q -m init', { cwd: tmp });
333
+ execSync('git branch feature', { cwd: tmp });
334
+ const wt = path.join(tmp, 'linked');
335
+ execSync(`git worktree add -q "${wt}" feature`, { cwd: tmp });
336
+
337
+ assert.equal(findMainWorktree(wt), null, 'precondition: no worktree is on main');
338
+ const found = findPrimaryWorktree(wt);
339
+ assert.ok(found, 'expected a path');
340
+ assert.equal(fs.realpathSync.native(found), fs.realpathSync.native(tmp));
341
+ } finally {
342
+ fs.rmSync(tmp, { recursive: true, force: true });
343
+ }
344
+ });
345
+
346
+ test('findPrimaryWorktree: a BARE primary is not a checkout to merge in → null', () => {
347
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'otb-primarywt-bare-'));
348
+ try {
349
+ const bare = path.join(tmp, 'repo.git');
350
+ execSync(`git init -q --bare -b main "${bare}"`, { cwd: tmp });
351
+ assert.equal(findPrimaryWorktree(bare), null);
352
+ } finally {
353
+ fs.rmSync(tmp, { recursive: true, force: true });
354
+ }
355
+ });
356
+
357
+ test('findPrimaryWorktree: returns null outside any git repo', () => {
358
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'otb-primarywt-nogit-'));
359
+ try {
360
+ assert.equal(findPrimaryWorktree(tmp), null);
361
+ } finally {
362
+ fs.rmSync(tmp, { recursive: true, force: true });
363
+ }
364
+ });
365
+
321
366
  // ---- closeoutPlan (task 932) ----
322
367
 
323
368
  test('closeoutPlan: clean tree → clean:true, no leftover', () => {
@@ -0,0 +1,259 @@
1
+ // tests/ship_vendored_instance.mjs
2
+ //
3
+ // task 1004584 — `bongos ship` could not land a task in a VENDORED instance (the
4
+ // core installed as node_modules/@bongos/core). Reported from mercury on core
5
+ // 1.21.20, Windows, laptop deploy mode. One REPO_ROOT meant both "the instance
6
+ // repo" (merge target, lock) and "where the core's scripts live" (smoke), and in a
7
+ // vendored instance those are two different folders:
8
+ //
9
+ // 1. no worktree had `main` checked out → REPO_ROOT fell back to the package
10
+ // dir → the merge lock opened node_modules/@bongos/core/.git/… → ENOENT;
11
+ // 2. `main` checked out in a LINKED worktree → <root>/.git is a FILE → the same
12
+ // ENOENT on the lock;
13
+ // 3. `main` in the primary checkout → the merge succeeded, then the post-merge
14
+ // smoke ran `node scripts/gds/smoke-gds.js` cwd-relative to the instance →
15
+ // MODULE_NOT_FOUND → ship reset main and the task stuck at `confirmed`.
16
+ //
17
+ // This builds the real layout and runs ship's real local merge leg in it: a bare
18
+ // origin, an instance clone whose node_modules/@bongos/core holds a COPY of the
19
+ // core files ship loads, a per-task linked worktree on the task branch, and a
20
+ // child process that requires the vendored copy of ship-merge.js and calls
21
+ // mergeLocally from the feature worktree — the shape `bongos ship` runs in.
22
+ //
23
+ // What is stubbed, and why: smoke-gds.js (the real one probes a live server — the
24
+ // stub only proves it was found at the CORE path and run), and cli-lib's
25
+ // cliClient (the land-proof POST). The deploy mode is pinned to laptop by env so
26
+ // no gh probe runs. Everything else — git, the lock, the fitness gate, the push
27
+ // to origin — is the real code against real repos.
28
+ //
29
+ // Run: node tests/ship_vendored_instance.mjs
30
+
31
+ import { strict as assert } from 'node:assert';
32
+ import { createRequire } from 'node:module';
33
+ import { execFileSync, spawnSync } from 'node:child_process';
34
+ import fs from 'node:fs';
35
+ import os from 'node:os';
36
+ import path from 'node:path';
37
+ import { fileURLToPath } from 'node:url';
38
+
39
+ const require = createRequire(import.meta.url);
40
+ const REPO = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
41
+ const TASK = 4242;
42
+ const BRANCH = `task-${TASK}`;
43
+
44
+ // The core as an install would carry it: every tracked file under the paths the
45
+ // package ships code from (media skipped — nothing on the merge leg reads it).
46
+ // Read from the working tree, so the test exercises the code being changed.
47
+ // Built ONCE into a template and copied into each fixture.
48
+ const CORE_FILES = execFileSync('git', ['ls-files', '-z', 'src', 'scripts', 'modules', 'config', 'package.json', '.claude/sounds'], {
49
+ cwd: REPO, encoding: 'utf8',
50
+ }).split('\0').filter((f) => f && !/\.(png|jpe?g|webp|gif|svg|mp3|wav|ogg)$/i.test(f) && fs.existsSync(path.join(REPO, f)));
51
+ const CORE_TEMPLATE = fs.mkdtempSync(path.join(os.tmpdir(), 'bongos-vendored-core-'));
52
+ for (const rel of CORE_FILES) {
53
+ const dst = path.join(CORE_TEMPLATE, rel);
54
+ fs.mkdirSync(path.dirname(dst), { recursive: true });
55
+ fs.copyFileSync(path.join(REPO, rel), dst);
56
+ }
57
+
58
+ // A git env that ignores the developer's own config (hooks, signing, drivers).
59
+ const emptyCfg = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'bongos-vendored-cfg-')), 'gitconfig');
60
+ fs.writeFileSync(emptyCfg, '');
61
+ const GIT_ENV = {
62
+ ...process.env,
63
+ GIT_CONFIG_NOSYSTEM: '1',
64
+ GIT_CONFIG_GLOBAL: emptyCfg,
65
+ GIT_AUTHOR_NAME: 'Vendored Test', GIT_AUTHOR_EMAIL: 'vendored@example.test',
66
+ GIT_COMMITTER_NAME: 'Vendored Test', GIT_COMMITTER_EMAIL: 'vendored@example.test',
67
+ };
68
+ function git(cwd, ...args) {
69
+ return execFileSync('git', args, { cwd, env: GIT_ENV, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim();
70
+ }
71
+
72
+ function writeFile(p, body) {
73
+ fs.mkdirSync(path.dirname(p), { recursive: true });
74
+ fs.writeFileSync(p, body);
75
+ }
76
+
77
+ // Build one fixture. `mainAt`: 'primary' | 'linked' | 'none'.
78
+ function buildFixture(mainAt) {
79
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), `bongos-vendored-${mainAt}-`));
80
+ const origin = path.join(root, 'origin.git');
81
+ const inst = path.join(root, 'instance');
82
+ git(root, 'init', '--bare', '-b', 'main', origin);
83
+ git(root, 'clone', '-q', origin, inst);
84
+ git(inst, 'checkout', '-q', '-b', 'main');
85
+ writeFile(path.join(inst, '.gitignore'), 'node_modules/\n');
86
+ writeFile(path.join(inst, 'README.md'), '# a vendored instance\n');
87
+ git(inst, 'add', '.');
88
+ git(inst, 'commit', '-q', '-m', 'instance: initial');
89
+ git(inst, 'push', '-q', '-u', 'origin', 'main');
90
+
91
+ // The vendored core, where `npm install` would put it.
92
+ const core = path.join(inst, 'node_modules', '@bongos', 'core');
93
+ fs.cpSync(CORE_TEMPLATE, core, { recursive: true });
94
+ // The smoke stub records WHERE it was run from — the bug was it not being found.
95
+ writeFile(path.join(core, 'scripts', 'gds', 'smoke-gds.js'),
96
+ "require('fs').writeFileSync(process.env.SMOKE_MARKER, JSON.stringify({ script: __filename, cwd: process.cwd() }));\n");
97
+
98
+ // The task's own worktree + branch, carrying one commit.
99
+ const feature = path.join(root, 'wt-task');
100
+ git(inst, 'worktree', 'add', '-q', '-b', BRANCH, feature, 'main');
101
+ writeFile(path.join(feature, 'feature.txt'), 'the work\n');
102
+ git(feature, 'add', 'feature.txt');
103
+ git(feature, 'commit', '-q', '-m', 'the task work');
104
+
105
+ // Where `main` lives.
106
+ let mainDir = inst;
107
+ if (mainAt !== 'primary') {
108
+ git(inst, 'checkout', '-q', '-b', 'scratch'); // the primary checkout leaves main
109
+ if (mainAt === 'linked') {
110
+ mainDir = path.join(root, 'wt-main');
111
+ git(inst, 'worktree', 'add', '-q', mainDir, 'main');
112
+ } else {
113
+ mainDir = null;
114
+ }
115
+ }
116
+ return { root, origin, inst, core, feature, mainDir };
117
+ }
118
+
119
+ // Run ship's local merge leg from the vendored copy, in the feature worktree.
120
+ function runMergeLeg(fx) {
121
+ const marker = path.join(fx.root, 'smoke-ran.json');
122
+ const runner = path.join(fx.root, 'run-merge.js');
123
+ fs.writeFileSync(runner, `
124
+ const path = require('path');
125
+ const core = ${JSON.stringify(fx.core)};
126
+ const cli = require(path.join(core, 'scripts', 'gds', 'cli-lib.js'));
127
+ cli.cliClient = async () => ({ tasks: { postTasksIdAttestGate: async () => ({ ok: true, status: 200, data: { tip_recorded: true } }) } });
128
+ const state = require(path.join(core, 'scripts', 'gds', 'ship-state.js'));
129
+ const { mergeLocally } = require(path.join(core, 'scripts', 'gds', 'ship-merge.js'));
130
+ mergeLocally(${TASK}, 'vendored land').then(
131
+ (o) => { console.log('ROOTS ' + JSON.stringify({ core: state.CORE_ROOT, instance: state.INSTANCE_ROOT, main: state.MAIN_CHECKED_OUT })); console.log('RESULT ' + JSON.stringify(o)); },
132
+ (e) => { console.log('THREW ' + JSON.stringify({ code: e.code, message: e.message })); process.exitCode = 1; },
133
+ );
134
+ `);
135
+ const env = { ...GIT_ENV, SMOKE_MARKER: marker, BONGOS_DEPLOY_MODE: 'laptop'};
136
+ for (const k of ['BONGOS_DEPLOY_SSH_TARGET', 'OTB_DEPLOY_SSH_TARGET', 'BONGOS_DEPLOY_HEALTHZ_URL', 'OTB_DEPLOY_HEALTHZ_URL']) delete env[k];
137
+ const r = spawnSync(process.execPath, [runner], { cwd: fx.feature, env, encoding: 'utf8', timeout: 120000 });
138
+ const out = `${r.stdout || ''}\n${r.stderr || ''}`;
139
+ const line = (tag) => {
140
+ const l = out.split('\n').find((s) => s.startsWith(`${tag} `));
141
+ return l ? JSON.parse(l.slice(tag.length + 1)) : null;
142
+ };
143
+ return { out, result: line('RESULT'), roots: line('ROOTS'), threw: line('THREW'), marker };
144
+ }
145
+
146
+ // The two failure signatures from the report. (Not a blanket ENOENT match: ship's
147
+ // best-effort doc regenerators also run on this leg and grumble about the core's
148
+ // docs/ folder in a vendored layout — non-fatal by design, and not this bug.)
149
+ const LOCK_ENOENT = /ENOENT[^\n]*otb-automerge\.lock/;
150
+ const SMOKE_MISSING = /Cannot find module[^\n]*smoke-gds/;
151
+ const same = (a, b) => path.resolve(a).toLowerCase() === path.resolve(b).toLowerCase();
152
+ const commonLock = (fx) => path.join(fx.inst, '.git', 'otb-automerge.lock');
153
+
154
+ let passed = 0;
155
+ let failed = 0;
156
+ const fixtures = [];
157
+ async function t(name, fn) {
158
+ try { await fn(); passed++; console.log(` PASS ${name}`); }
159
+ catch (e) { failed++; console.log(` FAIL ${name}\n ${e.message}`); }
160
+ }
161
+
162
+ function assertLanded(fx, run) {
163
+ assert.equal(run.threw, null, `the merge leg threw: ${JSON.stringify(run.threw)}\n${run.out}`);
164
+ assert.ok(run.result, `no result line:\n${run.out}`);
165
+ assert.equal(run.result.ok, true, `expected a land, got ${JSON.stringify(run.result)}\n${run.out}`);
166
+ assert.ok(!LOCK_ENOENT.test(run.out), `the merge lock hit ENOENT:\n${run.out}`);
167
+ assert.ok(!SMOKE_MISSING.test(run.out), `smoke-gds.js was not found:\n${run.out}`);
168
+ // the smoke ran — from the CORE package, with the instance checkout as cwd
169
+ const smoke = JSON.parse(fs.readFileSync(run.marker, 'utf8'));
170
+ assert.ok(same(path.dirname(smoke.script), path.join(fx.core, 'scripts', 'gds')), `smoke ran from ${smoke.script}`);
171
+ assert.ok(same(smoke.cwd, fx.mainDir), `smoke cwd was ${smoke.cwd}, expected the main checkout ${fx.mainDir}`);
172
+ // the work is on origin/main, and the lock is gone
173
+ git(fx.inst, 'fetch', '-q', 'origin');
174
+ const files = git(fx.inst, 'ls-tree', '--name-only', 'origin/main');
175
+ assert.ok(files.split('\n').includes('feature.txt'), `origin/main lacks the task's file:\n${files}`);
176
+ assert.ok(!fs.existsSync(commonLock(fx)), 'the merge lock was left behind');
177
+ }
178
+
179
+ console.log('ship merge leg in a vendored instance (task 1004584)');
180
+
181
+ // ---- resolveRoots, pure: git answers injected, no repo needed ----
182
+ const { resolveRoots } = require(path.join(REPO, 'scripts', 'gds', 'ship-state.js'));
183
+ const VENDORED_DIR = path.join('/inst', 'node_modules', '@bongos', 'core', 'scripts', 'gds');
184
+ const VENDORED_CORE = path.resolve(VENDORED_DIR, '..', '..'); // drive-qualified on Windows, as resolveRoots returns it
185
+
186
+ await t('resolveRoots: CORE_ROOT is the package dir, INSTANCE_ROOT the worktree with main', () => {
187
+ const r = resolveRoots({ dirname: VENDORED_DIR, cwd: '/wt-task', findMain: () => '/wt-main', findPrimary: () => '/inst' });
188
+ assert.deepEqual(r, { coreRoot: VENDORED_CORE, instanceRoot: '/wt-main', mainCheckedOut: true });
189
+ });
190
+
191
+ await t('resolveRoots: asks from the script dir first, then the process cwd', () => {
192
+ const asked = [];
193
+ const r = resolveRoots({
194
+ dirname: VENDORED_DIR, cwd: '/wt-task',
195
+ findMain: (d) => { asked.push(d); return d === '/wt-task' ? '/inst' : null; },
196
+ findPrimary: () => { throw new Error('primary must not be consulted when main was found'); },
197
+ });
198
+ assert.deepEqual(asked, [VENDORED_DIR, '/wt-task']);
199
+ assert.equal(r.instanceRoot, '/inst');
200
+ });
201
+
202
+ await t('resolveRoots: no main anywhere → the PRIMARY checkout, flagged, never the package dir', () => {
203
+ const r = resolveRoots({ dirname: VENDORED_DIR, cwd: '/wt-task', findMain: () => null, findPrimary: () => '/inst' });
204
+ assert.deepEqual(r, { coreRoot: VENDORED_CORE, instanceRoot: '/inst', mainCheckedOut: false });
205
+ });
206
+
207
+ await t('resolveRoots: git cannot answer at all → the package dir (the core repo itself, pre-git)', () => {
208
+ const r = resolveRoots({ dirname: VENDORED_DIR, cwd: '/x', findMain: () => null, findPrimary: () => null });
209
+ assert.deepEqual(r, { coreRoot: VENDORED_CORE, instanceRoot: VENDORED_CORE, mainCheckedOut: false });
210
+ });
211
+
212
+ // ---- the real merge leg, in a real vendored layout ----
213
+
214
+ await t('main in the PRIMARY checkout: lands, smoke found under the core package', () => {
215
+ const fx = buildFixture('primary'); fixtures.push(fx);
216
+ const run = runMergeLeg(fx);
217
+ assert.ok(same(run.roots.core, fx.core), `CORE_ROOT ${run.roots.core}`);
218
+ assert.ok(same(run.roots.instance, fx.inst), `INSTANCE_ROOT ${run.roots.instance}`);
219
+ assertLanded(fx, run);
220
+ });
221
+
222
+ await t('main in a LINKED worktree: lands, lock lives in the common git dir', () => {
223
+ const fx = buildFixture('linked'); fixtures.push(fx);
224
+ const run = runMergeLeg(fx);
225
+ assert.ok(same(run.roots.instance, fx.mainDir), `INSTANCE_ROOT ${run.roots.instance}, expected ${fx.mainDir}`);
226
+ assert.equal(run.roots.main, true);
227
+ assertLanded(fx, run);
228
+ // and the lock path ship used is the one the merge-lock CLI uses from anywhere
229
+ const lock = require(path.join(fx.core, 'modules', 'lifecycle', 'merge-lock.js'));
230
+ assert.ok(same(lock.lockPath(fx.mainDir), commonLock(fx)), `lockPath(linked main) = ${lock.lockPath(fx.mainDir)}`);
231
+ assert.ok(same(lock.lockPath(fx.inst), commonLock(fx)));
232
+ });
233
+
234
+ await t('NO worktree has main: a clear bail naming the fix, never ENOENT, nothing pushed', () => {
235
+ const fx = buildFixture('none'); fixtures.push(fx);
236
+ const run = runMergeLeg(fx);
237
+ assert.equal(run.threw, null, `the merge leg threw: ${JSON.stringify(run.threw)}\n${run.out}`);
238
+ assert.ok(!LOCK_ENOENT.test(run.out), `the merge lock hit ENOENT:\n${run.out}`);
239
+ assert.equal(run.roots.main, false);
240
+ assert.ok(same(run.roots.instance, fx.inst), `fell back to ${run.roots.instance}, not the primary checkout`);
241
+ assert.ok(!same(run.roots.instance, fx.core), 'fell back to the core package dir');
242
+ assert.equal(run.result.ok, false);
243
+ assert.match(run.result.reason, /no worktree of this repo has main checked out/);
244
+ assert.match(run.result.nextStep, /checkout main/);
245
+ assert.ok(!fs.existsSync(run.marker), 'smoke ran although nothing was merged');
246
+ const files = git(fx.inst, 'ls-tree', '--name-only', 'origin/main');
247
+ assert.ok(!files.split('\n').includes('feature.txt'), 'origin/main changed on a bail');
248
+ assert.ok(!fs.existsSync(commonLock(fx)), 'the merge lock was left behind');
249
+ });
250
+
251
+ for (const fx of fixtures) {
252
+ try { fs.rmSync(fx.root, { recursive: true, force: true }); } catch (_) { /* best-effort */ }
253
+ }
254
+ for (const dir of [path.dirname(emptyCfg), CORE_TEMPLATE]) {
255
+ try { fs.rmSync(dir, { recursive: true, force: true }); } catch (_) { /* best-effort */ }
256
+ }
257
+
258
+ console.log(`\n${passed} passed, ${failed} failed`);
259
+ process.exit(failed ? 1 : 0);