@gajae-code/coding-agent 0.15.3 → 0.15.5

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 (55) hide show
  1. package/CHANGELOG.md +26 -1
  2. package/dist/types/cli/update-cli.d.ts +25 -5
  3. package/dist/types/config/autorouting-tier-map.d.ts +3 -0
  4. package/dist/types/config/settings-schema.d.ts +52 -0
  5. package/dist/types/extensibility/extensions/types.d.ts +5 -0
  6. package/dist/types/extensibility/shared-events.d.ts +2 -0
  7. package/dist/types/gjc-runtime/workflow-placeholder.d.ts +6 -0
  8. package/dist/types/modes/components/hook-selector.d.ts +1 -0
  9. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +8 -1
  10. package/dist/types/sdk/cli/session-cli.d.ts +42 -1
  11. package/dist/types/sdk/host/host.d.ts +2 -0
  12. package/dist/types/sdk/host/session-runtime.d.ts +4 -0
  13. package/dist/types/session/agent-session.d.ts +4 -2
  14. package/dist/types/session/internal/managed-session-storage.d.ts +4 -3
  15. package/dist/types/tools/ask.d.ts +1 -0
  16. package/dist/types/tools/path-utils.d.ts +5 -6
  17. package/dist/types/tools/tmux-self-injection-guard.d.ts +15 -0
  18. package/package.json +7 -7
  19. package/src/cli/update-cli.ts +102 -1
  20. package/src/commands/sdk.ts +5 -0
  21. package/src/config/autorouting-tier-map.ts +1 -0
  22. package/src/config/model-profiles.ts +5 -5
  23. package/src/config/model-registry.ts +10 -4
  24. package/src/config/settings-schema.ts +53 -0
  25. package/src/config/settings.ts +1 -1
  26. package/src/coordinator-mcp/policy.ts +1 -1
  27. package/src/deep-interview/render-middleware.ts +6 -2
  28. package/src/extensibility/extensions/types.ts +5 -0
  29. package/src/extensibility/shared-events.ts +2 -0
  30. package/src/gjc-runtime/launch-worktree.ts +44 -7
  31. package/src/gjc-runtime/ralplan-runtime.ts +20 -2
  32. package/src/gjc-runtime/tmux-sessions.ts +145 -48
  33. package/src/gjc-runtime/ultragoal-runtime.ts +5 -0
  34. package/src/gjc-runtime/workflow-placeholder.ts +33 -0
  35. package/src/internal-urls/docs-index.generated.ts +10 -9
  36. package/src/modes/components/hook-selector.ts +2 -1
  37. package/src/modes/controllers/extension-ui-controller.ts +1 -0
  38. package/src/modes/prompt-action-autocomplete.ts +26 -47
  39. package/src/sdk/bus/chat-daemon-control.ts +2 -2
  40. package/src/sdk/bus/kind-aware-reconciliation.ts +10 -2
  41. package/src/sdk/bus/prompt-reconciliation.ts +5 -2
  42. package/src/sdk/bus/telegram-daemon-contract.ts +8 -1
  43. package/src/sdk/cli/session-cli.ts +149 -4
  44. package/src/sdk/host/host.ts +7 -0
  45. package/src/sdk/host/session-runtime.test.ts +821 -22
  46. package/src/sdk/host/session-runtime.ts +669 -67
  47. package/src/sdk/router/session-router.ts +69 -15
  48. package/src/session/agent-session.ts +302 -25
  49. package/src/session/internal/managed-session-storage.ts +64 -26
  50. package/src/session/session-manager.ts +26 -8
  51. package/src/tools/ask-contract.ts +54 -3
  52. package/src/tools/ask.ts +46 -6
  53. package/src/tools/bash.ts +14 -0
  54. package/src/tools/path-utils.ts +77 -29
  55. package/src/tools/tmux-self-injection-guard.ts +359 -0
@@ -838,6 +838,10 @@ class SdkSessionHelp extends Command {
838
838
  description: "Workspace directory for saved-session resolution (default: current directory)",
839
839
  }),
840
840
  op: Flags.string({ description: "Raw control or global operation" }),
841
+ scope: Flags.string({
842
+ description: "list scope: repo (default), cwd, worktree, or all",
843
+ options: ["repo", "cwd", "worktree", "all"],
844
+ }),
841
845
  query: Flags.string({ description: "Raw query name" }),
842
846
  "json-input": Flags.string({ description: "SDK request JSON object" }),
843
847
  "json-input-file": Flags.string({ description: "Read SDK request JSON from a 0600 file" }),
