graphlin 0.1.3 → 0.2.1

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 (106) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/.codex-plugin/plugin.json +1 -1
  3. package/README.md +12 -3
  4. package/docs/decision-service.md +393 -0
  5. package/docs/extension-authoring.md +553 -0
  6. package/docs/model-api.md +293 -0
  7. package/docs/usage.md +472 -0
  8. package/docs/visualizer-views.md +240 -0
  9. package/node_modules/@vscode/tree-sitter-wasm/LICENSE +21 -0
  10. package/node_modules/@vscode/tree-sitter-wasm/README.md +36 -0
  11. package/node_modules/@vscode/tree-sitter-wasm/SECURITY.md +41 -0
  12. package/node_modules/@vscode/tree-sitter-wasm/cgmanifest.json +16 -0
  13. package/node_modules/@vscode/tree-sitter-wasm/package.json +42 -0
  14. package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-bash.wasm +0 -0
  15. package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-c-sharp.wasm +0 -0
  16. package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-cpp.wasm +0 -0
  17. package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-css.wasm +0 -0
  18. package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-go.wasm +0 -0
  19. package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-ini.wasm +0 -0
  20. package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-java.wasm +0 -0
  21. package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-javascript.wasm +0 -0
  22. package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-php.wasm +0 -0
  23. package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-powershell.wasm +0 -0
  24. package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-python.wasm +0 -0
  25. package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-regex.wasm +0 -0
  26. package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-ruby.wasm +0 -0
  27. package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-rust.wasm +0 -0
  28. package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-tsx.wasm +0 -0
  29. package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-typescript.wasm +0 -0
  30. package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter.js +4075 -0
  31. package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter.wasm +0 -0
  32. package/node_modules/@vscode/tree-sitter-wasm/wasm/web-tree-sitter.d.ts +1027 -0
  33. package/package.json +78 -9
  34. package/plugin.json +4 -2
  35. package/runtime/architecture/analysis.mjs +344 -0
  36. package/runtime/architecture/controller.mjs +209 -0
  37. package/runtime/architecture/evidence.mjs +108 -0
  38. package/runtime/architecture/profile.mjs +56 -0
  39. package/runtime/core/evidence.mjs +43 -9
  40. package/runtime/core/graph.mjs +11 -6
  41. package/runtime/core/privacy.mjs +1 -0
  42. package/runtime/daemon/auth.mjs +7 -3
  43. package/runtime/daemon/diagnostics.mjs +1 -1
  44. package/runtime/daemon/extension-api.mjs +203 -0
  45. package/runtime/daemon/lineage.mjs +70 -0
  46. package/runtime/daemon/manager.mjs +9 -6
  47. package/runtime/daemon/model-api.mjs +728 -0
  48. package/runtime/daemon/model-persistence.mjs +220 -0
  49. package/runtime/daemon/server.mjs +81 -14
  50. package/runtime/daemon/settings.mjs +11 -3
  51. package/runtime/decisions/broker.mjs +349 -0
  52. package/runtime/decisions/contracts.mjs +179 -0
  53. package/runtime/decisions/evaluation.mjs +305 -0
  54. package/runtime/decisions/faults.mjs +32 -0
  55. package/runtime/decisions/index.mjs +818 -0
  56. package/runtime/decisions/profiles.mjs +93 -0
  57. package/runtime/decisions/questions.mjs +268 -0
  58. package/runtime/discovery/index.mjs +2 -0
  59. package/runtime/discovery/inventory.mjs +160 -0
  60. package/runtime/discovery/parser.mjs +40 -0
  61. package/runtime/discovery/structure.mjs +232 -0
  62. package/runtime/extensions/contracts.mjs +59 -0
  63. package/runtime/extensions/frame.mjs +64 -0
  64. package/runtime/extensions/index.mjs +9 -0
  65. package/runtime/extensions/manifest.mjs +95 -0
  66. package/runtime/extensions/packages.mjs +222 -0
  67. package/runtime/extensions/profiles.mjs +36 -0
  68. package/runtime/extensions/projection.mjs +130 -0
  69. package/runtime/extensions/registry.mjs +285 -0
  70. package/runtime/extensions/scene.mjs +105 -0
  71. package/runtime/extensions/sdk.d.ts +205 -0
  72. package/runtime/extensions/sdk.mjs +88 -0
  73. package/runtime/jev/index.mjs +13 -777
  74. package/runtime/jev/provider.mjs +101 -0
  75. package/runtime/jev/questions.mjs +16 -258
  76. package/runtime/jev/wire.mjs +17 -25
  77. package/runtime/model/changes.mjs +42 -0
  78. package/runtime/model/history.mjs +124 -0
  79. package/runtime/model/index.mjs +2 -0
  80. package/runtime/model/project-model.mjs +1020 -0
  81. package/runtime/model/records.mjs +240 -0
  82. package/runtime/pipeline.mjs +267 -55
  83. package/runtime/platform.mjs +254 -0
  84. package/runtime/visualizers/blocks.mjs +5 -0
  85. package/runtime/visualizers/c4.mjs +154 -0
  86. package/runtime/visualizers/changes.mjs +24 -0
  87. package/runtime/visualizers/code.mjs +5 -0
  88. package/runtime/visualizers/index.mjs +23 -0
  89. package/runtime/visualizers/structure.mjs +120 -0
  90. package/runtime/visualizers/timeline.mjs +66 -0
  91. package/runtime/web/app.js +225 -63
  92. package/runtime/web/extension-frame.js +128 -0
  93. package/runtime/web/index.html +38 -1
  94. package/runtime/web/model-client.js +162 -0
  95. package/runtime/web/platform.js +445 -0
  96. package/runtime/web/scene.js +111 -0
  97. package/runtime/web/style.css +51 -0
  98. package/schemas/graph.schema.json +4 -1
  99. package/scripts/arguments.mjs +5 -1
  100. package/scripts/build-packages.mjs +6 -2
  101. package/scripts/control.mjs +1 -1
  102. package/scripts/daemon.mjs +2 -1
  103. package/scripts/extensions.mjs +44 -0
  104. package/scripts/graphlin.mjs +23 -3
  105. package/scripts/onboarding.mjs +10 -3
  106. package/scripts/validate-packages.mjs +54 -8
