graphlin 0.1.2 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (102) 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 +465 -0
  8. package/docs/visualizer-views.md +199 -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 +74 -9
  34. package/plugin.json +4 -2
  35. package/runtime/core/evidence.mjs +43 -9
  36. package/runtime/core/graph.mjs +11 -6
  37. package/runtime/core/privacy.mjs +1 -0
  38. package/runtime/daemon/auth.mjs +7 -3
  39. package/runtime/daemon/diagnostics.mjs +1 -1
  40. package/runtime/daemon/extension-api.mjs +203 -0
  41. package/runtime/daemon/lineage.mjs +70 -0
  42. package/runtime/daemon/manager.mjs +9 -6
  43. package/runtime/daemon/model-api.mjs +728 -0
  44. package/runtime/daemon/model-persistence.mjs +220 -0
  45. package/runtime/daemon/server.mjs +70 -12
  46. package/runtime/daemon/settings.mjs +11 -3
  47. package/runtime/decisions/broker.mjs +349 -0
  48. package/runtime/decisions/contracts.mjs +179 -0
  49. package/runtime/decisions/evaluation.mjs +305 -0
  50. package/runtime/decisions/faults.mjs +32 -0
  51. package/runtime/decisions/index.mjs +818 -0
  52. package/runtime/decisions/profiles.mjs +93 -0
  53. package/runtime/decisions/questions.mjs +268 -0
  54. package/runtime/discovery/index.mjs +2 -0
  55. package/runtime/discovery/inventory.mjs +160 -0
  56. package/runtime/discovery/parser.mjs +40 -0
  57. package/runtime/discovery/structure.mjs +232 -0
  58. package/runtime/extensions/contracts.mjs +59 -0
  59. package/runtime/extensions/frame.mjs +64 -0
  60. package/runtime/extensions/index.mjs +9 -0
  61. package/runtime/extensions/manifest.mjs +95 -0
  62. package/runtime/extensions/packages.mjs +222 -0
  63. package/runtime/extensions/profiles.mjs +36 -0
  64. package/runtime/extensions/projection.mjs +130 -0
  65. package/runtime/extensions/registry.mjs +285 -0
  66. package/runtime/extensions/scene.mjs +105 -0
  67. package/runtime/extensions/sdk.d.ts +205 -0
  68. package/runtime/extensions/sdk.mjs +88 -0
  69. package/runtime/jev/index.mjs +13 -777
  70. package/runtime/jev/provider.mjs +101 -0
  71. package/runtime/jev/questions.mjs +16 -258
  72. package/runtime/jev/wire.mjs +17 -25
  73. package/runtime/model/changes.mjs +42 -0
  74. package/runtime/model/history.mjs +124 -0
  75. package/runtime/model/index.mjs +2 -0
  76. package/runtime/model/project-model.mjs +889 -0
  77. package/runtime/model/records.mjs +239 -0
  78. package/runtime/pipeline.mjs +127 -48
  79. package/runtime/platform.mjs +254 -0
  80. package/runtime/visualizers/blocks.mjs +5 -0
  81. package/runtime/visualizers/c4.mjs +52 -0
  82. package/runtime/visualizers/changes.mjs +24 -0
  83. package/runtime/visualizers/code.mjs +5 -0
  84. package/runtime/visualizers/index.mjs +23 -0
  85. package/runtime/visualizers/structure.mjs +120 -0
  86. package/runtime/visualizers/timeline.mjs +66 -0
  87. package/runtime/web/app.js +369 -86
  88. package/runtime/web/extension-frame.js +128 -0
  89. package/runtime/web/index.html +123 -80
  90. package/runtime/web/model-client.js +162 -0
  91. package/runtime/web/platform.js +337 -0
  92. package/runtime/web/scene.js +111 -0
  93. package/runtime/web/style.css +152 -142
  94. package/schemas/graph.schema.json +4 -1
  95. package/scripts/arguments.mjs +5 -1
  96. package/scripts/build-packages.mjs +6 -2
  97. package/scripts/control.mjs +1 -1
  98. package/scripts/daemon.mjs +2 -1
  99. package/scripts/extensions.mjs +44 -0
  100. package/scripts/graphlin.mjs +23 -3
  101. package/scripts/onboarding.mjs +10 -3
  102. package/scripts/validate-packages.mjs +54 -8
