@arcfold/dsh 0.0.0-stage → 0.4.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 (127) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +8 -2
  3. package/THIRD_PARTY_NOTICES.md +11 -0
  4. package/cordis.patch.yml +138 -0
  5. package/package.json +70 -4
  6. package/patches/deepseek-0.2.2-rc.1-transport.json +65 -0
  7. package/patches/deepseek-0.2.2-rc.1-transport.patch +61 -0
  8. package/presets/arcfold.patch.yml +110 -0
  9. package/runtime/README.md +19 -0
  10. package/runtime/bootstrap.json +15 -0
  11. package/runtime/desktop-python.tar.gz +0 -0
  12. package/runtime/host/package.json +11 -0
  13. package/runtime/host/pnpm-workspace.yaml +9 -0
  14. package/runtime/initial-policy.json +94 -0
  15. package/runtime/profile/pnpm-workspace.yaml +7 -0
  16. package/scripts/exact-package-patch.mjs +106 -0
  17. package/scripts/patch-deepseek-transport.mjs +14 -0
  18. package/src/agent.mjs +56 -0
  19. package/src/browse-scope.mjs +14 -0
  20. package/src/budget.mjs +369 -0
  21. package/src/business.mjs +334 -0
  22. package/src/context-material.mjs +119 -0
  23. package/src/deepseek-budget-plugin.mjs +58 -0
  24. package/src/deepseek-provider.mjs +57 -0
  25. package/src/deepseek-receipt.mjs +97 -0
  26. package/src/deepseek-transport.mjs +97 -0
  27. package/src/desktop/adapter.mjs +16 -0
  28. package/src/desktop/bootstrap.mjs +84 -0
  29. package/src/desktop/configuration.mjs +117 -0
  30. package/src/desktop/provider.mjs +46 -0
  31. package/src/desktop/python-runtime.mjs +105 -0
  32. package/src/desktop/setup-service.mjs +81 -0
  33. package/src/desktop/vendor/LICENSE.deepseek +21 -0
  34. package/src/desktop/vendor/NOTICE.md +5 -0
  35. package/src/desktop/vendor/deepseek-0.2.2-rc.1.mjs +2919 -0
  36. package/src/evidence-ownership.mjs +51 -0
  37. package/src/evidence.mjs +35 -0
  38. package/src/goal-context.mjs +52 -0
  39. package/src/goal-execution-tool.mjs +74 -0
  40. package/src/goal-execution.mjs +333 -0
  41. package/src/goal-tool.mjs +56 -0
  42. package/src/goal-work-control.mjs +63 -0
  43. package/src/goal-work.mjs +257 -0
  44. package/src/goals.mjs +578 -0
  45. package/src/importer.mjs +298 -0
  46. package/src/index.mjs +53 -0
  47. package/src/learning-contracts.mjs +220 -0
  48. package/src/learning-defaults.mjs +98 -0
  49. package/src/learning-goals.mjs +165 -0
  50. package/src/learning-plugin.mjs +28 -0
  51. package/src/learning-runner.mjs +275 -0
  52. package/src/learning.mjs +534 -0
  53. package/src/legacy-export.mjs +332 -0
  54. package/src/legacy-import-source.mjs +356 -0
  55. package/src/metered-provider-host.mjs +101 -0
  56. package/src/metered-transport.mjs +435 -0
  57. package/src/method-context.mjs +57 -0
  58. package/src/method-lifecycle.mjs +34 -0
  59. package/src/method-tool.mjs +61 -0
  60. package/src/methods.mjs +425 -0
  61. package/src/model-selection.mjs +41 -0
  62. package/src/native-context-call.mjs +88 -0
  63. package/src/native-continuation.mjs +217 -0
  64. package/src/native-request-authority.mjs +90 -0
  65. package/src/openai-models.mjs +4 -0
  66. package/src/openai-receipt.mjs +98 -0
  67. package/src/openai-transport.mjs +167 -0
  68. package/src/openai-usage.mjs +194 -0
  69. package/src/opportunities.mjs +295 -0
  70. package/src/preparation-artifacts.mjs +213 -0
  71. package/src/preparation-plugin.mjs +290 -0
  72. package/src/runtime-command.mjs +264 -0
  73. package/src/runtime-config.mjs +417 -0
  74. package/src/runtime-guard.mjs +284 -0
  75. package/src/runtime-host.mjs +212 -0
  76. package/src/runtime-init.mjs +278 -0
  77. package/src/runtime-installation.mjs +518 -0
  78. package/src/runtime-lifecycle.mjs +227 -0
  79. package/src/runtime-model-settings.mjs +111 -0
  80. package/src/runtime-readiness.mjs +285 -0
  81. package/src/runtime-workspace.mjs +18 -0
  82. package/src/session-bridge.mjs +365 -0
  83. package/src/store.mjs +394 -0
  84. package/src/tree/LICENSE +21 -0
  85. package/src/tree/attribution.mjs +120 -0
  86. package/src/tree/clusters.mjs +61 -0
  87. package/src/tree/consolidation.mjs +103 -0
  88. package/src/tree/context.mjs +211 -0
  89. package/src/tree/contracts.mjs +142 -0
  90. package/src/tree/experience.mjs +140 -0
  91. package/src/tree/experiment-budget.mjs +84 -0
  92. package/src/tree/hooks.mjs +254 -0
  93. package/src/tree/host-context.mjs +107 -0
  94. package/src/tree/host-process.mjs +288 -0
  95. package/src/tree/host.mjs +37 -0
  96. package/src/tree/improvement.mjs +106 -0
  97. package/src/tree/items.mjs +294 -0
  98. package/src/tree/jobs.mjs +184 -0
  99. package/src/tree/learning.mjs +202 -0
  100. package/src/tree/metrics.mjs +203 -0
  101. package/src/tree/model-owner.mjs +254 -0
  102. package/src/tree/observations.mjs +73 -0
  103. package/src/tree/prompts.mjs +500 -0
  104. package/src/tree/reports.mjs +67 -0
  105. package/src/tree/repository.mjs +236 -0
  106. package/src/tree/runtime.mjs +745 -0
  107. package/src/tree/script-writer.mjs +149 -0
  108. package/src/tree/scripts.mjs +208 -0
  109. package/src/tree/signals.mjs +118 -0
  110. package/src/tree/snapshots.mjs +379 -0
  111. package/src/tree/token-counter.mjs +75 -0
  112. package/src/tree/token-counter.py +37 -0
  113. package/src/tree/tokenizer-resources/LICENSE +21 -0
  114. package/src/tree/tokenizer-resources/README.md +31 -0
  115. package/src/tree/tokenizer-resources/deepseek_recipe-0.2.0-cp310-abi3-macosx_11_0_arm64.whl +0 -0
  116. package/src/tree/tokenizer-resources/manifest.json +61 -0
  117. package/src/tree/tokenizer-resources/native.pyi +1042 -0
  118. package/src/tree/tokenizer-resources/tokenizer-NOTICE.md +26 -0
  119. package/src/tree/tokenizer-resources/tokenizer-v4-NOTICE.md +27 -0
  120. package/src/tree/tokenizer-resources/tokenizer-v4.json +267359 -0
  121. package/src/tree/tokenizer-resources/tokenizer.json +267359 -0
  122. package/src/tree/tools.mjs +99 -0
  123. package/src/tree/tree.mjs +202 -0
  124. package/src/tree/ui.mjs +285 -0
  125. package/src/tree/verification.mjs +143 -0
  126. package/src/understandings.mjs +309 -0
  127. package/src/web-budget-recovery.mjs +181 -0
