@sayknow-cli/coding-agent 0.3.7 → 0.3.8

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 (248) hide show
  1. package/dist/types/append-only-mode.d.ts +31 -0
  2. package/dist/types/capability/rule.d.ts +8 -0
  3. package/dist/types/cli/completion-cli.d.ts +41 -0
  4. package/dist/types/cli/config-cli.d.ts +1 -0
  5. package/dist/types/cli/fixture-report.d.ts +20 -0
  6. package/dist/types/cli/fixture-report.test.d.ts +1 -0
  7. package/dist/types/cli/nofile-limit.d.ts +11 -0
  8. package/dist/types/cli/notify-cli.d.ts +7 -0
  9. package/dist/types/cli/update-cli.d.ts +22 -0
  10. package/dist/types/cli.d.ts +124 -1
  11. package/dist/types/commands/completion.d.ts +29 -0
  12. package/dist/types/commands/config.d.ts +3 -0
  13. package/dist/types/commands/deep-interview.d.ts +3 -0
  14. package/dist/types/config/file-lock.d.ts +7 -6
  15. package/dist/types/config/keybindings.d.ts +2 -2
  16. package/dist/types/config/model-profile-activation.d.ts +6 -0
  17. package/dist/types/config/model-profiles.d.ts +1 -0
  18. package/dist/types/config/settings-schema.d.ts +105 -1
  19. package/dist/types/config/settings.d.ts +8 -0
  20. package/dist/types/discovery/index.d.ts +1 -0
  21. package/dist/types/export/html/template.generated.d.ts +1 -1
  22. package/dist/types/export/ttsr.d.ts +14 -3
  23. package/dist/types/extensibility/extensions/runner.d.ts +2 -1
  24. package/dist/types/extensibility/extensions/types.d.ts +8 -0
  25. package/dist/types/goals/runtime.d.ts +1 -0
  26. package/dist/types/hooks/native-skill-hook.d.ts +12 -0
  27. package/dist/types/main.d.ts +4 -1
  28. package/dist/types/modes/components/assistant-message.d.ts +5 -1
  29. package/dist/types/modes/components/model-selector.d.ts +4 -0
  30. package/dist/types/modes/components/queued-message-selector.d.ts +12 -0
  31. package/dist/types/modes/components/settings-selector.d.ts +2 -0
  32. package/dist/types/modes/components/status-line.d.ts +9 -0
  33. package/dist/types/modes/controllers/command-controller.d.ts +1 -0
  34. package/dist/types/modes/controllers/event-controller.d.ts +5 -0
  35. package/dist/types/modes/controllers/input-controller.d.ts +1 -3
  36. package/dist/types/modes/interactive-mode.d.ts +2 -0
  37. package/dist/types/modes/print-mode.d.ts +13 -1
  38. package/dist/types/modes/prompt-action-autocomplete.d.ts +1 -1
  39. package/dist/types/modes/rpc/rpc-types.d.ts +14 -0
  40. package/dist/types/modes/shared/agent-wire/event-contract.d.ts +19 -0
  41. package/dist/types/modes/shared/agent-wire/event-envelope.d.ts +23 -1
  42. package/dist/types/modes/types.d.ts +1 -0
  43. package/dist/types/modes/utils/ui-helpers.d.ts +1 -0
  44. package/dist/types/notifications/config.d.ts +14 -1
  45. package/dist/types/notifications/daemon-paths.d.ts +1 -0
  46. package/dist/types/notifications/index.d.ts +6 -3
  47. package/dist/types/notifications/lifecycle-commands.d.ts +11 -3
  48. package/dist/types/notifications/telegram-daemon.d.ts +23 -3
  49. package/dist/types/notifications/telegram-reference.d.ts +0 -5
  50. package/dist/types/notifications/threaded-render.d.ts +6 -0
  51. package/dist/types/runtime-api-key.d.ts +7 -0
  52. package/dist/types/sdk.d.ts +3 -0
  53. package/dist/types/session/agent-session.d.ts +61 -6
  54. package/dist/types/session/agent-storage.d.ts +1 -0
  55. package/dist/types/session/file-mention-pruning.d.ts +8 -0
  56. package/dist/types/session/messages.d.ts +4 -0
  57. package/dist/types/session/session-manager.d.ts +17 -1
  58. package/dist/types/skc-runtime/deep-interview-ambiguity.d.ts +39 -0
  59. package/dist/types/skc-runtime/deep-interview-recorder.d.ts +10 -1
  60. package/dist/types/skc-runtime/deep-interview-state.d.ts +17 -2
  61. package/dist/types/skc-runtime/session-state-sidecar.d.ts +7 -0
  62. package/dist/types/skc-runtime/state-writer.d.ts +3 -0
  63. package/dist/types/skc-runtime/ultragoal-receipt-freshness.d.ts +42 -0
  64. package/dist/types/skc-runtime/ultragoal-runtime.d.ts +120 -13
  65. package/dist/types/skill-state/active-state.d.ts +18 -1
  66. package/dist/types/system-prompt.d.ts +18 -2
  67. package/dist/types/task/executor.d.ts +3 -0
  68. package/dist/types/task/fork-context-budget.d.ts +7 -0
  69. package/dist/types/task/token-log.d.ts +30 -0
  70. package/dist/types/task/token-log.test.d.ts +1 -0
  71. package/dist/types/tools/bisect.d.ts +81 -0
  72. package/dist/types/tools/computer.d.ts +2 -2
  73. package/dist/types/tools/index.d.ts +1 -0
  74. package/dist/types/tools/search.d.ts +2 -2
  75. package/dist/types/tools/ultragoal-ask-guard.d.ts +9 -2
  76. package/dist/types/utils/file-mentions.d.ts +14 -0
  77. package/dist/types/utils/git.d.ts +32 -0
  78. package/dist/types/web/scrapers/youtube.d.ts +8 -0
  79. package/dist/types/workflow/workflow-intent-diff.d.ts +8 -2
  80. package/dist/types/workflow/workflow-intent-report-consumer.d.ts +50 -0
  81. package/dist/types/workflow/workflow-intent-report-schema.d.ts +2 -0
  82. package/dist/types/workflow/workflow-intent-report.d.ts +48 -0
  83. package/package.json +8 -8
  84. package/src/append-only-mode.ts +58 -0
  85. package/src/autoresearch/prompt.md +1 -1
  86. package/src/autoresearch/tools/log-experiment.ts +1 -1
  87. package/src/capability/rule.ts +9 -0
  88. package/src/cli/completion-cli.ts +234 -0
  89. package/src/cli/config-cli.ts +61 -9
  90. package/src/cli/fast-help.ts +1 -0
  91. package/src/cli/fixture-report.test.ts +230 -0
  92. package/src/cli/fixture-report.ts +171 -0
  93. package/src/cli/nofile-limit.ts +48 -0
  94. package/src/cli/notify-cli.ts +92 -8
  95. package/src/cli/telegram-cli.ts +1 -1
  96. package/src/cli/update-cli.ts +85 -9
  97. package/src/cli.ts +29 -1
  98. package/src/commands/completion.ts +84 -0
  99. package/src/commands/config.ts +2 -0
  100. package/src/commands/contribution-prep.ts +1 -1
  101. package/src/commands/deep-interview.ts +2 -1
  102. package/src/config/file-lock.ts +73 -34
  103. package/src/config/keybindings.ts +2 -2
  104. package/src/config/model-profile-activation.ts +65 -0
  105. package/src/config/model-profiles.ts +31 -2
  106. package/src/config/settings-schema.ts +68 -1
  107. package/src/config/settings.ts +22 -0
  108. package/src/defaults/skc/skills/team/SKILL.md +17 -14
  109. package/src/defaults/skc/skills/ultragoal/SKILL.md +59 -4
  110. package/src/defaults/skc/skills/ultragoal/pipeline-validation-contracts.md +30 -0
  111. package/src/defaults/skc-defaults.ts +9 -0
  112. package/src/discovery/helpers.ts +10 -0
  113. package/src/discovery/index.ts +1 -0
  114. package/src/export/html/template.generated.ts +1 -1
  115. package/src/export/html/template.js +60 -2
  116. package/src/export/ttsr.ts +96 -18
  117. package/src/extensibility/extensions/runner.ts +183 -219
  118. package/src/extensibility/extensions/types.ts +9 -0
  119. package/src/extensibility/skc-plugins/runtime-adapters.ts +156 -44
  120. package/src/goals/runtime.ts +10 -2
  121. package/src/hindsight/backend.ts +1 -3
  122. package/src/hooks/native-skill-hook.ts +124 -4
  123. package/src/internal-urls/docs-index.generated.ts +14 -5
  124. package/src/main.ts +95 -43
  125. package/src/memories/index.ts +12 -1
  126. package/src/modes/components/assistant-message.ts +241 -82
  127. package/src/modes/components/custom-editor.ts +1 -0
  128. package/src/modes/components/model-selector.ts +54 -11
  129. package/src/modes/components/queued-message-selector.ts +92 -0
  130. package/src/modes/components/settings-selector.ts +4 -0
  131. package/src/modes/components/status-line.ts +167 -54
  132. package/src/modes/components/tool-execution.ts +44 -19
  133. package/src/modes/components/welcome.ts +181 -44
  134. package/src/modes/controllers/command-controller.ts +40 -4
  135. package/src/modes/controllers/event-controller.ts +66 -47
  136. package/src/modes/controllers/extension-ui-controller.ts +4 -10
  137. package/src/modes/controllers/input-controller.ts +180 -18
  138. package/src/modes/controllers/selector-controller.ts +127 -24
  139. package/src/modes/interactive-mode.ts +51 -16
  140. package/src/modes/print-mode.ts +43 -4
  141. package/src/modes/prompt-action-autocomplete.ts +21 -20
  142. package/src/modes/rpc/rpc-mode.ts +29 -3
  143. package/src/modes/rpc/rpc-types.ts +9 -0
  144. package/src/modes/shared/agent-wire/command-validation.ts +4 -0
  145. package/src/modes/shared/agent-wire/event-contract.ts +22 -0
  146. package/src/modes/shared/agent-wire/event-envelope.ts +82 -0
  147. package/src/modes/shared/agent-wire/scopes.ts +1 -0
  148. package/src/modes/types.ts +1 -0
  149. package/src/modes/utils/hotkeys-markdown.ts +1 -0
  150. package/src/modes/utils/ui-helpers.ts +20 -7
  151. package/src/notifications/config.ts +25 -5
  152. package/src/notifications/daemon-paths.ts +2 -0
  153. package/src/notifications/index.ts +117 -14
  154. package/src/notifications/lifecycle-commands.ts +37 -7
  155. package/src/notifications/lifecycle-orchestrator.ts +19 -6
  156. package/src/notifications/telegram-daemon-control.ts +5 -3
  157. package/src/notifications/telegram-daemon.ts +302 -51
  158. package/src/notifications/telegram-reference.ts +0 -16
  159. package/src/notifications/threaded-render.ts +17 -1
  160. package/src/prompts/agents/architect.md +19 -10
  161. package/src/prompts/agents/critic.md +16 -18
  162. package/src/prompts/agents/executor.md +14 -11
  163. package/src/prompts/agents/planner.md +18 -13
  164. package/src/prompts/ci-green-request.md +3 -8
  165. package/src/prompts/goals/goal-continuation.md +0 -5
  166. package/src/prompts/goals/goal-mode-active.md +0 -5
  167. package/src/prompts/system/agent-creation-architect.md +2 -2
  168. package/src/prompts/system/plan-mode-active.md +1 -1
  169. package/src/prompts/system/plan-mode-subagent.md +3 -3
  170. package/src/prompts/system/project-prompt.md +0 -13
  171. package/src/prompts/system/subagent-system-prompt.md +3 -2
  172. package/src/prompts/system/system-prompt.md +29 -17
  173. package/src/prompts/system/volatile-project-context.md +10 -0
  174. package/src/prompts/system/web-search.md +4 -7
  175. package/src/prompts/tools/apply-patch.md +6 -0
  176. package/src/prompts/tools/ast-edit.md +1 -0
  177. package/src/prompts/tools/bash.md +1 -1
  178. package/src/prompts/tools/bisect.md +22 -0
  179. package/src/prompts/tools/browser.md +1 -1
  180. package/src/prompts/tools/computer.md +2 -2
  181. package/src/prompts/tools/cron.md +0 -2
  182. package/src/prompts/tools/eval.md +2 -2
  183. package/src/prompts/tools/find.md +1 -1
  184. package/src/prompts/tools/github.md +6 -6
  185. package/src/prompts/tools/goal.md +1 -3
  186. package/src/prompts/tools/image-gen.md +12 -5
  187. package/src/prompts/tools/irc.md +5 -4
  188. package/src/prompts/tools/job.md +2 -2
  189. package/src/prompts/tools/lsp.md +1 -1
  190. package/src/prompts/tools/monitor.md +2 -2
  191. package/src/prompts/tools/patch.md +2 -2
  192. package/src/prompts/tools/replace.md +2 -21
  193. package/src/prompts/tools/rewind.md +1 -1
  194. package/src/prompts/tools/search-tool-bm25.md +1 -11
  195. package/src/prompts/tools/search.md +1 -1
  196. package/src/prompts/tools/skill.md +3 -2
  197. package/src/prompts/tools/ssh.md +2 -1
  198. package/src/prompts/tools/subagent.md +2 -2
  199. package/src/prompts/tools/task.md +4 -5
  200. package/src/prompts/tools/todo-write.md +1 -1
  201. package/src/prompts/tools/vim.md +1 -0
  202. package/src/prompts/tools/web-search.md +2 -0
  203. package/src/prompts/tools/write.md +8 -2
  204. package/src/runtime-api-key.ts +16 -0
  205. package/src/sdk.ts +52 -28
  206. package/src/session/agent-session.ts +1011 -141
  207. package/src/session/agent-storage.ts +4 -0
  208. package/src/session/file-mention-pruning.ts +64 -0
  209. package/src/session/messages.ts +4 -0
  210. package/src/session/session-manager.ts +103 -4
  211. package/src/skc-runtime/deep-interview-ambiguity.ts +206 -0
  212. package/src/skc-runtime/deep-interview-recorder.ts +62 -8
  213. package/src/skc-runtime/deep-interview-state.ts +44 -2
  214. package/src/skc-runtime/session-state-sidecar.ts +263 -20
  215. package/src/skc-runtime/state-runtime.ts +12 -2
  216. package/src/skc-runtime/state-writer.ts +15 -0
  217. package/src/skc-runtime/ultragoal-guard.ts +55 -66
  218. package/src/skc-runtime/ultragoal-receipt-freshness.ts +315 -0
  219. package/src/skc-runtime/ultragoal-runtime.ts +1663 -159
  220. package/src/skc-runtime/workflow-manifest.generated.json +113 -2
  221. package/src/skc-runtime/workflow-manifest.ts +74 -1
  222. package/src/skill-state/active-state.ts +141 -5
  223. package/src/skill-state/deep-interview-mutation-guard.ts +67 -3
  224. package/src/slash-commands/builtin-registry.ts +218 -71
  225. package/src/system-prompt.ts +57 -16
  226. package/src/task/executor.ts +132 -24
  227. package/src/task/fork-context-advisory.ts +2 -7
  228. package/src/task/fork-context-budget.ts +9 -0
  229. package/src/task/index.ts +13 -6
  230. package/src/task/output-manager.ts +20 -8
  231. package/src/task/receipt.ts +26 -7
  232. package/src/task/token-log.test.ts +188 -0
  233. package/src/task/token-log.ts +179 -0
  234. package/src/tools/bisect.ts +367 -0
  235. package/src/tools/computer.ts +246 -43
  236. package/src/tools/index.ts +3 -0
  237. package/src/tools/output-meta.ts +116 -4
  238. package/src/tools/search.ts +8 -2
  239. package/src/tools/skill.ts +15 -0
  240. package/src/tools/telegram-send.ts +6 -0
  241. package/src/tools/ultragoal-ask-guard.ts +39 -5
  242. package/src/utils/file-mentions.ts +39 -7
  243. package/src/utils/git.ts +73 -0
  244. package/src/web/scrapers/youtube.ts +24 -12
  245. package/src/workflow/workflow-intent-diff.ts +29 -6
  246. package/src/workflow/workflow-intent-report-consumer.ts +221 -0
  247. package/src/workflow/workflow-intent-report-schema.ts +129 -0
  248. package/src/workflow/workflow-intent-report.ts +135 -0