@@ -0,0 +1,239 @@
1
+ import { opaque, plain, integer } from '../core/common.mjs';
2
+ import { excluded, safeText } from '../core/privacy.mjs';
3
+
4
+ export const DEFAULT_LIMITS = Object.freeze({
5
+ entities: 20_000, relations: 40_000, artifacts: 10_000, interpretations: 512,
6
+ imports: 20_000, scopes: 1_024, activity: 2_048, sessions: 64, checkpoints: 8,
7
+ bytes: 48 * 1024 * 1024, softBytes: 32 * 1024 * 1024,
8
+ checkpointBytes: 32 * 1024 * 1024, refs: 16,
9
+ });
10
+ export const id = value => typeof value === 'string' &&
11
+ /^[A-Za-z0-9][A-Za-z0-9_.:-]{0,159}$/.test(value) && safeText(value, 160) ? value : null;
12
+ export const token = (value, fallback = 'unknown') => typeof value === 'string' &&
13
+ /^[a-z][a-z0-9_.-]{0,63}$/.test(value) && safeText(value, 64) ? value : fallback;
14
+ export const label = (value, fallback = 'Unknown') => typeof value === 'string' &&
15
+ safeText(value, 240) && !/[\r\n<>]/.test(value) && !/^(?:\/|[a-z]+:)/i.test(value) ? value : fallback;
16
+ export const validity = value => ['current', 'stale', 'retracted'].includes(value) ? value : 'stale';
17
+ export const classification = value => ['accepted', 'tentative', 'unknown', 'stale', 'pending', 'abstained'].includes(value)
18
+ ? value : 'unknown';
19
+ export const basis = value => ['metadata', 'parsed', 'lexical', 'decision', 'legacy'].includes(value) ? value : 'legacy';
20
+ export const byteSize = value => Buffer.byteLength(JSON.stringify(value));
21
+ export const key = (prefix, ...parts) => opaque(prefix, ...parts);
22
+ const version = value => typeof value === 'string' && value.length <= 256 &&
23
+ /^[@A-Za-z0-9][A-Za-z0-9_.@+/-]*$/.test(value) && safeText(value, 256) ? value : null;
24
+
25
+ export function relativePath(value, policy = {}) {
26
+ if (typeof value !== 'string' || value.length > 512 || !value ||
27
+ /[\\:\0\r\n<>%]/.test(value) || value.split('/').some(p => !p || p === '.' || p === '..') ||
28
+ !safeText(value, 512) || excluded(value, policy)) return null;
29
+ return value;
30
+ }
31
+
32
+ export function currentPolicy(provider) {
33
+ const value = (typeof provider === 'function' ? provider() : provider) ?? {};
34
+ return {
35
+ readSource: value.readSource === true || value.transmitSource === true,
36
+ displayEvidence: value.displayEvidence !== false,
37
+ persistEvidence: value.persistEvidence === true,
38
+ excludePaths: Array.isArray(value.excludePaths) ? value.excludePaths : [],
39
+ };
40
+ }
41
+
42
+ export function lineageRecord(value) {
43
+ if (!plain(value) || !id(value.id)) return null;
44
+ const branch = label(value.branch, '');
45
+ return {
46
+ id: value.id, status: token(value.status),
47
+ ...(branch ? { branch } : {}),
48
+ ...(typeof value.head === 'string' && /^(?:[a-f0-9]{40}|[a-f0-9]{64})$/.test(value.head) ? { head: value.head } : {}),
49
+ };
50
+ }
51
+
52
+ export function references(values, max = DEFAULT_LIMITS.refs) {
53
+ if (!Array.isArray(values) || values.length > max) return null;
54
+ const result = [];
55
+ for (const value of values) {
56
+ if (!plain(value) || !id(value.artifactId) || !integer(value.generation, 1) ||
57
+ typeof value.hash !== 'string' || !/^[a-f0-9]{64}$/.test(value.hash)) return null;
58
+ const ref = { artifactId: value.artifactId, hash: value.hash, generation: value.generation };
59
+ if (integer(value.startLine, 1, 10_000_000) && integer(value.endLine, value.startLine, 10_000_000)) {
60
+ ref.startLine = value.startLine;
61
+ ref.endLine = value.endLine;
62
+ }
63
+ if (id(value.eventId)) ref.eventId = value.eventId;
64
+ if (id(value.extractor)) ref.extractor = value.extractor;
65
+ if (version(value.extractorVersion)) ref.extractorVersion = value.extractorVersion;
66
+ if (version(value.identityVersion)) ref.identityVersion = value.identityVersion;
67
+ // Intent references cannot be promoted to deterministic source support.
68
+ if (value.sourceClass === 'public_intent') ref.sourceClass = 'public_intent';
69
+ result.push(ref);
70
+ }
71
+ return result;
72
+ }
73
+
74
+ export function entityRecord(value, maxRefs) {
75
+ if (!plain(value) || !id(value.id)) return null;
76
+ const sourceRefs = references(value.sourceRefs ?? [], maxRefs);
77
+ if (!sourceRefs) return null;
78
+ const entity = {
79
+ id: value.id, label: label(value.label), kind: token(value.kind),
80
+ parentId: id(value.parentId), sourceRefs, basis: basis(value.basis),
81
+ validity: validity(value.validity), classification: classification(value.classification),
82
+ };
83
+ if (id(value.artifactId)) entity.artifactId = value.artifactId;
84
+ if (value.qualifiedName) entity.qualifiedName = label(value.qualifiedName);
85
+ if (id(value.sessionId)) entity.sessionId = value.sessionId;
86
+ if (id(value.legacyId)) entity.legacyId = value.legacyId;
87
+ if (relativePath(value.relativePath)) entity.relativePath = value.relativePath;
88
+ if (integer(value.knownAtSequence)) entity.knownAtSequence = value.knownAtSequence;
89
+ if (integer(value.createdAtSequence, 1)) entity.createdAtSequence = value.createdAtSequence;
90
+ if (value.ownership === 'unresolved') entity.ownership = 'unresolved';
91
+ return entity;
92
+ }
93
+
94
+ export function relationRecord(value, maxRefs) {
95
+ if (!plain(value) || !id(value.id) || !id(value.source) || !id(value.target)) return null;
96
+ const sourceRefs = references(value.sourceRefs ?? [], maxRefs);
97
+ if (!sourceRefs) return null;
98
+ return {
99
+ id: value.id, source: value.source, target: value.target, kind: token(value.kind),
100
+ basis: basis(value.basis), validity: validity(value.validity), sourceRefs,
101
+ };
102
+ }
103
+
104
+ export function interpretationRecord(value, maxRefs) {
105
+ if (!plain(value) || !id(value.id) || typeof value.namespace !== 'string' ||
106
+ !/^[a-z][a-z0-9_-]*(?:[.:][a-z0-9_-]+)+$/.test(value.namespace) || !safeText(value.namespace, 80)) return null;
107
+ const sourceRefs = references(value.sourceRefs ?? [], maxRefs);
108
+ if (!sourceRefs || !Array.isArray(value.entityIds) || value.entityIds.length > 256 ||
109
+ value.entityIds.some(value => !id(value))) return null;
110
+ return {
111
+ id: value.id, namespace: value.namespace, kind: token(value.kind),
112
+ label: label(value.label), entityIds: [...new Set(value.entityIds)],
113
+ basis: 'decision', validity: validity(value.validity),
114
+ classification: classification(value.classification),
115
+ support: ['supported', 'tentative', 'unknown', 'contradicted'].includes(value.support) ? value.support : 'unknown',
116
+ sourceRefs, version: id(String(value.version ?? '1')) ?? '1',
117
+ ...(id(value.sessionId) ? { sessionId: value.sessionId } : {}),
118
+ };
119
+ }
120
+
121
+ export function certificate(value) {
122
+ if (!plain(value) || !id(value.artifactId) || !id(value.scopeId) ||
123
+ !integer(value.generation, 1) || !/^[a-f0-9]{64}$/.test(value.hash)) return null;
124
+ const ranges = Array.isArray(value.coveredRanges) ? value.coveredRanges.slice(0, 128).flatMap(range => {
125
+ const startLine = Array.isArray(range) ? range[0] : range?.startLine;
126
+ const endLine = Array.isArray(range) ? range[1] : range?.endLine;
127
+ return integer(startLine, 1, 10_000_000) && integer(endLine, startLine, 10_000_000) ? [{ startLine, endLine }] : [];
128
+ }) : [];
129
+ const omissions = Array.isArray(value.omissions) ? value.omissions.slice(0, 32).map(v => token(v, 'unspecified')) : ['unspecified'];
130
+ const validVersions = version(String(value.version ?? '')) && version(String(value.identityVersion ?? '')) && id(value.extractor);
131
+ return {
132
+ artifactId: value.artifactId, scopeId: value.scopeId, hash: value.hash, generation: value.generation,
133
+ complete: value.complete === true && omissions.length === 0 && !!validVersions && ranges.length > 0 &&
134
+ value.coveredRanges.length === ranges.length && value.capability === 'parsed',
135
+ extractor: id(value.extractor) ?? 'unknown', version: version(String(value.version ?? '')) ?? 'unknown',
136
+ identityVersion: version(String(value.identityVersion ?? '')) ?? 'unknown',
137
+ coveredRanges: ranges, omissions, capability: token(value.capability),
138
+ };
139
+ }
140
+
141
+ export function activityRecord(value, sequence, now) {
142
+ if (!plain(value)) return null;
143
+ const at = time(value.at ?? value.timestamp, now);
144
+ const record = {
145
+ id: id(value.id) ?? key('activity', sequence, at),
146
+ kind: token(value.kind, 'capture.gap'), sequence, knownAtSequence: sequence,
147
+ at, recordedAt: time(now, now),
148
+ outcome: ['pending', 'succeeded', 'failed', 'interrupted', 'denied', 'unresolved', 'observed'].includes(value.outcome)
149
+ ? value.outcome : 'unresolved',
150
+ toolCategory: token(value.toolCategory, 'other'),
151
+ attribution: ['observed', 'correlated', 'unknown'].includes(value.attribution) ? value.attribution : 'unknown',
152
+ };
153
+ for (const field of ['sessionId', 'agentId', 'toolCallId']) if (id(value[field])) record[field] = value[field];
154
+ for (const field of ['entityIds', 'artifactIds']) {
155
+ record[field] = Array.isArray(value[field]) ? [...new Set(value[field].slice(0, 256).filter(id))] : [];
156
+ }
157
+ const sourceRefs = references(value.sourceRefs ?? []);
158
+ record.sourceRefs = sourceRefs ?? [];
159
+ // Creation is accepted only as an explicit successful observation with source
160
+ // version correlation. A write attempt or a new file in inventory is not one.
161
+ record.creation = value.creation === true && record.outcome === 'succeeded' &&
162
+ record.kind !== 'tool.requested' && record.sourceRefs.length > 0 &&
163
+ record.sourceRefs.every(ref => ref.sourceClass !== 'public_intent');
164
+ return record;
165
+ }
166
+
167
+ export function time(value, fallback = 0) {
168
+ const number = typeof value === 'string' ? Date.parse(value) : value;
169
+ const safe = Number.isFinite(number) && number >= 0 && number <= 8_640_000_000_000_000 ? number : fallback;
170
+ return new Date(Number.isFinite(safe) ? safe : 0).toISOString();
171
+ }
172
+
173
+ /** Projection is an allowlist, including for restored state and old checkpoints. */
174
+ export function projectSnapshot(state, policy, { persistent = false, sessionId, scopeId } = {}) {
175
+ const sourceLabels = policy.readSource && policy.displayEvidence && (!persistent || policy.persistEvidence);
176
+ const blockedArtifacts = new Set((state.coverage.artifacts ?? []).filter(value =>
177
+ value.relativePath && !relativePath(value.relativePath, policy)).map(value => value.id));
178
+ let entities = state.entities.map(value => entityRecord(value)).filter(Boolean).map(entity => {
179
+ const path = relativePath(entity.relativePath, policy);
180
+ const metadata = path && (entity.basis === 'metadata' || ['file', 'module'].includes(entity.kind));
181
+ if (metadata) entity.label = path.split('/').at(-1);
182
+ if (!sourceLabels || blockedArtifacts.has(entity.artifactId) || entity.relativePath && !path) {
183
+ if (!metadata) {
184
+ entity.label = entity.kind === 'project' ? 'Project' : token(entity.kind, 'entity');
185
+ }
186
+ delete entity.qualifiedName;
187
+ }
188
+ if (entity.basis === 'metadata' && !path && entity.kind !== 'project') entity.label = entity.kind;
189
+ if (!path) delete entity.relativePath;
190
+ return entity;
191
+ });
192
+ if (scopeId) {
193
+ const children = new Map();
194
+ for (const entity of entities) {
195
+ if (!children.has(entity.parentId)) children.set(entity.parentId, []);
196
+ children.get(entity.parentId).push(entity.id);
197
+ }
198
+ const selected = new Set(), pending = [scopeId];
199
+ while (pending.length) {
200
+ const next = pending.pop();
201
+ if (selected.has(next)) continue;
202
+ selected.add(next);
203
+ pending.push(...(children.get(next) ?? []));
204
+ }
205
+ const byId = new Map(entities.map(entity => [entity.id, entity]));
206
+ let parent = byId.get(scopeId)?.parentId;
207
+ while (parent && !selected.has(parent)) { selected.add(parent); parent = byId.get(parent)?.parentId; }
208
+ entities = entities.filter(entity => selected.has(entity.id));
209
+ }
210
+ const entityIds = new Set(entities.map(entity => entity.id));
211
+ const relations = state.relations.map(value => relationRecord(value)).filter(value =>
212
+ value && entityIds.has(value.source) && entityIds.has(value.target));
213
+ const interpretations = state.interpretations.map(value => interpretationRecord(value)).filter(value =>
214
+ value && value.entityIds.every(entityId => entityIds.has(entityId))).map(value =>
215
+ sourceLabels && !value.sourceRefs.some(ref => blockedArtifacts.has(ref.artifactId)) ? value : { ...value, label: 'Interpretation' });
216
+ const activity = state.activity.filter(value => !sessionId || value.sessionId === sessionId).map(value => {
217
+ const record = activityRecord(value, value.sequence, Date.parse(value.recordedAt));
218
+ record.entityIds = record.entityIds.filter(value => entityIds.has(value));
219
+ return record;
220
+ });
221
+ const coverage = structuredClone(state.coverage);
222
+ coverage.scopes = (coverage.scopes ?? []).map(scope => {
223
+ const path = relativePath(scope.relativePath, policy);
224
+ return { ...scope, label: path ?? 'Scope', ...(path ? { relativePath: path } : { relativePath: undefined }) };
225
+ });
226
+ coverage.files = (coverage.files ?? []).map(file => {
227
+ const path = relativePath(file.relativePath, policy);
228
+ return { ...file, relativePath: path ?? undefined, root: relativePath(file.root, policy) ?? undefined };
229
+ });
230
+ coverage.artifacts = (coverage.artifacts ?? []).map(artifact => ({
231
+ ...artifact, relativePath: relativePath(artifact.relativePath, policy) ?? undefined,
232
+ }));
233
+ return {
234
+ schemaVersion: 2, projectId: state.projectId, revision: state.revision, sequence: state.sequence,
235
+ entities, relations, interpretations, activity, coverage,
236
+ sessions: state.sessions.filter(value => !sessionId || value.id === sessionId).map(value => ({ ...value })),
237
+ checkpoints: state.checkpoints.map(value => ({ ...value, label: sourceLabels ? label(value.label, 'Checkpoint') : 'Checkpoint' })),
238
+ };
239
+ }
@@ -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,7 @@ 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
11
 
