@moretea-labs/forge 1.5.1 → 1.6.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 (124) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/DESIGN.md +7 -2
  3. package/README.md +4 -4
  4. package/README.zh-CN.md +2 -2
  5. package/THIRD_PARTY_NOTICES.md +1 -1
  6. package/assets/hooks/pre-edit-guard.sh +8 -39
  7. package/assets/hooks/prompt-guard.sh +2 -2
  8. package/assets/plugin-registry.v1.json +54 -3
  9. package/assets/reference-configs/hook-operations.md +1 -1
  10. package/assets/reference-configs/sprint-contracts.md +2 -2
  11. package/assets/skill-version.json +2 -2
  12. package/assets/templates/helpers/ensure-task-workflow.sh +2 -2
  13. package/assets/templates/helpers/sprint-backlog.sh +6 -6
  14. package/docs/architecture/current/automation-and-schedule-engine.md +9 -3
  15. package/docs/architecture/current/codegraph-context-provider.md +15 -3
  16. package/docs/architecture/current/dispatch-and-agent-strategy.md +32 -13
  17. package/docs/architecture/current/entity-model.md +20 -0
  18. package/docs/architecture/current/external-plugin-governance.md +3 -3
  19. package/docs/architecture/current/system-overview.md +26 -1
  20. package/docs/architecture/current/thin-harness-v1.md +10 -0
  21. package/docs/operations/chatgpt-personal-assistant-runtime.md +12 -2
  22. package/docs/operations/controller-browser-plugin.md +12 -16
  23. package/docs/operations/features.md +1 -1
  24. package/docs/operations/homebrew.md +2 -2
  25. package/docs/tutorials/01-install-and-start.md +1 -1
  26. package/docs/tutorials/01-install-and-start.zh-CN.md +1 -1
  27. package/docs/wiki/Installation.md +1 -1
  28. package/docs/wiki/Quick-Start.md +1 -1
  29. package/package.json +1 -1
  30. package/scripts/check-public-docs.sh +14 -1
  31. package/scripts/migrate-chatgpt-automation-schedules.ts +64 -0
  32. package/scripts/sprint-backlog.sh +1 -1
  33. package/src/cli/commands/chatgpt.ts +31 -2
  34. package/src/cli/commands/plugin.ts +36 -9
  35. package/src/cli/controller/context-pack.ts +158 -6
  36. package/src/cli/hook/prompt-guard-decision.ts +17 -18
  37. package/src/cli/hook/prompt-intents.ts +1 -1
  38. package/src/cli/local-bridge/console-automations.ts +205 -23
  39. package/src/cli/local-bridge/console-view-models.ts +2 -0
  40. package/src/cli/local-bridge/facade-api.ts +2 -0
  41. package/src/cli/local-bridge/frontend/src/App.tsx +1 -1
  42. package/src/cli/local-bridge/frontend/src/features/automations/AutomationsView.tsx +143 -3
  43. package/src/cli/local-bridge/frontend/src/features/capabilities/CapabilitiesView.tsx +1 -1
  44. package/src/cli/local-bridge/frontend/src/features/overview/OverviewView.tsx +181 -3
  45. package/src/cli/local-bridge/frontend/src/features/repositories/RepositoriesView.tsx +1 -1
  46. package/src/cli/local-bridge/frontend/src/features/work/WorkView.tsx +4 -3
  47. package/src/cli/local-bridge/frontend/src/lib/api.ts +18 -6
  48. package/src/cli/local-bridge/frontend/src/shell/CommandBar.tsx +1 -1
  49. package/src/cli/local-bridge/frontend/src/shell/Sidebar.tsx +2 -1
  50. package/src/cli/local-bridge/frontend/src/styles.css +27 -11
  51. package/src/cli/local-bridge/frontend/src/types.ts +6 -2
  52. package/src/cli/local-bridge/server.ts +29 -19
  53. package/src/cli/local-bridge/ui-dist/app.css +1 -1
  54. package/src/cli/local-bridge/ui-dist/app.js +5003 -4815
  55. package/src/cli/mcp/paths.ts +8 -2
  56. package/src/cli/mcp/repository-tools.ts +124 -9
  57. package/src/cli/mcp/server.ts +164 -19
  58. package/src/cli/mcp/transports/http.ts +25 -2
  59. package/src/cli/mcp/transports/session-registry.ts +24 -0
  60. package/src/cli/repositories/command-classifier.ts +113 -0
  61. package/src/cli/repositories/safe-patch.ts +6 -4
  62. package/src/cli/repositories/structured-git.ts +49 -7
  63. package/src/cli/repository/inspector.ts +16 -3
  64. package/src/runtime/assistant/intent.ts +3 -3
  65. package/src/runtime/control-plane/execution/direct-edit-work-completion.ts +157 -2
  66. package/src/runtime/control-plane/execution/execution-identity.ts +65 -14
  67. package/src/runtime/control-plane/execution/validation.ts +15 -2
  68. package/src/runtime/control-plane/execution/work-terminal-cleanup.ts +32 -0
  69. package/src/runtime/control-plane/execution/work-validation-reconciler.ts +6 -7
  70. package/src/runtime/control-plane/execution/work-verification-snapshot.ts +2 -1
  71. package/src/runtime/control-plane/facade/capability-registry.ts +1 -1
  72. package/src/runtime/control-plane/facade/controller-session-store.ts +12 -13
  73. package/src/runtime/control-plane/facade/goal-workloop-access.ts +7 -2
  74. package/src/runtime/control-plane/facade/goal-workloop.ts +231 -8
  75. package/src/runtime/control-plane/facade/plan-contract-store.ts +32 -12
  76. package/src/runtime/control-plane/facade/requirement-board.ts +24 -8
  77. package/src/runtime/control-plane/facade/types.ts +33 -6
  78. package/src/runtime/control-plane/facade/work-contract-store.ts +31 -2
  79. package/src/runtime/control-plane/launcher/chatgpt-work-continuation.ts +375 -23
  80. package/src/runtime/control-plane/persistence/requirement-store.ts +15 -11
  81. package/src/runtime/diagnostics/mcp-timing.ts +6 -0
  82. package/src/runtime/execution/process-runtime/check-facade.ts +1 -10
  83. package/src/runtime/execution/process-runtime/check-result.ts +10 -2
  84. package/src/runtime/execution/process-runtime/command-facade.ts +1 -0
  85. package/src/runtime/execution/process-runtime/gc.ts +64 -10
  86. package/src/runtime/execution/process-runtime/persisted-check.ts +10 -2
  87. package/src/runtime/execution/process-runtime/resource-claims.ts +87 -2
  88. package/src/runtime/execution/process-runtime/runtime.ts +16 -1
  89. package/src/runtime/execution/process-runtime/store.ts +6 -0
  90. package/src/runtime/gateway/mcp/execution-tools.ts +27 -12
  91. package/src/runtime/gateway/mcp/router.ts +1 -1
  92. package/src/runtime/gateway/mcp/runtime-tools.ts +482 -178
  93. package/src/runtime/model-clients/deepseek-adapter.ts +0 -32
  94. package/src/runtime/plugins/browser-adapter.ts +19 -72
  95. package/src/runtime/plugins/browser-handoff-host.ts +6 -31
  96. package/src/runtime/plugins/browser-handoff.ts +0 -3
  97. package/src/runtime/plugins/browser-macos-bridge.ts +8 -0
  98. package/src/runtime/plugins/desktop-operator-registration.ts +8 -11
  99. package/src/runtime/plugins/external-registration.ts +84 -5
  100. package/src/runtime/plugins/first-party-registry.ts +2 -2
  101. package/src/runtime/plugins/gmail-adapter.ts +28 -2
  102. package/src/runtime/plugins/local-system-adapter.ts +2 -2
  103. package/src/runtime/plugins/plugin-management-adapter.ts +140 -0
  104. package/src/runtime/plugins/store.ts +107 -5
  105. package/src/runtime/plugins/types.ts +4 -0
  106. package/src/runtime/recovery/actions.ts +0 -10
  107. package/src/runtime/recovery/classifier.ts +0 -9
  108. package/src/runtime/recovery/maintenance-executor.ts +43 -11
  109. package/src/runtime/recovery/probe.ts +0 -1
  110. package/src/runtime/recovery/types.ts +0 -1
  111. package/src/runtime/root/release-materialize.ts +143 -19
  112. package/src/runtime/root/service.ts +6 -4
  113. package/src/runtime/safe-tooling/index.ts +0 -1
  114. package/src/runtime/safe-tooling/types.ts +0 -30
  115. package/src/runtime/standalone-recovery/core.ts +2 -2
  116. package/src/runtime/workflow/schedules/browser-probe.ts +180 -0
  117. package/src/runtime/workflow/schedules/chatgpt-automation-migration.ts +40 -0
  118. package/src/runtime/workflow/schedules/engine.ts +357 -64
  119. package/src/runtime/workflow/schedules/types.ts +5 -0
  120. package/src/runtime/workflow/schedules/work-continuation.ts +100 -27
  121. package/bin/forge-desktop-helper.mjs +0 -204
  122. package/docs/operations/controller-desktop-plugin.md +0 -112
  123. package/src/runtime/plugins/desktop-adapter.ts +0 -374
  124. package/src/runtime/safe-tooling/browser-safe.ts +0 -133
