kibi-mcp 0.7.1 → 0.8.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.
@@ -42,63 +42,49 @@ export const PROMPTS = [
42
42
  // implements REQ-002, REQ-013, REQ-mcp-search-discovery
43
43
  {
44
44
  name: "init-kibi",
45
- description: "Bootstrap Kibi on an existing repository with zero entities.",
45
+ description: "Activation workflow to populate a new or empty Kibi KB from an existing repository.",
46
46
  text: [
47
- "# Kibi Initialization Workflow",
47
+ "# Kibi Activation Workflow",
48
48
  "",
49
- "Use this workflow to retroactively bootstrap Kibi on an existing repository with zero entities.",
49
+ "Use this workflow to populate a Kibi knowledge base when it is new or empty.",
50
50
  "",
51
- "## Phase 1: Discovery",
52
- "1. Scan project structure to identify:",
53
- " - Requirements (docs/requirements/, README, specs)",
54
- " - Tests (unit, integration, e2e)",
55
- " - Architecture decisions (docs/adr/, ARCHITECTURE.md)",
56
- " - Feature flags (environment files, config)",
57
- " - Events (domain events, pub/sub topics)",
58
- " - Core symbols (key functions, classes, modules)",
51
+ "## Step 1: Generate Candidates (read-only)",
59
52
  "",
60
- "## Phase 2: Fact Extraction",
61
- "1. Identify atomic domain facts (invariants, constraints, properties)",
62
- "2. Create reusable fact entities with `kb_upsert` using type 'fact'",
63
- "3. Use consistent IDs: FACT-XXX with descriptive titles",
53
+ "Call `kb_autopilot_generate` to scan the repository and produce candidate entities.",
64
54
  "",
65
- "## Phase 3: Requirement Encoding",
66
- "1. Extract requirements from documentation",
67
- "2. For each requirement, determine which facts it constrains or requires",
68
- "3. Create req entities with `kb_upsert` using type 'req'",
69
- "4. Link reqs to facts using `constrains` and `requires_property` relationships",
70
- "5. Reuse fact IDs across related requirements for contradiction detection",
55
+ "This tool is **read-only** — it never writes to the KB. It returns:",
56
+ "- `activationState`: the current KB state (e.g. `root_uninitialized`, `root_partial`)",
57
+ "- `candidates[]`: proposed entities with confidence scores and evidence",
58
+ "- `suppressedCandidates[]`: candidates suppressed due to duplicates, existing entities, or shadowed by typed sources",
59
+ "- `discoverySummary` / `payoffSummary`: context for agent review",
71
60
  "",
72
- "## Phase 4: Test Linking",
73
- "1. Map existing tests to requirements they verify",
74
- "2. Create test entities with `kb_upsert` using type 'test'",
75
- "3. Link tests to requirements using `verified_by` relationship",
61
+ "## Step 2: Review Candidates",
76
62
  "",
77
- "## Phase 5: Architecture Documentation",
78
- "1. Extract ADRs from docs/adr/ or decision records",
79
- "2. Create adr entities with `kb_upsert` using type 'adr'",
80
- "3. Link ADRs to symbols they constrain using `constrained_by`",
63
+ "Inspect `activationState`. If `applyBlocked` is true, stop — the KB cannot accept writes.",
81
64
  "",
82
- "## Phase 6: Event Catalog",
83
- "1. Identify domain/system events from code",
84
- "2. Create event entities with `kb_upsert` using type 'event'",
85
- "3. Link symbols that publish/consume events using `publishes`/`consumes`",
65
+ "For each candidate, evaluate:",
66
+ "- **confidence** (0–1): prefer high-confidence entities first",
67
+ "- **evidence**: verify the source reference is real before applying",
86
68
  "",
87
- "## Phase 7: Symbol Mapping",
88
- "1. Map key code symbols to requirements",
89
- "2. Create symbol entities with `kb_upsert` using type 'symbol'",
90
- "3. Link symbols to requirements using `implements`",
69
+ "Discard or edit candidates that look speculative. The agent decides what to apply — the generator never writes.", "",
70
+ "## Step 3: Apply Approved Candidates", "",
71
+ "Apply approved candidates by executing each candidate.applyPlan sequentially:",
72
+ "1. For each approved candidate, run its `candidate.applyPlan` steps in ascending phase order and keep the candidate sequence deterministic",
73
+ "2. Execute each step with `kb_upsert` using the step's provided args, and confirm success before moving to the next step",
74
+ "3. After each batch, call `kb_check` with targeted rules (`required-fields`, `no-dangling-refs`) to catch issues early",
91
75
  "",
92
- "## Phase 8: Validation",
93
- "1. Run `kb_check` with all rules to verify integrity",
94
- "2. Fix any dangling references or constraint violations",
95
- "3. Re-run validation until clean",
76
+ "## Step 4: Payoff Verification",
77
+ "",
78
+ "After all approved candidates are applied, verify the result:",
79
+ "- `kb_check` with all rules — must return zero violations",
80
+ "- `kb_find_gaps` with `{ type: 'req', missingRelationships: ['specified_by', 'verified_by'] }` — identify under-linked requirements",
81
+ "- `kb_coverage` with `{ by: 'req' }` — confirm traceability coverage", "",
82
+ "## Doc Hygiene",
96
83
  "",
97
- "## Best Practices",
98
- "- Start with high-value entities first (critical requirements, security constraints)",
99
- "- Use incremental batches to avoid overwhelming the KB",
100
84
  "- Always call `kb_query` before creating to avoid duplicate entities",
101
- "- Run `kb_check` after each batch of changes",
85
+ "- Run `kb_check` after each batch, not just at the end",
86
+ "- All writes go through `kb_upsert` — do not invoke CLI commands directly",
87
+ "- `kb_autopilot_generate` is read-only; only `kb_upsert` mutates the KB",
102
88
  ].join("\n"),
103
89
  },
104
90
  {
@@ -11,6 +11,7 @@ import { handleKbQuery } from "../tools/query.js";
11
11
  import { handleKbSearch } from "../tools/search.js";
12
12
  import { handleKbStatus } from "../tools/status.js";
13
13
  import { handleKbUpsert } from "../tools/upsert.js";
14
+ import { handleKbAutopilotGenerate, } from "../tools/autopilot-generate.js";
14
15
  const defaultToolsServerDeps = {
15
16
  getSessionModule: () => import("./session.js"),
16
17
  };
@@ -57,6 +58,7 @@ const DEFAULT_TOOLS_RUNTIME = {
57
58
  handleKbSearch,
58
59
  handleKbStatus,
59
60
  handleKbUpsert,
61
+ handleKbAutopilotGenerate,
60
62
  };
61
63
  // implements REQ-008
62
64
  function debugLog(...args) {
@@ -317,4 +319,8 @@ runtime = DEFAULT_TOOLS_RUNTIME) {
317
319
  const prolog = await runtime.ensureProlog();
318
320
  return runtime.handleKbCheck(prolog, args);
319
321
  }, runtime);
322
+ addTool(server, "kb_autopilot_generate", toolDef("kb_autopilot_generate").description, toolDef("kb_autopilot_generate").inputSchema, async (args) => {
323
+ const prolog = await runtime.ensureProlog();
324
+ return runtime.handleKbAutopilotGenerate(prolog, args);
325
+ }, runtime);
320
326
  }
