mcp-server-opencode 1.2.0 → 1.3.0

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.
Files changed (89) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/README.md +34 -5
  3. package/build/src/index.js +19 -6
  4. package/build/src/index.js.map +1 -1
  5. package/build/src/modules/shared/execution-report.d.ts +66 -0
  6. package/build/src/modules/shared/execution-report.d.ts.map +1 -0
  7. package/build/src/modules/shared/execution-report.js +407 -0
  8. package/build/src/modules/shared/execution-report.js.map +1 -0
  9. package/build/src/modules/shared/execution-usage.d.ts +50 -0
  10. package/build/src/modules/shared/execution-usage.d.ts.map +1 -0
  11. package/build/src/modules/shared/execution-usage.js +89 -0
  12. package/build/src/modules/shared/execution-usage.js.map +1 -0
  13. package/build/src/modules/shared/git-workspace.d.ts +34 -0
  14. package/build/src/modules/shared/git-workspace.d.ts.map +1 -0
  15. package/build/src/modules/shared/git-workspace.js +193 -0
  16. package/build/src/modules/shared/git-workspace.js.map +1 -0
  17. package/build/src/modules/shared/instructions.d.ts.map +1 -1
  18. package/build/src/modules/shared/instructions.js +8 -2
  19. package/build/src/modules/shared/instructions.js.map +1 -1
  20. package/build/src/modules/shared/message-id.d.ts +3 -0
  21. package/build/src/modules/shared/message-id.d.ts.map +1 -0
  22. package/build/src/modules/shared/message-id.js +6 -0
  23. package/build/src/modules/shared/message-id.js.map +1 -0
  24. package/build/src/modules/shared/opencode-client.d.ts +12 -2
  25. package/build/src/modules/shared/opencode-client.d.ts.map +1 -1
  26. package/build/src/modules/shared/opencode-client.js +117 -34
  27. package/build/src/modules/shared/opencode-client.js.map +1 -1
  28. package/build/src/modules/shared/permission-scopes.d.ts +6 -0
  29. package/build/src/modules/shared/permission-scopes.d.ts.map +1 -0
  30. package/build/src/modules/shared/permission-scopes.js +60 -0
  31. package/build/src/modules/shared/permission-scopes.js.map +1 -0
  32. package/build/src/modules/shared/permissions.d.ts +2 -0
  33. package/build/src/modules/shared/permissions.d.ts.map +1 -1
  34. package/build/src/modules/shared/permissions.js +24 -3
  35. package/build/src/modules/shared/permissions.js.map +1 -1
  36. package/build/src/modules/shared/prompt-dispatch.d.ts +13 -0
  37. package/build/src/modules/shared/prompt-dispatch.d.ts.map +1 -0
  38. package/build/src/modules/shared/prompt-dispatch.js +34 -0
  39. package/build/src/modules/shared/prompt-dispatch.js.map +1 -0
  40. package/build/src/modules/shared/run-observer.d.ts +5 -0
  41. package/build/src/modules/shared/run-observer.d.ts.map +1 -0
  42. package/build/src/modules/shared/run-observer.js +421 -0
  43. package/build/src/modules/shared/run-observer.js.map +1 -0
  44. package/build/src/modules/shared/server-registry.d.ts +4 -0
  45. package/build/src/modules/shared/server-registry.d.ts.map +1 -1
  46. package/build/src/modules/shared/server-registry.js.map +1 -1
  47. package/build/src/modules/shared/task-registry.d.ts +64 -0
  48. package/build/src/modules/shared/task-registry.d.ts.map +1 -1
  49. package/build/src/modules/shared/task-registry.js +75 -0
  50. package/build/src/modules/shared/task-registry.js.map +1 -1
  51. package/build/src/modules/shared/workspace-manager.d.ts +25 -0
  52. package/build/src/modules/shared/workspace-manager.d.ts.map +1 -0
  53. package/build/src/modules/shared/workspace-manager.js +105 -0
  54. package/build/src/modules/shared/workspace-manager.js.map +1 -0
  55. package/build/src/modules/tools/cancel_task.d.ts.map +1 -1
  56. package/build/src/modules/tools/cancel_task.js +26 -2
  57. package/build/src/modules/tools/cancel_task.js.map +1 -1
  58. package/build/src/modules/tools/cleanup_workspace.d.ts +3 -0
  59. package/build/src/modules/tools/cleanup_workspace.d.ts.map +1 -0
  60. package/build/src/modules/tools/cleanup_workspace.js +82 -0
  61. package/build/src/modules/tools/cleanup_workspace.js.map +1 -0
  62. package/build/src/modules/tools/continue_task.d.ts.map +1 -1
  63. package/build/src/modules/tools/continue_task.js +116 -6
  64. package/build/src/modules/tools/continue_task.js.map +1 -1
  65. package/build/src/modules/tools/get_task_result.d.ts.map +1 -1
  66. package/build/src/modules/tools/get_task_result.js +108 -40
  67. package/build/src/modules/tools/get_task_result.js.map +1 -1
  68. package/build/src/modules/tools/get_task_status.d.ts.map +1 -1
  69. package/build/src/modules/tools/get_task_status.js +69 -6
  70. package/build/src/modules/tools/get_task_status.js.map +1 -1
  71. package/build/src/modules/tools/index.d.ts.map +1 -1
  72. package/build/src/modules/tools/index.js +2 -0
  73. package/build/src/modules/tools/index.js.map +1 -1
  74. package/build/src/modules/tools/list_agents.d.ts.map +1 -1
  75. package/build/src/modules/tools/list_agents.js +26 -3
  76. package/build/src/modules/tools/list_agents.js.map +1 -1
  77. package/build/src/modules/tools/start_server.d.ts.map +1 -1
  78. package/build/src/modules/tools/start_server.js +9 -9
  79. package/build/src/modules/tools/start_server.js.map +1 -1
  80. package/build/src/modules/tools/start_task.d.ts.map +1 -1
  81. package/build/src/modules/tools/start_task.js +218 -13
  82. package/build/src/modules/tools/start_task.js.map +1 -1
  83. package/build/src/modules/tools/stop_server.d.ts.map +1 -1
  84. package/build/src/modules/tools/stop_server.js +17 -0
  85. package/build/src/modules/tools/stop_server.js.map +1 -1
  86. package/build/src/modules/tools/wait_for_task.d.ts.map +1 -1
  87. package/build/src/modules/tools/wait_for_task.js +110 -24
  88. package/build/src/modules/tools/wait_for_task.js.map +1 -1
  89. package/package.json +2 -1
