klyro 0.1.53 → 0.1.55

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 (50) hide show
  1. package/dist/agent/anthropic-adapter.js +22 -2
  2. package/dist/agent/registry.js +11 -1
  3. package/dist/agent/retry.d.ts +5 -0
  4. package/dist/agent/retry.js +28 -1
  5. package/dist/agent/runtime.d.ts +2 -0
  6. package/dist/agent/runtime.js +48 -35
  7. package/dist/chat.js +2 -2
  8. package/dist/checkpoints/store.js +86 -7
  9. package/dist/cli/config.js +13 -1
  10. package/dist/cli/repl.js +200 -36
  11. package/dist/cli/run.js +88 -69
  12. package/dist/context/klyro-md.d.ts +5 -0
  13. package/dist/context/klyro-md.js +28 -6
  14. package/dist/context/tokenizer.js +8 -1
  15. package/dist/index.js +49 -14
  16. package/dist/persistence/session.d.ts +2 -0
  17. package/dist/persistence/session.js +11 -5
  18. package/dist/persistence/store.d.ts +7 -0
  19. package/dist/persistence/store.js +77 -28
  20. package/dist/policy/engine.d.ts +10 -1
  21. package/dist/policy/engine.js +33 -2
  22. package/dist/tools/fs/apply-patch.d.ts +16 -1
  23. package/dist/tools/fs/apply-patch.js +163 -75
  24. package/dist/tools/fs/edit-file.d.ts +13 -0
  25. package/dist/tools/fs/edit-file.js +96 -54
  26. package/dist/tools/fs/list-dir.d.ts +2 -0
  27. package/dist/tools/fs/list-dir.js +7 -5
  28. package/dist/tools/fs/multi-edit.js +12 -1
  29. package/dist/tools/fs/read-file.js +1 -1
  30. package/dist/tools/fs/read-history.d.ts +2 -2
  31. package/dist/tools/fs/read-history.js +39 -7
  32. package/dist/tools/fs/write-file.js +1 -1
  33. package/dist/tools/normalize.js +11 -1
  34. package/dist/tools/shell/background.d.ts +5 -0
  35. package/dist/tools/shell/background.js +29 -0
  36. package/dist/tools/shell/shell-exec.d.ts +9 -0
  37. package/dist/tools/shell/shell-exec.js +54 -8
  38. package/dist/tools/verify/run-verify.js +2 -2
  39. package/dist/tui/app.js +10 -5
  40. package/dist/tui/app.test.js +42 -0
  41. package/dist/tui/approval.d.ts +1 -1
  42. package/dist/tui/approval.js +20 -5
  43. package/dist/tui/diff.d.ts +5 -19
  44. package/dist/tui/diff.js +9 -25
  45. package/dist/tui/status.d.ts +4 -8
  46. package/dist/tui/status.js +8 -17
  47. package/dist/tui/transcript.d.ts +13 -20
  48. package/dist/tui/transcript.js +9 -74
  49. package/dist/verification/engine.js +4 -4
  50. package/package.json +1 -1
@@ -1,35 +1,53 @@
1
1
  /**
2
2
  * 4.4 — KLYRO.md loader: ~/.klyro/KLYRO.md → root → KLYRO.local.md → subdir files lazily
3
+ *
4
+ * Safety: @import targets are contained to the project cwd (escapes are
5
+ * skipped, not read — a malicious KLYRO.md must not pull /etc/passwd or
6
+ * secret files into context), each file is capped, and total output is
7
+ * capped so a giant monorepo doc can't blow the context budget.
3
8
  */
4
9
  import * as fs from 'node:fs/promises';
5
10
  import * as path from 'node:path';
6
11
  import * as os from 'node:os';
