@mmerterden/multi-agent-pipeline 20.2.0 → 20.3.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/CHANGELOG.md +923 -1
- package/README.md +104 -81
- package/README.tr.md +103 -62
- package/docs/FIGMA_PIPELINE.md +35 -35
- package/docs/adr/0006-skills-core-external-split.md +1 -1
- package/docs/adr/0007-multi-tool-adapter-framework.md +6 -0
- package/docs/adr/0008-installer-modularization-and-secret-leak-defense.md +7 -0
- package/docs/architecture.md +50 -14
- package/docs/best-practices.md +1 -1
- package/docs/ecosystem.md +56 -32
- package/docs/facts.json +10 -10
- package/docs/features.md +97 -5
- package/docs/recovery-guide.md +7 -14
- package/docs/server-readiness.md +31 -24
- package/index.js +1 -1
- package/install/_common.mjs +3 -5
- package/install/_platform-filter.mjs +23 -1
- package/install/_unattended-profile.mjs +321 -75
- package/install/claude.mjs +51 -10
- package/install/codex.mjs +2 -0
- package/install/copilot.mjs +2 -0
- package/install/index.mjs +30 -17
- package/install/templates/claude-hooks.json +16 -5
- package/install/templates/copilot-instructions.md +1 -1
- package/install/templates/multi-agent-autopilot-awake.plist.template +48 -0
- package/install/templates/multi-agent-autopilot.plist.template +12 -5
- package/install/unattended-profile-legacy.json +80 -0
- package/manifest.json +616 -483
- package/package.json +8 -3
- package/pipeline/agents/code-reviewer.md +10 -0
- package/pipeline/agents/plan-critic.md +98 -0
- package/pipeline/agents/security-auditor.md +10 -0
- package/pipeline/agents/task-clarifier.md +10 -0
- package/pipeline/commands/multi-agent/SKILL.md +2 -2
- package/pipeline/commands/multi-agent/analysis/SKILL.md +6 -2
- package/pipeline/commands/multi-agent/analysis-jira/SKILL.md +12 -1
- package/pipeline/commands/multi-agent/analysis-resolve/SKILL.md +4 -0
- package/pipeline/commands/multi-agent/autopilot/SKILL.md +6 -2
- package/pipeline/commands/multi-agent/autopilot-off/SKILL.md +36 -6
- package/pipeline/commands/multi-agent/autopilot-on/SKILL.md +77 -12
- package/pipeline/commands/multi-agent/autopilot-status/SKILL.md +29 -8
- package/pipeline/commands/multi-agent/build-optimize/SKILL.md +4 -0
- package/pipeline/commands/multi-agent/channels/SKILL.md +9 -9
- package/pipeline/commands/multi-agent/complaint-analysis/SKILL.md +5 -1
- package/pipeline/commands/multi-agent/create-jira/SKILL.md +4 -0
- package/pipeline/commands/multi-agent/design-check/SKILL.md +16 -11
- package/pipeline/commands/multi-agent/diff-explain/SKILL.md +4 -0
- package/pipeline/commands/multi-agent/doctor/SKILL.md +4 -0
- package/pipeline/commands/multi-agent/feedback/SKILL.md +5 -1
- package/pipeline/commands/multi-agent/forget/SKILL.md +4 -0
- package/pipeline/commands/multi-agent/garbage-collect/SKILL.md +4 -0
- package/pipeline/commands/multi-agent/graph/SKILL.md +4 -0
- package/pipeline/commands/multi-agent/help/SKILL.md +37 -39
- package/pipeline/commands/multi-agent/ios-coding-standard/SKILL.md +4 -0
- package/pipeline/commands/multi-agent/issue/SKILL.md +7 -1
- package/pipeline/commands/multi-agent/jira/SKILL.md +7 -1
- package/pipeline/commands/multi-agent/kill/SKILL.md +9 -3
- package/pipeline/commands/multi-agent/language/SKILL.md +4 -0
- package/pipeline/commands/multi-agent/log/SKILL.md +4 -0
- package/pipeline/commands/multi-agent/manual-test/SKILL.md +5 -0
- package/pipeline/commands/multi-agent/model/SKILL.md +4 -0
- package/pipeline/commands/multi-agent/prune-logs/SKILL.md +4 -0
- package/pipeline/commands/multi-agent/prune-prompts/SKILL.md +4 -0
- package/pipeline/commands/multi-agent/purge/SKILL.md +4 -0
- package/pipeline/commands/multi-agent/refactor/SKILL.md +5 -0
- package/pipeline/commands/multi-agent/research/SKILL.md +49 -0
- package/pipeline/commands/multi-agent/resume/SKILL.md +29 -6
- package/pipeline/commands/multi-agent/review/SKILL.md +28 -9
- package/pipeline/commands/multi-agent/review-analysis/SKILL.md +5 -1
- package/pipeline/commands/multi-agent/review-issue/SKILL.md +4 -0
- package/pipeline/commands/multi-agent/review-jira/SKILL.md +4 -0
- package/pipeline/commands/multi-agent/route-off/SKILL.md +4 -0
- package/pipeline/commands/multi-agent/route-on/SKILL.md +4 -0
- package/pipeline/commands/multi-agent/route-status/SKILL.md +4 -0
- package/pipeline/commands/multi-agent/routines/SKILL.md +4 -0
- package/pipeline/commands/multi-agent/save/SKILL.md +6 -2
- package/pipeline/commands/multi-agent/scaffold/SKILL.md +47 -0
- package/pipeline/commands/multi-agent/scan/SKILL.md +4 -0
- package/pipeline/commands/multi-agent/search/SKILL.md +12 -8
- package/pipeline/commands/multi-agent/security-review/SKILL.md +4 -0
- package/pipeline/commands/multi-agent/serve/SKILL.md +62 -0
- package/pipeline/commands/multi-agent/setup/SKILL.md +27 -36
- package/pipeline/commands/multi-agent/stack/SKILL.md +4 -0
- package/pipeline/commands/multi-agent/status/SKILL.md +10 -9
- package/pipeline/commands/multi-agent/steer/SKILL.md +5 -1
- package/pipeline/commands/multi-agent/store-ready/SKILL.md +4 -0
- package/pipeline/commands/multi-agent/sync/SKILL.md +13 -13
- package/pipeline/commands/multi-agent/test/SKILL.md +4 -0
- package/pipeline/commands/multi-agent/test-accessibility/SKILL.md +4 -0
- package/pipeline/commands/multi-agent/test-dark-mode/SKILL.md +4 -0
- package/pipeline/commands/multi-agent/test-dynamic-type/SKILL.md +4 -0
- package/pipeline/commands/multi-agent/test-screenshots/SKILL.md +5 -1
- package/pipeline/commands/multi-agent/testflight-validation/SKILL.md +4 -0
- package/pipeline/commands/multi-agent/uninstall/SKILL.md +5 -1
- package/pipeline/commands/multi-agent/update/SKILL.md +4 -0
- package/pipeline/contract/CHANGELOG.md +74 -0
- package/pipeline/contract/README.md +126 -0
- package/pipeline/contract/build.mjs +427 -0
- package/pipeline/contract/fixtures/answer-result.json +11 -0
- package/pipeline/contract/fixtures/error-invalid-request.json +8 -0
- package/pipeline/contract/fixtures/error-unauthorized.json +5 -0
- package/pipeline/contract/fixtures/error-unsigned.json +5 -0
- package/pipeline/contract/fixtures/issues-empty.json +18 -0
- package/pipeline/contract/fixtures/launch-plan.json +31 -0
- package/pipeline/contract/fixtures/runs-awaiting-question.json +70 -0
- package/pipeline/contract/fixtures/runs-empty.json +6 -0
- package/pipeline/contract/fixtures/runs-failed.json +84 -0
- package/pipeline/contract/fixtures/runs-old-schema.json +70 -0
- package/pipeline/contract/fixtures/runs-pr-opened-redacted.json +101 -0
- package/pipeline/contract/fixtures/runs-pr-opened.json +106 -0
- package/pipeline/contract/fixtures/runs-running.json +84 -0
- package/pipeline/contract/fixtures/worktrees-empty.json +4 -0
- package/pipeline/contract/frozen/toolbox.json +107 -0
- package/pipeline/contract/manifest.json +263 -0
- package/pipeline/contract/types/index.d.ts +343 -0
- package/pipeline/lib/_jira-auth.sh +6 -2
- package/pipeline/lib/account-resolver.sh +1 -1
- package/pipeline/lib/autopilot-state.sh +19 -0
- package/pipeline/lib/context-link-extractor.sh +12 -5
- package/pipeline/lib/credential-inventory.sh +12 -5
- package/pipeline/lib/credential-store.sh +116 -185
- package/pipeline/lib/fetch-confluence.sh +44 -3
- package/pipeline/lib/fetch-document.sh +3 -4
- package/pipeline/lib/fetch-fortify.sh +1 -1
- package/pipeline/lib/figma-mcp-refresh.sh +2 -2
- package/pipeline/lib/figma-token.sh +5 -1
- package/pipeline/lib/issue-fetcher.sh +233 -16
- package/pipeline/lib/json-file-lock.mjs +172 -0
- package/pipeline/lib/model-dispatch.sh +21 -12
- package/pipeline/lib/model-rung.sh +6 -1
- package/pipeline/lib/multi-repo-pipeline.sh +1 -1
- package/pipeline/lib/outbound-gate.mjs +46 -16
- package/pipeline/lib/parse-complaints.sh +14 -7
- package/pipeline/lib/plan-todos.sh +3 -3
- package/pipeline/lib/post-pr-review.sh +9 -9
- package/pipeline/lib/pr-request-location.mjs +85 -0
- package/pipeline/lib/regular-file.mjs +153 -0
- package/pipeline/lib/repo-hygiene.sh +17 -0
- package/pipeline/lib/route-state.sh +5 -1
- package/pipeline/lib/run-paths.sh +3 -2
- package/pipeline/lib/stack-detect.sh +19 -1
- package/pipeline/lib/unattended-profile-check.mjs +178 -0
- package/pipeline/lib/unattended-settings-location.mjs +28 -0
- package/pipeline/lib/unattended.mjs +76 -0
- package/pipeline/lib/unattended.sh +32 -0
- package/pipeline/lib/untrusted.mjs +76 -0
- package/pipeline/lib/user-facing.mjs +82 -0
- package/pipeline/lib/user-facing.sh +58 -0
- package/pipeline/multi-agent-refs/_dev-context.md +1 -1
- package/pipeline/multi-agent-refs/analysis/evidence.md +4 -0
- package/pipeline/multi-agent-refs/analysis/locked.md +2 -2
- package/pipeline/multi-agent-refs/analysis/render.md +4 -3
- package/pipeline/multi-agent-refs/analysis/resolve.md +3 -1
- package/pipeline/multi-agent-refs/analysis/synthesis.md +4 -5
- package/pipeline/multi-agent-refs/analysis-template.md +10 -17
- package/pipeline/multi-agent-refs/channels/jira.md +1 -1
- package/pipeline/multi-agent-refs/component-dispatch.md +3 -3
- package/pipeline/multi-agent-refs/conventions-defaults.md +32 -32
- package/pipeline/multi-agent-refs/cross-cli-contract.md +10 -17
- package/pipeline/multi-agent-refs/features/autopilot-circuit-breaker.md +37 -9
- package/pipeline/multi-agent-refs/features/autopilot-operations.md +277 -0
- package/pipeline/multi-agent-refs/features/constitution.md +196 -0
- package/pipeline/multi-agent-refs/features/doctor.md +6 -3
- package/pipeline/multi-agent-refs/features/external-context-injection.md +6 -1
- package/pipeline/multi-agent-refs/features/jira-context.md +1 -1
- package/pipeline/multi-agent-refs/features/maturity-followup.md +43 -23
- package/pipeline/multi-agent-refs/features/model-fallback.md +9 -0
- package/pipeline/multi-agent-refs/features/phone-api.md +306 -0
- package/pipeline/multi-agent-refs/features/plan-critic.md +159 -0
- package/pipeline/multi-agent-refs/features/research.md +150 -0
- package/pipeline/multi-agent-refs/features/review-decision.md +185 -0
- package/pipeline/multi-agent-refs/features/scaffold.md +160 -0
- package/pipeline/multi-agent-refs/features/security-audit.md +6 -2
- package/pipeline/multi-agent-refs/features/skill-conformance.md +4 -1
- package/pipeline/multi-agent-refs/features/stack-adapters.md +116 -0
- package/pipeline/multi-agent-refs/features/unattended-gates.md +316 -0
- package/pipeline/multi-agent-refs/features/unattended-security.md +731 -0
- package/pipeline/multi-agent-refs/features/url-enrichment.md +2 -2
- package/pipeline/multi-agent-refs/features/usage-reporting.md +127 -31
- package/pipeline/multi-agent-refs/features/verify-by-test.md +1 -1
- package/pipeline/multi-agent-refs/features/visual-evidence.md +7 -7
- package/pipeline/multi-agent-refs/keychain.md +6 -11
- package/pipeline/multi-agent-refs/payload-contracts.md +2 -2
- package/pipeline/multi-agent-refs/phases/log-format.md +2 -3
- package/pipeline/multi-agent-refs/phases/modes.md +10 -12
- package/pipeline/multi-agent-refs/phases/operations.md +11 -5
- package/pipeline/multi-agent-refs/phases/phase-0-init.md +73 -81
- package/pipeline/multi-agent-refs/phases/phase-1-plan.md +22 -22
- package/pipeline/multi-agent-refs/phases/phase-2-dev.md +42 -66
- package/pipeline/multi-agent-refs/phases/phase-3-review.md +45 -79
- package/pipeline/multi-agent-refs/phases/phase-4-commit.md +28 -38
- package/pipeline/multi-agent-refs/phases/phase-5-report.md +27 -25
- package/pipeline/multi-agent-refs/phases.md +1 -1
- package/pipeline/multi-agent-refs/picker-contract.md +1 -1
- package/pipeline/multi-agent-refs/progress-contract.md +13 -16
- package/pipeline/multi-agent-refs/readiness-review.md +1 -1
- package/pipeline/multi-agent-refs/research/engine.md +91 -0
- package/pipeline/multi-agent-refs/rules.md +6 -4
- package/pipeline/multi-agent-refs/setup/repo-discovery.md +2 -2
- package/pipeline/multi-agent-refs/tracker-contract.md +2 -2
- package/pipeline/multi-agent-refs/unattended-contract.md +68 -16
- package/pipeline/rules/figma-pipeline.md +12 -12
- package/pipeline/schemas/agent-state.schema.json +480 -18
- package/pipeline/schemas/analysis-spec.schema.json +4 -4
- package/pipeline/schemas/answer-request.schema.json +24 -0
- package/pipeline/schemas/answer-result.schema.json +28 -0
- package/pipeline/schemas/autopilot-config.schema.json +111 -13
- package/pipeline/schemas/command-parameters.schema.json +99 -0
- package/pipeline/schemas/constitution.schema.json +56 -0
- package/pipeline/schemas/contract-error.schema.json +52 -0
- package/pipeline/schemas/design-check-config.schema.json +5 -1
- package/pipeline/schemas/issues.schema.json +61 -0
- package/pipeline/schemas/launch-plan.schema.json +46 -0
- package/pipeline/schemas/launch-request.schema.json +45 -0
- package/pipeline/schemas/launch.json +61 -0
- package/pipeline/schemas/launch.schema.json +84 -0
- package/pipeline/schemas/phases.json +2 -2
- package/pipeline/schemas/phases.schema.json +68 -0
- package/pipeline/schemas/phone-devices.schema.json +61 -0
- package/pipeline/schemas/phone-signed-request.schema.json +67 -0
- package/pipeline/schemas/plan-critique.schema.json +99 -0
- package/pipeline/schemas/plan-todos.schema.json +7 -7
- package/pipeline/schemas/planning-output.schema.json +5 -0
- package/pipeline/schemas/pr-request.schema.json +46 -0
- package/pipeline/schemas/prefs.schema.json +82 -7
- package/pipeline/schemas/research-output.schema.json +118 -0
- package/pipeline/schemas/review-file-exclusions.schema.json +25 -0
- package/pipeline/schemas/reviewer-output.schema.json +40 -4
- package/pipeline/schemas/run-questions.json +392 -0
- package/pipeline/schemas/run-questions.schema.json +118 -0
- package/pipeline/schemas/runs-index.schema.json +189 -0
- package/pipeline/schemas/scaffold-manifest.schema.json +43 -0
- package/pipeline/schemas/secret-patterns.schema.json +28 -0
- package/pipeline/schemas/stack-adapters.json +527 -0
- package/pipeline/schemas/stack-adapters.schema.json +184 -0
- package/pipeline/schemas/token-budget.json +1 -1
- package/pipeline/schemas/token-budget.schema.json +26 -0
- package/pipeline/schemas/triage-output.schema.json +64 -3
- package/pipeline/schemas/unattended-policy.json +139 -0
- package/pipeline/schemas/unattended-policy.schema.json +73 -0
- package/pipeline/schemas/unattended-profile.json +248 -0
- package/pipeline/schemas/unattended-profile.schema.json +198 -0
- package/pipeline/schemas/worktrees.schema.json +51 -0
- package/pipeline/scripts/README.md +1 -0
- package/pipeline/scripts/_autopilot-config.mjs +130 -0
- package/pipeline/scripts/_autopilot-ops.mjs +567 -0
- package/pipeline/scripts/_autopilot-outcomes.mjs +174 -0
- package/pipeline/scripts/_command-contract.mjs +384 -0
- package/pipeline/scripts/_cost.mjs +40 -0
- package/pipeline/scripts/_notices.mjs +160 -0
- package/pipeline/scripts/_phone-auth.mjs +485 -0
- package/pipeline/scripts/_pre-existing.mjs +294 -0
- package/pipeline/scripts/_redact.mjs +77 -0
- package/pipeline/scripts/_run-paths.mjs +4 -2
- package/pipeline/scripts/_stack-adapter.mjs +678 -0
- package/pipeline/scripts/_stack-routing.mjs +1 -1
- package/pipeline/scripts/agent-guard.py +348 -37
- package/pipeline/scripts/agent-guard.sh +41 -13
- package/pipeline/scripts/analysis-story-tree.mjs +79 -3
- package/pipeline/scripts/answer-question.mjs +181 -0
- package/pipeline/scripts/audit-log-rotate.sh +1 -4
- package/pipeline/scripts/audit-log.sh +4 -4
- package/pipeline/scripts/autopilot-arming.mjs +389 -21
- package/pipeline/scripts/autopilot-awake.mjs +255 -0
- package/pipeline/scripts/autopilot-intake.mjs +137 -36
- package/pipeline/scripts/autopilot-menubar.swift +156 -44
- package/pipeline/scripts/autopilot-publish.mjs +1625 -0
- package/pipeline/scripts/autopilot-runner.mjs +1678 -222
- package/pipeline/scripts/autopilot-status.sh +198 -33
- package/pipeline/scripts/build-lock.sh +120 -0
- package/pipeline/scripts/build-references.mjs +4 -1
- package/pipeline/scripts/build-stack-plugins.mjs +59 -22
- package/pipeline/scripts/capture-flush.sh +1 -1
- package/pipeline/scripts/capture-resume.sh +13 -9
- package/pipeline/scripts/check-derived-drift.mjs +52 -11
- package/pipeline/scripts/commands.mjs +88 -0
- package/pipeline/scripts/constitution.mjs +362 -0
- package/pipeline/scripts/contract-server.mjs +776 -0
- package/pipeline/scripts/cost-analyze.mjs +89 -39
- package/pipeline/scripts/diff-explain.mjs +12 -1
- package/pipeline/scripts/doctor.mjs +77 -28
- package/pipeline/scripts/evidence-gate.mjs +192 -12
- package/pipeline/scripts/feedback-send.mjs +4 -2
- package/pipeline/scripts/gate-ledger.mjs +449 -0
- package/pipeline/scripts/gc-abandoned.sh +132 -13
- package/pipeline/scripts/gen-facts.mjs +31 -15
- package/pipeline/scripts/gen-mode-dispatch.mjs +3 -3
- package/pipeline/scripts/github-ssh-setup.sh +140 -29
- package/pipeline/scripts/graph-mermaid.mjs +4 -1
- package/pipeline/scripts/issues.mjs +236 -0
- package/pipeline/scripts/jira-attach.sh +6 -2
- package/pipeline/scripts/jira-search.sh +4 -3
- package/pipeline/scripts/keychain-save.sh +125 -24
- package/pipeline/scripts/keychain.py +63 -93
- package/pipeline/scripts/launch-request.mjs +747 -0
- package/pipeline/scripts/localize-commands.mjs +4 -10
- package/pipeline/scripts/log-metric.sh +6 -5
- package/pipeline/scripts/maturity-followup.mjs +13 -4
- package/pipeline/scripts/memory-save.sh +25 -0
- package/pipeline/scripts/migrate-prefs.mjs +4 -3
- package/pipeline/scripts/open-questions-gate.mjs +276 -0
- package/pipeline/scripts/phase-tracker.sh +41 -27
- package/pipeline/scripts/phase0-exit-gate.mjs +22 -4
- package/pipeline/scripts/phone-devices.mjs +224 -0
- package/pipeline/scripts/plan-coverage-gate.mjs +200 -66
- package/pipeline/scripts/plan-critique-gate.mjs +591 -0
- package/pipeline/scripts/pr-request.mjs +188 -0
- package/pipeline/scripts/pre-commit-check.sh +115 -4
- package/pipeline/scripts/probe-evidence-capability.sh +44 -5
- package/pipeline/scripts/record-phase.mjs +71 -0
- package/pipeline/scripts/render-agent-log-cost.sh +17 -2
- package/pipeline/scripts/render-cost-summary.sh +1 -1
- package/pipeline/scripts/render-work-summary.sh +1 -1
- package/pipeline/scripts/require-supported-version.sh +4 -1
- package/pipeline/scripts/research-gate.mjs +704 -0
- package/pipeline/scripts/review-decision-gate.mjs +403 -0
- package/pipeline/scripts/routine-registry.mjs +5 -2
- package/pipeline/scripts/runs-index.mjs +135 -27
- package/pipeline/scripts/scaffold-gate.mjs +393 -0
- package/pipeline/scripts/skill-conformance.mjs +25 -8
- package/pipeline/scripts/skill-siblings.mjs +2 -1
- package/pipeline/scripts/smoke-cross-cli-behavior.sh +32 -6
- package/pipeline/scripts/smoke-schema-validation.sh +6 -2
- package/pipeline/scripts/spec-consistency-gate.mjs +469 -0
- package/pipeline/scripts/symbol-existence-gate.mjs +450 -0
- package/pipeline/scripts/test-gap-scan.mjs +40 -2
- package/pipeline/scripts/test-integrity-gate.mjs +20 -4
- package/pipeline/scripts/test-strength.mjs +484 -0
- package/pipeline/scripts/test-summary.mjs +651 -0
- package/pipeline/scripts/triage-memory.mjs +49 -9
- package/pipeline/scripts/unattended_policy.py +2786 -0
- package/pipeline/scripts/uninstall.mjs +10 -10
- package/pipeline/scripts/update-issue-progress.sh +1 -1
- package/pipeline/scripts/usage-identity.mjs +288 -0
- package/pipeline/scripts/usage-register.mjs +185 -63
- package/pipeline/scripts/usage-report.mjs +230 -66
- package/pipeline/scripts/validate-complaint-doc.mjs +28 -10
- package/pipeline/scripts/validate-planning.mjs +6 -0
- package/pipeline/scripts/verify-citations.mjs +151 -38
- package/pipeline/scripts/verify.mjs +58 -18
- package/pipeline/scripts/worktree-prepare.sh +126 -0
- package/pipeline/scripts/worktrees.mjs +124 -0
- package/pipeline/scripts/write-state.mjs +48 -17
- package/pipeline/skills/.skill-manifest.json +222 -226
- package/pipeline/skills/.skills-index.json +77 -88
- package/pipeline/skills/shared/README.md +44 -45
- package/pipeline/skills/shared/core/apple-archive-compliance/SKILL.md +3 -2
- package/pipeline/skills/shared/core/google-play-compliance/SKILL.md +3 -2
- package/pipeline/skills/shared/core/multi-agent/SKILL.md +10 -9
- package/pipeline/skills/shared/core/multi-agent-analysis/SKILL.md +6 -5
- package/pipeline/skills/shared/core/multi-agent-analysis-jira/SKILL.md +11 -3
- package/pipeline/skills/shared/core/multi-agent-analysis-resolve/SKILL.md +2 -1
- package/pipeline/skills/shared/core/multi-agent-autopilot/SKILL.md +4 -3
- package/pipeline/skills/shared/core/multi-agent-autopilot-off/SKILL.md +32 -6
- package/pipeline/skills/shared/core/multi-agent-autopilot-on/SKILL.md +66 -8
- package/pipeline/skills/shared/core/multi-agent-autopilot-status/SKILL.md +27 -9
- package/pipeline/skills/shared/core/multi-agent-build-optimize/SKILL.md +2 -1
- package/pipeline/skills/shared/core/multi-agent-channels/SKILL.md +7 -4
- package/pipeline/skills/shared/core/multi-agent-complaint-analysis/SKILL.md +3 -2
- package/pipeline/skills/shared/core/multi-agent-create-jira/SKILL.md +2 -1
- package/pipeline/skills/shared/core/multi-agent-design-check/SKILL.md +7 -5
- package/pipeline/skills/shared/core/multi-agent-diff-explain/SKILL.md +2 -1
- package/pipeline/skills/shared/core/multi-agent-doctor/SKILL.md +3 -2
- package/pipeline/skills/shared/core/multi-agent-feedback/SKILL.md +2 -1
- package/pipeline/skills/shared/core/multi-agent-forget/SKILL.md +2 -1
- package/pipeline/skills/shared/core/multi-agent-garbage-collect/SKILL.md +2 -1
- package/pipeline/skills/shared/core/multi-agent-graph/SKILL.md +2 -1
- package/pipeline/skills/shared/core/multi-agent-help/SKILL.md +2 -1
- package/pipeline/skills/shared/core/multi-agent-ios-coding-standard/SKILL.md +2 -1
- package/pipeline/skills/shared/core/multi-agent-issue/SKILL.md +3 -2
- package/pipeline/skills/shared/core/multi-agent-jira/SKILL.md +2 -1
- package/pipeline/skills/shared/core/multi-agent-kill/SKILL.md +7 -4
- package/pipeline/skills/shared/core/multi-agent-language/SKILL.md +2 -1
- package/pipeline/skills/shared/core/multi-agent-log/SKILL.md +2 -1
- package/pipeline/skills/shared/core/multi-agent-manual-test/SKILL.md +2 -1
- package/pipeline/skills/shared/core/multi-agent-model/SKILL.md +2 -1
- package/pipeline/skills/shared/core/multi-agent-prune-logs/SKILL.md +2 -1
- package/pipeline/skills/shared/core/multi-agent-prune-prompts/SKILL.md +2 -1
- package/pipeline/skills/shared/core/multi-agent-purge/SKILL.md +2 -1
- package/pipeline/skills/shared/core/multi-agent-refactor/SKILL.md +2 -1
- package/pipeline/skills/shared/core/multi-agent-research/SKILL.md +35 -0
- package/pipeline/skills/shared/core/multi-agent-resume/SKILL.md +7 -3
- package/pipeline/skills/shared/core/multi-agent-review/SKILL.md +7 -5
- package/pipeline/skills/shared/core/multi-agent-review-analysis/SKILL.md +2 -1
- package/pipeline/skills/shared/core/multi-agent-review-issue/SKILL.md +3 -2
- package/pipeline/skills/shared/core/multi-agent-review-jira/SKILL.md +2 -1
- package/pipeline/skills/shared/core/multi-agent-route-off/SKILL.md +2 -1
- package/pipeline/skills/shared/core/multi-agent-route-on/SKILL.md +2 -1
- package/pipeline/skills/shared/core/multi-agent-route-status/SKILL.md +2 -1
- package/pipeline/skills/shared/core/multi-agent-routines/SKILL.md +2 -1
- package/pipeline/skills/shared/core/multi-agent-save/SKILL.md +3 -2
- package/pipeline/skills/shared/core/multi-agent-scaffold/SKILL.md +30 -0
- package/pipeline/skills/shared/core/multi-agent-scan/SKILL.md +2 -1
- package/pipeline/skills/shared/core/multi-agent-search/SKILL.md +4 -3
- package/pipeline/skills/shared/core/multi-agent-security-review/SKILL.md +2 -1
- package/pipeline/skills/shared/core/multi-agent-serve/SKILL.md +60 -0
- package/pipeline/skills/shared/core/multi-agent-setup/SKILL.md +8 -8
- package/pipeline/skills/shared/core/multi-agent-stack/SKILL.md +2 -1
- package/pipeline/skills/shared/core/multi-agent-status/SKILL.md +7 -8
- package/pipeline/skills/shared/core/multi-agent-steer/SKILL.md +2 -1
- package/pipeline/skills/shared/core/multi-agent-store-ready/SKILL.md +2 -1
- package/pipeline/skills/shared/core/multi-agent-sync/SKILL.md +10 -10
- package/pipeline/skills/shared/core/multi-agent-test/SKILL.md +2 -1
- package/pipeline/skills/shared/core/multi-agent-test-accessibility/SKILL.md +2 -1
- package/pipeline/skills/shared/core/multi-agent-test-dark-mode/SKILL.md +2 -1
- package/pipeline/skills/shared/core/multi-agent-test-dynamic-type/SKILL.md +2 -1
- package/pipeline/skills/shared/core/multi-agent-test-screenshots/SKILL.md +2 -1
- package/pipeline/skills/shared/core/multi-agent-testflight-validation/SKILL.md +2 -1
- package/pipeline/skills/shared/core/multi-agent-uninstall/SKILL.md +3 -2
- package/pipeline/skills/shared/core/multi-agent-update/SKILL.md +2 -1
- package/pipeline/skills/shared/external/NOTICE-avdlee-swiftui-agent-skill.md +46 -0
- package/pipeline/skills/shared/external/NOTICE-dimillian-skills.md +9 -3
- package/pipeline/skills/shared/external/NOTICE-paul-hudson-skills.md +54 -0
- package/pipeline/skills/shared/external/NOTICE-swift-ios-skills.md +9 -11
- package/pipeline/skills/shared/external/NOTICE-vibeship-spawner-skills.md +203 -0
- package/pipeline/skills/shared/external/NOTICE-xcode-build-skills.md +10 -3
- package/pipeline/skills/shared/external/accessibility-compliance-accessibility-audit/SKILL.md +131 -25
- package/pipeline/skills/shared/external/agent-introspection-debugging/SKILL.md +4 -3
- package/pipeline/skills/shared/external/alarmkit/SKILL.md +2 -1
- package/pipeline/skills/shared/external/android-architecture/SKILL.md +85 -76
- package/pipeline/skills/shared/external/android-build-quality-gates/SKILL.md +51 -3
- package/pipeline/skills/shared/external/android-build-quality-gates/references/patterns.md +9 -0
- package/pipeline/skills/shared/external/android-datastore/SKILL.md +4 -3
- package/pipeline/skills/shared/external/android-design-tokens-codegen/SKILL.md +4 -3
- package/pipeline/skills/shared/external/android-jetpack-compose-expert/SKILL.md +106 -181
- package/pipeline/skills/shared/external/android-jetpack-compose-expert/references/patterns.md +147 -0
- package/pipeline/skills/shared/external/android-mvi-viewmodel/SKILL.md +45 -4
- package/pipeline/skills/shared/external/android-performance/SKILL.md +27 -2
- package/pipeline/skills/shared/external/android-performance/references/patterns.md +37 -37
- package/pipeline/skills/shared/external/api-patterns/SKILL.md +111 -67
- package/pipeline/skills/shared/external/api-patterns/references/contract-details.md +128 -0
- package/pipeline/skills/shared/external/api-security-best-practices/SKILL.md +136 -186
- package/pipeline/skills/shared/external/api-security-best-practices/references/abuse-controls.md +140 -0
- package/pipeline/skills/shared/external/api-security-best-practices/references/identity-and-access.md +207 -0
- package/pipeline/skills/shared/external/api-security-best-practices/references/operations.md +66 -0
- package/pipeline/skills/shared/external/api-security-best-practices/references/request-handling.md +175 -0
- package/pipeline/skills/shared/external/app-clips/SKILL.md +2 -0
- package/pipeline/skills/shared/external/app-intents/SKILL.md +2 -0
- package/pipeline/skills/shared/external/app-store-changelog/SKILL.md +4 -3
- package/pipeline/skills/shared/external/app-store-optimization/SKILL.md +2 -0
- package/pipeline/skills/shared/external/app-store-review/SKILL.md +2 -0
- package/pipeline/skills/shared/external/apple-on-device-ai/SKILL.md +2 -0
- package/pipeline/skills/shared/external/architecture/SKILL.md +106 -39
- package/pipeline/skills/shared/external/architecture/references/adr-and-review.md +76 -0
- package/pipeline/skills/shared/external/authentication/SKILL.md +2 -0
- package/pipeline/skills/shared/external/avkit/SKILL.md +2 -0
- package/pipeline/skills/shared/external/background-processing/SKILL.md +2 -1
- package/pipeline/skills/shared/external/backlog/SKILL.md +3 -2
- package/pipeline/skills/shared/external/callkit-voip/SKILL.md +2 -0
- package/pipeline/skills/shared/external/callkit-voip/evals/evals.json +1 -1
- package/pipeline/skills/shared/external/ci-cd-pipelines/SKILL.md +3 -2
- package/pipeline/skills/shared/external/clean-code/SKILL.md +192 -90
- package/pipeline/skills/shared/external/cloudkit-sync/SKILL.md +2 -1
- package/pipeline/skills/shared/external/cloudkit-sync/evals/evals.json +1 -1
- package/pipeline/skills/shared/external/compose-components/SKILL.md +4 -4
- package/pipeline/skills/shared/external/compose-navigation/SKILL.md +22 -22
- package/pipeline/skills/shared/external/compose-navigation/references/patterns.md +10 -10
- package/pipeline/skills/shared/external/compose-testing/SKILL.md +13 -6
- package/pipeline/skills/shared/external/compose-testing/references/patterns.md +59 -59
- package/pipeline/skills/shared/external/contacts-framework/SKILL.md +2 -0
- package/pipeline/skills/shared/external/context-compression/SKILL.md +118 -250
- package/pipeline/skills/shared/external/core-bluetooth/SKILL.md +2 -0
- package/pipeline/skills/shared/external/core-data/SKILL.md +2 -0
- package/pipeline/skills/shared/external/core-motion/SKILL.md +2 -0
- package/pipeline/skills/shared/external/core-nfc/SKILL.md +2 -0
- package/pipeline/skills/shared/external/coreml/SKILL.md +2 -1
- package/pipeline/skills/shared/external/council/SKILL.md +2 -1
- package/pipeline/skills/shared/external/cryptokit/SKILL.md +2 -0
- package/pipeline/skills/shared/external/css-modern/SKILL.md +3 -2
- package/pipeline/skills/shared/external/database-patterns/SKILL.md +3 -2
- package/pipeline/skills/shared/external/debugging-instruments/SKILL.md +2 -0
- package/pipeline/skills/shared/external/debugging-strategies/SKILL.md +130 -20
- package/pipeline/skills/shared/external/debugging-strategies/references/hard-cases.md +54 -0
- package/pipeline/skills/shared/external/device-integrity/SKILL.md +2 -0
- package/pipeline/skills/shared/external/docker-expert/SKILL.md +137 -381
- package/pipeline/skills/shared/external/docker-expert/references/patterns.md +98 -0
- package/pipeline/skills/shared/external/energykit/SKILL.md +2 -1
- package/pipeline/skills/shared/external/eventkit-calendar/SKILL.md +3 -1
- package/pipeline/skills/shared/external/eventkit-calendar/evals/evals.json +2 -2
- package/pipeline/skills/shared/external/fastapi-pro/SKILL.md +116 -185
- package/pipeline/skills/shared/external/fastapi-pro/references/app-structure.md +235 -0
- package/pipeline/skills/shared/external/fastapi-pro/references/testing-and-deployment.md +69 -0
- package/pipeline/skills/shared/external/firebase/SKILL.md +4 -3
- package/pipeline/skills/shared/external/github-actions-templates/SKILL.md +151 -293
- package/pipeline/skills/shared/external/github-actions-templates/references/patterns.md +96 -0
- package/pipeline/skills/shared/external/healthkit/SKILL.md +2 -0
- package/pipeline/skills/shared/external/hig-components-content/SKILL.md +69 -72
- package/pipeline/skills/shared/external/hig-components-content/references/content-views.md +111 -0
- package/pipeline/skills/shared/external/hig-components-layout/SKILL.md +77 -86
- package/pipeline/skills/shared/external/hig-components-layout/references/containers.md +98 -0
- package/pipeline/skills/shared/external/hig-components-status/SKILL.md +150 -78
- package/pipeline/skills/shared/external/hig-components-system/SKILL.md +76 -97
- package/pipeline/skills/shared/external/hig-components-system/references/surfaces.md +98 -0
- package/pipeline/skills/shared/external/hig-foundations/SKILL.md +48 -76
- package/pipeline/skills/shared/external/hig-foundations/references/foundations-detail.md +132 -0
- package/pipeline/skills/shared/external/hig-inputs/SKILL.md +147 -106
- package/pipeline/skills/shared/external/hig-patterns/SKILL.md +61 -77
- package/pipeline/skills/shared/external/hig-patterns/references/patterns.md +92 -0
- package/pipeline/skills/shared/external/hig-platforms/SKILL.md +147 -77
- package/pipeline/skills/shared/external/hig-technologies/SKILL.md +61 -121
- package/pipeline/skills/shared/external/hig-technologies/references/technologies.md +108 -0
- package/pipeline/skills/shared/external/homekit-matter/SKILL.md +2 -0
- package/pipeline/skills/shared/external/homekit-matter/evals/evals.json +1 -1
- package/pipeline/skills/shared/external/html-semantic/SKILL.md +3 -2
- package/pipeline/skills/shared/external/humanizer/SKILL.md +2 -1
- package/pipeline/skills/shared/external/ios-accessibility/SKILL.md +2 -0
- package/pipeline/skills/shared/external/ios-coding-standard/SKILL.md +2 -1
- package/pipeline/skills/shared/external/ios-coding-standard/references/STANDARD.md +14 -2
- package/pipeline/skills/shared/external/ios-coding-standard/references/rules.yml +2 -2
- package/pipeline/skills/shared/external/ios-debugger-agent/SKILL.md +4 -3
- package/pipeline/skills/shared/external/ios-localization/SKILL.md +8 -6
- package/pipeline/skills/shared/external/ios-localization/evals/evals.json +2 -2
- package/pipeline/skills/shared/external/ios-localization/references/string-catalogs.md +11 -11
- package/pipeline/skills/shared/external/ios-module-structure/SKILL.md +2 -1
- package/pipeline/skills/shared/external/ios-networking/SKILL.md +2 -0
- package/pipeline/skills/shared/external/ios-simulator/SKILL.md +2 -0
- package/pipeline/skills/shared/external/kotlin-coroutines-expert/SKILL.md +102 -193
- package/pipeline/skills/shared/external/kotlin-coroutines-expert/references/patterns.md +103 -0
- package/pipeline/skills/shared/external/live-activities/SKILL.md +4 -2
- package/pipeline/skills/shared/external/live-activities/evals/evals.json +2 -2
- package/pipeline/skills/shared/external/localization-reuse-map/SKILL.md +7 -9
- package/pipeline/skills/shared/external/localization-reuse-map/reference/sources-and-recipes.md +3 -3
- package/pipeline/skills/shared/external/localization-reuse-map/scripts/resolve-new-values.py +2 -2
- package/pipeline/skills/shared/external/macos-menubar-tuist-app/SKILL.md +4 -3
- package/pipeline/skills/shared/external/macos-spm-app-packaging/SKILL.md +5 -3
- package/pipeline/skills/shared/external/mapkit-location/SKILL.md +2 -0
- package/pipeline/skills/shared/external/mapkit-location/evals/evals.json +1 -1
- package/pipeline/skills/shared/external/metrickit-diagnostics/SKILL.md +2 -0
- package/pipeline/skills/shared/external/metrickit-diagnostics/evals/evals.json +1 -1
- package/pipeline/skills/shared/external/monorepo-architect/SKILL.md +149 -46
- package/pipeline/skills/shared/external/monorepo-architect/references/patterns.md +70 -0
- package/pipeline/skills/shared/external/musickit-audio/SKILL.md +2 -0
- package/pipeline/skills/shared/external/musickit-audio/evals/evals.json +1 -1
- package/pipeline/skills/shared/external/natural-language/SKILL.md +2 -0
- package/pipeline/skills/shared/external/nextjs-app-router/SKILL.md +3 -2
- package/pipeline/skills/shared/external/nodejs-backend-patterns/SKILL.md +91 -21
- package/pipeline/skills/shared/external/nodejs-backend-patterns/references/runtime-patterns.md +247 -0
- package/pipeline/skills/shared/external/nodejs-backend-patterns/references/testing-and-frameworks.md +57 -0
- package/pipeline/skills/shared/external/observability-engineer/SKILL.md +155 -232
- package/pipeline/skills/shared/external/observability-engineer/references/patterns.md +75 -0
- package/pipeline/skills/shared/external/passkit-wallet/SKILL.md +2 -0
- package/pipeline/skills/shared/external/passkit-wallet/evals/evals.json +2 -2
- package/pipeline/skills/shared/external/passkit-wallet/references/wallet-passes.md +18 -18
- package/pipeline/skills/shared/external/pdfkit/SKILL.md +2 -1
- package/pipeline/skills/shared/external/pencilkit-drawing/SKILL.md +2 -0
- package/pipeline/skills/shared/external/pencilkit-drawing/evals/evals.json +1 -1
- package/pipeline/skills/shared/external/permissionkit/SKILL.md +2 -0
- package/pipeline/skills/shared/external/photos-camera-media/SKILL.md +2 -1
- package/pipeline/skills/shared/external/push-notifications/SKILL.md +2 -0
- package/pipeline/skills/shared/external/python-patterns/SKILL.md +3 -2
- package/pipeline/skills/shared/external/react-best-practices/SKILL.md +3 -2
- package/pipeline/skills/shared/external/realitykit-ar/SKILL.md +2 -2
- package/pipeline/skills/shared/external/realitykit-ar/evals/evals.json +1 -1
- package/pipeline/skills/shared/external/rest-api-design/SKILL.md +3 -2
- package/pipeline/skills/shared/external/retrofit-networking/SKILL.md +7 -7
- package/pipeline/skills/shared/external/retrofit-networking/references/patterns.md +69 -69
- package/pipeline/skills/shared/external/room-database/references/patterns.md +126 -126
- package/pipeline/skills/shared/external/search-first/SKILL.md +2 -1
- package/pipeline/skills/shared/external/security-review/SKILL.md +4 -3
- package/pipeline/skills/shared/external/shareplay-activities/SKILL.md +2 -2
- package/pipeline/skills/shared/external/signal-community/SKILL.md +1 -1
- package/pipeline/skills/shared/external/skill-creator/SKILL.md +2 -1
- package/pipeline/skills/shared/external/speech-recognition/SKILL.md +2 -1
- package/pipeline/skills/shared/external/spm-build-analysis/SKILL.md +2 -0
- package/pipeline/skills/shared/external/storekit/SKILL.md +2 -0
- package/pipeline/skills/shared/external/swift-api-design-guidelines/SKILL.md +2 -0
- package/pipeline/skills/shared/external/swift-architecture/SKILL.md +2 -1
- package/pipeline/skills/shared/external/swift-charts/SKILL.md +2 -1
- package/pipeline/skills/shared/external/swift-codable/SKILL.md +2 -0
- package/pipeline/skills/shared/external/swift-concurrency/SKILL.md +3 -1
- package/pipeline/skills/shared/external/swift-concurrency-expert/SKILL.md +5 -4
- package/pipeline/skills/shared/external/swift-concurrency-pro/SKILL.md +2 -1
- package/pipeline/skills/shared/external/swift-formatstyle/SKILL.md +2 -0
- package/pipeline/skills/shared/external/swift-language/SKILL.md +3 -1
- package/pipeline/skills/shared/external/swift-security/SKILL.md +2 -1
- package/pipeline/skills/shared/external/swift-testing/SKILL.md +2 -0
- package/pipeline/skills/shared/external/swift-testing-pro/SKILL.md +2 -1
- package/pipeline/skills/shared/external/swift-testing-pro/references/new-features.md +10 -0
- package/pipeline/skills/shared/external/swiftdata/SKILL.md +2 -0
- package/pipeline/skills/shared/external/swiftdata-pro/SKILL.md +2 -1
- package/pipeline/skills/shared/external/swiftlint/SKILL.md +2 -0
- package/pipeline/skills/shared/external/swiftui-animation/SKILL.md +2 -0
- package/pipeline/skills/shared/external/swiftui-expert-skill/SKILL.md +3 -1
- package/pipeline/skills/shared/external/swiftui-gestures/SKILL.md +2 -0
- package/pipeline/skills/shared/external/swiftui-layout-components/SKILL.md +2 -0
- package/pipeline/skills/shared/external/swiftui-liquid-glass/SKILL.md +2 -0
- package/pipeline/skills/shared/external/swiftui-navigation/SKILL.md +2 -0
- package/pipeline/skills/shared/external/swiftui-patterns/SKILL.md +3 -1
- package/pipeline/skills/shared/external/swiftui-performance/SKILL.md +3 -1
- package/pipeline/skills/shared/external/swiftui-performance-audit/SKILL.md +5 -4
- package/pipeline/skills/shared/external/swiftui-pro/SKILL.md +2 -1
- package/pipeline/skills/shared/external/swiftui-ui-patterns/SKILL.md +5 -4
- package/pipeline/skills/shared/external/swiftui-uikit-interop/SKILL.md +2 -0
- package/pipeline/skills/shared/external/swiftui-view-refactor/SKILL.md +4 -3
- package/pipeline/skills/shared/external/swiftui-webkit/SKILL.md +2 -0
- package/pipeline/skills/shared/external/tailwind-css/SKILL.md +3 -2
- package/pipeline/skills/shared/external/testing-backend/SKILL.md +3 -2
- package/pipeline/skills/shared/external/tipkit/SKILL.md +2 -0
- package/pipeline/skills/shared/external/typescript-patterns/SKILL.md +3 -2
- package/pipeline/skills/shared/external/vision-framework/SKILL.md +2 -0
- package/pipeline/skills/shared/external/vue-composition/SKILL.md +3 -2
- package/pipeline/skills/shared/external/weatherkit/SKILL.md +2 -0
- package/pipeline/skills/shared/external/web-accessibility/SKILL.md +3 -2
- package/pipeline/skills/shared/external/web-performance/SKILL.md +3 -2
- package/pipeline/skills/shared/external/web-testing/SKILL.md +3 -2
- package/pipeline/skills/shared/external/widgetkit/SKILL.md +2 -0
- package/pipeline/skills/shared/external/widgetkit/references/widgetkit-advanced.md +1 -1
- package/pipeline/skills/shared/external/xcode-build-benchmark/SKILL.md +2 -0
- package/pipeline/skills/shared/external/xcode-build-fixer/SKILL.md +2 -0
- package/pipeline/skills/shared/external/xcode-build-orchestrator/SKILL.md +2 -0
- package/pipeline/skills/shared/external/xcode-compilation-analyzer/SKILL.md +2 -0
- package/pipeline/skills/shared/external/xcode-project-analyzer/SKILL.md +2 -0
- package/pipeline/skills/skills-index.md +40 -41
- package/docs/token-budget-history.md +0 -24
- package/pipeline/skills/shared/external/agentflow/SKILL.md +0 -199
- package/pipeline/skills/shared/external/android-ui-verification/SKILL.md +0 -66
- package/pipeline/skills/shared/external/api-security-best-practices/references/auth.md +0 -299
- package/pipeline/skills/shared/external/api-security-best-practices/references/input-validation.md +0 -255
- package/pipeline/skills/shared/external/api-security-best-practices/references/rate-limiting.md +0 -167
- package/pipeline/skills/shared/external/closed-loop-delivery/SKILL.md +0 -116
- package/pipeline/skills/shared/external/ios-developer/SKILL.md +0 -216
|
@@ -0,0 +1,1625 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* @file autopilot-publish.mjs - the runner's publish step: the one place an
|
|
4
|
+
* unattended run's work leaves the machine.
|
|
5
|
+
*
|
|
6
|
+
* Under MULTI_AGENT_UNATTENDED=1 a run commits and then leaves a PR request
|
|
7
|
+
* (pr-request.mjs write) instead of pushing; agent-guard blocks every outward
|
|
8
|
+
* write it could make itself. After the run's session ends the autopilot
|
|
9
|
+
* runner calls publish(), which treats everything the run wrote - the request,
|
|
10
|
+
* the state, the gate ledger, the worktree and its git config - as a claim to
|
|
11
|
+
* check, not an instruction:
|
|
12
|
+
*
|
|
13
|
+
* 1. The request, the state and the test log are read as bounded regular
|
|
14
|
+
* files (lib/regular-file.mjs): a FIFO, a device or an oversized file is
|
|
15
|
+
* refused, never read. The request is read once and validated in process
|
|
16
|
+
* (pr-request.mjs validatePrRequest) on those same bytes.
|
|
17
|
+
* 2. The repository comes from the runner's own config (the queue item's
|
|
18
|
+
* `nameWithOwner`), never from the request. A request naming another
|
|
19
|
+
* repository is refused, not redirected. The base is the configured
|
|
20
|
+
* `baseBranch`, else the remote's default branch, or one listed in
|
|
21
|
+
* `allowedBases`; any other base is refused.
|
|
22
|
+
* 3. The worktree belongs to that repository, sits on the requested branch,
|
|
23
|
+
* which is the branch the run recorded in its state, has no uncommitted
|
|
24
|
+
* change to a tracked file, and the branch is not protected (main,
|
|
25
|
+
* master, develop, release/*, the configured base).
|
|
26
|
+
* 4. Everything that touches the network runs from a fresh bare staging
|
|
27
|
+
* repository with a git config the runner writes (no system config, a
|
|
28
|
+
* global config holding only the operator's credential helpers, hooks
|
|
29
|
+
* off). The branch is fetched into it from the worktree; the base is
|
|
30
|
+
* fetched into it from the resolved remote. The worktree's own config
|
|
31
|
+
* never sees a URL, a proxy or a credential.
|
|
32
|
+
* 5. The branch is strictly ahead of the base. A branch that already exists
|
|
33
|
+
* on the remote is refused unless this run pushed it and its tip is an
|
|
34
|
+
* ancestor of the run's HEAD.
|
|
35
|
+
* 6. The deterministic gates: the gate ledger for every commit's parent, the
|
|
36
|
+
* test log it names (a missing log is a refusal), the pre-commit-check.sh
|
|
37
|
+
* detectors over each of the branch's commits, and the outbound gate on the title
|
|
38
|
+
* and body. The ledger is written by the run and so is only a consistency
|
|
39
|
+
* check; the evidence is step 7.
|
|
40
|
+
* 7. Independent verification: the stack adapter's build and test commands,
|
|
41
|
+
* resolved from the base commit's manifests (a range that changes the
|
|
42
|
+
* commands, the package scripts they run or the package manager is
|
|
43
|
+
* refused), run here in the worktree with every token-shaped variable
|
|
44
|
+
* removed from the environment and a bounded timeout, then
|
|
45
|
+
* evidence-gate.mjs --stack and test-summary.mjs on that fresh output. A
|
|
46
|
+
* failure, zero tests, or a stack with no commands (unless
|
|
47
|
+
* `verify.allowUnverifiedStack`) refuses. The operator's credential
|
|
48
|
+
* settings are snapshotted before this step and the push uses the
|
|
49
|
+
* snapshot; a user git config that changed while it ran refuses.
|
|
50
|
+
*
|
|
51
|
+
* This step executes code the run wrote, as the runner's user. Removing
|
|
52
|
+
* variables keeps tokens out of its environment, not out of its reach:
|
|
53
|
+
* it can read Keychain items the user's own tools read without a prompt,
|
|
54
|
+
* run `gh auth token`, and read the user's files. Only running the runner
|
|
55
|
+
* as a separate, non-admin macOS user that holds nothing else bounds that
|
|
56
|
+
* (multi-agent-refs/features/unattended-security.md).
|
|
57
|
+
* 8. Push from the staging repository with a URL resolved from the config
|
|
58
|
+
* (`pushUrl`) or `gh repo view`: no force, explicit refspec
|
|
59
|
+
* HEAD:refs/heads/<branch>.
|
|
60
|
+
* 9. GitHub: `gh pr create --draft` with the body in a file. Bitbucket has no
|
|
61
|
+
* draft PR, so the branch is pushed, a summary is written beside the
|
|
62
|
+
* request, and the outcome is `pushed-awaiting-pr`. Any other host is
|
|
63
|
+
* refused.
|
|
64
|
+
*
|
|
65
|
+
* Every spawn that writes outward runs with MULTI_AGENT_UNATTENDED and
|
|
66
|
+
* MULTI_AGENT_SESSION_ID removed from its environment: the runner is not the
|
|
67
|
+
* agent, and these are processes it starts, not tool calls a hook inspects.
|
|
68
|
+
* GIT_TERMINAL_PROMPT=0 makes a missing credential a failure instead of a
|
|
69
|
+
* prompt nobody answers.
|
|
70
|
+
*
|
|
71
|
+
* A refusal at any step writes nothing outward, parks the run through
|
|
72
|
+
* gate-ledger.mjs park (`verificationFailed.gate` = `publish/<gate>`) and
|
|
73
|
+
* records the verdict at prVerdictPath (mode 0600). Findings name a rule and a
|
|
74
|
+
* file, never a matched value.
|
|
75
|
+
*
|
|
76
|
+
* Usage (a person re-running a parked publish after fixing its cause):
|
|
77
|
+
* autopilot-publish.mjs --session <uuid> --state <agent-state.json> --repo <owner/name>
|
|
78
|
+
*
|
|
79
|
+
* Output: the verdict as JSON on stdout. Exit: 0 published, 1 refused or
|
|
80
|
+
* failed, 2 usage, 3 nothing to publish.
|
|
81
|
+
*
|
|
82
|
+
* @module pipeline/scripts/autopilot-publish
|
|
83
|
+
*/
|
|
84
|
+
|
|
85
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
86
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
87
|
+
import {
|
|
88
|
+
chmodSync,
|
|
89
|
+
closeSync,
|
|
90
|
+
existsSync,
|
|
91
|
+
lstatSync,
|
|
92
|
+
mkdirSync,
|
|
93
|
+
mkdtempSync,
|
|
94
|
+
openSync,
|
|
95
|
+
readdirSync,
|
|
96
|
+
readFileSync,
|
|
97
|
+
readlinkSync,
|
|
98
|
+
realpathSync,
|
|
99
|
+
renameSync,
|
|
100
|
+
rmSync,
|
|
101
|
+
symlinkSync,
|
|
102
|
+
writeFileSync,
|
|
103
|
+
} from "node:fs";
|
|
104
|
+
import { tmpdir } from "node:os";
|
|
105
|
+
import { dirname, isAbsolute, join } from "node:path";
|
|
106
|
+
|
|
107
|
+
import { invokedDirectly } from "../lib/invoked-directly.mjs";
|
|
108
|
+
import { scan } from "../lib/outbound-gate.mjs";
|
|
109
|
+
import { prRequestPath, prRequestsDir, prVerdictPath } from "../lib/pr-request-location.mjs";
|
|
110
|
+
import { readRegularFile, readRegularJson } from "../lib/regular-file.mjs";
|
|
111
|
+
import { OUTCOME } from "./_autopilot-outcomes.mjs";
|
|
112
|
+
import { commandFor, getAdapter, loadAdapters, resolveStack } from "./_stack-adapter.mjs";
|
|
113
|
+
import { checkCommit, park, updateState } from "./gate-ledger.mjs";
|
|
114
|
+
import { LOCKFILES } from "./package-manager.mjs";
|
|
115
|
+
import { PROTECTED_BRANCHES, validatePrRequest } from "./pr-request.mjs";
|
|
116
|
+
import { summarize } from "./test-summary.mjs";
|
|
117
|
+
|
|
118
|
+
const HERE = import.meta.dirname;
|
|
119
|
+
const PRE_COMMIT = join(HERE, "pre-commit-check.sh");
|
|
120
|
+
const EVIDENCE_GATE = join(HERE, "evidence-gate.mjs");
|
|
121
|
+
const PHASE_COMMIT = 4;
|
|
122
|
+
const GIT_TIMEOUT_MS = 60 * 1000;
|
|
123
|
+
const NET_TIMEOUT_MS = 5 * 60 * 1000;
|
|
124
|
+
// A valid request is at most a 256-character title and a 65536-character body.
|
|
125
|
+
const REQUEST_MAX_BYTES = 1024 * 1024;
|
|
126
|
+
const STATE_MAX_BYTES = 16 * 1024 * 1024;
|
|
127
|
+
const LOG_MAX_BYTES = 64 * 1024 * 1024;
|
|
128
|
+
const VERIFY_TIMEOUT_SEC = 30 * 60;
|
|
129
|
+
export const SUMMARY_SUFFIX = ".summary.md";
|
|
130
|
+
export const HOSTS = ["github", "bitbucket"];
|
|
131
|
+
|
|
132
|
+
/** The environment of every outward spawn: not the agent's, never prompting. */
|
|
133
|
+
export function outwardEnv(env = process.env) {
|
|
134
|
+
const out = { ...env, GIT_TERMINAL_PROMPT: "0" };
|
|
135
|
+
for (const k of [
|
|
136
|
+
"MULTI_AGENT_UNATTENDED",
|
|
137
|
+
"MULTI_AGENT_SESSION_ID",
|
|
138
|
+
"GIT_DIR",
|
|
139
|
+
"GIT_WORK_TREE",
|
|
140
|
+
"GIT_INDEX_FILE",
|
|
141
|
+
"GIT_CONFIG_PARAMETERS",
|
|
142
|
+
"GIT_CONFIG_COUNT",
|
|
143
|
+
]) {
|
|
144
|
+
delete out[k];
|
|
145
|
+
}
|
|
146
|
+
return out;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const SECRET_NAME = /(?:^|_)(?:TOKEN|KEY|SECRET|PASSWORD|PASSWD|CREDENTIALS?)$/i;
|
|
150
|
+
const SECRET_NAMES = new Set([
|
|
151
|
+
"GH_TOKEN",
|
|
152
|
+
"GITHUB_TOKEN",
|
|
153
|
+
"SSH_AUTH_SOCK",
|
|
154
|
+
"GIT_ASKPASS",
|
|
155
|
+
"SSH_ASKPASS",
|
|
156
|
+
]);
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* The environment the run's own build and tests execute in: no token-shaped
|
|
160
|
+
* variable, no ssh agent socket and no askpass helper. This narrows what the
|
|
161
|
+
* process is handed; it does not take away what the runner's user can reach.
|
|
162
|
+
* The code still runs as that user, so a Keychain item readable without a
|
|
163
|
+
* prompt, `gh auth token` and every file the user can read stay available to
|
|
164
|
+
* it. A separate macOS user for the runner is what bounds that.
|
|
165
|
+
*/
|
|
166
|
+
export function verifyEnv(env = process.env) {
|
|
167
|
+
const out = outwardEnv(env);
|
|
168
|
+
for (const k of Object.keys(out)) {
|
|
169
|
+
if (SECRET_NAMES.has(k) || SECRET_NAME.test(k)) delete out[k];
|
|
170
|
+
}
|
|
171
|
+
return out;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* May the run's work land on this branch? Never a long-lived branch, and never
|
|
176
|
+
* a base the config names for the repository (`baseBranch`, `allowedBases`).
|
|
177
|
+
*
|
|
178
|
+
* @param {string} branch
|
|
179
|
+
* @param {{baseBranch?: string, allowedBases?: string[]}} repoCfg
|
|
180
|
+
*/
|
|
181
|
+
export function protectedBranch(branch, repoCfg = {}) {
|
|
182
|
+
if (PROTECTED_BRANCHES.includes(branch)) return true;
|
|
183
|
+
if (branch === "release" || branch.startsWith("release/")) return true;
|
|
184
|
+
if (Array.isArray(repoCfg.allowedBases) && repoCfg.allowedBases.includes(branch)) return true;
|
|
185
|
+
return Boolean(repoCfg.baseBranch) && branch === repoCfg.baseBranch;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function realOr(p) {
|
|
189
|
+
try {
|
|
190
|
+
return realpathSync(p);
|
|
191
|
+
} catch {
|
|
192
|
+
return p;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function git(wt, args, env, timeout = GIT_TIMEOUT_MS) {
|
|
197
|
+
return spawnSync("git", ["-C", wt, ...args], {
|
|
198
|
+
encoding: "utf8",
|
|
199
|
+
env,
|
|
200
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
201
|
+
timeout,
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function gitOut(wt, args, env) {
|
|
206
|
+
const r = git(wt, args, env);
|
|
207
|
+
return r.status === 0 ? r.stdout.trim() : null;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function writePrivate(path, text) {
|
|
211
|
+
const dir = dirname(path);
|
|
212
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
213
|
+
chmodSync(dir, 0o700);
|
|
214
|
+
const tmp = `${path}.${process.pid}.tmp`;
|
|
215
|
+
writeFileSync(tmp, text, { mode: 0o600 });
|
|
216
|
+
chmodSync(tmp, 0o600);
|
|
217
|
+
renameSync(tmp, path);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
class Refusal extends Error {
|
|
221
|
+
constructor(gate, reason, extra = {}) {
|
|
222
|
+
super(reason);
|
|
223
|
+
this.gate = gate;
|
|
224
|
+
this.extra = extra;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** First line of a process's stderr, bounded, for a verdict a person reads. */
|
|
229
|
+
function why(r) {
|
|
230
|
+
const text = `${r.stderr || ""}${r.error ? ` ${r.error.message}` : ""}`.trim();
|
|
231
|
+
return text.split("\n").filter(Boolean).slice(-3).join(" | ").slice(0, 400) || `exit ${r.status}`;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* The staging repository's global git config: the operator's credential
|
|
236
|
+
* settings (`credential.*` from the system and global scopes, read outside
|
|
237
|
+
* any repository), and nothing else. No URL rewrite, proxy, TLS switch, extra
|
|
238
|
+
* header, ssh command or include reaches the push, from the worktree or from
|
|
239
|
+
* anywhere else.
|
|
240
|
+
*
|
|
241
|
+
* @returns {string} gitconfig text
|
|
242
|
+
*/
|
|
243
|
+
export function credentialConfig(env = process.env) {
|
|
244
|
+
const base = outwardEnv(env);
|
|
245
|
+
const cwd = tmpdir();
|
|
246
|
+
const r = spawnSync(
|
|
247
|
+
"git",
|
|
248
|
+
["config", "--null", "--show-scope", "--get-regexp", "^credential\\."],
|
|
249
|
+
{
|
|
250
|
+
cwd,
|
|
251
|
+
encoding: "utf8",
|
|
252
|
+
env: base,
|
|
253
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
254
|
+
timeout: GIT_TIMEOUT_MS,
|
|
255
|
+
},
|
|
256
|
+
);
|
|
257
|
+
const dir = mkdtempSync(join(tmpdir(), "ap-cred-"));
|
|
258
|
+
const file = join(dir, "gitconfig");
|
|
259
|
+
try {
|
|
260
|
+
writeFileSync(file, "", { mode: 0o600 });
|
|
261
|
+
const fields = r.status === 0 ? r.stdout.split("\0") : [];
|
|
262
|
+
for (let i = 0; i + 1 < fields.length; i += 2) {
|
|
263
|
+
const scope = fields[i];
|
|
264
|
+
const rec = fields[i + 1];
|
|
265
|
+
if (!["system", "global", "unknown"].includes(scope)) continue;
|
|
266
|
+
const nl = rec.indexOf("\n");
|
|
267
|
+
const key = nl === -1 ? rec : rec.slice(0, nl);
|
|
268
|
+
const value = nl === -1 ? "" : rec.slice(nl + 1);
|
|
269
|
+
if (!/^credential\./i.test(key)) continue;
|
|
270
|
+
spawnSync("git", ["config", "--file", file, "--add", key, value], {
|
|
271
|
+
cwd,
|
|
272
|
+
env: base,
|
|
273
|
+
stdio: "ignore",
|
|
274
|
+
timeout: GIT_TIMEOUT_MS,
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
return readRegularFile(file);
|
|
278
|
+
} finally {
|
|
279
|
+
rmSync(dir, { recursive: true, force: true });
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* The user-level git config files a credential helper can be named in, by
|
|
285
|
+
* path, with a digest of each (absent is recorded as absent). Compared before
|
|
286
|
+
* and after the run's build and tests: a change means code the run shipped
|
|
287
|
+
* edited the operator's git config, and nothing is pushed with it.
|
|
288
|
+
*
|
|
289
|
+
* @returns {Record<string, string>}
|
|
290
|
+
*/
|
|
291
|
+
export function gitConfigFingerprint(env = process.env) {
|
|
292
|
+
const home = env.HOME || "";
|
|
293
|
+
const xdg = env.XDG_CONFIG_HOME ? env.XDG_CONFIG_HOME : join(home, ".config");
|
|
294
|
+
const paths = new Set([join(home, ".gitconfig"), join(xdg, "git", "config")]);
|
|
295
|
+
if (env.GIT_CONFIG_GLOBAL) paths.add(env.GIT_CONFIG_GLOBAL);
|
|
296
|
+
const out = {};
|
|
297
|
+
for (const p of paths) {
|
|
298
|
+
let digest;
|
|
299
|
+
try {
|
|
300
|
+
digest = `${realOr(p)}:${createHash("sha256")
|
|
301
|
+
.update(readRegularFile(p, { maxBytes: STATE_MAX_BYTES }))
|
|
302
|
+
.digest("hex")}`;
|
|
303
|
+
} catch (err) {
|
|
304
|
+
digest = err?.code === "missing" ? "absent" : `unreadable:${err?.code || "error"}`;
|
|
305
|
+
}
|
|
306
|
+
out[p] = digest;
|
|
307
|
+
}
|
|
308
|
+
return out;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
const HOST_WALK_MAX = 20000;
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* A digest per file of what the next run executes or is configured by: the
|
|
315
|
+
* installed pipeline (~/.claude/scripts, ~/.claude/lib), the Claude Code
|
|
316
|
+
* settings, the unattended profile, and the per-user launch agents. Taken
|
|
317
|
+
* before and after the run's build and tests; any difference is a refusal.
|
|
318
|
+
*
|
|
319
|
+
* @param {Record<string,string|undefined>} [env]
|
|
320
|
+
* @returns {Record<string,string>}
|
|
321
|
+
*/
|
|
322
|
+
export function hostFingerprint(env = process.env) {
|
|
323
|
+
const home = env.HOME || "";
|
|
324
|
+
const out = {};
|
|
325
|
+
const digest = (p) => {
|
|
326
|
+
try {
|
|
327
|
+
const st = lstatSync(p);
|
|
328
|
+
if (st.isSymbolicLink()) return `link:${readlinkSync(p)}`;
|
|
329
|
+
if (!st.isFile()) return `type:${st.mode & 0o170000}`;
|
|
330
|
+
return createHash("sha256")
|
|
331
|
+
.update(readFileSync(p))
|
|
332
|
+
.update(`:${st.mode & 0o7777}`)
|
|
333
|
+
.digest("hex");
|
|
334
|
+
} catch (err) {
|
|
335
|
+
return err?.code === "ENOENT" ? "absent" : `unreadable:${err?.code || "error"}`;
|
|
336
|
+
}
|
|
337
|
+
};
|
|
338
|
+
let seen = 0;
|
|
339
|
+
const walk = (dir) => {
|
|
340
|
+
let entries;
|
|
341
|
+
try {
|
|
342
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
343
|
+
} catch (err) {
|
|
344
|
+
out[dir] = err?.code === "ENOENT" ? "absent" : `unreadable:${err?.code || "error"}`;
|
|
345
|
+
return;
|
|
346
|
+
}
|
|
347
|
+
out[dir] = `dir:${entries
|
|
348
|
+
.map((e) => e.name)
|
|
349
|
+
.sort()
|
|
350
|
+
.join("/")}`;
|
|
351
|
+
for (const e of entries) {
|
|
352
|
+
if (++seen > HOST_WALK_MAX) {
|
|
353
|
+
out[`${dir}/...`] = "too-many-entries";
|
|
354
|
+
return;
|
|
355
|
+
}
|
|
356
|
+
const p = join(dir, e.name);
|
|
357
|
+
if (e.isDirectory()) walk(p);
|
|
358
|
+
else out[p] = digest(p);
|
|
359
|
+
}
|
|
360
|
+
};
|
|
361
|
+
const claude = join(home, ".claude");
|
|
362
|
+
for (const d of [
|
|
363
|
+
join(claude, "scripts"),
|
|
364
|
+
join(claude, "lib"),
|
|
365
|
+
join(home, "Library", "LaunchAgents"),
|
|
366
|
+
])
|
|
367
|
+
walk(d);
|
|
368
|
+
for (const f of ["settings.json", "settings.local.json", "multi-agent-unattended.settings.json"])
|
|
369
|
+
out[join(claude, f)] = digest(join(claude, f));
|
|
370
|
+
return out;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/**
|
|
374
|
+
* A fresh bare repository to push from, with a config the runner wrote. Every
|
|
375
|
+
* git process in it runs with GIT_CONFIG_NOSYSTEM, GIT_CONFIG_GLOBAL pointing
|
|
376
|
+
* at the credential config snapshot (credentialConfig()), hooks off and no
|
|
377
|
+
* terminal prompt.
|
|
378
|
+
*/
|
|
379
|
+
function stagingRepo(env, credText = credentialConfig(env)) {
|
|
380
|
+
const dir = mkdtempSync(join(tmpdir(), "ap-stage-"));
|
|
381
|
+
chmodSync(dir, 0o700);
|
|
382
|
+
const cfg = join(dir, "gitconfig");
|
|
383
|
+
writeFileSync(cfg, credText, { mode: 0o600 });
|
|
384
|
+
const bare = join(dir, "stage.git");
|
|
385
|
+
const stageEnv = {
|
|
386
|
+
...outwardEnv(env),
|
|
387
|
+
GIT_CONFIG_NOSYSTEM: "1",
|
|
388
|
+
GIT_CONFIG_GLOBAL: cfg,
|
|
389
|
+
GIT_TERMINAL_PROMPT: "0",
|
|
390
|
+
};
|
|
391
|
+
const r = spawnSync("git", ["init", "--bare", "--quiet", "--template=", bare], {
|
|
392
|
+
cwd: dir,
|
|
393
|
+
encoding: "utf8",
|
|
394
|
+
env: stageEnv,
|
|
395
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
396
|
+
timeout: GIT_TIMEOUT_MS,
|
|
397
|
+
});
|
|
398
|
+
const cleanup = () => rmSync(dir, { recursive: true, force: true });
|
|
399
|
+
if (r.status !== 0) {
|
|
400
|
+
cleanup();
|
|
401
|
+
throw new Refusal("push", `cannot create the staging repository: ${why(r)}`);
|
|
402
|
+
}
|
|
403
|
+
const run = (args, timeout = GIT_TIMEOUT_MS) =>
|
|
404
|
+
git(bare, ["-c", "core.hooksPath=/dev/null", ...args], stageEnv, timeout);
|
|
405
|
+
const out = (args) => {
|
|
406
|
+
const x = run(args);
|
|
407
|
+
return x.status === 0 ? x.stdout.trim() : null;
|
|
408
|
+
};
|
|
409
|
+
return { bare, run, out, cleanup };
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/**
|
|
413
|
+
* The worktree is still on `branch` at `headSha` with no change to a tracked
|
|
414
|
+
* file: the tree that is verified is the tree that is pushed.
|
|
415
|
+
*/
|
|
416
|
+
function assertUnmoved(wt, branch, headSha, env, gate) {
|
|
417
|
+
const at = gitOut(wt, ["rev-parse", "--verify", "--quiet", `refs/heads/${branch}^{commit}`], env);
|
|
418
|
+
const head = gitOut(wt, ["rev-parse", "--verify", "HEAD^{commit}"], env);
|
|
419
|
+
if (at !== headSha || head !== headSha) {
|
|
420
|
+
throw new Refusal(gate, `${branch} is no longer at ${headSha.slice(0, 12)} in the worktree`);
|
|
421
|
+
}
|
|
422
|
+
const dirty = git(
|
|
423
|
+
wt,
|
|
424
|
+
["-c", "core.fsmonitor=false", "status", "--porcelain", "--untracked-files=no"],
|
|
425
|
+
env,
|
|
426
|
+
);
|
|
427
|
+
if (dirty.status !== 0 || dirty.stdout.trim()) {
|
|
428
|
+
throw new Refusal(
|
|
429
|
+
gate,
|
|
430
|
+
"the worktree has uncommitted changes to tracked files; the tree verified would not be the tree pushed",
|
|
431
|
+
);
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/** The host a configured repository publishes to. */
|
|
436
|
+
export function resolveHost(repoCfg) {
|
|
437
|
+
const host = repoCfg.host ?? "github";
|
|
438
|
+
return HOSTS.includes(host) ? host : null;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
function resolvePushUrl(repo, repoCfg, host, env) {
|
|
442
|
+
if (typeof repoCfg.pushUrl === "string" && repoCfg.pushUrl.trim()) {
|
|
443
|
+
const url = repoCfg.pushUrl.trim();
|
|
444
|
+
if (outboundFindings({ pushUrl: url }).length) {
|
|
445
|
+
throw new Refusal(
|
|
446
|
+
"remote",
|
|
447
|
+
"pushUrl carries credentials; the credential helper supplies them",
|
|
448
|
+
);
|
|
449
|
+
}
|
|
450
|
+
return url;
|
|
451
|
+
}
|
|
452
|
+
if (host !== "github") {
|
|
453
|
+
throw new Refusal("remote", `${host} needs pushUrl in the runner's config for ${repo}`);
|
|
454
|
+
}
|
|
455
|
+
const r = spawnSync(
|
|
456
|
+
env.MA_AP_GH_BIN || "gh",
|
|
457
|
+
["repo", "view", repo, "--json", "url", "--jq", ".url"],
|
|
458
|
+
{
|
|
459
|
+
encoding: "utf8",
|
|
460
|
+
env: outwardEnv(env),
|
|
461
|
+
cwd: tmpdir(),
|
|
462
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
463
|
+
timeout: NET_TIMEOUT_MS,
|
|
464
|
+
},
|
|
465
|
+
);
|
|
466
|
+
const url = r.status === 0 ? r.stdout.trim().split("\n").pop() : "";
|
|
467
|
+
if (!url) throw new Refusal("remote", `gh repo view ${repo} gave no URL: ${why(r)}`);
|
|
468
|
+
return url;
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
/**
|
|
472
|
+
* The branch, copied from the worktree into the staging repository. The fetch
|
|
473
|
+
* runs in the staging repository, so only its config decides where it reads
|
|
474
|
+
* from; the worktree is the local path it names.
|
|
475
|
+
*/
|
|
476
|
+
function stageBranch(stage, wt, branch, headSha) {
|
|
477
|
+
const r = stage.run([
|
|
478
|
+
"fetch",
|
|
479
|
+
"--no-tags",
|
|
480
|
+
"--quiet",
|
|
481
|
+
wt,
|
|
482
|
+
`+refs/heads/${branch}:refs/heads/${branch}`,
|
|
483
|
+
]);
|
|
484
|
+
if (r.status !== 0) throw new Refusal("branch", `cannot stage ${branch}: ${why(r)}`);
|
|
485
|
+
const staged = stage.out(["rev-parse", "--verify", `refs/heads/${branch}^{commit}`]);
|
|
486
|
+
if (staged !== headSha) {
|
|
487
|
+
throw new Refusal("branch", `${branch} moved while it was staged`);
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
/** The branch the remote's HEAD names, or null when it names none. */
|
|
492
|
+
function remoteDefaultBranch(stage, url) {
|
|
493
|
+
const r = stage.run(["ls-remote", "--symref", url, "HEAD"], NET_TIMEOUT_MS);
|
|
494
|
+
if (r.status !== 0) throw new Refusal("base", `cannot read the remote's HEAD: ${why(r)}`);
|
|
495
|
+
const lines = r.stdout.split("\n");
|
|
496
|
+
const sym = lines.map((l) => /^ref: refs\/heads\/(\S+)\tHEAD$/.exec(l)?.[1]).find(Boolean);
|
|
497
|
+
if (!sym) return null;
|
|
498
|
+
return lines.some((l) => l.endsWith("\tHEAD") && !l.startsWith("ref: ")) ? sym : null;
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
/**
|
|
502
|
+
* The base a request may target: the configured `baseBranch`, else the
|
|
503
|
+
* remote's default branch, plus any branch the config lists in
|
|
504
|
+
* `allowedBases`. Anything else is refused, so a run cannot aim its PR at a
|
|
505
|
+
* release or deployment branch the operator did not choose.
|
|
506
|
+
*/
|
|
507
|
+
function checkBase(stage, url, base, repoCfg) {
|
|
508
|
+
const allowed = Array.isArray(repoCfg.allowedBases)
|
|
509
|
+
? repoCfg.allowedBases.filter((b) => typeof b === "string" && b)
|
|
510
|
+
: [];
|
|
511
|
+
if (allowed.includes(base)) return;
|
|
512
|
+
if (repoCfg.baseBranch) {
|
|
513
|
+
if (base === repoCfg.baseBranch) return;
|
|
514
|
+
throw new Refusal(
|
|
515
|
+
"base",
|
|
516
|
+
`the request targets ${base}; the config's base is ${repoCfg.baseBranch}`,
|
|
517
|
+
);
|
|
518
|
+
}
|
|
519
|
+
const def = remoteDefaultBranch(stage, url);
|
|
520
|
+
if (!def) {
|
|
521
|
+
throw new Refusal(
|
|
522
|
+
"base",
|
|
523
|
+
"no baseBranch is configured and the remote names no default branch; set baseBranch",
|
|
524
|
+
);
|
|
525
|
+
}
|
|
526
|
+
if (base !== def) {
|
|
527
|
+
throw new Refusal(
|
|
528
|
+
"base",
|
|
529
|
+
`the request targets ${base}; the remote's default branch is ${def} (allowedBases lists other permitted bases)`,
|
|
530
|
+
);
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
/** The base the PR would target, as the remote has it now. */
|
|
535
|
+
function fetchBase(stage, url, base) {
|
|
536
|
+
const r = stage.run(
|
|
537
|
+
["fetch", "--no-tags", "--quiet", url, `+refs/heads/${base}:refs/publish/base`],
|
|
538
|
+
NET_TIMEOUT_MS,
|
|
539
|
+
);
|
|
540
|
+
if (r.status !== 0)
|
|
541
|
+
throw new Refusal("base", `cannot fetch ${base} from the configured remote: ${why(r)}`);
|
|
542
|
+
const sha = stage.out(["rev-parse", "--verify", "refs/publish/base^{commit}"]);
|
|
543
|
+
if (!sha) throw new Refusal("base", `fetched ${base} but it does not name a commit`);
|
|
544
|
+
return sha;
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
/**
|
|
548
|
+
* A branch already on the remote is taken over only when this run pushed it
|
|
549
|
+
* (its previous verdict for this session says so) and its tip is an ancestor
|
|
550
|
+
* of the run's HEAD, so the push is a fast-forward of the run's own work.
|
|
551
|
+
*/
|
|
552
|
+
function checkRemoteBranch(stage, url, branch, headSha, prior) {
|
|
553
|
+
const r = stage.run(["ls-remote", "--heads", url, `refs/heads/${branch}`], NET_TIMEOUT_MS);
|
|
554
|
+
if (r.status !== 0) throw new Refusal("remote", `cannot list ${branch} on the remote: ${why(r)}`);
|
|
555
|
+
const line = r.stdout.split("\n").find((l) => l.endsWith(`\trefs/heads/${branch}`));
|
|
556
|
+
if (!line) return;
|
|
557
|
+
const tip = line.split("\t")[0];
|
|
558
|
+
const f = stage.run(
|
|
559
|
+
["fetch", "--no-tags", "--quiet", url, `+refs/heads/${branch}:refs/publish/remote`],
|
|
560
|
+
NET_TIMEOUT_MS,
|
|
561
|
+
);
|
|
562
|
+
if (f.status !== 0) throw new Refusal("remote", `cannot fetch ${branch}: ${why(f)}`);
|
|
563
|
+
if (stage.run(["merge-base", "--is-ancestor", tip, headSha]).status !== 0) {
|
|
564
|
+
throw new Refusal(
|
|
565
|
+
"remote-branch",
|
|
566
|
+
`${branch} exists on the remote at ${tip.slice(0, 12)}, which is not an ancestor of the run's HEAD`,
|
|
567
|
+
);
|
|
568
|
+
}
|
|
569
|
+
const ours = prior && prior.pushed === true && prior.branch === branch;
|
|
570
|
+
if (!ours) {
|
|
571
|
+
throw new Refusal(
|
|
572
|
+
"remote-branch",
|
|
573
|
+
`${branch} already exists on the remote and this run did not push it`,
|
|
574
|
+
);
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
/** Gate ledger: the commit hook's requirement, re-checked for every commit in the range. */
|
|
579
|
+
function checkLedger(state, commits, wt, env) {
|
|
580
|
+
for (const c of commits) {
|
|
581
|
+
const parent = gitOut(wt, ["rev-parse", "--verify", `${c}^1`], env);
|
|
582
|
+
if (!parent)
|
|
583
|
+
throw new Refusal("gate-ledger", `commit ${c.slice(0, 12)} has no parent to check`);
|
|
584
|
+
const r = checkCommit(state, parent);
|
|
585
|
+
if (!r.ok) {
|
|
586
|
+
const parts = [
|
|
587
|
+
...r.missing.map((g) => `missing ${g}`),
|
|
588
|
+
...r.failing.map((e) => `failing ${e.gate}`),
|
|
589
|
+
];
|
|
590
|
+
throw new Refusal(
|
|
591
|
+
"gate-ledger",
|
|
592
|
+
`commit ${c.slice(0, 12)} is not backed by the ledger for ${parent.slice(0, 12)}: ${parts.join(", ")}`,
|
|
593
|
+
);
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
/** The run's test log, as the latest test-summary ledger entry names it. */
|
|
599
|
+
function testLogOf(state) {
|
|
600
|
+
const entries = (Array.isArray(state?.gates) ? state.gates : []).filter(
|
|
601
|
+
(e) => e && e.gate === "test-summary",
|
|
602
|
+
);
|
|
603
|
+
const last = entries.at(-1);
|
|
604
|
+
const src = last?.detail?.source;
|
|
605
|
+
return typeof src === "string" && src && src !== "stdin"
|
|
606
|
+
? { path: src, parser: last.detail.parser || null }
|
|
607
|
+
: null;
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
/**
|
|
611
|
+
* The log the ledger's test-summary entry names, read as a bounded regular
|
|
612
|
+
* file inside the worktree or the run's own directory. No log is a refusal:
|
|
613
|
+
* a ledger without one has nothing to be consistent with.
|
|
614
|
+
*/
|
|
615
|
+
function checkTestLog(state, wt, statePath) {
|
|
616
|
+
const log = testLogOf(state);
|
|
617
|
+
if (!log) {
|
|
618
|
+
throw new Refusal("test-log", "the gate ledger names no test log for the run's tests");
|
|
619
|
+
}
|
|
620
|
+
const path = isAbsolute(log.path) ? log.path : join(wt, log.path);
|
|
621
|
+
let text;
|
|
622
|
+
try {
|
|
623
|
+
text = readRegularFile(path, { maxBytes: LOG_MAX_BYTES, within: [wt, dirname(statePath)] });
|
|
624
|
+
} catch (err) {
|
|
625
|
+
throw new Refusal("test-log", `the test log the ledger names is refused: ${err.message}`);
|
|
626
|
+
}
|
|
627
|
+
let r;
|
|
628
|
+
try {
|
|
629
|
+
r = summarize(text, { parsers: log.parser ? [log.parser] : undefined, source: path });
|
|
630
|
+
} catch {
|
|
631
|
+
r = summarize(text, { source: path });
|
|
632
|
+
}
|
|
633
|
+
if (r.status === "failed" || r.status === "no-tests") {
|
|
634
|
+
throw new Refusal(
|
|
635
|
+
"test-summary",
|
|
636
|
+
`the test log says ${r.status} (executed ${r.executed ?? "?"}, failed ${r.failed ?? "?"})`,
|
|
637
|
+
);
|
|
638
|
+
}
|
|
639
|
+
return r.status;
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
function checkSecrets(wt, range, env) {
|
|
643
|
+
if (!existsSync(PRE_COMMIT))
|
|
644
|
+
throw new Refusal("secret-scan", "pre-commit-check.sh is not installed beside this script");
|
|
645
|
+
const r = spawnSync("bash", [PRE_COMMIT, "--scan-range", range], {
|
|
646
|
+
cwd: wt,
|
|
647
|
+
encoding: "utf8",
|
|
648
|
+
env: verifyEnv(env),
|
|
649
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
650
|
+
timeout: GIT_TIMEOUT_MS * 5,
|
|
651
|
+
});
|
|
652
|
+
if (r.status === 0) return;
|
|
653
|
+
const blocked = (r.stderr || "")
|
|
654
|
+
.split("\n")
|
|
655
|
+
.filter((l) => l.startsWith("BLOCKED:"))
|
|
656
|
+
.slice(0, 5)
|
|
657
|
+
.join(" | ");
|
|
658
|
+
throw new Refusal(
|
|
659
|
+
"secret-scan",
|
|
660
|
+
blocked || `pre-commit-check.sh --scan-range exited ${r.status}`,
|
|
661
|
+
);
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
const RUN_MARKER = "MA_AP_VERIFY_RUN";
|
|
665
|
+
|
|
666
|
+
/**
|
|
667
|
+
* Processes carrying this run's marker in their environment. A child that
|
|
668
|
+
* calls setsid or double-forks leaves the process group, but it inherits the
|
|
669
|
+
* environment unless it clears it, and `ps -E` prints it. macOS only, like the
|
|
670
|
+
* rest of the pipeline.
|
|
671
|
+
*/
|
|
672
|
+
function markedPids(marker) {
|
|
673
|
+
const needle = `${RUN_MARKER}=${marker}`;
|
|
674
|
+
const pids = [];
|
|
675
|
+
const r = spawnSync("ps", ["-axwwE", "-o", "pid=,command="], {
|
|
676
|
+
encoding: "utf8",
|
|
677
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
678
|
+
timeout: GIT_TIMEOUT_MS,
|
|
679
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
680
|
+
});
|
|
681
|
+
for (const line of (r.stdout || "").split("\n")) {
|
|
682
|
+
const m = /^\s*(\d+)\s/.exec(line);
|
|
683
|
+
if (m && line.split(/\s/).includes(needle)) pids.push(Number(m[1]));
|
|
684
|
+
}
|
|
685
|
+
return pids;
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
function killMarked(marker) {
|
|
689
|
+
for (let round = 0; round < 3; round++) {
|
|
690
|
+
const pids = markedPids(marker).filter((p) => p !== process.pid);
|
|
691
|
+
if (!pids.length) return;
|
|
692
|
+
for (const pid of pids) {
|
|
693
|
+
try {
|
|
694
|
+
process.kill(-pid, "SIGKILL");
|
|
695
|
+
} catch {
|
|
696
|
+
// Not a group leader.
|
|
697
|
+
}
|
|
698
|
+
try {
|
|
699
|
+
process.kill(pid, "SIGKILL");
|
|
700
|
+
} catch {
|
|
701
|
+
// Already gone.
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
/**
|
|
708
|
+
* Run one command in its own process group, output to a file, and kill the
|
|
709
|
+
* whole group at the deadline and again when it exits. Every process started
|
|
710
|
+
* for the command carries a per-run marker in its environment, and any process
|
|
711
|
+
* still carrying it is killed too, so a child that left the group with setsid
|
|
712
|
+
* or a double fork does not outlive verification. A process that also clears
|
|
713
|
+
* its environment is out of reach of this check; the run's own code executes
|
|
714
|
+
* as this user, and that is the limit named in unattended-security.md.
|
|
715
|
+
*
|
|
716
|
+
* @returns {Promise<{status: number|null, signal: string|null, timedOut: boolean, error?: Error}>}
|
|
717
|
+
*/
|
|
718
|
+
function runBounded(command, { cwd, env, logPath, timeoutMs }) {
|
|
719
|
+
return new Promise((resolve) => {
|
|
720
|
+
const fd = openSync(logPath, "w", 0o600);
|
|
721
|
+
const marker = randomBytes(12).toString("hex");
|
|
722
|
+
let child;
|
|
723
|
+
try {
|
|
724
|
+
child = spawn("bash", ["-c", command], {
|
|
725
|
+
cwd,
|
|
726
|
+
env: { ...env, [RUN_MARKER]: marker },
|
|
727
|
+
stdio: ["ignore", fd, fd],
|
|
728
|
+
detached: true,
|
|
729
|
+
});
|
|
730
|
+
} catch (error) {
|
|
731
|
+
closeSync(fd);
|
|
732
|
+
resolve({ status: null, signal: null, timedOut: false, error });
|
|
733
|
+
return;
|
|
734
|
+
}
|
|
735
|
+
const killGroup = () => {
|
|
736
|
+
try {
|
|
737
|
+
process.kill(-child.pid, "SIGKILL");
|
|
738
|
+
} catch {
|
|
739
|
+
// The group is already gone.
|
|
740
|
+
}
|
|
741
|
+
killMarked(marker);
|
|
742
|
+
};
|
|
743
|
+
let timedOut = false;
|
|
744
|
+
const timer = setTimeout(() => {
|
|
745
|
+
timedOut = true;
|
|
746
|
+
killGroup();
|
|
747
|
+
}, timeoutMs);
|
|
748
|
+
const done = (result) => {
|
|
749
|
+
clearTimeout(timer);
|
|
750
|
+
killGroup();
|
|
751
|
+
closeSync(fd);
|
|
752
|
+
resolve({ ...result, timedOut });
|
|
753
|
+
};
|
|
754
|
+
child.on("error", (error) => done({ status: null, signal: null, error }));
|
|
755
|
+
child.on("exit", (status, signal) => done({ status, signal }));
|
|
756
|
+
});
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
const VERIFY_KINDS = ["build", "test"];
|
|
760
|
+
const PINNED_SCRIPTS = VERIFY_KINDS.flatMap((k) => [`pre${k}`, k, `post${k}`]);
|
|
761
|
+
// Files that change how a build or test command runs without changing the
|
|
762
|
+
// command line: the package manager's config (script-shell, registry), the
|
|
763
|
+
// test runner's config and plugins, and the Gradle wrapper and settings. A
|
|
764
|
+
// range that adds or edits one, at any depth, chooses how it is verified.
|
|
765
|
+
const PINNED_CONFIG = new Set([
|
|
766
|
+
".npmrc",
|
|
767
|
+
".yarnrc",
|
|
768
|
+
".yarnrc.yml",
|
|
769
|
+
"pytest.ini",
|
|
770
|
+
"conftest.py",
|
|
771
|
+
"tox.ini",
|
|
772
|
+
"gradle.properties",
|
|
773
|
+
"gradle-wrapper.properties",
|
|
774
|
+
"settings.gradle",
|
|
775
|
+
"settings.gradle.kts",
|
|
776
|
+
]);
|
|
777
|
+
const MANIFEST_MAX_BYTES = 2 * 1024 * 1024;
|
|
778
|
+
|
|
779
|
+
/**
|
|
780
|
+
* What decides the verification commands in one directory: the command lines
|
|
781
|
+
* the stack adapter resolves, the package scripts they run (with their pre and
|
|
782
|
+
* post hooks) and the declared package manager.
|
|
783
|
+
*/
|
|
784
|
+
function verificationPins(dir, stack, doc, wt) {
|
|
785
|
+
const commands = {};
|
|
786
|
+
for (const kind of VERIFY_KINDS) {
|
|
787
|
+
commands[kind] = commandFor(stack, kind, { dir, doc, vars: { worktreePath: wt } }).command;
|
|
788
|
+
}
|
|
789
|
+
let pkg = null;
|
|
790
|
+
if (existsSync(join(dir, "package.json"))) {
|
|
791
|
+
try {
|
|
792
|
+
pkg = readRegularJson(join(dir, "package.json"), null, { maxBytes: MANIFEST_MAX_BYTES });
|
|
793
|
+
} catch (err) {
|
|
794
|
+
throw new Refusal("verify-scripts", `package.json is refused unread: ${err.message}`);
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
const declared = pkg && typeof pkg.scripts === "object" && pkg.scripts ? pkg.scripts : {};
|
|
798
|
+
const scripts = {};
|
|
799
|
+
for (const name of PINNED_SCRIPTS) {
|
|
800
|
+
if (Object.hasOwn(declared, name)) scripts[name] = declared[name];
|
|
801
|
+
}
|
|
802
|
+
return { commands, scripts, packageManager: pkg?.packageManager ?? null };
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
/**
|
|
806
|
+
* The files command resolution reads, copied out of the base commit into a
|
|
807
|
+
* private directory, so the base's declaration is read and nothing the range
|
|
808
|
+
* wrote.
|
|
809
|
+
*/
|
|
810
|
+
function materializeBaseManifests(wt, baseSha, doc, dir, env) {
|
|
811
|
+
const names = new Set(["package.json", ...LOCKFILES.map(([file]) => file)]);
|
|
812
|
+
for (const a of Object.values(doc.adapters)) {
|
|
813
|
+
for (const set of a.commands || []) for (const n of set.when || []) names.add(n);
|
|
814
|
+
}
|
|
815
|
+
for (const name of names) {
|
|
816
|
+
const r = spawnSync("git", ["-C", wt, "cat-file", "blob", `${baseSha}:${name}`], {
|
|
817
|
+
env,
|
|
818
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
819
|
+
timeout: GIT_TIMEOUT_MS,
|
|
820
|
+
maxBuffer: MANIFEST_MAX_BYTES,
|
|
821
|
+
});
|
|
822
|
+
if (r.status === 0) writeFileSync(join(dir, name), r.stdout, { mode: 0o600 });
|
|
823
|
+
else if (r.error?.code === "ENOBUFS") writeFileSync(join(dir, name), "", { mode: 0o600 });
|
|
824
|
+
}
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
/** The [tool:pytest] section of a setup.cfg, or "" when it has none. */
|
|
828
|
+
export function pytestSection(text) {
|
|
829
|
+
const m = /^\[tool:pytest\][^\n]*\n([\s\S]*?)(?=^\[|(?![\s\S]))/m.exec(String(text || ""));
|
|
830
|
+
return m ? m[1].trim() : "";
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
/**
|
|
834
|
+
* The PINNED_CONFIG files the range adds, edits or removes, at any depth, and
|
|
835
|
+
* a setup.cfg whose [tool:pytest] section changed.
|
|
836
|
+
*/
|
|
837
|
+
function changedConfig(wt, baseSha, env) {
|
|
838
|
+
const r = git(
|
|
839
|
+
wt,
|
|
840
|
+
["-c", "core.quotePath=false", "diff", "--name-only", "-z", baseSha, "HEAD"],
|
|
841
|
+
env,
|
|
842
|
+
);
|
|
843
|
+
if (r.status !== 0) return ["the range's changed files (git diff refused)"];
|
|
844
|
+
const out = [];
|
|
845
|
+
for (const name of r.stdout.split("\0").filter(Boolean)) {
|
|
846
|
+
const base = name.split("/").pop();
|
|
847
|
+
if (PINNED_CONFIG.has(base)) out.push(name);
|
|
848
|
+
else if (base === "setup.cfg") {
|
|
849
|
+
const at = (ref) =>
|
|
850
|
+
spawnSync("git", ["-C", wt, "cat-file", "blob", `${ref}:${name}`], {
|
|
851
|
+
env,
|
|
852
|
+
encoding: "utf8",
|
|
853
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
854
|
+
timeout: GIT_TIMEOUT_MS,
|
|
855
|
+
maxBuffer: MANIFEST_MAX_BYTES,
|
|
856
|
+
}).stdout;
|
|
857
|
+
if (pytestSection(at(baseSha)) !== pytestSection(at("HEAD")))
|
|
858
|
+
out.push(`${name} [tool:pytest]`);
|
|
859
|
+
}
|
|
860
|
+
}
|
|
861
|
+
return out;
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
/**
|
|
865
|
+
* The verification commands, pinned to the base commit. The commands come from
|
|
866
|
+
* the base's manifests; a range that changes a command line, a package script
|
|
867
|
+
* the commands run, its pre/post hook, the declared package manager, or a
|
|
868
|
+
* repository executable a command line starts with is refused, because the run
|
|
869
|
+
* would otherwise choose how it is verified.
|
|
870
|
+
*
|
|
871
|
+
* @returns {{kind: string, command: string}[]}
|
|
872
|
+
*/
|
|
873
|
+
function pinnedCommands(wt, baseSha, stack, doc, headPins, env) {
|
|
874
|
+
const dir = mkdtempSync(join(tmpdir(), "ap-base-"));
|
|
875
|
+
chmodSync(dir, 0o700);
|
|
876
|
+
try {
|
|
877
|
+
materializeBaseManifests(wt, baseSha, doc, dir, env);
|
|
878
|
+
let basePins;
|
|
879
|
+
try {
|
|
880
|
+
basePins = verificationPins(dir, stack, doc, wt);
|
|
881
|
+
} catch (err) {
|
|
882
|
+
if (err instanceof Refusal) throw err;
|
|
883
|
+
throw new Refusal(
|
|
884
|
+
"verify-scripts",
|
|
885
|
+
`the base commit declares no ${stack} build and test command (${err.message}); the range would define its own verification`,
|
|
886
|
+
);
|
|
887
|
+
}
|
|
888
|
+
const changed = [];
|
|
889
|
+
for (const kind of VERIFY_KINDS) {
|
|
890
|
+
if (basePins.commands[kind] !== headPins.commands[kind]) changed.push(`${kind} command`);
|
|
891
|
+
}
|
|
892
|
+
for (const name of PINNED_SCRIPTS) {
|
|
893
|
+
if (basePins.scripts[name] !== headPins.scripts[name]) changed.push(`${name} script`);
|
|
894
|
+
}
|
|
895
|
+
if (basePins.packageManager !== headPins.packageManager) changed.push("packageManager");
|
|
896
|
+
for (const kind of VERIFY_KINDS) {
|
|
897
|
+
const exe = /^\.\/([^\s;&|<>]+)/.exec(basePins.commands[kind])?.[1];
|
|
898
|
+
if (!exe) continue;
|
|
899
|
+
const d = git(wt, ["diff", "--quiet", baseSha, "HEAD", "--", exe], env);
|
|
900
|
+
if (d.status !== 0) changed.push(`./${exe}`);
|
|
901
|
+
}
|
|
902
|
+
changed.push(...changedConfig(wt, baseSha, env));
|
|
903
|
+
if (changed.length) {
|
|
904
|
+
throw new Refusal(
|
|
905
|
+
"verify-scripts",
|
|
906
|
+
`the range changes what verification runs (${[...new Set(changed)].join(", ")}); verification uses the base commit's declaration`,
|
|
907
|
+
);
|
|
908
|
+
}
|
|
909
|
+
return VERIFY_KINDS.map((kind) => ({ kind, command: basePins.commands[kind] }));
|
|
910
|
+
} finally {
|
|
911
|
+
rmSync(dir, { recursive: true, force: true });
|
|
912
|
+
}
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
// Dependency directories a clean checkout does not have and the stack's build
|
|
916
|
+
// needs: the run's copies are linked in. What the run put there is outside
|
|
917
|
+
// what this step can vouch for; unattended-security.md states it.
|
|
918
|
+
const DEPENDENCY_DIRS = ["node_modules", "Pods", ".venv"];
|
|
919
|
+
|
|
920
|
+
/**
|
|
921
|
+
* A detached, clean checkout of `headSha` in a private directory: nothing
|
|
922
|
+
* untracked from the run's worktree (a .npmrc, a conftest.py, a config that
|
|
923
|
+
* shadows a committed one) reaches the build or the tests. Hooks are off for
|
|
924
|
+
* the checkout. The dependency directories the run installed are linked in.
|
|
925
|
+
*
|
|
926
|
+
* @returns {{dir: string, remove: () => void}}
|
|
927
|
+
*/
|
|
928
|
+
function cleanCheckout(wt, headSha, env) {
|
|
929
|
+
const parent = mkdtempSync(join(tmpdir(), "ap-checkout-"));
|
|
930
|
+
chmodSync(parent, 0o700);
|
|
931
|
+
const dir = join(parent, "checkout");
|
|
932
|
+
const remove = () => {
|
|
933
|
+
git(wt, ["worktree", "remove", "--force", dir], env);
|
|
934
|
+
git(wt, ["worktree", "prune"], env);
|
|
935
|
+
rmSync(parent, { recursive: true, force: true });
|
|
936
|
+
};
|
|
937
|
+
const r = git(
|
|
938
|
+
wt,
|
|
939
|
+
["-c", "core.hooksPath=/dev/null", "worktree", "add", "--detach", "--quiet", dir, headSha],
|
|
940
|
+
env,
|
|
941
|
+
);
|
|
942
|
+
if (r.status !== 0) {
|
|
943
|
+
remove();
|
|
944
|
+
throw new Refusal(
|
|
945
|
+
"verify-checkout",
|
|
946
|
+
`a clean checkout of ${headSha.slice(0, 12)} failed: ${why(r)}`,
|
|
947
|
+
);
|
|
948
|
+
}
|
|
949
|
+
for (const name of DEPENDENCY_DIRS) {
|
|
950
|
+
const src = join(wt, name);
|
|
951
|
+
if (existsSync(src) && !existsSync(join(dir, name))) symlinkSync(src, join(dir, name));
|
|
952
|
+
}
|
|
953
|
+
return { dir, remove };
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
/**
|
|
957
|
+
* Build and test a clean checkout of the run's commit with the stack adapter's
|
|
958
|
+
* own commands, pinned to the base commit's manifests, then judge the fresh
|
|
959
|
+
* output with evidence-gate.mjs --stack and test-summary.mjs. The run's ledger
|
|
960
|
+
* is not consulted here. A build or test that rewrites a tracked file of the
|
|
961
|
+
* checkout is refused: what was tested would not be what is pushed.
|
|
962
|
+
*
|
|
963
|
+
* @returns {Promise<{stack: string, verified: boolean, tests?: object, reason?: string}>}
|
|
964
|
+
*/
|
|
965
|
+
async function verifyIndependently(wt, headSha, baseSha, config, env) {
|
|
966
|
+
const co = cleanCheckout(wt, headSha, verifyEnv(env));
|
|
967
|
+
try {
|
|
968
|
+
const result = await verifyCheckout(co.dir, baseSha, config, env);
|
|
969
|
+
const dirty = git(
|
|
970
|
+
co.dir,
|
|
971
|
+
["-c", "core.fsmonitor=false", "status", "--porcelain", "--untracked-files=no"],
|
|
972
|
+
verifyEnv(env),
|
|
973
|
+
);
|
|
974
|
+
if (dirty.status !== 0 || dirty.stdout.trim()) {
|
|
975
|
+
throw new Refusal(
|
|
976
|
+
"verify-test",
|
|
977
|
+
"the build or tests rewrote tracked files; the tree verified would not be the tree pushed",
|
|
978
|
+
);
|
|
979
|
+
}
|
|
980
|
+
return result;
|
|
981
|
+
} finally {
|
|
982
|
+
co.remove();
|
|
983
|
+
}
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
async function verifyCheckout(wt, baseSha, config, env) {
|
|
987
|
+
let doc;
|
|
988
|
+
let stack;
|
|
989
|
+
try {
|
|
990
|
+
doc = loadAdapters();
|
|
991
|
+
stack = resolveStack(wt, { doc }).primary;
|
|
992
|
+
} catch (err) {
|
|
993
|
+
throw new Refusal("verify-stack", `the stack adapters cannot be read: ${err.message}`);
|
|
994
|
+
}
|
|
995
|
+
const allowUnverified = config?.verify?.allowUnverifiedStack === true;
|
|
996
|
+
let headPins;
|
|
997
|
+
try {
|
|
998
|
+
headPins = verificationPins(wt, stack, doc, wt);
|
|
999
|
+
} catch (err) {
|
|
1000
|
+
if (err instanceof Refusal) throw err;
|
|
1001
|
+
if (allowUnverified) return { stack, verified: false, reason: err.message };
|
|
1002
|
+
throw new Refusal(
|
|
1003
|
+
"verify-stack",
|
|
1004
|
+
`stack ${stack} has no build and test command to verify with (${err.message}); verify.allowUnverifiedStack publishes it unverified`,
|
|
1005
|
+
);
|
|
1006
|
+
}
|
|
1007
|
+
const commands = Object.fromEntries(
|
|
1008
|
+
pinnedCommands(wt, baseSha, stack, doc, headPins, verifyEnv(env)).map((c) => [c.kind, c]),
|
|
1009
|
+
);
|
|
1010
|
+
if (!existsSync(EVIDENCE_GATE)) {
|
|
1011
|
+
throw new Refusal("verify-build", "evidence-gate.mjs is not installed beside this script");
|
|
1012
|
+
}
|
|
1013
|
+
const sec =
|
|
1014
|
+
Number(config?.verify?.timeoutSec) > 0 ? Number(config.verify.timeoutSec) : VERIFY_TIMEOUT_SEC;
|
|
1015
|
+
const dir = mkdtempSync(join(tmpdir(), "ap-verify-"));
|
|
1016
|
+
chmodSync(dir, 0o700);
|
|
1017
|
+
const runEnv = verifyEnv(env);
|
|
1018
|
+
try {
|
|
1019
|
+
let tests = null;
|
|
1020
|
+
for (const kind of ["build", "test"]) {
|
|
1021
|
+
const gate = `verify-${kind}`;
|
|
1022
|
+
const logPath = join(dir, `${kind}.log`);
|
|
1023
|
+
const r = await runBounded(commands[kind].command, {
|
|
1024
|
+
cwd: wt,
|
|
1025
|
+
env: runEnv,
|
|
1026
|
+
logPath,
|
|
1027
|
+
timeoutMs: sec * 1000,
|
|
1028
|
+
});
|
|
1029
|
+
if (r.timedOut) throw new Refusal(gate, `the ${kind} command did not finish in ${sec}s`);
|
|
1030
|
+
if (r.error) throw new Refusal(gate, `the ${kind} command did not start: ${r.error.message}`);
|
|
1031
|
+
if (r.status !== 0) {
|
|
1032
|
+
throw new Refusal(gate, `the ${kind} command exited ${r.status ?? r.signal}`);
|
|
1033
|
+
}
|
|
1034
|
+
const ev = spawnSync(
|
|
1035
|
+
process.execPath,
|
|
1036
|
+
[
|
|
1037
|
+
EVIDENCE_GATE,
|
|
1038
|
+
"--claim",
|
|
1039
|
+
kind,
|
|
1040
|
+
"--status",
|
|
1041
|
+
"passed",
|
|
1042
|
+
"--evidence",
|
|
1043
|
+
logPath,
|
|
1044
|
+
"--stack",
|
|
1045
|
+
stack,
|
|
1046
|
+
],
|
|
1047
|
+
{
|
|
1048
|
+
cwd: dir,
|
|
1049
|
+
encoding: "utf8",
|
|
1050
|
+
env: runEnv,
|
|
1051
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
1052
|
+
timeout: GIT_TIMEOUT_MS,
|
|
1053
|
+
},
|
|
1054
|
+
);
|
|
1055
|
+
if (ev.status !== 0) {
|
|
1056
|
+
let reason = why(ev);
|
|
1057
|
+
try {
|
|
1058
|
+
reason = JSON.parse(ev.stdout).reason || reason;
|
|
1059
|
+
} catch {
|
|
1060
|
+
// The gate's stderr already says why.
|
|
1061
|
+
}
|
|
1062
|
+
throw new Refusal(gate, `evidence-gate refused the ${kind} output: ${reason}`);
|
|
1063
|
+
}
|
|
1064
|
+
if (kind === "test") {
|
|
1065
|
+
const adapter = getAdapter(stack, { doc });
|
|
1066
|
+
const s = summarize(readRegularFile(logPath, { maxBytes: LOG_MAX_BYTES }), {
|
|
1067
|
+
parsers: adapter.testSummary?.parsers,
|
|
1068
|
+
source: logPath,
|
|
1069
|
+
});
|
|
1070
|
+
if (s.status === "failed" || s.status === "no-tests") {
|
|
1071
|
+
throw new Refusal(
|
|
1072
|
+
gate,
|
|
1073
|
+
`the test run says ${s.status} (executed ${s.executed ?? "?"}, failed ${s.failed ?? "?"})`,
|
|
1074
|
+
);
|
|
1075
|
+
}
|
|
1076
|
+
tests = { status: s.status, executed: s.executed, failed: s.failed };
|
|
1077
|
+
}
|
|
1078
|
+
}
|
|
1079
|
+
return { stack, verified: true, tests };
|
|
1080
|
+
} finally {
|
|
1081
|
+
rmSync(dir, { recursive: true, force: true });
|
|
1082
|
+
}
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
/**
|
|
1086
|
+
* Certain outbound-gate findings in any of the texts, by name.
|
|
1087
|
+
*
|
|
1088
|
+
* @param {Record<string,string>} texts
|
|
1089
|
+
*/
|
|
1090
|
+
export function outboundFindings(texts) {
|
|
1091
|
+
const out = [];
|
|
1092
|
+
for (const [field, text] of Object.entries(texts)) {
|
|
1093
|
+
if (typeof text !== "string") continue;
|
|
1094
|
+
for (const f of scan(text)) if (f.certain) out.push(`${field}:${f.line} ${f.rule}`);
|
|
1095
|
+
}
|
|
1096
|
+
return out;
|
|
1097
|
+
}
|
|
1098
|
+
|
|
1099
|
+
/** The request, read once as a bounded regular file and validated on those bytes. */
|
|
1100
|
+
function readRequest(reqPath) {
|
|
1101
|
+
let req;
|
|
1102
|
+
try {
|
|
1103
|
+
req = JSON.parse(readRegularFile(reqPath, { maxBytes: REQUEST_MAX_BYTES }));
|
|
1104
|
+
} catch (err) {
|
|
1105
|
+
throw new Refusal("pr-request", `the request is refused unread: ${err.message}`, { exit: 1 });
|
|
1106
|
+
}
|
|
1107
|
+
const { errors, secrets } = validatePrRequest(req);
|
|
1108
|
+
if (secrets.length || errors.length) {
|
|
1109
|
+
const detail = [...errors, ...secrets.map((s) => `${s.field}:${s.line} ${s.rule}`)].join("; ");
|
|
1110
|
+
throw new Refusal("pr-request", `the request is invalid: ${detail}`, {
|
|
1111
|
+
exit: secrets.length ? 7 : 1,
|
|
1112
|
+
});
|
|
1113
|
+
}
|
|
1114
|
+
return req;
|
|
1115
|
+
}
|
|
1116
|
+
|
|
1117
|
+
function push(stage, url, branch) {
|
|
1118
|
+
const r = stage.run(
|
|
1119
|
+
[
|
|
1120
|
+
"-c",
|
|
1121
|
+
"push.recurseSubmodules=no",
|
|
1122
|
+
"push",
|
|
1123
|
+
"--no-verify",
|
|
1124
|
+
"--porcelain",
|
|
1125
|
+
url,
|
|
1126
|
+
`refs/heads/${branch}:refs/heads/${branch}`,
|
|
1127
|
+
],
|
|
1128
|
+
NET_TIMEOUT_MS,
|
|
1129
|
+
);
|
|
1130
|
+
if (r.status !== 0) throw new Refusal("push", `git push refused: ${why(r)}`);
|
|
1131
|
+
}
|
|
1132
|
+
|
|
1133
|
+
function openDraftPr(req, repo, env) {
|
|
1134
|
+
const dir = mkdtempSync(join(tmpdir(), "ap-publish-"));
|
|
1135
|
+
chmodSync(dir, 0o700);
|
|
1136
|
+
try {
|
|
1137
|
+
const bodyFile = join(dir, "body.md");
|
|
1138
|
+
writeFileSync(bodyFile, req.body, { mode: 0o600 });
|
|
1139
|
+
const r = spawnSync(
|
|
1140
|
+
env.MA_AP_GH_BIN || "gh",
|
|
1141
|
+
[
|
|
1142
|
+
"pr",
|
|
1143
|
+
"create",
|
|
1144
|
+
"--draft",
|
|
1145
|
+
"--repo",
|
|
1146
|
+
repo,
|
|
1147
|
+
"--base",
|
|
1148
|
+
req.base,
|
|
1149
|
+
"--head",
|
|
1150
|
+
req.branch,
|
|
1151
|
+
"--title",
|
|
1152
|
+
req.title,
|
|
1153
|
+
"--body-file",
|
|
1154
|
+
bodyFile,
|
|
1155
|
+
],
|
|
1156
|
+
{
|
|
1157
|
+
cwd: dir,
|
|
1158
|
+
encoding: "utf8",
|
|
1159
|
+
env: outwardEnv(env),
|
|
1160
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
1161
|
+
timeout: NET_TIMEOUT_MS,
|
|
1162
|
+
},
|
|
1163
|
+
);
|
|
1164
|
+
const url = (r.stdout || "").match(/https?:\/\/\S+\/pull\/\d+/g)?.pop() || null;
|
|
1165
|
+
if (r.status !== 0 || !url) {
|
|
1166
|
+
throw new Refusal("pr-create", `gh pr create did not open a PR: ${why(r)}`, { pushed: true });
|
|
1167
|
+
}
|
|
1168
|
+
return { url, number: Number(url.match(/\/pull\/(\d+)/)[1]) };
|
|
1169
|
+
} finally {
|
|
1170
|
+
rmSync(dir, { recursive: true, force: true });
|
|
1171
|
+
}
|
|
1172
|
+
}
|
|
1173
|
+
|
|
1174
|
+
function summaryText(req, repo, url, sha) {
|
|
1175
|
+
return [
|
|
1176
|
+
`# Branch pushed, PR not opened`,
|
|
1177
|
+
"",
|
|
1178
|
+
`The autopilot runner verified and pushed this branch. The host has no draft`,
|
|
1179
|
+
`pull request, so a person opens the PR from it.`,
|
|
1180
|
+
"",
|
|
1181
|
+
`- Repository: ${repo}`,
|
|
1182
|
+
`- Branch: ${req.branch}`,
|
|
1183
|
+
`- Base: ${req.base}`,
|
|
1184
|
+
`- Head: ${sha}`,
|
|
1185
|
+
`- Remote: ${url.replace(/\/\/[^/@]*@/, "//")}`,
|
|
1186
|
+
"",
|
|
1187
|
+
`## Title`,
|
|
1188
|
+
"",
|
|
1189
|
+
req.title,
|
|
1190
|
+
"",
|
|
1191
|
+
`## Body`,
|
|
1192
|
+
"",
|
|
1193
|
+
req.body,
|
|
1194
|
+
"",
|
|
1195
|
+
].join("\n");
|
|
1196
|
+
}
|
|
1197
|
+
|
|
1198
|
+
/**
|
|
1199
|
+
* Verify and publish one run's PR request.
|
|
1200
|
+
*
|
|
1201
|
+
* @param {{sessionId: string, statePath: string|null, item: {repo: string, source?: string, id?: string},
|
|
1202
|
+
* config: object, env?: Record<string,string|undefined>}} args
|
|
1203
|
+
* @returns {Promise<{outcome: string|null, reason?: string, gate?: string, prUrl?: string,
|
|
1204
|
+
* summaryPath?: string, request?: object}>} outcome null: nothing to publish
|
|
1205
|
+
*/
|
|
1206
|
+
export async function publish({ sessionId, statePath, item, config, env = process.env }) {
|
|
1207
|
+
let reqPath;
|
|
1208
|
+
try {
|
|
1209
|
+
reqPath = prRequestPath(sessionId, env);
|
|
1210
|
+
} catch {
|
|
1211
|
+
return { outcome: null, reason: "no session id" };
|
|
1212
|
+
}
|
|
1213
|
+
if (!existsSync(reqPath)) return { outcome: null, reason: "no PR request" };
|
|
1214
|
+
if (!statePath || !existsSync(statePath)) {
|
|
1215
|
+
return { outcome: null, reason: "no run state to publish from" };
|
|
1216
|
+
}
|
|
1217
|
+
|
|
1218
|
+
const verdictPath = prVerdictPath(sessionId, env);
|
|
1219
|
+
const repo = item?.repo;
|
|
1220
|
+
const base = { at: new Date().toISOString(), sessionId, repo };
|
|
1221
|
+
// What this session's previous publish recorded. The verdict lives under the
|
|
1222
|
+
// autopilot root, which an unattended run cannot write.
|
|
1223
|
+
const prior = readRegularJson(verdictPath, null);
|
|
1224
|
+
const priorPushed = prior?.pushed === true;
|
|
1225
|
+
let req = null;
|
|
1226
|
+
let pushed = false;
|
|
1227
|
+
let stateReadable = false;
|
|
1228
|
+
let stage = null;
|
|
1229
|
+
try {
|
|
1230
|
+
let state;
|
|
1231
|
+
try {
|
|
1232
|
+
state = JSON.parse(readRegularFile(statePath, { maxBytes: STATE_MAX_BYTES }));
|
|
1233
|
+
} catch (err) {
|
|
1234
|
+
throw new Refusal("state", `the run's state is refused unread: ${err.message}`);
|
|
1235
|
+
}
|
|
1236
|
+
if (!state || typeof state !== "object" || Array.isArray(state)) {
|
|
1237
|
+
throw new Refusal("state", "the run's state is not a JSON object");
|
|
1238
|
+
}
|
|
1239
|
+
stateReadable = true;
|
|
1240
|
+
if ((state.currentPhase ?? 0) < PHASE_COMMIT) {
|
|
1241
|
+
return {
|
|
1242
|
+
outcome: null,
|
|
1243
|
+
reason: `the run stopped at phase ${state.currentPhase ?? 0}, before the commit hand-off`,
|
|
1244
|
+
};
|
|
1245
|
+
}
|
|
1246
|
+
// A run whose own gate rejected its work is not published. A publish that
|
|
1247
|
+
// was refused before may be re-run once its cause is fixed.
|
|
1248
|
+
const vf = state.verificationFailed;
|
|
1249
|
+
if (vf && !String(vf.gate || "").startsWith("publish/")) {
|
|
1250
|
+
return { outcome: null, reason: "the run's own verification failed" };
|
|
1251
|
+
}
|
|
1252
|
+
if (state.pr?.url || state.publish?.outcome) {
|
|
1253
|
+
return { outcome: null, reason: "already published" };
|
|
1254
|
+
}
|
|
1255
|
+
|
|
1256
|
+
req = readRequest(reqPath);
|
|
1257
|
+
|
|
1258
|
+
const repoCfg = (config?.repos || []).find((r) => r && r.nameWithOwner === repo);
|
|
1259
|
+
if (!repo || !repoCfg)
|
|
1260
|
+
throw new Refusal("repo", `${repo || "(no repo)"} is not in the runner's config`);
|
|
1261
|
+
if (req.repo !== repo) {
|
|
1262
|
+
throw new Refusal("repo", `the request names ${req.repo}; this item belongs to ${repo}`);
|
|
1263
|
+
}
|
|
1264
|
+
const host = resolveHost(repoCfg);
|
|
1265
|
+
if (!host)
|
|
1266
|
+
throw new Refusal(
|
|
1267
|
+
"host",
|
|
1268
|
+
`host ${JSON.stringify(repoCfg.host)} is not one of ${HOSTS.join(", ")}`,
|
|
1269
|
+
);
|
|
1270
|
+
|
|
1271
|
+
const localEnv = verifyEnv(env);
|
|
1272
|
+
const wt = typeof state.worktreePath === "string" ? state.worktreePath : null;
|
|
1273
|
+
if (!wt || !existsSync(wt)) throw new Refusal("worktree", "the run's worktree is not on disk");
|
|
1274
|
+
const common = (d) => {
|
|
1275
|
+
const c = gitOut(d, ["rev-parse", "--path-format=absolute", "--git-common-dir"], localEnv);
|
|
1276
|
+
return c ? realOr(c) : null;
|
|
1277
|
+
};
|
|
1278
|
+
const wtCommon = common(wt);
|
|
1279
|
+
if (!wtCommon || !repoCfg.localPath || wtCommon !== common(repoCfg.localPath)) {
|
|
1280
|
+
throw new Refusal(
|
|
1281
|
+
"worktree",
|
|
1282
|
+
`the worktree is not a checkout of ${repoCfg.localPath || repo}`,
|
|
1283
|
+
);
|
|
1284
|
+
}
|
|
1285
|
+
|
|
1286
|
+
if (protectedBranch(req.branch, repoCfg)) {
|
|
1287
|
+
throw new Refusal(
|
|
1288
|
+
"branch",
|
|
1289
|
+
`${req.branch} is protected; the run's work belongs on its own branch`,
|
|
1290
|
+
);
|
|
1291
|
+
}
|
|
1292
|
+
if (typeof state.branch !== "string" || state.branch !== req.branch) {
|
|
1293
|
+
throw new Refusal(
|
|
1294
|
+
"branch",
|
|
1295
|
+
`the request names ${req.branch}; the run recorded ${state.branch ?? "no branch"}`,
|
|
1296
|
+
);
|
|
1297
|
+
}
|
|
1298
|
+
const branchSha = gitOut(
|
|
1299
|
+
wt,
|
|
1300
|
+
["rev-parse", "--verify", "--quiet", `refs/heads/${req.branch}^{commit}`],
|
|
1301
|
+
localEnv,
|
|
1302
|
+
);
|
|
1303
|
+
if (!branchSha)
|
|
1304
|
+
throw new Refusal("branch", `branch ${req.branch} does not exist in the worktree`);
|
|
1305
|
+
const headSha = gitOut(wt, ["rev-parse", "--verify", "HEAD^{commit}"], localEnv);
|
|
1306
|
+
if (headSha !== branchSha) throw new Refusal("branch", `the worktree is not on ${req.branch}`);
|
|
1307
|
+
assertUnmoved(wt, req.branch, headSha, localEnv, "worktree");
|
|
1308
|
+
|
|
1309
|
+
const url = resolvePushUrl(repo, repoCfg, host, env);
|
|
1310
|
+
stage = stagingRepo(env);
|
|
1311
|
+
checkBase(stage, url, req.base, repoCfg);
|
|
1312
|
+
stageBranch(stage, wt, req.branch, headSha);
|
|
1313
|
+
const baseSha = fetchBase(stage, url, req.base);
|
|
1314
|
+
const commits = (stage.out(["rev-list", "--reverse", `${baseSha}..${headSha}`]) || "")
|
|
1315
|
+
.split("\n")
|
|
1316
|
+
.filter(Boolean);
|
|
1317
|
+
if (!commits.length) throw new Refusal("branch", `${req.branch} is not ahead of ${req.base}`);
|
|
1318
|
+
checkRemoteBranch(stage, url, req.branch, headSha, prior);
|
|
1319
|
+
const mergeBase = stage.out(["merge-base", baseSha, headSha]);
|
|
1320
|
+
if (!mergeBase) throw new Refusal("branch", `${req.branch} shares no history with ${req.base}`);
|
|
1321
|
+
|
|
1322
|
+
checkLedger(state, commits, wt, localEnv);
|
|
1323
|
+
checkTestLog(state, wt, statePath);
|
|
1324
|
+
checkSecrets(wt, `${mergeBase}..${headSha}`, env);
|
|
1325
|
+
const leaks = outboundFindings({ title: req.title, body: req.body });
|
|
1326
|
+
if (leaks.length) throw new Refusal("outbound-gate", `the PR text carries ${leaks.join(", ")}`);
|
|
1327
|
+
stage.cleanup();
|
|
1328
|
+
stage = null;
|
|
1329
|
+
|
|
1330
|
+
// The run's own code executes next, as this user. The operator's
|
|
1331
|
+
// credential settings are read now, before it runs, and the push uses that
|
|
1332
|
+
// snapshot. Afterwards the user-level git config must be byte-for-byte
|
|
1333
|
+
// what it was: a build or test that rewrote it is refused, not pushed with.
|
|
1334
|
+
const credText = credentialConfig(env);
|
|
1335
|
+
const fingerprint = gitConfigFingerprint(env);
|
|
1336
|
+
const hostBefore = hostFingerprint(env);
|
|
1337
|
+
const verified = await verifyIndependently(wt, headSha, mergeBase, config, env);
|
|
1338
|
+
assertUnmoved(wt, req.branch, headSha, localEnv, "verify-test");
|
|
1339
|
+
const hostAfter = hostFingerprint(env);
|
|
1340
|
+
const hostEdited = Object.keys({ ...hostBefore, ...hostAfter }).filter(
|
|
1341
|
+
(p) => hostBefore[p] !== hostAfter[p],
|
|
1342
|
+
);
|
|
1343
|
+
if (hostEdited.length) {
|
|
1344
|
+
throw new Refusal(
|
|
1345
|
+
"verify-host",
|
|
1346
|
+
`the build or tests changed the host while they ran (${hostEdited.slice(0, 5).join(", ")}${hostEdited.length > 5 ? ", ..." : ""}); nothing is pushed`,
|
|
1347
|
+
);
|
|
1348
|
+
}
|
|
1349
|
+
const after = gitConfigFingerprint(env);
|
|
1350
|
+
const edited = Object.keys({ ...fingerprint, ...after }).filter(
|
|
1351
|
+
(p) => fingerprint[p] !== after[p],
|
|
1352
|
+
);
|
|
1353
|
+
if (edited.length || credentialConfig(env) !== credText) {
|
|
1354
|
+
throw new Refusal(
|
|
1355
|
+
"verify-credentials",
|
|
1356
|
+
`the git config changed while the run's build and tests ran${edited.length ? ` (${edited.join(", ")})` : ""}; nothing is pushed with it`,
|
|
1357
|
+
);
|
|
1358
|
+
}
|
|
1359
|
+
|
|
1360
|
+
stage = stagingRepo(env, credText);
|
|
1361
|
+
stageBranch(stage, wt, req.branch, headSha);
|
|
1362
|
+
push(stage, url, req.branch);
|
|
1363
|
+
pushed = true;
|
|
1364
|
+
|
|
1365
|
+
if (host === "bitbucket") {
|
|
1366
|
+
const summaryPath = join(prRequestsDir(env), `${sessionId}${SUMMARY_SUFFIX}`);
|
|
1367
|
+
const text = summaryText(req, repo, url, headSha);
|
|
1368
|
+
writePrivate(summaryPath, text);
|
|
1369
|
+
const publishRec = {
|
|
1370
|
+
outcome: OUTCOME.PUSHED_AWAITING_PR,
|
|
1371
|
+
at: base.at,
|
|
1372
|
+
host,
|
|
1373
|
+
branch: req.branch,
|
|
1374
|
+
base: req.base,
|
|
1375
|
+
sha: headSha,
|
|
1376
|
+
};
|
|
1377
|
+
await updateState(statePath, (s) => ({ ...s, publish: publishRec }));
|
|
1378
|
+
writePrivate(
|
|
1379
|
+
verdictPath,
|
|
1380
|
+
`${JSON.stringify({ ...base, ...publishRec, pushed: true, verified, summaryPath }, null, 2)}\n`,
|
|
1381
|
+
);
|
|
1382
|
+
return { outcome: OUTCOME.PUSHED_AWAITING_PR, summaryPath, request: req };
|
|
1383
|
+
}
|
|
1384
|
+
|
|
1385
|
+
const pr = openDraftPr(req, repo, env);
|
|
1386
|
+
await updateState(statePath, (s) => ({
|
|
1387
|
+
...s,
|
|
1388
|
+
pr: { url: pr.url, number: pr.number, draft: true },
|
|
1389
|
+
}));
|
|
1390
|
+
writePrivate(
|
|
1391
|
+
verdictPath,
|
|
1392
|
+
`${JSON.stringify({ ...base, outcome: OUTCOME.PR_OPENED, host, branch: req.branch, sha: headSha, pushed: true, verified, url: pr.url }, null, 2)}\n`,
|
|
1393
|
+
);
|
|
1394
|
+
return { outcome: OUTCOME.PR_OPENED, prUrl: pr.url, request: req };
|
|
1395
|
+
} catch (err) {
|
|
1396
|
+
const gate = err instanceof Refusal ? err.gate : "internal";
|
|
1397
|
+
const reason = err instanceof Refusal ? err.message : `unexpected: ${err?.message || err}`;
|
|
1398
|
+
const branch = req?.branch ?? prior?.branch ?? null;
|
|
1399
|
+
writePrivate(
|
|
1400
|
+
verdictPath,
|
|
1401
|
+
`${JSON.stringify(
|
|
1402
|
+
{
|
|
1403
|
+
...base,
|
|
1404
|
+
outcome: OUTCOME.VERIFICATION_FAILED,
|
|
1405
|
+
gate,
|
|
1406
|
+
error: reason,
|
|
1407
|
+
pushed: pushed || Boolean(err?.extra?.pushed) || (priorPushed && prior.branch === branch),
|
|
1408
|
+
branch,
|
|
1409
|
+
},
|
|
1410
|
+
null,
|
|
1411
|
+
2,
|
|
1412
|
+
)}\n`,
|
|
1413
|
+
);
|
|
1414
|
+
if (stateReadable) {
|
|
1415
|
+
try {
|
|
1416
|
+
await park(statePath, {
|
|
1417
|
+
outcome: OUTCOME.VERIFICATION_FAILED,
|
|
1418
|
+
gate: `publish/${gate}`,
|
|
1419
|
+
reason,
|
|
1420
|
+
});
|
|
1421
|
+
} catch {
|
|
1422
|
+
// The verdict file already says why; a state that cannot be written
|
|
1423
|
+
// still ends the attempt as verification-failed below.
|
|
1424
|
+
}
|
|
1425
|
+
}
|
|
1426
|
+
return { outcome: OUTCOME.VERIFICATION_FAILED, gate, reason };
|
|
1427
|
+
} finally {
|
|
1428
|
+
stage?.cleanup();
|
|
1429
|
+
}
|
|
1430
|
+
}
|
|
1431
|
+
|
|
1432
|
+
/**
|
|
1433
|
+
* Run one outward script for a channel, after the outbound gate. The text goes
|
|
1434
|
+
* to the script as a 0600 body file in a private temp dir, never on argv, and
|
|
1435
|
+
* the script runs with MULTI_AGENT_UNATTENDED unset. Pushes one result.
|
|
1436
|
+
*/
|
|
1437
|
+
function scriptRunner(results, env) {
|
|
1438
|
+
return (channel, script, args, text) => {
|
|
1439
|
+
if (text !== undefined) {
|
|
1440
|
+
const leaks = outboundFindings({ [channel]: text });
|
|
1441
|
+
if (leaks.length) {
|
|
1442
|
+
results.push({ channel, status: "refused", reason: `outbound gate: ${leaks.join(", ")}` });
|
|
1443
|
+
return;
|
|
1444
|
+
}
|
|
1445
|
+
}
|
|
1446
|
+
if (!existsSync(script)) {
|
|
1447
|
+
results.push({ channel, status: "failed", reason: `${script} is not installed` });
|
|
1448
|
+
return;
|
|
1449
|
+
}
|
|
1450
|
+
const dir = mkdtempSync(join(tmpdir(), "ap-report-"));
|
|
1451
|
+
try {
|
|
1452
|
+
const full = [...args];
|
|
1453
|
+
if (text !== undefined) {
|
|
1454
|
+
const f = join(dir, "body.md");
|
|
1455
|
+
writeFileSync(f, text, { mode: 0o600 });
|
|
1456
|
+
full.push("--body-file", f);
|
|
1457
|
+
}
|
|
1458
|
+
const r = spawnSync("bash", [script, ...full], {
|
|
1459
|
+
cwd: dir,
|
|
1460
|
+
encoding: "utf8",
|
|
1461
|
+
env: outwardEnv(env),
|
|
1462
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
1463
|
+
timeout: NET_TIMEOUT_MS,
|
|
1464
|
+
});
|
|
1465
|
+
results.push(
|
|
1466
|
+
r.status === 0
|
|
1467
|
+
? { channel, status: "posted" }
|
|
1468
|
+
: { channel, status: "failed", reason: why(r) },
|
|
1469
|
+
);
|
|
1470
|
+
} finally {
|
|
1471
|
+
rmSync(dir, { recursive: true, force: true });
|
|
1472
|
+
}
|
|
1473
|
+
};
|
|
1474
|
+
}
|
|
1475
|
+
|
|
1476
|
+
/**
|
|
1477
|
+
* What the runner posts after a PR opened, and nothing before. Each piece is
|
|
1478
|
+
* off unless the config turns it on:
|
|
1479
|
+
*
|
|
1480
|
+
* reportChannels + channels.mode "configured": `jira` posts one comment with
|
|
1481
|
+
* the PR link and the PR text through jira-publish.sh. `pr` is the draft PR
|
|
1482
|
+
* itself. `confluence` and `wiki` need a page composed by a session with
|
|
1483
|
+
* publish rights, which an unattended run does not get, so they are
|
|
1484
|
+
* reported as skipped.
|
|
1485
|
+
* reportIssueUpdates: update-issue-progress.sh for a GitHub issue the run
|
|
1486
|
+
* recorded, and a PR-link Jira comment when no channel comment went out.
|
|
1487
|
+
*
|
|
1488
|
+
* Every text passes the outbound gate here before a script sees it, and every
|
|
1489
|
+
* script runs with MULTI_AGENT_UNATTENDED unset (each script's own gate runs
|
|
1490
|
+
* again inside it).
|
|
1491
|
+
*
|
|
1492
|
+
* @returns {{channel: string, status: "posted"|"skipped"|"refused"|"failed", reason?: string}[]}
|
|
1493
|
+
*/
|
|
1494
|
+
export function reportAfterPublish({
|
|
1495
|
+
prUrl,
|
|
1496
|
+
request,
|
|
1497
|
+
item,
|
|
1498
|
+
state,
|
|
1499
|
+
config,
|
|
1500
|
+
env = process.env,
|
|
1501
|
+
scripts = {},
|
|
1502
|
+
}) {
|
|
1503
|
+
const results = [];
|
|
1504
|
+
if (!prUrl) return results;
|
|
1505
|
+
const jiraKey =
|
|
1506
|
+
(typeof state?.jiraId === "string" && state.jiraId) ||
|
|
1507
|
+
(item?.source === "jira" ? item.id : null);
|
|
1508
|
+
const jiraScript = scripts.jiraPublish || join(HERE, "..", "lib", "jira-publish.sh");
|
|
1509
|
+
const progressScript = scripts.issueProgress || join(HERE, "update-issue-progress.sh");
|
|
1510
|
+
|
|
1511
|
+
const runScript = scriptRunner(results, env);
|
|
1512
|
+
|
|
1513
|
+
let jiraPosted = false;
|
|
1514
|
+
const ch = config?.channels || {};
|
|
1515
|
+
if (config?.reportChannels === true && ch.mode === "configured") {
|
|
1516
|
+
for (const target of Array.isArray(ch.targets) ? ch.targets : []) {
|
|
1517
|
+
if (target === "pr") {
|
|
1518
|
+
results.push({ channel: "pr", status: "posted", reason: "the draft PR is the report" });
|
|
1519
|
+
} else if (target === "jira") {
|
|
1520
|
+
if (!jiraKey) {
|
|
1521
|
+
results.push({
|
|
1522
|
+
channel: "jira",
|
|
1523
|
+
status: "skipped",
|
|
1524
|
+
reason: "no Jira issue linked to this run",
|
|
1525
|
+
});
|
|
1526
|
+
continue;
|
|
1527
|
+
}
|
|
1528
|
+
runScript(
|
|
1529
|
+
"jira",
|
|
1530
|
+
jiraScript,
|
|
1531
|
+
["--issue", jiraKey],
|
|
1532
|
+
`Draft PR opened: ${prUrl}\n\n${request?.title || ""}\n\n${request?.body || ""}`,
|
|
1533
|
+
);
|
|
1534
|
+
jiraPosted = results.at(-1).status === "posted";
|
|
1535
|
+
} else {
|
|
1536
|
+
results.push({
|
|
1537
|
+
channel: target,
|
|
1538
|
+
status: "skipped",
|
|
1539
|
+
reason: `${target} needs a page composed with publish rights; not posted unattended`,
|
|
1540
|
+
});
|
|
1541
|
+
}
|
|
1542
|
+
}
|
|
1543
|
+
}
|
|
1544
|
+
if (config?.reportIssueUpdates === true) {
|
|
1545
|
+
if (state?.githubIssue?.number && state?.taskId) {
|
|
1546
|
+
runScript("issue-progress", progressScript, [String(state.taskId)]);
|
|
1547
|
+
}
|
|
1548
|
+
if (jiraKey && !jiraPosted) {
|
|
1549
|
+
runScript("jira-pr-link", jiraScript, ["--issue", jiraKey], `Draft PR opened: ${prUrl}\n`);
|
|
1550
|
+
}
|
|
1551
|
+
}
|
|
1552
|
+
return results;
|
|
1553
|
+
}
|
|
1554
|
+
|
|
1555
|
+
/**
|
|
1556
|
+
* The daily digest, through the same channels and the same gate as the post-PR
|
|
1557
|
+
* report. Sent only with `reportChannels: true` and `channels.mode:
|
|
1558
|
+
* "configured"`; the caller decides whether the digest runs at all
|
|
1559
|
+
* (`digest.enabled`). `jira` posts one comment on `digest.jiraIssue`, a
|
|
1560
|
+
* standing issue the operator chose, because a digest belongs to no single
|
|
1561
|
+
* item. `pr` has no PR to be, and `confluence` and `wiki` need a page composed
|
|
1562
|
+
* with publish rights, so all three are reported as skipped.
|
|
1563
|
+
*
|
|
1564
|
+
* @returns {{channel: string, status: "posted"|"skipped"|"refused"|"failed", reason?: string}[]}
|
|
1565
|
+
*/
|
|
1566
|
+
export function reportDigest({ text, config, env = process.env, scripts = {} }) {
|
|
1567
|
+
const results = [];
|
|
1568
|
+
const ch = config?.channels || {};
|
|
1569
|
+
if (config?.reportChannels !== true || ch.mode !== "configured") return results;
|
|
1570
|
+
const runScript = scriptRunner(results, env);
|
|
1571
|
+
const jiraScript = scripts.jiraPublish || join(HERE, "..", "lib", "jira-publish.sh");
|
|
1572
|
+
const issue = typeof config?.digest?.jiraIssue === "string" ? config.digest.jiraIssue.trim() : "";
|
|
1573
|
+
for (const target of Array.isArray(ch.targets) ? ch.targets : []) {
|
|
1574
|
+
if (target === "jira") {
|
|
1575
|
+
if (!issue) {
|
|
1576
|
+
results.push({ channel: "jira", status: "skipped", reason: "digest.jiraIssue is not set" });
|
|
1577
|
+
continue;
|
|
1578
|
+
}
|
|
1579
|
+
runScript("jira", jiraScript, ["--issue", issue], text);
|
|
1580
|
+
} else if (target === "pr") {
|
|
1581
|
+
results.push({ channel: "pr", status: "skipped", reason: "a digest has no PR" });
|
|
1582
|
+
} else {
|
|
1583
|
+
results.push({
|
|
1584
|
+
channel: target,
|
|
1585
|
+
status: "skipped",
|
|
1586
|
+
reason: `${target} needs a page composed with publish rights; not posted unattended`,
|
|
1587
|
+
});
|
|
1588
|
+
}
|
|
1589
|
+
}
|
|
1590
|
+
return results;
|
|
1591
|
+
}
|
|
1592
|
+
|
|
1593
|
+
function arg(argv, name) {
|
|
1594
|
+
const i = argv.indexOf(name);
|
|
1595
|
+
return i === -1 ? undefined : argv[i + 1];
|
|
1596
|
+
}
|
|
1597
|
+
|
|
1598
|
+
async function main(argv) {
|
|
1599
|
+
const sessionId = arg(argv, "--session");
|
|
1600
|
+
const statePath = arg(argv, "--state");
|
|
1601
|
+
const repo = arg(argv, "--repo");
|
|
1602
|
+
if (!sessionId || !statePath || !repo) {
|
|
1603
|
+
process.stderr.write(
|
|
1604
|
+
"usage: autopilot-publish.mjs --session <uuid> --state <agent-state.json> --repo <owner/name>\n",
|
|
1605
|
+
);
|
|
1606
|
+
return 2;
|
|
1607
|
+
}
|
|
1608
|
+
const root =
|
|
1609
|
+
process.env.MA_AUTOPILOT_ROOT || join(process.env.HOME || "", ".claude", "autopilot");
|
|
1610
|
+
const config = readRegularJson(join(root, "config.json"), null);
|
|
1611
|
+
if (!config) {
|
|
1612
|
+
process.stderr.write(`autopilot-publish: no config at ${join(root, "config.json")}\n`);
|
|
1613
|
+
return 2;
|
|
1614
|
+
}
|
|
1615
|
+
const r = await publish({ sessionId, statePath, item: { repo }, config });
|
|
1616
|
+
process.stdout.write(
|
|
1617
|
+
`${JSON.stringify({ outcome: r.outcome, gate: r.gate, reason: r.reason, prUrl: r.prUrl })}\n`,
|
|
1618
|
+
);
|
|
1619
|
+
if (r.outcome === null) return 3;
|
|
1620
|
+
return r.outcome === OUTCOME.VERIFICATION_FAILED ? 1 : 0;
|
|
1621
|
+
}
|
|
1622
|
+
|
|
1623
|
+
if (invokedDirectly(import.meta.url)) {
|
|
1624
|
+
main(process.argv.slice(2)).then((code) => process.exit(code));
|
|
1625
|
+
}
|