@@ -0,0 +1,229 @@
1
+ // Kibi — autopilot candidate builders
2
+ // Implements candidate assembly from public CLI extractors
3
+ import { extractFromManifest } from "kibi-cli/extractors/manifest";
4
+ import { extractFromMarkdown } from "kibi-cli/extractors/markdown";
5
+ import path from "node:path";
6
+ import fs from "node:fs";
7
+ function resolveCandidatePaths(filePath, workspaceRoot) {
8
+ const absolutePath = path.isAbsolute(filePath)
9
+ ? filePath
10
+ : path.resolve(workspaceRoot, filePath);
11
+ const relativePath = (path.relative(workspaceRoot, absolutePath) || filePath)
12
+ .split(path.sep)
13
+ .join("/");
14
+ return { absolutePath, relativePath };
15
+ }
16
+ function isIgnoredGenericMarkdownPath(relativePath) {
17
+ const normalized = relativePath.replace(/\\/g, "/").replace(/^\.\//, "");
18
+ return /(^|\/)(documentation|\.kb|\.git|node_modules|vendor|vendors|third_party|third-party|dist|coverage)(\/|$)/.test(normalized);
19
+ }
20
+ function buildUpsertFromExtraction(er, typeOverride) {
21
+ const ent = er.entity;
22
+ const type = typeOverride ?? String(ent.type ?? "");
23
+ const id = String(ent.id ?? "");
24
+ // Copy properties except `type` to avoid delete
25
+ const properties = {};
26
+ for (const [k, v] of Object.entries(ent)) {
27
+ if (k === "type")
28
+ continue;
29
+ properties[k] = v;
30
+ }
31
+ return {
32
+ type,
33
+ id,
34
+ properties,
35
+ relationships: er.relationships ?? [],
36
+ };
37
+ }
38
+ // implements REQ-mcp-init-kibi-autopilot-v1
39
+ export function buildTypedMarkdownCandidates(discoveryResult, existingEntities) {
40
+ const candidates = [];
41
+ const workspaceRoot = existingEntities.workspaceRoot ?? process.cwd();
42
+ for (const filePath of discoveryResult.markdownFiles || []) {
43
+ try {
44
+ const extraction = extractFromMarkdown(filePath);
45
+ const { entity, relationships } = extraction;
46
+ if (existingEntities.ids.has(entity.id))
47
+ continue;
48
+ const { absolutePath, relativePath } = resolveCandidatePaths(filePath, workspaceRoot);
49
+ const candidateId = `md:${relativePath}:${entity.id}`;
50
+ const upsert = buildUpsertFromExtraction({ entity, relationships });
51
+ candidates.push({
52
+ candidateId,
53
+ entityType: entity.type,
54
+ title: entity.title,
55
+ sourceKind: "typed_markdown",
56
+ sourcePath: absolutePath,
57
+ confidence: 1.0,
58
+ confidenceBand: "high",
59
+ evidence: [
60
+ `extracted_from_markdown:${relativePath}`,
61
+ `entity_id:${entity.id}`,
62
+ ],
63
+ relationships: relationships || [],
64
+ applyPlan: [upsert],
65
+ });
66
+ }
67
+ catch (error) {
68
+ // skip files that fail extraction
69
+ }
70
+ }
71
+ return candidates;
72
+ }
73
+ // implements REQ-mcp-init-kibi-autopilot-v1
74
+ export function buildSymbolManifestCandidates(discoveryResult, existingEntities) {
75
+ const candidates = [];
76
+ const workspaceRoot = existingEntities.workspaceRoot ?? process.cwd();
77
+ for (const filePath of discoveryResult.manifestFiles || []) {
78
+ try {
79
+ const results = extractFromManifest(filePath);
80
+ for (const res of results) {
81
+ const entity = res.entity;
82
+ const relationships = res.relationships || [];
83
+ if (existingEntities.ids.has(entity.id))
84
+ continue;
85
+ const { absolutePath, relativePath } = resolveCandidatePaths(filePath, workspaceRoot);
86
+ const candidateId = `mf:${relativePath}:${entity.id}`;
87
+ const upsert = buildUpsertFromExtraction({ entity, relationships });
88
+ candidates.push({
89
+ candidateId,
90
+ entityType: entity.type,
91
+ title: entity.title,
92
+ sourceKind: "symbol_manifest",
93
+ sourcePath: absolutePath,
94
+ confidence: 0.98,
95
+ confidenceBand: "high",
96
+ evidence: [
97
+ `extracted_from_manifest:${relativePath}`,
98
+ `entity_id:${entity.id}`,
99
+ ],
100
+ relationships,
101
+ applyPlan: [upsert],
102
+ });
103
+ }
104
+ }
105
+ catch (error) {
106
+ // skip manifest parse errors
107
+ }
108
+ }
109
+ return candidates;
110
+ }
111
+ /**
112
+ * Conservative generic markdown candidate builder.
113
+ * Scans a small, safe set of top-level markdown files and emits only
114
+ * ADR/REQ/FACT candidates when clear heading heuristics match.
115
+ *
116
+ * discoveryResult.markdownFiles is expected to be a list of file paths
117
+ * (absolute or relative). Files under documentation/**, .kb/**, .git/**,
118
+ * node_modules/**, vendor/**, third_party/** are ignored to avoid vendored
119
+ * trees and double-counting typed Kibi docs.
120
+ */
121
+ // implements REQ-mcp-init-kibi-autopilot-v1
122
+ export function buildGenericMarkdownCandidates(discoveryResult, existingEntities, minConfidence = 0.8) {
123
+ const candidates = [];
124
+ const workspaceRoot = existingEntities.workspaceRoot ?? process.cwd();
125
+ const files = discoveryResult.markdownFiles ?? [];
126
+ for (const rawPath of files) {
127
+ try {
128
+ const filePath = String(rawPath);
129
+ const { absolutePath, relativePath } = resolveCandidatePaths(filePath, workspaceRoot);
130
+ if (isIgnoredGenericMarkdownPath(relativePath))
131
+ continue;
132
+ const base = path.basename(relativePath).toLowerCase();
133
+ const inDocsDir = /(^|\/)docs\//.test(relativePath);
134
+ // Only scan README.md, ARCHITECTURE.md or files under docs/**
135
+ if (!(base === "readme.md" || base === "architecture.md" || inDocsDir)) {
136
+ continue;
137
+ }
138
+ if (!fs.existsSync(absolutePath))
139
+ continue;
140
+ const content = fs.readFileSync(absolutePath, "utf8");
141
+ const lines = content.split(/\r?\n/);
142
+ for (let i = 0; i < lines.length; i++) {
143
+ const line = lines[i];
144
+ if (line === undefined)
145
+ continue;
146
+ const headingMatch = line.match(/^\s*#+\s*(.+)$/);
147
+ if (!headingMatch)
148
+ continue;
149
+ const headingRaw = headingMatch[1];
150
+ if (!headingRaw)
151
+ continue;
152
+ const heading = headingRaw.trim();
153
+ const headingLower = heading.toLowerCase();
154
+ let type = null;
155
+ let confidence = 0;
156
+ // ADR heuristic: headings that mention ADR or Architectural Decision
157
+ if (/\badr\b/i.test(heading) || /architectur.*decision/i.test(heading)) {
158
+ type = "adr";
159
+ confidence = 0.9;
160
+ }
161
+ // Requirements heuristic: explicit Requirements heading
162
+ if (!type && /\brequirements?\b/i.test(heading)) {
163
+ type = "req";
164
+ confidence = 0.85;
165
+ }
166
+ // Fact/Observation heuristic
167
+ if (!type && /\b(observations?|facts?|notes?)\b/i.test(heading)) {
168
+ type = "fact";
169
+ confidence = 0.8;
170
+ }
171
+ if (!type)
172
+ continue;
173
+ // Suppress below-threshold candidates early
174
+ if (confidence < minConfidence)
175
+ continue;
176
+ // Build a safe ID for the candidate. Use a repo-relative slug so it's
177
+ // deterministic across runs and comparable to typed candidates.
178
+ const slug = heading
179
+ .toLowerCase()
180
+ .replace(/[^a-z0-9]+/g, "-")
181
+ .replace(/(^-|-$)/g, "")
182
+ .slice(0, 60);
183
+ const idPrefix = type === "adr" ? "ADR" : type === "req" ? "REQ" : "FACT";
184
+ const genId = `${idPrefix}-GEN-${slug || path.basename(relativePath).replace(/\.[^.]+$/, "")}`.toUpperCase();
185
+ if (existingEntities.ids.has(genId))
186
+ continue;
187
+ // Entity record for applyPlan; keep properties minimal and safe.
188
+ const entity = {
189
+ type,
190
+ id: genId,
191
+ title: heading,
192
+ status: type === "fact" ? "active" : type === "adr" ? "proposed" : "open",
193
+ source: `autopilot:generic:${relativePath}`,
194
+ text_ref: `${relativePath}#L${i + 1}`,
195
+ };
196
+ if (type === "fact") {
197
+ // Generic facts are observations by policy
198
+ entity.fact_kind = "observation";
199
+ }
200
+ const upsert = buildUpsertFromExtraction({
201
+ entity: entity,
202
+ relationships: [],
203
+ }, type);
204
+ const candidateId = `gen:${relativePath}:${type}:${slug}`;
205
+ const confidenceBand = confidence >= 0.95 ? "high" : "medium";
206
+ candidates.push({
207
+ candidateId,
208
+ entityType: type,
209
+ title: heading,
210
+ sourceKind: "generic_markdown",
211
+ sourcePath: absolutePath,
212
+ confidence,
213
+ confidenceBand,
214
+ evidence: [`generic_heading:${relativePath}#L${i + 1}`],
215
+ relationships: [],
216
+ applyPlan: [upsert],
217
+ });
218
+ }
219
+ }
220
+ catch (error) {
221
+ // skip unreadable files silently
222
+ }
223
+ }
224
+ return candidates;
225
+ }
226
+ export default {
227
+ buildTypedMarkdownCandidates,
228
+ buildSymbolManifestCandidates,
229
+ };
@@ -0,0 +1,243 @@
1
+ /*
2
+ * Autopilot discovery helpers
3
+ *
4
+ * Provides lightweight workspace activation classification and source discovery
5
+ * used by the `kb_autopilot_generate` workflow.
6
+ */
7
+ import fs from "node:fs";
8
+ import path from "node:path";
9
+ import { runJsonModuleQuery } from "./core-module.js";
10
+ // Minimal copy of the opencode defaults used by other packages. Keep in sync
11
+ // with packages/opencode/src/file-filter.ts DEFAULT_SYNC_PATHS.
12
+ const DEFAULT_SYNC_PATHS = {
13
+ requirements: "documentation/requirements/**/*.md",
14
+ scenarios: "documentation/scenarios/**/*.md",
15
+ tests: "documentation/tests/**/*.md",
16
+ adr: "documentation/adr/**/*.md",
17
+ flags: "documentation/flags/**/*.md",
18
+ events: "documentation/events/**/*.md",
19
+ facts: "documentation/facts/**/*.md",
20
+ symbols: "documentation/symbols.yaml",
21
+ };
22
+ function findVendoredTrees(cwd) {
23
+ const results = [];
24
+ const vendoredMarkers = [
25
+ ["kibi", "opencode.json"],
26
+ ["kibi", "package.json"],
27
+ ["kibi", "packages", "mcp"],
28
+ ["kibi", "documentation"],
29
+ ];
30
+ for (const marker of vendoredMarkers) {
31
+ const markerPath = path.join(cwd, ...marker);
32
+ if (fs.existsSync(markerPath)) {
33
+ results.push(marker.join("/"));
34
+ }
35
+ }
36
+ const nodeModules = path.join(cwd, "node_modules");
37
+ if (fs.existsSync(nodeModules)) {
38
+ try {
39
+ for (const entry of fs.readdirSync(nodeModules)) {
40
+ if (entry === "kibi" || entry.startsWith("kibi-")) {
41
+ results.push(`node_modules/${entry}`);
42
+ }
43
+ }
44
+ }
45
+ catch {
46
+ // ignore
47
+ }
48
+ }
49
+ return Array.from(new Set(results));
50
+ }
51
+ function rootKbConfigExists(cwd) {
52
+ return fs.existsSync(path.join(cwd, ".kb", "config.json"));
53
+ }
54
+ function readRootConfig(cwd) {
55
+ try {
56
+ const raw = fs.readFileSync(path.join(cwd, ".kb", "config.json"), "utf8");
57
+ return JSON.parse(raw) || null;
58
+ }
59
+ catch {
60
+ return null;
61
+ }
62
+ }
63
+ function stripToRoot(p) {
64
+ const segments = p.split("/");
65
+ const rootSegments = [];
66
+ for (const seg of segments) {
67
+ if (seg.includes("*") || seg.includes("?") || seg.includes("["))
68
+ break;
69
+ rootSegments.push(seg);
70
+ }
71
+ const result = rootSegments.join("/");
72
+ return result || ".";
73
+ }
74
+ function normalizePattern(p) {
75
+ if (!p)
76
+ return null;
77
+ if (p.includes("*"))
78
+ return p;
79
+ if (p.endsWith(".yaml") || p.endsWith(".yml") || path.extname(p))
80
+ return p;
81
+ return `${p.replace(/\/+$/, "")}/**/*.md`;
82
+ }
83
+ function rootTargetsAllResolve(cwd) {
84
+ const config = readRootConfig(cwd) || {};
85
+ const paths = config.paths ?? {};
86
+ const keys = [
87
+ "requirements",
88
+ "scenarios",
89
+ "tests",
90
+ "adr",
91
+ "flags",
92
+ "events",
93
+ "facts",
94
+ "symbols",
95
+ ];
96
+ for (const key of keys) {
97
+ const raw = paths[key] ?? DEFAULT_SYNC_PATHS[key];
98
+ if (!raw)
99
+ return false;
100
+ const normalized = raw.replace(/\/+$|\s+$/g, "");
101
+ const isFile = normalized.endsWith(".yaml") || normalized.endsWith(".yml");
102
+ if (isFile) {
103
+ if (!fs.existsSync(path.resolve(cwd, normalized)))
104
+ return false;
105
+ }
106
+ else {
107
+ const root = stripToRoot(normalized);
108
+ if (!fs.existsSync(path.resolve(cwd, root)))
109
+ return false;
110
+ }
111
+ }
112
+ return true;
113
+ }
114
+ /** Classify activation readiness for autopilot. */
115
+ // implements REQ-mcp-init-kibi-autopilot-v1
116
+ export async function classifyActivationState(workspaceRoot, prolog) {
117
+ const hasRootConfig = rootKbConfigExists(workspaceRoot);
118
+ const vendored = findVendoredTrees(workspaceRoot);
119
+ if (!hasRootConfig && vendored.length > 0) {
120
+ return "vendored_only";
121
+ }
122
+ if (!hasRootConfig) {
123
+ return "root_uninitialized";
124
+ }
125
+ // Root config exists → check targets
126
+ const allResolve = rootTargetsAllResolve(workspaceRoot);
127
+ if (!allResolve)
128
+ return "root_partial";
129
+ // Config exists and targets resolve — consult KB counts via Prolog
130
+ try {
131
+ const payload = await runJsonModuleQuery(prolog, "discovery.pl", "discovery:coverage_report_json(type, [], true, false, 100, 0, JsonString)", "Autopilot activation counts");
132
+ const rows = payload?.rows ?? [];
133
+ const counts = {};
134
+ for (const r of rows)
135
+ counts[r.type ?? r.id] = Number(r.count || 0);
136
+ const reqCount = counts.req ?? 0;
137
+ const nonSymbolTypes = [
138
+ "req",
139
+ "scenario",
140
+ "test",
141
+ "adr",
142
+ "flag",
143
+ "event",
144
+ "fact",
145
+ ];
146
+ const nonSymbolTotal = nonSymbolTypes.reduce((s, t) => s + (counts[t] ?? 0), 0);
147
+ const scenarioTestAdrFact = (counts.scenario ?? 0) +
148
+ (counts.test ?? 0) +
149
+ (counts.adr ?? 0) +
150
+ (counts.fact ?? 0);
151
+ if (reqCount >= 1 && nonSymbolTotal >= 5 && scenarioTestAdrFact >= 1) {
152
+ return "root_active_seeded";
153
+ }
154
+ return "root_active_thin";
155
+ }
156
+ catch {
157
+ // If Prolog is unavailable or the query fails, conservatively treat as thin
158
+ return "root_active_thin";
159
+ }
160
+ }
161
+ // Recursively collect markdown files under `dir`, excluding known ignore dirs.
162
+ function collectMarkdownFiles(dir, workspaceRoot, vendoredRoots) {
163
+ const results = [];
164
+ if (!fs.existsSync(dir))
165
+ return results;
166
+ const stat = fs.statSync(dir);
167
+ if (!stat.isDirectory())
168
+ return results;
169
+ const entries = fs.readdirSync(dir);
170
+ for (const entry of entries) {
171
+ const full = path.join(dir, entry);
172
+ // Skip ignores
173
+ if (entry === ".git" || entry === "node_modules" || entry === ".kb")
174
+ continue;
175
+ // Skip vendored roots
176
+ const rel = path.relative(workspaceRoot, full).split(path.sep).join("/");
177
+ if (vendoredRoots.some((v) => rel === v || rel.startsWith(`${v}/`)))
178
+ continue;
179
+ const st = fs.statSync(full);
180
+ if (st.isDirectory()) {
181
+ results.push(...collectMarkdownFiles(full, workspaceRoot, vendoredRoots));
182
+ continue;
183
+ }
184
+ if (st.isFile() && entry.endsWith(".md")) {
185
+ results.push(path.relative(workspaceRoot, full).split(path.sep).join("/"));
186
+ }
187
+ }
188
+ return results;
189
+ }
190
+ /** Discover eligible source inputs for autopilot. */
191
+ // implements REQ-mcp-init-kibi-autopilot-v1
192
+ export function discoverSources(workspaceRoot, activationState) {
193
+ const vendored = findVendoredTrees(workspaceRoot);
194
+ if (activationState === "vendored_only") {
195
+ return { candidates: [], summary: { activationState, vendored } };
196
+ }
197
+ const config = readRootConfig(workspaceRoot) || {};
198
+ const paths = config.paths ??
199
+ DEFAULT_SYNC_PATHS;
200
+ const candidates = new Set();
201
+ // First: configured KB paths (include documentation/* if configured)
202
+ for (const key of Object.keys(DEFAULT_SYNC_PATHS)) {
203
+ const raw = paths[key];
204
+ if (!raw)
205
+ continue;
206
+ const normalized = raw.replace(/\s+$/, "");
207
+ if (normalized.endsWith(".yaml") || normalized.endsWith(".yml")) {
208
+ const abs = path.resolve(workspaceRoot, normalized);
209
+ if (fs.existsSync(abs) && fs.statSync(abs).isFile()) {
210
+ candidates.add(path.relative(workspaceRoot, abs).split(path.sep).join("/"));
211
+ }
212
+ continue;
213
+ }
214
+ const pat = normalizePattern(normalized) ?? normalized;
215
+ const root = stripToRoot(pat);
216
+ const absRoot = path.resolve(workspaceRoot, root);
217
+ if (fs.existsSync(absRoot) && fs.statSync(absRoot).isDirectory()) {
218
+ for (const f of collectMarkdownFiles(absRoot, workspaceRoot, vendored)) {
219
+ candidates.add(f);
220
+ }
221
+ }
222
+ }
223
+ // Generic markdown candidates (top-level), but exclude documentation/** which
224
+ // is treated above via configured paths.
225
+ for (const file of ["README.md", "ARCHITECTURE.md"]) {
226
+ const abs = path.resolve(workspaceRoot, file);
227
+ if (fs.existsSync(abs) && fs.statSync(abs).isFile()) {
228
+ const rel = path.relative(workspaceRoot, abs).split(path.sep).join("/");
229
+ if (!rel.startsWith("documentation/"))
230
+ candidates.add(rel);
231
+ }
232
+ }
233
+ const docsRoot = path.resolve(workspaceRoot, "docs");
234
+ if (fs.existsSync(docsRoot) && fs.statSync(docsRoot).isDirectory()) {
235
+ for (const f of collectMarkdownFiles(docsRoot, workspaceRoot, vendored)) {
236
+ candidates.add(f);
237
+ }
238
+ }
239
+ return {
240
+ candidates: Array.from(candidates).sort(),
241
+ summary: { activationState, reason: "discovered sources", vendored },
242
+ };
243
+ }
@@ -0,0 +1,271 @@
1
+ import path from "node:path";
2
+ import { buildTypedMarkdownCandidates, buildSymbolManifestCandidates, } from "./autopilot-candidates.js";
3
+ import { classifyActivationState, discoverSources as discoverActivationSources, } from "./autopilot-discovery.js";
4
+ import { loadEntities } from "./entity-query.js";
5
+ import { resolveWorkspaceRoot } from "../workspace.js";
6
+ function extractTextRefFromApplyPlan(applyPlan) {
7
+ if (!Array.isArray(applyPlan) || applyPlan.length === 0)
8
+ return "";
9
+ const first = applyPlan[0];
10
+ if (!first || typeof first !== "object")
11
+ return "";
12
+ const firstRecord = first;
13
+ const properties = firstRecord.properties;
14
+ if (!properties || typeof properties !== "object")
15
+ return "";
16
+ const propsRecord = properties;
17
+ const textRef = propsRecord.text_ref;
18
+ return typeof textRef === "string" ? textRef : "";
19
+ }
20
+ function toSuppressedCandidate(reason, candidate) {
21
+ return {
22
+ candidateId: String(candidate.candidateId ?? ""),
23
+ reason,
24
+ sourcePath: String(candidate.sourcePath ?? ""),
25
+ entityType: String(candidate.entityType ?? ""),
26
+ };
27
+ }
28
+ function activationReasonFor(state) {
29
+ switch (state) {
30
+ case "vendored_only":
31
+ return "Workspace appears to contain vendored Kibi sources only; no local candidates generated.";
32
+ case "root_partial":
33
+ return "Workspace root is partially configured; discovery completed using available sources.";
34
+ case "root_active_seeded":
35
+ return "KB attached and discovery completed for a seeded workspace.";
36
+ case "root_active_thin":
37
+ return "KB attached and discovery completed for a thin workspace.";
38
+ default:
39
+ return "Workspace root is not fully initialized; discovery completed using the resolved workspace root.";
40
+ }
41
+ }
42
+ function splitDiscoveredSources(workspaceRoot, candidates) {
43
+ const markdownFiles = [];
44
+ const manifestFiles = [];
45
+ for (const relativePath of candidates) {
46
+ const absolutePath = path.resolve(workspaceRoot, relativePath);
47
+ if (/symbols\.ya?ml$/i.test(relativePath)) {
48
+ manifestFiles.push(absolutePath);
49
+ continue;
50
+ }
51
+ if (/\.md$/i.test(relativePath)) {
52
+ markdownFiles.push(absolutePath);
53
+ }
54
+ }
55
+ return { markdownFiles, manifestFiles };
56
+ }
57
+ export async function handleKbAutopilotGenerate(// implements REQ-mcp-init-kibi-autopilot-v1
58
+ _prolog, args) {
59
+ const { includeGenericMarkdown = true, minConfidence = 0.8, maxCandidates = 50, entityTypes, } = args;
60
+ // Minimal discovery + candidate assembly implementation
61
+ const prolog = _prolog;
62
+ // Gather existing entity ids to suppress duplicates
63
+ let existingIds = new Set();
64
+ try {
65
+ const entities = await loadEntities(prolog, {});
66
+ for (const e of entities) {
67
+ const id = String(e.id ?? "");
68
+ if (id)
69
+ existingIds.add(id);
70
+ }
71
+ }
72
+ catch (error) {
73
+ // If we can't list entities, proceed with empty set
74
+ existingIds = new Set();
75
+ }
76
+ const workspaceRoot = resolveWorkspaceRoot();
77
+ const activationState = await classifyActivationState(workspaceRoot, prolog);
78
+ const activationDiscovery = discoverActivationSources(workspaceRoot, activationState);
79
+ const discovery = splitDiscoveredSources(workspaceRoot, activationDiscovery.candidates);
80
+ const allowGeneration = activationState === "root_uninitialized" || activationState === "root_partial";
81
+ if (!allowGeneration) {
82
+ return {
83
+ content: [
84
+ {
85
+ type: "text",
86
+ text: "Autopilot generated 0 candidate(s).",
87
+ },
88
+ ],
89
+ structuredContent: {
90
+ activationState,
91
+ activationReason: activationReasonFor(activationState),
92
+ applyBlocked: true,
93
+ discoverySummary: {
94
+ markdownFiles: discovery.markdownFiles.length,
95
+ manifestFiles: discovery.manifestFiles.length,
96
+ vendored: activationDiscovery.summary.vendored ?? [],
97
+ },
98
+ candidates: [],
99
+ suppressedCandidates: [],
100
+ payoffSummary: {
101
+ current: {},
102
+ projectedIfAllApplied: {},
103
+ delta: {},
104
+ },
105
+ },
106
+ };
107
+ }
108
+ const typedMarkdownCandidates = buildTypedMarkdownCandidates(discovery, {
109
+ ids: existingIds,
110
+ workspaceRoot,
111
+ });
112
+ const manifestCandidates = buildSymbolManifestCandidates(discovery, {
113
+ ids: existingIds,
114
+ workspaceRoot,
115
+ });
116
+ // Lazy import to avoid circulars if any
117
+ // buildGenericMarkdownCandidates is added in autopilot-candidates
118
+ let genericCandidates = [];
119
+ if (includeGenericMarkdown) {
120
+ try {
121
+ // Import from same module file
122
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
123
+ const ac = await import("./autopilot-candidates.js");
124
+ if (typeof ac.buildGenericMarkdownCandidates === "function") {
125
+ genericCandidates = ac.buildGenericMarkdownCandidates(discovery, {
126
+ ids: existingIds,
127
+ workspaceRoot,
128
+ }, minConfidence);
129
+ }
130
+ }
131
+ catch (err) {
132
+ // ignore import failures and proceed with typed candidates only
133
+ genericCandidates = [];
134
+ }
135
+ }
136
+ // Merge and filter candidates by requested entityTypes and minConfidence
137
+ let allCandidates = [...typedMarkdownCandidates, ...manifestCandidates, ...genericCandidates];
138
+ if (entityTypes && entityTypes.length > 0) {
139
+ const allowed = new Set(entityTypes);
140
+ allCandidates = allCandidates.filter((c) => allowed.has(c.entityType));
141
+ }
142
+ allCandidates = allCandidates.filter((c) => c.confidence >= minConfidence);
143
+ // Limit and deterministic sort (confidence desc, sourcePath asc)
144
+ allCandidates.sort((a, b) => {
145
+ if (b.confidence !== a.confidence)
146
+ return b.confidence - a.confidence;
147
+ if (a.sourcePath < b.sourcePath)
148
+ return -1;
149
+ if (a.sourcePath > b.sourcePath)
150
+ return 1;
151
+ return 0;
152
+ });
153
+ allCandidates = allCandidates.slice(0, maxCandidates);
154
+ // Dedupe logic
155
+ const seenByKey = new Map();
156
+ const suppressed = [];
157
+ // Helpers
158
+ function normalizeTitle(entityType, title) {
159
+ return `${entityType}::${String(title).trim().toLowerCase().replace(/\s+/g, " ")}`;
160
+ }
161
+ const typedTitleKeys = new Set(typedMarkdownCandidates.map((candidate) => normalizeTitle(String(candidate.entityType || ""), String(candidate.title || ""))));
162
+ for (const c of allCandidates) {
163
+ const record = { ...c };
164
+ const entityType = String(c.entityType || "");
165
+ const title = String(c.title || "");
166
+ const sourceKind = String(c.sourceKind || "");
167
+ const sourcePath = String(c.sourcePath || "");
168
+ const textRef = extractTextRefFromApplyPlan(c.applyPlan);
169
+ const titleKey = normalizeTitle(entityType, title);
170
+ // entity_exists: exact entity ID present in KB
171
+ const upsert = Array.isArray(c.applyPlan) ? c.applyPlan[0] : null;
172
+ let upsertId = "";
173
+ if (upsert && typeof upsert === "object") {
174
+ const upsertRecord = upsert;
175
+ const directId = upsertRecord.id;
176
+ if (typeof directId === "string" && directId.length > 0) {
177
+ upsertId = directId;
178
+ }
179
+ else {
180
+ const properties = upsertRecord.properties;
181
+ if (properties && typeof properties === "object") {
182
+ const nestedId = properties.id;
183
+ if (typeof nestedId === "string" && nestedId.length > 0) {
184
+ upsertId = nestedId;
185
+ }
186
+ }
187
+ }
188
+ }
189
+ if (existingIds.has(upsertId)) {
190
+ suppressed.push(toSuppressedCandidate("entity_exists", record));
191
+ continue;
192
+ }
193
+ if (sourceKind === "generic_markdown" && typedTitleKeys.has(titleKey)) {
194
+ suppressed.push(toSuppressedCandidate("shadowed_by_typed_source", record));
195
+ continue;
196
+ }
197
+ // duplicate_title: same entityType + normalized title
198
+ const existing = seenByKey.get(titleKey);
199
+ if (existing) {
200
+ // keep the higher confidence one
201
+ const existingConf = Number(existing.confidence ?? 0);
202
+ const thisConf = Number(c.confidence ?? 0);
203
+ if (thisConf > existingConf) {
204
+ // move existing to suppressed
205
+ suppressed.push(toSuppressedCandidate("duplicate_title", existing));
206
+ seenByKey.set(titleKey, record);
207
+ }
208
+ else if (thisConf < existingConf) {
209
+ suppressed.push(toSuppressedCandidate("duplicate_title", record));
210
+ }
211
+ else {
212
+ // tie-break by lexicographically smallest sourcePath:textRef
213
+ const existingRef = `${String(existing.sourcePath ?? "")}::${extractTextRefFromApplyPlan(existing.applyPlan)}`;
214
+ const thisRef = `${sourcePath}::${textRef}`;
215
+ if (thisRef < existingRef) {
216
+ suppressed.push(toSuppressedCandidate("duplicate_title", existing));
217
+ seenByKey.set(titleKey, record);
218
+ }
219
+ else {
220
+ suppressed.push(toSuppressedCandidate("duplicate_title", record));
221
+ }
222
+ }
223
+ continue;
224
+ }
225
+ seenByKey.set(titleKey, record);
226
+ }
227
+ const candidateRecords = Array.from(seenByKey.values());
228
+ return {
229
+ content: [
230
+ {
231
+ type: "text",
232
+ text: `Autopilot generated ${allCandidates.length} candidate(s).`,
233
+ },
234
+ ],
235
+ structuredContent: {
236
+ activationState,
237
+ activationReason: activationReasonFor(activationState),
238
+ applyBlocked: activationState === "root_partial",
239
+ discoverySummary: {
240
+ markdownFiles: discovery.markdownFiles.length,
241
+ manifestFiles: discovery.manifestFiles.length,
242
+ vendored: activationDiscovery.summary.vendored ?? [],
243
+ },
244
+ candidates: candidateRecords,
245
+ suppressedCandidates: suppressed,
246
+ payoffSummary: (() => {
247
+ // current counts by type
248
+ const current = {};
249
+ try {
250
+ // compute from existingIds via loadEntities would be expensive; fall back to empty
251
+ }
252
+ catch (e) {
253
+ // noop
254
+ }
255
+ // projected if all applied
256
+ const projected = { ...current };
257
+ for (const r of candidateRecords) {
258
+ const t = String(r.entityType || "unknown");
259
+ projected[t] = (projected[t] || 0) + 1;
260
+ }
261
+ const delta = {};
262
+ for (const k of Object.keys(projected)) {
263
+ const projectedValue = projected[k] ?? 0;
264
+ const currentValue = current[k] ?? 0;
265
+ delta[k] = projectedValue - currentValue;
266
+ }
267
+ return { current, projectedIfAllApplied: projected, delta };
268
+ })(),
269
+ },
270
+ };
271
+ }
@@ -384,6 +384,42 @@ const BASE_TOOLS = [
384
384
  },
385
385
  },
386
386
  },
