kibi-agent-core 0.2.0 → 0.3.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.
package/CHANGELOG.md CHANGED
@@ -1,11 +1,26 @@
1
1
  # kibi-agent-core
2
2
 
3
+ ## 0.3.0
4
+
5
+ ### Minor Changes
6
+
7
+ - e00adf2: Projects set up with kibi-cli before 2.18.0 keep every symbol on one flow-sequence line of `.kb/symbols.yaml`, and the host hooks read none of it: the Claude Code hooks told agents that no symbol in a linked file was tied to a requirement, on every edit, when the opposite was true. The shared hook scanner now reads flow-layout manifests like block ones, the Claude Code edit hook no longer calls a file unowned when the manifest is unreadable or when the coordinate artifact binds a symbol to it that the scanner could not read (it says once per session that the manifest could not be read instead), and a plain `kibi sync` rewrites such a manifest in block layout once, keeping comments and order.
8
+
9
+ Technical summary: `kibi-agent-core` `knowledge-index` gains a flow-collection reader for `symbols: [ … ]` sections (single or multi-line, CRLF safe) and for flow values inside block records, the index reports `manifest` (`missing` | `empty` | `indexed` | `unreadable`), `coordinateFiles` and `unlinkedFiles`, and the cache format moves to v2 so stale indexes rebuild. `kibi-claude` adds `unreadableManifestNote` (once per session) in place of `unownedSourceNote` for an unreadable manifest or a coordinate-bound file with no indexed record; the Claude and Codex hook bundles are rebuilt. `kibi sync` calls `normalizeManifestLayout` on every tracked symbol manifest ahead of its no-op fast path, through the same `blockifySymbolManifest` helper `kb_upsert`'s manifest writer uses.
10
+
11
+ ## 0.2.1
12
+
13
+ ### Patch Changes
14
+
15
+ - 2a2b2db: Every Kibi package page on npm now has a README that says what the package is for and how to install it, and links to the documentation site. Package metadata now points npm's "Homepage" link at the documentation site, its "Repository" link at the package's own folder on GitHub, and adds an "Issues" link.
16
+
17
+ Adds READMEs to `kibi-cli`, `kibi-mcp`, `kibi-core`, `kibi-runtime`, `kibi-agent-core`, `kibi-codex`, `kibi-plugin-builtin` and `kibi-plugin-sdk`. Sets `homepage` to https://looted.github.io/kibi/, adds `repository.directory` and `bugs` to every package, and refreshes the `kibi-plugin-treesitter` integrity manifest and source-analyzer approval for its changed `package.json`.
18
+
3
19
  ## 0.2.0
4
20
 
5
21
  ### Minor Changes
6
22
 
7
23
  - f8fff87: The telemetry acceptance and remediation reports now show whether agents look requirements up before they change requirement-linked code. A new `lookup_before_first_edit` metric gives, per host session, the share of sessions that ran `kb_search` or `kb_query` (through MCP or the CLI) before their first edit of a file whose symbols implement a requirement. Sessions that edited first appear in the report with the file and its exact `.kb/usage.log` line. The data comes from opt-in hook rows that every Kibi host plugin now writes when `KIBI_DIAGNOSTIC_MODE` is set; nothing is recorded otherwise.
8
-
9
24
  - `kibi usage-metrics` / `kb_check` telemetry acceptance: new metric `lookup_before_first_edit` (threshold `>=` policy `lookupBeforeFirstEditMinimum`, default 1) with evidence `unguidedEditPaths` and `lookupOperations`. It is `not_applicable` when no host hook recorded a requirement-linked edit, so logs without hook rows are judged as before. A failed metric adds the advisory diagnostic `lookup_before_first_edit_bypassed` (rank 35).
10
25
  - `kibi usage-remediation`: one event item per session whose first linked edit had no earlier lookup, pointing at that hook row.
11
26
  - `parseTelemetryUsageLog` still returns only Kibi operation rows; hook rows stay attached to the returned array (read them with `partitionTelemetryUsage`). Remediation `logLine` values now count hook rows and blank lines, so they match the file.
