@kentwynn/kgraph 0.2.29 → 0.2.31

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.
@@ -1,5 +1,5 @@
1
- import { atomToCognitionNote, readKnowledgeAtoms, updateKnowledgeAtom, updateKnowledgeAtoms, } from '../../knowledge/atom-store.js';
2
1
  import { rebuildDomainRecords } from '../../cognition/domain-records.js';
2
+ import { atomToCognitionNote, readKnowledgeAtoms, updateKnowledgeAtom, updateKnowledgeAtoms, } from '../../knowledge/atom-store.js';
3
3
  import { assertWorkspace } from '../../storage/kgraph-paths.js';
4
4
  import { readMaps } from '../../storage/map-store.js';
5
5
  import { KGraphError, runCommand } from '../errors.js';
@@ -109,7 +109,10 @@ export function registerKnowledgeCommand(program) {
109
109
  lifecycle: {
110
110
  ...nextAtoms[newIndex].lifecycle,
111
111
  supersedes: [
112
- ...new Set([...nextAtoms[newIndex].lifecycle.supersedes, oldId]),
112
+ ...new Set([
113
+ ...nextAtoms[newIndex].lifecycle.supersedes,
114
+ oldId,
115
+ ]),
113
116
  ],
114
117
  },
115
118
  };
@@ -144,13 +147,18 @@ function filterAtoms(atoms, options) {
144
147
  return atoms.filter((atom) => {
145
148
  if (options.type && atom.type !== options.type)
146
149
  return false;
147
- if (options.status &&
148
- atom.status !== normalizeStatus(options.status)) {
150
+ if (options.status && atom.status !== normalizeStatus(options.status)) {
149
151
  return false;
150
152
  }
151
- if (options.topic &&
152
- !atom.topic.toLowerCase().includes(options.topic.toLowerCase())) {
153
- return false;
153
+ if (options.topic) {
154
+ const q = options.topic.toLowerCase();
155
+ const matches = atom.topic.toLowerCase().includes(q) ||
156
+ atom.claim.toLowerCase().includes(q) ||
157
+ (atom.summary?.toLowerCase().includes(q) ?? false) ||
158
+ atom.scopeRefs.files.some((f) => f.toLowerCase().includes(q)) ||
159
+ atom.scopeRefs.symbols.some((s) => s.toLowerCase().includes(q));
160
+ if (!matches)
161
+ return false;
154
162
  }
155
163
  return true;
156
164
  });
@@ -1,10 +1,10 @@
1
1
  import path from 'node:path';
2
+ import { loadConfig } from '../../config/config.js';
2
3
  import { buildContextPack } from '../../context/context-pack.js';
3
4
  import { queryContext } from '../../context/context-query.js';
4
- import { loadConfig } from '../../config/config.js';
5
5
  import { assertSessionAgent, recordSessionEvent, } from '../../session/session-store.js';
6
- import { assertWorkspace } from '../../storage/kgraph-paths.js';
7
6
  import { listInboxNotes } from '../../storage/cognition-store.js';
7
+ import { assertWorkspace } from '../../storage/kgraph-paths.js';
8
8
  import { mapsExist, readMaps } from '../../storage/map-store.js';
9
9
  import { KGraphError, runCommand } from '../errors.js';
10
10
  export function registerPackCommand(program) {
@@ -41,7 +41,7 @@ export function registerPackCommand(program) {
41
41
  ]);
42
42
  const response = await queryContext(workspace, config, maps, task);
43
43
  const pack = buildContextPack(response, budget, workspace.rootPath);
44
- const pendingInboxFiles = (await listInboxNotes(workspace)).map((file) => path.relative(workspace.rootPath, file));
44
+ const pendingInboxFiles = (await listInboxNotes(workspace)).map((file) => path.relative(workspace.rootPath, file).split(path.sep).join('/'));
45
45
  if (pendingInboxFiles.length > 0) {
46
46
  pack.pendingInbox = {
47
47
  count: pendingInboxFiles.length,
@@ -122,6 +122,12 @@ function appendGroup(lines, title, items) {
122
122
  lines.push(` range ${data.path}:${data.startLine}-${data.endLine}`);
123
123
  }
124
124
  }
125
+ if (item.kind === 'symbol') {
126
+ const data = item.data;
127
+ if (data.filePath && data.startLine != null && data.endLine != null) {
128
+ lines.push(` ${data.filePath}:${data.startLine}-${data.endLine}`);
129
+ }
130
+ }
125
131
  }
126
132
  if (items.length > 6)
127
133
  lines.push(` ◌ ${items.length - 6} more ${title.toLowerCase()} omitted from display`);
@@ -132,5 +138,7 @@ function formatReasons(reasons) {
132
138
  return 'included by pack ranking';
133
139
  const shown = reasons.slice(0, 3);
134
140
  const remaining = reasons.length - shown.length;
135
- return remaining > 0 ? `${shown.join('; ')}; and ${remaining} more` : shown.join('; ');
141
+ return remaining > 0
142
+ ? `${shown.join('; ')}; and ${remaining} more`
143
+ : shown.join('; ');
136
144
  }
@@ -13,14 +13,7 @@ export function buildContextPack(response, budget, rootPath) {
13
13
  data: ranked.item,
14
14
  })),
15
15
  ...buildFileRangeCandidates(response, budget, rootPath),
16
- ...response.relevantSymbols.map((ranked) => ({
17
- kind: 'symbol',
18
- id: ranked.item.id,
19
- title: ranked.item.name,
20
- tokenEstimate: 20,
21
- reasons: ranked.reasons,
22
- data: ranked.item,
23
- })),
16
+ ...response.relevantSymbols.map((ranked) => buildSymbolCandidate(ranked, rootPath)),
24
17
  ...response.relevantCognition.map((ranked) => ({
25
18
  kind: 'atom',
26
19
  id: ranked.item.id,
@@ -131,14 +124,18 @@ function strongPackPaths(candidates) {
131
124
  function isLowSignalCandidate(candidate, strongPaths) {
132
125
  if (strongPaths.size === 0)
133
126
  return false;
134
- if (candidate.kind === 'atom' || candidate.kind === 'git-change' || candidate.kind === 'file-range') {
127
+ if (candidate.kind === 'atom' ||
128
+ candidate.kind === 'git-change' ||
129
+ candidate.kind === 'file-range') {
135
130
  return false;
136
131
  }
137
132
  if (hasStrongReason(candidate))
138
133
  return false;
139
134
  if (candidateTouchesStrongPath(candidate, strongPaths))
140
135
  return false;
141
- return candidate.kind === 'file' || candidate.kind === 'symbol' || candidate.kind === 'relationship';
136
+ return (candidate.kind === 'file' ||
137
+ candidate.kind === 'symbol' ||
138
+ candidate.kind === 'relationship');
142
139
  }
143
140
  function hasStrongReason(candidate) {
144
141
  return candidate.reasons.some((reason) => reason.includes('matched atom') ||
@@ -170,6 +167,39 @@ const GENERIC_RANGE_TOKENS = new Set([
170
167
  'repo',
171
168
  'work',
172
169
  ]);
170
+ const MAX_SYMBOL_EXCERPT_LINES = 40;
171
+ function buildSymbolCandidate(ranked, rootPath) {
172
+ const symbol = ranked.item;
173
+ let excerpt;
174
+ let tokenEstimate = 20;
175
+ if (rootPath &&
176
+ symbol.startLine != null &&
177
+ symbol.endLine != null &&
178
+ symbol.endLine - symbol.startLine + 1 <= MAX_SYMBOL_EXCERPT_LINES) {
179
+ const fullPath = path.join(rootPath, symbol.filePath);
180
+ if (existsSync(fullPath)) {
181
+ try {
182
+ const content = readFileSync(fullPath, 'utf8');
183
+ const allLines = content.split(/\r?\n/);
184
+ const from = symbol.startLine - 1; // 0-based
185
+ const to = symbol.endLine; // exclusive
186
+ excerpt = allLines.slice(from, to).join('\n');
187
+ tokenEstimate = estimateTokens(excerpt, symbol.filePath);
188
+ }
189
+ catch {
190
+ // best-effort; fall back to default estimate
191
+ }
192
+ }
193
+ }
194
+ return {
195
+ kind: 'symbol',
196
+ id: symbol.id,
197
+ title: symbol.name,
198
+ tokenEstimate,
199
+ reasons: ranked.reasons,
200
+ data: excerpt != null ? { ...symbol, excerpt } : symbol,
201
+ };
202
+ }
173
203
  function buildFileRangeCandidates(response, budget, rootPath) {
174
204
  if (!rootPath)
175
205
  return [];
@@ -12,16 +12,9 @@ ${numberedWorkflow('claude-code', {
12
12
  commandFiles: [
13
13
  {
14
14
  path: '.claude/commands/kgraph.md',
15
- content: `Use KGraph persistent repo intelligence through the single normal \`kgraph "<topic>" --agent claude-code\` entry point.
15
+ content: `## KGraph Workflow
16
16
 
17
- 1. Infer a concise topic from the user's request.
18
- 2. Run exactly one command from the repository root: \`kgraph "<topic>" --agent claude-code\`.
19
- 3. Treat the returned files, symbols, relationships, atoms, and warnings as the first-pass source of truth.
20
- 4. If the user asked for an edit, inspect only the returned candidate file or the smallest necessary range, then make the edit.
21
- 5. Verify the change actually landed before claiming completion. Prefer a narrow read of the changed range or \`git diff -- <path>\`; if there is no diff or the expected text is missing, say the edit did not apply and fix it before summarizing.
22
- 6. Do not run \`kgraph\` again, \`kgraph context\`, \`kgraph pack\`, \`kgraph knowledge\`, \`kgraph stale\`, \`kgraph blame\`, \`kgraph scan\`, \`kgraph update\`, \`kgraph compact\`, or \`kgraph repair\` unless the user explicitly asks for that lower-level command.
23
- 7. Do not continue broad repository search after the target file is identified. If a path must be located, prefer \`rg --files\` and quote paths containing spaces or parentheses.
24
- 8. At the end of repository-file changes, run \`kgraph "<topic>" --final --agent claude-code\`. If KGraph reports capture-required, run \`kgraph "<topic>" --capture "<durable conclusion>" --capture-file <path> --capture-symbol <name> --agent claude-code\`, or explicitly say "No durable knowledge created" only when there is genuinely no reusable knowledge.
17
+ ${numberedWorkflow('claude-code')}
25
18
  `,
26
19
  },
27
20
  {
@@ -8,6 +8,19 @@ export const copilotAdapter = {
8
8
 
9
9
  ${numberedWorkflow('copilot')}
10
10
  `,
11
- commandFiles: agentSkillFiles('copilot'),
11
+ commandFiles: [
12
+ ...agentSkillFiles('copilot'),
13
+ {
14
+ path: '.github/agents/kgraph.agent.md',
15
+ content: `---
16
+ description: Use KGraph persistent repo intelligence — runs kgraph before answering to provide file maps, symbols, relationships, and durable knowledge atoms.
17
+ ---
18
+
19
+ ## KGraph Workflow
20
+
21
+ ${numberedWorkflow('copilot')}
22
+ `,
23
+ },
24
+ ],
12
25
  obsoleteCommandFiles: [],
13
26
  };
@@ -8,8 +8,11 @@ const REPAIR_STEP = `Run \`kgraph repair --dry-run\` before cleanup when stale/n
8
8
  const COMPACT_STEP = `Run \`kgraph compact --dry-run\` when cognition looks duplicated, noisy, or stale. Run \`kgraph compact\` only when the user asks to merge/archive cognition.`;
9
9
  const HISTORY_STEP = `Run \`kgraph history\` or \`kgraph history "<topic>"\` to review past cognition sessions with git author attribution.`;
10
10
  const KNOWLEDGE_STEP = `Run \`kgraph knowledge list --topic "<topic>"\` or \`kgraph knowledge get <atom-id>\` when the user asks what KGraph remembers or atom provenance/lifecycle matters.`;
11
+ function inboxStep(agentName) {
12
+ return `If the previous command output contains "pending inbox" or a \`pendingInbox\` field with count > 0, you MUST run \`kgraph "<topic>" --agent ${agentName}\` or \`kgraph update\` immediately before proceeding. Do not skip this step.`;
13
+ }
11
14
  function packStep(agentName) {
12
- return `For normal coding context, treat \`kgraph pack "<task>" --budget 8000 --json --agent ${agentName}\` as the machine-readable context contract: use atoms, source ranges, git changes, omitted items, and inclusion reasons from the ContextPack before reading files. If the pack reports pending inbox notes, run \`kgraph "<topic>" --agent ${agentName}\` or \`kgraph update\` before relying on history or newly captured atoms.`;
15
+ return `For normal coding context, treat \`kgraph pack "<task>" --budget 8000 --json --agent ${agentName}\` as the machine-readable context contract: use atoms, source ranges, git changes, omitted items, and inclusion reasons from the ContextPack before reading files.`;
13
16
  }
14
17
  function smartRootStep(agentName) {
15
18
  return `Use the root workflow when refresh or memory processing matters: run \`kgraph "<topic>" --agent ${agentName}\` to refresh maps, process inbox notes, and return a briefing; run \`kgraph "<topic>" --final --agent ${agentName}\` before the final answer when repository files changed; run \`kgraph "<topic>" --capture "<durable conclusion>" --capture-file <path> --capture-symbol <name> --agent ${agentName}\` when the final check requires durable knowledge.`;
@@ -29,24 +32,25 @@ function sessionStep(agentName, qualifier) {
29
32
  export function numberedWorkflow(agentName, options = {}) {
30
33
  return `1. Infer the topic from the user's request.
31
34
  2. {{KGRAPH_CONTEXT_POLICY}}
32
- 3. ${ROUTING_STEP}
33
- 4. Use the returned files, symbols, relationships, and cognition before broad exploration.
34
- 5. ${EXPLORATION_BOUNDARY_STEP}
35
- 6. ${VERIFY_EDIT_STEP}
36
- 7. ${smartRootStep(agentName)}
37
- 8. ${packStep(agentName)}
38
- 9. ${KNOWLEDGE_STEP}
39
- 10. ${DOCTOR_STEP}
40
- 11. ${STALE_STEP}
41
- 12. ${sessionStep(agentName, options.sessionQualifier)}
42
- 13. ${IMPACT_STEP}
35
+ 3. ${inboxStep(agentName)}
36
+ 4. ${ROUTING_STEP}
37
+ 5. Use the returned files, symbols, relationships, and cognition before broad exploration.
38
+ 6. ${EXPLORATION_BOUNDARY_STEP}
39
+ 7. ${VERIFY_EDIT_STEP}
40
+ 8. ${smartRootStep(agentName)}
41
+ 9. ${packStep(agentName)}
42
+ 10. ${KNOWLEDGE_STEP}
43
+ 11. ${DOCTOR_STEP}
44
+ 12. ${STALE_STEP}
45
+ 13. ${sessionStep(agentName, options.sessionQualifier)}
46
+ 14. ${IMPACT_STEP}
43
47
 
44
48
  {{KGRAPH_CAPTURE_POLICY}}
45
49
 
46
- 14. ${REPAIR_STEP}
47
- 15. ${COMPACT_STEP}
48
- 16. Run \`kgraph visualize\` when the user wants to inspect the dependency graph — opens an interactive graph at http://localhost:4242 with PNG export.
49
- 17. ${HISTORY_STEP}`;
50
+ 15. ${REPAIR_STEP}
51
+ 16. ${COMPACT_STEP}
52
+ 17. Run \`kgraph visualize\` when the user wants to inspect the dependency graph — opens an interactive graph at http://localhost:4242 with PNG export.
53
+ 18. ${HISTORY_STEP}`;
50
54
  }
51
55
  /**
52
56
  * Returns the bullet-list workflow for rules files.
@@ -54,6 +58,7 @@ export function numberedWorkflow(agentName, options = {}) {
54
58
  */
55
59
  export function bulletWorkflow(agentName, options = {}) {
56
60
  return `- {{KGRAPH_CONTEXT_POLICY}}
61
+ - ${inboxStep(agentName)}
57
62
  - ${ROUTING_STEP}
58
63
  - ${EXPLORATION_BOUNDARY_STEP}
59
64
  - ${VERIFY_EDIT_STEP}
@@ -1,5 +1,5 @@
1
1
  import { randomUUID } from 'node:crypto';
2
- import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
2
+ import { mkdir, readFile, rename, rm, unlink, writeFile, } from 'node:fs/promises';
3
3
  import path from 'node:path';
4
4
  import { setTimeout as delay } from 'node:timers/promises';
5
5
  import { KGraphError } from '../cli/errors.js';
@@ -120,8 +120,7 @@ export async function migrateLegacyCognitionToAtoms(workspace) {
120
120
  const current = await readAtomsFile(workspace);
121
121
  const currentIds = new Set(current.map((atom) => atom.id));
122
122
  const nextMigrated = migrated.filter((atom) => !currentIds.has(atom.id) &&
123
- !current.some((existing) => existing.topic === atom.topic &&
124
- existing.claim === atom.claim));
123
+ !current.some((existing) => existing.topic === atom.topic && existing.claim === atom.claim));
125
124
  if (nextMigrated.length > 0) {
126
125
  await writeKnowledgeAtomsUnlocked(workspace, [
127
126
  ...current,
@@ -206,7 +205,10 @@ export async function refreshKnowledgeAtomStatuses(workspace, maps, dryRun = fal
206
205
  export async function validateKnowledgeStore(workspace, maps) {
207
206
  const issues = [];
208
207
  if (!(await pathExists(schemaPath(workspace)))) {
209
- issues.push({ code: 'missing-schema', message: 'missing knowledge/schema.json' });
208
+ issues.push({
209
+ code: 'missing-schema',
210
+ message: 'missing knowledge/schema.json',
211
+ });
210
212
  }
211
213
  else {
212
214
  try {
@@ -220,7 +222,10 @@ export async function validateKnowledgeStore(workspace, maps) {
220
222
  }
221
223
  catch (error) {
222
224
  const message = error instanceof Error ? error.message : String(error);
223
- issues.push({ code: 'missing-schema', message: `invalid knowledge schema: ${message}` });
225
+ issues.push({
226
+ code: 'missing-schema',
227
+ message: `invalid knowledge schema: ${message}`,
228
+ });
224
229
  }
225
230
  }
226
231
  let atoms = [];
@@ -252,11 +257,15 @@ export async function validateKnowledgeStore(workspace, maps) {
252
257
  });
253
258
  }
254
259
  else if (ref.contentHash && ref.contentHash !== file.contentHash) {
255
- issues.push({
256
- code: 'stale-file-hash',
257
- atomId: atom.id,
258
- message: `${atom.id} references changed file ${ref.path}`,
259
- });
260
+ // Only report if the atom hasn't already been moved to needs-review;
261
+ // needs-review atoms are already caught by the quality gate.
262
+ if (atom.status !== 'needs-review') {
263
+ issues.push({
264
+ code: 'stale-file-hash',
265
+ atomId: atom.id,
266
+ message: `${atom.id} references changed file ${ref.path}; run \`kgraph stale\` to update atom status`,
267
+ });
268
+ }
260
269
  }
261
270
  }
262
271
  if (ref.type === 'symbol') {
@@ -344,7 +353,11 @@ function buildEvidenceRefs(input, maps) {
344
353
  refs.push({ type: 'git', commit: input.commit });
345
354
  }
346
355
  if (input.sessionId || input.agent) {
347
- refs.push({ type: 'session', sessionId: input.sessionId, agent: input.agent });
356
+ refs.push({
357
+ type: 'session',
358
+ sessionId: input.sessionId,
359
+ agent: input.agent,
360
+ });
348
361
  }
349
362
  return refs;
350
363
  }
@@ -373,7 +386,8 @@ function evaluateAtomHealth(refs, maps) {
373
386
  }
374
387
  }
375
388
  if (ref.type === 'symbol') {
376
- const exists = (ref.symbolId && symbolIds.has(ref.symbolId)) || symbolNames.has(ref.name);
389
+ const exists = (ref.symbolId && symbolIds.has(ref.symbolId)) ||
390
+ symbolNames.has(ref.name);
377
391
  if (!exists) {
378
392
  stale = true;
379
393
  reasons.push(`missing symbol:${ref.name}`);
@@ -393,7 +407,8 @@ function computeConfidence(initial, status, atom) {
393
407
  return 'low';
394
408
  if (status === 'needs-review' && initial === 'high')
395
409
  return 'medium';
396
- if (atom?.provenance.sourceCommand === 'legacy-migration' && initial === 'high') {
410
+ if (atom?.provenance.sourceCommand === 'legacy-migration' &&
411
+ initial === 'high') {
397
412
  return 'medium';
398
413
  }
399
414
  return initial;
@@ -518,7 +533,37 @@ async function atomicWriteFile(targetPath, content) {
518
533
  await mkdir(path.dirname(targetPath), { recursive: true });
519
534
  const tempPath = path.join(path.dirname(targetPath), `.${path.basename(targetPath)}.${process.pid}.${randomUUID()}.tmp`);
520
535
  await writeFile(tempPath, content, 'utf8');
521
- await rename(tempPath, targetPath);
536
+ // On Windows, rename over an existing file can transiently fail with EPERM
537
+ // when another handle was recently released. Retry with short delays.
538
+ let lastError;
539
+ for (let attempt = 0; attempt < 3; attempt++) {
540
+ try {
541
+ await rename(tempPath, targetPath);
542
+ return;
543
+ }
544
+ catch (error) {
545
+ const code = error.code;
546
+ if ((code === 'EPERM' || code === 'EACCES') && attempt < 2) {
547
+ lastError = error;
548
+ await delay(20 * (attempt + 1));
549
+ continue;
550
+ }
551
+ try {
552
+ await unlink(tempPath);
553
+ }
554
+ catch {
555
+ /* best-effort cleanup */
556
+ }
557
+ throw error;
558
+ }
559
+ }
560
+ try {
561
+ await unlink(tempPath);
562
+ }
563
+ catch {
564
+ /* best-effort cleanup */
565
+ }
566
+ throw lastError;
522
567
  }
523
568
  async function withKnowledgeWriteLock(workspace, operation) {
524
569
  await mkdir(workspace.knowledgePath, { recursive: true });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kentwynn/kgraph",
3
- "version": "0.2.29",
3
+ "version": "0.2.31",
4
4
  "description": "Persistent repo intelligence for AI coding assistants.",
5
5
  "type": "module",
6
6
  "bin": {