@entrorove/skill 0.1.41 → 0.1.47

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/CHANGELOG.md CHANGED
@@ -1,5 +1,35 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.47 — 2026-10-16
4
+
5
+ - 更新了Codex 后续轮次工具门禁、IDE 执行与复用纪律文档、技能运行和受管安装、IDE 工作流程说明、本地执行命令、远端恢复客户端。
6
+ - Updated Codex tool guards across turns, IDE execution and reuse discipline documentation, skill runtime and managed installation, IDE workflow instructions, local execution command, remote recovery client.
7
+
8
+ ## 0.1.46 — 2026-10-16
9
+
10
+ - 更新了Codex 后续轮次工具门禁、IDE 执行与复用纪律文档、技能运行和受管安装、IDE 工作流程说明。
11
+ - Updated Codex tool guards across turns, IDE execution and reuse discipline documentation, skill runtime and managed installation, IDE workflow instructions.
12
+
13
+ ## 0.1.45 — 2026-10-16
14
+
15
+ - 更新了原链同流程技能调用。
16
+ - Updated original-chain skill calls on the same workflow.
17
+
18
+ ## 0.1.44 — 2026-10-16
19
+
20
+ - 更新了Calctool 审计测试运维真实检查与独立最终闸门、自动版本与更新日志。
21
+ - Updated Calctool audit, test, operations checks and independent final gate, automatic versioning and changelog.
22
+
23
+ ## 0.1.43 — 2026-10-16
24
+
25
+ - 更新了Codex 后续轮次工具门禁、复用与变更一致性门禁、IDE 执行与复用纪律文档、技能运行和受管安装、旧版受管安装清理、IDE 工作流程说明、任务交付边界说明、安装包配置、自动版本与更新日志、本地三轴读取预算、本地阶段边界与可恢复停止、远端恢复客户端、账号沙箱偏好与任务入口确认、工作流本地调用契约。
26
+ - Updated Codex tool guards across turns, reuse and change consistency checks, IDE execution and reuse discipline documentation, skill runtime and managed installation, managed installation cleanup, IDE workflow instructions, task delivery boundaries, package configuration, automatic versioning and changelog, local three-axis read budgets, local stage boundaries and recoverable stops, remote recovery client, account sandbox preferences and task entry confirmation, workflow local invocation contracts.
27
+
28
+ ## 0.1.42 — 2026-10-16
29
+
30
+ - 修复受管钩子空文件恢复、旧安装清理及仓库控制快照误扫。
31
+ - Restored empty managed hook configs, cleaned old installations, and excluded control snapshots from semantic scanning.
32
+
3
33
  ## 0.1.41 — 2026-10-15
4
34
 
5
35
  - 修复 0.1.39 受管技能升级与 Codex hook 配置权限误拒;补齐本地 probe/reassess 的 importContexts 输入合同。
@@ -0,0 +1,30 @@
1
+ # Delivery boundaries and cost visibility
2
+
3
+ Contract for where a governed task may stop and which numbers the skill may display. Each section is marked **live** (implemented in this package) or **planned** (documented, not yet wired).
4
+
5
+ ## Stop only at a boundary (live)
6
+
7
+ A stopped task may only stop at a clean boundary: the work so far is runnable, demonstrable and roll-backable. Never stop in the middle of a stage. In this package the ordered verified stages of the accepted plan are the boundaries; `local-stage-boundary.mjs` decides and reports with them.
8
+
9
+ ## `delivered-stage-N` is its own terminal (live)
10
+
11
+ Stopping at a boundary reports the independent terminal status `delivered-stage-N` (N = delivered stages). It is never `failed` and never `blocked`, must not be shown as an error, and the guard keeps the `delivered` phase with Stop allowed. A new `start` for the same goal stays refused; continue the original bound flow instead. Resuming continues at the next boundary — delivered stages are never re-executed.
12
+
13
+ ## Stop-loss is measured, never predicted (live)
14
+
15
+ The close decision uses measured consumption only. Without an enforced budget there is no baseline, so the decision stays explicitly `not-estimable` and no remaining amount or duration may be displayed. With an enforced budget, `budgetView.consumedRatio` reports the measured share; the caller applies its configured ratio and finishes the current stage before closing at that boundary. Threshold values are not shipped: they must be calibrated from a measured baseline first.
16
+
17
+ ## What may be displayed (live)
18
+
19
+ - `npx @entrorove/skill usage [flowId]` reads measured consumption grouped per (host, model, meter kind, operation) and, with an original flow, its delivered stages. Its `hostQuota` block is `measured` only when a declared host adapter reports it, otherwise `unknown` (`no-host-adapter` / `adapter-unavailable`).
20
+ - `npx @entrorove/skill billing` exports measured cost per (account, host, model, task) together with that task's delivery stages; verified-only stages count as effective delivery and spend that is bound to no flow is listed separately instead of being dropped.
21
+ - Recurring tasks are scheduled by the service: `POST /api/recurring` registers a goal and interval, a periodic sweep creates exactly one run per due occurrence (a repeated sweep cannot duplicate it), and `npx @entrorove/skill recurring claim | finish <runId> | runs <recurringId>` picks a run up and reports its outcome. `runs` reports per-run measured cost (`durationMs`, `processedCount`); the trend stays `not-estimable` (`insufficient-samples`, then `no-calibrated-threshold`) while the measured deltas are still shown.
22
+ - Only measured values are shown; an empty sample answers `样本不足,暂不预估` instead of a default or a range.
23
+ - Quota stays `unknown / no-baseline` until a baseline exists. `invocation_limit` is a 60-second rate-limit threshold, not a quota.
24
+ - Every displayed number carries its basis (measured / extrapolated / tier); predicted durations and purchase prompts are forbidden.
25
+
26
+ ## Planned (not yet live)
27
+
28
+ - The earlier-of-two-ledgers stop-loss (P2): the host adapter interface and its explicit `unknown` degradation are live, but a real host adapter must still be written per host.
29
+ - Per-member attribution inside a team account (P3).
30
+ - The calibration thresholds themselves (ratio, trend rise, tier boundaries, minimum sample size) — none may be filled in before they are measured.
package/IDE-EXECUTION.md CHANGED
@@ -2,7 +2,9 @@ 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.
5
+ If the IDE guard itself is the blocked step, run `entrorove unlock` directly from the affected IDE terminal. The command is offline and records a 15-minute lease bound to that IDE session; it does not call a hook, read a credential, accept the agreement, inspect the protocol or authorize project writes. The host session ID must be present in the environment or supplied as `ENTROROVE_SESSION`. Under the lease, `flow-cancel` and `flow-abandon` recover the original flow; successful `flow-abandon` releases its task binding in both the flow state and the IDE session record, even without a hook callback. Queued requests remain, and other IDE sessions stay guarded.
6
+
7
+ To stop using Entrorove on this computer, run `entrorove uninstall` directly. It works offline, removes only the recorded Entrorove host hooks and pristine global skill, and leaves other host settings, credentials and task records in place. A hand-edited file is reported as preserved rather than deleted. This command does not inspect or change another project.
6
8
 