@@ -0,0 +1,149 @@
1
+ import { canonicalJson, digest, fail, shape, integer, text, jsonClone } from './contracts.mjs';
2
+ import { processPolicy } from './host-process.mjs';
3
+ import { relativeFile } from './snapshots.mjs';
4
+ import { SCRIPT_LIMITS, ARG_SCHEMA_GUIDANCE, scriptPath, validateArgSchema, normalizeArgs, ConfinedScriptRunner } from './scripts.mjs';
5
+ import { WRITER_TOOLS } from './jobs.mjs';
6
+
7
+ const program = { kind: 'program', id: 'tree-script-writer' };
8
+ const emptyArgs = { type: 'object', properties: {}, required: [], additionalProperties: false };
9
+ const strings = values => { if (!Array.isArray(values) || values.length > 20) fail('WRITER_MANIFEST', 'Bounded verification notes required'); return values.map(v => text(v, 'verification note', 2000)); };
10
+
11
+ /** One trusted binding per native writer job; models cannot choose its owner. */
12
+ export class ScriptWriter {
13
+ constructor({ repository, artifacts, workspaceIO, binding, assertJob, runtimeRoot, protectedRoots, runner = new ConfinedScriptRunner(), allowUnconfinedFixture = false }) {
14
+ Object.assign(this, { repo: repository, artifacts, io: workspaceIO, binding: jsonClone(binding), assertJob, runtimeRoot, protectedRoots, runner });
15
+ if (typeof assertJob !== 'function' || !binding.jobId || !binding.scriptId || !binding.methodId || !Number.isSafeInteger(binding.draftVersion) || !Number.isSafeInteger(binding.requestIndex)) fail('WRITER_BINDING', 'Trusted writer ownership required');
16
+ if ((runner.enforcement !== 'full' || workspaceIO.enforcement !== 'full') && !allowUnconfinedFixture) fail('SANDBOX_UNAVAILABLE', 'Writer I/O and probe require full confinement');
17
+ this.timeoutMs = binding.timeoutMs ?? SCRIPT_LIMITS.timeoutMs; integer(this.timeoutMs, 'timeoutMs', 1, SCRIPT_LIMITS.maximumTimeoutMs);
18
+ this.id = digest(['script-draft', binding.jobId]); this.active = new Set();
19
+ const old = this.draft(); if (old && digest(old.binding) !== digest(this.binding)) fail('WRITER_BINDING', 'Existing job binding differs');
20
+ if (!old) this.change('open', () => ({ binding: this.binding, state: 'open', files: [], probes: [], startedAt: this.repo.now() }));
21
+ }
22
+ draft() { return this.repo.meta().scriptDrafts?.[this.id] ?? null; }
23
+ assertCurrent({ finished = false } = {}) {
24
+ const result = this.assertJob(this.binding.jobId); if (result?.then || result !== true) fail('WRITER_STALE', 'Native writer job is no longer current');
25
+ const item = this.repo.get('tree_items', this.binding.methodId), bundle = this.repo.get('tree_methods', digest(['method', this.binding.methodId, this.binding.draftVersion]));
26
+ if (!item || item.lifecycle !== 'draft' || item.draftVersion !== this.binding.draftVersion || !bundle?.pendingScriptRequests[this.binding.requestIndex]) fail('WRITER_STALE', 'Requested method draft was withdrawn or replaced');
27
+ const version = this.repo.get('tree_item_versions', digest(['item_version', item.id, item.draftVersion]));
28
+ if (!version || version.sourceRefs.some(ref => !this.artifacts.resolveSource(ref))) fail('SOURCE_STALE', 'Writer source is no longer eligible');
29
+ if (!finished && this.draft()?.state !== 'open') fail('WRITER_FINISHED', 'This artifact draft is already finished');
30
+ }
31
+ change(kind, update, expectedRevision) {
32
+ const current = this.draft();
33
+ return this.repo.command('recordTreeWriter', { requestId: digest([this.id, kind, current?.revision ?? 0]), expected: {}, input: { draftId: this.id, kind, revision: current?.revision ?? 0 } }, program, () => {
34
+ const meta = this.repo.meta(), row = meta.scriptDrafts?.[this.id]; if ((row?.revision ?? 0) !== (current?.revision ?? 0)) fail('REVISION_CONFLICT', 'Writer draft changed');
35
+ if (expectedRevision !== undefined && (row?.revision ?? 0) !== expectedRevision) fail('ARTIFACT_CONFLICT', 'Draft changed while artifact bytes were being prepared');
36
+ const next = { ...row, ...update(row), id: this.id, revision: (row?.revision ?? 0) + 1, updatedAt: this.repo.now() };
37
+ this.repo.save('tree_meta', { ...meta, scriptDrafts: { ...meta.scriptDrafts, [this.id]: next } }, meta.revision); return next;
38
+ }).result;
39
+ }
40
+ async scan(copy, signal) { this.assertCurrent(); const result = await this.io.scan({ copy, signal }); this.assertCurrent(); return result; }
41
+ async read(input, options = {}) {
42
+ shape(input, ['relativePath'], ['offset', 'limit']); relativeFile(input.relativePath); const offset = input.offset ?? 0, limit = input.limit ?? 16000;
43
+ integer(offset, 'offset', 0, 20 * 1024 * 1024); integer(limit, 'limit', 1, 16000);
44
+ const { manifest, contents } = await this.scan(true, options.signal), file = manifest.files.find(f => f.path === input.relativePath);
45
+ if (!file) fail('WRITER_READ_DENIED', 'File is absent or outside ordinary workspace content');
46
+ const bytes = contents.get(file.path), value = bytes.toString('utf8'); if (!Buffer.from(value).equals(bytes) || value.includes('\0')) fail('WRITER_BINARY', 'Writer reads UTF-8 text only');
47
+ const chars = [...value]; return { path: file.path, sha256: file.sha256, offset, content: chars.slice(offset, offset + limit).join(''), nextOffset: offset + limit < chars.length ? offset + limit : null };
48
+ }
49
+ async list(input = {}, options = {}) {
50
+ shape(input, [], ['relativePath', 'offset', 'limit']); const root = input.relativePath ?? '', offset = input.offset ?? 0, limit = input.limit ?? 100;
51
+ if (root) relativeFile(root); integer(offset, 'offset', 0, 1000000); integer(limit, 'limit', 1, 200);
52
+ const { manifest } = await this.scan(false, options.signal), files = manifest.files.filter(f => !root || f.path.startsWith(root + '/')).map(f => ({ path: f.path, size: f.size }));
53
+ return { files: files.slice(offset, offset + limit), nextOffset: offset + limit < files.length ? offset + limit : null, coverage: manifest.exclusions.length ? 'partial' : 'full' };
54
+ }
55
+ async search(input, options = {}) {
56
+ shape(input, ['query'], ['relativePath', 'limit']); text(input.query, 'literal query', 512); const root = input.relativePath ?? '', limit = input.limit ?? 20;
57
+ if (root) relativeFile(root); integer(limit, 'limit', 1, 100);
58
+ const { manifest, contents } = await this.scan(true, options.signal), matches = []; let truncated = false;
59
+ for (const file of manifest.files) {
60
+ if (root && file.path !== root && !file.path.startsWith(root + '/')) continue; const bytes = contents.get(file.path), value = bytes.toString('utf8');
61
+ if (value.includes('\0') || !Buffer.from(value).equals(bytes)) continue;
62
+ const lines = value.split('\n'); for (let i = 0; i < lines.length; i++) if (lines[i].includes(input.query)) {
63
+ if (matches.length === limit) { truncated = true; break; } matches.push({ path: file.path, line: i + 1, text: [...lines[i]].slice(0, 120).join('') });
64
+ } if (truncated) break;
65
+ }
66
+ return { matches, truncated, mode: 'literal' };
67
+ }
68
+ put(input) {
69
+ shape(input, ['relativePath', 'content'], ['expectedHash']); this.assertCurrent(); const path = scriptPath(input.relativePath);
70
+ if (path.split('/').some(p => p.startsWith('.arcfold-'))) fail('ARTIFACT_PATH', 'Reserved execution filename');
71
+ const draft = this.draft(), old = draft.files.find(f => f.path === path);
72
+ if (old ? input.expectedHash !== old.sha256 : input.expectedHash !== undefined && input.expectedHash !== null) fail('ARTIFACT_CONFLICT', 'writer_put must compare the exact previous file hash');
73
+ if (typeof input.content !== 'string') fail('ARTIFACT_CONTENT', 'Source text required');
74
+ const files = draft.files.filter(f => f.path !== path), size = Buffer.byteLength(input.content);
75
+ if (files.length + 1 > SCRIPT_LIMITS.files || files.reduce((n, f) => n + f.size, size) > SCRIPT_LIMITS.bytes) fail('ARTIFACT_LIMIT', 'Draft exceeds 16 files or 256 KiB');
76
+ const file = { path, ...this.artifacts.put(input.content) }; files.push(file); files.sort((a, b) => a.path.localeCompare(b.path));
77
+ this.change('put', () => ({ files }), draft.revision); return file;
78
+ }
79
+ artifact(entry, argSchema = emptyArgs) {
80
+ return { id: digest(['script-artifact', this.binding.scriptId, this.binding.scriptVersion ?? 1]), scriptId: this.binding.scriptId, methodId: this.binding.methodId, version: this.binding.scriptVersion ?? 1, language: 'node-esm', entry: scriptPath(entry), files: this.draft().files, argSchema, dryRunSupport: true,
81
+ buildRunId: this.binding.jobId, activationState: 'ready', draftVersion: this.binding.draftVersion, requestIndex: this.binding.requestIndex, timeoutMs: this.timeoutMs };
82
+ }
83
+ cancel() { for (const abort of this.active) abort.abort(); }
84
+ async probe(input, { signal } = {}) {
85
+ shape(input, ['entry', 'args']); this.assertCurrent(); if (Buffer.byteLength(canonicalJson(input.args)) > SCRIPT_LIMITS.args) fail('ARGS_INVALID', 'Probe input exceeds 64 KiB');
86
+ const artifact = this.artifact(input.entry), filesHash = digest(artifact.files), args = jsonClone(input.args), abort = new AbortController(); this.active.add(abort);
87
+ const combined = signal ? AbortSignal.any([signal, abort.signal]) : abort.signal;
88
+ const check = () => { this.assertCurrent(); if (digest(this.draft().files) !== filesHash) fail('ARTIFACT_CONFLICT', 'Draft changed during probe'); };
89
+ const watcher = setInterval(() => { try { check(); } catch { abort.abort(); } }, 25); watcher.unref();
90
+ const policy = processPolicy({ workspace: this.io.workspace, runtimeRoot: this.runtimeRoot, protectedRoots: [...this.protectedRoots, this.artifacts.directory], sessionPolicy: { mode: 'read-only', workspaceRoot: this.io.workspace } });
91
+ let result;
92
+ try { const scan = await this.scan(false, combined); result = await this.runner.run({ artifact, args, artifacts: this.artifacts, policy, readPaths: scan.manifest.files.map(f => f.path), readOnly: true, timeoutMs: 10000, signal: combined, assertCurrent: check }); }
93
+ catch (error) { result = { code: null, reason: 'probe_error', error: { code: error.code ?? 'PROBE_FAILED', message: error.message } }; }
94
+ finally { clearInterval(watcher); this.active.delete(abort); }
95
+ const probe = { id: digest([this.id, 'probe', this.draft().probes.length]), entry: input.entry, filesHash, args, argsHash: digest(args), result, readOnly: true, enforcement: this.runner.enforcement, at: this.repo.now() };
96
+ // Interrupted/replaced drafts retain the attempt, but can never finish it.
97
+ this.change('probe', row => ({ probes: [...row.probes, probe] })); return probe;
98
+ }
99
+ finish(input) {
100
+ shape(input, ['manifest', 'verified', 'notVerified']); this.assertCurrent({ finished: true });
101
+ if (this.active.size) fail('WRITER_BUSY', 'A running probe must finish before the artifact can be sealed');
102
+ const draft = this.draft(); if (draft.state === 'finished') { if (draft.finishHash !== digest(input)) fail('ARTIFACT_CONFLICT', 'Finished manifest cannot change'); return this.repo.get('tree_script_artifacts', draft.artifactId); }
103
+ shape(input.manifest, ['entry', 'files', 'argSchema', 'dryRunSupport', 'usage'], ['writePaths']); validateArgSchema(input.manifest.argSchema); text(input.manifest.usage, 'script usage', 2000);
104
+ const writePaths = input.manifest.writePaths ?? [];
105
+ if (!Array.isArray(writePaths) || writePaths.length > 64 || new Set(writePaths).size !== writePaths.length) fail('WRITER_MANIFEST', 'At most 64 distinct relative write files are allowed');
106
+ writePaths.forEach(relativeFile);
107
+ if (input.manifest.dryRunSupport !== true || !Array.isArray(input.manifest.files) || digest([...input.manifest.files].sort()) !== digest(draft.files.map(f => f.path).sort())) fail('WRITER_MANIFEST', 'Exact file list and dry-run contract required');
108
+ const claims = strings(input.verified), notVerified = strings(input.notVerified), artifact = this.artifact(input.manifest.entry, input.manifest.argSchema); this.artifacts.verify(artifact);
109
+ const successful = draft.probes.filter(p => p.entry === artifact.entry && p.filesHash === digest(artifact.files) && p.result.code === 0 && !p.result.reason && !p.result.truncated);
110
+ if (!successful.length) fail('PROBE_REQUIRED', 'Current exact artifact needs a successful confined read-only probe');
111
+ for (const probe of successful) normalizeArgs(artifact.argSchema, probe.args);
112
+ const finished = { ...artifact, writePaths: [...writePaths].sort(), usage: input.manifest.usage, probeIds: successful.map(p => p.id), verified: ['Exact artifact read-only probe completed; enforcement=' + this.runner.enforcement], authoredVerificationClaims: claims, notVerified: [...new Set([...notVerified, 'Real write behavior was not verified by the writer probe'])] };
113
+ if (finished.notVerified.length > 20) fail('WRITER_MANIFEST', 'At most 19 authored limitations plus the mandatory real-write limitation fit the final report; combine related notes without dropping limitations.');
114
+ this.repo.transaction(() => {
115
+ this.repo.command('finishTreeScriptArtifact', { requestId: digest(['finish-artifact', this.id]), expected: {}, input: { binding: this.binding, manifest: input, files: artifact.files } }, program, () => {
116
+ this.assertCurrent(); if (this.repo.get('tree_script_artifacts', artifact.id)) fail('ARTIFACT_CONFLICT', 'Artifact version already exists'); this.repo.save('tree_script_artifacts', finished); return { artifactId: artifact.id };
117
+ });
118
+ this.change('finish', () => ({ state: 'finished', artifactId: artifact.id, finishHash: digest(input) }), draft.revision);
119
+ }); return this.repo.get('tree_script_artifacts', artifact.id);
120
+ }
121
+ verifyFinal(output) {
122
+ this.assertCurrent({ finished: true }); const draft = this.draft(), artifact = this.repo.get('tree_script_artifacts', draft.artifactId);
123
+ if (draft.state !== 'finished' || !artifact || !Array.isArray(output.files) || digest(output.files.map(f => f.name).sort()) !== digest(artifact.files.map(f => f.path).sort()) || output.usage !== artifact.usage) fail('WRITER_FINAL_MISMATCH', 'Native final result must describe the exact finished artifact and usage');
124
+ this.artifacts.verify(artifact); return { artifactId: artifact.id, filesHash: digest(artifact.files), probeIds: artifact.probeIds, notVerified: artifact.notVerified };
125
+ }
126
+ completionTemplate() {
127
+ this.assertCurrent({ finished: true }); const draft = this.draft(), artifact = this.repo.get('tree_script_artifacts', draft.artifactId);
128
+ if (draft.state !== 'finished' || !artifact) fail('WRITER_FINAL_MISMATCH', 'Finish the artifact before requesting its final report');
129
+ this.artifacts.verify(artifact);
130
+ return { files: artifact.files.map(file => ({ name: file.path, purpose: '已封存的方法脚本文件' })),
131
+ usage: artifact.usage, stepsText: artifact.usage, verified: artifact.verified,
132
+ notVerified: artifact.notVerified, problems: '' };
133
+ }
134
+ /** B2 owns the native loop; this mounts six closures, never general tools. */
135
+ mount(agentContext, defineTool) {
136
+ const schemas = {
137
+ writer_read: { relativePath: { type: 'string', required: true }, offset: { type: 'integer' }, limit: { type: 'integer' } },
138
+ writer_list: { relativePath: { type: 'string' }, offset: { type: 'integer' }, limit: { type: 'integer' } },
139
+ writer_search: { query: { type: 'string', required: true }, relativePath: { type: 'string' }, limit: { type: 'integer' } },
140
+ writer_put: { relativePath: { type: 'string', required: true }, content: { type: 'string', required: true }, expectedHash: { type: 'string' } },
141
+ writer_probe: { entry: { type: 'string', required: true }, args: { type: 'object', additionalProperties: true, required: true } },
142
+ writer_finish: { manifest: { type: 'object', additionalProperties: false, required: true, properties: { entry: { type: 'string', required: true }, files: { type: 'array', items: { type: 'string' }, required: true }, argSchema: { type: 'object', additionalProperties: true, required: true }, dryRunSupport: { type: 'boolean', required: true }, usage: { type: 'string', required: true }, writePaths: { type: 'array', items: { type: 'string' } } } }, verified: { type: 'array', items: { type: 'string' }, required: true }, notVerified: { type: 'array', items: { type: 'string' }, required: true } },
143
+ };
144
+ for (const name of WRITER_TOOLS) agentContext.tools.register(defineTool({ name, description: ({ writer_read: 'Read bounded UTF-8 workspace content. Paths are relative.', writer_list: 'List allowed workspace files. Omit relativePath or use an empty string for the root; do not use dot.', writer_search: 'Search allowed workspace text literally.', writer_put: 'Write one .mjs draft file; replacements require its previous hash.', writer_probe: 'Run this draft read-only for up to ten seconds. args is the JSON business argument object, not command-line flags. The host writes it to a temporary file outside the workspace and invokes entry --input <that-file> --dry-run. The script must parse that JSON, then resolve business file paths from its fields against process.cwd(). Do not treat the --input file itself as a workspace business file.', writer_finish: 'Finish the exact files with entry, closed argSchema for the JSON business arguments (not argv), dryRunSupport:true, usage, writePaths (at most 64 relative output files, no directories; omitted means read-only), and verification limits. The host intersects this declaration with current user permissions; it grants none. writePaths are business outputs, not private script files. ' + ARG_SCHEMA_GUIDANCE })[name],
145
+ parameters: schemas[name], output: { schema: { type: 'json' }, render: (_args, result) => [{ type: 'text', text: JSON.stringify(result) },
146
+ ...(name === 'writer_finish' ? [{ type: 'text', text: '作者任务已经封存。下一条直接返回完整最终JSON;usage必须逐字复制,files必须包含这些原文件名。可以补充文件用途和操作说明,但不能改写usage或声称已验证真实写入。完整模板:' + JSON.stringify(this.completionTemplate()) }] : [])] },
147
+ execute: async (args, context) => this[name.slice('writer_'.length)](args, { signal: context?.signal }) }));
148
+ }
149
+ }
@@ -0,0 +1,208 @@
1
+ import { constants, closeSync, fstatSync, mkdirSync, mkdtempSync, openSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs';
2
+ import { dirname, join } from 'node:path';
3
+ import { pathToFileURL } from 'node:url';
4
+ import { canonicalJson, digest, fail, shape, integer, jsonClone } from './contracts.mjs';
5
+ import { contentHash, processPolicy, confinedArgv, spawnConfined } from './host-process.mjs';
6
+ import { relativeFile } from './snapshots.mjs';
7
+ import { ItemService } from './items.mjs';
8
+
9
+ const program = { kind: 'program', id: 'tree-script-broker' };
10
+ export const SCRIPT_LIMITS = Object.freeze({ files: 16, bytes: 262144, args: 65536, timeoutMs: 30000, maximumTimeoutMs: 120000 });
11
+ export const ARG_SCHEMA_FIELDS = Object.freeze({ object: ['type', 'properties', 'required', 'additionalProperties'], array: ['type', 'items', 'maxItems'], string: ['type', 'maxLength', 'enum'], boolean: ['type'], integer: ['type', 'minimum', 'maximum'], number: ['type', 'minimum', 'maximum'] });
12
+ export const ARG_SCHEMA_GUIDANCE = `argSchema is a bounded subset, not arbitrary JSON Schema. Allowed keys by type: ${canonicalJson(ARG_SCHEMA_FIELDS)}. Every object requires properties (at most32 keys), required:[], and additionalProperties:false. Each string REQUIRES integer maxLength0..65536; optional enum has at most256 strings. Do NOT add minLength, pattern, default, description, $schema or $ref. Arrays require maxItems0..256 and items. Numbers/integers require finite minimum and maximum. Boolean has only type. Nesting at most6. Example: {"type":"object","properties":{"source":{"type":"string","maxLength":512},"destination":{"type":"string","maxLength":512}},"required":["source","destination"],"additionalProperties":false}. All successful probe.args must validate against the final schema; do not retroactively require untested fields.`;
13
+ export function scriptPath(path) { relativeFile(path); if (!path.endsWith('.mjs')) fail('SCRIPT_LANGUAGE', 'Only Node ESM .mjs files are supported'); return path; }
14
+
15
+ /** A closed, bounded JSON-schema subset; no references, coercion or regex. */
16
+ export function validateArgSchema(schema, depth = 0) {
17
+ if (depth > 6 || !schema || typeof schema !== 'object' || Array.isArray(schema)) fail('ARG_SCHEMA', 'Bounded argument schema required');
18
+ const allowed = ARG_SCHEMA_FIELDS[schema.type];
19
+ if (!allowed || Object.keys(schema).some(k => !allowed.includes(k))) fail('ARG_SCHEMA', `Unsupported ${schema.type} argument schema keys; allowed: ${(allowed ?? []).join(', ')}. ${ARG_SCHEMA_GUIDANCE}`);
20
+ if (schema.type === 'object') {
21
+ if (!schema.properties || typeof schema.properties !== 'object' || Array.isArray(schema.properties) || Object.keys(schema.properties).length > 32 || schema.additionalProperties !== false || !Array.isArray(schema.required) || schema.required.some(k => !Object.hasOwn(schema.properties, k))) fail('ARG_SCHEMA', 'Closed object properties and required fields must be explicit');
22
+ for (const [key, value] of Object.entries(schema.properties)) { if (['__proto__', 'constructor', 'prototype'].includes(key)) fail('ARG_SCHEMA', 'Unsafe property'); validateArgSchema(value, depth + 1); }
23
+ } else if (schema.type === 'array') { integer(schema.maxItems, 'maxItems', 0, 256); validateArgSchema(schema.items, depth + 1); }
24
+ else if (schema.type === 'string') { if (!Number.isInteger(schema.maxLength) || schema.maxLength < 0 || schema.maxLength > 65536) fail('ARG_SCHEMA', 'Every string requires integer maxLength between 0 and 65536; minLength is not supported.'); if (schema.enum !== undefined && (!Array.isArray(schema.enum) || schema.enum.length > 256 || schema.enum.some(v => typeof v !== 'string' || [...v].length > schema.maxLength))) fail('ARG_SCHEMA', 'Invalid string enumeration'); }
25
+ else if (['integer', 'number'].includes(schema.type)) { if (!Number.isFinite(schema.minimum) || !Number.isFinite(schema.maximum) || schema.minimum > schema.maximum) fail('ARG_SCHEMA', 'Finite numeric bounds required'); }
26
+ return schema;
27
+ }
28
+ export function normalizeArgs(schema, args) {
29
+ validateArgSchema(schema); const bytes = canonicalJson(args); if (Buffer.byteLength(bytes) > SCRIPT_LIMITS.args) fail('ARGS_INVALID', 'Arguments exceed 64 KiB');
30
+ const check = (s, value) => {
31
+ if (s.type === 'object') { if (!value || typeof value !== 'object' || Array.isArray(value) || Object.keys(value).some(k => !Object.hasOwn(s.properties, k)) || s.required.some(k => !Object.hasOwn(value, k))) fail('ARGS_INVALID', 'Unexpected or missing argument'); for (const [k, v] of Object.entries(value)) check(s.properties[k], v); }
32
+ else if (s.type === 'array') { if (!Array.isArray(value) || value.length > s.maxItems) fail('ARGS_INVALID', 'Invalid bounded array'); value.forEach(v => check(s.items, v)); }
33
+ else if (s.type === 'string') { if (typeof value !== 'string' || [...value].length > s.maxLength || s.enum && !s.enum.includes(value)) fail('ARGS_INVALID', 'Invalid bounded string'); }
34
+ else if (s.type === 'boolean') { if (typeof value !== 'boolean') fail('ARGS_INVALID', 'Boolean required'); }
35
+ else if (!Number.isFinite(value) || s.type === 'integer' && !Number.isSafeInteger(value) || value < s.minimum || value > s.maximum) fail('ARGS_INVALID', 'Number outside declared bounds');
36
+ };
37
+ check(schema, args); return JSON.parse(bytes);
38
+ }
39
+
40
+ /** Private content-addressed bytes; no model-selected host path is persisted. */
41
+ export class ScriptArtifacts {
42
+ constructor({ repository, directory, resolveSource, assertBuild }) { this.repo = repository; mkdirSync(directory, { recursive: true, mode: 0o700 }); this.directory = realpathSync(directory); this.resolveSource = resolveSource;
43
+ this.assertBuild = assertBuild ?? ((id, artifact) => { const job = this.repo.get('tree_jobs', id); return job?.role === 'script_writer' && job.status === 'committed' && job.resultRef?.artifactId === artifact.id && job.resultRef?.filesHash === digest(artifact.files); }); }
44
+ sourcePath(hash) { if (!/^[a-f0-9]{64}$/.test(hash)) fail('ARTIFACT_HASH', 'Invalid content identity'); return join(this.directory, hash + '.mjs'); }
45
+ put(content) {
46
+ if (typeof content !== 'string' || content.includes('\0') || Buffer.from(content).toString('utf8') !== content || Buffer.byteLength(content) > SCRIPT_LIMITS.bytes) fail('ARTIFACT_CONTENT', 'Bounded UTF-8 source text required');
47
+ const sha256 = contentHash(content), path = this.sourcePath(sha256);
48
+ try { writeFileSync(path, content, { flag: 'wx', mode: 0o400 }); } catch (error) { if (error.code !== 'EEXIST') throw error; }
49
+ this.read({ sha256, size: Buffer.byteLength(content) }); return { sha256, size: Buffer.byteLength(content) };
50
+ }
51
+ read(file) {
52
+ const path = this.sourcePath(file.sha256), fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW);
53
+ try { const stat = fstatSync(fd); if (!stat.isFile() || stat.nlink !== 1 || stat.size > SCRIPT_LIMITS.bytes || stat.size !== file.size) fail('ARTIFACT_HASH', 'Artifact file identity changed'); const bytes = readFileSync(fd); if (contentHash(bytes) !== file.sha256) fail('ARTIFACT_HASH', 'Artifact bytes changed'); return bytes; }
54
+ finally { closeSync(fd); }
55
+ }
56
+ verify(artifact) {
57
+ if (artifact?.language !== 'node-esm' || !Array.isArray(artifact.files) || !artifact.files.length || artifact.files.length > SCRIPT_LIMITS.files) fail('ARTIFACT_INVALID', 'Bounded Node artifact required');
58
+ const names = new Set(); let bytes = 0;
59
+ for (const file of artifact.files) { scriptPath(file.path); if (names.has(file.path)) fail('ARTIFACT_INVALID', 'Duplicate artifact path'); names.add(file.path); bytes += this.read(file).length; }
60
+ if (bytes > SCRIPT_LIMITS.bytes || !names.has(artifact.entry)) fail('ARTIFACT_INVALID', 'Entry or size invalid'); validateArgSchema(artifact.argSchema);
61
+ return { id: artifact.id, revision: artifact.revision, filesHash: digest(artifact.files) };
62
+ }
63
+ activate({ methodId, draftVersion, artifactIds, requestId }) {
64
+ const item = this.repo.get('tree_items', methodId), bundle = this.repo.get('tree_methods', digest(['method', methodId, draftVersion]));
65
+ if (!item || !bundle) fail('METHOD_MISSING', 'Draft method missing');
66
+ const artifacts = artifactIds.map(id => this.repo.get('tree_script_artifacts', id));
67
+ if (artifacts.some(a => !a || this.assertBuild(a.buildRunId, a) !== true)) fail('ARTIFACT_BUILD_REQUIRED', 'The exact native writer job must commit its final result before activation');
68
+ if (new Set(artifacts.map(a => a?.requestIndex)).size !== bundle.pendingScriptRequests.length || artifacts.some(a => a?.requestIndex < 0 || a?.requestIndex >= bundle.pendingScriptRequests.length || a?.draftVersion !== draftVersion)) fail('BUNDLE_INCOMPLETE', 'Each requested script must have its own completed artifact');
69
+ const verifiedArtifacts = artifacts.map(a => this.verify(a)), service = new ItemService({ repository: this.repo, resolveSource: this.resolveSource, context: () => ({ actor: program, verifiedArtifacts }) });
70
+ const version = this.repo.get('tree_item_versions', digest(['item_version', methodId, draftVersion]));
71
+ if (!version || version.sourceRefs.some(ref => !this.resolveSource(ref))) fail('SOURCE_STALE', 'Draft evidence was withdrawn');
72
+ return service.activateDraft({ requestId, expected: { ['tree_items:' + methodId]: item.revision }, input: { itemId: methodId, version: draftVersion, artifactIds } });
73
+ }
74
+ }
75
+
76
+ // Module resolution is a dependency rule, not an OS security boundary. The
77
+ // deny-by-default process policy remains authoritative even for eval or native
78
+ // builtin access. Normal imports can use only fixed Node builtins or this bundle.
79
+ function loaderSource(urls) {
80
+ return `import { registerHooks, isBuiltin } from 'node:module';\nimport { pathToFileURL } from 'node:url';\nconst allowed = new Set(${JSON.stringify(urls)});\nregisterHooks({ resolve(specifier, context, next) { if (!isBuiltin(specifier)) { let url; if (specifier.startsWith('/')) url = pathToFileURL(specifier).href; else if (specifier.startsWith('./') || specifier.startsWith('../') || specifier.startsWith('file:')) url = new URL(specifier, context.parentURL).href; if (!allowed.has(url)) throw new Error('SCRIPT_DEPENDENCY_DENIED'); } const result = next(specifier, context); if (!isBuiltin(result.url) && !allowed.has(result.url)) throw new Error('SCRIPT_DEPENDENCY_DENIED'); return result; }, load(url, context, next) { if (!isBuiltin(url) && !allowed.has(url)) throw new Error('SCRIPT_DEPENDENCY_DENIED'); return next(url, context); } });\n`;
81
+ }
82
+
83
+ /** Only this owned execution path copies artifact bytes across the private fence. */
84
+ export class ConfinedScriptRunner {
85
+ enforcement = 'full';
86
+ async run({ artifact, args, artifacts, policy, readPaths, readOnly = false, timeoutMs = SCRIPT_LIMITS.timeoutMs, signal, assertCurrent, beforeSpawn = () => {} }) {
87
+ if (!Array.isArray(readPaths)) fail('SCRIPT_READ_SCOPE', 'An inspected ordinary-file read set is required');
88
+ const reads = [...new Set(readPaths.map(p => join(policy.workspace, relativeFile(p))))];
89
+ artifacts.verify(artifact); const root = mkdtempSync(join(policy.runtimeRoot, 'method-'));
90
+ try {
91
+ const urls = [];
92
+ for (const file of artifact.files) { const target = join(root, file.path); mkdirSync(dirname(target), { recursive: true, mode: 0o700 }); writeFileSync(target, artifacts.read(file), { flag: 'wx', mode: 0o400 }); urls.push(pathToFileURL(target).href); }
93
+ const loader = join(root, '.arcfold-loader.mjs'), input = join(root, '.arcfold-input.json');
94
+ writeFileSync(loader, loaderSource(urls), { flag: 'wx', mode: 0o400 }); writeFileSync(input, canonicalJson(args), { flag: 'wx', mode: 0o400 });
95
+ const effective = { ...policy, writeRoots: readOnly ? [] : policy.writeRoots, immutableRoots: [...policy.immutableRoots, ...artifact.files.map(f => join(root, f.path)), loader, input] };
96
+ // Do not grant the entire workspace: credentials, private state, excluded
97
+ // directories, later-created files and uninspected links stay unreadable.
98
+ const wrapped = confinedArgv({ policy: { ...effective, workspace: root }, runDirectory: root, argv: [process.execPath, '--max-old-space-size=256', '--import', loader, join(root, artifact.entry), '--input', input, ...(readOnly ? ['--dry-run'] : [])] });
99
+ const exclusions = [...policy.protectedRoots, policy.runtimeRoot].map(p => `(require-not (subpath ${JSON.stringify(p)}))`).join(' ');
100
+ // Reading the root directory itself is needed by macOS getcwd; a literal
101
+ // directory grant does not authorize reading its descendant file bytes.
102
+ wrapped.argv[2] += '\n(allow file-read* ' + [policy.workspace, ...reads].map(p => `(require-all (literal ${JSON.stringify(p)}) ${exclusions})`).join(' ') + ')';
103
+ const profileHash = digest(wrapped.argv[2]), profileFile = join(root, '.arcfold-policy.sb');
104
+ writeFileSync(profileFile, wrapped.argv[2], { flag: 'wx', mode: 0o400 }); wrapped.argv[1] = '-f'; wrapped.argv[2] = profileFile;
105
+ signal?.throwIfAborted(); await assertCurrent(); signal?.throwIfAborted(); await beforeSpawn({ profileHash, readPaths, argvHash: digest(wrapped.argv) });
106
+ return await spawnConfined(wrapped, { cwd: policy.workspace, runDirectory: root, signal, timeoutMs: readOnly ? Math.min(timeoutMs, 10000) : timeoutMs, outputLimit: readOnly ? 65536 : 1048576, assertCurrent });
107
+ } finally { rmSync(root, { recursive: true, force: true }); }
108
+ }
109
+ }
110
+
111
+ export class MethodScriptBroker {
112
+ constructor({ repository, artifacts, snapshots, context, readScope, resolveSource, authorizeWrites, runtimeRoot, protectedRoots, runner = new ConfinedScriptRunner(), allowUnconfinedFixture = false }) {
113
+ if ([context, readScope, resolveSource, authorizeWrites].some(f => typeof f !== 'function')) fail('SCRIPT_HOST', 'Trusted authority, scope, sources and write authorization are required');
114
+ Object.assign(this, { repo: repository, artifacts, snapshots, context, readScope, resolveSource, authorizeWrites, runtimeRoot, protectedRoots, runner });
115
+ if (runner.enforcement !== 'full' && !allowUnconfinedFixture) fail('SANDBOX_UNAVAILABLE', 'Scripts require full process confinement'); this.active = new Map();
116
+ }
117
+ freeze(input) {
118
+ shape(input, ['methodId', 'expectedVersion', 'scriptId', 'args']); const ctx = this.context();
119
+ if (ctx.actor?.kind !== 'model' || ctx.actor.role !== 'main' || !ctx.sessionId || !ctx.nativeToolCallId) fail('MAIN_REQUIRED', 'Only an owned main-agent tool call may execute a method script');
120
+ const scope = this.readScope(ctx.sessionId), item = this.repo.get('tree_items', input.methodId), bundle = this.repo.get('tree_methods', digest(['method', input.methodId, input.expectedVersion]));
121
+ if (!scope || scope.phase !== 'active' || !scope.nodeIds.length) fail('SCOPE_STALE', 'No admitted main scope');
122
+ if (!item || item.kind !== 'method' || item.lifecycle !== 'active' || item.stale || item.activeVersion !== input.expectedVersion || !bundle || bundle.pendingScriptRequests.length) fail('METHOD_STALE', 'Method is not currently executable');
123
+ if (!scope.nodeIds.some(id => this.repo.ancestors(id).some(n => n.id === item.nodeId))) fail('OUT_OF_SCOPE', 'Method is outside current node ancestry');
124
+ const version = this.repo.get('tree_item_versions', digest(['item_version', item.id, item.activeVersion])); if (!version || version.sourceRefs.some(ref => !this.resolveSource(ref))) fail('SOURCE_STALE', 'Method source is no longer eligible');
125
+ const ref = bundle.scripts.find(s => s.id === input.scriptId), artifact = ref && this.repo.list('tree_script_artifacts', a => a.scriptId === ref.id && a.version === ref.version && a.methodId === item.id)[0];
126
+ if (!artifact || artifact.activationState !== 'ready' || digest(artifact.files) !== ref.hash) fail('ARTIFACT_STALE', 'No active artifact version'); this.artifacts.verify(artifact);
127
+ const args = normalizeArgs(artifact.argSchema, input.args), authorization = this.authorizeWrites({ context: ctx, scope, item, artifact, args });
128
+ if (!authorization || authorization.then || !Array.isArray(authorization.paths) || !authorization.authorizationRef) fail('SCRIPT_AUTHORIZATION', 'Synchronous host write authorization required');
129
+ const paths = [...new Set(authorization.paths.map(relativeFile))].sort();
130
+ const policy = processPolicy({ workspace: this.snapshots.workspace, runtimeRoot: this.runtimeRoot, protectedRoots: [...this.protectedRoots, this.artifacts.directory, this.snapshots.storageRoot], sessionPolicy: ctx.sessionPolicy, authorizedWriteRoots: paths.map(p => join(this.snapshots.workspace, p)) });
131
+ // Read-only calls may inspect data but cannot acquire any declared writes.
132
+ if (paths.length && !policy.writeRoots.length) fail('SCRIPT_POLICY', 'Current session does not authorize these writes');
133
+ const ticket = { sessionId: ctx.sessionId, nativeToolCallId: ctx.nativeToolCallId, actor: ctx.actor, scope, itemId: item.id, methodVersion: item.activeVersion, itemRevision: item.revision, artifactId: artifact.id, artifactRevision: artifact.revision, filesHash: digest(artifact.files), argsHash: digest(args), authorization, policyHash: digest(policy), workspaceIdentity: this.snapshots.identity };
134
+ return { ticket: jsonClone({ ...ticket, key: digest(ticket) }), artifact, args, paths, policy };
135
+ }
136
+ assertCurrent(frozen, input) { this.snapshots.assertIdentity(); if (this.freeze(input).ticket.key !== frozen.ticket.key) fail('SCRIPT_STALE', 'Execution authority changed'); }
137
+ update(id, updates) {
138
+ const old = this.repo.get('tree_script_runs', id); if (!old) fail('SCRIPT_RUN', 'Missing durable run intent');
139
+ this.repo.command('recordTreeScriptResult', { requestId: digest([id, old.revision, updates]), expected: {}, input: { id, ...updates } }, program, () => {
140
+ const row = this.repo.get('tree_script_runs', id); if (row.revision !== old.revision) fail('REVISION_CONFLICT', 'Script result changed');
141
+ this.repo.save('tree_script_runs', { ...row, ...updates, ...(row.state === 'revoked' ? { state: 'revoked' } : {}) }, row.revision); return { runId: id };
142
+ }); return this.repo.get('tree_script_runs', id);
143
+ }
144
+ invalidate() { for (const run of this.active.values()) { try { this.assertCurrent(run.frozen, run.input); } catch { run.abort.abort(); } } }
145
+ cancelSession(sessionId) { for (const run of this.active.values()) if (run.frozen.ticket.sessionId === sessionId) run.abort.abort(); }
146
+ async run(input, { signal } = {}) {
147
+ input = jsonClone(input); const frozen = this.freeze(input), id = digest(['script-run', frozen.ticket.sessionId, frozen.ticket.nativeToolCallId]);
148
+ if (this.active.has(id)) fail('SCRIPT_ACTIVE', 'This native tool call is already executing');
149
+ const previous = this.repo.get('tree_script_runs', id);
150
+ if (previous) { if (previous.inputHash !== digest(input)) fail('IDEMPOTENCY_CONFLICT', 'Native tool call changed'); return previous; }
151
+ const abort = new AbortController(), combined = signal ? AbortSignal.any([signal, abort.signal]) : abort.signal; this.active.set(id, { abort, frozen, input });
152
+ // Native withdrawal/scope events call invalidate immediately. This bounded
153
+ // watcher also catches changes committed by another session/process.
154
+ const watcher = setInterval(() => this.invalidate(), 25); watcher.unref();
155
+ try {
156
+ return await this.snapshots.locked(async () => {
157
+ combined.throwIfAborted(); this.snapshots.assertAvailable(); this.assertCurrent(frozen, input);
158
+ const snapshot = await this.snapshots.captureLocked({ requestId: id, reason: 'script', protectedPaths: frozen.paths, signal: combined });
159
+ combined.throwIfAborted(); this.assertCurrent(frozen, input);
160
+ this.repo.command('prepareTreeScript', { requestId: id, expected: {}, input: { input, ticket: frozen.ticket, snapshotId: snapshot.id } }, program, () => {
161
+ this.repo.save('tree_script_runs', { id, runId: id, methodVersion: { itemId: input.methodId, version: input.expectedVersion }, nodeEpoch: frozen.ticket.scope.epoch, workspaceIdentity: frozen.ticket.workspaceIdentity,
162
+ policyHash: frozen.ticket.policyHash, snapshotId: snapshot.id, argvHash: digest({ interpreter: process.execPath, artifact: frozen.ticket.filesHash, entry: frozen.artifact.entry, args: frozen.args }), inputHash: digest(input), ticket: frozen.ticket, state: 'prepared', processState: 'prepared' }); return { runId: id };
163
+ });
164
+ try {
165
+ const readPaths = [...new Set([...snapshot.manifest.files.map(f => f.path), ...frozen.paths])];
166
+ const result = await this.runner.run({ ...frozen, readPaths, artifacts: this.artifacts, signal: combined, timeoutMs: frozen.artifact.timeoutMs ?? SCRIPT_LIMITS.timeoutMs,
167
+ assertCurrent: () => this.assertCurrent(frozen, input), beforeSpawn: evidence => this.update(id, { state: 'spawn_intent', processState: 'spawn_intent', executionPolicy: evidence, policyHash: digest({ policy: frozen.policy, ...evidence }) }) });
168
+ return this.update(id, { state: result.code === 0 && !result.reason && !result.truncated ? 'succeeded' : result.reason === 'cancelled' ? 'cancelled' : 'failed', processState: 'completed', result, completedAt: this.repo.now() });
169
+ } catch (error) {
170
+ const current = this.repo.get('tree_script_runs', id), notStarted = current.processState === 'prepared'; return this.update(id, { state: notStarted ? 'failed' : 'unknown', processState: notStarted ? 'not_started' : 'unknown', error: { code: error.code ?? 'SCRIPT_PROCESS', message: error.message }, completedAt: this.repo.now() });
171
+ }
172
+ });
173
+ } finally { clearInterval(watcher); this.active.delete(id); }
174
+ }
175
+ recover(id) {
176
+ if (this.active.has(id)) fail('SCRIPT_ACTIVE', 'Current process still owns this execution'); const run = this.repo.get('tree_script_runs', id);
177
+ if (!run) fail('SCRIPT_RUN', 'Unknown execution');
178
+ // A completed process receipt is written in the same command as its result.
179
+ // Without it, preserve uncertainty; never run the generated program again.
180
+ const pending = ['prepared', 'spawn_intent'].includes(run.processState ?? run.state);
181
+ return pending ? this.update(id, { state: (run.processState ?? run.state) === 'prepared' ? 'failed' : 'unknown', processState: (run.processState ?? run.state) === 'prepared' ? 'not_started' : 'unknown', error: { code: 'HOST_INTERRUPTED', message: 'No durable completed process result' } }) : run;
182
+ }
183
+ }
184
+
185
+ /** Public tool surface; bind only to B7's admitted main-agent instance. */
186
+ export function mountMethodScriptTool(agentContext, { host, defineTool, isMain, resolveBroker }) {
187
+ if ([defineTool, isMain, resolveBroker].some(fn => typeof fn !== 'function')) fail('SCRIPT_HOST', 'Native tool registration requires trusted main ownership');
188
+ agentContext.tools.register(defineTool({ name: 'arcfold_run_method_script', description: 'Execute one active method script in the current goal scope. The host checks its exact version, current file permissions and snapshot coverage. This does not grant new permissions.',
189
+ parameters: { methodId: { type: 'string', required: true }, expectedVersion: { type: 'integer', required: true }, scriptId: { type: 'string', required: true }, args: { type: 'object', additionalProperties: true, required: true } },
190
+ output: { schema: { type: 'json' }, render: (_args, result) => [{ type: 'text', text: JSON.stringify(result) }] },
191
+ async execute(input, exec) {
192
+ const check = () => {
193
+ exec.signal?.throwIfAborted(); const agent = exec.agent;
194
+ if (!agent || host.agents.get(agent.id) !== agent || host.agents.currentInitiator() !== agent || agent.status !== 'running' || isMain(agent.session.id) !== true || exec.parent !== undefined || exec.callId !== exec.rootCallId) fail('MAIN_REQUIRED', 'Only the exact live native main tool call may cross the script boundary');
195
+ return agent;
196
+ };
197
+ const agent = check(); if (await host.sessions.flush(agent.session) !== true) fail('SCRIPT_NATIVE_EVIDENCE', 'The native tool call must be durable before script execution'); check();
198
+ const events = agent.session.snapshotEvents(), call = events.filter(e => e.type === 'tool/call' && e.data.callId === exec.callId);
199
+ const step = events.findLast(e => e.type === 'step/start'), turn = events.findLast(e => e.type === 'turn/start'), assistant = call.length === 1 && events.findLast(e => e.type === 'assistant/message' && e.seq < call[0].seq);
200
+ const block = assistant?.data.message.content?.filter(b => b.type === 'tool-call' && b.id === exec.callId);
201
+ if (call.length !== 1 || !step || !turn || !assistant || assistant.data.message.source?.kind !== 'model' || assistant.data.interrupted || block?.length !== 1 || block[0].name !== 'arcfold_run_method_script' || call[0].data.name !== block[0].name || call[0].data.arguments !== block[0].arguments || canonicalJson(JSON.parse(call[0].data.arguments)) !== canonicalJson(input) || canonicalJson(exec.arguments) !== canonicalJson(input) || step.seq <= turn.seq || assistant.seq <= step.seq || call[0].data.turn !== turn.data.turn || call[0].data.step !== step.data.step || events.some(e => e.seq > step.seq && ['step/end', 'turn/end'].includes(e.type)) || events.some(e => e.seq > call[0].seq && e.type === 'tool/result' && e.data.message?.toolCallId === exec.callId)) fail('SCRIPT_NATIVE_EVIDENCE', 'Script arguments and identity must match one unfinished durable native tool call');
202
+ const broker = resolveBroker({ agent, sessionId: agent.session.id, callId: exec.callId, turn: turn.data.turn, step: step.data.step });
203
+ if (!(broker instanceof MethodScriptBroker)) fail('SCRIPT_HOST', 'No trusted script broker for this main call');
204
+ const run = await broker.run(input, { signal: exec.signal });
205
+ return { runId: run.id, state: run.state, processState: run.processState, snapshotId: run.snapshotId, coverage: broker.snapshots.snapshot(run.snapshotId).coverage, result: run.result ?? null, error: run.error ?? null };
206
+ },
207
+ }));
208
+ }
@@ -0,0 +1,118 @@
1
+ import { canonicalJson, digest, fail, oneOf, sourceRefs } from './contracts.mjs';
2
+ import { SignalService } from './tree.mjs';
3
+
4
+ export const signalPolarity = category => category === 'proceeded' ? 'weak' : ['approved', 'adopted_proposal', 'confirmed_inference'].includes(category) ? 'strong' : 'negative';
5
+ const program = { kind: 'program', id: 'tree-signal-evidence' };
6
+ const unique = values => [...new Map(values.map(v => [canonicalJson(v), v])).values()];
7
+ export function evidenceResolver(repo, nativeResolve) {
8
+ return ref => {
9
+ if (ref.sessionId !== 'arcfold-ui:' + repo.rootId()) return nativeResolve(ref);
10
+ const event = repo.list('tree_change_events', e => e.ordinal === ref.seq)[0];
11
+ if (!event || event.actor.kind !== 'user' || digest(event) !== ref.sha256) return null;
12
+ const receipt = repo.get('tree_receipts', event.receiptId);
13
+ return { id: digest(['ui-source', ref]), role: 'user', origin: 'ui', text: canonicalJson({ operation: event.commandType, entityIds: event.entityIds, result: receipt.result }), ref, sha256: ref.sha256 };
14
+ };
15
+ }
16
+
17
+ /** Corrections replace an observation in derived views, never its audit row. */
18
+ export function effectiveSignals(repo, resolveSource = () => true) {
19
+ const rows = repo.list('tree_signals'), byId = new Map(rows.map(r => [r.id, r]));
20
+ const ordinals = new Map();
21
+ for (const event of repo.list('tree_change_events')) for (const [index, key] of event.entityIds.entries()) if (key.startsWith('tree_signals:') && event.beforeVersions[index] === 0) ordinals.set(key.slice('tree_signals:'.length), event.ordinal);
22
+ const root = row => { const seen = new Set(); while (row.correctionOf) { if (seen.has(row.id) || !byId.has(row.correctionOf)) fail('SIGNAL_HISTORY', 'Broken correction chain'); seen.add(row.id); row = byId.get(row.correctionOf); } return row.id; };
23
+ const latest = new Map();
24
+ for (const row of rows) { const key = root(row), old = latest.get(key); if (!old || (ordinals.get(row.id) ?? 0) > (ordinals.get(old.id) ?? 0)) latest.set(key, row); }
25
+ return [...latest.values()].filter(r => r.sourceRefs.every(resolveSource));
26
+ }
27
+
28
+ /** One human message is one episode even when several nodes learn from it. */
29
+ export class EpisodeSignals {
30
+ constructor({ repository, resolveSource, experience }) { Object.assign(this, { repo: repository, resolveSource, experience }); }
31
+ recordCategories(categories, { job, requestId }) {
32
+ const actor = { kind: 'model', id: job.id, role: job.role }, nodeIds = job.nodeIds;
33
+ const signalService = new SignalService({ repository: this.repo, resolveSource: this.resolveSource, context: () => ({ actor, nodeIds }) });
34
+ return this.repo.transaction(() => categories.map((category, index) => {
35
+ const source = job.input.sources.find(s => s.id === category.sourceId);
36
+ if (!source || source.role !== 'user' || source.origin !== 'user' || !job.input.currentTurn?.sourceIds.includes(source.id) || !this.resolveSource(source.ref)) fail('SIGNAL_USER_REQUIRED', 'Categories must refer to the current real human message');
37
+ const turn = this.repo.list('tree_turns', t => t.status === 'settled' && t.userSourceRefs.some(r => digest(r) === digest(source.ref)))[0];
38
+ if (!turn || !turn.nodeIds.some(id => nodeIds.includes(id))) fail('SIGNAL_SCOPE', 'Referenced message must belong to settled experience in this scope');
39
+ const eventId = digest(['human-message', source.ref]), episodeId = digest(['episode', eventId]);
40
+ const signal = signalService.record({ requestId: digest([requestId, 'category', index]), expected: {}, input: { eventId, subjectRefs: category.subjectIds,
41
+ dimension: 'collaboration', subitem: category.category, category: category.category, polarity: signalPolarity(category.category), evidenceKind: 'language', sourceRefs: [source.ref], nodeIds: turn.nodeIds, confidence: 1 } }).result;
42
+ this.repo.command('recordSignalEpisode', { requestId: digest([requestId, 'episode', index]), expected: {}, input: { episodeId, signalId: signal.signalId, note: category.note } }, actor, () => {
43
+ const old = this.repo.get('tree_episodes', episodeId);
44
+ const previous = this.repo.list('tree_turns', t => t.sessionId === turn.sessionId && t.nativeTurn < turn.nativeTurn && t.status === 'settled').sort((a, b) => b.nativeTurn - a.nativeTurn)[0];
45
+ const beforeRefs = previous?.nodeIds.some(id => turn.nodeIds.includes(id)) ? this.experience.material(previous).sources.filter(s => s.role === 'assistant' && s.origin === 'model').map(s => s.ref) : [];
46
+ const resolutionRefs = this.experience.material(turn).sources.filter(s => s.role === 'assistant' && s.origin === 'model').map(s => s.ref);
47
+ this.repo.save('tree_episodes', { ...old, id: episodeId, eventId, turnId: turn.id, signalIds: [...new Set([...(old?.signalIds ?? []), signal.signalId])],
48
+ beforeTurnId: previous?.id ?? null, beforeRefs, reactionRefs: [source.ref], resolutionRefs, nodeIds: turn.nodeIds, state: 'observed', start: old?.start ?? this.repo.now(), ordinal: old?.ordinal ?? this.repo.meta().lastEventOrdinal + 1,
49
+ annotations: [...(old?.annotations ?? []), { jobId: job.id, category: category.category, note: category.note }] }, old?.revision ?? null);
50
+ return { episodeId };
51
+ });
52
+ return { ...signal, episodeId };
53
+ }));
54
+ }
55
+ episodes() {
56
+ const signals = effectiveSignals(this.repo, this.resolveSource);
57
+ return this.repo.list('tree_episodes').map(e => {
58
+ const turn = e.turnId && this.repo.get('tree_turns', e.turnId);
59
+ const before = e.beforeTurnId && this.repo.get('tree_turns', e.beforeTurnId);
60
+ // Preserve stored audit refs, but remove a preceding answer from derived
61
+ // input when it no longer shares the corrected current assignment.
62
+ const beforeRefs = turn && !before?.nodeIds.some(id => turn.nodeIds.includes(id)) ? [] : e.beforeRefs;
63
+ return { ...e, beforeRefs, nodeIds: turn?.nodeIds ?? e.nodeIds, signals: signals.filter(s => s.eventId === e.eventId) };
64
+ }).filter(e => e.reactionRefs.every(this.resolveSource));
65
+ }
66
+ captureConfirmation(receiptId) {
67
+ const receipt = this.repo.get('tree_receipts', receiptId), event = receipt && this.repo.get('tree_change_events', receipt.eventIds[0]);
68
+ if (!event || event.actor.kind !== 'user' || event.commandType !== 'confirmItem') fail('UI_CONFIRMATION', 'Only an actual trusted user confirmation receipt is strong confirmation evidence');
69
+ const eventId = digest(['user-operation', receiptId]), id = digest(['episode', eventId]), existing = this.repo.get('tree_episodes', id); if (existing) return existing;
70
+ if (event.ordinal !== this.repo.meta().lastEventOrdinal) fail('UI_CONFIRMATION_ORDER', 'Capture UI evidence in the same outer transaction as confirmation');
71
+ const item = this.repo.get('tree_items', receipt.result.itemId);
72
+ if (!item || item.kind !== 'understanding' || item.activeVersion !== receipt.result.version) fail('UI_CONFIRMATION', 'Confirmation must identify the exact current understanding version');
73
+ const version = this.repo.get('tree_item_versions', digest(['item_version', item.id, item.activeVersion]));
74
+ const previous = this.repo.get('tree_item_versions', digest(['item_version', item.id, version.supersedes]));
75
+ if (previous?.sourceKind !== 'assistant_inferred') return null;
76
+ const ref = { sessionId: 'arcfold-ui:' + this.repo.rootId(), seq: event.ordinal, sha256: digest(event) }, resolver = evidenceResolver(this.repo, this.resolveSource);
77
+ const service = new SignalService({ repository: this.repo, resolveSource: resolver, context: () => ({ actor: event.actor }) });
78
+ return this.repo.transaction(() => {
79
+ const signal = service.record({ requestId: digest([id, 'signal']), expected: {}, input: { eventId, subjectRefs: [item.id], dimension: 'collaboration', subitem: 'confirmed_inference', category: 'confirmed_inference', polarity: 'strong', evidenceKind: 'ui', sourceRefs: [ref], nodeIds: [item.nodeId], confidence: 1 } }).result;
80
+ this.repo.command('captureUserConfirmation', { requestId: id, expected: {}, input: { receiptId, sourceRefs: [ref] } }, program, () => {
81
+ this.repo.save('tree_episodes', { id, eventId, signalIds: [signal.signalId], beforeRefs: [], reactionRefs: [ref], resolutionRefs: [], nodeIds: [item.nodeId], state: 'observed', start: event.createdAt, ordinal: event.ordinal, confirmationRef: receiptId }); return { episodeId: id };
82
+ }); return this.repo.get('tree_episodes', id);
83
+ });
84
+ }
85
+ // Controls come from a trusted explicit observation of a proposal and its
86
+ // response. No-response is unknown; only actual rejecting evidence is rejected.
87
+ control({ proposalRef, responseRefs, nodeIds, category, outcome, requestId }) {
88
+ oneOf(outcome, ['ignored', 'unknown', 'rejected'], 'control outcome');
89
+ oneOf(category, ['approved', 'adopted_proposal', 'confirmed_inference'], 'control category');
90
+ const proposal = this.resolveSource(proposalRef);
91
+ if (proposal?.role !== 'assistant' || proposal.origin !== 'model') fail('PROPOSAL_SOURCE', 'A control must identify the actual assistant proposal');
92
+ sourceRefs([proposalRef], this.resolveSource); sourceRefs(responseRefs, this.resolveSource, { required: outcome !== 'unknown', user: outcome !== 'unknown' });
93
+ for (const id of nodeIds) if (!this.repo.get('tree_nodes', id)) fail('NODE_NOT_FOUND', 'Unknown control node');
94
+ const id = digest(['proposal-control', proposalRef, category]);
95
+ return this.repo.command('recordProposalControl', { requestId, expected: {}, input: { proposalRef, responseRefs, nodeIds, category, outcome } }, program, () => {
96
+ const old = this.repo.get('tree_episodes', id);
97
+ this.repo.save('tree_episodes', { ...old, id, eventId: id, signalIds: [], beforeRefs: [proposalRef], reactionRefs: responseRefs, resolutionRefs: [], nodeIds,
98
+ state: 'control', category, outcome, start: old?.start ?? this.repo.now(), ordinal: old?.ordinal ?? this.repo.meta().lastEventOrdinal + 1, history: [...(old?.history ?? []), { outcome, responseRefs }] }, old?.revision ?? null);
99
+ return { episodeId: id };
100
+ }).result;
101
+ }
102
+ material(episode) {
103
+ return { ...episode, sources: unique([...episode.beforeRefs, ...episode.reactionRefs, ...episode.resolutionRefs]).map(ref => {
104
+ const s = this.resolveSource(ref); if (!s) fail('SOURCE_CHANGED', 'Episode evidence was withdrawn'); return { ...s, ref, id: s.id ?? digest(ref) };
105
+ }) };
106
+ }
107
+ hookCandidates() {
108
+ const runs = this.repo.list('tree_hook_runs'), groups = new Map();
109
+ for (const run of runs.filter(r => r.result.status === 'needs_action')) {
110
+ const id = digest([run.logicalTurnId, run.methodId, run.version]);
111
+ if (!groups.has(id)) groups.set(id, { id, logicalTurnId: run.logicalTurnId, methodId: run.methodId, version: run.version, hookRunIds: [], recovered: true });
112
+ const group = groups.get(id); group.hookRunIds.push(run.id);
113
+ group.recovered &&= runs.some(r => r.logicalTurnId === run.logicalTurnId && r.hookId === run.hookId && r.version === run.version && r.eventOrdinal > run.eventOrdinal && r.result.status === 'pass');
114
+ }
115
+ // These are execution observations, never additional human negative signals.
116
+ return [...groups.values()].map(group => ({ ...group, kind: 'hook_omission_candidate', separateHumanSignal: false }));
117
+ }
118
+ }