@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.
- package/CHANGELOG.md +28 -1
- package/DESIGN.md +7 -2
- package/README.md +7 -7
- package/README.zh-CN.md +5 -3
- package/THIRD_PARTY_NOTICES.md +1 -1
- package/assets/hooks/pre-edit-guard.sh +8 -39
- package/assets/hooks/prompt-guard.sh +2 -2
- package/assets/plugin-registry.v1.json +54 -3
- package/assets/reference-configs/hook-operations.md +1 -1
- package/assets/reference-configs/sprint-contracts.md +2 -2
- package/assets/skill-version.json +2 -2
- package/assets/templates/helpers/ensure-task-workflow.sh +2 -2
- package/assets/templates/helpers/sprint-backlog.sh +6 -6
- package/docs/architecture/current/automation-and-schedule-engine.md +9 -3
- package/docs/architecture/current/codegraph-context-provider.md +15 -3
- package/docs/architecture/current/dispatch-and-agent-strategy.md +32 -13
- package/docs/architecture/current/entity-model.md +20 -0
- package/docs/architecture/current/external-plugin-governance.md +3 -3
- package/docs/architecture/current/system-overview.md +26 -1
- package/docs/architecture/current/thin-harness-v1.md +10 -0
- package/docs/forge-chatgpt-mcp-setup.md +27 -3
- package/docs/operations/chatgpt-personal-assistant-runtime.md +12 -2
- package/docs/operations/controller-browser-plugin.md +12 -16
- package/docs/operations/features.md +1 -1
- package/docs/operations/homebrew.md +2 -2
- package/docs/public-usage-guide.md +1 -1
- package/docs/public-usage-guide.zh-CN.md +1 -1
- package/docs/tutorials/01-install-and-start.md +6 -4
- package/docs/tutorials/01-install-and-start.zh-CN.md +3 -1
- package/docs/tutorials/02-connect-chatgpt.md +8 -2
- package/docs/tutorials/02-connect-chatgpt.zh-CN.md +8 -2
- package/docs/wiki/Installation.md +1 -1
- package/docs/wiki/Quick-Start.md +1 -1
- package/package.json +1 -1
- package/scripts/check-public-docs.sh +14 -1
- package/scripts/migrate-chatgpt-automation-schedules.ts +64 -0
- package/scripts/sprint-backlog.sh +1 -1
- package/src/cli/commands/chatgpt.ts +31 -2
- package/src/cli/commands/init-hook.ts +3 -1
- package/src/cli/commands/mcp.ts +2 -0
- package/src/cli/commands/plugin.ts +36 -9
- package/src/cli/commands/runtime.ts +1 -0
- package/src/cli/commands/setup-profile.ts +49 -18
- package/src/cli/controller/context-pack.ts +158 -6
- package/src/cli/hook/prompt-guard-decision.ts +17 -18
- package/src/cli/hook/prompt-intents.ts +1 -1
- package/src/cli/local-bridge/console-assets.ts +17 -0
- package/src/cli/local-bridge/console-automations.ts +205 -23
- package/src/cli/local-bridge/console-view-models.ts +2 -0
- package/src/cli/local-bridge/facade-api.ts +2 -0
- package/src/cli/local-bridge/frontend/src/App.tsx +1 -1
- package/src/cli/local-bridge/frontend/src/features/automations/AutomationsView.tsx +143 -3
- package/src/cli/local-bridge/frontend/src/features/capabilities/CapabilitiesView.tsx +1 -1
- package/src/cli/local-bridge/frontend/src/features/overview/OverviewView.tsx +181 -3
- package/src/cli/local-bridge/frontend/src/features/repositories/RepositoriesView.tsx +1 -1
- package/src/cli/local-bridge/frontend/src/features/work/WorkView.tsx +4 -3
- package/src/cli/local-bridge/frontend/src/lib/api.ts +18 -6
- package/src/cli/local-bridge/frontend/src/shell/CommandBar.tsx +1 -1
- package/src/cli/local-bridge/frontend/src/shell/Sidebar.tsx +2 -1
- package/src/cli/local-bridge/frontend/src/styles.css +27 -11
- package/src/cli/local-bridge/frontend/src/types.ts +6 -2
- package/src/cli/local-bridge/server.ts +31 -20
- package/src/cli/local-bridge/ui-dist/app.css +1 -1
- package/src/cli/local-bridge/ui-dist/app.js +5003 -4815
- package/src/cli/mcp/auth.ts +2 -0
- package/src/cli/mcp/paths.ts +8 -2
- package/src/cli/mcp/repository-tools.ts +124 -9
- package/src/cli/mcp/server.ts +164 -19
- package/src/cli/mcp/setup.ts +9 -1
- package/src/cli/mcp/transports/http.ts +52 -2
- package/src/cli/mcp/transports/session-registry.ts +24 -0
- package/src/cli/repositories/command-classifier.ts +113 -0
- package/src/cli/repositories/safe-patch.ts +6 -4
- package/src/cli/repositories/structured-git.ts +49 -7
- package/src/cli/repository/inspector.ts +16 -3
- package/src/runtime/assistant/intent.ts +3 -3
- package/src/runtime/control-plane/execution/direct-edit-work-completion.ts +157 -2
- package/src/runtime/control-plane/execution/execution-identity.ts +65 -14
- package/src/runtime/control-plane/execution/validation.ts +15 -2
- package/src/runtime/control-plane/execution/work-terminal-cleanup.ts +32 -0
- package/src/runtime/control-plane/execution/work-validation-reconciler.ts +6 -7
- package/src/runtime/control-plane/execution/work-verification-snapshot.ts +2 -1
- package/src/runtime/control-plane/facade/capability-registry.ts +1 -1
- package/src/runtime/control-plane/facade/controller-session-store.ts +12 -13
- package/src/runtime/control-plane/facade/goal-workloop-access.ts +7 -2
- package/src/runtime/control-plane/facade/goal-workloop.ts +231 -8
- package/src/runtime/control-plane/facade/plan-contract-store.ts +32 -12
- package/src/runtime/control-plane/facade/requirement-board.ts +24 -8
- package/src/runtime/control-plane/facade/types.ts +33 -6
- package/src/runtime/control-plane/facade/work-contract-store.ts +31 -2
- package/src/runtime/control-plane/launcher/chatgpt-work-continuation.ts +375 -23
- package/src/runtime/control-plane/persistence/requirement-store.ts +15 -11
- package/src/runtime/diagnostics/mcp-timing.ts +6 -0
- package/src/runtime/execution/process-runtime/check-facade.ts +1 -10
- package/src/runtime/execution/process-runtime/check-result.ts +10 -2
- package/src/runtime/execution/process-runtime/command-facade.ts +1 -0
- package/src/runtime/execution/process-runtime/gc.ts +64 -10
- package/src/runtime/execution/process-runtime/persisted-check.ts +10 -2
- package/src/runtime/execution/process-runtime/resource-claims.ts +87 -2
- package/src/runtime/execution/process-runtime/runtime.ts +16 -1
- package/src/runtime/execution/process-runtime/store.ts +6 -0
- package/src/runtime/gateway/mcp/execution-tools.ts +27 -12
- package/src/runtime/gateway/mcp/router.ts +1 -1
- package/src/runtime/gateway/mcp/runtime-tools.ts +482 -178
- package/src/runtime/model-clients/deepseek-adapter.ts +0 -32
- package/src/runtime/plugins/browser-adapter.ts +19 -72
- package/src/runtime/plugins/browser-handoff-host.ts +6 -31
- package/src/runtime/plugins/browser-handoff.ts +0 -3
- package/src/runtime/plugins/browser-macos-bridge.ts +8 -0
- package/src/runtime/plugins/desktop-operator-registration.ts +8 -11
- package/src/runtime/plugins/external-registration.ts +84 -5
- package/src/runtime/plugins/first-party-registry.ts +2 -2
- package/src/runtime/plugins/gmail-adapter.ts +28 -2
- package/src/runtime/plugins/local-system-adapter.ts +2 -2
- package/src/runtime/plugins/plugin-management-adapter.ts +140 -0
- package/src/runtime/plugins/store.ts +107 -5
- package/src/runtime/plugins/types.ts +4 -0
- package/src/runtime/recovery/actions.ts +0 -10
- package/src/runtime/recovery/classifier.ts +0 -9
- package/src/runtime/recovery/maintenance-executor.ts +43 -11
- package/src/runtime/recovery/probe.ts +0 -1
- package/src/runtime/recovery/types.ts +0 -1
- package/src/runtime/root/package-connector-service.ts +136 -0
- package/src/runtime/root/package-runtime-service.ts +25 -12
- package/src/runtime/root/release-materialize.ts +161 -18
- package/src/runtime/root/service.ts +6 -4
- package/src/runtime/safe-tooling/index.ts +0 -1
- package/src/runtime/safe-tooling/types.ts +0 -30
- package/src/runtime/standalone-recovery/core.ts +2 -2
- package/src/runtime/workflow/schedules/browser-probe.ts +180 -0
- package/src/runtime/workflow/schedules/chatgpt-automation-migration.ts +40 -0
- package/src/runtime/workflow/schedules/engine.ts +357 -64
- package/src/runtime/workflow/schedules/types.ts +5 -0
- package/src/runtime/workflow/schedules/work-continuation.ts +100 -27
- package/bin/forge-desktop-helper.mjs +0 -204
- package/docs/operations/controller-desktop-plugin.md +0 -112
- package/src/runtime/plugins/desktop-adapter.ts +0 -374
- 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
|
-
|
|
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
|
-
|
|
240
|
-
|
|
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
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
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)
|
|
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
|
|
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: ['
|
|
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
|
|
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,
|