387
+ {
388
+ name: "kb_autopilot_generate",
389
+ description: "Generate autopilot candidate batches for KB population. Read-only analysis that returns activation state, candidate entities with evidence, payoff summary, and exact applyPlan payloads for later kb_upsert calls. No mutation side effects.",
390
+ inputSchema: {
391
+ type: "object",
392
+ properties: {
393
+ includeGenericMarkdown: {
394
+ type: "boolean",
395
+ default: true,
396
+ description: "Whether to include generic markdown file content as candidate facts. Default: true.",
397
+ },
398
+ minConfidence: {
399
+ type: "number",
400
+ default: 0.8,
401
+ minimum: 0.6,
402
+ maximum: 0.95,
403
+ description: "Minimum confidence threshold for candidates. Clamped to [0.60, 0.95]. Default: 0.80.",
404
+ },
405
+ maxCandidates: {
406
+ type: "integer",
407
+ default: 50,
408
+ minimum: 1,
409
+ maximum: 200,
410
+ description: "Maximum number of candidates to return. Clamped to [1, 200]. Default: 50.",
411
+ },
412
+ entityTypes: {
413
+ type: "array",
414
+ items: {
415
+ type: "string",
416
+ enum: ["req", "scenario", "test", "adr", "fact", "symbol"],
417
+ },
418
+ description: "Optional filter to limit candidate generation to specific entity types.",
419
+ },
420
+ },
421
+ },
422
+ }
387
423
  ];
388
424
  /**
389
425
  * Inject _diagnostic_telemetry schema into tool inputs when diagnostic mode is enabled.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kibi-mcp",
3
- "version": "0.7.1",
3
+ "version": "0.8.0",
4
4
  "dependencies": {
5
5
  "@modelcontextprotocol/sdk": "^1.26.0",
6
6
  "ajv": "^8.18.0",
@@ -9,7 +9,7 @@
9
9
  "fast-glob": "^3.2.12",
10
10
  "gray-matter": "^4.0.3",
11
11
  "js-yaml": "^4.1.0",
12
- "kibi-cli": "^0.6.1",
12
+ "kibi-cli": "^0.6.2",
13
13
  "kibi-core": "^0.5.1",
14
14
  "mcpcat": "^0.1.12",
15
15
  "ts-morph": "^23.0.0",