@entrorove/skill 0.1.39 → 0.1.40

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
@@ -8,6 +8,7 @@
8
8
  | 变更纪律 | `local discipline-review/discipline-check/guarded-delete/guarded-move`、`workflow-discipline-review` | 单一事实源、用途三态、全仓差集、已签名变更分类及原账号确认;删除数据需要绑定实际快照证据。未知用途保持未知,阈值仍待实测 |
9
9
  | 并行路径等价 | `local run-approved-plan`、Validator 7.0.44 | 固定基线来自本项目既有路径的实际输出;逐字段比对,缺证据不得 pass,差异列出字段。不是其它项目的历史输出 |
10
10
  | 结束清理与回传 | 原请求 `continue`、`workflow-verdict`、反馈确认 | 仅验证完成且回传已存后清理同流程冗余快照,保留原始及最新恢复点;公开技术评分按真实已确认回执聚合 |
11
+ | 失败 flow 终止与解绑 | 服务端 `flow-cancel` / `flow-abandon`;本地 `local flow-cancel` / `local flow-abandon` | `flow-cancel` 终止并保留任务绑定供只读恢复;`flow-abandon` 清理接受与 checkpoint、解绑任务并返回新的 `start`;旧 flow 永不重开 |
11
12
 
12
13
  任务优先级队列已接入 IDE 私有状态:新用户原文先进入 `queue status`,再按已有四级优先级通过 `queue route` 排队或在安全检查点插入;插入任务完成并取得真实回执后恢复原 flow、taskId、taskNumber 与 requestId。队列不承诺主机断电或 IDE 进程退出时的无人值守执行。源代码执行仍需原合同配置的可信运行器,范围签名器本身不授予执行权限。
13
14
 
@@ -22,7 +23,7 @@
22
23
  | 网页与手机通知 | 登录后的任务通知按钮 | 同一加密 Web Push 协议,账号与设备隔离;真实任务事务队列、持久重试、失效注销与到期清理;手机可见投递仍需设备本人授权与验收 |
23
24
  | 失败后接续 | `scope-connect` 返回 `nextInput`;`feedback <requestId> [generation]` | 范围门禁使用同一排序路径集;新执行保存新一代回执,原失败回执不可改,默认只读最新一代 |
24
25
 
25
- 本地公开 schema 已增加到 **101 项**(原 100 项加 `peer-status`)。Aimlock 运行时由 7.0.44 更新为 **7.0.45**,公开操作由 20 项增加到 24 项;其余七域的运行时版本保持原值。下方历史表保留原对账日期与当时验收边界,不表示当前仍缺上述入口。发布件与生产部署需独立核验,不能从仓库测试推断。
26
+ 本地公开 schema 已增加到 **107 项**(原 100 项加 `peer-status`,以及当前增量的 `discipline-check`、`discipline-review`、`flow-cancel`、`flow-abandon`、`guarded-delete`、`guarded-move`)。Aimlock 运行时由 7.0.44 更新为 **7.0.45**,公开操作由 20 项增加到 24 项;其余七域的运行时版本保持原值。下方历史表保留原对账日期与当时验收边界,不表示当前仍缺上述入口。发布件与生产部署需独立核验,不能从仓库测试推断。
26
27
 
27
28
  ## 2026-10-12 基线:八域 IDE 调用链
28
29
 
@@ -80,6 +81,8 @@ Entrorove 只治理通过技能入口执行的动作:路径、通行证、快
80
81
 
81
82
  `scope-connect <absolute repositoryRoot>` 是 CLI 编排入口,不改变这 100 项本地操作。它串联远端 `scope-runner-prepare/register/challenge/attest` 与已有真实快照、签名检查,为原账号与任务提交范围证据;不授予执行证据签名或写入权限。全部远端操作以实时 discovery 为准。
82
83
 
84
+ 当前增量操作(不改写上述历史分母)为:`peer-status`、`discipline-check`、`discipline-review`、`flow-cancel`、`flow-abandon`、`guarded-delete`、`guarded-move`。它们计入当前 107 项公开 schema。
85
+
83
86
  | 分区 | 精确操作名 | 证据位置 |
84
87
  | --- | --- | --- |
85
88
  | 核心 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,10 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.40 — 2026-10-15
4
+
5
+ - 修复 flow 终止与会话解绑、tsconfig 授权路径解析,并加入离线 unlock 自救入口。
6
+ - Fixed flow termination and session unbinding, tsconfig authorized-path resolution, and added an offline unlock recovery command.
7
+
3
8
  ## 0.1.39 — 2026-10-14
4
9
 
5
10
  - 增加受控需求队列,支持按优先级排队或在安全检查点插入;插入任务完成并回传真实确认后自动恢复原任务与原编号。
package/IDE-EXECUTION.md CHANGED
@@ -2,6 +2,8 @@ Install or update the managed copy with `npx @entrorove/skill install`. The inst
2
2
 
3
3
  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.
4
4
 
5
+ If the IDE guard itself blocks the management step, run `entrorove unlock` directly from the installed CLI. This break-glass command is offline and management-only: it does not call a hook, read a credential, accept the agreement, inspect the protocol or authorize project writes. Use it to inspect or terminate the original flow; `flow-abandon` releases its task binding so the original task can start again.
6
+
5
7
  Every invocation rereads that same credential file, including clients already running. After an authenticated account page supplies the current key and `configure` synchronizes it once, all tasks under the same OS user automatically use the new value. A revoked or expired key cannot obtain new permissions for itself: return to the authenticated account page and synchronize once through JSON stdin. Status recovery only queries the original request; it never rotates credentials or repeats the operation.
6
8
 
