graphlin 0.1.3 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (106) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/.codex-plugin/plugin.json +1 -1
  3. package/README.md +12 -3
  4. package/docs/decision-service.md +393 -0
  5. package/docs/extension-authoring.md +553 -0
  6. package/docs/model-api.md +293 -0
  7. package/docs/usage.md +472 -0
  8. package/docs/visualizer-views.md +240 -0
  9. package/node_modules/@vscode/tree-sitter-wasm/LICENSE +21 -0
  10. package/node_modules/@vscode/tree-sitter-wasm/README.md +36 -0
  11. package/node_modules/@vscode/tree-sitter-wasm/SECURITY.md +41 -0
  12. package/node_modules/@vscode/tree-sitter-wasm/cgmanifest.json +16 -0
  13. package/node_modules/@vscode/tree-sitter-wasm/package.json +42 -0
  14. package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-bash.wasm +0 -0
  15. package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-c-sharp.wasm +0 -0
  16. package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-cpp.wasm +0 -0
  17. package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-css.wasm +0 -0
  18. package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-go.wasm +0 -0
  19. package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-ini.wasm +0 -0
  20. package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-java.wasm +0 -0
  21. package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-javascript.wasm +0 -0
  22. package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-php.wasm +0 -0
  23. package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-powershell.wasm +0 -0
  24. package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-python.wasm +0 -0
  25. package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-regex.wasm +0 -0
  26. package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-ruby.wasm +0 -0
  27. package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-rust.wasm +0 -0
  28. package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-tsx.wasm +0 -0
  29. package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-typescript.wasm +0 -0
  30. package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter.js +4075 -0
  31. package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter.wasm +0 -0
  32. package/node_modules/@vscode/tree-sitter-wasm/wasm/web-tree-sitter.d.ts +1027 -0
  33. package/package.json +78 -9
  34. package/plugin.json +4 -2
  35. package/runtime/architecture/analysis.mjs +344 -0
  36. package/runtime/architecture/controller.mjs +209 -0
  37. package/runtime/architecture/evidence.mjs +108 -0
  38. package/runtime/architecture/profile.mjs +56 -0
  39. package/runtime/core/evidence.mjs +43 -9
  40. package/runtime/core/graph.mjs +11 -6
  41. package/runtime/core/privacy.mjs +1 -0
  42. package/runtime/daemon/auth.mjs +7 -3
  43. package/runtime/daemon/diagnostics.mjs +1 -1
  44. package/runtime/daemon/extension-api.mjs +203 -0
  45. package/runtime/daemon/lineage.mjs +70 -0
  46. package/runtime/daemon/manager.mjs +9 -6
  47. package/runtime/daemon/model-api.mjs +728 -0
  48. package/runtime/daemon/model-persistence.mjs +220 -0
  49. package/runtime/daemon/server.mjs +81 -14
  50. package/runtime/daemon/settings.mjs +11 -3
  51. package/runtime/decisions/broker.mjs +349 -0
  52. package/runtime/decisions/contracts.mjs +179 -0
  53. package/runtime/decisions/evaluation.mjs +305 -0
  54. package/runtime/decisions/faults.mjs +32 -0
  55. package/runtime/decisions/index.mjs +818 -0
  56. package/runtime/decisions/profiles.mjs +93 -0
  57. package/runtime/decisions/questions.mjs +268 -0
  58. package/runtime/discovery/index.mjs +2 -0
  59. package/runtime/discovery/inventory.mjs +160 -0
  60. package/runtime/discovery/parser.mjs +40 -0
  61. package/runtime/discovery/structure.mjs +232 -0
  62. package/runtime/extensions/contracts.mjs +59 -0
  63. package/runtime/extensions/frame.mjs +64 -0
  64. package/runtime/extensions/index.mjs +9 -0
  65. package/runtime/extensions/manifest.mjs +95 -0
  66. package/runtime/extensions/packages.mjs +222 -0
  67. package/runtime/extensions/profiles.mjs +36 -0
  68. package/runtime/extensions/projection.mjs +130 -0
  69. package/runtime/extensions/registry.mjs +285 -0
  70. package/runtime/extensions/scene.mjs +105 -0
  71. package/runtime/extensions/sdk.d.ts +205 -0
  72. package/runtime/extensions/sdk.mjs +88 -0
  73. package/runtime/jev/index.mjs +13 -777
  74. package/runtime/jev/provider.mjs +101 -0
  75. package/runtime/jev/questions.mjs +16 -258
  76. package/runtime/jev/wire.mjs +17 -25
  77. package/runtime/model/changes.mjs +42 -0
  78. package/runtime/model/history.mjs +124 -0
  79. package/runtime/model/index.mjs +2 -0
  80. package/runtime/model/project-model.mjs +1020 -0
  81. package/runtime/model/records.mjs +240 -0
  82. package/runtime/pipeline.mjs +267 -55
  83. package/runtime/platform.mjs +254 -0
  84. package/runtime/visualizers/blocks.mjs +5 -0
  85. package/runtime/visualizers/c4.mjs +154 -0
  86. package/runtime/visualizers/changes.mjs +24 -0
  87. package/runtime/visualizers/code.mjs +5 -0
  88. package/runtime/visualizers/index.mjs +23 -0
  89. package/runtime/visualizers/structure.mjs +120 -0
  90. package/runtime/visualizers/timeline.mjs +66 -0
  91. package/runtime/web/app.js +225 -63
  92. package/runtime/web/extension-frame.js +128 -0
  93. package/runtime/web/index.html +38 -1
  94. package/runtime/web/model-client.js +162 -0
  95. package/runtime/web/platform.js +445 -0
  96. package/runtime/web/scene.js +111 -0
  97. package/runtime/web/style.css +51 -0
  98. package/schemas/graph.schema.json +4 -1
  99. package/scripts/arguments.mjs +5 -1
  100. package/scripts/build-packages.mjs +6 -2
  101. package/scripts/control.mjs +1 -1
  102. package/scripts/daemon.mjs +2 -1
  103. package/scripts/extensions.mjs +44 -0
  104. package/scripts/graphlin.mjs +23 -3
  105. package/scripts/onboarding.mjs +10 -3
  106. package/scripts/validate-packages.mjs +54 -8
