@gajae-code/coding-agent 0.18.6 → 0.18.8
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 +318 -1
- package/dist/types/async/job-manager.d.ts +1 -1
- package/dist/types/capability/mcp.d.ts +2 -0
- package/dist/types/capability/skill.d.ts +2 -0
- package/dist/types/capability/types.d.ts +6 -0
- package/dist/types/cli/public-command-errors.d.ts +3 -1
- package/dist/types/cli/setup-cli.d.ts +1 -1
- package/dist/types/commands/deep-interview.d.ts +3 -0
- package/dist/types/config/autorouting-tier-map.d.ts +348 -9
- package/dist/types/config/file-lock.d.ts +1 -0
- package/dist/types/config/model-bindings-applier.d.ts +2 -0
- package/dist/types/config/model-profile-activation.d.ts +11 -3
- package/dist/types/config/model-registry.d.ts +4 -9
- package/dist/types/config/settings.d.ts +4 -0
- package/dist/types/config/skill-settings-defaults.d.ts +2 -0
- package/dist/types/cursor-delete-path.d.ts +1 -0
- package/dist/types/cursor-mcp-name.d.ts +8 -0
- package/dist/types/customization/inventory.d.ts +2 -0
- package/dist/types/debug/report-bundle-parent.d.ts +2 -0
- package/dist/types/defaults/gjc/extensions/grok-cli-vendor/src/provider/stream.d.ts +1 -0
- package/dist/types/defaults/gjc/extensions/grok-cli-vendor/src/provider/version-manager.d.ts +31 -0
- package/dist/types/defaults/gjc-skills.generated.d.ts +5 -6
- package/dist/types/discovery/helpers.d.ts +8 -3
- package/dist/types/export/html/css-color.d.ts +2 -0
- package/dist/types/export/html/template.generated.d.ts +1 -1
- package/dist/types/extensibility/extensions/types.d.ts +7 -0
- package/dist/types/extensibility/gjc-plugins/runtime-adapters.d.ts +14 -1
- package/dist/types/extensibility/plugins/marketplace/cache.d.ts +2 -0
- package/dist/types/extensibility/plugins/plugin-link-path.d.ts +2 -0
- package/dist/types/extensibility/runtime-skill-discovery.d.ts +3 -1
- package/dist/types/extensibility/skill-management.d.ts +3 -1
- package/dist/types/extensibility/skills.d.ts +2 -0
- package/dist/types/gjc-runtime/deep-interview-crystallize.d.ts +74 -0
- package/dist/types/gjc-runtime/deep-interview-runtime.d.ts +11 -0
- package/dist/types/gjc-runtime/gc-runtime.d.ts +5 -0
- package/dist/types/gjc-runtime/ralplan-runtime.d.ts +0 -5
- package/dist/types/gjc-runtime/state-argv.d.ts +1 -1
- package/dist/types/gjc-runtime/state-migrations.d.ts +8 -0
- package/dist/types/gjc-runtime/state-runtime.d.ts +95 -0
- package/dist/types/gjc-runtime/state-writer.d.ts +11 -1
- package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +8 -0
- package/dist/types/internal-urls/skill-path.d.ts +1 -0
- package/dist/types/lsp/workspace-path.d.ts +1 -0
- package/dist/types/modes/acp/acp-agent.d.ts +7 -0
- package/dist/types/modes/components/custom-editor.d.ts +6 -0
- package/dist/types/modes/components/customization/customization-dashboard.d.ts +1 -0
- package/dist/types/modes/components/extensions/state-manager.d.ts +4 -3
- package/dist/types/modes/controllers/input-controller.d.ts +9 -0
- package/dist/types/modes/interactive-mode.d.ts +1 -0
- package/dist/types/modes/utils/interrupt-ownership.d.ts +3 -0
- package/dist/types/modes/utils/keybinding-matchers.d.ts +1 -0
- package/dist/types/modes/utils/macos-option-forwarding.d.ts +17 -0
- package/dist/types/progress/collect-project-progress.d.ts +38 -0
- package/dist/types/progress/progress-contract.d.ts +154 -0
- package/dist/types/progress/progress-snapshot.d.ts +11 -0
- package/dist/types/progress/project-progress.d.ts +141 -0
- package/dist/types/progress/render-progress.d.ts +28 -0
- package/dist/types/runtime-mcp/elicitation.d.ts +12 -0
- package/dist/types/runtime-mcp/oauth-public-url.d.ts +1 -0
- package/dist/types/runtime-mcp/plugin-network-boundary.d.ts +1 -0
- package/dist/types/runtime-mcp/smithery-registry.d.ts +24 -0
- package/dist/types/runtime-mcp/types.d.ts +6 -0
- package/dist/types/sdk/acp/adapter.d.ts +1 -1
- package/dist/types/sdk/broker/broker-exit.d.ts +6 -2
- package/dist/types/sdk/broker/broker.d.ts +18 -1
- package/dist/types/sdk/broker/lifecycle.d.ts +9 -0
- package/dist/types/sdk/broker/session-index.d.ts +12 -6
- package/dist/types/sdk/broker/task-artifact-owner-validation.d.ts +55 -0
- package/dist/types/sdk/bus/index.d.ts +12 -1
- package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +6 -1
- package/dist/types/sdk/bus/telegram-daemon.d.ts +3 -2
- package/dist/types/sdk/client/client.d.ts +2 -0
- package/dist/types/sdk/host/host.d.ts +6 -0
- package/dist/types/sdk/host/query/handlers.d.ts +3 -0
- package/dist/types/sdk/index.d.ts +1 -0
- package/dist/types/sdk/router/session-router.d.ts +9 -0
- package/dist/types/sdk/session-list.d.ts +1 -1
- package/dist/types/sdk/session.d.ts +8 -0
- package/dist/types/session/agent-session.d.ts +38 -9
- package/dist/types/session/artifacts.d.ts +2 -2
- package/dist/types/session/internal/managed-gc-retirement-codec.d.ts +32 -0
- package/dist/types/session/internal/managed-session-scope.d.ts +79 -2
- package/dist/types/session/internal/managed-session-storage.d.ts +36 -1
- package/dist/types/session/internal/managed-task-owner-cleanup.d.ts +62 -0
- package/dist/types/session/internal/task-artifact-owner-access.d.ts +19 -0
- package/dist/types/session/internal/task-artifact-owner-transcript.d.ts +11 -0
- package/dist/types/session/session-manager.d.ts +31 -5
- package/dist/types/session/session-retirement.d.ts +71 -0
- package/dist/types/session/session-storage.d.ts +62 -48
- package/dist/types/session/session-transcript-header.d.ts +2 -0
- package/dist/types/session/task-artifact-owner-codec.d.ts +109 -0
- package/dist/types/session/task-artifact-owner-retirement.d.ts +7 -0
- package/dist/types/session/task-artifact-owner.d.ts +10 -0
- package/dist/types/setup/credential-auto-import.d.ts +1 -1
- package/dist/types/setup/credential-import.d.ts +6 -4
- package/dist/types/skill-state/active-state.d.ts +38 -0
- package/dist/types/slash-commands/helpers/progress-report.d.ts +8 -0
- package/dist/types/task/executor.d.ts +3 -0
- package/dist/types/tools/bash-skill-urls.d.ts +1 -1
- package/dist/types/tools/index.d.ts +3 -0
- package/dist/types/tools/python.d.ts +9 -1
- package/dist/types/tools/recipe/recipe-guard.d.ts +7 -0
- package/dist/types/tools/restricted-cwd.d.ts +1 -0
- package/dist/types/tools/todo-write.d.ts +6 -0
- package/dist/types/utils/tools-checksum.d.ts +7 -0
- package/package.json +7 -7
- package/scripts/generate-sdk-operation-inventory.ts +21 -0
- package/scripts/resident-memory-bench.ts +174 -20
- package/scripts/verify-managed-empty-stop.ts +28 -0
- package/src/async/job-manager.ts +228 -122
- package/src/capability/index.ts +18 -0
- package/src/capability/mcp.ts +2 -0
- package/src/capability/skill.ts +2 -0
- package/src/capability/types.ts +6 -0
- package/src/cli/auth-broker-cli.ts +1 -1
- package/src/cli/config-cli.ts +8 -18
- package/src/cli/customize-doctor.ts +15 -2
- package/src/cli/public-command-errors.ts +21 -1
- package/src/cli/setup-cli.ts +4 -4
- package/src/cli/skills-cli.ts +5 -0
- package/src/cli-main.ts +7 -8
- package/src/commands/deep-interview.ts +47 -8
- package/src/commands/sdk.ts +140 -29
- package/src/config/autorouting-tier-map.ts +117 -7
- package/src/config/file-lock.ts +307 -68
- package/src/config/mcp-schema.json +4 -0
- package/src/config/model-bindings-applier.ts +20 -0
- package/src/config/model-profile-activation.ts +240 -46
- package/src/config/model-registry.ts +47 -153
- package/src/config/settings.ts +44 -4
- package/src/config/skill-settings-defaults.ts +7 -0
- package/src/coordinator-mcp/policy.ts +3 -2
- package/src/cursor-delete-path.ts +10 -0
- package/src/cursor-mcp-name.ts +15 -0
- package/src/cursor.ts +13 -2
- package/src/customization/inventory.ts +3 -0
- package/src/debug/report-bundle-parent.ts +22 -0
- package/src/debug/report-bundle.ts +3 -0
- package/src/debug/system-info.ts +13 -1
- package/src/defaults/gjc/extensions/grok-cli-vendor/src/provider/stream.ts +67 -4
- package/src/defaults/gjc/extensions/grok-cli-vendor/src/provider/version-manager.ts +80 -0
- package/src/defaults/gjc/skills/deep-interview/SKILL.md +105 -10
- package/src/defaults/gjc/skills/ralplan/SKILL.md +6 -4
- package/src/defaults/gjc-defaults.test.ts +18 -1
- package/src/defaults/gjc-defaults.ts +3 -0
- package/src/defaults/gjc-skills.generated.ts +58 -36
- package/src/discovery/agents.ts +6 -1
- package/src/discovery/builtin.ts +10 -0
- package/src/discovery/helpers.ts +168 -23
- package/src/discovery/mcp-json.ts +6 -0
- package/src/discovery/ssh.ts +9 -4
- package/src/discovery/substitute-plugin-root.ts +3 -1
- package/src/edit/file-read-cache.ts +1 -1
- package/src/eval/py/executor.ts +380 -120
- package/src/export/html/css-color.ts +9 -0
- package/src/export/html/index.ts +5 -4
- package/src/export/html/template.generated.ts +1 -1
- package/src/extensibility/extensions/runner.ts +4 -0
- package/src/extensibility/extensions/types.ts +7 -0
- package/src/extensibility/gjc-plugins/runtime-adapters.ts +73 -8
- package/src/extensibility/plugins/manager.ts +3 -2
- package/src/extensibility/plugins/marketplace/cache.ts +15 -0
- package/src/extensibility/plugins/marketplace/manager.ts +11 -3
- package/src/extensibility/plugins/plugin-link-path.ts +12 -0
- package/src/extensibility/runtime-skill-discovery.ts +20 -2
- package/src/extensibility/skill-management.ts +6 -2
- package/src/extensibility/skills.ts +6 -1
- package/src/gjc-runtime/deep-interview-crystallize.ts +1814 -0
- package/src/gjc-runtime/deep-interview-recorder.ts +20 -2
- package/src/gjc-runtime/deep-interview-runtime.ts +1415 -13
- package/src/gjc-runtime/deep-interview-stage.ts +98 -21
- package/src/gjc-runtime/gc-runtime.ts +206 -8
- package/src/gjc-runtime/launch-worktree.ts +14 -4
- package/src/gjc-runtime/ralplan-runtime.ts +447 -52
- package/src/gjc-runtime/state-argv.ts +2 -0
- package/src/gjc-runtime/state-migrations.ts +91 -29
- package/src/gjc-runtime/state-runtime.ts +3716 -135
- package/src/gjc-runtime/state-writer.ts +83 -8
- package/src/gjc-runtime/ultragoal-runtime.ts +16 -4
- package/src/gjc-runtime/workflow-manifest.generated.json +70 -42
- package/src/gjc-runtime/workflow-manifest.ts +54 -21
- package/src/harness-control-plane/operate.ts +12 -4
- package/src/harness-control-plane/owner.ts +5 -1
- package/src/hashline/parser.ts +48 -9
- package/src/hashline/recovery.ts +13 -4
- package/src/hindsight/config.ts +9 -3
- package/src/internal-urls/docs-index.generated.ts +19 -18
- package/src/internal-urls/skill-path.ts +19 -0
- package/src/internal-urls/skill-protocol.ts +2 -0
- package/src/lsp/edits.ts +6 -0
- package/src/lsp/workspace-path.ts +51 -0
- package/src/main.ts +195 -31
- package/src/modes/acp/acp-agent.ts +163 -7
- package/src/modes/components/custom-editor.ts +14 -0
- package/src/modes/components/customization/customization-dashboard.ts +12 -0
- package/src/modes/components/extensions/extension-dashboard.ts +2 -2
- package/src/modes/components/extensions/state-manager.ts +15 -5
- package/src/modes/components/model-selector.ts +1 -4
- package/src/modes/components/provider-onboarding-selector.ts +1 -1
- package/src/modes/components/status-line/segments.ts +4 -0
- package/src/modes/controllers/event-controller.ts +11 -4
- package/src/modes/controllers/input-controller.ts +74 -29
- package/src/modes/controllers/runtime-mcp-command-controller.ts +8 -3
- package/src/modes/controllers/selector-controller.ts +28 -15
- package/src/modes/interactive-mode.ts +4 -0
- package/src/modes/runtime-init.ts +4 -0
- package/src/modes/utils/abort-message.ts +3 -1
- package/src/modes/utils/interrupt-ownership.ts +12 -0
- package/src/modes/utils/keybinding-matchers.ts +4 -0
- package/src/modes/utils/macos-option-forwarding.ts +80 -0
- package/src/progress/collect-project-progress.ts +153 -0
- package/src/progress/progress-contract.ts +172 -0
- package/src/progress/progress-snapshot.ts +140 -0
- package/src/progress/project-progress.ts +446 -0
- package/src/progress/render-progress.ts +209 -0
- package/src/prompts/system/master-mode.md +1 -0
- package/src/prompts/tools/skill-discovery.md +1 -1
- package/src/runtime-mcp/client.ts +13 -4
- package/src/runtime-mcp/config-writer.ts +10 -6
- package/src/runtime-mcp/config.ts +1 -0
- package/src/runtime-mcp/elicitation.ts +142 -0
- package/src/runtime-mcp/oauth-flow.ts +5 -0
- package/src/runtime-mcp/oauth-public-url.ts +8 -0
- package/src/runtime-mcp/plugin-network-boundary.ts +4 -0
- package/src/runtime-mcp/smithery-registry.ts +22 -3
- package/src/runtime-mcp/transports/http.ts +2 -2
- package/src/runtime-mcp/types.ts +6 -0
- package/src/sdk/acp/adapter.ts +93 -8
- package/src/sdk/broker/broker-exit.ts +52 -23
- package/src/sdk/broker/broker.ts +83 -7
- package/src/sdk/broker/ensure.ts +2 -0
- package/src/sdk/broker/lifecycle.ts +820 -73
- package/src/sdk/broker/session-index.ts +191 -81
- package/src/sdk/broker/task-artifact-owner-validation.ts +259 -0
- package/src/sdk/bus/chat-daemon-cli.ts +38 -1
- package/src/sdk/bus/chat-daemon-control.ts +2 -2
- package/src/sdk/bus/chat-daemon-runtime.ts +3 -0
- package/src/sdk/bus/index.ts +241 -46
- package/src/sdk/bus/telegram-daemon-contract.ts +6 -1
- package/src/sdk/bus/telegram-daemon.ts +4 -2
- package/src/sdk/cli/session-cli.ts +17 -3
- package/src/sdk/client/client.ts +28 -8
- package/src/sdk/host/host.ts +6 -0
- package/src/sdk/host/query/handlers.ts +9 -1
- package/src/sdk/host/session-runtime.test.ts +2805 -411
- package/src/sdk/host/session-runtime.ts +428 -111
- package/src/sdk/host/surface-policy.ts +1 -0
- package/src/sdk/index.ts +8 -0
- package/src/sdk/lifecycle-session.ts +3 -1
- package/src/sdk/mcp/server.ts +15 -3
- package/src/sdk/prompt-deadline-manager.ts +15 -0
- package/src/sdk/protocol/operation-inventory.generated.json +146 -0
- package/src/sdk/protocol/operation-registry.ts +9 -2
- package/src/sdk/router/session-router.ts +94 -14
- package/src/sdk/session-list.ts +1 -0
- package/src/sdk/session.ts +81 -38
- package/src/session/agent-session.ts +674 -224
- package/src/session/artifacts.ts +174 -70
- package/src/session/internal/managed-gc-retirement-codec.ts +319 -0
- package/src/session/internal/managed-session-scope.ts +3457 -462
- package/src/session/internal/managed-session-storage.ts +482 -61
- package/src/session/internal/managed-task-owner-cleanup.ts +473 -0
- package/src/session/internal/task-artifact-owner-access.ts +245 -0
- package/src/session/internal/task-artifact-owner-transcript.ts +614 -0
- package/src/session/session-manager.ts +939 -133
- package/src/session/session-retirement.ts +714 -0
- package/src/session/session-storage.ts +418 -174
- package/src/session/session-transcript-header.ts +17 -0
- package/src/session/task-artifact-owner-codec.ts +641 -0
- package/src/session/task-artifact-owner-retirement.ts +477 -0
- package/src/session/task-artifact-owner.ts +289 -0
- package/src/setup/credential-auto-import.ts +12 -3
- package/src/setup/credential-import.ts +176 -5
- package/src/setup/paseo/announce.ts +6 -1
- package/src/skill-state/active-state.ts +132 -2
- package/src/slash-commands/builtin-registry.ts +39 -9
- package/src/slash-commands/helpers/progress-report.ts +23 -0
- package/src/stt/setup.ts +3 -5
- package/src/task/commands.ts +3 -1
- package/src/task/executor.ts +4 -0
- package/src/task/index.ts +6 -0
- package/src/tools/ask.ts +205 -6
- package/src/tools/ast-edit.ts +4 -0
- package/src/tools/bash-allowed-prefixes.ts +15 -0
- package/src/tools/bash-skill-urls.ts +56 -3
- package/src/tools/bash.ts +48 -79
- package/src/tools/descriptors.ts +5 -1
- package/src/tools/eval.ts +56 -36
- package/src/tools/image-gen.ts +1 -1
- package/src/tools/index.ts +3 -0
- package/src/tools/python.ts +187 -66
- package/src/tools/recipe/index.ts +9 -0
- package/src/tools/recipe/recipe-guard.ts +19 -0
- package/src/tools/restricted-cwd.ts +10 -0
- package/src/tools/skill-discovery.ts +5 -0
- package/src/tools/skill.ts +28 -1
- package/src/tools/todo-write.ts +10 -1
- package/src/tools/tool-catalog.generated.ts +1 -1
- package/src/tools/write.ts +1 -0
- package/src/utils/tools-checksum.ts +34 -0
- package/src/utils/tools-manager.ts +24 -6
- package/src/web/search/providers/codex.ts +3 -2
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
|
+
import * as nodeFs from "node:fs";
|
|
2
3
|
import * as fs from "node:fs/promises";
|
|
3
4
|
import * as path from "node:path";
|
|
4
5
|
// Subpath import keeps this module native-free for the gjc-state-gates shards:
|
|
@@ -32,7 +33,9 @@ import {
|
|
|
32
33
|
} from "../skill-state/workflow-state-contract";
|
|
33
34
|
import { renderCliWriteReceipt } from "./cli-write-receipt";
|
|
34
35
|
import { applyAmbiguityFloorToEnvelope } from "./deep-interview-ambiguity";
|
|
36
|
+
import { crystalMarkdown, crystalSnapshotDigest, type DeepInterviewCrystal } from "./deep-interview-crystallize";
|
|
35
37
|
import {
|
|
38
|
+
answerHash,
|
|
36
39
|
assertDeepInterviewEnvelopeInputLimits,
|
|
37
40
|
assertDeepInterviewInputWithinLimit,
|
|
38
41
|
assertDeepInterviewIntentManifest,
|
|
@@ -43,7 +46,14 @@ import {
|
|
|
43
46
|
mergeDeepInterviewEnvelope,
|
|
44
47
|
normalizeDeepInterviewEnvelope,
|
|
45
48
|
} from "./deep-interview-state";
|
|
46
|
-
import {
|
|
49
|
+
import {
|
|
50
|
+
activeSnapshotPath,
|
|
51
|
+
auditPath,
|
|
52
|
+
modeStatePath,
|
|
53
|
+
sessionPlansDir,
|
|
54
|
+
sessionSpecsDir,
|
|
55
|
+
sessionStateDir,
|
|
56
|
+
} from "./session-layout";
|
|
47
57
|
import {
|
|
48
58
|
resolveGjcSessionForRead,
|
|
49
59
|
resolveGjcSessionForWrite,
|
|
@@ -52,7 +62,7 @@ import {
|
|
|
52
62
|
} from "./session-resolution";
|
|
53
63
|
import { classifyStateArgv, firstStateFlagValue, type StateAction, type StateArgvClassification } from "./state-argv";
|
|
54
64
|
import { renderStateGraph, type StateGraphFormat } from "./state-graph";
|
|
55
|
-
import { migrateAndPersistLegacyState, migrateWorkflowState } from "./state-migrations";
|
|
65
|
+
import { assertNotFutureWorkflowState, migrateAndPersistLegacyState, migrateWorkflowState } from "./state-migrations";
|
|
56
66
|
import {
|
|
57
67
|
buildStateStatusSummary,
|
|
58
68
|
compactProjectStateJson,
|
|
@@ -73,11 +83,14 @@ import {
|
|
|
73
83
|
type GenericHardPruneTarget,
|
|
74
84
|
hardPrune,
|
|
75
85
|
readExistingStateForMutation,
|
|
86
|
+
readWorkflowTransactionJournal,
|
|
76
87
|
type StateWriterAuditContext,
|
|
77
88
|
softDelete,
|
|
78
89
|
updateWorkflowTransactionJournal,
|
|
79
90
|
type WorkflowEnvelopeIntegrityMismatch,
|
|
91
|
+
type WorkflowTransactionJournal,
|
|
80
92
|
withWorkflowStateLock,
|
|
93
|
+
writeArtifact,
|
|
81
94
|
writeGuardedWorkflowEnvelopeAtomic,
|
|
82
95
|
} from "./state-writer";
|
|
83
96
|
import { assertSafePathComponent, CommandError, flagValue, hasFlag, isPlainObject } from "./workflow-cli-common";
|
|
@@ -123,6 +136,195 @@ function assertKnownMode(mode: string): asserts mode is CanonicalGjcWorkflowSkil
|
|
|
123
136
|
}
|
|
124
137
|
}
|
|
125
138
|
|
|
139
|
+
function assertNoFutureWorkflowEnvelope(value: unknown, skill: CanonicalGjcWorkflowSkill, surface: string): void {
|
|
140
|
+
if (!isPlainObject(value)) return;
|
|
141
|
+
try {
|
|
142
|
+
assertNotFutureWorkflowState(value, skill, surface);
|
|
143
|
+
} catch (error) {
|
|
144
|
+
throw new StateCommandError(2, error instanceof Error ? error.message : String(error));
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function assertDeepInterviewExecutionApprovalUnchanged(
|
|
149
|
+
existingEnvelope: Record<string, unknown>,
|
|
150
|
+
mergedEnvelope: Record<string, unknown>,
|
|
151
|
+
surface: string,
|
|
152
|
+
): void {
|
|
153
|
+
const existingInner = isPlainObject(existingEnvelope.state) ? existingEnvelope.state : {};
|
|
154
|
+
const mergedInner = isPlainObject(mergedEnvelope.state) ? mergedEnvelope.state : {};
|
|
155
|
+
if (mergedInner.execution_approval !== existingInner.execution_approval)
|
|
156
|
+
throw new StateCommandError(2, `crystallized execution approval is immutable through ${surface}`);
|
|
157
|
+
if (
|
|
158
|
+
JSON.stringify(mergedInner.execution_approval_receipt) !==
|
|
159
|
+
JSON.stringify(existingInner.execution_approval_receipt)
|
|
160
|
+
)
|
|
161
|
+
throw new StateCommandError(2, `crystallized execution approval provenance is immutable through ${surface}`);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function assertApprovedDeepInterviewLifecycleUnchanged(
|
|
165
|
+
existingEnvelope: Record<string, unknown>,
|
|
166
|
+
mergedEnvelope: Record<string, unknown>,
|
|
167
|
+
surface: string,
|
|
168
|
+
): void {
|
|
169
|
+
const existingInner = isPlainObject(existingEnvelope.state) ? existingEnvelope.state : {};
|
|
170
|
+
if (existingInner.execution_approval !== "approved") return;
|
|
171
|
+
if (
|
|
172
|
+
mergedEnvelope.active !== existingEnvelope.active ||
|
|
173
|
+
mergedEnvelope.current_phase !== existingEnvelope.current_phase
|
|
174
|
+
)
|
|
175
|
+
throw new StateCommandError(2, `approved Crystal lifecycle is immutable through ${surface}`);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Runtime-owned deep-interview evidence is monotonic once a Crystal exists.
|
|
180
|
+
* Keep this guard shared by the generic CLI write and the runtime reconciliation
|
|
181
|
+
* writer; having two subtly different fences is an authorization bypass.
|
|
182
|
+
*/
|
|
183
|
+
function assertDeepInterviewEvidenceUnchanged(
|
|
184
|
+
existingEnvelope: Record<string, unknown>,
|
|
185
|
+
mergedEnvelope: Record<string, unknown>,
|
|
186
|
+
surface: string,
|
|
187
|
+
): void {
|
|
188
|
+
const existingInner = isPlainObject(existingEnvelope.state) ? existingEnvelope.state : {};
|
|
189
|
+
const mergedInner = isPlainObject(mergedEnvelope.state) ? mergedEnvelope.state : {};
|
|
190
|
+
|
|
191
|
+
assertDeepInterviewExecutionApprovalUnchanged(existingEnvelope, mergedEnvelope, surface);
|
|
192
|
+
|
|
193
|
+
if (existingEnvelope.active === false && mergedEnvelope.active !== false)
|
|
194
|
+
throw new StateCommandError(
|
|
195
|
+
2,
|
|
196
|
+
surface === "generic state write"
|
|
197
|
+
? "generic state write cannot reactivate inactive deep-interview state"
|
|
198
|
+
: `inactive deep-interview state cannot be reactivated through ${surface}`,
|
|
199
|
+
);
|
|
200
|
+
if (existingInner.crystal === undefined && mergedInner.crystal !== undefined)
|
|
201
|
+
throw new StateCommandError(
|
|
202
|
+
2,
|
|
203
|
+
surface === "generic state write"
|
|
204
|
+
? "generic state write cannot introduce canonical Crystal state"
|
|
205
|
+
: `canonical Crystal is immutable through ${surface}`,
|
|
206
|
+
);
|
|
207
|
+
if (existingInner.intent_contract === undefined && mergedInner.intent_contract !== undefined)
|
|
208
|
+
throw new StateCommandError(
|
|
209
|
+
2,
|
|
210
|
+
surface === "generic state write"
|
|
211
|
+
? "generic state write cannot introduce a Round 0 intent contract"
|
|
212
|
+
: `canonical Round 0 intent contract is immutable through ${surface}`,
|
|
213
|
+
);
|
|
214
|
+
if (
|
|
215
|
+
existingInner.intent_contract !== undefined &&
|
|
216
|
+
JSON.stringify(existingInner.intent_contract) !== JSON.stringify(mergedInner.intent_contract)
|
|
217
|
+
)
|
|
218
|
+
throw new StateCommandError(2, `canonical Round 0 intent contract is immutable through ${surface}`);
|
|
219
|
+
|
|
220
|
+
if (
|
|
221
|
+
existingInner.execution_approval === "approved" &&
|
|
222
|
+
JSON.stringify(existingInner.intent_review) !== JSON.stringify(mergedInner.intent_review)
|
|
223
|
+
)
|
|
224
|
+
throw new StateCommandError(2, `approved intent review is immutable through ${surface}`);
|
|
225
|
+
|
|
226
|
+
if (existingInner.crystal !== undefined) {
|
|
227
|
+
if (JSON.stringify(existingInner.crystal) !== JSON.stringify(mergedInner.crystal))
|
|
228
|
+
throw new StateCommandError(
|
|
229
|
+
2,
|
|
230
|
+
surface === "generic state write"
|
|
231
|
+
? "canonical crystallized state cannot be replaced or deleted through generic state write"
|
|
232
|
+
: `canonical crystallized state cannot be replaced or deleted through ${surface}`,
|
|
233
|
+
);
|
|
234
|
+
for (const field of ["spec_path", "spec_sha256", "spec_slug", "spec_stage"] as const)
|
|
235
|
+
if (mergedEnvelope[field] !== existingEnvelope[field])
|
|
236
|
+
throw new StateCommandError(2, `crystallized ${field} is immutable through ${surface}`);
|
|
237
|
+
if (isPlainObject(existingInner.crystal) && existingInner.crystal.lifecycle === "ready") {
|
|
238
|
+
for (const field of ["rounds", "established_facts", "intent_review", "current_ambiguity"] as const)
|
|
239
|
+
if (JSON.stringify(mergedInner[field]) !== JSON.stringify(existingInner[field]))
|
|
240
|
+
throw new StateCommandError(2, `ready Crystal evidence is immutable through ${surface}`);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function assertHandoffLineageUnchanged(
|
|
246
|
+
existingEnvelope: Record<string, unknown>,
|
|
247
|
+
mergedEnvelope: Record<string, unknown>,
|
|
248
|
+
surface: string,
|
|
249
|
+
): void {
|
|
250
|
+
for (const field of ["handoff_from", "handoff_to", "handoff_at", "upstream_handoff_at"] as const) {
|
|
251
|
+
const existing = typeof existingEnvelope[field] === "string" ? existingEnvelope[field].trim() : "";
|
|
252
|
+
const merged = typeof mergedEnvelope[field] === "string" ? mergedEnvelope[field].trim() : "";
|
|
253
|
+
if (!existing && merged && surface === "generic state write")
|
|
254
|
+
throw new StateCommandError(2, `handoff lineage cannot be introduced through ${surface}`);
|
|
255
|
+
if (existing && mergedEnvelope[field] !== existing)
|
|
256
|
+
throw new StateCommandError(2, `handoff lineage is immutable through ${surface}`);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function persistedWorkflowReceipt(
|
|
261
|
+
value: unknown,
|
|
262
|
+
expectedSkill: CanonicalGjcWorkflowSkill,
|
|
263
|
+
): WorkflowStateReceipt | undefined {
|
|
264
|
+
if (!isPlainObject(value)) return undefined;
|
|
265
|
+
if (
|
|
266
|
+
value.version !== 1 ||
|
|
267
|
+
value.skill !== expectedSkill ||
|
|
268
|
+
(value.owner !== "gjc-state-cli" && value.owner !== "gjc-runtime" && value.owner !== "gjc-hook")
|
|
269
|
+
)
|
|
270
|
+
return undefined;
|
|
271
|
+
const requiredStrings = [
|
|
272
|
+
"command",
|
|
273
|
+
"state_path",
|
|
274
|
+
"storage_path",
|
|
275
|
+
"mutated_at",
|
|
276
|
+
"fresh_until",
|
|
277
|
+
"mutation_id",
|
|
278
|
+
] as const;
|
|
279
|
+
if (requiredStrings.some(field => typeof value[field] !== "string")) return undefined;
|
|
280
|
+
if (value.status !== "fresh" && value.status !== "stale") return undefined;
|
|
281
|
+
const receipt: WorkflowStateReceipt = {
|
|
282
|
+
version: 1,
|
|
283
|
+
skill: expectedSkill,
|
|
284
|
+
owner: value.owner as WorkflowStateReceipt["owner"],
|
|
285
|
+
command: value.command as string,
|
|
286
|
+
state_path: value.state_path as string,
|
|
287
|
+
storage_path: value.storage_path as string,
|
|
288
|
+
mutated_at: value.mutated_at as string,
|
|
289
|
+
fresh_until: value.fresh_until as string,
|
|
290
|
+
status: value.status as WorkflowStateReceipt["status"],
|
|
291
|
+
mutation_id: value.mutation_id as string,
|
|
292
|
+
};
|
|
293
|
+
if (typeof value.verb === "string") receipt.verb = value.verb;
|
|
294
|
+
if (typeof value.from_phase === "string") receipt.from_phase = value.from_phase;
|
|
295
|
+
if (typeof value.to_phase === "string") receipt.to_phase = value.to_phase;
|
|
296
|
+
if (typeof value.forced === "boolean") receipt.forced = value.forced;
|
|
297
|
+
if (isPlainObject(value.content_sha256))
|
|
298
|
+
receipt.content_sha256 = value.content_sha256 as unknown as WorkflowStateReceipt["content_sha256"];
|
|
299
|
+
return receipt;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function handoffReceiptMatches(
|
|
303
|
+
value: unknown,
|
|
304
|
+
skill: CanonicalGjcWorkflowSkill,
|
|
305
|
+
statePath: string,
|
|
306
|
+
activePath: string,
|
|
307
|
+
mutationId: string,
|
|
308
|
+
handoffAt: string,
|
|
309
|
+
): boolean {
|
|
310
|
+
const receipt = persistedWorkflowReceipt(value, skill);
|
|
311
|
+
if (
|
|
312
|
+
!receipt ||
|
|
313
|
+
receipt.mutation_id !== mutationId ||
|
|
314
|
+
receipt.mutated_at !== handoffAt ||
|
|
315
|
+
path.resolve(receipt.storage_path) !== path.resolve(statePath) ||
|
|
316
|
+
path.resolve(receipt.state_path) !== path.resolve(activePath)
|
|
317
|
+
)
|
|
318
|
+
return false;
|
|
319
|
+
const checksum = isPlainObject(receipt.content_sha256) ? receipt.content_sha256 : undefined;
|
|
320
|
+
return (
|
|
321
|
+
checksum?.algorithm === "sha256" &&
|
|
322
|
+
typeof checksum.value === "string" &&
|
|
323
|
+
checksum.value.length === 64 &&
|
|
324
|
+
path.resolve(checksum.covered_path) === path.resolve(statePath)
|
|
325
|
+
);
|
|
326
|
+
}
|
|
327
|
+
|
|
126
328
|
async function readInputJson(value: string | undefined, cwd: string): Promise<Record<string, unknown> | undefined> {
|
|
127
329
|
if (value === undefined) return undefined;
|
|
128
330
|
const trimmed = value.trim();
|
|
@@ -158,10 +360,19 @@ interface ResolvedSelectors {
|
|
|
158
360
|
payload: Record<string, unknown> | undefined;
|
|
159
361
|
}
|
|
160
362
|
|
|
363
|
+
interface ResolvedHandoffSelectors {
|
|
364
|
+
selectors: ResolvedSelectors;
|
|
365
|
+
caller: CanonicalGjcWorkflowSkill;
|
|
366
|
+
callee: string;
|
|
367
|
+
calleeIsWorkflow: boolean;
|
|
368
|
+
callerPath: string;
|
|
369
|
+
calleePath?: string;
|
|
370
|
+
}
|
|
371
|
+
|
|
161
372
|
// `clear` resolves like a read (explicit -> payload -> env -> latest-activity marker)
|
|
162
373
|
// per the spec: read/status/clear may fall back to the most-recent session. Commands
|
|
163
374
|
// that create or mutate new state roots still require an explicit/env session id.
|
|
164
|
-
const WRITE_SESSION_ACTIONS = new Set<StateAction>(["write", "handoff", "prune", "migrate"]);
|
|
375
|
+
const WRITE_SESSION_ACTIONS = new Set<StateAction>(["write", "approve-execution", "handoff", "prune", "migrate"]);
|
|
165
376
|
|
|
166
377
|
async function resolveSelectors(args: readonly string[], cwd: string, action: StateAction): Promise<ResolvedSelectors> {
|
|
167
378
|
const classification = classifyStateArgv(args);
|
|
@@ -206,6 +417,32 @@ async function resolveSelectors(args: readonly string[], cwd: string, action: St
|
|
|
206
417
|
};
|
|
207
418
|
}
|
|
208
419
|
|
|
420
|
+
async function resolveHandoffSelectors(args: readonly string[], cwd: string): Promise<ResolvedHandoffSelectors> {
|
|
421
|
+
const selectors = await resolveSelectors(args, cwd, "handoff");
|
|
422
|
+
const caller = selectors.mode ?? (await inferModeFromActiveState(cwd, selectors.gjcSessionId));
|
|
423
|
+
if (!caller) {
|
|
424
|
+
throw new StateCommandError(
|
|
425
|
+
2,
|
|
426
|
+
"gjc state handoff requires --mode <caller>, positional <caller>, input.skill, or an active workflow in the current session active state",
|
|
427
|
+
);
|
|
428
|
+
}
|
|
429
|
+
const calleeRaw = flagValue(args, "--to")?.trim();
|
|
430
|
+
if (!calleeRaw) throw new StateCommandError(2, "gjc state handoff requires --to <callee>");
|
|
431
|
+
assertSafePathComponent(calleeRaw, "to");
|
|
432
|
+
const callee = calleeRaw;
|
|
433
|
+
if (callee === caller)
|
|
434
|
+
throw new StateCommandError(2, `gjc state handoff: --to must differ from caller (both are "${caller}")`);
|
|
435
|
+
const calleeIsWorkflow = isKnownMode(callee);
|
|
436
|
+
return {
|
|
437
|
+
selectors,
|
|
438
|
+
caller,
|
|
439
|
+
callee,
|
|
440
|
+
calleeIsWorkflow,
|
|
441
|
+
callerPath: modeStateFile(cwd, caller, selectors.gjcSessionId),
|
|
442
|
+
...(calleeIsWorkflow ? { calleePath: modeStateFile(cwd, callee, selectors.gjcSessionId) } : {}),
|
|
443
|
+
};
|
|
444
|
+
}
|
|
445
|
+
|
|
209
446
|
async function inferModeFromActiveState(
|
|
210
447
|
cwd: string,
|
|
211
448
|
sessionId: string,
|
|
@@ -679,6 +916,31 @@ async function warnAndAuditOutOfBandIfNeeded(
|
|
|
679
916
|
return message;
|
|
680
917
|
}
|
|
681
918
|
|
|
919
|
+
async function assertHandoffRecoveryEnvelopeIntegrity(
|
|
920
|
+
cwd: string,
|
|
921
|
+
sessionId: string,
|
|
922
|
+
filePath: string,
|
|
923
|
+
skill: CanonicalGjcWorkflowSkill,
|
|
924
|
+
state: Record<string, unknown>,
|
|
925
|
+
label: "caller" | "callee",
|
|
926
|
+
): Promise<void> {
|
|
927
|
+
const receipt = persistedWorkflowReceipt(state.receipt, skill);
|
|
928
|
+
const checksum = receipt?.content_sha256;
|
|
929
|
+
if (
|
|
930
|
+
checksum?.algorithm !== "sha256" ||
|
|
931
|
+
typeof checksum.value !== "string" ||
|
|
932
|
+
!/^[0-9a-f]{64}$/.test(checksum.value) ||
|
|
933
|
+
typeof checksum.covered_path !== "string" ||
|
|
934
|
+
path.resolve(checksum.covered_path) !== path.resolve(filePath) ||
|
|
935
|
+
typeof checksum.computed_at !== "string" ||
|
|
936
|
+
!checksum.computed_at.trim()
|
|
937
|
+
)
|
|
938
|
+
throw new StateCommandError(2, `handoff recovery requires checksummed canonical ${label} state`);
|
|
939
|
+
const integrityWarning = await warnAndAuditOutOfBandIfNeeded(cwd, sessionId, filePath, skill);
|
|
940
|
+
if (integrityWarning)
|
|
941
|
+
throw new StateCommandError(2, `${integrityWarning}; handoff recovery refuses tampered ${label} state`);
|
|
942
|
+
}
|
|
943
|
+
|
|
682
944
|
function existingStateRevision(value: unknown): number | undefined {
|
|
683
945
|
if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
|
|
684
946
|
const revision = (value as Record<string, unknown>).state_revision;
|
|
@@ -1030,6 +1292,7 @@ export async function reconcileWorkflowSkillState(options: {
|
|
|
1030
1292
|
return withWorkflowStateLock(
|
|
1031
1293
|
path.relative(options.cwd, modeStateFile(options.cwd, options.mode, sessionId)),
|
|
1032
1294
|
async () => reconcileWorkflowSkillStateUnlocked(options, sessionId),
|
|
1295
|
+
{ cwd: options.cwd },
|
|
1033
1296
|
);
|
|
1034
1297
|
}
|
|
1035
1298
|
|
|
@@ -1041,7 +1304,10 @@ async function reconcileWorkflowSkillStateUnlocked(
|
|
|
1041
1304
|
const filePath = modeStateFile(cwd, mode, sessionId);
|
|
1042
1305
|
if (mode === "deep-interview") assertDeepInterviewStructuredResponseWithinLimit(payload);
|
|
1043
1306
|
const existingRead = await readExistingStateForMutation(filePath);
|
|
1044
|
-
|
|
1307
|
+
if (existingRead.kind === "valid")
|
|
1308
|
+
assertNoFutureWorkflowEnvelope(existingRead.value, mode, `runtime reconciliation of ${mode}`);
|
|
1309
|
+
assertNoFutureWorkflowEnvelope(payload, mode, `runtime reconciliation of ${mode}`);
|
|
1310
|
+
const existingPayload = existingRead.kind === "valid" ? migrateWorkflowState(existingRead.value, mode).state : {};
|
|
1045
1311
|
const nowIsoStr = nowIso();
|
|
1046
1312
|
const mutationId = `${mode}:reconcile:${nowIsoStr}`;
|
|
1047
1313
|
|
|
@@ -1076,10 +1342,17 @@ async function reconcileWorkflowSkillStateUnlocked(
|
|
|
1076
1342
|
unknown
|
|
1077
1343
|
>)
|
|
1078
1344
|
: mergeWithNullDelete(existingPayload, payload);
|
|
1079
|
-
|
|
1345
|
+
assertNoFutureWorkflowEnvelope(merged, mode, `runtime reconciliation of ${mode}`);
|
|
1346
|
+
assertHandoffLineageUnchanged(existingPayload, merged, "runtime reconciliation");
|
|
1347
|
+
if (mode === "deep-interview") {
|
|
1348
|
+
assertDeepInterviewEnvelopeInputLimits(merged);
|
|
1349
|
+
assertDeepInterviewEvidenceUnchanged(existingPayload, merged, "runtime reconciliation");
|
|
1350
|
+
}
|
|
1080
1351
|
merged.skill = mode;
|
|
1081
1352
|
merged.current_phase = trimmedPhase;
|
|
1082
1353
|
merged.active = active;
|
|
1354
|
+
if (mode === "deep-interview")
|
|
1355
|
+
assertApprovedDeepInterviewLifecycleUnchanged(existingPayload, merged, "runtime reconciliation");
|
|
1083
1356
|
merged.version = WORKFLOW_STATE_VERSION;
|
|
1084
1357
|
merged.updated_at = nowIsoStr;
|
|
1085
1358
|
merged.receipt = receipt;
|
|
@@ -1259,7 +1532,11 @@ async function handleWrite(args: readonly string[], cwd: string): Promise<StateC
|
|
|
1259
1532
|
`existing state for ${mode} is corrupt or tampered (${existingRead.error}); use --force to overwrite`,
|
|
1260
1533
|
);
|
|
1261
1534
|
}
|
|
1262
|
-
|
|
1535
|
+
if (existingRead.kind === "valid")
|
|
1536
|
+
assertNoFutureWorkflowEnvelope(existingRead.value, mode, `generic state write for ${mode}`);
|
|
1537
|
+
assertNoFutureWorkflowEnvelope(payload, mode, `generic state write for ${mode}`);
|
|
1538
|
+
const existingPayload =
|
|
1539
|
+
existingRead.kind === "valid" ? migrateWorkflowState(existingRead.value, mode).state : {};
|
|
1263
1540
|
const nowIsoStr = nowIso();
|
|
1264
1541
|
const mutationId = `${mode}:${nowIsoStr}`;
|
|
1265
1542
|
const receipt = buildWorkflowStateReceipt({
|
|
@@ -1305,10 +1582,22 @@ async function handleWrite(args: readonly string[], cwd: string): Promise<StateC
|
|
|
1305
1582
|
delete merged.state;
|
|
1306
1583
|
}
|
|
1307
1584
|
}
|
|
1585
|
+
assertNoFutureWorkflowEnvelope(merged, mode, `generic state write for ${mode}`);
|
|
1308
1586
|
const preDefaultValidation = validateWorkflowStateEnvelope(mode, merged);
|
|
1309
1587
|
if (!preDefaultValidation.valid) {
|
|
1310
1588
|
throw new StateCommandError(2, preDefaultValidation.error ?? `invalid ${mode} state envelope`);
|
|
1311
1589
|
}
|
|
1590
|
+
assertHandoffLineageUnchanged(existingPayload, merged, "generic state write");
|
|
1591
|
+
if (
|
|
1592
|
+
mode === "ralplan" &&
|
|
1593
|
+
typeof existingPayload.run_id === "string" &&
|
|
1594
|
+
existingPayload.run_id.trim() !== "" &&
|
|
1595
|
+
merged.run_id !== existingPayload.run_id
|
|
1596
|
+
)
|
|
1597
|
+
throw new StateCommandError(2, "Ralplan run identity cannot change through generic state write");
|
|
1598
|
+
if (mode === "deep-interview") {
|
|
1599
|
+
assertDeepInterviewEvidenceUnchanged(existingPayload, merged, "generic state write");
|
|
1600
|
+
}
|
|
1312
1601
|
merged.skill = mode;
|
|
1313
1602
|
if (incomingPhase) {
|
|
1314
1603
|
merged.current_phase = incomingPhase;
|
|
@@ -1319,7 +1608,25 @@ async function handleWrite(args: readonly string[], cwd: string): Promise<StateC
|
|
|
1319
1608
|
} else {
|
|
1320
1609
|
merged.current_phase = merged.current_phase.trim();
|
|
1321
1610
|
}
|
|
1611
|
+
if (mode === "deep-interview") {
|
|
1612
|
+
const mergedInner = isPlainObject(merged.state) ? merged.state : {};
|
|
1613
|
+
if (isPlainObject(mergedInner.crystal) && mergedInner.crystal.lifecycle !== "ready")
|
|
1614
|
+
merged.current_phase = "interviewing";
|
|
1615
|
+
assertApprovedDeepInterviewLifecycleUnchanged(existingPayload, merged, "generic state write");
|
|
1616
|
+
}
|
|
1322
1617
|
merged.version = WORKFLOW_STATE_VERSION;
|
|
1618
|
+
const existingAdmissionMarker = isPlainObject(existingPayload.final_admission_phase_transition)
|
|
1619
|
+
? existingPayload.final_admission_phase_transition
|
|
1620
|
+
: undefined;
|
|
1621
|
+
delete merged.final_admission_phase_transition;
|
|
1622
|
+
if (
|
|
1623
|
+
existingAdmissionMarker &&
|
|
1624
|
+
merged.run_id === existingAdmissionMarker.run_id &&
|
|
1625
|
+
createHash("sha256")
|
|
1626
|
+
.update(JSON.stringify(merged.auto_handoff ?? null))
|
|
1627
|
+
.digest("hex") === existingAdmissionMarker.auto_handoff_sha256
|
|
1628
|
+
)
|
|
1629
|
+
merged.final_admission_phase_transition = existingAdmissionMarker;
|
|
1323
1630
|
if (typeof merged.active !== "boolean") merged.active = true;
|
|
1324
1631
|
merged.updated_at = nowIsoStr;
|
|
1325
1632
|
merged.receipt = receipt;
|
|
@@ -1333,12 +1640,42 @@ async function handleWrite(args: readonly string[], cwd: string): Promise<StateC
|
|
|
1333
1640
|
throw new StateCommandError(2, `unknown ${mode} phase "${toPhase}"; use --force to bypass`);
|
|
1334
1641
|
}
|
|
1335
1642
|
if (fromPhase && toPhase && isKnownWorkflowState(mode, fromPhase) && isKnownWorkflowState(mode, toPhase)) {
|
|
1336
|
-
|
|
1643
|
+
let sanctionedRalplanHandoff = false;
|
|
1644
|
+
if (mode === "ralplan" && fromPhase === "final" && toPhase === "handoff" && sessionId) {
|
|
1645
|
+
try {
|
|
1646
|
+
await assertDeepInterviewExecutionLineage(cwd, sessionId, "ralplan", existingPayload);
|
|
1647
|
+
sanctionedRalplanHandoff = true;
|
|
1648
|
+
} catch {}
|
|
1649
|
+
}
|
|
1650
|
+
if (!isValidTransition(mode, fromPhase, toPhase) && !sanctionedRalplanHandoff && !forced) {
|
|
1337
1651
|
throw new StateCommandError(
|
|
1338
1652
|
2,
|
|
1339
1653
|
`invalid ${mode} phase transition from ${fromPhase} to ${toPhase}; use --force to bypass`,
|
|
1340
1654
|
);
|
|
1341
1655
|
}
|
|
1656
|
+
if (sanctionedRalplanHandoff) {
|
|
1657
|
+
const evidence = await verifiedRalplanFinalEvidence(cwd, sessionId, existingPayload);
|
|
1658
|
+
if (!evidence)
|
|
1659
|
+
throw new StateCommandError(2, "Ralplan handoff phase requires verified final plan evidence");
|
|
1660
|
+
if (
|
|
1661
|
+
merged.run_id !== evidence.runId ||
|
|
1662
|
+
createHash("sha256")
|
|
1663
|
+
.update(JSON.stringify(merged.auto_handoff ?? null))
|
|
1664
|
+
.digest("hex") !==
|
|
1665
|
+
createHash("sha256")
|
|
1666
|
+
.update(JSON.stringify(existingPayload.auto_handoff ?? null))
|
|
1667
|
+
.digest("hex")
|
|
1668
|
+
)
|
|
1669
|
+
throw new StateCommandError(2, "Ralplan handoff phase cannot change final admission identity");
|
|
1670
|
+
merged.final_admission_phase_transition = {
|
|
1671
|
+
run_id: evidence.runId,
|
|
1672
|
+
final_path: evidence.finalPath,
|
|
1673
|
+
final_sha256: evidence.finalSha256,
|
|
1674
|
+
auto_handoff_sha256: createHash("sha256")
|
|
1675
|
+
.update(JSON.stringify(existingPayload.auto_handoff ?? null))
|
|
1676
|
+
.digest("hex"),
|
|
1677
|
+
};
|
|
1678
|
+
}
|
|
1342
1679
|
}
|
|
1343
1680
|
|
|
1344
1681
|
const validation = validateWorkflowStateEnvelope(mode, merged);
|
|
@@ -1421,6 +1758,7 @@ async function handleClear(args: readonly string[], cwd: string): Promise<StateC
|
|
|
1421
1758
|
`existing state for ${mode} is corrupt or tampered (${existingRead.error}); use --force to overwrite`,
|
|
1422
1759
|
);
|
|
1423
1760
|
}
|
|
1761
|
+
if (existingRead.kind === "valid") assertNoFutureWorkflowEnvelope(existingRead.value, mode, `clear ${mode}`);
|
|
1424
1762
|
const existing = existingRead.kind === "valid" ? existingRead.value : {};
|
|
1425
1763
|
const staleReason = await describeStaleClearState(cwd, sessionId, mode, existing);
|
|
1426
1764
|
if (staleReason && !forced) {
|
|
@@ -1493,132 +1831,2466 @@ async function handleClear(args: readonly string[], cwd: string): Promise<StateC
|
|
|
1493
1831
|
|
|
1494
1832
|
const DEEP_INTERVIEW_INTENT_ID_RE = /(?:artifact|surface|integration|constraint):[a-z0-9][a-z0-9._/-]{0,127}/g;
|
|
1495
1833
|
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
MAX_DEEP_INTERVIEW_STRUCTURED_RESPONSE_LENGTH,
|
|
1513
|
-
"persisted deep-interview spec",
|
|
1514
|
-
);
|
|
1515
|
-
}
|
|
1516
|
-
const envelope = normalizeDeepInterviewEnvelope(state);
|
|
1517
|
-
const inner = envelope.state;
|
|
1518
|
-
if (!inner) return;
|
|
1519
|
-
if (inner.intent_contract === undefined) {
|
|
1520
|
-
if (inner.intent_contract_required === true)
|
|
1521
|
-
throw new StateCommandError(2, "deep-interview handoff requires a locked Round 0 intent contract");
|
|
1522
|
-
return;
|
|
1523
|
-
}
|
|
1524
|
-
assertDeepInterviewIntentManifest(inner.intent_contract);
|
|
1525
|
-
if (!specPath || !expectedSha || content === undefined)
|
|
1526
|
-
throw new StateCommandError(2, "deep-interview handoff requires a persisted intent-validated spec");
|
|
1527
|
-
if (createHash("sha256").update(content).digest("hex") !== expectedSha)
|
|
1528
|
-
throw new StateCommandError(2, "deep-interview handoff spec hash mismatch");
|
|
1529
|
-
const observedIds = [...new Set(content.match(DEEP_INTERVIEW_INTENT_ID_RE) ?? [])].sort();
|
|
1530
|
-
const rounds = Array.isArray(inner.rounds)
|
|
1531
|
-
? inner.rounds
|
|
1532
|
-
.filter(
|
|
1533
|
-
(round): round is Record<string, unknown> =>
|
|
1534
|
-
Boolean(round) && typeof round === "object" && !Array.isArray(round),
|
|
1535
|
-
)
|
|
1536
|
-
.map(round => ({ round: round.round, answer_hash: round.answer_hash }))
|
|
1537
|
-
: [];
|
|
1834
|
+
function requireReadyCanonicalCrystal(value: unknown): Record<string, unknown> {
|
|
1835
|
+
if (!isPlainObject(value) || value.lifecycle !== "ready")
|
|
1836
|
+
throw new StateCommandError(2, "approve-execution requires a ready canonical Crystal");
|
|
1837
|
+
if (value.schema_version !== 1 || !Number.isSafeInteger(value.spec_version) || (value.spec_version as number) < 1)
|
|
1838
|
+
throw new StateCommandError(2, "approve-execution requires a versioned canonical Crystal");
|
|
1839
|
+
if (!isPlainObject(value.source) || !Array.isArray(value.source.messages))
|
|
1840
|
+
throw new StateCommandError(2, "approve-execution requires canonical Crystal source evidence");
|
|
1841
|
+
const source = value.source;
|
|
1842
|
+
if (
|
|
1843
|
+
!Number.isSafeInteger(source.revision) ||
|
|
1844
|
+
!Number.isSafeInteger(source.start) ||
|
|
1845
|
+
!Number.isSafeInteger(source.end) ||
|
|
1846
|
+
typeof source.digest !== "string" ||
|
|
1847
|
+
!/^[a-f0-9]{64}$/.test(source.digest)
|
|
1848
|
+
)
|
|
1849
|
+
throw new StateCommandError(2, "approve-execution requires canonical Crystal source evidence");
|
|
1538
1850
|
try {
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1851
|
+
if (
|
|
1852
|
+
crystalSnapshotDigest({
|
|
1853
|
+
revision: source.revision as number,
|
|
1854
|
+
start: source.start as number,
|
|
1855
|
+
end: source.end as number,
|
|
1856
|
+
messages: source.messages as Array<{
|
|
1857
|
+
index: number;
|
|
1858
|
+
role: "user" | "assistant" | "system" | "tool" | "toolResult" | "developer";
|
|
1859
|
+
content: string;
|
|
1860
|
+
}>,
|
|
1861
|
+
}) !== source.digest
|
|
1862
|
+
)
|
|
1863
|
+
throw new StateCommandError(2, "approve-execution requires authentic Crystal source evidence");
|
|
1545
1864
|
} catch (error) {
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
`deep-interview handoff intent validation failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
1549
|
-
);
|
|
1865
|
+
if (error instanceof StateCommandError) throw error;
|
|
1866
|
+
throw new StateCommandError(2, "approve-execution requires authentic Crystal source evidence");
|
|
1550
1867
|
}
|
|
1868
|
+
if (!Array.isArray(value.items) || value.items.length === 0 || !isPlainObject(value.delta))
|
|
1869
|
+
throw new StateCommandError(2, "approve-execution requires complete canonical Crystal evidence");
|
|
1870
|
+
const safeId = (id: unknown): id is string => typeof id === "string" && /^[A-Za-z0-9][A-Za-z0-9._:-]*$/.test(id);
|
|
1871
|
+
if (
|
|
1872
|
+
value.items.some(item => !isPlainObject(item) || !safeId(item.id)) ||
|
|
1873
|
+
["removed_ids", "pending_removals"].some(
|
|
1874
|
+
field => value[field] !== undefined && (!Array.isArray(value[field]) || value[field].some(id => !safeId(id))),
|
|
1875
|
+
) ||
|
|
1876
|
+
(value.removed_item_anchors !== undefined &&
|
|
1877
|
+
(!Array.isArray(value.removed_item_anchors) ||
|
|
1878
|
+
value.removed_item_anchors.some(anchor => !isPlainObject(anchor) || !safeId(anchor.item))))
|
|
1879
|
+
)
|
|
1880
|
+
throw new StateCommandError(2, "approve-execution requires safe canonical Crystal identifiers");
|
|
1881
|
+
if (value.execution_approval !== "not-approved")
|
|
1882
|
+
throw new StateCommandError(2, "canonical Crystal must remain execution_approval: not-approved");
|
|
1883
|
+
return value;
|
|
1551
1884
|
}
|
|
1552
1885
|
|
|
1553
1886
|
/**
|
|
1554
|
-
*
|
|
1555
|
-
*
|
|
1556
|
-
*
|
|
1557
|
-
*
|
|
1558
|
-
*
|
|
1559
|
-
* old skill entirely or the new skill entirely, never both as active.
|
|
1560
|
-
* - As a phase: `current_phase: "handoff"` is set by this verb when demoting
|
|
1561
|
-
* the caller. Agents writing `current_phase: "handoff"` manually via
|
|
1562
|
-
* `gjc state <skill> write` are declaring "I am ready to be handed off";
|
|
1563
|
-
* the next agent-initiated `skill` tool call will then satisfy the phase
|
|
1564
|
-
* guard and may chain.
|
|
1565
|
-
*
|
|
1566
|
-
* `handoff` is in the terminal-phase set used by `isTerminalModeState` and by
|
|
1567
|
-
* the skill tool's chain guard. A manual `current_phase: "handoff"` write does
|
|
1568
|
-
* NOT mark `active: false` — only this verb does that — so a skill that wrote
|
|
1569
|
-
* the phase remains in `skill-active-state.json` until a chain call (or
|
|
1570
|
-
* explicit `clear`) demotes it.
|
|
1887
|
+
* A user-origin execution choice is deliberately kept outside the workflow
|
|
1888
|
+
* envelope. The envelope is agent/runtime state and therefore cannot itself
|
|
1889
|
+
* be evidence that a human selected the execution path. This record is the
|
|
1890
|
+
* short-lived bridge from the structured ask surface to the explicit
|
|
1891
|
+
* `approve-execution` command.
|
|
1571
1892
|
*/
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
1893
|
+
export const DEEP_INTERVIEW_EXECUTION_APPROVAL_RECORD_FILE = "deep-interview-execution-approval.json";
|
|
1894
|
+
export const DEEP_INTERVIEW_EXECUTION_APPROVAL_MAX_AGE_MS = 15 * 60 * 1000;
|
|
1895
|
+
const EXECUTION_APPROVAL_TRANSCRIPT_MAX_BYTES = 128 * 1024 * 1024;
|
|
1896
|
+
const DEEP_INTERVIEW_EXECUTION_APPROVAL_RECORD_MAX_BYTES = 16 * 1024;
|
|
1897
|
+
const DEEP_INTERVIEW_EXECUTION_APPROVAL_ID_MAX_LENGTH = 256;
|
|
1898
|
+
|
|
1899
|
+
export interface ExecutionApprovalTranscriptBoundary {
|
|
1900
|
+
byte_length: number;
|
|
1901
|
+
device: string;
|
|
1902
|
+
inode: string;
|
|
1903
|
+
leaf_id: string | null;
|
|
1904
|
+
approval_tool_call_id: string;
|
|
1905
|
+
}
|
|
1906
|
+
|
|
1907
|
+
function isExecutionApprovalTranscriptBoundary(value: unknown): value is ExecutionApprovalTranscriptBoundary {
|
|
1908
|
+
return (
|
|
1909
|
+
isPlainObject(value) &&
|
|
1910
|
+
Number.isSafeInteger(value.byte_length) &&
|
|
1911
|
+
(value.byte_length as number) > 0 &&
|
|
1912
|
+
typeof value.device === "string" &&
|
|
1913
|
+
/^\d+$/.test(value.device) &&
|
|
1914
|
+
typeof value.inode === "string" &&
|
|
1915
|
+
/^\d+$/.test(value.inode) &&
|
|
1916
|
+
(value.leaf_id === null || isExecutionApprovalId(value.leaf_id)) &&
|
|
1917
|
+
isExecutionApprovalId(value.approval_tool_call_id)
|
|
1918
|
+
);
|
|
1919
|
+
}
|
|
1920
|
+
|
|
1921
|
+
function approvalTranscriptRecords(text: string): Record<string, unknown>[] {
|
|
1922
|
+
try {
|
|
1923
|
+
const records: unknown[] = Bun.JSONL.parse(text);
|
|
1924
|
+
if (records.some(record => !isPlainObject(record))) throw new Error("invalid record");
|
|
1925
|
+
return records as Record<string, unknown>[];
|
|
1926
|
+
} catch {
|
|
1927
|
+
throw new StateCommandError(2, "execution approval transcript is malformed");
|
|
1581
1928
|
}
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
1929
|
+
}
|
|
1930
|
+
|
|
1931
|
+
function approvalTranscriptPrefix(
|
|
1932
|
+
cwd: string,
|
|
1933
|
+
sessionId: string,
|
|
1934
|
+
text: string,
|
|
1935
|
+
): {
|
|
1936
|
+
leaf: string | null;
|
|
1937
|
+
ids: Set<string>;
|
|
1938
|
+
askToolCallCounts: Map<string, number>;
|
|
1939
|
+
leafAskToolCallIds: Set<string>;
|
|
1940
|
+
} {
|
|
1941
|
+
const [header, ...records] = approvalTranscriptRecords(text);
|
|
1942
|
+
if (
|
|
1943
|
+
header?.type !== "session" ||
|
|
1944
|
+
header.id !== sessionId ||
|
|
1945
|
+
typeof header.cwd !== "string" ||
|
|
1946
|
+
path.resolve(header.cwd) !== path.resolve(cwd)
|
|
1947
|
+
)
|
|
1948
|
+
throw new StateCommandError(2, "execution approval transcript identity mismatch");
|
|
1949
|
+
const ids = new Set<string>();
|
|
1950
|
+
const askToolCallCounts = new Map<string, number>();
|
|
1951
|
+
let leafAskToolCallIds = new Set<string>();
|
|
1952
|
+
let leaf: string | null = null;
|
|
1953
|
+
for (const record of records) {
|
|
1954
|
+
if (record.type === "header_patch") {
|
|
1955
|
+
const patch = record.patch;
|
|
1956
|
+
if (!isPlainObject(patch) || ["id", "cwd", "version"].some(key => key in patch))
|
|
1957
|
+
throw new StateCommandError(2, "execution approval transcript identity patch is invalid");
|
|
1958
|
+
continue;
|
|
1959
|
+
}
|
|
1960
|
+
if (record.type === "entry_patch") {
|
|
1961
|
+
const patch = record.patch;
|
|
1962
|
+
if (
|
|
1963
|
+
!isPlainObject(patch) ||
|
|
1964
|
+
typeof record.entryId !== "string" ||
|
|
1965
|
+
!ids.has(record.entryId) ||
|
|
1966
|
+
["id", "parentId", "type"].some(key => key in patch)
|
|
1967
|
+
)
|
|
1968
|
+
throw new StateCommandError(2, "execution approval transcript branch patch is invalid");
|
|
1969
|
+
continue;
|
|
1970
|
+
}
|
|
1971
|
+
if (
|
|
1972
|
+
!isExecutionApprovalId(record.id) ||
|
|
1973
|
+
ids.has(record.id) ||
|
|
1974
|
+
(record.parentId !== null && (typeof record.parentId !== "string" || !ids.has(record.parentId)))
|
|
1975
|
+
)
|
|
1976
|
+
throw new StateCommandError(2, "execution approval transcript branch is invalid");
|
|
1977
|
+
const recordAskToolCallIds = new Set<string>();
|
|
1978
|
+
if (
|
|
1979
|
+
record.type === "message" &&
|
|
1980
|
+
isPlainObject(record.message) &&
|
|
1981
|
+
record.message.role === "toolResult" &&
|
|
1982
|
+
record.message.toolName === "ask" &&
|
|
1983
|
+
typeof record.message.toolCallId === "string"
|
|
1984
|
+
) {
|
|
1985
|
+
askToolCallCounts.set(record.message.toolCallId, (askToolCallCounts.get(record.message.toolCallId) ?? 0) + 1);
|
|
1986
|
+
}
|
|
1987
|
+
if (record.type === "message" && isPlainObject(record.message) && record.message.role === "assistant") {
|
|
1988
|
+
const content = record.message.content;
|
|
1989
|
+
if (Array.isArray(content)) {
|
|
1990
|
+
for (const block of content) {
|
|
1991
|
+
if (
|
|
1992
|
+
isPlainObject(block) &&
|
|
1993
|
+
block.type === "toolCall" &&
|
|
1994
|
+
block.name === "ask" &&
|
|
1995
|
+
typeof block.id === "string"
|
|
1996
|
+
) {
|
|
1997
|
+
recordAskToolCallIds.add(block.id);
|
|
1998
|
+
askToolCallCounts.set(block.id, (askToolCallCounts.get(block.id) ?? 0) + 1);
|
|
1999
|
+
}
|
|
2000
|
+
}
|
|
2001
|
+
}
|
|
2002
|
+
}
|
|
2003
|
+
leafAskToolCallIds = recordAskToolCallIds;
|
|
2004
|
+
ids.add(record.id);
|
|
2005
|
+
leaf = record.id;
|
|
1585
2006
|
}
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
2007
|
+
return { leaf, ids, askToolCallCounts, leafAskToolCallIds };
|
|
2008
|
+
}
|
|
2009
|
+
|
|
2010
|
+
export async function captureExecutionApprovalTranscriptBoundary(
|
|
2011
|
+
cwd: string,
|
|
2012
|
+
sessionId: string,
|
|
2013
|
+
transcriptPath: string,
|
|
2014
|
+
transcriptSha256: string,
|
|
2015
|
+
approvalToolCallId: string,
|
|
2016
|
+
): Promise<ExecutionApprovalTranscriptBoundary> {
|
|
2017
|
+
if (!isExecutionApprovalId(approvalToolCallId))
|
|
2018
|
+
throw new StateCommandError(2, "execution approval transcript tool-call identity is invalid");
|
|
2019
|
+
const before = await fs.lstat(transcriptPath, { bigint: true });
|
|
2020
|
+
const text = await readBoundedIdentityText(
|
|
2021
|
+
transcriptPath,
|
|
2022
|
+
EXECUTION_APPROVAL_TRANSCRIPT_MAX_BYTES,
|
|
2023
|
+
"execution approval transcript",
|
|
2024
|
+
);
|
|
2025
|
+
const after = await fs.lstat(transcriptPath, { bigint: true });
|
|
2026
|
+
if ((await fs.realpath(transcriptPath)) !== path.resolve(transcriptPath))
|
|
2027
|
+
throw new StateCommandError(2, "execution approval transcript path is not canonical");
|
|
2028
|
+
if (
|
|
2029
|
+
!sameBoundedFileIdentity(before, after) ||
|
|
2030
|
+
text === undefined ||
|
|
2031
|
+
!text.endsWith("\n") ||
|
|
2032
|
+
createHash("sha256").update(text).digest("hex") !== transcriptSha256
|
|
2033
|
+
)
|
|
2034
|
+
throw new StateCommandError(2, "execution approval transcript changed before recording consent");
|
|
2035
|
+
const prefix = approvalTranscriptPrefix(cwd, sessionId, text);
|
|
2036
|
+
if (prefix.askToolCallCounts.get(approvalToolCallId) !== 1 || !prefix.leafAskToolCallIds.has(approvalToolCallId))
|
|
2037
|
+
throw new StateCommandError(2, "execution approval transcript boundary lacks the current Ask call");
|
|
2038
|
+
return {
|
|
2039
|
+
byte_length: Buffer.byteLength(text),
|
|
2040
|
+
device: after.dev.toString(),
|
|
2041
|
+
inode: after.ino.toString(),
|
|
2042
|
+
leaf_id: prefix.leaf,
|
|
2043
|
+
approval_tool_call_id: approvalToolCallId,
|
|
2044
|
+
};
|
|
2045
|
+
}
|
|
2046
|
+
|
|
2047
|
+
export async function assertExecutionApprovalTranscriptBoundary(
|
|
2048
|
+
cwd: string,
|
|
2049
|
+
sessionId: string,
|
|
2050
|
+
transcriptPath: string,
|
|
2051
|
+
transcriptSha256: string,
|
|
2052
|
+
boundary: unknown,
|
|
2053
|
+
): Promise<void> {
|
|
2054
|
+
if (!isExecutionApprovalTranscriptBoundary(boundary))
|
|
2055
|
+
throw new StateCommandError(2, "execution approval transcript boundary is invalid");
|
|
2056
|
+
const before = await fs.lstat(transcriptPath, { bigint: true });
|
|
2057
|
+
const text = await readBoundedIdentityText(
|
|
2058
|
+
transcriptPath,
|
|
2059
|
+
EXECUTION_APPROVAL_TRANSCRIPT_MAX_BYTES,
|
|
2060
|
+
"execution approval transcript",
|
|
2061
|
+
);
|
|
2062
|
+
const after = await fs.lstat(transcriptPath, { bigint: true });
|
|
2063
|
+
if ((await fs.realpath(transcriptPath)) !== path.resolve(transcriptPath))
|
|
2064
|
+
throw new StateCommandError(2, "execution approval transcript path is not canonical");
|
|
2065
|
+
if (
|
|
2066
|
+
!sameBoundedFileIdentity(before, after) ||
|
|
2067
|
+
after.dev.toString() !== boundary.device ||
|
|
2068
|
+
after.ino.toString() !== boundary.inode ||
|
|
2069
|
+
text === undefined
|
|
2070
|
+
)
|
|
2071
|
+
throw new StateCommandError(2, "execution approval transcript identity changed");
|
|
2072
|
+
const bytes = Buffer.from(text);
|
|
2073
|
+
const prefix = bytes.subarray(0, boundary.byte_length);
|
|
2074
|
+
if (prefix.length !== boundary.byte_length || createHash("sha256").update(prefix).digest("hex") !== transcriptSha256)
|
|
2075
|
+
throw new StateCommandError(2, "execution approval transcript prefix changed after user approval");
|
|
2076
|
+
const branch = approvalTranscriptPrefix(cwd, sessionId, prefix.toString("utf8"));
|
|
2077
|
+
if (branch.leaf !== boundary.leaf_id)
|
|
2078
|
+
throw new StateCommandError(2, "execution approval transcript branch identity changed");
|
|
2079
|
+
if (
|
|
2080
|
+
branch.askToolCallCounts.get(boundary.approval_tool_call_id) !== 1 ||
|
|
2081
|
+
!branch.leafAskToolCallIds.has(boundary.approval_tool_call_id)
|
|
2082
|
+
)
|
|
2083
|
+
throw new StateCommandError(2, "execution approval Ask tool-call identity was reused from the captured prefix");
|
|
2084
|
+
const suffix = bytes.subarray(boundary.byte_length).toString("utf8");
|
|
2085
|
+
if (suffix && !suffix.endsWith("\n"))
|
|
2086
|
+
throw new StateCommandError(2, "execution approval transcript continuation is incomplete");
|
|
2087
|
+
let approvalAskResultSeen = false;
|
|
2088
|
+
for (const record of approvalTranscriptRecords(suffix)) {
|
|
2089
|
+
const message = record.message;
|
|
2090
|
+
const isUserBearingAskCall =
|
|
2091
|
+
record.type === "message" &&
|
|
2092
|
+
isPlainObject(message) &&
|
|
2093
|
+
message.role === "assistant" &&
|
|
2094
|
+
Array.isArray(message.content) &&
|
|
2095
|
+
message.content.some(block => isPlainObject(block) && block.type === "toolCall" && block.name === "ask");
|
|
2096
|
+
if (isUserBearingAskCall)
|
|
2097
|
+
throw new StateCommandError(2, "execution approval transcript continuation contains a new Ask call");
|
|
2098
|
+
const isUserBearingAskResult =
|
|
2099
|
+
record.type === "message" &&
|
|
2100
|
+
isPlainObject(message) &&
|
|
2101
|
+
message.role === "toolResult" &&
|
|
2102
|
+
message.toolName === "ask";
|
|
2103
|
+
const isRecordedApprovalAskResult =
|
|
2104
|
+
isUserBearingAskResult &&
|
|
2105
|
+
!approvalAskResultSeen &&
|
|
2106
|
+
message.toolCallId === boundary.approval_tool_call_id &&
|
|
2107
|
+
branch.leafAskToolCallIds.has(boundary.approval_tool_call_id);
|
|
2108
|
+
if (
|
|
2109
|
+
isUserBearingAskResult &&
|
|
2110
|
+
message.toolCallId === boundary.approval_tool_call_id &&
|
|
2111
|
+
!branch.leafAskToolCallIds.has(boundary.approval_tool_call_id)
|
|
2112
|
+
)
|
|
2113
|
+
throw new StateCommandError(
|
|
2114
|
+
2,
|
|
2115
|
+
"execution approval Ask tool-call identity was reused from the captured prefix",
|
|
2116
|
+
);
|
|
2117
|
+
if (
|
|
2118
|
+
record.type !== "message" ||
|
|
2119
|
+
!isExecutionApprovalId(record.id) ||
|
|
2120
|
+
branch.ids.has(record.id) ||
|
|
2121
|
+
record.parentId !== branch.leaf ||
|
|
2122
|
+
!isPlainObject(record.message) ||
|
|
2123
|
+
!["assistant", "toolResult"].includes(String(record.message.role)) ||
|
|
2124
|
+
(isUserBearingAskResult && !isRecordedApprovalAskResult)
|
|
2125
|
+
)
|
|
2126
|
+
throw new StateCommandError(
|
|
2127
|
+
2,
|
|
2128
|
+
isUserBearingAskResult
|
|
2129
|
+
? "execution approval transcript continuation contains a user-bearing Ask result"
|
|
2130
|
+
: "execution approval transcript continuation changed user evidence or branch",
|
|
2131
|
+
);
|
|
2132
|
+
if (isRecordedApprovalAskResult) approvalAskResultSeen = true;
|
|
2133
|
+
branch.ids.add(record.id);
|
|
2134
|
+
branch.leaf = record.id;
|
|
1591
2135
|
}
|
|
2136
|
+
if (!approvalAskResultSeen)
|
|
2137
|
+
throw new StateCommandError(2, "execution approval transcript continuation lacks the recorded Ask result");
|
|
2138
|
+
}
|
|
1592
2139
|
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
2140
|
+
export type ExecutionApprovalStage = "deep-interview" | "ralplan";
|
|
2141
|
+
|
|
2142
|
+
/** Select lineage before validation; malformed Crystal evidence must never fall back. */
|
|
2143
|
+
export async function executionApprovalLineage(
|
|
2144
|
+
cwd: string,
|
|
2145
|
+
sessionId: string,
|
|
2146
|
+
stage: ExecutionApprovalStage,
|
|
2147
|
+
): Promise<"crystal" | "ordinary"> {
|
|
2148
|
+
const current = await readExistingStateForMutation(modeStateFile(cwd, stage, sessionId));
|
|
2149
|
+
if (current.kind !== "valid") throw new StateCommandError(2, "execution approval requires valid workflow state");
|
|
2150
|
+
assertNoFutureWorkflowEnvelope(current.value, stage, "execution approval");
|
|
2151
|
+
if (await warnAndAuditOutOfBandIfNeeded(cwd, sessionId, modeStateFile(cwd, stage, sessionId), stage))
|
|
2152
|
+
throw new StateCommandError(2, "execution approval refuses tampered workflow state");
|
|
2153
|
+
let interview = current.value;
|
|
2154
|
+
if (stage === "ralplan") {
|
|
2155
|
+
const upstream = current.value.handoff_from;
|
|
2156
|
+
if (upstream && upstream !== "deep-interview")
|
|
2157
|
+
throw new StateCommandError(2, "execution approval has unsupported upstream lineage");
|
|
2158
|
+
if (!(await hasCurrentDeepInterviewHandoff(cwd, sessionId, "ralplan", current.value))) return "ordinary";
|
|
2159
|
+
const read = await readExistingStateForMutation(modeStateFile(cwd, "deep-interview", sessionId));
|
|
2160
|
+
if (read.kind !== "valid") throw new StateCommandError(2, "execution approval upstream state is unavailable");
|
|
2161
|
+
assertNoFutureWorkflowEnvelope(read.value, "deep-interview", "execution approval upstream");
|
|
2162
|
+
if (
|
|
2163
|
+
await warnAndAuditOutOfBandIfNeeded(
|
|
2164
|
+
cwd,
|
|
2165
|
+
sessionId,
|
|
2166
|
+
modeStateFile(cwd, "deep-interview", sessionId),
|
|
2167
|
+
"deep-interview",
|
|
2168
|
+
)
|
|
2169
|
+
)
|
|
2170
|
+
throw new StateCommandError(2, "execution approval refuses tampered upstream state");
|
|
2171
|
+
if (
|
|
2172
|
+
read.value.active !== false ||
|
|
2173
|
+
read.value.current_phase !== "handoff" ||
|
|
2174
|
+
read.value.handoff_to !== stage ||
|
|
2175
|
+
read.value.handoff_at !== (current.value.upstream_handoff_at ?? current.value.handoff_at)
|
|
2176
|
+
)
|
|
2177
|
+
throw new StateCommandError(2, "execution approval cannot authenticate Deep Interview approval lineage");
|
|
2178
|
+
interview = read.value;
|
|
1602
2179
|
}
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
2180
|
+
const inner = isPlainObject(interview.state) ? interview.state : {};
|
|
2181
|
+
const receipt = isPlainObject(interview.receipt) ? interview.receipt : {};
|
|
2182
|
+
return inner.crystal !== undefined || receipt.command === "gjc deep-interview crystallize" ? "crystal" : "ordinary";
|
|
2183
|
+
}
|
|
2184
|
+
|
|
2185
|
+
interface NonCrystalExecutionApprovalRecord {
|
|
2186
|
+
schema_version: 1;
|
|
2187
|
+
status: "pending" | "consumed" | "revoked";
|
|
2188
|
+
session_id: string;
|
|
2189
|
+
stage: ExecutionApprovalStage;
|
|
2190
|
+
target: "ultragoal";
|
|
2191
|
+
state_path: string;
|
|
2192
|
+
state_revision: number;
|
|
2193
|
+
artifact_path: string;
|
|
2194
|
+
artifact_sha256: string;
|
|
2195
|
+
run_id: string | null;
|
|
2196
|
+
question_id: string;
|
|
2197
|
+
gate_id: string;
|
|
2198
|
+
answer_hash: string;
|
|
2199
|
+
transcript_path: string;
|
|
2200
|
+
transcript_sha256: string;
|
|
2201
|
+
transcript_boundary: ExecutionApprovalTranscriptBoundary;
|
|
2202
|
+
created_at: string;
|
|
2203
|
+
expires_at: string;
|
|
2204
|
+
}
|
|
2205
|
+
|
|
2206
|
+
export interface ExecutionApprovalPresentation {
|
|
2207
|
+
state_path: string;
|
|
2208
|
+
state_revision: number;
|
|
2209
|
+
artifact_path: string;
|
|
2210
|
+
artifact_sha256: string;
|
|
2211
|
+
run_id: string | null;
|
|
2212
|
+
}
|
|
2213
|
+
|
|
2214
|
+
export function nonCrystalExecutionApprovalRecordPath(
|
|
2215
|
+
cwd: string,
|
|
2216
|
+
sessionId: string,
|
|
2217
|
+
stage: ExecutionApprovalStage,
|
|
2218
|
+
): string {
|
|
2219
|
+
return path.join(sessionStateDir(cwd, sessionId), `${stage}-ordinary-execution-approval.json`);
|
|
2220
|
+
}
|
|
2221
|
+
|
|
2222
|
+
async function nonCrystalApprovalPublication(cwd: string, sessionId: string, stage: ExecutionApprovalStage) {
|
|
2223
|
+
if ((await executionApprovalLineage(cwd, sessionId, stage)) !== "ordinary")
|
|
2224
|
+
throw new StateCommandError(2, "ordinary execution approval cannot authorize Crystal lineage");
|
|
2225
|
+
const statePath = modeStateFile(cwd, stage, sessionId);
|
|
2226
|
+
const read = await readExistingStateForMutation(statePath);
|
|
2227
|
+
if (read.kind !== "valid") throw new StateCommandError(2, "execution approval state is unavailable");
|
|
2228
|
+
const state = read.value;
|
|
2229
|
+
if (state.active !== true || !["final", "handoff"].includes(String(state.current_phase)))
|
|
2230
|
+
throw new StateCommandError(2, "execution approval requires an active final publication");
|
|
2231
|
+
if (stage === "ralplan") {
|
|
2232
|
+
const final = await verifiedRalplanFinalEvidence(cwd, sessionId, state);
|
|
2233
|
+
if (!final) throw new StateCommandError(2, "execution approval requires verified Ralplan final evidence");
|
|
2234
|
+
return {
|
|
2235
|
+
state_path: final.statePath,
|
|
2236
|
+
state_revision: final.stateRevision,
|
|
2237
|
+
artifact_path: final.finalPath,
|
|
2238
|
+
artifact_sha256: final.finalSha256,
|
|
2239
|
+
run_id: final.runId,
|
|
2240
|
+
};
|
|
1608
2241
|
}
|
|
1609
|
-
|
|
2242
|
+
await assertDeepInterviewHandoffReady(state, { cwd, sessionId, statePath });
|
|
2243
|
+
const receipt = persistedWorkflowReceipt(state.receipt, "deep-interview");
|
|
2244
|
+
if (
|
|
2245
|
+
state.spec_stage !== "final" ||
|
|
2246
|
+
typeof state.spec_path !== "string" ||
|
|
2247
|
+
!isSha256(state.spec_sha256) ||
|
|
2248
|
+
receipt?.owner !== "gjc-runtime" ||
|
|
2249
|
+
receipt.command !== "gjc deep-interview persist-spec-state" ||
|
|
2250
|
+
receipt.content_sha256?.covered_path !== path.resolve(statePath)
|
|
2251
|
+
)
|
|
2252
|
+
throw new StateCommandError(2, "execution approval requires a canonically published final spec");
|
|
2253
|
+
const relative = path.relative(path.resolve(sessionSpecsDir(cwd, sessionId)), path.resolve(state.spec_path));
|
|
2254
|
+
if (
|
|
2255
|
+
relative.startsWith("..") ||
|
|
2256
|
+
path.isAbsolute(relative) ||
|
|
2257
|
+
(await hashIdentityFile(state.spec_path, "ordinary final spec")) !== state.spec_sha256
|
|
2258
|
+
)
|
|
2259
|
+
throw new StateCommandError(2, "execution approval final spec identity mismatch");
|
|
2260
|
+
const revision = existingStateRevision(state);
|
|
2261
|
+
if (!Number.isSafeInteger(revision) || typeof revision !== "number" || revision < 0)
|
|
2262
|
+
throw new StateCommandError(2, "execution approval state revision is invalid");
|
|
2263
|
+
return {
|
|
2264
|
+
state_path: path.resolve(statePath),
|
|
2265
|
+
state_revision: revision,
|
|
2266
|
+
artifact_path: path.resolve(state.spec_path),
|
|
2267
|
+
artifact_sha256: state.spec_sha256,
|
|
2268
|
+
run_id: null,
|
|
2269
|
+
};
|
|
2270
|
+
}
|
|
2271
|
+
|
|
2272
|
+
/** Capture the exact publication shown by an execution approval gate. */
|
|
2273
|
+
export async function captureExecutionApprovalPresentation(
|
|
2274
|
+
cwd: string,
|
|
2275
|
+
sessionId: string,
|
|
2276
|
+
stage: ExecutionApprovalStage,
|
|
2277
|
+
): Promise<ExecutionApprovalPresentation> {
|
|
2278
|
+
if ((await executionApprovalLineage(cwd, sessionId, stage)) === "ordinary")
|
|
2279
|
+
return nonCrystalApprovalPublication(cwd, sessionId, stage);
|
|
2280
|
+
const statePath = modeStateFile(cwd, "deep-interview", sessionId);
|
|
2281
|
+
const read = await readExistingStateForMutation(statePath);
|
|
2282
|
+
if (read.kind !== "valid") throw new StateCommandError(2, "execution approval state is unavailable");
|
|
2283
|
+
const envelope = read.value;
|
|
2284
|
+
if (stage === "ralplan") {
|
|
2285
|
+
const ralplanRead = await readExistingStateForMutation(modeStateFile(cwd, "ralplan", sessionId));
|
|
2286
|
+
if (ralplanRead.kind !== "valid")
|
|
2287
|
+
throw new StateCommandError(2, "Ralplan execution approval requires valid final state");
|
|
2288
|
+
const final = await verifiedRalplanFinalEvidence(cwd, sessionId, ralplanRead.value);
|
|
2289
|
+
if (!final) throw new StateCommandError(2, "execution approval requires verified Ralplan final evidence");
|
|
2290
|
+
return {
|
|
2291
|
+
state_path: path.resolve(statePath),
|
|
2292
|
+
state_revision: existingStateRevision(envelope)!,
|
|
2293
|
+
artifact_path: final.finalPath,
|
|
2294
|
+
artifact_sha256: final.finalSha256,
|
|
2295
|
+
run_id: final.runId,
|
|
2296
|
+
};
|
|
2297
|
+
}
|
|
2298
|
+
if (typeof envelope.spec_path !== "string" || !isSha256(envelope.spec_sha256))
|
|
2299
|
+
throw new StateCommandError(2, "execution approval requires a canonically published final spec");
|
|
2300
|
+
const revision = existingStateRevision(envelope);
|
|
2301
|
+
if (revision === undefined) throw new StateCommandError(2, "execution approval state revision is invalid");
|
|
2302
|
+
return {
|
|
2303
|
+
state_path: path.resolve(statePath),
|
|
2304
|
+
state_revision: revision,
|
|
2305
|
+
artifact_path: path.resolve(envelope.spec_path),
|
|
2306
|
+
artifact_sha256: envelope.spec_sha256,
|
|
2307
|
+
run_id: null,
|
|
2308
|
+
};
|
|
2309
|
+
}
|
|
1610
2310
|
|
|
1611
|
-
|
|
1612
|
-
const
|
|
1613
|
-
const
|
|
2311
|
+
async function writeNonCrystalApproval(cwd: string, record: NonCrystalExecutionApprovalRecord): Promise<void> {
|
|
2312
|
+
const recordPath = nonCrystalExecutionApprovalRecordPath(cwd, record.session_id, record.stage);
|
|
2313
|
+
const content = `${JSON.stringify(record)}\n`;
|
|
2314
|
+
const digest = createHash("sha256").update(content).digest("hex");
|
|
2315
|
+
await writeArtifact(recordPath, content, {
|
|
1614
2316
|
cwd,
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
2317
|
+
audit: {
|
|
2318
|
+
category: "artifact",
|
|
2319
|
+
verb: "write",
|
|
2320
|
+
owner: "gjc-runtime",
|
|
2321
|
+
skill: record.stage,
|
|
2322
|
+
sessionId: record.session_id,
|
|
2323
|
+
},
|
|
2324
|
+
});
|
|
2325
|
+
const auditEntry = {
|
|
2326
|
+
ts: nowIso(),
|
|
2327
|
+
category: "state",
|
|
2328
|
+
verb: "approve-execution",
|
|
2329
|
+
owner: "gjc-runtime",
|
|
2330
|
+
skill: record.stage,
|
|
2331
|
+
mutation_id: `${record.stage}:ordinary-execution-approval:${record.status}:${digest}`,
|
|
2332
|
+
forced: false,
|
|
2333
|
+
paths: [recordPath],
|
|
2334
|
+
ordinary_approval_status: record.status,
|
|
2335
|
+
question_id: record.question_id,
|
|
2336
|
+
gate_id: record.gate_id,
|
|
2337
|
+
artifact_sha256: record.artifact_sha256,
|
|
2338
|
+
run_id: record.run_id,
|
|
2339
|
+
ordinary_approval_sha256: digest,
|
|
2340
|
+
} satisfies AuditEntry & Record<string, unknown>;
|
|
2341
|
+
await appendAuditEntry(cwd, record.session_id, auditEntry);
|
|
2342
|
+
}
|
|
2343
|
+
|
|
2344
|
+
async function nonCrystalApprovalAuditRows(
|
|
2345
|
+
cwd: string,
|
|
2346
|
+
sessionId: string,
|
|
2347
|
+
stage: ExecutionApprovalStage,
|
|
2348
|
+
): Promise<Record<string, unknown>[]> {
|
|
2349
|
+
const recordPath = nonCrystalExecutionApprovalRecordPath(cwd, sessionId, stage);
|
|
2350
|
+
const audit = await readBoundedIdentityText(auditPath(cwd, sessionId), 16 * 1024 * 1024, "execution approval audit");
|
|
2351
|
+
const rows = (audit ?? "")
|
|
2352
|
+
.split(/\r?\n/)
|
|
2353
|
+
.filter(Boolean)
|
|
2354
|
+
.map(line => {
|
|
2355
|
+
const row: unknown = JSON.parse(line);
|
|
2356
|
+
if (!isPlainObject(row)) throw new StateCommandError(2, "execution approval audit is invalid");
|
|
2357
|
+
return row;
|
|
2358
|
+
});
|
|
2359
|
+
return rows.filter(
|
|
2360
|
+
row =>
|
|
2361
|
+
row.owner === "gjc-runtime" &&
|
|
2362
|
+
row.verb === "approve-execution" &&
|
|
2363
|
+
row.skill === stage &&
|
|
2364
|
+
Array.isArray(row.paths) &&
|
|
2365
|
+
row.paths.includes(recordPath) &&
|
|
2366
|
+
row.ordinary_approval_sha256,
|
|
2367
|
+
);
|
|
2368
|
+
}
|
|
2369
|
+
|
|
2370
|
+
async function readNonCrystalApproval(
|
|
2371
|
+
cwd: string,
|
|
2372
|
+
sessionId: string,
|
|
2373
|
+
stage: ExecutionApprovalStage,
|
|
2374
|
+
): Promise<NonCrystalExecutionApprovalRecord | undefined> {
|
|
2375
|
+
const recordPath = nonCrystalExecutionApprovalRecordPath(cwd, sessionId, stage);
|
|
2376
|
+
const content = await readBoundedIdentityText(
|
|
2377
|
+
recordPath,
|
|
2378
|
+
DEEP_INTERVIEW_EXECUTION_APPROVAL_RECORD_MAX_BYTES,
|
|
2379
|
+
"ordinary execution approval",
|
|
2380
|
+
);
|
|
2381
|
+
if (content === undefined) return undefined;
|
|
2382
|
+
const value: unknown = JSON.parse(content);
|
|
2383
|
+
if (
|
|
2384
|
+
!isPlainObject(value) ||
|
|
2385
|
+
value.schema_version !== 1 ||
|
|
2386
|
+
value.session_id !== sessionId ||
|
|
2387
|
+
value.stage !== stage ||
|
|
2388
|
+
value.target !== "ultragoal" ||
|
|
2389
|
+
!["pending", "consumed", "revoked"].includes(String(value.status)) ||
|
|
2390
|
+
!isExecutionApprovalId(value.question_id) ||
|
|
2391
|
+
!isExecutionApprovalId(value.gate_id) ||
|
|
2392
|
+
!isSha256(value.answer_hash) ||
|
|
2393
|
+
typeof value.transcript_path !== "string" ||
|
|
2394
|
+
!path.isAbsolute(value.transcript_path) ||
|
|
2395
|
+
!isSha256(value.transcript_sha256) ||
|
|
2396
|
+
!isExecutionApprovalTranscriptBoundary(value.transcript_boundary) ||
|
|
2397
|
+
typeof value.expires_at !== "string" ||
|
|
2398
|
+
!Number.isFinite(Date.parse(value.expires_at))
|
|
2399
|
+
)
|
|
2400
|
+
throw new StateCommandError(2, "ordinary execution approval record is invalid");
|
|
2401
|
+
const digest = createHash("sha256").update(content).digest("hex");
|
|
2402
|
+
const latest = (await nonCrystalApprovalAuditRows(cwd, sessionId, stage)).at(-1);
|
|
2403
|
+
if (latest?.ordinary_approval_sha256 !== digest)
|
|
2404
|
+
throw new StateCommandError(2, "ordinary execution approval lacks sanctioned audit provenance");
|
|
2405
|
+
return value as unknown as NonCrystalExecutionApprovalRecord;
|
|
2406
|
+
}
|
|
2407
|
+
|
|
2408
|
+
export async function recordNonCrystalExecutionApproval(options: {
|
|
2409
|
+
cwd: string;
|
|
2410
|
+
sessionId: string;
|
|
2411
|
+
approvalStage: ExecutionApprovalStage;
|
|
2412
|
+
questionId: string;
|
|
2413
|
+
gateId: string;
|
|
2414
|
+
target: "ultragoal";
|
|
2415
|
+
selectedOptions: string[];
|
|
2416
|
+
transcriptPath: string;
|
|
2417
|
+
transcriptSha256: string;
|
|
2418
|
+
toolCallId: string;
|
|
2419
|
+
presentation: ExecutionApprovalPresentation;
|
|
2420
|
+
}): Promise<void> {
|
|
2421
|
+
const { cwd, sessionId, approvalStage: stage } = options;
|
|
2422
|
+
if (
|
|
2423
|
+
!isExecutionApprovalId(options.questionId) ||
|
|
2424
|
+
!isExecutionApprovalId(options.gateId) ||
|
|
2425
|
+
!isExecutionApprovalId(options.toolCallId) ||
|
|
2426
|
+
!options.presentation ||
|
|
2427
|
+
options.target !== "ultragoal" ||
|
|
2428
|
+
options.selectedOptions.length !== 1
|
|
2429
|
+
)
|
|
2430
|
+
throw new StateCommandError(2, "ordinary execution approval descriptor is invalid");
|
|
2431
|
+
await withWorkflowStateLock(
|
|
2432
|
+
modeStateFile(cwd, stage, sessionId),
|
|
2433
|
+
async () => {
|
|
2434
|
+
const publication = await nonCrystalApprovalPublication(cwd, sessionId, stage);
|
|
2435
|
+
if (options.presentation && JSON.stringify(options.presentation) !== JSON.stringify(publication))
|
|
2436
|
+
throw new StateCommandError(2, "ordinary execution approval publication changed while awaiting consent");
|
|
2437
|
+
await withWorkflowStateLock(
|
|
2438
|
+
nonCrystalExecutionApprovalRecordPath(cwd, sessionId, stage),
|
|
2439
|
+
async () => {
|
|
2440
|
+
await readNonCrystalApproval(cwd, sessionId, stage);
|
|
2441
|
+
const history = await nonCrystalApprovalAuditRows(cwd, sessionId, stage);
|
|
2442
|
+
if (
|
|
2443
|
+
history.some(
|
|
2444
|
+
row =>
|
|
2445
|
+
row.gate_id === options.gateId ||
|
|
2446
|
+
(row.ordinary_approval_status === "consumed" &&
|
|
2447
|
+
row.artifact_sha256 === publication.artifact_sha256 &&
|
|
2448
|
+
row.run_id === publication.run_id),
|
|
2449
|
+
)
|
|
2450
|
+
)
|
|
2451
|
+
throw new StateCommandError(2, "ordinary execution approval consent replay refused");
|
|
2452
|
+
await writeNonCrystalApproval(cwd, {
|
|
2453
|
+
schema_version: 1,
|
|
2454
|
+
status: "pending",
|
|
2455
|
+
session_id: sessionId,
|
|
2456
|
+
stage,
|
|
2457
|
+
target: "ultragoal",
|
|
2458
|
+
...publication,
|
|
2459
|
+
question_id: options.questionId,
|
|
2460
|
+
gate_id: options.gateId,
|
|
2461
|
+
answer_hash: answerHash(options.selectedOptions, undefined),
|
|
2462
|
+
transcript_path: options.transcriptPath,
|
|
2463
|
+
transcript_sha256: options.transcriptSha256,
|
|
2464
|
+
transcript_boundary: await captureExecutionApprovalTranscriptBoundary(
|
|
2465
|
+
cwd,
|
|
2466
|
+
sessionId,
|
|
2467
|
+
options.transcriptPath,
|
|
2468
|
+
options.transcriptSha256,
|
|
2469
|
+
options.toolCallId,
|
|
2470
|
+
),
|
|
2471
|
+
created_at: nowIso(),
|
|
2472
|
+
expires_at: new Date(Date.now() + DEEP_INTERVIEW_EXECUTION_APPROVAL_MAX_AGE_MS).toISOString(),
|
|
2473
|
+
});
|
|
2474
|
+
},
|
|
2475
|
+
{ cwd },
|
|
2476
|
+
);
|
|
2477
|
+
},
|
|
2478
|
+
{ cwd },
|
|
2479
|
+
);
|
|
2480
|
+
}
|
|
2481
|
+
|
|
2482
|
+
async function assertNonCrystalApprovalCurrent(
|
|
2483
|
+
cwd: string,
|
|
2484
|
+
sessionId: string,
|
|
2485
|
+
stage: ExecutionApprovalStage,
|
|
2486
|
+
status: "pending" | "consumed",
|
|
2487
|
+
) {
|
|
2488
|
+
const record = await readNonCrystalApproval(cwd, sessionId, stage);
|
|
2489
|
+
if (!record || record.status !== status || (status === "pending" && Date.parse(record.expires_at) <= Date.now()))
|
|
2490
|
+
throw new StateCommandError(2, "ordinary execution approval is missing, expired or consumed");
|
|
2491
|
+
const publication = await nonCrystalApprovalPublication(cwd, sessionId, stage);
|
|
2492
|
+
for (const key of ["state_path", "artifact_path", "artifact_sha256", "run_id"] as const)
|
|
2493
|
+
if (record[key] !== publication[key])
|
|
2494
|
+
throw new StateCommandError(2, "ordinary execution approval publication is stale");
|
|
2495
|
+
if (record.state_revision !== publication.state_revision) {
|
|
2496
|
+
const current = await readExistingStateForMutation(publication.state_path);
|
|
2497
|
+
const sanctionedPhaseTransition =
|
|
2498
|
+
stage === "ralplan" &&
|
|
2499
|
+
current.kind === "valid" &&
|
|
2500
|
+
current.value.current_phase === "handoff" &&
|
|
2501
|
+
isPlainObject(current.value.final_admission_phase_transition) &&
|
|
2502
|
+
publication.state_revision === record.state_revision + 1;
|
|
2503
|
+
if (!sanctionedPhaseTransition)
|
|
2504
|
+
throw new StateCommandError(2, "ordinary execution approval publication is stale");
|
|
2505
|
+
}
|
|
2506
|
+
await assertExecutionApprovalTranscriptBoundary(
|
|
2507
|
+
cwd,
|
|
2508
|
+
sessionId,
|
|
2509
|
+
record.transcript_path,
|
|
2510
|
+
record.transcript_sha256,
|
|
2511
|
+
record.transcript_boundary,
|
|
2512
|
+
);
|
|
2513
|
+
return record;
|
|
2514
|
+
}
|
|
2515
|
+
|
|
2516
|
+
/** Caller holds the workflow state lock. Consumption never rewrites final publication evidence. */
|
|
2517
|
+
async function consumeNonCrystalApprovalUnlocked(
|
|
2518
|
+
cwd: string,
|
|
2519
|
+
sessionId: string,
|
|
2520
|
+
stage: ExecutionApprovalStage,
|
|
2521
|
+
): Promise<StateCommandResult> {
|
|
2522
|
+
return withWorkflowStateLock(
|
|
2523
|
+
nonCrystalExecutionApprovalRecordPath(cwd, sessionId, stage),
|
|
2524
|
+
async () => {
|
|
2525
|
+
const record = await assertNonCrystalApprovalCurrent(cwd, sessionId, stage, "pending");
|
|
2526
|
+
await writeNonCrystalApproval(cwd, { ...record, status: "consumed" });
|
|
2527
|
+
return { status: 0, stdout: `${JSON.stringify({ skill: stage, execution_approval: "approved" })}\n` };
|
|
2528
|
+
},
|
|
2529
|
+
{ cwd },
|
|
2530
|
+
);
|
|
2531
|
+
}
|
|
2532
|
+
|
|
2533
|
+
export async function revokeNonCrystalExecutionApproval(
|
|
2534
|
+
cwd: string,
|
|
2535
|
+
sessionId: string,
|
|
2536
|
+
stage: ExecutionApprovalStage,
|
|
2537
|
+
): Promise<void> {
|
|
2538
|
+
await withWorkflowStateLock(
|
|
2539
|
+
nonCrystalExecutionApprovalRecordPath(cwd, sessionId, stage),
|
|
2540
|
+
async () => {
|
|
2541
|
+
const record = await readNonCrystalApproval(cwd, sessionId, stage);
|
|
2542
|
+
if (record?.status === "pending") await writeNonCrystalApproval(cwd, { ...record, status: "revoked" });
|
|
2543
|
+
},
|
|
2544
|
+
{ cwd },
|
|
2545
|
+
);
|
|
2546
|
+
}
|
|
2547
|
+
export interface DeepInterviewExecutionApprovalRecord {
|
|
2548
|
+
schema_version: 1;
|
|
2549
|
+
status: "pending" | "consumed";
|
|
2550
|
+
session_id: string;
|
|
2551
|
+
target: "ultragoal";
|
|
2552
|
+
state_path: string;
|
|
2553
|
+
state_revision: number;
|
|
2554
|
+
spec_path: string;
|
|
2555
|
+
spec_sha256: string;
|
|
2556
|
+
crystal_spec_version: number;
|
|
2557
|
+
crystal_source_digest: string;
|
|
2558
|
+
crystal_digest: string;
|
|
2559
|
+
question_id: string;
|
|
2560
|
+
gate_id: string;
|
|
2561
|
+
answer_hash: string;
|
|
2562
|
+
transcript_path: string;
|
|
2563
|
+
transcript_sha256: string;
|
|
2564
|
+
transcript_boundary: ExecutionApprovalTranscriptBoundary;
|
|
2565
|
+
approval_stage?: "deep-interview" | "ralplan";
|
|
2566
|
+
ralplan_state_path?: string;
|
|
2567
|
+
ralplan_state_revision?: number;
|
|
2568
|
+
ralplan_run_id?: string;
|
|
2569
|
+
ralplan_final_path?: string;
|
|
2570
|
+
ralplan_final_sha256?: string;
|
|
2571
|
+
created_at: string;
|
|
2572
|
+
expires_at: string;
|
|
2573
|
+
consumed_at?: string;
|
|
2574
|
+
consumed_mutation_id?: string;
|
|
2575
|
+
}
|
|
2576
|
+
|
|
2577
|
+
export function deepInterviewExecutionApprovalRecordPath(cwd: string, sessionId: string): string {
|
|
2578
|
+
return path.join(sessionStateDir(cwd, sessionId), DEEP_INTERVIEW_EXECUTION_APPROVAL_RECORD_FILE);
|
|
2579
|
+
}
|
|
2580
|
+
|
|
2581
|
+
function isExecutionApprovalId(value: unknown): value is string {
|
|
2582
|
+
return (
|
|
2583
|
+
typeof value === "string" &&
|
|
2584
|
+
value.trim().length > 0 &&
|
|
2585
|
+
value.length <= DEEP_INTERVIEW_EXECUTION_APPROVAL_ID_MAX_LENGTH
|
|
2586
|
+
);
|
|
2587
|
+
}
|
|
2588
|
+
|
|
2589
|
+
function isSha256(value: unknown): value is string {
|
|
2590
|
+
return typeof value === "string" && /^[a-f0-9]{64}$/.test(value);
|
|
2591
|
+
}
|
|
2592
|
+
|
|
2593
|
+
function assertExecutionApprovalRecordShape(value: unknown): asserts value is DeepInterviewExecutionApprovalRecord {
|
|
2594
|
+
if (!isPlainObject(value)) throw new StateCommandError(2, "deep-interview execution approval record is invalid");
|
|
2595
|
+
const allowed = new Set([
|
|
2596
|
+
"schema_version",
|
|
2597
|
+
"status",
|
|
2598
|
+
"session_id",
|
|
2599
|
+
"target",
|
|
2600
|
+
"state_path",
|
|
2601
|
+
"state_revision",
|
|
2602
|
+
"spec_path",
|
|
2603
|
+
"spec_sha256",
|
|
2604
|
+
"crystal_spec_version",
|
|
2605
|
+
"crystal_source_digest",
|
|
2606
|
+
"crystal_digest",
|
|
2607
|
+
"question_id",
|
|
2608
|
+
"gate_id",
|
|
2609
|
+
"answer_hash",
|
|
2610
|
+
"transcript_path",
|
|
2611
|
+
"transcript_sha256",
|
|
2612
|
+
"transcript_boundary",
|
|
2613
|
+
"approval_stage",
|
|
2614
|
+
"ralplan_state_path",
|
|
2615
|
+
"ralplan_state_revision",
|
|
2616
|
+
"ralplan_run_id",
|
|
2617
|
+
"ralplan_final_path",
|
|
2618
|
+
"ralplan_final_sha256",
|
|
2619
|
+
"created_at",
|
|
2620
|
+
"expires_at",
|
|
2621
|
+
"consumed_at",
|
|
2622
|
+
"consumed_mutation_id",
|
|
2623
|
+
]);
|
|
2624
|
+
if (Object.keys(value).some(key => !allowed.has(key)))
|
|
2625
|
+
throw new StateCommandError(2, "deep-interview execution approval record is invalid");
|
|
2626
|
+
if (
|
|
2627
|
+
value.schema_version !== 1 ||
|
|
2628
|
+
(value.status !== "pending" && value.status !== "consumed") ||
|
|
2629
|
+
!isExecutionApprovalId(value.session_id) ||
|
|
2630
|
+
value.target !== "ultragoal" ||
|
|
2631
|
+
typeof value.state_path !== "string" ||
|
|
2632
|
+
!path.isAbsolute(value.state_path) ||
|
|
2633
|
+
!Number.isSafeInteger(value.state_revision) ||
|
|
2634
|
+
(value.state_revision as number) < 0 ||
|
|
2635
|
+
typeof value.spec_path !== "string" ||
|
|
2636
|
+
!path.isAbsolute(value.spec_path) ||
|
|
2637
|
+
!isSha256(value.spec_sha256) ||
|
|
2638
|
+
!Number.isSafeInteger(value.crystal_spec_version) ||
|
|
2639
|
+
(value.crystal_spec_version as number) < 1 ||
|
|
2640
|
+
!isSha256(value.crystal_source_digest) ||
|
|
2641
|
+
!isSha256(value.crystal_digest) ||
|
|
2642
|
+
!isExecutionApprovalId(value.question_id) ||
|
|
2643
|
+
!isExecutionApprovalId(value.gate_id) ||
|
|
2644
|
+
!isSha256(value.answer_hash) ||
|
|
2645
|
+
typeof value.transcript_path !== "string" ||
|
|
2646
|
+
!path.isAbsolute(value.transcript_path) ||
|
|
2647
|
+
!isSha256(value.transcript_sha256) ||
|
|
2648
|
+
!isExecutionApprovalTranscriptBoundary(value.transcript_boundary) ||
|
|
2649
|
+
typeof value.created_at !== "string" ||
|
|
2650
|
+
typeof value.expires_at !== "string"
|
|
2651
|
+
)
|
|
2652
|
+
throw new StateCommandError(2, "deep-interview execution approval record is invalid");
|
|
2653
|
+
const createdAt = Date.parse(value.created_at);
|
|
2654
|
+
const expiresAt = Date.parse(value.expires_at);
|
|
2655
|
+
if (
|
|
2656
|
+
!Number.isFinite(createdAt) ||
|
|
2657
|
+
!Number.isFinite(expiresAt) ||
|
|
2658
|
+
expiresAt <= createdAt ||
|
|
2659
|
+
expiresAt - createdAt > DEEP_INTERVIEW_EXECUTION_APPROVAL_MAX_AGE_MS
|
|
2660
|
+
)
|
|
2661
|
+
throw new StateCommandError(2, "deep-interview execution approval record timestamp is invalid");
|
|
2662
|
+
if (value.status === "pending") {
|
|
2663
|
+
if (value.consumed_at !== undefined || value.consumed_mutation_id !== undefined)
|
|
2664
|
+
throw new StateCommandError(2, "deep-interview execution approval record is invalid");
|
|
2665
|
+
} else if (
|
|
2666
|
+
typeof value.consumed_at !== "string" ||
|
|
2667
|
+
!Number.isFinite(Date.parse(value.consumed_at)) ||
|
|
2668
|
+
!isExecutionApprovalId(value.consumed_mutation_id)
|
|
2669
|
+
) {
|
|
2670
|
+
throw new StateCommandError(2, "deep-interview execution approval record is invalid");
|
|
2671
|
+
}
|
|
2672
|
+
if (
|
|
2673
|
+
value.approval_stage !== undefined &&
|
|
2674
|
+
value.approval_stage !== "deep-interview" &&
|
|
2675
|
+
value.approval_stage !== "ralplan"
|
|
2676
|
+
)
|
|
2677
|
+
throw new StateCommandError(2, "deep-interview execution approval record is invalid");
|
|
2678
|
+
if (
|
|
2679
|
+
value.approval_stage === "ralplan" &&
|
|
2680
|
+
(typeof value.ralplan_state_path !== "string" ||
|
|
2681
|
+
!path.isAbsolute(value.ralplan_state_path) ||
|
|
2682
|
+
!Number.isSafeInteger(value.ralplan_state_revision) ||
|
|
2683
|
+
(value.ralplan_state_revision as number) < 0 ||
|
|
2684
|
+
!isExecutionApprovalId(value.ralplan_run_id) ||
|
|
2685
|
+
typeof value.ralplan_final_path !== "string" ||
|
|
2686
|
+
!path.isAbsolute(value.ralplan_final_path) ||
|
|
2687
|
+
!isSha256(value.ralplan_final_sha256))
|
|
2688
|
+
)
|
|
2689
|
+
throw new StateCommandError(2, "deep-interview execution approval record is invalid");
|
|
2690
|
+
if (
|
|
2691
|
+
value.approval_stage !== "ralplan" &&
|
|
2692
|
+
[
|
|
2693
|
+
value.ralplan_state_path,
|
|
2694
|
+
value.ralplan_state_revision,
|
|
2695
|
+
value.ralplan_run_id,
|
|
2696
|
+
value.ralplan_final_path,
|
|
2697
|
+
value.ralplan_final_sha256,
|
|
2698
|
+
].some(field => field !== undefined)
|
|
2699
|
+
)
|
|
2700
|
+
throw new StateCommandError(2, "deep-interview execution approval record is invalid");
|
|
2701
|
+
}
|
|
2702
|
+
|
|
2703
|
+
async function readDeepInterviewExecutionApprovalRecord(
|
|
2704
|
+
filePath: string,
|
|
2705
|
+
): Promise<DeepInterviewExecutionApprovalRecord | undefined> {
|
|
2706
|
+
const raw = await readBoundedIdentityText(
|
|
2707
|
+
filePath,
|
|
2708
|
+
DEEP_INTERVIEW_EXECUTION_APPROVAL_RECORD_MAX_BYTES,
|
|
2709
|
+
"deep-interview execution approval record",
|
|
2710
|
+
);
|
|
2711
|
+
if (raw === undefined) return undefined;
|
|
2712
|
+
let parsed: unknown;
|
|
2713
|
+
try {
|
|
2714
|
+
parsed = JSON.parse(raw);
|
|
2715
|
+
} catch {
|
|
2716
|
+
throw new StateCommandError(2, "deep-interview execution approval record is invalid");
|
|
2717
|
+
}
|
|
2718
|
+
assertExecutionApprovalRecordShape(parsed);
|
|
2719
|
+
return parsed;
|
|
2720
|
+
}
|
|
2721
|
+
|
|
2722
|
+
async function assertExecutionApprovalSpecIdentity(record: DeepInterviewExecutionApprovalRecord): Promise<void> {
|
|
2723
|
+
const content = await readBoundedIdentityText(
|
|
2724
|
+
record.spec_path,
|
|
2725
|
+
MAX_DEEP_INTERVIEW_STRUCTURED_RESPONSE_LENGTH,
|
|
2726
|
+
"deep-interview execution approval spec",
|
|
2727
|
+
);
|
|
2728
|
+
if (content === undefined || createHash("sha256").update(content).digest("hex") !== record.spec_sha256)
|
|
2729
|
+
throw new StateCommandError(2, "deep-interview execution approval spec identity mismatch");
|
|
2730
|
+
}
|
|
2731
|
+
|
|
2732
|
+
async function writeDeepInterviewExecutionApprovalRecord(
|
|
2733
|
+
cwd: string,
|
|
2734
|
+
sessionId: string,
|
|
2735
|
+
record: DeepInterviewExecutionApprovalRecord,
|
|
2736
|
+
): Promise<void> {
|
|
2737
|
+
const recordPath = deepInterviewExecutionApprovalRecordPath(cwd, sessionId);
|
|
2738
|
+
await writeArtifact(recordPath, `${JSON.stringify(record)}\n`, {
|
|
2739
|
+
cwd,
|
|
2740
|
+
audit: {
|
|
2741
|
+
category: "artifact",
|
|
2742
|
+
verb: "write",
|
|
2743
|
+
owner: "gjc-runtime",
|
|
2744
|
+
skill: "deep-interview",
|
|
2745
|
+
sessionId,
|
|
2746
|
+
},
|
|
2747
|
+
});
|
|
2748
|
+
}
|
|
2749
|
+
|
|
2750
|
+
function crystalCanonicalDigest(crystal: Record<string, unknown>): string {
|
|
2751
|
+
return createHash("sha256")
|
|
2752
|
+
.update(crystalMarkdown(crystal as unknown as DeepInterviewCrystal))
|
|
2753
|
+
.digest("hex");
|
|
2754
|
+
}
|
|
2755
|
+
|
|
2756
|
+
function assertExecutionApprovalRecordMatchesCurrentState(
|
|
2757
|
+
record: DeepInterviewExecutionApprovalRecord,
|
|
2758
|
+
options: {
|
|
2759
|
+
sessionId: string;
|
|
2760
|
+
statePath: string;
|
|
2761
|
+
envelope: Record<string, unknown>;
|
|
2762
|
+
crystal: Record<string, unknown>;
|
|
2763
|
+
recordRevision?: number;
|
|
2764
|
+
allowConsumed?: boolean;
|
|
2765
|
+
},
|
|
2766
|
+
): void {
|
|
2767
|
+
const now = Date.now();
|
|
2768
|
+
const createdAt = Date.parse(record.created_at);
|
|
2769
|
+
const expiresAt = Date.parse(record.expires_at);
|
|
2770
|
+
if (
|
|
2771
|
+
(record.status === "consumed" && !options.allowConsumed) ||
|
|
2772
|
+
!Number.isFinite(createdAt) ||
|
|
2773
|
+
!Number.isFinite(expiresAt) ||
|
|
2774
|
+
(!options.allowConsumed && (createdAt > now + 30_000 || expiresAt <= now))
|
|
2775
|
+
)
|
|
2776
|
+
throw new StateCommandError(2, "deep-interview execution approval record is stale or consumed");
|
|
2777
|
+
if (record.status === "consumed") {
|
|
2778
|
+
const consumedAt = Date.parse(record.consumed_at ?? "");
|
|
2779
|
+
if (!Number.isFinite(consumedAt) || consumedAt < createdAt || consumedAt > now + 30_000)
|
|
2780
|
+
throw new StateCommandError(2, "deep-interview execution approval record provenance is invalid");
|
|
2781
|
+
}
|
|
2782
|
+
const expectedSpecPath =
|
|
2783
|
+
typeof options.envelope.spec_path === "string" ? path.resolve(options.envelope.spec_path) : "";
|
|
2784
|
+
const expectedSpecSha = typeof options.envelope.spec_sha256 === "string" ? options.envelope.spec_sha256 : "";
|
|
2785
|
+
const revision = options.recordRevision ?? existingStateRevision(options.envelope);
|
|
2786
|
+
if (
|
|
2787
|
+
record.session_id !== options.sessionId ||
|
|
2788
|
+
record.state_path !== path.resolve(options.statePath) ||
|
|
2789
|
+
record.state_revision !== revision ||
|
|
2790
|
+
record.spec_path !== expectedSpecPath ||
|
|
2791
|
+
record.spec_sha256 !== expectedSpecSha ||
|
|
2792
|
+
record.crystal_spec_version !== options.crystal.spec_version ||
|
|
2793
|
+
record.crystal_source_digest !== (options.crystal.source as Record<string, unknown>).digest ||
|
|
2794
|
+
record.crystal_digest !== crystalCanonicalDigest(options.crystal)
|
|
2795
|
+
)
|
|
2796
|
+
throw new StateCommandError(2, "deep-interview execution approval record does not match current Crystal");
|
|
2797
|
+
}
|
|
2798
|
+
|
|
2799
|
+
export async function recordDeepInterviewExecutionApproval(options: {
|
|
2800
|
+
cwd: string;
|
|
2801
|
+
sessionId: string;
|
|
2802
|
+
questionId: string;
|
|
2803
|
+
gateId?: string;
|
|
2804
|
+
target: string;
|
|
2805
|
+
selectedOptions: readonly string[];
|
|
2806
|
+
customInput?: string;
|
|
2807
|
+
transcriptPath: string;
|
|
2808
|
+
transcriptSha256: string;
|
|
2809
|
+
toolCallId: string;
|
|
2810
|
+
approvalStage?: "deep-interview" | "ralplan";
|
|
2811
|
+
presentation: ExecutionApprovalPresentation;
|
|
2812
|
+
}): Promise<{ path: string; record: DeepInterviewExecutionApprovalRecord }> {
|
|
2813
|
+
if (options.target !== "ultragoal")
|
|
2814
|
+
throw new StateCommandError(2, "deep-interview execution approval target must be ultragoal");
|
|
2815
|
+
if (
|
|
2816
|
+
!isExecutionApprovalId(options.sessionId) ||
|
|
2817
|
+
!isExecutionApprovalId(options.questionId) ||
|
|
2818
|
+
!isExecutionApprovalId(options.toolCallId) ||
|
|
2819
|
+
!options.presentation
|
|
2820
|
+
)
|
|
2821
|
+
throw new StateCommandError(2, "deep-interview execution approval descriptor is invalid");
|
|
2822
|
+
if (
|
|
2823
|
+
!Array.isArray(options.selectedOptions) ||
|
|
2824
|
+
options.selectedOptions.length !== 1 ||
|
|
2825
|
+
options.customInput !== undefined
|
|
2826
|
+
)
|
|
2827
|
+
throw new StateCommandError(2, "deep-interview execution approval answer is invalid");
|
|
2828
|
+
const statePath = modeStateFile(options.cwd, "deep-interview", options.sessionId);
|
|
2829
|
+
const recordPath = deepInterviewExecutionApprovalRecordPath(options.cwd, options.sessionId);
|
|
2830
|
+
return withWorkflowStateLock(
|
|
2831
|
+
statePath,
|
|
2832
|
+
async () => {
|
|
2833
|
+
const current = await readExistingStateForMutation(statePath);
|
|
2834
|
+
if (current.kind !== "valid")
|
|
2835
|
+
throw new StateCommandError(2, "deep-interview execution approval requires valid current state");
|
|
2836
|
+
const envelope = normalizeDeepInterviewEnvelope(current.value) as Record<string, unknown>;
|
|
2837
|
+
const ralplanApproval =
|
|
2838
|
+
options.approvalStage === "ralplan" &&
|
|
2839
|
+
envelope.active === false &&
|
|
2840
|
+
envelope.handoff_to === "ralplan" &&
|
|
2841
|
+
typeof envelope.handoff_at === "string" &&
|
|
2842
|
+
(await hasAuditedDeepInterviewHandoff(options.cwd, options.sessionId, "ralplan", {
|
|
2843
|
+
handoffAt: envelope.handoff_at,
|
|
2844
|
+
}));
|
|
2845
|
+
let ralplanFinal: VerifiedRalplanFinalEvidence | undefined;
|
|
2846
|
+
if (ralplanApproval) {
|
|
2847
|
+
const ralplanPath = modeStateFile(options.cwd, "ralplan", options.sessionId);
|
|
2848
|
+
const ralplanRead = await readExistingStateForMutation(ralplanPath);
|
|
2849
|
+
if (ralplanRead.kind !== "valid")
|
|
2850
|
+
throw new StateCommandError(2, "Ralplan execution approval requires valid final state");
|
|
2851
|
+
const ralplanState = migrateWorkflowState(ralplanRead.value, "ralplan").state;
|
|
2852
|
+
if (ralplanState.active !== true || ralplanState.current_phase !== "final")
|
|
2853
|
+
throw new StateCommandError(2, "Ralplan execution approval requires current final plan");
|
|
2854
|
+
ralplanFinal = await verifiedRalplanFinalEvidence(options.cwd, options.sessionId, ralplanState);
|
|
2855
|
+
if (!ralplanFinal)
|
|
2856
|
+
throw new StateCommandError(2, "Ralplan execution approval requires verified final plan evidence");
|
|
2857
|
+
}
|
|
2858
|
+
if (
|
|
2859
|
+
envelope.version !== WORKFLOW_STATE_VERSION ||
|
|
2860
|
+
(envelope.active !== true && !ralplanApproval) ||
|
|
2861
|
+
envelope.current_phase !== "handoff"
|
|
2862
|
+
)
|
|
2863
|
+
throw new StateCommandError(2, "deep-interview execution approval requires active handoff state");
|
|
2864
|
+
const inner = isPlainObject(envelope.state) ? envelope.state : {};
|
|
2865
|
+
const crystal = requireReadyCanonicalCrystal(inner.crystal);
|
|
2866
|
+
if (inner.execution_approval === "approved")
|
|
2867
|
+
throw new StateCommandError(2, "deep-interview execution approval is already consumed");
|
|
2868
|
+
const publicationReceipt = isPlainObject(envelope.receipt) ? envelope.receipt : undefined;
|
|
2869
|
+
const publicationChecksum = isPlainObject(publicationReceipt?.content_sha256)
|
|
2870
|
+
? publicationReceipt.content_sha256
|
|
2871
|
+
: undefined;
|
|
2872
|
+
if (
|
|
2873
|
+
(ralplanApproval
|
|
2874
|
+
? publicationReceipt?.owner !== "gjc-state-cli" ||
|
|
2875
|
+
publicationReceipt.command !== "gjc state deep-interview handoff --to ralplan"
|
|
2876
|
+
: publicationReceipt?.owner !== "gjc-runtime" ||
|
|
2877
|
+
publicationReceipt.command !== "gjc deep-interview crystallize") ||
|
|
2878
|
+
publicationChecksum?.algorithm !== "sha256" ||
|
|
2879
|
+
!isSha256(publicationChecksum.value) ||
|
|
2880
|
+
publicationChecksum.covered_path !== path.resolve(statePath)
|
|
2881
|
+
)
|
|
2882
|
+
throw new StateCommandError(2, "deep-interview execution approval requires a published Crystal");
|
|
2883
|
+
const integrityWarning = await warnAndAuditOutOfBandIfNeeded(
|
|
2884
|
+
options.cwd,
|
|
2885
|
+
options.sessionId,
|
|
2886
|
+
statePath,
|
|
2887
|
+
"deep-interview",
|
|
2888
|
+
);
|
|
2889
|
+
if (integrityWarning)
|
|
2890
|
+
throw new StateCommandError(2, `${integrityWarning}; execution approval refuses tampered mode-state`);
|
|
2891
|
+
await assertDeepInterviewHandoffReady(envelope, {
|
|
2892
|
+
cwd: options.cwd,
|
|
2893
|
+
sessionId: options.sessionId,
|
|
2894
|
+
statePath,
|
|
2895
|
+
});
|
|
2896
|
+
const specPath = typeof envelope.spec_path === "string" ? path.resolve(envelope.spec_path) : "";
|
|
2897
|
+
const specContent = await readBoundedIdentityText(
|
|
2898
|
+
specPath,
|
|
2899
|
+
MAX_DEEP_INTERVIEW_STRUCTURED_RESPONSE_LENGTH,
|
|
2900
|
+
"canonical Crystal spec",
|
|
2901
|
+
);
|
|
2902
|
+
if (specContent === undefined || specContent !== crystalMarkdown(crystal as unknown as DeepInterviewCrystal))
|
|
2903
|
+
throw new StateCommandError(
|
|
2904
|
+
2,
|
|
2905
|
+
"deep-interview execution approval requires canonical Crystal spec identity",
|
|
2906
|
+
);
|
|
2907
|
+
const currentRevision = existingStateRevision(envelope);
|
|
2908
|
+
if (typeof currentRevision !== "number" || !Number.isSafeInteger(currentRevision) || currentRevision < 0)
|
|
2909
|
+
throw new StateCommandError(2, "deep-interview execution approval requires a valid state revision");
|
|
2910
|
+
const currentPresentation: ExecutionApprovalPresentation = ralplanFinal
|
|
2911
|
+
? {
|
|
2912
|
+
state_path: path.resolve(statePath),
|
|
2913
|
+
state_revision: currentRevision,
|
|
2914
|
+
artifact_path: ralplanFinal.finalPath,
|
|
2915
|
+
artifact_sha256: ralplanFinal.finalSha256,
|
|
2916
|
+
run_id: ralplanFinal.runId,
|
|
2917
|
+
}
|
|
2918
|
+
: {
|
|
2919
|
+
state_path: path.resolve(statePath),
|
|
2920
|
+
state_revision: currentRevision,
|
|
2921
|
+
artifact_path: specPath,
|
|
2922
|
+
artifact_sha256: envelope.spec_sha256 as string,
|
|
2923
|
+
run_id: null,
|
|
2924
|
+
};
|
|
2925
|
+
if (options.presentation && JSON.stringify(options.presentation) !== JSON.stringify(currentPresentation))
|
|
2926
|
+
throw new StateCommandError(
|
|
2927
|
+
2,
|
|
2928
|
+
"deep-interview execution approval publication changed while awaiting consent",
|
|
2929
|
+
);
|
|
2930
|
+
const gateId = options.gateId ?? options.questionId;
|
|
2931
|
+
if (!isExecutionApprovalId(gateId))
|
|
2932
|
+
throw new StateCommandError(2, "deep-interview execution approval descriptor is invalid");
|
|
2933
|
+
const now = nowIso();
|
|
2934
|
+
const record: DeepInterviewExecutionApprovalRecord = {
|
|
2935
|
+
schema_version: 1,
|
|
2936
|
+
status: "pending",
|
|
2937
|
+
session_id: options.sessionId,
|
|
2938
|
+
target: "ultragoal",
|
|
2939
|
+
state_path: path.resolve(statePath),
|
|
2940
|
+
state_revision: currentRevision,
|
|
2941
|
+
spec_path: specPath,
|
|
2942
|
+
spec_sha256: createHash("sha256").update(specContent).digest("hex"),
|
|
2943
|
+
crystal_spec_version: crystal.spec_version as number,
|
|
2944
|
+
crystal_source_digest: (crystal.source as Record<string, unknown>).digest as string,
|
|
2945
|
+
crystal_digest: crystalCanonicalDigest(crystal),
|
|
2946
|
+
question_id: options.questionId,
|
|
2947
|
+
gate_id: gateId,
|
|
2948
|
+
answer_hash: answerHash([...options.selectedOptions], options.customInput),
|
|
2949
|
+
transcript_path: path.resolve(options.transcriptPath),
|
|
2950
|
+
transcript_sha256: options.transcriptSha256,
|
|
2951
|
+
transcript_boundary: await captureExecutionApprovalTranscriptBoundary(
|
|
2952
|
+
options.cwd,
|
|
2953
|
+
options.sessionId,
|
|
2954
|
+
options.transcriptPath,
|
|
2955
|
+
options.transcriptSha256,
|
|
2956
|
+
options.toolCallId,
|
|
2957
|
+
),
|
|
2958
|
+
approval_stage: options.approvalStage ?? "deep-interview",
|
|
2959
|
+
...(ralplanFinal
|
|
2960
|
+
? {
|
|
2961
|
+
ralplan_state_path: ralplanFinal.statePath,
|
|
2962
|
+
ralplan_state_revision: ralplanFinal.stateRevision,
|
|
2963
|
+
ralplan_run_id: ralplanFinal.runId,
|
|
2964
|
+
ralplan_final_path: ralplanFinal.finalPath,
|
|
2965
|
+
ralplan_final_sha256: ralplanFinal.finalSha256,
|
|
2966
|
+
}
|
|
2967
|
+
: {}),
|
|
2968
|
+
created_at: now,
|
|
2969
|
+
expires_at: new Date(Date.parse(now) + DEEP_INTERVIEW_EXECUTION_APPROVAL_MAX_AGE_MS).toISOString(),
|
|
2970
|
+
};
|
|
2971
|
+
return withWorkflowStateLock(
|
|
2972
|
+
recordPath,
|
|
2973
|
+
async () => {
|
|
2974
|
+
const existing = await readDeepInterviewExecutionApprovalRecord(recordPath);
|
|
2975
|
+
if (existing?.status === "consumed") {
|
|
2976
|
+
if (record.gate_id === existing.gate_id)
|
|
2977
|
+
throw new StateCommandError(2, "deep-interview execution approval record is already consumed");
|
|
2978
|
+
const newPublication =
|
|
2979
|
+
inner.execution_approval === "not-approved" &&
|
|
2980
|
+
inner.execution_approval_receipt === undefined &&
|
|
2981
|
+
(ralplanApproval || publicationReceipt?.command === "gjc deep-interview crystallize") &&
|
|
2982
|
+
record.crystal_spec_version > existing.crystal_spec_version &&
|
|
2983
|
+
record.crystal_source_digest !== existing.crystal_source_digest &&
|
|
2984
|
+
record.spec_sha256 !== existing.spec_sha256 &&
|
|
2985
|
+
isPlainObject(crystal.delta) &&
|
|
2986
|
+
crystal.delta.approval_invalidated === true;
|
|
2987
|
+
if (!newPublication && !existing.consumed_mutation_id?.startsWith("deep-interview:approval-revoked:"))
|
|
2988
|
+
throw new StateCommandError(2, "deep-interview execution approval record is already consumed");
|
|
2989
|
+
if (newPublication) {
|
|
2990
|
+
await writeArtifact(
|
|
2991
|
+
`${recordPath}.${existing.crystal_spec_version}.${existing.spec_sha256}.consumed`,
|
|
2992
|
+
`${JSON.stringify(existing)}\n`,
|
|
2993
|
+
{
|
|
2994
|
+
cwd: options.cwd,
|
|
2995
|
+
audit: {
|
|
2996
|
+
category: "artifact",
|
|
2997
|
+
verb: "write",
|
|
2998
|
+
owner: "gjc-runtime",
|
|
2999
|
+
skill: "deep-interview",
|
|
3000
|
+
sessionId: options.sessionId,
|
|
3001
|
+
},
|
|
3002
|
+
},
|
|
3003
|
+
);
|
|
3004
|
+
}
|
|
3005
|
+
}
|
|
3006
|
+
if (existing?.status === "pending") {
|
|
3007
|
+
await assertExecutionApprovalSpecIdentity(existing);
|
|
3008
|
+
try {
|
|
3009
|
+
assertExecutionApprovalRecordMatchesCurrentState(existing, {
|
|
3010
|
+
sessionId: options.sessionId,
|
|
3011
|
+
statePath,
|
|
3012
|
+
envelope,
|
|
3013
|
+
crystal,
|
|
3014
|
+
allowConsumed: false,
|
|
3015
|
+
});
|
|
3016
|
+
if (
|
|
3017
|
+
existing.question_id === record.question_id &&
|
|
3018
|
+
existing.gate_id === record.gate_id &&
|
|
3019
|
+
existing.answer_hash === record.answer_hash &&
|
|
3020
|
+
existing.target === record.target
|
|
3021
|
+
)
|
|
3022
|
+
return { path: recordPath, record: existing };
|
|
3023
|
+
} catch (error) {
|
|
3024
|
+
if (
|
|
3025
|
+
!(error instanceof StateCommandError) ||
|
|
3026
|
+
![
|
|
3027
|
+
"deep-interview execution approval record is stale or consumed",
|
|
3028
|
+
"deep-interview execution approval record does not match current Crystal",
|
|
3029
|
+
].includes(error.message)
|
|
3030
|
+
)
|
|
3031
|
+
throw error;
|
|
3032
|
+
}
|
|
3033
|
+
if (record.gate_id === existing.gate_id)
|
|
3034
|
+
throw new StateCommandError(2, "deep-interview execution approval consent replay refused");
|
|
3035
|
+
}
|
|
3036
|
+
await writeDeepInterviewExecutionApprovalRecord(options.cwd, options.sessionId, record);
|
|
3037
|
+
return { path: recordPath, record };
|
|
3038
|
+
},
|
|
3039
|
+
{ cwd: options.cwd },
|
|
3040
|
+
);
|
|
3041
|
+
},
|
|
3042
|
+
{ cwd: options.cwd },
|
|
3043
|
+
);
|
|
3044
|
+
}
|
|
3045
|
+
|
|
3046
|
+
export async function revokeDeepInterviewExecutionApproval(cwd: string, sessionId: string): Promise<void> {
|
|
3047
|
+
const recordPath = deepInterviewExecutionApprovalRecordPath(cwd, sessionId);
|
|
3048
|
+
await withWorkflowStateLock(
|
|
3049
|
+
recordPath,
|
|
3050
|
+
async () => {
|
|
3051
|
+
const existing = await readDeepInterviewExecutionApprovalRecord(recordPath);
|
|
3052
|
+
if (existing?.status !== "pending") return;
|
|
3053
|
+
const revokedAt = nowIso();
|
|
3054
|
+
await writeDeepInterviewExecutionApprovalRecord(cwd, sessionId, {
|
|
3055
|
+
...existing,
|
|
3056
|
+
status: "consumed",
|
|
3057
|
+
consumed_at: revokedAt,
|
|
3058
|
+
consumed_mutation_id: `deep-interview:approval-revoked:${revokedAt}`,
|
|
3059
|
+
});
|
|
3060
|
+
},
|
|
3061
|
+
{ cwd },
|
|
3062
|
+
);
|
|
3063
|
+
}
|
|
3064
|
+
|
|
3065
|
+
function sameBoundedFileIdentity(left: nodeFs.BigIntStats, right: nodeFs.BigIntStats): boolean {
|
|
3066
|
+
return (
|
|
3067
|
+
left.dev === right.dev &&
|
|
3068
|
+
left.ino === right.ino &&
|
|
3069
|
+
left.mode === right.mode &&
|
|
3070
|
+
left.size === right.size &&
|
|
3071
|
+
left.mtimeNs === right.mtimeNs &&
|
|
3072
|
+
left.ctimeNs === right.ctimeNs &&
|
|
3073
|
+
left.nlink === right.nlink
|
|
3074
|
+
);
|
|
3075
|
+
}
|
|
3076
|
+
|
|
3077
|
+
async function readBoundedIdentityText(
|
|
3078
|
+
filePath: string,
|
|
3079
|
+
maxBytes: number,
|
|
3080
|
+
label: string,
|
|
3081
|
+
options: { tail?: boolean; offset?: number } = {},
|
|
3082
|
+
): Promise<string | undefined> {
|
|
3083
|
+
let initialStat: nodeFs.BigIntStats;
|
|
3084
|
+
try {
|
|
3085
|
+
initialStat = await fs.lstat(filePath, { bigint: true });
|
|
3086
|
+
} catch (error) {
|
|
3087
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
|
|
3088
|
+
throw new StateCommandError(2, `failed to read ${label}: ${(error as Error).message}`);
|
|
3089
|
+
}
|
|
3090
|
+
if (initialStat.isSymbolicLink() || !initialStat.isFile()) throw new StateCommandError(2, `${label} is invalid`);
|
|
3091
|
+
const openFlags =
|
|
3092
|
+
nodeFs.constants.O_RDONLY | (process.platform === "win32" ? 0 : (nodeFs.constants.O_NOFOLLOW ?? 0));
|
|
3093
|
+
let handle: fs.FileHandle | undefined;
|
|
3094
|
+
try {
|
|
3095
|
+
handle = await fs.open(filePath, openFlags);
|
|
3096
|
+
const openedStat = await handle.stat({ bigint: true });
|
|
3097
|
+
const beforeReadStat = await fs.lstat(filePath, { bigint: true });
|
|
3098
|
+
if (
|
|
3099
|
+
openedStat.isSymbolicLink() ||
|
|
3100
|
+
!openedStat.isFile() ||
|
|
3101
|
+
beforeReadStat.isSymbolicLink() ||
|
|
3102
|
+
!sameBoundedFileIdentity(initialStat, openedStat) ||
|
|
3103
|
+
!sameBoundedFileIdentity(initialStat, beforeReadStat)
|
|
3104
|
+
)
|
|
3105
|
+
throw new StateCommandError(2, `${label} is invalid`);
|
|
3106
|
+
const requestedOffset = options.offset === undefined ? undefined : BigInt(options.offset);
|
|
3107
|
+
if (requestedOffset !== undefined && (requestedOffset < 0n || requestedOffset > openedStat.size))
|
|
3108
|
+
throw new StateCommandError(2, `${label} offset is invalid`);
|
|
3109
|
+
const start =
|
|
3110
|
+
requestedOffset ??
|
|
3111
|
+
(options.tail ? (openedStat.size > BigInt(maxBytes) ? openedStat.size - BigInt(maxBytes) : 0n) : 0n);
|
|
3112
|
+
const available = openedStat.size - start;
|
|
3113
|
+
const readSize =
|
|
3114
|
+
requestedOffset === undefined ? available : available > BigInt(maxBytes) ? BigInt(maxBytes) : available;
|
|
3115
|
+
if (!options.tail && requestedOffset === undefined && readSize > BigInt(maxBytes))
|
|
3116
|
+
throw new StateCommandError(2, `${label} is invalid`);
|
|
3117
|
+
const buffer = Buffer.alloc(Number(readSize));
|
|
3118
|
+
let offset = 0;
|
|
3119
|
+
while (offset < buffer.length) {
|
|
3120
|
+
const { bytesRead } = await handle.read(buffer, offset, buffer.length - offset, Number(start) + offset);
|
|
3121
|
+
if (bytesRead === 0) throw new StateCommandError(2, `${label} is invalid`);
|
|
3122
|
+
offset += bytesRead;
|
|
3123
|
+
}
|
|
3124
|
+
const afterReadStat = await handle.stat({ bigint: true });
|
|
3125
|
+
const afterPathStat = await fs.lstat(filePath, { bigint: true });
|
|
3126
|
+
if (
|
|
3127
|
+
afterPathStat.isSymbolicLink() ||
|
|
3128
|
+
!afterPathStat.isFile() ||
|
|
3129
|
+
!sameBoundedFileIdentity(initialStat, afterReadStat) ||
|
|
3130
|
+
!sameBoundedFileIdentity(initialStat, afterPathStat)
|
|
3131
|
+
)
|
|
3132
|
+
throw new StateCommandError(2, `${label} is invalid`);
|
|
3133
|
+
let text = buffer.subarray(0, offset).toString("utf-8");
|
|
3134
|
+
if (options.tail && start > 0n) {
|
|
3135
|
+
const firstNewline = text.indexOf("\n");
|
|
3136
|
+
text = firstNewline >= 0 ? text.slice(firstNewline + 1) : "";
|
|
3137
|
+
}
|
|
3138
|
+
return text;
|
|
3139
|
+
} catch (error) {
|
|
3140
|
+
if (error instanceof StateCommandError) throw error;
|
|
3141
|
+
throw new StateCommandError(2, `failed to read ${label}: ${(error as Error).message}`);
|
|
3142
|
+
} finally {
|
|
3143
|
+
await handle?.close().catch(() => undefined);
|
|
3144
|
+
}
|
|
3145
|
+
}
|
|
3146
|
+
|
|
3147
|
+
async function hashIdentityFile(filePath: string, label: string): Promise<string | undefined> {
|
|
3148
|
+
let initialStat: nodeFs.BigIntStats;
|
|
3149
|
+
try {
|
|
3150
|
+
initialStat = await fs.lstat(filePath, { bigint: true });
|
|
3151
|
+
} catch (error) {
|
|
3152
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
|
|
3153
|
+
throw new StateCommandError(2, `failed to hash ${label}: ${(error as Error).message}`);
|
|
3154
|
+
}
|
|
3155
|
+
if (initialStat.isSymbolicLink() || !initialStat.isFile() || initialStat.size > BigInt(Number.MAX_SAFE_INTEGER))
|
|
3156
|
+
return undefined;
|
|
3157
|
+
const openFlags =
|
|
3158
|
+
nodeFs.constants.O_RDONLY | (process.platform === "win32" ? 0 : (nodeFs.constants.O_NOFOLLOW ?? 0));
|
|
3159
|
+
let handle: fs.FileHandle | undefined;
|
|
3160
|
+
try {
|
|
3161
|
+
handle = await fs.open(filePath, openFlags);
|
|
3162
|
+
const openedStat = await handle.stat({ bigint: true });
|
|
3163
|
+
const beforeReadStat = await fs.lstat(filePath, { bigint: true });
|
|
3164
|
+
if (
|
|
3165
|
+
openedStat.isSymbolicLink() ||
|
|
3166
|
+
!openedStat.isFile() ||
|
|
3167
|
+
beforeReadStat.isSymbolicLink() ||
|
|
3168
|
+
!sameBoundedFileIdentity(initialStat, openedStat) ||
|
|
3169
|
+
!sameBoundedFileIdentity(initialStat, beforeReadStat)
|
|
3170
|
+
)
|
|
3171
|
+
return undefined;
|
|
3172
|
+
const hasher = createHash("sha256");
|
|
3173
|
+
const buffer = Buffer.alloc(64 * 1024);
|
|
3174
|
+
let position = 0;
|
|
3175
|
+
const size = Number(openedStat.size);
|
|
3176
|
+
while (position < size) {
|
|
3177
|
+
const { bytesRead } = await handle.read(buffer, 0, Math.min(buffer.length, size - position), position);
|
|
3178
|
+
if (bytesRead === 0) return undefined;
|
|
3179
|
+
hasher.update(buffer.subarray(0, bytesRead));
|
|
3180
|
+
position += bytesRead;
|
|
3181
|
+
}
|
|
3182
|
+
const afterReadStat = await handle.stat({ bigint: true });
|
|
3183
|
+
const afterPathStat = await fs.lstat(filePath, { bigint: true });
|
|
3184
|
+
if (
|
|
3185
|
+
afterPathStat.isSymbolicLink() ||
|
|
3186
|
+
!afterPathStat.isFile() ||
|
|
3187
|
+
!sameBoundedFileIdentity(initialStat, afterReadStat) ||
|
|
3188
|
+
!sameBoundedFileIdentity(initialStat, afterPathStat)
|
|
3189
|
+
)
|
|
3190
|
+
return undefined;
|
|
3191
|
+
return hasher.digest("hex");
|
|
3192
|
+
} catch {
|
|
3193
|
+
return undefined;
|
|
3194
|
+
} finally {
|
|
3195
|
+
await handle?.close().catch(() => undefined);
|
|
3196
|
+
}
|
|
3197
|
+
}
|
|
3198
|
+
|
|
3199
|
+
async function assertSanctionedExecutionApprovalAudit(
|
|
3200
|
+
cwd: string,
|
|
3201
|
+
sessionId: string,
|
|
3202
|
+
statePath: string,
|
|
3203
|
+
approval: Record<string, unknown>,
|
|
3204
|
+
envelope?: Record<string, unknown>,
|
|
3205
|
+
): Promise<void> {
|
|
3206
|
+
if (!cwd || !sessionId || !statePath)
|
|
3207
|
+
throw new StateCommandError(2, "deep-interview execution approval lacks sanctioned transition provenance");
|
|
3208
|
+
const mutationId = typeof approval.mutation_id === "string" ? approval.mutation_id : undefined;
|
|
3209
|
+
const approvedAt = typeof approval.approved_at === "string" ? approval.approved_at : undefined;
|
|
3210
|
+
const resolvedStatePath = path.resolve(statePath);
|
|
3211
|
+
const resolvedActivePath = path.resolve(activeStateFile(cwd, sessionId));
|
|
3212
|
+
const approvalRevision = approval.state_revision;
|
|
3213
|
+
const approvalStatePath = typeof approval.state_path === "string" ? path.resolve(approval.state_path) : undefined;
|
|
3214
|
+
if (
|
|
3215
|
+
!mutationId ||
|
|
3216
|
+
!approvedAt ||
|
|
3217
|
+
!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/.test(approvedAt) ||
|
|
3218
|
+
!Number.isSafeInteger(approvalRevision) ||
|
|
3219
|
+
(approvalRevision as number) < 1 ||
|
|
3220
|
+
approvalStatePath !== resolvedStatePath ||
|
|
3221
|
+
mutationId !== `deep-interview:approve-execution:${approvedAt}`
|
|
3222
|
+
)
|
|
3223
|
+
throw new StateCommandError(2, "deep-interview execution approval lacks sanctioned transition provenance");
|
|
3224
|
+
|
|
3225
|
+
const indexedApprovalPath = path.join(sessionStateDir(cwd, sessionId), "deep-interview-approval-audit.json");
|
|
3226
|
+
let indexedRaw = "";
|
|
3227
|
+
try {
|
|
3228
|
+
const sameFileIdentity = (left: nodeFs.BigIntStats, right: nodeFs.BigIntStats): boolean =>
|
|
3229
|
+
left.dev === right.dev &&
|
|
3230
|
+
left.ino === right.ino &&
|
|
3231
|
+
left.mode === right.mode &&
|
|
3232
|
+
left.size === right.size &&
|
|
3233
|
+
left.mtimeNs === right.mtimeNs &&
|
|
3234
|
+
left.ctimeNs === right.ctimeNs &&
|
|
3235
|
+
left.nlink === right.nlink;
|
|
3236
|
+
const initialStat = await fs.lstat(indexedApprovalPath, { bigint: true });
|
|
3237
|
+
if (initialStat.isSymbolicLink() || !initialStat.isFile())
|
|
3238
|
+
throw new StateCommandError(2, "deep-interview execution approval index is invalid");
|
|
3239
|
+
const openFlags =
|
|
3240
|
+
nodeFs.constants.O_RDONLY | (process.platform === "win32" ? 0 : (nodeFs.constants.O_NOFOLLOW ?? 0));
|
|
3241
|
+
const handle = await fs.open(indexedApprovalPath, openFlags);
|
|
3242
|
+
try {
|
|
3243
|
+
const indexedStat = await handle.stat({ bigint: true });
|
|
3244
|
+
const beforeReadStat = await fs.lstat(indexedApprovalPath, { bigint: true });
|
|
3245
|
+
if (
|
|
3246
|
+
indexedStat.isSymbolicLink() ||
|
|
3247
|
+
!indexedStat.isFile() ||
|
|
3248
|
+
beforeReadStat.isSymbolicLink() ||
|
|
3249
|
+
!sameFileIdentity(initialStat, indexedStat) ||
|
|
3250
|
+
!sameFileIdentity(initialStat, beforeReadStat) ||
|
|
3251
|
+
indexedStat.size > 64n * 1024n
|
|
3252
|
+
)
|
|
3253
|
+
throw new StateCommandError(2, "deep-interview execution approval index is invalid");
|
|
3254
|
+
const buffer = Buffer.alloc(Number(indexedStat.size));
|
|
3255
|
+
let offset = 0;
|
|
3256
|
+
while (offset < buffer.length) {
|
|
3257
|
+
const { bytesRead } = await handle.read(buffer, offset, buffer.length - offset, offset);
|
|
3258
|
+
if (bytesRead === 0) throw new StateCommandError(2, "deep-interview execution approval index is invalid");
|
|
3259
|
+
offset += bytesRead;
|
|
3260
|
+
}
|
|
3261
|
+
const afterReadStat = await handle.stat({ bigint: true });
|
|
3262
|
+
const afterPathStat = await fs.lstat(indexedApprovalPath, { bigint: true });
|
|
3263
|
+
if (
|
|
3264
|
+
afterPathStat.isSymbolicLink() ||
|
|
3265
|
+
!afterPathStat.isFile() ||
|
|
3266
|
+
!sameFileIdentity(initialStat, afterReadStat) ||
|
|
3267
|
+
!sameFileIdentity(initialStat, afterPathStat)
|
|
3268
|
+
)
|
|
3269
|
+
throw new StateCommandError(2, "deep-interview execution approval index is invalid");
|
|
3270
|
+
indexedRaw = buffer.subarray(0, offset).toString("utf-8");
|
|
3271
|
+
} finally {
|
|
3272
|
+
await handle.close();
|
|
3273
|
+
}
|
|
3274
|
+
const indexedRecord: unknown = JSON.parse(indexedRaw);
|
|
3275
|
+
if (!isPlainObject(indexedRecord))
|
|
3276
|
+
throw new StateCommandError(2, "deep-interview execution approval index is invalid");
|
|
3277
|
+
indexedRaw = `${JSON.stringify(indexedRecord)}\n`;
|
|
3278
|
+
} catch (error) {
|
|
3279
|
+
if (error instanceof StateCommandError) throw error;
|
|
3280
|
+
const err = error as NodeJS.ErrnoException;
|
|
3281
|
+
if (err.code !== "ENOENT")
|
|
3282
|
+
throw new StateCommandError(2, `failed to read execution approval index: ${err.message}`);
|
|
3283
|
+
}
|
|
3284
|
+
let auditRaw = "";
|
|
3285
|
+
try {
|
|
3286
|
+
const filePath = auditPath(cwd, sessionId);
|
|
3287
|
+
const stat = await fs.stat(filePath);
|
|
3288
|
+
const maxBytes = 1024 * 1024;
|
|
3289
|
+
const start = Math.max(0, stat.size - maxBytes);
|
|
3290
|
+
const handle = await fs.open(filePath, "r");
|
|
3291
|
+
try {
|
|
3292
|
+
const buffer = Buffer.alloc(stat.size - start);
|
|
3293
|
+
let offset = 0;
|
|
3294
|
+
while (offset < buffer.length) {
|
|
3295
|
+
const { bytesRead } = await handle.read(buffer, offset, buffer.length - offset, start + offset);
|
|
3296
|
+
if (bytesRead === 0) throw new Error("execution approval audit ended before the bounded tail was read");
|
|
3297
|
+
offset += bytesRead;
|
|
3298
|
+
}
|
|
3299
|
+
auditRaw = buffer.subarray(0, offset).toString("utf-8");
|
|
3300
|
+
if (start > 0) {
|
|
3301
|
+
const firstNewline = auditRaw.indexOf("\n");
|
|
3302
|
+
auditRaw = firstNewline >= 0 ? auditRaw.slice(firstNewline + 1) : "";
|
|
3303
|
+
}
|
|
3304
|
+
} finally {
|
|
3305
|
+
await handle.close();
|
|
3306
|
+
}
|
|
3307
|
+
} catch (error) {
|
|
3308
|
+
const err = error as NodeJS.ErrnoException;
|
|
3309
|
+
if (err.code === "ENOENT" && !indexedRaw)
|
|
3310
|
+
throw new StateCommandError(2, "deep-interview execution approval lacks sanctioned approval audit record");
|
|
3311
|
+
if (err.code !== "ENOENT")
|
|
3312
|
+
throw new StateCommandError(2, `failed to read execution approval audit: ${err.message}`);
|
|
3313
|
+
}
|
|
3314
|
+
const raw = `${indexedRaw}${auditRaw}`;
|
|
3315
|
+
|
|
3316
|
+
const found = raw.split(/\r?\n/).some(line => {
|
|
3317
|
+
if (!line.trim()) return false;
|
|
3318
|
+
let parsed: unknown;
|
|
3319
|
+
try {
|
|
3320
|
+
parsed = JSON.parse(line);
|
|
3321
|
+
} catch {
|
|
3322
|
+
return false;
|
|
3323
|
+
}
|
|
3324
|
+
if (!isPlainObject(parsed)) return false;
|
|
3325
|
+
const paths = Array.isArray(parsed.paths)
|
|
3326
|
+
? parsed.paths.filter((value): value is string => typeof value === "string")
|
|
3327
|
+
: [];
|
|
3328
|
+
const auditReceipt = isPlainObject(parsed.receipt) ? parsed.receipt : undefined;
|
|
3329
|
+
const auditChecksum = isPlainObject(auditReceipt?.content_sha256) ? auditReceipt.content_sha256 : undefined;
|
|
3330
|
+
const auditPath = typeof parsed.state_path === "string" ? path.resolve(parsed.state_path) : undefined;
|
|
3331
|
+
const receiptIdentityMatches =
|
|
3332
|
+
Boolean(auditReceipt) &&
|
|
3333
|
+
auditReceipt?.version === 1 &&
|
|
3334
|
+
auditReceipt?.skill === "deep-interview" &&
|
|
3335
|
+
auditReceipt?.owner === "gjc-state-cli" &&
|
|
3336
|
+
auditReceipt?.command === "gjc state deep-interview approve-execution" &&
|
|
3337
|
+
auditReceipt?.verb === "approve-execution" &&
|
|
3338
|
+
auditReceipt?.from_phase === "handoff" &&
|
|
3339
|
+
auditReceipt?.to_phase === "handoff" &&
|
|
3340
|
+
auditReceipt?.forced === false &&
|
|
3341
|
+
auditReceipt?.mutation_id === mutationId &&
|
|
3342
|
+
auditReceipt?.mutated_at === approvedAt &&
|
|
3343
|
+
auditReceipt?.storage_path === resolvedStatePath &&
|
|
3344
|
+
auditReceipt?.state_path === resolvedActivePath &&
|
|
3345
|
+
auditChecksum?.algorithm === "sha256" &&
|
|
3346
|
+
typeof auditChecksum.value === "string" &&
|
|
3347
|
+
auditChecksum.value.length === 64 &&
|
|
3348
|
+
auditChecksum.covered_path === resolvedStatePath;
|
|
3349
|
+
const exactBinding =
|
|
3350
|
+
parsed.ts === approvedAt &&
|
|
3351
|
+
parsed.approved_at === approvedAt &&
|
|
3352
|
+
parsed.from_phase === "handoff" &&
|
|
3353
|
+
parsed.to_phase === "handoff" &&
|
|
3354
|
+
parsed.state_revision === approvalRevision &&
|
|
3355
|
+
parsed.receipt_state_revision === approvalRevision &&
|
|
3356
|
+
auditPath === resolvedStatePath &&
|
|
3357
|
+
receiptIdentityMatches;
|
|
3358
|
+
return (
|
|
3359
|
+
parsed.skill === "deep-interview" &&
|
|
3360
|
+
parsed.category === "state" &&
|
|
3361
|
+
parsed.verb === "approve-execution" &&
|
|
3362
|
+
parsed.owner === "gjc-state-cli" &&
|
|
3363
|
+
parsed.mutation_id === mutationId &&
|
|
3364
|
+
parsed.forced === false &&
|
|
3365
|
+
paths.length === 1 &&
|
|
3366
|
+
path.resolve(paths[0]) === resolvedStatePath &&
|
|
3367
|
+
exactBinding
|
|
3368
|
+
);
|
|
1621
3369
|
});
|
|
3370
|
+
if (!found)
|
|
3371
|
+
throw new StateCommandError(2, "deep-interview execution approval lacks sanctioned approval audit record");
|
|
3372
|
+
|
|
3373
|
+
// Before the execution handoff itself, the top-level receipt still carries
|
|
3374
|
+
// the approval mutation. Bind the audit to that exact stamped receipt so a
|
|
3375
|
+
// restamp can never turn a forged approval into an authenticated one. A
|
|
3376
|
+
// completed handoff retry has a new top-level handoff receipt and instead
|
|
3377
|
+
// relies on the durable fields above plus the retry identity checks.
|
|
3378
|
+
const currentReceipt = envelope && isPlainObject(envelope.receipt) ? envelope.receipt : undefined;
|
|
3379
|
+
if (currentReceipt?.mutation_id === mutationId) {
|
|
3380
|
+
const auditLine = raw
|
|
3381
|
+
.split(/\r?\n/)
|
|
3382
|
+
.map(line => {
|
|
3383
|
+
try {
|
|
3384
|
+
const parsed: unknown = line.trim() ? JSON.parse(line) : undefined;
|
|
3385
|
+
return isPlainObject(parsed) && parsed.mutation_id === mutationId && isPlainObject(parsed.receipt)
|
|
3386
|
+
? parsed
|
|
3387
|
+
: undefined;
|
|
3388
|
+
} catch {
|
|
3389
|
+
return undefined;
|
|
3390
|
+
}
|
|
3391
|
+
})
|
|
3392
|
+
.find((entry): entry is Record<string, unknown> => Boolean(entry));
|
|
3393
|
+
const auditedReceipt = auditLine && isPlainObject(auditLine.receipt) ? auditLine.receipt : undefined;
|
|
3394
|
+
if (!auditedReceipt || JSON.stringify(auditedReceipt) !== JSON.stringify(currentReceipt))
|
|
3395
|
+
throw new StateCommandError(2, "deep-interview execution approval receipt binding mismatch");
|
|
3396
|
+
}
|
|
3397
|
+
}
|
|
3398
|
+
|
|
3399
|
+
/** Historical audit entries do not attach lineage to a replacement workflow run. */
|
|
3400
|
+
async function hasCurrentDeepInterviewHandoff(
|
|
3401
|
+
cwd: string,
|
|
3402
|
+
sessionId: string,
|
|
3403
|
+
callee: CanonicalGjcWorkflowSkill,
|
|
3404
|
+
state: Record<string, unknown>,
|
|
3405
|
+
): Promise<boolean> {
|
|
3406
|
+
const handoffAt = state.upstream_handoff_at ?? state.handoff_at;
|
|
3407
|
+
if (
|
|
3408
|
+
state.handoff_from === undefined &&
|
|
3409
|
+
state.upstream_handoff_at === undefined &&
|
|
3410
|
+
(handoffAt === undefined || state.handoff_to !== undefined)
|
|
3411
|
+
)
|
|
3412
|
+
return false;
|
|
3413
|
+
if (
|
|
3414
|
+
typeof handoffAt !== "string" ||
|
|
3415
|
+
!handoffAt.trim() ||
|
|
3416
|
+
!(await hasAuditedDeepInterviewHandoff(cwd, sessionId, callee, { handoffAt }))
|
|
3417
|
+
)
|
|
3418
|
+
throw new StateCommandError(2, "execution handoff cannot authenticate Deep Interview approval lineage");
|
|
3419
|
+
return true;
|
|
3420
|
+
}
|
|
3421
|
+
|
|
3422
|
+
async function hasAuditedDeepInterviewHandoff(
|
|
3423
|
+
cwd: string,
|
|
3424
|
+
sessionId: string,
|
|
3425
|
+
callee: CanonicalGjcWorkflowSkill,
|
|
3426
|
+
options: { handoffAt?: string; indexOnly?: boolean } = {},
|
|
3427
|
+
): Promise<boolean> {
|
|
3428
|
+
const indexedRaw = await readBoundedIdentityText(
|
|
3429
|
+
path.join(sessionStateDir(cwd, sessionId), `deep-interview-handoff-${callee}-audit.json`),
|
|
3430
|
+
64 * 1024,
|
|
3431
|
+
"deep-interview handoff index",
|
|
3432
|
+
);
|
|
3433
|
+
const auditRaw = options.indexOnly
|
|
3434
|
+
? undefined
|
|
3435
|
+
: await readBoundedIdentityText(auditPath(cwd, sessionId), 1024 * 1024, "deep-interview handoff audit", {
|
|
3436
|
+
tail: true,
|
|
3437
|
+
});
|
|
3438
|
+
const raw = `${indexedRaw ?? ""}${auditRaw ?? ""}`;
|
|
3439
|
+
if (!raw) return false;
|
|
3440
|
+
const sourcePath = path.resolve(modeStateFile(cwd, "deep-interview", sessionId));
|
|
3441
|
+
const calleePath = path.resolve(modeStateFile(cwd, callee, sessionId));
|
|
3442
|
+
const activePath = path.resolve(activeStateFile(cwd, sessionId));
|
|
3443
|
+
const expectedAt = options.handoffAt;
|
|
3444
|
+
const expectedMutation = expectedAt ? `deep-interview:handoff:${callee}:${expectedAt}` : undefined;
|
|
3445
|
+
return raw.split(/\r?\n/).some(line => {
|
|
3446
|
+
if (!line.trim()) return false;
|
|
3447
|
+
let parsed: unknown;
|
|
3448
|
+
try {
|
|
3449
|
+
parsed = JSON.parse(line);
|
|
3450
|
+
} catch {
|
|
3451
|
+
return false;
|
|
3452
|
+
}
|
|
3453
|
+
if (!isPlainObject(parsed) || !Array.isArray(parsed.paths)) return false;
|
|
3454
|
+
const mutationId = parsed.mutation_id;
|
|
3455
|
+
const paths = parsed.paths
|
|
3456
|
+
.filter((value): value is string => typeof value === "string")
|
|
3457
|
+
.map(value => path.resolve(value));
|
|
3458
|
+
const callerReceipt = persistedWorkflowReceipt(parsed.caller_receipt, "deep-interview");
|
|
3459
|
+
const calleeReceipt = persistedWorkflowReceipt(parsed.callee_receipt, callee);
|
|
3460
|
+
const callerChecksum = callerReceipt?.content_sha256;
|
|
3461
|
+
const calleeChecksum = calleeReceipt?.content_sha256;
|
|
3462
|
+
const callerRevision = parsed.caller_state_revision;
|
|
3463
|
+
const calleeRevision = parsed.callee_state_revision;
|
|
3464
|
+
return (
|
|
3465
|
+
parsed.skill === "deep-interview" &&
|
|
3466
|
+
parsed.category === "state" &&
|
|
3467
|
+
parsed.verb === "handoff" &&
|
|
3468
|
+
parsed.owner === "gjc-state-cli" &&
|
|
3469
|
+
parsed.forced === false &&
|
|
3470
|
+
typeof mutationId === "string" &&
|
|
3471
|
+
(!expectedMutation
|
|
3472
|
+
? mutationId.startsWith(`deep-interview:handoff:${callee}:`)
|
|
3473
|
+
: mutationId === expectedMutation) &&
|
|
3474
|
+
typeof parsed.ts === "string" &&
|
|
3475
|
+
(!expectedAt
|
|
3476
|
+
? parsed.ts === mutationId.slice(`deep-interview:handoff:${callee}:`.length)
|
|
3477
|
+
: parsed.ts === expectedAt) &&
|
|
3478
|
+
parsed.handoff_at === parsed.ts &&
|
|
3479
|
+
parsed.to_phase === "handoff" &&
|
|
3480
|
+
paths.length === 3 &&
|
|
3481
|
+
new Set(paths).size === 3 &&
|
|
3482
|
+
paths.includes(sourcePath) &&
|
|
3483
|
+
paths.includes(calleePath) &&
|
|
3484
|
+
paths.includes(activePath) &&
|
|
3485
|
+
path.resolve(typeof parsed.caller_path === "string" ? parsed.caller_path : "") === sourcePath &&
|
|
3486
|
+
path.resolve(typeof parsed.callee_path === "string" ? parsed.callee_path : "") === calleePath &&
|
|
3487
|
+
path.resolve(typeof parsed.active_state_path === "string" ? parsed.active_state_path : "") === activePath &&
|
|
3488
|
+
Number.isSafeInteger(callerRevision) &&
|
|
3489
|
+
(callerRevision as number) > 0 &&
|
|
3490
|
+
Number.isSafeInteger(calleeRevision) &&
|
|
3491
|
+
(calleeRevision as number) > 0 &&
|
|
3492
|
+
Boolean(callerReceipt) &&
|
|
3493
|
+
Boolean(calleeReceipt) &&
|
|
3494
|
+
callerReceipt?.mutation_id === mutationId &&
|
|
3495
|
+
calleeReceipt?.mutation_id === mutationId &&
|
|
3496
|
+
callerReceipt?.mutated_at === parsed.ts &&
|
|
3497
|
+
calleeReceipt?.mutated_at === parsed.ts &&
|
|
3498
|
+
callerReceipt?.storage_path === sourcePath &&
|
|
3499
|
+
calleeReceipt?.storage_path === calleePath &&
|
|
3500
|
+
callerReceipt?.state_path === activePath &&
|
|
3501
|
+
calleeReceipt?.state_path === activePath &&
|
|
3502
|
+
callerChecksum?.algorithm === "sha256" &&
|
|
3503
|
+
typeof callerChecksum.value === "string" &&
|
|
3504
|
+
callerChecksum.value.length === 64 &&
|
|
3505
|
+
callerChecksum.covered_path === sourcePath &&
|
|
3506
|
+
calleeChecksum?.algorithm === "sha256" &&
|
|
3507
|
+
typeof calleeChecksum.value === "string" &&
|
|
3508
|
+
calleeChecksum.value.length === 64 &&
|
|
3509
|
+
calleeChecksum.covered_path === calleePath
|
|
3510
|
+
);
|
|
3511
|
+
});
|
|
3512
|
+
}
|
|
3513
|
+
|
|
3514
|
+
async function assertDeepInterviewHandoffReady(
|
|
3515
|
+
state: Record<string, unknown>,
|
|
3516
|
+
options: { cwd?: string; sessionId?: string; statePath?: string; requireExecutionApproval?: boolean } = {},
|
|
3517
|
+
): Promise<void> {
|
|
3518
|
+
const specPath = typeof state.spec_path === "string" ? state.spec_path : undefined;
|
|
3519
|
+
const expectedSha = typeof state.spec_sha256 === "string" ? state.spec_sha256 : undefined;
|
|
3520
|
+
const rawInnerStatePresent = isPlainObject(state.state);
|
|
3521
|
+
let content: string | undefined;
|
|
3522
|
+
if (specPath) {
|
|
3523
|
+
try {
|
|
3524
|
+
content = await readBoundedIdentityText(
|
|
3525
|
+
specPath,
|
|
3526
|
+
MAX_DEEP_INTERVIEW_STRUCTURED_RESPONSE_LENGTH * 4 + 1,
|
|
3527
|
+
"persisted deep-interview spec",
|
|
3528
|
+
);
|
|
3529
|
+
if (content === undefined) throw new Error("persisted spec is unavailable");
|
|
3530
|
+
} catch (error) {
|
|
3531
|
+
throw new StateCommandError(
|
|
3532
|
+
2,
|
|
3533
|
+
`deep-interview handoff cannot read persisted spec: ${error instanceof Error ? error.message : String(error)}`,
|
|
3534
|
+
);
|
|
3535
|
+
}
|
|
3536
|
+
const boundedContent = content.endsWith("\n") ? content.slice(0, -1) : content;
|
|
3537
|
+
assertDeepInterviewInputWithinLimit(
|
|
3538
|
+
boundedContent,
|
|
3539
|
+
MAX_DEEP_INTERVIEW_STRUCTURED_RESPONSE_LENGTH,
|
|
3540
|
+
"persisted deep-interview spec",
|
|
3541
|
+
);
|
|
3542
|
+
}
|
|
3543
|
+
const envelope = normalizeDeepInterviewEnvelope(state);
|
|
3544
|
+
const inner = envelope.state;
|
|
3545
|
+
if (!rawInnerStatePresent && options.requireExecutionApproval)
|
|
3546
|
+
throw new StateCommandError(2, "deep-interview execution handoff requires normalized inner state");
|
|
3547
|
+
if (!inner) return;
|
|
3548
|
+
const assertLockedIntentContract = (): void => {
|
|
3549
|
+
if (inner.intent_contract === undefined) {
|
|
3550
|
+
if (inner.intent_contract_required === true)
|
|
3551
|
+
throw new StateCommandError(2, "deep-interview handoff requires a locked Round 0 intent contract");
|
|
3552
|
+
return;
|
|
3553
|
+
}
|
|
3554
|
+
assertDeepInterviewIntentManifest(inner.intent_contract);
|
|
3555
|
+
if (!specPath || !expectedSha || content === undefined)
|
|
3556
|
+
throw new StateCommandError(2, "deep-interview handoff requires a persisted intent-validated spec");
|
|
3557
|
+
if (createHash("sha256").update(content).digest("hex") !== expectedSha)
|
|
3558
|
+
throw new StateCommandError(2, "deep-interview handoff spec hash mismatch");
|
|
3559
|
+
const observedIds = [...new Set(content.match(DEEP_INTERVIEW_INTENT_ID_RE) ?? [])].sort();
|
|
3560
|
+
const rounds = Array.isArray(inner.rounds)
|
|
3561
|
+
? inner.rounds
|
|
3562
|
+
.filter(
|
|
3563
|
+
(round): round is Record<string, unknown> =>
|
|
3564
|
+
Boolean(round) && typeof round === "object" && !Array.isArray(round),
|
|
3565
|
+
)
|
|
3566
|
+
.map(round => ({ round: round.round, answer_hash: round.answer_hash }))
|
|
3567
|
+
: [];
|
|
3568
|
+
try {
|
|
3569
|
+
assertDeepInterviewIntentReview(
|
|
3570
|
+
inner.intent_review,
|
|
3571
|
+
inner.intent_contract as DeepInterviewIntentManifest,
|
|
3572
|
+
observedIds,
|
|
3573
|
+
rounds,
|
|
3574
|
+
);
|
|
3575
|
+
} catch (error) {
|
|
3576
|
+
throw new StateCommandError(
|
|
3577
|
+
2,
|
|
3578
|
+
`deep-interview handoff intent validation failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
3579
|
+
);
|
|
3580
|
+
}
|
|
3581
|
+
};
|
|
3582
|
+
if (inner.crystal !== undefined) {
|
|
3583
|
+
const crystal = requireReadyCanonicalCrystal(inner.crystal);
|
|
3584
|
+
if (!specPath || !expectedSha || content === undefined)
|
|
3585
|
+
throw new StateCommandError(2, "deep-interview crystallized handoff requires a persisted spec");
|
|
3586
|
+
if (options.cwd && options.sessionId) {
|
|
3587
|
+
const specsRoot = path.resolve(sessionSpecsDir(options.cwd, options.sessionId));
|
|
3588
|
+
const relativeSpec = path.relative(specsRoot, path.resolve(specPath));
|
|
3589
|
+
if (relativeSpec.startsWith("..") || path.isAbsolute(relativeSpec))
|
|
3590
|
+
throw new StateCommandError(2, "deep-interview crystallized handoff requires a session-scoped spec");
|
|
3591
|
+
}
|
|
3592
|
+
if (createHash("sha256").update(content).digest("hex") !== expectedSha)
|
|
3593
|
+
throw new StateCommandError(2, "deep-interview crystallized handoff spec hash mismatch");
|
|
3594
|
+
let canonicalContent: string;
|
|
3595
|
+
try {
|
|
3596
|
+
canonicalContent = crystalMarkdown(crystal as unknown as DeepInterviewCrystal);
|
|
3597
|
+
} catch {
|
|
3598
|
+
throw new StateCommandError(2, "deep-interview crystallized handoff requires complete canonical evidence");
|
|
3599
|
+
}
|
|
3600
|
+
if (canonicalContent !== content)
|
|
3601
|
+
throw new StateCommandError(
|
|
3602
|
+
2,
|
|
3603
|
+
"deep-interview crystallized handoff spec is not the canonical Crystal rendering",
|
|
3604
|
+
);
|
|
3605
|
+
assertLockedIntentContract();
|
|
3606
|
+
if (inner.execution_approval !== "approved" && options.requireExecutionApproval)
|
|
3607
|
+
throw new StateCommandError(2, "deep-interview crystallization never grants execution approval");
|
|
3608
|
+
if (options.requireExecutionApproval) {
|
|
3609
|
+
const approval = isPlainObject(inner.execution_approval_receipt)
|
|
3610
|
+
? inner.execution_approval_receipt
|
|
3611
|
+
: undefined;
|
|
3612
|
+
if (
|
|
3613
|
+
approval?.schema_version !== 1 ||
|
|
3614
|
+
approval.method !== "explicit-state-action" ||
|
|
3615
|
+
typeof approval.approved_at !== "string" ||
|
|
3616
|
+
typeof approval.mutation_id !== "string" ||
|
|
3617
|
+
approval.spec_sha256 !== expectedSha ||
|
|
3618
|
+
approval.crystal_spec_version !== crystal.spec_version ||
|
|
3619
|
+
approval.crystal_source_digest !== (crystal.source as Record<string, unknown>).digest ||
|
|
3620
|
+
typeof approval.transcript_path !== "string" ||
|
|
3621
|
+
!path.isAbsolute(approval.transcript_path) ||
|
|
3622
|
+
!isSha256(approval.transcript_sha256)
|
|
3623
|
+
)
|
|
3624
|
+
throw new StateCommandError(2, "deep-interview execution approval lacks explicit provenance");
|
|
3625
|
+
await assertExecutionApprovalTranscriptBoundary(
|
|
3626
|
+
options.cwd ?? "",
|
|
3627
|
+
options.sessionId ?? "",
|
|
3628
|
+
approval.transcript_path,
|
|
3629
|
+
approval.transcript_sha256,
|
|
3630
|
+
approval.transcript_boundary,
|
|
3631
|
+
);
|
|
3632
|
+
await assertSanctionedExecutionApprovalAudit(
|
|
3633
|
+
options.cwd ?? "",
|
|
3634
|
+
options.sessionId ?? "",
|
|
3635
|
+
options.statePath ?? "",
|
|
3636
|
+
approval,
|
|
3637
|
+
envelope,
|
|
3638
|
+
);
|
|
3639
|
+
}
|
|
3640
|
+
return;
|
|
3641
|
+
}
|
|
3642
|
+
if (options.requireExecutionApproval)
|
|
3643
|
+
await assertNonCrystalApprovalCurrent(options.cwd ?? "", options.sessionId ?? "", "deep-interview", "consumed");
|
|
3644
|
+
assertLockedIntentContract();
|
|
3645
|
+
}
|
|
3646
|
+
|
|
3647
|
+
interface VerifiedRalplanFinalEvidence {
|
|
3648
|
+
runId: string;
|
|
3649
|
+
statePath: string;
|
|
3650
|
+
stateRevision: number;
|
|
3651
|
+
finalPath: string;
|
|
3652
|
+
finalSha256: string;
|
|
3653
|
+
}
|
|
3654
|
+
|
|
3655
|
+
async function hasDurableRalplanPlanningStuck(
|
|
3656
|
+
cwd: string,
|
|
3657
|
+
sessionId: string,
|
|
3658
|
+
state: Record<string, unknown>,
|
|
3659
|
+
): Promise<boolean> {
|
|
3660
|
+
if (isPlainObject(state.planning_stuck)) return true;
|
|
3661
|
+
const admission = isPlainObject(state.auto_handoff) ? state.auto_handoff : undefined;
|
|
3662
|
+
if (admission?.degradationReason === "planning_stuck") return true;
|
|
3663
|
+
const runId = typeof state.run_id === "string" ? state.run_id.trim() : "";
|
|
3664
|
+
if (!runId) return false;
|
|
3665
|
+
try {
|
|
3666
|
+
assertSafePathComponent(runId, "ralplan run-id");
|
|
3667
|
+
const indexPath = path.join(sessionPlansDir(cwd, sessionId), "ralplan", runId, "index.jsonl");
|
|
3668
|
+
const text = await readBoundedIdentityText(indexPath, 1024 * 1024, "ralplan final index");
|
|
3669
|
+
if (text === undefined) return false;
|
|
3670
|
+
return text.split(/\r?\n/).some(line => {
|
|
3671
|
+
if (!line.trim()) return false;
|
|
3672
|
+
try {
|
|
3673
|
+
const row = JSON.parse(line) as unknown;
|
|
3674
|
+
return isPlainObject(row) && (row.planning_stuck === true || row.event === "planning_stuck");
|
|
3675
|
+
} catch {
|
|
3676
|
+
return true;
|
|
3677
|
+
}
|
|
3678
|
+
});
|
|
3679
|
+
} catch {
|
|
3680
|
+
return true;
|
|
3681
|
+
}
|
|
3682
|
+
}
|
|
3683
|
+
|
|
3684
|
+
async function verifiedRalplanFinalEvidence(
|
|
3685
|
+
cwd: string,
|
|
3686
|
+
sessionId: string,
|
|
3687
|
+
state: Record<string, unknown>,
|
|
3688
|
+
): Promise<VerifiedRalplanFinalEvidence | undefined> {
|
|
3689
|
+
if (isPlainObject(state.final_publication_pending)) return undefined;
|
|
3690
|
+
const runId = typeof state.run_id === "string" ? state.run_id.trim() : "";
|
|
3691
|
+
if (!runId) return undefined;
|
|
3692
|
+
try {
|
|
3693
|
+
assertSafePathComponent(runId, "ralplan run-id");
|
|
3694
|
+
} catch {
|
|
3695
|
+
return undefined;
|
|
3696
|
+
}
|
|
3697
|
+
const admission = isPlainObject(state.auto_handoff) ? state.auto_handoff : undefined;
|
|
3698
|
+
if (!admission) return undefined;
|
|
3699
|
+
if (
|
|
3700
|
+
typeof admission.effectiveTarget !== "string" ||
|
|
3701
|
+
admission.degradationReason === "planning_stuck" ||
|
|
3702
|
+
typeof admission.source !== "string" ||
|
|
3703
|
+
!admission.source.trim()
|
|
3704
|
+
)
|
|
3705
|
+
return undefined;
|
|
3706
|
+
const ralplanPath = modeStateFile(cwd, "ralplan", sessionId);
|
|
3707
|
+
const receipt = persistedWorkflowReceipt(state.receipt, "ralplan");
|
|
3708
|
+
const checksum = receipt?.content_sha256;
|
|
3709
|
+
const phaseMarker = isPlainObject(state.final_admission_phase_transition)
|
|
3710
|
+
? state.final_admission_phase_transition
|
|
3711
|
+
: undefined;
|
|
3712
|
+
const phaseTransitionReceipt =
|
|
3713
|
+
Boolean(phaseMarker) &&
|
|
3714
|
+
state.current_phase === "handoff" &&
|
|
3715
|
+
receipt?.owner === "gjc-state-cli" &&
|
|
3716
|
+
(receipt.command === "gjc state ralplan write" || receipt.command === "gjc state ralplan handoff --to ultragoal");
|
|
3717
|
+
if (
|
|
3718
|
+
(!phaseTransitionReceipt &&
|
|
3719
|
+
(receipt?.owner !== "gjc-runtime" || receipt.command !== "gjc ralplan final-admission")) ||
|
|
3720
|
+
checksum?.algorithm !== "sha256" ||
|
|
3721
|
+
typeof checksum.value !== "string" ||
|
|
3722
|
+
!/^[0-9a-f]{64}$/.test(checksum.value) ||
|
|
3723
|
+
checksum.covered_path !== path.resolve(ralplanPath) ||
|
|
3724
|
+
typeof checksum.computed_at !== "string" ||
|
|
3725
|
+
!checksum.computed_at.trim()
|
|
3726
|
+
)
|
|
3727
|
+
return undefined;
|
|
3728
|
+
const integrityWarning = await warnAndAuditOutOfBandIfNeeded(cwd, sessionId, ralplanPath, "ralplan");
|
|
3729
|
+
if (integrityWarning) return undefined;
|
|
3730
|
+
|
|
3731
|
+
const runDir = path.join(sessionPlansDir(cwd, sessionId), "ralplan", runId);
|
|
3732
|
+
const indexPath = path.join(runDir, "index.jsonl");
|
|
3733
|
+
let indexText: string;
|
|
3734
|
+
try {
|
|
3735
|
+
const bounded = await readBoundedIdentityText(indexPath, 1024 * 1024, "ralplan final index");
|
|
3736
|
+
if (bounded === undefined) return undefined;
|
|
3737
|
+
indexText = bounded;
|
|
3738
|
+
} catch {
|
|
3739
|
+
return undefined;
|
|
3740
|
+
}
|
|
3741
|
+
let finalRow: Record<string, unknown> | undefined;
|
|
3742
|
+
let planningStuck = false;
|
|
3743
|
+
for (const line of indexText.split(/\r?\n/)) {
|
|
3744
|
+
if (!line.trim()) continue;
|
|
3745
|
+
let parsed: unknown;
|
|
3746
|
+
try {
|
|
3747
|
+
parsed = JSON.parse(line);
|
|
3748
|
+
} catch {
|
|
3749
|
+
return undefined;
|
|
3750
|
+
}
|
|
3751
|
+
if (!isPlainObject(parsed)) return undefined;
|
|
3752
|
+
if (parsed.stage === "final") finalRow = parsed;
|
|
3753
|
+
if (parsed.planning_stuck === true || parsed.event === "planning_stuck") planningStuck = true;
|
|
3754
|
+
}
|
|
3755
|
+
if (planningStuck || isPlainObject(state.planning_stuck)) return undefined;
|
|
3756
|
+
const indexedAdmission = isPlainObject(finalRow?.auto_handoff) ? finalRow.auto_handoff : undefined;
|
|
3757
|
+
if (
|
|
3758
|
+
!finalRow ||
|
|
3759
|
+
!indexedAdmission ||
|
|
3760
|
+
indexedAdmission.configuredTarget !== admission.configuredTarget ||
|
|
3761
|
+
indexedAdmission.effectiveTarget !== admission.effectiveTarget ||
|
|
3762
|
+
indexedAdmission.degradationReason !== admission.degradationReason ||
|
|
3763
|
+
indexedAdmission.source !== admission.source ||
|
|
3764
|
+
typeof finalRow.path !== "string" ||
|
|
3765
|
+
typeof finalRow.sha256 !== "string" ||
|
|
3766
|
+
!/^[0-9a-f]{64}$/.test(finalRow.sha256)
|
|
3767
|
+
)
|
|
3768
|
+
return undefined;
|
|
3769
|
+
const artifactPath = path.resolve(finalRow.path);
|
|
3770
|
+
if (!artifactPath.startsWith(`${path.resolve(runDir)}${path.sep}`)) return undefined;
|
|
3771
|
+
if ((await hashIdentityFile(artifactPath, "ralplan final artifact")) !== finalRow.sha256) return undefined;
|
|
3772
|
+
if (
|
|
3773
|
+
phaseMarker &&
|
|
3774
|
+
(phaseMarker.run_id !== runId ||
|
|
3775
|
+
phaseMarker.final_path !== artifactPath ||
|
|
3776
|
+
phaseMarker.final_sha256 !== finalRow.sha256 ||
|
|
3777
|
+
phaseMarker.auto_handoff_sha256 !== createHash("sha256").update(JSON.stringify(admission)).digest("hex"))
|
|
3778
|
+
)
|
|
3779
|
+
return undefined;
|
|
3780
|
+
const stateRevision = existingStateRevision(state);
|
|
3781
|
+
if (typeof stateRevision !== "number" || !Number.isSafeInteger(stateRevision) || stateRevision < 0) return undefined;
|
|
3782
|
+
return {
|
|
3783
|
+
runId,
|
|
3784
|
+
statePath: path.resolve(ralplanPath),
|
|
3785
|
+
stateRevision,
|
|
3786
|
+
finalPath: artifactPath,
|
|
3787
|
+
finalSha256: finalRow.sha256,
|
|
3788
|
+
};
|
|
3789
|
+
}
|
|
3790
|
+
|
|
3791
|
+
async function hasSanctionedRalplanFinalAdmission(
|
|
3792
|
+
cwd: string,
|
|
3793
|
+
sessionId: string,
|
|
3794
|
+
state: Record<string, unknown>,
|
|
3795
|
+
): Promise<boolean> {
|
|
3796
|
+
const admission = isPlainObject(state.auto_handoff) ? state.auto_handoff : undefined;
|
|
3797
|
+
return (
|
|
3798
|
+
admission?.effectiveTarget === "ultragoal" &&
|
|
3799
|
+
admission.degradationReason === null &&
|
|
3800
|
+
(await verifiedRalplanFinalEvidence(cwd, sessionId, state)) !== undefined
|
|
3801
|
+
);
|
|
3802
|
+
}
|
|
3803
|
+
|
|
3804
|
+
/** Whether a verified automatic Ultragoal admission already authorizes handoff. */
|
|
3805
|
+
export async function hasSanctionedRalplanFinalAdmissionForHandoff(cwd: string, sessionId: string): Promise<boolean> {
|
|
3806
|
+
const read = await readExistingStateForMutation(modeStateFile(cwd, "ralplan", sessionId));
|
|
3807
|
+
return read.kind === "valid" && (await hasSanctionedRalplanFinalAdmission(cwd, sessionId, read.value));
|
|
3808
|
+
}
|
|
3809
|
+
|
|
3810
|
+
async function assertRalplanApprovalRecordCurrent(
|
|
3811
|
+
cwd: string,
|
|
3812
|
+
sessionId: string,
|
|
3813
|
+
record: DeepInterviewExecutionApprovalRecord,
|
|
3814
|
+
state?: Record<string, unknown>,
|
|
3815
|
+
): Promise<void> {
|
|
3816
|
+
if (record.approval_stage !== "ralplan") return;
|
|
3817
|
+
let ralplanState = state;
|
|
3818
|
+
if (!ralplanState) {
|
|
3819
|
+
const read = await readExistingStateForMutation(modeStateFile(cwd, "ralplan", sessionId));
|
|
3820
|
+
if (read.kind !== "valid")
|
|
3821
|
+
throw new StateCommandError(2, "Ralplan execution approval final state is unavailable");
|
|
3822
|
+
ralplanState = migrateWorkflowState(read.value, "ralplan").state;
|
|
3823
|
+
}
|
|
3824
|
+
const evidence = await verifiedRalplanFinalEvidence(cwd, sessionId, ralplanState);
|
|
3825
|
+
if (!evidence) throw new StateCommandError(2, "Ralplan execution approval final evidence is unavailable");
|
|
3826
|
+
if (
|
|
3827
|
+
record.ralplan_state_path !== evidence.statePath ||
|
|
3828
|
+
record.ralplan_run_id !== evidence.runId ||
|
|
3829
|
+
record.ralplan_final_path !== evidence.finalPath ||
|
|
3830
|
+
record.ralplan_final_sha256 !== evidence.finalSha256 ||
|
|
3831
|
+
(record.ralplan_state_revision !== evidence.stateRevision && ralplanState.current_phase !== "handoff")
|
|
3832
|
+
)
|
|
3833
|
+
throw new StateCommandError(2, "Ralplan execution approval does not match current final plan");
|
|
3834
|
+
}
|
|
3835
|
+
|
|
3836
|
+
async function assertDeepInterviewExecutionLineage(
|
|
3837
|
+
cwd: string,
|
|
3838
|
+
sessionId: string,
|
|
3839
|
+
caller: CanonicalGjcWorkflowSkill,
|
|
3840
|
+
existingCaller: Record<string, unknown>,
|
|
3841
|
+
): Promise<void> {
|
|
3842
|
+
let upstreamRaw = typeof existingCaller.handoff_from === "string" ? existingCaller.handoff_from.trim() : "";
|
|
3843
|
+
if (!upstreamRaw) {
|
|
3844
|
+
if (!(await hasCurrentDeepInterviewHandoff(cwd, sessionId, caller, existingCaller))) return;
|
|
3845
|
+
upstreamRaw = "deep-interview";
|
|
3846
|
+
} else if (
|
|
3847
|
+
upstreamRaw === "deep-interview" &&
|
|
3848
|
+
!(await hasCurrentDeepInterviewHandoff(cwd, sessionId, caller, existingCaller))
|
|
3849
|
+
) {
|
|
3850
|
+
throw new StateCommandError(2, "execution handoff cannot authenticate Deep Interview approval lineage");
|
|
3851
|
+
}
|
|
3852
|
+
|
|
3853
|
+
const seen = new Set<CanonicalGjcWorkflowSkill>();
|
|
3854
|
+
let currentSkill = caller;
|
|
3855
|
+
let currentState =
|
|
3856
|
+
typeof existingCaller.handoff_from === "string" && existingCaller.handoff_from.trim() !== ""
|
|
3857
|
+
? existingCaller
|
|
3858
|
+
: { ...existingCaller, handoff_from: upstreamRaw };
|
|
3859
|
+
while (true) {
|
|
3860
|
+
if (seen.has(currentSkill))
|
|
3861
|
+
throw new StateCommandError(2, "execution handoff cannot authenticate Deep Interview approval lineage");
|
|
3862
|
+
seen.add(currentSkill);
|
|
3863
|
+
let upstreamValue = typeof currentState.handoff_from === "string" ? currentState.handoff_from.trim() : undefined;
|
|
3864
|
+
if (!upstreamValue && (await hasCurrentDeepInterviewHandoff(cwd, sessionId, currentSkill, currentState)))
|
|
3865
|
+
upstreamValue = "deep-interview";
|
|
3866
|
+
if (!upstreamValue) return;
|
|
3867
|
+
const upstream = canonicalWorkflowSkill(upstreamValue);
|
|
3868
|
+
if (!upstream)
|
|
3869
|
+
throw new StateCommandError(2, "execution handoff cannot authenticate Deep Interview approval lineage");
|
|
3870
|
+
const upstreamPath = modeStateFile(cwd, upstream, sessionId);
|
|
3871
|
+
const upstreamRead = await readExistingStateForMutation(upstreamPath);
|
|
3872
|
+
if (upstreamRead.kind !== "valid")
|
|
3873
|
+
throw new StateCommandError(2, "execution handoff cannot authenticate Deep Interview approval lineage");
|
|
3874
|
+
assertNoFutureWorkflowEnvelope(upstreamRead.value, upstream, `execution handoff upstream ${upstream}`);
|
|
3875
|
+
const upstreamState = migrateWorkflowState(upstreamRead.value, upstream).state;
|
|
3876
|
+
if (upstreamState.version !== WORKFLOW_STATE_VERSION)
|
|
3877
|
+
throw new StateCommandError(2, "execution handoff requires current upstream workflow state version");
|
|
3878
|
+
if (upstream === "ralplan" && !(await verifiedRalplanFinalEvidence(cwd, sessionId, upstreamState)))
|
|
3879
|
+
throw new StateCommandError(2, "execution handoff cannot traverse a stuck or unverifiable Ralplan final");
|
|
3880
|
+
if (upstream === "deep-interview") {
|
|
3881
|
+
const currentLineageHandoffAt =
|
|
3882
|
+
typeof currentState.upstream_handoff_at === "string"
|
|
3883
|
+
? currentState.upstream_handoff_at
|
|
3884
|
+
: currentState.handoff_at;
|
|
3885
|
+
if (
|
|
3886
|
+
upstreamState.active !== false ||
|
|
3887
|
+
upstreamState.current_phase !== "handoff" ||
|
|
3888
|
+
upstreamState.handoff_to !== currentSkill ||
|
|
3889
|
+
typeof upstreamState.handoff_at !== "string" ||
|
|
3890
|
+
typeof currentLineageHandoffAt !== "string" ||
|
|
3891
|
+
upstreamState.handoff_at !== currentLineageHandoffAt
|
|
3892
|
+
)
|
|
3893
|
+
throw new StateCommandError(2, "execution handoff cannot authenticate Deep Interview approval lineage");
|
|
3894
|
+
const lineageHandoffAt = upstreamState.handoff_at as string;
|
|
3895
|
+
if (
|
|
3896
|
+
!(await hasAuditedDeepInterviewHandoff(cwd, sessionId, currentSkill, {
|
|
3897
|
+
handoffAt: lineageHandoffAt,
|
|
3898
|
+
}))
|
|
3899
|
+
)
|
|
3900
|
+
throw new StateCommandError(2, "execution handoff cannot authenticate Deep Interview approval lineage");
|
|
3901
|
+
const receipt = isPlainObject(upstreamState.receipt) ? upstreamState.receipt : undefined;
|
|
3902
|
+
const checksum = isPlainObject(receipt?.content_sha256) ? receipt.content_sha256 : undefined;
|
|
3903
|
+
if (
|
|
3904
|
+
checksum?.algorithm !== "sha256" ||
|
|
3905
|
+
typeof checksum.value !== "string" ||
|
|
3906
|
+
checksum.value.length !== 64 ||
|
|
3907
|
+
checksum.covered_path !== path.resolve(upstreamPath)
|
|
3908
|
+
)
|
|
3909
|
+
throw new StateCommandError(2, "execution handoff requires checksummed canonical state");
|
|
3910
|
+
const integrityWarning = await warnAndAuditOutOfBandIfNeeded(cwd, sessionId, upstreamPath, "deep-interview");
|
|
3911
|
+
if (integrityWarning)
|
|
3912
|
+
throw new StateCommandError(2, `${integrityWarning}; execution handoff refuses tampered mode-state`);
|
|
3913
|
+
const ralplanAdmission =
|
|
3914
|
+
currentSkill === "ralplan" && (await hasSanctionedRalplanFinalAdmission(cwd, sessionId, currentState));
|
|
3915
|
+
const ordinaryRalplanApproval =
|
|
3916
|
+
currentSkill === "ralplan" &&
|
|
3917
|
+
isPlainObject(upstreamState.state) &&
|
|
3918
|
+
upstreamState.state.crystal === undefined;
|
|
3919
|
+
if (ordinaryRalplanApproval && !ralplanAdmission)
|
|
3920
|
+
await assertNonCrystalApprovalCurrent(cwd, sessionId, "ralplan", "consumed");
|
|
3921
|
+
try {
|
|
3922
|
+
await assertDeepInterviewHandoffReady(upstreamState, {
|
|
3923
|
+
cwd,
|
|
3924
|
+
sessionId,
|
|
3925
|
+
statePath: upstreamPath,
|
|
3926
|
+
requireExecutionApproval: !ralplanAdmission && !ordinaryRalplanApproval,
|
|
3927
|
+
});
|
|
3928
|
+
} catch (error) {
|
|
3929
|
+
throw new StateCommandError(
|
|
3930
|
+
2,
|
|
3931
|
+
`execution handoff cannot authenticate Deep Interview approval lineage: ${error instanceof Error ? error.message : String(error)}`,
|
|
3932
|
+
);
|
|
3933
|
+
}
|
|
3934
|
+
const upstreamInner = isPlainObject(upstreamState.state) ? upstreamState.state : {};
|
|
3935
|
+
const upstreamApproval = isPlainObject(upstreamInner.execution_approval_receipt)
|
|
3936
|
+
? upstreamInner.execution_approval_receipt
|
|
3937
|
+
: undefined;
|
|
3938
|
+
if (upstreamApproval?.approval_stage === "ralplan") {
|
|
3939
|
+
const record = await readDeepInterviewExecutionApprovalRecord(
|
|
3940
|
+
deepInterviewExecutionApprovalRecordPath(cwd, sessionId),
|
|
3941
|
+
);
|
|
3942
|
+
if (record?.status !== "consumed")
|
|
3943
|
+
throw new StateCommandError(2, "execution handoff requires consumed Ralplan approval evidence");
|
|
3944
|
+
if (
|
|
3945
|
+
record.approval_stage !== "ralplan" ||
|
|
3946
|
+
upstreamApproval.approval_stage !== record.approval_stage ||
|
|
3947
|
+
upstreamApproval.ralplan_state_path !== record.ralplan_state_path ||
|
|
3948
|
+
upstreamApproval.ralplan_state_revision !== record.ralplan_state_revision ||
|
|
3949
|
+
upstreamApproval.ralplan_run_id !== record.ralplan_run_id ||
|
|
3950
|
+
upstreamApproval.ralplan_final_path !== record.ralplan_final_path ||
|
|
3951
|
+
upstreamApproval.ralplan_final_sha256 !== record.ralplan_final_sha256 ||
|
|
3952
|
+
upstreamApproval.question_id !== record.question_id ||
|
|
3953
|
+
upstreamApproval.gate_id !== record.gate_id ||
|
|
3954
|
+
upstreamApproval.answer_hash !== record.answer_hash ||
|
|
3955
|
+
upstreamApproval.transcript_path !== record.transcript_path ||
|
|
3956
|
+
upstreamApproval.transcript_sha256 !== record.transcript_sha256 ||
|
|
3957
|
+
JSON.stringify(upstreamApproval.transcript_boundary) !== JSON.stringify(record.transcript_boundary)
|
|
3958
|
+
)
|
|
3959
|
+
throw new StateCommandError(2, "execution handoff Ralplan approval receipt identity mismatch");
|
|
3960
|
+
await assertRalplanApprovalRecordCurrent(cwd, sessionId, record, currentState);
|
|
3961
|
+
}
|
|
3962
|
+
return;
|
|
3963
|
+
}
|
|
3964
|
+
currentSkill = upstream;
|
|
3965
|
+
currentState = upstreamState;
|
|
3966
|
+
}
|
|
3967
|
+
}
|
|
3968
|
+
|
|
3969
|
+
function handoffJournalPathsMatch(
|
|
3970
|
+
journal: Record<string, unknown>,
|
|
3971
|
+
callerPath: string,
|
|
3972
|
+
calleePath: string,
|
|
3973
|
+
activePath: string,
|
|
3974
|
+
): boolean {
|
|
3975
|
+
if (journal.caller !== path.basename(callerPath, "-state.json")) return false;
|
|
3976
|
+
if (journal.callee !== path.basename(calleePath, "-state.json")) return false;
|
|
3977
|
+
const paths = Array.isArray(journal.paths)
|
|
3978
|
+
? journal.paths.filter((value): value is string => typeof value === "string").map(value => path.resolve(value))
|
|
3979
|
+
: [];
|
|
3980
|
+
return (
|
|
3981
|
+
paths.length === 3 &&
|
|
3982
|
+
new Set(paths).size === 3 &&
|
|
3983
|
+
paths.includes(path.resolve(callerPath)) &&
|
|
3984
|
+
paths.includes(path.resolve(calleePath)) &&
|
|
3985
|
+
paths.includes(path.resolve(activePath))
|
|
3986
|
+
);
|
|
3987
|
+
}
|
|
3988
|
+
|
|
3989
|
+
async function findPendingHandoffRecovery(options: {
|
|
3990
|
+
cwd: string;
|
|
3991
|
+
sessionId: string;
|
|
3992
|
+
caller: CanonicalGjcWorkflowSkill;
|
|
3993
|
+
callee: CanonicalGjcWorkflowSkill;
|
|
3994
|
+
callerPath: string;
|
|
3995
|
+
calleePath: string;
|
|
3996
|
+
activePath: string;
|
|
3997
|
+
calleeState: Record<string, unknown>;
|
|
3998
|
+
}): Promise<{ journal: Record<string, unknown>; mutationId: string; handoffAt: string } | undefined> {
|
|
3999
|
+
const transactionsDir = path.join(stateDirFor(options.cwd, options.sessionId), "transactions");
|
|
4000
|
+
let names: string[];
|
|
4001
|
+
try {
|
|
4002
|
+
names = await fs.readdir(transactionsDir);
|
|
4003
|
+
} catch (error) {
|
|
4004
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
|
|
4005
|
+
throw error;
|
|
4006
|
+
}
|
|
4007
|
+
for (const name of names.filter(entry => entry.endsWith(".json")).sort()) {
|
|
4008
|
+
const journalPath = path.join(transactionsDir, name);
|
|
4009
|
+
let parsed: unknown;
|
|
4010
|
+
try {
|
|
4011
|
+
parsed = JSON.parse(await fs.readFile(journalPath, "utf-8"));
|
|
4012
|
+
} catch (error) {
|
|
4013
|
+
throw new StateCommandError(
|
|
4014
|
+
2,
|
|
4015
|
+
`handoff recovery journal is unreadable: ${error instanceof Error ? error.message : String(error)}`,
|
|
4016
|
+
);
|
|
4017
|
+
}
|
|
4018
|
+
if (!isPlainObject(parsed) || parsed.status !== "pending") continue;
|
|
4019
|
+
if (parsed.caller !== options.caller || parsed.callee !== options.callee) continue;
|
|
4020
|
+
if (!handoffJournalPathsMatch(parsed, options.callerPath, options.calleePath, options.activePath))
|
|
4021
|
+
throw new StateCommandError(2, "handoff recovery journal identity mismatch");
|
|
4022
|
+
const steps = Array.isArray(parsed.steps)
|
|
4023
|
+
? parsed.steps.filter((step): step is string => typeof step === "string")
|
|
4024
|
+
: [];
|
|
4025
|
+
if (!steps.includes("callee-mode-state")) continue;
|
|
4026
|
+
const mutationId = typeof parsed.mutation_id === "string" ? parsed.mutation_id : "";
|
|
4027
|
+
const handoffAt =
|
|
4028
|
+
typeof options.calleeState.handoff_at === "string"
|
|
4029
|
+
? options.calleeState.handoff_at
|
|
4030
|
+
: isPlainObject(options.calleeState.receipt) && typeof options.calleeState.receipt.mutated_at === "string"
|
|
4031
|
+
? options.calleeState.receipt.mutated_at
|
|
4032
|
+
: "";
|
|
4033
|
+
const calleeReceipt = persistedWorkflowReceipt(options.calleeState.receipt, options.callee);
|
|
4034
|
+
if (
|
|
4035
|
+
!handoffAt ||
|
|
4036
|
+
!mutationId ||
|
|
4037
|
+
mutationId !== `${options.caller}:handoff:${options.callee}:${handoffAt}` ||
|
|
4038
|
+
options.calleeState.handoff_from !== options.caller ||
|
|
4039
|
+
options.calleeState.handoff_at !== handoffAt ||
|
|
4040
|
+
!calleeReceipt ||
|
|
4041
|
+
calleeReceipt.mutation_id !== mutationId ||
|
|
4042
|
+
calleeReceipt.mutated_at !== handoffAt
|
|
4043
|
+
)
|
|
4044
|
+
throw new StateCommandError(2, "handoff recovery journal/callee receipt identity mismatch");
|
|
4045
|
+
return { journal: parsed, mutationId, handoffAt };
|
|
4046
|
+
}
|
|
4047
|
+
return undefined;
|
|
4048
|
+
}
|
|
4049
|
+
|
|
4050
|
+
async function syncHandoffRetryProjection(options: {
|
|
4051
|
+
cwd: string;
|
|
4052
|
+
sessionId: string;
|
|
4053
|
+
caller: CanonicalGjcWorkflowSkill;
|
|
4054
|
+
callee: CanonicalGjcWorkflowSkill;
|
|
4055
|
+
callerPath: string;
|
|
4056
|
+
calleePath: string;
|
|
4057
|
+
callerState: Record<string, unknown>;
|
|
4058
|
+
calleeState: Record<string, unknown>;
|
|
4059
|
+
threadId?: string;
|
|
4060
|
+
turnId?: string;
|
|
4061
|
+
handoffAt: string;
|
|
4062
|
+
}): Promise<void> {
|
|
4063
|
+
const callerReceipt = persistedWorkflowReceipt(options.callerState.receipt, options.caller);
|
|
4064
|
+
const calleeReceipt = persistedWorkflowReceipt(options.calleeState.receipt, options.callee);
|
|
4065
|
+
const calleePhase =
|
|
4066
|
+
typeof options.calleeState.current_phase === "string"
|
|
4067
|
+
? options.calleeState.current_phase
|
|
4068
|
+
: initialPhaseForSkill(options.callee);
|
|
4069
|
+
await applyHandoffToActiveState({
|
|
4070
|
+
cwd: options.cwd,
|
|
4071
|
+
nowIso: options.handoffAt,
|
|
4072
|
+
strict: true,
|
|
4073
|
+
caller: {
|
|
4074
|
+
cwd: options.cwd,
|
|
4075
|
+
skill: options.caller,
|
|
4076
|
+
active: false,
|
|
4077
|
+
phase: "handoff",
|
|
4078
|
+
sessionId: options.sessionId,
|
|
4079
|
+
threadId: options.threadId,
|
|
4080
|
+
turnId: options.turnId,
|
|
4081
|
+
source: "gjc-state-cli",
|
|
4082
|
+
hud: buildHudForMode(options.caller, options.callerState),
|
|
4083
|
+
handoff_to: options.callee,
|
|
4084
|
+
handoff_at: options.handoffAt,
|
|
4085
|
+
...(callerReceipt ? { receipt: callerReceipt } : {}),
|
|
4086
|
+
},
|
|
4087
|
+
callee: {
|
|
4088
|
+
cwd: options.cwd,
|
|
4089
|
+
skill: options.callee,
|
|
4090
|
+
active: options.calleeState.active !== false,
|
|
4091
|
+
phase: calleePhase,
|
|
4092
|
+
sessionId: options.sessionId,
|
|
4093
|
+
threadId: options.threadId,
|
|
4094
|
+
turnId: options.turnId,
|
|
4095
|
+
source: "gjc-state-cli",
|
|
4096
|
+
hud: buildHudForMode(options.callee, options.calleeState),
|
|
4097
|
+
...(typeof options.calleeState.handoff_from === "string"
|
|
4098
|
+
? { handoff_from: options.calleeState.handoff_from }
|
|
4099
|
+
: { handoff_from: options.caller }),
|
|
4100
|
+
...(typeof options.calleeState.handoff_at === "string"
|
|
4101
|
+
? { handoff_at: options.calleeState.handoff_at }
|
|
4102
|
+
: { handoff_at: options.handoffAt }),
|
|
4103
|
+
...(calleeReceipt ? { receipt: calleeReceipt } : {}),
|
|
4104
|
+
},
|
|
4105
|
+
});
|
|
4106
|
+
}
|
|
4107
|
+
|
|
4108
|
+
interface HandoffAuditOptions {
|
|
4109
|
+
cwd: string;
|
|
4110
|
+
sessionId: string;
|
|
4111
|
+
caller: CanonicalGjcWorkflowSkill;
|
|
4112
|
+
callee: CanonicalGjcWorkflowSkill;
|
|
4113
|
+
callerPath: string;
|
|
4114
|
+
calleePath: string;
|
|
4115
|
+
activePath: string;
|
|
4116
|
+
mutationId: string;
|
|
4117
|
+
handoffAt: string;
|
|
4118
|
+
fromPhase?: string;
|
|
4119
|
+
callerState: Record<string, unknown>;
|
|
4120
|
+
calleeState: Record<string, unknown>;
|
|
4121
|
+
forced: boolean;
|
|
4122
|
+
}
|
|
4123
|
+
|
|
4124
|
+
function buildHandoffAuditEntry(options: HandoffAuditOptions): AuditEntry & Record<string, unknown> {
|
|
4125
|
+
const callerReceipt = isPlainObject(options.callerState.receipt) ? options.callerState.receipt : undefined;
|
|
4126
|
+
const calleeReceipt = isPlainObject(options.calleeState.receipt) ? options.calleeState.receipt : undefined;
|
|
4127
|
+
const callerRevision = options.callerState.state_revision;
|
|
4128
|
+
const calleeRevision = options.calleeState.state_revision;
|
|
4129
|
+
if (
|
|
4130
|
+
!callerReceipt ||
|
|
4131
|
+
!calleeReceipt ||
|
|
4132
|
+
!Number.isSafeInteger(callerRevision) ||
|
|
4133
|
+
!Number.isSafeInteger(calleeRevision) ||
|
|
4134
|
+
callerReceipt.mutation_id !== options.mutationId ||
|
|
4135
|
+
calleeReceipt.mutation_id !== options.mutationId ||
|
|
4136
|
+
callerReceipt.mutated_at !== options.handoffAt ||
|
|
4137
|
+
calleeReceipt.mutated_at !== options.handoffAt
|
|
4138
|
+
)
|
|
4139
|
+
throw new StateCommandError(1, "handoff writer did not return matching caller/callee receipts");
|
|
4140
|
+
return {
|
|
4141
|
+
ts: options.handoffAt,
|
|
4142
|
+
skill: options.caller,
|
|
4143
|
+
category: "state",
|
|
4144
|
+
verb: "handoff",
|
|
4145
|
+
owner: "gjc-state-cli",
|
|
4146
|
+
mutation_id: options.mutationId,
|
|
4147
|
+
from_phase: options.fromPhase,
|
|
4148
|
+
to_phase: "handoff",
|
|
4149
|
+
forced: options.forced,
|
|
4150
|
+
paths: [options.callerPath, options.calleePath, options.activePath],
|
|
4151
|
+
handoff_at: options.handoffAt,
|
|
4152
|
+
caller_path: path.resolve(options.callerPath),
|
|
4153
|
+
callee_path: path.resolve(options.calleePath),
|
|
4154
|
+
active_state_path: path.resolve(options.activePath),
|
|
4155
|
+
caller_state_revision: callerRevision,
|
|
4156
|
+
callee_state_revision: calleeRevision,
|
|
4157
|
+
caller_receipt: callerReceipt,
|
|
4158
|
+
callee_receipt: calleeReceipt,
|
|
4159
|
+
} as AuditEntry & Record<string, unknown>;
|
|
4160
|
+
}
|
|
4161
|
+
|
|
4162
|
+
async function writeHandoffAuditIndex(
|
|
4163
|
+
options: HandoffAuditOptions,
|
|
4164
|
+
entry: AuditEntry & Record<string, unknown>,
|
|
4165
|
+
): Promise<void> {
|
|
4166
|
+
if (options.caller !== "deep-interview") return;
|
|
4167
|
+
await writeArtifact(
|
|
4168
|
+
path.join(sessionStateDir(options.cwd, options.sessionId), `deep-interview-handoff-${options.callee}-audit.json`),
|
|
4169
|
+
`${JSON.stringify(entry)}\n`,
|
|
4170
|
+
{
|
|
4171
|
+
cwd: options.cwd,
|
|
4172
|
+
audit: {
|
|
4173
|
+
category: "artifact",
|
|
4174
|
+
verb: "write",
|
|
4175
|
+
owner: "gjc-state-cli",
|
|
4176
|
+
skill: "deep-interview",
|
|
4177
|
+
sessionId: options.sessionId,
|
|
4178
|
+
mutationId: options.mutationId,
|
|
4179
|
+
},
|
|
4180
|
+
},
|
|
4181
|
+
);
|
|
4182
|
+
}
|
|
4183
|
+
|
|
4184
|
+
async function appendHandoffAudit(
|
|
4185
|
+
options: HandoffAuditOptions,
|
|
4186
|
+
hooks: { afterAudit?: () => Promise<unknown>; afterIndex?: () => Promise<unknown> } = {},
|
|
4187
|
+
): Promise<void> {
|
|
4188
|
+
const entry = buildHandoffAuditEntry(options);
|
|
4189
|
+
await writeHandoffAuditIndex(options, entry);
|
|
4190
|
+
await hooks.afterIndex?.();
|
|
4191
|
+
await appendAuditEntry(options.cwd, options.sessionId, entry);
|
|
4192
|
+
await hooks.afterAudit?.();
|
|
4193
|
+
}
|
|
4194
|
+
|
|
4195
|
+
/**
|
|
4196
|
+
* `handoff` exists in two distinct roles:
|
|
4197
|
+
* - As a verb: this CLI action, which atomically transitions caller→callee.
|
|
4198
|
+
* Writes the callee mode-state first, the caller mode-state second, then
|
|
4199
|
+
* syncs both `skill-active-state.json` files. Every intermediate crashed
|
|
4200
|
+
* state remains HUD-coherent: the active-state file either reflects the
|
|
4201
|
+
* old skill entirely or the new skill entirely, never both as active.
|
|
4202
|
+
* - As a phase: `current_phase: "handoff"` is set by this verb when demoting
|
|
4203
|
+
* the caller. Agents writing `current_phase: "handoff"` manually via
|
|
4204
|
+
* `gjc state <skill> write` are declaring "I am ready to be handed off";
|
|
4205
|
+
* the next agent-initiated `skill` tool call will then satisfy the phase
|
|
4206
|
+
* guard and may chain.
|
|
4207
|
+
*
|
|
4208
|
+
* `handoff` is in the terminal-phase set used by `isTerminalModeState` and by
|
|
4209
|
+
* the skill tool's chain guard. A manual `current_phase: "handoff"` write does
|
|
4210
|
+
* NOT mark `active: false` — only this verb does that — so a skill that wrote
|
|
4211
|
+
* the phase remains in `skill-active-state.json` until a chain call (or
|
|
4212
|
+
* explicit `clear`) demotes it.
|
|
4213
|
+
*/
|
|
4214
|
+
async function handleHandoffUnlocked(
|
|
4215
|
+
args: readonly string[],
|
|
4216
|
+
cwd: string,
|
|
4217
|
+
resolved: ResolvedHandoffSelectors,
|
|
4218
|
+
options: { callerLockHeld?: boolean; calleeLockHeld?: boolean } = {},
|
|
4219
|
+
): Promise<StateCommandResult> {
|
|
4220
|
+
const { selectors, caller, callee, calleeIsWorkflow, callerPath, calleePath } = resolved;
|
|
4221
|
+
const { gjcSessionId: sessionId, threadId, turnId } = selectors;
|
|
4222
|
+
const forced = hasFlag(args, "--force");
|
|
4223
|
+
const callerRead = await readExistingStateForMutation(callerPath);
|
|
4224
|
+
if (callerRead.kind === "corrupt" && !forced) {
|
|
4225
|
+
throw new StateCommandError(
|
|
4226
|
+
2,
|
|
4227
|
+
`existing state for ${caller} is corrupt or tampered (${callerRead.error}); use --force to overwrite`,
|
|
4228
|
+
);
|
|
4229
|
+
}
|
|
4230
|
+
if (callerRead.kind === "absent") {
|
|
4231
|
+
throw new StateCommandError(
|
|
4232
|
+
2,
|
|
4233
|
+
`gjc state ${caller} handoff: caller is not active (no mode-state file at ${callerPath})`,
|
|
4234
|
+
);
|
|
4235
|
+
}
|
|
4236
|
+
const existingCaller = callerRead.kind === "valid" ? callerRead.value : {};
|
|
4237
|
+
if (callerRead.kind === "valid") assertNoFutureWorkflowEnvelope(existingCaller, caller, `handoff from ${caller}`);
|
|
4238
|
+
if (
|
|
4239
|
+
!forced &&
|
|
4240
|
+
existingCaller.active !== true &&
|
|
4241
|
+
!(existingCaller.current_phase === "handoff" && existingCaller.handoff_to === callee)
|
|
4242
|
+
)
|
|
4243
|
+
throw new StateCommandError(2, `gjc state ${caller} handoff: caller is not active`);
|
|
4244
|
+
if (caller === "deep-interview" && callee === "ultragoal") {
|
|
4245
|
+
const exactRecovery = existingCaller.active === false && existingCaller.handoff_to === callee;
|
|
4246
|
+
if ((!exactRecovery && existingCaller.active !== true) || existingCaller.current_phase !== "handoff")
|
|
4247
|
+
throw new StateCommandError(2, "deep-interview execution handoff requires active handoff phase");
|
|
4248
|
+
if (existingCaller.version !== WORKFLOW_STATE_VERSION)
|
|
4249
|
+
throw new StateCommandError(
|
|
4250
|
+
2,
|
|
4251
|
+
"deep-interview execution handoff requires current deep-interview state version",
|
|
4252
|
+
);
|
|
4253
|
+
const receipt = isPlainObject(existingCaller.receipt) ? existingCaller.receipt : undefined;
|
|
4254
|
+
const checksum = isPlainObject(receipt?.content_sha256) ? receipt.content_sha256 : undefined;
|
|
4255
|
+
if (
|
|
4256
|
+
checksum?.algorithm !== "sha256" ||
|
|
4257
|
+
typeof checksum?.value !== "string" ||
|
|
4258
|
+
checksum.value.length !== 64 ||
|
|
4259
|
+
checksum.covered_path !== path.resolve(callerPath)
|
|
4260
|
+
)
|
|
4261
|
+
throw new StateCommandError(2, "deep-interview execution handoff requires checksummed canonical state");
|
|
4262
|
+
const integrityWarning = await warnAndAuditOutOfBandIfNeeded(cwd, sessionId, callerPath, "deep-interview");
|
|
4263
|
+
if (integrityWarning)
|
|
4264
|
+
throw new StateCommandError(2, `${integrityWarning}; execution handoff refuses tampered mode-state`);
|
|
4265
|
+
}
|
|
4266
|
+
if (caller === "ralplan" && (await hasDurableRalplanPlanningStuck(cwd, sessionId, existingCaller)))
|
|
4267
|
+
throw new StateCommandError(2, "planning-stuck Ralplan is terminal and cannot hand off");
|
|
4268
|
+
let ralplanExecutionFinal: VerifiedRalplanFinalEvidence | undefined;
|
|
4269
|
+
if (caller === "ralplan" && callee === "ultragoal") {
|
|
4270
|
+
ralplanExecutionFinal = await verifiedRalplanFinalEvidence(cwd, sessionId, existingCaller);
|
|
4271
|
+
if (!ralplanExecutionFinal)
|
|
4272
|
+
throw new StateCommandError(2, "Ralplan execution handoff requires non-stuck verified final plan evidence");
|
|
4273
|
+
if (
|
|
4274
|
+
(await executionApprovalLineage(cwd, sessionId, "ralplan")) === "ordinary" &&
|
|
4275
|
+
!(await hasSanctionedRalplanFinalAdmission(cwd, sessionId, existingCaller))
|
|
4276
|
+
)
|
|
4277
|
+
await assertNonCrystalApprovalCurrent(cwd, sessionId, "ralplan", "consumed");
|
|
4278
|
+
}
|
|
4279
|
+
if (callee === "ultragoal" && caller !== "deep-interview")
|
|
4280
|
+
await assertDeepInterviewExecutionLineage(cwd, sessionId, caller, existingCaller);
|
|
4281
|
+
|
|
4282
|
+
let handoffAt = nowIso();
|
|
4283
|
+
let mutationId = `${caller}:handoff:${callee}:${handoffAt}`;
|
|
4284
|
+
let callerReceipt = buildWorkflowStateReceipt({
|
|
4285
|
+
cwd,
|
|
4286
|
+
skill: caller,
|
|
4287
|
+
owner: "gjc-state-cli",
|
|
4288
|
+
command: `gjc state ${caller} handoff --to ${callee}`,
|
|
4289
|
+
sessionId,
|
|
4290
|
+
nowIso: handoffAt,
|
|
4291
|
+
mutationId,
|
|
4292
|
+
});
|
|
4293
|
+
callerReceipt.forced = forced;
|
|
1622
4294
|
const normalizedCaller =
|
|
1623
4295
|
caller === "deep-interview"
|
|
1624
4296
|
? (normalizeDeepInterviewEnvelope(migrateWorkflowState(existingCaller, caller).state) as Record<
|
|
@@ -1626,7 +4298,17 @@ async function handleHandoffUnlocked(args: readonly string[], cwd: string): Prom
|
|
|
1626
4298
|
unknown
|
|
1627
4299
|
>)
|
|
1628
4300
|
: migrateWorkflowState(existingCaller, caller).state;
|
|
1629
|
-
if (caller === "deep-interview"
|
|
4301
|
+
if (caller === "deep-interview" && callee === "ultragoal" && !isPlainObject(existingCaller.state))
|
|
4302
|
+
throw new StateCommandError(2, "deep-interview execution handoff requires normalized inner state");
|
|
4303
|
+
if (caller === "deep-interview" && !calleeIsWorkflow)
|
|
4304
|
+
throw new StateCommandError(2, "deep-interview handoff requires a canonical GJC workflow skill");
|
|
4305
|
+
if (caller === "deep-interview")
|
|
4306
|
+
await assertDeepInterviewHandoffReady(normalizedCaller, {
|
|
4307
|
+
cwd,
|
|
4308
|
+
sessionId,
|
|
4309
|
+
statePath: callerPath,
|
|
4310
|
+
requireExecutionApproval: callee === "ultragoal",
|
|
4311
|
+
});
|
|
1630
4312
|
|
|
1631
4313
|
// Runtime callees have no native mode-state to clear later, so do not
|
|
1632
4314
|
// persist them as active-state entries; the prompt observer tracks them
|
|
@@ -1643,7 +4325,6 @@ async function handleHandoffUnlocked(args: readonly string[], cwd: string): Prom
|
|
|
1643
4325
|
updated_at: handoffAt,
|
|
1644
4326
|
receipt: callerReceipt,
|
|
1645
4327
|
};
|
|
1646
|
-
const force = hasFlag(args, "--force");
|
|
1647
4328
|
await beginWorkflowTransactionJournal({
|
|
1648
4329
|
cwd,
|
|
1649
4330
|
sessionId,
|
|
@@ -1655,9 +4336,10 @@ async function handleHandoffUnlocked(args: readonly string[], cwd: string): Prom
|
|
|
1655
4336
|
sessionId,
|
|
1656
4337
|
skill: caller,
|
|
1657
4338
|
mutationId,
|
|
1658
|
-
force,
|
|
4339
|
+
force: forced,
|
|
1659
4340
|
fromPhase: typeof existingCaller.current_phase === "string" ? existingCaller.current_phase : undefined,
|
|
1660
4341
|
toPhase: "handoff",
|
|
4342
|
+
lockHeld: options.callerLockHeld,
|
|
1661
4343
|
});
|
|
1662
4344
|
await updateWorkflowTransactionJournal(cwd, sessionId, mutationId, { steps: ["caller-mode-state"] });
|
|
1663
4345
|
if (callerWrite.warning) emitStateWarning(callerWrite.warning);
|
|
@@ -1710,35 +4392,332 @@ async function handleHandoffUnlocked(args: readonly string[], cwd: string): Prom
|
|
|
1710
4392
|
if (!calleePath) {
|
|
1711
4393
|
throw new StateCommandError(2, `gjc state handoff failed to resolve workflow callee path for ${callee}`);
|
|
1712
4394
|
}
|
|
4395
|
+
const workflowCallee = canonicalWorkflowSkill(callee);
|
|
4396
|
+
if (!workflowCallee)
|
|
4397
|
+
throw new StateCommandError(2, `gjc state handoff failed to resolve workflow callee path for ${callee}`);
|
|
4398
|
+
if (!options.calleeLockHeld) {
|
|
4399
|
+
return withWorkflowStateLock(
|
|
4400
|
+
calleePath,
|
|
4401
|
+
() => handleHandoffUnlocked(args, cwd, resolved, { ...options, calleeLockHeld: true }),
|
|
4402
|
+
{ cwd },
|
|
4403
|
+
);
|
|
4404
|
+
}
|
|
1713
4405
|
const calleeRead = await readExistingStateForMutation(calleePath);
|
|
4406
|
+
if (calleeRead.kind === "valid")
|
|
4407
|
+
assertNoFutureWorkflowEnvelope(calleeRead.value, workflowCallee, `handoff to ${workflowCallee}`);
|
|
4408
|
+
const exactRecovery = existingCaller.active === false && existingCaller.handoff_to === callee;
|
|
4409
|
+
const callerReceiptForRetry = isPlainObject(existingCaller.receipt) ? existingCaller.receipt : undefined;
|
|
4410
|
+
const retryMutationId =
|
|
4411
|
+
callerReceiptForRetry && typeof callerReceiptForRetry.mutation_id === "string"
|
|
4412
|
+
? callerReceiptForRetry.mutation_id
|
|
4413
|
+
: undefined;
|
|
4414
|
+
let retryJournal: WorkflowTransactionJournal | undefined;
|
|
4415
|
+
if (exactRecovery && retryMutationId) {
|
|
4416
|
+
try {
|
|
4417
|
+
retryJournal = await readWorkflowTransactionJournal(cwd, sessionId, retryMutationId);
|
|
4418
|
+
} catch (error) {
|
|
4419
|
+
throw new StateCommandError(
|
|
4420
|
+
2,
|
|
4421
|
+
`handoff recovery journal is unreadable: ${error instanceof Error ? error.message : String(error)}`,
|
|
4422
|
+
);
|
|
4423
|
+
}
|
|
4424
|
+
}
|
|
1714
4425
|
if (calleeRead.kind === "corrupt" && !forced) {
|
|
1715
4426
|
throw new StateCommandError(
|
|
1716
4427
|
2,
|
|
1717
4428
|
`existing state for ${callee} is corrupt or tampered (${calleeRead.error}); use --force to overwrite`,
|
|
1718
4429
|
);
|
|
1719
4430
|
}
|
|
4431
|
+
if (callee === "ultragoal" && calleeRead.kind === "valid") {
|
|
4432
|
+
const calleeVersion = calleeRead.value.version;
|
|
4433
|
+
if (
|
|
4434
|
+
calleeVersion !== undefined &&
|
|
4435
|
+
(!Number.isSafeInteger(calleeVersion) ||
|
|
4436
|
+
(calleeVersion as number) < 1 ||
|
|
4437
|
+
(calleeVersion as number) > WORKFLOW_STATE_VERSION)
|
|
4438
|
+
)
|
|
4439
|
+
throw new StateCommandError(2, "execution handoff requires current ultragoal state version");
|
|
4440
|
+
}
|
|
4441
|
+
const activePath = activeStateFile(cwd, sessionId);
|
|
4442
|
+
let pendingCalleeRecovery: { journal: Record<string, unknown>; mutationId: string; handoffAt: string } | undefined;
|
|
4443
|
+
let pendingRecoveryForced = forced;
|
|
4444
|
+
if (!exactRecovery && existingCaller.active === true && calleeRead.kind === "valid") {
|
|
4445
|
+
pendingCalleeRecovery = await findPendingHandoffRecovery({
|
|
4446
|
+
cwd,
|
|
4447
|
+
sessionId,
|
|
4448
|
+
caller,
|
|
4449
|
+
callee: workflowCallee,
|
|
4450
|
+
callerPath,
|
|
4451
|
+
calleePath,
|
|
4452
|
+
activePath,
|
|
4453
|
+
calleeState: calleeRead.value,
|
|
4454
|
+
});
|
|
4455
|
+
if (pendingCalleeRecovery) {
|
|
4456
|
+
handoffAt = pendingCalleeRecovery.handoffAt;
|
|
4457
|
+
mutationId = pendingCalleeRecovery.mutationId;
|
|
4458
|
+
callerReceipt = buildWorkflowStateReceipt({
|
|
4459
|
+
cwd,
|
|
4460
|
+
skill: caller,
|
|
4461
|
+
owner: "gjc-state-cli",
|
|
4462
|
+
command: `gjc state ${caller} handoff --to ${callee}`,
|
|
4463
|
+
sessionId,
|
|
4464
|
+
nowIso: handoffAt,
|
|
4465
|
+
mutationId,
|
|
4466
|
+
});
|
|
4467
|
+
const recoveredCalleeReceipt = persistedWorkflowReceipt(calleeRead.value.receipt, workflowCallee);
|
|
4468
|
+
pendingRecoveryForced = recoveredCalleeReceipt?.forced === true;
|
|
4469
|
+
callerReceipt.forced = pendingRecoveryForced;
|
|
4470
|
+
}
|
|
4471
|
+
}
|
|
4472
|
+
if (exactRecovery) {
|
|
4473
|
+
if (calleeRead.kind !== "valid")
|
|
4474
|
+
throw new StateCommandError(2, "completed handoff retry cannot recover a missing callee state");
|
|
4475
|
+
await assertHandoffRecoveryEnvelopeIntegrity(cwd, sessionId, callerPath, caller, existingCaller, "caller");
|
|
4476
|
+
await assertHandoffRecoveryEnvelopeIntegrity(
|
|
4477
|
+
cwd,
|
|
4478
|
+
sessionId,
|
|
4479
|
+
calleePath,
|
|
4480
|
+
workflowCallee,
|
|
4481
|
+
calleeRead.value,
|
|
4482
|
+
"callee",
|
|
4483
|
+
);
|
|
4484
|
+
const retryAt = typeof existingCaller.handoff_at === "string" ? existingCaller.handoff_at.trim() : undefined;
|
|
4485
|
+
if (!retryAt || !retryMutationId || retryMutationId !== `${caller}:handoff:${callee}:${retryAt}`)
|
|
4486
|
+
throw new StateCommandError(2, "handoff retry lacks durable transition identity");
|
|
4487
|
+
if (
|
|
4488
|
+
!handoffReceiptMatches(callerReceiptForRetry, caller, callerPath, activePath, retryMutationId, retryAt) ||
|
|
4489
|
+
calleeRead.value.handoff_from !== caller ||
|
|
4490
|
+
calleeRead.value.handoff_at !== retryAt ||
|
|
4491
|
+
calleeRead.value.active === false
|
|
4492
|
+
)
|
|
4493
|
+
throw new StateCommandError(2, "handoff retry cannot authenticate the persisted caller/callee state");
|
|
4494
|
+
if (retryJournal) {
|
|
4495
|
+
if (retryJournal.status !== "pending" && retryJournal.status !== "committed")
|
|
4496
|
+
throw new StateCommandError(2, "handoff recovery journal has unsupported status");
|
|
4497
|
+
if (
|
|
4498
|
+
retryJournal.mutation_id !== retryMutationId ||
|
|
4499
|
+
!handoffJournalPathsMatch(
|
|
4500
|
+
retryJournal as unknown as Record<string, unknown>,
|
|
4501
|
+
callerPath,
|
|
4502
|
+
calleePath,
|
|
4503
|
+
activePath,
|
|
4504
|
+
)
|
|
4505
|
+
)
|
|
4506
|
+
throw new StateCommandError(2, "handoff recovery journal identity mismatch");
|
|
4507
|
+
}
|
|
4508
|
+
const retryCalleeState = calleeRead.value;
|
|
4509
|
+
await syncHandoffRetryProjection({
|
|
4510
|
+
cwd,
|
|
4511
|
+
sessionId,
|
|
4512
|
+
caller,
|
|
4513
|
+
callee: workflowCallee,
|
|
4514
|
+
callerPath,
|
|
4515
|
+
calleePath,
|
|
4516
|
+
callerState: existingCaller,
|
|
4517
|
+
calleeState: retryCalleeState,
|
|
4518
|
+
threadId,
|
|
4519
|
+
turnId,
|
|
4520
|
+
handoffAt: retryAt,
|
|
4521
|
+
});
|
|
4522
|
+
if (retryJournal?.status === "pending") {
|
|
4523
|
+
const steps = new Set((retryJournal.steps ?? []).filter((step): step is string => typeof step === "string"));
|
|
4524
|
+
steps.add("callee-mode-state");
|
|
4525
|
+
steps.add("caller-mode-state");
|
|
4526
|
+
steps.add("active-state");
|
|
4527
|
+
await updateWorkflowTransactionJournal(cwd, sessionId, retryMutationId, { steps: [...steps] });
|
|
4528
|
+
if (caller === "deep-interview") {
|
|
4529
|
+
const auditOptions: HandoffAuditOptions = {
|
|
4530
|
+
cwd,
|
|
4531
|
+
sessionId,
|
|
4532
|
+
caller,
|
|
4533
|
+
callee: workflowCallee,
|
|
4534
|
+
callerPath,
|
|
4535
|
+
calleePath,
|
|
4536
|
+
activePath,
|
|
4537
|
+
mutationId: retryMutationId,
|
|
4538
|
+
handoffAt: retryAt,
|
|
4539
|
+
fromPhase: undefined,
|
|
4540
|
+
callerState: existingCaller,
|
|
4541
|
+
calleeState: retryCalleeState,
|
|
4542
|
+
forced: callerReceiptForRetry?.forced === true,
|
|
4543
|
+
};
|
|
4544
|
+
const indexPresent = await hasAuditedDeepInterviewHandoff(cwd, sessionId, workflowCallee, {
|
|
4545
|
+
handoffAt: retryAt,
|
|
4546
|
+
indexOnly: true,
|
|
4547
|
+
});
|
|
4548
|
+
const persistSteps = () =>
|
|
4549
|
+
updateWorkflowTransactionJournal(cwd, sessionId, retryMutationId, { steps: [...steps] });
|
|
4550
|
+
if (!indexPresent) {
|
|
4551
|
+
const entry = buildHandoffAuditEntry(auditOptions);
|
|
4552
|
+
await writeHandoffAuditIndex(auditOptions, entry);
|
|
4553
|
+
steps.add("handoff-index");
|
|
4554
|
+
await persistSteps();
|
|
4555
|
+
if (!steps.has("handoff-audit")) {
|
|
4556
|
+
await appendAuditEntry(cwd, sessionId, entry);
|
|
4557
|
+
steps.add("handoff-audit");
|
|
4558
|
+
await persistSteps();
|
|
4559
|
+
}
|
|
4560
|
+
} else if (!steps.has("handoff-index")) {
|
|
4561
|
+
steps.add("handoff-index");
|
|
4562
|
+
await persistSteps();
|
|
4563
|
+
}
|
|
4564
|
+
}
|
|
4565
|
+
await completeWorkflowTransactionJournal(cwd, sessionId, retryMutationId);
|
|
4566
|
+
}
|
|
4567
|
+
await touchStateActivityMarker(cwd, sessionId, callerPath);
|
|
4568
|
+
return {
|
|
4569
|
+
status: 0,
|
|
4570
|
+
stdout: renderCliWriteReceipt({
|
|
4571
|
+
ok: true,
|
|
4572
|
+
from: caller,
|
|
4573
|
+
to: callee,
|
|
4574
|
+
handoff_at: retryAt,
|
|
4575
|
+
phases: {
|
|
4576
|
+
from: "handoff",
|
|
4577
|
+
to:
|
|
4578
|
+
typeof retryCalleeState.current_phase === "string"
|
|
4579
|
+
? retryCalleeState.current_phase
|
|
4580
|
+
: initialPhaseForSkill(workflowCallee),
|
|
4581
|
+
},
|
|
4582
|
+
paths: { from: callerPath, to: calleePath, active_state: activePath },
|
|
4583
|
+
}),
|
|
4584
|
+
};
|
|
4585
|
+
}
|
|
4586
|
+
if (pendingCalleeRecovery) {
|
|
4587
|
+
if (calleeRead.kind !== "valid")
|
|
4588
|
+
throw new StateCommandError(2, "handoff recovery cannot proceed without persisted callee state");
|
|
4589
|
+
await assertHandoffRecoveryEnvelopeIntegrity(cwd, sessionId, callerPath, caller, existingCaller, "caller");
|
|
4590
|
+
await assertHandoffRecoveryEnvelopeIntegrity(
|
|
4591
|
+
cwd,
|
|
4592
|
+
sessionId,
|
|
4593
|
+
calleePath,
|
|
4594
|
+
workflowCallee,
|
|
4595
|
+
calleeRead.value,
|
|
4596
|
+
"callee",
|
|
4597
|
+
);
|
|
4598
|
+
const recoveryCalleeState = calleeRead.value;
|
|
4599
|
+
const recoveryCallerState: Record<string, unknown> = {
|
|
4600
|
+
...normalizedCaller,
|
|
4601
|
+
skill: caller,
|
|
4602
|
+
version: WORKFLOW_STATE_VERSION,
|
|
4603
|
+
active: false,
|
|
4604
|
+
current_phase: "handoff",
|
|
4605
|
+
handoff_to: callee,
|
|
4606
|
+
...(typeof normalizedCaller.handoff_from === "string" && typeof normalizedCaller.handoff_at === "string"
|
|
4607
|
+
? { upstream_handoff_at: normalizedCaller.upstream_handoff_at ?? normalizedCaller.handoff_at }
|
|
4608
|
+
: {}),
|
|
4609
|
+
...(ralplanExecutionFinal
|
|
4610
|
+
? {
|
|
4611
|
+
final_admission_phase_transition: {
|
|
4612
|
+
run_id: ralplanExecutionFinal.runId,
|
|
4613
|
+
final_path: ralplanExecutionFinal.finalPath,
|
|
4614
|
+
final_sha256: ralplanExecutionFinal.finalSha256,
|
|
4615
|
+
auto_handoff_sha256: createHash("sha256")
|
|
4616
|
+
.update(JSON.stringify(normalizedCaller.auto_handoff ?? null))
|
|
4617
|
+
.digest("hex"),
|
|
4618
|
+
},
|
|
4619
|
+
}
|
|
4620
|
+
: {}),
|
|
4621
|
+
handoff_at: handoffAt,
|
|
4622
|
+
updated_at: handoffAt,
|
|
4623
|
+
receipt: callerReceipt,
|
|
4624
|
+
};
|
|
4625
|
+
const callerWrite = await writeJsonAtomic(cwd, callerPath, recoveryCallerState, "handoff", {
|
|
4626
|
+
sessionId,
|
|
4627
|
+
skill: caller,
|
|
4628
|
+
mutationId,
|
|
4629
|
+
force: pendingRecoveryForced,
|
|
4630
|
+
fromPhase: typeof existingCaller.current_phase === "string" ? existingCaller.current_phase : undefined,
|
|
4631
|
+
toPhase: "handoff",
|
|
4632
|
+
lockHeld: options.callerLockHeld,
|
|
4633
|
+
});
|
|
4634
|
+
await updateWorkflowTransactionJournal(cwd, sessionId, mutationId, {
|
|
4635
|
+
steps: ["callee-mode-state", "caller-mode-state"],
|
|
4636
|
+
});
|
|
4637
|
+
await syncHandoffRetryProjection({
|
|
4638
|
+
cwd,
|
|
4639
|
+
sessionId,
|
|
4640
|
+
caller,
|
|
4641
|
+
callee: workflowCallee,
|
|
4642
|
+
callerPath,
|
|
4643
|
+
calleePath,
|
|
4644
|
+
callerState: callerWrite.stamped,
|
|
4645
|
+
calleeState: recoveryCalleeState,
|
|
4646
|
+
threadId,
|
|
4647
|
+
turnId,
|
|
4648
|
+
handoffAt,
|
|
4649
|
+
});
|
|
4650
|
+
await updateWorkflowTransactionJournal(cwd, sessionId, mutationId, {
|
|
4651
|
+
steps: ["callee-mode-state", "caller-mode-state", "active-state"],
|
|
4652
|
+
});
|
|
4653
|
+
const recoveryAuditOptions: HandoffAuditOptions = {
|
|
4654
|
+
cwd,
|
|
4655
|
+
sessionId,
|
|
4656
|
+
caller,
|
|
4657
|
+
callee: workflowCallee,
|
|
4658
|
+
callerPath,
|
|
4659
|
+
calleePath,
|
|
4660
|
+
activePath,
|
|
4661
|
+
mutationId,
|
|
4662
|
+
handoffAt,
|
|
4663
|
+
fromPhase: typeof existingCaller.current_phase === "string" ? existingCaller.current_phase : undefined,
|
|
4664
|
+
callerState: callerWrite.stamped,
|
|
4665
|
+
calleeState: recoveryCalleeState,
|
|
4666
|
+
forced: pendingRecoveryForced,
|
|
4667
|
+
};
|
|
4668
|
+
await appendHandoffAudit(recoveryAuditOptions, {
|
|
4669
|
+
afterIndex: () =>
|
|
4670
|
+
updateWorkflowTransactionJournal(cwd, sessionId, mutationId, {
|
|
4671
|
+
steps: ["callee-mode-state", "caller-mode-state", "active-state", "handoff-index"],
|
|
4672
|
+
}),
|
|
4673
|
+
afterAudit: () =>
|
|
4674
|
+
updateWorkflowTransactionJournal(cwd, sessionId, mutationId, {
|
|
4675
|
+
steps: ["callee-mode-state", "caller-mode-state", "active-state", "handoff-index", "handoff-audit"],
|
|
4676
|
+
}),
|
|
4677
|
+
});
|
|
4678
|
+
await completeWorkflowTransactionJournal(cwd, sessionId, mutationId);
|
|
4679
|
+
await touchStateActivityMarker(cwd, sessionId, callerPath);
|
|
4680
|
+
return {
|
|
4681
|
+
status: 0,
|
|
4682
|
+
stdout: renderCliWriteReceipt({
|
|
4683
|
+
ok: true,
|
|
4684
|
+
from: caller,
|
|
4685
|
+
to: callee,
|
|
4686
|
+
handoff_at: handoffAt,
|
|
4687
|
+
phases: {
|
|
4688
|
+
from: "handoff",
|
|
4689
|
+
to:
|
|
4690
|
+
typeof recoveryCalleeState.current_phase === "string"
|
|
4691
|
+
? recoveryCalleeState.current_phase
|
|
4692
|
+
: initialPhaseForSkill(workflowCallee),
|
|
4693
|
+
},
|
|
4694
|
+
paths: { from: callerPath, to: calleePath, active_state: activePath },
|
|
4695
|
+
}),
|
|
4696
|
+
};
|
|
4697
|
+
}
|
|
1720
4698
|
const existingCallee = calleeRead.kind === "valid" ? calleeRead.value : {};
|
|
1721
4699
|
const calleeReceipt = buildWorkflowStateReceipt({
|
|
1722
4700
|
cwd,
|
|
1723
|
-
skill:
|
|
4701
|
+
skill: workflowCallee,
|
|
1724
4702
|
owner: "gjc-state-cli",
|
|
1725
4703
|
command: `gjc state ${caller} handoff --to ${callee}`,
|
|
1726
4704
|
sessionId,
|
|
1727
4705
|
nowIso: handoffAt,
|
|
1728
4706
|
mutationId,
|
|
1729
4707
|
});
|
|
4708
|
+
calleeReceipt.forced = forced;
|
|
1730
4709
|
|
|
1731
|
-
const calleeInitial = initialPhaseForSkill(
|
|
4710
|
+
const calleeInitial = initialPhaseForSkill(workflowCallee);
|
|
1732
4711
|
const normalizedCallee =
|
|
1733
|
-
|
|
1734
|
-
? (normalizeDeepInterviewEnvelope(migrateWorkflowState(existingCallee,
|
|
4712
|
+
workflowCallee === "deep-interview"
|
|
4713
|
+
? (normalizeDeepInterviewEnvelope(migrateWorkflowState(existingCallee, workflowCallee).state) as Record<
|
|
1735
4714
|
string,
|
|
1736
4715
|
unknown
|
|
1737
4716
|
>)
|
|
1738
|
-
: migrateWorkflowState(existingCallee,
|
|
4717
|
+
: migrateWorkflowState(existingCallee, workflowCallee).state;
|
|
1739
4718
|
const mergedCalleeState: Record<string, unknown> = {
|
|
1740
4719
|
...normalizedCallee,
|
|
1741
|
-
skill:
|
|
4720
|
+
skill: workflowCallee,
|
|
1742
4721
|
version: WORKFLOW_STATE_VERSION,
|
|
1743
4722
|
active: true,
|
|
1744
4723
|
current_phase: calleeInitial,
|
|
@@ -1757,6 +4736,21 @@ async function handleHandoffUnlocked(args: readonly string[], cwd: string): Prom
|
|
|
1757
4736
|
active: false,
|
|
1758
4737
|
current_phase: "handoff",
|
|
1759
4738
|
handoff_to: callee,
|
|
4739
|
+
...(typeof normalizedCaller.handoff_from === "string" && typeof normalizedCaller.handoff_at === "string"
|
|
4740
|
+
? { upstream_handoff_at: normalizedCaller.upstream_handoff_at ?? normalizedCaller.handoff_at }
|
|
4741
|
+
: {}),
|
|
4742
|
+
...(ralplanExecutionFinal
|
|
4743
|
+
? {
|
|
4744
|
+
final_admission_phase_transition: {
|
|
4745
|
+
run_id: ralplanExecutionFinal.runId,
|
|
4746
|
+
final_path: ralplanExecutionFinal.finalPath,
|
|
4747
|
+
final_sha256: ralplanExecutionFinal.finalSha256,
|
|
4748
|
+
auto_handoff_sha256: createHash("sha256")
|
|
4749
|
+
.update(JSON.stringify(normalizedCaller.auto_handoff ?? null))
|
|
4750
|
+
.digest("hex"),
|
|
4751
|
+
},
|
|
4752
|
+
}
|
|
4753
|
+
: {}),
|
|
1760
4754
|
handoff_at: handoffAt,
|
|
1761
4755
|
updated_at: handoffAt,
|
|
1762
4756
|
receipt: callerReceipt,
|
|
@@ -1767,7 +4761,7 @@ async function handleHandoffUnlocked(args: readonly string[], cwd: string): Prom
|
|
|
1767
4761
|
sessionId,
|
|
1768
4762
|
mutationId,
|
|
1769
4763
|
caller,
|
|
1770
|
-
callee,
|
|
4764
|
+
callee: workflowCallee,
|
|
1771
4765
|
paths: [calleePath, callerPath, activeStateFile(cwd, sessionId)],
|
|
1772
4766
|
});
|
|
1773
4767
|
|
|
@@ -1778,23 +4772,27 @@ async function handleHandoffUnlocked(args: readonly string[], cwd: string): Prom
|
|
|
1778
4772
|
// and write order keeps the session-scoped source of truth ahead of the
|
|
1779
4773
|
// root aggregate. strict:true on the active-state read tolerates ENOENT
|
|
1780
4774
|
// only; corrupt JSON / IO failures propagate as non-zero CLI status.
|
|
1781
|
-
const force = hasFlag(args, "--force");
|
|
1782
4775
|
const calleeWrite = await writeJsonAtomic(cwd, calleePath, mergedCalleeState, "handoff", {
|
|
1783
4776
|
sessionId,
|
|
1784
|
-
skill:
|
|
4777
|
+
skill: workflowCallee,
|
|
1785
4778
|
mutationId,
|
|
1786
|
-
force,
|
|
4779
|
+
force: forced,
|
|
1787
4780
|
fromPhase: typeof existingCallee.current_phase === "string" ? existingCallee.current_phase : undefined,
|
|
1788
4781
|
toPhase: calleeInitial,
|
|
4782
|
+
lockHeld: options.calleeLockHeld,
|
|
1789
4783
|
});
|
|
1790
4784
|
await updateWorkflowTransactionJournal(cwd, sessionId, mutationId, { steps: ["callee-mode-state"] });
|
|
4785
|
+
if (process.env.GJC_STATE_HANDOFF_FAIL_AFTER_CALLEE === mutationId) {
|
|
4786
|
+
throw new StateCommandError(1, `injected handoff failure after callee write for ${mutationId}`);
|
|
4787
|
+
}
|
|
1791
4788
|
const callerWrite = await writeJsonAtomic(cwd, callerPath, mergedCallerState, "handoff", {
|
|
1792
4789
|
sessionId,
|
|
1793
4790
|
skill: caller,
|
|
1794
4791
|
mutationId,
|
|
1795
|
-
force,
|
|
4792
|
+
force: forced,
|
|
1796
4793
|
fromPhase: typeof existingCaller.current_phase === "string" ? existingCaller.current_phase : undefined,
|
|
1797
4794
|
toPhase: "handoff",
|
|
4795
|
+
lockHeld: options.callerLockHeld,
|
|
1798
4796
|
});
|
|
1799
4797
|
await updateWorkflowTransactionJournal(cwd, sessionId, mutationId, {
|
|
1800
4798
|
steps: ["callee-mode-state", "caller-mode-state"],
|
|
@@ -1828,14 +4826,14 @@ async function handleHandoffUnlocked(args: readonly string[], cwd: string): Prom
|
|
|
1828
4826
|
},
|
|
1829
4827
|
callee: {
|
|
1830
4828
|
cwd,
|
|
1831
|
-
skill:
|
|
4829
|
+
skill: workflowCallee,
|
|
1832
4830
|
active: true,
|
|
1833
4831
|
phase: calleeInitial,
|
|
1834
4832
|
sessionId,
|
|
1835
4833
|
threadId,
|
|
1836
4834
|
turnId,
|
|
1837
4835
|
source: "gjc-state-cli",
|
|
1838
|
-
hud: buildHudForMode(
|
|
4836
|
+
hud: buildHudForMode(workflowCallee, mergedCalleeState),
|
|
1839
4837
|
handoff_from: caller,
|
|
1840
4838
|
handoff_at: handoffAt,
|
|
1841
4839
|
receipt: calleeReceipt,
|
|
@@ -1844,6 +4842,31 @@ async function handleHandoffUnlocked(args: readonly string[], cwd: string): Prom
|
|
|
1844
4842
|
await updateWorkflowTransactionJournal(cwd, sessionId, mutationId, {
|
|
1845
4843
|
steps: ["callee-mode-state", "caller-mode-state", "active-state"],
|
|
1846
4844
|
});
|
|
4845
|
+
const handoffAuditOptions: HandoffAuditOptions = {
|
|
4846
|
+
cwd,
|
|
4847
|
+
sessionId,
|
|
4848
|
+
caller,
|
|
4849
|
+
callee: workflowCallee,
|
|
4850
|
+
callerPath,
|
|
4851
|
+
calleePath,
|
|
4852
|
+
activePath,
|
|
4853
|
+
mutationId,
|
|
4854
|
+
handoffAt,
|
|
4855
|
+
fromPhase: typeof existingCaller.current_phase === "string" ? existingCaller.current_phase : undefined,
|
|
4856
|
+
callerState: callerWrite.stamped,
|
|
4857
|
+
calleeState: calleeWrite.stamped,
|
|
4858
|
+
forced,
|
|
4859
|
+
};
|
|
4860
|
+
await appendHandoffAudit(handoffAuditOptions, {
|
|
4861
|
+
afterIndex: () =>
|
|
4862
|
+
updateWorkflowTransactionJournal(cwd, sessionId, mutationId, {
|
|
4863
|
+
steps: ["callee-mode-state", "caller-mode-state", "active-state", "handoff-index"],
|
|
4864
|
+
}),
|
|
4865
|
+
afterAudit: () =>
|
|
4866
|
+
updateWorkflowTransactionJournal(cwd, sessionId, mutationId, {
|
|
4867
|
+
steps: ["callee-mode-state", "caller-mode-state", "active-state", "handoff-index", "handoff-audit"],
|
|
4868
|
+
}),
|
|
4869
|
+
});
|
|
1847
4870
|
await completeWorkflowTransactionJournal(cwd, sessionId, mutationId);
|
|
1848
4871
|
await touchStateActivityMarker(cwd, sessionId, callerPath);
|
|
1849
4872
|
|
|
@@ -1881,7 +4904,8 @@ async function handleHandoffUnlocked(args: readonly string[], cwd: string): Prom
|
|
|
1881
4904
|
}
|
|
1882
4905
|
|
|
1883
4906
|
async function handleHandoff(args: readonly string[], cwd: string): Promise<StateCommandResult> {
|
|
1884
|
-
const
|
|
4907
|
+
const resolved = await resolveHandoffSelectors(args, cwd);
|
|
4908
|
+
const { selectors, callerPath } = resolved;
|
|
1885
4909
|
// Serialize concurrent handoffs on a dedicated sentinel lock, NOT on the
|
|
1886
4910
|
// derived `skill-active-state.json` cache. The inner transaction
|
|
1887
4911
|
// (applyHandoffToActiveState / syncSkillActiveState -> rebuildActiveSnapshot)
|
|
@@ -1891,7 +4915,560 @@ async function handleHandoff(args: readonly string[], cwd: string): Promise<Stat
|
|
|
1891
4915
|
// `{ cwd }` so the sentinel resolves against the handoff cwd rather than
|
|
1892
4916
|
// `process.cwd()`.
|
|
1893
4917
|
const handoffLock = path.join(sessionStateDir(cwd, selectors.gjcSessionId), "handoff");
|
|
1894
|
-
return withWorkflowStateLock(
|
|
4918
|
+
return withWorkflowStateLock(
|
|
4919
|
+
handoffLock,
|
|
4920
|
+
() =>
|
|
4921
|
+
withWorkflowStateLock(callerPath, () => handleHandoffUnlocked(args, cwd, resolved, { callerLockHeld: true }), {
|
|
4922
|
+
cwd,
|
|
4923
|
+
}),
|
|
4924
|
+
{ cwd },
|
|
4925
|
+
);
|
|
4926
|
+
}
|
|
4927
|
+
|
|
4928
|
+
interface ExecutionApprovalAuditOptions {
|
|
4929
|
+
cwd: string;
|
|
4930
|
+
sessionId: string;
|
|
4931
|
+
statePath: string;
|
|
4932
|
+
approvalRecordPath?: string;
|
|
4933
|
+
questionId?: string;
|
|
4934
|
+
gateId?: string;
|
|
4935
|
+
target?: "ultragoal";
|
|
4936
|
+
answerHash?: string;
|
|
4937
|
+
approvedAt: string;
|
|
4938
|
+
mutationId: string;
|
|
4939
|
+
revision: number;
|
|
4940
|
+
receipt: WorkflowStateReceipt;
|
|
4941
|
+
}
|
|
4942
|
+
|
|
4943
|
+
function buildExecutionApprovalAuditEntry(
|
|
4944
|
+
options: ExecutionApprovalAuditOptions,
|
|
4945
|
+
): AuditEntry & Record<string, unknown> {
|
|
4946
|
+
return {
|
|
4947
|
+
ts: options.approvedAt,
|
|
4948
|
+
skill: "deep-interview",
|
|
4949
|
+
category: "state",
|
|
4950
|
+
verb: "approve-execution",
|
|
4951
|
+
owner: "gjc-state-cli",
|
|
4952
|
+
mutation_id: options.mutationId,
|
|
4953
|
+
from_phase: "handoff",
|
|
4954
|
+
to_phase: "handoff",
|
|
4955
|
+
forced: false,
|
|
4956
|
+
paths: [options.statePath],
|
|
4957
|
+
...(options.approvalRecordPath ? { approval_record_path: options.approvalRecordPath } : {}),
|
|
4958
|
+
...(options.questionId ? { question_id: options.questionId } : {}),
|
|
4959
|
+
...(options.gateId ? { gate_id: options.gateId } : {}),
|
|
4960
|
+
...(options.target ? { target: options.target } : {}),
|
|
4961
|
+
...(options.answerHash ? { answer_hash: options.answerHash } : {}),
|
|
4962
|
+
approved_at: options.approvedAt,
|
|
4963
|
+
state_path: options.statePath,
|
|
4964
|
+
state_revision: options.revision,
|
|
4965
|
+
receipt_state_revision: options.revision,
|
|
4966
|
+
receipt: options.receipt,
|
|
4967
|
+
} as AuditEntry & Record<string, unknown>;
|
|
4968
|
+
}
|
|
4969
|
+
|
|
4970
|
+
function isSpecializedApprovalAuditLine(line: string, mutationId: string): boolean {
|
|
4971
|
+
if (!line.includes(mutationId) || !line.includes('"approve-execution"')) return false;
|
|
4972
|
+
try {
|
|
4973
|
+
const parsed: unknown = JSON.parse(line);
|
|
4974
|
+
return (
|
|
4975
|
+
isPlainObject(parsed) &&
|
|
4976
|
+
parsed.skill === "deep-interview" &&
|
|
4977
|
+
parsed.category === "state" &&
|
|
4978
|
+
parsed.verb === "approve-execution" &&
|
|
4979
|
+
parsed.mutation_id === mutationId &&
|
|
4980
|
+
typeof parsed.approved_at === "string" &&
|
|
4981
|
+
Number.isSafeInteger(parsed.receipt_state_revision)
|
|
4982
|
+
);
|
|
4983
|
+
} catch {
|
|
4984
|
+
return false;
|
|
4985
|
+
}
|
|
4986
|
+
}
|
|
4987
|
+
|
|
4988
|
+
async function approvalAuditContainsMutation(filePath: string, mutationId: string): Promise<boolean> {
|
|
4989
|
+
const raw = await readBoundedIdentityText(filePath, 8 * 1024 * 1024, "execution approval recovery audit", {
|
|
4990
|
+
tail: true,
|
|
4991
|
+
});
|
|
4992
|
+
if (!raw) return false;
|
|
4993
|
+
return raw.split(/\r?\n/).some(line => isSpecializedApprovalAuditLine(line, mutationId));
|
|
4994
|
+
}
|
|
4995
|
+
|
|
4996
|
+
async function approvalAuditContainsMutationAtOffset(
|
|
4997
|
+
filePath: string,
|
|
4998
|
+
mutationId: string,
|
|
4999
|
+
offset: number,
|
|
5000
|
+
): Promise<boolean> {
|
|
5001
|
+
const raw = await readBoundedIdentityText(filePath, 128 * 1024, "execution approval recovery audit offset", {
|
|
5002
|
+
offset,
|
|
5003
|
+
});
|
|
5004
|
+
if (raw === undefined) return false;
|
|
5005
|
+
return isSpecializedApprovalAuditLine(raw.split(/\r?\n/, 1)[0] ?? "", mutationId);
|
|
5006
|
+
}
|
|
5007
|
+
|
|
5008
|
+
async function appendExecutionApprovalAuditIdempotent(
|
|
5009
|
+
options: ExecutionApprovalAuditOptions,
|
|
5010
|
+
entry: AuditEntry & Record<string, unknown>,
|
|
5011
|
+
knownOffset?: number,
|
|
5012
|
+
beforeAppend?: (offset: number) => Promise<unknown>,
|
|
5013
|
+
): Promise<number | undefined> {
|
|
5014
|
+
const filePath = auditPath(options.cwd, options.sessionId);
|
|
5015
|
+
return withWorkflowStateLock(
|
|
5016
|
+
filePath,
|
|
5017
|
+
async () => {
|
|
5018
|
+
if (
|
|
5019
|
+
knownOffset !== undefined &&
|
|
5020
|
+
Number.isSafeInteger(knownOffset) &&
|
|
5021
|
+
knownOffset >= 0 &&
|
|
5022
|
+
(await approvalAuditContainsMutationAtOffset(filePath, options.mutationId, knownOffset))
|
|
5023
|
+
)
|
|
5024
|
+
return knownOffset;
|
|
5025
|
+
if (knownOffset === undefined && (await approvalAuditContainsMutation(filePath, options.mutationId))) return;
|
|
5026
|
+
let appendedOffset: number | undefined;
|
|
5027
|
+
await appendAuditEntry(options.cwd, options.sessionId, entry, {
|
|
5028
|
+
lockHeld: true,
|
|
5029
|
+
beforeAppend: async offset => {
|
|
5030
|
+
appendedOffset = offset;
|
|
5031
|
+
await beforeAppend?.(offset);
|
|
5032
|
+
},
|
|
5033
|
+
});
|
|
5034
|
+
return appendedOffset;
|
|
5035
|
+
},
|
|
5036
|
+
{ cwd: options.cwd },
|
|
5037
|
+
);
|
|
5038
|
+
}
|
|
5039
|
+
|
|
5040
|
+
async function writeExecutionApprovalIndex(
|
|
5041
|
+
options: ExecutionApprovalAuditOptions,
|
|
5042
|
+
entry: AuditEntry & Record<string, unknown>,
|
|
5043
|
+
): Promise<void> {
|
|
5044
|
+
await writeArtifact(
|
|
5045
|
+
path.join(sessionStateDir(options.cwd, options.sessionId), "deep-interview-approval-audit.json"),
|
|
5046
|
+
`${JSON.stringify(entry)}\n`,
|
|
5047
|
+
{
|
|
5048
|
+
cwd: options.cwd,
|
|
5049
|
+
audit: {
|
|
5050
|
+
category: "artifact",
|
|
5051
|
+
verb: "write",
|
|
5052
|
+
owner: "gjc-state-cli",
|
|
5053
|
+
skill: "deep-interview",
|
|
5054
|
+
sessionId: options.sessionId,
|
|
5055
|
+
mutationId: options.mutationId,
|
|
5056
|
+
},
|
|
5057
|
+
},
|
|
5058
|
+
);
|
|
5059
|
+
}
|
|
5060
|
+
|
|
5061
|
+
async function appendExecutionApprovalAudit(
|
|
5062
|
+
options: ExecutionApprovalAuditOptions,
|
|
5063
|
+
hooks: {
|
|
5064
|
+
afterIndex?: () => Promise<unknown>;
|
|
5065
|
+
beforeAudit?: (offset: number) => Promise<unknown>;
|
|
5066
|
+
afterAudit?: () => Promise<unknown>;
|
|
5067
|
+
} = {},
|
|
5068
|
+
): Promise<void> {
|
|
5069
|
+
const entry = buildExecutionApprovalAuditEntry(options);
|
|
5070
|
+
await writeExecutionApprovalIndex(options, entry);
|
|
5071
|
+
await hooks.afterIndex?.();
|
|
5072
|
+
const filePath = auditPath(options.cwd, options.sessionId);
|
|
5073
|
+
await withWorkflowStateLock(
|
|
5074
|
+
filePath,
|
|
5075
|
+
async () => {
|
|
5076
|
+
if (process.env.GJC_STATE_APPROVAL_FAIL_BEFORE_AUDIT === "1")
|
|
5077
|
+
await appendAuditEntry(options.cwd, options.sessionId, entry, {
|
|
5078
|
+
lockHeld: true,
|
|
5079
|
+
beforeAppend: async offset => {
|
|
5080
|
+
await hooks.beforeAudit?.(offset);
|
|
5081
|
+
throw new StateCommandError(1, "injected approval audit failure");
|
|
5082
|
+
},
|
|
5083
|
+
});
|
|
5084
|
+
else
|
|
5085
|
+
await appendAuditEntry(options.cwd, options.sessionId, entry, {
|
|
5086
|
+
lockHeld: true,
|
|
5087
|
+
beforeAppend: hooks.beforeAudit,
|
|
5088
|
+
});
|
|
5089
|
+
},
|
|
5090
|
+
{ cwd: options.cwd },
|
|
5091
|
+
);
|
|
5092
|
+
await hooks.afterAudit?.();
|
|
5093
|
+
}
|
|
5094
|
+
|
|
5095
|
+
async function handleApproveExecutionUnlocked(cwd: string, selectors: ResolvedSelectors): Promise<StateCommandResult> {
|
|
5096
|
+
if (
|
|
5097
|
+
(selectors.mode === "deep-interview" || selectors.mode === "ralplan") &&
|
|
5098
|
+
(await executionApprovalLineage(cwd, selectors.gjcSessionId, selectors.mode)) === "ordinary"
|
|
5099
|
+
)
|
|
5100
|
+
return consumeNonCrystalApprovalUnlocked(cwd, selectors.gjcSessionId, selectors.mode);
|
|
5101
|
+
if (selectors.mode !== "deep-interview")
|
|
5102
|
+
throw new StateCommandError(2, "approve-execution requires --mode deep-interview");
|
|
5103
|
+
const approvalRecordPath = deepInterviewExecutionApprovalRecordPath(cwd, selectors.gjcSessionId);
|
|
5104
|
+
return withWorkflowStateLock(
|
|
5105
|
+
approvalRecordPath,
|
|
5106
|
+
() => handleApproveExecutionRecordLocked(cwd, selectors, approvalRecordPath),
|
|
5107
|
+
{ cwd },
|
|
5108
|
+
);
|
|
5109
|
+
}
|
|
5110
|
+
|
|
5111
|
+
async function handleApproveExecutionRecordLocked(
|
|
5112
|
+
cwd: string,
|
|
5113
|
+
selectors: ResolvedSelectors,
|
|
5114
|
+
approvalRecordPath: string,
|
|
5115
|
+
): Promise<StateCommandResult> {
|
|
5116
|
+
if (selectors.mode !== "deep-interview")
|
|
5117
|
+
throw new StateCommandError(2, "approve-execution requires --mode deep-interview");
|
|
5118
|
+
const statePath = modeStateFile(cwd, "deep-interview", selectors.gjcSessionId);
|
|
5119
|
+
const current = await readExistingStateForMutation(statePath);
|
|
5120
|
+
if (current.kind !== "valid")
|
|
5121
|
+
throw new StateCommandError(2, "approve-execution requires valid active deep-interview state");
|
|
5122
|
+
const envelope = normalizeDeepInterviewEnvelope(current.value) as Record<string, unknown>;
|
|
5123
|
+
if (envelope.version !== WORKFLOW_STATE_VERSION)
|
|
5124
|
+
throw new StateCommandError(2, "approve-execution requires current deep-interview state version");
|
|
5125
|
+
const ralplanApproval =
|
|
5126
|
+
envelope.active === false &&
|
|
5127
|
+
envelope.handoff_to === "ralplan" &&
|
|
5128
|
+
typeof envelope.handoff_at === "string" &&
|
|
5129
|
+
(await hasAuditedDeepInterviewHandoff(cwd, selectors.gjcSessionId, "ralplan", {
|
|
5130
|
+
handoffAt: envelope.handoff_at,
|
|
5131
|
+
}));
|
|
5132
|
+
if (envelope.active !== true && !ralplanApproval)
|
|
5133
|
+
throw new StateCommandError(2, "approve-execution requires active or Ralplan-handed-off deep-interview state");
|
|
5134
|
+
if (envelope.current_phase !== "handoff")
|
|
5135
|
+
throw new StateCommandError(2, "approve-execution requires deep-interview current_phase handoff");
|
|
5136
|
+
const inner = isPlainObject(envelope.state) ? envelope.state : {};
|
|
5137
|
+
const crystal = requireReadyCanonicalCrystal(inner.crystal);
|
|
5138
|
+
const approvedAt = nowIso();
|
|
5139
|
+
const mutationId = `deep-interview:approve-execution:${approvedAt}`;
|
|
5140
|
+
const currentRevision = existingStateRevision(envelope);
|
|
5141
|
+
if (typeof currentRevision !== "number" || !Number.isSafeInteger(currentRevision) || currentRevision < 0)
|
|
5142
|
+
throw new StateCommandError(2, "approve-execution requires a valid deep-interview state revision");
|
|
5143
|
+
const approvalRevision = currentRevision + 1;
|
|
5144
|
+
const resolvedStatePath = path.resolve(statePath);
|
|
5145
|
+
const publicationReceipt = isPlainObject(envelope.receipt) ? envelope.receipt : undefined;
|
|
5146
|
+
const publicationChecksum = isPlainObject(publicationReceipt?.content_sha256)
|
|
5147
|
+
? publicationReceipt.content_sha256
|
|
5148
|
+
: undefined;
|
|
5149
|
+
if (
|
|
5150
|
+
inner.execution_approval !== "approved" &&
|
|
5151
|
+
((ralplanApproval
|
|
5152
|
+
? publicationReceipt?.owner !== "gjc-state-cli" ||
|
|
5153
|
+
publicationReceipt.command !== "gjc state deep-interview handoff --to ralplan"
|
|
5154
|
+
: publicationReceipt?.owner !== "gjc-runtime" ||
|
|
5155
|
+
publicationReceipt.command !== "gjc deep-interview crystallize") ||
|
|
5156
|
+
publicationChecksum?.algorithm !== "sha256" ||
|
|
5157
|
+
typeof publicationChecksum?.value !== "string" ||
|
|
5158
|
+
publicationChecksum.value.length !== 64 ||
|
|
5159
|
+
publicationChecksum.covered_path !== path.resolve(statePath))
|
|
5160
|
+
)
|
|
5161
|
+
throw new StateCommandError(2, "approve-execution requires a canonically published Crystal receipt");
|
|
5162
|
+
const integrityWarning = await warnAndAuditOutOfBandIfNeeded(
|
|
5163
|
+
cwd,
|
|
5164
|
+
selectors.gjcSessionId,
|
|
5165
|
+
statePath,
|
|
5166
|
+
"deep-interview",
|
|
5167
|
+
{ mutationId },
|
|
5168
|
+
);
|
|
5169
|
+
if (integrityWarning) throw new StateCommandError(2, `${integrityWarning}; approval refuses tampered mode-state`);
|
|
5170
|
+
await assertDeepInterviewHandoffReady(envelope, {
|
|
5171
|
+
cwd,
|
|
5172
|
+
sessionId: selectors.gjcSessionId,
|
|
5173
|
+
statePath,
|
|
5174
|
+
});
|
|
5175
|
+
const existingReceipt = isPlainObject(inner.execution_approval_receipt)
|
|
5176
|
+
? inner.execution_approval_receipt
|
|
5177
|
+
: undefined;
|
|
5178
|
+
if (inner.execution_approval === "approved") {
|
|
5179
|
+
if (existingReceipt?.schema_version !== 1 || existingReceipt.method !== "explicit-state-action")
|
|
5180
|
+
throw new StateCommandError(2, "deep-interview execution approval lacks explicit provenance");
|
|
5181
|
+
if (typeof existingReceipt.mutation_id !== "string")
|
|
5182
|
+
throw new StateCommandError(2, "deep-interview execution approval lacks explicit provenance");
|
|
5183
|
+
}
|
|
5184
|
+
const approvalRecord = await readDeepInterviewExecutionApprovalRecord(approvalRecordPath);
|
|
5185
|
+
if (!approvalRecord)
|
|
5186
|
+
throw new StateCommandError(2, "approve-execution requires a user-origin execution approval record");
|
|
5187
|
+
await assertExecutionApprovalSpecIdentity(approvalRecord);
|
|
5188
|
+
await assertExecutionApprovalTranscriptBoundary(
|
|
5189
|
+
cwd,
|
|
5190
|
+
selectors.gjcSessionId,
|
|
5191
|
+
approvalRecord.transcript_path,
|
|
5192
|
+
approvalRecord.transcript_sha256,
|
|
5193
|
+
approvalRecord.transcript_boundary,
|
|
5194
|
+
);
|
|
5195
|
+
await assertRalplanApprovalRecordCurrent(cwd, selectors.gjcSessionId, approvalRecord);
|
|
5196
|
+
assertExecutionApprovalRecordMatchesCurrentState(approvalRecord, {
|
|
5197
|
+
sessionId: selectors.gjcSessionId,
|
|
5198
|
+
statePath,
|
|
5199
|
+
envelope,
|
|
5200
|
+
crystal,
|
|
5201
|
+
recordRevision: inner.execution_approval === "approved" ? currentRevision - 1 : currentRevision,
|
|
5202
|
+
allowConsumed: inner.execution_approval === "approved",
|
|
5203
|
+
});
|
|
5204
|
+
if (inner.execution_approval !== "approved" && approvalRecord.status !== "pending")
|
|
5205
|
+
throw new StateCommandError(2, "approve-execution requires an unconsumed execution approval record");
|
|
5206
|
+
if (inner.execution_approval === "approved") {
|
|
5207
|
+
if (
|
|
5208
|
+
!existingReceipt ||
|
|
5209
|
+
typeof existingReceipt.mutation_id !== "string" ||
|
|
5210
|
+
typeof existingReceipt.approved_at !== "string" ||
|
|
5211
|
+
typeof existingReceipt.state_revision !== "number"
|
|
5212
|
+
)
|
|
5213
|
+
throw new StateCommandError(2, "deep-interview execution approval lacks explicit provenance");
|
|
5214
|
+
if (
|
|
5215
|
+
existingReceipt.question_id !== approvalRecord.question_id ||
|
|
5216
|
+
existingReceipt.gate_id !== approvalRecord.gate_id ||
|
|
5217
|
+
existingReceipt.answer_hash !== approvalRecord.answer_hash ||
|
|
5218
|
+
existingReceipt.transcript_path !== approvalRecord.transcript_path ||
|
|
5219
|
+
existingReceipt.transcript_sha256 !== approvalRecord.transcript_sha256 ||
|
|
5220
|
+
JSON.stringify(existingReceipt.transcript_boundary) !== JSON.stringify(approvalRecord.transcript_boundary) ||
|
|
5221
|
+
existingReceipt.target !== approvalRecord.target ||
|
|
5222
|
+
existingReceipt.approval_stage !== (approvalRecord.approval_stage ?? "deep-interview") ||
|
|
5223
|
+
existingReceipt.ralplan_state_path !== approvalRecord.ralplan_state_path ||
|
|
5224
|
+
existingReceipt.ralplan_state_revision !== approvalRecord.ralplan_state_revision ||
|
|
5225
|
+
existingReceipt.ralplan_run_id !== approvalRecord.ralplan_run_id ||
|
|
5226
|
+
existingReceipt.ralplan_final_path !== approvalRecord.ralplan_final_path ||
|
|
5227
|
+
existingReceipt.ralplan_final_sha256 !== approvalRecord.ralplan_final_sha256
|
|
5228
|
+
)
|
|
5229
|
+
throw new StateCommandError(2, "deep-interview execution approval recovery receipt identity mismatch");
|
|
5230
|
+
if (
|
|
5231
|
+
approvalRecord.status === "consumed" &&
|
|
5232
|
+
(approvalRecord.consumed_mutation_id !== existingReceipt.mutation_id ||
|
|
5233
|
+
approvalRecord.consumed_at !== existingReceipt.approved_at)
|
|
5234
|
+
)
|
|
5235
|
+
throw new StateCommandError(2, "deep-interview execution approval record provenance mismatch");
|
|
5236
|
+
const pendingJournal = await readWorkflowTransactionJournal(
|
|
5237
|
+
cwd,
|
|
5238
|
+
selectors.gjcSessionId,
|
|
5239
|
+
existingReceipt.mutation_id,
|
|
5240
|
+
);
|
|
5241
|
+
if (pendingJournal?.status !== "pending")
|
|
5242
|
+
throw new StateCommandError(2, "deep-interview execution approval record is already consumed");
|
|
5243
|
+
const persistedReceipt = persistedWorkflowReceipt(envelope.receipt, "deep-interview");
|
|
5244
|
+
const persistedRevision = existingStateRevision(envelope);
|
|
5245
|
+
const expectedJournalPaths = [
|
|
5246
|
+
resolvedStatePath,
|
|
5247
|
+
path.resolve(approvalRecordPath),
|
|
5248
|
+
auditPath(cwd, selectors.gjcSessionId),
|
|
5249
|
+
].map(value => path.resolve(value));
|
|
5250
|
+
if (
|
|
5251
|
+
!persistedReceipt ||
|
|
5252
|
+
typeof persistedRevision !== "number" ||
|
|
5253
|
+
persistedRevision !== existingReceipt.state_revision ||
|
|
5254
|
+
persistedReceipt.mutation_id !== existingReceipt.mutation_id ||
|
|
5255
|
+
persistedReceipt.mutated_at !== existingReceipt.approved_at ||
|
|
5256
|
+
pendingJournal.mutation_id !== existingReceipt.mutation_id ||
|
|
5257
|
+
pendingJournal.paths.length !== expectedJournalPaths.length ||
|
|
5258
|
+
pendingJournal.paths.some((value, index) => path.resolve(value) !== expectedJournalPaths[index])
|
|
5259
|
+
)
|
|
5260
|
+
throw new StateCommandError(2, "pending execution approval recovery journal identity mismatch");
|
|
5261
|
+
const recoverySteps = new Set(pendingJournal.steps);
|
|
5262
|
+
if (approvalRecord.status === "pending") {
|
|
5263
|
+
const consumedRecord: DeepInterviewExecutionApprovalRecord = {
|
|
5264
|
+
...approvalRecord,
|
|
5265
|
+
status: "consumed",
|
|
5266
|
+
consumed_at: existingReceipt.approved_at as string,
|
|
5267
|
+
consumed_mutation_id: existingReceipt.mutation_id,
|
|
5268
|
+
};
|
|
5269
|
+
await writeDeepInterviewExecutionApprovalRecord(cwd, selectors.gjcSessionId, consumedRecord);
|
|
5270
|
+
recoverySteps.add("approval-record");
|
|
5271
|
+
}
|
|
5272
|
+
const approvalOptions: ExecutionApprovalAuditOptions = {
|
|
5273
|
+
cwd,
|
|
5274
|
+
sessionId: selectors.gjcSessionId,
|
|
5275
|
+
statePath: resolvedStatePath,
|
|
5276
|
+
approvalRecordPath: path.resolve(approvalRecordPath),
|
|
5277
|
+
questionId: approvalRecord.question_id,
|
|
5278
|
+
gateId: approvalRecord.gate_id,
|
|
5279
|
+
target: approvalRecord.target,
|
|
5280
|
+
answerHash: approvalRecord.answer_hash,
|
|
5281
|
+
approvedAt: existingReceipt.approved_at as string,
|
|
5282
|
+
mutationId: existingReceipt.mutation_id,
|
|
5283
|
+
revision: persistedRevision,
|
|
5284
|
+
receipt: persistedReceipt,
|
|
5285
|
+
};
|
|
5286
|
+
const indexed = await readBoundedIdentityText(
|
|
5287
|
+
path.join(sessionStateDir(cwd, selectors.gjcSessionId), "deep-interview-approval-audit.json"),
|
|
5288
|
+
64 * 1024,
|
|
5289
|
+
"deep-interview execution approval index",
|
|
5290
|
+
);
|
|
5291
|
+
const expectedApprovalEntry = buildExecutionApprovalAuditEntry(approvalOptions);
|
|
5292
|
+
let indexedMatches = false;
|
|
5293
|
+
if (indexed) {
|
|
5294
|
+
try {
|
|
5295
|
+
const parsed: unknown = JSON.parse(indexed);
|
|
5296
|
+
indexedMatches = isPlainObject(parsed) && JSON.stringify(parsed) === JSON.stringify(expectedApprovalEntry);
|
|
5297
|
+
} catch {}
|
|
5298
|
+
}
|
|
5299
|
+
if (!indexedMatches) await writeExecutionApprovalIndex(approvalOptions, expectedApprovalEntry);
|
|
5300
|
+
recoverySteps.add("approval-index");
|
|
5301
|
+
let approvalAuditOffset = pendingJournal.approval_audit_offset;
|
|
5302
|
+
if (!recoverySteps.has("approval-audit")) {
|
|
5303
|
+
approvalAuditOffset = await appendExecutionApprovalAuditIdempotent(
|
|
5304
|
+
approvalOptions,
|
|
5305
|
+
expectedApprovalEntry,
|
|
5306
|
+
approvalAuditOffset,
|
|
5307
|
+
offset =>
|
|
5308
|
+
updateWorkflowTransactionJournal(cwd, selectors.gjcSessionId, approvalOptions.mutationId, {
|
|
5309
|
+
steps: [...recoverySteps],
|
|
5310
|
+
approval_audit_offset: offset,
|
|
5311
|
+
}),
|
|
5312
|
+
);
|
|
5313
|
+
recoverySteps.add("approval-audit");
|
|
5314
|
+
}
|
|
5315
|
+
await updateWorkflowTransactionJournal(cwd, selectors.gjcSessionId, existingReceipt.mutation_id, {
|
|
5316
|
+
steps: [...recoverySteps],
|
|
5317
|
+
approval_audit_offset: approvalAuditOffset,
|
|
5318
|
+
});
|
|
5319
|
+
await completeWorkflowTransactionJournal(cwd, selectors.gjcSessionId, existingReceipt.mutation_id);
|
|
5320
|
+
await assertDeepInterviewHandoffReady(envelope, {
|
|
5321
|
+
cwd,
|
|
5322
|
+
sessionId: selectors.gjcSessionId,
|
|
5323
|
+
statePath,
|
|
5324
|
+
requireExecutionApproval: true,
|
|
5325
|
+
});
|
|
5326
|
+
return {
|
|
5327
|
+
status: 0,
|
|
5328
|
+
stdout: `${JSON.stringify({ skill: "deep-interview", execution_approval: "approved", state_path: statePath })}\n`,
|
|
5329
|
+
};
|
|
5330
|
+
}
|
|
5331
|
+
inner.execution_approval = "approved";
|
|
5332
|
+
inner.execution_approval_receipt = {
|
|
5333
|
+
schema_version: 1,
|
|
5334
|
+
method: "explicit-state-action",
|
|
5335
|
+
approved_at: approvedAt,
|
|
5336
|
+
mutation_id: mutationId,
|
|
5337
|
+
state_revision: approvalRevision,
|
|
5338
|
+
state_path: resolvedStatePath,
|
|
5339
|
+
spec_sha256: envelope.spec_sha256,
|
|
5340
|
+
crystal_spec_version: crystal.spec_version,
|
|
5341
|
+
crystal_source_digest: (crystal.source as Record<string, unknown>).digest,
|
|
5342
|
+
approval_record_path: path.resolve(approvalRecordPath),
|
|
5343
|
+
question_id: approvalRecord.question_id,
|
|
5344
|
+
gate_id: approvalRecord.gate_id,
|
|
5345
|
+
answer_hash: approvalRecord.answer_hash,
|
|
5346
|
+
transcript_path: approvalRecord.transcript_path,
|
|
5347
|
+
transcript_sha256: approvalRecord.transcript_sha256,
|
|
5348
|
+
transcript_boundary: approvalRecord.transcript_boundary,
|
|
5349
|
+
target: approvalRecord.target,
|
|
5350
|
+
approval_stage: approvalRecord.approval_stage ?? "deep-interview",
|
|
5351
|
+
...(approvalRecord.approval_stage === "ralplan"
|
|
5352
|
+
? {
|
|
5353
|
+
ralplan_state_path: approvalRecord.ralplan_state_path,
|
|
5354
|
+
ralplan_state_revision: approvalRecord.ralplan_state_revision,
|
|
5355
|
+
ralplan_run_id: approvalRecord.ralplan_run_id,
|
|
5356
|
+
ralplan_final_path: approvalRecord.ralplan_final_path,
|
|
5357
|
+
ralplan_final_sha256: approvalRecord.ralplan_final_sha256,
|
|
5358
|
+
}
|
|
5359
|
+
: {}),
|
|
5360
|
+
};
|
|
5361
|
+
envelope.state = inner;
|
|
5362
|
+
envelope.updated_at = approvedAt;
|
|
5363
|
+
await beginWorkflowTransactionJournal({
|
|
5364
|
+
cwd,
|
|
5365
|
+
sessionId: selectors.gjcSessionId,
|
|
5366
|
+
mutationId,
|
|
5367
|
+
caller: "deep-interview",
|
|
5368
|
+
paths: [resolvedStatePath, path.resolve(approvalRecordPath), auditPath(cwd, selectors.gjcSessionId)],
|
|
5369
|
+
});
|
|
5370
|
+
const writeResult = await writeGuardedWorkflowEnvelopeAtomic(statePath, envelope, {
|
|
5371
|
+
cwd,
|
|
5372
|
+
policy: "source",
|
|
5373
|
+
lockHeld: true,
|
|
5374
|
+
receipt: {
|
|
5375
|
+
cwd,
|
|
5376
|
+
skill: "deep-interview",
|
|
5377
|
+
owner: "gjc-state-cli",
|
|
5378
|
+
command: "gjc state deep-interview approve-execution",
|
|
5379
|
+
sessionId: selectors.gjcSessionId,
|
|
5380
|
+
nowIso: approvedAt,
|
|
5381
|
+
mutationId,
|
|
5382
|
+
verb: "approve-execution",
|
|
5383
|
+
fromPhase: "handoff",
|
|
5384
|
+
toPhase: "handoff",
|
|
5385
|
+
forced: false,
|
|
5386
|
+
},
|
|
5387
|
+
audit: {
|
|
5388
|
+
category: "state",
|
|
5389
|
+
verb: "approve-execution",
|
|
5390
|
+
owner: "gjc-state-cli",
|
|
5391
|
+
sessionId: selectors.gjcSessionId,
|
|
5392
|
+
skill: "deep-interview",
|
|
5393
|
+
mutationId,
|
|
5394
|
+
fromPhase: "handoff",
|
|
5395
|
+
toPhase: "handoff",
|
|
5396
|
+
forced: false,
|
|
5397
|
+
},
|
|
5398
|
+
});
|
|
5399
|
+
if (!writeResult.written || !isPlainObject(writeResult.stamped))
|
|
5400
|
+
throw new StateCommandError(1, "approval writer did not return a stamped workflow envelope");
|
|
5401
|
+
const stampedApprovalReceipt = isPlainObject(writeResult.stamped.receipt) ? writeResult.stamped.receipt : undefined;
|
|
5402
|
+
if (!stampedApprovalReceipt)
|
|
5403
|
+
throw new StateCommandError(1, "approval writer did not return a stamped workflow receipt");
|
|
5404
|
+
await updateWorkflowTransactionJournal(cwd, selectors.gjcSessionId, mutationId, {
|
|
5405
|
+
steps: ["approval-state"],
|
|
5406
|
+
});
|
|
5407
|
+
const consumedApprovalRecord: DeepInterviewExecutionApprovalRecord = {
|
|
5408
|
+
...approvalRecord,
|
|
5409
|
+
status: "consumed",
|
|
5410
|
+
consumed_at: approvedAt,
|
|
5411
|
+
consumed_mutation_id: mutationId,
|
|
5412
|
+
};
|
|
5413
|
+
await writeDeepInterviewExecutionApprovalRecord(cwd, selectors.gjcSessionId, consumedApprovalRecord);
|
|
5414
|
+
await updateWorkflowTransactionJournal(cwd, selectors.gjcSessionId, mutationId, {
|
|
5415
|
+
steps: ["approval-state", "approval-record"],
|
|
5416
|
+
});
|
|
5417
|
+
const approvalAuditOptions: ExecutionApprovalAuditOptions = {
|
|
5418
|
+
cwd,
|
|
5419
|
+
sessionId: selectors.gjcSessionId,
|
|
5420
|
+
statePath: resolvedStatePath,
|
|
5421
|
+
approvalRecordPath: path.resolve(approvalRecordPath),
|
|
5422
|
+
questionId: approvalRecord.question_id,
|
|
5423
|
+
gateId: approvalRecord.gate_id,
|
|
5424
|
+
target: approvalRecord.target,
|
|
5425
|
+
answerHash: approvalRecord.answer_hash,
|
|
5426
|
+
approvedAt,
|
|
5427
|
+
mutationId,
|
|
5428
|
+
revision: writeResult.revision,
|
|
5429
|
+
receipt: persistedWorkflowReceipt(stampedApprovalReceipt, "deep-interview")!,
|
|
5430
|
+
};
|
|
5431
|
+
await appendExecutionApprovalAudit(approvalAuditOptions, {
|
|
5432
|
+
afterIndex: () =>
|
|
5433
|
+
updateWorkflowTransactionJournal(cwd, selectors.gjcSessionId, mutationId, {
|
|
5434
|
+
steps: ["approval-state", "approval-record", "approval-index"],
|
|
5435
|
+
}),
|
|
5436
|
+
beforeAudit: offset =>
|
|
5437
|
+
updateWorkflowTransactionJournal(cwd, selectors.gjcSessionId, mutationId, {
|
|
5438
|
+
steps: ["approval-state", "approval-record", "approval-index"],
|
|
5439
|
+
approval_audit_offset: offset,
|
|
5440
|
+
}),
|
|
5441
|
+
afterAudit: () =>
|
|
5442
|
+
updateWorkflowTransactionJournal(cwd, selectors.gjcSessionId, mutationId, {
|
|
5443
|
+
steps: ["approval-state", "approval-record", "approval-index", "approval-audit"],
|
|
5444
|
+
}),
|
|
5445
|
+
});
|
|
5446
|
+
await completeWorkflowTransactionJournal(cwd, selectors.gjcSessionId, mutationId);
|
|
5447
|
+
await syncSkillActiveState({
|
|
5448
|
+
cwd,
|
|
5449
|
+
skill: "deep-interview",
|
|
5450
|
+
active: true,
|
|
5451
|
+
phase: typeof envelope.current_phase === "string" ? envelope.current_phase : "handoff",
|
|
5452
|
+
sessionId: selectors.gjcSessionId,
|
|
5453
|
+
threadId: selectors.threadId,
|
|
5454
|
+
turnId: selectors.turnId,
|
|
5455
|
+
source: "gjc-state-cli",
|
|
5456
|
+
hud: buildHudForMode("deep-interview", envelope),
|
|
5457
|
+
sourceRevision: writeResult.revision,
|
|
5458
|
+
});
|
|
5459
|
+
await touchStateActivityMarker(cwd, selectors.gjcSessionId, statePath);
|
|
5460
|
+
return {
|
|
5461
|
+
status: 0,
|
|
5462
|
+
stdout: `${JSON.stringify({ skill: "deep-interview", execution_approval: "approved", state_path: statePath, mutation_id: mutationId })}\n`,
|
|
5463
|
+
};
|
|
5464
|
+
}
|
|
5465
|
+
|
|
5466
|
+
async function handleApproveExecution(args: readonly string[], cwd: string): Promise<StateCommandResult> {
|
|
5467
|
+
const selectors = await resolveSelectors(args, cwd, "approve-execution");
|
|
5468
|
+
if (selectors.mode !== "deep-interview" && selectors.mode !== "ralplan")
|
|
5469
|
+
throw new StateCommandError(2, "approve-execution requires --mode deep-interview or ralplan");
|
|
5470
|
+
const statePath = modeStateFile(cwd, selectors.mode, selectors.gjcSessionId);
|
|
5471
|
+
return withWorkflowStateLock(statePath, () => handleApproveExecutionUnlocked(cwd, selectors), { cwd });
|
|
1895
5472
|
}
|
|
1896
5473
|
|
|
1897
5474
|
async function handleContract(args: readonly string[], cwd: string): Promise<StateCommandResult> {
|
|
@@ -2206,6 +5783,8 @@ async function handleMigrate(args: readonly string[], cwd: string): Promise<Stat
|
|
|
2206
5783
|
if (mismatchWarning && !forced) {
|
|
2207
5784
|
throw new StateCommandError(2, `${mismatchWarning}; use --force to migrate tampered mode-state`);
|
|
2208
5785
|
}
|
|
5786
|
+
const existingRead = await readExistingStateForMutation(filePath);
|
|
5787
|
+
if (existingRead.kind === "valid") assertNoFutureWorkflowEnvelope(existingRead.value, mode, `migration of ${mode}`);
|
|
2209
5788
|
const result = await migrateAndPersistLegacyState({
|
|
2210
5789
|
cwd,
|
|
2211
5790
|
skill: mode,
|
|
@@ -2232,6 +5811,8 @@ export async function runNativeStateCommand(args: string[], cwd = process.cwd())
|
|
|
2232
5811
|
return await handleClear(args, cwd);
|
|
2233
5812
|
case "contract":
|
|
2234
5813
|
return await handleContract(args, cwd);
|
|
5814
|
+
case "approve-execution":
|
|
5815
|
+
return await handleApproveExecution(args, cwd);
|
|
2235
5816
|
case "status":
|
|
2236
5817
|
return await handleStatus(args, cwd);
|
|
2237
5818
|
case "doctor":
|