@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.
package/AGREEMENT.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # entrorove 使用、隐私与广告信息协议
2
2
 
3
- 版本:2026-09-29.2
3
+ 版本:2026-10-02.1
4
4
 
5
5
  本协议是首次使用 entrorove 技能时的合并告知与使用条款。只有用户明确选择“同意”后,技能请求才会发送。IDE 的“完全访问”权限只是本地权限,不代表同意本协议。
6
6
 
@@ -14,6 +14,7 @@
14
14
 
15
15
  - 安装包不会因安装本身自动扫描或上传项目文件。只有在发起技能调用时,才会向 entrorove API 发送 IDE 明确组装的请求负载。
16
16
  - 账号服务会处理登录邮箱、密钥哈希与前缀、加密保存的完整密钥、会话与安全记录、调用时间/数量,以及用户自愿提交的留言。已登录用户可再次复制自己的完整密钥。
17
+ - 用户主动提交并明确同意公开的 IDE 评价会展示 IDE 名称、评分、评价文字和时间;公开列表最多展示最近 30 条。
17
18
  - 当前实现不将项目源文件或技能任务负载写入业务数据库,不出售个人信息。推广选择使用密钥的连续调用时长和调用次数判断展示时机,不分析项目内容或建立跨站画像。
18
19
  - 本地同意回执仅保存协议版本、同意结果和时间,不包含邮箱、项目内容或密钥。
19
20
 
@@ -40,12 +41,13 @@
40
41
 
41
42
  # entrorove Terms, Privacy and Advertising Notice
42
43
 
43
- Version: 2026-09-29.2
44
+ Version: 2026-10-02.1
44
45
 
45
46
  This combined notice applies on first use. A skill request is sent only after an explicit **Accept** choice. Granting an IDE “full access” permission does not accept this agreement.
46
47
 
47
48
  - **Use and access.** entrorove acts only on tasks, context and permissions supplied by the user and IDE. Elevated permissions may permit file changes, command execution and external calls. Review scope, keep backups and separately confirm consequential actions.
48
49
  - **Data.** Installation alone does not scan or upload project files. The account service processes login email, key hashes and prefixes, an encrypted copy of the full key that a signed-in user can copy again, sessions and security records, invocation time/count, and messages submitted voluntarily. Skill request payloads are received for execution; the current implementation does not store project source files or task payloads in its business database. It does not sell personal information. Promotion timing uses consecutive invocation duration and count for the same key, without examining project content or building a cross-site profile.
50
+ - **Public reviews.** An IDE review is published only after the user submits it and explicitly consents to public display; the public list may show the IDE name, rating, review text and timestamp, limited to the latest 30 entries.
49
51
  - **Advertising.** A configured message may appear beneath a long-running-task notice. Commercial content carries its configured category label as `【Category】` and includes an HTTPS link. Administrators set active dates, task-duration windows, selection weights and probability bounds. The service adjusts display probability using consecutive invocation duration and count, without inspecting task text or project files. Third-party links are governed by their operators.
50
52
  - **Risk.** The skill is provided as available without a guarantee that every result is correct, complete, uninterrupted or fit for a particular purpose. Review and test changes before production use. To the maximum extent permitted by applicable law, entrorove excludes indirect, incidental, special and consequential losses. Nothing excludes non-waivable liability, fraud, wilful misconduct or mandatory consumer rights.
51
53
  - **Choice.** Declining prevents the skill request from being sent. Material changes require a new explicit acceptance. Mandatory local laws may provide additional rights. Qualified counsel should review this notice before production release for the actual operating and user jurisdictions.
