@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,14 +1,32 @@
|
|
|
1
1
|
import { createHash, randomBytes } from "node:crypto";
|
|
2
|
+
import type { Stats } from "node:fs";
|
|
2
3
|
import * as fs from "node:fs/promises";
|
|
3
4
|
import * as path from "node:path";
|
|
4
5
|
import { isSettingsInitialized, Settings } from "../config/settings";
|
|
6
|
+
import { listManagedSessionCandidates, resolveManagedSessionScope } from "../sdk/session-directory";
|
|
7
|
+
import {
|
|
8
|
+
type FileEntry,
|
|
9
|
+
isProjectSessionTranscriptPath,
|
|
10
|
+
listProjectSessionTranscriptFiles,
|
|
11
|
+
parseSessionEntries,
|
|
12
|
+
RESUME_TRANSCRIPT_MAX_BYTES,
|
|
13
|
+
readAuthorizedProjectSessionTranscript,
|
|
14
|
+
} from "../session/session-manager";
|
|
5
15
|
import { syncSkillActiveState } from "../skill-state/active-state";
|
|
6
16
|
import { deriveDeepInterviewHud } from "../skill-state/workflow-hud";
|
|
7
17
|
import { WORKFLOW_STATE_VERSION } from "../skill-state/workflow-state-contract";
|
|
18
|
+
import {
|
|
19
|
+
type CrystalSnapshot,
|
|
20
|
+
crystallizeDeepInterview,
|
|
21
|
+
crystalMarkdown,
|
|
22
|
+
crystalSnapshotDigest,
|
|
23
|
+
type DeepInterviewCrystal,
|
|
24
|
+
} from "./deep-interview-crystallize";
|
|
8
25
|
import { isDeepInterviewStageVerb, runDeepInterviewStageCommand } from "./deep-interview-stage";
|
|
9
26
|
import {
|
|
10
27
|
assertDeepInterviewInputWithinLimit,
|
|
11
28
|
assertDeepInterviewIntentReview,
|
|
29
|
+
assertDeepInterviewStructuredResponseWithinLimit,
|
|
12
30
|
type DeepInterviewIntentCategory,
|
|
13
31
|
type DeepInterviewIntentItem,
|
|
14
32
|
type DeepInterviewIntentManifest,
|
|
@@ -19,13 +37,39 @@ import {
|
|
|
19
37
|
reviewDeepInterviewIntent,
|
|
20
38
|
} from "./deep-interview-state";
|
|
21
39
|
import { runNativeRalplanCommand } from "./ralplan-runtime";
|
|
22
|
-
import { modeStatePath, sessionSpecsDir } from "./session-layout";
|
|
40
|
+
import { modeStatePath, sessionSpecsDir, transactionJournalPath } from "./session-layout";
|
|
23
41
|
import { resolveGjcSessionForWrite, writeSessionActivityMarker } from "./session-resolution";
|
|
24
42
|
import { runNativeStateCommand } from "./state-runtime";
|
|
25
|
-
import {
|
|
43
|
+
import {
|
|
44
|
+
appendJsonl,
|
|
45
|
+
beginWorkflowTransactionJournal,
|
|
46
|
+
completeWorkflowTransactionJournal,
|
|
47
|
+
detectWorkflowEnvelopeIntegrityMismatch,
|
|
48
|
+
readExistingStateForMutation,
|
|
49
|
+
updateWorkflowTransactionJournal,
|
|
50
|
+
type WorkflowTransactionJournal,
|
|
51
|
+
withWorkflowStateLock,
|
|
52
|
+
writeArtifact,
|
|
53
|
+
writeWorkflowEnvelopeAtomic,
|
|
54
|
+
} from "./state-writer";
|
|
26
55
|
import { assertSafePathComponent, CommandError, flagValue, hasFlag } from "./workflow-cli-common";
|
|
27
56
|
import { resolveWorkflowSetting } from "./workflow-settings";
|
|
28
57
|
|
|
58
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
59
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function assertNoFutureDeepInterviewEnvelope(value: Record<string, unknown>, surface: string): void {
|
|
63
|
+
if (
|
|
64
|
+
value.version !== undefined &&
|
|
65
|
+
(!Number.isSafeInteger(value.version) || (value.version as number) > WORKFLOW_STATE_VERSION)
|
|
66
|
+
)
|
|
67
|
+
throw new DeepInterviewCommandError(
|
|
68
|
+
2,
|
|
69
|
+
`${surface} has unsupported future deep-interview state version ${String(value.version)}; refusing downgrade`,
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
|
|
29
73
|
export * from "./deep-interview-recorder";
|
|
30
74
|
|
|
31
75
|
/**
|
|
@@ -97,6 +141,17 @@ const TRACE_SOURCE_EXTENSIONS = new Set([
|
|
|
97
141
|
".yaml",
|
|
98
142
|
]);
|
|
99
143
|
|
|
144
|
+
const DEEP_INTERVIEW_NON_TEXT_CONTENT_TYPES = new Set([
|
|
145
|
+
"image",
|
|
146
|
+
"audio",
|
|
147
|
+
"video",
|
|
148
|
+
"file",
|
|
149
|
+
"content",
|
|
150
|
+
"toolCall",
|
|
151
|
+
"thinking",
|
|
152
|
+
"redactedThinking",
|
|
153
|
+
]);
|
|
154
|
+
|
|
100
155
|
interface DeepInterviewTraceSummary {
|
|
101
156
|
enabled: true;
|
|
102
157
|
generated_at: string;
|
|
@@ -133,6 +188,1325 @@ const VALUE_FLAGS = new Set([
|
|
|
133
188
|
"--handoff",
|
|
134
189
|
]);
|
|
135
190
|
|
|
191
|
+
// Keep the runtime's transcript and publication bounds aligned with the pure
|
|
192
|
+
// Crystal validator. These are byte bounds for recovery reads; normal input
|
|
193
|
+
// validation remains code-point bounded by deep-interview-state.ts.
|
|
194
|
+
const CRYSTAL_MAX_MESSAGES = 200;
|
|
195
|
+
const CRYSTAL_MAX_JOURNAL_BYTES = 64 * 1024;
|
|
196
|
+
const CRYSTAL_MAX_INDEX_BYTES = 1_000_000;
|
|
197
|
+
const CRYSTAL_INDEX_RECOVERY_BYTES = CRYSTAL_MAX_INDEX_BYTES + 64 * 1024;
|
|
198
|
+
const CRYSTAL_MAX_ARTIFACT_BYTES = MAX_DEEP_INTERVIEW_STRUCTURED_RESPONSE_LENGTH * 4 + 4096;
|
|
199
|
+
const READ_NOFOLLOW_FLAGS =
|
|
200
|
+
fs.constants.O_RDONLY | (typeof fs.constants.O_NOFOLLOW === "number" ? fs.constants.O_NOFOLLOW : 0);
|
|
201
|
+
|
|
202
|
+
interface CrystalIndexRow {
|
|
203
|
+
slug: string;
|
|
204
|
+
stage: "final";
|
|
205
|
+
path: string;
|
|
206
|
+
created_at: string;
|
|
207
|
+
sha256: string;
|
|
208
|
+
canonicalPath: string;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
interface CrystalIndexCatalog {
|
|
212
|
+
rows: CrystalIndexRow[];
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function serializeCrystalIndexRows(rows: readonly CrystalIndexRow[]): string {
|
|
216
|
+
return rows
|
|
217
|
+
.map(row =>
|
|
218
|
+
JSON.stringify({
|
|
219
|
+
slug: row.slug,
|
|
220
|
+
stage: row.stage,
|
|
221
|
+
path: row.path,
|
|
222
|
+
created_at: row.created_at,
|
|
223
|
+
sha256: row.sha256,
|
|
224
|
+
}),
|
|
225
|
+
)
|
|
226
|
+
.join("\n")
|
|
227
|
+
.concat(rows.length > 0 ? "\n" : "");
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function compactCrystalIndexRows(
|
|
231
|
+
rows: readonly CrystalIndexRow[],
|
|
232
|
+
protectedPaths: ReadonlySet<string>,
|
|
233
|
+
): CrystalIndexRow[] {
|
|
234
|
+
const latestByPath = new Map<string, CrystalIndexRow>();
|
|
235
|
+
for (const row of rows) {
|
|
236
|
+
latestByPath.delete(row.canonicalPath);
|
|
237
|
+
latestByPath.set(row.canonicalPath, row);
|
|
238
|
+
}
|
|
239
|
+
const retained = [...latestByPath.values()];
|
|
240
|
+
while (Buffer.byteLength(serializeCrystalIndexRows(retained), "utf-8") > CRYSTAL_MAX_INDEX_BYTES) {
|
|
241
|
+
const removable = retained.findIndex(row => !protectedPaths.has(row.canonicalPath));
|
|
242
|
+
if (removable < 0) throw new DeepInterviewCommandError(2, "Crystal index live publication set exceeds its bound");
|
|
243
|
+
retained.splice(removable, 1);
|
|
244
|
+
}
|
|
245
|
+
return retained;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
interface CrystalJournalRecord extends WorkflowTransactionJournal {
|
|
249
|
+
artifact_sha256?: unknown;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function isErrnoCode(error: unknown, code: string): boolean {
|
|
253
|
+
return typeof error === "object" && error !== null && "code" in error && (error as { code?: unknown }).code === code;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function sameFileIdentity(left: Stats, right: Stats): boolean {
|
|
257
|
+
return (
|
|
258
|
+
left.dev === right.dev &&
|
|
259
|
+
left.ino === right.ino &&
|
|
260
|
+
left.mode === right.mode &&
|
|
261
|
+
left.size === right.size &&
|
|
262
|
+
left.mtimeMs === right.mtimeMs &&
|
|
263
|
+
left.ctimeMs === right.ctimeMs &&
|
|
264
|
+
left.nlink === right.nlink
|
|
265
|
+
);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function isPathWithin(root: string, target: string): boolean {
|
|
269
|
+
const relative = path.relative(path.resolve(root), path.resolve(target));
|
|
270
|
+
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
async function readBoundedFileBytes(
|
|
274
|
+
filePath: string,
|
|
275
|
+
maxBytes: number,
|
|
276
|
+
label: string,
|
|
277
|
+
options: { allowMissing?: boolean; rejectSymlink?: boolean } = {},
|
|
278
|
+
): Promise<Buffer | undefined> {
|
|
279
|
+
let lexicalStat: Stats;
|
|
280
|
+
try {
|
|
281
|
+
lexicalStat = await fs.lstat(filePath);
|
|
282
|
+
} catch (error) {
|
|
283
|
+
if (options.allowMissing && isErrnoCode(error, "ENOENT")) return undefined;
|
|
284
|
+
if (isErrnoCode(error, "ENOENT")) throw new DeepInterviewCommandError(2, `${label} is missing`);
|
|
285
|
+
throw new DeepInterviewCommandError(2, `failed to read ${label}`);
|
|
286
|
+
}
|
|
287
|
+
if (options.rejectSymlink !== false && lexicalStat.isSymbolicLink())
|
|
288
|
+
throw new DeepInterviewCommandError(2, `${label} must not be a symlink`);
|
|
289
|
+
if (!lexicalStat.isFile()) throw new DeepInterviewCommandError(2, `${label} is not a regular file`);
|
|
290
|
+
let handle: fs.FileHandle | undefined;
|
|
291
|
+
try {
|
|
292
|
+
handle = await fs.open(filePath, READ_NOFOLLOW_FLAGS);
|
|
293
|
+
const initial = await handle.stat();
|
|
294
|
+
if (!initial.isFile()) throw new DeepInterviewCommandError(2, `${label} is not a regular file`);
|
|
295
|
+
if (!sameFileIdentity(lexicalStat, initial))
|
|
296
|
+
throw new DeepInterviewCommandError(2, `${label} changed before recovery read`);
|
|
297
|
+
if (initial.size > maxBytes) throw new DeepInterviewCommandError(2, `${label} exceeds the bounded read limit`);
|
|
298
|
+
const output = Buffer.alloc(Number(initial.size));
|
|
299
|
+
let offset = 0;
|
|
300
|
+
while (offset < output.length) {
|
|
301
|
+
const { bytesRead } = await handle.read(output, offset, output.length - offset, offset);
|
|
302
|
+
if (bytesRead <= 0) throw new DeepInterviewCommandError(2, `${label} changed during recovery read`);
|
|
303
|
+
offset += bytesRead;
|
|
304
|
+
}
|
|
305
|
+
const final = await handle.stat();
|
|
306
|
+
if (!sameFileIdentity(initial, final))
|
|
307
|
+
throw new DeepInterviewCommandError(2, `${label} changed during recovery read`);
|
|
308
|
+
const finalPath = await fs.lstat(filePath);
|
|
309
|
+
if (!finalPath.isFile() || finalPath.isSymbolicLink() || !sameFileIdentity(final, finalPath))
|
|
310
|
+
throw new DeepInterviewCommandError(2, `${label} changed during recovery read`);
|
|
311
|
+
return output;
|
|
312
|
+
} catch (error) {
|
|
313
|
+
if (error instanceof DeepInterviewCommandError) throw error;
|
|
314
|
+
throw new DeepInterviewCommandError(2, `failed to read ${label}`);
|
|
315
|
+
} finally {
|
|
316
|
+
await handle?.close().catch(() => undefined);
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function boundedUtf8(bytes: Buffer, label: string): string {
|
|
321
|
+
try {
|
|
322
|
+
return new TextDecoder("utf-8", { fatal: true }).decode(bytes);
|
|
323
|
+
} catch {
|
|
324
|
+
throw new DeepInterviewCommandError(2, `${label} is not valid UTF-8`);
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
function crystalSpecsRoot(cwd: string, sessionId: string): string {
|
|
329
|
+
return path.resolve(sessionSpecsDir(cwd, sessionId));
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function canonicalPublicationPath(cwd: string, sessionId: string, value: string): string {
|
|
333
|
+
const canonical = path.resolve(value);
|
|
334
|
+
if (!isPathWithin(crystalSpecsRoot(cwd, sessionId), canonical))
|
|
335
|
+
throw new DeepInterviewCommandError(2, "Crystal publication path escapes the session specs directory");
|
|
336
|
+
return canonical;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
async function readCrystalIndexCatalog(
|
|
340
|
+
cwd: string,
|
|
341
|
+
sessionId: string,
|
|
342
|
+
indexPath: string,
|
|
343
|
+
): Promise<CrystalIndexCatalog> {
|
|
344
|
+
const bytes = await readBoundedFileBytes(indexPath, CRYSTAL_INDEX_RECOVERY_BYTES, "Crystal index", {
|
|
345
|
+
allowMissing: true,
|
|
346
|
+
});
|
|
347
|
+
if (!bytes) return { rows: [] };
|
|
348
|
+
const text = boundedUtf8(bytes, "Crystal index");
|
|
349
|
+
const rows: CrystalIndexRow[] = [];
|
|
350
|
+
const pathHashes = new Map<string, string>();
|
|
351
|
+
for (const line of text.split(/\r?\n/)) {
|
|
352
|
+
if (!line.trim()) continue;
|
|
353
|
+
let parsed: unknown;
|
|
354
|
+
try {
|
|
355
|
+
parsed = JSON.parse(line);
|
|
356
|
+
} catch {
|
|
357
|
+
throw new DeepInterviewCommandError(2, "Crystal index contains malformed JSON");
|
|
358
|
+
}
|
|
359
|
+
if (!isRecord(parsed)) throw new DeepInterviewCommandError(2, "Crystal index contains a malformed row");
|
|
360
|
+
const slug = parsed.slug;
|
|
361
|
+
const stage = parsed.stage;
|
|
362
|
+
const rowPath = parsed.path;
|
|
363
|
+
const createdAt = parsed.created_at;
|
|
364
|
+
const sha256 = parsed.sha256;
|
|
365
|
+
if (
|
|
366
|
+
typeof slug !== "string" ||
|
|
367
|
+
slug.trim() === "" ||
|
|
368
|
+
typeof stage !== "string" ||
|
|
369
|
+
stage !== "final" ||
|
|
370
|
+
typeof rowPath !== "string" ||
|
|
371
|
+
typeof createdAt !== "string" ||
|
|
372
|
+
createdAt.trim() === "" ||
|
|
373
|
+
typeof sha256 !== "string" ||
|
|
374
|
+
!/^[a-f0-9]{64}$/.test(sha256)
|
|
375
|
+
)
|
|
376
|
+
throw new DeepInterviewCommandError(2, "Crystal index contains a malformed row");
|
|
377
|
+
try {
|
|
378
|
+
assertSafePathComponent(slug, "Crystal index slug");
|
|
379
|
+
} catch {
|
|
380
|
+
throw new DeepInterviewCommandError(2, "Crystal index contains an unsafe slug");
|
|
381
|
+
}
|
|
382
|
+
const canonicalPath = canonicalPublicationPath(cwd, sessionId, rowPath);
|
|
383
|
+
const previousHash = pathHashes.get(canonicalPath);
|
|
384
|
+
// Versioned Crystal artifacts are immutable. A differing hash for one
|
|
385
|
+
// path therefore proves a conflicting ledger row. Direct spec writes are
|
|
386
|
+
// intentionally mutable and retain their append-only history.
|
|
387
|
+
if (previousHash && previousHash !== sha256 && /-v[0-9]+\.md$/.test(path.basename(canonicalPath)))
|
|
388
|
+
throw new DeepInterviewCommandError(2, "Crystal index contains conflicting rows");
|
|
389
|
+
pathHashes.set(canonicalPath, sha256);
|
|
390
|
+
rows.push({
|
|
391
|
+
slug: slug.trim(),
|
|
392
|
+
stage: "final",
|
|
393
|
+
path: rowPath,
|
|
394
|
+
created_at: createdAt,
|
|
395
|
+
sha256,
|
|
396
|
+
canonicalPath,
|
|
397
|
+
});
|
|
398
|
+
}
|
|
399
|
+
if (bytes.length > CRYSTAL_MAX_INDEX_BYTES) {
|
|
400
|
+
const protectedPaths = new Set<string>();
|
|
401
|
+
const currentState = await readExistingStateForMutation(modeStatePath(cwd, sessionId, "deep-interview"));
|
|
402
|
+
if (currentState.kind === "valid" && typeof currentState.value.spec_path === "string")
|
|
403
|
+
protectedPaths.add(path.resolve(currentState.value.spec_path));
|
|
404
|
+
const compacted = compactCrystalIndexRows(rows, protectedPaths);
|
|
405
|
+
await writeArtifact(indexPath, serializeCrystalIndexRows(compacted), {
|
|
406
|
+
cwd,
|
|
407
|
+
audit: { category: "ledger", verb: "write", owner: "gjc-runtime", skill: "deep-interview", sessionId },
|
|
408
|
+
});
|
|
409
|
+
return { rows: compacted };
|
|
410
|
+
}
|
|
411
|
+
return { rows };
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
async function appendCrystalIndexRow(
|
|
415
|
+
cwd: string,
|
|
416
|
+
sessionId: string,
|
|
417
|
+
indexPath: string,
|
|
418
|
+
row: Omit<CrystalIndexRow, "canonicalPath">,
|
|
419
|
+
protectedPaths: readonly string[] = [],
|
|
420
|
+
): Promise<void> {
|
|
421
|
+
await withWorkflowStateLock(
|
|
422
|
+
indexPath,
|
|
423
|
+
async () => {
|
|
424
|
+
const catalog = await readCrystalIndexCatalog(cwd, sessionId, indexPath);
|
|
425
|
+
const canonicalRow: CrystalIndexRow = {
|
|
426
|
+
...row,
|
|
427
|
+
canonicalPath: canonicalPublicationPath(cwd, sessionId, row.path),
|
|
428
|
+
};
|
|
429
|
+
const line = `${JSON.stringify(row)}\n`;
|
|
430
|
+
const currentSize = await fs
|
|
431
|
+
.stat(indexPath)
|
|
432
|
+
.then(stat => stat.size)
|
|
433
|
+
.catch(error => {
|
|
434
|
+
if (isErrnoCode(error, "ENOENT")) return 0;
|
|
435
|
+
throw error;
|
|
436
|
+
});
|
|
437
|
+
if (currentSize + Buffer.byteLength(line, "utf-8") <= CRYSTAL_MAX_INDEX_BYTES) {
|
|
438
|
+
await appendJsonl(indexPath, row, {
|
|
439
|
+
cwd,
|
|
440
|
+
audit: { category: "ledger", verb: "append", owner: "gjc-runtime", skill: "deep-interview", sessionId },
|
|
441
|
+
});
|
|
442
|
+
return;
|
|
443
|
+
}
|
|
444
|
+
const protectedCanonicalPaths = new Set(protectedPaths.map(value => path.resolve(value)));
|
|
445
|
+
protectedCanonicalPaths.add(canonicalRow.canonicalPath);
|
|
446
|
+
const compacted = compactCrystalIndexRows([...catalog.rows, canonicalRow], protectedCanonicalPaths);
|
|
447
|
+
await writeArtifact(indexPath, serializeCrystalIndexRows(compacted), {
|
|
448
|
+
cwd,
|
|
449
|
+
audit: { category: "ledger", verb: "write", owner: "gjc-runtime", skill: "deep-interview", sessionId },
|
|
450
|
+
});
|
|
451
|
+
},
|
|
452
|
+
{ cwd },
|
|
453
|
+
);
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
function indexRowsForPath(catalog: CrystalIndexCatalog, targetPath: string): CrystalIndexRow[] {
|
|
457
|
+
const canonical = path.resolve(targetPath);
|
|
458
|
+
return catalog.rows.filter(row => row.canonicalPath === canonical);
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
async function verifyPublishedArtifactIndex(options: {
|
|
462
|
+
cwd: string;
|
|
463
|
+
sessionId: string;
|
|
464
|
+
indexPath: string;
|
|
465
|
+
specPath: string;
|
|
466
|
+
specHash: string;
|
|
467
|
+
crystal?: DeepInterviewCrystal;
|
|
468
|
+
}): Promise<CrystalIndexCatalog> {
|
|
469
|
+
if (!/^[a-f0-9]{64}$/.test(options.specHash))
|
|
470
|
+
throw new DeepInterviewCommandError(2, "published Crystal hash is invalid");
|
|
471
|
+
const canonicalSpecPath = canonicalPublicationPath(options.cwd, options.sessionId, options.specPath);
|
|
472
|
+
const catalog = await readCrystalIndexCatalog(options.cwd, options.sessionId, options.indexPath);
|
|
473
|
+
const matchingRows = indexRowsForPath(catalog, canonicalSpecPath);
|
|
474
|
+
if (!matchingRows.some(row => row.sha256 === options.specHash))
|
|
475
|
+
throw new DeepInterviewCommandError(2, "published Crystal index verification failed");
|
|
476
|
+
const artifact = await readBoundedFileBytes(
|
|
477
|
+
canonicalSpecPath,
|
|
478
|
+
CRYSTAL_MAX_ARTIFACT_BYTES,
|
|
479
|
+
"published Crystal artifact",
|
|
480
|
+
);
|
|
481
|
+
if (!artifact) throw new DeepInterviewCommandError(2, "published Crystal artifact is missing");
|
|
482
|
+
const actualHash = createHash("sha256").update(artifact).digest("hex");
|
|
483
|
+
if (actualHash !== options.specHash)
|
|
484
|
+
throw new DeepInterviewCommandError(2, "published Crystal artifact verification failed");
|
|
485
|
+
if (options.crystal) {
|
|
486
|
+
const expectedHash = createHash("sha256").update(crystalMarkdown(options.crystal)).digest("hex");
|
|
487
|
+
if (expectedHash !== options.specHash)
|
|
488
|
+
throw new DeepInterviewCommandError(2, "published Crystal artifact does not match stored Crystal");
|
|
489
|
+
}
|
|
490
|
+
return catalog;
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
async function readCrystallizeInput(rawInput: string | undefined, cwd: string): Promise<Record<string, unknown>> {
|
|
494
|
+
if (!rawInput?.trim())
|
|
495
|
+
throw new DeepInterviewCommandError(2, "--input is required for deep-interview --crystallize");
|
|
496
|
+
let raw = rawInput.trim();
|
|
497
|
+
if (raw.startsWith("@")) {
|
|
498
|
+
const filePath = path.resolve(cwd, raw.slice(1));
|
|
499
|
+
try {
|
|
500
|
+
const stat = await fs.stat(filePath);
|
|
501
|
+
if (!stat.isFile() || stat.size > 1_000_000) throw new Error("input file exceeds 1 MiB");
|
|
502
|
+
raw = await fs.readFile(filePath, "utf-8");
|
|
503
|
+
} catch (error) {
|
|
504
|
+
throw new DeepInterviewCommandError(
|
|
505
|
+
2,
|
|
506
|
+
`failed to read --input file ${filePath}: ${error instanceof Error ? error.message : String(error)}`,
|
|
507
|
+
);
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
if ([...raw].length > 1_000_000) throw new DeepInterviewCommandError(2, "crystallize input exceeds 1 MiB");
|
|
511
|
+
let parsed: unknown;
|
|
512
|
+
try {
|
|
513
|
+
parsed = JSON.parse(raw);
|
|
514
|
+
} catch (error) {
|
|
515
|
+
throw new DeepInterviewCommandError(
|
|
516
|
+
2,
|
|
517
|
+
`--input is not valid JSON: ${error instanceof Error ? error.message : String(error)}`,
|
|
518
|
+
);
|
|
519
|
+
}
|
|
520
|
+
assertDeepInterviewStructuredResponseWithinLimit(parsed);
|
|
521
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
|
|
522
|
+
throw new DeepInterviewCommandError(2, "crystallize input must be a JSON object");
|
|
523
|
+
return parsed as Record<string, unknown>;
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
function activeSessionEntries(entries: FileEntry[]): FileEntry[] {
|
|
527
|
+
const body = entries.slice(1);
|
|
528
|
+
if (body.length === 0) return body;
|
|
529
|
+
const hasBranchMetadata = body.some(entry => "id" in entry || "parentId" in entry);
|
|
530
|
+
if (!hasBranchMetadata) return body;
|
|
531
|
+
const byId = new Map<string, FileEntry>();
|
|
532
|
+
for (const entry of body) {
|
|
533
|
+
const id = (entry as { id?: unknown }).id;
|
|
534
|
+
const parentId = (entry as { parentId?: unknown }).parentId;
|
|
535
|
+
if (
|
|
536
|
+
typeof id !== "string" ||
|
|
537
|
+
id.trim() === "" ||
|
|
538
|
+
!("parentId" in entry) ||
|
|
539
|
+
(parentId !== null && typeof parentId !== "string")
|
|
540
|
+
)
|
|
541
|
+
throw new DeepInterviewCommandError(2, "live session transcript branch metadata is malformed");
|
|
542
|
+
if (byId.has(id)) throw new DeepInterviewCommandError(2, "live session transcript branch contains duplicate IDs");
|
|
543
|
+
byId.set(id, entry);
|
|
544
|
+
}
|
|
545
|
+
for (const entry of body) {
|
|
546
|
+
const parentId = (entry as { parentId: string | null }).parentId;
|
|
547
|
+
if (parentId !== null && !byId.has(parentId))
|
|
548
|
+
throw new DeepInterviewCommandError(2, "live session transcript branch has a missing parent");
|
|
549
|
+
}
|
|
550
|
+
const branch: typeof body = [];
|
|
551
|
+
const visited = new Set<string>();
|
|
552
|
+
let current = body.at(-1);
|
|
553
|
+
while (current) {
|
|
554
|
+
const id = (current as { id: string }).id;
|
|
555
|
+
if (visited.has(id)) throw new DeepInterviewCommandError(2, "live session transcript branch contains a cycle");
|
|
556
|
+
visited.add(id);
|
|
557
|
+
branch.push(current);
|
|
558
|
+
const parentId = (current as { parentId: string | null }).parentId;
|
|
559
|
+
if (parentId === null) break;
|
|
560
|
+
current = byId.get(parentId);
|
|
561
|
+
if (!current) throw new DeepInterviewCommandError(2, "live session transcript branch has a missing parent");
|
|
562
|
+
}
|
|
563
|
+
branch.reverse();
|
|
564
|
+
return branch;
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
function validateTranscriptRecords(records: unknown[]): void {
|
|
568
|
+
if (records.length === 0 || !isRecord(records[0]) || records[0].type !== "session")
|
|
569
|
+
throw new DeepInterviewCommandError(2, "live session transcript must begin with one session header");
|
|
570
|
+
const headers = records.filter(record => isRecord(record) && record.type === "session");
|
|
571
|
+
if (headers.length !== 1)
|
|
572
|
+
throw new DeepInterviewCommandError(2, "live session transcript has multiple session headers");
|
|
573
|
+
const header = records[0] as Record<string, unknown>;
|
|
574
|
+
if (typeof header.id !== "string" || header.id.trim() === "" || typeof header.cwd !== "string")
|
|
575
|
+
throw new DeepInterviewCommandError(2, "live session transcript header is malformed");
|
|
576
|
+
const version = header.version === undefined ? 1 : header.version;
|
|
577
|
+
if (typeof version !== "number" || !Number.isSafeInteger(version) || version < 1)
|
|
578
|
+
throw new DeepInterviewCommandError(2, "live session transcript header is malformed");
|
|
579
|
+
const body = records.slice(1).filter((record): record is Record<string, unknown> => isRecord(record));
|
|
580
|
+
if (body.length !== records.length - 1)
|
|
581
|
+
throw new DeepInterviewCommandError(2, "live session transcript contains a malformed entry");
|
|
582
|
+
const entries = body.filter(record => record.type !== "header_patch" && record.type !== "entry_patch");
|
|
583
|
+
const hasBranchMetadata = entries.some(entry => "id" in entry || "parentId" in entry);
|
|
584
|
+
if (version >= 2 || hasBranchMetadata) {
|
|
585
|
+
const ids = new Set<string>();
|
|
586
|
+
for (const entry of entries) {
|
|
587
|
+
const id = entry.id;
|
|
588
|
+
const parentId = entry.parentId;
|
|
589
|
+
if (
|
|
590
|
+
typeof entry.type !== "string" ||
|
|
591
|
+
typeof id !== "string" ||
|
|
592
|
+
id.trim() === "" ||
|
|
593
|
+
!("parentId" in entry) ||
|
|
594
|
+
(parentId !== null && typeof parentId !== "string")
|
|
595
|
+
)
|
|
596
|
+
throw new DeepInterviewCommandError(2, "live session transcript branch metadata is malformed");
|
|
597
|
+
if (ids.has(id))
|
|
598
|
+
throw new DeepInterviewCommandError(2, "live session transcript branch contains duplicate IDs");
|
|
599
|
+
ids.add(id);
|
|
600
|
+
}
|
|
601
|
+
for (const entry of entries) {
|
|
602
|
+
const parentId = entry.parentId;
|
|
603
|
+
if (parentId !== null && !ids.has(parentId as string))
|
|
604
|
+
throw new DeepInterviewCommandError(2, "live session transcript branch has a missing parent");
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
for (const record of body) {
|
|
608
|
+
if (record.type === "header_patch") {
|
|
609
|
+
if (!isRecord(record.patch) || !Object.keys(record).every(key => key === "type" || key === "patch"))
|
|
610
|
+
throw new DeepInterviewCommandError(2, "live session transcript contains malformed branch metadata");
|
|
611
|
+
}
|
|
612
|
+
if (record.type === "entry_patch") {
|
|
613
|
+
if (
|
|
614
|
+
typeof record.entryId !== "string" ||
|
|
615
|
+
!isRecord(record.patch) ||
|
|
616
|
+
!Object.keys(record).every(key => key === "type" || key === "entryId" || key === "patch")
|
|
617
|
+
)
|
|
618
|
+
throw new DeepInterviewCommandError(2, "live session transcript contains malformed branch metadata");
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
/**
|
|
624
|
+
* Crystallization treats a project transcript as authoritative conversation
|
|
625
|
+
* evidence, so only transcripts written by the session manager's managed
|
|
626
|
+
* containers (`agent-session/`, `sessions/`) are eligible. The shared project
|
|
627
|
+
* classifier additionally accepts legacy root-level `.gjc/*.jsonl` files, which
|
|
628
|
+
* any repository content can forge with a matching header; those are rejected
|
|
629
|
+
* here before evidence or approval binding.
|
|
630
|
+
*/
|
|
631
|
+
function isManagedProjectSessionTranscriptPath(projectGjcDir: string, filePath: string): boolean {
|
|
632
|
+
if (!isProjectSessionTranscriptPath(projectGjcDir, filePath)) return false;
|
|
633
|
+
const relative = path.relative(projectGjcDir, filePath);
|
|
634
|
+
const segments = relative.split(path.sep);
|
|
635
|
+
return segments.length >= 2;
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
export async function authoritativeConversationSnapshot(
|
|
639
|
+
cwd: string,
|
|
640
|
+
sessionId: string,
|
|
641
|
+
): Promise<{
|
|
642
|
+
revision: number;
|
|
643
|
+
messages: CrystalSnapshot["messages"];
|
|
644
|
+
transcriptPath: string;
|
|
645
|
+
transcriptSha256: string;
|
|
646
|
+
}> {
|
|
647
|
+
let sessionFile = process.env.GJC_SESSION_FILE?.trim();
|
|
648
|
+
let explicitProjectTranscript = false;
|
|
649
|
+
if (sessionFile) {
|
|
650
|
+
sessionFile = path.resolve(cwd, sessionFile);
|
|
651
|
+
try {
|
|
652
|
+
const explicitStat = await fs.lstat(sessionFile);
|
|
653
|
+
if (!explicitStat.isFile() || explicitStat.isSymbolicLink())
|
|
654
|
+
throw new Error("symlink or non-regular transcript");
|
|
655
|
+
const explicitRealPath = await fs.realpath(sessionFile);
|
|
656
|
+
if (explicitRealPath !== sessionFile) throw new Error("symlink transcript");
|
|
657
|
+
sessionFile = explicitRealPath;
|
|
658
|
+
// Try authorized read first (atomic identity-bound read); if it fails,
|
|
659
|
+
// the transcript is either unauthorized or not a managed project transcript.
|
|
660
|
+
const authorizedBytes = readAuthorizedProjectSessionTranscript(
|
|
661
|
+
path.resolve(cwd, ".gjc"),
|
|
662
|
+
explicitRealPath,
|
|
663
|
+
RESUME_TRANSCRIPT_MAX_BYTES,
|
|
664
|
+
);
|
|
665
|
+
explicitProjectTranscript = !!authorizedBytes;
|
|
666
|
+
if (!explicitProjectTranscript) throw new Error("unauthorized project transcript");
|
|
667
|
+
} catch {
|
|
668
|
+
throw new DeepInterviewCommandError(2, "GJC_SESSION_FILE is not a managed canonical session transcript");
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
const canonicalCandidates = new Set<string>();
|
|
672
|
+
const managedCandidates = new Set<string>();
|
|
673
|
+
const lexicalCandidates = new Set<string>();
|
|
674
|
+
if (!sessionFile) {
|
|
675
|
+
const projectGjcDir = path.resolve(cwd, ".gjc");
|
|
676
|
+
for (const candidate of listProjectSessionTranscriptFiles(cwd)) {
|
|
677
|
+
const resolved = path.resolve(candidate);
|
|
678
|
+
// Legacy root-level `.gjc/*.jsonl` files are listed for session resume
|
|
679
|
+
// compatibility, but they are not trusted-writer provenance, so they can
|
|
680
|
+
// never become authoritative crystallization evidence.
|
|
681
|
+
if (isManagedProjectSessionTranscriptPath(projectGjcDir, resolved)) lexicalCandidates.add(resolved);
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
if (!explicitProjectTranscript) {
|
|
685
|
+
const managedScope = await resolveManagedSessionScope({ cwd });
|
|
686
|
+
if (managedScope.kind === "error")
|
|
687
|
+
throw new DeepInterviewCommandError(2, "managed session transcript scope is unavailable");
|
|
688
|
+
if (managedScope.kind === "resolved") {
|
|
689
|
+
const managedListing = await listManagedSessionCandidates({ scope: managedScope.scope });
|
|
690
|
+
if (managedListing.kind === "error")
|
|
691
|
+
throw new DeepInterviewCommandError(2, "managed session transcript listing is unavailable");
|
|
692
|
+
for (const candidate of managedListing.owned) {
|
|
693
|
+
const resolved = path.resolve(candidate.path);
|
|
694
|
+
managedCandidates.add(resolved);
|
|
695
|
+
lexicalCandidates.add(resolved);
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
// The native command accepts an explicit workspace cwd, which may differ from
|
|
700
|
+
// process.cwd(). Resolve relative managed transcript paths against that same
|
|
701
|
+
// workspace so transcript identity and content cannot drift with process launch location.
|
|
702
|
+
if (sessionFile) {
|
|
703
|
+
if (!explicitProjectTranscript && !managedCandidates.has(sessionFile))
|
|
704
|
+
throw new DeepInterviewCommandError(2, "GJC_SESSION_FILE is not a managed canonical session transcript");
|
|
705
|
+
} else {
|
|
706
|
+
if (lexicalCandidates.size > 1000)
|
|
707
|
+
throw new DeepInterviewCommandError(2, "session transcript discovery exceeded the bounded candidate limit");
|
|
708
|
+
for (const candidate of [...lexicalCandidates].sort()) {
|
|
709
|
+
try {
|
|
710
|
+
const stat = await fs.lstat(candidate);
|
|
711
|
+
if (!stat.isFile() || stat.isSymbolicLink()) continue;
|
|
712
|
+
const realPath = await fs.realpath(candidate);
|
|
713
|
+
if (realPath !== candidate) continue;
|
|
714
|
+
canonicalCandidates.add(realPath);
|
|
715
|
+
} catch {}
|
|
716
|
+
}
|
|
717
|
+
for (const candidate of [...canonicalCandidates].sort()) {
|
|
718
|
+
try {
|
|
719
|
+
const projectGjcDir = path.resolve(cwd, ".gjc");
|
|
720
|
+
// Try authorized project session transcript first (atomic identity-bound read),
|
|
721
|
+
// then fall back to general bounded read for non-managed transcripts.
|
|
722
|
+
let bytes = readAuthorizedProjectSessionTranscript(projectGjcDir, candidate, RESUME_TRANSCRIPT_MAX_BYTES);
|
|
723
|
+
if (!bytes) {
|
|
724
|
+
bytes = await readBoundedFileBytes(candidate, RESUME_TRANSCRIPT_MAX_BYTES, "session transcript", {
|
|
725
|
+
allowMissing: true,
|
|
726
|
+
});
|
|
727
|
+
}
|
|
728
|
+
if (!bytes) continue;
|
|
729
|
+
const header = JSON.parse(boundedUtf8(bytes, "session transcript").split(/\r?\n/, 1)[0]) as Record<
|
|
730
|
+
string,
|
|
731
|
+
unknown
|
|
732
|
+
>;
|
|
733
|
+
if (
|
|
734
|
+
header.id === sessionId &&
|
|
735
|
+
typeof header.cwd === "string" &&
|
|
736
|
+
path.resolve(header.cwd) === path.resolve(cwd)
|
|
737
|
+
) {
|
|
738
|
+
sessionFile = candidate;
|
|
739
|
+
break;
|
|
740
|
+
}
|
|
741
|
+
} catch {}
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
if (!sessionFile)
|
|
745
|
+
throw new DeepInterviewCommandError(2, "an authenticated session transcript is required for crystallization");
|
|
746
|
+
try {
|
|
747
|
+
const projectGjcDir = path.resolve(cwd, ".gjc");
|
|
748
|
+
// Try authorized project session transcript first (atomic identity-bound read),
|
|
749
|
+
// then fall back to general bounded read for non-managed transcripts.
|
|
750
|
+
let bytes = readAuthorizedProjectSessionTranscript(projectGjcDir, sessionFile, RESUME_TRANSCRIPT_MAX_BYTES);
|
|
751
|
+
if (!bytes) {
|
|
752
|
+
bytes = await readBoundedFileBytes(sessionFile, RESUME_TRANSCRIPT_MAX_BYTES, "live session transcript");
|
|
753
|
+
}
|
|
754
|
+
if (!bytes) throw new DeepInterviewCommandError(2, "live session transcript is unavailable");
|
|
755
|
+
const text = boundedUtf8(bytes, "live session transcript");
|
|
756
|
+
const records: unknown[] = [];
|
|
757
|
+
for (const line of text.split(/\r?\n/)) {
|
|
758
|
+
if (!line.trim()) continue;
|
|
759
|
+
let parsed: unknown;
|
|
760
|
+
try {
|
|
761
|
+
parsed = JSON.parse(line);
|
|
762
|
+
} catch {
|
|
763
|
+
throw new DeepInterviewCommandError(2, "live session transcript is malformed");
|
|
764
|
+
}
|
|
765
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
|
|
766
|
+
throw new DeepInterviewCommandError(2, "live session transcript is malformed");
|
|
767
|
+
records.push(parsed);
|
|
768
|
+
}
|
|
769
|
+
validateTranscriptRecords(records);
|
|
770
|
+
const entries = parseSessionEntries(text);
|
|
771
|
+
const header = entries[0];
|
|
772
|
+
if (
|
|
773
|
+
header?.type !== "session" ||
|
|
774
|
+
header?.id !== sessionId ||
|
|
775
|
+
typeof header?.cwd !== "string" ||
|
|
776
|
+
path.resolve(header.cwd) !== path.resolve(cwd)
|
|
777
|
+
)
|
|
778
|
+
throw new DeepInterviewCommandError(2, "live session transcript identity mismatch");
|
|
779
|
+
const messages: CrystalSnapshot["messages"] = [];
|
|
780
|
+
for (const entry of activeSessionEntries(entries)) {
|
|
781
|
+
if (entry.type !== "message") continue;
|
|
782
|
+
const index = messages.length;
|
|
783
|
+
const message = entry.message as unknown;
|
|
784
|
+
if (!isRecord(message) || typeof message.role !== "string")
|
|
785
|
+
throw new DeepInterviewCommandError(2, "live session transcript contains a malformed message");
|
|
786
|
+
const role =
|
|
787
|
+
message.role === "user" && (message.attribution === "agent" || message.synthetic === true)
|
|
788
|
+
? "developer"
|
|
789
|
+
: ["custom", "hookMessage"].includes(message.role)
|
|
790
|
+
? "system"
|
|
791
|
+
: ["bashExecution", "pythonExecution", "fileMention"].includes(message.role)
|
|
792
|
+
? "tool"
|
|
793
|
+
: message.role;
|
|
794
|
+
if (!["user", "assistant", "system", "developer", "tool", "toolResult"].includes(role))
|
|
795
|
+
throw new DeepInterviewCommandError(2, "live session transcript contains an unsupported message role");
|
|
796
|
+
let projectedContent: string;
|
|
797
|
+
if (["bashExecution", "pythonExecution", "fileMention"].includes(message.role)) {
|
|
798
|
+
projectedContent = `[${message.role} sha256:${createHash("sha256").update(JSON.stringify(message)).digest("hex")}]`;
|
|
799
|
+
} else if (typeof message.content === "string") {
|
|
800
|
+
projectedContent = message.content;
|
|
801
|
+
} else if (Array.isArray(message.content)) {
|
|
802
|
+
const projectedParts: string[] = [];
|
|
803
|
+
for (const item of message.content) {
|
|
804
|
+
if (!isRecord(item) || typeof item.type !== "string")
|
|
805
|
+
throw new DeepInterviewCommandError(
|
|
806
|
+
2,
|
|
807
|
+
"live session transcript contains unsupported message content",
|
|
808
|
+
);
|
|
809
|
+
if (item.type === "text") {
|
|
810
|
+
if (typeof item.text !== "string")
|
|
811
|
+
throw new DeepInterviewCommandError(2, "live session transcript contains malformed text content");
|
|
812
|
+
projectedParts.push(item.text);
|
|
813
|
+
} else if (DEEP_INTERVIEW_NON_TEXT_CONTENT_TYPES.has(item.type)) {
|
|
814
|
+
projectedParts.push(`[${item.type}]`);
|
|
815
|
+
} else {
|
|
816
|
+
throw new DeepInterviewCommandError(
|
|
817
|
+
2,
|
|
818
|
+
"live session transcript contains unsupported message content",
|
|
819
|
+
);
|
|
820
|
+
}
|
|
821
|
+
}
|
|
822
|
+
projectedContent = projectedParts.join("");
|
|
823
|
+
} else {
|
|
824
|
+
throw new DeepInterviewCommandError(2, "live session transcript contains malformed message content");
|
|
825
|
+
}
|
|
826
|
+
messages.push({
|
|
827
|
+
index,
|
|
828
|
+
role: role as CrystalSnapshot["messages"][number]["role"],
|
|
829
|
+
content: projectedContent.normalize("NFC").trim(),
|
|
830
|
+
});
|
|
831
|
+
}
|
|
832
|
+
if (messages.length === 0) throw new DeepInterviewCommandError(2, "live session transcript has no messages");
|
|
833
|
+
return {
|
|
834
|
+
revision: messages.length,
|
|
835
|
+
messages,
|
|
836
|
+
transcriptPath: path.resolve(sessionFile),
|
|
837
|
+
transcriptSha256: createHash("sha256").update(bytes).digest("hex"),
|
|
838
|
+
};
|
|
839
|
+
} catch (error) {
|
|
840
|
+
if (error instanceof DeepInterviewCommandError) throw error;
|
|
841
|
+
throw new DeepInterviewCommandError(2, "live session transcript is unavailable");
|
|
842
|
+
}
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
function parseCrystalMutationId(mutationId: string, sessionId: string): { specVersion: number } {
|
|
846
|
+
const prefix = `crystal:${sessionId}:`;
|
|
847
|
+
if (!mutationId.startsWith(prefix))
|
|
848
|
+
throw new DeepInterviewCommandError(2, "Crystal transaction journal identity mismatch");
|
|
849
|
+
const suffix = mutationId.slice(prefix.length);
|
|
850
|
+
const separator = suffix.indexOf(":");
|
|
851
|
+
if (
|
|
852
|
+
separator <= 0 ||
|
|
853
|
+
!/^\d+$/.test(suffix.slice(0, separator)) ||
|
|
854
|
+
!/^[a-f0-9]{64}$/.test(suffix.slice(separator + 1))
|
|
855
|
+
)
|
|
856
|
+
throw new DeepInterviewCommandError(2, "pending Crystal transaction journal is invalid");
|
|
857
|
+
const specVersion = Number(suffix.slice(0, separator));
|
|
858
|
+
if (!Number.isSafeInteger(specVersion) || specVersion < 1)
|
|
859
|
+
throw new DeepInterviewCommandError(2, "pending Crystal transaction journal is invalid");
|
|
860
|
+
return { specVersion };
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
function validateCrystalJournalRecord(
|
|
864
|
+
value: unknown,
|
|
865
|
+
cwd: string,
|
|
866
|
+
sessionId: string,
|
|
867
|
+
indexPath: string,
|
|
868
|
+
statePath: string,
|
|
869
|
+
): CrystalJournalRecord {
|
|
870
|
+
if (!isRecord(value)) throw new DeepInterviewCommandError(2, "pending Crystal transaction journal is invalid");
|
|
871
|
+
if (value.version !== 1 || typeof value.mutation_id !== "string")
|
|
872
|
+
throw new DeepInterviewCommandError(2, "pending Crystal transaction journal is invalid");
|
|
873
|
+
parseCrystalMutationId(value.mutation_id, sessionId);
|
|
874
|
+
if (value.status !== "pending" && value.status !== "committed")
|
|
875
|
+
throw new DeepInterviewCommandError(2, "pending Crystal transaction journal is invalid");
|
|
876
|
+
if (
|
|
877
|
+
typeof value.created_at !== "string" ||
|
|
878
|
+
typeof value.updated_at !== "string" ||
|
|
879
|
+
!Array.isArray(value.paths) ||
|
|
880
|
+
value.paths.length !== 3 ||
|
|
881
|
+
!Array.isArray(value.steps) ||
|
|
882
|
+
value.steps.some(step => typeof step !== "string")
|
|
883
|
+
)
|
|
884
|
+
throw new DeepInterviewCommandError(2, "pending Crystal transaction journal is invalid");
|
|
885
|
+
const [specPath, journalIndexPath, journalStatePath] = value.paths;
|
|
886
|
+
if (
|
|
887
|
+
typeof specPath !== "string" ||
|
|
888
|
+
typeof journalIndexPath !== "string" ||
|
|
889
|
+
typeof journalStatePath !== "string" ||
|
|
890
|
+
path.resolve(journalIndexPath) !== path.resolve(indexPath) ||
|
|
891
|
+
path.resolve(journalStatePath) !== path.resolve(statePath)
|
|
892
|
+
)
|
|
893
|
+
throw new DeepInterviewCommandError(2, "pending Crystal transaction journal identity mismatch");
|
|
894
|
+
canonicalPublicationPath(cwd, sessionId, specPath);
|
|
895
|
+
const allowedSteps = new Set(["artifact", "index", "state"]);
|
|
896
|
+
const steps = value.steps as unknown[];
|
|
897
|
+
if (
|
|
898
|
+
new Set(steps).size !== steps.length ||
|
|
899
|
+
steps.some(step => !allowedSteps.has(step as string)) ||
|
|
900
|
+
(steps.includes("index") && !steps.includes("artifact")) ||
|
|
901
|
+
(steps.includes("state") && (!steps.includes("artifact") || !steps.includes("index")))
|
|
902
|
+
)
|
|
903
|
+
throw new DeepInterviewCommandError(2, "pending Crystal transaction journal steps are invalid");
|
|
904
|
+
if (
|
|
905
|
+
value.artifact_sha256 !== undefined &&
|
|
906
|
+
(typeof value.artifact_sha256 !== "string" || !/^[a-f0-9]{64}$/.test(value.artifact_sha256))
|
|
907
|
+
)
|
|
908
|
+
throw new DeepInterviewCommandError(2, "pending Crystal transaction journal hash is invalid");
|
|
909
|
+
return value as unknown as CrystalJournalRecord;
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
async function readCrystalJournal(
|
|
913
|
+
cwd: string,
|
|
914
|
+
sessionId: string,
|
|
915
|
+
mutationId: string,
|
|
916
|
+
indexPath: string,
|
|
917
|
+
statePath: string,
|
|
918
|
+
): Promise<CrystalJournalRecord | undefined> {
|
|
919
|
+
const journalPath = transactionJournalPath(cwd, sessionId, mutationId);
|
|
920
|
+
const bytes = await readBoundedFileBytes(journalPath, CRYSTAL_MAX_JOURNAL_BYTES, "Crystal transaction journal", {
|
|
921
|
+
allowMissing: true,
|
|
922
|
+
});
|
|
923
|
+
if (!bytes) return undefined;
|
|
924
|
+
let parsed: unknown;
|
|
925
|
+
try {
|
|
926
|
+
parsed = JSON.parse(boundedUtf8(bytes, "Crystal transaction journal"));
|
|
927
|
+
} catch {
|
|
928
|
+
throw new DeepInterviewCommandError(2, "pending Crystal transaction journal is corrupt");
|
|
929
|
+
}
|
|
930
|
+
return validateCrystalJournalRecord(parsed, cwd, sessionId, indexPath, statePath);
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
async function verifyPriorPendingCrystalJournals(options: {
|
|
934
|
+
cwd: string;
|
|
935
|
+
sessionId: string;
|
|
936
|
+
indexPath: string;
|
|
937
|
+
statePath: string;
|
|
938
|
+
existing: Record<string, unknown>;
|
|
939
|
+
currentMutationId?: string;
|
|
940
|
+
}): Promise<CrystalJournalRecord | undefined> {
|
|
941
|
+
const transactionsDir = path.dirname(transactionJournalPath(options.cwd, options.sessionId, "scan"));
|
|
942
|
+
let names: string[];
|
|
943
|
+
try {
|
|
944
|
+
names = (await fs.readdir(transactionsDir)).filter(name => name.endsWith(".json")).sort();
|
|
945
|
+
} catch (error) {
|
|
946
|
+
if (isErrnoCode(error, "ENOENT")) return undefined;
|
|
947
|
+
throw new DeepInterviewCommandError(2, "Crystal transaction journal discovery failed");
|
|
948
|
+
}
|
|
949
|
+
if (names.length > 128)
|
|
950
|
+
throw new DeepInterviewCommandError(2, "Crystal transaction journal discovery exceeded the bounded limit");
|
|
951
|
+
const catalog = await readCrystalIndexCatalog(options.cwd, options.sessionId, options.indexPath);
|
|
952
|
+
let current: CrystalJournalRecord | undefined;
|
|
953
|
+
for (const name of names) {
|
|
954
|
+
const journalPath = path.join(transactionsDir, name);
|
|
955
|
+
const bytes = await readBoundedFileBytes(journalPath, CRYSTAL_MAX_JOURNAL_BYTES, "Crystal transaction journal");
|
|
956
|
+
if (!bytes) continue;
|
|
957
|
+
let parsed: unknown;
|
|
958
|
+
try {
|
|
959
|
+
parsed = JSON.parse(boundedUtf8(bytes, "Crystal transaction journal"));
|
|
960
|
+
} catch {
|
|
961
|
+
throw new DeepInterviewCommandError(2, "pending Crystal transaction journal is corrupt");
|
|
962
|
+
}
|
|
963
|
+
if (!isRecord(parsed) || typeof parsed.mutation_id !== "string")
|
|
964
|
+
throw new DeepInterviewCommandError(2, "pending Crystal transaction journal is invalid");
|
|
965
|
+
if (!parsed.mutation_id.startsWith(`crystal:${options.sessionId}:`)) continue;
|
|
966
|
+
const journal = validateCrystalJournalRecord(
|
|
967
|
+
parsed,
|
|
968
|
+
options.cwd,
|
|
969
|
+
options.sessionId,
|
|
970
|
+
options.indexPath,
|
|
971
|
+
options.statePath,
|
|
972
|
+
);
|
|
973
|
+
if (journal.status !== "pending") continue;
|
|
974
|
+
if (journal.mutation_id === options.currentMutationId) {
|
|
975
|
+
current = journal;
|
|
976
|
+
continue;
|
|
977
|
+
}
|
|
978
|
+
const specPath = journal.paths[0]!;
|
|
979
|
+
const canonicalSpecPath = canonicalPublicationPath(options.cwd, options.sessionId, specPath);
|
|
980
|
+
const rows = indexRowsForPath(catalog, canonicalSpecPath);
|
|
981
|
+
let expectedHash = typeof journal.artifact_sha256 === "string" ? journal.artifact_sha256 : undefined;
|
|
982
|
+
for (const row of rows) {
|
|
983
|
+
if (expectedHash && expectedHash !== row.sha256)
|
|
984
|
+
throw new DeepInterviewCommandError(2, "pending Crystal transaction hash mismatch");
|
|
985
|
+
expectedHash = row.sha256;
|
|
986
|
+
}
|
|
987
|
+
const existingSpecPath = typeof options.existing.spec_path === "string" ? options.existing.spec_path : undefined;
|
|
988
|
+
const existingSpecHash =
|
|
989
|
+
typeof options.existing.spec_sha256 === "string" ? options.existing.spec_sha256 : undefined;
|
|
990
|
+
if (existingSpecPath && path.resolve(existingSpecPath) === canonicalSpecPath) {
|
|
991
|
+
if (expectedHash && existingSpecHash && expectedHash !== existingSpecHash)
|
|
992
|
+
throw new DeepInterviewCommandError(2, "pending Crystal transaction hash mismatch");
|
|
993
|
+
expectedHash = existingSpecHash ?? expectedHash;
|
|
994
|
+
}
|
|
995
|
+
if (!expectedHash) throw new DeepInterviewCommandError(2, "pending Crystal transaction cannot be authenticated");
|
|
996
|
+
const artifact = await readBoundedFileBytes(
|
|
997
|
+
canonicalSpecPath,
|
|
998
|
+
CRYSTAL_MAX_ARTIFACT_BYTES,
|
|
999
|
+
"pending Crystal artifact",
|
|
1000
|
+
);
|
|
1001
|
+
if (!artifact || createHash("sha256").update(artifact).digest("hex") !== expectedHash)
|
|
1002
|
+
throw new DeepInterviewCommandError(2, "pending Crystal artifact verification failed");
|
|
1003
|
+
if (!rows.some(row => row.sha256 === expectedHash))
|
|
1004
|
+
throw new DeepInterviewCommandError(2, "pending Crystal index verification failed");
|
|
1005
|
+
if (
|
|
1006
|
+
!existingSpecPath ||
|
|
1007
|
+
!existingSpecHash ||
|
|
1008
|
+
path.resolve(existingSpecPath) !== canonicalSpecPath ||
|
|
1009
|
+
existingSpecHash !== expectedHash
|
|
1010
|
+
)
|
|
1011
|
+
throw new DeepInterviewCommandError(2, "pending Crystal state verification failed");
|
|
1012
|
+
await completeWorkflowTransactionJournal(options.cwd, options.sessionId, journal.mutation_id);
|
|
1013
|
+
}
|
|
1014
|
+
return current;
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
const CRYSTAL_ROLES = new Set(["user", "assistant", "system", "tool", "toolResult", "developer"]);
|
|
1018
|
+
const CRYSTAL_LIFECYCLES = new Set(["ready", "needs-questions", "stale", "superseded"]);
|
|
1019
|
+
const CRYSTAL_DELTA_KINDS = new Set(["none", "additive", "intent-changed", "goal-replaced", "stale"]);
|
|
1020
|
+
const CRYSTAL_ITEM_KINDS = new Set(["goal", "constraint", "decision", "acceptance_criterion", "non_goal"]);
|
|
1021
|
+
const CRYSTAL_CLASSIFICATIONS = new Set(["confirmed", "inferred", "disputed"]);
|
|
1022
|
+
|
|
1023
|
+
function parseCrystalSnapshot(value: unknown, label: string): CrystalSnapshot {
|
|
1024
|
+
if (!isRecord(value)) throw new DeepInterviewCommandError(2, `${label} is malformed`);
|
|
1025
|
+
const revision = value.revision;
|
|
1026
|
+
const start = value.start;
|
|
1027
|
+
const end = value.end;
|
|
1028
|
+
if (
|
|
1029
|
+
typeof revision !== "number" ||
|
|
1030
|
+
!Number.isSafeInteger(revision) ||
|
|
1031
|
+
typeof start !== "number" ||
|
|
1032
|
+
!Number.isSafeInteger(start) ||
|
|
1033
|
+
typeof end !== "number" ||
|
|
1034
|
+
!Number.isSafeInteger(end) ||
|
|
1035
|
+
start < 0 ||
|
|
1036
|
+
end < start ||
|
|
1037
|
+
!Array.isArray(value.messages) ||
|
|
1038
|
+
value.messages.length !== end - start + 1 ||
|
|
1039
|
+
value.messages.length > CRYSTAL_MAX_MESSAGES
|
|
1040
|
+
)
|
|
1041
|
+
throw new DeepInterviewCommandError(2, `${label} is malformed`);
|
|
1042
|
+
const messages = value.messages.map((entry, index) => {
|
|
1043
|
+
if (!isRecord(entry)) throw new DeepInterviewCommandError(2, `${label} contains a malformed message`);
|
|
1044
|
+
const messageIndex = entry.index;
|
|
1045
|
+
const role = entry.role;
|
|
1046
|
+
const content = entry.content;
|
|
1047
|
+
if (
|
|
1048
|
+
typeof messageIndex !== "number" ||
|
|
1049
|
+
!Number.isSafeInteger(messageIndex) ||
|
|
1050
|
+
messageIndex !== start + index ||
|
|
1051
|
+
typeof role !== "string" ||
|
|
1052
|
+
!CRYSTAL_ROLES.has(role) ||
|
|
1053
|
+
typeof content !== "string"
|
|
1054
|
+
)
|
|
1055
|
+
throw new DeepInterviewCommandError(2, `${label} contains a malformed message`);
|
|
1056
|
+
return { index: messageIndex, role: role as CrystalSnapshot["messages"][number]["role"], content };
|
|
1057
|
+
});
|
|
1058
|
+
if (typeof value.digest !== "string" || !/^[a-f0-9]{64}$/.test(value.digest))
|
|
1059
|
+
throw new DeepInterviewCommandError(2, `${label} digest is invalid`);
|
|
1060
|
+
const snapshot = { revision, start, end, digest: value.digest, messages } as CrystalSnapshot;
|
|
1061
|
+
if (crystalSnapshotDigest(snapshot) !== snapshot.digest)
|
|
1062
|
+
throw new DeepInterviewCommandError(2, `${label} digest mismatch`);
|
|
1063
|
+
return snapshot;
|
|
1064
|
+
}
|
|
1065
|
+
|
|
1066
|
+
function parseStoredCrystal(value: unknown): DeepInterviewCrystal {
|
|
1067
|
+
if (!isRecord(value) || value.schema_version !== 1 || !Number.isSafeInteger(value.spec_version))
|
|
1068
|
+
throw new DeepInterviewCommandError(2, "stored Crystal is invalid");
|
|
1069
|
+
if (
|
|
1070
|
+
(value.spec_version as number) < 1 ||
|
|
1071
|
+
typeof value.lifecycle !== "string" ||
|
|
1072
|
+
!CRYSTAL_LIFECYCLES.has(value.lifecycle)
|
|
1073
|
+
)
|
|
1074
|
+
throw new DeepInterviewCommandError(2, "stored Crystal is invalid");
|
|
1075
|
+
if (value.execution_approval !== "not-approved")
|
|
1076
|
+
throw new DeepInterviewCommandError(2, "stored Crystal execution approval is invalid");
|
|
1077
|
+
parseCrystalSnapshot(value.source, "stored Crystal source");
|
|
1078
|
+
if (!Array.isArray(value.items) || value.items.length === 0 || value.items.length > 128)
|
|
1079
|
+
throw new DeepInterviewCommandError(2, "stored Crystal items are invalid");
|
|
1080
|
+
const ids = new Set<string>();
|
|
1081
|
+
for (const item of value.items) {
|
|
1082
|
+
if (
|
|
1083
|
+
!isRecord(item) ||
|
|
1084
|
+
typeof item.id !== "string" ||
|
|
1085
|
+
item.id.trim() === "" ||
|
|
1086
|
+
!/^[A-Za-z0-9][A-Za-z0-9._:-]*$/.test(item.id) ||
|
|
1087
|
+
ids.has(item.id) ||
|
|
1088
|
+
typeof item.kind !== "string" ||
|
|
1089
|
+
!CRYSTAL_ITEM_KINDS.has(item.kind) ||
|
|
1090
|
+
typeof item.classification !== "string" ||
|
|
1091
|
+
!CRYSTAL_CLASSIFICATIONS.has(item.classification) ||
|
|
1092
|
+
typeof item.statement !== "string" ||
|
|
1093
|
+
item.statement.trim() === ""
|
|
1094
|
+
)
|
|
1095
|
+
throw new DeepInterviewCommandError(2, "stored Crystal items are invalid");
|
|
1096
|
+
if (item.classification === "confirmed") {
|
|
1097
|
+
if (
|
|
1098
|
+
!isRecord(item.anchor) ||
|
|
1099
|
+
!Number.isSafeInteger(item.anchor.message_index) ||
|
|
1100
|
+
typeof item.anchor.quote !== "string"
|
|
1101
|
+
)
|
|
1102
|
+
throw new DeepInterviewCommandError(2, "stored Crystal confirmed item anchor is invalid");
|
|
1103
|
+
}
|
|
1104
|
+
ids.add(item.id);
|
|
1105
|
+
}
|
|
1106
|
+
if (!isRecord(value.delta) || typeof value.delta.kind !== "string" || !CRYSTAL_DELTA_KINDS.has(value.delta.kind))
|
|
1107
|
+
throw new DeepInterviewCommandError(2, "stored Crystal delta is invalid");
|
|
1108
|
+
if (
|
|
1109
|
+
!Array.isArray(value.delta.changed_ids) ||
|
|
1110
|
+
!Array.isArray(value.delta.added_ids) ||
|
|
1111
|
+
!Array.isArray(value.delta.preserved_ids) ||
|
|
1112
|
+
typeof value.delta.approval_invalidated !== "boolean" ||
|
|
1113
|
+
[...value.delta.changed_ids, ...value.delta.added_ids, ...value.delta.preserved_ids].some(
|
|
1114
|
+
id => typeof id !== "string" || !/^[A-Za-z0-9][A-Za-z0-9._:-]*$/.test(id),
|
|
1115
|
+
)
|
|
1116
|
+
)
|
|
1117
|
+
throw new DeepInterviewCommandError(2, "stored Crystal delta is invalid");
|
|
1118
|
+
for (const field of ["open_gaps", "conflicts"] as const) {
|
|
1119
|
+
if (!Array.isArray(value[field]) || value[field].some(item => typeof item !== "string"))
|
|
1120
|
+
throw new DeepInterviewCommandError(2, "stored Crystal resolution fields are invalid");
|
|
1121
|
+
}
|
|
1122
|
+
for (const field of ["removed_ids", "pending_removals"] as const) {
|
|
1123
|
+
if (
|
|
1124
|
+
value[field] !== undefined &&
|
|
1125
|
+
(!Array.isArray(value[field]) ||
|
|
1126
|
+
value[field].some(item => typeof item !== "string" || !/^[A-Za-z0-9][A-Za-z0-9._:-]*$/.test(item)))
|
|
1127
|
+
)
|
|
1128
|
+
throw new DeepInterviewCommandError(2, "stored Crystal removal fields are invalid");
|
|
1129
|
+
}
|
|
1130
|
+
if (
|
|
1131
|
+
value.removed_item_anchors !== undefined &&
|
|
1132
|
+
(!Array.isArray(value.removed_item_anchors) ||
|
|
1133
|
+
value.removed_item_anchors.some(
|
|
1134
|
+
anchor =>
|
|
1135
|
+
!isRecord(anchor) ||
|
|
1136
|
+
typeof anchor.item !== "string" ||
|
|
1137
|
+
!/^[A-Za-z0-9][A-Za-z0-9._:-]*$/.test(anchor.item),
|
|
1138
|
+
))
|
|
1139
|
+
)
|
|
1140
|
+
throw new DeepInterviewCommandError(2, "stored Crystal removal anchors are invalid");
|
|
1141
|
+
return value as unknown as DeepInterviewCrystal;
|
|
1142
|
+
}
|
|
1143
|
+
|
|
1144
|
+
function verifyCrystalSourceAgainstLive(
|
|
1145
|
+
crystal: DeepInterviewCrystal,
|
|
1146
|
+
liveSnapshot: { revision: number; messages: Array<{ index: number; role: string; content: string }> },
|
|
1147
|
+
): CrystalSnapshot {
|
|
1148
|
+
const source = parseCrystalSnapshot(crystal.source, "stored Crystal source");
|
|
1149
|
+
if (source.end >= liveSnapshot.messages.length)
|
|
1150
|
+
throw new DeepInterviewCommandError(2, "stored Crystal source is outside the live transcript");
|
|
1151
|
+
const expected = liveSnapshot.messages.slice(source.start, source.end + 1);
|
|
1152
|
+
if (JSON.stringify(expected) !== JSON.stringify(source.messages))
|
|
1153
|
+
throw new DeepInterviewCommandError(2, "stored Crystal source evidence does not match the live transcript");
|
|
1154
|
+
return source;
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1157
|
+
export async function assertDeepInterviewCrystalCoversLiveTranscript(
|
|
1158
|
+
cwd: string,
|
|
1159
|
+
sessionId: string,
|
|
1160
|
+
requireCrystalTail = true,
|
|
1161
|
+
): Promise<{ transcriptPath: string; transcriptSha256: string }> {
|
|
1162
|
+
const statePath = deepInterviewStatePath(cwd, sessionId);
|
|
1163
|
+
let parsed: unknown;
|
|
1164
|
+
try {
|
|
1165
|
+
parsed = await Bun.file(statePath).json();
|
|
1166
|
+
} catch {
|
|
1167
|
+
throw new DeepInterviewCommandError(2, "execution approval requires valid Deep Interview state");
|
|
1168
|
+
}
|
|
1169
|
+
if (!isRecord(parsed) || !isRecord(parsed.state))
|
|
1170
|
+
throw new DeepInterviewCommandError(2, "execution approval requires canonical Crystal state");
|
|
1171
|
+
const crystal = parseStoredCrystal(parsed.state.crystal);
|
|
1172
|
+
const liveSnapshot = await authoritativeConversationSnapshot(cwd, sessionId);
|
|
1173
|
+
const source = verifyCrystalSourceAgainstLive(crystal, liveSnapshot);
|
|
1174
|
+
if (
|
|
1175
|
+
requireCrystalTail &&
|
|
1176
|
+
liveSnapshot.messages.slice(source.end + 1).some(message => !["assistant", "toolResult"].includes(message.role))
|
|
1177
|
+
)
|
|
1178
|
+
throw new DeepInterviewCommandError(2, "execution approval requires re-crystallization after transcript changes");
|
|
1179
|
+
return { transcriptPath: liveSnapshot.transcriptPath, transcriptSha256: liveSnapshot.transcriptSha256 };
|
|
1180
|
+
}
|
|
1181
|
+
|
|
1182
|
+
async function handleCrystallize(args: readonly string[], cwd: string): Promise<DeepInterviewCommandResult> {
|
|
1183
|
+
assertCrystallizeArgs(args);
|
|
1184
|
+
const input = await readCrystallizeInput(flagValue(args, "--input"), cwd);
|
|
1185
|
+
const session = resolveGjcSessionForWrite(cwd, {
|
|
1186
|
+
flagValue: flagValue(args, "--session-id"),
|
|
1187
|
+
payloadSessionId: input.session_id,
|
|
1188
|
+
envSessionId: process.env.GJC_SESSION_ID,
|
|
1189
|
+
});
|
|
1190
|
+
const sessionId = session.gjcSessionId;
|
|
1191
|
+
assertSafePathComponent(sessionId, "session-id");
|
|
1192
|
+
const statePath = deepInterviewStatePath(cwd, sessionId);
|
|
1193
|
+
return withWorkflowStateLock(
|
|
1194
|
+
statePath,
|
|
1195
|
+
async () => handleCrystallizeUnlocked(args, cwd, sessionId, statePath, input),
|
|
1196
|
+
{ cwd },
|
|
1197
|
+
);
|
|
1198
|
+
}
|
|
1199
|
+
|
|
1200
|
+
function assertCrystallizeArgs(args: readonly string[]): void {
|
|
1201
|
+
let valueExpected = false;
|
|
1202
|
+
for (const arg of args) {
|
|
1203
|
+
if (valueExpected) {
|
|
1204
|
+
valueExpected = false;
|
|
1205
|
+
continue;
|
|
1206
|
+
}
|
|
1207
|
+
if (arg === "--input" || arg === "--session-id" || arg === "--slug") {
|
|
1208
|
+
valueExpected = true;
|
|
1209
|
+
continue;
|
|
1210
|
+
}
|
|
1211
|
+
if (arg === "--crystallize" || arg === "--json") continue;
|
|
1212
|
+
throw new DeepInterviewCommandError(2, `unsupported crystallize argument: ${arg}`);
|
|
1213
|
+
}
|
|
1214
|
+
if (valueExpected) throw new DeepInterviewCommandError(2, "crystallize option requires a value");
|
|
1215
|
+
const slug = flagValue(args, "--slug")?.trim();
|
|
1216
|
+
if (!slug) throw new DeepInterviewCommandError(2, "--slug is required for deep-interview --crystallize");
|
|
1217
|
+
}
|
|
1218
|
+
|
|
1219
|
+
async function handleCrystallizeUnlocked(
|
|
1220
|
+
args: readonly string[],
|
|
1221
|
+
cwd: string,
|
|
1222
|
+
sessionId: string,
|
|
1223
|
+
statePath: string,
|
|
1224
|
+
input: Record<string, unknown>,
|
|
1225
|
+
): Promise<DeepInterviewCommandResult> {
|
|
1226
|
+
const existingRead = await readExistingStateForMutation(statePath);
|
|
1227
|
+
if (existingRead.kind === "corrupt")
|
|
1228
|
+
throw new DeepInterviewCommandError(
|
|
1229
|
+
2,
|
|
1230
|
+
`existing deep-interview state is corrupt or tampered (${existingRead.error})`,
|
|
1231
|
+
);
|
|
1232
|
+
if (existingRead.kind === "valid") assertNoFutureDeepInterviewEnvelope(existingRead.value, "crystallize state");
|
|
1233
|
+
const existing =
|
|
1234
|
+
existingRead.kind === "valid"
|
|
1235
|
+
? normalizeDeepInterviewEnvelope(existingRead.value)
|
|
1236
|
+
: normalizeDeepInterviewEnvelope({});
|
|
1237
|
+
if (existingRead.kind === "valid" && existing.active === false)
|
|
1238
|
+
throw new DeepInterviewCommandError(2, "cannot crystallize an inactive deep-interview state");
|
|
1239
|
+
const existingInner = isRecord(existing.state) ? existing.state : {};
|
|
1240
|
+
const storedPrior = existingInner.crystal;
|
|
1241
|
+
const priorCrystal = storedPrior === undefined ? undefined : parseStoredCrystal(storedPrior);
|
|
1242
|
+
if (existingRead.kind === "valid" && priorCrystal) {
|
|
1243
|
+
try {
|
|
1244
|
+
const integrity = await detectWorkflowEnvelopeIntegrityMismatch(statePath);
|
|
1245
|
+
if (integrity) throw new DeepInterviewCommandError(2, "stored Crystal state integrity verification failed");
|
|
1246
|
+
} catch (error) {
|
|
1247
|
+
if (error instanceof DeepInterviewCommandError) throw error;
|
|
1248
|
+
throw new DeepInterviewCommandError(2, "stored Crystal state integrity verification failed");
|
|
1249
|
+
}
|
|
1250
|
+
}
|
|
1251
|
+
if (input.prior !== undefined && storedPrior === undefined)
|
|
1252
|
+
throw new DeepInterviewCommandError(2, "supplied prior crystal requires canonical stored crystal provenance");
|
|
1253
|
+
if (
|
|
1254
|
+
input.prior !== undefined &&
|
|
1255
|
+
storedPrior !== undefined &&
|
|
1256
|
+
JSON.stringify(input.prior) !== JSON.stringify(storedPrior)
|
|
1257
|
+
)
|
|
1258
|
+
throw new DeepInterviewCommandError(2, "supplied prior crystal does not match canonical stored crystal");
|
|
1259
|
+
const liveSnapshot = await authoritativeConversationSnapshot(cwd, sessionId);
|
|
1260
|
+
if (liveSnapshot.revision !== input.current_revision)
|
|
1261
|
+
throw new DeepInterviewCommandError(2, "conversation snapshot is stale against the live session transcript");
|
|
1262
|
+
const snapshot = parseCrystalSnapshot(input.snapshot, "crystallize snapshot");
|
|
1263
|
+
if (snapshot.revision !== input.current_revision)
|
|
1264
|
+
throw new DeepInterviewCommandError(2, "conversation snapshot is stale");
|
|
1265
|
+
if (snapshot.end !== liveSnapshot.messages.length - 1)
|
|
1266
|
+
throw new DeepInterviewCommandError(2, "crystallize snapshot must cover the live transcript tail");
|
|
1267
|
+
const expectedMessages = liveSnapshot.messages.slice(snapshot.start, snapshot.end + 1);
|
|
1268
|
+
if (JSON.stringify(expectedMessages) !== JSON.stringify(snapshot.messages))
|
|
1269
|
+
throw new DeepInterviewCommandError(2, "conversation snapshot does not match the live session transcript");
|
|
1270
|
+
const existingSpecPath = typeof existing.spec_path === "string" ? existing.spec_path : undefined;
|
|
1271
|
+
const existingSpecHash = typeof existing.spec_sha256 === "string" ? existing.spec_sha256 : undefined;
|
|
1272
|
+
const indexPath = path.join(sessionSpecsDir(cwd, sessionId), "deep-interview-index.jsonl");
|
|
1273
|
+
const priorSource = priorCrystal ? verifyCrystalSourceAgainstLive(priorCrystal, liveSnapshot) : undefined;
|
|
1274
|
+
if ((existingSpecPath && !existingSpecHash) || (!existingSpecPath && existingSpecHash))
|
|
1275
|
+
throw new DeepInterviewCommandError(2, "existing Crystal publication identity is incomplete");
|
|
1276
|
+
if (existingSpecPath && existingSpecHash)
|
|
1277
|
+
await verifyPublishedArtifactIndex({
|
|
1278
|
+
cwd,
|
|
1279
|
+
sessionId,
|
|
1280
|
+
indexPath,
|
|
1281
|
+
specPath: existingSpecPath,
|
|
1282
|
+
specHash: existingSpecHash,
|
|
1283
|
+
crystal: priorCrystal?.lifecycle === "ready" ? priorCrystal : undefined,
|
|
1284
|
+
});
|
|
1285
|
+
if (
|
|
1286
|
+
priorCrystal &&
|
|
1287
|
+
priorSource &&
|
|
1288
|
+
priorSource.revision === snapshot.revision &&
|
|
1289
|
+
priorSource.digest === snapshot.digest
|
|
1290
|
+
) {
|
|
1291
|
+
if (
|
|
1292
|
+
priorSource.start !== snapshot.start ||
|
|
1293
|
+
priorSource.end !== snapshot.end ||
|
|
1294
|
+
JSON.stringify(priorSource.messages) !== JSON.stringify(snapshot.messages)
|
|
1295
|
+
)
|
|
1296
|
+
throw new DeepInterviewCommandError(2, "stored Crystal source evidence does not match the replay snapshot");
|
|
1297
|
+
if (priorCrystal.lifecycle === "ready") {
|
|
1298
|
+
if (!existingSpecPath || !existingSpecHash)
|
|
1299
|
+
throw new DeepInterviewCommandError(2, "stored Crystal publication identity is incomplete");
|
|
1300
|
+
const requestedSlug = flagValue(args, "--slug")!.trim();
|
|
1301
|
+
const expectedName = path.basename(existingSpecPath);
|
|
1302
|
+
const match = /^deep-interview-(.+)-v[0-9]+\.md$/.exec(expectedName);
|
|
1303
|
+
if (!match || match[1] !== requestedSlug)
|
|
1304
|
+
throw new DeepInterviewCommandError(2, "conversation snapshot was already crystallized under another slug");
|
|
1305
|
+
const mutationId = `crystal:${sessionId}:${priorCrystal.spec_version}:${createHash("sha256")
|
|
1306
|
+
.update(`${requestedSlug}\0${path.resolve(existingSpecPath)}`)
|
|
1307
|
+
.digest("hex")}`;
|
|
1308
|
+
const pending = await verifyPriorPendingCrystalJournals({
|
|
1309
|
+
cwd,
|
|
1310
|
+
sessionId,
|
|
1311
|
+
indexPath,
|
|
1312
|
+
statePath,
|
|
1313
|
+
existing,
|
|
1314
|
+
currentMutationId: mutationId,
|
|
1315
|
+
});
|
|
1316
|
+
if (pending) {
|
|
1317
|
+
if (
|
|
1318
|
+
pending.paths.some(
|
|
1319
|
+
(value, index) =>
|
|
1320
|
+
path.resolve(value) !== path.resolve([existingSpecPath, indexPath, statePath][index]!),
|
|
1321
|
+
)
|
|
1322
|
+
)
|
|
1323
|
+
throw new DeepInterviewCommandError(2, "Crystal promotion journal identity mismatch");
|
|
1324
|
+
await completeWorkflowTransactionJournal(cwd, sessionId, mutationId);
|
|
1325
|
+
}
|
|
1326
|
+
await syncDeepInterviewHud({
|
|
1327
|
+
cwd,
|
|
1328
|
+
sessionId,
|
|
1329
|
+
payload: existing,
|
|
1330
|
+
phase: typeof existing.current_phase === "string" ? existing.current_phase : "handoff",
|
|
1331
|
+
specStatus: "persisted",
|
|
1332
|
+
});
|
|
1333
|
+
await writeSessionActivityMarker(cwd, sessionId, { writer: "deep-interview-runtime", path: statePath });
|
|
1334
|
+
const summary = {
|
|
1335
|
+
skill: "deep-interview",
|
|
1336
|
+
mode: "crystallize",
|
|
1337
|
+
crystal: priorCrystal,
|
|
1338
|
+
spec_path: existingSpecPath,
|
|
1339
|
+
state_path: statePath,
|
|
1340
|
+
};
|
|
1341
|
+
return {
|
|
1342
|
+
status: 0,
|
|
1343
|
+
stdout: hasFlag(args, "--json")
|
|
1344
|
+
? `${JSON.stringify(summary)}\n`
|
|
1345
|
+
: `Crystal v${priorCrystal.spec_version} created\nReadiness: ${priorCrystal.lifecycle}\nExecution approval: none\nspec_path=${existingSpecPath}\n`,
|
|
1346
|
+
};
|
|
1347
|
+
}
|
|
1348
|
+
throw new DeepInterviewCommandError(2, "conversation snapshot was already crystallized");
|
|
1349
|
+
}
|
|
1350
|
+
if (priorSource && snapshot.revision <= priorSource.revision)
|
|
1351
|
+
throw new DeepInterviewCommandError(2, "conversation snapshot is stale against the stored Crystal");
|
|
1352
|
+
if (!priorCrystal && (liveSnapshot.messages.length > CRYSTAL_MAX_MESSAGES || snapshot.start !== 0))
|
|
1353
|
+
throw new DeepInterviewCommandError(
|
|
1354
|
+
2,
|
|
1355
|
+
`first Crystal must cover the full authenticated transcript from index 0 (maximum ${CRYSTAL_MAX_MESSAGES} messages); continue the ordinary interview flow instead of dropping earlier messages`,
|
|
1356
|
+
);
|
|
1357
|
+
const payload = { ...input, prior: storedPrior };
|
|
1358
|
+
const crystal = crystallizeDeepInterview(payload);
|
|
1359
|
+
const slug = flagValue(args, "--slug")!.trim();
|
|
1360
|
+
assertSafePathComponent(slug, "slug");
|
|
1361
|
+
const specPath =
|
|
1362
|
+
crystal.lifecycle === "ready"
|
|
1363
|
+
? path.join(sessionSpecsDir(cwd, sessionId), `deep-interview-${slug}-v${crystal.spec_version}.md`)
|
|
1364
|
+
: undefined;
|
|
1365
|
+
const now = new Date().toISOString();
|
|
1366
|
+
const specContent = specPath ? crystalMarkdown(crystal) : undefined;
|
|
1367
|
+
if (specContent && [...specContent].length > MAX_DEEP_INTERVIEW_STRUCTURED_RESPONSE_LENGTH)
|
|
1368
|
+
throw new DeepInterviewCommandError(2, "crystallized specification exceeds the structured response limit");
|
|
1369
|
+
const specHash = specContent ? createHash("sha256").update(specContent).digest("hex") : undefined;
|
|
1370
|
+
const mutationId =
|
|
1371
|
+
specPath && specHash
|
|
1372
|
+
? `crystal:${sessionId}:${crystal.spec_version}:${createHash("sha256").update(`${slug}\0${specPath}`).digest("hex")}`
|
|
1373
|
+
: undefined;
|
|
1374
|
+
const currentJournal = await verifyPriorPendingCrystalJournals({
|
|
1375
|
+
cwd,
|
|
1376
|
+
sessionId,
|
|
1377
|
+
indexPath,
|
|
1378
|
+
statePath,
|
|
1379
|
+
existing,
|
|
1380
|
+
currentMutationId: mutationId,
|
|
1381
|
+
});
|
|
1382
|
+
const indexCatalog = await readCrystalIndexCatalog(cwd, sessionId, indexPath);
|
|
1383
|
+
const publicationSnapshot = await authoritativeConversationSnapshot(cwd, sessionId);
|
|
1384
|
+
if (
|
|
1385
|
+
publicationSnapshot.revision !== liveSnapshot.revision ||
|
|
1386
|
+
JSON.stringify(publicationSnapshot.messages) !== JSON.stringify(liveSnapshot.messages)
|
|
1387
|
+
)
|
|
1388
|
+
throw new DeepInterviewCommandError(2, "conversation snapshot changed before Crystal publication");
|
|
1389
|
+
if (specPath && specContent && mutationId) {
|
|
1390
|
+
await beginWorkflowTransactionJournal({
|
|
1391
|
+
cwd,
|
|
1392
|
+
sessionId,
|
|
1393
|
+
mutationId: mutationId!,
|
|
1394
|
+
paths: [specPath, indexPath, statePath],
|
|
1395
|
+
});
|
|
1396
|
+
const journal = currentJournal ?? (await readCrystalJournal(cwd, sessionId, mutationId, indexPath, statePath));
|
|
1397
|
+
if (journal && journal.status !== "pending")
|
|
1398
|
+
throw new DeepInterviewCommandError(2, "Crystal promotion journal is already committed");
|
|
1399
|
+
if (journal && path.resolve(journal.paths[0]!) !== path.resolve(specPath))
|
|
1400
|
+
throw new DeepInterviewCommandError(2, "Crystal promotion journal identity mismatch");
|
|
1401
|
+
if (journal?.artifact_sha256 !== undefined && journal.artifact_sha256 !== specHash)
|
|
1402
|
+
throw new DeepInterviewCommandError(2, "Crystal promotion journal hash mismatch");
|
|
1403
|
+
let artifactReady = false;
|
|
1404
|
+
const existingArtifact = await readBoundedFileBytes(specPath, CRYSTAL_MAX_ARTIFACT_BYTES, "Crystal artifact", {
|
|
1405
|
+
allowMissing: true,
|
|
1406
|
+
});
|
|
1407
|
+
if (existingArtifact) {
|
|
1408
|
+
if (createHash("sha256").update(existingArtifact).digest("hex") !== specHash)
|
|
1409
|
+
throw new DeepInterviewCommandError(2, "existing Crystal artifact conflicts with the requested promotion");
|
|
1410
|
+
artifactReady = true;
|
|
1411
|
+
} else if (journal?.steps.includes("artifact")) {
|
|
1412
|
+
throw new DeepInterviewCommandError(2, "pending Crystal artifact verification failed");
|
|
1413
|
+
}
|
|
1414
|
+
if (!artifactReady)
|
|
1415
|
+
await writeArtifact(specPath, specContent, {
|
|
1416
|
+
cwd,
|
|
1417
|
+
audit: { category: "artifact", verb: "write", owner: "gjc-runtime", skill: "deep-interview", sessionId },
|
|
1418
|
+
});
|
|
1419
|
+
if (journal || mutationId)
|
|
1420
|
+
await updateWorkflowTransactionJournal(cwd, sessionId, mutationId, {
|
|
1421
|
+
steps: ["artifact"],
|
|
1422
|
+
artifact_sha256: specHash,
|
|
1423
|
+
} as Partial<WorkflowTransactionJournal>);
|
|
1424
|
+
const indexAlreadyContains = indexRowsForPath(indexCatalog, specPath).some(row => row.sha256 === specHash);
|
|
1425
|
+
if (journal?.steps.includes("index") && !indexAlreadyContains)
|
|
1426
|
+
throw new DeepInterviewCommandError(2, "pending Crystal index verification failed");
|
|
1427
|
+
if (!indexAlreadyContains)
|
|
1428
|
+
await appendCrystalIndexRow(
|
|
1429
|
+
cwd,
|
|
1430
|
+
sessionId,
|
|
1431
|
+
indexPath,
|
|
1432
|
+
{ slug, stage: "final", path: specPath!, created_at: now, sha256: specHash! },
|
|
1433
|
+
existingSpecPath ? [existingSpecPath] : [],
|
|
1434
|
+
);
|
|
1435
|
+
await updateWorkflowTransactionJournal(cwd, sessionId, mutationId, {
|
|
1436
|
+
steps: ["artifact", "index"],
|
|
1437
|
+
artifact_sha256: specHash,
|
|
1438
|
+
} as Partial<WorkflowTransactionJournal>);
|
|
1439
|
+
}
|
|
1440
|
+
const state: Record<string, unknown> = { ...existingInner, crystal, execution_approval: "not-approved" };
|
|
1441
|
+
delete state.execution_approval_receipt;
|
|
1442
|
+
const envelope = {
|
|
1443
|
+
...existing,
|
|
1444
|
+
active: true,
|
|
1445
|
+
current_phase: crystal.lifecycle === "ready" ? "handoff" : "interviewing",
|
|
1446
|
+
skill: "deep-interview",
|
|
1447
|
+
version: WORKFLOW_STATE_VERSION,
|
|
1448
|
+
session_id: sessionId,
|
|
1449
|
+
...(specPath && specContent
|
|
1450
|
+
? {
|
|
1451
|
+
spec_slug: slug,
|
|
1452
|
+
spec_path: specPath,
|
|
1453
|
+
spec_stage: "final",
|
|
1454
|
+
spec_sha256: createHash("sha256").update(specContent).digest("hex"),
|
|
1455
|
+
spec_persisted_at: now,
|
|
1456
|
+
}
|
|
1457
|
+
: {}),
|
|
1458
|
+
state,
|
|
1459
|
+
updated_at: now,
|
|
1460
|
+
};
|
|
1461
|
+
if (!specPath) {
|
|
1462
|
+
delete envelope.spec_slug;
|
|
1463
|
+
delete envelope.spec_path;
|
|
1464
|
+
delete envelope.spec_stage;
|
|
1465
|
+
delete envelope.spec_sha256;
|
|
1466
|
+
delete envelope.spec_persisted_at;
|
|
1467
|
+
}
|
|
1468
|
+
await writeWorkflowEnvelopeAtomic(statePath, envelope, {
|
|
1469
|
+
lockHeld: true,
|
|
1470
|
+
cwd,
|
|
1471
|
+
receipt: {
|
|
1472
|
+
cwd,
|
|
1473
|
+
skill: "deep-interview",
|
|
1474
|
+
owner: "gjc-runtime",
|
|
1475
|
+
command: "gjc deep-interview crystallize",
|
|
1476
|
+
sessionId,
|
|
1477
|
+
nowIso: now,
|
|
1478
|
+
},
|
|
1479
|
+
audit: { category: "state", verb: "write", owner: "gjc-runtime", skill: "deep-interview", sessionId },
|
|
1480
|
+
});
|
|
1481
|
+
if (specPath && specContent && mutationId)
|
|
1482
|
+
await updateWorkflowTransactionJournal(cwd, sessionId, mutationId, {
|
|
1483
|
+
steps: ["artifact", "index", "state"],
|
|
1484
|
+
artifact_sha256: specHash,
|
|
1485
|
+
} as Partial<WorkflowTransactionJournal>);
|
|
1486
|
+
await writeSessionActivityMarker(cwd, sessionId, { writer: "deep-interview-runtime", path: statePath });
|
|
1487
|
+
await syncDeepInterviewHud({
|
|
1488
|
+
cwd,
|
|
1489
|
+
sessionId,
|
|
1490
|
+
payload: envelope,
|
|
1491
|
+
phase: envelope.current_phase,
|
|
1492
|
+
specStatus: specPath ? "persisted" : "not_persisted",
|
|
1493
|
+
});
|
|
1494
|
+
if (specPath && specContent && mutationId) await completeWorkflowTransactionJournal(cwd, sessionId, mutationId);
|
|
1495
|
+
const summary = {
|
|
1496
|
+
skill: "deep-interview",
|
|
1497
|
+
mode: "crystallize",
|
|
1498
|
+
crystal,
|
|
1499
|
+
...(specPath ? { spec_path: specPath } : {}),
|
|
1500
|
+
state_path: statePath,
|
|
1501
|
+
};
|
|
1502
|
+
return {
|
|
1503
|
+
status: 0,
|
|
1504
|
+
stdout: hasFlag(args, "--json")
|
|
1505
|
+
? `${JSON.stringify(summary)}\n`
|
|
1506
|
+
: `Crystal v${crystal.spec_version} created\nReadiness: ${crystal.lifecycle}\nExecution approval: none\nspec_path=${specPath}\n`,
|
|
1507
|
+
};
|
|
1508
|
+
}
|
|
1509
|
+
|
|
136
1510
|
function defaultSpecSlug(now: Date = new Date()): string {
|
|
137
1511
|
const yyyy = now.getUTCFullYear().toString().padStart(4, "0");
|
|
138
1512
|
const mm = (now.getUTCMonth() + 1).toString().padStart(2, "0");
|
|
@@ -612,6 +1986,17 @@ function resolveLockedIntentReview(existing: unknown, content: string): DeepInte
|
|
|
612
1986
|
export async function persistDeepInterviewSpec(
|
|
613
1987
|
cwd: string,
|
|
614
1988
|
resolved: ResolvedDeepInterviewSpecWriteArgs,
|
|
1989
|
+
): Promise<PersistedDeepInterviewSpec> {
|
|
1990
|
+
return withWorkflowStateLock(
|
|
1991
|
+
deepInterviewStatePath(cwd, resolved.sessionId),
|
|
1992
|
+
() => persistDeepInterviewSpecUnlocked(cwd, resolved),
|
|
1993
|
+
{ cwd },
|
|
1994
|
+
);
|
|
1995
|
+
}
|
|
1996
|
+
|
|
1997
|
+
async function persistDeepInterviewSpecUnlocked(
|
|
1998
|
+
cwd: string,
|
|
1999
|
+
resolved: ResolvedDeepInterviewSpecWriteArgs,
|
|
615
2000
|
): Promise<PersistedDeepInterviewSpec> {
|
|
616
2001
|
assertDeepInterviewInputWithinLimit(
|
|
617
2002
|
resolved.spec,
|
|
@@ -626,7 +2011,15 @@ export async function persistDeepInterviewSpec(
|
|
|
626
2011
|
`existing deep-interview state is corrupt or tampered (${existingRead.error}); use --force to overwrite ${statePath}`,
|
|
627
2012
|
);
|
|
628
2013
|
}
|
|
2014
|
+
if (existingRead.kind === "valid") assertNoFutureDeepInterviewEnvelope(existingRead.value, "spec publication state");
|
|
629
2015
|
const existing = existingRead.kind === "valid" ? existingRead.value : {};
|
|
2016
|
+
const existingInner =
|
|
2017
|
+
existing.state && typeof existing.state === "object" && !Array.isArray(existing.state)
|
|
2018
|
+
? (existing.state as Record<string, unknown>)
|
|
2019
|
+
: undefined;
|
|
2020
|
+
if (existingInner?.crystal !== undefined) {
|
|
2021
|
+
throw new DeepInterviewCommandError(2, "direct spec write is unavailable while canonical Crystal state exists");
|
|
2022
|
+
}
|
|
630
2023
|
|
|
631
2024
|
const content = resolved.spec.endsWith("\n") ? resolved.spec : `${resolved.spec}\n`;
|
|
632
2025
|
const intentReview = resolveLockedIntentReview(existing, content);
|
|
@@ -644,19 +2037,11 @@ export async function persistDeepInterviewSpec(
|
|
|
644
2037
|
|
|
645
2038
|
const sha256 = createHash("sha256").update(content).digest("hex");
|
|
646
2039
|
const createdAt = new Date().toISOString();
|
|
647
|
-
await
|
|
2040
|
+
await appendCrystalIndexRow(
|
|
2041
|
+
cwd,
|
|
2042
|
+
resolved.sessionId,
|
|
648
2043
|
path.join(sessionSpecsDir(cwd, resolved.sessionId), "deep-interview-index.jsonl"),
|
|
649
2044
|
{ slug: resolved.slug, stage: resolved.stage, path: specPath, created_at: createdAt, sha256 },
|
|
650
|
-
{
|
|
651
|
-
cwd,
|
|
652
|
-
audit: {
|
|
653
|
-
category: "ledger",
|
|
654
|
-
verb: "append",
|
|
655
|
-
owner: "gjc-runtime",
|
|
656
|
-
skill: "deep-interview",
|
|
657
|
-
sessionId: resolved.sessionId,
|
|
658
|
-
},
|
|
659
|
-
},
|
|
660
2045
|
);
|
|
661
2046
|
|
|
662
2047
|
const payload = normalizeDeepInterviewEnvelope({
|
|
@@ -679,6 +2064,7 @@ export async function persistDeepInterviewSpec(
|
|
|
679
2064
|
if (resolved.sessionId) payload.session_id = resolved.sessionId;
|
|
680
2065
|
await writeWorkflowEnvelopeAtomic(statePath, payload, {
|
|
681
2066
|
cwd,
|
|
2067
|
+
lockHeld: true,
|
|
682
2068
|
receipt: {
|
|
683
2069
|
cwd,
|
|
684
2070
|
skill: "deep-interview",
|
|
@@ -716,8 +2102,20 @@ export async function persistDeepInterviewSpec(
|
|
|
716
2102
|
}
|
|
717
2103
|
|
|
718
2104
|
async function seedDeepInterviewState(cwd: string, resolved: ResolvedDeepInterviewArgs): Promise<string> {
|
|
2105
|
+
const statePath = deepInterviewStatePath(cwd, resolved.sessionId);
|
|
2106
|
+
return withWorkflowStateLock(statePath, () => seedDeepInterviewStateUnlocked(cwd, resolved), { cwd });
|
|
2107
|
+
}
|
|
2108
|
+
|
|
2109
|
+
async function seedDeepInterviewStateUnlocked(cwd: string, resolved: ResolvedDeepInterviewArgs): Promise<string> {
|
|
719
2110
|
const statePath = deepInterviewStatePath(cwd, resolved.sessionId);
|
|
720
2111
|
assertDeepInterviewInputWithinLimit(resolved.idea, MAX_INITIAL_CONTEXT_LENGTH, "initial_idea");
|
|
2112
|
+
const existingRead = await readExistingStateForMutation(statePath);
|
|
2113
|
+
if (existingRead.kind === "valid") {
|
|
2114
|
+
assertNoFutureDeepInterviewEnvelope(existingRead.value, "seed state");
|
|
2115
|
+
const existingInner = isRecord(existingRead.value.state) ? existingRead.value.state : undefined;
|
|
2116
|
+
if (existingInner?.crystal !== undefined)
|
|
2117
|
+
throw new DeepInterviewCommandError(2, "deep-interview seed cannot replace canonical Crystal state");
|
|
2118
|
+
}
|
|
721
2119
|
const now = new Date().toISOString();
|
|
722
2120
|
const payload: Record<string, unknown> = {
|
|
723
2121
|
active: true,
|
|
@@ -756,6 +2154,7 @@ async function seedDeepInterviewState(cwd: string, resolved: ResolvedDeepIntervi
|
|
|
756
2154
|
if (resolved.sessionId) payload.session_id = resolved.sessionId;
|
|
757
2155
|
await writeWorkflowEnvelopeAtomic(statePath, payload, {
|
|
758
2156
|
cwd,
|
|
2157
|
+
lockHeld: true,
|
|
759
2158
|
receipt: {
|
|
760
2159
|
cwd,
|
|
761
2160
|
skill: "deep-interview",
|
|
@@ -877,7 +2276,10 @@ export async function runNativeDeepInterviewCommand(
|
|
|
877
2276
|
): Promise<DeepInterviewCommandResult> {
|
|
878
2277
|
try {
|
|
879
2278
|
const [firstArg, ...restArgs] = args;
|
|
2279
|
+
if (firstArg === "approve-execution")
|
|
2280
|
+
return await runNativeStateCommand(["approve-execution", "--mode", "deep-interview", ...restArgs], cwd);
|
|
880
2281
|
if (isDeepInterviewStageVerb(firstArg)) return await runDeepInterviewStageCommand(firstArg, restArgs, cwd);
|
|
2282
|
+
if (hasFlag(args, "--crystallize")) return await handleCrystallize(args, cwd);
|
|
881
2283
|
if (isDeepInterviewSpecWriteInvocation(args)) return await handleSpecWrite(args, cwd, options.agentDir);
|
|
882
2284
|
const resolved = await resolveDeepInterviewArgs(args, cwd, options.agentDir);
|
|
883
2285
|
if (!resolved.idea) {
|