@entrorove/skill 0.1.31 → 0.1.33

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/CAPABILITY-MAP.md CHANGED
@@ -36,6 +36,8 @@ Entrorove 只治理通过技能入口执行的动作:路径、通行证、快
36
36
 
37
37
  ## 本地基线(当前 `LOCAL_OPERATION_SCHEMAS` 100 项)
38
38
 
39
+ `scope-connect <absolute repositoryRoot>` 是 CLI 编排入口,不改变这 100 项本地操作。它串联远端 `scope-runner-prepare/register/challenge/attest` 与已有真实快照、签名检查,为原账号与任务提交范围证据;不授予执行证据签名或写入权限。全部远端操作以实时 discovery 为准。
40
+
39
41
  | 分区 | 精确操作名 | 证据位置 |
40
42
  | --- | --- | --- |
41
43
  | 核心 discovery、快照、门控、预算、协调、证据、链、任务路由(54) | `capabilities`, `help`, `snapshot-plan`, `snapshot-verify`, `mutate-gate`, `gate-issue`, `mutation-snapshot-attest`, `gate-verify`, `guarded-write`, `budget-init`, `budget-handoff`, `budget-context`, `budget-read`, `budget-status`, `budget-extend`, `budget-transition`, `peer-coordinate`, `coordinate`, `spawn-bind`, `peer-complete`, `complete`, `lease-verify`, `evidence-sign`, `evidence-verify`, `rollback`, `final-gate`, `formula-evaluate`, `formula-graph`, `formula-final-gate`, `calculation-final-gate`, `run-approved-plan`, `branch-create`, `branch-create-virtual`, `branch-list`, `branch-switch`, `diff-report`, `preflight`, `ledger-query`, `chain-ledger-query`, `chain-init`, `chain-ledger-init`, `chain-resume`, `chain-status`, `chain-ledger-status`, `chain-answer`, `budget-auto-renew-request`, `budget-auto-renew`, `budget-auto-renew-stop`, `probe`, `reassess`, `task-describe`, `message-route`, `message-accept`, `task-resume` | `packages/entrorove-skill/local-contracts.mjs:14-114` |
package/CHANGELOG.md CHANGED
@@ -1,5 +1,15 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.33 — 2026-10-12
4
+
5
+ - 补齐账号与任务隔离的本地签名器和真实快照接入,解析实际别名及类型声明;动态依赖仅进入受限规划,保留独立写入核验。
6
+ - Connected account-bound scope signers and real snapshots, resolved actual aliases and declarations, and retained restricted planning for unresolved dynamic imports with separate mutation verification.
7
+
8
+ ## 0.1.32 — 2026-10-11
9
+
10
+ - 增加云端沙箱状态与用量只读查询,并支持按任务直接读取真实状态。
11
+ - Added read-only cloud sandbox status and direct reads of authoritative task state and usage.
12
+
3
13
  ## 0.1.31 — 2026-10-11
4
14
 
5
15
  - 修复云端沙箱任务响应处理并保留可继续的任务上下文。
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.31.
8
+ Version: 0.1.33.
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
 
@@ -25,11 +25,13 @@ On first use or after a material agreement revision, the CLI returns a Confirm P
25
25
 
26
26
  Send JSON on standard input to `npx @entrorove/skill invoke`. Before every execution call, discover `operation: "capabilities"` with `{}` and validate input against the returned operation schema. The CLI performs remote discovery automatically; local adapters discover the installed contract before execution. Discovery failure stops execution. 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.
27
27
 
28
- `start` creates a normal durable skill flow. It does not create a cloud-sandbox task or contribute to cloud-sandbox usage. To create a real cloud-sandbox task, call `operation: "cloud-task"` with `input.goal`; the CLI returns a Confirm Protocol choice interaction before any cloud request. Continue the same request with `sandboxChoice: "use"` or `sandboxChoice: "local"`. `use` calls the authenticated `/api/cloud-task` endpoint and returns the real `taskId`; `local` skips only this task. A `needs-input`, `blocked` or `failed` result is not a successful dispatch and must be shown to the user.
28
+ `start` creates a normal durable skill flow. It does not create a cloud-sandbox task or contribute to cloud-sandbox usage. To create a real cloud-sandbox task, call `operation: "cloud-task"` with `input.goal`; the CLI returns a Confirm Protocol choice interaction before any cloud request. Continue the same request with `sandboxChoice: "use"` or `sandboxChoice: "local"`. `use` calls the authenticated `/api/cloud-task` endpoint and returns the real `taskId`; `local` skips only this task. The server is authoritative for account entitlement and the account-wide sandbox preference: a disabled or unavailable account returns `blocked` and creates no task. A `needs-input`, `blocked` or `failed` result is not a successful dispatch and must be shown to the user.
29
+
30
+ After dispatch, query the real task without resending it with the CLI-only `operation: "cloud-status"` and `input.taskId`, or `npx @entrorove/skill cloud-status` with the same JSON. This special CLI route reads the authoritative task list and usage endpoints; it is not part of the `/api/skill/invoke` capability map. The read-only result includes the current task status, public title/summary, and recorded model usage when available. A missing task is reported explicitly; it is never treated as completed. This is the skill-side status and usage check for cloud tasks; the web dock may additionally subscribe to its event stream.
29
31
 
30
32
  ### Per-task cloud sandbox choice
31
33
 
32
- When the host exposes an enabled cloud-sandbox preference and a new task is about to dispatch a cloud model, ask once whether this task should use the cloud sandbox. This is a low-risk routing preference, separate from agreement acceptance and the medium/high-risk confirmations below. Offer `use for this task` and `continue locally`; if the user gives no answer, use the cloud sandbox by the host's stated timeout default. An explicit opt-out applies only to the current task and must not change the account-wide sandbox preference. Do not ask again while continuing or resuming the same task. If the account-wide preference is disabled or unavailable, do not dispatch a cloud task and do not show a misleading per-task prompt; preserve the normal local workflow and report the prerequisite clearly. This choice never bypasses the normal task schema, mutation authority, evidence checks, or risk confirmation.
34
+ When the host exposes an enabled cloud-sandbox preference and a new task is about to dispatch a cloud model, ask once whether this task should use the cloud sandbox. This is a low-risk routing preference, separate from agreement acceptance and the medium/high-risk confirmations below. Offer `use for this task` and `continue locally`; if the user gives no answer, continue locally. Cloud submission requires an explicit choice or the user’s existing authorization for this task; silence never authorizes cloud consumption. An explicit opt-out applies only to the current task and must not change the account-wide sandbox preference. Do not ask again while continuing or resuming the same task. If the host cannot preflight the account preference, show the choice only as a routing decision and let the server's authoritative `/api/cloud-task` check return a clear `blocked` prerequisite; never claim that a task started after a blocked response. This choice never bypasses the normal task schema, mutation authority, evidence checks, or risk confirmation.
33
35
 