7
9
  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-07.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`.
package/SKILL.md CHANGED
@@ -5,21 +5,23 @@ description: Execute an engineering task through Entrorove, preserving its origi
5
5
 
6
6
  # entrorove · 熵巡
7
7
 
8
- Version: 0.1.39.
8
+ Version: 0.1.40.
9
9
 
10
10
  Entrorove applies to the whole task once selected. Reading this document or calling an entry once does not execute the task. On every later turn, including after compaction, continue the original task and its required next operation before dependent work. Keep the original goal, scope, account, flow, cloud task and failure records. Never describe planning, a reported step or model output as verified delivery.
11
11
 
12
12
  ## Required execution
13
13
 
14
- 1. Install `npx --yes --prefer-online @entrorove/skill@latest install`. The managed installer installs the skill and Codex lifecycle guard. Review and trust Entrorove in Codex `/hooks`; installation alone is not proof that hooks run. New execution from a detected IDE requires the exact live host approval. An unsupported IDE adapter, missing hook callback, missing credential, or failed discovery stops execution explicitly. Do not silently use native tools instead.
14
+ 1. Install `npx --yes --prefer-online @entrorove/skill@latest install`. The managed installer installs the skill and Codex lifecycle guard. The live host callback must verify that its hook runs. If the guard blocks a management call, run the installed `entrorove unlock` command directly in a terminal to open a short-lived, offline management-only recovery path. New execution from a detected IDE requires the exact live host approval. An unsupported IDE adapter, missing hook callback, missing credential, or failed discovery stops execution explicitly. Do not silently use native tools instead.
15
15
  2. Read [IDE-EXECUTION.md](IDE-EXECUTION.md) for the exact entry, cloud callbacks, account agreement and operation contracts. Use the stored account credential; never put it in arguments, URLs, project files, output or fixtures. Entrorove is independent of other systems.
16
16
  3. Discover `capabilities`, then invoke `start` with the actual goal, task type, risk and scope facts. An enabled account returns a cloud choice before charging a model. Show that choice visibly using the host question tool. Confirmation calls its original callback; exit adopts the local route for this task. Never treat silence as permission, invent acceptance, or create both routes. Existing explicit authorization for this task persists.
17
17
  4. After real cloud acceptance, immediately display **云端任务 <taskNumber> · 已进入**. Preserve `taskNumber` verbatim: it is the homepage sandbox tab number. Keep its UUID `taskId` separately. Run the returned `cloud-watch`, show each real public event and its cursor, and continue the original accepted flow from status. A command log alone is insufficient. No accepted cloud task means no cloud ID or model-execution claim.
18
- 5. For an accepted `flowId`, execute the returned `nextStep` with its required payload. Discover the schema before constructing input. An empty payload is valid only when that operation's schema allows it. Reconnect and refresh only the original flow. A changed snapshot is a binding failure to investigate; do not invent a fresh successful baseline, rebuild a flow to evade it, or remove the old failure.
18
+ 5. For an accepted `flowId`, execute the returned `nextStep` with its required payload. Discover the schema before constructing input. An empty payload is valid only when that operation's schema allows it. Reconnect and refresh only the original flow. A changed snapshot is a binding failure to investigate; do not invent a fresh successful baseline, rebuild a flow to evade it, or erase the old failure. A terminal flow may release its task slot only through the explicit `flow-abandon` operation.
19
19
  6. All governed mutations use the original authority: `local guarded-write`, `local guarded-delete`, `local guarded-move`, or the original frozen approved runner plan. Native patches, scripts, terminal writes, database mutations and unknown MCP operations must not substitute for the controlled entry. **A failed gate or unknown outcome must never be replaced with another executor or reported as local success.** Query the original request read-only, repair the cause, and obtain the original flow's valid authority before proceeding.
20
20
  7. Read [REUSE-DISCIPLINE.md](REUSE-DISCIPLINE.md) when modifying an existing system. The comparison baseline is the existing implementation in that same repository. It never means another project, another account, or copying external systems.
21
21
  8. Before finishing, submit actual independent execution evidence, run the server's verification and verdict, and display the confirmed evaluation acknowledgement. Preserve `receiptId`, `requestId`, execution generation and evaluation digest. “已回传” requires `acknowledgement.stored: true`. Missing or failed feedback remains visible. Completed verified tasks automatically release superseded Entrorove snapshot copies, preserving origin and final rollback images; active, waiting and unknown tasks retain recovery data.
22
22
 
23
+ If the original flow is terminal, invoke the discovered `flow-cancel` or `flow-abandon` operation with that same `flowId`. `flow-cancel` records the termination and keeps the task bound for read-only recovery. `flow-abandon` clears acceptance and checkpoint state, unbinds the task and returns `nextStep.operation: "start"` so the original task can be routed again. The local equivalents are `local flow-cancel` and `local flow-abandon`; they never reopen the old flow.
24
+
23
25
  The Codex guard preserves this binding over subsequent prompts and compaction and denies unsupported native mutations before execution. Hooks are host guardrails, not an operating-system security boundary; unsupported tool paths or a disabled/untrusted hook cannot be described as enforced. An explicit user decision to stop using the skill ends its governance and never becomes verified Entrorove delivery. Do not infer such a decision from quoted screenshots or a service failure.
24
26
 
25
27
  When a new user requirement arrives while the original task is unfinished, store its exact text in the private IDE queue. Use `queue` with `{"action":"status"}` to inspect it, then `{"action":"route","messageId":"...","mode":"queued"|"interrupt","priority":"background"|"normal"|"high"|"urgent"}`. An urgent interrupt is admitted only at a completed controlled-call checkpoint; it saves the original flow, task UUID, homepage number and request ID. After the inserted task reaches a verified end acknowledgement, the queue restores that exact parent context and its next operation. Unclassified text, unresolved calls and unknown outcomes block ending the turn. Queue state is bounded, account/session/repository-bound and survives compaction and process restart; it cannot wake a powered-off host.
@@ -0,0 +1,56 @@
1
+ import { promises as fs } from 'node:fs';
2
+ import { randomUUID } from 'node:crypto';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+
6
+ export const breakGlassSchema = 'entrorove.ide-break-glass/1.0';
7
+ export const breakGlassTtlMs = 15 * 60 * 1000;
8
+
9
+ function homeDirectory(home) {
10
+ if (typeof home !== 'string' || !path.isAbsolute(home)) throw new Error('Break-glass home must be absolute');
11
+ const root = process.platform === 'win32' ? process.env.LOCALAPPDATA : home;
12
+ if (process.platform === 'win32' && (!root || !path.isAbsolute(root))) throw new Error('LOCALAPPDATA must be absolute');
13
+ return path.join(root, ...(process.platform === 'darwin'
14
+ ? ['Library', 'Application Support', 'Entrorove', 'ide'] : process.platform === 'win32'
15
+ ? ['Entrorove', 'ide'] : ['.local', 'share', 'entrorove', 'ide']));
16
+ }
17
+
18
+ async function ensurePrivateDirectory(directory, home) {
19
+ const homeStat = await fs.lstat(home).catch(() => null);
20
+ if (!homeStat?.isDirectory() || homeStat.isSymbolicLink()
21
+ || process.platform !== 'win32' && homeStat.uid !== process.getuid()) throw new Error('Break-glass home must be an owned real directory');
22
+ let current = process.platform === 'win32' ? path.parse(directory).root : await fs.realpath(home);
23
+ const relative = path.relative(process.platform === 'win32' ? path.parse(directory).root : home, directory);
24
+ if (relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) throw new Error('Break-glass state escapes its home');
25
+ for (const part of relative.split(path.sep).filter(Boolean)) {
26
+ current = path.join(current, part);
27
+ await fs.mkdir(current, { mode: 0o700 }).catch(cause => { if (cause.code !== 'EEXIST') throw cause; });
28
+ const stat = await fs.lstat(current);
29
+ if (!stat.isDirectory() || stat.isSymbolicLink()
30
+ || process.platform !== 'win32' && (stat.uid !== process.getuid() || stat.mode & 0o077)) {
31
+ throw new Error('Break-glass state must be an owned private directory');
32
+ }
33
+ }
34
+ if (process.platform !== 'win32') await fs.chmod(current, 0o700);
35
+ }
36
+
37
+ export function breakGlassPath(home = os.homedir()) {
38
+ return path.join(homeDirectory(home), 'break-glass.json');
39
+ }
40
+
41
+ export async function writeBreakGlass(home = os.homedir(), now = Date.now()) {
42
+ if (!Number.isFinite(now)) throw new Error('Break-glass clock is invalid');
43
+ const file = breakGlassPath(home), directory = path.dirname(file);
44
+ await ensurePrivateDirectory(directory, home);
45
+ const value = { schemaVersion: breakGlassSchema, mode: 'management-only', createdAt: new Date(now).toISOString(),
46
+ expiresAt: new Date(now + breakGlassTtlMs).toISOString() };
47
+ const stage = `${file}.${randomUUID()}.tmp`;
48
+ await fs.writeFile(stage, `${JSON.stringify(value)}\n`, { flag: 'wx', mode: 0o600 });
49
+ try {
50
+ const prior = await fs.lstat(file).catch(cause => { if (cause.code === 'ENOENT') return null; throw cause; });
51
+ if (prior && (!prior.isFile() || prior.isSymbolicLink() || prior.nlink !== 1)) throw new Error('Break-glass state must be a private regular file');
52
+ await fs.rename(stage, file);
53
+ if (process.platform !== 'win32') await fs.chmod(file, 0o600);
54
+ } finally { await fs.rm(stage, { force: true }); }
55
+ return { offline: true, network: false, hook: 'not-required', protocol: 'not-checked', mode: value.mode, expiresAt: value.expiresAt };
56
+ }
package/cli.mjs CHANGED
@@ -20,6 +20,7 @@ import { watchCloudTask } from './cloud-watch-client.mjs';
20
20
  import { queryAutomaticFeedback } from './automatic-feedback-client.mjs';
21
21
  import { installIdeHooks } from './ide-install.mjs';
22
22
  import { assertIdeExecution } from './ide-session.mjs';
23
+ import { writeBreakGlass } from './break-glass.mjs';
23
24
  const markerName = '.entrorove-managed.json';
24
25
  const installFiles = ['SKILL.md', 'CAPABILITY-MAP.md', 'AGREEMENT.md', 'manifest.json', 'IDE-EXECUTION.md', 'REUSE-DISCIPLINE.md'];
25
26
  const markerSchema = 'entrorove.managed-install/1.0';
@@ -62,13 +63,8 @@ async function sourceBundle() {
62
63
  return { manifest, content, hashes };
63
64
  }
64
65
 
65
- function globalInstallPath(home = os.homedir()) {
66
- return path.join(home, '.codex', 'skills', 'entrorove');
67
- }
68
-
69
- function agreementPath(home = os.homedir()) {
70
- return path.join(path.dirname(credentialPath(home)), 'agreement.json');
71
- }
66
+ function globalInstallPath(home = os.homedir()) { return path.join(home, '.codex', 'skills', 'entrorove'); }
67
+ function agreementPath(home = os.homedir()) { return path.join(path.dirname(credentialPath(home)), 'agreement.json'); }
72
68
 
73
69
  export function agreementInteraction() {
74
70
  return {
@@ -431,7 +427,9 @@ async function submitReview(data) {
431
427
 
432
428
  async function main() {
433
429
  const [command, ...args] = process.argv.slice(2);
434
- if (command === 'install' && args.length === 0) {
430
+ if (command === 'unlock' && args.length === 0) {
431
+ process.stdout.write(`${JSON.stringify(await writeBreakGlass())}\n`);
432
+ } else if (command === 'install' && args.length === 0) {
435
433
  process.stdout.write(JSON.stringify(await install()) + '\n');
436
434
  } else if (command === 'configure' && args.length === 0) {
437
435
  const data = await stdinJson();
@@ -489,7 +487,7 @@ async function main() {
489
487
  } else if (command === 'local') {
490
488
  await runLocal(args);
491
489
  } else {
492
- throw new Error('Usage: entrorove install | configure | agreement | invoke | queue | native <domain> <operation> | notifications configure|status|test | scope-connect <repositoryRoot> | host-message [adapterModulePath] | cloud-task | cloud-watch | cloud-status | feedback <requestId> [generation] | status <requestId> | flows | review | local (JSON on stdin)');
490
+ throw new Error('Usage: entrorove unlock | install | configure | agreement | invoke | queue | native <domain> <operation> | notifications configure|status|test | scope-connect <repositoryRoot> | host-message [adapterModulePath] | cloud-task | cloud-watch | cloud-status | feedback <requestId> [generation] | status <requestId> | flows | review | local (JSON on stdin)');
493
491
  }
494
492
  }
495
493
  if (process.argv[1] && realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url))) { const { captureIdeResults } = await import('./ide-results.mjs');
package/ide-command.mjs CHANGED
@@ -37,8 +37,11 @@ export function sdkCommand(parsed, runtime) {
37
37
  while (['--yes', '--prefer-online'].includes(words[0])) words.shift();
38
38
  if (!['@entrorove/skill', '@entrorove/skill@latest', `@entrorove/skill@${runtime.version}`].includes(words.shift())) return null;
39
39
  args = words;
40
+ } else if (path.basename(words[0]) === 'entrorove') {
41
+ args = words.slice(1);
40
42
  } else return null;
41
43
  const simple = new Set(['invoke', 'cloud-task', 'cloud-status', 'cloud-watch', 'agreement', 'configure', 'queue']);
44
+ if (args[0] === 'unlock' && args.length === 1 && !parsed.input) return { args };
42
45
  if (simple.has(args[0]) && args.length === 1 && parsed.input) return { args, input: parsed.input };
43
46
  if (['status', 'feedback'].includes(args[0]) && /^[a-f0-9]{64}$/u.test(args[1] ?? '') && [2,3].includes(args.length)) return { args };
44
47
  if (['flows', 'install'].includes(args[0]) && args.length === 1 && !parsed.input) return { args };
package/ide-hook.mjs CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import { realpathSync } from 'node:fs';
3
3
  import { fileURLToPath } from 'node:url';
4
- import { ideSession, ideRuntime, bindIdeAccount, ideContext, ideDigest } from './ide-session.mjs';
4
+ import { ideSession, ideRuntime, bindIdeAccount, ideContext, ideDigest, readBreakGlass } from './ide-session.mjs';
5
5
  import { literalCommand, sdkCommand, readCommand, renderSdkCommand, installerCommand } from './ide-command.mjs';
6
6
  import { inspectIdeCodeMode } from './ide-code-mode.mjs';
7
7
  import { pathToFileURL } from 'node:url';
@@ -14,13 +14,49 @@ const output = (event, extra = {}) => ({ hookSpecificOutput: { hookEventName: ev
14
14
  const deny = reason => output('PreToolUse', { permissionDecision: 'deny', permissionDecisionReason: reason });
15
15
  const publicId = value => typeof value === 'string' && /^[A-Za-z0-9_-]{1,200}$/u.test(value);
16
16
  const readTools = new Set(['wait', 'view_image', 'list_mcp_resources', 'list_mcp_resource_templates', 'read_mcp_resource', 'web_search', 'update_plan', 'request_user_input', 'request_user_input_async']);
17
+ const unlockCommand = 'entrorove unlock';
18
+ const managementOperations = new Set(['capabilities', 'help', 'start', 'continue', 'task-describe', 'flow-cancel', 'flow-abandon', 'scope-connect', 'status', 'feedback', 'cloud-status', 'cloud-watch', 'queue', 'unlock']);
19
+ function operationOf(call) {
20
+ if (!call) return null;
21
+ if (call.args[0] === 'invoke') return call.input?.operation ?? null;
22
+ if (call.args[0] === 'local') return call.args[1] ?? null;
23
+ return call.args[0] ?? null;
24
+ }
25
+ function isManagementCall(call) { return managementOperations.has(operationOf(call)); }
26
+ function resetBlockStreak(state) { state.blockStreak = 0; delete state.lastBlockKey; }
27
+ function blockedReason(state, event, reason) {
28
+ const key = ideDigest({ tool: event.tool_name, phase: state.phase, reason });
29
+ const repeated = state.lastBlockKey === key;
30
+ state.blockStreak = repeated ? (state.blockStreak ?? 0) + 1 : 1;
31
+ state.lastBlockKey = key;
32
+ const mechanism = `机制:${state.stateFile ?? '会话状态文件'} 当前 phase=${state.phase}${state.pendingCall ? `,pendingCall=${state.pendingCall.toolUseId}` : ''};该阶段只放行 Entrorove 管理调用,业务与写操作需先建立原 flow 并取得 nextStep。`;
33
+ const repeat = repeated && state.blockStreak >= 2 ? `同一阻断已出现 ${state.blockStreak} 次,禁止再次检查:说明机制、给出绕过方案并执行绕过。` : '';
34
+ return `${reason} ${mechanism} ${repeat}\n绕过方案(离线、无网络、无协议、不经 hook,终端直接运行):\n${unlockCommand}\n执行后重试原管理调用;项目写入仍需原任务授权。`;
35
+ }
36
+ const denyBlocked = (state, event, reason) => deny(blockedReason(state, event, reason));
37
+ function lifecycleResult(value, depth = 0) {
38
+ if (!value || typeof value !== 'object' || depth > 4) return null;
39
+ if (value.operation === 'flow-cancel' || value.operation === 'flow-abandon') return value;
40
+ for (const key of ['output', 'result']) {
41
+ const nested = lifecycleResult(value[key], depth + 1);
42
+ if (nested) return nested;
43
+ }
44
+ return null;
45
+ }
17
46
  function collectResult(state, values, local) {
18
47
  const messages = [];
19
48
  for (const result of values) {
20
49
  const value = result.output ?? result;
21
- if (typeof value.requestId === 'string' && /^[a-f0-9]{64}$/u.test(value.requestId)) state.requestId = value.requestId;
22
- if (!local && value.flowId && value.requestId) state.remoteRequestId = value.requestId;
23
- if (publicId(value.flowId)) {
50
+ const lifecycleValue = lifecycleResult(result) ?? value;
51
+ const lifecycle = lifecycleValue.operation === 'flow-cancel' || lifecycleValue.operation === 'flow-abandon';
52
+ const releasedLifecycle = lifecycleValue.operation === 'flow-abandon' && lifecycleValue.unbound === true && lifecycleValue.flowId === state.flowId;
53
+ if (releasedLifecycle) {
54
+ delete state.flowId; delete state.taskId; delete state.taskNumber; delete state.requestId; delete state.remoteRequestId;
55
+ delete state.nextStep; state.phase = 'entry'; state.feedbackAcknowledged = false;
56
+ } else if (lifecycleValue.operation === 'flow-cancel' && lifecycleValue.flowId === state.flowId) state.phase = 'ended';
57
+ if (!releasedLifecycle && typeof value.requestId === 'string' && /^[a-f0-9]{64}$/u.test(value.requestId)) state.requestId = value.requestId;
58
+ if (!releasedLifecycle && !local && value.flowId && value.requestId) state.remoteRequestId = value.requestId;
59
+ if (!releasedLifecycle && publicId(value.flowId)) {
24
60
  if (state.flowId && state.flowId !== value.flowId) throw new Error('Returned flow replaced the original task');
25
61
  state.flowId = value.flowId;
26
62
  }
@@ -29,15 +65,15 @@ function collectResult(state, values, local) {
29
65
  state.taskId = value.taskId; state.taskNumber = value.taskNumber;
30
66
  }
31
67
  const nextStep = value.nextStep ?? value.result?.nextStep ?? value.result?.result?.nextStep;
32
- if (value.status === 'needs-input' || value.result?.status === 'needs-input' || value.interaction || value.result?.interaction) state.phase = 'needs-input';
33
- else if (['blocked', 'failed', 'incomplete'].includes(value.status) || ['blocked', 'failed', 'incomplete'].includes(value.result?.status)
34
- || ['blocked', 'failed'].includes(value.taskStatus)) state.phase = 'blocked';
35
- else if (nextStep || value.nextAction) state.phase = 'continuing';
36
- else if (!local && (value.reports?.length || value.result?.reports?.length || ['completed','cancelled'].includes(value.taskStatus))
68
+ if (!lifecycle && (value.status === 'needs-input' || value.result?.status === 'needs-input' || value.interaction || value.result?.interaction)) state.phase = 'needs-input';
69
+ else if (!lifecycle && (['blocked', 'failed', 'incomplete'].includes(value.status) || ['blocked', 'failed', 'incomplete'].includes(value.result?.status)
70
+ || ['blocked', 'failed'].includes(value.taskStatus))) state.phase = 'blocked';
71
+ else if (!lifecycle && (nextStep || value.nextAction)) state.phase = 'continuing';
72
+ else if (!lifecycle && !local && (value.reports?.length || value.result?.reports?.length || ['completed','cancelled'].includes(value.taskStatus))
37
73
  && result.feedback?.acknowledgement?.stored === true) state.phase = 'ended';
38
- else if (local && value.status === 'verified') state.phase = 'continuing';
39
- else if (value.status === 'started' || value.status === 'succeeded') state.phase = 'continuing';
40
- if (nextStep) state.nextStep = { operation: nextStep.operation, payload: nextStep.payload };
74
+ else if (!lifecycle && local && value.status === 'verified') state.phase = 'continuing';
75
+ else if (!lifecycle && (value.status === 'started' || value.status === 'succeeded')) state.phase = 'continuing';
76
+ if (!lifecycle && nextStep) state.nextStep = { operation: nextStep.operation, payload: nextStep.payload };
41
77
  const notices = [...(result.ideMessages ?? []), ...(value.ideMessages ?? []),
42
78
  ...(value.schemaVersion === 'entrorove.ide-event/1.0' ? [value.message] : []), ...(value.events ?? []).map(item => item.message)];
43
79
  for (const message of notices) {
@@ -51,6 +87,7 @@ function collectResult(state, values, local) {
51
87
  return messages;
52
88
  }
53
89
  async function beforeTool(event, state, home) {
90
+ const unlocked = await readBreakGlass(home);
54
91
  const command = event.tool_input?.command ?? event.tool_input?.cmd, parsed = literalCommand(command);
55
92
  const isShell = ['Bash', 'exec_command', 'shell_command'].includes(event.tool_name);
56
93
  const installer = isShell && installerCommand(parsed);
@@ -62,26 +99,33 @@ async function beforeTool(event, state, home) {
62
99
  return output('PreToolUse', { permissionDecision: 'allow', updatedInput: { ...event.tool_input, command: installer,
63
100
  ...(Object.hasOwn(event.tool_input ?? {}, 'cmd') ? { cmd: installer } : {}) } });
64
101
  }
65
- const namedSdk = parsed?.words.some(word => /^@entrorove\/skill(?:@[^ ]+)?$/u.test(word)
102
+ const namedSdk = parsed?.words.some(word => word === 'entrorove' || /^@entrorove\/skill(?:@[^ ]+)?$/u.test(word)
66
103
  || /(?:@entrorove\/skill|entrorove-skill)\/cli\.mjs$/u.test(word));
67
104
  if (!state.active && !namedSdk) return {};
68
105
  const runtime = isShell && parsed ? await ideRuntime(home) : null;
69
106
  const call = runtime && sdkCommand(parsed, runtime);
70
107
  if (call) {
71
108
  state.active = true;
72
- if (call.args[0] !== 'configure') await bindIdeAccount(state, home);
109
+ if (call.args[0] !== 'configure' && call.args[0] !== 'unlock') await bindIdeAccount(state, home);
110
+ if (state.phase === 'setup' && !unlocked && !isManagementCall(call)) return denyBlocked(state, event, 'Entrorove:自举阶段只放行 Entrorove 管理调用(建 flow、改目标、scope-connect、capabilities、status、queue 等);业务与写操作需先建立原 flow。');
111
+ if (call.args[0] === 'unlock') {
112
+ resetBlockStreak(state);
113
+ const rewritten = renderSdkCommand(call, runtime, state.sessionId);
114
+ return output('PreToolUse', { permissionDecision: 'allow', updatedInput: { ...event.tool_input, command: rewritten,
115
+ ...(Object.hasOwn(event.tool_input ?? {}, 'cmd') ? { cmd: rewritten } : {}) }, additionalContext: `${ideContext(state)}离线管理解锁已放行。` });
116
+ }
73
117
  const newStart = call.args[0] === 'cloud-task' || call.args[0] === 'invoke' && ['start','cloud-task'].includes(call.input?.operation);
74
- if (newStart && (state.flowId || state.taskId) && state.phase !== 'ended') return deny('Entrorove:必须继续原任务,禁止用第二次 start 或 cloud-task 绕过原门禁。');
118
+ if (newStart && (state.flowId || state.taskId) && state.phase !== 'ended') return denyBlocked(state, event, 'Entrorove:必须继续原任务,禁止用第二次 start 或 cloud-task 绕过原门禁。');
75
119
  const discovery = ['invoke','local'].includes(call.args[0]) && ['capabilities','help'].includes(call.input?.operation ?? call.args[1]);
76
- const observer = discovery || ['status','feedback','cloud-status','configure','agreement','install','queue'].includes(call.args[0]);
77
- if (unclassifiedIdeRequirements(state) && !observer) return deny('Entrorove:新需求已保存,先用 queue status 读取并按轻重缓急 route 分类;禁止丢弃新需求或覆盖原任务。');
78
- if (newStart && !checkQueuedStart(state,call)) return deny('Entrorove:新任务目标必须与实际进入队列的用户原文一致。');
79
- if (call.args[0] === 'local' && !discovery && !state.flowId) return deny('Entrorove:本地执行前必须先受理原任务并绑定账号、flowId 与仓库;不能直接使用其它流程的通行证。');
80
- if (state.pendingCall && !observer) return deny('Entrorove:原调用尚未收到结束回执;先只读恢复其状态,禁止并发重发。');
120
+ const observer = discovery || ['status','feedback','cloud-status','configure','agreement','install','queue','flows'].includes(call.args[0]);
121
+ if (unclassifiedIdeRequirements(state) && !observer && !(unlocked && isManagementCall(call))) return denyBlocked(state, event, 'Entrorove:新需求已保存,先用 queue status 读取并按轻重缓急 route 分类;禁止丢弃新需求或覆盖原任务。');
122
+ if (newStart && !checkQueuedStart(state,call)) return denyBlocked(state, event, 'Entrorove:新任务目标必须与实际进入队列的用户原文一致。');
123
+ if (call.args[0] === 'local' && !discovery && !state.flowId) return denyBlocked(state, event, 'Entrorove:本地执行前必须先受理原任务并绑定账号、flowId 与仓库;不能直接使用其它流程的通行证。');
124
+ if (state.pendingCall && !observer && !(unlocked && isManagementCall(call))) return denyBlocked(state, event, 'Entrorove:原调用尚未收到结束回执;先只读恢复其状态,禁止并发重发。');
81
125
  const input = call.args[0] === 'invoke' ? call.input?.input : call.input;
82
- if (input?.flowId && state.flowId && input.flowId !== state.flowId || input?.taskId && state.taskId && ['cloud-watch','cloud-status'].includes(call.args[0]) && input.taskId !== state.taskId) return deny('Entrorove:调用与本 IDE 任务的原编号不一致。');
126
+ if (input?.flowId && state.flowId && input.flowId !== state.flowId || input?.taskId && state.taskId && ['cloud-watch','cloud-status'].includes(call.args[0]) && input.taskId !== state.taskId) return denyBlocked(state, event, 'Entrorove:调用与本 IDE 任务的原编号不一致。');
83
127
  if (call.args[0] === 'scope-connect' && call.args[1] !== state.repositoryRoot
84
- || call.args[0] === 'local' && (input?.repositoryRoot && input.repositoryRoot !== state.repositoryRoot || call.args[2] && call.args[2] !== state.repositoryRoot)) return deny('Entrorove:调用不能替换本任务的实际仓库。');
128
+ || call.args[0] === 'local' && (input?.repositoryRoot && input.repositoryRoot !== state.repositoryRoot || call.args[2] && call.args[2] !== state.repositoryRoot)) return denyBlocked(state, event, 'Entrorove:调用不能替换本任务的实际仓库。');
85
129
  let requestId;
86
130
  if (newStart) state.taskGoal = call.args[0] === 'cloud-task' ? call.input?.goal : call.input?.input?.task?.goal;
87
131
  if (call.args[0] === 'invoke' && !call.input.operation.startsWith('cloud-')) {
@@ -91,21 +135,22 @@ async function beforeTool(event, state, home) {
91
135
  const pending = { toolUseId: event.tool_use_id, inputDigest: ideDigest(call), command: call.args[0], ...(requestId ? { requestId } : {}) };
92
136
  if (observer) {
93
137
  state.readCalls ??= {};
94
- if (Object.keys(state.readCalls).length >= 32) return deny('Entrorove:只读恢复尚未返回,请等待原调用。');
138
+ if (Object.keys(state.readCalls).length >= 32) return denyBlocked(state, event, 'Entrorove:只读恢复尚未返回,请等待原调用。');
95
139
  state.readCalls[event.tool_use_id] = pending;
96
140
  } else state.pendingCall = pending;
97
141
  const installation = call.args[0] === 'install' && parsed.words.some(word => ['npx','npx.cmd'].includes(word));
98
142
  const rewritten = renderSdkCommand(call, runtime, state.sessionId);
143
+ resetBlockStreak(state);
99
144
  return output('PreToolUse', { permissionDecision: 'allow', ...(installation ? {} : { updatedInput: { ...event.tool_input, command: rewritten,
100
145
  ...(Object.hasOwn(event.tool_input ?? {}, 'cmd') ? { cmd: rewritten } : {}) } }), additionalContext: ideContext(state) });
101
146
  }
102
147
  if (!state.active) return {};
103
148
  if (['exec','functions.exec'].includes(event.tool_name)) {
104
149
  const current = await ideRuntime(home), code = typeof event.tool_input === 'string' ? event.tool_input : event.tool_input?.code;
105
- return inspectIdeCodeMode(code, current) ? output('PreToolUse', { additionalContext: ideContext(state) }) : deny('Entrorove:代码模式含未批准的原生执行或动态工具输入;请使用原任务的受控入口。');
150
+ return inspectIdeCodeMode(code, current) ? output('PreToolUse', { additionalContext: ideContext(state) }) : denyBlocked(state, event, 'Entrorove:代码模式含未批准的原生执行或动态工具输入;请使用原任务的受控入口。');
106
151
  }
107
152
  if (isShell && readCommand(parsed) || readTools.has(event.tool_name)) return output('PreToolUse', { additionalContext: ideContext(state) });
108
- return deny(`Entrorove:已阻止原生 ${event.tool_name ?? 'unknown'} 调用。当前阶段 ${state.phase}${state.taskNumber ? `,云端任务 ${state.taskNumber}` : ''}。写入、删除、移动及不确定的终端操作必须使用原任务的 guarded-write/guarded-delete/guarded-move 或已批准的执行计划;门禁未通过不得改换执行器。`);
153
+ return denyBlocked(state, event, `Entrorove:已阻止原生 ${event.tool_name ?? 'unknown'} 调用。当前阶段 ${state.phase}${state.taskNumber ? `,云端任务 ${state.taskNumber}` : ''}。写入、删除、移动及不确定的终端操作必须使用原任务的 guarded-write/guarded-delete/guarded-move 或已批准的执行计划。`);
109
154
  }
110
155
  export async function runIdeHook(event, { home } = {}) {
111
156
  return ideSession(event, async state => {
@@ -157,10 +202,10 @@ export async function runIdeHook(event, { home } = {}) {
157
202
  if (resumed) messages.push(resumed);
158
203
  return { ...output(name, { additionalContext: ideContext(state) }), ...(messages.length ? { systemMessage: messages.join('\n') } : {}) };
159
204
  }
160
- if (name==='Stop' && state.active) {
205
+ if (name==='Stop' && state.active && !(await readBreakGlass(home))) {
161
206
  const resumed = await advanceIdeQueue(state,home);
162
207
  if (resumed || unclassifiedIdeRequirements(state) || unfinishedIdeQueue(state) && !['blocked','needs-input'].includes(state.phase)
163
- || !['ended','blocked','needs-input','setup'].includes(state.phase)) return {decision:'block',reason:`${ideContext(state)}${ideQueueContext(state)}${resumed}原任务或队列尚未取得结束回执;继续可运行工作,真实阻断必须明确报告。`};
208
+ || !['ended','blocked','needs-input','setup'].includes(state.phase)) return {decision:'block',reason:blockedReason(state, event, `${ideContext(state)}${ideQueueContext(state)}${resumed}原任务或队列尚未取得结束回执;继续可运行工作,真实阻断必须明确报告。`)};
164
209
  }
165
210
  return state.active && ['SessionStart','UserPromptSubmit','PostCompact','PreCompact'].includes(name) ? output(name, { additionalContext: `${ideContext(state)}${ideQueueContext(state)}` }) : {};
166
211
  }, home);
@@ -176,7 +221,7 @@ async function main() {
176
221
  if (process.argv[1] && realpathSync(process.argv[1]) === fileURLToPath(import.meta.url)) main().catch(() => {
177
222
  // A non-zero generic exit may fail open in the host. Always return a supported
178
223
  // explicit denial, without including arguments, prompts, credentials or paths.
179
- const reason = 'Entrorove 安全检查失败;本次工具调用已阻止,请修复原检查。';
224
+ const reason = `Entrorove 安全检查失败;本次工具调用已阻止。可复制解锁命令:${unlockCommand}`;
180
225
  const result = incomingEvent === 'Stop' ? { decision: 'block', reason } : incomingEvent === 'PreToolUse' ? deny(reason)
181
226
  : { continue: false, stopReason: reason, systemMessage: reason };
182
227
  process.stdout.write(`${JSON.stringify(result)}\n`);
package/ide-install.mjs CHANGED
@@ -5,7 +5,7 @@ import { ideDirectory, ideDigest, readIdeJson, writeIdeJson, ideError } from './
5
5
  import { withStateLock } from './local-state-lock.mjs';
6
6
 
7
7
  const source = path.dirname(fileURLToPath(import.meta.url));
8
- const files = ['ide-hook.mjs', 'ide-session.mjs', 'ide-results.mjs', 'ide-queue.mjs', 'ide-command.mjs', 'ide-code-mode.mjs', 'remote-transport.mjs', 'local-credential.mjs', 'local-state-lock.mjs'];
8
+ const files = ['ide-hook.mjs', 'ide-session.mjs', 'break-glass.mjs', 'ide-results.mjs', 'ide-queue.mjs', 'ide-command.mjs', 'ide-code-mode.mjs', 'remote-transport.mjs', 'local-credential.mjs', 'local-state-lock.mjs'];
9
9
  const events = ['SessionStart', 'UserPromptSubmit', 'PreToolUse', 'PostToolUse', 'PostCompact', 'Stop'];
10
10
  const quote = text => `'${text.replace(/'/gu, "'\\''")}'`;
