@gajae-code/coding-agent 0.8.1 → 0.9.0

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 (253) hide show
  1. package/CHANGELOG.md +63 -0
  2. package/dist/types/append-only-mode.d.ts +31 -0
  3. package/dist/types/capability/rule.d.ts +8 -0
  4. package/dist/types/cli/completion-cli.d.ts +41 -0
  5. package/dist/types/cli/config-cli.d.ts +1 -0
  6. package/dist/types/cli/fixture-report.d.ts +20 -0
  7. package/dist/types/cli/fixture-report.test.d.ts +1 -0
  8. package/dist/types/cli/nofile-limit.d.ts +11 -0
  9. package/dist/types/cli/notify-cli.d.ts +7 -0
  10. package/dist/types/cli/update-cli.d.ts +22 -0
  11. package/dist/types/cli.d.ts +124 -1
  12. package/dist/types/commands/completion.d.ts +29 -0
  13. package/dist/types/commands/config.d.ts +3 -0
  14. package/dist/types/commands/deep-interview.d.ts +3 -0
  15. package/dist/types/config/file-lock.d.ts +7 -6
  16. package/dist/types/config/keybindings.d.ts +2 -2
  17. package/dist/types/config/model-profile-activation.d.ts +6 -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/gjc-runtime/deep-interview-ambiguity.d.ts +39 -0
  26. package/dist/types/gjc-runtime/deep-interview-recorder.d.ts +10 -1
  27. package/dist/types/gjc-runtime/deep-interview-runtime.d.ts +1 -1
  28. package/dist/types/gjc-runtime/deep-interview-state.d.ts +17 -2
  29. package/dist/types/gjc-runtime/session-state-sidecar.d.ts +7 -0
  30. package/dist/types/gjc-runtime/state-writer.d.ts +3 -0
  31. package/dist/types/gjc-runtime/team-runtime.d.ts +28 -1
  32. package/dist/types/gjc-runtime/ultragoal-receipt-freshness.d.ts +42 -0
  33. package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +120 -13
  34. package/dist/types/goals/runtime.d.ts +1 -0
  35. package/dist/types/hooks/native-skill-hook.d.ts +12 -0
  36. package/dist/types/main.d.ts +4 -1
  37. package/dist/types/modes/components/assistant-message.d.ts +5 -1
  38. package/dist/types/modes/components/model-selector.d.ts +4 -0
  39. package/dist/types/modes/components/queued-message-selector.d.ts +12 -0
  40. package/dist/types/modes/components/settings-selector.d.ts +2 -0
  41. package/dist/types/modes/components/status-line.d.ts +9 -0
  42. package/dist/types/modes/controllers/command-controller.d.ts +1 -0
  43. package/dist/types/modes/controllers/event-controller.d.ts +5 -0
  44. package/dist/types/modes/controllers/input-controller.d.ts +1 -3
  45. package/dist/types/modes/interactive-mode.d.ts +2 -0
  46. package/dist/types/modes/print-mode.d.ts +13 -1
  47. package/dist/types/modes/prompt-action-autocomplete.d.ts +1 -1
  48. package/dist/types/modes/rpc/rpc-types.d.ts +14 -0
  49. package/dist/types/modes/shared/agent-wire/event-contract.d.ts +19 -0
  50. package/dist/types/modes/shared/agent-wire/event-envelope.d.ts +23 -1
  51. package/dist/types/modes/types.d.ts +1 -0
  52. package/dist/types/modes/utils/ui-helpers.d.ts +1 -0
  53. package/dist/types/notifications/config.d.ts +14 -1
  54. package/dist/types/notifications/daemon-paths.d.ts +1 -0
  55. package/dist/types/notifications/index.d.ts +6 -3
  56. package/dist/types/notifications/lifecycle-commands.d.ts +11 -3
  57. package/dist/types/notifications/telegram-daemon.d.ts +23 -3
  58. package/dist/types/notifications/telegram-reference.d.ts +0 -5
  59. package/dist/types/notifications/threaded-render.d.ts +6 -0
  60. package/dist/types/runtime-api-key.d.ts +7 -0
  61. package/dist/types/sdk.d.ts +3 -0
  62. package/dist/types/session/agent-session.d.ts +53 -1
  63. package/dist/types/session/agent-storage.d.ts +1 -0
  64. package/dist/types/session/file-mention-pruning.d.ts +8 -0
  65. package/dist/types/session/messages.d.ts +4 -0
  66. package/dist/types/session/session-manager.d.ts +17 -1
  67. package/dist/types/skill-state/active-state.d.ts +18 -1
  68. package/dist/types/system-prompt.d.ts +18 -2
  69. package/dist/types/task/executor.d.ts +3 -0
  70. package/dist/types/task/fork-context-budget.d.ts +7 -0
  71. package/dist/types/task/token-log.d.ts +30 -0
  72. package/dist/types/task/token-log.test.d.ts +1 -0
  73. package/dist/types/tools/bisect.d.ts +81 -0
  74. package/dist/types/tools/computer.d.ts +2 -2
  75. package/dist/types/tools/index.d.ts +1 -0
  76. package/dist/types/tools/search.d.ts +2 -2
  77. package/dist/types/utils/file-mentions.d.ts +14 -0
  78. package/dist/types/utils/git.d.ts +32 -0
  79. package/dist/types/web/scrapers/youtube.d.ts +8 -0
  80. package/dist/types/workflow/workflow-intent-diff.d.ts +8 -2
  81. package/dist/types/workflow/workflow-intent-report-consumer.d.ts +50 -0
  82. package/dist/types/workflow/workflow-intent-report-schema.d.ts +2 -0
  83. package/dist/types/workflow/workflow-intent-report.d.ts +48 -0
  84. package/package.json +7 -7
  85. package/src/append-only-mode.ts +58 -0
  86. package/src/autoresearch/prompt.md +1 -1
  87. package/src/autoresearch/tools/log-experiment.ts +1 -1
  88. package/src/capability/rule.ts +9 -0
  89. package/src/cli/completion-cli.ts +234 -0
  90. package/src/cli/config-cli.ts +61 -9
  91. package/src/cli/fast-help.ts +1 -0
  92. package/src/cli/fixture-report.test.ts +230 -0
  93. package/src/cli/fixture-report.ts +171 -0
  94. package/src/cli/nofile-limit.ts +48 -0
  95. package/src/cli/notify-cli.ts +91 -7
  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-registry.ts +14 -2
  106. package/src/config/settings-schema.ts +68 -1
  107. package/src/config/settings.ts +22 -0
  108. package/src/coordinator-mcp/server.ts +34 -8
  109. package/src/defaults/gjc/skills/deep-interview/SKILL.md +50 -18
  110. package/src/defaults/gjc/skills/ralplan/SKILL.md +40 -99
  111. package/src/defaults/gjc/skills/team/SKILL.md +17 -14
  112. package/src/defaults/gjc/skills/ultragoal/SKILL.md +59 -4
  113. package/src/defaults/gjc/skills/ultragoal/pipeline-validation-contracts.md +30 -0
  114. package/src/defaults/gjc-defaults.ts +9 -0
  115. package/src/discovery/helpers.ts +10 -0
  116. package/src/discovery/index.ts +1 -0
  117. package/src/export/html/template.generated.ts +1 -1
  118. package/src/export/html/template.js +60 -2
  119. package/src/export/ttsr.ts +96 -18
  120. package/src/extensibility/extensions/runner.ts +183 -219
  121. package/src/extensibility/extensions/types.ts +9 -0
  122. package/src/extensibility/gjc-plugins/runtime-adapters.ts +156 -44
  123. package/src/gjc-runtime/deep-interview-ambiguity.ts +206 -0
  124. package/src/gjc-runtime/deep-interview-recorder.ts +62 -8
  125. package/src/gjc-runtime/deep-interview-runtime.ts +210 -2
  126. package/src/gjc-runtime/deep-interview-state.ts +44 -2
  127. package/src/gjc-runtime/session-state-sidecar.ts +263 -20
  128. package/src/gjc-runtime/state-runtime.ts +12 -2
  129. package/src/gjc-runtime/state-writer.ts +15 -0
  130. package/src/gjc-runtime/team-runtime.ts +187 -15
  131. package/src/gjc-runtime/ultragoal-guard.ts +55 -66
  132. package/src/gjc-runtime/ultragoal-receipt-freshness.ts +315 -0
  133. package/src/gjc-runtime/ultragoal-runtime.ts +1663 -159
  134. package/src/gjc-runtime/workflow-manifest.generated.json +113 -2
  135. package/src/gjc-runtime/workflow-manifest.ts +74 -1
  136. package/src/goals/runtime.ts +10 -2
  137. package/src/hindsight/backend.ts +1 -3
  138. package/src/hooks/native-skill-hook.ts +124 -4
  139. package/src/internal-urls/docs-index.generated.ts +16 -7
  140. package/src/main.ts +95 -43
  141. package/src/memories/index.ts +12 -1
  142. package/src/modes/components/assistant-message.ts +241 -82
  143. package/src/modes/components/custom-editor.ts +1 -0
  144. package/src/modes/components/model-selector.ts +54 -11
  145. package/src/modes/components/queued-message-selector.ts +92 -0
  146. package/src/modes/components/settings-selector.ts +4 -0
  147. package/src/modes/components/status-line.ts +167 -54
  148. package/src/modes/components/tool-execution.ts +44 -19
  149. package/src/modes/components/welcome.ts +160 -44
  150. package/src/modes/controllers/command-controller.ts +40 -4
  151. package/src/modes/controllers/event-controller.ts +66 -47
  152. package/src/modes/controllers/extension-ui-controller.ts +4 -10
  153. package/src/modes/controllers/input-controller.ts +180 -18
  154. package/src/modes/controllers/selector-controller.ts +122 -27
  155. package/src/modes/interactive-mode.ts +51 -16
  156. package/src/modes/print-mode.ts +43 -4
  157. package/src/modes/prompt-action-autocomplete.ts +21 -20
  158. package/src/modes/rpc/rpc-mode.ts +29 -3
  159. package/src/modes/rpc/rpc-types.ts +9 -0
  160. package/src/modes/shared/agent-wire/command-validation.ts +4 -0
  161. package/src/modes/shared/agent-wire/event-contract.ts +22 -0
  162. package/src/modes/shared/agent-wire/event-envelope.ts +82 -0
  163. package/src/modes/shared/agent-wire/scopes.ts +1 -0
  164. package/src/modes/types.ts +1 -0
  165. package/src/modes/utils/hotkeys-markdown.ts +1 -0
  166. package/src/modes/utils/ui-helpers.ts +19 -6
  167. package/src/notifications/config.ts +25 -5
  168. package/src/notifications/daemon-paths.ts +2 -0
  169. package/src/notifications/index.ts +117 -14
  170. package/src/notifications/lifecycle-commands.ts +37 -7
  171. package/src/notifications/lifecycle-orchestrator.ts +19 -6
  172. package/src/notifications/telegram-daemon-cli.ts +2 -2
  173. package/src/notifications/telegram-daemon-control.ts +5 -3
  174. package/src/notifications/telegram-daemon.ts +302 -51
  175. package/src/notifications/telegram-reference.ts +0 -16
  176. package/src/notifications/threaded-render.ts +17 -1
  177. package/src/prompts/agents/architect.md +19 -10
  178. package/src/prompts/agents/critic.md +16 -18
  179. package/src/prompts/agents/executor.md +14 -11
  180. package/src/prompts/agents/planner.md +18 -13
  181. package/src/prompts/ci-green-request.md +3 -8
  182. package/src/prompts/goals/goal-continuation.md +0 -5
  183. package/src/prompts/goals/goal-mode-active.md +0 -5
  184. package/src/prompts/system/agent-creation-architect.md +2 -2
  185. package/src/prompts/system/plan-mode-active.md +1 -1
  186. package/src/prompts/system/plan-mode-subagent.md +3 -3
  187. package/src/prompts/system/project-prompt.md +0 -13
  188. package/src/prompts/system/subagent-system-prompt.md +3 -2
  189. package/src/prompts/system/system-prompt.md +29 -19
  190. package/src/prompts/system/volatile-project-context.md +10 -0
  191. package/src/prompts/system/web-search.md +4 -7
  192. package/src/prompts/tools/apply-patch.md +6 -0
  193. package/src/prompts/tools/ast-edit.md +1 -0
  194. package/src/prompts/tools/bash.md +1 -1
  195. package/src/prompts/tools/bisect.md +22 -0
  196. package/src/prompts/tools/browser.md +1 -1
  197. package/src/prompts/tools/computer.md +2 -2
  198. package/src/prompts/tools/cron.md +0 -2
  199. package/src/prompts/tools/eval.md +2 -2
  200. package/src/prompts/tools/find.md +1 -1
  201. package/src/prompts/tools/github.md +6 -6
  202. package/src/prompts/tools/goal.md +1 -3
  203. package/src/prompts/tools/image-gen.md +12 -5
  204. package/src/prompts/tools/irc.md +5 -4
  205. package/src/prompts/tools/job.md +2 -2
  206. package/src/prompts/tools/lsp.md +1 -1
  207. package/src/prompts/tools/monitor.md +2 -2
  208. package/src/prompts/tools/patch.md +2 -2
  209. package/src/prompts/tools/replace.md +2 -21
  210. package/src/prompts/tools/rewind.md +1 -1
  211. package/src/prompts/tools/search-tool-bm25.md +1 -11
  212. package/src/prompts/tools/search.md +1 -1
  213. package/src/prompts/tools/skill.md +3 -2
  214. package/src/prompts/tools/ssh.md +2 -1
  215. package/src/prompts/tools/subagent.md +2 -2
  216. package/src/prompts/tools/task.md +4 -5
  217. package/src/prompts/tools/todo-write.md +1 -1
  218. package/src/prompts/tools/vim.md +1 -0
  219. package/src/prompts/tools/web-search.md +2 -0
  220. package/src/prompts/tools/write.md +8 -2
  221. package/src/runtime-api-key.ts +16 -0
  222. package/src/sdk.ts +52 -28
  223. package/src/session/agent-session.ts +951 -132
  224. package/src/session/agent-storage.ts +4 -0
  225. package/src/session/file-mention-pruning.ts +64 -0
  226. package/src/session/messages.ts +4 -0
  227. package/src/session/session-manager.ts +103 -4
  228. package/src/skill-state/active-state.ts +141 -5
  229. package/src/skill-state/deep-interview-mutation-guard.ts +67 -3
  230. package/src/slash-commands/builtin-registry.ts +218 -71
  231. package/src/system-prompt.ts +57 -16
  232. package/src/task/executor.ts +132 -24
  233. package/src/task/fork-context-advisory.ts +2 -7
  234. package/src/task/fork-context-budget.ts +9 -0
  235. package/src/task/index.ts +13 -6
  236. package/src/task/output-manager.ts +20 -8
  237. package/src/task/receipt.ts +26 -7
  238. package/src/task/token-log.test.ts +188 -0
  239. package/src/task/token-log.ts +179 -0
  240. package/src/tools/bisect.ts +367 -0
  241. package/src/tools/computer.ts +246 -43
  242. package/src/tools/index.ts +3 -0
  243. package/src/tools/output-meta.ts +116 -4
  244. package/src/tools/search.ts +8 -2
  245. package/src/tools/skill.ts +15 -0
  246. package/src/tools/telegram-send.ts +6 -0
  247. package/src/utils/file-mentions.ts +39 -7
  248. package/src/utils/git.ts +73 -0
  249. package/src/web/scrapers/youtube.ts +24 -12
  250. package/src/workflow/workflow-intent-diff.ts +29 -6
  251. package/src/workflow/workflow-intent-report-consumer.ts +221 -0
  252. package/src/workflow/workflow-intent-report-schema.ts +129 -0
  253. package/src/workflow/workflow-intent-report.ts +135 -0
