@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
@@ -2,18 +2,25 @@
2
2
  // scripts/gds/gating.js — owner kill-switch for main-branch merge gating.
3
3
  //
4
4
  // What "gating" means here — TWO independent levers, both toggled together:
5
- // 1. GitHub branch protection on `main`: required status checks (unit,
6
- // trufflehog, gate-review) + required code-owner review (CODEOWNERS).
5
+ // 1. GitHub branch protection on `main`: required status checks + required
6
+ // code-owner review (CODEOWNERS). The required contexts are ONLY checks
7
+ // that exist and can pass today — currently `unit` (the suite + fitness
8
+ // lane, task 1002610). The planned `secret-scan` and `gate-review`
9
+ // contexts (ADR 0058 phase 3; CORE_EXPECTED_CHECKS in adopt-preflight.js)
10
+ // join DEFAULT_PROTECTION when their workflows ship — requiring a context
11
+ // nothing reports blocks every merge indefinitely (D-12, ADR 0159).
7
12
  // 2. The application-layer trust gates that run on every PR — the gate-review
8
- // and grade-gate CI workflows. These post their own status checks and the
9
- // ship / auto-merge pipeline refuses to land a PR while gate-review is red
13
+ // and grade-gate CI workflows (ADR 0058 phases 1-3; NOT YET IN THE TREE —
14
+ // workflowState() reports them 'absent' and the toggles below no-op until
15
+ // they ship). Once live they post their own status checks and the ship /
16
+ // auto-merge pipeline refuses to land a PR while gate-review is red
10
17
  // (it escalates gate-surface changes — infra/, migrations/, CODEOWNERS,
11
18
  // scripts/gds plumbing — to owner approval). Branch protection alone does
12
19
  // NOT neutralize these, which is why `off` also `gh workflow disable`s them.
13
20
  // With gating ON, a gate-surface PR needs owner approval / --admin to merge.
14
21
  // With gating OFF, ordinary `gh pr merge N` (and every builder / autonomous
15
- // session) merges with no checks and no approval. `unit` + `trufflehog` keep
16
- // running either way — real safety nets that force no approval.
22
+ // session) merges with no checks and no approval. `unit` keeps running either
23
+ // way — a real safety net that forces no approval.
17
24
  //
18
25
  // It is REVERSIBLE by construction: `off` snapshots the live protection to a
19
26
  // replay-ready file, then deletes protection; `on` PUTs the snapshot back,
