@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.
- package/.bongos-core.json +5727 -0
- package/.claude/skills/blocker-review/SKILL.md +91 -0
- package/.claude/skills/blocker-solve/SKILL.md +96 -0
- package/.claude/skills/builder-backup/SKILL.md +54 -0
- package/.claude/skills/builder-box/SKILL.md +45 -0
- package/.claude/skills/builder-claim/SKILL.md +76 -0
- package/.claude/skills/builder-connect/SKILL.md +59 -0
- package/.claude/skills/builder-cost/SKILL.md +47 -0
- package/.claude/skills/builder-end/SKILL.md +81 -0
- package/.claude/skills/builder-exit/SKILL.md +88 -0
- package/.claude/skills/builder-key/SKILL.md +40 -0
- package/.claude/skills/builder-reauth/SKILL.md +50 -0
- package/.claude/skills/builder-redteam/SKILL.md +109 -0
- package/.claude/skills/builder-release/SKILL.md +41 -0
- package/.claude/skills/builder-setup/SKILL.md +62 -0
- package/.claude/skills/builder-ship/SKILL.md +79 -0
- package/.claude/skills/builder-stage/SKILL.md +51 -0
- package/.claude/skills/builder-start/SKILL.md +46 -0
- package/.claude/skills/close-box/SKILL.md +43 -0
- package/.claude/skills/design-sync/SKILL.md +69 -0
- package/.claude/skills/dev/SKILL.md +49 -0
- package/.claude/skills/feedback/SKILL.md +32 -0
- package/.claude/skills/figma-design-sync/SKILL.md +74 -0
- package/.claude/skills/goal-review/SKILL.md +89 -0
- package/.claude/skills/idea-triage/SKILL.md +144 -0
- package/.claude/skills/ideate/SKILL.md +82 -0
- package/.claude/skills/merge-mode/SKILL.md +169 -0
- package/.claude/skills/new-project/SKILL.md +108 -0
- package/.claude/skills/otb-character-review/SKILL.md +71 -0
- package/.claude/skills/otb-design-review/SKILL.md +47 -0
- package/.claude/skills/otb-feedback-capture/SKILL.md +45 -0
- package/.claude/skills/otb-figma-sync/SKILL.md +54 -0
- package/.claude/skills/otb-tile-generate/SKILL.md +45 -0
- package/.claude/skills/paint/SKILL.md +70 -0
- package/.claude/skills/planning-session/SKILL.md +299 -0
- package/.claude/skills/priority-session/SKILL.md +134 -0
- package/.claude/skills/read-session-export/SKILL.md +58 -0
- package/.claude/skills/recall/SKILL.md +52 -0
- package/.claude/skills/session-handoff/SKILL.md +46 -0
- package/.claude/skills/status/SKILL.md +33 -0
- package/.devcontainer/Dockerfile +69 -0
- package/.devcontainer/README.md +73 -0
- package/.devcontainer/devcontainer-lock.json +19 -0
- package/.devcontainer/devcontainer.json +93 -0
- package/.devcontainer/healthcheck.sh +84 -0
- package/.devcontainer/init-firewall.sh +198 -0
- package/.devcontainer/post-create.sh +153 -0
- package/.dockerignore +36 -0
- package/.env.local.example +32 -0
- package/.gitattributes +83 -0
- package/.gitignore +91 -0
- package/CLAUDE.md +242 -0
- package/CODE_OF_CONDUCT.md +46 -0
- package/CONTRIBUTING.md +51 -0
- package/Dockerfile +46 -0
- package/GOVERNANCE.md +44 -0
- package/LICENSE +661 -0
- package/README.md +88 -0
- package/bin/bongos.js +222 -0
- package/clients/bongos-client/README.md +48 -0
- package/clients/bongos-client/bongos-client.global.js +617 -0
- package/clients/bongos-client/examples/hello-world.mjs +62 -0
- package/clients/bongos-client/index.cjs +619 -0
- package/clients/bongos-client/index.d.ts +793 -0
- package/clients/bongos-client/index.mjs +614 -0
- package/clients/bongos-client/package.json +32 -0
- package/config/branding.neutral.json +60 -0
- package/config/design-tokens.neutral.json +51 -0
- package/config/design-tokens.schema.json +96 -0
- package/config/modules.neutral.json +9 -0
- package/docker-compose.yml +52 -0
- package/docker-entrypoint.sh +30 -0
- package/docs/adr/0001-stack-choice-single-vps.md +45 -0
- package/docs/adr/0002-digitalocean-over-hetzner.md +27 -0
- package/docs/adr/0003-cloudflare-origin-cert-over-letsencrypt.md +35 -0
- package/docs/adr/0004-amazonprimea-name-and-trademark-acceptance.md +45 -0
- package/docs/adr/0005-invisible-other-players-in-v1.md +37 -0
- package/docs/adr/0006-singleton-worldroom-plus-queueroom.md +30 -0
- package/docs/adr/0007-persistent-identity-via-localstorage-uuid.md +31 -0
- package/docs/adr/0008-family-based-tile-generation.md +86 -0
- package/docs/adr/0008-google-chat-oauth-user-auth.md +37 -0
- package/docs/adr/0009-autotile-architecture.md +181 -0
- package/docs/adr/0010-pms-architecture.md +70 -0
- package/docs/adr/0011-terrain-id-rendering-model.md +174 -0
- package/docs/adr/0012-ground-object-cell-split.md +208 -0
- package/docs/adr/0013-paths-as-object-overlays-with-autotile-dispatch.md +158 -0
- package/docs/adr/0014-families-canonical-over-vocabulary.md +103 -0
- package/docs/adr/0015-task-dependencies-and-auto-promotion.md +125 -0
- package/docs/adr/0016-trust-boundary-server-enforced-permissions.md +132 -0
- package/docs/adr/0017-code-simplification-and-repo-cleanliness.md +74 -0
- package/docs/adr/0018-three-rank-model-goes-live.md +97 -0
- package/docs/adr/0019-red-team-bounty-payout-table.md +52 -0
- package/docs/adr/0020-security-model-public-stub.md +70 -0
- package/docs/adr/0021-per-builder-skill-model-preferences.md +99 -0
- package/docs/adr/0022-secrets-policy.md +124 -0
- package/docs/adr/0023-kind-multipliers-and-idea-bonus.md +118 -0
- package/docs/adr/0024-cloneable-repo-local-first-memory.md +277 -0
- package/docs/adr/0024-multi-agent-system-architecture.md +125 -0
- package/docs/adr/0025-offsite-backup-vendor-digitalocean-spaces.md +59 -0
- package/docs/adr/0025-structured-criterion-task-link.md +44 -0
- package/docs/adr/0026-bfg-memory-hygiene-and-cross-builder-learning.md +117 -0
- package/docs/adr/0027-bfg-session-inefficiency-evaluator.md +170 -0
- package/docs/adr/0028-honest-llm-spend-accounting.md +109 -0
- package/docs/adr/0029-status-page-outage-resilience.md +59 -0
- package/docs/adr/0030-builders-hall-live-updates.md +59 -0
- package/docs/adr/0031-cloud-dev-environments-for-builders.md +222 -0
- package/docs/adr/0032-discord-mirror-of-gds.md +50 -0
- package/docs/adr/0033-discord-bot-service-principal.md +82 -0
- package/docs/adr/0034-thetes-graduated-newcomer-rank.md +53 -0
- package/docs/adr/0035-builder-onboarding-three-paths.md +128 -0
- package/docs/adr/0036-builders-subdomain-and-ui-reorg.md +40 -0
- package/docs/adr/0037-discord-channel-management.md +51 -0
- package/docs/adr/0038-chromebook-ttyd-cloudflare-tunnel.md +111 -0
- package/docs/adr/0039-setup-first-onboarding-ux.md +49 -0
- package/docs/adr/0040-remote-control-default-browser-onramp.md +47 -0
- package/docs/adr/0041-temporary-grader-bypass-killswitch.md +40 -0
- package/docs/adr/0042-builder-self-deploy-ci-auto-merge.md +82 -0
- package/docs/adr/0043-git-ssh-trust-boundary-and-rank-floor-on-permission-paths.md +74 -0
- package/docs/adr/0044-mediterranean-palette-replacement.md +73 -0
- package/docs/adr/0044-per-box-live-game-preview.md +57 -0
- package/docs/adr/0045-devbox-desktop-app.md +133 -0
- package/docs/adr/0046-sandbox-first-review-gate.md +72 -0
- package/docs/adr/0047-discord-bugs-intake-anti-abuse.md +36 -0
- package/docs/adr/0048-works-in-progress-ia-split.md +32 -0
- package/docs/adr/0049-split-parallel-safety-contract.md +46 -0
- package/docs/adr/0050-device-flow-admission-invite-gated-by-default.md +43 -0
- package/docs/adr/0051-full-session-transcript-corpus.md +57 -0
- package/docs/adr/0052-sandbox-for-everyone-game-only-preview.md +70 -0
- package/docs/adr/0053-scoped-dev-box-session.md +55 -0
- package/docs/adr/0054-session-token-reward-cost-plus.md +63 -0
- package/docs/adr/0055-server-mediated-branch-publish.md +47 -0
- package/docs/adr/0056-prod-deploy-script-mirror-and-healthcheck-retry.md +62 -0
- package/docs/adr/0057-container-cost-ledger.md +28 -0
- package/docs/adr/0057-discord-archon-approval-channels.md +64 -0
- package/docs/adr/0058-automatic-gate-surface-gatekeeper.md +64 -0
- package/docs/adr/0059-single-approval-remove-devbox-approval-gate.md +37 -0
- package/docs/adr/0060-gds-retrieval-layer.md +97 -0
- package/docs/adr/0061-context-layer-decomposition.md +61 -0
- package/docs/adr/0062-medusa-instance-model-decoupling.md +107 -0
- package/docs/adr/0063-generated-repo-map-symbol-skeleton.md +44 -0
- package/docs/adr/0064-rename-medusa-to-cloud-bongos.md +49 -0
- package/docs/adr/0065-agpl-nonprofit-ai-first-governance.md +69 -0
- package/docs/adr/0066-generated-file-map-sections.md +51 -0
- package/docs/adr/0067-session-start-fast-forward-pull.md +43 -0
- package/docs/adr/0068-shared-art-key-for-newcomers.md +38 -0
- package/docs/adr/0069-hall-live-channel-sse-decouple.md +35 -0
- package/docs/adr/0070-deterministic-session-card-delivery.md +44 -0
- package/docs/adr/0071-box-confirm-before-destroyed-and-drift-reconcile.md +59 -0
- package/docs/adr/0072-bongos-app-mac-signed-first-windows-deferred.md +35 -0
- package/docs/adr/0072-dev-box-code-staleness-visibility.md +39 -0
- package/docs/adr/0073-builder-needs-signal-and-byok-gemini-key.md +64 -0
- package/docs/adr/0073-secrets-scan-exclude-uri-detector.md +37 -0
- package/docs/adr/0074-memory-map-stays-light.md +32 -0
- package/docs/adr/0075-per-system-cost-attribution.md +37 -0
- package/docs/adr/0076-in-session-deterministic-compaction.md +58 -0
- package/docs/adr/0077-content-addressed-llm-cache.md +32 -0
- package/docs/adr/0078-deterministic-first-cron.md +48 -0
- package/docs/adr/0079-value-gated-autonomous-engagement.md +39 -0
- package/docs/adr/0080-llm-cache-api-transport-and-trust-model.md +46 -0
- package/docs/adr/0081-tool-agnostic-design-layer.md +62 -0
- package/docs/adr/0082-productized-key-storage-provisioning.md +32 -0
- package/docs/adr/0082-server-side-merge-conflict-auto-resolution.md +55 -0
- package/docs/adr/0083-modular-architecture-strangler-build-plan.md +143 -0
- package/docs/adr/0084-auto-derived-per-task-rank-floor.md +86 -0
- package/docs/adr/0085-remove-default-per-builder-spend-cap.md +39 -0
- package/docs/adr/0086-goal-scoped-work-hierarchy.md +123 -0
- package/docs/adr/0087-bongos-app-architecture-and-handoff-contract.md +49 -0
- package/docs/adr/0087-compete-on-governance-not-tooling-cursor.md +65 -0
- package/docs/adr/0088-consistent-character-generative-animation.md +169 -0
- package/docs/adr/0089-modular-multi-model-adversarial-grader.md +179 -0
- package/docs/adr/0090-metic-task-authoring.md +73 -0
- package/docs/adr/0091-bounding-the-kernel-and-db-carve.md +106 -0
- package/docs/adr/0092-hosting-module-and-on-prem-target.md +117 -0
- package/docs/adr/0093-tranche-2-core-carve-sequence.md +72 -0
- package/docs/adr/0094-repo-atlas-self-drawing-codebase-map.md +49 -0
- package/docs/adr/0095-borrowed-memory-and-retrieval-concepts.md +65 -0
- package/docs/adr/0095-cross-agent-context-management.md +58 -0
- package/docs/adr/0096-require-reward-before-workable.md +209 -0
- package/docs/adr/0097-one-active-claim-per-session-and-worktree-binding.md +46 -0
- package/docs/adr/0097-retroactive-reward-backfill.md +124 -0
- package/docs/adr/0098-public-mirror-publish-manifest.md +92 -0
- package/docs/adr/0099-delayed-redacted-mirror-export.md +55 -0
- package/docs/adr/0100-two-repo-instance-model.md +60 -0
- package/docs/adr/0101-goal-hierarchy-phase-5-judge-membership-reward.md +90 -0
- package/docs/adr/0102-finish-bounding-the-kernel.md +36 -0
- package/docs/adr/0103-core-first-extraction-cloud-bongos-trunk.md +70 -0
- package/docs/adr/0103-gdsv4-bongos-consolidation.md +28 -0
- package/docs/adr/0104-trust-gds-api-channel-in-auto-mode.md +42 -0
- package/docs/adr/0105-instance-seed-migrations-out-of-core.md +47 -0
- package/docs/adr/0106-goal-owner-manager-authority.md +84 -0
- package/docs/adr/0107-module-upstreaming-contribution-to-core-catalog.md +83 -0
- package/docs/adr/0108-instance-composition-configurable-root.md +87 -0
- package/docs/adr/0109-self-describing-openapi-and-hosted-docs.md +44 -0
- package/docs/adr/0110-untrack-whole-file-generated-nav-docs.md +49 -0
- package/docs/adr/0111-instance-hosting-provisioning-module.md +135 -0
- package/docs/adr/0112-private-goals-invitations-join-requests.md +72 -0
- package/docs/adr/0113-sandbox-studio-descope-superseded-by-0044-0052.md +41 -0
- package/docs/adr/0114-instance-docs-discovery-contract.md +32 -0
- package/docs/adr/0115-scheduled-routines-portability.md +31 -0
- package/docs/adr/0116-api-error-envelope-and-code-taxonomy.md +62 -0
- package/docs/adr/0117-api-versioning-and-deprecation-policy.md +50 -0
- package/docs/adr/0118-api-contract-source-of-truth-code-first.md +47 -0
- package/docs/adr/0119-api-pagination-contract.md +34 -0
- package/docs/adr/0120-pay-on-land-and-builder-owned-rebase-gate.md +108 -0
- package/docs/adr/0121-greenfield-vs-brownfield-onboarding-adopt-existing-repo.md +97 -0
- package/docs/adr/0122-onboarding-diagram-renders-deploy-rendered-off-git.md +43 -0
- package/docs/adr/0123-box-idle-sweep-autosave-before-destroy.md +36 -0
- package/docs/adr/0124-typed-api-response-bodies.md +37 -0
- package/docs/adr/0125-control-plane-scaffolds-standalone-instance-repo.md +36 -0
- package/docs/adr/0126-dedicated-cloudbongos-control-plane-droplet.md +30 -0
- package/docs/adr/0127-cross-box-fleet-observability.md +64 -0
- package/docs/adr/0128-provisioning-runner-privilege-and-db-auth-model.md +46 -0
- package/docs/adr/0129-cloudbongos-control-plane-pull-deploy.md +34 -0
- package/docs/adr/0130-provision-remote-exec-over-ssh.md +61 -0
- package/docs/adr/0131-rank-scoped-skill-visibility.md +47 -0
- package/docs/adr/0132-cohost-fleet-control-plane-migration.md +54 -0
- package/docs/adr/0133-github-app-manifest-onboarding-signin.md +42 -0
- package/docs/adr/0134-private-first-npm-distribution.md +38 -0
- package/docs/adr/README.md +208 -0
- package/docs/api/openapi.json +13720 -0
- package/docs/api-reference.md +437 -0
- package/docs/architecture.md +244 -0
- package/docs/branding-contract.md +66 -0
- package/docs/branding-fill-prompt.md +25 -0
- package/docs/canonical-permissions.md +241 -0
- package/docs/design/container-exterior-tiles.md +49 -0
- package/docs/design/container-exterior.md +81 -0
- package/docs/design/container-interior.md +77 -0
- package/docs/design/modular-architecture/00-research-report.md +107 -0
- package/docs/design/modular-architecture/01-architecture.md +125 -0
- package/docs/design/modular-architecture/02-module-map.md +116 -0
- package/docs/design/modular-architecture/03-builder-flows.md +104 -0
- package/docs/design/modular-architecture/04-module-lifecycle.md +62 -0
- package/docs/design/modular-architecture/README.md +27 -0
- package/docs/design/vanilla-hall-ui-redesign-scope.md +284 -0
- package/docs/design/world-map-scroll.md +41 -0
- package/docs/design-contract.md +320 -0
- package/docs/file-map.md +418 -0
- package/docs/handoff-template.md +92 -0
- package/docs/modules-contract.md +293 -0
- package/docs/onboarding/browser-terminal-guide.md +73 -0
- package/docs/onboarding/diagrams/01-task-lifecycle.mmd +28 -0
- package/docs/onboarding/diagrams/02-rank-ladder.mmd +54 -0
- package/docs/onboarding/diagrams/03-drachmae-karma.mmd +37 -0
- package/docs/onboarding/diagrams/04-architecture.mmd +51 -0
- package/docs/onboarding/diagrams/05-versioning.mmd +29 -0
- package/docs/onboarding/diagrams/README.md +94 -0
- package/docs/onboarding/diagrams/assertions.json +170 -0
- package/docs/onboarding/diagrams/puppeteer-config.json +1 -0
- package/docs/onboarding/diagrams/render-config.json +9 -0
- package/docs/onboarding/drachmae-and-karma.md +178 -0
- package/docs/onboarding/primer.md +202 -0
- package/docs/project-context.template.md +67 -0
- package/docs/recipes/README.md +35 -0
- package/docs/recipes/audit-replay.md +100 -0
- package/docs/recipes/bongos-cli-release.md +66 -0
- package/docs/recipes/builder-box-lifecycle.md +152 -0
- package/docs/recipes/claude-design-loop.md +143 -0
- package/docs/recipes/claude-gemini-bongos-doors.md +155 -0
- package/docs/recipes/cloudbongos-staging-gate.md +139 -0
- package/docs/recipes/cloudbongos-standup.md +111 -0
- package/docs/recipes/devbox-app-release.md +93 -0
- package/docs/recipes/devbox-app-windows-test.md +128 -0
- package/docs/recipes/failed-ship-recovery-proof.md +102 -0
- package/docs/recipes/figma-design-loop.md +129 -0
- package/docs/recipes/gds-db-backup.md +255 -0
- package/docs/recipes/instance-deploy.md +37 -0
- package/docs/recipes/managed-settings-remote-control.md +416 -0
- package/docs/recipes/memory-backup.md +71 -0
- package/docs/recipes/memory-sync-conflicts.md +98 -0
- package/docs/recipes/multi-builder-merge.md +78 -0
- package/docs/recipes/ops-gotchas.md +212 -0
- package/docs/recipes/overnight-builder.md +53 -0
- package/docs/recipes/packaging-the-core.md +49 -0
- package/docs/recipes/parallel-load-results-2026-05-30.md +63 -0
- package/docs/recipes/parallel-load-test.md +32 -0
- package/docs/recipes/pixel-art-pipeline.md +411 -0
- package/docs/recipes/private-npm-distribution.md +79 -0
- package/docs/recipes/search-index-ingestion.md +67 -0
- package/docs/recipes/self-host.md +60 -0
- package/docs/recipes/ship-and-regrade.md +72 -0
- package/docs/recipes/staging-data.md +50 -0
- package/docs/recipes/staging-environment.md +215 -0
- package/docs/recipes/upgrading-the-core.md +58 -0
- package/docs/research/cursor-competitive-analysis.md +255 -0
- package/docs/research/llm-model-agnostic-and-cheaper-grader.md +227 -0
- package/migrations/003_pms.sql +284 -0
- package/migrations/004_pms_cost_idempotency.sql +13 -0
- package/migrations/005_pms_smoke_filter.sql +44 -0
- package/migrations/006_pms_v2.sql +378 -0
- package/migrations/007_pms_v2_hierarchy_trigger.sql +59 -0
- package/migrations/008_pms_v2_migration_allocator.sql +27 -0
- package/migrations/009_pms_v2_blockers_table.sql +68 -0
- package/migrations/010_uptime.sql +29 -0
- package/migrations/011_backfill_v2_v3_done_when.sql +20 -0
- package/migrations/012_pms_v2_task_lifecycle_states.sql +82 -0
- package/migrations/013_claim_locking.sql +22 -0
- package/migrations/014_pms_task_discipline.sql +86 -0
- package/migrations/015_pms_estimation_calibration.sql +140 -0
- package/migrations/016_pms_task_grades.sql +60 -0
- package/migrations/017_version_progress_exclude_abandoned.sql +49 -0
- package/migrations/019_rename_pms_v3_to_gds_v3.sql +65 -0
- package/migrations/020_task_dependencies.sql +99 -0
- package/migrations/021_v3_renumber_and_deps.sql +224 -0
- package/migrations/022_v3_reconcile_dep_gated_status.sql +41 -0
- package/migrations/023_pms_rank.sql +30 -0
- package/migrations/025_builder_exit_status.sql +15 -0
- package/migrations/027_builders_preferred_disciplines.sql +46 -0
- package/migrations/028_tasks_parallel_safe.sql +71 -0
- package/migrations/029_rank_three_live.sql +32 -0
- package/migrations/030_vulnerability_reports.sql +61 -0
- package/migrations/031_reward_simplification.sql +57 -0
- package/migrations/032_repo_health_snapshots.sql +56 -0
- package/migrations/033_audit_log.sql +64 -0
- package/migrations/034_lifecycle_breakdown_leaf_only.sql +62 -0
- package/migrations/035_auth_token_hygiene.sql +66 -0
- package/migrations/037_builder_skill_model_prefs.sql +44 -0
- package/migrations/038_task_override_requests.sql +51 -0
- package/migrations/039_newcomer_friendly_column.sql +124 -0
- package/migrations/040_builder_karma.sql +58 -0
- package/migrations/041_kind_multipliers_and_idea_bonus.sql +42 -0
- package/migrations/042_criterion_proposal_workflow.sql +78 -0
- package/migrations/043_builder_onboarding_observability.sql +66 -0
- package/migrations/044_tasks_requires_rank.sql +97 -0
- package/migrations/045_builder_onboarding_state.sql +147 -0
- package/migrations/046_cost_log_builder_attribution.sql +42 -0
- package/migrations/054_builder_memory.sql +108 -0
- package/migrations/055_task_criteria.sql +90 -0
- package/migrations/058_peer_votes.sql +41 -0
- package/migrations/059_onboarding_pick_disciplines.sql +59 -0
- package/migrations/060_access_requests.sql +46 -0
- package/migrations/061_backfill_override_ship_attribution.sql +29 -0
- package/migrations/062_session_records.sql +34 -0
- package/migrations/063_session_records_tool_errors_idx.sql +11 -0
- package/migrations/064_grade_attempts.sql +18 -0
- package/migrations/065_bfg_principal.sql +58 -0
- package/migrations/066_live_notify_triggers.sql +69 -0
- package/migrations/067_builder_boxes.sql +117 -0
- package/migrations/067_discord_bot_foundation.sql +69 -0
- package/migrations/068_thetes_requires_rank.sql +27 -0
- package/migrations/069_builder_ssh_keys.sql +66 -0
- package/migrations/070_box_intents.sql +101 -0
- package/migrations/071_box_first_provision_approval.sql +46 -0
- package/migrations/072_onboarding_ship_three_remove_primer.sql +80 -0
- package/migrations/073_box_terminal_access.sql +46 -0
- package/migrations/075_builder_sound_prefs.sql +28 -0
- package/migrations/078_box_claude_active.sql +22 -0
- package/migrations/079_box_first_connected_at.sql +20 -0
- package/migrations/081_tasks_needs_migration.sql +34 -0
- package/migrations/084_tasks_security_sensitive.sql +24 -0
- package/migrations/085_security_engagement_docs.sql +34 -0
- package/migrations/086_rename_disciplines.sql +125 -0
- package/migrations/088_builders_anthropic_link.sql +28 -0
- package/migrations/089_builders_monthly_budget.sql +19 -0
- package/migrations/090_box_scoped_sessions.sql +38 -0
- package/migrations/091_session_token_reward.sql +44 -0
- package/migrations/093_tasks_sandbox_review.sql +22 -0
- package/migrations/102_remove_box_first_provision_approval.sql +58 -0
- package/migrations/103_search_chunks.sql +90 -0
- package/migrations/104_builder_box_blocked.sql +34 -0
- package/migrations/105_box_host_keys.sql +35 -0
- package/migrations/111_medusa_customer0_framing.sql +35 -0
- package/migrations/112_drop_track_check.sql +25 -0
- package/migrations/113_merge_lease.sql +16 -0
- package/migrations/114_cloudbongos_rename_live_strings.sql +32 -0
- package/migrations/118_currency_label_decoupling.sql +23 -0
- package/migrations/119_builder_behavior_prefs.sql +31 -0
- package/migrations/121_box_code_version.sql +37 -0
- package/migrations/122_builder_gemini_key.sql +30 -0
- package/migrations/123_cost_log_tiers.sql +33 -0
- package/migrations/124_cost_baseline.sql +45 -0
- package/migrations/125_llm_cache.sql +54 -0
- package/migrations/126_autonomy_runs.sql +33 -0
- package/migrations/143_cost_calibration.sql +63 -0
- package/migrations/145_backfill_task_requires_rank.sql +84 -0
- package/migrations/160_goal_tier.sql +88 -0
- package/migrations/163_polymorphic_dependencies.sql +187 -0
- package/migrations/164_event_sound_prefs.sql +20 -0
- package/migrations/165_task_module_key.sql +15 -0
- package/migrations/171_claims_published_head_sha.sql +24 -0
- package/migrations/174_close_orphaned_claims_on_terminal_tasks.sql +30 -0
- package/migrations/175_task_grades_diff_hash.sql +9 -0
- package/migrations/176_promotion_respects_open_blockers.sql +58 -0
- package/migrations/176_ui_discipline.sql +40 -0
- package/migrations/177_require_reward_before_workable.sql +76 -0
- package/migrations/178_reward_gate_auto_assign_revert_autopromote.sql +61 -0
- package/migrations/179_search_chunk_usefulness.sql +33 -0
- package/migrations/180_one_active_claim_per_session.sql +54 -0
- package/migrations/181_cost_log_skill_name.sql +33 -0
- package/migrations/184_claim_release_requested.sql +25 -0
- package/migrations/185_module_upstreaming_goal_wiring.sql +59 -0
- package/migrations/187_goal_sort_order.sql +30 -0
- package/migrations/187_tasks_kind_check_add_verify.sql +21 -0
- package/migrations/README.md +88 -0
- package/migrations/core_188_goal_visibility_and_membership_requests.sql +71 -0
- package/migrations/core_189_core_upgrades_ledger.sql +37 -0
- package/migrations/core_189_credit_log_task_id_index.sql +19 -0
- package/migrations/core_190_rebuild_claimable_task_views.sql +71 -0
- package/migrations/instance/018_pms_v3_planning_seed.sql +131 -0
- package/migrations/instance/080_gds_v4_planning_seed.sql +75 -0
- package/migrations/instance/110_medusa_planning_seed.sql +70 -0
- package/migrations/instance/120_bongos_v1_planning_seed.sql +69 -0
- package/migrations/instance/144_bongos_v1_module_system_criterion.sql +26 -0
- package/migrations/instance/159_bongos_v1_goal_hierarchy_criterion.sql +29 -0
- package/migrations/instance/172_v2_world_planning_seed.sql +163 -0
- package/migrations/instance/173_bongos_v1_c7_core_modularized.sql +33 -0
- package/migrations/instance/182_gdsv4_bongos_consolidation.sql +132 -0
- package/migrations/instance/183_gdsv4_carryover_themed_goals.sql +116 -0
- package/migrations/instance/186_bongos_v1_governance_planning_seed.sql +69 -0
- package/modules/autonomy/CLAUDE.md +49 -0
- package/modules/autonomy/db.js +50 -0
- package/modules/autonomy/module.json +13 -0
- package/modules/autonomy/pollers/routine-timers.js +146 -0
- package/modules/autonomy/routes/autonomy.js +37 -0
- package/modules/builder-settings/CLAUDE.md +58 -0
- package/modules/builder-settings/builder-needs.js +115 -0
- package/modules/builder-settings/db.js +168 -0
- package/modules/builder-settings/display-name.js +84 -0
- package/modules/builder-settings/event-sound-prefs.js +169 -0
- package/modules/builder-settings/interaction-prefs.js +201 -0
- package/modules/builder-settings/model-alloc-prefs.js +147 -0
- package/modules/builder-settings/module.json +10 -0
- package/modules/builder-settings/render-prefs.js +103 -0
- package/modules/builder-settings/routes/settings.js +417 -0
- package/modules/builder-settings/settings.js +52 -0
- package/modules/builder-settings/skill-prefs.js +196 -0
- package/modules/builder-settings/sound-prefs.js +178 -0
- package/modules/builder-settings/wandering-prefs.js +282 -0
- package/modules/dev-box/CLAUDE.md +38 -0
- package/modules/dev-box/box-access.js +275 -0
- package/modules/dev-box/box-credential.js +94 -0
- package/modules/dev-box/box-onboard.js +486 -0
- package/modules/dev-box/boxes.js +842 -0
- package/modules/dev-box/db.js +39 -0
- package/modules/dev-box/module.json +14 -0
- package/modules/dev-box/routes/box.js +977 -0
- package/modules/discord/CLAUDE.md +50 -0
- package/modules/discord/auth.js +54 -0
- package/modules/discord/bug-attachments.js +210 -0
- package/modules/discord/db.js +96 -0
- package/modules/discord/discord-approvals.js +145 -0
- package/modules/discord/discord-audit.js +29 -0
- package/modules/discord/discord-bot.js +545 -0
- package/modules/discord/discord-bugs.js +257 -0
- package/modules/discord/discord-channels.js +412 -0
- package/modules/discord/discord-inbound.js +147 -0
- package/modules/discord/discord-message-buffer.js +54 -0
- package/modules/discord/discord-reaction.js +186 -0
- package/modules/discord/discord-role-sync.js +318 -0
- package/modules/discord/module.json +14 -0
- package/modules/discord/pollers/discord-bot.js +19 -0
- package/modules/discord/routes/bug-attachments.js +44 -0
- package/modules/discord/routes/discord.js +102 -0
- package/modules/discord/ship-broadcast.js +78 -0
- package/modules/discord/webhook.js +74 -0
- package/modules/economy/CLAUDE.md +48 -0
- package/modules/economy/achievements.js +594 -0
- package/modules/economy/cost-classify.js +331 -0
- package/modules/economy/cost-summary-shape.js +90 -0
- package/modules/economy/cost.js +438 -0
- package/modules/economy/credits.js +430 -0
- package/modules/economy/migrations/economy_001_fix_gds_shipper_description.sql +91 -0
- package/modules/economy/module.json +10 -0
- package/modules/economy/reward.js +61 -0
- package/modules/economy/routes/cost.js +97 -0
- package/modules/economy/routes/leaderboard.js +26 -0
- package/modules/economy/routes/reward.js +42 -0
- package/modules/economy/streak.js +285 -0
- package/modules/grading/CLAUDE.md +42 -0
- package/modules/grading/db.js +39 -0
- package/modules/grading/grader-rubric.json +151 -0
- package/modules/grading/grader-workers/efficiency.js +213 -0
- package/modules/grading/grader-workers/extract-json.js +80 -0
- package/modules/grading/grader-workers/fence.js +100 -0
- package/modules/grading/grader-workers/hacker.js +239 -0
- package/modules/grading/grader-workers/index.js +183 -0
- package/modules/grading/grader-workers/narc.js +207 -0
- package/modules/grading/grader-workers/quality.js +198 -0
- package/modules/grading/grader-workers/rubric.js +20 -0
- package/modules/grading/grader-workers/worker-prompt.js +89 -0
- package/modules/grading/grader-workers/worker-verdict.js +50 -0
- package/modules/grading/grader.js +1790 -0
- package/modules/grading/module.json +12 -0
- package/modules/grading/routes/grade.js +51 -0
- package/modules/hall-ui/CLAUDE.md +61 -0
- package/modules/hall-ui/module.json +14 -0
- package/modules/hall-ui/public/atlas.html +180 -0
- package/modules/hall-ui/public/atlas.js +336 -0
- package/modules/hall-ui/public/box-panel.js +423 -0
- package/modules/hall-ui/public/brand-apply.js +117 -0
- package/modules/hall-ui/public/builders.js +3028 -0
- package/modules/hall-ui/public/chime.wav +0 -0
- package/modules/hall-ui/public/copy-on-click.js +174 -0
- package/modules/hall-ui/public/cursors/arrow.png +0 -0
- package/modules/hall-ui/public/cursors/hand-pointer.png +0 -0
- package/modules/hall-ui/public/cursors/magnifier-out.png +0 -0
- package/modules/hall-ui/public/cursors/magnifier.png +0 -0
- package/modules/hall-ui/public/diagram-viewer.css +125 -0
- package/modules/hall-ui/public/diagram-viewer.js +330 -0
- package/modules/hall-ui/public/diagrams.html +113 -0
- package/modules/hall-ui/public/dom-utils.js +212 -0
- package/modules/hall-ui/public/drachmae.html +68 -0
- package/modules/hall-ui/public/drachmae.js +136 -0
- package/modules/hall-ui/public/gate.html +143 -0
- package/modules/hall-ui/public/gate.js +228 -0
- package/modules/hall-ui/public/goals-page.js +1297 -0
- package/modules/hall-ui/public/goals.html +376 -0
- package/modules/hall-ui/public/goals.js +119 -0
- package/modules/hall-ui/public/hall-widgets.js +189 -0
- package/modules/hall-ui/public/harbor.html +167 -0
- package/modules/hall-ui/public/harbor.js +335 -0
- package/modules/hall-ui/public/index.html +116 -0
- package/modules/hall-ui/public/instances-panel.js +172 -0
- package/modules/hall-ui/public/md-reader.js +234 -0
- package/modules/hall-ui/public/modules.html +119 -0
- package/modules/hall-ui/public/modules.js +154 -0
- package/modules/hall-ui/public/new-project-wizard.js +186 -0
- package/modules/hall-ui/public/not-ready.html +48 -0
- package/modules/hall-ui/public/pair.html +119 -0
- package/modules/hall-ui/public/pair.js +132 -0
- package/modules/hall-ui/public/primer.css +404 -0
- package/modules/hall-ui/public/primer.html +85 -0
- package/modules/hall-ui/public/primer.js +641 -0
- package/modules/hall-ui/public/profile.html +85 -0
- package/modules/hall-ui/public/profile.js +236 -0
- package/modules/hall-ui/public/ranks.html +69 -0
- package/modules/hall-ui/public/ranks.js +206 -0
- package/modules/hall-ui/public/roadmap.html +209 -0
- package/modules/hall-ui/public/roadmap.js +375 -0
- package/modules/hall-ui/public/sessions.html +92 -0
- package/modules/hall-ui/public/sessions.js +222 -0
- package/modules/hall-ui/public/settings.html +640 -0
- package/modules/hall-ui/public/settings.js +1219 -0
- package/modules/hall-ui/public/shell.js +495 -0
- package/modules/hall-ui/public/style.css +1748 -0
- package/modules/hall-ui/public/task-detail.js +253 -0
- package/modules/hall-ui/public/task.html +83 -0
- package/modules/hall-ui/public/task.js +326 -0
- package/modules/hall-ui/public/watch.html +333 -0
- package/modules/hall-ui/public/watch.js +859 -0
- package/modules/hall-ui/public/work.html +469 -0
- package/modules/hall-ui/public/work.js +1292 -0
- package/modules/ideas/CLAUDE.md +37 -0
- package/modules/ideas/blockers.js +100 -0
- package/modules/ideas/inbox.js +459 -0
- package/modules/ideas/module.json +10 -0
- package/modules/ideas/routes/blockers.js +120 -0
- package/modules/ideas/routes/inbox.js +230 -0
- package/modules/lifecycle/CLAUDE.md +63 -0
- package/modules/lifecycle/conflict-resolve.js +436 -0
- package/modules/lifecycle/db.js +4970 -0
- package/modules/lifecycle/done-when.js +355 -0
- package/modules/lifecycle/github-push.js +969 -0
- package/modules/lifecycle/goal-conflicts.js +133 -0
- package/modules/lifecycle/hierarchy.js +104 -0
- package/modules/lifecycle/lifecycle.js +58 -0
- package/modules/lifecycle/merge-lock.js +261 -0
- package/modules/lifecycle/migrations/lifecycle_001_goal_first_leads.sql +28 -0
- package/modules/lifecycle/migrations/lifecycle_002_backfill_orphan_task_goals.sql +36 -0
- package/modules/lifecycle/module.json +13 -0
- package/modules/lifecycle/pollers/publish-reconciler.js +20 -0
- package/modules/lifecycle/publish-reconciler.js +418 -0
- package/modules/lifecycle/routes/analytics.js +63 -0
- package/modules/lifecycle/routes/claims.js +506 -0
- package/modules/lifecycle/routes/dependencies.js +82 -0
- package/modules/lifecycle/routes/done-when.js +136 -0
- package/modules/lifecycle/routes/gate-approvals.js +197 -0
- package/modules/lifecycle/routes/goals.js +1419 -0
- package/modules/lifecycle/routes/lifecycle.js +43 -0
- package/modules/lifecycle/routes/tasks.js +1703 -0
- package/modules/lifecycle/routes/versions.js +128 -0
- package/modules/lifecycle/ship-card.js +505 -0
- package/modules/lifecycle/ship-preflight.js +225 -0
- package/modules/lifecycle/task-classifier.js +512 -0
- package/modules/lifecycle/version-close.js +106 -0
- package/modules/memory/CLAUDE.md +40 -0
- package/modules/memory/learnings.js +275 -0
- package/modules/memory/memory.js +572 -0
- package/modules/memory/module.json +12 -0
- package/modules/memory/routes/learnings.js +90 -0
- package/modules/memory/routes/memory.js +916 -0
- package/modules/memory/routes/search.js +188 -0
- package/modules/memory/search-chunker.js +252 -0
- package/modules/memory/search.js +320 -0
- package/modules/onboarding/CLAUDE.md +55 -0
- package/modules/onboarding/approval-broadcast.js +59 -0
- package/modules/onboarding/db.js +54 -0
- package/modules/onboarding/module.json +10 -0
- package/modules/onboarding/newcomer-restock.js +212 -0
- package/modules/onboarding/onboarding-state.js +532 -0
- package/modules/onboarding/onboarding.js +508 -0
- package/modules/onboarding/port.js +63 -0
- package/modules/onboarding/routes/access-requests.js +208 -0
- package/modules/onboarding/routes/onboarding.js +54 -0
- package/modules/security/CLAUDE.md +44 -0
- package/modules/security/module.json +10 -0
- package/modules/security/routes/reports.js +251 -0
- package/modules/security/secret-scrub.js +201 -0
- package/modules/security/security.js +294 -0
- package/modules/sessions/CLAUDE.md +39 -0
- package/modules/sessions/bfg.js +357 -0
- package/modules/sessions/db.js +326 -0
- package/modules/sessions/module.json +10 -0
- package/modules/sessions/pulse.js +149 -0
- package/modules/sessions/routes/sessions.js +331 -0
- package/modules/status-ui/CLAUDE.md +47 -0
- package/modules/status-ui/module.json +14 -0
- package/modules/status-ui/public/README.md +29 -0
- package/modules/status-ui/public/cursors/arrow.png +0 -0
- package/modules/status-ui/public/cursors/hand-pointer.png +0 -0
- package/modules/status-ui/public/index.html +363 -0
- package/modules/status-ui/public/snapshots/.gitkeep +0 -0
- package/modules/status-ui/public/status.js +1375 -0
- package/modules/status-ui/public/style.css +1342 -0
- package/package-lock.json +975 -0
- package/package.json +28 -0
- package/public-docs/index.html +35 -0
- package/public-docs/redoc.standalone.js +1838 -0
- package/public-docs/redoc.standalone.js.LICENSE.txt +102 -0
- package/public-landing/buddha.png +0 -0
- package/public-landing/create.html +868 -0
- package/public-landing/index.html +168 -0
- package/scripts/bongos-mcp.js +28 -0
- package/scripts/cron/style_guide_audit.sh +134 -0
- package/scripts/discord/README.md +52 -0
- package/scripts/discord/lib.js +80 -0
- package/scripts/discord/post.js +51 -0
- package/scripts/gds/CLAUDE.md +144 -0
- package/scripts/gds/abandon.js +58 -0
- package/scripts/gds/adopt-detect.js +123 -0
- package/scripts/gds/adopt-import.js +111 -0
- package/scripts/gds/adopt-preflight.js +121 -0
- package/scripts/gds/api.js +78 -0
- package/scripts/gds/architect-audit.js +288 -0
- package/scripts/gds/art-stage.js +152 -0
- package/scripts/gds/audit-authorship.sh +243 -0
- package/scripts/gds/audit-deps.js +121 -0
- package/scripts/gds/audit-exports.js +128 -0
- package/scripts/gds/audit-replay.sh +161 -0
- package/scripts/gds/audit-ships.js +251 -0
- package/scripts/gds/autonomy-gate.js +135 -0
- package/scripts/gds/backfill-cost.js +165 -0
- package/scripts/gds/backfill-sessions.js +351 -0
- package/scripts/gds/backfill-task-rewards.js +408 -0
- package/scripts/gds/backup.js +94 -0
- package/scripts/gds/bfg-principal-token.js +65 -0
- package/scripts/gds/box-auth-check.js +160 -0
- package/scripts/gds/box-connect-lib.js +453 -0
- package/scripts/gds/box.js +1258 -0
- package/scripts/gds/build-bongos.js +94 -0
- package/scripts/gds/capture.js +123 -0
- package/scripts/gds/cf-tunnel.js +419 -0
- package/scripts/gds/ci-grade.js +82 -0
- package/scripts/gds/claim-recall.js +112 -0
- package/scripts/gds/claim.js +333 -0
- package/scripts/gds/classify-discipline.js +153 -0
- package/scripts/gds/claude-materialize.js +176 -0
- package/scripts/gds/cleanup-orphans.js +74 -0
- package/scripts/gds/cli-lib.js +518 -0
- package/scripts/gds/code.js +101 -0
- package/scripts/gds/codemod-rename-src-gds.js +145 -0
- package/scripts/gds/conductor-dispatch.js +426 -0
- package/scripts/gds/context-pack.js +228 -0
- package/scripts/gds/coreB-carve-proof.sh +716 -0
- package/scripts/gds/cost.js +47 -0
- package/scripts/gds/db-backup-nightly.sh +160 -0
- package/scripts/gds/db-backup-offsite.sh +152 -0
- package/scripts/gds/demote.js +40 -0
- package/scripts/gds/design-sync.js +62 -0
- package/scripts/gds/diagnose-achievements.js +118 -0
- package/scripts/gds/diagram-facts.js +319 -0
- package/scripts/gds/discipline-modes.json +25 -0
- package/scripts/gds/discord-channels.js +66 -0
- package/scripts/gds/do-api.js +176 -0
- package/scripts/gds/docs-entropy.js +403 -0
- package/scripts/gds/doctor.js +389 -0
- package/scripts/gds/feedback-latest.js +94 -0
- package/scripts/gds/fetch-art-key.js +106 -0
- package/scripts/gds/fetch-sound-prefs.js +88 -0
- package/scripts/gds/figma-design-sync.js +61 -0
- package/scripts/gds/fitness.js +1446 -0
- package/scripts/gds/gate-review.js +322 -0
- package/scripts/gds/gating.js +325 -0
- package/scripts/gds/gds-db-backup.service +24 -0
- package/scripts/gds/gds-db-backup.timer +20 -0
- package/scripts/gds/gds-db-offsite.service +29 -0
- package/scripts/gds/gds-db-offsite.timer +18 -0
- package/scripts/gds/gen-api-client.js +508 -0
- package/scripts/gds/gen-api-docs.js +1037 -0
- package/scripts/gds/gen-atlas.js +366 -0
- package/scripts/gds/gen-diagrams.js +608 -0
- package/scripts/gds/gen-file-map.js +196 -0
- package/scripts/gds/gen-repo-map.js +519 -0
- package/scripts/gds/gen-session-index.js +210 -0
- package/scripts/gds/git-merge-regen.js +147 -0
- package/scripts/gds/grade-smoke.js +205 -0
- package/scripts/gds/hooks/pre-push.js +169 -0
- package/scripts/gds/http-api-client.js +88 -0
- package/scripts/gds/init.js +903 -0
- package/scripts/gds/install-git-hooks.js +114 -0
- package/scripts/gds/key-card.js +213 -0
- package/scripts/gds/key-status.js +126 -0
- package/scripts/gds/land-watch.js +168 -0
- package/scripts/gds/learning-capture.js +145 -0
- package/scripts/gds/lib.sh +44 -0
- package/scripts/gds/linkify-refs.js +473 -0
- package/scripts/gds/local-preview-lib.js +159 -0
- package/scripts/gds/local-preview.js +95 -0
- package/scripts/gds/login.js +225 -0
- package/scripts/gds/main-audit.js +279 -0
- package/scripts/gds/me.js +88 -0
- package/scripts/gds/memory-map.js +174 -0
- package/scripts/gds/merge-lock.js +71 -0
- package/scripts/gds/migration-namespace.js +200 -0
- package/scripts/gds/mirror-lineage-check.js +319 -0
- package/scripts/gds/mirror-redact.js +305 -0
- package/scripts/gds/module.js +482 -0
- package/scripts/gds/newcomer-floor.config.json +13 -0
- package/scripts/gds/newcomer-floor.js +415 -0
- package/scripts/gds/newcomer-floor.templates.json +86 -0
- package/scripts/gds/newcomer-floor.test.js +235 -0
- package/scripts/gds/next-number.js +163 -0
- package/scripts/gds/oauth-secret.js +197 -0
- package/scripts/gds/onboard.js +217 -0
- package/scripts/gds/onboarding-analyze.js +226 -0
- package/scripts/gds/onboarding-config.js +178 -0
- package/scripts/gds/onboarding-ship.js +92 -0
- package/scripts/gds/package-core.js +684 -0
- package/scripts/gds/paste-token.js +131 -0
- package/scripts/gds/portability-smoke-art.sh +85 -0
- package/scripts/gds/portability-smoke-memory.sh +147 -0
- package/scripts/gds/provision.js +1324 -0
- package/scripts/gds/publish-manifest.js +307 -0
- package/scripts/gds/pulse.js +309 -0
- package/scripts/gds/push-main.js +45 -0
- package/scripts/gds/read-session-export.js +359 -0
- package/scripts/gds/reauth.js +149 -0
- package/scripts/gds/recall.js +135 -0
- package/scripts/gds/release.js +71 -0
- package/scripts/gds/render-ideas.js +173 -0
- package/scripts/gds/repo-metrics.js +363 -0
- package/scripts/gds/resolve-skill-model.js +57 -0
- package/scripts/gds/run-routine.js +197 -0
- package/scripts/gds/run-unit-tests.js +178 -0
- package/scripts/gds/sandbox-stage.js +653 -0
- package/scripts/gds/search-ingest-db.js +209 -0
- package/scripts/gds/search-ingest-md.js +225 -0
- package/scripts/gds/security-baseline-check.sh +180 -0
- package/scripts/gds/seed-adopt-tasks.js +75 -0
- package/scripts/gds/seed-api-contract-goal-tasks.js +141 -0
- package/scripts/gds/seed-bongos-app-tasks.js +294 -0
- package/scripts/gds/seed-bongos-coreB-tranche1-tasks.js +81 -0
- package/scripts/gds/seed-bongos-coreB-tranche2-tasks.js +100 -0
- package/scripts/gds/seed-bongos-goal-hierarchy-tasks.js +307 -0
- package/scripts/gds/seed-bongos-module-system-tasks.js +346 -0
- package/scripts/gds/seed-bongos-tasks.js +214 -0
- package/scripts/gds/seed-gds-v4-tasks.js +624 -0
- package/scripts/gds/seed-governance-tasks.js +340 -0
- package/scripts/gds/seed-medusa-tasks.js +440 -0
- package/scripts/gds/seed-module-upstreaming.js +219 -0
- package/scripts/gds/seed-productization-tasks.js +235 -0
- package/scripts/gds/seed-provisioning-tasks.js +83 -0
- package/scripts/gds/seed-staging.sh +104 -0
- package/scripts/gds/seed-v2-world-tasks.js +283 -0
- package/scripts/gds/seed-v3-tasks.js +1981 -0
- package/scripts/gds/session-cost.js +393 -0
- package/scripts/gds/session-digest.js +179 -0
- package/scripts/gds/session-eval.js +576 -0
- package/scripts/gds/session.js +404 -0
- package/scripts/gds/setup.js +504 -0
- package/scripts/gds/shell.js +100 -0
- package/scripts/gds/ship.js +3618 -0
- package/scripts/gds/smoke-batch-claim.sh +213 -0
- package/scripts/gds/smoke-batch-release.sh +296 -0
- package/scripts/gds/smoke-box.sh +150 -0
- package/scripts/gds/smoke-claim-consistency.sh +212 -0
- package/scripts/gds/smoke-dependencies.sh +84 -0
- package/scripts/gds/smoke-gds.js +125 -0
- package/scripts/gds/smoke-write.sh +168 -0
- package/scripts/gds/start.js +782 -0
- package/scripts/gds/status.js +365 -0
- package/scripts/gds/triage.js +366 -0
- package/scripts/gds/upgrade.js +421 -0
- package/scripts/gds/validate-design.js +254 -0
- package/scripts/gds/version-close.js +549 -0
- package/scripts/migrate.sh +183 -0
- package/scripts/migrate_cost_ledger.sh +178 -0
- package/scripts/public-mirror-export.js +291 -0
- package/scripts/render-diagrams.sh +88 -0
- package/scripts/security/dep-audit.js +276 -0
- package/scripts/setup-builder.js +274 -0
- package/scripts/setup-builder.sh +28 -0
- package/scripts/snapshots/backfill-history.mjs +292 -0
- package/scripts/snapshots/maybe-render-on-deploy.sh +66 -0
- package/scripts/snapshots/render-pre-art-eras.mjs +329 -0
- package/scripts/snapshots/render-world.mjs +322 -0
- package/scripts/status-mirror-sync.js +192 -0
- package/scripts/status-probe.mjs +185 -0
- package/server.js +212 -0
- package/src/asset-version.js +71 -0
- package/src/bongos/CLAUDE.md +64 -0
- package/src/bongos/api-errors.js +281 -0
- package/src/bongos/api-path-404.js +67 -0
- package/src/bongos/api-prefix.js +38 -0
- package/src/bongos/app-pair.js +172 -0
- package/src/bongos/auth.js +979 -0
- package/src/bongos/cascade-dispatch.js +123 -0
- package/src/bongos/db-kernel.js +252 -0
- package/src/bongos/db.js +911 -0
- package/src/bongos/goal-scope-check.js +120 -0
- package/src/bongos/hierarchy-config.js +189 -0
- package/src/bongos/live-channel.js +145 -0
- package/src/bongos/llm-cache.js +260 -0
- package/src/bongos/llm-pricing.js +220 -0
- package/src/bongos/metrics.js +286 -0
- package/src/bongos/middleware/audit.js +274 -0
- package/src/bongos/middleware/error-envelope.js +93 -0
- package/src/bongos/middleware/rate-limit.js +195 -0
- package/src/bongos/module-scope-map.js +271 -0
- package/src/bongos/optimizer.js +166 -0
- package/src/bongos/path-match.js +47 -0
- package/src/bongos/permission-path-check.js +248 -0
- package/src/bongos/pool.js +55 -0
- package/src/bongos/repo-info.js +91 -0
- package/src/bongos/route-rank-check.js +420 -0
- package/src/bongos/routes/CLAUDE.md +43 -0
- package/src/bongos/routes/_helpers.js +369 -0
- package/src/bongos/routes/audit-log.js +59 -0
- package/src/bongos/routes/auth.js +755 -0
- package/src/bongos/routes/backup.js +104 -0
- package/src/bongos/routes/builders.js +526 -0
- package/src/bongos/routes/healthz.js +16 -0
- package/src/bongos/routes/instance.js +129 -0
- package/src/bongos/routes/live.js +125 -0
- package/src/bongos/routes/llm-cache.js +119 -0
- package/src/bongos/routes/me.js +380 -0
- package/src/bongos/routes/modules.js +131 -0
- package/src/bongos/routes/optimizer.js +54 -0
- package/src/bongos/routes/public.js +476 -0
- package/src/bongos/routes/security.js +207 -0
- package/src/bongos/routes.js +166 -0
- package/src/bongos/secret-box.js +177 -0
- package/src/bongos/sensitive-surfaces.js +30 -0
- package/src/bongos/serve-internal.js +889 -0
- package/src/bongos/uptime-poller.js +90 -0
- package/src/bongos-downloads.js +70 -0
- package/src/branding.js +182 -0
- package/src/build-info.js +77 -0
- package/src/devbox-downloads.js +67 -0
- package/src/instance-config.js +200 -0
- package/src/module-api.js +366 -0
- package/src/module-loader/loader.js +268 -0
- package/src/module-loader/manifest-schema.js +140 -0
- package/src/module-loader/provenance.js +28 -0
- package/src/module-loader/semver.js +100 -0
- package/src/module-seams.js +291 -0
- package/src/modules.js +254 -0
- package/src/platform-server.js +135 -0
- package/src/ui/adapters/claude-design/index.js +333 -0
- package/src/ui/adapters/figma/index.js +328 -0
- package/tests/abandon_task.mjs +138 -0
- package/tests/achievements_reconcile.mjs +167 -0
- package/tests/adopt_detect.mjs +141 -0
- package/tests/adopt_import.mjs +132 -0
- package/tests/adopt_preflight.mjs +105 -0
- package/tests/anti_grid.mjs +89 -0
- package/tests/api_client.mjs +231 -0
- package/tests/api_docs.mjs +283 -0
- package/tests/api_error_envelope_buildcheck.mjs +56 -0
- package/tests/api_error_envelope_middleware.mjs +99 -0
- package/tests/api_error_registry.mjs +108 -0
- package/tests/api_pagination_contract.mjs +51 -0
- package/tests/api_path_404.mjs +96 -0
- package/tests/app_pair.mjs +168 -0
- package/tests/architect.mjs +140 -0
- package/tests/art_key.mjs +121 -0
- package/tests/art_stage.mjs +121 -0
- package/tests/asset_version.mjs +94 -0
- package/tests/atlas.mjs +69 -0
- package/tests/audit_deps.mjs +74 -0
- package/tests/audit_middleware.mjs +385 -0
- package/tests/auth_admission_gate.mjs +164 -0
- package/tests/auth_anthropic_identity.mjs +95 -0
- package/tests/auth_duplicate_cookie.mjs +56 -0
- package/tests/auth_error_brand.mjs +77 -0
- package/tests/auth_flow_slice1.mjs +223 -0
- package/tests/auth_inactive_builder.mjs +94 -0
- package/tests/auth_page_gate.mjs +92 -0
- package/tests/auth_session_ttl.mjs +155 -0
- package/tests/autonomous_fit_protected_paths.mjs +115 -0
- package/tests/autonomy_gate.mjs +85 -0
- package/tests/autotile_variants.mjs +256 -0
- package/tests/autotile_xverify.mjs +133 -0
- package/tests/backfill_sessions.mjs +101 -0
- package/tests/backfill_task_rewards.mjs +152 -0
- package/tests/backup_cli.mjs +104 -0
- package/tests/bfg.mjs +327 -0
- package/tests/bfg_write.mjs +423 -0
- package/tests/bongos_box_connect.mjs +89 -0
- package/tests/bongos_dispatch.mjs +74 -0
- package/tests/bongos_downloads.mjs +118 -0
- package/tests/bongos_login.mjs +34 -0
- package/tests/box_access.mjs +186 -0
- package/tests/box_auth_check.mjs +114 -0
- package/tests/box_code_staleness.mjs +116 -0
- package/tests/box_connect_e2e.mjs +196 -0
- package/tests/box_cost.mjs +70 -0
- package/tests/box_dns_repoint.mjs +151 -0
- package/tests/box_host_keys.mjs +102 -0
- package/tests/box_onboard.mjs +317 -0
- package/tests/box_scope_session.mjs +115 -0
- package/tests/box_ship_permissions.mjs +71 -0
- package/tests/box_terminal_ssrf.mjs +90 -0
- package/tests/boxes.mjs +1255 -0
- package/tests/branding.mjs +88 -0
- package/tests/bug_attachments.mjs +189 -0
- package/tests/build_info.mjs +88 -0
- package/tests/builder_needs.mjs +99 -0
- package/tests/cap_newcomer_tasks.mjs +114 -0
- package/tests/cascade-dispatch.mjs +95 -0
- package/tests/cf_tunnel.mjs +459 -0
- package/tests/claim_recall.mjs +150 -0
- package/tests/claims_bind_session.mjs +47 -0
- package/tests/claude_materialize.mjs +148 -0
- package/tests/cli_box_scope.mjs +52 -0
- package/tests/cli_lib_formatters.mjs +87 -0
- package/tests/cli_token_reissue.mjs +147 -0
- package/tests/client_bundle_route.mjs +47 -0
- package/tests/cloud_bongos_pack.mjs +85 -0
- package/tests/conductor.mjs +496 -0
- package/tests/confirm_attribution.mjs +156 -0
- package/tests/conflict_resolve.mjs +462 -0
- package/tests/context_pack.mjs +240 -0
- package/tests/cost.mjs +94 -0
- package/tests/cost_audit.mjs +122 -0
- package/tests/cost_budget_status.mjs +78 -0
- package/tests/cost_reconcile.mjs +102 -0
- package/tests/cost_summary_shape.mjs +94 -0
- package/tests/credit_grant.mjs +376 -0
- package/tests/criterion_progress.mjs +220 -0
- package/tests/criterion_proposal.mjs +109 -0
- package/tests/criterion_ref_parse.mjs +53 -0
- package/tests/currency_label.mjs +119 -0
- package/tests/day_cycle.mjs +197 -0
- package/tests/dependencies_migration.mjs +77 -0
- package/tests/dependencies_polymorphic.mjs +212 -0
- package/tests/deploy_instance.mjs +78 -0
- package/tests/devbox_downloads.mjs +122 -0
- package/tests/diff_derived_signals.mjs +115 -0
- package/tests/discord_approvals.mjs +174 -0
- package/tests/discord_autojoin.mjs +158 -0
- package/tests/discord_bot.mjs +94 -0
- package/tests/discord_bugs.mjs +297 -0
- package/tests/discord_channels.mjs +337 -0
- package/tests/discord_foundation.mjs +76 -0
- package/tests/discord_inbound.mjs +190 -0
- package/tests/discord_message_buffer.mjs +87 -0
- package/tests/discord_oauth.mjs +55 -0
- package/tests/discord_reaction.mjs +170 -0
- package/tests/discord_role_sync.mjs +151 -0
- package/tests/display_name.mjs +123 -0
- package/tests/dns_preflight.mjs +74 -0
- package/tests/event_sound_prefs.mjs +194 -0
- package/tests/eviction_policy.mjs +279 -0
- package/tests/figma_adapter.mjs +199 -0
- package/tests/file_map.mjs +91 -0
- package/tests/first_admin_bootstrap.mjs +40 -0
- package/tests/fitness.mjs +435 -0
- package/tests/fitness_two_root.mjs +104 -0
- package/tests/fixtures/session-506-digest.json +34 -0
- package/tests/gate_approvals.mjs +451 -0
- package/tests/gate_review.mjs +152 -0
- package/tests/git_merge_regen.mjs +151 -0
- package/tests/github_push_ancestry.mjs +119 -0
- package/tests/github_push_land_proof.mjs +127 -0
- package/tests/github_push_merge_driver.mjs +175 -0
- package/tests/goal_abuse_matrix.mjs +187 -0
- package/tests/goal_achievement.mjs +146 -0
- package/tests/goal_conflicts.mjs +305 -0
- package/tests/goal_dependencies_read.mjs +73 -0
- package/tests/goal_member_roles.mjs +420 -0
- package/tests/goal_membership_requests.mjs +332 -0
- package/tests/goal_routes.mjs +203 -0
- package/tests/goal_scope_check.mjs +167 -0
- package/tests/goal_tier.mjs +312 -0
- package/tests/goal_wall_proof.mjs +112 -0
- package/tests/grade_attempts.mjs +73 -0
- package/tests/grade_attribution.mjs +117 -0
- package/tests/grade_diff_hash.mjs +191 -0
- package/tests/grade_server_authoritative.mjs +305 -0
- package/tests/grade_unavailable_display.mjs +66 -0
- package/tests/grader.mjs +1960 -0
- package/tests/grader_bypass.mjs +57 -0
- package/tests/grader_panel.mjs +252 -0
- package/tests/grader_prompt_fencing.mjs +192 -0
- package/tests/helpers.mjs +52 -0
- package/tests/hierarchy-config.mjs +81 -0
- package/tests/init.mjs +463 -0
- package/tests/instance_config.mjs +76 -0
- package/tests/instance_manifest.mjs +110 -0
- package/tests/interaction_prefs.mjs +72 -0
- package/tests/key-card.mjs +110 -0
- package/tests/key-status.mjs +45 -0
- package/tests/land_watch.mjs +115 -0
- package/tests/lifecycle-card.mjs +145 -0
- package/tests/lifecycle_breakdown_view.mjs +236 -0
- package/tests/linkify_refs.mjs +264 -0
- package/tests/live_channel.mjs +128 -0
- package/tests/live_sse.mjs +122 -0
- package/tests/llm-cache-route.mjs +110 -0
- package/tests/llm-cache.mjs +140 -0
- package/tests/llm-pricing.mjs +152 -0
- package/tests/local_preview.mjs +136 -0
- package/tests/main_audit.mjs +137 -0
- package/tests/memory_isolation.mjs +431 -0
- package/tests/memory_map.mjs +107 -0
- package/tests/memory_pull.mjs +429 -0
- package/tests/merge_lease.mjs +100 -0
- package/tests/merge_lock.mjs +312 -0
- package/tests/metrics.mjs +147 -0
- package/tests/migration_allocation.mjs +146 -0
- package/tests/migration_namespace.mjs +185 -0
- package/tests/migrations_fresh_replay_guard.mjs +38 -0
- package/tests/mirror_lineage_fulltree.mjs +45 -0
- package/tests/mirror_lineage_proof.mjs +72 -0
- package/tests/mirror_redact.mjs +193 -0
- package/tests/model_alloc_prefs.mjs +63 -0
- package/tests/module-scope-map.mjs +88 -0
- package/tests/module_api.mjs +110 -0
- package/tests/module_cli.mjs +292 -0
- package/tests/module_contributions.mjs +148 -0
- package/tests/module_loader.mjs +286 -0
- package/tests/module_manifest.mjs +194 -0
- package/tests/module_owned_tests.mjs +105 -0
- package/tests/module_provenance.mjs +34 -0
- package/tests/module_route_rank.mjs +147 -0
- package/tests/module_seams.mjs +68 -0
- package/tests/modules.mjs +156 -0
- package/tests/modules_route.mjs +38 -0
- package/tests/newcomer_floor.mjs +17 -0
- package/tests/newcomer_restock.mjs +80 -0
- package/tests/next_number.mjs +154 -0
- package/tests/oauth_secret.mjs +60 -0
- package/tests/onboard.mjs +70 -0
- package/tests/onboarding_gate.mjs +267 -0
- package/tests/onboarding_observability.mjs +344 -0
- package/tests/onboarding_route_signals.mjs +175 -0
- package/tests/onboarding_state.mjs +346 -0
- package/tests/one_active_claim_per_session.mjs +111 -0
- package/tests/orphaned_claim_ship.mjs +135 -0
- package/tests/override_attribution.mjs +142 -0
- package/tests/override_request_ownership.mjs +235 -0
- package/tests/package_core.mjs +435 -0
- package/tests/parallel-load.mjs +402 -0
- package/tests/path_match.mjs +62 -0
- package/tests/permission_path.mjs +151 -0
- package/tests/platform_boot.mjs +167 -0
- package/tests/pre_compact.mjs +60 -0
- package/tests/preview_server.mjs +127 -0
- package/tests/profile_route.mjs +197 -0
- package/tests/provision.mjs +805 -0
- package/tests/provisioning_github_app_routes.mjs +98 -0
- package/tests/public_mirror_export.mjs +124 -0
- package/tests/public_task_resolver.mjs +112 -0
- package/tests/publish_branch_route.mjs +422 -0
- package/tests/publish_manifest.mjs +184 -0
- package/tests/publish_reconciler.mjs +728 -0
- package/tests/rank_gate.mjs +463 -0
- package/tests/rate_limit_public.mjs +217 -0
- package/tests/read_session_export.mjs +89 -0
- package/tests/recall_cli.mjs +82 -0
- package/tests/recall_hints.mjs +119 -0
- package/tests/recent_shipped_window.mjs +80 -0
- package/tests/regrade_eligibility.mjs +202 -0
- package/tests/render_prefs.mjs +122 -0
- package/tests/repo_map.mjs +92 -0
- package/tests/reward_gate.mjs +284 -0
- package/tests/route_rank_check.mjs +243 -0
- package/tests/route_validation.mjs +490 -0
- package/tests/routine_timers.mjs +72 -0
- package/tests/rubric_fixtures.mjs +140 -0
- package/tests/run_routine.mjs +69 -0
- package/tests/sandbox_stage.mjs +582 -0
- package/tests/scrubber_corpus.mjs +257 -0
- package/tests/search_chunker.mjs +209 -0
- package/tests/search_ingest.mjs +96 -0
- package/tests/search_ingest_db.mjs +88 -0
- package/tests/search_isolation.mjs +204 -0
- package/tests/search_route.mjs +95 -0
- package/tests/secret_box.mjs +189 -0
- package/tests/security_docs.mjs +186 -0
- package/tests/security_route.mjs +108 -0
- package/tests/served_paths_shipped.mjs +118 -0
- package/tests/session-cards.mjs +142 -0
- package/tests/session_506_regression.mjs +69 -0
- package/tests/session_caps.mjs +238 -0
- package/tests/session_cost.mjs +133 -0
- package/tests/session_digest.mjs +73 -0
- package/tests/session_eval.mjs +386 -0
- package/tests/session_index.mjs +105 -0
- package/tests/session_records.mjs +369 -0
- package/tests/session_search.mjs +78 -0
- package/tests/session_start_freshness.mjs +123 -0
- package/tests/setup_smoke.mjs +154 -0
- package/tests/ship_autolinkify.mjs +60 -0
- package/tests/ship_automerge_injection.mjs +174 -0
- package/tests/ship_broadcast_routing.mjs +125 -0
- package/tests/ship_broadcast_server.mjs +175 -0
- package/tests/ship_card.mjs +280 -0
- package/tests/ship_ci_deploy.mjs +374 -0
- package/tests/ship_preflight.mjs +368 -0
- package/tests/ship_premerge.mjs +75 -0
- package/tests/ship_resume.mjs +129 -0
- package/tests/ship_upload_skip.mjs +89 -0
- package/tests/ship_zero_commit.mjs +60 -0
- package/tests/single_host_hall.mjs +98 -0
- package/tests/skill_prefs.mjs +150 -0
- package/tests/smoke_preflight.mjs +66 -0
- package/tests/snapshot_picker.mjs +233 -0
- package/tests/sound_prefs.mjs +198 -0
- package/tests/sprite_facing.mjs +35 -0
- package/tests/status-probe.mjs +89 -0
- package/tests/status_backfill.mjs +94 -0
- package/tests/stop_refcheck.mjs +127 -0
- package/tests/streak.mjs +175 -0
- package/tests/subdomain_auth.mjs +58 -0
- package/tests/task_classifier_vocab.mjs +41 -0
- package/tests/task_create_idempotency.mjs +96 -0
- package/tests/task_goal_link.mjs +93 -0
- package/tests/task_ownership_gate.mjs +125 -0
- package/tests/terrain_picker.mjs +298 -0
- package/tests/touches_overlap.mjs +75 -0
- package/tests/triage_development.mjs +140 -0
- package/tests/turn_in_place.mjs +280 -0
- package/tests/upgrade.mjs +357 -0
- package/tests/verify_task_guard.mjs +64 -0
- package/tests/version_close.mjs +76 -0
- package/tests/versions_route.mjs +93 -0
- package/tests/walkability.mjs +188 -0
- package/tests/wandering_prefs.mjs +176 -0
- package/tests/worktree_disambiguation.mjs +105 -0
- 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
|
+
}
|