@bongos/core 1.21.122 → 1.21.124

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.122",
6
- "core_contract": "1.21.122",
7
- "source_commit": "1744caf4412e029c7cf9b465a9fae581bc2deea7",
5
+ "core_version": "1.21.124",
6
+ "core_contract": "1.21.124",
7
+ "source_commit": "695a6d5cad41a0d7a9e79cf58a7de3282b64daf3",
8
8
  "source_ref": "HEAD",
9
- "built_at": "2026-10-14T19:02:19.295Z",
9
+ "built_at": "2026-10-14T19:49:05.130Z",
10
10
  "redaction": {
11
11
  "model": "docs-redacted+functional-verbatim",
12
12
  "docs_redacted": 605,
13
13
  "agent_docs_stubbed": 28,
14
- "functional_verbatim": 3120,
14
+ "functional_verbatim": 3124,
15
15
  "rules": 3,
16
16
  "gate_literals": 3,
17
17
  "gate": "passed"
18
18
  },
19
- "file_count": 3754,
20
- "tree_sha256": "df175c2d7b99fee7204cf032575b41a9fa13988b1a3635717568822cf1d84d6b",
19
+ "file_count": 3758,
20
+ "tree_sha256": "97c9a1a6dc5c913bdacb02a6bbef3bef853e5babbbd971cd4f097403aa754be9",
21
21
  "files": [
22
22
  {
23
23
  "path": ".claude/skills/ask-for-help/SKILL.md",
@@ -107,7 +107,7 @@
107
107
  {
108
108
  "path": ".claude/skills/builder-ship/SKILL.md",
109
109
  "mode": "0000644",
110
- "sha256": "1053b6160f72c6f78d9608b8431ba59b707fb310493fdb24ea52efd52f1ef825"
110
+ "sha256": "bac025cc3ff28c70baf2de467e271bdb23fab1f3cf61100381efbc835f88a952"
111
111
  },
112
112
  {
113
113
  "path": ".claude/skills/builder-stage/SKILL.md",
@@ -2922,7 +2922,7 @@
2922
2922
  {
2923
2923
  "path": "docs/module-api-changelog.md",
2924
2924
  "mode": "0000644",
2925
- "sha256": "05a2ac9355a782e35f6af18287d0395ece73a55b0541f680106c250d1f604b63"
2925
+ "sha256": "1ead6d6c8697d53d3f324f3d5c1523f5c590ebcce268abcb2b29a367d6c34f17"
2926
2926
  },
2927
2927
  {
2928
2928
  "path": "docs/modules-contract.md",
@@ -5297,7 +5297,7 @@
5297
5297
  {
5298
5298
  "path": "modules/government/protected-surfaces.json",
5299
5299
  "mode": "0000644",
5300
- "sha256": "9aedf10b3cb611343baf96662e6f79d45ed2218b83abfb3ad8c03536c722d37c"
5300
+ "sha256": "3f7f0bd81b70a995464993a084d35951c3d98b927d0d02fbe97f84105cbdd89f"
5301
5301
  },
5302
5302
  {
5303
5303
  "path": "modules/government/ranks.js",
@@ -7242,7 +7242,7 @@
7242
7242
  {
7243
7243
  "path": "modules/lifecycle/github-push.js",
7244
7244
  "mode": "0000644",
7245
- "sha256": "65386fff8736c1b55c1f105c595758161e718352077603d1ba9aa7dd05d6d1d6"
7245
+ "sha256": "b15eb0c36f0acb41d11f52393ca9696de55b9326a483de02624b3a4d5e87250c"
7246
7246
  },
7247
7247
  {
7248
7248
  "path": "modules/lifecycle/goal-advisory.js",
@@ -7349,6 +7349,11 @@
7349
7349
  "mode": "0000644",
7350
7350
  "sha256": "69e9bf495dfdbc324e7f5664e09a5ad30e6eac896c12656b7d6ecb38d6be81f9"
7351
7351
  },
7352
+ {
7353
+ "path": "modules/lifecycle/local-land-proof.js",
7354
+ "mode": "0000644",
7355
+ "sha256": "7698b11de1978c29c1744032da5b28831a1f6823b905d60c8be8ac469916d46a"
7356
+ },
7352
7357
  {
7353
7358
  "path": "modules/lifecycle/matter-acts.js",
7354
7359
  "mode": "0000644",
@@ -7507,7 +7512,7 @@
7507
7512
  {
7508
7513
  "path": "modules/lifecycle/publish-reconciler.js",
7509
7514
  "mode": "0000644",
7510
- "sha256": "5d8bf02c3646f12debc411aa6291110c5bc4f1fe8917af9e17a4ecc5f6451e76"
7515
+ "sha256": "d03c36a2b3e50bb2334db66d177514542185a7277873891ee6d3a99c5ad10464"
7511
7516
  },
7512
7517
  {
7513
7518
  "path": "modules/lifecycle/removal-derivation.js",
@@ -10467,12 +10472,12 @@
10467
10472
  {
10468
10473
  "path": "package-lock.json",
10469
10474
  "mode": "0000644",
10470
- "sha256": "0698dfed8aaee5032ad705115bcbc515cc7328a78c31a30018a2e2f5df0cf1ca"
10475
+ "sha256": "31a3c967bca985d1dc5007279f9ed7e9352b0466128af6edd9debbf4e7b592bb"
10471
10476
  },
10472
10477
  {
10473
10478
  "path": "package.json",
10474
10479
  "mode": "0000644",
10475
- "sha256": "aefc81df15571d208d96577a7ade7189f1d1e064ae9a1af132b0725f871fcb40"
10480
+ "sha256": "4714b7161307f36c4ae4322e72f3670f6d958b1ab3b19f728a3f05f880898771"
10476
10481
  },
10477
10482
  {
10478
10483
  "path": "public-docs/index.html",
@@ -10492,7 +10497,7 @@
10492
10497
  {
10493
10498
  "path": "release-notes.json",
10494
10499
  "mode": "0000644",
10495
- "sha256": "43b16f9fbd2c3834c02bd141ce4b9e737003d1c42112e6cb607e7a118e29e7dd"
10500
+ "sha256": "aeb72646e01f775fe09e570e0f5e8dccf9733fc59cb79d5a67e6315f2195d88c"
10496
10501
  },
10497
10502
  {
10498
10503
  "path": "scripts/cron/style_guide_audit.sh",
@@ -10812,12 +10817,12 @@
10812
10817
  {
10813
10818
  "path": "scripts/gds/claude-materialize.js",
10814
10819
  "mode": "0000644",
10815
- "sha256": "1d141943f8367b8814e5a455c0c5d68a3915ac802df6cae9c3550f0628dbc384"
10820
+ "sha256": "eed7d9f195a2197d19bff7e1ebb61562a386db6bdda805db10f970afdf46b2fa"
10816
10821
  },
10817
10822
  {
10818
10823
  "path": "scripts/gds/claude-md-template.js",
10819
10824
  "mode": "0000644",
10820
- "sha256": "7659039447e370fe143327a8e55a730781d035fe6831e2af895288680ac5334f"
10825
+ "sha256": "d8560389c6a72cda1f7fe958af67b943e825b68fdd8f0fa28bf9143e42a1ece9"
10821
10826
  },
10822
10827
  {
10823
10828
  "path": "scripts/gds/cleanup-orphans.js",
@@ -11997,7 +12002,7 @@
11997
12002
  {
11998
12003
  "path": "scripts/gds/ship-flow.js",
11999
12004
  "mode": "0000644",
12000
- "sha256": "bb8edf9166b1c452b87d75e009d7404758bcba6ea58aa9011848ec44e761bb52"
12005
+ "sha256": "d944906397fc7936701bd36f3896a418829acf385c70a7ccaef07bd6cc348e91"
12001
12006
  },
12002
12007
  {
12003
12008
  "path": "scripts/gds/ship-git.js",
@@ -12049,6 +12054,11 @@
12049
12054
  "mode": "0000644",
12050
12055
  "sha256": "7a489ea223b4fa1d7a3b4ee1076c9a0cb65d6d929e9a5a6e0b8e403e15266afc"
12051
12056
  },
12057
+ {
12058
+ "path": "scripts/gds/ship-mode-manual.js",
12059
+ "mode": "0000644",
12060
+ "sha256": "077ab61bd14d0a5c8b4012103dd7289ba29a6a7d1ac3420ad401a7d6ef365597"
12061
+ },
12052
12062
  {
12053
12063
  "path": "scripts/gds/ship-preflight-steps.js",
12054
12064
  "mode": "0000644",
@@ -12087,7 +12097,7 @@
12087
12097
  {
12088
12098
  "path": "scripts/gds/ship.js",
12089
12099
  "mode": "0000644",
12090
- "sha256": "9f00804212ef485fd8cfd02be19cddb4be2570a34aa2a284f237940aff17753e"
12100
+ "sha256": "44f7161514392afb04a9165086a1fcc12e1f231d3a641220409ffe0c4ae2c9a2"
12091
12101
  },
12092
12102
  {
12093
12103
  "path": "scripts/gds/shipping-mode.js",
@@ -12227,7 +12237,7 @@
12227
12237
  {
12228
12238
  "path": "scripts/gds/update-channel.js",
12229
12239
  "mode": "0000644",
12230
- "sha256": "f6b0d78ff92c9962b158bc5b36bae021c63fcc24095c7b10866f1505ee3f5232"
12240
+ "sha256": "66bed9292a4f5ec70b15ba29a94abec5ed8c69891a5b674a030002844275880c"
12231
12241
  },
12232
12242
  {
12233
12243
  "path": "scripts/gds/update-sweep.js",
@@ -12262,7 +12272,7 @@
12262
12272
  {
12263
12273
  "path": "scripts/gds/upgrade.js",
12264
12274
  "mode": "0000644",
12265
- "sha256": "f4f158948ab880ceee1cf745ba0071983054c20deba885fed735b7828fe7c1ca"
12275
+ "sha256": "d55d4ba8c8b7d3abedbae59c28a9b1ef9c7021ccc214c6917d1a042c84019d25"
12266
12276
  },
12267
12277
  {
12268
12278
  "path": "scripts/gds/validate-design.js",
@@ -12567,7 +12577,7 @@
12567
12577
  {
12568
12578
  "path": "src/bongos/module-scope-map.js",
12569
12579
  "mode": "0000644",
12570
- "sha256": "1b0cec97fbdf2c14f48354741f5537a7db3dbc947e7b7182eb2aa9173cfa00dc"
12580
+ "sha256": "5def34a161ca00980bf8a68d4273f3183fdd7269f9b83c61e62e82c154ba881d"
12571
12581
  },
12572
12582
  {
12573
12583
  "path": "src/bongos/module-store.js",
@@ -12807,7 +12817,7 @@
12807
12817
  {
12808
12818
  "path": "src/module-api.js",
12809
12819
  "mode": "0000644",
12810
- "sha256": "d49bdec14177f77cd4e6dd63ac2532a095565373c0948d9d7b91c809e4e3e6f3"
12820
+ "sha256": "792dad669f6c0d0b86433d206c837ca7ffcbf6a8b5b7c02bda8707c3dd3f7099"
12811
12821
  },
12812
12822
  {
12813
12823
  "path": "src/module-loader/catalog.js",
@@ -13622,7 +13632,7 @@
13622
13632
  {
13623
13633
  "path": "tests/cli_allowlist_permissions.mjs",
13624
13634
  "mode": "0000644",
13625
- "sha256": "ce1d4031bf29064a8522440116e4da5dde78ffd59eb0c396b5d32a72790a260a"
13635
+ "sha256": "92bad4d5a6f224b0764bab5e7f906a0990c60f7fab75c5977ce64ba9e9b08b44"
13626
13636
  },
13627
13637
  {
13628
13638
  "path": "tests/cli_dispatcher.mjs",
@@ -14997,7 +15007,7 @@
14997
15007
  {
14998
15008
  "path": "tests/government_protected_surfaces.mjs",
14999
15009
  "mode": "0000644",
15000
- "sha256": "3518806d6b065029677e77a242604b6dc85bfd28537c2d109dacaedd29ac6d49"
15010
+ "sha256": "72fc7970c987dbb78120575546aed5faeb9e55342b68e6ba1668508927a9f103"
15001
15011
  },
15002
15012
  {
15003
15013
  "path": "tests/government_rank_sync.mjs",
@@ -16104,6 +16114,11 @@
16104
16114
  "mode": "0000644",
16105
16115
  "sha256": "64f8bd94dc461ca9b4ef82fae929d49675231afa6a24d6d65ba2dbe8721efc89"
16106
16116
  },
16117
+ {
16118
+ "path": "tests/local_land_proof.mjs",
16119
+ "mode": "0000644",
16120
+ "sha256": "0a7825f1e35c7251c66ab986555b71728575d3dff8be95f8a017dd66190d186f"
16121
+ },
16107
16122
  {
16108
16123
  "path": "tests/local_preview.mjs",
16109
16124
  "mode": "0000644",
@@ -17337,7 +17352,7 @@
17337
17352
  {
17338
17353
  "path": "tests/publish_reconciler.mjs",
17339
17354
  "mode": "0000644",
17340
- "sha256": "fac182001ce8a53bafea2d59661d2879c2ccbac55600936d151d1fe86ca59b71"
17355
+ "sha256": "882dfd49bebd89bed3c2bb20d0bf78e5f7b4da45e525492e8742b43f9c4e19a9"
17341
17356
  },
17342
17357
  {
17343
17358
  "path": "tests/publish_status_branch.mjs",
@@ -17949,6 +17964,11 @@
17949
17964
  "mode": "0000644",
17950
17965
  "sha256": "361d6c7a364c0c1f6fb8266f887fe25fc357250e4585b438fdbf3fb3fafa6849"
17951
17966
  },
17967
+ {
17968
+ "path": "tests/ship_mode_hands_off.mjs",
17969
+ "mode": "0000644",
17970
+ "sha256": "78f05df6119d9637994f628f3968eefff34aea8a04d28eaa899acc5a238ce154"
17971
+ },
17952
17972
  {
17953
17973
  "path": "tests/ship_post_land_verify.mjs",
17954
17974
  "mode": "0000644",
@@ -106,6 +106,7 @@ The CLI drives a **three-state lifecycle** with a subagent grader between states
106
106
 
107
107
  ## Constraints
108
108
 
109
+ - **Check the ship mode before you ship** (`node scripts/gds/shipping-mode.js`, task 1004631, [ADR 0369](../../../docs/adr/0369-ship-mode-decides-who-starts-a-ship-not-how-it-lands.md)). In **Auto** you run the ship yourself — never hand the person `cd …` and a ship command to type; a permission refusal on the ship command is a missing allow-rule to fix (the project's next core upgrade writes them), not a reason to hand it over. In **Manual** ask first; if the ship stops with `[otb-ask-ship-approval]` (exit 5), ask the person "Ship it, or not yet?" and re-run with `--person-approved` only after a real yes.
109
110
  - **Never ship a task you don't have an active claim on.** The API will reject it (`NOT_YOUR_CLAIM`); surface clearly and run `/builder-start` to find the right state.
110
111
  - **Don't fabricate the value summary.** If you can't summarize the value in non-jargon English, the work is probably not actually shippable yet — release instead and surface the gap to the user.
111
112
  - **Be honest in handoff notes.** Use `[verified-prod]` only when a real human exercised the feature on the live site. Otherwise `[verified-smoke]` or `[implemented-not-verified]`. The whole project relies on this honesty.
@@ -3081,5 +3081,9 @@ is load-bearing: the script throws rather than guess if it is missing, and
3081
3081
  landed since 1.21.120 with no explicit bump. run 38604777189. (task 1002620)
3082
3082
  1.21.122 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
3083
3083
  landed since 1.21.121 with no explicit bump. run 38620147259. (task 1002620)
3084
+ 1.21.123 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
3085
+ landed since 1.21.122 with no explicit bump. run 38624070870. (task 1002620)
3086
+ 1.21.124 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
3087
+ landed since 1.21.123 with no explicit bump. run 38626749089. (task 1002620)
3084
3088
  ---------------------------------------------------------------------------
3085
3089
  ```
@@ -71,6 +71,7 @@
71
71
  { "glob": "scripts/gds/ship-memory-sync.js", "group": "pipeline", "why": "task 1004024. The 2026-08-23 carve of ship.js (task 1003205): the land path, the ship state machine, the grade gate steps, the merge and pushVia, about 6,200 lines behind a 373-line protected entry point." },
72
72
  { "glob": "scripts/gds/ship-merge-gate.js", "group": "pipeline", "why": "task 1004024. The 2026-08-23 carve of ship.js (task 1003205): the land path, the ship state machine, the grade gate steps, the merge and pushVia, about 6,200 lines behind a 373-line protected entry point." },
73
73
  { "glob": "scripts/gds/ship-merge.js", "group": "pipeline", "why": "task 1004024. The 2026-08-23 carve of ship.js (task 1003205): the land path, the ship state machine, the grade gate steps, the merge and pushVia, about 6,200 lines behind a 373-line protected entry point." },
74
+ { "glob": "scripts/gds/ship-mode-manual.js", "group": "pipeline", "why": "task 1004631. Split out of the protected scripts/gds/ship.js beside it: the ship-mode step every ship runs before it lands, so it is protected at ship.js's floor (the 1004024 carve tripwire)." },
74
75
  { "glob": "scripts/gds/ship-mode-ask.js", "group": "pipeline", "why": "task 1004630. Split out beside ship.js: the first-ship ask that decides whether a project ships in Auto or Manual mode, so an edit here changes who may start a ship." },
75
76
  { "glob": "scripts/gds/ship-preflight-steps.js", "group": "pipeline", "why": "task 1004024. The 2026-08-23 carve of ship.js (task 1003205): the land path, the ship state machine, the grade gate steps, the merge and pushVia, about 6,200 lines behind a 373-line protected entry point." },
76
77
  { "glob": "scripts/gds/ship-regen.js", "group": "pipeline", "why": "task 1004024. The 2026-08-23 carve of ship.js (task 1003205): the land path, the ship state machine, the grade gate steps, the merge and pushVia, about 6,200 lines behind a 373-line protected entry point." },
@@ -79,7 +80,6 @@
79
80
  { "glob": "scripts/gds/ship-state.js", "group": "pipeline", "why": "task 1004024. The 2026-08-23 carve of ship.js (task 1003205): the land path, the ship state machine, the grade gate steps, the merge and pushVia, about 6,200 lines behind a 373-line protected entry point." },
80
81
  { "glob": "scripts/gds/ship-surprise.js", "group": "pipeline", "why": "task 1004024. The 2026-08-23 carve of ship.js (task 1003205): the land path, the ship state machine, the grade gate steps, the merge and pushVia, about 6,200 lines behind a 373-line protected entry point." },
81
82
  { "glob": "scripts/gds/ship-visual.js", "group": "pipeline", "why": "task 1004024. The 2026-08-23 carve of ship.js (task 1003205): the land path, the ship state machine, the grade gate steps, the merge and pushVia, about 6,200 lines behind a 373-line protected entry point." },
82
- { "glob": "scripts/gds/ship-mode-ask.js", "group": "pipeline", "why": "task 1004630. Split out of the protected scripts/gds/ship.js beside it: the ship-mode step every ship runs before it lands, so it is protected at ship.js's floor (the 1004024 carve tripwire)." },
83
83
  { "glob": "scripts/gds/confirm.js", "group": "pipeline", "why": "the confirm step" },
84
84
  { "glob": "scripts/gds/merge-mode.js", "group": "pipeline", "why": "the manual land fallback" },
85
85
  { "glob": "scripts/gds/ci-grade.js", "group": "pipeline", "why": "CI grade-gate driver — steers the grade check (ADR 0058)" },
@@ -1194,7 +1194,8 @@ async function landProvenForTask({ taskId, publishedSha, status }, deps = {}) {
1194
1194
  async function isAncestorOfDefaultBranch({ sha, base = 'main' } = {}, deps = {}) {
1195
1195
  if (typeof sha !== 'string' || !SAFE_SHA_RE.test(sha)) return false;
1196
1196
  const token = await resolveToken(deps);
1197
- if (!token) throw err('PUSH_UNCONFIGURED');
1197
+ // task 1004633: no credential (a laptop-mode instance) → ask the instance's own checkout.
1198
+ if (!token) return (deps.localLandProof || require('./local-land-proof')).isOnOriginMain({ sha, base }, deps.local || {});
1198
1199
  const repoInfo = deps.repoInfo || loadRepoInfo();
1199
1200
  if (!repoInfo || repoInfo.error) throw err('NO_REPO_INFO');
1200
1201
  const { owner, repo } = repoInfo;
@@ -0,0 +1,126 @@
1
+ 'use strict';
2
+
3
+ // local-land-proof — prove a land from the instance's OWN git checkout when the
4
+ // server holds no GitHub credential (task 1004633).
5
+ //
6
+ // WHY. Every land proof the lifecycle had asked GitHub: isAncestorOfDefaultBranch
7
+ // runs the compare API, and the publish reconciler returned early when
8
+ // isConfigured() was false. A laptop-mode instance (the owner's mercury project)
9
+ // merges a ship LOCALLY and pushes main itself — no PR, no server credential. So
10
+ // POST /tasks/:id/ship refused `ship_land_not_proven` (the compare threw
11
+ // PUSH_UNCONFIGURED, swallowed as "no proof"), the sweep never ran, and task 48
12
+ // sat at 'confirmed' with its merge commit on origin/main.
13
+ //
14
+ // THE PROOF is the same one the GitHub path makes, asked of a different witness:
15
+ // is the claim's recorded tip (claims.published_head_sha, written by attest-gate
16
+ // before its own credential check) an ancestor of origin/<base>? The witness is
17
+ // the remote-tracking ref after a fetch, never the local branch: a laptop ship
18
+ // merges into local main BEFORE it pushes, and a merge whose push failed must not
19
+ // count. A failed fetch (offline) falls back to the last fetched ref, which can
20
+ // only be behind the truth, so it can delay a ship but never invent one.
21
+ //
22
+ // Pure w.r.t. an injected `git` runner, so it unit-tests with no repository.
23
+
24
+ const { execFile } = require('node:child_process');
25
+ const { promisify } = require('node:util');
26
+
27
+ const execFileP = promisify(execFile);
28
+
29
+ // A real commit sha only: `merge-base` resolves refs, so "HEAD" or a branch name
30
+ // would answer about something other than the claim's tip.
31
+ const SAFE_SHA_RE = /^[0-9a-f]{7,64}$/;
32
+ const SAFE_BASE_RE = /^[A-Za-z0-9][A-Za-z0-9._/-]{0,100}$/;
33
+
34
+ function defaultGit(root) {
35
+ return (args) => execFileP('git', args, { cwd: root, timeout: 60_000, windowsHide: true });
36
+ }
37
+
38
+ function resolveRoot(deps) {
39
+ if (deps.root) return deps.root;
40
+ return require('../../src/module-api').resolveInstanceRoot();
41
+ }
42
+
43
+ async function fetchOrigin(git, base) {
44
+ try {
45
+ await git(['fetch', '--quiet', 'origin', base]);
46
+ } catch (_) {
47
+ /* offline or no credential: the last fetched origin ref still answers, only later */
48
+ }
49
+ }
50
+
51
+ // true = the tip is in origin/<base>; false = it is not (or git does not know the
52
+ // commit). Throws LOCAL_PROOF_UNAVAILABLE when there is no usable checkout (git
53
+ // missing, not a repository) so a caller can tell "not landed" from "cannot say".
54
+ async function isOnOriginMain({ sha, base = 'main' } = {}, deps = {}) {
55
+ if (typeof sha !== 'string' || !SAFE_SHA_RE.test(sha)) return false;
56
+ if (typeof base !== 'string' || !SAFE_BASE_RE.test(base) || base.includes('..')) return false;
57
+ const git = deps.git || defaultGit(resolveRoot(deps));
58
+ if (!deps.fetched) await fetchOrigin(git, base);
59
+ try {
60
+ await git(['merge-base', '--is-ancestor', sha, `refs/remotes/origin/${base}`]);
61
+ return true;
62
+ } catch (e) {
63
+ // 1 = not an ancestor; 128 = an unknown commit (never reached origin).
64
+ if (e && (e.code === 1 || e.code === 128)) return false;
65
+ const out = new Error('LOCAL_PROOF_UNAVAILABLE');
66
+ out.code = 'LOCAL_PROOF_UNAVAILABLE';
67
+ out.cause = e;
68
+ throw out;
69
+ }
70
+ }
71
+
72
+ // The reconciler's sweep for a host with no GitHub credential. Tip proof only: no
73
+ // PR, branch or deploy probe exists to consult, and a row with no recorded tip
74
+ // has nothing local to prove, so it waits (strand-watch reports it).
75
+ // One root and one fetch per sweep, not per row: origin/main is the same ref for
76
+ // every confirmed task the sweep reads.
77
+ async function sweepByLocalProof(rows, { db, emit, log = console.log, error = console.error, proof } = {}) {
78
+ if (!proof) {
79
+ let shared = null;
80
+ proof = async ({ sha }) => {
81
+ if (!shared) {
82
+ shared = { git: defaultGit(resolveRoot({})), fetched: true };
83
+ await fetchOrigin(shared.git, 'main');
84
+ }
85
+ return isOnOriginMain({ sha }, shared);
86
+ };
87
+ }
88
+ let checked = 0;
89
+ let reconciled = 0;
90
+ let errors = 0;
91
+ for (const r of rows || []) {
92
+ const tip = r && r.published_head_sha;
93
+ if (!tip || !SAFE_SHA_RE.test(String(tip))) continue;
94
+ checked++;
95
+ let landed = false;
96
+ try {
97
+ landed = await proof({ sha: String(tip) });
98
+ } catch (e) {
99
+ errors++;
100
+ error(`[publish-reconciler] task ${r.task_id}: local land proof unavailable: ${e && e.message}`);
101
+ continue;
102
+ }
103
+ if (!landed) continue;
104
+ const reason = `published tip ${String(tip).slice(0, 8)} in origin/main (local checkout; no GitHub credential)`;
105
+ try {
106
+ const shipped = await db.shipTask({
107
+ taskId: r.task_id,
108
+ builderId: r.builder_id,
109
+ landAssurance: { kind: 'proof', reason },
110
+ });
111
+ reconciled++;
112
+ log(`[publish-reconciler] task ${r.task_id} (${reason}) landed → shipped`);
113
+ try { await emit('task.shipped', shipped); } catch (e) {
114
+ error(`[publish-reconciler] ship broadcast for task ${r.task_id} (non-blocking): ${e && e.message}`);
115
+ }
116
+ } catch (e) {
117
+ if (!e || e.code !== 'TASK_NOT_CONFIRMED') {
118
+ errors++;
119
+ error(`[publish-reconciler] shipTask ${r.task_id} failed: ${e && e.message}`);
120
+ }
121
+ }
122
+ }
123
+ return { checked, reconciled, errors };
124
+ }
125
+
126
+ module.exports = { isOnOriginMain, sweepByLocalProof, SAFE_SHA_RE };
@@ -76,6 +76,7 @@
76
76
  const githubPush = require('./github-push');
77
77
  const seams = require('../../src/module-api');
78
78
  const conflictResolve = require('./conflict-resolve');
79
+ const localLandProof = require('./local-land-proof'); // task 1004633
79
80
  // The claim gate's grace for a strand its builder may be unable to clear — imported
80
81
  // rather than restated so the sweep's escalation and the gate agree on one number
81
82
  // (task 1003294).
@@ -195,10 +196,22 @@ async function runSweep(deps = {}) {
195
196
  if (!warn.unconfigured) {
196
197
  warn.unconfigured = true;
197
198
  errFn(
198
- `[publish-reconciler] WARN ${describeTarget({ configured: false, repo: resolveTarget(deps) })} — confirmed→shipped can NEVER land until a credential is set (GITHUB_APP_ID + GITHUB_APP_PRIVATE_KEY — installation id is auto-discovered — or GITHUB_PUSH_TOKEN). Subsequent sweeps stay quiet until this changes.`
199
+ `[publish-reconciler] WARN ${describeTarget({ configured: false, repo: resolveTarget(deps) })} — no GitHub credential (GITHUB_APP_ID + GITHUB_APP_PRIVATE_KEY — installation id is auto-discovered — or GITHUB_PUSH_TOKEN): confirmed→shipped lands only on a recorded tip this instance's own checkout finds in origin/main. Subsequent sweeps stay quiet until this changes.`
199
200
  );
200
201
  }
201
- return { skipped: 'unconfigured', checked: 0, reconciled: 0, merged: 0, resolvedConflicts: 0, errors: 0, strandsFlagged: 0 };
202
+ // task 1004633: a laptop-mode instance merges and pushes main itself, with no
203
+ // server credential. Returning here stranded its merged tasks at 'confirmed'
204
+ // forever (mercury task 48), so prove the recorded tip from the local checkout.
205
+ const local = deps.localLandProof || localLandProof;
206
+ let rows = [];
207
+ try {
208
+ rows = await db.listConfirmedTasksForReconcile({ limit: deps.limit || SWEEP_LIMIT });
209
+ } catch (e) {
210
+ errFn(`[publish-reconciler] could not list confirmed tasks: ${e && e.message}`);
211
+ return { skipped: 'unconfigured', checked: 0, reconciled: 0, merged: 0, resolvedConflicts: 0, errors: 1, strandsFlagged: 0 };
212
+ }
213
+ const swept = await local.sweepByLocalProof(rows, { db, emit, log: logFn, error: errFn });
214
+ return { skipped: 'unconfigured', checked: swept.checked, reconciled: swept.reconciled, merged: 0, resolvedConflicts: 0, errors: swept.errors, strandsFlagged: 0 };
202
215
  }
203
216
  warn.unconfigured = false; // re-arm: a later credential LOSS must warn again
204
217
 
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.21.122",
3
+ "version": "1.21.124",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.21.122",
9
+ "version": "1.21.124",
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.122",
3
+ "version": "1.21.124",
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",
@@ -9714,5 +9714,17 @@
9714
9714
  "id": "1004630",
9715
9715
  "text": "If a project never picked a ship mode, its next ship now pauses once and asks: Auto (recommended) or Manual. The answer is saved for the whole project and it never asks again. People who cannot change project settings, and una"
9716
9716
  }
9717
+ ],
9718
+ "1.21.123": [
9719
+ {
9720
+ "id": "1004631",
9721
+ "text": "Found why the AI refused to ship on Windows projects: the core update failed on Windows, so projects never got the permission that lets the AI run a ship. That is fixed. New projects also tell the AI to ship on its own in Auto"
9722
+ }
9723
+ ],
9724
+ "1.21.124": [
9725
+ {
9726
+ "id": "1004633",
9727
+ "text": "Found why a merged task on your own project stayed at 'confirmed': with no GitHub key set up on the project's server, Bongos could not prove the merge happened, and its background checker switched itself off. It now confirms t"
9728
+ }
9717
9729
  ]
9718
9730
  }
@@ -440,6 +440,9 @@ const STARTER_COMMANDS = [
440
440
  // tests/cli_allowlist_permissions.mjs pins this list to the core's, rule for rule.
441
441
  'npx bongos start:*',
442
442
  'npx bongos status:*',
443
+ // Exact form, no `:*`: reading the ship mode is how the agent knows whether to ship on its own
444
+ // (task 1004631, ADR 0369). `ship-mode auto|manual` WRITES it, and stays a prompt.
445
+ 'npx bongos ship-mode',
443
446
  'npx bongos recall:*',
444
447
  'npx bongos exec scripts/gds/me.js:*',
445
448
  'npx bongos doctor:*',
@@ -77,6 +77,7 @@ ${ident}
77
77
  - **Start every session with \`npx bongos start\`** (or \`/builder-start\`) — it lists the tasks you can claim right now, parallel-safe against everyone else's active claims.
78
78
  - **\`npx bongos claim <id>\`** locks a task to your session. If it refuses (already claimed, not ready, dependencies not shipped), pick another.
79
79
  - **\`npx bongos ship\`** runs the smoke + grade, then merges and deploys; **\`npx bongos release <id>\`** drops a claim with no credit. Ship or release before you sign off.
80
+ - **The project's ship mode says who starts a ship** — \`npx bongos ship-mode\` prints it. In **Auto** (the default) you run \`npx bongos ship\` yourself when the task is done; never hand the person commands to run. In **Manual** ask the person first, and ship only after they say yes.
80
81
  - **Dependencies gate everything** — a task becomes claimable only once every task it depends on has shipped.
81
82
  - **Code you write or touch carries a doc-comment** — each module a header, each public function/class a line or two, saying *what* it does and *why* it exists (never how), in the words someone would search for. Agents find code by searching text, so meaning written into the source is what makes it findable.
82
83
 
@@ -36,6 +36,25 @@ const { noteServerReward } = require('./ship-state.js');
36
36
  // task 1004536: each graded round lands in the autonomous run's ledger (inert
37
37
  // without one), so ship.js can refuse a third grade at its entry point.
38
38
  const { recordGradeOutcome } = require('./autobongos-grade-cap.js');
39
+ // task 1004631: on a Manual project an AI session asks before it ships (ADR 0369).
40
+ const manualShip = require('./ship-mode-manual.js');
41
+
42
+ // stopForManualApproval(args, taskId) — Manual ship mode (task 1004631, ADR 0369). Every path
43
+ // that can LAND work calls this first: a fresh ship, a resumed merge, and a re-grade (whose pass
44
+ // runs finishShip). When an AI session runs it on a Manual project without the person's yes, it
45
+ // prints the ask, exits 5 and returns true; the caller returns. A mode that cannot be read is
46
+ // Auto and never stops a ship. Who is stopped is ship-mode-manual.js's pure verdict.
47
+ async function stopForManualApproval(args, taskId) {
48
+ let shipMode = null;
49
+ try {
50
+ const r = await apiCall('GET', '/api/bongos/project-settings/ship-mode');
51
+ shipMode = r.ok ? r.data : null;
52
+ } catch (_) { shipMode = null; }
53
+ if (!manualShip.manualShipVerdict({ shipMode, args }).stop) return false;
54
+ for (const line of manualShip.manualAskLines(args, taskId)) console.log(line);
55
+ cliExit(manualShip.MANUAL_ASK_EXIT);
56
+ return true;
57
+ }
39
58
 
40
59
  // ---------- Main flow ----------
41
60
 
@@ -145,6 +164,7 @@ async function shipMain() {
145
164
  }
146
165
  if (action.action === 'resume-merge') {
147
166
  console.log(`Task #${taskId} is at 'confirmed' (merge/deploy stalled) and ${action.via === 'owner' ? 'you last held the claim' : 'you are an Archon'} — resuming the merge + deploy (no re-claim needed).`);
167
+ if (await stopForManualApproval(args, taskId)) return; // a resumed land is a ship too (task 1004631)
148
168
  return resumeMerge(taskId, { summary, skipMerge, force, task, dbOnly });
149
169
  }
150
170
  if (action.action === 'done') {
@@ -193,6 +213,10 @@ async function shipMain() {
193
213
  process.exit(1);
194
214
  }
195
215
 
216
+ // ---- Manual ship mode: an AI session asks the person first (task 1004631, ADR 0369) ----
217
+ // Before anything resolves, so a "not yet" leaves the claim exactly as it was.
218
+ if (await stopForManualApproval(args, taskId)) return;
219
+
196
220
  // ---- Surprise gate: forced "anything surprise you?" prompt ----
197
221
  //
198
222
  // Resolve the user's intent into a single value: either a non-empty surprise
@@ -850,6 +874,9 @@ async function regradeMain(opts = {}) {
850
874
  console.error('usage: node scripts/gds/ship.js <task-id> --regrade [--summary-file <path|-> | --summary "..."] [--response-file <path|-> | --response "..."] [--visual <image>] [--visual-alt "caption"] [--no-merge] [--force] [--verified] [--base <ref>]');
851
875
  process.exit(2);
852
876
  }
877
+ // A passing re-grade runs finishShip, the same land as a fresh ship, so Manual mode asks
878
+ // here too — whether reached by --regrade or by shipMain's resume-grade dispatch (task 1004631).
879
+ if (await stopForManualApproval(args, taskId)) return;
853
880
  // task 1003702: refuse --notes rather than swallowing it. The reasoning — why
854
881
  // a refusal and not a persist — is in ship-honesty.js above regradeNotesRefusal
855
882
  // and is not repeated here. What is load-bearing at THIS call site is the
@@ -0,0 +1,54 @@
1
+ // scripts/gds/ship-mode-manual.js — Manual ship mode: the AI asks before it ships (task 1004631, ADR 0369).
2
+ //
3
+ // WHY. Ship mode says who starts a ship. In Auto the AI ships on its own; in Manual a person
4
+ // approves each ship. The instance CLAUDE.md and the /builder-ship skill tell the AI to ask in
5
+ // Manual, but words alone are what failed the owner in Auto (the AI refused to ship), so the ship
6
+ // itself also stops: when an AI session runs it on a Manual project without the person's yes, it
7
+ // exits before anything resolves and prints the question. The re-run with `--person-approved`
8
+ // carries on.
9
+ //
10
+ // WHO IT STOPS. Only an AI session: Claude Code sets CLAUDECODE=1 in the shell it runs commands
11
+ // in, and a person typing `npx bongos ship` in their own terminal has already decided. An
12
+ // Autobongos worker (OTB_SUBAGENT=1) is not stopped: the runner is the owner's own opt-in to
13
+ // unattended shipping, and stopping it would only park every task it takes.
14
+ //
15
+ // WHAT IT IS NOT. A security wall. The flag is one word an agent could add, the same posture as
16
+ // `--approved` on the sandbox review: the rule is behavioural, and the skill says never to pass it
17
+ // without a real yes. Rank and every server gate are unchanged (ADR 0016). A mode that cannot be
18
+ // read never stops a ship (the resolver's own posture: Auto).
19
+ //
20
+ // The decision is pure (manualShipVerdict) so tests prove it with no network.
21
+
22
+ 'use strict';
23
+
24
+ // Same code as the first-ship question (ship-mode-ask.js, task 1004630): "a question for the
25
+ // person is waiting", distinct from failure (1), usage (2) and the grade cap (6).
26
+ const MANUAL_ASK_EXIT = 5;
27
+ const MANUAL_ASK_MARKER = '[otb-ask-ship-approval]';
28
+ const APPROVED_FLAG = '--person-approved';
29
+
30
+ // manualShipVerdict({ shipMode, args, env }) → { stop: boolean, why }
31
+ function manualShipVerdict({ shipMode, args = [], env = process.env }) {
32
+ if (!shipMode || shipMode.mode !== 'manual') return { stop: false, why: 'not_manual' };
33
+ if (args.includes(APPROVED_FLAG)) return { stop: false, why: 'approved' };
34
+ if (env.OTB_SUBAGENT === '1') return { stop: false, why: 'unattended_runner' };
35
+ if (env.CLAUDECODE !== '1') return { stop: false, why: 'person_at_terminal' };
36
+ return { stop: true, why: 'needs_approval' };
37
+ }
38
+
39
+ // manualAskLines(args, taskId) — the question the AI puts to the person, and the re-run.
40
+ function manualAskLines(args, taskId) {
41
+ const rerun = ['node', 'scripts/gds/ship.js', ...args]
42
+ .map((a) => (/\s/.test(a) ? JSON.stringify(a) : a)).join(' ');
43
+ return [
44
+ `${MANUAL_ASK_MARKER} This project's ship mode is Manual: a person approves each ship.`,
45
+ `Ask the person whether to ship task ${taskId} now, with two options: Ship it, or Not yet.`,
46
+ 'Only after they say yes, re-run:',
47
+ '',
48
+ ` ${rerun} ${APPROVED_FLAG}`,
49
+ '',
50
+ 'Nothing was resolved: the claim is still active. Never add the flag without their yes.',
51
+ ];
52
+ }
53
+
54
+ module.exports = { MANUAL_ASK_EXIT, MANUAL_ASK_MARKER, APPROVED_FLAG, manualShipVerdict, manualAskLines };
@@ -61,6 +61,11 @@
61
61
  // # sandbox URL. Persists a sandbox_review row on the
62
62
  // # task. Honored only when the game state is already
63
63
  // # staged-fresh (a re-run); it can't pre-approve.
64
+ // node scripts/gds/ship.js <task-id> --person-approved
65
+ // # Manual ship mode (task 1004631, ADR 0369): an AI
66
+ // # session on a Manual project stops before anything
67
+ // # resolves and exits 5 with an ask; re-run with this
68
+ // # ONLY after the person said yes to shipping now.
64
69
 
65
70
 
66
71
  // ---------------------------------------------------------------------------
@@ -179,6 +179,16 @@ function resolveChannelTarget({ installed, channel, available } = {}) {
179
179
 
180
180
  // ---- registry: what versions of the core are published? ---------------------
181
181
 
182
+ // npmSpawnOpts — what a spawn of `npm` needs on this platform (task 1004631). On Windows `npm` is
183
+ // `npm.cmd`, and Node refuses to spawn a .cmd without a shell (CVE-2024-27980), so a bare
184
+ // spawnSync('npm') fails with ENOENT there. That failure made `bongos upgrade` roll back on every
185
+ // Windows laptop before it wrote the project's .claude/ files — so the project had no permission
186
+ // rules, and Claude Code's safety check refused `npx bongos ship`. A shell is safe here because
187
+ // every argument passed to npm by these callers is a fixed word or a package@version string.
188
+ function npmSpawnOpts(platform = process.platform) {
189
+ return platform === 'win32' ? { shell: true } : {};
190
+ }
191
+
182
192
  // List published versions of @bongos/core from the registry via `npm view … versions --json`.
183
193
  // The package is public (task 1003872), so this needs no credential and no instance-local
184
194
  // `.npmrc` — any cwd resolves it. Fail-SOFT: any error returns
@@ -188,7 +198,7 @@ function resolveChannelTarget({ installed, channel, available } = {}) {
188
198
  function listAvailableVersions({ cwd = process.cwd(), pkg = CORE_PKG, run = spawnSync, env = process.env, timeoutMs = NPM_VIEW_TIMEOUT_MS } = {}) {
189
199
  let res;
190
200
  try {
191
- res = run('npm', ['view', pkg, 'versions', '--json'], { cwd, env, encoding: 'utf8', timeout: timeoutMs });
201
+ res = run('npm', ['view', pkg, 'versions', '--json'], { cwd, env, encoding: 'utf8', timeout: timeoutMs, ...npmSpawnOpts() });
192
202
  } catch (e) {
193
203
  return { ok: false, error: e && e.message ? e.message : String(e), versions: [] };
194
204
  }
@@ -220,7 +230,7 @@ function parseNpmViewVersions(stdout) {
220
230
  function readReleaseTags({ cwd = process.cwd(), pkg = CORE_PKG, run = spawnSync, env = process.env, timeoutMs = NPM_VIEW_TIMEOUT_MS } = {}) {
221
231
  let res;
222
232
  try {
223
- res = run('npm', ['view', pkg, 'dist-tags', '--json'], { cwd, env, encoding: 'utf8', timeout: timeoutMs });
233
+ res = run('npm', ['view', pkg, 'dist-tags', '--json'], { cwd, env, encoding: 'utf8', timeout: timeoutMs, ...npmSpawnOpts() });
224
234
  } catch (e) {
225
235
  return { ok: false, error: e && e.message ? e.message : String(e), released: null, tags: {} };
226
236
  }
@@ -463,6 +473,7 @@ function describeStaleness(st, { slug = 'instance' } = {}) {
463
473
  }
464
474
 
465
475
  module.exports = {
476
+ npmSpawnOpts,
466
477
  CORE_PKG,
467
478
  VALID_CHANNELS,
468
479
  DEFAULT_CHANNEL,
@@ -29,7 +29,7 @@ const path = require('node:path');
29
29
  const crypto = require('node:crypto');
30
30
  const { spawnSync } = require('node:child_process');
31
31
  const { arg, hasFlag } = require('./cli-lib');
32
- const { parseSemver, compareSemver, isStable, listAvailableVersions } = require('./update-channel');
32
+ const { parseSemver, compareSemver, isStable, listAvailableVersions, npmSpawnOpts } = require('./update-channel');
33
33
  const { checkArtistGate } = require('./upgrade-artist-gate');
34
34
  // The database side of a move: which command migrates, as which account, and whether that
35
35
  // account can reach the database (tasks 1004274, 1002712, 1004512).
@@ -514,7 +514,7 @@ function npmInstall(instanceDir, run = spawnSync) {
514
514
  // This used to take an extraEnv carrying a resolved npm credential, because the instance
515
515
  // `.npmrc`'s env expansion could shadow a valid user-level one (task 1002754). The core is
516
516
  // public and writes no `.npmrc`, so there is no credential to resolve (task 1003878).
517
- const opts = { cwd: instanceDir, stdio: 'inherit' };
517
+ const opts = { cwd: instanceDir, stdio: 'inherit', ...npmSpawnOpts() }; // Windows: npm.cmd needs a shell (task 1004631)
518
518
  const r = run('npm', ['install', '--no-audit', '--no-fund'], opts);
519
519
  return !r || r.error ? { ok: false, reason: r && r.error ? r.error.message : 'spawn failed' } : { ok: r.status === 0, code: r.status };
520
520
  }
@@ -104,6 +104,7 @@ const MODULE_GLOBS = {
104
104
  lifecycle: [
105
105
  'modules/lifecycle/',
106
106
  'scripts/gds/ship.js', 'scripts/gds/claim.js', 'scripts/gds/release.js',
107
+ 'scripts/gds/ship-mode-manual.js', // task 1004631
107
108
  // task 1004024: the 2026-08-23 ship.js carve, now protected (by name: no '*' here).
108
109
  'scripts/gds/ship-check.js', 'scripts/gds/ship-deploy-target.js', 'scripts/gds/ship-escalation.js',
109
110
  'scripts/gds/ship-finish.js', 'scripts/gds/ship-flow.js', 'scripts/gds/ship-git.js',
@@ -113,7 +114,6 @@ const MODULE_GLOBS = {
113
114
  'scripts/gds/ship-preflight-steps.js',
114
115
  'scripts/gds/ship-regen.js', 'scripts/gds/ship-resume.js', 'scripts/gds/ship-session-upload.js',
115
116
  'scripts/gds/ship-state.js', 'scripts/gds/ship-surprise.js', 'scripts/gds/ship-visual.js',
116
- 'scripts/gds/ship-mode-ask.js', // task 1004630
117
117
  'scripts/gds/merge-mode.js', 'scripts/gds/confirm.js', 'scripts/gds/main-audit.js',
118
118
  'scripts/gds/gate-review.js', 'scripts/gds/push-main.js', 'scripts/gds/ci-grade.js',
119
119
  'scripts/gds/merge-lock.js', 'scripts/gds/version-close.js',
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.122'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
78
+ const CORE_VERSION = '1.21.124'; // 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');
@@ -214,6 +214,10 @@ const GIT_GH_COMMANDS = [
214
214
  // CLI access gives `npx bongos paste-token <token>` (task 1004575), a blessed writer
215
215
  // (see BLESSED_WRITERS) spelled the way a clone can run it.
216
216
  'npx bongos paste-token:*',
217
+ // EXACT form, no `:*` (task 1004631, ADR 0369): with no argument shipping-mode.js only
218
+ // READS the ship mode, which the agent must know before it ships. With `auto|manual` it
219
+ // WRITES the project setting, so a prefix rule would pre-allow that write.
220
+ 'node scripts/gds/shipping-mode.js',
217
221
  ];
218
222
  // gds CLIs audited read-only under EVERY flag they take — a prefix rule allows them all.
219
223
  // Audited and left OUT: backlog-review.js (POSTs from its default walk) and
@@ -271,14 +271,6 @@ const ADDED_SINCE_R101 = [
271
271
  'ship-surprise', 'ship-visual'],
272
272
  'the 2026-08-23 ship.js carve: the land path, the ship state machine, the grade gate '
273
273
  + 'steps and pushVia, ~6,200 lines behind a 373-line protected entry point.'),
274
- // task 1004630: a ship.js sibling added after the carve — registered so the carve
275
- // tripwire below stays green and the file sits at ship.js's floor.
276
- {
277
- after: 'scripts/gds/ship-visual.js',
278
- glob: 'scripts/gds/ship-mode-ask.js',
279
- why: 'split out of the protected ship.js: the ship-mode step every ship runs '
280
- + 'before it lands, so an edit here could change how work reaches main.',
281
- },
282
274
  // task 1002839 (ADR 0089 §1). Not a `grader-*.js` sibling, so the carve tripwire
283
275
  // below would not demand it — registered by hand because it owns the gate.
284
276
  {
@@ -303,6 +295,14 @@ const ADDED_SINCE_R101 = [
303
295
  why: 'the core neutral provider set, which declares none. An entry added here would '
304
296
  + 'send every instance\'s diffs to a vendor by default.',
305
297
  },
298
+ // task 1004631: a ship.js sibling added after the carve — registered so the carve
299
+ // tripwire below stays green and the file sits at ship.js's floor.
300
+ {
301
+ after: 'scripts/gds/ship-merge.js',
302
+ glob: 'scripts/gds/ship-mode-manual.js',
303
+ why: 'split out of the protected ship.js: the ship-mode step every ship runs '
304
+ + 'before it lands, so an edit here could change how work reaches main.',
305
+ },
306
306
  ];
307
307
 
308
308
  // Surfaces RENAMED since R101 — still protected, under a new path. Distinct from
@@ -0,0 +1,173 @@
1
+ // tests/local_land_proof.mjs
2
+ //
3
+ // task 1004633 — a merged task on an instance with no GitHub credential becomes
4
+ // 'shipped'. The owner's mercury project merged task 48 locally and pushed main;
5
+ // with no server credential the ship route refused (the compare API threw
6
+ // PUSH_UNCONFIGURED) and the reconciler returned early, so the task sat at
7
+ // 'confirmed' with its merge on origin/main. These pin the three halves of the fix:
8
+ // 1. isOnOriginMain proves a tip against a REAL repository's origin ref, and a
9
+ // merge that never reached origin does not count;
10
+ // 2. landProvenForTask falls back to it when there is no token;
11
+ // 3. the unconfigured reconciler sweep ships a proven row, and only that row.
12
+ //
13
+ // Run: node tests/local_land_proof.mjs
14
+
15
+ import { strict as assert } from 'node:assert';
16
+ import { createRequire } from 'node:module';
17
+ import { execFileSync } from 'node:child_process';
18
+ import fs from 'node:fs';
19
+ import os from 'node:os';
20
+ import path from 'node:path';
21
+
22
+ const require = createRequire(import.meta.url);
23
+ const local = require('../modules/lifecycle/local-land-proof.js');
24
+ const gh = require('../modules/lifecycle/github-push.js');
25
+ const reconciler = require('../modules/lifecycle/publish-reconciler.js');
26
+
27
+ let passed = 0;
28
+ let failed = 0;
29
+ async function test(name, fn) {
30
+ try {
31
+ await fn();
32
+ passed++;
33
+ console.log(` ok ${name}`);
34
+ } catch (err) {
35
+ failed++;
36
+ console.log(` FAIL ${name}\n ${err && err.stack}`);
37
+ }
38
+ }
39
+
40
+ const git = (cwd, ...args) => execFileSync('git', args, { cwd, encoding: 'utf8', windowsHide: true, stdio: ['ignore', 'pipe', 'ignore'] }).trim();
41
+
42
+ // A bare origin plus a clone, the shape of a laptop-mode instance checkout.
43
+ function mkRepo() {
44
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'local-land-proof-'));
45
+ const origin = path.join(dir, 'origin.git');
46
+ const work = path.join(dir, 'work');
47
+ git(dir, 'init', '--quiet', '--bare', '-b', 'main', origin);
48
+ git(dir, 'clone', '--quiet', origin, work);
49
+ git(work, 'config', 'user.email', 't@example.com');
50
+ git(work, 'config', 'user.name', 't');
51
+ git(work, 'checkout', '--quiet', '-b', 'main');
52
+ fs.writeFileSync(path.join(work, 'a.txt'), 'a\n');
53
+ git(work, 'add', 'a.txt');
54
+ git(work, 'commit', '--quiet', '-m', 'base');
55
+ git(work, 'push', '--quiet', 'origin', 'main');
56
+ return { dir, work };
57
+ }
58
+
59
+ function commit(work, file) {
60
+ fs.writeFileSync(path.join(work, file), `${file}\n`);
61
+ git(work, 'add', file);
62
+ git(work, 'commit', '--quiet', '-m', file);
63
+ return git(work, 'rev-parse', 'HEAD');
64
+ }
65
+
66
+ await test('a tip merged and pushed to origin/main is proven', async () => {
67
+ const { dir, work } = mkRepo();
68
+ try {
69
+ git(work, 'checkout', '--quiet', '-b', 'task-48');
70
+ const tip = commit(work, 'b.txt');
71
+ git(work, 'checkout', '--quiet', 'main');
72
+ git(work, 'merge', '--quiet', '--no-ff', '-m', 'Merge task-48: (no value summary)', 'task-48');
73
+ git(work, 'push', '--quiet', 'origin', 'main');
74
+ assert.equal(await local.isOnOriginMain({ sha: tip }, { root: work }), true);
75
+ } finally { fs.rmSync(dir, { recursive: true, force: true }); }
76
+ });
77
+
78
+ await test('a merge into local main that never reached origin is NOT proven', async () => {
79
+ const { dir, work } = mkRepo();
80
+ try {
81
+ const tip = commit(work, 'c.txt'); // on local main, unpushed
82
+ assert.equal(await local.isOnOriginMain({ sha: tip }, { root: work }), false);
83
+ } finally { fs.rmSync(dir, { recursive: true, force: true }); }
84
+ });
85
+
86
+ await test('an unknown commit or a ref-shaped value is not proven', async () => {
87
+ const { dir, work } = mkRepo();
88
+ try {
89
+ assert.equal(await local.isOnOriginMain({ sha: 'c'.repeat(40) }, { root: work }), false);
90
+ assert.equal(await local.isOnOriginMain({ sha: 'HEAD' }, { root: work }), false, 'a ref must never resolve');
91
+ assert.equal(await local.isOnOriginMain({ sha: 'a'.repeat(40), base: '../x' }, { root: work }), false);
92
+ } finally { fs.rmSync(dir, { recursive: true, force: true }); }
93
+ });
94
+
95
+ await test('no usable checkout throws LOCAL_PROOF_UNAVAILABLE, never a silent false', async () => {
96
+ const broken = async () => { const e = new Error('spawn git ENOENT'); e.code = 'ENOENT'; throw e; };
97
+ await assert.rejects(
98
+ local.isOnOriginMain({ sha: 'a'.repeat(40) }, { git: broken }),
99
+ (e) => e.code === 'LOCAL_PROOF_UNAVAILABLE'
100
+ );
101
+ });
102
+
103
+ await test('a caller that already fetched (the sweep) is not made to fetch again per row', async () => {
104
+ const calls = [];
105
+ const fake = async (args) => { calls.push(args[0]); };
106
+ await local.isOnOriginMain({ sha: 'a'.repeat(40) }, { git: fake });
107
+ await local.isOnOriginMain({ sha: 'b'.repeat(40) }, { git: fake, fetched: true });
108
+ await local.isOnOriginMain({ sha: 'c'.repeat(40) }, { git: fake, fetched: true });
109
+ assert.deepEqual(calls, ['fetch', 'merge-base', 'merge-base', 'merge-base']);
110
+ });
111
+
112
+ await test('landProvenForTask with no credential proves the tip from the local checkout', async () => {
113
+ const saved = { t: process.env.GITHUB_PUSH_TOKEN, a: process.env.GITHUB_APP_ID, k: process.env.GITHUB_APP_PRIVATE_KEY };
114
+ delete process.env.GITHUB_PUSH_TOKEN; delete process.env.GITHUB_APP_ID; delete process.env.GITHUB_APP_PRIVATE_KEY;
115
+ try {
116
+ const asked = [];
117
+ const localLandProof = { isOnOriginMain: async ({ sha }) => { asked.push(sha); return sha === 'a'.repeat(40); } };
118
+ const yes = await gh.landProvenForTask({ taskId: 48, publishedSha: 'a'.repeat(40), status: null }, { localLandProof });
119
+ assert.equal(yes.proven, true);
120
+ const no = await gh.landProvenForTask({ taskId: 49, publishedSha: 'b'.repeat(40), status: null }, { localLandProof });
121
+ assert.equal(no.proven, false);
122
+ assert.deepEqual(asked, ['a'.repeat(40), 'b'.repeat(40)]);
123
+ } finally {
124
+ for (const [k, v] of [['GITHUB_PUSH_TOKEN', saved.t], ['GITHUB_APP_ID', saved.a], ['GITHUB_APP_PRIVATE_KEY', saved.k]]) {
125
+ if (v === undefined) delete process.env[k]; else process.env[k] = v;
126
+ }
127
+ }
128
+ });
129
+
130
+ await test('the unconfigured sweep ships a proven tip — and only that row', async () => {
131
+ const PROVEN = 'a'.repeat(40);
132
+ const shipped = [];
133
+ const events = [];
134
+ const rows = [
135
+ { task_id: 48, builder_id: 7, worktree_name: 'task-48', published_head_sha: PROVEN },
136
+ { task_id: 49, builder_id: 7, worktree_name: 'task-49', published_head_sha: 'b'.repeat(40) },
137
+ { task_id: 50, builder_id: 7, worktree_name: 'task-50', published_head_sha: null },
138
+ ];
139
+ const r = await reconciler.runSweep({
140
+ db: {
141
+ listConfirmedTasksForReconcile: async () => rows,
142
+ shipTask: async (a) => { shipped.push(a); return { id: a.taskId }; },
143
+ },
144
+ githubPush: { isConfigured: () => false },
145
+ localLandProof: {
146
+ sweepByLocalProof: (rs, o) => local.sweepByLocalProof(rs, { ...o, proof: async ({ sha }) => sha === PROVEN }),
147
+ },
148
+ emit: async (name, t) => { events.push([name, t.id]); },
149
+ log: () => {},
150
+ error: () => {},
151
+ });
152
+ assert.equal(r.skipped, 'unconfigured');
153
+ assert.equal(r.reconciled, 1);
154
+ assert.equal(r.checked, 2, 'the row with no tip has nothing local to prove');
155
+ assert.deepEqual(shipped.map((s) => s.taskId), [48]);
156
+ assert.equal(shipped[0].landAssurance.kind, 'proof');
157
+ assert.deepEqual(events, [['task.shipped', 48]]);
158
+ });
159
+
160
+ await test('a sweep racing another flip is not an error (TASK_NOT_CONFIRMED)', async () => {
161
+ const r = await local.sweepByLocalProof(
162
+ [{ task_id: 48, builder_id: 7, published_head_sha: 'a'.repeat(40) }],
163
+ {
164
+ db: { shipTask: async () => { const e = new Error('x'); e.code = 'TASK_NOT_CONFIRMED'; throw e; } },
165
+ emit: async () => {}, log: () => {}, error: () => {},
166
+ proof: async () => true,
167
+ }
168
+ );
169
+ assert.deepEqual(r, { checked: 1, reconciled: 0, errors: 0 });
170
+ });
171
+
172
+ console.log(`\n${passed} passed, ${failed} failed`);
173
+ if (failed) process.exit(1);
@@ -205,7 +205,9 @@ function mkDeps({
205
205
 
206
206
  // ---------------------------------------------------------------------------
207
207
 
208
- test('no push credential → no-op (skipped: unconfigured), never touches the DB', async () => {
208
+ // task 1004633: an unconfigured sweep now reads the confirmed rows (to prove a recorded tip
209
+ // from the local checkout), but a row with no tip still ships nothing and GitHub is never asked.
210
+ test('no push credential → skipped: unconfigured; a row with no tip ships nothing, GitHub never called', async () => {
209
211
  const deps = mkDeps({ configured: false, rows: [{ task_id: 1, builder_id: 9, worktree_name: 'wt' }] });
210
212
  const r = await reconciler.runSweep(deps);
211
213
  assert.equal(r.skipped, 'unconfigured');
@@ -0,0 +1,146 @@
1
+ // tests/ship_mode_hands_off.mjs — Auto ships hands-off, Manual asks first (task 1004631, ADR 0369).
2
+ //
3
+ // The owner's AI refused `npx bongos ship` on their own project. The cause: `bongos upgrade`
4
+ // spawned bare `npm`, which is ENOENT on Windows, so the upgrade rolled back before it wrote
5
+ // the project's .claude/settings.json, and with no allow-rule Claude Code's safety check denied
6
+ // the ship. Pinned here:
7
+ // 1. npm is spawned with a shell on Windows (and only there) by the upgrade and the registry reads;
8
+ // 2. a fresh project's starter allows the ship and the read-only ship-mode check, but NOT the
9
+ // ship-mode write;
10
+ // 3. the instance CLAUDE.md tells the AI to follow the ship mode and never hand over commands;
11
+ // 4. Manual mode: an AI session stops before anything resolves and asks; a person at a
12
+ // terminal, an approved re-run, an Autobongos worker, Auto and an unreadable mode never stop;
13
+ // 5. the wiring: the Manual check runs after the claim is found and before the grade.
14
+ //
15
+ // Run: node --test tests/ship_mode_hands_off.mjs
16
+ import { strict as assert } from 'node:assert';
17
+ import { test } from 'node:test';
18
+ import { createRequire } from 'node:module';
19
+ import fs from 'node:fs';
20
+ import path from 'node:path';
21
+ import { fileURLToPath } from 'node:url';
22
+
23
+ const require = createRequire(import.meta.url);
24
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
25
+
26
+ const { npmSpawnOpts, listAvailableVersions } = require('../scripts/gds/update-channel.js');
27
+ const { STARTER_ALLOW } = require('../scripts/gds/claude-materialize.js');
28
+ const {
29
+ MANUAL_ASK_EXIT, MANUAL_ASK_MARKER, APPROVED_FLAG, manualShipVerdict, manualAskLines,
30
+ } = require('../scripts/gds/ship-mode-manual.js');
31
+
32
+ // ---- 1. npm on Windows -----------------------------------------------------
33
+
34
+ test('npm gets a shell on Windows only (npm is npm.cmd there, and Node will not spawn a .cmd bare)', () => {
35
+ assert.deepEqual(npmSpawnOpts('win32'), { shell: true });
36
+ assert.deepEqual(npmSpawnOpts('linux'), {});
37
+ assert.deepEqual(npmSpawnOpts('darwin'), {});
38
+ });
39
+
40
+ test('the registry read passes the platform spawn options to npm', () => {
41
+ const calls = [];
42
+ listAvailableVersions({ run: (cmd, args, opts) => { calls.push({ cmd, args, opts }); return { status: 0, stdout: '["1.0.0"]' }; } });
43
+ assert.equal(calls[0].cmd, 'npm');
44
+ assert.equal(calls[0].opts.shell, npmSpawnOpts().shell);
45
+ });
46
+
47
+ test('the upgrade\'s npm install uses the same spawn options', () => {
48
+ const src = fs.readFileSync(path.join(ROOT, 'scripts', 'gds', 'upgrade.js'), 'utf8');
49
+ const fn = src.slice(src.indexOf('function npmInstall'), src.indexOf('async function runMigrate'));
50
+ assert.match(fn, /npmSpawnOpts\(\)/);
51
+ });
52
+
53
+ // ---- 2. the starter permissions --------------------------------------------
54
+
55
+ test('a fresh project allows the ship, and reading the ship mode, for both shell tools', () => {
56
+ for (const tool of ['Bash', 'PowerShell']) {
57
+ assert.ok(STARTER_ALLOW.includes(`${tool}(npx bongos ship:*)`), `${tool} ship`);
58
+ assert.ok(STARTER_ALLOW.includes(`${tool}(npx bongos ship-mode)`), `${tool} ship-mode read`);
59
+ }
60
+ });
61
+
62
+ test('the starter never pre-allows WRITING the ship mode', () => {
63
+ assert.ok(!STARTER_ALLOW.some((r) => /ship-mode[: ]/.test(r) || /ship-mode\*/.test(r)),
64
+ 'only the exact no-argument read is allowed; `ship-mode auto|manual` stays a prompt');
65
+ });
66
+
67
+ // ---- 3. the instance CLAUDE.md ---------------------------------------------
68
+
69
+ test('a new project\'s CLAUDE.md tells the AI to follow the ship mode and never hand over commands', () => {
70
+ const tpl = require('../scripts/gds/claude-md-template.js');
71
+ const render = Object.values(tpl).find((v) => typeof v === 'function');
72
+ const text = render({});
73
+ assert.match(text, /npx bongos ship-mode/);
74
+ assert.match(text, /In \*\*Auto\*\*[^\n]*run `npx bongos ship` yourself[^\n]*never hand the person commands/);
75
+ assert.match(text, /In \*\*Manual\*\* ask the person first/);
76
+ });
77
+
78
+ // ---- 4. Manual mode --------------------------------------------------------
79
+
80
+ const AI = { CLAUDECODE: '1' };
81
+ const manual = { mode: 'manual', chosen: true };
82
+
83
+ test('an AI session on a Manual project stops and asks', () => {
84
+ assert.deepEqual(manualShipVerdict({ shipMode: manual, args: ['7'], env: AI }), { stop: true, why: 'needs_approval' });
85
+ });
86
+
87
+ test('the approved re-run, a person at a terminal, and an Autobongos worker are never stopped', () => {
88
+ assert.equal(manualShipVerdict({ shipMode: manual, args: ['7', APPROVED_FLAG], env: AI }).stop, false);
89
+ assert.equal(manualShipVerdict({ shipMode: manual, args: ['7'], env: {} }).why, 'person_at_terminal');
90
+ assert.equal(manualShipVerdict({ shipMode: manual, args: ['7'], env: { ...AI, OTB_SUBAGENT: '1' } }).why, 'unattended_runner');
91
+ });
92
+
93
+ test('Auto and an unreadable mode ship on', () => {
94
+ assert.equal(manualShipVerdict({ shipMode: { mode: 'auto', chosen: true }, args: ['7'], env: AI }).stop, false);
95
+ assert.equal(manualShipVerdict({ shipMode: null, args: ['7'], env: AI }).stop, false);
96
+ });
97
+
98
+ test('the ask names the task, both answers, the exact re-run, and forbids a yes nobody gave', () => {
99
+ const lines = manualAskLines(['7', '--summary', 'two words'], 7);
100
+ const text = lines.join('\n');
101
+ assert.ok(lines[0].startsWith(MANUAL_ASK_MARKER));
102
+ assert.match(text, /ship task 7 now[\s\S]*Ship it, or Not yet/);
103
+ assert.match(text, /node scripts\/gds\/ship\.js 7 --summary "two words" --person-approved/);
104
+ assert.match(text, /Never add the flag without their yes/);
105
+ assert.equal(MANUAL_ASK_EXIT, 5);
106
+ });
107
+
108
+ // ---- 5. the wiring ---------------------------------------------------------
109
+
110
+ const flowSrc = () => fs.readFileSync(path.join(ROOT, 'scripts', 'gds', 'ship-flow.js'), 'utf8');
111
+
112
+ test('the Manual check stops with cliExit and returns, never a hard exit after a request', () => {
113
+ const src = flowSrc();
114
+ const helper = src.slice(src.indexOf('async function stopForManualApproval'), src.indexOf('async function shipMain'));
115
+ assert.match(helper, /manualShip\.manualShipVerdict\(/);
116
+ assert.match(helper, /cliExit\(manualShip\.MANUAL_ASK_EXIT\);\s*return true;/);
117
+ assert.doesNotMatch(helper, /process\.exit\(/);
118
+ });
119
+
120
+ test('a fresh ship asks after the claim is found and before preflight, the grade and the resolve', () => {
121
+ const main = flowSrc().slice(flowSrc().indexOf('async function shipMain'));
122
+ const claimFound = main.indexOf('const ac = claims.find(');
123
+ const gate = main.indexOf('if (await stopForManualApproval(args, taskId)) return;', claimFound);
124
+ const preflight = main.indexOf('Step 0: preflight');
125
+ assert.ok(claimFound > 0 && gate > claimFound && preflight > gate);
126
+ });
127
+
128
+ test('a resumed merge asks before it lands (the stranded-ship re-run)', () => {
129
+ const main = flowSrc().slice(flowSrc().indexOf('async function shipMain'));
130
+ const branch = main.slice(main.indexOf("action.action === 'resume-merge'"), main.indexOf('return resumeMerge(taskId'));
131
+ assert.match(branch, /if \(await stopForManualApproval\(args, taskId\)\) return;/);
132
+ });
133
+
134
+ test('a re-grade asks before it can pass into finishShip (direct --regrade and the resume-grade dispatch)', () => {
135
+ const src = flowSrc();
136
+ const regrade = src.slice(src.indexOf('async function regradeMain'));
137
+ const gate = regrade.indexOf('if (await stopForManualApproval(args, taskId)) return;');
138
+ const finish = regrade.indexOf('finishShip(');
139
+ assert.ok(gate > 0 && finish > gate, 'the gate runs before the land');
140
+ const main = src.slice(src.indexOf('async function shipMain'), src.indexOf('async function regradeMain'));
141
+ assert.match(main, /action\.action === 'resume-grade'[\s\S]*?return regradeMain\(/, 'resume-grade goes through regradeMain, so its gate covers it');
142
+ });
143
+
144
+ test('--person-approved is documented in the ship usage banner', () => {
145
+ assert.match(fs.readFileSync(path.join(ROOT, 'scripts', 'gds', 'ship.js'), 'utf8'), /--person-approved/);
146
+ });