jeo-code 0.1.0 → 0.4.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 (177) hide show
  1. package/README.ja.md +160 -0
  2. package/README.ko.md +160 -0
  3. package/README.md +115 -297
  4. package/README.zh.md +160 -0
  5. package/package.json +11 -6
  6. package/scripts/install.sh +28 -28
  7. package/scripts/uninstall.sh +17 -15
  8. package/src/AGENTS.md +50 -0
  9. package/src/agent/AGENTS.md +49 -0
  10. package/src/agent/bash-fixups.ts +103 -0
  11. package/src/agent/compaction.ts +410 -19
  12. package/src/agent/config-schema.ts +119 -5
  13. package/src/agent/context-files.ts +314 -17
  14. package/src/agent/dev/AGENTS.md +36 -0
  15. package/src/agent/dev/advanced-analyzer.ts +12 -0
  16. package/src/agent/dev/evolution-bridge.ts +82 -0
  17. package/src/agent/dev/evolution-logger.ts +41 -0
  18. package/src/agent/dev/self-analysis.ts +64 -0
  19. package/src/agent/dev/self-improve.ts +24 -0
  20. package/src/agent/dev/spec-automation.ts +49 -0
  21. package/src/agent/engine.ts +808 -54
  22. package/src/agent/hooks.ts +273 -0
  23. package/src/agent/loop.ts +21 -1
  24. package/src/agent/memory.ts +201 -0
  25. package/src/agent/model-recency.ts +32 -0
  26. package/src/agent/output-minimizer.ts +108 -0
  27. package/src/agent/output-util.ts +64 -0
  28. package/src/agent/plan.ts +187 -0
  29. package/src/agent/seed.ts +52 -0
  30. package/src/agent/session.ts +235 -21
  31. package/src/agent/state.ts +286 -39
  32. package/src/agent/step-budget.ts +232 -0
  33. package/src/agent/subagents.ts +223 -26
  34. package/src/agent/task-tool.ts +272 -0
  35. package/src/agent/todo-tool.ts +87 -0
  36. package/src/agent/tokenizer.ts +117 -0
  37. package/src/agent/tool-registry.ts +54 -0
  38. package/src/agent/tools.ts +624 -103
  39. package/src/agent/web-search.ts +538 -0
  40. package/src/ai/AGENTS.md +44 -0
  41. package/src/ai/index.ts +1 -0
  42. package/src/ai/model-catalog-compat.ts +3 -1
  43. package/src/ai/model-catalog.ts +74 -9
  44. package/src/ai/model-discovery.ts +215 -17
  45. package/src/ai/model-manager.ts +346 -32
  46. package/src/ai/model-picker.ts +1 -1
  47. package/src/ai/model-registry.ts +4 -2
  48. package/src/ai/pricing.ts +84 -0
  49. package/src/ai/provider-registry.ts +23 -0
  50. package/src/ai/provider-status.ts +60 -16
  51. package/src/ai/providers/AGENTS.md +42 -0
  52. package/src/ai/providers/anthropic.ts +250 -31
  53. package/src/ai/providers/antigravity.ts +219 -0
  54. package/src/ai/providers/errors.ts +15 -1
  55. package/src/ai/providers/gemini.ts +196 -13
  56. package/src/ai/providers/ollama.ts +37 -7
  57. package/src/ai/providers/openai-responses.ts +173 -0
  58. package/src/ai/providers/openai.ts +64 -12
  59. package/src/ai/sse.ts +4 -1
  60. package/src/ai/types.ts +18 -1
  61. package/src/auth/AGENTS.md +41 -0
  62. package/src/auth/callback-server.ts +6 -1
  63. package/src/auth/flows/AGENTS.md +32 -0
  64. package/src/auth/flows/antigravity.ts +151 -0
  65. package/src/auth/flows/google-project.ts +190 -0
  66. package/src/auth/flows/google.ts +39 -18
  67. package/src/auth/flows/index.ts +15 -5
  68. package/src/auth/flows/openai.ts +2 -2
  69. package/src/auth/oauth.ts +8 -0
  70. package/src/auth/refresh.ts +44 -27
  71. package/src/auth/storage.ts +149 -26
  72. package/src/auth/types.ts +1 -1
  73. package/src/autopilot.ts +362 -0
  74. package/src/bun-imports.d.ts +4 -0
  75. package/src/cli/AGENTS.md +39 -0
  76. package/src/cli/runner.ts +148 -14
  77. package/src/cli.ts +13 -4
  78. package/src/commands/AGENTS.md +40 -0
  79. package/src/commands/approve.ts +62 -3
  80. package/src/commands/auth.ts +167 -25
  81. package/src/commands/chat.ts +37 -8
  82. package/src/commands/deep-interview.ts +633 -175
  83. package/src/commands/doctor.ts +84 -37
  84. package/src/commands/evolve-core.ts +18 -0
  85. package/src/commands/evolve.ts +2 -1
  86. package/src/commands/export.ts +176 -0
  87. package/src/commands/gjc.ts +52 -0
  88. package/src/commands/launch.ts +3549 -240
  89. package/src/commands/mcp.ts +3 -3
  90. package/src/commands/ooo-seed.ts +19 -0
  91. package/src/commands/ralplan.ts +253 -35
  92. package/src/commands/resume.ts +1 -1
  93. package/src/commands/session.ts +183 -0
  94. package/src/commands/setup-helpers.ts +10 -3
  95. package/src/commands/setup.ts +57 -16
  96. package/src/commands/skills.ts +78 -18
  97. package/src/commands/state.ts +198 -0
  98. package/src/commands/status.ts +84 -0
  99. package/src/commands/team.ts +340 -212
  100. package/src/commands/ultragoal.ts +122 -61
  101. package/src/commands/update.ts +244 -0
  102. package/src/ledger.ts +270 -0
  103. package/src/mcp/AGENTS.md +38 -0
  104. package/src/mcp/server.ts +115 -14
  105. package/src/mcp/tools.ts +42 -22
  106. package/src/md-modules.d.ts +4 -0
  107. package/src/prompts/AGENTS.md +41 -0
  108. package/src/prompts/agents/AGENTS.md +35 -0
  109. package/src/prompts/agents/architect.md +35 -0
  110. package/src/prompts/agents/critic.md +37 -0
  111. package/src/prompts/agents/executor.md +36 -0
  112. package/src/prompts/agents/planner.md +37 -0
  113. package/src/prompts/skills/AGENTS.md +36 -0
  114. package/src/prompts/skills/deep-dive/AGENTS.md +31 -0
  115. package/src/prompts/skills/deep-dive/SKILL.md +13 -0
  116. package/src/prompts/skills/deep-interview/AGENTS.md +31 -0
  117. package/src/prompts/skills/deep-interview/SKILL.md +12 -0
  118. package/src/prompts/skills/gjc/AGENTS.md +31 -0
  119. package/src/prompts/skills/gjc/SKILL.md +15 -0
  120. package/src/prompts/skills/ralplan/AGENTS.md +31 -0
  121. package/src/prompts/skills/ralplan/SKILL.md +11 -0
  122. package/src/prompts/skills/team/AGENTS.md +31 -0
  123. package/src/prompts/skills/team/SKILL.md +11 -0
  124. package/src/prompts/skills/ultragoal/AGENTS.md +31 -0
  125. package/src/prompts/skills/ultragoal/SKILL.md +11 -0
  126. package/src/skills/AGENTS.md +38 -0
  127. package/src/skills/catalog.ts +565 -31
  128. package/src/tui/AGENTS.md +43 -0
  129. package/src/tui/app.ts +1181 -92
  130. package/src/tui/components/AGENTS.md +42 -0
  131. package/src/tui/components/ascii-art.ts +257 -15
  132. package/src/tui/components/autocomplete.ts +98 -16
  133. package/src/tui/components/autopilot-status.ts +65 -0
  134. package/src/tui/components/category-index.ts +49 -0
  135. package/src/tui/components/code-view.ts +54 -11
  136. package/src/tui/components/color.ts +171 -2
  137. package/src/tui/components/config-panel.ts +82 -15
  138. package/src/tui/components/duration.ts +38 -0
  139. package/src/tui/components/evolution.ts +3 -3
  140. package/src/tui/components/footer.ts +91 -42
  141. package/src/tui/components/forge.ts +426 -31
  142. package/src/tui/components/hints.ts +54 -0
  143. package/src/tui/components/hud.ts +73 -0
  144. package/src/tui/components/index.ts +4 -0
  145. package/src/tui/components/input-box.ts +150 -0
  146. package/src/tui/components/layout.ts +11 -3
  147. package/src/tui/components/live-model-picker.ts +108 -0
  148. package/src/tui/components/markdown-table.ts +140 -0
  149. package/src/tui/components/markdown-text.ts +97 -0
  150. package/src/tui/components/meter.ts +4 -1
  151. package/src/tui/components/model-picker.ts +3 -2
  152. package/src/tui/components/provider-picker.ts +3 -2
  153. package/src/tui/components/section.ts +70 -0
  154. package/src/tui/components/select-list.ts +40 -10
  155. package/src/tui/components/skill-picker.ts +25 -0
  156. package/src/tui/components/slash.ts +244 -21
  157. package/src/tui/components/status.ts +272 -11
  158. package/src/tui/components/step-timeline.ts +218 -0
  159. package/src/tui/components/stream.ts +26 -9
  160. package/src/tui/components/themes.ts +212 -6
  161. package/src/tui/components/todo-card.ts +47 -0
  162. package/src/tui/components/tool-list.ts +58 -12
  163. package/src/tui/components/transcript.ts +120 -0
  164. package/src/tui/components/update-box.ts +31 -0
  165. package/src/tui/components/welcome.ts +162 -0
  166. package/src/tui/components/width.ts +163 -0
  167. package/src/tui/monitoring/AGENTS.md +31 -0
  168. package/src/tui/monitoring/hud-view.ts +55 -0
  169. package/src/tui/renderer.ts +112 -3
  170. package/src/tui/terminal.ts +40 -33
  171. package/src/util/AGENTS.md +39 -0
  172. package/src/util/clipboard-image.ts +118 -0
  173. package/src/util/env.ts +12 -0
  174. package/src/util/provider-error.ts +78 -0
  175. package/src/util/retry.ts +91 -6
  176. package/src/util/update-check.ts +64 -0
  177. package/src/commands/models.ts +0 -104
