@gajae-code/coding-agent 0.5.0 → 0.5.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +36 -0
- package/README.md +1 -1
- package/dist/types/async/job-manager.d.ts +26 -0
- package/dist/types/cli/args.d.ts +1 -0
- package/dist/types/cli/list-models.d.ts +6 -0
- package/dist/types/cli/setup-cli.d.ts +8 -1
- package/dist/types/commands/gc.d.ts +26 -0
- package/dist/types/commands/setup.d.ts +7 -0
- package/dist/types/config/file-lock-gc.d.ts +5 -0
- package/dist/types/config/file-lock.d.ts +29 -0
- package/dist/types/config/model-registry.d.ts +4 -0
- package/dist/types/config/models-config-schema.d.ts +5 -0
- package/dist/types/config/settings-schema.d.ts +62 -0
- package/dist/types/coordinator/contract.d.ts +1 -1
- package/dist/types/defaults/gjc/extensions/grok-build/index.d.ts +1 -0
- package/dist/types/defaults/gjc/extensions/grok-cli-vendor/src/index.d.ts +1 -0
- package/dist/types/defaults/gjc/extensions/grok-cli-vendor/src/models/catalog.d.ts +25 -0
- package/dist/types/defaults/gjc/extensions/grok-cli-vendor/src/payload/sanitize.d.ts +27 -0
- package/dist/types/defaults/gjc/extensions/grok-cli-vendor/src/provider/billing.d.ts +8 -0
- package/dist/types/defaults/gjc/extensions/grok-cli-vendor/src/provider/register.d.ts +5 -0
- package/dist/types/defaults/gjc/extensions/grok-cli-vendor/src/provider/stream.d.ts +10 -0
- package/dist/types/defaults/gjc/extensions/grok-cli-vendor/src/provider/usage.d.ts +2 -0
- package/dist/types/defaults/gjc/extensions/grok-cli-vendor/src/shared/base-url.d.ts +2 -0
- package/dist/types/defaults/gjc/extensions/grok-cli-vendor/src/shared/errors.d.ts +38 -0
- package/dist/types/defaults/gjc-grok-cli.d.ts +5 -0
- package/dist/types/extensibility/extensions/index.d.ts +1 -0
- package/dist/types/extensibility/extensions/prefix-command-bridge.d.ts +35 -0
- package/dist/types/gjc-runtime/deep-interview-recorder.d.ts +103 -0
- package/dist/types/gjc-runtime/deep-interview-runtime.d.ts +2 -0
- package/dist/types/gjc-runtime/deep-interview-state.d.ts +112 -0
- package/dist/types/gjc-runtime/gc-render.d.ts +6 -0
- package/dist/types/gjc-runtime/gc-runtime.d.ts +134 -0
- package/dist/types/gjc-runtime/ledger-event-renderer.d.ts +68 -0
- package/dist/types/gjc-runtime/state-writer.d.ts +64 -2
- package/dist/types/gjc-runtime/team-gc.d.ts +7 -0
- package/dist/types/gjc-runtime/team-runtime.d.ts +5 -0
- package/dist/types/gjc-runtime/tmux-common.d.ts +11 -0
- package/dist/types/gjc-runtime/tmux-gc.d.ts +7 -0
- package/dist/types/gjc-runtime/tmux-sessions.d.ts +13 -0
- package/dist/types/gjc-runtime/ultragoal-guard.d.ts +10 -0
- package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +29 -0
- package/dist/types/harness-control-plane/gc-adapter.d.ts +3 -0
- package/dist/types/harness-control-plane/owner.d.ts +7 -0
- package/dist/types/harness-control-plane/storage.d.ts +20 -0
- package/dist/types/modes/components/hook-selector.d.ts +7 -1
- package/dist/types/modes/components/provider-onboarding-selector.d.ts +1 -1
- package/dist/types/modes/controllers/command-controller.d.ts +1 -0
- package/dist/types/modes/interactive-mode.d.ts +1 -1
- package/dist/types/modes/rpc/rpc-mode.d.ts +72 -2
- package/dist/types/modes/shared/agent-wire/deep-interview-gate.d.ts +13 -0
- package/dist/types/modes/shared/agent-wire/session-registry.d.ts +25 -0
- package/dist/types/modes/shared/agent-wire/unattended-action-policy.d.ts +2 -0
- package/dist/types/modes/shared/agent-wire/unattended-session.d.ts +10 -0
- package/dist/types/modes/theme/defaults/index.d.ts +302 -0
- package/dist/types/modes/theme/theme.d.ts +1 -0
- package/dist/types/modes/types.d.ts +1 -1
- package/dist/types/session/agent-session.d.ts +1 -1
- package/dist/types/session/blob-store.d.ts +39 -3
- package/dist/types/session/history-storage.d.ts +2 -2
- package/dist/types/session/session-manager.d.ts +10 -1
- package/dist/types/setup/credential-import.d.ts +79 -0
- package/dist/types/skill-state/workflow-hud.d.ts +14 -0
- package/dist/types/task/executor.d.ts +1 -0
- package/dist/types/task/render.d.ts +1 -1
- package/dist/types/tools/ask.d.ts +15 -1
- package/dist/types/tools/subagent-render.d.ts +7 -1
- package/dist/types/tools/subagent.d.ts +27 -0
- package/dist/types/tools/ultragoal-ask-guard.d.ts +5 -0
- package/dist/types/web/search/index.d.ts +4 -4
- package/dist/types/web/search/provider.d.ts +16 -20
- package/dist/types/web/search/providers/base.d.ts +2 -1
- package/dist/types/web/search/providers/openai-compatible.d.ts +9 -0
- package/dist/types/web/search/types.d.ts +14 -2
- package/package.json +7 -7
- package/scripts/build-binary.ts +7 -0
- package/src/async/job-manager.ts +52 -0
- package/src/cli/args.ts +5 -0
- package/src/cli/auth-broker-cli.ts +1 -0
- package/src/cli/fast-help.ts +2 -0
- package/src/cli/list-models.ts +13 -1
- package/src/cli/setup-cli.ts +138 -3
- package/src/cli.ts +1 -0
- package/src/commands/gc.ts +22 -0
- package/src/commands/harness.ts +7 -3
- package/src/commands/setup.ts +5 -1
- package/src/commands/ultragoal.ts +3 -1
- package/src/config/file-lock-gc.ts +193 -0
- package/src/config/file-lock.ts +66 -10
- package/src/config/model-profile-activation.ts +15 -3
- package/src/config/model-profiles.ts +39 -30
- package/src/config/model-registry.ts +21 -1
- package/src/config/models-config-schema.ts +1 -0
- package/src/config/settings-schema.ts +62 -0
- package/src/coordinator/contract.ts +1 -0
- package/src/coordinator-mcp/server.ts +459 -3
- package/src/defaults/gjc/agent.models.grok-cli.yml +36 -0
- package/src/defaults/gjc/extensions/grok-build/index.ts +1 -0
- package/src/defaults/gjc/extensions/grok-build/package.json +7 -0
- package/src/defaults/gjc/extensions/grok-cli-vendor/biome.json +39 -0
- package/src/defaults/gjc/extensions/grok-cli-vendor/package.json +8 -0
- package/src/defaults/gjc/extensions/grok-cli-vendor/src/index.ts +1 -0
- package/src/defaults/gjc/extensions/grok-cli-vendor/src/models/catalog.ts +155 -0
- package/src/defaults/gjc/extensions/grok-cli-vendor/src/payload/sanitize.ts +361 -0
- package/src/defaults/gjc/extensions/grok-cli-vendor/src/provider/billing.ts +57 -0
- package/src/defaults/gjc/extensions/grok-cli-vendor/src/provider/register.ts +99 -0
- package/src/defaults/gjc/extensions/grok-cli-vendor/src/provider/stream.ts +50 -0
- package/src/defaults/gjc/extensions/grok-cli-vendor/src/provider/usage.ts +56 -0
- package/src/defaults/gjc/extensions/grok-cli-vendor/src/shared/base-url.ts +36 -0
- package/src/defaults/gjc/extensions/grok-cli-vendor/src/shared/errors.ts +44 -0
- package/src/defaults/gjc/skills/deep-interview/SKILL.md +131 -113
- package/src/defaults/gjc/skills/deep-interview/lateral-review-panel.md +49 -0
- package/src/defaults/gjc/skills/ultragoal/SKILL.md +30 -8
- package/src/defaults/gjc-defaults.ts +7 -0
- package/src/defaults/gjc-grok-cli.ts +22 -0
- package/src/extensibility/extensions/index.ts +1 -0
- package/src/extensibility/extensions/prefix-command-bridge.ts +128 -0
- package/src/gjc-runtime/deep-interview-recorder.ts +457 -0
- package/src/gjc-runtime/deep-interview-runtime.ts +18 -26
- package/src/gjc-runtime/deep-interview-state.ts +324 -0
- package/src/gjc-runtime/gc-render.ts +70 -0
- package/src/gjc-runtime/gc-runtime.ts +403 -0
- package/src/gjc-runtime/launch-tmux.ts +3 -4
- package/src/gjc-runtime/ledger-event-renderer.ts +164 -0
- package/src/gjc-runtime/ralplan-runtime.ts +232 -19
- package/src/gjc-runtime/state-renderer.ts +12 -3
- package/src/gjc-runtime/state-runtime.ts +48 -30
- package/src/gjc-runtime/state-writer.ts +254 -7
- package/src/gjc-runtime/team-gc.ts +49 -0
- package/src/gjc-runtime/team-runtime.ts +179 -2
- package/src/gjc-runtime/tmux-common.ts +14 -0
- package/src/gjc-runtime/tmux-gc.ts +177 -0
- package/src/gjc-runtime/tmux-sessions.ts +49 -1
- package/src/gjc-runtime/ultragoal-guard.ts +155 -0
- package/src/gjc-runtime/ultragoal-runtime.ts +1239 -31
- package/src/gjc-runtime/workflow-manifest.generated.json +44 -0
- package/src/gjc-runtime/workflow-manifest.ts +12 -0
- package/src/harness-control-plane/gc-adapter.ts +184 -0
- package/src/harness-control-plane/owner.ts +14 -2
- package/src/harness-control-plane/rpc-adapter.ts +1 -1
- package/src/harness-control-plane/storage.ts +70 -0
- package/src/hooks/skill-state.ts +121 -2
- package/src/internal-urls/docs-index.generated.ts +22 -12
- package/src/lsp/defaults.json +1 -0
- package/src/main.ts +18 -3
- package/src/modes/acp/acp-agent.ts +4 -2
- package/src/modes/bridge/bridge-mode.ts +2 -1
- package/src/modes/components/history-search.ts +5 -2
- package/src/modes/components/hook-selector.ts +19 -0
- package/src/modes/components/model-selector.ts +51 -8
- package/src/modes/components/provider-onboarding-selector.ts +6 -1
- package/src/modes/components/status-line/segments.ts +1 -1
- package/src/modes/controllers/command-controller.ts +25 -6
- package/src/modes/controllers/extension-ui-controller.ts +3 -0
- package/src/modes/controllers/selector-controller.ts +81 -1
- package/src/modes/interactive-mode.ts +11 -1
- package/src/modes/rpc/rpc-mode.ts +266 -34
- package/src/modes/shared/agent-wire/command-dispatch.ts +281 -261
- package/src/modes/shared/agent-wire/deep-interview-gate.ts +30 -1
- package/src/modes/shared/agent-wire/host-tool-bridge.ts +3 -0
- package/src/modes/shared/agent-wire/session-registry.ts +109 -0
- package/src/modes/shared/agent-wire/unattended-action-policy.ts +24 -0
- package/src/modes/shared/agent-wire/unattended-run-controller.ts +23 -3
- package/src/modes/shared/agent-wire/unattended-session.ts +32 -2
- package/src/modes/theme/defaults/claude-code.json +100 -0
- package/src/modes/theme/defaults/codex.json +100 -0
- package/src/modes/theme/defaults/index.ts +6 -0
- package/src/modes/theme/defaults/opencode.json +102 -0
- package/src/modes/theme/theme.ts +2 -2
- package/src/modes/types.ts +1 -1
- package/src/prompts/agents/executor.md +5 -2
- package/src/sdk.ts +29 -4
- package/src/session/agent-session.ts +99 -19
- package/src/session/blob-store.ts +59 -3
- package/src/session/history-storage.ts +32 -11
- package/src/session/session-manager.ts +72 -20
- package/src/setup/credential-import.ts +429 -0
- package/src/setup/hermes/templates/operator-instructions.v1.md +7 -1
- package/src/skill-state/deep-interview-mutation-guard.ts +2 -1
- package/src/skill-state/workflow-hud.ts +106 -10
- package/src/slash-commands/builtin-registry.ts +3 -2
- package/src/task/executor.ts +16 -1
- package/src/task/render.ts +18 -7
- package/src/tools/ask.ts +59 -2
- package/src/tools/cron.ts +1 -1
- package/src/tools/job.ts +3 -2
- package/src/tools/monitor.ts +36 -1
- package/src/tools/subagent-render.ts +128 -29
- package/src/tools/subagent.ts +173 -9
- package/src/tools/ultragoal-ask-guard.ts +39 -0
- package/src/web/search/index.ts +25 -25
- package/src/web/search/provider.ts +178 -87
- package/src/web/search/providers/base.ts +2 -1
- package/src/web/search/providers/openai-compatible.ts +151 -0
- package/src/web/search/types.ts +47 -22
|
@@ -4,6 +4,7 @@ import autoAnswerUncertainFragment from "./gjc/skills/deep-interview/auto-answer
|
|
|
4
4
|
import autoResearchGreenfieldFragment from "./gjc/skills/deep-interview/auto-research-greenfield.md" with {
|
|
5
5
|
type: "text",
|
|
6
6
|
};
|
|
7
|
+
import lateralReviewPanelFragment from "./gjc/skills/deep-interview/lateral-review-panel.md" with { type: "text" };
|
|
7
8
|
import deepInterviewSkill from "./gjc/skills/deep-interview/SKILL.md" with { type: "text" };
|
|
8
9
|
import ralplanSkill from "./gjc/skills/ralplan/SKILL.md" with { type: "text" };
|
|
9
10
|
import teamSkill from "./gjc/skills/team/SKILL.md" with { type: "text" };
|
|
@@ -93,6 +94,12 @@ const DEFAULT_GJC_DEFINITIONS: readonly DefaultGjcDefinition[] = [
|
|
|
93
94
|
relativePath: "skill-fragments/deep-interview/auto-answer-uncertain.md",
|
|
94
95
|
content: autoAnswerUncertainFragment,
|
|
95
96
|
},
|
|
97
|
+
{
|
|
98
|
+
kind: "skill-fragment",
|
|
99
|
+
parentSkillName: "deep-interview",
|
|
100
|
+
relativePath: "skill-fragments/deep-interview/lateral-review-panel.md",
|
|
101
|
+
content: lateralReviewPanelFragment,
|
|
102
|
+
},
|
|
96
103
|
{
|
|
97
104
|
kind: "skill-fragment",
|
|
98
105
|
parentSkillName: "ultragoal",
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { ExtensionFactory } from "../extensibility/extensions/types";
|
|
2
|
+
import grokCliModelDefaults from "./gjc/agent.models.grok-cli.yml" with { type: "text" };
|
|
3
|
+
import grokBuildExtensionFactory from "./gjc/extensions/grok-build/index";
|
|
4
|
+
|
|
5
|
+
export const BUNDLED_GROK_BUILD_EXTENSION_ID = "bundled:grok-build";
|
|
6
|
+
|
|
7
|
+
export function getBundledGrokBuildExtensionFactory(): ExtensionFactory {
|
|
8
|
+
return grokBuildExtensionFactory;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function getBundledGrokCliModelDefaults(): string {
|
|
12
|
+
return grokCliModelDefaults;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export async function assertBundledGrokCliDefaults(): Promise<void> {
|
|
16
|
+
if (typeof grokBuildExtensionFactory !== "function") {
|
|
17
|
+
throw new Error("Bundled Grok Build extension factory is missing");
|
|
18
|
+
}
|
|
19
|
+
if (!grokCliModelDefaults.includes("grok-composer-2.5-fast")) {
|
|
20
|
+
throw new Error("Bundled Grok Build model defaults are missing Composer 2.5 Fast");
|
|
21
|
+
}
|
|
22
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { logger } from "@gajae-code/utils";
|
|
2
|
+
import { type ExecResult, execCommand } from "../../exec/exec";
|
|
3
|
+
import type { ExtensionContext, InputEvent, InputEventResult } from "./types";
|
|
4
|
+
|
|
5
|
+
export const OOO_BRIDGE_RECURSION_ENV = "_OUROBOROS_GJC_BRIDGE_DEPTH";
|
|
6
|
+
export const OOO_BRIDGE_CONTINUE_EXIT_CODE = 78;
|
|
7
|
+
export const OOO_BRIDGE_TIMEOUT_ENV = "OUROBOROS_GJC_BRIDGE_TIMEOUT_MS";
|
|
8
|
+
|
|
9
|
+
export interface ExactPrefixCommandBridgeOptions {
|
|
10
|
+
/** Bare command prefix to intercept, without trailing whitespace. */
|
|
11
|
+
prefix: string;
|
|
12
|
+
/** Command executable to run when the prefix matches. */
|
|
13
|
+
command: string;
|
|
14
|
+
/** Arguments inserted before the intercepted input text. */
|
|
15
|
+
args?: string[];
|
|
16
|
+
/** Environment variable used as the recursion-depth guard. */
|
|
17
|
+
recursionEnv?: string;
|
|
18
|
+
/** Exit code that maps to extension pass-through instead of handled input. */
|
|
19
|
+
continueExitCode?: number;
|
|
20
|
+
/** Optional dispatch timeout in milliseconds. */
|
|
21
|
+
timeout?: number;
|
|
22
|
+
/** Dispatch implementation. Defaults to the shared command executor in the extension context cwd. */
|
|
23
|
+
dispatch?: (
|
|
24
|
+
command: string,
|
|
25
|
+
args: string[],
|
|
26
|
+
ctx: ExtensionContext,
|
|
27
|
+
options: { timeout?: number },
|
|
28
|
+
) => Promise<ExecResult>;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function isExactPrefixMatch(text: string, prefix: string): boolean {
|
|
32
|
+
return text === prefix || text.startsWith(`${prefix} `) || text.startsWith(`${prefix}\t`);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function parseTimeoutEnv(envName: string): number | undefined {
|
|
36
|
+
const value = process.env[envName];
|
|
37
|
+
if (value === undefined || value.trim() === "") return undefined;
|
|
38
|
+
const parsed = Number(value);
|
|
39
|
+
return Number.isFinite(parsed) && parsed > 0 ? parsed : undefined;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function isDispatchSource(event: InputEvent): boolean {
|
|
43
|
+
return event.source === undefined || event.source === "interactive";
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const activeDispatches = new WeakSet<InputEvent>();
|
|
47
|
+
|
|
48
|
+
function hasActiveRecursionGuard(envName: string): boolean {
|
|
49
|
+
const value = process.env[envName];
|
|
50
|
+
if (value === undefined || value === "") return false;
|
|
51
|
+
const depth = Number(value);
|
|
52
|
+
return Number.isFinite(depth) ? depth > 1 : true;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function nextRecursionDepth(envName: string): string {
|
|
56
|
+
const current = Number(process.env[envName] ?? "0");
|
|
57
|
+
return String(Number.isFinite(current) && current >= 0 ? current + 1 : 1);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Build an extension `input` handler for an exact-prefix command bridge.
|
|
62
|
+
*
|
|
63
|
+
* Matching input is passed to `command` as `args + [event.text]`. A zero exit code
|
|
64
|
+
* handles the input, `continueExitCode` returns pass-through, and any other
|
|
65
|
+
* non-zero exit code surfaces an error and handles the input so the failed
|
|
66
|
+
* command is not forwarded to the model. The recursion
|
|
67
|
+
* guard prevents extension-originated or nested dispatch from re-entering the
|
|
68
|
+
* bridge while the child command runs.
|
|
69
|
+
*/
|
|
70
|
+
export function createExactPrefixCommandBridge(options: ExactPrefixCommandBridgeOptions) {
|
|
71
|
+
const recursionEnv = options.recursionEnv ?? OOO_BRIDGE_RECURSION_ENV;
|
|
72
|
+
const continueExitCode = options.continueExitCode ?? OOO_BRIDGE_CONTINUE_EXIT_CODE;
|
|
73
|
+
const args = options.args ?? [];
|
|
74
|
+
const timeout = options.timeout ?? parseTimeoutEnv(OOO_BRIDGE_TIMEOUT_ENV);
|
|
75
|
+
const dispatch =
|
|
76
|
+
options.dispatch ??
|
|
77
|
+
((command, commandArgs, ctx, execOptions) => execCommand(command, commandArgs, ctx.cwd, execOptions));
|
|
78
|
+
|
|
79
|
+
return async (event: InputEvent, ctx: ExtensionContext): Promise<InputEventResult> => {
|
|
80
|
+
if (!isExactPrefixMatch(event.text, options.prefix)) return {};
|
|
81
|
+
if (!isDispatchSource(event) || hasActiveRecursionGuard(recursionEnv)) return {};
|
|
82
|
+
if (activeDispatches.has(event)) return {};
|
|
83
|
+
|
|
84
|
+
const previousDepth = process.env[recursionEnv];
|
|
85
|
+
activeDispatches.add(event);
|
|
86
|
+
process.env[recursionEnv] = nextRecursionDepth(recursionEnv);
|
|
87
|
+
try {
|
|
88
|
+
const result = await dispatch(options.command, [...args, event.text], ctx, { timeout });
|
|
89
|
+
if (result.code === 0) return { handled: true };
|
|
90
|
+
if (result.code === continueExitCode) return {};
|
|
91
|
+
|
|
92
|
+
const output =
|
|
93
|
+
result.stderr.trim() || result.stdout.trim() || `${options.command} exited with code ${result.code}`;
|
|
94
|
+
logger.error("Exact-prefix command bridge dispatch failed", {
|
|
95
|
+
command: options.command,
|
|
96
|
+
code: result.code,
|
|
97
|
+
prefix: options.prefix,
|
|
98
|
+
error: output,
|
|
99
|
+
});
|
|
100
|
+
ctx.ui?.notify(output, "error");
|
|
101
|
+
return { handled: true };
|
|
102
|
+
} catch (err) {
|
|
103
|
+
const output = err instanceof Error ? err.message : String(err);
|
|
104
|
+
logger.error("Exact-prefix command bridge dispatch failed", {
|
|
105
|
+
command: options.command,
|
|
106
|
+
prefix: options.prefix,
|
|
107
|
+
error: output,
|
|
108
|
+
});
|
|
109
|
+
ctx.ui?.notify(output, "error");
|
|
110
|
+
return { handled: true };
|
|
111
|
+
} finally {
|
|
112
|
+
activeDispatches.delete(event);
|
|
113
|
+
if (previousDepth === undefined) {
|
|
114
|
+
delete process.env[recursionEnv];
|
|
115
|
+
} else {
|
|
116
|
+
process.env[recursionEnv] = previousDepth;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function createOuroborosOooBridge() {
|
|
123
|
+
return createExactPrefixCommandBridge({
|
|
124
|
+
prefix: "ooo",
|
|
125
|
+
command: "ouroboros",
|
|
126
|
+
args: ["dispatch"],
|
|
127
|
+
});
|
|
128
|
+
}
|
|
@@ -0,0 +1,457 @@
|
|
|
1
|
+
import { syncSkillActiveState } from "../skill-state/active-state";
|
|
2
|
+
import { deriveDeepInterviewHud } from "../skill-state/workflow-hud";
|
|
3
|
+
import { WORKFLOW_STATE_VERSION } from "../skill-state/workflow-state-contract";
|
|
4
|
+
import {
|
|
5
|
+
answerHash,
|
|
6
|
+
type DeepInterviewEstablishedFact,
|
|
7
|
+
type DeepInterviewRoundRecord,
|
|
8
|
+
type DeepInterviewStateEnvelope,
|
|
9
|
+
type DeepInterviewTriggerMetadata,
|
|
10
|
+
deriveRoundKey,
|
|
11
|
+
normalizeDeepInterviewEnvelope,
|
|
12
|
+
questionHash,
|
|
13
|
+
} from "./deep-interview-state";
|
|
14
|
+
import { readExistingStateForMutation, writeWorkflowEnvelopeAtomic } from "./state-writer";
|
|
15
|
+
|
|
16
|
+
export * from "./deep-interview-state";
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Runtime-owned deep-interview round recorder (conflict-aware scoring support).
|
|
20
|
+
*
|
|
21
|
+
* Ownership boundary (per the approved consensus plan): this module owns durable
|
|
22
|
+
* round-record semantics — stable identity, append-or-merge, lifecycle, compact
|
|
23
|
+
* reads, replay detection, and the pure scored-transition validator. Callers such
|
|
24
|
+
* as the `ask` tool only resolve an answer and invoke these helpers; they never
|
|
25
|
+
* compute state paths, merge records, or write `.gjc` files directly. All writes
|
|
26
|
+
* go through the sanctioned state-writer (`writeWorkflowEnvelopeAtomic`).
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
// =============================================================================
|
|
30
|
+
// Domain types
|
|
31
|
+
// =============================================================================
|
|
32
|
+
|
|
33
|
+
export interface DeepInterviewAnswerInput {
|
|
34
|
+
interviewId?: string;
|
|
35
|
+
round: number;
|
|
36
|
+
round_id?: string;
|
|
37
|
+
questionId?: string;
|
|
38
|
+
questionText: string;
|
|
39
|
+
component?: string;
|
|
40
|
+
dimension?: string;
|
|
41
|
+
ambiguity?: number;
|
|
42
|
+
selectedOptions?: string[];
|
|
43
|
+
customInput?: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface DeepInterviewScoringInput {
|
|
47
|
+
interviewId?: string;
|
|
48
|
+
round: number;
|
|
49
|
+
round_id?: string;
|
|
50
|
+
questionId?: string;
|
|
51
|
+
scores: Record<string, number>;
|
|
52
|
+
ambiguity: number;
|
|
53
|
+
triggers?: DeepInterviewTriggerMetadata[];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export type AppendOrMergeAction = "created" | "noop" | "replaced";
|
|
57
|
+
|
|
58
|
+
export interface AppendOrMergeResult {
|
|
59
|
+
rounds: DeepInterviewRoundRecord[];
|
|
60
|
+
action: AppendOrMergeAction;
|
|
61
|
+
record: DeepInterviewRoundRecord;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface DeepInterviewCompactState {
|
|
65
|
+
threshold?: number;
|
|
66
|
+
threshold_source?: string;
|
|
67
|
+
current_ambiguity?: number;
|
|
68
|
+
topology_summary?: { active: number; deferred: number; components: string[] };
|
|
69
|
+
established_facts: DeepInterviewEstablishedFact[];
|
|
70
|
+
unresolved_triggers: DeepInterviewTriggerMetadata[];
|
|
71
|
+
recent_scored_rounds: DeepInterviewRoundRecord[];
|
|
72
|
+
pending_shells: DeepInterviewRoundRecord[];
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export interface TransitionValidationResult {
|
|
76
|
+
ok: boolean;
|
|
77
|
+
violations: string[];
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// =============================================================================
|
|
81
|
+
// Pure helpers: records
|
|
82
|
+
// =============================================================================
|
|
83
|
+
|
|
84
|
+
export function buildAnswerShell(
|
|
85
|
+
input: DeepInterviewAnswerInput,
|
|
86
|
+
now: string = new Date().toISOString(),
|
|
87
|
+
): DeepInterviewRoundRecord {
|
|
88
|
+
return {
|
|
89
|
+
round_key: deriveRoundKey(input.interviewId, input),
|
|
90
|
+
round_id: input.round_id,
|
|
91
|
+
round: input.round,
|
|
92
|
+
question_id: input.questionId,
|
|
93
|
+
question_text: input.questionText,
|
|
94
|
+
question_hash: questionHash(input.questionText),
|
|
95
|
+
answer_hash: answerHash(input.selectedOptions, input.customInput),
|
|
96
|
+
selected_options: input.selectedOptions,
|
|
97
|
+
custom_input: input.customInput,
|
|
98
|
+
component: input.component,
|
|
99
|
+
dimension: input.dimension,
|
|
100
|
+
ambiguity_at_ask: input.ambiguity,
|
|
101
|
+
lifecycle: "answered",
|
|
102
|
+
answered_at: now,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Append-or-merge by `round_key`. Exactly one record per key:
|
|
108
|
+
* - no existing record -> append (`created`);
|
|
109
|
+
* - identical question_hash + answer_hash -> deterministic no-op (`noop`);
|
|
110
|
+
* - same key, different hashes -> deterministic replacement of the prior shell
|
|
111
|
+
* (`replaced`); the prior answer for that key is superseded and lifecycle resets.
|
|
112
|
+
*/
|
|
113
|
+
export function appendOrMergeRound(
|
|
114
|
+
rounds: readonly DeepInterviewRoundRecord[],
|
|
115
|
+
shell: DeepInterviewRoundRecord,
|
|
116
|
+
): AppendOrMergeResult {
|
|
117
|
+
const next = [...rounds];
|
|
118
|
+
const index = next.findIndex(r => r.round_key === shell.round_key);
|
|
119
|
+
if (index < 0) {
|
|
120
|
+
next.push(shell);
|
|
121
|
+
return { rounds: next, action: "created", record: shell };
|
|
122
|
+
}
|
|
123
|
+
const existing = next[index];
|
|
124
|
+
if (existing.question_hash === shell.question_hash && existing.answer_hash === shell.answer_hash) {
|
|
125
|
+
return { rounds: next, action: "noop", record: existing };
|
|
126
|
+
}
|
|
127
|
+
next[index] = shell;
|
|
128
|
+
return { rounds: next, action: "replaced", record: shell };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Merge scoring output into the existing record for the derived key, transitioning
|
|
133
|
+
* it to `scored`. Never appends a second record for the same key; if no shell exists
|
|
134
|
+
* yet (scoring without a prior ask), a scored record is created so data is not lost.
|
|
135
|
+
*/
|
|
136
|
+
export function enrichRoundWithScoring(
|
|
137
|
+
rounds: readonly DeepInterviewRoundRecord[],
|
|
138
|
+
input: DeepInterviewScoringInput,
|
|
139
|
+
now: string = new Date().toISOString(),
|
|
140
|
+
): { rounds: DeepInterviewRoundRecord[]; record: DeepInterviewRoundRecord } {
|
|
141
|
+
const roundKey = deriveRoundKey(input.interviewId, input);
|
|
142
|
+
const next = [...rounds];
|
|
143
|
+
const index = next.findIndex(r => r.round_key === roundKey);
|
|
144
|
+
if (index < 0) {
|
|
145
|
+
const created: DeepInterviewRoundRecord = {
|
|
146
|
+
round_key: roundKey,
|
|
147
|
+
round_id: input.round_id,
|
|
148
|
+
round: input.round,
|
|
149
|
+
question_id: input.questionId,
|
|
150
|
+
question_hash: "",
|
|
151
|
+
answer_hash: "",
|
|
152
|
+
lifecycle: "scored",
|
|
153
|
+
answered_at: now,
|
|
154
|
+
scored_at: now,
|
|
155
|
+
scores: input.scores,
|
|
156
|
+
ambiguity: input.ambiguity,
|
|
157
|
+
triggers: input.triggers,
|
|
158
|
+
};
|
|
159
|
+
next.push(created);
|
|
160
|
+
return { rounds: next, record: created };
|
|
161
|
+
}
|
|
162
|
+
const merged: DeepInterviewRoundRecord = {
|
|
163
|
+
...next[index],
|
|
164
|
+
lifecycle: "scored",
|
|
165
|
+
scored_at: now,
|
|
166
|
+
scores: input.scores,
|
|
167
|
+
ambiguity: input.ambiguity,
|
|
168
|
+
triggers: input.triggers,
|
|
169
|
+
};
|
|
170
|
+
next[index] = merged;
|
|
171
|
+
return { rounds: next, record: merged };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// =============================================================================
|
|
175
|
+
// Pure helper: scored-transition validator
|
|
176
|
+
// =============================================================================
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Bidirectional invariant: if `next` carries an `active` trigger, the affected
|
|
180
|
+
* dimension must not improve and overall ambiguity must rise vs the prior scored
|
|
181
|
+
* round. `disputed`/`unresolved` triggers are exempt but must carry a rationale.
|
|
182
|
+
*/
|
|
183
|
+
export function validateDeepInterviewScoredTransition(
|
|
184
|
+
prior: DeepInterviewRoundRecord | undefined,
|
|
185
|
+
next: DeepInterviewRoundRecord,
|
|
186
|
+
): TransitionValidationResult {
|
|
187
|
+
const violations: string[] = [];
|
|
188
|
+
const triggers = next.triggers ?? [];
|
|
189
|
+
for (const trigger of triggers) {
|
|
190
|
+
if (trigger.status === "disputed" || trigger.status === "unresolved") {
|
|
191
|
+
if (!trigger.rationale || trigger.rationale.trim() === "") {
|
|
192
|
+
violations.push(`trigger ${trigger.kind} is ${trigger.status} but has no rationale`);
|
|
193
|
+
}
|
|
194
|
+
continue;
|
|
195
|
+
}
|
|
196
|
+
// status === "active": enforce the invariant only when a prior scored round exists.
|
|
197
|
+
if (!prior) continue;
|
|
198
|
+
// Ambiguity must be present on both sides and must rise; missing metrics cannot prove a rise.
|
|
199
|
+
if (typeof prior.ambiguity !== "number" || typeof next.ambiguity !== "number") {
|
|
200
|
+
violations.push(`active trigger ${trigger.kind} is missing ambiguity metrics to prove a rise`);
|
|
201
|
+
} else if (!(next.ambiguity > prior.ambiguity)) {
|
|
202
|
+
violations.push(
|
|
203
|
+
`active trigger ${trigger.kind} did not raise ambiguity (${prior.ambiguity} -> ${next.ambiguity})`,
|
|
204
|
+
);
|
|
205
|
+
}
|
|
206
|
+
// Affected dimension must not improve. Prefer record scores, fall back to the trigger's
|
|
207
|
+
// own prior/new dimension scores; absent metrics cannot prove non-improvement.
|
|
208
|
+
const priorDim = prior.scores?.[trigger.dimension] ?? trigger.priorDimensionScore;
|
|
209
|
+
const nextDim = next.scores?.[trigger.dimension] ?? trigger.newDimensionScore;
|
|
210
|
+
if (typeof priorDim !== "number" || typeof nextDim !== "number") {
|
|
211
|
+
violations.push(
|
|
212
|
+
`active trigger ${trigger.kind} is missing dimension "${trigger.dimension}" scores to prove non-improvement`,
|
|
213
|
+
);
|
|
214
|
+
} else if (nextDim > priorDim) {
|
|
215
|
+
violations.push(
|
|
216
|
+
`active trigger ${trigger.kind} on dimension "${trigger.dimension}" improved clarity ${priorDim} -> ${nextDim}`,
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
return { ok: violations.length === 0, violations };
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// =============================================================================
|
|
224
|
+
// Pure helper: state-shape migration + compact projection
|
|
225
|
+
// =============================================================================
|
|
226
|
+
|
|
227
|
+
/** Back-compat wrapper: normalize a deep-interview envelope to its canonical nested shape. */
|
|
228
|
+
export function ensureDeepInterviewStateShape(value: unknown): DeepInterviewStateEnvelope {
|
|
229
|
+
return normalizeDeepInterviewEnvelope(value);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function readRounds(envelope: DeepInterviewStateEnvelope): DeepInterviewRoundRecord[] {
|
|
233
|
+
const inner = (envelope.state ?? {}) as Record<string, unknown>;
|
|
234
|
+
return Array.isArray(inner.rounds) ? (inner.rounds as DeepInterviewRoundRecord[]) : [];
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
export function projectCompactState(value: unknown, options: { lastN?: number } = {}): DeepInterviewCompactState {
|
|
238
|
+
const lastN = options.lastN ?? 3;
|
|
239
|
+
const envelope = ensureDeepInterviewStateShape(value);
|
|
240
|
+
const inner = (envelope.state ?? {}) as Record<string, unknown>;
|
|
241
|
+
const rounds = readRounds(envelope);
|
|
242
|
+
const scored = rounds.filter(r => r.lifecycle === "scored");
|
|
243
|
+
const pending = rounds.filter(r => r.lifecycle !== "scored");
|
|
244
|
+
const latestScored = scored.length > 0 ? scored[scored.length - 1] : undefined;
|
|
245
|
+
const established = Array.isArray(inner.established_facts)
|
|
246
|
+
? (inner.established_facts as DeepInterviewEstablishedFact[])
|
|
247
|
+
: [];
|
|
248
|
+
const unresolved: DeepInterviewTriggerMetadata[] = [];
|
|
249
|
+
for (const round of scored) {
|
|
250
|
+
for (const trigger of round.triggers ?? []) {
|
|
251
|
+
if (trigger.status === "unresolved" || trigger.status === "disputed") unresolved.push(trigger);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
const topology = inner.topology as { components?: Array<{ status?: string; name?: string }> } | undefined;
|
|
255
|
+
let topologySummary: DeepInterviewCompactState["topology_summary"];
|
|
256
|
+
if (topology && Array.isArray(topology.components)) {
|
|
257
|
+
const active = topology.components.filter(c => c.status !== "deferred");
|
|
258
|
+
topologySummary = {
|
|
259
|
+
active: active.length,
|
|
260
|
+
deferred: topology.components.length - active.length,
|
|
261
|
+
components: topology.components.map(c => c.name ?? "").filter(Boolean),
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
return {
|
|
265
|
+
threshold: typeof envelope.threshold === "number" ? envelope.threshold : (inner.threshold as number | undefined),
|
|
266
|
+
threshold_source:
|
|
267
|
+
typeof envelope.threshold_source === "string"
|
|
268
|
+
? envelope.threshold_source
|
|
269
|
+
: (inner.threshold_source as string | undefined),
|
|
270
|
+
current_ambiguity:
|
|
271
|
+
typeof latestScored?.ambiguity === "number"
|
|
272
|
+
? latestScored.ambiguity
|
|
273
|
+
: (inner.current_ambiguity as number | undefined),
|
|
274
|
+
topology_summary: topologySummary,
|
|
275
|
+
established_facts: established,
|
|
276
|
+
unresolved_triggers: unresolved,
|
|
277
|
+
recent_scored_rounds: scored.slice(-lastN),
|
|
278
|
+
pending_shells: pending,
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// =============================================================================
|
|
283
|
+
// Persistence wrappers (state-writer backed; runtime-owned)
|
|
284
|
+
// =============================================================================
|
|
285
|
+
|
|
286
|
+
async function readEnvelope(statePath: string): Promise<DeepInterviewStateEnvelope> {
|
|
287
|
+
const read = await readExistingStateForMutation(statePath);
|
|
288
|
+
if (read.kind === "valid") return ensureDeepInterviewStateShape(read.value);
|
|
289
|
+
if (read.kind === "corrupt") {
|
|
290
|
+
// Fail closed: never silently overwrite a corrupt/tampered state file. Callers
|
|
291
|
+
// (e.g. the ask tool) catch this and warn without mutating, preserving the file
|
|
292
|
+
// for recovery. Only a genuinely absent file is defaulted below.
|
|
293
|
+
throw new Error(
|
|
294
|
+
`deep-interview state at ${statePath} is corrupt or tampered (${read.error}); refusing to overwrite`,
|
|
295
|
+
);
|
|
296
|
+
}
|
|
297
|
+
// Absent: start from a defaulted shape.
|
|
298
|
+
return ensureDeepInterviewStateShape(undefined);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function interviewIdOf(envelope: DeepInterviewStateEnvelope): string | undefined {
|
|
302
|
+
const inner = (envelope.state ?? {}) as Record<string, unknown>;
|
|
303
|
+
return typeof inner.interview_id === "string" ? inner.interview_id : undefined;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
async function persistEnvelope(
|
|
307
|
+
cwd: string,
|
|
308
|
+
statePath: string,
|
|
309
|
+
envelope: DeepInterviewStateEnvelope,
|
|
310
|
+
sessionId: string | undefined,
|
|
311
|
+
command: string,
|
|
312
|
+
): Promise<void> {
|
|
313
|
+
const now = new Date().toISOString();
|
|
314
|
+
const payload: Record<string, unknown> = { ...normalizeDeepInterviewEnvelope(envelope), updated_at: now };
|
|
315
|
+
// Guarantee RequiredOnWriteEnvelopeSchema fields for the fresh/absent fallback;
|
|
316
|
+
// existing real state already carries these and is preserved by the spread above.
|
|
317
|
+
payload.skill ??= "deep-interview";
|
|
318
|
+
payload.version ??= WORKFLOW_STATE_VERSION;
|
|
319
|
+
payload.active ??= true;
|
|
320
|
+
payload.current_phase ??= "interviewing";
|
|
321
|
+
await writeWorkflowEnvelopeAtomic(statePath, payload, {
|
|
322
|
+
cwd,
|
|
323
|
+
receipt: { cwd, skill: "deep-interview", owner: "gjc-runtime", command, sessionId, nowIso: now },
|
|
324
|
+
audit: { category: "state", verb: "write", owner: "gjc-runtime", skill: "deep-interview" },
|
|
325
|
+
});
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* Best-effort active-state/HUD cache refresh for the deep-interview rail, derived
|
|
330
|
+
* from the complete normalized mode-state envelope. HUD is a cache; a failure here
|
|
331
|
+
* must never change durable record semantics.
|
|
332
|
+
*/
|
|
333
|
+
async function syncRecorderHud(
|
|
334
|
+
cwd: string,
|
|
335
|
+
envelope: DeepInterviewStateEnvelope,
|
|
336
|
+
sessionId: string | undefined,
|
|
337
|
+
): Promise<void> {
|
|
338
|
+
try {
|
|
339
|
+
const phase = typeof envelope.current_phase === "string" ? envelope.current_phase : "interviewing";
|
|
340
|
+
await syncSkillActiveState({
|
|
341
|
+
cwd,
|
|
342
|
+
skill: "deep-interview",
|
|
343
|
+
active: phase !== "complete",
|
|
344
|
+
phase,
|
|
345
|
+
sessionId,
|
|
346
|
+
source: "gjc-runtime-deep-interview-recorder",
|
|
347
|
+
hud: deriveDeepInterviewHud(envelope as Record<string, unknown>, { phase }),
|
|
348
|
+
});
|
|
349
|
+
} catch {
|
|
350
|
+
// HUD sync is best-effort cache maintenance and must not change record semantics.
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* Repair the cached HUD after a no-op append. A no-op writes no mode-state, so the
|
|
356
|
+
* HUD is derived from a fresh read of the current persisted state (never from the
|
|
357
|
+
* pre-noop in-memory envelope) to avoid overwriting newer active-state with stale values.
|
|
358
|
+
*/
|
|
359
|
+
async function repairRecorderHudFromPersisted(
|
|
360
|
+
cwd: string,
|
|
361
|
+
statePath: string,
|
|
362
|
+
sessionId: string | undefined,
|
|
363
|
+
): Promise<void> {
|
|
364
|
+
const read = await readExistingStateForMutation(statePath);
|
|
365
|
+
if (read.kind !== "valid") return;
|
|
366
|
+
await syncRecorderHud(cwd, normalizeDeepInterviewEnvelope(read.value), sessionId);
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/** Record an `answered` shell for one round (append-or-merge by durable key). */
|
|
370
|
+
export async function appendOrMergeDeepInterviewRound(
|
|
371
|
+
cwd: string,
|
|
372
|
+
statePath: string,
|
|
373
|
+
input: DeepInterviewAnswerInput,
|
|
374
|
+
options: { sessionId?: string } = {},
|
|
375
|
+
): Promise<{ action: AppendOrMergeAction; record: DeepInterviewRoundRecord }> {
|
|
376
|
+
const envelope = await readEnvelope(statePath);
|
|
377
|
+
const interviewId = input.interviewId ?? interviewIdOf(envelope);
|
|
378
|
+
const shell = buildAnswerShell({ ...input, interviewId });
|
|
379
|
+
const rounds = readRounds(envelope);
|
|
380
|
+
const result = appendOrMergeRound(rounds, shell);
|
|
381
|
+
if (result.action === "noop") {
|
|
382
|
+
await repairRecorderHudFromPersisted(cwd, statePath, options.sessionId);
|
|
383
|
+
return { action: result.action, record: result.record };
|
|
384
|
+
}
|
|
385
|
+
(envelope.state as Record<string, unknown>).rounds = result.rounds;
|
|
386
|
+
await persistEnvelope(cwd, statePath, envelope, options.sessionId, "gjc deep-interview record-answer");
|
|
387
|
+
await syncRecorderHud(cwd, envelope, options.sessionId);
|
|
388
|
+
return { action: result.action, record: result.record };
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* The chronological scored predecessor of the round currently being scored: the
|
|
393
|
+
* scored round with the greatest `round` strictly less than `currentRound`, with
|
|
394
|
+
* the same durable key excluded. Selecting by `round` (not array position) ensures
|
|
395
|
+
* an out-of-order re-score of an earlier round compares against its true prior, never
|
|
396
|
+
* a later ("future") scored round that happens to sit later in the array.
|
|
397
|
+
*
|
|
398
|
+
* Fail-safe: if `currentRound` is not a finite number, or a candidate's `round` is
|
|
399
|
+
* not finite, that comparison is treated as non-matching, so no prior is selected
|
|
400
|
+
* rather than risking a spurious comparison against an unrelated round.
|
|
401
|
+
*/
|
|
402
|
+
function latestPriorScoredRound(
|
|
403
|
+
rounds: readonly DeepInterviewRoundRecord[],
|
|
404
|
+
currentKey: string,
|
|
405
|
+
currentRound: number,
|
|
406
|
+
): DeepInterviewRoundRecord | undefined {
|
|
407
|
+
if (!Number.isFinite(currentRound)) return undefined;
|
|
408
|
+
let prior: DeepInterviewRoundRecord | undefined;
|
|
409
|
+
for (const candidate of rounds) {
|
|
410
|
+
if (candidate.lifecycle !== "scored") continue;
|
|
411
|
+
if (candidate.round_key === currentKey) continue;
|
|
412
|
+
if (!Number.isFinite(candidate.round)) continue;
|
|
413
|
+
if (!(candidate.round < currentRound)) continue;
|
|
414
|
+
if (prior === undefined || candidate.round > prior.round) prior = candidate;
|
|
415
|
+
}
|
|
416
|
+
return prior;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/** Merge scoring output into the same round record, transitioning to `scored`. */
|
|
420
|
+
export async function enrichDeepInterviewRoundScoring(
|
|
421
|
+
cwd: string,
|
|
422
|
+
statePath: string,
|
|
423
|
+
input: DeepInterviewScoringInput,
|
|
424
|
+
options: { sessionId?: string } = {},
|
|
425
|
+
): Promise<{ record: DeepInterviewRoundRecord }> {
|
|
426
|
+
const envelope = await readEnvelope(statePath);
|
|
427
|
+
const interviewId = input.interviewId ?? interviewIdOf(envelope);
|
|
428
|
+
const rounds = readRounds(envelope);
|
|
429
|
+
const { rounds: nextRounds, record } = enrichRoundWithScoring(rounds, { ...input, interviewId });
|
|
430
|
+
// Fail closed: a scored transition that violates the bidirectional invariant
|
|
431
|
+
// (an active trigger that improves the affected dimension or fails to raise
|
|
432
|
+
// overall ambiguity, or a disputed/unresolved trigger lacking a rationale) must
|
|
433
|
+
// never be persisted — storing it lets the interview falsely converge. Validate
|
|
434
|
+
// against the most recent prior scored round before writing any durable state.
|
|
435
|
+
const prior = latestPriorScoredRound(rounds, record.round_key, record.round);
|
|
436
|
+
const validation = validateDeepInterviewScoredTransition(prior, record);
|
|
437
|
+
if (!validation.ok) {
|
|
438
|
+
throw new Error(
|
|
439
|
+
`deep-interview scored transition for round ${record.round} is invalid and was refused: ${validation.violations.join("; ")}`,
|
|
440
|
+
);
|
|
441
|
+
}
|
|
442
|
+
(envelope.state as Record<string, unknown>).rounds = nextRounds;
|
|
443
|
+
(envelope.state as Record<string, unknown>).current_ambiguity = input.ambiguity;
|
|
444
|
+
await persistEnvelope(cwd, statePath, envelope, options.sessionId, "gjc deep-interview score-round");
|
|
445
|
+
await syncRecorderHud(cwd, envelope, options.sessionId);
|
|
446
|
+
return { record };
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/** Compact projection so callers read a slice instead of the full transcript. */
|
|
450
|
+
export async function readDeepInterviewStateCompact(
|
|
451
|
+
statePath: string,
|
|
452
|
+
options: { lastN?: number } = {},
|
|
453
|
+
): Promise<DeepInterviewCompactState> {
|
|
454
|
+
const read = await readExistingStateForMutation(statePath);
|
|
455
|
+
const value = read.kind === "valid" ? read.value : undefined;
|
|
456
|
+
return projectCompactState(value, options);
|
|
457
|
+
}
|