@kentwynn/kgraph 0.2.27 → 0.2.29

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/README.md CHANGED
@@ -163,10 +163,12 @@ kgraph doctor
163
163
 
164
164
  After useful AI work, assistants save durable runtime-capture notes into `.kgraph/inbox/`. These notes are not project documentation; they are KGraph input files that the next `kgraph` run processes automatically. You can also process them directly with `kgraph update`.
165
165
 
166
- Normal agent flow is intentionally small:
166
+ Normal agent flow is intentionally small. For coding context, agents use the
167
+ machine-readable pack. When memory refresh or inbox processing matters, they use
168
+ the root workflow or `kgraph update`.
167
169
 
168
170
  ```bash
169
- kgraph "topic"
171
+ kgraph pack "topic" --budget 8000 --json --agent codex
170
172
  # work normally
171
173
  kgraph "topic" --final --agent codex
172
174
  # if final check requires capture:
@@ -175,6 +177,10 @@ kgraph "topic" --capture "durable conclusion" --capture-file path/to/file.ts --c
175
177
 
176
178
  `kgraph "<topic>"` is the smart root workflow: it refreshes maps, processes capture notes, reports memory health, and returns focused context. Agents can pass `--agent <name>` to record a lightweight session context event. Agents can still use `kgraph pack "<topic>" --budget 8000 --json --agent <name>` when they need the stable machine-readable `ContextPack` contract with atoms, source ranges, git changes, omitted items, token estimates, and inclusion reasons.
177
179
 
180
+ `pack` does not process `.kgraph/inbox/`. If a pack reports pending inbox notes,
181
+ run `kgraph "<topic>" --agent <name>` or `kgraph update` before relying on
182
+ `kgraph history` or newly captured atoms.
183
+
178
184
  Use `kgraph doctor` after setup and before trusting a repo's saved intelligence. It checks initialization, maps, pending inbox notes, integration targets, and actionable quality problems. Use `kgraph doctor --quality` and `kgraph repair --dry-run` when stale or noisy atom references start making context harder to trust.
179
185
 
180
186
  Agents can also report session activity so KGraph can estimate token waste:
@@ -381,12 +387,12 @@ kgraph integrate list
381
387
  kgraph integrate remove cursor
382
388
  ```
383
389
 
384
- New integrations default to `always` mode, so every chat in the repository starts with `kgraph "<topic>"`. Use `--mode smart` to run KGraph only for repo-specific work, or `--mode manual` to run only when explicitly asked.
390
+ New integrations default to `always` mode, so every chat in the repository starts with the matching KGraph command. Use `--mode smart` to run KGraph only for repo-specific work, or `--mode manual` to run only when explicitly asked.
385
391
 
386
392
  | Mode | Behavior |
387
393
  | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
388
- | `always` | Every chat in the repository starts with `kgraph "<topic>"`, even simple or conversational requests. |
389
- | `smart` | Runs KGraph automatically for repo-specific coding, debugging, architecture, refactor, review, or file-exploration requests. Skips simple conversational requests that do not depend on repo knowledge. |
394
+ | `always` | Every chat in the repository starts with the matching KGraph command. Normal coding context uses `kgraph pack`; history, inbox/update, knowledge, and doctor requests route to their specific commands. |
395
+ | `smart` | Runs the matching KGraph command automatically for repo-specific coding, debugging, architecture, refactor, review, history, inbox/update, knowledge, health, or file-exploration requests. Skips simple conversational requests that do not depend on repo knowledge. |
390
396
  | `manual` | Exposes KGraph commands and instructions, but the agent runs KGraph only when the user explicitly asks. |
391
397
  | `off` | Disables that integration and removes generated KGraph instruction blocks/command files. |
392
398
 
@@ -1,4 +1,4 @@
1
- import { atomToCognitionNote, readKnowledgeAtoms, updateKnowledgeAtom, } from '../../knowledge/atom-store.js';
1
+ import { atomToCognitionNote, readKnowledgeAtoms, updateKnowledgeAtom, updateKnowledgeAtoms, } from '../../knowledge/atom-store.js';
2
2
  import { rebuildDomainRecords } from '../../cognition/domain-records.js';
3
3
  import { assertWorkspace } from '../../storage/kgraph-paths.js';
4
4
  import { readMaps } from '../../storage/map-store.js';