11
11
  function definition(node, file) {
package/ide-results.mjs CHANGED
@@ -14,9 +14,10 @@ export function parseIdeResults(response) {
14
14
  function resultMetadata(value, depth = 0) {
15
15
  if (!value || typeof value !== 'object' || depth > 3) return undefined;
16
16
  const metadata = {};
17
- for (const key of ['schemaVersion', 'requestId', 'flowId', 'status', 'taskId', 'taskNumber', 'taskStatus']) {
17
+ for (const key of ['schemaVersion', 'requestId', 'flowId', 'status', 'operation', 'termination', 'flowStatus', 'taskId', 'taskNumber', 'taskStatus']) {
18
18
  if (typeof value[key] === 'string' && value[key].length <= 200) metadata[key] = value[key];
19
19
  }
20
+ if (typeof value.unbound === 'boolean') metadata.unbound = value.unbound;
20
21
  for (const key of ['output', 'result']) if (value[key]) metadata[key] = resultMetadata(value[key], depth + 1);
21
22
  for (const key of ['nextStep', 'nextAction']) if (typeof value[key]?.operation === 'string') metadata[key] = { operation: value[key].operation };
22
23
  if (value.interaction) metadata.interaction = true;
package/ide-session.mjs CHANGED
@@ -5,6 +5,7 @@ import path from 'node:path';
5
5
  import { credentialDirectory, readCredential } from './local-credential.mjs';
6
6
  import { withStateLock } from './local-state-lock.mjs';
7
7
  import { requestTransport, parseTransportResponse } from './remote-transport.mjs';
8
+ import { breakGlassPath, breakGlassSchema, writeBreakGlass as writeOfflineBreakGlass } from './break-glass.mjs';
8
9
 
9
10
  export const ideDigest = value => createHash('sha256').update(typeof value === 'string' ? value : JSON.stringify(value)).digest('hex');
10
11
  export const ideError = (code, message) => Object.assign(new Error(message), { code });
@@ -32,15 +33,26 @@ export async function writeIdeJson(file, value) {
32
33
  await fs.writeFile(stage, `${JSON.stringify(value)}\n`, { flag: 'wx', mode: 0o600 });
33
34
  try { await fs.rename(stage, file); } finally { await fs.rm(stage, { force: true }); }
34
35
  }
36
+ export const writeBreakGlass = writeOfflineBreakGlass;
37
+ export async function readBreakGlass(home = os.homedir()) {
38
+ await ideDirectory(home, true);
39
+ const value = await readIdeJson(breakGlassPath(home));
40
+ if (!value || value.schemaVersion !== breakGlassSchema || value.mode !== 'management-only'
41
+ || typeof value.expiresAt !== 'string' || !Number.isFinite(Date.parse(value.expiresAt))
42
+ || Date.parse(value.expiresAt) <= Date.now()) return null;
43
+ return value;
44
+ }
35
45
  export async function ideSession(event, action, home = os.homedir()) {
36
46
  if (typeof event.session_id !== 'string' || !/^[A-Za-z0-9_-]{1,200}$/u.test(event.session_id) || !path.isAbsolute(event.cwd ?? '')) throw ideError('ENTROROVE_IDE_IDENTITY', 'Host hook requires its actual session and repository');
37
47
  const directory = await ideDirectory(home, true), file = path.join(directory, `session-${ideDigest(event.session_id)}.json`);
38
48
  return withStateLock(file, async () => {
39
49
  const root = await fs.realpath(event.cwd), prior = await readIdeJson(file);
40
50
  if (prior && (prior.schemaVersion !== 'entrorove.ide-session/1.0' || prior.sessionId !== event.session_id || prior.repositoryRoot !== root)) throw ideError('ENTROROVE_IDE_IDENTITY', 'A session cannot replace its original physical repository');
41
- const state = prior ?? { schemaVersion: 'entrorove.ide-session/1.0', sessionId: event.session_id, repositoryRoot: root, active: false, phase: 'entry', seen: [] };
51
+ const state = prior ?? { schemaVersion: 'entrorove.ide-session/1.0', sessionId: event.session_id, repositoryRoot: root, active: false, phase: 'entry', seen: [], blockStreak: 0 };
52
+ state.stateFile = file;
42
53
  const result = await action(state);
43
- await writeIdeJson(file, { ...state, observedAt: new Date().toISOString() });
54
+ const { stateFile, ...persisted } = state;
55
+ await writeIdeJson(file, { ...persisted, observedAt: new Date().toISOString() });
44
56
  return result;
45
57
  });
46
58
  }
package/local-cli.mjs CHANGED
@@ -12,6 +12,7 @@ import {
12
12
  LOCAL_LEDGER_OPERATIONS,
13
13
  LOCAL_RENEWAL_OPERATIONS, LOCAL_PROBE_OPERATIONS,
14
14
  LOCAL_TASK_ROUTING_OPERATIONS,
15
+ LOCAL_FLOW_LIFECYCLE_OPERATIONS,
15
16
  } from './local-execution.mjs';
16
17
  import { validateLocalInput } from './local-contracts.mjs';
17
18
  import { prepareMergeArtifacts } from './local-merge-artifacts.mjs';
@@ -58,6 +59,7 @@ const operations = {
58
59
  ...LOCAL_LEDGER_OPERATIONS, ...LOCAL_CHAIN_OPERATIONS, ...LOCAL_BUDGET_TRANSITION_OPERATIONS,
59
60
  ...LOCAL_RENEWAL_OPERATIONS, ...LOCAL_PROBE_OPERATIONS,
60
61
  ...LOCAL_TASK_ROUTING_OPERATIONS,
62
+ ...LOCAL_FLOW_LIFECYCLE_OPERATIONS,
61
63
  ...ARCHGUARD_LOCAL_OPERATIONS,
62
64
  ...LOCAL_BUDGET_HANDOFF_OPERATIONS,
63
65
  ...CALCTOOL_LOCAL_OPERATIONS,
@@ -15,6 +15,7 @@ import { array, binding, change, confirmation, enumeration, evidence, evidenceAr
15
15
  const schemas = {};
16
16
  const base = { repositoryRoot: text, home: text };
17
17
  const flow = { ...base, flowId: identifier };
18
+ const flowLifecycle = { ...base, flowId: identifier, reason: { ...text, minLength: 1, maxLength: 500, pattern: '\\S' } };
18
19
  const fixedRoundingPolicy = object({ decimalPlaces: { const: 2 }, mode: { const: 'half-away-from-zero' } }, ['decimalPlaces', 'mode']);
19
20
  const step = { ...flow, stepId: identifier };
20
21
  function add(names, properties, required = [], alternatives) {
@@ -24,6 +25,7 @@ function add(names, properties, required = [], alternatives) {
24
25
  for (const name of names.split('|')) schemas[name] = schema;
25
26
  }
26
27
  add('capabilities|help', {});
28
+ add('flow-cancel|flow-abandon', flowLifecycle, ['repositoryRoot', 'flowId']);
27
29
  add('snapshot-plan', { ...step, paths, snapshotId: identifier, contractDigest: nullable(sha256), handoffCandidate: HANDOFF_SNAPSHOT_CANDIDATE_SCHEMA }, ['repositoryRoot', 'flowId', 'stepId', 'paths']);
28
30
  add('snapshot-verify', { ...step, snapshotId: identifier }, ['repositoryRoot', 'flowId', 'stepId', 'snapshotId']);
29
31
  const gate = { ...step, taskId: identifier, mutationPass: record };
@@ -25,6 +25,7 @@ import { receiverClaim } from './local-task-handoff-overlay-wire.mjs';
25
25
  import { assertTaskHandoffActive, assertTaskHandoffSnapshotBinding, readTaskHandoffSnapshotBinding,
26
26
  withTaskHandoffIssueFence, withTaskHandoffWriteFence } from './local-task-handoff-fence.mjs';
27
27
  import { LOCAL_OPERATION_SCHEMAS } from './local-contracts.mjs';
28
+ import { LOCAL_FLOW_LIFECYCLE_OPERATIONS, flowCancel, flowAbandon } from './local-flow-lifecycle.mjs';
28
29
  export { LOCAL_OPERATION_SCHEMAS };
29
30
  export const LOCAL_SCHEMA = 'entrorove.local-execution/1.0';
30
31
  export const SNAPSHOT_SCHEMA = 'entrorove.snapshot/1.0';
@@ -314,3 +315,4 @@ export { LOCAL_RENEWAL_OPERATIONS, LOCAL_PROBE_OPERATIONS };
314
315
  export { branchCreate, branchList, branchSwitch, diffReport, preflight, ledgerQuery, appendLedgerRecord, chainInit, chainResume, chainStatus, chainAnswer };
315
316
  export { budgetAutoRenewRequest, budgetAutoRenew, budgetAutoRenewStop, probe, reassess };
316
317
  export { LOCAL_TASK_ROUTING_OPERATIONS, taskDescribe, messageRoute, messageAccept, taskResume };
318
+ export { LOCAL_FLOW_LIFECYCLE_OPERATIONS, flowCancel, flowAbandon };
@@ -0,0 +1,95 @@
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
+ import { settleTaskFlow } from './local-task-routing.mjs';
7
+
8
+ export const FLOW_LIFECYCLE_SCHEMA = 'entrorove.flow-lifecycle/1.0';
9
+ function fail(code, message) { throw Object.assign(new Error(message), { code }); }
10
+ function digest(value) { return createHash('sha256').update(value).digest('hex'); }
11
+ function canonical(value) {
12
+ if (Array.isArray(value)) return `[${value.map(canonical).join(',')}]`;
13
+ if (value && typeof value === 'object') return `{${Object.keys(value).sort().map(key => `${JSON.stringify(key)}:${canonical(value[key])}`).join(',')}}`;
14
+ return JSON.stringify(value);
15
+ }
16
+ function rootOf(value) {
17
+ if (typeof value !== 'string' || !path.isAbsolute(value)) fail('ENTROROVE_ROOT_INVALID', 'repositoryRoot must be absolute');
18
+ return fs.realpath(value);
19
+ }
20
+ function homeOf(value) {
21
+ const home = value ?? os.homedir();
22
+ if (typeof home !== 'string' || !path.isAbsolute(home)) fail('ENTROROVE_HOME_INVALID', 'home must be absolute');
23
+ return home;
24
+ }
25
+ async function privateDir(directory) {
26
+ await fs.mkdir(directory, { recursive: true, mode: 0o700 });
27
+ const stat = await fs.lstat(directory);
28
+ if (!stat.isDirectory() || stat.isSymbolicLink()) fail('ENTROROVE_STATE_INVALID', 'flow lifecycle state directory must be a real directory');
29
+ if (process.platform !== 'win32' && (stat.mode & 0o777) !== 0o700) await fs.chmod(directory, 0o700);
30
+ }
31
+ async function location(input) {
32
+ const root = await rootOf(input.repositoryRoot); const home = homeOf(input.home);
33
+ const repositoryDigest = digest(root).slice(0, 32);
34
+ const directory = path.join(home, '.entrorove', 'flow-lifecycle', repositoryDigest);
35
+ return { root, home, file: path.join(directory, 'state.json'), directory, repositoryDigest };
36
+ }
37
+ async function save(file, state) {
38
+ await privateDir(path.dirname(file));
39
+ const temporary = `${file}.${randomUUID()}.tmp`;
40
+ await fs.writeFile(temporary, `${canonical(state)}\n`, { flag: 'wx', mode: 0o600 });
41
+ await fs.rename(temporary, file);
42
+ if (process.platform !== 'win32') await fs.chmod(file, 0o600);
43
+ }
44
+ async function load(input) {
45
+ const place = await location(input); await privateDir(place.directory);
46
+ try {
47
+ const stat = await fs.lstat(place.file);
48
+ if (!stat.isFile() || stat.isSymbolicLink()) fail('ENTROROVE_STATE_INVALID', 'flow lifecycle state must be a regular file');
49
+ const state = JSON.parse(await fs.readFile(place.file, 'utf8'));
50
+ if (state.schemaVersion !== FLOW_LIFECYCLE_SCHEMA || state.repositoryDigest !== place.repositoryDigest
51
+ || !Array.isArray(state.flows) || !Array.isArray(state.records)) fail('ENTROROVE_STATE_INVALID', 'flow lifecycle state identity is invalid');
52
+ return { ...place, state };
53
+ } catch (error) {
54
+ if (error.code !== 'ENOENT') throw error;
55
+ return { ...place, state: { schemaVersion: FLOW_LIFECYCLE_SCHEMA, repositoryDigest: place.repositoryDigest, sequence: 0, flows: [], records: [] } };
56
+ }
57
+ }
58
+ function identifier(value, label) {
59
+ if (typeof value !== 'string' || !/^[A-Za-z0-9][A-Za-z0-9._:-]{0,199}$/.test(value)) fail('ENTROROVE_ID_INVALID', `${label} must be a safe identifier`);
60
+ return value;
61
+ }
62
+ function reason(value, operation) {
63
+ if (value === undefined) return operation === 'flow-abandon' ? 'flow-abandoned-by-owner' : 'flow-cancelled-by-owner';
64
+ if (typeof value !== 'string' || !value.trim() || value.length > 500 || /[\u0000-\u001f\u007f]/.test(value)) fail('ENTROROVE_INPUT_INVALID', 'reason must be 1 to 500 printable characters');
65
+ return value.trim();
66
+ }
67
+ async function runUnlocked(operation, input) {
68
+ const flowId = identifier(input.flowId, 'flowId'); const authority = await load(input);
69
+ const prior = authority.state.flows.find(flow => flow.flowId === flowId);
70
+ if (prior?.status === 'abandoned' || (prior?.status === 'cancelled' && operation === 'flow-cancel')) {
71
+ return { schemaVersion: 'entrorove.local-execution/1.0', status: 'reported', operation, flowId,
72
+ termination: prior.status === 'abandoned' ? 'abandoned' : 'cancelled', reason: prior.reason, unbound: prior.unbound === true, duplicate: true };
73
+ }
74
+ const now = new Date().toISOString(); const termination = operation === 'flow-abandon' ? 'abandoned' : 'cancelled';
75
+ const settledTask = await settleTaskFlow(input, operation);
76
+ const unbound = operation === 'flow-abandon';
77
+ const record = { flowId, status: termination, reason: reason(input.reason, operation), unbound, updatedAt: now };
78
+ const flows = [...authority.state.flows.filter(flow => flow.flowId !== flowId), record];
79
+ const event = { sequence: authority.state.sequence + 1, event: operation, flowId, at: now, reason: record.reason,
80
+ unbound: record.unbound, digest: digest(canonical({ operation, flowId, reason: record.reason, unbound: record.unbound })) };
81
+ const state = { ...authority.state, sequence: event.sequence, flows, records: [...authority.state.records, event] };
82
+ await save(authority.file, state);
83
+ return { schemaVersion: 'entrorove.local-execution/1.0', status: 'reported', operation, flowId,
84
+ termination, reason: record.reason, unbound: record.unbound,
85
+ ...(settledTask?.task ? { taskId: settledTask.task.taskId, taskStatus: settledTask.task.status } : {}),
86
+ progress: state.records, nextStep: record.unbound
87
+ ? { operation: 'start', instruction: 'The old local flow is released; start the original task again.' } : null };
88
+ }
89
+ export async function flowCancel(input) {
90
+ const place = await location(input); return withStateLock(place.file, () => runUnlocked('flow-cancel', input));
91
+ }
92
+ export async function flowAbandon(input) {
93
+ const place = await location(input); return withStateLock(place.file, () => runUnlocked('flow-abandon', input));
94
+ }
95
+ export const LOCAL_FLOW_LIFECYCLE_OPERATIONS = Object.freeze({ 'flow-cancel': flowCancel, 'flow-abandon': flowAbandon });
@@ -9,15 +9,20 @@ function literal(value, field) {
9
9
  }
10
10
  return value;
11
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');
12
+ function within(directory, value, allowRoot = false, field = 'path') {
13
+ const raw = literal(value, field);
14
+ const resolved = path.posix.normalize(path.posix.join(directory, raw)).replace(/\/+$/, '');
15
+ const reason = resolved === '..' || resolved.startsWith('../') || resolved.startsWith('/')
16
+ ? 'the resolved path is outside the repository root'
17
+ : resolved.split('/').some(part => ['.git', '.entrorove', '.aimlock', '.archguard'].includes(part))
18
+ ? 'the resolved path enters a reserved control directory'
19
+ : null;
20
+ if (reason) {
21
+ throw new Error(`Scope tsconfig path escapes the authorized repository: ${JSON.stringify(raw)} resolves to ${JSON.stringify(resolved || '.')} from ${JSON.stringify(directory || '.')} because ${reason}`);
17
22
  }
18
23
  if (resolved === '.') {
19
24
  if (!allowRoot)
20
- throw new Error('Scope tsconfig mapping requires a literal candidate within the repository');
25
+ throw new Error(`Scope tsconfig mapping requires a literal candidate within the repository: ${JSON.stringify(raw)} resolves to the repository root from ${JSON.stringify(directory || '.')}`);
21
26
  return '';
22
27
  }
23
28
  return resolved;
@@ -149,14 +154,14 @@ export async function scopeTsconfigImports(read, fromFile, configPath) {
149
154
  const extended = literal(config.extends, 'extends');
150
155
  if (!extended.startsWith('./') && !extended.startsWith('../'))
151
156
  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);
157
+ inherited = await load(within(directory, extended, false, 'extends'), new Set([...ancestors, relative]), depth + 1);
153
158
  }
154
159
  const options = config.compilerOptions === undefined ? {} : config.compilerOptions;
155
160
  if (!options || typeof options !== 'object' || Array.isArray(options))
156
161
  throw new Error('Scope tsconfig compilerOptions must be an object');
157
162
  const own = options;
158
163
  return { ...inherited,
159
- ...(own.baseUrl === undefined ? {} : { baseUrl: within(directory, literal(own.baseUrl, 'baseUrl'), true) }),
164
+ ...(own.baseUrl === undefined ? {} : { baseUrl: within(directory, own.baseUrl, true, 'baseUrl') }),
160
165
  ...(own.paths === undefined ? {} : { paths: pathsOption(own.paths), pathsOrigin: directory }) };
161
166
  };
162
167
  let directory = path.posix.dirname(canonical);
@@ -181,7 +186,7 @@ export async function scopeTsconfigImports(read, fromFile, configPath) {
181
186
  if (resolution?.paths)
182
187
  for (const targets of Object.values(resolution.paths))
183
188
  for (const target of targets) {
184
- within(resolution.baseUrl ?? resolution.pathsOrigin, target.replace('*', '__scope_wildcard__'));
189
+ within(resolution.baseUrl ?? resolution.pathsOrigin, target.replace('*', '__scope_wildcard__'), false, 'mapping');
185
190
  }
186
191
  return { candidates: specifier => importCandidates(resolution, specifier), mapped: specifier => {
187
192
  if (typeof specifier !== 'string' || !specifier)
@@ -23,9 +23,23 @@ export async function scopeRoot(value) {
23
23
  if (!stat.isDirectory() || stat.isSymbolicLink()) scopeFail('ENTROROVE_ROOT_INVALID', 'repositoryRoot must be a real directory');
24
24
  return fs.realpath(value);
25
25
  }
26
- export async function scopeTarget(root, relative, allowMissing = false) {
26
+ function dependencyReference(value) { return value.split('/').includes('node_modules'); }
27
+ function pathSegments(value) { return value.split(path.sep).filter(Boolean); }
28
+ async function assertDependencyReference(root, target, relative) {
29
+ const resolved = await fs.realpath(target), rootReal = await fs.realpath(root);
30
+ const inside = resolved === rootReal || resolved.startsWith(`${rootReal}${path.sep}`);
31
+ const reserved = pathSegments(resolved).some(part => ['.git', '.entrorove', '.aimlock', '.archguard'].includes(part));
32
+ if (reserved || (!inside && !dependencyReference(resolved.split(path.sep).join('/')))) {
33
+ const reason = reserved ? 'the resolved target enters a reserved control directory'
34
+ : 'the resolved target is outside the authorized root and is not a dependency directory';
35
+ scopeFail('ENTROROVE_PATH_UNSAFE', `Scope dependency reference "${relative}" resolves to "${resolved}" because ${reason}`);
36
+ }
37
+ return resolved;
38
+ }
39
+ export async function scopeTarget(root, relative, allowMissing = false, allowDependency = false) {
27
40
  let current = root;
28
41
  const parts = scopePath(relative).split('/');
42
+ const followDependency = allowDependency && dependencyReference(relative);
29
43
  for (const [index, part] of parts.entries()) {
30
44
  current = path.join(current, part);
31
45
  let stat;
@@ -33,30 +47,37 @@ export async function scopeTarget(root, relative, allowMissing = false) {
33
47
  if (error.code === 'ENOENT' && allowMissing) return { target: path.join(root, relative), exists: false, stat: null };
34
48
  throw error;
35
49
  }
36
- if (stat.isSymbolicLink() || (index < parts.length - 1 && !stat.isDirectory())) scopeFail('ENTROROVE_FILE_INVALID', 'scope refuses symlink or non-directory ancestors');
37
- if (index === parts.length - 1) return { target: current, exists: true, stat };
50
+ if (stat.isSymbolicLink()) {
51
+ if (!followDependency) scopeFail('ENTROROVE_FILE_INVALID', 'scope refuses symlink or non-directory ancestors');
52
+ await assertDependencyReference(root, current, relative);
53
+ stat = await fs.stat(current);
54
+ }
55
+ if (index < parts.length - 1 && !stat.isDirectory()) scopeFail('ENTROROVE_FILE_INVALID', 'scope refuses symlink or non-directory ancestors');
56
+ if (index === parts.length - 1) return { target: current, exists: true, stat, dependency: followDependency };
38
57
  }
39
58
  }
40
- async function safeScopeRead(root, relative, input) {
41
- const target = await scopeTarget(root, relative);
59
+ async function safeScopeRead(root, relative, input, allowDependency = true) {
60
+ const dependency = allowDependency && dependencyReference(relative), target = await scopeTarget(root, relative, false, dependency);
42
61
  if (!target.stat.isFile()) scopeFail('ENTROROVE_FILE_INVALID', 'scope source must be a regular file');
43
62
  if (target.stat.size > 16_777_216) scopeFail('ENTROROVE_SCOPE_LIMIT', 'scope source exceeds the 16 MiB inspection bound');
44
- const handle = await fs.open(target.target, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
63
+ const handle = await fs.open(target.target, constants.O_RDONLY | (dependency ? 0 : (constants.O_NOFOLLOW ?? 0)));
45
64
  try {
46
65
  const before = await handle.stat();
66
+ const beforeReal = dependency ? await fs.realpath(target.target) : null;
47
67
  const content = input ? await readBudgetedProjectFile(input, target.target, { sourcePath: relative }) : await handle.readFile();
48
68
  const after = await handle.stat();
49
- const latest = await fs.lstat(target.target);
69
+ const latest = dependency ? await fs.stat(target.target) : await fs.lstat(target.target);
70
+ const afterReal = dependency ? await fs.realpath(target.target) : null;
50
71
  if (before.ino !== target.stat.ino || before.dev !== target.stat.dev || after.size !== before.size
51
72
  || after.mtimeMs !== before.mtimeMs || after.ctimeMs !== before.ctimeMs || latest.ino !== after.ino
52
- || latest.dev !== after.dev || latest.isSymbolicLink()) scopeFail('ENTROROVE_SCOPE_CHANGED', 'scope source changed during inspection');
53
- await scopeTarget(root, relative);
73
+ || latest.dev !== after.dev || !dependency && latest.isSymbolicLink() || beforeReal !== afterReal) scopeFail('ENTROROVE_SCOPE_CHANGED', 'scope source changed during inspection');
74
+ await scopeTarget(root, relative, false, dependency);
54
75
  return { content, mode: after.mode & 0o777, sha256: sha256(content), bytes: content.length };
55
76
  } finally { await handle.close(); }
56
77
  }
57
- export const scopeRead = (root, relative, input = { repositoryRoot: root }) => safeScopeRead(root, relative, input);
78
+ export const scopeRead = (root, relative, input = { repositoryRoot: root }) => safeScopeRead(root, relative, input, true);
58
79
  // Internal control roles only: signed manifest metadata and already validated private signer files.
59
- export const scopeControlRead = (root, relative) => safeScopeRead(root, relative, null);
80
+ export const scopeControlRead = (root, relative) => safeScopeRead(root, relative, null, false);
60
81
  const excluded = new Set(['.git', 'node_modules', '.entrorove', '.aimlock', '.archguard']);
61
82
  export async function scopeFiles(root, requested, knownFiles = []) {
62
83
  if (!Array.isArray(requested) || !requested.length) scopeFail('ENTROROVE_SCOPE_EMPTY', 'a non-empty target scope is required');
@@ -214,11 +214,12 @@ async function resolveImport(root, from, specifier, workspace, input, aliases, c
214
214
  const relative = localPackage ?? path.posix.normalize(path.posix.join(path.posix.dirname(from), specifier));
215
215
  return resolveLocalImport(root, relative);
216
216
  }
217
+ const dependencyReference = relative => relative.split('/').includes('node_modules');
217
218
  async function resolveLocalImport(root, relative, required = true) {
218
219
  if (relative.startsWith('../') || relative === '..') scopeFail('ENTROROVE_PATH_UNSAFE', 'local import escapes the repository');
219
220
  const replacements = /\.js$/.test(relative) ? [relative.replace(/\.js$/, '.ts'), relative.replace(/\.js$/, '.tsx')] : [];
220
221
  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)]) {
221
- const item = await scopeTarget(root, candidate, true);
222
+ const item = await scopeTarget(root, candidate, true, dependencyReference(candidate));
222
223
  if (item.exists && item.stat.isFile()) return candidate;
223
224
  }
224
225
  if (required) scopeFail('ENTROROVE_IMPORT_UNRESOLVED', 'a local import has no resolvable source file');
@@ -74,6 +74,9 @@ function taskRecord(state, taskId) {
74
74
  if (!task) fail('ENTROROVE_TASK_UNKNOWN', `unknown task: ${taskId}`);
75
75
  return task;
76
76
  }
77
+ function taskFlowId(task) {
78
+ return task?.flowId ?? task?.checkpoint?.flowId ?? task?.route?.flowId ?? task?.acceptance?.flowId;
79
+ }
77
80
  async function record(authority, event, data, nextState = authority.state) {
78
81
  const entry = { sequence: authority.state.sequence + 1, event, at: new Date().toISOString(), ...data };
79
82
  const state = { ...nextState, sequence: entry.sequence,
@@ -208,9 +211,33 @@ async function resumeUnlocked(input) {
208
211
  }
209
212
  return { schemaVersion: TASK_ROUTING_SCHEMA, status: 'reported', ...recovered };
210
213
  }
214
+ async function settleTaskFlowUnlocked(input, operation) {
215
+ if (!['flow-cancel', 'flow-abandon'].includes(operation)) fail('ENTROROVE_OPERATION_INVALID', 'Unknown flow lifecycle operation');
216
+ const flowId = requireId(input.flowId, 'flowId');
217
+ const authority = await load(input);
218
+ const taskId = input.taskId === undefined ? undefined : requireId(input.taskId, 'taskId');
219
+ const matches = authority.state.tasks.filter(task => (taskId ? task.taskId === taskId : true)
220
+ && taskFlowId(task) === flowId);
221
+ if (!matches.length) return { schemaVersion: TASK_ROUTING_SCHEMA, status: 'reported', flowId, unbound: false, task: null };
222
+ const released = matches.map(task => {
223
+ if (operation === 'flow-abandon' && task.status === 'completed') fail('ENTROROVE_TASK_COMPLETED', 'Completed task cannot be abandoned');
224
+ if (operation === 'flow-cancel') return task.status === 'completed' ? task : { ...task, status: 'blocked', resumedAt: new Date().toISOString() };
225
+ const checkpoint = task.checkpoint && typeof task.checkpoint === 'object' && !Array.isArray(task.checkpoint)
226
+ ? { ...task.checkpoint, flowId: null } : task.checkpoint;
227
+ return { ...task, status: 'pending', flowId: null, route: null, ...(checkpoint ? { checkpoint } : {}), resumedAt: new Date().toISOString() };
228
+ });
229
+ const releasedById = new Map(released.map(task => [task.taskId, task]));
230
+ const state = { ...authority.state, tasks: authority.state.tasks.map(task => releasedById.get(task.taskId) ?? task) };
231
+ const unbound = operation === 'flow-abandon';
232
+ const entry = await record(authority, unbound ? 'flow-unbind' : 'flow-cancel', { flowId, taskIds: released.map(task => task.taskId) }, state);
233
+ return { schemaVersion: TASK_ROUTING_SCHEMA, status: 'reported', flowId, unbound,
234
+ task: released.length === 1 ? released[0] : undefined, taskIds: released.map(task => task.taskId), record: entry };
235
+ }
211
236
  export async function taskDescribe(input) { return routingLock(input, () => describeUnlocked(input)); }
212
237
  export async function messageRoute(input) { return routingLock(input, () => routeUnlocked(input)); }
213
238
  export async function messageAccept(input) { return routingLock(input, () => acceptUnlocked(input)); }
214
239
  export async function taskResume(input) { return resumeUnlocked(input); }
240
+ export async function settleTaskFlow(input, operation) { return routingLock(input, () => settleTaskFlowUnlocked(input, operation)); }
241
+ export async function releaseTaskFlow(input) { return settleTaskFlow(input, 'flow-abandon'); }
215
242
  export const LOCAL_TASK_ROUTING_OPERATIONS = Object.freeze({ 'task-describe': taskDescribe, 'message-route': messageRoute, 'message-accept': messageAccept, 'task-resume': taskResume });
216
243
  export const LOCAL_TASK_ROUTING_SCHEMAS = operationSchemasFor(LOCAL_TASK_ROUTING_OPERATIONS);
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.39",
4
+ "version": "0.1.40",
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.39",
3
+ "version": "0.1.40",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "entrorove": "cli.mjs"
@@ -65,6 +65,7 @@
65
65
  "workflow-local-contracts.mjs",
66
66
  "local-budget-renewal.mjs",
67
67
  "local-probe.mjs",
68
+ "local-flow-lifecycle.mjs",
68
69
  "local-task-routing.mjs",
69
70
  "local-task-handoff-fence.mjs",
70
71
  "local-task-handoff-storage.mjs",
@@ -203,6 +204,7 @@
203
204
  "ide-hook.mjs",
204
205
  "ide-install.mjs",
205
206
  "ide-session.mjs",
207
+ "break-glass.mjs",
206
208
  "ide-results.mjs",
207
209
  "ide-queue.mjs",
208
210
  "local-discipline-actions.mjs",
@@ -1,14 +1,15 @@
1
1
  {
2
2
  "schemaVersion": "entrorove.release-state/1.0",
3
- "version": "0.1.39",
4
- "digest": "5c83bae7a346a4a9f4592aab374b872b66289f13d8f7e95b5cf6676b365896ae",
3
+ "version": "0.1.40",
4
+ "digest": "4175f36123295cacefbcd09150116696d7528716ba1541256cf345ec3f31a2f3",
5
5
  "files": {
6
6
  "ide-code-mode.mjs": "cd5318342aac23ba9719ad760b7d7b8018d31839df1bed4ed7142c2782e22def",
7
- "ide-command.mjs": "11e9aeaf4e6f4dff5f4522c175dde4c48fdc29cfb4e7e1cfe9935e5a6ca842a3",
8
- "ide-hook.mjs": "06b2b771113c4b288d057e84eacc51406446bf55b9fab51c7bb720917d4f1cb8",
9
- "ide-install.mjs": "03ceb86a56ff2c2683a2de82b4fed54af5119a817f8017ee7789330af4a0c11f",
10
- "ide-session.mjs": "bbe68b077e8854af36e4514ec6747bdeff8a10513d5169e6fa111ce8f95bcb88",
11
- "ide-results.mjs": "8677b062bfe44b1683ae2508ad1048b93030a0840e8ccc1fa1c5e587ab43c7a3",
7
+ "ide-command.mjs": "e50a7e0b68bfc1812568f60562598a463ae169984a1abe9d401229c2b66c78c2",
8
+ "ide-hook.mjs": "567593a4c06911889954dc6499bd22be0ab67c78f0d8d46ad671bf55934f9041",
9
+ "ide-install.mjs": "65814d1e73cbdccdae4d8703cf9e8573492a3fd6a549ef8562a3f9d703f0ba09",
10
+ "ide-session.mjs": "964c5088454a9069fbf5672d2456ba136718c3b2e9eec31c96f7c5943972731b",
11
+ "break-glass.mjs": "2ad6f084c788d63fec11e64412864ac715bcb1a282dd2d8f03dc14e89e82135e",
12
+ "ide-results.mjs": "dfe0d3f773c65a9fc21056538d7005012c982ed4509bc0410d7f62813d0863fd",
12
13
  "ide-queue.mjs": "806e716c775fd0079417027b135977c8516f47dddb0bc5ca19e32a355dab0e9e",
13
14
  "local-discipline-actions.mjs": "2379c08d0f49c44068f8239db7e514d75885f4606b1b88a7a36e4ff19167caca",
14
15
  "local-discipline-analysis.mjs": "ebea932438a53a157d5d07a5d68366496eee121a2c67cecfb4fdb2d5aa9763eb",
@@ -28,9 +29,9 @@
28
29
  "runtime/validator-runtime-v7.0.44.d.mts": "7797edbfe2f7de24da370204dce4dfd22822a1bd80df93514fff900b98afe36f",
29
30
  "snapshot-retention.mjs": "4f8919fe8ec49fb4c23e080a8ad2fa3a9282bca3fffe7cf55c64e89ef316697e",
30
31
  "snapshot-cleanup-journal.mjs": "76a9684cc95b4eb076e1298d78a9dff37948b00d9607b8f546c599072d3ed331",
31
- "IDE-EXECUTION.md": "e34acaec2a92338fe2647ccedd2234e6e632415b22efe3fd7fcab828fe7d0a9a",
32
+ "IDE-EXECUTION.md": "fe1c65819749fdd7cad4663eab7d11e6e566ef623dc30352e47effe0bd1263d9",
32
33
  "REUSE-DISCIPLINE.md": "295ecd41c931b00c21d009ab65ac160f0e2ab81b4000ed498d2e8e97c2dc6c63",
33
- "reuse-discipline-release.mjs": "2db902cd2bc909e01c0b7238e9cdfe72f8b0b696828baa9e71eba5140336e950",
34
+ "reuse-discipline-release.mjs": "20ded9c21bd71b8779c454f5eba4c7c0a943ef99d94117b9bbef891d2ab6e3de",
34
35
  "local-final-gate16.mjs": "b30caab08441449ad556a6e9a3cefbf91190f0b3f7370f46d2a21156375aa8e3",
35
36
  "local-final-gate16-checks.mjs": "1dd30e9666d458d8b12ca6e4975f6f0ac8450c3ea9cbecc7401ae037f66b589a",
36
37
  "local-final-gate16-worker.mjs": "344d64b6c1c5552b96960e0d8514a876a3dbfa9762ab9eb749038e8351727121",
@@ -72,18 +73,18 @@
72
73
  "local-autocoord17-waits.mjs": "2b0a713987822548a94e1ed3a9a891532db45383797dfc5d5bc8d1e35593cf25",
73
74
  "local-validator-isolation17.mjs": "48e94604e28b8fddd567dfb35ff60b73fd094a5803bd49f076388b7f310f92aa",
74
75
  "local-calctool-composition17.mjs": "ac89fcb41ab0d0d9b47c1b5696945281b0894eb064820775e36a011c76314e6a",
75
- "cli.mjs": "d0f98b363515cb1ee9d9033c0ef47ddf60817bfe51a954fe155f5d823a8601da",
76
+ "cli.mjs": "bb99f5a52c770ded055fe27a35b3067ac348441b9488df336e6bf751b803e459",
76
77
  "review-client.mjs": "3491eef68ed533174b31db3c53febb6519b8f088f71a9964644714a9a4c03c77",
77
78
  "version.mjs": "1f392147d72c40af4b03b307ea607075fb815af5174fa07e254b95fe12dbf3e7",
78
- "SKILL.md": "71c6c38bc33f9793af880e006e3e3eda71a4ec191e3ea6856a609248e3dea20f",
79
- "CAPABILITY-MAP.md": "eb9c0687260801ff7187acd329a1f418e8fded492e50c45816d5620b2a99c2d0",
79
+ "SKILL.md": "d89c2a83a080a217d67d75fa0251b0f054b92e81a9e40a6c0b52c7ecf3d79099",
80
+ "CAPABILITY-MAP.md": "a0f0468a098725ced52a96dd8e22bfc7d1ce7681a8446ebf7dae3696c76c3385",
80
81
  "AGREEMENT.md": "f93a68e92c457200dd024610e65f74e478b8f60860f951da818b70561f1b9bf8",
81
- "package.json": "e6a90e4a2fb25bbe74ee58b7e6af4770743296aa68a03562dce88ecf92418161",
82
+ "package.json": "a522540d3967d9348a37c6538c1abb39fe6d50872aa8f7f90b85e920590091c7",
82
83
  "manifest.json": "ab06e307795bc9da5add926752b6c4dbdaf18625090261a035aa356a679c504f",
83
- "release.mjs": "203e367ed6d7a406937c358c87f5e5c43d2055ceedda4ca882711121a53c28ec",
84
- "local-cli.mjs": "ee6319e2027689e0a4f511c8790fa6e606e520e2f3f30be5031a2e055231de4b",
84
+ "release.mjs": "5a84da222a179cdc3b6be5db2a4a1c48085de27dff378b7598eb725f0627c98b",
85
+ "local-cli.mjs": "aa4c217c5029408020f76d6c0c5dafc9a0f51216085b2624f3b6aca3cffbe555",
85
86
  "local-state-lock.mjs": "1dbf64264642a0851faf043e07ee9f66d2ad0508e9f799b0626246e42fae893e",
86
- "local-execution.mjs": "3d7db6576d902e2a5dea2045c7b78c5453f698a49f4264aacb3db8e38461d5ba",
87
+ "local-execution.mjs": "46fddd78cd954fb080c660affe90cdaa716695580427336acb820089441a8025",
87
88
  "local-integrity.mjs": "e6d6eace80c610f3c7299d4f8644b0dc023aa9e3d8d64b26535ea7fbced248f1",
88
89
  "local-evidence-verification.mjs": "9b7dea31777505272f63616fd62af7603338f703bb1285fc565d6451715c7ce0",
89
90
  "local-formula.mjs": "706d5ec0cb2caf95a547531a81ca746fe246d4c53d902e03595a3b360e8c85af",
@@ -114,10 +115,11 @@
114
115
  "local-budget-store.mjs": "2ae36c977ecd32d81f88e2eaa697ce8c228cb4405ecc5c4169cdda104c8ce138",
115
116
  "local-budget-transition.mjs": "3894f05d4b5d4d6e16e3f201ff8902e6311cf6434010262934e0d1251aa8b9f0",
116
117
  "local-budget.mjs": "e4ba1b4873a7625f9ff3c43e932b7289e7f95468c9dd88c2aeddd1242d7ea415",
118
+ "local-flow-lifecycle.mjs": "47534e16599002e5e7cf7d3e95195f4d7a364d2b651ec339d8c6c017d71c1b05",
117
119
  "local-scope-candidate.mjs": "8f0bd9606e70d4a0ebacdd3cd600ec3afed85e2bb3e1760aa1144cf55d4e028a",
118
120
  "local-scope-context.mjs": "79753abfc6c76089d17f003a1be537dfaea10d6731c542f7fc53f2a9996ed164",
119
- "local-scope-files.mjs": "0671ac4baef610a55ea6c1db6ebca7a4b6cfda45edb8aeaa828019e2a26f5a76",
120
- "local-scope-repository.mjs": "957fa72afacc148d0251a3081c387467922e63b9e939f40751d63eb6b09b427c",
121
+ "local-scope-files.mjs": "96c301f2a45e5fa22f0b11fb15e5e564f118e53187fc7749263c78d5ed87b056",
122
+ "local-scope-repository.mjs": "0c955612d2f737e8661af84c1efb6e9e7fe6bb9ec1dfadd7776c61be177261f2",
121
123
  "local-scope-git.mjs": "53bbbb6c4795db711b5bc164591a6ad79a561427da046d6bbe997dae6eb82829",
122
124
  "local-scope-git-reads.mjs": "5d8a2636bab955cb325cb693db7423e69512355887ae5b5af3b8cb615f5ddad0",
123
125
  "local-git-executable.mjs": "9391d716529025b1de3ee006df658a9d765b6ef29ad24ef9b7436829318e651e",
@@ -142,12 +144,12 @@
142
144
  "runtime/git-native-linux-v1.mjs": "cf5cb91426a105e62c0f07a8a230dec592dba46f85a3f708b4c4e8cfcd1126c6",
143
145
  "runtime/git-native-darwin-v1.mjs": "f88aab72dc86f94eb1115784872957cb8ed0f74205ae70e1ecd1939e7790e310",
144
146
  "local-scope-signature.mjs": "bd05e8cf9e23f9511a16844974362297b9e9d11930ff7b8ad2a490e8d7660fe9",
145
- "local-scope-config-imports.mjs": "16053142410dd647f13360f29fb3680c3e3a3ebc38019e43af6db05896d92a33",
147
+ "local-scope-config-imports.mjs": "53e510609066355318d886cd96f73a0fc8005a28857f6ab4eb6cb81c4857945b",
146
148
  "local-scope-snapshot.mjs": "c0f851470b98863a1d187825ed61ee6072d1c057b178fdc85ae29af02a3b250f",
147
149
  "local-scope-state.mjs": "8b600a91fc0068d7a8c0bb752ddba7ed30ada93daafb118d6cba2368fd94cb27",
148
150
  "local-scope-workspaces.mjs": "a44c34537e5ed2dcaf193a985e5bc05957989fbb160a650f9e37ba7d66eb2de8",
149
- "local-task-routing.mjs": "e26d6f1ba134ffe1da2a49b4fb6bb6dfa5617671f774a08e719d04f202cf5eca",
150
- "remote-client.mjs": "1d99cf5bfdcad0203e2550a4c1deb6c2811b5a42d702ff9b73bf7b0ded6f72d1",
151
+ "local-task-routing.mjs": "6e2e506b2fb0e2e8d8c5147d7df32849b57a4e94a23634f01a355d45735858e4",
152
+ "remote-client.mjs": "9e4ef758fb7d83e30e82dca68623740e7848cd5a527b7a90846e125d00df0a12",
151
153
  "cloud-task-client.mjs": "11acb1a033c94cdfbdfb177a3402ad97f6a5226f577138c8cf6395dc3ebb1a39",
152
154
  "scope-runner-client.mjs": "646ab4f80d4a8209e922fd45f40efd968f0744d9dfc03188b222c1a2e11e83b8",
153
155
  "native-client.mjs": "6f5575909bae9daba643287f876ffa0f9ff1c53a367b32a40145d740456d6605",
@@ -180,7 +182,7 @@
180
182
  "workflow-local-request.mjs": "4e24c543ce386292a87dc9cd41f90d23375f12079cacffc515c8cd01fd571780",
181
183
  "task-message-entry.mjs": "31d10056cb905237cd5649e8eb067009ff1612ec5dc1061e0373209fbc0b646a",
182
184
  "task-message-cli.mjs": "3ca65e96c28129b3bd5cac962d106e0859a1f17df1f6a66ee543c6efc22f918d",
183
- "local-contracts.mjs": "f7c5eff49b6efa05857f6be8e37579b91a00d5ab79f8b8f5cceea5e935ddd2cf",
185
+ "local-contracts.mjs": "d47022162233ee0b840bfd808b9d12590f4a367133ef0999233b7f76d76daa43",
184
186
  "local-contract-types.mjs": "85e71fa4dd67e351a4b67573c740bcdb1661cede53f097189f11837771e75e91",
185
187
  "local-archguard-ast.mjs": "7ff7df9dab0d98ed62983add751c80edea598bc94ef4f38ede0ebd4d41226c87",
186
188
  "local-archguard-checkpoint.mjs": "37896902520cc995720c96288e4a5ed3585952b55c9c35be281f91fa754c71ec",
package/release.mjs CHANGED
@@ -60,7 +60,7 @@ const sourceFiles = [
60
60
  'local-budget-grants.mjs', 'local-budget-handoff.mjs', 'local-budget-handoff-contracts.mjs', 'local-budget-lock.mjs',
61
61
  'local-budget-handoff-completion.mjs',
62
62
  'local-budget-reads.mjs', 'local-budget-source-bindings.mjs', 'local-budget-store.mjs',
63
- 'local-budget-transition.mjs', 'local-budget.mjs',
63
+ 'local-budget-transition.mjs', 'local-budget.mjs', 'local-flow-lifecycle.mjs',
64
64
  'local-scope-candidate.mjs',
65
65
  'local-scope-context.mjs',
66
66
  'local-scope-files.mjs',
@@ -328,11 +328,13 @@ function releaseLabels() {
328
328
  'local-budget-store.mjs': { zh: '预算追加台账和完整性校验', en: 'budget append-only journal and integrity checks' },
329
329
  'local-budget-transition.mjs': { zh: '候选范围与正式范围预算转换', en: 'candidate and accepted scope budget transition' },
330
330
  'local-budget.mjs': { zh: '本地三轴读取预算', en: 'local three-axis read budgets' },
331
+ 'local-flow-lifecycle.mjs': { zh: 'Flow 终止与任务解绑', en: 'flow termination and task unbinding' },
331
332
  'local-probe.mjs': { zh: '范围探测与分档', en: 'scope probing and tiering' },
332
333
  'local-scope-candidate.mjs': { zh: '临时接管范围独立验签', en: 'temporary takeover scope verification' },
333
334
  'local-scope-context.mjs': { zh: '精确符号范围绑定', en: 'exact symbol scope binding' },
334
335
  'local-scope-files.mjs': { zh: '范围文件与安全读取', en: 'scope files and guarded reads' },
335
336
  'local-scope-repository.mjs': { zh: '真实仓库范围事实', en: 'actual repository scope facts' },
337
+ 'local-scope-config-imports.mjs': { zh: 'tsconfig 范围路径与依赖只读引用', en: 'tsconfig scope paths and read-only dependency references' },
336
338
  'local-scope-git.mjs': { zh: '实际Git范围与变更事实', en: 'actual Git scope and change facts' },
337
339
  'local-scope-git-reads.mjs': { zh: '固定Git元数据与源码读取', en: 'fixed Git metadata and source reads' },
338
340
  'local-scope-git-comparison.mjs': { zh: 'Git范围比较接线', en: 'Git scope comparison wiring' },
package/releases.json CHANGED
@@ -1,4 +1,10 @@
1
1
  [
2
+ {
3
+ "version": "0.1.40",
4
+ "date": "2026-10-15",
5
+ "zh": "修复 flow 终止与会话解绑、tsconfig 授权路径解析,并加入离线 unlock 自救入口。",
6
+ "en": "Fixed flow termination and session unbinding, tsconfig authorized-path resolution, and added an offline unlock recovery command."
7
+ },
2
8
  {
3
9
  "version": "0.1.39",
4
10
  "date": "2026-10-14",
package/remote-client.mjs CHANGED
@@ -34,7 +34,7 @@ function discoveredAjv() {
34
34
  }
35
35
 
36
36
  export function buildInvocation(operation, input) {
37
- if (!['capabilities', 'task-describe', 'message-route', 'message-accept', 'task-resume', 'task-checkpoint',
37
+ if (!['capabilities', 'task-describe', 'message-route', 'message-accept', 'task-resume', 'task-checkpoint', 'flow-cancel', 'flow-abandon',
38
38
  'message-status', 'message-delivery-start', 'message-delivery-report', 'message-resolve', 'message-complete',
39
39
  'handoff-prepare', 'handoff-release', 'handoff-claim', 'handoff-status', 'handoff-resume', 'handoff-execution-authority', 'mutation-snapshot-challenge', 'mutation-pass-issue',
40
40
  'scope-runner-prepare', 'scope-runner-register', 'scope-runner-challenge', 'scope-runner-attest',
@@ -1,4 +1,4 @@
1
- const ide = ['ide-code-mode.mjs', 'ide-command.mjs', 'ide-hook.mjs', 'ide-install.mjs', 'ide-session.mjs', 'ide-results.mjs', 'ide-queue.mjs'];
1
+ const ide = ['ide-code-mode.mjs', 'ide-command.mjs', 'ide-hook.mjs', 'ide-install.mjs', 'ide-session.mjs', 'break-glass.mjs', 'ide-results.mjs', 'ide-queue.mjs'];
2
2
  const discipline = ['local-discipline-actions.mjs', 'local-discipline-analysis.mjs', 'local-discipline-batch.mjs',
3
3
  'local-discipline-contracts.mjs', 'local-discipline-contracts.d.mts', 'local-discipline-gate.mjs',
4
4
  'local-discipline-native.mjs', 'local-discipline-operations.mjs', 'local-discipline-repository.mjs',