@@ -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
+ }
@@ -33,9 +33,10 @@ function validateRuntime() {
33
33
  }
34
34
 
35
35
  function validateExisting(current, options) {
36
- const { allowSource, persistEvidence, displayEvidence } = resolvePolicy(options, { current: current.policy });
36
+ const { allowSource, localSource, persistEvidence, displayEvidence } = resolvePolicy(options, { current: current.policy });
37
37
  const { mode = 'live', port = 0 } = options;
38
38
  if (current.mode !== mode || current.policy.transmitSource !== Boolean(allowSource) ||
39
+ current.policy.readSource !== Boolean(allowSource || localSource) ||
39
40
  current.policy.persistEvidence !== Boolean(persistEvidence) ||
40
41
  current.policy.displayEvidence !== Boolean(displayEvidence)) throw runtimeError('policy_restart_required');
41
42
  if (port && port !== current.port) throw runtimeError('port_restart_required');
@@ -90,11 +91,11 @@ async function stopInstance(paths, instanceId) {
90
91
  * instance closes or the caller aborts. Abort never stops a replacement owner.
91
92
  */
92
93
  export async function runForeground({ projectRoot, dataDir = defaultDataDir(), signal,
93
- allowSource, persistEvidence, displayEvidence, mode = 'live', port = 0 } = {}, onReady = () => {}) {
94
+ allowSource, localSource, persistEvidence, displayEvidence, mode = 'live', port = 0 } = {}, onReady = () => {}) {
94
95
  validateRuntime();
95
96
  if (signal?.aborted) return;
96
97
  const paths = await projectPaths(projectRoot, dataDir);
97
- const options = { allowSource, persistEvidence, displayEvidence, mode, port };
98
+ const options = { allowSource, localSource, persistEvidence, displayEvidence, mode, port };
98
99
  let server, launch, interrupted;
99
100
  const interruption = new Promise(resolve => { interrupted = resolve; });
100
101
  const interrupt = () => interrupted();
@@ -110,7 +111,8 @@ export async function runForeground({ projectRoot, dataDir = defaultDataDir(), s
110
111
  const demo = mode === 'demo' ? await import('./demo.mjs') : null;
111
112
  try {
112
113
  server = await startServer({ projectRoot: paths.projectRoot, dataDir: paths.dataDir, mode, port,
113
- policy: { transmitSource: policy.allowSource, persistEvidence: policy.persistEvidence,
114
+ policy: { readSource: Boolean(policy.localSource || policy.allowSource),
115
+ transmitSource: policy.allowSource, persistEvidence: policy.persistEvidence,
114
116
  displayEvidence: policy.displayEvidence },
115
117
  apiKey: policy.allowSource ? process.env.TYPESAFE_API_KEY ?? settings.apiKey : undefined,
116
118
  decisionService: demo?.demoDecisionService() });
@@ -155,11 +157,11 @@ export async function runForeground({ projectRoot, dataDir = defaultDataDir(), s
155
157
  // Detached operation is reserved for an explicit CLI --background request or
156
158
  // MCP. The normal interactive CLI uses runForeground instead.
157
159
  export async function startDaemon({ projectRoot, dataDir = defaultDataDir(), background = true,
158
- allowSource, persistEvidence, displayEvidence, mode = 'live', port = 0 } = {}) {
160
+ allowSource, localSource, persistEvidence, displayEvidence, mode = 'live', port = 0 } = {}) {
159
161
  validateRuntime();
160
162
  if (background !== true) throw runtimeError('background_required');
161
163
  const paths = await projectPaths(projectRoot, dataDir);
162
- const options = { allowSource, persistEvidence, displayEvidence, mode, port };
164
+ const options = { allowSource, localSource, persistEvidence, displayEvidence, mode, port };
163
165
  const existing = await daemonStatus({ projectRoot: paths.projectRoot, dataDir: paths.dataDir });
164
166
  if (existing.running) return { ...await existingLaunch(paths, existing, options), foreground: false };
165
167
  const settings = mode === 'demo' ? {} : await readSettings(paths);
@@ -167,6 +169,7 @@ export async function startDaemon({ projectRoot, dataDir = defaultDataDir(), bac
167
169
  await projectPaths(projectRoot, dataDir, { create: true });
168
170
  const args = [worker, '--project', paths.projectRoot, '--data-dir', paths.dataDir, '--mode', mode, '--port', String(port)];
169
171
  if (policy.allowSource) args.push('--allow-source');
172
+ else if (policy.localSource) args.push('--local-source');
170
173
  if (policy.persistEvidence) args.push('--persist-evidence');
171
174
  if (!policy.displayEvidence) args.push('--no-display-evidence');
172
175
  const env = { ...process.env };