@entrorove/skill 0.1.24 → 0.1.25

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 (41) hide show
  1. package/CAPABILITY-MAP.md +6 -0
  2. package/CHANGELOG.md +5 -0
  3. package/SKILL.md +7 -1
  4. package/calctool-runtime-entry.mjs +2 -2
  5. package/cli.mjs +18 -1
  6. package/local-archguard-checkpoint.mjs +1 -1
  7. package/local-archguard-snapshot.mjs +12 -12
  8. package/local-calctool-cli.mjs +3 -1
  9. package/local-calctool-runtime.mjs +1 -1
  10. package/local-calctool.mjs +1 -1
  11. package/local-chain-bindings.mjs +93 -0
  12. package/local-chain-broker.mjs +61 -0
  13. package/local-chain-contracts.mjs +21 -0
  14. package/local-chain-controller.mjs +321 -0
  15. package/local-chain-human.mjs +48 -0
  16. package/local-chain-lock.mjs +54 -0
  17. package/local-chain-operations.mjs +7 -0
  18. package/local-chain-plan.mjs +232 -0
  19. package/local-chain-release-labels.mjs +12 -0
  20. package/local-chain-results.mjs +75 -0
  21. package/local-cli.mjs +23 -15
  22. package/local-contract-types.mjs +1 -1
  23. package/local-contracts.mjs +2 -0
  24. package/local-error.mjs +23 -0
  25. package/local-execution.mjs +1 -1
  26. package/local-final-gate16-bundle.mjs +2 -2
  27. package/local-formula-ast.mjs +23 -14
  28. package/local-formula-engine.mjs +17 -3
  29. package/local-formula.mjs +1 -1
  30. package/local-invocations.mjs +35 -0
  31. package/local-ledger.mjs +13 -2
  32. package/local-mutation-authority.mjs +13 -6
  33. package/local-mutation-binding.mjs +5 -2
  34. package/local-mutation-files.mjs +29 -4
  35. package/local-task-handoff-observation.mjs +4 -1
  36. package/manifest.json +1 -1
  37. package/package.json +12 -1
  38. package/release-state.json +38 -28
  39. package/release.mjs +5 -1
  40. package/releases.json +6 -0
  41. package/runtime/calctool-runtime-v7.0.43.mjs +40 -17
@@ -9,6 +9,7 @@ const UUID = /^[a-f0-9-]{36}$/;
9
9
  const ID = /^[a-z][a-z0-9-]{0,63}$/;
10
10
  const COUNTS = ['inputTokens', 'outputTokens', 'durationMs'];
11
11
  const events = new Set(['started', 'completed', 'rejected', 'outcome-unknown', 'not-sent', 'status']);
12
+ const readOnlyOperations = new Set(['ledger-query', 'chain-ledger-query']);
12
13
 
