@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 +34 -24
- package/docs/adr/0366-hall-planning-is-forms-and-a-goal-made-there-carries-its-own-spec-task.md +85 -0
- package/docs/adr/README.md +1 -0
- package/docs/module-api-changelog.md +2 -0
- package/docs/onboarding/diagrams/assertions.json +1 -1
- package/modules/lifecycle/merge-lock.js +53 -9
- package/modules/lifecycle/ship-preflight.js +40 -22
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +10 -0
- package/scripts/gds/ship-grade.js +13 -6
- package/scripts/gds/ship-merge-gate.js +26 -0
- package/scripts/gds/ship-merge.js +12 -3
- package/scripts/gds/ship-state.js +42 -5
- package/src/module-api.js +1 -1
- package/tests/merge_lock.mjs +58 -0
- package/tests/ship_merge_gate.mjs +63 -0
- package/tests/ship_preflight.mjs +46 -1
- package/tests/ship_vendored_instance.mjs +259 -0
package/.bongos-core.json
CHANGED
|
@@ -2,22 +2,22 @@
|
|
|
2
2
|
"artifact": "bongos-core",
|
|
3
3
|
"manifest_schema": 1,
|
|
4
4
|
"generator": "scripts/gds/package-core.js",
|
|
5
|
-
"core_version": "1.21.
|
|
6
|
-
"core_contract": "1.21.
|
|
7
|
-
"source_commit": "
|
|
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-
|
|
9
|
+
"built_at": "2026-10-05T21:53:01.352Z",
|
|
10
10
|
"redaction": {
|
|
11
11
|
"model": "docs-redacted+functional-verbatim",
|
|
12
|
-
"docs_redacted":
|
|
12
|
+
"docs_redacted": 586,
|
|
13
13
|
"agent_docs_stubbed": 28,
|
|
14
|
-
"functional_verbatim":
|
|
14
|
+
"functional_verbatim": 2949,
|
|
15
15
|
"rules": 3,
|
|
16
16
|
"gate_literals": 3,
|
|
17
17
|
"gate": "passed"
|
|
18
18
|
},
|
|
19
|
-
"file_count":
|
|
20
|
-
"tree_sha256": "
|
|
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": "
|
|
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": "
|
|
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": "
|
|
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": "
|
|
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": "
|
|
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": "
|
|
9965
|
+
"sha256": "32077bf55d5b87cb987c0d64e579df513a72248dc79d719f878d1cfb41f4090d"
|
|
9961
9966
|
},
|
|
9962
9967
|
{
|
|
9963
9968
|
"path": "package.json",
|
|
9964
9969
|
"mode": "0000644",
|
|
9965
|
-
"sha256": "
|
|
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": "
|
|
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": "
|
|
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": "
|
|
11490
|
+
"sha256": "fba341724c94b1ab9a56b1b43cda608eaa477f50c6f8e65ec287632367d603a9"
|
|
11486
11491
|
},
|
|
11487
11492
|
{
|
|
11488
11493
|
"path": "scripts/gds/ship-merge.js",
|
|
11489
11494
|
"mode": "0000644",
|
|
11490
|
-
"sha256": "
|
|
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": "
|
|
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": "
|
|
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": "
|
|
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": "
|
|
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": "
|
|
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",
|
package/docs/adr/0366-hall-planning-is-forms-and-a-goal-made-there-carries-its-own-spec-task.md
ADDED
|
@@ -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.
|
package/docs/adr/README.md
CHANGED
|
@@ -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
|
```
|
|
@@ -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 <
|
|
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, '
|
|
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
|
-
|
|
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(
|
|
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 =
|
|
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
|
|
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(
|
|
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
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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",
|
package/release-notes.json
CHANGED
|
@@ -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
|
-
//
|
|
384
|
-
//
|
|
385
|
-
//
|
|
386
|
-
|
|
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, [
|
|
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
|
-
|
|
270
|
-
|
|
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
|
-
//
|
|
47
|
-
//
|
|
48
|
-
//
|
|
49
|
-
|
|
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.
|
|
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');
|
package/tests/merge_lock.mjs
CHANGED
|
@@ -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
|
+
});
|
package/tests/ship_preflight.mjs
CHANGED
|
@@ -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);
|