@bongos/core 1.17.0

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 (1142) hide show
  1. package/.bongos-core.json +5727 -0
  2. package/.claude/skills/blocker-review/SKILL.md +91 -0
  3. package/.claude/skills/blocker-solve/SKILL.md +96 -0
  4. package/.claude/skills/builder-backup/SKILL.md +54 -0
  5. package/.claude/skills/builder-box/SKILL.md +45 -0
  6. package/.claude/skills/builder-claim/SKILL.md +76 -0
  7. package/.claude/skills/builder-connect/SKILL.md +59 -0
  8. package/.claude/skills/builder-cost/SKILL.md +47 -0
  9. package/.claude/skills/builder-end/SKILL.md +81 -0
  10. package/.claude/skills/builder-exit/SKILL.md +88 -0
  11. package/.claude/skills/builder-key/SKILL.md +40 -0
  12. package/.claude/skills/builder-reauth/SKILL.md +50 -0
  13. package/.claude/skills/builder-redteam/SKILL.md +109 -0
  14. package/.claude/skills/builder-release/SKILL.md +41 -0
  15. package/.claude/skills/builder-setup/SKILL.md +62 -0
  16. package/.claude/skills/builder-ship/SKILL.md +79 -0
  17. package/.claude/skills/builder-stage/SKILL.md +51 -0
  18. package/.claude/skills/builder-start/SKILL.md +46 -0
  19. package/.claude/skills/close-box/SKILL.md +43 -0
  20. package/.claude/skills/design-sync/SKILL.md +69 -0
  21. package/.claude/skills/dev/SKILL.md +49 -0
  22. package/.claude/skills/feedback/SKILL.md +32 -0
  23. package/.claude/skills/figma-design-sync/SKILL.md +74 -0
  24. package/.claude/skills/goal-review/SKILL.md +89 -0
  25. package/.claude/skills/idea-triage/SKILL.md +144 -0
  26. package/.claude/skills/ideate/SKILL.md +82 -0
  27. package/.claude/skills/merge-mode/SKILL.md +169 -0
  28. package/.claude/skills/new-project/SKILL.md +108 -0
  29. package/.claude/skills/otb-character-review/SKILL.md +71 -0
  30. package/.claude/skills/otb-design-review/SKILL.md +47 -0
  31. package/.claude/skills/otb-feedback-capture/SKILL.md +45 -0
  32. package/.claude/skills/otb-figma-sync/SKILL.md +54 -0
  33. package/.claude/skills/otb-tile-generate/SKILL.md +45 -0
  34. package/.claude/skills/paint/SKILL.md +70 -0
  35. package/.claude/skills/planning-session/SKILL.md +299 -0
  36. package/.claude/skills/priority-session/SKILL.md +134 -0
  37. package/.claude/skills/read-session-export/SKILL.md +58 -0
  38. package/.claude/skills/recall/SKILL.md +52 -0
  39. package/.claude/skills/session-handoff/SKILL.md +46 -0
  40. package/.claude/skills/status/SKILL.md +33 -0
  41. package/.devcontainer/Dockerfile +69 -0
  42. package/.devcontainer/README.md +73 -0
  43. package/.devcontainer/devcontainer-lock.json +19 -0
  44. package/.devcontainer/devcontainer.json +93 -0
  45. package/.devcontainer/healthcheck.sh +84 -0
  46. package/.devcontainer/init-firewall.sh +198 -0
  47. package/.devcontainer/post-create.sh +153 -0
  48. package/.dockerignore +36 -0
  49. package/.env.local.example +32 -0
  50. package/.gitattributes +83 -0
  51. package/.gitignore +91 -0
  52. package/CLAUDE.md +242 -0
  53. package/CODE_OF_CONDUCT.md +46 -0
  54. package/CONTRIBUTING.md +51 -0
  55. package/Dockerfile +46 -0
  56. package/GOVERNANCE.md +44 -0
  57. package/LICENSE +661 -0
  58. package/README.md +88 -0
  59. package/bin/bongos.js +222 -0
  60. package/clients/bongos-client/README.md +48 -0
  61. package/clients/bongos-client/bongos-client.global.js +617 -0
  62. package/clients/bongos-client/examples/hello-world.mjs +62 -0
  63. package/clients/bongos-client/index.cjs +619 -0
  64. package/clients/bongos-client/index.d.ts +793 -0
  65. package/clients/bongos-client/index.mjs +614 -0
  66. package/clients/bongos-client/package.json +32 -0
  67. package/config/branding.neutral.json +60 -0
  68. package/config/design-tokens.neutral.json +51 -0
  69. package/config/design-tokens.schema.json +96 -0
  70. package/config/modules.neutral.json +9 -0
  71. package/docker-compose.yml +52 -0
  72. package/docker-entrypoint.sh +30 -0
  73. package/docs/adr/0001-stack-choice-single-vps.md +45 -0
  74. package/docs/adr/0002-digitalocean-over-hetzner.md +27 -0
  75. package/docs/adr/0003-cloudflare-origin-cert-over-letsencrypt.md +35 -0
  76. package/docs/adr/0004-amazonprimea-name-and-trademark-acceptance.md +45 -0
  77. package/docs/adr/0005-invisible-other-players-in-v1.md +37 -0
  78. package/docs/adr/0006-singleton-worldroom-plus-queueroom.md +30 -0
  79. package/docs/adr/0007-persistent-identity-via-localstorage-uuid.md +31 -0
  80. package/docs/adr/0008-family-based-tile-generation.md +86 -0
  81. package/docs/adr/0008-google-chat-oauth-user-auth.md +37 -0
  82. package/docs/adr/0009-autotile-architecture.md +181 -0
  83. package/docs/adr/0010-pms-architecture.md +70 -0
  84. package/docs/adr/0011-terrain-id-rendering-model.md +174 -0
  85. package/docs/adr/0012-ground-object-cell-split.md +208 -0
  86. package/docs/adr/0013-paths-as-object-overlays-with-autotile-dispatch.md +158 -0
  87. package/docs/adr/0014-families-canonical-over-vocabulary.md +103 -0
  88. package/docs/adr/0015-task-dependencies-and-auto-promotion.md +125 -0
  89. package/docs/adr/0016-trust-boundary-server-enforced-permissions.md +132 -0
  90. package/docs/adr/0017-code-simplification-and-repo-cleanliness.md +74 -0
  91. package/docs/adr/0018-three-rank-model-goes-live.md +97 -0
  92. package/docs/adr/0019-red-team-bounty-payout-table.md +52 -0
  93. package/docs/adr/0020-security-model-public-stub.md +70 -0
  94. package/docs/adr/0021-per-builder-skill-model-preferences.md +99 -0
  95. package/docs/adr/0022-secrets-policy.md +124 -0
  96. package/docs/adr/0023-kind-multipliers-and-idea-bonus.md +118 -0
  97. package/docs/adr/0024-cloneable-repo-local-first-memory.md +277 -0
  98. package/docs/adr/0024-multi-agent-system-architecture.md +125 -0
  99. package/docs/adr/0025-offsite-backup-vendor-digitalocean-spaces.md +59 -0
  100. package/docs/adr/0025-structured-criterion-task-link.md +44 -0
  101. package/docs/adr/0026-bfg-memory-hygiene-and-cross-builder-learning.md +117 -0
  102. package/docs/adr/0027-bfg-session-inefficiency-evaluator.md +170 -0
  103. package/docs/adr/0028-honest-llm-spend-accounting.md +109 -0
  104. package/docs/adr/0029-status-page-outage-resilience.md +59 -0
  105. package/docs/adr/0030-builders-hall-live-updates.md +59 -0
  106. package/docs/adr/0031-cloud-dev-environments-for-builders.md +222 -0
  107. package/docs/adr/0032-discord-mirror-of-gds.md +50 -0
  108. package/docs/adr/0033-discord-bot-service-principal.md +82 -0
  109. package/docs/adr/0034-thetes-graduated-newcomer-rank.md +53 -0
  110. package/docs/adr/0035-builder-onboarding-three-paths.md +128 -0
  111. package/docs/adr/0036-builders-subdomain-and-ui-reorg.md +40 -0
  112. package/docs/adr/0037-discord-channel-management.md +51 -0
  113. package/docs/adr/0038-chromebook-ttyd-cloudflare-tunnel.md +111 -0
  114. package/docs/adr/0039-setup-first-onboarding-ux.md +49 -0
  115. package/docs/adr/0040-remote-control-default-browser-onramp.md +47 -0
  116. package/docs/adr/0041-temporary-grader-bypass-killswitch.md +40 -0
  117. package/docs/adr/0042-builder-self-deploy-ci-auto-merge.md +82 -0
  118. package/docs/adr/0043-git-ssh-trust-boundary-and-rank-floor-on-permission-paths.md +74 -0
  119. package/docs/adr/0044-mediterranean-palette-replacement.md +73 -0
  120. package/docs/adr/0044-per-box-live-game-preview.md +57 -0
  121. package/docs/adr/0045-devbox-desktop-app.md +133 -0
  122. package/docs/adr/0046-sandbox-first-review-gate.md +72 -0
  123. package/docs/adr/0047-discord-bugs-intake-anti-abuse.md +36 -0
  124. package/docs/adr/0048-works-in-progress-ia-split.md +32 -0
  125. package/docs/adr/0049-split-parallel-safety-contract.md +46 -0
  126. package/docs/adr/0050-device-flow-admission-invite-gated-by-default.md +43 -0
  127. package/docs/adr/0051-full-session-transcript-corpus.md +57 -0
  128. package/docs/adr/0052-sandbox-for-everyone-game-only-preview.md +70 -0
  129. package/docs/adr/0053-scoped-dev-box-session.md +55 -0
  130. package/docs/adr/0054-session-token-reward-cost-plus.md +63 -0
  131. package/docs/adr/0055-server-mediated-branch-publish.md +47 -0
  132. package/docs/adr/0056-prod-deploy-script-mirror-and-healthcheck-retry.md +62 -0
  133. package/docs/adr/0057-container-cost-ledger.md +28 -0
  134. package/docs/adr/0057-discord-archon-approval-channels.md +64 -0
  135. package/docs/adr/0058-automatic-gate-surface-gatekeeper.md +64 -0
  136. package/docs/adr/0059-single-approval-remove-devbox-approval-gate.md +37 -0
  137. package/docs/adr/0060-gds-retrieval-layer.md +97 -0
  138. package/docs/adr/0061-context-layer-decomposition.md +61 -0
  139. package/docs/adr/0062-medusa-instance-model-decoupling.md +107 -0
  140. package/docs/adr/0063-generated-repo-map-symbol-skeleton.md +44 -0
  141. package/docs/adr/0064-rename-medusa-to-cloud-bongos.md +49 -0
  142. package/docs/adr/0065-agpl-nonprofit-ai-first-governance.md +69 -0
  143. package/docs/adr/0066-generated-file-map-sections.md +51 -0
  144. package/docs/adr/0067-session-start-fast-forward-pull.md +43 -0
  145. package/docs/adr/0068-shared-art-key-for-newcomers.md +38 -0
  146. package/docs/adr/0069-hall-live-channel-sse-decouple.md +35 -0
  147. package/docs/adr/0070-deterministic-session-card-delivery.md +44 -0
  148. package/docs/adr/0071-box-confirm-before-destroyed-and-drift-reconcile.md +59 -0
  149. package/docs/adr/0072-bongos-app-mac-signed-first-windows-deferred.md +35 -0
  150. package/docs/adr/0072-dev-box-code-staleness-visibility.md +39 -0
  151. package/docs/adr/0073-builder-needs-signal-and-byok-gemini-key.md +64 -0
  152. package/docs/adr/0073-secrets-scan-exclude-uri-detector.md +37 -0
  153. package/docs/adr/0074-memory-map-stays-light.md +32 -0
  154. package/docs/adr/0075-per-system-cost-attribution.md +37 -0
  155. package/docs/adr/0076-in-session-deterministic-compaction.md +58 -0
  156. package/docs/adr/0077-content-addressed-llm-cache.md +32 -0
  157. package/docs/adr/0078-deterministic-first-cron.md +48 -0
  158. package/docs/adr/0079-value-gated-autonomous-engagement.md +39 -0
  159. package/docs/adr/0080-llm-cache-api-transport-and-trust-model.md +46 -0
  160. package/docs/adr/0081-tool-agnostic-design-layer.md +62 -0
  161. package/docs/adr/0082-productized-key-storage-provisioning.md +32 -0
  162. package/docs/adr/0082-server-side-merge-conflict-auto-resolution.md +55 -0
  163. package/docs/adr/0083-modular-architecture-strangler-build-plan.md +143 -0
  164. package/docs/adr/0084-auto-derived-per-task-rank-floor.md +86 -0
  165. package/docs/adr/0085-remove-default-per-builder-spend-cap.md +39 -0
  166. package/docs/adr/0086-goal-scoped-work-hierarchy.md +123 -0
  167. package/docs/adr/0087-bongos-app-architecture-and-handoff-contract.md +49 -0
  168. package/docs/adr/0087-compete-on-governance-not-tooling-cursor.md +65 -0
  169. package/docs/adr/0088-consistent-character-generative-animation.md +169 -0
  170. package/docs/adr/0089-modular-multi-model-adversarial-grader.md +179 -0
  171. package/docs/adr/0090-metic-task-authoring.md +73 -0
  172. package/docs/adr/0091-bounding-the-kernel-and-db-carve.md +106 -0
  173. package/docs/adr/0092-hosting-module-and-on-prem-target.md +117 -0
  174. package/docs/adr/0093-tranche-2-core-carve-sequence.md +72 -0
  175. package/docs/adr/0094-repo-atlas-self-drawing-codebase-map.md +49 -0
  176. package/docs/adr/0095-borrowed-memory-and-retrieval-concepts.md +65 -0
  177. package/docs/adr/0095-cross-agent-context-management.md +58 -0
  178. package/docs/adr/0096-require-reward-before-workable.md +209 -0
  179. package/docs/adr/0097-one-active-claim-per-session-and-worktree-binding.md +46 -0
  180. package/docs/adr/0097-retroactive-reward-backfill.md +124 -0
  181. package/docs/adr/0098-public-mirror-publish-manifest.md +92 -0
  182. package/docs/adr/0099-delayed-redacted-mirror-export.md +55 -0
  183. package/docs/adr/0100-two-repo-instance-model.md +60 -0
  184. package/docs/adr/0101-goal-hierarchy-phase-5-judge-membership-reward.md +90 -0
  185. package/docs/adr/0102-finish-bounding-the-kernel.md +36 -0
  186. package/docs/adr/0103-core-first-extraction-cloud-bongos-trunk.md +70 -0
  187. package/docs/adr/0103-gdsv4-bongos-consolidation.md +28 -0
  188. package/docs/adr/0104-trust-gds-api-channel-in-auto-mode.md +42 -0
  189. package/docs/adr/0105-instance-seed-migrations-out-of-core.md +47 -0
  190. package/docs/adr/0106-goal-owner-manager-authority.md +84 -0
  191. package/docs/adr/0107-module-upstreaming-contribution-to-core-catalog.md +83 -0
  192. package/docs/adr/0108-instance-composition-configurable-root.md +87 -0
  193. package/docs/adr/0109-self-describing-openapi-and-hosted-docs.md +44 -0
  194. package/docs/adr/0110-untrack-whole-file-generated-nav-docs.md +49 -0
  195. package/docs/adr/0111-instance-hosting-provisioning-module.md +135 -0
  196. package/docs/adr/0112-private-goals-invitations-join-requests.md +72 -0
  197. package/docs/adr/0113-sandbox-studio-descope-superseded-by-0044-0052.md +41 -0
  198. package/docs/adr/0114-instance-docs-discovery-contract.md +32 -0
  199. package/docs/adr/0115-scheduled-routines-portability.md +31 -0
  200. package/docs/adr/0116-api-error-envelope-and-code-taxonomy.md +62 -0
  201. package/docs/adr/0117-api-versioning-and-deprecation-policy.md +50 -0
  202. package/docs/adr/0118-api-contract-source-of-truth-code-first.md +47 -0
  203. package/docs/adr/0119-api-pagination-contract.md +34 -0
  204. package/docs/adr/0120-pay-on-land-and-builder-owned-rebase-gate.md +108 -0
  205. package/docs/adr/0121-greenfield-vs-brownfield-onboarding-adopt-existing-repo.md +97 -0
  206. package/docs/adr/0122-onboarding-diagram-renders-deploy-rendered-off-git.md +43 -0
  207. package/docs/adr/0123-box-idle-sweep-autosave-before-destroy.md +36 -0
  208. package/docs/adr/0124-typed-api-response-bodies.md +37 -0
  209. package/docs/adr/0125-control-plane-scaffolds-standalone-instance-repo.md +36 -0
  210. package/docs/adr/0126-dedicated-cloudbongos-control-plane-droplet.md +30 -0
  211. package/docs/adr/0127-cross-box-fleet-observability.md +64 -0
  212. package/docs/adr/0128-provisioning-runner-privilege-and-db-auth-model.md +46 -0
  213. package/docs/adr/0129-cloudbongos-control-plane-pull-deploy.md +34 -0
  214. package/docs/adr/0130-provision-remote-exec-over-ssh.md +61 -0
  215. package/docs/adr/0131-rank-scoped-skill-visibility.md +47 -0
  216. package/docs/adr/0132-cohost-fleet-control-plane-migration.md +54 -0
  217. package/docs/adr/0133-github-app-manifest-onboarding-signin.md +42 -0
  218. package/docs/adr/0134-private-first-npm-distribution.md +38 -0
  219. package/docs/adr/README.md +208 -0
  220. package/docs/api/openapi.json +13720 -0
  221. package/docs/api-reference.md +437 -0
  222. package/docs/architecture.md +244 -0
  223. package/docs/branding-contract.md +66 -0
  224. package/docs/branding-fill-prompt.md +25 -0
  225. package/docs/canonical-permissions.md +241 -0
  226. package/docs/design/container-exterior-tiles.md +49 -0
  227. package/docs/design/container-exterior.md +81 -0
  228. package/docs/design/container-interior.md +77 -0
  229. package/docs/design/modular-architecture/00-research-report.md +107 -0
  230. package/docs/design/modular-architecture/01-architecture.md +125 -0
  231. package/docs/design/modular-architecture/02-module-map.md +116 -0
  232. package/docs/design/modular-architecture/03-builder-flows.md +104 -0
  233. package/docs/design/modular-architecture/04-module-lifecycle.md +62 -0
  234. package/docs/design/modular-architecture/README.md +27 -0
  235. package/docs/design/vanilla-hall-ui-redesign-scope.md +284 -0
  236. package/docs/design/world-map-scroll.md +41 -0
  237. package/docs/design-contract.md +320 -0
  238. package/docs/file-map.md +418 -0
  239. package/docs/handoff-template.md +92 -0
  240. package/docs/modules-contract.md +293 -0
  241. package/docs/onboarding/browser-terminal-guide.md +73 -0
  242. package/docs/onboarding/diagrams/01-task-lifecycle.mmd +28 -0
  243. package/docs/onboarding/diagrams/02-rank-ladder.mmd +54 -0
  244. package/docs/onboarding/diagrams/03-drachmae-karma.mmd +37 -0
  245. package/docs/onboarding/diagrams/04-architecture.mmd +51 -0
  246. package/docs/onboarding/diagrams/05-versioning.mmd +29 -0
  247. package/docs/onboarding/diagrams/README.md +94 -0
  248. package/docs/onboarding/diagrams/assertions.json +170 -0
  249. package/docs/onboarding/diagrams/puppeteer-config.json +1 -0
  250. package/docs/onboarding/diagrams/render-config.json +9 -0
  251. package/docs/onboarding/drachmae-and-karma.md +178 -0
  252. package/docs/onboarding/primer.md +202 -0
  253. package/docs/project-context.template.md +67 -0
  254. package/docs/recipes/README.md +35 -0
  255. package/docs/recipes/audit-replay.md +100 -0
  256. package/docs/recipes/bongos-cli-release.md +66 -0
  257. package/docs/recipes/builder-box-lifecycle.md +152 -0
  258. package/docs/recipes/claude-design-loop.md +143 -0
  259. package/docs/recipes/claude-gemini-bongos-doors.md +155 -0
  260. package/docs/recipes/cloudbongos-staging-gate.md +139 -0
  261. package/docs/recipes/cloudbongos-standup.md +111 -0
  262. package/docs/recipes/devbox-app-release.md +93 -0
  263. package/docs/recipes/devbox-app-windows-test.md +128 -0
  264. package/docs/recipes/failed-ship-recovery-proof.md +102 -0
  265. package/docs/recipes/figma-design-loop.md +129 -0
  266. package/docs/recipes/gds-db-backup.md +255 -0
  267. package/docs/recipes/instance-deploy.md +37 -0
  268. package/docs/recipes/managed-settings-remote-control.md +416 -0
  269. package/docs/recipes/memory-backup.md +71 -0
  270. package/docs/recipes/memory-sync-conflicts.md +98 -0
  271. package/docs/recipes/multi-builder-merge.md +78 -0
  272. package/docs/recipes/ops-gotchas.md +212 -0
  273. package/docs/recipes/overnight-builder.md +53 -0
  274. package/docs/recipes/packaging-the-core.md +49 -0
  275. package/docs/recipes/parallel-load-results-2026-05-30.md +63 -0
  276. package/docs/recipes/parallel-load-test.md +32 -0
  277. package/docs/recipes/pixel-art-pipeline.md +411 -0
  278. package/docs/recipes/private-npm-distribution.md +79 -0
  279. package/docs/recipes/search-index-ingestion.md +67 -0
  280. package/docs/recipes/self-host.md +60 -0
  281. package/docs/recipes/ship-and-regrade.md +72 -0
  282. package/docs/recipes/staging-data.md +50 -0
  283. package/docs/recipes/staging-environment.md +215 -0
  284. package/docs/recipes/upgrading-the-core.md +58 -0
  285. package/docs/research/cursor-competitive-analysis.md +255 -0
  286. package/docs/research/llm-model-agnostic-and-cheaper-grader.md +227 -0
  287. package/migrations/003_pms.sql +284 -0
  288. package/migrations/004_pms_cost_idempotency.sql +13 -0
  289. package/migrations/005_pms_smoke_filter.sql +44 -0
  290. package/migrations/006_pms_v2.sql +378 -0
  291. package/migrations/007_pms_v2_hierarchy_trigger.sql +59 -0
  292. package/migrations/008_pms_v2_migration_allocator.sql +27 -0
  293. package/migrations/009_pms_v2_blockers_table.sql +68 -0
  294. package/migrations/010_uptime.sql +29 -0
  295. package/migrations/011_backfill_v2_v3_done_when.sql +20 -0
  296. package/migrations/012_pms_v2_task_lifecycle_states.sql +82 -0
  297. package/migrations/013_claim_locking.sql +22 -0
  298. package/migrations/014_pms_task_discipline.sql +86 -0
  299. package/migrations/015_pms_estimation_calibration.sql +140 -0
  300. package/migrations/016_pms_task_grades.sql +60 -0
  301. package/migrations/017_version_progress_exclude_abandoned.sql +49 -0
  302. package/migrations/019_rename_pms_v3_to_gds_v3.sql +65 -0
  303. package/migrations/020_task_dependencies.sql +99 -0
  304. package/migrations/021_v3_renumber_and_deps.sql +224 -0
  305. package/migrations/022_v3_reconcile_dep_gated_status.sql +41 -0
  306. package/migrations/023_pms_rank.sql +30 -0
  307. package/migrations/025_builder_exit_status.sql +15 -0
  308. package/migrations/027_builders_preferred_disciplines.sql +46 -0
  309. package/migrations/028_tasks_parallel_safe.sql +71 -0
  310. package/migrations/029_rank_three_live.sql +32 -0
  311. package/migrations/030_vulnerability_reports.sql +61 -0
  312. package/migrations/031_reward_simplification.sql +57 -0
  313. package/migrations/032_repo_health_snapshots.sql +56 -0
  314. package/migrations/033_audit_log.sql +64 -0
  315. package/migrations/034_lifecycle_breakdown_leaf_only.sql +62 -0
  316. package/migrations/035_auth_token_hygiene.sql +66 -0
  317. package/migrations/037_builder_skill_model_prefs.sql +44 -0
  318. package/migrations/038_task_override_requests.sql +51 -0
  319. package/migrations/039_newcomer_friendly_column.sql +124 -0
  320. package/migrations/040_builder_karma.sql +58 -0
  321. package/migrations/041_kind_multipliers_and_idea_bonus.sql +42 -0
  322. package/migrations/042_criterion_proposal_workflow.sql +78 -0
  323. package/migrations/043_builder_onboarding_observability.sql +66 -0
  324. package/migrations/044_tasks_requires_rank.sql +97 -0
  325. package/migrations/045_builder_onboarding_state.sql +147 -0
  326. package/migrations/046_cost_log_builder_attribution.sql +42 -0
  327. package/migrations/054_builder_memory.sql +108 -0
  328. package/migrations/055_task_criteria.sql +90 -0
  329. package/migrations/058_peer_votes.sql +41 -0
  330. package/migrations/059_onboarding_pick_disciplines.sql +59 -0
  331. package/migrations/060_access_requests.sql +46 -0
  332. package/migrations/061_backfill_override_ship_attribution.sql +29 -0
  333. package/migrations/062_session_records.sql +34 -0
  334. package/migrations/063_session_records_tool_errors_idx.sql +11 -0
  335. package/migrations/064_grade_attempts.sql +18 -0
  336. package/migrations/065_bfg_principal.sql +58 -0
  337. package/migrations/066_live_notify_triggers.sql +69 -0
  338. package/migrations/067_builder_boxes.sql +117 -0
  339. package/migrations/067_discord_bot_foundation.sql +69 -0
  340. package/migrations/068_thetes_requires_rank.sql +27 -0
  341. package/migrations/069_builder_ssh_keys.sql +66 -0
  342. package/migrations/070_box_intents.sql +101 -0
  343. package/migrations/071_box_first_provision_approval.sql +46 -0
  344. package/migrations/072_onboarding_ship_three_remove_primer.sql +80 -0
  345. package/migrations/073_box_terminal_access.sql +46 -0
  346. package/migrations/075_builder_sound_prefs.sql +28 -0
  347. package/migrations/078_box_claude_active.sql +22 -0
  348. package/migrations/079_box_first_connected_at.sql +20 -0
  349. package/migrations/081_tasks_needs_migration.sql +34 -0
  350. package/migrations/084_tasks_security_sensitive.sql +24 -0
  351. package/migrations/085_security_engagement_docs.sql +34 -0
  352. package/migrations/086_rename_disciplines.sql +125 -0
  353. package/migrations/088_builders_anthropic_link.sql +28 -0
  354. package/migrations/089_builders_monthly_budget.sql +19 -0
  355. package/migrations/090_box_scoped_sessions.sql +38 -0
  356. package/migrations/091_session_token_reward.sql +44 -0
  357. package/migrations/093_tasks_sandbox_review.sql +22 -0
  358. package/migrations/102_remove_box_first_provision_approval.sql +58 -0
  359. package/migrations/103_search_chunks.sql +90 -0
  360. package/migrations/104_builder_box_blocked.sql +34 -0
  361. package/migrations/105_box_host_keys.sql +35 -0
  362. package/migrations/111_medusa_customer0_framing.sql +35 -0
  363. package/migrations/112_drop_track_check.sql +25 -0
  364. package/migrations/113_merge_lease.sql +16 -0
  365. package/migrations/114_cloudbongos_rename_live_strings.sql +32 -0
  366. package/migrations/118_currency_label_decoupling.sql +23 -0
  367. package/migrations/119_builder_behavior_prefs.sql +31 -0
  368. package/migrations/121_box_code_version.sql +37 -0
  369. package/migrations/122_builder_gemini_key.sql +30 -0
  370. package/migrations/123_cost_log_tiers.sql +33 -0
  371. package/migrations/124_cost_baseline.sql +45 -0
  372. package/migrations/125_llm_cache.sql +54 -0
  373. package/migrations/126_autonomy_runs.sql +33 -0
  374. package/migrations/143_cost_calibration.sql +63 -0
  375. package/migrations/145_backfill_task_requires_rank.sql +84 -0
  376. package/migrations/160_goal_tier.sql +88 -0
  377. package/migrations/163_polymorphic_dependencies.sql +187 -0
  378. package/migrations/164_event_sound_prefs.sql +20 -0
  379. package/migrations/165_task_module_key.sql +15 -0
  380. package/migrations/171_claims_published_head_sha.sql +24 -0
  381. package/migrations/174_close_orphaned_claims_on_terminal_tasks.sql +30 -0
  382. package/migrations/175_task_grades_diff_hash.sql +9 -0
  383. package/migrations/176_promotion_respects_open_blockers.sql +58 -0
  384. package/migrations/176_ui_discipline.sql +40 -0
  385. package/migrations/177_require_reward_before_workable.sql +76 -0
  386. package/migrations/178_reward_gate_auto_assign_revert_autopromote.sql +61 -0
  387. package/migrations/179_search_chunk_usefulness.sql +33 -0
  388. package/migrations/180_one_active_claim_per_session.sql +54 -0
  389. package/migrations/181_cost_log_skill_name.sql +33 -0
  390. package/migrations/184_claim_release_requested.sql +25 -0
  391. package/migrations/185_module_upstreaming_goal_wiring.sql +59 -0
  392. package/migrations/187_goal_sort_order.sql +30 -0
  393. package/migrations/187_tasks_kind_check_add_verify.sql +21 -0
  394. package/migrations/README.md +88 -0
  395. package/migrations/core_188_goal_visibility_and_membership_requests.sql +71 -0
  396. package/migrations/core_189_core_upgrades_ledger.sql +37 -0
  397. package/migrations/core_189_credit_log_task_id_index.sql +19 -0
  398. package/migrations/core_190_rebuild_claimable_task_views.sql +71 -0
  399. package/migrations/instance/018_pms_v3_planning_seed.sql +131 -0
  400. package/migrations/instance/080_gds_v4_planning_seed.sql +75 -0
  401. package/migrations/instance/110_medusa_planning_seed.sql +70 -0
  402. package/migrations/instance/120_bongos_v1_planning_seed.sql +69 -0
  403. package/migrations/instance/144_bongos_v1_module_system_criterion.sql +26 -0
  404. package/migrations/instance/159_bongos_v1_goal_hierarchy_criterion.sql +29 -0
  405. package/migrations/instance/172_v2_world_planning_seed.sql +163 -0
  406. package/migrations/instance/173_bongos_v1_c7_core_modularized.sql +33 -0
  407. package/migrations/instance/182_gdsv4_bongos_consolidation.sql +132 -0
  408. package/migrations/instance/183_gdsv4_carryover_themed_goals.sql +116 -0
  409. package/migrations/instance/186_bongos_v1_governance_planning_seed.sql +69 -0
  410. package/modules/autonomy/CLAUDE.md +49 -0
  411. package/modules/autonomy/db.js +50 -0
  412. package/modules/autonomy/module.json +13 -0
  413. package/modules/autonomy/pollers/routine-timers.js +146 -0
  414. package/modules/autonomy/routes/autonomy.js +37 -0
  415. package/modules/builder-settings/CLAUDE.md +58 -0
  416. package/modules/builder-settings/builder-needs.js +115 -0
  417. package/modules/builder-settings/db.js +168 -0
  418. package/modules/builder-settings/display-name.js +84 -0
  419. package/modules/builder-settings/event-sound-prefs.js +169 -0
  420. package/modules/builder-settings/interaction-prefs.js +201 -0
  421. package/modules/builder-settings/model-alloc-prefs.js +147 -0
  422. package/modules/builder-settings/module.json +10 -0
  423. package/modules/builder-settings/render-prefs.js +103 -0
  424. package/modules/builder-settings/routes/settings.js +417 -0
  425. package/modules/builder-settings/settings.js +52 -0
  426. package/modules/builder-settings/skill-prefs.js +196 -0
  427. package/modules/builder-settings/sound-prefs.js +178 -0
  428. package/modules/builder-settings/wandering-prefs.js +282 -0
  429. package/modules/dev-box/CLAUDE.md +38 -0
  430. package/modules/dev-box/box-access.js +275 -0
  431. package/modules/dev-box/box-credential.js +94 -0
  432. package/modules/dev-box/box-onboard.js +486 -0
  433. package/modules/dev-box/boxes.js +842 -0
  434. package/modules/dev-box/db.js +39 -0
  435. package/modules/dev-box/module.json +14 -0
  436. package/modules/dev-box/routes/box.js +977 -0
  437. package/modules/discord/CLAUDE.md +50 -0
  438. package/modules/discord/auth.js +54 -0
  439. package/modules/discord/bug-attachments.js +210 -0
  440. package/modules/discord/db.js +96 -0
  441. package/modules/discord/discord-approvals.js +145 -0
  442. package/modules/discord/discord-audit.js +29 -0
  443. package/modules/discord/discord-bot.js +545 -0
  444. package/modules/discord/discord-bugs.js +257 -0
  445. package/modules/discord/discord-channels.js +412 -0
  446. package/modules/discord/discord-inbound.js +147 -0
  447. package/modules/discord/discord-message-buffer.js +54 -0
  448. package/modules/discord/discord-reaction.js +186 -0
  449. package/modules/discord/discord-role-sync.js +318 -0
  450. package/modules/discord/module.json +14 -0
  451. package/modules/discord/pollers/discord-bot.js +19 -0
  452. package/modules/discord/routes/bug-attachments.js +44 -0
  453. package/modules/discord/routes/discord.js +102 -0
  454. package/modules/discord/ship-broadcast.js +78 -0
  455. package/modules/discord/webhook.js +74 -0
  456. package/modules/economy/CLAUDE.md +48 -0
  457. package/modules/economy/achievements.js +594 -0
  458. package/modules/economy/cost-classify.js +331 -0
  459. package/modules/economy/cost-summary-shape.js +90 -0
  460. package/modules/economy/cost.js +438 -0
  461. package/modules/economy/credits.js +430 -0
  462. package/modules/economy/migrations/economy_001_fix_gds_shipper_description.sql +91 -0
  463. package/modules/economy/module.json +10 -0
  464. package/modules/economy/reward.js +61 -0
  465. package/modules/economy/routes/cost.js +97 -0
  466. package/modules/economy/routes/leaderboard.js +26 -0
  467. package/modules/economy/routes/reward.js +42 -0
  468. package/modules/economy/streak.js +285 -0
  469. package/modules/grading/CLAUDE.md +42 -0
  470. package/modules/grading/db.js +39 -0
  471. package/modules/grading/grader-rubric.json +151 -0
  472. package/modules/grading/grader-workers/efficiency.js +213 -0
  473. package/modules/grading/grader-workers/extract-json.js +80 -0
  474. package/modules/grading/grader-workers/fence.js +100 -0
  475. package/modules/grading/grader-workers/hacker.js +239 -0
  476. package/modules/grading/grader-workers/index.js +183 -0
  477. package/modules/grading/grader-workers/narc.js +207 -0
  478. package/modules/grading/grader-workers/quality.js +198 -0
  479. package/modules/grading/grader-workers/rubric.js +20 -0
  480. package/modules/grading/grader-workers/worker-prompt.js +89 -0
  481. package/modules/grading/grader-workers/worker-verdict.js +50 -0
  482. package/modules/grading/grader.js +1790 -0
  483. package/modules/grading/module.json +12 -0
  484. package/modules/grading/routes/grade.js +51 -0
  485. package/modules/hall-ui/CLAUDE.md +61 -0
  486. package/modules/hall-ui/module.json +14 -0
  487. package/modules/hall-ui/public/atlas.html +180 -0
  488. package/modules/hall-ui/public/atlas.js +336 -0
  489. package/modules/hall-ui/public/box-panel.js +423 -0
  490. package/modules/hall-ui/public/brand-apply.js +117 -0
  491. package/modules/hall-ui/public/builders.js +3028 -0
  492. package/modules/hall-ui/public/chime.wav +0 -0
  493. package/modules/hall-ui/public/copy-on-click.js +174 -0
  494. package/modules/hall-ui/public/cursors/arrow.png +0 -0
  495. package/modules/hall-ui/public/cursors/hand-pointer.png +0 -0
  496. package/modules/hall-ui/public/cursors/magnifier-out.png +0 -0
  497. package/modules/hall-ui/public/cursors/magnifier.png +0 -0
  498. package/modules/hall-ui/public/diagram-viewer.css +125 -0
  499. package/modules/hall-ui/public/diagram-viewer.js +330 -0
  500. package/modules/hall-ui/public/diagrams.html +113 -0
  501. package/modules/hall-ui/public/dom-utils.js +212 -0
  502. package/modules/hall-ui/public/drachmae.html +68 -0
  503. package/modules/hall-ui/public/drachmae.js +136 -0
  504. package/modules/hall-ui/public/gate.html +143 -0
  505. package/modules/hall-ui/public/gate.js +228 -0
  506. package/modules/hall-ui/public/goals-page.js +1297 -0
  507. package/modules/hall-ui/public/goals.html +376 -0
  508. package/modules/hall-ui/public/goals.js +119 -0
  509. package/modules/hall-ui/public/hall-widgets.js +189 -0
  510. package/modules/hall-ui/public/harbor.html +167 -0
  511. package/modules/hall-ui/public/harbor.js +335 -0
  512. package/modules/hall-ui/public/index.html +116 -0
  513. package/modules/hall-ui/public/instances-panel.js +172 -0
  514. package/modules/hall-ui/public/md-reader.js +234 -0
  515. package/modules/hall-ui/public/modules.html +119 -0
  516. package/modules/hall-ui/public/modules.js +154 -0
  517. package/modules/hall-ui/public/new-project-wizard.js +186 -0
  518. package/modules/hall-ui/public/not-ready.html +48 -0
  519. package/modules/hall-ui/public/pair.html +119 -0
  520. package/modules/hall-ui/public/pair.js +132 -0
  521. package/modules/hall-ui/public/primer.css +404 -0
  522. package/modules/hall-ui/public/primer.html +85 -0
  523. package/modules/hall-ui/public/primer.js +641 -0
  524. package/modules/hall-ui/public/profile.html +85 -0
  525. package/modules/hall-ui/public/profile.js +236 -0
  526. package/modules/hall-ui/public/ranks.html +69 -0
  527. package/modules/hall-ui/public/ranks.js +206 -0
  528. package/modules/hall-ui/public/roadmap.html +209 -0
  529. package/modules/hall-ui/public/roadmap.js +375 -0
  530. package/modules/hall-ui/public/sessions.html +92 -0
  531. package/modules/hall-ui/public/sessions.js +222 -0
  532. package/modules/hall-ui/public/settings.html +640 -0
  533. package/modules/hall-ui/public/settings.js +1219 -0
  534. package/modules/hall-ui/public/shell.js +495 -0
  535. package/modules/hall-ui/public/style.css +1748 -0
  536. package/modules/hall-ui/public/task-detail.js +253 -0
  537. package/modules/hall-ui/public/task.html +83 -0
  538. package/modules/hall-ui/public/task.js +326 -0
  539. package/modules/hall-ui/public/watch.html +333 -0
  540. package/modules/hall-ui/public/watch.js +859 -0
  541. package/modules/hall-ui/public/work.html +469 -0
  542. package/modules/hall-ui/public/work.js +1292 -0
  543. package/modules/ideas/CLAUDE.md +37 -0
  544. package/modules/ideas/blockers.js +100 -0
  545. package/modules/ideas/inbox.js +459 -0
  546. package/modules/ideas/module.json +10 -0
  547. package/modules/ideas/routes/blockers.js +120 -0
  548. package/modules/ideas/routes/inbox.js +230 -0
  549. package/modules/lifecycle/CLAUDE.md +63 -0
  550. package/modules/lifecycle/conflict-resolve.js +436 -0
  551. package/modules/lifecycle/db.js +4970 -0
  552. package/modules/lifecycle/done-when.js +355 -0
  553. package/modules/lifecycle/github-push.js +969 -0
  554. package/modules/lifecycle/goal-conflicts.js +133 -0
  555. package/modules/lifecycle/hierarchy.js +104 -0
  556. package/modules/lifecycle/lifecycle.js +58 -0
  557. package/modules/lifecycle/merge-lock.js +261 -0
  558. package/modules/lifecycle/migrations/lifecycle_001_goal_first_leads.sql +28 -0
  559. package/modules/lifecycle/migrations/lifecycle_002_backfill_orphan_task_goals.sql +36 -0
  560. package/modules/lifecycle/module.json +13 -0
  561. package/modules/lifecycle/pollers/publish-reconciler.js +20 -0
  562. package/modules/lifecycle/publish-reconciler.js +418 -0
  563. package/modules/lifecycle/routes/analytics.js +63 -0
  564. package/modules/lifecycle/routes/claims.js +506 -0
  565. package/modules/lifecycle/routes/dependencies.js +82 -0
  566. package/modules/lifecycle/routes/done-when.js +136 -0
  567. package/modules/lifecycle/routes/gate-approvals.js +197 -0
  568. package/modules/lifecycle/routes/goals.js +1419 -0
  569. package/modules/lifecycle/routes/lifecycle.js +43 -0
  570. package/modules/lifecycle/routes/tasks.js +1703 -0
  571. package/modules/lifecycle/routes/versions.js +128 -0
  572. package/modules/lifecycle/ship-card.js +505 -0
  573. package/modules/lifecycle/ship-preflight.js +225 -0
  574. package/modules/lifecycle/task-classifier.js +512 -0
  575. package/modules/lifecycle/version-close.js +106 -0
  576. package/modules/memory/CLAUDE.md +40 -0
  577. package/modules/memory/learnings.js +275 -0
  578. package/modules/memory/memory.js +572 -0
  579. package/modules/memory/module.json +12 -0
  580. package/modules/memory/routes/learnings.js +90 -0
  581. package/modules/memory/routes/memory.js +916 -0
  582. package/modules/memory/routes/search.js +188 -0
  583. package/modules/memory/search-chunker.js +252 -0
  584. package/modules/memory/search.js +320 -0
  585. package/modules/onboarding/CLAUDE.md +55 -0
  586. package/modules/onboarding/approval-broadcast.js +59 -0
  587. package/modules/onboarding/db.js +54 -0
  588. package/modules/onboarding/module.json +10 -0
  589. package/modules/onboarding/newcomer-restock.js +212 -0
  590. package/modules/onboarding/onboarding-state.js +532 -0
  591. package/modules/onboarding/onboarding.js +508 -0
  592. package/modules/onboarding/port.js +63 -0
  593. package/modules/onboarding/routes/access-requests.js +208 -0
  594. package/modules/onboarding/routes/onboarding.js +54 -0
  595. package/modules/security/CLAUDE.md +44 -0
  596. package/modules/security/module.json +10 -0
  597. package/modules/security/routes/reports.js +251 -0
  598. package/modules/security/secret-scrub.js +201 -0
  599. package/modules/security/security.js +294 -0
  600. package/modules/sessions/CLAUDE.md +39 -0
  601. package/modules/sessions/bfg.js +357 -0
  602. package/modules/sessions/db.js +326 -0
  603. package/modules/sessions/module.json +10 -0
  604. package/modules/sessions/pulse.js +149 -0
  605. package/modules/sessions/routes/sessions.js +331 -0
  606. package/modules/status-ui/CLAUDE.md +47 -0
  607. package/modules/status-ui/module.json +14 -0
  608. package/modules/status-ui/public/README.md +29 -0
  609. package/modules/status-ui/public/cursors/arrow.png +0 -0
  610. package/modules/status-ui/public/cursors/hand-pointer.png +0 -0
  611. package/modules/status-ui/public/index.html +363 -0
  612. package/modules/status-ui/public/snapshots/.gitkeep +0 -0
  613. package/modules/status-ui/public/status.js +1375 -0
  614. package/modules/status-ui/public/style.css +1342 -0
  615. package/package-lock.json +975 -0
  616. package/package.json +28 -0
  617. package/public-docs/index.html +35 -0
  618. package/public-docs/redoc.standalone.js +1838 -0
  619. package/public-docs/redoc.standalone.js.LICENSE.txt +102 -0
  620. package/public-landing/buddha.png +0 -0
  621. package/public-landing/create.html +868 -0
  622. package/public-landing/index.html +168 -0
  623. package/scripts/bongos-mcp.js +28 -0
  624. package/scripts/cron/style_guide_audit.sh +134 -0
  625. package/scripts/discord/README.md +52 -0
  626. package/scripts/discord/lib.js +80 -0
  627. package/scripts/discord/post.js +51 -0
  628. package/scripts/gds/CLAUDE.md +144 -0
  629. package/scripts/gds/abandon.js +58 -0
  630. package/scripts/gds/adopt-detect.js +123 -0
  631. package/scripts/gds/adopt-import.js +111 -0
  632. package/scripts/gds/adopt-preflight.js +121 -0
  633. package/scripts/gds/api.js +78 -0
  634. package/scripts/gds/architect-audit.js +288 -0
  635. package/scripts/gds/art-stage.js +152 -0
  636. package/scripts/gds/audit-authorship.sh +243 -0
  637. package/scripts/gds/audit-deps.js +121 -0
  638. package/scripts/gds/audit-exports.js +128 -0
  639. package/scripts/gds/audit-replay.sh +161 -0
  640. package/scripts/gds/audit-ships.js +251 -0
  641. package/scripts/gds/autonomy-gate.js +135 -0
  642. package/scripts/gds/backfill-cost.js +165 -0
  643. package/scripts/gds/backfill-sessions.js +351 -0
  644. package/scripts/gds/backfill-task-rewards.js +408 -0
  645. package/scripts/gds/backup.js +94 -0
  646. package/scripts/gds/bfg-principal-token.js +65 -0
  647. package/scripts/gds/box-auth-check.js +160 -0
  648. package/scripts/gds/box-connect-lib.js +453 -0
  649. package/scripts/gds/box.js +1258 -0
  650. package/scripts/gds/build-bongos.js +94 -0
  651. package/scripts/gds/capture.js +123 -0
  652. package/scripts/gds/cf-tunnel.js +419 -0
  653. package/scripts/gds/ci-grade.js +82 -0
  654. package/scripts/gds/claim-recall.js +112 -0
  655. package/scripts/gds/claim.js +333 -0
  656. package/scripts/gds/classify-discipline.js +153 -0
  657. package/scripts/gds/claude-materialize.js +176 -0
  658. package/scripts/gds/cleanup-orphans.js +74 -0
  659. package/scripts/gds/cli-lib.js +518 -0
  660. package/scripts/gds/code.js +101 -0
  661. package/scripts/gds/codemod-rename-src-gds.js +145 -0
  662. package/scripts/gds/conductor-dispatch.js +426 -0
  663. package/scripts/gds/context-pack.js +228 -0
  664. package/scripts/gds/coreB-carve-proof.sh +716 -0
  665. package/scripts/gds/cost.js +47 -0
  666. package/scripts/gds/db-backup-nightly.sh +160 -0
  667. package/scripts/gds/db-backup-offsite.sh +152 -0
  668. package/scripts/gds/demote.js +40 -0
  669. package/scripts/gds/design-sync.js +62 -0
  670. package/scripts/gds/diagnose-achievements.js +118 -0
  671. package/scripts/gds/diagram-facts.js +319 -0
  672. package/scripts/gds/discipline-modes.json +25 -0
  673. package/scripts/gds/discord-channels.js +66 -0
  674. package/scripts/gds/do-api.js +176 -0
  675. package/scripts/gds/docs-entropy.js +403 -0
  676. package/scripts/gds/doctor.js +389 -0
  677. package/scripts/gds/feedback-latest.js +94 -0
  678. package/scripts/gds/fetch-art-key.js +106 -0
  679. package/scripts/gds/fetch-sound-prefs.js +88 -0
  680. package/scripts/gds/figma-design-sync.js +61 -0
  681. package/scripts/gds/fitness.js +1446 -0
  682. package/scripts/gds/gate-review.js +322 -0
  683. package/scripts/gds/gating.js +325 -0
  684. package/scripts/gds/gds-db-backup.service +24 -0
  685. package/scripts/gds/gds-db-backup.timer +20 -0
  686. package/scripts/gds/gds-db-offsite.service +29 -0
  687. package/scripts/gds/gds-db-offsite.timer +18 -0
  688. package/scripts/gds/gen-api-client.js +508 -0
  689. package/scripts/gds/gen-api-docs.js +1037 -0
  690. package/scripts/gds/gen-atlas.js +366 -0
  691. package/scripts/gds/gen-diagrams.js +608 -0
  692. package/scripts/gds/gen-file-map.js +196 -0
  693. package/scripts/gds/gen-repo-map.js +519 -0
  694. package/scripts/gds/gen-session-index.js +210 -0
  695. package/scripts/gds/git-merge-regen.js +147 -0
  696. package/scripts/gds/grade-smoke.js +205 -0
  697. package/scripts/gds/hooks/pre-push.js +169 -0
  698. package/scripts/gds/http-api-client.js +88 -0
  699. package/scripts/gds/init.js +903 -0
  700. package/scripts/gds/install-git-hooks.js +114 -0
  701. package/scripts/gds/key-card.js +213 -0
  702. package/scripts/gds/key-status.js +126 -0
  703. package/scripts/gds/land-watch.js +168 -0
  704. package/scripts/gds/learning-capture.js +145 -0
  705. package/scripts/gds/lib.sh +44 -0
  706. package/scripts/gds/linkify-refs.js +473 -0
  707. package/scripts/gds/local-preview-lib.js +159 -0
  708. package/scripts/gds/local-preview.js +95 -0
  709. package/scripts/gds/login.js +225 -0
  710. package/scripts/gds/main-audit.js +279 -0
  711. package/scripts/gds/me.js +88 -0
  712. package/scripts/gds/memory-map.js +174 -0
  713. package/scripts/gds/merge-lock.js +71 -0
  714. package/scripts/gds/migration-namespace.js +200 -0
  715. package/scripts/gds/mirror-lineage-check.js +319 -0
  716. package/scripts/gds/mirror-redact.js +305 -0
  717. package/scripts/gds/module.js +482 -0
  718. package/scripts/gds/newcomer-floor.config.json +13 -0
  719. package/scripts/gds/newcomer-floor.js +415 -0
  720. package/scripts/gds/newcomer-floor.templates.json +86 -0
  721. package/scripts/gds/newcomer-floor.test.js +235 -0
  722. package/scripts/gds/next-number.js +163 -0
  723. package/scripts/gds/oauth-secret.js +197 -0
  724. package/scripts/gds/onboard.js +217 -0
  725. package/scripts/gds/onboarding-analyze.js +226 -0
  726. package/scripts/gds/onboarding-config.js +178 -0
  727. package/scripts/gds/onboarding-ship.js +92 -0
  728. package/scripts/gds/package-core.js +684 -0
  729. package/scripts/gds/paste-token.js +131 -0
  730. package/scripts/gds/portability-smoke-art.sh +85 -0
  731. package/scripts/gds/portability-smoke-memory.sh +147 -0
  732. package/scripts/gds/provision.js +1324 -0
  733. package/scripts/gds/publish-manifest.js +307 -0
  734. package/scripts/gds/pulse.js +309 -0
  735. package/scripts/gds/push-main.js +45 -0
  736. package/scripts/gds/read-session-export.js +359 -0
  737. package/scripts/gds/reauth.js +149 -0
  738. package/scripts/gds/recall.js +135 -0
  739. package/scripts/gds/release.js +71 -0
  740. package/scripts/gds/render-ideas.js +173 -0
  741. package/scripts/gds/repo-metrics.js +363 -0
  742. package/scripts/gds/resolve-skill-model.js +57 -0
  743. package/scripts/gds/run-routine.js +197 -0
  744. package/scripts/gds/run-unit-tests.js +178 -0
  745. package/scripts/gds/sandbox-stage.js +653 -0
  746. package/scripts/gds/search-ingest-db.js +209 -0
  747. package/scripts/gds/search-ingest-md.js +225 -0
  748. package/scripts/gds/security-baseline-check.sh +180 -0
  749. package/scripts/gds/seed-adopt-tasks.js +75 -0
  750. package/scripts/gds/seed-api-contract-goal-tasks.js +141 -0
  751. package/scripts/gds/seed-bongos-app-tasks.js +294 -0
  752. package/scripts/gds/seed-bongos-coreB-tranche1-tasks.js +81 -0
  753. package/scripts/gds/seed-bongos-coreB-tranche2-tasks.js +100 -0
  754. package/scripts/gds/seed-bongos-goal-hierarchy-tasks.js +307 -0
  755. package/scripts/gds/seed-bongos-module-system-tasks.js +346 -0
  756. package/scripts/gds/seed-bongos-tasks.js +214 -0
  757. package/scripts/gds/seed-gds-v4-tasks.js +624 -0
  758. package/scripts/gds/seed-governance-tasks.js +340 -0
  759. package/scripts/gds/seed-medusa-tasks.js +440 -0
  760. package/scripts/gds/seed-module-upstreaming.js +219 -0
  761. package/scripts/gds/seed-productization-tasks.js +235 -0
  762. package/scripts/gds/seed-provisioning-tasks.js +83 -0
  763. package/scripts/gds/seed-staging.sh +104 -0
  764. package/scripts/gds/seed-v2-world-tasks.js +283 -0
  765. package/scripts/gds/seed-v3-tasks.js +1981 -0
  766. package/scripts/gds/session-cost.js +393 -0
  767. package/scripts/gds/session-digest.js +179 -0
  768. package/scripts/gds/session-eval.js +576 -0
  769. package/scripts/gds/session.js +404 -0
  770. package/scripts/gds/setup.js +504 -0
  771. package/scripts/gds/shell.js +100 -0
  772. package/scripts/gds/ship.js +3618 -0
  773. package/scripts/gds/smoke-batch-claim.sh +213 -0
  774. package/scripts/gds/smoke-batch-release.sh +296 -0
  775. package/scripts/gds/smoke-box.sh +150 -0
  776. package/scripts/gds/smoke-claim-consistency.sh +212 -0
  777. package/scripts/gds/smoke-dependencies.sh +84 -0
  778. package/scripts/gds/smoke-gds.js +125 -0
  779. package/scripts/gds/smoke-write.sh +168 -0
  780. package/scripts/gds/start.js +782 -0
  781. package/scripts/gds/status.js +365 -0
  782. package/scripts/gds/triage.js +366 -0
  783. package/scripts/gds/upgrade.js +421 -0
  784. package/scripts/gds/validate-design.js +254 -0
  785. package/scripts/gds/version-close.js +549 -0
  786. package/scripts/migrate.sh +183 -0
  787. package/scripts/migrate_cost_ledger.sh +178 -0
  788. package/scripts/public-mirror-export.js +291 -0
  789. package/scripts/render-diagrams.sh +88 -0
  790. package/scripts/security/dep-audit.js +276 -0
  791. package/scripts/setup-builder.js +274 -0
  792. package/scripts/setup-builder.sh +28 -0
  793. package/scripts/snapshots/backfill-history.mjs +292 -0
  794. package/scripts/snapshots/maybe-render-on-deploy.sh +66 -0
  795. package/scripts/snapshots/render-pre-art-eras.mjs +329 -0
  796. package/scripts/snapshots/render-world.mjs +322 -0
  797. package/scripts/status-mirror-sync.js +192 -0
  798. package/scripts/status-probe.mjs +185 -0
  799. package/server.js +212 -0
  800. package/src/asset-version.js +71 -0
  801. package/src/bongos/CLAUDE.md +64 -0
  802. package/src/bongos/api-errors.js +281 -0
  803. package/src/bongos/api-path-404.js +67 -0
  804. package/src/bongos/api-prefix.js +38 -0
  805. package/src/bongos/app-pair.js +172 -0
  806. package/src/bongos/auth.js +979 -0
  807. package/src/bongos/cascade-dispatch.js +123 -0
  808. package/src/bongos/db-kernel.js +252 -0
  809. package/src/bongos/db.js +911 -0
  810. package/src/bongos/goal-scope-check.js +120 -0
  811. package/src/bongos/hierarchy-config.js +189 -0
  812. package/src/bongos/live-channel.js +145 -0
  813. package/src/bongos/llm-cache.js +260 -0
  814. package/src/bongos/llm-pricing.js +220 -0
  815. package/src/bongos/metrics.js +286 -0
  816. package/src/bongos/middleware/audit.js +274 -0
  817. package/src/bongos/middleware/error-envelope.js +93 -0
  818. package/src/bongos/middleware/rate-limit.js +195 -0
  819. package/src/bongos/module-scope-map.js +271 -0
  820. package/src/bongos/optimizer.js +166 -0
  821. package/src/bongos/path-match.js +47 -0
  822. package/src/bongos/permission-path-check.js +248 -0
  823. package/src/bongos/pool.js +55 -0
  824. package/src/bongos/repo-info.js +91 -0
  825. package/src/bongos/route-rank-check.js +420 -0
  826. package/src/bongos/routes/CLAUDE.md +43 -0
  827. package/src/bongos/routes/_helpers.js +369 -0
  828. package/src/bongos/routes/audit-log.js +59 -0
  829. package/src/bongos/routes/auth.js +755 -0
  830. package/src/bongos/routes/backup.js +104 -0
  831. package/src/bongos/routes/builders.js +526 -0
  832. package/src/bongos/routes/healthz.js +16 -0
  833. package/src/bongos/routes/instance.js +129 -0
  834. package/src/bongos/routes/live.js +125 -0
  835. package/src/bongos/routes/llm-cache.js +119 -0
  836. package/src/bongos/routes/me.js +380 -0
  837. package/src/bongos/routes/modules.js +131 -0
  838. package/src/bongos/routes/optimizer.js +54 -0
  839. package/src/bongos/routes/public.js +476 -0
  840. package/src/bongos/routes/security.js +207 -0
  841. package/src/bongos/routes.js +166 -0
  842. package/src/bongos/secret-box.js +177 -0
  843. package/src/bongos/sensitive-surfaces.js +30 -0
  844. package/src/bongos/serve-internal.js +889 -0
  845. package/src/bongos/uptime-poller.js +90 -0
  846. package/src/bongos-downloads.js +70 -0
  847. package/src/branding.js +182 -0
  848. package/src/build-info.js +77 -0
  849. package/src/devbox-downloads.js +67 -0
  850. package/src/instance-config.js +200 -0
  851. package/src/module-api.js +366 -0
  852. package/src/module-loader/loader.js +268 -0
  853. package/src/module-loader/manifest-schema.js +140 -0
  854. package/src/module-loader/provenance.js +28 -0
  855. package/src/module-loader/semver.js +100 -0
  856. package/src/module-seams.js +291 -0
  857. package/src/modules.js +254 -0
  858. package/src/platform-server.js +135 -0
  859. package/src/ui/adapters/claude-design/index.js +333 -0
  860. package/src/ui/adapters/figma/index.js +328 -0
  861. package/tests/abandon_task.mjs +138 -0
  862. package/tests/achievements_reconcile.mjs +167 -0
  863. package/tests/adopt_detect.mjs +141 -0
  864. package/tests/adopt_import.mjs +132 -0
  865. package/tests/adopt_preflight.mjs +105 -0
  866. package/tests/anti_grid.mjs +89 -0
  867. package/tests/api_client.mjs +231 -0
  868. package/tests/api_docs.mjs +283 -0
  869. package/tests/api_error_envelope_buildcheck.mjs +56 -0
  870. package/tests/api_error_envelope_middleware.mjs +99 -0
  871. package/tests/api_error_registry.mjs +108 -0
  872. package/tests/api_pagination_contract.mjs +51 -0
  873. package/tests/api_path_404.mjs +96 -0
  874. package/tests/app_pair.mjs +168 -0
  875. package/tests/architect.mjs +140 -0
  876. package/tests/art_key.mjs +121 -0
  877. package/tests/art_stage.mjs +121 -0
  878. package/tests/asset_version.mjs +94 -0
  879. package/tests/atlas.mjs +69 -0
  880. package/tests/audit_deps.mjs +74 -0
  881. package/tests/audit_middleware.mjs +385 -0
  882. package/tests/auth_admission_gate.mjs +164 -0
  883. package/tests/auth_anthropic_identity.mjs +95 -0
  884. package/tests/auth_duplicate_cookie.mjs +56 -0
  885. package/tests/auth_error_brand.mjs +77 -0
  886. package/tests/auth_flow_slice1.mjs +223 -0
  887. package/tests/auth_inactive_builder.mjs +94 -0
  888. package/tests/auth_page_gate.mjs +92 -0
  889. package/tests/auth_session_ttl.mjs +155 -0
  890. package/tests/autonomous_fit_protected_paths.mjs +115 -0
  891. package/tests/autonomy_gate.mjs +85 -0
  892. package/tests/autotile_variants.mjs +256 -0
  893. package/tests/autotile_xverify.mjs +133 -0
  894. package/tests/backfill_sessions.mjs +101 -0
  895. package/tests/backfill_task_rewards.mjs +152 -0
  896. package/tests/backup_cli.mjs +104 -0
  897. package/tests/bfg.mjs +327 -0
  898. package/tests/bfg_write.mjs +423 -0
  899. package/tests/bongos_box_connect.mjs +89 -0
  900. package/tests/bongos_dispatch.mjs +74 -0
  901. package/tests/bongos_downloads.mjs +118 -0
  902. package/tests/bongos_login.mjs +34 -0
  903. package/tests/box_access.mjs +186 -0
  904. package/tests/box_auth_check.mjs +114 -0
  905. package/tests/box_code_staleness.mjs +116 -0
  906. package/tests/box_connect_e2e.mjs +196 -0
  907. package/tests/box_cost.mjs +70 -0
  908. package/tests/box_dns_repoint.mjs +151 -0
  909. package/tests/box_host_keys.mjs +102 -0
  910. package/tests/box_onboard.mjs +317 -0
  911. package/tests/box_scope_session.mjs +115 -0
  912. package/tests/box_ship_permissions.mjs +71 -0
  913. package/tests/box_terminal_ssrf.mjs +90 -0
  914. package/tests/boxes.mjs +1255 -0
  915. package/tests/branding.mjs +88 -0
  916. package/tests/bug_attachments.mjs +189 -0
  917. package/tests/build_info.mjs +88 -0
  918. package/tests/builder_needs.mjs +99 -0
  919. package/tests/cap_newcomer_tasks.mjs +114 -0
  920. package/tests/cascade-dispatch.mjs +95 -0
  921. package/tests/cf_tunnel.mjs +459 -0
  922. package/tests/claim_recall.mjs +150 -0
  923. package/tests/claims_bind_session.mjs +47 -0
  924. package/tests/claude_materialize.mjs +148 -0
  925. package/tests/cli_box_scope.mjs +52 -0
  926. package/tests/cli_lib_formatters.mjs +87 -0
  927. package/tests/cli_token_reissue.mjs +147 -0
  928. package/tests/client_bundle_route.mjs +47 -0
  929. package/tests/cloud_bongos_pack.mjs +85 -0
  930. package/tests/conductor.mjs +496 -0
  931. package/tests/confirm_attribution.mjs +156 -0
  932. package/tests/conflict_resolve.mjs +462 -0
  933. package/tests/context_pack.mjs +240 -0
  934. package/tests/cost.mjs +94 -0
  935. package/tests/cost_audit.mjs +122 -0
  936. package/tests/cost_budget_status.mjs +78 -0
  937. package/tests/cost_reconcile.mjs +102 -0
  938. package/tests/cost_summary_shape.mjs +94 -0
  939. package/tests/credit_grant.mjs +376 -0
  940. package/tests/criterion_progress.mjs +220 -0
  941. package/tests/criterion_proposal.mjs +109 -0
  942. package/tests/criterion_ref_parse.mjs +53 -0
  943. package/tests/currency_label.mjs +119 -0
  944. package/tests/day_cycle.mjs +197 -0
  945. package/tests/dependencies_migration.mjs +77 -0
  946. package/tests/dependencies_polymorphic.mjs +212 -0
  947. package/tests/deploy_instance.mjs +78 -0
  948. package/tests/devbox_downloads.mjs +122 -0
  949. package/tests/diff_derived_signals.mjs +115 -0
  950. package/tests/discord_approvals.mjs +174 -0
  951. package/tests/discord_autojoin.mjs +158 -0
  952. package/tests/discord_bot.mjs +94 -0
  953. package/tests/discord_bugs.mjs +297 -0
  954. package/tests/discord_channels.mjs +337 -0
  955. package/tests/discord_foundation.mjs +76 -0
  956. package/tests/discord_inbound.mjs +190 -0
  957. package/tests/discord_message_buffer.mjs +87 -0
  958. package/tests/discord_oauth.mjs +55 -0
  959. package/tests/discord_reaction.mjs +170 -0
  960. package/tests/discord_role_sync.mjs +151 -0
  961. package/tests/display_name.mjs +123 -0
  962. package/tests/dns_preflight.mjs +74 -0
  963. package/tests/event_sound_prefs.mjs +194 -0
  964. package/tests/eviction_policy.mjs +279 -0
  965. package/tests/figma_adapter.mjs +199 -0
  966. package/tests/file_map.mjs +91 -0
  967. package/tests/first_admin_bootstrap.mjs +40 -0
  968. package/tests/fitness.mjs +435 -0
  969. package/tests/fitness_two_root.mjs +104 -0
  970. package/tests/fixtures/session-506-digest.json +34 -0
  971. package/tests/gate_approvals.mjs +451 -0
  972. package/tests/gate_review.mjs +152 -0
  973. package/tests/git_merge_regen.mjs +151 -0
  974. package/tests/github_push_ancestry.mjs +119 -0
  975. package/tests/github_push_land_proof.mjs +127 -0
  976. package/tests/github_push_merge_driver.mjs +175 -0
  977. package/tests/goal_abuse_matrix.mjs +187 -0
  978. package/tests/goal_achievement.mjs +146 -0
  979. package/tests/goal_conflicts.mjs +305 -0
  980. package/tests/goal_dependencies_read.mjs +73 -0
  981. package/tests/goal_member_roles.mjs +420 -0
  982. package/tests/goal_membership_requests.mjs +332 -0
  983. package/tests/goal_routes.mjs +203 -0
  984. package/tests/goal_scope_check.mjs +167 -0
  985. package/tests/goal_tier.mjs +312 -0
  986. package/tests/goal_wall_proof.mjs +112 -0
  987. package/tests/grade_attempts.mjs +73 -0
  988. package/tests/grade_attribution.mjs +117 -0
  989. package/tests/grade_diff_hash.mjs +191 -0
  990. package/tests/grade_server_authoritative.mjs +305 -0
  991. package/tests/grade_unavailable_display.mjs +66 -0
  992. package/tests/grader.mjs +1960 -0
  993. package/tests/grader_bypass.mjs +57 -0
  994. package/tests/grader_panel.mjs +252 -0
  995. package/tests/grader_prompt_fencing.mjs +192 -0
  996. package/tests/helpers.mjs +52 -0
  997. package/tests/hierarchy-config.mjs +81 -0
  998. package/tests/init.mjs +463 -0
  999. package/tests/instance_config.mjs +76 -0
  1000. package/tests/instance_manifest.mjs +110 -0
  1001. package/tests/interaction_prefs.mjs +72 -0
  1002. package/tests/key-card.mjs +110 -0
  1003. package/tests/key-status.mjs +45 -0
  1004. package/tests/land_watch.mjs +115 -0
  1005. package/tests/lifecycle-card.mjs +145 -0
  1006. package/tests/lifecycle_breakdown_view.mjs +236 -0
  1007. package/tests/linkify_refs.mjs +264 -0
  1008. package/tests/live_channel.mjs +128 -0
  1009. package/tests/live_sse.mjs +122 -0
  1010. package/tests/llm-cache-route.mjs +110 -0
  1011. package/tests/llm-cache.mjs +140 -0
  1012. package/tests/llm-pricing.mjs +152 -0
  1013. package/tests/local_preview.mjs +136 -0
  1014. package/tests/main_audit.mjs +137 -0
  1015. package/tests/memory_isolation.mjs +431 -0
  1016. package/tests/memory_map.mjs +107 -0
  1017. package/tests/memory_pull.mjs +429 -0
  1018. package/tests/merge_lease.mjs +100 -0
  1019. package/tests/merge_lock.mjs +312 -0
  1020. package/tests/metrics.mjs +147 -0
  1021. package/tests/migration_allocation.mjs +146 -0
  1022. package/tests/migration_namespace.mjs +185 -0
  1023. package/tests/migrations_fresh_replay_guard.mjs +38 -0
  1024. package/tests/mirror_lineage_fulltree.mjs +45 -0
  1025. package/tests/mirror_lineage_proof.mjs +72 -0
  1026. package/tests/mirror_redact.mjs +193 -0
  1027. package/tests/model_alloc_prefs.mjs +63 -0
  1028. package/tests/module-scope-map.mjs +88 -0
  1029. package/tests/module_api.mjs +110 -0
  1030. package/tests/module_cli.mjs +292 -0
  1031. package/tests/module_contributions.mjs +148 -0
  1032. package/tests/module_loader.mjs +286 -0
  1033. package/tests/module_manifest.mjs +194 -0
  1034. package/tests/module_owned_tests.mjs +105 -0
  1035. package/tests/module_provenance.mjs +34 -0
  1036. package/tests/module_route_rank.mjs +147 -0
  1037. package/tests/module_seams.mjs +68 -0
  1038. package/tests/modules.mjs +156 -0
  1039. package/tests/modules_route.mjs +38 -0
  1040. package/tests/newcomer_floor.mjs +17 -0
  1041. package/tests/newcomer_restock.mjs +80 -0
  1042. package/tests/next_number.mjs +154 -0
  1043. package/tests/oauth_secret.mjs +60 -0
  1044. package/tests/onboard.mjs +70 -0
  1045. package/tests/onboarding_gate.mjs +267 -0
  1046. package/tests/onboarding_observability.mjs +344 -0
  1047. package/tests/onboarding_route_signals.mjs +175 -0
  1048. package/tests/onboarding_state.mjs +346 -0
  1049. package/tests/one_active_claim_per_session.mjs +111 -0
  1050. package/tests/orphaned_claim_ship.mjs +135 -0
  1051. package/tests/override_attribution.mjs +142 -0
  1052. package/tests/override_request_ownership.mjs +235 -0
  1053. package/tests/package_core.mjs +435 -0
  1054. package/tests/parallel-load.mjs +402 -0
  1055. package/tests/path_match.mjs +62 -0
  1056. package/tests/permission_path.mjs +151 -0
  1057. package/tests/platform_boot.mjs +167 -0
  1058. package/tests/pre_compact.mjs +60 -0
  1059. package/tests/preview_server.mjs +127 -0
  1060. package/tests/profile_route.mjs +197 -0
  1061. package/tests/provision.mjs +805 -0
  1062. package/tests/provisioning_github_app_routes.mjs +98 -0
  1063. package/tests/public_mirror_export.mjs +124 -0
  1064. package/tests/public_task_resolver.mjs +112 -0
  1065. package/tests/publish_branch_route.mjs +422 -0
  1066. package/tests/publish_manifest.mjs +184 -0
  1067. package/tests/publish_reconciler.mjs +728 -0
  1068. package/tests/rank_gate.mjs +463 -0
  1069. package/tests/rate_limit_public.mjs +217 -0
  1070. package/tests/read_session_export.mjs +89 -0
  1071. package/tests/recall_cli.mjs +82 -0
  1072. package/tests/recall_hints.mjs +119 -0
  1073. package/tests/recent_shipped_window.mjs +80 -0
  1074. package/tests/regrade_eligibility.mjs +202 -0
  1075. package/tests/render_prefs.mjs +122 -0
  1076. package/tests/repo_map.mjs +92 -0
  1077. package/tests/reward_gate.mjs +284 -0
  1078. package/tests/route_rank_check.mjs +243 -0
  1079. package/tests/route_validation.mjs +490 -0
  1080. package/tests/routine_timers.mjs +72 -0
  1081. package/tests/rubric_fixtures.mjs +140 -0
  1082. package/tests/run_routine.mjs +69 -0
  1083. package/tests/sandbox_stage.mjs +582 -0
  1084. package/tests/scrubber_corpus.mjs +257 -0
  1085. package/tests/search_chunker.mjs +209 -0
  1086. package/tests/search_ingest.mjs +96 -0
  1087. package/tests/search_ingest_db.mjs +88 -0
  1088. package/tests/search_isolation.mjs +204 -0
  1089. package/tests/search_route.mjs +95 -0
  1090. package/tests/secret_box.mjs +189 -0
  1091. package/tests/security_docs.mjs +186 -0
  1092. package/tests/security_route.mjs +108 -0
  1093. package/tests/served_paths_shipped.mjs +118 -0
  1094. package/tests/session-cards.mjs +142 -0
  1095. package/tests/session_506_regression.mjs +69 -0
  1096. package/tests/session_caps.mjs +238 -0
  1097. package/tests/session_cost.mjs +133 -0
  1098. package/tests/session_digest.mjs +73 -0
  1099. package/tests/session_eval.mjs +386 -0
  1100. package/tests/session_index.mjs +105 -0
  1101. package/tests/session_records.mjs +369 -0
  1102. package/tests/session_search.mjs +78 -0
  1103. package/tests/session_start_freshness.mjs +123 -0
  1104. package/tests/setup_smoke.mjs +154 -0
  1105. package/tests/ship_autolinkify.mjs +60 -0
  1106. package/tests/ship_automerge_injection.mjs +174 -0
  1107. package/tests/ship_broadcast_routing.mjs +125 -0
  1108. package/tests/ship_broadcast_server.mjs +175 -0
  1109. package/tests/ship_card.mjs +280 -0
  1110. package/tests/ship_ci_deploy.mjs +374 -0
  1111. package/tests/ship_preflight.mjs +368 -0
  1112. package/tests/ship_premerge.mjs +75 -0
  1113. package/tests/ship_resume.mjs +129 -0
  1114. package/tests/ship_upload_skip.mjs +89 -0
  1115. package/tests/ship_zero_commit.mjs +60 -0
  1116. package/tests/single_host_hall.mjs +98 -0
  1117. package/tests/skill_prefs.mjs +150 -0
  1118. package/tests/smoke_preflight.mjs +66 -0
  1119. package/tests/snapshot_picker.mjs +233 -0
  1120. package/tests/sound_prefs.mjs +198 -0
  1121. package/tests/sprite_facing.mjs +35 -0
  1122. package/tests/status-probe.mjs +89 -0
  1123. package/tests/status_backfill.mjs +94 -0
  1124. package/tests/stop_refcheck.mjs +127 -0
  1125. package/tests/streak.mjs +175 -0
  1126. package/tests/subdomain_auth.mjs +58 -0
  1127. package/tests/task_classifier_vocab.mjs +41 -0
  1128. package/tests/task_create_idempotency.mjs +96 -0
  1129. package/tests/task_goal_link.mjs +93 -0
  1130. package/tests/task_ownership_gate.mjs +125 -0
  1131. package/tests/terrain_picker.mjs +298 -0
  1132. package/tests/touches_overlap.mjs +75 -0
  1133. package/tests/triage_development.mjs +140 -0
  1134. package/tests/turn_in_place.mjs +280 -0
  1135. package/tests/upgrade.mjs +357 -0
  1136. package/tests/verify_task_guard.mjs +64 -0
  1137. package/tests/version_close.mjs +76 -0
  1138. package/tests/versions_route.mjs +93 -0
  1139. package/tests/walkability.mjs +188 -0
  1140. package/tests/wandering_prefs.mjs +176 -0
  1141. package/tests/worktree_disambiguation.mjs +105 -0
  1142. package/tests/worktree_guard.mjs +90 -0