@@ -1,10 +1,35 @@
1
1
  # Controller Runtime System Overview
2
2
 
3
3
  > Status: **Runtime Authority**
4
+ >
5
+ > Control convergence: ChatGPT/human Controller owns semantic understanding and commits Direct/Process/Durable Work/Plan only after sufficient context; Forge does not classify every request at ingress. `RouteDecision` owns execution mechanics after that commitment, while unrelated Work/Plan state never upgrades or blocks ordinary Direct execution. Semantic ownership, execution depth, and resource placement stay independent. CodeGraph/lexical/Forge relations are bounded impact evidence, not another decision authority.
4
6
 
5
7
  ## 1. System Definition
6
8
 
7
- Forge Controller Runtime is an Agent Engineering Control Plane for one or more local Git repositories. ChatGPT, Local UI, CLI and optional GitHub integrations submit decisions and commands. The runtime persists accepted work, schedules it under repository-owned conflict rules, executes it outside the Gateway process and records evidence for recovery, acceptance and release.
9
+ Forge Controller Runtime is an Agent Engineering Control Plane for one or more local Git repositories. ChatGPT is the primary semantic Controller: it may inspect first, then choose whether a request needs only a Direct action, a Process, finite durable Work, a Plan, or a Workflow. Local UI, CLI and integrations can submit the same explicit decisions and commands. Forge supplies bounded context, hard policy/resource constraints, persistence, execution and machine evidence; it does not replace the Controller with prompt classifiers or Plan/Work ceremony.
10
+
11
+ ### 1.1 End-to-end authority map
12
+
13
+ ```text
14
+ User request
15
+ -> ChatGPT / human Controller
16
+ -> Explore when needed: rh_context + raw reads + CodeGraph/Runtime/history evidence
17
+ -> semantic commitment: answer | Direct | Process | finite Work | Plan | Workflow
18
+ -> Forge execution boundary
19
+ -> hard policy / authorization / claims / leases / fencing
20
+ -> placement and executor mechanics; no semantic reinterpretation
21
+ -> execution
22
+ -> Direct mutation: SafePatch -> EditSession -> MutationGate
23
+ -> command/check: Unified Process Runtime
24
+ -> durable responsibility: finite Work using the same execution primitives
25
+ -> recurrence: Workflow/Schedule -> bounded Occurrence -> Controller decision
26
+ -> machine evidence
27
+ -> diff / Process receipt / check / delivery / cleanup facts
28
+ -> ChatGPT / user acceptance
29
+ -> accept, continue, replan, or close the semantic goal
30
+ ```
31
+
32
+ Authority rule: Context providers return evidence, RoutePolicy selects mechanics from a Controller commitment, Plan/Requirement/Work persist explicitly chosen durable semantics, and Process/EditSession/Evidence record execution facts. None of those layers may infer that a missing Plan/Work makes ordinary implementation unauthorized, or that a successful/failed cleanup receipt by itself proves the user's goal accepted/rejected.
8
33
 
9
34
  ## 2. Canonical Runtime Topology
10
35
 
@@ -174,6 +174,16 @@ bounded child process (process group) / yielded search
174
174
  - Timeout/cancel: SIGTERM process group → grace → SIGKILL via `terminateProcessTree`.
175
175
  - stdout/stderr collectors cap while streaming.
176
176
 
