@bongos/core 1.21.121 → 1.21.122

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.121",
6
- "core_contract": "1.21.121",
7
- "source_commit": "514ed742253be1dacb6b1d18d49f2f6fa3f6b427",
5
+ "core_version": "1.21.122",
6
+ "core_contract": "1.21.122",
7
+ "source_commit": "1744caf4412e029c7cf9b465a9fae581bc2deea7",
8
8
  "source_ref": "HEAD",
9
- "built_at": "2026-10-14T17:15:08.661Z",
9
+ "built_at": "2026-10-14T19:02:19.295Z",
10
10
  "redaction": {
11
11
  "model": "docs-redacted+functional-verbatim",
12
12
  "docs_redacted": 605,
13
13
  "agent_docs_stubbed": 28,
14
- "functional_verbatim": 3118,
14
+ "functional_verbatim": 3120,
15
15
  "rules": 3,
16
16
  "gate_literals": 3,
17
17
  "gate": "passed"
18
18
  },
19
- "file_count": 3752,
20
- "tree_sha256": "49b303e76f9a422c8c0b909fe0ee46687b713859b18104045df85b7ef48fa505",
19
+ "file_count": 3754,
20
+ "tree_sha256": "df175c2d7b99fee7204cf032575b41a9fa13988b1a3635717568822cf1d84d6b",
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": "9a646bdb323e8e22d7e65b4e721ee2ffe717b9b83006f2bf8fcec5db8614ae43"
110
+ "sha256": "1053b6160f72c6f78d9608b8431ba59b707fb310493fdb24ea52efd52f1ef825"
111
111
  },