package/CHANGELOG.md CHANGED
@@ -7,6 +7,33 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  Releases before `1.2.0` predate this file and are not documented here.
9
9
 
10
+ ## Unreleased
11
+
12
+ ## [1.3.0] — 2026-10-19
13
+
14
+ ### Added
15
+
16
+ - Caller-provided expected workspace changes and bounded executable checks, with additive per-run `workspace_delta` reports and verification states.
17
+ - Workspace-wide overlapping-directory leases, report-aware waits, and an independent lifecycle observer for accepted runs.
18
+ - Prompt dispatch now distinguishes definite HTTP 4xx rejection from ambiguous ACK failures. Ambiguous runs remain tracked with their writer lease held and return `dispatch_uncertain`; definite rejection rolls back only the provisional run.
19
+ - Caller message IDs use OpenCode's required `msg_` namespace; the real smoke includes a non-billable bare-UUID runtime probe.
20
+ - Bounded filesystem inventory, check output, and direct `shell: false` execution; report diagnostics disclose incomplete evidence and shared-workspace attribution uncertainty.
21
+ - Historical `run_id` status/result retrieval and cached final text/progress/report; tool getters do not execute checks again.
22
+ - Opt-in real OpenCode stdio smoke command (`pnpm smoke`) using only the exact `openai/gpt-6-luna` model, including directory-scoped permission handling and independent managed worktrees.
23
+ - Each accepted prompt has an additive `run_id` and run-history record. Explicit user-message IDs scope status, progress, and results to the active prompt; earlier assistant turns cannot appear as a pending continuation's output.
24
+ - Continuation dispatches are reserved per task, cancellation state is recorded only after an accepted abort, and accepted continuations clear prior cancellation state.
25
+ - Status and wait share terminal-state rules; `empty` and `cancelled` now finish long polls.
26
+ - SDK HTTP requests use a 30-second abort deadline; long-polling bounds each status read by the remaining total wait deadline.
27
+ - Server/task records optionally retain the canonical server working directory.
28
+ - Status, wait, and result payloads report direct assistant INFO usage with run/session snapshots, provider/model breakdowns, completeness metadata, and immutable per-run historical snapshots; missing metrics remain unknown and cost currency is unspecified.
29
+ - Optional task directories and managed detached Git worktrees bind SDK requests and per-session permission responders to the selected project. Worktree IDs/metadata appear in task history, and `opencode_cleanup_workspace` performs explicit serialized cleanup while preserving cached reports and usage.
30
+
31
+ ### Fixed
32
+
33
+ - SDK response-level errors are surfaced for prompt dispatch, abort, status/message reads, and permission responses, including HTTP failures returned without throwing.
34
+ - Permission responder diagnostics go to stderr rather than MCP stdio.
35
+ - `opencode_wait_for_task` progress enrichment now shares the absolute wait deadline; a slow enrichment is aborted and returns the last observed task status/progress rather than extending the call.
36
+
10
37
  ## [1.2.0] — 2026-08-28
11
38
 
12
39
  Delegated tasks could hang forever, or report `completed` after doing nothing.
