@entrorove/skill 0.1.6 → 0.1.22

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.
@@ -0,0 +1,130 @@
1
+ import { promises as fs } from 'node:fs';
2
+ import path from 'node:path';
3
+
4
+ export const FORMULA_SCHEMA = 'entrorove.formula-engine/1.0';
5
+ export const FORMULA_GATE_SCHEMA = 'entrorove.calctool-final-gate/1.0';
6
+ const MAX_SCALE = 28;
7
+ const DECIMAL = /^[+-]?(?:\d+(?:\.\d*)?|\.\d+)$/;
8
+ const SHA256 = /^[0-9a-f]{64}$/;
9
+
10
+ function error(code, message, details = {}) {
11
+ const value = new Error(message); value.code = code; Object.assign(value, details); return value;
12
+ }
13
+ function ten(power) { return 10n ** BigInt(power); }
14
+ function normalize(coefficient, scale) {
15
+ while (scale > 0 && coefficient % 10n === 0n) { coefficient /= 10n; scale -= 1; }
16
+ return { coefficient, scale };
17
+ }
18
+ function round(coefficient, fromScale, toScale) {
19
+ if (fromScale <= toScale) return coefficient * ten(toScale - fromScale);
20
+ const divisor = ten(fromScale - toScale); let quotient = coefficient / divisor; const remainder = coefficient % divisor;
21
+ if ((remainder < 0n ? -remainder : remainder) * 2n >= divisor) quotient += coefficient < 0n ? -1n : 1n;
22
+ return quotient;
23
+ }
24
+
25
+ export class Decimal {
26
+ constructor(value) {
27
+ if (value instanceof Decimal) { this.coefficient = value.coefficient; this.scale = value.scale; this.value = value.value; return; }
28
+ const source = String(value ?? '').trim();
29
+ if (!DECIMAL.test(source) || source === '' || /^(?:[+-]?)(?:nan|infinity)$/i.test(source)) throw error('INVALID_DECIMAL', `Invalid decimal: ${source}`);
30
+ const negative = source[0] === '-'; const unsigned = source.replace(/^[+-]/, ''); const [integer = '0', fraction = ''] = unsigned.split('.');
31
+ const digits = `${integer || '0'}${fraction}`.replace(/^0+(?=\d)/, '') || '0';
32
+ const raw = BigInt(digits) * (negative ? -1n : 1n); const boundedScale = Math.min(fraction.length, MAX_SCALE);
33
+ const normalized = normalize(round(raw, fraction.length, boundedScale), boundedScale); this.coefficient = normalized.coefficient; this.scale = normalized.scale;
34
+ this.value = Decimal.format(this.coefficient, this.scale);
35
+ }
36
+ static from(value) { return value instanceof Decimal ? value : new Decimal(value); }
37
+ static fromParts(coefficient, scale) { return new Decimal(Decimal.format(...Object.values(normalize(round(coefficient, scale, Math.min(scale, MAX_SCALE)), Math.min(scale, MAX_SCALE))))); }
38
+ static format(coefficient, scale) {
39
+ const negative = coefficient < 0n; const digits = (negative ? -coefficient : coefficient).toString().padStart(scale + 1, '0');
40
+ const text = scale === 0 ? digits : `${digits.slice(0, -scale)}.${digits.slice(-scale)}`;
41
+ return negative && coefficient !== 0n ? `-${text}` : text;
42
+ }
43
+ plus(other) { const right = Decimal.from(other); const scale = Math.max(this.scale, right.scale); return Decimal.fromParts(this.coefficient * ten(scale - this.scale) + right.coefficient * ten(scale - right.scale), scale); }
44
+ minus(other) { return this.plus(Decimal.from(other).negate()); }
45
+ negate() { return Decimal.fromParts(-this.coefficient, this.scale); }
46
+ times(other) { const right = Decimal.from(other); return Decimal.fromParts(this.coefficient * right.coefficient, this.scale + right.scale); }
47
+ div(other) {
48
+ const right = Decimal.from(other); if (right.coefficient === 0n) throw error('DIV_ZERO', 'division by zero');
49
+ const numerator = this.coefficient * ten(right.scale + MAX_SCALE); const denominator = right.coefficient * ten(this.scale); let quotient = numerator / denominator; const remainder = numerator % denominator;
50
+ if ((remainder < 0n ? -remainder : remainder) * 2n >= (denominator < 0n ? -denominator : denominator)) quotient += (numerator < 0n) !== (denominator < 0n) ? -1n : 1n;
51
+ return Decimal.fromParts(quotient, MAX_SCALE);
52
+ }
53
+ rounded(dp) { if (!Number.isInteger(dp) || dp < 0 || dp > MAX_SCALE) throw error('DECIMAL_SCALE_INVALID', 'decimal places must be 0..28'); return this.scale <= dp ? this : Decimal.fromParts(round(this.coefficient, this.scale, dp), dp); }
54
+ isZero() { return this.coefficient === 0n; }
55
+ toString() { return this.value; }
56
+ }
57
+
58
+ function requireNode(node) { if (!node || typeof node !== 'object' || Array.isArray(node)) throw error('FORMULA_NODE_INVALID', 'formula nodes must be objects'); }
59
+ function requireArgs(node, count, exact = false) {
60
+ if (!Array.isArray(node.args) || (exact ? node.args.length !== count : node.args.length < count)) throw error('FORMULA_ARGS_INVALID', `${node.op} requires ${exact ? `exactly ${count}` : `at least ${count}`} arguments`);
61
+ }
62
+
63
+ export function evaluateFormula(node, values = {}) {
64
+ requireNode(node);
65
+ if (Object.hasOwn(node, 'ref')) {
66
+ if (typeof node.ref !== 'string' || !node.ref || values[node.ref] === undefined || values[node.ref] === null || values[node.ref] === '') throw error('MISSING_INPUT', `missing input: ${node.ref}`, { fieldId: node.ref });
67
+ return Decimal.from(values[node.ref]);
68
+ }
69
+ if (Object.hasOwn(node, 'lit')) return Decimal.from(node.lit);
70
+ const op = node.op; const args = node.args;
71
+ switch (op) {
72
+ case 'add': requireArgs(node, 1); return args.reduce((total, item) => total.plus(evaluateFormula(item, values)), Decimal.from('0'));
73
+ case 'sub': requireArgs(node, 2, true); return evaluateFormula(args[0], values).minus(evaluateFormula(args[1], values));
74
+ case 'mul': requireArgs(node, 1); return args.reduce((total, item) => total.times(evaluateFormula(item, values)), Decimal.from('1'));
75
+ case 'div': requireArgs(node, 2, true); return evaluateFormula(args[0], values).div(evaluateFormula(args[1], values));
76
+ case 'safeDivide': requireArgs(node, 2, true); { const denominator = evaluateFormula(args[1], values); return denominator.isZero() ? Decimal.from('0') : evaluateFormula(args[0], values).div(denominator); }
77
+ case 'percentOf': requireArgs(node, 2, true); return evaluateFormula(args[0], values).div(evaluateFormula(args[1], values)).times('100');
78
+ case 'round': requireArgs(node, 1, true); return evaluateFormula(args[0], values).rounded(2);
79
+ case 'if': requireArgs(node, 3, true); return evaluateFormula(args[0], values).isZero() ? evaluateFormula(args[2], values) : evaluateFormula(args[1], values);
80
+ default: throw error('UNSUPPORTED_OPERATOR', `Unsupported operator: ${String(op)}`);
81
+ }
82
+ }
83
+
84
+ function refs(node, output = []) {
85
+ requireNode(node); if (Object.hasOwn(node, 'ref')) output.push(node.ref); if (Array.isArray(node.args)) node.args.forEach(item => refs(item, output)); return output;
86
+ }
87
+ export function evaluateFormulaGraph(formulas, inputs = {}) {
88
+ if (!Array.isArray(formulas)) throw error('FORMULA_GRAPH_INVALID', 'formulas must be an array');
89
+ const values = Object.fromEntries(Object.entries(inputs).map(([key, value]) => [key, Decimal.from(value)])); const definitions = new Map();
90
+ for (const formula of formulas) { if (!formula || typeof formula.key !== 'string' || !formula.key || definitions.has(formula.key)) throw error('FORMULA_KEY_INVALID', 'formula keys must be unique non-empty strings'); definitions.set(formula.key, formula); }
91
+ const visiting = new Set(); const done = new Set();
92
+ const compute = key => {
93
+ if (done.has(key)) return values[key]; if (visiting.has(key)) throw error('FORMULA_CYCLE', `formula cycle at ${key}`); const formula = definitions.get(key); if (!formula) throw error('FORMULA_REF_UNKNOWN', `unknown formula: ${key}`);
94
+ visiting.add(key); for (const ref of refs(formula.expression)) if (definitions.has(ref)) compute(ref); const result = evaluateFormula(formula.expression, values); visiting.delete(key); done.add(key); values[key] = result; return result;
95
+ };
96
+ for (const key of definitions.keys()) compute(key);
97
+ return Object.fromEntries([...definitions.keys()].map(key => [key, values[key].toString()]));
98
+ }
99
+
100
+ function digest(value) { return value; }
101
+ function engineFindings(engine) {
102
+ const findings = []; const formulas = Array.isArray(engine?.formulas) ? engine.formulas : [];
103
+ if (!formulas.length) findings.push({ severity: 'P1', ruleId: 'FORMULA_EMPTY', entityRef: 'formulas', message: 'no formulas declared' });
104
+ const known = new Set([...(engine?.fields ?? []).map(item => item?.key), ...formulas.map(item => item?.key)]);
105
+ for (const [index, formula] of formulas.entries()) {
106
+ if (!formula?.key || !formula?.expression) findings.push({ severity: 'P0', ruleId: 'FORMULA_DECLARATION', entityRef: `formulas[${index}]`, message: 'formula key and expression are required' });
107
+ try { for (const ref of refs(formula.expression)) if (!known.has(ref)) findings.push({ severity: 'P0', ruleId: 'FORMULA_REF_UNKNOWN', entityRef: `formulas[${index}]`, message: `unknown reference: ${ref}` }); } catch (cause) { findings.push({ severity: 'P0', ruleId: 'FORMULA_NODE_INVALID', entityRef: `formulas[${index}]`, message: cause.message }); }
108
+ }
109
+ return findings;
110
+ }
111
+ export function formulaFinalGate(engine, options = {}) {
112
+ const findings = engineFindings(engine); const formulas = Array.isArray(engine?.formulas) ? engine.formulas : []; const suites = Array.isArray(engine?.testSuites) ? engine.testSuites : [];
113
+ const tests = []; if (!suites.length) findings.push({ severity: 'P1', ruleId: 'FORMULA_TESTS_EMPTY', entityRef: 'testSuites', message: 'no runnable benchmark samples' });
114
+ for (const [index, suite] of suites.entries()) {
115
+ const expected = suite?.expected ?? suite?.expect ?? {}; const inputs = suite?.input ?? suite?.inputs ?? {}; let actual;
116
+ try { actual = evaluateFormulaGraph(formulas, inputs); } catch (cause) { findings.push({ severity: 'P1', ruleId: 'FORMULA_TEST_ERROR', entityRef: `testSuites[${index}]`, message: cause.message }); continue; }
117
+ const mismatches = []; for (const [key, expectedValue] of Object.entries(expected)) { try { if (Decimal.from(actual[key]).toString() !== Decimal.from(expectedValue).toString()) mismatches.push({ key, expected: String(expectedValue), actual: actual[key] }); } catch (cause) { mismatches.push({ key, expected: String(expectedValue), actual: actual[key], error: cause.message }); } }
118
+ tests.push({ name: suite.name ?? `suite-${index + 1}`, passed: mismatches.length === 0, mismatches }); if (mismatches.length) findings.push({ severity: 'P1', ruleId: 'FORMULA_TEST_FAIL', entityRef: `testSuites[${index}]`, message: 'formula test expectation mismatch', mismatches });
119
+ }
120
+ return { schemaVersion: FORMULA_GATE_SCHEMA, status: findings.some(item => item.severity === 'P0') ? 'blocked' : findings.length ? 'incomplete' : 'complete', authority: 'trusted-local-runner', findings, tests, engineId: engine?.engineId ?? null, guarantee: findings.length ? 'local-completion-gate-blocked' : 'local-completion-gate-green' };
121
+ }
122
+
123
+ export async function formulaFinalGateFile(input) {
124
+ if (typeof input?.repositoryRoot !== 'string' || !path.isAbsolute(input.repositoryRoot)) throw error('ENTROROVE_ROOT_INVALID', 'repositoryRoot must be absolute');
125
+ if (typeof input.enginePath !== 'string' || path.isAbsolute(input.enginePath) || input.enginePath.split('/').some(part => !part || part === '.' || part === '..' || part.includes('\\'))) throw error('FORMULA_PATH_INVALID', 'enginePath must be a safe relative file path');
126
+ const root = await fs.realpath(input.repositoryRoot); const target = path.resolve(root, ...input.enginePath.split('/')); const relative = path.relative(root, target); if (relative.startsWith('..') || path.isAbsolute(relative)) throw error('FORMULA_PATH_ESCAPE', 'enginePath escapes repositoryRoot');
127
+ const stat = await fs.lstat(target); if (!stat.isFile() || stat.isSymbolicLink()) throw error('FORMULA_FILE_INVALID', 'enginePath must be a regular file'); const source = await fs.readFile(target, 'utf8');
128
+ let engine; try { engine = JSON.parse(source); } catch (cause) { throw error('FORMULA_JSON_INVALID', `enginePath is not valid JSON: ${cause.message}`); }
129
+ return { ...formulaFinalGate(engine, input), enginePath: input.enginePath, engineSha256: (await import('node:crypto')).createHash('sha256').update(source).digest('hex') };
130
+ }
@@ -0,0 +1,118 @@
1
+ import { createHash, createPublicKey, verify } from 'node:crypto';
2
+
3
+ export const ARCHGUARD_PREFLIGHT_SCHEMA = 'entrorove.archguard-preflight/1.0';
4
+ export const RUNNER_RECEIPT_SCHEMA = 'entrorove.runner-receipt/1.0';
5
+ const EXECUTION_RECEIPT_SCHEMA = 'validator.execution-receipt/1.0';
6
+ const SNAPSHOT_SCHEMA = 'entrorove.snapshot/1.0';
7
+ const ID = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
8
+ const SHA256 = /^[0-9a-f]{64}$/;
9
+ const REPOSITORY_DIGEST = /^[0-9a-f]{32}$/;
10
+ const SAFE_PATH = /^(?!.*[\\\u0000-\u001f\u007f])(?!.*(?:^|\/)(?:\.|\.\.)(?:\/|$))[^/]+(?:\/[^/]+)*$/;
11
+
12
+ export function canonical(value) {
13
+ if (Array.isArray(value)) return `[${value.map(canonical).join(',')}]`;
14
+ if (value && typeof value === 'object') {
15
+ return `{${Object.keys(value).sort().map(key => `${JSON.stringify(key)}:${canonical(value[key])}`).join(',')}}`;
16
+ }
17
+ return JSON.stringify(value);
18
+ }
19
+
20
+ export function sha256(value) {
21
+ return createHash('sha256').update(value).digest('hex');
22
+ }
23
+
24
+ export function verifySnapshotManifest(manifest, binding) {
25
+ if (!manifest || typeof manifest !== 'object' || Array.isArray(manifest)
26
+ || manifest.schemaVersion !== SNAPSHOT_SCHEMA || manifest.snapshotId !== binding.snapshotId
27
+ || manifest.flowId !== binding.flowId || manifest.stepId !== binding.stepId
28
+ || !REPOSITORY_DIGEST.test(String(manifest.repositoryDigest)) || manifest.repositoryDigest !== binding.repositoryDigest || !Array.isArray(manifest.paths)
29
+ || !manifest.paths.length || manifest.paths.some(path => typeof path !== 'string' || path !== path.normalize('NFC') || !SAFE_PATH.test(path))
30
+ || [...manifest.paths].sort().join('\u0000') !== manifest.paths.join('\u0000')
31
+ || new Set(manifest.paths).size !== manifest.paths.length || !Array.isArray(manifest.files)
32
+ || manifest.files.length !== manifest.paths.length) return { verified: false, reason: 'snapshot manifest fields are invalid' };
33
+ if (manifest.contractDigest !== null && !SHA256.test(String(manifest.contractDigest))) return { verified: false, reason: 'snapshot contract digest is invalid' };
34
+ for (const [index, file] of manifest.files.entries()) {
35
+ if (!file || file.path !== manifest.paths[index] || typeof file.exists !== 'boolean'
36
+ || !SHA256.test(String(file.sha256)) || (file.mode !== null && !Number.isInteger(file.mode))) return { verified: false, reason: 'snapshot manifest file entry is invalid' };
37
+ }
38
+ const core = { snapshotId: manifest.snapshotId, flowId: manifest.flowId, stepId: manifest.stepId,
39
+ paths: manifest.paths, files: manifest.files, contractDigest: manifest.contractDigest };
40
+ return manifest.snapshotDigest === sha256(canonical(core))
41
+ ? { verified: true } : { verified: false, reason: 'snapshot manifest digest is invalid' };
42
+ }
43
+
44
+ function publicKeyBytes(receipt) {
45
+ if (typeof receipt?.publicKey !== 'string') throw new Error('Receipt publicKey is required');
46
+ const bytes = Buffer.from(receipt.publicKey, 'base64');
47
+ if (bytes.length === 0) throw new Error('Receipt publicKey encoding is invalid');
48
+ return bytes;
49
+ }
50
+
51
+ export function verifyReceipt(receipt, subject, options = {}) {
52
+ if (!receipt || receipt.schemaVersion !== (options.schemaVersion ?? RUNNER_RECEIPT_SCHEMA)
53
+ || typeof receipt.keyId !== 'string' || !receipt.keyId || typeof receipt.signature !== 'string' || !receipt.signature) {
54
+ throw new Error('Signed receipt fields are invalid');
55
+ }
56
+ const bytes = options.publicKey ? publicKeyBytes({ publicKey: options.publicKey }) : publicKeyBytes(receipt);
57
+ const publicKey = createPublicKey({ key: bytes, format: 'der', type: 'spki' });
58
+ const derivedKeyId = sha256(bytes);
59
+ if (receipt.keyId !== derivedKeyId) throw new Error('Receipt key identity does not match public key');
60
+ const valid = verify(null, Buffer.from(canonical(subject)), publicKey, Buffer.from(receipt.signature, 'base64url'));
61
+ if (!valid) throw new Error('Signed receipt signature is invalid');
62
+ return { publicKey, keyId: derivedKeyId };
63
+ }
64
+
65
+ function bindingMismatch(attestation, binding) {
66
+ return attestation.flowId !== binding.flowId || attestation.stepId !== binding.stepId
67
+ || attestation.snapshotDigest !== binding.snapshotDigest || attestation.contractDigest !== binding.contractDigest;
68
+ }
69
+
70
+ function verifyExecutionReceipt(receipt, evidence, binding) {
71
+ if (!receipt || receipt.schemaVersion !== EXECUTION_RECEIPT_SCHEMA || receipt.subjectDigest !== evidence.subjectDigest
72
+ || !ID.test(String(receipt.nonce ?? '')) || !receipt.result || receipt.result.runner !== 'trusted-runner' || typeof receipt.result.passed !== 'boolean'
73
+ || receipt.result.exitCode !== evidence.exitCode || receipt.result.durationMs !== evidence.durationMs || receipt.result.summary !== evidence.summary) throw new Error('Trusted execution receipt is not bound');
74
+ const issuedAt = Date.parse(String(receipt.issuedAt ?? '')); const expiresAt = Date.parse(String(receipt.expiresAt ?? ''));
75
+ if (!Number.isFinite(issuedAt) || !Number.isFinite(expiresAt) || issuedAt > Date.now() + 60_000 || expiresAt <= Date.now() || expiresAt <= issuedAt || expiresAt - issuedAt > 600_000) throw new Error('Trusted execution receipt lifetime is invalid');
76
+ const unsigned = Object.fromEntries(Object.entries(receipt).filter(([key]) => key !== 'signature'));
77
+ if (typeof binding.receiptPublicKey !== 'string') throw new Error('Trusted execution receipt public key is required');
78
+ return verifyReceipt(receipt, unsigned, { schemaVersion: EXECUTION_RECEIPT_SCHEMA, publicKey: binding.receiptPublicKey });
79
+ }
80
+
81
+ export function verifyArchguardPreflight(attestation, binding) {
82
+ if (!attestation || typeof attestation !== 'object' || Array.isArray(attestation)
83
+ || attestation.schemaVersion !== ARCHGUARD_PREFLIGHT_SCHEMA || attestation.status !== 'passed'
84
+ || bindingMismatch(attestation, binding) || !attestation.receipt) {
85
+ return { verified: false, reason: 'ArchGuard preflight is missing or not bound to this flow, step, snapshot, and contract' };
86
+ }
87
+ const unsigned = Object.fromEntries(Object.entries(attestation).filter(([key]) => key !== 'receipt'));
88
+ try {
89
+ const receipt = verifyReceipt(attestation.receipt, unsigned);
90
+ return { verified: true, attestation, keyId: receipt.keyId };
91
+ } catch (error) {
92
+ return { verified: false, reason: error instanceof Error ? error.message : String(error) };
93
+ }
94
+ }
95
+
96
+ export function verifyTrustedEvidence(evidence, binding) {
97
+ if (!evidence || evidence.runner !== 'trusted-runner' || !evidence.subject
98
+ || evidence.subject.schemaVersion !== 'validator.validation-subject/1.0'
99
+ || evidence.subject.chainId !== binding.flowId || evidence.subject.validationRunId !== binding.stepId
100
+ || evidence.subjectDigest !== sha256(canonical(evidence.subject))
101
+ || evidence.subject.snapshotDigest !== binding.snapshotDigest
102
+ || evidence.subject.contractDigest !== binding.contractDigest) {
103
+ return { verified: false, reason: 'trusted evidence subject is not bound to this flow, step, snapshot, and contract' };
104
+ }
105
+ const unsigned = Object.fromEntries(Object.entries(evidence).filter(([key]) => key !== 'receipt'));
106
+ try {
107
+ const receipt = evidence.receipt?.schemaVersion === EXECUTION_RECEIPT_SCHEMA
108
+ ? verifyExecutionReceipt(evidence.receipt, evidence, binding) : verifyReceipt(evidence.receipt, unsigned);
109
+ return { verified: true, evidence, keyId: receipt.keyId };
110
+ } catch (error) {
111
+ return { verified: false, reason: error instanceof Error ? error.message : String(error) };
112
+ }
113
+ }
114
+
115
+ export function assertGreen(value, label) {
116
+ if (value === true || (value && ['passed', 'green', 'complete'].includes(value.status))) return;
117
+ throw new Error(`${label} is not verified green`);
118
+ }
@@ -0,0 +1,58 @@
1
+ import { createHash, randomUUID } from 'node:crypto';
2
+ import { promises as fs } from 'node:fs';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+ import { verifyArchguardPreflight, verifyTrustedEvidence } from './local-integrity.mjs';
6
+ import { withStateLock } from './local-state-lock.mjs';
7
+
8
+ export const LEDGER_SCHEMA = 'entrorove.execution-ledger/1.0';
9
+ export const CHAIN_SCHEMA = 'entrorove.chain-execution/1.0';
10
+ const ID = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/; const SHA256 = /^[0-9a-f]{64}$/;
11
+ const ledgerLocks = new Map();
12
+ function fail(code, message) { const error = new Error(message); error.code = code; throw error; }
13
+ function id(value, label) { if (typeof value !== 'string' || !ID.test(value)) fail('ENTROROVE_ID_INVALID', `${label} must be a safe identifier`); return value; }
14
+ function canonical(value) { if (Array.isArray(value)) return `[${value.map(canonical).join(',')}]`; if (value && typeof value === 'object') return `{${Object.keys(value).sort().map(key => `${JSON.stringify(key)}:${canonical(value[key])}`).join(',')}}`; return JSON.stringify(value); }
15
+ function sha(value) { return createHash('sha256').update(value).digest('hex'); }
16
+ function rootFor(home = os.homedir()) { if (typeof home !== 'string' || !path.isAbsolute(home)) fail('ENTROROVE_HOME_INVALID', 'home must be absolute'); return path.join(home, '.entrorove'); }
17
+ async function repository(value) { if (typeof value !== 'string' || !path.isAbsolute(value)) fail('ENTROROVE_ROOT_INVALID', 'repositoryRoot must be absolute'); const stat = await fs.lstat(value); if (!stat.isDirectory() || stat.isSymbolicLink()) fail('ENTROROVE_ROOT_INVALID', 'repositoryRoot must be a real directory'); return fs.realpath(value); }
18
+ function repoDigest(root) { return sha(root).slice(0, 32); }
19
+ 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); }
20
+ 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); }
21
+ 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
+ function ledgerPath(home, root) { return path.join(rootFor(home), 'ledgers', repoDigest(root), 'state.json'); }
23
+ 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 }; } }
24
+ 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); } }
25
+ 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; }
26
+ 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 }; }
27
+ export async function branchList(input) { const authority = await loadLedger(input, true); return { schemaVersion: LEDGER_SCHEMA, status: 'verified', activeBranch: authority.state.activeBranch, branches: authority.state.branches, virtualOnly: true, forbidGitBranch: true, forbidWorktree: true }; }
28
+ export async function branchSwitch(input) { const name = id(input.branchName ?? input.name, 'branchName'); const authority = await loadLedger(input); if (!authority.state.branches.some(item => item.name === name)) fail('ENTROROVE_BRANCH_UNKNOWN', `unknown virtual branch: ${name}`); const state = { ...authority.state, activeBranch: name }; await atomic(authority.file, state); await append(input, 'branch-switch', { name, virtual: true }); return { schemaVersion: LEDGER_SCHEMA, status: 'verified', branchName: name, virtualOnly: true }; }
29
+ export async function diffReport(input) { const authority = await loadLedger(input, true); const entries = authority.state.records.filter(item => item.event === 'guarded-write' || item.event === 'merge-verified' || item.event === 'rollback'); return { schemaVersion: LEDGER_SCHEMA, status: 'verified', baseRef: input.baseRef ?? 'working-tree', targetRef: input.targetRef ?? authority.state.activeBranch, changes: entries.slice(-(input.limit ?? 100)), source: 'entrorove-ledger', virtualOnly: true }; }
30
+ export async function preflight(input) {
31
+ const binding = { flowId: input.flowId, stepId: input.stepId, snapshotDigest: input.snapshotDigest, contractDigest: input.contractDigest };
32
+ const archguard = verifyArchguardPreflight(input.archguard ?? input.preflight, binding);
33
+ const evidenceChecks = Array.isArray(input.evidence) ? input.evidence.map(item => verifyTrustedEvidence(item, binding)) : [];
34
+ const evidence = evidenceChecks.length > 0 && evidenceChecks.every(item => item.verified) && input.evidence.every(item => item.exitCode === 0);
35
+ 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');
36
+ 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 };
37
+ }
38
+ 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 }; }
39
+
40
+ 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; }
41
+ 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 }; } }
42
+ export async function chainInit(input) { const plan = validatePlan(input.plan ?? input.executionPlan); const authority = await chainAuthority({ ...input, chainId: plan.chainId }, true); if (authority.state) fail('ENTROROVE_CHAIN_EXISTS', 'chain already exists; use chain-resume'); const now = new Date().toISOString(); const state = { schemaVersion: CHAIN_SCHEMA, chainId: plan.chainId, repositoryDigest: repoDigest(authority.root), planDigest: sha(canonical(plan)), plan, status: 'pending', currentStepId: plan.steps[0].stepId, steps: plan.steps.map(step => ({ stepId: step.stepId, status: 'pending', output: null, error: null })), history: [{ event: 'chain-init', at: now }] }; await atomic(authority.file, state); await append(input, 'chain-init', { chainId: plan.chainId, planDigest: state.planDigest }); return { schemaVersion: CHAIN_SCHEMA, status: 'pending', chainId: plan.chainId, planDigest: state.planDigest, nextStep: state.currentStepId, statePath: authority.file }; }
43
+ export async function chainStatus(input) { const authority = await chainAuthority(input); if (!authority.state) fail('ENTROROVE_CHAIN_UNKNOWN', `unknown chain: ${authority.chainId}`); return { schemaVersion: CHAIN_SCHEMA, ...authority.state, statePath: authority.file }; }
44
+ export async function chainResume(input) { const authority = await chainAuthority(input); if (!authority.state) fail('ENTROROVE_CHAIN_UNKNOWN', `unknown chain: ${authority.chainId}`); const state = structuredClone(authority.state); if (['succeeded', 'failed', 'blocked'].includes(state.status)) return { schemaVersion: CHAIN_SCHEMA, ...state, resumed: false }; const stepId = id(input.stepId ?? state.currentStepId, 'stepId'); const step = state.steps.find(item => item.stepId === stepId); if (!step || step.status !== 'pending' && step.status !== 'running') fail('ENTROROVE_CHAIN_STEP_STATE', 'step is not resumable'); if (input.status && !['running', 'waiting', 'reported', 'verified', 'blocked', 'failed'].includes(input.status)) fail('ENTROROVE_STATUS_INVALID', 'invalid chain step status'); step.status = input.status ?? 'running'; step.output = input.output ?? step.output; step.error = input.error ?? null; state.status = step.status === 'failed' ? 'failed' : step.status === 'blocked' ? 'blocked' : 'running'; if (step.status === 'verified') { const index = state.steps.findIndex(item => item.stepId === stepId); const next = state.steps[index + 1]; if (next) { state.currentStepId = next.stepId; } else { state.currentStepId = null; state.status = 'succeeded'; } } state.history = [...state.history, { event: 'chain-resume', stepId, status: step.status, at: new Date().toISOString() }]; await atomic(authority.file, state); await append(input, 'chain-resume', { chainId: authority.chainId, stepId, status: step.status }); return { schemaVersion: CHAIN_SCHEMA, ...state, resumed: true }; }
45
+ export async function chainAnswer(input) { const answer = typeof input.answer === 'string' ? input.answer : input.answer?.id; if (!answer) fail('ENTROROVE_DECISION_REQUIRED', 'an explicit answer is required'); return chainResume({ ...input, status: input.accepted === false ? 'blocked' : 'verified', output: { answer, actorId: input.actorId ?? null, answeredAt: new Date().toISOString() } }); }
46
+
47
+ export const LOCAL_LEDGER_OPERATIONS = Object.freeze({
48
+ 'branch-create': branchCreate, 'branch-list': branchList, 'branch-switch': branchSwitch, 'diff-report': diffReport, preflight, 'ledger-query': ledgerQuery,
49
+ 'chain-init': chainInit, 'chain-resume': chainResume, 'chain-status': chainStatus, 'chain-answer': chainAnswer,
50
+ 'branch-create-virtual': branchCreate, 'chain-ledger-init': chainInit, 'chain-ledger-status': chainStatus, 'chain-ledger-query': ledgerQuery,
51
+ });
52
+ const LEDGER_REQUIRED = {
53
+ 'branch-create': ['repositoryRoot', 'name', 'fromRef'], 'branch-list': ['repositoryRoot'], 'branch-switch': ['repositoryRoot', 'branchName'],
54
+ 'diff-report': ['repositoryRoot'], preflight: ['repositoryRoot'], 'ledger-query': ['repositoryRoot'], 'chain-init': ['repositoryRoot', 'plan'],
55
+ 'chain-resume': ['repositoryRoot', 'chainId'], 'chain-status': ['repositoryRoot', 'chainId'], 'chain-answer': ['repositoryRoot', 'chainId', 'answer'],
56
+ 'branch-create-virtual': ['repositoryRoot', 'name', 'fromRef'], 'chain-ledger-init': ['repositoryRoot', 'plan'], 'chain-ledger-status': ['repositoryRoot', 'chainId'], 'chain-ledger-query': ['repositoryRoot'],
57
+ };
58
+ export const LOCAL_LEDGER_SCHEMAS = Object.freeze(Object.fromEntries(Object.keys(LOCAL_LEDGER_OPERATIONS).map(operation => [operation, { type: 'object', additionalProperties: false, required: LEDGER_REQUIRED[operation] ?? [] }])));
@@ -0,0 +1,13 @@
1
+ import { promises as fs } from 'node:fs';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+
5
+ const ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
6
+ function fail(code, message) { const error = new Error(message); error.code = code; throw error; }
7
+ function safe(value) { if (typeof value !== 'string' || !value || value !== value.normalize('NFC') || path.isAbsolute(value) || value.includes('\\') || /[\u0000-\u001f\u007f]/.test(value) || value.split('/').some(part => !part || part === '.' || part === '..')) fail('ENTROROVE_PATH_UNSAFE', 'probe path is unsafe'); return value; }
8
+ async function root(value) { if (typeof value !== 'string' || !path.isAbsolute(value)) fail('ENTROROVE_ROOT_INVALID', 'repositoryRoot must be absolute'); const stat = await fs.lstat(value); if (!stat.isDirectory() || stat.isSymbolicLink()) fail('ENTROROVE_ROOT_INVALID', 'repositoryRoot must be a real directory'); return fs.realpath(value); }
9
+ async function filesUnder(rootPath, relative, result = []) { const target = path.resolve(rootPath, ...safe(relative).split('/')); const stat = await fs.lstat(target); if (stat.isSymbolicLink()) fail('ENTROROVE_FILE_INVALID', 'probe refuses symlinks'); if (stat.isFile()) { result.push({ path: relative, bytes: stat.size }); return result; } if (!stat.isDirectory()) fail('ENTROROVE_FILE_INVALID', 'probe path is not a file or directory'); for (const name of await fs.readdir(target)) if (!['.git', 'node_modules', '.entrorove', '.aimlock', '.archguard'].includes(name)) await filesUnder(rootPath, `${relative}/${name}`, result); return result; }
10
+ export async function probe(input) { const rootPath = await root(input.repositoryRoot); const requested = input.paths ?? input.targetPaths ?? ['.']; const paths = requested.length === 1 && requested[0] === '.' ? (await fs.readdir(rootPath)).filter(name => !['.git', 'node_modules', '.entrorove'].includes(name)).map(name => name) : requested.map(safe); const files = []; for (const relative of paths) await filesUnder(rootPath, relative, files); const fileCount = files.length; const bytes = files.reduce((total, file) => total + file.bytes, 0); const lines = Math.ceil(bytes / 4); const mode = fileCount <= 1 && lines <= 500 ? 'lock' : fileCount <= 3 && lines <= 500 ? 'probe' : 'swarm'; return { schemaVersion: 'entrorove.local-execution/1.0', status: 'verified', repositoryRoot: rootPath, facts: { fileCount, bytes, tokenEstimate: lines, files: files.sort((left, right) => left.path.localeCompare(right.path)) }, classification: { mode, reason: mode === 'swarm' ? 'more than three files or over the 500-line budget' : mode === 'probe' ? 'bounded multi-file read' : 'single-file read' }, forbidGitBranch: true, forbidWorktree: true }; }
11
+ export async function reassess(input) { const result = await probe(input); const order = ['lock', 'probe', 'swarm']; const requested = input.currentMode ?? result.classification.mode; if (!ID.test(requested) || !order.includes(requested)) fail('ENTROROVE_MODE_INVALID', 'currentMode must be lock, probe, or swarm'); const currentIndex = order.indexOf(requested); const actualIndex = order.indexOf(result.classification.mode); const nextMode = actualIndex > currentIndex ? order[Math.min(currentIndex + 1, order.length - 1)] : requested; return { ...result, operation: 'reassess', previousMode: requested, mode: nextMode, upgraded: nextMode !== requested, preservedSnapshotId: input.snapshotId ?? null }; }
12
+ export const LOCAL_PROBE_OPERATIONS = Object.freeze({ probe, reassess });
13
+ export const LOCAL_PROBE_SCHEMAS = Object.freeze({ probe: { type: 'object', additionalProperties: false, required: ['repositoryRoot'] }, reassess: { type: 'object', additionalProperties: false, required: ['repositoryRoot', 'currentMode'] } });
@@ -0,0 +1,84 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { promises as fs } from 'node:fs';
3
+ import path from 'node:path';
4
+
5
+ const WAIT_MS = 25;
6
+ const MAX_WAIT_MS = 10_000;
7
+ const STALE_MS = 120_000;
8
+
9
+ function fail(code, message) {
10
+ const error = new Error(message);
11
+ error.code = code;
12
+ throw error;
13
+ }
14
+
15
+ async function livePid(pid) {
16
+ if (!Number.isSafeInteger(pid) || pid <= 0) return false;
17
+ try {
18
+ process.kill(pid, 0);
19
+ return true;
20
+ } catch (error) {
21
+ return error?.code !== 'ESRCH';
22
+ }
23
+ }
24
+
25
+ async function removeStale(lock) {
26
+ let stat;
27
+ try { stat = await fs.lstat(lock); } catch (error) { if (error.code === 'ENOENT') return true; throw error; }
28
+ if (!stat.isDirectory() || stat.isSymbolicLink()) fail('ENTROROVE_LOCK_INVALID', 'state lock must be a real directory');
29
+ if (Date.now() - stat.mtimeMs < STALE_MS) return false;
30
+ let owner = null;
31
+ try { owner = JSON.parse(await fs.readFile(`${lock}/owner.json`, 'utf8')); } catch (error) { if (error.code !== 'ENOENT') throw error; }
32
+ if (await livePid(owner?.pid)) return false;
33
+ const quarantine = `${lock}.stale-${randomUUID()}`;
34
+ try {
35
+ await fs.rename(lock, quarantine);
36
+ } catch (error) {
37
+ if (error.code === 'ENOENT') return true;
38
+ throw error;
39
+ }
40
+ const moved = await fs.lstat(quarantine);
41
+ if (moved.ino !== stat.ino || moved.dev !== stat.dev) {
42
+ await fs.rename(quarantine, lock).catch(() => undefined);
43
+ fail('ENTROROVE_LOCK_CHANGED', 'state lock changed during stale recovery');
44
+ }
45
+ await fs.rm(quarantine, { recursive: true, force: true });
46
+ return true;
47
+ }
48
+
49
+ export async function withStateLock(file, action) {
50
+ if (typeof file !== 'string' || !file) fail('ENTROROVE_LOCK_PATH', 'state lock path is required');
51
+ const parent = path.dirname(file);
52
+ await fs.mkdir(parent, { recursive: true, mode: 0o700 });
53
+ const parentStat = await fs.lstat(parent);
54
+ if (!parentStat.isDirectory() || parentStat.isSymbolicLink()) {
55
+ fail('ENTROROVE_LOCK_INVALID', 'state lock parent must be a real directory');
56
+ }
57
+ if (process.platform !== 'win32') await fs.chmod(parent, 0o700);
58
+ const lock = `${file}.lock`;
59
+ const token = randomUUID();
60
+ const deadline = Date.now() + MAX_WAIT_MS;
61
+ while (true) {
62
+ try {
63
+ await fs.mkdir(lock, { mode: 0o700 });
64
+ await fs.writeFile(`${lock}/owner.json`, JSON.stringify({ pid: process.pid, token }) + '\n', { flag: 'wx', mode: 0o600 });
65
+ break;
66
+ } catch (error) {
67
+ if (error.code !== 'EEXIST') throw error;
68
+ if (await removeStale(lock)) continue;
69
+ if (Date.now() >= deadline) fail('ENTROROVE_LOCK_BUSY', 'state is busy; retry after the active operation completes');
70
+ await new Promise(resolve => setTimeout(resolve, WAIT_MS));
71
+ }
72
+ }
73
+ try {
74
+ return await action();
75
+ } finally {
76
+ try {
77
+ const owner = JSON.parse(await fs.readFile(`${lock}/owner.json`, 'utf8'));
78
+ if (owner.token !== token) fail('ENTROROVE_LOCK_CHANGED', 'state lock ownership changed during operation');
79
+ await fs.rm(lock, { recursive: true, force: true });
80
+ } catch (error) {
81
+ if (error.code !== 'ENOENT') throw error;
82
+ }
83
+ }
84
+ }
@@ -0,0 +1,36 @@
1
+ import { createHash, randomUUID } from 'node:crypto';
2
+ import { promises as fs } from 'node:fs';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+ import { withStateLock } from './local-state-lock.mjs';
6
+
7
+ export const TASK_ROUTING_SCHEMA = 'entrorove.task-routing/1.0';
8
+ const ID = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
9
+ function fail(code, message) { const error = new Error(message); error.code = code; throw error; }
10
+ function digest(value) { return createHash('sha256').update(value).digest('hex'); }
11
+ function canonical(value) { if (Array.isArray(value)) return `[${value.map(canonical).join(',')}]`; if (value && typeof value === 'object') return `{${Object.keys(value).sort().map(key => `${JSON.stringify(key)}:${canonical(value[key])}`).join(',')}}`; return JSON.stringify(value); }
12
+ function homeRoot(home = os.homedir()) { if (typeof home !== 'string' || !path.isAbsolute(home)) fail('ENTROROVE_HOME_INVALID', 'home must be absolute'); return path.join(home, '.entrorove'); }
13
+ async function repo(value) { if (typeof value !== 'string' || !path.isAbsolute(value)) fail('ENTROROVE_ROOT_INVALID', 'repositoryRoot must be absolute'); const stat = await fs.lstat(value); if (!stat.isDirectory() || stat.isSymbolicLink()) fail('ENTROROVE_ROOT_INVALID', 'repositoryRoot must be a real directory'); return fs.realpath(value); }
14
+ function statePath(home, root) { return path.join(homeRoot(home), 'task-routing', digest(root).slice(0, 32), 'state.json'); }
15
+ 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); }
16
+ async function load(input, create = true) { const root = await repo(input.repositoryRoot); const file = statePath(input.home, root); await privateDir(path.dirname(file)); try { const stat = await fs.lstat(file); if (!stat.isFile() || stat.isSymbolicLink()) fail('ENTROROVE_STATE_INVALID', 'task routing state must be a regular file'); const state = JSON.parse(await fs.readFile(file, 'utf8')); if (state.schemaVersion !== TASK_ROUTING_SCHEMA || state.repositoryDigest !== digest(root).slice(0, 32)) fail('ENTROROVE_TASK_STATE_INVALID', 'task routing state identity mismatch'); return { root, file, state }; } catch (cause) { if (!create || cause.code !== 'ENOENT') throw cause; const state = { schemaVersion: TASK_ROUTING_SCHEMA, repositoryDigest: digest(root).slice(0, 32), sequence: 0, tasks: [], messages: [], records: [] }; await save(file, state); return { root, file, state }; } }
17
+ async function save(file, state) { await privateDir(path.dirname(file)); const temp = `${file}.${randomUUID()}.tmp`; await fs.writeFile(temp, `${canonical(state)}\n`, { flag: 'wx', mode: 0o600 }); await fs.rename(temp, file); if (process.platform !== 'win32') await fs.chmod(file, 0o600); }
18
+ function requireId(value, label) { if (typeof value !== 'string' || !ID.test(value)) fail('ENTROROVE_ID_INVALID', `${label} must be a safe identifier`); return value; }
19
+ function taskRecord(state, taskId) { const task = state.tasks.find(item => item.taskId === taskId); if (!task) fail('ENTROROVE_TASK_UNKNOWN', `unknown task: ${taskId}`); return task; }
20
+ async function record(authority, event, data) { const entry = { sequence: authority.state.sequence + 1, event, at: new Date().toISOString(), ...data }; const state = { ...authority.state, sequence: entry.sequence, records: [...authority.state.records, { ...entry, digest: digest(canonical(entry)) }] }; await save(authority.file, state); authority.state = state; return entry; }
21
+ async function routingLock(input, action) { const root = await repo(input.repositoryRoot); return withStateLock(statePath(input.home, root), action); }
22
+ async function describeUnlocked(input) { const taskId = requireId(input.taskId ?? `task-${randomUUID()}`, 'taskId'); if (typeof input.goal !== 'string' || !input.goal.trim()) fail('ENTROROVE_TASK_GOAL_REQUIRED', 'goal is required'); const authority = await load(input); const original = { taskId, goal: input.goal, ownerId: input.ownerId ?? null, checkpoint: input.checkpoint ?? null, createdAt: input.createdAt ?? new Date().toISOString() }; const task = authority.state.tasks.find(item => item.taskId === taskId); if (task) { if (task.goalDigest !== digest(canonical(original))) return { schemaVersion: TASK_ROUTING_SCHEMA, status: 'blocked', code: 'ENTROROVE_TASK_ID_REUSED' }; return { schemaVersion: TASK_ROUTING_SCHEMA, status: 'verified', task }; } const next = { ...original, goalDigest: digest(canonical(original)), status: 'pending', messages: [], resumedAt: null }; const state = { ...authority.state, tasks: [...authority.state.tasks, next] }; await save(authority.file, state); authority.state = state; await record(authority, 'task-describe', { taskId }); return { schemaVersion: TASK_ROUTING_SCHEMA, status: 'verified', task: next }; }
23
+ async function routeUnlocked(input) { const taskId = requireId(input.taskId, 'taskId'); const messageId = requireId(input.messageId ?? `message-${randomUUID()}`, 'messageId'); const authority = await load(input); const task = taskRecord(authority.state, taskId); if (authority.state.messages.some(item => item.messageId === messageId)) return { schemaVersion: TASK_ROUTING_SCHEMA, status: 'verified', message: authority.state.messages.find(item => item.messageId === messageId), duplicate: true }; if (task.status === 'failed' || task.status === 'completed') return { schemaVersion: TASK_ROUTING_SCHEMA, status: 'blocked', code: 'ENTROROVE_TASK_TERMINAL' }; const message = { messageId, taskId, body: input.body ?? null, destination: input.destination ?? task.ownerId, status: 'routed', routedAt: new Date().toISOString(), delivery: 'pending' }; const state = { ...authority.state, messages: [...authority.state.messages, message], tasks: authority.state.tasks.map(item => item.taskId === taskId ? { ...item, status: 'routed' } : item) }; await save(authority.file, state); authority.state = state; await record(authority, 'message-route', { taskId, messageId, destination: message.destination }); return { schemaVersion: TASK_ROUTING_SCHEMA, status: 'verified', message }; }
24
+ async function acceptUnlocked(input) { const taskId = requireId(input.taskId, 'taskId'); const messageId = requireId(input.messageId, 'messageId'); const authority = await load(input); const message = authority.state.messages.find(item => item.taskId === taskId && item.messageId === messageId); if (!message || message.status !== 'routed') return { schemaVersion: TASK_ROUTING_SCHEMA, status: 'blocked', code: 'ENTROROVE_MESSAGE_NOT_ROUTABLE' }; if (input.ownerId && message.destination && input.ownerId !== message.destination) return { schemaVersion: TASK_ROUTING_SCHEMA, status: 'blocked', code: 'ENTROROVE_MESSAGE_OWNER' }; const accepted = { ...message, status: 'accepted', acceptedAt: new Date().toISOString(), acceptedBy: input.ownerId ?? message.destination ?? null }; const state = { ...authority.state, messages: authority.state.messages.map(item => item.messageId === messageId ? accepted : item), tasks: authority.state.tasks.map(item => item.taskId === taskId ? { ...item, status: 'accepted', ownerId: accepted.acceptedBy ?? item.ownerId } : item) }; await save(authority.file, state); authority.state = state; await record(authority, 'message-accept', { taskId, messageId, acceptedBy: accepted.acceptedBy }); return { schemaVersion: TASK_ROUTING_SCHEMA, status: 'verified', receipt: { receiptId: `receipt-${randomUUID()}`, taskId, messageId, acceptedBy: accepted.acceptedBy, acceptedAt: accepted.acceptedAt }, message: accepted }; }
25
+ async function resumeUnlocked(input) { const taskId = requireId(input.taskId, 'taskId'); const authority = await load(input); const task = taskRecord(authority.state, taskId); if (!['accepted', 'active'].includes(task.status)) return { schemaVersion: TASK_ROUTING_SCHEMA, status: 'blocked', code: 'ENTROROVE_TASK_NOT_ACCEPTED', taskId }; const resumed = { ...task, status: 'active', resumedAt: new Date().toISOString() }; const state = { ...authority.state, tasks: authority.state.tasks.map(item => item.taskId === taskId ? resumed : item) }; await save(authority.file, state); authority.state = state; await record(authority, 'task-resume', { taskId, status: resumed.status }); return { schemaVersion: TASK_ROUTING_SCHEMA, status: 'verified', task: resumed, pendingMessages: authority.state.messages.filter(item => item.taskId === taskId && item.status === 'accepted') }; }
26
+ export async function taskDescribe(input) { return routingLock(input, () => describeUnlocked(input)); }
27
+ export async function messageRoute(input) { return routingLock(input, () => routeUnlocked(input)); }
28
+ export async function messageAccept(input) { return routingLock(input, () => acceptUnlocked(input)); }
29
+ export async function taskResume(input) { return routingLock(input, () => resumeUnlocked(input)); }
30
+ export const LOCAL_TASK_ROUTING_OPERATIONS = Object.freeze({ 'task-describe': taskDescribe, 'message-route': messageRoute, 'message-accept': messageAccept, 'task-resume': taskResume });
31
+ export const LOCAL_TASK_ROUTING_SCHEMAS = Object.freeze({
32
+ 'task-describe': { type: 'object', additionalProperties: false, required: ['repositoryRoot', 'goal'] },
33
+ 'message-route': { type: 'object', additionalProperties: false, required: ['repositoryRoot', 'taskId', 'messageId'] },
34
+ 'message-accept': { type: 'object', additionalProperties: false, required: ['repositoryRoot', 'taskId', 'messageId'] },
35
+ 'task-resume': { type: 'object', additionalProperties: false, required: ['repositoryRoot', 'taskId'] },
36
+ });
package/manifest.json CHANGED
@@ -1,7 +1,13 @@
1
1
  {
2
2
  "schemaVersion": "entrorove.skill-manifest/1.0",
3
3
  "name": "entrorove",
4
- "version": "0.1.6",
4
+ "version": "0.1.22",
5
5
  "endpoint": "https://entrorove.com",
6
- "requestSchemaVersion": "entrorove.skill.request/1.0"
6
+ "requestSchemaVersion": "entrorove.skill.request/2.0",
7
+ "supportedRequestSchemaVersions": [
8
+ "entrorove.skill.request/1.0",
9
+ "entrorove.skill.request/2.0"
10
+ ],
11
+ "localExecutionSchemaVersion": "entrorove.local-execution/1.0",
12
+ "evidenceSchemaVersion": "entrorove.test-evidence/2.0"
7
13
  }