@@ -0,0 +1,3618 @@
1
+ #!/usr/bin/env node
2
+ // Builder ship — three-step lifecycle (Phase 5, 2026-05-10):
3
+ //
4
+ // 1. completed: builder declared work done. Always reached.
5
+ // 2. confirmed: smoke green. Credits land here.
6
+ // 3. shipped: branch merged into main + deployed. Visible in production.
7
+ //
8
+ // /builder-ship runs steps 1 + 2 inline. If step 2 succeeds (auto-confirm),
9
+ // it chains into step 3 by pushing the branch, merging it on main, smoke-
10
+ // testing, and running ~/deploy.sh. If step 2 fails, the task stays at
11
+ // 'completed' for human follow-up; if step 3 fails (conflicts, smoke red),
12
+ // the task stays at 'confirmed' for /merge-mode to land later.
13
+ //
14
+ // Usage:
15
+ // node scripts/gds/ship.js <task-id> --notes "handoff markdown" --summary "value-summary line"
16
+ // node scripts/gds/ship.js <task-id> --no-merge # stop after confirm; skip auto-merge
17
+ // node scripts/gds/ship.js <task-id> --db-only # deliberate zero-commit / direct-SQL ship (task 391):
18
+ // # work was a write straight to the prod DB, nothing to
19
+ // # merge. Without it, a branch with no commits ahead of
20
+ // # main is REFUSED, not silently shipped.
21
+ // node scripts/gds/ship.js <task-id> --force # override behind-main preflight warning
22
+ // node scripts/gds/ship.js <task-id> --skip-grade # bypass subagent grader; manual-confirm via /confirm
23
+ // # Only for: (a) rolling-deploy windows when the
24
+ // # /tasks/:id/grade endpoint isn't live yet, or
25
+ // # (b) explicit "the grader is down" recovery.
26
+ // # NOT a routine flag — every use writes a
27
+ // # task_grades row with grader_kind='lars' so the
28
+ // # audit trail shows it was a manual override.
29
+ // node scripts/gds/ship.js <task-id> --allow-empty # ship a task with NO committed artifact (a pure
30
+ // # DB-only / no-op task). Required to get past the
31
+ // # empty-diff guard; confirms directly (no diff to
32
+ // # grade). REFUSED if the working tree is dirty —
33
+ // # that means you forgot to commit (task 1363/1365).
34
+ // node scripts/gds/ship.js <task-id> --regrade # re-grade a task STUCK at `completed` (a ship
35
+ // # whose grade failed): re-runs ONLY the grade step
36
+ // # against the committed diff, no claim required.
37
+ // # Caller must have last held the claim (or be
38
+ // # Archon). The cheap default after fixing grader
39
+ // # findings — closes the task-595-regrade-gap (#678).
40
+ // node scripts/gds/ship.js <task-id> --skip-stage # skip the sandbox-first review gate (#927,
41
+ // # ADR 0046). On a dev box, game-surface diffs are
42
+ // # otherwise staged to the builder's own live
43
+ // # preview (sandbox-<login>, ADR 0044) for a
44
+ // # browser review BEFORE the claim resolves.
45
+ // node scripts/gds/ship.js <task-id> --approved # record your sandbox sign-off (#1048): re-run with
46
+ // # this AFTER reviewing the staged change at the
47
+ // # sandbox URL. Persists a sandbox_review row on the
48
+ // # task. Honored only when the game state is already
49
+ // # staged-fresh (a re-run); it can't pre-approve.
50
+
51
+ const crypto = require('node:crypto');
52
+ const { execSync, execFileSync, spawnSync } = require('node:child_process');
53
+ const fs = require('node:fs');
54
+ const os = require('node:os');
55
+ const path = require('node:path');
56
+ const readline = require('node:readline');
57
+ const { cliClient, requireSession, loadSession, printOnboardingNudge, arg } = require('./cli-lib');
58
+ // ship-preflight + merge-lock carved to modules/lifecycle/ (BV1.R86). A script may
59
+ // require a module directly (it's outside the kernel boundary — the grading/economy
60
+ // CLI precedent).
61
+ const { trackedDirty, classifyEmptyShip, cleanupMacFinderDupRefs, findMainWorktree, closeoutPlan } = require('../../modules/lifecycle/ship-preflight');
62
+ const { scopeForModule, isModuleKey } = require('../../src/bongos/module-scope-map');
63
+ const { matchOne } = require('../../src/bongos/path-match');
64
+ // touches[] cleanup [4/8] (task 879): the predictive drift scanner
65
+ // (src/bongos/touches-scan.js) is gone — git merge + /merge-mode are the
66
+ // ground-truth collision detector. ship's gate is now smoke-only.
67
+ const mergeLock = require('../../modules/lifecycle/merge-lock');
68
+ const mergeRegen = require('./git-merge-regen');
69
+ const grader = require('../../modules/grading/grader');
70
+ const { runShipGate } = require('./sandbox-stage');
71
+ // task 1045: the ship-broadcast channel router lives in a shared module so the
72
+ // laptop-local path here and the server-mediated publish path (ADR 0055) route by
73
+ // the exact same rule. Re-exported below for tests/ship_broadcast_routing.mjs.
74
+ const { chooseBroadcastTargets } = require('../../modules/discord/ship-broadcast');
75
+
76
+ // V3.R18 (#209): cap the diff we feed the Opus subagent grader. Very large
77
+ // diffs blow up prompt size for marginal grading benefit — the subagent has
78
+ // Read access to the post-resolve working tree anyway, so it can pull the
79
+ // full text of any file it wants to inspect closely.
80
+ const GRADER_DIFF_CHAR_CAP = 250_000;
81
+ const GRADER_TIMEOUT_MS = 240_000; // 4 min — Opus on a big diff with Read tool calls
82
+
83
+ // Where auto-merge runs — the worktree that has `main` checked out, not the
84
+ // feature worktree this script lives in. Falls back to two-levels-up if we
85
+ // can't ask git (e.g. fresh clone with no `main` worktree set up yet).
86
+ const REPO_ROOT = findMainWorktree(__dirname) || path.resolve(__dirname, '..', '..');
87
+ const { playSound } = require(path.resolve(__dirname, '../../.claude/sounds/play.js'));
88
+ const DROPLET = 'lars@104.236.254.243';
89
+ const BEHIND_MAIN_LIMIT = 5;
90
+
91
+ // arg() — the =-aware flag-value parser now lives in cli-lib (the canonical
92
+ // single source, C11); imported above. ship.js's call sites pass `args`
93
+ // explicitly, which cli-lib.arg() accepts (its default is process.argv.slice(2)).
94
+
95
+ // ---------- Surprise / learning-capture helpers (Task #32) ----------
96
+ //
97
+ // Forced "anything surprise you?" gate so we never miss a learning. The flag
98
+ // can be supplied explicitly (--surprise "text" / --surprise none / --no-surprise),
99
+ // prompted interactively when stdin is a TTY, or default to no-surprise with a
100
+ // stderr warning in non-interactive contexts.
101
+
102
+ function promptSurprise() {
103
+ return new Promise((resolve) => {
104
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
105
+ rl.question('Anything surprise you in this work? (Enter for none, or describe): ', (answer) => {
106
+ rl.close();
107
+ resolve(answer == null ? '' : String(answer).trim());
108
+ });
109
+ });
110
+ }
111
+
112
+ function deriveTagsFromPaths(paths) {
113
+ // Each entry is a path/glob; take its leading folder segment as a tag.
114
+ // touches[] cleanup [3/8] (task 878): renamed from deriveTagsFromTouches — it
115
+ // now runs over the committed changed-files (reality), not the predicted
116
+ // touches[]. Same folder-segment logic, just a more honest input.
117
+ const tags = new Set();
118
+ for (const t of paths || []) {
119
+ if (!t) continue;
120
+ const first = String(t).split('/')[0].trim();
121
+ if (first && first !== '*' && first !== '**') tags.add(first);
122
+ }
123
+ if (tags.size === 0) tags.add('pms');
124
+ return [...tags];
125
+ }
126
+
127
+ async function captureSurpriseLearning(surpriseText, claim, taskId) {
128
+ const text = String(surpriseText).trim();
129
+ const title = text.length > 80 ? text.slice(0, 77) + '...' : text;
130
+ // touches[] cleanup [3/8] (task 878): tag + relate the learning by what was
131
+ // ACTUALLY shipped (the committed diff), not the predicted touches[]. Fall
132
+ // back to touches[] only when the diff is unavailable (no git / empty baseline).
133
+ const baseline = computeGraderBaseline(claim);
134
+ const changedFiles = computeChangedFiles(baseline);
135
+ const relatedFiles = changedFiles.length > 0 ? changedFiles : (claim.touches || []);
136
+ const tags = deriveTagsFromPaths(relatedFiles);
137
+ const r = await (await cliClient()).learnings.postLearnings({
138
+ body: {
139
+ title,
140
+ body_md: text,
141
+ tags,
142
+ related_files: relatedFiles,
143
+ source: 'ship-surprise',
144
+ source_ref: `task-${taskId}-claim-${claim.claim_id}`,
145
+ task_id: taskId,
146
+ },
147
+ });
148
+ if (r.status === 200 && r.data?.skipped) {
149
+ console.log(`📝 Learning already captured for this claim (skipped duplicate).`);
150
+ return true;
151
+ }
152
+ if (r.status !== 201) {
153
+ console.error(` learning capture failed (${r.status}):`, r.data);
154
+ return false;
155
+ }
156
+ console.log(`📝 Captured learning: ${title}`);
157
+ return true;
158
+ }
159
+
160
+ // ---------- Memory sync-on-ship (Task #274 / V3.R55) ----------
161
+ //
162
+ // After a successful ship, push the builder's local Claude Code memory dir to
163
+ // the server (POST /api/gds/memory/sync), so a builder's MEMORY.md +
164
+ // feedback_*.md follow them across machines and survive a dead laptop. This is
165
+ // the push half of ADR 0024's cloneable-memory chain; #275 wires the pull side.
166
+ //
167
+ // Convention for locating the memory dir (NO hardcoded identity — CLAUDE.md §10):
168
+ // Claude Code stores per-project memory under
169
+ // <homedir>/.claude/projects/<encoded-project-path>/memory/
170
+ // where <encoded-project-path> is the project's ABSOLUTE path with every '/'
171
+ // and '.' replaced by '-' (e.g. /Users/x/Desktop/Claude-OTB →
172
+ // -Users-x-Desktop-Claude-OTB). We derive the project path from the MAIN
173
+ // worktree root (REPO_ROOT), not this ephemeral feature worktree, because the
174
+ // memory dir is keyed to the canonical project checkout. CLAUDE_CONFIG_DIR
175
+ // overrides the ~/.claude base when set (matches Claude Code's own behavior).
176
+ //
177
+ // Conflict policy: storeMemoryFile is an unconditional UPSERT (last-write-wins,
178
+ // server clock — ADR 0024 §3), so we push every file and let the server
179
+ // overwrite. Smarter mtime-diffing (skip files the server already has newer) is
180
+ // a possible follow-up; for #274 a full push is correct and cheap (KB of md).
181
+ //
182
+ // BEST-EFFORT: any failure here is logged as a warning and swallowed — a memory
183
+ // sync hiccup (dir missing, API down, quota) must NEVER fail or undo a ship.
184
+
185
+ function claudeConfigBase() {
186
+ // Claude Code honors CLAUDE_CONFIG_DIR as the base for its state dir; default
187
+ // is ~/.claude. Resolve via os.homedir() so this works for any user.
188
+ return process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude');
189
+ }
190
+
191
+ function encodeProjectPath(absProjectPath) {
192
+ // Claude Code's encoding: replace every '/' and '.' with '-'.
193
+ return absProjectPath.replace(/[/.]/g, '-');
194
+ }
195
+
196
+ // resolveTranscriptPath — find this session's transcript .jsonl. Claude Code
197
+ // keys the transcript to the project dir of the CWD where the session RUNS, so
198
+ // a session in a git worktree stores it under the WORKTREE-encoded dir, not the
199
+ // main checkout. (6D.1 #540 shipped resolving only REPO_ROOT, which silently
200
+ // missed every worktree session — #551.) We try the session-local dir
201
+ // (process.cwd()) first, then REPO_ROOT as a fallback. Pure (exists + base
202
+ // injectable for tests). Returns the first existing path, or null.
203
+ function resolveTranscriptPath(sessionId, projectDirs, opts = {}) {
204
+ const exists = opts.existsFn || fs.existsSync;
205
+ const base = opts.base || claudeConfigBase();
206
+ for (const dir of projectDirs) {
207
+ if (!dir) continue;
208
+ const p = path.join(base, 'projects', encodeProjectPath(path.resolve(dir)), `${sessionId}.jsonl`);
209
+ if (exists(p)) return p;
210
+ }
211
+ return null;
212
+ }
213
+
214
+ function resolveMemoryDir() {
215
+ // REPO_ROOT is the main worktree (canonical project checkout). The memory dir
216
+ // is keyed to that path, not the per-task feature worktree we're shipping from.
217
+ const encoded = encodeProjectPath(path.resolve(REPO_ROOT));
218
+ return path.join(claudeConfigBase(), 'projects', encoded, 'memory');
219
+ }
220
+
221
+ // Enumerate the memory dir into [{ path, content, mtime }]. `path` is RELATIVE
222
+ // to the memory dir (matches the server's (builder_id, relative-path) key).
223
+ // Recurses so nested memory files (if Claude Code ever nests them) are caught.
224
+ // Returns [] if the dir doesn't exist (not all builders have memory).
225
+ function collectMemoryFiles(memDir) {
226
+ if (!fs.existsSync(memDir)) return [];
227
+ const out = [];
228
+ const walk = (absDir, relPrefix) => {
229
+ let entries;
230
+ try {
231
+ entries = fs.readdirSync(absDir, { withFileTypes: true });
232
+ } catch (_) {
233
+ return;
234
+ }
235
+ for (const ent of entries) {
236
+ const abs = path.join(absDir, ent.name);
237
+ const rel = relPrefix ? `${relPrefix}/${ent.name}` : ent.name;
238
+ if (ent.isDirectory()) {
239
+ walk(abs, rel);
240
+ } else if (ent.isFile()) {
241
+ try {
242
+ const content = fs.readFileSync(abs, 'utf8');
243
+ const mtime = fs.statSync(abs).mtime.toISOString();
244
+ // Use POSIX-style relative paths regardless of OS so the server key
245
+ // is stable across machines.
246
+ out.push({ path: rel.split(path.sep).join('/'), content, mtime });
247
+ } catch (_) {
248
+ /* skip unreadable file — best-effort */
249
+ }
250
+ }
251
+ }
252
+ };
253
+ walk(memDir, '');
254
+ return out;
255
+ }
256
+
257
+ async function syncMemoryOnShip() {
258
+ try {
259
+ const memDir = resolveMemoryDir();
260
+ const files = collectMemoryFiles(memDir);
261
+ if (files.length === 0) {
262
+ // Silent-ish: a builder with no memory dir is normal. One quiet line so
263
+ // the absence is visible in the ship log without looking like an error.
264
+ console.log(' memory sync: no local memory dir found — skipping (this is fine).');
265
+ return;
266
+ }
267
+ console.log(` syncing ${files.length} memory file${files.length === 1 ? '' : 's'} to the server...`);
268
+ const r = await (await cliClient({ allowUnauthenticated: true })).memory.postMemorySync({ body: { files } });
269
+ if (r.status === 401) {
270
+ console.log(' memory sync: skipped (session not valid for sync; non-blocking).');
271
+ return;
272
+ }
273
+ const d = r.data || {};
274
+ if (r.ok) {
275
+ const errN = Array.isArray(d.errors) ? d.errors.length : 0;
276
+ console.log(
277
+ ` memory sync: ${d.stored ?? 0} stored, ${d.skipped ?? 0} skipped${errN ? `, ${errN} error(s)` : ''}.`
278
+ );
279
+ } else if (r.status === 413) {
280
+ console.log(` memory sync: quota/size limit hit (${d.error || 'quota'}) — prune memory and re-sync. Non-blocking; ship is complete.`);
281
+ } else {
282
+ console.log(` memory sync: server returned ${r.status} (non-blocking):`, d.error || d);
283
+ }
284
+ } catch (e) {
285
+ // Any error at all — network, fs, anything — must not fail the ship.
286
+ console.log(' memory sync failed (non-blocking):', e && e.message ? e.message : e);
287
+ }
288
+ }
289
+
290
+ // ---------- Session upload-on-ship (6D.1 / ADR 0027) ----------
291
+ //
292
+ // After a successful ship, upload a COMPACT, secret-scrubbed digest of this
293
+ // Claude Code session (POST /api/gds/sessions), so the BFG (a local Claude Code
294
+ // routine — 6D.3) can later search the corpus (6D.2) and deep-dive the wasteful
295
+ // sessions. We never upload raw transcript CONTENT — only per-turn metadata
296
+ // (role, tool name, error flag, token usage, length) + short scrubbed snippets
297
+ // + token aggregates. That keeps the row small and the secret surface minimal
298
+ // (ADR 0027). BEST-EFFORT: any failure is a warning, never a ship failure —
299
+ // exactly like syncMemoryOnShip.
300
+
301
+ // The transcript-grade secret scrubber + its ADR-0022 internals now live in
302
+ // modules/security/secret-scrub.js — extracted (task 1211 / GDS-V4 R72) so the
303
+ // public-mirror export pipeline can reuse the ONE copy of the secret shapes
304
+ // without requiring ship.js (whose grader / Discord / sound / session deps are
305
+ // absent in a bare CI job). Imported + re-exported here so this file's public
306
+ // surface and tests/scrubber_corpus.mjs are unchanged. See that module for the
307
+ // recall-first design (a missed secret is a compromise; over-redaction is cosmetic).
308
+ const {
309
+ REDACTED, scrubSecrets, shannonEntropy, looksHighEntropySecret,
310
+ redactHighEntropy, collectEnvSecrets,
311
+ } = require('../../modules/security/secret-scrub');
312
+
313
+ // snippetOf — short, single-line, scrubbed excerpt for footgun context.
314
+ function snippetOf(text, extraSecrets, max = 160) {
315
+ if (typeof text !== 'string' || text.length === 0) return undefined;
316
+ const flat = text.replace(/\s+/g, ' ').trim();
317
+ if (!flat) return undefined;
318
+ const cut = flat.length > max ? flat.slice(0, max) : flat;
319
+ const scrubbed = scrubSecrets(cut, extraSecrets);
320
+ return scrubbed || undefined;
321
+ }
322
+
323
+ // buildSessionDigest — turn parsed Claude Code transcript entries into
324
+ // { startedAt, endedAt, durationSeconds, totalTokens, metrics, detail }.
325
+ // Pure (no I/O) + exported for unit tests. `detail` is per-turn metadata + short
326
+ // scrubbed snippets, capped at maxTurns (head+tail kept when over).
327
+ // Harness guardrail retries (task 959) — an is_error tool_result that is NOT real
328
+ // friction but the agent harness rejecting a tool call: an Edit/Write before the
329
+ // file was Read ("File has not been read yet"), or a foreground sleep ("Blocked:
330
+ // sleep"). These were counted in tool_error_count, inflating it and skewing the
331
+ // BFG's order=tool_errors ranking (top sessions ranked on benign retries). They
332
+ // now go into a separate harness_retry_count so tool_error_count stays NET — and
333
+ // because the sessions/search ordering reads metrics->>'tool_error_count'
334
+ // (src/bongos/db.js), that ordering automatically uses the net figure with no route
335
+ // change. (ADR 0027 lineage — the 6D session evaluator.)
336
+ const HARNESS_RETRY_RE =
337
+ /File has not been read yet|Read it first before (?:writing|editing)|Blocked:\s*sleep|sleep is blocked in (?:the )?foreground/i;
338
+
339
+ // task 1676 — the family bucket unlabeled token-bearing turns are folded into so
340
+ // the cost-plus reward (ADR 0054) prices the real spend instead of rounding to $0.
341
+ // A concrete, priceable model id (llm-pricing.js maps it to the opus family by
342
+ // substring) — NOT a fabricated dollar figure; it carries the REAL token counts
343
+ // ship.js already parsed, priced server-side like any other bucket.
344
+ const UNATTRIBUTED_MODEL = 'claude-opus-4-8';
345
+
346
+ function buildSessionDigest(entries, opts = {}) {
347
+ const extra = opts.extraSecrets || [];
348
+ const MAX_TURNS = opts.maxTurns || 4000;
349
+ const m = {
350
+ assistant_turns: 0,
351
+ user_turns: 0,
352
+ tool_use_count: 0,
353
+ tool_result_count: 0,
354
+ tool_error_count: 0, // NET real errors (harness guardrail retries excluded)
355
+ harness_retry_count: 0, // benign harness guardrail retries (task 959)
356
+ tokens: { input: 0, output: 0, cache_read: 0, cache_creation: 0 },
357
+ model_usage: {}, // per-model token buckets for the cost-plus reward (task 1005)
358
+ // task 1676: tokens from turns with NO model id (not '<synthetic>'). Folded
359
+ // into a priceable bucket after the loop, then stripped from the digest.
360
+ _unlabeled: { input: 0, output: 0, cache_read: 0, cache_creation: 0 },
361
+ };
362
+ const detail = [];
363
+ let firstTs = null;
364
+ let lastTs = null;
365
+ let i = 0;
366
+ for (const e of Array.isArray(entries) ? entries : []) {
367
+ if (!e || typeof e !== 'object') continue;
368
+ const ts = typeof e.timestamp === 'string' ? e.timestamp : null;
369
+ if (ts) {
370
+ if (!firstTs) firstTs = ts;
371
+ lastTs = ts;
372
+ }
373
+ const msg = e.message;
374
+ if (e.type === 'assistant' && msg) {
375
+ m.assistant_turns++;
376
+ const u = msg.usage || {};
377
+ const tin = u.input_tokens || 0;
378
+ const tout = u.output_tokens || 0;
379
+ const tcr = u.cache_read_input_tokens || 0;
380
+ const tcc = u.cache_creation_input_tokens || 0;
381
+ m.tokens.input += tin;
382
+ m.tokens.output += tout;
383
+ m.tokens.cache_read += tcr;
384
+ m.tokens.cache_creation += tcc;
385
+ // task 1005: per-model bucket for the cost-plus token reward, reusing the
386
+ // tin/tout/tcr/tcc already parsed above (no second pass). Skip synthetic
387
+ // turns (a grader 429 etc. emits no real model id AND no real spend).
388
+ const model = msg.model;
389
+ if (model === '<synthetic>') {
390
+ // synthetic: no real spend — exclude from BOTH model_usage and the
391
+ // task-1676 unlabeled-remainder fold below (don't price phantom tokens).
392
+ } else if (model) {
393
+ const mu = m.model_usage[model] || (m.model_usage[model] =
394
+ { input: 0, output: 0, cache_read: 0, cache_creation: 0 });
395
+ mu.input += tin; mu.output += tout; mu.cache_read += tcr; mu.cache_creation += tcc;
396
+ } else {
397
+ // task 1676: a token-bearing turn with NO model id. Previously dropped
398
+ // from model_usage (→ total_tokens>0 but priced $0 on the dev-box/CI
399
+ // path; ADR 0097). Accumulate the unlabeled tokens so the digest can
400
+ // attribute them to a real, priceable family bucket after the loop.
401
+ m._unlabeled.input += tin; m._unlabeled.output += tout;
402
+ m._unlabeled.cache_read += tcr; m._unlabeled.cache_creation += tcc;
403
+ }
404
+ const tools = [];
405
+ let textLen = 0;
406
+ let firstText = '';
407
+ if (Array.isArray(msg.content)) {
408
+ for (const c of msg.content) {
409
+ if (c && c.type === 'tool_use') {
410
+ m.tool_use_count++;
411
+ tools.push(String(c.name || '?'));
412
+ } else if (c && c.type === 'text' && typeof c.text === 'string') {
413
+ textLen += c.text.length;
414
+ if (!firstText) firstText = c.text;
415
+ }
416
+ }
417
+ }
418
+ const row = { i, role: 'assistant', ts, in: tin, out: tout, cache_read: tcr, cache_creation: tcc };
419
+ if (tools.length) row.tools = tools;
420
+ if (textLen) row.text_len = textLen;
421
+ const snip = snippetOf(firstText, extra);
422
+ if (snip) row.snippet = snip;
423
+ detail.push(row);
424
+ i++;
425
+ } else if (e.type === 'user' && msg) {
426
+ m.user_turns++;
427
+ let results = 0;
428
+ let errors = 0;
429
+ let harnessRetries = 0;
430
+ let firstResult = '';
431
+ if (Array.isArray(msg.content)) {
432
+ for (const c of msg.content) {
433
+ if (c && c.type === 'tool_result') {
434
+ results++;
435
+ m.tool_result_count++;
436
+ const text = typeof c.content === 'string'
437
+ ? c.content
438
+ : Array.isArray(c.content)
439
+ ? c.content.map((p) => (p && typeof p.text === 'string' ? p.text : '')).join(' ')
440
+ : '';
441
+ if (c.is_error) {
442
+ // Split benign harness guardrail retries out of the net error count
443
+ // so order=tool_errors ranks on real friction (task 959).
444
+ if (HARNESS_RETRY_RE.test(text)) {
445
+ harnessRetries++;
446
+ m.harness_retry_count++;
447
+ } else {
448
+ errors++;
449
+ m.tool_error_count++;
450
+ }
451
+ }
452
+ if (!firstResult) firstResult = text;
453
+ }
454
+ }
455
+ }
456
+ const row = { i, role: 'user', ts };
457
+ if (results) row.tool_results = results;
458
+ if (errors) row.tool_errors = errors;
459
+ if (harnessRetries) row.harness_retries = harnessRetries;
460
+ const snip = snippetOf(firstResult, extra);
461
+ if (snip) row.snippet = snip;
462
+ detail.push(row);
463
+ i++;
464
+ }
465
+ }
466
+ m.total_tokens = m.tokens.input + m.tokens.output + m.tokens.cache_read + m.tokens.cache_creation;
467
+ // task 1676 — cost-plus reward zero-out fix. The cost-plus session reward
468
+ // (ADR 0054) prices `model_usage` server-side. model_usage is keyed on msg.model
469
+ // and SKIPS any token-bearing turn whose model id is missing. On the dev-box/CI
470
+ // agent path that gap was the difference between total_tokens>0 (so the digest
471
+ // passes the route's empty-session guard + gets stored) and model_usage={} (so
472
+ // priceModelUsage returns $0 → 0 drachmae) — ADR 0097's "upload no priced model
473
+ // usage" symptom. Fold the unlabeled remainder (NOT synthetic turns) into a real,
474
+ // priceable family bucket so the cost-plus reward reflects the true spend rather
475
+ // than silently dropping to $0. opus is the conservative family for an unlabeled
476
+ // turn (matches llm-pricing.js's unknown-model fallback) — this instance runs
477
+ // opus by default, and pricing an unlabeled turn as opus never UNDER-pays.
478
+ const un = m._unlabeled;
479
+ if (un.input + un.output + un.cache_read + un.cache_creation > 0) {
480
+ const fb = m.model_usage[UNATTRIBUTED_MODEL] || (m.model_usage[UNATTRIBUTED_MODEL] =
481
+ { input: 0, output: 0, cache_read: 0, cache_creation: 0 });
482
+ fb.input += un.input; fb.output += un.output;
483
+ fb.cache_read += un.cache_read; fb.cache_creation += un.cache_creation;
484
+ }
485
+ delete m._unlabeled; // scratch — never persisted to the digest
486
+ let boundedDetail = detail;
487
+ if (detail.length > MAX_TURNS) {
488
+ const head = Math.floor(MAX_TURNS * 0.7);
489
+ const tail = MAX_TURNS - head;
490
+ boundedDetail = detail.slice(0, head).concat(detail.slice(detail.length - tail));
491
+ m.detail_truncated = true;
492
+ m.detail_total_turns = detail.length;
493
+ }
494
+ let durationSeconds = null;
495
+ if (firstTs && lastTs) {
496
+ const d = (Date.parse(lastTs) - Date.parse(firstTs)) / 1000;
497
+ if (Number.isFinite(d) && d >= 0) durationSeconds = Math.round(d);
498
+ }
499
+ return {
500
+ startedAt: firstTs,
501
+ endedAt: lastTs,
502
+ durationSeconds,
503
+ totalTokens: m.total_tokens,
504
+ metrics: m,
505
+ detail: boundedDetail,
506
+ };
507
+ }
508
+
509
+ // classifyUploadSkip — PURE decision for whether the session upload must skip,
510
+ // and the clear, actionable reason why. Separated from I/O so it is unit-tested
511
+ // (tests/ship_upload_skip.mjs). Every reason names the cause AND states the
512
+ // consequence plainly: a skip means this ship is absent from the BFG session
513
+ // corpus — the silent gap #608 surfaced, this (#617) makes it loud + auditable.
514
+ // Takes already-resolved preconditions so it stays free of env + fs reads.
515
+ function classifyUploadSkip({ sessionId, sessionIdSafe, transcriptPath, pathsTried }) {
516
+ const tail = 'This ship will NOT appear in the BFG session corpus.';
517
+ if (!sessionId) {
518
+ return {
519
+ skip: true,
520
+ code: 'no_session_id',
521
+ reason:
522
+ 'CLAUDE_CODE_SESSION_ID is not set — this ship did not run inside an interactive Claude Code ' +
523
+ `session (e.g. an autonomous / merge-mode / cron context), so there is no transcript to digest. ${tail}`,
524
+ };
525
+ }
526
+ if (!sessionIdSafe) {
527
+ return {
528
+ skip: true,
529
+ code: 'unsafe_session_id',
530
+ reason: `CLAUDE_CODE_SESSION_ID is not a plain token, refusing to interpolate it into a filesystem path. ${tail}`,
531
+ };
532
+ }
533
+ if (!transcriptPath) {
534
+ const where = Array.isArray(pathsTried) && pathsTried.length ? ` Looked in: ${pathsTried.join(' ; ')}.` : '';
535
+ return {
536
+ skip: true,
537
+ code: 'transcript_not_found',
538
+ reason: `the session transcript .jsonl was not found for ${sessionId}.${where} ${tail}`,
539
+ };
540
+ }
541
+ return { skip: false };
542
+ }
543
+
544
+ // recordUploadSkip — surface a skipped/failed session upload LOUDLY (not the old
545
+ // quiet console.log that scrolled past + vanished in non-interactive runs) AND
546
+ // leave a durable, append-only trace so a missed capture is auditable after the
547
+ // fact. ~/.config/otb survives worktree churn (same home as the GDS session
548
+ // token). Best-effort: never throws into the ship path. opts.dir overrides the
549
+ // trace dir for tests. Returns the trace file path written, or null.
550
+ function recordUploadSkip(taskId, { code, reason }, opts = {}) {
551
+ console.log(` ⚠️ session upload SKIPPED [${code}]: ${reason}`);
552
+ try {
553
+ const dir = opts.dir || require('../../src/instance-config').configHome();
554
+ fs.mkdirSync(dir, { recursive: true });
555
+ const file = path.join(dir, 'session-upload-skips.jsonl');
556
+ const row = {
557
+ ts: new Date().toISOString(),
558
+ task_id: taskId != null && Number.isFinite(Number(taskId)) ? Number(taskId) : null,
559
+ session_id: process.env.CLAUDE_CODE_SESSION_ID || null,
560
+ code,
561
+ reason,
562
+ };
563
+ fs.appendFileSync(file, JSON.stringify(row) + '\n');
564
+ return file;
565
+ } catch (e) {
566
+ console.log(` (could not persist skip trace: ${e && e.message ? e.message : e})`);
567
+ return null;
568
+ }
569
+ }
570
+
571
+ async function uploadSessionOnShip(taskId) {
572
+ try {
573
+ const sessionId = process.env.CLAUDE_CODE_SESSION_ID;
574
+ // Path-traversal guard: the session id is interpolated into a filesystem
575
+ // path below, so reject anything that isn't a plain UUID-ish token. A
576
+ // hostile env value with `../` could otherwise point the read elsewhere.
577
+ const sessionIdSafe = !!sessionId && /^[A-Za-z0-9_-]+$/.test(sessionId);
578
+ // The transcript is keyed to the session's CWD (the worktree when shipping
579
+ // from one), so try that before REPO_ROOT (#551). Reconstruct the exact
580
+ // paths resolveTranscriptPath checks so a transcript-not-found skip can name
581
+ // precisely where it looked (actionable, not a bare "not found").
582
+ const dirsTried = [process.cwd(), REPO_ROOT].filter(Boolean);
583
+ const pathsTried = sessionIdSafe
584
+ ? dirsTried.map((d) =>
585
+ path.join(claudeConfigBase(), 'projects', encodeProjectPath(path.resolve(d)), `${sessionId}.jsonl`)
586
+ )
587
+ : [];
588
+ const transcriptPath = sessionIdSafe ? resolveTranscriptPath(sessionId, dirsTried) : null;
589
+
590
+ const decision = classifyUploadSkip({ sessionId, sessionIdSafe, transcriptPath, pathsTried });
591
+ if (decision.skip) {
592
+ recordUploadSkip(taskId, decision);
593
+ return null;
594
+ }
595
+ const raw = fs.readFileSync(transcriptPath, 'utf8');
596
+ const entries = [];
597
+ for (const line of raw.split('\n')) {
598
+ const t = line.trim();
599
+ if (!t) continue;
600
+ try {
601
+ entries.push(JSON.parse(t));
602
+ } catch (_) {
603
+ /* skip a malformed line — best-effort */
604
+ }
605
+ }
606
+ // Dynamic secrets to scrub: every secret-shaped env VALUE (Apple/MAC_* CI
607
+ // creds, a baked GDS/DO token, GITHUB_TOKEN, ANTHROPIC_API_KEY, …) plus this
608
+ // run's live GDS session token. Defense in depth on top of the pattern +
609
+ // entropy scrubbers — these literals get redacted even with no known shape.
610
+ const extraSecrets = collectEnvSecrets();
611
+ // loadSession() returns null when there's no session — unlike
612
+ // requireSession(), it never process.exit()s, so the best-effort /
613
+ // non-blocking contract holds even with no valid session.
614
+ const s = await loadSession();
615
+ if (s && s.token) extraSecrets.push(s.token);
616
+ const digest = buildSessionDigest(entries, { extraSecrets });
617
+ // task 1270: surface the session usage so the completion card can show
618
+ // Claude tokens + an estimated cost. Returned even when the POST below fails
619
+ // (we already have the digest) — the card is best-effort either way.
620
+ const usageOut = {
621
+ totalTokens: digest.totalTokens,
622
+ modelUsage: (digest.metrics && digest.metrics.model_usage) || {},
623
+ };
624
+ // drachmae_earned is computed SERVER-SIDE from the credit_log ledger
625
+ // (POST /sessions). ship.js no longer fetches or sends it — that removes an
626
+ // extra apiCall whose 401 path would process.exit(2) and break this
627
+ // best-effort, non-blocking contract.
628
+ const payload = {
629
+ session_id: sessionId,
630
+ task_ids: [Number(taskId)].filter(Number.isFinite),
631
+ total_tokens: digest.totalTokens,
632
+ duration_seconds: digest.durationSeconds,
633
+ started_at: digest.startedAt,
634
+ ended_at: digest.endedAt,
635
+ detail: digest.detail,
636
+ metrics: digest.metrics,
637
+ model_usage: digest.metrics.model_usage || {}, // per-model tokens → cost-plus reward (task 1005)
638
+ };
639
+ const r = await (await cliClient({ allowUnauthenticated: true })).sessions.postSessions({ body: payload });
640
+ if (r.ok) {
641
+ console.log(
642
+ ` session upload: stored digest (${digest.metrics.assistant_turns} assistant turns, ${digest.totalTokens} tokens, ${digest.metrics.tool_error_count} tool errors, ${digest.metrics.harness_retry_count} harness retries).`
643
+ );
644
+ } else if (r.status === 401) {
645
+ recordUploadSkip(taskId, {
646
+ code: 'session_invalid',
647
+ reason:
648
+ 'POST /api/gds/sessions returned 401 — the GDS session token is missing or invalid, so the ' +
649
+ 'digest was rejected. Re-auth with /builder-reauth. This ship will NOT appear in the BFG session corpus.',
650
+ });
651
+ } else {
652
+ recordUploadSkip(taskId, {
653
+ code: `http_${r.status}`,
654
+ reason:
655
+ `POST /api/gds/sessions returned ${r.status}${r.data && r.data.error ? ` (${r.data.error})` : ''} — ` +
656
+ 'the API rejected or could not store the digest. This ship will NOT appear in the BFG session corpus.',
657
+ });
658
+ }
659
+ return usageOut;
660
+ } catch (e) {
661
+ recordUploadSkip(taskId, {
662
+ code: 'exception',
663
+ reason:
664
+ `session upload threw before completing: ${e && e.message ? e.message : e}. ` +
665
+ 'This ship will NOT appear in the BFG session corpus.',
666
+ });
667
+ return null;
668
+ }
669
+ }
670
+
671
+ function gitOk(cmd, opts = {}) {
672
+ try {
673
+ return execSync(cmd, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], ...opts }).trim();
674
+ } catch (_) {
675
+ return null;
676
+ }
677
+ }
678
+
679
+ function shellOk(cmd, opts = {}) {
680
+ try {
681
+ execSync(cmd, { stdio: 'inherit', ...opts });
682
+ return true;
683
+ } catch (_) {
684
+ return false;
685
+ }
686
+ }
687
+
688
+ // argvOk / argvOut — the two no-shell exec primitives behind the git/gh wrappers.
689
+ // Run `<cmd> <args...>` so each arg reaches the program as a literal argv element
690
+ // (never interpolated into a shell string) — the discipline that closes the
691
+ // autoMerge injection (#681), since branch names + DB-sourced value_summary flow
692
+ // through here. `shell: false` is forced AFTER the opts spread so a caller can
693
+ // never pass `{ shell: true }` and silently re-enable shell interpretation — the
694
+ // no-shell guarantee is the whole point and must not be overridable.
695
+ // argvOk — exit status only → boolean (gitArgvOk / ghArgvOk). stdio 'inherit'.
696
+ // argvOut — capture trimmed stdout → string|null (ghJson / fetchDeployedVersion).
697
+ // Default stdio captures BOTH stdout and stderr; a caller that must
698
+ // DISCARD stderr passes its own `stdio: ['ignore','pipe','ignore']`.
699
+ function argvOk(cmd, args, opts = {}) {
700
+ try {
701
+ execFileSync(cmd, args, { stdio: 'inherit', ...opts, shell: false });
702
+ return true;
703
+ } catch (_) {
704
+ return false;
705
+ }
706
+ }
707
+
708
+ function argvOut(cmd, args, opts = {}) {
709
+ try {
710
+ return execFileSync(cmd, args, {
711
+ encoding: 'utf8',
712
+ stdio: ['ignore', 'pipe', 'pipe'],
713
+ ...opts,
714
+ shell: false,
715
+ }).trim();
716
+ } catch (_) {
717
+ return null;
718
+ }
719
+ }
720
+
721
+ // gitArgvOk — the git boolean runner. The boolean/`stdio:'inherit'` contract
722
+ // matches shellOk so callers can swap one for the other; see buildMergeArgv + the
723
+ // #681 regression test.
724
+ function gitArgvOk(args, opts = {}) {
725
+ return argvOk('git', args, opts);
726
+ }
727
+
728
+ // sanitizeGitHubRefs — rewrite "#NNN" → "task NNN" in any text that lands on a
729
+ // GitHub-rendered surface that can't carry a markdown link (commit messages,
730
+ // PR titles). GitHub autolinks every bare "#NNN" there to its OWN PR/issue
731
+ // number space, which our GDS task ids long since diverged from — so a value
732
+ // summary like "#914 player straw hat" rendered a dead link to issues/914 on
733
+ // every merge commit (task 916). Pure + exported for the regression test.
734
+ function sanitizeGitHubRefs(text) {
735
+ return String(text == null ? '' : text).replace(/#(\d{1,5})\b/g, 'task $1');
736
+ }
737
+
738
+ // buildMergeArgv — construct the argv for the auto-merge commit. Pure +
739
+ // exported so the regression test can assert the untrusted value_summary lands
740
+ // as a single literal argv element (never spliced into a shell string). The
741
+ // merge message is `Merge <branch>: <summary>` with task refs de-#-ed (commit
742
+ // messages can't carry markdown links, so a bare "#NNN" would autolink to a
743
+ // dead GitHub issue — task 916); transport stays argv, never a shell string.
744
+ function buildMergeArgv(branch, valueSummary) {
745
+ const summary = sanitizeGitHubRefs(valueSummary) || `(no value summary)`;
746
+ const mergeMsg = `Merge ${branch}: ${summary}`;
747
+ return ['merge', '--no-ff', branch, '-m', mergeMsg];
748
+ }
749
+
750
+ // ---------- CI deploy mode (#679, ADR 0042) ----------
751
+ //
752
+ // Two ways the confirmed→shipped land can run, chosen by a SINGLE committed
753
+ // switch (config/deploy.json `mode`, env OTB_DEPLOY_MODE overrides):
754
+ //
755
+ // 'laptop' (default) — the operator's machine merges to main locally and
756
+ // SSHes `~/deploy.sh` on the droplet. Today's behavior, byte-for-byte.
757
+ // Requires the droplet SSH key, so only the operator can land.
758
+ //
759
+ // 'ci' — NO builder box holds the prod key. ship pushes the branch, opens a
760
+ // PR, and enables GitHub auto-merge; once required checks pass, GitHub
761
+ // merges to main and .github/workflows/deploy-prod.yml deploys from
762
+ // Actions. ship then CONFIRMS the deploy landed (PR merged + the
763
+ // deploy-prod workflow run succeeded for the merge commit + public
764
+ // healthz 200) so the synchronous "deploy-confirmed" UX is preserved
765
+ // without the key. This is what lets a Windows/Chromebook builder with no
766
+ // main checkout and no droplet key land their own work.
767
+ //
768
+ // parseDeployMode is pure (env string + raw config file contents → mode) and
769
+ // exported so the test can assert the precedence + the safe default without
770
+ // touching fs/env. The default is 'laptop' precisely so this code is INERT
771
+ // until the deliberate, atomic cutover (config flip + GitHub arming together).
772
+ function parseDeployMode(envVal, configRaw) {
773
+ const fromEnv = String(envVal || '').trim().toLowerCase();
774
+ if (fromEnv === 'ci' || fromEnv === 'laptop') return fromEnv;
775
+ if (configRaw) {
776
+ try {
777
+ const m = String(JSON.parse(configRaw).mode || '').trim().toLowerCase();
778
+ if (m === 'ci' || m === 'laptop') return m;
779
+ } catch (_) {
780
+ /* malformed config → fall through to the safe default */
781
+ }
782
+ }
783
+ return 'laptop';
784
+ }
785
+
786
+ // effectiveDeployMode — credential-aware adjustment of the parsed mode (pure;
787
+ // exported for the regression test). The 'laptop' land path pushes main + SSHes
788
+ // the droplet FROM THIS MACHINE, so it requires a local GitHub push credential.
789
+ // A machine without one — a dev box, or a fresh/stale local clone that can't even
790
+ // `git pull` (token-less origin, ADR 0053) — physically cannot run it; it must
791
+ // ship via the 'ci' server-mediated publish path instead (pushVia()==='server',
792
+ // ADR 0055), which lets the GDS server push with its own credential. So when the
793
+ // parsed mode is 'laptop' but the machine can't push locally AND the mode was NOT
794
+ // pinned by an explicit OTB_DEPLOY_MODE, fall back to 'ci' rather than strand
795
+ // every ship at merge. This is the scootermack 2026-06-22 case: a checkout left
796
+ // at the pre-cutover 'laptop' default that can't pull forward to 'ci', so it kept
797
+ // attempting a push it had no credential for. An explicit env override always
798
+ // wins (tests/staging may force 'laptop'); a machine WITH local push creds (a
799
+ // real authed laptop) is byte-for-byte unchanged. Mirrors pushVia()'s existing
800
+ // credential-awareness — the asymmetry (pushVia smart, resolveDeployMode blind)
801
+ // WAS the bug. (task 1449)
802
+ function effectiveDeployMode(mode, { canPushLocally, envForced } = {}) {
803
+ if (mode === 'laptop' && !envForced && !canPushLocally) return 'ci';
804
+ return mode;
805
+ }
806
+
807
+ function resolveDeployMode() {
808
+ let configRaw = null;
809
+ try {
810
+ configRaw = fs.readFileSync(path.join(REPO_ROOT, 'config', 'deploy.json'), 'utf8');
811
+ } catch (_) {
812
+ /* no config file → safe default */
813
+ }
814
+ const mode = parseDeployMode(process.env.OTB_DEPLOY_MODE, configRaw);
815
+ const envForced = ['ci', 'laptop'].includes(
816
+ String(process.env.OTB_DEPLOY_MODE || '').trim().toLowerCase()
817
+ );
818
+ // Probe `gh` ONLY when it can change the answer (resolved 'laptop', no env
819
+ // override) — the happy 'ci' path and an env-pinned mode spawn no extra gh,
820
+ // keeping the documented spawn discipline (the line-1019 note).
821
+ if (mode === 'laptop' && !envForced) {
822
+ return effectiveDeployMode(mode, { canPushLocally: ghAvailableAndAuthed(), envForced });
823
+ }
824
+ return mode;
825
+ }
826
+
827
+ // gh runners — mirror gitArgvOk/gitOk but for the GitHub CLI. No shell, ever:
828
+ // branch names + DB-sourced value_summary reach `gh` as literal argv elements
829
+ // (same injection discipline as #681). ghArgvOk = boolean side-effect; ghJson =
830
+ // parse `gh ... --json` stdout to an object/array (null on any failure).
831
+ function ghArgvOk(args, opts = {}) {
832
+ return argvOk('gh', args, opts);
833
+ }
834
+
835
+ function ghJson(args, opts = {}) {
836
+ const out = argvOut('gh', args, opts);
837
+ if (out == null) return null;
838
+ try { return JSON.parse(out); } catch (_) { return null; }
839
+ }
840
+
841
+ // Pure argv builders for the PR land path — exported + tested so the untrusted
842
+ // value_summary is proven to ride as ONE literal argv element (never a shell
843
+ // string), exactly like buildMergeArgv.
844
+ function buildPrCreateArgv(branch, title, body) {
845
+ return ['pr', 'create', '--head', branch, '--base', 'main', '--title', title, '--body', body];
846
+ }
847
+
848
+ // --merge keeps the project's --no-ff merge-commit convention (architecture.md
849
+ // branch-cleanup note); --auto arms GitHub to merge the instant required checks
850
+ // pass + branch protection is satisfied; --delete-branch mirrors the laptop
851
+ // path's origin-branch cleanup.
852
+ function buildPrMergeArgv(branch) {
853
+ return ['pr', 'merge', '--auto', '--merge', '--delete-branch', branch];
854
+ }
855
+
856
+ // Pure: did the prod-deploy workflow finish successfully for the merge commit?
857
+ // `runs` is the parsed `gh run list --json headSha,status,conclusion` array.
858
+ function deployRunSucceeded(runs, mergeSha) {
859
+ if (!mergeSha) return false;
860
+ return (runs || []).some(
861
+ (r) => r && r.headSha === mergeSha && r.status === 'completed' && r.conclusion === 'success'
862
+ );
863
+ }
864
+
865
+ // Pure: does the prod /version endpoint (#858) report it's running the commit we
866
+ // just merged? 'match' = commit-pinned confirmation; 'mismatch' = prod is on a
867
+ // different commit (e.g. a later deploy already superseded ours — still fine, our
868
+ // run succeeded); 'unknown' = /version absent or unparseable (older prod / blip)
869
+ // → caller falls back to the deploy-run + healthz signal. Compares on the shorter
870
+ // length so a short vs full sha still matches. Exported + tested.
871
+ function deployedCommitStatus(versionBody, mergeSha) {
872
+ if (!versionBody || !mergeSha) return 'unknown';
873
+ const c = String(versionBody.commit || '');
874
+ if (!/^[0-9a-f]{7,40}$/i.test(c)) return 'unknown';
875
+ const n = Math.min(c.length, String(mergeSha).length);
876
+ return c.slice(0, n).toLowerCase() === String(mergeSha).slice(0, n).toLowerCase()
877
+ ? 'match'
878
+ : 'mismatch';
879
+ }
880
+
881
+ // Fetch + parse the prod /version JSON (#858). null on any failure — never throws.
882
+ function fetchDeployedVersion(url = 'https://amazonprimea.com/version') {
883
+ // stdio override DISCARDS stderr (a curl progress/error line must not pollute
884
+ // the JSON parse) — the documented per-caller difference from ghJson.
885
+ const out = argvOut('curl', ['-fsS', '--max-time', '10', url], { stdio: ['ignore', 'pipe', 'ignore'] });
886
+ if (out == null) return null;
887
+ try { return JSON.parse(out); } catch (_) { return null; }
888
+ }
889
+
890
+ // ciLand — the 'ci' deploy-mode analogue of the laptop merge+SSH block. Pushes
891
+ // the branch, ensures a PR exists, enables auto-merge, then confirms the deploy
892
+ // actually landed. Returns true ONLY when prod is confirmed live on the merge
893
+ // commit — so the caller flips the task to shipped exactly as the laptop path
894
+ // does. Any earlier exit leaves the task at confirmed with a clear next step.
895
+ // pushVia — which publish path ship uses in 'ci' mode (ADR 0055 / task 1025):
896
+ // 'local' — this machine pushes the branch + opens the PR with git + gh
897
+ // (laptop: gh present + authenticated). Today's behaviour.
898
+ // 'server' — this machine has NO GitHub push credential (a dev box: no gh, a
899
+ // credential-less origin). ship uploads a thin branch bundle to the
900
+ // GDS, which pushes + opens the PR + auto-merges with the SERVER's
901
+ // credential. The box holds nothing (#919 model preserved).
902
+ // Override with OTB_PUSH_VIA_SERVER=1 (force server) / =0 (force local) for tests.
903
+ function ghAvailableAndAuthed() {
904
+ try {
905
+ const a = spawnSync('gh', ['auth', 'status'], {
906
+ stdio: 'ignore',
907
+ shell: process.platform === 'win32',
908
+ windowsHide: true,
909
+ timeout: 15000,
910
+ });
911
+ return !!a && !a.error && a.status === 0;
912
+ } catch (_) {
913
+ return false;
914
+ }
915
+ }
916
+
917
+ function pushVia() {
918
+ const o = String(process.env.OTB_PUSH_VIA_SERVER || '').trim();
919
+ if (o === '1') return 'server';
920
+ if (o === '0') return 'local';
921
+ // No explicit override: a machine that can run an authenticated gh (laptop)
922
+ // takes the local path; everything else (a dev box) goes server-mediated.
923
+ return ghAvailableAndAuthed() ? 'local' : 'server';
924
+ }
925
+
926
+ // broadcastHandledByServer — task 1045: on the server-mediated publish path the
927
+ // SERVER posts the Discord ship-broadcast after it confirms the deploy (it holds
928
+ // the webhook secrets; the box does NOT — ADR 0053). So ship.js must NOT also post
929
+ // locally there, or the box would either double-post (impossible — no secret) or,
930
+ // as before, fail silently. True only when the publish actually goes server-side:
931
+ // ci deploy mode AND this machine has no GitHub push credential (pushVia 'server').
932
+ // The laptop path (pushVia 'local') keeps posting locally, exactly as today. Pure
933
+ // w.r.t. its inputs (env + committed deploy.json) + exported so the gate is tested.
934
+ function broadcastHandledByServer() {
935
+ return resolveDeployMode() === 'ci' && pushVia() === 'server';
936
+ }
937
+
938
+ // shouldBailOnFetchFailure — autoMerge's decision when `git fetch origin main`
939
+ // fails. A credential-less dev box (private origin, scrubbed clone token —
940
+ // ADR 0053) CANNOT fetch origin, and that is FINE for the server-mediated
941
+ // publish path (ADR 0055): ciLandServer bundles against the box's LOCAL
942
+ // origin/main and the SERVER tops up main with its own credential. So a fetch
943
+ // failure is fatal ONLY when we are NOT taking that path — otherwise the box can
944
+ // never reach ciLand and strands every ship at confirmed (the bug task 1027
945
+ // surfaced: ADR 0055 shipped the server path but left this fetch guard in front
946
+ // of it). Pure + exported for the regression test (no git, no network).
947
+ // NOTE: callers must still guard the pushVia() gh-probe behind `!fetchedMain` so
948
+ // the happy path (fetch ok) spawns no extra gh and laptop behavior is unchanged.
949
+ function shouldBailOnFetchFailure(fetchedMain, deployMode, via) {
950
+ if (fetchedMain) return false;
951
+ const serverPublish = deployMode === 'ci' && via === 'server';
952
+ return !serverPublish;
953
+ }
954
+
955
+ // ciLand — dispatch the 'ci' confirmed->shipped land to the local or the
956
+ // server-mediated path. Async because the server path awaits GDS API calls.
957
+ async function ciLand(taskId, branch, valueSummary) {
958
+ console.log(' deploy mode: ci — landing via PR auto-merge (no droplet key needed)');
959
+ if (pushVia() === 'server') {
960
+ return ciLandServer(taskId, branch, valueSummary);
961
+ }
962
+ return await ciLandLocal(taskId, branch, valueSummary);
963
+ }
964
+
965
+ // ciLandServer — the dev-box path: no GitHub push credential on this machine, so
966
+ // hand the branch to the GDS, which pushes it + opens the PR + auto-merges with
967
+ // the server-side credential. Requires a RE-AUTHED (non-box) cli session — a
968
+ // box-scoped session is 403'd by the endpoint (it must `/builder-reauth` first).
969
+ async function ciLandServer(taskId, branch, valueSummary) {
970
+ console.log(' ci-land: server-mediated publish (this machine has no GitHub push credential)');
971
+ const headSha = gitOk('git rev-parse HEAD');
972
+ // Make sure origin/main is current so the THIN bundle's `origin/main..HEAD`
973
+ // base is resolvable (a read-only fetch — works on a credential-less origin).
974
+ gitArgvOk(['fetch', 'origin', 'main'], { stdio: 'ignore' });
975
+
976
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'otb-bundle-'));
977
+ const bundlePath = path.join(tmpDir, 'branch.bundle');
978
+ try {
979
+ if (!gitArgvOk(['bundle', 'create', bundlePath, `origin/main..${branch}`], { stdio: 'ignore' })) {
980
+ console.log(` ci-land: could not build the branch bundle (nothing new to push, or a git error); task stays at confirmed. ${strandNextStep(taskId, 'server')}`);
981
+ return false;
982
+ }
983
+ const bundleB64 = fs.readFileSync(bundlePath).toString('base64');
984
+ // allowBoxScope: handle the box_scope 403 locally (graceful "stays at
985
+ // confirmed, re-auth + re-ship") instead of cli-lib's central exit — task
986
+ // 1050. (In practice unreachable: a box session can't have claimed/confirmed
987
+ // to get here. Kept as a defensive, graceful path.)
988
+ const r = await (await cliClient({ allowBoxScope: true })).tasks.postTasksIdPublishBranch({
989
+ id: taskId,
990
+ body: { branch, head_sha: headSha, value_summary: valueSummary, bundle_b64: bundleB64 },
991
+ });
992
+ if (r.status === 403 && r.data && r.data.error === 'box_scope') {
993
+ console.log(' ci-land: this session is BOX-SCOPED — run /builder-reauth (browser, no terminal) to mint a real session, then re-ship. Task stays at confirmed.');
994
+ return false;
995
+ }
996
+ if (r.status === 503) {
997
+ console.log(' ci-land: server-mediated push is not configured on the server yet (no GitHub App nor GITHUB_PUSH_TOKEN — operator action, ADR 0055). Task stays at confirmed.');
998
+ return false;
999
+ }
1000
+ if (!r.ok) {
1001
+ const e = (r.data && (r.data.message || r.data.error)) || `HTTP ${r.status}`;
1002
+ console.log(` ci-land: server publish failed — ${e}. Task stays at confirmed. ${strandNextStep(taskId, 'server')}`);
1003
+ return false;
1004
+ }
1005
+ console.log(` ci-land: server pushed ${branch} + PR ${r.data.pr_url} (auto-merge ${r.data.auto_merge ? 'enabled' : 'NOT enabled — merge it on GitHub'}).`);
1006
+ return pollServerPublish(taskId, branch);
1007
+ } finally {
1008
+ try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch (_) { /* best-effort */ }
1009
+ }
1010
+ }
1011
+
1012
+ // task 1049 (B — async land semantics): classify a CI-land poll outcome so a
1013
+ // SUCCESSFUL-but-still-running publish reads as success, not failure. The #1027
1014
+ // retrospective: the ship's poll window is shorter than real CI, so a PR that was
1015
+ // opened + auto-merge-armed (a SUCCESS) printed "did not merge — task stays at
1016
+ // confirmed" and looked like a failure. Outcomes:
1017
+ // 'shipped' — merged AND deploy-prod succeeded → flip the task to shipped
1018
+ // 'failed' — PR closed without merging (a check failed / closed manually)
1019
+ // 'pending' — PR still open with auto-merge armed (or merged, deploy not yet):
1020
+ // NOT a failure — it lands on its own (the server reconciles the
1021
+ // task to shipped, and a re-run resumes — task 941).
1022
+ // Pure + exported for unit tests (no git/network). Accepts the publish-status
1023
+ // response shape verbatim — getPublishStatus returns snake_case (pr_state,
1024
+ // deploy_status); camelCase aliases are tolerated for test convenience.
1025
+ function classifyLandOutcome(s) {
1026
+ const merged = !!(s && s.merged);
1027
+ const deployStatus = s && (s.deploy_status ?? s.deployStatus);
1028
+ const prState = s && (s.pr_state ?? s.prState);
1029
+ if (merged && deployStatus === 'success') return 'shipped';
1030
+ if (prState === 'closed' && !merged) return 'failed';
1031
+ return 'pending';
1032
+ }
1033
+
1034
+ // task 1718: PURE — after ciLandLocal tries to arm GitHub auto-merge, decide which
1035
+ // poll path lands + watches the PR. Arming auto-merge commonly FAILS when shipping
1036
+ // from a worktree (`gh pr merge --auto` runs a git op that trips on `'main' is
1037
+ // already used by worktree`) or with branch protection off — but the PR is open and
1038
+ // this is NOT a dead-end: the server's merge driver + reconciler still land green
1039
+ // PRs. So an enable-failure must NOT strand the builder at 'confirmed' (they'd never
1040
+ // see the SHIPPED banner in-session, and the machine-global land-watch breadcrumb
1041
+ // would surface it in some OTHER session). Instead:
1042
+ // 'ci' → auto-merge armed: watch GitHub directly (pollCiDeploy).
1043
+ // 'server' → auto-merge NOT armed: drive + watch the server land via publish-status
1044
+ // (pollServerPublish) — reading it is reconcile-on-read, so it DRIVES the
1045
+ // merge and observes the flip to 'shipped'.
1046
+ // Both paths end at 'shipped' on a real land, so the banner renders in the shipping
1047
+ // session and no 'confirmed' breadcrumb is written. Exported for unit tests.
1048
+ function landPollAfterAutoMerge(autoMergeArmed) {
1049
+ return autoMergeArmed ? 'ci' : 'server';
1050
+ }
1051
+
1052
+ // task 1719: PURE — after mergeAndDeploy runs, the completion card + the pending-land
1053
+ // breadcrumb must reflect the AUTHORITATIVE post-land status, not the pre-merge one
1054
+ // finishShip was handed. If the task actually reached 'shipped' in-session (a laptop
1055
+ // sync land, the enable-success poll, or the task-1718 enable-failure server poll),
1056
+ // the card renders the SHIPPED banner and printCompletionCard writes NO breadcrumb;
1057
+ // otherwise keep the pre-merge status (the async land is still pending → 'confirmed' →
1058
+ // the next-turn land-watch breadcrumb is the intended fallback). Exported for tests.
1059
+ function reconciledCardStatus(liveStatus, preMergeStatus) {
1060
+ return liveStatus === 'shipped' ? 'shipped' : preMergeStatus;
1061
+ }
1062
+
1063
+ // task 1052 (E): the box-appropriate next step when a ship lands the task at
1064
+ // 'confirmed' without finishing. A dev box (pushVia 'server' — no authed gh)
1065
+ // CANNOT run /merge-mode (it needs gh + a main checkout), so the old "for
1066
+ // /merge-mode" advice was a dead end ON A BOX (the #1027 retrospective: the agent
1067
+ // had to reverse-engineer recovery from source). Point a box at the resume
1068
+ // instead (task 941); a laptop keeps /merge-mode (with resume as the alternative).
1069
+ // Pure + exported for unit tests.
1070
+ function strandNextStep(taskId, via) {
1071
+ if (via === 'server') {
1072
+ return `Re-run \`node scripts/gds/ship.js ${taskId}\` to resume — it picks up exactly where it stopped (task 941). ` +
1073
+ `(/merge-mode is NOT available here — it needs gh + a main checkout this machine doesn't have.)`;
1074
+ }
1075
+ return `Run /merge-mode to land it — or re-run \`node scripts/gds/ship.js ${taskId}\`, which resumes from here (task 941).`;
1076
+ }
1077
+
1078
+ // Print the standard "published — it'll land on its own" success-pending banner
1079
+ // (task 1049). Shared by both poll paths so the message is identical and never
1080
+ // reads as a failure. The task legitimately stays at 'confirmed' until CI lands
1081
+ // it; the server reconciles it to 'shipped' (publish-status reconcile-on-read)
1082
+ // and a re-run resumes (task 941).
1083
+ function printLandPending(taskId, { merged } = {}) {
1084
+ console.log('');
1085
+ console.log(` ci-land: ✓ Published — auto-merge is armed${merged ? ' and the PR has merged' : ''}. This is NOT a failure.`);
1086
+ console.log(` CI is still finishing; GitHub will ${merged ? 'deploy' : 'merge + deploy'} automatically once the required checks pass,`);
1087
+ console.log(` and the task flips to 'shipped' on its own. Re-run \`node scripts/gds/ship.js ${taskId}\` anytime to confirm`);
1088
+ console.log(` it landed — it resumes from here (task 941). The task is at 'confirmed' until the deploy completes.`);
1089
+ }
1090
+
1091
+ // One module-scope sleep command shared by both poll loops (task 1093 / C9). A
1092
+ // child `node -e setTimeout` is used (not `await new Promise`) so it blocks
1093
+ // identically in the SYNCHRONOUS pollCiDeploy and the async pollServerPublish —
1094
+ // the loops keep their own structure (sync vs async, 1-stage vs 2-stage); only
1095
+ // the ~10s cadence is shared.
1096
+ const POLL_SLEEP_CMD = 'node -e "setTimeout(()=>{}, 10000)"';
1097
+
1098
+ // pollServerPublish — the server-mediated analogue of pollCiDeploy. Polls the
1099
+ // GDS publish-status endpoint (which the server answers from GitHub, and which
1100
+ // reconciles the task to shipped when it observes merge+deploy — task 1049) until
1101
+ // the PR merges + deploy-prod succeeds. Returns true when prod is confirmed live;
1102
+ // a timeout with an armed PR is success-PENDING (the publish worked), not failure.
1103
+ async function pollServerPublish(taskId, branch) {
1104
+ const TRIES = 54; // ~9 min at 10s — checks + auto-merge + Actions deploy
1105
+ console.log(' ci-land: waiting for auto-merge + prod deploy (polling the server)...');
1106
+ for (let i = 0; i < TRIES; i++) {
1107
+ const r = await (await cliClient()).tasks.getTasksIdPublishStatus({ id: taskId, query: { branch } });
1108
+ if (r.ok && r.data) {
1109
+ const outcome = classifyLandOutcome(r.data);
1110
+ if (outcome === 'shipped') {
1111
+ console.log(` ci-land: merged (${String(r.data.merge_sha || '').slice(0, 8)}) + deploy-prod succeeded. Prod is live.`);
1112
+ return true;
1113
+ }
1114
+ if (outcome === 'failed') {
1115
+ console.log(` ci-land: PR was CLOSED without merging (a required check failed, or it was closed manually). Fix it and re-run \`node scripts/gds/ship.js ${taskId}\` to resume. Task stays at 'confirmed'.`);
1116
+ return false;
1117
+ }
1118
+ }
1119
+ shellOk(POLL_SLEEP_CMD);
1120
+ }
1121
+ printLandPending(taskId);
1122
+ return false;
1123
+ }
1124
+
1125
+ // attestGateTrust — task 1169 (ADR 0058). Ask the SERVER to post the trustless
1126
+ // gate-author-trust commit status for THIS builder's server-resolved rank on the
1127
+ // pushed head SHA. ship.js's local path pushes + opens the PR with the laptop's
1128
+ // OWN gh credential, but only the server can forge the rank attestation the
1129
+ // ENFORCING gate-review check reads — without it a Metic+ author's gate-surface PR
1130
+ // from a laptop is BLOCKED and they can't self-approve it (task 1162 / PR #131).
1131
+ // Best-effort + never throws: a non-Metic author, an unconfigured server, or a
1132
+ // transient failure just leaves the PR un-attested → it escalates (the safe
1133
+ // direction). A non-gate PR is unaffected by the attestation either way.
1134
+ async function attestGateTrust(taskId, headSha) {
1135
+ if (!headSha) return;
1136
+ try {
1137
+ const r = await (await cliClient()).tasks.postTasksIdAttestGate({ id: taskId, body: { head_sha: headSha } });
1138
+ if (r.ok && r.data) {
1139
+ if (r.data.attested) {
1140
+ console.log(' ci-land: gate-author-trust attested on the head SHA (Metic+) — a gate-surface PR auto-passes the ADR-0058 gatekeeper.');
1141
+ } else {
1142
+ console.log(` ci-land: gate-author-trust NOT attested (${r.data.reason || 'rank below Metic'}) — a gate-surface PR will need owner approval; a non-gate PR is unaffected.`);
1143
+ }
1144
+ } else if (r.status === 503) {
1145
+ console.log(' ci-land: the server has no push credential to post the gate attestation (operator: GITHUB_APP_* / GITHUB_PUSH_TOKEN). A gate-surface PR will need owner approval.');
1146
+ } else {
1147
+ console.log(` ci-land: gate attestation request failed (HTTP ${r.status}); a gate-surface PR may need a manual re-run / owner approval.`);
1148
+ }
1149
+ } catch (_) {
1150
+ /* best-effort — never block the ship on the attestation */
1151
+ }
1152
+ }
1153
+
1154
+ // pickStaleGateReviewRun — PURE. Among `gh run list --json
1155
+ // databaseId,headSha,status,conclusion` results for the gate-review workflow,
1156
+ // find a run on THIS head SHA that has already COMPLETED without success — the
1157
+ // one to re-run after the attestation is posted, because a new commit status does
1158
+ // NOT re-trigger a pull_request-triggered check (the task 1162 / PR #131 gotcha).
1159
+ // Returns the run's databaseId, or null when there's nothing stale to re-run (no
1160
+ // run yet, still in flight, or already green). Exported for unit tests.
1161
+ function pickStaleGateReviewRun(runs, headSha) {
1162
+ if (!headSha || !Array.isArray(runs)) return null;
1163
+ const hit = runs.find(
1164
+ (r) => r && r.headSha === headSha && r.status === 'completed' && r.conclusion && r.conclusion !== 'success'
1165
+ );
1166
+ return hit ? (hit.databaseId ?? null) : null;
1167
+ }
1168
+
1169
+ // rerunGateReviewIfStale — best-effort gh re-run of a stale (completed-non-success)
1170
+ // gate-review run on the current head, so it re-reads the freshly-posted
1171
+ // gate-author-trust attestation. Only acts when such a run exists: a freshly-opened
1172
+ // PR needs NO nudge (gate-review runs once, AFTER the attestation, and passes); the
1173
+ // SHA-reuse-after-red resume case (and the rare push-then-attest race) needs one.
1174
+ function rerunGateReviewIfStale(branch, headSha) {
1175
+ const runs = ghJson([
1176
+ 'run', 'list', '--workflow', 'gate-review.yml', '--branch', branch,
1177
+ '--json', 'databaseId,headSha,status,conclusion', '--limit', '15',
1178
+ ]);
1179
+ const id = pickStaleGateReviewRun(runs, headSha);
1180
+ if (id == null) return false;
1181
+ console.log(` ci-land: re-running the stale gate-review check (run ${id}) so it re-reads the gate-author-trust attestation...`);
1182
+ return ghArgvOk(['run', 'rerun', String(id)]);
1183
+ }
1184
+
1185
+ // ciLandLocal — today's local path: this machine pushes the branch + opens the PR
1186
+ // + enables auto-merge via git + gh. async since task 1169 — it asks the server to
1187
+ // post the gate-author-trust attestation between the push and the PR open.
1188
+ async function ciLandLocal(taskId, branch, valueSummary) {
1189
+ const headSha = gitOk('git rev-parse HEAD');
1190
+
1191
+ console.log(` pushing ${branch} to origin...`);
1192
+ if (!gitArgvOk(['push', 'origin', branch])) {
1193
+ console.log(' ci-land: push failed; task stays at confirmed. Fix the push and re-ship.');
1194
+ return false;
1195
+ }
1196
+
1197
+ // task 1169 (ADR 0058): attest the author's trust tier on the head SHA BEFORE
1198
+ // opening the PR, so the gate-review check reads it on its first (and only) run
1199
+ // and a Metic+ gate-surface PR auto-passes — race-free for a fresh PR, since the
1200
+ // `gh pr create` below is what fires gate-review and it runs only after this
1201
+ // returns. (A reused open PR is handled by the re-run nudge after auto-merge.)
1202
+ await attestGateTrust(taskId, headSha);
1203
+
1204
+ // Ensure exactly one PR for this branch. gh pr create is idempotent-ish: it
1205
+ // errors if a PR already exists, so check first and reuse it.
1206
+ const existing = ghJson(['pr', 'view', branch, '--json', 'number,url,state']);
1207
+ // Reusing an already-open PR means gate-review may have ALREADY run (red, before
1208
+ // the attestation existed) on this same head SHA — nudge a re-run below.
1209
+ const reusingOpenPr = !!(existing && existing.state === 'OPEN');
1210
+ // PR titles + bodies are GitHub-rendered: a bare "#NNN" autolinks into
1211
+ // GitHub's PR/issue number space (dead links for GDS task ids — task 916),
1212
+ // so the task ref is written as "task NNN" and the summary is de-#-ed.
1213
+ const title = `task ${taskId}: ${sanitizeGitHubRefs(valueSummary) || branch}`.slice(0, 240);
1214
+ const body =
1215
+ `Auto-opened by ship.js (deploy mode: ci) for task ${taskId}.\n\n` +
1216
+ `${sanitizeGitHubRefs(valueSummary) || '(no value summary)'}\n\n` +
1217
+ `Merging this PR triggers .github/workflows/deploy-prod.yml to deploy to ` +
1218
+ `https://amazonprimea.com. Auto-merge is enabled — GitHub lands it once the ` +
1219
+ `required checks pass.`;
1220
+ if (!existing || existing.state === 'CLOSED' || existing.state === 'MERGED') {
1221
+ console.log(' ci-land: opening PR -> main...');
1222
+ if (!ghArgvOk(buildPrCreateArgv(branch, title, body))) {
1223
+ console.log(' ci-land: `gh pr create` failed. Is the GitHub CLI installed + authenticated (`gh auth status`)? Open the PR manually; task stays at confirmed.');
1224
+ return false;
1225
+ }
1226
+ } else {
1227
+ console.log(` ci-land: reusing open PR ${existing.url}`);
1228
+ }
1229
+
1230
+ console.log(' ci-land: enabling auto-merge (lands when required checks pass)...');
1231
+ // task 1718: an enable-failure is NOT a dead-end — the PR is open and the server
1232
+ // still lands green PRs. Don't return false (which stranded the builder at
1233
+ // 'confirmed' with no in-session SHIPPED banner); fall through to the server poll,
1234
+ // which drives + observes the land. See landPollAfterAutoMerge.
1235
+ const autoMergeArmed = ghArgvOk(buildPrMergeArgv(branch));
1236
+ if (!autoMergeArmed) {
1237
+ console.log(' ci-land: GitHub auto-merge could not be armed from here (normal when shipping from a worktree, or with branch protection off). The PR is open; the server lands it — waiting for the land instead of stranding at confirmed...');
1238
+ }
1239
+
1240
+ // task 1169: if we REUSED an open PR, a stale red gate-review run may sit on this
1241
+ // same head SHA from before the attestation existed; a new commit status does not
1242
+ // re-trigger a pull_request check, so re-run it (best-effort). A freshly-opened PR
1243
+ // is left alone — its single gate-review run reads the attestation and passes.
1244
+ if (reusingOpenPr) rerunGateReviewIfStale(branch, headSha);
1245
+
1246
+ // enable-success watches GitHub directly; enable-failure drives + watches the
1247
+ // server land (publish-status reconcile-on-read). Both flip to 'shipped' on a real
1248
+ // land, so the banner renders in-session and no 'confirmed' breadcrumb is written.
1249
+ return landPollAfterAutoMerge(autoMergeArmed) === 'ci'
1250
+ ? pollCiDeploy(taskId, branch, headSha)
1251
+ : pollServerPublish(taskId, branch);
1252
+ }
1253
+
1254
+ // Confirm the CI land actually reached production. Polls in stages:
1255
+ // 1. PR state -> MERGED (checks passing + branch protection satisfied); grabs
1256
+ // the merge commit oid.
1257
+ // 2. the deploy-prod workflow run for that commit -> success.
1258
+ // 3. public edge healthz -> 200 (belt-and-braces, a different vantage).
1259
+ // Times out conservatively; on any timeout the task stays at confirmed with a
1260
+ // precise next step rather than a false "shipped".
1261
+ function pollCiDeploy(taskId, branch, headSha) {
1262
+ const MERGE_TRIES = 30; // ~5 min at 10s — checks + auto-merge
1263
+ const DEPLOY_TRIES = 24; // ~4 min at 10s — Actions deploy
1264
+
1265
+ console.log(' ci-land: waiting for auto-merge (checks + branch protection)...');
1266
+ let mergeSha = null;
1267
+ for (let i = 0; i < MERGE_TRIES; i++) {
1268
+ const pr = ghJson(['pr', 'view', branch, '--json', 'state,mergeCommit']);
1269
+ if (pr && pr.state === 'MERGED') {
1270
+ mergeSha = pr.mergeCommit && pr.mergeCommit.oid;
1271
+ break;
1272
+ }
1273
+ if (pr && pr.state === 'CLOSED') {
1274
+ console.log(` ci-land: PR was CLOSED without merging (a required check failed, or it was closed manually). Fix it and re-run \`node scripts/gds/ship.js ${taskId}\` to resume. Task stays at 'confirmed'.`);
1275
+ return false;
1276
+ }
1277
+ shellOk(POLL_SLEEP_CMD);
1278
+ }
1279
+ if (!mergeSha) {
1280
+ // task 1049: PR open + auto-merge armed but not merged in-window = the publish
1281
+ // SUCCEEDED and is still in CI — success-pending, not a failure.
1282
+ printLandPending(taskId);
1283
+ return false;
1284
+ }
1285
+ console.log(` ci-land: merged to main (${mergeSha.slice(0, 8)}). Waiting for the prod deploy...`);
1286
+
1287
+ for (let i = 0; i < DEPLOY_TRIES; i++) {
1288
+ const runs = ghJson([
1289
+ 'run', 'list', '--workflow', 'deploy-prod.yml', '--branch', 'main',
1290
+ '--json', 'headSha,status,conclusion', '--limit', '15',
1291
+ ]);
1292
+ if (deployRunSucceeded(runs, mergeSha)) {
1293
+ console.log(' ci-land: deploy-prod workflow succeeded for the merge commit.');
1294
+ // Commit-pin the confirmation against /version (#858) — stronger than a
1295
+ // liveness probe. The deploy run succeeding for OUR mergeSha is already
1296
+ // authoritative, so /version only refines the message (and never turns a
1297
+ // real deploy into a false negative on a transient miss or a concurrent
1298
+ // later deploy).
1299
+ const status = deployedCommitStatus(fetchDeployedVersion(), mergeSha);
1300
+ if (status === 'match') {
1301
+ console.log(' ci-land: /version confirms prod is live on the merge commit (commit-pinned).');
1302
+ } else if (status === 'mismatch') {
1303
+ console.log(' ci-land: deploy run succeeded; /version shows a different commit (a later deploy has since superseded it — fine, our change is in main).');
1304
+ } else if (shellOk('curl -fsS --max-time 10 https://amazonprimea.com/healthz -o /dev/null')) {
1305
+ console.log(' ci-land: /version unavailable; amazonprimea.com/healthz 200 — treating the successful deploy run as authoritative.');
1306
+ } else {
1307
+ console.log(' ci-land: deploy run succeeded but /version + healthz probes both missed (likely transient). Treating the successful deploy run as authoritative.');
1308
+ }
1309
+ return true;
1310
+ }
1311
+ shellOk(POLL_SLEEP_CMD);
1312
+ }
1313
+ // task 1049: merged but the deploy run hasn't confirmed in-window — still
1314
+ // success-pending (it deploys on its own), not a failure.
1315
+ printLandPending(taskId, { merged: true });
1316
+ return false;
1317
+ }
1318
+
1319
+ // ---------- Step 0: preflight (working tree + branch freshness) ----------
1320
+ //
1321
+ // Catches the 2026-05-10 "phantom ship" bug: builder forgot to stage/commit
1322
+ // their actual work. The committed-diff scanner reported clean (because nothing
1323
+ // was committed), smoke passed, and the task auto-shipped with zero code
1324
+ // change. The fix is to inspect the working tree BEFORE the claim resolves and
1325
+ // refuse to proceed when any tracked file has uncommitted/staged changes.
1326
+ //
1327
+ // Also refuses when the branch is far behind origin/main — merging a stale
1328
+ // branch is a known regression vector.
1329
+
1330
+ function preflight(claim, opts = {}) {
1331
+ // (a) Working-tree check — refuse if any TRACKED file carries an uncommitted
1332
+ // modification or staged change. This fires post-commit, so a clean tree
1333
+ // proves the builder's work is actually committed. touches[] cleanup [2/8]
1334
+ // (task 877): this replaced the old touches[]-scoped match, which was skipped
1335
+ // entirely for empty-touches tasks (leaving them unprotected). The plain
1336
+ // clean-tree check is strictly stronger and needs no touches[]. Untracked /
1337
+ // .gitignored files are ignored so autonomous scratch files don't false-refuse.
1338
+ const porcelain = gitOk('git status --porcelain');
1339
+ const dirty = trackedDirty(porcelain);
1340
+ if (dirty.length > 0) {
1341
+ console.log(' preflight: working tree has uncommitted tracked changes:');
1342
+ for (const file of dirty) console.log(` - ${file}`);
1343
+ console.log('');
1344
+ console.log(' Stage + commit these (or revert them) before shipping.');
1345
+ console.log(' This guard exists because uncommitted work has shipped as "complete" before.');
1346
+ return { ok: false, reason: 'working-tree-dirty' };
1347
+ }
1348
+
1349
+ // (b) Branch-freshness check — refuse if behind origin/main by too much.
1350
+ // Fetch quietly so the comparison is against current remote state. Clean
1351
+ // macOS Finder/iCloud `* 2` duplicate refs first or fetch will fail with
1352
+ // "bad object refs/heads/<name> 2".
1353
+ const cleaned = cleanupMacFinderDupRefs(REPO_ROOT);
1354
+ if (cleaned.length > 0) {
1355
+ console.log(` preflight: removed ${cleaned.length} stray refs (macOS Finder duplicates)`);
1356
+ }
1357
+ gitOk('git fetch origin main');
1358
+ const behindStr = gitOk('git rev-list --count HEAD..origin/main');
1359
+ const behind = behindStr ? Number(behindStr) : 0;
1360
+ if (Number.isFinite(behind) && behind > 0) {
1361
+ // (b) Branch-freshness is REPORT-ONLY now (#1068). Being behind origin/main
1362
+ // is no longer a refusal: shipMain() merges origin/main INTO the branch up
1363
+ // front (mergeMainIntoBranch), BEFORE the sandbox review gate, so the tree
1364
+ // the human reviews == the tree we ship — killing the post-approval re-stage
1365
+ // loop the #1057 retro hit. We still fetch (above) so that merge + the
1366
+ // stale-tooling guard below see current remote state.
1367
+ const note = behind > BEHIND_MAIN_LIMIT ? ` (well past the ${BEHIND_MAIN_LIMIT}-commit mark)` : '';
1368
+ console.log(` preflight: branch is ${behind} commit(s) behind origin/main${note} — shipMain merges it in before staging.`);
1369
+ }
1370
+
1371
+ // (c) Stale ship-tooling guard. The behind-limit above is deliberately lenient
1372
+ // so an unrelated commit to main doesn't block every parallel builder — but
1373
+ // that means a branch a FEW commits behind sails through, and if those commits
1374
+ // touched the ship/grade tooling, the builder runs a STALE ship.js / grader.js
1375
+ // / smoke runner and the ship silently fails the same way every time. That was
1376
+ // the #662 root cause (a grader.js predating the Windows spawn fix ran twice).
1377
+ // So: regardless of commit count, refuse if origin/main carries changes to the
1378
+ // tooling that runs the ship locally and this branch lacks them. Three-dot diff
1379
+ // (HEAD...origin/main) scopes to changes on the origin/main side since the
1380
+ // merge-base, so the builder's OWN edits to these files (e.g. shipping a
1381
+ // ship.js fix) do not trip it.
1382
+ const TOOLING_PATHS = [
1383
+ 'scripts/gds/ship.js',
1384
+ 'scripts/gds/smoke-gds.js',
1385
+ 'scripts/gds/cli-lib.js',
1386
+ 'modules/grading/grader.js', // BV1.R73: grading carved to modules/grading/
1387
+ ];
1388
+ const staleToolingStr = gitOk(`git diff --name-only HEAD...origin/main -- ${TOOLING_PATHS.join(' ')}`);
1389
+ const staleTooling = staleToolingStr ? staleToolingStr.trim().split('\n').filter(Boolean) : [];
1390
+ if (staleTooling.length > 0) {
1391
+ if (opts.force) {
1392
+ console.log(` preflight: ship tooling is behind origin/main (${staleTooling.join(', ')}) — proceeding due to --force`);
1393
+ } else {
1394
+ console.log(' preflight: your ship/grade tooling is behind origin/main:');
1395
+ for (const f of staleTooling) console.log(` - ${f}`);
1396
+ console.log(' These files RUN the ship + grader locally — shipping with a stale copy silently fails (root cause of #662).');
1397
+ console.log(' Run `git merge origin/main` (or `git pull --rebase origin main`) on this branch, then re-ship. Or --force to override.');
1398
+ return { ok: false, reason: 'stale-tooling' };
1399
+ }
1400
+ }
1401
+
1402
+ console.log(` preflight: working tree clean, branch ${behind} commits behind main, ship tooling current — OK`);
1403
+ return { ok: true };
1404
+ }
1405
+
1406
+ // mergeMainIntoBranch — pull origin/main INTO the current feature branch BEFORE
1407
+ // the sandbox review gate (#1068, gap 2), so the tree the human reviews == the
1408
+ // tree we ship and an --approved lands in ONE shot. This subsumes the old
1409
+ // behind-main hard refusal: instead of telling the builder to merge by hand
1410
+ // (which then invalidated their just-given sandbox approval — the #1057 retro),
1411
+ // we do it up front. Aborts ONLY on a genuine merge conflict (the human must
1412
+ // resolve those); a no-op when already current or not on a feature branch. Runs
1413
+ // in the session's worktree (currentBranch()/gitOk operate on process.cwd()).
1414
+ // Returns { ok, merged, conflict?, reason? }.
1415
+ // decidePreMerge — the PURE policy for the pre-stage merge: skip on main/detached
1416
+ // HEAD, on the server-publish path, or when already current; otherwise merge.
1417
+ // Exported so the truth table is unit-tested with no git. `via` is pushVia()'s
1418
+ // verdict ('server' | 'local' | undefined). Returns { action:'skip'|'merge', reason }.
1419
+ function decidePreMerge({ branch, behind, via }) {
1420
+ if (!branch || branch === 'main' || branch === 'HEAD') {
1421
+ return { action: 'skip', reason: `not on a feature branch (${branch})` };
1422
+ }
1423
+ // Server-mediated publish (ADR 0055): a credential-less partial-clone dev box
1424
+ // (blob:none, HTTPS origin, no gh/credential-helper — ADR 0053) CANNOT run a
1425
+ // local `git merge origin/main` — the ort merge lazily fetches main's blobs
1426
+ // from the promisor remote and there are no credentials to authenticate that
1427
+ // fetch, so the box strands here and can never ship while behind main, i.e.
1428
+ // ≈always (idea #364). It also does not NEED to: ciLandServer bundles
1429
+ // `origin/main..HEAD` and the SERVER reconciles the branch with the real main
1430
+ // when it pushes + opens + auto-merges the PR; a genuine conflict surfaces
1431
+ // server-side as merge_conflict (publish-reconciler / conflict-resolve,
1432
+ // ADR 0082). The local pre-merge (#1068) is a LAPTOP-path optimization (so the
1433
+ // sandbox-reviewed tree == the shipped tree); it stays exactly as-is there
1434
+ // (via 'local'/undefined) and is simply skipped on the box.
1435
+ if (via === 'server') {
1436
+ return {
1437
+ action: 'skip',
1438
+ reason: 'server-mediated publish reconciles with origin/main server-side (credential-less partial-clone box cannot fetch main blobs locally — idea #364)',
1439
+ };
1440
+ }
1441
+ if (!Number.isFinite(behind) || behind === 0) {
1442
+ return { action: 'skip', reason: 'already up to date with origin/main' };
1443
+ }
1444
+ return { action: 'merge', reason: `${behind} commit(s) behind origin/main` };
1445
+ }
1446
+
1447
+ function mergeMainIntoBranch() {
1448
+ const branch = currentBranch();
1449
+ // preflight already fetched origin/main + cleaned stray refs; reuse that state.
1450
+ const behindStr = gitOk('git rev-list --count HEAD..origin/main');
1451
+ const behind = behindStr ? Number(behindStr) : 0;
1452
+ const decision = decidePreMerge({ branch, behind, via: pushVia() });
1453
+ if (decision.action === 'skip') {
1454
+ return { ok: true, merged: false, reason: decision.reason };
1455
+ }
1456
+ console.log(` pre-stage: merging origin/main into ${branch} (${behind} commit(s) behind) so the reviewed tree == the shipped tree...`);
1457
+ const msg = `Merge origin/main into ${branch} (pre-stage freshness, task 1068)`;
1458
+ if (gitArgvOk(['merge', '--no-edit', '-m', msg, 'origin/main'])) {
1459
+ console.log(' pre-stage: merged origin/main cleanly.');
1460
+ return { ok: true, merged: true };
1461
+ }
1462
+ // Conflict (or other merge failure). Before giving up, AUTO-HEAL conflicts that
1463
+ // are confined to GENERATED files (task 1243): docs/repo-map.md + the nested
1464
+ // CLAUDE.md blocks reliably collide between parallel branches (they're rebuilt
1465
+ // from code, so two branches render them differently), and re-resolving them by
1466
+ // hand — three times — was the PR #167 churn. Regenerate them from the merged
1467
+ // tree, stage, and complete the merge. This is the guaranteed-to-run safety net
1468
+ // that backstops the `otb-regen` git merge driver (install-git-hooks.js), which
1469
+ // only fires where it's been registered per-clone.
1470
+ const conflicted = (gitOk('git diff --name-only --diff-filter=U') || '').trim();
1471
+ const files = conflicted ? conflicted.split('\n').map((s) => s.trim()).filter(Boolean) : [];
1472
+ if (files.length && files.every((f) => mergeRegen.REGENERABLE.has(f))) {
1473
+ const healed = files.every((f) => mergeRegen.resolveInWorktree(f));
1474
+ if (healed && gitArgvOk(['add', ...files]) && gitArgvOk(['commit', '--no-edit'])) {
1475
+ console.log(` pre-stage: auto-healed generated-file conflict(s) by regenerating ${files.join(', ')} from the merged tree.`);
1476
+ return { ok: true, merged: true, healed: files };
1477
+ }
1478
+ // A hand-written conflict remained (resolveInWorktree returned false) or the
1479
+ // commit failed — fall through to the clean abort below; no half-merged tree.
1480
+ }
1481
+ // Abort so the tree is left clean, and surface it — we must NOT ship a
1482
+ // half-merged tree.
1483
+ gitArgvOk(['merge', '--abort']);
1484
+ return {
1485
+ ok: false,
1486
+ merged: false,
1487
+ conflict: true,
1488
+ reason: conflicted
1489
+ ? `merge conflict with origin/main in: ${files.join(', ')}. Resolve on this branch (git merge origin/main), commit, then re-ship`
1490
+ : 'could not merge origin/main into this branch (see git output). Resolve manually, then re-ship',
1491
+ };
1492
+ }
1493
+
1494
+ // ---------- Pre-verify: auto-linkify the ship's own changed docs ----------
1495
+ //
1496
+ // The doc-ref-linkify CI gate (.github/workflows/doc-ref-linkify.yml) runs on
1497
+ // push:main — i.e. AFTER ship's auto-merge has already landed the commit. So a
1498
+ // single bare "#NNN" task ref that slips into a doc red-Xes the build and
1499
+ // emails a failure for an already-merged commit. Caught post-merge, the gate is
1500
+ // pure noise: it can't prevent the leak, only complain about it (#747).
1501
+ //
1502
+ // The structural cure is to fix the ship's OWN changed docs here, before the
1503
+ // merge, and commit the rewrite. The gate then stays as the backstop for refs
1504
+ // that arrive by other paths (generated docs, direct pushes) but stops firing
1505
+ // in the normal /builder-ship path.
1506
+ //
1507
+ // Scope is deliberately the docs already in THIS commit's diff: rewriting only
1508
+ // files the builder already changed means it can NEVER rewrite a doc they
1509
+ // didn't touch, and a pre-existing bare ref elsewhere is left for
1510
+ // the periodic backlog sweep (`linkify-refs.js --apply`) + the CI gate.
1511
+
1512
+ // Pure: which changed paths are in the linkifier's domain (the hand-authored
1513
+ // markdown a reader sees on github.com — root CLAUDE.md/README.md + docs/**).
1514
+ // Mirrors linkify-refs.js discoverDocs() roots. Exported for unit tests.
1515
+ function isLinkifiableDocPath(rel) {
1516
+ return (
1517
+ typeof rel === 'string' &&
1518
+ rel.endsWith('.md') &&
1519
+ (rel === 'CLAUDE.md' || rel === 'README.md' || rel.startsWith('docs/'))
1520
+ );
1521
+ }
1522
+
1523
+ function autoLinkifyChangedDocs(ac) {
1524
+ const baseline = computeGraderBaseline(ac);
1525
+ let docs = computeChangedFiles(baseline).filter(isLinkifiableDocPath);
1526
+ // Mirror discoverDocs()'s GENERATED-FILE skip — an explicit file set bypasses
1527
+ // it, and rewriting a generated doc just churns until its next regen.
1528
+ docs = docs.filter((rel) => {
1529
+ try {
1530
+ const head = fs.readFileSync(path.resolve(process.cwd(), rel), 'utf8').slice(0, 200);
1531
+ return !/GENERATED FILE/i.test(head);
1532
+ } catch (_) {
1533
+ return false;
1534
+ }
1535
+ });
1536
+ if (docs.length === 0) return;
1537
+
1538
+ console.log(`Auto-linkify: scanning ${docs.length} changed doc(s) for bare "#NNN" task refs...`);
1539
+ const linkifyScript = path.resolve(__dirname, 'linkify-refs.js');
1540
+ try {
1541
+ // --apply (no --check) throws on an unreachable id allow-list; treat any
1542
+ // failure as best-effort and continue — the CI gate is the backstop, a
1543
+ // network blip must never block a ship.
1544
+ execFileSync(process.execPath, [linkifyScript, '--apply', ...docs], {
1545
+ stdio: ['ignore', 'pipe', 'pipe'],
1546
+ cwd: process.cwd(),
1547
+ });
1548
+ } catch (err) {
1549
+ const line = (err.message || 'unknown error').split('\n')[0];
1550
+ console.log(` auto-linkify: skipped (linkifier unavailable: ${line}). CI gate remains the backstop.`);
1551
+ return;
1552
+ }
1553
+
1554
+ // Commit only if the rewrite actually changed something. status --porcelain
1555
+ // over the explicit doc set, via argv (paths can't be spliced into a shell).
1556
+ const dirty = gitOk(`git status --porcelain -- ${docs.map((d) => JSON.stringify(d)).join(' ')}`);
1557
+ if (!dirty) {
1558
+ console.log(' auto-linkify: changed docs already clean — nothing to commit.');
1559
+ return;
1560
+ }
1561
+ gitArgvOk(['add', '--', ...docs]);
1562
+ const committed = gitArgvOk(['commit', '-m', 'chore(docs): auto-linkify task refs before ship (task 747)']);
1563
+ if (committed) {
1564
+ const n = dirty.split('\n').filter(Boolean).length;
1565
+ console.log(` auto-linkify: rewrote bare task refs in ${n} doc(s) and committed before merge.`);
1566
+ } else {
1567
+ console.log(' auto-linkify: commit failed; continuing (CI gate is the backstop).');
1568
+ }
1569
+ }
1570
+
1571
+ // ---------- Step 1+2: local verification ----------
1572
+
1573
+ // V3.R18 (#209) → MAS Phase 1 #395: inputs for the subagent grader. The grader
1574
+ // needs three things from the local working tree: the changed files list, the
1575
+ // full unified diff against the baseline, and (for the audit-trail signals)
1576
+ // the shortstat summary.
1577
+ //
1578
+ // #536: the grader's baseline is computed INDEPENDENTLY of touches[] — always
1579
+ // from merge-base, never gated on the declared scope. (Historically the
1580
+ // touches-gated drift scanner returned a null baseline for empty touches[],
1581
+ // which once blinded the grader to a fully-committed diff and hard-FAILed
1582
+ // empty-touches newcomer ships; the scanner is gone as of task 879, but the
1583
+ // grader keeping its own merge-base baseline remains the correct invariant.)
1584
+ function computeGraderBaseline(claim) {
1585
+ return (
1586
+ gitOk('git merge-base origin/main HEAD') ||
1587
+ gitOk('git merge-base main HEAD') ||
1588
+ (claim && claim.head_sha) ||
1589
+ null
1590
+ );
1591
+ }
1592
+
1593
+ function computeChangedFiles(baseline) {
1594
+ if (!baseline) return [];
1595
+ const out = gitOk(`git diff --name-only ${baseline}...HEAD`);
1596
+ return out ? out.split('\n').map((s) => s.trim()).filter(Boolean) : [];
1597
+ }
1598
+
1599
+ function computeDiffText(baseline) {
1600
+ if (!baseline) return '';
1601
+ // -M to track renames; --no-color so the subagent gets clean diff lines.
1602
+ const diff = gitOk(`git diff --no-color -M ${baseline}...HEAD`);
1603
+ if (!diff) return '';
1604
+ if (diff.length > GRADER_DIFF_CHAR_CAP) {
1605
+ const head = diff.slice(0, GRADER_DIFF_CHAR_CAP);
1606
+ return `${head}\n\n[diff truncated — original was ${diff.length} chars, capped at ${GRADER_DIFF_CHAR_CAP}. The subagent can Read any file in its post-resolve state for the full picture.]`;
1607
+ }
1608
+ return diff;
1609
+ }
1610
+
1611
+ function computeDiffShortstat(baseline) {
1612
+ if (!baseline) return null;
1613
+ const summary = gitOk(`git diff --shortstat ${baseline}...HEAD`);
1614
+ if (!summary) return { files_changed: 0, lines_added: 0, lines_removed: 0 };
1615
+ const files = /(\d+)\s+files?\s+changed/.exec(summary);
1616
+ const ins = /(\d+)\s+insertions?\(\+\)/.exec(summary);
1617
+ const del = /(\d+)\s+deletions?\(-\)/.exec(summary);
1618
+ return {
1619
+ files_changed: files ? Number(files[1]) : 0,
1620
+ lines_added: ins ? Number(ins[1]) : 0,
1621
+ lines_removed: del ? Number(del[1]) : 0,
1622
+ };
1623
+ }
1624
+
1625
+ // ---------- V4.R08 — diff fingerprint + grade reuse ----------
1626
+
1627
+ // SHA-256 of the raw diff string. Stable identifier for "has the committed
1628
+ // diff changed?" — used as the key to skip re-running the adversarial panel
1629
+ // when the diff is byte-for-byte identical to the last PASS.
1630
+ function diffHash(rawDiff) {
1631
+ return crypto.createHash('sha256').update(rawDiff || '').digest('hex');
1632
+ }
1633
+
1634
+ // Compute the hash of the full (untruncated) diff against baseline. Returns
1635
+ // null when baseline is unavailable so callers can treat null as "unknown".
1636
+ function computeRawDiffHash(baseline) {
1637
+ if (!baseline) return null;
1638
+ const raw = gitOk(`git diff --no-color -M ${baseline}...HEAD`);
1639
+ return diffHash(raw || '');
1640
+ }
1641
+
1642
+ // Pure predicate: should we reuse the stored grade instead of running the
1643
+ // panel again? True when a PASS grade exists for the exact same diff hash.
1644
+ // A null storedGrade, a non-PASS grade, or a hash mismatch all return false.
1645
+ function shouldReuseGrade(storedGrade, currentDiffHash) {
1646
+ if (!storedGrade || !storedGrade.passed) return false;
1647
+ if (!storedGrade.diff_hash || !currentDiffHash) return false;
1648
+ return storedGrade.diff_hash === currentDiffHash;
1649
+ }
1650
+
1651
+ // --- Subagent grader runner ---
1652
+ // Wraps grader.gradeSubagent so ship.js callers see a single function with
1653
+ // progress logging. MAS Phase 2.0 #410: gradeSubagent now runs the WORKER
1654
+ // PANEL (Narc + Quality; Hacker / Efficiency Monger added in 2.1/2.2) in
1655
+ // parallel. Each worker is a specialized prompt with its own verdict; the
1656
+ // panel gate is unanimous PASS — any 'fail' blocks, any 'concerns' falls
1657
+ // through to manual-confirm.
1658
+ async function runSubagentGrader({ task, valueSummary, baseline, changedFiles: precomputedChanged }) {
1659
+ // Reuse the caller's changed-files list when provided (the empty-diff guard
1660
+ // computes it just before calling us) so we don't run `git diff --name-only`
1661
+ // twice per ship (#513).
1662
+ const changedFiles = precomputedChanged || computeChangedFiles(baseline);
1663
+ const diff = computeDiffText(baseline);
1664
+ const shortstat = computeDiffShortstat(baseline);
1665
+ // SEC #863: feed the grader the builder's LIVE rank so the deterministic
1666
+ // permission-path pre-pass can hard-fail a sub-Metic diff that touches the
1667
+ // rank/permission/ship-grade/deploy machinery. Best-effort read; if it fails
1668
+ // the pre-pass fails OPEN (the pre-push hook + post-push main audit are the
1669
+ // fail-closed layers).
1670
+ let builderRank;
1671
+ try {
1672
+ const meRes = await (await cliClient()).me.getMe();
1673
+ if (meRes.ok) builderRank = meRes.data?.builder?.rank;
1674
+ } catch (_) { /* unknown rank → pre-pass fails open; audit + hook still gate */ }
1675
+ const runOpts = {
1676
+ timeoutMs: GRADER_TIMEOUT_MS,
1677
+ cwd: process.cwd(), // grade the FEATURE worktree, not REPO_ROOT
1678
+ };
1679
+ const t0 = Date.now();
1680
+ let result;
1681
+ // #513: a small copy/docs-only diff gets a SINGLE generalist grader shot
1682
+ // rather than the full adversarial panel — "lighter, not skipped". The 1-shot
1683
+ // still returns ff+cq+economy+confidence so the server gate applies; the
1684
+ // deterministic scanner + smoke + route-rank checks ran regardless. Fixes the
1685
+ // inefficiency of running a 4-worker panel on a +1/-1 copy tweak.
1686
+ if (grader.isTrivialDiff({ changedFiles, diffStats: shortstat })) {
1687
+ const totalLines = (shortstat.lines_added || 0) + (shortstat.lines_removed || 0);
1688
+ console.log(` subagent grader ${grader.VERSION}: TRIVIAL diff (${shortstat.files_changed} file(s), ${totalLines} line(s), copy/docs only) → single generalist shot, model=${grader.selectGraderModel({})} — instead of the ${grader.WORKERS.DEFAULT_PANEL.length}-worker panel (#513)`);
1689
+ console.log(` gate: the shot must return functional_fidelity>=${grader.THRESHOLD} AND code_quality>=${grader.THRESHOLD} AND confidence>=0.4`);
1690
+ result = await grader.gradeSubagentV3MultiShot({
1691
+ task,
1692
+ valueSummary,
1693
+ diff,
1694
+ changedFiles,
1695
+ diffStats: shortstat,
1696
+ shots: 1,
1697
+ runOpts,
1698
+ });
1699
+ } else {
1700
+ const panel = grader.WORKERS.DEFAULT_PANEL;
1701
+ console.log(` subagent grader ${grader.VERSION}: panel of ${panel.length} workers (${panel.join(', ')}), model=${grader.DEFAULT_GRADER_MODEL} (assumed builder ${grader.ASSUMED_BUILDER_MODEL}), changed_files=${changedFiles.length}, diff_chars=${diff.length}, per-worker timeout=${GRADER_TIMEOUT_MS}ms`);
1702
+ console.log(` gate: every worker must return verdict='pass'; 'concerns' → manual-confirm fall-through; 'fail' blocks`);
1703
+ result = await grader.gradeSubagent({
1704
+ task,
1705
+ valueSummary,
1706
+ diff,
1707
+ changedFiles,
1708
+ diffStats: shortstat, // V3.R92 #353: feed diff size into the economy axis
1709
+ builderRank, // SEC #863: drives the permission-path pre-pass
1710
+ runOpts,
1711
+ });
1712
+ }
1713
+ const wallMs = Date.now() - t0;
1714
+ // Decorate signals with the local shortstat — useful in audit-trail.
1715
+ result.signals = {
1716
+ ...(result.signals || {}),
1717
+ diff_stats: shortstat,
1718
+ wall_ms: wallMs,
1719
+ changed_files_count: changedFiles.length,
1720
+ };
1721
+ return result;
1722
+ }
1723
+
1724
+ function runSmoke() {
1725
+ // `cwd: REPO_ROOT` + a relative script path is robust to spaces or other
1726
+ // shell metacharacters that may appear in the absolute repo path on some
1727
+ // machines.
1728
+ const okPms = shellOk(`"${process.execPath}" scripts/gds/smoke-gds.js`, { cwd: REPO_ROOT });
1729
+ return { ran: true, clean: okPms };
1730
+ }
1731
+
1732
+ // ---------- Step 3: merge + deploy chain ----------
1733
+
1734
+ function currentBranch() {
1735
+ return gitOk('git rev-parse --abbrev-ref HEAD');
1736
+ }
1737
+
1738
+ // Task #357: keep the 3 mechanical onboarding diagrams current on every deploy.
1739
+ // Regenerate them from live facts; if any changed, commit the refresh on this
1740
+ // branch so it ships with the deploy. Non-fatal — a render/network hiccup must
1741
+ // never block a ship; the diagram-drift routine will flag any miss.
1742
+ //
1743
+ // Returns a status string ('skipped-server' | 'regen-failed' | 'current' |
1744
+ // 'committed' | 'committed-nofatal') so the regression test (task 1046) can
1745
+ // prove the dev-box skip without git or a headless browser.
1746
+ function regenerateDiagrams() {
1747
+ // task 1046: on the server-mediated-publish path (a dev box — pushVia 'server'),
1748
+ // there is no headless browser, so render-diagrams.sh fails and gen-diagrams.js
1749
+ // exits non-zero AFTER writing the regenerated .mmd + assertions.json — which
1750
+ // shellOk() then treats as a failed regen and returns BEFORE the commit below,
1751
+ // leaving those files UNCOMMITTED in the builder's tree (had to be reverted by
1752
+ // hand). The box must not attempt the regen at all: skip here and let a
1753
+ // render-capable ship (the laptop path, which CAN render the PNGs) refresh them
1754
+ // as a matched .mmd+.png+assertions set; the diagram-drift routine backstops any
1755
+ // staleness in the meantime. Skipping entirely (vs --no-render) avoids committing
1756
+ // a .mmd/.png mismatch to main. Mirrors gen-diagrams.js's own non-fatal skip when
1757
+ // bash itself is absent (ENOENT).
1758
+ if (pushVia() === 'server') {
1759
+ console.log(' diagram regen skipped on the dev-box ship path (no GDS session for the conceptual diagrams) — a full-clone ship refreshes the .mmd; drift checker backstops staleness');
1760
+ return 'skipped-server';
1761
+ }
1762
+ // ADR 0110 pt.3 (task 2027): the .svg/.png RENDERS are now gitignored and
1763
+ // regenerated at deploy (deploy-prod.sh) — no longer committed here — so ship
1764
+ // only refreshes the TRACKED sources. Run gen-diagrams with --no-render: it
1765
+ // rewrites the mechanical .mmd + assertions.json from live facts WITHOUT
1766
+ // invoking the headless-Chromium render, which is why this no longer needs a
1767
+ // render-capable machine. The gitignored .svg/.png never appear in the git add
1768
+ // below (a directory add silently skips ignored paths).
1769
+ console.log(' regenerating onboarding diagram SOURCES from live facts (--no-render; renders are a deploy artifact, ADR 0110 pt.3)...');
1770
+ // process.execPath (quoted) instead of bare `node`: on Windows cmd.exe can't
1771
+ // resolve a bare `node` without a full PATH, the same failure #660 fixed for
1772
+ // the smoke-gds spawn. The quotes survive spaces in a Windows install path
1773
+ // (e.g. C:\Program Files\nodejs\node.exe).
1774
+ if (!shellOk(`"${process.execPath}" ${JSON.stringify(path.join(__dirname, 'gen-diagrams.js'))} --no-render`)) {
1775
+ console.log(' diagram regen failed (non-fatal) — drift checker will flag any staleness');
1776
+ return 'regen-failed';
1777
+ }
1778
+ const dirty = gitOk('git status --porcelain docs/onboarding/diagrams');
1779
+ if (!dirty) {
1780
+ console.log(' diagram sources already current — nothing to commit');
1781
+ return 'current';
1782
+ }
1783
+ shellOk('git add docs/onboarding/diagrams');
1784
+ if (shellOk('git commit -m "chore(diagrams): auto-regenerate diagram sources from live facts (task 357)"')) {
1785
+ console.log(' committed auto-regenerated diagrams');
1786
+ return 'committed';
1787
+ }
1788
+ return 'committed-nofatal';
1789
+ }
1790
+
1791
+ // task 1224 (Path B / ADR 0063): refresh the generated symbol skeleton from the shipped tree so the
1792
+ // altitude map can't drift. Pure JS (no headless browser, no render) — unlike the diagrams it is safe
1793
+ // on EVERY ship path, including the dev-box server-publish path. Non-fatal: a hiccup must never block
1794
+ // a ship. ADR 0110 (task 1926): docs/repo-map.md is now GITIGNORED + regenerated at deploy — the
1795
+ // generator still WRITES it (untracked, harmless), but ship commits ONLY the tracked nested-doc
1796
+ // blocks. It is deliberately absent from the git add/status lists below: `git add <ignored path>`
1797
+ // ABORTS the whole command (exit 1), which would drop the nested-block commit. The block bucket is
1798
+ // backstopped on main by fitness.js (`gen-repo-map --check-block`).
1799
+ function regenerateRepoMap() {
1800
+ console.log(' regenerating code symbol skeleton (repo map + nested-doc blocks) from the shipped tree...');
1801
+ if (!shellOk(`"${process.execPath}" ${JSON.stringify(path.join(__dirname, 'gen-repo-map.js'))}`)) {
1802
+ console.log(' repo-map regen failed (non-fatal) — CI --check-block backstops the tracked blocks');
1803
+ return 'regen-failed';
1804
+ }
1805
+ const dirty = gitOk('git status --porcelain src/bongos/CLAUDE.md src/bongos/routes/CLAUDE.md scripts/gds/CLAUDE.md');
1806
+ if (!dirty) {
1807
+ console.log(' nested-doc symbol blocks already current — nothing to commit (docs/repo-map.md is gitignored, ADR 0110)');
1808
+ return 'current';
1809
+ }
1810
+ shellOk('git add src/bongos/CLAUDE.md src/bongos/routes/CLAUDE.md scripts/gds/CLAUDE.md');
1811
+ if (shellOk('git commit -m "chore(repo-map): auto-regenerate nested-doc symbol blocks (task 1224)"')) {
1812
+ console.log(' committed auto-regenerated nested-doc symbol blocks');
1813
+ return 'committed';
1814
+ }
1815
+ return 'committed-nofatal';
1816
+ }
1817
+
1818
+ // Refresh the generated session-log index (docs/session-log-index.md) + the bounded CLAUDE.md §13
1819
+ // "recent sessions" snippet from the shipped tree's docs/session-logs/*.md (ADR 0062 §8, task 1226).
1820
+ // Pure JS (reads files, no DB, no headless browser) — safe on EVERY ship path including the dev-box
1821
+ // server-publish path. Non-fatal: a hiccup must never block a ship; the Path-C CI gate
1822
+ // (`gen-session-index --check`) backstops staleness on main.
1823
+ // Refresh the generated API docs (docs/api/openapi.json + docs/api-reference.md +
1824
+ // docs/routes-permissions.md) from the shipped route tree (task 1918). Pure JS (reads
1825
+ // route files, no DB, no headless browser) — safe on EVERY ship path incl. the dev-box
1826
+ // server-publish path. Non-fatal: a hiccup must never block a ship; the fitness
1827
+ // `gen-api-docs --check` gate backstops staleness on main.
1828
+ function regenerateApiDocs() {
1829
+ console.log(' regenerating OpenAPI spec + API reference from the shipped route tree...');
1830
+ if (!shellOk(`"${process.execPath}" ${JSON.stringify(path.join(__dirname, 'gen-api-docs.js'))}`)) {
1831
+ console.log(' api-docs regen failed (non-fatal) — CI --check backstops staleness');
1832
+ return 'regen-failed';
1833
+ }
1834
+ const dirty = gitOk('git status --porcelain docs/api/openapi.json docs/api-reference.md docs/routes-permissions.md');
1835
+ if (!dirty) {
1836
+ console.log(' API docs already current — nothing to commit');
1837
+ return 'current';
1838
+ }
1839
+ shellOk('git add docs/api/openapi.json docs/api-reference.md docs/routes-permissions.md');
1840
+ if (shellOk('git commit -m "chore(api-docs): auto-regenerate OpenAPI spec + reference (task 1918)"')) {
1841
+ console.log(' committed auto-regenerated API docs');
1842
+ return 'committed';
1843
+ }
1844
+ return 'committed-nofatal';
1845
+ }
1846
+
1847
+ // Refresh the generated typed client (clients/bongos-client) FROM the just-regenerated
1848
+ // spec, then keep the devbox vendored CJS copy in lockstep (task 2051). Runs right
1849
+ // after regenerateApiDocs so the client reflects the same route tree. Automatic here
1850
+ // (not fail-if-forgotten): before this, ship regenerated the spec but not the client,
1851
+ // so a route change left the client stale until the api_client test caught it in CI.
1852
+ // Non-fatal + safe on every ship path; the api_client freshness test backstops main.
1853
+ function regenerateApiClient() {
1854
+ console.log(' regenerating typed API client (clients/bongos-client) from the OpenAPI spec...');
1855
+ if (!shellOk(`"${process.execPath}" ${JSON.stringify(path.join(__dirname, 'gen-api-client.js'))}`)) {
1856
+ console.log(' api-client regen failed (non-fatal) — CI api_client freshness test backstops staleness');
1857
+ return 'regen-failed';
1858
+ }
1859
+ // The devbox Electron app can't require() clients/ at runtime, so it vendors the CJS
1860
+ // build (ADR 0045); keep the copy byte-identical (the api_client test asserts it).
1861
+ // Present only in the core repo — a consumer instance has no devbox-app/, so skip.
1862
+ // Resolve it WORKTREE-LOCALLY (like the generator above, __dirname=scripts/gds), NOT
1863
+ // via REPO_ROOT: on a ship from a linked worktree REPO_ROOT is the MAIN checkout, so a
1864
+ // REPO_ROOT path would vendor the main tree while gen-api-client just wrote THIS tree,
1865
+ // leaving this worktree's vendored copy stale → the api_client byte-identity test reds
1866
+ // the PR (task 2060 fix). __dirname keeps the generator, the vendored file, and the
1867
+ // git add all on the same tree.
1868
+ const vendorScript = path.join(__dirname, '..', '..', 'devbox-app', 'scripts', 'vendor-client.js');
1869
+ if (fs.existsSync(vendorScript)) {
1870
+ shellOk(`"${process.execPath}" ${JSON.stringify(vendorScript)}`);
1871
+ }
1872
+ const dirty = gitOk('git status --porcelain clients/bongos-client devbox-app/src/vendor/bongos-client.cjs');
1873
+ if (!dirty) {
1874
+ console.log(' API client already current — nothing to commit');
1875
+ return 'current';
1876
+ }
1877
+ shellOk('git add clients/bongos-client devbox-app/src/vendor/bongos-client.cjs');
1878
+ if (shellOk('git commit -m "chore(api-client): auto-regenerate typed client + devbox vendor (task 2051)"')) {
1879
+ console.log(' committed auto-regenerated API client');
1880
+ return 'committed';
1881
+ }
1882
+ return 'committed-nofatal';
1883
+ }
1884
+
1885
+ // ADR 0110 (task 1926): docs/session-log-index.md is GITIGNORED + deploy-regenerated — the generator
1886
+ // still writes it (untracked), but ship commits ONLY the tracked CLAUDE.md §13 snippet. The index is
1887
+ // omitted from the git add/status lists (an explicit `git add` of an ignored path aborts the commit);
1888
+ // the tracked snippet is backstopped on main by fitness.js (`gen-session-index --check-block`).
1889
+ function regenerateSessionIndex() {
1890
+ console.log(' regenerating session-log index + CLAUDE.md snippet from the shipped tree...');
1891
+ if (!shellOk(`"${process.execPath}" ${JSON.stringify(path.join(__dirname, 'gen-session-index.js'))}`)) {
1892
+ console.log(' session-index regen failed (non-fatal) — CI --check-block backstops the tracked snippet');
1893
+ return 'regen-failed';
1894
+ }
1895
+ const dirty = gitOk('git status --porcelain CLAUDE.md');
1896
+ if (!dirty) {
1897
+ console.log(' CLAUDE.md §13 snippet already current — nothing to commit (docs/session-log-index.md is gitignored, ADR 0110)');
1898
+ return 'current';
1899
+ }
1900
+ shellOk('git add CLAUDE.md');
1901
+ if (shellOk('git commit -m "chore(session-log): auto-regenerate CLAUDE.md §13 snippet (task 1226)"')) {
1902
+ console.log(' committed auto-regenerated CLAUDE.md §13 snippet');
1903
+ return 'committed';
1904
+ }
1905
+ return 'committed-nofatal';
1906
+ }
1907
+
1908
+ // Refresh the generated file-map sections (.claude/skills/ + .claude/scheduled-tasks/ trees in
1909
+ // docs/file-map.md) from what is on disk (ADR 0066, task 1276). Pure JS (reads dirs + the
1910
+ // docs/file-map.notes.json sidecar, no DB, no headless browser) — safe on EVERY ship path including
1911
+ // the dev-box server-publish path. Non-fatal: a hiccup must never block a ship; the Path-C CI gate
1912
+ // (`gen-file-map --check`, in fitness.js) backstops staleness on main. NB gen-file-map exits 2 when
1913
+ // a skill/routine has no note, so shellOk may report "failed" on a legit note-gap ship — that is
1914
+ // fine, we still commit whatever it wrote and CI surfaces the missing note.
1915
+ function regenerateFileMap() {
1916
+ console.log(' regenerating file-map sections (skills + scheduled-tasks) from the shipped tree...');
1917
+ shellOk(`"${process.execPath}" ${JSON.stringify(path.join(__dirname, 'gen-file-map.js'))}`);
1918
+ const dirty = gitOk('git status --porcelain docs/file-map.md');
1919
+ if (!dirty) {
1920
+ console.log(' file-map already current — nothing to commit');
1921
+ return 'current';
1922
+ }
1923
+ shellOk('git add docs/file-map.md');
1924
+ if (shellOk('git commit -m "chore(file-map): auto-regenerate skills + scheduled-tasks sections (task 1276)"')) {
1925
+ console.log(' committed auto-regenerated file-map sections');
1926
+ return 'committed';
1927
+ }
1928
+ return 'committed-nofatal';
1929
+ }
1930
+
1931
+ // ---------- merge serialization lock (#462 C4; extended in #269 C6) ----------
1932
+ //
1933
+ // autoMerge() mutates the SHARED main worktree (REPO_ROOT). Two concurrent
1934
+ // ships — or a ship racing a /merge-mode in another session — collide on that
1935
+ // single working tree. Observed 2026-05-30: a parallel session was mid-merge
1936
+ // in REPO_ROOT, so this ship's `git merge --ff-only` failed and it bailed with
1937
+ // the misleading "main is ahead of origin somehow".
1938
+ //
1939
+ // The lock primitives now live in src/bongos/merge-lock.js (V3.R50 / #269) so BOTH
1940
+ // ship.js AND the /merge-mode CLI take the SAME mutex — closing the
1941
+ // ship-vs-merge-mode and merge-mode-vs-merge-mode races that the in-ship.js
1942
+ // lock could not cover. See docs/recipes/multi-builder-merge.md.
1943
+
1944
+ // REPO_ROOT must be on main, not mid-merge, and clean before we mutate it.
1945
+ // Returns a human reason string if busy, or null if safe to proceed.
1946
+ function mainWorktreeBusy() {
1947
+ if (gitOk('git rev-parse -q --verify MERGE_HEAD', { cwd: REPO_ROOT })) {
1948
+ return 'a merge is already in progress';
1949
+ }
1950
+ const dirty = gitOk('git status --porcelain', { cwd: REPO_ROOT });
1951
+ if (dirty && dirty.trim().length > 0) {
1952
+ return 'the working tree is dirty or has unmerged files';
1953
+ }
1954
+ return null;
1955
+ }
1956
+
1957
+ // task 391 (idea #37): the verdict for a ship whose branch has no commits ahead
1958
+ // of main. Pure, so the gate is unit-tested.
1959
+ // 'has-commits' → normal path: there is a real diff to merge + deploy. (Also
1960
+ // the value when `git rev-list` failed/returned null — same as
1961
+ // the original `=== '0'` check, which only short-circuited an
1962
+ // exact '0'.)
1963
+ // 'db-only' → zero commits + explicit --db-only: a deliberate direct-SQL
1964
+ // ship (work already live in the prod DB); short-circuit to shipped.
1965
+ // 'refuse' → zero commits + NO --db-only: almost always a mistake
1966
+ // (uncommitted work / wrong branch / empty touches[]); refuse.
1967
+ function zeroCommitVerdict(commitsAhead, dbOnly) {
1968
+ if (commitsAhead !== '0') return 'has-commits';
1969
+ return dbOnly ? 'db-only' : 'refuse';
1970
+ }
1971
+
1972
+ async function autoMerge(taskId, valueSummary, { dbOnly = false } = {}) {
1973
+ const branch = currentBranch();
1974
+ if (!branch || branch === 'main' || branch === 'HEAD') {
1975
+ console.log(` auto-merge: skipped (current branch is ${branch})`);
1976
+ return false;
1977
+ }
1978
+
1979
+ // Refresh the data-driven diagrams before computing commits-ahead, so a
1980
+ // diagram-only refresh still counts as something to deploy.
1981
+ regenerateDiagrams();
1982
+ // Refresh the generated code symbol skeleton (Path B / task 1224) on the same beat.
1983
+ regenerateRepoMap();
1984
+ // Refresh the generated session-log index + the bounded CLAUDE.md §13 snippet (ADR 0062 §8 / task 1226).
1985
+ regenerateSessionIndex();
1986
+ // Refresh the generated file-map sections (skills + scheduled-tasks) on the same beat (ADR 0066 / task 1276).
1987
+ regenerateFileMap();
1988
+ // Refresh the generated OpenAPI spec + API reference from the shipped route tree (task 1918).
1989
+ regenerateApiDocs();
1990
+ // ...then the typed client FROM that spec (+ the devbox vendored copy) on the same beat (task 2051).
1991
+ regenerateApiClient();
1992
+
1993
+ // Resolve the deploy mode ONCE (task 1093 / C9) — it reads config/deploy.json;
1994
+ // reused at the fetch-bail check + the ci-land branch below.
1995
+ const deployMode = resolveDeployMode();
1996
+
1997
+ // DB-only / no-op ship detection (task #219). Some legitimate ships have
1998
+ // no commits to merge — e.g. a task whose work was a direct SQL write to
1999
+ // the production DB. The change is already live; the merge step has
2000
+ // nothing to push, merge, or deploy. Short-circuit so the caller's POST
2001
+ // /tasks/:id/ship still flips status to shipped instead of stranding the
2002
+ // task at confirmed forever.
2003
+ // A credential-less dev box can't `git fetch origin main` (private origin, no
2004
+ // creds — ADR 0053), but that's only fatal OFF the server-publish path: the
2005
+ // box's ADR-0055 path bundles against its LOCAL origin/main and the server tops
2006
+ // up main. Without this, the box dead-ends here and strands every ship at
2007
+ // confirmed (task 1027). The `!fetchedMain &&` short-circuit keeps the gh-probe
2008
+ // (pushVia) off the happy laptop path, so laptop behavior is byte-for-byte.
2009
+ const fetchedMain = shellOk('git fetch origin main');
2010
+ if (!fetchedMain && shouldBailOnFetchFailure(fetchedMain, deployMode, pushVia())) {
2011
+ console.log(' auto-merge: fetch failed; task stays at confirmed for /merge-mode');
2012
+ return false;
2013
+ }
2014
+ const commitsAhead = gitOk('git rev-list origin/main..HEAD --count');
2015
+ const verdict = zeroCommitVerdict(commitsAhead, dbOnly);
2016
+ if (verdict === 'refuse') {
2017
+ // task 391 (idea #37): the old code short-circuited ANY zero-commit branch
2018
+ // straight to shipped — silently advancing a claim with empty/wrong touches[]
2019
+ // or simply uncommitted work, awarding credit for no change. Now a zero-commit
2020
+ // ship must be DELIBERATELY declared with --db-only. Fail-safe: the claim is
2021
+ // already resolved by this point, so the task stays at confirmed and a plain
2022
+ // re-run resumes the merge leg (task 941) once they commit or pass --db-only.
2023
+ console.error('');
2024
+ console.error(' Ship refused: no commits ahead of main, and --db-only was not passed.');
2025
+ console.error(' A zero-commit ship is almost always a mistake (uncommitted work, wrong branch, or empty/incorrect touches[]).');
2026
+ console.error(' • If you have local changes, commit them, then re-ship.');
2027
+ console.error(' • If this is a DELIBERATE direct-SQL / DB-only ship (the work was a write straight to the prod');
2028
+ console.error(' DB — nothing to merge or deploy), re-run with --db-only:');
2029
+ console.error(` node scripts/gds/ship.js ${taskId} --db-only`);
2030
+ console.error(' The task stays at confirmed; nothing was merged or deployed.');
2031
+ return false;
2032
+ }
2033
+ if (verdict === 'db-only') {
2034
+ console.log(' auto-merge: deliberate empty ship (--db-only / --allow-empty) - no commits ahead of main; advancing to shipped without merge/deploy.');
2035
+ return true;
2036
+ }
2037
+
2038
+ // #679 / ADR 0042: in 'ci' deploy mode the operator's machine never merges or
2039
+ // SSHes the droplet — ship lands via PR auto-merge and CI deploys. The laptop
2040
+ // block below stays the default ('laptop' mode) so this is inert until the
2041
+ // deliberate cutover. The DB-only short-circuit above is shared by both modes.
2042
+ if (deployMode === 'ci') {
2043
+ return await ciLand(taskId, branch, valueSummary);
2044
+ }
2045
+
2046
+ console.log(` pushing ${branch} to origin...`);
2047
+ if (!gitArgvOk(['push', 'origin', branch])) {
2048
+ console.log(' auto-merge: push failed; task stays at confirmed for /merge-mode');
2049
+ return false;
2050
+ }
2051
+
2052
+ // Serialize the shared-main-worktree merge (#462 C4; #269 C6). Concurrent
2053
+ // ships AND manual /merge-mode runs queue on this one lock; if a live holder
2054
+ // is still merging past the wait window we bail cleanly to /merge-mode rather
2055
+ // than racing its working tree.
2056
+ if (!mergeLock.acquire(REPO_ROOT)) {
2057
+ console.log(' auto-merge: another active ship or /merge-mode run holds the merge lock; task stays at confirmed for /merge-mode');
2058
+ return false;
2059
+ }
2060
+ let mergedClean = false;
2061
+ try {
2062
+ // Never touch REPO_ROOT if a parallel session left it mid-merge or dirty —
2063
+ // this is the exact collision that previously bailed with the misleading
2064
+ // "main is ahead of origin somehow" (#462).
2065
+ const busy = mainWorktreeBusy();
2066
+ if (busy) {
2067
+ console.log(` auto-merge: main worktree busy (${busy}); task stays at confirmed for /merge-mode`);
2068
+ return false;
2069
+ }
2070
+
2071
+ console.log(` switching to main worktree (${REPO_ROOT}) for merge...`);
2072
+ // Same cleanup as preflight: drop macOS Finder/iCloud `* 2` ref duplicates
2073
+ // before fetching, otherwise `git fetch origin` fails with "bad object
2074
+ // refs/heads/<name> 2" and the auto-merge bails. Was a recurring 2026-05-10
2075
+ // failure mode — every ship of the day hit this and required /merge-mode.
2076
+ const dupRefs = cleanupMacFinderDupRefs(REPO_ROOT);
2077
+ if (dupRefs.length > 0) {
2078
+ console.log(` auto-merge: removed ${dupRefs.length} stray refs (macOS Finder duplicates)`);
2079
+ }
2080
+ if (!shellOk(`git fetch origin`, { cwd: REPO_ROOT })) {
2081
+ console.log(' auto-merge: fetch failed; task stays at confirmed for /merge-mode');
2082
+ return false;
2083
+ }
2084
+
2085
+ // The main worktree must currently have main checked out.
2086
+ const onMain = gitOk('git rev-parse --abbrev-ref HEAD', { cwd: REPO_ROOT });
2087
+ if (onMain !== 'main') {
2088
+ console.log(` auto-merge: main worktree is on ${onMain}, not main; task stays at confirmed`);
2089
+ return false;
2090
+ }
2091
+
2092
+ if (!gitArgvOk(['merge', '--ff-only', 'origin/main'], { cwd: REPO_ROOT })) {
2093
+ console.log(' auto-merge: main is ahead of origin somehow; task stays at confirmed');
2094
+ return false;
2095
+ }
2096
+
2097
+ // #681: build the merge argv and run it WITHOUT a shell. valueSummary is
2098
+ // DB-sourced (untrusted) — passing it as a literal argv element means
2099
+ // `$(...)`/backticks in it can never be interpreted as a command.
2100
+ if (!gitArgvOk(buildMergeArgv(branch, valueSummary), { cwd: REPO_ROOT })) {
2101
+ console.log(' auto-merge: merge failed (conflicts?); task stays at confirmed for manual /merge-mode');
2102
+ return false;
2103
+ }
2104
+
2105
+ console.log(' smoke against post-merge state...');
2106
+ if (!runSmoke().clean) {
2107
+ console.log(' auto-merge: post-merge smoke failed; task stays at confirmed; investigate');
2108
+ return false;
2109
+ }
2110
+
2111
+ console.log(' pushing merged main to origin...');
2112
+ // SEC #863: route through push-main.js, which carries the
2113
+ // OTB_ALLOW_MAIN_PUSH override so the pre-push permission gate lets THIS
2114
+ // graded+confirmed pipeline push through while still blocking ad-hoc
2115
+ // `git push origin main`. process.execPath (quoted) for the same
2116
+ // cross-platform reason as the other node spawns here (#660).
2117
+ if (!shellOk(`"${process.execPath}" ${JSON.stringify(path.join(__dirname, 'push-main.js'))}`, { cwd: REPO_ROOT })) {
2118
+ console.log(' auto-merge: push failed; task stays at confirmed');
2119
+ return false;
2120
+ }
2121
+
2122
+ console.log(' triggering deploy on droplet...');
2123
+ // Quote ~/deploy.sh so the LOCAL shell doesn't tilde-expand it to
2124
+ // /Users/<mac-user>/deploy.sh before ssh transmits the command. With
2125
+ // the quotes, ssh sends the literal `~/deploy.sh` and the REMOTE shell
2126
+ // expands it to /home/lars/deploy.sh. (Matches /merge-mode SKILL.md:81.)
2127
+ if (!shellOk(`ssh -o ConnectTimeout=10 ${DROPLET} "~/deploy.sh"`)) {
2128
+ // task 984 / idea 180: ~/deploy.sh's internal /healthz curl can race the
2129
+ // service-restart window (Node not yet bound) and exit non-zero even though
2130
+ // the deploy succeeded — which used to false-fail the ship and force a
2131
+ // needless /merge-mode. Before concluding failure, re-probe the public
2132
+ // /healthz with backoff (curl's own --retry; cross-platform, no sleep).
2133
+ // --retry-all-errors retries connection-refused during the bind window;
2134
+ // -f makes a non-200 an error so a 502/503 also retries. If it comes up
2135
+ // within the window the deploy really succeeded; only then-still-down fails.
2136
+ console.log(' auto-merge: ~/deploy.sh returned non-zero — re-probing /healthz with backoff before concluding...');
2137
+ if (shellOk('curl -fsS --max-time 10 --retry 5 --retry-delay 3 --retry-all-errors https://amazonprimea.com/healthz -o /dev/null')) {
2138
+ console.log(' auto-merge: /healthz 200 on re-probe — deploy actually succeeded (deploy.sh raced the restart window). Proceeding.');
2139
+ } else {
2140
+ console.log(' auto-merge: deploy failed (/healthz never returned 200 after retries); task stays at confirmed');
2141
+ return false;
2142
+ }
2143
+ }
2144
+
2145
+ // Merged + deployed — the feature branch is permanently in main's history
2146
+ // (--no-ff merge commit) and no longer needed on origin. Deleting it keeps
2147
+ // GitHub's "Create PR" affordance from offering empty PRs against zero-diff
2148
+ // branches and prevents builder confusion about whether a branch landed.
2149
+ // Local refs/heads ref stays so the worktree on the Mac isn't disturbed
2150
+ // (cleanup of stale local refs happens next `git fetch --prune`).
2151
+ // Non-blocking: a deploy that succeeded shouldn't be marked failed just
2152
+ // because the cleanup push fails (e.g. branch already deleted manually,
2153
+ // permission glitch, network blip).
2154
+ console.log(` cleaning up: deleting ${branch} on origin...`);
2155
+ if (!gitArgvOk(['push', 'origin', '--delete', branch], { cwd: REPO_ROOT })) {
2156
+ console.log(` warn: could not delete origin/${branch} — may already be gone (non-blocking)`);
2157
+ }
2158
+
2159
+ console.log(' flipping task to shipped...');
2160
+ mergedClean = true;
2161
+ return true;
2162
+ } finally {
2163
+ // Leave REPO_ROOT clean for the NEXT ship/merge no matter how we exited.
2164
+ // A failed merge here wedges everyone else: a conflict leaves the tree
2165
+ // mid-merge (MERGE_HEAD + conflicted index → mainWorktreeBusy() rejects all
2166
+ // future ships), and a failure AFTER the local merge commit but BEFORE push
2167
+ // (post-merge smoke/push/deploy) leaves main ahead-but-unpushed (the next
2168
+ // `git merge --ff-only origin/main` then fails with the misleading "main is
2169
+ // ahead of origin somehow"). Only normalize on the failure path — on the
2170
+ // success path we already pushed, so touching the tree is needless risk.
2171
+ // Best-effort: cleanup must never mask the real outcome. This is the
2172
+ // clean-failure twin of merge-lock.recoverWorktree() (which handles the
2173
+ // crash/stale-reclaim case where we never reach this finally at all).
2174
+ if (!mergedClean) {
2175
+ try {
2176
+ if (gitOk('git rev-parse -q --verify MERGE_HEAD', { cwd: REPO_ROOT })) {
2177
+ shellOk('git merge --abort', { cwd: REPO_ROOT });
2178
+ }
2179
+ if (gitOk('git rev-parse --abbrev-ref HEAD', { cwd: REPO_ROOT }) === 'main') {
2180
+ shellOk('git reset --hard origin/main', { cwd: REPO_ROOT });
2181
+ }
2182
+ } catch (_) {
2183
+ /* best-effort cleanup; the lock release below is what matters most */
2184
+ }
2185
+ }
2186
+ mergeLock.release(REPO_ROOT);
2187
+ }
2188
+ }
2189
+
2190
+ // ---------- Shared grade-step helpers (used by shipMain AND regradeMain) ----------
2191
+ //
2192
+ // #678: the grade step is factored out of shipMain so the `--regrade` recovery
2193
+ // path can reuse it verbatim instead of replicating ~50 lines (the trap that
2194
+ // learning #43 warned about — "runSubagentGrader in ship.js is NOT exported, so
2195
+ // replicate its ~10 lines"). These helpers are behavior-preserving lifts of the
2196
+ // inline blocks shipMain used before; both paths now share one implementation.
2197
+
2198
+ // Map a grader result into the POST /tasks/:id/grade request body. Forwards the
2199
+ // advisory sub-scores (economy/security/efficiency) only when present so the
2200
+ // server's validateAndScore re-records them (back-compat: omitted when null).
2201
+ function buildGradePayload(gradeResult, committedFiles = null) {
2202
+ return {
2203
+ // touches[] cleanup [3/8] (task 878): pass the real committed changed-files
2204
+ // so the server keys the shipper badges off the diff, not touches[].
2205
+ ...(Array.isArray(committedFiles) && committedFiles.length > 0
2206
+ ? { committed_files: committedFiles }
2207
+ : {}),
2208
+ subagent_output: {
2209
+ functional_fidelity: gradeResult.rubric_scores?.functional_fidelity,
2210
+ code_quality: gradeResult.rubric_scores?.code_quality,
2211
+ ...(gradeResult.rubric_scores?.economy != null
2212
+ ? { economy: gradeResult.rubric_scores.economy }
2213
+ : {}),
2214
+ ...(gradeResult.rubric_scores?.security_score != null
2215
+ ? { security_score: gradeResult.rubric_scores.security_score }
2216
+ : {}),
2217
+ ...(gradeResult.rubric_scores?.efficiency_score != null
2218
+ ? { efficiency_score: gradeResult.rubric_scores.efficiency_score }
2219
+ : {}),
2220
+ notes_md: gradeResult.notes_md,
2221
+ confidence: gradeResult.signals?.confidence ?? null,
2222
+ issues: gradeResult.issues,
2223
+ },
2224
+ signals: gradeResult.signals,
2225
+ };
2226
+ }
2227
+
2228
+ // Log the grader panel's own API spend (#486 / ADR 0028 path 1). Best-effort +
2229
+ // idempotent on (source, source_ref): a cost-log failure must never block a
2230
+ // ship/re-grade, and re-grading at the same HEAD is a no-op.
2231
+ async function logGraderPanelCost(taskId, gradeResult) {
2232
+ const panelCostUsd = Number(gradeResult.panel_cost_usd) || 0;
2233
+ if (panelCostUsd <= 0) return;
2234
+ const headSha = gitOk('git rev-parse --short HEAD') || 'nohead';
2235
+ const costPost = await (await cliClient()).cost.postCost({
2236
+ body: {
2237
+ task_id: Number(taskId),
2238
+ amount_usd: panelCostUsd,
2239
+ category: 'api',
2240
+ source: 'grader',
2241
+ source_ref: `grade-${taskId}-${headSha}`,
2242
+ description: `grader panel (${gradeResult.grader_kind || 'subagent'}) on #${taskId}`,
2243
+ },
2244
+ });
2245
+ // An idempotent conflict returns HTTP 200 {skipped:true} (cost.js), so
2246
+ // costPost.ok is TRUE on a skip — check `skipped` first.
2247
+ if (costPost.data && costPost.data.skipped) {
2248
+ console.log(` grader cost already logged for ${headSha} (idempotent skip)`);
2249
+ } else if (costPost.ok) {
2250
+ console.log(` grader cost logged: $${panelCostUsd.toFixed(4)} → cost_log (category=api, source=grader)`);
2251
+ } else {
2252
+ console.log(` grader cost log skipped (non-fatal): HTTP ${costPost.status}`);
2253
+ }
2254
+ }
2255
+
2256
+ // Run the subagent grader against the committed diff, POST the result to the
2257
+ // rank-open /tasks/:id/grade endpoint, and log the panel cost on success.
2258
+ // Returns the raw apiCall response so the caller can branch on .ok / read the
2259
+ // grade. Shared by the ship path and the --regrade path.
2260
+ //
2261
+ // V4.R08: before spawning the adversarial panel we compute the raw diff hash
2262
+ // and check whether a stored PASS grade exists for the same hash. On a match
2263
+ // we re-POST the stored grade (marking it reused in signals) and skip the
2264
+ // panel entirely — cheap re-ships with an unchanged diff stay fast.
2265
+ async function gradeAndRecord({ taskId, task, summary, baseline, committedFiles }) {
2266
+ // Compute the fingerprint of the raw (untruncated) diff so we can compare
2267
+ // against any stored grade, independent of the display-truncation cap.
2268
+ const currentHash = computeRawDiffHash(baseline);
2269
+
2270
+ if (currentHash) {
2271
+ const storedGradeRes = await (await cliClient()).tasks.getTasksId({ id: taskId, query: { include: 'grade' } });
2272
+ const storedGrade = storedGradeRes.ok ? (storedGradeRes.data.grade ?? null) : null;
2273
+
2274
+ if (shouldReuseGrade(storedGrade, currentHash)) {
2275
+ console.log(' Grade reuse: stored PASS grade matches current diff hash — skipping panel.');
2276
+ // Re-POST the stored result with a reused marker so the audit trail is
2277
+ // complete. We keep the original grader_kind and inject reuse metadata
2278
+ // into signals so a bare table scan can spot reused grades.
2279
+ const reusedSignals = {
2280
+ ...(storedGrade.signals || {}),
2281
+ reused_from_diff_hash: storedGrade.diff_hash,
2282
+ reused_at: new Date().toISOString(),
2283
+ };
2284
+ const reusedResult = {
2285
+ passed: storedGrade.passed,
2286
+ score: storedGrade.score,
2287
+ threshold: storedGrade.threshold,
2288
+ grader_kind: storedGrade.grader_kind,
2289
+ grader_version: storedGrade.grader_version,
2290
+ rubric_scores: storedGrade.rubric_scores,
2291
+ notes_md: storedGrade.notes_md,
2292
+ signals: reusedSignals,
2293
+ };
2294
+ const payload = { ...buildGradePayload(reusedResult, committedFiles), diff_hash: currentHash };
2295
+ return (await cliClient()).tasks.postTasksIdGrade({ id: taskId, body: payload });
2296
+ // No logGraderPanelCost — the panel didn't run, there's no cost to log.
2297
+ }
2298
+ }
2299
+
2300
+ const gradeResult = await runSubagentGrader({
2301
+ task,
2302
+ valueSummary: summary,
2303
+ baseline,
2304
+ changedFiles: committedFiles,
2305
+ });
2306
+ const payload = { ...buildGradePayload(gradeResult, committedFiles), diff_hash: currentHash ?? undefined };
2307
+ const gradePost = await (await cliClient()).tasks.postTasksIdGrade({ id: taskId, body: payload });
2308
+ // Log the panel's cost once the grade is recorded — independent of the
2309
+ // pass/fail VERDICT (the panel ran, and cost money, either way), but only
2310
+ // when the POST itself succeeded (a failed POST means nothing was recorded).
2311
+ if (gradePost.ok) await logGraderPanelCost(taskId, gradeResult);
2312
+ return gradePost;
2313
+ }
2314
+
2315
+ // Build the grade verdict display block as a string (per-axis scores + notes +
2316
+ // issues + next-step hints). Pure + exported so the #943 grader-unavailable vs
2317
+ // FAIL branch is unit-tested without capturing stdout. printGradeOutcome just
2318
+ // prints what this returns.
2319
+ function formatGradeOutcome(g, taskId) {
2320
+ const out = [];
2321
+ // #943: a TOTAL grader outage (every gating worker failed to spawn / the
2322
+ // grader was bypassed pre-spawn) is NOT a quality rejection — it's "the grader
2323
+ // could not run". Present it distinctly so a worker-infra failure never reaches
2324
+ // the builder as a misleading "FAIL · 0/10 — fix the issues" (the session-1659
2325
+ // false-FAIL). There are no issues to fix; the path forward is re-grade when the
2326
+ // grader is reachable, or manual-confirm.
2327
+ if (g.signals?.panel_outcome === 'unavailable') {
2328
+ const reason = g.signals?.grader_unavailable_reason;
2329
+ out.push('');
2330
+ out.push(`Subagent grade: GRADER UNAVAILABLE${reason ? ` (${reason})` : ''} · no quality signal produced — NOT a quality rejection`);
2331
+ if (g.notes_md) out.push(g.notes_md.split('\n').map((l) => ' ' + l).join('\n'));
2332
+ out.push('');
2333
+ out.push('The grader could not run (infra/outage/bypass) — there are no findings to fix. Task stays at completed.');
2334
+ out.push('When the grader is reachable again, re-grade:');
2335
+ out.push(` node scripts/gds/ship.js ${taskId} --regrade`);
2336
+ out.push('Or land it now via manual-confirm:');
2337
+ out.push(` curl -X POST -H "Authorization: Bearer <token>" https://amazonprimea.com/api/gds/tasks/${taskId}/confirm`);
2338
+ return out.join('\n');
2339
+ }
2340
+
2341
+ const verdict = g.passed ? 'PASS' : 'FAIL';
2342
+ out.push('');
2343
+ out.push(`Subagent grade: ${verdict} · blended ${g.score}/10 · threshold ${g.threshold} (both axes)`);
2344
+ if (g.rubric_scores) {
2345
+ const ff = g.rubric_scores.functional_fidelity;
2346
+ const cq = g.rubric_scores.code_quality;
2347
+ if (ff != null) out.push(` functional_fidelity: ${ff}/10`);
2348
+ if (cq != null) out.push(` code_quality: ${cq}/10`);
2349
+ }
2350
+ if (g.notes_md) out.push(g.notes_md.split('\n').map((l) => ' ' + l).join('\n'));
2351
+ if ((g.issues || []).length > 0) {
2352
+ out.push('');
2353
+ out.push(`Issues (${g.issues.length}):`);
2354
+ for (const it of g.issues) {
2355
+ const sev = String(it.severity || '?').padEnd(7);
2356
+ out.push(` [${sev}] ${it.file || '?'} — ${it.summary || ''}`);
2357
+ }
2358
+ }
2359
+ if (!g.passed) {
2360
+ out.push('');
2361
+ out.push('Grade failed — task stays at completed. Fix the issues above, commit, then re-grade:');
2362
+ out.push(` node scripts/gds/ship.js ${taskId} --regrade`);
2363
+ out.push('Last resort (only if the grade was wrong):');
2364
+ out.push(` curl -X POST -H "Authorization: Bearer <token>" https://amazonprimea.com/api/gds/tasks/${taskId}/confirm`);
2365
+ }
2366
+ return out.join('\n');
2367
+ }
2368
+
2369
+ // Print the grade verdict + per-axis scores + notes + issues. On a FAIL, print
2370
+ // both the re-grade hint (the cheap default) and the Archon override curl (the
2371
+ // last resort — see feedback_prefer_regrade_over_override). #943: a total grader
2372
+ // outage prints as "GRADER UNAVAILABLE", not a 0/10 FAIL — see formatGradeOutcome.
2373
+ function printGradeOutcome(g, taskId) {
2374
+ console.log(formatGradeOutcome(g, taskId));
2375
+ }
2376
+
2377
+ // Print the credits line + multiplier breakdown + idea bonus + unlocked
2378
+ // achievements from a grade/confirm response.
2379
+ function printCreditsAndAchievements({ postGradeStatus, credits, creditsRaw, creditsMultiplier, ideaBonusInfo, justUnlocked }) {
2380
+ console.log('');
2381
+ if (postGradeStatus === 'completed') {
2382
+ console.log(`Credits awarded: ${credits} (none — task at completed, grade not passed)`);
2383
+ } else if (postGradeStatus === 'confirmed') {
2384
+ console.log(`Credits awarded: ${credits} (landed at confirmed)`);
2385
+ } else {
2386
+ console.log(`Credits awarded: ${credits}`);
2387
+ }
2388
+ // Write the credits flag so the NEXT Claude turn plays the credits-awarded
2389
+ // sound via the Stop hook (task 582). Only when credits were actually awarded.
2390
+ if (typeof credits === 'number' && credits > 0) {
2391
+ const tmpDir = process.platform === 'win32'
2392
+ ? (process.env.TEMP || process.env.TMP || os.tmpdir()) : '/tmp';
2393
+ try { fs.writeFileSync(require('path').join(tmpDir, 'otb-credits'), ''); } catch (_) {}
2394
+ }
2395
+ if (creditsRaw != null && creditsMultiplier != null && Number(creditsMultiplier) !== 1.0) {
2396
+ const mult = Number(creditsMultiplier).toFixed(2).replace(/0$/, '').replace(/\.$/, '.0');
2397
+ console.log(` Kind multiplier: ${creditsRaw}c × ${mult}× → ${credits}c`);
2398
+ }
2399
+ if (ideaBonusInfo && ideaBonusInfo.ideaBonusAwarded > 0) {
2400
+ console.log(
2401
+ ` Idea-promotion bonus: +${ideaBonusInfo.ideaBonusAwarded}c paid to builder #${ideaBonusInfo.bonusBuilderId} (idea #${ideaBonusInfo.ideaId})`
2402
+ );
2403
+ }
2404
+ for (const ach of justUnlocked || []) {
2405
+ const bonus = ach.credit_bonus > 0 ? ` (+${ach.credit_bonus}c bonus)` : '';
2406
+ console.log(`✨ Achievement unlocked: ${ach.name}${bonus}`);
2407
+ }
2408
+ }
2409
+
2410
+ // chooseBroadcastTargets — the ship-broadcast channel router (task 990) now lives
2411
+ // in src/bongos/ship-broadcast.js (task 1045) so the server-mediated publish path
2412
+ // shares the exact same security-sensitive routing. Imported at the top of this
2413
+ // file; still re-exported below for tests/ship_broadcast_routing.mjs.
2414
+
2415
+ // task 1442: play the task-shipped "ship bell". ship.js runs as its own process
2416
+ // (not inside a Claude turn), so it plays the sound directly rather than via the
2417
+ // Stop hook. Called at the confirmed/shipped success milestone so it rings on
2418
+ // the ci/async landing path too — there ship.js exits at 'confirmed' and the
2419
+ // deploy finishes later server-side, which is exactly why the old post-deploy
2420
+ // call site never fired (bug #1442). Resolve the builder's chosen variant from
2421
+ // the local prefs file; fall back to 'ship'. Best-effort: playSound swallows all
2422
+ // errors and is a silent no-op with no audio player, so this can never fail a ship.
2423
+ function playShipBell() {
2424
+ let shipWav = 'ship';
2425
+ try {
2426
+ const prefsPath = require('path').join(os.homedir(), '.config', 'otb', 'sound-prefs.json');
2427
+ const raw = require('fs').readFileSync(prefsPath, 'utf8');
2428
+ const parsed = JSON.parse(raw);
2429
+ const ev = parsed && parsed.event_sounds && parsed.event_sounds['task-shipped'];
2430
+ if (typeof ev === 'string') shipWav = ev;
2431
+ } catch (_) { /* fall through to default */ }
2432
+ playSound(shipWav);
2433
+ }
2434
+
2435
+ // Step 3 of the lifecycle: confirmed → shipped. Merge the branch into main,
2436
+ // deploy, flip the task to shipped, and fire the Discord broadcast (only when
2437
+ // the change actually deploys — #94 / ADR 0032; best-effort). Side-effecting;
2438
+ // the early returns are control flow only.
2439
+ async function mergeAndDeploy({ taskId, summary, advanceToMerge, skipMerge, dbOnly = false }) {
2440
+ if (advanceToMerge && skipMerge) {
2441
+ console.log('');
2442
+ console.log('--no-merge: stopping at confirmed. Run /merge-mode later to land it.');
2443
+ return;
2444
+ }
2445
+ if (!advanceToMerge) return;
2446
+
2447
+ console.log('');
2448
+ console.log('Auto-merge chain (confirmed → shipped):');
2449
+ const merged = await autoMerge(taskId, summary, { dbOnly });
2450
+ if (!merged) return;
2451
+
2452
+ const ship = await (await cliClient()).tasks.postTasksIdShip({ id: taskId, body: {} });
2453
+ if (!ship.ok) {
2454
+ // task 1049: the server's publish-status reconcile-on-read may have already
2455
+ // flipped this task to shipped (a concurrent status read saw merge+deploy).
2456
+ // That's a SUCCESS — don't print it as a failure.
2457
+ if (ship.status === 409 && ship.data && ship.data.error === 'task_not_confirmed') {
2458
+ console.log(`Task #${taskId} is now: shipped (the server reconciled it from the merged PR).`);
2459
+ } else {
2460
+ console.error(` /tasks/${taskId}/ship failed:`, ship.data);
2461
+ return;
2462
+ }
2463
+ } else {
2464
+ console.log(`Task #${taskId} is now: shipped`);
2465
+ }
2466
+
2467
+ // task 1442: the task-shipped bell used to play HERE, but this point is only
2468
+ // reached on a SYNCHRONOUS merge+deploy. In ci/async landing mode (now the
2469
+ // norm) ship.js exits at 'confirmed' and never gets here, so the bell never
2470
+ // rang. It now plays at the confirmed/shipped success milestone via
2471
+ // playShipBell() (finishShip + resumeMerge), which covers both paths.
2472
+
2473
+ // If the builder was promoted during this ship, write the promotion flag so
2474
+ // the NEXT Claude turn plays the fanfare via the Stop hook.
2475
+ const TMP_FLAGS = process.platform === 'win32'
2476
+ ? (process.env.TEMP || process.env.TMP || os.tmpdir())
2477
+ : '/tmp';
2478
+ if (ship.data?.task?.graduatedTo) {
2479
+ try { fs.writeFileSync(require('path').join(TMP_FLAGS, 'otb-promotion'), ''); } catch (_) {}
2480
+ console.log(` Rank promotion: ${ship.data.task.graduatedTo} — fanfare queued.`);
2481
+ }
2482
+
2483
+
2484
+ if (summary) {
2485
+ // task 1749: the SERVER is now the SINGLE ship broadcaster. It emits
2486
+ // task.shipped on the confirmed→shipped transition from every entry point
2487
+ // (POST /ship, the publish-status reconcile-on-read, and the 5-min
2488
+ // reconciler sweep — all idempotent via db.shipTask), and the discord
2489
+ // module posts the #ship-feed line off that one event. This local post used
2490
+ // to fire IN ADDITION to the server's, so a single ship announced FOUR
2491
+ // messages (local #ship-news + #ship-feed, then the server's #ship-news +
2492
+ // #ship-feed). Defer unconditionally so exactly one broadcast fires — from
2493
+ // the server, after the deploy lands. (#ship-news itself is paused in
2494
+ // chooseBroadcastTargets, so that one broadcast is the #ship-feed line —
2495
+ // task 1749 / idea 458.) The security-sensitive fail-closed routing lives
2496
+ // server-side in ship-broadcast.postShipBroadcast, so it is preserved.
2497
+ console.log(' Discord broadcast: deferred to the server — it posts #ship-feed after the deploy lands.');
2498
+ }
2499
+ }
2500
+
2501
+ // Memory + session-digest push, gated on a successful land (confirmed/shipped).
2502
+ // Both are best-effort — a failure here is a warning, never a ship failure.
2503
+ async function postShipSync(taskId, postGradeStatus) {
2504
+ if (postGradeStatus !== 'confirmed' && postGradeStatus !== 'shipped') return null;
2505
+ console.log('');
2506
+ console.log('Memory sync (best-effort):');
2507
+ await syncMemoryOnShip();
2508
+ console.log('');
2509
+ console.log('Session upload (best-effort):');
2510
+ // task 1270: the upload returns { totalTokens, modelUsage } (or null) so the
2511
+ // completion card can show Claude usage + an estimated cost.
2512
+ return await uploadSessionOnShip(taskId);
2513
+ }
2514
+
2515
+ // Post-ship onboarding nudge (V3.R13 #237). Best-effort — never let a stale /me
2516
+ // read make the script exit non-zero after a successful ship.
2517
+ async function onboardingNudgeBestEffort() {
2518
+ try {
2519
+ const meRes = await (await cliClient()).me.getMe();
2520
+ if (meRes.ok) {
2521
+ console.log('');
2522
+ printOnboardingNudge(meRes.data);
2523
+ }
2524
+ } catch (_) {
2525
+ /* never block on a UX nudge */
2526
+ }
2527
+ }
2528
+
2529
+ // finishShip — the confirmed→shipped tail shared by shipMain + regradeMain (task
2530
+ // 1093 / C9): auto-merge + deploy, then the best-effort memory/session sync, the
2531
+ // Done line, and the onboarding nudge. Only this UNIFORM tail is shared;
2532
+ // printGradeOutcome + printCreditsAndAchievements stay caller-owned (their inputs
2533
+ // differ between the two paths). `doneLine` is a param so each caller keeps its
2534
+ // own wording ('Done.' vs the re-grade pass/fail message).
2535
+ // task 932 (Lars 2026-06-12): deterministic, no-LLM post-ship working-tree
2536
+ // close-out, folded into the delivery process (NOT a separate skill). After a
2537
+ // ship lands, scratch often lingers uncommitted — untracked files the preflight
2538
+ // lets through, generated artifacts, post-commit edits — making the session
2539
+ // hard to archive cleanly. This reports what's left and stashes it under a
2540
+ // labeled, RECOVERABLE stash so the tree is left clean + archive-ready and
2541
+ // nothing is lost. STRICTLY fail-open: the ship has already succeeded by the
2542
+ // time this runs, so nothing here may throw, prompt, or change the exit status —
2543
+ // a messy tree (or a stash failure) must never surface as a ship failure. The
2544
+ // caller gates this on a SUCCESSFUL land so a grade-failed 'completed' tree the
2545
+ // builder still needs to fix is never stashed out from under them.
2546
+ function postShipCloseout(taskId) {
2547
+ try {
2548
+ const out = gitOk('git status --porcelain');
2549
+ if (out == null) return; // git unavailable / not a repo — nothing to reconcile
2550
+ const plan = closeoutPlan(out, taskId);
2551
+ if (plan.clean) {
2552
+ console.log('Close-out: working tree clean — session is archive-ready.');
2553
+ return;
2554
+ }
2555
+ console.log('');
2556
+ console.log(`Close-out: ${plan.leftover.length} uncommitted/untracked path(s) remain after the ship:`);
2557
+ for (const f of plan.leftover.slice(0, 15)) console.log(` ${f}`);
2558
+ if (plan.leftover.length > 15) console.log(` … and ${plan.leftover.length - 15} more`);
2559
+ // --include-untracked so scratch the preflight waved through is captured too.
2560
+ // Label is `otb-postship-<numericId>` — no shell metacharacters.
2561
+ const stashed = shellOk(`git stash push --include-untracked -m "${plan.stashLabel}"`);
2562
+ if (stashed) {
2563
+ console.log(`Close-out: stashed the leftover as "${plan.stashLabel}" — tree is now clean + archive-ready.`);
2564
+ console.log(' Recover anytime: git stash list → git stash pop');
2565
+ } else {
2566
+ console.log('Close-out: could not stash the leftover; left it in place (the ship already succeeded — tidy up manually when convenient).');
2567
+ }
2568
+ } catch (err) {
2569
+ // Fail-open — a post-ship close-out problem must never look like a ship failure.
2570
+ console.log(`Close-out: skipped (${err && err.message ? err.message : 'error'}).`);
2571
+ }
2572
+ }
2573
+
2574
+ async function finishShip({ taskId, summary, advanceToMerge, skipMerge, postGradeStatus, doneLine, card, dbOnly = false }) {
2575
+ await mergeAndDeploy({ taskId, summary, advanceToMerge, skipMerge, dbOnly });
2576
+ // task 1719: mergeAndDeploy may have landed the task to 'shipped' IN-SESSION — the
2577
+ // enable-success poll (pollCiDeploy) or, since task 1718, the enable-failure server
2578
+ // poll (pollServerPublish). finishShip was handed postGradeStatus from the CONFIRM
2579
+ // step ('confirmed'), so without re-reading it the completion card renders the stale
2580
+ // 'confirmed — landing' banner AND printCompletionCard writes a pending-land
2581
+ // breadcrumb — the SHIPPED banner then only appears on a LATER prompt, in whatever
2582
+ // session is next (the owner-reported confusion). Re-fetch the authoritative status
2583
+ // (exactly as resumeMerge does, task 1275) so a real in-session land shows the
2584
+ // SHIPPED banner here and writes no breadcrumb. Best-effort: any read failure keeps
2585
+ // today's behavior; the grade-fail path (advanceToMerge=false) never re-fetches.
2586
+ let landedStatus = postGradeStatus;
2587
+ if (advanceToMerge && (postGradeStatus === 'confirmed' || postGradeStatus === 'shipped')) {
2588
+ try {
2589
+ const t = await (await cliClient()).tasks.getTasksId({ id: taskId });
2590
+ const live = (t.ok && t.data && t.data.task) ? t.data.task.status : null;
2591
+ landedStatus = reconciledCardStatus(live, postGradeStatus);
2592
+ } catch (_) { /* keep postGradeStatus — the land above already awaited its outcome */ }
2593
+ }
2594
+ // Memory + session sync (#274 / 6D.1 / ADR 0027) — postShipSync itself gates on
2595
+ // a successful land (confirmed/shipped); both legs are best-effort. It returns
2596
+ // the session usage ({ totalTokens, modelUsage } | null) so the completion card
2597
+ // can show Claude usage + an estimated cost (task 1270).
2598
+ const usage = await postShipSync(taskId, landedStatus);
2599
+ console.log('');
2600
+ console.log(doneLine);
2601
+ await onboardingNudgeBestEffort();
2602
+ // task 1270: the ONE deterministic completion card — the same shape every ship.
2603
+ // Replaces the agent's free-form prose recap (the builder-ship skill renders the
2604
+ // rich in-feed card from the HTML this writes; the monospace card below is the
2605
+ // always-printed fallback + the headless summary). Strictly best-effort.
2606
+ // task 1719: pass the reconciled status so a just-landed ship shows SHIPPED (and
2607
+ // writes no breadcrumb), not the stale pre-merge 'confirmed'.
2608
+ await printCompletionCard({ taskId, postGradeStatus: landedStatus, card, usage });
2609
+ // task 932: reconcile the working tree — ONLY on a successful land. A
2610
+ // grade-failed ship stays at 'completed'; leave that tree untouched so the
2611
+ // builder can fix findings + re-ship.
2612
+ if (landedStatus === 'confirmed' || landedStatus === 'shipped') {
2613
+ // task 1442: ring the ship bell at the success milestone. 'confirmed' counts —
2614
+ // in ci/async mode the work is done + credited here and the deploy lands later
2615
+ // server-side, so this is the moment the builder experiences as "shipped".
2616
+ playShipBell();
2617
+ postShipCloseout(taskId);
2618
+ }
2619
+ }
2620
+
2621
+ // task 1275: sum the committed diffstat (added/removed lines) over baseline...HEAD.
2622
+ // Shared by the completion card and the in-review card. Best-effort: returns
2623
+ // nulls when there's no baseline or git is unavailable.
2624
+ function diffstatFor(baseline) {
2625
+ if (!baseline) return { added: null, removed: null };
2626
+ const numstat = gitOk(`git diff --numstat ${baseline}...HEAD`) || gitOk(`git diff --numstat ${baseline} HEAD`);
2627
+ if (!numstat) return { added: null, removed: null };
2628
+ let added = 0;
2629
+ let removed = 0;
2630
+ for (const line of numstat.split('\n')) {
2631
+ const [a, r] = line.split('\t');
2632
+ if (a !== '-' && a != null && a !== '') added += Number(a) || 0;
2633
+ if (r !== '-' && r != null && r !== '') removed += Number(r) || 0;
2634
+ }
2635
+ return { added, removed };
2636
+ }
2637
+
2638
+ // task 1270: build + print the deterministic completion card. Prints the
2639
+ // alignment-proof monospace card to stdout (always), then writes the branded
2640
+ // in-feed HTML to a temp file and prints an [otb-card-html] marker the
2641
+ // builder-ship skill picks up to render it in the chat feed. Every step is
2642
+ // best-effort — a summary card must NEVER fail or undo a completed ship.
2643
+ async function printCompletionCard({ taskId, postGradeStatus, card, usage }) {
2644
+ if (!card) return;
2645
+ try {
2646
+ const shipCard = require('../../modules/lifecycle/ship-card');
2647
+
2648
+ // Diffstat from the committed range the grader used (baseline...HEAD).
2649
+ const fileCount = Array.isArray(card.files) && card.files.length ? card.files.length : null;
2650
+ const { added, removed } = diffstatFor(card.baseline);
2651
+
2652
+ // Deployed commit (shipped only): prefer prod /version, fall back to short HEAD.
2653
+ let commit = null;
2654
+ if (postGradeStatus === 'shipped') {
2655
+ const v = fetchDeployedVersion();
2656
+ commit = (v && v.commit) ? String(v.commit).slice(0, 7) : (gitOk('git rev-parse --short HEAD') || null);
2657
+ }
2658
+
2659
+ // Running drachmae total + the builder's render-widgets knob (task 1279),
2660
+ // both from one /me fetch (best-effort).
2661
+ let total = null;
2662
+ let widgetsEnabled = true;
2663
+ try {
2664
+ const me = await (await cliClient()).me.getMe();
2665
+ if (me.ok && me.data) {
2666
+ if (me.data.builder) total = me.data.builder.total_credits;
2667
+ if (me.data.render) widgetsEnabled = me.data.render.widgets_enabled !== false;
2668
+ }
2669
+ } catch (_) { /* total + knob are optional; default widgets on */ }
2670
+
2671
+ // Token count + estimated cost from the session usage. Split the tokens into
2672
+ // 'new' (fresh input + output + first-time cache writes) vs 'cached' re-reads
2673
+ // (cache_read — the same large context re-read every turn) so the card can
2674
+ // lead with cost and not with a single all-in count that cache re-reads make
2675
+ // look alarmingly huge (task 1765). The per-model breakdown is already on hand
2676
+ // via usage.modelUsage — no new server data needed.
2677
+ let tokens = null;
2678
+ let costUsd = null;
2679
+ let newTokens = null;
2680
+ let cachedTokens = null;
2681
+ if (usage) {
2682
+ tokens = usage.totalTokens;
2683
+ const mu = usage.modelUsage || {};
2684
+ let cache = 0;
2685
+ let fresh = 0;
2686
+ let sawUsage = false;
2687
+ for (const u of Object.values(mu)) {
2688
+ if (!u) continue;
2689
+ sawUsage = true;
2690
+ cache += Number(u.cache_read) || 0;
2691
+ fresh += (Number(u.input) || 0) + (Number(u.output) || 0) + (Number(u.cache_creation) || 0);
2692
+ }
2693
+ if (sawUsage) { newTokens = fresh; cachedTokens = cache; }
2694
+ try {
2695
+ const { priceModelUsage } = require('../../src/bongos/llm-pricing');
2696
+ costUsd = priceModelUsage(mu).true_cost_usd;
2697
+ } catch (_) { /* cost is optional */ }
2698
+ }
2699
+
2700
+ const cardInputs = {
2701
+ postGradeStatus,
2702
+ taskId,
2703
+ title: card.title,
2704
+ valueSummary: card.valueSummary,
2705
+ notes: card.notes,
2706
+ fileCount,
2707
+ added,
2708
+ removed,
2709
+ grade: card.grade || null,
2710
+ credits: { awarded: card.creditsAwarded, total },
2711
+ usage: { tokens, costUsd, newTokens, cachedTokens },
2712
+ deploy: { commit },
2713
+ };
2714
+ const model = shipCard.buildCardModel(cardInputs);
2715
+
2716
+ // Print the monospace card + write the branded HTML for the in-feed renderer
2717
+ // (shared emitCard, task 1275). The skill reads the printed path and passes
2718
+ // the contents to the visualization tool — the agent authors nothing, so the
2719
+ // card is identical every ship. task 1279: when the builder has widgets off,
2720
+ // emitCard prints only the monospace card (no HTML written or relayed).
2721
+ shipCard.emitCard(model, taskId, { widgetsEnabled });
2722
+
2723
+ // task 1687: in CI deploy mode this card is 'confirmed — landing' and the real
2724
+ // confirmed->shipped land happens minutes later, server-side — so the SHIPPED
2725
+ // banner would never render in-session. Drop a pending-land breadcrumb; the
2726
+ // land-watch hook renders the shipped card on a later prompt once it lands.
2727
+ // Best-effort: a breadcrumb must never fail a ship (mirrors the card itself).
2728
+ if (postGradeStatus === 'confirmed') {
2729
+ try { require('./land-watch').writeBreadcrumb(taskId, { widgetsEnabled, cardInputs }); }
2730
+ catch (_) { /* breadcrumb is best-effort */ }
2731
+ }
2732
+ } catch (_) {
2733
+ /* a summary card must never fail a ship */
2734
+ }
2735
+ }
2736
+
2737
+ // Decide whether a builder may re-grade a task via `--regrade` (#678). Pure so
2738
+ // it's unit-testable. A re-grade is allowed iff the task is stuck at `completed`
2739
+ // (a ship whose grade failed) AND the caller either last held the claim or is an
2740
+ // Archon. Every other status gets a tailored, actionable message. Note this is a
2741
+ // client-side UX guard, not a trust boundary: /tasks/:id/grade is rank-open by
2742
+ // design (the subagent grade is the authority, not builder identity).
2743
+ function evaluateRegradeEligibility({ task, lastClaim, builder }) {
2744
+ if (!task) {
2745
+ return { ok: false, code: 'task_not_found', reason: 'Task not found.' };
2746
+ }
2747
+ const status = task.status;
2748
+ if (status !== 'completed') {
2749
+ let reason;
2750
+ if (status === 'confirmed') {
2751
+ reason = `Task #${task.id} is already 'confirmed' — its grade passed. Nothing to re-grade; run /merge-mode if it hasn't shipped.`;
2752
+ } else if (status === 'shipped') {
2753
+ reason = `Task #${task.id} is already 'shipped'. Done.`;
2754
+ } else if (status === 'active' || status === 'ready') {
2755
+ reason = `Task #${task.id} is '${status}', not 'completed'. Claim it and run a normal /builder-ship — --regrade only re-grades a ship that landed at 'completed'.`;
2756
+ } else {
2757
+ reason = `Task #${task.id} is '${status}'. --regrade only applies to a task stuck at 'completed' after a failed grade.`;
2758
+ }
2759
+ return { ok: false, code: 'not_completed', reason, status };
2760
+ }
2761
+ const isArchon = !!builder && builder.rank === 'archon';
2762
+ const owns = !!lastClaim && !!builder && Number(lastClaim.builder_id) === Number(builder.id);
2763
+ if (!isArchon && !owns) {
2764
+ const holder = lastClaim?.github_login ? `@${lastClaim.github_login}` : 'another builder';
2765
+ return {
2766
+ ok: false,
2767
+ code: 'not_owner',
2768
+ reason: `Task #${task.id} was last claimed by ${holder}. Only that builder or an Archon can re-grade it.`,
2769
+ };
2770
+ }
2771
+ return {
2772
+ ok: true,
2773
+ code: 'eligible',
2774
+ via: owns ? 'owner' : 'archon',
2775
+ reason: owns ? 'you last held this claim' : 'archon re-grade',
2776
+ };
2777
+ }
2778
+
2779
+ // task 941 (V4.R03 — idempotent ship re-drive): decide what a CLAIM-LESS
2780
+ // `ship.js <id>` re-run should do. The claim is consumed at the resolve step, so
2781
+ // a ship that fails AFTER it (grade fail → stuck `completed`; merge/deploy fail →
2782
+ // stuck `confirmed`, the task-1027 nightmare) left a re-run dead-ending at "you
2783
+ // don't have an active claim" — the builder had to know the `--regrade` flag (and
2784
+ // for `confirmed` there was NO path at all). This makes a PLAIN re-run resume
2785
+ // from the stalled leg: `completed` → re-grade, `confirmed` → re-merge/deploy.
2786
+ // Pure + exported so the truth table is unit-tested with no git/network. Returns
2787
+ // { action, via?, reason? } where action ∈
2788
+ // resume-grade | resume-merge | done | claim-first | error
2789
+ function holderLabel(lastClaim) {
2790
+ return lastClaim?.github_login ? `@${lastClaim.github_login}` : 'another builder';
2791
+ }
2792
+ function evaluateResumeAction({ task, lastClaim, builder }) {
2793
+ if (!task) return { action: 'error', reason: 'Task not found.' };
2794
+ const status = task.status;
2795
+ if (status === 'shipped') {
2796
+ return { action: 'done', reason: `Task #${task.id} is already 'shipped'. Nothing to do.` };
2797
+ }
2798
+ const isArchon = !!builder && builder.rank === 'archon';
2799
+ const owns = !!lastClaim && !!builder && Number(lastClaim.builder_id) === Number(builder.id);
2800
+ if (status === 'completed' || status === 'confirmed') {
2801
+ const leg = status === 'completed' ? 'grade' : 'merge';
2802
+ if (!isArchon && !owns) {
2803
+ return {
2804
+ action: 'error',
2805
+ reason: `Task #${task.id} is at '${status}' but was last claimed by ${holderLabel(lastClaim)}. ` +
2806
+ `Only that builder or an Archon can resume the ${leg}.`,
2807
+ };
2808
+ }
2809
+ return {
2810
+ action: status === 'completed' ? 'resume-grade' : 'resume-merge',
2811
+ via: owns ? 'owner' : 'archon',
2812
+ };
2813
+ }
2814
+ if (status === 'active') {
2815
+ return {
2816
+ action: 'claim-first',
2817
+ reason: `Task #${task.id} is 'active' (claimed) but not in THIS session. Ship from the session that ` +
2818
+ `claimed it, or \`node scripts/gds/release.js ${task.id} --force\` here, then re-claim.`,
2819
+ };
2820
+ }
2821
+ if (status === 'ready' || status === 'backlog' || status === 'blocked') {
2822
+ return {
2823
+ action: 'claim-first',
2824
+ reason: `Task #${task.id} is '${status}' — claim it first: \`node scripts/gds/claim.js ${task.id}\`.`,
2825
+ };
2826
+ }
2827
+ return { action: 'error', reason: `Task #${task.id} is '${status}'. Nothing to resume.` };
2828
+ }
2829
+
2830
+ // task 941: resume a `confirmed`-but-unshipped task — the merge/deploy leg only
2831
+ // (it already passed the grade + earned credits; this is the leg that stalls when
2832
+ // a box can't reach the server publish, a poll window expires, etc.). Mirrors the
2833
+ // tail of a normal ship: preflight → mergeAndDeploy → post-ship sync.
2834
+ async function resumeMerge(taskId, { summary, skipMerge, force, task, dbOnly = false }) {
2835
+ console.log(`Resuming merge + deploy for task #${taskId}: ${task?.title || ''}`);
2836
+ // Same working-tree-clean / branch-freshness guard a first ship runs before
2837
+ // the land. head_sha is unused on this leg; touches is only for preflight.
2838
+ const claimLike = { touches: task?.touches || [], head_sha: null, task_id: taskId, title: task?.title };
2839
+ console.log('Preflight (working tree + branch freshness)...');
2840
+ const pre = preflight(claimLike, { force });
2841
+ if (!pre.ok) {
2842
+ console.error('');
2843
+ console.error(`Resume aborted: ${pre.reason}. Task stays at confirmed.`);
2844
+ process.exit(1);
2845
+ }
2846
+ // The first ship stored value_summary; reuse it so the merge commit + broadcast
2847
+ // carry meaningful text even when --summary is omitted on the re-run.
2848
+ const effSummary = summary || task?.value_summary || '';
2849
+ await mergeAndDeploy({ taskId, summary: effSummary, advanceToMerge: true, skipMerge, dbOnly });
2850
+ const usage = await postShipSync(taskId, 'confirmed');
2851
+ console.log('');
2852
+ console.log('Done — merge/deploy resume complete.');
2853
+ playShipBell(); // task 1442: ring the bell when a stalled ship finally lands.
2854
+ await onboardingNudgeBestEffort();
2855
+ // task 1275: the resume path lands a stalled ship — show the completion card
2856
+ // too (the gap surfaced on task 1270's own ship). Re-fetch the status so the
2857
+ // card reflects whether it actually reached shipped. Best-effort.
2858
+ try {
2859
+ const t = await (await cliClient()).tasks.getTasksId({ id: taskId });
2860
+ const status = (t.ok && t.data && t.data.task) ? t.data.task.status : 'shipped';
2861
+ await printCompletionCard({
2862
+ taskId, postGradeStatus: status, usage,
2863
+ card: { title: task?.title, valueSummary: effSummary, notes: '', creditsAwarded: null },
2864
+ });
2865
+ } catch (_) { /* the resume already succeeded; the card is a bonus */ }
2866
+ }
2867
+
2868
+ // ---------- Main flow ----------
2869
+
2870
+ async function shipMain() {
2871
+ await requireSession();
2872
+ const args = process.argv.slice(2);
2873
+ const taskId = Number(args[0]);
2874
+ if (!Number.isFinite(taskId)) {
2875
+ console.error('usage: node scripts/gds/ship.js <task-id> --notes "..." --summary "..." [--surprise "text" | --surprise none | --no-surprise] [--no-merge] [--db-only] [--verified] [--allow-empty]');
2876
+ process.exit(2);
2877
+ }
2878
+ const notes = arg('--notes', args) || '';
2879
+ const summary = arg('--summary', args) || '';
2880
+ const skipMerge = args.includes('--no-merge');
2881
+ const force = args.includes('--force');
2882
+ // task 391 (idea #37): a deliberate zero-commit / direct-SQL ship. Without it,
2883
+ // a branch with no commits ahead of main is REFUSED (not silently shipped).
2884
+ const dbOnly = args.includes('--db-only') || args.includes('--allow-empty');
2885
+ // task 1053 (F): explicit attestation that a verification task's verify
2886
+ // actually passed. Required to confirm a kind='verify' task (the server
2887
+ // enforces it too); a no-op for every other task.
2888
+ const verified = args.includes('--verified');
2889
+ // #853 (ADR 0041): `let` not `const` — when the server has the temporary
2890
+ // global grader bypass ON (surfaced as me.grader_bypass below), we force the
2891
+ // skip-grade/confirm path for everyone so no builder runs the subagent grader
2892
+ // during a grader outage window.
2893
+ let skipGrade = args.includes('--skip-grade');
2894
+ // task 1365: explicit acknowledgement that this task legitimately has NO
2895
+ // committed artifact (a pure DB-only / no-op task). Required for the
2896
+ // empty-diff guard below to ALLOW an empty committed diff; without it, an
2897
+ // empty diff is refused (it used to slip through under --skip-grade — the
2898
+ // task-1363 phantom ship). Never overrides a dirty working tree.
2899
+ const allowEmpty = args.includes('--allow-empty');
2900
+ // Surprise flag handling — see captureSurpriseLearning(). Resolved below
2901
+ // once we know which claim is being shipped (so touches[] can become tags).
2902
+ const surpriseRaw = arg('--surprise', args);
2903
+ const surpriseProvided = surpriseRaw !== null;
2904
+ const noSurprise = args.includes('--no-surprise');
2905
+
2906
+ const me = await (await cliClient()).me.getMe();
2907
+ if (!me.ok) {
2908
+ console.error('failed to load profile:', me.data);
2909
+ process.exit(1);
2910
+ }
2911
+ // #853 (ADR 0041): honor the server-side global grader bypass. When ON, every
2912
+ // builder lands work without the subagent grader (the /tasks/:id/confirm gate
2913
+ // is relaxed to any-builder server-side to match). Default OFF.
2914
+ if (me.data.grader_bypass && !skipGrade) {
2915
+ skipGrade = true;
2916
+ console.log('Note: GRADER_BYPASS_ENABLED is on server-side — skipping the subagent grader and confirming directly (temporary outage window).');
2917
+ }
2918
+ const claims = me.data.active_claims || (me.data.active_claim ? [me.data.active_claim] : []);
2919
+ const ac = claims.find((c) => Number(c.task_id) === taskId);
2920
+ if (!ac) {
2921
+ // task 941 (V4.R03): no active claim in THIS session is NOT automatically a
2922
+ // dead-end. The claim is consumed at resolve, so a ship that stalled AFTER it
2923
+ // (grade fail → `completed`; merge/deploy fail → `confirmed`, the #1027 case)
2924
+ // leaves no claim — but a plain re-run should RESUME the stalled leg, not
2925
+ // strand the task. Route on the task's current status + ownership.
2926
+ const tRes = await (await cliClient()).tasks.getTasksId({ id: taskId, query: { include: 'last_claim' } });
2927
+ const task = tRes.ok ? tRes.data.task : null;
2928
+ const lastClaim = tRes.ok ? (tRes.data.last_claim || null) : null;
2929
+ const action = evaluateResumeAction({ task, lastClaim, builder: me.data.builder });
2930
+ if (action.action === 'resume-grade') {
2931
+ console.log(`Task #${taskId} is at 'completed' (grade stalled) and ${action.via === 'owner' ? 'you last held the claim' : 'you are an Archon'} — resuming the grade (no re-claim needed).`);
2932
+ return regradeMain();
2933
+ }
2934
+ if (action.action === 'resume-merge') {
2935
+ console.log(`Task #${taskId} is at 'confirmed' (merge/deploy stalled) and ${action.via === 'owner' ? 'you last held the claim' : 'you are an Archon'} — resuming the merge + deploy (no re-claim needed).`);
2936
+ return resumeMerge(taskId, { summary, skipMerge, force, task, dbOnly });
2937
+ }
2938
+ if (action.action === 'done') {
2939
+ console.log(action.reason);
2940
+ return;
2941
+ }
2942
+ console.error(action.reason || `You don't have an active claim on task #${taskId}.`);
2943
+ if (claims.length > 0) {
2944
+ console.error(`Your active claims:`);
2945
+ for (const c of claims) console.error(` #${c.task_id}: ${c.title}`);
2946
+ }
2947
+ process.exit(1);
2948
+ }
2949
+
2950
+ // ---- Hard cap: refuse to ship over the monthly budget (V3.R84 #302, C3) ----
2951
+ // Shipping runs the subagent grader, which spends API $. If this builder is at
2952
+ // or over their monthly budget, refuse here — BEFORE the grader runs and BEFORE
2953
+ // the claim resolves, so the claim stays active and no cost is incurred. An
2954
+ // Archon lifts the block by raising the builder's budget
2955
+ // (PATCH /builders/:id/budget). Fail-open: a missing/unreadable cost status
2956
+ // (e.g. an older server) never blocks a ship.
2957
+ const costStatus = me.data.cost;
2958
+ if (costStatus && costStatus.level === 'over') {
2959
+ console.error('');
2960
+ console.error(`Ship refused: monthly budget exceeded — $${costStatus.spend_mtd_usd} of $${costStatus.budget_mtd_usd} spent this month (${costStatus.pct_used}%).`);
2961
+ console.error('Shipping runs the quality grader, which spends API budget. No claim resolved, no credits awarded.');
2962
+ console.error('Ask an Archon to raise your monthly budget (PATCH /builders/:id/budget), then re-ship.');
2963
+ process.exit(1);
2964
+ }
2965
+
2966
+ // ---- Surprise gate: forced "anything surprise you?" prompt ----
2967
+ //
2968
+ // Resolve the user's intent into a single value: either a non-empty surprise
2969
+ // string (capture a learning) or empty (no surprise, ship normally).
2970
+ // --surprise "text" → capture
2971
+ // --surprise none → no surprise
2972
+ // --surprise "" → no surprise
2973
+ // --no-surprise → no surprise
2974
+ // neither + TTY → prompt
2975
+ // neither + non-TTY → no surprise, with stderr warning
2976
+ let surpriseText = '';
2977
+ if (surpriseProvided) {
2978
+ const v = (surpriseRaw || '').trim();
2979
+ surpriseText = (v === '' || v.toLowerCase() === 'none') ? '' : v;
2980
+ } else if (noSurprise) {
2981
+ surpriseText = '';
2982
+ } else if (process.stdout.isTTY && process.stdin.isTTY) {
2983
+ surpriseText = await promptSurprise();
2984
+ if (surpriseText.toLowerCase() === 'none') surpriseText = '';
2985
+ } else {
2986
+ console.error('warn: no --surprise or --no-surprise provided in non-interactive context; defaulting to no surprise.');
2987
+ surpriseText = '';
2988
+ }
2989
+
2990
+ // ---- Step 0: preflight (working tree + branch freshness) ----
2991
+ console.log(`Verifying claim #${ac.claim_id} on task #${ac.task_id}: ${ac.title}`);
2992
+ console.log('Preflight (working tree + branch freshness)...');
2993
+ const pre = preflight(ac, { force });
2994
+ if (!pre.ok) {
2995
+ console.error('');
2996
+ console.error(`Ship aborted: ${pre.reason}. No claim resolved, no credits awarded.`);
2997
+ process.exit(1);
2998
+ }
2999
+
3000
+ // ---- Step 0b: pull origin/main into the branch up front (#1068, gap 2) ----
3001
+ // So the tree reviewed on the sandbox == the tree we ship — no post-approval
3002
+ // re-stage loop. Skipped under --no-merge (a deliberate "don't touch main"
3003
+ // opt-out). Aborts only on a genuine conflict, claim left intact.
3004
+ if (!skipMerge) {
3005
+ const m = mergeMainIntoBranch();
3006
+ if (!m.ok) {
3007
+ console.error('');
3008
+ console.error(`Ship aborted: ${m.reason}.`);
3009
+ console.error('No claim resolved, no credits awarded — your claim is still active.');
3010
+ process.exit(1);
3011
+ }
3012
+ }
3013
+
3014
+ // ---- Capture surprise learning (if any) ----
3015
+ if (surpriseText) {
3016
+ console.log('Capturing surprise as a learning...');
3017
+ await captureSurpriseLearning(surpriseText, ac, taskId);
3018
+ }
3019
+
3020
+ // ---- Pre-verify: auto-linkify this ship's own changed docs (#747) ----
3021
+ // Fix bare "#NNN" task refs in the docs this ship changed BEFORE the merge,
3022
+ // so they never reach main and red-X the post-merge doc-ref-linkify gate.
3023
+ // Best-effort.
3024
+ autoLinkifyChangedDocs(ac);
3025
+
3026
+ // ---- Empty-diff / phantom-ship guard (must run BEFORE the review gate + resolve) ----
3027
+ // #513 added this check, but at step 2 — AFTER the resolve below already
3028
+ // landed the task at `completed` and consumed the claim. A builder who ran
3029
+ // /builder-ship before committing then burned the claim and couldn't re-ship
3030
+ // ("You don't have an active claim" — the task-595-regrade-gap). Observed
3031
+ // live on #662 (Windows). Moving it here means an empty committed diff aborts
3032
+ // cleanly with the claim STILL ACTIVE, so the builder can commit and re-run.
3033
+ // The grader reads committed history (baseline...HEAD), independent of
3034
+ // touches[] (see computeGraderBaseline). The computed list is reused by the
3035
+ // grader at step 2 so we don't diff twice.
3036
+ //
3037
+ // task 1365: this guard now runs REGARDLESS of --skip-grade. The old version
3038
+ // was gated on `!skipGrade`, so --skip-grade (and the server-side
3039
+ // grader_bypass that forces skipGrade) sailed an empty committed diff straight
3040
+ // through to resolve → manual-confirm → the publish reconciler then shipped a
3041
+ // task with NO artifact on main (task 1363: a new doc left UNTRACKED, never
3042
+ // committed, HEAD never moved — the ledger lied, CLAUDE.md §8). classifyEmptyShip
3043
+ // splits the three cases: a dirty/untracked working tree means the deliverable
3044
+ // was never committed (refuse, even under --allow-empty); a clean tree with an
3045
+ // explicit --allow-empty is a genuine no-artifact/DB-only ship (allow); a clean
3046
+ // tree without the flag is nothing-to-ship (refuse). Refusing here leaves the
3047
+ // claim ACTIVE so the builder can commit + re-run.
3048
+ const graderBaseline = computeGraderBaseline(ac);
3049
+ const committedFiles = computeChangedFiles(graderBaseline);
3050
+ if (committedFiles.length === 0) {
3051
+ const verdict = classifyEmptyShip(gitOk('git status --porcelain'), { allowEmpty });
3052
+ if (verdict.reason === 'uncommitted-work') {
3053
+ console.error('Ship refused: nothing is committed on this branch, but your working tree has uncommitted changes:');
3054
+ for (const f of verdict.uncommitted) console.error(` - ${f}`);
3055
+ console.error('');
3056
+ console.error('Your work was never committed — shipping now would mark the task "shipped" with NO artifact on main (the ledger would lie).');
3057
+ console.error('Stage + commit your work (e.g. `git add -A && git commit`), then re-run /builder-ship. Your claim is still active.');
3058
+ process.exit(1);
3059
+ }
3060
+ if (verdict.reason === 'nothing-to-ship') {
3061
+ console.error(`Nothing to ship: the committed diff vs ${graderBaseline || 'baseline'} is empty and the working tree is clean.`);
3062
+ console.error('The grader reads committed history, not the working tree — commit your work on this branch, then re-run /builder-ship. Your claim is still active.');
3063
+ console.error('If this task legitimately has no code/doc artifact (a pure DB-only or no-op task), re-run with --allow-empty.');
3064
+ process.exit(1);
3065
+ }
3066
+ // verdict.reason === 'allow-empty': a deliberate no-artifact / DB-only ship.
3067
+ // There is no committed diff for the subagent grader to read, so confirm
3068
+ // directly (this is the DB-only path the old --skip-grade route served).
3069
+ if (!skipGrade) skipGrade = true;
3070
+ console.log('--allow-empty: no committed diff (no-artifact / DB-only ship) — confirming directly without the subagent grader.');
3071
+ }
3072
+
3073
+ // ---- Module drift check (BV1.R53, task 1422) ----
3074
+ // If the task carries a module_key, verify the committed diff doesn't stray
3075
+ // into a DIFFERENT module's territory. "You said game, you touched dev-box" is
3076
+ // the semantic violation this catches. Files outside all module scopes (docs,
3077
+ // session logs, general scripts) are allowed — they're cross-cutting. Only
3078
+ // contact with a DIFFERENT module's glob set is refused.
3079
+ //
3080
+ // The check is ADVISORY (warns but does not refuse) on the first cut because
3081
+ // the module_key column is new and existing tasks may carry a broader diff scope
3082
+ // until all tasks are labelled. Enforced tasks can opt in with --enforce-module.
3083
+ // ac.module_key comes from getActiveClaimsForBuilder (t.module_key in the join).
3084
+ const enforceModule = args.includes('--enforce-module');
3085
+ if (ac.module_key && isModuleKey(ac.module_key) && committedFiles.length > 0) {
3086
+ const declaredScope = scopeForModule(ac.module_key);
3087
+ const { MODULE_SCOPE_MAP } = require('../../src/bongos/module-scope-map');
3088
+ const moduleDriftFiles = committedFiles.filter((f) => {
3089
+ if (declaredScope && matchOne(f, declaredScope.globs)) return false;
3090
+ for (const [key, entry] of Object.entries(MODULE_SCOPE_MAP)) {
3091
+ if (key === ac.module_key) continue;
3092
+ if (matchOne(f, entry.globs)) return true;
3093
+ }
3094
+ return false;
3095
+ });
3096
+ if (moduleDriftFiles.length > 0) {
3097
+ const msg = [
3098
+ `\n⚠ Module drift detected: task is scoped to '${ac.module_key}' but ${moduleDriftFiles.length} file(s) touch a different module:`,
3099
+ ...moduleDriftFiles.map((f) => ` - ${f}`),
3100
+ '',
3101
+ enforceModule
3102
+ ? 'Refused (--enforce-module). Confine your changes to modules/' + ac.module_key + '/ or clear the module_key label.'
3103
+ : 'Advisory — ship proceeds, but consider confining your changes to modules/' + ac.module_key + '/.',
3104
+ ].join('\n');
3105
+ console.log(msg);
3106
+ if (enforceModule) process.exit(1);
3107
+ }
3108
+ }
3109
+
3110
+ // ---- Sandbox-first review gate (#927, ADR 0046) ----
3111
+ // On a dev box, a diff that touches game surfaces must be STAGED on the
3112
+ // builder's own live preview (sandbox-<login>.amazonprimea.com, ADR 0044)
3113
+ // and reviewed in a browser before the claim resolves — see it running,
3114
+ // get the chance to iterate, THEN decide to ship to the main game. Laptop
3115
+ // sessions, non-game diffs, quick-tunnel boxes, and --skip-stage all pass
3116
+ // through untouched. Interactive: stage + ask; declining aborts with the
3117
+ // claim intact. Non-TTY (agent-driven sessions): stage, then abort with the
3118
+ // review URL — the re-run after the human reviews passes on the fresh
3119
+ // staged marker. Never hangs on stdin.
3120
+ const sandboxGate = await runShipGate({
3121
+ taskId,
3122
+ files: committedFiles,
3123
+ skipStage: args.includes('--skip-stage'),
3124
+ // #1048: --approved records the builder's sign-off after they reviewed the
3125
+ // staged change on their sandbox. Only honored on a re-run whose game state
3126
+ // is already staged-fresh — it can't pre-approve an unstaged change.
3127
+ approved: args.includes('--approved'),
3128
+ tty: Boolean(process.stdout.isTTY && process.stdin.isTTY),
3129
+ });
3130
+ if (!sandboxGate.ok) {
3131
+ if (sandboxGate.review) {
3132
+ // task 1275: the in-review card — the SAME lifecycle card, amber, whose
3133
+ // action is "Open in sandbox ↗" (the sandbox URL the owner asked to surface
3134
+ // here). Best-effort, never blocks the pause.
3135
+ try {
3136
+ const shipCard = require('../../modules/lifecycle/ship-card');
3137
+ const { added, removed } = diffstatFor(graderBaseline);
3138
+ const model = shipCard.buildCardModel({
3139
+ stage: 'review',
3140
+ taskId,
3141
+ title: ac.title,
3142
+ valueSummary: summary,
3143
+ fileCount: committedFiles.length || null,
3144
+ added,
3145
+ removed,
3146
+ sandboxUrl: sandboxGate.url,
3147
+ });
3148
+ // task 1279: honour the builder's render-widgets knob (from the /me
3149
+ // fetched at the top of shipMain; default ON if absent/older server).
3150
+ const widgetsEnabled = me?.data?.render?.widgets_enabled !== false;
3151
+ shipCard.emitCard(model, taskId, { widgetsEnabled });
3152
+ } catch (_) { /* the review pause stands with or without the card */ }
3153
+ // #1061 polish: a sandbox review pause is a DELIBERATE, expected
3154
+ // checkpoint — not a failed ship. printReviewBlock already showed the URL
3155
+ // + the --approved next step, so don't follow it with red "Ship aborted /
3156
+ // no credits awarded" text that makes the review moment read as broken.
3157
+ // Calm note on stdout (not stderr); the claim is left fully intact.
3158
+ console.log('');
3159
+ console.log('⏸ Paused for your sandbox review — this is expected, not an error.');
3160
+ console.log(' Your claim is still active; nothing was lost.');
3161
+ console.log(' Walk the change at the link above, then approve with: /builder-ship --approved');
3162
+ process.exit(1);
3163
+ }
3164
+ console.error('');
3165
+ console.error(`Ship aborted: ${sandboxGate.reason}.`);
3166
+ console.error('No claim resolved, no credits awarded — your claim is still active.');
3167
+ process.exit(1);
3168
+ }
3169
+ // #1048: the recorded sandbox self-approval (when the builder approved a
3170
+ // staged game change), threaded into the resolve call below so the server
3171
+ // persists it on the task. null on laptop ships, non-game diffs, quick-tunnel
3172
+ // boxes, --skip-stage, and bare fresh-marker re-runs without --approved.
3173
+ const sandboxReview = sandboxGate.sandboxReview || null;
3174
+
3175
+ // ---- Step 1+2: verify locally (smoke) — runs AFTER the review gate (#1061) ----
3176
+ // touches[] cleanup [4/8] (task 879): the predictive drift scanner is gone;
3177
+ // local verification is smoke-only. Smoke runs HERE (post-gate) so a review
3178
+ // PAUSE doesn't bury the sandbox link under the smoke output — smoke only runs
3179
+ // on the actual ship: the --approved re-run, or a laptop / non-game ship the
3180
+ // gate waved straight through. (#1061 — the gate is a no-op for those, so this
3181
+ // is the same single smoke run as before, just sequenced after it.)
3182
+ console.log('Running smoke tests against production...');
3183
+ const smoke = runSmoke();
3184
+ const passed = smoke.clean;
3185
+ console.log('');
3186
+ console.log(`Verification: smoke=${smoke.clean ? 'clean' : 'FAIL'}, overall=${passed ? 'PASS' : 'FAIL'}`);
3187
+ console.log('');
3188
+
3189
+ // ---- Step 1: resolve the claim (lands at completed) ----
3190
+ // V3.R18 (#209): resolveClaim no longer runs the v1 deterministic grader.
3191
+ // It now lands the task at `completed` when verification (smoke) passes, and
3192
+ // stops. The v2 subagent grader (step 2 below) is what gates the credits +
3193
+ // the completed→confirmed transition.
3194
+ const r = await (await cliClient()).claims.postClaimsIdResolve({
3195
+ id: ac.claim_id,
3196
+ body: {
3197
+ outcome: 'shipped',
3198
+ notes_md: notes,
3199
+ value_summary: summary,
3200
+ verification: {
3201
+ passed,
3202
+ // touches[] cleanup [4/8] (task 879): the drift scanner is gone; with no
3203
+ // scanner there is nothing to drift, so this audit-trail signal
3204
+ // (grader.scoreScannerSmoke) is vacuously clean.
3205
+ scanner_clean: true,
3206
+ smoke_clean: smoke.clean,
3207
+ },
3208
+ // touches[] cleanup [7/8] (task 882): the real committed changed-files, so
3209
+ // the server backfills touches[] from reality (the planning prediction is
3210
+ // now optional). Same list the grader already used.
3211
+ committed_files: committedFiles,
3212
+ // #1048: the sandbox self-approval { stamp, url, method } when the builder
3213
+ // reviewed + approved a staged game change. The server stamps by/at and
3214
+ // persists it to tasks.sandbox_review. null = nothing to record.
3215
+ sandbox_review: sandboxReview,
3216
+ },
3217
+ });
3218
+ if (!r.ok) {
3219
+ if (r.data?.error === 'CLAIM_SESSION_MISMATCH') {
3220
+ const wt = r.data.claimWorktree;
3221
+ console.error(`This claim was created in a different session${wt ? ` (worktree: ${wt})` : ''}.`);
3222
+ console.error('You can only ship from the session that originally claimed this task.');
3223
+ console.error(`To force-release from this session: node scripts/gds/release.js ${taskId} --force`);
3224
+ } else {
3225
+ console.error(`resolve failed (${r.status}):`, r.data);
3226
+ }
3227
+ process.exit(1);
3228
+ }
3229
+
3230
+ const taskStatus = r.data.taskStatus || 'shipped (legacy response)';
3231
+ console.log(`Task #${r.data.taskId} is now: ${taskStatus}`);
3232
+
3233
+ // ---- Step 2: subagent quality grader ----
3234
+ // Runs when local verification passed AND resolveClaim landed at completed.
3235
+ // If verification did not pass we don't bother grading — the task stays at
3236
+ // completed (or whatever resolveClaim returned) with no credits.
3237
+ let credits = r.data.creditsAwarded ?? 0;
3238
+ let postGradeStatus = taskStatus;
3239
+ let postGradeJustUnlocked = r.data.just_unlocked || [];
3240
+ let gradePassed = false;
3241
+ let advanceToMerge = false;
3242
+ let gradeObj = null; // task 1270: the grade result, hoisted for the completion card
3243
+ // V3.R27 (#247): credit breakdown surfaced from the grade or confirm
3244
+ // response. Kept separate from `credits` so we only print the multiplier
3245
+ // line when the multiplier actually moved the payout.
3246
+ let creditsRaw = null;
3247
+ let creditsMultiplier = null;
3248
+ let ideaBonusInfo = null;
3249
+
3250
+ if (passed && taskStatus === 'completed' && skipGrade) {
3251
+ // Escape hatch: skip the subagent grader and manual-confirm. The
3252
+ // task_grades row gets grader_kind='lars' so the audit trail captures
3253
+ // that this ship bypassed the v2 grader.
3254
+ console.log('');
3255
+ console.log('--skip-grade: skipping subagent grader; manual-confirming via POST /tasks/:id/confirm.');
3256
+ // task 1053 (F): pass the verify attestation. The server refuses to confirm a
3257
+ // kind='verify' task without it (so a hardware-verify can't land before the
3258
+ // verify ran). --verified is a no-op for every other kind.
3259
+ const confirm = await (await cliClient()).tasks.postTasksIdConfirm({ id: taskId, body: { verified } });
3260
+ if (!confirm.ok) {
3261
+ if (confirm.status === 409 && confirm.data && confirm.data.error === 'verify_attestation_required') {
3262
+ console.error('');
3263
+ console.error(`This is a VERIFICATION task — it won't confirm until you attest the verify actually passed.`);
3264
+ console.error(`Run the verification, then re-ship with --verified:`);
3265
+ console.error(` node scripts/gds/ship.js ${taskId} --verified`);
3266
+ console.error(`(Your work is committed and the task is at 'completed' — re-running resumes, task 941.)`);
3267
+ process.exit(1);
3268
+ }
3269
+ console.error(`manual-confirm failed (${confirm.status}):`, confirm.data);
3270
+ process.exit(1);
3271
+ }
3272
+ postGradeStatus = confirm.data.taskStatus;
3273
+ credits = confirm.data.creditsAwarded ?? 0;
3274
+ ideaBonusInfo = confirm.data.ideaBonus ?? null;
3275
+ advanceToMerge = postGradeStatus === 'confirmed';
3276
+ gradePassed = true; // by builder fiat, not by grader
3277
+ } else if (passed && taskStatus === 'completed') {
3278
+ console.log('');
3279
+ console.log('Running subagent quality grader...');
3280
+
3281
+ // Fetch the full task (description, kind, discipline, etc.) so the
3282
+ // grader prompt has the intent the subagent is grading against.
3283
+ const tFetch = await (await cliClient()).tasks.getTasksId({ id: taskId });
3284
+ const fullTask = tFetch.ok ? tFetch.data.task : { id: taskId, title: ac.title };
3285
+
3286
+ // #513 + #536: the grader reads COMMITTED history (baseline...HEAD), not
3287
+ // the working tree. The empty-committed-diff guard now runs earlier — BEFORE
3288
+ // the claim is resolved (see above) — so by the time we reach here the diff
3289
+ // is guaranteed non-empty on the grader path. Reuse the baseline + file list
3290
+ // computed there so we don't diff twice. #678: gradeAndRecord runs the
3291
+ // subagent grader, POSTs the result, and logs the panel cost — the same unit
3292
+ // the --regrade path reuses.
3293
+ const gradePost = await gradeAndRecord({
3294
+ taskId,
3295
+ task: fullTask,
3296
+ summary,
3297
+ baseline: graderBaseline,
3298
+ committedFiles,
3299
+ });
3300
+
3301
+ if (!gradePost.ok) {
3302
+ console.error(`grade POST failed (${gradePost.status}):`, gradePost.data);
3303
+ console.error('Task stays at completed. Manual options:');
3304
+ console.error(` - Re-run /builder-ship ${taskId} to try grading again, or`);
3305
+ console.error(` - node scripts/gds/ship.js ${taskId} --regrade after fixing findings, or`);
3306
+ console.error(` - POST /api/gds/tasks/${taskId}/confirm to manual-confirm.`);
3307
+ process.exit(1);
3308
+ }
3309
+
3310
+ const g = gradePost.data.grade;
3311
+ gradeObj = g;
3312
+ gradePassed = g.passed;
3313
+ advanceToMerge = gradePost.data.advanceToMerge;
3314
+ postGradeStatus = gradePost.data.taskStatus;
3315
+ credits = gradePost.data.creditsAwarded;
3316
+ postGradeJustUnlocked = gradePost.data.just_unlocked || [];
3317
+ // V3.R27 (#247): pull the multiplier breakdown + idea-bonus info forward.
3318
+ creditsRaw = gradePost.data.creditsRaw ?? null;
3319
+ creditsMultiplier = gradePost.data.creditsMultiplier ?? null;
3320
+ ideaBonusInfo = gradePost.data.ideaBonus ?? null;
3321
+
3322
+ printGradeOutcome(g, taskId);
3323
+ }
3324
+
3325
+ printCreditsAndAchievements({
3326
+ postGradeStatus, credits, creditsRaw, creditsMultiplier, ideaBonusInfo,
3327
+ justUnlocked: postGradeJustUnlocked,
3328
+ });
3329
+
3330
+ // ---- Step 3: confirmed→shipped tail (merge + deploy → memory/session sync →
3331
+ // Done → onboarding nudge → completion card), shared with regradeMain via
3332
+ // finishShip (#1093/C9). ----
3333
+ await finishShip({
3334
+ taskId, summary, advanceToMerge, skipMerge, postGradeStatus, doneLine: 'Done.', dbOnly,
3335
+ // task 1270: data for the deterministic completion card.
3336
+ card: {
3337
+ title: ac.title,
3338
+ valueSummary: summary,
3339
+ notes,
3340
+ files: committedFiles,
3341
+ baseline: graderBaseline,
3342
+ creditsAwarded: credits,
3343
+ // task 1275: a real grade ran → pass/fail + issues; otherwise (--skip-grade
3344
+ // or server-side GRADER_BYPASS) it was confirmed without a grade → skipped,
3345
+ // so the card reads "grade skipped" rather than implying a real pass.
3346
+ grade: gradeObj
3347
+ ? { passed: gradeObj.passed, issues: gradeObj.issues || [], panel: Array.isArray(gradeObj.workers) ? gradeObj.workers.length : null, skipped: false }
3348
+ : { passed: gradePassed, skipped: true },
3349
+ },
3350
+ });
3351
+ }
3352
+
3353
+ // ---------- Re-grade flow (#678) ----------
3354
+ //
3355
+ // `node scripts/gds/ship.js <id> --regrade` closes the task-595-regrade-gap
3356
+ // (re-hit on #600): when a ship lands at `completed` but the subagent grader
3357
+ // fails, /builder-ship has already resolved+consumed the claim, leaving no CLI
3358
+ // path back in (claim refuses "not ready", PATCH refuses, promote refuses, a
3359
+ // fresh ship.js says "no active claim"). The only escapes were the Archon
3360
+ // /confirm override (bypasses the grader — see feedback_prefer_regrade_over_override)
3361
+ // or hand-building a grader harness (learning #43).
3362
+ //
3363
+ // This re-runs ONLY the grade step against the committed diff, with no claim:
3364
+ // - eligibility: task at `completed` AND (you last held the claim OR Archon)
3365
+ // - preflight: working tree committed + ship/grade tooling current
3366
+ // - empty-diff guard: the grader reads committed history, so refuse if empty
3367
+ // - grade: gradeAndRecord → POST /tasks/:id/grade (rank-open; applyGrade
3368
+ // promotes completed→confirmed + awards credits on pass, idempotent)
3369
+ // - on pass: the normal confirmed→shipped merge + sync tail runs.
3370
+ async function regradeMain() {
3371
+ await requireSession();
3372
+ const args = process.argv.slice(2);
3373
+ const taskId = Number(args[0]);
3374
+ if (!Number.isFinite(taskId)) {
3375
+ console.error('usage: node scripts/gds/ship.js <task-id> --regrade [--summary "..."] [--no-merge] [--force] [--verified]');
3376
+ process.exit(2);
3377
+ }
3378
+ const summaryArg = arg('--summary', args) || '';
3379
+ const skipMerge = args.includes('--no-merge');
3380
+ const force = args.includes('--force');
3381
+ const dbOnly = args.includes('--db-only') || args.includes('--allow-empty'); // task 391 — honor --db-only on a regrade too
3382
+ const verified = args.includes('--verified'); // task 1053 (F) — see gate below
3383
+
3384
+ // ---- Eligibility: completed + (owner | archon) ----
3385
+ const me = await (await cliClient()).me.getMe();
3386
+ if (!me.ok) {
3387
+ console.error('failed to load profile:', me.data);
3388
+ process.exit(1);
3389
+ }
3390
+ const builder = me.data.builder || {};
3391
+ // ?include=last_claim — opt into the claim-holder lookup (it's off by default
3392
+ // so the supervisor hot path stays a single query; #678 quality finding).
3393
+ const tRes = await (await cliClient()).tasks.getTasksId({ id: taskId, query: { include: 'last_claim' } });
3394
+ if (!tRes.ok) {
3395
+ if (tRes.status === 404) {
3396
+ console.error(`Task #${taskId} not found.`);
3397
+ } else {
3398
+ console.error(`failed to load task #${taskId} (${tRes.status}):`, tRes.data);
3399
+ }
3400
+ process.exit(1);
3401
+ }
3402
+ const task = tRes.data.task;
3403
+ const lastClaim = tRes.data.last_claim || null;
3404
+ const elig = evaluateRegradeEligibility({ task, lastClaim, builder });
3405
+ if (!elig.ok) {
3406
+ console.error(elig.reason);
3407
+ process.exit(1);
3408
+ }
3409
+ // task 1053 (F): the resume/regrade path promotes completed→confirmed via the
3410
+ // grade (applyGrade), which the server-side /confirm verify guard does NOT
3411
+ // cover. Gate it here so a verification task can't be confirmed by simply
3412
+ // re-running without attesting the verify passed (closing the bypass).
3413
+ if (task.kind === 'verify' && !verified) {
3414
+ console.error('');
3415
+ console.error(`Task #${taskId} is a VERIFICATION task — it won't confirm until you attest the verify actually passed.`);
3416
+ console.error(`Run the verification, then re-run with --verified:`);
3417
+ console.error(` node scripts/gds/ship.js ${taskId} --verified`);
3418
+ process.exit(1);
3419
+ }
3420
+ console.log(`Re-grading task #${taskId}: ${task.title}`);
3421
+ console.log(` eligibility: ${elig.reason} (${elig.via})`);
3422
+
3423
+ // A grade-failed ship never reached the merge/broadcast step, so a passing
3424
+ // re-grade is the FIRST time this task actually ships. Fall back to the
3425
+ // task's stored value_summary so the merge commit + Discord #ship-news post
3426
+ // carry meaningful text even when --summary is omitted.
3427
+ const summary = summaryArg || task.value_summary || '';
3428
+
3429
+ // The grader helpers want a claim-like object for touches[] (preflight's
3430
+ // working-tree-drift check) and head_sha (baseline fallback). The claim is
3431
+ // gone, so synthesize one from the task row + the last claim's head_sha.
3432
+ const claimLike = { touches: task.touches || [], head_sha: lastClaim?.head_sha ?? null, task_id: taskId, title: task.title };
3433
+
3434
+ // ---- Preflight: work committed + ship/grade tooling current ----
3435
+ // No scanner/smoke re-run — --regrade re-runs ONLY the grade step. But the
3436
+ // grader judges COMMITTED history, so uncommitted fixes on touches[] files
3437
+ // wouldn't be graded — refuse that (it would grade a stale diff). Also refuse
3438
+ // if the local ship/grade tooling is behind main (a stale grader silently
3439
+ // fails the same way — the #662 lesson). --force overrides the freshness leg.
3440
+ console.log('Preflight (working tree + branch freshness)...');
3441
+ const pre = preflight(claimLike, { force });
3442
+ if (!pre.ok) {
3443
+ console.error('');
3444
+ console.error(`Re-grade aborted: ${pre.reason}. Task stays at completed.`);
3445
+ process.exit(1);
3446
+ }
3447
+
3448
+ // ---- Empty-diff guard (the grader reads committed history) ----
3449
+ const graderBaseline = computeGraderBaseline(claimLike);
3450
+ const committedFiles = computeChangedFiles(graderBaseline);
3451
+ if (committedFiles.length === 0) {
3452
+ console.error(`Nothing to grade: the committed diff vs ${graderBaseline || 'baseline'} is empty.`);
3453
+ console.error('The grader reads committed history, not the working tree — commit your fixes on this branch, then re-run --regrade.');
3454
+ process.exit(1);
3455
+ }
3456
+
3457
+ // ---- Grade step (the only step) ----
3458
+ console.log('');
3459
+ console.log('Running subagent quality grader...');
3460
+ const gradePost = await gradeAndRecord({
3461
+ taskId,
3462
+ task,
3463
+ summary,
3464
+ baseline: graderBaseline,
3465
+ committedFiles,
3466
+ });
3467
+ if (!gradePost.ok) {
3468
+ console.error(`grade POST failed (${gradePost.status}):`, gradePost.data);
3469
+ console.error('Task stays at completed. Fix the issue and re-run --regrade, or POST /confirm to manual-confirm.');
3470
+ process.exit(1);
3471
+ }
3472
+
3473
+ const g = gradePost.data.grade;
3474
+ const postGradeStatus = gradePost.data.taskStatus;
3475
+ printGradeOutcome(g, taskId);
3476
+ printCreditsAndAchievements({
3477
+ postGradeStatus,
3478
+ credits: gradePost.data.creditsAwarded,
3479
+ creditsRaw: gradePost.data.creditsRaw ?? null,
3480
+ creditsMultiplier: gradePost.data.creditsMultiplier ?? null,
3481
+ ideaBonusInfo: gradePost.data.ideaBonus ?? null,
3482
+ justUnlocked: gradePost.data.just_unlocked || [],
3483
+ });
3484
+
3485
+ // On a pass the task is now confirmed — run the SAME confirmed→shipped tail as a
3486
+ // normal ship (finishShip, #1093/C9) so a re-grade lands identically.
3487
+ await finishShip({
3488
+ taskId, summary, advanceToMerge: gradePost.data.advanceToMerge, skipMerge, dbOnly,
3489
+ postGradeStatus,
3490
+ doneLine: g.passed ? 'Done — re-grade passed.' : 'Done — re-grade still failing; fix and try again.',
3491
+ // task 1270: completion card for the re-grade path too.
3492
+ card: {
3493
+ title: task.title,
3494
+ valueSummary: summary,
3495
+ notes: '',
3496
+ files: committedFiles,
3497
+ baseline: graderBaseline,
3498
+ creditsAwarded: gradePost.data.creditsAwarded,
3499
+ grade: { passed: g.passed, issues: g.issues || [], panel: Array.isArray(g.workers) ? g.workers.length : null },
3500
+ },
3501
+ });
3502
+ }
3503
+
3504
+ // Pure helpers are exported for unit tests (tests/session_records.mjs). The
3505
+ // orchestration only runs when ship.js is invoked directly — so `require()`ing
3506
+ // this module (a test) does NOT kick off a ship. (uploadSessionOnShip is not
3507
+ // exported: it does I/O + network and is covered by the live smoke at ship.)
3508
+ module.exports = {
3509
+ scrubSecrets, snippetOf, buildSessionDigest, resolveTranscriptPath,
3510
+ // task 1003: transcript-grade scrubber internals, exported pure so the planted-
3511
+ // secret corpus test (tests/scrubber_corpus.mjs) can exercise each layer + tune.
3512
+ shannonEntropy, looksHighEntropySecret, redactHighEntropy, collectEnvSecrets,
3513
+ classifyUploadSkip, recordUploadSkip,
3514
+ // #678: re-grade path + the grade-step helpers it shares with shipMain.
3515
+ // Exporting these formalizes learning #43 (which had to "replicate ~10
3516
+ // lines" because runSubagentGrader wasn't reachable).
3517
+ evaluateRegradeEligibility, computeGraderBaseline, computeChangedFiles,
3518
+ runSubagentGrader, buildGradePayload,
3519
+ // V4.R08: diff fingerprint + grade-reuse helpers — exported pure so the
3520
+ // truth table (same hash+pass→reuse, fail→no reuse, mismatch→re-grade,
3521
+ // null→re-grade) is unit-tested without git/network.
3522
+ diffHash, computeRawDiffHash, shouldReuseGrade, gradeAndRecord,
3523
+ // #943: pure grade-outcome formatter — exported so the grader-unavailable vs
3524
+ // FAIL display branch is unit-tested without capturing stdout.
3525
+ formatGradeOutcome,
3526
+ // task 941 (V4.R03): pure resume-action decision for a claim-less `ship.js <id>`
3527
+ // re-run — exported so the truth table (completed→grade, confirmed→merge,
3528
+ // ownership, terminal states) is unit-tested with no git/network.
3529
+ evaluateResumeAction,
3530
+ // #747: pre-ship auto-linkify of the ship's own changed docs. The path
3531
+ // predicate is pure + exported so the regression test can assert which
3532
+ // changed files fall in the linkifier's domain without git/fs/API.
3533
+ isLinkifiableDocPath,
3534
+ // #681: shell-injection fix for autoMerge — argv git runner + pure merge-argv
3535
+ // builder, exported so the regression test can prove no shell interpretation.
3536
+ // task 916: sanitizeGitHubRefs keeps "#NNN" out of commit/PR text (GitHub
3537
+ // autolinks it to dead issue numbers).
3538
+ gitArgvOk, buildMergeArgv, sanitizeGitHubRefs,
3539
+ // #679 / ADR 0042: CI deploy mode — pure helpers exported for the regression
3540
+ // test (mode precedence/safe-default, no-shell PR argv, deploy-run match).
3541
+ parseDeployMode, buildPrCreateArgv, buildPrMergeArgv, deployRunSucceeded,
3542
+ // task 1049 (B): pure CI-land outcome classifier — a timed-out-but-armed PR is
3543
+ // 'pending' (success), not failure. Exported so the semantics are unit-tested.
3544
+ classifyLandOutcome,
3545
+ // task 1718: pure poll-path decision — enable-failure polls the SERVER (not a
3546
+ // dead-end strand at confirmed), enable-success watches GitHub. Exported so the
3547
+ // regression test locks in that a worktree enable-failure still waits for the land.
3548
+ landPollAfterAutoMerge,
3549
+ // task 1719: pure post-land status reconciler — a task that reached 'shipped'
3550
+ // in-session shows the SHIPPED banner + writes no breadcrumb; otherwise keep the
3551
+ // pre-merge status (confirmed → next-turn breadcrumb fallback). Exported for tests.
3552
+ reconciledCardStatus,
3553
+ // task 1169 (ADR 0058): pure picker for the stale (completed-non-success)
3554
+ // gate-review run on the current head SHA that must be re-run after the
3555
+ // gate-author-trust attestation is posted (a new commit status doesn't
3556
+ // re-trigger a pull_request check). Exported so the logic is tested without gh.
3557
+ pickStaleGateReviewRun,
3558
+ // task 1068: pure pre-stage-merge decision (skip on main/current, else merge).
3559
+ decidePreMerge,
3560
+ // task 1052 (E): box-aware "what to do next" for a confirmed-strand — a box
3561
+ // (via 'server') is pointed at the resume, never at gh-only /merge-mode.
3562
+ strandNextStep,
3563
+ // #857: resolveDeployMode (env + committed config) so doctor.js reports the
3564
+ // active deploy mode exactly as ship will use it.
3565
+ resolveDeployMode,
3566
+ // task 1449: pure credential-aware mode adjustment — a machine that can't push
3567
+ // locally never honors a stale 'laptop' checkout (it would strand at merge);
3568
+ // it falls back to ci/server-publish. Exported so the truth table is unit-tested
3569
+ // without spawning gh / touching fs.
3570
+ effectiveDeployMode,
3571
+ // #858: commit-pinned deploy confirmation via the /version endpoint.
3572
+ deployedCommitStatus,
3573
+ // ADR 0055 / task 1025: which ci-mode publish path this machine uses
3574
+ // ('local' = git+gh here; 'server' = server-mediated, e.g. a dev box) — so
3575
+ // doctor.js reports the same path ship will take instead of failing on
3576
+ // missing gh where gh isn't needed.
3577
+ pushVia,
3578
+ // task 1046: diagram regen returns a status string and SKIPS on the dev-box
3579
+ // (server-publish) path so a box never leaves uncommitted .mmd/assertions in
3580
+ // its tree — exported so the regression test proves the skip without git.
3581
+ regenerateDiagrams,
3582
+ regenerateRepoMap,
3583
+ regenerateSessionIndex,
3584
+ // ADR 0066 / task 1276: regenerate docs/file-map.md's skills + scheduled-tasks sections from disk.
3585
+ regenerateFileMap,
3586
+ // task 1037: autoMerge's fetch-failure decision — fatal only OFF the server-
3587
+ // publish path (a credential-less dev box can't fetch but ships via ADR 0055).
3588
+ // Exported pure so the regression test proves the truth table without git.
3589
+ shouldBailOnFetchFailure,
3590
+ // task 391 (idea #37): autoMerge's zero-commit verdict — refuse a zero-commit
3591
+ // ship unless --db-only is explicitly passed. Exported pure so the gate is
3592
+ // unit-tested (has-commits / db-only / refuse) without git.
3593
+ zeroCommitVerdict,
3594
+ // touches[] cleanup [3/8] (task 878): folder-tag derivation, now over the
3595
+ // committed diff — exported pure so the regression test can assert it.
3596
+ deriveTagsFromPaths,
3597
+ // task 990: pure ship-broadcast router (security-sensitive → Archon-only
3598
+ // #security-ship-feed; else public #ship-news + builder #ship-feed) —
3599
+ // exported so tests/ship_broadcast_routing.mjs can prove the security branch
3600
+ // can never reach a public channel, with no git/network. (Definition now lives
3601
+ // in src/bongos/ship-broadcast.js — task 1045 — and is re-exported here.)
3602
+ chooseBroadcastTargets,
3603
+ // task 1045: the gate that defers the broadcast to the server on the server-
3604
+ // mediated publish path — exported so the truth table (ci+server → true, laptop
3605
+ // / non-ci → false) is unit-tested with no gh/network (env-driven via
3606
+ // OTB_DEPLOY_MODE + OTB_PUSH_VIA_SERVER).
3607
+ broadcastHandledByServer,
3608
+ };
3609
+
3610
+ if (require.main === module) {
3611
+ // #678: --regrade re-grades a grade-failed `completed` task (no claim needed).
3612
+ // Dispatched here so it owns its own arg-parsing + session bootstrap.
3613
+ const main = process.argv.slice(2).includes('--regrade') ? regradeMain : shipMain;
3614
+ main().catch((err) => {
3615
+ console.error('fatal:', err);
3616
+ process.exit(1);
3617
+ });
3618
+ }