@@ -1,14 +1,161 @@
1
+ import { createHash } from "node:crypto";
2
+ import * as fs from "node:fs/promises";
1
3
  import * as path from "node:path";
2
4
  import { loadCustomTools } from "../custom-tools/loader";
3
5
  import type { CustomTool } from "../custom-tools/types";
4
- import { loadEffectiveGjcPluginRegistry } from "./registry";
5
- import { type SessionQuarantine, validateSessionBundles, verifyEntryHashes } from "./session-validation";
6
+ import { loadEffectiveGjcPluginRegistry, registryPathForScope } from "./registry";
7
+ import { type SessionQuarantine, type SessionValidationResult, validateSessionBundles } from "./session-validation";
8
+ import type { GjcPluginRegistryEntry, GjcPluginScope } from "./types";
6
9
 
7
10
  export interface AlwaysOnPluginTools {
8
11
  tools: CustomTool[];
9
12
  quarantine: SessionQuarantine[];
10
13
  }
11
14
 
15
+ interface FileSnapshot {
16
+ path: string;
17
+ mtimeMs: number;
18
+ ctimeMs: number;
19
+ size: number;
20
+ ino: number;
21
+ }
22
+
23
+ interface ValidatedPluginRegistry {
24
+ effective: GjcPluginRegistryEntry[];
25
+ active: GjcPluginRegistryEntry[];
26
+ quarantine: SessionQuarantine[];
27
+ validation: SessionValidationResult;
28
+ registryFiles: FileSnapshot[];
29
+ pluginFiles: FileSnapshot[];
30
+ }
31
+
32
+ interface CachedValidatedPluginRegistry extends ValidatedPluginRegistry {
33
+ registryKey: string;
34
+ pluginKey: string;
35
+ }
36
+
37
+ const validatedRegistryCache = new Map<string, CachedValidatedPluginRegistry>();
38
+ const hashCache = new Map<string, string>();
39
+ // Bound the digest memo so long sessions with plugin churn cannot grow it
40
+ // unboundedly; entries are re-derivable from disk at the cost of one read.
41
+ const HASH_CACHE_MAX_ENTRIES = 512;
42
+ const registryScopes: GjcPluginScope[] = ["user", "project"];
43
+
44
+ async function snapshotExistingFile(filePath: string): Promise<FileSnapshot | null> {
45
+ try {
46
+ const stat = await fs.stat(filePath);
47
+ return { path: filePath, mtimeMs: stat.mtimeMs, ctimeMs: stat.ctimeMs, size: stat.size, ino: stat.ino };
48
+ } catch (error) {
49
+ if ((error as NodeJS.ErrnoException)?.code === "ENOENT") return null;
50
+ throw error;
51
+ }
52
+ }
53
+
54
+ function snapshotsKey(snapshots: readonly FileSnapshot[]): string {
55
+ return snapshots.map(s => `${s.path}:${s.mtimeMs}:${s.ctimeMs}:${s.size}:${s.ino}`).join("|");
56
+ }
57
+
58
+ async function snapshotRegistryFiles(cwd: string): Promise<FileSnapshot[]> {
59
+ const snapshots = await Promise.all(
60
+ registryScopes.map(scope => snapshotExistingFile(registryPathForScope(scope, cwd))),
61
+ );
62
+ return snapshots.filter((s): s is FileSnapshot => s !== null);
63
+ }
64
+
65
+ async function snapshotPluginFiles(entries: readonly GjcPluginRegistryEntry[]): Promise<FileSnapshot[]> {
66
+ const snapshots: FileSnapshot[] = [];
67
+ for (const entry of entries) {
68
+ if (!entry.enabled) continue;
69
+ for (const file of entry.copiedFiles) {
70
+ const abs = path.join(entry.pluginRoot, file.relativePath);
71
+ const snapshot = await snapshotExistingFile(abs);
72
+ if (!snapshot) {
73
+ snapshots.push({ path: abs, mtimeMs: Number.NaN, ctimeMs: Number.NaN, size: Number.NaN, ino: Number.NaN });
74
+ } else {
75
+ snapshots.push(snapshot);
76
+ }
77
+ }
78
+ }
79
+ return snapshots;
80
+ }
81
+
82
+ function sha256(buf: Buffer): string {
83
+ return createHash("sha256").update(buf).digest("hex");
84
+ }
85
+
86
+ async function hashFile(snapshot: FileSnapshot): Promise<string> {
87
+ const key = `${snapshot.path}:${snapshot.mtimeMs}:${snapshot.ctimeMs}:${snapshot.size}:${snapshot.ino}`;
88
+ const cached = hashCache.get(key);
89
+ if (cached) return cached;
90
+ const digest = sha256(await fs.readFile(snapshot.path));
91
+ if (hashCache.size >= HASH_CACHE_MAX_ENTRIES) {
92
+ // FIFO eviction is sufficient: the memo only avoids re-reads within a
93
+ // session; correctness never depends on a hit.
94
+ const oldest = hashCache.keys().next().value;
95
+ if (oldest !== undefined) hashCache.delete(oldest);
96
+ }
97
+ hashCache.set(key, digest);
98
+ return digest;
99
+ }
100
+
101
+ async function verifyEntryHashesCached(entry: GjcPluginRegistryEntry): Promise<SessionQuarantine | null> {
102
+ for (const file of entry.copiedFiles) {
103
+ const abs = path.join(entry.pluginRoot, file.relativePath);
104
+ const snapshot = await snapshotExistingFile(abs);
105
+ if (!snapshot) {
106
+ return {
107
+ plugin: entry.name,
108
+ surfaceId: `plugin:${entry.name}`,
109
+ code: "runtime_mismatch",
110
+ message: `Installed file missing: ${file.relativePath}`,
111
+ };
112
+ }
113
+ if ((await hashFile(snapshot)) !== file.sha256) {
114
+ return {
115
+ plugin: entry.name,
116
+ surfaceId: `plugin:${entry.name}`,
117
+ code: "runtime_mismatch",
118
+ message: `Installed file hash drift: ${file.relativePath}`,
119
+ };
120
+ }
121
+ }
122
+ return null;
123
+ }
124
+
125
+ async function loadValidatedPluginRegistry(cwd: string): Promise<ValidatedPluginRegistry> {
126
+ const registryFiles = await snapshotRegistryFiles(cwd);
127
+ const registryKey = snapshotsKey(registryFiles);
128
+ const cached = validatedRegistryCache.get(cwd);
129
+ if (cached && cached.registryKey === registryKey) {
130
+ const pluginFiles = await snapshotPluginFiles(cached.effective);
131
+ const pluginKey = snapshotsKey(pluginFiles);
132
+ if (cached.pluginKey === pluginKey) return cached;
133
+ }
134
+
135
+ const effective = await loadEffectiveGjcPluginRegistry(cwd);
136
+ const currentRegistryFiles = await snapshotRegistryFiles(cwd);
137
+ const preQuarantine: SessionQuarantine[] = [];
138
+ for (const entry of effective) {
139
+ if (!entry.enabled) continue;
140
+ const drift = await verifyEntryHashesCached(entry);
141
+ if (drift) preQuarantine.push(drift);
142
+ }
143
+ const validation = validateSessionBundles(effective, {}, preQuarantine);
144
+ const pluginFiles = await snapshotPluginFiles(effective);
145
+ const next: CachedValidatedPluginRegistry = {
146
+ effective,
147
+ active: validation.active,
148
+ quarantine: validation.quarantine,
149
+ validation,
150
+ registryFiles: currentRegistryFiles,
151
+ pluginFiles,
152
+ registryKey: snapshotsKey(currentRegistryFiles),
153
+ pluginKey: snapshotsKey(pluginFiles),
154
+ };
155
+ validatedRegistryCache.set(cwd, next);
156
+ return next;
157
+ }
158
+
12
159
  /**
13
160
  * Load the always-on plugin tool surfaces for the effective registry at `cwd`.
14
161
  *
@@ -26,22 +173,15 @@ export async function loadAlwaysOnPluginTools(input: {
26
173
  cwd: string;
27
174
  reservedToolNames: string[];
28
175
  }): Promise<AlwaysOnPluginTools> {
29
- const effective = await loadEffectiveGjcPluginRegistry(input.cwd);
176
+ const validated = await loadValidatedPluginRegistry(input.cwd);
177
+ const { effective } = validated;
30
178
  if (effective.length === 0) return { tools: [], quarantine: [] };
31
179
 
32
- // Hash-drift quarantine before importing any plugin code.
33
- const preQuarantine: SessionQuarantine[] = [];
34
- for (const entry of effective) {
35
- if (!entry.enabled) continue;
36
- const drift = await verifyEntryHashes(entry);
37
- if (drift) preQuarantine.push(drift);
38
- }
39
-
40
180
  const reserved = new Set(input.reservedToolNames);
41
181
  const { active, quarantine } = validateSessionBundles(
42
182
  effective,
43
183
  { toolNames: input.reservedToolNames },
44
- preQuarantine,
184
+ validated.quarantine,
45
185
  );
46
186
 
47
187
  // Map declared (path -> name) for every active always-on tool surface.
@@ -109,15 +249,8 @@ export async function loadAlwaysOnPluginTools(input: {
109
249
  * plugins are installed/enabled. Safe to call unconditionally at session start.
110
250
  */