112
112
  {
113
113
  "path": ".claude/skills/builder-stage/SKILL.md",
@@ -337,7 +337,7 @@
337
337
  {
338
338
  "path": "clients/bongos-client/index.d.ts",
339
339
  "mode": "0000644",
340
- "sha256": "493cebcc4535646738cfda7d3df73ba9895e7e88c3c268d62cca55420aebab29"
340
+ "sha256": "c13884fb0bd175f8bc9745150b2ca5ed254dd0ad14bcaa63511cf892cdbaec36"
341
341
  },
342
342
  {
343
343
  "path": "clients/bongos-client/index.mjs",
@@ -2307,7 +2307,7 @@
2307
2307
  {
2308
2308
  "path": "docs/adr/0369-ship-mode-decides-who-starts-a-ship-not-how-it-lands.md",
2309
2309
  "mode": "0000644",
2310
- "sha256": "9a5d9de0e7895a66d5515a453beca25d4839e7f93ef4dd0cd60524239ab005ee"
2310
+ "sha256": "a0dba98c2dfe34ed6110f2d813fef346ad8adf7430f2b7ab4da38be72c37f7c8"
2311
2311
  },
2312
2312
  {
2313
2313
  "path": "docs/adr/0370-a-repo-we-create-is-private.md",
@@ -2382,7 +2382,7 @@
2382
2382
  {
2383
2383
  "path": "docs/api/openapi.json",
2384
2384
  "mode": "0000644",
2385
- "sha256": "1504a1bc002e033dd4c69c55a074014b35f88fe0015746911ac69e02cff4a430"
2385
+ "sha256": "b8d8cae75596996b38203b5b73b8fc4f5cfc4afa8fa6a170541131fdfed924ea"
2386
2386
  },
2387
2387
  {
2388
2388
  "path": "docs/architecture.md",
@@ -2922,7 +2922,7 @@
2922
2922
  {
2923
2923
  "path": "docs/module-api-changelog.md",
2924
2924
  "mode": "0000644",
2925
- "sha256": "24cea3fdb2f8e21b6358090612695e37c03e734fcd305e87d35456fa8a515adf"
2925
+ "sha256": "05a2ac9355a782e35f6af18287d0395ece73a55b0541f680106c250d1f604b63"
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": "f7b6342000bd9f49d9747e2e02056060ad5aeda074424fbba586cd8bc1ef6bd2"
5300
+ "sha256": "9aedf10b3cb611343baf96662e6f79d45ed2218b83abfb3ad8c03536c722d37c"
5301
5301
  },
5302
5302
  {
5303
5303
  "path": "modules/government/ranks.js",
@@ -7622,7 +7622,7 @@
7622
7622
  {
7623
7623
  "path": "modules/lifecycle/routes/project-settings.js",
7624
7624
  "mode": "0000644",
7625
- "sha256": "ff31bc1e4dbfec3026e250beaf8d5f4bdfc02b39cee616be1bb36ddc74b6f11b"
7625
+ "sha256": "67d28a56701e45e176236fc0eae3ece9e349cc5c7b61cf6e7b8267afe304ade5"
7626
7626
  },
7627
7627
  {
7628
7628
  "path": "modules/lifecycle/routes/recommendations.js",
@@ -10467,12 +10467,12 @@
10467
10467
  {
10468
10468
  "path": "package-lock.json",
10469
10469
  "mode": "0000644",
10470
- "sha256": "4fbad4e149e09def5dfaf46fb9069c0298871200c54d8836c61efdf527e14038"
10470
+ "sha256": "0698dfed8aaee5032ad705115bcbc515cc7328a78c31a30018a2e2f5df0cf1ca"
10471
10471
  },
10472
10472
  {
10473
10473
  "path": "package.json",
10474
10474
  "mode": "0000644",
10475
- "sha256": "7107e720fbe7a367451fb610dcccac24f7563e1a50b898bf1899b76afc53fd6f"
10475
+ "sha256": "aefc81df15571d208d96577a7ade7189f1d1e064ae9a1af132b0725f871fcb40"
10476
10476
  },
10477
10477
  {
10478
10478
  "path": "public-docs/index.html",
@@ -10492,7 +10492,7 @@
10492
10492
  {
10493
10493
  "path": "release-notes.json",
10494
10494
  "mode": "0000644",
10495
- "sha256": "7818da891dbc2d601d13dc938d43d83f49061084ed93fef83fe6d4479c98f828"
10495
+ "sha256": "43b16f9fbd2c3834c02bd141ce4b9e737003d1c42112e6cb607e7a118e29e7dd"
10496
10496
  },
10497
10497
  {
10498
10498
  "path": "scripts/cron/style_guide_audit.sh",
@@ -11997,7 +11997,7 @@
11997
11997
  {
11998
11998
  "path": "scripts/gds/ship-flow.js",
11999
11999
  "mode": "0000644",
12000
- "sha256": "ad768320095b1d6693e6da5cee1f8e857ac69cbbfbea0a64dfeaa7092c432de2"
12000
+ "sha256": "bb8edf9166b1c452b87d75e009d7404758bcba6ea58aa9011848ec44e761bb52"
12001
12001
  },
12002
12002
  {
12003
12003
  "path": "scripts/gds/ship-git.js",
@@ -12044,6 +12044,11 @@
12044
12044
  "mode": "0000644",
12045
12045
  "sha256": "8d37d5c6cbaa1ca0e8363fab6c34159d1e16922fdae11d4ffc17b4d9e2c7f795"
12046
12046
  },
12047
+ {
12048
+ "path": "scripts/gds/ship-mode-ask.js",
12049
+ "mode": "0000644",
12050
+ "sha256": "7a489ea223b4fa1d7a3b4ee1076c9a0cb65d6d929e9a5a6e0b8e403e15266afc"
12051
+ },
12047
12052
  {
12048
12053
  "path": "scripts/gds/ship-preflight-steps.js",
12049
12054
  "mode": "0000644",
@@ -12082,7 +12087,7 @@
12082
12087
  {
12083
12088
  "path": "scripts/gds/ship.js",
12084
12089
  "mode": "0000644",
12085
- "sha256": "96bda284d66eb2428bbd05d359143fb6090fe6ce51bc2deca0a47590a8f392ca"
12090
+ "sha256": "9f00804212ef485fd8cfd02be19cddb4be2570a34aa2a284f237940aff17753e"
12086
12091
  },
12087
12092
  {
12088
12093
  "path": "scripts/gds/shipping-mode.js",
@@ -12562,7 +12567,7 @@
12562
12567
  {
12563
12568
  "path": "src/bongos/module-scope-map.js",
12564
12569
  "mode": "0000644",
12565
- "sha256": "67711bf6dd6becd1edeb6866279a788fd6360a51d013eed77f2592be0aaab01b"
12570
+ "sha256": "1b0cec97fbdf2c14f48354741f5537a7db3dbc947e7b7182eb2aa9173cfa00dc"
12566
12571
  },
12567
12572
  {
12568
12573
  "path": "src/bongos/module-store.js",
@@ -12802,7 +12807,7 @@
12802
12807
  {
12803
12808
  "path": "src/module-api.js",
12804
12809
  "mode": "0000644",
12805
- "sha256": "0ebfd076b4e28ae56b54b3bdc1f43d1f4ee3e74391b4b28600de50f653ee097c"
12810
+ "sha256": "d49bdec14177f77cd4e6dd63ac2532a095565373c0948d9d7b91c809e4e3e6f3"
12806
12811
  },
12807
12812
  {
12808
12813
  "path": "src/module-loader/catalog.js",
@@ -14992,7 +14997,7 @@
14992
14997
  {
14993
14998
  "path": "tests/government_protected_surfaces.mjs",
14994
14999
  "mode": "0000644",
14995
- "sha256": "3f002c9669af99cba90480163e1040604ec77b53583a779c0ef81ee75dd02547"
15000
+ "sha256": "3518806d6b065029677e77a242604b6dc85bfd28537c2d109dacaedd29ac6d49"
14996
15001
  },
14997
15002
  {
14998
15003
  "path": "tests/government_rank_sync.mjs",
@@ -17939,6 +17944,11 @@
17939
17944
  "mode": "0000644",
17940
17945
  "sha256": "aee20a5f6e2998d18f605b91d1eb1019e8ada824135759b29633caa3153d9255"
17941
17946
  },
17947
+ {
17948
+ "path": "tests/ship_mode_first_ship.mjs",
17949
+ "mode": "0000644",
17950
+ "sha256": "361d6c7a364c0c1f6fb8266f887fe25fc357250e4585b438fdbf3fb3fafa6849"
17951
+ },
17942
17952
  {
17943
17953
  "path": "tests/ship_post_land_verify.mjs",
17944
17954
  "mode": "0000644",
@@ -83,6 +83,8 @@ The CLI drives a **three-state lifecycle** with a subagent grader between states
83
83
  `--notes-file` is refused rather than resolved by precedence, so nothing you
84
84
  wrote is dropped without a word.
85
85
 
86
+ **If the ship stops with `[otb-ask-ship-mode]` (exit 5), ask the person, once** (task 1004630, [ADR 0369](../../../docs/adr/0369-ship-mode-decides-who-starts-a-ship-not-how-it-lands.md)). The project never chose who starts a ship, so its first ship asks. Nothing has resolved and the claim is still active. Put the question to the person with your question tool if you have one — two options, **Auto (recommended)** first, then **Manual**, each with the one-line meaning the ship printed — and re-run the exact command it printed with `--ship-mode auto` or `--ship-mode manual`. That saves the answer for the whole project and the ship carries on; it never asks again. Never answer it yourself: the choice is the owner's. An unattended run is never asked (it ships on the default, Auto).
87
+
86
88
  **Never merge a task branch to main before running `ship.js`.** `ship.js` diffs the claim branch against `main`; an already-merged branch yields an empty diff, and the only exits are `--allow-empty` (dead-ends the task at `completed`, requiring an Archon-only confirm) or reverting the merge. Ship from the branch, then let `ship.js` land it. (This trap was hit on tasks 1002417 and 1002419, producing blockers 1000081/1000082 and an ~8.5-hour stall.)
87
89
 
88
90
  5. **Render the completion card — it IS the summary; do NOT write a prose recap (task 1270).**
@@ -129,7 +129,7 @@ export interface GetPageNotesSiteResponse { notes: unknown }
129
129
  export interface GetProfilesHandleResponse { account: unknown; cross_project: unknown }
130
130
  export interface GetProjectManageLinkResponse { ok: boolean; link: unknown }
131
131
  export interface GetProjectSettingsResponse { rows: unknown }
132
- export interface GetProjectSettingsShipModeResponse { mode: unknown; chosen: unknown; source: unknown; sentence: unknown; choices: unknown }
132
+ export interface GetProjectSettingsShipModeResponse { mode: unknown; chosen: unknown; source: unknown; sentence: unknown; choices: unknown; can_change: unknown }
133
133
  export interface GetProjectsFeaturedResponse { projects: unknown; page: PageInfo }
134
134
  export interface GetProjectsResponse { projects: unknown }
135
135
  export interface GetProvisioningFleetResponse { instances: unknown; capacity: unknown }
@@ -33,7 +33,7 @@ It lives in `project_settings` (migration `core_231`), so it needs no migration.
33
33
 
34
34
  - This task adds the setting only. Behaviour follows in the dependent tasks:
35
35
  - the creation wizard asks for it (task 1004629);
36
- - the first ship asks once if nobody has chosen (task 1004630);
36
+ - the first ship asks once if nobody has chosen (task 1004630). The ship stops before anything resolves and exits 5 with an `[otb-ask-ship-mode]` ask; the AI puts the two choices to the person and re-runs with `--ship-mode auto|manual`, which saves the answer. It never asks a caller who cannot change settings (`can_change` on the read), an unattended run, or when the mode cannot be read; those ship on the default;
37
37
  - the AI obeys it (task 1004631).
38
38
  - **How the wizard's answer reaches the project (task 1004629).** The wizard runs on the platform, and the new project's database is out of its reach. So the answer travels the way the founding marker does: the platform keeps it on the new project's record, sends it to the project at startup, and the project reads it as a *chosen* mode (`source: 'wizard'`) only while its own setting is unset. The settings page and the CLI write that setting, so a later change always wins. Nothing is written into the project's database on its behalf.
39
39
  - A database read failure answers Auto rather than blocking a ship. That matches the setting's default, and blocking would be the worse outcome for a person mid-task.
@@ -16489,7 +16489,7 @@
16489
16489
  "project-settings"
16490
16490
  ],
16491
16491
  "summary": "GET /project-settings/ship-mode",
16492
- "description": "GET /project-settings/ship-mode — the ship mode alone, for the CLI and the AI. rank: any signed-in builder. Unlike the full settings read this carries no reward internals or file paths an agent could not already read, and every builder's AI session must know whether to ship on its own or stop and ask — gating it Metic+ would leave a newcomer's session guessing.\n\n**Rank:** `any-builder` — Any authenticated builder (row-level ownership enforced in-handler).",
16492
+ "description": "GET /project-settings/ship-mode — the ship mode alone, for the CLI and the AI. rank: any signed-in builder. Unlike the full settings read this carries no reward internals or file paths an agent could not already read, and every builder's AI session must know whether to ship on its own or stop and ask — gating it Metic+ would leave a newcomer's session guessing. can_change (task 1004630): whether THIS caller holds project.settings.manage, so the first-ship question is put only to someone who can answer it. null when authority could not be established; the CLI then asks, and a refused save lets the ship go on. Cosmetic only — the PATCH below keeps its own gate (ADR 0016).\n\n**Rank:** `any-builder` — Any authenticated builder (row-level ownership enforced in-handler).",
16493
16493
  "x-rank": "any-builder",
16494
16494
  "x-source": "modules/lifecycle/routes/project-settings.js",
16495
16495
  "responses": {
@@ -27665,14 +27665,16 @@
27665
27665
  "chosen": {},
27666
27666
  "source": {},
27667
27667
  "sentence": {},
27668
- "choices": {}
27668
+ "choices": {},
27669
+ "can_change": {}
27669
27670
  },
27670
27671
  "required": [
27671
27672
  "mode",
27672
27673
  "chosen",
27673
27674
  "source",
27674
27675
  "sentence",
27675
- "choices"
27676
+ "choices",
27677
+ "can_change"
27676
27678
  ]
27677
27679
  },
27678
27680
  "GetProjectsFeaturedResponse": {
@@ -3079,5 +3079,7 @@ is load-bearing: the script throws rather than guess if it is missing, and
3079
3079
  landed since 1.21.119 with no explicit bump. run 38597828843. (task 1002620)
3080
3080
  1.21.121 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
3081
3081
  landed since 1.21.120 with no explicit bump. run 38604777189. (task 1002620)
3082
+ 1.21.122 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
3083
+ landed since 1.21.121 with no explicit bump. run 38620147259. (task 1002620)
3082
3084
  ---------------------------------------------------------------------------
3083
3085
  ```
@@ -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-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." },
74
75
  { "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." },
75
76
  { "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." },
76
77
  { "glob": "scripts/gds/ship-resume.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." },
@@ -78,6 +79,7 @@
78
79
  { "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." },
79
80
  { "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." },
80
81
  { "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)." },
81
83
  { "glob": "scripts/gds/confirm.js", "group": "pipeline", "why": "the confirm step" },
82
84
  { "glob": "scripts/gds/merge-mode.js", "group": "pipeline", "why": "the manual land fallback" },
83
85
  { "glob": "scripts/gds/ci-grade.js", "group": "pipeline", "why": "CI grade-gate driver — steers the grade check (ADR 0058)" },
@@ -58,6 +58,21 @@ function shipModeRow(resolved) {
58
58
  };
59
59
  }
60
60
 
61
+ // callerHolds(builderId, atom) → true | false | null. null means authority could
62
+ // not be established (government port missing or the resolver threw); a reader
63
+ // treats that as unknown, never as a grant.
64
+ async function callerHolds(builderId, atom) {
65
+ try {
66
+ const government = api.resolveOptional('government');
67
+ if (!government || typeof government.resolveBuilderPermissions !== 'function') return null;
68
+ const held = (await government.resolveBuilderPermissions(builderId)) || [];
69
+ return held.includes(atom);
70
+ } catch (err) {
71
+ log.error(`ship-mode can_change: ${err && err.message}`);
72
+ return null;
73
+ }
74
+ }
75
+
61
76
  // --- the three rows that are READ, never written -----------------------------
62
77
  //
63
78
  // Each names the committed artefact it resolves from, because "you cannot edit this"
@@ -348,12 +363,18 @@ module.exports = function buildProjectSettingsRouter() {
348
363
  // reward internals or file paths an agent could not already read, and every
349
364
  // builder's AI session must know whether to ship on its own or stop and ask —
350
365
  // gating it Metic+ would leave a newcomer's session guessing.
366
+ //
367
+ // can_change (task 1004630): whether THIS caller holds project.settings.manage,
368
+ // so the first-ship question is put only to someone who can answer it. null when
369
+ // authority could not be established; the CLI then asks, and a refused save lets
370
+ // the ship go on. Cosmetic only — the PATCH below keeps its own gate (ADR 0016).
351
371
  router.get('/project-settings/ship-mode', api.requireBuilder, async (req, res) => {
352
372
  try {
353
373
  const resolved = await api.projectSettings.resolveShipMode();
354
374
  res.set('Cache-Control', 'no-store');
355
375
  const row = shipModeRow(resolved);
356
- res.json({ mode: resolved.mode, chosen: resolved.chosen, source: resolved.source, sentence: row.sentence, choices: row.choices });
376
+ const canChange = req.builder ? await callerHolds(req.builder.id, 'project.settings.manage') : null;
377
+ res.json({ mode: resolved.mode, chosen: resolved.chosen, source: resolved.source, sentence: row.sentence, choices: row.choices, can_change: canChange });
357
378
  } catch (err) {
358
379
  log.error(`GET /project-settings/ship-mode: ${err && err.message}`);
359
380
  res.fail('project_settings_read_failed', { status: 500, message: 'internal error' });
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.21.121",
3
+ "version": "1.21.122",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.21.121",
9
+ "version": "1.21.122",
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.121",
3
+ "version": "1.21.122",
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",
@@ -9708,5 +9708,11 @@
9708
9708
  "id": "1004463",
9709
9709
  "text": "Turning a module on or off now says honestly when it takes effect at the next restart, and the platform's own server can be restarted from its project page instead of needing a login to the machine."
9710
9710
  }
9711
+ ],
9712
+ "1.21.122": [
9713
+ {
9714
+ "id": "1004630",
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
+ }
9711
9717
  ]
9712
9718
  }
@@ -8,7 +8,9 @@
8
8
 
9
9
  'use strict';
10
10
 
11
- const { cliClient, requireSession, arg, detectWorktreeName, cardFlagsFromMe } = require('./cli-lib');
11
+ const { cliClient, requireSession, arg, detectWorktreeName, cardFlagsFromMe, apiCall, cliExit } = require('./cli-lib');
12
+ // task 1004630: the first ship asks for the ship mode once (ADR 0369).
13
+ const { runFirstShipAsk } = require('./ship-mode-ask.js');
12
14
  const { resolveProse, markShipProgress, smokeGateMessages, proseFlagPresent, regradeNotesRefusal, staleSummaryNotice } = require('./ship-honesty.js');
13
15
  const { plainCardLines } = require('./plain-cards.js');
14
16
  const { classifyEmptyShip } = require('../../modules/lifecycle/ship-preflight');
@@ -42,7 +44,7 @@ async function shipMain() {
42
44
  const args = process.argv.slice(2);
43
45
  const taskId = Number(args[0]);
44
46
  if (!Number.isFinite(taskId)) {
45
- console.error('usage: node scripts/gds/ship.js <task-id> (--notes-file <path|-> | --notes "...") (--summary-file <path|-> | --summary "...") [--visual <image>] [--visual-alt "caption"] [--surprise "text" | --surprise none | --no-surprise] [--no-merge] [--db-only] [--verified] [--allow-empty] [--allow-smoke-fail] [--base <ref>]');
47
+ console.error('usage: node scripts/gds/ship.js <task-id> (--notes-file <path|-> | --notes "...") (--summary-file <path|-> | --summary "...") [--visual <image>] [--visual-alt "caption"] [--surprise "text" | --surprise none | --no-surprise] [--no-merge] [--db-only] [--verified] [--allow-empty] [--allow-smoke-fail] [--base <ref>] [--ship-mode auto|manual]');
46
48
  console.error(' prefer the -file forms for prose: a shell command-substitutes backticks in an inline value before node sees it (learning 1000197).');
47
49
  process.exit(2);
48
50
  }
@@ -157,6 +159,24 @@ async function shipMain() {
157
159
  process.exit(1);
158
160
  }
159
161
 
162
+ // ---- First ship asks for the ship mode, once (task 1004630, ADR 0369) ----
163
+ // A project that never chose Auto or Manual stops HERE, before anything resolves,
164
+ // so the AI can ask the person; the re-run carries --ship-mode and saves it. Never
165
+ // blocks a ship nobody can answer for (see ship-mode-ask.js).
166
+ const firstShip = await runFirstShipAsk({
167
+ args,
168
+ getShipMode: async () => {
169
+ const r = await apiCall('GET', '/api/bongos/project-settings/ship-mode');
170
+ return r.ok ? r.data : null;
171
+ },
172
+ saveShipMode: (mode) => apiCall('PATCH', '/api/bongos/project-settings/ship-mode', { mode }),
173
+ });
174
+ if (firstShip.stop) {
175
+ for (const line of firstShip.lines) console.log(line);
176
+ cliExit(firstShip.exit);
177
+ return;
178
+ }
179
+
160
180
  // ---- Hard cap: refuse to ship over the monthly budget (V3.R84 #302, C3) ----
161
181
  // Shipping runs the subagent grader, which spends API $. If this builder is at
162
182
  // or over their monthly budget, refuse here — BEFORE the grader runs and BEFORE
@@ -0,0 +1,138 @@
1
+ // scripts/gds/ship-mode-ask.js — the first-ship ship-mode question (task 1004630, ADR 0369).
2
+ //
3
+ // WHY. Ship mode says who starts a ship: Auto (the AI ships on its own) or Manual
4
+ // (a person approves each ship). The creation wizard asks for it (task 1004629),
5
+ // but a project made before that step, or by `bongos init`, never answered. Such a
6
+ // project quietly runs on the default, so its owner never learns there was a choice.
7
+ // This asks ONCE, at the next ship, and saves the answer. Once saved the mode reads
8
+ // as chosen and the question never comes back.
9
+ //
10
+ // HOW. The ship stops BEFORE anything resolves (the claim stays active) and prints
11
+ // an ask the AI turns into a two-option question for the person. The AI re-runs the
12
+ // same ship with `--ship-mode auto|manual`; that saves the answer and the ship goes on.
13
+ //
14
+ // It never blocks a ship it cannot get an answer for. The ship goes on with the
15
+ // default, and nobody is asked, when:
16
+ // - the mode could not be read (the resolver's own posture: a ship is never
17
+ // blocked by a settings read);
18
+ // - the caller cannot change project settings (only an owner-side builder can
19
+ // answer, so asking a teammate would stop every ship of theirs for nothing);
20
+ // - nobody is there to answer (an Autobongos worker or a subagent).
21
+ //
22
+ // The decision is pure (shipModeAskVerdict) so tests prove it with no network.
23
+
24
+ 'use strict';
25
+
26
+ // The ship exits with this code when it stops to ask. Distinct from 1 (failure)
27
+ // and 2 (usage), and from the grade cap's 6, so a wrapper can tell "a question is
28
+ // waiting" from "something broke".
29
+ const ASK_EXIT = 5;
30
+
31
+ const ASK_MARKER = '[otb-ask-ship-mode]';
32
+
33
+ // unattended(env) — true when no person is there to answer.
34
+ // OTB_SUBAGENT is set on every Autobongos worker (scripts/gds/autobongos-run.js
35
+ // workerEnv); AUTOBONGOS_GRADE_LEDGER is set only by that supervisor.
36
+ function unattended(env = process.env) {
37
+ return env.OTB_SUBAGENT === '1' || Boolean(env.AUTOBONGOS_GRADE_LEDGER);
38
+ }
39
+
40
+ // shipModeFlag(args) — the --ship-mode answer, or null when absent.
41
+ // { mode } for auto|manual, { bad } for any other value (the ship refuses it).
42
+ function shipModeFlag(args) {
43
+ const i = args.indexOf('--ship-mode');
44
+ if (i === -1) return null;
45
+ const raw = String(args[i + 1] || '').trim().toLowerCase();
46
+ if (raw === 'auto' || raw === 'manual') return { mode: raw };
47
+ return { bad: raw || '(missing)' };
48
+ }
49
+
50
+ // shipModeAskVerdict({ shipMode, flag, unattended }) →
51
+ // { action: 'proceed', why } — nothing to ask; ship on
52
+ // { action: 'save', mode } — the person answered; save it, then ship on
53
+ // { action: 'ask' } — stop and ask
54
+ // shipMode is the GET /project-settings/ship-mode body, or null when unreadable.
55
+ // can_change is absent on a server older than this task; absent means "unknown",
56
+ // and unknown asks (a refused save then lets the ship go on, see saveFailureLines).
57
+ function shipModeAskVerdict({ shipMode, flag, unattended: alone }) {
58
+ if (!shipMode) return { action: 'proceed', why: 'unreadable' };
59
+ if (shipMode.chosen) return { action: 'proceed', why: 'chosen' };
60
+ if (flag && flag.mode) return { action: 'save', mode: flag.mode };
61
+ if (shipMode.can_change === false) return { action: 'proceed', why: 'cannot_change' };
62
+ if (alone) return { action: 'proceed', why: 'unattended' };
63
+ return { action: 'ask' };
64
+ }
65
+
66
+ // askLines(shipMode, argv) — what the ship prints when it stops to ask. The first
67
+ // lines are for the AI; the choices are the server's own words, so the question a
68
+ // person sees matches the settings page.
69
+ function askLines(shipMode, argv) {
70
+ const choices = (shipMode && Array.isArray(shipMode.choices) && shipMode.choices.length)
71
+ ? shipMode.choices
72
+ : [
73
+ { mode: 'auto', label: 'Auto (recommended)', sentence: 'When the AI finishes a task it ships and lands it. Nobody has to type a command.' },
74
+ { mode: 'manual', label: 'Manual', sentence: 'The AI stops before each ship and asks a person to approve it.' },
75
+ ];
76
+ const rerun = ['node', 'scripts/gds/ship.js', ...argv.filter((a, i) => a !== '--ship-mode' && argv[i - 1] !== '--ship-mode')]
77
+ .map((a) => (/\s/.test(a) ? JSON.stringify(a) : a)).join(' ');
78
+ return [
79
+ `${ASK_MARKER} This project has never chosen a ship mode, so this first ship asks once.`,
80
+ 'Ask the person this question with two options, Auto first, then re-run this ship with their answer:',
81
+ '',
82
+ ' How should finished work ship on this project?',
83
+ ...choices.map((c) => ` - ${c.label}: ${c.sentence}`),
84
+ '',
85
+ ` Re-run: ${rerun} --ship-mode auto|manual`,
86
+ '',
87
+ 'Nothing was resolved: the claim is still active. The answer is saved for the',
88
+ 'whole project and can be changed later on the project settings page.',
89
+ ];
90
+ }
91
+
92
+ // saveFailureLines(status) — a refused or failed save never blocks the ship. The
93
+ // default (Auto) applies, and the person learns who can set it.
94
+ function saveFailureLines(status) {
95
+ const who = status === 403
96
+ ? 'Only the project owner (or someone with project settings access) can set the ship mode,'
97
+ : `The ship mode could not be saved (${status || 'no response'}),`;
98
+ return [
99
+ `${who} so this ship goes on with the default, Auto.`,
100
+ 'It can be set later on the project settings page, or with: node scripts/gds/shipping-mode.js auto|manual',
101
+ ];
102
+ }
103
+
104
+ // runFirstShipAsk({ args, getShipMode, saveShipMode, env, log }) — the side-effect
105
+ // half shipMain calls once it knows this is a real ship. Returns
106
+ // { stop: true, lines } when the ship must stop and ask, else { stop: false }.
107
+ // getShipMode() → body or null; saveShipMode(mode) → { ok, status, data }.
108
+ async function runFirstShipAsk({ args, getShipMode, saveShipMode, env = process.env, log = console.log }) {
109
+ const flag = shipModeFlag(args);
110
+ if (flag && flag.bad) {
111
+ return { stop: true, exit: 2, lines: [`--ship-mode must be auto or manual (got ${flag.bad}). Nothing was resolved: the claim is still active.`] };
112
+ }
113
+ let shipMode = null;
114
+ try { shipMode = await getShipMode(); } catch (_) { shipMode = null; }
115
+ const verdict = shipModeAskVerdict({ shipMode, flag, unattended: unattended(env) });
116
+ if (verdict.action === 'ask') return { stop: true, exit: ASK_EXIT, lines: askLines(shipMode, args) };
117
+ if (verdict.action === 'save') {
118
+ let r = null;
119
+ try { r = await saveShipMode(verdict.mode); } catch (_) { r = null; }
120
+ if (r && r.ok) {
121
+ log(`Ship mode saved: ${verdict.mode}. ${(r.data && r.data.sentence) || ''}`.trim());
122
+ } else {
123
+ for (const line of saveFailureLines(r && r.status)) log(line);
124
+ }
125
+ }
126
+ return { stop: false };
127
+ }
128
+
129
+ module.exports = {
130
+ ASK_EXIT,
131
+ ASK_MARKER,
132
+ unattended,
133
+ shipModeFlag,
134
+ shipModeAskVerdict,
135
+ askLines,
136
+ saveFailureLines,
137
+ runFirstShipAsk,
138
+ };
@@ -50,6 +50,12 @@
50
50
  // # game-surface diffs are otherwise staged to
51
51
  // # the builder's own local preview (ADR 0044) for
52
52
  // # a browser review BEFORE the claim resolves.
53
+ // node scripts/gds/ship.js <task-id> --ship-mode auto|manual
54
+ // # answer the first-ship question (task 1004630,
55
+ // # ADR 0369): a project that never chose a ship
56
+ // # mode stops once, before anything resolves, and
57
+ // # exits 5 with an ask; the re-run with the answer
58
+ // # saves it for the whole project and ships on.
53
59
  // node scripts/gds/ship.js <task-id> --approved # record your sandbox sign-off (#1048): re-run with
54
60
  // # this AFTER reviewing the staged change at the
55
61
  // # sandbox URL. Persists a sandbox_review row on the
@@ -109,9 +109,11 @@ const MODULE_GLOBS = {
109
109
  'scripts/gds/ship-finish.js', 'scripts/gds/ship-flow.js', 'scripts/gds/ship-git.js',
110
110
  'scripts/gds/ship-grade-steps.js', 'scripts/gds/ship-grade.js', 'scripts/gds/ship-honesty.js',
111
111
  'scripts/gds/ship-io.js', 'scripts/gds/ship-land.js', 'scripts/gds/ship-memory-sync.js',
112
- 'scripts/gds/ship-merge-gate.js', 'scripts/gds/ship-merge.js', 'scripts/gds/ship-preflight-steps.js',
112
+ 'scripts/gds/ship-merge-gate.js', 'scripts/gds/ship-merge.js', 'scripts/gds/ship-mode-ask.js',
113
+ 'scripts/gds/ship-preflight-steps.js',
113
114
  'scripts/gds/ship-regen.js', 'scripts/gds/ship-resume.js', 'scripts/gds/ship-session-upload.js',
114
115
  'scripts/gds/ship-state.js', 'scripts/gds/ship-surprise.js', 'scripts/gds/ship-visual.js',
116
+ 'scripts/gds/ship-mode-ask.js', // task 1004630
115
117
  'scripts/gds/merge-mode.js', 'scripts/gds/confirm.js', 'scripts/gds/main-audit.js',
116
118
  'scripts/gds/gate-review.js', 'scripts/gds/push-main.js', 'scripts/gds/ci-grade.js',
117
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.121'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
78
+ const CORE_VERSION = '1.21.122'; // 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');
@@ -266,11 +266,19 @@ const ADDED_SINCE_R101 = [
266
266
  + 'score guards a model cannot talk past.'),
267
267
  ...carve('scripts/gds/ship.js', ['ship-check', 'ship-deploy-target', 'ship-escalation',
268
268
  'ship-finish', 'ship-flow', 'ship-git', 'ship-grade-steps', 'ship-grade', 'ship-honesty',
269
- 'ship-io', 'ship-land', 'ship-memory-sync', 'ship-merge-gate', 'ship-merge',
269
+ 'ship-io', 'ship-land', 'ship-memory-sync', 'ship-merge-gate', 'ship-merge', 'ship-mode-ask',
270
270
  'ship-preflight-steps', 'ship-regen', 'ship-resume', 'ship-session-upload', 'ship-state',
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
+ },
274
282
  // task 1002839 (ADR 0089 §1). Not a `grader-*.js` sibling, so the carve tripwire
275
283
  // below would not demand it — registered by hand because it owns the gate.
276
284
  {
@@ -0,0 +1,219 @@
1
+ // tests/ship_mode_first_ship.mjs — the first ship asks for the ship mode once
2
+ // (task 1004630, ADR 0369).
3
+ //
4
+ // Pinned here:
5
+ // 1. the verdict: ask only when the mode was never chosen, the caller can answer,
6
+ // and someone is there to answer; an answer is saved; a chosen mode never asks;
7
+ // 2. the flow: an ask stops the ship with exit 5 and prints the two choices plus
8
+ // the re-run; the re-run saves the answer once and the ship goes on; a refused
9
+ // save never blocks the ship; a bad --ship-mode value is refused;
10
+ // 3. asked exactly once: after the answer is saved, the next ship does not ask;
11
+ // 4. the wiring: shipMain runs the ask after the claim is found and before the
12
+ // budget cap, the grader and the resolve, so nothing resolves while it waits;
13
+ // 5. the read route reports can_change for the caller, and null when authority
14
+ // cannot be established.
15
+ //
16
+ // Run: node --test tests/ship_mode_first_ship.mjs
17
+ import { strict as assert } from 'node:assert';
18
+ import { test } from 'node:test';
19
+ import { createRequire } from 'node:module';
20
+ import fs from 'node:fs';
21
+ import path from 'node:path';
22
+ import { fileURLToPath } from 'node:url';
23
+
24
+ const require = createRequire(import.meta.url);
25
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
26
+
27
+ const {
28
+ ASK_EXIT, ASK_MARKER, unattended, shipModeFlag, shipModeAskVerdict, askLines, runFirstShipAsk,
29
+ } = require('../scripts/gds/ship-mode-ask.js');
30
+
31
+ const unchosen = { mode: 'auto', chosen: false, source: 'default', can_change: true };
32
+ const chosen = { mode: 'manual', chosen: true, source: 'project_settings', can_change: true };
33
+
34
+ // ---- 1. the verdict --------------------------------------------------------
35
+
36
+ test('a never-chosen mode asks; a chosen one never does', () => {
37
+ assert.deepEqual(shipModeAskVerdict({ shipMode: unchosen, flag: null, unattended: false }), { action: 'ask' });
38
+ assert.equal(shipModeAskVerdict({ shipMode: chosen, flag: null, unattended: false }).action, 'proceed');
39
+ });
40
+
41
+ test('an answer on an unchosen project is saved; on a chosen one it changes nothing', () => {
42
+ assert.deepEqual(shipModeAskVerdict({ shipMode: unchosen, flag: { mode: 'manual' }, unattended: false }), { action: 'save', mode: 'manual' });
43
+ assert.equal(shipModeAskVerdict({ shipMode: chosen, flag: { mode: 'auto' }, unattended: false }).action, 'proceed',
44
+ 'the flag answers the first-ship question only; changing a chosen mode is the settings page or shipping-mode.js');
45
+ });
46
+
47
+ test('never blocks a ship nobody can answer for', () => {
48
+ assert.equal(shipModeAskVerdict({ shipMode: null, flag: null, unattended: false }).why, 'unreadable');
49
+ assert.equal(shipModeAskVerdict({ shipMode: { ...unchosen, can_change: false }, flag: null, unattended: false }).why, 'cannot_change');
50
+ assert.equal(shipModeAskVerdict({ shipMode: unchosen, flag: null, unattended: true }).why, 'unattended');
51
+ });
52
+
53
+ test('an older server with no can_change still asks (unknown is not "no")', () => {
54
+ const { can_change, ...old } = unchosen;
55
+ assert.equal(shipModeAskVerdict({ shipMode: old, flag: null, unattended: false }).action, 'ask');
56
+ assert.equal(shipModeAskVerdict({ shipMode: { ...unchosen, can_change: null }, flag: null, unattended: false }).action, 'ask');
57
+ });
58
+
59
+ test('unattended means an Autobongos worker or a subagent', () => {
60
+ assert.equal(unattended({}), false);
61
+ assert.equal(unattended({ OTB_SUBAGENT: '1' }), true);
62
+ assert.equal(unattended({ AUTOBONGOS_GRADE_LEDGER: '/tmp/ledger.json' }), true);
63
+ });
64
+
65
+ test('--ship-mode parses auto|manual in any case and flags anything else', () => {
66
+ assert.equal(shipModeFlag(['1', '--notes', 'x']), null);
67
+ assert.deepEqual(shipModeFlag(['1', '--ship-mode', 'Auto']), { mode: 'auto' });
68
+ assert.deepEqual(shipModeFlag(['1', '--ship-mode', 'manual']), { mode: 'manual' });
69
+ assert.deepEqual(shipModeFlag(['1', '--ship-mode', 'sometimes']), { bad: 'sometimes' });
70
+ assert.deepEqual(shipModeFlag(['1', '--ship-mode']), { bad: '(missing)' });
71
+ });
72
+
73
+ // ---- 2. the flow -----------------------------------------------------------
74
+
75
+ function harness(initial, { saveStatus = 200 } = {}) {
76
+ let current = { ...initial };
77
+ const saves = [];
78
+ const logs = [];
79
+ return {
80
+ saves, logs,
81
+ deps: (args, env = {}) => ({
82
+ args,
83
+ env,
84
+ log: (l) => logs.push(l),
85
+ getShipMode: async () => ({ ...current }),
86
+ saveShipMode: async (mode) => {
87
+ saves.push(mode);
88
+ if (saveStatus !== 200) return { ok: false, status: saveStatus, data: { error: 'permission_forbidden' } };
89
+ current = { ...current, mode, chosen: true, source: 'project_settings' };
90
+ return { ok: true, status: 200, data: { mode, chosen: true, sentence: `Saved ${mode}.` } };
91
+ },
92
+ }),
93
+ };
94
+ }
95
+
96
+ test('an unchosen project stops the ship with exit 5 and asks Auto first', async () => {
97
+ const h = harness(unchosen);
98
+ const args = ['1004630', '--notes-file', 'notes.md', '--summary', 'two words'];
99
+ const r = await runFirstShipAsk(h.deps(args));
100
+ assert.equal(r.stop, true);
101
+ assert.equal(r.exit, ASK_EXIT);
102
+ assert.equal(ASK_EXIT, 5);
103
+ const text = r.lines.join('\n');
104
+ assert.ok(r.lines[0].startsWith(ASK_MARKER));
105
+ assert.match(text, /Auto \(recommended\)[\s\S]*Manual/, 'Auto is offered first and named as recommended');
106
+ assert.match(text, /node scripts\/gds\/ship\.js 1004630 --notes-file notes\.md --summary "two words" --ship-mode auto\|manual/);
107
+ assert.match(text, /claim is still active/);
108
+ assert.deepEqual(h.saves, [], 'asking saves nothing');
109
+ });
110
+
111
+ test('the re-run with the answer saves it and the ship goes on', async () => {
112
+ const h = harness(unchosen);
113
+ const r = await runFirstShipAsk(h.deps(['1004630', '--ship-mode', 'manual']));
114
+ assert.equal(r.stop, false);
115
+ assert.deepEqual(h.saves, ['manual']);
116
+ assert.match(h.logs.join('\n'), /Ship mode saved: manual/);
117
+ });
118
+
119
+ test('asked exactly once: after the answer, the next ship neither asks nor saves', async () => {
120
+ const h = harness(unchosen);
121
+ const first = await runFirstShipAsk(h.deps(['1']));
122
+ assert.equal(first.stop, true);
123
+ await runFirstShipAsk(h.deps(['1', '--ship-mode', 'auto']));
124
+ const next = await runFirstShipAsk(h.deps(['2']));
125
+ assert.equal(next.stop, false, 'the second ship does not ask again');
126
+ const nextWithStaleFlag = await runFirstShipAsk(h.deps(['3', '--ship-mode', 'manual']));
127
+ assert.equal(nextWithStaleFlag.stop, false);
128
+ assert.deepEqual(h.saves, ['auto'], 'the answer was written once and never overwritten by a later ship');
129
+ });
130
+
131
+ test('a refused save never blocks the ship, and says who can set it', async () => {
132
+ const h = harness(unchosen, { saveStatus: 403 });
133
+ const r = await runFirstShipAsk(h.deps(['1', '--ship-mode', 'manual']));
134
+ assert.equal(r.stop, false);
135
+ assert.match(h.logs.join('\n'), /Only the project owner[\s\S]*default, Auto/);
136
+ });
137
+
138
+ test('a bad --ship-mode value is refused before anything resolves', async () => {
139
+ const h = harness(unchosen);
140
+ const r = await runFirstShipAsk(h.deps(['1', '--ship-mode', 'sometimes']));
141
+ assert.equal(r.stop, true);
142
+ assert.equal(r.exit, 2);
143
+ assert.match(r.lines[0], /must be auto or manual/);
144
+ assert.deepEqual(h.saves, []);
145
+ });
146
+
147
+ test('an unreadable mode and an unattended run both ship on without asking', async () => {
148
+ const dead = await runFirstShipAsk({ args: ['1'], getShipMode: async () => { throw new Error('down'); }, saveShipMode: async () => assert.fail('no save'), env: {}, log: () => {} });
149
+ assert.equal(dead.stop, false);
150
+ const h = harness(unchosen);
151
+ const alone = await runFirstShipAsk(h.deps(['1'], { OTB_SUBAGENT: '1' }));
152
+ assert.equal(alone.stop, false);
153
+ });
154
+
155
+ test('the ask falls back to its own wording when the server sends no choices', () => {
156
+ const lines = askLines({ mode: 'auto', chosen: false }, ['1']);
157
+ assert.match(lines.join('\n'), /Auto \(recommended\)[\s\S]*Manual/);
158
+ });
159
+
160
+ // ---- 4. the wiring ---------------------------------------------------------
161
+
162
+ test('shipMain asks after finding the claim and before the budget cap and grader', () => {
163
+ const src = fs.readFileSync(path.join(ROOT, 'scripts', 'gds', 'ship-flow.js'), 'utf8');
164
+ const main = src.slice(src.indexOf('async function shipMain'));
165
+ const claimFound = main.indexOf("const ac = claims.find(");
166
+ const ask = main.indexOf('runFirstShipAsk(');
167
+ const budget = main.indexOf('Hard cap: refuse to ship over the monthly budget');
168
+ assert.ok(claimFound > 0 && ask > claimFound, 'asks only once a real claim is in hand');
169
+ assert.ok(budget > ask, 'asks before the budget cap, so nothing has resolved or been spent');
170
+ assert.match(main.slice(ask, budget), /cliExit\(firstShip\.exit\);\s*return;/, 'stops with cliExit, never a hard exit after a request');
171
+ });
172
+
173
+ test('the --ship-mode flag is documented in the ship usage banner', () => {
174
+ const src = fs.readFileSync(path.join(ROOT, 'scripts', 'gds', 'ship.js'), 'utf8');
175
+ assert.match(src, /--ship-mode auto\|manual/);
176
+ });
177
+
178
+ // ---- 5. the read route's can_change ----------------------------------------
179
+
180
+ const api = require(path.join(ROOT, 'src', 'module-api.js'));
181
+ const fakeRes = () => ({
182
+ code: null, statusCode: 200, payload: null, headers: {},
183
+ fail(code, opts) { this.code = code; this.statusCode = opts?.status ?? 400; this.payload = { error: code }; return this; },
184
+ status(c) { this.statusCode = c; return this; },
185
+ set(k, v) { this.headers[k] = v; return this; },
186
+ json(p) { this.payload = p; return this; },
187
+ });
188
+
189
+ async function readAs({ builder, government }) {
190
+ const saved = { resolve: api.projectSettings.resolveShipMode, resolveOptional: api.resolveOptional };
191
+ api.projectSettings.resolveShipMode = async () => ({ mode: 'auto', source: 'default', chosen: false, invalid: null });
192
+ api.resolveOptional = (port) => (port === 'government' ? government : saved.resolveOptional(port));
193
+ try {
194
+ const file = path.join(ROOT, 'modules', 'lifecycle', 'routes', 'project-settings.js');
195
+ delete require.cache[require.resolve(file)];
196
+ const router = require(file)();
197
+ const route = router.stack.find((l) => l.route && l.route.path === '/project-settings/ship-mode' && l.route.methods.get).route;
198
+ const res = fakeRes();
199
+ await route.stack[route.stack.length - 1].handle({ builder }, res);
200
+ return res;
201
+ } finally {
202
+ api.projectSettings.resolveShipMode = saved.resolve;
203
+ api.resolveOptional = saved.resolveOptional;
204
+ }
205
+ }
206
+
207
+ test('can_change is true for a settings manager and false for anyone else', async () => {
208
+ const government = { resolveBuilderPermissions: async (id) => (id === 1 ? ['project.settings.manage'] : ['task.claim.any']) };
209
+ assert.equal((await readAs({ builder: { id: 1 }, government })).payload.can_change, true);
210
+ assert.equal((await readAs({ builder: { id: 2 }, government })).payload.can_change, false);
211
+ });
212
+
213
+ test('can_change is null, never true, when authority cannot be established', async () => {
214
+ assert.equal((await readAs({ builder: { id: 1 }, government: null })).payload.can_change, null);
215
+ const throwing = { resolveBuilderPermissions: async () => { throw new Error('boom'); } };
216
+ const res = await readAs({ builder: { id: 1 }, government: throwing });
217
+ assert.equal(res.statusCode, 200, 'a permission read failure never fails the read');
218
+ assert.equal(res.payload.can_change, null);
219
+ });