kibi-cli 0.18.1 → 0.19.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.
Files changed (89) hide show
  1. package/dist/cli-json-command.d.ts +1 -2
  2. package/dist/cli-json-command.d.ts.map +1 -1
  3. package/dist/cli-json-command.js +2 -34
  4. package/dist/cli-operation-loader.d.ts +3 -0
  5. package/dist/cli-operation-loader.d.ts.map +1 -0
  6. package/dist/cli-operation-loader.js +58 -0
  7. package/dist/cli-operation-metadata.d.ts +82 -0
  8. package/dist/cli-operation-metadata.d.ts.map +1 -0
  9. package/dist/cli-operation-metadata.js +106 -0
  10. package/dist/cli-protocol.d.ts +1 -1
  11. package/dist/cli-protocol.d.ts.map +1 -1
  12. package/dist/cli-protocol.js +4 -3
  13. package/dist/cli-register-foundation.d.ts.map +1 -1
  14. package/dist/cli-register-foundation.js +14 -17
  15. package/dist/cli-register-json.d.ts +3 -0
  16. package/dist/cli-register-json.d.ts.map +1 -0
  17. package/dist/cli-register-json.js +35 -0
  18. package/dist/cli-register-maintenance.d.ts.map +1 -1
  19. package/dist/cli-register-maintenance.js +35 -10
  20. package/dist/cli-register-reporting.d.ts.map +1 -1
  21. package/dist/cli-register-reporting.js +13 -14
  22. package/dist/cli-register-skills.d.ts.map +1 -1
  23. package/dist/cli-register-skills.js +8 -6
  24. package/dist/cli.js +1 -1
  25. package/dist/commands/check.d.ts.map +1 -1
  26. package/dist/commands/check.js +30 -12
  27. package/dist/commands/coverage.js +1 -1
  28. package/dist/commands/discovery-shared.d.ts +2 -1
  29. package/dist/commands/discovery-shared.d.ts.map +1 -1
  30. package/dist/commands/discovery-shared.js +35 -13
  31. package/dist/commands/engine.d.ts +8 -0
  32. package/dist/commands/engine.d.ts.map +1 -0
  33. package/dist/commands/engine.js +100 -0
  34. package/dist/commands/gaps.js +1 -1
  35. package/dist/commands/graph.js +1 -1
  36. package/dist/commands/query.d.ts.map +1 -1
  37. package/dist/commands/query.js +3 -1
  38. package/dist/commands/search.d.ts.map +1 -1
  39. package/dist/commands/search.js +3 -1
  40. package/dist/commands/status.d.ts.map +1 -1
  41. package/dist/commands/status.js +2 -1
  42. package/dist/commands/sync/cache.d.ts +10 -0
  43. package/dist/commands/sync/cache.d.ts.map +1 -1
  44. package/dist/commands/sync/cache.js +30 -1
  45. package/dist/commands/sync/persistence.d.ts +10 -1
  46. package/dist/commands/sync/persistence.d.ts.map +1 -1
  47. package/dist/commands/sync/persistence.js +220 -44
  48. package/dist/commands/sync/staging.d.ts +10 -1
  49. package/dist/commands/sync/staging.d.ts.map +1 -1
  50. package/dist/commands/sync/staging.js +84 -1
  51. package/dist/commands/sync.d.ts.map +1 -1
  52. package/dist/commands/sync.js +450 -13
  53. package/dist/engine-daemon.d.ts +3 -0
  54. package/dist/engine-daemon.d.ts.map +1 -0
  55. package/dist/engine-daemon.js +34 -0
  56. package/dist/engine.d.ts +63 -0
  57. package/dist/engine.d.ts.map +1 -0
  58. package/dist/engine.js +1256 -0
  59. package/dist/extractors/relationships.d.ts +2 -0
  60. package/dist/extractors/relationships.d.ts.map +1 -1
  61. package/dist/extractors/relationships.js +15 -17
  62. package/dist/prolog.d.ts +8 -0
  63. package/dist/prolog.d.ts.map +1 -1
  64. package/dist/prolog.js +39 -8
  65. package/dist/public/operations/discovery-entities.d.ts.map +1 -1
  66. package/dist/public/operations/discovery-entities.js +3 -2
  67. package/dist/public/operations/discovery-executors.d.ts.map +1 -1
  68. package/dist/public/operations/discovery-executors.js +40 -3
  69. package/dist/public/operations/prolog-json.d.ts.map +1 -1
  70. package/dist/public/operations/prolog-json.js +14 -4
  71. package/dist/public/operations/runtime-types.d.ts +31 -0
  72. package/dist/public/operations/runtime-types.d.ts.map +1 -1
  73. package/dist/public/skills/init-kibi/SKILL.md +2 -2
  74. package/dist/public/skills/kibi-freshness/SKILL.md +2 -2
  75. package/dist/public/skills/kibi-traceability/SKILL.md +1 -1
  76. package/dist/public/skills/kibi-usage/SKILL.md +2 -2
  77. package/dist/public/skills/kibi-usage/resources/operation-access.md +1 -1
  78. package/dist/runtime/cli-runtime.d.ts.map +1 -1
  79. package/dist/runtime/cli-runtime.js +33 -37
  80. package/package.json +8 -4
  81. package/src/public/operations/discovery-entities.ts +3 -2
  82. package/src/public/operations/discovery-executors.ts +44 -3
  83. package/src/public/operations/prolog-json.ts +15 -3
  84. package/src/public/operations/runtime-types.ts +39 -0
  85. package/src/public/skills/init-kibi/SKILL.md +2 -2
  86. package/src/public/skills/kibi-freshness/SKILL.md +2 -2
  87. package/src/public/skills/kibi-traceability/SKILL.md +1 -1
  88. package/src/public/skills/kibi-usage/SKILL.md +2 -2
  89. package/src/public/skills/kibi-usage/resources/operation-access.md +1 -1