177
+ ## MCP tool-surface freshness
178
+
179
+ The Canonical Runtime `tools/list` schema fingerprint is the tool-surface identity. Release identity alone does not invalidate a session, while any exposed schema change does.
180
+
181
+ HTTP MCP sessions capture the exact Canonical Runtime fingerprint and tool names at initialize time. The Gateway also observes the existing atomic `runtime/status.json` publication; when its published tool-surface fingerprint changes, it sends the standard MCP `notifications/tools/list_changed` notification to connected sessions whose captured fingerprint is stale. This is event-driven over the existing Runtime status publication—there is no schema poller, refresh daemon, or extra control-plane entity.
182
+
183
+ The notification is an optimization hint, not authority. It never rewrites a session's captured fingerprint. Until the client re-runs discovery/reinitializes, the existing per-request fingerprint fence still rejects stale-schema execution. A lost notification therefore cannot make an obsolete tool call valid; it only risks falling back to the deterministic reinitialize path.
184
+
185
+ This removes the normal stale-tool extra round trip for clients that honor standard MCP list-changed notifications while retaining the same fail-closed schema fence for clients that do not.
186
+
177
187
  ## Process output confidentiality
178
188
 
179
189
  Process Runtime applies one shared bounded redaction policy before stdout/stderr
@@ -54,7 +54,15 @@ A routine stores the user's natural-language goal rather than a traditional GUI
54
54
  }
