salmon-loop 0.3.2 → 0.5.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 (227) hide show
  1. package/dist/cli/authorization/non-interactive.js +9 -13
  2. package/dist/cli/authorization/provider.js +2 -10
  3. package/dist/cli/chat.js +12 -6
  4. package/dist/cli/commands/allowlist.js +1 -1
  5. package/dist/cli/commands/chat.js +13 -13
  6. package/dist/cli/commands/config.js +2 -2
  7. package/dist/cli/commands/mode.js +2 -2
  8. package/dist/cli/commands/parallel.js +1 -1
  9. package/dist/cli/commands/run/handler.js +9 -4
  10. package/dist/cli/commands/run/loop-params.js +2 -0
  11. package/dist/cli/commands/run/parse-options.js +14 -26
  12. package/dist/cli/commands/run/runtime-llm.js +15 -12
  13. package/dist/cli/commands/run/runtime-options.js +3 -1
  14. package/dist/cli/config.js +0 -8
  15. package/dist/cli/headless/openai-responses-canonical-applier.js +1 -7
  16. package/dist/cli/locales/en.js +2 -2
  17. package/dist/cli/reporters/standard.js +12 -3
  18. package/dist/cli/reporters/stream-json.js +2 -1
  19. package/dist/cli/slash/runtime.js +2 -2
  20. package/dist/cli/ui/hooks/useLoopEvents.js +1 -1
  21. package/dist/cli/ui/hooks/useLoopState.js +1 -1
  22. package/dist/core/adapters/fs/file-adapter.js +3 -1
  23. package/dist/core/adapters/git/git-adapter.js +6 -3
  24. package/dist/core/adapters/git/git-runner.js +5 -2
  25. package/dist/core/adapters/git/lock-manager.js +7 -4
  26. package/dist/core/ast/parser.js +18 -9
  27. package/dist/core/checkpoint-domain/manifest-store.js +21 -13
  28. package/dist/core/checkpoint-domain/service.js +3 -1
  29. package/dist/core/config/limits.js +1 -1
  30. package/dist/core/config/model-pricing.js +61 -0
  31. package/dist/core/config/schema.js +738 -0
  32. package/dist/core/config/validate.js +11 -922
  33. package/dist/core/context/ast/skeleton-extractor.js +225 -0
  34. package/dist/core/context/ast/source-outline.js +24 -1
  35. package/dist/core/context/budget/dynamic-adjuster.js +20 -5
  36. package/dist/core/context/builder.js +7 -3
  37. package/dist/core/context/cache/store-factory.js +3 -1
  38. package/dist/core/context/dependencies.js +2 -1
  39. package/dist/core/context/effectiveness/persistence.js +50 -0
  40. package/dist/core/context/effectiveness/tracker.js +24 -0
  41. package/dist/core/context/gatherers/architecture-gatherer.js +2 -1
  42. package/dist/core/context/gatherers/artifact-gatherer.js +7 -4
  43. package/dist/core/context/gatherers/ast-gatherer.js +34 -40
  44. package/dist/core/context/gatherers/ghost-dependency-gatherer.js +0 -1
  45. package/dist/core/context/gatherers/git-history-gatherer.js +3 -1
  46. package/dist/core/context/gatherers/knowledge-gatherer.js +21 -2
  47. package/dist/core/context/gatherers/metadata-gatherer.js +12 -7
  48. package/dist/core/context/gatherers/ripgrep-gatherer.js +6 -3
  49. package/dist/core/context/service.js +12 -2
  50. package/dist/core/context/steps/context-gather.js +14 -3
  51. package/dist/core/context/steps/context-targets.js +1 -0
  52. package/dist/core/context/targeting/target-resolver.js +29 -11
  53. package/dist/core/context/token/cache.js +5 -2
  54. package/dist/core/context/token/encoding-registry.js +7 -6
  55. package/dist/core/context/truncation/strategies/json.js +5 -2
  56. package/dist/core/context/truncation/type-detector.js +3 -1
  57. package/dist/core/extensions/index.js +48 -3
  58. package/dist/core/extensions/load.js +3 -2
  59. package/dist/core/extensions/merge.js +5 -1
  60. package/dist/core/extensions/paths.js +8 -2
  61. package/dist/core/extensions/schemas.js +21 -0
  62. package/dist/core/facades/cli-authorization-provider.js +1 -0
  63. package/dist/core/facades/cli-command-chat.js +2 -0
  64. package/dist/core/facades/cli-run-handler.js +1 -0
  65. package/dist/core/facades/cli-utils-serialize.js +2 -0
  66. package/dist/core/feedback/parsers.js +290 -1
  67. package/dist/core/grizzco/dsl/llm-strategy.js +4 -3
  68. package/dist/core/grizzco/engine/observability/loop-telemetry.js +5 -2
  69. package/dist/core/grizzco/engine/outcome/loop-result-mapper.js +30 -13
  70. package/dist/core/grizzco/engine/pipeline/pipeline.js +149 -240
  71. package/dist/core/grizzco/engine/transaction/attempt-failure.js +49 -24
  72. package/dist/core/grizzco/engine/transaction/authorization-summary.js +2 -1
  73. package/dist/core/grizzco/engine/transaction/transaction-runner.js +40 -34
  74. package/dist/core/grizzco/execution/RejectionManager.js +7 -5
  75. package/dist/core/grizzco/runtime/apply-back-runtime.js +5 -2
  76. package/dist/core/grizzco/services/implementations/default/GitConfigService.js +2 -1
  77. package/dist/core/grizzco/services/registry.js +18 -0
  78. package/dist/core/grizzco/steps/audit.js +20 -10
  79. package/dist/core/grizzco/steps/autopilot.js +21 -32
  80. package/dist/core/grizzco/steps/display-report.js +4 -11
  81. package/dist/core/grizzco/steps/explore.js +14 -4
  82. package/dist/core/grizzco/steps/generateReview.js +3 -1
  83. package/dist/core/grizzco/steps/patch/prompt-input.js +4 -1
  84. package/dist/core/grizzco/steps/patch.js +1 -0
  85. package/dist/core/grizzco/steps/plan.js +58 -49
  86. package/dist/core/grizzco/steps/research.js +3 -1
  87. package/dist/core/grizzco/steps/tool-runtime.js +3 -0
  88. package/dist/core/grizzco/steps/verify.js +7 -1
  89. package/dist/core/grizzco/validation/AstValidationService.js +3 -1
  90. package/dist/core/grizzco/workers/strata-sync-worker.js +2 -1
  91. package/dist/core/history/input-history.js +3 -1
  92. package/dist/core/intent/chat-intent.js +3 -1
  93. package/dist/core/llm/ai-sdk/message-mapper.js +37 -26
  94. package/dist/core/llm/ai-sdk/request-params.js +2 -6
  95. package/dist/core/llm/ai-sdk/result-mapper.js +14 -8
  96. package/dist/core/llm/ai-sdk/retry-classifier.js +17 -7
  97. package/dist/core/llm/ai-sdk/retry-executor.js +1 -1
  98. package/dist/core/llm/contracts/repair.js +16 -8
  99. package/dist/core/llm/errors.js +18 -14
  100. package/dist/core/llm/output-policy.js +8 -0
  101. package/dist/core/llm/redact.js +1 -3
  102. package/dist/core/llm/retry-utils.js +8 -2
  103. package/dist/core/llm/stream-utils.js +5 -3
  104. package/dist/core/llm/sub-agent-factory.js +51 -0
  105. package/dist/core/llm/tool-calling-stub.js +48 -0
  106. package/dist/core/llm/utils.js +17 -6
  107. package/dist/core/mcp/bridge/prompt-command-provider.js +4 -3
  108. package/dist/core/mcp/bridge/resource-context-provider.js +3 -1
  109. package/dist/core/mcp/bridge/tool-bridge.js +5 -14
  110. package/dist/core/mcp/catalog/discovery.js +3 -1
  111. package/dist/core/mcp/client/connection-manager.js +7 -4
  112. package/dist/core/mcp/client/transport-factory.js +7 -3
  113. package/dist/core/mcp/host/sampling-provider.js +1 -1
  114. package/dist/core/mcp/schema/json-schema-to-zod.js +2 -1
  115. package/dist/core/memory/relevant-retrieval.js +6 -4
  116. package/dist/core/observability/audit-file.js +2 -1
  117. package/dist/core/observability/audit-trail.js +3 -1
  118. package/dist/core/observability/authorization-decisions.js +13 -12
  119. package/dist/core/observability/error-mapping.js +2 -1
  120. package/dist/core/observability/logger.js +2 -1
  121. package/dist/core/observability/monitor.js +24 -0
  122. package/dist/core/observability/run-outcome-reporter.js +1 -0
  123. package/dist/core/observability/token-usage.js +5 -4
  124. package/dist/core/permission-gate/default-gate.js +5 -8
  125. package/dist/core/plan/storage.js +7 -4
  126. package/dist/core/plugin/loader.js +8 -5
  127. package/dist/core/prompts/registry.js +12 -30
  128. package/dist/core/prompts/runtime.js +3 -1
  129. package/dist/core/prompts/templates/system/autopilot_system.hbs +28 -4
  130. package/dist/core/protocols/a2a/sdk/executor.js +3 -1
  131. package/dist/core/protocols/a2a/sdk/server.js +5 -4
  132. package/dist/core/protocols/acp/acp-command-runner.js +7 -6
  133. package/dist/core/protocols/acp/acp-session-persistence.js +13 -10
  134. package/dist/core/protocols/acp/formal-agent.js +13 -6
  135. package/dist/core/protocols/acp/permission-provider.js +3 -2
  136. package/dist/core/protocols/acp/stdio-server.js +6 -6
  137. package/dist/core/reflection/engine.js +114 -14
  138. package/dist/core/runtime/agent-server-runtime.js +3 -2
  139. package/dist/core/runtime/batch-runner.js +81 -0
  140. package/dist/core/runtime/initialize.js +71 -6
  141. package/dist/core/runtime/loop-finalize.js +3 -0
  142. package/dist/core/runtime/loop-session-runner.js +5 -0
  143. package/dist/core/runtime/loop.js +4 -0
  144. package/dist/core/runtime/paths.js +9 -6
  145. package/dist/core/runtime/spawn-interactive.js +5 -4
  146. package/dist/core/security/redaction.js +3 -2
  147. package/dist/core/session/compaction/index.js +4 -3
  148. package/dist/core/session/compression.js +3 -1
  149. package/dist/core/session/manager.js +26 -38
  150. package/dist/core/session/pruning-strategy.js +2 -1
  151. package/dist/core/session/token-tracker.js +27 -9
  152. package/dist/core/skills/parser.js +3 -2
  153. package/dist/core/skills/permissions.js +2 -2
  154. package/dist/core/skills/runtime/MicroTaskRunner.js +1 -1
  155. package/dist/core/skills/runtime/SkillRunner.js +5 -2
  156. package/dist/core/slash/steps/slash-execute.js +7 -5
  157. package/dist/core/slash/strategy.js +1 -1
  158. package/dist/core/strata/checkpoint/manager.js +16 -10
  159. package/dist/core/strata/checkpoint/snapshot-create.js +5 -4
  160. package/dist/core/strata/checkpoint/snapshot-write-tree.js +7 -3
  161. package/dist/core/strata/engine/shadow-merge-engine.js +4 -2
  162. package/dist/core/strata/interaction/file-system-provider.js +2 -1
  163. package/dist/core/strata/layers/file-state-resolver.js +9 -7
  164. package/dist/core/strata/layers/immutable-git-layer.js +3 -1
  165. package/dist/core/strata/layers/shadow-driver/readonly-lock.js +8 -6
  166. package/dist/core/strata/layers/shadow-driver/shadow-driver.js +2 -1
  167. package/dist/core/strata/layers/worktree.js +9 -10
  168. package/dist/core/strata/runtime/environment.js +2 -1
  169. package/dist/core/strata/runtime/synchronizer.js +28 -26
  170. package/dist/core/streaming/canonical/parts-from-llm-stream-chunk.js +1 -11
  171. package/dist/core/structured-output/json-extract.js +3 -1
  172. package/dist/core/structured-output/json-schema-validator.js +1 -13
  173. package/dist/core/sub-agent/artifacts/store.js +2 -1
  174. package/dist/core/sub-agent/context-snapshot.js +12 -6
  175. package/dist/core/sub-agent/controller.js +70 -1
  176. package/dist/core/sub-agent/core/loop.js +25 -3
  177. package/dist/core/sub-agent/core/manager.js +343 -117
  178. package/dist/core/sub-agent/registry-defaults.js +12 -0
  179. package/dist/core/sub-agent/registry.js +8 -0
  180. package/dist/core/sub-agent/summary.js +96 -0
  181. package/dist/core/sub-agent/team.js +98 -0
  182. package/dist/core/sub-agent/tools/task-await.js +109 -0
  183. package/dist/core/sub-agent/tools/task-spawn.js +52 -7
  184. package/dist/core/sub-agent/tools/team.js +92 -0
  185. package/dist/core/sub-agent/types.js +11 -2
  186. package/dist/core/target-runtime/profile.js +3 -1
  187. package/dist/core/tools/audit.js +3 -2
  188. package/dist/core/tools/budget.js +7 -12
  189. package/dist/core/tools/builtin/ast.js +144 -0
  190. package/dist/core/tools/builtin/code-search/backends/powershell.js +3 -1
  191. package/dist/core/tools/builtin/code-search/backends/rg.js +3 -1
  192. package/dist/core/tools/builtin/code-search/executor.js +46 -43
  193. package/dist/core/tools/builtin/code-search/parse/plain-grep.js +3 -1
  194. package/dist/core/tools/builtin/code-search/parse/rg-json.js +3 -1
  195. package/dist/core/tools/builtin/fs.js +90 -7
  196. package/dist/core/tools/builtin/git.js +242 -0
  197. package/dist/core/tools/builtin/glob.js +79 -0
  198. package/dist/core/tools/builtin/index.js +53 -111
  199. package/dist/core/tools/builtin/interaction.js +13 -15
  200. package/dist/core/tools/builtin/knowledge.js +146 -4
  201. package/dist/core/tools/builtin/proposal.js +14 -3
  202. package/dist/core/tools/builtin/verify.js +35 -3
  203. package/dist/core/tools/capability/executor.js +5 -5
  204. package/dist/core/tools/headless-payload.js +1 -3
  205. package/dist/core/tools/mapper.js +8 -42
  206. package/dist/core/tools/parallel/persistence.js +17 -5
  207. package/dist/core/tools/parallel/scheduler.js +23 -21
  208. package/dist/core/tools/permissions/permission-rules.js +69 -115
  209. package/dist/core/tools/plugins/loader.js +4 -3
  210. package/dist/core/tools/router.js +112 -58
  211. package/dist/core/tools/session.js +64 -102
  212. package/dist/core/tools/streaming/ToolCallAccumulator.js +1 -3
  213. package/dist/core/tools/tool-visibility.js +2 -1
  214. package/dist/core/tools/types.js +10 -0
  215. package/dist/core/types/batch.js +2 -0
  216. package/dist/core/utils/error.js +79 -0
  217. package/dist/core/utils/sanitizer.js +5 -2
  218. package/dist/core/utils/serialize.js +66 -0
  219. package/dist/core/utils/zod.js +29 -0
  220. package/dist/core/verification/detect-runner.js +86 -0
  221. package/dist/core/verification/runner.js +76 -0
  222. package/dist/core/version.js +3 -1
  223. package/dist/core/workspace/capabilities.js +3 -2
  224. package/dist/integrations/langfuse/litellm-langfuse-outcome-reporter.js +9 -8
  225. package/dist/languages/python/index.js +154 -0
  226. package/dist/locales/en.js +8 -1
  227. package/package.json +2 -1