@@ -16,28 +16,103 @@
16
16
  along with this program. If not, see <https://www.gnu.org/licenses/>.
17
17
  */
18
18
  import { execSync } from "node:child_process";
19
- import { existsSync } from "node:fs";
19
+ import { existsSync, readdirSync } from "node:fs";
20
20
  import * as path from "node:path";
21
21
  import { branchErrorToDiagnostic, createDocsNotIndexedDiagnostic, createInvalidAuthoringDiagnostic, createKbMissingDiagnostic, formatSyncSummary, } from "../diagnostics.js";
22
+ import { EngineClient, engineSocketPath, ensureJournaledBranchStoreAsync, fsyncJournaledBranchStore, } from "../engine.js";
22
23
  import { isCliDebugEnabled } from "../env.js";
23
- import { extractFromRelationshipShards, flattenRelationships, validateRelationships, } from "../extractors/relationships.js";
24
+ import { extractRelationshipShard, validateRelationships, } from "../extractors/relationships.js";
24
25
  import { analyzeSemanticAdvisorInput } from "../operations/semantic-advisor/analyze-prose.js";
25
26
  import { validateSemanticInventoryBoundary } from "../operations/semantic-advisor/ingestion-boundary.js";
26
27
  import { PrologProcess } from "../prolog.js";
27
28
  import { resolveActiveBranch, } from "../utils/branch-resolver.js";
28
29
  import { loadSyncConfig } from "../utils/config.js";
29
- import { SYNC_CACHE_TTL_MS, SYNC_CACHE_VERSION, hashFile, readSyncCache, toCacheKey, writeSyncCache, } from "./sync/cache.js";
30
+ import { SYNC_CACHE_TTL_MS, SYNC_CACHE_VERSION, hashFile, hashNormalized, readSyncCache, toCacheKey, writeSyncCache, } from "./sync/cache.js";
30
31
  import { discoverSourceFiles, } from "./sync/discovery.js";
31
32
  import { processExtractions } from "./sync/extraction.js";
32
33
  import { refreshManifestCoordinates } from "./sync/manifest.js";
33
- import { persistEntities, persistRelationships } from "./sync/persistence.js";
34
- import { atomicPublish, cleanupStaging, createUniqueStagingPath, prepareStagingEnvironment, } from "./sync/staging.js";
34
+ import { persistEntities, persistRelationships, retractEntitiesById, retractEntitiesForSources, retractEntityRelationshipsById, retractRelationships, } from "./sync/persistence.js";
35
+ import { atomicPublish, atomicPublishGeneration, cleanupStaging, createUniqueStagingPath, prepareStagingEnvironment, } from "./sync/staging.js";
35
36
  export class SyncError extends Error {
36
37
  constructor(message) {
37
38
  super(message);
38
39
  this.name = "SyncError";
39
40
  }
40
41
  }