@@ -0,0 +1,18 @@
1
+ # v2 capability map (AC-16)
2
+
3
+ This is the operation-level inventory for the eight legacy domains. `equivalent` means the v2 operation preserves the contract; `narrower` means the operation is available behind a stricter v2 gate or with a smaller side-effect boundary; `uncovered` means the old operation remains intentionally unavailable and callers must receive an explicit unsupported result.
4
+
5
+ | Legacy domain | Legacy operations | v2 mapping and semantics |
6
+ | --- | --- | --- |
7
+ | aimlock | capabilities, help, intake, classify, scope-contract, skill-route, propose-nodes, accept-nodes, snapshot-plan, mutate-gate, continuity-check, interrupt, keep-alive, delivery-doc, validate-json, snapshot-verify, run-status, chain-plan, chain-status, feedback | `capabilities/help/intake` → equivalent shared discovery; `classify/skill-route/scope-contract/propose-nodes/accept-nodes/continuity-check/validate-json` → equivalent through `task-describe/message-route`; `snapshot-plan/snapshot-verify/mutate-gate/interrupt/keep-alive/run-status/chain-plan/chain-status/feedback/delivery-doc` → narrower through persisted flow state, local guarded writes, cancellation, status, and evidence gates. |
8
+ | blueprint | capabilities, help, intake, validate, compile-inline, acceptance-report | `capabilities/help/intake/validate/compile-inline/acceptance-report` → equivalent through `blueprint` runtime; acceptance evidence is caller supplied and cannot be self-promoted to verified. |
9
+ | calctool | capabilities, help, intake, validate, compile-inline | `capabilities/help/intake/validate/compile-inline` → equivalent through `calctool`; `formula-evaluate/formula-graph/formula-final-gate` → local Decimal execution and final-gate evidence. |
10
+ | swarm | capabilities, help, intake, org-chart, blueprint-bridge, dispatch, claim, report, accept, swarm-status, traffic-light, security-check, validate-json, heartbeat, reclaim | `capabilities/help/intake/org-chart/blueprint-bridge/dispatch/claim/report/accept/swarm-status/traffic-light/security-check/validate-json/heartbeat/reclaim` → narrower through server-routed coordination, bounded worker counts, leases, and persisted reported/verified states. |
11
+ | validator | capabilities, help, intake, plan, validate-structure, security-scan, compliance-audit, functional-verify, verdict | All nine → equivalent through the `validator` runtime; `functional-verify` requires API-side receipt verification and `verdict` cannot turn reported evidence green. |
12
+ | mergeguard | capabilities, help, intake, resolve-propose, ruleguard-scan, ruleguard-compile | All six → equivalent through `mergeguard`; `merge-verified` remains a local operation and fails closed without a valid baseline, evidence, and receipt. |
13
+ | archguard | capabilities, help, intake, contract-create, contract-get, contract-update, template-list, checkpoint, rules-scan, complexity-report, drift-status, ledger-query | All twelve → equivalent through `archguard`; contract writes and ledger records are local and path/baseline bound. |
14
+ | confirm-protocol | capabilities, help, interaction-request, interaction-answer, chat-render, memory-set, memory-list, memory-clear, batch-request, audit-query | All ten → equivalent for pure protocol operations; native/mobile notifier operations remain explicitly planned and are returned as unavailable. High-risk interaction keeps `default: null`, no memory, and no batch approval. |
15
+
16
+ The local execution surface also covers `snapshot-plan`, `snapshot-verify`, `mutate-gate`, `gate-issue`, `gate-verify`, `guarded-write`, `budget-init`, `budget-context`, `budget-read`, `budget-status`, `budget-extend`, `budget-auto-renew-request`, `budget-auto-renew`, `budget-auto-renew-stop`, `peer-coordinate`, `spawn-bind`, `peer-complete`, `complete`, `lease-verify`, `evidence-sign`, `evidence-verify`, `merge-verified`, `rollback`, `final-gate`, `formula-evaluate`, `formula-graph`, `formula-final-gate`, `calculation-final-gate`, `run-approved-plan`, `branch-create`, `branch-list`, `branch-switch`, `diff-report`, `preflight`, `ledger-query`, `chain-init`, `chain-resume`, `chain-status`, `chain-answer`, `branch-create-virtual`, `chain-ledger-init`, `chain-ledger-status`, `chain-ledger-query`, `probe`, `reassess`, `task-describe`, `message-route`, `message-accept`, and `task-resume`. Local branch operations are virtual ledger records and never create Git branches or worktrees.
17
+
18
+ The only intentionally uncovered legacy surface is an old notifier or an operation that would execute remote filesystem changes. These are represented as planned or narrowed behavior in capabilities; no adapter may silently substitute a different operation.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,85 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.22 — 2026-10-04
4
+
5
+ - 完整补齐隔离检测、运行时生成、基线契约、云任务取消与事件流、能力覆盖映射及隐私告知。
6
+ - Completed isolation detection, reproducible runtime generation, baseline contracts, cloud cancellation and event streaming, capability coverage mapping, and privacy notices.
7
+
8
+ ## 0.1.21 — 2026-10-04
9
+
10
+ - 更新了本地执行闸门与预算。
11
+ - Updated local execution gates and budgets.
12
+
13
+ ## 0.1.20 — 2026-10-04
14
+
15
+ - 更新了本地状态并发锁、本地执行闸门与预算、执行与链路台账。
16
+ - Updated local state concurrency lock, local execution gates and budgets, execution and chain ledger.
17
+
18
+ ## 0.1.19 — 2026-10-04
19
+
20
+ - 更新了安装包配置、自动版本与更新日志、本地状态并发锁、本地执行闸门与预算、预算续期确认、本地任务路由。
21
+ - Updated package configuration, automatic versioning and changelog, local state concurrency lock, local execution gates and budgets, budget renewal confirmation, local task routing.
22
+
23
+ ## 0.1.18 — 2026-10-04
24
+
25
+ - 更新了冻结计划执行器。
26
+ - Updated frozen plan runner.
27
+
28
+ ## 0.1.17 — 2026-10-04
29
+
30
+ - 更新了本地执行命令、冻结计划执行器。
31
+ - Updated local execution command, frozen plan runner.
32
+
33
+ ## 0.1.16 — 2026-10-04
34
+
35
+ - 更新了远端恢复客户端。
36
+ - Updated remote recovery client.
37
+
38
+ ## 0.1.15 — 2026-10-04
39
+
40
+ - 更新了IDE 工作流程说明。
41
+ - Updated IDE workflow instructions.
42
+
43
+ ## 0.1.14 — 2026-10-02
44
+
45
+ - 更新了本地执行闸门与预算。
46
+ - Updated local execution gates and budgets.
47
+
48
+ ## 0.1.13 — 2026-10-02
49
+
50
+ - 更新了执行与链路台账、预算续期确认、本地任务路由。
51
+ - Updated execution and chain ledger, budget renewal confirmation, local task routing.
52
+
53
+ ## 0.1.12 — 2026-10-02
54
+
55
+ - 更新了远端恢复客户端。
56
+ - Updated remote recovery client.
57
+
58
+ ## 0.1.11 — 2026-10-02
59
+
60
+ - 更新了本地执行闸门与预算。
61
+ - Updated local execution gates and budgets.
62
+
63
+ ## 0.1.10 — 2026-10-02
64
+
65
+ - 更新了本地执行闸门与预算。
66
+ - Updated local execution gates and budgets.
67
+
68
+ ## 0.1.9 — 2026-10-02
69
+
70
+ - 更新了自动版本与更新日志、本地执行闸门与预算、证据与回执验签、执行与链路台账、冻结计划执行器。
71
+ - Updated automatic versioning and changelog, local execution gates and budgets, evidence and receipt verification, execution and chain ledger, frozen plan runner.
72
+
73
+ ## 0.1.8 — 2026-10-02
74
+
75
+ - 恢复本地执行层与质量闭环:快照隔离、签名写入、预算、协作、验证回执、公式和任务路由。
76
+ - Restored the local execution and quality workflow: snapshots, signed writes, budgets, coordination, receipts, formulas, and task routing.
77
+
78
+ ## 0.1.7 — 2026-10-02
79
+
80
+ - 恢复统一技能全集、本地执行层与安全复制流程。
81
+ - Restored the complete unified skill set, local execution layer, and secure copy flow.
82
+
3
83
  ## 0.1.6 — 2026-09-29
4
84
 
5
85
  - 配置密钥前检查凭据目录,拒绝符号链接目录,避免密钥写入非预期位置。
package/SKILL.md CHANGED
@@ -5,7 +5,7 @@ description: An engineering skill that understands a task, chooses a fitting wor
5
5
 
6
6
  # entrorove · 熵巡
7
7
 
8
- Version: 0.1.6.
8
+ Version: 0.1.22.
9
9
 
10
10
  Read the user's goal and repository instructions first. Preserve the original task through planning, execution and verification. Use the workflow only where it helps; a small, low-risk edit can proceed directly. Never claim a reported step is a verified result.
11
11
 
@@ -13,18 +13,28 @@ Install or update the managed copy with `npx @entrorove/skill install`. The inst
13
13
 
14
14
  Register or sign in by email at https://entrorove.com. Enter the account key once with `npx @entrorove/skill configure` and `{"token":"..."}` on standard input. The credential persists in the current OS user's restricted local store across skill upgrades. Before asking again, use the stored credential; a missing or invalid credential must be reported explicitly. Never place the key in arguments, URLs, project files or logs. entrorove accounts and keys are independent of other services.
15
15
 
16
- On first use or after a material agreement revision, the CLI returns a Confirm Protocol-compatible agreement interaction. Render it with the IDE's native confirmation UI, or show its `chatFallback` text when no adapter exists. The user must explicitly accept with `npx @entrorove/skill agreement` and JSON `{"version":"2026-09-29.2","answer":"accept"}` on standard input before retrying the skill request. A full-access IDE permission never counts as agreement acceptance. The combined terms, privacy and advertising notice is installed as `AGREEMENT.md`.
16
+ On first use or after a material agreement revision, the CLI returns a Confirm Protocol-compatible agreement interaction. Render it with the IDE's native confirmation UI, or show its `chatFallback` text when no adapter exists. The user must explicitly accept with `npx @entrorove/skill agreement` and JSON `{"version":"2026-10-02.1","answer":"accept"}` on standard input before retrying the skill request. A full-access IDE permission never counts as agreement acceptance. The combined terms, privacy and advertising notice is installed as `AGREEMENT.md`.
17
17
 
18
18
  ## Work with a task
19
19
 
