@gajae-code/coding-agent 0.11.7 → 0.11.9

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 (213) hide show
  1. package/CHANGELOG.md +73 -1
  2. package/dist/types/cli/args.d.ts +0 -2
  3. package/dist/types/cli.d.ts +1 -0
  4. package/dist/types/config/keybindings.d.ts +33 -3
  5. package/dist/types/config/model-profile-activation.d.ts +4 -3
  6. package/dist/types/config/model-profile-contract.d.ts +38 -0
  7. package/dist/types/config/model-profiles.d.ts +2 -3
  8. package/dist/types/config/settings-schema.d.ts +15 -2
  9. package/dist/types/edit/modes/patch.d.ts +13 -0
  10. package/dist/types/edit/path-mutation-lock.d.ts +15 -0
  11. package/dist/types/eval/py/executor.d.ts +1 -1
  12. package/dist/types/eval/py/tool-bridge.d.ts +1 -2
  13. package/dist/types/gjc-runtime/deep-interview-ambiguity.d.ts +12 -5
  14. package/dist/types/gjc-runtime/deep-interview-draft.d.ts +8 -0
  15. package/dist/types/gjc-runtime/deep-interview-payload.d.ts +41 -0
  16. package/dist/types/gjc-runtime/deep-interview-recorder.d.ts +32 -17
  17. package/dist/types/gjc-runtime/deep-interview-repair.d.ts +17 -0
  18. package/dist/types/gjc-runtime/deep-interview-state.d.ts +135 -4
  19. package/dist/types/gjc-runtime/repository-binding.d.ts +63 -0
  20. package/dist/types/gjc-runtime/state-writer.d.ts +44 -0
  21. package/dist/types/gjc-runtime/ultragoal-guard.d.ts +1 -0
  22. package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +24 -0
  23. package/dist/types/gjc-runtime/workflow-manifest.d.ts +5 -3
  24. package/dist/types/main.d.ts +8 -0
  25. package/dist/types/modes/components/runtime-mcp-add-wizard.d.ts +2 -1
  26. package/dist/types/modes/components/tool-status-header.d.ts +3 -2
  27. package/dist/types/modes/components/welcome.d.ts +4 -0
  28. package/dist/types/modes/controllers/command-controller.d.ts +2 -0
  29. package/dist/types/modes/controllers/selector-controller.d.ts +2 -1
  30. package/dist/types/modes/interactive-mode.d.ts +5 -2
  31. package/dist/types/modes/prompt-action-autocomplete.d.ts +6 -1
  32. package/dist/types/modes/prompt-suggestion-controller.d.ts +35 -0
  33. package/dist/types/modes/shared/agent-wire/workflow-gate-broker.d.ts +2 -2
  34. package/dist/types/modes/types.d.ts +3 -0
  35. package/dist/types/modes/utils/hotkeys-markdown.d.ts +2 -1
  36. package/dist/types/runtime-mcp/manager.d.ts +1 -1
  37. package/dist/types/runtime-mcp/oauth-flow.d.ts +0 -5
  38. package/dist/types/runtime-mcp/plugin-network-boundary.d.ts +14 -0
  39. package/dist/types/runtime-mcp/redaction.d.ts +2 -0
  40. package/dist/types/sdk/broker/broker.d.ts +2 -0
  41. package/dist/types/sdk/broker/lifecycle.d.ts +6 -0
  42. package/dist/types/sdk/bus/chat-daemon-control.d.ts +7 -1
  43. package/dist/types/sdk/bus/config-commands.d.ts +2 -2
  44. package/dist/types/sdk/bus/config.d.ts +2 -0
  45. package/dist/types/sdk/bus/conversation-store.d.ts +1 -0
  46. package/dist/types/sdk/bus/index.d.ts +25 -0
  47. package/dist/types/sdk/bus/notification-orchestration.d.ts +1 -0
  48. package/dist/types/sdk/bus/prompt-reconciliation.d.ts +105 -0
  49. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +15 -3
  50. package/dist/types/sdk/bus/telegram-daemon-control.d.ts +5 -1
  51. package/dist/types/sdk/bus/telegram-daemon.d.ts +128 -1
  52. package/dist/types/sdk/bus/telegram-reference.d.ts +2 -0
  53. package/dist/types/sdk/host/control/operations.d.ts +1 -1
  54. package/dist/types/sdk/host/host.d.ts +1 -1
  55. package/dist/types/sdk/host/query/handlers.d.ts +9 -0
  56. package/dist/types/sdk/index.d.ts +2 -0
  57. package/dist/types/sdk/prompt-status.d.ts +88 -0
  58. package/dist/types/sdk/session-directory.d.ts +1 -1
  59. package/dist/types/sdk/startup-capability.d.ts +3 -0
  60. package/dist/types/session/agent-session.d.ts +2 -0
  61. package/dist/types/session/internal/managed-session-scope.d.ts +22 -3
  62. package/dist/types/session/internal/managed-session-storage.d.ts +24 -4
  63. package/dist/types/session/internal/native-publish-outcome.d.ts +48 -0
  64. package/dist/types/session/session-manager.d.ts +5 -1
  65. package/dist/types/session/session-storage.d.ts +31 -0
  66. package/dist/types/skill-state/active-state.d.ts +4 -0
  67. package/dist/types/task/index.d.ts +3 -1
  68. package/dist/types/task/receipt.d.ts +2 -0
  69. package/dist/types/task/types.d.ts +94 -0
  70. package/dist/types/tools/ask.d.ts +454 -174
  71. package/dist/types/tools/image-gen.d.ts +47 -1
  72. package/dist/types/tools/index.d.ts +4 -0
  73. package/dist/types/utils/prompt-suggestion.d.ts +33 -0
  74. package/package.json +8 -8
  75. package/scripts/capture-platform-shortcut-labels-showcase.ts +342 -0
  76. package/scripts/dogfood-repository-binding.ts +209 -0
  77. package/scripts/generate-sdk-operation-inventory.ts +2 -0
  78. package/scripts/run-test-manifest.ts +4 -4
  79. package/src/cli/args.ts +2 -92
  80. package/src/cli/mcp-cli.ts +4 -21
  81. package/src/cli/notify-cli.ts +5 -38
  82. package/src/cli.ts +50 -0
  83. package/src/commands/deep-interview.ts +9 -4
  84. package/src/commands/sdk.ts +2 -2
  85. package/src/commit/agentic/index.ts +3 -3
  86. package/src/commit/map-reduce/index.ts +2 -2
  87. package/src/config/keybindings.ts +157 -24
  88. package/src/config/model-profile-activation.ts +7 -18
  89. package/src/config/model-profile-contract.ts +161 -0
  90. package/src/config/model-profiles.ts +2 -3
  91. package/src/config/model-registry.ts +11 -1
  92. package/src/config/settings-schema.ts +17 -2
  93. package/src/coordinator-mcp/model-preset.ts +21 -74
  94. package/src/defaults/gjc/skills/deep-interview/SKILL.md +80 -165
  95. package/src/defaults/gjc/skills/ralplan/SKILL.md +3 -1
  96. package/src/edit/index.ts +3 -6
  97. package/src/edit/modes/patch.ts +38 -1
  98. package/src/edit/modes/replace.ts +42 -0
  99. package/src/edit/path-mutation-lock.ts +112 -0
  100. package/src/eval/py/executor.ts +30 -11
  101. package/src/eval/py/prelude.py +2 -2
  102. package/src/eval/py/tool-bridge.ts +34 -18
  103. package/src/extensibility/gjc-plugins/runtime-adapters.ts +2 -1
  104. package/src/gjc-runtime/deep-interview-ambiguity.ts +68 -47
  105. package/src/gjc-runtime/deep-interview-draft.ts +842 -0
  106. package/src/gjc-runtime/deep-interview-payload.ts +262 -0
  107. package/src/gjc-runtime/deep-interview-recorder.ts +294 -226
  108. package/src/gjc-runtime/deep-interview-repair.ts +1674 -0
  109. package/src/gjc-runtime/deep-interview-runtime.ts +264 -103
  110. package/src/gjc-runtime/deep-interview-state.ts +916 -4
  111. package/src/gjc-runtime/launch-tmux.ts +1 -1
  112. package/src/gjc-runtime/managed-owner-supervisor.ts +83 -4
  113. package/src/gjc-runtime/ralplan-runtime.ts +78 -4
  114. package/src/gjc-runtime/repository-binding.ts +267 -0
  115. package/src/gjc-runtime/session-state-sidecar.ts +25 -9
  116. package/src/gjc-runtime/state-writer.ts +166 -2
  117. package/src/gjc-runtime/tmux-owner-isolation.ts +2 -1
  118. package/src/gjc-runtime/tmux-sessions.ts +45 -6
  119. package/src/gjc-runtime/ultragoal-guard.ts +5 -5
  120. package/src/gjc-runtime/ultragoal-runtime.ts +181 -19
  121. package/src/gjc-runtime/workflow-manifest.generated.json +386 -1
  122. package/src/gjc-runtime/workflow-manifest.ts +191 -5
  123. package/src/internal-urls/docs-index.generated.ts +8 -6
  124. package/src/internal-urls/local-protocol.ts +33 -16
  125. package/src/internal-urls/mcp-protocol.ts +73 -11
  126. package/src/main.ts +25 -10
  127. package/src/modes/DESIGN.md +5 -0
  128. package/src/modes/acp/acp-agent.ts +50 -16
  129. package/src/modes/components/model-selector.ts +42 -8
  130. package/src/modes/components/plan-preview-overlay.ts +1 -1
  131. package/src/modes/components/runtime-mcp-add-wizard.ts +5 -0
  132. package/src/modes/components/tool-status-header.ts +20 -4
  133. package/src/modes/components/welcome.ts +32 -20
  134. package/src/modes/controllers/command-controller.ts +97 -54
  135. package/src/modes/controllers/event-controller.ts +10 -0
  136. package/src/modes/controllers/input-controller.ts +7 -2
  137. package/src/modes/controllers/runtime-mcp-command-controller.ts +29 -6
  138. package/src/modes/controllers/selector-controller.ts +46 -54
  139. package/src/modes/interactive-mode.ts +70 -36
  140. package/src/modes/prompt-action-autocomplete.ts +32 -13
  141. package/src/modes/prompt-suggestion-controller.ts +96 -0
  142. package/src/modes/shared/agent-wire/workflow-gate-broker.ts +2 -2
  143. package/src/modes/types.ts +3 -0
  144. package/src/modes/utils/hotkeys-markdown.ts +64 -36
  145. package/src/modes/utils/ui-helpers.ts +5 -3
  146. package/src/prompts/agents/architect.md +0 -1
  147. package/src/prompts/agents/critic.md +0 -1
  148. package/src/prompts/system/prompt-suggestion-system.md +32 -0
  149. package/src/prompts/system/system-prompt.md +7 -4
  150. package/src/prompts/tools/cron.md +3 -1
  151. package/src/runtime-mcp/json-rpc.ts +7 -4
  152. package/src/runtime-mcp/manager.ts +51 -53
  153. package/src/runtime-mcp/oauth-flow.ts +0 -72
  154. package/src/runtime-mcp/plugin-network-boundary.ts +101 -0
  155. package/src/runtime-mcp/redaction.ts +30 -0
  156. package/src/runtime-mcp/transports/http.ts +13 -6
  157. package/src/sdk/broker/broker.ts +34 -20
  158. package/src/sdk/broker/lifecycle.ts +185 -36
  159. package/src/sdk/broker/session-index.ts +11 -1
  160. package/src/sdk/bus/chat-daemon-control.ts +20 -5
  161. package/src/sdk/bus/config-commands.ts +2 -2
  162. package/src/sdk/bus/config.ts +9 -1
  163. package/src/sdk/bus/conversation-store.ts +40 -6
  164. package/src/sdk/bus/index.ts +240 -65
  165. package/src/sdk/bus/lifecycle-commands.ts +47 -3
  166. package/src/sdk/bus/lifecycle-control-runtime.ts +11 -2
  167. package/src/sdk/bus/lifecycle-orchestrator.ts +32 -1
  168. package/src/sdk/bus/notification-orchestration.ts +12 -0
  169. package/src/sdk/bus/notification-service.ts +27 -2
  170. package/src/sdk/bus/prompt-reconciliation.ts +239 -0
  171. package/src/sdk/bus/recent-activity.ts +90 -26
  172. package/src/sdk/bus/telegram-daemon-contract.ts +15 -3
  173. package/src/sdk/bus/telegram-daemon-control.ts +145 -33
  174. package/src/sdk/bus/telegram-daemon.ts +853 -112
  175. package/src/sdk/bus/telegram-reference.ts +20 -2
  176. package/src/sdk/cli/session-cli.ts +15 -2
  177. package/src/sdk/host/control/dispatch.ts +1 -1
  178. package/src/sdk/host/control/operations.ts +1 -1
  179. package/src/sdk/host/host.ts +2 -6
  180. package/src/sdk/host/query/handlers.ts +70 -5
  181. package/src/sdk/index.ts +12 -0
  182. package/src/sdk/prompt-status.ts +97 -0
  183. package/src/sdk/protocol/operation-inventory.generated.json +47 -0
  184. package/src/sdk/protocol/operation-registry.ts +18 -4
  185. package/src/sdk/session-directory.ts +5 -1
  186. package/src/sdk/session.ts +2 -0
  187. package/src/sdk/startup-capability.ts +9 -1
  188. package/src/session/agent-session.ts +138 -5
  189. package/src/session/blob-store.ts +7 -2
  190. package/src/session/internal/managed-session-scope.ts +998 -133
  191. package/src/session/internal/managed-session-storage.ts +268 -203
  192. package/src/session/internal/native-publish-outcome.ts +266 -0
  193. package/src/session/session-manager.ts +406 -360
  194. package/src/session/session-storage.ts +151 -11
  195. package/src/skill-state/active-state.ts +10 -0
  196. package/src/task/executor.ts +13 -5
  197. package/src/task/gjc-command.ts +2 -2
  198. package/src/task/index.ts +121 -15
  199. package/src/task/receipt.ts +3 -0
  200. package/src/task/types.ts +34 -3
  201. package/src/tools/ask.ts +199 -88
  202. package/src/tools/ast-edit.ts +2 -2
  203. package/src/tools/bash-pty-selection.ts +2 -2
  204. package/src/tools/browser/screenshot-format.ts +6 -1
  205. package/src/tools/cron.ts +1 -1
  206. package/src/tools/image-gen.ts +178 -6
  207. package/src/tools/index.ts +4 -0
  208. package/src/tools/render-utils.ts +7 -1
  209. package/src/utils/edit-mode.ts +2 -2
  210. package/src/utils/prompt-suggestion.ts +265 -0
  211. package/src/utils/sixel.ts +3 -3
  212. package/src/web/insane/url-guard.ts +3 -1
  213. package/src/web/scrapers/docs-rs.ts +30 -5
