autodev-cli 1.4.97 → 1.4.101

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 (50) hide show
  1. package/media/profile/00-identity.md +8 -20
  2. package/media/profile/01-learning.md +12 -19
  3. package/media/profile/02-memory-mcp.md +4 -44
  4. package/media/profile/03-living-docs.md +10 -13
  5. package/media/profile/04-skill-files.md +3 -5
  6. package/media/profile/05-skill-creation.md +3 -14
  7. package/media/profile/06-core-rules.md +10 -47
  8. package/media/profile/07-core-loop.md +8 -25
  9. package/media/profile/08-thinking.md +10 -23
  10. package/media/profile/09-parallel-panel.md +9 -24
  11. package/media/profile/10-codebase-verification.md +7 -16
  12. package/media/profile/11-git-debug-security.md +5 -10
  13. package/media/profile/12-todo-format.md +3 -16
  14. package/media/profile/13-workflow-principles.md +3 -7
  15. package/media/profile/14-contracts.md +9 -12
  16. package/media/profile/15-soul.md +6 -8
  17. package/media/profile/16-journal.md +5 -32
  18. package/media/profile/17-issue-tracking.md +9 -14
  19. package/media/profile/18-knowledgebase.md +6 -9
  20. package/media/profile/19-subagent-context-management.md +11 -496
  21. package/media/profile/20-project-graph.md +23 -49
  22. package/out/commands/mcpOperate.js +150 -32
  23. package/out/commands/mcpOperate.js.map +1 -1
  24. package/out/core/controlSpec.d.ts +56 -0
  25. package/out/core/controlSpec.js +267 -0
  26. package/out/core/controlSpec.js.map +1 -0
  27. package/out/dispatcher.js +3 -1
  28. package/out/dispatcher.js.map +1 -1
  29. package/out/graphRender.d.ts +87 -0
  30. package/out/graphRender.js +279 -0
  31. package/out/graphRender.js.map +1 -0
  32. package/out/graphStore.d.ts +377 -2
  33. package/out/graphStore.js +1240 -64
  34. package/out/graphStore.js.map +1 -1
  35. package/out/journal.d.ts +35 -0
  36. package/out/journal.js +167 -0
  37. package/out/journal.js.map +1 -0
  38. package/out/messageBuilder.d.ts +1 -1
  39. package/out/messageBuilder.js +29 -29
  40. package/out/messageBuilder.js.map +1 -1
  41. package/out/periodicActions.d.ts +18 -0
  42. package/out/periodicActions.js +49 -0
  43. package/out/periodicActions.js.map +1 -1
  44. package/out/profileBuilder.d.ts +1 -1
  45. package/out/profileBuilder.js +31 -4
  46. package/out/profileBuilder.js.map +1 -1
  47. package/out/taskLoop.d.ts +19 -0
  48. package/out/taskLoop.js +214 -4
  49. package/out/taskLoop.js.map +1 -1
  50. package/package.json +2 -2
package/out/graphStore.js CHANGED
@@ -23,6 +23,14 @@
23
23
  * 3. Every `evaluation` identifies a `rubric`.
24
24
  * 4. Superseded nodes remain addressable — supersede never deletes; it links a
25
25
  * new version with a `supersedes` edge and flags the old one.
26
+ *
27
+ * MATERIALISATION (Tier 0). The log is append-only, so reload is incremental: we
28
+ * remember a byte offset and apply only the newly-appended tail on top of the
29
+ * live maps (apply is idempotent last-writer-wins) — a full clear+rebuild only
30
+ * happens when the file shrank (a rewrite). Two derived indexes are kept in step:
31
+ * an adjacency index (nodeId → incident edges) and an entity canonical-key index
32
+ * (name → entity id) so `UsageService` / `usage-service` / `usage service` all
33
+ * resolve to ONE entity.
26
34
  */
27
35
  var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
28
36
  if (k2 === undefined) k2 = k;
@@ -59,6 +67,10 @@ var __importStar = (this && this.__importStar) || (function () {
59
67
  })();
60
68
  Object.defineProperty(exports, "__esModule", { value: true });
61
69
  exports.GraphStore = exports.GraphInvariantError = exports.EDGE_TYPES = exports.NODE_TYPES = void 0;
70
+ exports.tokenize = tokenize;
71
+ exports.canonicalKey = canonicalKey;
72
+ exports.estimateTokens = estimateTokens;
73
+ exports.parseSourcePath = parseSourcePath;
62
74
  const fs = __importStar(require("fs"));
63
75
  const path = __importStar(require("path"));
64
76
  const crypto = __importStar(require("crypto"));
@@ -75,22 +87,192 @@ exports.EDGE_TYPES = [
75
87
  class GraphInvariantError extends Error {
76
88
  }
77
89
  exports.GraphInvariantError = GraphInvariantError;
90
+ /**
91
+ * Legacy display slug — kept for backward-compatible id resolution of graphs
92
+ * written before the canonical-key dedup landed. New entity ids use
93
+ * {@link canonicalKey}. `slug("UsageService")` and `slug("usage-service")`
94
+ * DIVERGE, which is exactly the bug canonicalKey fixes; slug survives only so
95
+ * old `entity:usageservice`-style ids still resolve via the fallback path.
96
+ */
78
97
  function slug(s) {
79
98
  return s.toLowerCase().trim().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 60) || 'x';
80
99
  }
100
+ /**
101
+ * Split a label into normalised word tokens: break camelCase / PascalCase and
102
+ * acronym boundaries into words, lowercase, and split on any non-alphanumeric.
103
+ * `"UsageService"` → `["usage","service"]`, `"usage-service"` → `["usage","service"]`,
104
+ * `"HTTPServer v2"` → `["http","server","v2"]`.
105
+ */
106
+ function tokenize(s) {
107
+ return String(s || '')
108
+ .replace(/([a-z0-9])([A-Z])/g, '$1 $2') // camelCase boundary
109
+ .replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2') // ACRONYMWord → ACRONYM Word
110
+ .toLowerCase()
111
+ .split(/[^a-z0-9]+/)
112
+ .filter(Boolean);
113
+ }
114
+ /**
115
+ * Canonical dedup key for an entity name: a **sorted, de-duplicated token set**.
116
+ * This collapses camelCase, kebab-case and spaced spellings AND word order to a
117
+ * single key, so `UsageService`, `usage-service` and `usage service` map to one
118
+ * `entity:` id. Sorting means `"Service Usage"` also collapses onto the same key.
119
+ */
120
+ function canonicalKey(s) {
121
+ const toks = Array.from(new Set(tokenize(s))).sort();
122
+ return toks.join('-').slice(0, 60) || 'x';
123
+ }
81
124
  function shortId() { return crypto.randomBytes(5).toString('hex'); }