@@ -1,6 +1,17 @@
1
+ import { applyBashFixups } from "./bash-fixups";
1
2
  import * as fs from "node:fs/promises";
2
3
  import * as path from "node:path";
3
- import { readWorkflowState } from "./state";
4
+ import { readWorkflowState, readWorkflowStateStrict, type WorkflowState } from "./state";
5
+ import { jeoEnv } from "../util/env";
6
+
7
+ /** Read the deep-interview lock; on corrupt state fail CLOSED (treat as active lock). */
8
+ async function readMutationLock(cwd: string): Promise<WorkflowState | null> {
9
+ try {
10
+ return await readWorkflowStateStrict("deep-interview", cwd);
11
+ } catch {
12
+ return { active: true, current_phase: "locked", skill: "deep-interview" };
13
+ }
14
+ }
4
15
 
5
16
  export interface ToolResult {
6
17
  success: boolean;
@@ -10,7 +21,7 @@ export interface ToolResult {
10
21
 
11
22
  /**
12
23
  * Directories that pollute `find`/`search` results and waste time: VCS, build
13
- * artifacts, dependency trees, and joc's own runtime dir. gjc's native search
24
+ * artifacts, dependency trees, and jeo's own runtime dir. gjc's native search
14
25
  * respects ignore files; this is the pure-TS equivalent.
15
26
  */
16
27
  export const IGNORED_DIRS = [
@@ -20,32 +31,62 @@ export const IGNORED_DIRS = [
20
31
  "build",
21
32
  "coverage",
22
33
  ".next",
23
- ".joc",
34
+ ".jeo",
24
35
  "vendor",
25
36
  ".cache",
26
37
  ];
27
38
 
39
+ /**
40
+ * Read the repo-root `.gitignore` into basename dir + file-glob exclude lists, so
41
+ * find/search match repository intent (build artifacts, logs, .env, …) on top of
42
+ * IGNORED_DIRS. Conservative semantics: single-segment patterns only (entries with
43
+ * an internal `/` are anchored/path patterns that basename excludes can't represent),
44
+ * negations (`!`) and comments skipped. Absent/unreadable .gitignore → empty (no-op).
45
+ */
46
+ export async function readGitignore(cwd: string): Promise<{ dirs: string[]; fileGlobs: string[] }> {
47
+ let content = "";
48
+ try {
49
+ content = await fs.readFile(path.join(cwd, ".gitignore"), "utf-8");
50
+ } catch {
51
+ return { dirs: [], fileGlobs: [] };
52
+ }
53
+ const dirs = new Set<string>();
54
+ const fileGlobs = new Set<string>();
55
+ for (const raw of content.split("\n")) {
56
+ const line = raw.trim();
57
+ if (!line || line.startsWith("#") || line.startsWith("!")) continue;
58
+ let p = line;
59
+ const dirOnly = p.endsWith("/");
60
+ if (dirOnly) p = p.slice(0, -1);
61
+ if (p.startsWith("/")) p = p.slice(1);
62
+ if (!p || p.includes("/")) continue; // skip anchored/multi-segment patterns
63
+ dirs.add(p);
64
+ if (!dirOnly) fileGlobs.add(p);
65
+ }
66
+ return { dirs: [...dirs], fileGlobs: [...fileGlobs] };
67
+ }
68
+
28
69
  /**
29
70
  * Validates if codebase mutation tools are blocked due to an active Socratic interview.
30
71
  * Mutation is blocked only if deep-interview is active, not completed, and the file
31
- * is NOT under the `.joc/` directory (planning/spec files are allowed).
72
+ * is NOT under the `.jeo/` directory (planning/spec files are allowed).
32
73
  */
33
74
  export async function assertMutationAllowed(
34
75
  filePath: string,
35
76
  cwd: string = process.cwd()
36
77
  ): Promise<void> {
37
- const deepInterviewState = await readWorkflowState("deep-interview", cwd);
78
+ const deepInterviewState = await readMutationLock(cwd);
38
79
  if (deepInterviewState && deepInterviewState.active && deepInterviewState.current_phase !== "complete") {
39
- // Check if the target is NOT inside the local .joc folder. Use a path-boundary
40
- // check (not bare startsWith) so siblings like ".joc-backup" aren't mistaken for ".joc/".
80
+ // Check if the target is NOT inside the local .jeo folder. Use a path-boundary
81
+ // check (not bare startsWith) so siblings like ".jeo-backup" aren't mistaken for ".jeo/".
41
82
  const absPath = path.resolve(cwd, filePath);
42
- const jocDir = path.resolve(cwd, ".joc");
43
- const insideJoc = absPath === jocDir || absPath.startsWith(jocDir + path.sep);
44
- if (!insideJoc) {
83
+ const jeoDir = path.resolve(cwd, ".jeo");
84
+ const insideJeo = absPath === jeoDir || absPath.startsWith(jeoDir + path.sep);
85
+ if (!insideJeo) {
45
86
  throw new Error(
46
- `[MutationGuard Blocked] Code mutation is strictly blocked during an active Socratic interview.\n` +
47
- `Current Ambiguity Score: ${((deepInterviewState.current_ambiguity ?? 1) * 100).toFixed(0)}% (must be <= 20% to unlock).\n` +
48
- `Only spec/planning writes under '.joc/' are permitted. Finish requirements with 'joc deep-interview' first.`
87
+ `[MutationGuard Blocked] Code mutation is blocked while a Socratic interview is active (the requirements seed is not yet frozen).\n` +
88
+ `Current ambiguity: ${((deepInterviewState.current_ambiguity ?? 1) * 100).toFixed(0)}%. Finish the interview to freeze the seed and unlock writes — run 'jeo deep-interview'. Non-interactive '--auto' can continue clarification, but it does not bypass the ambiguity gate.\n` +
89
+ `Only spec/planning writes under '.jeo/' are permitted until then.`
49
90
  );
50
91
  }
51
92
  }
@@ -54,46 +95,204 @@ export async function assertMutationAllowed(
54
95
  export async function assertBashAllowed(
55
96
  cwd: string = process.cwd()
56
97
  ): Promise<void> {
57
- const deepInterviewState = await readWorkflowState("deep-interview", cwd);
98
+ const deepInterviewState = await readMutationLock(cwd);
58
99
  if (deepInterviewState && deepInterviewState.active && deepInterviewState.current_phase !== "complete") {
59
100
  throw new Error(
60
- "[MutationGuard] bash is disabled during an active Socratic interview (ambiguity must reach <=20% first). Finish 'joc deep-interview'."
101
+ "[MutationGuard] bash is blocked while a Socratic interview is active (requirements seed not frozen). Finish 'jeo deep-interview' to continue; '--auto' does not bypass the ambiguity gate."
61
102
  );
62
103
  }
63
104
  }
64
105
 
106
+ /**
107
+ * Parse a read line selector into sorted, merged, inclusive [start,end] ranges.
108
+ * Segments are comma-separated; each is one of: "a-b" (range), "a-" (a→EOF),
109
+ * "a" (single line), or "a+n" (n lines starting at a). Out-of-range starts are
110
+ * dropped; an explicit "a-b" with b<a is an error. Mirrors gjc's read selectors.
111
+ */
112
+ export function parseLineSelector(spec: string | number, total: number): { ranges: [number, number][] } | { error: string } {
113
+ // Field crash guard: models pass `lineRange: 10` (number) or other JSON junk —
114
+ // `spec.split is not a function` killed the read instead of degrading politely.
115
+ if (typeof spec !== "string") {
116
+ if (typeof spec === "number" && Number.isFinite(spec)) spec = String(spec);
117
+ else return { error: `selector must be a string like "10-20", got ${JSON.stringify(spec)}` };
118
+ }
119
+ const segs = spec.split(",").map(s => s.trim()).filter(Boolean);
120
+ if (segs.length === 0) return { error: "empty selector" };
121
+ const ranges: [number, number][] = [];
122
+ for (const seg of segs) {
123
+ let m: RegExpMatchArray | null;
124
+ if ((m = seg.match(/^(\d+)\+(\d+)$/))) {
125
+ const start = Math.max(1, parseInt(m[1]));
126
+ const count = Math.max(1, parseInt(m[2]));
127
+ if (start <= total) ranges.push([start, Math.min(total, start + count - 1)]);
128
+ } else if ((m = seg.match(/^(\d+)-(\d+)$/))) {
129
+ const start = Math.max(1, parseInt(m[1]));
130
+ const end = parseInt(m[2]);
131
+ if (end < start) return { error: `segment '${seg}': end < start (file has ${total} lines)` };
132
+ if (start <= total) ranges.push([start, Math.min(total, end)]);
133
+ } else if ((m = seg.match(/^(\d+)-$/))) {
134
+ const start = Math.max(1, parseInt(m[1]));
135
+ if (start <= total) ranges.push([start, total]);
136
+ } else if ((m = seg.match(/^(\d+)$/))) {
137
+ const start = Math.max(1, parseInt(m[1]));
138
+ if (start <= total) ranges.push([start, start]);
139
+ } else {
140
+ return { error: `invalid segment '${seg}'. Use "a-b", "a-", "a", or "a+n".` };
141
+ }
142
+ }
143
+ ranges.sort((x, y) => x[0] - y[0]);
144
+ const merged: [number, number][] = [];
145
+ for (const [s, e] of ranges) {
146
+ const last = merged[merged.length - 1];
147
+ if (last && s <= last[1] + 1) last[1] = Math.max(last[1], e);
148
+ else merged.push([s, e]);
149
+ }
150
+ return { ranges: merged };
151
+ }
152
+
153
+ // ── hashline-lite (plan/gjc-inheritance.md B2, gjc hashline 경량 계승) ──
154
+ // Every annotated read line carries a 2-char CONTENT anchor: `42ab|text`. The
155
+ // hash depends only on the line's content (trailing whitespace/CR ignored), so
156
+ // sibling edits that shift line numbers keep the anchor valid. The edit tool
157
+ // accepts the anchors on `≔` ranges (`≔12ab..15cd`) and verifies them before
158
+ // mutating — a mismatch means the model is editing content it has not seen.
159
+ // The anchor ALWAYS leads with a letter ([a-z]) so a directive like `≔1ab` is
160
+ // unambiguous: the `\d+` line number can never swallow the anchor. (A purely
161
+ // numeric anchor such as `68` would make `≔1`+`68` parse as line 168 with no
162
+ // anchor, silently skipping verification — exactly what hashline must prevent.)
163
+ const ANCHOR_FIRST = 26; // a-z
164
+ const ANCHOR_SECOND = 36; // 0-9a-z
165
+ export function lineAnchor(line: string): string {
166
+ const normalized = line.replace(/\r$/, "").replace(/[ \t]+$/, "");
167
+ const n = Number(BigInt(Bun.hash(normalized)) % BigInt(ANCHOR_FIRST * ANCHOR_SECOND));
168
+ const first = String.fromCharCode(97 + Math.floor(n / ANCHOR_SECOND)); // a-z, never a digit
169
+ return first + (n % ANCHOR_SECOND).toString(36); // second char 0-9a-z
170
+ }
171
+
172
+ /** A read-output anchor prefix at line start: `42ab|` (hashed) or legacy `42|`. */
173
+ const ANCHOR_PREFIX_RE = /^\d+(?:[a-z0-9]{2})?\|/;
174
+
175
+ /** Strip read-output anchor prefixes from a block IF every non-empty line carries
176
+ * one — the signature of content copy-pasted from read output into a SEARCH
177
+ * block (the dual-protocol trap: anchors are display chrome, not file bytes). */
178
+ function stripAnchorPrefixes(block: string): string | null {
179
+ const lines = block.split("\n");
180
+ if (!lines.some(l => l.trim() !== "")) return null;
181
+ if (!lines.every(l => l.trim() === "" || ANCHOR_PREFIX_RE.test(l))) return null;
182
+ return lines.map(l => l.replace(ANCHOR_PREFIX_RE, "")).join("\n");
183
+ }
184
+ // ── File-freshness guard (plan/gjc-inheritance.md B7, gjc edit/file-read-cache 계승) ──
185
+ // `read` records each file's stat fingerprint; `edit`/`write` verify it before
186
+ // mutating. A file changed by someone else (concurrent agent, user, formatter)
187
+ // between the read and the edit is REJECTED once — with the CURRENT content
188
+ // re-presented so the model can retry immediately (recovery, not just a guard).
189
+ const lastReadSnapshots = new Map<string, { mtimeMs: number; size: number }>();
190
+ const MAX_SNAPSHOT_ENTRIES = 64;
191
+
192
+ function recordReadSnapshot(absPath: string, st: { mtimeMs: number; size: number } | null): void {
193
+ if (!st) return;
194
+ if (lastReadSnapshots.size >= MAX_SNAPSHOT_ENTRIES && !lastReadSnapshots.has(absPath)) {
195
+ const oldest = lastReadSnapshots.keys().next().value;
196
+ if (oldest !== undefined) lastReadSnapshots.delete(oldest);
197
+ }
198
+ lastReadSnapshots.delete(absPath); // re-insert to refresh LRU order
199
+ lastReadSnapshots.set(absPath, { mtimeMs: st.mtimeMs, size: st.size });
200
+ }
201
+
202
+ /** Annotated excerpt of the file's CURRENT content for recovery errors (read-format `N|`). */
203
+ function excerptForRecovery(content: string, centerLine?: number): string {
204
+ const lines = content.split("\n");
205
+ const SPAN = 60;
206
+ let start = 1;
207
+ let end = Math.min(lines.length, 2 * SPAN);
208
+ if (centerLine && centerLine >= 1 && centerLine <= lines.length) {
209
+ start = Math.max(1, centerLine - SPAN);
210
+ end = Math.min(lines.length, centerLine + SPAN);
211
+ }
212
+ const body = lines.slice(start - 1, end).map((l, i) => `${start + i}${lineAnchor(l)}|${l}`).join("\n");
213
+ const note = lines.length > end - start + 1 ? `\n…(showing lines ${start}-${end} of ${lines.length})` : "";
214
+ return body + note;
215
+ }
216
+
217
+ /** Returns a recovery error when `absPath` changed since the agent last read it; null when fresh.
218
+ * Refreshes the snapshot to CURRENT so the immediate retry (model just saw fresh content) passes. */
219
+ async function staleReadError(absPath: string, filePath: string, verb: string): Promise<ToolResult | null> {
220
+ const snap = lastReadSnapshots.get(absPath);
221
+ if (!snap) return null; // never read → no guard (back-compat; read-first is advisory)
222
+ const st = await fs.stat(absPath).catch(() => null);
223
+ if (!st || !st.isFile()) return null; // deleted/replaced-by-dir: let the op surface its own error
224
+ if (st.mtimeMs === snap.mtimeMs && st.size === snap.size) return null;
225
+ const content = await fs.readFile(absPath, "utf-8").catch(() => null);
226
+ recordReadSnapshot(absPath, st); // the model sees the fresh content below → retry passes
227
+ const excerpt = content !== null ? `\nCurrent content:\n${excerptForRecovery(content)}` : "";
228
+ return {
229
+ success: false,
230
+ output: "",
231
+ error:
232
+ `${verb} rejected: ${filePath} changed on disk since you last read it (another agent, the user, or a formatter touched it). ` +
233
+ `Re-target your ${verb.toLowerCase()} against the CURRENT content below, then retry.${excerpt}`,
234
+ };
235
+ }
65
236
  export async function readTool(
66
237
  filePath: string,
67
- lineRange?: string,
68
- cwd: string = process.cwd()
238
+ lineRange?: string | number,
239
+ cwd: string = process.cwd(),
240
+ raw: boolean = false
69
241
  ): Promise<ToolResult> {
70
242
  try {
243
+ if (typeof filePath !== "string" || filePath.trim() === "") {
244
+ return { success: false, output: "", error: 'read requires a non-empty "filePath".' };
245
+ }
71
246
  const absPath = path.resolve(cwd, filePath);
247
+ // gjc parity: reading a directory returns its listing instead of an EISDIR error.
248
+ const st = await fs.stat(absPath).catch(() => null);
249
+ if (st?.isDirectory()) {
250
+ if (raw || lineRange) {
251
+ return { success: false, output: "", error: `${filePath} is a directory — drop raw/lineRange; reading it lists entries.` };
252
+ }
253
+ return lsTool(filePath, cwd);
254
+ }
72
255
  const content = await fs.readFile(absPath, "utf-8");
256
+ if (st?.isFile()) recordReadSnapshot(absPath, st); // arm the edit/write freshness guard
257
+
258
+ if (raw) {
259
+ // Verbatim bytes, no "N|" line prefixes (gjc `:raw`), char-capped for context safety.
260
+ const MAX_CHARS = 50_000;
261
+ if (content.length > MAX_CHARS) {
262
+ return { success: true, output: content.slice(0, MAX_CHARS) + `\n…(raw truncated at ${MAX_CHARS} of ${content.length} chars; drop raw and pass lineRange to read a slice)` };
263
+ }
264
+ return { success: true, output: content };
265
+ }
266
+
73
267
  const lines = content.split("\n");
74
268
 
75
- if (lineRange) {
76
- // Accept "start-end", open-ended "start-", or a single "start".
77
- const match = lineRange.match(/^(\d+)(?:-(\d+)?)?$/);
78
- if (match) {
79
- const start = Math.max(1, parseInt(match[1]));
80
- const hasRange = lineRange.includes("-");
81
- const end = match[2]
82
- ? Math.min(lines.length, parseInt(match[2]))
83
- : hasRange
84
- ? lines.length // "start-" to EOF
85
- : Math.min(lines.length, start); // single line
86
- if (end < start) {
87
- return { success: false, output: "", error: `Invalid lineRange '${lineRange}': end < start (file has ${lines.length} lines)` };
269
+ if (lineRange !== undefined && lineRange !== null && lineRange !== "") {
270
+ const parsed = parseLineSelector(lineRange, lines.length);
271
+ if ("error" in parsed) {
272
+ return { success: false, output: "", error: `Invalid lineRange '${lineRange}': ${parsed.error}` };
273
+ }
274
+ if (parsed.ranges.length === 0) {
275
+ return { success: true, output: `(no lines in range; file has ${lines.length} lines)` };
276
+ }
277
+ const MAX_RANGE_LINES = 2000; // cap selected output so a huge `1-` can't materialize MBs
278
+ const out: string[] = [];
279
+ let emitted = 0;
280
+ let capped = false;
281
+ outer: for (const [i, [start, end]] of parsed.ranges.entries()) {
282
+ if (emitted >= MAX_RANGE_LINES) { capped = true; break; }
283
+ if (i > 0) out.push("…"); // gap marker between non-contiguous ranges
284
+ for (let ln = start; ln <= end; ln++) {
285
+ if (emitted >= MAX_RANGE_LINES) { capped = true; break outer; }
286
+ out.push(`${ln}${lineAnchor(lines[ln - 1] ?? "")}|${lines[ln - 1] ?? ""}`);
287
+ emitted++;
88
288
  }
89
- const sliced = lines.slice(start - 1, end).map((l, i) => `${start + i}|${l}`).join("\n");
90
- return { success: true, output: sliced };
91
289
  }
92
- return { success: false, output: "", error: `Invalid lineRange '${lineRange}'. Use "start-end", "start-", or "start".` };
290
+ if (capped) out.push(`…(range truncated at ${MAX_RANGE_LINES} lines; narrow the range)`);
291
+ return { success: true, output: out.join("\n") };
93
292
  }
94
293
 
95
294
  const MAX_LINES = 500;
96
- const annotated = lines.slice(0, MAX_LINES).map((l, i) => `${i + 1}|${l}`).join("\n");
295
+ const annotated = lines.slice(0, MAX_LINES).map((l, i) => `${i + 1}${lineAnchor(l)}|${l}`).join("\n");
97
296
  if (lines.length > MAX_LINES) {
98
297
  const notice = `\n…(showing lines 1-${MAX_LINES} of ${lines.length}; pass lineRange "${MAX_LINES + 1}-" to read the rest)`;
99
298
  return { success: true, output: annotated + notice };
@@ -110,124 +309,251 @@ export async function writeTool(
110
309
  cwd: string = process.cwd()
111
310
  ): Promise<ToolResult> {
112
311
  try {
312
+ if (typeof filePath !== "string" || filePath.trim() === "") {
313
+ return { success: false, output: "", error: 'write requires a non-empty "filePath".' };
314
+ }
113
315
  await assertMutationAllowed(filePath, cwd);
114
316
  const absPath = path.resolve(cwd, filePath);
317
+ const stale = await staleReadError(absPath, filePath, "Write");
318
+ if (stale) return stale;
115
319
  await fs.mkdir(path.dirname(absPath), { recursive: true });
116
320
  await fs.writeFile(absPath, content, "utf-8");
321
+ recordReadSnapshot(absPath, await fs.stat(absPath).catch(() => null)); // own change ≠ stale
117
322
  return { success: true, output: `Successfully wrote ${content.length} characters to ${filePath}` };
118
323
  } catch (err: any) {
119
324
  return { success: false, output: "", error: err.message };
120
325
  }
121
326
  }
122
327
 
328
+ /** Strip a single leading and trailing newline (CRLF or LF) — the editBlock framing. */
329
+ function trimOneNewline(s: string): string {
330
+ if (s.startsWith("\r\n")) s = s.slice(2);
331
+ else if (s.startsWith("\n")) s = s.slice(1);
332
+ if (s.endsWith("\r\n")) s = s.slice(0, -2);
333
+ else if (s.endsWith("\n")) s = s.slice(0, -1);
334
+ return s;
335
+ }
336
+
337
+ /**
338
+ * Parse one or MORE `<<<<<<< SEARCH / ======= / >>>>>>>` hunks from an edit block.
339
+ * Returns null when there are no SEARCH markers (so the ≔ path / format error stands).
340
+ * Each hunk's search/replace has one framing newline trimmed (same as the legacy
341
+ * single-hunk path). A marker present but malformed (no `=======`/`>>>>>>>`) → null.
342
+ */
343
+ export function parseEditHunks(block: string): { search: string; replace: string }[] | null {
344
+ if (!block.includes("<<<<<<< SEARCH")) return null;
345
+ const hunks: { search: string; replace: string }[] = [];
346
+ for (const seg of block.split("<<<<<<< SEARCH").slice(1)) {
347
+ const eq = seg.indexOf("=======");
348
+ if (eq === -1) return null;
349
+ const gt = seg.indexOf(">>>>>>>", eq);
350
+ if (gt === -1) return null;
351
+ hunks.push({ search: trimOneNewline(seg.slice(0, eq)), replace: trimOneNewline(seg.slice(eq + 7, gt)) });
352
+ }
353
+ return hunks.length ? hunks : null;
354
+ }
355
+
123
356
  export async function editTool(
124
357
  filePath: string,
125
358
  editBlock: string,
126
359
  cwd: string = process.cwd()
127
360
  ): Promise<ToolResult> {
128
361
  try {
362
+ if (typeof filePath !== "string" || filePath.trim() === "") {
363
+ return { success: false, output: "", error: 'edit requires a non-empty "filePath".' };
364
+ }
365
+ if (typeof editBlock !== "string" || editBlock === "") {
366
+ return { success: false, output: "", error: 'edit requires a non-empty "editBlock" (≔ directive or <<<<<<< SEARCH block).' };
367
+ }
129
368
  await assertMutationAllowed(filePath, cwd);
130
369
  const absPath = path.resolve(cwd, filePath);
370
+ const stale = await staleReadError(absPath, filePath, "Edit");
371
+ if (stale) return stale;
131
372
  let content = await fs.readFile(absPath, "utf-8");
132
373
 
133
374
  // Line-anchored edit parser. Modes (payload follows the directive's newline):
134
375
  // ≔A..B replace lines A..B ≔A replace line A
135
376
  // ≔A+ insert AFTER line A (A=0 prepends)
136
377
  // ≔$ append to end of file
378
+ // Line numbers MAY carry the 2-char content anchors from read output
379
+ // (`≔12ab..15cd`, `≔7xy+`) — hashline-lite verifies them against the CURRENT
380
+ // content before mutating, so an edit aimed at moved/changed lines is
381
+ // rejected with the fresh content instead of silently corrupting the file.
137
382
  // Falls back to <<<<<<< SEARCH / ======= / >>>>>>> substring replacement.
138
383
  const lines = content.split("\n");
139
384
 
385
+ /** Verify a model-supplied anchor against the current line; null = ok. */
386
+ const anchorMismatch = (lineNo: number, anchor: string | undefined): ToolResult | null => {
387
+ if (!anchor) return null; // anchors are optional — plain ≔A..B stays valid
388
+ const actual = lineAnchor(lines[lineNo - 1] ?? "");
389
+ if (actual === anchor) return null;
390
+ return {
391
+ success: false,
392
+ output: "",
393
+ error:
394
+ `Edit rejected: anchor mismatch at line ${lineNo} — you sent ${lineNo}${anchor} but the current line hashes to ${lineNo}${actual}. ` +
395
+ `The content moved or changed since you read it. Re-target against the CURRENT content below and retry.\n` +
396
+ `Current content:\n${excerptForRecovery(content, lineNo)}`,
397
+ };
398
+ };
399
+ // hashline 3-way re-map (plan/gjc-inheritance.md cycle 9): content-only
400
+ // hashes mean a line that sibling edits SHIFTED still carries its anchor.
401
+ // When a supplied anchor no longer sits at its line number, look within a
402
+ // ±window for the UNIQUE line carrying that anchor and relocate the edit
403
+ // there. Ambiguous (>1 match) or absent → null, so the caller falls back to
404
+ // the existing reject+re-present path rather than guessing.
405
+ const ANCHOR_REMAP_WINDOW = 64;
406
+ const locateAnchor = (anchor: string, near: number): number | null => {
407
+ const lo = Math.max(1, near - ANCHOR_REMAP_WINDOW);
408
+ const hi = Math.min(lines.length, near + ANCHOR_REMAP_WINDOW);
409
+ let found = -1;
410
+ for (let i = lo; i <= hi; i++) {
411
+ if (lineAnchor(lines[i - 1] ?? "") === anchor) {
412
+ if (found !== -1) return null; // ambiguous — refuse to guess
413
+ found = i;
414
+ }
415
+ }
416
+ return found === -1 ? null : found;
417
+ };
418
+
140
419
  let updated = false;
141
420
  if (editBlock.startsWith("≔")) {
142
421
  const appendMatch = editBlock.match(/^≔\$\n?([\s\S]*)$/);
143
- const insertMatch = editBlock.match(/^≔(\d+)\+\n?([\s\S]*)$/);
144
- const replaceMatch = editBlock.match(/^≔(\d+)(?:\.\.(\d+))?\n([\s\S]*)$/);
422
+ const insertMatch = editBlock.match(/^≔(\d+)([a-z0-9]{2})?\+\n?([\s\S]*)$/);
423
+ const replaceMatch = editBlock.match(/^≔(\d+)([a-z0-9]{2})?(?:\.\.(\d+)([a-z0-9]{2})?)?\n([\s\S]*)$/);
145
424
  if (appendMatch) {
146
425
  const payload = appendMatch[1];
147
426
  content = content === "" || content.endsWith("\n") ? content + payload : content + "\n" + payload;
148
427
  updated = true;
149
428
  } else if (insertMatch) {
150
- const at = parseInt(insertMatch[1]); // insert AFTER line `at`; 0 prepends
151
- const payload = insertMatch[2];
429
+ let at = parseInt(insertMatch[1]); // insert AFTER line `at`; 0 prepends
430
+ const payload = insertMatch[3];
152
431
  if (at < 0 || at > lines.length) {
153
432
  return { success: false, output: "", error: `Invalid insert position ${at}: out of bounds (file has ${lines.length} lines)` };
154
433
  }
434
+ const anchor = insertMatch[2];
435
+ if (at >= 1 && anchor && lineAnchor(lines[at - 1] ?? "") !== anchor) {
436
+ const moved = locateAnchor(anchor, at); // shifted line → re-map the insert point
437
+ if (moved !== null) at = moved;
438
+ }
439
+ if (at >= 1) {
440
+ const mismatch = anchorMismatch(at, anchor);
441
+ if (mismatch) return mismatch;
442
+ }
155
443
  lines.splice(at, 0, payload);
156
444
  content = lines.join("\n");
157
445
  updated = true;
158
446
  } else if (replaceMatch) {
159
447
  const startLine = parseInt(replaceMatch[1]);
160
- const endLine = replaceMatch[2] ? parseInt(replaceMatch[2]) : startLine;
161
- const payload = replaceMatch[3];
162
- if (startLine < 1 || endLine < startLine || endLine > lines.length) {
448
+ const endLine = replaceMatch[3] ? parseInt(replaceMatch[3]) : startLine;
449
+ const payload = replaceMatch[5];
450
+ const aStart = replaceMatch[2];
451
+ const aEnd = replaceMatch[4];
452
+ // 3-way re-map: when the anchors no longer match their line numbers,
453
+ // relocate the WHOLE range by one uniform delta. Both ends must agree
454
+ // (preserves range length + contiguity), else fall through to reject.
455
+ let s = startLine;
456
+ let e = endLine;
457
+ const sBad = !!aStart && lineAnchor(lines[s - 1] ?? "") !== aStart;
458
+ const eBad = !!aEnd && lineAnchor(lines[e - 1] ?? "") !== aEnd;
459
+ if (sBad || eBad) {
460
+ let delta: number | null = null;
461
+ if (sBad) {
462
+ const moved = locateAnchor(aStart!, s);
463
+ if (moved !== null) delta = moved - s;
464
+ }
465
+ if (delta === null && eBad) {
466
+ const moved = locateAnchor(aEnd!, e);
467
+ if (moved !== null) delta = moved - e;
468
+ }
469
+ if (delta !== null && delta !== 0) {
470
+ const ns = s + delta;
471
+ const ne = e + delta;
472
+ const okStart = !aStart || (ns >= 1 && ns <= lines.length && lineAnchor(lines[ns - 1] ?? "") === aStart);
473
+ const okEnd = !aEnd || (ne >= 1 && ne <= lines.length && lineAnchor(lines[ne - 1] ?? "") === aEnd);
474
+ if (okStart && okEnd && ns >= 1 && ne >= ns && ne <= lines.length) {
475
+ s = ns;
476
+ e = ne;
477
+ }
478
+ }
479
+ }
480
+ if (s < 1 || e < s || e > lines.length) {
163
481
  return {
164
482
  success: false,
165
483
  output: "",
166
484
  error: `Invalid edit range ${startLine}..${endLine}: out of bounds or reversed (file has ${lines.length} lines)`,
167
485
  };
168
486
  }
169
- lines.splice(startLine - 1, endLine - startLine + 1, payload);
487
+ const mismatch = anchorMismatch(s, aStart) ?? anchorMismatch(e, aEnd);
488
+ if (mismatch) return mismatch;
489
+ lines.splice(s - 1, e - s + 1, payload);
170
490
  content = lines.join("\n");
171
491
  updated = true;
172
492
  }
173
493
  }
174
494
 
175
495
  if (!updated) {
176
- // Direct substring replacement fallback
177
- const searchMatch = editBlock.split("<<<<<<< SEARCH");
178
- if (searchMatch.length > 1) {
179
- const parts = searchMatch[1].split("=======");
180
- if (parts.length > 1) {
181
- let searchVal = parts[0];
182
- if (searchVal.startsWith("\r\n")) {
183
- searchVal = searchVal.slice(2);
184
- } else if (searchVal.startsWith("\n")) {
185
- searchVal = searchVal.slice(1);
496
+ // SEARCH/REPLACE hunks one or MORE blocks applied in order to a working copy.
497
+ // Atomic: if ANY hunk fails to match, nothing is written to disk.
498
+ const hunks = parseEditHunks(editBlock);
499
+ if (hunks) {
500
+ let working = content;
501
+ for (let [i, h] of hunks.entries()) {
502
+ if (h.search === "") {
503
+ return { success: false, output: "", error: `Failed to apply edit: hunk ${i + 1} has an empty SEARCH block.` };
186
504
  }
187
- if (searchVal.endsWith("\r\n")) {
188
- searchVal = searchVal.slice(0, -2);
189
- } else if (searchVal.endsWith("\n")) {
190
- searchVal = searchVal.slice(0, -1);
505
+ // Anchor-strip fixup (B2 dual-protocol trap): a SEARCH block copied from
506
+ // read output carries `42ab|` display prefixes that are not file bytes.
507
+ // When the raw block misses but a fully-prefixed variant strips clean,
508
+ // apply the stripped hunk (replace side stripped by the same rule).
509
+ if (!working.includes(h.search)) {
510
+ const strippedSearch = stripAnchorPrefixes(h.search);
511
+ if (strippedSearch !== null && working.includes(strippedSearch)) {
512
+ h = { search: strippedSearch, replace: stripAnchorPrefixes(h.replace) ?? h.replace };
513
+ }
191
514
  }
192
-
193
- if (searchVal === "") {
515
+ if (working.includes(h.search)) {
516
+ // Function replacer: bypasses String.replace's `$`-pattern substitution
517
+ // ($&, $`, $', $$) so a replacement containing literal `$` (Makefiles,
518
+ // shell `$'…'`, regex literals) is inserted verbatim, not corrupted.
519
+ working = working.replace(h.search, () => h.replace);
520
+ } else {
521
+ // Near-miss diagnostics so the model can self-correct instead of blindly retrying.
522
+ const firstLine = h.search.split("\n")[0] ?? "";
523
+ const trimmedHit = working.replace(/[ \t]+$/gm, "").includes(h.search.trim())
524
+ ? " A whitespace-trimmed version DOES match — fix leading/trailing spaces or indentation."
525
+ : "";
526
+ const anchorHit = !trimmedHit && firstLine.trim() && working.includes(firstLine)
527
+ ? " The first search line IS present, so the mismatch is below it."
528
+ : "";
529
+ const which = hunks.length > 1 ? ` (hunk ${i + 1}/${hunks.length})` : "";
530
+ // gjc-style recovery (plan/gjc-inheritance.md B3.5): re-present the CURRENT
531
+ // content around the best anchor so the failed edit costs ONE retry, not a
532
+ // separate read round-trip — failed edits are the #1 step-budget waste.
533
+ const anchorIdx = firstLine.trim() ? working.split("\n").findIndex(l => l.includes(firstLine.trim())) : -1;
534
+ const excerpt = `\nCurrent content near the target:\n${excerptForRecovery(working, anchorIdx >= 0 ? anchorIdx + 1 : undefined)}`;
194
535
  return {
195
536
  success: false,
196
537
  output: "",
197
- error: "Failed to apply edit: Search block is empty.",
538
+ error: `Failed to apply edit: Search block not found in file${which}.${trimmedHit}${anchorHit} Re-target against the content below and retry.${excerpt}`,
198
539
  };
199
540
  }
200
-
201
- const replaceParts = parts[1].split(">>>>>>>");
202
- if (replaceParts.length > 0) {
203
- let replaceVal = replaceParts[0];
204
- if (replaceVal.startsWith("\r\n")) {
205
- replaceVal = replaceVal.slice(2);
206
- } else if (replaceVal.startsWith("\n")) {
207
- replaceVal = replaceVal.slice(1);
208
- }
209
- if (replaceVal.endsWith("\r\n")) {
210
- replaceVal = replaceVal.slice(0, -2);
211
- } else if (replaceVal.endsWith("\n")) {
212
- replaceVal = replaceVal.slice(0, -1);
213
- }
214
-
215
- if (content.includes(searchVal)) {
216
- content = content.replace(searchVal, replaceVal);
217
- updated = true;
218
- } else {
219
- return {
220
- success: false,
221
- output: "",
222
- error: "Failed to apply edit: Search block not found in file.",
223
- };
224
- }
225
- }
226
541
  }
542
+ content = working;
543
+ updated = true;
227
544
  }
228
545
  }
229
546
 
230
547
  if (!updated) {
548
+ // A SEARCH marker present but unparsed means the divider/terminator is missing —
549
+ // point the model at the marker rather than at the unrelated ≔ syntax.
550
+ if (editBlock.includes("<<<<<<< SEARCH")) {
551
+ return {
552
+ success: false,
553
+ output: "",
554
+ error: "Failed to apply edit: unterminated SEARCH block — each hunk needs '<<<<<<< SEARCH', a '=======' divider, and a '>>>>>>>' terminator.",
555
+ };
556
+ }
231
557
  return {
232
558
  success: false,
233
559
  output: "",
@@ -236,6 +562,7 @@ export async function editTool(
236
562
  }
237
563
 
238
564
  await fs.writeFile(absPath, content, "utf-8");
565
+ recordReadSnapshot(absPath, await fs.stat(absPath).catch(() => null)); // own change ≠ stale
239
566
  return { success: true, output: `Successfully updated ${filePath}` };
240
567
  } catch (err: any) {
241
568
  return { success: false, output: "", error: err.message };
@@ -245,15 +572,30 @@ export async function editTool(
245
572
  export async function bashTool(
246
573
  command: string,
247
574
  cwd: string = process.cwd(),
248
- timeoutMs: number = 120_000
575
+ timeoutMs: number = 120_000,
576
+ subdir?: string,
577
+ env?: Record<string, string>
249
578
  ): Promise<ToolResult> {
579
+ if (jeoEnv("BASH_FIXUPS") === "1") {
580
+ const fx = applyBashFixups(command);
581
+ command = fx.command;
582
+ }
250
583
  try {
584
+ // The mutation lock is keyed on the PROJECT cwd, not the run subdir.
251
585
  await assertBashAllowed(cwd);
586
+ const runCwd = subdir ? path.resolve(cwd, subdir) : cwd;
587
+ // Sanitize caller env: keep only string values (a model may send numbers/arrays),
588
+ // so a bad value can't make Bun.spawn throw cryptically.
589
+ const safeEnv = env && !Array.isArray(env)
590
+ ? Object.fromEntries(Object.entries(env).filter(([, v]) => typeof v === "string")) as Record<string, string>
591
+ : undefined;
252
592
  // Run the command using Bun's native spawn
253
593
  const proc = Bun.spawn(["bash", "-c", command], {
254
- cwd,
594
+ cwd: runCwd,
255
595
  stdout: "pipe",
256
596
  stderr: "pipe",
597
+ // Inherit the parent env; merge caller-supplied (sanitized) vars on top.
598
+ ...(safeEnv && Object.keys(safeEnv).length ? { env: { ...process.env, ...safeEnv } } : {}),
257
599
  });
258
600
 
259
601
  let timedOut = false;
@@ -297,22 +639,85 @@ export async function bashTool(
297
639
  }
298
640
  }
299
641
 
642
+ /** Spawn a command, capture output, and escalate SIGTERM→SIGKILL if it exceeds
643
+ * timeoutMs — so a runaway grep/find over a huge tree can't block the whole turn. */
644
+ async function spawnTextWithTimeout(
645
+ cmd: string[],
646
+ cwd: string,
647
+ timeoutMs = 60_000,
648
+ ): Promise<{ stdout: string; stderr: string; exitCode: number | null; timedOut: boolean }> {
649
+ const proc = Bun.spawn(cmd, { cwd, stdout: "pipe", stderr: "pipe" });
650
+ let timedOut = false;
651
+ let killTimer: ReturnType<typeof setTimeout> | undefined;
652
+ const timer = setTimeout(() => {
653
+ timedOut = true;
654
+ try { proc.kill(); } catch {}
655
+ killTimer = setTimeout(() => { try { proc.kill(9); } catch {} }, 3_000);
656
+ }, timeoutMs);
657
+ try {
658
+ await proc.exited;
659
+ } finally {
660
+ clearTimeout(timer);
661
+ if (killTimer) clearTimeout(killTimer);
662
+ }
663
+ const stdout = await new Response(proc.stdout).text();
664
+ const stderr = await new Response(proc.stderr).text();
665
+ return { stdout, stderr, exitCode: proc.exitCode, timedOut };
666
+ }
667
+
300
668
  export async function findTool(
301
669
  globPattern: string,
302
670
  cwd: string = process.cwd()
303
671
  ): Promise<ToolResult> {
672
+ // Guard loose model input: a missing/empty pattern (model called find with no
673
+ // globPattern) must be a soft tool error, not an uncaught `globPattern.includes`
674
+ // crash that aborts the whole turn.
675
+ if (typeof globPattern !== "string" || globPattern.trim() === "") {
676
+ return { success: false, output: "", error: 'find requires a non-empty "globPattern", e.g. "src/**/*.ts" or "*.ts".' };
677
+ }
678
+ // Bare-name patterns (no path separator, no `**`) → recursive basename match via
679
+ // `find -name`, preserving the "find files by name" contract and the expectation that
680
+ // `*.ts` matches at any depth. Patterns with a `/` or `**` are real PATH globs
681
+ // (`src/**/*.ts`, `src/agent/*.ts`, an exact relative path) which `find -name` can NEVER
682
+ // match (it only sees basenames) — route those through Bun.Glob for correct semantics.
683
+ if (globPattern.includes("/") || globPattern.includes("**")) {
684
+ try {
685
+ const gi = await readGitignore(cwd);
686
+ const prunedDirs = new Set([...IGNORED_DIRS, ...gi.dirs]);
687
+ const fileGlobs = gi.fileGlobs.map(g => new Bun.Glob(g));
688
+ const matches: string[] = [];
689
+ for await (const rel of new Bun.Glob(globPattern).scan({ cwd, onlyFiles: true })) {
690
+ const segs = rel.split("/");
691
+ if (segs.some(seg => prunedDirs.has(seg))) continue;
692
+ const base = segs[segs.length - 1] ?? rel;
693
+ if (fileGlobs.some(g => g.match(base))) continue;
694
+ matches.push(`./${rel}`);
695
+ if (matches.length >= 5000) break;
696
+ }
697
+ matches.sort();
698
+ let output = matches.length ? matches.join("\n") : "No matching files found.";
699
+ const MAX_OUTPUT = 100_000;
700
+ if (output.length > MAX_OUTPUT) {
701
+ output = output.slice(0, MAX_OUTPUT) + "\n…(output truncated at 100000 chars)";
702
+ }
703
+ return { success: true, output };
704
+ } catch (err: any) {
705
+ return { success: false, output: "", error: err.message };
706
+ }
707
+ }
304
708
  try {
709
+ const gi = await readGitignore(cwd);
710
+ const pruneNames = [...IGNORED_DIRS, ...gi.dirs];
305
711
  const pruneGroup: string[] = [];
306
- for (let i = 0; i < IGNORED_DIRS.length; i++) {
712
+ for (let i = 0; i < pruneNames.length; i++) {
307
713
  if (i > 0) pruneGroup.push("-o");
308
- pruneGroup.push("-name", IGNORED_DIRS[i]);
714
+ pruneGroup.push("-name", pruneNames[i]!);
309
715
  }
310
- const proc = Bun.spawn(
716
+ const { stdout, timedOut } = await spawnTextWithTimeout(
311
717
  ["find", ".", "-type", "d", "(", ...pruneGroup, ")", "-prune", "-o", "-name", globPattern, "-print"],
312
- { cwd, stdout: "pipe", stderr: "pipe" },
718
+ cwd,
313
719
  );
314
- await proc.exited;
315
- const stdout = await new Response(proc.stdout).text();
720
+ if (timedOut) return { success: false, output: "", error: "find timed out (60s) — narrow the pattern." };
316
721
  const files = stdout.split("\n").filter(Boolean);
317
722
  let output = files.length > 0 ? files.join("\n") : "No matching files found.";
318
723
  const MAX_OUTPUT = 100_000;
@@ -325,23 +730,51 @@ export async function findTool(
325
730
  }
326
731
  }
327
732
 
733
+ export interface SearchOptions {
734
+ /** Lines of context before/after each match (grep -B/-A). */
735
+ before?: number;
736
+ after?: number;
737
+ /** Symmetric context (grep -C); overrides before/after when set. */
738
+ context?: number;
739
+ /** Stop after this many matches per file (grep -m). */
740
+ maxMatches?: number;
741
+ }
742
+
328
743
  export async function searchTool(
329
744
  pattern: string,
330
745
  globPattern: string = "*",
331
- cwd: string = process.cwd()
746
+ cwd: string = process.cwd(),
747
+ ignoreCase: boolean = false,
748
+ opts: SearchOptions = {},
332
749
  ): Promise<ToolResult> {
750
+ if (typeof pattern !== "string" || pattern === "") {
751
+ return { success: false, output: "", error: 'search requires a non-empty "pattern" (a regex/string to grep for).' };
752
+ }
333
753
  try {
334
- const excludes = IGNORED_DIRS.map(d => `--exclude-dir=${d}`);
335
- const proc = Bun.spawn(
336
- ["grep", "-rnI", "--include", globPattern, ...excludes, "--", pattern, "."],
337
- { cwd, stdout: "pipe", stderr: "pipe" },
754
+ const flags = ignoreCase ? "-rnIi" : "-rnI";
755
+ const gi = await readGitignore(cwd);
756
+ const excludes = [
757
+ ...[...IGNORED_DIRS, ...gi.dirs].map(d => `--exclude-dir=${d}`),
758
+ ...gi.fileGlobs.map(f => `--exclude=${f}`),
759
+ ];
760
+ const n = (v: unknown): number | undefined =>
761
+ typeof v === "number" && Number.isFinite(v) && v >= 0 ? Math.floor(v) : undefined;
762
+ const ctx: string[] = [];
763
+ const C = n(opts.context), B = n(opts.before), A = n(opts.after), M = n(opts.maxMatches);
764
+ if (C !== undefined) ctx.push("-C", String(C));
765
+ else {
766
+ if (B !== undefined) ctx.push("-B", String(B));
767
+ if (A !== undefined) ctx.push("-A", String(A));
768
+ }
769
+ if (M !== undefined) ctx.push("-m", String(M));
770
+ const { stdout, stderr, exitCode, timedOut } = await spawnTextWithTimeout(
771
+ ["grep", flags, ...ctx, "--include", globPattern, ...excludes, "--", pattern, "."],
772
+ cwd,
338
773
  );
339
- await proc.exited;
340
- const stdout = await new Response(proc.stdout).text();
341
- const stderr = await new Response(proc.stderr).text();
774
+ if (timedOut) return { success: false, output: "", error: "search timed out (60s) — narrow the pattern or glob." };
342
775
  // grep exit codes: 0 = match, 1 = no match (not an error), >=2 = a real error.
343
- if (proc.exitCode !== null && proc.exitCode >= 2) {
344
- return { success: false, output: stdout, error: stderr.trim() || `grep failed (exit ${proc.exitCode})` };
776
+ if (exitCode !== null && exitCode >= 2) {
777
+ return { success: false, output: stdout, error: stderr.trim() || `grep failed (exit ${exitCode})` };
345
778
  }
346
779
  let output = stdout || "No matches found.";
347
780
  const MAX_OUTPUT = 100_000;
@@ -353,3 +786,91 @@ export async function searchTool(
353
786
  return { success: false, output: "", error: err.message };
354
787
  }
355
788
  }
789
+ /**
790
+ * List a single directory's entries (read-only): directories first (with a
791
+ * trailing `/`), then files, alphabetically. Hidden entries are included. This
792
+ * gives the model gjc-style directory inspection without shelling out to `ls`.
793
+ */
794
+ export async function lsTool(
795
+ dirPath: string = ".",
796
+ cwd: string = process.cwd()
797
+ ): Promise<ToolResult> {
798
+ try {
799
+ const abs = path.resolve(cwd, dirPath);
800
+ const stat = await fs.stat(abs);
801
+ if (!stat.isDirectory()) {
802
+ return { success: false, output: "", error: `Not a directory: ${dirPath} (use read for files).` };
803
+ }
804
+ const entries = await fs.readdir(abs, { withFileTypes: true });
805
+ if (entries.length === 0) return { success: true, output: "(empty directory)" };
806
+ const sorted = entries.sort((a, b) =>
807
+ a.isDirectory() !== b.isDirectory() ? (a.isDirectory() ? -1 : 1) : a.name.localeCompare(b.name),
808
+ );
809
+ const out = sorted.map(e => (e.isDirectory() ? `${e.name}/` : e.name)).join("\n");
810
+ return { success: true, output: out };
811
+ } catch (err: any) {
812
+ return { success: false, output: "", error: err.message };
813
+ }
814
+ }
815
+
816
+ /**
817
+ * Create a directory (and any missing parents). Idempotent: an already-existing
818
+ * directory is a success, not an error — the model should not need to branch on
819
+ * existence. Respects the deep-interview mutation lock like write/edit.
820
+ */
821
+ export async function mkdirTool(
822
+ dirPath: string,
823
+ cwd: string = process.cwd()
824
+ ): Promise<ToolResult> {
825
+ try {
826
+ if (typeof dirPath !== "string" || dirPath.trim() === "") {
827
+ return { success: false, output: "", error: 'mkdir requires a non-empty "dirPath".' };
828
+ }
829
+ await assertMutationAllowed(dirPath, cwd);
830
+ const abs = path.resolve(cwd, dirPath);
831
+ const existing = await fs.stat(abs).catch(() => null);
832
+ if (existing && !existing.isDirectory()) {
833
+ return { success: false, output: "", error: `Path exists and is not a directory: ${dirPath}` };
834
+ }
835
+ await fs.mkdir(abs, { recursive: true });
836
+ return { success: true, output: `Directory ready: ${dirPath}` };
837
+ } catch (err: any) {
838
+ return { success: false, output: "", error: err.message };
839
+ }
840
+ }
841
+
842
+ /**
843
+ * Delete a file or directory. A directory requires `recursive: true` so a stray
844
+ * call cannot wipe a populated tree by accident. Missing paths are a soft error
845
+ * (nothing to delete) rather than a crash. Respects the mutation lock like
846
+ * write/edit; the file-freshness snapshot is cleared so a later write to the
847
+ * same path is not rejected as stale.
848
+ */
849
+ export async function deleteTool(
850
+ targetPath: string,
851
+ cwd: string = process.cwd(),
852
+ recursive: boolean = false
853
+ ): Promise<ToolResult> {
854
+ try {
855
+ if (typeof targetPath !== "string" || targetPath.trim() === "") {
856
+ return { success: false, output: "", error: 'delete requires a non-empty "path".' };
857
+ }
858
+ await assertMutationAllowed(targetPath, cwd);
859
+ const abs = path.resolve(cwd, targetPath);
860
+ if (abs === path.resolve(cwd)) {
861
+ return { success: false, output: "", error: "Refusing to delete the working directory itself." };
862
+ }
863
+ const st = await fs.stat(abs).catch(() => null);
864
+ if (!st) {
865
+ return { success: false, output: "", error: `Nothing to delete: ${targetPath} (does not exist).` };
866
+ }
867
+ if (st.isDirectory() && !recursive) {
868
+ return { success: false, output: "", error: `${targetPath} is a directory — pass recursive:true to remove it and its contents.` };
869
+ }
870
+ await fs.rm(abs, { recursive, force: false });
871
+ lastReadSnapshots.delete(abs); // a future write to this path must not be flagged stale
872
+ return { success: true, output: `Deleted ${st.isDirectory() ? "directory" : "file"}: ${targetPath}` };
873
+ } catch (err: any) {
874
+ return { success: false, output: "", error: err.message };
875
+ }
876
+ }