graphlin 0.1.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 (60) hide show
  1. package/.claude-plugin/plugin.json +12 -0
  2. package/.codex-plugin/plugin.json +29 -0
  3. package/.mcp.json +9 -0
  4. package/LICENSE +21 -0
  5. package/README.md +71 -0
  6. package/adapters/README.md +32 -0
  7. package/adapters/claude/hooks.json +10 -0
  8. package/adapters/claude/profile.json +18 -0
  9. package/adapters/codex/hooks.json +9 -0
  10. package/adapters/codex/profile.json +22 -0
  11. package/adapters/kiro/profile.json +8 -0
  12. package/mcp.json +11 -0
  13. package/package.json +114 -0
  14. package/plugin.json +20 -0
  15. package/runtime/collector/index.mjs +23 -0
  16. package/runtime/core/candidates.mjs +300 -0
  17. package/runtime/core/common.mjs +69 -0
  18. package/runtime/core/evidence.mjs +150 -0
  19. package/runtime/core/graph.mjs +398 -0
  20. package/runtime/core/index.mjs +4 -0
  21. package/runtime/core/lexical.mjs +255 -0
  22. package/runtime/core/privacy.mjs +206 -0
  23. package/runtime/core/tool-discovery.mjs +122 -0
  24. package/runtime/daemon/auth.mjs +50 -0
  25. package/runtime/daemon/connection-info.mjs +249 -0
  26. package/runtime/daemon/demo.mjs +195 -0
  27. package/runtime/daemon/diagnostics.mjs +404 -0
  28. package/runtime/daemon/export.mjs +7 -0
  29. package/runtime/daemon/ipc.mjs +28 -0
  30. package/runtime/daemon/lock.mjs +137 -0
  31. package/runtime/daemon/manager.mjs +320 -0
  32. package/runtime/daemon/paths.mjs +108 -0
  33. package/runtime/daemon/persistence.mjs +64 -0
  34. package/runtime/daemon/server.mjs +292 -0
  35. package/runtime/daemon/settings.mjs +103 -0
  36. package/runtime/jev/fixture.mjs +99 -0
  37. package/runtime/jev/index.mjs +784 -0
  38. package/runtime/jev/questions.mjs +268 -0
  39. package/runtime/jev/wire.mjs +152 -0
  40. package/runtime/pipeline.mjs +1071 -0
  41. package/runtime/web/app.js +2596 -0
  42. package/runtime/web/index.html +265 -0
  43. package/runtime/web/layout.js +336 -0
  44. package/runtime/web/sidebar.js +525 -0
  45. package/runtime/web/sketch.js +347 -0
  46. package/runtime/web/style.css +593 -0
  47. package/schemas/bundle.schema.json +243 -0
  48. package/schemas/event.schema.json +108 -0
  49. package/schemas/graph.schema.json +449 -0
  50. package/schemas/patch.schema.json +111 -0
  51. package/scripts/arguments.mjs +37 -0
  52. package/scripts/build-packages.mjs +160 -0
  53. package/scripts/collect.sh +23 -0
  54. package/scripts/collector.mjs +11 -0
  55. package/scripts/control.mjs +80 -0
  56. package/scripts/daemon.mjs +28 -0
  57. package/scripts/graphlin.mjs +112 -0
  58. package/scripts/onboarding.mjs +413 -0
  59. package/scripts/validate-packages.mjs +118 -0
  60. package/skills/graphlin/SKILL.md +103 -0
