@gajae-code/coding-agent 0.5.0 → 0.5.1
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 +19 -0
- 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/commands/gc.d.ts +26 -0
- package/dist/types/config/file-lock-gc.d.ts +5 -0
- package/dist/types/config/file-lock.d.ts +7 -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/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/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/controllers/command-controller.d.ts +1 -0
- package/dist/types/modes/rpc/rpc-mode.d.ts +16 -1
- 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/session/agent-session.d.ts +1 -1
- package/dist/types/session/blob-store.d.ts +39 -3
- package/dist/types/skill-state/workflow-hud.d.ts +14 -0
- package/dist/types/tools/ask.d.ts +15 -1
- package/dist/types/tools/subagent.d.ts +6 -0
- package/package.json +7 -7
- package/src/async/job-manager.ts +52 -0
- package/src/cli/args.ts +3 -0
- package/src/cli/auth-broker-cli.ts +1 -0
- package/src/cli/list-models.ts +13 -1
- package/src/cli.ts +1 -0
- package/src/commands/gc.ts +22 -0
- package/src/commands/harness.ts +7 -3
- package/src/config/file-lock-gc.ts +181 -0
- package/src/config/file-lock.ts +14 -0
- package/src/config/model-profiles.ts +24 -15
- 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-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 +417 -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/ledger-event-renderer.ts +164 -0
- package/src/gjc-runtime/ralplan-runtime.ts +58 -7
- package/src/gjc-runtime/state-renderer.ts +12 -3
- package/src/gjc-runtime/state-runtime.ts +46 -29
- 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 +176 -0
- package/src/gjc-runtime/tmux-sessions.ts +49 -1
- package/src/gjc-runtime/ultragoal-runtime.ts +12 -0
- package/src/harness-control-plane/gc-adapter.ts +184 -0
- package/src/harness-control-plane/owner.ts +11 -0
- package/src/harness-control-plane/storage.ts +70 -0
- package/src/internal-urls/docs-index.generated.ts +14 -8
- package/src/main.ts +7 -2
- package/src/modes/components/hook-selector.ts +19 -0
- package/src/modes/components/model-selector.ts +25 -8
- 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 +1 -0
- package/src/modes/rpc/rpc-mode.ts +151 -33
- package/src/modes/shared/agent-wire/command-dispatch.ts +278 -261
- package/src/modes/shared/agent-wire/deep-interview-gate.ts +30 -1
- 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 +16 -1
- package/src/sdk.ts +17 -3
- package/src/session/agent-session.ts +77 -8
- package/src/session/blob-store.ts +59 -3
- package/src/session/session-manager.ts +4 -4
- package/src/setup/hermes/templates/operator-instructions.v1.md +7 -1
- package/src/skill-state/workflow-hud.ts +106 -10
- package/src/slash-commands/builtin-registry.ts +3 -2
- package/src/task/executor.ts +9 -0
- package/src/tools/ask.ts +56 -1
- package/src/tools/job.ts +3 -2
- package/src/tools/monitor.ts +36 -1
- package/src/tools/subagent-render.ts +9 -0
- package/src/tools/subagent.ts +26 -2
|
@@ -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,417 @@
|
|
|
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
|
+
/** Merge scoring output into the same round record, transitioning to `scored`. */
|
|
392
|
+
export async function enrichDeepInterviewRoundScoring(
|
|
393
|
+
cwd: string,
|
|
394
|
+
statePath: string,
|
|
395
|
+
input: DeepInterviewScoringInput,
|
|
396
|
+
options: { sessionId?: string } = {},
|
|
397
|
+
): Promise<{ record: DeepInterviewRoundRecord }> {
|
|
398
|
+
const envelope = await readEnvelope(statePath);
|
|
399
|
+
const interviewId = input.interviewId ?? interviewIdOf(envelope);
|
|
400
|
+
const rounds = readRounds(envelope);
|
|
401
|
+
const { rounds: nextRounds, record } = enrichRoundWithScoring(rounds, { ...input, interviewId });
|
|
402
|
+
(envelope.state as Record<string, unknown>).rounds = nextRounds;
|
|
403
|
+
(envelope.state as Record<string, unknown>).current_ambiguity = input.ambiguity;
|
|
404
|
+
await persistEnvelope(cwd, statePath, envelope, options.sessionId, "gjc deep-interview score-round");
|
|
405
|
+
await syncRecorderHud(cwd, envelope, options.sessionId);
|
|
406
|
+
return { record };
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/** Compact projection so callers read a slice instead of the full transcript. */
|
|
410
|
+
export async function readDeepInterviewStateCompact(
|
|
411
|
+
statePath: string,
|
|
412
|
+
options: { lastN?: number } = {},
|
|
413
|
+
): Promise<DeepInterviewCompactState> {
|
|
414
|
+
const read = await readExistingStateForMutation(statePath);
|
|
415
|
+
const value = read.kind === "valid" ? read.value : undefined;
|
|
416
|
+
return projectCompactState(value, options);
|
|
417
|
+
}
|
|
@@ -3,12 +3,15 @@ import * as fs from "node:fs/promises";
|
|
|
3
3
|
import * as os from "node:os";
|
|
4
4
|
import * as path from "node:path";
|
|
5
5
|
import { syncSkillActiveState } from "../skill-state/active-state";
|
|
6
|
-
import {
|
|
6
|
+
import { deriveDeepInterviewHud } from "../skill-state/workflow-hud";
|
|
7
7
|
import { WORKFLOW_STATE_VERSION } from "../skill-state/workflow-state-contract";
|
|
8
|
+
import { normalizeDeepInterviewEnvelope } from "./deep-interview-state";
|
|
8
9
|
import { runNativeRalplanCommand } from "./ralplan-runtime";
|
|
9
10
|
import { runNativeStateCommand } from "./state-runtime";
|
|
10
11
|
import { appendJsonl, readExistingStateForMutation, writeArtifact, writeWorkflowEnvelopeAtomic } from "./state-writer";
|
|
11
12
|
|
|
13
|
+
export * from "./deep-interview-recorder";
|
|
14
|
+
|
|
12
15
|
/**
|
|
13
16
|
* Native implementation of `gjc deep-interview`.
|
|
14
17
|
*
|
|
@@ -92,7 +95,7 @@ function stateDirFor(cwd: string, sessionId: string | undefined): string {
|
|
|
92
95
|
: path.join(cwd, ".gjc", "state");
|
|
93
96
|
}
|
|
94
97
|
|
|
95
|
-
function deepInterviewStatePath(cwd: string, sessionId: string | undefined): string {
|
|
98
|
+
export function deepInterviewStatePath(cwd: string, sessionId: string | undefined): string {
|
|
96
99
|
return path.join(stateDirFor(cwd, sessionId), "deep-interview-state.json");
|
|
97
100
|
}
|
|
98
101
|
|
|
@@ -413,7 +416,7 @@ export async function persistDeepInterviewSpec(
|
|
|
413
416
|
{ cwd, audit: { category: "ledger", verb: "append", owner: "gjc-runtime", skill: "deep-interview" } },
|
|
414
417
|
);
|
|
415
418
|
|
|
416
|
-
const payload
|
|
419
|
+
const payload = normalizeDeepInterviewEnvelope({
|
|
417
420
|
...existing,
|
|
418
421
|
active: true,
|
|
419
422
|
current_phase: "handoff",
|
|
@@ -425,7 +428,7 @@ export async function persistDeepInterviewSpec(
|
|
|
425
428
|
spec_stage: resolved.stage,
|
|
426
429
|
spec_persisted_at: createdAt,
|
|
427
430
|
updated_at: createdAt,
|
|
428
|
-
}
|
|
431
|
+
}) as Record<string, unknown>;
|
|
429
432
|
if (resolved.sessionId) payload.session_id = resolved.sessionId;
|
|
430
433
|
await writeWorkflowEnvelopeAtomic(statePath, payload, {
|
|
431
434
|
cwd,
|
|
@@ -448,6 +451,7 @@ export async function persistDeepInterviewSpec(
|
|
|
448
451
|
await syncDeepInterviewHud({
|
|
449
452
|
cwd,
|
|
450
453
|
sessionId: resolved.sessionId,
|
|
454
|
+
payload,
|
|
451
455
|
phase: "handoff",
|
|
452
456
|
specStatus: "persisted",
|
|
453
457
|
});
|
|
@@ -476,6 +480,7 @@ async function seedDeepInterviewState(cwd: string, resolved: ResolvedDeepIntervi
|
|
|
476
480
|
state: {
|
|
477
481
|
initial_idea: resolved.idea,
|
|
478
482
|
rounds: [],
|
|
483
|
+
established_facts: [],
|
|
479
484
|
current_ambiguity: 1.0,
|
|
480
485
|
threshold: resolved.threshold,
|
|
481
486
|
threshold_source: resolved.thresholdSource,
|
|
@@ -499,34 +504,29 @@ async function seedDeepInterviewState(cwd: string, resolved: ResolvedDeepIntervi
|
|
|
499
504
|
},
|
|
500
505
|
audit: { category: "state", verb: "write", owner: "gjc-runtime", skill: "deep-interview" },
|
|
501
506
|
});
|
|
507
|
+
await syncDeepInterviewHud({ cwd, sessionId: resolved.sessionId, payload, phase: "interviewing" });
|
|
502
508
|
return statePath;
|
|
503
509
|
}
|
|
504
510
|
|
|
505
511
|
async function syncDeepInterviewHud(options: {
|
|
506
512
|
cwd: string;
|
|
507
513
|
sessionId?: string;
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
threshold?: number;
|
|
511
|
-
roundCount?: number;
|
|
514
|
+
payload: Record<string, unknown>;
|
|
515
|
+
phase?: string;
|
|
512
516
|
specStatus?: string;
|
|
513
517
|
}): Promise<void> {
|
|
514
518
|
try {
|
|
519
|
+
const phase =
|
|
520
|
+
options.phase ??
|
|
521
|
+
(typeof options.payload.current_phase === "string" ? options.payload.current_phase : "interviewing");
|
|
515
522
|
await syncSkillActiveState({
|
|
516
523
|
cwd: options.cwd,
|
|
517
524
|
skill: "deep-interview",
|
|
518
|
-
active:
|
|
519
|
-
phase
|
|
525
|
+
active: phase !== "complete",
|
|
526
|
+
phase,
|
|
520
527
|
sessionId: options.sessionId,
|
|
521
528
|
source: "gjc-deep-interview-native",
|
|
522
|
-
hud:
|
|
523
|
-
phase: options.phase,
|
|
524
|
-
ambiguity: options.ambiguity,
|
|
525
|
-
threshold: options.threshold,
|
|
526
|
-
roundCount: options.roundCount,
|
|
527
|
-
specStatus: options.specStatus,
|
|
528
|
-
updatedAt: new Date().toISOString(),
|
|
529
|
-
}),
|
|
529
|
+
hud: deriveDeepInterviewHud(options.payload, { phase, specStatus: options.specStatus }),
|
|
530
530
|
});
|
|
531
531
|
} catch {
|
|
532
532
|
// HUD sync is best-effort and must not change command semantics.
|
|
@@ -611,14 +611,6 @@ export async function runNativeDeepInterviewCommand(
|
|
|
611
611
|
);
|
|
612
612
|
}
|
|
613
613
|
const statePath = await seedDeepInterviewState(cwd, resolved);
|
|
614
|
-
await syncDeepInterviewHud({
|
|
615
|
-
cwd,
|
|
616
|
-
sessionId: resolved.sessionId,
|
|
617
|
-
phase: "interviewing",
|
|
618
|
-
ambiguity: 1,
|
|
619
|
-
threshold: resolved.threshold,
|
|
620
|
-
roundCount: 0,
|
|
621
|
-
});
|
|
622
614
|
|
|
623
615
|
const summary = {
|
|
624
616
|
skill: "deep-interview",
|