@moretea-labs/forge 1.5.0 → 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 (138) hide show
  1. package/CHANGELOG.md +28 -1
  2. package/DESIGN.md +7 -2
  3. package/README.md +7 -7
  4. package/README.zh-CN.md +5 -3
  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/forge-chatgpt-mcp-setup.md +27 -3
  22. package/docs/operations/chatgpt-personal-assistant-runtime.md +12 -2
  23. package/docs/operations/controller-browser-plugin.md +12 -16
  24. package/docs/operations/features.md +1 -1
  25. package/docs/operations/homebrew.md +2 -2
  26. package/docs/public-usage-guide.md +1 -1
  27. package/docs/public-usage-guide.zh-CN.md +1 -1
  28. package/docs/tutorials/01-install-and-start.md +6 -4
  29. package/docs/tutorials/01-install-and-start.zh-CN.md +3 -1
  30. package/docs/tutorials/02-connect-chatgpt.md +8 -2
  31. package/docs/tutorials/02-connect-chatgpt.zh-CN.md +8 -2
  32. package/docs/wiki/Installation.md +1 -1
  33. package/docs/wiki/Quick-Start.md +1 -1
  34. package/package.json +1 -1
  35. package/scripts/check-public-docs.sh +14 -1
  36. package/scripts/migrate-chatgpt-automation-schedules.ts +64 -0
  37. package/scripts/sprint-backlog.sh +1 -1
  38. package/src/cli/commands/chatgpt.ts +31 -2
  39. package/src/cli/commands/init-hook.ts +3 -1
  40. package/src/cli/commands/mcp.ts +2 -0
  41. package/src/cli/commands/plugin.ts +36 -9
  42. package/src/cli/commands/runtime.ts +1 -0
  43. package/src/cli/commands/setup-profile.ts +49 -18
  44. package/src/cli/controller/context-pack.ts +158 -6
  45. package/src/cli/hook/prompt-guard-decision.ts +17 -18
  46. package/src/cli/hook/prompt-intents.ts +1 -1
  47. package/src/cli/local-bridge/console-assets.ts +17 -0
  48. package/src/cli/local-bridge/console-automations.ts +205 -23
  49. package/src/cli/local-bridge/console-view-models.ts +2 -0
  50. package/src/cli/local-bridge/facade-api.ts +2 -0
  51. package/src/cli/local-bridge/frontend/src/App.tsx +1 -1
  52. package/src/cli/local-bridge/frontend/src/features/automations/AutomationsView.tsx +143 -3
  53. package/src/cli/local-bridge/frontend/src/features/capabilities/CapabilitiesView.tsx +1 -1
  54. package/src/cli/local-bridge/frontend/src/features/overview/OverviewView.tsx +181 -3
  55. package/src/cli/local-bridge/frontend/src/features/repositories/RepositoriesView.tsx +1 -1
  56. package/src/cli/local-bridge/frontend/src/features/work/WorkView.tsx +4 -3
  57. package/src/cli/local-bridge/frontend/src/lib/api.ts +18 -6
  58. package/src/cli/local-bridge/frontend/src/shell/CommandBar.tsx +1 -1
  59. package/src/cli/local-bridge/frontend/src/shell/Sidebar.tsx +2 -1
  60. package/src/cli/local-bridge/frontend/src/styles.css +27 -11
  61. package/src/cli/local-bridge/frontend/src/types.ts +6 -2
  62. package/src/cli/local-bridge/server.ts +31 -20
  63. package/src/cli/local-bridge/ui-dist/app.css +1 -1
  64. package/src/cli/local-bridge/ui-dist/app.js +5003 -4815
  65. package/src/cli/mcp/auth.ts +2 -0
  66. package/src/cli/mcp/paths.ts +8 -2
  67. package/src/cli/mcp/repository-tools.ts +124 -9
  68. package/src/cli/mcp/server.ts +164 -19
  69. package/src/cli/mcp/setup.ts +9 -1
  70. package/src/cli/mcp/transports/http.ts +52 -2
  71. package/src/cli/mcp/transports/session-registry.ts +24 -0
  72. package/src/cli/repositories/command-classifier.ts +113 -0
  73. package/src/cli/repositories/safe-patch.ts +6 -4
  74. package/src/cli/repositories/structured-git.ts +49 -7
  75. package/src/cli/repository/inspector.ts +16 -3
  76. package/src/runtime/assistant/intent.ts +3 -3
  77. package/src/runtime/control-plane/execution/direct-edit-work-completion.ts +157 -2
  78. package/src/runtime/control-plane/execution/execution-identity.ts +65 -14
  79. package/src/runtime/control-plane/execution/validation.ts +15 -2
  80. package/src/runtime/control-plane/execution/work-terminal-cleanup.ts +32 -0
  81. package/src/runtime/control-plane/execution/work-validation-reconciler.ts +6 -7
  82. package/src/runtime/control-plane/execution/work-verification-snapshot.ts +2 -1
  83. package/src/runtime/control-plane/facade/capability-registry.ts +1 -1
  84. package/src/runtime/control-plane/facade/controller-session-store.ts +12 -13
  85. package/src/runtime/control-plane/facade/goal-workloop-access.ts +7 -2
  86. package/src/runtime/control-plane/facade/goal-workloop.ts +231 -8
  87. package/src/runtime/control-plane/facade/plan-contract-store.ts +32 -12
  88. package/src/runtime/control-plane/facade/requirement-board.ts +24 -8
  89. package/src/runtime/control-plane/facade/types.ts +33 -6
  90. package/src/runtime/control-plane/facade/work-contract-store.ts +31 -2
  91. package/src/runtime/control-plane/launcher/chatgpt-work-continuation.ts +375 -23
  92. package/src/runtime/control-plane/persistence/requirement-store.ts +15 -11
  93. package/src/runtime/diagnostics/mcp-timing.ts +6 -0
  94. package/src/runtime/execution/process-runtime/check-facade.ts +1 -10
  95. package/src/runtime/execution/process-runtime/check-result.ts +10 -2
  96. package/src/runtime/execution/process-runtime/command-facade.ts +1 -0
  97. package/src/runtime/execution/process-runtime/gc.ts +64 -10
  98. package/src/runtime/execution/process-runtime/persisted-check.ts +10 -2
  99. package/src/runtime/execution/process-runtime/resource-claims.ts +87 -2
  100. package/src/runtime/execution/process-runtime/runtime.ts +16 -1
  101. package/src/runtime/execution/process-runtime/store.ts +6 -0
  102. package/src/runtime/gateway/mcp/execution-tools.ts +27 -12
  103. package/src/runtime/gateway/mcp/router.ts +1 -1
  104. package/src/runtime/gateway/mcp/runtime-tools.ts +482 -178
  105. package/src/runtime/model-clients/deepseek-adapter.ts +0 -32
  106. package/src/runtime/plugins/browser-adapter.ts +19 -72
  107. package/src/runtime/plugins/browser-handoff-host.ts +6 -31
  108. package/src/runtime/plugins/browser-handoff.ts +0 -3
  109. package/src/runtime/plugins/browser-macos-bridge.ts +8 -0
  110. package/src/runtime/plugins/desktop-operator-registration.ts +8 -11
  111. package/src/runtime/plugins/external-registration.ts +84 -5
  112. package/src/runtime/plugins/first-party-registry.ts +2 -2
  113. package/src/runtime/plugins/gmail-adapter.ts +28 -2
  114. package/src/runtime/plugins/local-system-adapter.ts +2 -2
  115. package/src/runtime/plugins/plugin-management-adapter.ts +140 -0
  116. package/src/runtime/plugins/store.ts +107 -5
  117. package/src/runtime/plugins/types.ts +4 -0
  118. package/src/runtime/recovery/actions.ts +0 -10
  119. package/src/runtime/recovery/classifier.ts +0 -9
  120. package/src/runtime/recovery/maintenance-executor.ts +43 -11
  121. package/src/runtime/recovery/probe.ts +0 -1
  122. package/src/runtime/recovery/types.ts +0 -1
  123. package/src/runtime/root/package-connector-service.ts +136 -0
  124. package/src/runtime/root/package-runtime-service.ts +25 -12
  125. package/src/runtime/root/release-materialize.ts +161 -18
  126. package/src/runtime/root/service.ts +6 -4
  127. package/src/runtime/safe-tooling/index.ts +0 -1
  128. package/src/runtime/safe-tooling/types.ts +0 -30
  129. package/src/runtime/standalone-recovery/core.ts +2 -2
  130. package/src/runtime/workflow/schedules/browser-probe.ts +180 -0
  131. package/src/runtime/workflow/schedules/chatgpt-automation-migration.ts +40 -0
  132. package/src/runtime/workflow/schedules/engine.ts +357 -64
  133. package/src/runtime/workflow/schedules/types.ts +5 -0
  134. package/src/runtime/workflow/schedules/work-continuation.ts +100 -27
  135. package/bin/forge-desktop-helper.mjs +0 -204
  136. package/docs/operations/controller-desktop-plugin.md +0 -112
  137. package/src/runtime/plugins/desktop-adapter.ts +0 -374
  138. package/src/runtime/safe-tooling/browser-safe.ts +0 -133
