kibi-mcp 0.15.1 → 0.16.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.
@@ -37,7 +37,7 @@ let diagnosticUsageLogPath = null;
37
37
  export function initializeDiagnosticMode(enabled = DIAGNOSTIC_MODE_ENABLED) {
38
38
  diagnosticUsageLogPath = null;
39
39
  if (!enabled) {
40
- Reflect.deleteProperty(process.env, "KIBI_MCP_DIAGNOSTIC_MODE");
40
+ process.env.KIBI_MCP_DIAGNOSTIC_MODE = "0";
41
41
  return;
42
42
  }
43
43
  const workspaceRoot = resolveWorkspaceRoot();
@@ -89,6 +89,57 @@ export const DIAGNOSTIC_TELEMETRY_SCHEMA = {
89
89
  },
90
90
  };
91
91
  // implements REQ-002
92
+ export function classifyDiagnosticError(error) {
93
+ const err = error instanceof Error ? error : new Error(String(error));
94
+ const message = err.message;
95
+ const lower = message.toLowerCase();
96
+ if (lower.includes("stale_snapshot")) {
97
+ return buildErrorFields(err, "stale_snapshot", "persistence", "KB snapshot is stale; refresh/retry after the latest KB state is attached.");
98
+ }
99
+ if (lower.includes("unknown option") && lower.includes("h for help")) {
100
+ return buildErrorFields(err, "prolog_unknown_option", "prolog_runtime", "Prolog rejected startup/module/query options; inspect MCP package wiring and Prolog invocation.");
101
+ }
102
+ if (lower.includes("prolog process not started")) {
103
+ return buildErrorFields(err, "prolog_process_not_started", "prolog_lifecycle", "Prolog process is unavailable; restart the MCP server before retrying.");
104
+ }
105
+ if (lower.includes("resetting prolog worker") ||
106
+ lower.includes("prolog worker reset")) {
107
+ return buildErrorFields(err, "prolog_worker_reset", "prolog_lifecycle", "Prolog worker was reset so the next MCP call can start from a fresh worker.");
108
+ }
109
+ if (/timed out after \d+ms/i.test(message) ||
110
+ lower.includes("tool timeout")) {
111
+ return buildErrorFields(err, "tool_timeout", "tool_timeout", "MCP tool execution exceeded its bounded timeout.");
112
+ }
113
+ if (lower.includes("coarsely while granular symbols are available")) {
114
+ return buildErrorFields(err, "coarse_symbol_linkage", "validation", "Symbol traceability targeted a coarse file/module while narrower exported symbols exist.");
115
+ }
116
+ if (message.startsWith("Entity validation failed:")) {
117
+ return buildErrorFields(err, "entity_validation_failed", "validation", "Entity payload failed schema validation.");
118
+ }
119
+ if (message.startsWith("Relationship validation failed")) {
120
+ return buildErrorFields(err, "relationship_validation_failed", "validation", "Relationship payload failed schema validation.");
121
+ }
122
+ if (message.startsWith("Relationship source must match the upserted entity")) {
123
+ return buildErrorFields(err, "relationship_source_mismatch", "validation", "Relationship source did not match the entity being upserted.");
124
+ }
125
+ if (lower.includes("module load failed")) {
126
+ return buildErrorFields(err, "prolog_module_load_failed", "prolog_runtime", "Prolog failed to load an execution module.");
127
+ }
128
+ if (lower.includes("query failed")) {
129
+ return buildErrorFields(err, "prolog_query_failed", "prolog_runtime", "Prolog query execution failed.");
130
+ }
131
+ return buildErrorFields(err, "handler_error", "handler", "Unhandled MCP handler error.");
132
+ }
133
+ function buildErrorFields(error, category, stage, summary) {
134
+ return {
135
+ error_name: error.name,
136
+ error_message: error.message,
137
+ error_category: category,
138
+ error_stage: stage,
139
+ error_summary: summary,
140
+ };
141
+ }
142
+ // implements REQ-002
92
143
  export function deriveDiagnosticFields(toolName, args, telemetry, result) {
93
144
  const fields = {
94
145
  telemetry_status: telemetry ? "provided" : "missing",
@@ -38,6 +38,7 @@ function renderToolsDoc() {
38
38
  lines.push("");
39
39
  lines.push("Modeling note: Kibi has eight core entity types grouped into common authoring (req, scenario, test, fact) and supporting/system (adr, flag, event, symbol).");
40
40
  lines.push("Only strict domain facts (`fact_kind: subject` + `property_value`) participate in contradiction inference; use `flag` for runtime/config gates and `fact_kind: observation` or `meta` for bug/workaround notes.");
41
+ lines.push("Predicate flow: before writing ontology prose, call `kb_suggest_predicates`; apply a suggested `fact_kind: predicate` via `requires_predicate`, or record the returned `review:ontology-gap` observation when no predicate fits.");
41
42
  return lines.join("\n");
42
43
  }
43
44
  export const PROMPTS = [
@@ -48,7 +49,7 @@ export const PROMPTS = [
48
49
  text: [
49
50
  "# Kibi Interactive Activation Workflow",
50
51
  "",
51
- "Use this workflow to onboard a new or empty repository into Kibi through interactive discovery.",
52
+ "Use this post-hoc workflow to onboard a new or empty repository into Kibi through interactive discovery.",
52
53
  "",
53
54
  "## Step 1: Gather Declared Context",
54
55
  "",
@@ -63,27 +64,28 @@ export const PROMPTS = [
63
64
  "Call `kb_autopilot_generate` with the gathered context to synthesize candidate entities.",
64
65
  "",
65
66
  "This tool is **read-only**. It returns additive `structuredContent` with:",
66
- "- `promptBlock`: review text that can be surfaced for optional human review",
67
+ "- `promptBlock`: review text that must be surfaced before writes",
67
68
  "- `recommendedActions`: agent-facing next steps, including any REQ/SCEN/TEST authoring routed for manual handling",
68
69
  "- `declaredContext`: the user-provided bootstrap context",
69
70
  "- `confidence`: confidence summary for the generated output",
70
71
  "- `bootstrapMode`: current KB state (e.g., `root_uninitialized`)",
71
72
  "- `candidates`: synthesized entities grounded in declared context and source evidence",
73
+ "- `applyPlan`: exact sequential `kb_upsert` payloads for approved candidates",
72
74
  "- `discoverySummary`: source-backed discovery notes",
73
75
  "",
74
- "## Step 3: Optional Human Review",
76
+ "## Step 3: Preview and Approval",
75
77
  "",
76
- "Surface the `promptBlock` and a summary of `candidates` when optional human review is useful. Human review is post-hoc/optional and must not block writes.",
78
+ "Surface the `promptBlock`, a summary of `candidates`, and the exact `structuredContent.applyPlan` payloads. Wait for explicit user approval before proceeding to writes.",
77
79
  "",
78
80
  "## Step 4: Apply Candidates",
79
81
  "",
80
82
  "Apply candidates sequentially using `kb_upsert`.",
81
- "1. Execute each candidate's `applyPlan` in ascending phase order.",
83
+ "1. Execute `structuredContent.applyPlan` sequentially in listed order.",
82
84
  "2. Confirm success of each `kb_upsert` before moving to the next.",
83
85
  "3. Run `kb_check` after the batch to verify KB integrity.",
84
86
  "",
85
87
  "## Rules",
86
- "- Human review is optional and post-hoc; do not gate writes on synchronous sign-off.",
88
+ "- Never apply bootstrap writes without user-facing preview and explicit approval.",
87
89
  "- `kb_autopilot_generate` is strictly read-only; synthesis is the backend, not the actor.",
88
90
  "- Guidance must stay MCP-only; do not suggest `kibi` CLI commands.",
89
91
  ].join("\n"),
@@ -110,7 +112,7 @@ export const PROMPTS = [
110
112
  "Core modeling principles:",
111
113
  "- Kibi has eight entity types: common authoring (req, scenario, test, fact) and supporting/system (adr, flag, event, symbol).",
112
114
  "- Encode requirements as linked facts: `req --constrains--> fact` plus `req --requires_property--> fact`.",
113
- "- High-confidence modeling (>= 0.7) is fully automated; optional human review can happen post-hoc.",
115
+ "- High-confidence `kb_model_requirement` output is deterministic; `/init-kibi` bootstrap writes still require preview and explicit approval.",
114
116
  "- Low-confidence claims (< 0.7) are downgraded to `observation` facts to prevent false-positive contradictions.",
115
117
  "- Only strict domain facts participate in contradiction inference; observation and meta facts are non-blocking notes.",
116
118
  "- v1 contradictions are limited to exact-value, boolean/enum, numeric range, and polarity conflicts.",
@@ -134,8 +136,9 @@ export const PROMPTS = [
134
136
  "3. **Create-before-link**: Create endpoint entities with `kb_upsert` before linking them.",
135
137
  "4. **Validate intent**: If creating links, call `kb_query` for both endpoint IDs first to ensure they exist.",
136
138
  "5. **Model requirements as facts**: For new/updated reqs, create/reuse fact entities first, then express req semantics with `constrains` + `requires_property` (automated via `kb_model_requirement`).",
137
- "5. **Mutate**: Call `kb_upsert` for create/update, or `kb_delete` for explicit removals.",
138
- "6. **Targeted checks**: Run `kb_check` after meaningful mutations; specify only the rules you need.",
139
+ "6. **Suggest predicates before prose**: For ontology-lane requirements, spell out the prose claim and call `kb_suggest_predicates` before writing `fact_kind: observation`. Apply the selected `fact_kind: predicate` applyPlan, then attach the returned `relationshipPlan` as `requires_predicate` while preserving existing req metadata; use the returned `review:ontology-gap` observation when no predicate fits.",
140
+ "7. **Mutate**: Call `kb_upsert` for create/update, or `kb_delete` for explicit removals.",
141
+ "8. **Targeted checks**: Run `kb_check` after meaningful mutations; specify only the rules you need.",
139
142
  "",
140
143
  "If a tool returns empty results, do not assume failure. Re-check filters (type, id, tags, sourceFile, limit, or offset).",
141
144
  ].join("\n"),
@@ -202,16 +205,16 @@ function registerDocResources() {
202
205
  "4. Reuse the same constrained fact ID across related requirements; vary property facts only when semantics differ",
203
206
  '5. `kb_check` with `{ "rules": ["required-fields","no-dangling-refs"] }` for targeted validation',
204
207
  "",
208
+ "## Model requirements as ontology predicates",
209
+ '1. Spell out the requirement prose and call `kb_suggest_predicates` with `{ "text": "...", "requirementId": "REQ-..." }`',
210
+ "2. If candidates are returned, apply the top or user-selected `structuredContent.applyPlan` to create `fact_kind: predicate`, then attach `structuredContent.relationshipPlan` with `requires_predicate` while preserving existing req metadata",
211
+ "3. If no candidate fits, apply or review the returned `review:ontology-gap` observation instead of silently writing prose",
212
+ "",
205
213
  "Note: Kibi has eight core entity types. Create or reuse `fact` entities first, then create `req` entities and link with `constrains` and `requires_property` (create-before-link).",
206
214
  "Only strict domain facts are contradiction-safe. Use `flag` for runtime/config gates; use `fact` with `fact_kind: observation` or `meta` for bug/workaround notes.",
207
215
  "",
208
216
  "## Find missing coverage",
209
217
  '1. `kb_find_gaps` with `{ "type": "req", "missingRelationships": ["specified_by", "verified_by"] }` to find under-linked requirements',
210
- "",
211
- "## Find missing coverage",
212
- "",
213
- "## Find missing coverage",
214
- '1. `kb_find_gaps` with `{ "type": "req", "missingRelationships": ["specified_by", "verified_by"] }` to find under-linked requirements',
215
218
  '2. `kb_coverage` with `{ "by": "req", "includePassing": false }` to review evaluated coverage rows',
216
219
  '3. `kb_graph` with `{ "seedIds": ["REQ-001"], "direction": "both", "depth": 2 }` to inspect neighboring entities',
217
220
  "",
@@ -38,6 +38,7 @@ export let prologProcess = null;
38
38
  let isInitialized = false;
39
39
  export let activeBranchName = "develop";
40
40
  let ensurePrologTail = Promise.resolve();
41
+ let prologResetGeneration = 0;
41
42
  export let isShuttingDown = false;
42
43
  let shutdownTimeout = null;
43
44
  export const inFlightRequests = new Map();
@@ -47,6 +48,7 @@ export function resetSessionStateForTests() {
47
48
  isInitialized = false;
48
49
  activeBranchName = "develop";
49
50
  ensurePrologTail = Promise.resolve();
51
+ prologResetGeneration = 0;
50
52
  isShuttingDown = false;
51
53
  inFlightRequests.clear();
52
54
  if (shutdownTimeout) {
@@ -138,8 +140,38 @@ export async function initiateGracefulShutdown(exitCode = 0) {
138
140
  process.exit(exitCode);
139
141
  }
140
142
  // implements REQ-008
143
+ export async function resetProlog(reason) {
144
+ debugLog(`[KIBI-MCP] Resetting Prolog worker: ${reason}`);
145
+ prologResetGeneration += 1;
146
+ const current = prologProcess;
147
+ prologProcess = null;
148
+ isInitialized = false;
149
+ if (current) {
150
+ try {
151
+ await current.terminate();
152
+ }
153
+ catch (error) {
154
+ console.error("[KIBI-MCP] Error resetting Prolog worker:", error);
155
+ }
156
+ }
157
+ }
158
+ // implements REQ-008
141
159
  async function ensurePrologUnsafe() {
160
+ const generationAtStart = prologResetGeneration;
142
161
  const workspaceRoot = sessionDeps.resolveWorkspaceRoot();
162
+ const assertGeneration = async () => {
163
+ if (generationAtStart !== prologResetGeneration) {
164
+ const current = prologProcess;
165
+ prologProcess = null;
166
+ isInitialized = false;
167
+ if (current) {
168
+ await current.terminate().catch((error) => {
169
+ console.error("[KIBI-MCP] Error terminating stale Prolog after reset generation change:", error);
170
+ });
171
+ }
172
+ throw new Error("Prolog worker reset while initialization was in progress");
173
+ }
174
+ };
143
175
  // Determine target branch: respect KIBI_BRANCH override or resolve from git
144
176
  const envBranch = getBranchOverride();
145
177
  let targetBranch;
@@ -170,10 +202,12 @@ async function ensurePrologUnsafe() {
170
202
  debugLog(`[KIBI-MCP] Branch changed: ${activeBranchName} -> ${targetBranch}`);
171
203
  // Persist and detach from old KB
172
204
  const saveResult = await prologProcess.query("kb_save");
205
+ await assertGeneration();
173
206
  if (!saveResult.success) {
174
207
  throw new Error(`Failed to save old KB before detach: ${saveResult.error || "Unknown error"}`);
175
208
  }
176
209
  const detachResult = await prologProcess.query("kb_detach");
210
+ await assertGeneration();
177
211
  if (!detachResult.success) {
178
212
  debugLog(`[KIBI-MCP] Warning: failed to detach from old KB: ${detachResult.error || "Unknown error"}`);
179
213
  // Continue anyway - we'll try to attach to the new KB
@@ -183,6 +217,7 @@ async function ensurePrologUnsafe() {
183
217
  const newKbPath = sessionDeps.resolveKbPath(workspaceRoot, targetBranch);
184
218
  // Attach to new branch KB
185
219
  const attachResult = await prologProcess.query(`kb_attach('${newKbPath}')`);
220
+ await assertGeneration();
186
221
  if (!attachResult.success) {
187
222
  throw new Error(`Failed to attach to new branch KB: ${attachResult.error || "Unknown error"}`);
188
223
  }
@@ -195,6 +230,7 @@ async function ensurePrologUnsafe() {
195
230
  debugLog("[KIBI-MCP] Initializing Prolog process...");
196
231
  prologProcess = new sessionDeps.PrologProcess({ timeout: 120000 });
197
232
  await prologProcess.start();
233
+ await assertGeneration();
198
234
  // Startup debug: resolve which kibi-cli is being used and its version (best-effort).
199
235
  // Gate all output under KIBI_MCP_DEBUG and write only to stderr via debugLog.
200
236
  if (isMcpDebugEnabled()) {
@@ -235,6 +271,7 @@ async function ensurePrologUnsafe() {
235
271
  ensureBranchKbExists(workspaceRoot, targetBranch);
236
272
  const kbPath = sessionDeps.resolveKbPath(workspaceRoot, targetBranch);
237
273
  const attachResult = await prologProcess.query(`kb_attach('${kbPath}')`);
274
+ await assertGeneration();
238
275
  if (!attachResult.success) {
239
276
  throw new Error(`Failed to attach KB: ${attachResult.error || "Unknown error"}`);
240
277
  }
@@ -1,5 +1,23 @@
1
+ /*
2
+ Kibi — repo-local, per-branch, queryable long-term memory for software projects
3
+ Copyright (C) 2026 Piotr Franczyk
4
+
5
+ This program is free software: you can redistribute it and/or modify
6
+ it under the terms of the GNU Affero General Public License as published by
7
+ the Free Software Foundation, either version 3 of the License, or
8
+ (at your option) any later version.
9
+
10
+ This program is distributed in the hope that it will be useful,
11
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
12
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13
+ GNU Affero General Public License for more details.
14
+
15
+ You should have received a copy of the GNU Affero General Public License
16
+ along with this program. If not, see <https://www.gnu.org/licenses/>.
17
+ */
18
+ import process from "node:process";
1
19
  import { z } from "zod";
2
- import { DIAGNOSTIC_MODE_ENABLED, appendUsageLogLine, deriveDiagnosticFields, extractToolCallPayload, } from "../diagnostics.js";
20
+ import { DIAGNOSTIC_MODE_ENABLED, appendUsageLogLine, classifyDiagnosticError, deriveDiagnosticFields, extractToolCallPayload, } from "../diagnostics.js";
3
21
  import { isMcpDebugEnabled } from "../env.js";
4
22
  import { TOOLS } from "../tools-config.js";
5
23
  import { handleKbAutopilotGenerate, } from "../tools/autopilot-generate.js";
@@ -13,7 +31,10 @@ import { handleKbQuery } from "../tools/query.js";
13
31
  import { handleKbSearch } from "../tools/search.js";
14
32
  import { handleKbSkillsList, handleKbSkillsLoad, handleKbSkillsRead, } from "../tools/skills.js";
15
33
  import { handleKbStatus } from "../tools/status.js";
34
+ import { handleKbSuggestPredicates, } from "../tools/suggest-predicates.js";
16
35
  import { handleKbUpsert } from "../tools/upsert.js";
36
+ const DEFAULT_TOOL_TIMEOUT_MS = 90_000;
37
+ const TOOL_TIMEOUT_ENV = "KIBI_MCP_TOOL_TIMEOUT_MS";
17
38
  const defaultToolsServerDeps = {
18
39
  getSessionModule: () => import("./session.js"),
19
40
  };
@@ -40,6 +61,7 @@ async function getSessionModule() {
40
61
  const DEFAULT_TOOLS_RUNTIME = {
41
62
  diagnosticModeEnabled: () => DIAGNOSTIC_MODE_ENABLED,
42
63
  appendUsageLogLine,
64
+ classifyDiagnosticError,
43
65
  deriveDiagnosticFields,
44
66
  extractToolCallPayload,
45
67
  // INTENTIONAL: TOOLS is imported as a Zod-inferred schema type; ToolConfig is the
@@ -48,6 +70,7 @@ const DEFAULT_TOOLS_RUNTIME = {
48
70
  tools: TOOLS,
49
71
  activeBranchName: async () => (await getSessionModule()).activeBranchName,
50
72
  ensureProlog: async () => (await getSessionModule()).ensureProlog(),
73
+ resetProlog: async (reason) => (await getSessionModule()).resetProlog(reason),
51
74
  inFlightRequests: async () => (await getSessionModule()).inFlightRequests,
52
75
  isShuttingDown: async () => (await getSessionModule()).isShuttingDown,
53
76
  prologProcess: async () => (await getSessionModule()).prologProcess,
@@ -64,6 +87,7 @@ const DEFAULT_TOOLS_RUNTIME = {
64
87
  handleKbSkillsRead,
65
88
  handleKbUpsert,
66
89
  handleKbModelRequirement,
90
+ handleKbSuggestPredicates,
67
91
  handleKbAutopilotGenerate,
68
92
  };
69
93
  // implements REQ-008
@@ -73,6 +97,43 @@ function debugLog(...args) {
73
97
  }
74
98
  }
75
99
  // implements REQ-002
100
+ function getToolTimeoutMs() {
101
+ const raw = process.env[TOOL_TIMEOUT_ENV]?.trim();
102
+ if (!raw) {
103
+ return DEFAULT_TOOL_TIMEOUT_MS;
104
+ }
105
+ const parsed = Number(raw);
106
+ return Number.isSafeInteger(parsed) && parsed > 0
107
+ ? parsed
108
+ : DEFAULT_TOOL_TIMEOUT_MS;
109
+ }
110
+ // implements REQ-002
111
+ function createToolTimeoutError(toolName, timeoutMs) {
112
+ return new Error(`Tool ${toolName} timed out after ${timeoutMs}ms`);
113
+ }
114
+ // implements REQ-002
115
+ async function withToolTimeout(toolName, operation, onTimeout) {
116
+ const timeoutMs = getToolTimeoutMs();
117
+ let timeout;
118
+ try {
119
+ return await Promise.race([
120
+ operation,
121
+ new Promise((_, reject) => {
122
+ timeout = setTimeout(() => {
123
+ const error = createToolTimeoutError(toolName, timeoutMs);
124
+ reject(error);
125
+ void onTimeout(error, timeoutMs);
126
+ }, timeoutMs);
127
+ }),
128
+ ]);
129
+ }
130
+ finally {
131
+ if (timeout) {
132
+ clearTimeout(timeout);
133
+ }
134
+ }
135
+ }
136
+ // implements REQ-002
76
137
  export function jsonSchemaToZod(schema) {
77
138
  if (!schema || typeof schema !== "object") {
78
139
  return z.any();
@@ -209,9 +270,21 @@ runtime = DEFAULT_TOOLS_RUNTIME) {
209
270
  const trackedRequests = await runtime.inFlightRequests();
210
271
  const handlerPromise = handler(businessArgs);
211
272
  trackedRequests.set(requestId, handlerPromise);
273
+ let resetAttempted = false;
274
+ let resetSucceeded = false;
275
+ let resetError = null;
212
276
  try {
213
277
  // Execute handler
214
- const result = await handlerPromise;
278
+ const result = await withToolTimeout(name, handlerPromise, async () => {
279
+ resetAttempted = true;
280
+ try {
281
+ await runtime.resetProlog(`tool timeout: ${name}`);
282
+ resetSucceeded = true;
283
+ }
284
+ catch (error) {
285
+ resetError = error instanceof Error ? error.message : String(error);
286
+ }
287
+ });
215
288
  // Log usage in diagnostic mode
216
289
  if (diagnosticModeEnabled) {
217
290
  const finishedAt = new Date();
@@ -240,6 +313,7 @@ runtime = DEFAULT_TOOLS_RUNTIME) {
240
313
  if (diagnosticModeEnabled) {
241
314
  const finishedAt = new Date();
242
315
  const err = error instanceof Error ? error : new Error(String(error));
316
+ const diagnosticErrorFields = runtime.classifyDiagnosticError(err);
243
317
  const processHandle = await runtime.prologProcess();
244
318
  const branchName = await runtime.activeBranchName();
245
319
  runtime.appendUsageLogLine({
@@ -254,7 +328,10 @@ runtime = DEFAULT_TOOLS_RUNTIME) {
254
328
  duration_ms: finishedAt.getTime() - startedAt.getTime(),
255
329
  prolog_pid: processHandle?.getPid() ?? null,
256
330
  active_branch: branchName,
257
- error_message: err.message,
331
+ reset_attempted: resetAttempted,
332
+ reset_succeeded: resetSucceeded,
333
+ reset_error: resetError,
334
+ ...diagnosticErrorFields,
258
335
  });
259
336
  }
260
337
  throw error;
@@ -332,6 +409,10 @@ runtime = DEFAULT_TOOLS_RUNTIME) {
332
409
  const prolog = await runtime.ensureProlog();
333
410
  return runtime.handleKbModelRequirement(prolog, args);
334
411
  }, runtime);
412
+ addTool(server, "kb_suggest_predicates", toolDef("kb_suggest_predicates").description, toolDef("kb_suggest_predicates").inputSchema, async (args) => {
413
+ const prolog = await runtime.ensureProlog();
414
+ return runtime.handleKbSuggestPredicates(prolog, args);
415
+ }, runtime);
335
416
  addTool(server, "kb_autopilot_generate", toolDef("kb_autopilot_generate").description, toolDef("kb_autopilot_generate").inputSchema, async (args) => {
336
417
  const prolog = await runtime.ensureProlog();
337
418
  return runtime.handleKbAutopilotGenerate(prolog, args);
@@ -847,7 +847,7 @@ export async function classifyActivationState(workspaceRoot, prolog) {
847
847
  }
848
848
  }
849
849
  // Recursively collect markdown files under `dir`, excluding known ignore dirs.
850
- function collectMarkdownFiles(dir, workspaceRoot, vendoredRoots) {
850
+ export function collectMarkdownFiles(dir, workspaceRoot, vendoredRoots) {
851
851
  const results = [];
852
852
  if (!fs.existsSync(dir))
853
853
  return results;
@@ -6,6 +6,25 @@ import { buildGenericMarkdownCandidates, buildNormativeRequirementCandidates, bu
6
6
  import { discoverProviderEvidence, resolveActivationPolicy, } from "./autopilot-discovery.js";
7
7
  import { loadEntities } from "./entity-query.js";
8
8
  import { getWorkspaceMigrationWarning } from "./model-requirement.js";
9
+ const defaultAutopilotGenerateDeps = {
10
+ buildGenericMarkdownCandidates,
11
+ buildNormativeRequirementCandidates,
12
+ buildProviderEvidenceCandidates,
13
+ buildSymbolManifestCandidates,
14
+ buildTypedMarkdownCandidates,
15
+ collectSourceOnlyAuthoringSignals,
16
+ discoverProviderEvidence,
17
+ getWorkspaceMigrationWarning,
18
+ loadEntities,
19
+ resolveActivationPolicy,
20
+ };
21
+ let autopilotGenerateDeps = defaultAutopilotGenerateDeps;
22
+ export function _setAutopilotGenerateDepsForTests(deps) {
23
+ autopilotGenerateDeps = { ...defaultAutopilotGenerateDeps, ...deps };
24
+ }
25
+ export function _resetAutopilotGenerateDepsForTests() {
26
+ autopilotGenerateDeps = defaultAutopilotGenerateDeps;
27
+ }
9
28
  function clamp(value, min, max) {
10
29
  return Math.max(min, Math.min(max, value));
11
30
  }
@@ -60,6 +79,13 @@ function countCandidatesByType(candidateRecords) {
60
79
  }
61
80
  return counts;
62
81
  }
82
+ function buildApplyPlan(candidateRecords) {
83
+ return candidateRecords.flatMap((candidate) => {
84
+ if (!Array.isArray(candidate.applyPlan))
85
+ return [];
86
+ return candidate.applyPlan.filter((entry) => entry !== null && typeof entry === "object" && !Array.isArray(entry));
87
+ });
88
+ }
63
89
  function formatCandidateTypeCounts(candidateRecords) {
64
90
  const counts = countCandidatesByType(candidateRecords);
65
91
  return Object.keys(counts)
@@ -370,7 +396,7 @@ _prolog, args) {
370
396
  // Gather existing entity ids to suppress duplicates
371
397
  let existingIds = new Set();
372
398
  try {
373
- const entities = await loadEntities(prolog, {});
399
+ const entities = await autopilotGenerateDeps.loadEntities(prolog, {});
374
400
  for (const e of entities) {
375
401
  const id = String(e.id ?? "");
376
402
  if (id)
@@ -382,10 +408,10 @@ _prolog, args) {
382
408
  existingIds = new Set();
383
409
  }
384
410
  const workspaceRoot = resolveWorkspaceRoot();
385
- const activation = await resolveActivationPolicy(workspaceRoot, prolog);
411
+ const activation = await autopilotGenerateDeps.resolveActivationPolicy(workspaceRoot, prolog);
386
412
  const activationState = activation.activationState;
387
- const activationDiscovery = discoverProviderEvidence(workspaceRoot, activation);
388
- const migrationWarning = await getWorkspaceMigrationWarning(workspaceRoot);
413
+ const activationDiscovery = autopilotGenerateDeps.discoverProviderEvidence(workspaceRoot, activation);
414
+ const migrationWarning = await autopilotGenerateDeps.getWorkspaceMigrationWarning(workspaceRoot);
389
415
  const declaredContext = normalizeBootstrapContext(bootstrapContext);
390
416
  const discoveredCandidatePaths = activationDiscovery.evidence.reduce((acc, item) => {
391
417
  const relativePath = item.relativePath;
@@ -407,7 +433,7 @@ _prolog, args) {
407
433
  markdownFiles: [],
408
434
  evidence: candidateDiscovery.evidence.filter((item) => item.kind !== "generic_markdown"),
409
435
  };
410
- let sourceOnlySignals = collectSourceOnlyAuthoringSignals(guidanceDiscovery, {
436
+ let sourceOnlySignals = autopilotGenerateDeps.collectSourceOnlyAuthoringSignals(guidanceDiscovery, {
411
437
  ids: existingIds,
412
438
  workspaceRoot,
413
439
  }, normalizedMinConfidence);
@@ -428,28 +454,31 @@ _prolog, args) {
428
454
  return `${entityType}::${String(title).trim().toLowerCase().replace(/\s+/g, " ")}`;
429
455
  }
430
456
  if (activation.allowCandidateGeneration) {
431
- typedMarkdownCandidates = buildTypedMarkdownCandidates(candidateDiscovery, {
432
- ids: existingIds,
433
- workspaceRoot,
434
- });
435
- manifestCandidates = buildSymbolManifestCandidates(candidateDiscovery, {
457
+ typedMarkdownCandidates =
458
+ autopilotGenerateDeps.buildTypedMarkdownCandidates(candidateDiscovery, {
459
+ ids: existingIds,
460
+ workspaceRoot,
461
+ });
462
+ manifestCandidates = autopilotGenerateDeps.buildSymbolManifestCandidates(candidateDiscovery, {
436
463
  ids: existingIds,
437
464
  workspaceRoot,
438
465
  });
439
466
  if (includeGenericMarkdown) {
440
- genericCandidates = buildGenericMarkdownCandidates(candidateDiscovery, {
467
+ genericCandidates = autopilotGenerateDeps.buildGenericMarkdownCandidates(candidateDiscovery, {
441
468
  ids: existingIds,
442
469
  workspaceRoot,
443
470
  }, normalizedMinConfidence);
444
- normativeRequirementCandidates = buildNormativeRequirementCandidates(candidateDiscovery, {
471
+ normativeRequirementCandidates =
472
+ autopilotGenerateDeps.buildNormativeRequirementCandidates(candidateDiscovery, {
473
+ ids: existingIds,
474
+ workspaceRoot,
475
+ }, normalizedMinConfidence);
476
+ }
477
+ providerEvidenceCandidates =
478
+ autopilotGenerateDeps.buildProviderEvidenceCandidates(candidateDiscovery, {
445
479
  ids: existingIds,
446
480
  workspaceRoot,
447
481
  }, normalizedMinConfidence);
448
- }
449
- providerEvidenceCandidates = buildProviderEvidenceCandidates(candidateDiscovery, {
450
- ids: existingIds,
451
- workspaceRoot,
452
- }, normalizedMinConfidence);
453
482
  allCandidates = [
454
483
  ...typedMarkdownCandidates,
455
484
  ...manifestCandidates,
@@ -573,6 +602,7 @@ _prolog, args) {
573
602
  // best-effort only; ignore failures here so generation can continue
574
603
  }
575
604
  const candidateRecords = Array.from(seenByKey.values());
605
+ const applyPlan = buildApplyPlan(candidateRecords);
576
606
  const payoffSummary = buildPayoffSummary(candidateRecords);
577
607
  const promptBlock = buildPromptBlock(workspaceRoot, activationState, activation.activationMode, activation.reason, activation.applyBlocked, declaredContext, candidateRecords, sourceOnlySignals, activationDiscovery.summary.scanWarnings);
578
608
  const confidence = buildConfidence(activation.activationMode, activation.applyBlocked, declaredContext, candidateRecords, sourceOnlySignals, promptBlock);
@@ -585,6 +615,9 @@ _prolog, args) {
585
615
  const effectiveTldr = confidence.level === "low" && !activation.applyBlocked
586
616
  ? `Low-confidence bootstrap (${confidence.score}): review diagnostics before proceeding. ${tldr}`
587
617
  : tldr;
618
+ const effectiveText = applyPlan.length > 0
619
+ ? `${effectiveTldr} Review structuredContent.applyPlan for exact sequential kb_upsert payloads before requesting approval.`
620
+ : effectiveTldr;
588
621
  const structuredContent = {
589
622
  activationState,
590
623
  activationMode: activation.activationMode,
@@ -602,6 +635,7 @@ _prolog, args) {
602
635
  declaredContext,
603
636
  discoverySummary: activationDiscovery.summary,
604
637
  candidates: candidateRecords,
638
+ applyPlan,
605
639
  suppressedCandidates: suppressed,
606
640
  payoffSummary,
607
641
  };
@@ -609,12 +643,13 @@ _prolog, args) {
609
643
  content: [
610
644
  {
611
645
  type: "text",
612
- text: effectiveTldr,
646
+ text: effectiveText,
613
647
  },
614
648
  ],
615
649
  structuredContent,
616
650
  migrationWarning,
617
651
  candidates: candidateRecords,
652
+ applyPlan,
618
653
  suppressedCandidates: suppressed,
619
654
  payoffSummary,
620
655
  };