salmon-loop 0.3.0 → 0.3.2

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 (93) hide show
  1. package/dist/cli/authorization/non-interactive.js +7 -21
  2. package/dist/cli/commands/chat.js +1 -1
  3. package/dist/cli/commands/parallel.js +46 -41
  4. package/dist/cli/commands/run/assistant-message.js +3 -0
  5. package/dist/cli/commands/run/handler.js +2 -1
  6. package/dist/cli/commands/serve.js +123 -154
  7. package/dist/cli/headless/json-protocol.js +1 -1
  8. package/dist/cli/headless/stream-json-protocol.js +3 -2
  9. package/dist/cli/slash/runtime.js +5 -1
  10. package/dist/cli/ui/components/CommandSuggestionList.js +1 -1
  11. package/dist/core/adapters/fs/node-fs.js +1 -0
  12. package/dist/core/benchmark/patch-artifact.js +1 -1
  13. package/dist/core/context/service.js +36 -10
  14. package/dist/core/extensions/index.js +2 -35
  15. package/dist/core/extensions/redact.js +9 -3
  16. package/dist/core/extensions/schemas.js +2 -51
  17. package/dist/core/facades/cli-authorization-non-interactive.js +1 -1
  18. package/dist/core/facades/cli-serve.js +0 -1
  19. package/dist/core/grizzco/dsl/strategies.js +1 -3
  20. package/dist/core/grizzco/engine/outcome/loop-result-mapper.js +12 -7
  21. package/dist/core/grizzco/engine/transaction/attempt-failure.js +23 -23
  22. package/dist/core/grizzco/engine/transaction/report-mapper.js +3 -0
  23. package/dist/core/grizzco/engine/transaction/transaction-runner.js +14 -0
  24. package/dist/core/grizzco/flows/AutopilotFlow.js +1 -0
  25. package/dist/core/grizzco/flows/SalmonLoopFlow.js +1 -0
  26. package/dist/core/grizzco/steps/apply.js +0 -7
  27. package/dist/core/grizzco/steps/autopilot.js +108 -6
  28. package/dist/core/grizzco/steps/preflight.js +10 -0
  29. package/dist/core/grizzco/steps/tool-runtime.js +1 -0
  30. package/dist/core/interaction/events/bus.js +14 -0
  31. package/dist/core/interaction/orchestration/facade.js +10 -0
  32. package/dist/core/mcp/bridge/index.js +4 -0
  33. package/dist/core/mcp/bridge/prompt-command-provider.js +261 -0
  34. package/dist/core/mcp/bridge/resource-context-provider.js +259 -0
  35. package/dist/core/mcp/bridge/tool-bridge.js +303 -0
  36. package/dist/core/mcp/cache/resource-cache.js +41 -0
  37. package/dist/core/mcp/catalog/discovery.js +51 -0
  38. package/dist/core/mcp/catalog/notification-router.js +28 -0
  39. package/dist/core/mcp/catalog/prompt-catalog.js +4 -0
  40. package/dist/core/mcp/catalog/resource-catalog.js +7 -0
  41. package/dist/core/mcp/catalog/tool-catalog.js +4 -0
  42. package/dist/core/mcp/client/connection-manager.js +239 -0
  43. package/dist/core/mcp/client/lifecycle.js +13 -0
  44. package/dist/core/mcp/client/transport-factory.js +168 -0
  45. package/dist/core/mcp/config/index.js +32 -0
  46. package/dist/core/mcp/config/schema-v2.js +129 -0
  47. package/dist/core/mcp/host/elicitation-provider.js +209 -0
  48. package/dist/core/mcp/host/roots-provider.js +70 -0
  49. package/dist/core/mcp/host/sampling-provider.js +170 -0
  50. package/dist/core/mcp/index.js +4 -0
  51. package/dist/core/mcp/observability/events.js +19 -0
  52. package/dist/core/mcp/policy/approval-policy.js +2 -0
  53. package/dist/core/mcp/policy/classifier.js +172 -0
  54. package/dist/core/mcp/policy/grants.js +356 -0
  55. package/dist/core/mcp/policy/uri-policy.js +60 -0
  56. package/dist/core/mcp/schema/json-schema-to-zod.js +511 -0
  57. package/dist/core/mcp/types.js +2 -0
  58. package/dist/core/protocols/a2a/agent-card.js +36 -11
  59. package/dist/core/protocols/a2a/sdk/executor.js +105 -36
  60. package/dist/core/protocols/a2a/sdk/server.js +1311 -3
  61. package/dist/core/protocols/acp/acp-checkpoint-probe.js +113 -0
  62. package/dist/core/protocols/acp/acp-session-persistence.js +336 -0
  63. package/dist/core/protocols/acp/acp-types.js +17 -0
  64. package/dist/core/protocols/acp/formal-agent.js +271 -603
  65. package/dist/core/protocols/acp/handlers.js +3 -0
  66. package/dist/core/protocols/acp/permission-provider.js +11 -39
  67. package/dist/core/protocols/acp/stdio-server.js +20 -1
  68. package/dist/core/protocols/acp/tool-kind-mapping.js +62 -0
  69. package/dist/core/protocols/shared/flow-mode-mapping.js +0 -8
  70. package/dist/core/public-capabilities/flow-mode-metadata.js +0 -6
  71. package/dist/core/public-capabilities/projections.js +1 -0
  72. package/dist/core/runtime/agent-server-runtime.js +2 -3
  73. package/dist/core/runtime/spawn-command.js +8 -2
  74. package/dist/core/runtime/spawn-interactive.js +26 -0
  75. package/dist/core/session/manager.js +65 -35
  76. package/dist/core/tools/builtin/index.js +6 -1
  77. package/dist/core/tools/builtin/proposal.js +0 -7
  78. package/dist/core/tools/builtin/workspace.js +76 -0
  79. package/dist/core/tools/dispatcher.js +1 -0
  80. package/dist/core/tools/loader.js +92 -46
  81. package/dist/core/verification/runner.js +60 -31
  82. package/dist/core/workspace/capabilities.js +80 -0
  83. package/dist/locales/en.js +17 -3
  84. package/package.json +4 -2
  85. package/dist/core/protocols/a2a/mapper.js +0 -14
  86. package/dist/core/protocols/a2a/sdk/auth-middleware.js +0 -31
  87. package/dist/core/protocols/a2a/task-projection.js +0 -45
  88. package/dist/core/protocols/acp/checkpoint-meta.js +0 -2
  89. package/dist/core/tools/mcp/client.js +0 -309
  90. package/dist/core/tools/mcp/loader.js +0 -110
  91. package/dist/core/tools/mcp/schema.js +0 -54
  92. package/dist/core/tools/mcp/streamable-http.js +0 -101
  93. package/dist/core/tools/mcp/types.js +0 -26
