@bongos/core 1.18.19 → 1.19.3

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 (259) hide show
  1. package/.bongos-core.json +547 -197
  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/bin/bongos.js +33 -7
  9. package/clients/bongos-client/README.md +1 -1
  10. package/clients/bongos-client/bongos-client.global.js +63 -41
  11. package/clients/bongos-client/index.cjs +63 -41
  12. package/clients/bongos-client/index.d.ts +78 -44
  13. package/clients/bongos-client/index.mjs +63 -41
  14. package/docs/adr/0020-security-model-public-stub.md +1 -1
  15. package/docs/adr/0097-one-active-claim-per-session-and-worktree-binding.md +2 -0
  16. package/docs/adr/0137-upstream-publish-policy-manual-owner-gated.md +1 -1
  17. package/docs/adr/0144-devbox-rehome-onto-cloudbongos-plane.md +1 -1
  18. package/docs/adr/0151-governance-permissions-as-atom-ranks-as-roles.md +2 -1
  19. package/docs/adr/0155-adopt-private-repo-widen-oauth-scope.md +51 -0
  20. package/docs/adr/0156-user-set-display-name-wins.md +62 -0
  21. package/docs/adr/0157-archon-is-rank-and-identity-only.md +93 -0
  22. package/docs/adr/0158-choke-point-verdict-taxonomy.md +174 -0
  23. package/docs/adr/0159-merge-gate-reconciliation.md +88 -0
  24. package/docs/adr/0160-main-audit-repo-verifiable-caller.md +77 -0
  25. package/docs/adr/0161-publish-on-merge.md +34 -0
  26. package/docs/adr/README.md +9 -0
  27. package/docs/api/openapi.json +1209 -233
  28. package/docs/api-reference.md +61 -46
  29. package/docs/architecture.md +1 -1
  30. package/docs/canonical-permissions.md +61 -16
  31. package/docs/design/vanilla-hall-ui-redesign-scope.md +1 -1
  32. package/docs/file-map.md +4 -1
  33. package/docs/onboarding/diagrams/assertions.json +3 -3
  34. package/docs/recipes/core-release-pipeline.md +181 -0
  35. package/docs/recipes/packaging-the-core.md +2 -0
  36. package/docs/recipes/parallel-goal-waves.md +1 -3
  37. package/docs/recipes/private-npm-distribution.md +6 -2
  38. package/docs/recipes/upgrading-the-core.md +2 -0
  39. package/migrations/core_197_display_name_source.sql +56 -0
  40. package/migrations/core_204_rekey_active_claim_per_session_worktree.sql +77 -0
  41. package/migrations/core_205_versions_track_default.sql +28 -0
  42. package/modules/autonomy/routes/autonomy.js +1 -1
  43. package/modules/builder-settings/db.js +7 -1
  44. package/modules/dev-box/app/src/vendor/bongos-client.cjs +63 -41
  45. package/modules/dev-box/routes/box.js +11 -9
  46. package/modules/discord/auth.js +3 -1
  47. package/modules/discord/routes/discord.js +8 -2
  48. package/modules/discord/ship-broadcast.js +48 -1
  49. package/modules/governance/catalog.js +70 -20
  50. package/modules/governance/db.js +102 -0
  51. package/modules/governance/migrations/governance_006_version_create.sql +38 -0
  52. package/modules/governance/migrations/governance_007_delegate_archon_ops_to_metic.sql +72 -0
  53. package/modules/governance/routes/governance.js +235 -6
  54. package/modules/grading/grader-workers/index.js +18 -9
  55. package/modules/hall-ui/public/gate.js +6 -2
  56. package/modules/hall-ui/public/goals-page.js +6 -2
  57. package/modules/hall-ui/public/governance.css +100 -0
  58. package/modules/hall-ui/public/governance.html +77 -0
  59. package/modules/hall-ui/public/governance.js +353 -0
  60. package/modules/hall-ui/public/harbor.js +14 -8
  61. package/modules/hall-ui/public/instances-panel.js +3 -1
  62. package/modules/hall-ui/public/new-project-wizard.js +6 -0
  63. package/modules/hall-ui/public/people.html +51 -0
  64. package/modules/hall-ui/public/people.js +100 -0
  65. package/modules/hall-ui/public/profile.html +2 -1
  66. package/modules/hall-ui/public/profile.js +9 -3
  67. package/modules/hall-ui/public/sessions.js +4 -2
  68. package/modules/hall-ui/public/shell.js +28 -7
  69. package/modules/hall-ui/public/task-detail.js +6 -3
  70. package/modules/hall-ui/public/watch.js +17 -9
  71. package/modules/ideas/blockers.js +44 -7
  72. package/modules/ideas/routes/blockers.js +12 -7
  73. package/modules/ideas/routes/inbox.js +2 -2
  74. package/modules/lifecycle/db.js +84 -17
  75. package/modules/lifecycle/github-push.js +37 -10
  76. package/modules/lifecycle/publish-reconciler.js +128 -31
  77. package/modules/lifecycle/routes/claims.js +10 -4
  78. package/modules/lifecycle/routes/dependencies.js +2 -2
  79. package/modules/lifecycle/routes/done-when.js +4 -4
  80. package/modules/lifecycle/routes/gate-approvals.js +9 -3
  81. package/modules/lifecycle/routes/goals.js +5 -5
  82. package/modules/lifecycle/routes/tasks.js +99 -69
  83. package/modules/lifecycle/routes/versions.js +13 -2
  84. package/modules/lifecycle/ship-card.js +38 -7
  85. package/modules/memory/routes/memory.js +2 -2
  86. package/modules/onboarding/routes/access-requests.js +3 -3
  87. package/modules/platform-identity/routes/projects.js +10 -6
  88. package/modules/platform-identity/routes/scouting.js +2 -2
  89. package/modules/provisioning/migrations/provisioning_004_onboard_mode.sql +30 -0
  90. package/modules/provisioning/provisioning.js +24 -6
  91. package/modules/provisioning/routes/provisioning.js +24 -6
  92. package/modules/security/artifact-scan/aggregate.js +133 -0
  93. package/modules/security/artifact-scan/detectors/capabilities.js +53 -0
  94. package/modules/security/artifact-scan/detectors/config-obfuscation.js +41 -0
  95. package/modules/security/artifact-scan/detectors/credentials.js +112 -0
  96. package/modules/security/artifact-scan/detectors/dependencies.js +115 -0
  97. package/modules/security/artifact-scan/detectors/hooks.js +116 -0
  98. package/modules/security/artifact-scan/detectors/injection.js +118 -0
  99. package/modules/security/artifact-scan/detectors/mcp.js +102 -0
  100. package/modules/security/artifact-scan/detectors/network.js +67 -0
  101. package/modules/security/artifact-scan/detectors/npm-lifecycle.js +67 -0
  102. package/modules/security/artifact-scan/detectors/permissions.js +91 -0
  103. package/modules/security/artifact-scan/detectors/shared.js +219 -0
  104. package/modules/security/artifact-scan/fence-local.js +99 -0
  105. package/modules/security/artifact-scan/fetch.js +330 -0
  106. package/modules/security/artifact-scan/floor.js +319 -0
  107. package/modules/security/artifact-scan/limits.js +66 -0
  108. package/modules/security/artifact-scan/manifest.js +203 -0
  109. package/modules/security/artifact-scan/mirror.js +158 -0
  110. package/modules/security/artifact-scan/quarantine.js +149 -0
  111. package/modules/security/artifact-scan/rebuild-spec.js +120 -0
  112. package/modules/security/artifact-scan/report.js +155 -0
  113. package/modules/security/artifact-scan/sanitize.js +317 -0
  114. package/modules/security/migrations/security_001_tool_scans.sql +145 -0
  115. package/modules/security/module.json +2 -2
  116. package/modules/security/routes/reports.js +11 -7
  117. package/modules/security/routes/scans.js +115 -0
  118. package/modules/security/scan-store.js +214 -0
  119. package/modules/security/tests/aggregate.mjs +283 -0
  120. package/modules/security/tests/artifact_scan.mjs +591 -0
  121. package/modules/security/tests/floor.mjs +549 -0
  122. package/modules/security/tests/mirror.mjs +350 -0
  123. package/modules/security/tests/sanitize_rebuild.mjs +499 -0
  124. package/modules/security/tests/scan_store.mjs +250 -0
  125. package/modules/security/tests/tool_scans_migration.mjs +116 -0
  126. package/modules/sessions/routes/sessions.js +6 -5
  127. package/package-lock.json +2 -2
  128. package/package.json +1 -1
  129. package/public-landing/create.html +13 -3
  130. package/scripts/gds/abandon.js +1 -1
  131. package/scripts/gds/audit-authorship.sh +6 -0
  132. package/scripts/gds/box.js +1 -1
  133. package/scripts/gds/bump-version.js +76 -0
  134. package/scripts/gds/claim.js +15 -0
  135. package/scripts/gds/cli-lib.js +16 -4
  136. package/scripts/gds/gating.js +28 -13
  137. package/scripts/gds/gen-api-docs.js +1 -1
  138. package/scripts/gds/goal.js +234 -0
  139. package/scripts/gds/init.js +77 -15
  140. package/scripts/gds/main-audit.js +80 -11
  141. package/scripts/gds/mirror-redact.js +6 -0
  142. package/scripts/gds/newcomer-floor.js +2 -2
  143. package/scripts/gds/provision.js +230 -94
  144. package/scripts/gds/run-unit-tests.js +9 -1
  145. package/scripts/gds/sandbox-stage.js +66 -9
  146. package/scripts/gds/search-ingest-md.js +1 -0
  147. package/scripts/gds/sequence.js +67 -8
  148. package/scripts/gds/ship.js +183 -50
  149. package/scripts/hall-preview/server.js +1 -1
  150. package/scripts/migrate.sh +5 -5
  151. package/src/bongos/auth.js +129 -27
  152. package/src/bongos/db-kernel.js +66 -5
  153. package/src/bongos/db.js +6 -7
  154. package/src/bongos/github-repos.js +19 -7
  155. package/src/bongos/goal-scope-check.js +1 -1
  156. package/src/bongos/module-scope-map.js +3 -1
  157. package/src/bongos/permission-path-check.js +1 -1
  158. package/src/bongos/route-rank-check.js +73 -30
  159. package/src/bongos/routes/audit-log.js +4 -2
  160. package/src/bongos/routes/auth.js +8 -4
  161. package/src/bongos/routes/backup.js +7 -4
  162. package/src/bongos/routes/builders.js +23 -11
  163. package/src/bongos/routes/github.js +15 -4
  164. package/src/bongos/routes/llm-cache.js +5 -2
  165. package/src/bongos/routes/modules.js +9 -5
  166. package/src/bongos/routes/security.js +21 -12
  167. package/src/bongos/serve-internal.js +22 -7
  168. package/src/module-api.js +96 -13
  169. package/src/platform-server.js +14 -0
  170. package/tests/api_client.mjs +18 -2
  171. package/tests/api_path_404.mjs +3 -0
  172. package/tests/art_stage.mjs +2 -1
  173. package/tests/atlas.mjs +32 -4
  174. package/tests/auth_admission_gate.mjs +17 -5
  175. package/tests/auth_error_brand.mjs +41 -21
  176. package/tests/auth_resolve_failure.mjs +155 -0
  177. package/tests/blocker_link.mjs +88 -0
  178. package/tests/bongos_cli.mjs +91 -0
  179. package/tests/bongos_downloads.mjs +2 -1
  180. package/tests/box_dns_repoint.mjs +2 -0
  181. package/tests/box_ship_permissions.mjs +2 -0
  182. package/tests/box_terminal_ssrf.mjs +23 -17
  183. package/tests/bump_version.mjs +71 -0
  184. package/tests/claim_error_surface.mjs +23 -0
  185. package/tests/claude_materialize.mjs +4 -0
  186. package/tests/cli_box_scope.mjs +40 -0
  187. package/tests/cloud_bongos_pack.mjs +34 -4
  188. package/tests/conductor.mjs +2 -1
  189. package/tests/criterion_authoring_gate.mjs +28 -5
  190. package/tests/currency_label.mjs +10 -1
  191. package/tests/deploy_instance.mjs +2 -0
  192. package/tests/devbox_downloads.mjs +2 -1
  193. package/tests/display_name.mjs +95 -6
  194. package/tests/eviction_policy.mjs +2 -1
  195. package/tests/file_map.mjs +10 -0
  196. package/tests/first_admin_bootstrap.mjs +10 -2
  197. package/tests/first_run_engine_check.mjs +2 -1
  198. package/tests/fitness.mjs +26 -6
  199. package/tests/gate_approvals.mjs +15 -2
  200. package/tests/github_push_land_proof.mjs +50 -4
  201. package/tests/github_repos_helpers.mjs +30 -5
  202. package/tests/github_token_store.mjs +23 -0
  203. package/tests/goal_abuse_matrix.mjs +13 -3
  204. package/tests/goal_cli.mjs +89 -0
  205. package/tests/governance_abuse_matrix.mjs +386 -0
  206. package/tests/governance_fold.mjs +156 -0
  207. package/tests/governance_fold_bench.mjs +50 -0
  208. package/tests/governance_live_proof.mjs +201 -0
  209. package/tests/governance_page_gates.mjs +21 -9
  210. package/tests/governance_parity.mjs +11 -2
  211. package/tests/governance_require_permission.mjs +125 -1
  212. package/tests/governance_routes.mjs +334 -0
  213. package/tests/grader.mjs +7 -3
  214. package/tests/hall_nav.mjs +14 -4
  215. package/tests/helpers.mjs +73 -0
  216. package/tests/hook_core_resolve.mjs +2 -1
  217. package/tests/host_topology_skips.mjs +97 -0
  218. package/tests/infra_exec_paths.mjs +14 -4
  219. package/tests/init.mjs +147 -0
  220. package/tests/known_failing_ratchet.mjs +89 -0
  221. package/tests/lifecycle-card.mjs +2 -1
  222. package/tests/main_audit.mjs +72 -1
  223. package/tests/memory_isolation.mjs +56 -9
  224. package/tests/migrate_root_resolution.mjs +1 -1
  225. package/tests/mirror_redact.mjs +19 -0
  226. package/tests/module_api.mjs +3 -1
  227. package/tests/module_loader.mjs +4 -0
  228. package/tests/modules.mjs +35 -7
  229. package/tests/modules_route.mjs +29 -5
  230. package/tests/onboarding_observability.mjs +49 -11
  231. package/tests/onboarding_route_signals.mjs +1 -0
  232. package/tests/one_active_claim_per_session.mjs +80 -0
  233. package/tests/patch_gate_ordering.mjs +102 -0
  234. package/tests/pre_compact.mjs +2 -1
  235. package/tests/preview_server.mjs +2 -1
  236. package/tests/profile_route.mjs +1 -0
  237. package/tests/provision.mjs +170 -0
  238. package/tests/provisioning_onboard_mode.mjs +180 -0
  239. package/tests/publish_manifest.mjs +16 -3
  240. package/tests/publish_reconciler.mjs +181 -10
  241. package/tests/rank_tier_single_source.mjs +205 -0
  242. package/tests/recall_hints.mjs +2 -1
  243. package/tests/repo_map.mjs +4 -0
  244. package/tests/route_permission_check.mjs +14 -5
  245. package/tests/rubric_fixtures.mjs +3 -0
  246. package/tests/run_routine_two_root.mjs +2 -0
  247. package/tests/sandbox_stage.mjs +69 -7
  248. package/tests/search_ingest.mjs +7 -0
  249. package/tests/security_docs.mjs +28 -6
  250. package/tests/sequence.mjs +64 -0
  251. package/tests/session-cards.mjs +2 -1
  252. package/tests/session_start_freshness.mjs +2 -1
  253. package/tests/ship_broadcast_server.mjs +63 -1
  254. package/tests/ship_card.mjs +66 -0
  255. package/tests/ship_ci_deploy.mjs +66 -1
  256. package/tests/ship_smoke_gate.mjs +76 -0
  257. package/tests/versions_route.mjs +15 -0
  258. package/tests/workflow_exec_paths.mjs +16 -4
  259. package/tests/worktree_guard.mjs +2 -1