20
- Send JSON on standard input to `npx @entrorove/skill invoke`. Start with `operation: "start"` and an `input.task` containing the goal, work type, risk, difficulty and observed scope facts. Work types are `implementation`, `documentation`, `calculation`, `merge`, `review` and `decision`. Assess facts from the real task and repository; do not invent certainty. entrorove chooses the workflow. A small task may return `decision: "direct"`, meaning the IDE should do the work without another service call.
20
+ Send JSON on standard input to `npx @entrorove/skill invoke`. Call `operation: "capabilities"` with `{}` first when an adapter needs the live operation and schema list. Adapters that expose the four-step task route can call `task-describe`, `message-route`, `message-accept` and `task-resume` in order; `start` remains the one-call workflow equivalent. Then start with `operation: "start"` and an `input.task` containing the goal, work type, risk, difficulty and observed scope facts. Work types are `implementation`, `documentation`, `calculation`, `merge`, `review`, `decision` and `diagnosis`. Assess facts from the real task and repository; do not invent certainty. entrorove chooses the workflow. A small task may return `decision: "direct"`, meaning the IDE should do the work without another service call. The public envelope is `entrorove.skill.request/2.0`; `requestId` is the SHA-256 digest of `operation + canonical(input)`. Version 1.0 remains accepted for older callers.
21
21
 
22
22
  ```json
23
23
  {"operation":"start","input":{"task":{"goal":"Update the checkout flow and verify it","workType":"implementation","risk":"medium","difficulty":"medium","crossModule":true,"parallel":false,"unclear":false,"architectureContract":false}}}
24
24
  ```
25
25
 
26
+ For `workType: "merge"`, include `baselineRef` with the concrete branch, tag, commit, or review baseline used for comparison. The API rejects a merge plan without it, and the baseline must remain unchanged through verification. For `workType: "decision"`, include the structured `interaction`; elevated-risk decisions also require `riskDescription`.
27
+
28
+ The v2 capability map is recorded in [`CAPABILITY-MAP.md`](CAPABILITY-MAP.md). It maps every legacy domain and operation to its v2 equivalent, narrower semantics, or an explicitly documented uncovered area; adapters must not silently substitute an uncovered operation.
29
+
26
30
  For a returned `flowId`, continue with `{"operation":"continue","input":{"flowId":"<returned ID>","operation":"<next operation>","payload":{}}}`. Fill `payload` with the next operation's required input. Keep the same task and evidence in the IDE. For later work, `input.task.phase` may be `plan`, `coordinate`, `architecture`, `verify`, `merge` or `decision`; it remains the same skill entry point. Never treat an unknown request outcome as permission to retry a paid or mutating action; query the original request ID when the workflow supports it.
27
31
 
32
+ The v2 progress sequence is durable and ordered: the first runtime step reports `intake`, then the applicability `gate` is recorded and verified, then the selected contract or plan steps run. Every step can be `pending`, `running`, `reported`, `verified`, `blocked` or `failed`; only a completed verdict with trusted evidence is green.
33
+
28
34
  For a decision, include `input.task.interaction` with `type` (`confirm`, `choice`, `multi` or `input`), a `question`, `options` (two or more with unique `id` and `label`, or an empty array for input), and a `callback` containing its `operation` and `payload`. Medium- and high-risk decisions also need a `riskDescription`. The decision waits for an explicit answer; it never chooses a default on timeout.
29
35
 
36
+ Use `npx @entrorove/skill status <requestId>` after a connection loss. It performs a read-only GET lookup and never resends a POST. Use `npx @entrorove/skill flows` to list recoverable flow steps. A v2 flow has its own `flowId` and progresses through `pending`, `running`, `reported`, `verified`, `blocked` or `failed`; a reported result is not a verified result.
37
+
38
+ The local execution layer is available through `npx @entrorove/skill local <operation> [repositoryRoot]` with a JSON object on standard input. `local capabilities` lists the complete contract. Operations include `snapshot-plan`, `snapshot-verify`, `mutate-gate`, `gate-issue`, `gate-verify`, `guarded-write`, `budget-init`, `budget-context`, `budget-read`, `budget-status`, `budget-extend`, budget renewal confirmation, `peer-coordinate`, `spawn-bind`, `peer-complete`, `lease-verify`, `probe`, `reassess`, execution ledger and virtual branch records, `chain-init`, `chain-resume`, `chain-status`, `chain-answer`, `evidence-sign`, `evidence-verify`, `merge-verified`, `rollback`, `formula-evaluate`, `formula-graph`, `formula-final-gate`, `run-approved-plan` and `final-gate`. State is kept only under `~/.entrorove/`; snapshots never create Git branches or worktrees. Guarded writes require a short-lived Ed25519 pass bound to the flow, step, snapshot digest and path set. Local evidence uses `entrorove.test-evidence/2.0`; unsigned local evidence remains incomplete and verified delivery requires a trusted receipt. Approved plan execution requires a frozen plan, matching approval and unchanged files; without a configured runner signer it reports local evidence as incomplete.
39
+
30
40
  When the user gives an actual evaluation after using the skill, offer to publish that feedback. Only after they explicitly agree to public display, send their own words, IDE name and 1–5 rating as JSON on standard input to `npx @entrorove/skill review` with `publicConsent: true`. Never invent, rewrite or infer a review from task completion. The website reads accepted reviews directly from entrorove's independent database.
package/cli.mjs CHANGED
@@ -6,24 +6,24 @@ import os from 'node:os';
6
6
  import path from 'node:path';
7
7
  import { fileURLToPath } from 'node:url';
8
8
  import { compareVersions } from './version.mjs';
9
-
9
+ import { runLocal } from './local-cli.mjs';
10
+ import { apiUrl, buildInvocation, invokeRemote, queryFlows, queryStatus } from './remote-client.mjs';
10
11
  const packageDir = path.dirname(fileURLToPath(import.meta.url));
11
- const apiUrl = 'https://entrorove.com/api/skill/invoke';
12
12
  const reviewUrl = 'https://entrorove.com/api/ide-reviews';
13
13
  const markerName = '.entrorove-managed.json';
14
14
  const installFiles = ['SKILL.md', 'AGREEMENT.md', 'manifest.json'];
15
15
  const markerSchema = 'entrorove.managed-install/1.0';
16
16
  const credentialSchema = 'entrorove.credential/1.0';
17
17
  const agreementSchema = 'entrorove.agreement-acceptance/1.0';
18
- const invocationTimeoutMs = 135_000;
19
18
  const installLockWaitMs = 10_000;
20
19
  const installLockStaleMs = 120_000;
21
- export const agreementVersion = '2026-09-29.2';
20
+ export const agreementVersion = '2026-10-02.1';
22
21
 
23
22
  function sha256(content) {
24
23
  return createHash('sha256').update(content).digest('hex');
25
24
  }
26
25
 