@@ -1,13 +1,8 @@
1
1
  import { syncSkillActiveState } from "../skill-state/active-state";
2
2
  import { deriveDeepInterviewHud } from "../skill-state/workflow-hud";
3
- import { WORKFLOW_STATE_VERSION } from "../skill-state/workflow-state-contract";
4
- import {
5
- clampReportedAmbiguity,
6
- computeAmbiguityFloor,
7
- disputeFactsFromRetractedRound,
8
- } from "./deep-interview-ambiguity";
9
3
  import {
10
4
  answerHash,
5
+ applyDeepInterviewRoundResultV1,
11
6
  assertDeepInterviewInputWithinLimit,
12
7
  assertDeepInterviewIntentManifest,
13
8
  assertDeepInterviewStructuredResponseWithinLimit,
@@ -16,8 +11,10 @@ import {
16
11
  type DeepInterviewIntentItem,
17
12
  type DeepInterviewIntentSubstitution,
18
13
  type DeepInterviewRoundRecord,
14
+ type DeepInterviewRoundResultV1,
19
15
  type DeepInterviewStateEnvelope,
20
16
  type DeepInterviewTriggerMetadata,
17
+ deepInterviewAnswerIdentityEqual,
21
18
  deriveRoundKey,
22
19
  MAX_USER_RESPONSE_LENGTH,
23
20
  normalizeDeepInterviewEnvelope,
@@ -25,7 +22,7 @@ import {
25
22
  reviewDeepInterviewIntent,
26
23
  } from "./deep-interview-state";
27
24
  import { writeSessionActivityMarker } from "./session-resolution";
28
- import { readExistingStateForMutation, writeGuardedWorkflowEnvelopeAtomic } from "./state-writer";
25
+ import { readExistingStateForMutation, transformGuardedWorkflowEnvelopeAtomic } from "./state-writer";
29
26
 
30
27
  export * from "./deep-interview-ambiguity";
31
28
  export * from "./deep-interview-state";
@@ -72,6 +69,8 @@ export interface DeepInterviewScoringInput {
72
69
  scores: Record<string, number>;
73
70
  ambiguity: number;
74
71
  triggers?: DeepInterviewTriggerMetadata[];
72
+ /** Complete native round result for typed callers. */
73
+ roundResult?: DeepInterviewRoundResultV1;
75
74
  }
76
75
 
77
76
  export type AppendOrMergeAction = "created" | "noop" | "replaced";
@@ -125,11 +124,9 @@ export function buildAnswerShell(
125
124
  }
126
125
 
127
126
  /**
128
- * Append-or-merge by `round_key`. Exactly one record per key:
129
- * - no existing record -> append (`created`);
130
- * - identical question_hash + answer_hash -> deterministic no-op (`noop`);
131
- * - same key, different hashes -> deterministic replacement of the prior shell
132
- * (`replaced`); the prior answer for that key is superseded and lifecycle resets.
127
+ * Append-or-merge by `round_key`. An answer is a no-op only when its complete
128
+ * canonical shell identity matches; a scored record is immutable evidence and
129
+ * cannot be replaced by a late answer.
133
130
  */
134
131
  export function appendOrMergeRound(
135
132
  rounds: readonly DeepInterviewRoundRecord[],
@@ -142,9 +139,10 @@ export function appendOrMergeRound(
142
139
  return { rounds: next, action: "created", record: shell };
143
140
  }
144
141
  const existing = next[index];
145
- if (existing.question_hash === shell.question_hash && existing.answer_hash === shell.answer_hash) {
142
+ if (deepInterviewAnswerIdentityEqual(existing, shell) && existing.answer_hash === shell.answer_hash) {
146
143
  return { rounds: next, action: "noop", record: existing };
147
144
  }
145
+ if (existing.lifecycle === "scored") throw new Error("DI_ANSWER_LIFECYCLE_CONFLICT");
148
146
  next[index] = shell;
149
147
  return { rounds: next, action: "replaced", record: shell };
150
148
  }
@@ -224,10 +222,10 @@ export function validateDeepInterviewScoredTransition(
224
222
  `active trigger ${trigger.kind} did not raise ambiguity (${prior.ambiguity} -> ${next.ambiguity})`,
225
223
  );
226
224
  }
227
- // Affected dimension must not improve. Prefer record scores, fall back to the trigger's
228
- // own prior/new dimension scores; absent metrics cannot prove non-improvement.
229
- const priorDim = prior.scores?.[trigger.dimension] ?? trigger.priorDimensionScore;
230
- const nextDim = next.scores?.[trigger.dimension] ?? trigger.newDimensionScore;
225
+ // The runtime derives both dimension values from scored records; callers cannot
226
+ // provide parallel trigger metrics.
227
+ const priorDim = prior.scores?.[trigger.dimension];
228
+ const nextDim = next.scores?.[trigger.dimension];
231
229
  if (typeof priorDim !== "number" || typeof nextDim !== "number") {
232
230
  violations.push(
233
231
  `active trigger ${trigger.kind} is missing dimension "${trigger.dimension}" scores to prove non-improvement`,
@@ -304,19 +302,16 @@ export function projectCompactState(value: unknown, options: { lastN?: number }
304
302
  // Persistence wrappers (state-writer backed; runtime-owned)
305
303
  // =============================================================================
306
304
 
305
+ interface RecorderMutationOptions {
306
+ sessionId?: string;
307
+ expectedRevision?: number;
308
+ }
309
+
307
310
  async function readEnvelope(statePath: string): Promise<DeepInterviewStateEnvelope> {
308
311
  const read = await readExistingStateForMutation(statePath);
309
312
  if (read.kind === "valid") return ensureDeepInterviewStateShape(read.value);
310
- if (read.kind === "corrupt") {
311
- // Fail closed: never silently overwrite a corrupt/tampered state file. Callers
312
- // (e.g. the ask tool) catch this and warn without mutating, preserving the file
313
- // for recovery. Only a genuinely absent file is defaulted below.
314
- throw new Error(
315
- `deep-interview state at ${statePath} is corrupt or tampered (${read.error}); refusing to overwrite`,
316
- );
317
- }
318
- // Absent: start from a defaulted shape.
319
- return ensureDeepInterviewStateShape(undefined);
313
+ if (read.kind === "absent") throw new Error("DI_STATE_ABSENT");
314
+ throw new Error(`deep-interview state at ${statePath} is corrupt or tampered (${read.error})`);
320
315
  }
321
316
 
322
317
  function existingStateRevision(value: unknown): number | undefined {
@@ -329,49 +324,16 @@ function interviewIdOf(envelope: DeepInterviewStateEnvelope): string | undefined
329
324
  const inner = (envelope.state ?? {}) as Record<string, unknown>;
330
325
  return typeof inner.interview_id === "string" ? inner.interview_id : undefined;
331
326
  }
332
-
333
- async function persistEnvelope(
334
- cwd: string,
335
- statePath: string,
336
- envelope: DeepInterviewStateEnvelope,
337
- sessionId: string | undefined,
338
- command: string,
339
- ): Promise<void> {
340
- if (!sessionId) throw new Error("deep-interview recorder requires a session id");
341
- const now = new Date().toISOString();
342
- const payload: Record<string, unknown> = { ...normalizeDeepInterviewEnvelope(envelope), updated_at: now };
343
- // Guarantee RequiredOnWriteEnvelopeSchema fields for the fresh/absent fallback;
344
- // existing real state already carries these and is preserved by the spread above.
345
- payload.skill ??= "deep-interview";
346
- payload.version ??= WORKFLOW_STATE_VERSION;
347
- payload.active ??= true;
348
- payload.current_phase ??= "interviewing";
349
- const expectedRevision = existingStateRevision(envelope);
350
- const writeResult = await writeGuardedWorkflowEnvelopeAtomic(statePath, payload, {
351
- cwd,
352
- policy: "source",
353
- expectedRevision,
354
- receipt: { cwd, skill: "deep-interview", owner: "gjc-runtime", command, sessionId, nowIso: now },
355
- audit: { category: "state", verb: "write", owner: "gjc-runtime", skill: "deep-interview", sessionId },
356
- });
357
- // Reflect the freshly written revision back onto the in-memory envelope so a
358
- // follow-up HUD sync derives its `sourceRevision` from the persisted revision
359
- // (not the stale pre-write value), otherwise the active-state writer treats the
360
- // newer HUD as stale and skips it (e.g. dropping the ambiguity chip after scoring).
361
- if (writeResult.written && typeof expectedRevision === "number") {
362
- (envelope as Record<string, unknown>).state_revision = expectedRevision + 1;
363
- }
364
- await writeSessionActivityMarker(cwd, sessionId, { writer: "deep-interview-recorder", path: statePath });
327
+ interface RecorderHudSyncSlot {
328
+ latestCommittedRevision: number;
329
+ tail: Promise<void>;
365
330
  }
366
331
 
367
- /**
368
- * Best-effort active-state/HUD cache refresh for the deep-interview rail, derived
369
- * from the complete normalized mode-state envelope. HUD is a cache; a failure here
370
- * must never change durable record semantics.
371
- */
332
+ const recorderHudSyncSlots = new Map<string, RecorderHudSyncSlot>();
333
+
372
334
  async function syncRecorderHud(
373
335
  cwd: string,
374
- envelope: DeepInterviewStateEnvelope,
336
+ envelope: Record<string, unknown>,
375
337
  sessionId: string | undefined,
376
338
  ): Promise<void> {
377
339
  const phase = typeof envelope.current_phase === "string" ? envelope.current_phase : "interviewing";
@@ -382,26 +344,65 @@ async function syncRecorderHud(
382
344
  phase,
383
345
  sessionId,
384
346
  source: "gjc-runtime-deep-interview-recorder",
385
- hud: deriveDeepInterviewHud(normalizeDeepInterviewEnvelope(envelope) as Record<string, unknown>, { phase }),
386
- sourceRevision: (existingStateRevision(envelope) ?? 0) + 1,
347
+ hud: deriveDeepInterviewHud(envelope, { phase }),
348
+ ...(typeof envelope.state_revision === "number" ? { committedModeRevision: envelope.state_revision } : {}),
387
349
  });
388
350
  }
389
351
 
390
- /**
391
- * Repair the cached HUD after a no-op append. A no-op writes no mode-state, so the
392
- * HUD is derived from a fresh read of the current persisted state (never from the
393
- * pre-noop in-memory envelope) to avoid overwriting newer active-state with stale values.
394
- */
395
- async function repairRecorderHudFromPersisted(
352
+ async function syncRecorderHudAtCommittedRevision(
396
353
  cwd: string,
397
- statePath: string,
398
- sessionId: string | undefined,
354
+ envelope: Record<string, unknown>,
355
+ sessionId: string,
356
+ revision: number,
399
357
  ): Promise<void> {
358
+ const key = `${cwd}\0${sessionId}`;
359
+ const slot = recorderHudSyncSlots.get(key) ?? { latestCommittedRevision: -1, tail: Promise.resolve() };
360
+ recorderHudSyncSlots.set(key, slot);
361
+ const sync = slot.tail
362
+ .catch(() => undefined)
363
+ .then(async () => {
364
+ if (revision <= slot.latestCommittedRevision) return;
365
+ await syncRecorderHud(cwd, { ...envelope, state_revision: revision }, sessionId);
366
+ slot.latestCommittedRevision = revision;
367
+ });
368
+ slot.tail = sync;
369
+ await sync;
370
+ }
371
+ export interface DeepInterviewPostCommitWarning {
372
+ code: "DI_POST_COMMIT_AUDIT_FAILED" | "DI_POST_COMMIT_ACTIVITY_FAILED" | "DI_POST_COMMIT_HUD_FAILED";
373
+ message: string;
374
+ }
375
+
376
+ export async function runDeepInterviewPostCommitEffects(options: {
377
+ cwd: string;
378
+ statePath: string;
379
+ sessionId: string;
380
+ envelope: Record<string, unknown>;
381
+ revision: number;
382
+ writer: string;
383
+ auditWarnings?: readonly { code: "DI_POST_COMMIT_AUDIT_FAILED"; message: string }[];
384
+ }): Promise<DeepInterviewPostCommitWarning[]> {
385
+ const warnings: DeepInterviewPostCommitWarning[] = [...(options.auditWarnings ?? [])];
400
386
  try {
401
- await syncDeepInterviewRecorderHud(cwd, statePath, sessionId);
402
- } catch {
403
- // HUD sync is best-effort cache maintenance and must not change record semantics.
387
+ await writeSessionActivityMarker(options.cwd, options.sessionId, {
388
+ writer: options.writer,
389
+ path: options.statePath,
390
+ });
391
+ } catch (error) {
392
+ warnings.push({
393
+ code: "DI_POST_COMMIT_ACTIVITY_FAILED",
394
+ message: error instanceof Error ? error.message : String(error),
395
+ });
404
396
  }
397
+ try {
398
+ await syncRecorderHudAtCommittedRevision(options.cwd, options.envelope, options.sessionId, options.revision);
399
+ } catch (error) {
400
+ warnings.push({
401
+ code: "DI_POST_COMMIT_HUD_FAILED",
402
+ message: error instanceof Error ? error.message : String(error),
403
+ });
404
+ }
405
+ return warnings;
405
406
  }
406
407
 
407
408
  /** Refresh the best-effort HUD cache from persisted deep-interview state. */
@@ -412,110 +413,129 @@ export async function syncDeepInterviewRecorderHud(
412
413
  ): Promise<void> {
413
414
  const read = await readExistingStateForMutation(statePath);
414
415
  if (read.kind !== "valid") return;
415
- await syncRecorderHud(cwd, normalizeDeepInterviewEnvelope(read.value), sessionId);
416
+ const envelope = normalizeDeepInterviewEnvelope(read.value);
417
+ const revision = existingStateRevision(read.value);
418
+ if (revision === undefined || !sessionId) return;
419
+ await syncRecorderHudAtCommittedRevision(cwd, envelope, sessionId, revision);
416
420
  }
417
421
 
418
422
  /**
419
423
  * Record an `answered` shell for one round (append-or-merge by durable key).
420
424
  *
421
- * Retraction dynamics: replacing an already-`scored` answer is a mechanical
422
- * contradiction signal (the user pivoted). The facts that round established are
423
- * marked disputed, which raises the deterministic ambiguity floor immediately —
424
- * without waiting for the LLM scorer to self-report a trigger.
425
+ * Replacing an already-scored answer is rejected with
426
+ * `DI_ANSWER_LIFECYCLE_CONFLICT`; scored evidence is immutable.
425
427
  */
426
428
  export async function appendOrMergeDeepInterviewRound(
427
429
  cwd: string,
428
430
  statePath: string,
429
431
  input: DeepInterviewAnswerInput,
430
- options: { sessionId?: string } = {},
431
- ): Promise<{ action: AppendOrMergeAction; record: DeepInterviewRoundRecord; disputedFactIds?: string[] }> {
432
+ options: RecorderMutationOptions = {},
433
+ ): Promise<{
434
+ action: AppendOrMergeAction;
435
+ record: DeepInterviewRoundRecord;
436
+ warnings: DeepInterviewPostCommitWarning[];
437
+ }> {
432
438
  assertDeepInterviewStructuredResponseWithinLimit(input);
433
439
  if (input.customInput !== undefined)
434
440
  assertDeepInterviewInputWithinLimit(input.customInput, MAX_USER_RESPONSE_LENGTH, "user_response");
435
- const envelope = await readEnvelope(statePath);
436
- const interviewId = input.interviewId ?? interviewIdOf(envelope);
437
- const shell = buildAnswerShell({
438
- ...input,
439
- interviewId,
440
- customInput: input.intent_contract || input.intent_review ? undefined : input.customInput,
441
- });
442
- const rounds = readRounds(envelope);
443
- const priorRecord = rounds.find(r => r.round_key === shell.round_key);
444
- const result = appendOrMergeRound(rounds, shell);
445
- const inner = envelope.state as Record<string, unknown>;
446
- let intentStateChanged = false;
447
- if (input.intent_contract) {
448
- if (input.round !== 0 || input.component !== "review-topology" || input.dimension !== "topology")
449
- throw new Error("intent contract requires Round 0 topology metadata");
450
- const confirmed =
451
- input.selectedOptions?.length === 1 &&
452
- input.intent_contract.confirmation_options.includes(input.selectedOptions[0]);
453
- if (confirmed) {
454
- const contract = createDeepInterviewIntentManifest(input.intent_contract.items, {
455
- round: 0,
456
- answer_hash: shell.answer_hash,
457
- });
458
- const existingContract = inner.intent_contract;
459
- if (existingContract !== undefined) {
460
- assertDeepInterviewIntentManifest(existingContract);
461
- if (
462
- existingContract.digest !== contract.digest ||
463
- existingContract.confirmation_answer_hash !== contract.confirmation_answer_hash
464
- )
465
- throw new Error("locked intent contract cannot be replaced");
466
- } else {
467
- inner.intent_contract = contract;
468
- intentStateChanged = true;
469
- }
470
- }
471
- }
472
- if (input.intent_review) {
473
- if (input.round <= 0) throw new Error("intent review requires a post-Round-0 answer");
474
- const locked = inner.intent_contract;
475
- assertDeepInterviewIntentManifest(locked);
476
- const approved =
477
- input.selectedOptions?.length === 1 && input.intent_review.approval_options.includes(input.selectedOptions[0]);
478
- inner.intent_review = reviewDeepInterviewIntent(locked, input.intent_review.observed_items, {
479
- status: approved ? "approved" : "pending",
480
- supporting_substitutions: input.intent_review.supporting_substitutions,
481
- ...(approved
482
- ? {
483
- approval_round: input.round,
441
+ if (!options.sessionId) throw new Error("deep-interview recorder requires a session id");
442
+ const initial = await readEnvelope(statePath);
443
+ let result: AppendOrMergeResult | undefined;
444
+ const now = new Date().toISOString();
445
+ const committed = await transformGuardedWorkflowEnvelopeAtomic(statePath, {
446
+ cwd,
447
+ expectedRevision: options.expectedRevision ?? existingStateRevision(initial) ?? 0,
448
+ receipt: {
449
+ cwd,
450
+ skill: "deep-interview",
451
+ owner: "gjc-runtime",
452
+ command: "gjc deep-interview record-answer",
453
+ sessionId: options.sessionId,
454
+ nowIso: now,
455
+ },
456
+ audit: {
457
+ category: "state",
458
+ verb: "write",
459
+ owner: "gjc-runtime",
460
+ skill: "deep-interview",
461
+ sessionId: options.sessionId,
462
+ },
463
+ transform: current => {
464
+ const envelope = ensureDeepInterviewStateShape(current);
465
+ const shell = buildAnswerShell(
466
+ {
467
+ ...input,
468
+ interviewId: input.interviewId ?? interviewIdOf(envelope),
469
+ customInput: input.intent_contract || input.intent_review ? undefined : input.customInput,
470
+ },
471
+ now,
472
+ );
473
+ result = appendOrMergeRound(readRounds(envelope), shell);
474
+ const state = envelope.state as Record<string, unknown>;
475
+ let intentStateChanged = false;
476
+ if (input.intent_contract) {
477
+ if (input.round !== 0 || input.component !== "review-topology" || input.dimension !== "topology")
478
+ throw new Error("intent contract requires Round 0 topology metadata");
479
+ const confirmed =
480
+ input.selectedOptions?.length === 1 &&
481
+ input.intent_contract.confirmation_options.includes(input.selectedOptions[0]);
482
+ if (confirmed) {
483
+ const contract = createDeepInterviewIntentManifest(input.intent_contract.items, {
484
+ round: 0,
484
485
  answer_hash: shell.answer_hash,
485
- user_answer_evidence: `answer_hash:${shell.answer_hash}`,
486
+ });
487
+ const existingContract = state.intent_contract;
488
+ if (existingContract !== undefined) {
489
+ assertDeepInterviewIntentManifest(existingContract);
490
+ if (
491
+ existingContract.digest !== contract.digest ||
492
+ existingContract.confirmation_answer_hash !== contract.confirmation_answer_hash
493
+ )
494
+ throw new Error("locked intent contract cannot be replaced");
495
+ } else {
496
+ state.intent_contract = contract;
497
+ intentStateChanged = true;
486
498
  }
487
- : {}),
488
- });
489
- intentStateChanged = true;
490
- }
491
- if (result.action === "noop" && !intentStateChanged) {
492
- await repairRecorderHudFromPersisted(cwd, statePath, options.sessionId);
493
- return { action: result.action, record: result.record };
494
- }
495
- inner.rounds = result.rounds;
496
- let disputedFactIds: string[] | undefined;
497
- if (result.action === "replaced" && priorRecord?.lifecycle === "scored") {
498
- const facts = Array.isArray(inner.established_facts) ? inner.established_facts : [];
499
- const disputed = disputeFactsFromRetractedRound(facts, shell.round);
500
- if (disputed.disputedIds.length > 0) {
501
- inner.established_facts = disputed.facts;
502
- disputedFactIds = disputed.disputedIds;
503
- // Surface the rise right away: the retraction changed the evidence, so the
504
- // gating score must reflect the new floor before the next scoring pass.
505
- const breakdown = computeAmbiguityFloor(inner);
506
- inner.ambiguity_floor = breakdown;
507
- if (typeof inner.current_ambiguity === "number") {
508
- inner.current_ambiguity = clampReportedAmbiguity(inner.current_ambiguity, breakdown.floor).effective;
499
+ }
509
500
  }
510
- }
511
- }
512
- await persistEnvelope(cwd, statePath, envelope, options.sessionId, "gjc deep-interview record-answer");
513
- try {
514
- await syncRecorderHud(cwd, envelope, options.sessionId);
515
- } catch {
516
- // HUD sync is best-effort cache maintenance and must not change record semantics.
517
- }
518
- return { action: result.action, record: result.record, disputedFactIds };
501
+ if (input.intent_review) {
502
+ if (input.round <= 0) throw new Error("intent review requires a post-Round-0 answer");
503
+ const locked = state.intent_contract;
504
+ assertDeepInterviewIntentManifest(locked);
505
+ const approved =
506
+ input.selectedOptions?.length === 1 &&
507
+ input.intent_review.approval_options.includes(input.selectedOptions[0]);
508
+ state.intent_review = reviewDeepInterviewIntent(locked, input.intent_review.observed_items, {
509
+ status: approved ? "approved" : "pending",
510
+ supporting_substitutions: input.intent_review.supporting_substitutions,
511
+ ...(approved
512
+ ? {
513
+ approval_round: input.round,
514
+ answer_hash: shell.answer_hash,
515
+ user_answer_evidence: `answer_hash:${shell.answer_hash}`,
516
+ }
517
+ : {}),
518
+ });
519
+ intentStateChanged = true;
520
+ }
521
+ if (result.action === "noop" && !intentStateChanged) return { kind: "noop" as const };
522
+ state.rounds = result.rounds;
523
+ return { kind: "write" as const, value: { ...envelope, updated_at: now } as Record<string, unknown> };
524
+ },
525
+ });
526
+ if (!result) throw new Error("DI_STATE_SCHEMA_INVALID");
527
+ const warnings = committed.written
528
+ ? await runDeepInterviewPostCommitEffects({
529
+ cwd,
530
+ statePath,
531
+ sessionId: options.sessionId,
532
+ envelope: committed.stamped,
533
+ revision: committed.revision,
534
+ writer: "deep-interview-recorder",
535
+ auditWarnings: committed.warnings,
536
+ })
537
+ : [];
538
+ return { action: result.action, record: result.record, warnings };
519
539
  }
520
540
 
521
541
  /**
@@ -529,21 +549,58 @@ export async function appendOrMergeDeepInterviewRound(
529
549
  * not finite, that comparison is treated as non-matching, so no prior is selected
530
550
  * rather than risking a spurious comparison against an unrelated round.
531
551
  */
532
- function latestPriorScoredRound(
533
- rounds: readonly DeepInterviewRoundRecord[],
534
- currentKey: string,
535
- currentRound: number,
536
- ): DeepInterviewRoundRecord | undefined {
537
- if (!Number.isFinite(currentRound)) return undefined;
538
- let prior: DeepInterviewRoundRecord | undefined;
539
- for (const candidate of rounds) {
540
- if (candidate.lifecycle !== "scored") continue;
541
- if (candidate.round_key === currentKey) continue;
542
- if (!Number.isFinite(candidate.round)) continue;
543
- if (!(candidate.round < currentRound)) continue;
544
- if (prior === undefined || candidate.round > prior.round) prior = candidate;
552
+ function legacyScoringRoundResult(
553
+ envelope: DeepInterviewStateEnvelope,
554
+ input: DeepInterviewScoringInput,
555
+ ): DeepInterviewRoundResultV1 {
556
+ if (input.roundResult) return input.roundResult;
557
+ const state = envelope.state as Record<string, unknown>;
558
+ const type = state.type;
559
+ const dimensions =
560
+ type === "brownfield"
561
+ ? ["goal", "constraints", "criteria", "context"]
562
+ : type === "greenfield"
563
+ ? ["goal", "constraints", "criteria"]
564
+ : undefined;
565
+ if (
566
+ !dimensions?.every(
567
+ dimension => typeof input.scores[dimension] === "number" && Number.isFinite(input.scores[dimension]),
568
+ )
569
+ ) {
570
+ throw new Error(
571
+ "DI_LEGACY_SCORING_INPUT_INSUFFICIENT: provide roundResult with complete global and component scores",
572
+ );
573
+ }
574
+ const topology = state.topology;
575
+ const hasActiveComponents =
576
+ !!topology &&
577
+ typeof topology === "object" &&
578
+ !Array.isArray(topology) &&
579
+ (() => {
580
+ const topologyRecord = topology as Record<string, unknown>;
581
+ if (!Array.isArray(topologyRecord.components)) return false;
582
+ const components = topologyRecord.components as unknown[];
583
+ return components.some(
584
+ (component: unknown) =>
585
+ component &&
586
+ typeof component === "object" &&
587
+ !Array.isArray(component) &&
588
+ (component as Record<string, unknown>).active !== false &&
589
+ (component as Record<string, unknown>).status !== "deferred",
590
+ );
591
+ })();
592
+ if (hasActiveComponents) {
593
+ throw new Error(
594
+ "DI_LEGACY_SCORING_INPUT_INSUFFICIENT: provide roundResult with complete global and component scores",
595
+ );
545
596
  }
546
- return prior;
597
+ return {
598
+ global_scores: Object.fromEntries(
599
+ dimensions.map(dimension => [dimension, input.scores[dimension]]),
600
+ ) as DeepInterviewRoundResultV1["global_scores"],
601
+ component_scores: {},
602
+ ...(input.triggers === undefined ? {} : { triggers: input.triggers }),
603
+ };
547
604
  }
548
605
 
549
606
  /** Merge scoring output into the same round record, transitioning to `scored`. */
@@ -551,53 +608,64 @@ export async function enrichDeepInterviewRoundScoring(
551
608
  cwd: string,
552
609
  statePath: string,
553
610
  input: DeepInterviewScoringInput,
554
- options: { sessionId?: string } = {},
555
- ): Promise<{ record: DeepInterviewRoundRecord }> {
611
+ options: RecorderMutationOptions = {},
612
+ ): Promise<{ record: DeepInterviewRoundRecord; warnings: DeepInterviewPostCommitWarning[] }> {
556
613
  assertDeepInterviewStructuredResponseWithinLimit(input);
557
- const envelope = await readEnvelope(statePath);
558
- const interviewId = input.interviewId ?? interviewIdOf(envelope);
559
- const rounds = readRounds(envelope);
560
- const { rounds: enrichedRounds, record: reportedRecord } = enrichRoundWithScoring(rounds, {
561
- ...input,
562
- interviewId,
614
+ if (!options.sessionId) throw new Error("deep-interview recorder requires a session id");
615
+ const initial = await readEnvelope(statePath);
616
+ const now = new Date().toISOString();
617
+ let record: DeepInterviewRoundRecord | undefined;
618
+ const committed = await transformGuardedWorkflowEnvelopeAtomic(statePath, {
619
+ cwd,
620
+ expectedRevision: options.expectedRevision ?? existingStateRevision(initial) ?? 0,
621
+ receipt: {
622
+ cwd,
623
+ skill: "deep-interview",
624
+ owner: "gjc-runtime",
625
+ command: "gjc deep-interview score-round",
626
+ sessionId: options.sessionId,
627
+ nowIso: now,
628
+ },
629
+ audit: {
630
+ category: "state",
631
+ verb: "write",
632
+ owner: "gjc-runtime",
633
+ skill: "deep-interview",
634
+ sessionId: options.sessionId,
635
+ },
636
+ transform: current => {
637
+ const envelope = ensureDeepInterviewStateShape(current);
638
+ const interviewId = input.interviewId ?? interviewIdOf(envelope);
639
+ const roundKey = deriveRoundKey(interviewId, input);
640
+ const outcome = applyDeepInterviewRoundResultV1(
641
+ envelope,
642
+ roundKey,
643
+ legacyScoringRoundResult(envelope, input),
644
+ now,
645
+ );
646
+ const next = ensureDeepInterviewStateShape(outcome.envelope);
647
+ record = readRounds(next).find(round => round.round_key === roundKey);
648
+ if (!record) throw new Error("DI_STATE_SCHEMA_INVALID");
649
+ if (outcome.kind === "noop") return { kind: "noop" as const };
650
+ return {
651
+ kind: "write" as const,
652
+ value: { ...next, schema_version: 1, updated_at: now } as Record<string, unknown>,
653
+ };
654
+ },
563
655
  });
564
- // Deterministic floor (Ouroboros principle): the LLM-reported ambiguity is only a
565
- // lower-bound input. Evidence persisted in state — unresolved disputed facts,
566
- // unscored active components, auto-answer dilution — sets a code-computed floor
567
- // the reported score can never fall under, so a pivot mechanically raises the
568
- // gating score even when the scorer omits triggers.
569
- const inner = envelope.state as Record<string, unknown>;
570
- const breakdown = computeAmbiguityFloor({ ...inner, rounds: enrichedRounds });
571
- const clampResult = clampReportedAmbiguity(input.ambiguity, breakdown.floor);
572
- let record = reportedRecord;
573
- let nextRounds = enrichedRounds;
574
- if (clampResult.clamped) {
575
- record = {
576
- ...reportedRecord,
577
- ambiguity: clampResult.effective,
578
- reported_ambiguity: input.ambiguity,
579
- ambiguity_floor: breakdown.floor,
580
- };
581
- nextRounds = enrichedRounds.map(item => (item.round_key === record.round_key ? record : item));
582
- }
583
- // Fail closed: a scored transition that violates the bidirectional invariant
584
- // (an active trigger that improves the affected dimension or fails to raise
585
- // overall ambiguity, or a disputed/unresolved trigger lacking a rationale) must
586
- // never be persisted — storing it lets the interview falsely converge. Validate
587
- // the effective (floor-clamped) record against the most recent prior scored round.
588
- const prior = latestPriorScoredRound(rounds, record.round_key, record.round);
589
- const validation = validateDeepInterviewScoredTransition(prior, record);
590
- if (!validation.ok) {
591
- throw new Error(
592
- `deep-interview scored transition for round ${record.round} is invalid and was refused: ${validation.violations.join("; ")}`,
593
- );
594
- }
595
- inner.rounds = nextRounds;
596
- inner.current_ambiguity = clampResult.effective;
597
- inner.ambiguity_floor = breakdown;
598
- await persistEnvelope(cwd, statePath, envelope, options.sessionId, "gjc deep-interview score-round");
599
- await syncRecorderHud(cwd, envelope, options.sessionId);
600
- return { record };
656
+ if (!record) throw new Error("DI_STATE_SCHEMA_INVALID");
657
+ const warnings = committed.written
658
+ ? await runDeepInterviewPostCommitEffects({
659
+ cwd,
660
+ statePath,
661
+ sessionId: options.sessionId,
662
+ envelope: committed.stamped,
663
+ revision: committed.revision,
664
+ writer: "deep-interview-recorder",
665
+ auditWarnings: committed.warnings,
666
+ })
667
+ : [];
668
+ return { record, warnings };
601
669
  }
602
670
 
603
671
  /** Compact projection so callers read a slice instead of the full transcript. */