@gajae-code/coding-agent 0.11.8 → 0.11.10

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 (101) hide show
  1. package/CHANGELOG.md +35 -1
  2. package/dist/types/cli.d.ts +1 -0
  3. package/dist/types/config/model-profiles.d.ts +2 -3
  4. package/dist/types/config/model-registry.d.ts +2 -0
  5. package/dist/types/gjc-runtime/deep-interview-ambiguity.d.ts +12 -5
  6. package/dist/types/gjc-runtime/deep-interview-draft.d.ts +8 -0
  7. package/dist/types/gjc-runtime/deep-interview-payload.d.ts +41 -0
  8. package/dist/types/gjc-runtime/deep-interview-recorder.d.ts +32 -17
  9. package/dist/types/gjc-runtime/deep-interview-repair.d.ts +17 -0
  10. package/dist/types/gjc-runtime/deep-interview-state.d.ts +135 -4
  11. package/dist/types/gjc-runtime/state-writer.d.ts +45 -0
  12. package/dist/types/gjc-runtime/workflow-manifest.d.ts +5 -3
  13. package/dist/types/modes/components/welcome.d.ts +2 -0
  14. package/dist/types/modes/controllers/selector-controller.d.ts +16 -8
  15. package/dist/types/runtime-mcp/redaction.d.ts +2 -0
  16. package/dist/types/sdk/bus/chat-daemon-control.d.ts +3 -1
  17. package/dist/types/sdk/bus/daemon-paths.d.ts +1 -0
  18. package/dist/types/sdk/bus/index.d.ts +8 -1
  19. package/dist/types/sdk/bus/operator-runtime.d.ts +1 -0
  20. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +14 -7
  21. package/dist/types/sdk/bus/telegram-daemon.d.ts +74 -5
  22. package/dist/types/sdk/session.d.ts +7 -0
  23. package/dist/types/session/agent-session.d.ts +2 -0
  24. package/dist/types/session/internal/managed-session-scope.d.ts +13 -3
  25. package/dist/types/session/internal/managed-session-storage.d.ts +8 -3
  26. package/dist/types/session/session-manager.d.ts +5 -1
  27. package/dist/types/session/session-storage.d.ts +13 -0
  28. package/dist/types/skill-state/active-state.d.ts +4 -0
  29. package/dist/types/task/provider-retry-status.d.ts +13 -0
  30. package/dist/types/task/types.d.ts +3 -0
  31. package/dist/types/tools/index.d.ts +3 -1
  32. package/package.json +8 -8
  33. package/scripts/generate-sdk-operation-inventory.ts +1 -0
  34. package/src/cli/mcp-cli.ts +4 -21
  35. package/src/cli/notify-cli.ts +1 -0
  36. package/src/cli.ts +32 -0
  37. package/src/commands/deep-interview.ts +9 -4
  38. package/src/commit/agentic/index.ts +3 -3
  39. package/src/commit/map-reduce/index.ts +2 -2
  40. package/src/config/model-profiles.ts +9 -10
  41. package/src/config/model-registry.ts +4 -2
  42. package/src/defaults/gjc/skills/deep-interview/SKILL.md +78 -163
  43. package/src/eval/js/tool-bridge.ts +1 -1
  44. package/src/gjc-runtime/deep-interview-ambiguity.ts +68 -47
  45. package/src/gjc-runtime/deep-interview-draft.ts +843 -0
  46. package/src/gjc-runtime/deep-interview-payload.ts +262 -0
  47. package/src/gjc-runtime/deep-interview-recorder.ts +310 -227
  48. package/src/gjc-runtime/deep-interview-repair.ts +1674 -0
  49. package/src/gjc-runtime/deep-interview-runtime.ts +264 -103
  50. package/src/gjc-runtime/deep-interview-state.ts +916 -4
  51. package/src/gjc-runtime/ralplan-runtime.ts +3 -1
  52. package/src/gjc-runtime/state-writer.ts +166 -2
  53. package/src/gjc-runtime/workflow-manifest.generated.json +386 -1
  54. package/src/gjc-runtime/workflow-manifest.ts +191 -5
  55. package/src/hooks/skill-state.ts +23 -1
  56. package/src/internal-urls/docs-index.generated.ts +6 -5
  57. package/src/modes/components/welcome.ts +10 -2
  58. package/src/modes/controllers/selector-controller.ts +38 -8
  59. package/src/modes/interactive-mode.ts +29 -9
  60. package/src/prompts/agent-fragments/restricted-bash.md +1 -1
  61. package/src/prompts/agents/architect.md +8 -1
  62. package/src/prompts/agents/critic.md +8 -1
  63. package/src/prompts/agents/planner.md +8 -1
  64. package/src/prompts/system/system-prompt.md +1 -0
  65. package/src/runtime-mcp/json-rpc.ts +7 -4
  66. package/src/runtime-mcp/redaction.ts +30 -0
  67. package/src/sdk/broker/identity.ts +22 -12
  68. package/src/sdk/broker/session-index.ts +11 -1
  69. package/src/sdk/bus/chat-daemon-control.ts +5 -3
  70. package/src/sdk/bus/daemon-paths.ts +2 -0
  71. package/src/sdk/bus/index.ts +60 -5
  72. package/src/sdk/bus/notification-service.ts +19 -5
  73. package/src/sdk/bus/operator-runtime.ts +31 -5
  74. package/src/sdk/bus/telegram-cli.ts +6 -13
  75. package/src/sdk/bus/telegram-daemon-cli.ts +8 -3
  76. package/src/sdk/bus/telegram-daemon-contract.ts +15 -7
  77. package/src/sdk/bus/telegram-daemon-control.ts +14 -5
  78. package/src/sdk/bus/telegram-daemon.ts +685 -120
  79. package/src/sdk/protocol/operation-inventory.generated.json +11 -0
  80. package/src/sdk/session.ts +12 -0
  81. package/src/session/agent-session.ts +140 -10
  82. package/src/session/internal/managed-session-scope.ts +339 -228
  83. package/src/session/internal/managed-session-storage.ts +17 -4
  84. package/src/session/session-manager.ts +96 -33
  85. package/src/session/session-storage.ts +23 -0
  86. package/src/skill-state/active-state.ts +10 -0
  87. package/src/skill-state/workflow-mutation-guard.ts +41 -7
  88. package/src/task/executor.ts +154 -44
  89. package/src/task/provider-retry-status.ts +88 -0
  90. package/src/task/render.ts +45 -26
  91. package/src/task/types.ts +3 -0
  92. package/src/tools/ask.ts +1 -0
  93. package/src/tools/bash-allowed-prefixes.ts +45 -1
  94. package/src/tools/index.ts +3 -1
  95. package/src/tools/subagent-render.ts +74 -16
  96. package/src/tools/subagent.ts +2 -0
  97. package/vendor/insane-search/MANIFEST.json +4 -1
  98. package/vendor/insane-search/engine/bias_check.py +5 -0
  99. package/vendor/insane-search/engine/fetch_chain.py +67 -1
  100. package/vendor/insane-search/engine/transport.py +13 -2
  101. package/vendor/insane-search/engine/validators.py +4 -1
