@gajae-code/coding-agent 0.8.0 → 0.8.2

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 (116) hide show
  1. package/CHANGELOG.md +48 -0
  2. package/dist/types/cli/completion-cli.d.ts +41 -0
  3. package/dist/types/cli/config-cli.d.ts +1 -0
  4. package/dist/types/cli/notify-cli.d.ts +7 -0
  5. package/dist/types/cli.d.ts +124 -1
  6. package/dist/types/commands/completion.d.ts +29 -0
  7. package/dist/types/commands/config.d.ts +3 -0
  8. package/dist/types/commands/deep-interview.d.ts +3 -0
  9. package/dist/types/config/file-lock.d.ts +7 -6
  10. package/dist/types/config/keybindings.d.ts +2 -2
  11. package/dist/types/config/model-profile-activation.d.ts +6 -0
  12. package/dist/types/config/settings.d.ts +8 -0
  13. package/dist/types/extensibility/extensions/runner.d.ts +2 -1
  14. package/dist/types/extensibility/extensions/types.d.ts +8 -0
  15. package/dist/types/gjc-runtime/deep-interview-ambiguity.d.ts +39 -0
  16. package/dist/types/gjc-runtime/deep-interview-recorder.d.ts +10 -1
  17. package/dist/types/gjc-runtime/deep-interview-runtime.d.ts +1 -1
  18. package/dist/types/gjc-runtime/deep-interview-state.d.ts +12 -0
  19. package/dist/types/gjc-runtime/team-runtime.d.ts +2 -1
  20. package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +1 -0
  21. package/dist/types/main.d.ts +4 -1
  22. package/dist/types/modes/components/model-selector.d.ts +4 -0
  23. package/dist/types/modes/components/queued-message-selector.d.ts +12 -0
  24. package/dist/types/modes/components/settings-selector.d.ts +2 -0
  25. package/dist/types/modes/controllers/input-controller.d.ts +1 -3
  26. package/dist/types/modes/print-mode.d.ts +13 -1
  27. package/dist/types/modes/prompt-action-autocomplete.d.ts +1 -1
  28. package/dist/types/notifications/config.d.ts +8 -1
  29. package/dist/types/notifications/daemon-paths.d.ts +1 -0
  30. package/dist/types/notifications/index.d.ts +6 -3
  31. package/dist/types/notifications/lifecycle-commands.d.ts +11 -3
  32. package/dist/types/notifications/telegram-daemon.d.ts +18 -3
  33. package/dist/types/notifications/telegram-reference.d.ts +0 -5
  34. package/dist/types/runtime-api-key.d.ts +7 -0
  35. package/dist/types/session/agent-session.d.ts +10 -0
  36. package/dist/types/session/agent-storage.d.ts +1 -0
  37. package/dist/types/task/executor.d.ts +2 -0
  38. package/dist/types/tools/computer.d.ts +2 -2
  39. package/dist/types/tools/skill.d.ts +1 -1
  40. package/dist/types/workflow/workflow-intent-diff.d.ts +8 -2
  41. package/dist/types/workflow/workflow-intent-report-consumer.d.ts +50 -0
  42. package/dist/types/workflow/workflow-intent-report-schema.d.ts +2 -0
  43. package/dist/types/workflow/workflow-intent-report.d.ts +48 -0
  44. package/package.json +7 -7
  45. package/src/cli/completion-cli.ts +234 -0
  46. package/src/cli/config-cli.ts +37 -9
  47. package/src/cli/fast-help.ts +1 -0
  48. package/src/cli/notify-cli.ts +91 -7
  49. package/src/cli.ts +3 -1
  50. package/src/commands/completion.ts +84 -0
  51. package/src/commands/config.ts +2 -0
  52. package/src/commands/deep-interview.ts +2 -1
  53. package/src/config/file-lock.ts +73 -34
  54. package/src/config/keybindings.ts +2 -2
  55. package/src/config/model-profile-activation.ts +65 -0
  56. package/src/config/model-registry.ts +14 -2
  57. package/src/config/settings.ts +22 -0
  58. package/src/coordinator-mcp/server.ts +67 -12
  59. package/src/defaults/gjc/skills/deep-interview/SKILL.md +38 -5
  60. package/src/defaults/gjc/skills/ralplan/SKILL.md +15 -11
  61. package/src/defaults/gjc/skills/team/SKILL.md +1 -1
  62. package/src/extensibility/extensions/runner.ts +2 -0
  63. package/src/extensibility/extensions/types.ts +9 -0
  64. package/src/extensibility/skills.ts +30 -2
  65. package/src/gjc-runtime/deep-interview-ambiguity.ts +206 -0
  66. package/src/gjc-runtime/deep-interview-recorder.ts +62 -8
  67. package/src/gjc-runtime/deep-interview-runtime.ts +210 -2
  68. package/src/gjc-runtime/deep-interview-state.ts +13 -0
  69. package/src/gjc-runtime/session-state-sidecar.ts +59 -11
  70. package/src/gjc-runtime/state-runtime.ts +12 -2
  71. package/src/gjc-runtime/team-runtime.ts +23 -0
  72. package/src/gjc-runtime/ultragoal-runtime.ts +73 -19
  73. package/src/hooks/native-skill-hook.ts +29 -0
  74. package/src/internal-urls/docs-index.generated.ts +2 -2
  75. package/src/main.ts +52 -41
  76. package/src/modes/acp/acp-agent.ts +7 -6
  77. package/src/modes/components/model-selector.ts +54 -11
  78. package/src/modes/components/queued-message-selector.ts +92 -0
  79. package/src/modes/components/settings-selector.ts +4 -0
  80. package/src/modes/components/welcome.ts +160 -44
  81. package/src/modes/controllers/command-controller.ts +4 -4
  82. package/src/modes/controllers/extension-ui-controller.ts +4 -10
  83. package/src/modes/controllers/input-controller.ts +186 -25
  84. package/src/modes/controllers/selector-controller.ts +122 -27
  85. package/src/modes/interactive-mode.ts +43 -16
  86. package/src/modes/print-mode.ts +43 -4
  87. package/src/modes/prompt-action-autocomplete.ts +24 -13
  88. package/src/modes/utils/hotkeys-markdown.ts +1 -0
  89. package/src/modes/utils/ui-helpers.ts +2 -2
  90. package/src/notifications/config.ts +9 -1
  91. package/src/notifications/daemon-paths.ts +2 -0
  92. package/src/notifications/index.ts +39 -9
  93. package/src/notifications/lifecycle-commands.ts +37 -7
  94. package/src/notifications/lifecycle-orchestrator.ts +19 -6
  95. package/src/notifications/telegram-daemon-control.ts +3 -1
  96. package/src/notifications/telegram-daemon.ts +231 -43
  97. package/src/notifications/telegram-reference.ts +0 -16
  98. package/src/prompts/system/system-prompt.md +3 -0
  99. package/src/prompts/tools/computer.md +2 -2
  100. package/src/runtime-api-key.ts +16 -0
  101. package/src/sdk.ts +20 -6
  102. package/src/session/agent-session.ts +225 -31
  103. package/src/session/agent-storage.ts +4 -0
  104. package/src/session/session-manager.ts +44 -2
  105. package/src/slash-commands/builtin-registry.ts +153 -71
  106. package/src/task/executor.ts +7 -4
  107. package/src/task/index.ts +9 -3
  108. package/src/task/output-manager.ts +16 -6
  109. package/src/task/receipt.ts +26 -7
  110. package/src/tools/computer.ts +246 -43
  111. package/src/tools/skill.ts +1 -1
  112. package/src/tools/telegram-send.ts +6 -0
  113. package/src/workflow/workflow-intent-diff.ts +29 -6
  114. package/src/workflow/workflow-intent-report-consumer.ts +221 -0
  115. package/src/workflow/workflow-intent-report-schema.ts +129 -0
  116. package/src/workflow/workflow-intent-report.ts +135 -0