package/README.md CHANGED
@@ -27,15 +27,16 @@ See [Installation](#installation) for manual config, and from-source options.
27
27
 
28
28
  | Tool | Description |
29
29
  | --------------------------- | -------------------------------------------------------------------------------------------------------- |
30
- | `opencode_start_server` | Start (or attach to) an OpenCode server instance |
30
+ | `opencode_start_server` | Start a new OpenCode server instance in the MCP process working directory |
31
31
  | `opencode_stop_server` | Stop a running OpenCode server instance |
32
- | `opencode_list_agents` | List agents/models available on a server instance |
32
+ | `opencode_list_agents` | List agents/models available on a server instance (optional `directory` selects a project) |
33
33
  | `opencode_start_task` | Delegate a task to an agent by starting a new session and prompt (optional `agent` / `model` override) |
34
34
  | `opencode_continue_task` | Send a follow-up prompt to an existing task's session for iterative back-and-forth with the subagent |
35
35
  | `opencode_cancel_task` | Abort a running delegated task by cancelling its session |
36
- | `opencode_get_task_status` | Poll the status of a delegated task (`pending` / `running` / `completed` / `failed`); optional `include_progress` adds a partial output snippet and the currently running tool while it's still running |
36
+ | `opencode_cleanup_workspace` | Explicitly remove an owned Git worktree after task reports finalize; dirty worktrees require opt-in discard |
37
+ | `opencode_get_task_status` | Poll the status of a delegated task (`pending` / `running` / `completed` / `failed` / `empty` / `cancelled`); optional `include_progress` adds a partial output snippet and the currently running tool while it's still running |
37
38
  | `opencode_get_task_result` | Fetch the final result of a completed task |
38
- | `opencode_wait_for_task` | Long-poll one or more delegated tasks until they finish (`mode: "all"` or `"any"`) or the timeout elapses; optional `include_progress` enriches any still-unfinished tasks in the final result with a partial output snippet and the currently running tool |
39
+ | `opencode_wait_for_task` | Long-poll one or more delegated tasks until they finish (`mode: "all"` or `"any"`) or the timeout elapses; optional `include_progress` best-effort enriches unfinished tasks within the same deadline, falling back to the last observed status/progress when time runs out |
39
40
 
40
41
  | Prompt | Description |
41
42
  | --------------- | ---------------------------------------------------------------------------------------------------- |
@@ -44,7 +45,35 @@ See [Installation](#installation) for manual config, and from-source options.
44
45
  ## How it works
45
46
  ![opencode mcp architecture](./opencode-architecture.svg)
46
47
 
47
- Task delegation is **asynchronous**: starting a task returns immediately with a `task_id` instead of blocking until the subagent finishes. This lets Claude Code fire multiple `opencode_start_task` calls in parallel — each one opens an isolated OpenCode `Session` — without hitting MCP client timeouts on long-running work. Status and results are fetched separately via polling.
48
+ Task delegation is **asynchronous**: starting a task returns immediately with a `task_id` instead of blocking until the subagent finishes. This lets hosts fire multiple `opencode_start_task` calls in parallel — each opens an isolated OpenCode `Session` — without hitting MCP client timeouts on long-running work. Tasks use the selected directory directly by default; managed Git worktrees are an explicit opt-in. Status and results are fetched separately via polling. If the prompt ACK transport is lost after OpenCode may have accepted the request, the tool returns `dispatch_uncertain` with task/run IDs and retains its lease; poll that task or cancel it rather than retrying the prompt as a new task. Definite HTTP 4xx rejection rolls back provisional run state and lease.
49
+
50
+ Each accepted start or continuation prompt also receives a `run_id`. Status, progress, and results are scoped to that prompt's explicit message ID, while progress evidence remains cumulative across turns in the same run. `empty` and `cancelled` are terminal outcomes. A cancelled task can be continued; the accepted continuation becomes the current run and clears the previous cancellation state. Concurrent continuation dispatches for the same task are rejected with `dispatch_in_progress`.
51
+
52
+ OpenCode 1.18.34 requires caller message IDs to start with `msg`; the MCP uses `msg_${randomUUID()}` for both start and continuation prompts. The opt-in harness also probes a bare UUID through the installed SDK/runtime using an invalid provider/model so the identifier contract can be checked without a paid request.
53
+
54
+ ### Execution reports and caller checks
55
+
56
+ `opencode_start_task` and `opencode_continue_task` accept optional `expected_changes` (`[{ path, change }]`, where `change` is `created`, `modified`, or `deleted`) and `checks` (`[{ executable, argv, cwd?, timeout_ms? }]`). Paths and check working directories are relative to the server workspace; checks run directly with `shell: false`. Reports are additive on status/result as `run_id`, `changes`, `verification`, `diagnostics`, and `evidence`. `opencode_get_task_result` and `opencode_get_task_status` accept an optional `run_id` to retrieve a historical run; omitted means the current run.
57
+
58
+ ### Shared directories and managed worktrees
59
+
60
+ `opencode_start_task` accepts `directory` (relative to the server directory, defaulting to it), `workspace_mode` (`shared` by default or `worktree`), and `base_ref` (worktree mode only, default `HEAD`). The selected directory must already exist. Shared mode runs directly in that canonical directory. Worktree mode creates a unique detached Git worktree at the selected committed ref; source modifications and untracked files are neither copied nor altered. When the selected base does not contain the requested nested source directory, the request fails instead of silently changing the execution directory.
61
+
62
+ Start, status, result, wait, and historical-run payloads expose a `workspace` object with source and execution directories, mode, managed state, and (for worktrees) `workspace_id`, base commit, source dirty state, and lifecycle state. SDK requests are bound to the selected directory through OpenCode's directory routing. Permission responders are keyed by server and canonical directory and answer only sessions registered to that scope.
63
+
64
+ Accepted worktrees are retained after task completion. Call `opencode_cleanup_workspace` with the returned `workspace_id` after reports are finalized. Cleanup serializes against task continuation/dispatch, rejects unfinished reports and dirty worktrees, and removes only worktrees provisioned by this MCP process. Set `discard_changes: true` only when changes inside that owned worktree should be discarded. Cleanup never removes source files; cached results, usage, and historical runs remain readable after the worktree is removed.
65
+
66
+ ### Usage reporting
67
+
68
+ Status, wait, and result payloads include an additive `usage` snapshot. Its `run` summary selects direct assistant messages whose `parentID` matches that run's user-message ID; `session` summarizes all direct assistant messages observed in the session snapshot. Neither includes descendants or separate subagent sessions. Per-provider/model breakdowns preserve raw input, output, reasoning, cache-read, cache-write, and OpenCode-reported cost independently; token categories are not combined into a potentially overlapping total. Step-finish part metrics are excluded.
69
+
70
+ Each metric has a nullable `value`, `complete` flag, and `missing_messages` count. Missing, invalid, or negative values remain unknown; explicit zero is known. Empty snapshots do not claim zero consumption. Cost is labeled as OpenCode-reported, with currency unspecified; it is not an invoice total. `captured_at` and `snapshot_kind` identify the observation. A finalized run caches its own run and session snapshot, so retrieving an older `run_id` after a continuation returns the historical session snapshot from that run's finalization—not a current cumulative total. Failed message reads return `report: null` with a diagnostic.
71
+
72
+ The MCP server captures a bounded workspace inventory before accepted dispatch and after execution. The inventory recursively hashes workspace files except `.git` and `node_modules`; caller-declared expected paths are also checked explicitly, including paths inside otherwise skipped directories. Created/untracked, modified (including pre-existing dirty files changed since baseline), deleted, binary, and symlink entries are reported. Changes are labeled `workspace_delta`: concurrent or unmanaged writers can still make attribution uncertain. A verified run holds an exclusive lease across the canonical workspace and overlapping directories; it blocks verified and ordinary MCP task writers and conflicts fail fast with `workspace_busy`. Ordinary unverified runs may share a workspace. `session.diff` is not used as task provenance. Capture errors, missing expected changes, and scope limits are disclosed. `opencode_wait_for_task` supports `until: "execution"` (default) or `until: "report"`; report waits end on finalized failed, unavailable, or cancelled reports while respecting the whole-call timeout.
73
+
74
+ Each accepted run retains its own baseline, criteria, lease, result, report, and finalized usage snapshot. Checks are executed once by the background lifecycle observer after terminal execution. Their result includes exit code/signal, duration, bounded output and exact truncation flags; timeout/cancellation terminates the child process group. Verification may be `not_requested`, `pending`, `running`, `passed`, `failed`, `unavailable`, or `cancelled`; execution success alone does not mean verification passed. Final task text/progress, reports, and usage snapshots are cached, so result queries do not rerun checks and historical results remain available after a continuation or backend removal. Workspace inventories are capped at 20,000 entries and 32 MiB of file content; check output is capped at 16 KiB per stream. These limits and shared-workspace attribution mean the report is evidence, not proof of exclusive authorship.
75
+
76
+ Run the opt-in real OpenCode stdio smoke harness with `pnpm smoke`. It uses only `openai/gpt-6-luna` for its paid prompts, creates and removes temporary fixtures, and checks real reports/usage, historical retrieval, no-change and cancellation continuations, two detached worktrees editing the same relative file independently, an external read permission in a scoped worktree session, continuation in that worktree, dirty-cleanup refusal, explicit discard, and cached history after removal. The no-model identifier probe is also runnable with `node --experimental-strip-types scripts/smoke.ts --message-id-only`. The paid smoke is intentionally not part of default CI because it requires configured credentials.
48
77
 
49
78
  ## Installation
50
79
 
@@ -2,6 +2,7 @@
2
2
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
3
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
4
  import { createDelegateTaskInstructions } from "./modules/shared/instructions.js";
5
+ import { stopRunObservers } from "./modules/shared/run-observer.js";
5
6
  import { killAllServers } from "./modules/shared/server-registry.js";
6
7
  import { registerTools } from "./modules/tools/index.js";
7
8
  // Create server instance
@@ -9,7 +10,7 @@ async function createServer() {
9
10
  const instructions = await createDelegateTaskInstructions();
10
11
  return new McpServer({
11
12
  name: "opencode-mcp",
12
- version: "1.0.0",
13
+ version: "1.2.0",
13
14
  title: "OpenCode MCP",
14
15
  description: "MCP server that drives an OpenCode instance and delegates work asynchronously to its agents",
15
16
  websiteUrl: "https://opencode-mcp.alejandrotechnology.com",
@@ -17,10 +18,20 @@ async function createServer() {
17
18
  instructions,
18
19
  });
19
20
  }
20
- function shutdown(signal) {
21
+ let shuttingDown = false;
22
+ async function shutdown(signal, exitCode = 0) {
23
+ if (shuttingDown)
24
+ return;
25
+ shuttingDown = true;
21
26
  console.error(`Received ${signal}, stopping tracked OpenCode servers...`);
27
+ const checksDrained = await stopRunObservers();
28
+ if (!checksDrained) {
29
+ console.error("One or more check process groups could not be confirmed stopped; OpenCode servers remain active and shutdown can be retried.");
30
+ shuttingDown = false;
31
+ return;
32
+ }
22
33
  killAllServers();
23
- process.exit(0);
34
+ process.exit(exitCode);
24
35
  }
25
36
  // Terminal closed (SIGHUP), Ctrl+C (SIGINT), or a normal kill (SIGTERM):
26
37
  // make sure no `opencode serve` child keeps running after we die.
@@ -28,7 +39,9 @@ process.on("SIGHUP", shutdown);
28
39
  process.on("SIGINT", shutdown);
29
40
  process.on("SIGTERM", shutdown);
30
41
  // Safety net for any other exit path (uncaught exception, process.exit elsewhere).
31
- process.on("exit", killAllServers);
42
+ process.on("exit", () => {
43
+ killAllServers();
44
+ });
32
45
  async function main() {
33
46
  const server = await createServer();
34
47
  registerTools(server);
@@ -36,8 +49,8 @@ async function main() {
36
49
  await server.connect(transport);
37
50
  console.error("OpenCode MCP Server running on stdio");
38
51
  }
39
- main().catch((error) => {
52
+ main().catch(async (error) => {
40
53
  console.error("Fatal error in main():", error);
41
- process.exit(1);
54
+ await shutdown("SIGTERM", 1);
42
55
  });
43
56
  //# sourceMappingURL=index.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/index.ts"],"names":[],"mappings":";AACA,OAAO,EAAE,SAAS,EAAE,MAAM,yCAAyC,CAAC;AACpE,OAAO,EAAE,oBAAoB,EAAE,MAAM,2CAA2C,CAAC;AACjF,OAAO,EAAE,8BAA8B,EAAE,MAAM,kCAAkC,CAAC;AAClF,OAAO,EAAE,cAAc,EAAE,MAAM,qCAAqC,CAAC;AACrE,OAAO,EAAE,aAAa,EAAE,MAAM,0BAA0B,CAAC;AAEzD,yBAAyB;AACzB,KAAK,UAAU,YAAY;IACzB,MAAM,YAAY,GAAG,MAAM,8BAA8B,EAAE,CAAC;IAC5D,OAAO,IAAI,SAAS,CAClB;QACE,IAAI,EAAE,cAAc;QACpB,OAAO,EAAE,OAAO;QAChB,KAAK,EAAE,cAAc;QACrB,WAAW,EACT,6FAA6F;QAC/F,UAAU,EAAE,8CAA8C;KAC3D,EACD;QACE,YAAY;KACb,CACF,CAAC;AACJ,CAAC;AAED,SAAS,QAAQ,CAAC,MAAsB;IACtC,OAAO,CAAC,KAAK,CAAC,YAAY,MAAM,wCAAwC,CAAC,CAAC;IAC1E,cAAc,EAAE,CAAC;IACjB,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;AAClB,CAAC;AAED,yEAAyE;AACzE,kEAAkE;AAClE,OAAO,CAAC,EAAE,CAAC,QAAQ,EAAE,QAAQ,CAAC,CAAC;AAC/B,OAAO,CAAC,EAAE,CAAC,QAAQ,EAAE,QAAQ,CAAC,CAAC;AAC/B,OAAO,CAAC,EAAE,CAAC,SAAS,EAAE,QAAQ,CAAC,CAAC;AAChC,mFAAmF;AACnF,OAAO,CAAC,EAAE,CAAC,MAAM,EAAE,cAAc,CAAC,CAAC;AAEnC,KAAK,UAAU,IAAI;IACjB,MAAM,MAAM,GAAG,MAAM,YAAY,EAAE,CAAC;IACpC,aAAa,CAAC,MAAM,CAAC,CAAC;IACtB,MAAM,SAAS,GAAG,IAAI,oBAAoB,EAAE,CAAC;IAC7C,MAAM,MAAM,CAAC,OAAO,CAAC,SAAS,CAAC,CAAC;IAChC,OAAO,CAAC,KAAK,CAAC,sCAAsC,CAAC,CAAC;AACxD,CAAC;AAED,IAAI,EAAE,CAAC,KAAK,CAAC,CAAC,KAAK,EAAE,EAAE;IACrB,OAAO,CAAC,KAAK,CAAC,wBAAwB,EAAE,KAAK,CAAC,CAAC;IAC/C,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;AAClB,CAAC,CAAC,CAAC"}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/index.ts"],"names":[],"mappings":";AACA,OAAO,EAAE,SAAS,EAAE,MAAM,yCAAyC,CAAC;AACpE,OAAO,EAAE,oBAAoB,EAAE,MAAM,2CAA2C,CAAC;AACjF,OAAO,EAAE,8BAA8B,EAAE,MAAM,kCAAkC,CAAC;AAClF,OAAO,EAAE,gBAAgB,EAAE,MAAM,kCAAkC,CAAC;AACpE,OAAO,EAAE,cAAc,EAAE,MAAM,qCAAqC,CAAC;AACrE,OAAO,EAAE,aAAa,EAAE,MAAM,0BAA0B,CAAC;AAEzD,yBAAyB;AACzB,KAAK,UAAU,YAAY;IACzB,MAAM,YAAY,GAAG,MAAM,8BAA8B,EAAE,CAAC;IAC5D,OAAO,IAAI,SAAS,CAClB;QACE,IAAI,EAAE,cAAc;QACpB,OAAO,EAAE,OAAO;QAChB,KAAK,EAAE,cAAc;QACrB,WAAW,EACT,6FAA6F;QAC/F,UAAU,EAAE,8CAA8C;KAC3D,EACD;QACE,YAAY;KACb,CACF,CAAC;AACJ,CAAC;AAED,IAAI,YAAY,GAAG,KAAK,CAAC;AAEzB,KAAK,UAAU,QAAQ,CAAC,MAAsB,EAAE,QAAQ,GAAG,CAAC;IAC1D,IAAI,YAAY;QAAE,OAAO;IACzB,YAAY,GAAG,IAAI,CAAC;IACpB,OAAO,CAAC,KAAK,CAAC,YAAY,MAAM,wCAAwC,CAAC,CAAC;IAC1E,MAAM,aAAa,GAAG,MAAM,gBAAgB,EAAE,CAAC;IAC/C,IAAI,CAAC,aAAa,EAAE,CAAC;QACnB,OAAO,CAAC,KAAK,CACX,8HAA8H,CAC/H,CAAC;QACF,YAAY,GAAG,KAAK,CAAC;QACrB,OAAO;IACT,CAAC;IACD,cAAc,EAAE,CAAC;IACjB,OAAO,CAAC,IAAI,CAAC,QAAQ,CAAC,CAAC;AACzB,CAAC;AAED,yEAAyE;AACzE,kEAAkE;AAClE,OAAO,CAAC,EAAE,CAAC,QAAQ,EAAE,QAAQ,CAAC,CAAC;AAC/B,OAAO,CAAC,EAAE,CAAC,QAAQ,EAAE,QAAQ,CAAC,CAAC;AAC/B,OAAO,CAAC,EAAE,CAAC,SAAS,EAAE,QAAQ,CAAC,CAAC;AAChC,mFAAmF;AACnF,OAAO,CAAC,EAAE,CAAC,MAAM,EAAE,GAAG,EAAE;IACtB,cAAc,EAAE,CAAC;AACnB,CAAC,CAAC,CAAC;AAEH,KAAK,UAAU,IAAI;IACjB,MAAM,MAAM,GAAG,MAAM,YAAY,EAAE,CAAC;IACpC,aAAa,CAAC,MAAM,CAAC,CAAC;IACtB,MAAM,SAAS,GAAG,IAAI,oBAAoB,EAAE,CAAC;IAC7C,MAAM,MAAM,CAAC,OAAO,CAAC,SAAS,CAAC,CAAC;IAChC,OAAO,CAAC,KAAK,CAAC,sCAAsC,CAAC,CAAC;AACxD,CAAC;AAED,IAAI,EAAE,CAAC,KAAK,CAAC,KAAK,EAAE,KAAK,EAAE,EAAE;IAC3B,OAAO,CAAC,KAAK,CAAC,wBAAwB,EAAE,KAAK,CAAC,CAAC;IAC/C,MAAM,QAAQ,CAAC,SAAS,EAAE,CAAC,CAAC,CAAC;AAC/B,CAAC,CAAC,CAAC"}
@@ -0,0 +1,66 @@
1
+ import { type ChildProcess, spawn } from "node:child_process";
2
+ export type ExpectedChange = {
3
+ path: string;
4
+ change: "created" | "modified" | "deleted";
5
+ };
6
+ export type VerificationCheck = {
7
+ executable: string;
8
+ argv: string[];
9
+ cwd?: string;
10
+ timeout_ms?: number;
11
+ };
12
+ export type WorkspaceSnapshot = {
13
+ root: string;
14
+ files: Map<string, string>;
15
+ };
16
+ export type FileChange = ExpectedChange & {
17
+ evidence: "workspace_delta";
18
+ };
19
+ export type VerificationState = "not_requested" | "pending" | "running" | "passed" | "failed" | "unavailable" | "cancelled";
20
+ export type CheckResult = {
21
+ executable: string;
22
+ argv: string[];
23
+ exit_code: number | null;
24
+ signal: NodeJS.Signals | null;
25
+ duration_ms: number;
26
+ stdout: string;
27
+ stderr: string;
28
+ stdout_truncated: boolean;
29
+ stderr_truncated: boolean;
30
+ timed_out?: boolean;
31
+ termination_confirmed?: boolean;
32
+ error?: string;
33
+ };
34
+ export type ExecutionReport = {
35
+ changes: FileChange[];
36
+ verification: {
37
+ state: VerificationState;
38
+ checks: CheckResult[];
39
+ execution_state?: string;
40
+ };
41
+ diagnostics: string[];
42
+ evidence: {
43
+ source: "workspace_delta";
44
+ inventory_scope: "recursive_except_git_and_node_modules_plus_explicit_paths";
45
+ complete: boolean;
46
+ errors: string[];
47
+ attribution: "uncertain_shared_workspace";
48
+ };
49
+ };
50
+ /** @internal Signal a direct check child and its descendants without invoking a shell. */
51
+ export declare function signalCheckProcess(child: Pick<ChildProcess, "pid" | "kill">, force: boolean, platform?: NodeJS.Platform, spawnKiller?: typeof spawn): Promise<boolean>;
52
+ /** Abort in-flight checks and wait until every check process has drained or the deadline expires. */
53
+ export declare function abortAndDrainChecks(controller: AbortController, timeoutMs?: number): Promise<boolean>;
54
+ /** @internal Confirm process-group termination, with an injectable state probe for tests. */
55
+ export declare function waitForProcessGroupExit(pid: number, timeoutMs?: number, readStates?: (groupId: number) => Promise<string[] | undefined>): Promise<boolean>;
56
+ /** @internal Read process-group states, with an injectable spawner for failure-path tests. */
57
+ export declare function processGroupStates(groupId: number, spawnProcess?: typeof spawn): Promise<string[] | undefined>;
58
+ export declare function validateRelativePath(value: string): boolean;
59
+ export declare function validateRelativeCwd(value: string): boolean;
60
+ export declare function captureWorkspace(directory: string, explicitPaths?: string[]): Promise<WorkspaceSnapshot>;
61
+ export declare function compareWorkspace(before: Awaited<ReturnType<typeof captureWorkspace>>, after: Awaited<ReturnType<typeof captureWorkspace>>): FileChange[];
62
+ export declare function runCheck(check: VerificationCheck, workspace: string, signal?: AbortSignal): Promise<CheckResult>;
63
+ export declare function reserveWorkspace(directory: string, exclusive: boolean): Promise<symbol | undefined>;
64
+ export declare function releaseWorkspace(token: symbol): void;
65
+ export declare function captureReport(before: Awaited<ReturnType<typeof captureWorkspace>>, expected: ExpectedChange[], checks: VerificationCheck[], execution: string, signal?: AbortSignal): Promise<ExecutionReport>;
66
+ //# sourceMappingURL=execution-report.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"execution-report.d.ts","sourceRoot":"","sources":["../../../../src/modules/shared/execution-report.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,407 @@
1
+ import { spawn } from "node:child_process";
2
+ import { createHash } from "node:crypto";
3
+ import { lstat, readdir, readFile, readlink, realpath } from "node:fs/promises";
4
+ import path from "node:path";
5
+ const MAX_FILES = 20_000;
6
+ const MAX_BYTES = 32 * 1024 * 1024;
7
+ const MAX_OUTPUT_BYTES = 16 * 1024;
8
+ function insideWorkspace(root, candidate) {
9
+ const relative = path.relative(root, candidate);
10
+ return (relative === "" ||
11
+ (relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative)));
12
+ }
13
+ /** @internal Signal a direct check child and its descendants without invoking a shell. */
14
+ export function signalCheckProcess(child, force, platform = process.platform, spawnKiller = spawn) {
15
+ const signalName = force ? "SIGKILL" : "SIGTERM";
16
+ const childPid = child.pid;
17
+ if (platform === "win32" && childPid) {
18
+ return new Promise((resolve) => {
19
+ const killer = spawnKiller("taskkill.exe", ["/PID", String(childPid), "/T", ...(force ? ["/F"] : [])], { shell: false, stdio: "ignore", windowsHide: true });
20
+ killer.once("error", () => {
21
+ child.kill(signalName);
22
+ resolve(false);
23
+ });
24
+ killer.once("close", (code) => resolve(code === 0));
25
+ killer.unref();
26
+ });
27
+ }
28
+ if (childPid) {
29
+ try {
30
+ process.kill(-childPid, signalName);
31
+ }
32
+ catch (error) {
33
+ if (force && error instanceof Error && "code" in error && error.code === "ESRCH")
34
+ return Promise.resolve(true);
35
+ if (!(error instanceof Error && "code" in error && error.code === "ESRCH"))
36
+ return Promise.resolve(false);
37
+ }
38
+ return force ? waitForProcessGroupExit(childPid) : Promise.resolve(true);
39
+ }
40
+ child.kill(signalName);
41
+ return Promise.resolve(false);
42
+ }
43
+ const activeChecks = new WeakMap();
44
+ /** Abort in-flight checks and wait until every check process has drained or the deadline expires. */
45
+ export async function abortAndDrainChecks(controller, timeoutMs = 5_000) {
46
+ controller.abort(new Error("workspace execution is stopping"));
47
+ const checks = [...(activeChecks.get(controller.signal) ?? [])];
48
+ if (checks.length === 0)
49
+ return true;
50
+ let resolveTimeout;
51
+ const timeout = new Promise((resolve) => {
52
+ resolveTimeout = resolve;
53
+ });
54
+ const timer = setTimeout(() => resolveTimeout(false), timeoutMs);
55
+ timer.unref?.();
56
+ try {
57
+ return await Promise.race([
58
+ Promise.all(checks).then((confirmations) => confirmations.every(Boolean)),
59
+ timeout,
60
+ ]);
61
+ }
62
+ finally {
63
+ clearTimeout(timer);
64
+ }
65
+ }
66
+ /** @internal Confirm process-group termination, with an injectable state probe for tests. */
67
+ export async function waitForProcessGroupExit(pid, timeoutMs = 2_000, readStates = processGroupStates) {
68
+ const deadline = Date.now() + timeoutMs;
69
+ while (Date.now() < deadline) {
70
+ try {
71
+ process.kill(-pid, 0);
72
+ }
73
+ catch (error) {
74
+ if (error instanceof Error && "code" in error && error.code === "ESRCH")
75
+ return true;
76
+ const states = await readStates(pid);
77
+ if (states &&
78
+ (states.length === 0 ||
79
+ states.every((state) => state.startsWith("Z") || state.startsWith("X"))))
80
+ return true;
81
+ return false;
82
+ }
83
+ const states = await readStates(pid);
84
+ if (states &&
85
+ states.length > 0 &&
86
+ states.every((state) => state.startsWith("Z") || state.startsWith("X")))
87
+ return true;
88
+ await new Promise((resolve) => setTimeout(resolve, 20));
89
+ }
90
+ return false;
91
+ }
92
+ /** @internal Read process-group states, with an injectable spawner for failure-path tests. */
93
+ export async function processGroupStates(groupId, spawnProcess = spawn) {
94
+ return new Promise((resolve) => {
95
+ const ps = spawnProcess("ps", ["-axo", "pgid=,stat="], {
96
+ shell: false,
97
+ stdio: ["ignore", "pipe", "ignore"],
98
+ });
99
+ let output = "";
100
+ ps.stdout.on("data", (chunk) => {
101
+ output += chunk.toString("utf8");
102
+ });
103
+ ps.once("error", () => resolve(undefined));
104
+ ps.once("close", (code) => {
105
+ if (code !== 0)
106
+ return resolve(undefined);
107
+ const states = output.split("\n").flatMap((line) => {
108
+ const [pidText, state] = line.trim().split(/\s+/);
109
+ return Number(pidText) === groupId && state ? [state] : [];
110
+ });
111
+ resolve(states);
112
+ });
113
+ });
114
+ }
115
+ export function validateRelativePath(value) {
116
+ return (value.length > 0 &&
117
+ !path.isAbsolute(value) &&
118
+ !value.split(/[\\/]/).includes("..") &&
119
+ value !== ".");
120
+ }
121
+ export function validateRelativeCwd(value) {
122
+ return value === "." || value === "" || validateRelativePath(value);
123
+ }
124
+ async function walk(root, dir = root, result = new Map(), budget = { bytes: 0 }) {
125
+ const entries = await readdir(dir, { withFileTypes: true });
126
+ for (const entry of entries) {
127
+ if (entry.name === ".git" || entry.name === "node_modules")
128
+ continue;
129
+ const full = path.join(dir, entry.name);
130
+ const rel = path.relative(root, full);
131
+ if (result.size >= MAX_FILES)
132
+ throw new Error(`file inventory exceeded ${MAX_FILES} entries`);
133
+ const info = await lstat(full);
134
+ if (info.isDirectory())
135
+ await walk(root, full, result, budget);
136
+ else {
137
+ if (!info.isSymbolicLink() && budget.bytes + info.size > MAX_BYTES)
138
+ throw new Error(`file inventory exceeded ${MAX_BYTES} bytes`);
139
+ const content = info.isSymbolicLink()
140
+ ? `symlink:${await readlink(full)}`
141
+ : await readFile(full);
142
+ budget.bytes += typeof content === "string" ? Buffer.byteLength(content) : content.length;
143
+ if (budget.bytes > MAX_BYTES)
144
+ throw new Error(`file inventory exceeded ${MAX_BYTES} bytes`);
145
+ result.set(rel, createHash("sha256").update(content).digest("hex"));
146
+ }
147
+ }
148
+ return result;
149
+ }
150
+ export async function captureWorkspace(directory, explicitPaths = []) {
151
+ const root = await realpath(directory);
152
+ const files = await walk(root);
153
+ let explicitBytes = 0;
154
+ for (const relative of explicitPaths) {
155
+ if (!validateRelativePath(relative))
156
+ throw new Error(`invalid expected workspace path: ${relative}`);
157
+ const full = path.resolve(root, relative);
158
+ const info = await lstat(full).catch(() => undefined);
159
+ if (!info || info.isDirectory())
160
+ continue;
161
+ const alreadyInventoried = files.has(relative);
162
+ if (!info.isSymbolicLink() && !alreadyInventoried && explicitBytes + info.size > MAX_BYTES)
163
+ throw new Error(`explicit files exceeded ${MAX_BYTES} bytes`);
164
+ let content;
165
+ if (!info.isSymbolicLink()) {
166
+ const canonicalFile = await realpath(full);
167
+ if (!insideWorkspace(root, canonicalFile))
168
+ throw new Error(`expected workspace path resolves outside the workspace: ${relative}`);
169
+ if (!alreadyInventoried)
170
+ explicitBytes += info.size;
171
+ content = await readFile(full);
172
+ }
173
+ else
174
+ content = `symlink:${await readlink(full)}`;
175
+ files.set(relative, createHash("sha256").update(content).digest("hex"));
176
+ }
177
+ return { root, files };
178
+ }
179
+ export function compareWorkspace(before, after) {
180
+ const changes = [];
181
+ for (const [file, hash] of after.files) {
182
+ const old = before.files.get(file);
183
+ if (old !== hash)
184
+ changes.push({
185
+ path: file,
186
+ change: old === undefined ? "created" : "modified",
187
+ evidence: "workspace_delta",
188
+ });
189
+ }
190
+ for (const file of before.files.keys())
191
+ if (!after.files.has(file))
192
+ changes.push({ path: file, change: "deleted", evidence: "workspace_delta" });
193
+ return changes.sort((a, b) => a.path.localeCompare(b.path));
194
+ }
195
+ export async function runCheck(check, workspace, signal) {
196
+ const started = Date.now();
197
+ const canonicalWorkspace = await realpath(workspace).catch(() => "");
198
+ const cwd = check.cwd ? path.resolve(canonicalWorkspace, check.cwd) : canonicalWorkspace;
199
+ const canonical = await realpath(cwd).catch(() => "");
200
+ if (!canonicalWorkspace || !canonical || !insideWorkspace(canonicalWorkspace, canonical)) {
201
+ return {
202
+ executable: check.executable,
203
+ argv: check.argv,
204
+ exit_code: null,
205
+ signal: null,
206
+ duration_ms: Date.now() - started,
207
+ stdout: "",
208
+ stderr: "",
209
+ stdout_truncated: false,
210
+ stderr_truncated: false,
211
+ error: "check cwd is missing or outside workspace",
212
+ };
213
+ }
214
+ if (signal?.aborted) {
215
+ return {
216
+ executable: check.executable,
217
+ argv: check.argv,
218
+ exit_code: null,
219
+ signal: null,
220
+ duration_ms: Date.now() - started,
221
+ stdout: "",
222
+ stderr: "",
223
+ stdout_truncated: false,
224
+ stderr_truncated: false,
225
+ error: "check cancelled",
226
+ };
227
+ }
228
+ let resolveDrain;
229
+ const drained = new Promise((resolve) => {
230
+ resolveDrain = resolve;
231
+ });
232
+ if (signal) {
233
+ const checks = activeChecks.get(signal) ?? new Set();
234
+ checks.add(drained);
235
+ activeChecks.set(signal, checks);
236
+ }
237
+ return new Promise((resolve) => {
238
+ let stdout = Buffer.alloc(0);
239
+ let stderr = Buffer.alloc(0);
240
+ let stdoutTruncated = false;
241
+ let stderrTruncated = false;
242
+ let settled = false;
243
+ let timedOut = false;
244
+ let cancelled = false;
245
+ let closeResult;
246
+ let termination;
247
+ const child = spawn(check.executable, check.argv, {
248
+ cwd: canonical,
249
+ shell: false,
250
+ detached: process.platform !== "win32",
251
+ stdio: ["ignore", "pipe", "pipe"],
252
+ });
253
+ let killTimer;
254
+ const terminate = () => {
255
+ if (termination)
256
+ return;
257
+ termination = (async () => {
258
+ const graceful = await signalCheckProcess(child, false);
259
+ if (graceful) {
260
+ await new Promise((resolve) => {
261
+ killTimer = setTimeout(resolve, 250);
262
+ killTimer.unref?.();
263
+ });
264
+ }
265
+ killTimer = undefined;
266
+ return signalCheckProcess(child, true);
267
+ })();
268
+ void termination.then((confirmed) => {
269
+ if (closeResult)
270
+ finish(closeResult.code, closeResult.signal, undefined, confirmed);
271
+ });
272
+ };
273
+ const append = (target, chunk, set, truncated) => {
274
+ const combined = Buffer.concat([target, chunk]);
275
+ if (combined.length > MAX_OUTPUT_BYTES)
276
+ truncated();
277
+ set(combined.subarray(-MAX_OUTPUT_BYTES));
278
+ };
279
+ child.stdout.on("data", (chunk) => {
280
+ append(stdout, chunk, (value) => {
281
+ stdout = value;
282
+ }, () => {
283
+ stdoutTruncated = true;
284
+ });
285
+ });
286
+ child.stderr.on("data", (chunk) => {
287
+ append(stderr, chunk, (value) => {
288
+ stderr = value;
289
+ }, () => {
290
+ stderrTruncated = true;
291
+ });
292
+ });
293
+ const timer = setTimeout(() => {
294
+ timedOut = true;
295
+ terminate();
296
+ }, check.timeout_ms ?? 30_000);
297
+ const onAbort = () => {
298
+ cancelled = true;
299
+ terminate();
300
+ };
301
+ signal?.addEventListener("abort", onAbort, { once: true });
302
+ const finish = (code, closeSignal, error, terminationConfirmed) => {
303
+ if (settled)
304
+ return;
305
+ settled = true;
306
+ clearTimeout(timer);
307
+ clearTimeout(killTimer);
308
+ killTimer = undefined;
309
+ signal?.removeEventListener("abort", onAbort);
310
+ if (signal) {
311
+ const checks = activeChecks.get(signal);
312
+ checks?.delete(drained);
313
+ if (checks?.size === 0)
314
+ activeChecks.delete(signal);
315
+ }
316
+ resolveDrain(terminationConfirmed !== false);
317
+ resolve({
318
+ executable: check.executable,
319
+ argv: check.argv,
320
+ exit_code: code,
321
+ signal: closeSignal,
322
+ duration_ms: Date.now() - started,
323
+ stdout: stdout.toString("utf8"),
324
+ stderr: stderr.toString("utf8"),
325
+ stdout_truncated: stdoutTruncated,
326
+ stderr_truncated: stderrTruncated,
327
+ timed_out: timedOut,
328
+ ...(terminationConfirmed === undefined
329
+ ? {}
330
+ : { termination_confirmed: terminationConfirmed }),
331
+ ...(cancelled ? { error: "check cancelled" } : {}),
332
+ ...(terminationConfirmed === false
333
+ ? { error: "check process termination could not be confirmed" }
334
+ : {}),
335
+ ...(error ? { error: error.message } : {}),
336
+ });
337
+ };
338
+ child.once("error", (error) => finish(null, null, error));
339
+ child.once("close", (code, closeSignal) => {
340
+ closeResult = { code, signal: closeSignal };
341
+ if (termination) {
342
+ void termination.then((confirmed) => finish(code, closeSignal, undefined, confirmed));
343
+ }
344
+ else
345
+ finish(code, closeSignal);
346
+ });
347
+ });
348
+ }
349
+ const reservations = [];
350
+ export async function reserveWorkspace(directory, exclusive) {
351
+ const root = await realpath(directory);
352
+ const overlap = (a, b) => a === b || a.startsWith(`${b}${path.sep}`) || b.startsWith(`${a}${path.sep}`);
353
+ if (reservations.some((item) => overlap(root, item.root) && (exclusive || item.exclusive)))
354
+ return undefined;
355
+ const token = Symbol(root);
356
+ reservations.push({ root, exclusive, token });
357
+ return token;
358
+ }
359
+ export function releaseWorkspace(token) {
360
+ const index = reservations.findIndex((item) => item.token === token);
361
+ if (index >= 0)
362
+ reservations.splice(index, 1);
363
+ }
364
+ export async function captureReport(before, expected, checks, execution, signal) {
365
+ const errors = [];
366
+ let changes = [];
367
+ try {
368
+ changes = compareWorkspace(before, await captureWorkspace(before.root, expected.map((item) => item.path)));
369
+ }
370
+ catch (error) {
371
+ errors.push(error instanceof Error ? error.message : String(error));
372
+ }
373
+ const results = [];
374
+ if (execution !== "cancelled")
375
+ for (const check of checks)
376
+ results.push(await runCheck(check, before.root, signal));
377
+ const missing = expected.filter((item) => !changes.some((actual) => actual.path === item.path && actual.change === item.change));
378
+ const checksPassed = results.every((result) => result.exit_code === 0 && result.timed_out !== true && !result.error);
379
+ const state = errors.length
380
+ ? "unavailable"
381
+ : execution === "cancelled" || signal?.aborted
382
+ ? "cancelled"
383
+ : execution !== "completed"
384
+ ? "failed"
385
+ : checksPassed && missing.length === 0
386
+ ? "passed"
387
+ : "failed";
388
+ return {
389
+ changes,
390
+ verification: {
391
+ state: checks.length || expected.length ? state : "not_requested",
392
+ checks: results,
393
+ },
394
+ diagnostics: [
395
+ ...errors,
396
+ ...missing.map((item) => `expected ${item.change}: ${item.path} was not observed`),
397
+ ],
398
+ evidence: {
399
+ source: "workspace_delta",
400
+ inventory_scope: "recursive_except_git_and_node_modules_plus_explicit_paths",
401
+ complete: errors.length === 0,
402
+ errors,
403
+ attribution: "uncertain_shared_workspace",
404
+ },
405
+ };
406
+ }
407
+ //# sourceMappingURL=execution-report.js.map