@jmylchreest/aide-plugin 0.1.0 → 0.1.2

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 (35) hide show
  1. package/package.json +1 -1
  2. package/skills/reflect/SKILL.md +24 -8
  3. package/src/cli/codex-config.ts +15 -0
  4. package/src/core/context-pruning/dedup.ts +7 -2
  5. package/src/core/context-pruning/tracker.ts +0 -23
  6. package/src/core/memory-query.ts +63 -0
  7. package/src/core/partial-memory.ts +23 -105
  8. package/src/core/session-checkpoint-logic.ts +170 -0
  9. package/src/core/session-resume-logic.ts +104 -0
  10. package/src/core/session-summary-logic.ts +16 -41
  11. package/src/core/session-text.ts +60 -0
  12. package/src/core/tool-observe.ts +53 -14
  13. package/src/hooks/agent-cleanup.ts +11 -28
  14. package/src/hooks/agent-signals.ts +11 -18
  15. package/src/hooks/comment-checker.ts +14 -31
  16. package/src/hooks/context-guard.ts +17 -31
  17. package/src/hooks/context-pruning.ts +13 -30
  18. package/src/hooks/hud-updater.ts +11 -29
  19. package/src/hooks/permission-handler.ts +14 -25
  20. package/src/hooks/persistence.ts +15 -31
  21. package/src/hooks/pre-compact.ts +52 -38
  22. package/src/hooks/pre-tool-enforcer.ts +14 -30
  23. package/src/hooks/reflect.ts +15 -8
  24. package/src/hooks/search-enrichment.ts +12 -29
  25. package/src/hooks/session-end.ts +60 -11
  26. package/src/hooks/session-start.ts +68 -30
  27. package/src/hooks/session-summary.ts +11 -27
  28. package/src/hooks/skill-injector.ts +15 -30
  29. package/src/hooks/subagent-tracker.ts +44 -39
  30. package/src/hooks/task-completed.ts +6 -9
  31. package/src/hooks/tool-observe.ts +27 -32
  32. package/src/hooks/tool-tracker.ts +11 -28
  33. package/src/hooks/write-guard.ts +12 -29
  34. package/src/lib/hook-utils.ts +48 -0
  35. package/src/lib/project-root.ts +30 -15
@@ -1,18 +1,27 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * Tool Observe Hook (PostToolUse)
3
+ * Tool Observe Hook (PostToolUse + PostToolUseFailure)
4
4
  *
5
5
  * Single-purpose: record every Claude-native tool invocation as an
6
6
  * observe.KindToolCall event. Mirror image of the MCP middleware on the Go
7
7
  * side — together they give the dashboard complete tool-call coverage.
8
8
  *
9
+ * Claude Code fires PostToolUse only on success and PostToolUseFailure on
10
+ * failure (the latter carries top-level is_error/error/exit_code). This hook is
11
+ * registered for BOTH so failed tool calls are recorded with their error text —
12
+ * the signal the friction detector reads.
13
+ *
9
14
  * All taxonomy (tool → category/subtype) and the recording itself live in
10
15
  * src/core/tool-observe.ts so the OpenCode tool.execute.after handler can
11
16
  * reuse the same logic.
12
17
  */
13
18
 
14
- import { readStdin } from "../lib/hook-utils.js";
15
- import { findAideBinary } from "../core/aide-client.js";
19
+ import {
20
+ readStdin,
21
+ emitHookResult,
22
+ installHookSafetyNet,
23
+ findAideBinary,
24
+ } from "../lib/hook-utils.js";
16
25
  import { recordToolEvent } from "../core/tool-observe.js";
17
26
  import { debug } from "../lib/logger.js";
18
27
 
@@ -28,50 +37,46 @@ interface HookInput {
28
37
  // Claude Code passes the tool's actual output payload as tool_response.
29
38
  // Shape varies per tool (string for Bash, object for others).
30
39
  tool_response?: unknown;
31
- }
32
-
33
- function outputContinue(): void {
34
- try {
35
- console.log(JSON.stringify({ continue: true }));
36
- } catch {
37
- console.log('{"continue":true}');
38
- }
40
+ // PostToolUseFailure-only fields: the harness flags the failure at the top
41
+ // level (PostToolUse never sets these — it only fires on success).
42
+ is_error?: boolean;
43
+ error?: string;
44
+ exit_code?: number;
39
45
  }
40
46
 
