@gajae-code/coding-agent 0.12.0 → 0.12.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 (223) hide show
  1. package/CHANGELOG.md +82 -1
  2. package/dist/types/async/job-manager.d.ts +17 -3
  3. package/dist/types/config/settings-schema.d.ts +54 -0
  4. package/dist/types/exa/mcp-client.d.ts +10 -1
  5. package/dist/types/exec/bash-executor.d.ts +45 -12
  6. package/dist/types/extensibility/extensions/types.d.ts +16 -2
  7. package/dist/types/extensibility/gjc-plugins/index.d.ts +12 -2
  8. package/dist/types/extensibility/gjc-plugins/installer.d.ts +63 -7
  9. package/dist/types/extensibility/gjc-plugins/lifecycle-reconciliation.d.ts +55 -0
  10. package/dist/types/extensibility/gjc-plugins/lifecycle.d.ts +64 -0
  11. package/dist/types/extensibility/gjc-plugins/runtime-quarantine.d.ts +67 -0
  12. package/dist/types/extensibility/gjc-plugins/session-validation.d.ts +3 -1
  13. package/dist/types/extensibility/gjc-plugins/types.d.ts +124 -0
  14. package/dist/types/gjc-runtime/launch-tmux.d.ts +1 -0
  15. package/dist/types/gjc-runtime/ralplan-runtime.d.ts +18 -0
  16. package/dist/types/gjc-runtime/team-launch.d.ts +1 -1
  17. package/dist/types/gjc-runtime/team-runtime.d.ts +25 -0
  18. package/dist/types/gjc-runtime/team-worker-heartbeat.d.ts +43 -0
  19. package/dist/types/gjc-runtime/tmux-owner-isolation.d.ts +10 -0
  20. package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +31 -80
  21. package/dist/types/modes/acp/acp-agent.d.ts +2 -19
  22. package/dist/types/modes/components/gjc-bundle-settings.d.ts +34 -0
  23. package/dist/types/modes/components/notifications-settings-editor.d.ts +1 -0
  24. package/dist/types/modes/components/session-observer-overlay.d.ts +18 -0
  25. package/dist/types/modes/components/settings-selector.d.ts +9 -0
  26. package/dist/types/modes/components/status-line/gh.d.ts +5 -0
  27. package/dist/types/modes/components/transcript-viewer-overlay.d.ts +18 -1
  28. package/dist/types/reminders/star-reminder.d.ts +3 -13
  29. package/dist/types/runtime-mcp/manager.d.ts +1 -0
  30. package/dist/types/sdk/acp/adapter.d.ts +3 -1
  31. package/dist/types/sdk/broker/transport.d.ts +2 -0
  32. package/dist/types/sdk/bus/attachment-registry.d.ts +1 -0
  33. package/dist/types/sdk/bus/chat-daemon-control.d.ts +4 -1
  34. package/dist/types/sdk/bus/config.d.ts +6 -2
  35. package/dist/types/sdk/bus/index.d.ts +29 -0
  36. package/dist/types/sdk/bus/kind-aware-reconciliation.d.ts +8 -1
  37. package/dist/types/sdk/bus/lifecycle-control-runtime.d.ts +4 -0
  38. package/dist/types/sdk/bus/reconciliation-store.d.ts +7 -3
  39. package/dist/types/sdk/bus/rich-draft.d.ts +1 -0
  40. package/dist/types/sdk/bus/rich-render.d.ts +2 -0
  41. package/dist/types/sdk/bus/telegram-adoption-intent.d.ts +165 -0
  42. package/dist/types/sdk/bus/telegram-cli.d.ts +12 -0
  43. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +11 -4
  44. package/dist/types/sdk/bus/telegram-daemon.d.ts +25 -1
  45. package/dist/types/sdk/bus/telegram-reference.d.ts +11 -1
  46. package/dist/types/sdk/bus/threaded-render.d.ts +3 -2
  47. package/dist/types/sdk/bus/topic-registry.d.ts +11 -1
  48. package/dist/types/sdk/host/query/revision-store.d.ts +2 -0
  49. package/dist/types/sdk/host/reverse-leases.d.ts +1 -1
  50. package/dist/types/sdk/prompt-status.d.ts +23 -3
  51. package/dist/types/sdk/session.d.ts +6 -0
  52. package/dist/types/session/agent-session.d.ts +38 -2
  53. package/dist/types/session/artifacts.d.ts +8 -0
  54. package/dist/types/session/blob-store.d.ts +57 -4
  55. package/dist/types/session/internal/managed-session-storage.d.ts +8 -1
  56. package/dist/types/session/session-manager.d.ts +23 -6
  57. package/dist/types/session/session-storage.d.ts +4 -0
  58. package/dist/types/session/streaming-output.d.ts +36 -7
  59. package/dist/types/setup/credential-import.d.ts +12 -0
  60. package/dist/types/skill-state/active-state.d.ts +12 -0
  61. package/dist/types/skill-state/workflow-hud.d.ts +6 -0
  62. package/dist/types/task/executor.d.ts +3 -2
  63. package/dist/types/task/index.d.ts +3 -1
  64. package/dist/types/task/provider-retry-status.d.ts +8 -0
  65. package/dist/types/task/receipt.d.ts +7 -1
  66. package/dist/types/task/types.d.ts +23 -0
  67. package/dist/types/tools/ask-answer-registry.d.ts +2 -2
  68. package/dist/types/tools/bash-interactive.d.ts +4 -1
  69. package/dist/types/tools/bash.d.ts +3 -1
  70. package/dist/types/tools/image-gen.d.ts +4 -0
  71. package/dist/types/tools/index.d.ts +4 -0
  72. package/dist/types/tools/output-meta.d.ts +23 -2
  73. package/dist/types/tools/path-utils.d.ts +1 -0
  74. package/dist/types/tools/read.d.ts +0 -6
  75. package/dist/types/tools/review.d.ts +5 -7
  76. package/dist/types/tools/subagent.d.ts +2 -0
  77. package/dist/types/utils/gh.d.ts +13 -0
  78. package/dist/types/utils/image-resize.d.ts +1 -0
  79. package/dist/types/web/search/providers/kimi.d.ts +2 -0
  80. package/dist/types/web/search/providers/searxng.d.ts +7 -0
  81. package/dist/types/web/search/providers/xai.d.ts +2 -0
  82. package/package.json +10 -8
  83. package/scripts/acp-conformance-agent.ts +218 -0
  84. package/scripts/capture-gjc-bundle-settings.ts +306 -0
  85. package/scripts/generate-sdk-operation-inventory.ts +3 -0
  86. package/scripts/resident-memory-bench.ts +1341 -0
  87. package/scripts/run-acp-conformance.ts +285 -0
  88. package/src/async/job-manager.ts +78 -20
  89. package/src/cli/fast-help.ts +1 -1
  90. package/src/cli/plugin-cli.ts +176 -18
  91. package/src/commands/team.ts +19 -1
  92. package/src/config/file-lock.ts +11 -2
  93. package/src/config/settings-schema.ts +51 -0
  94. package/src/coordinator-mcp/question-gate-codec.ts +1 -1
  95. package/src/coordinator-mcp/server.ts +5 -12
  96. package/src/defaults/gjc/skills/deep-interview/SKILL.md +1 -0
  97. package/src/defaults/gjc/skills/ralplan/SKILL.md +19 -16
  98. package/src/defaults/gjc/skills/team/SKILL.md +2 -0
  99. package/src/defaults/gjc/skills/ultragoal/SKILL.md +37 -24
  100. package/src/defaults/gjc/skills/ultragoal/validation-batch-contracts.md +12 -0
  101. package/src/defaults/gjc-defaults.ts +4 -4
  102. package/src/exa/mcp-client.ts +12 -3
  103. package/src/exec/bash-executor.ts +144 -27
  104. package/src/extensibility/extensions/runner.ts +15 -0
  105. package/src/extensibility/extensions/types.ts +18 -1
  106. package/src/extensibility/gjc-plugins/constrained-hooks.ts +8 -2
  107. package/src/extensibility/gjc-plugins/index.ts +19 -2
  108. package/src/extensibility/gjc-plugins/installer.ts +189 -50
  109. package/src/extensibility/gjc-plugins/lifecycle-reconciliation.ts +199 -0
  110. package/src/extensibility/gjc-plugins/lifecycle.ts +626 -0
  111. package/src/extensibility/gjc-plugins/observability.ts +3 -3
  112. package/src/extensibility/gjc-plugins/prompt-appendix.ts +5 -1
  113. package/src/extensibility/gjc-plugins/runtime-adapters.ts +12 -2
  114. package/src/extensibility/gjc-plugins/runtime-quarantine.ts +121 -0
  115. package/src/extensibility/gjc-plugins/schema.ts +84 -12
  116. package/src/extensibility/gjc-plugins/session-validation.ts +9 -3
  117. package/src/extensibility/gjc-plugins/types.ts +141 -0
  118. package/src/extensibility/gjc-plugins/validation.ts +9 -0
  119. package/src/gjc-runtime/launch-tmux.ts +25 -6
  120. package/src/gjc-runtime/managed-owner-admission.ts +1 -1
  121. package/src/gjc-runtime/ralplan-runtime.ts +411 -17
  122. package/src/gjc-runtime/team-launch.ts +8 -0
  123. package/src/gjc-runtime/team-runtime.ts +140 -32
  124. package/src/gjc-runtime/team-worker-heartbeat.ts +149 -0
  125. package/src/gjc-runtime/tmux-owner-isolation.ts +10 -0
  126. package/src/gjc-runtime/tmux-sessions.ts +58 -13
  127. package/src/gjc-runtime/ultragoal-runtime.ts +401 -611
  128. package/src/gjc-runtime/workflow-manifest.generated.json +8 -99
  129. package/src/gjc-runtime/workflow-manifest.ts +4 -68
  130. package/src/internal-urls/docs-index.generated.ts +15 -15
  131. package/src/modes/DESIGN.md +51 -0
  132. package/src/modes/acp/acp-agent.ts +298 -186
  133. package/src/modes/components/gjc-bundle-settings.ts +508 -0
  134. package/src/modes/components/notifications-settings-editor.ts +12 -1
  135. package/src/modes/components/oauth-selector.ts +68 -30
  136. package/src/modes/components/session-observer-overlay.ts +305 -86
  137. package/src/modes/components/settings-selector.ts +56 -5
  138. package/src/modes/components/status-line/gh.ts +16 -0
  139. package/src/modes/components/tool-status-header.ts +3 -14
  140. package/src/modes/components/transcript-viewer-overlay.ts +99 -24
  141. package/src/modes/controllers/command-controller.ts +4 -0
  142. package/src/modes/controllers/extension-ui-controller.ts +7 -0
  143. package/src/modes/controllers/selector-controller.ts +45 -0
  144. package/src/modes/runtime-init.ts +3 -0
  145. package/src/prompts/agent-fragments/ralplan-persistence.md +4 -3
  146. package/src/prompts/system/eager-todo.md +8 -4
  147. package/src/prompts/system/system-prompt.md +11 -4
  148. package/src/prompts/tools/bash.md +5 -5
  149. package/src/prompts/tools/search-tool-bm25.md +5 -0
  150. package/src/prompts/tools/task.md +1 -1
  151. package/src/reminders/star-reminder.ts +4 -42
  152. package/src/runtime-mcp/manager.ts +19 -4
  153. package/src/sdk/acp/adapter.ts +22 -6
  154. package/src/sdk/broker/transport.ts +35 -3
  155. package/src/sdk/bus/attachment-registry.ts +5 -1
  156. package/src/sdk/bus/chat-daemon-cli.ts +4 -39
  157. package/src/sdk/bus/chat-daemon-control.ts +6 -3
  158. package/src/sdk/bus/config.ts +18 -2
  159. package/src/sdk/bus/index.ts +661 -126
  160. package/src/sdk/bus/kind-aware-reconciliation.ts +148 -73
  161. package/src/sdk/bus/lifecycle-commands.ts +4 -1
  162. package/src/sdk/bus/lifecycle-control-runtime.ts +403 -88
  163. package/src/sdk/bus/reconciliation-store.ts +95 -16
  164. package/src/sdk/bus/rich-draft.ts +1 -1
  165. package/src/sdk/bus/rich-render.ts +2 -2
  166. package/src/sdk/bus/telegram-adoption-intent.ts +574 -0
  167. package/src/sdk/bus/telegram-cli.ts +33 -8
  168. package/src/sdk/bus/telegram-daemon-cli.ts +1 -0
  169. package/src/sdk/bus/telegram-daemon-contract.ts +11 -4
  170. package/src/sdk/bus/telegram-daemon.ts +916 -23
  171. package/src/sdk/bus/telegram-reference.ts +40 -3
  172. package/src/sdk/bus/threaded-render.ts +48 -3
  173. package/src/sdk/bus/topic-registry.ts +20 -0
  174. package/src/sdk/host/query/revision-store.ts +36 -2
  175. package/src/sdk/host/reverse-leases.ts +44 -7
  176. package/src/sdk/prompt-status.ts +16 -3
  177. package/src/sdk/protocol/operation-inventory.generated.json +22 -0
  178. package/src/sdk/session.ts +88 -9
  179. package/src/session/agent-session.ts +822 -172
  180. package/src/session/artifacts.ts +22 -0
  181. package/src/session/auth-broker-config.ts +10 -3
  182. package/src/session/blob-store.ts +915 -24
  183. package/src/session/internal/managed-session-scope.ts +44 -6
  184. package/src/session/internal/managed-session-storage.ts +48 -6
  185. package/src/session/session-manager.ts +1614 -435
  186. package/src/session/session-storage.ts +9 -1
  187. package/src/session/streaming-output.ts +238 -68
  188. package/src/setup/credential-import.ts +75 -13
  189. package/src/skill-state/active-state.ts +32 -0
  190. package/src/skill-state/workflow-hud.ts +17 -0
  191. package/src/skill-state/workflow-mutation-guard.ts +496 -2
  192. package/src/task/executor.ts +122 -39
  193. package/src/task/index.ts +23 -14
  194. package/src/task/provider-retry-status.ts +16 -0
  195. package/src/task/receipt.ts +23 -7
  196. package/src/task/render.ts +6 -2
  197. package/src/task/types.ts +108 -0
  198. package/src/tools/ask-answer-registry.ts +12 -6
  199. package/src/tools/ask.ts +86 -55
  200. package/src/tools/ast-edit.ts +1 -0
  201. package/src/tools/ast-grep.ts +1 -0
  202. package/src/tools/bash-interactive.ts +8 -3
  203. package/src/tools/bash.ts +503 -60
  204. package/src/tools/find.ts +1 -0
  205. package/src/tools/image-gen.ts +29 -5
  206. package/src/tools/index.ts +4 -0
  207. package/src/tools/output-meta.ts +108 -14
  208. package/src/tools/path-utils.ts +3 -2
  209. package/src/tools/read.ts +18 -0
  210. package/src/tools/review.ts +5 -7
  211. package/src/tools/search.ts +1 -0
  212. package/src/tools/subagent-render.ts +6 -2
  213. package/src/tools/subagent.ts +6 -0
  214. package/src/tools/telegram-send.ts +6 -3
  215. package/src/utils/gh.ts +39 -0
  216. package/src/utils/image-resize.ts +3 -0
  217. package/src/web/search/providers/anthropic.ts +4 -4
  218. package/src/web/search/providers/kimi.ts +15 -5
  219. package/src/web/search/providers/searxng.ts +47 -4
  220. package/src/web/search/providers/xai.ts +7 -2
  221. package/dist/types/gjc-runtime/ultragoal-pipeline.d.ts +0 -23
  222. package/src/defaults/gjc/skills/ultragoal/pipeline-validation-contracts.md +0 -30
  223. package/src/gjc-runtime/ultragoal-pipeline.ts +0 -306