111
251
  export async function renderAlwaysOnSystemAppendices(input: { cwd: string }): Promise<string> {
112
- const effective = await loadEffectiveGjcPluginRegistry(input.cwd);
252
+ const { effective, active } = await loadValidatedPluginRegistry(input.cwd);
113
253
  if (effective.length === 0) return "";
114
- const preQuarantine: SessionQuarantine[] = [];
115
- for (const entry of effective) {
116
- if (!entry.enabled) continue;
117
- const drift = await verifyEntryHashes(entry);
118
- if (drift) preQuarantine.push(drift);
119
- }
120
- const { active } = validateSessionBundles(effective, {}, preQuarantine);
121
254
  const { renderPluginAppendices } = await import("./prompt-appendix");
122
255
  return (await renderPluginAppendices(active)).system;
123
256
  }
@@ -131,15 +264,8 @@ export async function renderAgentPromptAdditions(input: {
131
264
  cwd: string;
132
265
  agentName: string;
133
266
  }): Promise<{ appendix: string; advertisement: string }> {
134
- const effective = await loadEffectiveGjcPluginRegistry(input.cwd);
267
+ const { effective, active } = await loadValidatedPluginRegistry(input.cwd);
135
268
  if (effective.length === 0) return { appendix: "", advertisement: "" };
136
- const preQuarantine: SessionQuarantine[] = [];
137
- for (const entry of effective) {
138
- if (!entry.enabled) continue;
139
- const drift = await verifyEntryHashes(entry);
140
- if (drift) preQuarantine.push(drift);
141
- }
142
- const { active } = validateSessionBundles(effective, {}, preQuarantine);
143
269
  const { renderPluginAppendices } = await import("./prompt-appendix");
144
270
  const { buildAgentSubskillAdvertisement } = await import("./injection");
145
271
  const rendered = await renderPluginAppendices(active);
@@ -158,15 +284,8 @@ export async function renderSkillAdvertisement(input: {
158
284
  skillName: string;
159
285
  phase?: string;
160
286
  }): Promise<string> {
161
- const effective = await loadEffectiveGjcPluginRegistry(input.cwd);
287
+ const { effective, active } = await loadValidatedPluginRegistry(input.cwd);
162
288
  if (effective.length === 0) return "";
163
- const preQuarantine: SessionQuarantine[] = [];
164
- for (const entry of effective) {
165
- if (!entry.enabled) continue;
166
- const drift = await verifyEntryHashes(entry);
167
- if (drift) preQuarantine.push(drift);
168
- }
169
- const { active } = validateSessionBundles(effective, {}, preQuarantine);
170
289
  const { buildSubskillAdvertisement } = await import("./injection");
171
290
  return buildSubskillAdvertisement(active, input.skillName, input.phase);
172
291
  }
@@ -181,15 +300,8 @@ export async function buildPluginMcpConfigs(input: { cwd: string }): Promise<{
181
300
  configs: Record<string, any>;
182
301
  quarantine: SessionQuarantine[];
183
302
  }> {
184
- const effective = await loadEffectiveGjcPluginRegistry(input.cwd);
303
+ const { effective, active, quarantine } = await loadValidatedPluginRegistry(input.cwd);
185
304
  if (effective.length === 0) return { configs: {}, quarantine: [] };
186
- const preQuarantine: SessionQuarantine[] = [];
187
- for (const entry of effective) {
188
- if (!entry.enabled) continue;
189
- const drift = await verifyEntryHashes(entry);
190
- if (drift) preQuarantine.push(drift);
191
- }
192
- const { active, quarantine } = validateSessionBundles(effective, {}, preQuarantine);
193
305
  const { assertMcpInstallPolicy, assertDnsResolvesPublic, assertUrlAllowed } = await import("./mcp-policy");
194
306
  const nodePath = await import("node:path");
195
307
 
@@ -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 };