@gajae-code/coding-agent 0.4.5 → 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.
Files changed (185) hide show
  1. package/CHANGELOG.md +62 -0
  2. package/dist/types/async/job-manager.d.ts +26 -0
  3. package/dist/types/cli/args.d.ts +1 -0
  4. package/dist/types/cli/list-models.d.ts +6 -0
  5. package/dist/types/commands/gc.d.ts +26 -0
  6. package/dist/types/commands/harness.d.ts +3 -0
  7. package/dist/types/config/file-lock-gc.d.ts +5 -0
  8. package/dist/types/config/file-lock.d.ts +7 -0
  9. package/dist/types/config/model-profile-activation.d.ts +11 -2
  10. package/dist/types/config/model-profiles.d.ts +7 -0
  11. package/dist/types/config/model-registry.d.ts +3 -0
  12. package/dist/types/config/model-resolver.d.ts +2 -0
  13. package/dist/types/config/models-config-schema.d.ts +30 -0
  14. package/dist/types/config/settings-schema.d.ts +4 -3
  15. package/dist/types/coordinator/contract.d.ts +1 -1
  16. package/dist/types/defaults/gjc/extensions/grok-build/index.d.ts +1 -0
  17. package/dist/types/defaults/gjc/extensions/grok-cli-vendor/src/index.d.ts +1 -0
  18. package/dist/types/defaults/gjc/extensions/grok-cli-vendor/src/models/catalog.d.ts +25 -0
  19. package/dist/types/defaults/gjc/extensions/grok-cli-vendor/src/payload/sanitize.d.ts +27 -0
  20. package/dist/types/defaults/gjc/extensions/grok-cli-vendor/src/provider/billing.d.ts +8 -0
  21. package/dist/types/defaults/gjc/extensions/grok-cli-vendor/src/provider/register.d.ts +5 -0
  22. package/dist/types/defaults/gjc/extensions/grok-cli-vendor/src/provider/stream.d.ts +10 -0
  23. package/dist/types/defaults/gjc/extensions/grok-cli-vendor/src/provider/usage.d.ts +2 -0
  24. package/dist/types/defaults/gjc/extensions/grok-cli-vendor/src/shared/base-url.d.ts +2 -0
  25. package/dist/types/defaults/gjc/extensions/grok-cli-vendor/src/shared/errors.d.ts +38 -0
  26. package/dist/types/defaults/gjc-grok-cli.d.ts +5 -0
  27. package/dist/types/extensibility/extensions/index.d.ts +1 -0
  28. package/dist/types/extensibility/extensions/prefix-command-bridge.d.ts +35 -0
  29. package/dist/types/gjc-runtime/deep-interview-recorder.d.ts +103 -0
  30. package/dist/types/gjc-runtime/deep-interview-runtime.d.ts +2 -0
  31. package/dist/types/gjc-runtime/deep-interview-state.d.ts +112 -0
  32. package/dist/types/gjc-runtime/gc-render.d.ts +6 -0
  33. package/dist/types/gjc-runtime/gc-runtime.d.ts +134 -0
  34. package/dist/types/gjc-runtime/ledger-event-renderer.d.ts +68 -0
  35. package/dist/types/gjc-runtime/team-gc.d.ts +7 -0
  36. package/dist/types/gjc-runtime/team-runtime.d.ts +5 -1
  37. package/dist/types/gjc-runtime/tmux-common.d.ts +14 -0
  38. package/dist/types/gjc-runtime/tmux-gc.d.ts +7 -0
  39. package/dist/types/gjc-runtime/tmux-sessions.d.ts +13 -0
  40. package/dist/types/harness-control-plane/gc-adapter.d.ts +3 -0
  41. package/dist/types/harness-control-plane/owner.d.ts +8 -1
  42. package/dist/types/harness-control-plane/receipt-spool.d.ts +19 -0
  43. package/dist/types/harness-control-plane/state-machine.d.ts +6 -1
  44. package/dist/types/harness-control-plane/storage.d.ts +20 -0
  45. package/dist/types/harness-control-plane/types.d.ts +4 -0
  46. package/dist/types/hindsight/mental-models.d.ts +5 -5
  47. package/dist/types/modes/components/hook-selector.d.ts +7 -1
  48. package/dist/types/modes/components/model-selector.d.ts +1 -12
  49. package/dist/types/modes/controllers/command-controller.d.ts +1 -0
  50. package/dist/types/modes/rpc/rpc-client.d.ts +2 -2
  51. package/dist/types/modes/rpc/rpc-mode.d.ts +16 -1
  52. package/dist/types/modes/rpc/rpc-types.d.ts +4 -1
  53. package/dist/types/modes/shared/agent-wire/deep-interview-gate.d.ts +13 -0
  54. package/dist/types/modes/shared/agent-wire/session-registry.d.ts +25 -0
  55. package/dist/types/modes/shared/agent-wire/unattended-action-policy.d.ts +2 -0
  56. package/dist/types/sdk.d.ts +5 -0
  57. package/dist/types/session/agent-session.d.ts +3 -1
  58. package/dist/types/session/blob-store.d.ts +59 -4
  59. package/dist/types/session/session-manager.d.ts +24 -6
  60. package/dist/types/session/streaming-output.d.ts +3 -2
  61. package/dist/types/session/tool-choice-queue.d.ts +6 -0
  62. package/dist/types/skill-state/workflow-hud.d.ts +14 -0
  63. package/dist/types/task/receipt.d.ts +1 -0
  64. package/dist/types/task/types.d.ts +7 -0
  65. package/dist/types/thinking-metadata.d.ts +16 -0
  66. package/dist/types/thinking.d.ts +3 -12
  67. package/dist/types/tools/ask.d.ts +15 -1
  68. package/dist/types/tools/index.d.ts +2 -0
  69. package/dist/types/tools/resolve.d.ts +0 -10
  70. package/dist/types/tools/subagent.d.ts +6 -0
  71. package/dist/types/utils/tool-choice.d.ts +14 -1
  72. package/package.json +7 -7
  73. package/src/async/job-manager.ts +52 -0
  74. package/src/cli/args.ts +3 -0
  75. package/src/cli/auth-broker-cli.ts +1 -0
  76. package/src/cli/list-models.ts +13 -1
  77. package/src/cli.ts +9 -4
  78. package/src/commands/gc.ts +22 -0
  79. package/src/commands/harness.ts +43 -5
  80. package/src/commands/launch.ts +2 -2
  81. package/src/commands/session.ts +3 -1
  82. package/src/config/file-lock-gc.ts +181 -0
  83. package/src/config/file-lock.ts +14 -0
  84. package/src/config/model-profile-activation.ts +15 -3
  85. package/src/config/model-profiles.ts +264 -56
  86. package/src/config/model-resolver.ts +9 -6
  87. package/src/config/models-config-schema.ts +1 -0
  88. package/src/config/settings-schema.ts +6 -3
  89. package/src/coordinator/contract.ts +1 -0
  90. package/src/coordinator-mcp/server.ts +513 -26
  91. package/src/cursor.ts +16 -2
  92. package/src/defaults/gjc/agent.models.grok-cli.yml +36 -0
  93. package/src/defaults/gjc/extensions/grok-build/index.ts +1 -0
  94. package/src/defaults/gjc/extensions/grok-build/package.json +7 -0
  95. package/src/defaults/gjc/extensions/grok-cli-vendor/biome.json +39 -0
  96. package/src/defaults/gjc/extensions/grok-cli-vendor/package.json +8 -0
  97. package/src/defaults/gjc/extensions/grok-cli-vendor/src/index.ts +1 -0
  98. package/src/defaults/gjc/extensions/grok-cli-vendor/src/models/catalog.ts +155 -0
  99. package/src/defaults/gjc/extensions/grok-cli-vendor/src/payload/sanitize.ts +361 -0
  100. package/src/defaults/gjc/extensions/grok-cli-vendor/src/provider/billing.ts +57 -0
  101. package/src/defaults/gjc/extensions/grok-cli-vendor/src/provider/register.ts +99 -0
  102. package/src/defaults/gjc/extensions/grok-cli-vendor/src/provider/stream.ts +50 -0
  103. package/src/defaults/gjc/extensions/grok-cli-vendor/src/provider/usage.ts +56 -0
  104. package/src/defaults/gjc/extensions/grok-cli-vendor/src/shared/base-url.ts +36 -0
  105. package/src/defaults/gjc/extensions/grok-cli-vendor/src/shared/errors.ts +44 -0
  106. package/src/defaults/gjc/skills/deep-interview/SKILL.md +131 -113
  107. package/src/defaults/gjc/skills/deep-interview/lateral-review-panel.md +49 -0
  108. package/src/defaults/gjc/skills/team/SKILL.md +3 -2
  109. package/src/defaults/gjc/skills/ultragoal/SKILL.md +8 -2
  110. package/src/defaults/gjc-defaults.ts +7 -0
  111. package/src/defaults/gjc-grok-cli.ts +22 -0
  112. package/src/export/html/index.ts +13 -9
  113. package/src/extensibility/extensions/index.ts +1 -0
  114. package/src/extensibility/extensions/prefix-command-bridge.ts +128 -0
  115. package/src/gjc-runtime/deep-interview-recorder.ts +417 -0
  116. package/src/gjc-runtime/deep-interview-runtime.ts +18 -26
  117. package/src/gjc-runtime/deep-interview-state.ts +324 -0
  118. package/src/gjc-runtime/gc-render.ts +70 -0
  119. package/src/gjc-runtime/gc-runtime.ts +403 -0
  120. package/src/gjc-runtime/ledger-event-renderer.ts +164 -0
  121. package/src/gjc-runtime/ralplan-runtime.ts +58 -7
  122. package/src/gjc-runtime/state-renderer.ts +12 -3
  123. package/src/gjc-runtime/state-runtime.ts +46 -29
  124. package/src/gjc-runtime/team-gc.ts +49 -0
  125. package/src/gjc-runtime/team-runtime.ts +211 -8
  126. package/src/gjc-runtime/tmux-common.ts +29 -0
  127. package/src/gjc-runtime/tmux-gc.ts +176 -0
  128. package/src/gjc-runtime/tmux-sessions.ts +68 -12
  129. package/src/gjc-runtime/ultragoal-runtime.ts +517 -41
  130. package/src/gjc-runtime/workflow-manifest.generated.json +27 -1
  131. package/src/gjc-runtime/workflow-manifest.ts +16 -1
  132. package/src/harness-control-plane/gc-adapter.ts +184 -0
  133. package/src/harness-control-plane/owner.ts +89 -27
  134. package/src/harness-control-plane/receipt-spool.ts +128 -0
  135. package/src/harness-control-plane/state-machine.ts +27 -6
  136. package/src/harness-control-plane/storage.ts +93 -0
  137. package/src/harness-control-plane/types.ts +4 -0
  138. package/src/hindsight/mental-models.ts +17 -16
  139. package/src/internal-urls/docs-index.generated.ts +14 -8
  140. package/src/main.ts +7 -2
  141. package/src/modes/components/assistant-message.ts +26 -14
  142. package/src/modes/components/diff.ts +97 -0
  143. package/src/modes/components/hook-selector.ts +19 -0
  144. package/src/modes/components/model-selector.ts +370 -181
  145. package/src/modes/components/status-line/segments.ts +1 -1
  146. package/src/modes/components/tool-execution.ts +30 -13
  147. package/src/modes/controllers/command-controller.ts +25 -6
  148. package/src/modes/controllers/extension-ui-controller.ts +3 -0
  149. package/src/modes/controllers/selector-controller.ts +34 -42
  150. package/src/modes/rpc/rpc-client.ts +3 -2
  151. package/src/modes/rpc/rpc-mode.ts +187 -39
  152. package/src/modes/rpc/rpc-types.ts +5 -2
  153. package/src/modes/shared/agent-wire/command-dispatch.ts +279 -257
  154. package/src/modes/shared/agent-wire/command-validation.ts +11 -0
  155. package/src/modes/shared/agent-wire/deep-interview-gate.ts +30 -1
  156. package/src/modes/shared/agent-wire/session-registry.ts +109 -0
  157. package/src/modes/shared/agent-wire/unattended-action-policy.ts +24 -0
  158. package/src/modes/shared/agent-wire/unattended-run-controller.ts +23 -3
  159. package/src/modes/shared/agent-wire/unattended-session.ts +16 -1
  160. package/src/sdk.ts +46 -5
  161. package/src/secrets/obfuscator.ts +102 -27
  162. package/src/session/agent-session.ts +179 -25
  163. package/src/session/blob-store.ts +148 -6
  164. package/src/session/session-manager.ts +311 -60
  165. package/src/session/streaming-output.ts +185 -122
  166. package/src/session/tool-choice-queue.ts +23 -0
  167. package/src/setup/hermes/templates/operator-instructions.v1.md +7 -1
  168. package/src/skill-state/workflow-hud.ts +106 -10
  169. package/src/slash-commands/builtin-registry.ts +3 -2
  170. package/src/task/executor.ts +78 -6
  171. package/src/task/receipt.ts +5 -0
  172. package/src/task/render.ts +21 -1
  173. package/src/task/types.ts +8 -0
  174. package/src/thinking-metadata.ts +51 -0
  175. package/src/thinking.ts +26 -46
  176. package/src/tools/ask.ts +56 -1
  177. package/src/tools/bash.ts +1 -1
  178. package/src/tools/index.ts +2 -0
  179. package/src/tools/job.ts +3 -2
  180. package/src/tools/monitor.ts +36 -1
  181. package/src/tools/resolve.ts +93 -18
  182. package/src/tools/subagent-render.ts +9 -0
  183. package/src/tools/subagent.ts +26 -2
  184. package/src/utils/edit-mode.ts +1 -1
  185. package/src/utils/tool-choice.ts +45 -16