7
- const CACHE = new Map();
12
+ const MAX_FILE_CHARS = 4000;
13
+ const MAX_TOTAL_CHARS = 8000;
14
+ function cap(s, n) {
15
+ return s.length > n ? s.slice(0, n) + `\n... [truncated ${s.length - n} chars]` : s;
16
+ }
8
17
  export async function loadKlyroMd(cwd) {
9
18
  const parts = [];
19
+ let total = 0;
20
+ const push = (s) => {
21
+ if (total >= MAX_TOTAL_CHARS)
22
+ return;
23
+ const room = MAX_TOTAL_CHARS - total;
24
+ const chunk = s.length > room ? s.slice(0, room) + '\n... [truncated]' : s;
25
+ parts.push(chunk);
26
+ total += chunk.length;
27
+ };
10
28
  // Global
11
29
  const home = os.homedir();
12
30
  if (home) {
13
31
  for (const p of [path.join(home, '.klyro', 'KLYRO.md'), path.join(home, '.klyro', 'KLYRO.local.md')]) {
14
32
  try {
15
33
  const t = await fs.readFile(p, 'utf-8');
16
- parts.push(`# ${p}\n${t}`);
34
+ push(`# ${p}\n${cap(t, MAX_FILE_CHARS)}`);
17
35
  }
18
36
  catch { /* ignore */ }
19
37
  }
20
38
  }
21
- // Root
39
+ // Root (imports resolved relative to each file, contained to cwd)
22
40
  for (const name of ['KLYRO.md', 'KLYRO.local.md', 'AGENTS.md', 'CLAUDE.md', '.cursorrules']) {
23
41
  const p = path.join(cwd, name);
24
42
  try {
25
43
  const t = await fs.readFile(p, 'utf-8');
26
- parts.push(`# ${p}\n${await resolveImports(t, path.dirname(p))}`);
44
+ push(`# ${p}\n${cap(await resolveImports(t, path.dirname(p), cwd), MAX_FILE_CHARS)}`);
27
45
  }
28
46
  catch { /* ignore */ }
29
47
  }
30
48
  return parts.join('\n\n---\n\n');
31
49
  }
