@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
@@ -1,4 +1,6 @@
1
1
  import { randomUUID, timingSafeEqual } from 'crypto';
2
+ import { existsSync, watch } from 'fs';
3
+ import { dirname } from 'path';
2
4
  import express, { type Request, type Response, type NextFunction } from 'express';
3
5
  import { tokenHandler } from '@modelcontextprotocol/sdk/server/auth/handlers/token.js';
4
6
  import { revocationHandler } from '@modelcontextprotocol/sdk/server/auth/handlers/revoke.js';
@@ -37,7 +39,7 @@ import { readForgeRuntimeStatus } from '../../../runtime/control-plane/runtime-s
37
39
  import { runtimeIdentitySnapshot } from '../../../runtime/gateway/mcp/runtime-tools';
38
40
  import { projectionBlocksReadiness, readRepositoryProjectionSnapshot } from '../../../runtime/projections/materialized-view';
39
41
  import { readRuntimeGeneration } from '../../../runtime/control-plane/runtime-generation';
40
- import { readRuntimeStatusSnapshot } from '../../../runtime/root/status';
42
+ import { readRuntimeStatusSnapshot, runtimeStatusPath } from '../../../runtime/root/status';
41
43
  import { getRepository, listRepositories } from '../../repositories/registry';
42
44
  import { buildControllerTaskLedgerProjection } from '../../controller/task-ledger';
43
45
  import { legacyIssueAuthorityRetired } from '../../controller/legacy-issue-cutover';
@@ -95,6 +97,24 @@ function isInitializeRequest(body: unknown): boolean {
95
97
  return typeof body === 'object' && body !== null && (body as Record<string, unknown>).method === 'initialize';
96
98
  }
97
99
 