@@ -1,7 +1,9 @@
1
1
  import { text } from '../../../locales/index.js';
2
+ import { getLogger } from '../../observability/logger.js';
2
3
  import { normalizeDiff, validateDiff } from '../../patch/diff.js';
3
4
  import { ArtifactStore } from '../../sub-agent/artifacts/store.js';
4
5
  import { normalizeRepoRelativePath } from '../../utils/path.js';
6
+ import { isRecord } from '../../utils/serialize.js';
5
7
  const DEFAULT_TOOL_ALIASES = {
6
8
  bash: 'Bash',
7
9
  read: 'Read',
@@ -218,57 +220,66 @@ function compilePathMatcher(specifier) {
218
220
  matches: (repoRelativePath) => re.test(normalizeRepoRelativePath(repoRelativePath)),
219
221
  };
220
222
  }
223
+ const TOOL_CATEGORY = {
224
+ Bash: 'bash',
225
+ bash: 'bash',
226
+ 'shell.exec': 'bash',
227
+ 'test.run': 'bash',
228
+ Edit: 'edit',
229
+ edit: 'edit',
230
+ 'proposal.apply': 'edit',
231
+ Read: 'path',
232
+ read: 'path',
233
+ LS: 'path',
234
+ ls: 'path',
235
+ 'fs.read': 'path',
236
+ 'code.read': 'path',
237
+ 'git.cat': 'path',
238
+ 'fs.list': 'path',
239
+ 'fs.list_directory': 'path',
240
+ 'fs.list_files': 'path',
241
+ 'artifact.read': 'path',
242
+ };
243
+ function resolveToolCategory(tool) {
244
+ if (TOOL_CATEGORY[tool])
245
+ return TOOL_CATEGORY[tool];
246
+ if (isAliasToolName(tool)) {
247
+ const alias = DEFAULT_TOOL_ALIASES[tool.toLowerCase()];
248
+ return TOOL_CATEGORY[alias];
249
+ }
250
+ return undefined;
251
+ }
221
252
  function compileRule(effect, parsed) {
222
- const tool = parsed.tool;
223
- const specifier = parsed.specifier;
224
- const asAlias = typeof tool === 'string' && isAliasToolName(tool) ? tool : null;
225
- const shouldTreatAsBash = tool === 'Bash' ||
226
- tool === 'bash' ||
227
- tool === 'shell.exec' ||
228
- tool === 'test.run' ||
229
- asAlias === 'Bash';
230
- const shouldTreatAsEdit = tool === 'Edit' || tool === 'edit' || tool === 'proposal.apply' || asAlias === 'Edit';
231
- const shouldTreatAsPath = tool === 'Read' ||
232
- tool === 'read' ||
233
- tool === 'LS' ||
234
- tool === 'ls' ||
235
- tool === 'fs.read' ||
236
- tool === 'code.read' ||
237
- tool === 'git.cat' ||
238
- tool === 'fs.list' ||
239
- tool === 'fs.list_directory' ||
240
- tool === 'fs.list_files' ||
241
- tool === 'artifact.read' ||
242
- asAlias === 'Read' ||
243
- asAlias === 'LS';
244
- if (shouldTreatAsBash) {
253
+ const { tool, raw, specifier } = parsed;
254
+ const category = resolveToolCategory(tool);
255
+ if (category === 'bash') {
245
256
  return {
246
257
  effect,
247
258
  tool,
248
- raw: parsed.raw,
259
+ raw,
249
260
  specifier,
250
261
  compiled: { kind: 'bash', matcher: compileBashMatcher(specifier) },
251
262
  };
252
263
  }
253
- if (shouldTreatAsEdit) {
264
+ if (category === 'edit') {
254
265
  return {
255
266
  effect,
256
267
  tool,
257
- raw: parsed.raw,
268
+ raw,
258
269
  specifier,
259
270
  compiled: { kind: 'edit', matcher: compilePathMatcher(specifier) },
260
271
  };
261
272
  }
262
- if (shouldTreatAsPath) {
273
+ if (category === 'path') {
263
274
  return {
264
275
  effect,
265
276
  tool,
266
- raw: parsed.raw,
277
+ raw,
267
278
  specifier,
268
279
  compiled: { kind: 'path', matcher: compilePathMatcher(specifier) },
269
280
  };
270
281
  }
271
- return { effect, tool, raw: parsed.raw, specifier, compiled: { kind: 'tool_any' } };
282
+ return { effect, tool, raw, specifier, compiled: { kind: 'tool_any' } };
272
283
  }
273
284
  function buildVisibleToolNamesFromAllow(allowRules) {
274
285
  const visible = new Set();
@@ -353,85 +364,42 @@ async function loadProposalChangedFiles(handle) {
353
364
  const meta = validateDiff(normalized);
354
365
  return meta.changedFiles ?? [];
355
366
  }
356
- catch {
367
+ catch (error) {
368
+ getLogger().warn(`[PermissionRules] Failed to load proposal changed files: ${error instanceof Error ? error.message : String(error)}`);
357
369
  return null;
358
370
  }
359
371
  }
360
- function matchAllowRule(rule, toolName, args) {
372
+ function matchRule(rule, toolName, args) {
361
373
  if (!toolMatchesRuleTool(toolName, rule.tool))
362
374
  return false;
363
- if (rule.compiled.kind === 'tool_any')
364
- return true;
365
- if (rule.compiled.kind === 'bash') {
366
- const cmd = extractCommandArg(toolName, args);
367
- if (!cmd)
368
- return false;
369
- return rule.compiled.matcher.matches(cmd);
370
- }
371
- if (rule.compiled.kind === 'path') {
372
- const p = extractPrimaryPathArg(toolName, args);
373
- if (!p)
374
- return false;
375
- return rule.compiled.matcher.matches(p);
376
- }
377
- if (rule.compiled.kind === 'edit') {
378
- // Edit allow rules are handled by an async path-aware matcher.
379
- return toolName === 'proposal.apply';
375
+ switch (rule.compiled.kind) {
376
+ case 'tool_any':
377
+ return true;
378
+ case 'bash': {
379
+ const cmd = extractCommandArg(toolName, args);
380
+ return cmd ? rule.compiled.matcher.matches(cmd) : false;
381
+ }
382
+ case 'path': {
383
+ const p = extractPrimaryPathArg(toolName, args);
384
+ return p ? rule.compiled.matcher.matches(p) : false;
385
+ }
386
+ case 'edit':
387
+ // Edit rules require async file-level matching; sync check is a gate.
388
+ return toolName === 'proposal.apply';
380
389
  }
381
- return false;
382
390
  }
383
- async function matchAllowEditRule(rule, args) {
391
+ async function matchEditRule(rule, args, filePredicate) {
384
392
  if (rule.compiled.kind !== 'edit')
385
393
  return false;
386
- const matcher = rule.compiled.matcher;
387
- if (!args || typeof args !== 'object' || Array.isArray(args))
394
+ if (!isRecord(args))
388
395
  return false;
389
396
  const handle = args.handle;
390
397
  if (typeof handle !== 'string' || !handle.trim())
391
398
  return false;
392
399
  const changedFiles = await loadProposalChangedFiles(handle);
393
- if (!changedFiles)
394
- return false;
395
- if (changedFiles.length === 0)
400
+ if (!changedFiles || changedFiles.length === 0)
396
401
  return false;
397
- return changedFiles.every((p) => matcher.matches(p));
398
- }
399
- async function matchDenyEditRule(rule, args) {
400
- if (rule.compiled.kind !== 'edit')
401
- return false;
402
- const matcher = rule.compiled.matcher;
403
- if (!args || typeof args !== 'object' || Array.isArray(args))
404
- return false;
405
- const handle = args.handle;
406
- if (typeof handle !== 'string' || !handle.trim())
407
- return false;
408
- const changedFiles = await loadProposalChangedFiles(handle);
409
- if (!changedFiles)
410
- return false;
411
- return changedFiles.some((p) => matcher.matches(p));
412
- }
413
- function matchDenyRule(rule, toolName, args) {
414
- if (!toolMatchesRuleTool(toolName, rule.tool))
415
- return false;
416
- if (rule.compiled.kind === 'tool_any')
417
- return true;
418
- if (rule.compiled.kind === 'bash') {
419
- const cmd = extractCommandArg(toolName, args);
420
- if (!cmd)
421
- return false;
422
- return rule.compiled.matcher.matches(cmd);
423
- }
424
- if (rule.compiled.kind === 'path') {
425
- const p = extractPrimaryPathArg(toolName, args);
426
- if (!p)
427
- return false;
428
- return rule.compiled.matcher.matches(p);
429
- }
430
- if (rule.compiled.kind === 'edit') {
431
- // Deny edit rules are handled by an async path-aware matcher.
432
- return toolName === 'proposal.apply';
433
- }
434
- return false;
402
+ return filePredicate(changedFiles, rule.compiled.matcher);
435
403
  }
436
404
  export async function decidePermissionForToolCall(options) {
437
405
  const rules = options.rules;
@@ -442,17 +410,10 @@ export async function decidePermissionForToolCall(options) {
442
410
  }
443
411
  // Deny rules win.
444
412
  for (const rule of rules.deny) {
445
- if (rule.compiled.kind === 'edit') {
446
- if (options.toolName === 'proposal.apply' && (await matchDenyEditRule(rule, options.args))) {
447
- return {
448
- kind: 'deny',
449
- reason: text.tools.permissionRuleDenied(rule.raw),
450
- rule: { effect: 'deny', raw: rule.raw, tool: rule.tool },
451
- };
452
- }
453
- continue;
454
- }
455
- if (matchDenyRule(rule, options.toolName, options.args)) {
413
+ const isDeny = rule.compiled.kind === 'edit'
414
+ ? await matchEditRule(rule, options.args, (files, matcher) => files.some((p) => matcher.matches(p)))
415
+ : matchRule(rule, options.toolName, options.args);
416
+ if (isDeny) {
456
417
  return {
457
418
  kind: 'deny',
458
419
  reason: text.tools.permissionRuleDenied(rule.raw),
@@ -461,17 +422,10 @@ export async function decidePermissionForToolCall(options) {
461
422
  }
462
423
  }
463
424
  for (const rule of rules.allow) {
464
- if (rule.compiled.kind === 'edit') {
465
- if (options.toolName === 'proposal.apply' && (await matchAllowEditRule(rule, options.args))) {
466
- return {
467
- kind: 'allow',
468
- reason: rule.raw,
469
- rule: { effect: 'allow', raw: rule.raw, tool: rule.tool },
470
- };
471
- }
472
- continue;
473
- }
474
- if (matchAllowRule(rule, options.toolName, options.args)) {
425
+ const isAllow = rule.compiled.kind === 'edit'
426
+ ? await matchEditRule(rule, options.args, (files, matcher) => files.every((p) => matcher.matches(p)))
427
+ : matchRule(rule, options.toolName, options.args);
428
+ if (isAllow) {
475
429
  return {
476
430
  kind: 'allow',
477
431
  reason: rule.raw,
@@ -3,6 +3,7 @@ import { pathToFileURL } from 'node:url';
3
3
  import { syncFs as fs } from '../../adapters/fs/node-fs.js';
4
4
  import { getLogger } from '../../observability/logger.js';
5
5
  import { Phase } from '../../types/runtime.js';
6
+ import { errorMessage } from '../../utils/error.js';
6
7
  const FORBIDDEN_PHASES = new Set([
7
8
  Phase.PLAN,
8
9
  Phase.PATCH,
@@ -25,7 +26,7 @@ export async function registerPluginTools(registry, plugins) {
25
26
  }
26
27
  }
27
28
  catch (error) {
28
- getLogger().warn(`Plugin ${plugin.id} path ${entryPoint} is not accessible: ${error instanceof Error ? error.message : String(error)}`);
29
+ getLogger().warn(`Plugin ${plugin.id} path ${entryPoint} is not accessible: ${errorMessage(error)}`);
29
30
  continue;
30
31
  }
31
32
  const moduleUrl = pathToFileURL(entryPoint).href;
@@ -35,7 +36,7 @@ export async function registerPluginTools(registry, plugins) {
35
36
  manifest = (mod.default ?? mod);
36
37
  }
37
38
  catch (error) {
38
- getLogger().error(`Failed to import plugin ${plugin.id} from ${entryPoint}: ${error instanceof Error ? error.message : String(error)}`);
39
+ getLogger().error(`Failed to import plugin ${plugin.id} from ${entryPoint}: ${errorMessage(error)}`);
39
40
  continue;
40
41
  }
41
42
  const registerFn = manifest?.register;
@@ -52,7 +53,7 @@ export async function registerPluginTools(registry, plugins) {
52
53
  tools = await registerFn();
53
54
  }
54
55
  catch (error) {
55
- getLogger().error(`Plugin ${pluginId} register() failed: ${error instanceof Error ? error.message : String(error)}`);
56
+ getLogger().error(`Plugin ${pluginId} register() failed: ${errorMessage(error)}`);
56
57
  continue;
57
58
  }
58
59
  if (!Array.isArray(tools)) {
@@ -1,7 +1,12 @@
1
1
  import * as crypto from 'crypto';
2
+ import path from 'path';
2
3
  import { z } from 'zod';
4
+ import { readFile } from '../adapters/fs/node-fs.js';
5
+ import { AstParser } from '../ast/parser.js';
3
6
  import { LIMITS } from '../config/limits.js';
4
7
  import { getLogger } from '../observability/logger.js';
8
+ import { isRecord } from '../utils/serialize.js';
9
+ import { unwrapZodSchema } from '../utils/zod.js';
5
10
  import { decidePermissionForToolCall } from './permissions/permission-rules.js';
6
11
  export class ToolRouter {
7
12
  registry;
@@ -14,32 +19,7 @@ export class ToolRouter {
14
19
  authorizationMode;
15
20
  permissionRules;
16
21
  unwrapForHint(schema) {
17
- let current = schema;
18
- for (let depth = 0; depth < 20; depth++) {
19
- const ZodEffects = z.ZodEffects;
20
- if (typeof ZodEffects === 'function' && current instanceof ZodEffects) {
21
- current = current._def.schema;
22
- continue;
23
- }
24
- if (current instanceof z.ZodPipe) {
25
- current = current._def.out;
26
- continue;
27
- }
28
- if (current instanceof z.ZodOptional) {
29
- current = current._def.innerType;
30
- continue;
31
- }
32
- if (current instanceof z.ZodNullable) {
33
- current = current._def.innerType;
34
- continue;
35
- }
36
- if (current instanceof z.ZodDefault) {
37
- current = current._def.innerType;
38
- continue;
39
- }
40
- break;
41
- }
42
- return current;
22
+ return unwrapZodSchema(schema);
43
23
  }
44
24
  buildInputHint(spec) {
45
25
  if (!spec.inputSchema)
@@ -115,7 +95,7 @@ export class ToolRouter {
115
95
  const message = hint
116
96
  ? `${inputCheck.message} (${hint})`
117
97
  : inputCheck.message || 'Invalid input';
118
- throw { code: 'INVALID_INPUT', message };
98
+ throw Object.assign(new Error(message), { code: 'INVALID_INPUT' });
119
99
  }
120
100
  const normalizedArgs = inputCheck.value ?? envelope.args;
121
101
  const normalizedEnvelope = normalizedArgs === envelope.args ? envelope : { ...envelope, args: normalizedArgs };
@@ -133,7 +113,7 @@ export class ToolRouter {
133
113
  ctx: normalizedEnvelope.ctx,
134
114
  });
135
115
  if (permissionDecision.kind === 'deny') {
136
- const result = this.createErrorResult(normalizedEnvelope, startedAt, 'denied', 'PERMISSION_RULE_DENY', permissionDecision.reason, {
116
+ const result = this.createErrorResult(normalizedEnvelope, startedAt, 'denied', 'PERMISSION_RULE_DENY', permissionDecision.reason ?? 'Permission denied', {
137
117
  authorization: {
138
118
  outcome: 'deny',
139
119
  reason: permissionDecision.reason,
@@ -178,7 +158,9 @@ export class ToolRouter {
178
158
  },
179
159
  });
180
160
  // Provide a stable token for challenge-response UIs.
181
- result.error.confirmToken = auth.challenge;
161
+ if (isRecord(result.error)) {
162
+ result.error.confirmToken = auth.challenge;
163
+ }
182
164
  this.audit.onEnd(result);
183
165
  return result;
184
166
  }
@@ -199,7 +181,7 @@ export class ToolRouter {
199
181
  // 5. Output Validation & Sanitize: Result validation and sensitive summary
200
182
  const sanitized = this.sanitizer.sanitizeOutput(spec, rawOutput);
201
183
  if (!sanitized.ok) {
202
- throw { code: 'INVALID_OUTPUT', message: sanitized.message };
184
+ throw Object.assign(new Error(sanitized.message), { code: 'INVALID_OUTPUT' });
203
185
  }
204
186
  // 6. Return Standard Result (ok)
205
187
  const durationMs = Date.now() - startedAt;
@@ -213,6 +195,12 @@ export class ToolRouter {
213
195
  outputSummary: sanitized.summary,
214
196
  durationMs,
215
197
  };
198
+ // Per-edit syntax guard: check for syntax errors after file writes
199
+ const syntaxWarnings = await checkPostEditSyntax(spec, normalizedEnvelope.args, rawOutput, normalizedEnvelope.ctx);
200
+ if (syntaxWarnings.length > 0) {
201
+ result.warnings = syntaxWarnings;
202
+ result.meta = { ...result.meta, syntaxWarning: true };
203
+ }
216
204
  this.audit.onEnd(result);
217
205
  return result;
218
206
  }
@@ -222,28 +210,18 @@ export class ToolRouter {
222
210
  let errorMeta;
223
211
  if (e instanceof Error) {
224
212
  errorMessage = e.message;
225
- if ('code' in e && typeof e.code === 'string') {
226
- errorCode = e.code;
227
- }
228
- if ('interrupt' in e) {
229
- errorMeta = { ...(errorMeta ?? {}), interrupt: e.interrupt };
230
- }
231
- if ('inputRequired' in e) {
232
- errorMeta = { ...(errorMeta ?? {}), inputRequired: e.inputRequired };
233
- }
234
213
  }
235
- else if (e && typeof e === 'object') {
236
- if ('message' in e && typeof e.message === 'string') {
237
- errorMessage = e.message;
214
+ const errObj = isRecord(e) ? e : null;
215
+ if (errObj) {
216
+ if (typeof errObj.message === 'string')
217
+ errorMessage = errObj.message;
218
+ if (typeof errObj.code === 'string')
219
+ errorCode = errObj.code;
220
+ if ('interrupt' in errObj) {
221
+ errorMeta = { ...(errorMeta ?? {}), interrupt: errObj.interrupt };
238
222
  }
239
- if ('code' in e && typeof e.code === 'string') {
240
- errorCode = e.code;
241
- }
242
- if ('interrupt' in e) {
243
- errorMeta = { ...(errorMeta ?? {}), interrupt: e.interrupt };
244
- }
245
- if ('inputRequired' in e) {
246
- errorMeta = { ...(errorMeta ?? {}), inputRequired: e.inputRequired };
223
+ if ('inputRequired' in errObj) {
224
+ errorMeta = { ...(errorMeta ?? {}), inputRequired: errObj.inputRequired };
247
225
  }
248
226
  }
249
227
  const result = this.createErrorResult(envelope, startedAt, errorCode === 'TIMEOUT' ? 'timeout' : 'error', errorCode, errorMessage, errorMeta);
@@ -291,7 +269,7 @@ export class ToolRouter {
291
269
  ctx: normalizedEnvelope.ctx,
292
270
  });
293
271
  if (permissionDecision.kind === 'deny') {
294
- const toolResult = this.createErrorResult(normalizedEnvelope, startedAt, 'denied', 'PERMISSION_RULE_DENY', permissionDecision.reason, {
272
+ const toolResult = this.createErrorResult(normalizedEnvelope, startedAt, 'denied', 'PERMISSION_RULE_DENY', permissionDecision.reason ?? 'Permission denied', {
295
273
  authorization: {
296
274
  outcome: 'deny',
297
275
  reason: permissionDecision.reason,
@@ -315,7 +293,7 @@ export class ToolRouter {
315
293
  source: spec.source,
316
294
  phase: normalizedEnvelope.phase,
317
295
  riskLevel: spec.riskLevel,
318
- sideEffects: (spec.sideEffects || []),
296
+ sideEffects: spec.sideEffects,
319
297
  argsSummary,
320
298
  argsHash,
321
299
  repoRoot: normalizedEnvelope.ctx.repoRoot,
@@ -333,7 +311,9 @@ export class ToolRouter {
333
311
  source: 'user',
334
312
  },
335
313
  });
336
- toolResult.error.confirmToken = deferred.challenge;
314
+ if (isRecord(toolResult.error)) {
315
+ toolResult.error.confirmToken = deferred.challenge;
316
+ }
337
317
  return {
338
318
  kind: 'pending',
339
319
  message: deferred.message,
@@ -408,7 +388,8 @@ export class ToolRouter {
408
388
  return raw;
409
389
  return `${raw.slice(0, maxLength)}...`;
410
390
  }
411
- catch {
391
+ catch (error) {
392
+ getLogger().debug(`[ToolRouter] Failed to summarize args: ${error instanceof Error ? error.message : String(error)}`);
412
393
  return '[Unserializable]';
413
394
  }
414
395
  }
@@ -421,7 +402,8 @@ export class ToolRouter {
421
402
  // Truncation to 16 hex was insufficient collision resistance for security use.
422
403
  return crypto.createHash('sha256').update(raw).digest('hex');
423
404
  }
424
- catch {
405
+ catch (error) {
406
+ getLogger().debug(`[ToolRouter] Failed to hash args: ${error instanceof Error ? error.message : String(error)}`);
425
407
  return undefined;
426
408
  }
427
409
  }
@@ -450,7 +432,7 @@ export class ToolRouter {
450
432
  source: spec.source,
451
433
  phase: envelope.phase,
452
434
  riskLevel: spec.riskLevel,
453
- sideEffects: (spec.sideEffects || []),
435
+ sideEffects: (spec.sideEffects ?? []),
454
436
  argsSummary,
455
437
  argsHash,
456
438
  repoRoot: envelope.ctx.repoRoot,
@@ -511,7 +493,7 @@ export class ToolRouter {
511
493
  }
512
494
  async getAuthorizationArgsSummary(envelope, spec) {
513
495
  const fallback = this.summarizeArgs(envelope.args);
514
- const summarize = spec?.summarizeArgsForAuthorization;
496
+ const summarize = spec.summarizeArgsForAuthorization;
515
497
  if (typeof summarize !== 'function')
516
498
  return fallback;
517
499
  // Best-effort only. Avoid hanging authorization prompts on slow IO.
@@ -524,9 +506,81 @@ export class ToolRouter {
524
506
  ]);
525
507
  return typeof result === 'string' && result.trim() ? result : fallback;
526
508
  }
527
- catch {
509
+ catch (error) {
510
+ getLogger().debug(`[ToolRouter] Failed to get authorization args summary: ${error instanceof Error ? error.message : String(error)}`);
528
511
  return fallback;
529
512
  }
530
513
  }
531
514
  }
515
+ /**
516
+ * Per-edit syntax guard: after a file write, parse the file with tree-sitter
517
+ * and return syntax error warnings. Non-blocking — returns empty array on
518
+ * any failure (missing grammar, parse error, etc.).
519
+ */
520
+ async function checkPostEditSyntax(spec, args, rawOutput, ctx) {
521
+ if (spec.name !== 'fs.write_file' && spec.name !== 'fs.edit_file')
522
+ return [];
523
+ if (!isRecord(rawOutput) || typeof rawOutput.path !== 'string')
524
+ return [];
525
+ const filePath = rawOutput.path;
526
+ let content;
527
+ if (spec.name === 'fs.write_file') {
528
+ if (!isRecord(args) || typeof args.content !== 'string')
529
+ return [];
530
+ content = args.content;
531
+ }
532
+ else {
533
+ // fs.edit_file — read post-edit content from disk
534
+ try {
535
+ const absolutePath = path.resolve(ctx.repoRoot, filePath);
536
+ content = await readFile(absolutePath, 'utf-8');
537
+ }
538
+ catch (error) {
539
+ getLogger().debug(`[ToolRouter] Failed to read file for post-edit syntax check: ${error instanceof Error ? error.message : String(error)}`);
540
+ return [];
541
+ }
542
+ }
543
+ // Detect language from extension
544
+ const ext = path.extname(filePath).toLowerCase().replace('.', '');
545
+ if (!ext)
546
+ return [];
547
+ // Only check for languages that have tree-sitter support
548
+ const plugin = ctx.languagePlugins?.getByExtension(`.${ext}`);
549
+ if (!plugin)
550
+ return [];
551
+ try {
552
+ const tree = await AstParser.parse(content, plugin.meta.id);
553
+ if (!tree?.rootNode)
554
+ return [];
555
+ const errors = collectSyntaxErrors(tree.rootNode);
556
+ if (errors.length === 0)
557
+ return [];
558
+ return [
559
+ `Syntax warning in ${filePath}: ${errors.length} error(s) detected — ` +
560
+ errors
561
+ .slice(0, 3)
562
+ .map((e) => `line ${e.line}: ${e.text}`)
563
+ .join('; '),
564
+ ];
565
+ }
566
+ catch (error) {
567
+ // Tree-sitter parse failed (no grammar, etc.) — silently skip
568
+ getLogger().debug(`[ToolRouter] Post-edit syntax check parse failed: ${error instanceof Error ? error.message : String(error)}`);
569
+ return [];
570
+ }
571
+ }
572
+ function collectSyntaxErrors(node, errors = [], depth = 0) {
573
+ if (depth > 50)
574
+ return errors; // prevent stack overflow
575
+ if (node.type === 'ERROR' || node.isMissing) {
576
+ errors.push({
577
+ line: (node.startPosition?.row ?? 0) + 1,
578
+ text: node.text?.slice(0, 80) ?? node.type,
579
+ });
580
+ }
581
+ for (const child of node.children ?? []) {
582
+ collectSyntaxErrors(child, errors, depth + 1);
583
+ }
584
+ return errors;
585
+ }
532
586
  //# sourceMappingURL=router.js.map