55
55
  ```
56
56
 
57
- The first version stores the routine and can run it on demand. Automatic time-based scheduling should attach this store to the existing schedule engine in a later iteration.
57
+ Routine execution and long-lived goal continuation use the existing Schedule Engine rather than a second assistant daemon. Semantic/model-backed routine logic remains external-controller-owned; the Kernel does not execute an unbounded model loop on a timer.
58
+
59
+ For an accepted non-terminal Work, ChatGPT can create a bounded `rh_work.schedule_create` policy in one of three modes:
60
+
61
+ - `continuation` — wake the bound ChatGPT/Codex/Claude/Grok Controller at the configured trigger;
62
+ - `browser_watch` — silently refresh/read an allowed browser target, fingerprint a deterministic text projection, and wake the bound Controller only when the observation changes;
63
+ - `browser_keepalive` — silently refresh an authenticated browser target and wake only when configured login markers indicate that user authentication is required.
64
+
65
+ The first `browser_watch` observation is a silent baseline by default. Unchanged observations do not create a ChatGPT conversation turn. When the bound Work becomes terminal, Forge disables the Schedule before performing another external probe, so temporary watchers naturally disappear with the Goal they serve.
58
66
 
59
67
  ## Policy defaults
60
68
 
@@ -76,8 +84,10 @@ Requires human approval:
76
84
 
77
85
  ## Assistant Inbox
78
86
 
79
- Assistant Inbox is the user-facing result surface for routine outputs and intent execution records. ChatGPT can read it and then present the result conversationally, for example: "今天助理整理了什么?"
87
+ Assistant Inbox is the user-facing result surface for meaningful routine outputs, approval/authentication needs, and intent execution records. A successful scheduled probe with no external change should remain silent rather than generating a daily "completed" notification. ChatGPT can read meaningful items and then present the result conversationally, for example: "今天助理整理了什么?"
80
88
 
81
89
  ## Current limitation
82
90
 
83
91
  The runtime does not embed a full NLP model. ChatGPT Actions should provide structure when precision matters. The local runtime includes conservative intent inference only for routine creation, reminders, and immediate mail-summary collection.
92
+
93
+ `browser_watch` is a fallback observation source for services without a usable API or webhook; it depends on a live authenticated browser session and caller-supplied selectors/terms when the page contains unrelated or volatile content. Browser refresh can reduce inactivity-related logout but cannot override a provider's absolute session lifetime, security checks, or MFA policy. Provider-native APIs/events should replace browser probes when they are available and more reliable.
@@ -48,7 +48,7 @@ Session metadata is reusable across actions via `session_id`. Any action that re
48
48
  - Artifacts stay under `.forge/browser/**` (not arbitrary local paths).
49
49
  - CDP attach is bounded to configured loopback endpoints only; the plugin does not scan arbitrary ports or remote hosts. Native discovery checks only the configured Chrome/Vivaldi candidates and does not launch them.
50
50
  - CDP browsers are disconnected after the action; Apple Events keeps plugin-owned session tabs open until `close_session`/`close_page` while preserving user-owned tabs; managed contexts are closed after the action. Standard native DOM reads/interactions do not foreground the owned tab and return DOM evidence instead of attempting a screenshot.
51
- - Health `userFacingStatus` reports `ready`, `domain restricted`, `session active`, or setup states.
51
+ - Health `userFacingStatus` reports `ready`, `session active`, or setup states.
52
52
 
53
53
  ## Configuration
54
54
 
@@ -67,12 +67,11 @@ Example:
67
67
  "nativeBrowserCandidates": ["vivaldi", "chrome"],
68
68
  "cdpAttachFallback": "managed_persistent",
69
69
  "browserChannel": "chromium",
70
- "defaultTimeoutMs": 30000,
71
- "allowedDomains": ["example.com", "docs.example.com"]
70
+ "defaultTimeoutMs": 30000
72
71
  }
73
72
  ```
74
73
 
75
- `allowedDomains` is the safety boundary. If it is empty, the plugin can target any HTTP(S) host. If it is set, the plugin accepts only exact hosts or subdomains of those entries.
74
+ Browser URLs are open by default across hosts. The URL boundary is scheme-based: top-level navigation accepts absolute `http://` and `https://` URLs and rejects `file://`, `data:`, browser-internal pages, and custom schemes. Interaction risk and authorization are enforced by the typed action policy rather than a domain grant.
76
75
 
77
76
  Additional browser/profile fields:
78
77
 
@@ -131,8 +130,7 @@ Example custom Chrome automation-profile binding:
131
130
  "browserChannel": "chrome",
132
131
  "cdpEndpoint": "http://127.0.0.1:9222",
133
132
  "cdpAttachFallback": "fail_closed",
134
- "defaultTimeoutMs": 30000,
135
- "allowedDomains": ["appstoreconnect.apple.com"]
133
+ "defaultTimeoutMs": 30000
136
134
  }
137
135
  ```
138
136
 
@@ -152,12 +150,11 @@ Use native attachment when the target session is already logged in:
152
150
  "browserMode": "attach_preferred",
153
151
  "nativeAttachMode": "auto",
154
152
  "nativeBrowserCandidates": ["vivaldi", "chrome"],
155
- "cdpAttachFallback": "managed_persistent",
156
- "allowedDomains": ["chatgpt.com", "example.com"]
153
+ "cdpAttachFallback": "managed_persistent"
157
154
  }
158
155
  ```
159
156
 
160
- The attach order is CDP, then Apple Events, then managed fallback. macOS may require Automation permission for the controller/Node process to control Vivaldi or Google Chrome. The browser must also permit JavaScript from Apple Events for DOM extraction and interaction. If either permission is unavailable or the browser has no window, the attempt is recorded and fallback policy applies. The provider uses the active browser only for discovery, then operates on a plugin-owned tab identified by stable window/tab metadata. It does not read cookies or storage and re-checks the target URL against `allowedDomains` before and after actions.
157
+ The attach order is CDP, then Apple Events, then managed fallback. macOS may require Automation permission for the controller/Node process to control Vivaldi or Google Chrome. The browser must also permit JavaScript from Apple Events for DOM extraction and interaction. If either permission is unavailable or the browser has no window, the attempt is recorded and fallback policy applies. The provider uses the active browser only for discovery, then operates on a plugin-owned tab identified by stable window/tab metadata. It does not read cookies or storage. Top-level Browser actions accept any absolute HTTP(S) URL; non-HTTP(S) schemes fail before launch.
161
158
 
162
159
  Native limitations are explicit: console/network event capture is unavailable and native file-input/download semantics are not equivalent to Playwright. A screenshot of a plugin-owned background tab fails closed with `PLUGIN_BROWSER_FOREGROUND_REQUIRED`; Forge does not silently activate the tab just to capture evidence. Use explicit foreground/human handoff, CDP, or a managed context for workflows that require screenshots or capabilities not available through background DOM automation.
163
160
 
@@ -219,15 +216,14 @@ Risk levels:
219
216
  - `click`, `type`, `press` use `risk=remote_write`
220
217
  - `wait_for_selector` uses `risk=workspace_write`
221
218
 
222
- ## Allowed-domain enforcement
223
-
224
- The plugin enforces `allowedDomains` in three places:
219
+ ## URL scheme boundary
225
220
 
226
- 1. It validates the explicit `url` before opening a page.
227
- 2. It validates any saved `session_id` target before interaction.
228
- 3. It re-checks the resulting page URL after the action and rejects the result if navigation leaves the allowed set.
221
+ Browser no longer maintains per-domain grants or a host allowlist. Any absolute HTTP(S) URL may be opened, redirected to, or reached by an interaction. This avoids configuration churn for normal browsing while preserving the actual trust boundaries:
229
222
 
230
- The plugin does not intentionally provide any action that can bypass this boundary.
223
+ 1. top-level Browser targets must use `http:` or `https:`;
224
+ 2. local files, browser-internal URLs, `data:` URLs, and custom schemes are rejected as Browser targets;
225
+ 3. action risk and confirmation still govern remote writes and interactive operations;
226
+ 4. silent native automation stays on plugin-owned background tabs unless an explicit human handoff requires foreground presentation.
231
227
 
232
228
  ## Dependency requirement
233
229
 
@@ -24,7 +24,7 @@ These features are part of the default local setup:
24
24
  | Codex / Claude | Delegated implementation for work too large for Direct Edit | Install and authenticate the corresponding CLI; explicitly enable the dev runner. |
25
25
  | GitHub | Issue/Project synchronization, PR workflows, cloud-agent sessions | Authenticated `gh` and repository permission. |
26
26
  | Tailscale / Cloudflare | Stable HTTPS endpoint for ChatGPT MCP | Tunnel client and appropriate account/domain configuration. |
27
- | Browser | Playwright navigation, screenshots, and bounded browser evidence | Browser binaries and allowed domains. |
27
+ | Browser | Playwright navigation, screenshots, and bounded browser evidence | Browser binaries and HTTP(S) URL scheme validation. |
28
28
  | CodeGraph | Additional code relationships and impact navigation | CodeGraph CLI; native Windows automatic setup is not yet enabled. |
29
29
  | Google Workspace | Gmail and Calendar assistant actions | Explicit OAuth setup and plugin permission. |
30
30
  | Schedules and findings | Supervised recurring checks and candidate findings | Canonical Forge Runtime running; live actions remain policy-gated. |
@@ -6,8 +6,8 @@ Homebrew is planned as a convenience layer after the first stable npm/GitHub rel
6
6
 
7
7
  Do not publish a formula until all of the following are true:
8
8
 
9
- - `@moretea-labs/forge@1.5.1` exists on npm;
10
- - Git tag and GitHub Release `v1.5.1` exist and point to the tested commit;
9
+ - `@moretea-labs/forge@1.6.0` exists on npm;
10
+ - Git tag and GitHub Release `v1.6.0` exist and point to the tested commit;
11
11
  - the npm tarball has an immutable checksum;
12
12
  - macOS installation and `forge --version` / `forge doctor` tests pass;
13
13
  - upgrade and uninstall behavior is documented.
@@ -34,7 +34,7 @@ For Forge source development only:
34
34
  ```bash
35
35
  git clone https://github.com/moretea-labs/forge.git
36
36
  cd forge
37
- npm ci --ignore-scripts --no-audit --no-fund
37
+ bun install --frozen-lockfile
38
38
  npm install -g . --omit=optional --no-audit --no-fund
39
39
  ```
40
40
 
@@ -34,7 +34,7 @@ forge --version
34
34
  ```bash
35
35
  git clone https://github.com/moretea-labs/forge.git
36
36
  cd forge
37
- npm ci --ignore-scripts --no-audit --no-fund
37
+ bun install --frozen-lockfile
38
38
  npm install -g . --omit=optional --no-audit --no-fund
39
39
  ```
40
40
 
@@ -7,7 +7,7 @@
7
7
  This is the verified path while `@moretea-labs/forge` remains unpublished:
8
8
 
9
9
  ```bash
10
- npm ci --ignore-scripts --no-audit --no-fund
10
+ bun install --frozen-lockfile
11
11
  npm install -g . --omit=optional --no-audit --no-fund
12
12
  ```
13
13
 
@@ -7,7 +7,7 @@ Until the npm RC is publicly available, use the verified source path:
7
7
  ```bash
8
8
  git clone https://github.com/moretea-labs/forge.git
9
9
  cd forge
10
- npm ci --ignore-scripts --no-audit --no-fund
10
+ bun install --frozen-lockfile
11
11
  npm install -g . --omit=optional --no-audit --no-fund
12
12
  ```
13
13
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@moretea-labs/forge",
3
- "version": "1.5.1",
3
+ "version": "1.6.0",
4
4
  "description": "Local-first action assistant for software work with resumable execution, bounded tools, and reviewable evidence",
5
5
  "license": "MIT",
6
6
  "author": "Moretea Labs contributors",
@@ -72,9 +72,22 @@ grep -q "$README_CHANNEL_EN" README.md || { echo "[public-docs] English README m
72
72
  grep -q "$README_CHANNEL_ZH" README.zh-CN.md || { echo "[public-docs] Chinese README must state the npm channel for $PACKAGE_VERSION" >&2; exit 1; }
73
73
  grep -q 'See \[README.md\](README.md)' README.en.md || { echo "[public-docs] README.en.md must point to the maintained English README" >&2; exit 1; }
74
74
 
75
+ source_install_docs=(
76
+ README.md README.zh-CN.md
77
+ docs/tutorials/01-install-and-start.md docs/tutorials/01-install-and-start.zh-CN.md
78
+ docs/wiki/Quick-Start.md docs/wiki/Installation.md
79
+ )
80
+ for path in "${source_install_docs[@]}"; do
81
+ grep -q 'bun install --frozen-lockfile' "$path" || { echo "[public-docs] source checkout must use the CI-authoritative Bun frozen lock: $path" >&2; exit 1; }
82
+ grep -q 'npm install -g \.' "$path" || { echo "[public-docs] missing source package install: $path" >&2; exit 1; }
83
+ done
84
+ if git grep -n 'npm ci --ignore-scripts --no-audit --no-fund' -- "${source_install_docs[@]}" >/dev/null; then
85
+ echo "[public-docs] generic npm ci must not be advertised for Forge source checkouts" >&2
86
+ exit 1
87
+ fi
88
+
75
89
  for path in docs/tutorials/01-install-and-start.md docs/tutorials/01-install-and-start.zh-CN.md; do
76
90
  grep -q 'Node.js 20.10' "$path" || { echo "[public-docs] missing Node baseline: $path" >&2; exit 1; }
77
- grep -q 'npm install -g \.' "$path" || { echo "[public-docs] missing source install: $path" >&2; exit 1; }
78
91
  grep -q "$INSTALL_SPEC" "$path" || { echo "[public-docs] missing package command for $PACKAGE_VERSION: $path" >&2; exit 1; }
79
92
  done
80
93
 
@@ -0,0 +1,64 @@
1
+ #!/usr/bin/env bun
2
+ import { mkdirSync, writeFileSync } from 'fs';
3
+ import { dirname, join, resolve } from 'path';
4
+ import { resolveRepoPreferredControllerHome } from '../src/cli/repositories/controller-home';
5
+ import { listRepositories } from '../src/cli/repositories/registry';
6
+ import { migrateChatgptAutomationSchedule } from '../src/runtime/workflow/schedules/chatgpt-automation-migration';
7
+ import { listSchedules, saveSchedule } from '../src/runtime/workflow/schedules/store';
8
+ import type { RepositorySchedule } from '../src/runtime/workflow/schedules/types';
9
+
10
+ interface Args { controllerHome?: string; apply: boolean; json: boolean; }
11
+
12
+ function parseArgs(argv: string[]): Args {
13
+ const args: Args = { apply: false, json: false };
14
+ for (let index = 0; index < argv.length; index += 1) {
15
+ const value = argv[index];
16
+ if (value === '--apply') args.apply = true;
17
+ else if (value === '--json') args.json = true;
18
+ else if (value === '--controller-home') args.controllerHome = resolve(argv[++index] ?? '');
19
+ else throw new Error(`unknown argument: ${value}`);
20
+ }
21
+ return args;
22
+ }
23
+
24
+ function main(): void {
25
+ const args = parseArgs(process.argv.slice(2));
26
+ const controllerHome = resolveRepoPreferredControllerHome(process.cwd(), args.controllerHome);
27
+ const repositories = listRepositories(controllerHome, { includeRemoved: true });
28
+ const candidates: Array<{ repositoryName: string; original: RepositorySchedule; migrated: RepositorySchedule }> = [];
29
+ for (const repository of repositories) {
30
+ for (const schedule of listSchedules(controllerHome, repository.repoId)) {
31
+ const result = migrateChatgptAutomationSchedule(schedule);
32
+ if (result.changed) candidates.push({ repositoryName: repository.displayName, original: schedule, migrated: result.schedule });
33
+ }
34
+ }
35
+
36
+ let backupPath: string | undefined;
37
+ if (args.apply && candidates.length > 0) {
38
+ const stamp = new Date().toISOString().replace(/[:.]/g, '-');
39
+ backupPath = join(controllerHome, 'backups', `chatgpt-automation-schedules-${stamp}.json`);
40
+ mkdirSync(dirname(backupPath), { recursive: true });
41
+ writeFileSync(backupPath, `${JSON.stringify({ schemaVersion: 1, createdAt: new Date().toISOString(), schedules: candidates.map((item) => item.original) }, null, 2)}\n`);
42
+ for (const candidate of candidates) saveSchedule(controllerHome, candidate.migrated);
43
+ }
44
+
45
+ const output = {
46
+ schemaVersion: 1,
47
+ controllerHome,
48
+ repositoryCount: repositories.length,
49
+ migratedCount: candidates.length,
50
+ writesApplied: args.apply,
51
+ backupPath,
52
+ schedules: candidates.map((item) => ({ repoId: item.original.repoId, repositoryName: item.repositoryName, scheduleId: item.original.scheduleId, name: item.original.name })),
53
+ };
54
+ if (args.json) console.log(JSON.stringify(output, null, 2));
55
+ else {
56
+ console.log(`[chatgpt-automation-migration] ${candidates.length} schedule(s) eligible across ${repositories.length} repository record(s).`);
57
+ console.log(args.apply ? `[chatgpt-automation-migration] Applied. Backup: ${backupPath ?? 'not needed'}` : '[chatgpt-automation-migration] Preview only; pass --apply after the new Runtime baseline is active.');
58
+ }
59
+ }
60
+
61
+ try { main(); } catch (error) {
62
+ console.error(error instanceof Error ? error.message : String(error));
63
+ process.exitCode = 1;
64
+ }
@@ -21,7 +21,7 @@ Usage:
21
21
 
22
22
  Program-level sprint backlog helper. PRDs live in plans/prds/ as the upper
23
23
  planning layer; sprints live in plans/sprints/ as ordered execution backlogs.
24
- Each backlog row is expanded with $think before the existing plan -> contract
24
+ Each backlog row is expanded with /plan before the existing plan -> contract
25
25
  -> worktree flow. tasks/todos.md stays the deferred-goal ledger.
26
26
 
27
27
  start-task reserves the next (or named) pending backlog row and can capture a
@@ -13,7 +13,11 @@ import {
13
13
  } from '../chatgpt-browser/engine';
14
14
  import type { BrowserProviderName, BrowserSessionStatus, NativeBrowserChannel, ThinkingLevel } from '../chatgpt-browser/types';
15
15
  import { durableControllerHome } from '../repositories/controller-home';
16
- import { runWorkChatgptContinuation } from '../../runtime/control-plane/launcher/chatgpt-work-continuation';
16
+ import {
17
+ runWorkChatgptContinuation,
18
+ type ChatgptAutomationReasoning,
19
+ type ChatgptAutomationTabPolicy,
20
+ } from '../../runtime/control-plane/launcher/chatgpt-work-continuation';
17
21
 
18
22
  interface BrowserCommonOptions {
19
23
  repo?: string;
@@ -84,6 +88,9 @@ interface WorkChatgptContinueOptions extends BrowserCommonOptions {
84
88
  title?: string;
85
89
  session?: string;
86
90
  conversationUrl?: string;
91
+ model?: string;
92
+ reasoning?: string;
93
+ tabPolicy?: string;
87
94
  timeoutMs?: string;
88
95
  }
89
96
 
@@ -109,6 +116,18 @@ interface BrowserFollowupOptions extends BrowserCommonOptions {
109
116
  oracleBin?: string;
110
117
  }
111
118
 
119
+ function parseAutomationReasoning(value?: string): ChatgptAutomationReasoning | undefined {
120
+ if (value === undefined) return undefined;
121
+ if (value === 'medium' || value === 'high' || value === 'xhigh') return value;
122
+ throw new Error(`invalid --reasoning "${value}" (expected: medium, high, xhigh)`);
123
+ }
124
+
125
+ function parseAutomationTabPolicy(value?: string): ChatgptAutomationTabPolicy | undefined {
126
+ if (value === undefined) return undefined;
127
+ if (value === 'auto' || value === 'reuse' || value === 'new') return value;
128
+ throw new Error(`invalid --tab-policy "${value}" (expected: auto, reuse, new)`);
129
+ }
130
+
112
131
  function parseProvider(value?: string): BrowserProviderName {
113
132
  if (value === undefined || value === 'oracle') return 'oracle';
114
133
  if (value === 'native') return 'native';
@@ -384,8 +403,11 @@ export function buildChatgptCommand(): Command {
384
403
  .requiredOption('--work-id <work-id>', 'Stable Forge Work id')
385
404
  .requiredOption('--prompt <text>', 'Bounded continuation prompt')
386
405
  .option('--title <title>', 'Human-readable local conversation alias')
387
- .option('--session <session-id>', 'Legacy saved browser session to seed the binding')
406
+ .option('--session <session-id>', 'Saved browser session to seed or resume the Work binding')
388
407
  .option('--conversation-url <url>', 'Explicit ChatGPT conversation URL to seed the binding')
408
+ .option('--model <label>', 'Requested ChatGPT automation model', 'gpt-5.6')
409
+ .option('--reasoning <level>', 'Reasoning level: medium|high|xhigh', 'high')
410
+ .option('--tab-policy <policy>', 'Browser tab policy: auto|reuse|new', 'auto')
389
411
  .option('--timeout-ms <ms>', 'Assistant timeout in milliseconds')
390
412
  .action((rawOpts: WorkChatgptContinueOptions) => {
391
413
  void runChatgptAction(async () => {
@@ -398,6 +420,9 @@ export function buildChatgptCommand(): Command {
398
420
  title: rawOpts.title,
399
421
  browserSessionId: rawOpts.session,
400
422
  conversationUrl: rawOpts.conversationUrl,
423
+ model: rawOpts.model,
424
+ reasoning: parseAutomationReasoning(rawOpts.reasoning),
425
+ tabPolicy: parseAutomationTabPolicy(rawOpts.tabPolicy),
401
426
  timeoutMs: parsePositiveInteger('timeout-ms', rawOpts.timeoutMs),
402
427
  });
403
428
  console.log(JSON.stringify({
@@ -408,6 +433,10 @@ export function buildChatgptCommand(): Command {
408
433
  conversationId: result.conversationId,
409
434
  localAlias: result.localAlias,
410
435
  resumedFromBinding: result.resumedFromBinding,
436
+ model: result.model,
437
+ reasoning: result.reasoning,
438
+ tabPolicy: result.tabPolicy,
439
+ executionPreferenceVerified: result.executionPreferenceVerified,
411
440
  error: result.error,
412
441
  }, null, 2));
413
442
  });
@@ -5,11 +5,11 @@ import { dirname, isAbsolute, join, resolve, sep } from 'path';
5
5
  import { fileURLToPath } from 'url';
6
6
  import { Command } from 'commander';
7
7
  import { resolveControllerHome, controllerSystemRoot } from '../repositories/controller-home';
8
- import { installExternalPluginRegistration, listExternalPluginRegistrations, type ExternalPluginRegistrationInput } from '../../runtime/plugins/external-registration';
8
+ import { installExternalPluginRegistration, listExternalPluginRegistrations, type ExternalPluginRegistration, type ExternalPluginRegistrationInput } from '../../runtime/plugins/external-registration';
9
9
  import { createDesktopOperatorRegistrationInput } from '../../runtime/plugins/desktop-operator-registration';
10
10
  import { controllerPluginRepository, getAssistantPluginManifest, syncAssistantPluginRegistry } from '../../runtime/plugins/store';
11
11
 
12
- interface RegistryEntry { id:string; name:string; version:string; description:string; repository:string; ref:string; installer:string; platforms:NodeJS.Platform[]; }
12
+ export interface RegistryEntry { id:string; name:string; version:string; description:string; repository:string; ref:string; installer:string; providerVersion?:string; protocolVersion?:string; platforms:NodeJS.Platform[]; }
13
13
  interface Registry { schemaVersion:1; plugins:RegistryEntry[]; }
14
14
  interface CliOptions { json?:boolean; controllerHome?:string; refresh?:boolean; }
15
15
  const packageRoot=resolve(dirname(fileURLToPath(import.meta.url)),'../../..');
@@ -24,11 +24,26 @@ function registry():Registry{
24
24
  if(!plugin.repository.startsWith('https://github.com/moretea-labs/')||!plugin.repository.endsWith('.git'))throw new Error(`PLUGIN_CATALOG_UNTRUSTED_REPOSITORY: ${plugin.id}`);
25
25
  if(!/^v\d+\.\d+\.\d+(?:[-+][A-Za-z0-9.-]+)?$/.test(plugin.ref))throw new Error(`PLUGIN_CATALOG_UNPINNED_REF: ${plugin.id}`);
26
26
  if(plugin.installer!=='forge-plugin-install.mjs')throw new Error(`PLUGIN_CATALOG_INSTALLER_INVALID: ${plugin.id}`);
27
+ if(plugin.providerVersion!==undefined&&!/^\d+\.\d+\.\d+(?:[-+][A-Za-z0-9.-]+)?$/.test(plugin.providerVersion))throw new Error(`PLUGIN_CATALOG_PROVIDER_VERSION_INVALID: ${plugin.id}`);
28
+ if(plugin.protocolVersion!==undefined&&!/^\d+\.\d+$/.test(plugin.protocolVersion))throw new Error(`PLUGIN_CATALOG_PROTOCOL_VERSION_INVALID: ${plugin.id}`);
27
29
  ids.add(plugin.id);
28
30
  }
29
31
  return value;
30
32
  }
31
33
  function within(root:string,relative:string):string{const target=resolve(root,relative);const base=`${resolve(root)}${sep}`;if(target!==resolve(root)&&!target.startsWith(base))throw new Error('PLUGIN_INSTALL_PATH_ESCAPE');return target;}
34
+ export function pluginCatalogCompatibility(entry:RegistryEntry,platform:NodeJS.Platform=process.platform):{compatible:boolean;reason?:string}{
35
+ if(!entry.platforms.includes(platform))return{compatible:false,reason:`unsupported platform: ${platform}`};
36
+ if(entry.providerVersion&&entry.providerVersion!==entry.version)return{compatible:false,reason:`catalog version ${entry.version} does not match pinned provider version ${entry.providerVersion}`};
37
+ return{compatible:true};
38
+ }
39
+ export function officialPluginCatalogItems(platform:NodeJS.Platform=process.platform){return registry().plugins.map(entry=>({...entry,...pluginCatalogCompatibility(entry,platform)}));}
40
+ export function externalPluginListItem(controllerHome:string,registration:ExternalPluginRegistration){
41
+ const base={pluginId:registration.pluginId,version:registration.pluginVersion,provider:registration.provider,enabled:registration.enabled,scope:registration.scope,transport:registration.transport.kind};
42
+ if(registration.scope==='repository')return{...base,healthScope:'repository_context_required' as const};
43
+ const repository=controllerPluginRepository(controllerHome);
44
+ try{return{...base,health:getAssistantPluginManifest(controllerHome,repository,registration.pluginId).health};}
45
+ catch(error){return{...base,health:{state:'error',message:error instanceof Error?error.message:String(error)}};}
46
+ }
32
47
  function run(command:string,args:string[],cwd:string,timeoutMs=120000):string{
33
48
  const result=spawnSync(command,args,{cwd,encoding:'utf8',timeout:timeoutMs,maxBuffer:32*1024*1024,shell:false,windowsHide:true});
34
49
  if(result.error)throw new Error(`PLUGIN_INSTALL_COMMAND_FAILED: ${result.error.message}`);
@@ -40,17 +55,29 @@ function installerJson(stdout:string):Record<string,unknown>{
40
55
  for(let i=lines.length-1;i>=0;i--){try{const x=JSON.parse(lines[i]);if(x&&typeof x==='object'&&!Array.isArray(x)&&x.schemaVersion===1)return x;}catch{}}
41
56
  throw new Error('PLUGIN_INSTALLER_OUTPUT_INVALID');
42
57
  }
58
+ export function installerNextSteps(result:Record<string,unknown>):string[]{
59
+ if(!Array.isArray(result.nextSteps))return[];
60
+ return result.nextSteps
61
+ .filter((value):value is string=>typeof value==='string')
62
+ .map(value=>value.replace(/[\u0000-\u001F\u007F]/g,' ').trim().slice(0,1000))
63
+ .filter(Boolean)
64
+ .slice(0,10);
65
+ }
43
66
  function registrationFrom(result:Record<string,unknown>,entry:RegistryEntry):ExternalPluginRegistrationInput{
44
67
  if(result.registration){const input=result.registration as ExternalPluginRegistrationInput;if(input.pluginId!==entry.id)throw new Error('PLUGIN_INSTALLER_ID_MISMATCH');if(input.pluginVersion!==entry.version)throw new Error('PLUGIN_INSTALLER_VERSION_MISMATCH');return input;}
45
68
  const facts=result.providerInstall as Record<string,unknown>|undefined;
46
69
  if(!facts||facts.kind!=='desktop_operator'||entry.id!=='desktop_operator')throw new Error('PLUGIN_INSTALLER_REGISTRATION_MISSING');
47
- if(facts.pluginVersion!==entry.version||facts.protocolVersion!=='1.0')throw new Error('PLUGIN_INSTALLER_PROVIDER_VERSION_MISMATCH');
70
+ const expectedProviderVersion=entry.providerVersion??entry.version;
71
+ const expectedProtocolVersion=entry.protocolVersion??'1.0';
72
+ if(facts.pluginVersion!==expectedProviderVersion)throw new Error(`PLUGIN_INSTALLER_PROVIDER_VERSION_MISMATCH: expected ${expectedProviderVersion} for ${entry.id}@${entry.version}, actual ${String(facts.pluginVersion??'missing')}`);
73
+ if(facts.protocolVersion!==expectedProtocolVersion)throw new Error(`PLUGIN_INSTALLER_PROTOCOL_VERSION_MISMATCH: expected ${expectedProtocolVersion} for ${entry.id}@${entry.version}, actual ${String(facts.protocolVersion??'missing')}`);
48
74
  const socketPath=typeof facts.socketPath==='string'?facts.socketPath:'';const launchAgentLabel=typeof facts.launchAgentLabel==='string'?facts.launchAgentLabel:'';const expectedProgramContains=typeof facts.expectedProgramContains==='string'?facts.expectedProgramContains:'';
49
75
  if(!isAbsolute(socketPath)||!launchAgentLabel||!expectedProgramContains)throw new Error('PLUGIN_INSTALLER_PROVIDER_FACTS_INVALID');
50
- return createDesktopOperatorRegistrationInput({socketPath,launchAgentLabel,expectedProgramContains,pluginVersion:entry.version,protocolVersion:'1.0'});
76
+ return createDesktopOperatorRegistrationInput({socketPath,launchAgentLabel,expectedProgramContains,pluginVersion:expectedProviderVersion,protocolVersion:expectedProtocolVersion});
51
77
  }
52
78
  function install(entry:RegistryEntry,controllerHome:string):Record<string,unknown>{
53
- if(!entry.platforms.includes(process.platform))throw new Error(`PLUGIN_PLATFORM_UNSUPPORTED: ${entry.id}/${process.platform}`);
79
+ const compatibility=pluginCatalogCompatibility(entry,process.platform);
80
+ if(!compatibility.compatible)throw new Error(`PLUGIN_CATALOG_INCOMPATIBLE: ${entry.id}@${entry.version}: ${compatibility.reason}`);
54
81
  const packagesRoot=join(controllerSystemRoot(controllerHome),'plugins','packages');mkdirSync(packagesRoot,{recursive:true,mode:0o700});
55
82
  const finalRoot=join(packagesRoot,entry.id),stage=join(packagesRoot,`.${entry.id}.${randomUUID()}.staging`),backup=join(packagesRoot,`.${entry.id}.${randomUUID()}.backup`);let backed=false;
56
83
  try{
@@ -62,13 +89,13 @@ function install(entry:RegistryEntry,controllerHome:string):Record<string,unknow
62
89
  const installed=installExternalPluginRegistration(controllerHome,registrationFrom(result,entry));
63
90
  const repository=controllerPluginRepository(controllerHome);syncAssistantPluginRegistry(controllerHome,repository);const plugin=getAssistantPluginManifest(controllerHome,repository,entry.id);
64
91
  if(backed)rmSync(backup,{recursive:true,force:true});
65
- return{pluginId:entry.id,version:entry.version,packageRoot:finalRoot,registrationRevision:installed.revision,enabled:plugin.enabled,health:plugin.health};
92
+ return{pluginId:entry.id,version:entry.version,packageRoot:finalRoot,registrationRevision:installed.revision,enabled:plugin.enabled,health:plugin.health,nextSteps:installerNextSteps(result)};
66
93
  }catch(error){rmSync(stage,{recursive:true,force:true});if(backed&&existsSync(finalRoot))rmSync(finalRoot,{recursive:true,force:true});if(backed&&existsSync(backup))renameSync(backup,finalRoot);throw error;}
67
94
  }
68
95
  export function buildPluginCommand():Command{
69
96
  const root=new Command('plugin').description('Discover and install trusted Forge plugins');
70
- root.command('catalog').description('List official plugins').option('--json','Output JSON').action((o:CliOptions)=>{const items=registry().plugins.map(x=>({...x,compatible:x.platforms.includes(process.platform)}));if(o.json)console.log(JSON.stringify({schemaVersion:1,platform:process.platform,plugins:items},null,2));else items.forEach(x=>console.log(`${x.id}\t${x.version}\t${x.compatible?'compatible':'unsupported'}\t${x.name}`));});
71
- root.command('list').description('List installed external plugins').option('--json','Output JSON').option('--controller-home <path>','Override Controller Home').option('--refresh','Probe providers').action((o:CliOptions)=>{const home=resolveControllerHome(o.controllerHome),repo=controllerPluginRepository(home);if(o.refresh)syncAssistantPluginRegistry(home,repo);const items=listExternalPluginRegistrations(home).map(r=>{let health:unknown;try{health=getAssistantPluginManifest(home,repo,r.pluginId).health;}catch(e){health={state:'error',message:e instanceof Error?e.message:String(e)};}return{pluginId:r.pluginId,version:r.pluginVersion,provider:r.provider,enabled:r.enabled,transport:r.transport.kind,health};});if(o.json)console.log(JSON.stringify({schemaVersion:1,plugins:items},null,2));else if(!items.length)console.log('No external Forge plugins installed.');else items.forEach(x=>console.log(`${x.pluginId}\t${x.version}\t${x.enabled?'enabled':'disabled'}\t${(x.health as {state?:string})?.state??'unknown'}`));});
72
- root.command('install <plugin-id>').description('Install/update an official plugin from a pinned public release').option('--json','Output JSON').option('--controller-home <path>','Override Controller Home').action((id:string,o:CliOptions)=>{const entry=registry().plugins.find(x=>x.id===id);if(!entry)throw new Error(`PLUGIN_NOT_IN_OFFICIAL_CATALOG: ${id}`);const result=install(entry,resolveControllerHome(o.controllerHome));if(o.json)console.log(JSON.stringify(result,null,2));else console.log(`Installed ${entry.name} ${entry.version} (${(result.health as {state?:string})?.state??'unknown'}).`);});
97
+ root.command('catalog').description('List official plugins').option('--json','Output JSON').action((o:CliOptions)=>{const items=officialPluginCatalogItems();if(o.json)console.log(JSON.stringify({schemaVersion:1,platform:process.platform,plugins:items},null,2));else items.forEach(x=>console.log(`${x.id}\t${x.version}\t${x.compatible?'compatible':`incompatible (${x.reason})`}\t${x.name}`));});
98
+ root.command('list').description('List installed external plugins').option('--json','Output JSON').option('--controller-home <path>','Override Controller Home').option('--refresh','Probe controller-scoped providers').action((o:CliOptions)=>{const home=resolveControllerHome(o.controllerHome),repo=controllerPluginRepository(home);if(o.refresh)syncAssistantPluginRegistry(home,repo);const items=listExternalPluginRegistrations(home).map(r=>externalPluginListItem(home,r));if(o.json)console.log(JSON.stringify({schemaVersion:1,plugins:items},null,2));else if(!items.length)console.log('No external Forge plugins installed.');else items.forEach(x=>console.log(`${x.pluginId}\t${x.version}\t${x.enabled?'enabled':'disabled'}\t${'healthScope' in x?'repository-context-required':((x.health as {state?:string})?.state??'unknown')}`));});
99
+ root.command('install <plugin-id>').description('Install/update an official plugin from a pinned public release').option('--json','Output JSON').option('--controller-home <path>','Override Controller Home').action((id:string,o:CliOptions)=>{const entry=registry().plugins.find(x=>x.id===id);if(!entry)throw new Error(`PLUGIN_NOT_IN_OFFICIAL_CATALOG: ${id}`);const result=install(entry,resolveControllerHome(o.controllerHome));if(o.json)console.log(JSON.stringify(result,null,2));else{console.log(`Installed ${entry.name} ${entry.version} (${(result.health as {state?:string})?.state??'unknown'}).`);for(const step of (result.nextSteps as string[]|undefined)??[])console.log(`- ${step}`);}});
73
100
  return root;
74
101
  }