@@ -2,14 +2,31 @@ export * from "./activation";
2
2
  export * from "./compiler";
3
3
  export * from "./constrained-hooks";
4
4
  export * from "./injection";
5
- export * from "./installer";
5
+ /**
6
+ * Public barrel. Mutation primitives are deliberately NOT re-exported here:
7
+ * `lifecycle.ts` is the sole policy and persistence writer, so the installer
8
+ * transaction and the registry writers stay reachable only through their own
9
+ * modules. Re-exporting them would let a caller commit a replacement and
10
+ * bypass the create-only rule.
11
+ */
12
+ export { isGjcPluginBundleSource, isGjcPluginSourceShape } from "./installer";
13
+ export * from "./lifecycle";
14
+ export * from "./lifecycle-reconciliation";
6
15
  export * from "./loader";
7
16
  export * from "./mcp-policy";
8
17
  export * from "./observability";
9
18
  export * from "./paths";
10
19
  export * from "./prompt-appendix";
11
- export * from "./registry";
20
+ export {
21
+ loadEffectiveGjcPluginRegistry,
22
+ readRegistry,
23
+ registryEntryFingerprint,
24
+ registryPathForScope,
25
+ registryRootForScope,
26
+ sortRegistryEntries,
27
+ } from "./registry";
12
28
  export * from "./runtime-adapters";
