@bongos/core 1.18.19 → 1.19.2

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.
Files changed (251) hide show
  1. package/.bongos-core.json +514 -194
  2. package/.claude/skills/builder-box/SKILL.md +1 -1
  3. package/.claude/skills/builder-claim/SKILL.md +1 -1
  4. package/.claude/skills/goal-create/SKILL.md +1 -1
  5. package/.claude/skills/idea-triage/SKILL.md +1 -1
  6. package/.claude/skills/planning-session/SKILL.md +5 -5
  7. package/.claude/skills/scan-before-install/SKILL.md +165 -0
  8. package/clients/bongos-client/README.md +1 -1
  9. package/clients/bongos-client/bongos-client.global.js +63 -41
  10. package/clients/bongos-client/index.cjs +63 -41
  11. package/clients/bongos-client/index.d.ts +78 -44
  12. package/clients/bongos-client/index.mjs +63 -41
  13. package/docs/adr/0020-security-model-public-stub.md +1 -1
  14. package/docs/adr/0097-one-active-claim-per-session-and-worktree-binding.md +2 -0
  15. package/docs/adr/0144-devbox-rehome-onto-cloudbongos-plane.md +1 -1
  16. package/docs/adr/0151-governance-permissions-as-atom-ranks-as-roles.md +2 -1
  17. package/docs/adr/0155-adopt-private-repo-widen-oauth-scope.md +51 -0
  18. package/docs/adr/0156-user-set-display-name-wins.md +62 -0
  19. package/docs/adr/0157-archon-is-rank-and-identity-only.md +93 -0
  20. package/docs/adr/0158-choke-point-verdict-taxonomy.md +174 -0
  21. package/docs/adr/0159-merge-gate-reconciliation.md +88 -0
  22. package/docs/adr/0160-main-audit-repo-verifiable-caller.md +77 -0
  23. package/docs/adr/README.md +8 -0
  24. package/docs/api/openapi.json +1209 -233
  25. package/docs/api-reference.md +61 -46
  26. package/docs/architecture.md +1 -1
  27. package/docs/canonical-permissions.md +61 -16
  28. package/docs/design/vanilla-hall-ui-redesign-scope.md +1 -1
  29. package/docs/file-map.md +4 -1
  30. package/docs/onboarding/diagrams/assertions.json +3 -3
  31. package/docs/recipes/core-release-pipeline.md +179 -0
  32. package/docs/recipes/packaging-the-core.md +2 -0
  33. package/docs/recipes/parallel-goal-waves.md +1 -3
  34. package/docs/recipes/private-npm-distribution.md +2 -0
  35. package/docs/recipes/upgrading-the-core.md +2 -0
  36. package/migrations/core_197_display_name_source.sql +56 -0
  37. package/migrations/core_204_rekey_active_claim_per_session_worktree.sql +77 -0
  38. package/modules/autonomy/routes/autonomy.js +1 -1
  39. package/modules/builder-settings/db.js +7 -1
  40. package/modules/dev-box/app/src/vendor/bongos-client.cjs +63 -41
  41. package/modules/dev-box/routes/box.js +11 -9
  42. package/modules/discord/auth.js +3 -1
  43. package/modules/discord/routes/discord.js +8 -2
  44. package/modules/discord/ship-broadcast.js +48 -1
  45. package/modules/governance/catalog.js +70 -20
  46. package/modules/governance/db.js +102 -0
  47. package/modules/governance/migrations/governance_006_version_create.sql +38 -0
  48. package/modules/governance/migrations/governance_007_delegate_archon_ops_to_metic.sql +72 -0
  49. package/modules/governance/routes/governance.js +235 -6
  50. package/modules/grading/grader-workers/index.js +18 -9
  51. package/modules/hall-ui/public/gate.js +6 -2
  52. package/modules/hall-ui/public/goals-page.js +6 -2
  53. package/modules/hall-ui/public/governance.css +100 -0
  54. package/modules/hall-ui/public/governance.html +77 -0
  55. package/modules/hall-ui/public/governance.js +353 -0
  56. package/modules/hall-ui/public/harbor.js +14 -8
  57. package/modules/hall-ui/public/instances-panel.js +3 -1
  58. package/modules/hall-ui/public/new-project-wizard.js +6 -0
  59. package/modules/hall-ui/public/people.html +51 -0
  60. package/modules/hall-ui/public/people.js +100 -0
  61. package/modules/hall-ui/public/profile.html +2 -1
  62. package/modules/hall-ui/public/profile.js +9 -3
  63. package/modules/hall-ui/public/sessions.js +4 -2
  64. package/modules/hall-ui/public/shell.js +28 -7
  65. package/modules/hall-ui/public/task-detail.js +6 -3
  66. package/modules/hall-ui/public/watch.js +17 -9
  67. package/modules/ideas/blockers.js +44 -7
  68. package/modules/ideas/routes/blockers.js +12 -7
  69. package/modules/ideas/routes/inbox.js +2 -2
  70. package/modules/lifecycle/db.js +84 -17
  71. package/modules/lifecycle/github-push.js +37 -10
  72. package/modules/lifecycle/publish-reconciler.js +128 -31
  73. package/modules/lifecycle/routes/claims.js +10 -4
  74. package/modules/lifecycle/routes/dependencies.js +2 -2
  75. package/modules/lifecycle/routes/done-when.js +4 -4
  76. package/modules/lifecycle/routes/gate-approvals.js +9 -3
  77. package/modules/lifecycle/routes/goals.js +5 -5
  78. package/modules/lifecycle/routes/tasks.js +16 -16
  79. package/modules/lifecycle/routes/versions.js +13 -2
  80. package/modules/lifecycle/ship-card.js +38 -7
  81. package/modules/memory/routes/memory.js +2 -2
  82. package/modules/onboarding/routes/access-requests.js +3 -3
  83. package/modules/platform-identity/routes/projects.js +10 -6
  84. package/modules/platform-identity/routes/scouting.js +2 -2
  85. package/modules/provisioning/migrations/provisioning_004_onboard_mode.sql +30 -0
  86. package/modules/provisioning/provisioning.js +24 -6
  87. package/modules/provisioning/routes/provisioning.js +24 -6
  88. package/modules/security/artifact-scan/aggregate.js +133 -0
  89. package/modules/security/artifact-scan/detectors/capabilities.js +53 -0
  90. package/modules/security/artifact-scan/detectors/config-obfuscation.js +41 -0
  91. package/modules/security/artifact-scan/detectors/credentials.js +112 -0
  92. package/modules/security/artifact-scan/detectors/dependencies.js +115 -0
  93. package/modules/security/artifact-scan/detectors/hooks.js +116 -0
  94. package/modules/security/artifact-scan/detectors/injection.js +118 -0
  95. package/modules/security/artifact-scan/detectors/mcp.js +102 -0
  96. package/modules/security/artifact-scan/detectors/network.js +67 -0
  97. package/modules/security/artifact-scan/detectors/npm-lifecycle.js +67 -0
  98. package/modules/security/artifact-scan/detectors/permissions.js +91 -0
  99. package/modules/security/artifact-scan/detectors/shared.js +219 -0
  100. package/modules/security/artifact-scan/fence-local.js +99 -0
  101. package/modules/security/artifact-scan/fetch.js +330 -0
  102. package/modules/security/artifact-scan/floor.js +319 -0
  103. package/modules/security/artifact-scan/limits.js +66 -0
  104. package/modules/security/artifact-scan/manifest.js +203 -0
  105. package/modules/security/artifact-scan/mirror.js +158 -0
  106. package/modules/security/artifact-scan/quarantine.js +149 -0
  107. package/modules/security/artifact-scan/rebuild-spec.js +120 -0
  108. package/modules/security/artifact-scan/report.js +155 -0
  109. package/modules/security/artifact-scan/sanitize.js +317 -0
  110. package/modules/security/migrations/security_001_tool_scans.sql +145 -0
  111. package/modules/security/module.json +2 -2
  112. package/modules/security/routes/reports.js +11 -7
  113. package/modules/security/routes/scans.js +115 -0
  114. package/modules/security/scan-store.js +214 -0
  115. package/modules/security/tests/aggregate.mjs +283 -0
  116. package/modules/security/tests/artifact_scan.mjs +591 -0
  117. package/modules/security/tests/floor.mjs +549 -0
  118. package/modules/security/tests/mirror.mjs +350 -0
  119. package/modules/security/tests/sanitize_rebuild.mjs +499 -0
  120. package/modules/security/tests/scan_store.mjs +250 -0
  121. package/modules/security/tests/tool_scans_migration.mjs +116 -0
  122. package/modules/sessions/routes/sessions.js +6 -5
  123. package/package-lock.json +2 -2
  124. package/package.json +1 -1
  125. package/public-landing/create.html +13 -3
  126. package/scripts/gds/abandon.js +1 -1
  127. package/scripts/gds/audit-authorship.sh +6 -0
  128. package/scripts/gds/box.js +1 -1
  129. package/scripts/gds/claim.js +15 -0
  130. package/scripts/gds/cli-lib.js +16 -4
  131. package/scripts/gds/gating.js +28 -13
  132. package/scripts/gds/gen-api-docs.js +1 -1
  133. package/scripts/gds/goal.js +234 -0
  134. package/scripts/gds/init.js +70 -15
  135. package/scripts/gds/main-audit.js +80 -11
  136. package/scripts/gds/mirror-redact.js +6 -0
  137. package/scripts/gds/newcomer-floor.js +2 -2
  138. package/scripts/gds/provision.js +230 -94
  139. package/scripts/gds/run-unit-tests.js +9 -1
  140. package/scripts/gds/sandbox-stage.js +66 -9
  141. package/scripts/gds/search-ingest-md.js +1 -0
  142. package/scripts/gds/sequence.js +67 -8
  143. package/scripts/gds/ship.js +183 -50
  144. package/scripts/hall-preview/server.js +1 -1
  145. package/scripts/migrate.sh +5 -5
  146. package/src/bongos/auth.js +129 -27
  147. package/src/bongos/db-kernel.js +66 -5
  148. package/src/bongos/db.js +6 -7
  149. package/src/bongos/github-repos.js +19 -7
  150. package/src/bongos/goal-scope-check.js +1 -1
  151. package/src/bongos/module-scope-map.js +3 -1
  152. package/src/bongos/permission-path-check.js +1 -1
  153. package/src/bongos/route-rank-check.js +73 -30
  154. package/src/bongos/routes/audit-log.js +4 -2
  155. package/src/bongos/routes/auth.js +8 -4
  156. package/src/bongos/routes/backup.js +7 -4
  157. package/src/bongos/routes/builders.js +23 -11
  158. package/src/bongos/routes/github.js +15 -4
  159. package/src/bongos/routes/llm-cache.js +5 -2
  160. package/src/bongos/routes/modules.js +9 -5
  161. package/src/bongos/routes/security.js +21 -12
  162. package/src/bongos/serve-internal.js +22 -7
  163. package/src/module-api.js +86 -13
  164. package/src/platform-server.js +14 -0
  165. package/tests/api_client.mjs +18 -2
  166. package/tests/api_path_404.mjs +3 -0
  167. package/tests/art_stage.mjs +2 -1
  168. package/tests/atlas.mjs +32 -4
  169. package/tests/auth_admission_gate.mjs +17 -5
  170. package/tests/auth_error_brand.mjs +41 -21
  171. package/tests/auth_resolve_failure.mjs +155 -0
  172. package/tests/blocker_link.mjs +88 -0
  173. package/tests/bongos_downloads.mjs +2 -1
  174. package/tests/box_dns_repoint.mjs +2 -0
  175. package/tests/box_ship_permissions.mjs +2 -0
  176. package/tests/box_terminal_ssrf.mjs +23 -17
  177. package/tests/claim_error_surface.mjs +23 -0
  178. package/tests/claude_materialize.mjs +4 -0
  179. package/tests/cli_box_scope.mjs +40 -0
  180. package/tests/cloud_bongos_pack.mjs +34 -4
  181. package/tests/conductor.mjs +2 -1
  182. package/tests/criterion_authoring_gate.mjs +28 -5
  183. package/tests/currency_label.mjs +10 -1
  184. package/tests/deploy_instance.mjs +2 -0
  185. package/tests/devbox_downloads.mjs +2 -1
  186. package/tests/display_name.mjs +95 -6
  187. package/tests/eviction_policy.mjs +2 -1
  188. package/tests/file_map.mjs +10 -0
  189. package/tests/first_admin_bootstrap.mjs +10 -2
  190. package/tests/first_run_engine_check.mjs +2 -1
  191. package/tests/fitness.mjs +26 -6
  192. package/tests/gate_approvals.mjs +15 -2
  193. package/tests/github_push_land_proof.mjs +50 -4
  194. package/tests/github_repos_helpers.mjs +30 -5
  195. package/tests/github_token_store.mjs +23 -0
  196. package/tests/goal_abuse_matrix.mjs +13 -3
  197. package/tests/goal_cli.mjs +89 -0
  198. package/tests/governance_abuse_matrix.mjs +386 -0
  199. package/tests/governance_fold.mjs +156 -0
  200. package/tests/governance_fold_bench.mjs +50 -0
  201. package/tests/governance_live_proof.mjs +201 -0
  202. package/tests/governance_page_gates.mjs +21 -9
  203. package/tests/governance_parity.mjs +11 -2
  204. package/tests/governance_require_permission.mjs +125 -1
  205. package/tests/governance_routes.mjs +334 -0
  206. package/tests/grader.mjs +7 -3
  207. package/tests/hall_nav.mjs +14 -4
  208. package/tests/helpers.mjs +73 -0
  209. package/tests/hook_core_resolve.mjs +2 -1
  210. package/tests/host_topology_skips.mjs +97 -0
  211. package/tests/infra_exec_paths.mjs +14 -4
  212. package/tests/init.mjs +128 -0
  213. package/tests/known_failing_ratchet.mjs +89 -0
  214. package/tests/lifecycle-card.mjs +2 -1
  215. package/tests/main_audit.mjs +72 -1
  216. package/tests/memory_isolation.mjs +56 -9
  217. package/tests/migrate_root_resolution.mjs +1 -1
  218. package/tests/mirror_redact.mjs +19 -0
  219. package/tests/module_api.mjs +3 -1
  220. package/tests/module_loader.mjs +4 -0
  221. package/tests/modules.mjs +35 -7
  222. package/tests/modules_route.mjs +29 -5
  223. package/tests/onboarding_observability.mjs +49 -11
  224. package/tests/onboarding_route_signals.mjs +1 -0
  225. package/tests/one_active_claim_per_session.mjs +80 -0
  226. package/tests/pre_compact.mjs +2 -1
  227. package/tests/preview_server.mjs +2 -1
  228. package/tests/profile_route.mjs +1 -0
  229. package/tests/provision.mjs +170 -0
  230. package/tests/provisioning_onboard_mode.mjs +180 -0
  231. package/tests/publish_manifest.mjs +16 -3
  232. package/tests/publish_reconciler.mjs +181 -10
  233. package/tests/rank_tier_single_source.mjs +205 -0
  234. package/tests/recall_hints.mjs +2 -1
  235. package/tests/repo_map.mjs +4 -0
  236. package/tests/route_permission_check.mjs +14 -5
  237. package/tests/rubric_fixtures.mjs +3 -0
  238. package/tests/run_routine_two_root.mjs +2 -0
  239. package/tests/sandbox_stage.mjs +69 -7
  240. package/tests/search_ingest.mjs +7 -0
  241. package/tests/security_docs.mjs +28 -6
  242. package/tests/sequence.mjs +64 -0
  243. package/tests/session-cards.mjs +2 -1
  244. package/tests/session_start_freshness.mjs +2 -1
  245. package/tests/ship_broadcast_server.mjs +63 -1
  246. package/tests/ship_card.mjs +66 -0
  247. package/tests/ship_ci_deploy.mjs +66 -1
  248. package/tests/ship_smoke_gate.mjs +76 -0
  249. package/tests/versions_route.mjs +15 -0
  250. package/tests/workflow_exec_paths.mjs +16 -4
  251. package/tests/worktree_guard.mjs +2 -1