34
36
  ```json
35
37
  {"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}}}
@@ -43,13 +45,19 @@ For a returned `flowId`, continue with `{"operation":"continue","input":{"flowId
43
45
 
44
46
  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.
45
47
 
48
+ When intake returns `workflow-gate`, connect the current repository before invoking that gate: run `npx @entrorove/skill scope-connect <absolute repositoryRoot>` with JSON `{"flowId":"<original flowId>","paths":["<existing source file>"]}` on stdin. Use the concrete existing source files in the task's scope; new files belong in the later plan's declared artifacts. The adapter enrolls an OS-private Ed25519 scope signer for this accepted account and task, answers a server challenge, copies and verifies real source files, and submits the signed facts. It sends hashes and scope metadata rather than source contents or the private key. Enrollment grants scope inspection only; execution evidence and write passes retain their separate authority checks. An operator-configured signer cannot be replaced. After a successful connection, invoke the original `workflow-gate` with the same paths. Refresh an expired snapshot by reconnecting the same flow and path set. Show an explicit blocked result; query the original request ID after an unknown transport outcome before considering another attempt.
49
+
50
+ Scope inspection resolves aliases from actual `tsconfig.json` or `jsconfig.json`, including relative `extends`. For plugin source compiled by a host project whose config is outside the source's ancestors, include `importContexts: [{"sourcePrefix":"<plugin source directory>","configPath":"<actual host tsconfig.json>"}]` in `scope-connect` input. Inspect the real build configuration first; never invent an alias or mark an unresolved import as external. These context paths remain inside the repository, their hashes enter the signed facts, and the local task binding freezes them.
51
+
52
+ Computed runtime imports remain explicitly unresolved with their actual source paths and hashes. A member's scope-only signer may submit these observations to enter restricted planning; the cloud returns `planningOnly: true` and `requires-complete-import-graph-before-mutation`. Do not treat this as full dependency verification, execution permission, or business completion. Resolve the dynamic dependency contract and obtain complete verified facts before mutation; never set `complete` manually or replace the original signer or paths to bypass the restriction.
53
+
46
54
  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.
47
55
 
48
56
  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.
49
57
 
50
- 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.
58
+ 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, operation groups and `requiresPass` metadata. `local capabilities <group>` filters that same 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.
51
59
 
52
- Local CLI failures use the machine-readable `entrorove.local-error/1.0` envelope `{schemaVersion,status,code,message}` on stderr. `status` is `failed` for invalid input or computation errors and `blocked` for missing mutation, lease, snapshot or handoff authority. Formula `add` and `mul` require at least one argument; `div`, `safeDivide` and `percentOf` reject a zero denominator with `DIV_ZERO`. The formula engine declares `round` as half-away-from-zero with two decimal places by default; an engine may declare another `decimalPlaces` value from 0 through 28 in its `roundingPolicy`.
60
+ Local CLI failures use the machine-readable `entrorove.local-error/1.0` envelope `{schemaVersion,status,code,disposition,message}` on stderr. `status` is `failed` for invalid input or computation errors and `blocked` for missing mutation, lease, snapshot or handoff authority. Formula `add` and `mul` require at least one argument; `div`, `safeDivide` and `percentOf` reject a zero denominator with `DIV_ZERO`. The formula engine declares `round` as half-away-from-zero with two decimal places by default; an engine may declare another `decimalPlaces` value from 0 through 28 in its `roundingPolicy`.
53
61
 
54
62
  When the user gives an actual evaluation after using the skill, offer to publish that feedback. Only after they explicitly agree to public display under the current notice, send their unchanged words, IDE name and 1–5 rating as JSON on standard input to `npx @entrorove/skill review`, including `invocationRequestId` from their actual successful call, `publicConsent: true` and `noticeVersion: "2026-10-07.1"`. The same account must own that successful call within the last 30 days; a recorded usage event, failed call or unknown result does not qualify. Three public reviews per account per day share one server quota across API replicas. Public output exposes only IDE, rating, original text and time, never account or invocation identity. Never invent, rewrite or infer a review from task completion. The website reads accepted reviews directly from entrorove's independent database.
55
63
 
@@ -58,3 +66,5 @@ Feedback command example (replace the request ID with the original successful in
58
66
  ```json
59
67
  {"invocationRequestId":"<original successful request ID>","ide":"Codex","rating":5,"comment":"<unchanged user words>","publicConsent":true,"noticeVersion":"2026-10-07.1"}
60
68
  ```
69
+
70
+ The fixed Git byte-metering adapter ships hash-pinned precompiled libraries for Darwin and glibc Linux on arm64/x64. It extracts the selected library on first use; it never invokes a C compiler on the user machine. Darwin requires Apple’s trusted Git (Xcode or Command Line Tools), xcrun and codesign; Linux requires the trusted `/usr/bin/git` and glibc. Missing or unsupported tools return `ENTROROVE_GIT_READER_UNSUPPORTED` explicitly and do not fall back to unmetered source reads.
package/cli.mjs CHANGED
@@ -9,10 +9,11 @@ import { compareVersions } from './version.mjs';
9
9
  import { credentialPath, readCredential, writeCredential } from './local-credential.mjs';
10
10
  import { runLocal } from './local-cli.mjs';
11
11
  import { runTaskMessageCli } from './task-message-cli.mjs';
12
+ import { runScopeConnect } from './scope-runner-client.mjs';
12
13
  import { apiUrl, buildInvocation, invokeRemote, queryFlows, queryStatus } from './remote-client.mjs';
13
14
  const packageDir = path.dirname(fileURLToPath(import.meta.url));
14
15
  import { buildReview, submitSkillFeedback, feedbackNoticeVersion } from './review-client.mjs';
15
- import { runCloudTask as dispatchCloudTask } from './cloud-task-client.mjs';
16
+ import { queryCloudTask, runCloudTask as dispatchCloudTask } from './cloud-task-client.mjs';
16
17
  const markerName = '.entrorove-managed.json';
17
18
  const installFiles = ['SKILL.md', 'CAPABILITY-MAP.md', 'AGREEMENT.md', 'manifest.json'];
18
19
  const markerSchema = 'entrorove.managed-install/1.0';
@@ -389,10 +390,20 @@ export async function runCloudTask(input, dependencies = remoteDependencies()) {
389
390
  return result;
390
391
  }
391
392
 
393
+ export async function runCloudStatus(input, dependencies = remoteDependencies()) {
394
+ const result = await queryCloudTask(input?.taskId, dependencies);
395
+ process.stdout.write(`${JSON.stringify(result)}\n`);
396
+ if (['blocked', 'failed', 'missing'].includes(result.status)) process.exitCode = 2;
397
+ return result;
398
+ }
399
+
392
400
  async function invoke(data) {
393
401
  if (data?.operation === 'cloud-task') {
394
402
  return runCloudTask(data.input, remoteDependencies());
395
403
  }
404
+ if (data?.operation === 'cloud-status') {
405
+ return runCloudStatus(data.input, remoteDependencies());
406
+ }
396
407
  return invokeRemote(data, remoteDependencies());
397
408
  }
398
409
 
@@ -419,10 +430,16 @@ async function main() {
419
430
  process.stdout.write(JSON.stringify({ accepted, version: agreementVersion }) + '\n');
420
431
  } else if (command === 'invoke' && args.length === 0) {
421
432
  await invoke(await stdinJson());
433
+ } else if (command === 'scope-connect' && args.length === 1) {
434
+ const result = await runScopeConnect(args[0], await stdinJson(), remoteDependencies());
435
+ process.stdout.write(`${JSON.stringify(result)}\n`);
436
+ if (result.status !== 'succeeded') process.exitCode = 2;
422
437
  } else if (command === 'host-message') {
423
438
  await runTaskMessageCli(args, await stdinJson(), remoteDependencies());
424
439
  } else if (command === 'cloud-task' && args.length === 0) {
425
440
  await runCloudTask(await stdinJson());
441
+ } else if (command === 'cloud-status' && args.length === 0) {
442
+ await runCloudStatus(await stdinJson());
426
443
  } else if (command === 'review' && args.length === 0) {
427
444
  await submitReview(await stdinJson());
428
445
  } else if (command === 'status' && args.length === 1) {
@@ -444,7 +461,7 @@ async function main() {
444
461
  } else if (command === 'local') {
445
462
  await runLocal(args);
446
463
  } else {
447
- throw new Error('Usage: entrorove install | configure | agreement | invoke | host-message [adapterModulePath] | cloud-task | status <requestId> | flows | review | local (JSON on stdin)');
464
+ throw new Error('Usage: entrorove install | configure | agreement | invoke | scope-connect <repositoryRoot> | host-message [adapterModulePath] | cloud-task | cloud-status | status <requestId> | flows | review | local (JSON on stdin)');
448
465
  }
449
466
  }
450
467
  if (process.argv[1] && realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url))) {
@@ -3,6 +3,7 @@ import { appendInvocation } from './local-invocations.mjs';
3
3
  import { parseTransportResponse, requestTransport, trustedOrigin } from './remote-transport.mjs';
4
4
 
5
5
  const CLOUD_TASK_SCHEMA = 'entrorove.cloud-task/1.0';
6
+ const CLOUD_STATUS_SCHEMA = 'entrorove.cloud-status/1.0';
6
7
  const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
7
8
 
8
9
  function canonical(value) {
@@ -38,6 +39,15 @@ function validateDependencies(dependencies) {
38
39
  }
39
40
  }
40
41
 
42
+ function validateTaskId(taskId) {
43
+ if (typeof taskId !== 'string' || !UUID.test(taskId)) throw new Error('cloud-status taskId must be a UUID');
44
+ return taskId;
45
+ }
46
+
47
+ function authenticatedHeaders(token, headers = {}) {
48
+ return { ...headers, authorization: `Bearer ${token}` };
49
+ }
50
+
41
51
  export function sandboxChoiceInteraction(id) {
42
52
  return {
43
53
  schemaVersion: 'confirm.interaction/1.0', requestId: id, type: 'choice',
@@ -46,7 +56,7 @@ export function sandboxChoiceInteraction(id) {
46
56
  { id: 'use', label: '本任务使用云端沙箱', hint: '创建真实云端任务并持续记录进度' },
47
57
  { id: 'local', label: '继续本地执行', hint: '仅当前任务退出云端沙箱' },
48
58
  ],
49
- default: 'use', timeout: 8_000, timeoutAction: 'use', risk: 'low',
59
+ default: 'local', timeout: 8_000, timeoutAction: 'local', risk: 'low',
50
60
  riskDescription: '这是当前任务的执行位置选择,不改变账号级云端沙箱开关。',
51
61
  rememberable: false, memoryKey: '',
52
62
  callback: { operation: 'cloud-task', payload: { sandboxChoice: 'use' } },
@@ -83,7 +93,7 @@ export async function runCloudTask(input, dependencies) {
83
93
  try {
84
94
  response = await requestTransport(origin, '/api/cloud-task', {
85
95
  method: 'POST', timeoutMs: 30_000,
86
- headers: { 'content-type': 'application/json', authorization: `Bearer ${token}` },
96
+ headers: authenticatedHeaders(token, { 'content-type': 'application/json' }),
87
97
  body: JSON.stringify(task),
88
98
  });
89
99
  } catch (cause) {
@@ -117,3 +127,53 @@ export async function runCloudTask(input, dependencies) {
117
127
  executionIsolation: typeof result.executionIsolation === 'string' ? result.executionIsolation : 'worker' });
118
128
  }
119
129
 
130
+ function publicTask(task) {
131
+ if (!task || typeof task !== 'object' || Array.isArray(task)) return null;
132
+ const allowed = ['id', 'status', 'displayTitle', 'titleStatus', 'titleError', 'summary', 'ideTaskId',
133
+ 'createdAt', 'updatedAt', 'completedAt'];
134
+ return Object.fromEntries(allowed.filter(key => task[key] !== undefined).map(key => [key, task[key]]));
135
+ }
136
+
137
+ function publicUsage(value) {
138
+ if (!Array.isArray(value)) return [];
139
+ const allowed = ['usageId', 'requestId', 'operation', 'attempt', 'meterKind', 'state', 'tokenStatus',
140
+ 'inputTokens', 'outputTokens', 'durationMs', 'durationBasis', 'providerId', 'modelId'];
141
+ return value.filter(item => item && typeof item === 'object' && !Array.isArray(item))
142
+ .map(item => Object.fromEntries(allowed.filter(key => item[key] !== undefined).map(key => [key, item[key]])));
143
+ }
144
+
145
+ export async function queryCloudTask(taskId, dependencies) {
146
+ const id = validateTaskId(taskId);
147
+ validateDependencies(dependencies);
148
+ await dependencies.install();
149
+ if (!await dependencies.agreementAccepted(dependencies.home)) {
150
+ return { schemaVersion: CLOUD_STATUS_SCHEMA, status: 'blocked', taskId: id,
151
+ code: 'agreement-required', interaction: dependencies.agreementRequired(dependencies.home) };
152
+ }
153
+ const token = await dependencies.readCredential(dependencies.home);
154
+ const origin = trustedOrigin(dependencies.apiOrigin());
155
+ const metadata = { kind: 'remote', operation: 'cloud-status', requestId: requestId({ taskId: id }) };
156
+ const taskResponse = await requestTransport(origin, `/api/cloud-sandbox/tasks/${id}`, { headers: authenticatedHeaders(token) });
157
+ let taskBody;
158
+ try { taskBody = await responseBody(taskResponse); } catch { taskBody = null; }
159
+ if (!taskResponse.ok || !taskBody?.task || taskBody.task.id !== id) {
160
+ await appendInvocation({ ...metadata, event: 'rejected' }, dependencies.home);
161
+ return { schemaVersion: CLOUD_STATUS_SCHEMA, status: taskResponse.status === 403 ? 'blocked' : taskResponse.status === 404 ? 'missing' : 'failed', taskId: id,
162
+ code: typeof taskBody?.error === 'string' ? taskBody.error : taskResponse.status === 404 ? 'cloud-task-not-found' : 'cloud-status-unavailable', httpStatus: taskResponse.status };
163
+ }
164
+ const task = taskBody.task;
165
+ let usage = [];
166
+ let usageError;
167
+ const usageResponse = await requestTransport(origin, `/api/cloud-sandbox/tasks/${id}/usage`, {
168
+ headers: authenticatedHeaders(token),
169
+ });
170
+ try {
171
+ const usageBody = await responseBody(usageResponse);
172
+ if (usageResponse.ok) usage = publicUsage(usageBody?.usage);
173
+ else usageError = typeof usageBody?.error === 'string' ? usageBody.error : 'cloud-usage-unavailable';
174
+ } catch { usageError = 'cloud-usage-invalid-response'; }
175
+ await appendInvocation({ ...metadata, event: 'status' }, dependencies.home);
176
+ return { schemaVersion: CLOUD_STATUS_SCHEMA, status: 'observed', taskId: id,
177
+ taskStatus: task.status, task: publicTask(task), usage, ...(usageError ? { usageError } : {}) };
178
+ }
179
+
package/local-cli.mjs CHANGED
@@ -60,6 +60,17 @@ const operations = {
60
60
 
61
61
  export const LOCAL_OPERATIONS = Object.freeze(operations);
62
62
 
63
+ function capabilitiesFor(group) {
64
+ if (group === undefined) return LOCAL_CAPABILITIES;
65
+ if (typeof group !== 'string' || !LOCAL_CAPABILITIES.groups.includes(group)) {
66
+ throw Object.assign(new Error(`Unknown local capability group: ${String(group)}`), { code: 'ENTROROVE_CAPABILITY_GROUP_UNKNOWN' });
67
+ }
68
+ const operations = LOCAL_CAPABILITIES.operations.filter(operation => LOCAL_CAPABILITIES.operationMetadata[operation]?.group === group);
69
+ return { ...LOCAL_CAPABILITIES, groups: [group], operations,
70
+ operationSchemas: Object.fromEntries(operations.map(operation => [operation, LOCAL_CAPABILITIES.operationSchemas[operation]])),
71
+ operationMetadata: Object.fromEntries(operations.map(operation => [operation, LOCAL_CAPABILITIES.operationMetadata[operation]])) };
72
+ }
73
+
63
74
  async function checkedLocalInput(operation, input) {
64
75
  try { return validateLocalInput(operation, input); }
65
76
  catch (cause) {
@@ -73,7 +84,7 @@ export async function runLocal(args = [], dependencies) {
73
84
  const operation = args[0];
74
85
  if (operation === 'capabilities' || operation === 'help') {
75
86
  validateLocalInput(operation, process.stdin.isTTY ? {} : await stdinJson());
76
- process.stdout.write(`${JSON.stringify(LOCAL_CAPABILITIES)}\n`); return;
87
+ process.stdout.write(`${JSON.stringify(capabilitiesFor(args[1]))}\n`); return;
77
88
  }
78
89
  const handler = operations[operation];
79
90
  if (!handler) throw new Error(`Usage: entrorove local <${['capabilities', ...Object.keys(operations)].join('|')}> [repositoryRoot]`);
@@ -30,13 +30,19 @@ export const evidenceArray = array(evidence, 1);
30
30
  export const change = object({ gatePassPath: text, targetPath: relativePath, content: {}, expectedSha256: nullable(sha256), workerId: identifier, leaseId: identifier }, ['gatePassPath', 'targetPath', 'content']);
31
31
  export const decimal = { anyOf: [{ type: 'string', pattern: '^\\s*[+-]?(?:[0-9]+(?:\\.[0-9]*)?|\\.[0-9]+)\\s*$' }, { type: 'number' }] };
32
32
  export const formula = { $ref: '#/$defs/formula' };
33
+ const formulaBranch = (schema, title, description) => ({ ...schema, title, description });
33
34
  function expression(operators, minItems, maxItems) {
34
- return object({ op: enumeration(operators), args: { ...array(formula, minItems), ...(maxItems === undefined ? {} : { maxItems }) } }, ['op', 'args']);
35
+ const name = operators.join('|');
36
+ const arity = maxItems === undefined ? `${minItems} or more` : minItems === maxItems ? `exactly ${minItems}` : `${minItems} to ${maxItems}`;
37
+ return formulaBranch(object({ op: enumeration(operators), args: { ...array(formula, minItems), ...(maxItems === undefined ? {} : { maxItems }) } }, ['op', 'args']),
38
+ `${name} formula node`, `Operator ${name} requires ${arity} arguments.`);
35
39
  }
36
40
  export const formulaDefinitions = { formula: { oneOf: [
37
- object({ ref: text }, ['ref']), object({ lit: decimal }, ['lit']),
41
+ formulaBranch(object({ ref: text }, ['ref']), 'Reference formula node', 'Reads one named input field.'),
42
+ formulaBranch(object({ lit: decimal }, ['lit']), 'Literal formula node', 'Contains one decimal literal.'),
38
43
  expression(['add', 'mul'], 1), expression(['sub', 'div', 'safeDivide', 'percentOf'], 2, 2),
39
- expression(['round'], 1, 1), expression(['if'], 3, 3),
44
+ formulaBranch(expression(['round'], 1, 1), 'round formula node', 'Uses half-away-from-zero rounding at the declared two decimal places.'),
45
+ expression(['if'], 3, 3),
40
46
  ] } };
41
47
  export const decimalValues = { type: 'object', additionalProperties: decimal };
42
48
  export const formulaEntry = { type: 'object', properties: { key: text, expression: formula }, required: ['key', 'expression'] };
@@ -14,6 +14,7 @@ import { array, binding, change, confirmation, enumeration, evidence, evidenceAr
14
14
  const schemas = {};
15
15
  const base = { repositoryRoot: text, home: text };
16
16
  const flow = { ...base, flowId: identifier };
17
+ const fixedRoundingPolicy = object({ decimalPlaces: { const: 2 }, mode: { const: 'half-away-from-zero' } }, ['decimalPlaces', 'mode']);
17
18
  const step = { ...flow, stepId: identifier };
18
19
  function add(names, properties, required = [], alternatives) {
19
20
  const schema = object(properties, required);
@@ -54,8 +55,8 @@ add('rollback', { ...step, snapshotId: identifier }, ['repositoryRoot', 'flowId'
54
55
  const check = { anyOf: [{ type: 'boolean' }, { type: 'object', properties: { status: text }, required: ['status'] }] };
55
56
  add('final-gate', { ...base, ...binding, audit: check, test: check, operations: check, evidence: array(evidence), preflight: record }, ['audit', 'test', 'operations', 'evidence']);
56
57
  schemas['final-gate'] = { oneOf: [schemas['final-gate'], FINAL_GATE16_INPUT_SCHEMA, FINAL_GATE16_READ_INPUT_SCHEMA] };
57
- add('formula-evaluate', { formula, values: decimalValues }, ['formula']);
58
- add('formula-graph', { formulas: array(formulaEntry), inputs: decimalValues }, ['formulas']);
58
+ add('formula-evaluate', { formula, values: decimalValues, roundingPolicy: fixedRoundingPolicy }, ['formula']);
59
+ add('formula-graph', { formulas: array(formulaEntry), inputs: decimalValues, roundingPolicy: fixedRoundingPolicy }, ['formulas']);
59
60
  add('formula-final-gate', { engine: record, flowId: identifier, stepId: identifier }, ['engine']);
60
61
  add('calculation-final-gate', { ...step, enginePath: relativePath }, ['repositoryRoot', 'enginePath']);
61
62
  add('run-approved-plan', { repositoryRoot: text, planPath: relativePath, approvalPath: text, signerConfigPath: nullable(text) }, ['repositoryRoot', 'planPath', 'approvalPath', 'signerConfigPath']);
@@ -114,9 +115,37 @@ Object.assign(schemas, ARCHGUARD_LOCAL_OPERATION_SCHEMAS, CALCTOOL_LOCAL_OPERATI
114
115
  export const LOCAL_OPERATION_SCHEMAS = Object.freeze(schemas);
115
116
  const ajv = new Ajv({ strict: true, strictRequired: false, allErrors: true });
116
117
  const validators = new Map(Object.entries(schemas).map(([operation, schema]) => [operation, ajv.compile(schema)]));
118
+ const FORMULA_ARITY = { add: [1, Infinity], mul: [1, Infinity], sub: [2, 2], div: [2, 2], safeDivide: [2, 2], percentOf: [2, 2], round: [1, 1], if: [3, 3] };
119
+ function formulaArityError(node, path) {
120
+ if (!node || typeof node !== 'object' || Array.isArray(node) || typeof node.op !== 'string') return undefined;
121
+ const bounds = FORMULA_ARITY[node.op];
122
+ if (!bounds) return undefined;
123
+ const count = Array.isArray(node.args) ? node.args.length : -1;
124
+ if (count < bounds[0] || count > bounds[1]) {
125
+ const expected = bounds[0] === bounds[1] ? `exactly ${bounds[0]}` : `at least ${bounds[0]}`;
126
+ return `${path}.args: operator "${node.op}" requires ${expected} arguments; received ${count < 0 ? 'non-array args' : count}`;
127
+ }
128
+ for (const [index, child] of node.args.entries()) {
129
+ const failure = formulaArityError(child, `${path}.args[${index}]`);
130
+ if (failure) return failure;
131
+ }
132
+ return undefined;
133
+ }
134
+ function formulaInputHint(operation, input) {
135
+ if (operation === 'formula-evaluate') return formulaArityError(input?.formula, 'formula');
136
+ if (operation === 'formula-graph' && Array.isArray(input?.formulas)) {
137
+ for (const [index, entry] of input.formulas.entries()) {
138
+ const failure = formulaArityError(entry?.expression, `formulas[${index}].expression`);
139
+ if (failure) return failure;
140
+ }
141
+ }
142
+ return undefined;
143
+ }
117
144
  export function validateLocalInput(operation, input) {
118
145
  const validate = validators.get(operation);
119
146
  if (!validate) throw Object.assign(new Error(`Unsupported local operation: ${operation}`), { code: 'ENTROROVE_OPERATION_UNKNOWN' });
147
+ const hint = formulaInputHint(operation, input);
148
+ if (hint) throw Object.assign(new Error(`Invalid ${operation} input: ${hint}`), { code: 'ENTROROVE_INPUT_INVALID' });
120
149
  if (!validate(input)) {
121
150
  const details = validate.errors.map(error => `${error.instancePath || '/'} ${error.message}`).join('; ');
122
151
  throw Object.assign(new Error(`Invalid ${operation} input: ${details}`), { code: 'ENTROROVE_INPUT_INVALID' });
package/local-error.mjs CHANGED
@@ -1,4 +1,14 @@
1
1
  export const LOCAL_ERROR_SCHEMA = 'entrorove.local-error/1.0';
2
+ export const LOCAL_ERROR_CODE_CATALOG = Object.freeze({
3
+ ENTROROVE_INPUT_INVALID: Object.freeze({ disposition: 'terminal', stability: 'stable' }),
4
+ ENTROROVE_OPERATION_UNKNOWN: Object.freeze({ disposition: 'terminal', stability: 'stable' }),
5
+ ENTROROVE_LOCAL_FAILURE: Object.freeze({ disposition: 'terminal', stability: 'stable' }),
6
+ DIV_ZERO: Object.freeze({ disposition: 'terminal', stability: 'stable' }),
7
+ ENTROROVE_MUTATE_NOT_ACCEPTED: Object.freeze({ disposition: 'needs-human', stability: 'stable' }),
8
+ ENTROROVE_MUTATION_AUTHORITY_REQUIRED: Object.freeze({ disposition: 'needs-human', stability: 'stable' }),
9
+ ENTROROVE_BUDGET_LEASE_EXPIRED: Object.freeze({ disposition: 'retriable', stability: 'stable' }),
10
+ ENTROROVE_GIT_READER_UNSUPPORTED: Object.freeze({ disposition: 'terminal', stability: 'stable' }),
11
+ });
2
12
 
3
13
  const BLOCKED_CODES = new Set([
4
14
  'ENTROROVE_MUTATE_NOT_ACCEPTED', 'ENTROROVE_MUTATION_AUTHORITY_REQUIRED',
@@ -16,8 +26,16 @@ export function localErrorStatus(error) {
16
26
  ? 'blocked' : 'failed';
17
27
  }
18
28
 
29
+ export function localErrorDisposition(code) {
30
+ const catalogued = LOCAL_ERROR_CODE_CATALOG[code];
31
+ if (catalogued) return catalogued.disposition;
32
+ if (/_LEASE_EXPIRED$|_BUSY$|_RECONNECT/.test(code)) return 'retriable';
33
+ if (BLOCKED_CODES.has(code) || /(?:AUTHORITY_REQUIRED|NOT_ACCEPTED|REVOKED)$/.test(code)) return 'needs-human';
34
+ return 'terminal';
35
+ }
36
+
19
37
  export function localErrorEnvelope(error, fallbackCode = 'ENTROROVE_LOCAL_FAILURE') {
20
38
  const code = typeof error?.code === 'string' && error.code ? error.code : fallbackCode;
21
39
  const message = typeof error?.message === 'string' && error.message ? error.message : 'Local operation failed';
22
- return { schemaVersion: LOCAL_ERROR_SCHEMA, status: localErrorStatus({ ...error, code }), code, message };
40
+ return { schemaVersion: LOCAL_ERROR_SCHEMA, status: localErrorStatus({ ...error, code }), code, disposition: localErrorDisposition(code), message };
23
41
  }
@@ -275,7 +275,29 @@ export async function finalGate(input) {
275
275
  guarantee: 'Original engine, frozen role plans and actual three-party checks are required' };
276
276
  }
277
277
 
278
- export const LOCAL_CAPABILITIES = Object.freeze({ schemaVersion: LOCAL_SCHEMA, operations: Object.freeze(Object.keys(LOCAL_OPERATION_SCHEMAS)), operationSchemas: LOCAL_OPERATION_SCHEMAS, stateDirectory: '~/.entrorove', forbidGitBranch: true, forbidWorktree: true, evidenceSchema: EVIDENCE_SCHEMA, budgets: READ_BUDGETS, validatorRunner: RUNNER_CAPABILITIES, workflowRunner: WORKFLOW_LOCAL_CAPABILITIES });
278
+ const LOCAL_OPERATION_GROUPS = Object.freeze(['core', 'budget', 'scope', 'merge', 'chain', 'handoff', 'archguard', 'calctool', 'swarm', 'validator', 'formula', 'evidence']);
279
+ const PASS_REQUIRED_OPERATIONS = new Set(['mutation-snapshot-attest', 'mutate-gate', 'gate-issue', 'gate-verify', 'guarded-write',
280
+ 'merge-verified', 'rollback', 'archguard-contract-create', 'archguard-contract-update', 'archguard-checkpoint', 'archguard-scope-request',
281
+ 'budget-handoff', 'budget-transition', 'spawn-bind', 'peer-complete', 'complete']);
282
+ function operationGroup(operation) {
283
+ if (operation.startsWith('archguard-')) return 'archguard';
284
+ if (operation.startsWith('calctool-')) return 'calctool';
285
+ if (operation.includes('handoff')) return 'handoff';
286
+ if (operation.startsWith('budget-')) return 'budget';
287
+ if (operation.startsWith('chain-')) return 'chain';
288
+ if (operation.startsWith('formula-')) return 'formula';
289
+ if (operation.startsWith('merge-') || ['branch-create', 'branch-create-virtual', 'branch-list', 'branch-switch', 'diff-report', 'preflight', 'ledger-query'].includes(operation)) return 'merge';
290
+ if (operation.startsWith('peer-') || ['coordinate', 'spawn-bind', 'complete', 'lease-verify'].includes(operation)) return 'swarm';
291
+ if (operation.startsWith('evidence-')) return 'evidence';
292
+ if (operation.startsWith('probe') || operation === 'reassess' || operation.startsWith('snapshot') || operation.includes('scope')) return 'scope';
293
+ if (operation === 'final-gate' || operation.includes('verify') || operation.includes('validate')) return 'validator';
294
+ return 'core';
295
+ }
296
+ const LOCAL_OPERATION_METADATA = Object.freeze(Object.fromEntries(Object.keys(LOCAL_OPERATION_SCHEMAS).map(operation => [operation, Object.freeze({
297
+ group: operationGroup(operation), requiresPass: PASS_REQUIRED_OPERATIONS.has(operation),
298
+ })])));
299
+ export const LOCAL_CAPABILITIES = Object.freeze({ schemaVersion: LOCAL_SCHEMA, operations: Object.freeze(Object.keys(LOCAL_OPERATION_SCHEMAS)), operationSchemas: LOCAL_OPERATION_SCHEMAS,
300
+ operationMetadata: LOCAL_OPERATION_METADATA, groups: LOCAL_OPERATION_GROUPS, stateDirectory: '~/.entrorove', forbidGitBranch: true, forbidWorktree: true, evidenceSchema: EVIDENCE_SCHEMA, budgets: READ_BUDGETS, validatorRunner: RUNNER_CAPABILITIES, workflowRunner: WORKFLOW_LOCAL_CAPABILITIES });
279
301
  export function capabilities() { return LOCAL_CAPABILITIES; }
280
302
  export const guardedWriteFile = guardedWrite;
281
303
  export const verifyMutationPassFile = verifyMutationPass;
@@ -0,0 +1,192 @@
1
+ import { createHash } from 'node:crypto';
2
+ import path from 'node:path';
3
+ import ts from 'typescript';
4
+ const missing = (error) => ['ENOENT', 'ENOTDIR'].includes(error?.code ?? '');
5
+ function literal(value, field) {
6
+ if (typeof value !== 'string' || !value || value !== value.trim() || value !== value.normalize('NFC')
7
+ || /[\\:\u0000-\u001f\u007f]/.test(value) || value.startsWith('/') || value.startsWith('~')) {
8
+ throw new Error(`Scope tsconfig ${field} requires a supported repository-relative literal path`);
9
+ }
10
+ return value;
11
+ }
12
+ function within(directory, value, allowRoot = false) {
13
+ const resolved = path.posix.normalize(path.posix.join(directory, literal(value, 'path'))).replace(/\/+$/, '');
14
+ if (resolved === '..' || resolved.startsWith('../') || resolved.startsWith('/')
15
+ || resolved.split('/').some(part => ['node_modules', '.git', '.entrorove', '.aimlock', '.archguard'].includes(part))) {
16
+ throw new Error('Scope tsconfig path escapes the authorized repository');
17
+ }
18
+ if (resolved === '.') {
19
+ if (!allowRoot)
20
+ throw new Error('Scope tsconfig mapping requires a literal candidate within the repository');
21
+ return '';
22
+ }
23
+ return resolved;
24
+ }
25
+ function pathsOption(value) {
26
+ if (!value || typeof value !== 'object' || Array.isArray(value))
27
+ throw new Error('Scope tsconfig paths requires an object of literal mappings');
28
+ const result = Object.create(null);
29
+ const entries = Object.entries(value);
30
+ if (entries.length > 512)
31
+ throw new Error('Scope tsconfig paths exceeds 512 mappings');
32
+ for (const [key, targets] of entries) {
33
+ if (!key || key !== key.trim() || key !== key.normalize('NFC') || /[\\:\u0000-\u001f\u007f]/.test(key)
34
+ || key.startsWith('/') || key.split('/').some(part => part === '.' || part === '..') || key.split('*').length > 2) {
35
+ throw new Error('Scope tsconfig paths supports only exact or single-wildcard mappings');
36
+ }
37
+ if (!Array.isArray(targets) || !targets.length || targets.length > 128 || targets.some(target => typeof target !== 'string')) {
38
+ throw new Error('Scope tsconfig paths targets must be a nonempty bounded string array');
39
+ }
40
+ result[key] = targets.map(target => {
41
+ literal(target, 'mapping');
42
+ if (target.split('*').length > 2 || (target.includes('*') && !key.includes('*'))) {
43
+ throw new Error('Scope tsconfig paths target has an unsupported wildcard mapping');
44
+ }
45
+ return target;
46
+ });
47
+ }
48
+ return result;
49
+ }
50
+ function parsedConfig(relative, source) {
51
+ const parsed = ts.parseConfigFileTextToJson(relative, source);
52
+ if (parsed.error)
53
+ throw new Error(`Scope tsconfig JSONC syntax is invalid (diagnostic ${parsed.error.code})`);
54
+ if (!parsed.config || typeof parsed.config !== 'object' || Array.isArray(parsed.config))
55
+ throw new Error('Scope tsconfig must contain a configuration object');
56
+ return parsed.config;
57
+ }
58
+ function pathMatch(paths, specifier) {
59
+ if (!paths)
60
+ return null;
61
+ if (Object.hasOwn(paths, specifier))
62
+ return { targets: paths[specifier], wildcard: '' };
63
+ let selected = null;
64
+ let prefixLength = -1;
65
+ for (const [key, targets] of Object.entries(paths)) {
66
+ const star = key.indexOf('*');
67
+ if (star < 0)
68
+ continue;
69
+ const prefix = key.slice(0, star);
70
+ const suffix = key.slice(star + 1);
71
+ if (prefix.length > prefixLength && specifier.startsWith(prefix) && specifier.endsWith(suffix)
72
+ && specifier.length >= prefix.length + suffix.length) {
73
+ selected = { targets, wildcard: specifier.slice(prefix.length, specifier.length - suffix.length) };
74
+ prefixLength = prefix.length;
75
+ }
76
+ }
77
+ return selected;
78
+ }
79
+ function importCandidates(resolution, specifier) {
80
+ if (typeof specifier !== 'string' || !specifier)
81
+ throw new Error('Scope tsconfig import specifier must be nonempty');
82
+ if (specifier.startsWith('node:') || specifier.startsWith('./') || specifier.startsWith('../'))
83
+ return [];
84
+ if (specifier !== specifier.trim() || /[\\:\u0000-\u001f\u007f*]/.test(specifier)
85
+ || specifier.startsWith('/') || specifier.split('/').some(part => part === '.' || part === '..')) {
86
+ throw new Error('Scope tsconfig import alias or candidate uses an unsupported path');
87
+ }
88
+ const match = pathMatch(resolution?.paths, specifier);
89
+ if (match) {
90
+ const base = resolution.baseUrl ?? resolution.pathsOrigin;
91
+ return [...new Set(match.targets.map(target => within(base, target.replace('*', match.wildcard))))];
92
+ }
93
+ if (specifier.startsWith('@/') || specifier.startsWith('#') || specifier.startsWith('~/')) {
94
+ throw new Error('Scope local import alias requires an actual tsconfig or package-import mapping');
95
+ }
96
+ return resolution?.baseUrl !== undefined ? [within(resolution.baseUrl, specifier)] : [];
97
+ }
98
+ export async function scopeTsconfigImports(read, fromFile, configPath) {
99
+ const canonical = within('', fromFile);
100
+ if (canonical !== fromFile)
101
+ throw new Error('Scope tsconfig source path must be canonical within the repository');
102
+ const observed = new Map();
103
+ const absent = new Set();
104
+ let bytes = 0;
105
+ const metadata = async (relative) => {
106
+ if (absent.has(relative))
107
+ return null;
108
+ if (observed.has(relative))
109
+ return Buffer.from(observed.get(relative).contentBase64, 'base64').toString('utf8');
110
+ let item;
111
+ try {
112
+ item = await read(relative);
113
+ }
114
+ catch (error) {
115
+ if (missing(error)) {
116
+ absent.add(relative);
117
+ return null;
118
+ }
119
+ ;
120
+ throw error;
121
+ }
122
+ const data = Buffer.from(item.contentBase64, 'base64');
123
+ if (item.path !== relative || data.toString('base64') !== item.contentBase64
124
+ || createHash('sha256').update(data).digest('hex') !== item.sha256 || !Buffer.from(data.toString('utf8')).equals(data)) {
125
+ throw new Error('Scope tsconfig metadata lost its actual path, UTF-8 content or hash');
126
+ }
127
+ bytes += data.length;
128
+ if (data.length > 1024 * 1024 || bytes > 16 * 1024 * 1024)
129
+ throw new Error('Scope tsconfig metadata exceeds its bounded read size');
130
+ observed.set(relative, item);
131
+ return data.toString('utf8');
132
+ };
133
+ const load = async (relative, ancestors, depth = 0) => {
134
+ if (depth >= 10)
135
+ throw new Error('Scope tsconfig extends exceeds the 10-configuration depth bound');
136
+ let source = await metadata(relative);
137
+ if (source === null && !relative.endsWith('.json')) {
138
+ relative += '.json';
139
+ source = await metadata(relative);
140
+ }
141
+ if (source === null)
142
+ throw new Error('Scope tsconfig extends references a missing actual configuration');
143
+ if (ancestors.has(relative))
144
+ throw new Error('Scope tsconfig extends contains a configuration cycle');
145
+ const config = parsedConfig(relative, source);
146
+ const directory = path.posix.dirname(relative) === '.' ? '' : path.posix.dirname(relative);
147
+ let inherited = { pathsOrigin: directory };
148
+ if (config.extends !== undefined) {
149
+ const extended = literal(config.extends, 'extends');
150
+ if (!extended.startsWith('./') && !extended.startsWith('../'))
151
+ throw new Error('Scope tsconfig npm extends is unsupported; use an actual repository-relative extends');
152
+ inherited = await load(within(directory, extended), new Set([...ancestors, relative]), depth + 1);
153
+ }
154
+ const options = config.compilerOptions === undefined ? {} : config.compilerOptions;
155
+ if (!options || typeof options !== 'object' || Array.isArray(options))
156
+ throw new Error('Scope tsconfig compilerOptions must be an object');
157
+ const own = options;
158
+ return { ...inherited,
159
+ ...(own.baseUrl === undefined ? {} : { baseUrl: within(directory, literal(own.baseUrl, 'baseUrl'), true) }),
160
+ ...(own.paths === undefined ? {} : { paths: pathsOption(own.paths), pathsOrigin: directory }) };
161
+ };
162
+ let directory = path.posix.dirname(canonical);
163
+ let resolution = null;
164
+ if (configPath !== undefined) {
165
+ if (within('', configPath) !== configPath || !/(?:^|\/)(?:ts|js)config(?:\.[^/]+)?\.json$/.test(configPath))
166
+ throw new Error('Scope import context requires an actual repository-relative tsconfig or jsconfig');
167
+ resolution = await load(configPath, new Set());
168
+ }
169
+ while (configPath === undefined) {
170
+ const prefix = directory === '.' ? '' : `${directory}/`;
171
+ const relative = await metadata(`${prefix}tsconfig.json`) !== null ? `${prefix}tsconfig.json`
172
+ : await metadata(`${prefix}jsconfig.json`) !== null ? `${prefix}jsconfig.json` : null;
173
+ if (relative !== null) {
174
+ resolution = await load(relative, new Set());
175
+ break;
176
+ }
177
+ if (directory === '.')
178
+ break;
179
+ directory = path.posix.dirname(directory);
180
+ }
181
+ if (resolution?.paths)
182
+ for (const targets of Object.values(resolution.paths))
183
+ for (const target of targets) {
184
+ within(resolution.baseUrl ?? resolution.pathsOrigin, target.replace('*', '__scope_wildcard__'));
185
+ }
186
+ return { candidates: specifier => importCandidates(resolution, specifier), mapped: specifier => {
187
+ if (typeof specifier !== 'string' || !specifier)
188
+ throw new Error('Scope tsconfig mapping lookup requires a nonempty specifier');
189
+ return pathMatch(resolution?.paths, specifier) !== null;
190
+ }, observations: [...observed.keys()].sort()
191
+ .map(relative => ({ path: relative, sha256: observed.get(relative).sha256 })) };
192
+ }
@@ -11,6 +11,7 @@ import { workspaceImport, workspaceIndex } from './local-scope-workspaces.mjs';
11
11
  import { assertBudgetRunnerAllowed } from './local-budget-reads.mjs';
12
12
  import { observeBudgetScopeGit, scopeGitBudgetEnforced } from './local-scope-git.mjs';
13
13
  import { scopeGitMetadata } from './local-scope-git-reads.mjs';
14
+ import { scopeTsconfigImports } from './local-scope-config-imports.mjs';
14
15
 
15
16
  const execute = promisify(execFile);
16
17
  async function git(root, args, allowed = [], input = { repositoryRoot: root }) {
@@ -179,48 +180,83 @@ function importSpecifiers(content, filename) {
179
180
  }
180
181
  }
181
182
  const sourceExtension = /\.(?:[cm]?[jt]sx?|vue)$/i;
182
- async function resolveImport(root, from, specifier, workspace, input) {
183
+ function declarationModules(content, from) {
184
+ if (!/\.d\.[cm]?ts$/i.test(from)) return new Set();
185
+ const parsed = ts.createSourceFile(from, content.toString('utf8'), ts.ScriptTarget.Latest, true, ts.ScriptKind.TS);
186
+ return new Set(parsed.statements.filter(node => ts.isModuleDeclaration(node) && ts.isStringLiteral(node.name)
187
+ && node.modifiers?.some(modifier => modifier.kind === ts.SyntaxKind.DeclareKeyword)).map(node => node.name.text));
188
+ }
189
+ async function resolveImport(root, from, specifier, workspace, input, aliases, configPath, declarations) {
183
190
  if (/^(?:https?:|file:|data:)/i.test(specifier)) scopeFail('ENTROROVE_IMPORT_UNSUPPORTED', 'remote source imports cannot be inspected as local facts');
191
+ if (declarations.has(specifier)) return null;
192
+ const label = /^[A-Za-z0-9@._/~+-]{1,256}$/.test(specifier) ? specifier : '[unsupported specifier]';
193
+ let candidates;
194
+ try { candidates = aliases.candidates(specifier); }
195
+ catch (error) { scopeFail('ENTROROVE_IMPORT_UNRESOLVED', `${from}: ${error.message}; specify its actual build config with importContexts`); }
196
+ for (const candidate of candidates) {
197
+ const resolved = await resolveLocalImport(root, candidate, false);
198
+ if (resolved) return resolved;
199
+ }
200
+ if (aliases.mapped(specifier)) scopeFail('ENTROROVE_IMPORT_UNRESOLVED', `${from}: ${label} has no actual resolvable source file in its build config`);
184
201
  const localPackage = !specifier.startsWith('.') ? workspaceImport(workspace, specifier) : null;
185
202
  if (!specifier.startsWith('.') && !localPackage) {
186
203
  if (builtinModules.includes(specifier) || (specifier.startsWith('node:') && builtinModules.includes(specifier.slice(5)))) return null;
187
- const boundary = await packageBoundary(root, from, input);
204
+ const boundary = await packageBoundary(root, configPath ?? from, input);
188
205
  if (boundary.manifestPath) {
189
206
  const manifest = JSON.parse((await scopeRead(root, boundary.manifestPath, input)).content.toString('utf8'));
190
207
  const name = specifier.startsWith('@') ? specifier.split('/').slice(0, 2).join('/') : specifier.split('/')[0];
191
208
  if (['dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies'].some(field => Object.hasOwn(manifest[field] ?? {}, name))) return null;
192
209
  }
193
- scopeFail('ENTROROVE_IMPORT_UNRESOLVED', 'bare imports require a builtin, declared dependency, or actual workspace export; unresolved aliases cannot be assumed external');
210
+ scopeFail('ENTROROVE_IMPORT_UNRESOLVED', `${from}: ${label} requires a builtin, declared dependency, or actual workspace export`);
194
211
  }
195
212
  const relative = localPackage ?? path.posix.normalize(path.posix.join(path.posix.dirname(from), specifier));
213
+ return resolveLocalImport(root, relative);
214
+ }
215
+ async function resolveLocalImport(root, relative, required = true) {
196
216
  if (relative.startsWith('../') || relative === '..') scopeFail('ENTROROVE_PATH_UNSAFE', 'local import escapes the repository');
197
217
  const replacements = /\.js$/.test(relative) ? [relative.replace(/\.js$/, '.ts'), relative.replace(/\.js$/, '.tsx')] : [];
198
- for (const candidate of [relative, ...replacements, ...['.js', '.mjs', '.cjs', '.ts', '.tsx', '.jsx', '/index.js', '/index.ts'].map(ext => relative + ext)]) {
218
+ for (const candidate of [relative, ...replacements, ...['.js', '.mjs', '.cjs', '.ts', '.tsx', '.jsx', '.vue', '.json', '.d.ts', '.d.mts', '.d.cts', '/index.js', '/index.ts', '/index.d.ts'].map(ext => relative + ext)]) {
199
219
  const item = await scopeTarget(root, candidate, true);
200
220
  if (item.exists && item.stat.isFile()) return candidate;
201
221
  }
202
- scopeFail('ENTROROVE_IMPORT_UNRESOLVED', 'a local import has no resolvable source file');
222
+ if (required) scopeFail('ENTROROVE_IMPORT_UNRESOLVED', 'a local import has no resolvable source file');
223
+ return null;
203
224
  }
204
225
  export async function importGraph(root, originals, input = { repositoryRoot: root }) {
205
226
  const pending = originals.filter(file => file.exists !== false).map(file => file.path); const visited = new Map(); const edges = []; let dynamic = false;
227
+ const dynamicSources = [];
206
228
  const workspace = await workspaceIndex(root, input);
229
+ const metadata = new Map(workspace.metadata.map(item => [item.path, item]));
207
230
  while (pending.length) {
208
231
  const relative = pending.shift(); if (visited.has(relative)) continue;
209
232
  if (visited.size >= 1000) scopeFail('ENTROROVE_SCOPE_LIMIT', 'import graph exceeds the 1000-file inspection bound');
210
233
  const read = await scopeRead(root, relative, input); const boundary = await packageBoundary(root, relative, input);
211
234
  visited.set(relative, { path: relative, sha256: read.sha256, module: boundary.path, boundary });
212
235
  if (!sourceExtension.test(relative)) continue;
236
+ const contexts = (input.importContexts ?? []).filter(context => relative === context.sourcePrefix || relative.startsWith(`${context.sourcePrefix}/`))
237
+ .sort((a, b) => b.sourcePrefix.length - a.sourcePrefix.length);
238
+ const configPath = contexts[0]?.configPath;
239
+ const aliases = await scopeTsconfigImports(async target => {
240
+ const item = await scopeRead(root, target, input);
241
+ return { path: target, sha256: item.sha256, contentBase64: item.content.toString('base64') };
242
+ }, relative, configPath);
243
+ for (const observed of aliases.observations) metadata.set(observed.path, observed);
244
+ const declarations = declarationModules(read.content, relative);
213
245
  const imports = importSpecifiers(read.content, relative); dynamic ||= imports.dynamic;
246
+ if (imports.dynamic) dynamicSources.push({ path: relative, sha256: read.sha256 });
214
247
  for (const specifier of imports.specifiers.sort()) {
215
- const target = await resolveImport(root, relative, specifier, workspace, input);
216
- edges.push({ from: relative, to: target, external: target === null, specifier });
248
+ const target = await resolveImport(root, relative, specifier, workspace, input, aliases, configPath, declarations);
249
+ edges.push({ from: relative, to: target, external: target === null, specifier,
250
+ ...(declarations.has(specifier) ? { declarationOnly: true, declarationPath: relative } : {}) });
217
251
  if (target !== null && !visited.has(target)) pending.push(target);
218
252
  }
219
253
  }
220
254
  const nodes = [...visited.values()].sort((a, b) => a.path.localeCompare(b.path));
221
255
  await stableScope(root, nodes, input);
222
- await stableScope(root, workspace.metadata, input);
223
- return { nodes, edges: edges.sort((a, b) => `${a.from}:${a.specifier}`.localeCompare(`${b.from}:${b.specifier}`)), dynamic, complete: !dynamic, workspaceMetadata: workspace.metadata };
256
+ const workspaceMetadata = [...metadata.values()].sort((a, b) => a.path.localeCompare(b.path));
257
+ await stableScope(root, workspaceMetadata, input);
258
+ return { nodes, edges: edges.sort((a, b) => `${a.from}:${a.specifier}`.localeCompare(`${b.from}:${b.specifier}`)),
259
+ dynamic, complete: !dynamic, dynamicSources: dynamicSources.sort((a, b) => a.path.localeCompare(b.path)), workspaceMetadata };
224
260
  }
225
261
  export async function collectScopeFacts(root, requested, baselineRef, knownFiles = [], input = { repositoryRoot: root }) {
226
262
  const selected = await scopeFiles(root, requested, knownFiles); const files = [];
package/manifest.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "schemaVersion": "entrorove.skill-manifest/1.0",
3
3
  "name": "entrorove",
4
- "version": "0.1.31",
4
+ "version": "0.1.33",
5
5
  "endpoint": "https://entrorove.com",
6
6
  "requestSchemaVersion": "entrorove.skill.request/2.0",
7
7
  "supportedRequestSchemaVersions": [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@entrorove/skill",
3
- "version": "0.1.31",
3
+ "version": "0.1.33",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "entrorove": "cli.mjs"
@@ -76,6 +76,7 @@
76
76
  "local-task-handoff-write-archive.mjs",
77
77
  "local-task-handoff-write-receipts.mjs",
78
78
  "remote-client.mjs",
79
+ "scope-runner-client.mjs",
79
80
  "cloud-task-client.mjs",
80
81
  "remote-transport.mjs",
81
82
  "local-credential.mjs",
@@ -122,6 +123,7 @@
122
123
  "local-scope-files.mjs",
123
124
  "local-scope-repository.mjs",
124
125
  "local-scope-signature.mjs",
126
+ "local-scope-config-imports.mjs",
125
127
  "local-scope-snapshot.mjs",
126
128
  "local-scope-state.mjs",
127
129
  "local-scope-workspaces.mjs",
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "schemaVersion": "entrorove.release-state/1.0",
3
- "version": "0.1.31",
4
- "digest": "005df83b58c713b893d715a7480c08a91896301f6295746525f49abd2d746044",
3
+ "version": "0.1.33",
4
+ "digest": "79b9167ef252402e374202c08093e75e98fe3bf41227a53f34a44fa757834fe2",
5
5
  "files": {
6
6
  "local-final-gate16.mjs": "b30caab08441449ad556a6e9a3cefbf91190f0b3f7370f46d2a21156375aa8e3",
7
7
  "local-final-gate16-checks.mjs": "1dd30e9666d458d8b12ca6e4975f6f0ac8450c3ea9cbecc7401ae037f66b589a",
@@ -44,23 +44,23 @@
44
44
  "local-autocoord17-waits.mjs": "2b0a713987822548a94e1ed3a9a891532db45383797dfc5d5bc8d1e35593cf25",
45
45
  "local-validator-isolation17.mjs": "48e94604e28b8fddd567dfb35ff60b73fd094a5803bd49f076388b7f310f92aa",
46
46
  "local-calctool-composition17.mjs": "ac89fcb41ab0d0d9b47c1b5696945281b0894eb064820775e36a011c76314e6a",
47
- "cli.mjs": "6b2f31ee3204c8584ac4733a9b2d1c6ca72e2ea205e84f8a314a68f31917f629",
47
+ "cli.mjs": "d06e372e7f54f726b700b0d909669616c5f35aa7eaa6c67c008d0670ac18678a",
48
48
  "review-client.mjs": "3491eef68ed533174b31db3c53febb6519b8f088f71a9964644714a9a4c03c77",
49
49
  "version.mjs": "1f392147d72c40af4b03b307ea607075fb815af5174fa07e254b95fe12dbf3e7",
50
- "SKILL.md": "fa3fa41f1551e54512de0cae7940e2d7ad670f81a46c2e642cdc2323b171224f",
51
- "CAPABILITY-MAP.md": "128ecc8d90b2c17846ce6f0c650eada9e7fa7240f5d3c03b31e3ee01135557b8",
50
+ "SKILL.md": "726140d61da8561a6a87ff818931964994ad5356c56c45afeb6b5133609697ab",
51
+ "CAPABILITY-MAP.md": "4b8479928481289b82540ff2624ebb16d1f0a5b6697b6eec52d2d459f86a0001",
52
52
  "AGREEMENT.md": "f93a68e92c457200dd024610e65f74e478b8f60860f951da818b70561f1b9bf8",
53
- "package.json": "77e29f7740cc2a2284490a992a9945fbb4a3ad3ac044f4ff9a59b1aa83e44ae0",
53
+ "package.json": "2afd7c74c464da6f8fa7dfa58d465104e77c1d332ec2b4adb17a8df518b0d3ba",
54
54
  "manifest.json": "ab06e307795bc9da5add926752b6c4dbdaf18625090261a035aa356a679c504f",
55
- "release.mjs": "1e6155d10b5e6384414233847815742e13f0945101c5f53253ad9ca827766beb",
56
- "local-cli.mjs": "ecc85bc5f36a5663fef004e59525374dc7883d3889f5a0cfe219ae5e84b2d3ea",
55
+ "release.mjs": "e33a80cb7463f4db532af18d26880b198de637d376ce1952d751668bbd8df44d",
56
+ "local-cli.mjs": "a79037d7383a29822b465e6db1c862a43d47cc344f05b6651c849633c49d19f7",
57
57
  "local-state-lock.mjs": "1dbf64264642a0851faf043e07ee9f66d2ad0508e9f799b0626246e42fae893e",
58
- "local-execution.mjs": "97dfad5e889e679e9cc34434005342526502795abba21078fa40673788073153",
58
+ "local-execution.mjs": "cdada6df6dc8831cebb58a629b56c4b3f8e1bf9aae4e3fdc121521ba20839383",
59
59
  "local-integrity.mjs": "4f325376e536999c3b00245f66aac9ef1ae762c33012b363c231622008f72805",
60
60
  "local-evidence-verification.mjs": "9b7dea31777505272f63616fd62af7603338f703bb1285fc565d6451715c7ce0",
61
61
  "local-formula.mjs": "706d5ec0cb2caf95a547531a81ca746fe246d4c53d902e03595a3b360e8c85af",
62
62
  "local-ledger.mjs": "2009fb68aadcd7292b72f1d0d99f378d03644be1c5b4b4150d9f6a0b3f7c15ce",
63
- "local-error.mjs": "66484e2e27c9870a17dc0f1cf64d108ea384ad9fb41b7adc3aacb243ae65fe2e",
63
+ "local-error.mjs": "9dc42763ca7013cde607543308e029796d68f3b020a5a3b4ad9e68815e38d0f5",
64
64
  "blueprint-criterion-wire.mjs": "fb6b7dcce0b97a699e3e8968372767a3fddd83fedc750998573f789177243072",
65
65
  "blueprint-criterion-runner.mjs": "761c0e670b4b20c6bed53e5ca5d0178759505c3977ea2ed1b7223383c534bc4c",
66
66
  "local-chain-bindings.mjs": "b8eff999ccd17b53c614a3c9f5376da78fcaa8632bb3e07bc1adcceda2e75f6c",
@@ -89,7 +89,7 @@
89
89
  "local-scope-candidate.mjs": "8f0bd9606e70d4a0ebacdd3cd600ec3afed85e2bb3e1760aa1144cf55d4e028a",
90
90
  "local-scope-context.mjs": "79753abfc6c76089d17f003a1be537dfaea10d6731c542f7fc53f2a9996ed164",
91
91
  "local-scope-files.mjs": "0671ac4baef610a55ea6c1db6ebca7a4b6cfda45edb8aeaa828019e2a26f5a76",
92
- "local-scope-repository.mjs": "f3fa673fdb4b37bb085ce0caab6a24be2b9f056e12d59bd81aac798d85dd507c",
92
+ "local-scope-repository.mjs": "af7fe24408509dcebe39bdfc6fcef6adcf66f6ad09473149f920d5e921a48d1a",
93
93
  "local-scope-git.mjs": "53bbbb6c4795db711b5bc164591a6ad79a561427da046d6bbe997dae6eb82829",
94
94
  "local-scope-git-reads.mjs": "d18d4441664aa59ad8360ee16a69fc0282e7530705d5ae7950944a8158448bb4",
95
95
  "local-scope-git-comparison.mjs": "d86e559843087ae851795c9fb16b844dc46cc461fb5da37dbca0f6ba0d06450b",
@@ -113,12 +113,14 @@
113
113
  "runtime/git-native-linux-v1.mjs": "cf5cb91426a105e62c0f07a8a230dec592dba46f85a3f708b4c4e8cfcd1126c6",
114
114
  "runtime/git-native-darwin-v1.mjs": "f88aab72dc86f94eb1115784872957cb8ed0f74205ae70e1ecd1939e7790e310",
115
115
  "local-scope-signature.mjs": "bd05e8cf9e23f9511a16844974362297b9e9d11930ff7b8ad2a490e8d7660fe9",
116
+ "local-scope-config-imports.mjs": "16053142410dd647f13360f29fb3680c3e3a3ebc38019e43af6db05896d92a33",
116
117
  "local-scope-snapshot.mjs": "c0f851470b98863a1d187825ed61ee6072d1c057b178fdc85ae29af02a3b250f",
117
118
  "local-scope-state.mjs": "8b600a91fc0068d7a8c0bb752ddba7ed30ada93daafb118d6cba2368fd94cb27",
118
119
  "local-scope-workspaces.mjs": "a44c34537e5ed2dcaf193a985e5bc05957989fbb160a650f9e37ba7d66eb2de8",
119
120
  "local-task-routing.mjs": "e26d6f1ba134ffe1da2a49b4fb6bb6dfa5617671f774a08e719d04f202cf5eca",
120
- "remote-client.mjs": "ce16258faffb09c9db19609d47a0af6f63fcbc256feb35ba7a14bbb89e69cc6e",
121
- "cloud-task-client.mjs": "b7dc4be6c4d2a60404538e3d875cf95533e1b7be3f43ac7f5d49118042ce1d45",
121
+ "remote-client.mjs": "6df222e3e55a893fda2aa7a15feb545db5ff9d74e33025a1b0e3f03b849f5d14",
122
+ "cloud-task-client.mjs": "d519e2c020e19a67e8c6e32d2da39bc3fa45538b773e7baf02605505d065daaf",
123
+ "scope-runner-client.mjs": "940c841e2c56e92793e5bf65e713bf4087151f721d0ecb659e0a75d6fdefeab9",
122
124
  "remote-transport.mjs": "5e5a8c0b490371f7f5a052cffbeee6202d4cf713dff1c43b6d910fe944f8429c",
123
125
  "local-credential.mjs": "2a302a7f67b5ea8dc73453fc96ab33db349b59ca11fa9456e48ca1d9669d877c",
124
126
  "local-invocations.mjs": "f6ac4c41c1a5ec34de358c4cca210ca8e403b3a913150a43bb55ec9be55d9998",
@@ -139,8 +141,8 @@
139
141
  "workflow-local-request.mjs": "4e24c543ce386292a87dc9cd41f90d23375f12079cacffc515c8cd01fd571780",
140
142
  "task-message-entry.mjs": "31d10056cb905237cd5649e8eb067009ff1612ec5dc1061e0373209fbc0b646a",
141
143
  "task-message-cli.mjs": "3ca65e96c28129b3bd5cac962d106e0859a1f17df1f6a66ee543c6efc22f918d",
142
- "local-contracts.mjs": "43715cd73460ccea734501a3dd5667184da0f6a5b0189e038319a439ba52fc76",
143
- "local-contract-types.mjs": "5d244237fc69052b2fa83098022761bae929e8b75a0683883b620711bc132050",
144
+ "local-contracts.mjs": "e6c6b9b8b8b89e991c5689d935e26ccd92d398405ebced298817d673095bca1e",
145
+ "local-contract-types.mjs": "85e71fa4dd67e351a4b67573c740bcdb1661cede53f097189f11837771e75e91",
144
146
  "local-archguard-ast.mjs": "cefd835312a623e83bf48e4d8e1f76ac6a8adc56c35843705c16967d16bec4e6",
145
147
  "local-archguard-checkpoint.mjs": "37896902520cc995720c96288e4a5ed3585952b55c9c35be281f91fa754c71ec",
146
148
  "local-archguard-components.mjs": "a8fb3d0fd787f9160905013fd333cc0b9e8cf624cce797461f36f4ab31d170ad",
package/release.mjs CHANGED
@@ -86,10 +86,12 @@ const sourceFiles = [
86
86
  'runtime/git-native-linux-v1.mjs',
87
87
  'runtime/git-native-darwin-v1.mjs',
88
88
  'local-scope-signature.mjs',
89
+ 'local-scope-config-imports.mjs',
89
90
  'local-scope-snapshot.mjs',
90
91
  'local-scope-state.mjs',
91
92
  'local-scope-workspaces.mjs',
92
93
  'local-task-routing.mjs', 'remote-client.mjs', 'cloud-task-client.mjs',
94
+ 'scope-runner-client.mjs',
93
95
  'remote-transport.mjs', 'local-credential.mjs', 'local-invocations.mjs',
94
96
  'local-task-handoff-fence.mjs',
95
97
  'local-task-handoff-storage.mjs', 'local-task-handoff-overlay-wire.mjs', 'local-task-handoff-completion-wire.mjs',
@@ -257,7 +259,15 @@ function releaseNote(changed) {
257
259
  if (suppliedZh && suppliedEn) return {
258
260
  zh: suppliedZh.replaceAll(/\s+/g, ' '), en: suppliedEn.replaceAll(/\s+/g, ' '),
259
261
  }
260
- const labels = withFinalGate16ReleaseLabels({ ...calctoolReleaseLabels, ...mutationReleaseLabels, ...swarmReleaseLabels, ...coordination17ReleaseLabels, ...nativeReadReleaseLabels, ...feedbackReleaseLabels, ...invocationReleaseLabels, ...chainReleaseLabels,
262
+ if (changed.includes('scope-runner-client.mjs')) return {
263
+ zh: '补齐账号与任务隔离的本地签名器和真实快照接入,解析实际别名及类型声明;动态依赖仅进入受限规划,保留独立写入核验。',
264
+ en: 'Connected account-bound scope signers and real snapshots, resolved actual aliases and declarations, and retained restricted planning for unresolved dynamic imports with separate mutation verification.',
265
+ }
266
+ const distinct = distinctReleaseLabels(changed, releaseLabels())
267
+ return { zh: `更新了${distinct.map(label => label.zh).join('、')}。`, en: `Updated ${distinct.map(label => label.en).join(', ')}.` }
268
+ }
269
+ function releaseLabels() {
270
+ return withFinalGate16ReleaseLabels({ ...calctoolReleaseLabels, ...mutationReleaseLabels, ...swarmReleaseLabels, ...coordination17ReleaseLabels, ...nativeReadReleaseLabels, ...feedbackReleaseLabels, ...invocationReleaseLabels, ...chainReleaseLabels,
261
271
  'blueprint-criterion-wire.mjs': { zh: '蓝图验收标准执行授权', en: 'Blueprint criterion execution authority' },
262
272
  'blueprint-criterion-runner.mjs': { zh: '蓝图验收标准实际执行器', en: 'Blueprint criterion process runner' },
263
273
  'local-archguard-ast.mjs': { zh: 'Archguard 架构合同与执行守卫', en: 'Archguard architecture contract and execution guard' },
@@ -345,11 +355,6 @@ function releaseNote(changed) {
345
355
  'task-message-cli.mjs': { zh: '任务消息命令与适配器', en: 'task message command and adapter' },
346
356
  'cloud-task-client.mjs': { zh: '云端沙箱任务路由与逐任务选择', en: 'cloud sandbox task routing and per-task choice' },
347
357
  })
348
- const distinct = distinctReleaseLabels(changed, labels)
349
- return {
350
- zh: `更新了${distinct.map(label => label.zh).join('、')}。`,
351
- en: `Updated ${distinct.map(label => label.en).join(', ')}.`,
352
- }
353
358
  }
354
359
 
355
360
  async function writeAll(changes) {
package/releases.json CHANGED
@@ -1,4 +1,16 @@
1
1
  [
2
+ {
3
+ "version": "0.1.33",
4
+ "date": "2026-10-12",
5
+ "zh": "补齐账号与任务隔离的本地签名器和真实快照接入,解析实际别名及类型声明;动态依赖仅进入受限规划,保留独立写入核验。",
6
+ "en": "Connected account-bound scope signers and real snapshots, resolved actual aliases and declarations, and retained restricted planning for unresolved dynamic imports with separate mutation verification."
7
+ },
8
+ {
9
+ "version": "0.1.32",
10
+ "date": "2026-10-11",
11
+ "zh": "增加云端沙箱状态与用量只读查询,并支持按任务直接读取真实状态。",
12
+ "en": "Added read-only cloud sandbox status and direct reads of authoritative task state and usage."
13
+ },
2
14
  {
3
15
  "version": "0.1.31",
4
16
  "date": "2026-10-11",
package/remote-client.mjs CHANGED
@@ -33,6 +33,7 @@ export function buildInvocation(operation, input) {
33
33
  if (!['capabilities', 'task-describe', 'message-route', 'message-accept', 'task-resume', 'task-checkpoint',
34
34
  'message-status', 'message-delivery-start', 'message-delivery-report', 'message-resolve', 'message-complete',
35
35
  'handoff-prepare', 'handoff-release', 'handoff-claim', 'handoff-status', 'handoff-resume', 'handoff-execution-authority', 'mutation-snapshot-challenge', 'mutation-pass-issue',
36
+ 'scope-runner-prepare', 'scope-runner-register', 'scope-runner-challenge', 'scope-runner-attest',
36
37
  'start', 'continue'].includes(operation)) throw new Error('Invalid entrorove operation');
37
38
  if (!input || typeof input !== 'object' || Array.isArray(input)) throw new Error('Input must be a JSON object');
38
39
  const id = requestId(operation, input);
@@ -62,8 +63,19 @@ export async function invokeRemote(data, dependencies) {
62
63
  process.exitCode = 2;
63
64
  return;
64
65
  }
65
- const call = { ...buildInvocation(data.operation, data.input), url: `${trustedOrigin(apiOrigin())}/api/skill/invoke` };
66
66
  const token = await readCredential(home);
67
+ const { response, result } = await invokeDiscovered(data, token, { home, apiOrigin });
68
+ process.stdout.write(JSON.stringify(result) + '\n');
69
+ if (!response.ok || result.output.status === 'blocked' || result.output.status === 'failed'
70
+ || result.output.status === 'incomplete') process.exitCode = 2;
71
+ }
72
+
73
+ export async function invokeRemoteResult(data, dependencies) {
74
+ const token = await machineCredential(dependencies);
75
+ return invokeDiscovered(data, token, dependencies);
76
+ }
77
+ async function invokeDiscovered(data, token, { home, apiOrigin }) {
78
+ const call = { ...buildInvocation(data.operation, data.input), url: `${trustedOrigin(apiOrigin())}/api/skill/invoke` };
67
79
  if (data.operation !== 'capabilities') {
68
80
  const discovery = await postInvocation({ ...buildInvocation('capabilities', {}), url: call.url }, token, home);
69
81
  if (!discovery.response.ok || discovery.result.output.status !== 'succeeded') throw new Error(`Capability discovery failed: HTTP ${discovery.response.status}`);
@@ -74,10 +86,7 @@ export async function invokeRemote(data, dependencies) {
74
86
  if (!validate(data.input)) throw new Error(`Invalid ${data.operation} input: ${validate.errors.map(error => `${error.instancePath || '/'} ${error.message}`).join('; ')}`);
75
87
  validateDiscoveredChecks(data.input, capabilities.inputChecks?.[data.operation]);
76
88
  }
77
- const { response, result } = await postInvocation(call, token, home);
78
- process.stdout.write(JSON.stringify(result) + '\n');
79
- if (!response.ok || result.output.status === 'blocked' || result.output.status === 'failed'
80
- || result.output.status === 'incomplete') process.exitCode = 2;
89
+ return postInvocation(call, token, home);
81
90
  }
82
91
 
83
92
  async function postInvocation(call, token, home) {
@@ -0,0 +1,125 @@
1
+ import { createPrivateKey, createPublicKey, generateKeyPairSync, randomUUID, sign } from 'node:crypto';
2
+ import { constants, promises as fs } from 'node:fs';
3
+ import path from 'node:path';
4
+ import { canonical, sha256 } from './local-integrity.mjs';
5
+ import { scopeFail, scopePath, scopeRoot, scopeTarget } from './local-scope-files.mjs';
6
+ import { withStateLock } from './local-state-lock.mjs';
7
+ import { snapshotPlan, snapshotVerify } from './local-execution.mjs';
8
+ import { probe } from './local-probe.mjs';
9
+ import { invokeRemoteResult } from './remote-client.mjs';
10
+
11
+ async function privateDirectory(directory) {
12
+ await fs.mkdir(directory, { recursive: true, mode: 0o700 });
13
+ const stat = await fs.lstat(directory);
14
+ if (!stat.isDirectory() || stat.isSymbolicLink() || process.getuid && stat.uid !== process.getuid()) {
15
+ scopeFail('ENTROROVE_SCOPE_STATE', 'Scope signer storage must belong to the current OS account');
16
+ }
17
+ if (process.platform !== 'win32') await fs.chmod(directory, 0o700);
18
+ }
19
+ async function readPrivate(file) {
20
+ const stat = await fs.lstat(file);
21
+ if (!stat.isFile() || stat.isSymbolicLink() || process.getuid && stat.uid !== process.getuid()
22
+ || process.platform !== 'win32' && (stat.mode & 0o077)) scopeFail('ENTROROVE_SCOPE_STATE', 'Scope signer files must be private regular files');
23
+ const handle = await fs.open(file, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
24
+ try { return await handle.readFile('utf8'); } finally { await handle.close(); }
25
+ }
26
+ async function signer(directory, identity) {
27
+ const controls = path.join(directory, 'identity'), file = path.join(controls, 'binding.json');
28
+ let binding;
29
+ try { binding = JSON.parse(await readPrivate(file)); }
30
+ catch (error) { if (error.code !== 'ENOENT') throw error; }
31
+ if (!binding) {
32
+ const keys = generateKeyPairSync('ed25519'), publicDer = keys.publicKey.export({ type: 'spki', format: 'der' });
33
+ binding = { schemaVersion: 'entrorove.local-scope-binding/1.0', ...identity, runnerId: `local-scope-${randomUUID()}`,
34
+ repositoryId: `local-repository-${sha256(identity.repositoryRoot).slice(0, 32)}`, publicKey: publicDer.toString('base64'), keyId: sha256(publicDer) };
35
+ const stage = path.join(directory, `.signer-${randomUUID()}`); await privateDirectory(stage);
36
+ try {
37
+ await fs.writeFile(path.join(stage, 'private.pem'), keys.privateKey.export({ type: 'pkcs8', format: 'pem' }), { flag: 'wx', mode: 0o600 });
38
+ await fs.writeFile(path.join(stage, 'signer.json'), `${JSON.stringify({ schemaVersion: 'entrorove.scope-signer/1.0',
39
+ privateKeyPath: path.join(controls, 'private.pem'), keyId: binding.keyId, receiptTtlMs: 60_000 })}\n`, { flag: 'wx', mode: 0o600 });
40
+ await fs.writeFile(path.join(stage, 'binding.json'), `${JSON.stringify(binding)}\n`, { flag: 'wx', mode: 0o600 });
41
+ await fs.rename(stage, controls);
42
+ } finally { await fs.rm(stage, { recursive: true, force: true }); }
43
+ }
44
+ if (Object.keys(identity).some(field => canonical(binding[field]) !== canonical(identity[field]))) {
45
+ scopeFail('ENTROROVE_SCOPE_REPLACEMENT', 'The original local task signer, repository and path set cannot be replaced');
46
+ }
47
+ await privateDirectory(controls);
48
+ const key = createPrivateKey(await readPrivate(path.join(controls, 'private.pem')));
49
+ if (key.asymmetricKeyType !== 'ed25519' || sha256(createPublicKey(key).export({ type: 'spki', format: 'der' })) !== binding.keyId) {
50
+ scopeFail('ENTROROVE_SCOPE_STATE', 'Original scope signer key identity changed');
51
+ }
52
+ return { binding, key, signerConfigPath: path.join(controls, 'signer.json') };
53
+ }
54
+ async function remote(operation, input, dependencies) {
55
+ const { response, result } = await invokeRemoteResult({ operation, input }, dependencies);
56
+ if (!response.ok || result.output.status !== 'succeeded') {
57
+ throw Object.assign(new Error(result.output.message ?? `Scope connection blocked at ${operation}`),
58
+ { code: result.output.code ?? 'ENTROROVE_SCOPE_BLOCKED', output: result.output });
59
+ }
60
+ return result.output;
61
+ }
62
+ async function connect(identity, directory, dependencies) {
63
+ const { binding, key, signerConfigPath } = await signer(directory, identity);
64
+ const prepared = await remote('scope-runner-prepare', { flowId: identity.flowId, attemptId: randomUUID(), runnerId: binding.runnerId,
65
+ repositoryId: binding.repositoryId, publicKey: binding.publicKey, targetPaths: identity.targetPaths }, dependencies);
66
+ const enrollment = prepared.enrollment;
67
+ if (enrollment.flowId !== identity.flowId || enrollment.runnerId !== binding.runnerId || enrollment.keyId !== binding.keyId
68
+ || canonical(enrollment.targetPaths) !== canonical(identity.targetPaths)) scopeFail('ENTROROVE_SCOPE_BINDING', 'Cloud enrollment differs from the original local task signer');
69
+ await remote('scope-runner-register', { flowId: identity.flowId, enrollmentId: enrollment.enrollmentId,
70
+ signature: sign(null, Buffer.from(canonical(enrollment)), key).toString('base64url') }, dependencies);
71
+ const issued = await remote('scope-runner-challenge', { flowId: identity.flowId, receiverId: binding.runnerId, attemptId: randomUUID() }, dependencies);
72
+ const challenge = issued.challenge;
73
+ if (challenge.flowId !== identity.flowId || challenge.ownerId !== enrollment.ownerId || challenge.runnerId !== binding.runnerId
74
+ || challenge.repositoryId !== binding.repositoryId || challenge.taskDigest !== enrollment.taskDigest
75
+ || challenge.chainDigest !== enrollment.chainDigest || canonical(challenge.targetPaths) !== canonical(identity.targetPaths)) {
76
+ scopeFail('ENTROROVE_SCOPE_BINDING', 'Cloud scope challenge differs from the original account and accepted task');
77
+ }
78
+ const input = { repositoryRoot: identity.repositoryRoot, home: dependencies.home, flowId: identity.flowId,
79
+ stepId: challenge.stepId, paths: identity.targetPaths, snapshotId: `snapshot-${randomUUID()}`, importContexts: identity.importContexts };
80
+ const planned = await snapshotPlan(input), verified = await snapshotVerify(input);
81
+ if (verified.verified !== true || verified.receipt.snapshotDigest !== planned.snapshotDigest) scopeFail('ENTROROVE_SCOPE_SNAPSHOT', 'Actual copied snapshot did not verify');
82
+ const observed = await probe({ ...input, ownerId: challenge.ownerId, task: challenge.task, taskDigest: challenge.taskDigest,
83
+ chainDigest: challenge.chainDigest, repositoryId: challenge.repositoryId, challenge: challenge.challenge, signerConfigPath });
84
+ const accepted = await remote('scope-runner-attest', { flowId: identity.flowId, attestation: observed.attestation }, dependencies);
85
+ return { schemaVersion: 'entrorove.scope-connection/1.0', status: 'succeeded', flowId: identity.flowId,
86
+ runnerId: binding.runnerId, scopeEvidence: accepted.scopeEvidence, grantsExecutionAuthority: false,
87
+ nextOperation: 'workflow-gate' };
88
+ }
89
+ export async function connectScopeRunner(repositoryRoot, input, dependencies) {
90
+ if (!input || Object.keys(input).some(field => !['flowId', 'paths', 'importContexts'].includes(field))
91
+ || !/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(input.flowId ?? '') || !Array.isArray(input.paths) || !input.paths.length) {
92
+ scopeFail('ENTROROVE_SCOPE_INPUT', 'Expected {flowId,paths} for the original task and concrete existing source files');
93
+ }
94
+ const root = await scopeRoot(repositoryRoot), targetPaths = [...new Set(input.paths.map(scopePath))].sort();
95
+ if (targetPaths.length !== input.paths.length) scopeFail('ENTROROVE_SCOPE_INPUT', 'Scope paths must be unique');
96
+ for (const relative of targetPaths) {
97
+ const target = await scopeTarget(root, relative);
98
+ if (!target.stat.isFile()) scopeFail('ENTROROVE_SCOPE_INPUT', 'Scope connection requires concrete existing source files');
99
+ }
100
+ const importContexts = input.importContexts ?? [];
101
+ if (!Array.isArray(importContexts) || importContexts.length > 100 || new Set(importContexts.map(context => context?.sourcePrefix)).size !== importContexts.length) {
102
+ scopeFail('ENTROROVE_SCOPE_INPUT', 'Import contexts require unique source prefixes and at most 100 actual build configurations');
103
+ }
104
+ for (const context of importContexts) {
105
+ if (!context || Object.keys(context).sort().join(',') !== 'configPath,sourcePrefix') scopeFail('ENTROROVE_SCOPE_INPUT', 'Each import context requires sourcePrefix and configPath');
106
+ scopePath(context.sourcePrefix); scopePath(context.configPath);
107
+ }
108
+ const identity = { flowId: input.flowId, repositoryRoot: root, apiOrigin: dependencies.apiOrigin(), targetPaths, importContexts };
109
+ const parent = path.join(dependencies.home, '.entrorove');
110
+ await privateDirectory(parent);
111
+ const runners = path.join(parent, 'scope-runners'); await privateDirectory(runners);
112
+ const directory = path.join(runners, sha256(`${identity.apiOrigin}:${input.flowId}:${root}`)); await privateDirectory(directory);
113
+ return withStateLock(path.join(directory, 'connection'), () => connect(identity, directory, dependencies));
114
+ }
115
+
116
+ export async function runScopeConnect(repositoryRoot, input, dependencies) {
117
+ await dependencies.install();
118
+ if (!await dependencies.agreementAccepted(dependencies.home)) return dependencies.agreementRequired(dependencies.home);
119
+ try { return await connectScopeRunner(repositoryRoot, input, dependencies); }
120
+ catch (error) {
121
+ if (error.outcomeUnknown || !error.code?.startsWith('ENTROROVE_')) throw error;
122
+ return { schemaVersion: 'entrorove.scope-connection/1.0', status: 'blocked', flowId: input?.flowId,
123
+ code: error.code, message: error.message, ...(error.output ? { output: error.output } : {}) };
124
+ }
125
+ }