29
+ export * from "./runtime-quarantine";
13
30
  export * from "./schema";
14
31
  export * from "./session-validation";
15
32
  export * from "./state";
@@ -6,16 +6,12 @@ import * as path from "node:path";
6
6
  import { gunzipSync } from "node:zlib";
7
7
  import { compileGjcPluginBundle } from "./compiler";
8
8
  import { gjcPluginProjectRoot, gjcPluginUserRoot } from "./paths";
9
- import {
10
- readRegistry,
11
- registryEntryFingerprint,
12
- sortRegistryEntries,
13
- withRegistryLock,
14
- writeRegistryUnlocked,
15
- } from "./registry";
9
+ import { readRegistry, sortRegistryEntries, withRegistryLock, writeRegistryUnlocked } from "./registry";
16
10
  import {
17
11
  GJC_PLUGIN_MANIFEST_FILENAME,
12
+ type GjcLifecycleError,
18
13
  GjcPluginLoadError,
14
+ type GjcPluginRegistry,
19
15
  type GjcPluginRegistryEntry,
20
16
  type GjcPluginRegistrySource,
21
17
  type GjcPluginScope,
@@ -23,17 +19,37 @@ import {
23
19
  } from "./types";
24
20
  import { validateInstallPlan } from "./validation";
25
21
 
26
- export interface InstallGjcPluginOptions {
22
+ export interface GjcBundleTransactionOptions {
27
23
  scope: GjcPluginScope;
28
24
  cwd: string;
29
- force?: boolean;
25
+ /**
26
+ * Policy hook evaluated while both scope locks are held. It decides whether
27
+ * to commit the candidate, report an already-satisfied no-op, or abort with
28
+ * a typed lifecycle error. Only the lifecycle service supplies this.
29
+ */
30
+ decide: (input: GjcBundleTransactionContext) => Promise<GjcBundleTransactionDecision>;
30
31
  }
31
32
 
32
- export interface InstallGjcPluginResult {
33
- status: "installed" | "updated" | "unchanged";
34
- entry: GjcPluginRegistryEntry;
33
+ export interface GjcBundleTransactionContext {
34
+ targetRegistry: GjcPluginRegistry;
35
+ /** Both scopes, deterministically sorted, for cross-scope decisions. */
36
+ effective: GjcPluginRegistryEntry[];
37
+ existing: GjcPluginRegistryEntry | undefined;
38
+ bundle: NormalizedGjcPluginBundle;
39
+ /** Entry the candidate would produce if committed as-is. */
40
+ candidate: GjcPluginRegistryEntry;
35
41
  }
36
42
 
43
+ export type GjcBundleTransactionDecision =
44
+ | { kind: "commit"; entry: GjcPluginRegistryEntry }
45
+ | { kind: "noop"; entry: GjcPluginRegistryEntry }
46
+ | { kind: "abort"; error: GjcLifecycleError };
47
+
48
+ export type GjcBundleTransactionResult =
49
+ | { status: "committed"; entry: GjcPluginRegistryEntry; remnants: string[] }
50
+ | { status: "noop"; entry: GjcPluginRegistryEntry; remnants: string[] }
51
+ | { status: "aborted"; error: GjcLifecycleError; remnants: string[] };
52
+
37
53
  // Resource limits for the in-house tar extractor (third-party security boundary).
38
54
  const TAR_MAX_FILES = 8192;
39
55
  const TAR_MAX_FILE_BYTES = 16 * 1024 * 1024;
@@ -113,8 +129,21 @@ function tarHeaderChecksumOk(header: Uint8Array): boolean {
113
129
 
114
130
  /** Minimal, traversal/symlink-safe, resource-bounded extraction of a tar(.gz). */
115
131
  async function extractTarball(tarPath: string, destRoot: string): Promise<void> {
116
- const raw = await fs.readFile(tarPath);
117
- const buf = /\.(tgz|tar\.gz)$/i.test(tarPath) ? gunzipSync(raw) : raw;
132
+ // A missing or corrupt archive surfaces as a native fs/zlib error. Translate
133
+ // it here so callers see the same typed source failure they get for every
134
+ // other unreachable source, instead of a raw errno escaping the lifecycle.
135
+ let raw: Buffer;
136
+ try {
137
+ raw = await fs.readFile(tarPath);
138
+ } catch {
139
+ throw new GjcPluginLoadError("missing_file", "GJC plugin tarball could not be read");
140
+ }
141
+ let buf: Buffer;
142
+ try {
143
+ buf = /\.(tgz|tar\.gz)$/i.test(tarPath) ? gunzipSync(raw) : raw;
144
+ } catch {
145
+ throw new GjcPluginLoadError("invalid_manifest", "GJC plugin tarball could not be decompressed");
146
+ }
118
147
  const resolvedRoot = path.resolve(destRoot);
119
148
  const decoder = new TextDecoder();
120
149
  let offset = 0;
@@ -215,17 +244,23 @@ function runGit(args: string[], cwd?: string): Promise<string> {
215
244
  // argv array (no shell) — repo/ref are passed as discrete args, not interpolated.
216
245
  const child = spawn("git", args, { cwd, stdio: ["ignore", "pipe", "pipe"] });
217
246
  let stdout = "";
218
- let stderr = "";
219
247
  child.stdout.on("data", d => {
220
248
  stdout += d;
221
249
  });
222
- child.stderr.on("data", d => {
223
- stderr += d;
250
+ // stderr is drained but never surfaced: git writes the remote URL into it,
251
+ // which can carry credentials.
252
+ child.stderr.resume();
253
+ // A spawn failure (git missing, ENOENT, EACCES) arrives as a raw system
254
+ // error. Convert it so the lifecycle can report a typed, sanitized source
255
+ // failure instead of letting an errno escape to the CLI.
256
+ child.on("error", () => {
257
+ reject(new GjcPluginLoadError("missing_file", "git is unavailable or could not be started"));
224
258
  });
225
- child.on("error", reject);
226
259
  child.on("close", code => {
227
260
  if (code === 0) resolve(stdout.trim());
228
- else reject(new GjcPluginLoadError("install_conflict", `git ${args[0]} failed: ${stderr.trim()}`));
261
+ // git writes the remote URL into stderr, which can carry credentials, so
262
+ // the operation is named without echoing the underlying output.
263
+ else reject(new GjcPluginLoadError("install_conflict", `git ${args[0]} failed`));
229
264
  });
230
265
  return promise;
231
266
  }
@@ -334,29 +369,61 @@ async function cleanupOrphans(root: string, dirName: string): Promise<void> {
334
369
  }
335
370
  }
336
371
 
337
- export async function installGjcPluginBundle(
372
+ /**
373
+ * Serialized bundle transaction: prepare outside the locks, then hold the
374
+ * user->project locks in a fixed order so the decision sees a consistent
375
+ * cross-scope view. Only the target scope is ever committed.
376
+ */
377
+ export async function runGjcBundleTransaction(
338
378
  source: string,
339
- options: InstallGjcPluginOptions,
340
- ): Promise<InstallGjcPluginResult> {
379
+ options: GjcBundleTransactionOptions,
380
+ ): Promise<GjcBundleTransactionResult> {
341
381
  const resolved = await resolveSource(source);
342
382
  try {
343
- // 1. Compile + validate (never imports plugin code).
383
+ // Compile + validate outside every lock (never imports plugin code).
344
384
  const bundle = await compileGjcPluginBundle(resolved.dir);
345
385
  const dirName = safeDirSegment(bundle.name);
346
386
  const root = scopeRoot(options.scope, options.cwd);
347
387
  const finalDir = path.join(root, dirName);
348
388
 
349
- // 2-4. Conflict check, atomic swap, and registry write are one serialized
350
- // transaction per scope so concurrent installs cannot race or lose updates.
351
- return await withRegistryLock(options.scope, options.cwd, async () => {
352
- await fs.mkdir(root, { recursive: true });
353
- await cleanupOrphans(root, dirName);
389
+ // Lock-free refusal preflight. Acquiring a scope lock creates the scope
390
+ // root and mutates directory metadata, so a create-only refusal must be
391
+ // decided before any lock is taken; otherwise "zero mutation" is false.
392
+ // The locked decision below re-checks, so this is an early-out only.
393
+ const preflightTarget = await readRegistry(options.scope, options.cwd);
394
+ const preexisting = preflightTarget.plugins.find(p => p.name === bundle.name);
395
+ if (preexisting) {
396
+ // The decision may compare a cross-scope fingerprint, so it must see the
397
+ // same complete universe the locked decision sees.
398
+ const preflightOther = await readRegistry(options.scope === "user" ? "project" : "user", options.cwd);
399
+ const early = await options.decide({
400
+ targetRegistry: preflightTarget,
401
+ effective: sortRegistryEntries([...preflightTarget.plugins, ...preflightOther.plugins]),
402
+ existing: preexisting,
403
+ bundle,
404
+ candidate: bundleToRegistryEntry(
405
+ bundle,
406
+ finalDir,
407
+ options.scope,
408
+ resolved.source,
409
+ new Date().toISOString(),
410
+ ),
411
+ });
412
+ // Only an abort is honoured here; everything else is re-decided under
413
+ // the lock, so this can never short-circuit a commit.
414
+ if (early.kind === "abort") return { status: "aborted", error: early.error, remnants: [] };
415
+ }
354
416
 
355
- const registry = await readRegistry(options.scope, options.cwd);
356
- const existing = registry.plugins.find(p => p.name === bundle.name);
357
- // Hard install-time collision + MCP security validation against the
358
- // effective installed registry (registry is the collision authority).
359
- validateInstallPlan(bundle, registry.plugins);
417
+ const critical = async (): Promise<GjcBundleTransactionResult> => {
418
+ // Read-only until the policy decision resolves. A refusal must not create
419
+ // the scope root or sweep orphans, so an existing-target refusal leaves
420
+ // the filesystem byte-for-byte untouched.
421
+
422
+ const targetRegistry = await readRegistry(options.scope, options.cwd);
423
+ const otherScope: GjcPluginScope = options.scope === "user" ? "project" : "user";
424
+ const otherRegistry = await readRegistry(otherScope, options.cwd);
425
+ const effective = sortRegistryEntries([...targetRegistry.plugins, ...otherRegistry.plugins]);
426
+ const existing = targetRegistry.plugins.find(p => p.name === bundle.name);
360
427
  const candidate = bundleToRegistryEntry(
361
428
  bundle,
362
429
  finalDir,
@@ -364,18 +431,21 @@ export async function installGjcPluginBundle(
364
431
  resolved.source,
365
432
  new Date().toISOString(),
366
433
  );
367
- if (existing) {
368
- const sameContent = registryEntryFingerprint(existing) === registryEntryFingerprint(candidate);
369
- if (sameContent && (await isDirectory(finalDir))) {
370
- return { status: "unchanged" as const, entry: existing };
371
- }
372
- if (!options.force) {
373
- throw new GjcPluginLoadError(
374
- "install_conflict",
375
- `GJC plugin "${bundle.name}" is already installed with different content; pass --force to replace it`,
376
- );
377
- }
378
- }
434
+
435
+ const decision = await options.decide({ targetRegistry, effective, existing, bundle, candidate });
436
+ if (decision.kind === "abort") return { status: "aborted", error: decision.error, remnants: [] };
437
+ if (decision.kind === "noop") return { status: "noop", entry: decision.entry, remnants: [] };
438
+
439
+ // The decision committed, so mutation may begin.
440
+ await fs.mkdir(root, { recursive: true });
441
+ await cleanupOrphans(root, dirName);
442
+
443
+ // Hard install-time collision + MCP security validation against the
444
+ // effective registry across BOTH scopes. Surface IDs derive from the
445
+ // surface name, not the bundle name, so a differently named bundle in
446
+ // the opposite scope can claim the same ID; only the exact target
447
+ // identity is excluded, since that is the entry being replaced.
448
+ validateInstallPlan(bundle, effective);
379
449
 
380
450
  const unique = `${process.pid}-${randomBytes(6).toString("hex")}`;
381
451
  const stagingDir = `${finalDir}.installing-${unique}`;
@@ -394,8 +464,8 @@ export async function installGjcPluginBundle(
394
464
  // Registry write last; on failure, roll the filesystem back.
395
465
  try {
396
466
  const next = sortRegistryEntries([
397
- ...registry.plugins.filter(p => p.name !== bundle.name),
398
- { ...candidate, installedAt: existing?.installedAt ?? candidate.installedAt },
467
+ ...targetRegistry.plugins.filter(p => p.name !== bundle.name),
468
+ decision.entry,
399
469
  ]);
400
470
  await writeRegistryUnlocked({ version: 1, scope: options.scope, plugins: next }, options.cwd);
401
471
  } catch (error) {
@@ -403,17 +473,86 @@ export async function installGjcPluginBundle(
403
473
  if (hadFinal) await fs.rename(backupDir, finalDir);
404
474
  throw error;
405
475
  }
406
- if (hadFinal) await fs.rm(backupDir, { recursive: true, force: true });
407
- return { status: existing ? ("updated" as const) : ("installed" as const), entry: candidate };
476
+ const remnants: string[] = [];
477
+ if (hadFinal) {
478
+ try {
479
+ await fs.rm(backupDir, { recursive: true, force: true });
480
+ } catch {
481
+ remnants.push(backupDir);
482
+ }
483
+ }
484
+ return { status: "committed", entry: decision.entry, remnants };
408
485
  } finally {
409
486
  await fs.rm(stagingDir, { recursive: true, force: true });
410
487
  }
411
- });
488
+ };
489
+
490
+ // Surface IDs are globally unique, so the collision decision spans both
491
+ // scopes and must be serialized against every other writer. Both locks are
492
+ // therefore held, in a fixed user->project order to avoid deadlock,
493
+ // regardless of which scope commits. Refusal purity is preserved by the
494
+ // pre-lock preflight above, which returns before any lock is acquired.
495
+ return await withRegistryLock("user", options.cwd, () => withRegistryLock("project", options.cwd, critical));
496
+ } finally {
497
+ await resolved.cleanup();
498
+ }
499
+ }
500
+
501
+ /** Compile a source into a validated candidate bundle without touching disk state. */
502
+ export async function resolveGjcBundleCandidate<T>(
503
+ source: string,
504
+ fn: (input: { bundle: NormalizedGjcPluginBundle; source: GjcPluginRegistrySource }) => Promise<T>,
505
+ ): Promise<T> {
506
+ const resolved = await resolveSource(source);
507
+ try {
508
+ const bundle = await compileGjcPluginBundle(resolved.dir);
509
+ return await fn({ bundle, source: resolved.source });
412
510
  } finally {
413
511
  await resolved.cleanup();
414
512
  }
415
513
  }
416
514
 
515
+ /** Build the registry entry a candidate bundle would produce at a target path. */
516
+ export function candidateRegistryEntry(
517
+ bundle: NormalizedGjcPluginBundle,
518
+ scope: GjcPluginScope,
519
+ cwd: string,
520
+ source: GjcPluginRegistrySource,
521
+ now: string,
522
+ ): GjcPluginRegistryEntry {
523
+ const finalDir = path.join(scopeRoot(scope, cwd), safeDirSegment(bundle.name));
524
+ return bundleToRegistryEntry(bundle, finalDir, scope, source, now);
525
+ }
526
+
527
+ /**
528
+ * True when a spec has the SHAPE of a GJC bundle source: a filesystem path, a
529
+ * git locator, or a tarball. This is a pure string test that never touches the
530
+ * filesystem or the network, so a deleted or unreachable source is still
531
+ * recognised as GJC-intent and can reach the lifecycle's typed refusal instead
532
+ * of falling through to npm.
533
+ *
534
+ * npm and marketplace specs are never path/git/tarball shaped, so this cleanly
535
+ * separates the two install worlds.
536
+ */
537
+ export function isGjcPluginSourceShape(source: string): boolean {
538
+ if (looksLikeGit(source)) return true;
539
+ // Explicit path forms, POSIX and Windows.
540
+ const isPathShaped =
541
+ source.startsWith("/") ||
542
+ source.startsWith("./") ||
543
+ source.startsWith("../") ||
544
+ source.startsWith("~/") ||
545
+ source.startsWith(".\\") ||
546
+ source.startsWith("..\\") ||
547
+ /^[a-zA-Z]:[\\/]/.test(source) ||
548
+ source.startsWith("\\\\");
549
+ if (isPathShaped) return true;
550
+ // A tarball SUFFIX alone is not enough: npm package names may contain dots,
551
+ // so `foo.tgz` and `@scope/foo.tar.gz` are legal npm specs. Only claim an
552
+ // archive when the locator is also path- or URL-shaped.
553
+ return isTarball(source) && /^[a-z][a-z0-9+.-]*:\/\//i.test(source);
554
+ }
555
+
417
556
  /** True only when the source actually resolves to a GJC plugin bundle (root gajae-plugin.json). */
418
557
  export async function isGjcPluginBundleSource(source: string): Promise<boolean> {
419
558
  if (!isTarball(source) && !looksLikeGit(source)) {
@@ -0,0 +1,199 @@
1
+ import { createHash } from "node:crypto";
2
+ import {
3
+ GJC_BUNDLE_KIND,
4
+ type GjcBundleIdentity,
5
+ type GjcPluginQuarantineEntry,
6
+ type GjcPluginRegistryEntry,
7
+ type GjcPluginScope,
8
+ type NormalizedGjcPluginBundle,
9
+ type NormalizedGjcPluginSurfaces,
10
+ } from "./types";
11
+
12
+ /**
13
+ * Pure fingerprint and reconciliation helpers for the GJC bundle lifecycle.
14
+ * Nothing here reads or writes the filesystem: every function is a
15
+ * deterministic projection of its inputs so preview/apply can compare-and-swap
16
+ * on stable hashes.
17
+ */
18
+
19
+ function sha256(text: string): string {
20
+ return createHash("sha256").update(text, "utf8").digest("hex");
21
+ }
22
+
23
+ export function bundleIdentity(scope: GjcPluginScope, name: string): GjcBundleIdentity {
24
+ return { kind: GJC_BUNDLE_KIND, scope, name };
25
+ }
26
+
27
+ export function identityKey(identity: GjcBundleIdentity): string {
28
+ return `${identity.kind}\u0000${identity.scope}\u0000${identity.name}`;
29
+ }
30
+
31
+ export function identityEquals(a: GjcBundleIdentity, b: GjcBundleIdentity): boolean {
32
+ return a.kind === b.kind && a.scope === b.scope && a.name === b.name;
33
+ }
34
+
35
+ /** All stable surface IDs of a surface set, sorted and de-duplicated. */
36
+ export function surfaceIdsOf(surfaces: NormalizedGjcPluginSurfaces): string[] {
37
+ const ids = [
38
+ ...surfaces.subskills.map(s => s.extensionId),
39
+ ...surfaces.tools.map(t => t.extensionId),
40
+ ...surfaces.hooks.map(h => h.extensionId),
41
+ ...surfaces.mcps.map(m => m.extensionId),
42
+ ...surfaces.systemAppendices.map(a => a.extensionId),
43
+ ...surfaces.agentAppendices.map(a => a.extensionId),
44
+ ];
45
+ return [...new Set(ids)].sort();
46
+ }
47
+
48
+ /**
49
+ * Fingerprint of the exact installed target: identity plus the persisted bytes
50
+ * that any update must not silently replace.
51
+ */
52
+ export function targetFingerprint(entry: GjcPluginRegistryEntry): string {
53
+ const files = [...entry.copiedFiles]
54
+ .map(f => `${f.relativePath}:${f.sha256}:${f.bytes}`)
55
+ .sort()
56
+ .join("\n");
57
+ return sha256(
58
+ [
59
+ identityKey(bundleIdentity(entry.scope, entry.name)),
60
+ entry.version,
61
+ entry.manifestHash,
62
+ surfaceIdsOf(entry.surfaces).join(","),
63
+ files,
64
+ ].join("\u0000"),
65
+ );
66
+ }
67
+
68
+ /**
69
+ * Fingerprint of the exact installed baseline an update compares against: the
70
+ * installed content, the persisted enablement intent it must carry forward,
71
+ * and the stored source descriptor the update re-resolves from.
72
+ *
73
+ * Toggling the bundle or any surface changes this, so a preview taken before
74
+ * the toggle can no longer be applied. Binding the source matters just as much:
75
+ * apply re-resolves `entry.source.uri` before taking the locks, so a descriptor
76
+ * that changed in between must invalidate the reviewed baseline rather than let
77
+ * an update be committed from a locator the reviewer never saw.
78
+ */
79
+ export function baselineFingerprint(entry: GjcPluginRegistryEntry): string {
80
+ const source = [
81
+ entry.source.kind,
82
+ entry.source.uri,
83
+ entry.source.ref ?? "",
84
+ entry.source.sha ?? "",
85
+ entry.source.resolvedAt,
86
+ ].join("\u0001");
87
+ return sha256(
88
+ [
89
+ targetFingerprint(entry),
90
+ entry.enabled ? "1" : "0",
91
+ [...new Set(entry.disabledSurfaceIds)].sort().join(","),
92
+ [...new Set((entry.quarantine ?? []).map(q => `${q.surfaceId}:${q.code}`))].sort().join(","),
93
+ source,
94
+ ].join("\u0000"),
95
+ );
96
+ }
97
+
98
+ /** Fingerprint of an update candidate before it is written anywhere. */
99
+ export function candidateFingerprint(scope: GjcPluginScope, bundle: NormalizedGjcPluginBundle): string {
100
+ const files = [...bundle.files]
101
+ .map(f => `${f.relativePath}:${f.sha256}:${f.bytes}`)
102
+ .sort()
103
+ .join("\n");
104
+ return sha256(
105
+ [
106
+ identityKey(bundleIdentity(scope, bundle.name)),
107
+ bundle.version,
108
+ bundle.manifestHash,
109
+ surfaceIdsOf(bundle.surfaces).join(","),
110
+ files,
111
+ ].join("\u0000"),
112
+ );
113
+ }
114
+
115
+ /**
116
+ * Fingerprint of everything besides the target and candidate bytes that the
117
+ * update decision depended on: opposite-scope same-name entries and the
118
+ * effective collision universe.
119
+ */
120
+ export function decisionContextFingerprint(
121
+ target: GjcBundleIdentity,
122
+ effectiveEntries: readonly GjcPluginRegistryEntry[],
123
+ ): string {
124
+ // The target's own state is covered by the baseline fingerprint; this hash
125
+ // only tracks the surrounding universe (notably same-name opposite-scope
126
+ // entries) so drift is attributed to the correct gate.
127
+ const parts = effectiveEntries
128
+ .filter(e => !identityEquals(bundleIdentity(e.scope, e.name), target))
129
+ .map(e => `${e.scope}\u0000${e.name}\u0000${e.version}\u0000${e.manifestHash}\u0000${e.enabled ? "1" : "0"}`)
130
+ .sort();
131
+ return sha256([identityKey(target), ...parts].join("\n"));
132
+ }
133
+
134
+ /**
135
+ * Fingerprint of the inputs that decide live activation. Changes here (and only
136
+ * here) advance the activation generation.
137
+ */
138
+ export function activationFingerprint(entries: readonly GjcPluginRegistryEntry[]): string {
139
+ const parts = entries
140
+ .filter(e => e.enabled)
141
+ .map(e => {
142
+ const disabled = [...new Set(e.disabledSurfaceIds)].sort().join(",");
143
+ const quarantined = [...new Set((e.quarantine ?? []).map(q => q.surfaceId))].sort().join(",");
144
+ return [identityKey(bundleIdentity(e.scope, e.name)), e.manifestHash, disabled, quarantined].join("\u0000");
145
+ })
146
+ .sort();
147
+ return sha256(parts.join("\n"));
148
+ }
149
+
150
+ export interface ReconciledEnablement {
151
+ disabledSurfaceIds: string[];
152
+ quarantine: GjcPluginQuarantineEntry[];
153
+ }
154
+
155
+ /**
156
+ * Carry persisted enablement intent across an update:
157
+ * - surviving disabled IDs stay disabled,
158
+ * - IDs whose surface disappeared are dropped,
159
+ * - new surface IDs are enabled by omission,
160
+ * - quarantine is recomputed from candidateQuarantine against the candidate's
161
+ * surface set; omitted input means no candidate quarantine is justified.
162
+ */
163
+ export function reconcileEnablement(
164
+ previousDisabledSurfaceIds: readonly string[],
165
+ candidateSurfaceIds: readonly string[],
166
+ candidateQuarantine: readonly GjcPluginQuarantineEntry[] = [],
167
+ ): ReconciledEnablement {
168
+ const surviving = new Set(candidateSurfaceIds);
169
+ const disabledSurfaceIds = [...new Set(previousDisabledSurfaceIds)].filter(id => surviving.has(id)).sort();
170
+ const seen = new Set<string>();
171
+ const quarantine = candidateQuarantine
172
+ .filter(q => {
173
+ if (!surviving.has(q.surfaceId)) return false;
174
+ if (seen.has(q.surfaceId)) return false;
175
+ seen.add(q.surfaceId);
176
+ return true;
177
+ })
178
+ .sort((a, b) => a.surfaceId.localeCompare(b.surfaceId));
179
+ return { disabledSurfaceIds, quarantine };
180
+ }
181
+
182
+ export interface SurfaceDelta {
183
+ addedSurfaceIds: string[];
184
+ removedSurfaceIds: string[];
185
+ retainedSurfaceIds: string[];
186
+ }
187
+
188
+ export function diffSurfaceIds(
189
+ currentSurfaceIds: readonly string[],
190
+ candidateSurfaceIds: readonly string[],
191
+ ): SurfaceDelta {
192
+ const current = new Set(currentSurfaceIds);
193
+ const candidate = new Set(candidateSurfaceIds);
194
+ return {
195
+ addedSurfaceIds: [...candidate].filter(id => !current.has(id)).sort(),
196
+ removedSurfaceIds: [...current].filter(id => !candidate.has(id)).sort(),
197
+ retainedSurfaceIds: [...candidate].filter(id => current.has(id)).sort(),
198
+ };
199
+ }