@@ -10,10 +10,11 @@
10
10
 
11
11
  import { strict as assert } from 'node:assert';
12
12
  import { createRequire } from 'node:module';
13
+ import { requireHostModule } from './helpers.mjs';
13
14
 
14
15
  const require = createRequire(import.meta.url);
15
16
  const { taskQuery, pickActiveClaim, formatHints, DEADLINE_MS } =
16
- require('../.claude/hooks/recall-hints.js');
17
+ requireHostModule(require, '../.claude/hooks/recall-hints.js');
17
18
 
18
19
  let passed = 0;
19
20
  let failed = 0;
@@ -71,6 +71,10 @@ test('each nested CLAUDE.md carries a delimited generated block with signatures'
71
71
  const doc = readFileSync(path.join(ROOT, dir, 'CLAUDE.md'), 'utf8');
72
72
  assert.ok(doc.includes(BEGIN), `${dir}/CLAUDE.md should have a generated-block start marker`);
73
73
  assert.ok(doc.includes(END), `${dir}/CLAUDE.md should have a generated-block end marker`);
74
+ // In a PUBLISHED core the nested agent notes are replaced by package-core.js's
75
+ // CORE_AGENT_DOC_STUB, so there is no hand-written prose to preserve (task 1002470).
76
+ // Where the real doc is present the prose must survive the generated-block injection.
77
+ if (doc.includes('@bongos/core — installed platform dependency')) continue;
74
78
  // the hand-written prose is preserved alongside the generated block
75
79
  assert.match(doc, /Nested CLAUDE\.md — loads on demand/);
76
80
  }