13
14
  function receipt(value, requestId) {
14
15
  if (!value || !UUID.test(value.usageId) || value.requestId !== requestId
@@ -44,6 +45,22 @@ async function ledgerFile(home) {
44
45
  return path.join(directory, 'invocations.jsonl');
45
46
  }
46
47
 
48
+ async function finalGateStateExists(input) {
49
+ if (!input || typeof input.home !== 'string' || typeof input.repositoryRoot !== 'string' || typeof input.gateId !== 'string') return false;
50
+ try {
51
+ const root = await fs.realpath(input.repositoryRoot);
52
+ const repositoryDigest = createHash('sha256').update(root).digest('hex');
53
+ const base = path.join(await fs.realpath(input.home), '.entrorove', 'calctool', 'final-gates', repositoryDigest);
54
+ for (const flow of await fs.readdir(base, { withFileTypes: true })) {
55
+ if (!flow.isDirectory() || flow.isSymbolicLink()) continue;
56
+ const target = path.join(base, flow.name, `${input.gateId}.json`);
57
+ try { const stat = await fs.lstat(target); if (stat.isFile() && !stat.isSymbolicLink()) return true; }
58
+ catch (error) { if (error?.code !== 'ENOENT') throw error; }
59
+ }
60
+ } catch (error) { if (!['ENOENT', 'ENOTDIR'].includes(error?.code)) throw error; }
61
+ return false;
62
+ }
63
+
47
64
  export async function appendInvocation(value, home = os.homedir()) {
48
65
  if (!value || !SHA.test(value.requestId) || !ID.test(value.operation) || !events.has(value.event)
49
66
  || !['remote', 'local'].includes(value.kind)) throw new Error('Invalid invocation ledger metadata');
@@ -77,6 +94,24 @@ function canonical(value) {
77
94
  }
78
95
 
79
96
  export async function withLocalInvocation(operation, input, work) {
97
+ // A final-gate recovery carries only the immutable identity fields and must
98
+ // remain a read-only GET-style lookup. A fresh gate includes plans and keeps
99
+ // the normal invocation audit record.
100
+ if (readOnlyOperations.has(operation)) return work();
101
+ if (operation === 'final-gate' && input) {
102
+ if (!Object.hasOwn(input, 'plans')) return work();
103
+ try {
104
+ const { statePath16 } = await import('./local-final-gate16-state.mjs');
105
+ const target = await statePath16(input);
106
+ if (target) {
107
+ try { await fs.access(target); return work(); }
108
+ catch (error) { if (error?.code !== 'ENOENT') throw error; }
109
+ }
110
+ if (await finalGateStateExists(input)) return work();
111
+ } catch (error) {
112
+ if (error?.code !== 'ENOENT') throw error;
113
+ }
114
+ }
80
115
  const requestId = createHash('sha256').update(operation + canonical(input)).digest('hex');
81
116
  const metadata = { kind: 'local', operation, requestId };
82
117
  await appendInvocation({ ...metadata, event: 'started' }, input.home);
package/local-ledger.mjs CHANGED
@@ -19,9 +19,20 @@ async function repository(value) { if (typeof value !== 'string' || !path.isAbso
19
19
  function repoDigest(root) { return sha(root).slice(0, 32); }
20
20
  async function privateDir(directory) { await fs.mkdir(directory, { recursive: true, mode: 0o700 }); const stat = await fs.lstat(directory); if (!stat.isDirectory() || stat.isSymbolicLink()) fail('ENTROROVE_STATE_INVALID', 'state directory must be a real directory'); if (process.platform !== 'win32') await fs.chmod(directory, 0o700); }
21
21
  async function atomic(file, value) { await privateDir(path.dirname(file)); const tmp = `${file}.${randomUUID()}.tmp`; await fs.writeFile(tmp, `${canonical(value)}\n`, { flag: 'wx', mode: 0o600 }); await fs.rename(tmp, file); if (process.platform !== 'win32') await fs.chmod(file, 0o600); }
22
- async function read(file) { await privateDir(path.dirname(file)); const stat = await fs.lstat(file); if (!stat.isFile() || stat.isSymbolicLink()) fail('ENTROROVE_STATE_INVALID', 'ledger state must be a regular file'); return JSON.parse(await fs.readFile(file, 'utf8')); }
22
+ async function read(file, ensureDirectory = true) { if (ensureDirectory) await privateDir(path.dirname(file)); const stat = await fs.lstat(file); if (!stat.isFile() || stat.isSymbolicLink()) fail('ENTROROVE_STATE_INVALID', 'ledger state must be a regular file'); return JSON.parse(await fs.readFile(file, 'utf8')); }
23
23
  function ledgerPath(home, root) { return path.join(rootFor(home), 'ledgers', repoDigest(root), 'state.json'); }
24
24
  async function loadLedger(input, create = false) { const root = await repository(input.repositoryRoot); const file = ledgerPath(input.home, root); try { const state = await read(file); if (state.schemaVersion !== LEDGER_SCHEMA || state.repositoryDigest !== repoDigest(root)) fail('ENTROROVE_LEDGER_INVALID', 'ledger identity mismatch'); return { root, file, state }; } catch (error) { if (!create || error.code !== 'ENOENT') throw error; const state = { schemaVersion: LEDGER_SCHEMA, repositoryDigest: repoDigest(root), sequence: 0, head: null, activeBranch: null, branches: [], records: [] }; await atomic(file, state); return { root, file, state }; } }
25
+ async function readLedgerForQuery(input) {
26
+ const root = await repository(input.repositoryRoot); const file = ledgerPath(input.home, root);
27
+ try {
28
+ const state = await read(file, false);
29
+ if (state.schemaVersion !== LEDGER_SCHEMA || state.repositoryDigest !== repoDigest(root)) fail('ENTROROVE_LEDGER_INVALID', 'ledger identity mismatch');
30
+ return { root, file, state };
31
+ } catch (error) {
32
+ if (error.code !== 'ENOENT') throw error;
33
+ return { root, file, state: { schemaVersion: LEDGER_SCHEMA, repositoryDigest: repoDigest(root), sequence: 0, head: null, activeBranch: null, branches: [], records: [] } };
34
+ }
35
+ }
25
36
  async function append(input, event, data = {}) { const root = await repository(input.repositoryRoot); const file = ledgerPath(input.home, root); const previous = ledgerLocks.get(file) ?? Promise.resolve(); let release; const current = new Promise(resolve => { release = resolve; }); const queued = previous.then(() => current); ledgerLocks.set(file, queued); await previous; try { return await withStateLock(file, async () => { const authority = await loadLedger(input, true); const at = new Date().toISOString(); const unsigned = { sequence: authority.state.sequence + 1, event, at, ...data }; const record = { ...unsigned, previousDigest: authority.state.head, digest: sha(canonical(unsigned) + (authority.state.head ?? '')) }; const state = { ...authority.state, sequence: record.sequence, head: record.digest, records: [...authority.state.records, record] }; await atomic(authority.file, state); return { authority, state, record }; }); } finally { release(); if (ledgerLocks.get(file) === queued) ledgerLocks.delete(file); } }
26
37
  export async function appendLedgerRecord(input, event, data = {}) { if (typeof event !== 'string' || !event.trim()) fail('ENTROROVE_LEDGER_EVENT', 'ledger event is required'); return (await append(input, event, data)).record; }
27
38
  export async function branchCreate(input) { const name = id(input.name ?? input.branchName, 'branchName'); const fromRef = typeof input.fromRef === 'string' && input.fromRef ? input.fromRef : 'working-tree'; const authority = await loadLedger(input, true); if (authority.state.branches.some(item => item.name === name)) fail('ENTROROVE_BRANCH_EXISTS', `virtual branch already exists: ${name}`); const branch = { branchId: `branch-${randomUUID()}`, name, fromRef, description: input.description ?? '', virtual: true, createdAt: new Date().toISOString(), snapshotId: input.snapshotId ?? null }; const state = { ...authority.state, branches: [...authority.state.branches, branch], activeBranch: authority.state.activeBranch ?? name }; await atomic(authority.file, state); await append(input, 'branch-create', { name, fromRef, virtual: true }); return { schemaVersion: LEDGER_SCHEMA, status: 'verified', branch, forbidGitBranch: true, forbidWorktree: true }; }
@@ -36,7 +47,7 @@ export async function preflight(input) {
36
47
  const reasons = []; if (!archguard.verified) reasons.push(archguard.reason); if (!evidence) reasons.push('trusted signed evidence bound to this flow, snapshot, and contract with exitCode 0 is required');
37
48
  return { schemaVersion: LEDGER_SCHEMA, status: archguard.verified && evidence ? 'verified' : 'blocked', source: 'entrorove-ledger', checks: { archguard: archguard.verified, evidence }, reasons, virtualOnly: true, keyId: archguard.keyId ?? null };
38
49
  }
39
- export async function ledgerQuery(input) { const authority = await loadLedger(input, true); const limit = Number.isSafeInteger(input.limit) ? Math.min(500, Math.max(1, input.limit)) : 100; return { schemaVersion: LEDGER_SCHEMA, status: 'verified', repositoryDigest: repoDigest(authority.root), records: authority.state.records.slice(-limit), head: authority.state.head, sequence: authority.state.sequence }; }
50
+ export async function ledgerQuery(input) { const authority = await readLedgerForQuery(input); const limit = Number.isSafeInteger(input.limit) ? Math.min(500, Math.max(1, input.limit)) : 100; return { schemaVersion: LEDGER_SCHEMA, status: 'verified', repositoryDigest: repoDigest(authority.root), records: authority.state.records.slice(-limit), head: authority.state.head, sequence: authority.state.sequence }; }
40
51
 
41
52
  function validatePlan(plan) { if (!plan || typeof plan !== 'object' || Array.isArray(plan) || plan.schemaVersion !== 'entrorove.execution-plan/1.0' || !ID.test(plan.chainId) || !Array.isArray(plan.steps) || !plan.steps.length) fail('ENTROROVE_CHAIN_PLAN_INVALID', 'chain plan must declare a schema, chainId, and steps'); const seen = new Set(); for (const [index, step] of plan.steps.entries()) { if (!step || typeof step !== 'object' || !ID.test(step.stepId) || seen.has(step.stepId) || !['command', 'skill', 'coordinator', 'decision'].includes(step.kind)) fail('ENTROROVE_CHAIN_STEP_INVALID', `step ${index} is invalid`); seen.add(step.stepId); } return plan; }
42
53
  async function chainAuthority(input, create = false) { const root = await repository(input.repositoryRoot); const chainId = id(input.chainId ?? input.plan?.chainId, 'chainId'); const file = path.join(rootFor(input.home), 'chains', repoDigest(root), chainId, 'state.json'); try { const state = await read(file); if (state.schemaVersion !== CHAIN_SCHEMA || state.chainId !== chainId || state.repositoryDigest !== repoDigest(root)) fail('ENTROROVE_CHAIN_INVALID', 'chain state identity mismatch'); return { root, chainId, file, state }; } catch (error) { if (!create || error.code !== 'ENOENT') throw error; return { root, chainId, file, state: null }; } }
@@ -4,7 +4,7 @@ import { appendLedgerRecord } from './local-ledger.mjs';
4
4
  import { withStateLock } from './local-state-lock.mjs';
5
5
  import { withTaskHandoffIssueFence, withTaskHandoffWriteFence, assertTaskHandoffSnapshotBinding } from './local-task-handoff-fence.mjs';
6
6
  import { mutationFail14, mutationPath14, mutationStateRoot14,
7
- privateMutationDirectory14, writeMutationProjectFile14 } from './local-mutation-files.mjs';
7
+ mutationRead14, privateMutationDirectory14, writeMutationProjectFile14 } from './local-mutation-files.mjs';
8
8
  import { mutationPass14, mutationLifetime14 } from './local-mutation-wire.mjs';
9
9
  import { mutationOriginalBinding14, mutationSnapshotBinding14 } from './local-mutation-binding.mjs';
10
10
  import { installMutationPass14, mutationPassLocations14, readMutationConsumption14,
@@ -42,20 +42,27 @@ export async function issueMutationPass14(input) {
42
42
  });
43
43
  });
44
44
  }
45
- async function checkedPass(input, state) {
45
+ async function checkedPass(input, state, { allowTargetDrift = false } = {}) {
46
46
  const pass = await readStoredMutationPass14(input), target = mutationPath14(input.targetPath, 'targetPath');
47
47
  if (!pass.paths.includes(target)) mutationFail14('ENTROROVE_MUTATION_WRITE_SCOPE', 'Guarded writing is outside the cloud pass path set');
48
48
  const binding = await mutationOriginalBinding14(input, pass, state);
49
- const snapshot = await mutationSnapshotBinding14(input, pass, { targetPath: target });
49
+ const snapshot = await mutationSnapshotBinding14(input, pass, { targetPath: target, allowTargetDrift });
50
50
  assertTaskHandoffSnapshotBinding(binding.state, pass.snapshotReceipt);
51
51
  const locations = await mutationPassLocations14(input, binding, pass);
52
52
  const ledger = await readMutationConsumption14(pass, locations);
53
- if (Object.hasOwn(ledger.writes, target)) mutationFail14('ENTROROVE_MUTATION_PASS_CONSUMED', 'Mutation pass was already consumed or has an unresolved physical write');
53
+ const consumedWrite = ledger.writes[target];
54
+ if (allowTargetDrift) {
55
+ if (consumedWrite?.status !== 'written') mutationFail14('ENTROROVE_MUTATION_PASS_CONSUMED', 'Checkpoint requires a completed guarded write');
56
+ const current = await mutationRead14(input, binding.root, target);
57
+ if (current.sha256 !== consumedWrite.contentDigest) mutationFail14('ENTROROVE_MUTATION_WRITE_CHANGED', 'Checkpoint target differs from its completed guarded write');
58
+ } else if (consumedWrite) {
59
+ mutationFail14('ENTROROVE_MUTATION_PASS_CONSUMED', 'Mutation pass was already consumed or has an unresolved physical write');
60
+ }
54
61
  return { pass, target, binding, snapshot, locations, ledger };
55
62
  }
56
- export async function verifyMutationPass14(input) {
63
+ export async function verifyMutationPass14(input, { allowTargetDrift = false } = {}) {
57
64
  return withMutationAudit14(input, 'mutation-pass-verify', async () => {
58
- const value = await checkedPass(input);
65
+ const value = await checkedPass(input, undefined, { allowTargetDrift });
59
66
  return { schemaVersion: SCHEMA, verified: true, pass: value.pass, targetPath: value.target };
60
67
  });
61
68
  }
@@ -39,7 +39,7 @@ export async function mutationOriginalBinding14(input, authority, state) {
39
39
  }
40
40
  return { root, home, context: ctx, state: current };
41
41
  }
42
- export async function mutationSnapshotBinding14(input, pass, { allSources = false, targetPath } = {}) {
42
+ export async function mutationSnapshotBinding14(input, pass, { allSources = false, targetPath, allowTargetDrift = false } = {}) {
43
43
  mutationPass14(pass);
44
44
  const root = await mutationRoot14(input.repositoryRoot);
45
45
  const snapshot = await snapshotAuthority14(input, root, pass.snapshotReceipt);
@@ -53,7 +53,10 @@ export async function mutationSnapshotBinding14(input, pass, { allSources = fals
53
53
  if (snapshot.manifest.files.some(file => !file.exists && !pass.newFilePaths.includes(file.path))) {
54
54
  mutationFail14('ENTROROVE_MUTATION_NEW_FILE_SCOPE', 'Missing source is outside the original planned new-file set');
55
55
  }
56
- if (allSources || targetPath) await verifySnapshotSources14(input, root, snapshot.manifest,
56
+ // ArchGuard validates the changed target after a guarded write. The original
57
+ // snapshot still binds the pass, while its target source is expected to drift
58
+ // before checkpoint evaluation. Ordinary mutation paths keep the hash check.
59
+ if (allSources || targetPath && !allowTargetDrift) await verifySnapshotSources14(input, root, snapshot.manifest,
57
60
  { paths: allSources ? snapshot.manifest.paths : [targetPath] });
58
61
  return { ...snapshot, root };
59
62
  }
@@ -63,6 +63,31 @@ async function inspectPath(target, { allowMissing = false, final = 'file', code
63
63
  else return { target: resolved, exists: true, stat, ancestors };
64
64
  }
65
65
  }
66
+ async function canonicalDirectoryPath(value, { allowMissing = false, code }) {
67
+ const resolved = absolute(value, 'filesystem path');
68
+ const base = path.parse(resolved).root;
69
+ const parts = path.relative(base, resolved).split(path.sep).filter(Boolean);
70
+ let current = base;
71
+ for (const [index, part] of parts.entries()) {
72
+ const candidate = path.join(current, part);
73
+ let stat;
74
+ try { stat = await fs.lstat(candidate); } catch (error) {
75
+ if (error.code === 'ENOENT' && allowMissing) return path.join(current, ...parts.slice(index));
76
+ throw error;
77
+ }
78
+ if (stat.isSymbolicLink()) {
79
+ const linked = await fs.realpath(candidate);
80
+ const systemAlias = process.platform === 'darwin' && current === base
81
+ && ['/var', '/tmp', '/etc'].includes(candidate) && linked === path.join('/private', candidate);
82
+ if (!systemAlias) mutationFail14(code, 'mutation paths refuse symlink, non-directory ancestors, and invalid file types');
83
+ current = linked;
84
+ continue;
85
+ }
86
+ if (!stat.isDirectory()) mutationFail14(code, 'mutation paths refuse symlink, non-directory ancestors, and invalid file types');
87
+ current = candidate;
88
+ }
89
+ return current;
90
+ }
66
91
  async function assertAncestors(item, code = 'ENTROROVE_STATE_CHANGED') {
67
92
  for (const ancestor of item.ancestors) {
68
93
  const stat = await fs.lstat(ancestor.path);
@@ -72,12 +97,12 @@ async function assertAncestors(item, code = 'ENTROROVE_STATE_CHANGED') {
72
97
  }
73
98
  }
74
99
  export async function mutationRoot14(value) {
75
- const root = absolute(value, 'repositoryRoot');
76
- await inspectPath(root, { final: 'directory', code: 'ENTROROVE_ROOT_INVALID' });
77
- return root;
100
+ const canonicalRoot = await canonicalDirectoryPath(value, { code: 'ENTROROVE_ROOT_INVALID' });
101
+ await inspectPath(canonicalRoot, { final: 'directory', code: 'ENTROROVE_ROOT_INVALID' });
102
+ return canonicalRoot;
78
103
  }
79
104
  export async function mutationHome14(value = os.homedir()) {
80
- const home = absolute(value, 'home');
105
+ const home = await canonicalDirectoryPath(value, { allowMissing: true, code: 'ENTROROVE_HOME_INVALID' });
81
106
  await inspectPath(home, { allowMissing: true, final: 'directory', code: 'ENTROROVE_HOME_INVALID' });
82
107
  return home;
83
108
  }
@@ -9,7 +9,10 @@ function fail(message) { throw Object.assign(new Error(message), { code: 'ENTROR
9
9
  export async function loadTaskHandoffSigner(ctx, file, keyId) {
10
10
  async function control(target) {
11
11
  if (typeof target !== 'string' || !path.isAbsolute(target)) fail('runner signer controls are unavailable; an external absolute configuration is required');
12
- const actual = await fs.realpath(target), relative = path.relative(ctx.root, actual);
12
+ let actual;
13
+ try { actual = await fs.realpath(target); }
14
+ catch { fail('runner signer controls are unavailable; an external absolute configuration is required'); }
15
+ const relative = path.relative(ctx.root, actual);
13
16
  if (actual !== path.resolve(target) || relative === '' || (!relative.startsWith(`..${path.sep}`) && relative !== '..' && !path.isAbsolute(relative))) fail('runner signer controls must be external real files');
14
17
  const value = await regular(target, 16 * 1024);
15
18
  if ((process.platform !== 'win32' && (value.stat.mode & 0o077)) || (typeof process.getuid === 'function' && value.stat.uid !== process.getuid())) fail('runner signer controls must be owned private files');
package/manifest.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "schemaVersion": "entrorove.skill-manifest/1.0",
3
3
  "name": "entrorove",
4
- "version": "0.1.24",
4
+ "version": "0.1.25",
5
5
  "endpoint": "https://entrorove.com",
6
6
  "requestSchemaVersion": "entrorove.skill.request/2.0",
7
7
  "supportedRequestSchemaVersions": [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@entrorove/skill",
3
- "version": "0.1.24",
3
+ "version": "0.1.25",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "entrorove": "cli.mjs"
@@ -37,6 +37,16 @@
37
37
  "local-evidence-verification.mjs",
38
38
  "local-formula.mjs",
39
39
  "local-ledger.mjs",
40
+ "local-chain-bindings.mjs",
41
+ "local-chain-broker.mjs",
42
+ "local-chain-contracts.mjs",
43
+ "local-chain-controller.mjs",
44
+ "local-chain-human.mjs",
45
+ "local-chain-lock.mjs",
46
+ "local-chain-operations.mjs",
47
+ "local-chain-plan.mjs",
48
+ "local-chain-release-labels.mjs",
49
+ "local-chain-results.mjs",
40
50
  "local-approved-plan.mjs",
41
51
  "workflow-local-runner.mjs",
42
52
  "workflow-local-client.mjs",
@@ -133,6 +143,7 @@
133
143
  "local-formula-tests.mjs",
134
144
  "local-calctool.mjs",
135
145
  "local-calctool-cli.mjs",
146
+ "local-error.mjs",
136
147
  "local-calctool-contracts.mjs",
137
148
  "local-calctool-runtime.mjs",
138
149
  "local-calctool-storage.mjs",
@@ -1,13 +1,13 @@
1
1
  {
2
2
  "schemaVersion": "entrorove.release-state/1.0",
3
- "version": "0.1.24",
4
- "digest": "48ec31da93f81d541551d5168e946e2fcd0516e9a19970d994199d9e9ab42ba4",
3
+ "version": "0.1.25",
4
+ "digest": "51ba0446dc6d2004bdf31195d4cf62bdd522f3c0a76de1c35344df32ae130046",
5
5
  "files": {
6
6
  "local-final-gate16.mjs": "b30caab08441449ad556a6e9a3cefbf91190f0b3f7370f46d2a21156375aa8e3",
7
7
  "local-final-gate16-checks.mjs": "1dd30e9666d458d8b12ca6e4975f6f0ac8450c3ea9cbecc7401ae037f66b589a",
8
8
  "local-final-gate16-worker.mjs": "344d64b6c1c5552b96960e0d8514a876a3dbfa9762ab9eb749038e8351727121",
9
9
  "local-final-gate16-plan.mjs": "9d34c51fa239376e552abc312409a7f3258519efe0b29d88a5054680cdda483c",
10
- "local-final-gate16-bundle.mjs": "92cc4008676abff816c8ca9244d06e5a2c16eed7e0e22b8c3a4e886417bf2b9c",
10
+ "local-final-gate16-bundle.mjs": "08cff47892199a0b1c08196e151770075ee09a9036e9aea4906fdb619edf7499",
11
11
  "local-final-gate16-state.mjs": "a8cf6ac28183fb2274772774d52418a82343485271f78b4d304d0dae7c8a642d",
12
12
  "local-final-gate16-proof.mjs": "5f7d5be29b1ce5fbb1a05fd2204969215ec5d0297ad84dcb7b29abb3ef285f56",
13
13
  "local-final-gate16-contract.mjs": "cffa9b0d89eab360ce6c27c73400b8b377adbdc32813b244f754423d10061fe0",
@@ -36,24 +36,34 @@
36
36
  "local-swarm-state.mjs": "ff84c5f35ef3e4a0ad9887d145e404f5786ae0dc92496116699d031b98e90c6a",
37
37
  "local-swarm-storage.mjs": "4b506481913fe22769f47a14a024a2cc4c0f1e986bb52ef606d14c7844226086",
38
38
  "local-swarm.mjs": "94b5adde0239cc21bada288886482934d409f4383d31076025437ed621ef43b6",
39
- "cli.mjs": "f85f2583e657a8120666bf559d6d6fa39c3401f95f258f7d149c7d43b7ec80cd",
39
+ "cli.mjs": "c85289b1cbe0ea469a57e67edb1fc475dc9c626c2beae00f5e0954c00757481d",
40
40
  "review-client.mjs": "3491eef68ed533174b31db3c53febb6519b8f088f71a9964644714a9a4c03c77",
41
41
  "version.mjs": "1f392147d72c40af4b03b307ea607075fb815af5174fa07e254b95fe12dbf3e7",
42
- "SKILL.md": "e4deb2f0a82829d156ddcf804cd593645c95ef1b1da0ded54b41e11ac7cf4ba3",
43
- "CAPABILITY-MAP.md": "276426da6b76820b3a502fe4cd80ca246ccab2d6c70036f89793b63fbced1552",
42
+ "SKILL.md": "f6a140c4d0dd79b6985d1cd085c831a26c64119a4107e35d25c3ac5a566ed797",
43
+ "CAPABILITY-MAP.md": "1a58ac506efe894c0ba224605a758cbc8a1c8f7b9ed05ce355e0c9a3ce43c61f",
44
44
  "AGREEMENT.md": "f93a68e92c457200dd024610e65f74e478b8f60860f951da818b70561f1b9bf8",
45
- "package.json": "8f12e62ea09b1fa70e9fd1c7fb59e199f150a1b5bd8d8148f2b0a1b5c85462b4",
45
+ "package.json": "a60c18189e680be7c8547c5331ceee74f64115dc571e8e069e76570a5fcede0b",
46
46
  "manifest.json": "ab06e307795bc9da5add926752b6c4dbdaf18625090261a035aa356a679c504f",
47
- "release.mjs": "94f221b47049ccf19d887d06376a52b3668e56674eb95f55e21507461c4ba166",
48
- "local-cli.mjs": "17146679dd2c7236bf37e6a1d5549a6b32372e6f9925adcd57fc60849c00abc9",
47
+ "release.mjs": "b6882c24084129a0853330ec707b46fac69c25d533cdcc23b95b23cdefda27e9",
48
+ "local-cli.mjs": "ecc85bc5f36a5663fef004e59525374dc7883d3889f5a0cfe219ae5e84b2d3ea",
49
49
  "local-state-lock.mjs": "1dbf64264642a0851faf043e07ee9f66d2ad0508e9f799b0626246e42fae893e",
50
- "local-execution.mjs": "bb3efe6c7e19343aed2d542a74fc949c4dd8a22c412f8ef8b2a4c9612f6bb045",
50
+ "local-execution.mjs": "97dfad5e889e679e9cc34434005342526502795abba21078fa40673788073153",
51
51
  "local-integrity.mjs": "4f325376e536999c3b00245f66aac9ef1ae762c33012b363c231622008f72805",
52
52
  "local-evidence-verification.mjs": "9b7dea31777505272f63616fd62af7603338f703bb1285fc565d6451715c7ce0",
53
- "local-formula.mjs": "64d19bd08798f21be3a67b0ce9d0965416131e576374cd6672b2cef0d01ad967",
54
- "local-ledger.mjs": "d393dee5fb64c94afd32a235649b3793ef490b1091fbc8ada04b2298c711ec03",
53
+ "local-formula.mjs": "706d5ec0cb2caf95a547531a81ca746fe246d4c53d902e03595a3b360e8c85af",
54
+ "local-ledger.mjs": "2009fb68aadcd7292b72f1d0d99f378d03644be1c5b4b4150d9f6a0b3f7c15ce",
55
55
  "blueprint-criterion-wire.mjs": "fb6b7dcce0b97a699e3e8968372767a3fddd83fedc750998573f789177243072",
56
56
  "blueprint-criterion-runner.mjs": "761c0e670b4b20c6bed53e5ca5d0178759505c3977ea2ed1b7223383c534bc4c",
57
+ "local-chain-bindings.mjs": "b8eff999ccd17b53c614a3c9f5376da78fcaa8632bb3e07bc1adcceda2e75f6c",
58
+ "local-chain-broker.mjs": "0f43ae3d5e713a344d94c3f67891b0d5f6ed0b70a8d237a54d06c0fa36e4c6d8",
59
+ "local-chain-contracts.mjs": "d4d2be4e5c6980f2dda8ff598c7333a75e0bbf2f656aa7fe327af7c9fe73da07",
60
+ "local-chain-controller.mjs": "dd80d8001a11d8433b615a017a34c30dd130ff5cd9c5b58731314a8f93c9571b",
61
+ "local-chain-human.mjs": "9d5dbaef13ebbbe0cfdd1146cd9c48cefcca30f33ccc0b1566e67cbd730d3499",
62
+ "local-chain-lock.mjs": "e0bc9dccc3c152007f4d67eff0b48de40516ce991b6868f513bc88b53a9b41b2",
63
+ "local-chain-operations.mjs": "78ba550f374b3b7fd03abfc88ae773e1189795d498929a13089fbf50a5611d9f",
64
+ "local-chain-plan.mjs": "e45a9170f569c80f7e5532bbc109dbae3c24b696815316e503cb3779135be648",
65
+ "local-chain-release-labels.mjs": "7ce5350d1062d5236f57f10e7e81713c1101463d5e7f252604ef0c2a30840bfe",
66
+ "local-chain-results.mjs": "0455f72cad574cfef87c2c6c0928d6432bba7e5dbaff521a2b62a9575299ca10",
57
67
  "local-approved-plan.mjs": "bb31bf0f826ce22d1e57c6d9cda5314cbbb1fc04e6c0778eb319045ab23a3193",
58
68
  "local-budget-renewal.mjs": "6b436358066fe33b2f6d871fc90d8e7c551edd6255efd73c0d255d0fd35d5ef8",
59
69
  "local-probe.mjs": "9b948db1de1e34c7605c4c454cd4aced66d2b57d2cf4471adbb80bcc76dee635",
@@ -101,14 +111,14 @@
101
111
  "remote-client.mjs": "dabbcc7bd0fd16ba029163f407edb85e914df83cd5914221eb2ce0d061ca8f28",
102
112
  "remote-transport.mjs": "5e5a8c0b490371f7f5a052cffbeee6202d4cf713dff1c43b6d910fe944f8429c",
103
113
  "local-credential.mjs": "2a302a7f67b5ea8dc73453fc96ab33db349b59ca11fa9456e48ca1d9669d877c",
104
- "local-invocations.mjs": "c7cc4a2d8fbe271865c12ff0cfb89b8db3bbd07e28f26854cd4df18698d31f94",
114
+ "local-invocations.mjs": "f6ac4c41c1a5ec34de358c4cca210ca8e403b3a913150a43bb55ec9be55d9998",
105
115
  "local-task-handoff-fence.mjs": "5532a359ee29b885f7ce0cf7178f76dba0fc9245bdd92ef460982275baf0d557",
106
116
  "local-task-handoff-storage.mjs": "f4d5046d54658e8092e035b31fdaeaed6fdbef55a6953827417c857f2e9e6638",
107
117
  "local-task-handoff-overlay-wire.mjs": "4fbe3f9d1ed8b6ddf839e9525cdc52c930a22e9d4f4ad562c6f193f621ee6527",
108
118
  "local-task-handoff-completion-wire.mjs": "2f3708b162d2d7524dde8c276bb0ad2328c17eefa597e50f30a2cd2ca1fa7fba",
109
119
  "local-task-handoff-contracts.mjs": "c03094185fd05308c8a968a719b121e04e2b86201cc0408f3656e7f81f1840ad",
110
120
  "local-task-handoff-write-constraints.mjs": "19cba9330dddd170d1cb8707405574f888292acea2dc3f48bc71f6b3c61065cc",
111
- "local-task-handoff-observation.mjs": "1bb0d6fb42825f64c5fc3975be7fe3306992a98450382c7fde73d2571a860e91",
121
+ "local-task-handoff-observation.mjs": "d17c75dba647a1ce01681feeb9109c2f51927309255452166a2c2f7758c5ab50",
112
122
  "local-task-handoff-write-wire.mjs": "7b49b66de8ecc33c1c92e9dd50118528972f00e1c7748cb3a230a58eefa9ff3f",
113
123
  "local-task-handoff-read-authority.mjs": "7d2991cfac4562039232566d25441aa98106ce27bc9b7bcf6df4ff28d738e167",
114
124
  "local-task-handoff-write-archive.mjs": "3668f39d8ab5664ed717994cf1b4bcc7ea23e146a0e06291f851d30337faeb77",
@@ -119,10 +129,10 @@
119
129
  "workflow-local-request.mjs": "e8cce489ecadc756e27bf8686556a830e9e789df03a56e034f7b741e6455d3ae",
120
130
  "task-message-entry.mjs": "31d10056cb905237cd5649e8eb067009ff1612ec5dc1061e0373209fbc0b646a",
121
131
  "task-message-cli.mjs": "3ca65e96c28129b3bd5cac962d106e0859a1f17df1f6a66ee543c6efc22f918d",
122
- "local-contracts.mjs": "805bf53e3ba9f83f7c0e889f605308f74ff981ff6606a92422e2cda25c33dc0b",
123
- "local-contract-types.mjs": "b338b623ea8085dc04536795e4a01c208189cf430e12ef8c08aaa5e21b01b0b9",
132
+ "local-contracts.mjs": "43715cd73460ccea734501a3dd5667184da0f6a5b0189e038319a439ba52fc76",
133
+ "local-contract-types.mjs": "5d244237fc69052b2fa83098022761bae929e8b75a0683883b620711bc132050",
124
134
  "local-archguard-ast.mjs": "cefd835312a623e83bf48e4d8e1f76ac6a8adc56c35843705c16967d16bec4e6",
125
- "local-archguard-checkpoint.mjs": "beaedc6992f0d9b36b6e881abf4ea5798f678d7b5dd00e1cc15feb254edeefc7",
135
+ "local-archguard-checkpoint.mjs": "1ee3a40561940a62583382036d4049321627fe9e41e0f31a973ef40bc86fcb55",
126
136
  "local-archguard-components.mjs": "a8fb3d0fd787f9160905013fd333cc0b9e8cf624cce797461f36f4ab31d170ad",
127
137
  "local-archguard-contract.mjs": "cfc397a1ce171ab84f8bf1db152e6d8d280b7ca041da56a7a4cd62b02c2e2e89",
128
138
  "local-archguard-contracts.mjs": "29e9b86f14dc086aa0b791c874d6efc50d849f806b3f2b8725481114f01ba8ba",
@@ -132,31 +142,31 @@
132
142
  "local-archguard-operations.mjs": "1a35268b857b86eab54dd4efa78fc54bf08b39dfab4fa980c7dd63b8959206b0",
133
143
  "local-archguard-scope.mjs": "afbd23a79c175f198892ddb2026ecac219a1650ee6115763736d9b1ee5a07ee0",
134
144
  "local-archguard-signing.mjs": "7de0fe4b8f6f5a92d043ad8976054faaa76b7b37556ed582e5e012c6f0facc87",
135
- "local-archguard-snapshot.mjs": "9c47d68ebfd3b05fc0c2acd04f6c69f5c10b7676a5c3ecceee786d72e0505d4b",
145
+ "local-archguard-snapshot.mjs": "81b456915232b8e87d9b663912bf95062753a0bd0e38a096cbd80c2ef03683f9",
136
146
  "local-archguard-state.mjs": "1d3c74c4ec222646e82120be4aa6a00c52d2867fb8c20323420d6ab0bf2cd46c",
137
147
  "local-archguard.mjs": "4c8ec5d569d728b15161eb88e49371c77def7d1fe511aa16694581a2c1d59433",
138
148
  "archguard-runtime-build.mjs": "45bf31764763973bd8de32b3e16d194162407e7cb74802df3ab363f0fd30e866",
139
149
  "runtime/archguard-runtime-v7.0.44.mjs": "836f20b270139371bef43e3934fa7232371e42d46371c513db2a5e3c5622b215",
140
150
  "local-decimal.mjs": "93580b0cbdac2e881942658c1cd10394b432219f8657afaaaf4cb1c35c91d644",
141
- "local-formula-ast.mjs": "e7475176409f146f46eb8565be8df4108c88f12b46c9b600915834f9843089d7",
142
- "local-formula-engine.mjs": "3b6ed599575b17d3da738e0c798804264540cbff87c58459e2ef3fa6072cd003",
151
+ "local-formula-ast.mjs": "768ec4aa88a127588035d5b94aa2721df00e3e0576dc7e341baaead44795f314",
152
+ "local-formula-engine.mjs": "cfc44f03bb8dc4797f9f9e859a64a2dd5e4dd316db45624c03f7f8fad3202ede",
143
153
  "local-formula-tests.mjs": "d6d2809572c46955e6b556023ed6a12362c8acd0ad0d5be3b1b2dd64439215c0",
144
- "local-calctool.mjs": "52c5a53a4c2dda5b62bfc00dfd0f027cbc6ec0c601f10c848842a06ffd5ee93a",
145
- "local-calctool-cli.mjs": "75eb8916efbb9049e382a25a312fb0e1fc8bbad6f051e4f024a7e0a2b1588953",
154
+ "local-calctool.mjs": "9398e642250b38b2831ec58e490fbbef761455ce9ae49af033baa8c3ba8d778e",
155
+ "local-calctool-cli.mjs": "a6b46e2aa9a91dfb73fc0dc415b2281a0343e22e317ff1bfcf58cadbb8093122",
146
156
  "local-calctool-contracts.mjs": "d8fccf662b2dd7aefd5cc6770b2473014af7a28855074fcf294937ca5a49eae8",
147
- "local-calctool-runtime.mjs": "a8ec5bc77925a6f43cf4a1bae666d46d109e90b59b1064e4832e6c5bb209f187",
157
+ "local-calctool-runtime.mjs": "9070b6900d1782bf77357fea1cc522bf796898d56929f96d7cb935b3036ac82f",
148
158
  "local-calctool-storage.mjs": "4c3f352847aeb368dea1247759a1055beeab25534adf25b6e7323196d76fd684",
149
- "local-mutation-files.mjs": "2de96efc1def554a7fca062e1e605c3dbb60aecd0942b19eb4d179100f0e258f",
159
+ "local-mutation-files.mjs": "b99a1987e0884cee1f0f04552300fbf61853ea2547d74c77b77c71fc3ebb89ad",
150
160
  "local-mutation-snapshot.mjs": "a2f5f1df1fa0ecb40bdd4776e319c5244508ed04a2aa4493aebcec926fc61148",
151
161
  "local-mutation-audit.mjs": "5a30ee3446f632e032ffcfe46bcd97d6dfc0f099a76ea5185c3f9a9118c4f6cb",
152
162
  "local-mutation-wire.mjs": "dc79992422254d876bec94c621407bede94c9f43c95367a2c9f16a2a64b15576",
153
- "local-mutation-binding.mjs": "a4d91da2a7199c8c74adf53c67ae8ccdb2912cb96401c59c7b6c72d36c4e9241",
163
+ "local-mutation-binding.mjs": "a1756642dd758701b476d332a48799b4af56fdd8f67aba6d46ca9bb2341b6327",
154
164
  "local-mutation-pass-store.mjs": "8d6b57c6d1ea3df15a794e6a4cd39497f4412a5f911b9a30b0437498f51f92c0",
155
- "local-mutation-authority.mjs": "3a8c275218129afe2674391771d32f7041fe31925431d7e6b73c16a2f664d61e",
165
+ "local-mutation-authority.mjs": "28561b5b672838a46fa13b0044b5d9302630d073aa5893a7e0990e6567336499",
156
166
  "local-mutation-batch.mjs": "80293d8111203687e2527cd23b771447cc44ff58c0b0155c368bcbfa05aa3bb8",
157
167
  "local-mutation-batch-receipt.mjs": "b0e6a8522867255d899e60b66677885933f6a1c4c771cbb52f84dbbbdcf5e5c2",
158
- "calctool-runtime-entry.mjs": "ae0c678c5b81edb20b5e947548b57c69737659c71fa1c012e4efa5b2153ac261",
168
+ "calctool-runtime-entry.mjs": "98d2bc8df38d16131fc56db0a3d4d260531ba4df412baa5ddc4b8b5c5ca99fe9",
159
169
  "calctool-runtime-build.mjs": "3c5a9bd40bc1abe02e3b6e307809eea4eaaf198e8f0db6f5d6b2583dd68a59d2",
160
- "runtime/calctool-runtime-v7.0.43.mjs": "5d3506320d6baba4a8ca42cf55d062ae2b10f186741896c8cc93e536734f961f"
170
+ "runtime/calctool-runtime-v7.0.43.mjs": "5473427c350411f62c16ac5239fa0db7b96e7b2c19458f9046768dcb4ada87d1"
161
171
  }
162
172
  }
package/release.mjs CHANGED
@@ -3,6 +3,7 @@ import { createHash } from 'node:crypto'
3
3
  import { readFile, writeFile, rename, rm } from 'node:fs/promises'
4
4
  import path from 'node:path'
5
5
  import { fileURLToPath } from 'node:url'
6
+ import { chainReleaseLabels } from './local-chain-release-labels.mjs'
6
7
 
7
8
  const directory = path.dirname(fileURLToPath(import.meta.url))
8
9
  const swarmSourceFiles = Object.freeze([
@@ -38,6 +39,9 @@ const sourceFiles = [
38
39
  'package.json', 'manifest.json', 'release.mjs',
39
40
  'local-cli.mjs', 'local-state-lock.mjs', 'local-execution.mjs', 'local-integrity.mjs', 'local-evidence-verification.mjs', 'local-formula.mjs', 'local-ledger.mjs',
40
41
  'blueprint-criterion-wire.mjs', 'blueprint-criterion-runner.mjs',
42
+ 'local-chain-bindings.mjs', 'local-chain-broker.mjs', 'local-chain-contracts.mjs', 'local-chain-controller.mjs',
43
+ 'local-chain-human.mjs', 'local-chain-lock.mjs', 'local-chain-operations.mjs', 'local-chain-plan.mjs',
44
+ 'local-chain-release-labels.mjs', 'local-chain-results.mjs',
41
45
  'local-approved-plan.mjs', 'local-budget-renewal.mjs', 'local-probe.mjs',
42
46
  'local-budget-grants.mjs', 'local-budget-handoff.mjs', 'local-budget-handoff-contracts.mjs', 'local-budget-lock.mjs',
43
47
  'local-budget-handoff-completion.mjs',
@@ -227,7 +231,7 @@ function releaseNote(changed) {
227
231
  if (suppliedZh && suppliedEn) return {
228
232
  zh: suppliedZh.replaceAll(/\s+/g, ' '), en: suppliedEn.replaceAll(/\s+/g, ' '),
229
233
  }
230
- const labels = withFinalGate16ReleaseLabels({ ...calctoolReleaseLabels, ...mutationReleaseLabels, ...swarmReleaseLabels, ...nativeReadReleaseLabels, ...feedbackReleaseLabels, ...invocationReleaseLabels,
234
+ const labels = withFinalGate16ReleaseLabels({ ...calctoolReleaseLabels, ...mutationReleaseLabels, ...swarmReleaseLabels, ...nativeReadReleaseLabels, ...feedbackReleaseLabels, ...invocationReleaseLabels, ...chainReleaseLabels,
231
235
  'blueprint-criterion-wire.mjs': { zh: '蓝图验收标准执行授权', en: 'Blueprint criterion execution authority' },
232
236
  'blueprint-criterion-runner.mjs': { zh: '蓝图验收标准实际执行器', en: 'Blueprint criterion process runner' },
233
237
  'local-archguard-ast.mjs': { zh: 'Archguard 架构合同与执行守卫', en: 'Archguard architecture contract and execution guard' },
package/releases.json CHANGED
@@ -1,4 +1,10 @@
1
1
  [
2
+ {
3
+ "version": "0.1.25",
4
+ "date": "2026-10-07",
5
+ "zh": "原链本地执行与固定Git计费收口,补齐macOS路径和发布夹具",
6
+ "en": "Close original-chain local execution and fixed Git accounting, with macOS path and release fixture coverage"
7
+ },
2
8
  {
3
9
  "version": "0.1.24",
4
10
  "date": "2026-10-07",
@@ -1528,7 +1528,8 @@ class Decimal {
1528
1528
  const Decimal28 = Decimal;
1529
1529
 
1530
1530
  const FORMULA_OPERATORS = Object.freeze(['add', 'sub', 'mul', 'div', 'safeDivide', 'percentOf', 'round', 'if']);
1531
- const ARITY = { add: [0, 1000], mul: [0, 1000], sub: [2, 2], div: [2, 2], safeDivide: [2, 2], percentOf: [2, 2], round: [1, 1], if: [3, 3] };
1531
+ const FORMULA_ROUNDING_POLICY = Object.freeze({ decimalPlaces: 2, mode: 'half-away-from-zero' });
1532
+ const ARITY = { add: [1, 1000], mul: [1, 1000], sub: [2, 2], div: [2, 2], safeDivide: [2, 2], percentOf: [2, 2], round: [1, 1], if: [3, 3] };
1532
1533
  function formulaKey(value) {
1533
1534
  if (typeof value !== 'string' || !value.trim() || value.length > 128 || /[\u0000-\u001f\u007f]/.test(value)) throw formulaError('FORMULA_KEY_INVALID', 'formula and field keys must be non-empty strings of at most 128 characters');
1534
1535
  return value;
@@ -1557,28 +1558,35 @@ function formulaRefs(node, output = new Set()) {
1557
1558
  if (node.args) for (const item of node.args) formulaRefs(item, output);
1558
1559
  return [...output].sort();
1559
1560
  }
1560
- function evaluateNode(node, resolve) {
1561
+ function roundPlaces(policy) {
1562
+ const value = policy?.decimalPlaces;
1563
+ if (!Number.isInteger(value) || value < 0 || value > 28) throw formulaError('ROUND_POLICY_INVALID', 'rounding policy decimalPlaces must be an integer from 0 to 28');
1564
+ if (policy.mode !== 'half-away-from-zero') throw formulaError('ROUND_POLICY_INVALID', 'rounding policy mode must be half-away-from-zero');
1565
+ return value;
1566
+ }
1567
+ function evaluateNode(node, resolve, policy) {
1561
1568
  if (Object.hasOwn(node, 'lit')) return Decimal.from(node.lit);
1562
1569
  if (Object.hasOwn(node, 'ref')) return Decimal.from(resolve(node.ref));
1563
1570
  const [left, right, alternate] = node.args;
1564
1571
  switch (node.op) {
1565
- case 'add': return node.args.reduce((total, item) => total.plus(evaluateNode(item, resolve)), Decimal.from('0'));
1566
- case 'mul': return node.args.reduce((total, item) => total.times(evaluateNode(item, resolve)), Decimal.from('1'));
1567
- case 'sub': return evaluateNode(left, resolve).minus(evaluateNode(right, resolve));
1568
- case 'div': return evaluateNode(left, resolve).div(evaluateNode(right, resolve));
1569
- case 'safeDivide': { const denominator = evaluateNode(right, resolve); return denominator.isZero() ? Decimal.from('0') : evaluateNode(left, resolve).div(denominator); }
1570
- case 'percentOf': return evaluateNode(left, resolve).div(evaluateNode(right, resolve)).times('100');
1571
- case 'round': return evaluateNode(left, resolve).rounded(2);
1572
- case 'if': return evaluateNode(left, resolve).isZero() ? evaluateNode(alternate, resolve) : evaluateNode(right, resolve);
1572
+ case 'add': return node.args.reduce((total, item) => total.plus(evaluateNode(item, resolve, policy)), Decimal.from('0'));
1573
+ case 'mul': return node.args.reduce((total, item) => total.times(evaluateNode(item, resolve, policy)), Decimal.from('1'));
1574
+ case 'sub': return evaluateNode(left, resolve, policy).minus(evaluateNode(right, resolve, policy));
1575
+ case 'div': return evaluateNode(left, resolve, policy).div(evaluateNode(right, resolve, policy));
1576
+ case 'safeDivide': { const denominator = evaluateNode(right, resolve, policy); if (denominator.isZero()) throw formulaError('DIV_ZERO', 'division by zero', { nodeId: 'safeDivide' }); return evaluateNode(left, resolve, policy).div(denominator); }
1577
+ case 'percentOf': return evaluateNode(left, resolve, policy).div(evaluateNode(right, resolve, policy)).times('100');
1578
+ case 'round': return evaluateNode(left, resolve, policy).rounded(roundPlaces(policy));
1579
+ case 'if': return evaluateNode(left, resolve, policy).isZero() ? evaluateNode(alternate, resolve, policy) : evaluateNode(right, resolve, policy);
1573
1580
  default: throw formulaError('UNSUPPORTED_OPERATOR', 'unsupported normalized formula operator');
1574
1581
  }
1575
1582
  }
1576
- function evaluateFormula(node, values = {}) {
1583
+ function evaluateFormula(node, values = {}, options = {}) {
1577
1584
  formulaObject(values, 'formula values');
1585
+ const policy = options.roundingPolicy ?? FORMULA_ROUNDING_POLICY;
1578
1586
  return evaluateNode(normalizeFormula(node), key => {
1579
1587
  if (!Object.hasOwn(values, key) || values[key] === null || values[key] === undefined || values[key] === '') throw formulaError('MISSING_INPUT', `missing input: ${key}`, { fieldId: key });
1580
1588
  return values[key];
1581
- });
1589
+ }, policy);
1582
1590
  }
1583
1591
  function compileFormulaGraph(formulas, fieldKeys) {
1584
1592
  if (!Array.isArray(formulas) || formulas.length > 1000) throw formulaError('FORMULA_GRAPH_INVALID', 'formulas must be an array of at most 1000 definitions');
@@ -1605,8 +1613,9 @@ function compileFormulaGraph(formulas, fieldKeys) {
1605
1613
  for (const key of [...definitions.keys()].sort()) visit(key);
1606
1614
  return { formulas: [...definitions.values()].sort((a, b) => a.key < b.key ? -1 : a.key > b.key ? 1 : 0), dependencies, order };
1607
1615
  }
1608
- function evaluateFormulaGraph(formulas, inputs = {}) {
1616
+ function evaluateFormulaGraph(formulas, inputs = {}, options = {}) {
1609
1617
  formulaObject(inputs, 'formula inputs');
1618
+ const policy = options.roundingPolicy ?? FORMULA_ROUNDING_POLICY;
1610
1619
  const values = new Map(Object.entries(inputs).map(([key, value]) => [key, Decimal.from(value)]));
1611
1620
  const graph = compileFormulaGraph(formulas);
1612
1621
  const definitions = new Map(graph.formulas.map(item => [item.key, item.expression]));
@@ -1618,13 +1627,14 @@ function evaluateFormulaGraph(formulas, inputs = {}) {
1618
1627
  return values.get(key);
1619
1628
  }
1620
1629
  if (visiting.has(key)) throw formulaError('FORMULA_CYCLE', `formula cycle at ${key}`);
1621
- visiting.add(key); const value = evaluateNode(definitions.get(key), resolve); visiting.delete(key); results.set(key, value); return value;
1630
+ visiting.add(key); const value = evaluateNode(definitions.get(key), resolve, policy); visiting.delete(key); results.set(key, value); return value;
1622
1631
  };
1623
1632
  return Object.fromEntries(graph.formulas.map(item => [item.key, resolve(item.key).toString()]));
1624
1633
  }
1625
1634
 
1626
1635
  const FORMULA_SCHEMA = 'entrorove.formula-engine/1.0';
1627
1636
  const FORMULA_RUNTIME_VERSION = 'decimal28-ast/1.0';
1637
+ { FORMULA_ROUNDING_POLICY };
1628
1638
  const TYPES = ['number', 'integer', 'money', 'percent', 'text', 'date', 'enum'];
1629
1639
  const NUMERIC = TYPES.slice(0, 4);
1630
1640
  const ID = /^[a-z0-9][a-z0-9._-]{0,127}$/;
@@ -1642,6 +1652,14 @@ function cloned(value) { return JSON.parse(formulaCanonical(value)); }
1642
1652
  function requiredText(value, field) {
1643
1653
  if (typeof value !== 'string' || !value.trim()) throw formulaError('ENGINE_REQUIRED_FIELD', `${field} is required`);
1644
1654
  }
1655
+ function normalizeRoundingPolicy(value) {
1656
+ const policy = value ?? FORMULA_ROUNDING_POLICY;
1657
+ formulaObject(policy, 'roundingPolicy');
1658
+ if (Object.keys(policy).sort().join(',') !== 'decimalPlaces,mode'
1659
+ || !Number.isInteger(policy.decimalPlaces) || policy.decimalPlaces < 0 || policy.decimalPlaces > 28
1660
+ || policy.mode !== 'half-away-from-zero') throw formulaError('ROUND_POLICY_INVALID', 'roundingPolicy must declare decimalPlaces 0..28 and half-away-from-zero mode');
1661
+ return { decimalPlaces: policy.decimalPlaces, mode: policy.mode };
1662
+ }
1645
1663
  function normalizeEngine(engine) {
1646
1664
  formulaObject(engine, 'engine');
1647
1665
  if (engine.schemaVersion !== 'engine.spec/1') throw formulaError('ENGINE_SCHEMA_VERSION', 'Expected engine.spec/1');
@@ -1662,12 +1680,14 @@ function normalizeEngine(engine) {
1662
1680
  if (NUMERIC.includes(field.type) && typeof field.unit !== 'string') throw formulaError('FIELD_UNIT_MISSING', 'numeric fields require an explicit unit');
1663
1681
  if (field.required !== undefined && typeof field.required !== 'boolean') throw formulaError('FIELD_REQUIRED_INVALID', 'field required must be boolean');
1664
1682
  }
1683
+ const roundingPolicy = normalizeRoundingPolicy(engine.roundingPolicy);
1665
1684
  const graph = compileFormulaGraph(engine.formulas, [...keys]);
1666
1685
  for (const item of graph.formulas) for (const ref of graph.dependencies[item.key]) {
1667
1686
  const field = engine.fields.find(entry => entry.key === ref);
1668
1687
  if (field && !NUMERIC.includes(field.type)) throw formulaError('FORMULA_REF_TYPE', `formula reference ${ref} must be numeric`);
1669
1688
  }
1670
1689
  const normalized = cloned(engine);
1690
+ normalized.roundingPolicy = roundingPolicy;
1671
1691
  normalized.formulas = engine.formulas.map(item => ({ ...cloned(item), expression: graph.formulas.find(entry => entry.key === item.key).expression }));
1672
1692
  return { engine: normalized, graph };
1673
1693
  }
@@ -1683,7 +1703,8 @@ function buildFormulaEngine(requirements) {
1683
1703
  semanticVersion: requirements.semanticVersion ?? '1.0.0', status: 'draft', compatibilityProfile: 'legacy-compatible',
1684
1704
  decimalPolicy: requirements.decimalPolicy ?? 'decimal-string', defaultLocale: requirements.defaultLocale ?? 'zh-CN',
1685
1705
  category: requirements.category ?? 'general', inputMethod: requirements.inputMethod ?? 'manual', output: requirements.output ?? ['metric-cards'],
1686
- constraints: requirements.constraints ?? '', fields: requirements.inputs, formulas: requirements.formulas,
1706
+ constraints: requirements.constraints ?? '', roundingPolicy: requirements.roundingPolicy ?? FORMULA_ROUNDING_POLICY,
1707
+ fields: requirements.inputs, formulas: requirements.formulas,
1687
1708
  rules: requirements.rules ?? [], views: requirements.views ?? [], importProfiles: requirements.importProfiles ?? [],
1688
1709
  reports: requirements.reports ?? [], testSuites: requirements.testSuites ?? [], acceptance: requirements.acceptance ?? '',
1689
1710
  };
@@ -1692,6 +1713,7 @@ function buildFormulaEngine(requirements) {
1692
1713
  function compileFormulaEngine(engine) {
1693
1714
  const normalized = normalizeEngine(engine);
1694
1715
  const program = { schemaVersion: FORMULA_SCHEMA, runtimeVersion: FORMULA_RUNTIME_VERSION, decimalPolicy: DECIMAL_POLICY,
1716
+ roundingPolicy: normalized.engine.roundingPolicy,
1695
1717
  engineId: normalized.engine.engineId, engineVersion: normalized.engine.semanticVersion,
1696
1718
  engineDigest: formulaDigest(normalized.engine), engine: normalized.engine,
1697
1719
  formulas: normalized.graph.formulas, dependencies: normalized.graph.dependencies, order: normalized.graph.order };
@@ -1719,7 +1741,8 @@ function evaluateFormulaProgram(program, inputs = {}, expected = {}) {
1719
1741
  } else if (typeof value !== 'string') throw formulaError('ENGINE_INPUT_TYPE', `${key} must be text`);
1720
1742
  }
1721
1743
  for (const field of fields.values()) if (field.required && (!Object.hasOwn(inputs, field.key) || inputs[field.key] === '' || inputs[field.key] === null)) throw formulaError('MISSING_INPUT', `missing input: ${field.key}`, { fieldId: field.key });
1722
- return evaluateFormulaGraph(verified.formulas, Object.fromEntries(Object.entries(inputs).filter(([key]) => NUMERIC.includes(fields.get(key).type))));
1744
+ return evaluateFormulaGraph(verified.formulas, Object.fromEntries(Object.entries(inputs).filter(([key]) => NUMERIC.includes(fields.get(key).type))),
1745
+ { roundingPolicy: verified.roundingPolicy });
1723
1746
  }
1724
1747
  function formulaImpact(program, changedKeys) {
1725
1748
  const verified = verifyFormulaProgram(program);
@@ -1818,7 +1841,7 @@ function createCalctoolEntrypoint(core) {
1818
1841
  const output = await core.run(request, options);
1819
1842
  return { ...output, capabilities: { ...output.capabilities, operations: CLOUD_OPERATIONS, localRunnerOperations: LOCAL_OPERATIONS,
1820
1843
  formulaOperators: FORMULA_OPERATORS, engineSchema: 'engine.spec/1', formulaRuntime: 'decimal28-ast/1.0',
1821
- decimalPolicy: { precision: 28, rounding: 'half-away-from-zero', representation: 'decimal-string' } },
1844
+ decimalPolicy: { precision: 28, rounding: 'half-away-from-zero', representation: 'decimal-string' }, roundingPolicy: FORMULA_ROUNDING_POLICY },
1822
1845
  localRunner: { command: 'entrorove local calctool-<operation>', requiredFor: LOCAL_OPERATIONS } };
1823
1846
  }
1824
1847
  if (request.operation === 'validate') {