12
12
  const MAX_ACTIVITY = 200;
13
13
  const MAX_HOOK_EVENTS = 200;
@@ -23,11 +23,6 @@ const CLASSIFICATION_QUEUE_TTL_MS = 120_000;
23
23
  const DEADLINE_MS = 2000;
24
24
  const PENDING_LEASE_MS = 60_000;
25
25
  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
26
  const FIXED_CLASSIFIER = new Set([
32
27
  'ready', 'metadata_only', 'missing_key', 'paused', 'unavailable', 'timeout', 'demo',
33
28
  ]);
@@ -97,7 +92,7 @@ function restoreGraph(input, { stale = true } = {}) {
97
92
  */
98
93
  export function createPipeline({
99
94
  projectRoot, policy: policyOptions, decisionService, onChange = () => {},
100
- onDiagnostic = () => {}, restoredState, mode = 'live', clock = Date.now,
95
+ onDiagnostic = () => {}, restoredState, restoredModel, mode = 'live', clock = Date.now,
101
96
  classificationDeadlineMs = DEADLINE_MS,
102
97
  } = {}) {
103
98
  if (!Number.isSafeInteger(classificationDeadlineMs) ||
@@ -107,13 +102,37 @@ export function createPipeline({
107
102
  const root = realpathSync(projectRoot);
108
103
  const inputRoot = path.resolve(projectRoot);
109
104
  const projectId = opaque(root);
105
+ const modelProjectId = createHash('sha256').update(root).digest('hex');
110
106
  const policy = createPolicy(policyOptions ?? {});
111
- const evidence = new EvidenceStore({ projectRoot, policy });
107
+ // Restored claims stay stale until recaptured, and their versions must never
108
+ // collide with a new daemon's counters. No saved content is trusted here.
109
+ let generationFloor = 0;
110
+ const retainGeneration = value => {
111
+ if (Number.isSafeInteger(value) && value > generationFloor && value < Number.MAX_SAFE_INTEGER) generationFloor = value;
112
+ };
113
+ const retainRefs = rows => {
114
+ for (const row of Array.isArray(rows) ? rows.slice(0, 40000) : []) {
115
+ for (const ref of Array.isArray(row?.sourceRefs) ? row.sourceRefs.slice(0, 16) : []) retainGeneration(ref.generation);
116
+ }
117
+ };
118
+ if (restoredModel?.projectId === modelProjectId) {
119
+ const artifacts = restoredModel.coverage?.artifacts;
120
+ for (const artifact of Array.isArray(artifacts) ? artifacts.slice(0, 10000) : []) retainGeneration(artifact?.generation);
121
+ for (const rows of [restoredModel.entities, restoredModel.relations, restoredModel.interpretations]) retainRefs(rows);
122
+ }
123
+ if (restoredState?.projectId === projectId) {
124
+ const states = Array.isArray(restoredState.sessionStates) ? restoredState.sessionStates.slice(0, MAX_SESSIONS) : [];
125
+ for (const session of [restoredState, ...states]) {
126
+ retainRefs(session?.graph?.nodes); retainRefs(session?.graph?.edges);
127
+ }
128
+ }
129
+ const evidence = new EvidenceStore({ projectRoot, policy, maxTrackedPaths: 10000, generationFloor });
112
130
  const sessions = new Map();
113
131
  const dedup = new Map();
114
132
  const sessionStarts = new Map();
115
133
  const hookEvents = [];
116
134
  const knownArtifacts = new Map();
135
+ const lineageWork = new Set();
117
136
  const messageVersions = new Map();
118
137
  const deferredWork = new Map();
119
138
  const classificationQueue = [];
@@ -130,16 +149,26 @@ export function createPipeline({
130
149
  let pending = 0;
131
150
  let classifier = mode === 'demo' ? 'demo' : policy.transmitSource ? 'ready' : 'metadata_only';
132
151
  let serial = Promise.resolve();
133
- let lastDiscoveryAt = -Infinity;
134
- let discoveredPaths = [];
135
152
  let reconciliationTask = null;
136
153
  let resumeScheduled = false;
154
+ let lineageId = null;
137
155
 
138
156
  const serialized = (fn) => {
139
157
  const operation = serial.then(fn);
140
158
  serial = operation.catch(() => {});
141
159
  return operation;
142
160
  };
161
+ const platform = createPlatform({
162
+ projectRoot: root, projectId: modelProjectId,
163
+ policy, restoredState: restoredModel, now: clock,
164
+ accept: operation => serialized(operation),
165
+ revalidate: refs => serialized(async () => {
166
+ if (closed) return false;
167
+ registerArtifacts(await evidence.reconcile({ refs }));
168
+ return evidence.isCurrent(refs);
169
+ }),
170
+ onChange: notify,
171
+ });
143
172
 
144
173
  function artifactMetadata(artifact) {
145
174
  const relative = artifact.relativePath;
@@ -217,17 +246,24 @@ export function createPipeline({
217
246
  const session = freshSession(id, `Session ${sessions.size + 1}`);
218
247
  sessions.set(id, session);
219
248
  selectedSession ??= id;
249
+ platform.setSessions([...sessions.values()].map(item => ({ id: item.id, label: item.label })));
220
250
  return session;
221
251
  }
222
252
 
223
253
  function evictSession(id) {
224
254
  sessions.delete(id);
255
+ syncSessions();
225
256
  deferredWork.delete(id);
226
257
  for (const key of completedClassifications.keys()) {
227
258
  if (key.startsWith(`${id}:`)) completedClassifications.delete(key);
228
259
  }
229
260
  }
230
261
 
262
+ function syncSessions() {
263
+ platform.setSessions([...sessions.values()].map(({ id, label, host, status, startedAt, endedAt }) =>
264
+ ({ id, label, host, status, startedAt, endedAt })));
265
+ }
266
+
231
267
  if (restoredState?.projectId === projectId) {
232
268
  const saved = Array.isArray(restoredState.sessionStates)
233
269
  ? [restoredState, ...restoredState.sessionStates.filter(s => s.id !== restoredState.sessionId)].slice(-MAX_SESSIONS)
@@ -237,6 +273,7 @@ export function createPipeline({
237
273
  if (typeof id !== 'string' || !/^[a-zA-Z0-9_-]{1,100}$/.test(id)) continue;
238
274
  const session = ensureSession(id);
239
275
  session.graph = restoreGraph(entry.graph);
276
+ platform.observeLegacy(session.graph, { sessionId: session.id });
240
277
  // Activity is reconstructed through the metadata allowlist, never trusted verbatim.
241
278
  session.activity = (Array.isArray(entry.activity) ? entry.activity : [])
242
279
  .slice(-MAX_ACTIVITY).map(item => {
@@ -329,6 +366,7 @@ export function createPipeline({
329
366
  function recordPatch(session, patch) {
330
367
  if (!patch || !patch.operations?.length) return;
331
368
  session.graph = applyPatch(session.graph, patch);
369
+ platform.observeLegacy(session.graph, { sessionId: session.id, fresh: true });
332
370
  session.history.push({
333
371
  revision: session.graph.revision, at: new Date(clock()).toISOString(),
334
372
  graph: structuredClone(session.graph),
@@ -356,7 +394,7 @@ export function createPipeline({
356
394
  }
357
395
  }
358
396
 
359
- function registerArtifacts(artifacts) {
397
+ function registerArtifacts(artifacts, event) {
360
398
  const changed = [];
361
399
  for (const artifact of artifacts) {
362
400
  const previous = knownArtifacts.get(artifact.id);
@@ -390,12 +428,14 @@ export function createPipeline({
390
428
  recordPatch(session, invalidateArtifacts(session.graph, changed));
391
429
  }
392
430
  }
431
+ platform.observeArtifacts(artifacts, event);
393
432
  return changed;
394
433
  }
395
434
 
396
435
  function addActivity(session, event) {
397
436
  const state = activityState(event);
398
437
  const row = { ...metadataEvent(event), label: activityLabel(event), state };
438
+ platform.recordActivity(row);
399
439
  const existing = event.toolCallId
400
440
  ? session.activity.findIndex(item => item.toolCallId === event.toolCallId &&
401
441
  item.agentId === event.agentId && item.kind?.startsWith('tool.'))
@@ -422,6 +462,7 @@ export function createPipeline({
422
462
  sequence: ++sequence, incomplete: true,
423
463
  });
424
464
  session.activity[index] = { ...event, label: activityLabel(event), state: 'unresolved' };
465
+ platform.recordActivity(event);
425
466
  changed = true;
426
467
  }
427
468
  }
@@ -429,31 +470,7 @@ export function createPipeline({
429
470
  }
430
471
 
431
472
  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;
473
+ return platform.discover({ limit: 64 });
457
474
  }
458
475
 
459
476
  function canonicalNamedPaths(paths, raw) {
@@ -555,7 +572,15 @@ export function createPipeline({
555
572
  if (closed || paused || !deferredWork.size) return;
556
573
  const work = [...deferredWork];
557
574
  deferredWork.clear();
558
- const artifacts = await evidence.reconcile();
575
+ const needed = new Set(work.flatMap(([, entry]) => [...entry.artifacts]));
576
+ for (const [sessionId] of work) {
577
+ for (const edge of sessions.get(sessionId)?.graph.edges ?? []) {
578
+ if (edge.sourceRefs.some(ref => needed.has(ref.artifactId))) {
579
+ edge.sourceRefs.forEach(ref => needed.add(ref.artifactId));
580
+ }
581
+ }
582
+ }
583
+ const artifacts = await evidence.reconcile({ refs: [...needed].map(artifactId => ({ artifactId })) });
559
584
  registerArtifacts(artifacts);
560
585
  for (const [sessionId, entry] of work) {
561
586
  const session = sessions.get(sessionId);
@@ -602,7 +627,7 @@ export function createPipeline({
602
627
  const sourceIdentity = job => job.candidates.some(candidate => candidate.sourceRef?.type === 'message')
603
628
  ? null : sourceVersions(job.candidates).map(ref => ref.artifactId).sort().join(',');
604
629
  const classificationKey = job => `${job.event.sessionId}:${opaque(JSON.stringify([
605
- policy.version,
630
+ policy.version, job.lineageId,
606
631
  { kind: job.event.kind, toolCategory: job.event.toolCategory,
607
632
  outcome: job.event.outcome, incomplete: job.event.incomplete !== false },
608
633
  job.candidates.map(candidate => candidate.digest),
@@ -650,7 +675,7 @@ export function createPipeline({
650
675
  const session = sessions.get(event.sessionId);
651
676
  if (!session) { skip('session_evicted'); return; }
652
677
  if (paused) { deferClassification(event, candidates); skip('paused_deferred'); return; }
653
- const job = { event, candidates, sourceEventId, session, enqueuedAt: clock(),
678
+ const job = { event, candidates, sourceEventId, session, lineageId, enqueuedAt: clock(),
654
679
  needsRefresh: activeClassifications.size >= MAX_CLASSIFICATIONS };
655
680
  const covered = coverageReason(job);
656
681
  if (covered) { skipJob(job, covered); return; }
@@ -783,6 +808,7 @@ export function createPipeline({
783
808
  diagnostics: result?.diagnostics ?? {} });
784
809
  await serialized(async () => {
785
810
  if (closed || !sessions.has(event.sessionId)) { skip(closed ? 'pipeline_closed' : 'session_evicted'); return; }
811
+ if (job.lineageId !== lineageId) { skip('source_changed_during_classification'); return; }
786
812
  if (paused) { deferClassification(event, candidates); skip('paused_deferred'); notify(); return; }
787
813
  if (clock() >= deadlineAt) { classifier = 'timeout'; dropped++; skip('deadline_before_apply'); notify(); return; }
788
814
  if (result.status === 'timeout') classifier = 'timeout';
@@ -796,7 +822,7 @@ export function createPipeline({
796
822
  bundle?.policyVersion === policy.version && Array.isArray(bundle.candidates)) {
797
823
  // Reobserve the worktree before accepting remote answers; a tool could
798
824
  // have edited these files while either Jev request was in flight.
799
- registerArtifacts(await evidence.reconcile());
825
+ registerArtifacts(await evidence.reconcile({ refs: sourceVersions(candidates) }));
800
826
  if (closed || sessions.get(event.sessionId) !== session) { skip(closed ? 'pipeline_closed' : 'session_evicted'); return; }
801
827
  if (paused) { deferClassification(event, candidates); skip('paused_deferred'); notify(); return; }
802
828
  const artifactRefs = sourceVersions(candidates);
@@ -825,6 +851,10 @@ export function createPipeline({
825
851
  const patch = compileDecision(before, { event, decision: result, policy,
826
852
  onDiagnostic: entry => admission.push(entry) });
827
853
  recordPatch(session, patch);
854
+ // The legacy canvas cap is not a semantic admission cap. Compile
855
+ // this bounded decision independently so the model can retain it.
856
+ const independent = compileDecision(emptyGraph(), { event, decision: result, policy });
857
+ if (independent) platform.observeLegacy(applyPatch(emptyGraph(), independent), { sessionId: session.id, fresh: true });
828
858
  remember();
829
859
  const after = session.graph;
830
860
  const counts = { revisionBefore: before.revision, revisionAfter: after.revision };
@@ -923,6 +953,11 @@ export function createPipeline({
923
953
  if (dedup.size > MAX_DEDUP) dedup.delete(dedup.keys().next().value);
924
954
  }
925
955
  const session = ensureSession(event.sessionId);
956
+ session.host = host;
957
+ session.startedAt ??= event.at;
958
+ session.status = event.kind === 'session.ended' ? 'ended' : 'active';
959
+ if (event.kind === 'session.ended') session.endedAt = event.at;
960
+ syncSessions();
926
961
  if (followSession) selectedSession = session.id;
927
962
  observeMessage(event, prepared.publicText);
928
963
  addActivity(session, event);
@@ -935,20 +970,22 @@ export function createPipeline({
935
970
  const named = Array.isArray(prepared.paths) ? canonicalNamedPaths(prepared.paths, raw) : [];
936
971
  const discover = event.kind === 'session.started' ||
937
972
  ['tool.succeeded', 'tool.failed'].includes(event.kind);
938
- const paths = [...new Set([...named, ...(discover ? await discoverPaths() : [])])];
973
+ const sessionPaths = event.kind === 'session.started'
974
+ ? [...knownArtifacts.values()].filter(item => item.status === 'present').slice(0, 64).map(item => item.path) : [];
975
+ const paths = [...new Set([...named, ...sessionPaths, ...(discover ? await discoverPaths() : [])])];
939
976
  // EvidenceStore bounds each capture to 32 paths. Process the bounded
940
977
  // discovery list in chunks rather than silently losing its tail.
941
978
  for (let index = 0; index < paths.length; index += 32) {
942
979
  artifacts.push(...await evidence.capture(paths.slice(index, index + 32)));
943
980
  }
944
- const changed = registerArtifacts(artifacts);
981
+ const changed = registerArtifacts(artifacts, metadataEvent(event));
945
982
  trace(event, 'capture', { status: 'observed', reason: 'artifacts_observed',
946
983
  artifacts: artifacts.map(artifact => ({ ...artifactMetadata(artifact),
947
984
  reason: changed.some(item => item.id === artifact.id) ? 'artifact_changed' : 'artifact_unchanged' })) });
948
985
  // A tool completion also reconciles prior support, including deletions
949
986
  // omitted from the tool's returned file list.
950
987
  if (event.kind.startsWith('tool.') && event.kind !== 'tool.requested') {
951
- registerArtifacts(await evidence.reconcile());
988
+ registerArtifacts(await evidence.reconcile({ limit: 32 }));
952
989
  }
953
990
  namedSet = new Set(named.map(file => path.resolve(root, file)));
954
991
  // Global observation history is not a session's classification
@@ -998,10 +1035,26 @@ export function createPipeline({
998
1035
  reconciliationTask = serialized(async () => {
999
1036
  if (closed) return;
1000
1037
  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; }
1038
+ const beforeModel = platform.model.stats();
1039
+ const paths = await discoverPaths();
1040
+ const artifacts = [];
1041
+ for (let index = 0; index < paths.length; index += 32) {
1042
+ artifacts.push(...await evidence.capture(paths.slice(index, index + 32)));
1043
+ }
1044
+ const known = await evidence.reconcile({ limit: 32 });
1045
+ const observed = [...artifacts, ...known];
1046
+ const changed = registerArtifacts(observed);
1047
+ // Parser revalidation may have already refreshed a file's generation.
1048
+ // Retain canceled classifier work until this dispatch path sees it.
1049
+ for (const artifact of observed) {
1050
+ if (!lineageWork.delete(artifact.id)) continue;
1051
+ if (!changed.some(item => item.id === artifact.id)) changed.push(artifact);
1052
+ }
1053
+ if (!changed.length) {
1054
+ const afterModel = platform.model.stats();
1055
+ if (expired || beforeModel.revision !== afterModel.revision || beforeModel.sequence !== afterModel.sequence) notify();
1056
+ return;
1057
+ }
1005
1058
  const session = selectedSession ? sessions.get(selectedSession) : null;
1006
1059
  if (session) {
1007
1060
  const event = observationEvent(session.id);
@@ -1037,6 +1090,23 @@ export function createPipeline({
1037
1090
  return getState();
1038
1091
  }
1039
1092
 
1093
+ function observeLineage(lineage) {
1094
+ return serialized(() => {
1095
+ if (closed || lineage.id === lineageId) return;
1096
+ platform.observeLineage(lineage);
1097
+ lineageId = lineage.id;
1098
+ completedClassifications.clear();
1099
+ for (const job of activeClassifications) job.controller.abort();
1100
+ for (const job of classificationQueue.splice(0)) {
1101
+ pending--;
1102
+ skipJob(job, 'source_changed_during_classification');
1103
+ }
1104
+ for (const artifactId of knownArtifacts.keys()) lineageWork.add(artifactId);
1105
+ registerArtifacts(evidence.setLineage(lineage.id));
1106
+ notify();
1107
+ });
1108
+ }
1109
+
1040
1110
  function selectSession(id) {
1041
1111
  if (!sessions.has(id)) return false;
1042
1112
  selectedSession = id;
@@ -1045,12 +1115,15 @@ export function createPipeline({
1045
1115
  }
1046
1116
 
1047
1117
  async function whenIdle() {
1118
+ await serial;
1119
+ await platform.whenIdle();
1048
1120
  await serial;
1049
1121
  while (tasks.size || classificationQueue.length) {
1050
1122
  pumpClassifications();
1051
1123
  await Promise.allSettled([...tasks]);
1052
1124
  await serial;
1053
1125
  }
1126
+ await platform.whenIdle();
1054
1127
  await serial;
1055
1128
  }
1056
1129
 
@@ -1064,8 +1137,14 @@ export function createPipeline({
1064
1137
  }
1065
1138
  for (const job of activeClassifications) job.controller.abort();
1066
1139
  decisionService?.close?.();
1140
+ await platform.close();
1067
1141
  await whenIdle();
1068
1142
  }
1069
1143
 
1070
- return { ingest, getState, reconcile, setPaused, selectSession, whenIdle, close };
1144
+ return {
1145
+ ingest, getState, reconcile, observeLineage, setPaused, selectSession, whenIdle, close,
1146
+ getModelState: options => platform.snapshot(options),
1147
+ createCheckpoint: options => platform.checkpoint(options),
1148
+ model: platform.model,
1149
+ };
1071
1150
  }