@@ -894,6 +898,7 @@ class SdkSessionCommand extends Command {
894
898
  allEvents: Boolean(flagRec["all-events"]),
895
899
  agentDir: flagRec["agent-dir"] as string | undefined,
896
900
  repo: flagRec.repo as string | undefined,
901
+ scope: flagRec.scope as string | undefined,
897
902
  });
898
903
  }
899
904
  }
@@ -146,6 +146,7 @@ export const TIER_MAP_SKIP_LIST = {
146
146
  },
147
147
  "vercel-ai-gateway/google/gemini-3.7-flash": { rationale: "post-rebase catalog addition from dev; not yet curated" },
148
148
  "zai/glm-5.3": { rationale: "post-rebase catalog addition from dev; not yet curated" },
149
+ "zai/glm-5.3-flash": { rationale: "post-feature catalog addition; not yet curated" },
149
150
  "cursor/gemini-3.7-flash-high": {
150
151
  rationale: "post-rebase catalog addition from dev; not yet curated",
151
152
  },
@@ -247,22 +247,22 @@ export const BUILTIN_MODEL_PROFILES: readonly ModelProfileDefinition[] = [
247
247
  architect: "anthropic/claude-fable-5:xhigh",
248
248
  }),
249
249
  profile("glm-eco", ["zai"], {
250
- default: "zai/glm-5.3:low",
251
- executor: "zai/glm-5.3:low",
252
- planner: "zai/glm-5.3:low",
250
+ default: "zai/glm-5.3-flash:low",
251
+ executor: "zai/glm-5.3-flash:low",
252
+ planner: "zai/glm-5.3-flash:low",
253
253
  critic: "zai/glm-5.3:high",
254
254
  architect: "zai/glm-5.3:high",
255
255
  }),
256
256
  profile("glm-medium", ["zai"], {
257
257
  default: "zai/glm-5.3:high",
258
- executor: "zai/glm-5.3:low",
258
+ executor: "zai/glm-5.3-flash:low",
259
259
  planner: "zai/glm-5.3:high",
260
260
  critic: "zai/glm-5.3:high",
261
261
  architect: "zai/glm-5.3:max",
262
262
  }),