@@ -420,6 +420,10 @@ FROM model_usage_legacy
420
420
  this.#authStore.setCache(key, value, expiresAtSec);
421
421
  }
422
422
 
423
+ deleteCachePrefix(prefix: string): void {
424
+ this.#authStore.deleteCachePrefix?.(prefix);
425
+ }
426
+
423
427
  /**
424
428
  * Deletes expired cache entries. Call periodically for cleanup.
425
429
  */
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Staleness pruning for auto-read file-mention entries.
3
+ *
4
+ * A `@path` mention is superseded once a newer read or mention of the same
5
+ * resolved path appears later in the branch. Stale bodies are replaced with an
6
+ * explicit digest notice (never silently deleted) so context shrinks without
7
+ * losing the reference.
8
+ */
9
+ import type { FileMentionMessage } from "./messages";
10
+ import type { SessionEntry, SessionMessageEntry } from "./session-manager";
11
+
12
+ export interface FileMentionPruneResult {
13
+ changed: SessionMessageEntry[];
14
+ bytesSaved: number;
15
+ }
16
+
17
+ /** Notice injected in place of a superseded file-mention body. */
18
+ export function stalefileMentionNotice(path: string): string {
19
+ return `(pruned: a newer view of \`${path}\` appears later in the conversation; use the read tool to fetch it again)`;
20
+ }
21
+
22
+ export function pruneStaleFileMentions(
23
+ entries: readonly SessionEntry[],
24
+ resolveAbs: (path: string) => string,
25
+ ): FileMentionPruneResult {
26
+ // Latest branch index at which each absolute path was shown (read or mention).
27
+ const latestShownIndex = new Map<string, number>();
28
+ entries.forEach((entry, i) => {
29
+ if (entry.type !== "message") return;
30
+ const msg = entry.message;
31
+ if (msg.role === "fileMention") {
32
+ for (const file of msg.files) {
33
+ if (file.duplicate || file.pruned) continue;
34
+ latestShownIndex.set(resolveAbs(file.path), i);
35
+ }
36
+ } else if (msg.role === "toolResult") {
37
+ const resolved = (msg.details as { resolvedPath?: unknown } | undefined)?.resolvedPath;
38
+ if (typeof resolved === "string" && resolved) latestShownIndex.set(resolveAbs(resolved), i);
39
+ }
40
+ });
41
+
42
+ const changed: SessionMessageEntry[] = [];
43
+ let bytesSaved = 0;
44
+ entries.forEach((entry, i) => {
45
+ if (entry.type !== "message" || entry.message.role !== "fileMention") return;
46
+ const msg = entry.message as FileMentionMessage;
47
+ let mutated = false;
48
+ for (const file of msg.files) {
49
+ if (file.duplicate || file.pruned || !file.content) continue;
50
+ const latest = latestShownIndex.get(resolveAbs(file.path));
51
+ if (latest !== undefined && latest > i) {
52
+ const notice = stalefileMentionNotice(file.path);
53
+ bytesSaved += Math.max(0, Buffer.byteLength(file.content, "utf-8") - Buffer.byteLength(notice, "utf-8"));
54
+ file.content = notice;
55
+ file.pruned = true;
56
+ file.image = undefined;
57
+ file.lineCount = undefined;
58
+ mutated = true;
59
+ }
60
+ }
61
+ if (mutated) changed.push(entry as SessionMessageEntry);
62
+ });
63
+ return { changed, bytesSaved };
64
+ }
@@ -192,6 +192,10 @@ export interface FileMentionMessage {
192
192
  /** Why the file contents were omitted from auto-read. */
193
193
  skippedReason?: "tooLarge";
194
194
  image?: ImageContent;
195
+ /** Set when this mention duplicated a recently shown path (compact note, no full body). */
196
+ duplicate?: boolean;
197
+ /** Set when a stale entry was pruned to a digest notice (never silently deleted). */
198
+ pruned?: boolean;
195
199
  }>;