125
+ /** Rough token estimate for budgeting — ~4 chars/token. */
126
+ function estimateTokens(s) {
127
+ return Math.ceil((s ? s.length : 0) / 4);
128
+ }
129
+ // ── Tier 2 constants ─────────────────────────────────────────────────────────
130
+ /** Node types whose `file:line` source triggers the parent_of auto-scaffold (item 1). */
131
+ const SCAFFOLD_SOURCE_TYPES = new Set(['claim', 'artifact', 'decision', 'note']);
132
+ /** Regenerate the materialised snapshot when the uncovered JSONL tail exceeds this many bytes. */
133
+ const SNAPSHOT_TAIL_BYTES = 256 * 1024;
134
+ /** …or this many ops applied since the last snapshot (item 3). */
135
+ const SNAPSHOT_TAIL_OPS = 2000;
136
+ /**
137
+ * Parse a `file:line`-style source into its cumulative path segments — the spine
138
+ * an auto-scaffold builds (item 1). `"src/foo/bar.ts:12"` → `["src","src/foo",
139
+ * "src/foo/bar.ts"]`; `"pay.ts:10"` → `["pay.ts"]`. Returns null for a URL, a
140
+ * source-node id (`source:ab12`), or anything that doesn't look like a path — the
141
+ * scaffold is deterministic and conservative, never a guess.
142
+ */
143
+ function parseSourcePath(source) {
144
+ let s = String(source || '').trim();
145
+ if (!s) {
146
+ return null;
147
+ }
148
+ if (/^[a-z][a-z0-9+.-]*:\/\//i.test(s)) {
149
+ return null;
150
+ } // url (http://, file://, …)
151
+ s = s.replace(/:\d+(:\d+)?$/, ''); // strip :line(:col)
152
+ s = s.replace(/\\/g, '/').replace(/^\.\//, '').replace(/^\/+/, '');
153
+ if (!s || s.includes('..')) {
154
+ return null;
155
+ }
156
+ // must look like a file path: has a '/' or a dotted extension (rules out `source:ab12`)
157
+ const looksPath = s.includes('/') || /\.[a-z0-9]+$/i.test(s);
158
+ if (!looksPath) {
159
+ return null;
160
+ }
161
+ const parts = s.split('/').filter((x) => x && x !== '.');
162
+ if (!parts.length || parts.length > 20) {
163
+ return null;
164
+ }
165
+ const cumulative = [];
166
+ let acc = '';
167
+ for (const p of parts) {
168
+ acc = acc ? `${acc}/${p}` : p;
169
+ cumulative.push(acc);
170
+ }
171
+ return cumulative;
172
+ }
173
+ // atomicity ceiling for a lock-free appendFileSync: writes at or below PIPE_BUF
174
+ // (4096 on Linux) can't interleave with a concurrent writer. We warn a little
175
+ // below it so a serialized op line that risks a torn interleave is never silent.
176
+ const PIPE_BUF_SAFE = 4000;
177
+ const EDGE_ONTOLOGY = {
178
+ produced: [{ from: ['agent_run'], to: ['artifact'] }],
179
+ evaluates: [{ from: ['evaluation'], to: ['artifact', 'agent_run'] }],
180
+ supports: [{ from: '*', to: ['claim'] }],
181
+ contradicts: [{ from: '*', to: ['claim'] }],
182
+ resolved_to: [{ from: '*', to: ['entity'] }],
183
+ derived_from: [{ from: '*', to: '*' }],
184
+ parent_of: [{ from: '*', to: '*' }],
185
+ depends_on: [{ from: '*', to: '*' }],
186
+ mentions: [{ from: '*', to: '*' }],
187
+ relates_to: [{ from: '*', to: '*' }],
188
+ revises: [{ from: '*', to: '*' }],
189
+ supersedes: [{ from: '*', to: '*' }],
190
+ };
191
+ function describeRule(r) {
192
+ const f = r.from === '*' ? '*' : r.from.join('|');
193
+ const t = r.to === '*' ? '*' : r.to.join('|');
194
+ return `(${f})→(${t})`;
195
+ }
196
+ /** @returns true if the triple is allowed, else a human-readable rejection reason. */
197
+ function edgeAllowed(rel, fromType, toType) {
198
+ const rules = EDGE_ONTOLOGY[rel];
199
+ if (!rules) {
200
+ return true;
201
+ } // unknown relation types are validated earlier; stay permissive here
202
+ for (const r of rules) {
203
+ const okFrom = r.from === '*' || r.from.includes(fromType);
204
+ const okTo = r.to === '*' || r.to.includes(toType);
205
+ if (okFrom && okTo) {
206
+ return true;
207
+ }
208
+ }
209
+ return `edge "${rel}" expects ${rules.map(describeRule).join(' or ')}, but got (${fromType})→(${toType})`;
210
+ }
211
+ // ── Relevance ranking (Tier 1, item 1) ──────────────────────────────────────
212
+ // A deliberately VECTORLESS scorer: field-weighted TF·IDF over the resident node
213
+ // set, lifted by recency + degree. Honours PageIndex's "similarity ≠ relevance" —
214
+ // no embeddings, fully explainable. Field weights: name > summary ≈ aliases > body.
215
+ const FIELD_WEIGHTS = [
216
+ { field: 'name', weight: 3 },
217
+ { field: 'summary', weight: 2 },
218
+ { field: 'aliases', weight: 2 },
219
+ { field: 'body', weight: 1 },
220
+ ];
221
+ /** Weight of the recency lift in finalScore = textScore·(1 + wRecency·decay + wDegree·log1p(deg)). */
222
+ const W_RECENCY = 0.5;
223
+ /** Weight of the connectivity (degree) lift. */
224
+ const W_DEGREE = 0.3;
225
+ /** Recency half-life-ish constant in days: exp(-ageDays/RECENCY_TAU). */
226
+ const RECENCY_TAU_DAYS = 30;
227
+ /** Guaranteed-inclusion floor for an exact-substring hit that scored 0 on tokens
228
+ * (keeps the old substring behaviour a strict subset of ranked results). */
229
+ const SUBSTR_FLOOR = 0.001;
230
+ /** Provenance strength for best-first expansion: strong provenance edges outrank weak ones. */
231
+ const EDGE_STRENGTH = {
232
+ supports: 3, derived_from: 3, produced: 3, evaluates: 3, depends_on: 3, parent_of: 3,
233
+ supersedes: 2, revises: 2, resolved_to: 2, contradicts: 2,
234
+ mentions: 1, relates_to: 1,
235
+ };
82
236
  class GraphStore {
83
237
  identity;
84
238
  dir;
85
239
  file;
240
+ /** Disposable materialised cache for cold-start (item 3). JSONL stays the sole source of truth. */
241
+ snapshotFile;
242
+ snapshotEnabled;
243
+ /** Byte offset the on-disk snapshot covers (0 = none loaded/written this session). */
244
+ snapshotCoversBytes = 0;
245
+ /** Ops applied since the last snapshot was written/loaded — the regeneration trigger. */
246
+ opsSinceSnapshot = 0;
86
247
  nodes = new Map();
87
248
  edges = new Map();
249
+ /** nodeId → incident edges (both directions). Rebuilt on clear, kept in step in apply(). */
250
+ adjacency = new Map();
251
+ /** entity canonical-key → entity node id — the dedup + legacy-id resolution index. */
252
+ entityIndex = new Map();
88
253
  lastSize = -1;
89
254
  lastMtimeMs = -1;
90
- constructor(workspaceRoot, identity) {
255
+ /** byte offset up to (and including) the last COMPLETE log line we've applied. */
256
+ lastOffset = 0;
257
+ /** total log ops applied since the last full rebuild (for dead-weight telemetry). */
258
+ appliedOps = 0;
259
+ /** complete-but-corrupt (JSON-unparseable) MIDDLE lines seen; a torn FINAL line is NOT counted. */
260
+ parseFailures = 0;
261
+ /** wall-clock ms of the last (incremental or full) reload. */
262
+ lastReplayMs = 0;
263
+ /** Monotonic node-set version bumped by apply() — the invalidation signal reload uses. */
264
+ mutationSeq = 0;
265
+ /** IDF over the resident node set, memoised until mutationSeq changes (item 1). */
266
+ idfCache = null;
267
+ idfCacheSeq = -1;
268
+ constructor(workspaceRoot, identity, opts) {
91
269
  this.identity = identity;
92
270
  this.dir = path.join(workspaceRoot, '.autodev', 'graph');
93
271
  this.file = path.join(this.dir, 'graph.jsonl');
272
+ this.snapshotFile = path.join(this.dir, 'graph.snapshot.json');
273
+ // Snapshot is on by default; pass { snapshot: false } for a pure full-replay store
274
+ // (used by tests to prove snapshot+tail == full replay).
275
+ this.snapshotEnabled = opts?.snapshot !== false;
94
276
  this.reloadIfChanged();
95
277
  }
96
278
  /** Where the graph lives (for messages). */
@@ -101,7 +283,27 @@ class GraphStore {
101
283
  fs.mkdirSync(this.dir, { recursive: true });
102
284
  }
103
285
  }
104
- /** Re-materialise from disk when the log changed under us (another agent wrote). */
286
+ clear() {
287
+ this.nodes.clear();
288
+ this.edges.clear();
289
+ this.adjacency.clear();
290
+ this.entityIndex.clear();
291
+ this.appliedOps = 0;
292
+ this.parseFailures = 0;
293
+ this.lastOffset = 0;
294
+ this.idfCache = null;
295
+ this.snapshotCoversBytes = 0;
296
+ this.opsSinceSnapshot = 0;
297
+ }
298
+ /**
299
+ * Re-materialise from disk when the log changed under us (another agent wrote).
300
+ * INCREMENTAL: since the log is append-only we treat `lastOffset` as a byte
301
+ * cursor and apply only the newly-appended tail on top of the live maps (apply
302
+ * is idempotent). A full clear+rebuild happens only when the file shrank below
303
+ * our cursor (a rewrite). A torn final line is left unconsumed and picked up on
304
+ * a later tick once the writer finishes it. This is provably identical to a
305
+ * full replay because apply() is last-writer-wins and the log is append-only.
306
+ */
105
307
  reloadIfChanged() {
106
308
  let st = null;
107
309
  try {
@@ -111,21 +313,195 @@ class GraphStore {
111
313
  st = null;
112
314
  }
113
315
  if (!st) {
114
- if (this.lastSize !== 0) {
115
- this.nodes.clear();
116
- this.edges.clear();
117
- this.lastSize = 0;
118
- this.lastMtimeMs = 0;
316
+ if (this.nodes.size || this.edges.size || this.lastOffset !== 0) {
317
+ this.clear();
119
318
  }
319
+ this.lastSize = 0;
320
+ this.lastMtimeMs = 0;
321
+ this.lastOffset = 0;
120
322
  return;
121
323
  }
122
324
  if (st.size === this.lastSize && st.mtimeMs === this.lastMtimeMs) {
123
325
  return;
124
326
  }
125
- this.nodes.clear();
126
- this.edges.clear();
127
- const text = fs.readFileSync(this.file, 'utf8');
128
- for (const line of text.split('\n')) {
327
+ const t0 = Date.now();
328
+ const firstLoad = this.lastSize < 0;
329
+ if (st.size < this.lastOffset) {
330
+ // the file was truncated/rewritten under us — rebuild from scratch.
331
+ this.clear();
332
+ this.loadFromScratch(st.size);
333
+ }
334
+ else if (firstLoad) {
335
+ // cold start — try the materialised snapshot, else full replay.
336
+ this.loadFromScratch(st.size);
337
+ }
338
+ else {
339
+ // append-only growth — fold just the new tail onto the current state.
340
+ this.replayRange(this.lastOffset, st.size);
341
+ }
342
+ this.lastReplayMs = Date.now() - t0;
343
+ this.lastSize = st.size;
344
+ this.lastMtimeMs = st.mtimeMs;
345
+ if (this.snapshotEnabled) {
346
+ this.maybeWriteSnapshot(st.size);
347
+ }
348
+ }
349
+ // ── materialised snapshot (Tier 2, item 3) ─────────────────────────────────
350
+ /**
351
+ * Cold-start load: if a valid snapshot covers a prefix of the current log, adopt
352
+ * it and tail-replay only the uncovered bytes; otherwise full-replay. The
353
+ * snapshot is a DISPOSABLE cache — a missing / stale / corrupt one simply falls
354
+ * back to full replay, and the JSONL is never touched.
355
+ */
356
+ loadFromScratch(size) {
357
+ if (this.snapshotEnabled && this.tryLoadSnapshot(size)) {
358
+ this.replayRange(this.lastOffset, size); // fold the tail beyond the snapshot
359
+ return;
360
+ }
361
+ this.clear();
362
+ this.replayRange(0, size);
363
+ }
364
+ /** sha256 of the first min(4096, covers) bytes of the log — the cheap rewrite guard. */
365
+ headSig(covers) {
366
+ const n = Math.min(4096, covers);
367
+ const fd = fs.openSync(this.file, 'r');
368
+ try {
369
+ const b = Buffer.allocUnsafe(n);
370
+ let read = 0;
371
+ while (read < n) {
372
+ const k = fs.readSync(fd, b, read, n - read, read);
373
+ if (k <= 0) {
374
+ break;
375
+ }
376
+ read += k;
377
+ }
378
+ return crypto.createHash('sha256').update(b.subarray(0, read)).digest('hex');
379
+ }
380
+ finally {
381
+ fs.closeSync(fd);
382
+ }
383
+ }
384
+ /**
385
+ * Adopt the on-disk snapshot iff it is a valid prefix of the current log:
386
+ * `coversBytes` in (0, size] AND the head-signature still matches (guards against
387
+ * a rewritten/truncated log whose prefix changed). Rebuilds the derived indexes
388
+ * from the resident node/edge set rather than trusting serialized indexes.
389
+ */
390
+ tryLoadSnapshot(size) {
391
+ try {
392
+ if (!fs.existsSync(this.snapshotFile)) {
393
+ return false;
394
+ }
395
+ const snap = JSON.parse(fs.readFileSync(this.snapshotFile, 'utf8'));
396
+ const covers = Number(snap.coversBytes);
397
+ if (!Number.isFinite(covers) || covers <= 0 || covers > size) {
398
+ return false;
399
+ }
400
+ if (!Array.isArray(snap.nodes) || !Array.isArray(snap.edges)) {
401
+ return false;
402
+ }
403
+ if (typeof snap.headSig !== 'string' || this.headSig(covers) !== snap.headSig) {
404
+ return false;
405
+ }
406
+ // valid → adopt. Populate the maps and rebuild adjacency + entity indexes.
407
+ this.clear();
408
+ for (const n of snap.nodes) {
409
+ this.nodes.set(n.id, n);
410
+ this.indexEntity(n);
411
+ }
412
+ for (const e of snap.edges) {
413
+ this.edges.set(e.id, e);
414
+ this.indexEdge(e);
415
+ }
416
+ this.appliedOps = this.nodes.size + this.edges.size;
417
+ this.mutationSeq++;
418
+ this.idfCache = null;
419
+ this.lastOffset = covers;
420
+ this.snapshotCoversBytes = covers;
421
+ this.opsSinceSnapshot = 0;
422
+ return true;
423
+ }
424
+ catch {
425
+ return false;
426
+ }
427
+ }
428
+ /** Regenerate the snapshot when the uncovered tail grew past the byte/op threshold. */
429
+ maybeWriteSnapshot(size) {
430
+ const uncoveredBytes = size - this.snapshotCoversBytes;
431
+ if (uncoveredBytes >= SNAPSHOT_TAIL_BYTES || this.opsSinceSnapshot >= SNAPSHOT_TAIL_OPS) {
432
+ this.writeSnapshot();
433
+ }
434
+ }
435
+ /**
436
+ * Write the snapshot atomically (temp file + rename) so a partial write is never
437
+ * visible and concurrent multi-agent regenerations can't corrupt it — the loser
438
+ * of a rename race simply leaves a consistent, self-describing file behind. Snaps
439
+ * only the byte prefix we've fully applied (`lastOffset`), so a torn final line is
440
+ * never captured.
441
+ */
442
+ writeSnapshot() {
443
+ const covers = this.lastOffset;
444
+ if (covers <= 0) {
445
+ return false;
446
+ }
447
+ try {
448
+ this.ensureDir();
449
+ const snap = {
450
+ coversBytes: covers,
451
+ generatedAt: new Date().toISOString(),
452
+ generatedBy: this.identity.agentId,
453
+ headSig: this.headSig(covers),
454
+ nodes: [...this.nodes.values()],
455
+ edges: [...this.edges.values()],
456
+ };
457
+ const tmp = `${this.snapshotFile}.${process.pid}.${shortId()}.tmp`;
458
+ fs.writeFileSync(tmp, JSON.stringify(snap));
459
+ fs.renameSync(tmp, this.snapshotFile);
460
+ this.snapshotCoversBytes = covers;
461
+ this.opsSinceSnapshot = 0;
462
+ return true;
463
+ }
464
+ catch {
465
+ return false;
466
+ }
467
+ }
468
+ /** Force a snapshot now (test hook / explicit checkpoint). No-op when snapshots are disabled. */
469
+ materializeSnapshot() {
470
+ this.reloadIfChanged();
471
+ return this.snapshotEnabled ? this.writeSnapshot() : false;
472
+ }
473
+ /** Read bytes [start,end), apply every COMPLETE line, and advance lastOffset past them. */
474
+ replayRange(start, end) {
475
+ if (end <= start) {
476
+ this.lastOffset = end;
477
+ return;
478
+ }
479
+ let buf;
480
+ const fd = fs.openSync(this.file, 'r');
481
+ try {
482
+ const len = end - start;
483
+ const b = Buffer.allocUnsafe(len);
484
+ let read = 0;
485
+ while (read < len) {
486
+ const n = fs.readSync(fd, b, read, len - read, start + read);
487
+ if (n <= 0) {
488
+ break;
489
+ }
490
+ read += n;
491
+ }
492
+ buf = read === len ? b : b.subarray(0, read);
493
+ }
494
+ finally {
495
+ fs.closeSync(fd);
496
+ }
497
+ // Only apply up to the last newline; the trailing bytes (if any) are a torn
498
+ // final line — leave the cursor before them so the next tick re-reads them.
499
+ const lastNl = buf.lastIndexOf(0x0A);
500
+ if (lastNl < 0) {
501
+ return;
502
+ } // no complete line yet
503
+ const complete = buf.subarray(0, lastNl + 1).toString('utf8');
504
+ for (const line of complete.split('\n')) {
129
505
  const t = line.trim();
130
506
  if (!t) {
131
507
  continue;
@@ -135,21 +511,30 @@ class GraphStore {
135
511
  op = JSON.parse(t);
136
512
  }
137
513
  catch {
514
+ this.parseFailures++;
138
515
  continue;
139
- } // tolerate a torn final line
516
+ } // a complete but corrupt MIDDLE line — surfaced in stats
140
517
  this.apply(op);
141
518
  }
142
- this.lastSize = st.size;
143
- this.lastMtimeMs = st.mtimeMs;
519
+ this.lastOffset = start + lastNl + 1;
144
520
  }
145
521
  apply(op) {
522
+ this.appliedOps++;
523
+ this.opsSinceSnapshot++;
524
+ this.mutationSeq++; // invalidates the IDF cache (same signal reload rides)
146
525
  if (op.op === 'node') {
147
526
  const { op: _o, ...node } = op;
148
527
  this.nodes.set(node.id, node);
528
+ this.indexEntity(node);
149
529
  }
150
530
  else if (op.op === 'edge') {
151
531
  const { op: _o, ...edge } = op;
532
+ const prev = this.edges.get(edge.id);
533
+ if (prev) {
534
+ this.unindexEdge(prev);
535
+ }
152
536
  this.edges.set(edge.id, edge);
537
+ this.indexEdge(edge);
153
538
  }
154
539
  else if (op.op === 'supersede') {
155
540
  const old = this.nodes.get(op.id);
@@ -159,19 +544,79 @@ class GraphStore {
159
544
  }
160
545
  }
161
546
  }
547
+ indexEntity(n) {
548
+ if (n.type !== 'entity') {
549
+ return;
550
+ }
551
+ // name wins; aliases fill gaps so they can resolve the same entity.
552
+ this.entityIndex.set(canonicalKey(n.name), n.id);
553
+ for (const a of n.aliases || []) {
554
+ const k = canonicalKey(a);
555
+ if (!this.entityIndex.has(k)) {
556
+ this.entityIndex.set(k, n.id);
557
+ }
558
+ }
559
+ }
560
+ indexEdge(e) {
561
+ this.pushAdj(e.from, e);
562
+ if (e.to !== e.from) {
563
+ this.pushAdj(e.to, e);
564
+ }
565
+ }
566
+ pushAdj(nid, e) {
567
+ const list = this.adjacency.get(nid);
568
+ if (list) {
569
+ list.push(e);
570
+ }
571
+ else {
572
+ this.adjacency.set(nid, [e]);
573
+ }
574
+ }
575
+ unindexEdge(e) {
576
+ for (const nid of new Set([e.from, e.to])) {
577
+ const list = this.adjacency.get(nid);
578
+ if (!list) {
579
+ continue;
580
+ }
581
+ const i = list.findIndex((x) => x.id === e.id);
582
+ if (i >= 0) {
583
+ list.splice(i, 1);
584
+ }
585
+ }
586
+ }
162
587
  append(op) {
163
588
  this.ensureDir();
164
- fs.appendFileSync(this.file, JSON.stringify(op) + '\n');
589
+ const line = JSON.stringify(op) + '\n';
590
+ const bytes = Buffer.byteLength(line, 'utf8');
591
+ // SAFETY (Tier 0, item 3): appendFileSync is only atomic below PIPE_BUF; a
592
+ // larger line can interleave with a concurrent agent's append in this
593
+ // lock-free model. Never silent — warn on stderr with a pointer to the cause.
594
+ if (bytes > PIPE_BUF_SAFE) {
595
+ const id = op.id ?? op.op;
596
+ process.stderr.write(`[graph] warning: op ${id} serialises to ${bytes} bytes (> ${PIPE_BUF_SAFE}); ` +
597
+ `appendFileSync is not atomic above PIPE_BUF and may interleave with a concurrent writer — ` +
598
+ `consider shortening its body/props.\n`);
599
+ }
600
+ fs.appendFileSync(this.file, line);
601
+ this.apply(op);
165
602
  try {
166
603
  const st = fs.statSync(this.file);
167
604
  this.lastSize = st.size;
168
605
  this.lastMtimeMs = st.mtimeMs;
606
+ this.lastOffset = st.size; // our own write is fully consumed
169
607
  }
170
608
  catch { /* stat best-effort */ }
171
- this.apply(op);
172
609
  }
173
610
  // ── writes ───────────────────────────────────────────────────────────────
174
611
  addNode(input) {
612
+ return this.addNodeInternal(input, { scaffold: true });
613
+ }
614
+ /**
615
+ * The write path. `scaffold:true` (the public {@link addNode}) additionally runs
616
+ * the deterministic parent_of auto-scaffold (item 1); the scaffold's own entity
617
+ * writes pass `scaffold:false` so they never recurse.
618
+ */
619
+ addNodeInternal(input, opts) {
175
620
  this.reloadIfChanged();
176
621
  const type = String(input.type || '').toLowerCase().trim();
177
622
  if (!exports.NODE_TYPES.includes(type)) {
@@ -193,16 +638,25 @@ class GraphStore {
193
638
  if (type === 'evaluation' && !String(input.rubric || '').trim()) {
194
639
  throw new GraphInvariantError('an "evaluation" needs a rubric (pass rubric:"what criteria were checked")');
195
640
  }
196
- // Stable ids: entities dedupe by normalised name so resolution is idempotent;
197
- // everything else gets a fresh id unless the caller pins one (upsert).
198
- const id = String(input.id || '').trim()
199
- || (type === 'entity' ? `entity:${slug(name)}` : `${type}:${shortId()}`);
641
+ // Stable ids: entities dedupe by a sorted-token-set canonical key so
642
+ // UsageService / usage-service / "usage service" resolve to ONE id and old
643
+ // graphs stay addressable because the key index is seeded from stored names.
644
+ // Everything else gets a fresh id unless the caller pins one (upsert).
645
+ const id = String(input.id || '').trim() || this.mintId(type, name, input.aliases);
200
646
  const now = new Date().toISOString();
201
647
  const existing = this.nodes.get(id);
648
+ // Stamp when the summary was (re)written so staleness — gaining children or being
649
+ // superseded AFTER the summary — is detectable without a separate op format.
650
+ const summaryProvided = input.summary != null && String(input.summary).trim() !== '';
651
+ let props = input.props ?? existing?.props;
652
+ if (summaryProvided) {
653
+ props = { ...(props || {}), summaryAt: now };
654
+ }
202
655
  const node = {
203
656
  id, type, name,
204
657
  body: input.body ?? existing?.body,
205
- props: input.props ?? existing?.props,
658
+ summary: summaryProvided ? String(input.summary).trim() : existing?.summary,
659
+ props,
206
660
  source: input.source ?? existing?.source,
207
661
  inference: input.inference ?? existing?.inference,
208
662
  version: input.version ?? existing?.version,
@@ -215,8 +669,92 @@ class GraphStore {
215
669
  updatedAt: now,
216
670
  };
217
671
  this.append({ op: 'node', ...node });
672
+ if (opts.scaffold) {
673
+ this.scaffold(node);
674
+ }
218
675
  return { node, created: !existing };
219
676
  }
677
+ // ── parent_of auto-scaffold from source paths / area tags (Tier 2, item 1) ──
678
+ /**
679
+ * Deterministically fill the parent_of tree from a fact's provenance — NO LLM.
680
+ * A `file:line` source on a claim/artifact/decision/note upserts `entity` nodes
681
+ * for the file and its ancestor dirs and links a `dir → file → fact` spine; a
682
+ * `props.area` tag upserts an area entity and parents the fact under it. Every
683
+ * derived node/edge carries this run's provenance and a `props.autoScaffold=true`
684
+ * marker, and is idempotent (deterministic ids + edge dedup) so re-adds and
685
+ * concurrent writers converge instead of duplicating.
686
+ */
687
+ scaffold(node) {
688
+ try {
689
+ if (SCAFFOLD_SOURCE_TYPES.has(node.type) && node.source) {
690
+ this.scaffoldFromSource(node);
691
+ }
692
+ const area = node.props?.area;
693
+ if (typeof area === 'string' && area.trim()) {
694
+ this.scaffoldFromArea(node, area.trim());
695
+ }
696
+ }
697
+ catch { /* scaffolding is best-effort — it must never fail the primary write */ }
698
+ }
699
+ scaffoldFromSource(node) {
700
+ const segs = parseSourcePath(node.source);
701
+ if (!segs || !segs.length) {
702
+ return;
703
+ }
704
+ let prevId = null;
705
+ for (let i = 0; i < segs.length; i++) {
706
+ const p = segs[i];
707
+ const isFile = i === segs.length - 1;
708
+ const id = `entity:path:${p}`;
709
+ this.addNodeInternal({ type: 'entity', name: p, id, props: { autoScaffold: true, kind: isFile ? 'file' : 'dir', path: p } }, { scaffold: false });
710
+ if (prevId) {
711
+ this.linkParentOf(prevId, id, node.source);
712
+ }
713
+ prevId = id;
714
+ }
715
+ if (prevId) {
716
+ this.linkParentOf(prevId, node.id, node.source);
717
+ }
718
+ }
719
+ scaffoldFromArea(node, area) {
720
+ const id = `entity:area:${slug(area)}`;
721
+ this.addNodeInternal({ type: 'entity', name: area, id, props: { autoScaffold: true, kind: 'area', area } }, { scaffold: false });
722
+ this.linkParentOf(id, node.id);
723
+ }
724
+ /** Is there already an edge of `type` from→to? (idempotency for scaffold/rollup edges). */
725
+ hasEdge(type, from, to) {
726
+ for (const e of this.adjacency.get(from) || []) {
727
+ if (e.type === type && e.from === from && e.to === to) {
728
+ return true;
729
+ }
730
+ }
731
+ return false;
732
+ }
733
+ /** Add a parent_of edge (marked autoScaffold) unless it already exists. */
734
+ linkParentOf(from, to, source) {
735
+ if (from === to || this.hasEdge('parent_of', from, to)) {
736
+ return;
737
+ }
738
+ this.addEdge({ type: 'parent_of', from, to, source, props: { autoScaffold: true } });
739
+ }
740
+ /** Deterministic id for a new node: entities dedupe by canonical key (+ aliases), others are fresh. */
741
+ mintId(type, name, aliases) {
742
+ if (type !== 'entity') {
743
+ return `${type}:${shortId()}`;
744
+ }
745
+ const key = canonicalKey(name);
746
+ let existingId = this.entityIndex.get(key);
747
+ if (!existingId) {
748
+ for (const a of aliases || []) {
749
+ const hit = this.entityIndex.get(canonicalKey(a));
750
+ if (hit) {
751
+ existingId = hit;
752
+ break;
753
+ }
754
+ }
755
+ }
756
+ return existingId || `entity:${key}`;
757
+ }
220
758
  addEdge(input) {
221
759
  this.reloadIfChanged();
222
760
  const type = String(input.type || '').toLowerCase().trim();
@@ -228,12 +766,20 @@ class GraphStore {
228
766
  if (!from || !to) {
229
767
  throw new GraphInvariantError('edge needs both "from" and "to" node ids');
230
768
  }
231
- if (!this.nodes.has(from)) {
769
+ const fromNode = this.nodes.get(from);
770
+ const toNode = this.nodes.get(to);
771
+ if (!fromNode) {
232
772
  throw new GraphInvariantError(`edge "from" node not found: ${from}`);
233
773
  }
234
- if (!this.nodes.has(to)) {
774
+ if (!toNode) {
235
775
  throw new GraphInvariantError(`edge "to" node not found: ${to}`);
236
776
  }
777
+ // SAFETY (Tier 0, item 6): reject an obviously-wrong (fromType, relation,
778
+ // targetType) triple — e.g. evaluates: commit→task — with a helpful message.
779
+ const verdict = edgeAllowed(type, fromNode.type, toNode.type);
780
+ if (verdict !== true) {
781
+ throw new GraphInvariantError(verdict);
782
+ }
237
783
  const edge = {
238
784
  id: `edge:${shortId()}`, type, from, to,
239
785
  props: input.props, source: input.source,
@@ -269,25 +815,122 @@ class GraphStore {
269
815
  return this.nodes.get(idOrName);
270
816
  }
271
817
  const needle = idOrName.toLowerCase();
272
- // exact name match first, then alias, then the entity-slug convention
818
+ // exact name match first, then alias, then the entity canonical-key index,
819
+ // then the legacy slug convention (so pre-canonical graphs still resolve).
273
820
  for (const n of this.nodes.values()) {
274
821
  if (n.name.toLowerCase() === needle) {
275
822
  return n;
276
823
  }
277
824
  }
278
825
  for (const n of this.nodes.values()) {
279
- if ((n.aliases || []).some(a => a.toLowerCase() === needle)) {
826
+ if ((n.aliases || []).some((a) => a.toLowerCase() === needle)) {
280
827
  return n;
281
828
  }
282
829
  }
830
+ const eid = this.entityIndex.get(canonicalKey(idOrName));
831
+ if (eid && this.nodes.has(eid)) {
832
+ return this.nodes.get(eid);
833
+ }
283
834
  return this.nodes.get(`entity:${slug(idOrName)}`);
284
835
  }
285
- query(opts) {
836
+ /** Incident `contradicts` edges of a node, resolved to {id,name} of the other endpoint. */
837
+ contradictionsFor(id) {
838
+ const out = [];
839
+ for (const e of this.adjacency.get(id) || []) {
840
+ if (e.type !== 'contradicts') {
841
+ continue;
842
+ }
843
+ const otherId = e.from === id ? e.to : e.from;
844
+ out.push({ id: otherId, name: this.nodes.get(otherId)?.name ?? otherId });
845
+ }
846
+ return out;
847
+ }
848
+ /** Node degree (incident edge count) — shared ranking infra for Tier 1. */
849
+ degree(id) {
850
+ return (this.adjacency.get(id) || []).length;
851
+ }
852
+ // ── relevance ranking (Tier 1, item 1) ────────────────────────────────────
853
+ /** IDF over the resident node set, memoised until the node set mutates. */
854
+ idf() {
855
+ if (this.idfCache && this.idfCacheSeq === this.mutationSeq) {
856
+ return this.idfCache;
857
+ }
858
+ const df = new Map();
859
+ const N = this.nodes.size || 1;
860
+ for (const n of this.nodes.values()) {
861
+ const toks = new Set([
862
+ ...tokenize(n.name),
863
+ ...tokenize(n.summary || ''),
864
+ ...tokenize((n.aliases || []).join(' ')),
865
+ ...tokenize(n.body || ''),
866
+ ]);
867
+ for (const t of toks) {
868
+ df.set(t, (df.get(t) || 0) + 1);
869
+ }
870
+ }
871
+ const idf = new Map();
872
+ for (const [t, d] of df) {
873
+ idf.set(t, Math.log(1 + N / (1 + d)));
874
+ }
875
+ this.idfCache = idf;
876
+ this.idfCacheSeq = this.mutationSeq;
877
+ return idf;
878
+ }
879
+ /** Exponential recency lift in [0,1]: 1 at now, decaying with age (τ≈30d). */
880
+ recencyDecay(ts) {
881
+ const ms = Date.parse(ts || '');
882
+ if (!isFinite(ms)) {
883
+ return 0;
884
+ }
885
+ const days = Math.max(0, (Date.now() - ms) / 86400000);
886
+ return Math.exp(-days / RECENCY_TAU_DAYS);
887
+ }
888
+ /** Field-weighted TF·IDF text score for a node against a set of query terms. */
889
+ scoreNode(n, terms, idf) {
890
+ if (!terms.length) {
891
+ return { score: 0, matched: [] };
892
+ }
893
+ const fieldTokens = {
894
+ name: tokenize(n.name),
895
+ summary: tokenize(n.summary || ''),
896
+ aliases: tokenize((n.aliases || []).join(' ')),
897
+ body: tokenize(n.body || ''),
898
+ };
899
+ let score = 0;
900
+ const matched = [];
901
+ for (const term of terms) {
902
+ let tf = 0;
903
+ for (const { field, weight } of FIELD_WEIGHTS) {
904
+ for (const tok of fieldTokens[field]) {
905
+ if (tok === term) {
906
+ tf += weight;
907
+ }
908
+ }
909
+ }
910
+ if (tf > 0) {
911
+ score += tf * (idf.get(term) || 0);
912
+ matched.push(term);
913
+ }
914
+ }
915
+ return { score, matched };
916
+ }
917
+ /**
918
+ * Relevance-ranked query (Tier 1, item 1). OR-semantics: a node is a hit if it
919
+ * matches ≥1 query term OR contains the intent as an exact substring (the old
920
+ * behaviour, kept as a guaranteed-inclusion signal → strict superset). Ranked by
921
+ * `textScore·(1 + wRecency·decay + wDegree·log1p(degree))`. `mode:'recent'` keeps
922
+ * the pure recency order; superseded nodes hidden unless includeSuperseded.
923
+ */
924
+ scoredQuery(opts) {
286
925
  this.reloadIfChanged();
287
926
  const limit = Math.max(1, Math.min(200, opts.limit ?? 30));
288
927
  const type = opts.type ? String(opts.type).toLowerCase() : undefined;
289
- const text = opts.text ? String(opts.text).toLowerCase() : undefined;
290
- const out = [];
928
+ const text = opts.text ? String(opts.text) : undefined;
929
+ const textLower = text ? text.toLowerCase() : undefined;
930
+ const terms = text ? Array.from(new Set(tokenize(text))) : [];
931
+ const idf = terms.length ? this.idf() : null;
932
+ const mode = opts.mode ?? 'relevance';
933
+ const rows = [];
291
934
  for (const n of this.nodes.values()) {
292
935
  if (opts.id && n.id !== opts.id) {
293
936
  continue;
@@ -298,22 +941,55 @@ class GraphStore {
298
941
  if (!opts.includeSuperseded && n.supersededBy) {
299
942
  continue;
300
943
  }
301
- if (text) {
302
- const hay = `${n.name}\n${n.body ?? ''}\n${(n.aliases || []).join(' ')}`.toLowerCase();
303
- if (!hay.includes(text)) {
944
+ if (textLower) {
945
+ const { score: ts, matched } = this.scoreNode(n, terms, idf);
946
+ const hay = `${n.name}\n${n.summary ?? ''}\n${n.body ?? ''}\n${(n.aliases || []).join(' ')}`.toLowerCase();
947
+ const substring = hay.includes(textLower);
948
+ let textScore = ts;
949
+ if (substring && textScore <= 0) {
950
+ textScore = SUBSTR_FLOOR;
951
+ } // guaranteed inclusion
952
+ if (textScore <= 0) {
304
953
  continue;
305
- }
954
+ } // no term hit and no substring — a real miss
955
+ const final = textScore * (1 + W_RECENCY * this.recencyDecay(n.updatedAt) + W_DEGREE * Math.log1p(this.degree(n.id)));
956
+ rows.push({ node: n, score: final, textScore, matched, substring });
957
+ }
958
+ else {
959
+ rows.push({ node: n, score: this.recencyDecay(n.updatedAt), textScore: 0, matched: [], substring: false });
306
960
  }
307
- out.push(n);
308
961
  }
309
- // freshest first — recent verified facts are the most useful context
310
- out.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
311
- return out.slice(0, limit);
962
+ rows.sort((a, b) => {
963
+ if (mode === 'recent' || !textLower) {
964
+ const r = (b.node.updatedAt || '').localeCompare(a.node.updatedAt || '');
965
+ if (r !== 0) {
966
+ return r;
967
+ }
968
+ return b.score - a.score;
969
+ }
970
+ if (b.score !== a.score) {
971
+ return b.score - a.score;
972
+ }
973
+ return (b.node.updatedAt || '').localeCompare(a.node.updatedAt || '');
974
+ });
975
+ return rows.slice(0, limit);
976
+ }
977
+ /**
978
+ * Search nodes by type and/or text. Thin wrapper over {@link scoredQuery}: with
979
+ * `text` it returns relevance-ranked hits (item 1); without, recency order. The
980
+ * old pure-substring behaviour survives as a guaranteed-inclusion subset.
981
+ */
982
+ query(opts) {
983
+ return this.scoredQuery(opts).map((r) => r.node);
312
984
  }
313
985
  /**
314
986
  * Bounded neighbourhood around a node — the playbook's "context construction
315
987
  * from a graph": resolve the entity, expand a hop or two over allowed edge
316
988
  * types, and return a small, citable subgraph rather than the whole graph.
989
+ * Uses the adjacency index (O(visited degree), not O(E·hops)) and returns the
990
+ * BFS hop-distance per node so the caller can rank/token-budget the result.
991
+ * `includeConflicts` (default true) always follows `contradicts` edges so a
992
+ * conflicting claim can never be hidden by an `edgeTypes` filter.
317
993
  */
318
994
  neighbors(opts) {
319
995
  this.reloadIfChanged();
@@ -324,57 +1000,529 @@ class GraphStore {
324
1000
  const hops = Math.max(1, Math.min(3, opts.hops ?? 1));
325
1001
  const edgeLimit = Math.max(1, Math.min(200, opts.limit ?? 40));
326
1002
  const allow = opts.edgeTypes && opts.edgeTypes.length
327
- ? new Set(opts.edgeTypes.map(e => e.toLowerCase())) : null;
328
- const seen = new Set([center.id]);
1003
+ ? new Set(opts.edgeTypes.map((e) => e.toLowerCase())) : null;
1004
+ if (allow && opts.includeConflicts !== false) {
1005
+ allow.add('contradicts');
1006
+ }
1007
+ const dist = new Map([[center.id, 0]]);
329
1008
  const nodeOut = new Map([[center.id, center]]);
330
1009
  const edgeOut = [];
1010
+ const edgeSeen = new Set();
331
1011
  let frontier = [center.id];
332
1012
  for (let h = 0; h < hops && edgeOut.length < edgeLimit; h++) {
333
1013
  const next = [];
334
- for (const e of this.edges.values()) {
335
- if (allow && !allow.has(e.type)) {
336
- continue;
337
- }
338
- const touchesFrontier = frontier.includes(e.from) || frontier.includes(e.to);
339
- if (!touchesFrontier) {
340
- continue;
341
- }
342
- if (!edgeOut.find(x => x.id === e.id)) {
343
- edgeOut.push(e);
344
- if (edgeOut.length >= edgeLimit) {
345
- break;
1014
+ for (const nid of frontier) {
1015
+ for (const e of this.adjacency.get(nid) || []) {
1016
+ if (allow && !allow.has(e.type)) {
1017
+ continue;
346
1018
  }
347
- }
348
- for (const nid of [e.from, e.to]) {
349
- if (!seen.has(nid)) {
350
- seen.add(nid);
351
- const nn = this.nodes.get(nid);
1019
+ if (!edgeSeen.has(e.id)) {
1020
+ edgeSeen.add(e.id);
1021
+ edgeOut.push(e);
1022
+ if (edgeOut.length >= edgeLimit) {
1023
+ break;
1024
+ }
1025
+ }
1026
+ const other = e.from === nid ? e.to : e.from;
1027
+ if (!nodeOut.has(other)) {
1028
+ const nn = this.nodes.get(other);
352
1029
  if (nn) {
353
- nodeOut.set(nid, nn);
354
- next.push(nid);
1030
+ nodeOut.set(other, nn);
1031
+ dist.set(other, h + 1);
1032
+ next.push(other);
355
1033
  }
356
1034
  }
357
1035
  }
1036
+ if (edgeOut.length >= edgeLimit) {
1037
+ break;
1038
+ }
358
1039
  }
359
1040
  frontier = next;
360
1041
  if (!frontier.length) {
361
1042
  break;
362
1043
  }
363
1044
  }
364
- return { center, nodes: [...nodeOut.values()], edges: edgeOut };
1045
+ return { center, nodes: [...nodeOut.values()], edges: edgeOut, dist: Object.fromEntries(dist) };
1046
+ }
1047
+ // ── summaries (Tier 1, item 3) ────────────────────────────────────────────
1048
+ /** Distinct neighbour nodes of a node (via the adjacency index). */
1049
+ neighborNodes(id) {
1050
+ const out = [];
1051
+ const seen = new Set();
1052
+ for (const e of this.adjacency.get(id) || []) {
1053
+ const other = e.from === id ? e.to : e.from;
1054
+ if (seen.has(other)) {
1055
+ continue;
1056
+ }
1057
+ seen.add(other);
1058
+ const nn = this.nodes.get(other);
1059
+ if (nn) {
1060
+ out.push(nn);
1061
+ }
1062
+ }
1063
+ return out;
1064
+ }
1065
+ /** `parent_of` children of a node (this node as the parent). */
1066
+ childrenOf(id) {
1067
+ const out = [];
1068
+ for (const e of this.adjacency.get(id) || []) {
1069
+ if (e.type === 'parent_of' && e.from === id) {
1070
+ const c = this.nodes.get(e.to);
1071
+ if (c) {
1072
+ out.push(c);
1073
+ }
1074
+ }
1075
+ }
1076
+ return out;
1077
+ }
1078
+ /**
1079
+ * DETERMINISTIC one-line rollup for an interior/hub node with NO authored
1080
+ * summary — NO LLM call. Synthesised from structure: child (or, absent
1081
+ * `parent_of` children, neighbour) type counts + the top child names by degree.
1082
+ * Returns undefined for a leaf/low-degree node (nothing to summarise).
1083
+ */
1084
+ rollupSummary(id) {
1085
+ let kids = this.childrenOf(id);
1086
+ if (!kids.length) {
1087
+ if (this.degree(id) < 3) {
1088
+ return undefined;
1089
+ } // a leaf — no structure to roll up
1090
+ kids = this.neighborNodes(id);
1091
+ }
1092
+ if (!kids.length) {
1093
+ return undefined;
1094
+ }
1095
+ const counts = new Map();
1096
+ for (const k of kids) {
1097
+ counts.set(k.type, (counts.get(k.type) || 0) + 1);
1098
+ }
1099
+ const countStr = [...counts.entries()]
1100
+ .sort((a, b) => b[1] - a[1])
1101
+ .map(([t, c]) => `${c} ${t}`)
1102
+ .join(', ');
1103
+ const top = [...kids]
1104
+ .sort((a, b) => this.degree(b.id) - this.degree(a.id))
1105
+ .slice(0, 3)
1106
+ .map((k) => k.name);
1107
+ return `${countStr} · top: ${top.join(', ')}`;
1108
+ }
1109
+ /**
1110
+ * The summary to SHOW for a node: the authored one if present, else the
1111
+ * deterministic structural rollup (flagged synthesized), else nothing.
1112
+ */
1113
+ effectiveSummary(node) {
1114
+ if (node.summary && node.summary.trim()) {
1115
+ return { text: node.summary.trim(), synthesized: false };
1116
+ }
1117
+ const roll = this.rollupSummary(node.id);
1118
+ return roll ? { text: roll, synthesized: true } : undefined;
1119
+ }
1120
+ /**
1121
+ * An AUTHORED summary is stale when the node has been superseded, or gained a
1122
+ * `parent_of` child AFTER the summary was written (`props.summaryAt`). Nodes
1123
+ * with no authored summary are "unsummarized", not stale.
1124
+ */
1125
+ summaryStale(node) {
1126
+ if (!node.summary || !node.summary.trim()) {
1127
+ return false;
1128
+ }
1129
+ if (node.supersededBy) {
1130
+ return true;
1131
+ }
1132
+ const at = Date.parse(String(node.props?.summaryAt ?? node.updatedAt));
1133
+ if (!isFinite(at)) {
1134
+ return false;
1135
+ }
1136
+ for (const e of this.adjacency.get(node.id) || []) {
1137
+ if (e.type === 'parent_of' && e.from === node.id && Date.parse(e.createdAt) > at) {
1138
+ return true;
1139
+ }
1140
+ }
1141
+ return false;
1142
+ }
1143
+ mapEntry(node) {
1144
+ const es = this.effectiveSummary(node);
1145
+ return {
1146
+ id: node.id, type: node.type, name: node.name,
1147
+ summary: es?.text, synthesized: es?.synthesized,
1148
+ stale: this.summaryStale(node) || undefined,
1149
+ degree: this.degree(node.id),
1150
+ };
1151
+ }
1152
+ // ── cold-start anchor index (Tier 1, item 2) ───────────────────────────────
1153
+ /**
1154
+ * "Where do I start" for an agent with no id/name in hand. Ranks entities (and
1155
+ * any node when `focusType` unset) by degree() over the adjacency index, and
1156
+ * bundles the open `question`s, freshest `decision`s, and live `contradicts`
1157
+ * pairs it already knows how to find. Purely read-side; render via graphRender.
1158
+ */
1159
+ map(opts) {
1160
+ this.reloadIfChanged();
1161
+ const topK = Math.max(1, Math.min(50, opts?.maxChildren ?? 10));
1162
+ const focus = opts?.focusType ? String(opts.focusType).toLowerCase() : undefined;
1163
+ const live = [...this.nodes.values()].filter((n) => !n.supersededBy);
1164
+ const hubs = live
1165
+ .filter((n) => this.degree(n.id) > 0 && (!focus || n.type === focus))
1166
+ .sort((a, b) => this.degree(b.id) - this.degree(a.id) || (b.updatedAt || '').localeCompare(a.updatedAt || ''))
1167
+ .slice(0, topK)
1168
+ .map((n) => this.mapEntry(n));
1169
+ const freshest = (t) => live
1170
+ .filter((n) => n.type === t)
1171
+ .sort((a, b) => (b.updatedAt || '').localeCompare(a.updatedAt || ''))
1172
+ .slice(0, topK)
1173
+ .map((n) => this.mapEntry(n));
1174
+ // live (unsuperseded on both ends) contradicts pairs, deduped
1175
+ const seenPair = new Set();
1176
+ const contradictions = [];
1177
+ for (const e of this.edges.values()) {
1178
+ if (e.type !== 'contradicts') {
1179
+ continue;
1180
+ }
1181
+ const a = this.nodes.get(e.from);
1182
+ const b = this.nodes.get(e.to);
1183
+ if (!a || !b || a.supersededBy || b.supersededBy) {
1184
+ continue;
1185
+ }
1186
+ const key = [a.id, b.id].sort().join('|');
1187
+ if (seenPair.has(key)) {
1188
+ continue;
1189
+ }
1190
+ seenPair.add(key);
1191
+ contradictions.push({ a: { id: a.id, name: a.name }, b: { id: b.id, name: b.name } });
1192
+ if (contradictions.length >= topK) {
1193
+ break;
1194
+ }
1195
+ }
1196
+ const pinned = this.pinnedNodes().slice(0, topK).map((n) => this.mapEntry(n));
1197
+ return { pinned, hubs, questions: freshest('question'), decisions: freshest('decision'), contradictions };
1198
+ }
1199
+ // ── pinned "core" tier (Tier 2, item 4) ────────────────────────────────────
1200
+ /** A node is pinned by `props.pinned===true` or by the reserved `props.area==='core'`. */
1201
+ isPinnedNode(n) {
1202
+ const p = (n.props || {});
1203
+ return p.pinned === true || p.area === 'core';
1204
+ }
1205
+ /** Live pinned nodes (project invariants) — always prepended within budget. */
1206
+ pinnedNodes() {
1207
+ this.reloadIfChanged();
1208
+ return [...this.nodes.values()]
1209
+ .filter((n) => !n.supersededBy && this.isPinnedNode(n))
1210
+ .sort((a, b) => this.degree(b.id) - this.degree(a.id) || (b.updatedAt || '').localeCompare(a.updatedAt || ''));
1211
+ }
1212
+ /** Set/clear the pin flag on a node (upsert; the node stays otherwise unchanged). */
1213
+ pin(idOrName, pinned = true) {
1214
+ this.reloadIfChanged();
1215
+ const n = this.getNode(idOrName);
1216
+ if (!n) {
1217
+ throw new GraphInvariantError(`${pinned ? 'pin' : 'unpin'}: node not found: ${idOrName}`);
1218
+ }
1219
+ const { node } = this.addNodeInternal({ type: n.type, name: n.name, id: n.id, props: { ...(n.props || {}), pinned } }, { scaffold: false });
1220
+ return node;
1221
+ }
1222
+ // ── reasoning-based best-first retrieval (Tier 1, item 4) ──────────────────
1223
+ /**
1224
+ * Vectorless, explainable, CITABLE retrieval — the PageIndex centrepiece done
1225
+ * without embeddings and without a server-side model (the calling agent is the
1226
+ * reasoner). Seed with the item-1 ranked query on `intent`, then best-first
1227
+ * expand a relevance-priority frontier: pop the best node, expand 1 hop
1228
+ * (strong provenance edges — supports/derived_from/produced/evaluates/
1229
+ * depends_on/parent_of — before weak relates_to/mentions), score newly-found
1230
+ * nodes against the intent, push. Stop at node_budget. Each returned row cites
1231
+ * `[id]` with the matched terms and the edge-path from its seed.
1232
+ */
1233
+ search(opts) {
1234
+ this.reloadIfChanged();
1235
+ const budget = Math.max(1, Math.min(60, opts.nodeBudget ?? 12));
1236
+ const hops = Math.max(1, Math.min(4, opts.hops ?? 2));
1237
+ const intent = String(opts.intent || '');
1238
+ const terms = Array.from(new Set(tokenize(intent)));
1239
+ const idf = this.idf();
1240
+ const rows = new Map();
1241
+ const frontier = [];
1242
+ const seedK = Math.min(budget, Math.max(3, Math.ceil(budget / 2)));
1243
+ for (const s of this.scoredQuery({ text: intent, mode: 'relevance', limit: seedK })) {
1244
+ rows.set(s.node.id, { node: s.node, score: s.score, matched: s.matched, seedId: s.node.id, depth: 0, path: [] });
1245
+ frontier.push({ id: s.node.id, score: s.score, depth: 0 });
1246
+ }
1247
+ while (frontier.length && rows.size < budget) {
1248
+ frontier.sort((a, b) => b.score - a.score);
1249
+ const cur = frontier.shift();
1250
+ if (cur.depth >= hops) {
1251
+ continue;
1252
+ }
1253
+ const curRec = rows.get(cur.id);
1254
+ if (!curRec) {
1255
+ continue;
1256
+ }
1257
+ const incident = [...(this.adjacency.get(cur.id) || [])]
1258
+ .sort((x, y) => (EDGE_STRENGTH[y.type] || 0) - (EDGE_STRENGTH[x.type] || 0));
1259
+ for (const e of incident) {
1260
+ if (rows.size >= budget) {
1261
+ break;
1262
+ }
1263
+ const otherId = e.from === cur.id ? e.to : e.from;
1264
+ if (rows.has(otherId)) {
1265
+ continue;
1266
+ }
1267
+ const other = this.nodes.get(otherId);
1268
+ if (!other || other.supersededBy) {
1269
+ continue;
1270
+ } // superseded hidden by default
1271
+ const { score: ts, matched } = this.scoreNode(other, terms, idf);
1272
+ const textScore = ts > 0 ? ts : SUBSTR_FLOOR * 0.5; // reachable-but-off-intent nodes stay citable, ranked low
1273
+ const final = textScore * (1 + W_RECENCY * this.recencyDecay(other.updatedAt) + W_DEGREE * Math.log1p(this.degree(otherId)));
1274
+ const dir = e.from === cur.id ? 'out' : 'in';
1275
+ const path = [...curRec.path, `${e.type}${dir === 'out' ? '→' : '←'} ${other.type}:${other.id}`];
1276
+ rows.set(otherId, { node: other, score: final, matched, seedId: curRec.seedId, depth: cur.depth + 1, path, via: { edge: e.type, fromId: cur.id, dir } });
1277
+ frontier.push({ id: otherId, score: final, depth: cur.depth + 1 });
1278
+ }
1279
+ }
1280
+ const out = [...rows.values()]
1281
+ .sort((a, b) => b.score - a.score)
1282
+ .map((r) => {
1283
+ const es = this.effectiveSummary(r.node);
1284
+ return {
1285
+ id: r.node.id, type: r.node.type, name: r.node.name,
1286
+ score: r.score, matched: r.matched,
1287
+ summary: es?.text, synthesized: es?.synthesized,
1288
+ inference: r.node.inference,
1289
+ seedId: r.seedId, via: r.via, path: r.path,
1290
+ conflicts: this.contradictionsFor(r.node.id),
1291
+ };
1292
+ });
1293
+ return { intent, terms, budget, hops, rows: out };
1294
+ }
1295
+ // ── navigable table-of-contents (Tier 2, item 2) ───────────────────────────
1296
+ parentOfChildIds(id) {
1297
+ const out = [];
1298
+ for (const e of this.adjacency.get(id) || []) {
1299
+ if (e.type === 'parent_of' && e.from === id) {
1300
+ out.push(e.to);
1301
+ }
1302
+ }
1303
+ return out;
1304
+ }
1305
+ hasParentOfParent(id) {
1306
+ return (this.adjacency.get(id) || []).some((e) => e.type === 'parent_of' && e.to === id);
1307
+ }
1308
+ hasParentOfChild(id) {
1309
+ return (this.adjacency.get(id) || []).some((e) => e.type === 'parent_of' && e.from === id);
1310
+ }
1311
+ /** Connected components of `pool` over the given (undirected) edge types. */
1312
+ components(pool, edgeTypes) {
1313
+ const idset = new Set(pool.map((n) => n.id));
1314
+ const parent = new Map();
1315
+ const find = (x) => { while (parent.get(x) !== x) {
1316
+ parent.set(x, parent.get(parent.get(x)));
1317
+ x = parent.get(x);
1318
+ } return x; };
1319
+ for (const n of pool) {
1320
+ parent.set(n.id, n.id);
1321
+ }
1322
+ const allow = new Set(edgeTypes);
1323
+ for (const e of this.edges.values()) {
1324
+ if (!allow.has(e.type) || !idset.has(e.from) || !idset.has(e.to)) {
1325
+ continue;
1326
+ }
1327
+ const ra = find(e.from), rb = find(e.to);
1328
+ if (ra !== rb) {
1329
+ parent.set(ra, rb);
1330
+ }
1331
+ }
1332
+ const groups = new Map();
1333
+ for (const n of pool) {
1334
+ const r = find(n.id);
1335
+ const g = groups.get(r);
1336
+ if (g) {
1337
+ g.push(n);
1338
+ }
1339
+ else {
1340
+ groups.set(r, [n]);
1341
+ }
1342
+ }
1343
+ return [...groups.values()];
1344
+ }
1345
+ /**
1346
+ * Depth-bounded, summarized hierarchy (PageIndex-style "text-stripped overview →
1347
+ * drill"). The spine is `parent_of` (roots = entities with no incoming parent_of);
1348
+ * where that spine is sparse the uncovered remainder falls back to deterministic
1349
+ * clustering (by `props.area`, else connected components over relates_to/
1350
+ * depends_on). Each level shows the top `maxChildren` by degree and collapses the
1351
+ * rest to `+K more`. Summaries reuse {@link effectiveSummary}. Pass `root` to
1352
+ * expand one branch.
1353
+ */
1354
+ toc(opts) {
1355
+ this.reloadIfChanged();
1356
+ const depth = Math.max(1, Math.min(6, opts?.depth ?? 2));
1357
+ const maxChildren = Math.max(1, Math.min(50, opts?.maxChildren ?? 8));
1358
+ const focus = opts?.focusType ? String(opts.focusType).toLowerCase() : undefined;
1359
+ const build = (n, d, seen) => {
1360
+ seen.add(n.id);
1361
+ let kids = this.parentOfChildIds(n.id)
1362
+ .map((id) => this.nodes.get(id))
1363
+ .filter((c) => !!c && !c.supersededBy && !seen.has(c.id));
1364
+ if (focus) {
1365
+ kids = kids.filter((c) => c.type === focus);
1366
+ }
1367
+ kids.sort((a, b) => this.degree(b.id) - this.degree(a.id) || (b.updatedAt || '').localeCompare(a.updatedAt || ''));
1368
+ const childCount = kids.length;
1369
+ const cap = d > 1 ? maxChildren : 0;
1370
+ const shown = kids.slice(0, cap);
1371
+ const children = shown.map((c) => build(c, d - 1, seen));
1372
+ const es = this.effectiveSummary(n);
1373
+ const more = childCount - shown.length;
1374
+ return { id: n.id, type: n.type, name: n.name, summary: es?.text, synthesized: es?.synthesized, childCount, children, moreChildren: more > 0 ? more : undefined };
1375
+ };
1376
+ if (opts?.root) {
1377
+ const r = this.getNode(opts.root);
1378
+ return r ? { roots: [build(r, depth, new Set())] } : { roots: [] };
1379
+ }
1380
+ const seen = new Set();
1381
+ const live = [...this.nodes.values()].filter((n) => !n.supersededBy);
1382
+ const spineRoots = live
1383
+ .filter((n) => this.hasParentOfChild(n.id) && !this.hasParentOfParent(n.id))
1384
+ .sort((a, b) => this.degree(b.id) - this.degree(a.id) || (b.updatedAt || '').localeCompare(a.updatedAt || ''));
1385
+ const roots = spineRoots.map((r) => build(r, depth, seen));
1386
+ // Fall back to clustering for significant nodes the spine didn't cover.
1387
+ const synthCluster = (id, label, members) => {
1388
+ const sorted = [...members].sort((a, b) => this.degree(b.id) - this.degree(a.id));
1389
+ const shown = sorted.slice(0, maxChildren);
1390
+ const children = shown.map((m) => build(m, 1, seen));
1391
+ const more = members.length - shown.length;
1392
+ return { id, type: 'cluster', name: label, summary: `${members.length} node(s)`, childCount: members.length, children, moreChildren: more > 0 ? more : undefined };
1393
+ };
1394
+ const remaining = live.filter((n) => !seen.has(n.id) && this.degree(n.id) > 0);
1395
+ const clusterRoots = [];
1396
+ if (remaining.length) {
1397
+ const byArea = new Map();
1398
+ const rest = [];
1399
+ for (const n of remaining) {
1400
+ const area = typeof n.props?.area === 'string' ? n.props.area : undefined;
1401
+ if (area) {
1402
+ const g = byArea.get(area);
1403
+ if (g) {
1404
+ g.push(n);
1405
+ }
1406
+ else {
1407
+ byArea.set(area, [n]);
1408
+ }
1409
+ }
1410
+ else {
1411
+ rest.push(n);
1412
+ }
1413
+ }
1414
+ for (const [area, members] of byArea) {
1415
+ clusterRoots.push(synthCluster(`cluster:area:${slug(area)}`, `area: ${area}`, members));
1416
+ }
1417
+ for (const comp of this.components(rest, ['relates_to', 'depends_on'])) {
1418
+ if (comp.length === 1) {
1419
+ continue;
1420
+ } // a lone node with only foreign edges — not a cluster
1421
+ const rep = [...comp].sort((a, b) => this.degree(b.id) - this.degree(a.id))[0];
1422
+ clusterRoots.push(synthCluster(`cluster:${rep.id}`, `cluster: ${rep.name}`, comp));
1423
+ }
1424
+ clusterRoots.sort((a, b) => b.childCount - a.childCount);
1425
+ }
1426
+ return { roots: [...roots, ...clusterRoots], clustered: roots.length === 0 && clusterRoots.length > 0 };
1427
+ }
1428
+ // ── hierarchical rollup / summary nodes (Tier 2, item 5) ────────────────────
1429
+ /** Deterministic id for a cluster's rollup node, so concurrent rollups upsert not duplicate. */
1430
+ rollupId(center) {
1431
+ const key = center.id.startsWith('entity:') ? center.id.slice('entity:'.length) : center.id.replace(/[^a-z0-9]+/gi, '-');
1432
+ return `summary:${key || 'x'}`;
1433
+ }
1434
+ /** parent_of descendants of a node (transitive), excluding itself. */
1435
+ descendants(id) {
1436
+ const out = new Set();
1437
+ const stack = [id];
1438
+ while (stack.length) {
1439
+ const cur = stack.pop();
1440
+ for (const cid of this.parentOfChildIds(cur)) {
1441
+ if (cid !== id && !out.has(cid)) {
1442
+ out.add(cid);
1443
+ stack.push(cid);
1444
+ }
1445
+ }
1446
+ }
1447
+ return [...out];
1448
+ }
1449
+ rollupFromMembers(memberIds) {
1450
+ const counts = new Map();
1451
+ for (const mid of memberIds) {
1452
+ const n = this.nodes.get(mid);
1453
+ if (n) {
1454
+ counts.set(n.type, (counts.get(n.type) || 0) + 1);
1455
+ }
1456
+ }
1457
+ const countStr = [...counts.entries()].sort((a, b) => b[1] - a[1]).map(([t, c]) => `${c} ${t}`).join(', ');
1458
+ const top = memberIds.map((id) => this.nodes.get(id)).filter((n) => !!n)
1459
+ .sort((a, b) => this.degree(b.id) - this.degree(a.id)).slice(0, 3).map((n) => n.name);
1460
+ return `${memberIds.length} member(s): ${countStr}${top.length ? ` · top: ${top.join(', ')}` : ''}`;
1461
+ }
1462
+ /** The rollup/summary node for a cluster centre, if one has been created. */
1463
+ rollupNodeFor(idOrName) {
1464
+ this.reloadIfChanged();
1465
+ const c = this.getNode(idOrName);
1466
+ if (!c) {
1467
+ return undefined;
1468
+ }
1469
+ return this.nodes.get(this.rollupId(c));
1470
+ }
1471
+ /**
1472
+ * Create or upsert a hierarchical rollup for a cluster (item 5). The cluster is a
1473
+ * centre entity's `parent_of` descendants, or — absent a spine — its N-hop
1474
+ * neighbourhood. The rollup is a `note` with `props.rollup=true` and a
1475
+ * DETERMINISTIC id (`summary:<centre-key>`) so concurrent rollups of the same
1476
+ * cluster upsert rather than duplicate; it links `derived_from → each member`
1477
+ * (members stay fully addressable in `props.members`) and hangs under the centre
1478
+ * via `parent_of` so it's discoverable in the TOC. Summary text is agent-supplied
1479
+ * or the deterministic Tier-1 rollup (no LLM on the default path).
1480
+ */
1481
+ rollup(opts) {
1482
+ this.reloadIfChanged();
1483
+ const center = opts.idOrName ? this.getNode(opts.idOrName) : undefined;
1484
+ if (!center) {
1485
+ throw new GraphInvariantError(`graph_rollup: centre node not found: ${opts.idOrName ?? ''}`);
1486
+ }
1487
+ const id = this.rollupId(center);
1488
+ let members = this.descendants(center.id);
1489
+ if (!members.length) {
1490
+ const hops = Math.max(1, Math.min(3, opts.hops ?? 1));
1491
+ const res = this.neighbors({ idOrName: center.id, hops, limit: 500 });
1492
+ members = (res ? res.nodes : []).map((n) => n.id).filter((mid) => mid !== center.id);
1493
+ }
1494
+ members = members.filter((mid) => mid !== id && !mid.startsWith('summary:'));
1495
+ const summaryText = (opts.summary && opts.summary.trim())
1496
+ ? opts.summary.trim()
1497
+ : (this.rollupSummary(center.id) || this.rollupFromMembers(members));
1498
+ const { node, created } = this.addNodeInternal({
1499
+ type: 'note', id, name: `Rollup: ${center.name}`,
1500
+ summary: summaryText,
1501
+ props: { rollup: true, of: center.id, members },
1502
+ }, { scaffold: false });
1503
+ this.linkParentOf(center.id, id);
1504
+ for (const m of members) {
1505
+ if (m === id || !this.nodes.has(m)) {
1506
+ continue;
1507
+ }
1508
+ if (!this.hasEdge('derived_from', id, m)) {
1509
+ this.addEdge({ type: 'derived_from', from: id, to: m });
1510
+ }
1511
+ }
1512
+ return { node, members, created };
365
1513
  }
366
1514
  stats() {
367
1515
  this.reloadIfChanged();
368
1516
  const nodesByType = {};
369
1517
  const edgesByType = {};
370
- let superseded = 0, openQuestions = 0;
371
- const touched = new Set();
1518
+ let superseded = 0, openQuestions = 0, estTokens = 0;
372
1519
  for (const e of this.edges.values()) {
373
1520
  edgesByType[e.type] = (edgesByType[e.type] || 0) + 1;
374
- touched.add(e.from);
375
- touched.add(e.to);
1521
+ estTokens += estimateTokens(`${e.type}${e.from}${e.to}${e.source ?? ''}`);
376
1522
  }
377
- let isolated = 0;
1523
+ // Types that benefit most from a summary (item 3).
1524
+ const SUMMARY_TYPES = new Set(['entity', 'decision', 'question', 'artifact', 'agent_run']);
1525
+ let isolated = 0, unsummarized = 0, staleSummaries = 0, spinelessHubs = 0, pinned = 0;
378
1526
  for (const n of this.nodes.values()) {
379
1527
  nodesByType[n.type] = (nodesByType[n.type] || 0) + 1;
380
1528
  if (n.supersededBy) {
@@ -383,14 +1531,42 @@ class GraphStore {
383
1531
  if (n.type === 'question' && !n.supersededBy) {
384
1532
  openQuestions++;
385
1533
  }
386
- if (!touched.has(n.id) && !n.supersededBy) {
1534
+ if (this.degree(n.id) === 0 && !n.supersededBy) {
387
1535
  isolated++;
388
1536
  }
1537
+ const hasSummary = !!(n.summary && n.summary.trim());
1538
+ if (!n.supersededBy && SUMMARY_TYPES.has(n.type) && !hasSummary) {
1539
+ unsummarized++;
1540
+ }
1541
+ if (hasSummary && this.summaryStale(n)) {
1542
+ staleSummaries++;
1543
+ }
1544
+ if (!n.supersededBy && this.isPinnedNode(n)) {
1545
+ pinned++;
1546
+ }
1547
+ // A hub with children (degree≥3) but NO parent_of spine — nudge to scaffold the tree (item 1).
1548
+ if (!n.supersededBy && n.type === 'entity' && this.degree(n.id) >= 3
1549
+ && !this.hasParentOfChild(n.id) && !this.hasParentOfParent(n.id)) {
1550
+ spinelessHubs++;
1551
+ }
1552
+ estTokens += estimateTokens(`${n.name}${n.summary ?? ''}${n.body ?? ''}`);
389
1553
  }
390
1554
  const contradictions = edgesByType['contradicts'] || 0;
1555
+ const live = this.nodes.size + this.edges.size;
1556
+ const deadWeight = Math.max(0, this.appliedOps - live);
391
1557
  return {
392
1558
  nodeCount: this.nodes.size, edgeCount: this.edges.size,
393
1559
  nodesByType, edgesByType, superseded, isolated, contradictions, openQuestions,
1560
+ unsummarized, staleSummaries,
1561
+ spinelessHubs, pinned,
1562
+ fileBytes: this.lastSize < 0 ? 0 : this.lastSize,
1563
+ totalOps: this.appliedOps,
1564
+ deadWeight,
1565
+ deadWeightRatio: this.appliedOps ? deadWeight / this.appliedOps : 0,
1566
+ lastReplayMs: this.lastReplayMs,
1567
+ estTokens,
1568
+ parseFailures: this.parseFailures,
1569
+ tornFinalLine: this.lastOffset < this.lastSize,
394
1570
  };
395
1571
  }
396
1572
  }