26
+
27
27
  async function readJson(file) {
28
28
  const value = JSON.parse(await fs.readFile(file, 'utf8'));
29
29
  if (!value || typeof value !== 'object' || Array.isArray(value)) {
@@ -38,7 +38,7 @@ async function sourceBundle() {
38
38
  if (manifest.schemaVersion !== 'entrorove.skill-manifest/1.0'
39
39
  || manifest.name !== 'entrorove'
40
40
  || manifest.endpoint !== 'https://entrorove.com'
41
- || manifest.requestSchemaVersion !== 'entrorove.skill.request/1.0'
41
+ || !['entrorove.skill.request/1.0', 'entrorove.skill.request/2.0'].includes(manifest.requestSchemaVersion)
42
42
  || !/^\d+\.\d+\.\d+$/.test(manifest.version)
43
43
  || packageMetadata.name !== '@entrorove/skill'
44
44
  || packageMetadata.version !== manifest.version) {
@@ -326,7 +326,7 @@ async function preparePrivateDirectory(parent) {
326
326
  }
327
327
 
328
328
  async function writeCredential(token, home) {
329
- if (typeof token !== 'string' || !/^ent_[A-Za-z0-9_-]{16,256}$/.test(token)) {
329
+ if (typeof token !== 'string' || !/^ent_[A-Za-z0-9_-]{43}$/.test(token)) {
330
330
  throw new Error('Invalid entrorove key');
331
331
  }
332
332
  const file = credentialPath(home);
@@ -353,7 +353,7 @@ async function readCredential(home) {
353
353
  if (process.platform !== 'win32' && (stat.mode & 0o077) !== 0) throw new Error('Credential permissions must be 0600');
354
354
  const data = await readJson(file);
355
355
  if (data.schemaVersion !== credentialSchema || typeof data.token !== 'string'
356
- || !/^ent_[A-Za-z0-9_-]{16,256}$/.test(data.token)) throw new Error('Invalid credential file');
356
+ || !/^ent_[A-Za-z0-9_-]{43}$/.test(data.token)) throw new Error('Invalid credential file');
357
357
  return data.token;
358
358
  }
359
359
 
@@ -396,43 +396,17 @@ async function writeAgreementAcceptance(data, home) {
396
396
  return true;
397
397
  }
398
398
 
399
- export function buildInvocation(operation, input) {
400
- if (operation !== 'start' && operation !== 'continue') throw new Error('Invalid entrorove operation');
401
- if (!input || typeof input !== 'object' || Array.isArray(input)) throw new Error('Input must be a JSON object');
402
- const requestId = randomUUID();
403
- return { url: apiUrl, requestId, body: { schemaVersion: 'entrorove.skill.request/1.0', requestId, operation, input } };
399
+ export { buildInvocation };
400
+
401
+ function remoteDependencies() {
402
+ return {
403
+ install, agreementAccepted, agreementRequired, readCredential, home: os.homedir(),
404
+ apiOrigin: () => new URL(apiUrl).origin,
405
+ };
404
406
  }
405
407
 
406
408
  async function invoke(data) {
407
- await install();
408
- if (!await agreementAccepted(os.homedir())) {
409
- process.stdout.write(JSON.stringify(agreementRequired(os.homedir())) + '\n');
410
- process.exitCode = 2;
411
- return;
412
- }
413
- const call = buildInvocation(data.operation, data.input);
414
- const token = await readCredential(os.homedir());
415
- let response;
416
- try {
417
- response = await fetch(call.url, {
418
- method: 'POST', redirect: 'error', signal: AbortSignal.timeout(invocationTimeoutMs),
419
- headers: { 'content-type': 'application/json', authorization: `Bearer ${token}` },
420
- body: JSON.stringify(call.body),
421
- });
422
- } catch {
423
- throw new Error(`Invocation outcome is unknown; requestId=${call.requestId}. Do not automatically resend this request.`);
424
- }
425
- if (!response.ok) throw new Error(`entrorove invocation failed: HTTP ${response.status}; requestId=${call.requestId}`);
426
- let result;
427
- try {
428
- result = await response.json();
429
- } catch {
430
- throw new Error(`Invocation outcome is unknown; requestId=${call.requestId}. Do not automatically resend this request.`);
431
- }
432
- if (result?.ok !== true || result.output?.requestId !== call.requestId) {
433
- throw new Error(`Invalid entrorove response; requestId=${call.requestId}`);
434
- }
435
- process.stdout.write(JSON.stringify(result) + '\n');
409
+ return invokeRemote(data, remoteDependencies());
436
410
  }
437
411
 
438
412
  export function buildReview(data) {
@@ -486,11 +460,28 @@ async function main() {
486
460
  await invoke(await stdinJson());
487
461
  } else if (command === 'review' && args.length === 0) {
488
462
  await submitReview(await stdinJson());
463
+ } else if (command === 'status' && args.length === 1) {
464
+ await install();
465
+ if (!await agreementAccepted(os.homedir())) {
466
+ process.stdout.write(JSON.stringify(agreementRequired(os.homedir())) + '\n');
467
+ process.exitCode = 2;
468
+ return;
469
+ }
470
+ await queryStatus(args[0], remoteDependencies());
471
+ } else if (command === 'flows' && args.length === 0) {
472
+ await install();
473
+ if (!await agreementAccepted(os.homedir())) {
474
+ process.stdout.write(JSON.stringify(agreementRequired(os.homedir())) + '\n');
475
+ process.exitCode = 2;
476
+ return;
477
+ }
478
+ await queryFlows(remoteDependencies());
479
+ } else if (command === 'local') {
480
+ await runLocal(args);
489
481
  } else {
490
- throw new Error('Usage: entrorove install | configure | agreement | invoke | review (JSON on stdin)');
482
+ throw new Error('Usage: entrorove install | configure | agreement | invoke | status <requestId> | flows | review | local (JSON on stdin)');
491
483
  }
492
484
  }
493
-
494
485
  if (process.argv[1] && realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url))) {
495
486
  main().catch((error) => {
496
487
  process.stderr.write(`${error.message}\n`);
@@ -0,0 +1,81 @@
1
+ import { createHash, createPrivateKey, createPublicKey, randomUUID, sign } from 'node:crypto';
2
+ import { spawn } from 'node:child_process';
3
+ import { promises as fs, constants } from 'node:fs';
4
+ import os from 'node:os';
5
+ import path from 'node:path';
6
+ import { performance } from 'node:perf_hooks';
7
+
8
+ export const RUNNER_SCHEMA = 'entrorove.validator-local-runner/1.0';
9
+ export const PLAN_SCHEMA = 'validator.execution-plan/1.0';
10
+ export const APPROVAL_SCHEMA = 'validator.runner-approval/1.0';
11
+ export const SIGNER_SCHEMA = 'validator.runner-signer/1.0';
12
+ export const RECEIPT_SCHEMA = 'validator.execution-receipt/1.0';
13
+ export const RUNNER_LIMITS = Object.freeze({ maxFiles: 512, maxFileBytes: 16 * 1024 * 1024, maxControlBytes: 1024 * 1024, maxExecutableBytes: 256 * 1024 * 1024, maxTimeoutMs: 300_000, maxOutputBytes: 1024 * 1024, maxApprovalLifetimeMs: 86_400_000, maxReceiptLifetimeMs: 600_000 });
14
+ const SHA256 = /^[0-9a-f]{64}$/; const ID = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/; const ENV = /^[A-Za-z_][A-Za-z0-9_]*$/;
15
+ function fail(code, message) { const error = new Error(message); error.code = code; throw error; }
16
+ function digest(value) { return createHash('sha256').update(value).digest('hex'); }
17
+ 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); }
18
+ function safeRelative(value, label = 'path') { if (typeof value !== 'string' || !value || value !== value.normalize('NFC') || path.isAbsolute(value) || /^[A-Za-z]:/.test(value) || value.includes('\\') || /[\u0000-\u001f\u007f]/.test(value) || value.split('/').some(part => !part || part === '.' || part === '..')) fail('ENTROROVE_PATH_UNSAFE', `${label} must be a normalized relative path`); return value; }
19
+ function inside(root, target, label) { const relative = path.relative(root, target); if (relative === '' || (!path.isAbsolute(relative) && relative !== '..' && !relative.startsWith(`..${path.sep}`))) return; fail('ENTROROVE_PATH_ESCAPE', `${label} escapes repositoryRoot`); }
20
+ async function rootOf(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); }
21
+ async function regular(file, max, privateFile = false) { const stat = await fs.lstat(file); if (!stat.isFile() || stat.isSymbolicLink() || stat.size > max) fail('ENTROROVE_FILE_INVALID', 'control file must be a bounded regular file'); if (privateFile && process.platform !== 'win32' && ((typeof process.getuid === 'function' && stat.uid !== process.getuid()) || (stat.mode & 0o777) !== 0o600 || stat.nlink !== 1)) fail('ENTROROVE_CONTROL_PERMISSIONS', 'control file must be owned and mode 0600'); return { bytes: await fs.readFile(file), sha256: digest(await fs.readFile(file)) }; }
22
+ async function projectFile(root, relative) { const safe = safeRelative(relative); const target = path.resolve(root, ...safe.split('/')); inside(root, target, safe); const stat = await fs.lstat(target); if (!stat.isFile() || stat.isSymbolicLink() || await fs.realpath(target) !== target) fail('ENTROROVE_FILE_INVALID', `${safe} must be a regular file`); return { path: safe, target, stat, ...(await regular(target, RUNNER_LIMITS.maxFileBytes)) }; }
23
+ async function externalFile(root, value, label) { if (typeof value !== 'string' || !path.isAbsolute(value)) fail('ENTROROVE_CONTROL_PATH', `${label} must be an absolute path`); const entry = await fs.lstat(value); if (!entry.isFile() || entry.isSymbolicLink()) fail('ENTROROVE_CONTROL_FILE', `${label} must be a regular non-symlink file`); const target = await fs.realpath(value); const relative = path.relative(root, target); if (relative === '' || (!path.isAbsolute(relative) && relative !== '..' && !relative.startsWith(`..${path.sep}`))) fail('ENTROROVE_PATH_ESCAPE', `${label} must be outside repositoryRoot`); return { path: target, ...(await regular(target, RUNNER_LIMITS.maxControlBytes, true)) }; }
24
+ function exactObject(value, fields, label) { if (!value || typeof value !== 'object' || Array.isArray(value) || Object.keys(value).sort().join('|') !== [...fields].sort().join('|')) fail('ENTROROVE_PLAN_FIELDS', `${label} has invalid fields`); return value; }
25
+ function boundedInt(value, max, label) { if (!Number.isSafeInteger(value) || value <= 0 || value > max) fail('ENTROROVE_LIMIT_INVALID', `${label} must be a positive bounded integer`); }
26
+ function planArtifact(files) { return digest(canonical(files)); }
27
+
28
+ function validatePlan(plan) {
29
+ exactObject(plan, ['schemaVersion', 'frozen', 'planId', 'validationRunId', 'memberId', 'chainId', 'artifactSha256', 'files', 'tests', 'policy', 'goldenBaseline', 'contracts'], 'execution plan');
30
+ if (plan.schemaVersion !== PLAN_SCHEMA || plan.frozen !== true || !ID.test(plan.planId) || !ID.test(plan.validationRunId) || !ID.test(plan.memberId) || !ID.test(plan.chainId) || !SHA256.test(plan.artifactSha256) || !Array.isArray(plan.files) || plan.files.length === 0 || plan.files.length > RUNNER_LIMITS.maxFiles) fail('ENTROROVE_PLAN_INVALID', 'plan must be frozen and bounded');
31
+ for (let i = 0; i < plan.files.length; i += 1) { const file = exactObject(plan.files[i], ['path', 'sha256'], 'manifest entry'); safeRelative(file.path); if (!SHA256.test(file.sha256) || (i > 0 && plan.files[i - 1].path >= file.path)) fail('ENTROROVE_PLAN_MANIFEST', 'manifest paths must be sorted and unique'); }
32
+ if (planArtifact(plan.files) !== plan.artifactSha256) fail('ENTROROVE_PLAN_MANIFEST', 'artifact manifest digest mismatch');
33
+ const policy = exactObject(plan.policy, ['executable', 'executableSha256', 'args', 'environment', 'timeoutMs', 'maxOutputBytes', 'requiredExitCode'], 'execution policy');
34
+ if (typeof policy.executable !== 'string' || !path.isAbsolute(policy.executable) || !SHA256.test(policy.executableSha256) || !Array.isArray(policy.args) || policy.args.length > 128 || policy.args.some(value => typeof value !== 'string' || value.includes('\0') || value.length > 4096)) fail('ENTROROVE_POLICY_INVALID', 'executable and args are invalid');
35
+ if (!policy.environment || typeof policy.environment !== 'object' || Array.isArray(policy.environment) || Object.keys(policy.environment).length > 64 || Object.entries(policy.environment).some(([key, value]) => !ENV.test(key) || typeof value !== 'string' || value.includes('\0') || value.length > 16_384)) fail('ENTROROVE_POLICY_ENVIRONMENT', 'environment must be explicit bounded strings');
36
+ boundedInt(policy.timeoutMs, RUNNER_LIMITS.maxTimeoutMs, 'timeoutMs'); boundedInt(policy.maxOutputBytes, RUNNER_LIMITS.maxOutputBytes, 'maxOutputBytes'); if (policy.requiredExitCode !== 0) fail('ENTROROVE_POLICY_EXIT', 'requiredExitCode must be zero');
37
+ if (!Array.isArray(plan.tests) || plan.tests.length === 0 || plan.tests.length > RUNNER_LIMITS.maxFiles) fail('ENTROROVE_PLAN_TESTS', 'tests must be non-empty');
38
+ for (const test of plan.tests) { exactObject(test, ['testId', 'path'], 'test'); if (!ID.test(test.testId) || !plan.files.some(file => file.path === test.path) || !policy.args.includes(test.path)) fail('ENTROROVE_PLAN_TESTS', 'each test must be in the manifest and explicit in args'); }
39
+ if (plan.contracts?.archguard?.driftStatus === 'red') fail('ENTROROVE_ARCHGUARD_BLOCKED', 'ArchGuard red blocks execution'); return plan;
40
+ }
41
+
42
+ async function validateApproval(root, input, planSha256) {
43
+ const file = await externalFile(root, input.approvalPath, 'approvalPath'); let approval; try { approval = JSON.parse(file.bytes.toString('utf8')); } catch (cause) { fail('ENTROROVE_APPROVAL_INVALID', `approval JSON is invalid: ${cause.message}`); }
44
+ exactObject(approval, ['schemaVersion', 'repositoryRoot', 'planSha256', 'approvedBy', 'approvedAt', 'expiresAt'], 'runner approval'); const approvedAt = Date.parse(approval.approvedAt); const expiresAt = Date.parse(approval.expiresAt); let approvedRoot = null; try { approvedRoot = await fs.realpath(approval.repositoryRoot); } catch {}
45
+ if (approval.schemaVersion !== APPROVAL_SCHEMA || approvedRoot !== root || approval.planSha256 !== planSha256 || !ID.test(approval.approvedBy) || !Number.isFinite(approvedAt) || !Number.isFinite(expiresAt) || approvedAt > Date.now() || expiresAt <= Date.now() || expiresAt <= approvedAt || expiresAt - approvedAt > RUNNER_LIMITS.maxApprovalLifetimeMs) fail('ENTROROVE_APPROVAL_INVALID', 'approval is expired or does not match the frozen plan'); return { ...file, approval };
46
+ }
47
+ async function signer(root, input, policy) {
48
+ if (input.signerConfigPath === null) return null; const file = await externalFile(root, input.signerConfigPath, 'signerConfigPath'); let config; try { config = JSON.parse(file.bytes.toString('utf8')); } catch (cause) { fail('ENTROROVE_SIGNER_INVALID', `signer JSON is invalid: ${cause.message}`); }
49
+ exactObject(config, ['schemaVersion', 'privateKeyPath', 'keyId', 'receiptTtlMs'], 'runner signer'); if (config.schemaVersion !== SIGNER_SCHEMA || !SHA256.test(config.keyId)) fail('ENTROROVE_SIGNER_INVALID', 'signer config is invalid'); boundedInt(config.receiptTtlMs, RUNNER_LIMITS.maxReceiptLifetimeMs, 'receiptTtlMs'); const privateFile = await externalFile(root, config.privateKeyPath, 'privateKeyPath');
50
+ let privateKey; try { privateKey = createPrivateKey(privateFile.bytes); if (privateKey.asymmetricKeyType !== 'ed25519' || digest(createPublicKey(privateKey).export({ format: 'der', type: 'spki' })) !== config.keyId) fail('ENTROROVE_SIGNER_INVALID', 'signer public key fingerprint mismatch'); } finally { privateFile.bytes.fill(0); }
51
+ if (JSON.stringify([policy.args, policy.environment]).includes(privateFile.path) || JSON.stringify([policy.args, policy.environment]).includes(file.path)) fail('ENTROROVE_SIGNER_SCOPE', 'signer path may not be passed to child'); return { config, file, privateKey };
52
+ }
53
+
54
+ async function authority(input) {
55
+ if (process.platform === 'win32' || typeof process.getuid !== 'function') fail('ENTROROVE_POSIX_REQUIRED', 'approved plan runner requires POSIX');
56
+ exactObject(input, ['repositoryRoot', 'planPath', 'approvalPath', 'signerConfigPath'], 'runner input'); const root = await rootOf(input.repositoryRoot); const planFile = await projectFile(root, input.planPath); if (planFile.bytes.length > RUNNER_LIMITS.maxControlBytes) fail('ENTROROVE_PLAN_SIZE', 'plan exceeds control limit');
57
+ let plan; try { plan = validatePlan(JSON.parse(planFile.bytes.toString('utf8'))); } catch (cause) { throw cause; } const approval = await validateApproval(root, input, planFile.sha256); const signing = await signer(root, input, plan.policy); const executable = await regular(plan.policy.executable, RUNNER_LIMITS.maxExecutableBytes); if (executable.sha256 !== plan.policy.executableSha256) fail('ENTROROVE_EXECUTABLE_HASH', 'executable SHA-256 mismatch');
58
+ for (const file of plan.files) { const actual = await projectFile(root, file.path); if (actual.sha256 !== file.sha256) fail('ENTROROVE_PLAN_FILE_HASH', `${file.path} changed before execution`); }
59
+ return { root, plan, planFile, approval, signing };
60
+ }
61
+
62
+ function runCommand(authority) {
63
+ return new Promise((resolve, reject) => {
64
+ const started = performance.now(); const { policy } = authority.plan; const child = spawn(policy.executable, policy.args, { cwd: authority.root, env: { ...policy.environment }, shell: false, detached: true, stdio: ['ignore', 'pipe', 'pipe'] });
65
+ const stdout = []; const stderr = []; let bytes = 0; let dropped = 0; let timedOut = false; let terminating = false; let timer; const stdoutHash = createHash('sha256'); const stderrHash = createHash('sha256');
66
+ const terminate = () => { if (terminating) return; terminating = true; try { process.kill(-child.pid, 'SIGTERM'); } catch {} setTimeout(() => { try { process.kill(-child.pid, 'SIGKILL'); } catch {} }, 250).unref(); };
67
+ const consume = (target, hash, chunk) => { const data = Buffer.from(chunk); hash.update(data); const keep = Math.max(0, Math.min(data.length, policy.maxOutputBytes - bytes)); if (keep) target.push(data.subarray(0, keep)); bytes += keep; dropped += data.length - keep; if (dropped) terminate(); };
68
+ child.stdout.on('data', chunk => consume(stdout, stdoutHash, chunk)); child.stderr.on('data', chunk => consume(stderr, stderrHash, chunk)); child.once('error', reject); timer = setTimeout(() => { timedOut = true; terminate(); }, policy.timeoutMs);
69
+ child.once('close', (exitCode, signal) => { clearTimeout(timer); resolve({ schemaVersion: RUNNER_SCHEMA, status: exitCode === 0 && signal === null && !timedOut && !dropped ? 'succeeded' : 'failed', exitCode, signal, timedOut, outputLimitExceeded: dropped > 0, durationMs: Math.max(0, Math.round(performance.now() - started)), stdout: Buffer.concat(stdout).toString('utf8'), stderr: Buffer.concat(stderr).toString('utf8'), stdoutSha256: stdoutHash.digest('hex'), stderrSha256: stderrHash.digest('hex'), capturedBytes: bytes, droppedBytes: dropped }); });
70
+ });
71
+ }
72
+ function subject(authority, result) { return { schemaVersion: 'validator.validation-subject/1.0', artifactSha256: authority.plan.artifactSha256, memberId: authority.plan.memberId, chainId: authority.plan.chainId, executedAt: new Date().toISOString(), files: authority.plan.files, validationRunId: authority.plan.validationRunId, planId: authority.plan.planId, tests: authority.plan.tests, policy: { command: [authority.plan.policy.executable, ...authority.plan.policy.args].map(value => JSON.stringify(value)).join(' '), requiredExitCode: 0, executionPlanSha256: authority.planFile.sha256 }, goldenBaseline: authority.plan.goldenBaseline, contracts: authority.plan.contracts, execution: { exitCode: result.exitCode, signal: result.signal, timedOut: result.timedOut, outputLimitExceeded: result.outputLimitExceeded } }; }
73
+ function evidence(authority, result, sub) { const summary = result.timedOut ? 'Approved validation command exceeded its deadline' : result.outputLimitExceeded ? 'Approved validation command exceeded its output limit' : result.signal ? `Approved validation command ended on signal ${result.signal}` : `Approved validation command exited with code ${result.exitCode}`; const base = { schemaVersion: 'entrorove.test-evidence/2.0', evidenceId: `${authority.plan.validationRunId}:process`, kind: 'test', runner: authority.signing ? 'trusted-runner' : 'local', command: [authority.plan.policy.executable, ...authority.plan.policy.args].map(value => JSON.stringify(value)).join(' '), exitCode: result.exitCode, durationMs: result.durationMs, summary, artifactSha256: authority.plan.artifactSha256, subject: sub, subjectDigest: digest(canonical(sub)) };
74
+ if (!authority.signing || !Number.isInteger(result.exitCode)) return base; const privateKey = authority.signing.privateKey; const receiptCore = { schemaVersion: RECEIPT_SCHEMA, keyId: authority.signing.config.keyId, subjectDigest: base.subjectDigest, nonce: randomUUID(), issuedAt: new Date().toISOString(), expiresAt: new Date(Date.now() + authority.signing.config.receiptTtlMs).toISOString(), result: { runner: 'trusted-runner', passed: result.status === 'succeeded', exitCode: result.exitCode, durationMs: result.durationMs, summary } }; const receipt = { ...receiptCore, signature: sign(null, Buffer.from(canonical(receiptCore)), privateKey).toString('base64url') }; return { ...base, receipt };
75
+ }
76
+
77
+ export async function runApprovedPlan(input) { const owner = await authority(input); const result = await runCommand(owner); try { const after = await authority(input); if (after.planFile.sha256 !== owner.planFile.sha256) fail('ENTROROVE_PLAN_CHANGED', 'plan changed during execution'); } catch (cause) { const execution = { schemaVersion: RUNNER_SCHEMA, status: 'failed', error: cause.message, process: result, evidence: [] }; const error = new Error(cause.message); error.code = cause.code; error.execution = execution; throw error; }
78
+ const sub = subject(owner, result); const item = evidence(owner, result, sub); const status = !owner.signing && result.status === 'succeeded' ? 'incomplete' : result.status; return { schemaVersion: RUNNER_SCHEMA, status, planSha256: owner.planFile.sha256, approvalSha256: owner.approval.sha256, approvedBy: owner.approval.approval.approvedBy, subject: sub, process: result, evidence: [item], receipt: item.receipt ?? null, ...(owner.signing ? { receiptPublicKey: createPublicKey(owner.signing.privateKey).export({ format: 'der', type: 'spki' }).toString('base64') } : {}), signingBoundary: owner.signing ? 'configured-signature-only' : 'local-evidence-only' };
79
+ }
80
+
81
+ export const RUNNER_CAPABILITIES = Object.freeze({ schemaVersion: RUNNER_SCHEMA, operations: ['capabilities', 'run-approved-plan'], operationSchemas: { capabilities: { type: 'object', additionalProperties: false, required: [] }, 'run-approved-plan': { type: 'object', additionalProperties: false, required: ['repositoryRoot', 'planPath', 'approvalPath', 'signerConfigPath'] } }, limits: RUNNER_LIMITS, boundary: 'POSIX subprocess runner; external approval is required; local evidence remains incomplete without a configured signer.' });
@@ -0,0 +1,37 @@
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
+ const ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
8
+ function fail(code, message) { const error = new Error(message); error.code = code; throw error; }
9
+ function digest(value) { return createHash('sha256').update(value).digest('hex'); }
10
+ 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); }
11
+ function rootHome(home = os.homedir()) { if (typeof home !== 'string' || !path.isAbsolute(home)) fail('ENTROROVE_HOME_INVALID', 'home must be absolute'); return path.join(home, '.entrorove'); }
12
+ 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); }
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, flowId) { return path.join(rootHome(home), 'budgets', digest(root).slice(0, 32), flowId, 'budget.json'); }
15
+ async function load(input) { const root = await repo(input.repositoryRoot); if (typeof input.flowId !== 'string' || !ID.test(input.flowId)) fail('ENTROROVE_ID_INVALID', 'flowId must be a safe identifier'); const file = statePath(input.home, root, input.flowId); await privateDir(path.dirname(file)); const stat = await fs.lstat(file); if (!stat.isFile() || stat.isSymbolicLink()) fail('ENTROROVE_STATE_INVALID', 'budget state must be a regular file'); const state = JSON.parse(await fs.readFile(file, 'utf8')); if (state.flowId !== input.flowId || state.repositoryDigest !== digest(root).slice(0, 32)) fail('ENTROROVE_BUDGET_SCOPE', 'budget state does not match repository and flow'); return { root, file, state }; }
16
+ 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); }
17
+ export async function consumeBudgetRenewal({ file, state, now = Date.now() }) {
18
+ if (!state.enforced || !state.autoRenew || state.autoRenew.status !== 'authorized') return state;
19
+ const elapsed = state.elapsedMs + now - Date.parse(state.startedAt); const used = state.autoRenew.renewalsUsed ?? 0;
20
+ if (elapsed < state.maxDurationMs || used >= state.autoRenew.policy.maxRenewals) return state;
21
+ const updated = { ...state, startedAt: new Date(now).toISOString(), elapsedMs: 0, autoRenew: { ...state.autoRenew, renewalsUsed: used + 1, lastRenewedAt: new Date(now).toISOString() } };
22
+ await save(file, updated); return updated;
23
+ }
24
+ function scope(input) { if (!input.scope || typeof input.scope.goal !== 'string' || !Array.isArray(input.scope.allowedPaths) || !input.scope.allowedPaths.length) fail('ENTROROVE_RENEWAL_SCOPE', 'renewal scope must include goal and allowedPaths'); return { goal: input.scope.goal, allowedPaths: [...new Set(input.scope.allowedPaths)].sort() }; }
25
+ function policy(input) { if (!input.policy || !Number.isSafeInteger(input.policy.intervalMs) || input.policy.intervalMs < 1 || !Number.isSafeInteger(input.policy.maxRenewals) || input.policy.maxRenewals < 1 || input.policy.maxRenewals > 100) fail('ENTROROVE_RENEWAL_POLICY', 'renewal policy must have bounded intervalMs and maxRenewals'); return { intervalMs: input.policy.intervalMs, maxRenewals: input.policy.maxRenewals }; }
26
+ async function renewalRequestUnlocked(input) { const current = await load(input); const requested = scope(input); const renewalPolicy = policy(input); if (!input.requestId || typeof input.requestId !== 'string' || !ID.test(input.requestId)) fail('ENTROROVE_REQUEST_ID', 'requestId must be a safe identifier'); if (current.state.autoRenew?.status === 'authorized') fail('ENTROROVE_RENEWAL_EXISTS', 'this budget already has an immutable renewal authorization'); const record = { requestId: input.requestId, chainId: input.flowId, scope: requested, scopeDigest: digest(canonical(requested)), policy: renewalPolicy, status: 'pending', createdAt: new Date().toISOString() }; const state = { ...current.state, autoRenew: record }; await save(current.file, state); return { schemaVersion: 'entrorove.local-execution/1.0', status: 'waiting', interaction: { schemaVersion: 'confirm.interaction/1.0', interactionId: `renew-${randomUUID()}`, risk: 'medium', question: `Allow automatic read-time renewal for chain ${input.flowId}?`, options: [{ id: 'approve', label: 'Approve' }, { id: 'reject', label: 'Reject' }], default: null, expiresAt: new Date(Date.now() + 300_000).toISOString(), callback: { operation: 'budget-auto-renew', requestId: input.requestId } }, requestId: input.requestId, scope: requested, policy: renewalPolicy }; }
27
+ export async function budgetAutoRenewRequest(input) { const current = await load(input); return withStateLock(current.file, () => renewalRequestUnlocked(input)); }
28
+ async function renewalUnlocked(input) { const current = await load(input); const pending = current.state.autoRenew; const requested = scope(input); const renewalPolicy = policy(input); const confirmation = input.confirmation; if (!pending || pending.requestId !== input.requestId || pending.status !== 'pending' || confirmation?.answer !== 'approve' || confirmation.requestId !== input.requestId || pending.scopeDigest !== digest(canonical(requested)) || canonical(pending.policy) !== canonical(renewalPolicy)) return { schemaVersion: 'entrorove.local-execution/1.0', status: 'blocked', allowed: false, code: 'ENTROROVE_CONFIRMATION_BINDING' }; const state = { ...current.state, autoRenew: { ...pending, status: 'authorized', approvedAt: new Date().toISOString(), renewalsUsed: 0 } }; await save(current.file, state); return { schemaVersion: 'entrorove.local-execution/1.0', status: 'verified', allowed: true, chainId: input.flowId, requestId: input.requestId, scope: requested, policy: renewalPolicy, renewalsUsed: 0 }; }
29
+ export async function budgetAutoRenew(input) { const current = await load(input); return withStateLock(current.file, () => renewalUnlocked(input)); }
30
+ async function renewalStopUnlocked(input) { const current = await load(input); const reason = input.reason; if (!['revoked', 'completed'].includes(reason)) fail('ENTROROVE_RENEWAL_REASON', 'reason must be revoked or completed'); if (!current.state.autoRenew || current.state.autoRenew.status !== 'authorized') return { schemaVersion: 'entrorove.local-execution/1.0', status: 'verified', stopped: false, reason }; const state = { ...current.state, autoRenew: { ...current.state.autoRenew, status: 'stopped', stoppedAt: new Date().toISOString(), stopReason: reason } }; await save(current.file, state); return { schemaVersion: 'entrorove.local-execution/1.0', status: 'verified', stopped: true, reason, renewalsUsed: state.autoRenew.renewalsUsed ?? 0 }; }
31
+ export async function budgetAutoRenewStop(input) { const current = await load(input); return withStateLock(current.file, () => renewalStopUnlocked(input)); }
32
+ export const LOCAL_RENEWAL_OPERATIONS = Object.freeze({ 'budget-auto-renew-request': budgetAutoRenewRequest, 'budget-auto-renew': budgetAutoRenew, 'budget-auto-renew-stop': budgetAutoRenewStop });
33
+ export const LOCAL_RENEWAL_SCHEMAS = Object.freeze({
34
+ 'budget-auto-renew-request': { type: 'object', additionalProperties: false, required: ['repositoryRoot', 'flowId', 'requestId', 'scope', 'policy'] },
35
+ 'budget-auto-renew': { type: 'object', additionalProperties: false, required: ['repositoryRoot', 'flowId', 'requestId', 'scope', 'policy', 'confirmation'] },
36
+ 'budget-auto-renew-stop': { type: 'object', additionalProperties: false, required: ['repositoryRoot', 'flowId', 'reason'] },
37
+ });
package/local-cli.mjs ADDED
@@ -0,0 +1,58 @@
1
+ import {
2
+ LOCAL_CAPABILITIES, budgetContext, budgetExtend, budgetInit, budgetRead, budgetStatus,
3
+ completePeer, finalGate, guardedWrite, issueMutationPass, mergeVerified, mutateGate,
4
+ peerCoordinate, rollback, signEvidence, snapshotPlan, snapshotVerify, spawnBind,
5
+ verifyCoordinationLease, verifyEvidence, verifyMutationPass,
6
+ evaluateFormula, evaluateFormulaGraph, formulaFinalGate, formulaFinalGateFile, runApprovedPlan,
7
+ LOCAL_LEDGER_OPERATIONS,
8
+ LOCAL_RENEWAL_OPERATIONS, LOCAL_PROBE_OPERATIONS,
9
+ LOCAL_TASK_ROUTING_OPERATIONS,
10
+ } from './local-execution.mjs';
11
+
12
+ async function stdinJson() {
13
+ let raw = '';
14
+ for await (const chunk of process.stdin) raw += chunk;
15
+ if (!raw.trim()) return {};
16
+ const value = JSON.parse(raw);
17
+ if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('local input must be a JSON object');
18
+ return value;
19
+ }
20
+
21
+ const operations = {
22
+ 'snapshot-plan': snapshotPlan, 'snapshot-verify': snapshotVerify, 'mutate-gate': mutateGate,
23
+ 'gate-issue': issueMutationPass, 'gate-verify': verifyMutationPass, 'guarded-write': guardedWrite,
24
+ 'budget-init': budgetInit, 'budget-context': budgetContext, 'budget-read': budgetRead, 'budget-status': budgetStatus,
25
+ 'budget-extend': budgetExtend, 'peer-coordinate': peerCoordinate, coordinate: peerCoordinate,
26
+ 'spawn-bind': spawnBind, 'peer-complete': completePeer, complete: completePeer,
27
+ 'lease-verify': verifyCoordinationLease,
28
+ 'evidence-sign': signEvidence, 'evidence-verify': verifyEvidence, 'merge-verified': mergeVerified,
29
+ rollback, 'final-gate': finalGate,
30
+ 'formula-evaluate': input => ({ schemaVersion: 'entrorove.local-execution/1.0', status: 'verified', value: evaluateFormula(input.formula, input.values ?? {}).toString() }),
31
+ 'formula-graph': input => ({ schemaVersion: 'entrorove.local-execution/1.0', status: 'verified', values: evaluateFormulaGraph(input.formulas, input.inputs ?? {}) }),
32
+ 'formula-final-gate': input => formulaFinalGate(input.engine), 'calculation-final-gate': formulaFinalGateFile,
33
+ 'run-approved-plan': runApprovedPlan,
34
+ ...LOCAL_LEDGER_OPERATIONS,
35
+ ...LOCAL_RENEWAL_OPERATIONS, ...LOCAL_PROBE_OPERATIONS,
36
+ ...LOCAL_TASK_ROUTING_OPERATIONS,
37
+ };
38
+
39
+ export const LOCAL_OPERATIONS = Object.freeze(operations);
40
+
41
+ export async function runLocal(args = []) {
42
+ const operation = args[0];
43
+ if (operation === 'capabilities' || operation === 'help') { process.stdout.write(`${JSON.stringify(LOCAL_CAPABILITIES)}\n`); return; }
44
+ const handler = operations[operation];
45
+ if (!handler) throw new Error(`Usage: entrorove local <${['capabilities', ...Object.keys(operations)].join('|')}> [repositoryRoot]`);
46
+ const input = await stdinJson();
47
+ if (args[1] && input.repositoryRoot === undefined) input.repositoryRoot = args[1];
48
+ const output = await handler(input);
49
+ process.stdout.write(`${JSON.stringify(output)}\n`);
50
+ if (output?.status === 'blocked' || output?.status === 'failed' || output?.status === 'incomplete') process.exitCode = 2;
51
+ }
52
+
53
+ export async function runLocalInput(operation, input) {
54
+ if (operation === 'capabilities' || operation === 'help') return LOCAL_CAPABILITIES;
55
+ const handler = operations[operation];
56
+ if (!handler) throw new Error(`Unsupported local operation: ${operation}`);
57
+ return handler(input);
58
+ }