32
- async function resolveImports(text, base, depth = 0) {
50
+ async function resolveImports(text, base, root, depth = 0) {
33
51
  if (depth > 5)
34
52
  return text;
35
53
  const importRe = /^@import\s+(.+)$/gm;
@@ -38,9 +56,13 @@ async function resolveImports(text, base, depth = 0) {
38
56
  while ((m = importRe.exec(text))) {
39
57
  const rel = m[1].trim().replace(/^["']|["']$/g, '');
40
58
  const p = path.resolve(base, rel);
59
+ // Containment: never follow imports outside the project root.
60
+ const relToRoot = path.relative(root, p);
61
+ if (relToRoot.startsWith('..') || path.isAbsolute(relToRoot))
62
+ continue;
41
63
  try {
42
64
  const t = await fs.readFile(p, 'utf-8');
43
- const resolved = await resolveImports(t, path.dirname(p), depth + 1);
65
+ const resolved = await resolveImports(cap(t, MAX_FILE_CHARS), path.dirname(p), root, depth + 1);
44
66
  out = out.replace(m[0], resolved);
45
67
  }
46
68
  catch { /* ignore missing */ }
@@ -93,10 +93,17 @@ export function compressTranscript(system, messages, budget) {
93
93
  }
94
94
  }
95
95
  // Phase 3: hard cap — drop oldest messages until we fit, but always
96
- // preserve the first message (the user's task).
96
+ // preserve the first message (the user's task). Drops keep tool_use /
97
+ // tool_result PAIRS intact: removing an assistant turn also removes the
98
+ // tool messages that answer it, and an orphaned tool message (its turn
99
+ // already gone) is dropped on its own. Split pairs make providers 400.
97
100
  while (totalTokens(system, result) > budget.total && result.length > 2) {
98
101
  result.splice(1, 1);
99
102
  dropped++;
103
+ while (result[1] && result[1].role === 'tool') {
104
+ result.splice(1, 1);
105
+ dropped++;
106
+ }
100
107
  }
101
108
  return { system, messages: result, dropped };
102
109
  }
package/dist/index.js CHANGED
@@ -435,11 +435,17 @@ async function main() {
435
435
  .description('Show session transcript and observations')
436
436
  .option('--json', 'Output JSON')
437
437
  .action(async (id, opts) => {
438
- const { getDefaultSessionStore, resolveSessionId } = await import('./persistence/session.js');
438
+ const { getDefaultSessionStore, resolveSessionId, matchSessionIds } = await import('./persistence/session.js');
439
439
  const store = getDefaultSessionStore();
440
440
  const full = await resolveSessionId(store, id);
441
441
  if (!full) {
442
- process.stderr.write(`session not found: ${id}\n`);
442
+ const matches = await matchSessionIds(store, id);
443
+ if (matches.length > 1) {
444
+ process.stderr.write(`ambiguous id "${id}" matches:\n${matches.map((r) => ` ${r.id.slice(0, 8)} ${r.task.slice(0, 50)}`).join('\n')}\n`);
445
+ }
446
+ else {
447
+ process.stderr.write(`session not found: ${id}\n`);
448
+ }
443
449
  process.exit(2);
444
450
  }
445
451
  const rec = await store.get(full);
@@ -466,11 +472,17 @@ async function main() {
466
472
  .option('--verify-command <cmd>', 'Override verification command')
467
473
  .option('--verify', 'Enable verification (default: enabled)')
468
474
  .action(async (id, opts) => {
469
- const { getDefaultSessionStore, resolveSessionId } = await import('./persistence/session.js');
475
+ const { getDefaultSessionStore, resolveSessionId, matchSessionIds } = await import('./persistence/session.js');
470
476
  const store = getDefaultSessionStore();
471
477
  const full = await resolveSessionId(store, id);
472
478
  if (!full) {
473
- process.stderr.write(`session not found: ${id}\n`);
479
+ const matches = await matchSessionIds(store, id);
480
+ if (matches.length > 1) {
481
+ process.stderr.write(`ambiguous id "${id}" matches:\n${matches.map((r) => ` ${r.id.slice(0, 8)} ${r.task.slice(0, 50)}`).join('\n')}\n`);
482
+ }
483
+ else {
484
+ process.stderr.write(`session not found: ${id}\n`);
485
+ }
474
486
  process.exit(2);
475
487
  }
476
488
  const rec = await store.get(full);
@@ -501,11 +513,17 @@ async function main() {
501
513
  .option('-m, --model <id>', 'Model')
502
514
  .option('--max-steps <n>', 'Max steps', (v) => parsePositiveInt('--max-steps', v))
503
515
  .action(async (id, opts) => {
504
- const { getDefaultSessionStore, resolveSessionId } = await import('./persistence/session.js');
516
+ const { getDefaultSessionStore, resolveSessionId, matchSessionIds } = await import('./persistence/session.js');
505
517
  const store = getDefaultSessionStore();
506
518
  const full = await resolveSessionId(store, id);
507
519
  if (!full) {
508
- process.stderr.write(`session not found: ${id}\n`);
520
+ const matches = await matchSessionIds(store, id);
521
+ if (matches.length > 1) {
522
+ process.stderr.write(`ambiguous id "${id}" matches:\n${matches.map((r) => ` ${r.id.slice(0, 8)} ${r.task.slice(0, 50)}`).join('\n')}\n`);
523
+ }
524
+ else {
525
+ process.stderr.write(`session not found: ${id}\n`);
526
+ }
509
527
  process.exit(2);
510
528
  }
511
529
  const rec = await store.get(full);
@@ -555,21 +573,38 @@ async function main() {
555
573
  const rec = await store.create({ cwd: data.record?.cwd ?? process.cwd(), task: data.record?.task ?? 'imported', config: data.record?.config ?? { model: 'imported', maxSteps: 30 } });
556
574
  process.stdout.write(`imported → ${rec.id}\n`);
557
575
  });
558
- sessions.command('fork <id>').description('Fork session (9.4)').action(async (id) => {
559
- const { getDefaultSessionStore, resolveSessionId } = await import('./persistence/session.js');
576
+ sessions.command('fork <id>').description('Fork session with full context (9.4)').action(async (id) => {
577
+ const { getDefaultSessionStore, matchSessionIds } = await import('./persistence/session.js');
560
578
  const store = getDefaultSessionStore();
561
- const full = await resolveSessionId(store, id);
562
- if (!full) {
579
+ const matches = await matchSessionIds(store, id);
580
+ if (matches.length === 0) {
563
581
  process.stderr.write(`session not found: ${id}\n`);
564
582
  process.exit(2);
565
583
  }
566
- const rec = await store.get(full);
567
- if (!rec) {
584
+ if (matches.length > 1) {
585
+ process.stderr.write(`ambiguous id "${id}" matches:\n${matches.map((r) => ` ${r.id.slice(0, 8)} ${r.task.slice(0, 50)}`).join('\n')}\n`);
586
+ process.exit(2);
587
+ }
588
+ const full = matches[0].id;
589
+ const forked = await store.fork(full);
590
+ const msgs = await store.loadMessages(forked.id);
591
+ process.stdout.write(`forked ${full.slice(0, 8)} → ${forked.id.slice(0, 8)} (${msgs.length} messages carried over)\n`);
592
+ });
593
+ sessions.command('delete <id>').description('Delete a session and its artifacts').action(async (id) => {
594
+ const { getDefaultSessionStore, matchSessionIds } = await import('./persistence/session.js');
595
+ const store = getDefaultSessionStore();
596
+ const matches = await matchSessionIds(store, id);
597
+ if (matches.length === 0) {
568
598
  process.stderr.write(`session not found: ${id}\n`);
569
599
  process.exit(2);
570
600
  }
571
- const forked = await store.create({ cwd: rec.cwd, task: rec.task + ' (fork)', config: rec.config });
572
- process.stdout.write(`forked ${full.slice(0, 8)} → ${forked.id.slice(0, 8)}\n`);
601
+ if (matches.length > 1) {
602
+ process.stderr.write(`ambiguous id "${id}" matches:\n${matches.map((r) => ` ${r.id.slice(0, 8)} ${r.task.slice(0, 50)}`).join('\n')}\n`);
603
+ process.exit(2);
604
+ }
605
+ const full = matches[0].id;
606
+ await store.delete(full);
607
+ process.stdout.write(`deleted ${full.slice(0, 8)}\n`);
573
608
  });
574
609
  // 10.1 — MCP
575
610
  const mcp = program.command('mcp').description('MCP client/server (10.1)');
@@ -10,3 +10,5 @@ export declare function createSessionStore(dir: string): SessionStore;
10
10
  export declare function formatSession(rec: SessionRecord): string;
11
11
  /** Resolve a short id prefix to full id (like git). */
12
12
  export declare function resolveSessionId(store: SessionStore, prefix: string): Promise<string | null>;
13
+ /** All sessions matching an id prefix (lets callers distinguish missing vs ambiguous). */
14
+ export declare function matchSessionIds(store: SessionStore, prefix: string): Promise<SessionRecord[]>;
@@ -34,12 +34,18 @@ export async function resolveSessionId(store, prefix) {
34
34
  const rec = await store.get(prefix);
35
35
  return rec ? rec.id : null;
36
36
  }
37
- const all = await store.list();
38
- const matches = all.filter((r) => r.id.startsWith(prefix));
37
+ const matches = await matchSessionIds(store, prefix);
39
38
  if (matches.length === 1)
40
39
  return matches[0].id;
41
- if (matches.length === 0)
42
- return null;
43
- // ambiguous — return null and let caller report
40
+ // zero or ambiguous — null; callers use matchSessionIds for a good message
44
41
  return null;
45
42
  }
43
+ /** All sessions matching an id prefix (lets callers distinguish missing vs ambiguous). */
44
+ export async function matchSessionIds(store, prefix) {
45
+ if (prefix.length >= 32) {
46
+ const rec = await store.get(prefix);
47
+ return rec ? [rec] : [];
48
+ }
49
+ const all = await store.list();
50
+ return all.filter((r) => r.id.startsWith(prefix));
51
+ }
@@ -56,6 +56,13 @@ export declare class SessionStore {
56
56
  task: string;
57
57
  config: SessionConfig;
58
58
  }): Promise<SessionRecord>;
59
+ /**
60
+ * Fork a session: new id + copied messages/observations so the fork
61
+ * continues with full context instead of starting blank.
62
+ */
63
+ fork(id: string, taskSuffix?: string): Promise<SessionRecord>;
64
+ /** Delete a session and all its artifacts (record, transcript, jsonl, indexes). */
65
+ delete(id: string): Promise<boolean>;
59
66
  private jsonlPath;
60
67
  appendJsonl(id: string, entry: unknown): Promise<void>;
61
68
  readJsonl(id: string): Promise<unknown[]>;
@@ -81,37 +81,86 @@ export class SessionStore {
81
81
  return w.length > 40 ? w.slice(0, 40) + '…' : w || 'untitled';
82
82
  }
83
83
  async create(opts) {
84
- await this.ensureDir();
85
- const id = randomUUID();
86
- const now = Date.now();
87
- const record = {
88
- id,
89
- cwd: opts.cwd,
90
- task: opts.task,
91
- status: 'open',
92
- createdAt: now,
93
- updatedAt: now,
94
- config: opts.config,
95
- };
96
- record.title = this.titleFor(opts.task);
97
- await fs.writeFile(path.join(this.dir, `${id}.json`), JSON.stringify({ record, messages: [], observations: [] }, null, 2));
98
- await this.appendJsonl(id, { type: 'session.create', record, ts: now });
99
- const idx = await this.readIndex();
100
- idx[id] = record;
101
- await this.writeIndex(idx);
102
- // per-project index
103
- try {
104
- const pp = this.perProjectIndexPath(opts.cwd);
105
- let pIdx = {};
84
+ // Locked: the index read-modify-write below races under concurrent creates.
85
+ return this.withLock('index', async () => {
86
+ await this.ensureDir();
87
+ const id = randomUUID();
88
+ const now = Date.now();
89
+ const record = {
90
+ id,
91
+ cwd: opts.cwd,
92
+ task: opts.task,
93
+ status: 'open',
94
+ createdAt: now,
95
+ updatedAt: now,
96
+ config: opts.config,
97
+ };
98
+ record.title = this.titleFor(opts.task);
99
+ await fs.writeFile(path.join(this.dir, `${id}.json`), JSON.stringify({ record, messages: [], observations: [] }, null, 2));
100
+ await this.appendJsonl(id, { type: 'session.create', record, ts: now });
101
+ const idx = await this.readIndex();
102
+ idx[id] = record;
103
+ await this.writeIndex(idx);
104
+ // per-project index
106
105
  try {
107
- pIdx = JSON.parse(await fs.readFile(pp, 'utf-8'));
106
+ const pp = this.perProjectIndexPath(opts.cwd);
107
+ let pIdx = {};
108
+ try {
109
+ pIdx = JSON.parse(await fs.readFile(pp, 'utf-8'));
110
+ }
111
+ catch { }
112
+ pIdx[id] = record;
113
+ await fs.writeFile(pp, JSON.stringify(pIdx, null, 2), 'utf-8');
108
114
  }
109
115
  catch { }
110
- pIdx[id] = record;
111
- await fs.writeFile(pp, JSON.stringify(pIdx, null, 2), 'utf-8');
112
- }
113
- catch { }
114
- return record;
116
+ return record;
117
+ });
118
+ }
119
+ /**
120
+ * Fork a session: new id + copied messages/observations so the fork
121
+ * continues with full context instead of starting blank.
122
+ */
123
+ async fork(id, taskSuffix = ' (fork)') {
124
+ const data = await this.readSession(id);
125
+ const forked = await this.create({
126
+ cwd: data.record.cwd,
127
+ task: `${data.record.task}${taskSuffix}`,
128
+ config: data.record.config,
129
+ });
130
+ await this.withLock(forked.id, async () => {
131
+ const fresh = await this.readSession(forked.id);
132
+ fresh.messages = data.messages.map((m) => ({ ...m }));
133
+ fresh.observations = data.observations.map((o) => ({ ...o }));
134
+ await this.writeSession(forked.id, fresh);
135
+ });
136
+ await this.appendJsonl(forked.id, { type: 'session.fork', from: id, ts: Date.now() });
137
+ return forked;
138
+ }
139
+ /** Delete a session and all its artifacts (record, transcript, jsonl, indexes). */
140
+ async delete(id) {
141
+ return this.withLock('index', async () => {
142
+ const rec = await this.get(id);
143
+ if (!rec)
144
+ return false;
145
+ for (const f of [`${id}.json`, `${id}.jsonl`]) {
146
+ try {
147
+ await fs.unlink(path.join(this.dir, f));
148
+ }
149
+ catch { /* ignore */ }
150
+ }
151
+ const idx = await this.readIndex();
152
+ delete idx[id];
153
+ await this.writeIndex(idx);
154
+ try {
155
+ const pp = this.perProjectIndexPath(rec.cwd);
156
+ const raw = await fs.readFile(pp, 'utf-8');
157
+ const pIdx = JSON.parse(raw);
158
+ delete pIdx[id];
159
+ await fs.writeFile(pp, JSON.stringify(pIdx, null, 2), 'utf-8');
160
+ }
161
+ catch { /* ignore */ }
162
+ return true;
163
+ });
115
164
  }
116
165
  jsonlPath(id) { return path.join(this.dir, `${id}.jsonl`); }
117
166
  async appendJsonl(id, entry) {
@@ -76,8 +76,17 @@ export declare const shellDenyRule: PolicyRule;
76
76
  export declare const shellAllowRule: PolicyRule;
77
77
  /** Writes are auto-allowed if path resolves inside cwd or --add-dir (path-guard throws otherwise). */
78
78
  export declare const writeFileCwdRule: PolicyRule;
79
- /** Reads > 5 MiB require confirmation (avoid filling the context window). */
79
+ /**
80
+ * Reads > readSizeAskMiB require confirmation (avoid filling the context).
81
+ * Stats the file directly — the old sizeBytes input never existed in the
82
+ * read_file schema, so this rule never fired (dead code until now).
83
+ */
80
84
  export declare const readFileSizeRule: PolicyRule;
85
+ /**
86
+ * Deep-clone a PolicyConfig so per-session tweaks (/mode, /sandbox) never
87
+ * leak across engines via the shared DEFAULT_POLICY_CONFIG reference.
88
+ */
89
+ export declare function clonePolicyConfig(base?: PolicyConfig): PolicyConfig;
81
90
  /** Convenience: evaluate via safe() so a buggy rule doesn't crash the loop. */
82
91
  export declare function evaluatePolicy(engine: PolicyEngine, call: ToolCallLike, ctx: {
83
92
  cwd: string;
@@ -218,13 +218,27 @@ export const writeFileCwdRule = {
218
218
  return null;
219
219
  },
220
220
  };
221
- /** Reads > 5 MiB require confirmation (avoid filling the context window). */
221
+ /**
222
+ * Reads > readSizeAskMiB require confirmation (avoid filling the context).
223
+ * Stats the file directly — the old sizeBytes input never existed in the
224
+ * read_file schema, so this rule never fired (dead code until now).
225
+ */
222
226
  export const readFileSizeRule = {
223
227
  name: 'read-file-size',
224
228
  evaluate(call, ctx) {
225
229
  if (call.name !== 'read_file')
226
230
  return null;
227
- const size = typeof call.input.sizeBytes === 'number' ? call.input.sizeBytes : 0;
231
+ const p = asString(call.input.path);
232
+ if (!p)
233
+ return null;
234
+ let size = 0;
235
+ try {
236
+ const { resolved } = resolveWithinCwd(ctx.cwd, p);
237
+ size = fsSync.statSync(resolved).size;
238
+ }
239
+ catch {
240
+ return null; // unreadable → other rules/tools report it
241
+ }
228
242
  if (size > ctx.config.readSizeAskMiB * 1024 * 1024) {
229
243
  if (ctx.nonInteractive)
230
244
  return { action: 'deny', reason: `file > ${ctx.config.readSizeAskMiB} MiB` };
@@ -233,6 +247,21 @@ export const readFileSizeRule = {
233
247
  return null;
234
248
  },
235
249
  };
250
+ /**
251
+ * Deep-clone a PolicyConfig so per-session tweaks (/mode, /sandbox) never
252
+ * leak across engines via the shared DEFAULT_POLICY_CONFIG reference.
253
+ */
254
+ export function clonePolicyConfig(base = DEFAULT_POLICY_CONFIG) {
255
+ return {
256
+ ...base,
257
+ shellAllow: [...base.shellAllow],
258
+ shellDeny: [...base.shellDeny],
259
+ allow: [...(base.allow ?? [])],
260
+ deny: [...(base.deny ?? [])],
261
+ ask: [...(base.ask ?? [])],
262
+ additionalDirs: [...(base.additionalDirs ?? [])],
263
+ };
264
+ }
236
265
  /** Convenience: evaluate via safe() so a buggy rule doesn't crash the loop. */
237
266
  export async function evaluatePolicy(engine, call, ctx) {
238
267
  const r = await safe(() => engine.evaluate(call, ctx));
@@ -244,3 +273,5 @@ export async function evaluatePolicy(engine, call, ctx) {
244
273
  // (path-guard is already in policy/, and tools/ depend on it. This keeps
245
274
  // policy/ depending only on tools/normalize, no other tool code.)
246
275
  import * as path from 'node:path';
276
+ import * as fsSync from 'node:fs';
277
+ import { resolveWithinCwd } from './path-guard.js';
@@ -1,5 +1,20 @@
1
1
  /**
2
- * 4.2 — apply_patch: Codex-style unified patch, tolerant hunks
2
+ * 4.2 — apply_patch: Codex-style unified patch with REAL hunk application.
3
+ *
4
+ * Format:
5
+ * *** Begin Patch
6
+ * *** Update File: <path> (must contain @@ hunks for existing files)
7
+ * *** Add File: <path> (created from + lines)
8
+ * *** End Patch
9
+ *
10
+ * Hunks are standard unified diff: `@@ -a,b +c,d @@` headers, ' ' context,
11
+ * '-' removals, '+' additions. Context/removals must match the file (exact
12
+ * first, trailing-whitespace-insensitive fallback with ±3 line drift);
13
+ * otherwise INVALID_PATCH names the file+hunk instead of corrupting it.
14
+ *
15
+ * Legacy compat: a file section with NO @@ lines and only + lines is treated
16
+ * as file creation (the old tolerant behavior) — but never as an edit to an
17
+ * existing file, where hunk-less appends used to corrupt content.
3
18
  */
4
19
  export declare const applyPatchTool: import("../types.js").Tool<{
5
20
  patch: string;