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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "graphlin",
3
- "version": "0.1.2",
3
+ "version": "0.2.0",
4
4
  "description": "Live architecture and activity diagrams from observable coding-agent work.",
5
5
  "private": false,
6
6
  "type": "module",
@@ -14,6 +14,7 @@
14
14
  "start": "node scripts/graphlin.mjs start",
15
15
  "demo": "node scripts/graphlin.mjs demo",
16
16
  "test": "node --test --test-timeout=30000",
17
+ "test:stress": "node --test --test-timeout=90000 tests/stress/*.mjs",
17
18
  "build": "node scripts/build-packages.mjs",
18
19
  "doctor": "node scripts/graphlin.mjs doctor",
19
20
  "eval:jev": "node scripts/evaluate-jev.mjs",
@@ -51,20 +52,24 @@
51
52
  "registry": "https://registry.npmjs.org/"
52
53
  },
53
54
  "files": [
54
- "./LICENSE",
55
- "./README.md",
56
- "./plugin.json",
57
- "./mcp.json",
58
- "./.mcp.json",
59
55
  "./.claude-plugin/plugin.json",
60
56
  "./.codex-plugin/plugin.json",
57
+ "./.mcp.json",
58
+ "./LICENSE",
59
+ "./README.md",
61
60
  "./adapters/README.md",
62
61
  "./adapters/claude/hooks.json",
63
62
  "./adapters/claude/profile.json",
64
63
  "./adapters/codex/hooks.json",
65
64
  "./adapters/codex/profile.json",
66
65
  "./adapters/kiro/profile.json",
67
- "./skills/graphlin/SKILL.md",
66
+ "./docs/decision-service.md",
67
+ "./docs/extension-authoring.md",
68
+ "./docs/model-api.md",
69
+ "./docs/usage.md",
70
+ "./docs/visualizer-views.md",
71
+ "./mcp.json",
72
+ "./plugin.json",
68
73
  "./runtime/collector/index.mjs",
69
74
  "./runtime/core/candidates.mjs",
70
75
  "./runtime/core/common.mjs",
@@ -80,21 +85,65 @@
80
85
  "./runtime/daemon/demo.mjs",
81
86
  "./runtime/daemon/diagnostics.mjs",
82
87
  "./runtime/daemon/export.mjs",
88
+ "./runtime/daemon/extension-api.mjs",
83
89
  "./runtime/daemon/ipc.mjs",
90
+ "./runtime/daemon/lineage.mjs",
84
91
  "./runtime/daemon/lock.mjs",
85
92
  "./runtime/daemon/manager.mjs",
93
+ "./runtime/daemon/model-api.mjs",
94
+ "./runtime/daemon/model-persistence.mjs",
86
95
  "./runtime/daemon/paths.mjs",
87
96
  "./runtime/daemon/persistence.mjs",
88
97
  "./runtime/daemon/server.mjs",
89
98
  "./runtime/daemon/settings.mjs",
99
+ "./runtime/decisions/broker.mjs",
100
+ "./runtime/decisions/contracts.mjs",
101
+ "./runtime/decisions/evaluation.mjs",
102
+ "./runtime/decisions/faults.mjs",
103
+ "./runtime/decisions/index.mjs",
104
+ "./runtime/decisions/profiles.mjs",
105
+ "./runtime/decisions/questions.mjs",
106
+ "./runtime/discovery/index.mjs",
107
+ "./runtime/discovery/inventory.mjs",
108
+ "./runtime/discovery/parser.mjs",
109
+ "./runtime/discovery/structure.mjs",
110
+ "./runtime/extensions/contracts.mjs",
111
+ "./runtime/extensions/frame.mjs",
112
+ "./runtime/extensions/index.mjs",
113
+ "./runtime/extensions/manifest.mjs",
114
+ "./runtime/extensions/packages.mjs",
115
+ "./runtime/extensions/profiles.mjs",
116
+ "./runtime/extensions/projection.mjs",
117
+ "./runtime/extensions/registry.mjs",
118
+ "./runtime/extensions/scene.mjs",
119
+ "./runtime/extensions/sdk.d.ts",
120
+ "./runtime/extensions/sdk.mjs",
90
121
  "./runtime/jev/fixture.mjs",
91
122
  "./runtime/jev/index.mjs",
123
+ "./runtime/jev/provider.mjs",
92
124
  "./runtime/jev/questions.mjs",
93
125
  "./runtime/jev/wire.mjs",
126
+ "./runtime/model/changes.mjs",
127
+ "./runtime/model/history.mjs",
128
+ "./runtime/model/index.mjs",
129
+ "./runtime/model/project-model.mjs",
130
+ "./runtime/model/records.mjs",
94
131
  "./runtime/pipeline.mjs",
132
+ "./runtime/platform.mjs",
133
+ "./runtime/visualizers/blocks.mjs",
134
+ "./runtime/visualizers/c4.mjs",
135
+ "./runtime/visualizers/changes.mjs",
136
+ "./runtime/visualizers/code.mjs",
137
+ "./runtime/visualizers/index.mjs",
138
+ "./runtime/visualizers/structure.mjs",
139
+ "./runtime/visualizers/timeline.mjs",
95
140
  "./runtime/web/app.js",
141
+ "./runtime/web/extension-frame.js",
96
142
  "./runtime/web/index.html",
97
143
  "./runtime/web/layout.js",
144
+ "./runtime/web/model-client.js",
145
+ "./runtime/web/platform.js",
146
+ "./runtime/web/scene.js",
98
147
  "./runtime/web/sidebar.js",
99
148
  "./runtime/web/sketch.js",
100
149
  "./runtime/web/style.css",
@@ -108,8 +157,24 @@
108
157
  "./scripts/collector.mjs",
109
158
  "./scripts/control.mjs",
110
159
  "./scripts/daemon.mjs",
160
+ "./scripts/extensions.mjs",
111
161
  "./scripts/graphlin.mjs",
112
162
  "./scripts/onboarding.mjs",
113
- "./scripts/validate-packages.mjs"
114
- ]
163
+ "./scripts/validate-packages.mjs",
164
+ "./skills/graphlin/SKILL.md"
165
+ ],
166
+ "dependencies": {
167
+ "@vscode/tree-sitter-wasm": "0.3.1"
168
+ },
169
+ "bundledDependencies": [
170
+ "@vscode/tree-sitter-wasm"
171
+ ],
172
+ "exports": {
173
+ "./extensions/sdk": {
174
+ "types": "./runtime/extensions/sdk.d.ts",
175
+ "import": "./runtime/extensions/sdk.mjs",
176
+ "default": "./runtime/extensions/sdk.mjs"
177
+ },
178
+ "./extensions/scene": "./runtime/extensions/scene.mjs"
179
+ }
115
180
  }
