@gajae-code/coding-agent 0.16.7 → 0.17.1

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 (321) hide show
  1. package/CHANGELOG.md +180 -1
  2. package/dist/types/capability/fs.d.ts +37 -4
  3. package/dist/types/capability/index.d.ts +19 -0
  4. package/dist/types/capability/types.d.ts +9 -0
  5. package/dist/types/cli/doctor/args.d.ts +36 -0
  6. package/dist/types/cli/doctor/checks.d.ts +18 -0
  7. package/dist/types/cli/doctor/config-repairs.d.ts +38 -0
  8. package/dist/types/cli/doctor/context.d.ts +89 -0
  9. package/dist/types/cli/doctor/files.d.ts +27 -0
  10. package/dist/types/cli/doctor/ids.d.ts +20 -0
  11. package/dist/types/cli/doctor/install-repairs.d.ts +94 -0
  12. package/dist/types/cli/doctor/journal.d.ts +43 -0
  13. package/dist/types/cli/doctor/managed-link.d.ts +39 -0
  14. package/dist/types/cli/doctor/native-probe.d.ts +2 -0
  15. package/dist/types/cli/doctor/permission-repairs.d.ts +25 -0
  16. package/dist/types/cli/doctor/plugin-quarantine.d.ts +77 -0
  17. package/dist/types/cli/doctor/plugin-restore.d.ts +40 -0
  18. package/dist/types/cli/doctor/plugin-targets.d.ts +11 -0
  19. package/dist/types/cli/doctor/probe-client.d.ts +9 -0
  20. package/dist/types/cli/doctor/probe-types.d.ts +18 -0
  21. package/dist/types/cli/doctor/probe.d.ts +3 -0
  22. package/dist/types/cli/doctor/projection-probe.d.ts +2 -0
  23. package/dist/types/cli/doctor/repairs.d.ts +28 -0
  24. package/dist/types/cli/doctor/report.d.ts +27 -0
  25. package/dist/types/cli/doctor/runner.d.ts +4 -0
  26. package/dist/types/cli/doctor/safety.d.ts +10 -0
  27. package/dist/types/cli/doctor/service-repairs.d.ts +8 -0
  28. package/dist/types/cli/doctor/service-targets.d.ts +41 -0
  29. package/dist/types/cli/doctor/types.d.ts +145 -0
  30. package/dist/types/cli/doctor-cli.d.ts +7 -0
  31. package/dist/types/cli/doctor-supervisor.d.ts +121 -0
  32. package/dist/types/cli/doctor-worker.d.ts +29 -0
  33. package/dist/types/cli/install-activation.d.ts +158 -0
  34. package/dist/types/cli/macos-community-app.d.ts +41 -0
  35. package/dist/types/cli/mcp-cli.d.ts +0 -11
  36. package/dist/types/cli/update-cli.d.ts +41 -4
  37. package/dist/types/cli-main.d.ts +174 -0
  38. package/dist/types/cli.d.ts +1 -171
  39. package/dist/types/commands/doctor.d.ts +7 -0
  40. package/dist/types/commands/sdk.d.ts +17 -8
  41. package/dist/types/config/autorouting-tier-map.d.ts +19 -0
  42. package/dist/types/config/file-lock.d.ts +27 -3
  43. package/dist/types/config/model-preset-registry.d.ts +1 -0
  44. package/dist/types/config/model-registry.d.ts +12 -5
  45. package/dist/types/config/model-resolver.d.ts +16 -3
  46. package/dist/types/config/models-config-schema.d.ts +2 -0
  47. package/dist/types/config/settings-schema.d.ts +1 -1
  48. package/dist/types/coordinator-mcp/question-gate-codec.d.ts +1 -1
  49. package/dist/types/coordinator-mcp/session-reaper.d.ts +15 -5
  50. package/dist/types/cursor.d.ts +9 -9
  51. package/dist/types/discovery/agents-md.d.ts +6 -1
  52. package/dist/types/discovery/helpers.d.ts +36 -7
  53. package/dist/types/discovery/index.d.ts +1 -1
  54. package/dist/types/discovery/mcp-json.d.ts +2 -0
  55. package/dist/types/extensibility/extensions/loader.d.ts +22 -1
  56. package/dist/types/extensibility/gjc-plugins/lifecycle.d.ts +14 -1
  57. package/dist/types/extensibility/gjc-plugins/paths.d.ts +2 -1
  58. package/dist/types/extensibility/gjc-plugins/types.d.ts +25 -0
  59. package/dist/types/extensibility/plugins/manager.d.ts +33 -4
  60. package/dist/types/extensibility/plugins/marketplace/cache.d.ts +6 -0
  61. package/dist/types/extensibility/plugins/marketplace/manager.d.ts +186 -0
  62. package/dist/types/extensibility/plugins/marketplace/registry.d.ts +27 -0
  63. package/dist/types/extensibility/plugins/marketplace/source-resolver.d.ts +16 -1
  64. package/dist/types/extensibility/runtime-skill-discovery.d.ts +16 -3
  65. package/dist/types/gjc-runtime/autoresearch-runtime.d.ts +6 -0
  66. package/dist/types/gjc-runtime/launch-worktree.d.ts +53 -0
  67. package/dist/types/gjc-runtime/session-layout.d.ts +8 -0
  68. package/dist/types/gjc-runtime/session-state-lock.d.ts +15 -0
  69. package/dist/types/gjc-runtime/session-state-sidecar.d.ts +13 -0
  70. package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +6 -0
  71. package/dist/types/main.d.ts +19 -1
  72. package/dist/types/modes/acp/acp-agent.d.ts +40 -0
  73. package/dist/types/modes/components/agent-dashboard.d.ts +1 -0
  74. package/dist/types/modes/components/model-selector.d.ts +2 -0
  75. package/dist/types/modes/interactive-mode.d.ts +1 -1
  76. package/dist/types/runtime-mcp/config-writer.d.ts +47 -0
  77. package/dist/types/runtime-mcp/policy-json-edit.d.ts +60 -0
  78. package/dist/types/runtime-mcp/startup-policy.d.ts +4 -0
  79. package/dist/types/runtime-mcp/types.d.ts +2 -2
  80. package/dist/types/sdk/acp/adapter.d.ts +5 -1
  81. package/dist/types/sdk/broker/broker.d.ts +51 -0
  82. package/dist/types/sdk/broker/daemon-entry.d.ts +39 -0
  83. package/dist/types/sdk/broker/discovery.d.ts +38 -0
  84. package/dist/types/sdk/broker/doctor-restart.d.ts +79 -0
  85. package/dist/types/sdk/broker/endpoint-authority.d.ts +7 -0
  86. package/dist/types/sdk/broker/ensure.d.ts +14 -0
  87. package/dist/types/sdk/broker/lifecycle.d.ts +2 -0
  88. package/dist/types/sdk/broker/process-incarnation.d.ts +50 -0
  89. package/dist/types/sdk/broker/runtime.d.ts +26 -0
  90. package/dist/types/sdk/broker/session-index.d.ts +13 -4
  91. package/dist/types/sdk/broker/worktree-occupancy.d.ts +14 -0
  92. package/dist/types/sdk/bus/chat-daemon-cli.d.ts +16 -0
  93. package/dist/types/sdk/bus/chat-daemon-control.d.ts +101 -6
  94. package/dist/types/sdk/bus/chat-daemon-runtime.d.ts +16 -0
  95. package/dist/types/sdk/bus/daemon-startup-exclusion.d.ts +43 -0
  96. package/dist/types/sdk/bus/doctor-daemon-restart-client.d.ts +51 -0
  97. package/dist/types/sdk/bus/doctor-daemon-restart.d.ts +39 -0
  98. package/dist/types/sdk/bus/index.d.ts +2 -0
  99. package/dist/types/sdk/bus/notification-service.d.ts +3 -0
  100. package/dist/types/sdk/bus/telegram-daemon-cli.d.ts +11 -0
  101. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +1 -1
  102. package/dist/types/sdk/bus/telegram-daemon-control.d.ts +5 -0
  103. package/dist/types/sdk/bus/telegram-daemon-owner-registry.d.ts +1 -0
  104. package/dist/types/sdk/bus/telegram-daemon.d.ts +36 -5
  105. package/dist/types/sdk/cli/session-cli.d.ts +1 -0
  106. package/dist/types/sdk/host/session-runtime.d.ts +4 -0
  107. package/dist/types/sdk/host/surface-policy.d.ts +3 -1
  108. package/dist/types/sdk/lifecycle/service.d.ts +44 -10
  109. package/dist/types/sdk/prompt-deadline-lease.d.ts +6 -3
  110. package/dist/types/sdk/prompt-deadline-manager.d.ts +2 -2
  111. package/dist/types/sdk/prompt-failure.d.ts +47 -0
  112. package/dist/types/sdk/prompt-status.d.ts +19 -0
  113. package/dist/types/sdk/router/session-router.d.ts +3 -4
  114. package/dist/types/sdk/service-artifact-paths.d.ts +16 -0
  115. package/dist/types/sdk/session-authority.d.ts +9 -0
  116. package/dist/types/sdk/session.d.ts +3 -1
  117. package/dist/types/session/agent-session.d.ts +1 -1
  118. package/dist/types/session/blob-store.d.ts +1 -1
  119. package/dist/types/session/fallback-chain-controller.d.ts +3 -6
  120. package/dist/types/session/internal/managed-session-scope.d.ts +44 -0
  121. package/dist/types/session/primary-control-surface.d.ts +4 -0
  122. package/dist/types/session/session-manager.d.ts +6 -0
  123. package/dist/types/system-prompt.d.ts +6 -0
  124. package/dist/types/tools/ask.d.ts +25 -0
  125. package/dist/types/tools/image-gen.d.ts +0 -4
  126. package/dist/types/tools/skill-discovery.d.ts +7 -5
  127. package/dist/types/tools/ultragoal-ask-guard.d.ts +7 -4
  128. package/dist/types/utils/changelog.d.ts +0 -9
  129. package/dist/types/utils/mupdf-wasm.d.ts +1 -0
  130. package/package.json +9 -8
  131. package/scripts/compile-args.ts +8 -4
  132. package/src/capability/fs.ts +395 -17
  133. package/src/capability/index.ts +273 -10
  134. package/src/capability/types.ts +9 -0
  135. package/src/cli/customize-doctor.ts +18 -12
  136. package/src/cli/doctor/args.ts +236 -0
  137. package/src/cli/doctor/checks.ts +431 -0
  138. package/src/cli/doctor/config-repairs.ts +517 -0
  139. package/src/cli/doctor/context.ts +149 -0
  140. package/src/cli/doctor/files.ts +149 -0
  141. package/src/cli/doctor/ids.ts +115 -0
  142. package/src/cli/doctor/install-repairs.ts +335 -0
  143. package/src/cli/doctor/journal.ts +423 -0
  144. package/src/cli/doctor/managed-link.ts +514 -0
  145. package/src/cli/doctor/native-probe.ts +13 -0
  146. package/src/cli/doctor/permission-repairs.ts +242 -0
  147. package/src/cli/doctor/plugin-quarantine.ts +377 -0
  148. package/src/cli/doctor/plugin-restore.ts +59 -0
  149. package/src/cli/doctor/plugin-targets.ts +373 -0
  150. package/src/cli/doctor/probe-client.ts +195 -0
  151. package/src/cli/doctor/probe-types.ts +21 -0
  152. package/src/cli/doctor/probe.ts +42 -0
  153. package/src/cli/doctor/projection-probe.ts +249 -0
  154. package/src/cli/doctor/repairs.ts +1157 -0
  155. package/src/cli/doctor/report.ts +176 -0
  156. package/src/cli/doctor/runner.ts +342 -0
  157. package/src/cli/doctor/safety.ts +40 -0
  158. package/src/cli/doctor/service-repairs.ts +452 -0
  159. package/src/cli/doctor/service-targets.ts +274 -0
  160. package/src/cli/doctor/types.ts +164 -0
  161. package/src/cli/doctor-cli.ts +62 -0
  162. package/src/cli/doctor-supervisor.ts +571 -0
  163. package/src/cli/doctor-worker.ts +181 -0
  164. package/src/cli/install-activation.ts +666 -0
  165. package/src/cli/macos-community-app.ts +1449 -0
  166. package/src/cli/mcp-cli.ts +11 -21
  167. package/src/cli/nofile-limit.ts +5 -1
  168. package/src/cli/plugin-cli.ts +6 -1
  169. package/src/cli/update-cli.ts +362 -264
  170. package/src/cli-main.ts +517 -0
  171. package/src/cli.ts +64 -433
  172. package/src/commands/autoresearch.ts +11 -5
  173. package/src/commands/completion.ts +1 -1
  174. package/src/commands/doctor.ts +13 -0
  175. package/src/commands/sdk.ts +58 -10
  176. package/src/commands/ultragoal.ts +11 -5
  177. package/src/config/autorouting-tier-map.ts +7 -0
  178. package/src/config/file-lock.ts +736 -71
  179. package/src/config/mcp-schema.json +3 -3
  180. package/src/config/model-preset-registry.ts +1 -0
  181. package/src/config/model-registry.ts +80 -27
  182. package/src/config/model-resolver.ts +33 -4
  183. package/src/config/models-config-schema.ts +2 -0
  184. package/src/config/settings-schema.ts +6 -2
  185. package/src/coordinator-mcp/question-gate-codec.ts +1 -0
  186. package/src/coordinator-mcp/server.ts +534 -51
  187. package/src/coordinator-mcp/session-reaper.ts +64 -8
  188. package/src/cursor.ts +223 -53
  189. package/src/discovery/agents-md.ts +74 -8
  190. package/src/discovery/agents.ts +32 -7
  191. package/src/discovery/builtin.ts +184 -75
  192. package/src/discovery/claude-plugins.ts +85 -17
  193. package/src/discovery/cline.ts +38 -10
  194. package/src/discovery/cursor.ts +19 -6
  195. package/src/discovery/gemini.ts +62 -22
  196. package/src/discovery/github.ts +13 -3
  197. package/src/discovery/helpers.ts +483 -89
  198. package/src/discovery/index.ts +1 -0
  199. package/src/discovery/mcp-json.ts +36 -10
  200. package/src/discovery/opencode.ts +36 -19
  201. package/src/discovery/ssh.ts +11 -8
  202. package/src/discovery/windsurf.ts +12 -3
  203. package/src/exec/bash-executor.ts +10 -3
  204. package/src/exec/isolated-shell.ts +15 -2
  205. package/src/extensibility/extensions/loader.ts +88 -26
  206. package/src/extensibility/gjc-plugins/lifecycle.ts +189 -0
  207. package/src/extensibility/gjc-plugins/paths.ts +4 -1
  208. package/src/extensibility/gjc-plugins/types.ts +27 -0
  209. package/src/extensibility/plugins/manager.ts +200 -22
  210. package/src/extensibility/plugins/marketplace/cache.ts +55 -2
  211. package/src/extensibility/plugins/marketplace/manager.ts +1262 -162
  212. package/src/extensibility/plugins/marketplace/registry.ts +153 -0
  213. package/src/extensibility/plugins/marketplace/source-resolver.ts +116 -10
  214. package/src/extensibility/runtime-skill-discovery.ts +61 -9
  215. package/src/extensibility/skill-management.ts +7 -1
  216. package/src/gjc-runtime/autoresearch-runtime.ts +10 -1
  217. package/src/gjc-runtime/launch-worktree.test.ts +248 -0
  218. package/src/gjc-runtime/launch-worktree.ts +275 -38
  219. package/src/gjc-runtime/repository-binding.ts +26 -1
  220. package/src/gjc-runtime/session-layout.ts +10 -0
  221. package/src/gjc-runtime/session-state-lock.ts +330 -23
  222. package/src/gjc-runtime/session-state-sidecar.ts +328 -80
  223. package/src/gjc-runtime/ultragoal-runtime.ts +10 -1
  224. package/src/harness-control-plane/session-lease.ts +2 -2
  225. package/src/internal-urls/docs-index.generated.ts +28 -26
  226. package/src/main.ts +150 -29
  227. package/src/memories/index.ts +24 -2
  228. package/src/modes/acp/acp-agent.ts +522 -68
  229. package/src/modes/components/agent-dashboard.ts +3 -1
  230. package/src/modes/components/model-selector.ts +28 -19
  231. package/src/modes/components/plugin-settings.ts +2 -1
  232. package/src/modes/controllers/selector-controller.ts +15 -9
  233. package/src/modes/interactive-mode.ts +21 -3
  234. package/src/prompts/system/system-prompt.md +5 -1
  235. package/src/prompts/tools/browser.md +1 -1
  236. package/src/prompts/tools/move-session.md +4 -0
  237. package/src/prompts/tools/read.md +2 -1
  238. package/src/prompts/tools/skill-discovery.md +6 -5
  239. package/src/prompts/tools/task.md +1 -0
  240. package/src/runtime-mcp/config-writer.ts +228 -0
  241. package/src/runtime-mcp/oauth-discovery.ts +114 -14
  242. package/src/runtime-mcp/policy-json-edit.ts +129 -0
  243. package/src/runtime-mcp/startup-policy.ts +14 -0
  244. package/src/runtime-mcp/types.ts +2 -2
  245. package/src/sdk/acp/adapter.ts +27 -12
  246. package/src/sdk/broker/broker.ts +273 -44
  247. package/src/sdk/broker/daemon-entry.ts +119 -0
  248. package/src/sdk/broker/discovery.ts +173 -20
  249. package/src/sdk/broker/doctor-restart.ts +206 -0
  250. package/src/sdk/broker/endpoint-authority.ts +36 -0
  251. package/src/sdk/broker/ensure.ts +49 -14
  252. package/src/sdk/broker/lifecycle.ts +174 -42
  253. package/src/sdk/broker/process-incarnation.ts +86 -2
  254. package/src/sdk/broker/runtime.ts +145 -21
  255. package/src/sdk/broker/session-index.ts +136 -22
  256. package/src/sdk/broker/spawn-substrate.ts +4 -1
  257. package/src/sdk/broker/transport.ts +5 -0
  258. package/src/sdk/broker/worktree-occupancy.ts +21 -1
  259. package/src/sdk/bus/chat-daemon-cli.ts +63 -0
  260. package/src/sdk/bus/chat-daemon-control.ts +260 -64
  261. package/src/sdk/bus/chat-daemon-runtime.ts +141 -25
  262. package/src/sdk/bus/daemon-startup-exclusion.ts +119 -0
  263. package/src/sdk/bus/doctor-daemon-restart-client.ts +291 -0
  264. package/src/sdk/bus/doctor-daemon-restart.ts +92 -0
  265. package/src/sdk/bus/index.ts +252 -51
  266. package/src/sdk/bus/kind-aware-reconciliation.ts +76 -41
  267. package/src/sdk/bus/notification-service.ts +21 -3
  268. package/src/sdk/bus/reconciliation-store.ts +17 -11
  269. package/src/sdk/bus/telegram-daemon-cli.ts +91 -5
  270. package/src/sdk/bus/telegram-daemon-contract.ts +1 -1
  271. package/src/sdk/bus/telegram-daemon-control.ts +47 -0
  272. package/src/sdk/bus/telegram-daemon-owner-registry.ts +1 -1
  273. package/src/sdk/bus/telegram-daemon.ts +178 -13
  274. package/src/sdk/cli/session-cli.ts +100 -12
  275. package/src/sdk/host/host.ts +2 -7
  276. package/src/sdk/host/session-runtime.test.ts +329 -8
  277. package/src/sdk/host/session-runtime.ts +202 -50
  278. package/src/sdk/host/surface-policy.ts +8 -1
  279. package/src/sdk/lifecycle/service.ts +199 -22
  280. package/src/sdk/mcp/server.ts +1 -1
  281. package/src/sdk/prompt-deadline-lease.ts +12 -4
  282. package/src/sdk/prompt-deadline-manager.ts +5 -7
  283. package/src/sdk/prompt-failure.ts +148 -0
  284. package/src/sdk/prompt-status.ts +30 -1
  285. package/src/sdk/prompt-watchdog.ts +11 -3
  286. package/src/sdk/protocol/operation-inventory.generated.json +18 -0
  287. package/src/sdk/protocol/operation-registry.ts +20 -8
  288. package/src/sdk/router/session-router.ts +8 -3
  289. package/src/sdk/service-artifact-paths.ts +26 -0
  290. package/src/sdk/session-authority.ts +10 -0
  291. package/src/sdk/session.ts +361 -194
  292. package/src/session/agent-session.ts +398 -93
  293. package/src/session/blob-store.ts +9 -9
  294. package/src/session/fallback-chain-controller.ts +3 -7
  295. package/src/session/internal/managed-session-scope.ts +445 -29
  296. package/src/session/internal/managed-session-storage.ts +38 -6
  297. package/src/session/primary-control-surface.ts +12 -0
  298. package/src/session/session-manager.ts +11 -3
  299. package/src/session/session-storage.ts +1 -1
  300. package/src/session-import/codex.ts +6 -1
  301. package/src/session-import/redact.ts +7 -0
  302. package/src/skill-state/workflow-mutation-guard.ts +645 -39
  303. package/src/slash-commands/builtin-registry.ts +7 -3
  304. package/src/system-prompt.ts +8 -0
  305. package/src/task/executor.ts +36 -3
  306. package/src/task/types.ts +27 -5
  307. package/src/tools/ask.ts +114 -34
  308. package/src/tools/browser/actions.ts +2 -2
  309. package/src/tools/browser/tab-worker.ts +11 -8
  310. package/src/tools/descriptors.ts +6 -1
  311. package/src/tools/fetch.ts +7 -2
  312. package/src/tools/image-gen.ts +33 -34
  313. package/src/tools/read.ts +78 -32
  314. package/src/tools/skill-discovery.ts +17 -7
  315. package/src/tools/tool-catalog.generated.ts +8 -8
  316. package/src/tools/ultragoal-ask-guard.ts +16 -26
  317. package/src/utils/changelog.ts +7 -1
  318. package/src/utils/git.ts +3 -1
  319. package/src/utils/markit.ts +24 -2
  320. package/src/utils/mupdf-wasm.ts +26 -0
  321. package/src/web/search/providers/openai-compatible.ts +191 -14