@@ -0,0 +1,300 @@
1
+ import {
2
+ GENERIC_LABELS, LIMITS, RELATIONS, clone, equal, exactKeys, freeze, hash, integer, isHash, isId, opaque, plain, probability,
3
+ } from './common.mjs';
4
+ import { createPolicy, excluded, safeLabel, safeText } from './privacy.mjs';
5
+ import { lexicalHints } from './lexical.mjs';
6
+
7
+ const FIELDS = ['id', 'artifactId', 'hash', 'generation', 'label', 'text', 'startLine', 'endLine',
8
+ 'sourceClass', 'complete', 'entityKey', 'labelOrigin', 'sourceRef', 'digest'];
9
+ const approvedBundles = new WeakSet();
10
+
11
+ export function validSourceRef(ref) {
12
+ if (ref?.type === 'artifact') return exactKeys(ref, ['type', 'artifactId', 'hash', 'generation']) &&
13
+ isId(ref.artifactId) && isHash(ref.hash) && integer(ref.generation, 1);
14
+ return ref?.type === 'message' && exactKeys(ref, ['type', 'messageId', 'hash', 'contentVersion']) &&
15
+ isId(ref.messageId) && isHash(ref.hash) && integer(ref.contentVersion, 1);
16
+ }
17
+
18
+ function digestFields(candidate) {
19
+ // Explicit field order makes digests independent of JSON object key order.
20
+ return {
21
+ artifactId: candidate.artifactId, hash: candidate.hash, generation: candidate.generation,
22
+ label: candidate.label, text: candidate.text, startLine: candidate.startLine, endLine: candidate.endLine,
23
+ sourceClass: candidate.sourceClass, complete: candidate.complete, entityKey: candidate.entityKey,
24
+ labelOrigin: candidate.labelOrigin, sourceRef: candidate.sourceRef,
25
+ };
26
+ }
27
+ const candidateDigest = (candidate, policy) => hash([policy.version, digestFields(candidate)]);
28
+
29
+ export function validCandidate(candidate, policy) {
30
+ if (!policy.transmitSource || !exactKeys(candidate, FIELDS) || !isId(candidate.id) ||
31
+ !isId(candidate.artifactId) || !isId(candidate.entityKey) || !isHash(candidate.hash) ||
32
+ !isHash(candidate.digest) || !integer(candidate.generation, 1) ||
33
+ !integer(candidate.startLine, 1, 10000000) || !integer(candidate.endLine, candidate.startLine, 10000000) ||
34
+ candidate.endLine - candidate.startLine >= LIMITS.snippetLines ||
35
+ !safeLabel(candidate.label) || !safeText(candidate.text) || typeof candidate.complete !== 'boolean' ||
36
+ !validSourceRef(candidate.sourceRef)) return false;
37
+ const ref = candidate.sourceRef;
38
+ if (candidate.hash !== ref.hash || candidate.generation !== (ref.generation ?? ref.contentVersion) ||
39
+ candidate.artifactId !== (ref.artifactId ?? ref.messageId) ||
40
+ candidate.sourceClass !== (ref.type === 'artifact' ? 'source' : 'public_intent')) return false;
41
+ const lines = candidate.text.split('\n');
42
+ if (lines.length !== candidate.endLine - candidate.startLine + 1) return false;
43
+ const origin = candidate.labelOrigin;
44
+ if (origin?.type === 'span') {
45
+ if (!exactKeys(origin, ['type', 'startLine', 'endLine', 'startColumn', 'endColumn']) ||
46
+ !integer(origin.startLine, candidate.startLine, candidate.endLine) || origin.endLine !== origin.startLine ||
47
+ !integer(origin.startColumn, 1, LIMITS.snippetChars) ||
48
+ !integer(origin.endColumn, origin.startColumn, LIMITS.snippetChars + 1) ||
49
+ lines[origin.startLine - candidate.startLine]?.slice(origin.startColumn - 1, origin.endColumn - 1) !== candidate.label) return false;
50
+ } else if (!exactKeys(origin, ['type', 'label']) || origin.type !== 'generic' ||
51
+ origin.label !== candidate.label || !GENERIC_LABELS.includes(candidate.label)) return false;
52
+ return candidate.digest === candidateDigest(candidate, policy) && candidate.id === opaque('candidate', candidate.digest);
53
+ }
54
+
55
+ function snippets(text, coverage) {
56
+ const lines = text.split('\n'), result = [];
57
+ let current = [], startLine = 1, chars = 0, offset = 0, startOffset = 0;
58
+ function emit() {
59
+ if (current.length && current.some(line => line.trim())) result.push({ text: current.join('\n'), startLine, endLine: startLine + current.length - 1, offset: startOffset });
60
+ current = []; chars = 0;
61
+ }
62
+ let i = 0;
63
+ for (; i < lines.length && result.length < LIMITS.candidates; i++) {
64
+ const line = lines[i];
65
+ if (line.length > LIMITS.snippetChars) {
66
+ coverage.truncated = true;
67
+ emit(); startLine = i + 2; offset += line.length + 1; continue;
68
+ }
69
+ if (current.length >= LIMITS.snippetLines || chars + line.length + (current.length ? 1 : 0) > LIMITS.snippetChars) {
70
+ emit(); startLine = i + 1;
71
+ }
72
+ if (!current.length) { startLine = i + 1; startOffset = offset; }
73
+ chars += line.length + (current.length ? 1 : 0);
74
+ current.push(line);
75
+ offset += line.length + 1;
76
+ }
77
+ if (result.length < LIMITS.candidates) emit();
78
+ else if (current.length || i < lines.length) coverage.truncated = true;
79
+ return result;
80
+ }
81
+
82
+ function entities(snippet, hints, available) {
83
+ const found = [];
84
+ for (const hint of hints.entities) {
85
+ const { label } = hint, offset = hint.start - snippet.offset;
86
+ if (offset < 0 || hint.end > snippet.offset + snippet.text.length || !safeLabel(label)) continue;
87
+ available.add(`span:${label}`);
88
+ if (found.length >= LIMITS.candidates) continue;
89
+ const before = snippet.text.slice(0, offset), lineOffset = before.split('\n').length - 1;
90
+ const column = offset - before.lastIndexOf('\n');
91
+ found.push({
92
+ label, labelOrigin: { type: 'span', startLine: snippet.startLine + lineOffset, endLine: snippet.startLine + lineOffset,
93
+ startColumn: column, endColumn: column + label.length },
94
+ });
95
+ }
96
+ if (!found.length) available.add('generic:Module');
97
+ return found.length ? found : [{ label: 'Module', labelOrigin: { type: 'generic', label: 'Module' } }];
98
+ }
99
+
100
+ function candidatesFor(source, policy, coverage = {}) {
101
+ const selections = [], seen = new Set();
102
+ const available = new Set();
103
+ const hints = lexicalHints(source.text);
104
+ const ranks = new Map(hints.entities.map(hint => [hint.label, hint.rank]));
105
+ for (const selection of snippets(source.text, coverage)) {
106
+ const { offset, ...snippet } = selection;
107
+ if (!safeText(snippet.text)) continue;
108
+ for (const entity of entities(selection, hints, available)) {
109
+ // Same exact identifier in a file is one lexical entity, not a claim
110
+ // about language-level scoping. Different files always have distinct IDs.
111
+ const entityKey = opaque('entity', source.id, entity.labelOrigin.type, entity.label);
112
+ if (seen.has(entityKey)) continue;
113
+ seen.add(entityKey);
114
+ selections.push({ entity, snippet, entityKey, rank: ranks.get(entity.label) ?? 5 });
115
+ }
116
+ }
117
+ coverage.available = available.size;
118
+ return selections.sort((a, b) => a.rank - b.rank || a.snippet.startLine - b.snippet.startLine)
119
+ .slice(0, LIMITS.candidates).map(({ entity, snippet, entityKey }) => {
120
+ const fields = {
121
+ artifactId: source.id, hash: source.hash, generation: source.generation,
122
+ ...entity, ...snippet, sourceClass: source.sourceClass, complete: source.complete,
123
+ entityKey, sourceRef: source.sourceRef,
124
+ };
125
+ const digest = candidateDigest(fields, policy);
126
+ return freeze({ id: opaque('candidate', digest), ...fields, digest });
127
+ });
128
+ }
129
+
130
+ export function buildCandidates({ event, artifacts = [], publicText = null, policy, onDiagnostic } = {}) {
131
+ policy = createPolicy(policy);
132
+ const report = value => {
133
+ try {
134
+ const pending = onDiagnostic?.(freeze(value));
135
+ if (pending && typeof pending.then === 'function') Promise.resolve(pending).catch(() => {});
136
+ } catch { /* Diagnostics never affect extraction. */ }
137
+ };
138
+ if (!policy.transmitSource || !plain(event) || !isId(event.id) ||
139
+ ['tool.requested', 'capture.gap'].includes(event.kind)) {
140
+ report({ reason: !policy.transmitSource ? 'metadata_only' : 'event_not_classifiable', selected: 0 });
141
+ return [];
142
+ }
143
+ const groups = [];
144
+ const observations = [];
145
+ if (Array.isArray(artifacts)) for (const artifact of artifacts.slice(0, LIMITS.paths)) {
146
+ const observation = { artifactId: artifact?.id, available: 0, selected: 0, reason: 'no_candidates' };
147
+ observations.push(observation);
148
+ if (artifact?.status !== 'present' || artifact.exists !== true || artifact.complete !== true ||
149
+ !isId(artifact.id) || !isHash(artifact.hash) || !integer(artifact.generation, 1) ||
150
+ excluded(artifact.relativePath, policy) || !safeText(artifact.text, LIMITS.fileBytes)) {
151
+ observation.reason = excluded(artifact?.relativePath, policy) ? 'excluded_path'
152
+ : artifact?.status === 'missing' ? 'file_missing'
153
+ : artifact?.status === 'partial' || artifact?.complete === false ? 'incomplete_artifact'
154
+ : artifact?.status !== 'present' ? 'artifact_unavailable'
155
+ : typeof artifact.text !== 'string' ? 'source_withheld'
156
+ : !artifact.text.trim() ? 'empty_source' : 'source_not_safe';
157
+ continue;
158
+ }
159
+ const group = candidatesFor({
160
+ ...artifact, sourceClass: 'source', sourceRef: {
161
+ type: 'artifact', artifactId: artifact.id, hash: artifact.hash, generation: artifact.generation,
162
+ },
163
+ }, policy, observation);
164
+ groups.push(group);
165
+ }
166
+ if (['turn.prompted', 'intent.observed'].includes(event.kind) && safeText(publicText, LIMITS.snippetChars * 4)) {
167
+ const contentVersion = integer(event.sequence, 1) ? event.sequence : 1;
168
+ const digest = hash(publicText);
169
+ groups.push(candidatesFor({
170
+ id: event.id, text: publicText, hash: digest, generation: contentVersion,
171
+ complete: event.incomplete === false, sourceClass: 'public_intent',
172
+ sourceRef: { type: 'message', messageId: event.id, hash: digest, contentVersion },
173
+ }, policy));
174
+ }
175
+ const result = [];
176
+ for (let offset = 0; offset < LIMITS.candidates && result.length < LIMITS.candidates; offset++) {
177
+ for (const group of groups) if (group[offset] && result.length < LIMITS.candidates) result.push(group[offset]);
178
+ }
179
+ for (const observation of observations) {
180
+ observation.selected = result.filter(candidate => candidate.artifactId === observation.artifactId).length;
181
+ if (observation.truncated) observation.reason = 'snippet_limit';
182
+ else if (observation.available) observation.reason = observation.selected < observation.available ? 'candidate_limit' : 'candidates_ready';
183
+ report(observation);
184
+ }
185
+ if (Array.isArray(artifacts) && artifacts.length > LIMITS.paths) report({
186
+ reason: 'artifact_limit', available: artifacts.length, selected: LIMITS.paths,
187
+ });
188
+ return freeze(result);
189
+ }
190
+
191
+ function readSet(candidates) {
192
+ const refs = new Map();
193
+ for (const candidate of candidates) {
194
+ const ref = candidate.sourceRef.type === 'message' ? candidate.sourceRef :
195
+ { artifactId: candidate.artifactId, hash: candidate.hash, generation: candidate.generation };
196
+ refs.set(JSON.stringify(ref), clone(ref));
197
+ }
198
+ return [...refs.values()];
199
+ }
200
+ const bundleId = (policyVersion, candidates, refs) => opaque('bundle', policyVersion, candidates.map(c => c.digest), refs);
201
+
202
+ export function materializeBundle({ candidates = [], verdicts = [], policy, intakePolicy = {} } = {}) {
203
+ policy = createPolicy(policy);
204
+ const sensitiveMax = intakePolicy?.sensitiveMax ?? 0.1, relevantMin = intakePolicy?.relevantMin ?? 0.5;
205
+ let approved = [];
206
+ const counts = new Map(), judgments = new Map(), versions = new Map(), quarantined = [];
207
+ // Overflow is rejected wholesale; trimming before duplicate detection would
208
+ // let a conflicting verdict placed beyond the limit approve content.
209
+ if (Array.isArray(candidates) && candidates.length <= LIMITS.candidates && Array.isArray(verdicts) &&
210
+ verdicts.length <= LIMITS.candidates && probability(sensitiveMax) && probability(relevantMin)) {
211
+ for (const c of candidates) {
212
+ counts.set(c?.id, (counts.get(c?.id) ?? 0) + 1);
213
+ if (c?.artifactId) {
214
+ const key = `${c.hash}:${c.generation}`;
215
+ const previous = versions.get(c.artifactId);
216
+ versions.set(c.artifactId, previous === undefined || previous === key ? key : null);
217
+ }
218
+ }
219
+ for (const v of verdicts) {
220
+ if (judgments.has(v?.candidateId)) judgments.set(v?.candidateId, null);
221
+ else judgments.set(v?.candidateId, v);
222
+ }
223
+ for (const c of candidates) {
224
+ const v = judgments.get(c?.id);
225
+ const sensitivityApproved = counts.get(c?.id) === 1 && versions.get(c?.artifactId) !== null && validCandidate(c, policy) &&
226
+ exactKeys(v, ['candidateId', 'digest', 'relevant', 'sensitive']) && v.digest === c.digest &&
227
+ probability(v.sensitive) && probability(v.relevant) && v.sensitive <= sensitiveMax;
228
+ if (!sensitivityApproved) { if (plain(c)) quarantined.push(c); continue; }
229
+ if (v.relevant < relevantMin) continue;
230
+ approved.push(clone(c));
231
+ }
232
+ // One snippet may describe several entities. A conflicting/missing safety
233
+ // answer quarantines its copied context, not just one candidate's title.
234
+ approved = approved.filter(c => !quarantined.some(blocked =>
235
+ c.text === blocked.text || c.artifactId === blocked.artifactId &&
236
+ c.startLine <= blocked.endLine && blocked.startLine <= c.endLine));
237
+ }
238
+ const refs = readSet(approved);
239
+ const bundle = freeze({ id: bundleId(policy.version, approved, refs), policyVersion: policy.version, candidates: approved, readSet: refs });
240
+ approvedBundles.add(bundle);
241
+ return bundle;
242
+ }
243
+
244
+ export function validBundle(bundle, policy) {
245
+ if (!approvedBundles.has(bundle) || !exactKeys(bundle, ['id', 'policyVersion', 'candidates', 'readSet']) || bundle.policyVersion !== policy.version ||
246
+ !Array.isArray(bundle.candidates) || bundle.candidates.length > LIMITS.candidates ||
247
+ !Array.isArray(bundle.readSet) || bundle.readSet.length > LIMITS.candidates ||
248
+ bundle.candidates.some(c => !validCandidate(c, policy))) return false;
249
+ if (new Set(bundle.candidates.map(c => c.id)).size !== bundle.candidates.length) return false;
250
+ const refs = readSet(bundle.candidates);
251
+ return equal(refs, bundle.readSet) && bundle.id === bundleId(policy.version, bundle.candidates, refs);
252
+ }
253
+
254
+ export function proposalId(bundle, source, target, relation, evidence) {
255
+ return opaque('proposal', bundle.id, source, target, relation, evidence);
256
+ }
257
+ export function buildRelationProposals(bundle, limits = {}) {
258
+ const candidates = Array.isArray(bundle?.candidates) ? bundle.candidates : [];
259
+ if (!approvedBundles.has(bundle) || candidates.length > LIMITS.candidates || !isId(bundle?.id) ||
260
+ new Set(candidates.map(c => c.id)).size !== candidates.length || candidates.some(c => !isId(c.id))) return { proposals: [], omitted: 0 };
261
+ const requested = [limits.maxProposals, limits.maxRelationProposals].filter(n => integer(n, 0));
262
+ const questionLimit = integer(limits.maxQuestionsPerStage, 0, 1000) ? limits.maxQuestionsPerStage : 40;
263
+ const maximum = Math.min(LIMITS.proposals, ...requested, Math.max(0, Math.floor((questionLimit - 1 - 2 * candidates.length) / 2)));
264
+ const pairs = [], hints = new Map(), analyses = new Map();
265
+ for (const candidate of candidates) {
266
+ const key = JSON.stringify([candidate.artifactId, candidate.hash, candidate.generation, candidate.startLine, candidate.text]);
267
+ if (analyses.has(key)) continue;
268
+ const analysis = lexicalHints(candidate.text);
269
+ analyses.set(key, analysis);
270
+ for (const hint of analysis.pairs) {
271
+ const pairKey = JSON.stringify([candidate.artifactId, candidate.hash, candidate.generation, hint.source, hint.target]);
272
+ const prior = hints.get(pairKey);
273
+ if (!prior || prior.rank > hint.rank) hints.set(pairKey, hint);
274
+ }
275
+ }
276
+ for (const source of candidates) for (const target of candidates) {
277
+ if (source.id === target.id || source.entityKey === target.entityKey) continue;
278
+ const colocated = source.artifactId === target.artifactId && source.hash === target.hash && source.generation === target.generation;
279
+ const hint = colocated ? hints.get(JSON.stringify([source.artifactId, source.hash, source.generation, source.label, target.label])) : null;
280
+ pairs.push({ source, target, rank: hint?.rank ?? (colocated ? 10 : 20), offset: hint?.offset ?? 0, kind: hint?.kind });
281
+ }
282
+ pairs.sort((a, b) => a.rank - b.rank || a.offset - b.offset);
283
+ const proposals = [];
284
+ // Exhaust the six independent relation questions for the best lexical pair
285
+ // before fanout. A binding/constructor pair prioritizes the dependency
286
+ // question in a remaining slot; this is selection, never semantic evidence.
287
+ for (const { source, target, kind } of pairs) {
288
+ const relations = kind === 'binding' ? ['depends_on', ...RELATIONS.filter(r => r !== 'depends_on')] : RELATIONS;
289
+ for (const relation of relations) {
290
+ if (proposals.length >= maximum) break;
291
+ const evidenceCandidateIds = [source.id, target.id];
292
+ proposals.push({
293
+ id: proposalId(bundle, source.id, target.id, relation, evidenceCandidateIds),
294
+ sourceCandidateId: source.id, targetCandidateId: target.id, relation, evidenceCandidateIds,
295
+ });
296
+ }
297
+ if (proposals.length >= maximum) break;
298
+ }
299
+ return freeze({ proposals, omitted: pairs.length * RELATIONS.length - proposals.length });
300
+ }
@@ -0,0 +1,69 @@
1
+ import { createHash } from 'node:crypto';
2
+
3
+ export const LIMITS = Object.freeze({
4
+ paths: 32, trackedPaths: 1024, fileBytes: 256 * 1024,
5
+ candidates: 12, snippetChars: 1800, snippetLines: 24,
6
+ labelChars: 80, refs: 8, excerptChars: 256, graphBytes: 1024 * 1024,
7
+ admissionBytes: 896 * 1024, nodes: 256, edges: 768, operations: 1024,
8
+ proposals: 7, coordinate: 100000, rawChars: 256 * 1024,
9
+ });
10
+ export const ROLES = Object.freeze([
11
+ 'client', 'service', 'datastore', 'queue', 'external', 'module',
12
+ 'function', 'class', 'interface', 'event', 'configuration', 'package',
13
+ ]);
14
+ export const ROLE_SHAPES = Object.freeze({
15
+ client: 'browser', service: 'component', datastore: 'cylinder', queue: 'queue',
16
+ external: 'cloud', module: 'rect', function: 'hexagon', class: 'class_box',
17
+ interface: 'interface_box', event: 'document', configuration: 'parallelogram', package: 'folder',
18
+ });
19
+ export const ROLE_LABELS = Object.freeze({
20
+ client: 'Client', service: 'Service', datastore: 'Datastore', queue: 'Queue',
21
+ external: 'External', module: 'Module', function: 'Function', class: 'Class',
22
+ interface: 'Interface', event: 'Event', configuration: 'Configuration', package: 'Package',
23
+ });
24
+ export const GENERIC_LABELS = Object.freeze([
25
+ 'Component', 'Module', 'Client', 'Service', 'Datastore', 'Queue', 'External',
26
+ 'Function', 'Class', 'Interface', 'Event', 'Configuration', 'Package',
27
+ ]);
28
+ export const RELATIONS = Object.freeze(['calls', 'reads', 'writes', 'publishes', 'consumes', 'depends_on']);
29
+ export const KINDS = Object.freeze([
30
+ 'session.started', 'turn.prompted', 'intent.observed', 'tool.requested',
31
+ 'tool.succeeded', 'tool.failed', 'tool.interrupted', 'tool.denied', 'tool.unresolved',
32
+ 'batch.completed', 'artifact.changed', 'verification.observed', 'agent.started',
33
+ 'agent.stopped', 'turn.stopped', 'session.ended', 'capture.gap',
34
+ ]);
35
+ export const CATEGORIES = Object.freeze(['read', 'write', 'edit', 'shell', 'search', 'test', 'other']);
36
+ export const OUTCOMES = Object.freeze(['pending', 'succeeded', 'failed', 'interrupted', 'denied', 'unresolved', 'observed']);
37
+ export const SHAPES = Object.freeze([
38
+ 'rounded_rect', 'rect', 'cylinder', 'cloud', 'diamond', 'group',
39
+ 'hexagon', 'class_box', 'interface_box', 'document', 'parallelogram', 'folder',
40
+ 'browser', 'component', 'queue',
41
+ ]);
42
+ export const EVIDENCE_STATES = Object.freeze(['proposed', 'observed', 'verified', 'removed']);
43
+ export const ACTIVITY_STATES = Object.freeze(['idle', 'pending', 'running', 'failed', 'interrupted', 'unknown']);
44
+ export const CLASSIFICATIONS = Object.freeze(['pending', 'accepted', 'tentative', 'abstained', 'stale']);
45
+ export const VALIDITIES = Object.freeze(['current', 'stale', 'retracted']);
46
+
47
+ export const hash = value => createHash('sha256').update(typeof value === 'string' || value instanceof Uint8Array ? value : JSON.stringify(value)).digest('hex');
48
+ export const opaque = (prefix, ...values) => `${prefix}-${hash(values).slice(0, 32)}`;
49
+ export const isId = value => typeof value === 'string' && /^(?:[a-z][a-z0-9_]{0,23}-)?[a-f0-9]{24,64}$/.test(value);
50
+ export const isHash = value => typeof value === 'string' && /^[a-f0-9]{64}$/.test(value);
51
+ export const probability = value => typeof value === 'number' && Number.isFinite(value) && value >= 0 && value <= 1;
52
+ export const integer = (value, min = 0, max = Number.MAX_SAFE_INTEGER) => Number.isSafeInteger(value) && value >= min && value <= max;
53
+ export const plain = value => value !== null && typeof value === 'object' && !Array.isArray(value) && [Object.prototype, null].includes(Object.getPrototypeOf(value));
54
+ export const exactKeys = (value, required, optional = []) => plain(value) &&
55
+ required.every(key => Object.hasOwn(value, key)) &&
56
+ Object.keys(value).every(key => required.includes(key) || optional.includes(key));
57
+ export const equal = (a, b) => JSON.stringify(a) === JSON.stringify(b);
58
+ export const clone = value => structuredClone(value);
59
+ export function freeze(value) {
60
+ if (value && typeof value === 'object' && !Object.isFrozen(value)) {
61
+ Object.values(value).forEach(freeze);
62
+ Object.freeze(value);
63
+ }
64
+ return value;
65
+ }
66
+ export function fail(code = 'INVALID_CORE_INPUT') {
67
+ // Fixed errors only: no paths, source, transport messages or raw caller fields.
68
+ throw new TypeError(code);
69
+ }
@@ -0,0 +1,150 @@
1
+ import { constants, realpathSync, statSync } from 'node:fs';
2
+ import { lstat, open, realpath } from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ import { LIMITS, fail, freeze, hash, integer, isHash, opaque, plain } from './common.mjs';
5
+ import { createPolicy, excluded, privateText } from './privacy.mjs';
6
+
7
+ const absent = error => error?.code === 'ENOENT' || error?.code === 'ENOTDIR';
8
+ const fingerprint = stat => [stat.dev, stat.ino, stat.size, stat.mtimeNs, stat.ctimeNs, stat.mode].map(String).join(':');
9
+
10
+ export class EvidenceStore {
11
+ #root; #inputRoot; #policy; #records = new Map(); #byId = new Map();
12
+ constructor({ projectRoot, policy } = {}) {
13
+ try {
14
+ this.#inputRoot = path.resolve(projectRoot);
15
+ this.#root = realpathSync(projectRoot);
16
+ if (!statSync(this.#root).isDirectory()) fail();
17
+ } catch { fail('INVALID_PROJECT_ROOT'); }
18
+ this.#policy = createPolicy(policy);
19
+ }
20
+
21
+ #locator(input) {
22
+ if (typeof input !== 'string' || !input || input.length > 4096 || /[\0\r\n\\]/.test(input)) return null;
23
+ let absolute = path.resolve(this.#root, input);
24
+ // Accept the root spelling supplied by the caller (e.g. macOS /var ->
25
+ // /private/var), while keeping the authority and stored identity canonical.
26
+ const inputRelative = path.relative(this.#inputRoot, absolute);
27
+ if (this.#inputRoot !== this.#root && inputRelative && inputRelative !== '..' &&
28
+ !inputRelative.startsWith(`..${path.sep}`) && !path.isAbsolute(inputRelative)) {
29
+ absolute = path.resolve(this.#root, inputRelative);
30
+ }
31
+ const relative = path.relative(this.#root, absolute);
32
+ if (!relative || relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) return null;
33
+ return { absolute, relative: relative.split(path.sep).join('/') };
34
+ }
35
+
36
+ async #inspect(locator) {
37
+ // Fail closed on symlinks, including inside-root aliases. Never follow an
38
+ // excluded path through a differently named alias.
39
+ if (excluded(locator.relative, this.#policy)) {
40
+ let stamp = 'excluded';
41
+ try { stamp += `:${fingerprint(await lstat(locator.absolute, { bigint: true }))}`; } catch { /* No absence assertion through a policy exclusion. */ }
42
+ return { status: 'unavailable', exists: null, complete: false, hash: null, text: null, stamp };
43
+ }
44
+ const parts = locator.relative.split('/');
45
+ let current = this.#root;
46
+ try {
47
+ if (await realpath(this.#root) !== this.#root) return this.#unavailable('root_changed');
48
+ for (const part of parts) {
49
+ current = path.join(current, part);
50
+ const stat = await lstat(current, { bigint: true });
51
+ if (stat.isSymbolicLink()) return this.#unavailable(`symlink:${fingerprint(stat)}`);
52
+ }
53
+ } catch (error) {
54
+ return absent(error) ? { status: 'missing', exists: false, complete: true, hash: null, text: null, stamp: 'missing' }
55
+ : this.#unavailable('unreadable');
56
+ }
57
+ let handle;
58
+ try {
59
+ // NOFOLLOW closes the final-component race. Revalidate the complete path
60
+ // and inode before and after reading to reject ancestor replacement races.
61
+ handle = await open(locator.absolute, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0) | (constants.O_NONBLOCK ?? 0));
62
+ const before = await handle.stat({ bigint: true });
63
+ const stamp = fingerprint(before);
64
+ if (!before.isFile() || (before.mode & 0o444n) === 0n) return this.#unavailable(stamp);
65
+ if (before.size > BigInt(LIMITS.fileBytes)) return { status: 'partial', exists: true, complete: false, hash: null, text: null, stamp };
66
+ if (!(await this.#sameFile(locator, before))) return this.#unavailable(stamp);
67
+ const buffer = Buffer.alloc(Number(before.size) + 1);
68
+ let offset = 0;
69
+ while (offset < buffer.length) {
70
+ const { bytesRead } = await handle.read(buffer, offset, buffer.length - offset, offset);
71
+ if (!bytesRead) break;
72
+ offset += bytesRead;
73
+ }
74
+ const after = await handle.stat({ bigint: true });
75
+ if (fingerprint(after) !== stamp || offset !== Number(before.size) || !(await this.#sameFile(locator, after))) {
76
+ return { status: 'partial', exists: true, complete: false, hash: null, text: null, stamp: `unstable:${fingerprint(after)}` };
77
+ }
78
+ const bytes = buffer.subarray(0, offset);
79
+ let text;
80
+ try { text = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(bytes); } catch {
81
+ return { status: 'partial', exists: true, complete: false, hash: hash(bytes), text: null, stamp };
82
+ }
83
+ if (text.includes('\0')) return { status: 'partial', exists: true, complete: false, hash: hash(bytes), text: null, stamp };
84
+ return {
85
+ status: 'present', exists: true, complete: true, hash: hash(bytes),
86
+ text: this.#policy.transmitSource && !privateText(text) ? text : null, stamp,
87
+ };
88
+ } catch {
89
+ // A disappearing/racing file during open/read is uncertainty. A subsequent
90
+ // reconcile can confirm absence with a fresh path walk.
91
+ return this.#unavailable('unreadable');
92
+ } finally { if (handle) await handle.close().catch(() => {}); }
93
+ }
94
+
95
+ #unavailable(stamp) {
96
+ return { status: 'unavailable', exists: null, complete: false, hash: null, text: null, stamp };
97
+ }
98
+ async #sameFile(locator, stat) {
99
+ try {
100
+ if (await realpath(locator.absolute) !== locator.absolute) return false;
101
+ const current = await lstat(locator.absolute, { bigint: true });
102
+ return current.isFile() && !current.isSymbolicLink() && fingerprint(current) === fingerprint(stat);
103
+ } catch { return false; }
104
+ }
105
+
106
+ async #captureOne(locator) {
107
+ const observed = await this.#inspect(locator);
108
+ const previous = this.#records.get(locator.relative);
109
+ const version = hash([observed.status, observed.hash, observed.stamp]);
110
+ const id = opaque('artifact', this.#root, locator.relative);
111
+ const generation = previous ? previous.generation + (previous.version !== version ? 1 : 0) : 1;
112
+ const artifact = {
113
+ id, path: locator.absolute, relativePath: locator.relative,
114
+ hash: observed.hash, generation, exists: observed.exists, status: observed.status,
115
+ text: observed.text, complete: observed.complete,
116
+ };
117
+ // Registry retains no source bytes; returned captures are immutable private
118
+ // snapshots. All path/cache cardinalities are bounded.
119
+ const record = { ...locator, id, generation, hash: observed.hash, status: observed.status, version };
120
+ this.#records.set(locator.relative, record);
121
+ this.#byId.set(id, record);
122
+ return freeze(artifact);
123
+ }
124
+
125
+ async capture(paths = []) {
126
+ if (!Array.isArray(paths)) return [];
127
+ const results = [], seen = new Set();
128
+ for (const input of paths.slice(0, LIMITS.paths)) {
129
+ const locator = this.#locator(input);
130
+ if (!locator || seen.has(locator.relative)) continue;
131
+ seen.add(locator.relative);
132
+ if (!this.#records.has(locator.relative) && this.#records.size >= LIMITS.trackedPaths) continue;
133
+ results.push(await this.#captureOne(locator));
134
+ }
135
+ return results;
136
+ }
137
+ async reconcile() {
138
+ const results = [];
139
+ for (const record of this.#records.values()) results.push(await this.#captureOne(record));
140
+ return results;
141
+ }
142
+ isCurrent(refs) {
143
+ if (!Array.isArray(refs) || refs.length > LIMITS.trackedPaths) return false;
144
+ return refs.every(ref => {
145
+ if (!plain(ref) || !isHash(ref.hash) || !integer(ref.generation, 1)) return false;
146
+ const current = this.#byId.get(ref.artifactId);
147
+ return current?.status === 'present' && current.hash === ref.hash && current.generation === ref.generation;
148
+ });
149
+ }
150
+ }