@@ -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";
@@ -102,6 +101,21 @@ export interface TransitionValidationResult {
102
101
  // Pure helpers: records
103
102
  // =============================================================================
104
103
 
104
+ /**
105
+ * `deepInterview.dimension` is agent-supplied free text on post-topology asks, but the persisted
106
+ * envelope only accepts canonical dimension ids. Fold obvious case/label variants down, and drop
107
+ * anything unrecognized rather than persisting a value the validator rejects.
108
+ */
109
+ function canonicalDimension(value: string | undefined): string | undefined {
110
+ if (value === undefined) return undefined;
111
+ const normalized = value.trim().toLowerCase();
112
+ if (normalized === "topology") return "topology";
113
+ for (const dimension of ["goal", "constraints", "criteria", "context"]) {
114
+ if (normalized === dimension || normalized.startsWith(`${dimension} `)) return dimension;
115
+ }
116
+ return undefined;
117
+ }
118
+
105
119
  export function buildAnswerShell(
106
120
  input: DeepInterviewAnswerInput,
107
121
  now: string = new Date().toISOString(),
@@ -117,7 +131,7 @@ export function buildAnswerShell(
117
131
  selected_options: input.selectedOptions,
118
132
  custom_input: input.customInput,
119
133
  component: input.component,
120
- dimension: input.dimension,
134
+ dimension: canonicalDimension(input.dimension),
121
135
  ambiguity_at_ask: input.ambiguity,
122
136
  lifecycle: "answered",
123
137
  answered_at: now,
@@ -125,11 +139,9 @@ export function buildAnswerShell(
125
139
  }
126
140
 
127
141
  /**
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.
142
+ * Append-or-merge by `round_key`. An answer is a no-op only when its complete
143
+ * canonical shell identity matches; a scored record is immutable evidence and
144
+ * cannot be replaced by a late answer.
133
145
  */
134
146
  export function appendOrMergeRound(
135
147
  rounds: readonly DeepInterviewRoundRecord[],
@@ -142,9 +154,10 @@ export function appendOrMergeRound(
142
154
  return { rounds: next, action: "created", record: shell };
143
155
  }
144
156
  const existing = next[index];
145
- if (existing.question_hash === shell.question_hash && existing.answer_hash === shell.answer_hash) {
157
+ if (deepInterviewAnswerIdentityEqual(existing, shell) && existing.answer_hash === shell.answer_hash) {
146
158
  return { rounds: next, action: "noop", record: existing };
147
159
  }
160
+ if (existing.lifecycle === "scored") throw new Error("DI_ANSWER_LIFECYCLE_CONFLICT");
148
161
  next[index] = shell;
149
162
  return { rounds: next, action: "replaced", record: shell };
150
163
  }
@@ -224,10 +237,10 @@ export function validateDeepInterviewScoredTransition(
224
237
  `active trigger ${trigger.kind} did not raise ambiguity (${prior.ambiguity} -> ${next.ambiguity})`,
225
238
  );
226
239
  }
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;
240
+ // The runtime derives both dimension values from scored records; callers cannot
241
+ // provide parallel trigger metrics.
242
+ const priorDim = prior.scores?.[trigger.dimension];
243
+ const nextDim = next.scores?.[trigger.dimension];
231
244
  if (typeof priorDim !== "number" || typeof nextDim !== "number") {
232
245
  violations.push(
233
246
  `active trigger ${trigger.kind} is missing dimension "${trigger.dimension}" scores to prove non-improvement`,
@@ -304,19 +317,16 @@ export function projectCompactState(value: unknown, options: { lastN?: number }
304
317
  // Persistence wrappers (state-writer backed; runtime-owned)
305
318
  // =============================================================================
306
319
 
320
+ interface RecorderMutationOptions {
321
+ sessionId?: string;
322
+ expectedRevision?: number;
323
+ }
324
+
307
325
  async function readEnvelope(statePath: string): Promise<DeepInterviewStateEnvelope> {
308
326
  const read = await readExistingStateForMutation(statePath);
309
327
  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);
328
+ if (read.kind === "absent") throw new Error("DI_STATE_ABSENT");
329
+ throw new Error(`deep-interview state at ${statePath} is corrupt or tampered (${read.error})`);
320
330
  }
321
331
 
322
332
  function existingStateRevision(value: unknown): number | undefined {
@@ -329,49 +339,16 @@ function interviewIdOf(envelope: DeepInterviewStateEnvelope): string | undefined
329
339
  const inner = (envelope.state ?? {}) as Record<string, unknown>;
330
340
  return typeof inner.interview_id === "string" ? inner.interview_id : undefined;
331
341
  }
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 });
342
+ interface RecorderHudSyncSlot {
343
+ latestCommittedRevision: number;
344
+ tail: Promise<void>;
365
345
  }
366
346
 
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
- */
347
+ const recorderHudSyncSlots = new Map<string, RecorderHudSyncSlot>();
348
+
372
349
  async function syncRecorderHud(
373
350
  cwd: string,
374
- envelope: DeepInterviewStateEnvelope,
351
+ envelope: Record<string, unknown>,
375
352
  sessionId: string | undefined,
376
353
  ): Promise<void> {
377
354
  const phase = typeof envelope.current_phase === "string" ? envelope.current_phase : "interviewing";
@@ -382,26 +359,65 @@ async function syncRecorderHud(
382
359
  phase,
383
360
  sessionId,
384
361
  source: "gjc-runtime-deep-interview-recorder",
385
- hud: deriveDeepInterviewHud(normalizeDeepInterviewEnvelope(envelope) as Record<string, unknown>, { phase }),
386
- sourceRevision: (existingStateRevision(envelope) ?? 0) + 1,
362
+ hud: deriveDeepInterviewHud(envelope, { phase }),
363
+ ...(typeof envelope.state_revision === "number" ? { committedModeRevision: envelope.state_revision } : {}),
387
364
  });
388
365
  }
389
366
 
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(
367
+ async function syncRecorderHudAtCommittedRevision(
396
368
  cwd: string,
397
- statePath: string,
398
- sessionId: string | undefined,
369
+ envelope: Record<string, unknown>,
370
+ sessionId: string,
371
+ revision: number,
399
372
  ): Promise<void> {
373
+ const key = `${cwd}\0${sessionId}`;
374
+ const slot = recorderHudSyncSlots.get(key) ?? { latestCommittedRevision: -1, tail: Promise.resolve() };
375
+ recorderHudSyncSlots.set(key, slot);
376
+ const sync = slot.tail
377
+ .catch(() => undefined)
378
+ .then(async () => {
379
+ if (revision <= slot.latestCommittedRevision) return;
380
+ await syncRecorderHud(cwd, { ...envelope, state_revision: revision }, sessionId);
381
+ slot.latestCommittedRevision = revision;
382
+ });
383
+ slot.tail = sync;
384
+ await sync;
385
+ }
386
+ export interface DeepInterviewPostCommitWarning {
387
+ code: "DI_POST_COMMIT_AUDIT_FAILED" | "DI_POST_COMMIT_ACTIVITY_FAILED" | "DI_POST_COMMIT_HUD_FAILED";
388
+ message: string;
389
+ }
390
+
391
+ export async function runDeepInterviewPostCommitEffects(options: {
392
+ cwd: string;
393
+ statePath: string;
394
+ sessionId: string;
395
+ envelope: Record<string, unknown>;
396
+ revision: number;
397
+ writer: string;
398
+ auditWarnings?: readonly { code: "DI_POST_COMMIT_AUDIT_FAILED"; message: string }[];
399
+ }): Promise<DeepInterviewPostCommitWarning[]> {
400
+ const warnings: DeepInterviewPostCommitWarning[] = [...(options.auditWarnings ?? [])];
401
+ try {
402
+ await writeSessionActivityMarker(options.cwd, options.sessionId, {
403
+ writer: options.writer,
404
+ path: options.statePath,
405
+ });
406
+ } catch (error) {
407
+ warnings.push({
408
+ code: "DI_POST_COMMIT_ACTIVITY_FAILED",
409
+ message: error instanceof Error ? error.message : String(error),
410
+ });
411
+ }
400
412
  try {
401
- await syncDeepInterviewRecorderHud(cwd, statePath, sessionId);
402
- } catch {
403
- // HUD sync is best-effort cache maintenance and must not change record semantics.
413
+ await syncRecorderHudAtCommittedRevision(options.cwd, options.envelope, options.sessionId, options.revision);
414
+ } catch (error) {
415
+ warnings.push({
416
+ code: "DI_POST_COMMIT_HUD_FAILED",
417
+ message: error instanceof Error ? error.message : String(error),
418
+ });
404
419
  }
420
+ return warnings;
405
421
  }
406
422
 
407
423
  /** Refresh the best-effort HUD cache from persisted deep-interview state. */
@@ -412,110 +428,129 @@ export async function syncDeepInterviewRecorderHud(
412
428
  ): Promise<void> {
413
429
  const read = await readExistingStateForMutation(statePath);
414
430
  if (read.kind !== "valid") return;
415
- await syncRecorderHud(cwd, normalizeDeepInterviewEnvelope(read.value), sessionId);
431
+ const envelope = normalizeDeepInterviewEnvelope(read.value);
432
+ const revision = existingStateRevision(read.value);
433
+ if (revision === undefined || !sessionId) return;
434
+ await syncRecorderHudAtCommittedRevision(cwd, envelope, sessionId, revision);
416
435
  }
417
436
 
418
437
  /**
419
438
  * Record an `answered` shell for one round (append-or-merge by durable key).
420
439
  *
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.
440
+ * Replacing an already-scored answer is rejected with
441
+ * `DI_ANSWER_LIFECYCLE_CONFLICT`; scored evidence is immutable.
425
442
  */
426
443
  export async function appendOrMergeDeepInterviewRound(
427
444
  cwd: string,
428
445
  statePath: string,
429
446
  input: DeepInterviewAnswerInput,
430
- options: { sessionId?: string } = {},
431
- ): Promise<{ action: AppendOrMergeAction; record: DeepInterviewRoundRecord; disputedFactIds?: string[] }> {
447
+ options: RecorderMutationOptions = {},
448
+ ): Promise<{
449
+ action: AppendOrMergeAction;
450
+ record: DeepInterviewRoundRecord;
451
+ warnings: DeepInterviewPostCommitWarning[];
452
+ }> {
432
453
  assertDeepInterviewStructuredResponseWithinLimit(input);
433
454
  if (input.customInput !== undefined)
434
455
  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,
456
+ if (!options.sessionId) throw new Error("deep-interview recorder requires a session id");
457
+ const initial = await readEnvelope(statePath);
458
+ let result: AppendOrMergeResult | undefined;
459
+ const now = new Date().toISOString();
460
+ const committed = await transformGuardedWorkflowEnvelopeAtomic(statePath, {
461
+ cwd,
462
+ expectedRevision: options.expectedRevision ?? existingStateRevision(initial) ?? 0,
463
+ receipt: {
464
+ cwd,
465
+ skill: "deep-interview",
466
+ owner: "gjc-runtime",
467
+ command: "gjc deep-interview record-answer",
468
+ sessionId: options.sessionId,
469
+ nowIso: now,
470
+ },
471
+ audit: {
472
+ category: "state",
473
+ verb: "write",
474
+ owner: "gjc-runtime",
475
+ skill: "deep-interview",
476
+ sessionId: options.sessionId,
477
+ },
478
+ transform: current => {
479
+ const envelope = ensureDeepInterviewStateShape(current);
480
+ const shell = buildAnswerShell(
481
+ {
482
+ ...input,
483
+ interviewId: input.interviewId ?? interviewIdOf(envelope),
484
+ customInput: input.intent_contract || input.intent_review ? undefined : input.customInput,
485
+ },
486
+ now,
487
+ );
488
+ result = appendOrMergeRound(readRounds(envelope), shell);
489
+ const state = envelope.state as Record<string, unknown>;
490
+ let intentStateChanged = false;
491
+ if (input.intent_contract) {
492
+ if (input.round !== 0 || input.component !== "review-topology" || input.dimension !== "topology")
493
+ throw new Error("intent contract requires Round 0 topology metadata");
494
+ const confirmed =
495
+ input.selectedOptions?.length === 1 &&
496
+ input.intent_contract.confirmation_options.includes(input.selectedOptions[0]);
497
+ if (confirmed) {
498
+ const contract = createDeepInterviewIntentManifest(input.intent_contract.items, {
499
+ round: 0,
484
500
  answer_hash: shell.answer_hash,
485
- user_answer_evidence: `answer_hash:${shell.answer_hash}`,
501
+ });
502
+ const existingContract = state.intent_contract;
503
+ if (existingContract !== undefined) {
504
+ assertDeepInterviewIntentManifest(existingContract);
505
+ if (
506
+ existingContract.digest !== contract.digest ||
507
+ existingContract.confirmation_answer_hash !== contract.confirmation_answer_hash
508
+ )
509
+ throw new Error("locked intent contract cannot be replaced");
510
+ } else {
511
+ state.intent_contract = contract;
512
+ intentStateChanged = true;
486
513
  }
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;
514
+ }
509
515
  }
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 };
516
+ if (input.intent_review) {
517
+ if (input.round <= 0) throw new Error("intent review requires a post-Round-0 answer");
518
+ const locked = state.intent_contract;
519
+ assertDeepInterviewIntentManifest(locked);
520
+ const approved =
521
+ input.selectedOptions?.length === 1 &&
522
+ input.intent_review.approval_options.includes(input.selectedOptions[0]);
523
+ state.intent_review = reviewDeepInterviewIntent(locked, input.intent_review.observed_items, {
524
+ status: approved ? "approved" : "pending",
525
+ supporting_substitutions: input.intent_review.supporting_substitutions,
526
+ ...(approved
527
+ ? {
528
+ approval_round: input.round,
529
+ answer_hash: shell.answer_hash,
530
+ user_answer_evidence: `answer_hash:${shell.answer_hash}`,
531
+ }
532
+ : {}),
533
+ });
534
+ intentStateChanged = true;
535
+ }
536
+ if (result.action === "noop" && !intentStateChanged) return { kind: "noop" as const };
537
+ state.rounds = result.rounds;
538
+ return { kind: "write" as const, value: { ...envelope, updated_at: now } as Record<string, unknown> };
539
+ },
540
+ });
541
+ if (!result) throw new Error("DI_STATE_SCHEMA_INVALID");
542
+ const warnings = committed.written
543
+ ? await runDeepInterviewPostCommitEffects({
544
+ cwd,
545
+ statePath,
546
+ sessionId: options.sessionId,
547
+ envelope: committed.stamped,
548
+ revision: committed.revision,
549
+ writer: "deep-interview-recorder",
550
+ auditWarnings: committed.warnings,
551
+ })
552
+ : [];
553
+ return { action: result.action, record: result.record, warnings };
519
554
  }
520
555
 
521
556
  /**
@@ -529,21 +564,58 @@ export async function appendOrMergeDeepInterviewRound(
529
564
  * not finite, that comparison is treated as non-matching, so no prior is selected
530
565
  * rather than risking a spurious comparison against an unrelated round.
531
566
  */
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;
567
+ function legacyScoringRoundResult(
568
+ envelope: DeepInterviewStateEnvelope,
569
+ input: DeepInterviewScoringInput,
570
+ ): DeepInterviewRoundResultV1 {
571
+ if (input.roundResult) return input.roundResult;
572
+ const state = envelope.state as Record<string, unknown>;
573
+ const type = state.type;
574
+ const dimensions =
575
+ type === "brownfield"
576
+ ? ["goal", "constraints", "criteria", "context"]
577
+ : type === "greenfield"
578
+ ? ["goal", "constraints", "criteria"]
579
+ : undefined;
580
+ if (
581
+ !dimensions?.every(
582
+ dimension => typeof input.scores[dimension] === "number" && Number.isFinite(input.scores[dimension]),
583
+ )
584
+ ) {
585
+ throw new Error(
586
+ "DI_LEGACY_SCORING_INPUT_INSUFFICIENT: provide roundResult with complete global and component scores",
587
+ );
588
+ }
589
+ const topology = state.topology;
590
+ const hasActiveComponents =
591
+ !!topology &&
592
+ typeof topology === "object" &&
593
+ !Array.isArray(topology) &&
594
+ (() => {
595
+ const topologyRecord = topology as Record<string, unknown>;
596
+ if (!Array.isArray(topologyRecord.components)) return false;
597
+ const components = topologyRecord.components as unknown[];
598
+ return components.some(
599
+ (component: unknown) =>
600
+ component &&
601
+ typeof component === "object" &&
602
+ !Array.isArray(component) &&
603
+ (component as Record<string, unknown>).active !== false &&
604
+ (component as Record<string, unknown>).status !== "deferred",
605
+ );
606
+ })();
607
+ if (hasActiveComponents) {
608
+ throw new Error(
609
+ "DI_LEGACY_SCORING_INPUT_INSUFFICIENT: provide roundResult with complete global and component scores",
610
+ );
545
611
  }
546
- return prior;
612
+ return {
613
+ global_scores: Object.fromEntries(
614
+ dimensions.map(dimension => [dimension, input.scores[dimension]]),
615
+ ) as DeepInterviewRoundResultV1["global_scores"],
616
+ component_scores: {},
617
+ ...(input.triggers === undefined ? {} : { triggers: input.triggers }),
618
+ };
547
619
  }
548
620
 
549
621
  /** Merge scoring output into the same round record, transitioning to `scored`. */
@@ -551,53 +623,64 @@ export async function enrichDeepInterviewRoundScoring(
551
623
  cwd: string,
552
624
  statePath: string,
553
625
  input: DeepInterviewScoringInput,
554
- options: { sessionId?: string } = {},
555
- ): Promise<{ record: DeepInterviewRoundRecord }> {
626
+ options: RecorderMutationOptions = {},
627
+ ): Promise<{ record: DeepInterviewRoundRecord; warnings: DeepInterviewPostCommitWarning[] }> {
556
628
  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,
629
+ if (!options.sessionId) throw new Error("deep-interview recorder requires a session id");
630
+ const initial = await readEnvelope(statePath);
631
+ const now = new Date().toISOString();
632
+ let record: DeepInterviewRoundRecord | undefined;
633
+ const committed = await transformGuardedWorkflowEnvelopeAtomic(statePath, {
634
+ cwd,
635
+ expectedRevision: options.expectedRevision ?? existingStateRevision(initial) ?? 0,
636
+ receipt: {
637
+ cwd,
638
+ skill: "deep-interview",
639
+ owner: "gjc-runtime",
640
+ command: "gjc deep-interview score-round",
641
+ sessionId: options.sessionId,
642
+ nowIso: now,
643
+ },
644
+ audit: {
645
+ category: "state",
646
+ verb: "write",
647
+ owner: "gjc-runtime",
648
+ skill: "deep-interview",
649
+ sessionId: options.sessionId,
650
+ },
651
+ transform: current => {
652
+ const envelope = ensureDeepInterviewStateShape(current);
653
+ const interviewId = input.interviewId ?? interviewIdOf(envelope);
654
+ const roundKey = deriveRoundKey(interviewId, input);
655
+ const outcome = applyDeepInterviewRoundResultV1(
656
+ envelope,
657
+ roundKey,
658
+ legacyScoringRoundResult(envelope, input),
659
+ now,
660
+ );
661
+ const next = ensureDeepInterviewStateShape(outcome.envelope);
662
+ record = readRounds(next).find(round => round.round_key === roundKey);
663
+ if (!record) throw new Error("DI_STATE_SCHEMA_INVALID");
664
+ if (outcome.kind === "noop") return { kind: "noop" as const };
665
+ return {
666
+ kind: "write" as const,
667
+ value: { ...next, schema_version: 1, updated_at: now } as Record<string, unknown>,
668
+ };
669
+ },
563
670
  });
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 };
671
+ if (!record) throw new Error("DI_STATE_SCHEMA_INVALID");
672
+ const warnings = committed.written
673
+ ? await runDeepInterviewPostCommitEffects({
674
+ cwd,
675
+ statePath,
676
+ sessionId: options.sessionId,
677
+ envelope: committed.stamped,
678
+ revision: committed.revision,
679
+ writer: "deep-interview-recorder",
680
+ auditWarnings: committed.warnings,
681
+ })
682
+ : [];
683
+ return { record, warnings };
601
684
  }
602
685
 
603
686
  /** Compact projection so callers read a slice instead of the full transcript. */