@@ -41,12 +41,16 @@ const rowOf = (src) => rrc.classifyText(src)[0];
41
41
  // here ONCE by hand. This is the independent side of every classification assertion
42
42
  // below — if someone lowers `backup.manage` to 'xenos', the classifier follows the
43
43
  // catalog and this file goes red, which is the whole point.
44
+ // ADR 0157 moved 20 atoms archon→metic. `audit_log.read` and `builder.roster.read` are
45
+ // transcribed at their new floor below; the archon entries that remain are the
46
+ // rank/identity core, which is the point of that ADR.
44
47
  const HAND_WRITTEN_FLOORS = {
45
48
  'builder.rank.set': 'archon',
46
- 'audit_log.read': 'archon',
47
49
  'auth.session.revoke': 'archon',
48
- 'builder.roster.read': 'archon',
49
50
  'builder.status.set': 'archon',
51
+ 'access_request.review': 'archon',
52
+ 'audit_log.read': 'metic',
53
+ 'builder.roster.read': 'metic',
50
54
  'backup.manage': 'metic',
51
55
  'idea.triage': 'metic',
52
56
  'security.report.file': 'metic',
@@ -132,13 +136,17 @@ test('P1: the label agrees with the REAL threshold function over the whole rank
132
136
  });
133
137
 
134
138
  test('P1: AND semantics — a multi-atom gate takes the STRICTEST floor', () => {
135
- const src = "router.post('/x', auth.requireBuilder, auth.requirePermission('idea.file', 'audit_log.read'), h);";
139
+ // Uses `builder.rank.set` as the archon end of the pair: ADR 0157 lowered
140
+ // `audit_log.read` (the previous example) to metic, which would no longer exercise the
141
+ // metic-vs-archon spread this test exists to prove. Rank-setting is the one power that
142
+ // ADR 0157 guarantees stays archon, so it is the stable choice here.
143
+ const src = "router.post('/x', auth.requireBuilder, auth.requirePermission('idea.file', 'builder.rank.set'), h);";
136
144
  assert.equal(rankOf(src), 'archon',
137
145
  'requirePermission ANDs, so the gate is as strict as its strictest atom (archon), not its loosest');
138
146
  const src2 = "router.post('/x', auth.requireBuilder, auth.requirePermission('cost.log', 'backup.manage'), h);";
139
147
  assert.equal(rankOf(src2), 'metic+archon');
140
148
  // order must not matter
141
- const src3 = "router.post('/x', auth.requireBuilder, auth.requirePermission('audit_log.read', 'idea.file'), h);";
149
+ const src3 = "router.post('/x', auth.requireBuilder, auth.requirePermission('builder.rank.set', 'idea.file'), h);";
142
150
  assert.equal(rankOf(src3), 'archon');
143
151
  });
144
152
 