@@ -13,7 +28,6 @@
13
28
  - Claude Code: `edited` rows now carry the file's `requirement_ids`, and every row names its `host`. Cursor (`postToolUse`), Codex and ZCode (`PostToolUse`) and OpenCode (`tool.execute.after`) now write the same `kb_usage` and `edited` rows.
14
29
 
15
30
  - 1012d1c: When an agent edits a file that implements a requirement, every Kibi host plugin (Claude Code, Cursor, Codex, ZCode and OpenCode) now says what that requirement must keep true and the decision behind it, not just its ID. The extra lines come from the requirement's linked facts and ADR, so agents see the constraint before they change the code. All hosts build the snippet with one shared builder in `kibi-agent-core`, so they show the same lines within each host's size limits, and none of them presents a superseded or retired requirement as current.
16
-
17
31
  - New `kibi-agent-core/snippets` export: `fileKnowledgeSnippet`, `requirementGroundingLines`, `editKnowledgeContext`, `editFocus` and `createEntitySummarizer`. Edit snippets add "`<REQ>` must keep true: …" (up to two facts linked via `constrains`, `requires_property`, `requires_predicate` or `requires_rule`) and "Decision: `<ADR>`" for the lead requirement. Read snippets keep the requirement and test lines only.
18
32
  - A superseded, deprecated or retired lead requirement gets no "must keep true" or "Decision" lines, so retired policy is not shown as current.
19
33
  - Claude Code: the `PreToolUse` edit snippet now comes from the shared builder (no change in content).
@@ -27,7 +41,6 @@
27
41
  ### Patch Changes
28
42
 
29
43
  - 15356de: Host hooks no longer ask for a `kb_check` after a `kb_upsert` dry run. A dry run only validates, so the Cursor, Codex, ZCode and OpenCode hooks now count it as validation rather than a KB write.
30
-
31
44
  - `extractKbMcpToolCall` reports `kb_upsert` with `dryRun: true` as `kb_validate_upsert`.
32
45
  - OpenCode's freshness evidence records a dry-run upsert as `kb_validate_upsert`, which carries no mutation evidence.
33
46
 