196
200
  timestamp: number;
197
201
  }
@@ -27,7 +27,9 @@ import {
27
27
  Snowflake,
28
28
  toError,
29
29
  } from "@sayknow-cli/utils";
30
+ import type { TtsrInjectionRecord } from "../export/ttsr";
30
31
  import { writeTextAtomic } from "../skc-runtime/state-writer";
32
+
31
33
  import * as git from "../utils/git";
32
34
  import { ArtifactManager } from "./artifacts";
33
35
  import {
@@ -221,6 +223,10 @@ export interface TtsrInjectionEntry extends SessionEntryBase {
221
223
  type: "ttsr_injection";
222
224
  /** Names of rules that were injected */
223
225
  injectedRules: string[];
226
+ /** Rich rule injection records with repeat state. */
227
+ injectedRuleRecords?: TtsrInjectionRecord[];
228
+ /** TTSR manager message count when this injection was recorded. */
229
+ ttsrMessageCount?: number;
224
230
  }
225
231
 
226
232
  /** Persisted MCP discovery selection state for a session branch. */
@@ -313,6 +319,11 @@ export interface SessionContext {
313
319
  models: Record<string, string>;
314
320
  /** Names of TTSR rules that have been injected this session */
315
321
  injectedTtsrRules: string[];
322
+ /** Rich TTSR rule injection records for repeat resume. */
323
+ injectedTtsrRuleRecords?: TtsrInjectionRecord[];
324
+ /** TTSR manager message count for repeat resume. */
325
+ ttsrMessageCount?: number;
326
+
316
327
  /** MCP tool names selected through discovery for this session branch. */
317
328
  selectedMCPToolNames: string[];
318
329
  /** Whether this branch contains an explicit persisted MCP selection entry. */
@@ -612,6 +623,9 @@ export function buildSessionContext(
612
623
  serviceTier: undefined,
613
624
  models: {},
614
625
  injectedTtsrRules: [],
626
+ injectedTtsrRuleRecords: [],
627
+ ttsrMessageCount: 0,
628
+
615
629
  selectedMCPToolNames: [],
616
630
  hasPersistedMCPToolSelection: false,
617
631
  mode: "none",
@@ -632,6 +646,8 @@ export function buildSessionContext(
632
646
  serviceTier: undefined,
633
647
  models: {},
634
648
  injectedTtsrRules: [],
649
+ injectedTtsrRuleRecords: [],
650
+ ttsrMessageCount: 0,
635
651
  selectedMCPToolNames: [],
636
652
  hasPersistedMCPToolSelection: false,
637
653
  mode: "none",
@@ -656,6 +672,9 @@ export function buildSessionContext(
656
672
  const models: Record<string, string> = {};
657
673
  let compaction: CompactionEntry | null = null;
658
674
  const injectedTtsrRulesSet = new Set<string>();
675
+ const injectedTtsrRuleRecords = new Map<string, TtsrInjectionRecord>();
676
+ let ttsrMessageCount = 0;
677
+
659
678
  let selectedMCPToolNames: string[] = [];
660
679
  let hasPersistedMCPToolSelection = false;
661
680
  let mode = "none";
@@ -695,9 +714,19 @@ export function buildSessionContext(
695
714
  } else if (entry.type === "compaction") {
696
715
  compaction = entry;
697
716
  } else if (entry.type === "ttsr_injection") {
698
- // Collect injected TTSR rule names
717
+ // Collect injected TTSR rule names and richer records when present.
699
718
  for (const ruleName of entry.injectedRules) {
700
719
  injectedTtsrRulesSet.add(ruleName);
720
+ if (!injectedTtsrRuleRecords.has(ruleName)) {
721
+ injectedTtsrRuleRecords.set(ruleName, { name: ruleName, lastInjectedAt: 0 });
722
+ }
723
+ }
724
+ for (const record of entry.injectedRuleRecords ?? []) {
725
+ injectedTtsrRulesSet.add(record.name);
726
+ injectedTtsrRuleRecords.set(record.name, record);
727
+ }
728
+ if (typeof entry.ttsrMessageCount === "number" && Number.isFinite(entry.ttsrMessageCount)) {
729
+ ttsrMessageCount = entry.ttsrMessageCount;
701
730
  }
702
731
  } else if (entry.type === "mcp_tool_selection") {
703
732
  selectedMCPToolNames = [...entry.selectedToolNames];
@@ -709,6 +738,7 @@ export function buildSessionContext(
709
738
  }
710
739
 
711
740
  const injectedTtsrRules = Array.from(injectedTtsrRulesSet);
741
+ const injectedTtsrRuleRecordsArray = Array.from(injectedTtsrRuleRecords.values());
712
742
 
713
743
  // Build messages and collect corresponding entries
714
744
  // When there's a compaction, we need to:
@@ -797,6 +827,9 @@ export function buildSessionContext(
797
827
  serviceTier,
798
828
  models,
799
829
  injectedTtsrRules,
830
+ injectedTtsrRuleRecords: injectedTtsrRuleRecordsArray,
831
+ ttsrMessageCount,
832
+
800
833
  selectedMCPToolNames,
801
834
  hasPersistedMCPToolSelection,
802
835
  mode,
@@ -810,6 +843,9 @@ function cloneSessionContext(context: SessionContext): SessionContext {
810
843
  messages: cloneJsonSemantic(context.messages),
811
844
  models: { ...context.models },
812
845
  injectedTtsrRules: [...context.injectedTtsrRules],
846
+ injectedTtsrRuleRecords: context.injectedTtsrRuleRecords?.map(record => ({ ...record })),
847
+ ttsrMessageCount: context.ttsrMessageCount,
848
+
813
849
  selectedMCPToolNames: [...context.selectedMCPToolNames],
814
850
  modeData: cloneJsonSemantic(context.modeData),
815
851
  };
@@ -1308,6 +1344,45 @@ function isImageBlock(value: unknown): value is { type: "image"; data: string; m
1308
1344
  );
1309
1345
  }
1310
1346
 
1347
+ function stripUndefinedPlainObjectFields(value: unknown, path = "entry"): unknown {
1348
+ if (value === undefined) return undefined;
1349
+ if (value === null || typeof value !== "object") return value;
1350
+ if (Array.isArray(value)) {
1351
+ let changed = false;
1352
+ const result: unknown[] = new Array(value.length);
1353
+ for (let index = 0; index < value.length; index++) {
1354
+ const item = value[index];
1355
+ if (item === undefined) {
1356
+ throw new Error(`Session entry contains undefined array item at ${path}[${index}]`);
1357
+ }
1358
+ const next = stripUndefinedPlainObjectFields(item, `${path}[${index}]`);
1359
+ if (next !== item) changed = true;
1360
+ result[index] = next;
1361
+ }
1362
+ return changed ? result : value;
1363
+ }
1364
+
1365
+ const prototype = Object.getPrototypeOf(value);
1366
+ if (prototype !== Object.prototype && prototype !== null) return value;
1367
+
1368
+ let changed = false;
1369
+ const entries: Array<readonly [string, unknown]> = [];
1370
+ for (const [key, item] of Object.entries(value)) {
1371
+ if (item === undefined) {
1372
+ changed = true;
1373
+ continue;
1374
+ }
1375
+ const next = stripUndefinedPlainObjectFields(item, `${path}.${key}`);
1376
+ if (next !== item) changed = true;
1377
+ entries.push([key, next]);
1378
+ }
1379
+ return changed ? Object.fromEntries(entries) : value;
1380
+ }
1381
+
1382
+ function normalizeSessionEntryForStorage(entry: SessionEntry): SessionEntry {
1383
+ return stripUndefinedPlainObjectFields(entry) as SessionEntry;
1384
+ }
1385
+
1311
1386
  const RESIDENT_EXTERNALIZE_STRING_EXCLUDED_KEYS = new Set([
1312
1387
  "id",
1313
1388
  "type",
@@ -2254,10 +2329,12 @@ class NdjsonFileWriter {
2254
2329
  }
2255
2330
  }
2256
2331
 
2332
+ const DEFAULT_WELCOME_RECENT_SESSION_LIMIT = 20;
2333
+
2257
2334
  /** Get recent sessions for display in welcome screen */
2258
2335
  export async function getRecentSessions(
2259
2336
  sessionDir: string,
2260
- limit = 3,
2337
+ limit = DEFAULT_WELCOME_RECENT_SESSION_LIMIT,
2261
2338
  storage: SessionStorage = new FileSessionStorage(),
2262
2339
  ): Promise<RecentSessionInfo[]> {
2263
2340
  const sessions = await getSortedSessions(sessionDir, storage);
@@ -3734,7 +3811,8 @@ export class SessionManager {
3734
3811
  }
3735
3812
 
3736
3813
  #appendEntry(entry: SessionEntry): void {
3737
- const residentEntry = prepareEntryForResidentSync(entry, this.#residentBlobStores()) as SessionEntry;
3814
+ const normalizedEntry = normalizeSessionEntryForStorage(entry);
3815
+ const residentEntry = prepareEntryForResidentSync(normalizedEntry, this.#residentBlobStores()) as SessionEntry;
3738
3816
  this.#fileEntries.push(residentEntry);
3739
3817
  this.#byId.set(residentEntry.id, residentEntry);
3740
3818
  this.#leafId = residentEntry.id;
@@ -3915,6 +3993,25 @@ export class SessionManager {
3915
3993
  return entry.id;
3916
3994
  }
3917
3995
 
3996
+ /**
3997
+ * Append a root marker that starts a fresh active branch without changing the
3998
+ * session id or deleting earlier durable entries. Subsequent messages descend
3999
+ * from this marker, so provider context is clear while history remains
4000
+ * available for diagnostics/export.
4001
+ */
4002
+ appendContextClearEntry(data?: Record<string, unknown>): string {
4003
+ const entry: CustomEntry = {
4004
+ type: "custom",
4005
+ customType: "context_clear",
4006
+ data,
4007
+ id: generateId(this.#byId),
4008
+ parentId: null,
4009
+ timestamp: new Date().toISOString(),
4010
+ };
4011
+ this.#appendEntry(entry);
4012
+ return entry.id;
4013
+ }
4014
+
3918
4015
  /**
3919
4016
  * Write mutated message entries back into the canonical entry store by id.
3920
4017
  *
@@ -4006,13 +4103,15 @@ export class SessionManager {
4006
4103
  * @param ruleNames Names of rules that were injected
4007
4104
  * @returns Entry id
4008
4105
  */
4009
- appendTtsrInjection(ruleNames: string[]): string {
4106
+ appendTtsrInjection(ruleNames: string[], records?: TtsrInjectionRecord[], ttsrMessageCount?: number): string {
4010
4107
  const entry: TtsrInjectionEntry = {
4011
4108
  type: "ttsr_injection",
4012
4109
  id: generateId(this.#byId),
4013
4110
  parentId: this.#leafId,
4014
4111
  timestamp: new Date().toISOString(),
4015
4112
  injectedRules: ruleNames,
4113
+ injectedRuleRecords: records,
4114
+ ttsrMessageCount,
4016
4115
  };
4017
4116
  this.#appendEntry(entry);
4018
4117
  return entry.id;
@@ -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, "skc 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, "skc deep-interview score-round");
478
532
  await syncRecorderHud(cwd, envelope, options.sessionId);
479
533
  return { record };