@@ -364,7 +364,33 @@ function gmailActions(): AssistantPluginActionDescriptor[] {
364
364
  ];
365
365
  }
366
366
 
367
- function buildMimeMessage(args: Record<string, unknown>, config: GmailPluginConfig): string {
367
+ export function encodeMimeHeaderValue(value: string): string {
368
+ if (/[\r\n]/.test(value)) {
369
+ throw new AssistantPluginError('PLUGIN_ACTION_ARGUMENT_INVALID', 'MIME header values must not contain CR or LF.');
370
+ }
371
+ if (/^[\x00-\x7F]*$/.test(value)) return value;
372
+
373
+ // RFC 2047 encoded-words are limited to 75 characters including wrappers.
374
+ // 45 UTF-8 bytes encode to at most 60 Base64 characters, leaving room for
375
+ // the =?UTF-8?B?...?= wrapper. Split only at Unicode code-point boundaries.
376
+ const chunks: string[] = [];
377
+ let current = '';
378
+ for (const character of value) {
379
+ const candidate = `${current}${character}`;
380
+ if (current && Buffer.byteLength(candidate, 'utf8') > 45) {
381
+ chunks.push(current);
382
+ current = character;
383
+ } else {
384
+ current = candidate;
385
+ }
386
+ }
387
+ if (current) chunks.push(current);
388
+ return chunks
389
+ .map((chunk) => `=?UTF-8?B?${Buffer.from(chunk, 'utf8').toString('base64')}?=`)
390
+ .join('\r\n ');
391
+ }
392
+
393
+ export function buildMimeMessage(args: Record<string, unknown>, config: GmailPluginConfig): string {
368
394
  const to = stringArray(args.to);
369
395
  const cc = stringArray(args.cc);
370
396
  const bcc = stringArray(args.bcc);
@@ -378,7 +404,7 @@ function buildMimeMessage(args: Record<string, unknown>, config: GmailPluginConf
378
404
  ...(cc ? [`Cc: ${cc.join(', ')}`] : []),
379
405
  ...(bcc ? [`Bcc: ${bcc.join(', ')}`] : []),
380
406
  ...(config.accountEmail ? [`From: ${config.accountEmail}`] : []),
381
- `Subject: ${subject}`,
407
+ `Subject: ${encodeMimeHeaderValue(subject)}`,
382
408
  'Content-Type: text/plain; charset="UTF-8"',
383
409
  'MIME-Version: 1.0',
384
410
  '',
@@ -522,7 +522,7 @@ function actions(): AssistantPluginActionDescriptor[] {
522
522
  { actionId: 'restart_user_launch_agent', title: 'Restart verified user LaunchAgent', description: 'Restart one loaded user LaunchAgent by exact label only after launchctl evidence contains the expected program identity.', readOnly: false, risk: 'workspace_write', confirmation: 'authorization', defaultTimeoutMs: 30_000, cancellable: true, idempotent: false, scopes: ['local-system.process'], resourceClaims: controllerWrite, argumentsSchema: { type: 'object', properties: { label: { type: 'string' }, expected_program_contains: { type: 'string' } }, required: ['label', 'expected_program_contains'], additionalProperties: false } },
523
523
  { actionId: 'stop_user_launch_agent', title: 'Stop verified user LaunchAgent', description: 'Boot out one exact loaded user LaunchAgent only after its launchd identity matches the expected program.', readOnly: false, risk: 'workspace_write', confirmation: 'authorization', defaultTimeoutMs: 30_000, cancellable: true, idempotent: true, scopes: ['local-system.process'], resourceClaims: controllerWrite, argumentsSchema: { type: 'object', properties: { label: { type: 'string' }, expected_program_contains: { type: 'string' } }, required: ['label', 'expected_program_contains'], additionalProperties: false } },
524
524
  { actionId: 'start_user_launch_agent', title: 'Start verified user LaunchAgent', description: 'Bootstrap or start one exact current-user LaunchAgent from ~/Library/LaunchAgents only after the installed plist matches the expected program identity.', readOnly: false, risk: 'workspace_write', confirmation: 'authorization', defaultTimeoutMs: 30_000, cancellable: true, idempotent: true, scopes: ['local-system.process'], resourceClaims: controllerWrite, argumentsSchema: { type: 'object', properties: { label: { type: 'string' }, expected_program_contains: { type: 'string' } }, required: ['label', 'expected_program_contains'], additionalProperties: false } },
525
- { actionId: 'open_application', title: 'Open application', description: 'Open one macOS application by name or bundle id.', readOnly: false, risk: 'workspace_write', confirmation: 'authorization', defaultTimeoutMs: 30_000, cancellable: true, idempotent: false, scopes: ['local-system.open'], resourceClaims: controllerWrite, argumentsSchema: { type: 'object', properties: { app_name: { type: 'string' }, bundle_id: { type: 'string' } }, additionalProperties: false } },
525
+ { actionId: 'open_application', title: 'Open application', description: 'Open one macOS application by name or bundle id in the background without stealing foreground focus.', readOnly: false, risk: 'workspace_write', confirmation: 'authorization', defaultTimeoutMs: 30_000, cancellable: true, idempotent: false, scopes: ['local-system.open'], resourceClaims: controllerWrite, argumentsSchema: { type: 'object', properties: { app_name: { type: 'string' }, bundle_id: { type: 'string' } }, additionalProperties: false } },
526
526
  { actionId: 'list_targets', title: 'List filesystem targets', description: 'List active expiring local filesystem grants.', readOnly: true, risk: 'readonly', confirmation: 'none', defaultTimeoutMs: 10_000, cancellable: true, idempotent: true, scopes: ['local-system.files.read'], resourceClaims: controllerRead, argumentsSchema: { type: 'object', properties: {}, additionalProperties: false } },
527
527
  { actionId: 'authorize_target', title: 'Authorize filesystem target', description: 'Authorize an expiring filesystem target. By default, a path inside a Git project authorizes the whole project/repository root; use scope=directory only when intentionally granting a narrower directory.', readOnly: false, risk: 'workspace_write', confirmation: 'authorization', defaultTimeoutMs: 15_000, cancellable: true, idempotent: true, scopes: ['local-system.files.write'], resourceClaims: controllerWrite, argumentsSchema: { type: 'object', properties: { target_key: { type: 'string' }, root_path: { type: 'string' }, scope: { type: 'string', enum: ['auto', 'project', 'directory'] }, expires_in_minutes: { type: 'number' }, reason: { type: 'string' }, access: { type: 'string', enum: ['read_only', 'read_write'] } }, required: ['target_key', 'root_path', 'reason'], additionalProperties: false } },
528
528
  { actionId: 'revoke_target', title: 'Revoke filesystem target', description: 'Immediately revoke one active filesystem target owned by the authenticated caller.', readOnly: false, risk: 'workspace_write', confirmation: 'authorization', defaultTimeoutMs: 15_000, cancellable: true, idempotent: false, scopes: ['local-system.files.write'], resourceClaims: controllerWrite, argumentsSchema: { type: 'object', properties: { target_key: { type: 'string' } }, required: ['target_key'], additionalProperties: false } },
@@ -636,7 +636,7 @@ export async function executeLocalSystemPluginAction(input: AssistantPluginActio
636
636
  if ((!appName && !bundleId) || (appName && bundleId)) {
637
637
  throw new AssistantPluginError('PLUGIN_ACTION_ARGUMENT_INVALID', 'Provide exactly one of app_name or bundle_id.', { retryable: false });
638
638
  }
639
- const command = bundleId ? ['open', '-b', bundleId] : ['open', '-a', appName as string];
639
+ const command = bundleId ? ['open', '-g', '-b', bundleId] : ['open', '-g', '-a', appName as string];
640
640
  const opened = run(command[0], command.slice(1));
641
641
  if (!opened.ok) throw new AssistantPluginError('LOCAL_SYSTEM_OPEN_FAILED', opened.stderr || opened.stdout, { retryable: true, details: { command } });
642
642
  return { opened: true, command };
@@ -0,0 +1,140 @@
1
+ import type {
2
+ AssistantPluginActionDescriptor,
3
+ AssistantPluginActionExecutionInput,
4
+ AssistantPluginCapability,
5
+ AssistantPluginManifest,
6
+ AssistantPluginPermissionScope,
7
+ } from './types';
8
+ import { AssistantPluginError } from './errors';
9
+ import {
10
+ disableExternalPluginRegistration,
11
+ getExternalPluginRegistration,
12
+ installExternalPluginRegistration,
13
+ listExternalPluginRegistrations,
14
+ previewExternalPluginRegistration,
15
+ removeExternalPluginRegistration,
16
+ type ExternalPluginRegistrationInput,
17
+ } from './external-registration';
18
+
19
+ const PLUGIN_ID = 'plugin_management';
20
+
21
+ function now(): string { return new Date().toISOString(); }
22
+ function requiredString(value: unknown, field: string): string {
23
+ if (typeof value !== 'string' || !value.trim()) throw new AssistantPluginError('PLUGIN_ACTION_ARGUMENT_INVALID', `${field} is required.`);
24
+ return value.trim();
25
+ }
26
+ function expectedRevision(value: unknown): number | undefined {
27
+ if (value === undefined) return undefined;
28
+ if (typeof value !== 'number' || !Number.isInteger(value) || value < 0) {
29
+ throw new AssistantPluginError('PLUGIN_ACTION_ARGUMENT_INVALID', 'expected_revision must be a non-negative integer.');
30
+ }
31
+ return value;
32
+ }
33
+ function registrationInput(value: unknown): ExternalPluginRegistrationInput {
34
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
35
+ throw new AssistantPluginError('PLUGIN_ACTION_ARGUMENT_INVALID', 'registration must be an object.');
36
+ }
37
+ return value as ExternalPluginRegistrationInput;
38
+ }
39
+
40
+ function permissions(): AssistantPluginPermissionScope[] {
41
+ return [
42
+ { scope: 'external-plugins.read', mode: 'read', description: 'Read and validate external plugin registrations.', granted: true, required: true },
43
+ { scope: 'external-plugins.write', mode: 'write', description: 'Install, update, and disable validated external plugin registrations.', granted: true, required: true },
44
+ { scope: 'external-plugins.remove', mode: 'write', description: 'Remove one external plugin registration after strong confirmation.', granted: true, required: true },
45
+ ];
46
+ }
47
+
48
+ function capabilities(): AssistantPluginCapability[] {
49
+ return [{
50
+ capabilityId: 'external-plugin-registration-management',
51
+ title: 'External Plugin Registration Management',
52
+ description: 'Preview, install/update, inspect, disable, and remove external provider registrations through the canonical validation authority.',
53
+ scopes: ['external-plugins.read', 'external-plugins.write', 'external-plugins.remove'],
54
+ actions: ['list_registrations', 'get_registration', 'preview_registration', 'install_registration', 'disable_registration', 'remove_registration'],
55
+ }];
56
+ }
57
+
58
+ const registrationProperty = { type: 'object', description: 'ExternalPluginRegistrationInput. Canonical Forge validation rejects invalid identity, transport, lifecycle, permissions, capabilities, and actions.' };
59
+ function actions(): AssistantPluginActionDescriptor[] {
60
+ return [
61
+ {
62
+ actionId: 'list_registrations', title: 'List external plugin registrations', description: 'List canonical installed external plugin registrations.',
63
+ readOnly: true, risk: 'readonly', confirmation: 'none', defaultTimeoutMs: 10_000, cancellable: true, idempotent: true,
64
+ scopes: ['external-plugins.read'], resourceClaims: [{ resource: 'provider-state', mode: 'read' }],
65
+ argumentsSchema: { type: 'object', properties: {}, additionalProperties: false },
66
+ },
67
+ {
68
+ actionId: 'get_registration', title: 'Get external plugin registration', description: 'Read one canonical external plugin registration.',
69
+ readOnly: true, risk: 'readonly', confirmation: 'none', defaultTimeoutMs: 10_000, cancellable: true, idempotent: true,
70
+ scopes: ['external-plugins.read'], resourceClaims: [{ resource: 'provider-state', mode: 'read' }],
71
+ argumentsSchema: { type: 'object', properties: { plugin_id: { type: 'string' } }, required: ['plugin_id'], additionalProperties: false },
72
+ },
73
+ {
74
+ actionId: 'preview_registration', title: 'Preview external plugin registration', description: 'Validate registration input and return its canonical fingerprint/revision plan without mutation.',
75
+ readOnly: true, risk: 'readonly', confirmation: 'none', defaultTimeoutMs: 10_000, cancellable: true, idempotent: true,
76
+ scopes: ['external-plugins.read'], resourceClaims: [{ resource: 'provider-state', mode: 'read' }],
77
+ argumentsSchema: { type: 'object', properties: { registration: registrationProperty }, required: ['registration'], additionalProperties: false },
78
+ },
79
+ {
80
+ actionId: 'install_registration', title: 'Install or update external plugin registration', description: 'Install/update through installExternalPluginRegistration with revision guarding.',
81
+ readOnly: false, risk: 'workspace_write', confirmation: 'authorization', defaultTimeoutMs: 10_000, cancellable: true, idempotent: true,
82
+ scopes: ['external-plugins.write'], resourceClaims: [{ resource: 'provider-state', mode: 'write' }],
83
+ argumentsSchema: { type: 'object', properties: { registration: registrationProperty, expected_revision: { type: 'number' } }, required: ['registration'], additionalProperties: false },
84
+ },
85
+ {
86
+ actionId: 'disable_registration', title: 'Disable external plugin registration', description: 'Disable one installed registration while preserving its audited identity and revision history.',
87
+ readOnly: false, risk: 'workspace_write', confirmation: 'authorization', defaultTimeoutMs: 10_000, cancellable: true, idempotent: true,
88
+ scopes: ['external-plugins.write'], resourceClaims: [{ resource: 'provider-state', mode: 'write' }],
89
+ argumentsSchema: { type: 'object', properties: { plugin_id: { type: 'string' }, expected_revision: { type: 'number' } }, required: ['plugin_id'], additionalProperties: false },
90
+ },
91
+ {
92
+ actionId: 'remove_registration', title: 'Remove external plugin registration', description: 'Explicitly remove one canonical external provider registration.',
93
+ readOnly: false, risk: 'destructive', confirmation: 'strong_confirmation', requiredConfirmationText: 'remove-external-plugin-registration', defaultTimeoutMs: 10_000, cancellable: true, idempotent: true,
94
+ scopes: ['external-plugins.remove'], resourceClaims: [{ resource: 'provider-state', mode: 'write' }],
95
+ argumentsSchema: { type: 'object', properties: { plugin_id: { type: 'string' }, expected_revision: { type: 'number' } }, required: ['plugin_id'], additionalProperties: false },
96
+ },
97
+ ];
98
+ }
99
+
100
+ export function buildPluginManagementManifest(previousRevision = 0, previousUpdatedAt?: string): AssistantPluginManifest {
101
+ return {
102
+ schemaVersion: 1, manifestVersion: 1, revision: Math.max(1, previousRevision || 1), pluginId: PLUGIN_ID,
103
+ provider: 'forge-controller', displayName: 'Forge Plugin Management', pluginVersion: '1.0.0',
104
+ authority: { strategy: 'derived', duplicateStateAllowed: false, sourceOfTruth: ['controller-home:plugins/external/registrations'] },
105
+ enabled: true, lifecycle: { state: 'enabled', reason: 'Canonical external plugin registration authority is available.' },
106
+ health: { state: 'ready', checkedAt: now(), ready: true, probed: true, errors: [], warnings: [] },
107
+ permissions: permissions(), capabilities: capabilities(), actions: actions(), updatedAt: previousUpdatedAt ?? now(),
108
+ };
109
+ }
110
+
111
+ export async function executePluginManagementAction(input: AssistantPluginActionExecutionInput): Promise<Record<string, unknown>> {
112
+ switch (input.actionId) {
113
+ case 'list_registrations': return { registrations: listExternalPluginRegistrations(input.controllerHome) };
114
+ case 'get_registration': {
115
+ const pluginId = requiredString(input.args.plugin_id, 'plugin_id');
116
+ const registration = getExternalPluginRegistration(input.controllerHome, pluginId);
117
+ if (!registration) throw new AssistantPluginError('PLUGIN_EXTERNAL_REGISTRATION_NOT_FOUND', `External plugin registration ${pluginId} was not found.`, { retryable: false });
118
+ return { registration };
119
+ }
120
+ case 'preview_registration': return { preview: previewExternalPluginRegistration(input.controllerHome, registrationInput(input.args.registration)) };
121
+ case 'install_registration': return { registration: installExternalPluginRegistration(input.controllerHome, registrationInput(input.args.registration), { expectedRevision: expectedRevision(input.args.expected_revision) }) };
122
+ case 'disable_registration': return { registration: disableExternalPluginRegistration(input.controllerHome, requiredString(input.args.plugin_id, 'plugin_id'), { expectedRevision: expectedRevision(input.args.expected_revision) }) };
123
+ case 'remove_registration': return { removed: removeExternalPluginRegistration(input.controllerHome, requiredString(input.args.plugin_id, 'plugin_id'), { expectedRevision: expectedRevision(input.args.expected_revision) }) };
124
+ default: throw new AssistantPluginError('PLUGIN_ACTION_NOT_SUPPORTED', `${PLUGIN_ID}/${input.actionId} is not supported.`, { retryable: false });
125
+ }
126
+ }
127
+
128
+ function affectedPluginIdsAfterAction(actionId: string, result: Record<string, unknown>): string[] {
129
+ if (!['install_registration', 'disable_registration', 'remove_registration'].includes(actionId)) return [];
130
+ const value = (result.registration ?? result.removed) as { pluginId?: unknown } | undefined;
131
+ return typeof value?.pluginId === 'string' && value.pluginId.trim() ? [value.pluginId.trim()] : [];
132
+ }
133
+
134
+ export const pluginManagementAdapter = {
135
+ pluginId: PLUGIN_ID,
136
+ scope: 'controller' as const,
137
+ buildManifest: buildPluginManagementManifest,
138
+ executeAction: executePluginManagementAction,
139
+ affectedPluginIdsAfterAction,
140
+ };
@@ -2,7 +2,7 @@ import { createHash } from 'crypto';
2
2
  import { join } from 'path';
3
3
  import type { RepositoryRecord } from '../../cli/repositories/types';
4
4
  import { CONTROLLER_SCOPE_REPO_ID, controllerSystemRoot, ensureControllerHome, repositoryControllerRoot } from '../../cli/repositories/controller-home';
5
- import { existsSync, mkdirSync, readdirSync} from 'fs';
5
+ import { existsSync, mkdirSync, readdirSync, rmSync } from 'fs';
6
6
  import type { ExecutionJob, ResourceClaimSpec } from '../execution/jobs/types';
7
7
  import { appendRuntimeEvent } from '../evidence/event-ledger';
8
8
  import { readJsonFile, sanitizeFileComponent, writeJsonAtomic } from '../shared/json-files';
@@ -18,7 +18,7 @@ import {
18
18
  recordWorkCompletionReceipt,
19
19
  updateWorkContract,
20
20
  } from '../control-plane/facade/work-contract-store';
21
- import type { LocalEffectCompletionReceipt, WorkRisk } from '../control-plane/facade/types';
21
+ import { isTerminalWorkContractStatus, type LocalEffectCompletionReceipt, type RemoteEffectCompletionReceipt, type WorkContract, type WorkRisk } from '../control-plane/facade/types';
22
22
  import type {
23
23
  AssistantPluginAdapter,
24
24
  AssistantPluginActionDescriptor,
@@ -671,6 +671,74 @@ function localEffectTarget(
671
671
  };
672
672
  }
673
673
 
674
+ function remoteEffectWorkForPluginAction(
675
+ controllerHome: string,
676
+ repository: RepositoryRecord,
677
+ action: AssistantPluginActionDescriptor,
678
+ request: AssistantPluginActionRequest,
679
+ receiptId?: string,
680
+ ): WorkContract | undefined {
681
+ const workId = request.workId?.trim();
682
+ if (!workId) return undefined;
683
+ if (action.risk !== 'remote_write') {
684
+ throw new Error(`WORK_PLUGIN_RECEIPT_BINDING_REQUIRES_REMOTE_WRITE: ${action.actionId}`);
685
+ }
686
+ const work = getWorkContract({ controllerHome, repoId: repository.repoId }, workId);
687
+ if (!work) throw new Error(`WORK_PLUGIN_RECEIPT_BINDING_NOT_FOUND: ${workId}`);
688
+ if (work.workKind !== 'remote_effect') {
689
+ throw new Error(`WORK_PLUGIN_RECEIPT_BINDING_KIND_MISMATCH: ${workId} is ${work.workKind}, expected remote_effect`);
690
+ }
691
+ if (work.checks.length > 0) {
692
+ throw new Error(`WORK_PLUGIN_RECEIPT_BINDING_CHECKS_PRESENT: ${workId} declares repository checks and cannot be completed by a plugin receipt alone`);
693
+ }
694
+ if (isTerminalWorkContractStatus(work.status)) {
695
+ if (work.status === 'completed' && receiptId && work.completionReceipt?.receiptId === receiptId) return work;
696
+ throw new Error(`WORK_PLUGIN_RECEIPT_BINDING_TERMINAL: ${workId} is ${work.status}`);
697
+ }
698
+ return work;
699
+ }
700
+
701
+ function bindRemoteEffectReceiptToWork(
702
+ controllerHome: string,
703
+ repository: RepositoryRecord,
704
+ action: AssistantPluginActionDescriptor,
705
+ request: AssistantPluginActionRequest,
706
+ receipt: PluginActionReceipt,
707
+ ): WorkContract | undefined {
708
+ if (!request.workId) return undefined;
709
+ if (receipt.status !== 'succeeded') return remoteEffectWorkForPluginAction(controllerHome, repository, action, request);
710
+ const work = remoteEffectWorkForPluginAction(controllerHome, repository, action, request, receipt.receiptId);
711
+ if (!work) return undefined;
712
+ if (work.status === 'completed' && work.completionReceipt?.receiptId === receipt.receiptId) return work;
713
+ if (!work.evidenceRefs.some((evidence) => evidence.evidenceId === receipt.receiptId)) {
714
+ appendWorkEvidence({ controllerHome, repoId: repository.repoId }, work.workId, {
715
+ evidenceId: receipt.receiptId,
716
+ title: 'typed remote plugin effect completed',
717
+ summary: `${receipt.pluginId}/${receipt.actionId} completed with durable receipt ${receipt.receiptId}.`,
718
+ detailLevel: 'summary',
719
+ });
720
+ }
721
+ const remoteReceipt: RemoteEffectCompletionReceipt = {
722
+ schemaVersion: 1,
723
+ receiptId: receipt.receiptId,
724
+ source: 'remote_effect',
725
+ workId: work.workId,
726
+ pluginId: receipt.pluginId,
727
+ actionId: receipt.actionId,
728
+ requestId: receipt.requestId,
729
+ semanticKey: receipt.semanticKey,
730
+ resultDigest: createHash('sha256').update(JSON.stringify(receipt.result ?? null)).digest('hex'),
731
+ recordedAt: new Date().toISOString(),
732
+ };
733
+ return recordWorkCompletionReceipt(
734
+ { controllerHome, repoId: repository.repoId },
735
+ work.workId,
736
+ remoteReceipt,
737
+ 'completed_remote',
738
+ 'remote_effect',
739
+ );
740
+ }
741
+
674
742
  function compatibilityJobFromReceipt(receipt: PluginActionReceipt, checkoutId: string): ExecutionJob {
675
743
  return {
676
744
  schemaVersion: 1,
@@ -741,6 +809,10 @@ export async function submitAssistantPluginAction(
741
809
  if (!existing) {
742
810
  throw new Error(`PLUGIN_RECEIPT_LOST: ${request.requestId}`);
743
811
  }
812
+ if (request.workId && existing.workId !== request.workId) {
813
+ throw new Error(`WORK_PLUGIN_RECEIPT_BINDING_CONFLICT: ${request.requestId} already belongs to Work ${existing.workId ?? 'none'}`);
814
+ }
815
+ if (request.workId) bindRemoteEffectReceiptToWork(controllerHome, repository, action, request, existing);
744
816
  return {
745
817
  manifest,
746
818
  action,
@@ -754,6 +826,7 @@ export async function submitAssistantPluginAction(
754
826
 
755
827
  const createdAt = new Date().toISOString();
756
828
  const receiptId = `PLG-${Date.now()}-${createHash('sha256').update(request.requestId).digest('hex').slice(0, 8)}`;
829
+ const boundRemoteWork = remoteEffectWorkForPluginAction(controllerHome, repository, action, request);
757
830
  const requiresLocalEffectWork = localSystemActionRequiresWork(
758
831
  repository,
759
832
  request.pluginId,
@@ -833,6 +906,17 @@ export async function submitAssistantPluginAction(
833
906
  : undefined,
834
907
  });
835
908
  let resultWithLineage = result;
909
+ if (boundRemoteWork) {
910
+ resultWithLineage = {
911
+ ...result,
912
+ work: {
913
+ workId: boundRemoteWork.workId,
914
+ workKind: 'remote_effect',
915
+ completionOutcome: 'completed_remote',
916
+ status: 'completed',
917
+ },
918
+ };
919
+ }
836
920
  if (acceptedWork) {
837
921
  const recordedAt = new Date().toISOString();
838
922
  const target = localEffectTarget(normalizedArgs, result);
@@ -877,7 +961,7 @@ export async function submitAssistantPluginAction(
877
961
  semanticKey: key,
878
962
  status: 'succeeded',
879
963
  createdAt,
880
- ...(acceptedWork ? { workId: acceptedWork.workId } : {}),
964
+ ...(acceptedWork ? { workId: acceptedWork.workId } : boundRemoteWork ? { workId: boundRemoteWork.workId } : {}),
881
965
  origin: request.origin,
882
966
  result: resultWithLineage,
883
967
  };
@@ -889,6 +973,7 @@ export async function submitAssistantPluginAction(
889
973
  semanticKey: key,
890
974
  createdAt,
891
975
  } satisfies PluginActionRequestIndex);
976
+ if (boundRemoteWork) bindRemoteEffectReceiptToWork(controllerHome, repository, action, request, receipt);
892
977
  const nextManifest = getAssistantPluginManifest(controllerHome, repository, request.pluginId);
893
978
  return {
894
979
  manifest: nextManifest,
@@ -897,11 +982,18 @@ export async function submitAssistantPluginAction(
897
982
  deduplicated: false,
898
983
  result: resultWithLineage,
899
984
  receipt,
900
- workId: acceptedWork?.workId,
985
+ workId: acceptedWork?.workId ?? boundRemoteWork?.workId,
901
986
  };
902
987
  } catch (error) {
903
988
  const message = error instanceof Error ? error.message : String(error);
904
989
  const code = /^([A-Z][A-Z0-9_]+)/.exec(message)?.[1] ?? 'PLUGIN_ACTION_FAILED';
990
+ if (boundRemoteWork) {
991
+ appendWorkEvidence({ controllerHome, repoId: repository.repoId }, boundRemoteWork.workId, {
992
+ title: 'typed remote plugin effect failed',
993
+ summary: `${request.pluginId}/${request.actionId}: ${message}`.slice(0, 1_000),
994
+ detailLevel: 'summary',
995
+ });
996
+ }
905
997
  if (acceptedWork) {
906
998
  const current = getWorkContract({ controllerHome, repoId: repository.repoId }, acceptedWork.workId);
907
999
  updateWorkContract({ controllerHome, repoId: repository.repoId }, acceptedWork.workId, {
@@ -926,7 +1018,7 @@ export async function submitAssistantPluginAction(
926
1018
  semanticKey: key,
927
1019
  status: 'failed',
928
1020
  createdAt,
929
- ...(acceptedWork ? { workId: acceptedWork.workId } : {}),
1021
+ ...(acceptedWork ? { workId: acceptedWork.workId } : boundRemoteWork ? { workId: boundRemoteWork.workId } : {}),
930
1022
  origin: request.origin,
931
1023
  error: { code, message },
932
1024
  };
@@ -963,6 +1055,16 @@ export async function executeAssistantPluginAction(
963
1055
  args: normalizedArgs,
964
1056
  providerIdentityPrevalidated: manifestLookup.providerIdentityPrevalidated,
965
1057
  });
1058
+ for (const affectedPluginId of adapter.affectedPluginIdsAfterAction?.(input.actionId, result) ?? []) {
1059
+ if (!affectedPluginId || affectedPluginId === input.pluginId) continue;
1060
+ invalidateAssistantPluginManifestCache(input.controllerHome, repository.repoId, affectedPluginId);
1061
+ const affectedAdapter = resolvePluginAdapter(input.controllerHome, affectedPluginId);
1062
+ if (affectedAdapter && adapterMatchesRepository(affectedAdapter, repository)) {
1063
+ syncAssistantPluginManifest(input.controllerHome, repository, affectedPluginId);
1064
+ } else {
1065
+ rmSync(manifestPath(input.controllerHome, repository.repoId, affectedPluginId), { force: true });
1066
+ }
1067
+ }
966
1068
  const refreshManifest = adapter.shouldRefreshManifestAfterAction?.(input.actionId) ?? true;
967
1069
  const nextManifest = refreshManifest
968
1070
  ? syncAssistantPluginManifest(input.controllerHome, repository, input.pluginId).manifest
@@ -100,6 +100,8 @@ export interface AssistantPluginActionRequest {
100
100
  pluginId: string;
101
101
  actionId: string;
102
102
  requestId: string;
103
+ /** Optional existing external-effect Work that owns this typed action receipt. */
104
+ workId?: string;
103
105
  args: Record<string, unknown>;
104
106
  timeoutMs?: number;
105
107
  signal?: AbortSignal;
@@ -135,4 +137,6 @@ export interface AssistantPluginAdapter {
135
137
  executeAction(input: AssistantPluginActionExecutionInput): Promise<Record<string, unknown>>;
136
138
  /** Defaults to true. External providers can defer routine health refresh until the normal manifest cache expires. */
137
139
  shouldRefreshManifestAfterAction?(actionId: string): boolean;
140
+ /** Other plugin manifests whose canonical authority changed as a side effect of this action. */
141
+ affectedPluginIdsAfterAction?(actionId: string, result: Record<string, unknown>): string[];
138
142
  }
@@ -131,16 +131,6 @@ export const RECOVERY_ACTIONS = {
131
131
  localOnly: true,
132
132
  boundedTo: ['google_workspace_auth_handoff'],
133
133
  },
134
- browserDomainAccessPreview: {
135
- id: 'recovery.browser_domain_access_preview',
136
- title: 'Preview browser domain grant',
137
- description: 'Preview a domain-key browser grant instead of retrying arbitrary URLs.',
138
- class: 'browser_domain_grant_required',
139
- risk: 'readonly',
140
- confirmation: 'none',
141
- localOnly: true,
142
- boundedTo: ['browser_allowed_domains'],
143
- },
144
134
  externalFilesystemGrantPreview: {
145
135
  id: 'recovery.external_filesystem_grant_preview',
146
136
  title: 'Preview external filesystem grant',
@@ -52,13 +52,6 @@ const LOCAL_JOBS_LEGACY_ACTIVE_PATTERNS = [
52
52
  /local jobs?.*finish before runtime storage/i,
53
53
  ];
54
54
 
55
- const BROWSER_GRANT_PATTERNS = [
56
- /web_target_not_allowed/i,
57
- /browser.*domain.*not allowed/i,
58
- /allowed_domains/i,
59
- /domain access.*required/i,
60
- /browser domain grant/i,
61
- ];
62
55
 
63
56
  const EXTERNAL_FILESYSTEM_GRANT_PATTERNS = [
64
57
  /external filesystem/i,
@@ -125,7 +118,6 @@ export function classifyFailure(message: string | undefined): RecoveryClass {
125
118
  if (LOCAL_JOBS_UNREADABLE_PATTERNS.some((pattern) => pattern.test(text))) return 'local_jobs_unreadable';
126
119
  if (RUNTIME_STORAGE_PATTERNS.some((pattern) => pattern.test(text))) return 'runtime_storage_not_ready';
127
120
  if (AUTH_PATTERNS.some((pattern) => pattern.test(text))) return 'auth_required';
128
- if (BROWSER_GRANT_PATTERNS.some((pattern) => pattern.test(text))) return 'browser_domain_grant_required';
129
121
  if (EXTERNAL_FILESYSTEM_GRANT_PATTERNS.some((pattern) => pattern.test(text))) return 'external_filesystem_grant_required';
130
122
  if (DIRTY_WORKTREE_CONFLICT_PATTERNS.some((pattern) => pattern.test(text))) return 'dirty_worktree_conflict';
131
123
  if (USER_ACTION_PATTERNS.some((pattern) => pattern.test(text))) return 'user_action_required';
@@ -144,7 +136,6 @@ export function dominantRecoveryClass(classes: readonly RecoveryClass[]): Recove
144
136
  'local_jobs_legacy_active',
145
137
  'local_jobs_unreadable',
146
138
  'runtime_storage_not_ready',
147
- 'browser_domain_grant_required',
148
139
  'external_filesystem_grant_required',
149
140
  'local_jobs_reconciliation_required',
150
141
  'maintenance_executor_required',
@@ -6,7 +6,11 @@ import { ensureRepositoryRuntimeStorage, type RepositoryRuntimeStorageReport } f
6
6
  import type { RepositoryRecord } from '../../cli/repositories/types';
7
7
  import { rebuildRepositoryProjection } from '../projections/materialized-view';
8
8
  import { getWorkContract, readWorkContractStore, transitionWorkContractPhase } from '../control-plane/facade/work-contract-store';
9
- import { isTerminalWorkContractStatus } from '../control-plane/facade/types';
9
+ import { getControllerSession } from '../control-plane/facade/controller-session-store';
10
+ import { listPlanContracts } from '../control-plane/facade/plan-contract-store';
11
+ import { readRequirement } from '../control-plane/persistence/requirement-store';
12
+ import { isTerminalWorkContractStatus, type WorkContract } from '../control-plane/facade/types';
13
+ import { listSchedules } from '../workflow/schedules/store';
10
14
  import {
11
15
  collectRuntimeProcesses,
12
16
  removeRuntimeTempEntry,
@@ -397,6 +401,29 @@ function normalizedOptions(options: RuntimeMaintenanceOptions = {}): Required<Ru
397
401
  };
398
402
  }
399
403
 
404
+ function activeWorkAuthorityRefs(
405
+ repository: RuntimeMaintenanceRepository,
406
+ controllerHome: string,
407
+ contract: WorkContract,
408
+ ): string[] {
409
+ const refs: string[] = [];
410
+ if (getControllerSession({ controllerHome, repoId: repository.repoId }, contract.workId)) refs.push('controller_session');
411
+ const activePlans = listPlanContracts({ controllerHome, repoId: repository.repoId, status: 'active', limit: 100 });
412
+ const plan = activePlans.find((candidate) => candidate.planId === contract.planId || candidate.steps.some((step) => step.workId === contract.workId));
413
+ if (plan) refs.push(`plan:${plan.planId}`);
414
+ if (contract.requirementId) {
415
+ const requirement = readRequirement({ controllerHome }, contract.requirementId)?.value;
416
+ if (requirement && !['done', 'cancelled'].includes(requirement.state)) refs.push(`requirement:${requirement.requirementId}:${requirement.state}`);
417
+ }
418
+ const boundSchedule = listSchedules(controllerHome, repository.repoId).find((schedule) => {
419
+ if (!schedule.enabled) return false;
420
+ const args = schedule.action.arguments as Record<string, unknown> | undefined;
421
+ return args?.work_id === contract.workId;
422
+ });
423
+ if (boundSchedule) refs.push(`schedule:${boundSchedule.scheduleId}`);
424
+ return refs;
425
+ }
426
+
400
427
  function scanStaleWorkContractCandidates(
401
428
  repository: RuntimeMaintenanceRepository,
402
429
  controllerHome: string,
@@ -413,16 +440,21 @@ function scanStaleWorkContractCandidates(
413
440
  .filter(({ ageMinutes }) => ageMinutes >= options.minAgeMinutes)
414
441
  .sort((left, right) => right.ageMinutes - left.ageMinutes)
415
442
  .slice(0, options.maxCandidates)
416
- .map(({ contract, ageMinutes }) => ({
417
- kind: 'stale_work_contract' as const,
418
- id: contract.workId,
419
- status: contract.status,
420
- safe: true,
421
- reason: 'Nonterminal WorkContract exceeded the explicit maintenance age threshold; cancellation preserves durable evidence and does not delete source or remote state.',
422
- ageMinutes,
423
- suggestedAction: 'full_maintenance_pass' as const,
424
- ownershipStatus: 'explicit' as const,
425
- }));
443
+ .map(({ contract, ageMinutes }) => {
444
+ const authorityRefs = activeWorkAuthorityRefs(repository, controllerHome, contract);
445
+ return {
446
+ kind: 'stale_work_contract' as const,
447
+ id: contract.workId,
448
+ status: contract.status,
449
+ safe: authorityRefs.length === 0,
450
+ reason: authorityRefs.length > 0
451
+ ? `Nonterminal WorkContract is old but still has active lifecycle authority (${authorityRefs.join(', ')}); maintenance must not cancel it from age alone.`
452
+ : 'Nonterminal WorkContract exceeded the explicit maintenance age threshold and has no active Plan, Requirement, Schedule, or Controller authority; explicit full maintenance may cancel it while retaining evidence.',
453
+ ageMinutes,
454
+ suggestedAction: 'full_maintenance_pass' as const,
455
+ ownershipStatus: 'explicit' as const,
456
+ };
457
+ });
426
458
  }
427
459
 
428
460
  function scanStaleEditSessionCandidates(
@@ -61,7 +61,6 @@ function failingJobClass(input: CapabilityRecoveryInput): RecoveryClass {
61
61
  function suggestedActionsForClass(recoveryClass: RecoveryClass): RecoveryActionDescriptor[] {
62
62
  if (recoveryClass === 'platform_blocked' || recoveryClass === 'dirty_worktree_conflict') return [RECOVERY_ACTIONS.createPatchHandoff];
63
63
  if (recoveryClass === 'auth_required') return [RECOVERY_ACTIONS.workspaceAuthLoginPrepare];
64
- if (recoveryClass === 'browser_domain_grant_required') return [RECOVERY_ACTIONS.browserDomainAccessPreview];
65
64
  if (recoveryClass === 'external_filesystem_grant_required') return [RECOVERY_ACTIONS.externalFilesystemGrantPreview];
66
65
  if (recoveryClass === 'agent_runtime_failure') return [RECOVERY_ACTIONS.reconcileJobs];
67
66
  if (recoveryClass === 'source_defect_suspected' || recoveryClass === 'external_lifecycle_required') return [];
@@ -9,7 +9,6 @@ export type RecoveryClass =
9
9
  | 'stale_runtime_state'
10
10
  | 'external_lifecycle_required'
11
11
  | 'runtime_storage_not_ready'
12
- | 'browser_domain_grant_required'
13
12
  | 'external_filesystem_grant_required'
14
13
  | 'local_jobs_legacy_active'
15
14
  | 'local_jobs_unreadable'