263
263
  profile("glm-pro", ["zai"], {
264
264
  default: "zai/glm-5.3:max",
265
- executor: "zai/glm-5.3:high",
265
+ executor: "zai/glm-5.3-flash:high",
266
266
  planner: "zai/glm-5.3:high",
267
267
  critic: "zai/glm-5.3:max",
268
268
  architect: "zai/glm-5.3:max",
@@ -2154,7 +2154,9 @@ export class ModelRegistry {
2154
2154
  });
2155
2155
  if (localCompatResolvedKey) {
2156
2156
  this.#customProviderApiKeys.set(providerName, localCompatResolvedKey);
2157
- this.authStorage.setConfigApiKey(providerName, localCompatResolvedKey);
2157
+ this.authStorage.setConfigApiKey(providerName, localCompatResolvedKey, {
2158
+ envSourced: !localOpenAICompat.apiKey,
2159
+ });
2158
2160
  } else {
2159
2161
  keylessProviders.add(providerName);
2160
2162
  this.#optionalAuthProviders.add(providerName);
@@ -2216,7 +2218,9 @@ export class ModelRegistry {
2216
2218
  ? resolveApiKeyEnvConfig(providerConfig.apiKeyEnv)
2217
2219
  : undefined;
2218
2220
  if (resolved) this.#customProviderApiKeys.set(providerName, resolved);
2219
- if (resolved) this.authStorage.setConfigApiKey(providerName, resolved);
2221
+ if (resolved) {
2222
+ this.authStorage.setConfigApiKey(providerName, resolved, { envSourced: !providerConfig.apiKey });
2223
+ }
2220
2224
  }
2221
2225
 
2222
2226
  // Parse per-model overrides
@@ -3806,7 +3810,9 @@ export class ModelRegistry {
3806
3810
  ? resolveApiKeyEnvConfig(providerConfig.apiKeyEnv)
3807
3811
  : undefined;
3808
3812
  if (resolved) this.#customProviderApiKeys.set(providerName, resolved);
3809
- if (resolved) this.authStorage.setConfigApiKey(providerName, resolved);
3813
+ if (resolved) {
3814
+ this.authStorage.setConfigApiKey(providerName, resolved, { envSourced: !providerConfig.apiKey });
3815
+ }
3810
3816
  }
3811
3817
  for (const modelDef of modelDefs) {
3812
3818
  const providerCompat = providerConfig.disableStrictTools
@@ -4547,7 +4553,7 @@ export class ModelRegistry {
4547
4553
  return;
4548
4554
  }
4549
4555
  this.#customProviderApiKeys.set(provider, resolved);
4550
- this.authStorage.setConfigApiKey(provider, resolved);
4556
+ this.authStorage.setConfigApiKey(provider, resolved, { envSourced: true });
4551
4557
  }
4552
4558
 
4553
4559
  async #peekApiKeyForProvider(
@@ -1940,6 +1940,52 @@ export const SETTINGS_SCHEMA = {
1940
1940
  },
1941
1941
  },
1942
1942
 
1943
+ "compaction.adaptive.enabled": {
1944
+ type: "boolean",
1945
+ default: false,
1946
+ ui: {
1947
+ tab: "context",
1948
+ label: "Adaptive Compaction",
1949
+ description: "Adjust compaction frequency by context size and call rate",
1950
+ },
1951
+ },
1952
+ "compaction.adaptive.baseThresholdPercent": {
1953
+ type: "number",
1954
+ default: 85,
1955
+ ui: {
1956
+ tab: "context",
1957
+ label: "Adaptive Base Threshold %",
1958
+ description: "Base context percentage used when adaptive compaction is enabled",
1959
+ },
1960
+ },
1961
+ "compaction.adaptive.aggression": {
1962
+ type: "number",
1963
+ default: 0.15,
1964
+ ui: {
1965
+ tab: "context",
1966
+ label: "Adaptive Aggression",
1967
+ description: "How strongly call rate lowers the compaction threshold; use 0 to 1",
1968
+ },
1969
+ },
1970
+ "compaction.adaptive.turnWindow": {
1971
+ type: "number",
1972
+ default: 15,
1973
+ ui: {
1974
+ tab: "context",
1975
+ label: "Call-Rate Window",
1976
+ description: "Minutes of recent calls considered by adaptive compaction",
1977
+ },
1978
+ },
1979
+ "compaction.adaptive.minThresholdPercent": {
1980
+ type: "number",
1981
+ default: 50,
1982
+ ui: {
1983
+ tab: "context",
1984
+ label: "Adaptive Floor %",
1985
+ description: "Lowest context percentage adaptive compaction may lower the threshold to",
1986
+ },
1987
+ },
1988
+
1943
1989
  "compaction.handoffSaveToDisk": {
1944
1990
  type: "boolean",
1945
1991
  default: false,
@@ -4332,6 +4378,13 @@ export interface CompactionSettings {
4332
4378
  strategy: "context-full" | "handoff" | "off";
4333
4379
  thresholdPercent: number;
4334
4380
  thresholdTokens: number;
4381
+ adaptive: {
4382
+ enabled: boolean;
4383
+ baseThresholdPercent: number;
4384
+ aggression: number;
4385
+ turnWindow: number;
4386
+ minThresholdPercent: number;
4387
+ };
4335
4388
  reserveTokens: number;
4336
4389
  keepRecentTokens: number;
4337
4390
  handoffSaveToDisk: boolean;
@@ -1174,7 +1174,7 @@ export class Settings implements NotificationSettingsReader {
1174
1174
  for (const key of Object.keys(SETTINGS_SCHEMA) as SettingPath[]) {
1175
1175
  if (key.startsWith(`${prefix}.`)) {
1176
1176
  const suffix = key.slice(prefix.length + 1);
1177
- result[suffix] = this.get(key);
1177
+ setByPath(result, suffix.split("."), this.get(key));
1178
1178
  }
1179
1179
  }
1180
1180
  return result as unknown as GroupTypeMap[G];
@@ -112,7 +112,7 @@ function parseRootList(value: string | undefined): string[] {
112
112
  }
113
113
 
114
114
  function resolveManagedWorktreeRoot(root: string, configured: string | undefined): string {
115
- const template = (configured?.trim() || "{repo}.gajae-code-worktrees").replace(/^~(?=\/|$)/, os.homedir());
115
+ const template = (configured?.trim() || "{repo}/.worktrees").replace(/^~(?=\/|$)/, os.homedir());
116
116
  const resolved = template.replaceAll("{repo}", path.basename(root));
117
117
  return path.resolve(path.isAbsolute(resolved) ? resolved : path.join(path.dirname(root), resolved));
118
118
  }
@@ -10,6 +10,7 @@ import {
10
10
  type ViewportAnchorSource,
11
11
  type ViewportAnchorSourceRenderer,
12
12
  } from "@gajae-code/tui";
13
+ import { isLegacyDeepInterviewPlaceholder, isWorkflowPlaceholderText } from "../gjc-runtime/workflow-placeholder";
13
14
  import { getMarkdownTheme, type Theme } from "../modes/theme/theme";
14
15
 
15
16
  interface RoundQuestionModel {
@@ -132,7 +133,7 @@ function parseRoundQuestion(text: string): RoundQuestionModel | null {
132
133
  .slice(headerIndex + 1)
133
134
  .join("\n")
134
135
  .trim();
135
- if (!body) return null;
136
+ if (isWorkflowPlaceholderText(body)) return null;
136
137
 
137
138
  const componentMatch =
138
139
  /^Round\s+(\d+)\s+\|\s+Component:\s*(.*?)\s+\|\s+Targeting:\s*(.*?)\s+\|\s+Why now:\s*(.*?)\s+\|\s+Ambiguity:\s*(.+?)%?\s*$/i.exec(
@@ -204,11 +205,13 @@ function parseTopologyQuestion(text: string): TopologyQuestionModel | null {
204
205
  if (inQuestion) questionLines.push(trimmed);
205
206
  else contextLines.push(trimmed);
206
207
  }
208
+ const question = questionLines.join("\n");
209
+ if (isWorkflowPlaceholderText(question)) return null;
207
210
  return {
208
211
  kind: "topology-question",
209
212
  context: contextLines.join("\n") || undefined,
210
213
  components,
211
- question: questionLines.join("\n"),
214
+ question,
212
215
  };
213
216
  }
214
217
 
@@ -399,6 +402,7 @@ export function renderDeepInterviewAskQuestion(question: string, uiTheme: Theme)
399
402
  export function isDeepInterviewAskQuestion(question: string): boolean {
400
403
  if (parseTopologyQuestion(question) ?? parseRoundQuestion(question)) return true;
401
404
  const normalized = normalizeText(question);
405
+ if (isLegacyDeepInterviewPlaceholder(normalized)) return false;
402
406
  return /(?:^|\n)\s*Round\s+\d+\s*\|.*?\bAmbiguity\b/i.test(normalized);
403
407
  }
404
408
 
@@ -155,6 +155,8 @@ export interface ExtensionUIDialogOptions {
155
155
  customInput?: {
156
156
  optionLabel: string;
157
157
  onSubmit: (text: string) => void;
158
+ /** Empty/whitespace-only submissions may be rejected by the selector. */
159
+ allowEmpty?: boolean;
158
160
  };
159
161
  /**
160
162
  * Inline free-text input for a non-answer clarification action. It is
@@ -534,6 +536,7 @@ export interface ExtensionContext {
534
536
  onPreflightAcceptCommit?: () => void | Promise<void>;
535
537
  onSkillPrepared?: (meta: { name: string; path: string; lineCount?: number; cleanedArgs?: string }) => void;
536
538
  preflightSignal?: AbortSignal;
539
+ sdkRunToken?: string;
537
540
  },
538
541
  ): Promise<unknown>;
539
542
  setPlanMode?(on: boolean): unknown;
@@ -752,6 +755,8 @@ export interface AgentStartEvent extends SharedAgentStartEvent {
752
755
  export interface AgentFailedEvent {
753
756
  type: "agent_failed";
754
757
  error: AgentFailureDiagnostic;
758
+ /** Internal SDK queue-owner binding for exact lifecycle attribution. */
759
+ sdkRunToken?: string;
755
760
  /** Attempt correlation for the failing run, when scoped (matches the
756
761
  * agent_start/agent_end scope contract). */
757
762
  scope?: AttemptScope;
@@ -184,6 +184,8 @@ export interface AgentStartEvent {
184
184
  export interface AgentEndEvent {
185
185
  type: "agent_end";
186
186
  messages: AgentMessage[];
187
+ /** Internal SDK queue-owner binding for exact lifecycle attribution. */
188
+ sdkRunToken?: string;
187
189
  /** Indicates whether the loop ended normally, suspended, cancelled, or entered maintenance. */
188
190
  stopReason?: "completed" | "paused" | "cancelled" | "maintenance";
189
191
  /** Present for maintenance checkpoints; non-aborted checkpoints are not terminal. */
@@ -78,7 +78,7 @@ const WORKTREE_BUCKET_ENV = "GJC_WORKTREE_DIR";
78
78
  * what keeps two repositories that share a branch name from resolving to one worktree.
79
79
  */
80
80
  const REPO_NAME_PLACEHOLDER = "{repo}";
81
- const DEFAULT_WORKTREE_BUCKET = `${REPO_NAME_PLACEHOLDER}.gajae-code-worktrees`;
81
+ const DEFAULT_WORKTREE_BUCKET = `${REPO_NAME_PLACEHOLDER}/.worktrees`;
82
82
 
83
83
  function expandHomePrefix(value: string, home: string, pathApi: typeof path.posix): string {
84
84
  if (value === "~") return home;
@@ -108,14 +108,36 @@ export function resolveWorktreeBucketForPath(
108
108
  /**
109
109
  * Directory that holds this repository's launch worktrees.
110
110
  *
111
- * A relative override resolves against the repository's PARENT directory, matching the
112
- * default's own shape, so `{repo}.worktrees` adopts an existing sibling bucket and
113
- * `.worktrees` parks a hidden bucket beside the repository. An absolute override is used verbatim.
111
+ * A relative override resolves against the repository's parent directory. The default
112
+ * `{repo}/.worktrees` template places managed worktrees inside the repository, while
113
+ * `{repo}.worktrees` adopts an existing sibling bucket. An absolute override is used verbatim.
114
114
  */
115
115
  function resolveWorktreeBucket(repoRoot: string): string {
116
116
  return resolveWorktreeBucketForPath(repoRoot, process.env[WORKTREE_BUCKET_ENV], os.homedir(), path);
117
117
  }
118
118
 
119
+ function ensureRepositoryBucketIgnored(repoRoot: string, bucketPath: string): void {
120
+ const relativeBucket = path.relative(repoRoot, bucketPath);
121
+ if (relativeBucket.startsWith(`..${path.sep}`) || relativeBucket === ".." || path.isAbsolute(relativeBucket)) return;
122
+ const ignoreProbe = path.join(relativeBucket, ".gjc-worktree-probe");
123
+ for (const candidate of [relativeBucket, ignoreProbe]) {
124
+ const result = Bun.spawnSync(["git", "check-ignore", "--quiet", "--", candidate], {
125
+ cwd: repoRoot,
126
+ stdout: "ignore",
127
+ stderr: "pipe",
128
+ });
129
+ if (result.exitCode === 0) return;
130
+ }
131
+ throw new Error(
132
+ [
133
+ "worktree_bucket_not_ignored",
134
+ "The GJC launch worktree bucket is inside the repository but is not ignored by Git.",
135
+ `Path: ${formatBucketPath(bucketPath)}`,
136
+ `Safe remediation: add /${relativeBucket.replaceAll(path.sep, "/")} to ${path.join(repoRoot, ".gitignore")}, then relaunch.`,
137
+ ].join("\n"),
138
+ );
139
+ }
140
+
119
141
  function resolveSourceBranchSlug(repoRoot: string, baseRef: string): string {
120
142
  const branch = tryRunGit(repoRoot, ["symbolic-ref", "--quiet", "--short", "HEAD"]);
121
143
  if (branch) return sanitizePathToken(branch);
@@ -301,6 +323,7 @@ function pruneStaleWorktreePath(repoRoot: string): void {
301
323
 
302
324
  function readWorktreeEntryFromPath(repoRoot: string, worktreePath: string): GitWorktreeEntry | null {
303
325
  if (!fs.existsSync(worktreePath)) return null;
326
+ if (!fs.existsSync(path.join(worktreePath, ".git"))) return null;
304
327
  const repoCommonDir = tryRunGit(repoRoot, ["rev-parse", "--git-common-dir"]);
305
328
  const worktreeCommonDir = tryRunGit(worktreePath, ["rev-parse", "--git-common-dir"]);
306
329
  if (!repoCommonDir || !worktreeCommonDir) return null;
@@ -379,6 +402,7 @@ export function planLaunchWorktree(
379
402
  if (branchName) validateBranchName(repoRoot, branchName);
380
403
  const worktreeSlug = mode.detached ? resolveSourceBranchSlug(repoRoot, baseRef) : sanitizePathToken(mode.name);
381
404
  const worktreePath = path.join(resolveWorktreeBucket(repoRoot), worktreeSlug);
405
+ if (path.resolve(worktreePath) === path.resolve(repoRoot)) throw new Error(`worktree_path_conflict:${worktreePath}`);
382
406
  return { enabled: true, repoRoot, worktreePath, detached: mode.detached, baseRef, branchName };
383
407
  }
384
408
 
@@ -386,6 +410,9 @@ export function ensureLaunchWorktree(
386
410
  plan: GjcLaunchWorktreePlan | { enabled: false },
387
411
  ): GjcLaunchWorktreeResult | { enabled: false } {
388
412
  if (!plan.enabled) return { enabled: false };
413
+ const bucketPath = path.dirname(plan.worktreePath);
414
+ inspectBucketDir(bucketPath);
415
+ ensureRepositoryBucketIgnored(plan.repoRoot, bucketPath);
389
416
  let allWorktrees = listWorktrees(plan.repoRoot);
390
417
  const staleAtPath = findWorktreeByPath(allWorktrees, plan.worktreePath);
391
418
  if (staleAtPath && !fs.existsSync(staleAtPath.path)) {
@@ -393,9 +420,19 @@ export function ensureLaunchWorktree(
393
420
  allWorktrees = listWorktrees(plan.repoRoot);
394
421
  }
395
422
 
396
- const existingAtPath =
397
- findWorktreeByPath(allWorktrees, plan.worktreePath) ??
398
- readWorktreeEntryFromPath(plan.repoRoot, plan.worktreePath);
423
+ const listedAtPath = findWorktreeByPath(allWorktrees, plan.worktreePath);
424
+ const existingAtPath = readWorktreeEntryFromPath(plan.repoRoot, plan.worktreePath);
425
+ if (listedAtPath && !existingAtPath) {
426
+ if (fs.existsSync(plan.worktreePath)) throw new Error(`worktree_path_conflict:${plan.worktreePath}`);
427
+ throw new Error(
428
+ [
429
+ "worktree_path_unavailable",
430
+ "The requested launch worktree is still registered by Git but its directory is unavailable or locked.",
431
+ `Path: ${formatBucketPath(plan.worktreePath)}`,
432
+ "Safe remediation: inspect the worktree lock and remove or repair it with git worktree remove/prune when it is no longer needed, then relaunch. GJC did not delete or replace the entry.",
433
+ ].join("\n"),
434
+ );
435
+ }
399
436
  const expectedBranchRef = plan.branchName ? `refs/heads/${plan.branchName}` : null;
400
437
 
401
438
  if (existingAtPath) {
@@ -747,13 +747,31 @@ export async function resolveRalplanTargetRoot(
747
747
  if (!repository) {
748
748
  throw new RalplanCommandError(2, `ralplan --worktree-root is not inside a git repository: ${canonical}`);
749
749
  }
750
- const verified = Bun.spawn(["git", "-C", canonical, "rev-parse", "--verify", "HEAD^{commit}"], {
750
+ // Do not pass the caret-containing `HEAD^{commit}` revision here. On Windows,
751
+ // Bun may dispatch git through a `.cmd` shim, where `^` is interpreted by the
752
+ // command shell before git receives the argument. HEAD is sufficient after
753
+ // resolving a repository root: an unborn or non-commit HEAD still fails the
754
+ // same validation, while the argv remains shell-safe on every platform.
755
+ const verified = Bun.spawn(["git", "-C", canonical, "rev-parse", "--verify", "HEAD"], {
751
756
  stdout: "pipe",
752
757
  stderr: "pipe",
753
758
  });
754
- if ((await verified.exited) !== 0) {
759
+ const [verifiedStatus, verifiedOutput] = await Promise.all([verified.exited, new Response(verified.stdout).text()]);
760
+ const headObject = verifiedOutput.trim();
761
+ if (verifiedStatus !== 0 || !/^[0-9a-f]{40,64}$/u.test(headObject)) {
755
762
  throw new RalplanCommandError(2, `ralplan --worktree-root is not a valid git worktree: ${canonical}`);
756
763
  }
764
+ const objectType = Bun.spawn(["git", "-C", canonical, "cat-file", "-t", headObject], {
765
+ stdout: "pipe",
766
+ stderr: "pipe",
767
+ });
768
+ const [objectTypeStatus, objectTypeOutput] = await Promise.all([
769
+ objectType.exited,
770
+ new Response(objectType.stdout).text(),
771
+ ]);
772
+ if (objectTypeStatus !== 0 || objectTypeOutput.trim() !== "commit") {
773
+ throw new RalplanCommandError(2, `ralplan --worktree-root is not a commit worktree: ${canonical}`);
774
+ }
757
775
  let worktreeRoot: string;
758
776
  try {
759
777
  worktreeRoot = await fs.realpath(repository.repoRoot);