@@ -82,28 +82,43 @@ export function registerKnowledgeCommand(program) {
82
82
  .option('--json', 'Print JSON output')
83
83
  .action((oldId, newId, options) => runCommand(async () => {
84
84
  const workspace = await assertWorkspace(process.cwd());
85
- await requireAtom(workspace, newId);
86
85
  const now = new Date().toISOString();
87
- const oldAtom = await updateKnowledgeAtom(workspace, oldId, (current) => ({
88
- ...current,
89
- status: 'archived',
90
- provenance: { ...current.provenance, updatedAt: now },
91
- lifecycle: {
92
- ...current.lifecycle,
93
- supersededBy: newId,
94
- archivedAt: now,
95
- },
96
- }));
97
- const newAtom = await updateKnowledgeAtom(workspace, newId, (current) => ({
98
- ...current,
99
- provenance: { ...current.provenance, updatedAt: now },
100
- lifecycle: {
101
- ...current.lifecycle,
102
- supersedes: [...new Set([...current.lifecycle.supersedes, oldId])],
103
- },
104
- }));
86
+ const result = await updateKnowledgeAtoms(workspace, (atoms) => {
87
+ const oldIndex = atoms.findIndex((atom) => atom.id === oldId);
88
+ const newIndex = atoms.findIndex((atom) => atom.id === newId);
89
+ if (oldIndex === -1) {
90
+ throw new KGraphError(`Knowledge atom not found: ${oldId}`);
91
+ }
92
+ if (newIndex === -1) {
93
+ throw new KGraphError(`Knowledge atom not found: ${newId}`);
94
+ }
95
+ const nextAtoms = [...atoms];
96
+ nextAtoms[oldIndex] = {
97
+ ...nextAtoms[oldIndex],
98
+ status: 'archived',
99
+ provenance: { ...nextAtoms[oldIndex].provenance, updatedAt: now },
100
+ lifecycle: {
101
+ ...nextAtoms[oldIndex].lifecycle,
102
+ supersededBy: newId,
103
+ archivedAt: now,
104
+ },
105
+ };
106
+ nextAtoms[newIndex] = {
107
+ ...nextAtoms[newIndex],
108
+ provenance: { ...nextAtoms[newIndex].provenance, updatedAt: now },
109
+ lifecycle: {
110
+ ...nextAtoms[newIndex].lifecycle,
111
+ supersedes: [
112
+ ...new Set([...nextAtoms[newIndex].lifecycle.supersedes, oldId]),
113
+ ],
114
+ },
115
+ };
116
+ return {
117
+ atoms: nextAtoms,
118
+ result: { old: nextAtoms[oldIndex], new: nextAtoms[newIndex] },
119
+ };
120
+ });
105
121
  await rebuildActiveDomainRecords(workspace);
106
- const result = { old: oldAtom, new: newAtom };
107
122
  console.log(options.json
108
123
  ? JSON.stringify(result, null, 2)
109
124
  : `Superseded ${oldId} with ${newId}`);
@@ -1,8 +1,10 @@
1
+ import path from 'node:path';
1
2
  import { buildContextPack } from '../../context/context-pack.js';
2
3
  import { queryContext } from '../../context/context-query.js';
3
4
  import { loadConfig } from '../../config/config.js';
4
5
  import { assertSessionAgent, recordSessionEvent, } from '../../session/session-store.js';
5
6
  import { assertWorkspace } from '../../storage/kgraph-paths.js';
7
+ import { listInboxNotes } from '../../storage/cognition-store.js';
6
8
  import { mapsExist, readMaps } from '../../storage/map-store.js';
7
9
  import { KGraphError, runCommand } from '../errors.js';
8
10
  export function registerPackCommand(program) {
@@ -39,6 +41,17 @@ export function registerPackCommand(program) {
39
41
  ]);
40
42
  const response = await queryContext(workspace, config, maps, task);
41
43
  const pack = buildContextPack(response, budget, workspace.rootPath);
44
+ const pendingInboxFiles = (await listInboxNotes(workspace)).map((file) => path.relative(workspace.rootPath, file));
45
+ if (pendingInboxFiles.length > 0) {
46
+ pack.pendingInbox = {
47
+ count: pendingInboxFiles.length,
48
+ files: pendingInboxFiles,
49
+ };
50
+ pack.warnings = [
51
+ ...pack.warnings,
52
+ `Pending inbox notes are not processed by pack. Run \`kgraph "${task}"${agent ? ` --agent ${agent}` : ''}\` or \`kgraph update\` before relying on history or newly captured atoms.`,
53
+ ];
54
+ }
42
55
  if (options.json) {
43
56
  console.log(JSON.stringify(pack, null, 2));
44
57
  return;
@@ -69,6 +82,9 @@ export function renderPackText(pack) {
69
82
  ` omitted ${pack.omitted.length}`,
70
83
  ``,
71
84
  ];
85
+ if (pack.pendingInbox && pack.pendingInbox.count > 0) {
86
+ lines.push(`● Pending Inbox`, ` ${pack.pendingInbox.count} note${pack.pendingInbox.count === 1 ? '' : 's'} waiting; pack does not process inbox notes`, ` run kgraph "${pack.task}" or kgraph update before relying on history/new atoms`, ``);
87
+ }
72
88
  appendGroup(lines, 'Atoms', pack.items.filter((item) => item.kind === 'atom'));
73
89
  appendGroup(lines, 'Git Changes', pack.items.filter((item) => item.kind === 'git-change'));
74
90
  appendGroup(lines, 'Source Ranges', pack.items.filter((item) => item.kind === 'file-range'));
@@ -1,10 +1,11 @@
1
- import { rm } from 'node:fs/promises';
1
+ import { readdir, rm, rmdir } from 'node:fs/promises';
2
2
  import path from 'node:path';
3
3
  import { loadConfig } from '../../config/config.js';
4
4
  import { removeIntegrations } from '../../integrations/integration-store.js';
5
5
  import { pathExists, resolveWorkspace } from '../../storage/kgraph-paths.js';
6
6
  import { runCommand } from '../errors.js';
7
7
  const LEGACY_GENERATED_FILES = [
8
+ '.agents/generated/kgraph.md',
8
9
  '.github/agents/kgraph.agent.md',
9
10
  '.github/kgraph.agent.md',
10
11
  ];
@@ -44,7 +45,26 @@ export function registerUninstallCommand(program) {
44
45
  }));
45
46
  }
46
47
  async function removeLegacyGeneratedFiles(rootPath) {
47
- await Promise.all(LEGACY_GENERATED_FILES.map((filePath) => rm(path.join(rootPath, filePath), { force: true })));
48
+ for (const filePath of LEGACY_GENERATED_FILES) {
49
+ const fullPath = path.join(rootPath, filePath);
50
+ await rm(fullPath, { force: true });
51
+ await pruneEmptyParents(rootPath, path.dirname(fullPath));
52
+ }
53
+ }
54
+ async function pruneEmptyParents(rootPath, startDir) {
55
+ let dir = startDir;
56
+ while (dir !== rootPath && dir.startsWith(rootPath)) {
57
+ try {
58
+ const entries = await readdir(dir);
59
+ if (entries.length > 0)
60
+ break;
61
+ await rmdir(dir);
62
+ dir = path.dirname(dir);
63
+ }
64
+ catch {
65
+ break;
66
+ }
67
+ }
48
68
  }
49
69
  function printUninstallPreview(input) {
50
70
  console.log('KGraph Uninstall Preview');
@@ -227,7 +227,24 @@ function atomsHaveReplacementSignal(a, b) {
227
227
  bSymbols.add(ref.name);
228
228
  }
229
229
  const symbolOverlap = [...aSymbols].some((symbol) => bSymbols.has(symbol));
230
- return symbolOverlap || meaningfulTopicOverlap(a.topic, b.topic);
230
+ if (symbolOverlap)
231
+ return true;
232
+ if (!atomsShareFile(a, b))
233
+ return false;
234
+ return meaningfulTopicOverlap(a.topic, b.topic);
235
+ }
236
+ function atomsShareFile(a, b) {
237
+ const aFiles = new Set(a.scopeRefs.files);
238
+ for (const ref of a.evidenceRefs) {
239
+ if (ref.type === 'file')
240
+ aFiles.add(ref.path);
241
+ }
242
+ const bFiles = new Set(b.scopeRefs.files);
243
+ for (const ref of b.evidenceRefs) {
244
+ if (ref.type === 'file')
245
+ bFiles.add(ref.path);
246
+ }
247
+ return [...aFiles].some((file) => bFiles.has(file));
231
248
  }
232
249
  function meaningfulTopicOverlap(a, b) {
233
250
  const weakTokens = new Set([
@@ -239,6 +256,10 @@ function meaningfulTopicOverlap(a, b) {
239
256
  'new',
240
257
  'old',
241
258
  'review',
259
+ 'check',
260
+ 'current',
261
+ 'session',
262
+ 'smoke',
242
263
  'update',
243
264
  'with',
244
265
  ]);
@@ -13,7 +13,7 @@ export async function analyzeCognitionQuality(workspace, maps) {
13
13
  .map((note) => analyzeNote(note, maps))
14
14
  .filter((change) => change.removedFileRefs.length > 0 ||
15
15
  change.removedSymbolRefs.length > 0);
16
- const orphanedNoteCount = notes.filter((note) => note.referencesStatus === 'stale').length;
16
+ const orphanedNoteCount = notes.filter((note) => analyzeNote(note, maps).nextStatus === 'stale').length;
17
17
  return {
18
18
  atomCount: activeAtoms.length,
19
19
  staleAtomCount: activeAtoms.filter((atom) => atom.status === 'stale').length,
@@ -64,54 +64,55 @@ export async function repairCognition(workspace, maps, dryRun = false) {
64
64
  }
65
65
  // Archive fully-orphaned notes (all refs dead) so they no longer appear in context
66
66
  const orphanedNotes = nextNotes.filter((note) => note.referencesStatus === 'stale');
67
- if (!dryRun && (changes.length > 0 || orphanedNotes.length > 0)) {
68
- const now = new Date().toISOString();
69
- const nextAtoms = atoms.map((atom) => {
70
- if (atom.status === 'archived')
71
- return atom;
72
- const change = changesById.get(atom.id);
73
- if (!change && !orphanedNotes.some((note) => note.id === atom.id)) {
74
- return atom;
75
- }
76
- if (orphanedNotes.some((note) => note.id === atom.id)) {
77
- return {
78
- ...atom,
79
- status: 'archived',
80
- confidence: 'low',
81
- lifecycle: { ...atom.lifecycle, archivedAt: now },
82
- provenance: { ...atom.provenance, updatedAt: now },
83
- };
84
- }
85
- const removedFiles = new Set(change?.removedFileRefs ?? []);
86
- const removedSymbols = new Set(change?.removedSymbolRefs ?? []);
87
- const nextStatus = atomStatusFromReferenceStatus(change?.nextStatus ?? 'current');
67
+ const now = new Date().toISOString();
68
+ const orphanedNoteIds = new Set(orphanedNotes.map((note) => note.id));
69
+ const nextAtoms = atoms.map((atom) => {
70
+ if (atom.status === 'archived')
71
+ return atom;
72
+ const change = changesById.get(atom.id);
73
+ if (!change && !orphanedNoteIds.has(atom.id)) {
74
+ return atom;
75
+ }
76
+ if (orphanedNoteIds.has(atom.id)) {
88
77
  return {
89
78
  ...atom,
90
- status: nextStatus,
91
- confidence: atom.confidence === 'low' && atom.status === 'stale' && nextStatus !== 'stale'
92
- ? 'medium'
93
- : atom.confidence,
94
- scopeRefs: {
95
- ...atom.scopeRefs,
96
- files: atom.scopeRefs.files.filter((file) => !removedFiles.has(file)),
97
- symbols: atom.scopeRefs.symbols.filter((symbol) => !removedSymbols.has(symbol)),
98
- },
99
- evidenceRefs: atom.evidenceRefs.filter((ref) => {
100
- if (ref.type === 'file')
101
- return !removedFiles.has(ref.path);
102
- if (ref.type === 'symbol')
103
- return !removedSymbols.has(ref.name);
104
- return true;
105
- }),
106
- lifecycle: {
107
- ...atom.lifecycle,
108
- invalidatedBy: change?.nextStatus === 'current'
109
- ? undefined
110
- : atom.lifecycle.invalidatedBy,
111
- },
79
+ status: 'archived',
80
+ confidence: 'low',
81
+ lifecycle: { ...atom.lifecycle, archivedAt: now },
112
82
  provenance: { ...atom.provenance, updatedAt: now },
113
83
  };
114
- });
84
+ }
85
+ const removedFiles = new Set(change?.removedFileRefs ?? []);
86
+ const removedSymbols = new Set(change?.removedSymbolRefs ?? []);
87
+ const nextStatus = atomStatusFromReferenceStatus(change?.nextStatus ?? 'current');
88
+ return {
89
+ ...atom,
90
+ status: nextStatus,
91
+ confidence: atom.confidence === 'low' && atom.status === 'stale' && nextStatus !== 'stale'
92
+ ? 'medium'
93
+ : atom.confidence,
94
+ scopeRefs: {
95
+ ...atom.scopeRefs,
96
+ files: atom.scopeRefs.files.filter((file) => !removedFiles.has(file)),
97
+ symbols: atom.scopeRefs.symbols.filter((symbol) => !removedSymbols.has(symbol)),
98
+ },
99
+ evidenceRefs: atom.evidenceRefs.filter((ref) => {
100
+ if (ref.type === 'file')
101
+ return !removedFiles.has(ref.path);
102
+ if (ref.type === 'symbol')
103
+ return !removedSymbols.has(ref.name);
104
+ return true;
105
+ }),
106
+ lifecycle: {
107
+ ...atom.lifecycle,
108
+ invalidatedBy: change?.nextStatus === 'current'
109
+ ? undefined
110
+ : atom.lifecycle.invalidatedBy,
111
+ },
112
+ provenance: { ...atom.provenance, updatedAt: now },
113
+ };
114
+ });
115
+ if (!dryRun && (changes.length > 0 || orphanedNotes.length > 0)) {
115
116
  await writeKnowledgeAtoms(workspace, nextAtoms);
116
117
  // Exclude fully-orphaned notes from domain records — they are being archived
117
118
  await repairDomainRecords(workspace, nextAtoms
@@ -126,9 +127,9 @@ export async function repairCognition(workspace, maps, dryRun = false) {
126
127
  const orphanedNoteCount = orphanedNotes.length;
127
128
  return {
128
129
  atomCount: activeAtoms.length,
129
- staleAtomCount: nextNotes.filter((note) => note.referencesStatus === 'stale').length,
130
- needsReviewAtomCount: nextNotes.filter((note) => note.referencesStatus === 'mixed').length,
131
- archivedAtomCount: atoms.filter((atom) => atom.status === 'archived').length + orphanedNoteCount,
130
+ staleAtomCount: nextAtoms.filter((atom) => atom.status === 'stale').length,
131
+ needsReviewAtomCount: nextAtoms.filter((atom) => atom.status === 'needs-review').length,
132
+ archivedAtomCount: nextAtoms.filter((atom) => atom.status === 'archived').length,
132
133
  duplicateAtomTopicCount: countDuplicateTitles(nextNotes),
133
134
  noteCount: notes.length,
134
135
  mixedOrStaleCount: nextNotes.filter((note) => ['mixed', 'stale', 'unresolved'].includes(note.referencesStatus)).length,
@@ -40,16 +40,26 @@ export function renderContextPolicy(mode, agentName) {
40
40
  const packCommand = agentName
41
41
  ? `kgraph pack "<topic>" --budget 8000 --json --agent ${agentName}`
42
42
  : 'kgraph pack "<topic>" --budget 8000 --json';
43
+ const rootCommand = agentName
44
+ ? `kgraph "<topic>" --agent ${agentName}`
45
+ : 'kgraph "<topic>"';
46
+ const routing = [
47
+ 'Command routing comes first.',
48
+ 'If the user asks about prior work, previous decisions, timeline, or history, run `kgraph history "<topic>"` instead of pack.',
49
+ 'If the user asks what KGraph remembers, atom provenance, or durable knowledge, run `kgraph knowledge list --topic "<topic>"` or `kgraph knowledge get <atom-id>` instead of pack.',
50
+ `If the user asks to process inbox notes, pending capture, refresh memory, or update cognition, run \`${rootCommand}\` or low-level \`kgraph update\` instead of pack.`,
51
+ 'If the user asks for setup, health, broken integrations, or quality, run `kgraph doctor` or `kgraph doctor --quality` instead of pack.',
52
+ ].join(' ');
43
53
  switch (mode) {
44
54
  case 'always':
45
- return `Every chat in this repository must start by running \`${packCommand}\` before answering or exploring files. Infer the topic from the user's message; no exceptions, including simple or conversational requests. This records a lightweight KGraph session context event for the agent. Use \`kgraph "<topic>"\` only when a human-readable briefing is explicitly needed. ${useResultBoundary}`;
55
+ return `Every chat in this repository must use the correct KGraph command before answering or exploring files. ${routing} For normal repo context, code navigation, debugging, review, or edits, run \`${packCommand}\`. If that pack reports pending inbox notes, run \`${rootCommand}\` or \`kgraph update\` before relying on history or newly captured atoms. Infer the topic from the user's message. This records a lightweight KGraph session context event for the agent. ${useResultBoundary}`;
46
56
  case 'manual':
47
- return 'Do not run KGraph automatically. Run `kgraph pack "<topic>" --budget 8000 --json` only when the user explicitly asks for KGraph context, invokes KGraph, or needs a machine-readable repo-memory pack.';
57
+ return 'Do not run KGraph automatically. Run `kgraph pack "<topic>" --budget 8000 --json` only when the user explicitly asks for KGraph context, invokes KGraph, or needs a machine-readable repo-memory pack. If the user explicitly asks for KGraph history, inbox/update, knowledge, or doctor, run that specific KGraph command instead of pack.';
48
58
  case 'off':
49
59
  return 'KGraph is disabled for this integration.';
50
60
  case 'smart':
51
61
  default:
52
- return `For repo-specific coding, debugging, architecture, refactor, review, or file-exploration requests, run \`${packCommand}\` before broad repository exploration. Infer the topic from the user's message. This records a lightweight KGraph session context event only when KGraph is actually used. Skip KGraph for simple conversational requests that do not depend on repo knowledge. Use \`kgraph "<topic>"\` only when a human-readable briefing is explicitly needed. ${useResultBoundary}`;
62
+ return `For repo-specific coding, debugging, architecture, refactor, review, file-exploration, history, inbox/update, knowledge, or health requests, run the matching KGraph command before broad repository exploration. ${routing} For normal repo context, code navigation, debugging, review, or edits, run \`${packCommand}\`. If that pack reports pending inbox notes, run \`${rootCommand}\` or \`kgraph update\` before relying on history or newly captured atoms. Infer the topic from the user's message. This records a lightweight KGraph session context event only when KGraph is actually used. Skip KGraph for simple conversational requests that do not depend on repo knowledge. ${useResultBoundary}`;
53
63
  }
54
64
  }
55
65
  export function renderCapturePolicy(agentName) {
@@ -68,7 +78,7 @@ export function renderCapturePolicy(agentName) {
68
78
  - Use \`.kgraph/inbox/<slug>.md\` only when a longer structured note is clearer than a single \`kgraph conclude\` command.
69
79
  - A \`.kgraph/inbox/*.md\` note is KGraph runtime capture, not project documentation. It is allowed by this workflow unless the user explicitly says not to capture to KGraph.
70
80
  - Do not skip capture for meaningful UI text, button, link, route, styling, or small file edits. Skip capture only when no reusable repository knowledge was created.
71
- - Do not run KGraph repeatedly. Run it once at the start with \`kgraph pack "<topic>" --budget 8000 --json${agentName ? ` --agent ${agentName}` : ''}\` for agent-readable context. If repo files changed, run \`${finalCommand}\` once before the final answer.
81
+ - Do not run KGraph repeatedly. At the start, run the one KGraph command that matches the request: \`kgraph history "<topic>"\` for history, \`kgraph update\` or \`kgraph "<topic>"${agentName ? ` --agent ${agentName}` : ''}\` for inbox/update, and \`kgraph pack "<topic>" --budget 8000 --json${agentName ? ` --agent ${agentName}` : ''}\` for normal coding context. If repo files changed, run \`${finalCommand}\` once before the final answer.
72
82
  - After the final \`kgraph\` run, mention whether durable cognition was stored or processed.
73
83
 
74
84
  When using an inbox note, use this structure:
@@ -98,6 +98,7 @@ async function removeIntegrationInstructions(rootPath, targetPath, integrationNa
98
98
  const next = removeManagedBlock(existing, integrationName);
99
99
  if (next.trim().length === 0) {
100
100
  await rm(fullPath, { force: true });
101
+ await pruneEmptyParents(rootPath, path.dirname(fullPath));
101
102
  return;
102
103
  }
103
104
  await writeFile(fullPath, next, 'utf8');
@@ -114,19 +115,21 @@ async function removeIntegrationCommandFiles(rootPath, files) {
114
115
  const filePath = typeof file === 'string' ? file : file.path;
115
116
  const fullPath = path.join(rootPath, filePath);
116
117
  await rm(fullPath, { force: true, recursive: true });
117
- // Remove empty parent directories up to (but not including) rootPath
118
- let dir = path.dirname(fullPath);
119
- while (dir !== rootPath && dir.startsWith(rootPath)) {
120
- try {
121
- const entries = await readdir(dir);
122
- if (entries.length > 0)
123
- break;
124
- await rmdir(dir);
125
- dir = path.dirname(dir);
126
- }
127
- catch {
118
+ await pruneEmptyParents(rootPath, path.dirname(fullPath));
119
+ }
120
+ }
121
+ async function pruneEmptyParents(rootPath, startDir) {
122
+ let dir = startDir;
123
+ while (dir !== rootPath && dir.startsWith(rootPath)) {
124
+ try {
125
+ const entries = await readdir(dir);
126
+ if (entries.length > 0)
128
127
  break;
129
- }
128
+ await rmdir(dir);
129
+ dir = path.dirname(dir);
130
+ }
131
+ catch {
132
+ break;
130
133
  }
131
134
  }
132
135
  }
@@ -9,14 +9,15 @@ const COMPACT_STEP = `Run \`kgraph compact --dry-run\` when cognition looks dupl
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
11
  function packStep(agentName) {
12
- return `Treat \`kgraph pack "<task>" --budget 8000 --json --agent ${agentName}\` as the primary agent contract: use atoms, source ranges, git changes, omitted items, and inclusion reasons from the ContextPack before reading files. Use human \`kgraph "<topic>" --agent ${agentName}\` output only when the user explicitly wants a briefing.`;
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.`;
13
13
  }
14
14
  function smartRootStep(agentName) {
15
- return `Prefer the root workflow for normal agent work: run \`kgraph "<topic>" --agent ${agentName}\` when a human-readable refresh/context briefing is needed; 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.`;
15
+ 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.`;
16
16
  }
17
17
  const STALE_STEP = `Run \`kgraph stale\` when changed or deleted code may have invalidated durable knowledge. Run \`kgraph blame <atom-id>\` when provenance or evidence for a memory matters.`;
18
18
  const EXPLORATION_BOUNDARY_STEP = `Keep exploration bounded by the task. For simple edits, use KGraph to identify the likely file, then read only that file or a narrow range and make the edit. Do not keep searching after the target file is found, do not retry malformed shell commands with broader variants, and do not run broad \`find\`, recursive \`grep\`, or repeated full-file dumps after KGraph already returned candidate files. Use \`rg --files\` and quoted paths when a path must be located.`;
19
19
  const VERIFY_EDIT_STEP = `After editing, 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.`;
20
+ const ROUTING_STEP = `Route explicit KGraph requests to the matching command before any default context lookup: history/prior work -> \`kgraph history "<topic>"\`; inbox/pending capture/process notes/update cognition -> \`kgraph "<topic>"\` or \`kgraph update\`; remembered knowledge/atoms/provenance -> \`kgraph knowledge list --topic "<topic>"\` or \`kgraph knowledge get <atom-id>\`; setup/health -> \`kgraph doctor\`.`;
20
21
  function sessionStep(agentName, qualifier) {
21
22
  const base = `Track meaningful session activity with \`kgraph session start --agent ${agentName}\`, \`kgraph session read <path> --agent ${agentName}\`, \`kgraph session write <path> --agent ${agentName}\`, and \`kgraph session end --agent ${agentName} --conclude --topic "<topic>"\` when durable session memory is useful`;
22
23
  return qualifier ? `${base} ${qualifier}.` : `${base}.`;
@@ -28,23 +29,24 @@ function sessionStep(agentName, qualifier) {
28
29
  export function numberedWorkflow(agentName, options = {}) {
29
30
  return `1. Infer the topic from the user's request.
30
31
  2. {{KGRAPH_CONTEXT_POLICY}}
31
- 3. Use the returned files, symbols, relationships, and cognition before broad exploration.
32
- 4. ${EXPLORATION_BOUNDARY_STEP}
33
- 5. ${VERIFY_EDIT_STEP}
34
- 6. ${smartRootStep(agentName)}
35
- 7. ${packStep(agentName)}
36
- 8. ${KNOWLEDGE_STEP}
37
- 9. ${DOCTOR_STEP}
38
- 10. ${STALE_STEP}
39
- 11. ${sessionStep(agentName, options.sessionQualifier)}
40
- 12. ${IMPACT_STEP}
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}
41
43
 
42
44
  {{KGRAPH_CAPTURE_POLICY}}
43
45
 
44
- 13. ${REPAIR_STEP}
45
- 14. ${COMPACT_STEP}
46
- 15. Run \`kgraph visualize\` when the user wants to inspect the dependency graph — opens an interactive graph at http://localhost:4242 with PNG export.
47
- 16. ${HISTORY_STEP}`;
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}`;
48
50
  }
49
51
  /**
50
52
  * Returns the bullet-list workflow for rules files.
@@ -52,6 +54,7 @@ export function numberedWorkflow(agentName, options = {}) {
52
54
  */
53
55
  export function bulletWorkflow(agentName, options = {}) {
54
56
  return `- {{KGRAPH_CONTEXT_POLICY}}
57
+ - ${ROUTING_STEP}
55
58
  - ${EXPLORATION_BOUNDARY_STEP}
56
59
  - ${VERIFY_EDIT_STEP}
57
60
  - ${smartRootStep(agentName)}
@@ -41,6 +41,10 @@ export declare function createKnowledgeAtom(workspace: KGraphWorkspace, input: A
41
41
  }): Promise<KnowledgeAtom>;
42
42
  export declare function migrateLegacyCognitionToAtoms(workspace: KGraphWorkspace): Promise<void>;
43
43
  export declare function updateKnowledgeAtom(workspace: KGraphWorkspace, atomId: string, updater: (atom: KnowledgeAtom) => KnowledgeAtom): Promise<KnowledgeAtom>;
44
+ export declare function updateKnowledgeAtoms<T>(workspace: KGraphWorkspace, updater: (atoms: KnowledgeAtom[]) => {
45
+ atoms: KnowledgeAtom[];
46
+ result: T;
47
+ }): Promise<T>;
44
48
  export declare function refreshKnowledgeAtomStatuses(workspace: KGraphWorkspace, maps: {
45
49
  fileMap: FileMap;
46
50
  symbolMap: SymbolMap;
@@ -1,5 +1,7 @@
1
- import { mkdir, readFile, writeFile } from 'node:fs/promises';
1
+ import { randomUUID } from 'node:crypto';
2
+ import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
2
3
  import path from 'node:path';
4
+ import { setTimeout as delay } from 'node:timers/promises';
3
5
  import { KGraphError } from '../cli/errors.js';
4
6
  import { getCurrentCommit } from '../scanner/git-utils.js';
5
7
  import { readCognitionNotes } from '../storage/cognition-store.js';
@@ -17,7 +19,7 @@ export async function ensureKnowledgeStore(workspace) {
17
19
  });
18
20
  }
19
21
  if (!(await pathExists(atomsPath(workspace)))) {
20
- await writeFile(atomsPath(workspace), '', 'utf8');
22
+ await atomicWriteFile(atomsPath(workspace), '');
21
23
  }
22
24
  }
23
25
  export async function readKnowledgeAtoms(workspace) {
@@ -45,16 +47,21 @@ export function parseAtomsJsonl(raw) {
45
47
  return atoms;
46
48
  }
47
49
  export async function writeKnowledgeAtoms(workspace, atoms) {
50
+ await withKnowledgeWriteLock(workspace, () => writeKnowledgeAtomsUnlocked(workspace, atoms));
51
+ }
52
+ async function writeKnowledgeAtomsUnlocked(workspace, atoms) {
48
53
  await ensureKnowledgeStore(workspace);
49
- await writeFile(atomsPath(workspace), atoms.map((atom) => JSON.stringify(atom)).join('\n') +
50
- (atoms.length > 0 ? '\n' : ''), 'utf8');
54
+ await atomicWriteFile(atomsPath(workspace), atoms.map((atom) => JSON.stringify(atom)).join('\n') +
55
+ (atoms.length > 0 ? '\n' : ''));
51
56
  await writeKnowledgeIndexes(workspace, atoms);
52
57
  await touchSchema(workspace);
53
58
  }
54
59
  export async function appendKnowledgeAtom(workspace, atom) {
55
- const atoms = await readAtomsFile(workspace);
56
- atoms.push(atom);
57
- await writeKnowledgeAtoms(workspace, atoms);
60
+ await withKnowledgeWriteLock(workspace, async () => {
61
+ const atoms = await readAtomsFile(workspace);
62
+ atoms.push(atom);
63
+ await writeKnowledgeAtomsUnlocked(workspace, atoms);
64
+ });
58
65
  return atom;
59
66
  }
60
67
  export async function createKnowledgeAtom(workspace, input, maps) {
@@ -103,28 +110,51 @@ export async function migrateLegacyCognitionToAtoms(workspace) {
103
110
  if (existingIds.has(id))
104
111
  continue;
105
112
  if (existing.some((atom) => atom.topic === note.title &&
106
- atom.claim === (note.summary ?? note.title) &&
107
- atom.provenance.createdAt === note.createdAt)) {
113
+ atom.claim === (note.summary ?? note.title))) {
108
114
  continue;
109
115
  }
110
116
  migrated.push(legacyNoteToAtom(note, id));
111
117
  }
112
118
  if (migrated.length > 0) {
113
- await writeKnowledgeAtoms(workspace, [...existing, ...migrated]);
119
+ await withKnowledgeWriteLock(workspace, async () => {
120
+ const current = await readAtomsFile(workspace);
121
+ const currentIds = new Set(current.map((atom) => atom.id));
122
+ const nextMigrated = migrated.filter((atom) => !currentIds.has(atom.id) &&
123
+ !current.some((existing) => existing.topic === atom.topic &&
124
+ existing.claim === atom.claim));
125
+ if (nextMigrated.length > 0) {
126
+ await writeKnowledgeAtomsUnlocked(workspace, [
127
+ ...current,
128
+ ...nextMigrated,
129
+ ]);
130
+ }
131
+ });
114
132
  }
115
133
  else {
116
134
  await writeKnowledgeIndexes(workspace, existing);
117
135
  }
118
136
  }
119
137
  export async function updateKnowledgeAtom(workspace, atomId, updater) {
120
- const atoms = await readKnowledgeAtoms(workspace);
121
- const index = atoms.findIndex((atom) => atom.id === atomId);
122
- if (index === -1) {
123
- throw new KGraphError(`Knowledge atom not found: ${atomId}`);
124
- }
125
- atoms[index] = updater(atoms[index]);
126
- await writeKnowledgeAtoms(workspace, atoms);
127
- return atoms[index];
138
+ await migrateLegacyCognitionToAtoms(workspace);
139
+ return withKnowledgeWriteLock(workspace, async () => {
140
+ const atoms = await readAtomsFile(workspace);
141
+ const index = atoms.findIndex((atom) => atom.id === atomId);
142
+ if (index === -1) {
143
+ throw new KGraphError(`Knowledge atom not found: ${atomId}`);
144
+ }
145
+ atoms[index] = updater(atoms[index]);
146
+ await writeKnowledgeAtomsUnlocked(workspace, atoms);
147
+ return atoms[index];
148
+ });
149
+ }
150
+ export async function updateKnowledgeAtoms(workspace, updater) {
151
+ await migrateLegacyCognitionToAtoms(workspace);
152
+ return withKnowledgeWriteLock(workspace, async () => {
153
+ const current = await readAtomsFile(workspace);
154
+ const { atoms, result } = updater(current);
155
+ await writeKnowledgeAtomsUnlocked(workspace, atoms);
156
+ return result;
157
+ });
128
158
  }
129
159
  export async function refreshKnowledgeAtomStatuses(workspace, maps, dryRun = false) {
130
160
  const atoms = await readKnowledgeAtoms(workspace);
@@ -414,9 +444,9 @@ async function writeKnowledgeIndexes(workspace, atoms) {
414
444
  await mkdir(indexesPath(workspace), { recursive: true });
415
445
  const indexes = buildIndexes(atoms);
416
446
  await Promise.all([
417
- writeFile(path.join(indexesPath(workspace), 'terms.json'), JSON.stringify(indexes.terms, null, 2) + '\n', 'utf8'),
418
- writeFile(path.join(indexesPath(workspace), 'refs.json'), JSON.stringify(indexes.refs, null, 2) + '\n', 'utf8'),
419
- writeFile(path.join(indexesPath(workspace), 'topics.json'), JSON.stringify(indexes.topics, null, 2) + '\n', 'utf8'),
447
+ atomicWriteFile(path.join(indexesPath(workspace), 'terms.json'), JSON.stringify(indexes.terms, null, 2) + '\n'),
448
+ atomicWriteFile(path.join(indexesPath(workspace), 'refs.json'), JSON.stringify(indexes.refs, null, 2) + '\n'),
449
+ atomicWriteFile(path.join(indexesPath(workspace), 'topics.json'), JSON.stringify(indexes.topics, null, 2) + '\n'),
420
450
  ]);
421
451
  }
422
452
  function buildIndexes(atoms) {
@@ -454,7 +484,7 @@ async function readSchema(workspace) {
454
484
  }
455
485
  async function writeSchema(workspace, schema) {
456
486
  await mkdir(workspace.knowledgePath, { recursive: true });
457
- await writeFile(schemaPath(workspace), JSON.stringify(schema, null, 2) + '\n', 'utf8');
487
+ await atomicWriteFile(schemaPath(workspace), JSON.stringify(schema, null, 2) + '\n');
458
488
  }
459
489
  function buildAtomId(createdAt, seed) {
460
490
  return `${createdAt.replace(/[:.]/g, '-')}-${slugify(seed) || 'atom'}`;
@@ -484,3 +514,32 @@ function schemaPath(workspace) {
484
514
  function indexesPath(workspace) {
485
515
  return path.join(workspace.knowledgePath, 'indexes');
486
516
  }
517
+ async function atomicWriteFile(targetPath, content) {
518
+ await mkdir(path.dirname(targetPath), { recursive: true });
519
+ const tempPath = path.join(path.dirname(targetPath), `.${path.basename(targetPath)}.${process.pid}.${randomUUID()}.tmp`);
520
+ await writeFile(tempPath, content, 'utf8');
521
+ await rename(tempPath, targetPath);
522
+ }
523
+ async function withKnowledgeWriteLock(workspace, operation) {
524
+ await mkdir(workspace.knowledgePath, { recursive: true });
525
+ const lockPath = path.join(workspace.knowledgePath, '.write.lock');
526
+ const startedAt = Date.now();
527
+ while (true) {
528
+ try {
529
+ await mkdir(lockPath);
530
+ break;
531
+ }
532
+ catch (error) {
533
+ if (Date.now() - startedAt > 10_000) {
534
+ throw new KGraphError('Timed out waiting for KGraph knowledge write lock.');
535
+ }
536
+ await delay(50);
537
+ }
538
+ }
539
+ try {
540
+ return await operation();
541
+ }
542
+ finally {
543
+ await rm(lockPath, { recursive: true, force: true });
544
+ }
545
+ }
@@ -89,4 +89,8 @@ export interface ContextPack {
89
89
  items: ContextPackItem[];
90
90
  omitted: ContextPackItem[];
91
91
  warnings: string[];
92
+ pendingInbox?: {
93
+ count: number;
94
+ files: string[];
95
+ };
92
96
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kentwynn/kgraph",
3
- "version": "0.2.27",
3
+ "version": "0.2.29",
4
4
  "description": "Persistent repo intelligence for AI coding assistants.",
5
5
  "type": "module",
6
6
  "bin": {