package/package.json CHANGED
@@ -1,12 +1,23 @@
1
1
  {
2
2
  "name": "@entrorove/skill",
3
- "version": "0.1.6",
3
+ "version": "0.1.22",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "entrorove": "cli.mjs"
7
7
  },
8
8
  "files": [
9
9
  "cli.mjs",
10
+ "local-cli.mjs",
11
+ "local-state-lock.mjs",
12
+ "local-execution.mjs",
13
+ "local-integrity.mjs",
14
+ "local-formula.mjs",
15
+ "local-ledger.mjs",
16
+ "local-approved-plan.mjs",
17
+ "local-budget-renewal.mjs",
18
+ "local-probe.mjs",
19
+ "local-task-routing.mjs",
20
+ "remote-client.mjs",
10
21
  "version.mjs",
11
22
  "SKILL.md",
12
23
  "AGREEMENT.md",
@@ -14,7 +25,8 @@
14
25
  "CHANGELOG.md",
15
26
  "releases.json",
16
27
  "release.mjs",
17
- "release-state.json"
28
+ "release-state.json",
29
+ "CAPABILITY-MAP.md"
18
30
  ],
19
31
  "scripts": {
20
32
  "test": "node --test test/*.test.mjs",
@@ -1,14 +1,26 @@
1
1
  {
2
2
  "schemaVersion": "entrorove.release-state/1.0",
3
- "version": "0.1.6",
4
- "digest": "4ab58749501fc5c9e3fe609b28142ea5840eea737c26d709b775a463edf9df47",
3
+ "version": "0.1.22",
4
+ "digest": "9adb6cbd536a4a2838d3db2cefb1761ba59f48ec528d0f763c534d2286c1d77c",
5
5
  "files": {
6
- "cli.mjs": "35e9f546dc8750d034c4804030361388ea81551366494e9d16a2b65cc5c75fcb",
6
+ "cli.mjs": "dd56d1a184dab5c5e07abf01d1c5e6f49e1df47fee5cc6d82afc2497c7105194",
7
7
  "version.mjs": "1f392147d72c40af4b03b307ea607075fb815af5174fa07e254b95fe12dbf3e7",
8
- "SKILL.md": "3aa9031fe5df2989096d4153717a439d646d30b8ab03d2df2f0207731b1e400a",
9
- "AGREEMENT.md": "24a38ad19b8364a7fafed145782fb91469bbf3bb018f0c845b02c4dc2a534f4e",
10
- "package.json": "a63df90309025a4fca247b3236c4602d81cea6ca3c52b6e5a2acfafc8c90dfe9",
11
- "manifest.json": "a2086fe3e4dded88775ef7d9cd978e958dc6c8d42f16e7f559d77562ef5c6f59",
12
- "release.mjs": "7db31862735e8e734207cbba05ac2a5f58e2faf9b4c752100a6af098f9eaf7eb"
8
+ "SKILL.md": "da0566308440a676f004dd6fed47c8ffe2c0be17497ef3d76ef29f94fe090c5c",
9
+ "CAPABILITY-MAP.md": "276426da6b76820b3a502fe4cd80ca246ccab2d6c70036f89793b63fbced1552",
10
+ "AGREEMENT.md": "7a9a95c7d796f356d48f65c2b9a2f7b425548f0efb90241cba2579ae195089e3",
11
+ "package.json": "e6d26143455badf1d35697a177973b09bd0b84cb6cec0ebc425fb2f69a4e0088",
12
+ "manifest.json": "ab06e307795bc9da5add926752b6c4dbdaf18625090261a035aa356a679c504f",
13
+ "release.mjs": "2074abd5c892487ea393f36ca56544230d69c9d158d2b6f48fea6ff0e0b3f8a9",
14
+ "local-cli.mjs": "6754d6d974c0f4775f7c3679751c3c3605ec8cfe6975bb152465fc37bd518d09",
15
+ "local-state-lock.mjs": "1dbf64264642a0851faf043e07ee9f66d2ad0508e9f799b0626246e42fae893e",
16
+ "local-execution.mjs": "8c5bb7359f4baf4c5388c9a70863a2720722c406654952b6295fcec422e6ab8c",
17
+ "local-integrity.mjs": "b9d9d671e8e2c0ca28533bd81fddd2b0aa440e34312fdaa93f5296748cfc8604",
18
+ "local-formula.mjs": "2128b88bbf704431e9344def075225fd9f1331ab6d363ead018ae75e9f360757",
19
+ "local-ledger.mjs": "978e4da8f85baf1632cc65dba806f6e9cb28655bcb9f4a02c5100d55281a5ed0",
20
+ "local-approved-plan.mjs": "564b53ba40af6f12634488c240e3fbe36abce0f74ee563a81d2f939c2537901a",
21
+ "local-budget-renewal.mjs": "825124431b4cf26f27f3810363acb9162582f918f3333b2adc7d40b41a723f83",
22
+ "local-probe.mjs": "c6c17de570f123bdd353b9e6a8c411936d10208d38f5601568e99aaf48f00086",
23
+ "local-task-routing.mjs": "7b26847728353697c3b6449f4ccbd118f31facc03fa74214f21be8c146ac7c2a",
24
+ "remote-client.mjs": "f592ea226baee2edbc9c38e02950c77e0c294d7e546c3dcca4a0ea024b32e6e9"
13
25
  }
14
26
  }
package/release.mjs CHANGED
@@ -5,8 +5,11 @@ import { fileURLToPath } from 'node:url'
5
5
 
6
6
  const directory = path.dirname(fileURLToPath(import.meta.url))
7
7
  const sourceFiles = [
8
- 'cli.mjs', 'version.mjs', 'SKILL.md', 'AGREEMENT.md',
8
+ 'cli.mjs', 'version.mjs', 'SKILL.md', 'CAPABILITY-MAP.md', 'AGREEMENT.md',
9
9
  'package.json', 'manifest.json', 'release.mjs',
10
+ 'local-cli.mjs', 'local-state-lock.mjs', 'local-execution.mjs', 'local-integrity.mjs', 'local-formula.mjs', 'local-ledger.mjs',
11
+ 'local-approved-plan.mjs', 'local-budget-renewal.mjs', 'local-probe.mjs',
12
+ 'local-task-routing.mjs', 'remote-client.mjs',
10
13
  ]
11
14
  const stateFile = 'release-state.json'
12
15
  const changelogFile = 'CHANGELOG.md'
@@ -59,10 +62,22 @@ function releaseNote(changed) {
59
62
  'cli.mjs': { zh: '技能运行和受管安装', en: 'skill runtime and managed installation' },
60
63
  'version.mjs': { zh: '版本安全检查', en: 'version safety checks' },
61
64
  'SKILL.md': { zh: 'IDE 工作流程说明', en: 'IDE workflow instructions' },
65
+ 'CAPABILITY-MAP.md': { zh: '能力覆盖映射', en: 'capability coverage map' },
62
66
  'AGREEMENT.md': { zh: '使用、隐私与推广告知', en: 'usage, privacy, and promotion notice' },
63
67
  'package.json': { zh: '安装包配置', en: 'package configuration' },
64
68
  'manifest.json': { zh: '技能清单', en: 'skill manifest' },
65
69
  'release.mjs': { zh: '自动版本与更新日志', en: 'automatic versioning and changelog' },
70
+ 'local-cli.mjs': { zh: '本地执行命令', en: 'local execution command' },
71
+ 'local-state-lock.mjs': { zh: '本地状态并发锁', en: 'local state concurrency lock' },
72
+ 'local-execution.mjs': { zh: '本地执行闸门与预算', en: 'local execution gates and budgets' },
73
+ 'local-integrity.mjs': { zh: '证据与回执验签', en: 'evidence and receipt verification' },
74
+ 'local-formula.mjs': { zh: '确定性公式引擎', en: 'deterministic formula engine' },
75
+ 'local-ledger.mjs': { zh: '执行与链路台账', en: 'execution and chain ledger' },
76
+ 'local-approved-plan.mjs': { zh: '冻结计划执行器', en: 'frozen plan runner' },
77
+ 'local-budget-renewal.mjs': { zh: '预算续期确认', en: 'budget renewal confirmation' },
78
+ 'local-probe.mjs': { zh: '范围探测与分档', en: 'scope probing and tiering' },
79
+ 'local-task-routing.mjs': { zh: '本地任务路由', en: 'local task routing' },
80
+ 'remote-client.mjs': { zh: '远端恢复客户端', en: 'remote recovery client' },
66
81
  }
67
82
  return {
68
83
  zh: `更新了${changed.map(name => labels[name].zh).join('、')}。`,