@@ -70,19 +70,22 @@ The consensus workflow:
70
70
  - If only one viable option remains, explicit invalidation rationale for alternatives
71
71
  - Deliberate mode only: pre-mortem (3 scenarios) + expanded test plan (unit/integration/e2e/observability)
72
72
  2. **User feedback** *(--interactive only)*: If `--interactive` is set, use the `ask` tool to present the draft plan **plus the Principles / Drivers / Options summary** before review (Proceed to review / Request changes / Skip review). Otherwise, automatically proceed to review.
73
- 3. **Architect** reviews for architectural soundness and must provide the strongest steelman antithesis, at least one real tradeoff tension, and (when possible) synthesis — **await completion before step 4**. In deliberate mode, Architect should explicitly flag principle violations.
73
+ 3. **Review fan-out after Planner persistence**: after the Planner stage is persisted, launch fresh Architect and Critic review lanes against the same immutable Planner receipt/path/sha/stage_n when the Critic lane is **plan-only** and does not consume Architect output.
74
+ - **Architect lane**: Architect reviews for architectural soundness and must provide the strongest steelman antithesis, at least one real tradeoff tension, and (when possible) synthesis. In deliberate mode, Architect should explicitly flag principle violations.
74
75
  - The Architect agent/subagent must persist its review with `gjc ralplan --write --stage architect --stage_n <N> --artifact-env GJC_RALPLAN_ARTIFACT --json`, then return the receipt/path plus compact verdict/status (`CLEAR`/`WATCH`/`BLOCK`, `APPROVE`/`COMMENT`/`REQUEST CHANGES`) instead of pasting the full review body.
75
- 4. **Critic** evaluates against quality criteria — run only after step 3 completes. Critic must enforce principle-option consistency, fair alternatives, risk mitigation clarity, testable acceptance criteria, and concrete verification steps. In deliberate mode, Critic must reject missing/weak pre-mortem or expanded test plan.
76
+ - **Plan-only Critic lane**: Critic evaluates the Planner artifact against quality criteria independently of Architect output. It must enforce principle-option consistency, fair alternatives, risk mitigation clarity, testable acceptance criteria, and concrete verification steps. In deliberate mode, Critic must reject missing/weak pre-mortem or expanded test plan.
76
77
  - The Critic agent/subagent must persist its evaluation with `gjc ralplan --write --stage critic --stage_n <N> --artifact-env GJC_RALPLAN_ARTIFACT --json`, then return the receipt/path plus compact verdict/status (`OKAY`/`ITERATE`/`REJECT`) instead of pasting the full evaluation body.