@@ -163,7 +163,7 @@ export class ContextService {
163
163
  const absoluteFile = defaultPathAdapter.resolve(repoPath, relativeFile);
164
164
  try {
165
165
  const stat = await this.fileAdapter.stat(absoluteFile);
166
- parts.push(`${relativeFile}:${stat.mtimeMs}:${stat.size}`);
166
+ parts.push(this.formatStatSignature(relativeFile, stat));
167
167
  }
168
168
  catch {
169
169
  parts.push(`${relativeFile}:missing`);
@@ -182,7 +182,7 @@ export class ContextService {
182
182
  const gitPath = defaultPathAdapter.resolve(repoPath, rel);
183
183
  try {
184
184
  const stat = await this.fileAdapter.stat(gitPath);
185
- parts.push(`${rel}:${stat.mtimeMs}:${stat.size}`);
185
+ parts.push(this.formatStatSignature(rel, stat));
186
186
  }
187
187
  catch {
188
188
  parts.push(`${rel}:missing`);
@@ -190,6 +190,9 @@ export class ContextService {
190
190
  }
191
191
  return parts;
192
192
  }
193
+ formatStatSignature(relativePath, stat) {
194
+ return `${relativePath}:${stat.mtimeMs}:${stat.ctimeMs ?? 0}:${stat.size}`;
195
+ }
193
196
  getEntryTimestamp(entry) {
194
197
  return entry.lastAccessedAt ?? entry.createdAt ?? 0;
195
198
  }
@@ -211,25 +214,48 @@ export class ContextService {
211
214
  return true;
212
215
  }
213
216
  async evictExpiredEntries() {
214
- for (const [key, entry] of await this.cacheStore.entries()) {
215
- await this.isExpired(key, entry);
217
+ const entries = Array.from(await this.cacheStore.entries());
218
+ const now = Date.now();
219
+ const expiredEntries = entries.filter(([, entry]) => {
220
+ const last = this.getEntryTimestamp(entry);
221
+ return last && now - last > this.cacheTtlMs;
222
+ });
223
+ for (let i = 0; i < expiredEntries.length; i += 10) {
224
+ const chunk = expiredEntries.slice(i, i + 10);
225
+ await Promise.all(chunk.map(([key, entry]) => this.isExpired(key, entry)));
216
226
  }
217
227
  }
218
228
  async evictLruIfNeeded() {
219
- while ((await this.cacheStore.size()) > this.cacheMaxEntries) {
229
+ const size = await this.cacheStore.size();
230
+ if (size <= this.cacheMaxEntries)
231
+ return;
232
+ const excess = size - this.cacheMaxEntries;
233
+ const entries = Array.from(await this.cacheStore.entries());
234
+ if (excess === 1) {
220
235
  let victimKey;
221
236
  let victimTs = Number.POSITIVE_INFINITY;
222
- for (const [key, entry] of await this.cacheStore.entries()) {
237
+ for (const [key, entry] of entries) {
223
238
  const ts = this.getEntryTimestamp(entry);
224
239
  if (ts < victimTs) {
225
240
  victimTs = ts;
226
241
  victimKey = key;
227
242
  }
228
243
  }
229
- if (!victimKey)
230
- break;
231
- await this.cacheStore.delete(victimKey);
232
- this.cacheMetrics.evictions += 1;
244
+ if (victimKey) {
245
+ await this.cacheStore.delete(victimKey);
246
+ this.cacheMetrics.evictions += 1;
247
+ }
248
+ }
249
+ else {
250
+ entries.sort((a, b) => (this.getEntryTimestamp(a[1]) || 0) - (this.getEntryTimestamp(b[1]) || 0));
251
+ const victims = entries.slice(0, excess);
252
+ for (let i = 0; i < victims.length; i += 10) {
253
+ const chunk = victims.slice(i, i + 10);
254
+ await Promise.all(chunk.map(async ([key]) => {
255
+ await this.cacheStore.delete(key);
256
+ this.cacheMetrics.evictions += 1;
257
+ }));
258
+ }
233
259
  }
234
260
  }
235
261
  async getCacheStats() {
@@ -1,4 +1,5 @@
1
1
  import path from 'node:path';
2
+ import { buildResolvedMcpServersV2 } from '../mcp/config/index.js';
2
3
  import { getLogger } from '../observability/logger.js';
3
4
  import { loadConfig } from './load.js';
4
5
  import { mergeScopedEntries } from './merge.js';
@@ -14,40 +15,6 @@ function resolvePathForScope(value, scope, repoRoot) {
14
15
  const expanded = expandHome(value);
15
16
  return scope === 'repo' ? resolveRepoRelative(repoRoot, expanded) : resolveUserRelative(expanded);
16
17
  }
17
- function buildResolvedServers(entries, repoRoot) {
18
- return entries.map((entry) => {
19
- const scope = entry.scope;
20
- const source = entry.entry;
21
- const enabled = source.enabled ?? defaultEnabled(scope);
22
- if (source.url) {
23
- return {
24
- name: entry.key,
25
- enabled,
26
- transport: 'http',
27
- url: source.url,
28
- headers: source.headers ?? {},
29
- allowTools: source.allow?.tools ?? [],
30
- allowResources: source.allow?.resources ?? [],
31
- scope,
32
- };
33
- }
34
- if (!source.command) {
35
- throw new Error(`Invalid MCP server entry ${entry.key}: missing "command" or "url"`);
36
- }
37
- return {
38
- name: entry.key,
39
- enabled,
40
- transport: 'stdio',
41
- command: source.command,
42
- args: source.args ?? [],
43
- env: source.env ?? {},
44
- cwd: resolvePathForScope(source.cwd, scope, repoRoot),
45
- allowTools: source.allow?.tools ?? [],
46
- allowResources: source.allow?.resources ?? [],
47
- scope,
48
- };
49
- });
50
- }
51
18
  function buildResolvedPlugins(entries, repoRoot) {
52
19
  return entries.map((entry) => {
53
20
  const scope = entry.scope;
@@ -117,7 +84,7 @@ export async function resolveExtensions(options) {
117
84
  const mergedServers = mergeScopedEntries(userMcp?.config.servers, repoMcp?.config.servers);
118
85
  const mergedPlugins = mergeScopedEntries(userTools?.config.plugins, repoTools?.config.plugins);
119
86
  const resolved = {
120
- mcpServers: buildResolvedServers(mergedServers, repoRoot),
87
+ mcpServers: buildResolvedMcpServersV2(mergedServers, repoRoot),
121
88
  toolPlugins: buildResolvedPlugins(mergedPlugins, repoRoot),
122
89
  skillDiscovery: buildResolvedSkills(userSkills?.config, repoSkills?.config, repoRoot),
123
90
  };
@@ -17,15 +17,21 @@ function redactHeaders(headers) {
17
17
  return output;
18
18
  }
19
19
  function redactServer(server) {
20
- if (server.transport === 'http') {
20
+ if (server.transport.type === 'http') {
21
21
  return {
22
22
  ...server,
23
- headers: redactHeaders(server.headers || {}),
23
+ transport: {
24
+ ...server.transport,
25
+ headers: redactHeaders(server.transport.headers || {}),
26
+ },
24
27
  };
25
28
  }
26
29
  return {
27
30
  ...server,
28
- env: redactEnv(server.env || {}),
31
+ transport: {
32
+ ...server.transport,
33
+ env: redactEnv(server.transport.env || {}),
34
+ },
29
35
  };
30
36
  }
31
37
  export function redactExtensions(extensions) {
@@ -1,55 +1,6 @@
1
1
  import { z } from 'zod';
2
- const mcpAllowSchema = z.object({
3
- tools: z.array(z.string()).optional(),
4
- resources: z.array(z.string()).optional(),
5
- });
6
- const mcpServerSchema = z
7
- .object({
8
- enabled: z.boolean().optional(),
9
- command: z.string().optional(),
10
- url: z.string().url().optional(),
11
- args: z.array(z.string()).optional(),
12
- env: z.record(z.string(), z.string()).optional(),
13
- headers: z.record(z.string(), z.string()).optional(),
14
- cwd: z.string().optional(),
15
- allow: mcpAllowSchema.optional(),
16
- })
17
- .superRefine((value, ctx) => {
18
- const hasCommand = Boolean(value.command);
19
- const hasUrl = Boolean(value.url);
20
- if (hasCommand === hasUrl) {
21
- ctx.addIssue({
22
- code: z.ZodIssueCode.custom,
23
- message: 'MCP server entry must include exactly one of "command" or "url".',
24
- path: ['command'],
25
- });
26
- }
27
- if (hasUrl && value.args && value.args.length > 0) {
28
- ctx.addIssue({
29
- code: z.ZodIssueCode.custom,
30
- message: '"args" is only valid for stdio MCP servers ("command" transport).',
31
- path: ['args'],
32
- });
33
- }
34
- if (hasUrl && value.cwd) {
35
- ctx.addIssue({
36
- code: z.ZodIssueCode.custom,
37
- message: '"cwd" is only valid for stdio MCP servers ("command" transport).',
38
- path: ['cwd'],
39
- });
40
- }
41
- if (hasUrl && value.env && Object.keys(value.env).length > 0) {
42
- ctx.addIssue({
43
- code: z.ZodIssueCode.custom,
44
- message: '"env" is only valid for stdio MCP servers ("command" transport).',
45
- path: ['env'],
46
- });
47
- }
48
- });
49
- export const McpConfigSchema = z.object({
50
- version: z.literal(1),
51
- servers: z.record(z.string(), mcpServerSchema).optional().default({}),
52
- });
2
+ import { McpConfigV2Schema } from '../mcp/config/schema-v2.js';
3
+ export const McpConfigSchema = McpConfigV2Schema;
53
4
  const toolPluginSchema = z.object({
54
5
  enabled: z.boolean().optional(),
55
6
  path: z.string(),
@@ -1,3 +1,3 @@
1
+ export { McpConnectionManager } from '../mcp/client/connection-manager.js';
1
2
  export { getLogger } from '../observability/logger.js';
2
- export { McpClient } from '../tools/mcp/client.js';
3
3
  //# sourceMappingURL=cli-authorization-non-interactive.js.map
@@ -10,7 +10,6 @@ export { PluginLoader } from '../plugin/loader.js';
10
10
  export { clearPluginRegistry, createPluginRegistry, setPluginRegistry, } from '../plugin/registry.js';
11
11
  export { clearPromptRegistry, createPromptRegistry, setPromptRegistry, } from '../prompts/registry.js';
12
12
  export { buildA2AAgentCard } from '../protocols/a2a/agent-card.js';
13
- export { buildA2AFlowSkills } from '../protocols/shared/flow-mode-mapping.js';
14
13
  export { createAcpFormalAgent } from '../protocols/acp/formal-agent.js';
15
14
  export { startAcpStdioServer } from '../protocols/acp/stdio-server.js';
16
15
  export { createAgentServerRuntime } from '../runtime/agent-server-runtime.js';
@@ -15,9 +15,7 @@ export const SafetyChecks = (engine) => {
15
15
  .require((c) => !c.file.isIgnored || c.options.force, 'Refusing to modify ignored file without --force')
16
16
  .phase('Lock Check')
17
17
  .requireData('remote_lock')
18
- .require((c) => !c.data?.remote_lock?.isLocked, text.grizzco.remoteLocked)
19
- .requireData('git_config')
20
- .require((c) => !!(c.data?.git_config?.user?.name && c.data?.git_config?.user?.email), text.grizzco.gitUserConfigMissing);
18
+ .require((c) => !c.data?.remote_lock?.isLocked, text.grizzco.remoteLocked);
21
19
  };
22
20
  export const IntentRouting = (engine) => {
23
21
  return engine
@@ -135,17 +135,21 @@ export function buildLoopResultFromTransaction({ executionReport, flowMode, opti
135
135
  };
136
136
  }
137
137
  const retryFailureReason = executionReport.history.at(-1)?.error ?? text.loop.loopExecutionFailed;
138
- const failureReason = executionReport.terminalReason ||
139
- (executionReport.retryExhausted ? text.loop.exceededMaxRetriesSimple : retryFailureReason);
138
+ const failureReason = executionReport.retryExhausted
139
+ ? text.loop.exceededMaxRetriesSimple
140
+ : executionReport.terminalReason || retryFailureReason;
140
141
  const safeHint = executionReport.terminalSafeHint ||
141
142
  (executionReport.retryExhausted
142
- ? text.loop.exceededMaxRetriesSimple
143
+ ? failureReason
143
144
  : executionReport.terminalReason || failureReason);
144
145
  const remediationSteps = executionReport.terminalRemediationSteps ?? [];
145
- const reasonCode = executionReport.terminalReasonCode ||
146
- (executionReport.retryExhausted ? 'MAX_RETRIES' : 'LOOP_FAILED');
146
+ const reasonCode = executionReport.retryExhausted
147
+ ? 'MAX_RETRIES'
148
+ : executionReport.terminalReasonCode || 'LOOP_FAILED';
149
+ const diagnosticCode = executionReport.terminalDiagnosticCode ?? executionReport.terminalReasonCode ?? reasonCode;
147
150
  const failurePhase = executionReport.terminalFailurePhase ||
148
151
  (executionReport.retryExhausted ? Phase.VERIFY : undefined);
152
+ const resultReason = executionReport.retryExhausted ? failureReason : safeHint;
149
153
  const usage = getTokenUsageFromAuditTrail() ?? undefined;
150
154
  const budgetSummary = getBudgetRunSummary() ?? undefined;
151
155
  const errorEnvelope = buildFailureEnvelope({
@@ -157,11 +161,11 @@ export function buildLoopResultFromTransaction({ executionReport, flowMode, opti
157
161
  });
158
162
  return {
159
163
  success: false,
160
- reason: safeHint,
164
+ reason: resultReason,
161
165
  reasonCode,
162
166
  terminalReason,
163
167
  rootCause,
164
- diagnosticCode: executionReport.terminalDiagnosticCode ?? reasonCode,
168
+ diagnosticCode,
165
169
  safeHint,
166
170
  remediationSteps,
167
171
  errorEnvelope,
@@ -171,6 +175,7 @@ export function buildLoopResultFromTransaction({ executionReport, flowMode, opti
171
175
  usage,
172
176
  authorizationDecisions,
173
177
  history: telemetry.getHistory(),
178
+ changedFiles,
174
179
  failurePhase,
175
180
  errorType: ErrorType.UNKNOWN,
176
181
  errorCode: executionReport.lastErrorCode,
@@ -122,6 +122,29 @@ export function resolveAttemptFailure(params) {
122
122
  return undefined;
123
123
  }
124
124
  const errorCode = extractErrorCode(flowReport.error) ?? extractErrorCodeFromTraces(flowReport);
125
+ if (profile.verifyPolicy !== 'never' && context?.verifyResult?.ok === false) {
126
+ const verifyOutput = context.verifyResult.output || text.loop.loopExecutionFailed;
127
+ const errorType = classifyError(verifyOutput);
128
+ const fallbackReason = sanitizeReason(context.lastError || verifyOutput);
129
+ const guidance = buildFailureGuidance({
130
+ reasonCode: 'VERIFY_FAILED',
131
+ failurePhase: 'VERIFY',
132
+ errorCode: String(errorType),
133
+ verifyOutput,
134
+ environmentMode,
135
+ fallbackReason,
136
+ });
137
+ return {
138
+ reason: guidance.safeHint,
139
+ reasonCode: 'VERIFY_FAILED',
140
+ failurePhase: 'VERIFY',
141
+ retryable: isRetryable(errorType),
142
+ errorCode: String(errorType),
143
+ diagnosticCode: guidance.diagnosticCode,
144
+ safeHint: guidance.safeHint,
145
+ remediationSteps: guidance.remediationSteps,
146
+ };
147
+ }
125
148
  if (flowMode === 'autopilot' && autopilotCompletion) {
126
149
  if (autopilotCompletion.status === 'changed' ||
127
150
  autopilotCompletion.status === 'read_only_answer') {
@@ -238,29 +261,6 @@ export function resolveAttemptFailure(params) {
238
261
  remediationSteps: guidance.remediationSteps,
239
262
  };
240
263
  }
241
- if (profile.verifyPolicy !== 'never' && context?.verifyResult?.ok === false) {
242
- const verifyOutput = context.verifyResult.output || text.loop.loopExecutionFailed;
243
- const errorType = classifyError(verifyOutput);
244
- const fallbackReason = sanitizeReason(context.lastError || verifyOutput);
245
- const guidance = buildFailureGuidance({
246
- reasonCode: 'VERIFY_FAILED',
247
- failurePhase: 'VERIFY',
248
- errorCode: String(errorType),
249
- verifyOutput,
250
- environmentMode,
251
- fallbackReason,
252
- });
253
- return {
254
- reason: guidance.safeHint,
255
- reasonCode: 'VERIFY_FAILED',
256
- failurePhase: 'VERIFY',
257
- retryable: isRetryable(errorType),
258
- errorCode: String(errorType),
259
- diagnosticCode: guidance.diagnosticCode,
260
- safeHint: guidance.safeHint,
261
- remediationSteps: guidance.remediationSteps,
262
- };
263
- }
264
264
  const failurePhase = inferFailurePhase(flowReport);
265
265
  const fallbackReason = sanitizeReason(context?.lastError || flowReport.error);
266
266
  if (isRecoverableToolInputErrorCode(errorCode)) {
@@ -58,7 +58,10 @@ export function mapRetryExhaustedReport(params) {
58
58
  lastRecentReadArtifacts,
59
59
  lastToolResultPreviewArtifacts,
60
60
  terminalFailurePhase: failure?.failurePhase,
61
+ terminalReasonCode: failure?.reasonCode,
61
62
  terminalDiagnosticCode: failure?.diagnosticCode,
63
+ terminalSafeHint: failure?.safeHint,
64
+ terminalRemediationSteps: failure ? [...failure.remediationSteps] : undefined,
62
65
  };
63
66
  }
64
67
  //# sourceMappingURL=report-mapper.js.map
@@ -128,6 +128,7 @@ export class FlowTransactionRunner {
128
128
  lastRecentReadArtifacts;
129
129
  lastToolResultPreviewArtifacts;
130
130
  lastReplacementState;
131
+ pendingAutopilotVerification;
131
132
  constructor(params) {
132
133
  this.params = params;
133
134
  this.lastVerifyArtifact = params.options.artifactHints?.verifyArtifact;
@@ -182,6 +183,7 @@ export class FlowTransactionRunner {
182
183
  : undefined,
183
184
  },
184
185
  replacementState: this.lastReplacementState,
186
+ pendingVerification: this.pendingAutopilotVerification,
185
187
  lastError: this.currentLastError,
186
188
  applyBackRuntime: {
187
189
  activeRepoPath: this.params.env.activeRepoPath,
@@ -212,6 +214,18 @@ export class FlowTransactionRunner {
212
214
  flowMode: this.params.flowMode,
213
215
  });
214
216
  lastAttemptFailure = attemptFailure;
217
+ if (this.params.flowMode === 'autopilot') {
218
+ if (attemptFailure?.reasonCode === 'VERIFY_FAILED') {
219
+ this.pendingAutopilotVerification = {
220
+ changedFiles: terminalCtx && 'changedFiles' in terminalCtx
221
+ ? (terminalCtx.changedFiles ?? undefined)
222
+ : undefined,
223
+ };
224
+ }
225
+ else if (!attemptFailure) {
226
+ this.pendingAutopilotVerification = undefined;
227
+ }
228
+ }
215
229
  const entry = {
216
230
  attempt,
217
231
  plan: shrinkCtx?.plan ?? null,
@@ -10,6 +10,7 @@ export async function executeAutopilotFlow(initCtx) {
10
10
  .step('VERIFY_GATE', runAutopilotVerifyGate)
11
11
  .step('REPORT', displayReport);
12
12
  const report = await pipeline.execute();
13
+ await report.data?.toolstack?.dispose?.();
13
14
  report.auditPath = await saveAudit(report, initCtx.options);
14
15
  report.strategyName = initCtx.mode;
15
16
  report.fsMode = initCtx.mode;
@@ -93,6 +93,7 @@ function buildPipelineByMode(initCtx) {
93
93
  export async function executeSalmonLoopFlow(initCtx) {
94
94
  const pipeline = buildPipelineByMode(initCtx);
95
95
  const report = await pipeline.execute();
96
+ await report.data?.toolstack?.dispose?.();
96
97
  // Save audit log
97
98
  report.auditPath = await saveAudit(report, initCtx.options);
98
99
  report.strategyName = initCtx.mode;
@@ -7,10 +7,7 @@ import { MicroTaskRunner } from '../dsl/MicroTaskRunner.js';
7
7
  import { StandardStrategy } from '../dsl/strategies.js';
8
8
  import { Executor } from '../execution/Executor.js';
9
9
  import { WorkerFactory } from '../execution/WorkerFactory.js';
10
- import { CachedService } from '../services/CachedService.js';
11
- import { GitConfigService } from '../services/implementations/default/GitConfigService.js';
12
10
  import { MockLockService } from '../services/implementations/mock/MockLockService.js';
13
- import { MockUserQuotaService } from '../services/implementations/mock/MockUserQuotaService.js';
14
11
  import { registry } from '../services/registry.js';
15
12
  /**
16
13
  * Bootstraps the service registry with required providers.
@@ -18,10 +15,6 @@ import { registry } from '../services/registry.js';
18
15
  export function bootstrapRegistry() {
19
16
  if (!registry.has('remote_lock'))
20
17
  registry.register(new MockLockService());
21
- if (!registry.has('user_quota'))
22
- registry.register(new MockUserQuotaService());
23
- if (!registry.has('git_config'))
24
- registry.register(new CachedService(new GitConfigService()));
25
18
  }
26
19
  export const runApply = async (ctx) => {
27
20
  const { workspace, diff, fileStateResolver, emit } = ctx;
@@ -1,7 +1,7 @@
1
1
  import { createHash } from 'crypto';
2
2
  import { join } from 'path';
3
3
  import { text } from '../../../locales/index.js';
4
- import { lstat, readlink } from '../../adapters/fs/node-fs.js';
4
+ import { lstat, readFile, readdir, readlink } from '../../adapters/fs/node-fs.js';
5
5
  import { GitAdapter } from '../../adapters/git/git-adapter.js';
6
6
  import { LIMITS } from '../../config/limits.js';
7
7
  import { supportsLlmStreaming } from '../../llm/capabilities.js';
@@ -23,6 +23,20 @@ const GIT_HASH_OUTPUT_LIMITS = {
23
23
  maxStdoutBytes: 256,
24
24
  maxStderrChars: 4_096,
25
25
  };
26
+ const FILESYSTEM_SAMPLE_LIMITS = {
27
+ maxEntries: 10_000,
28
+ maxBytesPerFile: 5 * 1024 * 1024,
29
+ };
30
+ const FILESYSTEM_EXCLUDED_SEGMENTS = new Set([
31
+ '.git',
32
+ '.salmonloop',
33
+ 'node_modules',
34
+ '.next',
35
+ 'dist',
36
+ 'build',
37
+ '.turbo',
38
+ '.cache',
39
+ ]);
26
40
  function hashFingerprintValue(value) {
27
41
  return createHash('sha256').update(value).digest('hex');
28
42
  }
@@ -165,6 +179,7 @@ async function captureWorkspaceFingerprint(workspacePath) {
165
179
  }
166
180
  const workingContent = hashFingerprintValue(workingEntries.map(([path, fingerprint]) => `${path}:${fingerprint}`).join('\n'));
167
181
  return {
182
+ kind: 'git',
168
183
  head,
169
184
  index,
170
185
  statusMetadata: hashFingerprintBuffer(statusOutput),
@@ -173,6 +188,79 @@ async function captureWorkspaceFingerprint(workspacePath) {
173
188
  workingEntries,
174
189
  };
175
190
  }
191
+ function isFilesystemSamplingExcludedPath(path) {
192
+ const normalized = path.replace(/\\/g, '/');
193
+ if (isRuntimeGeneratedPath(normalized))
194
+ return true;
195
+ return normalized.split('/').some((segment) => FILESYSTEM_EXCLUDED_SEGMENTS.has(segment));
196
+ }
197
+ function formatFilesystemMode(mode) {
198
+ return (mode & 0o7777).toString(8);
199
+ }
200
+ async function hashFilesystemPath(absolutePath) {
201
+ const stats = await lstat(absolutePath);
202
+ if (stats.isSymbolicLink()) {
203
+ return `symlink:${formatFilesystemMode(stats.mode)}:${hashFingerprintValue(await readlink(absolutePath))}`;
204
+ }
205
+ if (stats.isDirectory()) {
206
+ return `dir:${formatFilesystemMode(stats.mode)}`;
207
+ }
208
+ if (stats.size > FILESYSTEM_SAMPLE_LIMITS.maxBytesPerFile) {
209
+ throw new Error(`Workspace sampling skipped file larger than ${FILESYSTEM_SAMPLE_LIMITS.maxBytesPerFile} bytes`);
210
+ }
211
+ if (stats.isFile()) {
212
+ return `file:${formatFilesystemMode(stats.mode)}:${hashFingerprintBuffer(await readFile(absolutePath))}`;
213
+ }
214
+ return `other:${formatFilesystemMode(stats.mode)}:${stats.size}`;
215
+ }
216
+ function appendFilesystemEntry(entries, path, fingerprint) {
217
+ if (entries.length >= FILESYSTEM_SAMPLE_LIMITS.maxEntries) {
218
+ throw new Error(`Workspace sampling exceeded ${FILESYSTEM_SAMPLE_LIMITS.maxEntries} entries`);
219
+ }
220
+ entries.push([path, fingerprint]);
221
+ }
222
+ async function collectFilesystemEntries(params) {
223
+ const absoluteDir = join(params.workspacePath, params.relativeDir);
224
+ const dirents = await readdir(absoluteDir, { withFileTypes: true });
225
+ const sorted = [...dirents].sort((left, right) => left.name.localeCompare(right.name));
226
+ for (const dirent of sorted) {
227
+ const relativePath = params.relativeDir ? `${params.relativeDir}/${dirent.name}` : dirent.name;
228
+ if (isFilesystemSamplingExcludedPath(relativePath)) {
229
+ continue;
230
+ }
231
+ const absolutePath = join(params.workspacePath, relativePath);
232
+ if (dirent.isDirectory()) {
233
+ appendFilesystemEntry(params.entries, `${relativePath}/`, await hashFilesystemPath(absolutePath));
234
+ await collectFilesystemEntries({
235
+ workspacePath: params.workspacePath,
236
+ relativeDir: relativePath,
237
+ entries: params.entries,
238
+ });
239
+ continue;
240
+ }
241
+ appendFilesystemEntry(params.entries, relativePath, await hashFilesystemPath(absolutePath));
242
+ }
243
+ }
244
+ async function captureFilesystemFingerprint(workspacePath) {
245
+ const workingEntries = [];
246
+ await collectFilesystemEntries({ workspacePath, relativeDir: '', entries: workingEntries });
247
+ const statusMetadata = hashFingerprintValue(workingEntries.map(([path, fingerprint]) => `${path}:${fingerprint}`).join('\n'));
248
+ return {
249
+ kind: 'filesystem',
250
+ head: '',
251
+ index: '',
252
+ statusMetadata,
253
+ workingContent: statusMetadata,
254
+ statusEntries: workingEntries,
255
+ workingEntries,
256
+ };
257
+ }
258
+ async function captureWorkspaceFingerprintForContext(ctx) {
259
+ if (ctx.workspace.capabilities?.git.insideWorkTree === false) {
260
+ return captureFilesystemFingerprint(ctx.workspace.workPath);
261
+ }
262
+ return captureWorkspaceFingerprint(ctx.workspace.workPath);
263
+ }
176
264
  function collectChangedWorkspacePaths(before, after) {
177
265
  const beforeStatusEntries = new Map(before.statusEntries);
178
266
  const afterStatusEntries = new Map(after.statusEntries);
@@ -280,7 +368,7 @@ export async function runAutopilot(ctx) {
280
368
  let samplingFailedClosed = false;
281
369
  if (supportsTools) {
282
370
  try {
283
- workspaceFingerprintBefore = await captureWorkspaceFingerprint(ctx.workspace.workPath);
371
+ workspaceFingerprintBefore = await captureWorkspaceFingerprintForContext(ctx);
284
372
  }
285
373
  catch {
286
374
  samplingFailedClosed = true;
@@ -339,10 +427,11 @@ export async function runAutopilot(ctx) {
339
427
  }
340
428
  else {
341
429
  try {
342
- const workspaceFingerprintAfter = await captureWorkspaceFingerprint(ctx.workspace.workPath);
430
+ const workspaceFingerprintAfter = await captureWorkspaceFingerprintForContext(ctx);
343
431
  changedFiles = collectChangedWorkspacePaths(workspaceFingerprintBefore, workspaceFingerprintAfter);
344
432
  mutated =
345
433
  changedFiles.length > 0 ||
434
+ workspaceFingerprintBefore.statusMetadata !== workspaceFingerprintAfter.statusMetadata ||
346
435
  workspaceFingerprintBefore.head !== workspaceFingerprintAfter.head ||
347
436
  workspaceFingerprintBefore.index !== workspaceFingerprintAfter.index;
348
437
  }
@@ -351,11 +440,23 @@ export async function runAutopilot(ctx) {
351
440
  }
352
441
  }
353
442
  }
443
+ const pendingChangedFiles = ctx.pendingVerification?.changedFiles && ctx.pendingVerification.changedFiles.length > 0
444
+ ? ctx.pendingVerification.changedFiles
445
+ : undefined;
446
+ const effectiveChangedFiles = changedFiles && changedFiles.length > 0
447
+ ? changedFiles
448
+ : pendingChangedFiles && pendingChangedFiles.length > 0
449
+ ? pendingChangedFiles
450
+ : undefined;
451
+ const effectiveMutated = mutated || Boolean(ctx.pendingVerification);
354
452
  return {
355
453
  ...ctx,
356
- mutated,
357
- changedFiles: changedFiles && changedFiles.length > 0 ? changedFiles : undefined,
358
- completion: resolveAutopilotCompletion({ content, mutated, localAudit }),
454
+ mutated: effectiveMutated,
455
+ changedFiles: effectiveChangedFiles,
456
+ pendingVerification: effectiveMutated
457
+ ? { changedFiles: effectiveChangedFiles }
458
+ : ctx.pendingVerification,
459
+ completion: resolveAutopilotCompletion({ content, mutated: effectiveMutated, localAudit }),
359
460
  toolCallingAudit: mergedAudit,
360
461
  report: {
361
462
  kind: 'answer',
@@ -390,6 +491,7 @@ export async function runAutopilotVerifyGate(ctx) {
390
491
  const nextCtx = {
391
492
  ...ctx,
392
493
  verifyResult,
494
+ pendingVerification: verifyResult.ok ? undefined : ctx.pendingVerification,
393
495
  };
394
496
  return verifyArtifact ? { ...nextCtx, verifyArtifact } : nextCtx;
395
497
  }
@@ -3,11 +3,17 @@ import { recordAuditEvent } from '../../observability/audit-trail.js';
3
3
  import { resolveExecutionProfile } from '../../runtime/execution-profile.js';
4
4
  import { createStandardToolstack } from '../../tools/loader.js';
5
5
  import { preflight } from '../../verification/runner.js';
6
+ import { requiresGitWorkspace } from '../../workspace/capabilities.js';
6
7
  import { resolveLlmToolCallingPolicy } from '../dsl/llm-strategy.js';
7
8
  export const runPreflight = async (ctx) => {
8
9
  const executionProfile = resolveExecutionProfile(ctx.mode);
9
10
  const result = await preflight(ctx.workspace, ctx.emit, {
10
11
  ignoreDirty: executionProfile.ignoreDirtyPreflight,
12
+ requireWrite: !executionProfile.readOnly,
13
+ requireGit: requiresGitWorkspace({
14
+ mode: ctx.mode,
15
+ strategy: ctx.workspace.strategy,
16
+ }),
11
17
  });
12
18
  if (!result.ok) {
13
19
  const reason = result.reason || text.loop.preflightFailedNotGit;
@@ -27,12 +33,16 @@ export const runPreflight = async (ctx) => {
27
33
  message: text.loop.preflightPassed,
28
34
  timestamp: new Date(),
29
35
  });
36
+ if (result.capabilities) {
37
+ ctx.workspace.capabilities = result.capabilities;
38
+ }
30
39
  const toolstack = resolveLlmToolCallingPolicy(executionProfile.entryPhase, ctx.options.llm)
31
40
  .enabled
32
41
  ? await createStandardToolstack({
33
42
  repoRoot: ctx.workspace.workPath,
34
43
  persistenceRoot: ctx.workspace.baseRepoPath || ctx.workspace.workPath,
35
44
  worktreeRoot: ctx.workspace.strategy === 'worktree' ? ctx.workspace.workPath : undefined,
45
+ workspaceCapabilities: ctx.workspace.capabilities,
36
46
  attemptId: ctx.attempt ?? 1,
37
47
  dryRun: Boolean(ctx.options?.dryRun),
38
48
  allowedToolNames: Array.isArray(ctx.options.allowedToolNames)
@@ -4,6 +4,7 @@ export function buildPhaseToolRuntimeContext(ctx, phase, cacheSurface) {
4
4
  repoRoot: ctx.workspace.workPath,
5
5
  persistenceRoot: ctx.workspace.baseRepoPath || ctx.workspace.workPath,
6
6
  worktreeRoot: ctx.workspace.strategy === 'worktree' ? ctx.workspace.workPath : undefined,
7
+ workspaceCapabilities: ctx.workspace.capabilities,
7
8
  flowMode: ctx.mode,
8
9
  attemptId: ctx.attempt ?? 1,
9
10
  dryRun: Boolean(ctx.options?.dryRun),