@@ -55,10 +55,19 @@ test('clientBranding is a SMALL allow-list — no server-only field leaks to the
55
55
  db: { database: 'amazonprimea' },
56
56
  envPrefix: 'OTB',
57
57
  });
58
- assert.deepEqual(Object.keys(projected).sort(), ['copy', 'currency', 'identity', 'rankLabels', 'theme']);
58
+ // `reward` joined the projection in task 1002469 (ADR 0146 follow-up) — the resolved
59
+ // reward POLICY, so a builder on a cost-plus-only instance isn't shown a per-task
60
+ // estimate that will never pay. The allow-list was not updated with it, so this guard
61
+ // had been failing since that landed (task 1002470).
62
+ assert.deepEqual(Object.keys(projected).sort(), ['copy', 'currency', 'identity', 'rankLabels', 'reward', 'theme']);
59
63
  for (const leak of ['domains', 'repo', 'db', 'envPrefix', 'llmProjectName']) {
60
64
  assert.equal(projected[leak], undefined, `${leak} must not reach the client`);
61
65
  }
66
+ // The new member is itself held to the same small-surface rule — the point of this
67
+ // test is that every projected key is a reviewed one, not merely that it is listed.
68
+ assert.deepEqual(Object.keys(projected.reward).sort(), ['mode', 'perTaskCredit']);
69
+ assert.equal(typeof projected.reward.mode, 'string');
70
+ assert.equal(typeof projected.reward.perTaskCredit, 'boolean');
62
71
  });