package/plugin.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
3
3
  "name": "graphlin",
4
- "version": "0.1.2",
4
+ "version": "0.2.0",
5
5
  "description": "Local architecture and activity diagrams from observable coding-agent work.",
6
6
  "extensions": {
7
7
  "com.openai": {
@@ -11,7 +11,9 @@
11
11
  "longDescription": "A local viewer with passive event collection, explicit source-sharing controls, and an offline fixture demo.",
12
12
  "developerName": "Graphlin contributors",
13
13
  "category": "Productivity",
14
- "defaultPrompt": ["Start Graphlin for this project in metadata-only mode."],
14
+ "defaultPrompt": [
15
+ "Start Graphlin for this project in metadata-only mode."
16
+ ],
15
17
  "brandColor": "#20BFA9"
16
18
  },
17
19
  "hooks": "./adapters/codex/hooks.json"
@@ -9,13 +9,16 @@ const fingerprint = stat => [stat.dev, stat.ino, stat.size, stat.mtimeNs, stat.c
9
9
 
10
10
  export class EvidenceStore {
11
11
  #root; #inputRoot; #policy; #records = new Map(); #byId = new Map();
12
- constructor({ projectRoot, policy } = {}) {
12
+ #maxTrackedPaths; #reconcileCursor = 0; #lineage = null; #generationFloor;
13
+ constructor({ projectRoot, policy, maxTrackedPaths = LIMITS.trackedPaths, generationFloor = 0 } = {}) {
13
14
  try {
14
15
  this.#inputRoot = path.resolve(projectRoot);
15
16
  this.#root = realpathSync(projectRoot);
16
17
  if (!statSync(this.#root).isDirectory()) fail();
17
18
  } catch { fail('INVALID_PROJECT_ROOT'); }
18
19
  this.#policy = createPolicy(policy);
20
+ this.#maxTrackedPaths = integer(maxTrackedPaths, 1, 20000) ? maxTrackedPaths : LIMITS.trackedPaths;
21
+ this.#generationFloor = integer(generationFloor, 0, Number.MAX_SAFE_INTEGER - 1) ? generationFloor : 0;
19
22
  }
20
23
 
21
24
  #locator(input) {
@@ -43,17 +46,26 @@ export class EvidenceStore {
43
46
  }
44
47
  const parts = locator.relative.split('/');
45
48
  let current = this.#root;
49
+ let finalStat;
46
50
  try {
47
51
  if (await realpath(this.#root) !== this.#root) return this.#unavailable('root_changed');
48
52
  for (const part of parts) {
49
53
  current = path.join(current, part);
50
54
  const stat = await lstat(current, { bigint: true });
51
55
  if (stat.isSymbolicLink()) return this.#unavailable(`symlink:${fingerprint(stat)}`);
56
+ finalStat = stat;
52
57
  }
53
58
  } catch (error) {
54
59
  return absent(error) ? { status: 'missing', exists: false, complete: true, hash: null, text: null, stamp: 'missing' }
55
60
  : this.#unavailable('unreadable');
56
61
  }
62
+ // Metadata consent permits names/stat observations, never content reads or
63
+ // hashes. A local-source grant is distinct from permission to transmit it.
64
+ if (!this.#policy.readSource) {
65
+ if (!finalStat?.isFile()) return this.#unavailable('not_file');
66
+ return { status: 'present', exists: true, complete: true,
67
+ hash: null, text: null, stamp: fingerprint(finalStat) };
68
+ }
57
69
  let handle;
58
70
  try {
59
71
  // NOFOLLOW closes the final-component race. Revalidate the complete path
@@ -83,7 +95,7 @@ export class EvidenceStore {
83
95
  if (text.includes('\0')) return { status: 'partial', exists: true, complete: false, hash: hash(bytes), text: null, stamp };
84
96
  return {
85
97
  status: 'present', exists: true, complete: true, hash: hash(bytes),
86
- text: this.#policy.transmitSource && !privateText(text) ? text : null, stamp,
98
+ text: !privateText(text) ? text : null, stamp,
87
99
  };
88
100
  } catch {
89
101
  // A disappearing/racing file during open/read is uncertainty. A subsequent
@@ -106,9 +118,9 @@ export class EvidenceStore {
106
118
  async #captureOne(locator) {
107
119
  const observed = await this.#inspect(locator);
108
120
  const previous = this.#records.get(locator.relative);
109
- const version = hash([observed.status, observed.hash, observed.stamp]);
121
+ const version = hash([this.#lineage, observed.status, observed.hash, observed.stamp]);
110
122
  const id = opaque('artifact', this.#root, locator.relative);
111
- const generation = previous ? previous.generation + (previous.version !== version ? 1 : 0) : 1;
123
+ const generation = previous ? previous.generation + (previous.version !== version ? 1 : 0) : this.#generationFloor + 1;
112
124
  const artifact = {
113
125
  id, path: locator.absolute, relativePath: locator.relative,
114
126
  hash: observed.hash, generation, exists: observed.exists, status: observed.status,
@@ -116,7 +128,7 @@ export class EvidenceStore {
116
128
  };
117
129
  // Registry retains no source bytes; returned captures are immutable private
118
130
  // snapshots. All path/cache cardinalities are bounded.
119
- const record = { ...locator, id, generation, hash: observed.hash, status: observed.status, version };
131
+ const record = { ...locator, id, generation, hash: observed.hash, status: observed.status, version, lineage: this.#lineage };
120
132
  this.#records.set(locator.relative, record);
121
133
  this.#byId.set(id, record);
122
134
  return freeze(artifact);
@@ -129,14 +141,35 @@ export class EvidenceStore {
129
141
  const locator = this.#locator(input);
130
142
  if (!locator || seen.has(locator.relative)) continue;
131
143
  seen.add(locator.relative);
132
- if (!this.#records.has(locator.relative) && this.#records.size >= LIMITS.trackedPaths) continue;
144
+ if (!this.#records.has(locator.relative) && this.#records.size >= this.#maxTrackedPaths) continue;
133
145
  results.push(await this.#captureOne(locator));
134
146
  }
135
147
  return results;
136
148
  }
137
- async reconcile() {
149
+ setLineage(id) {
150
+ if (typeof id !== 'string' || !/^[a-f0-9]{64}$/.test(id)) fail('INVALID_LINEAGE');
151
+ if (id === this.#lineage) return [];
152
+ this.#lineage = id;
153
+ // A checkout change invalidates in-flight references immediately. The next
154
+ // real capture advances each generation, even if its bytes are identical.
155
+ return [...this.#records.values()].map(record => ({
156
+ id: record.id, path: record.absolute, relativePath: record.relative,
157
+ generation: record.generation, hash: record.hash, status: 'unavailable',
158
+ complete: false, exists: null, text: null,
159
+ }));
160
+ }
161
+ async reconcile({ refs, limit } = {}) {
138
162
  const results = [];
139
- for (const record of this.#records.values()) results.push(await this.#captureOne(record));
163
+ let records;
164
+ if (Array.isArray(refs)) {
165
+ records = [...new Set(refs.slice(0, LIMITS.trackedPaths).map(ref => this.#byId.get(ref.artifactId)).filter(Boolean))];
166
+ } else if (integer(limit, 1, this.#maxTrackedPaths)) {
167
+ const all = [...this.#records.values()];
168
+ const count = Math.min(limit, all.length);
169
+ records = Array.from({ length: count }, (_, index) => all[(this.#reconcileCursor + index) % all.length]);
170
+ this.#reconcileCursor = all.length ? (this.#reconcileCursor + count) % all.length : 0;
171
+ } else records = [...this.#records.values()];
172
+ for (const record of records) results.push(await this.#captureOne(record));
140
173
  return results;
141
174
  }
142
175
  isCurrent(refs) {
@@ -144,7 +177,8 @@ export class EvidenceStore {
144
177
  return refs.every(ref => {
145
178
  if (!plain(ref) || !isHash(ref.hash) || !integer(ref.generation, 1)) return false;
146
179
  const current = this.#byId.get(ref.artifactId);
147
- return current?.status === 'present' && current.hash === ref.hash && current.generation === ref.generation;
180
+ return current?.status === 'present' && current.lineage === this.#lineage &&
181
+ current.hash === ref.hash && current.generation === ref.generation;
148
182
  });
149
183
  }
150
184
  }
@@ -31,7 +31,8 @@ function validReference(ref) {
31
31
  if (!exactKeys(ref, REF, ['excerpt', 'sourceRef']) || !isId(ref.artifactId) || !isHash(ref.hash) ||
32
32
  !integer(ref.generation, 1) || !isId(ref.eventId) || !integer(ref.startLine, 1, 10000000) ||
33
33
  !integer(ref.endLine, ref.startLine, 10000000) || ref.endLine - ref.startLine >= LIMITS.snippetLines ||
34
- !['source', 'public_intent'].includes(ref.sourceClass) || ref.basis !== 'jev_interpretation' ||
34
+ !['source', 'public_intent'].includes(ref.sourceClass) ||
35
+ !['jev_interpretation', 'decision_interpretation'].includes(ref.basis) ||
35
36
  (Object.hasOwn(ref, 'excerpt') && !safeText(ref.excerpt, LIMITS.excerptChars))) return false;
36
37
  if (ref.sourceClass === 'public_intent' && !ref.sourceRef) return false;
37
38
  if (ref.sourceRef) {
@@ -131,11 +132,11 @@ function makePatch(graph, operations, causedBy = []) {
131
132
  baseRevision: graph.revision, revision: graph.revision + 1, causedBy, operations,
132
133
  });
133
134
  }
134
- function sourceReference(candidate, event, policy) {
135
+ function sourceReference(candidate, event, policy, basis = 'jev_interpretation') {
135
136
  const ref = {
136
137
  artifactId: candidate.artifactId, hash: candidate.hash, generation: candidate.generation, eventId: event.id,
137
138
  startLine: candidate.startLine, endLine: candidate.endLine, sourceClass: candidate.sourceClass,
138
- basis: 'jev_interpretation', sourceRef: clone(candidate.sourceRef),
139
+ basis, sourceRef: clone(candidate.sourceRef),
139
140
  };
140
141
  if (policy.displayEvidence || policy.persistEvidence) {
141
142
  const excerpt = candidate.text.slice(0, LIMITS.excerptChars);
@@ -251,6 +252,10 @@ function compileAuditedDecision(graph, { event, decision, policy }, audit) {
251
252
  new Set(decision.edges.map(e => e?.proposalId)).size !== decision.edges.length) return audit.reject(decision, 'duplicate_judgments');
252
253
  if (!decision.nodes.every(n => validNodeJudgment(n, candidates)) ||
253
254
  !decision.edges.every(e => validEdgeJudgment(e, bundle, candidates))) return audit.reject(decision, 'invalid_judgments');
255
+ // The service supplies validated provider provenance. Older manual decisions
256
+ // omit it and keep the legacy basis; answers/candidates cannot select a basis.
257
+ const providerId = decision.provider?.id;
258
+ const basis = providerId && providerId !== 'jev' ? 'decision_interpretation' : 'jev_interpretation';
254
259
  const existingNodes = new Map(graph.nodes.map(n => [n.id, n]));
255
260
  const existingEdges = new Map(graph.edges.map(e => [e.id, e]));
256
261
  const admitted = new Map(), operations = [];
@@ -278,7 +283,7 @@ function compileAuditedDecision(graph, { event, decision, policy }, audit) {
278
283
  const classification = judgment.classification === 'accepted' && candidate.complete && !event.incomplete &&
279
284
  judgment.supportProbability >= 0.85 && judgment.roleProbability >= 0.8 && judgment.roleConfidence >= 0.6 ? 'accepted' : 'tentative';
280
285
  const index = projected.nodes.length;
281
- const refs = mergeReferences(old?.sourceRefs ?? [], [sourceReference(candidate, event, policy)]);
286
+ const refs = mergeReferences(old?.sourceRefs ?? [], [sourceReference(candidate, event, policy, basis)]);
282
287
  const node = {
283
288
  id, label: candidate.label, kind: judgment.role, shape: ROLE_SHAPES[judgment.role],
284
289
  x: old?.x ?? 80 + (index % 6) * 220, y: old?.y ?? 80 + Math.floor(index / 6) * 140,
@@ -301,10 +306,10 @@ function compileAuditedDecision(graph, { event, decision, policy }, audit) {
301
306
  (ref.generation > c.generation || (old.validity !== 'current' && ref.generation === c.generation))))) {
302
307
  report('skipped', 'stale_generation'); continue;
303
308
  }
304
- const refs = mergeReferences([], evidence.map(c => sourceReference(c, event, policy)));
309
+ const refs = mergeReferences([], evidence.map(c => sourceReference(c, event, policy, basis)));
305
310
  // Every relation retains all dependencies; never silently drop evidence
306
311
  // when the reference budget is exceeded.
307
- if (refs.length !== new Set(evidence.map(c => refKey(sourceReference(c, event, policy)))).size) {
312
+ if (refs.length !== new Set(evidence.map(c => refKey(sourceReference(c, event, policy, basis)))).size) {
308
313
  report('skipped', 'reference_limit'); continue;
309
314
  }
310
315
  const classification = judgment.classification === 'accepted' && source.classification === 'accepted' &&
@@ -19,6 +19,7 @@ export function createPolicy(options = {}) {
19
19
  .filter(p => !DEFAULT_EXCLUDES.includes(p)).slice(0, 64) : []
20
20
  )])].sort();
21
21
  const fields = {
22
+ readSource: options.readSource === true || options.transmitSource === true,
22
23
  transmitSource: options.transmitSource === true,
23
24
  displayEvidence: options.displayEvidence !== false,
24
25
  persistEvidence: options.persistEvidence === true,
@@ -10,12 +10,16 @@ export function createAuth({ origin, instanceId, now = Date.now }) {
10
10
  for (const [key, expires] of map) if (expires <= now()) map.delete(key);
11
11
  while (map.size >= 16) map.delete(map.keys().next().value);
12
12
  }
13
+ function validTransport(req) {
14
+ const hosts = req.rawHeaders.filter((value, index) => index % 2 === 0 && value.toLowerCase() === 'host');
15
+ return hosts.length === 1 && req.headers.host === new URL(origin).host &&
16
+ ['127.0.0.1', '::ffff:127.0.0.1'].includes(req.socket.remoteAddress);
17
+ }
13
18
  return {
14
19
  cookieName,
20
+ validTransport,
15
21
  validRequest(req, { mutation = false } = {}) {
16
- const hosts = req.rawHeaders.filter((value, index) => index % 2 === 0 && value.toLowerCase() === 'host');
17
- if (hosts.length !== 1 || req.headers.host !== new URL(origin).host) return false;
18
- if (!['127.0.0.1', '::ffff:127.0.0.1'].includes(req.socket.remoteAddress)) return false;
22
+ if (!validTransport(req)) return false;
19
23
  if (req.headers['sec-fetch-site'] && !['same-origin', 'none'].includes(req.headers['sec-fetch-site'])) return false;
20
24
  if (mutation) return req.headers.origin === origin;
21
25
  return req.headers.origin === undefined || req.headers.origin === origin;
@@ -4,7 +4,7 @@ import { randomUUID } from 'node:crypto';
4
4
  import path from 'node:path';
5
5
  import { CATEGORIES, KINDS, ROLES, RELATIONS, isId, opaque, plain } from '../core/common.mjs';
6
6
  import { createPolicy, excluded, privateText, safeLabel } from '../core/privacy.mjs';
7
- import { ACTIVITIES } from '../jev/questions.mjs';
7
+ import { ACTIVITIES } from '../decisions/questions.mjs';
8
8
  import { runtimeError, uid } from './paths.mjs';
9
9
 
10
10
  export const DIAGNOSTIC_LIMITS = Object.freeze({
@@ -0,0 +1,203 @@
1
+ import {
2
+ createFrameDocument, getExtensionDataProjection, validateGrant, extensionId,
3
+ id, digest, integer, exact, uniqueStrings, jsonBytes, EXTENSION_LIMITS,
4
+ } from '../extensions/index.mjs';
5
+ import { safeText } from '../core/privacy.mjs';
6
+
7
+ const PREFIX = '/api/extensions';
8
+ const HTTP_ERROR = Symbol('extension_http_error');
9
+ const fail = (status, code) => { throw Object.assign(new Error(code), { status, code, [HTTP_ERROR]: true }); };
10
+ const sameGrant = (left, right) => JSON.stringify(left) === JSON.stringify(right);
11
+
12
+ function reply(res, status, value) {
13
+ const body = jsonBytes(value, EXTENSION_LIMITS.projectionBytes);
14
+ res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8' });
15
+ res.end(body);
16
+ }
17
+ async function bodyJSON(req) {
18
+ if (!/^application\/json(?:;\s*charset=utf-8)?$/i.test(req.headers['content-type'] ?? '')) fail(415, 'invalid_content_type');
19
+ if (req.headers['content-encoding']) fail(400, 'unsupported_content_encoding');
20
+ const chunks = [];
21
+ let size = 0;
22
+ const timer = setTimeout(() => req.destroy(), 2000);
23
+ try {
24
+ for await (const chunk of req) {
25
+ size += chunk.length;
26
+ if (size > 16 * 1024) fail(413, 'extension_request_limit');
27
+ chunks.push(chunk);
28
+ }
29
+ let value;
30
+ try { value = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(Buffer.concat(chunks))); }
31
+ catch { fail(400, 'invalid_json'); }
32
+ return value;
33
+ } finally { clearTimeout(timer); }
34
+ }
35
+ function parameters(query, allowed) {
36
+ const result = {};
37
+ for (const [name, value] of new URLSearchParams(query)) {
38
+ if (!allowed.includes(name) || Object.hasOwn(result, name) || !value) fail(400, 'invalid_extension_query');
39
+ result[name] = value;
40
+ }
41
+ for (const field of ['scope', 'session', 'checkpoint']) {
42
+ if (result[field] !== undefined && !id(result[field])) fail(400, 'invalid_extension_query');
43
+ }
44
+ if (result.nonce !== undefined && !/^[A-Za-z0-9_-]{24,128}$/.test(result.nonce)) fail(400, 'invalid_frame_nonce');
45
+ return result;
46
+ }
47
+
48
+ /**
49
+ * Parent performs loopback/Host/Origin checks and viewer-cookie authentication.
50
+ * Never pass viewerAuthorized for an external read bearer or an opaque origin.
51
+ * getSnapshot is synchronous and reapplies current core policy, like model-api.
52
+ */
53
+ export function createExtensionAPI({ registry, getSnapshot, projectId, runAnalysis } = {}) {
54
+ if (!registry || typeof getSnapshot !== 'function' || !id(projectId) ||
55
+ (runAnalysis !== undefined && typeof runAnalysis !== 'function')) throw new TypeError('invalid_extension_api_options');
56
+
57
+ async function requireGrant(extension, expectedDigest) {
58
+ const value = await registry.getGrant(extension);
59
+ try { validateGrant(value); } catch { fail(403, 'extension_grant_denied'); }
60
+ if (!value.approved || value.projectId !== projectId || value.extensionId !== extension) fail(403, 'extension_grant_denied');
61
+ if (expectedDigest !== undefined && value.digest !== expectedDigest) fail(409, 'extension_digest_changed');
62
+ return value;
63
+ }
64
+ function snapshot(params, grant) {
65
+ // A session selector can address historical work even without a checkpoint.
66
+ if ((params.checkpoint || params.session) && !grant.history) fail(403, 'history_not_granted');
67
+ const raw = getSnapshot({
68
+ ...(params.scope ? { scopeId: params.scope } : {}),
69
+ ...(params.session ? { sessionId: params.session } : {}),
70
+ ...(params.checkpoint ? { checkpointId: params.checkpoint } : {}),
71
+ persistent: false,
72
+ });
73
+ if (!raw || typeof raw !== 'object' || raw.then || raw.projectId !== projectId) fail(503, 'invalid_model_snapshot');
74
+ const projected = getExtensionDataProjection(
75
+ params.checkpoint ? { ...raw, checkpointId: params.checkpoint, replay: true } : raw, grant);
76
+ if (!projected) fail(403, 'extension_grant_denied');
77
+ return projected;
78
+ }
79
+ async function unchanged(extension, prior) {
80
+ const current = await requireGrant(extension, prior.digest);
81
+ if (!sameGrant(prior, current)) fail(403, 'extension_grant_changed');
82
+ return current;
83
+ }
84
+ async function analyze(req, res) {
85
+ const input = await bodyJSON(req);
86
+ if (!exact(input, ['id', 'digest', 'profileId', 'entityIds', 'revision']) ||
87
+ !extensionId(input.id) || !digest(input.digest) || !id(input.profileId) ||
88
+ !uniqueStrings(input.entityIds, id, 256) || !input.entityIds.length || !integer(input.revision)) {
89
+ fail(400, 'invalid_analysis_request');
90
+ }
91
+ const grant = await requireGrant(input.id, input.digest);
92
+ if (!grant.profiles?.includes(input.profileId)) fail(403, 'analysis_profile_not_granted');
93
+ const installed = await registry.getAssets(input.id, { digest: input.digest });
94
+ if (!installed.manifest.capabilities.includes('analysis.request')) fail(403, 'analysis_not_granted');
95
+ const profile = installed.profiles.find(value => value.id === input.profileId);
96
+ if (!profile) fail(403, 'analysis_profile_not_granted');
97
+ if (profile.selectors.fields.some(field => !grant.fields.includes(field))) fail(403, 'analysis_field_not_granted');
98
+ await unchanged(input.id, grant);
99
+ const model = snapshot({}, grant);
100
+ if (model.revision !== input.revision) fail(409, 'analysis_revision_changed');
101
+ if (!input.entityIds.every(entityId => model.entities.some(value => value.id === entityId)) ||
102
+ (profile.selectors.candidateIds.length &&
103
+ !input.entityIds.every(entityId => profile.selectors.candidateIds.includes(entityId)))) fail(403, 'analysis_candidate_not_granted');
104
+ if (!runAnalysis) fail(501, 'extension_analysis_unavailable');
105
+ const controller = new AbortController();
106
+ const abort = () => controller.abort();
107
+ req.once('aborted', abort);
108
+ res.once('close', abort);
109
+ try {
110
+ // Core owns provider calls, source consent/filtering, approval capabilities,
111
+ // evidence-version checks, subscriptions, and recording interpretations.
112
+ const result = await runAnalysis({
113
+ projectId, extensionId: input.id, digest: input.digest, profile,
114
+ entityIds: [...input.entityIds], revision: input.revision, grant, signal: controller.signal,
115
+ });
116
+ await unchanged(input.id, grant);
117
+ const current = snapshot({}, grant);
118
+ if (!result || !['accepted', 'pending', 'complete', 'unavailable'].includes(result.status)) fail(503, 'invalid_analysis_result');
119
+ const interpretationIds = Array.isArray(result.interpretationIds) ? result.interpretationIds.slice(0, 256)
120
+ .filter(value => id(value) && current.interpretations.some(item =>
121
+ item.id === value && item.namespace === profile.namespace)) : [];
122
+ if (!res.destroyed) reply(res, result.status === 'pending' || result.status === 'accepted' ? 202 : 200, {
123
+ status: result.status,
124
+ ...(id(result.requestId) && safeText(result.requestId, 160) ? { requestId: result.requestId } : {}),
125
+ interpretationIds: [...new Set(interpretationIds)],
126
+ });
127
+ } finally {
128
+ req.removeListener('aborted', abort);
129
+ res.removeListener('close', abort);
130
+ }
131
+ }
132
+ async function handle(req, res, { viewerAuthorized = false } = {}) {
133
+ if (typeof req.url !== 'string' ||
134
+ !(req.url === PREFIX || req.url.startsWith(`${PREFIX}/`) || req.url.startsWith(`${PREFIX}?`))) return false;
135
+ res.setHeader('Cache-Control', 'no-store');
136
+ res.setHeader('X-Content-Type-Options', 'nosniff');
137
+ res.setHeader('Referrer-Policy', 'no-referrer');
138
+ try {
139
+ if (viewerAuthorized !== true || req.headers.origin === 'null') fail(401, 'viewer_authorization_required');
140
+ if (req.url.length > 4096 || /[\s\\#]/.test(req.url) || /%(?![a-f\d]{2})/i.test(req.url)) fail(400, 'invalid_extension_route');
141
+ const [pathname, query = '', ...extra] = req.url.split('?');
142
+ if (extra.length || pathname.includes('%') || pathname.includes('//') ||
143
+ pathname.split('/').some(part => part === '.' || part === '..')) fail(400, 'invalid_extension_route');
144
+ const route = pathname.slice(PREFIX.length);
145
+ const read = /^\/(data|frame)\/([^/]+)$/.exec(route);
146
+ if (read && !extensionId(read[2])) fail(400, 'invalid_extension_id');
147
+ const params = parameters(query, read?.[1] === 'data' ? ['scope', 'session', 'checkpoint'] :
148
+ read?.[1] === 'frame' ? ['nonce'] : []);
149
+ if (req.method === 'GET') {
150
+ if (req.headers['transfer-encoding'] || Number(req.headers['content-length'] ?? 0) !== 0) fail(400, 'unexpected_body');
151
+ if (route === '') { reply(res, 200, await registry.list()); return true; }
152
+ if (!read) fail(404, 'extension_route_not_found');
153
+ const extension = read[2];
154
+ const grant = await requireGrant(extension);
155
+ if (read[1] === 'data') {
156
+ reply(res, 200, snapshot(params, grant));
157
+ } else {
158
+ if (!params.nonce) fail(400, 'invalid_frame_nonce');
159
+ const assets = await registry.getAssets(extension, { digest: grant.digest });
160
+ await unchanged(extension, grant);
161
+ const frame = createFrameDocument({ ...assets, nonce: params.nonce });
162
+ // DENY remains on every other route. Response CSP sandboxing and
163
+ // frame-ancestors 'self' protect the directly opened frame document.
164
+ res.removeHeader('X-Frame-Options');
165
+ for (const [name, value] of Object.entries(frame.headers)) res.setHeader(name, value);
166
+ res.writeHead(200); res.end(frame.body);
167
+ }
168
+ return true;
169
+ }
170
+ if (req.method !== 'POST') fail(405, 'extension_method_not_allowed');
171
+ if (route === '/analysis') { await analyze(req, res); return true; }
172
+ if (!['/grant', '/revoke'].includes(route)) fail(404, 'extension_route_not_found');
173
+ const input = await bodyJSON(req);
174
+ if (route === '/grant') {
175
+ if (!exact(input, ['id', 'digest', 'fields', 'history', 'approved'], ['profiles']) ||
176
+ !extensionId(input.id)) fail(400, 'invalid_extension_grant');
177
+ const { id: extension, ...request } = input;
178
+ reply(res, 200, await registry.grant(extension, request));
179
+ } else {
180
+ if (!exact(input, ['id']) || !extensionId(input.id)) fail(400, 'invalid_extension_revoke');
181
+ reply(res, 200, await registry.revoke(input.id));
182
+ }
183
+ } catch (error) {
184
+ const known = {
185
+ extension_not_installed: [404, 'extension_not_installed'],
186
+ extension_digest_changed: [409, 'extension_digest_changed'],
187
+ unrequested_data_field: [403, 'unrequested_data_field'],
188
+ unrequested_profile: [403, 'unrequested_profile'],
189
+ unrequested_history: [403, 'unrequested_history'],
190
+ unrequested_analysis: [403, 'unrequested_analysis'],
191
+ invalid_extension_grant: [400, 'invalid_extension_grant'],
192
+ MODEL_CHECKPOINT_UNAVAILABLE: [404, 'checkpoint_unavailable'],
193
+ MODEL_SCOPE_UNAVAILABLE: [404, 'scope_unavailable'],
194
+ }[error?.code];
195
+ const status = error?.[HTTP_ERROR] ? error.status : known?.[0] ?? 503;
196
+ const code = error?.[HTTP_ERROR] ? error.code : known?.[1] ?? 'extension_unavailable';
197
+ if (!res.headersSent && !res.destroyed) reply(res, status, { error: code });
198
+ else if (!res.destroyed) res.destroy();
199
+ }
200
+ return true;
201
+ }
202
+ return { handle };
203
+ }
@@ -0,0 +1,70 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { createHash } from 'node:crypto';
3
+ import path from 'node:path';
4
+ import { safeText } from '../core/privacy.mjs';
5
+
6
+ const TTL_MS = 2000;
7
+ const CONTROLS = /[\u0000-\u001f\u007f-\u009f\u2028\u2029\u202a-\u202e\u2066-\u2069]/;
8
+ const BRANCH = ['symbolic-ref', '--quiet', '--short', 'HEAD'];
9
+ const HEAD = ['rev-parse', '--verify', '--end-of-options', 'HEAD'];
10
+ const validBranch = value => safeText(value, 240) && !CONTROLS.test(value) &&
11
+ !/[<>\\:\s]/.test(value) && !value.startsWith('/') && !value.startsWith('-');
12
+
13
+ /** Local ref metadata only. The caller owns reconciliation and model updates. */
14
+ export function createLineageReader({ projectRoot, projectId, execute = execFile } = {}) {
15
+ if (typeof projectRoot !== 'string' || !path.isAbsolute(projectRoot) ||
16
+ Buffer.byteLength(projectRoot) > 4096 || projectRoot.includes('\0') ||
17
+ typeof projectId !== 'string' || !/^[A-Za-z0-9][A-Za-z0-9_.:-]{0,159}$/.test(projectId) ||
18
+ typeof execute !== 'function') throw new TypeError('invalid_lineage_options');
19
+ let cached, expiresAt = 0, pending;
20
+ const result = (status, branch, head) => Object.freeze({
21
+ id: createHash('sha256').update(JSON.stringify([projectId, status, branch ?? null, head ?? null])).digest('hex'),
22
+ status, ...(branch ? { branch } : {}), ...(head ? { head } : {}),
23
+ });
24
+
25
+ function git(args) {
26
+ return new Promise(resolve => {
27
+ let child, settled = false;
28
+ const finish = value => { if (!settled) { settled = true; clearTimeout(timer); resolve(value); } };
29
+ const timer = setTimeout(() => {
30
+ finish({ status: 'unavailable' });
31
+ try { child?.kill?.('SIGKILL'); } catch { /* No child details leave the reader. */ }
32
+ }, 800);
33
+ try {
34
+ child = execute('git', ['-c', 'core.fsmonitor=false', '-c', 'core.hooksPath=/dev/null', ...args], {
35
+ cwd: projectRoot, shell: false, timeout: 750, killSignal: 'SIGKILL', maxBuffer: 4096, encoding: 'utf8',
36
+ env: { PATH: process.env.PATH || '/usr/bin:/bin', LC_ALL: 'C',
37
+ GIT_CONFIG_NOSYSTEM: '1', GIT_CONFIG_GLOBAL: '/dev/null',
38
+ GIT_OPTIONAL_LOCKS: '0', GIT_TERMINAL_PROMPT: '0', GIT_NO_REPLACE_OBJECTS: '1', GIT_NO_LAZY_FETCH: '1' },
39
+ }, (error, stdout, stderr) => {
40
+ const text = typeof stdout === 'string' ? stdout.replace(/\r?\n$/, '') : '';
41
+ if (!error) return finish({ status: 'ok', text });
42
+ if (!error.killed && error.code === 1 && !text && args === BRANCH) return finish({ status: 'detached' });
43
+ finish({ status: !error.killed && error.code === 128 && typeof stderr === 'string' &&
44
+ stderr.includes('not a git repository') ? 'not_git' : 'unavailable' });
45
+ });
46
+ } catch { finish({ status: 'unavailable' }); }
47
+ });
48
+ }
49
+
50
+ async function readRefs() {
51
+ if (CONTROLS.test(projectRoot)) return result('unavailable');
52
+ const branch = await git(BRANCH);
53
+ if (branch.status === 'not_git') return result('not_git');
54
+ if (branch.status !== 'detached' && (branch.status !== 'ok' || !validBranch(branch.text))) return result('unavailable');
55
+ const head = await git(HEAD);
56
+ if (head.status !== 'ok' || !/^(?:[a-f0-9]{40}|[a-f0-9]{64})$/.test(head.text)) return result('unavailable');
57
+ // Do not combine a branch observed before checkout with another branch's HEAD.
58
+ const after = await git(BRANCH);
59
+ if (branch.status !== after.status || branch.text !== after.text) return result('unavailable');
60
+ return result('git', branch.status === 'ok' ? branch.text : undefined, head.text);
61
+ }
62
+
63
+ return async function read() {
64
+ if (cached && Date.now() < expiresAt) return cached;
65
+ if (!pending) pending = readRefs().then(value => {
66
+ cached = value; expiresAt = Date.now() + TTL_MS; return value;
67
+ }).finally(() => { pending = undefined; });
68
+ return pending;
69
+ };
70
+ }