@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.
Files changed (125) hide show
  1. package/CHANGELOG.md +19 -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/config/file-lock-gc.d.ts +5 -0
  7. package/dist/types/config/file-lock.d.ts +7 -0
  8. package/dist/types/coordinator/contract.d.ts +1 -1
  9. package/dist/types/defaults/gjc/extensions/grok-build/index.d.ts +1 -0
  10. package/dist/types/defaults/gjc/extensions/grok-cli-vendor/src/index.d.ts +1 -0
  11. package/dist/types/defaults/gjc/extensions/grok-cli-vendor/src/models/catalog.d.ts +25 -0
  12. package/dist/types/defaults/gjc/extensions/grok-cli-vendor/src/payload/sanitize.d.ts +27 -0
  13. package/dist/types/defaults/gjc/extensions/grok-cli-vendor/src/provider/billing.d.ts +8 -0
  14. package/dist/types/defaults/gjc/extensions/grok-cli-vendor/src/provider/register.d.ts +5 -0
  15. package/dist/types/defaults/gjc/extensions/grok-cli-vendor/src/provider/stream.d.ts +10 -0
  16. package/dist/types/defaults/gjc/extensions/grok-cli-vendor/src/provider/usage.d.ts +2 -0
  17. package/dist/types/defaults/gjc/extensions/grok-cli-vendor/src/shared/base-url.d.ts +2 -0
  18. package/dist/types/defaults/gjc/extensions/grok-cli-vendor/src/shared/errors.d.ts +38 -0
  19. package/dist/types/defaults/gjc-grok-cli.d.ts +5 -0
  20. package/dist/types/extensibility/extensions/index.d.ts +1 -0
  21. package/dist/types/extensibility/extensions/prefix-command-bridge.d.ts +35 -0
  22. package/dist/types/gjc-runtime/deep-interview-recorder.d.ts +103 -0
  23. package/dist/types/gjc-runtime/deep-interview-runtime.d.ts +2 -0
  24. package/dist/types/gjc-runtime/deep-interview-state.d.ts +112 -0
  25. package/dist/types/gjc-runtime/gc-render.d.ts +6 -0
  26. package/dist/types/gjc-runtime/gc-runtime.d.ts +134 -0
  27. package/dist/types/gjc-runtime/ledger-event-renderer.d.ts +68 -0
  28. package/dist/types/gjc-runtime/team-gc.d.ts +7 -0
  29. package/dist/types/gjc-runtime/team-runtime.d.ts +5 -0
  30. package/dist/types/gjc-runtime/tmux-common.d.ts +11 -0
  31. package/dist/types/gjc-runtime/tmux-gc.d.ts +7 -0
  32. package/dist/types/gjc-runtime/tmux-sessions.d.ts +13 -0
  33. package/dist/types/harness-control-plane/gc-adapter.d.ts +3 -0
  34. package/dist/types/harness-control-plane/owner.d.ts +7 -0
  35. package/dist/types/harness-control-plane/storage.d.ts +20 -0
  36. package/dist/types/modes/components/hook-selector.d.ts +7 -1
  37. package/dist/types/modes/controllers/command-controller.d.ts +1 -0
  38. package/dist/types/modes/rpc/rpc-mode.d.ts +16 -1
  39. package/dist/types/modes/shared/agent-wire/deep-interview-gate.d.ts +13 -0
  40. package/dist/types/modes/shared/agent-wire/session-registry.d.ts +25 -0
  41. package/dist/types/modes/shared/agent-wire/unattended-action-policy.d.ts +2 -0
  42. package/dist/types/session/agent-session.d.ts +1 -1
  43. package/dist/types/session/blob-store.d.ts +39 -3
  44. package/dist/types/skill-state/workflow-hud.d.ts +14 -0
  45. package/dist/types/tools/ask.d.ts +15 -1
  46. package/dist/types/tools/subagent.d.ts +6 -0
  47. package/package.json +7 -7
  48. package/src/async/job-manager.ts +52 -0
  49. package/src/cli/args.ts +3 -0
  50. package/src/cli/auth-broker-cli.ts +1 -0
  51. package/src/cli/list-models.ts +13 -1
  52. package/src/cli.ts +1 -0
  53. package/src/commands/gc.ts +22 -0
  54. package/src/commands/harness.ts +7 -3
  55. package/src/config/file-lock-gc.ts +181 -0
  56. package/src/config/file-lock.ts +14 -0
  57. package/src/config/model-profiles.ts +24 -15
  58. package/src/coordinator/contract.ts +1 -0
  59. package/src/coordinator-mcp/server.ts +459 -3
  60. package/src/defaults/gjc/agent.models.grok-cli.yml +36 -0
  61. package/src/defaults/gjc/extensions/grok-build/index.ts +1 -0
  62. package/src/defaults/gjc/extensions/grok-build/package.json +7 -0
  63. package/src/defaults/gjc/extensions/grok-cli-vendor/biome.json +39 -0
  64. package/src/defaults/gjc/extensions/grok-cli-vendor/package.json +8 -0
  65. package/src/defaults/gjc/extensions/grok-cli-vendor/src/index.ts +1 -0
  66. package/src/defaults/gjc/extensions/grok-cli-vendor/src/models/catalog.ts +155 -0
  67. package/src/defaults/gjc/extensions/grok-cli-vendor/src/payload/sanitize.ts +361 -0
  68. package/src/defaults/gjc/extensions/grok-cli-vendor/src/provider/billing.ts +57 -0
  69. package/src/defaults/gjc/extensions/grok-cli-vendor/src/provider/register.ts +99 -0
  70. package/src/defaults/gjc/extensions/grok-cli-vendor/src/provider/stream.ts +50 -0
  71. package/src/defaults/gjc/extensions/grok-cli-vendor/src/provider/usage.ts +56 -0
  72. package/src/defaults/gjc/extensions/grok-cli-vendor/src/shared/base-url.ts +36 -0
  73. package/src/defaults/gjc/extensions/grok-cli-vendor/src/shared/errors.ts +44 -0
  74. package/src/defaults/gjc/skills/deep-interview/SKILL.md +131 -113
  75. package/src/defaults/gjc/skills/deep-interview/lateral-review-panel.md +49 -0
  76. package/src/defaults/gjc-defaults.ts +7 -0
  77. package/src/defaults/gjc-grok-cli.ts +22 -0
  78. package/src/extensibility/extensions/index.ts +1 -0
  79. package/src/extensibility/extensions/prefix-command-bridge.ts +128 -0
  80. package/src/gjc-runtime/deep-interview-recorder.ts +417 -0
  81. package/src/gjc-runtime/deep-interview-runtime.ts +18 -26
  82. package/src/gjc-runtime/deep-interview-state.ts +324 -0
  83. package/src/gjc-runtime/gc-render.ts +70 -0
  84. package/src/gjc-runtime/gc-runtime.ts +403 -0
  85. package/src/gjc-runtime/ledger-event-renderer.ts +164 -0
  86. package/src/gjc-runtime/ralplan-runtime.ts +58 -7
  87. package/src/gjc-runtime/state-renderer.ts +12 -3
  88. package/src/gjc-runtime/state-runtime.ts +46 -29
  89. package/src/gjc-runtime/team-gc.ts +49 -0
  90. package/src/gjc-runtime/team-runtime.ts +179 -2
  91. package/src/gjc-runtime/tmux-common.ts +14 -0
  92. package/src/gjc-runtime/tmux-gc.ts +176 -0
  93. package/src/gjc-runtime/tmux-sessions.ts +49 -1
  94. package/src/gjc-runtime/ultragoal-runtime.ts +12 -0
  95. package/src/harness-control-plane/gc-adapter.ts +184 -0
  96. package/src/harness-control-plane/owner.ts +11 -0
  97. package/src/harness-control-plane/storage.ts +70 -0
  98. package/src/internal-urls/docs-index.generated.ts +14 -8
  99. package/src/main.ts +7 -2
  100. package/src/modes/components/hook-selector.ts +19 -0
  101. package/src/modes/components/model-selector.ts +25 -8
  102. package/src/modes/components/status-line/segments.ts +1 -1
  103. package/src/modes/controllers/command-controller.ts +25 -6
  104. package/src/modes/controllers/extension-ui-controller.ts +3 -0
  105. package/src/modes/controllers/selector-controller.ts +1 -0
  106. package/src/modes/rpc/rpc-mode.ts +151 -33
  107. package/src/modes/shared/agent-wire/command-dispatch.ts +278 -261
  108. package/src/modes/shared/agent-wire/deep-interview-gate.ts +30 -1
  109. package/src/modes/shared/agent-wire/session-registry.ts +109 -0
  110. package/src/modes/shared/agent-wire/unattended-action-policy.ts +24 -0
  111. package/src/modes/shared/agent-wire/unattended-run-controller.ts +23 -3
  112. package/src/modes/shared/agent-wire/unattended-session.ts +16 -1
  113. package/src/sdk.ts +17 -3
  114. package/src/session/agent-session.ts +77 -8
  115. package/src/session/blob-store.ts +59 -3
  116. package/src/session/session-manager.ts +4 -4
  117. package/src/setup/hermes/templates/operator-instructions.v1.md +7 -1
  118. package/src/skill-state/workflow-hud.ts +106 -10
  119. package/src/slash-commands/builtin-registry.ts +3 -2
  120. package/src/task/executor.ts +9 -0
  121. package/src/tools/ask.ts +56 -1
  122. package/src/tools/job.ts +3 -2
  123. package/src/tools/monitor.ts +36 -1
  124. package/src/tools/subagent-render.ts +9 -0
  125. 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 { buildDeepInterviewHudSummary } from "../skill-state/workflow-hud";
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: Record<string, unknown> = {
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
- phase: string;
509
- ambiguity?: number;
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: options.phase !== "complete",
519
- phase: options.phase,
525
+ active: phase !== "complete",
526
+ phase,
520
527
  sessionId: options.sessionId,
521
528
  source: "gjc-deep-interview-native",
522
- hud: buildDeepInterviewHudSummary({
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",