package/README.md ADDED
@@ -0,0 +1,17 @@
1
+ # kibi-agent-core
2
+
3
+ Shared, low-latency helpers for [Kibi](https://looted.github.io/kibi/) agent-host adapters. The Claude Code, Cursor, Codex, OpenCode and ZCode integrations use it to build the short requirement snippets their hooks show before an agent reads or edits linked code (the requirement, what it must keep true, the decision behind it and its tests), to classify paths, and to log opt-in hook usage.
4
+
5
+ Entry points: `kibi-agent-core`, plus the subpaths `/snippets`, `/knowledge-index`, `/path-policy`, `/kb-mcp-tools` and `/hook-usage-log`.
6
+
7
+ It is an internal building block of the host plugins, not something you install by hand. To use Kibi, see the [quick start](https://looted.github.io/kibi/guide/quick-start.html).
8
+
9
+ ## Links
10
+
11
+ - [Documentation](https://looted.github.io/kibi/)
12
+ - [Source](https://github.com/Looted/kibi)
13
+ - [Issues](https://github.com/Looted/kibi/issues)
14
+
15
+ ## License
16
+
17
+ AGPL-3.0-or-later
@@ -13,10 +13,30 @@ export type IndexedSymbol = {
13
13
  coveredBy: string[];
14
14
  executableFor: string[];
15
15
  };
16
+ /**
17
+ * What the scanner made of `.kb/symbols.yaml`: `missing` (no file), `empty`
18
+ * (no symbol records, such as the `symbols: []` that `kibi init` writes),
19
+ * `indexed` (records were read) or `unreadable` (the file has content the
20
+ * line scanner could not turn into a single record, so an empty index must
21
+ * not be read as "nothing is linked").
22
+ */
23
+ export type ManifestState = "missing" | "empty" | "indexed" | "unreadable";
16
24
  export type KnowledgeIndex = {
17
25
  /** Workspace-relative source file -> symbols with at least one relationship. */
18
26
  files: Record<string, IndexedSymbol[]>;
19
27
  symbolCount: number;
28
+ manifest: ManifestState;
29
+ /**
30
+ * Workspace-relative source files that `.kb/symbol-coordinates.yaml` binds
31
+ * a symbol to, whether or not the manifest record for it could be indexed.
32
+ */
33
+ coordinateFiles: string[];
34
+ /**
35
+ * Workspace-relative source files that have a manifest record but no
36
+ * symbol with a relationship, so an edit hook can tell "not linked yet"
37
+ * from "the record could not be read".
38
+ */
39
+ unlinkedFiles: string[];
20
40
  };
21
41
  type RawRecord = Record<string, unknown>;
22
42
  /**
@@ -27,11 +47,17 @@ export declare function scanSymbolsManifest(text: string): RawRecord[];
27
47
  export type Coordinates = Record<string, {
28
48
  line?: number;
29
49
  endLine?: number;
50
+ sourceFile?: string;
30
51
  }>;
31
- /** Scan `coordinates:` -> `<symbol id>:` -> `sourceLine` / `sourceEndLine`. */
52
+ /** Scan `coordinates:` -> `<symbol id>:` -> `sourceLine` / `sourceEndLine` / `sourceFile`. */
32
53
  export declare function scanSymbolCoordinates(text: string): Coordinates;
54
+ /**
55
+ * Manifest state for a scan result: `unreadable` when the file holds content
56
+ * beyond comments and an empty `symbols` value yet yielded no record.
57
+ */
58
+ export declare function manifestStateOf(text: string | undefined, recordCount: number): ManifestState;
33
59
  /** Collapse raw manifest records and coordinates into the file-keyed index. */
34
- export declare function buildKnowledgeIndex(records: readonly RawRecord[], coordinates?: Coordinates, options?: KnowledgeIndexOptions): KnowledgeIndex;
60
+ export declare function buildKnowledgeIndex(records: readonly RawRecord[], coordinates?: Coordinates, options?: KnowledgeIndexOptions, manifest?: ManifestState): KnowledgeIndex;
35
61
  /**
36
62
  * Load the index for a workspace, reusing the cache in `cacheDir` while both
37
63
  * manifests keep their size and mtime.
@@ -13,14 +13,17 @@ import path from "node:path";
13
13
  * file plus a JSON parse.
14
14
  *
15
15
  * The scanner understands the subset the Kibi writer emits (block mappings and
16
- * sequences, quoted scalars, no line folding) plus hand-authored flow lists
17
- * such as `links: [REQ-001]`. The manifest is a hint for progressive
18
- * disclosure, not the source of truth: snippets always point at kb_query for
19
- * the authoritative view.
16
+ * sequences, quoted scalars, no line folding), flow collections wherever a
17
+ * value starts with `[` or `{` (hand-authored `links: [REQ-001]`, typed
18
+ * `relationships: [{type: implements, target: REQ-1}]`), and a whole manifest
19
+ * written as one flow sequence: kibi-cli before 2.18.0 appended to the
20
+ * `symbols: []` that `kibi init` writes and kept every symbol on that line.
21
+ * The manifest is a hint for progressive disclosure, not the source of truth:
22
+ * snippets always point at kb_query for the authoritative view.
20
23
  */
21
24
  const SYMBOLS_MANIFEST = ".kb/symbols.yaml";
22
25
  const SYMBOL_COORDINATES = ".kb/symbol-coordinates.yaml";
23
- const INDEX_FORMAT = "kibi-agent-core.knowledge-index.v1";
26
+ const INDEX_FORMAT = "kibi-agent-core.knowledge-index.v2";
24
27
  const CACHE_FILE = "knowledge-index.json";
25
28
  function unquote(raw) {
26
29
  const value = raw.trim();
@@ -39,23 +42,157 @@ function unquote(raw) {
39
42
  const comment = value.search(/\s#/);
40
43
  return comment >= 0 ? value.slice(0, comment).trimEnd() : value;
41
44
  }
42
- function parseFlowList(raw) {
43
- const value = raw.trim();
44
- if (!value.startsWith("[") || !value.endsWith("]"))
45
+ // implements REQ-kibi-hooks-flow-manifest
46
+ function parseFlow(text, start = 0) {
47
+ let pos = start;
48
+ const skip = () => {
49
+ while (pos < text.length) {
50
+ const char = text[pos];
51
+ if (char === "#") {
52
+ while (pos < text.length && text[pos] !== "\n")
53
+ pos += 1;
54
+ }
55
+ else if (char === " " ||
56
+ char === "\t" ||
57
+ char === "\n" ||
58
+ char === "\r") {
59
+ pos += 1;
60
+ }
61
+ else {
62
+ break;
63
+ }
64
+ }
65
+ };
66
+ const scalar = (stops) => {
67
+ const quote = text[pos];
68
+ if (quote === '"' || quote === "'") {
69
+ let cursor = pos + 1;
70
+ while (cursor < text.length) {
71
+ if (quote === '"' && text[cursor] === "\\")
72
+ cursor += 2;
73
+ else if (text[cursor] === quote) {
74
+ if (quote === "'" && text[cursor + 1] === "'")
75
+ cursor += 2;
76
+ else
77
+ break;
78
+ }
79
+ else
80
+ cursor += 1;
81
+ }
82
+ if (cursor >= text.length)
83
+ return undefined;
84
+ const raw = text.slice(pos, cursor + 1);
85
+ pos = cursor + 1;
86
+ return unquote(raw);
87
+ }
88
+ let cursor = pos;
89
+ while (cursor < text.length) {
90
+ const char = text[cursor] ?? "";
91
+ if ((stops.includes(char) && char !== ":") ||
92
+ char === "\n" ||
93
+ char === "\r")
94
+ break;
95
+ // A plain mapping key ends at the first `: ` (or `:` before a stop).
96
+ if (stops.includes(":") &&
97
+ char === ":" &&
98
+ (cursor + 1 >= text.length || /[\s,\]}]/.test(text[cursor + 1] ?? "")))
99
+ break;
100
+ if (char === "#" && /\s/.test(text[cursor - 1] ?? ""))
101
+ break;
102
+ cursor += 1;
103
+ }
104
+ const raw = text.slice(pos, cursor).trim();
105
+ pos = cursor;
106
+ return raw;
107
+ };
108
+ const value = () => {
109
+ skip();
110
+ const open = text[pos];
111
+ if (open === "[") {
112
+ pos += 1;
113
+ const items = [];
114
+ for (;;) {
115
+ skip();
116
+ if (text[pos] === "]") {
117
+ pos += 1;
118
+ return items;
119
+ }
120
+ if (pos >= text.length)
121
+ throw new SyntaxError("unterminated flow sequence");
122
+ const item = value();
123
+ if (item !== undefined)
124
+ items.push(item);
125
+ skip();
126
+ if (text[pos] === ",")
127
+ pos += 1;
128
+ else if (text[pos] !== "]")
129
+ throw new SyntaxError("expected , or ]");
130
+ }
131
+ }
132
+ if (open === "{") {
133
+ pos += 1;
134
+ const record = {};
135
+ for (;;) {
136
+ skip();
137
+ if (text[pos] === "}") {
138
+ pos += 1;
139
+ return record;
140
+ }
141
+ if (pos >= text.length)
142
+ throw new SyntaxError("unterminated flow mapping");
143
+ const key = scalar(":,}");
144
+ if (key === undefined)
145
+ throw new SyntaxError("unterminated key");
146
+ skip();
147
+ let entry;
148
+ if (text[pos] === ":") {
149
+ pos += 1;
150
+ skip();
151
+ entry = text[pos] === "," || text[pos] === "}" ? undefined : value();
152
+ }
153
+ if (key.length > 0)
154
+ record[key] = entry;
155
+ skip();
156
+ if (text[pos] === ",")
157
+ pos += 1;
158
+ else if (text[pos] !== "}")
159
+ throw new SyntaxError("expected , or }");
160
+ }
161
+ }
162
+ const raw = scalar(",]}");
163
+ if (raw === undefined)
164
+ throw new SyntaxError("unterminated scalar");
165
+ return raw;
166
+ };
167
+ try {
168
+ const parsed = value();
169
+ return { value: parsed, end: pos };
170
+ }
171
+ catch {
45
172
  return undefined;
46
- const inner = value.slice(1, -1).trim();
47
- if (inner.length === 0)
48
- return [];
49
- return inner
50
- .split(",")
51
- .map(unquote)
52
- .filter((item) => item.length > 0);
173
+ }
174
+ }
175
+ /** True when `raw` opens a flow collection the rest of the line does not close. */
176
+ function opensFlow(raw) {
177
+ const value = raw.trim();
178
+ return value.startsWith("[") || value.startsWith("{");
53
179
  }
54
180
  const keyValue = /^([A-Za-z0-9_"'-][^:]*?):(?:\s+(.*))?$/;
181
+ /** The flow value `raw` holds, or undefined when it is not a complete one. */
182
+ function completeFlowValue(raw) {
183
+ if (!opensFlow(raw))
184
+ return undefined;
185
+ const parsed = parseFlow(raw);
186
+ if (parsed === undefined)
187
+ return undefined;
188
+ const rest = raw.slice(parsed.end).trim();
189
+ // `[WIP] fix rounding` is a plain scalar that merely starts with `[`.
190
+ return rest.length === 0 || rest.startsWith("#") ? parsed : undefined;
191
+ }
55
192
  function parseScalarOrFlow(raw) {
56
193
  if (raw === undefined || raw.trim().length === 0)
57
194
  return undefined;
58
- return parseFlowList(raw) ?? unquote(raw);
195
+ return completeFlowValue(raw)?.value ?? unquote(raw);
59
196
  }
60
197
  /**
61
198
  * Scan the symbols manifest into raw records. Accepts both the canonical
@@ -64,11 +201,65 @@ function parseScalarOrFlow(raw) {
64
201
  export function scanSymbolsManifest(text) {
65
202
  return scanRecordSequence(text, "symbols");
66
203
  }
204
+ function unbalancedFlow(raw) {
205
+ return opensFlow(raw) && parseFlow(raw) === undefined;
206
+ }
207
+ /** Records of a manifest whose `<sectionKey>:` value (or whole root) is a flow sequence. */
208
+ function flowRecords(text, start) {
209
+ const parsed = parseFlow(text, start);
210
+ if (!Array.isArray(parsed?.value))
211
+ return undefined;
212
+ return parsed.value.filter((item) => item !== null && typeof item === "object" && !Array.isArray(item));
213
+ }
214
+ /**
215
+ * Records of a manifest whose `<sectionKey>:` value (or whole root) is a flow
216
+ * sequence, possibly spanning several lines; undefined when the section is
217
+ * block-style or absent.
218
+ */
219
+ // implements REQ-kibi-hooks-flow-manifest
220
+ function scanFlowSection(text, sectionKey) {
221
+ const escapedKey = sectionKey.replaceAll(/[.*+?^$(){}|[\]\\]/g, "\\$&");
222
+ const header = new RegExp(`^${escapedKey}:[ \\t]*(\\[)?`);
223
+ let offset = 0;
224
+ let first = true;
225
+ let afterHeader = false;
226
+ // Keep the separators so offsets stay exact for CRLF files.
227
+ for (const piece of text.split(/(\r?\n)/)) {
228
+ const content = piece.trim();
229
+ const indent = piece.length - piece.trimStart().length;
230
+ if (content.length > 0 && !content.startsWith("#")) {
231
+ if (afterHeader) {
232
+ // `symbols:` with its value on a later line: flow only when that
233
+ // line opens the sequence.
234
+ return content.startsWith("[")
235
+ ? flowRecords(text, offset + piece.indexOf("["))
236
+ : undefined;
237
+ }
238
+ if (first && content.startsWith("[")) {
239
+ return flowRecords(text, offset + piece.indexOf("["));
240
+ }
241
+ first = false;
242
+ if (indent === 0) {
243
+ const match = header.exec(piece);
244
+ if (match) {
245
+ if (match[1] !== undefined)
246
+ return flowRecords(text, offset + piece.indexOf("["));
247
+ afterHeader = true;
248
+ }
249
+ }
250
+ }
251
+ offset += piece.length;
252
+ }
253
+ return undefined;
254
+ }
67
255
  /**
68
256
  * Scan the block sequence under top-level `sectionKey` (or a bare root
69
257
  * sequence) into raw records.
70
258
  */
71
259
  function scanRecordSequence(text, sectionKey) {
260
+ const flowSection = scanFlowSection(text, sectionKey);
261
+ if (flowSection !== undefined)
262
+ return flowSection;
72
263
  const records = [];
73
264
  let itemIndent;
74
265
  let keyIndent = 0;
@@ -78,12 +269,31 @@ function scanRecordSequence(text, sectionKey) {
78
269
  // Top-level section: records are the `<sectionKey>:` sequence or a bare
79
270
  // root sequence; sequences under any other top-level key are ignored.
80
271
  let section = "root";
272
+ // A flow value (`relationships: [` …) that the writer wrapped over several
273
+ // lines is collected until its brackets balance, then parsed as one value.
274
+ let pendingFlow;
81
275
  for (const rawLine of text.split(/\r?\n/)) {
82
276
  const content = rawLine.trim();
83
277
  if (content.length === 0 || content.startsWith("#"))
84
278
  continue;
85
279
  const indent = rawLine.length - rawLine.trimStart().length;
86
280
  const dash = /^(\s*)-(\s+)(.*)$/.exec(rawLine);
281
+ if (pendingFlow !== undefined && current !== undefined) {
282
+ const target = pendingFlow.onListItem && listItem !== undefined ? listItem : current;
283
+ if (indent > (itemIndent ?? 0) && !(dash && indent === itemIndent)) {
284
+ pendingFlow.text += `\n${content}`;
285
+ const parsed = parseFlow(pendingFlow.text);
286
+ if (parsed === undefined)
287
+ continue;
288
+ target[pendingFlow.key] = parsed.value;
289
+ pendingFlow = undefined;
290
+ continue;
291
+ }
292
+ // The next record or section started before the brackets balanced:
293
+ // keep the opening line as a plain scalar and read on.
294
+ target[pendingFlow.key] = unquote(pendingFlow.first);
295
+ pendingFlow = undefined;
296
+ }
87
297
  if (indent === 0 && !dash) {
88
298
  const pair = keyValue.exec(content);
89
299
  section =
@@ -105,11 +315,21 @@ function scanRecordSequence(text, sectionKey) {
105
315
  listItem = undefined;
106
316
  const pair = keyValue.exec(dash[3] ?? "");
107
317
  if (pair?.[1] !== undefined) {
318
+ const key = unquote(pair[1]);
319
+ if (pair[2] !== undefined && unbalancedFlow(pair[2])) {
320
+ pendingFlow = {
321
+ key,
322
+ first: pair[2],
323
+ text: pair[2],
324
+ onListItem: false,
325
+ };
326
+ continue;
327
+ }
108
328
  const value = parseScalarOrFlow(pair[2]);
109
329
  if (value !== undefined)
110
- current[unquote(pair[1])] = value;
330
+ current[key] = value;
111
331
  else
112
- listKey = unquote(pair[1]);
332
+ listKey = key;
113
333
  }
114
334
  continue;
115
335
  }
@@ -124,14 +344,21 @@ function scanRecordSequence(text, sectionKey) {
124
344
  ? current[listKey]
125
345
  : [];
126
346
  current[listKey] = list;
127
- const pair = keyValue.exec(dash[3] ?? "");
128
- if (pair?.[1] !== undefined && pair[2] !== undefined) {
129
- listItem = { [unquote(pair[1])]: unquote(pair[2]) };
347
+ const item = dash[3] ?? "";
348
+ const flowItem = completeFlowValue(item);
349
+ const pair = flowItem === undefined ? keyValue.exec(item) : null;
350
+ if (flowItem !== undefined) {
351
+ // `- { type: implements, target: REQ-1 }`
352
+ listItem = undefined;
353
+ list.push(flowItem.value);
354
+ }
355
+ else if (pair?.[1] !== undefined && pair[2] !== undefined) {
356
+ listItem = { [unquote(pair[1])]: parseScalarOrFlow(pair[2]) };
130
357
  list.push(listItem);
131
358
  }
132
359
  else {
133
360
  listItem = undefined;
134
- list.push(unquote(dash[3] ?? ""));
361
+ list.push(unquote(item));
135
362
  }
136
363
  continue;
137
364
  }
@@ -141,6 +368,16 @@ function scanRecordSequence(text, sectionKey) {
141
368
  const key = unquote(pair[1]);
142
369
  if (indent === keyIndent) {
143
370
  listItem = undefined;
371
+ if (pair[2] !== undefined && unbalancedFlow(pair[2])) {
372
+ pendingFlow = {
373
+ key,
374
+ first: pair[2],
375
+ text: pair[2],
376
+ onListItem: false,
377
+ };
378
+ listKey = undefined;
379
+ continue;
380
+ }
144
381
  const value = parseScalarOrFlow(pair[2]);
145
382
  if (value === undefined) {
146
383
  listKey = key;
@@ -151,12 +388,16 @@ function scanRecordSequence(text, sectionKey) {
151
388
  }
152
389
  }
153
390
  else if (listItem !== undefined && pair[2] !== undefined) {
154
- listItem[key] = unquote(pair[2]);
391
+ if (unbalancedFlow(pair[2])) {
392
+ pendingFlow = { key, first: pair[2], text: pair[2], onListItem: true };
393
+ continue;
394
+ }
395
+ listItem[key] = parseScalarOrFlow(pair[2]);
155
396
  }
156
397
  }
157
398
  return records;
158
399
  }
159
- /** Scan `coordinates:` -> `<symbol id>:` -> `sourceLine` / `sourceEndLine`. */
400
+ /** Scan `coordinates:` -> `<symbol id>:` -> `sourceLine` / `sourceEndLine` / `sourceFile`. */
160
401
  export function scanSymbolCoordinates(text) {
161
402
  const coordinates = {};
162
403
  let inCoordinates = false;
@@ -188,6 +429,13 @@ export function scanSymbolCoordinates(text) {
188
429
  if (currentId === undefined || indent < idIndent)
189
430
  continue;
190
431
  const entry = coordinates[currentId] ?? {};
432
+ if (key === "sourceFile") {
433
+ const sourceFile = normalizeSourceFile(unquote(pair[2] ?? ""));
434
+ if (sourceFile.length > 0)
435
+ entry.sourceFile = sourceFile;
436
+ coordinates[currentId] = entry;
437
+ continue;
438
+ }
191
439
  const numeric = Number.parseInt(unquote(pair[2] ?? ""), 10);
192
440
  if (!Number.isFinite(numeric))
193
441
  continue;
@@ -207,18 +455,49 @@ function pushUnique(list, value) {
207
455
  if (!list.includes(value))
208
456
  list.push(value);
209
457
  }
458
+ function normalizeSourceFile(value) {
459
+ return value.replaceAll("\\", "/").replace(/^\.\//, "");
460
+ }
461
+ /**
462
+ * Manifest state for a scan result: `unreadable` when the file holds content
463
+ * beyond comments and an empty `symbols` value yet yielded no record.
464
+ */
465
+ // implements REQ-kibi-hooks-flow-manifest
466
+ export function manifestStateOf(text, recordCount) {
467
+ if (text === undefined)
468
+ return "missing";
469
+ if (recordCount > 0)
470
+ return "indexed";
471
+ const meaningful = text
472
+ .split(/\r?\n/)
473
+ .map((line) => line.trim())
474
+ .filter((line) => line.length > 0 && !line.startsWith("#"))
475
+ .filter((line) => !/^symbols:\s*(\[\s*\])?$/.test(line))
476
+ .filter((line) => line !== "[]")
477
+ // Document markers and a version stamp do not make a manifest unreadable.
478
+ .filter((line) => !/^(?:---|\.\.\.|%\S.*|version:\s*\S+)$/.test(line));
479
+ return meaningful.length === 0 ? "empty" : "unreadable";
480
+ }
210
481
  /** Collapse raw manifest records and coordinates into the file-keyed index. */
211
- export function buildKnowledgeIndex(records, coordinates = {}, options = {}) {
482
+ export function buildKnowledgeIndex(records, coordinates = {}, options = {}, manifest = records.length > 0 ? "indexed" : "empty") {
212
483
  const files = {};
213
484
  let symbolCount = 0;
485
+ const recordedFiles = new Set();
486
+ const coordinateFiles = [];
487
+ for (const entry of Object.values(coordinates)) {
488
+ if (entry.sourceFile !== undefined)
489
+ pushUnique(coordinateFiles, entry.sourceFile);
490
+ }
491
+ coordinateFiles.sort();
214
492
  for (const record of records) {
215
493
  const id = typeof record.id === "string" ? record.id : undefined;
216
494
  const sourceFile = typeof record.sourceFile === "string"
217
- ? record.sourceFile.replaceAll("\\", "/").replace(/^\.\//, "")
495
+ ? normalizeSourceFile(record.sourceFile)
218
496
  : undefined;
219
497
  if (!id || !sourceFile)
220
498
  continue;
221
499
  symbolCount += 1;
500
+ recordedFiles.add(sourceFile);
222
501
  const symbol = {
223
502
  id,
224
503
  title: typeof record.title === "string" ? record.title : id,
@@ -281,7 +560,10 @@ export function buildKnowledgeIndex(records, coordinates = {}, options = {}) {
281
560
  bucket.push(symbol);
282
561
  files[sourceFile] = bucket;
283
562
  }
284
- return { files, symbolCount };
563
+ const unlinkedFiles = [...recordedFiles]
564
+ .filter((file) => files[file] === undefined)
565
+ .sort();
566
+ return { files, symbolCount, manifest, coordinateFiles, unlinkedFiles };
285
567
  }
286
568
  function fileSignature(filePath) {
287
569
  try {
@@ -337,9 +619,8 @@ export function loadKnowledgeIndex(workspaceRoot, cacheDir, options = {}) {
337
619
  }
338
620
  }
339
621
  const symbolsText = readText(symbolsPath);
340
- const index = symbolsText === undefined
341
- ? { files: {}, symbolCount: 0 }
342
- : buildKnowledgeIndex(scanSymbolsManifest(symbolsText), scanSymbolCoordinates(readText(coordinatesPath) ?? ""), options);
622
+ const records = symbolsText === undefined ? [] : scanSymbolsManifest(symbolsText);
623
+ const index = buildKnowledgeIndex(records, scanSymbolCoordinates(readText(coordinatesPath) ?? ""), options, manifestStateOf(symbolsText, records.length));
343
624
  if (cachePath) {
344
625
  writeCacheAtomically(cachePath, JSON.stringify({ signature, index }));
345
626
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kibi-agent-core",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Shared low-latency hook helpers for Kibi agent adapters",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -31,10 +31,7 @@
31
31
  "default": "./dist/snippets.js"
32
32
  }
33
33
  },
34
- "files": [
35
- "dist",
36
- "CHANGELOG.md"
37
- ],
34
+ "files": ["dist", "CHANGELOG.md"],
38
35
  "scripts": {
39
36
  "build": "tsc -p tsconfig.json",
40
37
  "typecheck": "tsc -p tsconfig.json --noEmit",
@@ -45,9 +42,14 @@
45
42
  },
46
43
  "license": "AGPL-3.0-or-later",
47
44
  "author": "Piotr Franczyk",
45
+ "homepage": "https://looted.github.io/kibi/",
48
46
  "repository": {
49
47
  "type": "git",
50
- "url": "https://github.com/Looted/kibi.git"
48
+ "url": "https://github.com/Looted/kibi.git",
49
+ "directory": "packages/agent-core"
50
+ },
51
+ "bugs": {
52
+ "url": "https://github.com/Looted/kibi/issues"
51
53
  },
52
54
  "publishConfig": {
53
55
  "access": "public"