salmon-loop 0.2.13 → 0.2.16

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 (218) hide show
  1. package/dist/cli/argv/headless-detection.js +27 -0
  2. package/dist/cli/chat-flow.js +11 -0
  3. package/dist/cli/chat.js +160 -24
  4. package/dist/cli/commands/chat.js +14 -7
  5. package/dist/cli/commands/flow-mode.js +63 -0
  6. package/dist/cli/commands/registry.js +2 -0
  7. package/dist/cli/commands/run/benchmark-artifacts.js +41 -0
  8. package/dist/cli/commands/run/early-errors.js +23 -0
  9. package/dist/cli/commands/run/handler.js +115 -27
  10. package/dist/cli/commands/run/headless-error-writer.js +8 -0
  11. package/dist/cli/commands/run/loop-params.js +2 -0
  12. package/dist/cli/commands/run/mode.js +2 -5
  13. package/dist/cli/commands/run/parse-options.js +16 -0
  14. package/dist/cli/commands/run/persist-session.js +10 -1
  15. package/dist/cli/commands/run/preflight.js +10 -0
  16. package/dist/cli/commands/run/reporter-factory.js +4 -0
  17. package/dist/cli/commands/run/runtime-llm.js +38 -11
  18. package/dist/cli/commands/run/runtime-options.js +2 -2
  19. package/dist/cli/commands/serve.js +91 -71
  20. package/dist/cli/commands/tool-names.js +78 -78
  21. package/dist/cli/headless/anthropic-stream-normalized-encoder.js +6 -1
  22. package/dist/cli/headless/json-protocol.js +37 -0
  23. package/dist/cli/headless/native-stream-normalized-encoder.js +6 -1
  24. package/dist/cli/headless/protocol-metadata.js +22 -0
  25. package/dist/cli/headless/stream-json-protocol.js +34 -1
  26. package/dist/cli/index.js +6 -4
  27. package/dist/cli/locales/en.js +30 -6
  28. package/dist/cli/program-bootstrap.js +8 -3
  29. package/dist/cli/program-commands.js +5 -1
  30. package/dist/cli/reporters/anthropic-stream.js +7 -1
  31. package/dist/cli/reporters/json.js +4 -0
  32. package/dist/cli/reporters/stream-json.js +17 -2
  33. package/dist/cli/run-cli.js +5 -3
  34. package/dist/cli/slash/runtime.js +27 -12
  35. package/dist/cli/ui/components/CommandInput.js +7 -3
  36. package/dist/cli/ui/components/CommandSuggestionList.js +1 -1
  37. package/dist/cli/utils/command-option-source.js +13 -0
  38. package/dist/cli/utils/verify-resolver.js +8 -4
  39. package/dist/cli/utils/worktree-prepare-resolver.js +7 -3
  40. package/dist/core/adapters/fs/file-adapter.js +6 -0
  41. package/dist/core/adapters/fs/filesystem.js +2 -1
  42. package/dist/core/adapters/git/git-adapter.js +78 -1
  43. package/dist/core/benchmark/patch-artifact.js +124 -0
  44. package/dist/core/benchmark/swe-bench.js +25 -0
  45. package/dist/core/config/load.js +18 -11
  46. package/dist/core/config/resolve-llm.js +12 -0
  47. package/dist/core/config/resolvers/server.js +0 -6
  48. package/dist/core/config/validate.js +73 -21
  49. package/dist/core/context/gatherers/metadata-gatherer.js +1 -0
  50. package/dist/core/context/gatherers/ripgrep-gatherer.js +84 -2
  51. package/dist/core/context/keywords.js +18 -4
  52. package/dist/core/context/service-deps.js +2 -2
  53. package/dist/core/context/service.js +8 -0
  54. package/dist/core/context/steps/context-gather.js +38 -0
  55. package/dist/core/context/summarization/summarizer.js +55 -12
  56. package/dist/core/context/targeting/target-resolver.js +4 -4
  57. package/dist/core/extensions/index.js +23 -5
  58. package/dist/core/extensions/paths.js +31 -0
  59. package/dist/core/extensions/schemas.js +8 -5
  60. package/dist/core/facades/cli-chat.js +6 -2
  61. package/dist/core/facades/cli-command-chat.js +1 -0
  62. package/dist/core/facades/cli-command-tool-names.js +2 -0
  63. package/dist/core/facades/cli-observability.js +1 -1
  64. package/dist/core/facades/cli-run-handler.js +4 -2
  65. package/dist/core/facades/cli-run-persist-session.js +1 -0
  66. package/dist/core/facades/cli-serve.js +2 -4
  67. package/dist/core/facades/cli-utils-worktree.js +1 -1
  68. package/dist/core/failure/diagnostics.js +53 -1
  69. package/dist/core/grizzco/dsl/llm-strategy.js +4 -1
  70. package/dist/core/grizzco/engine/outcome/loop-result-mapper.js +67 -9
  71. package/dist/core/grizzco/engine/pipeline/pipeline.js +6 -2
  72. package/dist/core/grizzco/engine/transaction/attempt-failure.js +90 -15
  73. package/dist/core/grizzco/engine/transaction/report-mapper.js +17 -3
  74. package/dist/core/grizzco/engine/transaction/transaction-runner.js +165 -7
  75. package/dist/core/grizzco/flows/AutopilotFlow.js +18 -0
  76. package/dist/core/grizzco/flows/flow-dispatch.js +11 -0
  77. package/dist/core/grizzco/steps/answer.js +13 -14
  78. package/dist/core/grizzco/steps/autopilot.js +396 -0
  79. package/dist/core/grizzco/steps/cache-sharing.js +29 -0
  80. package/dist/core/grizzco/steps/explore.js +37 -21
  81. package/dist/core/grizzco/steps/generateReview.js +2 -5
  82. package/dist/core/grizzco/steps/patch/apply-check.js +10 -0
  83. package/dist/core/grizzco/steps/patch/diff-normalization.js +70 -0
  84. package/dist/core/grizzco/steps/patch/diff-salvage.js +46 -0
  85. package/dist/core/grizzco/steps/patch/prompt-input.js +42 -0
  86. package/dist/core/grizzco/steps/patch.js +105 -146
  87. package/dist/core/grizzco/steps/plan.js +101 -25
  88. package/dist/core/grizzco/steps/preflight.js +5 -6
  89. package/dist/core/grizzco/steps/request-assembly.js +78 -0
  90. package/dist/core/grizzco/steps/research.js +39 -36
  91. package/dist/core/grizzco/steps/tool-runtime.js +47 -0
  92. package/dist/core/grizzco/steps/verify-shared.js +23 -0
  93. package/dist/core/grizzco/steps/verify.js +13 -21
  94. package/dist/core/llm/ai-sdk/chat-executor.js +2 -0
  95. package/dist/core/llm/ai-sdk/high-level-phase-specs.js +63 -0
  96. package/dist/core/llm/ai-sdk/message-mapper.js +40 -10
  97. package/dist/core/llm/ai-sdk/provider-factory.js +14 -0
  98. package/dist/core/llm/ai-sdk/request-params.js +73 -0
  99. package/dist/core/llm/ai-sdk/result-mapper.js +16 -0
  100. package/dist/core/llm/ai-sdk.js +112 -27
  101. package/dist/core/llm/capabilities.js +12 -0
  102. package/dist/core/llm/contracts/repair.js +36 -30
  103. package/dist/core/llm/errors.js +83 -2
  104. package/dist/core/llm/message-composition.js +7 -22
  105. package/dist/core/llm/phase-router.js +29 -10
  106. package/dist/core/llm/redact.js +28 -3
  107. package/dist/core/llm/registry.js +2 -0
  108. package/dist/core/llm/request-augmentation.js +55 -0
  109. package/dist/core/llm/request-envelope.js +334 -0
  110. package/dist/core/llm/shared-request-assembly.js +35 -0
  111. package/dist/core/llm/stream-utils.js +13 -4
  112. package/dist/core/llm/utils.js +18 -29
  113. package/dist/core/memory/relevant-retrieval.js +144 -0
  114. package/dist/core/observability/logger.js +11 -2
  115. package/dist/core/patch/diff.js +1 -0
  116. package/dist/core/prompts/registry.js +39 -2
  117. package/dist/core/prompts/runtime.js +50 -12
  118. package/dist/core/prompts/templates/phases/patch_user.hbs +2 -5
  119. package/dist/core/prompts/templates/phases/research_user.hbs +11 -0
  120. package/dist/core/prompts/templates/phases/review_user.hbs +3 -0
  121. package/dist/core/prompts/templates/system/answer_system.hbs +5 -0
  122. package/dist/core/prompts/templates/system/autopilot_system.hbs +11 -0
  123. package/dist/core/prompts/templates/system/explore_system.hbs +14 -23
  124. package/dist/core/prompts/templates/system/main_system.hbs +4 -16
  125. package/dist/core/prompts/templates/system/patch_system.hbs +39 -8
  126. package/dist/core/prompts/templates/system/plan_system.hbs +86 -1
  127. package/dist/core/prompts/templates/system/research_system.hbs +2 -0
  128. package/dist/core/protocols/a2a/agent-card.js +3 -2
  129. package/dist/core/protocols/a2a/sdk/executor.js +2 -1
  130. package/dist/core/protocols/a2a/sdk/server.js +0 -1
  131. package/dist/core/protocols/acp/formal-agent.js +74 -51
  132. package/dist/core/protocols/acp/handlers.js +5 -1
  133. package/dist/core/protocols/acp/permission-provider.js +1 -1
  134. package/dist/core/protocols/shared/flow-mode-mapping.js +23 -0
  135. package/dist/core/public-capabilities/flow-mode-metadata.js +39 -0
  136. package/dist/core/public-capabilities/projections.js +29 -0
  137. package/dist/core/public-capabilities/registry.js +26 -0
  138. package/dist/core/public-capabilities/types.js +2 -0
  139. package/dist/core/runtime/agent-server-runtime.js +47 -43
  140. package/dist/core/runtime/execution-profile.js +67 -0
  141. package/dist/core/session/artifact-state.js +160 -0
  142. package/dist/core/session/compaction/index.js +183 -0
  143. package/dist/core/session/compaction/microcompact.js +78 -0
  144. package/dist/core/session/compaction/tracking.js +48 -0
  145. package/dist/core/session/compaction/types.js +11 -0
  146. package/dist/core/session/compression.js +8 -0
  147. package/dist/core/session/manager.js +244 -8
  148. package/dist/core/session/pruning-strategy.js +55 -9
  149. package/dist/core/session/replacement-preview-provider.js +24 -0
  150. package/dist/core/session/replacement-state.js +131 -0
  151. package/dist/core/session/resume-repair/pipeline.js +79 -0
  152. package/dist/core/session/resume-repair/stages/load-raw-archive-state.js +40 -0
  153. package/dist/core/session/resume-repair/stages/reattach-runtime-state.js +8 -0
  154. package/dist/core/session/resume-repair/stages/recover-orphaned-branches.js +10 -0
  155. package/dist/core/session/resume-repair/stages/relink-boundary-and-tail.js +36 -0
  156. package/dist/core/session/resume-repair/stages/replay-startup-hooks.js +23 -0
  157. package/dist/core/session/resume-repair/stages/rescue-stale-metadata.js +17 -0
  158. package/dist/core/session/resume-repair/types.js +2 -0
  159. package/dist/core/session/summary-sync.js +164 -13
  160. package/dist/core/session/token-tracker.js +6 -0
  161. package/dist/core/skills/audit.js +34 -0
  162. package/dist/core/skills/bridge.js +84 -7
  163. package/dist/core/skills/discovery.js +94 -0
  164. package/dist/core/skills/feature-flags.js +52 -0
  165. package/dist/core/skills/index.js +1 -1
  166. package/dist/core/skills/loader.js +195 -20
  167. package/dist/core/skills/parser.js +296 -24
  168. package/dist/core/skills/permissions.js +117 -0
  169. package/dist/core/skills/runtime/MicroTaskRunner.js +10 -4
  170. package/dist/core/skills/runtime/SkillRunner.js +240 -61
  171. package/dist/core/strata/layers/shadow-driver/shadow-driver.js +37 -7
  172. package/dist/core/strata/layers/worktree.js +67 -10
  173. package/dist/core/strata/runtime/synchronizer.js +29 -2
  174. package/dist/core/streaming/stream-assembler.js +75 -31
  175. package/dist/core/sub-agent/context-snapshot.js +156 -0
  176. package/dist/core/sub-agent/core/loop.js +1 -1
  177. package/dist/core/sub-agent/core/manager.js +119 -20
  178. package/dist/core/sub-agent/dispatch-policy.js +29 -0
  179. package/dist/core/sub-agent/prefix-consistency.js +48 -0
  180. package/dist/core/sub-agent/registry-defaults.js +4 -0
  181. package/dist/core/sub-agent/tools/task-spawn.js +79 -2
  182. package/dist/core/sub-agent/types.js +134 -5
  183. package/dist/core/tools/audit.js +13 -4
  184. package/dist/core/tools/builtin/ast-grep.js +1 -1
  185. package/dist/core/tools/builtin/ast.js +1 -1
  186. package/dist/core/tools/builtin/benchmark.js +360 -0
  187. package/dist/core/tools/builtin/code-search/backends/rg.js +2 -1
  188. package/dist/core/tools/builtin/code-search/executor.js +6 -1
  189. package/dist/core/tools/builtin/code-search/spec.js +26 -2
  190. package/dist/core/tools/builtin/fs.js +256 -23
  191. package/dist/core/tools/builtin/git.js +2 -2
  192. package/dist/core/tools/builtin/index.js +51 -2
  193. package/dist/core/tools/builtin/interaction.js +8 -1
  194. package/dist/core/tools/builtin/plan.js +37 -15
  195. package/dist/core/tools/builtin/shell.js +1 -1
  196. package/dist/core/tools/loader.js +39 -16
  197. package/dist/core/tools/mapper.js +17 -3
  198. package/dist/core/tools/parallel/scheduler.js +35 -4
  199. package/dist/core/tools/permissions/permission-rules.js +5 -10
  200. package/dist/core/tools/policy.js +6 -1
  201. package/dist/core/tools/recoverable-tool-errors.js +10 -0
  202. package/dist/core/tools/router.js +24 -6
  203. package/dist/core/tools/session.js +458 -48
  204. package/dist/core/tools/tool-visibility.js +62 -0
  205. package/dist/core/tools/types.js +9 -1
  206. package/dist/core/types/execution.js +4 -0
  207. package/dist/core/types/flow-mode.js +8 -0
  208. package/dist/core/utils/path.js +52 -0
  209. package/dist/core/verification/runner.js +4 -1
  210. package/dist/languages/typescript/index.js +4 -1
  211. package/dist/locales/en.js +35 -2
  212. package/dist/utils/eol.js +1 -1
  213. package/package.json +13 -6
  214. package/scripts/fix-es-abstract-compat.js +77 -0
  215. package/dist/core/runtime/fastify-server-bundle.js +0 -26
  216. package/dist/core/runtime/sidecar-fastify-plugin.js +0 -35
  217. package/dist/core/runtime/sidecar-paths.js +0 -47
  218. package/dist/core/runtime/sidecar-route-catalog.js +0 -103