77
- 5. **Re-review loop** (max 5 iterations): Any non-`OKAY` Critic verdict (`ITERATE` or `REJECT`) MUST run the same full closed loop:
78
+ - **Sequential fallback**: when Critic must evaluate Architect findings, verdict, antithesis, tradeoffs, synthesis, architectural status, or any other Architect-produced artifact, await the Architect result before issuing that Architect-dependent Critic pass.
79
+ 4. **Review join gate**: before consensus, revision, reconciliation, finalization, or approval, verify both Architect and Critic receipts/verdicts exist for the same Planner artifact/pass (`path`, `sha256`, `stage_n`). A non-`CLEAR` Architect verdict, non-`APPROVE` Architect decision, or any non-`OKAY` Critic verdict routes back to Planner revision; do not finalize from only one review lane.
80
+ 5. **Re-review loop** (max 5 iterations): Any non-`OKAY` Critic verdict (`ITERATE` or `REJECT`) or Architect result that is not `CLEAR`/`APPROVE` MUST run the same full closed loop:
78
81
  a. Collect Architect + Critic feedback
79
82
  b. Revise the plan by resuming the SAME persisted Planner subagent with consolidated Architect + Critic feedback (see **Persisted Planner** below); fall back to a fresh Planner spawn only per the fallback routing table
80
- c. Return to Architect review
83
+ c. Return to the review fan-out or sequential fallback path above
81
84
  - Persist each Planner revision with `gjc ralplan --write --stage revision --stage_n <N> --artifact-env GJC_RALPLAN_ARTIFACT --json` before re-review, then pass the receipt/path forward instead of duplicating the full revision markdown in the parent conversation.
82
- d. Return to Critic evaluation
83
- e. Repeat this loop until Critic returns `OKAY` or 5 iterations are reached
84
- f. If 5 iterations are reached without `OKAY`, present the best version to the user
85
- 6. **Post-ralplan interview** (intent reconciliation gate): After Critic returns `OKAY` and before the plan is finalized, reconcile the consensus plan against the user's actual intent. The goal is to make sure ralplan did not silently bake in assumptions that conflict with what the user wants.
85
+ d. Re-join Architect and Critic verdicts for the same revised Planner artifact/pass
86
+ e. Repeat this loop until Critic returns `OKAY` **and** Architect is `CLEAR`/`APPROVE` for the same Planner artifact/pass, or 5 iterations are reached
87
+ f. If 5 iterations are reached without Critic `OKAY` plus Architect `CLEAR`/`APPROVE`, present the best version to the user
88
+ 6. **Post-ralplan interview** (intent reconciliation gate): After the review join gate has both Critic `OKAY` and Architect `CLEAR`/`APPROVE` for the same Planner artifact/pass, and before the plan is finalized, reconcile the consensus plan against the user's actual intent. The goal is to make sure ralplan did not silently bake in assumptions that conflict with what the user wants.
86
89
  a. **Collect open items** from the run: every assumption the Planner/Architect/Critic resolved by assumption rather than by stated fact, every ambiguity flagged during review, and every decision the loop made without explicit user input. Source these from the persisted `planner`/`architect`/`critic`/`revision` stage artifacts, not from memory.
87
90
  b. **Cross-check prior context for conflicts**: glob `.gjc/_session-{sessionid}/specs/deep-interview-*.md` and other prior specs/plans/context relevant by topic. For each, list points where the consensus plan contradicts, weakens, or expands beyond a previously crystallized decision, constraint, or non-goal. Cite the conflicting artifact and line/section.
88
91
  c. **Reconcile with the user via the `ask` tool (always, regardless of `--interactive`)**: Never stop idle with plain-text prose after the consensus loop. Every reconciliation question MUST go through the `ask` tool with contextual options plus free-text.
@@ -90,7 +93,7 @@ The consensus workflow:
90
93
  - If the plan is crystal clear (no open assumptions or prior-context conflicts), skip straight to the step 8 final-options `ask` instead of inventing filler questions.
91
94
  - For every confirmed open item, embed the resolved outcome into the final plan under an **## Intent Reconciliation** section so the `pending approval` artifact records each decision; record any item the user explicitly defers as an open confirmation under that same section.
92
95
  d. Persist the reconciliation with `gjc ralplan --write --stage post-interview --stage_n <N> --artifact-env GJC_RALPLAN_ARTIFACT --json`, then return the receipt/path plus a compact status (reconciled-clean / reconciled-with-revision / open-confirmations-pending) instead of pasting the full body.
93
- 7. On reconciliation completion, mark the plan `pending approval` unless explicit execution approval has already been captured, persist the ADR/final plan via `gjc ralplan --write --stage final --stage_n <N> --artifact-env GJC_RALPLAN_ARTIFACT`, and do not directly edit `.gjc/_session-{sessionid}/plans`. Final plan must include ADR (Decision, Drivers, Alternatives considered, Why chosen, Consequences, Follow-ups) and, when present, the **## Intent Reconciliation** section.
96
+ 7. On reconciliation completion, re-check the review join gate (Critic `OKAY` plus Architect `CLEAR`/`APPROVE` for the same Planner artifact/pass), mark the plan `pending approval` unless explicit execution approval has already been captured, persist the ADR/final plan via `gjc ralplan --write --stage final --stage_n <N> --artifact-env GJC_RALPLAN_ARTIFACT`, and do not directly edit `.gjc/_session-{sessionid}/plans`. Final plan must include ADR (Decision, Drivers, Alternatives considered, Why chosen, Consequences, Follow-ups) and, when present, the **## Intent Reconciliation** section.
94
97
  8. **Always** present the finalized plan via the `ask` tool (regardless of `--interactive`) with `workflowGate: { stage: "ralplan", kind: "approval" }` on the final question so RPC/headless clients receive a `ralplan`/`approval` workflow gate, not a deep-interview question gate. Use these options:
95
98
  - **Refine further** — re-run the consensus loop / request changes, then return here
96
99
  - **Approve execution via ultragoal (Recommended)** — goal-tracked autonomous execution
@@ -108,13 +111,14 @@ The consensus workflow:
108
111
 
109
112
  The skill tool then dispatches the execution skill same-turn and runs `gjc state ralplan handoff --to <team|ultragoal> --json` in-process to atomically demote ralplan, promote the callee, and sync `.gjc/_session-{sessionid}/state/skill-active-state.json`. You do not need to run the handoff verb yourself.
110
113
 
111
- > **Important:** Steps 3 and 4 MUST run sequentially. Do NOT issue both agent Task calls in the same parallel batch. Always await the Architect result before issuing the Critic Task.
114
+ > **Important:** Architect and Critic MAY run in the same parallel batch only for the plan-only Critic lane after Planner persistence. Any Architect-dependent Critic pass MUST remain sequential: await Architect before issuing Critic, then apply the same review join gate before consensus.
115
+
112
116
 
113
117
  Follow the Plan skill's full documentation for consensus mode details.
114
118
 
115
119
  ### Persisted Planner (consensus loop)
116
120
 
117
- The Planner is a **same-session persisted subagent**: launched detached once, awaited before the Architect, then **resumed** with consolidated Architect + Critic feedback on every re-review pass instead of being re-spawned. The Architect and Critic stay **fresh, independent spawns each pass** so their verdicts remain reproducible from their pass artifacts alone. Do NOT modify the subagent control surface; this orchestration uses the existing `subagent` resume/steer controls only.
121
+ The Planner is a **same-session persisted subagent**: launched detached once, awaited before review fan-out, then **resumed** with consolidated Architect + Critic feedback on every re-review pass instead of being re-spawned. The Architect and Critic stay **fresh, independent spawns each pass** so their verdicts remain reproducible from their pass artifacts alone; the Critic may run in parallel only when it is plan-only and tied to the same Planner receipt/path/sha/stage_n as Architect. Do NOT modify the subagent control surface; this orchestration uses the existing `subagent` resume/steer controls only.
118
122
 
119
123
  **Persistence boundary:** this is same-parent, active-session continuity only. Resumability depends on the manager's retained subagent resume metadata and a persistent parent session (an in-memory parent yields `resumable:false`), not just the `.gjc` run-state record. A terminal subagent whose live job record was evicted can still be resumed when its retained resume descriptor points at a saved subagent session file. After a process restart, missing resume metadata, or any unavailable/failed resume, use the fresh Planner fallback.
120
124
 
@@ -112,7 +112,7 @@ Before launching `gjc team`, require a grounded context snapshot:
112
112
  - unknowns/open questions
113
113
  - likely codebase touchpoints
114
114
  4. If ambiguity remains high, gather brownfield facts with focused repo inspection or a canonical read-only role agent first, then run `$deep-interview --quick <task>` before team launch.
115
- 5. If current correctness depends on official docs, version-aware framework guidance, best practices, or external dependency behavior, auto-delegate `researcher` as an evidence lane before or alongside worker launch instead of relying on repo-local recall alone.
115
+ 5. If current correctness depends on official docs, version-aware framework guidance, best practices, or external dependency behavior, gather evidence before or alongside worker launch using supported surfaces only: focused repo inspection for local facts, `planner` for broad context mapping/sequencing, `architect` for architecture or external-doc-risk assessment, and direct web/search tools when configured.
116
116
 
117
117
  Do not start the worker pane until this gate is satisfied; if forced to proceed quickly, state explicit scope/risk limitations in the launch report.
118
118
 
@@ -205,6 +205,7 @@ export class ExtensionRunner {
205
205
  private readonly cwd: string,
206
206
  private readonly sessionManager: SessionManager,
207
207
  private readonly modelRegistry: ModelRegistry,
208
+ private readonly sessionMetadata?: ExtensionContext["sessionMetadata"],
208
209
  ) {
209
210
  this.#uiContext = noOpUIContext;
210
211
  }
