@jmylchreest/aide-plugin 0.0.65 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -12,6 +12,8 @@
12
12
  import { readStdin } from "../lib/hook-utils.js";
13
13
  import { debug } from "../lib/logger.js";
14
14
  import { checkWriteGuard } from "../core/write-guard.js";
15
+ import { findAideBinary } from "../core/aide-client.js";
16
+ import { emitInjectionEvent } from "../core/read-tracking.js";
15
17
 
16
18
  const SOURCE = "write-guard";
17
19
 
@@ -48,14 +50,35 @@ async function main(): Promise<void> {
48
50
  const toolName = data.tool_name || "";
49
51
  const toolInput = data.tool_input || {};
50
52
  const cwd = data.cwd || process.cwd();
53
+ const sessionId = data.session_id || "";
51
54
 
52
55
  const result = checkWriteGuard(toolName, toolInput, cwd);
53
56
 
54
57
  if (!result.allowed) {
55
- debug(
56
- SOURCE,
57
- `Advisory: Write to existing file: ${toolInput.file_path || toolInput.filePath || toolInput.path}`,
58
- );
58
+ const filePath =
59
+ (toolInput.file_path as string) ||
60
+ (toolInput.filePath as string) ||
61
+ (toolInput.path as string) ||
62
+ "";
63
+ debug(SOURCE, `Advisory: Write to existing file: ${filePath}`);
64
+ try {
65
+ const binary = findAideBinary({
66
+ cwd,
67
+ pluginRoot:
68
+ process.env.AIDE_PLUGIN_ROOT || process.env.CLAUDE_PLUGIN_ROOT,
69
+ });
70
+ if (binary && result.message) {
71
+ emitInjectionEvent(binary, cwd, {
72
+ source: SOURCE,
73
+ subtype: "guard",
74
+ content: result.message,
75
+ sessionId,
76
+ attrs: { tool: toolName, ...(filePath ? { file: filePath } : {}) },
77
+ });
78
+ }
79
+ } catch {
80
+ // Non-fatal
81
+ }
59
82
  const output: HookOutput = {
60
83
  continue: true,
61
84
  hookSpecificOutput: {
@@ -28,6 +28,8 @@ import { Readable, Transform } from "stream";
28
28
  import { pipeline } from "stream/promises";
29
29
  // Canonical binary finder — import for local use, re-export for backward compat
30
30
  import { findAideBinary } from "./hook-utils.js";
31
+ import { findProjectRoot } from "./project-root.js";
32
+ import { loadGlobalConfig } from "../core/session-init.js";
31
33
  export { findAideBinary };
32
34
 
33
35
  export interface DownloadResult {
@@ -544,8 +546,24 @@ Downloads the aide binary from GitHub releases.
544
546
  }
545
547
  destDir = join(pluginRoot, "bin");
546
548
  } else if (!destDir) {
547
- // Default to current directory's .aide/bin
548
- destDir = join(process.cwd(), ".aide", "bin");
549
+ // Default: resolve to the project root rather than blindly using cwd.
550
+ // Matches the SessionStart hook so the CLI fallback never plants an
551
+ // orphan .aide/bin/ in a subdirectory of a git repo.
552
+ const { root, hasMarker } = findProjectRoot(process.cwd());
553
+ if (!hasMarker) {
554
+ const requireGit = loadGlobalConfig().requireGit ?? true;
555
+ if (requireGit) {
556
+ console.error(
557
+ `[aide] No .git/ or .aide/ found walking up from ${process.cwd()}. ` +
558
+ `Set \`requireGit\`: false in ~/.aide/config/aide.json or pass --cwd / --dest to install anyway.`,
559
+ );
560
+ process.exit(1);
561
+ }
562
+ console.error(
563
+ `[aide] No project root found, installing into ${process.cwd()} (requireGit=false).`,
564
+ );
565
+ }
566
+ destDir = join(hasMarker ? root : process.cwd(), ".aide", "bin");
549
567
  }
550
568
 
551
569
  const result = await downloadAideBinary(destDir, { force, quiet: false });
@@ -93,6 +93,69 @@ export function detectPlatform(): "claude-code" | "codex" {
93
93
  return "claude-code";
94
94
  }
95
95
 
96
+ import { existsSync, readFileSync } from "fs";
97
+ import { join } from "path";
98
+ import { findProjectRoot } from "./project-root.js";
99
+
100
+ const TRUTHY = new Set(["1", "true", "on", "yes"]);
101
+ const FALSY = new Set(["0", "false", "off", "no"]);
102
+
103
+ /**
104
+ * isTruthy reports whether an env-var value should be treated as "on".
105
+ * Accepts the same set as the Go config helper: 1/true/on/yes
106
+ * (case-insensitive, whitespace-trimmed). Use for opt-in flags.
107
+ */
108
+ export function isTruthy(v: string | undefined): boolean {
109
+ if (!v) return false;
110
+ return TRUTHY.has(v.trim().toLowerCase());
111
+ }
112
+
113
+ /**
114
+ * isFalsy reports whether an env-var value was explicitly set to disable.
115
+ * Accepts 0/false/off/no. Unset/empty/unknown values return false so the
116
+ * caller's default-on behaviour wins. Use for opt-out flags.
117
+ */
118
+ export function isFalsy(v: string | undefined): boolean {
119
+ if (!v) return false;
120
+ return FALSY.has(v.trim().toLowerCase());
121
+ }
122
+
123
+ /**
124
+ * reflectEnabled mirrors the Go-side config.ResolveReflectEnabled precedence
125
+ * so TS hooks that need to gate on the reflect setting see the same answer
126
+ * as `aide reflect run`. Precedence:
127
+ *
128
+ * 1. AIDE_REFLECT env (recognised truthy/falsy values win)
129
+ * 2. .aide/config/aide.json `reflect.enabled` at the resolved project
130
+ * root (walks up from cwd via findProjectRoot — does NOT just look
131
+ * at cwd/.aide/)
132
+ * 3. default false
133
+ *
134
+ * Used by skill-injector.ts and opencode/hooks.ts to gate the user_prompt
135
+ * observe-event emit that convergence detection depends on.
136
+ */
137
+ export function reflectEnabled(cwd: string): boolean {
138
+ const env = process.env.AIDE_REFLECT;
139
+ if (env !== undefined && env !== "") {
140
+ const norm = env.trim().toLowerCase();
141
+ if (TRUTHY.has(norm)) return true;
142
+ if (FALSY.has(norm)) return false;
143
+ }
144
+ try {
145
+ const { root } = findProjectRoot(cwd);
146
+ const cfgPath = join(root, ".aide", "config", "aide.json");
147
+ if (existsSync(cfgPath)) {
148
+ const cfg = JSON.parse(readFileSync(cfgPath, "utf-8")) as {
149
+ reflect?: { enabled?: boolean };
150
+ };
151
+ return cfg?.reflect?.enabled === true;
152
+ }
153
+ } catch {
154
+ // Unreadable / malformed config — treat as unset.
155
+ }
156
+ return false;
157
+ }
158
+
96
159
  /**
97
160
  * Get the plugin root directory from environment variables.
98
161
  */
package/src/lib/hud.ts CHANGED
@@ -9,6 +9,7 @@ import { existsSync, readFileSync, writeFileSync, mkdirSync } from "fs";
9
9
  import { join } from "path";
10
10
  import { execFileSync } from "child_process";
11
11
  import { runAide, findAideBinary } from "./hook-utils.js";
12
+ import { findProjectRoot } from "./project-root.js";
12
13
 
13
14
  // Cache the aide version for the session (won't change)
14
15
  let aideVersionCache: string | null = null;
@@ -149,7 +150,8 @@ export function getAgentStates(cwd: string): AgentState[] {
149
150
  * Load HUD configuration
150
151
  */
151
152
  export function loadHudConfig(cwd: string): HudConfig {
152
- const configPath = join(cwd, ".aide", "config", "hud.json");
153
+ const { root } = findProjectRoot(cwd);
154
+ const configPath = join(root, ".aide", "config", "hud.json");
153
155
 
154
156
  if (existsSync(configPath)) {
155
157
  try {
@@ -444,7 +446,8 @@ export function formatHud(
444
446
  * Write HUD output to state file
445
447
  */
446
448
  export function writeHudOutput(cwd: string, output: string): void {
447
- const stateDir = join(cwd, ".aide", "state");
449
+ const { root } = findProjectRoot(cwd);
450
+ const stateDir = join(root, ".aide", "state");
448
451
  if (!existsSync(stateDir)) {
449
452
  try {
450
453
  mkdirSync(stateDir, { recursive: true });
package/src/lib/logger.ts CHANGED
@@ -21,6 +21,7 @@
21
21
 
22
22
  import { existsSync, mkdirSync, appendFileSync } from "fs";
23
23
  import { join } from "path";
24
+ import { findProjectRoot } from "./project-root.js";
24
25
 
25
26
  export type LogLevel = "debug" | "info" | "warn" | "error";
26
27
 
@@ -51,10 +52,14 @@ export class Logger {
51
52
  constructor(source: string, cwd?: string) {
52
53
  this.source = source;
53
54
  this.cwd = cwd || process.cwd();
54
- // Set debugLogCwd so isDebugEnabled() can check the sentinel file
55
- if (cwd) setDebugCwd(cwd);
55
+ // Resolve to the canonical project root so logs land alongside the
56
+ // real .aide/ store, not in a stray subdir the harness launched from.
57
+ const { root } = findProjectRoot(this.cwd);
58
+ // Set debugLogCwd so isDebugEnabled() can check the sentinel file at
59
+ // the resolved root.
60
+ setDebugCwd(root);
56
61
  this.enabled = isDebugEnabled();
57
- this.logDir = join(this.cwd, ".aide", "_logs");
62
+ this.logDir = join(root, ".aide", "_logs");
58
63
  this.logFile = join(this.logDir, "startup.log");
59
64
  this.sessionStart = Date.now();
60
65
 
@@ -288,11 +293,13 @@ export function isDebugEnabled(): boolean {
288
293
  const debugEnv = process.env.AIDE_DEBUG || "";
289
294
  if (debugEnv === "1" || debugEnv === "true") return true;
290
295
 
291
- // Check sentinel file (cached per cwd)
296
+ // Check sentinel file (cached per cwd). Resolve to project root so the
297
+ // sentinel works regardless of which subdir the hook fired from.
292
298
  if (debugLogCwd !== debugSentinelCwd) {
293
299
  debugSentinelCwd = debugLogCwd;
294
300
  try {
295
- debugSentinelResult = existsSync(join(debugLogCwd, ".aide", ".debug"));
301
+ const { root } = findProjectRoot(debugLogCwd);
302
+ debugSentinelResult = existsSync(join(root, ".aide", ".debug"));
296
303
  } catch {
297
304
  debugSentinelResult = false;
298
305
  }
@@ -303,6 +310,10 @@ export function isDebugEnabled(): boolean {
303
310
  /**
304
311
  * Set the working directory for debug logging.
305
312
  * Call this after parsing stdin to use project-local logs.
313
+ *
314
+ * Note: callers may pass either the raw cwd or an already-resolved project
315
+ * root. Logger's constructor resolves cwd → root before calling this; bare
316
+ * callers of debug() may pass cwd and we'll resolve at use-time.
306
317
  */
307
318
  export function setDebugCwd(cwd: string): void {
308
319
  debugLogCwd = cwd;
@@ -326,7 +337,8 @@ export function setDebugCwd(cwd: string): void {
326
337
  export function debug(source: string, msg: string): void {
327
338
  if (!isDebugEnabled()) return;
328
339
 
329
- const logDir = join(debugLogCwd, ".aide", "_logs");
340
+ const { root } = findProjectRoot(debugLogCwd);
341
+ const logDir = join(root, ".aide", "_logs");
330
342
  try {
331
343
  if (!existsSync(logDir)) {
332
344
  mkdirSync(logDir, { recursive: true });
@@ -0,0 +1,174 @@
1
+ /**
2
+ * Project root resolution for the AIDE plugin.
3
+ *
4
+ * Mirrors the Go binary's findProjectRoot() in aide/cmd/aide/main.go so that
5
+ * the TypeScript hook layer and the Go binary always agree on where `.aide/`
6
+ * lives. Without this, the hook would plant a sibling `.aide/` in whatever
7
+ * subdirectory `claude` was launched from, while the Go binary would walk up
8
+ * and use the real one at the repo root.
9
+ *
10
+ * Resolution order, matching main.go:findProjectRoot:
11
+ * 1. AIDE_PROJECT_ROOT env override (must be an existing directory).
12
+ * 2. Walk the full ancestry from cwd to /, collecting candidates, then
13
+ * prefer:
14
+ * a. Closest ancestor with BOTH .aide/ and a VCS marker
15
+ * (.git/.hg/.svn/.bzr/.fossil) — handles the common case where
16
+ * the canonical root sits at the git repo root.
17
+ * b. Closest ancestor with a VCS marker only — .aide/ will be
18
+ * created there if needed.
19
+ * c. Closest ancestor with .aide/ only — standalone projects with
20
+ * no VCS.
21
+ * ~/.aide/ is skipped as a project marker unless cwd is $HOME.
22
+ * 3. No marker found: return { root: cwd, hasMarker: false }.
23
+ *
24
+ * The "both markers wins" priority is what stops a stray child .aide/ from
25
+ * shadowing the real project root: a sibling .aide/ created by an
26
+ * accidental CLI invocation lives in a subdir with no .git/, so the walk
27
+ * keeps going until it finds the parent that has both.
28
+ */
29
+
30
+ import { basename, dirname, join, resolve } from "path";
31
+ import { existsSync, readFileSync, statSync } from "fs";
32
+ import { homedir } from "os";
33
+
34
+ export interface ProjectRootResult {
35
+ root: string;
36
+ hasMarker: boolean;
37
+ }
38
+
39
+ /**
40
+ * Resolve the AIDE project root for a given cwd.
41
+ *
42
+ * `hasMarker` is true when an actual `.aide/` or `.git/` marker was found
43
+ * (or when AIDE_PROJECT_ROOT is set to an existing directory). When false,
44
+ * `root` is just the input cwd — callers should decide whether to fall
45
+ * back to it (e.g. via the `requireGit` config) or refuse to bootstrap.
46
+ */
47
+ export function findProjectRoot(cwd: string): ProjectRootResult {
48
+ const override = process.env.AIDE_PROJECT_ROOT;
49
+ if (override) {
50
+ try {
51
+ const abs = resolve(override);
52
+ const stat = statSync(abs);
53
+ if (stat.isDirectory()) {
54
+ return { root: abs, hasMarker: true };
55
+ }
56
+ } catch {
57
+ // Fall through to the walk-up.
58
+ }
59
+ process.stderr.write(
60
+ `aide: AIDE_PROJECT_ROOT=${JSON.stringify(override)} is not a directory; falling back to walk-up\n`,
61
+ );
62
+ }
63
+
64
+ const startCwd = resolve(cwd);
65
+ const home = homedir();
66
+
67
+ interface Candidate {
68
+ dir: string;
69
+ hasAide: boolean;
70
+ hasVCS: boolean;
71
+ vcsResolved: string;
72
+ }
73
+ const path: Candidate[] = [];
74
+
75
+ let dir = startCwd;
76
+ for (;;) {
77
+ const cand: Candidate = { dir, hasAide: false, hasVCS: false, vcsResolved: "" };
78
+
79
+ if (existsSync(join(dir, ".aide"))) {
80
+ // Skip ~/.aide/ unless cwd is $HOME itself.
81
+ const isHomeAide = home && dir === home && startCwd !== home;
82
+ if (!isHomeAide) cand.hasAide = true;
83
+ }
84
+
85
+ const vcs = vcsMarkerAt(dir);
86
+ if (vcs.ok) {
87
+ cand.hasVCS = true;
88
+ cand.vcsResolved = vcs.resolved || dir;
89
+ }
90
+
91
+ if (cand.hasAide || cand.hasVCS) path.push(cand);
92
+
93
+ const parent = dirname(dir);
94
+ if (parent === dir) break;
95
+ dir = parent;
96
+ }
97
+
98
+ for (const c of path) {
99
+ if (c.hasAide && c.hasVCS) return { root: c.vcsResolved, hasMarker: true };
100
+ }
101
+ for (const c of path) {
102
+ if (c.hasVCS) return { root: c.vcsResolved, hasMarker: true };
103
+ }
104
+ for (const c of path) {
105
+ if (c.hasAide) return { root: c.dir, hasMarker: true };
106
+ }
107
+ return { root: startCwd, hasMarker: false };
108
+ }
109
+
110
+ function vcsMarkerAt(dir: string): { ok: boolean; resolved: string } {
111
+ const gitPath = join(dir, ".git");
112
+ if (existsSync(gitPath)) {
113
+ try {
114
+ const st = statSync(gitPath);
115
+ if (st.isDirectory()) return { ok: true, resolved: dir };
116
+ if (st.isFile()) {
117
+ const mainRoot = resolveWorktreeGitFile(gitPath);
118
+ return { ok: true, resolved: mainRoot || dir };
119
+ }
120
+ return { ok: true, resolved: dir };
121
+ } catch {
122
+ return { ok: true, resolved: dir };
123
+ }
124
+ }
125
+ for (const marker of [".hg", ".svn", ".bzr", ".fossil"]) {
126
+ if (existsSync(join(dir, marker))) return { ok: true, resolved: dir };
127
+ }
128
+ return { ok: false, resolved: "" };
129
+ }
130
+
131
+ /**
132
+ * Walk up from `startDir` looking for `.aide/` or `.git/` markers.
133
+ * Returns the resolved root directory, or null when nothing is found.
134
+ *
135
+ * Thin wrapper around findProjectRoot for callers that want a nullable
136
+ * result rather than the {root,hasMarker} shape (e.g. the OpenCode plugin
137
+ * which has its own fallback chain).
138
+ */
139
+ export function walkUpForProjectRoot(startDir: string): string | null {
140
+ const { root, hasMarker } = findProjectRoot(startDir);
141
+ return hasMarker ? root : null;
142
+ }
143
+
144
+ /**
145
+ * Read a .git worktree file ("gitdir: <path>") and return the main repo root.
146
+ *
147
+ * Mirrors aide/cmd/aide/main.go:resolveWorktreeRoot(). The file's gitdir
148
+ * normally points at "<main>/.git/worktrees/<name>"; we walk up that path
149
+ * until we find a component named ".git" and return its parent.
150
+ */
151
+ export function resolveWorktreeGitFile(gitFilePath: string): string | null {
152
+ try {
153
+ const content = readFileSync(gitFilePath, "utf-8").trim();
154
+ if (!content.startsWith("gitdir:")) return null;
155
+
156
+ let gitdir = content.slice("gitdir:".length).trim();
157
+ if (!gitdir.startsWith("/")) {
158
+ gitdir = resolve(dirname(gitFilePath), gitdir);
159
+ }
160
+
161
+ let candidate = gitdir;
162
+ for (;;) {
163
+ const parent = dirname(candidate);
164
+ if (parent === candidate) break;
165
+ if (basename(candidate) === ".git") {
166
+ return parent;
167
+ }
168
+ candidate = parent;
169
+ }
170
+ return null;
171
+ } catch {
172
+ return null;
173
+ }
174
+ }
@@ -52,12 +52,14 @@ import { checkWriteGuard } from "../core/write-guard.js";
52
52
  import { checkSmartReadHint } from "../core/context-guard.js";
53
53
  import { checkSearchEnrichment } from "../core/search-enrichment.js";
54
54
  import { recordToolEvent } from "../core/tool-observe.js";
55
+ import { recordObserveEvent, previewContent } from "../core/read-tracking.js";
55
56
  import {
56
57
  checkComments,
57
58
  getCheckableFilePath,
58
59
  getContentToCheck,
59
60
  } from "../core/comment-checker.js";
60
61
  import { getState, setState } from "../core/aide-client.js";
62
+ import { isFalsy, reflectEnabled } from "../lib/hook-utils.js";
61
63
  import { saveStateSnapshot } from "../core/pre-compact-logic.js";
62
64
  import { cleanupSession } from "../core/cleanup.js";
63
65
  import {
@@ -335,11 +337,16 @@ function initializeAide(state: AideState): void {
335
337
 
336
338
  ensureDirectories(state.cwd);
337
339
 
338
- // Sync MCP server configs across assistants (FS only, fast)
339
- try {
340
- syncMcpServers("opencode", state.cwd);
341
- } catch (err) {
342
- debug(SOURCE, `MCP sync failed (non-fatal): ${err}`);
340
+ // Sync MCP server configs across assistants (FS only, fast).
341
+ // Opt-out via AIDE_MCP_SYNC=0 (defaults to enabled).
342
+ if (isFalsy(process.env.AIDE_MCP_SYNC)) {
343
+ debug(SOURCE, "MCP sync disabled (AIDE_MCP_SYNC falsy)");
344
+ } else {
345
+ try {
346
+ syncMcpServers("opencode", state.cwd);
347
+ } catch (err) {
348
+ debug(SOURCE, `MCP sync failed (non-fatal): ${err}`);
349
+ }
343
350
  }
344
351
 
345
352
  const config = loadConfig(state.cwd);
@@ -592,6 +599,22 @@ async function handleSessionIdle(
592
599
  cleanupPartials(state.binary, state.cwd, sessionId);
593
600
  }
594
601
  }
602
+
603
+ // Reflect (extract instinct proposals). The CLI itself checks env +
604
+ // .aide/config/aide.json reflect.enabled and no-ops when disabled, so
605
+ // invoke unconditionally. Mirrors the Claude Code Stop hook.
606
+ if (state.binary) {
607
+ try {
608
+ execFileSync(state.binary, ["reflect", "run", `--session=${sessionId}`], {
609
+ cwd: state.cwd,
610
+ timeout: 10000,
611
+ stdio: ["pipe", "pipe", "pipe"],
612
+ });
613
+ debug(SOURCE, `reflect run session=${sessionId.slice(0, 8)} ok`);
614
+ } catch (err) {
615
+ debug(SOURCE, `reflect run failed (non-fatal): ${err}`);
616
+ }
617
+ }
595
618
  }
596
619
 
597
620
  async function handleSessionDeleted(
@@ -647,6 +670,24 @@ async function handleMessagePartUpdated(
647
670
  // this event fires after the transform (defensive against ordering).
648
671
  state.lastUserPrompt = prompt;
649
672
 
673
+ // Emit a user-prompt observe event so the convergence detector can find
674
+ // corrective markers near edit sequences. Gated behind AIDE_REFLECT,
675
+ // mirroring src/hooks/skill-injector.ts for cross-harness parity.
676
+ if (state.binary && reflectEnabled(state.cwd)) {
677
+ try {
678
+ recordObserveEvent(state.binary, state.cwd, {
679
+ kind: "hook",
680
+ name: "user_prompt",
681
+ category: "input",
682
+ tokens: Math.round(prompt.length / 3.0),
683
+ session: extractSessionId(event),
684
+ attrs: { text: previewContent(prompt, 2000) },
685
+ });
686
+ } catch {
687
+ // Non-fatal
688
+ }
689
+ }
690
+
650
691
  const skills = discoverSkills(state.cwd, state.pluginRoot ?? undefined);
651
692
  const matched = matchSkills(prompt, skills, 3, "opencode");
652
693
 
@@ -36,11 +36,11 @@
36
36
  * ```
37
37
  */
38
38
 
39
- import { basename, dirname, join, resolve } from "path";
39
+ import { dirname, join, resolve } from "path";
40
40
  import { fileURLToPath } from "url";
41
- import { existsSync, readFileSync, statSync } from "fs";
42
41
  import { createHooks } from "./hooks.js";
43
42
  import { isDebugEnabled } from "../lib/logger.js";
43
+ import { walkUpForProjectRoot } from "../lib/project-root.js";
44
44
  import type { Plugin, PluginInput, Hooks } from "./types.js";
45
45
 
46
46
  // Resolve the plugin package root so we can find bundled skills.
@@ -121,84 +121,6 @@ function resolveProjectRoot(ctx: PluginInput): {
121
121
  return { root: directory || "/", hasProjectRoot: false };
122
122
  }
123
123
 
124
- /**
125
- * Walk up from `startDir` looking for .aide/ or .git/ directories.
126
- * Returns the project root path, or null if none found.
127
- *
128
- * For git worktrees, .git is a file containing "gitdir: <path>".
129
- * We follow it to the main repo root, matching the Go binary's
130
- * resolveWorktreeRoot() behavior.
131
- */
132
- function walkUpForProjectRoot(startDir: string): string | null {
133
- let dir = resolve(startDir);
134
- for (;;) {
135
- if (existsSync(join(dir, ".aide"))) {
136
- return dir;
137
- }
138
- const gitPath = join(dir, ".git");
139
- if (existsSync(gitPath)) {
140
- try {
141
- const stat = statSync(gitPath);
142
- if (stat.isDirectory()) {
143
- // Normal git repo
144
- return dir;
145
- }
146
- if (stat.isFile()) {
147
- // Worktree: .git is a file containing "gitdir: <path>"
148
- // Follow it to the main repo root.
149
- const mainRoot = resolveWorktreeGitFile(gitPath);
150
- if (mainRoot) return mainRoot;
151
- // Fallback to current dir if resolution fails
152
- return dir;
153
- }
154
- } catch {
155
- return dir;
156
- }
157
- }
158
- const parent = resolve(dir, "..");
159
- if (parent === dir) {
160
- return null;
161
- }
162
- dir = parent;
163
- }
164
- }
165
-
166
- /**
167
- * Read a .git worktree file and resolve to the main repository root.
168
- * Mirrors the Go binary's resolveWorktreeRoot() in main.go.
169
- *
170
- * The file contains "gitdir: /path/to/repo/.git/worktrees/<name>".
171
- * We walk up from that gitdir path to find the .git directory,
172
- * then return its parent.
173
- */
174
- function resolveWorktreeGitFile(gitFilePath: string): string | null {
175
- try {
176
- const content = readFileSync(gitFilePath, "utf-8").trim();
177
- if (!content.startsWith("gitdir:")) return null;
178
-
179
- let gitdir = content.slice("gitdir:".length).trim();
180
- // Make absolute if relative
181
- if (!gitdir.startsWith("/")) {
182
- gitdir = resolve(dirname(gitFilePath), gitdir);
183
- }
184
-
185
- // Walk up from .git/worktrees/<name> to find the .git directory,
186
- // then return its parent as the repo root.
187
- let candidate = gitdir;
188
- for (;;) {
189
- const parent = dirname(candidate);
190
- if (parent === candidate) break;
191
- if (basename(candidate) === ".git") {
192
- return parent;
193
- }
194
- candidate = parent;
195
- }
196
- return null;
197
- } catch {
198
- return null;
199
- }
200
- }
201
-
202
124
  export const AidePlugin: Plugin = async (ctx: PluginInput): Promise<Hooks> => {
203
125
  // Log raw plugin input BEFORE any resolution for diagnostics.
204
126
  // This is the key to understanding what OpenCode actually passes.