@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,294 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { array, canonicalJson, digest, fail, integer, oneOf, shape, text, validateLong, validateShort, CATEGORIES } from './contracts.mjs';
3
+ import { DomainService } from './tree.mjs';
4
+
5
+ const versionId = (id, version) => digest(['item_version', id, version]);
6
+ const methodId = (id, version) => digest(['method', id, version]);
7
+ const supplementId = (id, version) => digest(['supplement', id, version]);
8
+ export const HOOK_RULES = Object.freeze({
9
+ user_message: { fields: ['userMessage'], actions: ['add_context'] },
10
+ before_tool: { fields: ['toolName', 'command', 'filePath', 'earlierToolsInTurn', 'earlierFilesInTurn'], actions: ['block', 'ask_user', 'add_context'] },
11
+ after_tool: { fields: ['toolName', 'command', 'filePath', 'isError', 'output'], actions: ['add_context'] },
12
+ turn_end: { fields: ['toolsCalled', 'filesWritten', 'earlierToolsInTurn', 'earlierFilesInTurn'], actions: ['remind_user', 'continue'] },
13
+ node_enter: { fields: ['nodeId', 'nodeName'], actions: ['add_context'] },
14
+ node_leave: { fields: ['filesWrittenInNode'], actions: ['remind_user', 'leave_note'] },
15
+ node_complete: { fields: ['filesWrittenInNode', 'completedBy'], actions: ['remind_user', 'block'] },
16
+ });
17
+ export function validateHook(hook) {
18
+ shape(hook, ['moment', 'conditions', 'action', 'message']); const rule = HOOK_RULES[hook.moment];
19
+ if (!rule) fail('INVALID_HOOK', 'Unknown hook moment'); oneOf(hook.action, rule.actions, 'hook action'); text(hook.message, 'hook message', 80, true);
20
+ for (const condition of array(hook.conditions, 'conditions', 16)) {
21
+ shape(condition, ['field', 'op', 'value']); oneOf(condition.field, rule.fields, 'hook field'); oneOf(condition.op, ['equals', 'contains', 'not_contains', 'starts_with', 'ends_with'], 'operator');
22
+ if (typeof condition.value !== 'boolean') text(condition.value, 'condition value', 512, true);
23
+ if (typeof condition.value === 'boolean' && condition.op !== 'equals') fail('INVALID_HOOK', 'Boolean values only support equality');
24
+ }
25
+ }
26
+
27
+ export class ItemService extends DomainService {
28
+ item(id) { const item = this.repo.get('tree_items', id); if (!item) fail('ITEM_NOT_FOUND', 'Item not found'); return item; }
29
+ version(id, version) { const row = this.repo.get('tree_item_versions', versionId(id, version)); if (!row) fail('ITEM_VERSION_NOT_FOUND', 'Item version not found'); return row; }
30
+ #newItem(kind, nodeId, content, refs, sourceKind, { lifecycle = 'active' } = {}) {
31
+ const id = randomUUID(); this.repo.save('tree_items', { id, kind, nodeId, activeVersion: lifecycle === 'active' ? 1 : null, draftVersion: lifecycle === 'draft' ? 1 : null, lifecycle, validation: 'unverified', userProtected: this.context().actor.kind === 'user', originNodeId: nodeId, originChain: [nodeId] });
32
+ this.repo.save('tree_item_versions', { id: versionId(id, 1), itemId: id, version: 1, content, sourceKind, sourceRefs: refs, reason: 'proposal' }); return id;
33
+ }
34
+ #understanding(proposal, nodeId) {
35
+ shape(proposal, ['content', 'source', 'sourceRefs']); text(proposal.content, 'content', 2000); oneOf(proposal.source, ['user_expressed', 'assistant_inferred', 'unknown'], 'source');
36
+ const refs = this.refs(proposal.sourceRefs, { user: proposal.source === 'user_expressed' }); return this.#newItem('understanding', nodeId, proposal.content, refs, proposal.source);
37
+ }
38
+ #method(proposal, nodeId, previous = null) {
39
+ shape(proposal, ['kind', 'short', 'long', 'hooks', 'scriptRequests', 'expectedEffect', 'sourceRefs']); oneOf(proposal.kind, ['collaboration', 'technique'], 'method kind');
40
+ if (proposal.short === null && proposal.long === null) fail('TEXT_REQUIRED', 'A method requires short or long text');
41
+ if (proposal.short !== null) validateShort(proposal.short); if (proposal.long !== null) validateLong(proposal.long);
42
+ array(proposal.hooks, 'hooks', 8).forEach(validateHook);
43
+ for (const request of array(proposal.scriptRequests, 'scriptRequests', 8)) {
44
+ shape(request, ['purpose', 'evidence', 'files']); text(request.purpose, 'script purpose', 2000); array(request.evidence, 'evidence', 20).forEach(s => text(s, 'evidence id', 512)); array(request.files, 'files', 20).forEach(s => text(s, 'file reference', 512));
45
+ }
46
+ if (!proposal.expectedEffect) fail('EXPECTATION_REQUIRED', 'An explicit expected effect is required');
47
+ shape(proposal.expectedEffect, ['category', 'change', 'text']); oneOf(proposal.expectedEffect.category, CATEGORIES, 'category'); oneOf(proposal.expectedEffect.change, ['gone', 'less', 'more'], 'expected change'); text(proposal.expectedEffect.text, 'expectation', 40);
48
+ const refs = this.refs(proposal.sourceRefs), lifecycle = proposal.scriptRequests.length ? 'draft' : 'active';
49
+ const id = previous?.id ?? this.#newItem('method', nodeId, proposal, refs, 'assistant_inferred', { lifecycle }), version = previous ? previous.activeVersion + 1 : 1;
50
+ if (previous) {
51
+ this.#withdraw(previous, 'bundle_replaced');
52
+ this.repo.save('tree_item_versions', { id: versionId(id, version), itemId: id, version, content: proposal, sourceKind: 'assistant_inferred', sourceRefs: refs, reason: 'bundle_replaced', supersedes: previous.activeVersion });
53
+ this.replace('tree_items', this.item(id), { lifecycle, activeVersion: lifecycle === 'active' ? version : null, draftVersion: lifecycle === 'draft' ? version : null, validation: 'unverified' });
54
+ }
55
+ const hooks = proposal.hooks.map((h, index) => {
56
+ const hookId = digest(['hook', id, index]), key = digest(['hook_version', hookId, version]);
57
+ this.repo.save('tree_hooks', { ...h, id: key, hookId, methodId: id, version, scopeNodeId: nodeId, enabled: lifecycle === 'active' }); return { id: hookId, version };
58
+ });
59
+ this.repo.save('tree_methods', { id: methodId(id, version), itemId: id, version, short: proposal.short, long: proposal.long, scripts: previous ? this.repo.get('tree_methods', methodId(id, previous.activeVersion)).scripts : [], hooks, expectation: proposal.expectedEffect, evaluation: { status: 'unverified', windowId: null, evidenceIds: [] }, pendingScriptRequests: proposal.scriptRequests }); return id;
60
+ }
61
+ #supplement(proposal, nodeId) {
62
+ shape(proposal, ['target', 'text', 'sourceRefs', 'traitEvidence', 'universalStopgap'], ['reportId']);
63
+ oneOf(proposal.target, ['learning', 'attribution', 'consolidation'], 'target'); validateShort(proposal.text); const refs = this.refs(proposal.sourceRefs);
64
+ const evidence = this.refs(proposal.traitEvidence, { required: !proposal.universalStopgap });
65
+ if (typeof proposal.universalStopgap !== 'boolean') fail('INVALID_FIELD', 'universalStopgap must be boolean');
66
+ if (proposal.universalStopgap && (!proposal.reportId || !this.repo.get('tree_reports', proposal.reportId))) fail('REPORT_REQUIRED', 'A universal stopgap must link a local report');
67
+ const id = this.#newItem('supplement', nodeId, proposal.text, refs, 'assistant_inferred');
68
+ this.repo.save('tree_supplements', { id: supplementId(id, 1), itemId: id, version: 1, target: proposal.target, scopeNodeId: nodeId, traitEvidence: evidence, universalStopgap: proposal.universalStopgap, reportId: proposal.reportId ?? null }); return id;
69
+ }
70
+ commitProposal(command) {
71
+ return this.repo.command('commitProposal', command, this.actor(['learning', 'consolidation', 'verification', 'improvement']), () => {
72
+ const input = command.input; shape(input, ['nodeId', 'understandings', 'methods', 'supplements'], ['revisions']); this.node(input.nodeId); this.scoped(input.nodeId);
73
+ const result = {};
74
+ for (const [partition, limit, apply] of [['understandings', 20, p => this.#understanding(p, input.nodeId)], ['methods', 10, p => this.#method(p, input.nodeId)], ['supplements', 10, p => this.#supplement(p, input.nodeId)]]) {
75
+ try {
76
+ result[partition] = this.repo.transaction(() => {
77
+ const ids = array(input[partition], partition, limit).map(apply);
78
+ if (partition === 'understandings') for (const revision of array(input.revisions ?? [], 'revisions', 20)) this.#correctVersion(revision, ids, input.nodeId, command);
79
+ return { status: 'applied', itemIds: ids };
80
+ });
81
+ } catch (error) {
82
+ if (!error.code || ['SQLITE_BUSY', 'REVISION_CONFLICT'].includes(error.code)) throw error;
83
+ result[partition] = { status: 'rejected', code: error.code, reason: error.message };
84
+ }
85
+ }
86
+ return result;
87
+ });
88
+ }
89
+ #correctVersion(revision, replacements, nodeId, command) {
90
+ shape(revision, ['targetId', 'targetVersion', 'mark', 'replacedByIndex', 'sourceRefs']); oneOf(revision.mark, ['changed', 'invalid'], 'mark'); integer(revision.replacedByIndex, 'replacement index', 0, replacements.length - 1);
91
+ const item = this.item(revision.targetId); this.repo.requireExpected(command, 'tree_items', item.id);
92
+ if (item.kind !== 'understanding') fail('UNDERSTANDING_REQUIRED', 'Understanding corrections cannot rewrite a method bundle');
93
+ if (item.activeVersion !== revision.targetVersion) fail('VERSION_CONFLICT', 'Target version changed'); this.refs(revision.sourceRefs, { user: true });
94
+ // The learning adapter validates exact current-user evidence and binds each
95
+ // classified correction to this item. Domain proposals cannot grant it.
96
+ const correction = this.context().corrections?.[item.id] ?? this.context().correction;
97
+ if (!correction || !correction.itemIds?.includes(item.id) || revision.sourceRefs.some(r => !correction.sourceRefs?.some(s => canonicalJson(r) === canonicalJson(s)))) fail('CORRECTION_REQUIRED', 'No trusted current-user correction for this item');
98
+ if (item.nodeId !== nodeId && !correction.global) fail('LOCAL_EXCEPTION', 'A local exception cannot invalidate an ancestor item');
99
+ if (item.nodeId !== nodeId) {
100
+ const replacement = this.item(replacements[revision.replacedByIndex]);
101
+ this.replace('tree_items', replacement, { nodeId: item.nodeId, originChain: [...new Set([...replacement.originChain, item.nodeId])] });
102
+ }
103
+ this.replace('tree_items', item, { lifecycle: revision.mark, replacedBy: replacements[revision.replacedByIndex], correctionSourceRefs: revision.sourceRefs });
104
+ }
105
+ #copyBundle(item, from, to, updates = {}) {
106
+ if (item.kind === 'method') {
107
+ const bundle = this.repo.get('tree_methods', methodId(item.id, from)); if (!bundle) fail('BUNDLE_MISSING', 'Method version missing');
108
+ const hooks = bundle.hooks.map(ref => {
109
+ const old = this.repo.get('tree_hooks', digest(['hook_version', ref.id, ref.version]));
110
+ if (!old) fail('BUNDLE_MISSING', 'Hook missing'); this.replace('tree_hooks', old, { enabled: false });
111
+ this.repo.save('tree_hooks', { ...old, id: digest(['hook_version', ref.id, to]), version: to, scopeNodeId: item.nodeId, enabled: !old.manuallyDisabled }); return { id: ref.id, version: to };
112
+ });
113
+ this.repo.save('tree_methods', { ...bundle, ...updates, id: methodId(item.id, to), version: to, hooks });
114
+ } else if (item.kind === 'supplement') {
115
+ const old = this.repo.get('tree_supplements', supplementId(item.id, from)); this.repo.save('tree_supplements', { ...old, ...updates, id: supplementId(item.id, to), version: to, scopeNodeId: item.nodeId });
116
+ }
117
+ }
118
+ activateDraft(command) {
119
+ return this.repo.command('activateDraft', command, this.actor([]), () => {
120
+ if (this.context().actor.kind !== 'program') fail('FORBIDDEN', 'Only the artifact broker may activate a finished draft');
121
+ shape(command.input, ['itemId', 'version', 'artifactIds']); const item = this.item(command.input.itemId); this.repo.requireExpected(command, 'tree_items', item.id);
122
+ if (item.lifecycle !== 'draft' || item.draftVersion !== command.input.version) fail('VERSION_CONFLICT', 'Draft changed');
123
+ const bundle = this.repo.get('tree_methods', methodId(item.id, item.draftVersion));
124
+ const artifacts = array(command.input.artifactIds, 'artifacts', 8).map(id => this.repo.get('tree_script_artifacts', id));
125
+ if (artifacts.length !== bundle.pendingScriptRequests.length || new Set(command.input.artifactIds).size !== artifacts.length) fail('BUNDLE_INCOMPLETE', 'Every requested script must be ready exactly once');
126
+ for (const artifact of artifacts) if (!artifact || artifact.methodId !== item.id || artifact.activationState !== 'ready' || artifact.language !== 'node-esm' || !this.context().verifiedArtifacts?.some(v => v.id === artifact.id && v.revision === artifact.revision && v.filesHash === digest(artifact.files))) fail('ARTIFACT_UNVERIFIED', 'Broker must verify current artifact bytes before submitting activation');
127
+ const version = item.draftVersion + 1, old = this.version(item.id, item.draftVersion);
128
+ this.repo.save('tree_item_versions', { ...old, id: versionId(item.id, version), version, reason: 'all_scripts_ready', supersedes: old.version, bundleHash: digest(artifacts.map(a => a.files)) });
129
+ this.#copyBundle(item, item.draftVersion, version, { scripts: [...bundle.scripts, ...artifacts.map(a => ({ id: a.scriptId, version: a.version, hash: digest(a.files) }))], pendingScriptRequests: [] });
130
+ this.replace('tree_items', item, { lifecycle: 'active', activeVersion: version, draftVersion: null }); return { itemId: item.id, version };
131
+ });
132
+ }
133
+ confirm(command) {
134
+ return this.repo.command('confirmItem', command, this.actor([], true), () => {
135
+ shape(command.input, ['itemId', 'version']); const item = this.item(command.input.itemId); this.repo.requireExpected(command, 'tree_items', item.id);
136
+ if (item.lifecycle !== 'active' || item.activeVersion !== command.input.version) fail('VERSION_CONFLICT', 'Active version changed');
137
+ const old = this.version(item.id, item.activeVersion), version = old.version + 1;
138
+ this.repo.save('tree_item_versions', { ...old, id: versionId(item.id, version), version, sourceKind: 'user_expressed', confirmationRef: digest(['change', command.requestId]), supersedes: old.version, reason: 'trusted_user_confirmation' });
139
+ this.#copyBundle(item, old.version, version); this.replace('tree_items', item, { activeVersion: version, userProtected: true, validation: 'supported' }); return { itemId: item.id, version };
140
+ });
141
+ }
142
+ #withdraw(item, reason, lifecycle = 'withdrawn') {
143
+ for (const hook of this.repo.list('tree_hooks', h => h.methodId === item.id && h.enabled)) this.replace('tree_hooks', hook, { enabled: false });
144
+ for (const run of this.repo.list('tree_script_runs', r => r.methodVersion?.itemId === item.id && !['succeeded', 'failed', 'revoked'].includes(r.state))) this.replace('tree_script_runs', run, { state: 'revoked', denial: reason });
145
+ for (const e of this.repo.list('tree_exposures', e => !e.revoked && e.itemVersions.some(v => v.itemId === item.id))) this.replace('tree_exposures', e, { revoked: true, invalidationReason: reason });
146
+ this.replace('tree_items', item, { lifecycle });
147
+ }
148
+ withdraw(command) {
149
+ return this.repo.command('withdrawItem', command, this.actor([], true), () => {
150
+ shape(command.input, ['itemId', 'version', 'reason']); const item = this.item(command.input.itemId); this.repo.requireExpected(command, 'tree_items', item.id); text(command.input.reason, 'reason');
151
+ if ((item.activeVersion ?? item.draftVersion) !== command.input.version) fail('VERSION_CONFLICT', 'Item version changed'); this.#withdraw(item, command.input.reason); return { itemId: item.id, lifecycle: 'withdrawn' };
152
+ });
153
+ }
154
+ recheckSources(command) {
155
+ return this.repo.command('recheckItemSources', command, this.actor(['verification']), () => {
156
+ const input = command.input, authority = this.context().sourceRecheck;
157
+ shape(input, ['itemId', 'version', 'operation', 'reason', 'sourceRefs'], ['replacement']);
158
+ oneOf(input.operation, ['support', 'rewrite', 'withdraw', 'probe'], 'source review operation'); text(input.reason, 'reason');
159
+ const job = authority && this.repo.get('tree_jobs', authority.jobId), exposure = authority && this.repo.get('tree_exposures', authority.exposureId);
160
+ const invocation = exposure && this.repo.get('tree_invocations', exposure.invocationId);
161
+ if (!job || job.role !== 'verification' || job.status !== 'validated' || job.input.sourceRecheck?.itemId !== input.itemId || job.input.sourceRecheck?.itemVersion !== input.version ||
162
+ this.context().actor.id !== job.id || exposure?.kind !== 'source_recheck' || exposure.role !== 'verification' || exposure.revoked || exposure.inputHash !== job.inputHash ||
163
+ invocation?.jobId !== job.id || invocation.state !== 'result_received' || invocation.inputHash !== job.inputHash) fail('RECHECK_AUTHORITY', 'An actual validated source-review owner is required');
164
+ const item = this.item(input.itemId); this.repo.requireExpected(command, 'tree_items', item.id); this.scoped(item.nodeId);
165
+ if (item.lifecycle !== 'active' || !item.stale || item.staleReason !== 'assignment_corrected' || item.activeVersion !== input.version) fail('VERSION_CONFLICT', 'Source-review target changed');
166
+ if (item.userProtected && input.operation !== 'probe') fail('USER_PROTECTED', 'Protected source conflicts require owner review');
167
+ const refs = this.refs(input.sourceRefs), allowed = new Set(job.input.sources.map(s => canonicalJson(s.ref)));
168
+ if (refs.some(ref => !allowed.has(canonicalJson(ref)))) fail('RECHECK_SOURCE', 'Only still-assigned frozen evidence may support this version');
169
+ const review = { jobId: job.id, intentId: job.input.sourceRecheck.intentId, exposureId: exposure.id, version: input.version,
170
+ operation: input.operation, reason: input.reason, sourceRefs: refs, status: input.operation === 'probe' ? 'needs_evidence' : 'resolved' };
171
+ if (input.operation === 'probe') {
172
+ this.replace('tree_items', item, { sourceReview: review, probe: { what: input.reason, evidenceRefs: refs, version: item.activeVersion, windowId: job.input.window.id } });
173
+ return { itemId: item.id, status: 'needs_evidence', active: false };
174
+ }
175
+ if (input.operation === 'withdraw') {
176
+ this.#withdraw(item, 'assignment_source_review');
177
+ this.replace('tree_items', this.item(item.id), { sourceReview: review, stale: false, staleReason: null });
178
+ return { itemId: item.id, status: 'withdrawn', active: false };
179
+ }
180
+ const old = this.version(item.id, item.activeVersion), next = old.version + 1;
181
+ let content = old.content;
182
+ if (input.operation === 'rewrite') {
183
+ if (item.kind === 'understanding') { text(input.replacement, 'replacement', 2000); content = input.replacement; }
184
+ else if (item.kind === 'method') {
185
+ const proposal = input.replacement;
186
+ shape(proposal, ['kind', 'short', 'long', 'hooks', 'scriptRequests', 'expectedEffect', 'sourceRefs']);
187
+ if (proposal.kind !== old.content.kind || canonicalJson(proposal.hooks) !== canonicalJson(old.content.hooks) || proposal.scriptRequests.length || this.repo.get('tree_methods', methodId(item.id, old.version)).scripts.length) fail('RECHECK_COMPONENT', 'Source recheck cannot create or silently change executable components');
188
+ if (proposal.short === null && proposal.long === null) fail('TEXT_REQUIRED', 'A method requires text');
189
+ if (proposal.short !== null) validateShort(proposal.short); if (proposal.long !== null) validateLong(proposal.long);
190
+ shape(proposal.expectedEffect, ['category', 'change', 'text']); oneOf(proposal.expectedEffect.category, CATEGORIES, 'category'); oneOf(proposal.expectedEffect.change, ['gone', 'less', 'more'], 'change'); text(proposal.expectedEffect.text, 'expectation', 40);
191
+ content = proposal;
192
+ } else fail('RECHECK_KIND', 'A supplement needing different wording requires an explicit probe');
193
+ }
194
+ if (item.kind === 'method') content = { ...content, sourceRefs: refs };
195
+ const supplement = item.kind === 'supplement' ? this.repo.get('tree_supplements', supplementId(item.id, old.version)) : null;
196
+ const remainingTraits = supplement?.traitEvidence.filter(ref => refs.some(r => canonicalJson(r) === canonicalJson(ref)));
197
+ if (supplement && !supplement.universalStopgap && !remainingTraits.length) fail('RECHECK_TRAIT', 'A personal supplement still needs its own user evidence');
198
+ const sourceKind = input.operation === 'support' ? old.sourceKind : 'assistant_inferred';
199
+ if (sourceKind === 'user_expressed') this.refs(refs, { user: true });
200
+ this.repo.save('tree_item_versions', { id: versionId(item.id, next), itemId: item.id, version: next, content, sourceKind, sourceRefs: refs, supersedes: old.version, reason: 'assignment_source_review' });
201
+ this.#copyBundle(item, old.version, next, item.kind === 'method' ? { short: content.short, long: content.long, expectation: content.expectedEffect, evaluation: { status: 'unverified', windowId: null, evidenceIds: [] } } : supplement ? { traitEvidence: remainingTraits } : {});
202
+ this.replace('tree_items', item, { activeVersion: next, stale: false, staleReason: null, validation: 'unverified', sourceReview: review });
203
+ for (const e of this.repo.list('tree_exposures', e => !e.revoked && e.itemVersions.some(v => v.itemId === item.id))) this.replace('tree_exposures', e, { revoked: true, invalidationReason: 'assignment_source_review' });
204
+ return { itemId: item.id, version: next, status: 'resolved', active: true };
205
+ });
206
+ }
207
+ review(command) {
208
+ return this.repo.command('reviewItem', command, this.actor(['verification', 'consolidation']), () => {
209
+ const input = command.input; shape(input, ['itemId', 'version', 'exposureId', 'operation', 'reason', 'sourceRefs', 'windowId', 'opportunityRefs'], ['toNodeId', 'replacement', 'result', 'mergeTargetId', 'mergeTargetVersion']);
210
+ oneOf(input.operation, ['support', 'rewrite', 'mark_invalid', 'mark_changed', 'move', 'merge', 'promote', 'demote', 'withdraw', 'harden'], 'operation'); text(input.reason, 'reason'); text(input.windowId, 'windowId', 512);
211
+ const item = this.item(input.itemId); this.repo.requireExpected(command, 'tree_items', item.id);
212
+ if (item.lifecycle !== 'active' || item.activeVersion !== input.version) fail('VERSION_CONFLICT', 'Target is no longer active');
213
+ // A verification transaction may change several items from the same
214
+ // actual exposure. Its trusted adapter validates every snapshot before
215
+ // any mutation and supplies those exact snapshots for this transaction.
216
+ // No command/model field can provide this authority.
217
+ const exposure = this.context().reviewExposure?.(input.exposureId) ?? this.repo.get('tree_exposures', input.exposureId);
218
+ if (!exposure || exposure.kind === 'source_recheck' || exposure.revoked || !exposure.itemVersions.some(v => v.itemId === item.id && v.version === input.version)) fail('EXPOSURE_REQUIRED', 'Review must bind an actual exposure of this version');
219
+ this.refs(input.sourceRefs); this.refs(input.opportunityRefs, { required: false });
220
+ if (item.userProtected && this.context().actor.kind !== 'user' && input.operation !== 'support') fail('USER_PROTECTED', 'A model cannot modify protected content');
221
+ if (this.context().actor.kind === 'model' && !exposure.nodeIds.some(id => (this.context().nodeIds ?? []).includes(id))) fail('OUT_OF_SCOPE', 'Exposure is outside reviewer scope');
222
+ const result = input.opportunityRefs.length ? oneOf(input.result ?? 'unclear', ['met', 'not_met', 'unclear'], 'verification result') : 'unclear';
223
+ const reviewId = digest(['review', item.id, input.version, input.windowId]);
224
+ if ((item.reviews ?? []).some(r => r.id === reviewId)) return { itemId: item.id, duplicateWindow: true };
225
+ const review = { id: reviewId, version: input.version, windowId: input.windowId, operation: input.operation, result, sourceRefs: input.sourceRefs, opportunityRefs: input.opportunityRefs, exposureId: exposure.id, reason: input.reason };
226
+ let updates = { reviews: [...(item.reviews ?? []), review] };
227
+ if (input.operation === 'support') {
228
+ const supportingNodes = new Set(); const observedSources = new Set();
229
+ for (const r of updates.reviews.filter(r => r.version === item.activeVersion && r.result === result && result !== 'unclear')) {
230
+ const uniqueEvidence = r.opportunityRefs.some(ref => !observedSources.has(canonicalJson(ref)));
231
+ r.opportunityRefs.forEach(ref => observedSources.add(canonicalJson(ref)));
232
+ const nodes = this.repo.get('tree_exposures', r.exposureId)?.nodeIds ?? [];
233
+ // One observation assigned to multiple nodes is still one observation;
234
+ // without a per-source node mapping it cannot prove independent support.
235
+ if (uniqueEvidence && nodes.length === 1) supportingNodes.add(nodes[0]);
236
+ }
237
+ const ancestorItem = item.nodeId === this.repo.rootId() || exposure.nodeIds.some(id => id !== item.nodeId);
238
+ const supported = this.context().actor.kind === 'user' || !ancestorItem || supportingNodes.size >= 2;
239
+ updates.validation = result === 'met' ? supported ? 'supported' : 'unverified' : result === 'not_met' ? supported ? 'refuted' : item.validation : item.validation;
240
+ }
241
+ else if (['mark_invalid', 'mark_changed'].includes(input.operation)) updates.lifecycle = input.operation === 'mark_invalid' ? 'invalid' : 'changed';
242
+ else if (['move', 'promote', 'demote'].includes(input.operation)) {
243
+ this.node(input.toNodeId); const ancestors = this.repo.ancestors(item.nodeId).map(n => n.id), chain = item.originChain ?? [item.originNodeId];
244
+ if (input.operation === 'promote' && (input.toNodeId === item.nodeId || !ancestors.includes(input.toNodeId))) fail('INVALID_PROMOTION', 'Promotion is restricted to ancestors');
245
+ if (input.operation === 'demote' && (input.toNodeId === item.nodeId || !chain.includes(input.toNodeId) || !this.repo.ancestors(input.toNodeId).some(n => n.id === item.nodeId))) fail('INVALID_DEMOTION', 'Demotion requires a recorded origin below current scope');
246
+ if (input.operation === 'move' && !exposure.nodeIds.includes(input.toNodeId)) fail('OUT_OF_SCOPE', 'Move requires evidence in its target scope');
247
+ updates = { ...updates, nodeId: input.toNodeId, validation: 'unverified', originChain: [...new Set([...chain, input.toNodeId])] };
248
+ for (const h of this.repo.list('tree_hooks', h => h.methodId === item.id && h.enabled)) this.replace('tree_hooks', h, { scopeNodeId: input.toNodeId });
249
+ } else if (['rewrite', 'harden', 'merge'].includes(input.operation)) {
250
+ let merged = null;
251
+ if (input.operation === 'merge') {
252
+ merged = this.item(input.mergeTargetId); this.repo.requireExpected(command, 'tree_items', merged.id);
253
+ if (merged.id === item.id || merged.activeVersion !== input.mergeTargetVersion || merged.kind !== item.kind || merged.nodeId !== item.nodeId || merged.lifecycle !== 'active' || !exposure.itemVersions.some(v => v.itemId === merged.id && v.version === merged.activeVersion)) fail('MERGE_CONFLICT', 'Merge requires two current exposed versions of the same kind and scope');
254
+ if (merged.userProtected && this.context().actor.kind !== 'user') fail('USER_PROTECTED', 'Merge cannot consume protected content');
255
+ }
256
+ const version = item.activeVersion + 1, old = this.version(item.id, item.activeVersion);
257
+ const refs = [...new Map([...old.sourceRefs, ...(merged ? this.version(merged.id, merged.activeVersion).sourceRefs : []), ...input.sourceRefs].map(ref => [canonicalJson(ref), ref])).values()];
258
+ if (item.kind === 'method') this.#method({ ...input.replacement, sourceRefs: refs }, item.nodeId, item);
259
+ else {
260
+ if (input.operation === 'harden') fail('METHOD_REQUIRED', 'Only methods can be hardened');
261
+ if (item.kind === 'understanding') text(input.replacement, 'replacement', 2000); else validateShort(input.replacement);
262
+ this.repo.save('tree_item_versions', { id: versionId(item.id, version), itemId: item.id, version, content: input.replacement, sourceKind: 'assistant_inferred', sourceRefs: refs, supersedes: old.version, reason: input.reason });
263
+ this.#copyBundle(item, old.version, version); updates.activeVersion = version; updates.validation = 'unverified';
264
+ }
265
+ if (merged) { this.#withdraw(merged, 'merged_into_version'); this.replace('tree_items', this.item(merged.id), { mergedInto: { itemId: item.id, version } }); }
266
+ }
267
+ const updated = this.replace('tree_items', this.item(item.id), updates);
268
+ if (['withdraw', 'mark_invalid', 'mark_changed'].includes(input.operation)) this.#withdraw(updated, input.reason, input.operation === 'withdraw' ? 'withdrawn' : updates.lifecycle);
269
+ if (input.operation !== 'support') for (const e of this.repo.list('tree_exposures', e => !e.revoked && e.itemVersions.some(v => v.itemId === item.id))) this.replace('tree_exposures', e, { revoked: true, invalidationReason: 'item_reviewed' });
270
+ const unclear = new Set();
271
+ for (const r of [...updates.reviews].reverse().filter(r => r.version === input.version)) { if (r.result !== 'unclear') break; unclear.add(r.windowId); }
272
+ const unclearWindows = unclear.size;
273
+ return { itemId: item.id, result, unclearWindows, needsReflection: unclearWindows >= 3 };
274
+ });
275
+ }
276
+ }
277
+
278
+ export class ReportService extends DomainService {
279
+ preview(reportId, { includeEvidence = false } = {}) {
280
+ this.actor([], true); const report = this.repo.get('tree_reports', reportId); if (!report) fail('REPORT_NOT_FOUND', 'Local report not found');
281
+ const safeText = value => String(value).replace(/(?:\/[\w.\-\p{L}]+){2,}/gu, '[路径已隐藏]').replace(/[“「"]([^”」"]*)[”」"]/gu, '[原话已隐藏]');
282
+ // Default export omits the original problem/evidence fields altogether:
283
+ // unmarked free text cannot be reliably distinguished from a raw quote.
284
+ return { reportId, revision: report.revision, category: safeText(report.category), step: safeText(report.step), problem: includeEvidence ? report.problem : '[原始问题描述已隐藏;可选择附证据]', whyUniversal: includeEvidence ? report.whyUniversal : '[详细诊断已隐藏]', suggestedFix: includeEvidence ? report.suggestedFix : '[详细建议已隐藏]', evidenceRefs: includeEvidence ? report.evidenceRefs : [], ...(includeEvidence && report.reviewOutput ? { reviewOutput: report.reviewOutput } : {}), status: report.status, includesEvidence: includeEvidence };
285
+ }
286
+ export(command) {
287
+ return this.repo.command('exportReport', command, this.actor([], true), () => {
288
+ shape(command.input, ['reportId', 'includeEvidence', 'previewHash']); if (typeof command.input.includeEvidence !== 'boolean') fail('INVALID_FIELD', 'Evidence choice must be boolean');
289
+ const report = this.repo.get('tree_reports', command.input.reportId); if (!report) fail('REPORT_NOT_FOUND', 'Report missing'); this.repo.requireExpected(command, 'tree_reports', report.id);
290
+ const preview = this.preview(report.id, command.input); if (digest(preview) !== command.input.previewHash) fail('PREVIEW_CHANGED', 'Preview changed before export');
291
+ this.replace('tree_reports', report, { exportAudit: [...report.exportAudit, { requestId: command.requestId, at: this.repo.now(), includeEvidence: command.input.includeEvidence, contentHash: digest(preview) }] }); return { content: preview };
292
+ });
293
+ }
294
+ }
@@ -0,0 +1,184 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { CONTRACT, ROLES, array, canonicalJson, digest, fail, integer, jsonClone, oneOf, shape, text } from './contracts.mjs';
3
+ import { compilePrompt, parseFinalOutput, outputBindings } from './prompts.mjs';
4
+ import { recordKey } from './repository.mjs';
5
+
6
+ export const ROLE_LIMITS = Object.freeze({ main: 8192, attribution: 2048, learning: 8192, consolidation: 8192, verification: 8192, improvement: 8192, script_writer: 8192 });
7
+ export const WRITER_TOOLS = Object.freeze(['writer_read', 'writer_list', 'writer_search', 'writer_put', 'writer_probe', 'writer_finish']);
8
+ export const TERMINAL_JOBS = Object.freeze(['committed', 'failed', 'unknown', 'invalid_output', 'stale', 'cancelled']);
9
+ const priority = { attribution: 0, script_writer: 1, learning: 2, consolidation: 3, verification: 4, improvement: 5, main: 0 };
10
+ const triggers = {
11
+ main: ['user_turn'], attribution: ['human_input'], learning: ['settled_main_turn', 'assignment_correction'],
12
+ consolidation: ['threshold', 'leave_timeout', 'node_complete'], verification: ['window', 'cluster', 'stagnation', 'assignment_correction'],
13
+ improvement: ['repair_group'], script_writer: ['explicit_script_request', 'learned_script_request'],
14
+ };
15
+ export function freezeModelConfig(role, value) {
16
+ shape(value, ['provider', 'model', 'reasoningEffort', 'maxTokens', 'preset', 'cwd', 'modelVersion', 'priceVersion', 'budgetScope', 'stage']);
17
+ for (const key of ['provider', 'model', 'preset', 'cwd', 'modelVersion', 'priceVersion', 'budgetScope', 'stage']) text(value[key], key, 4096);
18
+ if (!value.cwd.startsWith('/')) fail('CONFIG_PATH', 'Explicit absolute working directory required');
19
+ oneOf(value.reasoningEffort, ['off', 'low', 'high', 'max'], 'reasoning effort'); integer(value.maxTokens, 'maxTokens', 1, role === 'main' && value.budgetScope !== 'arcfold-tree-v3-150cny' ? Number.MAX_SAFE_INTEGER : ROLE_LIMITS[role]); return jsonClone(value);
20
+ }
21
+
22
+ export class TreeJobs {
23
+ constructor({ repository, assertSources, now = () => Date.now() }) {
24
+ if (typeof assertSources !== 'function') fail('SOURCE_RESOLVER_REQUIRED', 'Jobs require a trusted source recheck');
25
+ this.repo = repository; this.assertSources = assertSources; this.now = now;
26
+ }
27
+ get(id) { const job = this.repo.get('tree_jobs', id); if (!job) fail('JOB_NOT_FOUND', 'Model job not found'); return job; }
28
+ #command(type, requestId, input, expected, mutation) { return this.repo.command(type, { requestId, input, expected }, { kind: 'program', id: 'tree-model-jobs' }, mutation); }
29
+ #replace(job, updates) { return this.repo.save('tree_jobs', { ...job, ...updates }, job.revision); }
30
+ assertCurrent(job) {
31
+ if (job.invalidated || ['stale', 'cancelled'].includes(job.status)) fail('JOB_STALE', 'Job scope was invalidated');
32
+ for (const [key, revision] of Object.entries(job.inputRevisionSet)) { const index = key.indexOf(':'); if (index < 1 || this.repo.get(key.slice(0, index), key.slice(index + 1))?.revision !== revision) fail('JOB_STALE', 'A frozen input revision changed'); }
33
+ const checked = this.assertSources(job.input.sources);
34
+ if (checked?.then) fail('ASYNC_GUARD', 'Source recheck must be synchronous'); if (checked === false) fail('JOB_STALE', 'A frozen source changed or was withdrawn');
35
+ if (digest(job.input) !== job.inputHash || digest(job.modelConfig) !== job.modelConfigHash || digest(job.prompt.text) !== job.promptHash) fail('JOB_CORRUPT', 'Frozen model input was altered');
36
+ return true;
37
+ }
38
+ enqueue({ role, key, nodeIds, input, revisionSet, config, trigger }) {
39
+ oneOf(role, ROLES, 'role'); text(key, 'stable job key', 512); array(nodeIds, 'nodeIds', 8); if (!nodeIds.length) fail('JOB_SCOPE', 'At least one node is required');
40
+ if (!trigger || !triggers[role].includes(trigger.kind)) fail('JOB_TRIGGER', 'Role requires an explicit host trigger');
41
+ if (['main', 'attribution', 'learning'].includes(role) && !['main', 'user', 'program'].includes(trigger.producerRole)) fail('RECURSIVE_JOB', 'Auxiliary events cannot recursively schedule user learning');
42
+ if (['main', 'attribution'].includes(role) && trigger.origin !== 'user') fail('PROGRAM_NOT_USER', 'Only real human input starts a logical user turn');
43
+ if (role === 'learning' && (trigger.kind === 'settled_main_turn' ? trigger.producerRole !== 'main' || trigger.status !== 'settled' : trigger.actorKind !== 'user')) fail('RECURSIVE_JOB', 'Learning requires settled main experience or a trusted user correction');
44
+ if (role === 'learning' && nodeIds.length !== 1) fail('JOB_SCOPE', 'Each learning job belongs to one node');
45
+ const id = digest(['model_job', role, key]), prompt = compilePrompt(role), modelConfig = freezeModelConfig(role, config);
46
+ const envelope = { ...jsonClone(input), inputVersion: CONTRACT, role, jobId: id, trigger: jsonClone(trigger) };
47
+ if (canonicalJson(envelope.scope?.nodeIds) !== canonicalJson(nodeIds) || !Array.isArray(envelope.sources) || !Array.isArray(envelope.knownItems) || !envelope.allowedIds) fail('INPUT_CONTRACT', 'A complete frozen scope and reference envelope is required');
48
+ if (['learning', 'verification', 'improvement'].includes(role)) envelope.outputBindings = outputBindings(envelope);
49
+ for (const nodeId of nodeIds) { const node = this.repo.get('tree_nodes', nodeId); if (!node || node.deleted) fail('JOB_SCOPE', 'Unknown scope node'); }
50
+ const inputHash = digest(envelope), inputRevisionSet = jsonClone(revisionSet), specification = { role, key, nodeIds, inputHash, inputRevisionSet, modelConfig, promptHash: prompt.hash };
51
+ // Retry under another caller request id must still find this same job.
52
+ const existing = this.repo.get('tree_jobs', id);
53
+ if (existing) { if (existing.specificationHash !== digest(specification)) fail('JOB_IDENTITY_CONFLICT', 'A job key cannot be reused with different frozen input or configuration'); return existing; }
54
+ this.#command('enqueueModelJob', digest(['enqueue', id]), specification, inputRevisionSet, () => {
55
+ const job = { id, kind: role, role, scopeKey: nodeIds.join(','), nodeIds, input: envelope, inputHash, inputRevisionSet, modelConfig, modelConfigHash: digest(modelConfig), prompt, promptHash: prompt.hash, specificationHash: digest(specification), status: 'queued', trigger, nativeSessionId: null, invocationIds: [], enqueuedOrder: trigger.ordinal ?? this.repo.meta().lastEventOrdinal };
56
+ this.assertCurrent(job); this.repo.save('tree_jobs', job); return { jobId: id };
57
+ }); return this.get(id);
58
+ }
59
+ prepare(id, workerId = 'local-worker') {
60
+ const current = this.get(id); if (current.status !== 'queued') return current;
61
+ this.#command('prepareModelJob', digest(['prepare', id]), { jobId: id, workerId }, { [recordKey('tree_jobs', id)]: current.revision }, () => {
62
+ const job = this.get(id); this.assertCurrent(job);
63
+ const active = this.repo.list('tree_jobs', j => ['prepared', 'submitted', 'result_received', 'validated'].includes(j.status));
64
+ if (!['main', 'attribution'].includes(job.role) && active.filter(j => !['main', 'attribution'].includes(j.role)).length >= 2) fail('WORKER_BUSY', 'Two background model jobs are already active');
65
+ if (job.role === 'script_writer' && active.some(j => j.role === 'script_writer')) fail('WRITER_BUSY', 'Only one writer may run');
66
+ if (['learning', 'consolidation'].includes(job.role)) {
67
+ if (this.repo.list('tree_jobs', j => j.role === 'learning' && j.status === 'unknown' && j.nodeIds.some(n => job.nodeIds.includes(n))).length) fail('LEARNING_UNKNOWN', 'Resolve the uncertain prior learning request before reusing its session');
68
+ if (active.some(j => ['learning', 'consolidation'].includes(j.role) && j.nodeIds.some(n => job.nodeIds.includes(n)))) fail('NODE_BUSY', 'This node already has an active learning/consolidation writer');
69
+ if (this.repo.list('tree_jobs', j => j.id !== id && j.status === 'queued' && ['learning', 'consolidation'].includes(j.role) && j.nodeIds.some(n => job.nodeIds.includes(n)) && j.enqueuedOrder < job.enqueuedOrder).length) fail('NODE_ORDER', 'Earlier node experience must be processed first');
70
+ }
71
+ let nativeSessionId = `session-${randomUUID()}`, contextEpoch = 1;
72
+ if (job.role === 'main') { text(job.trigger.nativeSessionId, 'main native session', 512); integer(job.trigger.nativeTurn, 'main native turn', 1); nativeSessionId = job.trigger.nativeSessionId; }
73
+ if (job.role === 'learning') {
74
+ const sessionId = digest(['learning_session', job.nodeIds[0]]); let session = this.repo.get('tree_learning_sessions', sessionId);
75
+ if (!session) session = this.repo.save('tree_learning_sessions', { id: sessionId, nodeId: job.nodeIds[0], sessionId: nativeSessionId, contextEpoch: 1, appendSeq: 0, sourceWatermark: null, appliedChangeOrdinal: 0, estimateTokens: 0, history: [] });
76
+ if (session.paused) fail('LEARNING_PAUSED', 'Learning awaits successful context consolidation');
77
+ nativeSessionId = session.sessionId; contextEpoch = session.contextEpoch;
78
+ }
79
+ this.#replace(job, { status: 'prepared', nativeSessionId, contextEpoch, leaseOwner: workerId, preparedAt: this.repo.now() }); return { jobId: id, nativeSessionId, contextEpoch };
80
+ }); return this.get(id);
81
+ }
82
+ queued() { return this.repo.list('tree_jobs', j => j.status === 'queued' && ROLES.includes(j.role)).sort((a, b) => priority[a.role] - priority[b.role] || a.enqueuedOrder - b.enqueuedOrder || a.id.localeCompare(b.id)); }
83
+ transition(id, next, details = {}) {
84
+ const current = this.get(id); if (current.status === next) return current;
85
+ const allowed = { prepared: ['submitted', 'failed', 'stale', 'cancelled', 'unknown'], submitted: ['result_received', 'unknown', 'cancelled', 'stale'], unknown: ['result_received'], result_received: ['validated', 'invalid_output', 'stale', 'unknown'], validated: ['committed', 'stale', 'cancelled'], queued: ['failed', 'stale', 'cancelled'] };
86
+ if (!allowed[current.status]?.includes(next)) fail('JOB_TRANSITION', `Cannot change ${current.status} to ${next}`);
87
+ this.#command('transitionModelJob', digest(['transition', id, current.status, next, current.revision]), { jobId: id, next, details }, { [recordKey('tree_jobs', id)]: current.revision }, () => {
88
+ this.#replace(current, { ...details, status: next, ...(TERMINAL_JOBS.includes(next) ? { leaseOwner: null } : {}) }); return { jobId: id, status: next };
89
+ }); return this.get(id);
90
+ }
91
+ submit(id) { const job = this.get(id); this.assertCurrent(job); return this.transition(id, 'submitted', { submittedAt: this.repo.now(), startedMs: this.now() }); }
92
+ failBeforeInvocation(id, { nativeLogRef, eventsHash, reason }) {
93
+ const job = this.get(id);
94
+ if (!['submitted', 'unknown'].includes(job.status) || job.invocationIds.length || this.repo.list('tree_invocations', call => call.jobId === id).length) fail('INVOCATION_EXISTS', 'Only a durably ended, never admitted request can be recorded as not sent');
95
+ text(nativeLogRef, 'native failure receipt', 4096); text(eventsHash, 'native failure hash', 128); text(reason, 'native failure reason', 4096);
96
+ this.#command('recordPreDispatchFailure', digest(['not-sent', id, eventsHash]), { jobId: id, nativeLogRef, eventsHash, reason }, { [recordKey('tree_jobs', id)]: job.revision }, () => {
97
+ this.#replace(job, { status: 'failed', leaseOwner: null, nativeLogRef, eventsHash, error: { code: 'NATIVE_PRE_DISPATCH', message: reason }, notSent: true }); return { jobId: id, status: 'failed' };
98
+ }); return this.get(id);
99
+ }
100
+ recordError(id, error) {
101
+ const job = this.get(id), details = { code: error.code ?? 'HOST_ERROR', message: error.message };
102
+ this.#command('recordModelJobError', digest(['job-error', id, job.revision]), { jobId: id, error: details }, { [recordKey('tree_jobs', id)]: job.revision }, () => {
103
+ this.#replace(job, { error: details }); return { jobId: id };
104
+ }); return this.get(id);
105
+ }
106
+ bindMessage(id, message) {
107
+ const job = this.get(id);
108
+ if (job.nativeMessage) { if (canonicalJson(job.nativeMessage) !== canonicalJson(message)) fail('MESSAGE_CONFLICT', 'The native input identity is immutable'); return job; }
109
+ if (job.status !== 'prepared' || message.role !== 'user' || !message.id || message.source?.kind !== 'arcfold/tree-job' || message.source.jobId !== job.id || message.source.inputHash !== job.inputHash || canonicalJson(message.content) !== canonicalJson([{ type: 'text', text: canonicalJson(job.input) }])) fail('MESSAGE_CONFLICT', 'Exact host-created input message required before submission');
110
+ this.#command('bindModelMessage', digest(['native-message', id]), { jobId: id, message }, { [recordKey('tree_jobs', id)]: job.revision }, () => { this.#replace(job, { nativeMessage: jsonClone(message) }); return { jobId: id, messageId: message.id }; }); return this.get(id);
111
+ }
112
+ admitInvocation(id, { turn, step, stepStartSeq, requestHash, allowedTools }) {
113
+ const job = this.get(id); if (job.status !== 'submitted') fail('JOB_NOT_SUBMITTED', 'Request has no submitted job'); this.assertCurrent(job);
114
+ for (const n of [turn, step, stepStartSeq]) integer(n, 'native position', 0);
115
+ const position = { sessionId: job.nativeSessionId, turn, step, stepStartSeq }, requestId = `dsh-messages-${digest(position)}`, invocationId = digest(['tree_invocation', requestId]);
116
+ const existing = this.repo.get('tree_invocations', invocationId);
117
+ if (existing) { if (existing.jobId !== id || existing.requestHash !== requestHash || existing.state !== 'admitted') fail('INVOCATION_REPLAY', 'Native position already owns a different or finished request'); return existing; }
118
+ if (job.role !== 'main' && job.role !== 'script_writer' && job.invocationIds.length >= 1) fail('ROLE_CALL_LIMIT', 'Reader roles have one model request per job');
119
+ if (job.role === 'script_writer' && (job.invocationIds.length >= 12 || this.now() - job.startedMs >= 120000)) fail('WRITER_LIMIT', 'Writer step/time limit reached');
120
+ const tools = [...allowedTools].sort();
121
+ if (!['main', 'script_writer'].includes(job.role) && tools.length || job.role === 'script_writer' && tools.some(t => !WRITER_TOOLS.includes(t))) fail('ROLE_TOOLS', 'Role tools exceed the frozen allowlist');
122
+ this.#command('admitModelInvocation', digest(['admit', invocationId]), { jobId: id, position, requestHash, allowedTools: tools }, { [recordKey('tree_jobs', id)]: job.revision }, () => {
123
+ this.repo.save('tree_invocations', { id: invocationId, jobId: id, role: job.role, nativeSessionId: job.nativeSessionId, nativeTurn: turn, nativeStep: step, stepStartSeq, ordinal: job.invocationIds.length + 1, modelConfigHash: job.modelConfigHash, promptHash: job.promptHash, inputHash: job.inputHash, requestHash, allowedTools: tools, state: 'admitted', ledgerRequestId: requestId });
124
+ this.#replace(job, { invocationIds: [...job.invocationIds, invocationId] }); return { invocationId, requestId };
125
+ }); return this.repo.get('tree_invocations', invocationId);
126
+ }
127
+ invocationResult(invocationId, details) {
128
+ const invocation = this.repo.get('tree_invocations', invocationId); if (!invocation) fail('INVOCATION_MISSING', 'No request identity');
129
+ shape(details, ['state'], ['usage', 'resultHash', 'nativeLogRef']); oneOf(details.state, ['result_received', 'unknown'], 'invocation result');
130
+ if (invocation.state === 'result_received') { if (details.resultHash !== invocation.resultHash) fail('RESULT_CONFLICT', 'A request has one immutable result'); return invocation; }
131
+ this.#command('recordInvocationResult', digest(['invocation-result', invocationId, details.state]), { invocationId, ...details }, { [recordKey('tree_invocations', invocationId)]: invocation.revision }, () => { this.repo.save('tree_invocations', { ...invocation, ...details }, invocation.revision); return { invocationId }; }); return this.repo.get('tree_invocations', invocationId);
132
+ }
133
+ receive(id, { message, nativeLogRef, eventsHash }) {
134
+ const job = this.get(id); text(nativeLogRef, 'durable native log reference', 4096); text(eventsHash, 'native events hash', 128);
135
+ const resultHash = digest(message);
136
+ if (job.resultHash) {
137
+ if (job.resultHash !== resultHash) fail('RESULT_CONFLICT', 'A job cannot replace its received result');
138
+ return job.status === 'unknown' ? this.transition(id, 'result_received') : job;
139
+ }
140
+ return this.transition(id, 'result_received', { rawMessage: jsonClone(message), resultHash, nativeLogRef, eventsHash, resultReceivedAt: this.repo.now() });
141
+ }
142
+ validate(id) {
143
+ const job = this.get(id); if (['validated', 'committed', 'invalid_output', 'stale'].includes(job.status)) return job;
144
+ if (job.status !== 'result_received') fail('RESULT_REQUIRED', 'Durable received output required');
145
+ try {
146
+ this.assertCurrent(job);
147
+ const parsed = job.role === 'main' ? { result: { message: job.rawMessage }, rawHash: digest(job.rawMessage) } : parseFinalOutput(job.role, job.rawMessage, job.input);
148
+ return this.transition(id, 'validated', { validatedResult: parsed.result, outputEnvelope: { schemaVersion: CONTRACT, jobId: id, inputHash: job.inputHash, result: parsed.result }, rawHash: parsed.rawHash, rejectedItems: parsed.rejectedItems ?? [] });
149
+ } catch (e) { return this.transition(id, e.code === 'JOB_STALE' ? 'stale' : 'invalid_output', { error: { code: e.code ?? 'OUTPUT_JSON', message: e.message, validationErrors: e.validationErrors ?? e.errors ?? [] }, rawHash: e.rawHash ?? digest(job.rawMessage) }); }
150
+ }
151
+ commit(id, applyResult = () => ({})) {
152
+ const job = this.get(id); if (job.status === 'committed') return job;
153
+ if (job.status !== 'validated') fail('VALIDATED_REQUIRED', 'Only validated output can commit');
154
+ try {
155
+ return this.repo.transaction(() => {
156
+ this.assertCurrent(this.get(id));
157
+ const result = applyResult(jsonClone(job.validatedResult), { job, requestId: digest(['apply-model-result', id]) });
158
+ if (result?.then) fail('ASYNC_COMMIT', 'Domain application must be synchronous');
159
+ if (job.role === 'learning') {
160
+ const s = this.repo.get('tree_learning_sessions', digest(['learning_session', job.nodeIds[0]]));
161
+ if (s.sessionId !== job.nativeSessionId || s.contextEpoch !== job.contextEpoch) fail('JOB_STALE', 'Learning epoch changed');
162
+ this.#command('appendLearningReceipt', digest(['learning-append', id]), { jobId: id }, { [recordKey('tree_learning_sessions', s.id)]: s.revision }, () => {
163
+ this.repo.save('tree_learning_sessions', { ...s, appendSeq: s.appendSeq + 1, sourceWatermark: job.trigger.watermark ?? job.trigger.ordinal ?? s.sourceWatermark, appliedChangeOrdinal: this.repo.meta().lastEventOrdinal, history: [...s.history, { jobId: id, turnId: job.trigger.turnId ?? null, sourceRefs: job.input.sources.map(s => s.ref), nativeLogRef: job.nativeLogRef, resultHash: job.resultHash, receipt: jsonClone(result), inputHash: job.inputHash }] }, s.revision); return { nodeId: s.nodeId };
164
+ });
165
+ }
166
+ return this.transition(id, 'committed', { resultRef: jsonClone(result), committedAt: this.repo.now() });
167
+ });
168
+ } catch (e) { if (e.code === 'JOB_STALE') return this.transition(id, 'stale', { error: { code: e.code, message: e.message } }); throw e; }
169
+ }
170
+ replaceEpoch(nodeId, { summaryId, sourceWatermark, recentJobIds, summaryHash }) {
171
+ const session = this.repo.get('tree_learning_sessions', digest(['learning_session', nodeId])); if (!session) fail('SESSION_REQUIRED', 'Learning session missing');
172
+ const summary = this.repo.get('tree_summaries', summaryId);
173
+ if (!summary || summary.nodeId !== nodeId || digest(summary) !== summaryHash || canonicalJson(session.history.slice(-3).map(h => h.jobId)) !== canonicalJson(recentJobIds) || sourceWatermark === undefined || summary.sourceWatermark !== sourceWatermark || canonicalJson(summary.coveredJobIds ?? []) !== canonicalJson(session.history.slice(0, -3).map(h => h.jobId))) fail('EPOCH_COVERAGE', 'A verified summary, exact coverage watermark and latest three learning jobs are required');
174
+ if (this.repo.list('tree_jobs', j => j.nodeIds?.includes(nodeId) && ['prepared', 'submitted', 'result_received', 'validated'].includes(j.status)).length) fail('NODE_BUSY', 'Cannot replace an active learning epoch');
175
+ const id = digest(['epoch', nodeId, session.contextEpoch + 1]);
176
+ this.#command('replaceLearningEpoch', id, { nodeId, summaryId, sourceWatermark, recentJobIds, summaryHash }, { [recordKey('tree_learning_sessions', session.id)]: session.revision }, () => {
177
+ this.repo.save('tree_learning_sessions', { ...session, sessionId: `session-${randomUUID()}`, previousSessions: [...(session.previousSessions ?? []), { sessionId: session.sessionId, contextEpoch: session.contextEpoch }], contextEpoch: session.contextEpoch + 1, appendSeq: 0, summaryId, summaryHash, sourceWatermark, recentJobIds, paused: false, estimateTokens: 0 }, session.revision); return { nodeId, epoch: session.contextEpoch + 1 };
178
+ }); return this.repo.get('tree_learning_sessions', session.id);
179
+ }
180
+ pauseLearning(nodeId, reason) {
181
+ const s = this.repo.get('tree_learning_sessions', digest(['learning_session', nodeId])); if (!s) fail('SESSION_REQUIRED', 'Learning session missing'); text(reason, 'pause reason');
182
+ this.#command('pauseLearningEpoch', digest(['pause-learning', s.id, s.revision]), { nodeId, reason }, { [recordKey('tree_learning_sessions', s.id)]: s.revision }, () => { this.repo.save('tree_learning_sessions', { ...s, paused: true, pauseReason: reason }, s.revision); return { nodeId }; });
183
+ }
184
+ }