41
47
  async function main(): Promise<void> {
42
48
  try {
43
49
  const input = await readStdin();
44
50
  if (!input.trim()) {
45
- outputContinue();
51
+ emitHookResult();
46
52
  return;
47
53
  }
48
54
  const data: HookInput = JSON.parse(input);
49
55
  const cwd = data.cwd || process.cwd();
50
56
  const toolName = data.tool_name;
51
57
  if (!toolName) {
52
- outputContinue();
58
+ emitHookResult();
53
59
  return;
54
60
  }
55
- const binary = findAideBinary({
56
- cwd,
57
- pluginRoot:
58
- process.env.AIDE_PLUGIN_ROOT || process.env.CLAUDE_PLUGIN_ROOT,
59
- });
61
+ const binary = findAideBinary(cwd);
60
62
  if (!binary) {
61
- outputContinue();
63
+ emitHookResult();
62
64
  return;
63
65
  }
64
66
  recordToolEvent(binary, cwd, {
65
67
  toolName,
66
68
  toolInput: data.tool_input as ToolInput,
67
69
  toolResponse: data.tool_response,
68
- success: data.tool_result?.success,
70
+ // PostToolUseFailure marks failure at the top level; PostToolUse omits
71
+ // these (success only). Map both into the shared recorder.
72
+ success: data.is_error === true ? false : data.tool_result?.success,
73
+ errorText: data.error,
69
74
  sessionId: data.session_id,
70
75
  });
71
76
  } catch (err) {
72
77
  debug(SOURCE, `Hook error: ${err}`);
73
78
  }
74
- outputContinue();
79
+ emitHookResult();
75
80
  }
76
81
 