@@ -17,6 +17,8 @@ import * as path from "node:path";
17
17
 
18
18
  import { getConfigRootDir, getPluginsDir, isEnoent, logger, tryParseJson } from "@gajae-code/utils";
19
19
 
20
+ import { withFileLock } from "../../../config/file-lock";
21
+
20
22
  import type {
21
23
  InstalledPluginEntry,
22
24
  InstalledPluginsRegistry,
@@ -131,6 +133,157 @@ export async function writeInstalledPluginsRegistry(filePath: string, reg: Insta
131
133
  await atomicWriteJson(filePath, reg);
132
134
  }
133
135
 
136
+ /**
137
+ * Refuse a registry file that is not an ordinary, singly-linked regular file:
138
+ * a symlink could redirect the write elsewhere, and a hard link (nlink > 1)
139
+ * means the mutation would silently also change another path. Absence is not
140
+ * a refusal — that is the normal "nothing installed yet" state.
141
+ */
142
+ async function assertOrdinaryRegistryFile(filePath: string): Promise<void> {
143
+ let stat: import("node:fs").Stats;
144
+ try {
145
+ stat = await fs.lstat(filePath);
146
+ } catch (err) {
147
+ if (isEnoent(err)) return;
148
+ throw err;
149
+ }
150
+ if (stat.isSymbolicLink() || !stat.isFile() || stat.nlink > 1) {
151
+ throw Object.assign(new Error(`Installed plugins registry at ${filePath} is not a safe ordinary file`), {
152
+ code: "unsafe_registry_link",
153
+ });
154
+ }
155
+ }
156
+
157
+ /**
158
+ * Strict read for mutation/CAS callers: a missing file is legitimately empty,
159
+ * but malformed JSON or an unexpected shape must never be silently treated as
160
+ * an empty registry — that would let a mutating writer overwrite (and lose)
161
+ * an unreadable but non-empty registry. Use {@link readInstalledPluginsRegistry}
162
+ * for lenient, display-only reads.
163
+ */
164
+ async function readInstalledPluginsRegistryStrict(filePath: string): Promise<InstalledPluginsRegistry> {
165
+ await assertOrdinaryRegistryFile(filePath);
166
+ let text: string;
167
+ try {
168
+ text = await fs.readFile(filePath, "utf8");
169
+ } catch (err) {
170
+ if (isEnoent(err)) return emptyInstalledPluginsRegistry();
171
+ throw err;
172
+ }
173
+ const data = tryParseJson<InstalledPluginsRegistry>(text);
174
+ if (
175
+ !data ||
176
+ typeof data !== "object" ||
177
+ typeof data.version !== "number" ||
178
+ !data.plugins ||
179
+ typeof data.plugins !== "object" ||
180
+ Array.isArray(data.plugins)
181
+ ) {
182
+ throw Object.assign(new Error(`Installed plugins registry is malformed at ${filePath}`), {
183
+ code: "malformed_registry",
184
+ });
185
+ }
186
+ return { ...data, version: 2 };
187
+ }
188
+
189
+ /** dev/ino/mtime/size identity of a file plus its parent directory, for exact CAS. */
190
+ async function fileParentIdentity(filePath: string): Promise<Record<string, string> | null> {
191
+ try {
192
+ const stat = await fs.lstat(filePath, { bigint: true });
193
+ const parent = await fs.lstat(path.dirname(filePath), { bigint: true });
194
+ return {
195
+ dev: stat.dev.toString(),
196
+ ino: stat.ino.toString(),
197
+ mtimeNs: stat.mtimeNs.toString(),
198
+ size: stat.size.toString(),
199
+ parentDev: parent.dev.toString(),
200
+ parentIno: parent.ino.toString(),
201
+ };
202
+ } catch {
203
+ return null;
204
+ }
205
+ }
206
+
207
+ function entryBaseline(identity: Record<string, string> | null, entry: InstalledPluginEntry): string {
208
+ return JSON.stringify({ identity, entry });
209
+ }
210
+
211
+ export type InstalledPluginEnablementState =
212
+ | { status: "ok"; enabled: boolean; baseline: string }
213
+ | { status: "not_installed" }
214
+ | { status: "malformed" }
215
+ | { status: "unsafe_link" };
216
+
217
+ /**
218
+ * Pure read-only snapshot of one (id, scope) entry's durable enablement plus
219
+ * an exact file+parent CAS baseline. Never touches the cached artifact at
220
+ * `installPath` — presence/absence of that artifact is a D6 concern, not D7's.
221
+ * A symlinked or hard-linked registry file is refused distinctly from an
222
+ * absent ("not_installed"-eligible) or malformed one.
223
+ */
224
+ export async function getInstalledPluginEnablementState(
225
+ filePath: string,
226
+ id: string,
227
+ scope: "user" | "project",
228
+ ): Promise<InstalledPluginEnablementState> {
229
+ let reg: InstalledPluginsRegistry;
230
+ try {
231
+ reg = await readInstalledPluginsRegistryStrict(filePath);
232
+ } catch (error) {
233
+ if (error instanceof Error && (error as { code?: string }).code === "unsafe_registry_link")
234
+ return { status: "unsafe_link" };
235
+ return { status: "malformed" };
236
+ }
237
+ const entry = reg.plugins[id]?.find(e => e.scope === scope);
238
+ if (!entry) return { status: "not_installed" };
239
+ const identity = await fileParentIdentity(filePath);
240
+ return { status: "ok", enabled: entry.enabled !== false, baseline: entryBaseline(identity, entry) };
241
+ }
242
+
243
+ /**
244
+ * Update durable marketplace enablement without touching cached artifacts.
245
+ * Serialized under the canonical native-identity-bound file lock ordinary
246
+ * marketplace writers use (bounded retries, no blind directory removal).
247
+ * `expectedBaseline` is required: a caller with no prior read has no CAS
248
+ * authority and must call {@link getInstalledPluginEnablementState} first.
249
+ */
250
+ export async function setInstalledPluginEnabled(
251
+ filePath: string,
252
+ id: string,
253
+ scope: "user" | "project",
254
+ enabled: boolean,
255
+ expectedBaseline: string,
256
+ ): Promise<"updated" | "not_needed"> {
257
+ return await withFileLock(filePath, async () => {
258
+ let reg: InstalledPluginsRegistry;
259
+ try {
260
+ reg = await readInstalledPluginsRegistryStrict(filePath);
261
+ } catch (error) {
262
+ if (error instanceof Error && (error as { code?: string }).code === "unsafe_registry_link") throw error;
263
+ throw Object.assign(new Error(`Installed plugins registry is malformed at ${filePath}`), {
264
+ code: "malformed_registry",
265
+ cause: error,
266
+ });
267
+ }
268
+ const entries = reg.plugins[id];
269
+ const index = entries?.findIndex(entry => entry.scope === scope) ?? -1;
270
+ if (!entries || index < 0)
271
+ throw Object.assign(new Error(`Plugin "${id}" is not installed in ${scope} scope`), { code: "not_installed" });
272
+ const current = entries[index] as InstalledPluginEntry;
273
+ const identity = await fileParentIdentity(filePath);
274
+ const baseline = entryBaseline(identity, current);
275
+ if (baseline !== expectedBaseline)
276
+ throw Object.assign(new Error("The installed plugin entry changed since it was reviewed"), {
277
+ code: "stale_baseline",
278
+ });
279
+ if ((current.enabled ?? true) === enabled) return "not_needed";
280
+ const nextEntries = [...entries];
281
+ nextEntries[index] = { ...current, enabled };
282
+ await writeInstalledPluginsRegistry(filePath, { ...reg, plugins: { ...reg.plugins, [id]: nextEntries } });
283
+ return "updated";
284
+ });
285
+ }
286
+
134
287
  // ── Marketplace CRUD ─────────────────────────────────────────────────
135
288
  // Pure functions that transform registry state. Caller is responsible for
136
289
  // reading, mutating, and writing back.
@@ -27,6 +27,34 @@ export interface ResolveContext {
27
27
  tmpDir: string;
28
28
  }
29
29
 
30
+ export function sourcePin(source: PluginSource): { ref?: string; sha?: string; immutable: boolean } {
31
+ if (typeof source === "string") return { immutable: false };
32
+ if (source.source === "npm") return { immutable: false };
33
+ return {
34
+ ref: source.ref,
35
+ sha: source.sha,
36
+ immutable: typeof source.sha === "string" && /^[a-f0-9]{40}$/i.test(source.sha),
37
+ };
38
+ }
39
+
40
+ export function assertPinnedSource(source: PluginSource): void {
41
+ const pin = sourcePin(source);
42
+ if (!pin.immutable) throw new Error("marketplace restore requires an immutable source SHA");
43
+ }
44
+
45
+ /**
46
+ * Independently verify that a resolved git checkout's HEAD matches the pinned
47
+ * SHA the plan/token claims to restore. `git.clone` already checks out the
48
+ * exact SHA, but this re-derives the fact from the checkout itself (a fresh
49
+ * `rev-parse HEAD` in the resolved directory) rather than trusting whatever
50
+ * the resolver returned, so a resolver bug or a swapped working tree cannot
51
+ * silently mismatch source and to-be-published bytes.
52
+ */
53
+ export async function verifyResolvedProvenance(resolvedDir: string, expectedSha: string): Promise<boolean> {
54
+ const actual = await git.head.sha(resolvedDir).catch(() => null);
55
+ return typeof actual === "string" && actual.toLowerCase() === expectedSha.toLowerCase();
56
+ }
57
+
30
58
  /**
31
59
  * Resolve a plugin source to an absolute local directory path.
32
60
  *
@@ -65,12 +93,10 @@ async function resolveRelativeSource(
65
93
 
66
94
  // Resolve against marketplace root (not the .Anthropic model-plugin/ catalog subdirectory)
67
95
  const resolved = path.resolve(context.marketplaceClonePath, relativePath);
96
+ const outsideRoot = `Plugin source "${source}" resolves outside marketplace root ("${context.marketplaceClonePath}")`;
68
97
 
69
- if (!pathIsWithin(context.marketplaceClonePath, resolved)) {
70
- throw new Error(
71
- `Plugin source "${source}" resolves outside marketplace root ("${context.marketplaceClonePath}")`,
72
- );
73
- }
98
+ if (!pathIsWithin(context.marketplaceClonePath, resolved)) throw new Error(outsideRoot);
99
+ await assertCanonicalContainment(context.marketplaceClonePath, resolved, outsideRoot);
74
100
 
75
101
  await verifyDirExists(resolved, `Plugin source directory does not exist: "${resolved}"`);
76
102
  return { dir: resolved };
@@ -87,13 +113,13 @@ async function resolveObjectSource(
87
113
  // { source: "url", url: "https://github.com/owner/repo.git" }
88
114
  // Despite the name, this is typically a git clone URL
89
115
  const targetDir = path.join(context.tmpDir, `plugin-${crypto.randomUUID()}`);
90
- await git.clone(source.url, targetDir, { ref: source.ref, sha: source.sha });
116
+ await git.clone(assertCloneUrl(source.url), targetDir, { ref: source.ref, sha: source.sha });
91
117
  return { dir: targetDir, tempCloneRoot: targetDir };
92
118
  }
93
119
 
94
120
  case "github": {
95
121
  // { source: "github", repo: "owner/repo" }
96
- const url = `https://github.com/${source.repo}.git`;
122
+ const url = `https://github.com/${assertRepoSlug(source.repo)}.git`;
97
123
  const targetDir = path.join(context.tmpDir, `plugin-${crypto.randomUUID()}`);
98
124
  await git.clone(url, targetDir, { ref: source.ref, sha: source.sha });
99
125
  return { dir: targetDir, tempCloneRoot: targetDir };
@@ -103,17 +129,20 @@ async function resolveObjectSource(
103
129
  // { source: "git-subdir", url: "owner/repo" | "https://...", path: "plugins/foo" }
104
130
  const url =
105
131
  source.url.includes("://") || source.url.startsWith("git@")
106
- ? source.url
107
- : `https://github.com/${source.url}.git`;
132
+ ? assertCloneUrl(source.url)
133
+ : `https://github.com/${assertRepoSlug(source.url)}.git`;
134
+ assertSubdirPath(source.path);
108
135
  const cloneDir = path.join(context.tmpDir, `plugin-repo-${crypto.randomUUID()}`);
109
136
  await git.clone(url, cloneDir, { ref: source.ref, sha: source.sha });
110
137
 
111
138
  const subdirPath = path.resolve(cloneDir, source.path);
139
+ const escapes = `git-subdir path "${source.path}" escapes the cloned repository`;
112
140
  if (!pathIsWithin(cloneDir, subdirPath)) {
113
141
  await fs.rm(cloneDir, { recursive: true, force: true });
114
- throw new Error(`git-subdir path "${source.path}" escapes the cloned repository`);
142
+ throw new Error(escapes);
115
143
  }
116
144
  try {
145
+ await assertCanonicalContainment(cloneDir, subdirPath, escapes);
117
146
  await verifyDirExists(subdirPath, `git-subdir path "${source.path}" does not exist in cloned repository`);
118
147
  } catch (err) {
119
148
  await fs.rm(cloneDir, { recursive: true, force: true });
@@ -132,6 +161,83 @@ async function resolveObjectSource(
132
161
 
133
162
  // ── Helpers ─────────────────────────────────────────────────────────
134
163
 
164
+ /** Network transports a plugin source may be fetched over. */
165
+ const CLONE_URL_SCHEME = /^(?:https|http|git|ssh|file):\/\//;
166
+ /** scp-like `user@host:path`, which git accepts without a scheme. */
167
+ const SCP_LIKE_URL = /^[A-Za-z0-9._-]+@[A-Za-z0-9._-]+:/;
168
+ /** `git-remote-<helper>` dispatch (`ext::sh -c ...`), i.e. arbitrary command execution. */
169
+ const REMOTE_HELPER_URL = /^[A-Za-z0-9][A-Za-z0-9+.-]*::/;
170
+ /** `owner/repo` as GitHub itself accepts it; no options, no path traversal, no separators. */
171
+ const REPO_SLUG = /^[A-Za-z0-9][A-Za-z0-9._-]*\/[A-Za-z0-9][A-Za-z0-9._-]*$/;
172
+
173
+ /**
174
+ * A catalog is untrusted input, and its URL goes straight to `git clone`.
175
+ *
176
+ * Two shapes are dangerous on their own: a value starting with `-`, which git
177
+ * reads as an option (`--upload-pack=<cmd>` is command execution), and a
178
+ * remote-helper URL such as `ext::sh -c ...`, which git dispatches to
179
+ * `git-remote-ext` and which executes its argument by design. `git.clone` also
180
+ * passes `--` before the URL, but that only closes the first hole and only for
181
+ * callers that go through it, so both are refused here where the failure can
182
+ * name the offending catalog entry.
183
+ *
184
+ * Local absolute paths stay allowed: a privately hosted marketplace pointing at
185
+ * an on-disk repository is a supported layout.
186
+ */
187
+ function assertCloneUrl(url: string): string {
188
+ const supported =
189
+ !url.startsWith("-") &&
190
+ !REMOTE_HELPER_URL.test(url) &&
191
+ (CLONE_URL_SCHEME.test(url) || SCP_LIKE_URL.test(url) || path.isAbsolute(url));
192
+ if (!supported)
193
+ throw new Error(
194
+ `Plugin source URL must be an https/http/git/ssh/file URL, a user@host:path remote, or an absolute local path — got: "${url}"`,
195
+ );
196
+ return url;
197
+ }
198
+
199
+ function assertRepoSlug(repo: string): string {
200
+ if (!REPO_SLUG.test(repo)) throw new Error(`Plugin source repository must be "owner/repo" — got: "${repo}"`);
201
+ return repo;
202
+ }
203
+
204
+ /** The subdir is joined into a clone path, so it must be a plain relative path. */
205
+ function assertSubdirPath(subdir: string): string {
206
+ if (subdir.startsWith("-") || path.isAbsolute(subdir) || path.win32.isAbsolute(subdir))
207
+ throw new Error(`git-subdir path must be a relative path — got: "${subdir}"`);
208
+ return subdir;
209
+ }
210
+
211
+ /**
212
+ * Decide containment on canonical paths, and fail closed when they cannot be
213
+ * derived.
214
+ *
215
+ * A catalog is untrusted input and controls the tree it ships, so the plugin
216
+ * directory it names can be a symlink pointing out of the marketplace root.
217
+ * `pathIsWithin` does resolve symlinks, but it silently degrades to a lexical
218
+ * comparison whenever `realpath` fails — a dangling or looping link therefore
219
+ * passes the containment gate and is only rejected later (or not at all) by the
220
+ * existence probe, which follows symlinks itself. Here both sides must
221
+ * canonicalize successfully. A candidate that exists as a directory entry but
222
+ * cannot be canonicalized (dangling or looping link) is classified as an
223
+ * untrusted source; a path that is simply absent is left to the existence probe
224
+ * so a missing plugin still reports as missing.
225
+ */
226
+ async function assertCanonicalContainment(root: string, candidate: string, errorMessage: string): Promise<void> {
227
+ const canonicalRoot = await fs.realpath(root).catch(() => undefined);
228
+ if (canonicalRoot === undefined) throw new Error(errorMessage);
229
+ const canonicalCandidate = await fs.realpath(candidate).catch(() => undefined);
230
+ if (canonicalCandidate === undefined) {
231
+ const entryExists = await fs
232
+ .lstat(candidate)
233
+ .then(() => true)
234
+ .catch(() => false);
235
+ if (entryExists) throw new Error(errorMessage);
236
+ return;
237
+ }
238
+ if (!pathIsWithin(canonicalRoot, canonicalCandidate)) throw new Error(errorMessage);
239
+ }
240
+
135
241
  async function verifyDirExists(dirPath: string, errorMessage: string): Promise<void> {
136
242
  try {
137
243
  const stat = await fs.stat(dirPath);
@@ -10,6 +10,7 @@ import { findRepoRoot } from "../capability/fs";
10
10
  import type { Skill as CapabilitySkill } from "../capability/skill";
11
11
  import type { SkillsSettings } from "../config/settings-schema";
12
12
  import { resolveSkillScopeTrust } from "../config/skill-settings-defaults";
13
+ import { BUNDLED_GJC_SKILL_CATALOG } from "../defaults/gjc-skills.generated";
13
14
  import { scanClaudeProjectSkills, scanClaudeUserSkills } from "../discovery/claude";
14
15
  import { loadMarketplaceSkills } from "../discovery/claude-plugins";
15
16
  import { scanCodexProjectSkills, scanCodexUserSkills } from "../discovery/codex";
@@ -27,7 +28,9 @@ import {
27
28
  import { expandTilde } from "../tools/path-utils";
28
29
  import { loadSkills, type Skill } from "./skills";
29
30
 
30
- export type RuntimeSkillDiscoverySource = "project" | "user";
31
+ export type RuntimeSkillDiscoverySource = "project" | "user" | "bundled";
32
+ /** Search requests cover filesystem scopes; bundled is only a candidate source. */
33
+ type RuntimeSkillDiscoveryScope = Exclude<RuntimeSkillDiscoverySource, "bundled">;
31
34
 
32
35
  export interface RuntimeSkillDiscoveryCandidate {
33
36
  name: string;
@@ -49,6 +52,8 @@ export interface RuntimeSkillDiscoveryDiagnostics {
49
52
 
50
53
  export interface RuntimeSkillDiscoveryResult {
51
54
  candidates: RuntimeSkillDiscoveryCandidate[];
55
+ /** Deduped skill metadata the query filter ran against, including bundled entries; candidates.length <= scanned. */
56
+ scanned: number;
52
57
  diagnostics: RuntimeSkillDiscoveryDiagnostics;
53
58
  }
54
59
 
@@ -60,7 +65,7 @@ export interface DiscoverRuntimeSkillsOptions {
60
65
  profileAuthority?: "default" | "custom";
61
66
  query?: string;
62
67
  limit?: number;
63
- source?: RuntimeSkillDiscoverySource | "all";
68
+ source?: RuntimeSkillDiscoveryScope | "all";
64
69
  policy?: SkillsSettings;
65
70
  }
66
71
 
@@ -146,7 +151,7 @@ function getUseWhen(skill: CapabilitySkill): string[] | undefined {
146
151
  return values.length > 0 ? values : undefined;
147
152
  }
148
153
 
149
- function sourceEnabled(source: RuntimeSkillDiscoverySource, policy: SkillsSettings | undefined): boolean {
154
+ function sourceEnabled(source: RuntimeSkillDiscoveryScope, policy: SkillsSettings | undefined): boolean {
150
155
  if (policy?.enabled !== true) return false;
151
156
  return resolveSkillScopeTrust(policy, source);
152
157
  }
@@ -184,7 +189,7 @@ async function realPathOrSelf(filePath: string): Promise<string> {
184
189
  }
185
190
 
186
191
  interface ScanJobResult {
187
- items: Array<{ skill: CapabilitySkill; source: RuntimeSkillDiscoverySource; providerPriority: number }>;
192
+ items: Array<{ skill: CapabilitySkill; source: RuntimeSkillDiscoveryScope; providerPriority: number }>;
188
193
  warnings: string[];
189
194
  label: string;
190
195
  }
@@ -261,12 +266,13 @@ async function scanProjectOrUserDir(
261
266
  dir: string,
262
267
  level: "project" | "user",
263
268
  label: string,
264
- source: RuntimeSkillDiscoverySource,
269
+ source: RuntimeSkillDiscoveryScope,
265
270
  providerPriority: number,
266
271
  authorityRoot?: string,
267
272
  ): Promise<ScanJobResult> {
268
273
  const result = await scanSkillsFromDir(ctx, {
269
274
  dir,
275
+ linkContainmentRoot: level === "project" ? (ctx.repoRoot ?? undefined) : undefined,
270
276
  authorityRoot,
271
277
  providerId: "runtime",
272
278
  level,
@@ -282,7 +288,7 @@ async function scanProjectOrUserDir(
282
288
  interface ConventionImportScan {
283
289
  host: "Claude Code" | "Codex";
284
290
  dir: string;
285
- scope: RuntimeSkillDiscoverySource;
291
+ scope: RuntimeSkillDiscoveryScope;
286
292
  skills: CapabilitySkill[];
287
293
  }
288
294
 
@@ -297,7 +303,7 @@ interface ConventionImportScan {
297
303
  */
298
304
  async function collectConventionImportCandidates(
299
305
  ctx: { cwd: string; home: string; repoRoot: string | null },
300
- source: RuntimeSkillDiscoverySource | "all",
306
+ source: RuntimeSkillDiscoveryScope | "all",
301
307
  policy: SkillsSettings | undefined,
302
308
  ): Promise<ConventionImportScan[]> {
303
309
  const scans: ConventionImportScan[] = [];
@@ -359,7 +365,7 @@ function reportConventionImportCandidates(
359
365
  * tool and `gjc skills discover`.
360
366
  */
361
367
  export function describeDisabledSkillScopes(
362
- source: RuntimeSkillDiscoverySource | "all",
368
+ source: RuntimeSkillDiscoveryScope | "all",
363
369
  policy: SkillsSettings | undefined,
364
370
  ): string | undefined {
365
371
  if (policy?.enabled !== true) {
@@ -379,6 +385,36 @@ export function describeDisabledSkillScopes(
379
385
  return `Skill discovery skipped disabled scope(s): ${skipped.join(", ")}. Enable them with ${commands.join(" and ")}.`;
380
386
  }
381
387
 
388
+ /**
389
+ * Explain a zero-candidate result caused by the conjunctive query filter rather
390
+ * than by an empty catalog or disabled scopes: every whitespace-separated term
391
+ * must appear in a candidate's name, description, source, or use conditions (or
392
+ * one term must equal the exact skill name), so a single keyword that appears
393
+ * nowhere drops every skill. Shared by the skill_discovery tool.
394
+ */
395
+ export function describeNoSkillMatch(query: string | undefined, scanned: number): string | undefined {
396
+ const trimmed = (query ?? "").trim();
397
+ if (trimmed.length === 0 || scanned === 0) return undefined;
398
+ return `No skill matched every query term (${scanned} skill${scanned === 1 ? "" : "s"} scanned). Matching is conjunctive substring: every whitespace-separated term must appear in the skill's name, description, source, or use conditions, unless a term equals the exact skill name. Retry with the exact skill name or fewer terms; a zero-candidate result does not prove no skills exist.`;
399
+ }
400
+
401
+ function getBundledSkillCandidates(): RuntimeSkillDiscoveryCandidate[] {
402
+ // Catalog loaders remain lazy; discovery reads only each entry's metadata.
403
+ return BUNDLED_GJC_SKILL_CATALOG.flatMap(entry => {
404
+ if (entry.kind !== "skill" || entry.name === undefined) return [];
405
+ return [
406
+ {
407
+ name: entry.name,
408
+ description: entry.description ?? "",
409
+ source: "bundled" as const,
410
+ // `embedded:gjc/...` is the stable non-filesystem identifier used by
411
+ // bundled Skill metadata; the relative path comes from the generated catalog.
412
+ path: `embedded:gjc/${entry.relativePath}`,
413
+ },
414
+ ];
415
+ });
416
+ }
417
+
382
418
  export async function discoverRuntimeSkills(
383
419
  options: DiscoverRuntimeSkillsOptions,
384
420
  ): Promise<RuntimeSkillDiscoveryResult> {
@@ -442,7 +478,7 @@ export async function discoverRuntimeSkills(
442
478
  collectPluginSkills(home, options.cwd, allowedLevels).then(result => ({
443
479
  items: result.items.map(skill => ({
444
480
  skill,
445
- source: skill.level as RuntimeSkillDiscoverySource,
481
+ source: skill.level as RuntimeSkillDiscoveryScope,
446
482
  providerPriority: 70,
447
483
  })),
448
484
  warnings: result.warnings,
@@ -461,6 +497,20 @@ export async function discoverRuntimeSkills(
461
497
  const seenNames = new Set<string>();
462
498
  const seenPaths = new Set<string>();
463
499
  const candidates: RuntimeSkillDiscoveryCandidate[] = [];
500
+ let scanned = 0;
501
+ // Bundled workflow skills are neither project nor user scope, so explicit
502
+ // project/user requests exclude them; source "all" includes them regardless
503
+ // of filesystem trust and include/ignore/disabled policy filters because the
504
+ // bundled definitions cannot be replaced.
505
+ if (source === "all") {
506
+ for (const candidate of getBundledSkillCandidates()) {
507
+ const normalizedName = getSkillFilesystemIdentity(candidate.name);
508
+ seenNames.add(normalizedName);
509
+ seenPaths.add(candidate.path);
510
+ scanned += 1;
511
+ if (matchesQuery(candidate, options.query ?? "")) candidates.push(candidate);
512
+ }
513
+ }
464
514
  const orderedItems: ScanJobResult["items"] = [];
465
515
  for (const entry of settled) {
466
516
  if ("error" in entry) {
@@ -499,6 +549,7 @@ export async function discoverRuntimeSkills(
499
549
  path: item.skill.path,
500
550
  useWhen: getUseWhen(item.skill),
501
551
  };
552
+ scanned += 1;
502
553
  if (matchesQuery(candidate, options.query ?? "")) candidates.push(candidate);
503
554
  }
504
555
  candidates.sort((a, b) => compareSkillOrder(a.name, a.path, b.name, b.path));
@@ -509,6 +560,7 @@ export async function discoverRuntimeSkills(
509
560
  );
510
561
  return {
511
562
  candidates: candidates.slice(0, normalizeLimit(options.limit)),
563
+ scanned,
512
564
  diagnostics: { messages: diagnostics },
513
565
  };
514
566
  }
@@ -329,7 +329,13 @@ export async function listNativeSkillsForManagement(options: {
329
329
  scanJobs.push(
330
330
  scanSkillsFromDir(
331
331
  { cwd: options.cwd, home, repoRoot: projectDirs.repoRoot },
332
- { dir, providerId: "runtime", level: "project", requireDescription: true },
332
+ {
333
+ dir,
334
+ linkContainmentRoot: projectDirs.repoRoot ?? undefined,
335
+ providerId: "runtime",
336
+ level: "project",
337
+ requireDescription: true,
338
+ },
333
339
  ).then(result => ({ dir, scope: "project" as const, items: result.items })),
334
340
  );
335
341
  }
@@ -894,6 +894,15 @@ export async function autoresearchIssueVerdict(input: {
894
894
 
895
895
  /* ------------------------------ CLI dispatch ------------------------------ */
896
896
 
897
+ /**
898
+ * A help request is `--help`/`-h` anywhere in the argv, or a leading `help`
899
+ * verb. Exported so the CLI command class routes help back through this
900
+ * runtime instead of a generic command-level fallback.
901
+ */
902
+ export function isAutoresearchHelpInvocation(args: readonly string[]): boolean {
903
+ return args.includes("--help") || args.includes("-h") || args[0] === "help";
904
+ }
905
+
897
906
  function renderAutoresearchHelp(): string {
898
907
  return [
899
908
  "Run native GJC Autoresearch workflow commands",
@@ -1081,7 +1090,7 @@ export async function runNativeAutoresearchCommand(
1081
1090
  cwd = process.cwd(),
1082
1091
  ): Promise<AutoresearchCommandResult> {
1083
1092
  try {
1084
- if (args.includes("--help") || args.includes("-h") || args[0] === "help") {
1093
+ if (isAutoresearchHelpInvocation(args)) {
1085
1094
  return { status: 0, stdout: renderAutoresearchHelp() };
1086
1095
  }
1087
1096
  const verb = args[0];