@@ -458,6 +459,7 @@ export class ExtensionRunner {
458
459
  hasUI: this.hasUI(),
459
460
  cwd: this.cwd,
460
461
  sessionManager: this.sessionManager,
462
+ sessionMetadata: this.sessionMetadata,
461
463
  modelRegistry: this.modelRegistry,
462
464
  get model() {
463
465
  return getModel();
@@ -285,6 +285,13 @@ export interface CompactOptions {
285
285
  onError?: (error: Error) => void;
286
286
  }
287
287
 
288
+ export interface ExtensionSessionMetadata {
289
+ kind: "main" | "sub";
290
+ taskDepth: number;
291
+ parentTaskPrefix?: string;
292
+ currentAgentType?: string;
293
+ }
294
+
288
295
  /**
289
296
  * Context passed to extension event handlers.
290
297
  */
@@ -306,6 +313,8 @@ export interface ExtensionContext {
306
313
  cwd: string;
307
314
  /** Session manager (read-only) */
308
315
  sessionManager: ReadonlySessionManager;
316
+ /** Session classification supplied by the SDK for extension policy decisions. */
317
+ sessionMetadata?: ExtensionSessionMetadata;
309
318
  /** Model registry for API key resolution */
310
319
  modelRegistry: ModelRegistry;
311
320
  /** Current model (may be undefined) */
@@ -326,12 +326,28 @@ interface SkillTokenMatch {
326
326
  skill: Skill;
327
327
  }
328
328
 
329
+ function buildInlineSkillInvocationArgs(text: string, matches: readonly SkillTokenMatch[]): string {
330
+ const pieces: string[] = [];
331
+ let cursor = 0;
332
+ for (const match of matches) {
333
+ pieces.push(text.slice(cursor, match.index));
334
+ cursor = match.end;
335
+ }
336
+ pieces.push(text.slice(cursor));
337
+ return pieces
338
+ .join("")
339
+ .replace(/[ \t]{2,}/g, " ")
340
+ .replace(/[ \t]+\n/g, "\n")
341
+ .replace(/\n[ \t]+/g, "\n")
342
+ .trim();
343
+ }
344
+
329
345
  export function parseSkillInvocations(
330
346
  text: string,
331
347
  skillsByCommandName: ReadonlyMap<string, Skill>,
332
348
  ): ParsedSkillInvocation[] {
333
349
  const trimmedText = text.trim();
334
- if (!trimmedText.startsWith("/")) return [];
350
+ if (!trimmedText) return [];
335
351
  const canonicalSkillCommandPattern = /(^|\s)\/(skill:[^\s]+)/g;
336
352
  const matches: SkillTokenMatch[] = [];
337
353
  for (const match of trimmedText.matchAll(canonicalSkillCommandPattern)) {
@@ -348,7 +364,19 @@ export function parseSkillInvocations(
348
364
  skill,
349
365
  });
350
366
  }
351
- if (matches.length === 0 || matches[0]?.index !== 0) return [];
367
+ if (matches.length === 0) return [];
368
+ if (matches[0]?.index !== 0) {
369
+ // Preserve leading slash-command semantics: `/skill:unknown /skill:alpha`
370
+ // should remain plain text rather than silently skipping the unknown
371
+ // leading command and invoking the later known one.
372
+ if (trimmedText.startsWith("/")) return [];
373
+ const args = buildInlineSkillInvocationArgs(trimmedText, matches);
374
+ return matches.map(match => ({
375
+ commandName: match.commandName,
376
+ args,
377
+ skill: match.skill,
378
+ }));
379
+ }
352
380
  return matches.map((match, index) => {
353
381
  const next = matches[index + 1];
354
382
  return {
@@ -0,0 +1,206 @@
1
+ import {
2
+ type DeepInterviewEstablishedFact,
3
+ type DeepInterviewRoundRecord,
4
+ type DeepInterviewStateEnvelope,
5
+ normalizeDeepInterviewEnvelope,
6
+ } from "./deep-interview-state";
7
+
8
+ /**
9
+ * Deterministic ambiguity floor for deep-interview.
10
+ *
11
+ * The interview's ambiguity score is reported by an LLM scorer, which anchors on
12
+ * prior scores and under-reports rises when the user pivots or contradicts earlier
13
+ * answers. Following the Ouroboros `max(llm_score, deterministic_floor(ledger))`
14
+ * principle, this pure leaf module computes a code-level lower bound from evidence
15
+ * already persisted in deep-interview state, so the reported score can never fall
16
+ * below what code can objectively measure:
17
+ *
18
+ * - `0.10` per established fact marked disputed with no `superseded_by` resolution
19
+ * (contradiction pressure — a pivot keeps ambiguity elevated until resolved);
20
+ * - `0.05` per active topology component whose goal/constraints/criteria clarity
21
+ * is still unscored (gap pressure — a sibling component cannot hide);
22
+ * - `0.05 × (auto-answered rounds / scored rounds)` (assumption dilution).
23
+ *
24
+ * Like `deep-interview-state`, this module MUST stay pure and dependency-free
25
+ * (no filesystem, no state-writer, no CLI runtime) so every writer can apply it.
26
+ */
27
+
28
+ // =============================================================================
29
+ // Weights (mirrors the Ouroboros deterministic_floor coefficients)
30
+ // =============================================================================
31
+
32
+ const DISPUTED_FACT_WEIGHT = 0.1;
33
+ const UNSCORED_COMPONENT_WEIGHT = 0.05;
34
+ const AUTO_ANSWER_DILUTION_WEIGHT = 0.05;
35
+
36
+ const CORE_CLARITY_DIMENSIONS = ["goal", "constraints", "criteria"] as const;
37
+
38
+ export interface AmbiguityFloorBreakdown {
39
+ floor: number;
40
+ disputed_fact_count: number;
41
+ unscored_active_component_count: number;
42
+ auto_answer_ratio: number;
43
+ }
44
+
45
+ export interface AmbiguityClampResult {
46
+ effective: number;
47
+ clamped: boolean;
48
+ }
49
+
50
+ // =============================================================================
51
+ // Pure helpers
52
+ // =============================================================================
53
+
54
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
55
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
56
+ }
57
+
58
+ function asArray(value: unknown): unknown[] {
59
+ return Array.isArray(value) ? value : [];
60
+ }
61
+
62
+ function round2(value: number): number {
63
+ return Math.round(value * 100) / 100;
64
+ }
65
+
66
+ /** A disputed fact contributes pressure until it is resolved via `superseded_by` or re-confirmation. */
67
+ function isUnresolvedDisputedFact(value: unknown): boolean {
68
+ if (!isPlainObject(value)) return false;
69
+ const fact = value as Partial<DeepInterviewEstablishedFact>;
70
+ if (fact.disputed !== true) return false;
71
+ return typeof fact.superseded_by !== "string" || fact.superseded_by.trim() === "";
72
+ }
73
+
74
+ /**
75
+ * An active component is unscored while any core clarity dimension lacks a finite
76
+ * numeric score. Deferred components are excluded (they are outside ambiguity math),
77
+ * and the gate only arms once the topology was explicitly confirmed in Round 0.
78
+ */
79
+ function countUnscoredActiveComponents(topology: unknown): number {
80
+ if (!isPlainObject(topology) || topology.status !== "confirmed") return 0;
81
+ let unscored = 0;
82
+ for (const component of asArray(topology.components)) {
83
+ if (!isPlainObject(component) || component.status === "deferred") continue;
84
+ const clarity = isPlainObject(component.clarity_scores) ? component.clarity_scores : {};
85
+ const incomplete = CORE_CLARITY_DIMENSIONS.some(dimension => {
86
+ const score = clarity[dimension];
87
+ return typeof score !== "number" || !Number.isFinite(score);
88
+ });
89
+ if (incomplete) unscored += 1;
90
+ }
91
+ return unscored;
92
+ }
93
+
94
+ function autoAnswerRatio(inner: Record<string, unknown>): number {
95
+ const autoAnswered = asArray(inner.auto_answered_rounds).length;
96
+ if (autoAnswered === 0) return 0;
97
+ const scored = asArray(inner.rounds).filter(round => isPlainObject(round) && round.lifecycle === "scored").length;
98
+ return Math.min(1, autoAnswered / Math.max(scored, 1));
99
+ }
100
+
101
+ // =============================================================================
102
+ // Floor computation + clamping
103
+ // =============================================================================
104
+
105
+ /** Compute the deterministic floor from the inner deep-interview `state` object. */
106
+ export function computeAmbiguityFloor(inner: unknown): AmbiguityFloorBreakdown {
107
+ const state = isPlainObject(inner) ? inner : {};
108
+ const disputedFactCount = asArray(state.established_facts).filter(isUnresolvedDisputedFact).length;
109
+ const unscoredActiveComponentCount = countUnscoredActiveComponents(state.topology);
110
+ const ratio = autoAnswerRatio(state);
111
+ const floor =
112
+ DISPUTED_FACT_WEIGHT * disputedFactCount +
113
+ UNSCORED_COMPONENT_WEIGHT * unscoredActiveComponentCount +
114
+ AUTO_ANSWER_DILUTION_WEIGHT * ratio;
115
+ return {
116
+ floor: round2(Math.min(1, Math.max(0, floor))),
117
+ disputed_fact_count: disputedFactCount,
118
+ unscored_active_component_count: unscoredActiveComponentCount,
119
+ auto_answer_ratio: round2(ratio),
120
+ };
121
+ }
122
+
123
+ /** Clamp an LLM-reported ambiguity to the deterministic floor: `max(reported, floor)`. */
124
+ export function clampReportedAmbiguity(reported: number, floor: number): AmbiguityClampResult {
125
+ const bounded = Math.min(1, Math.max(0, reported));
126
+ if (floor > bounded) return { effective: Math.min(1, floor), clamped: true };
127
+ return { effective: bounded, clamped: false };
128
+ }
129
+
130
+ /**
131
+ * Mark every established fact recorded in `retractedRound` as disputed. This is the
132
+ * mechanical contradiction signal for answer retraction: when the user replaces an
133
+ * already-scored answer (A -> B pivot), the facts that answer established can no
134
+ * longer be trusted, so they raise the floor until re-confirmed or superseded.
135
+ * Returns new arrays/objects; never mutates the input.
136
+ */
137
+ export function disputeFactsFromRetractedRound(
138
+ facts: readonly unknown[],
139
+ retractedRound: number,
140
+ ): { facts: Record<string, unknown>[]; disputedIds: string[] } {
141
+ const disputedIds: string[] = [];
142
+ const next = facts.filter(isPlainObject).map(fact => {
143
+ const record = fact as Record<string, unknown>;
144
+ if (record.round !== retractedRound || record.disputed === true) return { ...record };
145
+ if (typeof record.superseded_by === "string" && record.superseded_by.trim() !== "") return { ...record };
146
+ if (typeof record.id === "string") disputedIds.push(record.id);
147
+ return { ...record, disputed: true };
148
+ });
149
+ return { facts: next, disputedIds };
150
+ }
151
+
152
+ export interface AppliedAmbiguityFloor {
153
+ envelope: DeepInterviewStateEnvelope;
154
+ breakdown: AmbiguityFloorBreakdown;
155
+ clamped: boolean;
156
+ }
157
+
158
+ /**
159
+ * Enforce the floor invariant on a full deep-interview envelope: recompute the floor
160
+ * from persisted evidence, clamp `state.current_ambiguity`, and clamp the latest
161
+ * scored round (preserving the original value as `reported_ambiguity` for audit).
162
+ * Historical rounds are never rewritten. Idempotent and non-mutating; every writer
163
+ * (state CLI write/reconcile, recorder) applies this immediately before persisting.
164
+ */
165
+ export function applyAmbiguityFloorToEnvelope(value: unknown): AppliedAmbiguityFloor {
166
+ const envelope = normalizeDeepInterviewEnvelope(value);
167
+ const inner = { ...(envelope.state as Record<string, unknown>) };
168
+ const breakdown = computeAmbiguityFloor(inner);
169
+ let clamped = false;
170
+
171
+ const rounds = asArray(inner.rounds).filter(isPlainObject) as unknown as DeepInterviewRoundRecord[];
172
+ let latestScoredIndex = -1;
173
+ for (let index = 0; index < rounds.length; index += 1) {
174
+ const candidate = rounds[index];
175
+ if (candidate.lifecycle !== "scored" || !Number.isFinite(candidate.round)) continue;
176
+ if (latestScoredIndex < 0 || candidate.round >= rounds[latestScoredIndex].round) latestScoredIndex = index;
177
+ }
178
+ if (latestScoredIndex >= 0) {
179
+ const latest = rounds[latestScoredIndex];
180
+ if (typeof latest.ambiguity === "number") {
181
+ const clampedRound = clampReportedAmbiguity(latest.ambiguity, breakdown.floor);
182
+ if (clampedRound.clamped) {
183
+ const nextRounds = [...rounds];
184
+ nextRounds[latestScoredIndex] = {
185
+ ...latest,
186
+ reported_ambiguity: latest.reported_ambiguity ?? latest.ambiguity,
187
+ ambiguity: clampedRound.effective,
188
+ ambiguity_floor: breakdown.floor,
189
+ };
190
+ inner.rounds = nextRounds;
191
+ clamped = true;
192
+ }
193
+ }
194
+ }
195
+
196
+ if (typeof inner.current_ambiguity === "number") {
197
+ const clampedCurrent = clampReportedAmbiguity(inner.current_ambiguity, breakdown.floor);
198
+ if (clampedCurrent.clamped) {
199
+ inner.current_ambiguity = clampedCurrent.effective;
200
+ clamped = true;
201
+ }
202
+ }
203
+
204
+ inner.ambiguity_floor = breakdown;
205
+ return { envelope: { ...envelope, state: inner }, breakdown, clamped };
206
+ }
@@ -1,6 +1,11 @@
1
1
  import { syncSkillActiveState } from "../skill-state/active-state";
2
2
  import { deriveDeepInterviewHud } from "../skill-state/workflow-hud";
3
3
  import { WORKFLOW_STATE_VERSION } from "../skill-state/workflow-state-contract";
4
+ import {
5
+ clampReportedAmbiguity,
6
+ computeAmbiguityFloor,
7
+ disputeFactsFromRetractedRound,
8
+ } from "./deep-interview-ambiguity";
4
9
  import {
5
10
  answerHash,
6
11
  type DeepInterviewEstablishedFact,
@@ -14,6 +19,7 @@ import {
14
19
  import { writeSessionActivityMarker } from "./session-resolution";
15
20
  import { readExistingStateForMutation, writeGuardedWorkflowEnvelopeAtomic } from "./state-writer";
16
21
 
22
+ export * from "./deep-interview-ambiguity";
17
23
  export * from "./deep-interview-state";
18
24
 
19
25
  /**
@@ -395,30 +401,55 @@ export async function syncDeepInterviewRecorderHud(
395
401
  await syncRecorderHud(cwd, normalizeDeepInterviewEnvelope(read.value), sessionId);
396
402
  }
397
403
 
398
- /** Record an `answered` shell for one round (append-or-merge by durable key). */
404
+ /**
405
+ * Record an `answered` shell for one round (append-or-merge by durable key).
406
+ *
407
+ * Retraction dynamics: replacing an already-`scored` answer is a mechanical
408
+ * contradiction signal (the user pivoted). The facts that round established are
409
+ * marked disputed, which raises the deterministic ambiguity floor immediately —
410
+ * without waiting for the LLM scorer to self-report a trigger.
411
+ */
399
412
  export async function appendOrMergeDeepInterviewRound(
400
413
  cwd: string,
401
414
  statePath: string,
402
415
  input: DeepInterviewAnswerInput,
403
416
  options: { sessionId?: string } = {},
404
- ): Promise<{ action: AppendOrMergeAction; record: DeepInterviewRoundRecord }> {
417
+ ): Promise<{ action: AppendOrMergeAction; record: DeepInterviewRoundRecord; disputedFactIds?: string[] }> {
405
418
  const envelope = await readEnvelope(statePath);
406
419
  const interviewId = input.interviewId ?? interviewIdOf(envelope);
407
420
  const shell = buildAnswerShell({ ...input, interviewId });
408
421
  const rounds = readRounds(envelope);
422
+ const priorRecord = rounds.find(r => r.round_key === shell.round_key);
409
423
  const result = appendOrMergeRound(rounds, shell);
410
424
  if (result.action === "noop") {
411
425
  await repairRecorderHudFromPersisted(cwd, statePath, options.sessionId);
412
426
  return { action: result.action, record: result.record };
413
427
  }
414
- (envelope.state as Record<string, unknown>).rounds = result.rounds;
428
+ const inner = envelope.state as Record<string, unknown>;
429
+ inner.rounds = result.rounds;
430
+ let disputedFactIds: string[] | undefined;
431
+ if (result.action === "replaced" && priorRecord?.lifecycle === "scored") {
432
+ const facts = Array.isArray(inner.established_facts) ? inner.established_facts : [];
433
+ const disputed = disputeFactsFromRetractedRound(facts, shell.round);
434
+ if (disputed.disputedIds.length > 0) {
435
+ inner.established_facts = disputed.facts;
436
+ disputedFactIds = disputed.disputedIds;
437
+ // Surface the rise right away: the retraction changed the evidence, so the
438
+ // gating score must reflect the new floor before the next scoring pass.
439
+ const breakdown = computeAmbiguityFloor(inner);
440
+ inner.ambiguity_floor = breakdown;
441
+ if (typeof inner.current_ambiguity === "number") {
442
+ inner.current_ambiguity = clampReportedAmbiguity(inner.current_ambiguity, breakdown.floor).effective;
443
+ }
444
+ }
445
+ }
415
446
  await persistEnvelope(cwd, statePath, envelope, options.sessionId, "gjc deep-interview record-answer");
416
447
  try {
417
448
  await syncRecorderHud(cwd, envelope, options.sessionId);
418
449
  } catch {
419
450
  // HUD sync is best-effort cache maintenance and must not change record semantics.
420
451
  }
421
- return { action: result.action, record: result.record };
452
+ return { action: result.action, record: result.record, disputedFactIds };
422
453
  }
423
454
 
424
455
  /**
@@ -459,12 +490,34 @@ export async function enrichDeepInterviewRoundScoring(
459
490
  const envelope = await readEnvelope(statePath);
460
491
  const interviewId = input.interviewId ?? interviewIdOf(envelope);
461
492
  const rounds = readRounds(envelope);
462
- const { rounds: nextRounds, record } = enrichRoundWithScoring(rounds, { ...input, interviewId });
493
+ const { rounds: enrichedRounds, record: reportedRecord } = enrichRoundWithScoring(rounds, {
494
+ ...input,
495
+ interviewId,
496
+ });
497
+ // Deterministic floor (Ouroboros principle): the LLM-reported ambiguity is only a
498
+ // lower-bound input. Evidence persisted in state — unresolved disputed facts,
499
+ // unscored active components, auto-answer dilution — sets a code-computed floor
500
+ // the reported score can never fall under, so a pivot mechanically raises the
501
+ // gating score even when the scorer omits triggers.
502
+ const inner = envelope.state as Record<string, unknown>;
503
+ const breakdown = computeAmbiguityFloor({ ...inner, rounds: enrichedRounds });
504
+ const clampResult = clampReportedAmbiguity(input.ambiguity, breakdown.floor);
505
+ let record = reportedRecord;
506
+ let nextRounds = enrichedRounds;
507
+ if (clampResult.clamped) {
508
+ record = {
509
+ ...reportedRecord,
510
+ ambiguity: clampResult.effective,
511
+ reported_ambiguity: input.ambiguity,
512
+ ambiguity_floor: breakdown.floor,
513
+ };
514
+ nextRounds = enrichedRounds.map(item => (item.round_key === record.round_key ? record : item));
515
+ }
463
516
  // Fail closed: a scored transition that violates the bidirectional invariant
464
517
  // (an active trigger that improves the affected dimension or fails to raise
465
518
  // overall ambiguity, or a disputed/unresolved trigger lacking a rationale) must
466
519
  // never be persisted — storing it lets the interview falsely converge. Validate
467
- // against the most recent prior scored round before writing any durable state.
520
+ // the effective (floor-clamped) record against the most recent prior scored round.
468
521
  const prior = latestPriorScoredRound(rounds, record.round_key, record.round);
469
522
  const validation = validateDeepInterviewScoredTransition(prior, record);
470
523
  if (!validation.ok) {
@@ -472,8 +525,9 @@ export async function enrichDeepInterviewRoundScoring(
472
525
  `deep-interview scored transition for round ${record.round} is invalid and was refused: ${validation.violations.join("; ")}`,
473
526
  );
474
527
  }
475
- (envelope.state as Record<string, unknown>).rounds = nextRounds;
476
- (envelope.state as Record<string, unknown>).current_ambiguity = input.ambiguity;
528
+ inner.rounds = nextRounds;
529
+ inner.current_ambiguity = clampResult.effective;
530
+ inner.ambiguity_floor = breakdown;
477
531
  await persistEnvelope(cwd, statePath, envelope, options.sessionId, "gjc deep-interview score-round");
478
532
  await syncRecorderHud(cwd, envelope, options.sessionId);
479
533
  return { record };