@@ -47,19 +54,27 @@ const SNAPSHOT = require('../../src/instance-config').configPath('gating-snapsho
47
54
  // approval / --admin on gate-surface changes. Branch protection alone does NOT
48
55
  // neutralize these — they post their own status checks and the auto-merge /
49
56
  // ship pipeline refuses to land a PR while gate-review is red. So the kill-switch
50
- // also disables/enables these workflows. `unit` + `trufflehog` (secret scan) are
51
- // deliberately left running — they are real safety nets and force no approval.
57
+ // also disables/enables these workflows. PLANNED, not yet in the tree (ADR 0058
58
+ // phases 1-3): while absent, workflowState() reports 'absent' and both toggles
59
+ // no-op — the names stay here so the switch covers them the moment they ship.
60
+ // `unit` is deliberately left running — a real safety net that forces no approval.
52
61
  const GATE_WORKFLOWS = ['gate-review.yml', 'grade-gate.yml'];
53
62
 
54
- // Baked-in known-good config (the live state captured 2026-06-19). Used only as
55
- // a restore fallback when no snapshot exists, so `on` can never be stuck.
63
+ // Baked-in known-good config. Used only as a restore fallback when no snapshot
64
+ // exists, so `on` can never be stuck. Requires ONLY contexts that exist and can
65
+ // pass today (ADR 0159 — D-12: a required context nothing reports blocks every
66
+ // merge indefinitely, and a required context that is red on every PR does the
67
+ // same). When the secret-scan / gate-review workflows ship (ADR 0058), add
68
+ // their contexts back here — those names are the contract (adopt-preflight.js
69
+ // CORE_EXPECTED_CHECKS, init.js), tool-agnostic on purpose.
56
70
  const DEFAULT_PROTECTION = {
57
71
  required_status_checks: {
58
72
  strict: false,
59
73
  checks: [
60
74
  { context: 'unit', app_id: 15368 },
61
- { context: 'trufflehog', app_id: 15368 },
62
- { context: 'gate-review', app_id: 15368 },
75
+ // Stated intent, not yet requirable — uncomment as each workflow ships:
76
+ // { context: 'secret-scan', app_id: 15368 }, // secret scanning (ADR 0058)
77
+ // { context: 'gate-review', app_id: 15368 }, // gate-surface review (ADR 0058 phase 3)
63
78
  ],
64
79
  },
65
80
  enforce_admins: false,
@@ -270,7 +285,7 @@ function cmdOff(repo) {
270
285
 
271
286
  console.log('\n🔴 gating OFF. `main` is UNPROTECTED and the gate-review/grade CI gates no longer');
272
287
  console.log(' run — ordinary `gh pr merge N` (and every builder / autonomous session) merges');
273
- console.log(' with no checks and no approval. (unit + secret-scan still run.)');
288
+ console.log(' with no checks and no approval. (unit still runs.)');
274
289
  console.log(' ↩ restore everything: node scripts/gds/gating.js on');
275
290
  }
276
291
 
@@ -81,7 +81,7 @@ const RANK_BLURB = {
81
81
  public: 'No authentication — any caller.',
82
82
  'any-builder': 'Any authenticated builder (row-level ownership enforced in-handler).',
83
83
  'metic+archon': 'Metic or Archon rank (review/triage powers).',
84
- archon: 'Archon only (the single owner — ratification, infra, rank changes).',
84
+ archon: 'Archon only (rank and identity management + the escalation keys — the trust boundary).',
85
85
  'bfg-principal': 'The BFG cross-builder memory service principal (not a human rank).',
86
86
  unknown: 'Gate could not be classified from source — treat as privileged.',
87
87
  };
@@ -0,0 +1,234 @@
1
+ #!/usr/bin/env node
2
+ // Goal board CLI — list goals, or show one goal's criteria + tasks + what to claim next.
3
+ //
4
+ // Usage:
5
+ // node scripts/gds/goal.js list [--version V] [--status open|achieved|archived|all]
6
+ // node scripts/gds/goal.js show <id>
7
+ //
8
+ // Read-only. Default list filter is --status open.
9
+
10
+ const { cliClient, requireSession, arg } = require('./cli-lib');
11
+
12
+ const GOAL_GLYPH = { open: '○', achieved: '✓', archived: '·' };
13
+ const IN_FLIGHT = new Set(['active', 'completed', 'confirmed']);
14
+ const LIST_STATUSES = ['open', 'achieved', 'archived', 'all'];
15
+
16
+ function usageExit() {
17
+ console.error('usage: node scripts/gds/goal.js list [--version V] [--status open|achieved|archived|all]');
18
+ console.error(' node scripts/gds/goal.js show <id>');
19
+ process.exit(2);
20
+ }
21
+
22
+ // abandoned is a terminal state (deliberately cut), not pending work — excluded
23
+ // from every denominator, the same rule migration 017 applies to version_progress.
24
+ function liveTasks(tasks) {
25
+ return tasks.filter((t) => t.status !== 'abandoned');
26
+ }
27
+
28
+ function rollup(tasks) {
29
+ const live = liveTasks(tasks);
30
+ const shipped = live.filter((t) => t.status === 'shipped').length;
31
+ const ready = live.filter((t) => t.status === 'ready').length;
32
+ const inFlight = live.filter((t) => IN_FLIGHT.has(t.status)).length;
33
+ const parts = [`${shipped}/${live.length} shipped`];
34
+ if (inFlight) parts.push(`${inFlight} in flight`);
35
+ if (ready) parts.push(`${ready} ready`);
36
+ return parts.join(' · ');
37
+ }
38
+
39
+ async function list(api, args) {
40
+ const version = arg('--version', args) || null;
41
+ if (args.includes('--version') && (!version || !/^[\w.-]+$/.test(version))) usageExit();
42
+ const status = arg('--status', args) || 'open';
43
+ if (!LIST_STATUSES.includes(status)) usageExit();
44
+ const query = {};
45
+ if (version) query.version = version;
46
+ if (status !== 'all') query.status = status;
47
+
48
+ const g = await api.goals.getGoals({ query });
49
+ if (!g.ok) {
50
+ console.error('goals list failed:', JSON.stringify(g.data));
51
+ // process.exitCode (not process.exit): hard-exit while undici keep-alive
52
+ // sockets tear down trips a libuv assertion on Windows.
53
+ process.exitCode = 1;
54
+ return;
55
+ }
56
+ const goals = g.data.goals || [];
57
+ if (goals.length === 0) {
58
+ console.log('No goals match.');
59
+ return;
60
+ }
61
+
62
+ // One goal_id-filtered fetch per listed goal, in parallel — returns exactly
63
+ // the rows the rollups need, never a version-wide or table-wide over-fetch.
64
+ const results = await Promise.all(goals.map((goal) => api.tasks.getTasks({ query: { goal_id: goal.id } })));
65
+ const failed = results.find((r) => !r.ok);
66
+ if (failed) {
67
+ console.error('tasks fetch failed (rollups would be wrong):', JSON.stringify(failed.data));
68
+ process.exitCode = 1;
69
+ return;
70
+ }
71
+ const byGoal = new Map(goals.map((goal, i) => [String(goal.id), results[i].data.tasks || []]));
72
+
73
+ for (const goal of goals) {
74
+ const glyph = GOAL_GLYPH[goal.status] || '?';
75
+ const tasks = byGoal.get(String(goal.id)) || [];
76
+ const tail = liveTasks(tasks).length ? ` [${rollup(tasks)}]` : '';
77
+ console.log(`${glyph} #${goal.id} ${goal.version_id} ${goal.title}${tail}`);
78
+ }
79
+ console.log('');
80
+ console.log(`${goals.length} goal${goals.length === 1 ? '' : 's'} · node scripts/gds/goal.js show <id> for detail`);
81
+ }
82
+
83
+ function criterionGlyph(c) {
84
+ if (c.satisfied) return '✓';
85
+ if (c.pending_review) return '◇';
86
+ return '○';
87
+ }
88
+
89
+ function criterionState(c) {
90
+ if (c.satisfied) return 'satisfied';
91
+ if (c.pending_review) return 'met — pending review (/goal-review)';
92
+ const linked = Number(c.linked_tasks) || 0;
93
+ if (linked === 0) return 'no linked tasks yet';
94
+ const n = Number(c.unshipped_tasks) || 0;
95
+ return `${n} of ${linked} linked task${linked === 1 ? '' : 's'} unshipped`;
96
+ }
97
+
98
+ async function show(api, id) {
99
+ const [g, t] = await Promise.all([
100
+ api.goals.getGoalsId({ id }),
101
+ api.tasks.getTasks({ query: { goal_id: id } }),
102
+ ]);
103
+ if (!g.ok) {
104
+ console.error(`goal ${id} fetch failed:`, JSON.stringify(g.data));
105
+ process.exitCode = 1;
106
+ return;
107
+ }
108
+ if (!t.ok) {
109
+ console.error(`tasks fetch for goal ${id} failed:`, JSON.stringify(t.data));
110
+ process.exitCode = 1;
111
+ return;
112
+ }
113
+ const { goal, members, criteria, dependencies } = g.data;
114
+ const tasks = t.data.tasks || [];
115
+
116
+ console.log(`#${goal.id} ${goal.title}`);
117
+ console.log(`${goal.version_id} · ${goal.status} · modules: ${(goal.scope_modules || []).join(', ') || '—'}`);
118
+ const allMembers = members || [];
119
+ const lead = allMembers.filter((m) => m.role === 'lead').map((m) => `@${m.github_login}`);
120
+ if (lead.length) {
121
+ console.log(`lead: ${lead.join(', ')}${allMembers.length > lead.length ? ` (+${allMembers.length - lead.length} members)` : ''}`);
122
+ } else if (allMembers.length) {
123
+ console.log(`members: ${allMembers.length} (no lead)`);
124
+ }
125
+ for (const d of dependencies || []) {
126
+ console.log(`prerequisite goal: #${d.id ?? d.goal_id} ${d.title || ''} ${d.satisfied ? '(satisfied)' : '(NOT satisfied)'}`);
127
+ }
128
+ if (goal.description) {
129
+ console.log('');
130
+ console.log(goal.description);
131
+ }
132
+
133
+ if ((criteria || []).length) {
134
+ console.log('');
135
+ console.log('Done-when criteria:');
136
+ for (const c of criteria) {
137
+ console.log(` ${criterionGlyph(c)} ${c.criterion_id} — ${criterionState(c)}`);
138
+ }
139
+ }
140
+
141
+ if (tasks.length === 0) {
142
+ console.log('');
143
+ console.log('No tasks filed under this goal yet.');
144
+ return;
145
+ }
146
+ await printTaskBuckets(api, tasks);
147
+ }
148
+
149
+ // Pure bucketing, split from printing so it unit-tests without a live API.
150
+ function bucketTasks(tasks) {
151
+ return {
152
+ shipped: tasks.filter((x) => x.status === 'shipped'),
153
+ inFlight: tasks.filter((x) => IN_FLIGHT.has(x.status)),
154
+ ready: tasks.filter((x) => x.status === 'ready'),
155
+ gated: tasks.filter((x) => x.status === 'backlog' || x.status === 'blocked'),
156
+ abandoned: tasks.filter((x) => x.status === 'abandoned'),
157
+ };
158
+ }
159
+
160
+ async function printTaskBuckets(api, tasks) {
161
+ const buckets = bucketTasks(tasks);
162
+ const live = liveTasks(tasks);
163
+
164
+ // Explain WHY each gated task waits — parallel, with per-task catch: one
165
+ // failed dependency fetch degrades to "dependencies unknown" for that task
166
+ // instead of aborting the listing.
167
+ const waits = new Map();
168
+ const waitsUnknown = new Set();
169
+ await Promise.all(buckets.gated.map(async (task) => {
170
+ try {
171
+ const d = await api.tasks.getTasksIdDependencies({ id: task.id });
172
+ if (!d.ok) { waitsUnknown.add(String(task.id)); return; }
173
+ const unshipped = (d.data.dependencies || []).filter((dep) => dep.status !== 'shipped');
174
+ if (unshipped.length) waits.set(String(task.id), unshipped.map((dep) => `task ${dep.id} (${dep.status})`).join(', '));
175
+ } catch {
176
+ waitsUnknown.add(String(task.id));
177
+ }
178
+ }));
179
+
180
+ console.log('');
181
+ const abandonedNote = buckets.abandoned.length ? `, ${buckets.abandoned.length} abandoned` : '';
182
+ console.log(`Tasks (${buckets.shipped.length}/${live.length} shipped${abandonedNote}):`);
183
+ for (const x of buckets.shipped) console.log(` ✓ ${x.id} ${x.title}`);
184
+ for (const x of buckets.inFlight) console.log(` ▶ ${x.id} ${x.title} [${x.status}]`);
185
+ for (const x of buckets.ready) console.log(` ● ${x.id} ${x.title} [ready — claimable now]`);
186
+ for (const x of buckets.gated) {
187
+ const key = String(x.id);
188
+ const why = waits.has(key) ? ` — waits on ${waits.get(key)}`
189
+ : waitsUnknown.has(key) ? ' — dependencies unknown (fetch failed)' : '';
190
+ console.log(` ○ ${x.id} ${x.title} [${x.status}${why}]`);
191
+ }
192
+ for (const x of buckets.abandoned) console.log(` ✕ ${x.id} ${x.title} [abandoned — excluded from progress]`);
193
+
194
+ console.log('');
195
+ if (buckets.ready.length) {
196
+ console.log(`Next: node scripts/gds/claim.js ${buckets.ready[0].id}`);
197
+ } else if (buckets.inFlight.length) {
198
+ console.log('Nothing claimable — work in flight must land first.');
199
+ } else if (buckets.gated.length) {
200
+ const allWait = buckets.gated.every((x) => waits.has(String(x.id)));
201
+ console.log(allWait
202
+ ? 'Nothing claimable — every remaining task waits on an unshipped dependency.'
203
+ : 'Nothing claimable — remaining tasks are backlog/blocked and not yet promoted to ready.');
204
+ } else if (live.length === 0) {
205
+ console.log('No live tasks — everything filed here was abandoned.');
206
+ } else {
207
+ console.log('All tasks shipped — run /goal-review to confirm criteria.');
208
+ }
209
+ }
210
+
211
+ async function main() {
212
+ await requireSession();
213
+ const args = process.argv.slice(2);
214
+ const cmd = args[0];
215
+ const api = await cliClient();
216
+
217
+ if (cmd === 'list') {
218
+ await list(api, args.slice(1));
219
+ } else if (cmd === 'show') {
220
+ if (!/^\d+$/.test(args[1] || '')) usageExit();
221
+ await show(api, Number(args[1]));
222
+ } else {
223
+ usageExit();
224
+ }
225
+ }
226
+
227
+ if (require.main === module) {
228
+ main().catch((err) => {
229
+ console.error('goal.js failed:', err && err.message ? err.message : err);
230
+ process.exitCode = 1;
231
+ });
232
+ }
233
+
234
+ module.exports = { rollup, criterionGlyph, criterionState, bucketTasks, liveTasks };
@@ -258,6 +258,13 @@ function buildBrandingConfig(spec) {
258
258
  firstAdmin: spec.firstAdmin,
259
259
  currency: { label: (spec.currency && spec.currency.label) || 'credits', symbol: (spec.currency && spec.currency.symbol) || '' },
260
260
  envPrefix: spec.envPrefix || 'CLOUDBONGOS',
261
+ // Every instance gets its OWN session/config dir (task 1002626). Without
262
+ // this, instance-config.js falls back to the shared 'cloudbongos' dir, so
263
+ // every un-branded instance on a machine reads/writes the SAME
264
+ // ~/.config/cloudbongos/gds-session.json — signing in to one silently
265
+ // evicts the CLI session of every other, including the hub. Same slug
266
+ // family as the package name + provisioning slug.
267
+ configDir: spec.configDir ? String(spec.configDir).trim() : slugify(productName, 'instance'),
261
268
  };
262
269
  if (spec.tagline) branding.copy = { tagline: spec.tagline };
263
270
  return branding;
@@ -364,7 +371,11 @@ function kickoffTasks(spec) {
364
371
  if (!provisioningRequest(spec)) tasks.push(productionTopologyKickoff());
365
372
  tasks.push({
366
373
  title: 'Fill the instance look with your own LLM',
367
- kind: 'ideator',
374
+ // 'ideator' is a DISCIPLINE, not a kind — tasks_kind_check rejects it as a
375
+ // kind and the task silently vanished from the checklist (the one non-infra
376
+ // kickoff step). Deciding the instance look is a decision by an ideator.
377
+ kind: 'decision',
378
+ discipline: 'ideator',
368
379
  description: 'init wrote a lean config/branding.json and left the LOOK to you (a locked project rule: brand with your ' +
369
380
  'own LLM, not a generator). Hand docs/branding-fill-prompt.md to your AI to design a palette/fonts/wordmark, or keep the vanilla look.',
370
381
  });
@@ -451,6 +462,9 @@ function versionPayload(spec) {
451
462
  id: v.id,
452
463
  name: v.name,
453
464
  status: v.status || 'building',
465
+ // versions.track is NOT NULL (no DB default) — always send it explicitly.
466
+ // An instance's first version is its product track unless the spec says otherwise.
467
+ track: v.track === 'internal' ? 'internal' : 'product',
454
468
  done_when: v.done_when,
455
469
  criteria: [{ criterion_id: v.criterion_id ? slugify(v.criterion_id) : 'first-goal', criterion_md: v.done_when }],
456
470
  };
@@ -728,12 +742,19 @@ async function seedViaApi(spec, { dryRun, log = console.log, api = apiCall } = {
728
742
  else log(` ✓ created version ${vp.id} + 1 done-when goal`);
729
743
  const criterionId = vp.criteria[0].criterion_id;
730
744
 
731
- // A tracked kickoff/starter task (status ready, engineer discipline). Returns true on success.
745
+ // A tracked kickoff/starter task (status ready). Returns true on success.
746
+ // The whole point of filing these is "nothing is silently skipped" — so a
747
+ // rejected POST must be LOUD: every failure is collected with its reason and
748
+ // reported against the expected count, never absorbed into a smaller total.
749
+ let attempted = 0;
750
+ const failures = [];
732
751
  const postTask = async (t, extra = {}) => {
752
+ attempted++;
733
753
  const res = await api('POST', '/api/gds/tasks', {
734
754
  version_id: vp.id, title: t.title, description: t.description || '', status: 'ready',
735
- kind: t.kind || 'infra', discipline: 'engineer', source: 'bongos-init', ...extra,
755
+ kind: t.kind || 'infra', discipline: t.discipline || 'engineer', source: 'bongos-init', ...extra,
736
756
  });
757
+ if (!res.ok) failures.push({ title: t.title, status: res.status, detail: JSON.stringify(res.data) });
737
758
  return res.ok;
738
759
  };
739
760
 
@@ -771,8 +792,12 @@ async function seedViaApi(spec, { dryRun, log = console.log, api = apiCall } = {
771
792
  }
772
793
 
773
794
  const kickoffLabel = provisioned ? `${kickoff.length} kickoff + provisioning request` : `${kickoff.length} kickoff`;
774
- log(` ✓ filed ${created} task(s) (${starters.length} starter + ${kickoffLabel})`);
775
- return { seeded: true, created, provisioned };
795
+ if (failures.length) {
796
+ log(` ✗ ${failures.length} of ${attempted} task(s) FAILED to file — the checklist below is INCOMPLETE:`);
797
+ for (const f of failures) log(` ✗ "${f.title}" (${f.status}): ${f.detail}`);
798
+ }
799
+ log(` ${failures.length ? '⚠' : '✓'} filed ${created} of ${attempted} task(s) (${starters.length} starter + ${kickoffLabel})`);
800
+ return { seeded: true, created, attempted, failures, provisioned };
776
801
  }
777
802
 
778
803
  // ---------------------------------------------------------------------------
@@ -795,7 +820,12 @@ function confirmProceed({ input = process.stdin, output = process.stdout } = {})
795
820
  // seeds the repo's REAL version/goal + IMPORTS its existing backlog (task 2044), NOT a
796
821
  // synthetic "V1 — Prove the loop". `--dry-run` previews everything and writes nothing;
797
822
  // `--no-seed` layers without seeding. Returns a process exit code.
798
- async function runAdopt(spec, { dir, dryRun, noSeed = false, accept = false, log = console.log }) {
823
+ // `coreDep`/`vendorPlan` (task 1002562): adopt used to ALWAYS write the caret range,
824
+ // which resolves NOWHERE on npm — so an adopted repo could not `npm ci --omit=dev`, the
825
+ // exact command the provisioner runs on the box. Greenfield already had --vendor-core for
826
+ // this; passing the same resolved pin through here makes an adopted repo equally
827
+ // installable. Omitted (a plain `bongos init --adopt` on a laptop) → the caret, unchanged.
828
+ async function runAdopt(spec, { dir, dryRun, noSeed = false, accept = false, coreDep = null, vendorPlan = null, log = console.log }) {
799
829
  log(`\nAdopting Cloud Bongos onto the existing repo at "${dir}"${dryRun ? ' (dry run — nothing will be written or created)' : ''}:`);
800
830
  log(' Layering — pre-existing files are left untouched; nothing is overwritten.');
801
831
 
@@ -827,7 +857,30 @@ async function runAdopt(spec, { dir, dryRun, noSeed = false, accept = false, log
827
857
  }
828
858
 
829
859
  const cfg = layerConfigs(spec, { dir, dryRun, log });
830
- const pkg = layerPackageJson(spec, { dir, dryRun, log });
860
+ const pkg = layerPackageJson(spec, { dir, dryRun, log, ...(coreDep ? { coreRange: coreDep } : {}) });
861
+
862
+ // Vendor the pinned core tarball + generate the lockfile, mirroring the greenfield
863
+ // --vendor-core steps, so the adopted repo is `npm ci --omit=dev`-installable (ADR 0108).
864
+ // Skipped when layerPackageJson found the dep ALREADY present — re-pinning someone's
865
+ // existing declaration is an overwrite, and adopt never overwrites.
866
+ if (vendorPlan && pkg.action !== 'present' && pkg.action !== 'unparseable') {
867
+ const upgrade = require('./upgrade');
868
+ if (dryRun) {
869
+ log(`\n [dry-run] would vendor ${vendorPlan.tgz} → ${vendorPlan.vendorRel} and run \`npm install\` to generate package-lock.json`);
870
+ } else {
871
+ log('\nVendoring the pinned @bongos/core + generating the lockfile:');
872
+ upgrade.vendorTarball({ instanceDir: dir, fromTgz: vendorPlan.tgz, targetVersion: vendorPlan.version });
873
+ log(` ✓ vendored ${vendorPlan.vendorRel}`);
874
+ const inst = upgrade.npmInstall(dir);
875
+ if (!inst.ok) {
876
+ log(` ! npm install failed (${inst.reason || 'exit ' + inst.code}) — package-lock.json not generated; run \`npm install\` in ${dir} by hand before provisioning.`);
877
+ } else {
878
+ log(' ✓ npm install → package-lock.json generated (node_modules/@bongos/core installed)');
879
+ }
880
+ }
881
+ } else if (vendorPlan && pkg.action === 'present') {
882
+ log(` • ${CORE_PKG} was already declared — left as-is; the vendored tarball was NOT pinned over it.`);
883
+ }
831
884
 
832
885
  log('\nMaterializing the .claude/ methodology surface (additive — existing files left untouched):');
833
886
  const claude = materializeClaude({ coreRoot: resolveCoreRoot(), instanceDir: dir, dryRun, overwrite: false, log });
@@ -846,7 +899,10 @@ async function runAdopt(spec, { dir, dryRun, noSeed = false, accept = false, log
846
899
  ? `${backlog.tasks.length} imported task(s) from ${backlog.source}${backlog.truncated ? ` (capped at ${BACKLOG_MAX})` : ''}`
847
900
  : 'no backlog found (capture tasks later)';
848
901
  log(`\nReflecting real state — seeding version "${spec.version && spec.version.id}" + goal + ${importNote}:`);
849
- await seedViaApi(spec, { dryRun, log });
902
+ const seedResult = (await seedViaApi(spec, { dryRun, log })) || {};
903
+ if (Array.isArray(seedResult.failures) && seedResult.failures.length) {
904
+ log(`\n ⚠ ${seedResult.failures.length} seed task(s) failed to file (details above) — re-file them by hand or re-run the seed; the tracked checklist is incomplete until they exist.`);
905
+ }
850
906
  } else {
851
907
  log('\n (--no-seed: layered config only; skipped real-state seeding)');
852
908
  }
@@ -907,17 +963,16 @@ async function main(argv) {
907
963
  return 1;
908
964
  }
909
965
 
910
- // Brownfield (ADR 0121): a distinct LAYER + real-state path — never the greenfield
911
- // scaffold/overwrite below. Its own report + next-steps live in runAdopt.
912
- if (adopt) return await runAdopt(spec, { dir, dryRun, noSeed, accept });
913
-
914
- // Greenfield core-dep resolution (ADR 0108 gap 1/4, task 2053). With --vendor-core, pin
966
+ // Core-dep resolution (ADR 0108 gap 1/4, task 2053). With --vendor-core, pin
915
967
  // package.json to the vendored `file:` tarball (the caret range resolves NOWHERE on npm,
916
968
  // so a caret-only scaffold can't `npm ci`); the tarball is copied + the lockfile generated
917
- // just below. Without it, the scaffold carries the declared caret (a local/dev scaffold the
969
+ // below. Without it, the scaffold carries the declared caret (a local/dev scaffold the
918
970
  // owner resolves another way). Reuses the bongos-upgrade vendoring + install steps.
971
+ //
972
+ // Resolved BEFORE the adopt branch (task 1002562) because BOTH paths need it: an adopted
973
+ // repo has to be `npm ci`-installable by the provisioner exactly like a scaffolded one.
919
974
  const upgrade = require('./upgrade');
920
- let coreDep = null; // null → writeConfigs writes the caret range
975
+ let coreDep = null; // null → writeConfigs/layerPackageJson write the caret range
921
976
  let vendorPlan = null;
922
977
  if (vendorCore) {
923
978
  const tgz = path.resolve(vendorCore);
@@ -935,6 +990,10 @@ async function main(argv) {
935
990
  vendorPlan = { tgz, version, vendorRel };
936
991
  }
937
992
 
993
+ // Brownfield (ADR 0121): a distinct LAYER + real-state path — never the greenfield
994
+ // scaffold/overwrite below. Its own report + next-steps live in runAdopt.
995
+ if (adopt) return await runAdopt(spec, { dir, dryRun, noSeed, accept, coreDep, vendorPlan });
996
+
938
997
  console.log(`\nScaffolding "${spec.identity.productName}"${dryRun ? ' (dry run — nothing will be written or created)' : ''}:`);
939
998
  writeConfigs(spec, { dir, force, dryRun, coreDep });
940
999
 
@@ -1024,6 +1083,9 @@ async function main(argv) {
1024
1083
  else console.log(' (--no-seed: skipped version/task seeding)');
1025
1084
 
1026
1085
  console.log(`\n${dryRun ? 'Dry run complete.' : 'Done.'} Next:`);
1086
+ if (Array.isArray(seedResult.failures) && seedResult.failures.length) {
1087
+ console.log(` ⚠ ${seedResult.failures.length} seed task(s) failed to file (details above) — re-file them by hand or re-run the seed; the tracked checklist is incomplete until they exist.`);
1088
+ }
1027
1089
  console.log(' • fill the look: hand docs/branding-fill-prompt.md to your own LLM');
1028
1090
  console.log(` • sign in as ${spec.firstAdmin} to become the owner, then run \`bongos start\` to see the kickoff tasks`);
1029
1091
  console.log(' • work through the kickoff tasks the way you would any task: claim → ship');
@@ -34,12 +34,13 @@
34
34
  // node scripts/gds/main-audit.js --revert # also print `git revert` plan
35
35
  // node scripts/gds/main-audit.js --strict # also block on unresolved authors
36
36
  // node scripts/gds/main-audit.js --db # resolve ranks from Postgres instead of the API
37
+ // node scripts/gds/main-audit.js --require-roster # CI: an empty roster is a loud DID-NOT-RUN, not a vacuous green
37
38
 
38
39
  'use strict';
39
40
 
40
41
  const { execSync } = require('node:child_process');
41
42
  const ppc = require(require('node:path').join(__dirname, '..', '..', 'src', 'bongos', 'permission-path-check.js'));
42
- const { cliClient } = require('./cli-lib');
43
+ const { cliClient, API_BASE } = require('./cli-lib');
43
44
 
44
45
  // ---------- pure analyzer (tested in tests/main_audit.mjs) ----------
45
46
 
@@ -87,7 +88,7 @@ function analyzeCommits(commits, rankByIdentity, { strict = false } = {}) {
87
88
  author: c.authorName,
88
89
  rank,
89
90
  files: hits.map((h) => h.file),
90
- summary: `commit ${short(c.sha)} by '${c.authorName}' (rank '${rank}', below Metic) modified permission-sensitive path(s) [${fileList}] — this is the SEC #863 violation class. It must be reverted and re-landed by an Archon if genuinely needed.`,
91
+ summary: `commit ${short(c.sha)} by '${c.authorName}' (rank '${rank}', below Metic) modified permission-sensitive path(s) [${fileList}] — this is the SEC #863 violation class. It must be reverted and re-landed by a Metic+ builder if genuinely needed.`,
91
92
  });
92
93
  }
93
94
  }
@@ -120,6 +121,34 @@ function git(args) {
120
121
  return execSync(`git ${args}`, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
121
122
  }
122
123
 
124
+ // How far back the default scan reaches, in first-parent steps.
125
+ const DEFAULT_WINDOW = 200;
126
+
127
+ // First-parent depth of a ref. `<ref>~N` walks FIRST parents N times, so this —
128
+ // not the total commit count — is what bounds the window. On a repo where every
129
+ // land is a --no-ff merge the two diverge sharply: this one has 526 commits
130
+ // reachable from origin/main but a first-parent chain only 186 deep.
131
+ function firstParentDepth(ref) {
132
+ try {
133
+ return Number(git(`rev-list --count --first-parent ${ref}`).trim());
134
+ } catch (_) {
135
+ return NaN;
136
+ }
137
+ }
138
+
139
+ // Clamp the window to what the history actually holds. Returns null when there
140
+ // is nothing to audit, so the caller reports that plainly instead of emitting a
141
+ // `<ref>~200..<ref>` that git rejects as an unknown revision (task 1002566).
142
+ function clampWindow(depth, want = DEFAULT_WINDOW) {
143
+ if (typeof depth !== 'number' || !Number.isFinite(depth) || depth < 2) return null;
144
+ return Math.min(want, depth - 1);
145
+ }
146
+
147
+ function defaultRange(ref, depth, want = DEFAULT_WINDOW) {
148
+ const n = clampWindow(depth, want);
149
+ return n === null ? null : `${ref}~${n}..${ref}`;
150
+ }
151
+
123
152
  // Parse `git log --name-only` with a NUL-delimited custom format into commits.
124
153
  function parseCommits(range) {
125
154
  // %x00 = NUL. Format: NUL sha NUL parents NUL authorName NUL authorEmail NUL subject \n files...
@@ -128,7 +157,9 @@ function parseCommits(range) {
128
157
  try {
129
158
  out = git(`log ${range} --name-only --format=${JSON.stringify(fmt)}`);
130
159
  } catch (e) {
131
- return { error: `git log failed for range ${range}: ${e.message}` };
160
+ // Say plainly that nothing was audited. A bare git error here used to read
161
+ // as a transient glitch, which is how a dead check passed for a clean one.
162
+ return { error: `DID NOT RUN — git log could not resolve range ${range}, so no commits were audited: ${e.message}` };
132
163
  }
133
164
  const records = out.split('\x00').slice(1); // first chunk is empty / pre-first-NUL
134
165
  const commits = [];
@@ -194,8 +225,25 @@ async function fetchRankMap({ useDb = false } = {}) {
194
225
  try {
195
226
  const api = await cliClient({ allowUnauthenticated: true });
196
227
  const res = await api.builders.getBuildersRoster();
197
- if (res && res.ok) return rowsToMap(res.data?.builders || []);
198
- } catch (_) { /* offline / not archon — map stays empty → unresolved authors */ }
228
+ if (res && res.ok) {
229
+ const map = rowsToMap(res.data?.builders || []);
230
+ if (Object.keys(map).length) return map;
231
+ }
232
+ } catch (_) { /* offline / no session — fall through to the public surface */ }
233
+ // Public fallback (task 1002612): CI has no GDS session, and with an empty
234
+ // rank map every author resolves to null so a blocker can NEVER fire — a
235
+ // vacuous green. github_login + rank are public facts on this platform (the
236
+ // status dashboard serves them), and the public leaderboard is server-
237
+ // authoritative, so resolving from it is as trustworthy as the roster route.
238
+ // Caveat: a zero-activity builder may be absent → resolves as UNRESOLVED
239
+ // ('review' finding), never as falsely-trusted.
240
+ try {
241
+ const res = await fetch(`${API_BASE}/api/gds/public/leaderboard`);
242
+ if (res.ok) {
243
+ const data = await res.json();
244
+ return rowsToMap(data?.leaderboard || []);
245
+ }
246
+ } catch (_) { /* offline — map stays empty → unresolved authors */ }
199
247
  return {};
200
248
  }
201
249
 
@@ -221,12 +269,22 @@ async function main() {
221
269
  range = process.env.OTB_AUDIT_RANGE;
222
270
  }
223
271
  if (!range || range === true) {
224
- if (verifyHead) {
225
- range = 'HEAD~200..HEAD';
226
- } else {
272
+ const ref = verifyHead ? 'HEAD' : 'origin/main';
273
+ if (!verifyHead) {
227
274
  // Default: recent origin/main. Fetch first so we're auditing the truth.
228
275
  try { git('fetch origin main'); } catch (_) { /* offline — audit local */ }
229
- range = 'origin/main~200..origin/main';
276
+ }
277
+ // Clamp to the ref's real first-parent depth. A fixed ~200 silently made
278
+ // this audit unrunnable on any repo with a shorter chain (task 1002566).
279
+ const depth = firstParentDepth(ref);
280
+ range = defaultRange(ref, depth);
281
+ if (!range) {
282
+ console.error(
283
+ `main-audit: DID NOT RUN — cannot build a default range for ${ref}: its first-parent history is `
284
+ + `${Number.isFinite(depth) ? `${depth} commit(s)` : 'unreadable'} deep. `
285
+ + 'Pass --range <A..B> or set OTB_AUDIT_RANGE. This is a check that did not execute, not a clean audit.'
286
+ );
287
+ process.exit(2);
230
288
  }
231
289
  }
232
290
 
@@ -237,7 +295,15 @@ async function main() {
237
295
  }
238
296
  const rankMap = await fetchRankMap({ useDb });
239
297
  if (Object.keys(rankMap).length === 0) {
240
- console.error('main-audit: WARNING — could not load the GDS roster (offline / not Archon / no DB). Authors will be unresolved; run as an Archon or with --db (on the droplet) for a definitive audit.');
298
+ // --require-roster (task 1002612): the CI caller refuses to "pass" an audit
299
+ // that could not have failed. With no ranks, every author is unresolved and
300
+ // a blocker can never fire — that is a check that did not execute, not a
301
+ // clean audit (the same false-green shape parseCommits guards against).
302
+ if (process.argv.includes('--require-roster')) {
303
+ console.error('main-audit: DID NOT RUN — the roster is unavailable (offline / no session / public leaderboard unreachable). An empty rank map can never produce a blocker, so exiting green would be a vacuous pass. Fix connectivity or drop --require-roster for a best-effort local read.');
304
+ process.exit(2);
305
+ }
306
+ console.error('main-audit: WARNING — could not load the GDS roster (offline / insufficient rank / no DB). Authors will be unresolved; run as a Metic+ builder or with --db (on the droplet) for a definitive audit.');
241
307
  }
242
308
 
243
309
  const result = analyzeCommits(parsed.commits, rankMap, { strict });
@@ -276,4 +342,7 @@ if (require.main === module) {
276
342
  });
277
343
  }
278
344
 
279
- module.exports = { analyzeCommits, resolveRank, parseCommits };
345
+ module.exports = {
346
+ analyzeCommits, resolveRank, parseCommits, rowsToMap,
347
+ clampWindow, defaultRange, firstParentDepth, DEFAULT_WINDOW,
348
+ };