42
+ function relationshipKey(relationship) {
43
+ return `${relationship.type}\0${relationship.from}\0${relationship.to}`;
44
+ }
45
+ function relationshipFromKey(key) {
46
+ const [type, from, to] = key.split("\0");
47
+ if (type === undefined || from === undefined || to === undefined) {
48
+ throw new SyncError(`Invalid cached relationship key: ${key}`);
49
+ }
50
+ return { type, from, to };
51
+ }
52
+ function normalizedEntityHash(result) {
53
+ const entity = { ...result.entity };
54
+ // Manifest defaults are generated at extraction time. They must not turn a
55
+ // one-record edit into 10,000 false-positive symbol updates.
56
+ entity.created_at = undefined;
57
+ entity.updated_at = undefined;
58
+ return hashNormalized({
59
+ entity,
60
+ sourceFile: result.sourceFile,
61
+ relationships: result.relationships
62
+ .map(relationshipKey)
63
+ .sort((left, right) => left.localeCompare(right)),
64
+ });
65
+ }
66
+ async function checkpointNoopSync(workspaceRoot, branch) {
67
+ const engine = new EngineClient({
68
+ workspaceRoot,
69
+ branch,
70
+ timeout: 120_000,
71
+ });
72
+ try {
73
+ await engine.start();
74
+ const checkpoint = await engine.checkpoint();
75
+ if (!checkpoint.success) {
76
+ throw new SyncError(`Failed to publish the no-op sync checkpoint: ${checkpoint.error ?? "Unknown error"}`);
77
+ }
78
+ }
79
+ finally {
80
+ await engine.terminate();
81
+ }
82
+ }
83
+ function compilerCacheHasEntityDelta(cache) {
84
+ return (cache.entityHashes !== undefined && cache.sourceEntityIds !== undefined);
85
+ }
86
+ function compilerCacheIsFresh(cache, sourceFiles, nowMs) {
87
+ const sourceKeys = sourceFiles.map(toCacheKey);
88
+ if (Object.keys(cache.hashes).some((key) => !sourceKeys.includes(key))) {
89
+ return false;
90
+ }
91
+ return sourceKeys.every((key) => {
92
+ const lastSeen = cache.seenAt[key];
93
+ const lastSeenMs = lastSeen ? Date.parse(lastSeen) : Number.NaN;
94
+ return (typeof cache.hashes[key] === "string" &&
95
+ lastSeen !== undefined &&
96
+ !Number.isNaN(lastSeenMs) &&
97
+ nowMs - lastSeenMs <= SYNC_CACHE_TTL_MS);
98
+ });
99
+ }
100
+ function compilerCacheFilesMatch(cache, sourceFiles, relationshipFiles) {
101
+ for (const file of sourceFiles) {
102
+ if (cache.hashes[toCacheKey(file)] !== hashFile(file))
103
+ return false;
104
+ }
105
+ const relationshipKeys = new Set(relationshipFiles.map(toCacheKey));
106
+ if (Object.keys(cache.relationshipHashes ?? {}).some((key) => !relationshipKeys.has(key))) {
107
+ return false;
108
+ }
109
+ for (const file of relationshipFiles) {
110
+ if (cache.relationshipHashes?.[toCacheKey(file)] !== hashFile(file)) {
111
+ return false;
112
+ }
113
+ }
114
+ return true;
115
+ }
41
116
  // implements REQ-003, REQ-007