7
9
  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.
8
10
 
@@ -28,6 +30,12 @@ Set `ENTROROVE_IDE` to the actual host name (for example `Codex`, `Cursor` or `C
28
30
 
29
31
  User text received during an unfinished governed task is persisted as an exact, private queue entry. `queue status` reads it; `queue route` assigns the existing `background`, `normal`, `high` or `urgent` priority. `interrupt` only takes effect at a controlled-call checkpoint and keeps the parent flow, task ID, task number, request ID, repository and account binding. Once the inserted requirement has a verified completion and stored feedback acknowledgement, the parent is restored and its returned `nextStep` is the only continuation. The guard blocks Stop while an entry is unclassified, a call is unresolved or a runnable parent/child remains. The queue does not promise execution when the host is powered off or the IDE process is unavailable.
30
32
 
33
+ ### Delivery boundaries and stop-loss
34
+
35
+ A stopped task may only stop at a clean boundary: one where the work so far is runnable, demonstrable and roll-backable. Never stop in the middle of a stage. Such a stop is its own terminal result `delivered-stage-N` (N = delivered stages); it is never `failed` or `blocked`, must not be shown as an error, and the IDE keeps phase `delivered` with Stop allowed. A new `start` for the same goal stays refused: continue the original bound flow instead.
36
+
37
+ The close decision uses measured consumption only. Without an enforced budget there is no baseline, so the result is explicitly not-estimable and no remaining amount or duration may be displayed. When measured consumption reaches the configured share of the enforced budget, finish the current stage and close at that boundary. Resuming continues at the next boundary; delivered stages are never re-executed.
38
+
31
39
  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"`, which skips extra planning round trips. It does not authorize native writes outside the controlled task; keep its executable acceptance assertion and the original mutation authority. 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.
32
40
 
33
41
  The server's ordinary `start` operation creates a durable skill flow and counts a skill invocation. The IDE CLI routes new starts through the account's preference and explicit task choice described above. A real sandbox is created only by `/api/cloud-task`, which returns both `taskId` and `taskNumber` and counts sandbox usage separately. `local` changes only this task's route. Disabled entitlement or missing model prerequisites stop cloud dispatch; a `needs-input`, `blocked` or `failed` result must never be described as sandbox entry. Do not send a second ordinary `start` after cloud acceptance; continue the original bound flow returned by its watch/status.
@@ -64,7 +72,7 @@ Computed runtime imports remain explicitly unresolved with their actual source p
64
72
 
65
73
  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.
66
74
 
67
- 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.
75
+ 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. `npx @entrorove/skill usage [flowId]` reads measured consumption grouped per host and model and, with an original flow, its delivered stages; it never displays an invented quota or a predicted duration. `npx @entrorove/skill billing` exports measured cost per account, host, model and task with that task's delivery stages. A declared host adapter may report the host's own remaining quota through the same module that describes its guard; when it cannot, the answer stays explicitly `unknown` and only this skill's measured ledger is used. `GET /api/skill/flows/:id/status` reports `delivery` with delivered, total and remaining stages, counted from independently verified steps only. 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.
68
76
 
69
77
  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.
70
78
 
package/SKILL.md CHANGED
@@ -5,25 +5,29 @@ description: Execute an engineering task through Entrorove, preserving its origi
5
5
 
6
6
  # entrorove · 熵巡
7
7
 
8
- Version: 0.1.41.
8
+ Version: 0.1.47.
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. 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.
14
+ 1. Install `npx --yes --prefer-online @entrorove/skill@latest install` only when the user chooses to enable this skill. The managed installer writes the skill and IDE lifecycle hook configuration; confirm that the IDE trusts it and observe a real callback before describing enforcement as active. Do not ask the user to type a nonexistent `/hooks` command. If the host reports a blocked management call, show and allow the offline `entrorove unlock` command; it only opens the skill management plane and never grants project-write authority. 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. The guard is host-agnostic: one managed hook is installed once, and each host is described by its own adapter (config location, event names, entry shape). Codex and Claude Code are the verified adapters today; a host with no verified adapter, or a run whose host session identity is missing, is refused explicitly rather than degrading to native tools.
15
+ `entrorove unlock` is the break-glass path: run the installed CLI directly in the affected IDE terminal. It writes a short-lived lease for that IDE session only, without a hook callback, network request, credential read, agreement check or protocol lookup. The current session ID must be available from the host environment or `ENTROROVE_SESSION`. The lease permits `flow-cancel` and `flow-abandon` recovery, never project writes or another session's calls. A successful `flow-abandon` clears that session's old binding locally even without a hook callback and preserves its queued requests.
16
+ To remove this skill and its host hooks, run `entrorove uninstall` directly. It works offline and preserves unrelated host settings, credentials and task records. If a managed file was edited, it reports the preserved path instead of deleting it.
15
17
  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
18
  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.
19
+ When the model produces a task contract, preserve its `workType`, `risk`, `difficulty`, `crossModule`, `parallel`, `unclear`, `architectureContract`, optional `baselineRef`, and optional `interaction` as returned. Valid work types are `implementation`, `documentation`, `calculation`, `merge`, `review`, `decision`, and `diagnosis`. A `merge` requires a concrete `baselineRef` identifying the branch, tag, commit, or review baseline; keep that baseline unchanged through independent verification. A `decision` requires `interaction`, including a risk description for medium or high risk. Do not fill missing required fields by guessing.
17
20
  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 erase the old failure. A terminal flow may release its task slot only through the explicit `flow-abandon` operation.
21
+ 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. If the terminal flow truly owns a stale task slot, use the explicit `flow-abandon` lifecycle operation described below before routing the original task again.
19
22
  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
23
  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
- 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
-
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
+ 8. Read [DELIVERY-BOUNDARIES.md](DELIVERY-BOUNDARIES.md) before stopping a task or showing any consumption number. A task may only stop at a clean boundary, such a stop is `delivered-stage-N` and never `failed` or `blocked`, and a number without a measured baseline is never displayed.
25
+ 9. 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.
24
26
 
25
27
  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.
26
28
 
29
+ If the original flow is terminal and must release its task slot, invoke the discovered `flow-cancel` or `flow-abandon` operation with that same `flowId`. `flow-cancel` records an owner cancellation and keeps the task bound for read-only recovery. `flow-abandon` records the termination, clears acceptance and checkpoint state, unbinds the task and returns `nextStep.operation: "start"` so the original task can be routed again. These operations are available through the server and `local flow-cancel` / `local flow-abandon`; they never reopen the old flow.
30
+
27
31
  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.
28
32
 
29
33
  ## Operations and visible progress
package/break-glass.mjs CHANGED
@@ -1,10 +1,16 @@
1
1
  import { promises as fs } from 'node:fs';
2
- import { randomUUID } from 'node:crypto';
2
+ import { createHash, randomUUID } from 'node:crypto';
3
3
  import os from 'node:os';
4
4
  import path from 'node:path';
5
5
 
6
6
  export const breakGlassSchema = 'entrorove.ide-break-glass/1.0';
7
7
  export const breakGlassTtlMs = 15 * 60 * 1000;
8
+ export function breakGlassSessionDigest(sessionId) {
9
+ if (typeof sessionId !== 'string' || !/^[A-Za-z0-9_-]{1,200}$/u.test(sessionId)) {
10
+ throw new Error('Break-glass requires the current IDE session identity');
11
+ }
12
+ return createHash('sha256').update(sessionId).digest('hex');
13
+ }
8
14
 
9
15
  function homeDirectory(home) {
10
16
  if (typeof home !== 'string' || !path.isAbsolute(home)) throw new Error('Break-glass home must be absolute');
@@ -38,11 +44,12 @@ export function breakGlassPath(home = os.homedir()) {
38
44
  return path.join(homeDirectory(home), 'break-glass.json');
39
45
  }
40
46
 
41
- export async function writeBreakGlass(home = os.homedir(), now = Date.now()) {
47
+ export async function writeBreakGlass(home = os.homedir(), now = Date.now(), sessionId) {
42
48
  if (!Number.isFinite(now)) throw new Error('Break-glass clock is invalid');
49
+ const sessionDigest = breakGlassSessionDigest(sessionId);
43
50
  const file = breakGlassPath(home), directory = path.dirname(file);
44
51
  await ensurePrivateDirectory(directory, home);
45
- const value = { schemaVersion: breakGlassSchema, mode: 'management-only', createdAt: new Date(now).toISOString(),
52
+ const value = { schemaVersion: breakGlassSchema, mode: 'management-only', sessionDigest, createdAt: new Date(now).toISOString(),
46
53
  expiresAt: new Date(now + breakGlassTtlMs).toISOString() };
47
54
  const stage = `${file}.${randomUUID()}.tmp`;
48
55
  await fs.writeFile(stage, `${JSON.stringify(value)}\n`, { flag: 'wx', mode: 0o600 });
package/cli.mjs CHANGED
@@ -1,6 +1,5 @@
1
1
  #!/usr/bin/env node
2
2
  import { createHash, randomUUID } from 'node:crypto';
3
- import { execFileSync } from 'node:child_process';
4
3
  import { promises as fs, realpathSync } from 'node:fs';
5
4
  import os from 'node:os';
6
5
  import path from 'node:path';
@@ -12,17 +11,18 @@ import { runNative } from './native-client.mjs';
12
11
  import { configureNativeNotifications, notificationSettings, sendNativeNotification } from './native-notification.mjs';
13
12
  import { runTaskMessageCli } from './task-message-cli.mjs';
14
13
  import { runScopeConnect } from './scope-runner-client.mjs';
15
- import { apiUrl, buildInvocation, invokeRemote, queryFlows, queryStatus } from './remote-client.mjs';
14
+ import { apiUrl, buildInvocation, claimRecurring, finishRecurring, invokeRemote, listRecurring, queryBillingExport, queryFlows, queryStatus, queryUsageSummary, readRecurringRuns } from './remote-client.mjs';
16
15
  const packageDir = path.dirname(fileURLToPath(import.meta.url));
17
16
  import { buildReview, submitSkillFeedback, feedbackNoticeVersion } from './review-client.mjs';
18
17
  import { queryCloudTask, runCloudTask as dispatchCloudTask } from './cloud-task-client.mjs';
19
18
  import { watchCloudTask } from './cloud-watch-client.mjs';
20
19
  import { queryAutomaticFeedback } from './automatic-feedback-client.mjs';
21
- import { installIdeHooks } from './ide-install.mjs';
22
- import { assertIdeExecution } from './ide-session.mjs';
20
+ import { activeIdeAdapters, installIdeHooks, uninstallIdeHooks } from './ide-install.mjs';
21
+ import { cleanManagedDuplicates } from './install-cleanup.mjs';
22
+ import { assertIdeExecution, ideGuardBinding, settleBreakGlassSession } from './ide-session.mjs';
23
23
  import { writeBreakGlass } from './break-glass.mjs';
24
24
  const markerName = '.entrorove-managed.json';
25
- const installFiles = ['SKILL.md', 'CAPABILITY-MAP.md', 'AGREEMENT.md', 'manifest.json', 'IDE-EXECUTION.md', 'REUSE-DISCIPLINE.md'];
25
+ const installFiles = ['SKILL.md', 'CAPABILITY-MAP.md', 'AGREEMENT.md', 'manifest.json', 'IDE-EXECUTION.md', 'REUSE-DISCIPLINE.md', 'DELIVERY-BOUNDARIES.md'];
26
26
  const markerSchema = 'entrorove.managed-install/1.0';
27
27
  const agreementSchema = 'entrorove.agreement-acceptance/1.0';
28
28
  const installLockWaitMs = 10_000;
@@ -87,8 +87,8 @@ export function agreementInteraction() {
87
87
  };
88
88
  }
89
89
 
90
- function agreementRequired(home) {
91
- const agreement = path.join(globalInstallPath(home), 'AGREEMENT.md');
90
+ function agreementRequired() {
91
+ const agreement = path.join(packageDir, 'AGREEMENT.md');
92
92
  return {
93
93
  ok: false,
94
94
  error: 'agreement-required',
@@ -246,57 +246,35 @@ async function updateGlobalInstall(target, bundle) {
246
246
  return true;
247
247
  }
248
248
 
249
- function projectRoot(cwd) {
250
- try {
251
- return execFileSync('git', ['-C', cwd, 'rev-parse', '--show-toplevel'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
252
- } catch {
253
- return null;
254
- }
255
- }
256
-
257
- function gitTracked(root, relativePath) {
258
- const files = execFileSync('git', ['-C', root, 'ls-files', '--cached', '--', relativePath], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
259
- return files.trim().length > 0;
260
- }
261
-
262
- async function removeManagedDuplicate(directory, root) {
263
- if (gitTracked(root, path.relative(root, directory))) return false;
264
- if (!await managedState(directory)) return false;
265
- const quarantine = `${directory}.entrorove-cleanup-${randomUUID()}`;
266
- await fs.rename(directory, quarantine);
267
- try {
268
- if (!await managedState(quarantine)) throw new Error('Managed duplicate changed during cleanup');
269
- await fs.rm(quarantine, { recursive: true });
270
- } catch (error) {
271
- await fs.rename(quarantine, directory);
272
- throw error;
273
- }
274
- return true;
275
- }
276
-
277
- async function cleanProjectDuplicates(target, cwd) {
278
- const root = projectRoot(cwd);
279
- if (!root) return [];
280
- const removed = [];
281
- for (const relative of ['.codex/skills/entrorove', '.agents/skills/entrorove', '.claude/skills/entrorove']) {
282
- const directory = path.join(root, relative);
283
- if (directory === target) continue;
284
- if (await removeManagedDuplicate(directory, root)) removed.push(relative);
285
- }
286
- return removed;
287
- }
288
-
289
- export async function install({ home = os.homedir(), cwd = process.cwd() } = {}) {
290
- const bundle = await sourceBundle();
249
+ export async function install({ home = os.homedir(), cwd = process.cwd(), explicit = false } = {}) {
291
250
  const target = globalInstallPath(home);
292
- const stale = await agreementStale(home);
251
+ if (!explicit && !(await managedState(target))) return { installed: false, updated: false };
293
252
  const parent = path.dirname(target);
294
253
  await fs.mkdir(parent, { recursive: true, mode: 0o700 });
295
254
  return withInstallLock(parent, async () => {
255
+ const active = explicit ? null : await activeIdeAdapters({ home });
256
+ if (!explicit && (!(await managedState(target)) || active.length === 0)) return { installed: false, updated: false };
257
+ const bundle = await sourceBundle();
258
+ const stale = await agreementStale(home);
296
259
  const updated = await updateGlobalInstall(target, bundle);
297
- const removed = await cleanProjectDuplicates(target, cwd);
298
- const ide = await installIdeHooks({ home, version: bundle.manifest.version });
299
- return { version: bundle.manifest.version, updated, removed, agreementStale: stale, ide };
260
+ const { removed, preserved } = await cleanManagedDuplicates({
261
+ target, cwd, home, isManaged: managedState, includeProject: explicit,
262
+ });
263
+ const ide = await installIdeHooks({ home, version: bundle.manifest.version, adapters: active });
264
+ return { version: bundle.manifest.version, updated, removed, preserved, agreementStale: stale, ide };
265
+ });
266
+ }
267
+
268
+ export async function uninstall({ home = os.homedir() } = {}) {
269
+ const target = globalInstallPath(home), parent = path.dirname(target);
270
+ await fs.mkdir(parent, { recursive: true, mode: 0o700 });
271
+ return withInstallLock(parent, async () => {
272
+ const ide = await uninstallIdeHooks({ home });
273
+ const managed = await managedState(target);
274
+ if (managed) await fs.rm(target, { recursive: true });
275
+ return { uninstalled: managed !== false && ide.preserved.length === 0,
276
+ removed: [...ide.removed, ...(managed ? [target] : [])],
277
+ preserved: [...ide.preserved, ...(managed === false ? [{ path: target, reason: 'unmanaged-or-edited' }] : [])] };
300
278
  });
301
279
  }
302
280
 
@@ -407,14 +385,12 @@ export async function runCloudWatch(input, dependencies = remoteDependencies())
407
385
  }
408
386
 
409
387
  async function invoke(data) {
410
- if (data?.operation === 'cloud-task') {
411
- return runCloudTask(data.input, remoteDependencies());
412
- }
413
- if (data?.operation === 'cloud-status') {
414
- return runCloudStatus(data.input, remoteDependencies());
415
- }
388
+ if (data?.operation === 'cloud-task') return runCloudTask(data.input, remoteDependencies());
389
+ if (data?.operation === 'cloud-status') return runCloudStatus(data.input, remoteDependencies());
416
390
  if (data?.operation === 'cloud-watch') return runCloudWatch(data.input);
417
- return invokeRemote(data, remoteDependencies());
391
+ const result = await invokeRemote(data, remoteDependencies());
392
+ await settleBreakGlassSession(['invoke'], data, result);
393
+ return result;
418
394
  }
419
395
 
420
396
  export { buildReview };
@@ -425,12 +401,36 @@ async function submitReview(data) {
425
401
  process.stdout.write(JSON.stringify(result) + '\n');
426
402
  }
427
403
 
404
+ async function runAccountCommand(command, args) {
405
+ await install();
406
+ const home = os.homedir();
407
+ if (!await agreementAccepted(home)) {
408
+ process.stdout.write(JSON.stringify(agreementRequired(home)) + '\n');
409
+ process.exitCode = 2;
410
+ return;
411
+ }
412
+ const dependencies = remoteDependencies();
413
+ if (command === 'status') return queryStatus(args[0], dependencies);
414
+ if (command === 'flows') return queryFlows(dependencies);
415
+ if (command === 'usage') return queryUsageSummary(args[0], dependencies);
416
+ if (command === 'billing') return queryBillingExport(dependencies);
417
+ if (args[0] === 'list' && args.length === 1) return listRecurring(dependencies);
418
+ if (args[0] === 'claim' && args.length === 1) return claimRecurring(`cli-${process.pid}-${Date.now().toString(36)}`, dependencies);
419
+ if (args[0] === 'finish' && args.length === 2) return finishRecurring(args[1], await stdinJson(), dependencies);
420
+ if (args[0] === 'runs' && args.length === 2) return readRecurringRuns(args[1], dependencies);
421
+ throw new Error('Usage: entrorove recurring list | claim | finish <runId> | runs <recurringId>');
422
+ }
423
+
428
424
  async function main() {
429
425
  const [command, ...args] = process.argv.slice(2);
430
426
  if (command === 'unlock' && args.length === 0) {
431
- process.stdout.write(`${JSON.stringify(await writeBreakGlass())}\n`);
427
+ process.stdout.write(`${JSON.stringify(await writeBreakGlass(os.homedir(), Date.now(), ideGuardBinding().sessionId))}\n`);
428
+ } else if (command === 'uninstall' && args.length === 0) {
429
+ const result = await uninstall();
430
+ process.stdout.write(`${JSON.stringify(result)}\n`);
431
+ if (!result.uninstalled) process.exitCode = 2;
432
432
  } else if (command === 'install' && args.length === 0) {
433
- process.stdout.write(JSON.stringify(await install()) + '\n');
433
+ process.stdout.write(JSON.stringify(await install({ explicit: true })) + '\n');
434
434
  } else if (command === 'configure' && args.length === 0) {
435
435
  const data = await stdinJson();
436
436
  if (Object.keys(data).length !== 1 || typeof data.token !== 'string') throw new Error('Expected {"token":"..."} on stdin');
@@ -468,26 +468,14 @@ async function main() {
468
468
  process.stdout.write(`${JSON.stringify(await queryAutomaticFeedback(args[0],remoteDependencies(),args[1]))}\n`);
469
469
  } else if (command === 'review' && args.length === 0) {
470
470
  await submitReview(await stdinJson());
471
- } else if (command === 'status' && args.length === 1) {
472
- await install();
473
- if (!await agreementAccepted(os.homedir())) {
474
- process.stdout.write(JSON.stringify(agreementRequired(os.homedir())) + '\n');
475
- process.exitCode = 2;
476
- return;
477
- }
478
- await queryStatus(args[0], remoteDependencies());
479
- } else if (command === 'flows' && args.length === 0) {
480
- await install();
481
- if (!await agreementAccepted(os.homedir())) {
482
- process.stdout.write(JSON.stringify(agreementRequired(os.homedir())) + '\n');
483
- process.exitCode = 2;
484
- return;
485
- }
486
- await queryFlows(remoteDependencies());
471
+ } else if (command === 'status' && args.length === 1 || command === 'flows' && args.length === 0
472
+ || command === 'usage' && args.length <= 1 || command === 'billing' && args.length === 0
473
+ || command === 'recurring') {
474
+ await runAccountCommand(command, args);
487
475
  } else if (command === 'local') {
488
476
  await runLocal(args);
489
477
  } else {
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)');
478
+ throw new Error('Usage: entrorove unlock | uninstall | 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 | usage [flowId] | billing | recurring list|claim|finish|runs | review | local (JSON on stdin)');
491
479
  }
492
480
  }
493
481
  if (process.argv[1] && realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url))) { const { captureIdeResults } = await import('./ide-results.mjs');
package/ide-code-mode.mjs CHANGED
@@ -29,6 +29,12 @@ export function inspectIdeCodeMode(code, runtime) {
29
29
  const args = literal(node.arguments[0]), command = literalCommand(args.cmd ?? args.command);
30
30
  if (!(readCommand(command) || sdkCommand(command, runtime))) allowed = false;
31
31
  } catch { allowed = false; }
32
+ } else if (name === 'tools.update_goal' || name === 'tools.get_goal') {
33
+ try {
34
+ const args = node.arguments.length ? literal(node.arguments[0]) : {};
35
+ if (name === 'tools.update_goal' && !['complete', 'blocked', 'paused'].includes(args?.status)) allowed = false;
36
+ if (name === 'tools.get_goal' && (args === null || typeof args !== 'object' || Array.isArray(args))) allowed = false;
37
+ } catch { allowed = false; }
32
38
  } else if (!['text','image','notify','Promise.all','Promise.allSettled'].includes(name)
33
39
  && !/^tools\.(?:view_image|web__run|list_mcp_resources|list_mcp_resource_templates|read_mcp_resource|clock__curr_time)$/u.test(name)) allowed = false;
34
40
  }
package/ide-command.mjs CHANGED
@@ -28,7 +28,10 @@ export function sdkCommand(parsed, runtime) {
28
28
  if (!parsed) return null;
29
29
  const words = [...parsed.words];
30
30
  if (words[0]?.startsWith('ENTROROVE_IDE=')) {
31
- if (words.shift() !== 'ENTROROVE_IDE=Codex') return null;
31
+ if (!/^ENTROROVE_IDE=[\p{L}\p{N} .+_-]{2,48}$/u.test(words.shift())) return null;
32
+ while (['ENTROROVE_SESSION=', 'CODEX_THREAD_ID='].some(prefix => words[0]?.startsWith(prefix))) {
33
+ if (!/^[A-Z_]+=[A-Za-z0-9_-]{1,200}$/u.test(words.shift())) return null;
34
+ }
32
35
  }
33
36
  let args;
34
37
  if ([runtime.node, 'node'].includes(words[0]) && words[1] === runtime.cli) args = words.slice(2);
@@ -40,11 +43,15 @@ export function sdkCommand(parsed, runtime) {
40
43
  } else if (path.basename(words[0]) === 'entrorove') {
41
44
  args = words.slice(1);
42
45
  } else return null;
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 };
46
+ const simple = new Set(['invoke', 'cloud-task', 'cloud-status', 'cloud-watch', 'agreement', 'configure', 'queue', 'unlock']);
47
+ if (['unlock', 'uninstall'].includes(args[0]) && args.length === 1 && !parsed.input) return { args };
45
48
  if (simple.has(args[0]) && args.length === 1 && parsed.input) return { args, input: parsed.input };
46
49
  if (['status', 'feedback'].includes(args[0]) && /^[a-f0-9]{64}$/u.test(args[1] ?? '') && [2,3].includes(args.length)) return { args };
47
- if (['flows', 'install'].includes(args[0]) && args.length === 1 && !parsed.input) return { args };
50
+ if (['flows', 'install', 'billing'].includes(args[0]) && args.length === 1 && !parsed.input) return { args };
51
+ if (args[0] === 'recurring' && ['list', 'claim'].includes(args[1] ?? '') && args.length === 2 && !parsed.input) return { args };
52
+ if (args[0] === 'recurring' && ['finish', 'runs'].includes(args[1] ?? '') && args.length === 3 && (!parsed.input || args[1] === 'finish')) return { args, ...(parsed.input ? { input: parsed.input } : {}) };
53
+ if (args[0] === 'usage' && args.length <= 2 && !parsed.input
54
+ && (args[1] === undefined || /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/u.test(args[1]))) return { args };
48
55
  if (args[0] === 'local' && /^[a-z][a-z0-9-]+$/u.test(args[1] ?? '') && [2,3].includes(args.length) && parsed.input) return { args, input: parsed.input };
49
56
  if (args[0] === 'scope-connect' && args.length === 2 && parsed.input) return { args, input: parsed.input };
50
57
  if (args[0] === 'native' && args.length === 3 && parsed.input) return { args, input: parsed.input };
@@ -72,8 +79,8 @@ export function readCommand(parsed) {
72
79
  return false;
73
80
  }
74
81
 
75
- export function renderSdkCommand(call, runtime, sessionId) {
82
+ export function renderSdkCommand(call, runtime, sessionId, host = process.env.ENTROROVE_IDE ?? 'Codex') {
76
83
  const quote = value => `'${String(value).replace(/'/gu, "'\\''")}'`;
77
- const header = `ENTROROVE_IDE=Codex CODEX_THREAD_ID=${quote(sessionId)} ${[runtime.node, runtime.cli, ...call.args].map(quote).join(' ')}`;
84
+ const header = `ENTROROVE_IDE=${host} ENTROROVE_SESSION=${quote(sessionId)} CODEX_THREAD_ID=${quote(sessionId)} ${[runtime.node, runtime.cli, ...call.args].map(quote).join(' ')}`;
78
85
  return call.input ? `${header} <<'ENTROROVE_INPUT'\n${JSON.stringify(call.input)}\nENTROROVE_INPUT` : header;
79
86
  }
package/ide-hook.mjs CHANGED
@@ -2,6 +2,7 @@
2
2
  import { realpathSync } from 'node:fs';
3
3
  import { fileURLToPath } from 'node:url';
4
4
  import { ideSession, ideRuntime, bindIdeAccount, ideContext, ideDigest, readBreakGlass } from './ide-session.mjs';
5
+ import { isStageTerminal, stageOf } from './local-stage-boundary.mjs';
5
6
  import { literalCommand, sdkCommand, readCommand, renderSdkCommand, installerCommand } from './ide-command.mjs';
6
7
  import { inspectIdeCodeMode } from './ide-code-mode.mjs';
7
8
  import { pathToFileURL } from 'node:url';
@@ -13,9 +14,18 @@ import { registerIdeRequirement, ideQueueContext, unclassifiedIdeRequirements,
13
14
  const output = (event, extra = {}) => ({ hookSpecificOutput: { hookEventName: event, ...extra } });
14
15
  const deny = reason => output('PreToolUse', { permissionDecision: 'deny', permissionDecisionReason: reason });
15
16
  const publicId = value => typeof value === 'string' && /^[A-Za-z0-9_-]{1,200}$/u.test(value);
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 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', 'get_goal']);
17
18
  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
+ const exitPrompt = /^(?:(?:请)?(?:停止使用|退出)\s*\$?entrorove|(?:stop using|exit)\s+\$?entrorove)[.!。!]?$/iu;
20
+ const selectPrompt = /^(?:(?:请)?(?:使用|启用)\s+\$?entrorove|\$entrorove)(?:\s|[,。,::!!]|$)/iu;
21
+ function userPrompt(event) {
22
+ let prompt = (event.prompt ?? '').trim();
23
+ for (const wrapper of ['**', '__', '`']) {
24
+ if (prompt.startsWith(wrapper) && prompt.endsWith(wrapper)) prompt = prompt.slice(wrapper.length, -wrapper.length).trim();
25
+ }
26
+ return prompt;
27
+ }
28
+ const managementOperations = new Set(['capabilities', 'help', 'start', 'continue', 'task-describe', 'flow-cancel', 'flow-abandon', 'scope-connect', 'status', 'feedback', 'cloud-status', 'cloud-watch', 'queue', 'unlock', 'usage', 'billing', 'recurring']);
19
29
  function operationOf(call) {
20
30
  if (!call) return null;
21
31
  if (call.args[0] === 'invoke') return call.input?.operation ?? null;
@@ -66,6 +76,9 @@ function collectResult(state, values, local) {
66
76
  }
67
77
  const nextStep = value.nextStep ?? value.result?.nextStep ?? value.result?.result?.nextStep;
68
78
  if (!lifecycle && (value.status === 'needs-input' || value.result?.status === 'needs-input' || value.interaction || value.result?.interaction)) state.phase = 'needs-input';
79
+ else if (isStageTerminal(value.status) || isStageTerminal(value.result?.status)) {
80
+ state.phase = 'delivered'; state.deliveredStage = stageOf(value.status) ?? stageOf(value.result?.status);
81
+ }
69
82
  else if (!lifecycle && (['blocked', 'failed', 'incomplete'].includes(value.status) || ['blocked', 'failed', 'incomplete'].includes(value.result?.status)
70
83
  || ['blocked', 'failed'].includes(value.taskStatus))) state.phase = 'blocked';
71
84
  else if (!lifecycle && (nextStep || value.nextAction)) state.phase = 'continuing';
@@ -87,7 +100,8 @@ function collectResult(state, values, local) {
87
100
  return messages;
88
101
  }
89
102
  async function beforeTool(event, state, home) {
90
- const unlocked = await readBreakGlass(home);
103
+ if (state.phase === 'user-exited') return {};
104
+ const unlocked = await readBreakGlass(home, state.sessionId);
91
105
  const command = event.tool_input?.command ?? event.tool_input?.cmd, parsed = literalCommand(command);
92
106
  const isShell = ['Bash', 'exec_command', 'shell_command'].includes(event.tool_name);
93
107
  const installer = isShell && installerCommand(parsed);
@@ -105,6 +119,12 @@ async function beforeTool(event, state, home) {
105
119
  const runtime = isShell && parsed ? await ideRuntime(home) : null;
106
120
  const call = runtime && sdkCommand(parsed, runtime);
107
121
  if (call) {
122
+ if (call.args[0] === 'uninstall') {
123
+ state.active = false; state.phase = 'user-exited';
124
+ return output('PreToolUse', { permissionDecision: 'allow', updatedInput: { ...event.tool_input,
125
+ command: renderSdkCommand(call, runtime, state.sessionId),
126
+ ...(Object.hasOwn(event.tool_input ?? {}, 'cmd') ? { cmd: renderSdkCommand(call, runtime, state.sessionId) } : {}) } });
127
+ }
108
128
  state.active = true;
109
129
  if (call.args[0] !== 'configure' && call.args[0] !== 'unlock') await bindIdeAccount(state, home);
110
130
  if (state.phase === 'setup' && !unlocked && !isManagementCall(call)) return denyBlocked(state, event, 'Entrorove:自举阶段只放行 Entrorove 管理调用(建 flow、改目标、scope-connect、capabilities、status、queue 等);业务与写操作需先建立原 flow。');
@@ -117,7 +137,7 @@ async function beforeTool(event, state, home) {
117
137
  const newStart = call.args[0] === 'cloud-task' || call.args[0] === 'invoke' && ['start','cloud-task'].includes(call.input?.operation);
118
138
  if (newStart && (state.flowId || state.taskId) && state.phase !== 'ended') return denyBlocked(state, event, 'Entrorove:必须继续原任务,禁止用第二次 start 或 cloud-task 绕过原门禁。');
119
139
  const discovery = ['invoke','local'].includes(call.args[0]) && ['capabilities','help'].includes(call.input?.operation ?? call.args[1]);
120
- const observer = discovery || ['status','feedback','cloud-status','configure','agreement','install','queue','flows'].includes(call.args[0]);
140
+ const observer = discovery || ['status','feedback','cloud-status','configure','agreement','install','queue','flows','usage','billing','recurring'].includes(call.args[0]);
121
141
  if (unclassifiedIdeRequirements(state) && !observer && !(unlocked && isManagementCall(call))) return denyBlocked(state, event, 'Entrorove:新需求已保存,先用 queue status 读取并按轻重缓急 route 分类;禁止丢弃新需求或覆盖原任务。');
122
142
  if (newStart && !checkQueuedStart(state,call)) return denyBlocked(state, event, 'Entrorove:新任务目标必须与实际进入队列的用户原文一致。');
123
143
  if (call.args[0] === 'local' && !discovery && !state.flowId) return denyBlocked(state, event, 'Entrorove:本地执行前必须先受理原任务并绑定账号、flowId 与仓库;不能直接使用其它流程的通行证。');
@@ -149,21 +169,29 @@ async function beforeTool(event, state, home) {
149
169
  const current = await ideRuntime(home), code = typeof event.tool_input === 'string' ? event.tool_input : event.tool_input?.code;
150
170
  return inspectIdeCodeMode(code, current) ? output('PreToolUse', { additionalContext: ideContext(state) }) : denyBlocked(state, event, 'Entrorove:代码模式含未批准的原生执行或动态工具输入;请使用原任务的受控入口。');
151
171
  }
172
+ if (event.tool_name === 'update_goal' && (state.phase === 'setup' || unlocked)) return output('PreToolUse', { additionalContext: ideContext(state) });
152
173
  if (isShell && readCommand(parsed) || readTools.has(event.tool_name)) return output('PreToolUse', { additionalContext: ideContext(state) });
153
174
  return denyBlocked(state, event, `Entrorove:已阻止原生 ${event.tool_name ?? 'unknown'} 调用。当前阶段 ${state.phase}${state.taskNumber ? `,云端任务 ${state.taskNumber}` : ''}。写入、删除、移动及不确定的终端操作必须使用原任务的 guarded-write/guarded-delete/guarded-move 或已批准的执行计划。`);
154
175
  }
155
176
  export async function runIdeHook(event, { home } = {}) {
156
177
  return ideSession(event, async state => {
157
178
  const name = event.hook_event_name;
158
- if (name === 'UserPromptSubmit' && /^(?:(?:请)?(?:停止使用|退出)\s*\$?entrorove|(?:stop using|exit)\s+\$?entrorove)[.!。!]?$/iu.test((event.prompt ?? '').trim())) {
179
+ const selected = name === 'UserPromptSubmit' && selectPrompt.test(userPrompt(event));
180
+ const newSelection = selected && !state.active;
181
+ if (name === 'PreToolUse') state.enforcement = 'enforced';
182
+ if (name === 'UserPromptSubmit' && exitPrompt.test(userPrompt(event))) {
183
+ if (state.phase !== 'user-exited') state.exitPhase = state.phase;
159
184
  state.active = false; state.phase = 'user-exited';
160
185
  return output(name, { additionalContext: '用户明确退出 Entrorove 管理;原任务仍保留,未验收、未评分为成功。' });
161
186
  }
162
- if (name === 'UserPromptSubmit' && /\bentrorove\b/iu.test(event.prompt ?? '')) {
187
+ if (selected) {
163
188
  state.active = true;
164
- if (state.phase === 'ended') { delete state.flowId; delete state.taskId; delete state.taskNumber; delete state.requestId; state.phase = 'entry'; }
189
+ if (['ended', 'user-exited'].includes(state.phase)) {
190
+ if (state.phase === 'ended') { delete state.flowId; delete state.taskId; delete state.taskNumber; delete state.requestId; }
191
+ state.phase = state.exitPhase ?? 'entry'; delete state.exitPhase;
192
+ }
165
193
  }
166
- if (name === 'UserPromptSubmit') registerIdeRequirement(state,event);
194
+ if (name === 'UserPromptSubmit' && state.active && !newSelection) registerIdeRequirement(state,event);
167
195
  if (name === 'PreToolUse') return beforeTool(event, state, home);
168
196
  const pendingRead = state.readCalls?.[event.tool_use_id];
169
197
  if (name === 'PostToolUse' && state.active && (state.pendingCall?.toolUseId === event.tool_use_id || pendingRead)) {
@@ -202,10 +230,10 @@ export async function runIdeHook(event, { home } = {}) {
202
230
  if (resumed) messages.push(resumed);
203
231
  return { ...output(name, { additionalContext: ideContext(state) }), ...(messages.length ? { systemMessage: messages.join('\n') } : {}) };
204
232
  }
205
- if (name==='Stop' && state.active && !(await readBreakGlass(home))) {
233
+ if (name==='Stop' && state.active && !(await readBreakGlass(home, state.sessionId))) {
206
234
  const resumed = await advanceIdeQueue(state,home);
207
- if (resumed || unclassifiedIdeRequirements(state) || unfinishedIdeQueue(state) && !['blocked','needs-input'].includes(state.phase)
208
- || !['ended','blocked','needs-input','setup'].includes(state.phase)) return {decision:'block',reason:blockedReason(state, event, `${ideContext(state)}${ideQueueContext(state)}${resumed}原任务或队列尚未取得结束回执;继续可运行工作,真实阻断必须明确报告。`)};
235
+ if (resumed || unclassifiedIdeRequirements(state) || unfinishedIdeQueue(state) && !['blocked','needs-input','delivered'].includes(state.phase)
236
+ || !['ended','blocked','needs-input','setup','delivered'].includes(state.phase)) return {decision:'block',reason:blockedReason(state, event, `${ideContext(state)}${ideQueueContext(state)}${resumed}原任务或队列尚未取得结束回执;继续可运行工作,真实阻断必须明确报告。`)};
209
237
  }
210
238
  return state.active && ['SessionStart','UserPromptSubmit','PostCompact','PreCompact'].includes(name) ? output(name, { additionalContext: `${ideContext(state)}${ideQueueContext(state)}` }) : {};
211
239
  }, home);