77
82
  type ToolInput = {
@@ -82,16 +87,6 @@ type ToolInput = {
82
87
  pattern?: string;
83
88
  };
84
89
 
85
- process.on("uncaughtException", (err) => {
86
- debug(SOURCE, `UNCAUGHT EXCEPTION: ${err}`);
87
- outputContinue();
88
- process.exit(0);
89
- });
90
-
91
- process.on("unhandledRejection", (reason) => {
92
- debug(SOURCE, `UNHANDLED REJECTION: ${reason}`);
93
- outputContinue();
94
- process.exit(0);
95
- });
90
+ installHookSafetyNet(SOURCE);
96
91
 
97
92
  main();
@@ -6,9 +6,13 @@
6
6
  * Sets currentTool in aide-memory before tool execution.
7
7
  */
8
8
 
9
- import { readStdin } from "../lib/hook-utils.js";
9
+ import {
10
+ readStdin,
11
+ emitHookResult,
12
+ installHookSafetyNet,
13
+ findAideBinary,
14
+ } from "../lib/hook-utils.js";
10
15
  import { trackToolUse, formatToolDescription } from "../core/tool-tracking.js";
11
- import { findAideBinary } from "../core/aide-client.js";
12
16
  import { debug } from "../lib/logger.js";
13
17
 
14
18
  const SOURCE = "tool-tracker";
@@ -35,7 +39,7 @@ async function main(): Promise<void> {
35
39
  try {
36
40
  const input = await readStdin();
37
41
  if (!input.trim()) {
38
- console.log(JSON.stringify({ continue: true }));
42
+ emitHookResult({ continue: true });
39
43
  return;
40
44
  }
41
45
 
@@ -45,11 +49,7 @@ async function main(): Promise<void> {
45
49
  const toolName = data.tool_name || "";
46
50
 
47
51
  if (agentId && toolName) {
48
- const binary = findAideBinary({
49
- cwd,
50
- pluginRoot:
51
- process.env.AIDE_PLUGIN_ROOT || process.env.CLAUDE_PLUGIN_ROOT,
52
- });
52
+ const binary = findAideBinary(cwd);
53
53
 
54
54
  if (binary) {
55
55
  trackToolUse(binary, cwd, {
@@ -60,30 +60,13 @@ async function main(): Promise<void> {
60
60
  }
61
61
  }
62
62
 
63
- console.log(JSON.stringify({ continue: true }));
63
+ emitHookResult({ continue: true });
64
64
  } catch (error) {
65
65
  debug(SOURCE, `Hook error: ${error}`);
66
- console.log(JSON.stringify({ continue: true }));
66
+ emitHookResult({ continue: true });
67
67
  }
68
68
  }
69
69
 
70
- process.on("uncaughtException", (err) => {
71
- debug(SOURCE, `UNCAUGHT EXCEPTION: ${err}`);
72
- try {
73
- console.log(JSON.stringify({ continue: true }));
74
- } catch {
75
- console.log('{"continue":true}');
76
- }
77
- process.exit(0);
78
- });
79
- process.on("unhandledRejection", (reason) => {
80
- debug(SOURCE, `UNHANDLED REJECTION: ${reason}`);
81
- try {
82
- console.log(JSON.stringify({ continue: true }));
83
- } catch {
84
- console.log('{"continue":true}');
85
- }
86
- process.exit(0);
87
- });
70
+ installHookSafetyNet(SOURCE);
88
71
 
89
72
  main();
@@ -9,10 +9,14 @@
9
9
  * Core logic is in src/core/write-guard.ts for cross-platform reuse.
10
10
  */
11
11
 
12
- import { readStdin } from "../lib/hook-utils.js";
12
+ import {
13
+ readStdin,
14
+ emitHookResult,
15
+ installHookSafetyNet,
16
+ findAideBinary,
17
+ } from "../lib/hook-utils.js";
13
18
  import { debug } from "../lib/logger.js";
14
19
  import { checkWriteGuard } from "../core/write-guard.js";
15
- import { findAideBinary } from "../core/aide-client.js";
16
20
  import { emitInjectionEvent } from "../core/read-tracking.js";
17
21
 
18
22
  const SOURCE = "write-guard";
@@ -42,7 +46,7 @@ async function main(): Promise<void> {
42
46
  try {
43
47
  const input = await readStdin();
44
48
  if (!input.trim()) {
45
- console.log(JSON.stringify({ continue: true }));
49
+ emitHookResult({ continue: true });
46
50
  return;
47
51
  }
48
52
 
@@ -62,11 +66,7 @@ async function main(): Promise<void> {
62
66
  "";
63
67
  debug(SOURCE, `Advisory: Write to existing file: ${filePath}`);
64
68
  try {
65
- const binary = findAideBinary({
66
- cwd,
67
- pluginRoot:
68
- process.env.AIDE_PLUGIN_ROOT || process.env.CLAUDE_PLUGIN_ROOT,
69
- });
69
+ const binary = findAideBinary(cwd);
70
70
  if (binary && result.message) {
71
71
  emitInjectionEvent(binary, cwd, {
72
72
  source: SOURCE,
@@ -86,33 +86,16 @@ async function main(): Promise<void> {
86
86
  additionalContext: result.message,
87
87
  },
88
88
  };
89
- console.log(JSON.stringify(output));
89
+ emitHookResult(output);
90
90
  } else {
91
- console.log(JSON.stringify({ continue: true }));
91
+ emitHookResult({ continue: true });
92
92
  }
93
93
  } catch (error) {
94
94
  debug(SOURCE, `Hook error: ${error}`);
95
- console.log(JSON.stringify({ continue: true }));
95
+ emitHookResult({ continue: true });
96
96
  }
97
97
  }
98
98
 
99
- process.on("uncaughtException", (err) => {
100
- debug(SOURCE, `UNCAUGHT EXCEPTION: ${err}`);
101
- try {
102
- console.log(JSON.stringify({ continue: true }));
103
- } catch {
104
- console.log('{"continue":true}');
105
- }
106
- process.exit(0);
107
- });
108
- process.on("unhandledRejection", (reason) => {
109
- debug(SOURCE, `UNHANDLED REJECTION: ${reason}`);
110
- try {
111
- console.log(JSON.stringify({ continue: true }));
112
- } catch {
113
- console.log('{"continue":true}');
114
- }
115
- process.exit(0);
116
- });
99
+ installHookSafetyNet(SOURCE);
117
100
 
118
101
  main();
@@ -16,9 +16,57 @@ import {
16
16
  sanitizeForLog,
17
17
  shellEscape,
18
18
  } from "../core/aide-client.js";
19
+ import { debug } from "./logger.js";
19
20
 
20
21
  export { sanitizeForLog, shellEscape };
21
22
 
23
+ /**
24
+ * A hook's JSON result object (printed to stdout for the harness). Typed as
25
+ * `object` rather than `Record<string, unknown>` so callers can pass their own
26
+ * named result interfaces (interfaces lack the implicit index signature
27
+ * `Record` requires).
28
+ */
29
+ export type HookResult = object;
30
+
31
+ /**
32
+ * Serialise and print a hook result as exactly one JSON line on stdout, with a
33
+ * raw-string fallback if serialisation itself throws. Defaults to
34
+ * `{continue:true}` — the fail-open result a hook emits when it has nothing
35
+ * specific to say. Callers needing a different shape (e.g. `{}` or a permission
36
+ * decision) pass it explicitly: the output decision stays with the caller, this
37
+ * helper only guarantees a single, always-valid line.
38
+ */
39
+ export function emitHookResult(result: HookResult = { continue: true }): void {
40
+ try {
41
+ console.log(JSON.stringify(result));
42
+ } catch {
43
+ console.log('{"continue":true}');
44
+ }
45
+ }
46
+
47
+ /**
48
+ * Register process-level last-resort handlers so an uncaught error or rejection
49
+ * still emits a valid hook result (fail-open) instead of crashing with no
50
+ * output. `fallback` is the result emitted on crash (default `{continue:true}`).
51
+ * Replaces the identical uncaughtException/unhandledRejection block that was
52
+ * copy-pasted across every hook.
53
+ */
54
+ export function installHookSafetyNet(
55
+ source: string,
56
+ fallback: HookResult = { continue: true },
57
+ ): void {
58
+ process.on("uncaughtException", (err) => {
59
+ debug(source, `UNCAUGHT EXCEPTION: ${err}`);
60
+ emitHookResult(fallback);
61
+ process.exit(0);
62
+ });
63
+ process.on("unhandledRejection", (reason) => {
64
+ debug(source, `UNHANDLED REJECTION: ${reason}`);
65
+ emitHookResult(fallback);
66
+ process.exit(0);
67
+ });
68
+ }
69
+
22
70
  /** Maximum stdin payload size: 50 MiB. Prevents unbounded memory allocation. */
23
71
  const MAX_STDIN_BYTES = 50 * 1024 * 1024;
24
72
 
@@ -11,23 +11,24 @@
11
11
  * 1. AIDE_PROJECT_ROOT env override (must be an existing directory).
12
12
  * 2. Walk the full ancestry from cwd to /, collecting candidates, then
13
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
14
+ * a. Closest ancestor with a VCS marker (.git/.hg/.svn/.bzr/.fossil).
15
+ * A VCS boundary including a submodule, which is its own
16
+ * repository is the project boundary: starting inside a
17
+ * submodule anchors the submodule, starting in the superproject
18
+ * anchors the superproject. Worktree .git files resolve to the
19
+ * main repo root (worktrees share one store); submodule .git
20
+ * files anchor the submodule directory itself.
21
+ * b. Closest ancestor with .aide/ only — standalone projects with
20
22
  * no VCS.
21
23
  * ~/.aide/ is skipped as a project marker unless cwd is $HOME.
22
24
  * 3. No marker found: return { root: cwd, hasMarker: false }.
23
25
  *
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.
26
+ * A stray child .aide/ (created by an accidental CLI invocation from a
27
+ * subdir) cannot shadow the real project root: it has no VCS marker, so
28
+ * any real repository above it takes priority.
28
29
  */
29
30
 
30
- import { basename, dirname, join, resolve } from "path";
31
+ import { basename, dirname, isAbsolute, join, resolve } from "path";
31
32
  import { existsSync, readFileSync, statSync } from "fs";
32
33
  import { homedir } from "os";
33
34
 
@@ -95,9 +96,6 @@ export function findProjectRoot(cwd: string): ProjectRootResult {
95
96
  dir = parent;
96
97
  }
97
98
 
98
- for (const c of path) {
99
- if (c.hasAide && c.hasVCS) return { root: c.vcsResolved, hasMarker: true };
100
- }
101
99
  for (const c of path) {
102
100
  if (c.hasVCS) return { root: c.vcsResolved, hasMarker: true };
103
101
  }
@@ -141,12 +139,27 @@ export function walkUpForProjectRoot(startDir: string): string | null {
141
139
  return hasMarker ? root : null;
142
140
  }
143
141
 
142
+ /**
143
+ * Reports whether a resolved gitdir path points into a superproject's
144
+ * .git/modules/ tree, i.e. the .git file belongs to a submodule checkout
145
+ * rather than a linked worktree.
146
+ */
147
+ function isSubmoduleGitdir(gitdir: string): boolean {
148
+ const parts = gitdir.split(/[\\/]+/);
149
+ for (let i = 0; i < parts.length - 1; i++) {
150
+ if (parts[i] === ".git" && parts[i + 1] === "modules") return true;
151
+ }
152
+ return false;
153
+ }
154
+
144
155
  /**
145
156
  * Read a .git worktree file ("gitdir: <path>") and return the main repo root.
146
157
  *
147
158
  * Mirrors aide/cmd/aide/main.go:resolveWorktreeRoot(). The file's gitdir
148
159
  * normally points at "<main>/.git/worktrees/<name>"; we walk up that path
149
160
  * until we find a component named ".git" and return its parent.
161
+ * Submodule .git files (gitdir under .git/modules/) return null so the
162
+ * submodule directory itself becomes the root.
150
163
  */
151
164
  export function resolveWorktreeGitFile(gitFilePath: string): string | null {
152
165
  try {
@@ -154,10 +167,12 @@ export function resolveWorktreeGitFile(gitFilePath: string): string | null {
154
167
  if (!content.startsWith("gitdir:")) return null;
155
168
 
156
169
  let gitdir = content.slice("gitdir:".length).trim();
157
- if (!gitdir.startsWith("/")) {
170
+ if (!isAbsolute(gitdir)) {
158
171
  gitdir = resolve(dirname(gitFilePath), gitdir);
159
172
  }
160
173
 
174
+ if (isSubmoduleGitdir(gitdir)) return null;
175
+
161
176
  let candidate = gitdir;
162
177
  for (;;) {
163
178
  const parent = dirname(candidate);