@gajae-code/coding-agent 0.11.0 → 0.11.2
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 +63 -1
- package/dist/types/async/job-manager.d.ts +46 -7
- package/dist/types/cli/config-cli.d.ts +11 -1
- package/dist/types/cli/notify-cli.d.ts +1 -0
- package/dist/types/cli/session-picker.d.ts +2 -1
- package/dist/types/cli/setup-cli.d.ts +1 -1
- package/dist/types/commands/mcp-serve.d.ts +33 -0
- package/dist/types/commands/mcp.d.ts +1 -1
- package/dist/types/commands/notify.d.ts +0 -2
- package/dist/types/commands/sdk.d.ts +1 -1
- package/dist/types/config/atomic-yaml-patch.d.ts +18 -0
- package/dist/types/config/file-lock.d.ts +14 -4
- package/dist/types/config/keybindings.d.ts +42 -1
- package/dist/types/config/model-auth.d.ts +3 -0
- package/dist/types/config/model-bindings-applier.d.ts +13 -0
- package/dist/types/config/model-discovery-manager.d.ts +49 -0
- package/dist/types/config/model-equivalence.d.ts +20 -0
- package/dist/types/config/model-registry.d.ts +16 -15
- package/dist/types/config/model-resolver.d.ts +28 -18
- package/dist/types/config/models-config-schema.d.ts +11 -0
- package/dist/types/config/settings-schema.d.ts +56 -1
- package/dist/types/config/settings.d.ts +14 -11
- package/dist/types/coordinator-mcp/policy.d.ts +5 -0
- package/dist/types/coordinator-mcp/question-gate-codec.d.ts +129 -0
- package/dist/types/coordinator-mcp/question-state.d.ts +356 -0
- package/dist/types/coordinator-mcp/server.d.ts +4 -0
- package/dist/types/discovery/helpers.d.ts +0 -6
- package/dist/types/edit/index.d.ts +3 -0
- package/dist/types/eval/py/env.d.ts +9 -0
- package/dist/types/extensibility/extensions/loader.d.ts +2 -1
- package/dist/types/extensibility/extensions/runner.d.ts +2 -2
- package/dist/types/extensibility/extensions/types.d.ts +34 -3
- package/dist/types/extensibility/hooks/runner.d.ts +1 -1
- package/dist/types/extensibility/shared-events.d.ts +2 -0
- package/dist/types/gjc-runtime/deep-interview-recorder.d.ts +10 -1
- package/dist/types/gjc-runtime/deep-interview-state.d.ts +44 -0
- package/dist/types/gjc-runtime/goal-mode-request.d.ts +11 -1
- package/dist/types/gjc-runtime/launch-worktree.d.ts +4 -2
- package/dist/types/gjc-runtime/linux-proc.d.ts +29 -0
- package/dist/types/gjc-runtime/session-resolution.d.ts +2 -2
- package/dist/types/gjc-runtime/state-migrations.d.ts +1 -1
- package/dist/types/gjc-runtime/state-writer.d.ts +2 -1
- package/dist/types/gjc-runtime/team-launch.d.ts +59 -0
- package/dist/types/gjc-runtime/team-notify.d.ts +45 -0
- package/dist/types/gjc-runtime/team-runtime.d.ts +29 -121
- package/dist/types/gjc-runtime/team-store.d.ts +160 -0
- package/dist/types/gjc-runtime/team-workers.d.ts +78 -0
- package/dist/types/gjc-runtime/tmux-owner-isolation.d.ts +8 -0
- package/dist/types/gjc-runtime/ultragoal-change-set.d.ts +13 -0
- package/dist/types/gjc-runtime/ultragoal-evidence.d.ts +38 -0
- package/dist/types/gjc-runtime/ultragoal-guard.d.ts +7 -0
- package/dist/types/gjc-runtime/ultragoal-pipeline.d.ts +23 -0
- package/dist/types/gjc-runtime/ultragoal-receipt-freshness.d.ts +11 -0
- package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +55 -33
- package/dist/types/gjc-runtime/workflow-cli-common.d.ts +8 -0
- package/dist/types/gjc-runtime/workflow-manifest.d.ts +3 -0
- package/dist/types/goals/continuation-timeout-guard.d.ts +22 -0
- package/dist/types/goals/runtime.d.ts +1 -0
- package/dist/types/goals/state.d.ts +8 -0
- package/dist/types/harness-control-plane/lifecycle-reconciliation.d.ts +17 -0
- package/dist/types/harness-control-plane/phase-rollup.d.ts +4 -7
- package/dist/types/harness-control-plane/receipt-ingest.d.ts +5 -0
- package/dist/types/harness-control-plane/receipt-spool.d.ts +4 -0
- package/dist/types/hindsight/state.d.ts +11 -4
- package/dist/types/lsp/lspmux.d.ts +5 -3
- package/dist/types/lsp/path-trust.d.ts +1 -0
- package/dist/types/main.d.ts +3 -2
- package/dist/types/memories/storage.d.ts +1 -0
- package/dist/types/modes/action-registry.d.ts +29 -0
- package/dist/types/modes/components/chrome.d.ts +14 -0
- package/dist/types/modes/components/command-palette.d.ts +27 -0
- package/dist/types/modes/components/custom-provider-wizard.d.ts +1 -0
- package/dist/types/modes/components/index.d.ts +2 -1
- package/dist/types/modes/components/pet-selector.d.ts +1 -1
- package/dist/types/modes/components/plan-preview-overlay.d.ts +43 -0
- package/dist/types/modes/components/queue-mode-selector.d.ts +1 -1
- package/dist/types/modes/components/queue-pane.d.ts +12 -0
- package/dist/types/modes/components/read-tool-group.d.ts +6 -0
- package/dist/types/modes/components/session-observer-overlay.d.ts +3 -4
- package/dist/types/modes/components/sessions-dashboard.d.ts +32 -0
- package/dist/types/modes/components/settings-selector.d.ts +1 -1
- package/dist/types/modes/components/show-images-selector.d.ts +1 -1
- package/dist/types/modes/components/status-line/types.d.ts +1 -1
- package/dist/types/modes/components/status-line.d.ts +1 -94
- package/dist/types/modes/components/tasks-pane.d.ts +15 -0
- package/dist/types/modes/components/theme-selector.d.ts +1 -1
- package/dist/types/modes/components/tool-execution.d.ts +15 -0
- package/dist/types/modes/components/tool-status-header.d.ts +111 -0
- package/dist/types/modes/components/transcript-viewer-overlay.d.ts +42 -0
- package/dist/types/modes/controllers/command-controller.d.ts +2 -2
- package/dist/types/modes/controllers/extension-ui-controller.d.ts +1 -0
- package/dist/types/modes/controllers/goal-mode-controller.d.ts +62 -0
- package/dist/types/modes/controllers/input-controller.d.ts +8 -1
- package/dist/types/modes/controllers/mode-gate.d.ts +8 -0
- package/dist/types/modes/controllers/plan-mode-controller.d.ts +65 -0
- package/dist/types/modes/controllers/selector-controller.d.ts +15 -3
- package/dist/types/modes/controllers/stt-controller.d.ts +26 -0
- package/dist/types/modes/interactive-mode.d.ts +21 -19
- package/dist/types/modes/jobs-observer.d.ts +2 -0
- package/dist/types/modes/shared/agent-wire/deep-interview-gate.d.ts +4 -11
- package/dist/types/modes/shared/agent-wire/workflow-gate-broker.d.ts +21 -2
- package/dist/types/modes/shared/agent-wire/workflow-gate-types.d.ts +31 -0
- package/dist/types/modes/tasks-aggregator.d.ts +39 -0
- package/dist/types/modes/transcript-item-registry.d.ts +60 -0
- package/dist/types/modes/types.d.ts +22 -15
- package/dist/types/modes/utils/ui-helpers.d.ts +2 -2
- package/dist/types/runtime-mcp/manager.d.ts +1 -1
- package/dist/types/sdk/broker/broker.d.ts +78 -12
- package/dist/types/sdk/broker/ensure.d.ts +9 -0
- package/dist/types/sdk/broker/lifecycle-codec.d.ts +4 -0
- package/dist/types/sdk/broker/lifecycle-ledger.d.ts +15 -1
- package/dist/types/sdk/broker/lifecycle.d.ts +6 -2
- package/dist/types/sdk/broker/process-incarnation.d.ts +2 -0
- package/dist/types/sdk/broker/session-index.d.ts +0 -1
- package/dist/types/sdk/broker/state-version.d.ts +2 -0
- package/dist/types/sdk/bus/chat-effect-journal.d.ts +1 -0
- package/dist/types/sdk/bus/config-commands.d.ts +1 -1
- package/dist/types/sdk/bus/config.d.ts +11 -0
- package/dist/types/sdk/bus/daemon-paths.d.ts +1 -0
- package/dist/types/sdk/bus/discord-daemon.d.ts +5 -0
- package/dist/types/sdk/bus/html-format.d.ts +3 -2
- package/dist/types/sdk/bus/index.d.ts +27 -0
- package/dist/types/sdk/bus/lifecycle-control-runtime.d.ts +4 -0
- package/dist/types/sdk/bus/notification-service.d.ts +20 -0
- package/dist/types/sdk/bus/rate-limit-pool.d.ts +13 -2
- package/dist/types/sdk/bus/recent-activity.d.ts +21 -12
- package/dist/types/sdk/bus/rich-document.d.ts +7 -0
- package/dist/types/sdk/bus/rich-render.d.ts +18 -41
- package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +9 -4
- package/dist/types/sdk/bus/telegram-daemon.d.ts +69 -3
- package/dist/types/sdk/bus/threaded-inbound.d.ts +1 -1
- package/dist/types/sdk/bus/threaded-render.d.ts +13 -0
- package/dist/types/sdk/bus/topic-registry.d.ts +24 -2
- package/dist/types/sdk/host/control/operations.d.ts +1 -1
- package/dist/types/sdk/host/host.d.ts +2 -0
- package/dist/types/sdk/index.d.ts +1 -0
- package/dist/types/sdk/session-directory.d.ts +56 -0
- package/dist/types/session/agent-session.d.ts +82 -47
- package/dist/types/session/agent-storage.d.ts +7 -0
- package/dist/types/session/fallback-chain-controller.d.ts +10 -2
- package/dist/types/session/internal/managed-session-scope.d.ts +102 -0
- package/dist/types/session/internal/managed-session-storage.d.ts +38 -0
- package/dist/types/session/messages.d.ts +16 -0
- package/dist/types/session/session-manager-internal.d.ts +14 -0
- package/dist/types/session/session-manager.d.ts +82 -12
- package/dist/types/session/session-storage.d.ts +43 -3
- package/dist/types/session/volatile-context-pruning.d.ts +15 -0
- package/dist/types/skill-state/{deep-interview-mutation-guard.d.ts → workflow-mutation-guard.d.ts} +24 -8
- package/dist/types/skill-state/workflow-state-contract.d.ts +1 -3
- package/dist/types/slash-commands/builtin-registry.d.ts +4 -1
- package/dist/types/system-prompt.d.ts +2 -11
- package/dist/types/task/executor.d.ts +3 -1
- package/dist/types/thinking.d.ts +7 -0
- package/dist/types/tool-discovery/tool-index.d.ts +1 -50
- package/dist/types/tools/ask.d.ts +64 -2
- package/dist/types/tools/bash.d.ts +3 -0
- package/dist/types/tools/browser/dead-tab-recovery.d.ts +24 -0
- package/dist/types/tools/browser/tab-supervisor.d.ts +21 -0
- package/dist/types/tools/cron.d.ts +2 -0
- package/dist/types/tools/fetch.d.ts +1 -0
- package/dist/types/tools/index.d.ts +30 -15
- package/dist/types/tools/output-meta.d.ts +1 -0
- package/dist/types/tools/read.d.ts +3 -0
- package/dist/types/tools/search-tool-bm25.d.ts +3 -4
- package/dist/types/tools/subagent-render.d.ts +1 -0
- package/dist/types/tools/subagent.d.ts +6 -1
- package/dist/types/utils/event-bus.d.ts +1 -1
- package/dist/types/web/search/index.d.ts +3 -1
- package/package.json +21 -9
- package/scripts/build-sdk-package-smoke.ts +39 -1
- package/scripts/generate-hotkeys-docs.ts +74 -0
- package/scripts/generate-sdk-operation-inventory.ts +64 -20
- package/scripts/verify-gjc-sdk-canonicalization.ts +182 -2
- package/src/async/job-manager.ts +455 -69
- package/src/cli/config-cli.ts +75 -2
- package/src/cli/notify-cli.ts +92 -26
- package/src/cli/session-picker.ts +20 -3
- package/src/cli/setup-cli.ts +1 -1
- package/src/cli.ts +1 -1
- package/src/commands/config.ts +1 -1
- package/src/commands/harness.ts +34 -106
- package/src/commands/launch.ts +6 -4
- package/src/commands/mcp-serve.ts +85 -8
- package/src/commands/mcp.ts +2 -2
- package/src/commands/notify.ts +8 -4
- package/src/commands/sdk.ts +61 -40
- package/src/commands/ultragoal.ts +4 -1
- package/src/config/atomic-yaml-patch.ts +80 -13
- package/src/config/file-lock.ts +105 -43
- package/src/config/keybindings.ts +64 -20
- package/src/config/model-auth.ts +9 -0
- package/src/config/model-bindings-applier.ts +139 -0
- package/src/config/model-discovery-manager.ts +224 -0
- package/src/config/model-equivalence.ts +66 -0
- package/src/config/model-registry.ts +169 -302
- package/src/config/model-resolver.ts +361 -369
- package/src/config/models-config-schema.ts +5 -4
- package/src/config/settings-schema.ts +159 -2
- package/src/config/settings.ts +458 -482
- package/src/coordinator-mcp/policy.ts +76 -11
- package/src/coordinator-mcp/question-gate-codec.ts +274 -0
- package/src/coordinator-mcp/question-state.ts +778 -0
- package/src/coordinator-mcp/server.ts +1702 -238
- package/src/debug/index.ts +1 -1
- package/src/defaults/gjc/skills/deep-interview/SKILL.md +13 -1
- package/src/defaults/gjc/skills/team/SKILL.md +16 -0
- package/src/discovery/helpers.ts +1 -50
- package/src/edit/index.ts +64 -0
- package/src/eval/py/env.ts +26 -0
- package/src/eval/py/kernel.ts +5 -3
- package/src/extensibility/extensions/loader.ts +9 -1
- package/src/extensibility/extensions/runner.ts +12 -3
- package/src/extensibility/extensions/types.ts +39 -1
- package/src/extensibility/gjc-plugins/installer.ts +16 -16
- package/src/extensibility/hooks/runner.ts +2 -2
- package/src/extensibility/runtime-skill-discovery.ts +33 -13
- package/src/extensibility/shared-events.ts +2 -0
- package/src/gjc-runtime/deep-interview-recorder.ts +63 -3
- package/src/gjc-runtime/deep-interview-runtime.ts +76 -27
- package/src/gjc-runtime/deep-interview-state.ts +317 -5
- package/src/gjc-runtime/goal-mode-request.ts +55 -8
- package/src/gjc-runtime/launch-tmux.ts +2 -5
- package/src/gjc-runtime/launch-worktree.ts +5 -5
- package/src/gjc-runtime/linux-proc.ts +71 -0
- package/src/gjc-runtime/ralplan-runtime.ts +74 -90
- package/src/gjc-runtime/session-resolution.ts +18 -3
- package/src/gjc-runtime/session-state-sidecar.ts +6 -107
- package/src/gjc-runtime/state-migrations.ts +1 -1
- package/src/gjc-runtime/state-runtime.ts +320 -249
- package/src/gjc-runtime/state-writer.ts +36 -40
- package/src/gjc-runtime/team-launch.ts +319 -0
- package/src/gjc-runtime/team-notify.ts +326 -0
- package/src/gjc-runtime/team-runtime.ts +1099 -1482
- package/src/gjc-runtime/team-store.ts +828 -0
- package/src/gjc-runtime/team-workers.ts +962 -0
- package/src/gjc-runtime/tmux-owner-isolation-cli.ts +2 -11
- package/src/gjc-runtime/tmux-owner-isolation.ts +14 -9
- package/src/gjc-runtime/tmux-sessions.ts +4 -21
- package/src/gjc-runtime/ultragoal-change-set.ts +172 -0
- package/src/gjc-runtime/ultragoal-evidence.ts +874 -0
- package/src/gjc-runtime/ultragoal-guard.ts +83 -12
- package/src/gjc-runtime/ultragoal-pipeline.ts +306 -0
- package/src/gjc-runtime/ultragoal-receipt-freshness.ts +102 -1
- package/src/gjc-runtime/ultragoal-runtime.ts +156 -1319
- package/src/gjc-runtime/workflow-cli-common.ts +30 -0
- package/src/gjc-runtime/workflow-manifest.generated.json +58 -0
- package/src/gjc-runtime/workflow-manifest.ts +18 -0
- package/src/goals/continuation-timeout-guard.ts +79 -0
- package/src/goals/runtime.ts +7 -5
- package/src/goals/state.ts +18 -0
- package/src/harness-control-plane/lifecycle-reconciliation.ts +64 -0
- package/src/harness-control-plane/phase-rollup.ts +4 -7
- package/src/harness-control-plane/receipt-ingest.ts +5 -0
- package/src/harness-control-plane/receipt-spool.ts +51 -7
- package/src/harness-control-plane/session-lease.ts +12 -130
- package/src/hindsight/backend.ts +26 -27
- package/src/hindsight/state.ts +101 -32
- package/src/hooks/skill-state.ts +17 -11
- package/src/internal-urls/docs-index.generated.ts +21 -18
- package/src/lsp/client.ts +1 -1
- package/src/lsp/config.ts +145 -26
- package/src/lsp/index.ts +1 -1
- package/src/lsp/lspmux.ts +39 -10
- package/src/lsp/path-trust.ts +81 -0
- package/src/main.ts +28 -8
- package/src/memories/index.ts +1 -0
- package/src/memories/storage.ts +6 -2
- package/src/modes/action-registry.ts +165 -0
- package/src/modes/components/chrome.ts +40 -0
- package/src/modes/components/command-palette.ts +168 -0
- package/src/modes/components/custom-editor.ts +1 -1
- package/src/modes/components/custom-provider-wizard.ts +51 -14
- package/src/modes/components/index.ts +2 -1
- package/src/modes/components/pet-selector.ts +11 -17
- package/src/modes/components/plan-preview-overlay.ts +295 -0
- package/src/modes/components/queue-mode-selector.ts +10 -27
- package/src/modes/components/queue-pane.ts +79 -0
- package/src/modes/components/read-tool-group.ts +13 -0
- package/src/modes/components/session-observer-overlay.ts +207 -772
- package/src/modes/components/sessions-dashboard.ts +181 -0
- package/src/modes/components/settings-selector.ts +1 -1
- package/src/modes/components/show-images-selector.ts +10 -24
- package/src/modes/components/status-line/types.ts +1 -1
- package/src/modes/components/status-line.ts +1 -969
- package/src/modes/components/tasks-pane.ts +73 -0
- package/src/modes/components/theme-selector.ts +11 -31
- package/src/modes/components/tool-execution.ts +22 -0
- package/src/modes/components/tool-status-header.ts +1056 -0
- package/src/modes/components/transcript-viewer-overlay.ts +384 -0
- package/src/modes/controllers/btw-controller.ts +1 -0
- package/src/modes/controllers/command-controller.ts +9 -15
- package/src/modes/controllers/event-controller.ts +3 -2
- package/src/modes/controllers/extension-ui-controller.ts +72 -25
- package/src/modes/controllers/goal-mode-controller.ts +474 -0
- package/src/modes/controllers/input-controller.ts +764 -229
- package/src/modes/controllers/mode-gate.ts +24 -0
- package/src/modes/controllers/plan-mode-controller.ts +517 -0
- package/src/modes/controllers/selector-controller.ts +225 -10
- package/src/modes/controllers/stt-controller.ts +125 -0
- package/src/modes/interactive-mode.ts +302 -1151
- package/src/modes/jobs-observer.ts +3 -0
- package/src/modes/runtime-init.ts +8 -0
- package/src/modes/shared/agent-wire/deep-interview-gate.ts +31 -106
- package/src/modes/shared/agent-wire/workflow-gate-broker.ts +78 -19
- package/src/modes/shared/agent-wire/workflow-gate-types.ts +177 -0
- package/src/modes/tasks-aggregator.ts +199 -0
- package/src/modes/transcript-item-registry.ts +229 -0
- package/src/modes/types.ts +47 -20
- package/src/modes/utils/ui-helpers.ts +17 -9
- package/src/prompts/agent-fragments/ralplan-persistence.md +6 -0
- package/src/prompts/agent-fragments/restricted-bash.md +1 -0
- package/src/prompts/agents/architect.md +2 -8
- package/src/prompts/agents/critic.md +2 -8
- package/src/prompts/agents/executor.md +3 -0
- package/src/prompts/agents/planner.md +4 -12
- package/src/prompts/system/custom-system-prompt.md +1 -23
- package/src/prompts/system/plan-mode-approved.md +8 -0
- package/src/prompts/system/system-prompt.md +17 -96
- package/src/prompts/tools/bash.md +4 -4
- package/src/prompts/tools/browser.md +15 -52
- package/src/prompts/tools/find.md +2 -2
- package/src/prompts/tools/hashline.md +13 -55
- package/src/prompts/tools/read.md +3 -6
- package/src/prompts/tools/search-tool-bm25.md +0 -2
- package/src/prompts/tools/search.md +2 -4
- package/src/prompts/tools/skill-discovery.md +1 -2
- package/src/prompts/tools/task.md +1 -1
- package/src/runtime-mcp/manager.ts +1 -1
- package/src/sdk/broker/broker.ts +206 -78
- package/src/sdk/broker/discovery.ts +21 -2
- package/src/sdk/broker/ensure.ts +85 -25
- package/src/sdk/broker/lifecycle-codec.ts +11 -0
- package/src/sdk/broker/lifecycle-ledger.ts +371 -74
- package/src/sdk/broker/lifecycle.ts +1399 -142
- package/src/sdk/broker/process-incarnation.ts +5 -13
- package/src/sdk/broker/session-index.ts +145 -56
- package/src/sdk/broker/state-version.ts +21 -0
- package/src/sdk/broker/transport.ts +1 -1
- package/src/sdk/bus/chat-daemon-cli.ts +2 -0
- package/src/sdk/bus/chat-daemon-control.ts +8 -28
- package/src/sdk/bus/chat-effect-journal.ts +14 -4
- package/src/sdk/bus/config-commands.ts +5 -4
- package/src/sdk/bus/config.ts +98 -0
- package/src/sdk/bus/conversation-store.ts +5 -29
- package/src/sdk/bus/daemon-paths.ts +2 -0
- package/src/sdk/bus/discord-daemon.ts +25 -8
- package/src/sdk/bus/html-format.ts +20 -3
- package/src/sdk/bus/index.ts +834 -113
- package/src/sdk/bus/lifecycle-control-runtime.ts +91 -33
- package/src/sdk/bus/native-runtime-compatibility.ts +1 -1
- package/src/sdk/bus/natives-augment.d.ts +26 -1
- package/src/sdk/bus/notification-service.ts +168 -20
- package/src/sdk/bus/rate-limit-pool.ts +58 -14
- package/src/sdk/bus/recent-activity.ts +188 -74
- package/src/sdk/bus/rich-document.ts +258 -0
- package/src/sdk/bus/rich-render.ts +62 -70
- package/src/sdk/bus/telegram-daemon-cli.ts +15 -59
- package/src/sdk/bus/telegram-daemon-contract.ts +9 -4
- package/src/sdk/bus/telegram-daemon.ts +1491 -247
- package/src/sdk/bus/threaded-inbound.ts +8 -3
- package/src/sdk/bus/threaded-render.ts +57 -1
- package/src/sdk/bus/topic-registry.ts +120 -13
- package/src/sdk/host/control/dispatch.ts +14 -2
- package/src/sdk/host/control/operations.ts +6 -1
- package/src/sdk/host/host.ts +31 -1
- package/src/sdk/host/query/handlers.ts +20 -22
- package/src/sdk/host/query/revision-store.ts +80 -57
- package/src/sdk/index.ts +1 -0
- package/src/sdk/protocol/operation-inventory.generated.json +943 -1907
- package/src/sdk/session-directory.ts +141 -0
- package/src/sdk/session.ts +458 -479
- package/src/session/agent-session.ts +1901 -637
- package/src/session/agent-storage.ts +18 -0
- package/src/session/cache-economics.ts +25 -8
- package/src/session/fallback-chain-controller.ts +53 -3
- package/src/session/internal/managed-session-scope.ts +2226 -0
- package/src/session/internal/managed-session-storage.ts +453 -0
- package/src/session/messages.ts +91 -2
- package/src/session/session-manager-internal.ts +33 -0
- package/src/session/session-manager.ts +1112 -333
- package/src/session/session-storage.ts +444 -39
- package/src/session/volatile-context-pruning.ts +78 -0
- package/src/setup/credential-auto-import.ts +2 -1
- package/src/setup/hermes/templates/operator-instructions.v1.md +1 -1
- package/src/setup/provider-onboarding.ts +36 -61
- package/src/setup/provider-presets.json +5 -0
- package/src/skill-state/active-state.ts +3 -13
- package/src/skill-state/{deep-interview-mutation-guard.ts → workflow-mutation-guard.ts} +47 -29
- package/src/skill-state/workflow-state-contract.ts +6 -41
- package/src/slash-commands/builtin-registry.ts +46 -11
- package/src/system-prompt.ts +37 -44
- package/src/task/agents.ts +19 -1
- package/src/task/executor.ts +90 -6
- package/src/task/index.ts +62 -37
- package/src/thinking.ts +21 -6
- package/src/tool-discovery/tool-index.ts +2 -134
- package/src/tools/ask.ts +157 -21
- package/src/tools/ast-edit.ts +1 -1
- package/src/tools/bash.ts +38 -0
- package/src/tools/browser/dead-tab-recovery.ts +78 -0
- package/src/tools/browser/tab-supervisor.ts +282 -36
- package/src/tools/computer.ts +10 -3
- package/src/tools/cron.ts +30 -16
- package/src/tools/eval.ts +1 -2
- package/src/tools/fetch.ts +11 -4
- package/src/tools/index.ts +76 -24
- package/src/tools/output-meta.ts +13 -5
- package/src/tools/read.ts +37 -1
- package/src/tools/resource-gc.ts +2 -2
- package/src/tools/search-tool-bm25.ts +11 -74
- package/src/tools/subagent-render.ts +19 -8
- package/src/tools/subagent.ts +84 -32
- package/src/utils/event-bus.ts +9 -4
- package/src/web/search/index.ts +3 -3
- package/dist/types/discovery/plugin-dir-roots.d.ts +0 -15
- package/dist/types/runtime-mcp/discoverable-tool-metadata.d.ts +0 -7
- package/src/discovery/plugin-dir-roots.ts +0 -28
- package/src/runtime-mcp/discoverable-tool-metadata.ts +0 -25
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import * as crypto from "node:crypto";
|
|
2
2
|
import * as os from "node:os";
|
|
3
3
|
import * as path from "node:path";
|
|
4
|
-
import { inflateSync } from "node:zlib";
|
|
5
4
|
import type { WorkflowHudSummary } from "../skill-state/active-state";
|
|
6
5
|
import { buildUltragoalHudSummary as buildWorkflowUltragoalHudSummary } from "../skill-state/workflow-hud";
|
|
7
6
|
import { renderCliWriteReceipt } from "./cli-write-receipt";
|
|
@@ -291,18 +290,18 @@ export interface UltragoalCommandResult {
|
|
|
291
290
|
createdPlan?: boolean;
|
|
292
291
|
}
|
|
293
292
|
|
|
294
|
-
interface JsonObject {
|
|
293
|
+
export interface JsonObject {
|
|
295
294
|
[key: string]: unknown;
|
|
296
295
|
}
|
|
297
296
|
|
|
298
|
-
function currentUltragoalSessionId(cwd: string): string {
|
|
297
|
+
export function currentUltragoalSessionId(cwd: string): string {
|
|
299
298
|
return resolveGjcSessionForWrite(cwd, { envSessionId: process.env.GJC_SESSION_ID }).gjcSessionId;
|
|
300
299
|
}
|
|
301
300
|
|
|
302
301
|
const TERMINAL_OR_SKIPPED_STATUSES = new Set<UltragoalGoalStatus>(["complete", "superseded"]);
|
|
303
302
|
const CLEAN_ARCHITECT_STATUS = "CLEAR";
|
|
304
303
|
const APPROVE_RECOMMENDATION = "APPROVE";
|
|
305
|
-
const PASSED_STATUS = "passed";
|
|
304
|
+
export const PASSED_STATUS = "passed";
|
|
306
305
|
const NOT_APPLICABLE_STATUS = "not_applicable";
|
|
307
306
|
const COVERED_STATUS = "covered";
|
|
308
307
|
const ACCEPTED_PROOF_STATUSES = new Set([COVERED_STATUS, "passed", "verified"]);
|
|
@@ -364,13 +363,17 @@ export function getUltragoalPaths(cwd: string, sessionId?: string | null): Ultra
|
|
|
364
363
|
};
|
|
365
364
|
}
|
|
366
365
|
|
|
367
|
-
function isEnoent(error: unknown): boolean {
|
|
366
|
+
export function isEnoent(error: unknown): boolean {
|
|
368
367
|
return (
|
|
369
368
|
typeof error === "object" && error !== null && "code" in error && (error as { code?: unknown }).code === "ENOENT"
|
|
370
369
|
);
|
|
371
370
|
}
|
|
372
371
|
|
|
373
|
-
async function appendLedger(
|
|
372
|
+
export async function appendLedger(
|
|
373
|
+
cwd: string,
|
|
374
|
+
event: JsonObject,
|
|
375
|
+
sessionId?: string | null,
|
|
376
|
+
): Promise<UltragoalLedgerEvent> {
|
|
374
377
|
const resolvedSessionId =
|
|
375
378
|
sessionId?.trim() || resolveGjcSessionForWrite(cwd, { envSessionId: process.env.GJC_SESSION_ID }).gjcSessionId;
|
|
376
379
|
const paths = getUltragoalPaths(cwd, resolvedSessionId);
|
|
@@ -554,7 +557,7 @@ export async function recordUltragoalNudgeIfBudgetRemaining(input: {
|
|
|
554
557
|
);
|
|
555
558
|
}
|
|
556
559
|
|
|
557
|
-
async function writePlan(cwd: string, plan: UltragoalPlan, sessionId?: string | null): Promise<void> {
|
|
560
|
+
export async function writePlan(cwd: string, plan: UltragoalPlan, sessionId?: string | null): Promise<void> {
|
|
558
561
|
const resolvedSessionId =
|
|
559
562
|
sessionId?.trim() || resolveGjcSessionForWrite(cwd, { envSessionId: process.env.GJC_SESSION_ID }).gjcSessionId;
|
|
560
563
|
const paths = getUltragoalPaths(cwd, resolvedSessionId);
|
|
@@ -573,16 +576,32 @@ async function writePlan(cwd: string, plan: UltragoalPlan, sessionId?: string |
|
|
|
573
576
|
|
|
574
577
|
function chooseReceiptKind(
|
|
575
578
|
plan: UltragoalPlan,
|
|
579
|
+
ledger: readonly UltragoalLedgerEvent[],
|
|
576
580
|
goal: UltragoalGoal,
|
|
577
581
|
status: UltragoalGoalStatus,
|
|
578
582
|
): UltragoalReceiptKind {
|
|
579
583
|
if (plan.gjcGoalMode === "per-story") return "per-goal";
|
|
580
584
|
if (status !== "complete") return "per-goal";
|
|
585
|
+
// A non-final validation-batch member must always carry a per-goal
|
|
586
|
+
// deferred receipt; only the batch's final goal may close the batch and
|
|
587
|
+
// (in aggregate mode) carry the final-aggregate receipt. Without this, a
|
|
588
|
+
// context-stale re-verification replay of a member could mint an invalid
|
|
589
|
+
// final-aggregate receipt with validationBatch.role "deferred-member".
|
|
590
|
+
if (goal.validationBatch && goal.validationBatch.finalGoalId !== goal.id) return "per-goal";
|
|
581
591
|
const requiredGoals = requiredUltragoalGoals(plan);
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
)
|
|
585
|
-
|
|
592
|
+
// Only a still-fresh final-aggregate receipt on another goal defers this
|
|
593
|
+
// checkpoint to per-goal. A stale one (e.g. staled by `steer add_subgoal`
|
|
594
|
+
// appending goals after a terminal run) must not suppress re-minting,
|
|
595
|
+
// otherwise the run can never regain a verifiable final-aggregate receipt:
|
|
596
|
+
// the completion guard demands a fresh final-aggregate receipt while this
|
|
597
|
+
// gate would keep answering per-goal forever.
|
|
598
|
+
const existingFreshFinalAggregateGoal = requiredGoals.find(item => {
|
|
599
|
+
if (item.id === goal.id) return false;
|
|
600
|
+
const receipt = item.completionVerification;
|
|
601
|
+
if (receipt?.receiptKind !== "final-aggregate") return false;
|
|
602
|
+
return validateReceiptFreshBase({ plan, ledger, goal: item, receipt, receiptKind: "final-aggregate" }) === null;
|
|
603
|
+
});
|
|
604
|
+
if (existingFreshFinalAggregateGoal) return "per-goal";
|
|
586
605
|
const unfinishedRequiredGoals = requiredGoals.filter(
|
|
587
606
|
item => item.id !== goal.id && !TERMINAL_OR_SKIPPED_STATUSES.has(item.status),
|
|
588
607
|
);
|
|
@@ -673,10 +692,10 @@ function buildCompletionReceipt(input: {
|
|
|
673
692
|
};
|
|
674
693
|
}
|
|
675
694
|
|
|
676
|
-
function nonEmptyString(value: unknown): string | null {
|
|
695
|
+
export function nonEmptyString(value: unknown): string | null {
|
|
677
696
|
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
|
|
678
697
|
}
|
|
679
|
-
function stringArray(value: unknown): string[] | null {
|
|
698
|
+
export function stringArray(value: unknown): string[] | null {
|
|
680
699
|
return Array.isArray(value) && value.every(item => typeof item === "string") ? value.map(item => item.trim()) : null;
|
|
681
700
|
}
|
|
682
701
|
|
|
@@ -726,7 +745,7 @@ function pipelineMetadataHashBasis(metadata: Omit<UltragoalPipelineMetadata, "me
|
|
|
726
745
|
};
|
|
727
746
|
}
|
|
728
747
|
|
|
729
|
-
function hashPipelineMetadata(metadata: Omit<UltragoalPipelineMetadata, "metadataHash">): string {
|
|
748
|
+
export function hashPipelineMetadata(metadata: Omit<UltragoalPipelineMetadata, "metadataHash">): string {
|
|
730
749
|
return hashStructuredValue(pipelineMetadataHashBasis(metadata));
|
|
731
750
|
}
|
|
732
751
|
|
|
@@ -842,7 +861,7 @@ function pipelineMetadataConflictsWithValidationBatch(metadata: UltragoalPipelin
|
|
|
842
861
|
return metadata?.eligible === true || metadata?.source === "original_plan_graph" || metadata?.source === "steering";
|
|
843
862
|
}
|
|
844
863
|
|
|
845
|
-
function validateValidationBatchPipelineExclusion(goal: UltragoalGoal): void {
|
|
864
|
+
export function validateValidationBatchPipelineExclusion(goal: UltragoalGoal): void {
|
|
846
865
|
if (goal.validationBatch && pipelineMetadataConflictsWithValidationBatch(goal.pipelineMetadata)) {
|
|
847
866
|
throw new Error(`Goal ${goal.id} cannot combine validationBatch with eligible pipeline metadata`);
|
|
848
867
|
}
|
|
@@ -1035,28 +1054,28 @@ function normalizePipelineMetadataRecord(value: unknown, goalIds: ReadonlySet<st
|
|
|
1035
1054
|
return withPipelineMetadataHash(basis);
|
|
1036
1055
|
}
|
|
1037
1056
|
|
|
1038
|
-
function targetsAreDisjoint(left: UltragoalPipelineTargets, right: UltragoalPipelineTargets): boolean {
|
|
1057
|
+
export function targetsAreDisjoint(left: UltragoalPipelineTargets, right: UltragoalPipelineTargets): boolean {
|
|
1039
1058
|
return (
|
|
1040
1059
|
left.files.every(file => !right.files.includes(file)) &&
|
|
1041
1060
|
left.surfaces.every(surface => !right.surfaces.includes(surface))
|
|
1042
1061
|
);
|
|
1043
1062
|
}
|
|
1044
1063
|
|
|
1045
|
-
function targetsOverlap(left: UltragoalPipelineTargets, right: UltragoalPipelineTargets): boolean {
|
|
1064
|
+
export function targetsOverlap(left: UltragoalPipelineTargets, right: UltragoalPipelineTargets): boolean {
|
|
1046
1065
|
return (
|
|
1047
1066
|
left.files.some(file => right.files.includes(file)) ||
|
|
1048
1067
|
left.surfaces.some(surface => right.surfaces.includes(surface))
|
|
1049
1068
|
);
|
|
1050
1069
|
}
|
|
1051
1070
|
|
|
1052
|
-
function requireNonEmptyPipelineTargets(value: unknown, fieldName: string): UltragoalPipelineTargets {
|
|
1071
|
+
export function requireNonEmptyPipelineTargets(value: unknown, fieldName: string): UltragoalPipelineTargets {
|
|
1053
1072
|
const targets = normalizePipelineTargets(value, fieldName);
|
|
1054
1073
|
if (targets.files.length === 0 && targets.surfaces.length === 0)
|
|
1055
1074
|
throw new Error(`${fieldName} requires files or surfaces`);
|
|
1056
1075
|
return targets;
|
|
1057
1076
|
}
|
|
1058
1077
|
|
|
1059
|
-
function collectPipelineBlockerFootprints(result: JsonObject, fieldName: string): UltragoalPipelineTargets[] {
|
|
1078
|
+
export function collectPipelineBlockerFootprints(result: JsonObject, fieldName: string): UltragoalPipelineTargets[] {
|
|
1060
1079
|
const raw = Array.isArray(result.blockers)
|
|
1061
1080
|
? result.blockers
|
|
1062
1081
|
: Array.isArray(result.blockerFootprints)
|
|
@@ -1074,12 +1093,12 @@ function pipelineTargetsCoverPath(targets: UltragoalPipelineTargets, filePath: s
|
|
|
1074
1093
|
return targets.files.some(target => normalized === target || normalized.startsWith(`${target}/`));
|
|
1075
1094
|
}
|
|
1076
1095
|
|
|
1077
|
-
function pipelinePeer(plan: UltragoalPlan, metadata: UltragoalPipelineMetadata): UltragoalGoal | undefined {
|
|
1096
|
+
export function pipelinePeer(plan: UltragoalPlan, metadata: UltragoalPipelineMetadata): UltragoalGoal | undefined {
|
|
1078
1097
|
const peerId = metadata.goalId === metadata.priorGoalId ? metadata.nextGoalId : metadata.priorGoalId;
|
|
1079
1098
|
return peerId ? plan.goals.find(goal => goal.id === peerId) : undefined;
|
|
1080
1099
|
}
|
|
1081
1100
|
|
|
1082
|
-
function handleIdsFromValue(value: JsonObject | JsonObject[], fieldName: string): string[] {
|
|
1101
|
+
export function handleIdsFromValue(value: JsonObject | JsonObject[], fieldName: string): string[] {
|
|
1083
1102
|
const records = Array.isArray(value) ? value : [value];
|
|
1084
1103
|
const ids = records.map(
|
|
1085
1104
|
(record, index) =>
|
|
@@ -1092,13 +1111,13 @@ function handleIdsFromValue(value: JsonObject | JsonObject[], fieldName: string)
|
|
|
1092
1111
|
return ids;
|
|
1093
1112
|
}
|
|
1094
1113
|
|
|
1095
|
-
function resultHandleIds(value: JsonObject, fieldName: string): string[] {
|
|
1114
|
+
export function resultHandleIds(value: JsonObject, fieldName: string): string[] {
|
|
1096
1115
|
const ids = stringArray(value.handleIds) ?? stringArray(value.handles) ?? [];
|
|
1097
1116
|
if (ids.length === 0) throw new Error(`${fieldName} requires handleIds`);
|
|
1098
1117
|
return ids;
|
|
1099
1118
|
}
|
|
1100
1119
|
|
|
1101
|
-
function requireCoveredHandles(expected: readonly string[], actual: readonly string[], fieldName: string): void {
|
|
1120
|
+
export function requireCoveredHandles(expected: readonly string[], actual: readonly string[], fieldName: string): void {
|
|
1102
1121
|
const missing = expected.filter(id => !actual.includes(id));
|
|
1103
1122
|
if (missing.length > 0) throw new Error(`${fieldName} is missing handle coverage for ${missing.join(", ")}`);
|
|
1104
1123
|
}
|
|
@@ -1186,7 +1205,7 @@ function currentPipelineHash(metadata: UltragoalPipelineMetadata): string {
|
|
|
1186
1205
|
return hashPipelineMetadata(metadata);
|
|
1187
1206
|
}
|
|
1188
1207
|
|
|
1189
|
-
function requireFreshPipelineMetadata(goal: UltragoalGoal): UltragoalPipelineMetadata {
|
|
1208
|
+
export function requireFreshPipelineMetadata(goal: UltragoalGoal): UltragoalPipelineMetadata {
|
|
1190
1209
|
const metadata = goal.pipelineMetadata;
|
|
1191
1210
|
if (!metadata) throw new Error(`Goal ${goal.id} has no pipeline metadata`);
|
|
1192
1211
|
if (metadata.metadataHash !== currentPipelineHash(metadata))
|
|
@@ -1194,7 +1213,7 @@ function requireFreshPipelineMetadata(goal: UltragoalGoal): UltragoalPipelineMet
|
|
|
1194
1213
|
return metadata;
|
|
1195
1214
|
}
|
|
1196
1215
|
|
|
1197
|
-
function openPipelineOverlap(
|
|
1216
|
+
export function openPipelineOverlap(
|
|
1198
1217
|
plan: UltragoalPlan,
|
|
1199
1218
|
): { prior: UltragoalGoal; next: UltragoalGoal; overlapId: string } | null {
|
|
1200
1219
|
const openGoals = plan.goals.filter(goal => goal.pipelineMetadata?.overlap === "open");
|
|
@@ -1533,14 +1552,14 @@ export interface UltragoalRunCompletionState {
|
|
|
1533
1552
|
hasBlockers: boolean;
|
|
1534
1553
|
needsFinalAggregateReceipt: boolean;
|
|
1535
1554
|
}
|
|
1536
|
-
function requireJsonObjectValue(value: unknown, fieldName: string): JsonObject {
|
|
1555
|
+
export function requireJsonObjectValue(value: unknown, fieldName: string): JsonObject {
|
|
1537
1556
|
if (typeof value !== "object" || value === null || Array.isArray(value))
|
|
1538
1557
|
throw new Error(`${fieldName} must be an object`);
|
|
1539
1558
|
if (Object.keys(value).length === 0) throw new Error(`${fieldName} must be non-empty`);
|
|
1540
1559
|
return value as JsonObject;
|
|
1541
1560
|
}
|
|
1542
1561
|
|
|
1543
|
-
function requireJsonObjectOrArrayValue(value: unknown, fieldName: string): JsonObject | JsonObject[] {
|
|
1562
|
+
export function requireJsonObjectOrArrayValue(value: unknown, fieldName: string): JsonObject | JsonObject[] {
|
|
1544
1563
|
if (Array.isArray(value)) {
|
|
1545
1564
|
if (value.length === 0) throw new Error(`${fieldName} must be non-empty`);
|
|
1546
1565
|
return value.map((item, index) => requireJsonObjectValue(item, `${fieldName}[${index}]`));
|
|
@@ -1560,278 +1579,13 @@ async function readRequiredJsonObjectOrArray(
|
|
|
1560
1579
|
return requireJsonObjectOrArrayValue(await readStructuredValue(cwd, value), fieldName);
|
|
1561
1580
|
}
|
|
1562
1581
|
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
if (prior.status !== "active")
|
|
1569
|
-
throw new Error(`Prior goal ${prior.id} must be active before pipeline overlap starts`);
|
|
1570
|
-
if (next.status !== "pending" && next.status !== "failed")
|
|
1571
|
-
throw new Error(`Next goal ${next.id} must be pending or retryable failed`);
|
|
1572
|
-
validateValidationBatchPipelineExclusion(prior);
|
|
1573
|
-
validateValidationBatchPipelineExclusion(next);
|
|
1574
|
-
if (prior.validationBatch || next.validationBatch)
|
|
1575
|
-
throw new Error("pipeline overlap cannot start for validation batch goals");
|
|
1576
|
-
const priorMetadata = requireFreshPipelineMetadata(prior);
|
|
1577
|
-
const nextMetadata = requireFreshPipelineMetadata(next);
|
|
1578
|
-
if (!priorMetadata.eligible || !nextMetadata.eligible)
|
|
1579
|
-
throw new Error("pipeline overlap requires eligible original-plan metadata on both goals");
|
|
1580
|
-
if (!priorMetadata.independentOf.includes(next.id) || !nextMetadata.independentOf.includes(prior.id)) {
|
|
1581
|
-
throw new Error("pipeline overlap requires symmetric original independence");
|
|
1582
|
-
}
|
|
1583
|
-
if (!targetsAreDisjoint(priorMetadata.targets, nextMetadata.targets))
|
|
1584
|
-
throw new Error("pipeline overlap requires disjoint files and surfaces");
|
|
1585
|
-
}
|
|
1586
|
-
|
|
1587
|
-
function pipelineEventRefs(prior: UltragoalGoal, next: UltragoalGoal): JsonObject {
|
|
1588
|
-
return {
|
|
1589
|
-
priorMetadataHash: prior.pipelineMetadata?.metadataHash,
|
|
1590
|
-
nextMetadataHash: next.pipelineMetadata?.metadataHash,
|
|
1591
|
-
priorTargets: prior.pipelineMetadata?.targets,
|
|
1592
|
-
nextTargets: next.pipelineMetadata?.targets,
|
|
1593
|
-
};
|
|
1594
|
-
}
|
|
1595
|
-
|
|
1596
|
-
function pipelineReceipt(
|
|
1597
|
-
cwd: string,
|
|
1598
|
-
event: string,
|
|
1599
|
-
overlapId: string,
|
|
1600
|
-
prior: UltragoalGoal,
|
|
1601
|
-
next: UltragoalGoal,
|
|
1602
|
-
): UltragoalPipelineOverlapReceipt {
|
|
1603
|
-
const paths = getUltragoalPaths(cwd, currentUltragoalSessionId(cwd));
|
|
1604
|
-
return {
|
|
1605
|
-
ok: true,
|
|
1606
|
-
event,
|
|
1607
|
-
overlap_id: overlapId,
|
|
1608
|
-
prior_goal_id: prior.id,
|
|
1609
|
-
next_goal_id: next.id,
|
|
1610
|
-
status: next.pipelineMetadata?.overlap,
|
|
1611
|
-
next_goal_status: next.status,
|
|
1612
|
-
goals_path: paths.goalsPath,
|
|
1613
|
-
ledger_path: paths.ledgerPath,
|
|
1614
|
-
};
|
|
1615
|
-
}
|
|
1616
|
-
|
|
1617
|
-
export async function startUltragoalPipelineOverlap(input: {
|
|
1618
|
-
cwd: string;
|
|
1619
|
-
priorGoalId: string;
|
|
1620
|
-
nextGoalId: string;
|
|
1621
|
-
reviewHandles: JsonObject | JsonObject[];
|
|
1622
|
-
qaHandles: JsonObject | JsonObject[];
|
|
1623
|
-
implementationHandle: JsonObject;
|
|
1624
|
-
}): Promise<UltragoalPipelineOverlapReceipt> {
|
|
1625
|
-
const plan = await readUltragoalPlan(input.cwd);
|
|
1626
|
-
if (!plan) throw new Error("No ultragoal plan found. Run `gjc ultragoal create-goals --brief ...` first.");
|
|
1627
|
-
const prior = plan.goals.find(goal => goal.id === input.priorGoalId);
|
|
1628
|
-
const next = plan.goals.find(goal => goal.id === input.nextGoalId);
|
|
1629
|
-
if (!prior || !next) throw new Error("start-pipeline-overlap requires existing prior and next goal ids");
|
|
1630
|
-
const reviewHandles = requireJsonObjectOrArrayValue(input.reviewHandles, "review handles");
|
|
1631
|
-
const qaHandles = requireJsonObjectOrArrayValue(input.qaHandles, "QA handles");
|
|
1632
|
-
requireJsonObjectValue(input.implementationHandle, "implementation handle");
|
|
1633
|
-
requirePipelineStartable(plan, prior, next);
|
|
1634
|
-
const now = new Date().toISOString();
|
|
1635
|
-
const overlapId = `pipeline-${crypto.randomUUID()}`;
|
|
1636
|
-
const priorMetadata = requireFreshPipelineMetadata(prior);
|
|
1637
|
-
const nextMetadata = requireFreshPipelineMetadata(next);
|
|
1638
|
-
const priorOpenMetadata = {
|
|
1639
|
-
...priorMetadata,
|
|
1640
|
-
overlap: "open" as const,
|
|
1641
|
-
overlapId,
|
|
1642
|
-
priorGoalId: prior.id,
|
|
1643
|
-
nextGoalId: next.id,
|
|
1644
|
-
};
|
|
1645
|
-
const nextOpenMetadata = {
|
|
1646
|
-
...nextMetadata,
|
|
1647
|
-
overlap: "open" as const,
|
|
1648
|
-
overlapId,
|
|
1649
|
-
priorGoalId: prior.id,
|
|
1650
|
-
nextGoalId: next.id,
|
|
1651
|
-
};
|
|
1652
|
-
prior.pipelineMetadata = { ...priorOpenMetadata, metadataHash: hashPipelineMetadata(priorOpenMetadata) };
|
|
1653
|
-
next.pipelineMetadata = { ...nextOpenMetadata, metadataHash: hashPipelineMetadata(nextOpenMetadata) };
|
|
1654
|
-
prior.updatedAt = now;
|
|
1655
|
-
next.updatedAt = now;
|
|
1656
|
-
next.status = "active";
|
|
1657
|
-
next.startedAt = next.startedAt ?? now;
|
|
1658
|
-
plan.updatedAt = now;
|
|
1659
|
-
await writePlan(input.cwd, plan);
|
|
1660
|
-
const refs = pipelineEventRefs(prior, next);
|
|
1661
|
-
const expectedReviewHandleIds = handleIdsFromValue(reviewHandles, "review");
|
|
1662
|
-
const expectedQaHandleIds = handleIdsFromValue(qaHandles, "QA");
|
|
1663
|
-
await appendLedger(input.cwd, {
|
|
1664
|
-
event: "pipeline_overlap_started",
|
|
1665
|
-
eventId: crypto.randomUUID(),
|
|
1666
|
-
timestamp: now,
|
|
1667
|
-
schemaVersion: 1,
|
|
1668
|
-
overlapId,
|
|
1669
|
-
priorGoalId: prior.id,
|
|
1670
|
-
nextGoalId: next.id,
|
|
1671
|
-
reviewHandles,
|
|
1672
|
-
reviewHandleIds: expectedReviewHandleIds,
|
|
1673
|
-
qaHandles,
|
|
1674
|
-
qaHandleIds: expectedQaHandleIds,
|
|
1675
|
-
implementationHandle: input.implementationHandle,
|
|
1676
|
-
...refs,
|
|
1677
|
-
});
|
|
1678
|
-
await appendLedger(input.cwd, { event: "goal_started", goalId: next.id, pipelineOverlapId: overlapId });
|
|
1679
|
-
return pipelineReceipt(input.cwd, "pipeline_overlap_started", overlapId, prior, next);
|
|
1680
|
-
}
|
|
1681
|
-
|
|
1682
|
-
function resultStatus(value: JsonObject, fieldName: string): string {
|
|
1683
|
-
const status = nonEmptyString(value.status) ?? nonEmptyString(value.verdict) ?? nonEmptyString(value.result);
|
|
1684
|
-
if (!status) throw new Error(`${fieldName} requires status, verdict, or result`);
|
|
1685
|
-
return status.toLowerCase();
|
|
1686
|
-
}
|
|
1687
|
-
|
|
1688
|
-
function requireCleanPipelineResult(result: JsonObject, expectedHandleIds: readonly string[], fieldName: string): void {
|
|
1689
|
-
const status = resultStatus(result, fieldName);
|
|
1690
|
-
if (!["passed", "pass", "approved", "clear"].includes(status)) throw new Error(`${fieldName} did not pass`);
|
|
1691
|
-
const evidence = nonEmptyString(result.evidence);
|
|
1692
|
-
if (!evidence || !isSubstantiveEvidence(evidence)) throw new Error(`${fieldName} requires substantive evidence`);
|
|
1693
|
-
requireCoveredHandles(expectedHandleIds, resultHandleIds(result, fieldName), fieldName);
|
|
1694
|
-
if (collectPipelineBlockerFootprints(result, fieldName).length > 0)
|
|
1695
|
-
throw new Error(`${fieldName} cannot clean-join with blockers`);
|
|
1696
|
-
}
|
|
1697
|
-
|
|
1698
|
-
function pipelineStartEventHandleIds(
|
|
1699
|
-
ledger: UltragoalLedgerEvent[],
|
|
1700
|
-
overlapId: string,
|
|
1701
|
-
): { review: string[]; qa: string[] } {
|
|
1702
|
-
const event = ledger.find(row => row.event === "pipeline_overlap_started" && row.overlapId === overlapId) as
|
|
1703
|
-
| JsonObject
|
|
1704
|
-
| undefined;
|
|
1705
|
-
if (!event) throw new Error(`No pipeline_overlap_started event found for ${overlapId}`);
|
|
1706
|
-
const review =
|
|
1707
|
-
stringArray(event.reviewHandleIds) ??
|
|
1708
|
-
handleIdsFromValue(requireJsonObjectOrArrayValue(event.reviewHandles, "review handles"), "review");
|
|
1709
|
-
const qa =
|
|
1710
|
-
stringArray(event.qaHandleIds) ??
|
|
1711
|
-
handleIdsFromValue(requireJsonObjectOrArrayValue(event.qaHandles, "QA handles"), "QA");
|
|
1712
|
-
return { review, qa };
|
|
1713
|
-
}
|
|
1714
|
-
|
|
1715
|
-
export async function joinUltragoalPipelineOverlap(input: {
|
|
1716
|
-
cwd: string;
|
|
1717
|
-
overlapId: string;
|
|
1718
|
-
reviewResult: JsonObject;
|
|
1719
|
-
qaResult: JsonObject;
|
|
1720
|
-
}): Promise<UltragoalPipelineOverlapReceipt> {
|
|
1721
|
-
const plan = await readUltragoalPlan(input.cwd);
|
|
1722
|
-
if (!plan) throw new Error("No ultragoal plan found. Run `gjc ultragoal create-goals --brief ...` first.");
|
|
1723
|
-
const overlap = openPipelineOverlap(plan);
|
|
1724
|
-
if (!overlap || overlap.overlapId !== input.overlapId)
|
|
1725
|
-
throw new Error(`No open pipeline overlap found for ${input.overlapId}`);
|
|
1726
|
-
const { prior, next, overlapId } = overlap;
|
|
1727
|
-
const nextMetadata = requireFreshPipelineMetadata(next);
|
|
1728
|
-
const ledger = await readUltragoalLedger(input.cwd);
|
|
1729
|
-
const expectedHandles = pipelineStartEventHandleIds(ledger, overlapId);
|
|
1730
|
-
const reviewBlockers = collectPipelineBlockerFootprints(input.reviewResult, "review result");
|
|
1731
|
-
const qaBlockers = collectPipelineBlockerFootprints(input.qaResult, "QA result");
|
|
1732
|
-
const blockerFootprints = [...reviewBlockers, ...qaBlockers];
|
|
1733
|
-
let state: UltragoalPipelineOverlapState;
|
|
1734
|
-
let event: UltragoalPipelineLedgerEventName;
|
|
1735
|
-
if (blockerFootprints.length === 0) {
|
|
1736
|
-
try {
|
|
1737
|
-
requireCleanPipelineResult(input.reviewResult, expectedHandles.review, "review result");
|
|
1738
|
-
requireCleanPipelineResult(input.qaResult, expectedHandles.qa, "QA result");
|
|
1739
|
-
state = "joined_clean";
|
|
1740
|
-
event = "pipeline_overlap_joined";
|
|
1741
|
-
} catch {
|
|
1742
|
-
state = "quarantine_required";
|
|
1743
|
-
event = "pipeline_overlap_quarantined";
|
|
1744
|
-
}
|
|
1745
|
-
} else if (blockerFootprints.every(footprint => !targetsOverlap(footprint, nextMetadata.targets))) {
|
|
1746
|
-
state = "blocked_disjoint_continue";
|
|
1747
|
-
event = "pipeline_overlap_joined";
|
|
1748
|
-
} else {
|
|
1749
|
-
state = "quarantine_required";
|
|
1750
|
-
event = "pipeline_overlap_quarantined";
|
|
1751
|
-
}
|
|
1752
|
-
const now = new Date().toISOString();
|
|
1753
|
-
for (const goal of [prior, next]) {
|
|
1754
|
-
const metadata = requireFreshPipelineMetadata(goal);
|
|
1755
|
-
const joinedMetadata = { ...metadata, overlap: state, blockerFootprints };
|
|
1756
|
-
goal.pipelineMetadata = { ...joinedMetadata, metadataHash: hashPipelineMetadata(joinedMetadata) };
|
|
1757
|
-
goal.updatedAt = now;
|
|
1758
|
-
}
|
|
1759
|
-
if (state === "quarantine_required") next.status = "blocked";
|
|
1760
|
-
plan.updatedAt = now;
|
|
1761
|
-
await writePlan(input.cwd, plan);
|
|
1762
|
-
await appendLedger(input.cwd, {
|
|
1763
|
-
event,
|
|
1764
|
-
eventId: crypto.randomUUID(),
|
|
1765
|
-
timestamp: now,
|
|
1766
|
-
schemaVersion: 1,
|
|
1767
|
-
overlapId,
|
|
1768
|
-
priorGoalId: prior.id,
|
|
1769
|
-
nextGoalId: next.id,
|
|
1770
|
-
status: state,
|
|
1771
|
-
reviewResult: input.reviewResult,
|
|
1772
|
-
qaResult: input.qaResult,
|
|
1773
|
-
blockerFootprints,
|
|
1774
|
-
...pipelineEventRefs(prior, next),
|
|
1775
|
-
});
|
|
1776
|
-
return pipelineReceipt(input.cwd, event, overlapId, prior, next);
|
|
1777
|
-
}
|
|
1582
|
+
import {
|
|
1583
|
+
joinUltragoalPipelineOverlap,
|
|
1584
|
+
rebaselineUltragoalPipelineOverlap,
|
|
1585
|
+
startUltragoalPipelineOverlap,
|
|
1586
|
+
} from "./ultragoal-pipeline";
|
|
1778
1587
|
|
|
1779
|
-
export
|
|
1780
|
-
cwd: string;
|
|
1781
|
-
overlapId: string;
|
|
1782
|
-
goalId: string;
|
|
1783
|
-
evidence: string;
|
|
1784
|
-
targetState: JsonObject;
|
|
1785
|
-
}): Promise<UltragoalPipelineOverlapReceipt> {
|
|
1786
|
-
const plan = await readUltragoalPlan(input.cwd);
|
|
1787
|
-
if (!plan) throw new Error("No ultragoal plan found. Run `gjc ultragoal create-goals --brief ...` first.");
|
|
1788
|
-
const goal = plan.goals.find(item => item.id === input.goalId);
|
|
1789
|
-
if (!goal) throw new Error(`No ultragoal goal found for ${input.goalId}.`);
|
|
1790
|
-
const evidence = input.evidence.trim();
|
|
1791
|
-
if (!isSubstantiveEvidence(evidence)) throw new Error("rebaseline-pipeline-overlap requires substantive evidence");
|
|
1792
|
-
const targetState = requireNonEmptyPipelineTargets(input.targetState, "target state");
|
|
1793
|
-
const metadata = requireFreshPipelineMetadata(goal);
|
|
1794
|
-
if (metadata.overlap !== "quarantine_required" || metadata.overlapId !== input.overlapId) {
|
|
1795
|
-
throw new Error(`Goal ${goal.id} is not quarantined for overlap ${input.overlapId}`);
|
|
1796
|
-
}
|
|
1797
|
-
for (const footprint of metadata.blockerFootprints ?? []) {
|
|
1798
|
-
if (targetsOverlap(footprint, targetState))
|
|
1799
|
-
throw new Error("rebaseline-pipeline-overlap target state overlaps unresolved blocker footprints");
|
|
1800
|
-
}
|
|
1801
|
-
const now = new Date().toISOString();
|
|
1802
|
-
const rebaselinedMetadata = { ...metadata, overlap: "rebaseline_complete" as const, targets: targetState };
|
|
1803
|
-
goal.pipelineMetadata = { ...rebaselinedMetadata, metadataHash: hashPipelineMetadata(rebaselinedMetadata) };
|
|
1804
|
-
goal.status = "active";
|
|
1805
|
-
goal.evidence = evidence;
|
|
1806
|
-
goal.updatedAt = now;
|
|
1807
|
-
plan.updatedAt = now;
|
|
1808
|
-
await writePlan(input.cwd, plan);
|
|
1809
|
-
const peer = pipelinePeer(plan, metadata);
|
|
1810
|
-
await appendLedger(input.cwd, {
|
|
1811
|
-
event: "pipeline_overlap_rebaselined",
|
|
1812
|
-
eventId: crypto.randomUUID(),
|
|
1813
|
-
timestamp: now,
|
|
1814
|
-
schemaVersion: 1,
|
|
1815
|
-
overlapId: input.overlapId,
|
|
1816
|
-
priorGoalId: metadata.priorGoalId ?? peer?.id ?? "",
|
|
1817
|
-
nextGoalId: metadata.nextGoalId ?? goal.id,
|
|
1818
|
-
goalId: goal.id,
|
|
1819
|
-
evidence,
|
|
1820
|
-
targetState,
|
|
1821
|
-
metadataHash: goal.pipelineMetadata.metadataHash,
|
|
1822
|
-
});
|
|
1823
|
-
const paths = getUltragoalPaths(input.cwd, currentUltragoalSessionId(input.cwd));
|
|
1824
|
-
return {
|
|
1825
|
-
ok: true,
|
|
1826
|
-
event: "pipeline_overlap_rebaselined",
|
|
1827
|
-
overlap_id: input.overlapId,
|
|
1828
|
-
prior_goal_id: metadata.priorGoalId ?? peer?.id ?? "",
|
|
1829
|
-
goal_id: goal.id,
|
|
1830
|
-
status: goal.pipelineMetadata.overlap,
|
|
1831
|
-
goals_path: paths.goalsPath,
|
|
1832
|
-
ledger_path: paths.ledgerPath,
|
|
1833
|
-
};
|
|
1834
|
-
}
|
|
1588
|
+
export { joinUltragoalPipelineOverlap, rebaselineUltragoalPipelineOverlap, startUltragoalPipelineOverlap };
|
|
1835
1589
|
|
|
1836
1590
|
export function getUltragoalRunCompletionState(
|
|
1837
1591
|
plan: UltragoalPlan,
|
|
@@ -1886,11 +1640,11 @@ async function readStructuredValue(cwd: string, value: string): Promise<unknown>
|
|
|
1886
1640
|
throw error;
|
|
1887
1641
|
}
|
|
1888
1642
|
}
|
|
1889
|
-
function qualityGateObject(value: unknown): JsonObject | null {
|
|
1643
|
+
export function qualityGateObject(value: unknown): JsonObject | null {
|
|
1890
1644
|
return typeof value === "object" && value !== null && !Array.isArray(value) ? (value as JsonObject) : null;
|
|
1891
1645
|
}
|
|
1892
1646
|
|
|
1893
|
-
function nonEmptyStringArray(value: unknown): string[] | null {
|
|
1647
|
+
export function nonEmptyStringArray(value: unknown): string[] | null {
|
|
1894
1648
|
if (!Array.isArray(value)) return null;
|
|
1895
1649
|
const strings = value.filter(item => typeof item === "string" && item.trim().length > 0);
|
|
1896
1650
|
return strings.length === value.length && strings.length > 0 ? strings : null;
|
|
@@ -1907,20 +1661,20 @@ function requireEmptyBlockers(value: unknown, fieldName: string): void {
|
|
|
1907
1661
|
throw new Error(`qualityGate ${fieldName} must be an empty blockers array`);
|
|
1908
1662
|
}
|
|
1909
1663
|
}
|
|
1910
|
-
function requireQualityGateObject(value: unknown, fieldName: string): JsonObject {
|
|
1664
|
+
export function requireQualityGateObject(value: unknown, fieldName: string): JsonObject {
|
|
1911
1665
|
const object = qualityGateObject(value);
|
|
1912
1666
|
if (!object) throw new Error(`qualityGate ${fieldName} must be an object`);
|
|
1913
1667
|
return object;
|
|
1914
1668
|
}
|
|
1915
1669
|
|
|
1916
|
-
function requireObjectArray(value: unknown, fieldName: string): JsonObject[] {
|
|
1670
|
+
export function requireObjectArray(value: unknown, fieldName: string): JsonObject[] {
|
|
1917
1671
|
if (!Array.isArray(value) || value.length === 0) {
|
|
1918
1672
|
throw new Error(`qualityGate ${fieldName} must be a non-empty object array`);
|
|
1919
1673
|
}
|
|
1920
1674
|
return value.map((item, index) => requireQualityGateObject(item, `${fieldName}[${index}]`));
|
|
1921
1675
|
}
|
|
1922
1676
|
|
|
1923
|
-
function requiredStringField(row: JsonObject, key: string, fieldName: string): string {
|
|
1677
|
+
export function requiredStringField(row: JsonObject, key: string, fieldName: string): string {
|
|
1924
1678
|
const value = row[key];
|
|
1925
1679
|
if (typeof value !== "string" || value.trim().length === 0) {
|
|
1926
1680
|
const hint =
|
|
@@ -1969,7 +1723,7 @@ function requireSuccessfulRowOutcome(row: JsonObject, fieldName: string): void {
|
|
|
1969
1723
|
}
|
|
1970
1724
|
}
|
|
1971
1725
|
|
|
1972
|
-
function requireStringLinks(value: unknown, fieldName: string): string[] {
|
|
1726
|
+
export function requireStringLinks(value: unknown, fieldName: string): string[] {
|
|
1973
1727
|
const strings = nonEmptyStringArray(value);
|
|
1974
1728
|
if (!strings) throw new Error(`qualityGate ${fieldName} must be a non-empty string array`);
|
|
1975
1729
|
return strings.map(item => item.trim());
|
|
@@ -1990,7 +1744,7 @@ function buildRowIdMap(rows: JsonObject[], fieldName: string): Map<string, JsonO
|
|
|
1990
1744
|
return ids;
|
|
1991
1745
|
}
|
|
1992
1746
|
|
|
1993
|
-
function requireResolvedLinks(ids: string[], map: Map<string, JsonObject>, fieldName: string): void {
|
|
1747
|
+
export function requireResolvedLinks(ids: string[], map: Map<string, JsonObject>, fieldName: string): void {
|
|
1994
1748
|
for (const id of ids) {
|
|
1995
1749
|
if (!map.has(id)) throw new Error(`qualityGate ${fieldName} references unknown id ${id}`);
|
|
1996
1750
|
}
|
|
@@ -2006,11 +1760,11 @@ function successfulLinkedRows(ids: string[], map: Map<string, JsonObject>, field
|
|
|
2006
1760
|
return rows;
|
|
2007
1761
|
}
|
|
2008
1762
|
|
|
2009
|
-
function normalizedEvidenceKind(row: JsonObject): string {
|
|
1763
|
+
export function normalizedEvidenceKind(row: JsonObject): string {
|
|
2010
1764
|
return requiredStringField(row, "kind", "executorQa.artifactRefs[]").toLowerCase().replaceAll("_", "-");
|
|
2011
1765
|
}
|
|
2012
1766
|
|
|
2013
|
-
function evidenceKindMatches(kind: string, words: string[]): boolean {
|
|
1767
|
+
export function evidenceKindMatches(kind: string, words: string[]): boolean {
|
|
2014
1768
|
return words.some(word => kind.includes(word));
|
|
2015
1769
|
}
|
|
2016
1770
|
function formatActualArtifactKinds(artifactIds: string[], kinds: string[]): string {
|
|
@@ -2022,10 +1776,10 @@ function formatExpectedKindWords(words: string[]): string {
|
|
|
2022
1776
|
return words.map(word => `"${word}"`).join(", ");
|
|
2023
1777
|
}
|
|
2024
1778
|
|
|
2025
|
-
type SurfaceFamily = "web" | "cli" | "native" | "api-package" | "algorithm-math" | "unknown";
|
|
1779
|
+
export type SurfaceFamily = "web" | "cli" | "native" | "api-package" | "algorithm-math" | "unknown";
|
|
2026
1780
|
|
|
2027
|
-
type UltragoalChangeStatus = "added" | "modified" | "deleted" | "renamed" | "copied" | "unknown";
|
|
2028
|
-
type UltragoalChangeCategory =
|
|
1781
|
+
export type UltragoalChangeStatus = "added" | "modified" | "deleted" | "renamed" | "copied" | "unknown";
|
|
1782
|
+
export type UltragoalChangeCategory =
|
|
2029
1783
|
| "code"
|
|
2030
1784
|
| "generated-binding"
|
|
2031
1785
|
| "tool"
|
|
@@ -2033,13 +1787,13 @@ type UltragoalChangeCategory =
|
|
|
2033
1787
|
| "prompt-doc-behavior"
|
|
2034
1788
|
| "docs-static"
|
|
2035
1789
|
| "other";
|
|
2036
|
-
interface UltragoalChangeSetPath extends JsonObject {
|
|
1790
|
+
export interface UltragoalChangeSetPath extends JsonObject {
|
|
2037
1791
|
path: string;
|
|
2038
1792
|
status: UltragoalChangeStatus;
|
|
2039
1793
|
oldPath?: string;
|
|
2040
1794
|
category?: UltragoalChangeCategory;
|
|
2041
1795
|
}
|
|
2042
|
-
interface UltragoalChangeSet extends JsonObject {
|
|
1796
|
+
export interface UltragoalChangeSet extends JsonObject {
|
|
2043
1797
|
source: "checkpoint-git" | "review-pr" | "review-branch" | "review-worktree" | "review-spec";
|
|
2044
1798
|
baseRef?: string;
|
|
2045
1799
|
headRef?: string;
|
|
@@ -2061,7 +1815,7 @@ const MANDATORY_COMPUTER_CASE_IDS = [
|
|
|
2061
1815
|
] as const;
|
|
2062
1816
|
const TOOLS_INDEX_PATH = "packages/coding-agent/src/tools/index.ts";
|
|
2063
1817
|
|
|
2064
|
-
function normalizeRepoPath(value: string): string {
|
|
1818
|
+
export function normalizeRepoPath(value: string): string {
|
|
2065
1819
|
return value.replaceAll("\\\\", "/").replace(/^\.\//, "");
|
|
2066
1820
|
}
|
|
2067
1821
|
|
|
@@ -2069,7 +1823,7 @@ function isToolsIndexPath(value: string): boolean {
|
|
|
2069
1823
|
return normalizeRepoPath(value) === TOOLS_INDEX_PATH;
|
|
2070
1824
|
}
|
|
2071
1825
|
|
|
2072
|
-
function categorizeComputerChangePath(value: string): UltragoalChangeCategory {
|
|
1826
|
+
export function categorizeComputerChangePath(value: string): UltragoalChangeCategory {
|
|
2073
1827
|
const normalized = normalizeRepoPath(value);
|
|
2074
1828
|
if (normalized.startsWith("crates/pi-natives/src/computer/")) return "code";
|
|
2075
1829
|
if (/^packages\/natives\/native\/index\.(?:d\.ts|js)$/.test(normalized)) return "generated-binding";
|
|
@@ -2137,7 +1891,7 @@ function isComputerSpecificToolsIndexDiff(diff: string | undefined, targetPath:
|
|
|
2137
1891
|
/** Settings registry file that holds ALL settings, most of them unrelated to computer control. */
|
|
2138
1892
|
const SETTINGS_SCHEMA_PATH = "packages/coding-agent/src/config/settings-schema.ts";
|
|
2139
1893
|
|
|
2140
|
-
function isSettingsSchemaPath(value: string): boolean {
|
|
1894
|
+
export function isSettingsSchemaPath(value: string): boolean {
|
|
2141
1895
|
return normalizeRepoPath(value) === SETTINGS_SCHEMA_PATH;
|
|
2142
1896
|
}
|
|
2143
1897
|
|
|
@@ -2229,7 +1983,7 @@ export function surfaceFamily(value: string): SurfaceFamily {
|
|
|
2229
1983
|
return "unknown";
|
|
2230
1984
|
}
|
|
2231
1985
|
|
|
2232
|
-
function isLiveSurfaceFamily(family: SurfaceFamily): boolean {
|
|
1986
|
+
export function isLiveSurfaceFamily(family: SurfaceFamily): boolean {
|
|
2233
1987
|
return family === "web" || family === "cli" || family === "native";
|
|
2234
1988
|
}
|
|
2235
1989
|
|
|
@@ -2281,7 +2035,7 @@ function validateSurfaceArtifactCompatibility(
|
|
|
2281
2035
|
}
|
|
2282
2036
|
}
|
|
2283
2037
|
|
|
2284
|
-
function isSubstantiveEvidence(value: unknown): boolean {
|
|
2038
|
+
export function isSubstantiveEvidence(value: unknown): boolean {
|
|
2285
2039
|
if (typeof value !== "string") return false;
|
|
2286
2040
|
const trimmed = value.trim();
|
|
2287
2041
|
if (trimmed.length < MIN_SUBSTANTIVE_EVIDENCE_CHARS) return false;
|
|
@@ -2291,7 +2045,7 @@ function isSubstantiveEvidence(value: unknown): boolean {
|
|
|
2291
2045
|
return !["todo", "tbd", "n/a", "na", "none", "placeholder", "empty", "stub"].includes(normalized);
|
|
2292
2046
|
}
|
|
2293
2047
|
|
|
2294
|
-
function hasTypedVerifiedReceipt(value: unknown): boolean {
|
|
2048
|
+
export function hasTypedVerifiedReceipt(value: unknown): boolean {
|
|
2295
2049
|
const receipt = qualityGateObject(value);
|
|
2296
2050
|
if (!receipt) return false;
|
|
2297
2051
|
const type = nonEmptyString(receipt.type) ?? nonEmptyString(receipt.kind) ?? nonEmptyString(receipt.receiptType);
|
|
@@ -2300,7 +2054,7 @@ function hasTypedVerifiedReceipt(value: unknown): boolean {
|
|
|
2300
2054
|
return Boolean(type && id && (status === "verified" || status === "passed"));
|
|
2301
2055
|
}
|
|
2302
2056
|
|
|
2303
|
-
async function hasExistingNonEmptyArtifact(cwd: string, value: unknown): Promise<boolean> {
|
|
2057
|
+
export async function hasExistingNonEmptyArtifact(cwd: string, value: unknown): Promise<boolean> {
|
|
2304
2058
|
const artifactPath = nonEmptyString(value);
|
|
2305
2059
|
if (!artifactPath) return false;
|
|
2306
2060
|
const resolved = path.resolve(cwd, artifactPath);
|
|
@@ -2313,7 +2067,7 @@ async function hasExistingNonEmptyArtifact(cwd: string, value: unknown): Promise
|
|
|
2313
2067
|
}
|
|
2314
2068
|
}
|
|
2315
2069
|
|
|
2316
|
-
async function readArtifactBytes(cwd: string, row: JsonObject, fieldName: string): Promise<Buffer | null> {
|
|
2070
|
+
export async function readArtifactBytes(cwd: string, row: JsonObject, fieldName: string): Promise<Buffer | null> {
|
|
2317
2071
|
const artifactPath = nonEmptyString(row.path);
|
|
2318
2072
|
if (!artifactPath) return null;
|
|
2319
2073
|
const resolved = path.resolve(cwd, artifactPath);
|
|
@@ -2327,839 +2081,27 @@ async function readArtifactBytes(cwd: string, row: JsonObject, fieldName: string
|
|
|
2327
2081
|
}
|
|
2328
2082
|
}
|
|
2329
2083
|
|
|
2330
|
-
|
|
2331
|
-
|
|
2332
|
-
|
|
2333
|
-
|
|
2334
|
-
|
|
2335
|
-
|
|
2336
|
-
|
|
2337
|
-
|
|
2338
|
-
|
|
2339
|
-
}
|
|
2340
|
-
|
|
2341
|
-
|
|
2342
|
-
|
|
2343
|
-
|
|
2344
|
-
|
|
2345
|
-
|
|
2346
|
-
|
|
2347
|
-
|
|
2348
|
-
|
|
2349
|
-
|
|
2350
|
-
|
|
2351
|
-
if (!bytes.subarray(0, 8).equals(PNG_SIGNATURE)) return null;
|
|
2352
|
-
let offset = 8;
|
|
2353
|
-
let width = 0;
|
|
2354
|
-
let height = 0;
|
|
2355
|
-
let sawIhdr = false;
|
|
2356
|
-
let sawIdat = false;
|
|
2357
|
-
const idatChunks: Buffer[] = [];
|
|
2358
|
-
while (offset + 12 <= bytes.length) {
|
|
2359
|
-
const chunkStart = offset;
|
|
2360
|
-
const length = bytes.readUInt32BE(offset);
|
|
2361
|
-
offset += 4;
|
|
2362
|
-
const type = bytes.toString("ascii", offset, offset + 4);
|
|
2363
|
-
offset += 4;
|
|
2364
|
-
if (offset + length + 4 > bytes.length) return null;
|
|
2365
|
-
const data = bytes.subarray(offset, offset + length);
|
|
2366
|
-
offset += length;
|
|
2367
|
-
const expectedCrc = bytes.readUInt32BE(offset);
|
|
2368
|
-
offset += 4;
|
|
2369
|
-
if (pngCrc32(bytes.subarray(chunkStart + 4, offset - 4)) !== expectedCrc) return null;
|
|
2370
|
-
if (!sawIhdr) {
|
|
2371
|
-
if (type !== "IHDR" || length !== 13) return null;
|
|
2372
|
-
width = data.readUInt32BE(0);
|
|
2373
|
-
height = data.readUInt32BE(4);
|
|
2374
|
-
if (
|
|
2375
|
-
width === 0 ||
|
|
2376
|
-
height === 0 ||
|
|
2377
|
-
data[8] !== 8 ||
|
|
2378
|
-
![2, 6].includes(data[9]!) ||
|
|
2379
|
-
data[10] !== 0 ||
|
|
2380
|
-
data[11] !== 0 ||
|
|
2381
|
-
data[12] !== 0
|
|
2382
|
-
)
|
|
2383
|
-
return null;
|
|
2384
|
-
sawIhdr = true;
|
|
2385
|
-
} else if (type === "IHDR") return null;
|
|
2386
|
-
if (type === "IDAT") {
|
|
2387
|
-
if (!sawIhdr || length === 0) return null;
|
|
2388
|
-
sawIdat = true;
|
|
2389
|
-
idatChunks.push(data);
|
|
2390
|
-
}
|
|
2391
|
-
if (type === "IEND") {
|
|
2392
|
-
if (length !== 0 || !sawIhdr || !sawIdat || offset !== bytes.length) return null;
|
|
2393
|
-
try {
|
|
2394
|
-
return { width, height, headerBytes: 8, sampleBytes: inflateSync(Buffer.concat(idatChunks)) };
|
|
2395
|
-
} catch {
|
|
2396
|
-
return null;
|
|
2397
|
-
}
|
|
2398
|
-
}
|
|
2399
|
-
}
|
|
2400
|
-
return null;
|
|
2401
|
-
}
|
|
2402
|
-
|
|
2403
|
-
function parseJpegDimensions(
|
|
2404
|
-
bytes: Buffer,
|
|
2405
|
-
): { width: number; height: number; headerBytes: number; sampleBytes?: Buffer } | null {
|
|
2406
|
-
if (bytes.length < 8 || bytes[0] !== 0xff || bytes[1] !== JPEG_START_OF_IMAGE) return null;
|
|
2407
|
-
let offset = 2;
|
|
2408
|
-
let dimensions: { width: number; height: number; headerBytes: number } | null = null;
|
|
2409
|
-
let sawStartOfScan = false;
|
|
2410
|
-
let scanStart = -1;
|
|
2411
|
-
while (offset < bytes.length) {
|
|
2412
|
-
if (bytes[offset] !== 0xff) return null;
|
|
2413
|
-
while (offset < bytes.length && bytes[offset] === 0xff) offset++;
|
|
2414
|
-
if (offset >= bytes.length) return null;
|
|
2415
|
-
const marker = bytes[offset++];
|
|
2416
|
-
if (marker === 0x00) return null;
|
|
2417
|
-
if (marker === JPEG_END_OF_IMAGE) return null;
|
|
2418
|
-
if (JPEG_STANDALONE_MARKERS.has(marker)) continue;
|
|
2419
|
-
if (offset + 2 > bytes.length) return null;
|
|
2420
|
-
const segmentLength = bytes.readUInt16BE(offset);
|
|
2421
|
-
if (segmentLength < 2 || offset + segmentLength > bytes.length) return null;
|
|
2422
|
-
const segmentDataEnd = offset + segmentLength;
|
|
2423
|
-
if (marker === 0xc0 || marker === 0xc1 || marker === 0xc2) {
|
|
2424
|
-
if (segmentLength < 8) return null;
|
|
2425
|
-
dimensions = {
|
|
2426
|
-
width: bytes.readUInt16BE(offset + 5),
|
|
2427
|
-
height: bytes.readUInt16BE(offset + 3),
|
|
2428
|
-
headerBytes: offset + segmentLength,
|
|
2429
|
-
};
|
|
2430
|
-
}
|
|
2431
|
-
if (marker === JPEG_START_OF_SCAN) {
|
|
2432
|
-
if (!dimensions || segmentDataEnd >= bytes.length) return null;
|
|
2433
|
-
sawStartOfScan = true;
|
|
2434
|
-
scanStart = segmentDataEnd;
|
|
2435
|
-
break;
|
|
2436
|
-
}
|
|
2437
|
-
offset += segmentLength;
|
|
2438
|
-
}
|
|
2439
|
-
if (!dimensions || !sawStartOfScan || scanStart < 0) return null;
|
|
2440
|
-
let scanOffset = scanStart;
|
|
2441
|
-
let entropyBytes = 0;
|
|
2442
|
-
while (scanOffset < bytes.length) {
|
|
2443
|
-
const byte = bytes[scanOffset++]!;
|
|
2444
|
-
if (byte !== 0xff) {
|
|
2445
|
-
entropyBytes++;
|
|
2446
|
-
continue;
|
|
2447
|
-
}
|
|
2448
|
-
if (scanOffset >= bytes.length) return null;
|
|
2449
|
-
const marker = bytes[scanOffset++]!;
|
|
2450
|
-
if (marker === 0x00) {
|
|
2451
|
-
entropyBytes++;
|
|
2452
|
-
continue;
|
|
2453
|
-
}
|
|
2454
|
-
if (JPEG_STANDALONE_MARKERS.has(marker)) continue;
|
|
2455
|
-
if (marker === JPEG_END_OF_IMAGE) {
|
|
2456
|
-
if (scanOffset !== bytes.length || entropyBytes < 32) return null;
|
|
2457
|
-
return { ...dimensions, sampleBytes: bytes.subarray(scanStart, scanOffset - 2) };
|
|
2458
|
-
}
|
|
2459
|
-
return null;
|
|
2460
|
-
}
|
|
2461
|
-
return null;
|
|
2462
|
-
}
|
|
2463
|
-
|
|
2464
|
-
function unsupportedScreenshotFormat(bytes: Buffer): string | null {
|
|
2465
|
-
if (bytes.toString("ascii", 0, 6) === "GIF87a" || bytes.toString("ascii", 0, 6) === "GIF89a") return "GIF";
|
|
2466
|
-
if (bytes.toString("ascii", 0, 2) === "BM") return "BMP";
|
|
2467
|
-
if (bytes.length >= 12 && bytes.toString("ascii", 0, 4) === "RIFF" && bytes.toString("ascii", 8, 12) === "WEBP")
|
|
2468
|
-
return "WebP";
|
|
2469
|
-
return null;
|
|
2470
|
-
}
|
|
2471
|
-
|
|
2472
|
-
function parseImageDimensions(
|
|
2473
|
-
bytes: Buffer,
|
|
2474
|
-
): { width: number; height: number; headerBytes: number; sampleBytes?: Buffer } | null {
|
|
2475
|
-
return parsePngDimensions(bytes) ?? parseJpegDimensions(bytes);
|
|
2476
|
-
}
|
|
2477
|
-
|
|
2478
|
-
function hasNonUniformImageBytes(bytes: Buffer, headerBytes: number, sampleBytes?: Buffer): boolean {
|
|
2479
|
-
const source = sampleBytes ?? bytes;
|
|
2480
|
-
const sampleStart = sampleBytes ? 0 : Math.min(Math.max(headerBytes, 0), source.length);
|
|
2481
|
-
const sampleLength = source.length - sampleStart;
|
|
2482
|
-
if (sampleLength < 32) return false;
|
|
2483
|
-
const windows: Buffer[] = [];
|
|
2484
|
-
for (let index = 0; index < 64; index++) {
|
|
2485
|
-
const offset = sampleStart + Math.floor(((sampleLength - 32) * index) / 63);
|
|
2486
|
-
windows.push(source.subarray(offset, offset + 32));
|
|
2487
|
-
}
|
|
2488
|
-
const byteCounts = new Map<number, number>();
|
|
2489
|
-
let total = 0;
|
|
2490
|
-
for (const window of windows) {
|
|
2491
|
-
for (const byte of window) {
|
|
2492
|
-
byteCounts.set(byte, (byteCounts.get(byte) ?? 0) + 1);
|
|
2493
|
-
total++;
|
|
2494
|
-
}
|
|
2495
|
-
}
|
|
2496
|
-
const first = windows[0]!;
|
|
2497
|
-
const differingWindows = windows.slice(1).filter(window => !window.equals(first)).length;
|
|
2498
|
-
const maxCount = Math.max(...byteCounts.values());
|
|
2499
|
-
return byteCounts.size >= 16 && differingWindows >= 8 && maxCount / total <= 0.95;
|
|
2500
|
-
}
|
|
2501
|
-
|
|
2502
|
-
async function validateScreenshotArtifact(cwd: string, row: JsonObject, fieldName: string): Promise<boolean> {
|
|
2503
|
-
const bytes = await readArtifactBytes(cwd, row, fieldName);
|
|
2504
|
-
if (!bytes) throw new Error(`qualityGate ${fieldName} screenshot artifact path must resolve to an existing file`);
|
|
2505
|
-
if (bytes.length < 4096) throw new Error(`qualityGate ${fieldName} screenshot artifact must be at least 4096 bytes`);
|
|
2506
|
-
const unsupportedFormat = unsupportedScreenshotFormat(bytes);
|
|
2507
|
-
if (unsupportedFormat) {
|
|
2508
|
-
throw new Error(
|
|
2509
|
-
`qualityGate ${fieldName} unsupported/undecodable screenshot format ${unsupportedFormat}; use PNG or fully marker-validated JPEG`,
|
|
2510
|
-
);
|
|
2511
|
-
}
|
|
2512
|
-
const dimensions = parseImageDimensions(bytes);
|
|
2513
|
-
if (!dimensions)
|
|
2514
|
-
throw new Error(`qualityGate ${fieldName} screenshot artifact must be a decodable PNG or JPEG image`);
|
|
2515
|
-
if (dimensions.width < 320 || dimensions.height < 180) {
|
|
2516
|
-
throw new Error(`qualityGate ${fieldName} screenshot artifact must be at least 320x180 pixels`);
|
|
2517
|
-
}
|
|
2518
|
-
if (!hasNonUniformImageBytes(bytes, dimensions.headerBytes, dimensions.sampleBytes)) {
|
|
2519
|
-
throw new Error(
|
|
2520
|
-
`qualityGate ${fieldName} screenshot artifact must be non-uniform, not blank, solid, tiny, or placeholder imagery`,
|
|
2521
|
-
);
|
|
2522
|
-
}
|
|
2523
|
-
return true;
|
|
2524
|
-
}
|
|
2525
|
-
|
|
2526
|
-
function normalizeTranscriptTimestamp(value: unknown): number | null {
|
|
2527
|
-
if (typeof value === "number" && Number.isFinite(value)) return value;
|
|
2528
|
-
if (typeof value !== "string" || value.trim().length === 0) return null;
|
|
2529
|
-
const numeric = Number(value);
|
|
2530
|
-
if (Number.isFinite(numeric)) return numeric;
|
|
2531
|
-
const parsed = Date.parse(value);
|
|
2532
|
-
return Number.isFinite(parsed) ? parsed : null;
|
|
2533
|
-
}
|
|
2534
|
-
|
|
2535
|
-
function transcriptSurfaceCompatible(value: unknown, family: SurfaceFamily): boolean {
|
|
2536
|
-
const surface = nonEmptyString(value);
|
|
2537
|
-
return !surface || family === "unknown" || surfaceFamily(surface) === family;
|
|
2538
|
-
}
|
|
2539
|
-
|
|
2540
|
-
function actionSelectorRequired(type: string): boolean {
|
|
2541
|
-
return ["click", "fill", "press", "assert", "screenshot", "observe"].includes(type);
|
|
2542
|
-
}
|
|
2543
|
-
|
|
2544
|
-
async function validateAutomationTranscriptArtifact(
|
|
2545
|
-
cwd: string,
|
|
2546
|
-
row: JsonObject,
|
|
2547
|
-
fieldName: string,
|
|
2548
|
-
options: { surfaceFamily: SurfaceFamily },
|
|
2549
|
-
): Promise<boolean> {
|
|
2550
|
-
const bytes = await readArtifactBytes(cwd, row, fieldName);
|
|
2551
|
-
if (!bytes) throw new Error(`qualityGate ${fieldName} automation transcript path must resolve to an existing file`);
|
|
2552
|
-
let transcript: JsonObject;
|
|
2553
|
-
try {
|
|
2554
|
-
const parsed = JSON.parse(bytes.toString("utf8"));
|
|
2555
|
-
transcript = requireQualityGateObject(parsed, `${fieldName}.transcript`);
|
|
2556
|
-
} catch (error) {
|
|
2557
|
-
throw new Error(`qualityGate ${fieldName} automation transcript must be valid JSON: ${String(error)}`);
|
|
2558
|
-
}
|
|
2559
|
-
if (transcript.schemaVersion !== 1)
|
|
2560
|
-
throw new Error(`qualityGate ${fieldName} automation transcript schemaVersion must be 1`);
|
|
2561
|
-
if (!transcriptSurfaceCompatible(transcript.surface, options.surfaceFamily)) {
|
|
2562
|
-
throw new Error(
|
|
2563
|
-
`qualityGate ${fieldName} automation transcript surface is not compatible with ${options.surfaceFamily}`,
|
|
2564
|
-
);
|
|
2565
|
-
}
|
|
2566
|
-
if (!nonEmptyString(transcript.tool))
|
|
2567
|
-
throw new Error(`qualityGate ${fieldName} automation transcript tool must be non-empty`);
|
|
2568
|
-
const actions = requireObjectArray(transcript.actions, `${fieldName}.actions`);
|
|
2569
|
-
if (actions.length < 1) throw new Error(`qualityGate ${fieldName} automation transcript actions must be non-empty`);
|
|
2570
|
-
const assertionsValue = transcript.assertions;
|
|
2571
|
-
const assertions =
|
|
2572
|
-
assertionsValue === undefined ? [] : requireObjectArray(assertionsValue, `${fieldName}.assertions`);
|
|
2573
|
-
const timestamps: number[] = [];
|
|
2574
|
-
let hasSelectorBearingEntry = false;
|
|
2575
|
-
for (const [index, action] of actions.entries()) {
|
|
2576
|
-
const actionField = `${fieldName}.actions[${index}]`;
|
|
2577
|
-
const type = requiredStringField(action, "type", actionField).toLowerCase();
|
|
2578
|
-
const timestamp = normalizeTranscriptTimestamp(action.timestamp);
|
|
2579
|
-
if (timestamp === null) throw new Error(`qualityGate ${actionField}.timestamp must be present and parseable`);
|
|
2580
|
-
timestamps.push(timestamp);
|
|
2581
|
-
const selector = nonEmptyString(action.selector);
|
|
2582
|
-
if (actionSelectorRequired(type) && !selector)
|
|
2583
|
-
throw new Error(`qualityGate ${actionField}.selector must be non-empty`);
|
|
2584
|
-
if (type === "goto" && !nonEmptyString(action.url))
|
|
2585
|
-
throw new Error(`qualityGate ${actionField}.url must be non-empty`);
|
|
2586
|
-
if (type === "custom" && !selector && !nonEmptyString(action.target)) {
|
|
2587
|
-
throw new Error(`qualityGate ${actionField}.selector or target must be non-empty`);
|
|
2588
|
-
}
|
|
2589
|
-
if (selector) hasSelectorBearingEntry = true;
|
|
2590
|
-
}
|
|
2591
|
-
for (const [index, assertion] of assertions.entries()) {
|
|
2592
|
-
const assertionField = `${fieldName}.assertions[${index}]`;
|
|
2593
|
-
const timestamp = normalizeTranscriptTimestamp(assertion.timestamp);
|
|
2594
|
-
if (timestamp === null) throw new Error(`qualityGate ${assertionField}.timestamp must be present and parseable`);
|
|
2595
|
-
timestamps.push(timestamp);
|
|
2596
|
-
if (nonEmptyString(assertion.status)?.toLowerCase() !== PASSED_STATUS) {
|
|
2597
|
-
throw new Error(`qualityGate ${assertionField}.status must be passed`);
|
|
2598
|
-
}
|
|
2599
|
-
if (nonEmptyString(assertion.selector)) hasSelectorBearingEntry = true;
|
|
2600
|
-
}
|
|
2601
|
-
for (let index = 1; index < timestamps.length; index++) {
|
|
2602
|
-
if (timestamps[index]! < timestamps[index - 1]!) {
|
|
2603
|
-
throw new Error(`qualityGate ${fieldName} automation transcript timestamps must be monotonic non-decreasing`);
|
|
2604
|
-
}
|
|
2605
|
-
}
|
|
2606
|
-
if (!hasSelectorBearingEntry) {
|
|
2607
|
-
throw new Error(
|
|
2608
|
-
`qualityGate ${fieldName} automation transcript must include at least one selector-bearing action or assertion`,
|
|
2609
|
-
);
|
|
2610
|
-
}
|
|
2611
|
-
return true;
|
|
2612
|
-
}
|
|
2613
|
-
|
|
2614
|
-
async function validatePtyCaptureArtifact(cwd: string, row: JsonObject, fieldName: string): Promise<boolean> {
|
|
2615
|
-
const bytes = await readArtifactBytes(cwd, row, fieldName);
|
|
2616
|
-
if (!bytes) throw new Error(`qualityGate ${fieldName} PTY capture path must resolve to an existing file`);
|
|
2617
|
-
if (bytes.length < 512) throw new Error(`qualityGate ${fieldName} PTY capture must be at least 512 bytes`);
|
|
2618
|
-
const text = bytes.toString("utf8");
|
|
2619
|
-
const hasCsi = /\x1b\[[0-?]*[ -/]*[@-~]/.test(text);
|
|
2620
|
-
const hasOsc = /\x1b\][^\x07]*(?:\x07|\x1b\\)/.test(text);
|
|
2621
|
-
const hasAltOrCursor = /\x1b\[\?1049[hl]|\x1b\[H|\x1b\[2J/.test(text);
|
|
2622
|
-
const hasRedraw = /[\r\b]/.test(text) && hasCsi;
|
|
2623
|
-
if (!hasCsi && !hasOsc && !hasAltOrCursor && !hasRedraw) {
|
|
2624
|
-
throw new Error(`qualityGate ${fieldName} PTY capture must contain terminal control sequences`);
|
|
2625
|
-
}
|
|
2626
|
-
if (!/[\x20-\x7e]{10,}/.test(text)) {
|
|
2627
|
-
throw new Error(
|
|
2628
|
-
`qualityGate ${fieldName} PTY capture must contain a printable text run of at least 10 characters`,
|
|
2629
|
-
);
|
|
2630
|
-
}
|
|
2631
|
-
return true;
|
|
2632
|
-
}
|
|
2633
|
-
|
|
2634
|
-
function structuralArtifactKind(row: JsonObject): "screenshot" | "automation" | "pty" | null {
|
|
2635
|
-
const kind = normalizedEvidenceKind(row);
|
|
2636
|
-
if (evidenceKindMatches(kind, ["screenshot", "image", "visual"])) return "screenshot";
|
|
2637
|
-
if (evidenceKindMatches(kind, ["browser", "playwright", "pandawright", "automation", "app-automation"]))
|
|
2638
|
-
return "automation";
|
|
2639
|
-
if (evidenceKindMatches(kind, ["pty", "tui", "terminal-capture"])) return "pty";
|
|
2640
|
-
return null;
|
|
2641
|
-
}
|
|
2642
|
-
|
|
2643
|
-
async function validateStructuralArtifact(
|
|
2644
|
-
cwd: string,
|
|
2645
|
-
row: JsonObject,
|
|
2646
|
-
fieldName: string,
|
|
2647
|
-
options: { surfaceFamily: SurfaceFamily; live: boolean },
|
|
2648
|
-
): Promise<boolean> {
|
|
2649
|
-
void options.live;
|
|
2650
|
-
const kind = structuralArtifactKind(row);
|
|
2651
|
-
if (!kind) return false;
|
|
2652
|
-
if (kind === "screenshot") return validateScreenshotArtifact(cwd, row, fieldName);
|
|
2653
|
-
if (kind === "automation") return validateAutomationTranscriptArtifact(cwd, row, fieldName, options);
|
|
2654
|
-
if (kind === "pty") return validatePtyCaptureArtifact(cwd, row, fieldName);
|
|
2655
|
-
return false;
|
|
2656
|
-
}
|
|
2657
|
-
|
|
2658
|
-
const CLI_REPLAY_MAX_OUTPUT_BYTES = 1024 * 1024;
|
|
2659
|
-
const CLI_REPLAY_DEFAULT_TIMEOUT_MS = 10_000;
|
|
2660
|
-
const CLI_REPLAY_MIN_TIMEOUT_MS = 1_000;
|
|
2661
|
-
const CLI_REPLAY_MAX_TIMEOUT_MS = 30_000;
|
|
2662
|
-
const CLI_REPLAY_EXEMPT_REASON_CODES = [
|
|
2663
|
-
"unsafe_side_effect",
|
|
2664
|
-
"requires_credentials",
|
|
2665
|
-
"requires_network",
|
|
2666
|
-
"non_deterministic_external",
|
|
2667
|
-
"destructive",
|
|
2668
|
-
"interactive_only",
|
|
2669
|
-
"platform_unavailable",
|
|
2670
|
-
] as const;
|
|
2671
|
-
const CLI_REPLAY_EXEMPT_REASON_CODE_SET = new Set<string>(CLI_REPLAY_EXEMPT_REASON_CODES);
|
|
2672
|
-
const CLI_REPLAY_ENV_BASE: Record<string, string> = { CI: "1", NO_COLOR: "1", GJC_ULTRAGOAL_REPLAY: "1" };
|
|
2673
|
-
const CLI_REPLAY_EXEMPT_REASON_CODE_LIST = CLI_REPLAY_EXEMPT_REASON_CODES.join(", ");
|
|
2674
|
-
const CLI_REPLAY_SAFE_ENV_NAMES = new Set(["LANG", "LC_ALL", "LC_CTYPE", "TZ"]);
|
|
2675
|
-
const CLI_REPLAY_DANGEROUS_ENV_NAME_PATTERN =
|
|
2676
|
-
/^(?:NODE_OPTIONS|GIT_EXTERNAL_DIFF|GIT_SSH|GIT_SSH_COMMAND|GIT_PAGER|PATH|LD_PRELOAD|LD_LIBRARY_PATH)$|^(?:GIT_CONFIG|DYLD_|BUN_|NPM_CONFIG_)|(?:^|_)OPTIONS$|PRELOAD$/;
|
|
2677
|
-
const ANSI_ESCAPE_PATTERN = /\x1b(?:\[[0-?]*[ -/]*[@-~]|\][^\x07]*(?:\x07|\x1b\\)|[@-Z\\-_])/g;
|
|
2678
|
-
|
|
2679
|
-
function clampCliReplayTimeout(value: unknown): number {
|
|
2680
|
-
if (value === undefined) return CLI_REPLAY_DEFAULT_TIMEOUT_MS;
|
|
2681
|
-
if (typeof value !== "number" || !Number.isFinite(value)) {
|
|
2682
|
-
throw new Error("qualityGate CLI replay timeoutMs must be a finite number");
|
|
2683
|
-
}
|
|
2684
|
-
return Math.min(CLI_REPLAY_MAX_TIMEOUT_MS, Math.max(CLI_REPLAY_MIN_TIMEOUT_MS, Math.trunc(value)));
|
|
2685
|
-
}
|
|
2686
|
-
|
|
2687
|
-
function basenameCommand(value: string): string {
|
|
2688
|
-
return path.basename(value).toLowerCase();
|
|
2689
|
-
}
|
|
2690
|
-
|
|
2691
|
-
function isDeterministicConsoleLogReplay(code: string): boolean {
|
|
2692
|
-
let remaining = code.trim();
|
|
2693
|
-
if (remaining.length === 0) return false;
|
|
2694
|
-
let matched = false;
|
|
2695
|
-
while (remaining.length > 0) {
|
|
2696
|
-
const match =
|
|
2697
|
-
/^console\.log\(\s*("(?:\\[\s\S]|[^"\\])*"|'(?:\\[\s\S]|[^'\\])*'|`(?:\\[\s\S]|[^`\\$])*`)\s*\)\s*;?\s*/.exec(
|
|
2698
|
-
remaining,
|
|
2699
|
-
);
|
|
2700
|
-
if (!match) return false;
|
|
2701
|
-
const statement = match[0]!;
|
|
2702
|
-
const literal = match[1]!;
|
|
2703
|
-
if (literal.startsWith("`") && literal.includes("${")) return false;
|
|
2704
|
-
matched = true;
|
|
2705
|
-
remaining = remaining.slice(statement.length);
|
|
2706
|
-
}
|
|
2707
|
-
return matched;
|
|
2708
|
-
}
|
|
2709
|
-
|
|
2710
|
-
function hasShellRedirectionToken(value: string): boolean {
|
|
2711
|
-
return /^(?:[<>]|\d?[<>]|\d?>&\d|\|\|?|&&|;)$/.test(value) || /(?:^|[^\w])-?>/.test(value);
|
|
2712
|
-
}
|
|
2713
|
-
|
|
2714
|
-
function isSafeRefOrPathspec(value: string): boolean {
|
|
2715
|
-
return value.length > 0 && !value.startsWith("-") && !/[\0\n\r]/.test(value) && !hasShellRedirectionToken(value);
|
|
2716
|
-
}
|
|
2717
|
-
|
|
2718
|
-
function isAllowedGitReplayCommand(args: readonly string[]): boolean {
|
|
2719
|
-
const subcommand = args[0];
|
|
2720
|
-
const rest = args.slice(1);
|
|
2721
|
-
if (subcommand === "status") return rest.every(arg => ["--short", "--porcelain", "--branch"].includes(arg));
|
|
2722
|
-
if (subcommand === "rev-parse" || subcommand === "merge-base")
|
|
2723
|
-
return rest.length > 0 && rest.every(isSafeRefOrPathspec);
|
|
2724
|
-
if (subcommand !== "diff" && subcommand !== "show" && subcommand !== "log") return false;
|
|
2725
|
-
let pathspecMode = false;
|
|
2726
|
-
for (const arg of rest) {
|
|
2727
|
-
if (arg === "--") {
|
|
2728
|
-
pathspecMode = true;
|
|
2729
|
-
continue;
|
|
2730
|
-
}
|
|
2731
|
-
if (pathspecMode) {
|
|
2732
|
-
if (!isSafeRefOrPathspec(arg)) return false;
|
|
2733
|
-
continue;
|
|
2734
|
-
}
|
|
2735
|
-
if (["--stat", "--name-only", "--oneline", "--no-ext-diff"].includes(arg)) continue;
|
|
2736
|
-
if (!isSafeRefOrPathspec(arg)) return false;
|
|
2737
|
-
}
|
|
2738
|
-
return true;
|
|
2739
|
-
}
|
|
2740
|
-
|
|
2741
|
-
function isBareExecutableName(value: string): boolean {
|
|
2742
|
-
// The allowlist is keyed on the basename, but the raw command[0] is what gets spawned.
|
|
2743
|
-
// Reject path-qualified or case-spoofed executables (e.g. ./git, /tmp/npm, scripts/node, GIT)
|
|
2744
|
-
// so an attacker-controlled binary cannot impersonate a trusted tool.
|
|
2745
|
-
return (
|
|
2746
|
-
value.length > 0 &&
|
|
2747
|
-
!value.includes("/") &&
|
|
2748
|
-
!value.includes("\\") &&
|
|
2749
|
-
value === path.basename(value) &&
|
|
2750
|
-
value === value.toLowerCase()
|
|
2751
|
-
);
|
|
2752
|
-
}
|
|
2753
|
-
|
|
2754
|
-
function isAllowedCliReplayCommand(command: readonly string[]): boolean {
|
|
2755
|
-
if (
|
|
2756
|
-
command.length === 0 ||
|
|
2757
|
-
command.some(arg => arg.trim() !== arg || arg.length === 0 || hasShellRedirectionToken(arg))
|
|
2758
|
-
)
|
|
2759
|
-
return false;
|
|
2760
|
-
if (!isBareExecutableName(command[0]!)) return false;
|
|
2761
|
-
const executable = basenameCommand(command[0]!);
|
|
2762
|
-
const args = command.slice(1);
|
|
2763
|
-
if (executable === "bun" || executable === "node") {
|
|
2764
|
-
if (args.length === 1 && args[0] === "--version") return true;
|
|
2765
|
-
return args.length === 2 && args[0] === "-e" && isDeterministicConsoleLogReplay(args[1]!);
|
|
2766
|
-
}
|
|
2767
|
-
if (executable === "npm" || executable === "pnpm" || executable === "yarn") {
|
|
2768
|
-
return (args.length === 1 && args[0] === "--version") || (args.length === 1 && args[0] === "list");
|
|
2769
|
-
}
|
|
2770
|
-
if (executable === "git") return isAllowedGitReplayCommand(args);
|
|
2771
|
-
if (executable === "gjc") return args.length === 1 && ["read", "status"].includes(args[0] ?? "");
|
|
2772
|
-
return false;
|
|
2773
|
-
}
|
|
2774
|
-
function summarizeBlockedCliReplayCommand(command: readonly string[]): string {
|
|
2775
|
-
const executable = command[0] ? basenameCommand(command[0]) : "<missing>";
|
|
2776
|
-
const argCount = Math.max(0, command.length - 1);
|
|
2777
|
-
return `${JSON.stringify(executable)} with ${argCount} arg${argCount === 1 ? "" : "s"}`;
|
|
2778
|
-
}
|
|
2779
|
-
|
|
2780
|
-
function cliReplayAllowlistDescription(): string {
|
|
2781
|
-
return [
|
|
2782
|
-
'`bun --version`, `node --version`, or deterministic `bun/node -e "console.log(...)"`',
|
|
2783
|
-
"`npm|pnpm|yarn --version` or `npm|pnpm|yarn list`",
|
|
2784
|
-
"read-only `git status|rev-parse|merge-base|diff|show|log` with safe args",
|
|
2785
|
-
"`gjc read` or `gjc status`",
|
|
2786
|
-
].join("; ");
|
|
2787
|
-
}
|
|
2788
|
-
|
|
2789
|
-
function resolveCliReplayCommand(command: string[]): string[] {
|
|
2790
|
-
if (basenameCommand(command[0]!) === "bun") return [process.execPath, ...command.slice(1)];
|
|
2791
|
-
return command;
|
|
2792
|
-
}
|
|
2793
|
-
|
|
2794
|
-
function resolveUnderCwd(cwd: string, replayCwd: unknown, fieldName: string): string {
|
|
2795
|
-
const relative = replayCwd === undefined ? "." : nonEmptyString(replayCwd);
|
|
2796
|
-
if (!relative) throw new Error(`qualityGate ${fieldName}.cwd must be a non-empty string when provided`);
|
|
2797
|
-
const root = path.resolve(cwd);
|
|
2798
|
-
const resolved = path.resolve(root, relative);
|
|
2799
|
-
const relativeToRoot = path.relative(root, resolved);
|
|
2800
|
-
if (relativeToRoot === ".." || relativeToRoot.startsWith(`..${path.sep}`) || path.isAbsolute(relativeToRoot)) {
|
|
2801
|
-
throw new Error(`qualityGate ${fieldName}.cwd must resolve under the repository cwd`);
|
|
2802
|
-
}
|
|
2803
|
-
return resolved;
|
|
2804
|
-
}
|
|
2805
|
-
|
|
2806
|
-
function buildCliReplayEnv(value: unknown, fieldName: string): Record<string, string> {
|
|
2807
|
-
const env: Record<string, string> = { ...CLI_REPLAY_ENV_BASE };
|
|
2808
|
-
if (value === undefined) return env;
|
|
2809
|
-
const object = requireQualityGateObject(value, `${fieldName}.env`);
|
|
2810
|
-
for (const [key, envValue] of Object.entries(object)) {
|
|
2811
|
-
if (!/^[A-Z_][A-Z0-9_]*$/.test(key))
|
|
2812
|
-
throw new Error(`qualityGate ${fieldName}.env.${key} must be an uppercase environment key`);
|
|
2813
|
-
if (CLI_REPLAY_DANGEROUS_ENV_NAME_PATTERN.test(key) || !CLI_REPLAY_SAFE_ENV_NAMES.has(key)) {
|
|
2814
|
-
throw new Error(`qualityGate ${fieldName}.env.${key} is not in the CLI replay safe environment allowlist`);
|
|
2815
|
-
}
|
|
2816
|
-
if (typeof envValue !== "string") throw new Error(`qualityGate ${fieldName}.env.${key} must be a string`);
|
|
2817
|
-
env[key] = envValue;
|
|
2818
|
-
}
|
|
2819
|
-
return env;
|
|
2820
|
-
}
|
|
2821
|
-
|
|
2822
|
-
function normalizeCliReplayOutput(value: string, cwd: string): string {
|
|
2823
|
-
let normalized = value.replace(ANSI_ESCAPE_PATTERN, "").replace(/\r\n?/g, "\n");
|
|
2824
|
-
const home = process.env.HOME;
|
|
2825
|
-
const replacements: Array<[RegExp, string]> = [
|
|
2826
|
-
[/\b\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z\b/g, "<TIMESTAMP>"],
|
|
2827
|
-
[/\b[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\b/gi, "<UUID>"],
|
|
2828
|
-
[/\b[0-9a-f]{7,}\b/gi, "<HASH>"],
|
|
2829
|
-
[/(?:\/private)?\/var\/folders\/[^\s"']+|\/tmp\/[^\s"']+|\/var\/tmp\/[^\s"']+/g, "<TMP>"],
|
|
2830
|
-
];
|
|
2831
|
-
for (const candidate of [path.resolve(cwd), home]) {
|
|
2832
|
-
if (!candidate) continue;
|
|
2833
|
-
const escaped = candidate.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
2834
|
-
normalized = normalized.replace(new RegExp(escaped, "g"), candidate === home ? "<HOME>" : "<CWD>");
|
|
2835
|
-
}
|
|
2836
|
-
for (const [pattern, replacement] of replacements) normalized = normalized.replace(pattern, replacement);
|
|
2837
|
-
const lines = normalized.split("\n").map(line => line.replace(/[ \t]+$/g, ""));
|
|
2838
|
-
while (lines.length > 0 && lines[lines.length - 1] === "") lines.pop();
|
|
2839
|
-
return lines.join("\n");
|
|
2840
|
-
}
|
|
2841
|
-
|
|
2842
|
-
async function readCliReplayRecord(cwd: string, row: JsonObject, fieldName: string): Promise<JsonObject | null> {
|
|
2843
|
-
const inline = qualityGateObject(row.replay) ?? (row.kind === "cli-replay" ? row : null);
|
|
2844
|
-
if (inline) return inline;
|
|
2845
|
-
if (!evidenceKindMatches(normalizedEvidenceKind(row), ["cli-replay", "command-replay"])) return null;
|
|
2846
|
-
const bytes = await readArtifactBytes(cwd, row, fieldName);
|
|
2847
|
-
if (!bytes) return null;
|
|
2848
|
-
try {
|
|
2849
|
-
return requireQualityGateObject(JSON.parse(bytes.toString("utf8")), `${fieldName}.replay`);
|
|
2850
|
-
} catch (error) {
|
|
2851
|
-
throw new Error(`qualityGate ${fieldName} CLI replay artifact must be valid JSON: ${String(error)}`);
|
|
2852
|
-
}
|
|
2853
|
-
}
|
|
2854
|
-
|
|
2855
|
-
function parseCliReplayRecord(
|
|
2856
|
-
record: JsonObject,
|
|
2857
|
-
fieldName: string,
|
|
2858
|
-
): {
|
|
2859
|
-
command: string[];
|
|
2860
|
-
replayCwd: unknown;
|
|
2861
|
-
env: Record<string, string>;
|
|
2862
|
-
timeoutMs: number;
|
|
2863
|
-
expectedExitCode: number;
|
|
2864
|
-
recordedStdout: string;
|
|
2865
|
-
invariants: JsonObject[];
|
|
2866
|
-
} {
|
|
2867
|
-
if (record.schemaVersion !== 1) throw new Error(`qualityGate ${fieldName}.schemaVersion must be 1`);
|
|
2868
|
-
if (record.kind !== "cli-replay") throw new Error(`qualityGate ${fieldName}.kind must be cli-replay`);
|
|
2869
|
-
if (record.command !== undefined && typeof record.command === "string") {
|
|
2870
|
-
throw new Error(`qualityGate ${fieldName}.command must be an argv string array, not a shell string`);
|
|
2871
|
-
}
|
|
2872
|
-
const command = nonEmptyStringArray(record.command);
|
|
2873
|
-
if (!command) throw new Error(`qualityGate ${fieldName}.command must be a non-empty string array`);
|
|
2874
|
-
if (record.replaySafe !== true)
|
|
2875
|
-
throw new Error(`qualityGate ${fieldName}.replaySafe must be true before CLI replay executes`);
|
|
2876
|
-
if (!isAllowedCliReplayCommand(command)) {
|
|
2877
|
-
throw new Error(
|
|
2878
|
-
`qualityGate ${fieldName}.command is not in the conservative CLI replay allowlist; command ${summarizeBlockedCliReplayCommand(command)} is blocked. Allowed replay commands: ${cliReplayAllowlistDescription()}. For other commands, provide audited replayExempt metadata with reasonCode, reason, approvedBy, and fallbackArtifactRefs that point to a structurally valid fallback artifact.`,
|
|
2879
|
-
);
|
|
2880
|
-
}
|
|
2881
|
-
if (record.normalization !== undefined && record.normalization !== "default") {
|
|
2882
|
-
throw new Error(`qualityGate ${fieldName}.normalization must be default when provided`);
|
|
2883
|
-
}
|
|
2884
|
-
if (typeof record.recordedStdout !== "string")
|
|
2885
|
-
throw new Error(`qualityGate ${fieldName}.recordedStdout must be a string`);
|
|
2886
|
-
if (record.recordedStderr !== undefined && typeof record.recordedStderr !== "string") {
|
|
2887
|
-
throw new Error(`qualityGate ${fieldName}.recordedStderr must be a string when provided`);
|
|
2888
|
-
}
|
|
2889
|
-
const expectedExitCode = record.expectedExitCode === undefined ? 0 : record.expectedExitCode;
|
|
2890
|
-
if (typeof expectedExitCode !== "number" || !Number.isInteger(expectedExitCode)) {
|
|
2891
|
-
throw new Error(`qualityGate ${fieldName}.expectedExitCode must be an integer`);
|
|
2892
|
-
}
|
|
2893
|
-
const invariants =
|
|
2894
|
-
record.invariants === undefined ? [] : requireObjectArray(record.invariants, `${fieldName}.invariants`);
|
|
2895
|
-
return {
|
|
2896
|
-
command: command.map(item => item.trim()),
|
|
2897
|
-
replayCwd: record.cwd,
|
|
2898
|
-
env: buildCliReplayEnv(record.env, fieldName),
|
|
2899
|
-
timeoutMs: clampCliReplayTimeout(record.timeoutMs),
|
|
2900
|
-
expectedExitCode,
|
|
2901
|
-
recordedStdout: record.recordedStdout,
|
|
2902
|
-
invariants,
|
|
2903
|
-
};
|
|
2904
|
-
}
|
|
2905
|
-
|
|
2906
|
-
function validateCliReplayInvariants(invariants: JsonObject[], stdout: string, fieldName: string): void {
|
|
2907
|
-
for (const [index, invariant] of invariants.entries()) {
|
|
2908
|
-
const invariantField = `${fieldName}.invariants[${index}]`;
|
|
2909
|
-
const type = requiredStringField(invariant, "type", invariantField);
|
|
2910
|
-
const value = requiredStringField(invariant, "value", invariantField);
|
|
2911
|
-
if (type === "substring" && !stdout.includes(value))
|
|
2912
|
-
throw new Error(`qualityGate ${invariantField} substring invariant did not match stdout`);
|
|
2913
|
-
else if (type === "not_substring" && stdout.includes(value))
|
|
2914
|
-
throw new Error(`qualityGate ${invariantField} not_substring invariant matched stdout`);
|
|
2915
|
-
else if (type === "regex") {
|
|
2916
|
-
const flags = invariant.flags === undefined ? "" : requiredStringField(invariant, "flags", invariantField);
|
|
2917
|
-
if (!/^[im]*$/.test(flags)) throw new Error(`qualityGate ${invariantField}.flags may only contain i and m`);
|
|
2918
|
-
if (!new RegExp(value, flags).test(stdout))
|
|
2919
|
-
throw new Error(`qualityGate ${invariantField} regex invariant did not match stdout`);
|
|
2920
|
-
} else if (type !== "substring" && type !== "not_substring") {
|
|
2921
|
-
throw new Error(`qualityGate ${invariantField}.type must be substring, regex, or not_substring`);
|
|
2922
|
-
}
|
|
2923
|
-
}
|
|
2924
|
-
}
|
|
2925
|
-
|
|
2926
|
-
async function collectCliReplayOutput(
|
|
2927
|
-
stream: ReadableStream<Uint8Array> | null,
|
|
2928
|
-
): Promise<{ text: string; truncated: boolean }> {
|
|
2929
|
-
if (!stream) return { text: "", truncated: false };
|
|
2930
|
-
const reader = stream.getReader();
|
|
2931
|
-
const chunks: Buffer[] = [];
|
|
2932
|
-
let size = 0;
|
|
2933
|
-
let truncated = false;
|
|
2934
|
-
try {
|
|
2935
|
-
while (true) {
|
|
2936
|
-
const { done, value } = await reader.read();
|
|
2937
|
-
if (done) break;
|
|
2938
|
-
if (size < CLI_REPLAY_MAX_OUTPUT_BYTES) {
|
|
2939
|
-
const remaining = CLI_REPLAY_MAX_OUTPUT_BYTES - size;
|
|
2940
|
-
const chunk = Buffer.from(value.subarray(0, remaining));
|
|
2941
|
-
chunks.push(chunk);
|
|
2942
|
-
size += chunk.length;
|
|
2943
|
-
}
|
|
2944
|
-
if (value.length > 0 && size >= CLI_REPLAY_MAX_OUTPUT_BYTES) {
|
|
2945
|
-
truncated = true;
|
|
2946
|
-
await reader.cancel().catch(() => undefined);
|
|
2947
|
-
break;
|
|
2948
|
-
}
|
|
2949
|
-
}
|
|
2950
|
-
} finally {
|
|
2951
|
-
reader.releaseLock();
|
|
2952
|
-
}
|
|
2953
|
-
return { text: Buffer.concat(chunks).toString("utf8"), truncated };
|
|
2954
|
-
}
|
|
2955
|
-
|
|
2956
|
-
export interface ReplayProcessHandle {
|
|
2957
|
-
readonly exited: Promise<number>;
|
|
2958
|
-
kill(signal?: number | NodeJS.Signals): void;
|
|
2959
|
-
}
|
|
2960
|
-
|
|
2961
|
-
export async function waitForReplayProcessWithTimeout(
|
|
2962
|
-
process: ReplayProcessHandle,
|
|
2963
|
-
timeoutMs: number,
|
|
2964
|
-
graceMs = 2000,
|
|
2965
|
-
): Promise<number> {
|
|
2966
|
-
let timeoutTimer: NodeJS.Timeout | undefined;
|
|
2967
|
-
let graceTimer: NodeJS.Timeout | undefined;
|
|
2968
|
-
const timedOut = Symbol("timedOut");
|
|
2969
|
-
const timeout = new Promise<typeof timedOut>(resolve => {
|
|
2970
|
-
timeoutTimer = setTimeout(() => resolve(timedOut), timeoutMs);
|
|
2971
|
-
});
|
|
2972
|
-
const first = await Promise.race([process.exited, timeout]);
|
|
2973
|
-
if (first !== timedOut) {
|
|
2974
|
-
if (timeoutTimer) clearTimeout(timeoutTimer);
|
|
2975
|
-
return first;
|
|
2976
|
-
}
|
|
2977
|
-
process.kill("SIGTERM");
|
|
2978
|
-
const killed = Symbol("killed");
|
|
2979
|
-
const grace = new Promise<typeof killed>(resolve => {
|
|
2980
|
-
graceTimer = setTimeout(() => {
|
|
2981
|
-
process.kill("SIGKILL");
|
|
2982
|
-
resolve(killed);
|
|
2983
|
-
}, graceMs);
|
|
2984
|
-
});
|
|
2985
|
-
await Promise.race([process.exited, grace]);
|
|
2986
|
-
await process.exited.catch(() => undefined);
|
|
2987
|
-
if (timeoutTimer) clearTimeout(timeoutTimer);
|
|
2988
|
-
if (graceTimer) clearTimeout(graceTimer);
|
|
2989
|
-
throw new Error("timeout");
|
|
2990
|
-
}
|
|
2991
|
-
|
|
2992
|
-
async function validateReplayExemptFallback(
|
|
2993
|
-
cwd: string,
|
|
2994
|
-
record: JsonObject,
|
|
2995
|
-
fieldName: string,
|
|
2996
|
-
artifactRefs: Map<string, JsonObject>,
|
|
2997
|
-
options: { surfaceFamily: SurfaceFamily; live: boolean },
|
|
2998
|
-
): Promise<boolean> {
|
|
2999
|
-
const exempt = qualityGateObject(record.replayExempt);
|
|
3000
|
-
if (!exempt) return false;
|
|
3001
|
-
const reasonCode = requiredStringField(exempt, "reasonCode", `${fieldName}.replayExempt`);
|
|
3002
|
-
if (!CLI_REPLAY_EXEMPT_REASON_CODE_SET.has(reasonCode))
|
|
3003
|
-
throw new Error(
|
|
3004
|
-
`qualityGate ${fieldName}.replayExempt.reasonCode must be one of: ${CLI_REPLAY_EXEMPT_REASON_CODE_LIST}`,
|
|
3005
|
-
);
|
|
3006
|
-
const reason = requiredStringField(exempt, "reason", `${fieldName}.replayExempt`);
|
|
3007
|
-
if (!isSubstantiveEvidence(reason) || reason.length < 30)
|
|
3008
|
-
throw new Error(`qualityGate ${fieldName}.replayExempt.reason must be audited and substantive`);
|
|
3009
|
-
requiredStringField(exempt, "approvedBy", `${fieldName}.replayExempt`);
|
|
3010
|
-
const fallbackRefs = requireStringLinks(
|
|
3011
|
-
exempt.fallbackArtifactRefs,
|
|
3012
|
-
`${fieldName}.replayExempt.fallbackArtifactRefs`,
|
|
3013
|
-
);
|
|
3014
|
-
requireResolvedLinks(fallbackRefs, artifactRefs, `${fieldName}.replayExempt.fallbackArtifactRefs`);
|
|
3015
|
-
let validFallback = false;
|
|
3016
|
-
for (const fallbackRef of fallbackRefs) {
|
|
3017
|
-
if (fallbackRef === requiredStringField(record, "id", fieldName)) {
|
|
3018
|
-
throw new Error(`qualityGate ${fieldName}.replayExempt fallback must not reference the replay record itself`);
|
|
3019
|
-
}
|
|
3020
|
-
const fallback = artifactRefs.get(fallbackRef)!;
|
|
3021
|
-
if (await validateStructuralArtifact(cwd, fallback, `executorQa.artifactRefs.${fallbackRef}`, options))
|
|
3022
|
-
validFallback = true;
|
|
3023
|
-
}
|
|
3024
|
-
if (!validFallback)
|
|
3025
|
-
throw new Error(
|
|
3026
|
-
`qualityGate ${fieldName}.replayExempt requires at least one structurally-valid fallback artifact`,
|
|
3027
|
-
);
|
|
3028
|
-
return true;
|
|
3029
|
-
}
|
|
3030
|
-
async function validateCliReplay(
|
|
3031
|
-
cwd: string,
|
|
3032
|
-
row: JsonObject,
|
|
3033
|
-
fieldName: string,
|
|
3034
|
-
options: { live: boolean },
|
|
3035
|
-
): Promise<boolean> {
|
|
3036
|
-
const record = await readCliReplayRecord(cwd, row, fieldName);
|
|
3037
|
-
if (!record) return false;
|
|
3038
|
-
if (record.replayExempt !== undefined) {
|
|
3039
|
-
throw new Error(
|
|
3040
|
-
`qualityGate ${fieldName}.replayExempt can only be validated from surfaceEvidence with fallback context`,
|
|
3041
|
-
);
|
|
3042
|
-
}
|
|
3043
|
-
void options.live;
|
|
3044
|
-
const replay = parseCliReplayRecord(record, fieldName);
|
|
3045
|
-
const replayCwd = resolveUnderCwd(cwd, replay.replayCwd, fieldName);
|
|
3046
|
-
const process = Bun.spawn(resolveCliReplayCommand(replay.command), {
|
|
3047
|
-
cwd: replayCwd,
|
|
3048
|
-
env: replay.env,
|
|
3049
|
-
stdout: "pipe",
|
|
3050
|
-
stderr: "pipe",
|
|
3051
|
-
});
|
|
3052
|
-
try {
|
|
3053
|
-
const [stdout, stderr, exitCode] = await Promise.all([
|
|
3054
|
-
collectCliReplayOutput(process.stdout),
|
|
3055
|
-
collectCliReplayOutput(process.stderr),
|
|
3056
|
-
waitForReplayProcessWithTimeout(process, replay.timeoutMs),
|
|
3057
|
-
]);
|
|
3058
|
-
if (stdout.truncated || stderr.truncated)
|
|
3059
|
-
throw new Error(`qualityGate ${fieldName} CLI replay output exceeded 1 MiB buffer cap`);
|
|
3060
|
-
if (exitCode !== replay.expectedExitCode) {
|
|
3061
|
-
throw new Error(
|
|
3062
|
-
`qualityGate ${fieldName} CLI replay exit code ${exitCode} did not match expected ${replay.expectedExitCode}`,
|
|
3063
|
-
);
|
|
3064
|
-
}
|
|
3065
|
-
const actualStdout = normalizeCliReplayOutput(stdout.text, cwd);
|
|
3066
|
-
const recordedStdout = normalizeCliReplayOutput(replay.recordedStdout, cwd);
|
|
3067
|
-
if (replay.invariants.length > 0) {
|
|
3068
|
-
validateCliReplayInvariants(replay.invariants, actualStdout, fieldName);
|
|
3069
|
-
} else if (actualStdout !== recordedStdout) {
|
|
3070
|
-
throw new Error(`qualityGate ${fieldName} CLI replay stdout did not match recordedStdout after normalization`);
|
|
3071
|
-
}
|
|
3072
|
-
return true;
|
|
3073
|
-
} catch (error) {
|
|
3074
|
-
if (error instanceof Error && error.message === "timeout") {
|
|
3075
|
-
throw new Error(`qualityGate ${fieldName} CLI replay timed out after ${replay.timeoutMs}ms`);
|
|
3076
|
-
}
|
|
3077
|
-
throw error;
|
|
3078
|
-
}
|
|
3079
|
-
}
|
|
3080
|
-
|
|
3081
|
-
async function hasLiveProofPresence(
|
|
3082
|
-
cwd: string,
|
|
3083
|
-
row: JsonObject,
|
|
3084
|
-
fieldName: string,
|
|
3085
|
-
family: SurfaceFamily,
|
|
3086
|
-
): Promise<boolean> {
|
|
3087
|
-
if (await hasExistingNonEmptyArtifact(cwd, row.path)) return true;
|
|
3088
|
-
if (family === "cli") {
|
|
3089
|
-
const record = await readCliReplayRecord(cwd, row, fieldName);
|
|
3090
|
-
if (record) return true;
|
|
3091
|
-
}
|
|
3092
|
-
return false;
|
|
3093
|
-
}
|
|
3094
|
-
|
|
3095
|
-
async function validateLiveSurfaceProofPresence(
|
|
3096
|
-
cwd: string,
|
|
3097
|
-
family: SurfaceFamily,
|
|
3098
|
-
artifactIds: string[],
|
|
3099
|
-
artifactRefs: Map<string, JsonObject>,
|
|
3100
|
-
): Promise<void> {
|
|
3101
|
-
if (!isLiveSurfaceFamily(family)) return;
|
|
3102
|
-
for (const artifactId of artifactIds) {
|
|
3103
|
-
if (
|
|
3104
|
-
await hasLiveProofPresence(cwd, artifactRefs.get(artifactId)!, `executorQa.artifactRefs.${artifactId}`, family)
|
|
3105
|
-
)
|
|
3106
|
-
return;
|
|
3107
|
-
}
|
|
3108
|
-
throw new Error(
|
|
3109
|
-
`qualityGate ${artifactIds.map(id => `executorQa.artifactRefs.${id}`).join(", ")} must reference a live proof artifact, structural capture, or CLI replay; inlineEvidence and typed verifiedReceipt do not prove live surfaces`,
|
|
3110
|
-
);
|
|
3111
|
-
}
|
|
3112
|
-
async function validateSurfaceStructuralRequirement(
|
|
3113
|
-
cwd: string,
|
|
3114
|
-
family: SurfaceFamily,
|
|
3115
|
-
artifactIds: string[],
|
|
3116
|
-
artifactRefs: Map<string, JsonObject>,
|
|
3117
|
-
fieldName: string,
|
|
3118
|
-
): Promise<void> {
|
|
3119
|
-
if (family !== "web" && family !== "native") return;
|
|
3120
|
-
let hasScreenshot = false;
|
|
3121
|
-
let hasAutomation = false;
|
|
3122
|
-
let hasPty = false;
|
|
3123
|
-
for (const artifactId of artifactIds) {
|
|
3124
|
-
const artifact = artifactRefs.get(artifactId)!;
|
|
3125
|
-
const kind = structuralArtifactKind(artifact);
|
|
3126
|
-
if (!kind) continue;
|
|
3127
|
-
const valid = await validateStructuralArtifact(cwd, artifact, `executorQa.artifactRefs.${artifactId}`, {
|
|
3128
|
-
surfaceFamily: family,
|
|
3129
|
-
live: true,
|
|
3130
|
-
});
|
|
3131
|
-
if (kind === "screenshot" && valid) hasScreenshot = true;
|
|
3132
|
-
if (kind === "automation" && valid) hasAutomation = true;
|
|
3133
|
-
if (kind === "pty" && valid) hasPty = true;
|
|
3134
|
-
}
|
|
3135
|
-
if (family === "web" && (!hasScreenshot || !hasAutomation)) {
|
|
3136
|
-
throw new Error(
|
|
3137
|
-
`qualityGate ${fieldName} for GUI/web surfaces must include a valid automation transcript and non-uniform screenshot`,
|
|
3138
|
-
);
|
|
3139
|
-
}
|
|
3140
|
-
if (family === "native" && !hasScreenshot && !hasAutomation && !hasPty) {
|
|
3141
|
-
throw new Error(
|
|
3142
|
-
`qualityGate ${fieldName} for native surfaces must include a valid screenshot, PTY capture, or app-automation transcript`,
|
|
3143
|
-
);
|
|
3144
|
-
}
|
|
3145
|
-
}
|
|
3146
|
-
|
|
3147
|
-
async function validateArtifactProof(
|
|
3148
|
-
cwd: string,
|
|
3149
|
-
row: JsonObject,
|
|
3150
|
-
fieldName: string,
|
|
3151
|
-
options: { surfaceFamily: SurfaceFamily; live: boolean },
|
|
3152
|
-
): Promise<void> {
|
|
3153
|
-
if (await hasExistingNonEmptyArtifact(cwd, row.path)) return;
|
|
3154
|
-
if (await validateStructuralArtifact(cwd, row, fieldName, options)) return;
|
|
3155
|
-
if (options.surfaceFamily === "cli" && (await validateCliReplay(cwd, row, fieldName, { live: options.live })))
|
|
3156
|
-
return;
|
|
3157
|
-
if (!options.live && (hasTypedVerifiedReceipt(row.verifiedReceipt) || hasTypedVerifiedReceipt(row.receipt))) return;
|
|
3158
|
-
const proofLabel = options.live
|
|
3159
|
-
? "a live proof artifact, structural capture, or CLI replay; inlineEvidence and typed verifiedReceipt do not prove live surfaces"
|
|
3160
|
-
: "an existing non-empty artifact path or a typed verifiedReceipt; inlineEvidence alone is not sufficient";
|
|
3161
|
-
throw new Error(`qualityGate ${fieldName} must reference ${proofLabel}`);
|
|
3162
|
-
}
|
|
2084
|
+
import {
|
|
2085
|
+
readCliReplayRecord,
|
|
2086
|
+
validateArtifactProof,
|
|
2087
|
+
validateCliReplay,
|
|
2088
|
+
validateLiveSurfaceProofPresence,
|
|
2089
|
+
validateReplayExemptFallback,
|
|
2090
|
+
validateStructuralArtifact,
|
|
2091
|
+
validateSurfaceStructuralRequirement,
|
|
2092
|
+
waitForReplayProcessWithTimeout,
|
|
2093
|
+
} from "./ultragoal-evidence";
|
|
2094
|
+
|
|
2095
|
+
export type { ReplayProcessHandle } from "./ultragoal-evidence";
|
|
2096
|
+
export {
|
|
2097
|
+
validateArtifactProof,
|
|
2098
|
+
validateCliReplay,
|
|
2099
|
+
validateLiveSurfaceProofPresence,
|
|
2100
|
+
validateReplayExemptFallback,
|
|
2101
|
+
validateStructuralArtifact,
|
|
2102
|
+
validateSurfaceStructuralRequirement,
|
|
2103
|
+
waitForReplayProcessWithTimeout,
|
|
2104
|
+
};
|
|
3163
2105
|
|
|
3164
2106
|
async function validateArtifactRefs(cwd: string, executorQa: JsonObject): Promise<Map<string, JsonObject>> {
|
|
3165
2107
|
void cwd;
|
|
@@ -3964,13 +2906,21 @@ export async function checkpointUltragoalGoal(input: {
|
|
|
3964
2906
|
const evidence = input.evidence.trim();
|
|
3965
2907
|
if (!evidence) throw new Error("checkpoint evidence is required");
|
|
3966
2908
|
const ledgerBefore = await readUltragoalLedger(input.cwd);
|
|
3967
|
-
const
|
|
2909
|
+
const matchingIdempotentEvents = ledgerBefore.filter(
|
|
3968
2910
|
event =>
|
|
3969
2911
|
event.event === "goal_checkpointed" &&
|
|
3970
2912
|
event.goalId === goal.id &&
|
|
3971
2913
|
event.status === input.status &&
|
|
3972
2914
|
event.evidence === evidence,
|
|
3973
2915
|
);
|
|
2916
|
+
// Re-verification replays legitimately append repeated same-status,
|
|
2917
|
+
// same-evidence checkpoint events for one goal. The recorded receipt must
|
|
2918
|
+
// be compared against ITS OWN checkpoint event — resolved by the receipt's
|
|
2919
|
+
// checkpointLedgerEventId, falling back to the latest match — never the
|
|
2920
|
+
// oldest duplicate, which would wrongly report the current receipt stale.
|
|
2921
|
+
const matchingIdempotentEvent =
|
|
2922
|
+
matchingIdempotentEvents.find(event => event.eventId === goal.completionVerification?.checkpointLedgerEventId) ??
|
|
2923
|
+
matchingIdempotentEvents.at(-1);
|
|
3974
2924
|
const batchMetadata = input.status === "complete" ? requireFreshValidationBatchMetadata(goal) : undefined;
|
|
3975
2925
|
if (batchMetadata && goal.completionVerification?.validationBatch) {
|
|
3976
2926
|
const receiptBatch = goal.completionVerification.validationBatch;
|
|
@@ -3987,7 +2937,35 @@ export async function checkpointUltragoalGoal(input: {
|
|
|
3987
2937
|
if (input.status === "complete" && goal.completionVerification?.validationBatch && !batchMetadata) {
|
|
3988
2938
|
throw new Error(`Goal ${goal.id} has stale validation batch completion receipt`);
|
|
3989
2939
|
}
|
|
3990
|
-
|
|
2940
|
+
// An identical-evidence complete replay is only a no-op while the recorded
|
|
2941
|
+
// receipt still validates fresh. When the goal row itself is untouched
|
|
2942
|
+
// (updatedAt still matches the receipt's verifiedAt) but later goal-tagged
|
|
2943
|
+
// ledger events (e.g. blocker classifications) or plan growth staled the
|
|
2944
|
+
// receipt, the replay is a genuine re-verification: it must run the full
|
|
2945
|
+
// quality gate and mint a fresh receipt, otherwise a completed goal with a
|
|
2946
|
+
// context-staled receipt can never be repaired (different evidence is
|
|
2947
|
+
// rejected on complete goals by design). A mutated goal row keeps the
|
|
2948
|
+
// fail-loud tamper handling in the idempotent branch below.
|
|
2949
|
+
const staleCompleteReceiptReplay =
|
|
2950
|
+
input.status === "complete" &&
|
|
2951
|
+
goal.status === "complete" &&
|
|
2952
|
+
goal.evidence === evidence &&
|
|
2953
|
+
Boolean(matchingIdempotentEvent) &&
|
|
2954
|
+
(!goal.completionVerification ||
|
|
2955
|
+
(goal.completionVerification.verifiedAt === goal.updatedAt &&
|
|
2956
|
+
validateReceiptFreshBase({
|
|
2957
|
+
plan,
|
|
2958
|
+
ledger: ledgerBefore,
|
|
2959
|
+
goal,
|
|
2960
|
+
receipt: goal.completionVerification,
|
|
2961
|
+
receiptKind: goal.completionVerification.receiptKind,
|
|
2962
|
+
}) !== null));
|
|
2963
|
+
if (
|
|
2964
|
+
goal.status === input.status &&
|
|
2965
|
+
goal.evidence === evidence &&
|
|
2966
|
+
matchingIdempotentEvent &&
|
|
2967
|
+
!staleCompleteReceiptReplay
|
|
2968
|
+
) {
|
|
3991
2969
|
if (batchMetadata) {
|
|
3992
2970
|
const receipt = goal.completionVerification;
|
|
3993
2971
|
const receiptBatch = receipt?.validationBatch;
|
|
@@ -4009,6 +2987,18 @@ export async function checkpointUltragoalGoal(input: {
|
|
|
4009
2987
|
)
|
|
4010
2988
|
throw new Error(`Goal ${goal.id} has stale validation batch metadata hash in close receipt`);
|
|
4011
2989
|
}
|
|
2990
|
+
// A complete goal whose row changed after its receipt was verified is
|
|
2991
|
+
// neither a clean no-op nor a repairable context-stale replay: fail loud
|
|
2992
|
+
// instead of silently laundering a tampered/inconsistent durable row.
|
|
2993
|
+
if (
|
|
2994
|
+
input.status === "complete" &&
|
|
2995
|
+
goal.completionVerification &&
|
|
2996
|
+
goal.completionVerification.verifiedAt !== goal.updatedAt
|
|
2997
|
+
) {
|
|
2998
|
+
throw new Error(
|
|
2999
|
+
`Goal ${goal.id} changed after its completion receipt was verified; refusing idempotent replay. Investigate the durable goals.json row before re-checkpointing.`,
|
|
3000
|
+
);
|
|
3001
|
+
}
|
|
4012
3002
|
// Idempotent re-checkpoint: this goal is already recorded in the target status with the same
|
|
4013
3003
|
// evidence, so skip the plan rewrite and ledger append to avoid duplicate goal_checkpointed
|
|
4014
3004
|
// events. The ledger is the dedup source of truth because it is exactly what a duplicate write
|
|
@@ -4020,7 +3010,7 @@ export async function checkpointUltragoalGoal(input: {
|
|
|
4020
3010
|
const changeSet = input.status === "complete" ? await computeCheckpointChangeSet(input.cwd) : undefined;
|
|
4021
3011
|
if (input.status === "complete") {
|
|
4022
3012
|
validatePipelineCheckpointSafety(plan, goal, changeSet);
|
|
4023
|
-
validateCompleteCheckpointTargetGoal(goal);
|
|
3013
|
+
if (!staleCompleteReceiptReplay) validateCompleteCheckpointTargetGoal(goal);
|
|
4024
3014
|
}
|
|
4025
3015
|
const qualityGateJson =
|
|
4026
3016
|
input.status === "complete"
|
|
@@ -4047,7 +3037,7 @@ export async function checkpointUltragoalGoal(input: {
|
|
|
4047
3037
|
blockedGoal.updatedAt = now;
|
|
4048
3038
|
}
|
|
4049
3039
|
}
|
|
4050
|
-
const receiptKind = input.status === "complete" ? chooseReceiptKind(plan, goal, input.status) : null;
|
|
3040
|
+
const receiptKind = input.status === "complete" ? chooseReceiptKind(plan, ledgerBefore, goal, input.status) : null;
|
|
4051
3041
|
const pendingCheckpointEventId = crypto.randomUUID();
|
|
4052
3042
|
if (input.status === "complete" && receiptKind && qualityGateJson && !Array.isArray(qualityGateJson)) {
|
|
4053
3043
|
goal.completionVerification = buildCompletionReceipt({
|
|
@@ -4618,168 +3608,15 @@ async function readOptionalExecutorQa(cwd: string, value: string | undefined): P
|
|
|
4618
3608
|
return structured as JsonObject;
|
|
4619
3609
|
}
|
|
4620
3610
|
|
|
4621
|
-
|
|
4622
|
-
|
|
4623
|
-
|
|
4624
|
-
|
|
4625
|
-
|
|
4626
|
-
|
|
4627
|
-
|
|
4628
|
-
|
|
4629
|
-
|
|
4630
|
-
new Response(proc.stderr).text(),
|
|
4631
|
-
proc.exited,
|
|
4632
|
-
]);
|
|
4633
|
-
clearTimeout(timeout);
|
|
4634
|
-
return { ok: exitCode === 0, stdout, stderr };
|
|
4635
|
-
} catch (error) {
|
|
4636
|
-
return { ok: false, stdout: "", stderr: error instanceof Error ? error.message : String(error) };
|
|
4637
|
-
}
|
|
4638
|
-
}
|
|
4639
|
-
|
|
4640
|
-
export async function resolveGitBase(cwd: string, branch?: string): Promise<string> {
|
|
4641
|
-
if (branch) {
|
|
4642
|
-
const exists = await spawnText(["git", "rev-parse", "--verify", branch], { cwd, timeoutMs: 3000 });
|
|
4643
|
-
if (exists.ok) return branch;
|
|
4644
|
-
} else {
|
|
4645
|
-
// Prefer the NEAREST integration base (the branch this work actually forks
|
|
4646
|
-
// from) rather than always `main`. A branch opened against `dev` must be
|
|
4647
|
-
// scoped to `dev`; using a stale `main` sweeps in unrelated trunk history
|
|
4648
|
-
// and mis-attributes other people's changes to this story (e.g. falsely
|
|
4649
|
-
// tripping change-scoped gates). Among existing candidates, pick the one
|
|
4650
|
-
// whose merge-base with HEAD is closest to HEAD (fewest commits ahead).
|
|
4651
|
-
const candidates = ["origin/dev", "dev", "origin/main", "origin/master", "main", "master"];
|
|
4652
|
-
let best: { ref: string; ahead: number } | undefined;
|
|
4653
|
-
for (const candidate of candidates) {
|
|
4654
|
-
const exists = await spawnText(["git", "rev-parse", "--verify", candidate], { cwd, timeoutMs: 3000 });
|
|
4655
|
-
if (!exists.ok) continue;
|
|
4656
|
-
const mergeBase = await spawnText(["git", "merge-base", "HEAD", candidate], { cwd, timeoutMs: 3000 });
|
|
4657
|
-
if (!mergeBase.ok || !mergeBase.stdout.trim()) continue;
|
|
4658
|
-
const count = await spawnText(["git", "rev-list", "--count", `${mergeBase.stdout.trim()}..HEAD`], {
|
|
4659
|
-
cwd,
|
|
4660
|
-
timeoutMs: 3000,
|
|
4661
|
-
});
|
|
4662
|
-
const ahead = Number.parseInt(count.stdout.trim(), 10);
|
|
4663
|
-
if (!Number.isFinite(ahead)) continue;
|
|
4664
|
-
if (!best || ahead < best.ahead) best = { ref: candidate, ahead };
|
|
4665
|
-
}
|
|
4666
|
-
if (best) return best.ref;
|
|
4667
|
-
}
|
|
4668
|
-
const mergeBase = await spawnText(["git", "merge-base", "HEAD", "origin/main"], { cwd, timeoutMs: 3000 });
|
|
4669
|
-
if (mergeBase.ok && mergeBase.stdout.trim()) return mergeBase.stdout.trim();
|
|
4670
|
-
return "HEAD~1";
|
|
4671
|
-
}
|
|
4672
|
-
|
|
4673
|
-
function parseGitNameStatus(output: string): UltragoalChangeSetPath[] {
|
|
4674
|
-
const rows: UltragoalChangeSetPath[] = [];
|
|
4675
|
-
for (const line of output.split("\n")) {
|
|
4676
|
-
const trimmed = line.trim();
|
|
4677
|
-
if (!trimmed) continue;
|
|
4678
|
-
const parts = trimmed.split(/\s+/);
|
|
4679
|
-
const statusCode = parts[0] ?? "";
|
|
4680
|
-
let status: UltragoalChangeStatus = "unknown";
|
|
4681
|
-
if (statusCode.startsWith("A")) status = "added";
|
|
4682
|
-
else if (statusCode.startsWith("M")) status = "modified";
|
|
4683
|
-
else if (statusCode.startsWith("D")) status = "deleted";
|
|
4684
|
-
else if (statusCode.startsWith("R")) status = "renamed";
|
|
4685
|
-
else if (statusCode.startsWith("C")) status = "copied";
|
|
4686
|
-
const pathValue = status === "renamed" || status === "copied" ? parts[2] : parts[1];
|
|
4687
|
-
if (!pathValue) continue;
|
|
4688
|
-
const oldPath = status === "renamed" || status === "copied" ? parts[1] : undefined;
|
|
4689
|
-
rows.push({
|
|
4690
|
-
path: normalizeRepoPath(pathValue),
|
|
4691
|
-
oldPath: oldPath ? normalizeRepoPath(oldPath) : undefined,
|
|
4692
|
-
status,
|
|
4693
|
-
category: categorizeComputerChangePath(pathValue),
|
|
4694
|
-
});
|
|
4695
|
-
}
|
|
4696
|
-
return rows;
|
|
4697
|
-
}
|
|
4698
|
-
|
|
4699
|
-
function categorizeCiChangedPath(value: string): UltragoalChangeCategory {
|
|
4700
|
-
// CI_DEV_CHANGED_PATHS intentionally carries path names only. Mixed registries
|
|
4701
|
-
// such as settings-schema.ts require diff-level narrowing; without the diff,
|
|
4702
|
-
// treating the whole registry as computer-control source forces the mandatory
|
|
4703
|
-
// computer red-team suite on unrelated settings changes.
|
|
4704
|
-
if (isSettingsSchemaPath(value)) return "other";
|
|
4705
|
-
return categorizeComputerChangePath(value);
|
|
4706
|
-
}
|
|
4707
|
-
|
|
4708
|
-
function ciDevChangedPathRows(): UltragoalChangeSetPath[] {
|
|
4709
|
-
const raw = process.env.CI_DEV_CHANGED_PATHS;
|
|
4710
|
-
if (!raw) return [];
|
|
4711
|
-
return raw
|
|
4712
|
-
.split(/\r?\n/)
|
|
4713
|
-
.map(row => row.trim())
|
|
4714
|
-
.filter(Boolean)
|
|
4715
|
-
.map(pathValue => ({
|
|
4716
|
-
path: normalizeRepoPath(pathValue),
|
|
4717
|
-
status: "unknown" as UltragoalChangeStatus,
|
|
4718
|
-
category: categorizeCiChangedPath(pathValue),
|
|
4719
|
-
}));
|
|
4720
|
-
}
|
|
4721
|
-
|
|
4722
|
-
function mergeChangeSetPaths(groups: UltragoalChangeSetPath[][]): UltragoalChangeSetPath[] {
|
|
4723
|
-
const byKey = new Map<string, UltragoalChangeSetPath>();
|
|
4724
|
-
for (const row of groups.flat()) byKey.set(`${row.oldPath ?? ""}\u0000${row.path}`, row);
|
|
4725
|
-
return [...byKey.values()];
|
|
4726
|
-
}
|
|
4727
|
-
|
|
4728
|
-
async function computeCheckpointChangeSet(cwd: string): Promise<UltragoalChangeSet | undefined> {
|
|
4729
|
-
const ciChangedPaths = ciDevChangedPathRows();
|
|
4730
|
-
const inGit = await spawnText(["git", "rev-parse", "--is-inside-work-tree"], { cwd, timeoutMs: 3000 });
|
|
4731
|
-
if (!inGit.ok || inGit.stdout.trim() !== "true") {
|
|
4732
|
-
if (ciChangedPaths.length === 0) return undefined;
|
|
4733
|
-
return { source: "checkpoint-git", paths: ciChangedPaths, trusted: true };
|
|
4734
|
-
}
|
|
4735
|
-
const baseRef = await resolveGitBase(cwd);
|
|
4736
|
-
const base = baseRef;
|
|
4737
|
-
const mergeBase = await spawnText(["git", "merge-base", "HEAD", baseRef], { cwd, timeoutMs: 3000 });
|
|
4738
|
-
const [committed, unstaged, staged, stat, committedDiff, unstagedDiff, stagedDiff] = await Promise.all([
|
|
4739
|
-
spawnText(["git", "diff", "--name-status", `${base}...HEAD`], { cwd, timeoutMs: 5000 }),
|
|
4740
|
-
spawnText(["git", "diff", "--name-status"], { cwd, timeoutMs: 5000 }),
|
|
4741
|
-
spawnText(["git", "diff", "--cached", "--name-status"], { cwd, timeoutMs: 5000 }),
|
|
4742
|
-
spawnText(["git", "diff", "--stat", `${base}...HEAD`], { cwd, timeoutMs: 5000 }),
|
|
4743
|
-
spawnText(["git", "diff", `${base}...HEAD`], { cwd, timeoutMs: 5000 }),
|
|
4744
|
-
spawnText(["git", "diff"], { cwd, timeoutMs: 5000 }),
|
|
4745
|
-
spawnText(["git", "diff", "--cached"], { cwd, timeoutMs: 5000 }),
|
|
4746
|
-
]);
|
|
4747
|
-
if (!committed.ok && !unstaged.ok && !staged.ok && ciChangedPaths.length === 0) return undefined;
|
|
4748
|
-
const gitPaths = mergeChangeSetPaths([
|
|
4749
|
-
parseGitNameStatus(committed.stdout),
|
|
4750
|
-
parseGitNameStatus(unstaged.stdout),
|
|
4751
|
-
parseGitNameStatus(staged.stdout),
|
|
4752
|
-
]);
|
|
4753
|
-
const paths = gitPaths.length > 0 ? gitPaths : ciChangedPaths;
|
|
4754
|
-
return {
|
|
4755
|
-
source: "checkpoint-git",
|
|
4756
|
-
baseRef,
|
|
4757
|
-
mergeBase: mergeBase.ok && mergeBase.stdout.trim() ? mergeBase.stdout.trim() : undefined,
|
|
4758
|
-
headRef: "HEAD",
|
|
4759
|
-
paths,
|
|
4760
|
-
rawDiffStat: stat.stdout,
|
|
4761
|
-
rawDiff: [committedDiff.stdout, unstagedDiff.stdout, stagedDiff.stdout].filter(Boolean).join("\n"),
|
|
4762
|
-
trusted: true,
|
|
4763
|
-
};
|
|
4764
|
-
}
|
|
4765
|
-
|
|
4766
|
-
function parseUnifiedDiffPaths(diff: string): UltragoalChangeSetPath[] {
|
|
4767
|
-
const paths: UltragoalChangeSetPath[] = [];
|
|
4768
|
-
for (const line of diff.split("\n")) {
|
|
4769
|
-
if (!line.startsWith("diff --git ")) continue;
|
|
4770
|
-
const match = /^diff --git a\/(.+?) b\/(.+)$/.exec(line);
|
|
4771
|
-
if (!match) continue;
|
|
4772
|
-
const oldPath = normalizeRepoPath(match[1]!);
|
|
4773
|
-
const newPath = normalizeRepoPath(match[2]!);
|
|
4774
|
-
paths.push({
|
|
4775
|
-
path: newPath,
|
|
4776
|
-
oldPath: oldPath === newPath ? undefined : oldPath,
|
|
4777
|
-
status: oldPath === newPath ? "modified" : "renamed",
|
|
4778
|
-
category: categorizeComputerChangePath(newPath),
|
|
4779
|
-
});
|
|
4780
|
-
}
|
|
4781
|
-
return paths;
|
|
4782
|
-
}
|
|
3611
|
+
import {
|
|
3612
|
+
computeCheckpointChangeSet,
|
|
3613
|
+
parseGitNameStatus,
|
|
3614
|
+
parseUnifiedDiffPaths,
|
|
3615
|
+
resolveGitBase,
|
|
3616
|
+
spawnText,
|
|
3617
|
+
} from "./ultragoal-change-set";
|
|
3618
|
+
|
|
3619
|
+
export { computeCheckpointChangeSet, parseGitNameStatus, parseUnifiedDiffPaths, resolveGitBase, spawnText };
|
|
4783
3620
|
|
|
4784
3621
|
function changeSetFromReviewSource(source: JsonObject): UltragoalChangeSet | undefined {
|
|
4785
3622
|
const kind = nonEmptyString(source.kind);
|