100
+ function isServerDiscoverRequest(body: unknown): boolean {
101
+ return typeof body === 'object' && body !== null && (body as Record<string, unknown>).method === 'server/discover';
102
+ }
103
+
104
+ function sendLegacyServerDiscoverUnsupported(res: Response, body: unknown): void {
105
+ const record = typeof body === 'object' && body !== null ? body as Record<string, unknown> : {};
106
+ const id = typeof record.id === 'string' || typeof record.id === 'number' || record.id === null ? record.id : null;
107
+ res.setHeader('Cache-Control', 'no-store');
108
+ res.status(404).json({
109
+ jsonrpc: '2.0',
110
+ id,
111
+ error: {
112
+ code: -32601,
113
+ message: 'Method not found',
114
+ },
115
+ });
116
+ }
117
+
98
118
  function initializeClientIdentity(req: Request, body: unknown, route: McpSessionRoute, principalId: string): string {
99
119
  const params = typeof body === 'object' && body !== null
100
120
  ? (body as { params?: { clientInfo?: { name?: unknown; version?: unknown } } }).params
@@ -676,6 +696,15 @@ async function handleMcpPost(
676
696
  return;
677
697
  }
678
698
  const sessionId = req.headers['mcp-session-id'] as string | undefined;
699
+ // MCP 2026-07-28 clients probe legacy servers with server/discover before
700
+ // falling back to the initialize-era protocol. Forge still serves the
701
+ // legacy stateful transport, so reject the unsupported modern RPC with the
702
+ // protocol-prescribed Method not found / HTTP 404 response instead of
703
+ // misclassifying it as a missing-session HTTP 400.
704
+ if (isServerDiscoverRequest(body)) {
705
+ sendLegacyServerDiscoverUnsupported(res, body);
706
+ return;
707
+ }
679
708
  if (isInitializeRequest(body)) {
680
709
  if (sessionId) {
681
710
  res.setHeader('Mcp-Session-Reset', 'reinitialized');
@@ -713,6 +742,7 @@ async function handleMcpPost(
713
742
  principalId,
714
743
  });
715
744
  const runtimeSchema = await resolveRuntimeSchema?.(sessionContext);
745
+ let server: ReturnType<typeof createForgeMcpServerFromContext> | undefined;
716
746
  transport = new StreamableHTTPServerTransport({
717
747
  sessionIdGenerator: () => randomUUID(),
718
748
  onsessioninitialized: (newSessionId: string): void => {
@@ -725,6 +755,7 @@ async function handleMcpPost(
725
755
  clientIdentity,
726
756
  toolSurfaceFingerprint: runtimeSchema?.fingerprint ?? currentToolSurfaceFingerprint(),
727
757
  toolNames: runtimeSchema?.toolNames,
758
+ notifyToolListChanged: async () => { await server?.sendToolListChanged(); },
728
759
  });
729
760
  initializedSessionId = newSessionId;
730
761
  },
@@ -732,7 +763,7 @@ async function handleMcpPost(
732
763
  transport.onclose = () => {
733
764
  if (transport?.sessionId) registry.detach(transport.sessionId);
734
765
  };
735
- const server = createForgeMcpServerFromContext(sessionContext, runtimeSchema);
766
+ server = createForgeMcpServerFromContext(sessionContext, runtimeSchema);
736
767
  await server.connect(transport);
737
768
  await transport.handleRequest(req, res, body);
738
769
  } finally {
@@ -890,6 +921,23 @@ export async function startMcpHttp(opts: McpHttpOptions): Promise<void> {
890
921
  const currentRuntimeToolSurfaceFingerprint = () => runtimeControllerHome
891
922
  ? readRuntimeStatusSnapshot(runtimeControllerHome)?.toolSurfaceFingerprint
892
923
  : undefined;
924
+ let observedRuntimeToolSurfaceFingerprint = currentRuntimeToolSurfaceFingerprint();
925
+ let toolSurfaceNotificationTimer: ReturnType<typeof setTimeout> | undefined;
926
+ const runtimeStatusDirectory = runtimeControllerHome ? dirname(runtimeStatusPath(runtimeControllerHome)) : undefined;
927
+ const runtimeStatusWatcher = runtimeStatusDirectory && existsSync(runtimeStatusDirectory)
928
+ ? watch(runtimeStatusDirectory, { persistent: false }, (_event, filename) => {
929
+ if (String(filename ?? '') !== 'status.json') return;
930
+ if (toolSurfaceNotificationTimer) clearTimeout(toolSurfaceNotificationTimer);
931
+ toolSurfaceNotificationTimer = setTimeout(() => {
932
+ const currentFingerprint = currentRuntimeToolSurfaceFingerprint();
933
+ if (!currentFingerprint || currentFingerprint === observedRuntimeToolSurfaceFingerprint) return;
934
+ observedRuntimeToolSurfaceFingerprint = currentFingerprint;
935
+ void sessionRegistry.notifyToolListChanged(currentFingerprint);
936
+ }, 25);
937
+ toolSurfaceNotificationTimer.unref();
938
+ })
939
+ : undefined;
940
+ runtimeStatusWatcher?.on('error', () => undefined);
893
941
  const resolveRuntimeSchema = runtimeControllerHome
894
942
  ? async (context: McpToolContext): Promise<CanonicalRuntimeToolSchema | undefined> => {
895
943
  if (!('controllerHome' in context)) return undefined;
@@ -1233,6 +1281,8 @@ export async function startMcpHttp(opts: McpHttpOptions): Promise<void> {
1233
1281
 
1234
1282
  httpServer.on('close', () => {
1235
1283
  clearInterval(cleanupTimer);
1284
+ if (toolSurfaceNotificationTimer) clearTimeout(toolSurfaceNotificationTimer);
1285
+ runtimeStatusWatcher?.close();
1236
1286
  void sessionRegistry.closeAll('shutdown');
1237
1287
  });
1238
1288
 
@@ -31,6 +31,9 @@ export interface ManagedMcpSession<
31
31
  toolSurfaceFingerprint?: string;
32
32
  /** Exact tool names returned by the Canonical Runtime at initialize time. */
33
33
  toolNames?: string[];
34
+ /** Standard MCP tools/list_changed notification bound to this session's connected Server. */
35
+ notifyToolListChanged?: () => Promise<void> | void;
36
+ lastNotifiedToolSurfaceFingerprint?: string;
34
37
  createdAt: number;
35
38
  lastActivityAt: number;
36
39
  streamOpenedAt?: number;
@@ -88,6 +91,7 @@ interface RegisterMcpSession<TTransport extends ClosableMcpTransport, TContext>
88
91
  clientIdentity: string;
89
92
  toolSurfaceFingerprint?: string;
90
93
  toolNames?: string[];
94
+ notifyToolListChanged?: () => Promise<void> | void;
91
95
  initialPost?: boolean;
92
96
  }
93
97
 
@@ -206,6 +210,26 @@ export class McpSessionRegistry<
206
210
  if (session) session.lastActivityAt = this.now();
207
211
  }
208
212
 
213
+ async notifyToolListChanged(currentFingerprint: string | undefined): Promise<number> {
214
+ if (!currentFingerprint) return 0;
215
+ let notified = 0;
216
+ await Promise.all(Array.from(this.sessions.values()).map(async (session) => {
217
+ if (!session.toolSurfaceFingerprint
218
+ || session.toolSurfaceFingerprint === currentFingerprint
219
+ || session.lastNotifiedToolSurfaceFingerprint === currentFingerprint
220
+ || !session.notifyToolListChanged) return;
221
+ try {
222
+ await session.notifyToolListChanged();
223
+ session.lastNotifiedToolSurfaceFingerprint = currentFingerprint;
224
+ notified += 1;
225
+ } catch {
226
+ // Fingerprint fencing remains authoritative. A broken notification
227
+ // stream must not mark a stale session as refreshed or close active work.
228
+ }
229
+ }));
230
+ return notified;
231
+ }
232
+
209
233
  beginPost(sessionId: string): ManagedMcpSession<TTransport, TContext> | undefined {
210
234
  const session = this.sessions.get(sessionId);
211
235
  if (!session) return undefined;
@@ -80,6 +80,77 @@ function shellSegments(command: string): string[] {
80
80
  return segments;
81
81
  }
82
82
 
83
+ function shellWordsPreservingQuotes(segment: string): string[] {
84
+ const words: string[] = [];
85
+ let current = '';
86
+ let quote: "'" | '"' | undefined;
87
+ let escaped = false;
88
+ const flush = () => {
89
+ if (current) words.push(current);
90
+ current = '';
91
+ };
92
+ for (let index = 0; index < segment.length; index += 1) {
93
+ const char = segment[index]!;
94
+ if (escaped) { current += char; escaped = false; continue; }
95
+ if (char === '\\' && quote !== "'") { escaped = true; continue; }
96
+ if (quote) {
97
+ if (char === quote) quote = undefined;
98
+ else current += char;
99
+ continue;
100
+ }
101
+ if (char === "'" || char === '"') { quote = char; continue; }
102
+ if (/\s/.test(char)) { flush(); continue; }
103
+ current += char;
104
+ }
105
+ flush();
106
+ return words;
107
+ }
108
+
109
+ const SAFE_SQLITE_READ_OPTIONS = new Set([
110
+ '-safe', '--safe', '-readonly', '--readonly', '-batch', '--batch',
111
+ '-header', '--header', '-noheader', '--noheader', '-json', '--json',
112
+ '-csv', '--csv', '-list', '--list', '-line', '--line', '-column', '--column',
113
+ '-tabs', '--tabs', '-table', '--table', '-box', '--box', '-markdown', '--markdown',
114
+ '-quote', '--quote', '-ascii', '--ascii',
115
+ ]);
116
+
117
+ function isSingleReadOnlySql(sql: string): boolean {
118
+ const normalized = sql.trim();
119
+ if (!normalized || normalized.startsWith('.')) return false;
120
+ const withoutTrailingSemicolon = normalized.endsWith(';') ? normalized.slice(0, -1).trimEnd() : normalized;
121
+ if (withoutTrailingSemicolon.includes(';')) return false;
122
+ if (!/^(?:select\b|with\b)/i.test(withoutTrailingSemicolon)) return false;
123
+ return !/\b(?:insert|update|delete|replace|create|drop|alter|vacuum|attach|detach|reindex|analyze|pragma|begin|commit|rollback|savepoint|release)\b/i.test(withoutTrailingSemicolon);
124
+ }
125
+
126
+ function isSafeReadOnlySqliteWords(rawWords: string[]): boolean {
127
+ const words = rawWords.filter(Boolean);
128
+ const program = words[0]?.split(/[\\/]/).at(-1)?.toLowerCase();
129
+ if (program !== 'sqlite3') return false;
130
+ const args = words.slice(1);
131
+ if (args.some((word) => /^(?:--?nonce)(?:=|$)/i.test(word) || /^(?:--?unsafe-testing)(?:=|$)/i.test(word))) return false;
132
+ let safe = false;
133
+ let readonly = false;
134
+ let index = 0;
135
+ for (; index < args.length; index += 1) {
136
+ const arg = args[index]!;
137
+ if (!arg.startsWith('-')) break;
138
+ if (!SAFE_SQLITE_READ_OPTIONS.has(arg.toLowerCase())) return false;
139
+ if (arg === '-safe' || arg === '--safe') safe = true;
140
+ if (arg === '-readonly' || arg === '--readonly') readonly = true;
141
+ }
142
+ if (!safe || !readonly) return false;
143
+ const database = args[index];
144
+ const sql = args[index + 1];
145
+ if (!database || !sql || args.length !== index + 2) return false;
146
+ return isSingleReadOnlySql(sql);
147
+ }
148
+
149
+ function isSafeReadOnlySqliteSegment(segment: string): boolean {
150
+ const stripped = segment.replace(/^\s*(?:[A-Za-z_][A-Za-z0-9_]*=[^\s]+\s+)*/, '');
151
+ return isSafeReadOnlySqliteWords(shellWordsPreservingQuotes(stripped));
152
+ }
153
+
83
154
  function isSedInPlaceSegment(segment: string): boolean {
84
155
  const words = firstWords(segment);
85
156
  if (words[0]?.toLowerCase() !== 'sed') return false;
@@ -177,11 +248,18 @@ function readOnlyGitSegment(words: string[], _segment: string): boolean {
177
248
  return isReadOnlyGitCommand(words);
178
249
  }
179
250
 
251
+ function isReadOnlyCodegraphCommand(words: string[]): boolean {
252
+ const program = words[0]?.split(/[\\/]/).at(-1)?.toLowerCase();
253
+ return program === 'codegraph' && words[1]?.toLowerCase() === 'status';
254
+ }
255
+
180
256
  function isReadOnlySegment(segment: string): boolean {
181
257
  const words = firstWords(segment);
182
258
  const program = words[0]?.toLowerCase();
183
259
  if (!program) return false;
184
260
  if (program === 'git') return readOnlyGitSegment(words, segment);
261
+ if (isReadOnlyCodegraphCommand(words)) return true;
262
+ if (program === 'sqlite3') return isSafeReadOnlySqliteSegment(segment);
185
263
  if (program === 'find') return !/(?:-delete|-exec|-execdir|-ok|-okdir)\b/.test(segment);
186
264
  if (program === 'sed') return !isSedInPlaceSegment(segment);
187
265
  if (program === 'gh') {
@@ -193,6 +271,26 @@ function isReadOnlySegment(segment: string): boolean {
193
271
  /** Package scripts that are local validation (not install/publish/mutate deps). */
194
272
  const SAFE_PACKAGE_SCRIPT = /^(?:test|check|lint|typecheck|format:check|tsc)(?::|$)/i;
195
273
 
274
+ function isReadOnlyLoopbackCurl(words: string[]): boolean {
275
+ const program = words[0]?.split(/[\\/]/).at(-1)?.toLowerCase();
276
+ if (program !== 'curl') return false;
277
+ const args = words.slice(1);
278
+ const writeFlags = new Set(['-d', '--data', '--data-raw', '--data-binary', '-f', '--form', '-t', '--upload-file', '-o', '--output', '-O', '--remote-name']);
279
+ if (args.some((arg) => writeFlags.has(arg))) return false;
280
+ for (let index = 0; index < args.length; index += 1) {
281
+ if (args[index] === '-X' || args[index] === '--request') {
282
+ if (!/^(?:GET|HEAD)$/i.test(args[index + 1] ?? '')) return false;
283
+ index += 1;
284
+ }
285
+ }
286
+ const urls = args.filter((arg) => /^https?:\/\//i.test(arg));
287
+ if (urls.length === 0) return false;
288
+ return urls.every((url) => {
289
+ try { return ['127.0.0.1', 'localhost', '::1'].includes(new URL(url).hostname.toLowerCase()); }
290
+ catch { return false; }
291
+ });
292
+ }
293
+
196
294
  function isSafePackageRunner(program: string, words: string[]): boolean {
197
295
  if (!['bun', 'npm', 'pnpm', 'yarn', 'node'].includes(program)) return false;
198
296
  // bun test <path>, npm test, etc.
@@ -440,6 +538,15 @@ function classifyArgvCommand(
440
538
  if (program === 'git' && isReadOnlyGitCommand(argv)) {
441
539
  return { risk: 'readonly', confirmation: 'none', reasons: ['the argv command is a recognized repository-local read operation'] };
442
540
  }
541
+ if (isReadOnlyCodegraphCommand(argv)) {
542
+ return { risk: 'readonly', confirmation: 'none', reasons: ['CodeGraph status only observes the local semantic index'] };
543
+ }
544
+ if (program === 'curl' && isReadOnlyLoopbackCurl(argv)) {
545
+ return { risk: 'readonly', confirmation: 'none', reasons: ['curl performs a read-only loopback HTTP observation without writing response data to disk'] };
546
+ }
547
+ if (program === 'sqlite3' && isSafeReadOnlySqliteWords(argv)) {
548
+ return { risk: 'readonly', confirmation: 'none', reasons: ['sqlite3 is constrained by safe mode, readonly database access, and one read-only SQL statement'] };
549
+ }
443
550
  // Explicit shell wrappers are common in generated diagnostics. Re-classify
444
551
  // the fixed inner command instead of treating `sh -c` / `bash -lc` wrappers
445
552
  // as workspace mutations. Unsafe shell constructs are still rejected by the
@@ -484,6 +591,12 @@ function classifyArgvCommand(
484
591
  || (['npm', 'bun', 'pnpm', 'yarn'].includes(program) && ['install', 'add', 'remove', 'update', 'run'].includes(subcommand ?? ''))) {
485
592
  return { risk: 'workspace_write', confirmation: 'authorization', reasons: ['writes repository files'] };
486
593
  }
594
+ // Package-manager `version` is a mutation command (for example
595
+ // `npm version 1.5.1 --no-git-tag-version` updates package metadata). Keep
596
+ // flag-style queries such as `npm --version` on the readonly path below.
597
+ if (['npm', 'pnpm', 'yarn'].includes(program) && subcommand === 'version') {
598
+ return { risk: 'workspace_write', confirmation: 'authorization', reasons: ['package-manager version command may update package metadata'] };
599
+ }
487
600
  // Lightweight project script info queries stay readonly when they only request help/version.
488
601
  if (['bun', 'node', 'npm', 'pnpm', 'yarn', 'python', 'python3', 'cargo', 'go', 'swift'].includes(program)
489
602
  && argv.slice(1).some((word) => word === '--help' || word === '-h' || word === '--version' || word === '-V' || word === 'version' || word === 'help')) {
@@ -7,6 +7,7 @@ import {
7
7
  getEditSession,
8
8
  type EditOperation,
9
9
  type EditSession,
10
+ type EditSessionBinding,
10
11
  type EditSessionPatchError,
11
12
  } from '../editing/edit-session';
12
13
  import { getMcpPolicy } from '../mcp/policy';
@@ -345,6 +346,7 @@ export function applySafePatch(repository: RepositoryRecord, input: {
345
346
  continueOnError?: unknown;
346
347
  refreshFingerprints?: unknown;
347
348
  recoverStaleSession?: unknown;
349
+ binding?: EditSessionBinding;
348
350
  }): SafePatchApplyResult {
349
351
  const operations = normalizedSafePatchOperations(input.operations);
350
352
  if (operations.length === 0) throw new Error('SAFE_PATCH_OPERATIONS_REQUIRED: provide at least one operation');
@@ -356,7 +358,7 @@ export function applySafePatch(repository: RepositoryRecord, input: {
356
358
  const allowedPaths = Array.isArray(input.allowedPaths) ? input.allowedPaths.map((item) => String(item).trim()).filter(Boolean) : undefined;
357
359
  let session = sessionId
358
360
  ? getEditSession(repository.canonicalRoot, sessionId)
359
- : beginEditSession(repository.canonicalRoot, { purpose: String(input.purpose ?? 'Safe repository patch').trim() || 'Safe repository patch', allowedPaths });
361
+ : beginEditSession(repository.canonicalRoot, { purpose: String(input.purpose ?? 'Safe repository patch').trim() || 'Safe repository patch', allowedPaths, binding: input.binding });
360
362
  const createdSession = !sessionId;
361
363
  let recoveredSession: SafePatchRecoveredSession | undefined;
362
364
  const appliedChunks: SafePatchAppliedChunk[] = [];
@@ -376,17 +378,17 @@ export function applySafePatch(repository: RepositoryRecord, input: {
376
378
  continue;
377
379
  }
378
380
  try {
379
- session = applyEditOperations(repository.canonicalRoot, policy, session.sessionId, preflight.operations.map(stripInternalIndex), { expectedRevision, maxBatchOperations: MAX_SAFE_PATCH_CHUNK_SIZE });
381
+ session = applyEditOperations(repository.canonicalRoot, policy, session.sessionId, preflight.operations.map(stripInternalIndex), { expectedRevision, maxBatchOperations: MAX_SAFE_PATCH_CHUNK_SIZE, binding: input.binding });
380
382
  } catch (error) {
381
383
  const message = error instanceof Error ? error.message : String(error);
382
384
  if (!sessionId && input.recoverStaleSession !== false && message.includes('EDIT_SESSION_FINGERPRINT_STALE')) {
383
385
  const previousSessionId = session.sessionId;
384
- session = beginEditSession(repository.canonicalRoot, { purpose: `${String(input.purpose ?? 'Safe repository patch').trim() || 'Safe repository patch'} (recovered)`, allowedPaths });
386
+ session = beginEditSession(repository.canonicalRoot, { purpose: `${String(input.purpose ?? 'Safe repository patch').trim() || 'Safe repository patch'} (recovered)`, allowedPaths, binding: input.binding });
385
387
  expectedRevision = session.currentRevision;
386
388
  recoveredSession = { reason: 'Previous edit session fingerprints were stale; remaining chunks were moved into a fresh session.', previousSessionId, newSessionId: session.sessionId };
387
389
  const retry = preflightChunk(repository, group, chunkIndex, refresh);
388
390
  if (retry.failures.length === 0) {
389
- session = applyEditOperations(repository.canonicalRoot, policy, session.sessionId, retry.operations.map(stripInternalIndex), { expectedRevision, maxBatchOperations: MAX_SAFE_PATCH_CHUNK_SIZE });
391
+ session = applyEditOperations(repository.canonicalRoot, policy, session.sessionId, retry.operations.map(stripInternalIndex), { expectedRevision, maxBatchOperations: MAX_SAFE_PATCH_CHUNK_SIZE, binding: input.binding });
390
392
  } else {
391
393
  failures.push(...retry.failures);
392
394
  failedChunk = chunkIndex;
@@ -236,18 +236,60 @@ export function repositoryGitFinishWorkflow(controllerHome: string, repository:
236
236
  const featureBranch = assertSafeBranchName(input.featureBranch ?? before.branch);
237
237
  const targetBranch = assertSafeBranchName(input.targetBranch ?? repository.defaultBranch ?? 'main');
238
238
  const steps: RepositoryGitFinishResult['steps'] = [];
239
- if (!before.clean) {
240
- return { repoId: repository.repoId, checkoutId: repository.activeCheckoutId, featureBranch, targetBranch, before, steps, after: before, completed: false, error: { code: 'GIT_WORKTREE_NOT_CLEAN', message: 'Commit or revert all changes before finishing a workflow.' } };
239
+ const alreadyOnTarget = before.branch === targetBranch;
240
+ if (!before.clean && !alreadyOnTarget) {
241
+ return { repoId: repository.repoId, checkoutId: repository.activeCheckoutId, featureBranch, targetBranch, before, steps, after: before, completed: false, error: { code: 'GIT_WORKTREE_NOT_CLEAN', message: 'Commit or revert changes before switching branches to finish a workflow.' } };
241
242
  }
242
243
  if (featureBranch === targetBranch) {
243
244
  return { repoId: repository.repoId, checkoutId: repository.activeCheckoutId, featureBranch, targetBranch, before, steps, after: before, completed: false, error: { code: 'GIT_ALREADY_ON_TARGET', message: 'Current/feature branch equals target branch; nothing to merge or delete.' } };
244
245
  }
245
- const switchTarget = executeRepositoryGitCommand(controllerHome, repository, { args: ['switch', targetBranch], authorization: 'explicit_user_request', ...input });
246
- steps.push({ name: 'switch_target', execution: switchTarget });
247
- if (switchTarget.status !== 'executed' || switchTarget.ok !== true) return { repoId: repository.repoId, checkoutId: repository.activeCheckoutId, featureBranch, targetBranch, before, steps, after: repositoryGitStatus(repository), completed: false, error: { code: 'GIT_SWITCH_TARGET_FAILED', message: switchTarget.stderr || 'git switch failed' } };
248
- const merge = executeRepositoryGitCommand(controllerHome, repository, { args: ['merge', ...(input.noFf === true ? ['--no-ff'] : ['--ff-only']), featureBranch], authorization: 'explicit_user_request', ...input });
246
+ if (!alreadyOnTarget) {
247
+ const switchTarget = executeRepositoryGitCommand(controllerHome, repository, { args: ['switch', targetBranch], authorization: 'explicit_user_request', ...input });
248
+ steps.push({ name: 'switch_target', execution: switchTarget });
249
+ if (switchTarget.status !== 'executed' || switchTarget.ok !== true) return { repoId: repository.repoId, checkoutId: repository.activeCheckoutId, featureBranch, targetBranch, before, steps, after: repositoryGitStatus(repository), completed: false, error: { code: 'GIT_SWITCH_TARGET_FAILED', message: switchTarget.stderr || 'git switch failed' } };
250
+ }
251
+ const protectTrackedChanges = alreadyOnTarget && (before.staged.length > 0 || before.unstaged.length > 0);
252
+ const finishAuthorization = 'explicit_user_request' as const;
253
+ let stashRef: string | null = null;
254
+ if (protectTrackedChanges) {
255
+ const stash = executeRepositoryGitCommand(controllerHome, repository, { args: ['stash', 'push', '-m', `forge-finish:${input.workId ?? featureBranch}`], authorization: finishAuthorization, ...input });
256
+ steps.push({ name: 'stash_target_changes', execution: stash });
257
+ if (stash.status !== 'executed' || stash.ok !== true) return { repoId: repository.repoId, checkoutId: repository.activeCheckoutId, featureBranch, targetBranch, before, steps, after: repositoryGitStatus(repository), completed: false, error: { code: 'GIT_STASH_LOCAL_CHANGES_FAILED', message: stash.stderr || 'git stash push failed' } };
258
+ stashRef = gitText(repository, ['rev-parse', 'refs/stash']);
259
+ if (!stashRef) return { repoId: repository.repoId, checkoutId: repository.activeCheckoutId, featureBranch, targetBranch, before, steps, after: repositoryGitStatus(repository), completed: false, error: { code: 'GIT_STASH_REFERENCE_MISSING', message: 'Tracked changes were stashed but the stash reference could not be resolved.' } };
260
+ const workingTreeMerge = runGit(repository, ['merge-tree', '--write-tree', featureBranch, stashRef], 128 * 1024);
261
+ const indexTreeMerge = runGit(repository, ['merge-tree', '--write-tree', featureBranch, `${stashRef}^2`], 128 * 1024);
262
+ if (!workingTreeMerge.ok || !indexTreeMerge.ok) {
263
+ const restore = executeRepositoryGitCommand(controllerHome, repository, { args: ['stash', 'apply', '--index', stashRef], authorization: finishAuthorization, ...input });
264
+ steps.push({ name: 'restore_target_changes_after_preflight_conflict', execution: restore });
265
+ if (restore.status !== 'executed' || restore.ok !== true) return { repoId: repository.repoId, checkoutId: repository.activeCheckoutId, featureBranch, targetBranch, before, steps, after: repositoryGitStatus(repository), completed: false, error: { code: 'GIT_LOCAL_CHANGES_RESTORE_FAILED', message: restore.stderr || 'Tracked target changes could not be restored after integration preflight; recovery stash was retained.' } };
266
+ const drop = executeRepositoryGitCommand(controllerHome, repository, { args: ['stash', 'drop', stashRef], authorization: finishAuthorization, ...input });
267
+ steps.push({ name: 'drop_target_stash', execution: drop });
268
+ return { repoId: repository.repoId, checkoutId: repository.activeCheckoutId, featureBranch, targetBranch, before, steps, after: repositoryGitStatus(repository), completed: false, error: { code: 'GIT_LOCAL_CHANGES_REAPPLY_CONFLICT', message: workingTreeMerge.stderr || indexTreeMerge.stderr || 'Tracked target changes conflict with the feature branch; target branch was left unchanged and local changes were restored.' } };
269
+ }
270
+ }
271
+ const applyTrackedStash = (name: string): RepositoryGitExecution | undefined => {
272
+ if (!stashRef) return undefined;
273
+ const execution = executeRepositoryGitCommand(controllerHome, repository, { args: ['stash', 'apply', '--index', stashRef], authorization: finishAuthorization, ...input });
274
+ steps.push({ name, execution });
275
+ return execution;
276
+ };
277
+ const dropTrackedStash = (): void => {
278
+ if (!stashRef) return;
279
+ const execution = executeRepositoryGitCommand(controllerHome, repository, { args: ['stash', 'drop', stashRef], authorization: finishAuthorization, ...input });
280
+ steps.push({ name: 'drop_target_stash', execution });
281
+ };
282
+ const merge = executeRepositoryGitCommand(controllerHome, repository, { args: ['merge', ...(input.noFf === true ? ['--no-ff'] : ['--ff-only']), featureBranch], authorization: finishAuthorization, ...input });
249
283
  steps.push({ name: 'merge_feature', execution: merge });
250
- if (merge.status !== 'executed' || merge.ok !== true) return { repoId: repository.repoId, checkoutId: repository.activeCheckoutId, featureBranch, targetBranch, before, steps, after: repositoryGitStatus(repository), completed: false, error: { code: 'GIT_MERGE_FAILED', message: merge.stderr || 'git merge failed' } };
284
+ if (merge.status !== 'executed' || merge.ok !== true) {
285
+ const restore = applyTrackedStash('restore_target_changes_after_merge_failure');
286
+ if (restore && (restore.status !== 'executed' || restore.ok !== true)) return { repoId: repository.repoId, checkoutId: repository.activeCheckoutId, featureBranch, targetBranch, before, steps, after: repositoryGitStatus(repository), completed: false, error: { code: 'GIT_LOCAL_CHANGES_RESTORE_FAILED', message: restore.stderr || 'git stash apply failed while restoring the pre-merge target state' } };
287
+ dropTrackedStash();
288
+ return { repoId: repository.repoId, checkoutId: repository.activeCheckoutId, featureBranch, targetBranch, before, steps, after: repositoryGitStatus(repository), completed: false, error: { code: 'GIT_MERGE_FAILED', message: merge.stderr || 'git merge failed' } };
289
+ }
290
+ const reapply = applyTrackedStash('reapply_target_changes');
291
+ if (reapply && (reapply.status !== 'executed' || reapply.ok !== true)) return { repoId: repository.repoId, checkoutId: repository.activeCheckoutId, featureBranch, targetBranch, before, steps, after: repositoryGitStatus(repository), completed: false, error: { code: 'GIT_LOCAL_CHANGES_RESTORE_FAILED', message: reapply.stderr || 'Tracked target changes passed merge-tree preflight but could not be reapplied after merge; recovery stash was retained.' } };
292
+ dropTrackedStash();
251
293
  if (input.deleteBranch !== false) {
252
294
  const del = executeRepositoryGitCommand(controllerHome, repository, { args: ['branch', '-d', featureBranch], authorization: 'explicit_user_request', ...input });
253
295
  steps.push({ name: 'delete_feature_branch', execution: del });
@@ -1,5 +1,5 @@
1
1
  import { createHash } from 'crypto';
2
- import { existsSync, lstatSync, readdirSync, readFileSync, statSync } from 'fs';
2
+ import { existsSync, lstatSync, readdirSync, readFileSync, realpathSync, statSync } from 'fs';
3
3
  import { join } from 'path';
4
4
  import { runProcess } from '../../effects/process-runner';
5
5
  import { globMatches, resolveMcpPath } from '../mcp/paths';
@@ -264,6 +264,8 @@ export interface SearchRepositoryOptions {
264
264
 
265
265
  export interface SearchRepositoryManyOptions {
266
266
  queries: string[];
267
+ /** Pre-resolved exact candidate files. Skips repository inventory discovery. */
268
+ files?: string[];
267
269
  includeGlobs?: string[];
268
270
  excludeGlobs?: string[];
269
271
  maxResultsPerQuery?: number;
@@ -327,7 +329,17 @@ export function searchRepositoryMany(
327
329
  const maxFiles = Math.min(Math.max(opts.maxFiles ?? 5000, 1), 20_000);
328
330
  const includes = opts.includeGlobs ?? [];
329
331
  const excludes = [...(includes.length === 0 ? DEFAULT_EXCLUDES : []), ...(opts.excludeGlobs ?? [])];
330
- const inventory = searchInventory(repoRoot, includes, excludes, maxFiles, opts.cacheKey);
332
+ const directFiles = opts.files?.map((path) => path.trim()).filter(Boolean);
333
+ const directCandidates = directFiles
334
+ ? Array.from(new Set(directFiles)).filter((path) => !isExcluded(path, excludes))
335
+ : undefined;
336
+ const inventory = directCandidates && directCandidates.length > 0
337
+ ? {
338
+ files: directCandidates.slice(0, maxFiles),
339
+ candidateCount: directCandidates.length,
340
+ cacheHit: false,
341
+ }
342
+ : searchInventory(repoRoot, includes, excludes, maxFiles, opts.cacheKey);
331
343
  const needles = queries.map((query) => ({ query, needle: opts.caseSensitive ? query : query.toLowerCase() }));
332
344
  const counts = new Map(queries.map((query) => [query, 0]));
333
345
  const results: SearchRepositoryManyResult['results'] = [];
@@ -335,10 +347,11 @@ export function searchRepositoryMany(
335
347
  let policyDeniedFiles = 0;
336
348
  let skippedLargeFiles = 0;
337
349
  let skippedBinaryFiles = 0;
350
+ const canonicalRepoRoot = realpathSync(repoRoot);
338
351
 
339
352
  for (const path of inventory.files) {
340
353
  if (needles.every(({ query }) => (counts.get(query) ?? 0) >= maxResultsPerQuery)) break;
341
- const decision = resolveMcpPath(repoRoot, path, policy, 'read');
354
+ const decision = resolveMcpPath(repoRoot, path, policy, 'read', canonicalRepoRoot);
342
355
  if (!decision.ok || !decision.absolutePath) {
343
356
  policyDeniedFiles += 1;
344
357
  continue;
@@ -370,8 +370,8 @@ export function runAssistantRoutineNow(
370
370
  relatedRoutineId: routine.routineId,
371
371
  relatedRequestId: requestId,
372
372
  jobIds: [],
373
- recommendations: ['Claim or create Work for this routine, then execute it through an explicit external Controller.'],
374
- data: { routine: touched, externalControllerRequired: true },
373
+ recommendations: ['Resume this existing Routine authority through its external Controller context. Do not create a new Work merely because the timer fired.'],
374
+ data: { routine: touched, externalControllerRequired: true, createWorkOnTrigger: false },
375
375
  });
376
376
  return {
377
377
  schemaVersion: 1,
@@ -381,7 +381,7 @@ export function runAssistantRoutineNow(
381
381
  requestId,
382
382
  understoodIntent: 'run_routine',
383
383
  displayTitle: `Routine requires an external Controller: ${routine.name}`,
384
- displayText: 'No ExecutionJob was created. Continue this routine from the recorded Inbox item with an explicitly claimed Controller session.',
384
+ displayText: 'No ExecutionJob or new Work was created. Continue the existing Routine authority from the recorded Inbox item with an explicitly claimed Controller session.',
385
385
  requiresConfirmation: false,
386
386
  plan: [],
387
387
  routine: touched,