42
117
  export async function syncCommand(options = {}, runtime = {}) {
43
118
  const validateOnly = options.validateOnly ?? false;
@@ -72,6 +147,24 @@ export async function syncCommand(options = {}, runtime = {}) {
72
147
  throw new SyncError(`Failed to resolve active branch: ${branchResult.error}`);
73
148
  }
74
149
  currentBranch = branchResult.branch;
150
+ // Cut over legacy branches before creating a staging copy. If another
151
+ // CLI/MCP operation has an engine attached, stop that single writer so the
152
+ // atomic publish below cannot race its RDF lock.
153
+ const livePathForEngine = path.join(process.cwd(), `.kb/branches/${currentBranch}`);
154
+ if (!validateOnly) {
155
+ await ensureJournaledBranchStoreAsync(livePathForEngine);
156
+ const existingEngine = new EngineClient({
157
+ workspaceRoot: process.cwd(),
158
+ branch: currentBranch,
159
+ timeout: 2_000,
160
+ });
161
+ const needsExclusiveGenerationPublish = rebuild || runtime.createProlog !== undefined;
162
+ if (needsExclusiveGenerationPublish &&
163
+ existsSync(engineSocketPath(process.cwd(), currentBranch))) {
164
+ await existingEngine.stop(false).catch(() => undefined);
165
+ await existingEngine.terminate();
166
+ }
167
+ }
75
168
  if (isCliDebugEnabled()) {
76
169
  // eslint-disable-next-line no-console
77
170
  console.log("[kibi-debug] currentBranch:", currentBranch);
@@ -90,6 +183,44 @@ export async function syncCommand(options = {}, runtime = {}) {
90
183
  const syncCache = readSyncCache(cachePath);
91
184
  const nowIso = new Date().toISOString();
92
185
  const nowMs = Date.now();
186
+ const currentSourceKeys = new Set(sourceFiles.map(toCacheKey));
187
+ const relationshipShardFiles = existsSync(relationshipsDir)
188
+ ? readdirSync(relationshipsDir)
189
+ .filter((file) => file.endsWith(".yaml") || file.endsWith(".yml"))
190
+ .map((file) => path.join(relationshipsDir, file))
191
+ .sort()
192
+ : [];
193
+ // No-op is a first-class compiler result. Hashing the configured inputs is
194
+ // sufficient when normalized entity/shard inventories are already fresh;
195
+ // avoid rebuilding maps and rediscovering RDF state for the common path.
196
+ if (!validateOnly &&
197
+ !rebuild &&
198
+ !options.refreshSymbolCoordinates &&
199
+ runtime.createProlog === undefined &&
200
+ compilerCacheHasEntityDelta(syncCache) &&
201
+ compilerCacheIsFresh(syncCache, sourceFiles, nowMs) &&
202
+ compilerCacheFilesMatch(syncCache, sourceFiles, relationshipShardFiles)) {
203
+ await checkpointNoopSync(process.cwd(), currentBranch);
204
+ console.log("✓ Imported 0 entities, 0 relationships (no changes)");
205
+ return withOptionalCommit({
206
+ branch: currentBranch,
207
+ timestamp: new Date().toISOString(),
208
+ entityCounts,
209
+ relationshipCount: 0,
210
+ success: true,
211
+ published: false,
212
+ failures: diagnostics,
213
+ durationMs: Date.now() - startTime,
214
+ exitCode: 0,
215
+ }, getCurrentCommit());
216
+ }
217
+ const changedSourceHashes = new Map();
218
+ for (const file of sourceFiles) {
219
+ const key = toCacheKey(file);
220
+ const hash = hashFile(file);
221
+ if (syncCache.hashes[key] !== hash)
222
+ changedSourceHashes.set(key, hash);
223
+ }
93
224
  const nextHashes = {};
94
225
  const nextSeenAt = {};
95
226
  const nextSemanticHashes = {
@@ -98,15 +229,74 @@ export async function syncCommand(options = {}, runtime = {}) {
98
229
  const nextSemanticContracts = {
99
230
  ...syncCache.semanticContracts,
100
231
  };
232
+ const nextRelationshipHashes = {};
233
+ const nextShardRelationships = {};
234
+ const addedShardRelationships = [];
235
+ const removedShardRelationships = [];
101
236
  const initialSemanticBaseline = Object.keys(syncCache.hashes).length === 0;
102
- const shardResults = extractFromRelationshipShards(relationshipsDir);
103
- const allRelationships = flattenRelationships(shardResults);
104
237
  const changedMarkdownFiles = [];
105
238
  const changedManifestFiles = [];
239
+ const forceEntitySourceKeys = new Set();
240
+ // A removed source has no file left to extract, but its entities must be
241
+ // retracted from the branch. Keep this separate from failed extraction:
242
+ // malformed current files remain authoritative until a later successful
243
+ // sync rather than deleting their last known-good entities.
244
+ const deletedSourceFiles = Object.keys(syncCache.hashes)
245
+ .filter((key) => !currentSourceKeys.has(key))
246
+ .map((key) => path.resolve(process.cwd(), key));
247
+ const currentRelationshipKeys = new Set();
248
+ for (const shardPath of relationshipShardFiles) {
249
+ const key = toCacheKey(shardPath);
250
+ currentRelationshipKeys.add(key);
251
+ let hash;
252
+ try {
253
+ hash = hashFile(shardPath);
254
+ nextRelationshipHashes[key] = hash;
255
+ }
256
+ catch (error) {
257
+ const message = error instanceof Error ? error.message : String(error);
258
+ console.warn(`Warning: Failed to hash relationship shard ${shardPath}: ${message}`);
259
+ }
260
+ const previousKeys = syncCache.shardRelationships?.[key] ?? [];
261
+ const shardChanged = syncCache.shardRelationships === undefined ||
262
+ hash === undefined ||
263
+ syncCache.relationshipHashes?.[key] !== hash;
264
+ const currentKeys = shardChanged
265
+ ? extractRelationshipShard(shardPath)
266
+ .relationships.map(relationshipKey)
267
+ .sort((left, right) => left.localeCompare(right))
268
+ : previousKeys;
269
+ nextShardRelationships[key] = currentKeys;
270
+ if (shardChanged) {
271
+ const previous = new Set(previousKeys);
272
+ const current = new Set(currentKeys);
273
+ for (const edge of previous) {
274
+ if (!current.has(edge)) {
275
+ removedShardRelationships.push(relationshipFromKey(edge));
276
+ }
277
+ }
278
+ for (const edge of current) {
279
+ if (!previous.has(edge)) {
280
+ addedShardRelationships.push(relationshipFromKey(edge));
281
+ }
282
+ }
283
+ }
284
+ }
285
+ for (const [key, previousKeys] of Object.entries(syncCache.shardRelationships ?? {})) {
286
+ if (currentRelationshipKeys.has(key))
287
+ continue;
288
+ for (const edge of previousKeys) {
289
+ removedShardRelationships.push(relationshipFromKey(edge));
290
+ }
291
+ }
292
+ const relationshipChanged = addedShardRelationships.length > 0 ||
293
+ removedShardRelationships.length > 0;
106
294
  for (const file of sourceFiles) {
107
295
  try {
108
296
  const key = toCacheKey(file);
109
- const hash = hashFile(file);
297
+ const hash = changedSourceHashes.get(key) ?? syncCache.hashes[key];
298
+ if (hash === undefined)
299
+ continue;
110
300
  const lastSeen = syncCache.seenAt[key];
111
301
  const lastSeenMs = lastSeen ? Date.parse(lastSeen) : Number.NaN;
112
302
  const expired = !lastSeen ||
@@ -114,8 +304,10 @@ export async function syncCommand(options = {}, runtime = {}) {
114
304
  nowMs - lastSeenMs > SYNC_CACHE_TTL_MS;
115
305
  nextHashes[key] = hash;
116
306
  nextSeenAt[key] = nowIso;
117
- const isChanged = expired || syncCache.hashes[key] !== hash || validateOnly || rebuild;
307
+ const isChanged = expired || changedSourceHashes.has(key) || validateOnly || rebuild;
118
308
  if (isChanged) {
309
+ if (expired)
310
+ forceEntitySourceKeys.add(key);
119
311
  if (markdownFiles.includes(file)) {
120
312
  changedMarkdownFiles.push(file);
121
313
  }
@@ -129,6 +321,19 @@ export async function syncCommand(options = {}, runtime = {}) {
129
321
  console.warn(`Warning: Failed to hash ${file}: ${message}`);
130
322
  }
131
323
  }
324
+ // A v1 cache has only whole-file hashes. Perform one compiler-metadata
325
+ // backfill so subsequent edits can be reduced to normalized entity deltas.
326
+ if (syncCache.entityHashes === undefined ||
327
+ syncCache.sourceEntityIds === undefined) {
328
+ for (const file of markdownFiles) {
329
+ if (!changedMarkdownFiles.includes(file))
330
+ changedMarkdownFiles.push(file);
331
+ }
332
+ for (const file of manifestFiles) {
333
+ if (!changedManifestFiles.includes(file))
334
+ changedManifestFiles.push(file);
335
+ }
336
+ }
132
337
  // Coordinate refresh must precede extraction so the manifest overlay that
133
338
  // is persisted into RDF observes the newly generated coordinates. Force
134
339
  // each refreshed manifest through extraction even when its authored YAML
@@ -151,8 +356,73 @@ export async function syncCommand(options = {}, runtime = {}) {
151
356
  }
152
357
  const performedFullReindex = changedMarkdownFiles.length === markdownFiles.length &&
153
358
  changedManifestFiles.length === manifestFiles.length;
154
- const { results, failedCacheKeys, errors } = await processExtractions(changedMarkdownFiles, changedManifestFiles, validateOnly);
155
- for (const result of results) {
359
+ const extraction = await processExtractions(changedMarkdownFiles, changedManifestFiles, validateOnly);
360
+ const { failedCacheKeys, errors } = extraction;
361
+ const extractedResults = extraction.results;
362
+ const nextEntityHashes = {
363
+ ...(syncCache.entityHashes ?? {}),
364
+ };
365
+ const nextSourceEntityIds = {
366
+ ...(syncCache.sourceEntityIds ?? {}),
367
+ };
368
+ const removedEntityIds = new Set();
369
+ const changedEntityIds = new Set();
370
+ const supportsEntityDelta = syncCache.entityHashes !== undefined &&
371
+ syncCache.sourceEntityIds !== undefined;
372
+ const useEntityDelta = supportsEntityDelta &&
373
+ !validateOnly &&
374
+ !rebuild &&
375
+ runtime.createProlog === undefined;
376
+ const materializeAllRelationships = !useEntityDelta || changedMarkdownFiles.length > 0;
377
+ const allRelationships = materializeAllRelationships
378
+ ? Object.values(nextShardRelationships).flat().map(relationshipFromKey)
379
+ : [...addedShardRelationships, ...removedShardRelationships];
380
+ for (const deletedSource of deletedSourceFiles) {
381
+ const sourceKey = toCacheKey(deletedSource);
382
+ for (const id of nextSourceEntityIds[sourceKey] ?? []) {
383
+ removedEntityIds.add(id);
384
+ delete nextEntityHashes[id];
385
+ }
386
+ delete nextSourceEntityIds[sourceKey];
387
+ }
388
+ const resultsBySource = new Map();
389
+ for (const result of extractedResults) {
390
+ const sourceKey = toCacheKey(result.entity.source);
391
+ const sourceResults = resultsBySource.get(sourceKey) ?? [];
392
+ sourceResults.push(result);
393
+ resultsBySource.set(sourceKey, sourceResults);
394
+ }
395
+ for (const changedSource of [
396
+ ...changedMarkdownFiles,
397
+ ...changedManifestFiles,
398
+ ]) {
399
+ const sourceKey = toCacheKey(changedSource);
400
+ if (failedCacheKeys.has(sourceKey))
401
+ continue;
402
+ const sourceResults = resultsBySource.get(sourceKey) ?? [];
403
+ const previousIds = new Set(nextSourceEntityIds[sourceKey] ?? []);
404
+ const currentIds = sourceResults.map(({ entity }) => entity.id).sort();
405
+ const currentIdSet = new Set(currentIds);
406
+ for (const previousId of previousIds) {
407
+ if (!currentIdSet.has(previousId)) {
408
+ removedEntityIds.add(previousId);
409
+ delete nextEntityHashes[previousId];
410
+ }
411
+ }
412
+ for (const result of sourceResults) {
413
+ const hash = normalizedEntityHash(result);
414
+ if (forceEntitySourceKeys.has(sourceKey) ||
415
+ nextEntityHashes[result.entity.id] !== hash) {
416
+ changedEntityIds.add(result.entity.id);
417
+ }
418
+ nextEntityHashes[result.entity.id] = hash;
419
+ }
420
+ nextSourceEntityIds[sourceKey] = currentIds;
421
+ }
422
+ const results = useEntityDelta
423
+ ? extractedResults.filter(({ entity }) => changedEntityIds.has(entity.id))
424
+ : extractedResults;
425
+ for (const result of extractedResults) {
156
426
  if (result.entity.type !== "req")
157
427
  continue;
158
428
  const key = toCacheKey(result.entity.source);
@@ -212,7 +482,13 @@ export async function syncCommand(options = {}, runtime = {}) {
212
482
  exitCode: 1,
213
483
  }, getCurrentCommit());
214
484
  }
215
- if (results.length === 0 && allRelationships.length === 0 && !rebuild) {
485
+ if (results.length === 0 &&
486
+ removedEntityIds.size === 0 &&
487
+ !relationshipChanged &&
488
+ (useEntityDelta || changedMarkdownFiles.length === 0) &&
489
+ (useEntityDelta || changedManifestFiles.length === 0) &&
490
+ deletedSourceFiles.length === 0 &&
491
+ !rebuild) {
216
492
  const evictedHashes = {};
217
493
  const evictedSeenAt = {};
218
494
  const evictedSemanticHashes = {};
@@ -231,10 +507,17 @@ export async function syncCommand(options = {}, runtime = {}) {
231
507
  writeSyncCache(cachePath, {
232
508
  version: SYNC_CACHE_VERSION,
233
509
  hashes: evictedHashes,
510
+ relationshipHashes: nextRelationshipHashes,
511
+ entityHashes: nextEntityHashes,
512
+ sourceEntityIds: nextSourceEntityIds,
513
+ shardRelationships: nextShardRelationships,
234
514
  seenAt: evictedSeenAt,
235
515
  semanticHashes: evictedSemanticHashes,
236
516
  semanticContracts: evictedSemanticContracts,
237
517
  });
518
+ if (runtime.createProlog === undefined) {
519
+ await checkpointNoopSync(process.cwd(), currentBranch);
520
+ }
238
521
  console.log("✓ Imported 0 entities, 0 relationships (no changes)");
239
522
  return withOptionalCommit({
240
523
  branch: currentBranch,
@@ -253,6 +536,135 @@ export async function syncCommand(options = {}, runtime = {}) {
253
536
  if (!kbExists && !rebuild) {
254
537
  diagnostics.push(createKbMissingDiagnostic(currentBranch, livePath));
255
538
  }
539
+ // implements REQ-core-journaled-engine-delta-sync
540
+ // Normal syncs are compiled directly into the long-lived single-writer
541
+ // engine. Rebuilds retain generation replacement semantics, and injected
542
+ // Prolog runtimes keep the staging path used by the contract fixtures.
543
+ // This is the cutover that avoids copying and rewriting a complete branch
544
+ // for a one-symbol or relationship-only change.
545
+ if (!validateOnly && !rebuild && runtime.createProlog === undefined) {
546
+ const engine = new EngineClient({
547
+ workspaceRoot: process.cwd(),
548
+ branch: currentBranch,
549
+ timeout: 120_000,
550
+ });
551
+ const engineProlog = engine;
552
+ try {
553
+ await engine.start();
554
+ const successfulChangedSources = [
555
+ ...changedMarkdownFiles,
556
+ ...changedManifestFiles,
557
+ ].filter((file) => !failedCacheKeys.has(toCacheKey(file)));
558
+ const removedCount = useEntityDelta
559
+ ? await retractEntitiesById(engineProlog, [...removedEntityIds])
560
+ : await retractEntitiesForSources(engineProlog, [
561
+ ...successfulChangedSources,
562
+ ...deletedSourceFiles,
563
+ ]);
564
+ if (useEntityDelta) {
565
+ await retractEntityRelationshipsById(engineProlog, [
566
+ ...changedEntityIds,
567
+ ]);
568
+ const exactRemovedEdges = removedShardRelationships.filter((relationship) => !removedEntityIds.has(relationship.from) &&
569
+ !removedEntityIds.has(relationship.to) &&
570
+ !changedEntityIds.has(relationship.from));
571
+ await retractRelationships(engineProlog, exactRemovedEdges);
572
+ }
573
+ else if (relationshipChanged) {
574
+ const cleared = await engine.query("kb_retract_all_relationships");
575
+ if (!cleared.success) {
576
+ throw new SyncError(`Failed to clear changed relationship shards: ${cleared.error || "Unknown error"}`);
577
+ }
578
+ }
579
+ const entityIds = new Set();
580
+ for (const { entity } of results) {
581
+ entityCounts[entity.type] = (entityCounts[entity.type] || 0) + 1;
582
+ }
583
+ const { entityCount, kbModified: entitiesModified } = await persistEntities(engineProlog, results, entityIds, {
584
+ loadExistingEntityIds: !useEntityDelta,
585
+ });
586
+ const validationErrors = useEntityDelta
587
+ ? []
588
+ : validateRelationships(allRelationships, entityIds);
589
+ if (validationErrors.length > 0) {
590
+ console.warn(`Warning: ${validationErrors.length} dangling relationship(s) found`);
591
+ }
592
+ const danglingKeys = new Set(validationErrors.map(({ relationship }) => `${relationship.type}|${relationship.from}|${relationship.to}`));
593
+ const shardDeltaByKey = new Map();
594
+ const shardCandidates = useEntityDelta
595
+ ? [
596
+ ...addedShardRelationships,
597
+ ...allRelationships.filter((relationship) => changedEntityIds.has(relationship.from)),
598
+ ]
599
+ : allRelationships;
600
+ for (const relationship of shardCandidates) {
601
+ if (!danglingKeys.has(`${relationship.type}|${relationship.from}|${relationship.to}`)) {
602
+ shardDeltaByKey.set(relationshipKey(relationship), relationship);
603
+ }
604
+ }
605
+ const { relationshipCount, kbModified: relationshipsModified } = await persistRelationships(engineProlog, results, [
606
+ ...shardDeltaByKey.values(),
607
+ ]);
608
+ const kbModified = removedCount > 0 ||
609
+ relationshipChanged ||
610
+ entitiesModified ||
611
+ relationshipsModified;
612
+ if (kbModified)
613
+ engine.invalidateCache();
614
+ const saveResult = await engine.save();
615
+ if (!saveResult.success) {
616
+ throw new SyncError(`Failed to save journaled KB: ${saveResult.error || "Unknown error"}`);
617
+ }
618
+ const evictedHashes = {};
619
+ const evictedSeenAt = {};
620
+ const evictedSemanticHashes = {};
621
+ const evictedSemanticContracts = {};
622
+ for (const [key, hash] of Object.entries(nextHashes)) {
623
+ if (failedCacheKeys.has(key))
624
+ continue;
625
+ evictedHashes[key] = hash;
626
+ evictedSeenAt[key] = nextSeenAt[key] ?? nowIso;
627
+ if (nextSemanticHashes[key] !== undefined) {
628
+ evictedSemanticHashes[key] = nextSemanticHashes[key];
629
+ evictedSemanticContracts[key] = nextSemanticContracts[key] === true;
630
+ }
631
+ }
632
+ writeSyncCache(path.join(livePath, "sync-cache.json"), {
633
+ version: SYNC_CACHE_VERSION,
634
+ hashes: evictedHashes,
635
+ relationshipHashes: nextRelationshipHashes,
636
+ entityHashes: nextEntityHashes,
637
+ sourceEntityIds: nextSourceEntityIds,
638
+ shardRelationships: nextShardRelationships,
639
+ seenAt: evictedSeenAt,
640
+ semanticHashes: evictedSemanticHashes,
641
+ semanticContracts: evictedSemanticContracts,
642
+ });
643
+ published = true;
644
+ if (performedFullReindex &&
645
+ markdownFiles.length > 0 &&
646
+ entityCount < markdownFiles.length) {
647
+ diagnostics.push(createDocsNotIndexedDiagnostic(markdownFiles.length, entityCount));
648
+ }
649
+ console.log(`✓ Imported ${entityCount} entities, ${relationshipCount} relationships (removed ${removedCount} entities)`);
650
+ const commit = getCurrentCommit();
651
+ const summary = withOptionalCommit({
652
+ branch: currentBranch,
653
+ timestamp: new Date().toISOString(),
654
+ entityCounts,
655
+ relationshipCount,
656
+ success: true,
657
+ published,
658
+ failures: diagnostics,
659
+ durationMs: Date.now() - startTime,
660
+ }, commit);
661
+ console.log(formatSyncSummary(summary));
662
+ return { ...summary, exitCode: 0 };
663
+ }
664
+ finally {
665
+ await engine.terminate();
666
+ }
667
+ }
256
668
  stagingPath = createUniqueStagingPath(currentBranch, process.cwd());
257
669
  const runtimeContext = {
258
670
  currentBranch,
@@ -276,6 +688,20 @@ export async function syncCommand(options = {}, runtime = {}) {
276
688
  for (const { entity } of results) {
277
689
  entityCounts[entity.type] = (entityCounts[entity.type] || 0) + 1;
278
690
  }
691
+ // A copied generation retains entities from old source contents. Remove
692
+ // only successfully extracted/deleted sources before applying the new
693
+ // entities; failed extraction deliberately leaves the previous data.
694
+ await retractEntitiesForSources(prolog, [
695
+ ...changedMarkdownFiles,
696
+ ...changedManifestFiles,
697
+ ...deletedSourceFiles,
698
+ ].filter((file) => !failedCacheKeys.has(toCacheKey(file))));
699
+ if (relationshipChanged) {
700
+ const clearRelationships = await prolog.query("kb_retract_all_relationships");
701
+ if (!clearRelationships.success) {
702
+ throw new SyncError(`Failed to clear changed relationship shards: ${clearRelationships.error || "Unknown error"}`);
703
+ }
704
+ }
279
705
  const { entityCount, kbModified: entitiesModified } = await persistEntities(prolog, results, entityIds);
280
706
  const validationErrors = validateRelationships(allRelationships, entityIds);
281
707
  if (validationErrors.length > 0) {
@@ -333,7 +759,14 @@ export async function syncCommand(options = {}, runtime = {}) {
333
759
  }
334
760
  await prolog.query("kb_detach");
335
761
  await prolog.terminate();
336
- atomicPublish(stagingPath, livePath);
762
+ const journaledLive = existsSync(path.join(livePath, "storage.json"));
763
+ if (rebuild && journaledLive) {
764
+ atomicPublishGeneration(stagingPath, livePath);
765
+ }
766
+ else {
767
+ atomicPublish(stagingPath, livePath);
768
+ }
769
+ fsyncJournaledBranchStore(livePath);
337
770
  cleanupStaging(stagingPath);
338
771
  const evictedHashes = {};
339
772
  const evictedSeenAt = {};
@@ -354,6 +787,10 @@ export async function syncCommand(options = {}, runtime = {}) {
354
787
  writeSyncCache(liveCachePath, {
355
788
  version: SYNC_CACHE_VERSION,
356
789
  hashes: evictedHashes,
790
+ relationshipHashes: nextRelationshipHashes,
791
+ entityHashes: nextEntityHashes,
792
+ sourceEntityIds: nextSourceEntityIds,
793
+ shardRelationships: nextShardRelationships,
357
794
  seenAt: evictedSeenAt,
358
795
  semanticHashes: evictedSemanticHashes,
359
796
  semanticContracts: evictedSemanticContracts,
@@ -0,0 +1,3 @@
1
+ #!/usr/bin/env node
2
+ export {};
3
+ //# sourceMappingURL=engine-daemon.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"engine-daemon.d.ts","sourceRoot":"","sources":["../src/engine-daemon.ts"],"names":[],"mappings":""}
@@ -0,0 +1,34 @@
1
+ #!/usr/bin/env node
2
+ import { writeFileSync } from "node:fs";
3
+ import { runEngineDaemon } from "./engine.js";
4
+ function requiredArg(args, name) {
5
+ const index = args.indexOf(name);
6
+ const value = index >= 0 ? args[index + 1] : undefined;
7
+ if (value === undefined || value.length === 0) {
8
+ throw new Error(`Missing ${name} argument`);
9
+ }
10
+ return value;
11
+ }
12
+ const args = process.argv.slice(2);
13
+ try {
14
+ await runEngineDaemon({
15
+ workspaceRoot: requiredArg(args, "--workspace"),
16
+ branch: requiredArg(args, "--branch"),
17
+ socketPath: requiredArg(args, "--socket"),
18
+ });
19
+ }
20
+ catch (error) {
21
+ const message = error instanceof Error ? error.message : String(error);
22
+ console.error(message);
23
+ const socketIndex = args.indexOf("--socket");
24
+ const socketPath = socketIndex >= 0 ? args[socketIndex + 1] : undefined;
25
+ if (socketPath) {
26
+ try {
27
+ writeFileSync(`${socketPath}.error`, `${message}\n`, { mode: 0o600 });
28
+ }
29
+ catch {
30
+ // Preserve the process exit code even if the diagnostic path is gone.
31
+ }
32
+ }
33
+ process.exitCode = 1;
34
+ }