@@ -1,71 +1,246 @@
1
1
  import os from 'node:os';
2
2
  import path from 'node:path';
3
+ import { text } from '../../locales/index.js';
3
4
  import { syncFs as fs } from '../adapters/fs/node-fs.js';
4
- import { getLogger } from '../observability/logger.js';
5
+ import { tryGetLogger } from '../observability/logger.js';
5
6
  import { SkillParser } from './parser.js';
7
+ /**
8
+ * Safe logger accessor that never throws when the logger is not yet initialized.
9
+ *
10
+ * Falls back to a no-op stub so that loader code can run in test environments
11
+ * or early startup paths where the global logger has not been set.
12
+ */
13
+ function safeLogger() {
14
+ return (tryGetLogger() ?? {
15
+ error: (..._args) => { },
16
+ warn: (..._args) => { },
17
+ info: (..._args) => { },
18
+ debug: (..._args) => { },
19
+ audit: (..._args) => { },
20
+ });
21
+ }
6
22
  export class SkillLoader {
7
23
  options;
24
+ /**
25
+ * Format the skill catalog into a model-consumable disclosure block.
26
+ *
27
+ * Pure data contract: accepts catalog + optional context paths, returns string.
28
+ * No dependency on session or prompt layer.
29
+ *
30
+ * @param catalog - Array of SkillCatalogEntry from loadCatalog()
31
+ * @returns Formatted string with preamble + skill entries, or empty string if
32
+ * no disclosable skills
33
+ * @see Requirements 4.1, 4.6
34
+ */
35
+ static formatCatalogDisclosure(catalog) {
36
+ if (catalog.length === 0) {
37
+ return '';
38
+ }
39
+ const preamble = `## Available Skills\n\n${text.skills.catalogDisclosurePreamble}`;
40
+ const entries = catalog
41
+ .map((entry) => `- **${entry.name}**: ${entry.description}\n Location: ${entry.location}`)
42
+ .join('\n\n');
43
+ return `${preamble}\n\n${entries}\n`;
44
+ }
45
+ /** Cache of fully activated skills (Tier 2). */
46
+ activated = new Map();
47
+ /** Cached catalog entries from the last loadCatalog() call. */
48
+ catalogCache = null;
8
49
  constructor(options) {
9
50
  this.options = options;
10
51
  }
11
- async initialize() {
12
- const inventory = [];
52
+ /**
53
+ * Tier 2: Activate a skill by id — load full SKILL.md content on demand.
54
+ *
55
+ * Looks up the skill in the catalog (loading it first if necessary),
56
+ * reads the full file content, parses it with {@link SkillParser.parse},
57
+ * and caches the result. Subsequent calls for the same id return the
58
+ * cached {@link Skill} without re-reading the file.
59
+ *
60
+ * @param id - The skill identifier (must match a catalog entry)
61
+ * @returns The fully loaded {@link Skill} with instructions
62
+ * @throws Error if the skill id is not found in the catalog
63
+ * @see Requirements 6.2, 6.4
64
+ */
65
+ async activateSkill(id) {
66
+ // Return cached activation if already loaded
67
+ const cached = this.activated.get(id);
68
+ if (cached) {
69
+ return cached;
70
+ }
71
+ // Ensure catalog is available
72
+ if (!this.catalogCache) {
73
+ this.catalogCache = await this.loadCatalog();
74
+ }
75
+ const entry = this.catalogCache.find((e) => e.id === id);
76
+ if (!entry) {
77
+ throw new Error(text.skills.skillNotFoundInCatalog(id));
78
+ }
79
+ // Read full content and parse with SkillParser
80
+ const content = fs.readFileSync(entry.location, 'utf-8');
81
+ const skill = SkillParser.parse(content, entry.location);
82
+ this.activated.set(id, skill);
83
+ safeLogger().info(text.skills.skillActivated(id));
84
+ return skill;
85
+ }
86
+ /**
87
+ * Tier 1: Load a lightweight catalog of all discoverable skills.
88
+ *
89
+ * Parses only YAML frontmatter (name + description + location) without
90
+ * loading full instruction content. Keeps startup context cost at
91
+ * approximately 50-100 tokens per skill.
92
+ *
93
+ * Uses the same search path priority and conflict resolution as
94
+ * {@link initialize}, but avoids reading instruction bodies.
95
+ *
96
+ * @returns Array of {@link SkillCatalogEntry} in discovery priority order
97
+ * @see Requirements 6.1, 6.3
98
+ */
99
+ async loadCatalog() {
100
+ const catalog = [];
13
101
  const seen = new Map();
14
102
  for (const target of this.buildSearchPaths()) {
15
103
  if (!fs.existsSync(target.path))
16
104
  continue;
105
+ const scope = this.labelToScope(target.label);
17
106
  const entries = fs.readdirSync(target.path, { withFileTypes: true });
18
107
  for (const entry of entries) {
19
- let skillFile = null;
20
- if (entry.isDirectory()) {
21
- skillFile = path.join(target.path, entry.name, 'SKILL.md');
108
+ if (!entry.isDirectory())
109
+ continue;
110
+ const skillFile = path.join(target.path, entry.name, 'SKILL.md');
111
+ if (!skillFile || !fs.existsSync(skillFile))
112
+ continue;
113
+ try {
114
+ const content = fs.readFileSync(skillFile, 'utf-8');
115
+ const catalogEntry = SkillParser.parseFrontmatterOnly(content, skillFile, scope);
116
+ if (seen.has(catalogEntry.id)) {
117
+ const firstSource = seen.get(catalogEntry.id);
118
+ safeLogger().warn(`Duplicate skill ${catalogEntry.id} found in ${skillFile}; already loaded from ${firstSource}`);
119
+ safeLogger().audit('SKILL_DUPLICATE_SKIPPED', {
120
+ skillId: catalogEntry.id,
121
+ skippedPath: skillFile,
122
+ firstSource,
123
+ reason: 'first_win_conflict_resolution',
124
+ }, { source: 'skill-loader', severity: 'low', scope: 'repo' });
125
+ continue;
126
+ }
127
+ seen.set(catalogEntry.id, `${target.label}:${skillFile}`);
128
+ catalog.push(catalogEntry);
22
129
  }
23
- else if (entry.name.endsWith('.md')) {
24
- skillFile = path.join(target.path, entry.name);
130
+ catch (err) {
131
+ safeLogger().error(`Failed to load skill catalog entry at ${skillFile}: ${err instanceof Error ? err.message : String(err)}`);
25
132
  }
133
+ }
134
+ }
135
+ this.catalogCache = catalog;
136
+ return catalog;
137
+ }
138
+ async initialize() {
139
+ return this.loadSkillsFromPaths();
140
+ }
141
+ /**
142
+ * Synchronous variant of {@link initialize} for callers that cannot await.
143
+ *
144
+ * Safe because all underlying I/O uses `syncFs` (synchronous fs operations).
145
+ * Used by tool-name discovery for tab completion.
146
+ */
147
+ initializeSync() {
148
+ return this.loadSkillsFromPaths();
149
+ }
150
+ loadSkillsFromPaths() {
151
+ const inventory = [];
152
+ const seen = new Map();
153
+ for (const target of this.buildSearchPaths()) {
154
+ if (!fs.existsSync(target.path))
155
+ continue;
156
+ const entries = fs.readdirSync(target.path, { withFileTypes: true });
157
+ for (const entry of entries) {
158
+ if (!entry.isDirectory())
159
+ continue;
160
+ const skillFile = path.join(target.path, entry.name, 'SKILL.md');
26
161
  if (!skillFile || !fs.existsSync(skillFile))
27
162
  continue;
28
163
  try {
29
164
  const content = fs.readFileSync(skillFile, 'utf-8');
30
165
  const skill = SkillParser.parse(content, skillFile);
31
166
  if (seen.has(skill.id)) {
32
- getLogger().warn(`Duplicate skill ${skill.id} found in ${skillFile}; already loaded from ${seen.get(skill.id)}`);
167
+ const firstSource = seen.get(skill.id);
168
+ safeLogger().warn(`Duplicate skill ${skill.id} found in ${skillFile}; already loaded from ${firstSource}`);
169
+ safeLogger().audit('SKILL_DUPLICATE_SKIPPED', {
170
+ skillId: skill.id,
171
+ skippedPath: skillFile,
172
+ firstSource,
173
+ reason: 'first_win_conflict_resolution',
174
+ }, { source: 'skill-loader', severity: 'low', scope: 'repo' });
33
175
  continue;
34
176
  }
35
177
  seen.set(skill.id, `${target.label}:${skillFile}`);
36
178
  inventory.push(skill);
37
179
  }
38
180
  catch (err) {
39
- getLogger().error(`Failed to load skill at ${skillFile}: ${err instanceof Error ? err.message : String(err)}`);
181
+ safeLogger().error(`Failed to load skill at ${skillFile}: ${err instanceof Error ? err.message : String(err)}`);
40
182
  }
41
183
  }
42
184
  }
43
185
  return inventory;
44
186
  }
187
+ /**
188
+ * Derive the catalog scope from a search path label.
189
+ *
190
+ * Labels follow the pattern `prefix:path` where prefix is one of:
191
+ * - `config` → 'config' scope (extra paths from skills.json)
192
+ * - `repo` → 'repo' scope (repo-level directories)
193
+ * - `user` → 'user' scope (user home directories)
194
+ * - `repo` / others → 'repo' scope
195
+ */
196
+ labelToScope(label) {
197
+ if (label.startsWith('config:'))
198
+ return 'config';
199
+ if (label.startsWith('user:'))
200
+ return 'user';
201
+ return 'repo';
202
+ }
203
+ /**
204
+ * Build the ordered list of skill search paths.
205
+ *
206
+ * Priority (high → low):
207
+ * 1. Config extra paths (from skills.json discovery.paths)
208
+ * 2. {repoRoot}/.salmonloop/skills
209
+ * 3. {repoRoot}/.agents/skills (cross-client interop)
210
+ * 4. ~/.salmonloop/skills
211
+ * 5. ~/.agents/skills (cross-client interop)
212
+ *
213
+ * First-win conflict resolution: when two skills share the same name,
214
+ * the one from the higher-priority path wins and a warning is logged
215
+ * (handled in initialize()).
216
+ */
45
217
  buildSearchPaths() {
46
218
  const paths = [];
219
+ // 1. Config extra paths (highest priority)
47
220
  const extra = this.options.extraPaths ?? [];
48
221
  for (const extraPath of extra) {
49
222
  paths.push({ path: extraPath, label: `config:${extraPath}` });
50
223
  }
224
+ // 2. Repo-level .salmonloop/skills
51
225
  paths.push({
52
226
  path: path.join(this.options.repoRoot, '.salmonloop', 'skills'),
53
227
  label: 'repo:.salmonloop/skills',
54
228
  });
229
+ // 3. Repo-level .agents/skills (cross-client interop)
230
+ paths.push({
231
+ path: path.join(this.options.repoRoot, '.agents', 'skills'),
232
+ label: 'repo:.agents/skills',
233
+ });
234
+ // 4. User-level ~/.salmonloop/skills
55
235
  paths.push({
56
236
  path: path.join(os.homedir(), '.salmonloop', 'skills'),
57
237
  label: 'user:~/.salmonloop/skills',
58
238
  });
59
- if (this.options.useDefaults ?? true) {
60
- paths.push({
61
- path: path.join(os.homedir(), '.claude', 'skills'),
62
- label: 'compat:~/.claude/skills',
63
- });
64
- paths.push({
65
- path: path.join(this.options.repoRoot, '.claude', 'skills'),
66
- label: 'compat:.claude/skills',
67
- });
68
- }
239
+ // 5. User-level ~/.agents/skills (cross-client interop)
240
+ paths.push({
241
+ path: path.join(os.homedir(), '.agents', 'skills'),
242
+ label: 'user:~/.agents/skills',
243
+ });
69
244
  const deduped = [];
70
245
  const seen = new Set();
71
246
  for (const entry of paths) {
@@ -1,37 +1,225 @@
1
- import { getLogger } from '../observability/logger.js';
1
+ import path from 'node:path';
2
+ import { parse as parseYaml } from 'yaml';
3
+ import { z } from 'zod';
4
+ import { text } from '../../locales/index.js';
5
+ import { tryGetLogger } from '../observability/logger.js';
6
+ /**
7
+ * Safe logger accessor that never throws when the logger is not yet initialized.
8
+ *
9
+ * Falls back to a no-op stub so that parser code can run in test environments
10
+ * or early startup paths where the global logger has not been set.
11
+ */
12
+ function safeLogger() {
13
+ return (tryGetLogger() ?? {
14
+ error: (..._args) => { },
15
+ warn: (..._args) => { },
16
+ info: (..._args) => { },
17
+ debug: (..._args) => { },
18
+ audit: (..._args) => { },
19
+ });
20
+ }
21
+ /**
22
+ * Naming convention regex for AgentSkills spec compliance.
23
+ *
24
+ * AgentSkills spec: "unicode lowercase alphanumeric characters (a-z) and
25
+ * hyphens (-)". We accept Unicode lowercase letters (\p{Ll}) and Unicode
26
+ * digits (\p{N}) in addition to ASCII, using the `u` flag for Unicode
27
+ * property escapes.
28
+ */
29
+ const SKILL_NAME_REGEX = /^[\p{Ll}\p{N}](?:[\p{Ll}\p{N}-]*[\p{Ll}\p{N}])?$/u;
30
+ /** Shared field definitions defined by the Agent Skills specification. */
31
+ const sharedFields = {
32
+ license: z.string().optional(),
33
+ compatibility: z.string().max(500).optional(),
34
+ metadata: z.record(z.string(), z.string()).optional(),
35
+ // AgentSkills spec: "Space-delimited list of pre-approved tools" (Experimental).
36
+ // YAML bare key (`allowed-tools:`) parses as null — normalize to undefined so
37
+ // that Zod's `.optional()` accepts it as "not declared".
38
+ 'allowed-tools': z.preprocess((val) => {
39
+ if (val === null || val === undefined)
40
+ return undefined;
41
+ return val;
42
+ }, z.string().optional()),
43
+ };
44
+ /**
45
+ * Strict Zod schema for SKILL.md frontmatter validation.
46
+ *
47
+ * Enforces AgentSkills naming conventions and type correctness:
48
+ * - `name`: 1-64 chars, lowercase alphanumeric + hyphens, no leading/trailing/consecutive hyphens
49
+ * - `description`: 1-1024 chars, non-empty
50
+ * @see Requirements 5.1, 5.2, 5.4, 5.5
51
+ */
52
+ export const SkillFrontmatterSchema = z
53
+ .object({
54
+ name: z
55
+ .string()
56
+ .min(1)
57
+ .max(64)
58
+ .regex(SKILL_NAME_REGEX, 'name must be unicode lowercase alphanumeric + hyphens per AgentSkills spec')
59
+ .refine((s) => !s.includes('--'), 'name must not contain consecutive hyphens'),
60
+ description: z.string().min(1).max(1024),
61
+ ...sharedFields,
62
+ })
63
+ .strict();
64
+ /**
65
+ * Maximum allowed length for a single extracted command (in characters).
66
+ *
67
+ * This limit mitigates denial-of-service via extremely long command strings and
68
+ * reduces the blast radius of any injection that bypasses pattern checks.
69
+ * The value (4096) aligns with common OS `ARG_MAX` per-argument limits.
70
+ *
71
+ * @security Guard — enforced inside {@link SkillParser.extractCommands}.
72
+ */
73
+ export const COMMAND_MAX_LENGTH = 4096;
74
+ /** Control character range that must not appear in commands (C0 subset excluding tab, LF, CR). */
75
+ // eslint-disable-next-line no-control-regex
76
+ const CONTROL_CHAR_PATTERN = /[\x00-\x08\x0e-\x1f]/;
77
+ /**
78
+ * Default deny-list of dangerous shell patterns.
79
+ *
80
+ * Each regex targets a well-known destructive or injection-prone idiom:
81
+ *
82
+ * | Pattern | Threat |
83
+ * |-----------------------|-------------------------------------------------|
84
+ * | `rm -rf /` | Recursive root deletion |
85
+ * | `curl … \| sh` | Remote code execution via piped download |
86
+ * | `\beval\b` | Arbitrary code evaluation in shell |
87
+ * | `\bexec\b.*<` | Process replacement with redirected input |
88
+ *
89
+ * The list is intentionally conservative — it catches blatant patterns but does
90
+ * NOT attempt full shell-syntax analysis. Callers may supply their own list via
91
+ * the `dangerousPatterns` parameter of {@link SkillParser.extractCommands}.
92
+ *
93
+ * @security Guard — applied after length and control-character checks.
94
+ */
95
+ export const DEFAULT_DANGEROUS_PATTERNS = [
96
+ /rm\s+-rf\s+\//,
97
+ /curl\s.*\|\s*sh/,
98
+ /\beval\b/,
99
+ /\bexec\b.*</,
100
+ ];
2
101
  export class SkillParser {
102
+ /**
103
+ * Parse a SKILL.md file with YAML frontmatter validation.
104
+ *
105
+ * Uses the `yaml` library for robust YAML parsing and Zod schema for
106
+ * field validation and type coercion. Throws on missing or invalid
107
+ * frontmatter instead of silently falling back.
108
+ *
109
+ * @param content - Raw SKILL.md file content
110
+ * @param filePath - Absolute or relative path to the SKILL.md file
111
+ * @throws Error if frontmatter is missing, YAML is malformed, or schema validation fails
112
+ * @see Requirements 1.1, 1.2, 1.3, 1.5, 1.6, 1.8, 1.9, 1.10
113
+ */
3
114
  static parse(content, filePath) {
4
- // COMPLIANCE: Lightweight parsing instead of heavy gray-matter
5
- const yamlRegex = /^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]*)$/;
115
+ // Accept frontmatter with or without a body after the closing ---.
116
+ // The body capture is optional to avoid rejecting minimal SKILL.md files
117
+ // that contain only frontmatter (no trailing newline or instruction text).
118
+ const yamlRegex = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n([\s\S]*))?$/;
6
119
  const match = content.match(yamlRegex);
7
120
  if (!match) {
8
- getLogger().error(`Failed to parse skill at ${filePath}: Missing frontmatter`);
9
- return {
10
- id: filePath,
11
- path: filePath,
12
- metadata: {},
13
- rawContent: content,
14
- instructions: content.trim(),
15
- };
121
+ const msg = text.skills.missingFrontmatter(filePath);
122
+ safeLogger().error(msg);
123
+ throw new Error(msg);
16
124
  }
17
125
  const yamlRaw = match[1];
18
- const instructions = match[2];
19
- const data = {};
20
- // Simple key-value parser for basic frontmatter
21
- yamlRaw.split('\n').forEach((line) => {
22
- const [key, ...value] = line.split(':');
23
- if (key && value) {
24
- data[key.trim()] = value.join(':').trim();
25
- }
26
- });
126
+ const instructions = match[2] ?? '';
127
+ let parsed;
128
+ try {
129
+ parsed = parseYaml(yamlRaw);
130
+ }
131
+ catch (error) {
132
+ const reason = error instanceof Error ? error.message : String(error);
133
+ const msg = text.skills.yamlParseError(filePath, reason);
134
+ safeLogger().error(msg);
135
+ throw new Error(msg);
136
+ }
137
+ if (parsed == null || typeof parsed !== 'object') {
138
+ const msg = text.skills.missingFrontmatter(filePath);
139
+ safeLogger().error(msg);
140
+ throw new Error(msg);
141
+ }
142
+ const result = SkillFrontmatterSchema.safeParse(parsed);
143
+ if (!result.success) {
144
+ const issues = result.error.issues.map((i) => `${i.path.join('.')}: ${i.message}`).join('; ');
145
+ const msg = text.skills.invalidFrontmatter(filePath, issues);
146
+ safeLogger().error(msg);
147
+ throw new Error(msg);
148
+ }
149
+ const data = result.data;
150
+ const parentDir = path.basename(path.dirname(filePath));
151
+ if (parentDir && parentDir !== '.' && parentDir !== data.name) {
152
+ const msg = text.skills.nameDirMismatch(filePath, parentDir, data.name);
153
+ safeLogger().error(msg);
154
+ throw new Error(msg);
155
+ }
27
156
  return {
28
- id: data.name || filePath,
157
+ id: data.name,
29
158
  path: filePath,
30
159
  metadata: data,
31
160
  rawContent: content,
32
161
  instructions: instructions.trim(),
33
162
  };
34
163
  }
164
+ /**
165
+ * Parse only the YAML frontmatter of a SKILL.md file (Tier 1 catalog loading).
166
+ *
167
+ * Extracts name, description, and optional conditional paths without reading
168
+ * the full instruction body. This keeps startup context cost at approximately
169
+ * 50-100 tokens per skill.
170
+ *
171
+ * @param content - Raw SKILL.md file content
172
+ * @param filePath - Absolute or relative path to the SKILL.md file
173
+ * @param scope - Discovery scope for the catalog entry
174
+ * @returns A lightweight {@link SkillCatalogEntry} or throws on invalid frontmatter
175
+ * @see Requirements 1.1, 1.2, 1.3, 1.5, 1.6, 6.1, 6.3
176
+ */
177
+ static parseFrontmatterOnly(content, filePath, scope) {
178
+ const yamlRegex = /^---\r?\n([\s\S]*?)\r?\n---/;
179
+ const match = content.match(yamlRegex);
180
+ if (!match) {
181
+ const msg = text.skills.missingFrontmatter(filePath);
182
+ safeLogger().error(msg);
183
+ throw new Error(msg);
184
+ }
185
+ const yamlRaw = match[1];
186
+ let parsed;
187
+ try {
188
+ parsed = parseYaml(yamlRaw);
189
+ }
190
+ catch (error) {
191
+ const reason = error instanceof Error ? error.message : String(error);
192
+ const msg = text.skills.yamlParseError(filePath, reason);
193
+ safeLogger().error(msg);
194
+ throw new Error(msg);
195
+ }
196
+ if (parsed == null || typeof parsed !== 'object') {
197
+ const msg = text.skills.missingFrontmatter(filePath);
198
+ safeLogger().error(msg);
199
+ throw new Error(msg);
200
+ }
201
+ const result = SkillFrontmatterSchema.safeParse(parsed);
202
+ if (!result.success) {
203
+ const issues = result.error.issues.map((i) => `${i.path.join('.')}: ${i.message}`).join('; ');
204
+ const msg = text.skills.invalidFrontmatter(filePath, issues);
205
+ safeLogger().error(msg);
206
+ throw new Error(msg);
207
+ }
208
+ const data = result.data;
209
+ const parentDir = path.basename(path.dirname(filePath));
210
+ if (parentDir && parentDir !== '.' && parentDir !== data.name) {
211
+ const msg = text.skills.nameDirMismatch(filePath, parentDir, data.name);
212
+ safeLogger().error(msg);
213
+ throw new Error(msg);
214
+ }
215
+ return {
216
+ id: data.name,
217
+ name: data.name,
218
+ description: data.description,
219
+ location: filePath,
220
+ scope,
221
+ };
222
+ }
35
223
  static substituteVariables(template, args) {
36
224
  let result = template;
37
225
  // Sort keys by length descending to prevent shorter keys from replacing parts of longer keys
@@ -56,11 +244,95 @@ export class SkillParser {
56
244
  }
57
245
  return result;
58
246
  }
59
- static extractCommands(instructions) {
60
- // Matches !sh command or !command
247
+ /**
248
+ * Extract shell commands from skill instruction markdown.
249
+ *
250
+ * ## Regex: `/^!(?:sh\s+)?(.*)$/gm`
251
+ *
252
+ * ### What it matches
253
+ * Lines that begin with `!` are treated as command directives. Two forms are
254
+ * recognised:
255
+ *
256
+ * - `!sh <command>` — explicit shell prefix (the `sh ` prefix is consumed,
257
+ * only `<command>` is captured).
258
+ * - `!<command>` — shorthand without the `sh` keyword.
259
+ *
260
+ * The `m` (multiline) flag makes `^` / `$` match per-line, so every command
261
+ * line in a multi-line instruction block is extracted independently.
262
+ *
263
+ * ### What it intentionally excludes
264
+ * - Lines that do NOT start with `!` (regular markdown prose).
265
+ * - The `!` prefix itself and the optional `sh ` token — only the payload
266
+ * after them is captured in group 1.
267
+ * - Empty captures are filtered out after extraction (`trim().length > 0`).
268
+ *
269
+ * ### Security implications
270
+ *
271
+ * 1. **Greedy `(.*)` capture** — the capture group accepts any character
272
+ * (except newline) without restriction. This means the regex alone does
273
+ * NOT prevent shell metacharacters, variable expansion, pipes, or
274
+ * subshell invocations from appearing in the captured command.
275
+ *
276
+ * 2. **Multiline mode (`m` flag)** — each line is evaluated independently.
277
+ * An attacker cannot splice two lines into a single command via the regex
278
+ * itself, but embedded newlines within a single logical line (e.g. via
279
+ * `\n` literals in a YAML value) would not be caught by `^…$` anchors.
280
+ * The control-character filter below mitigates this.
281
+ *
282
+ * 3. **No shell-syntax parsing** — the regex performs plain text extraction;
283
+ * it has no awareness of quoting, escaping, or shell grammar. Security
284
+ * therefore relies on the downstream guard chain, NOT on the regex.
285
+ *
286
+ * ### Downstream security guards (defense-in-depth)
287
+ *
288
+ * The following guards are applied sequentially after extraction to mitigate
289
+ * the risks above:
290
+ *
291
+ * | Guard | Constant / Pattern | Purpose |
292
+ * |--------------------------|-----------------------------|--------------------------------------------|
293
+ * | Max length | {@link COMMAND_MAX_LENGTH} | Caps command size to 4096 chars |
294
+ * | Control-char rejection | `CONTROL_CHAR_PATTERN` | Blocks C0 control chars (except tab/LF/CR) |
295
+ * | Dangerous-pattern filter | {@link DEFAULT_DANGEROUS_PATTERNS} | Rejects known destructive idioms |
296
+ * | Audit logging | `SKILL_COMMANDS_EXTRACTED` | Logs all surviving commands for review |
297
+ *
298
+ * @param instructions - Raw skill instruction text (may contain markdown).
299
+ * @param dangerousPatterns - Optional override for the dangerous-pattern
300
+ * deny-list. Defaults to {@link DEFAULT_DANGEROUS_PATTERNS}.
301
+ * @returns Array of sanitised command strings ready for governed execution
302
+ * via ToolRouter.
303
+ *
304
+ * @security Requirement 8.4 — command extraction regex documented with
305
+ * security implications.
306
+ */
307
+ static extractCommands(instructions, dangerousPatterns = DEFAULT_DANGEROUS_PATTERNS) {
61
308
  const commandRegex = /^!(?:sh\s+)?(.*)$/gm;
62
309
  const matches = instructions.matchAll(commandRegex);
63
- return Array.from(matches, (m) => m[1].trim()).filter((cmd) => cmd.length > 0);
310
+ const raw = Array.from(matches, (m) => m[1].trim()).filter((cmd) => cmd.length > 0);
311
+ const logger = tryGetLogger();
312
+ const safe = raw.filter((cmd) => {
313
+ if (cmd.length > COMMAND_MAX_LENGTH) {
314
+ logger?.warn(`Skill command rejected: exceeds max length (${cmd.length} > ${COMMAND_MAX_LENGTH})`);
315
+ return false;
316
+ }
317
+ if (CONTROL_CHAR_PATTERN.test(cmd)) {
318
+ logger?.warn('Skill command rejected: contains control characters');
319
+ return false;
320
+ }
321
+ const matched = dangerousPatterns.find((p) => p.test(cmd));
322
+ if (matched) {
323
+ logger?.warn(`Skill command rejected: matches dangerous pattern ${matched}`);
324
+ return false;
325
+ }
326
+ return true;
327
+ });
328
+ // Audit: log all commands that will be executed
329
+ if (safe.length > 0) {
330
+ logger?.audit('SKILL_COMMANDS_EXTRACTED', {
331
+ commandCount: safe.length,
332
+ commands: safe,
333
+ }, { source: 'skill-parser', severity: 'low', scope: 'session' });
334
+ }
335
+ return safe;
64
336
  }
65
337
  }
66
338
  //# sourceMappingURL=parser.js.map