@@ -150,15 +150,19 @@ export async function exportFromFile(inputPath: string, options?: ExportOptions
150
150
  throw err;
151
151
  }
152
152
 
153
- const sessionData: SessionData = {
154
- header: sm.getHeader(),
155
- entries: sm.getEntries(),
156
- leafId: sm.getLeafId(),
157
- };
153
+ try {
154
+ const sessionData: SessionData = {
155
+ header: sm.getHeader(),
156
+ entries: sm.getEntries(),
157
+ leafId: sm.getLeafId(),
158
+ };
158
159
 
159
- const html = await generateHtml(sessionData, opts.themeName);
160
- const outputPath = opts.outputPath || `${APP_NAME}-session-${path.basename(inputPath, ".jsonl")}.html`;
160
+ const html = await generateHtml(sessionData, opts.themeName);
161
+ const outputPath = opts.outputPath || `${APP_NAME}-session-${path.basename(inputPath, ".jsonl")}.html`;
161
162
 
162
- await Bun.write(outputPath, html);
163
- return outputPath;
163
+ await Bun.write(outputPath, html);
164
+ return outputPath;
165
+ } finally {
166
+ await sm.close();
167
+ }
164
168
  }
@@ -9,6 +9,7 @@ export {
9
9
  loadExtensionFromFactory,
10
10
  loadExtensions,
11
11
  } from "./loader";
12
+ export * from "./prefix-command-bridge";
12
13
  export * from "./runner";
13
14
  // Type guards
14
15
  export * from "./types";
@@ -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
+ }