@@ -274,7 +282,8 @@ test('P4: gen-api-docs labels a permission-gated route by its floor, not any-bui
274
282
  const trigger = model.find((r) => r.route === '/backup/trigger');
275
283
  const audit = model.find((r) => r.route === '/audit-log');
276
284
  assert.equal(trigger.rank, 'metic+archon', 'the published rank must be the real floor');
277
- assert.equal(audit.rank, 'archon');
285
+ // ADR 0157: `audit_log.read` floor archon→metic, so the published rank follows.
286
+ assert.equal(audit.rank, 'metic+archon');
278
287
  assert.deepEqual(trigger.perms, ['backup.manage'], 'the docs also name the authority atom');
279
288
 
280
289
  const spec = genApiDocs.buildOpenapi(model);
@@ -23,6 +23,7 @@
23
23
  import { strict as assert } from 'node:assert';
24
24
  import { readFileSync, existsSync } from 'node:fs';
25
25
  import { fileURLToPath } from 'node:url';
26
+ import { skipUnlessPresent } from './helpers.mjs';
26
27
  import { dirname, resolve } from 'node:path';
27
28
 
28
29
  const HERE = dirname(fileURLToPath(import.meta.url));
@@ -30,6 +31,8 @@ const ROOT = resolve(HERE, '..');
30
31
  const FIXTURES_DIR = resolve(ROOT, 'modules', 'art-pipeline', 'template', 'rubric_fixtures');
31
32
  const MANIFEST = resolve(FIXTURES_DIR, 'fixtures.json');
32
33
  const RUBRIC = resolve(ROOT, 'modules', 'art-pipeline', 'template', 'rubric.json');
34
+ skipUnlessPresent('modules/art-pipeline/template/rubric_fixtures',
35
+ 'The art-pipeline module is not installed in this instance.');
33
36
 
34
37
  let passed = 0;
35
38
  function test(name, fn) {
@@ -19,11 +19,13 @@ import fs from 'node:fs';
19
19
  import os from 'node:os';
20
20
  import path from 'node:path';
21
21
  import { fileURLToPath } from 'node:url';
22
+ import { skipUnlessPresent } from './helpers.mjs';
22
23
 
23
24
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
24
25
  const REPO = path.resolve(__dirname, '..'); // the core root (this repo), fixed by resolveCoreRoot()
25
26
  const RUN_ROUTINE = path.join(REPO, 'scripts', 'gds', 'run-routine.js');
26
27
  const CORE_RESOLVE = path.join(REPO, '.claude', 'hooks', '_core-resolve.js');
28
+ skipUnlessPresent('.claude/hooks/_core-resolve.js');
27
29
 
28
30
  let passed = 0, failed = 0;
29
31
  function test(name, fn) {
@@ -240,7 +240,10 @@ function boxDeps(markerFile, overrides = {}) {
240
240
  if (cmd === 'git') return execFileSync(cmd, args, opts);
241
241
  return '';
242
242
  },
243
- fetchImpl: async () => ({ status: 200 }),
243
+ // task 1002607: emulate a real PREVIEW — 200 on the health URL, 404 on the
244
+ // /api/gds/healthz discriminator (the game-only entry mounts no /api/gds).
245
+ // A flat always-200 mock would now read as "the MAIN server answered".
246
+ fetchImpl: async (url) => ({ status: String(url).includes('/api/gds/healthz') ? 404 : 200 }),
244
247
  markerPath: markerFile,
245
248
  cwd: tmp,
246
249
  pollMs: 1,
@@ -346,11 +349,19 @@ await ta('runShipGate: --skip-stage → ok, untouched', async () => {
346
349
  assert.equal(fs.existsSync(mp), false);
347
350
  });
348
351
 
349
- await ta('runShipGate: staging failure (health never green) warns but does not block', async () => {
352
+ // task 1002607 (§4.4 station 9): a staging failure used to WARN one stderr line
353
+ // and return ok — the review gate silently turned itself off. It now fails
354
+ // CLOSED (like the smoke gate, task 1002605): abort, claim intact, --skip-stage
355
+ // as the explicit bypass, and the reason says plainly the review DID NOT run.
356
+ await ta('1002607 runShipGate: staging failure (health never green) ABORTS — review gate cannot silently turn itself off', async () => {
350
357
  const mp = path.join(markerDir, 'm8.json');
351
358
  const deps = boxDeps(mp, { fetchImpl: async () => ({ status: 502 }), timeoutMs: 10 });
352
359
  const r = await sbx.runShipGate({ taskId: 927, files: ['public/game/a.js'], skipStage: false, tty: true }, deps);
353
- assert.equal(r.ok, true, 'a broken preview must not brick shipping');
360
+ assert.equal(r.ok, false, 'a failed stage must not ship as if the review happened');
361
+ assert.equal(r.review, undefined, 'a failure to present is NOT a calm review pause');
362
+ assert.match(r.reason, /DID NOT run/, 'says plainly the review step did not run');
363
+ assert.match(r.reason, /--skip-stage/, 'names the explicit bypass');
364
+ assert.match(r.reason, /claim is still active/i, 'claim-preserving');
354
365
  });
355
366
 
356
367
  // ---------- recorded self-approval (#1048), box ----------
@@ -502,12 +513,62 @@ await ta('stage(box): a HARD verify failure → {ok:false,hard:true}, no marker'
502
513
  assert.equal(fs.existsSync(mp), false, 'no marker on a hard verify failure');
503
514
  });
504
515
 
505
- await ta('runShipGate(box): a SOFT failure (preview never healthy) still warns + continues', async () => {
516
+ await ta('1002607 runShipGate(box): a SOFT failure (preview never healthy) now fails closed — abort, claim intact', async () => {
506
517
  const mp = path.join(markerDir, 'soft1.json');
507
- // sync ok (injected), but health never green → soft failure → ship continues
518
+ // sync ok (injected), but health never green → the ship must NOT continue
519
+ // as if the review ran (that was the silent-disable defect).
508
520
  const deps = boxDeps(mp, { fetchImpl: async () => ({ status: 502 }), timeoutMs: 10 });
509
521
  const r = await sbx.runShipGate({ taskId: 1068, files: ['public/game/a.js'], skipStage: false, tty: true }, deps);
510
- assert.equal(r.ok, true, 'a broken preview must not brick shipping (soft failure)');
522
+ assert.equal(r.ok, false, 'the soft failure aborts instead of silently skipping the review');
523
+ assert.match(r.reason, /sandbox staging failed/);
524
+ });
525
+
526
+ // ---------- task 1002607: waitHealthy truth table ----------
527
+
528
+ await ta('1002607 waitHealthy: 404 at the health URL is NOT healthy (was: anything < 500 passed)', async () => {
529
+ const r = await sbx.waitHealthy({ fetchImpl: async () => ({ status: 404 }), timeoutMs: 10, pollMs: 1 });
530
+ assert.equal(r.ok, false);
531
+ assert.match(r.reason, /HTTP 404/);
532
+ });
533
+
534
+ await ta('1002607 waitHealthy: a 200 answered by the MAIN server (api healthz 200) is an impostor, not a healthy preview', async () => {
535
+ // The full server binds the same 127.0.0.1:3000 — root answers 200, and so does
536
+ // /api/gds/healthz. The game-only preview (ADR 0052) would 404 the latter.
537
+ const r = await sbx.waitHealthy({ fetchImpl: async () => ({ status: 200 }), timeoutMs: 1000, pollMs: 1 });
538
+ assert.equal(r.ok, false, 'a dead preview must not report healthy off the main server');
539
+ assert.match(r.reason, /MAIN server/);
540
+ assert.match(r.reason, /preview did not start/);
541
+ });
542
+
543
+ await ta('1002607 waitHealthy: a real preview (root 200, api healthz 404) is healthy', async () => {
544
+ const r = await sbx.waitHealthy({
545
+ fetchImpl: async (url) => ({ status: String(url).includes('/api/gds/healthz') ? 404 : 200 }),
546
+ timeoutMs: 1000, pollMs: 1,
547
+ });
548
+ assert.equal(r.ok, true);
549
+ });
550
+
551
+ await ta('1002607 waitHealthy: a discriminator probe that ERRORS counts as "not the main server" (preview healthy)', async () => {
552
+ const r = await sbx.waitHealthy({
553
+ fetchImpl: async (url) => {
554
+ if (String(url).includes('/api/gds/healthz')) throw new Error('ECONNREFUSED on a path the preview never mounts');
555
+ return { status: 200 };
556
+ },
557
+ timeoutMs: 1000, pollMs: 1,
558
+ });
559
+ assert.equal(r.ok, true, 'only a definite 200 on api healthz marks the impostor');
560
+ });
561
+
562
+ t('1002607 apiHealthzProbeUrl: same origin as the health URL, api path; null on garbage', () => {
563
+ assert.equal(sbx.apiHealthzProbeUrl('http://127.0.0.1:3000/'), 'http://127.0.0.1:3000/api/gds/healthz');
564
+ assert.equal(sbx.apiHealthzProbeUrl('http://127.0.0.1:3100/healthz'), 'http://127.0.0.1:3100/api/gds/healthz');
565
+ assert.equal(sbx.apiHealthzProbeUrl('not a url'), null);
566
+ });
567
+
568
+ t('1002607 NO_SANDBOX_MSG: names what is actually missing, not the builder\'s clone', () => {
569
+ assert.match(sbx.NO_SANDBOX_MSG, /src\/preview-server\.js/, 'names the missing entry file');
570
+ assert.match(sbx.NO_SANDBOX_MSG, /ADR 0052/, 'points at the contract that defines it');
571
+ assert.doesNotMatch(sbx.NO_SANDBOX_MSG, /this clone has no game preview entry/, 'the old your-clone-is-at-fault phrasing is gone');
511
572
  });
512
573
 
513
574
  // ---------- runShipGate: LOCAL preview context + recorded approval (task 1056) ----------
@@ -529,7 +590,8 @@ function localDeps(markerFile, overrides = {}) {
529
590
  writeFileSync: (p, d) => fs.writeFileSync(p, d),
530
591
  },
531
592
  execFileSync: (cmd, args, opts) => (cmd === 'git' ? execFileSync(cmd, args, opts) : ''),
532
- fetchImpl: async () => ({ status: 200 }),
593
+ // task 1002607: preview-shaped mock — health 200, API-healthz discriminator 404.
594
+ fetchImpl: async (url) => ({ status: String(url).includes('/api/gds/healthz') ? 404 : 200 }),
533
595
  markerPath: markerFile,
534
596
  cwd: tmp,
535
597
  pollMs: 1,
@@ -30,6 +30,13 @@ test('metic-tagging: read-sensitive doc surfaces → metic', () => {
30
30
  assert.equal(minRankForDoc('docs/audits/2026-06-08-server-security-audit.md'), 'metic');
31
31
  assert.equal(minRankForDoc('docs/adr/0016-trust-boundary-server-enforced-permissions.md'), 'metic');
32
32
  assert.equal(minRankForDoc('docs/adr/0043-git-ssh-trust-boundary-and-rank-floor-on-permission-paths.md'), 'metic');
33
+ // task 1002598: docs/governance/ holds the complete gate enumeration (the authority
34
+ // sweep) and the choke-point map, which catalogue live fail-open paths by file:line.
35
+ // Served at xenos they were a searchable exploitation index for the population they
36
+ // describe; the read floor matches security.report.adjudicate, where ADR 0158 §6
37
+ // sends the same class of detail.
38
+ assert.equal(minRankForDoc('docs/governance/rank-choke-point-map-2026-08-06.md'), 'metic');
39
+ assert.equal(minRankForDoc('docs/governance/authority-sweep-2026-07-24.md'), 'metic');
33
40
  });
34
41
 
35
42
  test('metic-tagging: ordinary docs → xenos (read-public)', () => {
@@ -18,8 +18,11 @@
18
18
  // - the slug character-class regex rejects non-kebab slugs
19
19
  //
20
20
  // Rank wiring (source assertion — mirrors tests/cli_token_reissue.mjs):
21
- // - all three /security/docs* routes are auth.requireRank('archon')
22
- // - route-rank-check pins them to 'archon' in EXPECTED_RANKS
21
+ // - all three /security/docs* routes gate on the atom
22
+ // auth.requirePermission('security.report.adjudicate') (BV1.R104; was
23
+ // requireRank('archon') and admitted exactly the same builders, ADR 0151)
24
+ // - that atom's floor moved archon→metic in ADR 0157, so the routes now classify
25
+ // 'metic+archon' and are pinned BY ATOM (`perm:…`) rather than by rank
23
26
  //
24
27
  // We stub the pool by pre-seeding the require cache for src/db.js so the db
25
28
  // module under test never opens a socket — the same monkeypatch seam the repo
@@ -167,18 +170,37 @@ await test('slug character-class regex rejects non-kebab slugs', () => {
167
170
 
168
171
  console.log('\nsecurity_docs — rank wiring (source + pin):');
169
172
 
170
- await test('all three /security/docs* routes are requireRank(archon)', () => {
173
+ await test('all three /security/docs* routes gate on security.report.adjudicate (metic floor)', () => {
171
174
  const src = fs.readFileSync(path.join(ROOT, 'src', 'bongos', 'routes', 'security.js'), 'utf8');
172
175
  const routes = rrc.classifyText(src).filter((r) => r.route.startsWith('/security/docs'));
173
176
  assert.equal(routes.length, 3, 'expected GET list, GET :slug, POST');
174
177
  for (const r of routes) {
175
- assert.equal(r.rank, 'archon', `${r.key} must classify as archon (serves exploit detail)`);
178
+ // The ATOM is the authority once governance grows custom roles; the rank is what
179
+ // that atom's catalog floor resolves to today. Assert both, so neither a swapped
180
+ // atom nor an UNINTENDED floor edit can quietly widen who reads the exploit cookbook.
181
+ //
182
+ // ADR 0157 made that widening INTENTIONALLY: `security.report.adjudicate` moved
183
+ // archon→metic with the rest of the operational block, so the engagement-report
184
+ // store is now readable by the working rank. That is the one place in the ADR where
185
+ // delegation carries a genuine CONFIDENTIALITY cost rather than just an
186
+ // administrative one — every other delegated atom widens who may *act*, this one
187
+ // widens who may *read attacker-usable detail*. If that trade is unwanted, the fix
188
+ // is to split the atom the way ADR 0157 §4 split `gate_approval.read` off
189
+ // `gate_approval.decide`: keep a `security.docs.read` at archon for the cookbook and
190
+ // let report adjudication delegate. Recorded here so the next reader sees the choice
191
+ // rather than inferring it from a floor value.
192
+ assert.deepEqual(r.perms, ['security.report.adjudicate'],
193
+ `${r.key} must gate on the adjudicate atom (serves exploit detail)`);
194
+ assert.equal(r.rank, 'metic+archon', `${r.key} must classify as metic+archon (ADR 0157)`);
176
195
  }
177
196
  });
178
197
 
179
- await test('route-rank-check pins all three /security/docs* routes to archon', () => {
198
+ await test('route-rank-check pins all three /security/docs* routes by atom', () => {
199
+ // Re-pinned from the rank 'archon' to the ATOM in ADR 0157: the invariant worth
200
+ // guarding is "these gate on the adjudicate permission", which survives a floor move.
180
201
  for (const key of ['GET /security/docs', 'GET /security/docs/:slug', 'POST /security/docs']) {
181
- assert.equal(rrc.EXPECTED_RANKS[key], 'archon', `${key} must be pinned archon in EXPECTED_RANKS`);
202
+ assert.equal(rrc.EXPECTED_RANKS[key], 'perm:security.report.adjudicate',
203
+ `${key} must be pinned to the adjudicate atom in EXPECTED_RANKS`);
182
204
  }
183
205
  });
184
206
 
@@ -13,6 +13,7 @@ const {
13
13
  topoOrder,
14
14
  chainFrom,
15
15
  haltReasons,
16
+ pickNext,
16
17
  summarize,
17
18
  MIN_SPEC_CHARS,
18
19
  } = require('../scripts/gds/sequence.js');
@@ -263,5 +264,68 @@ t('summarize tolerates missing numbers', () => {
263
264
  { count: 1, credits: 0, minutes: 0 });
264
265
  });
265
266
 
267
+ // ------------------------------------------------------- pickNext (--next)
268
+
269
+ // The live regression this closes (goal 1000030, 2026-08-05): a backlog task
270
+ // sorted to position 0 and --next reported "nothing runnable" while two ready
271
+ // tasks sat behind it.
272
+ const GOAL_1000030 = () => [
273
+ task({ id: 1002556, status: 'backlog', title: 'perf: fold the resolve' }),
274
+ task({ id: 1001850, status: 'ready', title: 'R108 — collapse rank-tier maps' }),
275
+ task({ id: 1001858, status: 'ready', title: 'R110 — governance API routes' }),
276
+ ];
277
+ const claimable = (...ids) => ({ rank: 'archon', claimableIds: new Set(ids) });
278
+
279
+ t('pickNext offers the head when the head is clear', () => {
280
+ const plan = GOAL_1000030().slice(1);
281
+ const r = pickNext(plan, claimable(1001850, 1001858));
282
+ assert.equal(r.next.id, 1001850);
283
+ assert.deepEqual(r.skipped, []);
284
+ });
285
+
286
+ t('pickNext skips a blocked head and offers the first runnable task', () => {
287
+ const r = pickNext(GOAL_1000030(), claimable(1001850, 1001858));
288
+ assert.equal(r.next.id, 1001850, 'must not dead-end on the backlog task at position 0');
289
+ assert.equal(r.skipped.length, 1);
290
+ assert.equal(r.skipped[0].id, 1002556);
291
+ });
292
+
293
+ t('pickNext reports WHY each skipped task was passed over', () => {
294
+ const r = pickNext(GOAL_1000030(), claimable(1001850, 1001858));
295
+ assert.ok(r.skipped[0].reasons.some((x) => x.code === 'deps_unmet'));
296
+ assert.ok(r.skipped[0].title, 'skipped entries carry a title for the printout');
297
+ });
298
+
299
+ t('pickNext keeps topological order among the runnable — first clear wins', () => {
300
+ const r = pickNext(GOAL_1000030(), claimable(1001850, 1001858));
301
+ assert.equal(r.next.id, 1001850, 'not 1001858, which sorts later');
302
+ });
303
+
304
+ t('pickNext still halts when NOTHING in the plan clears', () => {
305
+ const plan = [
306
+ task({ id: 1, status: 'backlog' }),
307
+ task({ id: 2, status: 'shipped' }),
308
+ task({ id: 3, status: 'ready', requires_rank: 'archon' }),
309
+ ];
310
+ const r = pickNext(plan, { rank: 'xenos', claimableIds: new Set([3]) });
311
+ assert.equal(r.next, null);
312
+ assert.equal(r.skipped.length, 3, 'every task was tried');
313
+ assert.equal(r.head.id, 1, 'the head is preserved so the halt prints its reasons');
314
+ });
315
+
316
+ t('pickNext does not weaken a gate — a ready-but-unclaimable task is skipped, not offered', () => {
317
+ const plan = [task({ id: 9, status: 'ready' })];
318
+ const r = pickNext(plan, claimable(/* 9 absent from the claimable feed */));
319
+ assert.equal(r.next, null);
320
+ assert.ok(r.skipped[0].reasons.some((x) => x.code === 'not_claimable'));
321
+ });
322
+
323
+ t('pickNext on an empty plan returns no pick and nothing skipped', () => {
324
+ const r = pickNext([], claimable());
325
+ assert.equal(r.next, null);
326
+ assert.deepEqual(r.skipped, []);
327
+ assert.equal(r.head, null);
328
+ });
329
+
266
330
  console.log(`\nsequence: ${passed} passed, ${failed} failed`);
267
331
  process.exit(failed ? 1 : 0);
@@ -12,9 +12,10 @@
12
12
 
13
13
  import { strict as assert } from 'node:assert';
14
14
  import { createRequire } from 'node:module';
15
+ import { requireHostModule } from './helpers.mjs';
15
16
 
16
17
  const require = createRequire(import.meta.url);
17
- const hook = require('../.claude/hooks/session-cards.js');
18
+ const hook = requireHostModule(require, '../.claude/hooks/session-cards.js');
18
19
 
19
20
  let passed = 0;
20
21
  let failed = 0;
@@ -16,9 +16,10 @@
16
16
 
17
17
  import { strict as assert } from 'node:assert';
18
18
  import { createRequire } from 'node:module';
19
+ import { requireHostModule } from './helpers.mjs';
19
20
 
20
21
  const require = createRequire(import.meta.url);
21
- const { decideFreshness } = require('../.claude/hooks/session-start.js');
22
+ const { decideFreshness } = requireHostModule(require, '../.claude/hooks/session-start.js');
22
23
 
23
24
  let passed = 0;
24
25
  let failed = 0;
@@ -171,5 +171,67 @@ test('laptop deploy mode → never server-handled, even with server push (false)
171
171
  });
172
172
  });
173
173
 
174
- console.log(`\nship broadcast server (task 1045) tests: ${passed} passed, ${failed} failed`);
174
+ // ---- 3. task 1002606: the strand notice (task.strand-flagged → Discord) ----
175
+ // A strand previously had NO push channel — Discord fired only on task.shipped, so
176
+ // a builder learned of a dead land at their next /builder-start, up to a session
177
+ // later. These pin the poster the reconciler's task.strand-flagged listener fires.
178
+
179
+ test('1002606 chooseStrandTargets: non-security strand → #ship-feed; line does NOT lead with #<id>', () => {
180
+ const t = shipBroadcast.chooseStrandTargets({ securitySensitive: false, taskId: 88, reason: 'strand:pr_closed_unmerged' });
181
+ assert.equal(t.length, 1);
182
+ assert.equal(t[0].channel, 'ship_feed');
183
+ assert.match(t[0].line, /task 88/);
184
+ assert.match(t[0].line, /strand:pr_closed_unmerged/);
185
+ // A LEADING #<id> is the applause marker (discord-reaction.parseShipMarker) —
186
+ // a strand must never collect 👍-votes as if it were shipped work.
187
+ assert.ok(!/^\s*#\d/.test(t[0].line), 'strand line must not start with a #<id> ship marker');
188
+ });
189
+
190
+ test('1002606 chooseStrandTargets: security strand → ONLY #security-ship-feed (reason can name an unpatched fix)', () => {
191
+ const t = shipBroadcast.chooseStrandTargets({ securitySensitive: true, taskId: 91, reason: 'strand:unresolvable_conflict:src/gds/auth.js' });
192
+ assert.equal(t.length, 1);
193
+ assert.equal(t[0].channel, 'security_ship_feed');
194
+ for (const { channel } of t) assert.ok(!PUBLIC_CHANNELS.has(channel));
195
+ });
196
+
197
+ await atest('1002606 postStrandNotice: posts to #ship-feed for a plain strand', async () => {
198
+ const { fn, calls } = fakePoster();
199
+ const res = await shipBroadcast.postStrandNotice(
200
+ { id: 88, builder_id: 7, security_sensitive: false, reason: 'strand:pr_closed_unmerged' },
201
+ { postWebhook: fn, log: quietLog }
202
+ );
203
+ assert.deepEqual(res.posted, ['ship_feed']);
204
+ assert.equal(calls.length, 1);
205
+ assert.match(calls[0].line, /task 88/);
206
+ });
207
+
208
+ await atest('1002606 postStrandNotice: indeterminate security flag → FAIL CLOSED (no post)', async () => {
209
+ for (const sec of [null, undefined, 'true', 1]) {
210
+ const { fn, calls } = fakePoster();
211
+ const res = await shipBroadcast.postStrandNotice(
212
+ { id: 9, security_sensitive: sec, reason: 'strand:x' },
213
+ { postWebhook: fn, log: quietLog }
214
+ );
215
+ assert.equal(calls.length, 0, `flag=${String(sec)} must suppress`);
216
+ assert.equal(res.skipped, 'security_flag_indeterminate');
217
+ }
218
+ });
219
+
220
+ await atest('1002606 postStrandNotice: best-effort — a throwing webhook never propagates; no task → no_task', async () => {
221
+ const boom = async () => { throw new Error('webhook down'); };
222
+ let threw = false;
223
+ let res;
224
+ try {
225
+ res = await shipBroadcast.postStrandNotice(
226
+ { id: 10, security_sensitive: false, reason: 'strand:y' },
227
+ { postWebhook: boom, log: quietLog }
228
+ );
229
+ } catch (_) { threw = true; }
230
+ assert.equal(threw, false, 'postStrandNotice must never throw');
231
+ assert.deepEqual(res.posted, [], 'a failed post is not recorded as posted');
232
+ const { fn } = fakePoster();
233
+ assert.equal((await shipBroadcast.postStrandNotice(null, { postWebhook: fn, log: quietLog })).skipped, 'no_task');
234
+ });
235
+
236
+ console.log(`\nship broadcast server (task 1045 + 1002606) tests: ${passed} passed, ${failed} failed`);
175
237
  process.exit(failed > 0 ? 1 : 0);
@@ -105,6 +105,72 @@ test('confirmed: info tone, auto-landing deploy, merge-mode override action', ()
105
105
  assert.equal(m.action.cmd, '/merge-mode'); // kept as an optional manual override
106
106
  });
107
107
 
108
+ // ---- task 1002541: the card must never claim a bailed merge is landing ----
109
+ //
110
+ // The bug: deployText came from the STATUS alone, so a 'confirmed' task printed
111
+ // "landing automatically" even when auto-merge had already given up — no PR, no
112
+ // merge, and (ADR 0120, pay on land) no credits. The failure announced itself as
113
+ // success, which is why these strands survived for days.
114
+ const bailed = {
115
+ ...shippedInput,
116
+ postGradeStatus: 'confirmed',
117
+ land: { ok: false, pending: false, reason: 'the main checkout is on claude/foo, not main', nextStep: '/merge-mode' },
118
+ };
119
+
120
+ test('confirmed + bailed land: says NOT landed, names the reason, warns', () => {
121
+ const m = card.buildCardModel(bailed);
122
+ assert.equal(m.stage, 'confirmed');
123
+ assert.equal(row(m, 'Deploy'), 'NOT landed — the main checkout is on claude/foo, not main');
124
+ assert.equal(m.cfg.tone, 'warning'); // not the reassuring 'info'
125
+ assert.equal(m.cfg.statusWord, 'Not landed');
126
+ assert.match(m.cfg.headline(1266), /NOT landed/);
127
+ });
128
+
129
+ test('confirmed + bailed land: the bail reaches the printed monospace card', () => {
130
+ // The monospace card is the ALWAYS-printed fallback (headless, cron, dev box).
131
+ // If the reason stops at the model, the builder still never sees it.
132
+ const out = card.renderMonospaceCard(card.buildCardModel(bailed));
133
+ assert.match(out, /NOT landed/);
134
+ assert.ok(!/landing automatically/.test(out), 'the lie must not survive anywhere on the card');
135
+ });
136
+
137
+ test('confirmed + bailed land: the action is the bail-specific recovery command', () => {
138
+ const m = card.buildCardModel({
139
+ ...bailed,
140
+ land: { ok: false, pending: false, reason: 'no commits ahead of main', nextStep: 'node scripts/gds/ship.js 7 --db-only' },
141
+ });
142
+ assert.equal(m.action.cmd, 'node scripts/gds/ship.js 7 --db-only'); // beats the generic /merge-mode
143
+ });
144
+
145
+ test('confirmed + PENDING land still reads "landing automatically"', () => {
146
+ // A poll timeout with auto-merge armed is success-PENDING (task 1049): CI or the
147
+ // server reconciler really does finish it. Crying failure here would be a NEW
148
+ // lie in the opposite direction, and would train builders to ignore the warning.
149
+ const m = card.buildCardModel({ ...bailed, land: { ok: false, pending: true, reason: null, nextStep: null } });
150
+ assert.equal(row(m, 'Deploy'), 'landing automatically');
151
+ assert.equal(m.cfg.tone, 'info');
152
+ assert.equal(m.cfg.statusWord, 'Confirmed');
153
+ });
154
+
155
+ test('confirmed with NO land info is unchanged (back-compat)', () => {
156
+ // Older land-watch breadcrumbs replay stored cardInputs that have no `land` key,
157
+ // and other callers (resumeMerge, the claim card) may pass none. Absent ⇒ the
158
+ // pre-existing behaviour, byte for byte.
159
+ const m = card.buildCardModel({ ...shippedInput, postGradeStatus: 'confirmed', land: null });
160
+ assert.equal(row(m, 'Deploy'), 'landing automatically');
161
+ assert.equal(m.cfg.tone, 'info');
162
+ assert.equal(m.action.cmd, '/merge-mode');
163
+ });
164
+
165
+ test('a stale bailed land never overrides a task that DID reach shipped', () => {
166
+ // land-watch replays the breadcrumb after the server lands it; the status is
167
+ // then 'shipped' and the old bail is history. The card must show live, not NOT-landed.
168
+ const m = card.buildCardModel({ ...bailed, postGradeStatus: 'shipped' });
169
+ assert.equal(m.stage, 'shipped');
170
+ assert.equal(row(m, 'Deploy'), 'live · 967fb4a');
171
+ assert.equal(m.cfg.tone, 'success');
172
+ });
173
+
108
174
  test('failed: danger tone, no credits, issue count, regrade action', () => {
109
175
  const m = card.buildCardModel({
110
176
  ...shippedInput, postGradeStatus: 'completed',
@@ -44,7 +44,7 @@ function test(name, fn) {
44
44
  }
45
45
  }
46
46
 
47
- const { parseDeployMode, effectiveDeployMode, buildPrCreateArgv, buildPrMergeArgv, deployRunSucceeded, shouldBailOnFetchFailure, classifyLandOutcome, strandNextStep, pickStaleGateReviewRun, landPollAfterAutoMerge, reconciledCardStatus } = ship;
47
+ const { parseDeployMode, effectiveDeployMode, buildPrCreateArgv, buildPrMergeArgv, deployRunSucceeded, shouldBailOnFetchFailure, classifyLandOutcome, strandNextStep, pickStaleGateReviewRun, landPollAfterAutoMerge, reconciledCardStatus, landed, landPending, landBailed, strandCommand, landPendingLines } = ship;
48
48
 
49
49
  // ---- 1. parseDeployMode precedence + safe default ----
50
50
 
@@ -266,6 +266,44 @@ test('reconciledCardStatus: status read failed (null) → keep the pre-merge sta
266
266
  assert.equal(reconciledCardStatus(undefined, 'confirmed'), 'confirmed');
267
267
  });
268
268
 
269
+ // ---- 3d. the land outcome: why this is a 3-way object, not a boolean ----
270
+ // task 1002541. The land chain used to answer true/false, so every bail site's
271
+ // precise reason was discarded and the card fell back to the STATUS — printing
272
+ // "landing automatically" over a merge that had already given up.
273
+
274
+ test('landBailed carries the reason + the recovery command', () => {
275
+ const o = landBailed('the main checkout is on claude/foo, not main', '/merge-mode');
276
+ assert.equal(o.ok, false);
277
+ assert.equal(o.pending, false);
278
+ assert.equal(o.reason, 'the main checkout is on claude/foo, not main');
279
+ assert.equal(o.nextStep, '/merge-mode');
280
+ });
281
+
282
+ test('THE TRAP: every outcome is truthy, so consumers must test .ok', () => {
283
+ // mergeAndDeploy's guard used to be `if (!merged) return;`. With objects that
284
+ // check NEVER fires, so a bailed ship would fall through and POST /ship for a
285
+ // task that never landed. This is the assertion that catches a re-introduction.
286
+ assert.ok(landBailed('nope', '/merge-mode'), 'a bail object is truthy — !merged cannot detect it');
287
+ assert.ok(landPending(), 'a pending object is truthy too');
288
+ assert.equal(landBailed('nope', '/merge-mode').ok, false);
289
+ assert.equal(landPending().ok, false);
290
+ assert.equal(landed().ok, true);
291
+ });
292
+
293
+ test('pending is NOT a bail: no reason, so the card keeps "landing automatically"', () => {
294
+ // A poll timeout with auto-merge armed is success-pending (task 1049) — it does
295
+ // land on its own. The card gates its warning on `reason`, so this stays quiet.
296
+ const o = landPending();
297
+ assert.equal(o.pending, true);
298
+ assert.equal(o.reason, null);
299
+ });
300
+
301
+ test('strandCommand: a box gets the resume, a laptop gets /merge-mode', () => {
302
+ // /merge-mode needs gh + a main checkout a dev box doesn't have (task 1052).
303
+ assert.equal(strandCommand(77, 'server'), 'node scripts/gds/ship.js 77');
304
+ assert.equal(strandCommand(77, 'local'), '/merge-mode');
305
+ });
306
+
269
307
  test('reconciledCardStatus: already shipped stays shipped', () => {
270
308
  assert.equal(reconciledCardStatus('shipped', 'shipped'), 'shipped');
271
309
  });
@@ -506,6 +544,33 @@ test('effectiveDeployMode: tolerates a missing options object (defaults to no fa
506
544
  assert.equal(effectiveDeployMode('laptop'), 'ci'); // canPushLocally undefined ⇒ falsy ⇒ fall back
507
545
  });
508
546
 
547
+ // ---- task 1002606: the success-pending banner carries the strand-detection story ----
548
+ // Finding P-3: the banner ended at "This is NOT a failure", so when a land DID die
549
+ // (hand-written conflict, dead PR, red check) the builder's last signal said all was
550
+ // well. The banner must keep the reassurance AND say how a real failure reaches them.
551
+
552
+ test('1002606 landPendingLines: keeps the "NOT a failure" reassurance + the resume command', () => {
553
+ const lines = landPendingLines(940).join('\n');
554
+ assert.match(lines, /NOT a failure/);
555
+ assert.match(lines, /node scripts\/gds\/ship\.js 940/, 'the resume command names THIS task');
556
+ });
557
+
558
+ test('1002606 landPendingLines: tells the builder how a strand WILL reach them (needs_rebase + /builder-start)', () => {
559
+ const lines = landPendingLines(940).join('\n');
560
+ assert.match(lines, /CANNOT land on its own/i, 'the failure story is present');
561
+ assert.match(lines, /needs_rebase/, 'names the flag the reconciler sets');
562
+ assert.match(lines, /\/builder-start/, 'names WHERE the warning appears');
563
+ assert.match(lines, /release/, 'names the release exit too');
564
+ });
565
+
566
+ test('1002606 landPendingLines: merged variant says deploy-only', () => {
567
+ const merged = landPendingLines(1, { merged: true }).join('\n');
568
+ assert.match(merged, /and the PR has merged/);
569
+ assert.match(merged, /GitHub will deploy automatically/);
570
+ const unmerged = landPendingLines(1, {}).join('\n');
571
+ assert.match(unmerged, /GitHub will merge \+ deploy automatically/);
572
+ });
573
+
509
574
  console.log('');
510
575
  console.log(`ship_ci_deploy: ${passed} passed, ${failed} failed`);
511
576
  process.exit(failed === 0 ? 0 : 1);