63
72
 
64
73
  test('R56: injectBrandingGlobal inserts a synchronous window.__BRANDING__ before </head>', () => {
@@ -14,9 +14,11 @@ import { mkdtempSync, rmSync, existsSync, statSync } from 'node:fs';
14
14
  import { tmpdir } from 'node:os';
15
15
  import { join, dirname } from 'node:path';
16
16
  import { fileURLToPath } from 'node:url';
17
+ import { skipUnlessPresent } from './helpers.mjs';
17
18
 
18
19
  const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
19
20
  const SCRIPT = join(ROOT, 'scripts', 'deploy', 'deploy-instance.sh');
21
+ skipUnlessPresent('scripts/deploy/deploy-instance.sh');
20
22
 
21
23
  let passed = 0;
22
24
  let failed = 0;
@@ -9,10 +9,11 @@
9
9
 
10
10
  import { strict as assert } from 'node:assert';
11
11
  import { createRequire } from 'node:module';
12
+ import { requireHostModule } from './helpers.mjs';
12
13
 
13
14
  const require = createRequire(import.meta.url);
14
15
  const { buildDevboxDownloads } = require('../src/devbox-downloads.js');
15
- const committedConfig = require('../config/devbox-app.json');
16
+ const committedConfig = requireHostModule(require, '../config/devbox-app.json');
16
17
 
17
18
  let passed = 0;
18
19
  let failed = 0;
@@ -1,11 +1,19 @@
1
1
  // tests/display_name.mjs
2
2
  //
3
- // Unit smoke for modules/builder-settings/display-name.js (task 1669) — the pure
4
- // validator behind GET/PATCH /me/display-name (the self-service display-name
5
- // editor). Covers the rules the route enforces: trim, reject empty /
6
- // whitespace-only, the length ceiling, and control-character rejection. The DB
7
- // write + route plumbing are exercised by the prod smoke; here we cover the
8
- // pure logic without touching pg.
3
+ // Unit smoke for the self-service display-name editor (GET/PATCH
4
+ // /me/display-name), in two parts:
5
+ //
6
+ // 1. modules/builder-settings/display-name.js (task 1669) — the pure
7
+ // validator. Trim, reject empty / whitespace-only, the length ceiling, and
8
+ // control-character rejection.
9
+ // 2. The PERSISTENCE guard (task 1002568, ADR 0156) — "your name wins,
10
+ // always". Only covering the validator is why the bug shipped: the save
11
+ // worked and then upsertBuilder, which runs on EVERY sign-in, re-set
12
+ // display_name from the GitHub name. These tests read the REAL SQL both
13
+ // halves emit and check the conflict branch's behaviour, so deleting the
14
+ // guard fails here instead of in production.
15
+ //
16
+ // No pg: the shared pool's query() is stubbed to record the SQL.
9
17
  //
10
18
  // Run: node tests/display_name.mjs
11
19
 
@@ -119,5 +127,86 @@ t('hasControlChar detects control chars and clears normal text', () => {
119
127
  assert.equal(d.hasControlChar(`with${BEL}bell`), true);
120
128
  });
121
129
 
130
+ // ---------------------------------------------------------------------------
131
+ // Persistence: a user-set name survives the next sign-in (task 1002568)
132
+ // ---------------------------------------------------------------------------
133
+
134
+ // Stub the SHARED pool object before requiring anything that queries it — both
135
+ // db-kernel.js and the module's db.js capture this exact singleton at load.
136
+ const { pool } = require('../src/bongos/pool.js');
137
+ let lastSql = null;
138
+ pool.query = async (sql) => { lastSql = sql; return { rows: [{}] }; };
139
+
140
+ const kernel = require('../src/bongos/db-kernel.js');
141
+ const settingsDb = require('../modules/builder-settings/db.js');
142
+
143
+ // Pull one assignment out of an ON CONFLICT ... DO UPDATE SET clause. None of
144
+ // the assignments contain a comma, so splitting on commas is exact.
145
+ function conflictAssignment(sql, column) {
146
+ const setClause = sql.slice(sql.indexOf('DO UPDATE'), sql.indexOf('RETURNING'));
147
+ for (const part of setClause.replace(/^[\s\S]*?SET/, '').split(',')) {
148
+ const [lhs, ...rhs] = part.split('=');
149
+ if (lhs.trim() === column) return rhs.join('=').trim();
150
+ }
151
+ throw new Error(`no "${column} = …" assignment in the conflict branch`);
152
+ }
153
+
154
+ // Evaluate the display_name assignment for one row, so the tests below read as
155
+ // behaviour ("whose name ends up stored?") rather than string matching. An
156
+ // unguarded assignment — the shape that shipped the bug — is a bare column
157
+ // reference and evaluates to whatever GitHub just sent.
158
+ function resolvedDisplayName(expr, { source, stored, incoming }) {
159
+ const ref = (token) => {
160
+ if (/^EXCLUDED\.display_name$/i.test(token)) return incoming;
161
+ if (/^builders\.display_name$/i.test(token)) return stored;
162
+ throw new Error(`unrecognised display_name reference: ${token}`);
163
+ };
164
+ const norm = expr.replace(/\s+/g, ' ').trim();
165
+ const m = /^CASE WHEN builders\.display_name_source = '(\w+)' THEN (\S+) ELSE (\S+) END$/i.exec(norm);
166
+ if (!m) return ref(norm);
167
+ return source === m[1] ? ref(m[2]) : ref(m[3]);
168
+ }
169
+
170
+ // Drive both halves once (they're async), then assert on the captured SQL with
171
+ // the same synchronous `t` helper the validator tests use.
172
+ await kernel.upsertBuilder({
173
+ githubId: 64553469, githubLogin: 'jaxri', displayName: 'GitHub Name', avatarUrl: null,
174
+ });
175
+ const upsertExpr = conflictAssignment(lastSql, 'display_name');
176
+
177
+ await settingsDb.setBuilderDisplayName(1000095, 'Renée the Builder');
178
+ const setNameSql = lastSql.replace(/\s+/g, ' ');
179
+
180
+ console.log('\nsign-in persistence (upsertBuilder conflict branch):');
181
+
182
+ t('the conflict branch keys display_name off display_name_source', () => {
183
+ assert.ok(
184
+ /display_name_source/.test(upsertExpr),
185
+ `upsertBuilder must not re-set display_name unconditionally; got: ${upsertExpr}`
186
+ );
187
+ });
188
+
189
+ t('a builder who set their own name keeps it when GitHub sends a different one', () => {
190
+ const kept = resolvedDisplayName(upsertExpr, {
191
+ source: 'user', stored: 'Renée the Builder', incoming: 'renee-gh',
192
+ });
193
+ assert.equal(kept, 'Renée the Builder');
194
+ });
195
+
196
+ t('a builder who never renamed still picks up a GitHub rename', () => {
197
+ const synced = resolvedDisplayName(upsertExpr, {
198
+ source: 'github', stored: 'Old GitHub Name', incoming: 'New GitHub Name',
199
+ });
200
+ assert.equal(synced, 'New GitHub Name');
201
+ });
202
+
203
+ t('setBuilderDisplayName stamps the name as user-set', () => {
204
+ assert.match(
205
+ setNameSql,
206
+ /display_name_source = 'user'/,
207
+ 'without the stamp the saved name reverts at the next sign-in'
208
+ );
209
+ });
210
+
122
211
  console.log(`\n${passed} passed, ${failed} failed`);
123
212
  process.exit(failed === 0 ? 0 : 1);
@@ -4,8 +4,9 @@
4
4
 
5
5
  import { strict as assert } from 'node:assert';
6
6
  import { createRequire } from 'node:module';
7
+ import { requireHostModule } from './helpers.mjs';
7
8
  const require = createRequire(import.meta.url);
8
- const { classifyEviction, estTokens, blockText, stubFor, normalize, readRange, isVolatileBash, salientTokens } = require('../.claude/hooks/eviction-policy.js');
9
+ const { classifyEviction, estTokens, blockText, stubFor, normalize, readRange, isVolatileBash, salientTokens } = requireHostModule(require, '../.claude/hooks/eviction-policy.js');
9
10
 
10
11
  let passed = 0, failed = 0;
11
12
  function test(name, fn) {
@@ -31,7 +31,12 @@ test('committed file-map sections are current (the Path-C CI gate)', () => {
31
31
  test('EVERY skill + scheduled-task dir on disk is listed AND has a note — no silent omission, no ghost', () => {
32
32
  const model = gen.buildModel();
33
33
  assert.ok(model.length === 2, 'two generated sections');
34
+ let checked = 0;
34
35
  for (const section of model) {
36
+ // .claude/skills + .claude/scheduled-tasks are INSTANCE-materialized; a neutral
37
+ // core checkout carries neither (task 1002470). Check the sections that exist.
38
+ if (!existsSync(path.join(ROOT, section.dir))) continue;
39
+ checked += 1;
35
40
  const onDisk = readdirSync(path.join(ROOT, section.dir), { withFileTypes: true })
36
41
  .filter((e) => e.isDirectory() && !e.name.startsWith('.'))
37
42
  .map((e) => e.name)
@@ -41,6 +46,11 @@ test('EVERY skill + scheduled-task dir on disk is listed AND has a note — no s
41
46
  assert.equal(section.missing.length, 0, `${section.dir}: dirs without a note: ${section.missing.join(', ')}`);
42
47
  assert.equal(section.stale.length, 0, `${section.dir}: notes with no dir (ghosts): ${section.stale.join(', ')}`);
43
48
  }
49
+ // Non-vacuity: on an instance that materializes .claude/, this must have checked
50
+ // something — a skipped-everything run would otherwise pass while asserting nothing.
51
+ if (existsSync(path.join(ROOT, '.claude'))) {
52
+ assert.ok(checked > 0, '.claude/ exists but no section was checked — the exhaustiveness guard went vacuous');
53
+ }
44
54
  });
45
55
 
46
56
  test('the generated blocks live between markers and carry one ← note per entry', () => {
@@ -6,16 +6,24 @@
6
6
  import assert from 'node:assert/strict';
7
7
  import { test } from 'node:test';
8
8
  import auth from '../src/bongos/auth.js';
9
+ import brandingMod from '../src/branding.js';
9
10
 
10
11
  const { firstAdminLogin, maybeSeatFirstAdmin } = auth;
12
+ const { branding } = brandingMod;
11
13
 
12
14
  function clearEnv() {
13
15
  for (const k of ['OTB_FIRST_ADMIN', 'GDS_FIRST_ADMIN', 'PMS_FIRST_ADMIN', 'CLOUDBONGOS_FIRST_ADMIN']) delete process.env[k];
14
16
  }
15
17
 
16
- test('firstAdminLogin: resolves from branding (OTB pack → lars589) when no env override', () => {
18
+ test('firstAdminLogin: resolves from the branding pack when no env override', () => {
19
+ // Asserts the RESOLUTION, not one instance's owner. This used to hardcode the OTB
20
+ // founder's login, which both broke on any other instance (the neutral pack sets
21
+ // firstAdmin: "") and put a founder identity in a test — the thing
22
+ // scripts/gds/audit-authorship.sh exists to keep out (task 1002470).
17
23
  clearEnv();
18
- assert.equal(firstAdminLogin(), 'lars589');
24
+ const configured = (branding().firstAdmin || '').trim() || null;
25
+ assert.equal(firstAdminLogin(), configured,
26
+ 'with no env override, firstAdminLogin must be exactly what the branding pack configures');
19
27
  });
20
28
 
21
29
  test('firstAdminLogin: env override wins over branding (configured prefix)', () => {
@@ -10,12 +10,13 @@
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
  import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
14
15
  import { tmpdir } from 'node:os';
15
16
  import { join } from 'node:path';
16
17
 
17
18
  const require = createRequire(import.meta.url);
18
- const { firstRunEngineCheck } = require('../.claude/hooks/session-start.js');
19
+ const { firstRunEngineCheck } = requireHostModule(require, '../.claude/hooks/session-start.js');
19
20
 
20
21
  let passed = 0, failed = 0;
21
22
  function t(name, fn) {
package/tests/fitness.mjs CHANGED
@@ -4,11 +4,13 @@ import { test } from 'node:test';
4
4
  import { createRequire } from 'node:module';
5
5
  import path from 'node:path';
6
6
  import { fileURLToPath } from 'node:url';
7
+ import { onDiskModuleKeys } from './helpers.mjs';
7
8
 
8
9
  const require = createRequire(import.meta.url);
9
10
  const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
10
11
  const fitness = require(path.join(ROOT, 'scripts', 'gds', 'fitness.js'));
11
12
 
13
+
12
14
  test('all fitness checks pass with no hard failures (the CI gate)', () => {
13
15
  const results = fitness.runAll();
14
16
  const failed = results.filter((r) => r.hardFail);
@@ -158,12 +160,26 @@ test('module boundary actually CATCHES violations when enforcing (ratchet flip)'
158
160
  test('core-carries-no-module-code: the live kernel machinery is clean', () => {
159
161
  const r = fitness.checkCoreCarriesNoModuleCode();
160
162
  assert.equal(r.hardFail, false, r.violations.join('\n'));
161
- // the check is live, not vacuous — it discovered the real module keys
162
- // (incl. the net-new `demo` proof module, BV1.R52 / task 1424, `ideas`, BV1.R78,
163
- // `security`, BV1.R79, `builder-settings`, BV1.R80, `onboarding`, BV1.R81,
164
- // `sessions`, BV1.R82, `autonomy`, BV1.R83, `status-ui`, BV1.R84,
165
- // `hall-ui`, BV1.R85, and the net-new `provisioning` feature module, ADR 0111)
166
- assert.deepEqual(fitness.discoveredModuleKeys().sort(), ['art-pipeline', 'autonomy', 'builder-settings', 'character-anim', 'demo', 'dev-box', 'discord', 'economy', 'game', 'grading', 'hall-ui', 'ideas', 'lifecycle', 'memory', 'onboarding', 'provisioning', 'security', 'sessions', 'status-ui']);
163
+ // The check is live, not vacuous — it discovered the real module keys. This used to
164
+ // be a hardcoded 19-key roster that went stale at the CB→OTB split (it still named
165
+ // art-pipeline / character-anim / demo / game, and had never learned governance or
166
+ // platform-identity), so it asserted a topology that no longer existed — task 1002470.
167
+ // Derived from an INDEPENDENT readdir of modules/, so a discovery regression in
168
+ // fitness.js still diverges from disk and fails here.
169
+ assert.deepEqual(fitness.discoveredModuleKeys().sort(), onDiskModuleKeys());
170
+ });
171
+
172
+ test('the discovered-module roster is derived from a NON-EMPTY disk read (mutation check)', () => {
173
+ // Guards the check above against going vacuous: if modules/ read back empty, the
174
+ // deepEqual would compare [] to [] and pass while proving nothing.
175
+ const keys = onDiskModuleKeys();
176
+ assert.ok(keys.length >= 5, `expected a real on-disk module roster, found ${keys.length}: ${keys.join(', ')}`);
177
+ // And it still FAILS when discovery actually drops a module.
178
+ assert.throws(
179
+ () => assert.deepEqual(keys.slice(1), keys),
180
+ /Expected values to be strictly deep-equal/,
181
+ 'a dropped module must still fail the roster comparison',
182
+ );
167
183
  });
168
184
 
169
185
  test('core-carries-no-module-code actually CATCHES a hardcoded key (and ignores comments)', () => {
@@ -407,6 +423,10 @@ test('hall zone budgets check actually CATCHES a breach (not a no-op)', () => {
407
423
  // (a sibling-module import, and an ungated module write route).
408
424
 
409
425
  test('demo proof module is live + clean (boundary + rank + migration green)', () => {
426
+ // modules/demo/ is the acceptance-proof module; an instance that does not install it
427
+ // has nothing to prove here (task 1002470). The RED-LINE tests below are pure-function
428
+ // checks on path strings and still run either way, so the one-way rules stay covered.
429
+ if (!onDiskModuleKeys().includes('demo')) return;
410
430
  // discovered with the other modules (asserted above) and clean on every gate.
411
431
  assert.ok(fitness.discoveredModuleKeys().includes('demo'), 'demo is a discovered module');
412
432
  assert.equal(fitness.checkModulesImportOnlyApi().hardFail, false, 'demo imports only the doorway');
@@ -438,11 +438,24 @@ await t('both routes are requireBuilder-gated and registered', async () => {
438
438
  }
439
439
  });
440
440
 
441
- await t('route-rank auditor: both gate-approval routes classify to archon with no findings', async () => {
441
+ // ADR 0157 SPLIT these two apart: reading the held-PR list is `gate_approval.read`
442
+ // (metic — the Gate page opens to the working rank, and a page whose only data endpoint
443
+ // 403s is a page that renders an error), while APPROVING still clears the gate-review
444
+ // hard floor and stays `gate_approval.decide` (archon, system:true). Asserting the split
445
+ // explicitly is the point — a future edit that re-merged them onto one atom would either
446
+ // close the page again or hand out the approval, and both are caught here.
447
+ await t('route-rank auditor: gate-approval READ is metic+, APPROVE stays archon, no findings', async () => {
442
448
  const res = checkRouteRanks();
443
449
  const mine = res.routes.filter((r) => r.route.includes('gate-approvals'));
444
450
  assert.equal(mine.length, 2, 'both routes seen by the auditor');
445
- for (const r of mine) assert.equal(r.rank, 'archon', `${r.key} must classify archon`);
451
+ const byKey = new Map(mine.map((r) => [r.key, r]));
452
+ const read = byKey.get('GET /gate-approvals');
453
+ const approve = byKey.get('POST /gate-approvals/:pr/approve');
454
+ assert.ok(read && approve, 'both the list and the approve route must be present');
455
+ assert.deepEqual(read.perms, ['gate_approval.read'], 'the list route gates on the read atom');
456
+ assert.equal(read.rank, 'metic+archon', 'the list route is metic+ (ADR 0157)');
457
+ assert.deepEqual(approve.perms, ['gate_approval.decide'], 'approve gates on the decide atom');
458
+ assert.equal(approve.rank, 'archon', 'approving a held PR must stay archon');
446
459
  const myFindings = res.findings.filter((f) => (f.route || '').includes('gate-approvals'));
447
460
  assert.deepEqual(myFindings, [], 'no auditor findings on the gate-approval routes');
448
461
  });
@@ -77,10 +77,12 @@ test('landProvenForTask: published tip NOT in main → NOT proven (the false-shi
77
77
  assert.match(r.reason, /not in main/);
78
78
  });
79
79
 
80
- test('landProvenForTask: no published tip but matched PR references this task → proven (legacy fallback)', async () => {
80
+ test('landProvenForTask: no published tip but matched MERGED PR references this task → proven (legacy fallback)', async () => {
81
81
  const r = await gh.landProvenForTask(
82
- { taskId: 999, publishedSha: null, status: { pr_body: 'for task 999' } },
83
- depsWithCompare('behind') // must NOT be consulted — no sha
82
+ // merged:true mirrors how real callers reach this fallback (post-landConfirmed);
83
+ // task 1002615 made it a hard requirement (see the open-PR test below).
84
+ { taskId: 999, publishedSha: null, status: { merged: true, pr_body: 'for task 999' } },
85
+ depsWithCompare('behind') // must NOT be consulted — no sha anywhere
84
86
  );
85
87
  assert.equal(r.proven, true);
86
88
  assert.match(r.reason, /references task 999/);
@@ -88,12 +90,56 @@ test('landProvenForTask: no published tip but matched PR references this task
88
90
 
89
91
  test('landProvenForTask: no published tip and matched PR references a DIFFERENT task → NOT proven', async () => {
90
92
  const r = await gh.landProvenForTask(
91
- { taskId: 1299, publishedSha: null, status: { pr_title: 'task 1297: overlay', pr_body: 'for task 1297' } },
93
+ { taskId: 1299, publishedSha: null, status: { merged: true, pr_title: 'task 1297: overlay', pr_body: 'for task 1297' } },
92
94
  depsWithCompare('behind')
93
95
  );
94
96
  assert.equal(r.proven, false);
95
97
  });
96
98
 
99
+ // --- task 1002615: the out-of-band / rewritten-history land proofs -----------
100
+
101
+ // Per-sha compare responses, so the tip and the merge commit can answer differently.
102
+ function depsWithCompareBySha(map) {
103
+ return {
104
+ token: 'x',
105
+ repoInfo: { owner: 'o', repo: 'r' },
106
+ fetchImpl: async (url) => {
107
+ const m = /compare\/[^.]+\.\.\.([0-9a-f]{7,64})/.exec(String(url));
108
+ return { ok: true, status: 200, json: async () => ({ status: (m && map[m[1]]) || 'ahead' }) };
109
+ },
110
+ };
111
+ }
112
+
113
+ const MERGE_SHA = 'f'.repeat(40);
114
+
115
+ test('landProvenForTask (1002615): squash merge — tip NOT in main but the merged PR\'s merge_sha is → proven', async () => {
116
+ const r = await gh.landProvenForTask(
117
+ { taskId: 7, publishedSha: SHA,
118
+ status: { merged: true, merge_sha: MERGE_SHA, pr_title: 'task 7: thing' } },
119
+ depsWithCompareBySha({ [SHA]: 'ahead', [MERGE_SHA]: 'behind' })
120
+ );
121
+ assert.equal(r.proven, true, 'a squash/rebase land must not strand on the tip check');
122
+ assert.match(r.reason, /merged PR for task 7/);
123
+ });
124
+
125
+ test('landProvenForTask (1002615): an OPEN PR that references the task is NOT a land (merged:true required)', async () => {
126
+ const r = await gh.landProvenForTask(
127
+ { taskId: 8, publishedSha: null, status: { merged: false, pr_state: 'open', pr_body: 'for task 8' } },
128
+ depsWithCompare('behind')
129
+ );
130
+ assert.equal(r.proven, false, 'reference alone on an un-merged PR must never prove');
131
+ });
132
+
133
+ test('landProvenForTask (1002615): merged PR for a DIFFERENT task + tip not in main → NOT proven (reuse guard intact)', async () => {
134
+ const r = await gh.landProvenForTask(
135
+ { taskId: 7, publishedSha: SHA,
136
+ status: { merged: true, merge_sha: MERGE_SHA, pr_title: 'task 999: other' } },
137
+ depsWithCompareBySha({ [SHA]: 'ahead', [MERGE_SHA]: 'behind' })
138
+ );
139
+ assert.equal(r.proven, false);
140
+ assert.match(r.reason, /not in main/);
141
+ });
142
+
97
143
  test('landProvenForTask: a non-sha published tip ("HEAD") falls back to the PR check, never the compare API', async () => {
98
144
  let compareCalled = false;
99
145
  const deps = {
@@ -17,12 +17,34 @@ const auth = require(path.join(ROOT, 'src/bongos/auth.js'));
17
17
  const routesAuth = require(path.join(ROOT, 'src/bongos/routes/auth.js'));
18
18
  const gh = require(path.join(ROOT, 'src/bongos/github-repos.js'));
19
19
 
20
- test('scopeForWebFlow: identity-only by default, adds public_repo ONLY when repos=true', () => {
20
+ test('scopeForWebFlow: identity-only by default, adds repo ONLY when repos=true', () => {
21
21
  assert.equal(auth.scopeForWebFlow(), 'read:user');
22
22
  assert.equal(auth.scopeForWebFlow({ repos: false }), 'read:user');
23
- assert.equal(auth.scopeForWebFlow({ repos: true }), 'read:user public_repo');
24
- // Never the all-or-nothing `repo` scope (ADR 0141) — public_repo only.
25
- assert.ok(!auth.scopeForWebFlow({ repos: true }).split(' ').includes('repo'));
23
+ // ADR 0155 (task 1002561): `repo`, not `public_repo` — public_repo grants write to
24
+ // PUBLIC repos only, so adopting an existing PRIVATE repo 403'd at the scaffold push.
25
+ assert.equal(auth.scopeForWebFlow({ repos: true }), 'read:user repo');
26
+ // The widening is opt-in and MUST stay opt-in: an ordinary hall sign-in / the CLI
27
+ // device flow must never carry a repo-acting scope.
28
+ assert.ok(!auth.scopeForWebFlow().split(' ').includes('repo'));
29
+ });
30
+
31
+ test('grantedScopeCoversRepos: a stale pre-0155 public_repo token is NOT sufficient', () => {
32
+ // The exact shape GitHub stored for the live failure (github_oauth_tokens.scope):
33
+ // comma-separated, GitHub's own order, and `public_repo` CONTAINS the substring
34
+ // "repo" — a naive match would wrongly accept it and reintroduce the silent
35
+ // "private repos missing from the picker" degrade.
36
+ assert.equal(auth.grantedScopeCoversRepos('public_repo,read:user'), false);
37
+ assert.equal(auth.grantedScopeCoversRepos('read:user'), false);
38
+ assert.equal(auth.grantedScopeCoversRepos(''), false);
39
+ assert.equal(auth.grantedScopeCoversRepos(null), false);
40
+ assert.equal(auth.grantedScopeCoversRepos(undefined), false);
41
+ // Sufficient: `repo` present, in either order / separator GitHub might use.
42
+ assert.equal(auth.grantedScopeCoversRepos('read:user,repo'), true);
43
+ assert.equal(auth.grantedScopeCoversRepos('repo,read:user'), true);
44
+ assert.equal(auth.grantedScopeCoversRepos('read:user repo'), true);
45
+ assert.equal(auth.grantedScopeCoversRepos('repo'), true);
46
+ // `repo` implies public_repo, so a token carrying both is fine too.
47
+ assert.equal(auth.grantedScopeCoversRepos('public_repo,repo,read:user'), true);
26
48
  });
27
49
 
28
50
  test('safeReturnTo still guards after the ?repos change: relative ok, cross-origin/protocol-relative blocked', () => {
@@ -77,7 +99,10 @@ test('createUserRepo: sends auto_init:false + private:false, returns full_name/h
77
99
  };
78
100
  const out = await gh.createUserRepo('tok', { name: 'mercury', fetchImpl });
79
101
  assert.equal(sentBody.auto_init, false, 'empty repo — no README/first commit');
80
- assert.equal(sentBody.private, false, 'public_repo scope → public');
102
+ // Still public AFTER the ADR 0155 scope widen: `repo` COULD create a private repo,
103
+ // but a repo Cloud Bongos creates stays public by product choice (open-source /
104
+ // capture-resistant by design). Guards against the widen silently changing what we make.
105
+ assert.equal(sentBody.private, false, 'a repo WE create is public by product choice');
81
106
  assert.equal(sentBody.name, 'mercury');
82
107
  assert.deepEqual(out, { full_name: 'me/mercury', html_url: 'https://github.com/me/mercury' });
83
108
  });
@@ -119,3 +119,26 @@ test('GET /github/repos → 403 repo_scope_required when configured but no store
119
119
  db.getGithubToken = orig;
120
120
  }
121
121
  });
122
+
123
+ // ADR 0155 / task 1002561. Widening scopeForWebFlow to `repo` does NOT upgrade rows
124
+ // already in github_oauth_tokens. A surviving pre-0155 `public_repo` token can still
125
+ // list + create PUBLIC repos, so WITHOUT the scope check the route 200s while silently
126
+ // omitting the user's private repos — and the real failure resurfaces minutes later as
127
+ // an opaque 403 on the provisioner's push. It must take the reconnect path instead.
128
+ // (No 200-path test here: `repo`-scoped tokens fall through to a real GitHub call, and
129
+ // this file is deliberately network-free. The positive direction is covered exhaustively
130
+ // by grantedScopeCoversRepos in tests/github_repos_helpers.mjs.)
131
+ test('GET /github/repos → 403 repo_scope_required for a STALE pre-0155 public_repo token', async () => {
132
+ const orig = db.getGithubToken;
133
+ db.getGithubToken = async () => ({ token: 'gho_stale', scope: 'public_repo,read:user' });
134
+ try {
135
+ const handler = finalHandler(buildGithubRouter(), 'get', '/github/repos');
136
+ const res = fakeRes();
137
+ await handler({ builder: { id: 1 }, query: {} }, res);
138
+ assert.equal(res._status, 403, 'a public_repo-only token must not be treated as sufficient');
139
+ assert.equal(res._body.error.code, 'repo_scope_required');
140
+ assert.match(res._body.error.message, /private/i, 'tells the user WHY reconnecting is needed');
141
+ } finally {
142
+ db.getGithubToken = orig;
143
+ }
144
+ });
@@ -51,7 +51,7 @@ const buildGoalsRouter = require('../modules/lifecycle/routes/goals.js');
51
51
  // The pure, exported, ordered create gate — the one place the decision is made.
52
52
  const { authorizeGoalTaskCreate } = buildGoalsRouter;
53
53
  const { deriveRequiredRank, highestRank } = require('../modules/lifecycle/db.js');
54
- const { MODULE_EXPECTED_RANKS, checkRouteRanks } = require('../src/bongos/route-rank-check.js');
54
+ const { MODULE_EXPECTED_RANKS, checkRouteRanks, pinMismatch } = require('../src/bongos/route-rank-check.js');
55
55
  const { scopeIncludesProtected, protectedModules, isModuleKey } = require('../src/bongos/module-scope-map.js');
56
56
 
57
57
  const goalsSrc = readFileSync(new URL('../modules/lifecycle/routes/goals.js', import.meta.url), 'utf8');
@@ -169,7 +169,12 @@ await test('[pin] every goal route is pinned in MODULE_EXPECTED_RANKS.lifecycle'
169
169
  assert.equal(pins['POST /goals'], 'metic+archon');
170
170
  assert.equal(pins['POST /goals/:id/members'], 'any-builder', 'task 1762: widened to owner|lead|archon, in-handler gate');
171
171
  assert.equal(pins['POST /goals/:id/scope'], 'metic+archon');
172
- assert.equal(pins['POST /goals/reorder'], 'archon', 'priority is an owner decision, never self-granted');
172
+ // ADR 0157 lowered `goal.reorder` archon→metic and re-pinned it by atom. Ordering the
173
+ // queue is prioritisation, not an authority grant — the "never self-granted" property
174
+ // that matters here is that reorder is NOT reachable through a goal's own
175
+ // owner/manager authority, and that still holds: it needs a global permission no
176
+ // member role confers.
177
+ assert.equal(pins['POST /goals/reorder'], 'perm:goal.reorder', 'reorder needs a global permission, never a goal-member grant');
173
178
  });
174
179
  await test('[pin] LIVE: every pinned goal route classifies to its expected rank (a drift would mismatch)', () => {
175
180
  const res = checkRouteRanks();
@@ -180,7 +185,12 @@ await test('[pin] LIVE: every pinned goal route classifies to its expected rank
180
185
  if (!key.includes('/goals')) continue; // this suite owns the goal-route pins
181
186
  const r = byKey.get(key);
182
187
  assert.ok(r, `pinned goal route ${key} must exist in the live route set`);
183
- assert.equal(r.rank, expected, `goal route ${key} must classify as '${expected}', got '${r.rank}'`);
188
+ // A pin is either a rank label or `perm:<atom>[,<atom>]` (ADR 0151 / ADR 0157). For an
189
+ // atom pin the live row carries the ATOMS, and its rank label is derived from the
190
+ // catalog floor — so compare atoms to atoms, ranks to ranks. Reusing the checker's own
191
+ // pinMismatch keeps this suite from re-implementing (and drifting from) that rule.
192
+ const mismatch = pinMismatch(expected, r);
193
+ assert.equal(mismatch, null, `goal route ${key} ${mismatch}`);
184
194
  }
185
195
  });
186
196
 
@@ -0,0 +1,89 @@
1
+ // tests/goal_cli.mjs
2
+ //
3
+ // Unit test for the PURE helpers exported from scripts/gds/goal.js (the goal
4
+ // board CLI): rollup(), criterionGlyph(), criterionState(), bucketTasks(),
5
+ // liveTasks(). All are deterministic with no DB, network, or live server —
6
+ // requiring the script must NOT run the CLI (main is require.main-guarded).
7
+ // Run: node tests/goal_cli.mjs
8
+
9
+ import { strict as assert } from 'node:assert';
10
+ import { createRequire } from 'node:module';
11
+
12
+ const require = createRequire(import.meta.url);
13
+ const goal = require('../scripts/gds/goal.js');
14
+
15
+ let passed = 0;
16
+ let failed = 0;
17
+ function test(name, fn) {
18
+ try { fn(); passed++; console.log(` ok ${name}`); }
19
+ catch (err) { failed++; console.log(` FAIL ${name}`); console.log(` ${err.message}`); }
20
+ }
21
+
22
+ // All EIGHT statuses of migration 012's CHECK constraint.
23
+ const ALL_STATUSES = ['backlog', 'ready', 'active', 'blocked', 'completed', 'confirmed', 'shipped', 'abandoned'];
24
+ const t = (status) => ({ status });
25
+
26
+ test('rollup: counts shipped over total', () => {
27
+ assert.equal(goal.rollup([t('shipped'), t('shipped'), t('ready')]), '2/3 shipped · 1 ready');
28
+ });
29
+
30
+ test('rollup: in-flight covers active/completed/confirmed', () => {
31
+ assert.equal(goal.rollup([t('active'), t('completed'), t('confirmed')]), '0/3 shipped · 3 in flight');
32
+ });
33
+
34
+ test('rollup: omits zero segments (backlog-only goal shows only shipped fraction)', () => {
35
+ assert.equal(goal.rollup([t('backlog'), t('backlog')]), '0/2 shipped');
36
+ });
37
+
38
+ test('rollup: abandoned is excluded from the denominator (migration 017 rule)', () => {
39
+ // Without the exclusion this would read "2/3 shipped" for a goal with no live work left.
40
+ assert.equal(goal.rollup([t('shipped'), t('shipped'), t('abandoned')]), '2/2 shipped');
41
+ });
42
+
43
+ test('liveTasks: filters only abandoned', () => {
44
+ const live = goal.liveTasks(ALL_STATUSES.map(t));
45
+ assert.equal(live.length, ALL_STATUSES.length - 1);
46
+ assert.ok(live.every((x) => x.status !== 'abandoned'));
47
+ });
48
+
49
+ test('bucketTasks: partitions all 8 lifecycle statuses into exactly one bucket', () => {
50
+ const tasks = ALL_STATUSES.map(t);
51
+ const b = goal.bucketTasks(tasks);
52
+ assert.equal(b.shipped.length, 1);
53
+ assert.equal(b.inFlight.length, 3);
54
+ assert.equal(b.ready.length, 1);
55
+ assert.equal(b.gated.length, 2); // backlog + blocked
56
+ assert.equal(b.abandoned.length, 1);
57
+ const total = b.shipped.length + b.inFlight.length + b.ready.length + b.gated.length + b.abandoned.length;
58
+ assert.equal(total, tasks.length, 'no status may fall through the buckets');
59
+ });
60
+
61
+ test('criterionGlyph: satisfied wins over pending_review', () => {
62
+ assert.equal(goal.criterionGlyph({ satisfied: true, pending_review: true }), '✓');
63
+ assert.equal(goal.criterionGlyph({ satisfied: false, pending_review: true }), '◇');
64
+ assert.equal(goal.criterionGlyph({ satisfied: false, pending_review: false }), '○');
65
+ });
66
+
67
+ test('criterionState: pending review names the /goal-review queue', () => {
68
+ assert.match(goal.criterionState({ satisfied: false, pending_review: true }), /pending review/);
69
+ });
70
+
71
+ test('criterionState: open criterion reports unshipped-of-linked counts (string API ids)', () => {
72
+ // The API returns counts as strings — the formatter must not NaN on them.
73
+ const s = goal.criterionState({ satisfied: false, pending_review: false, unshipped_tasks: '3', linked_tasks: '3' });
74
+ assert.equal(s, '3 of 3 linked tasks unshipped');
75
+ });
76
+
77
+ test('criterionState: singular linked task reads grammatically', () => {
78
+ const s = goal.criterionState({ satisfied: false, pending_review: false, unshipped_tasks: '1', linked_tasks: '1' });
79
+ assert.equal(s, '1 of 1 linked task unshipped');
80
+ });
81
+
82
+ test('criterionState: zero linked tasks must NOT read as complete', () => {
83
+ const s = goal.criterionState({ satisfied: false, pending_review: false, unshipped_tasks: '0', linked_tasks: '0' });
84
+ assert.equal(s, 'no linked tasks yet');
85
+ });
86
+
87
+ console.log('');
88
+ console.log(`${passed} passed, ${failed} failed`);
89
+ if (failed > 0) process.exit(1);