@@ -1,6 +1,5 @@
1
1
  import { createHash, randomUUID } from 'node:crypto';
2
2
  import { realpathSync } from 'node:fs';
3
- import { readdir } from 'node:fs/promises';
4
3
  import path from 'node:path';
5
4
  import {
6
5
  createPolicy, normalizeHostEvent, metadataEvent, EvidenceStore,
@@ -8,6 +7,9 @@ import {
8
7
  applyPatch, projectGraph,
9
8
  } from './core/index.mjs';
10
9
  import { safeLabel, safeText, excluded } from './core/privacy.mjs';
10
+ import { createPlatform } from './platform.mjs';
11
+ import { createArchitectureController } from './architecture/controller.mjs';
12
+ import { analyzeArchitecture, ARCHITECTURE_NAMESPACE } from './architecture/analysis.mjs';
11
13
 
12
14
  const MAX_ACTIVITY = 200;
13
15
  const MAX_HOOK_EVENTS = 200;
@@ -23,11 +25,6 @@ const CLASSIFICATION_QUEUE_TTL_MS = 120_000;
23
25
  const DEADLINE_MS = 2000;
24
26
  const PENDING_LEASE_MS = 60_000;
25
27
  const TERMINAL = new Set(['succeeded', 'failed', 'interrupted', 'unresolved']);
26
- const SOURCE_EXT = /\.(?:[cm]?[jt]sx?|py|go|rs|java|kt|rb|php|cs|swift|sql|ya?ml|json|toml|tf)$/i;
27
- const SKIP_DIR = new Set([
28
- '.git', '.graphlin', '.graphlin-data', 'node_modules', 'dist', 'build',
29
- 'coverage', '.next', '.cache', '.venv', 'venv', 'vendor', 'research',
30
- ]);
31
28
  const FIXED_CLASSIFIER = new Set([
32
29
  'ready', 'metadata_only', 'missing_key', 'paused', 'unavailable', 'timeout', 'demo',
33
30
  ]);
@@ -97,8 +94,8 @@ function restoreGraph(input, { stale = true } = {}) {
97
94
  */
98
95
  export function createPipeline({
99
96
  projectRoot, policy: policyOptions, decisionService, onChange = () => {},
100
- onDiagnostic = () => {}, restoredState, mode = 'live', clock = Date.now,
101
- classificationDeadlineMs = DEADLINE_MS,
97
+ onDiagnostic = () => {}, restoredState, restoredModel, mode = 'live', clock = Date.now,
98
+ classificationDeadlineMs = DEADLINE_MS, missingKey = false,
102
99
  } = {}) {
103
100
  if (!Number.isSafeInteger(classificationDeadlineMs) ||
104
101
  classificationDeadlineMs < DEADLINE_MS || classificationDeadlineMs > 10_000) {
@@ -107,13 +104,37 @@ export function createPipeline({
107
104
  const root = realpathSync(projectRoot);
108
105
  const inputRoot = path.resolve(projectRoot);
109
106
  const projectId = opaque(root);
107
+ const modelProjectId = createHash('sha256').update(root).digest('hex');
110
108
  const policy = createPolicy(policyOptions ?? {});
111
- const evidence = new EvidenceStore({ projectRoot, policy });
109
+ // Restored claims stay stale until recaptured, and their versions must never
110
+ // collide with a new daemon's counters. No saved content is trusted here.
111
+ let generationFloor = 0;
112
+ const retainGeneration = value => {
113
+ if (Number.isSafeInteger(value) && value > generationFloor && value < Number.MAX_SAFE_INTEGER) generationFloor = value;
114
+ };
115
+ const retainRefs = rows => {
116
+ for (const row of Array.isArray(rows) ? rows.slice(0, 40000) : []) {
117
+ for (const ref of Array.isArray(row?.sourceRefs) ? row.sourceRefs.slice(0, 16) : []) retainGeneration(ref.generation);
118
+ }
119
+ };
120
+ if (restoredModel?.projectId === modelProjectId) {
121
+ const artifacts = restoredModel.coverage?.artifacts;
122
+ for (const artifact of Array.isArray(artifacts) ? artifacts.slice(0, 10000) : []) retainGeneration(artifact?.generation);
123
+ for (const rows of [restoredModel.entities, restoredModel.relations, restoredModel.interpretations]) retainRefs(rows);
124
+ }
125
+ if (restoredState?.projectId === projectId) {
126
+ const states = Array.isArray(restoredState.sessionStates) ? restoredState.sessionStates.slice(0, MAX_SESSIONS) : [];
127
+ for (const session of [restoredState, ...states]) {
128
+ retainRefs(session?.graph?.nodes); retainRefs(session?.graph?.edges);
129
+ }
130
+ }
131
+ const evidence = new EvidenceStore({ projectRoot, policy, maxTrackedPaths: 10000, generationFloor });
112
132
  const sessions = new Map();
113
133
  const dedup = new Map();
114
134
  const sessionStarts = new Map();
115
135
  const hookEvents = [];
116
136
  const knownArtifacts = new Map();
137
+ const lineageWork = new Set();
117
138
  const messageVersions = new Map();
118
139
  const deferredWork = new Map();
119
140
  const classificationQueue = [];
@@ -130,16 +151,144 @@ export function createPipeline({
130
151
  let pending = 0;
131
152
  let classifier = mode === 'demo' ? 'demo' : policy.transmitSource ? 'ready' : 'metadata_only';
132
153
  let serial = Promise.resolve();
133
- let lastDiscoveryAt = -Infinity;
134
- let discoveredPaths = [];
135
154
  let reconciliationTask = null;
136
155
  let resumeScheduled = false;
156
+ let lineageId = null;
157
+ let architecture;
158
+ let architectureEpoch = 0;
159
+ const architectureGuards = new WeakMap();
137
160
 
138
161
  const serialized = (fn) => {
139
162
  const operation = serial.then(fn);
140
163
  serial = operation.catch(() => {});
141
164
  return operation;
142
165
  };
166
+ const platform = createPlatform({
167
+ projectRoot: root, projectId: modelProjectId,
168
+ policy, restoredState: restoredModel, now: clock,
169
+ accept: operation => serialized(operation),
170
+ revalidate: refs => serialized(async () => {
171
+ if (closed) return false;
172
+ registerArtifacts(await evidence.reconcile({ refs }));
173
+ return evidence.isCurrent(refs);
174
+ }),
175
+ onChange: () => { architecture?.wake(); notify(); },
176
+ });
177
+ architecture = createArchitectureController({
178
+ snapshot: () => platform.snapshot(),
179
+ capture: captureArchitecture,
180
+ commit: commitArchitecture,
181
+ analyze: async input => {
182
+ const guard = {
183
+ epoch: architectureEpoch, signal: input.signal, policyVersion: policy.version,
184
+ lineageId: input.model.coverage.lineage?.id ?? modelProjectId,
185
+ };
186
+ const result = await analyzeArchitecture({ ...input, service: decisionService, policy });
187
+ architectureGuards.set(result, guard);
188
+ return result;
189
+ },
190
+ available: architectureUnavailable,
191
+ ready: () => {
192
+ const { queued, active } = platform.stats();
193
+ return queued === 0 && active === 0;
194
+ },
195
+ onChange: notify,
196
+ onDiagnostic: result => trace(null, 'classification', {
197
+ status: result.status === 'complete' ? 'accepted' : result.status === 'partial' ? 'partial' : 'unavailable',
198
+ reason: result.status === 'complete' ? 'ok' : result.status === 'partial' ? 'unknown' : 'decision_failure',
199
+ diagnostics: { calls: result.providerRequests },
200
+ }),
201
+ now: clock,
202
+ });
203
+
204
+ function architectureUnavailable() {
205
+ if (closed) return 'closed';
206
+ if (!policy.transmitSource) return 'source_consent_required';
207
+ if (missingKey) return 'missing_key';
208
+ if (paused) return 'paused';
209
+ if (mode === 'demo') return 'demo';
210
+ if (typeof decisionService?.analyze !== 'function' || typeof decisionService?.evaluate !== 'function') {
211
+ return 'unsupported_service';
212
+ }
213
+ return null;
214
+ }
215
+
216
+ async function captureArchitecture(ids) {
217
+ const artifacts = await serialized(async () => {
218
+ if (architectureUnavailable()) return [];
219
+ const refs = ids.slice(0, 64).filter(id => knownArtifacts.has(id)).map(artifactId => ({ artifactId }));
220
+ const captures = await evidence.reconcile({ refs });
221
+ registerArtifacts(captures);
222
+ return captures;
223
+ });
224
+ // Parser acceptance uses the same serial queue; waiting inside it deadlocks.
225
+ await platform.whenIdle();
226
+ return artifacts;
227
+ }
228
+
229
+ function commitArchitecture(result, context) {
230
+ return serialized(async () => {
231
+ const guard = architectureGuards.get(result);
232
+ architectureGuards.delete(result);
233
+ const current = () => guard && guard.signal === context.signal && !context.signal.aborted &&
234
+ guard.epoch === architectureEpoch && guard.policyVersion === policy.version &&
235
+ guard.lineageId === (lineageId ?? platform.snapshot().coverage.lineage?.id ?? modelProjectId) &&
236
+ !architectureUnavailable();
237
+ if (!current() || result.sourceRefs.length > 256 || context.artifacts.length > 64) return false;
238
+ const expected = new Map();
239
+ const sameVersion = (a, b) => a && b && a.hash === b.hash &&
240
+ a.generation === b.generation && a.status === b.status;
241
+ for (const value of [...context.artifacts, ...result.sourceRefs.map(ref => ({ ...ref, status: 'present' }))]) {
242
+ if (!knownArtifacts.has(value.artifactId) ||
243
+ expected.has(value.artifactId) && !sameVersion(expected.get(value.artifactId), value)) return false;
244
+ expected.set(value.artifactId, value);
245
+ }
246
+ if (expected.size > 256) return false;
247
+ const observed = new Map();
248
+ const refs = [...expected.keys()].map(artifactId => ({ artifactId }));
249
+ // Re-read all selected versions, including absence, and any extra
250
+ // membership support. Retain metadata only across these bounded reads.
251
+ for (let offset = 0; offset < refs.length; offset += 32) {
252
+ const captures = await evidence.reconcile({ refs: refs.slice(offset, offset + 32) });
253
+ registerArtifacts(captures);
254
+ for (const artifact of captures) observed.set(artifact.id, {
255
+ hash: artifact.hash, generation: artifact.generation, status: artifact.status,
256
+ exists: artifact.exists, complete: artifact.complete,
257
+ });
258
+ if (!current()) return false;
259
+ }
260
+ if ([...expected].some(([id, value]) => !sameVersion(value, observed.get(id)))) return false;
261
+ const missing = new Set(result.coverage.missingArtifactIds ?? []);
262
+ const selected = new Set(context.artifacts.map(value => value.artifactId));
263
+ for (const id of missing) {
264
+ const value = observed.get(id);
265
+ if (!selected.has(id) || value?.status !== 'missing' || value.hash !== null ||
266
+ value.exists !== false || value.complete !== true) return false;
267
+ }
268
+ const withdrawn = new Set(result.coverage.withdrawnEntityIds ?? []);
269
+ const model = platform.snapshot();
270
+ const entities = new Map(model.entities.map(value => [value.id, value]));
271
+ if ([...withdrawn].some(id => !missing.has(entities.get(id)?.artifactId))) return false;
272
+ const affected = result.affectedEntityIds.filter(id => !withdrawn.has(id));
273
+ let omitted = 0;
274
+ if (affected.length || result.interpretations.length) {
275
+ const replacement = platform.model.replaceInterpretations(ARCHITECTURE_NAMESPACE, result.interpretations, {
276
+ affectedEntityIds: affected, sourceRefs: result.sourceRefs,
277
+ });
278
+ if (!replacement.accepted) return false;
279
+ omitted = Math.max(0, result.interpretations.length - replacement.retained);
280
+ }
281
+ if (missing.size) {
282
+ // Present-source guards cannot prove deletion. The serialized reread
283
+ // above authorizes this clear, even when deleted anchors were evicted.
284
+ const cleared = platform.model.replaceInterpretations(ARCHITECTURE_NAMESPACE, [], {
285
+ affectedEntityIds: [...withdrawn], artifactIds: [...missing],
286
+ });
287
+ if (!cleared.accepted) return false;
288
+ }
289
+ return omitted ? { accepted: true, omitted } : true;
290
+ });
291
+ }
143
292
 
144
293
  function artifactMetadata(artifact) {
145
294
  const relative = artifact.relativePath;
@@ -217,17 +366,24 @@ export function createPipeline({
217
366
  const session = freshSession(id, `Session ${sessions.size + 1}`);
218
367
  sessions.set(id, session);
219
368
  selectedSession ??= id;
369
+ platform.setSessions([...sessions.values()].map(item => ({ id: item.id, label: item.label })));
220
370
  return session;
221
371
  }
222
372
 
223
373
  function evictSession(id) {
224
374
  sessions.delete(id);
375
+ syncSessions();
225
376
  deferredWork.delete(id);
226
377
  for (const key of completedClassifications.keys()) {
227
378
  if (key.startsWith(`${id}:`)) completedClassifications.delete(key);
228
379
  }
229
380
  }
230
381
 
382
+ function syncSessions() {
383
+ platform.setSessions([...sessions.values()].map(({ id, label, host, status, startedAt, endedAt }) =>
384
+ ({ id, label, host, status, startedAt, endedAt })));
385
+ }
386
+
231
387
  if (restoredState?.projectId === projectId) {
232
388
  const saved = Array.isArray(restoredState.sessionStates)
233
389
  ? [restoredState, ...restoredState.sessionStates.filter(s => s.id !== restoredState.sessionId)].slice(-MAX_SESSIONS)
@@ -237,6 +393,7 @@ export function createPipeline({
237
393
  if (typeof id !== 'string' || !/^[a-zA-Z0-9_-]{1,100}$/.test(id)) continue;
238
394
  const session = ensureSession(id);
239
395
  session.graph = restoreGraph(entry.graph);
396
+ platform.observeLegacy(session.graph, { sessionId: session.id });
240
397
  // Activity is reconstructed through the metadata allowlist, never trusted verbatim.
241
398
  session.activity = (Array.isArray(entry.activity) ? entry.activity : [])
242
399
  .slice(-MAX_ACTIVITY).map(item => {
@@ -329,6 +486,7 @@ export function createPipeline({
329
486
  function recordPatch(session, patch) {
330
487
  if (!patch || !patch.operations?.length) return;
331
488
  session.graph = applyPatch(session.graph, patch);
489
+ platform.observeLegacy(session.graph, { sessionId: session.id, fresh: true });
332
490
  session.history.push({
333
491
  revision: session.graph.revision, at: new Date(clock()).toISOString(),
334
492
  graph: structuredClone(session.graph),
@@ -356,7 +514,7 @@ export function createPipeline({
356
514
  }
357
515
  }
358
516
 
359
- function registerArtifacts(artifacts) {
517
+ function registerArtifacts(artifacts, event) {
360
518
  const changed = [];
361
519
  for (const artifact of artifacts) {
362
520
  const previous = knownArtifacts.get(artifact.id);
@@ -390,12 +548,15 @@ export function createPipeline({
390
548
  recordPatch(session, invalidateArtifacts(session.graph, changed));
391
549
  }
392
550
  }
551
+ platform.observeArtifacts(artifacts, event);
552
+ if (changed.length) architecture.observe(changed);
393
553
  return changed;
394
554
  }
395
555
 
396
556
  function addActivity(session, event) {
397
557
  const state = activityState(event);
398
558
  const row = { ...metadataEvent(event), label: activityLabel(event), state };
559
+ platform.recordActivity(row);
399
560
  const existing = event.toolCallId
400
561
  ? session.activity.findIndex(item => item.toolCallId === event.toolCallId &&
401
562
  item.agentId === event.agentId && item.kind?.startsWith('tool.'))
@@ -422,6 +583,7 @@ export function createPipeline({
422
583
  sequence: ++sequence, incomplete: true,
423
584
  });
424
585
  session.activity[index] = { ...event, label: activityLabel(event), state: 'unresolved' };
586
+ platform.recordActivity(event);
425
587
  changed = true;
426
588
  }
427
589
  }
@@ -429,31 +591,7 @@ export function createPipeline({
429
591
  }
430
592
 
431
593
  async function discoverPaths() {
432
- // Bounded name discovery lets a shell-created file enter the next observation.
433
- // Core still authorizes each resolved file before reading any content.
434
- if (clock() - lastDiscoveryAt < 1000) return discoveredPaths;
435
- lastDiscoveryAt = clock();
436
- const found = [];
437
- const dirs = [{ dir: root, depth: 0 }];
438
- let visited = 0;
439
- while (dirs.length && found.length < 64 && visited++ < 100) {
440
- const { dir, depth } = dirs.shift();
441
- let entries;
442
- try { entries = await readdir(dir, { withFileTypes: true }); } catch { continue; }
443
- entries.sort((a, b) => a.name.localeCompare(b.name));
444
- for (const entry of entries) {
445
- if (entry.isSymbolicLink() || entry.name.startsWith('.')) continue;
446
- if (entry.isDirectory() && depth < 5 && !SKIP_DIR.has(entry.name)) {
447
- dirs.push({ dir: path.join(dir, entry.name), depth: depth + 1 });
448
- } else if (entry.isFile() && SOURCE_EXT.test(entry.name) &&
449
- !/(?:package-lock|pnpm-lock|yarn\.lock)/.test(entry.name)) {
450
- found.push(path.join(dir, entry.name));
451
- if (found.length >= 64) break;
452
- }
453
- }
454
- }
455
- discoveredPaths = found;
456
- return found;
594
+ return platform.discover({ limit: 64 });
457
595
  }
458
596
 
459
597
  function canonicalNamedPaths(paths, raw) {
@@ -555,7 +693,15 @@ export function createPipeline({
555
693
  if (closed || paused || !deferredWork.size) return;
556
694
  const work = [...deferredWork];
557
695
  deferredWork.clear();
558
- const artifacts = await evidence.reconcile();
696
+ const needed = new Set(work.flatMap(([, entry]) => [...entry.artifacts]));
697
+ for (const [sessionId] of work) {
698
+ for (const edge of sessions.get(sessionId)?.graph.edges ?? []) {
699
+ if (edge.sourceRefs.some(ref => needed.has(ref.artifactId))) {
700
+ edge.sourceRefs.forEach(ref => needed.add(ref.artifactId));
701
+ }
702
+ }
703
+ }
704
+ const artifacts = await evidence.reconcile({ refs: [...needed].map(artifactId => ({ artifactId })) });
559
705
  registerArtifacts(artifacts);
560
706
  for (const [sessionId, entry] of work) {
561
707
  const session = sessions.get(sessionId);
@@ -602,7 +748,7 @@ export function createPipeline({
602
748
  const sourceIdentity = job => job.candidates.some(candidate => candidate.sourceRef?.type === 'message')
603
749
  ? null : sourceVersions(job.candidates).map(ref => ref.artifactId).sort().join(',');
604
750
  const classificationKey = job => `${job.event.sessionId}:${opaque(JSON.stringify([
605
- policy.version,
751
+ policy.version, job.lineageId,
606
752
  { kind: job.event.kind, toolCategory: job.event.toolCategory,
607
753
  outcome: job.event.outcome, incomplete: job.event.incomplete !== false },
608
754
  job.candidates.map(candidate => candidate.digest),
@@ -650,7 +796,7 @@ export function createPipeline({
650
796
  const session = sessions.get(event.sessionId);
651
797
  if (!session) { skip('session_evicted'); return; }
652
798
  if (paused) { deferClassification(event, candidates); skip('paused_deferred'); return; }
653
- const job = { event, candidates, sourceEventId, session, enqueuedAt: clock(),
799
+ const job = { event, candidates, sourceEventId, session, lineageId, enqueuedAt: clock(),
654
800
  needsRefresh: activeClassifications.size >= MAX_CLASSIFICATIONS };
655
801
  const covered = coverageReason(job);
656
802
  if (covered) { skipJob(job, covered); return; }
@@ -783,6 +929,7 @@ export function createPipeline({
783
929
  diagnostics: result?.diagnostics ?? {} });
784
930
  await serialized(async () => {
785
931
  if (closed || !sessions.has(event.sessionId)) { skip(closed ? 'pipeline_closed' : 'session_evicted'); return; }
932
+ if (job.lineageId !== lineageId) { skip('source_changed_during_classification'); return; }
786
933
  if (paused) { deferClassification(event, candidates); skip('paused_deferred'); notify(); return; }
787
934
  if (clock() >= deadlineAt) { classifier = 'timeout'; dropped++; skip('deadline_before_apply'); notify(); return; }
788
935
  if (result.status === 'timeout') classifier = 'timeout';
@@ -796,7 +943,7 @@ export function createPipeline({
796
943
  bundle?.policyVersion === policy.version && Array.isArray(bundle.candidates)) {
797
944
  // Reobserve the worktree before accepting remote answers; a tool could
798
945
  // have edited these files while either Jev request was in flight.
799
- registerArtifacts(await evidence.reconcile());
946
+ registerArtifacts(await evidence.reconcile({ refs: sourceVersions(candidates) }));
800
947
  if (closed || sessions.get(event.sessionId) !== session) { skip(closed ? 'pipeline_closed' : 'session_evicted'); return; }
801
948
  if (paused) { deferClassification(event, candidates); skip('paused_deferred'); notify(); return; }
802
949
  const artifactRefs = sourceVersions(candidates);
@@ -825,6 +972,10 @@ export function createPipeline({
825
972
  const patch = compileDecision(before, { event, decision: result, policy,
826
973
  onDiagnostic: entry => admission.push(entry) });
827
974
  recordPatch(session, patch);
975
+ // The legacy canvas cap is not a semantic admission cap. Compile
976
+ // this bounded decision independently so the model can retain it.
977
+ const independent = compileDecision(emptyGraph(), { event, decision: result, policy });
978
+ if (independent) platform.observeLegacy(applyPatch(emptyGraph(), independent), { sessionId: session.id, fresh: true });
828
979
  remember();
829
980
  const after = session.graph;
830
981
  const counts = { revisionBefore: before.revision, revisionAfter: after.revision };
@@ -923,6 +1074,11 @@ export function createPipeline({
923
1074
  if (dedup.size > MAX_DEDUP) dedup.delete(dedup.keys().next().value);
924
1075
  }
925
1076
  const session = ensureSession(event.sessionId);
1077
+ session.host = host;
1078
+ session.startedAt ??= event.at;
1079
+ session.status = event.kind === 'session.ended' ? 'ended' : 'active';
1080
+ if (event.kind === 'session.ended') session.endedAt = event.at;
1081
+ syncSessions();
926
1082
  if (followSession) selectedSession = session.id;
927
1083
  observeMessage(event, prepared.publicText);
928
1084
  addActivity(session, event);
@@ -935,20 +1091,22 @@ export function createPipeline({
935
1091
  const named = Array.isArray(prepared.paths) ? canonicalNamedPaths(prepared.paths, raw) : [];
936
1092
  const discover = event.kind === 'session.started' ||
937
1093
  ['tool.succeeded', 'tool.failed'].includes(event.kind);
938
- const paths = [...new Set([...named, ...(discover ? await discoverPaths() : [])])];
1094
+ const sessionPaths = event.kind === 'session.started'
1095
+ ? [...knownArtifacts.values()].filter(item => item.status === 'present').slice(0, 64).map(item => item.path) : [];
1096
+ const paths = [...new Set([...named, ...sessionPaths, ...(discover ? await discoverPaths() : [])])];
939
1097
  // EvidenceStore bounds each capture to 32 paths. Process the bounded
940
1098
  // discovery list in chunks rather than silently losing its tail.
941
1099
  for (let index = 0; index < paths.length; index += 32) {
942
1100
  artifacts.push(...await evidence.capture(paths.slice(index, index + 32)));
943
1101
  }
944
- const changed = registerArtifacts(artifacts);
1102
+ const changed = registerArtifacts(artifacts, metadataEvent(event));
945
1103
  trace(event, 'capture', { status: 'observed', reason: 'artifacts_observed',
946
1104
  artifacts: artifacts.map(artifact => ({ ...artifactMetadata(artifact),
947
1105
  reason: changed.some(item => item.id === artifact.id) ? 'artifact_changed' : 'artifact_unchanged' })) });
948
1106
  // A tool completion also reconciles prior support, including deletions
949
1107
  // omitted from the tool's returned file list.
950
1108
  if (event.kind.startsWith('tool.') && event.kind !== 'tool.requested') {
951
- registerArtifacts(await evidence.reconcile());
1109
+ registerArtifacts(await evidence.reconcile({ limit: 32 }));
952
1110
  }
953
1111
  namedSet = new Set(named.map(file => path.resolve(root, file)));
954
1112
  // Global observation history is not a session's classification
@@ -998,10 +1156,26 @@ export function createPipeline({
998
1156
  reconciliationTask = serialized(async () => {
999
1157
  if (closed) return;
1000
1158
  const expired = expirePending();
1001
- const artifacts = await evidence.capture(await discoverPaths());
1002
- const known = await evidence.reconcile();
1003
- const changed = registerArtifacts([...artifacts, ...known]);
1004
- if (!changed.length) { if (expired) notify(); return; }
1159
+ const beforeModel = platform.model.stats();
1160
+ const paths = await discoverPaths();
1161
+ const artifacts = [];
1162
+ for (let index = 0; index < paths.length; index += 32) {
1163
+ artifacts.push(...await evidence.capture(paths.slice(index, index + 32)));
1164
+ }
1165
+ const known = await evidence.reconcile({ limit: 32 });
1166
+ const observed = [...artifacts, ...known];
1167
+ const changed = registerArtifacts(observed);
1168
+ // Parser revalidation may have already refreshed a file's generation.
1169
+ // Retain canceled classifier work until this dispatch path sees it.
1170
+ for (const artifact of observed) {
1171
+ if (!lineageWork.delete(artifact.id)) continue;
1172
+ if (!changed.some(item => item.id === artifact.id)) changed.push(artifact);
1173
+ }
1174
+ if (!changed.length) {
1175
+ const afterModel = platform.model.stats();
1176
+ if (expired || beforeModel.revision !== afterModel.revision || beforeModel.sequence !== afterModel.sequence) notify();
1177
+ return;
1178
+ }
1005
1179
  const session = selectedSession ? sessions.get(selectedSession) : null;
1006
1180
  if (session) {
1007
1181
  const event = observationEvent(session.id);
@@ -1021,6 +1195,7 @@ export function createPipeline({
1021
1195
  function setPaused(value) {
1022
1196
  const wasPaused = paused;
1023
1197
  paused = Boolean(value);
1198
+ if (wasPaused !== paused) architectureEpoch++;
1024
1199
  if (!wasPaused && paused) {
1025
1200
  for (const job of classificationQueue.splice(0)) {
1026
1201
  deferClassification(job.event, job.candidates);
@@ -1033,10 +1208,30 @@ export function createPipeline({
1033
1208
  serialized(flushDeferred).catch(() => { dropped++; })
1034
1209
  .finally(() => { resumeScheduled = false; });
1035
1210
  }
1211
+ architecture.wake();
1036
1212
  notify();
1037
1213
  return getState();
1038
1214
  }
1039
1215
 
1216
+ function observeLineage(lineage) {
1217
+ return serialized(() => {
1218
+ if (closed || lineage.id === lineageId) return;
1219
+ platform.observeLineage(lineage);
1220
+ lineageId = lineage.id;
1221
+ architectureEpoch++;
1222
+ architecture.invalidate();
1223
+ completedClassifications.clear();
1224
+ for (const job of activeClassifications) job.controller.abort();
1225
+ for (const job of classificationQueue.splice(0)) {
1226
+ pending--;
1227
+ skipJob(job, 'source_changed_during_classification');
1228
+ }
1229
+ for (const artifactId of knownArtifacts.keys()) lineageWork.add(artifactId);
1230
+ registerArtifacts(evidence.setLineage(lineage.id));
1231
+ notify();
1232
+ });
1233
+ }
1234
+
1040
1235
  function selectSession(id) {
1041
1236
  if (!sessions.has(id)) return false;
1042
1237
  selectedSession = id;
@@ -1045,18 +1240,26 @@ export function createPipeline({
1045
1240
  }
1046
1241
 
1047
1242
  async function whenIdle() {
1048
- await serial;
1049
- while (tasks.size || classificationQueue.length) {
1050
- pumpClassifications();
1051
- await Promise.allSettled([...tasks]);
1243
+ do {
1052
1244
  await serial;
1053
- }
1054
- await serial;
1245
+ await platform.whenIdle();
1246
+ await serial;
1247
+ while (tasks.size || classificationQueue.length) {
1248
+ pumpClassifications();
1249
+ await Promise.allSettled([...tasks]);
1250
+ await serial;
1251
+ }
1252
+ await platform.whenIdle();
1253
+ await architecture.whenIdle();
1254
+ await serial;
1255
+ } while (tasks.size || classificationQueue.length || platform.stats().queued || platform.stats().active);
1055
1256
  }
1056
1257
 
1057
1258
  async function close() {
1058
1259
  if (closed) return;
1059
1260
  closed = true;
1261
+ architectureEpoch++;
1262
+ const architectureClosed = architecture.close();
1060
1263
  deferredWork.clear();
1061
1264
  for (const job of classificationQueue.splice(0)) {
1062
1265
  skipJob(job, 'pipeline_closed');
@@ -1064,8 +1267,17 @@ export function createPipeline({
1064
1267
  }
1065
1268
  for (const job of activeClassifications) job.controller.abort();
1066
1269
  decisionService?.close?.();
1270
+ await platform.close();
1271
+ await architectureClosed;
1067
1272
  await whenIdle();
1068
1273
  }
1069
1274
 
1070
- return { ingest, getState, reconcile, setPaused, selectSession, whenIdle, close };
1275
+ return {
1276
+ ingest, getState, reconcile, observeLineage, setPaused, selectSession, whenIdle, close,
1277
+ getArchitectureStatus: () => architecture.status(),
1278
+ discoverArchitecture: () => architecture.request(),
1279
+ getModelState: options => platform.snapshot(options),
1280
+ createCheckpoint: options => platform.checkpoint(options),
1281
+ model: platform.model,
1282
+ };
1071
1283
  }