@wrongstack/tools 0.264.0 → 0.267.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. package/dist/audit.js +154 -11
  2. package/dist/audit.js.map +1 -1
  3. package/dist/bash.js +138 -2
  4. package/dist/bash.js.map +1 -1
  5. package/dist/batch-tool-use.js +1 -0
  6. package/dist/batch-tool-use.js.map +1 -1
  7. package/dist/builtin.d.ts +20 -1
  8. package/dist/builtin.js +796 -340
  9. package/dist/builtin.js.map +1 -1
  10. package/dist/circuit-breaker.d.ts +20 -0
  11. package/dist/circuit-breaker.js +40 -2
  12. package/dist/circuit-breaker.js.map +1 -1
  13. package/dist/codebase-index/index.d.ts +16 -0
  14. package/dist/codebase-index/index.js +59 -25
  15. package/dist/codebase-index/index.js.map +1 -1
  16. package/dist/codebase-index/worker.js +56 -25
  17. package/dist/codebase-index/worker.js.map +1 -1
  18. package/dist/diff.js +14 -7
  19. package/dist/diff.js.map +1 -1
  20. package/dist/document.js +14 -8
  21. package/dist/document.js.map +1 -1
  22. package/dist/edit.d.ts +1 -0
  23. package/dist/edit.js +33 -22
  24. package/dist/edit.js.map +1 -1
  25. package/dist/exec.js +140 -3
  26. package/dist/exec.js.map +1 -1
  27. package/dist/fetch.js +1 -0
  28. package/dist/fetch.js.map +1 -1
  29. package/dist/format.js +153 -11
  30. package/dist/format.js.map +1 -1
  31. package/dist/git.d.ts +7 -0
  32. package/dist/git.js +20 -2
  33. package/dist/git.js.map +1 -1
  34. package/dist/glob.js +14 -7
  35. package/dist/glob.js.map +1 -1
  36. package/dist/grep.js +14 -7
  37. package/dist/grep.js.map +1 -1
  38. package/dist/index.d.ts +55 -3
  39. package/dist/index.js +957 -341
  40. package/dist/index.js.map +1 -1
  41. package/dist/install.js +153 -11
  42. package/dist/install.js.map +1 -1
  43. package/dist/json.js +1 -0
  44. package/dist/json.js.map +1 -1
  45. package/dist/lint.js +153 -11
  46. package/dist/lint.js.map +1 -1
  47. package/dist/logs.js +14 -7
  48. package/dist/logs.js.map +1 -1
  49. package/dist/memory.js +1 -0
  50. package/dist/memory.js.map +1 -1
  51. package/dist/mode.js +1 -0
  52. package/dist/mode.js.map +1 -1
  53. package/dist/outdated.js +21 -10
  54. package/dist/outdated.js.map +1 -1
  55. package/dist/pack.js +765 -339
  56. package/dist/pack.js.map +1 -1
  57. package/dist/patch.js +14 -7
  58. package/dist/patch.js.map +1 -1
  59. package/dist/process-registry.d.ts +56 -2
  60. package/dist/process-registry.js +138 -3
  61. package/dist/process-registry.js.map +1 -1
  62. package/dist/read.d.ts +3 -0
  63. package/dist/read.js +124 -22
  64. package/dist/read.js.map +1 -1
  65. package/dist/replace.js +14 -7
  66. package/dist/replace.js.map +1 -1
  67. package/dist/scaffold.js +14 -7
  68. package/dist/scaffold.js.map +1 -1
  69. package/dist/search.js +1 -0
  70. package/dist/search.js.map +1 -1
  71. package/dist/test.js +153 -11
  72. package/dist/test.js.map +1 -1
  73. package/dist/todo.js +1 -0
  74. package/dist/todo.js.map +1 -1
  75. package/dist/tool-help.js +1 -0
  76. package/dist/tool-help.js.map +1 -1
  77. package/dist/tool-icons.d.ts +20 -0
  78. package/dist/tool-icons.js +130 -0
  79. package/dist/tool-icons.js.map +1 -0
  80. package/dist/tool-search.js +1 -0
  81. package/dist/tool-search.js.map +1 -1
  82. package/dist/tool-use.js +1 -0
  83. package/dist/tool-use.js.map +1 -1
  84. package/dist/tree.js +14 -7
  85. package/dist/tree.js.map +1 -1
  86. package/dist/typecheck.js +153 -11
  87. package/dist/typecheck.js.map +1 -1
  88. package/dist/write.js +21 -15
  89. package/dist/write.js.map +1 -1
  90. package/package.json +6 -2
package/dist/pack.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { spawn, execFileSync, spawnSync } from 'node:child_process';
2
2
  import * as Core from '@wrongstack/core';
3
- import { buildChildEnv, detectNewlineStyle, normalizeToLf, toStyle, atomicWrite, unifiedDiff, isPrivateIPv4, isPrivateIPv6, compileGlob, expectDefined, recordPackageAction, detectPackageEcosystem, mutatePlan, clearPlan, getPlanTemplate, addPlanItem, deriveTodosFromPlanItem, removePlanItem, setPlanItemStatus, loadTasks, emptyTaskFile, saveTasks, formatTaskList, formatPlan, mutateTasks, loadPlan, emptyPlan, savePlan, computeTaskItemProgress, wstackGlobalRoot, resolveWstackPaths, truncate } from '@wrongstack/core';
3
+ import { buildChildEnv, detectNewlineStyle, normalizeToLf, toStyle, atomicWrite, unifiedDiff, isPrivateIPv4, isPrivateIPv6, assessCommitSafety, compileGlob, expectDefined, recordPackageAction, detectPackageEcosystem, mutatePlan, clearPlan, getPlanTemplate, addPlanItem, deriveTodosFromPlanItem, removePlanItem, setPlanItemStatus, mutateTasks, formatTaskList, formatPlan, computeTaskItemProgress, loadPlan, savePlan, loadTasks, saveTasks, wstackGlobalRoot, resolveWstackPaths, truncate } from '@wrongstack/core';
4
4
  import * as fs from 'node:fs';
5
5
  import { statSync, mkdirSync, createWriteStream, writeFileSync } from 'node:fs';
6
6
  import * as fs14 from 'node:fs/promises';
@@ -142,6 +142,23 @@ var CircuitBreaker = class {
142
142
  lastSlowAt = null;
143
143
  /** Timestamp when the breaker was opened (for cooldown calculation). */
144
144
  openedAt = null;
145
+ /**
146
+ * Master enable flag. When false the breaker is bypassed: `beforeCall`
147
+ * always returns true and `afterCall` records nothing. The class itself
148
+ * defaults to enabled (so the standalone unit tests exercise tripping); the
149
+ * ProcessRegistry flips this off until the user opts in via `/settings`.
150
+ */
151
+ enabled = true;
152
+ /**
153
+ * Fired (best-effort) when the breaker transitions into the `open` state.
154
+ * The registry uses this to arm its auto kill/reset countdown.
155
+ */
156
+ onTrip;
157
+ /**
158
+ * Fired (best-effort) when the breaker returns to `closed` after having been
159
+ * open/half-open. The registry uses this to cancel a pending kill/reset.
160
+ */
161
+ onReset;
145
162
  constructor(config = {}) {
146
163
  this.maxConsecutiveFailures = config.maxConsecutiveFailures ?? DEFAULT_MAX_CONSECUTIVE_FAILURES;
147
164
  this.slowCallThresholdMs = config.slowCallThresholdMs ?? DEFAULT_SLOW_CALL_THRESHOLD_MS;
@@ -150,12 +167,22 @@ var CircuitBreaker = class {
150
167
  this.maxCallsPerWindow = config.maxCallsPerWindow ?? DEFAULT_MAX_CALLS_PER_WINDOW;
151
168
  this.cooldownMs = config.cooldownMs ?? DEFAULT_COOLDOWN_MS;
152
169
  }
170
+ /** Toggle the master enable. Disabling resets to a clean `closed` state. */
171
+ setEnabled(enabled) {
172
+ if (this.enabled === enabled) return;
173
+ this.enabled = enabled;
174
+ if (!enabled) this._reset();
175
+ }
176
+ get isEnabled() {
177
+ return this.enabled;
178
+ }
153
179
  /**
154
180
  * Returns true if the circuit allows a new call to proceed.
155
181
  * When false, callers should abort the tool call and return a
156
182
  * circuit-breaker error instead of spawning a process.
157
183
  */
158
184
  get canProceed() {
185
+ if (!this.enabled) return true;
159
186
  this._checkStateTransition();
160
187
  return this.state !== "open";
161
188
  }
@@ -191,7 +218,7 @@ var CircuitBreaker = class {
191
218
  * not affect breaker state.
192
219
  */
193
220
  beforeCall(bypass = false) {
194
- if (bypass) return true;
221
+ if (bypass || !this.enabled) return true;
195
222
  this._checkStateTransition();
196
223
  if (this.state === "open") return false;
197
224
  return true;
@@ -206,7 +233,7 @@ var CircuitBreaker = class {
206
233
  * Use for background/fire-and-forget processes.
207
234
  */
208
235
  afterCall(durationMs, failed, bypass = false) {
209
- if (bypass) return;
236
+ if (bypass || !this.enabled) return;
210
237
  const now = Date.now();
211
238
  if (this.state === "half-open") {
212
239
  if (failed) {
@@ -252,12 +279,23 @@ var CircuitBreaker = class {
252
279
  if (this.state === "open") return;
253
280
  this.state = "open";
254
281
  this.openedAt = Date.now();
282
+ try {
283
+ this.onTrip?.();
284
+ } catch {
285
+ }
255
286
  }
256
287
  _reset() {
288
+ const wasRecovering = this.state !== "closed";
257
289
  this.state = "closed";
258
290
  this.consecutiveFailures = 0;
259
291
  this.window = [];
260
292
  this.openedAt = null;
293
+ if (wasRecovering) {
294
+ try {
295
+ this.onReset?.();
296
+ } catch {
297
+ }
298
+ }
261
299
  }
262
300
  /** Transition from open → half-open when cooldown elapses. */
263
301
  _checkStateTransition() {
@@ -319,8 +357,21 @@ function killWin32Tree(pid) {
319
357
  var ProcessRegistryImpl = class {
320
358
  processes = /* @__PURE__ */ new Map();
321
359
  breaker;
360
+ /**
361
+ * Auto kill/reset config. When the breaker trips and `autoKillResetMs > 0`,
362
+ * a countdown is armed; on expiry all tracked processes are killed and the
363
+ * breaker is reset to closed (forced recovery). Zero means manual recovery
364
+ * only (`/kill reset`).
365
+ */
366
+ autoKillResetMs = 0;
367
+ autoKillTimer = null;
368
+ autoKillArmedAt = null;
369
+ breakerCountdownListeners = [];
322
370
  constructor(breakerConfig) {
323
371
  this.breaker = new CircuitBreaker(breakerConfig);
372
+ this.breaker.onTrip = () => this._armAutoKillReset();
373
+ this.breaker.onReset = () => this._cancelAutoKillReset();
374
+ this.breaker.setEnabled(false);
324
375
  }
325
376
  register(info) {
326
377
  this.processes.set(info.pid, { ...info, killed: false, protected: info.protected ?? false });
@@ -396,6 +447,90 @@ var ProcessRegistryImpl = class {
396
447
  forceBreakerReset() {
397
448
  this.breaker.forceReset();
398
449
  }
450
+ /**
451
+ * Configure circuit-breaker protection at runtime. Called from `/settings`
452
+ * (instant, all modes) and on TUI mount (applies persisted config).
453
+ *
454
+ * - `enabled` toggles whether the breaker gates `bash`/`exec`.
455
+ * - `autoKillResetMs` arms the auto kill/reset countdown when the breaker
456
+ * trips (0 = manual recovery only).
457
+ *
458
+ * Re-applies cleanly on every call: cancels a pending countdown when the
459
+ * timeout is cleared or protection disabled, and re-arms if the breaker is
460
+ * currently open under the new settings.
461
+ */
462
+ setBreakerConfig(cfg) {
463
+ if (cfg.enabled !== void 0) this.breaker.setEnabled(cfg.enabled);
464
+ if (cfg.autoKillResetMs !== void 0) this.autoKillResetMs = Math.max(0, cfg.autoKillResetMs);
465
+ if (this.autoKillResetMs <= 0) {
466
+ this._cancelAutoKillReset();
467
+ return;
468
+ }
469
+ if (this.breaker.isEnabled && this.breaker.snapshot().state === "open") {
470
+ this._armAutoKillReset();
471
+ }
472
+ }
473
+ /**
474
+ * Live countdown to the next auto kill/reset, or null when nothing is armed.
475
+ * The TUI polls this on a 1s tick while armed so the statusline decrements.
476
+ */
477
+ getBreakerCountdown() {
478
+ if (this.autoKillArmedAt === null || this.autoKillResetMs <= 0) return null;
479
+ const elapsed = Date.now() - this.autoKillArmedAt;
480
+ return { remainingMs: Math.max(0, this.autoKillResetMs - elapsed), totalMs: this.autoKillResetMs };
481
+ }
482
+ /**
483
+ * Subscribe to countdown arm/cancel events. Returns an unsubscribe function.
484
+ * Use {@link getBreakerCountdown} for the live ticking value between events.
485
+ */
486
+ onBreakerCountdownChange(listener) {
487
+ this.breakerCountdownListeners.push(listener);
488
+ return () => {
489
+ this.breakerCountdownListeners = this.breakerCountdownListeners.filter((l) => l !== listener);
490
+ };
491
+ }
492
+ _emitBreakerCountdown() {
493
+ const snap = this.getBreakerCountdown();
494
+ for (const l of this.breakerCountdownListeners) {
495
+ try {
496
+ l(snap);
497
+ } catch {
498
+ }
499
+ }
500
+ }
501
+ /**
502
+ * Arm the auto kill/reset countdown. Idempotent: re-arming resets the window
503
+ * (a fresh trip after a failed half-open probe restarts the clock). No-op
504
+ * when protection is off or no timeout is configured.
505
+ */
506
+ _armAutoKillReset() {
507
+ if (this.autoKillResetMs <= 0 || !this.breaker.isEnabled) return;
508
+ this._clearAutoKillTimer();
509
+ this.autoKillArmedAt = Date.now();
510
+ this.autoKillTimer = setTimeout(() => {
511
+ this.autoKillTimer = null;
512
+ this.autoKillArmedAt = null;
513
+ this.killAll({ force: false });
514
+ this.breaker.forceReset();
515
+ this._emitBreakerCountdown();
516
+ }, this.autoKillResetMs);
517
+ this.autoKillTimer.unref?.();
518
+ this._emitBreakerCountdown();
519
+ }
520
+ _cancelAutoKillReset() {
521
+ const wasArmed = this.autoKillArmedAt !== null;
522
+ this._clearAutoKillTimer();
523
+ if (wasArmed) {
524
+ this.autoKillArmedAt = null;
525
+ this._emitBreakerCountdown();
526
+ }
527
+ }
528
+ _clearAutoKillTimer() {
529
+ if (this.autoKillTimer !== null) {
530
+ clearTimeout(this.autoKillTimer);
531
+ this.autoKillTimer = null;
532
+ }
533
+ }
399
534
  /** Kill a single process by PID.
400
535
  *
401
536
  * On POSIX: sends SIGTERM to the *process group* (-pid) so that
@@ -534,8 +669,9 @@ async function* spawnStream(opts) {
534
669
  let pending2 = "";
535
670
  let error;
536
671
  const spool = createOutputSpool({ tool: opts.cmd, thresholdBytes: max });
537
- const cmd = resolveWin32Command(opts.cmd);
538
- const needsShell = isWin && (cmd.endsWith(".cmd") || cmd.endsWith(".bat"));
672
+ const resolved = resolveWin32Command(opts.cmd);
673
+ const needsShell = isWin && (resolved.endsWith(".cmd") || resolved.endsWith(".bat"));
674
+ const cmd = needsShell ? opts.cmd : resolved;
539
675
  const child = spawn(cmd, opts.args, {
540
676
  cwd: opts.cwd,
541
677
  env: buildChildEnv(),
@@ -699,22 +835,29 @@ async function detectPackageManager(cwd) {
699
835
  function resolvePath(input, ctx) {
700
836
  return path3.isAbsolute(input) ? path3.normalize(input) : path3.resolve(ctx.workingDir ?? ctx.cwd, input);
701
837
  }
838
+ function allowedRoots(ctx) {
839
+ return [path3.resolve(ctx.projectRoot), path3.resolve(Core.wstackGlobalRoot())];
840
+ }
841
+ function isInsideAny(target, roots) {
842
+ return roots.some((root) => {
843
+ const rel = path3.relative(root, target);
844
+ return rel === "" || !rel.startsWith("..") && !path3.isAbsolute(rel);
845
+ });
846
+ }
702
847
  function ensureInsideRoot(absPath, ctx) {
703
- if (ctx.allowOutsideProjectRoot) return path3.resolve(absPath);
704
- const root = path3.resolve(ctx.projectRoot);
705
848
  const target = path3.resolve(absPath);
706
- const rel = path3.relative(root, target);
707
- if (rel.startsWith("..") || path3.isAbsolute(rel)) {
708
- throw new Error(`Path "${absPath}" is outside project root "${root}"`);
709
- }
710
- return target;
849
+ if (ctx.allowOutsideProjectRoot) return target;
850
+ if (isInsideAny(target, allowedRoots(ctx))) return target;
851
+ throw new Error(`Path "${absPath}" is outside project root "${path3.resolve(ctx.projectRoot)}"`);
711
852
  }
712
853
  function safeResolve(input, ctx) {
713
854
  return ensureInsideRoot(resolvePath(input, ctx), ctx);
714
855
  }
715
856
  async function assertRealInsideRoot(absPath, ctx) {
716
857
  if (ctx.allowOutsideProjectRoot) return;
717
- const realRoot = await fs14.realpath(ctx.projectRoot).catch(() => path3.resolve(ctx.projectRoot));
858
+ const realRoots = await Promise.all(
859
+ allowedRoots(ctx).map((r) => fs14.realpath(r).catch(() => path3.resolve(r)))
860
+ );
718
861
  let probe = absPath;
719
862
  for (; ; ) {
720
863
  let real;
@@ -729,13 +872,10 @@ async function assertRealInsideRoot(absPath, ctx) {
729
872
  }
730
873
  throw err;
731
874
  }
732
- const rel = path3.relative(realRoot, real);
733
- if (rel.startsWith("..") || path3.isAbsolute(rel)) {
734
- throw new Error(
735
- `Path "${absPath}" resolves through a symlink outside project root "${realRoot}"`
736
- );
737
- }
738
- return;
875
+ if (isInsideAny(real, realRoots)) return;
876
+ throw new Error(
877
+ `Path "${absPath}" resolves through a symlink outside project root "${realRoots[0]}"`
878
+ );
739
879
  }
740
880
  }
741
881
  async function safeResolveReal(input, ctx) {
@@ -837,6 +977,7 @@ var auditTool = {
837
977
  permission: "confirm",
838
978
  mutating: false,
839
979
  capabilities: ["shell.restricted"],
980
+ icon: "package",
840
981
  timeoutMs: 6e4,
841
982
  inputSchema: {
842
983
  type: "object",
@@ -941,6 +1082,7 @@ var bashTool = {
941
1082
  permission: "confirm",
942
1083
  mutating: true,
943
1084
  riskTier: "destructive",
1085
+ icon: "terminal",
944
1086
  // Trust rules match on the literal `command` string. Without subjectKey
945
1087
  // the policy heuristic would have done the same here, but declaring it
946
1088
  // explicitly removes the implicit cross-tool aliasing.
@@ -1281,6 +1423,7 @@ var batchToolUseTool = {
1281
1423
  mutating: true,
1282
1424
  timeoutMs: 12e4,
1283
1425
  capabilities: ["tool.mutate.any"],
1426
+ icon: "meta",
1284
1427
  inputSchema: {
1285
1428
  type: "object",
1286
1429
  properties: {
@@ -2056,39 +2199,57 @@ var IndexStore = class {
2056
2199
  }
2057
2200
  });
2058
2201
  }
2202
+ /**
2203
+ * Bulk-insert refs for many source symbols in a single transaction.
2204
+ *
2205
+ * Unlike {@link insertRefs} this does NOT delete per source id — the caller
2206
+ * (the indexer) has already cleared stale refs for the file via
2207
+ * {@link deleteRefsForFile}, so the per-source DELETE would be redundant work
2208
+ * repeated once per symbol. One transaction for the whole file instead of one
2209
+ * per symbol turns an O(symbols) transaction count into O(1).
2210
+ *
2211
+ * Each ref's own {@link Ref.fromId} is used; pass an empty array to no-op.
2212
+ */
2213
+ insertRefsBatch(refs) {
2214
+ if (refs.length === 0) return;
2215
+ this.runWithRetry(() => {
2216
+ const stmt = this.db.prepare(
2217
+ `INSERT INTO refs(from_id, to_name, to_id, call_type, line)
2218
+ VALUES (?, ?, ?, ?, ?)`
2219
+ );
2220
+ for (const ref of refs) {
2221
+ stmt.run(ref.fromId, ref.toName, ref.toId ?? null, ref.callType, ref.line);
2222
+ }
2223
+ });
2224
+ }
2059
2225
  /**
2060
2226
  * Delete all refs whose source symbols are in a given file.
2061
2227
  * Used when re-indexing a file to clear stale refs.
2062
2228
  */
2063
2229
  deleteRefsForFile(file) {
2064
2230
  this.runWithRetry(() => {
2065
- const ids = this.db.prepare(
2066
- "SELECT id FROM symbols WHERE file = ?"
2067
- ).all(file);
2068
- if (!ids.length) return;
2069
- const placeholders = ids.map(() => "?").join(",");
2070
- this.db.prepare(`DELETE FROM refs WHERE from_id IN (${placeholders})`).run(...ids.map((r) => r.id));
2231
+ this.db.prepare(
2232
+ "DELETE FROM refs WHERE from_id IN (SELECT id FROM symbols WHERE file = ?)"
2233
+ ).run(file);
2071
2234
  });
2072
2235
  }
2073
2236
  /**
2074
2237
  * Resolve `to_name` → `to_id` for all refs that have a name but no id.
2075
2238
  * Call this after all symbols have been inserted to fill in cross-references.
2239
+ *
2240
+ * Single statement: the `to_name IN (SELECT name FROM symbols)` guard restricts
2241
+ * the UPDATE to refs that will actually resolve, so `.changes` counts only refs
2242
+ * that found a target — matching the previous per-row loop's return value.
2076
2243
  */
2077
2244
  resolveRefs() {
2078
2245
  return this.runWithRetry(() => {
2079
- const unresolved = this.db.prepare(
2080
- "SELECT id, to_name FROM refs WHERE to_id IS NULL AND to_name IS NOT NULL"
2081
- ).all();
2082
- let resolved = 0;
2083
- for (const row of unresolved) {
2084
- const target = this.db.prepare("SELECT id FROM symbols WHERE name = ? LIMIT 1").all(row.to_name);
2085
- const first = target[0];
2086
- if (first) {
2087
- this.db.prepare("UPDATE refs SET to_id = ? WHERE id = ?").run(first.id, row.id);
2088
- resolved++;
2089
- }
2090
- }
2091
- return resolved;
2246
+ const result = this.db.prepare(
2247
+ `UPDATE refs SET to_id = (
2248
+ SELECT id FROM symbols WHERE name = refs.to_name LIMIT 1
2249
+ ) WHERE to_id IS NULL AND to_name IS NOT NULL
2250
+ AND to_name IN (SELECT name FROM symbols)`
2251
+ ).run();
2252
+ return result.changes ?? 0;
2092
2253
  });
2093
2254
  }
2094
2255
  /**
@@ -3563,14 +3724,27 @@ async function runIndexerWithStore(store, opts) {
3563
3724
  symbolsIndexed += count;
3564
3725
  langStats[lang] = (langStats[lang] ?? 0) + count;
3565
3726
  if (parsed.refs && parsed.refs.length > 0) {
3566
- for (let i = 0; i < symbolsWithIds.length; i++) {
3567
- const sym = expectDefined(symbolsWithIds[i]);
3568
- const symRefs = parsed.refs.filter((r) => r.line === sym.line);
3569
- if (symRefs.length > 0) {
3570
- const refsWithFromId = symRefs.map((r) => ({ ...r, fromId: sym.id }));
3571
- store.insertRefs(sym.id, refsWithFromId);
3727
+ const refsByLine = /* @__PURE__ */ new Map();
3728
+ for (const r of parsed.refs) {
3729
+ let arr = refsByLine.get(r.line);
3730
+ if (!arr) {
3731
+ arr = [];
3732
+ refsByLine.set(r.line, arr);
3733
+ }
3734
+ arr.push(r);
3735
+ }
3736
+ const batch = [];
3737
+ for (const sym of symbolsWithIds) {
3738
+ const symRefs = refsByLine.get(sym.line);
3739
+ if (symRefs) {
3740
+ for (const r of symRefs) {
3741
+ batch.push({ ...r, fromId: sym.id });
3742
+ }
3572
3743
  }
3573
3744
  }
3745
+ if (batch.length > 0) {
3746
+ store.insertRefsBatch(batch);
3747
+ }
3574
3748
  }
3575
3749
  store.upsertFile({
3576
3750
  file,
@@ -3903,6 +4077,7 @@ async function codebaseIndexStats(args, opts = {}) {
3903
4077
  var codebaseIndexTool = {
3904
4078
  name: "codebase-index",
3905
4079
  category: "Project",
4080
+ icon: "index",
3906
4081
  description: "Build or incrementally update the project-wide symbol index. This powers fast codebase search and understanding. By default it only processes files that have changed since the last indexing run.",
3907
4082
  usageHint: "IMPORTANT FOR LARGE CODEBASES:\n\n- First run (or after major changes): consider `force: true` for a clean rebuild.\n- Normal usage: call without arguments for fast incremental updates.\n- Use `langs` to restrict to specific languages if you only care about certain parts of the project.\nThis tool is relatively expensive \u2014 do not call it on every turn. Use it when the index is stale or before heavy codebase-search sessions.",
3908
4083
  permission: "confirm",
@@ -3959,6 +4134,7 @@ var codebaseIndexTool = {
3959
4134
  var codebaseSearchTool = {
3960
4135
  name: "codebase-search",
3961
4136
  category: "Project",
4137
+ icon: "index",
3962
4138
  description: "Semantic/keyword search over the indexed codebase symbols (functions, classes, interfaces, etc.). Uses BM25 ranking. Much more powerful and structured than raw `grep` for finding code by name or concept.",
3963
4139
  usageHint: "PREFERRED FOR CODE UNDERSTANDING:\n\n- Use when you need to find where something is defined or used by name.\n- `kind` filter is very useful (e.g. only functions or only interfaces).\n- Combine with `file` filter to scope to a specific directory or module.\nThis is generally better than `grep` when you are looking for symbols rather than arbitrary text patterns.",
3964
4140
  permission: "auto",
@@ -4047,6 +4223,7 @@ var codebaseSearchTool = {
4047
4223
  var codebaseStatsTool = {
4048
4224
  name: "codebase-stats",
4049
4225
  category: "Project",
4226
+ icon: "index",
4050
4227
  description: "Return health and statistics about the current symbol index (total symbols, files, language/kind breakdown, size, last update). Useful to decide whether to re-index.",
4051
4228
  usageHint: "CALL BEFORE HEAVY CODEBASE-SEARCH WORK:\n\n- Use to see if the index is up-to-date or needs a refresh.\n- No arguments required.\n- Helps avoid wasting tokens on searches against a stale index.\nLightweight and safe to call frequently.",
4052
4229
  permission: "auto",
@@ -4107,6 +4284,7 @@ var diffTool = {
4107
4284
  permission: "auto",
4108
4285
  mutating: false,
4109
4286
  capabilities: ["fs.read"],
4287
+ icon: "diff",
4110
4288
  timeoutMs: 1e4,
4111
4289
  inputSchema: {
4112
4290
  type: "object",
@@ -4253,6 +4431,7 @@ var documentTool = {
4253
4431
  mutating: false,
4254
4432
  timeoutMs: 3e4,
4255
4433
  capabilities: ["fs.read"],
4434
+ icon: "document",
4256
4435
  inputSchema: {
4257
4436
  type: "object",
4258
4437
  properties: {
@@ -4392,11 +4571,12 @@ function processFile(content, absPath, _style, _overwrite, target) {
4392
4571
  var editTool = {
4393
4572
  name: "edit",
4394
4573
  category: "Filesystem",
4395
- description: "Perform a precise, surgical text replacement in a file. This is the preferred tool for modifying existing code. It requires that you have previously called `read` on the file in the current session. Fails safely if the `old_string` appears more than once unless `replace_all` is set.",
4396
- usageHint: "MANDATORY WORKFLOW:\n1. Call `read` on the target file first (in the same conversation).\n2. Use a sufficiently unique `old_string` (include surrounding lines/context if needed).\n3. If the string appears multiple times and you want to change all of them, set `replace_all: true`.\n4. `new_string` must be the exact replacement text.\n\nThis tool is much safer than `write` for existing files because it works against the last-read version.",
4574
+ description: "Perform a precise, surgical text replacement in a file. This is the preferred tool for modifying existing code. It works best after a prior `read`, but can auto-read the current file when the replacement is still unambiguous. Fails safely if the `old_string` appears more than once unless `replace_all` is set.",
4575
+ usageHint: "RECOMMENDED WORKFLOW:\n1. Prefer calling `read` on the target file first when planning an edit.\n2. Use a sufficiently unique `old_string` (include surrounding lines/context if needed).\n3. If the string appears multiple times and you want to change all of them, set `replace_all: true`.\n4. `new_string` must be the exact replacement text.\n\nIf no prior read is recorded, the tool auto-reads the current file and only applies the edit after the same ambiguity checks pass.",
4397
4576
  permission: "confirm",
4398
4577
  mutating: true,
4399
4578
  capabilities: ["fs.write"],
4579
+ icon: "edit",
4400
4580
  timeoutMs: 5e3,
4401
4581
  inputSchema: {
4402
4582
  type: "object",
@@ -4421,9 +4601,7 @@ var editTool = {
4421
4601
  throw err;
4422
4602
  });
4423
4603
  if (!stat11.isFile()) throw new Error(`edit: "${input.path}" is not a regular file`);
4424
- if (!ctx.hasRead(absPath)) {
4425
- throw new Error(`edit: file "${input.path}" was not read in this session. Read it first.`);
4426
- }
4604
+ const autoRead = !ctx.hasRead(absPath);
4427
4605
  const original = await fs14.readFile(absPath, "utf8");
4428
4606
  const updated = await fs14.stat(absPath);
4429
4607
  const mtimeTolerance = process.platform === "win32" ? 2e3 : 1;
@@ -4431,15 +4609,21 @@ var editTool = {
4431
4609
  if (lastReadMtime !== void 0 && updated.mtimeMs > lastReadMtime + mtimeTolerance) {
4432
4610
  throw new Error(`edit: file "${input.path}" was modified externally. Re-read it first.`);
4433
4611
  }
4612
+ if (autoRead && updated.mtimeMs > stat11.mtimeMs + mtimeTolerance) {
4613
+ throw new Error(`edit: file "${input.path}" changed while being auto-read. Retry the edit.`);
4614
+ }
4615
+ const autoReadNote = autoRead ? `No prior read was recorded for "${input.path}"; edit auto-read the current file and applied the replacement only after the ambiguity checks passed.` : void 0;
4434
4616
  const style = detectNewlineStyle(original);
4435
4617
  const fileLf = normalizeToLf(original);
4436
4618
  const oldLf = normalizeToLf(input.old_string);
4437
4619
  const newLf = normalizeToLf(input.new_string);
4438
4620
  if (oldLf === newLf) {
4621
+ if (autoRead) ctx.recordRead(absPath, updated.mtimeMs);
4439
4622
  return {
4440
4623
  path: absPath,
4441
4624
  replacements: 0,
4442
- diff: "(no-op: old and new are identical)"
4625
+ diff: "(no-op: old and new are identical)",
4626
+ note: autoReadNote
4443
4627
  };
4444
4628
  }
4445
4629
  let count = 0;
@@ -4480,7 +4664,8 @@ var editTool = {
4480
4664
  return {
4481
4665
  path: absPath,
4482
4666
  replacements: input.replace_all ? count : 1,
4483
- diff
4667
+ diff,
4668
+ note: autoReadNote
4484
4669
  };
4485
4670
  }
4486
4671
  };
@@ -4619,6 +4804,7 @@ var execTool = {
4619
4804
  riskTier: "standard",
4620
4805
  timeoutMs: DEFAULT_TIMEOUT_MS2,
4621
4806
  capabilities: ["shell.restricted"],
4807
+ icon: "terminal",
4622
4808
  inputSchema: {
4623
4809
  type: "object",
4624
4810
  properties: {
@@ -4718,7 +4904,8 @@ function runCommand(cmd, args, cwd, timeout, signal, sessionId) {
4718
4904
  const spool = createOutputSpool({ tool: `exec-${cmd}`, thresholdBytes: MAX_OUTPUT2 });
4719
4905
  const resolved = resolveWin32Command(cmd);
4720
4906
  const needsShell = isWin2 && (resolved.endsWith(".cmd") || resolved.endsWith(".bat"));
4721
- const child = spawn(resolved, args, {
4907
+ const spawnCmd = needsShell ? cmd : resolved;
4908
+ const child = spawn(spawnCmd, args, {
4722
4909
  cwd,
4723
4910
  env: buildChildEnv(sessionId),
4724
4911
  stdio: ["ignore", "pipe", "pipe"],
@@ -4905,6 +5092,7 @@ var fetchTool = {
4905
5092
  permission: "confirm",
4906
5093
  mutating: false,
4907
5094
  capabilities: ["net.outbound"],
5095
+ icon: "web",
4908
5096
  // Trust rules for fetch match on the literal URL — declare it explicitly
4909
5097
  // so a user can trust `https://api.example.com/*` without accidentally
4910
5098
  // matching that pattern on any other tool that happens to have a `url`
@@ -5054,6 +5242,7 @@ var formatTool = {
5054
5242
  permission: "confirm",
5055
5243
  mutating: true,
5056
5244
  capabilities: ["fs.write", "shell.exec"],
5245
+ icon: "code",
5057
5246
  timeoutMs: 6e4,
5058
5247
  inputSchema: {
5059
5248
  type: "object",
@@ -5152,8 +5341,9 @@ var gitTool = {
5152
5341
  name: "git",
5153
5342
  category: "Git",
5154
5343
  description: "Safe wrapper around common git operations. Supports status, log, diff, commit, branch, checkout, stash, push, pull, fetch, reset, worktree, etc. This is the preferred way to interact with git instead of using the raw `bash` or `exec` tools.",
5155
- usageHint: "ALWAYS prefer this tool over raw shell git commands.\n\nKey fields:\n- `command`: one of the supported subcommands (status, log, diff, commit, etc.)\n- Use `message` only for commit operations.\n- Use `files` array for operations that take paths (status, diff, add, etc.).\n- Non-mutating commands (status, log, diff, branch, fetch) are still permission:confirm for safety.\nNever pass raw git flags through `args` for dangerous operations \u2014 use the structured fields.",
5344
+ usageHint: "ALWAYS prefer this tool over raw shell git commands.\n\nKey fields:\n- `command`: one of the supported subcommands (status, log, diff, commit, etc.)\n- Use `message` only for commit operations.\n- Use `files` array for operations that take paths (status, diff, add, etc.).\n- Non-mutating commands (status, log, diff, branch, fetch) are still permission:confirm for safety.\n- For `commit` in a possibly-shared working tree, pass an explicit `files` list scoped to what YOU changed. A bare commit (no `files`) includes ALL staged changes and may capture another agent's half-done work. Heed the `warning` field on the result.\nNever pass raw git flags through `args` for dangerous operations \u2014 use the structured fields.",
5156
5345
  permission: "confirm",
5346
+ icon: "git",
5157
5347
  // Conservative: any of these may mutate. The non-mutating commands
5158
5348
  // (status/log/diff/branch/fetch) are still gated on `permission: 'confirm'`
5159
5349
  // and `MUTATING_SUBCOMMANDS` is consulted at runtime for per-call checks.
@@ -5240,6 +5430,22 @@ var gitTool = {
5240
5430
  };
5241
5431
  }
5242
5432
  const args = buildArgs(input);
5433
+ let safetyWarning;
5434
+ if (input.command === "commit") {
5435
+ try {
5436
+ const report = await assessCommitSafety({
5437
+ cwd: ctx.cwd,
5438
+ projectRoot: ctx.projectRoot,
5439
+ sessionId: ctx.session?.id,
5440
+ signal: opts.signal
5441
+ });
5442
+ if (report.warning) {
5443
+ const scopeNote = input.files ? "" : "\nNote: this commit has no explicit `files` list, so it will include ALL staged changes. Pass `files` to scope the commit to only what you changed.";
5444
+ safetyWarning = report.warning + scopeNote;
5445
+ }
5446
+ } catch {
5447
+ }
5448
+ }
5243
5449
  let stagedDiff;
5244
5450
  if (input.command === "commit" && !input.dry_run) {
5245
5451
  try {
@@ -5253,6 +5459,7 @@ var gitTool = {
5253
5459
  }
5254
5460
  const result = await runGit2(args, gitDir, opts.signal);
5255
5461
  if (stagedDiff !== void 0) result.diff = stagedDiff;
5462
+ if (safetyWarning !== void 0) result.warning = safetyWarning;
5256
5463
  return result;
5257
5464
  }
5258
5465
  };
@@ -5413,6 +5620,7 @@ var globTool = {
5413
5620
  permission: "auto",
5414
5621
  mutating: false,
5415
5622
  capabilities: ["fs.read"],
5623
+ icon: "folder",
5416
5624
  maxOutputBytes: 65536,
5417
5625
  timeoutMs: 5e3,
5418
5626
  inputSchema: {
@@ -5545,6 +5753,7 @@ var grepTool = {
5545
5753
  permission: "auto",
5546
5754
  mutating: false,
5547
5755
  capabilities: ["fs.read"],
5756
+ icon: "search",
5548
5757
  maxOutputBytes: 131072,
5549
5758
  timeoutMs: 1e4,
5550
5759
  inputSchema: {
@@ -5831,6 +6040,7 @@ var installTool = {
5831
6040
  permission: "confirm",
5832
6041
  mutating: true,
5833
6042
  riskTier: "standard",
6043
+ icon: "package",
5834
6044
  timeoutMs: 12e4,
5835
6045
  capabilities: ["package.install", "shell.restricted"],
5836
6046
  inputSchema: {
@@ -5969,6 +6179,7 @@ var jsonTool = {
5969
6179
  mutating: false,
5970
6180
  timeoutMs: 5e3,
5971
6181
  capabilities: ["fs.read"],
6182
+ icon: "json",
5972
6183
  inputSchema: {
5973
6184
  type: "object",
5974
6185
  properties: {
@@ -6094,6 +6305,7 @@ var lintTool = {
6094
6305
  mutating: false,
6095
6306
  timeoutMs: 6e4,
6096
6307
  capabilities: ["shell.restricted"],
6308
+ icon: "code",
6097
6309
  inputSchema: {
6098
6310
  type: "object",
6099
6311
  properties: {
@@ -6187,6 +6399,7 @@ var logsTool = {
6187
6399
  mutating: false,
6188
6400
  timeoutMs: 3e4,
6189
6401
  capabilities: ["shell.restricted"],
6402
+ icon: "logs",
6190
6403
  inputSchema: {
6191
6404
  type: "object",
6192
6405
  properties: {
@@ -6388,6 +6601,7 @@ var outdatedTool = {
6388
6601
  description: "Check for outdated dependencies in the project. Reports current, wanted (semver range), and latest versions available.",
6389
6602
  usageHint: "MAINTENANCE & SECURITY TOOL:\n\n- Run periodically or before dependency-related work.\n- Helps surface packages that may need updates for security or features.\n- Hits the package registry over HTTP, so it is NOT purely local \u2014 flagged as mutating for the confirmation gate.\nUse the output to decide on upgrades. Prefer this over manual shell commands for dependency hygiene.",
6390
6603
  permission: "confirm",
6604
+ icon: "package",
6391
6605
  // Network side-effecting (registry HTTP). Pairs with `mutating: true`
6392
6606
  // so the H7 invariant test (`no auto-permission tool declares
6393
6607
  // mutating: true`) passes — a tool claiming `'auto'` must be purely
@@ -6397,12 +6611,15 @@ var outdatedTool = {
6397
6611
  // fixed four sibling tools (mcp_control, shellcheck, shellcheck_scan,
6398
6612
  // web_search) but missed this one; applying the same contract here.
6399
6613
  mutating: true,
6400
- // Capability is just "network" — the tool only hits the package
6614
+ // Capability is outbound network — the tool only hits the package
6401
6615
  // registry over HTTP, never touches the filesystem or runs shell.
6616
+ // Use the canonical `net.outbound` capability (not the non-existent
6617
+ // `network` string) so the subagent allowlist recognises it and
6618
+ // permits read-only registry lookups under a director.
6402
6619
  // The H7 invariant test requires this array to be non-empty for
6403
6620
  // any mutating:true tool (meta-tools whitelisted). See
6404
6621
  // tests/permission-mutating-invariant.test.ts:92.
6405
- capabilities: ["network"],
6622
+ capabilities: ["net.outbound"],
6406
6623
  timeoutMs: 6e4,
6407
6624
  inputSchema: {
6408
6625
  type: "object",
@@ -6439,7 +6656,8 @@ function runOutdated(manager, args, cwd, signal) {
6439
6656
  const MAX = 1e5;
6440
6657
  const resolved = resolveWin32Command(manager);
6441
6658
  const needsShell = process.platform === "win32" && (resolved.endsWith(".cmd") || resolved.endsWith(".bat"));
6442
- const child = spawn(resolved, args, { cwd, signal, env: buildChildEnv(), stdio: ["ignore", "pipe", "pipe"], windowsHide: true, ...needsShell ? { shell: true, windowsVerbatimArguments: true } : {} });
6659
+ const spawnCmd = needsShell ? manager : resolved;
6660
+ const child = spawn(spawnCmd, args, { cwd, signal, env: buildChildEnv(), stdio: ["ignore", "pipe", "pipe"], windowsHide: true, ...needsShell ? { shell: true, windowsVerbatimArguments: true } : {} });
6443
6661
  child.stdout?.on("data", (c) => {
6444
6662
  if (stdout.length < MAX) stdout += c.toString();
6445
6663
  });
@@ -6503,6 +6721,7 @@ var patchTool = {
6503
6721
  permission: "confirm",
6504
6722
  mutating: true,
6505
6723
  capabilities: ["fs.write"],
6724
+ icon: "edit",
6506
6725
  timeoutMs: 3e4,
6507
6726
  inputSchema: {
6508
6727
  type: "object",
@@ -6610,11 +6829,12 @@ function extractPatchedFiles(output) {
6610
6829
  var planTool = {
6611
6830
  name: "plan",
6612
6831
  category: "Session",
6613
- description: "Manage a persistent strategic plan for the current session. Unlike todos, plans are meant for higher-level, multi-phase approaches and survive across conversation resumptions. Use this to outline big-picture work, then promote concrete items into the todo list when ready to execute.",
6614
- usageHint: 'RECOMMENDED FOR COMPLEX, MULTI-PHASE WORK:\n\n- Start by creating a high-level plan with `action: "add"` or using templates (`template_use`).\n- Use `promote` to turn a plan item into actionable todos.\n- Use `taskify` to convert a plan item into a structured task (with type/priority/deps).\n- Keep plans at the "why and what" level, and todos at the "how and next step" level.\n- Common templates: "new-feature", "bug-fix", "refactor", "release", "security-audit".\n\nThis tool is excellent for maintaining long-term direction across many turns or even multiple sessions.',
6832
+ description: 'Manage a session-persistent strategic plan. The plan is written to disk and survives conversation resumptions within the same session, but is isolated to this session \u2014 other sessions have their own separate plans. Unlike todos (which are per-turn and lost on restart), a plan tracks high-level progress across multiple turns. Use this to outline big-picture work, then promote concrete items into the todo list when ready to execute. By default plans are isolated to this session; use `scope: "project"` to store the plan in a shared project-level file visible to all sessions.',
6833
+ usageHint: 'RECOMMENDED FOR COMPLEX, MULTI-PHASE WORK:\n\n- Start by creating a high-level plan with `action: "add"` or using templates (`template_use`).\n- Use `promote` to turn a plan item into actionable todos.\n- Use `taskify` to convert a plan item into a structured task (with type/priority/deps).\n- Keep plans at the "why and what" level, and todos at the "how and next step" level.\n- Common templates: "new-feature", "bug-fix", "refactor", "release", "security-audit".\n\nThis tool is excellent for maintaining long-term direction across many turns within a session. Plans survive resume but are not shared across separate sessions.\nUse `scope: "project"` to use a shared project-level plan file.',
6615
6834
  permission: "confirm",
6616
6835
  mutating: true,
6617
6836
  capabilities: ["fs.write"],
6837
+ icon: "plan",
6618
6838
  timeoutMs: 2e3,
6619
6839
  inputSchema: {
6620
6840
  type: "object",
@@ -6654,12 +6874,26 @@ var planTool = {
6654
6874
  template: {
6655
6875
  type: "string",
6656
6876
  description: "Template identifier when using action=template_use. Common values: new-feature, bug-fix, refactor, release, security-audit."
6877
+ },
6878
+ scope: {
6879
+ type: "string",
6880
+ enum: ["session", "project"],
6881
+ description: 'Storage scope: "session" (default, isolated to this session) or "project" (shared across all sessions for this project).'
6657
6882
  }
6658
6883
  },
6659
6884
  required: ["action"]
6660
6885
  },
6661
6886
  async execute(input, ctx) {
6662
- const planPath = ctx.meta["plan.path"];
6887
+ const sessionPlanPath = ctx.meta["plan.path"];
6888
+ let planPath;
6889
+ if (input.scope === "project") {
6890
+ if (typeof sessionPlanPath === "string") {
6891
+ const lastSep = Math.max(sessionPlanPath.lastIndexOf("/"), sessionPlanPath.lastIndexOf("\\"));
6892
+ planPath = lastSep >= 0 ? sessionPlanPath.slice(0, lastSep + 1) + "backlog.plan.json" : "backlog.plan.json";
6893
+ }
6894
+ } else {
6895
+ planPath = sessionPlanPath;
6896
+ }
6663
6897
  if (typeof planPath !== "string" || !planPath) {
6664
6898
  return {
6665
6899
  ok: false,
@@ -6673,148 +6907,169 @@ var planTool = {
6673
6907
  let early = null;
6674
6908
  const taskifyMeta = { title: "", details: "" };
6675
6909
  let didTaskify = false;
6676
- const plan = await mutatePlan(planPath, sessionId, async (p) => {
6677
- switch (input.action) {
6678
- case "show":
6679
- break;
6680
- case "add": {
6681
- const title = input.title?.trim();
6682
- if (!title) {
6683
- early = mkResult(p, false, "add requires `title`.");
6684
- return p;
6685
- }
6686
- const { plan: updated } = addPlanItem(p, title, input.details?.trim() || void 0);
6687
- return updated;
6688
- }
6689
- case "start":
6690
- case "done": {
6691
- if (!input.target) {
6692
- early = mkResult(p, false, `${input.action} requires \`target\` (id|index|substring).`);
6693
- return p;
6694
- }
6695
- const next = setPlanItemStatus(
6696
- p,
6697
- input.target,
6698
- input.action === "start" ? "in_progress" : "done"
6699
- );
6700
- if (next === p) {
6701
- early = mkResult(p, false, `No plan item matched "${input.target}".`);
6702
- return p;
6703
- }
6704
- return next;
6705
- }
6706
- case "remove": {
6707
- if (!input.target) {
6708
- early = mkResult(p, false, "remove requires `target` (id|index|substring).");
6709
- return p;
6710
- }
6711
- const next = removePlanItem(p, input.target);
6712
- if (next === p) {
6713
- early = mkResult(p, false, `No plan item matched "${input.target}".`);
6714
- return p;
6715
- }
6716
- return next;
6717
- }
6718
- case "promote": {
6719
- if (!input.target) {
6720
- early = mkResult(p, false, `${input.action} requires \`target\` (id|index|substring).`);
6721
- return p;
6722
- }
6723
- const derived = deriveTodosFromPlanItem(p, input.target, input.subtasks);
6724
- if (!derived) {
6725
- early = mkResult(p, false, `No plan item matched "${input.target}".`);
6726
- return p;
6910
+ let plan;
6911
+ try {
6912
+ plan = await mutatePlan(planPath, sessionId, async (p) => {
6913
+ switch (input.action) {
6914
+ case "show":
6915
+ break;
6916
+ case "add": {
6917
+ const title = input.title?.trim();
6918
+ if (!title) {
6919
+ early = mkResult(p, false, "add requires `title`.");
6920
+ return p;
6921
+ }
6922
+ const { plan: updated } = addPlanItem(p, title, input.details?.trim() || void 0);
6923
+ return updated;
6727
6924
  }
6728
- ctx.state.replaceTodos(derived.todos);
6729
- early = mkResult(
6730
- derived.plan,
6731
- true,
6732
- `${input.action} ok \u2014 ${derived.todos.length} todo(s) created.`,
6733
- derived.todos
6734
- );
6735
- return derived.plan;
6736
- }
6737
- case "template_use": {
6738
- const templateName = input.template?.trim();
6739
- if (!templateName) {
6740
- early = mkResult(p, false, "template_use requires `template` name.");
6741
- return p;
6925
+ case "start":
6926
+ case "done": {
6927
+ if (!input.target) {
6928
+ early = mkResult(p, false, `${input.action} requires \`target\` (id|index|substring).`);
6929
+ return p;
6930
+ }
6931
+ const next = setPlanItemStatus(
6932
+ p,
6933
+ input.target,
6934
+ input.action === "start" ? "in_progress" : "done"
6935
+ );
6936
+ if (next === p) {
6937
+ early = mkResult(p, false, `No plan item matched "${input.target}".`);
6938
+ return p;
6939
+ }
6940
+ return next;
6742
6941
  }
6743
- const template = getPlanTemplate(templateName);
6744
- if (!template) {
6745
- early = mkResult(p, false, `Unknown template "${templateName}".`);
6746
- return p;
6942
+ case "remove": {
6943
+ if (!input.target) {
6944
+ early = mkResult(p, false, "remove requires `target` (id|index|substring).");
6945
+ return p;
6946
+ }
6947
+ const next = removePlanItem(p, input.target);
6948
+ if (next === p) {
6949
+ early = mkResult(p, false, `No plan item matched "${input.target}".`);
6950
+ return p;
6951
+ }
6952
+ return next;
6747
6953
  }
6748
- let updated = p;
6749
- for (const item of template.items) {
6750
- ({ plan: updated } = addPlanItem(updated, item.title, item.details));
6954
+ case "promote": {
6955
+ if (!input.target) {
6956
+ early = mkResult(p, false, `${input.action} requires \`target\` (id|index|substring).`);
6957
+ return p;
6958
+ }
6959
+ const derived = deriveTodosFromPlanItem(p, input.target, input.subtasks);
6960
+ if (!derived) {
6961
+ early = mkResult(p, false, `No plan item matched "${input.target}".`);
6962
+ return p;
6963
+ }
6964
+ ctx.state.replaceTodos(derived.todos);
6965
+ early = mkResult(
6966
+ derived.plan,
6967
+ true,
6968
+ `${input.action} ok \u2014 ${derived.todos.length} todo(s) created.`,
6969
+ derived.todos
6970
+ );
6971
+ return derived.plan;
6751
6972
  }
6752
- early = mkResult(
6753
- updated,
6754
- true,
6755
- `Applied template "${template.name}" \u2014 ${template.items.length} items added.`
6756
- );
6757
- return updated;
6758
- }
6759
- case "clear":
6760
- return clearPlan(p);
6761
- case "taskify": {
6762
- if (!input.target) {
6763
- early = mkResult(p, false, "taskify requires `target` (plan item id|index|substring).");
6764
- return p;
6973
+ case "template_use": {
6974
+ const templateName = input.template?.trim();
6975
+ if (!templateName) {
6976
+ early = mkResult(p, false, "template_use requires `template` name.");
6977
+ return p;
6978
+ }
6979
+ const template = getPlanTemplate(templateName);
6980
+ if (!template) {
6981
+ early = mkResult(p, false, `Unknown template "${templateName}".`);
6982
+ return p;
6983
+ }
6984
+ let updated = p;
6985
+ for (const item of template.items) {
6986
+ ({ plan: updated } = addPlanItem(updated, item.title, item.details));
6987
+ }
6988
+ early = mkResult(
6989
+ updated,
6990
+ true,
6991
+ `Applied template "${template.name}" \u2014 ${template.items.length} items added.`
6992
+ );
6993
+ return updated;
6765
6994
  }
6766
- let itemIdx = -1;
6767
- const asNum = Number.parseInt(input.target, 10);
6768
- if (!Number.isNaN(asNum) && asNum >= 1 && asNum <= p.items.length) {
6769
- itemIdx = asNum - 1;
6770
- } else {
6771
- itemIdx = p.items.findIndex((it) => it.id === input.target);
6772
- if (itemIdx === -1) {
6773
- const lower = input.target.toLowerCase();
6774
- itemIdx = p.items.findIndex((it) => it.title.toLowerCase().includes(lower));
6995
+ case "clear":
6996
+ return clearPlan(p);
6997
+ case "taskify": {
6998
+ if (!input.target) {
6999
+ early = mkResult(p, false, "taskify requires `target` (plan item id|index|substring).");
7000
+ return p;
6775
7001
  }
7002
+ let itemIdx = -1;
7003
+ const asNum = Number.parseInt(input.target, 10);
7004
+ if (!Number.isNaN(asNum) && asNum >= 1 && asNum <= p.items.length) {
7005
+ itemIdx = asNum - 1;
7006
+ } else {
7007
+ itemIdx = p.items.findIndex((it) => it.id === input.target);
7008
+ if (itemIdx === -1) {
7009
+ const lower = input.target.toLowerCase();
7010
+ itemIdx = p.items.findIndex((it) => it.title.toLowerCase().includes(lower));
7011
+ }
7012
+ }
7013
+ if (itemIdx === -1 || !p.items[itemIdx]) {
7014
+ early = mkResult(p, false, `No plan item matched "${input.target}".`);
7015
+ return p;
7016
+ }
7017
+ const item = p.items[itemIdx];
7018
+ taskifyMeta.title = item.title;
7019
+ taskifyMeta.details = item.details ?? "";
7020
+ didTaskify = true;
7021
+ break;
6776
7022
  }
6777
- if (itemIdx === -1 || !p.items[itemIdx]) {
6778
- early = mkResult(p, false, `No plan item matched "${input.target}".`);
7023
+ default:
7024
+ early = mkResult(p, false, `Unknown action "${input.action}".`);
6779
7025
  return p;
6780
- }
6781
- const item = p.items[itemIdx];
6782
- taskifyMeta.title = item.title;
6783
- taskifyMeta.details = item.details ?? "";
6784
- didTaskify = true;
6785
- break;
6786
7026
  }
6787
- default:
6788
- early = mkResult(p, false, `Unknown action "${input.action}".`);
6789
- return p;
6790
- }
6791
- return p;
6792
- });
7027
+ return p;
7028
+ });
7029
+ } catch (err) {
7030
+ return {
7031
+ ok: false,
7032
+ message: `Plan change not saved \u2014 ${err instanceof Error ? err.message : String(err)}`,
7033
+ plan: "",
7034
+ count: 0,
7035
+ open: 0
7036
+ };
7037
+ }
6793
7038
  if (early) return early;
6794
7039
  if (didTaskify) {
6795
- const taskPath = ctx.meta["task.path"];
6796
- if (typeof taskPath !== "string" || !taskPath) {
7040
+ const taskPathRaw = ctx.meta["task.path"];
7041
+ if (typeof taskPathRaw !== "string" || !taskPathRaw) {
6797
7042
  return mkResult(plan, false, "Task storage path not configured \u2014 cannot taskify.");
6798
7043
  }
6799
- const taskFile = await loadTasks(taskPath) ?? emptyTaskFile(sessionId);
7044
+ let taskPath = taskPathRaw;
7045
+ if (input.scope === "project") {
7046
+ const lastSep = Math.max(taskPath.lastIndexOf("/"), taskPath.lastIndexOf("\\"));
7047
+ taskPath = lastSep >= 0 ? taskPath.slice(0, lastSep + 1) + "backlog.tasks.json" : "backlog.tasks.json";
7048
+ }
6800
7049
  const now = (/* @__PURE__ */ new Date()).toISOString();
6801
- taskFile.tasks.push({
6802
- id: `task_${randomUUID()}`,
6803
- title: taskifyMeta.title,
6804
- description: taskifyMeta.details || void 0,
6805
- type: "feature",
6806
- priority: "medium",
6807
- status: "pending",
6808
- createdAt: now,
6809
- updatedAt: now
6810
- });
6811
- await saveTasks(taskPath, taskFile);
6812
- return mkResult(
6813
- plan,
6814
- true,
6815
- `taskify ok \u2014 added "${taskifyMeta.title}" to tasks.
7050
+ try {
7051
+ const taskFile = await mutateTasks(taskPath, sessionId, (f) => {
7052
+ f.tasks.push({
7053
+ id: `task_${randomUUID()}`,
7054
+ title: taskifyMeta.title,
7055
+ description: taskifyMeta.details || void 0,
7056
+ type: "feature",
7057
+ priority: "medium",
7058
+ status: "pending",
7059
+ createdAt: now,
7060
+ updatedAt: now
7061
+ });
7062
+ return f;
7063
+ });
7064
+ return mkResult(
7065
+ plan,
7066
+ true,
7067
+ `taskify ok \u2014 added "${taskifyMeta.title}" to tasks.
6816
7068
  ${formatTaskList(taskFile.tasks)}`
6817
- );
7069
+ );
7070
+ } catch (err) {
7071
+ return mkResult(plan, false, `taskify: task not saved \u2014 ${err instanceof Error ? err.message : String(err)}`);
7072
+ }
6818
7073
  }
6819
7074
  return mkResult(plan, true, `Plan ${input.action} ok.`);
6820
7075
  }
@@ -6840,6 +7095,7 @@ var readTool = {
6840
7095
  permission: "auto",
6841
7096
  mutating: false,
6842
7097
  capabilities: ["fs.read"],
7098
+ icon: "file",
6843
7099
  maxOutputBytes: 262144,
6844
7100
  timeoutMs: 5e3,
6845
7101
  inputSchema: {
@@ -6856,6 +7112,11 @@ var readTool = {
6856
7112
  limit: {
6857
7113
  type: "integer",
6858
7114
  description: "Maximum number of lines to return (default is 2000)."
7115
+ },
7116
+ mode: {
7117
+ type: "string",
7118
+ enum: ["content", "summary"],
7119
+ description: "Return full line-numbered content (default) or a compact file summary with imports/exports/symbols."
6859
7120
  }
6860
7121
  },
6861
7122
  required: ["path"]
@@ -6869,14 +7130,27 @@ var readTool = {
6869
7130
  } catch (err) {
6870
7131
  const code = err.code;
6871
7132
  if (code === "ENOENT") throw new Error(`read: file not found "${input.path}"`);
6872
- throw new Error(
6873
- `read: failed to stat "${input.path}": ${toErrorMessage(err)}`
6874
- );
7133
+ throw new Error(`read: failed to stat "${input.path}": ${toErrorMessage(err)}`);
6875
7134
  }
6876
7135
  if (!stat11.isFile()) throw new Error(`read: "${input.path}" is not a regular file`);
6877
7136
  if (stat11.size > MAX_BYTES2) {
6878
7137
  throw new Error(`read: file too large (${stat11.size} bytes, limit ${MAX_BYTES2})`);
6879
7138
  }
7139
+ const offset = Math.max(1, input.offset ?? 1);
7140
+ const limit = Math.max(0, Math.min(input.limit ?? 2e3, 5e3));
7141
+ const prior = getReadRangeRecord(ctx, absPath);
7142
+ const requestedEnd = prior ? Math.min(offset + limit - 1, prior.totalLines) : offset + limit - 1;
7143
+ if (input.mode !== "summary" && limit > 0 && prior && coversRange(prior, stat11.mtimeMs, offset, requestedEnd)) {
7144
+ ctx.recordRead(absPath, stat11.mtimeMs);
7145
+ return {
7146
+ text: `[unchanged since previous read: "${input.path}" mtime=${Math.round(stat11.mtimeMs)}; requested lines ${offset}-${requestedEnd} were already shown. Use offset/limit for a new range if needed.]`,
7147
+ total_lines: prior.totalLines,
7148
+ encoding: "utf8",
7149
+ truncated: requestedEnd < prior.totalLines,
7150
+ cached: true,
7151
+ note: "Repeated read suppressed to save tokens."
7152
+ };
7153
+ }
6880
7154
  const buf = await fs14.readFile(absPath);
6881
7155
  if (isBinaryBuffer(buf)) {
6882
7156
  throw new Error(`read: "${input.path}" appears to be binary`);
@@ -6884,17 +7158,38 @@ var readTool = {
6884
7158
  const text = buf.toString("utf8");
6885
7159
  const allLines = text.split(/\r\n|\r|\n/);
6886
7160
  const total = allLines.length;
6887
- const offset = Math.max(1, input.offset ?? 1);
6888
- const limit = Math.max(0, Math.min(input.limit ?? 2e3, 5e3));
7161
+ if (input.mode === "summary") {
7162
+ ctx.recordRead(absPath, stat11.mtimeMs);
7163
+ rememberReadRange(ctx, absPath, stat11.mtimeMs, total, 1, Math.min(total, 200));
7164
+ return {
7165
+ text: summarizeFile(input.path, stat11.size, allLines),
7166
+ total_lines: total,
7167
+ encoding: "utf8",
7168
+ truncated: total > 200,
7169
+ note: "Summary mode returned compact structure instead of full file content."
7170
+ };
7171
+ }
6889
7172
  if (limit === 0) {
6890
7173
  ctx.recordRead(absPath, stat11.mtimeMs);
7174
+ rememberReadRange(ctx, absPath, stat11.mtimeMs, total, 1, 0);
6891
7175
  return { text: "", total_lines: total, encoding: "utf8", truncated: total > 0 };
6892
7176
  }
7177
+ if (offset > total) {
7178
+ ctx.recordRead(absPath, stat11.mtimeMs);
7179
+ rememberReadRange(ctx, absPath, stat11.mtimeMs, total, total + 1, total + 1);
7180
+ return {
7181
+ text: `[offset ${offset} is past end of file "${input.path}" \u2014 file has ${total} line(s). Do not retry this offset.]`,
7182
+ total_lines: total,
7183
+ encoding: "utf8",
7184
+ truncated: false
7185
+ };
7186
+ }
6893
7187
  const slice = allLines.slice(offset - 1, offset - 1 + limit);
6894
7188
  const truncated = offset - 1 + slice.length < total;
6895
7189
  const width = String(offset + slice.length - 1).length;
6896
7190
  const numbered = slice.map((line, i) => `${String(offset + i).padStart(width, " ")}\u2192${line}`).join("\n");
6897
7191
  ctx.recordRead(absPath, stat11.mtimeMs);
7192
+ rememberReadRange(ctx, absPath, stat11.mtimeMs, total, offset, offset + slice.length - 1);
6898
7193
  return {
6899
7194
  text: numbered,
6900
7195
  total_lines: total,
@@ -6903,6 +7198,62 @@ var readTool = {
6903
7198
  };
6904
7199
  }
6905
7200
  };
7201
+ var READ_RANGES_META_KEY = "tools.read.ranges.v1";
7202
+ function getReadRanges(ctx) {
7203
+ const existing = ctx.meta[READ_RANGES_META_KEY];
7204
+ if (existing && typeof existing === "object" && !Array.isArray(existing)) {
7205
+ return existing;
7206
+ }
7207
+ const next = {};
7208
+ ctx.meta[READ_RANGES_META_KEY] = next;
7209
+ return next;
7210
+ }
7211
+ function getReadRangeRecord(ctx, absPath) {
7212
+ return getReadRanges(ctx)[absPath];
7213
+ }
7214
+ function rememberReadRange(ctx, absPath, mtimeMs, totalLines, start, end) {
7215
+ if (end < start) return;
7216
+ const ranges = getReadRanges(ctx);
7217
+ const prior = ranges[absPath];
7218
+ const nextRanges = prior && Math.abs(prior.mtimeMs - mtimeMs) <= 1 ? prior.ranges.slice() : [];
7219
+ nextRanges.push({ start, end });
7220
+ ranges[absPath] = {
7221
+ mtimeMs,
7222
+ totalLines,
7223
+ ranges: mergeRanges(nextRanges)
7224
+ };
7225
+ }
7226
+ function coversRange(record, mtimeMs, start, end) {
7227
+ if (Math.abs(record.mtimeMs - mtimeMs) > 1) return false;
7228
+ return record.ranges.some((range) => range.start <= start && range.end >= end);
7229
+ }
7230
+ function mergeRanges(ranges) {
7231
+ const sorted = ranges.slice().sort((a, b) => a.start - b.start);
7232
+ const merged = [];
7233
+ for (const range of sorted) {
7234
+ const last = merged[merged.length - 1];
7235
+ if (!last || range.start > last.end + 1) {
7236
+ merged.push({ ...range });
7237
+ continue;
7238
+ }
7239
+ last.end = Math.max(last.end, range.end);
7240
+ }
7241
+ return merged;
7242
+ }
7243
+ function summarizeFile(filePath, bytes, lines) {
7244
+ const interesting = lines.map((line, index) => ({ line: line.trim(), number: index + 1 })).filter(
7245
+ ({ line }) => /^(import\s|export\s|class\s|interface\s|type\s|function\s|const\s+\w+\s*=|let\s+\w+\s*=|var\s+\w+\s*=|def\s+|async\s+function\s)/.test(
7246
+ line
7247
+ )
7248
+ ).slice(0, 80).map(({ line, number }) => `${number}: ${line}`);
7249
+ return [
7250
+ `summary: ${filePath}`,
7251
+ `bytes=${bytes}`,
7252
+ `total_lines=${lines.length}`,
7253
+ interesting.length > 0 ? `symbols/imports:
7254
+ ${interesting.join("\n")}` : "symbols/imports: (none detected)"
7255
+ ].join("\n");
7256
+ }
6906
7257
  var DEFAULT_IGNORE4 = ["node_modules", ".git", "dist", "build", ".next", "coverage"];
6907
7258
  var replaceTool = {
6908
7259
  name: "replace",
@@ -6912,6 +7263,7 @@ var replaceTool = {
6912
7263
  permission: "confirm",
6913
7264
  mutating: true,
6914
7265
  capabilities: ["fs.write"],
7266
+ icon: "edit",
6915
7267
  timeoutMs: 3e4,
6916
7268
  inputSchema: {
6917
7269
  type: "object",
@@ -7203,6 +7555,7 @@ var scaffoldTool = {
7203
7555
  permission: "confirm",
7204
7556
  mutating: true,
7205
7557
  capabilities: ["fs.write.outside-project", "fs.write"],
7558
+ icon: "scaffold",
7206
7559
  timeoutMs: 3e4,
7207
7560
  inputSchema: {
7208
7561
  type: "object",
@@ -7298,6 +7651,7 @@ var searchTool = {
7298
7651
  permission: "confirm",
7299
7652
  mutating: false,
7300
7653
  capabilities: ["net.outbound"],
7654
+ icon: "search",
7301
7655
  timeoutMs: TIMEOUT_MS3,
7302
7656
  inputSchema: {
7303
7657
  type: "object",
@@ -7511,6 +7865,7 @@ var setWorkingDirTool = {
7511
7865
  permission: "confirm",
7512
7866
  mutating: true,
7513
7867
  capabilities: ["fs.read"],
7868
+ icon: "settings",
7514
7869
  timeoutMs: 5e3,
7515
7870
  inputSchema: {
7516
7871
  type: "object",
@@ -7571,11 +7926,12 @@ function findTaskIndex(tasks, query2) {
7571
7926
  var taskTool = {
7572
7927
  name: "task",
7573
7928
  category: "Session",
7574
- description: "Manage structured work items with dependencies, types, and priorities. Use this for complex, multi-step work where tasks have ordering constraints. Unlike `todo` (flat, tactical), `task` supports typed work (feature/bugfix/refactor/etc.), dependencies between items, priority ranking, and agent assignment. The task list persists across session resumes.",
7575
- usageHint: 'USE FOR STRUCTURED WORK:\n- `action: "replace"` \u2014 set the complete task list (tasks ordered by priority)\n- `action: "add"` \u2014 append a single task\n- `action: "status"` \u2014 update a task\'s status (e.g. pending\u2192in_progress, in_progress\u2192completed)\n- `action: "show"` \u2014 view current tasks without changing them\n- `action: "promote"` \u2014 convert a task into actionable todo items via `target` (id|index|substring)\n- `action: "planify"` \u2014 promote a task to a plan item (strategic level) via `target` (id|index|substring)\n\nTask fields:\n- `dependsOn`: list of task IDs this one waits for\n- `type`: "feature" | "bugfix" | "refactor" | "docs" | "test" | "chore"\n- `priority`: "critical" | "high" | "medium" | "low"\n- `assignee`: agent/subagent name (e.g. "bug-hunter", "refactor-planner")\n- `estimateHours`: rough time estimate',
7929
+ description: 'Manage session-persistent structured work items with dependencies, types, and priorities. Unlike `todo` (flat, tactical), `task` supports typed work (feature/bugfix/refactor/etc.), dependencies between items, priority ranking, and agent assignment. Tasks are written to disk and survive session resumes. By default they are isolated to this session; use `scope: "project"` to store tasks in a shared project-level file visible to all sessions.',
7930
+ usageHint: 'USE FOR STRUCTURED WORK:\n- `action: "replace"` \u2014 set the complete task list (tasks ordered by priority)\n- `action: "add"` \u2014 append a single task\n- `action: "status"` \u2014 update a task\'s status (e.g. pending\u2192in_progress, in_progress\u2192completed)\n- `action: "show"` \u2014 view current tasks without changing them\n- `action: "promote"` \u2014 convert a task into actionable todo items via `target` (id|index|substring)\n- `action: "planify"` \u2014 promote a task to a plan item (strategic level) via `target` (id|index|substring)\n\nTask fields:\n- `dependsOn`: list of task IDs this one waits for\n- `type`: "feature" | "bugfix" | "refactor" | "docs" | "test" | "chore"\n- `priority`: "critical" | "high" | "medium" | "low"\n- `assignee`: agent/subagent name (e.g. "bug-hunter", "refactor-planner")\n- `estimateHours`: rough time estimate\n- `scope`: "session" (default, isolated) or "project" (shared across sessions)',
7576
7931
  permission: "confirm",
7577
7932
  mutating: true,
7578
7933
  capabilities: ["fs.write"],
7934
+ icon: "task",
7579
7935
  timeoutMs: 2e3,
7580
7936
  inputSchema: {
7581
7937
  type: "object",
@@ -7641,12 +7997,26 @@ var taskTool = {
7641
7997
  type: "array",
7642
7998
  items: { type: "string" },
7643
7999
  description: "Optional subtask titles for action=promote. Each becomes a pending todo."
8000
+ },
8001
+ scope: {
8002
+ type: "string",
8003
+ enum: ["session", "project"],
8004
+ description: 'Storage scope: "session" (default, isolated to this session) or "project" (shared across all sessions for this project).'
7644
8005
  }
7645
8006
  },
7646
8007
  required: ["action"]
7647
8008
  },
7648
8009
  async execute(input, ctx) {
7649
- const taskPath = ctx.meta["task.path"];
8010
+ const sessionTaskPath = ctx.meta["task.path"];
8011
+ let taskPath;
8012
+ if (input.scope === "project") {
8013
+ if (typeof sessionTaskPath === "string") {
8014
+ const lastSep = Math.max(sessionTaskPath.lastIndexOf("/"), sessionTaskPath.lastIndexOf("\\"));
8015
+ taskPath = lastSep >= 0 ? sessionTaskPath.slice(0, lastSep + 1) + "backlog.tasks.json" : "backlog.tasks.json";
8016
+ }
8017
+ } else {
8018
+ taskPath = sessionTaskPath;
8019
+ }
7650
8020
  if (typeof taskPath !== "string" || !taskPath) {
7651
8021
  return { ok: false, message: "Task storage path not configured.", count: 0, completed: 0, inProgress: 0 };
7652
8022
  }
@@ -7656,23 +8026,66 @@ var taskTool = {
7656
8026
  const planifyMeta = { title: "", details: "" };
7657
8027
  let didPlanify = false;
7658
8028
  let todosToReplace = null;
7659
- const file = await mutateTasks(taskPath, sessionId, async (f) => {
7660
- switch (input.action) {
7661
- case "show":
7662
- break;
7663
- case "replace": {
7664
- if (!Array.isArray(input.tasks)) {
7665
- early = { ok: false, message: "action=replace requires `tasks` array.", count: 0, completed: 0, inProgress: 0 };
7666
- return f;
8029
+ let file;
8030
+ try {
8031
+ file = await mutateTasks(taskPath, sessionId, async (f) => {
8032
+ switch (input.action) {
8033
+ case "show":
8034
+ break;
8035
+ case "replace": {
8036
+ if (!Array.isArray(input.tasks)) {
8037
+ early = { ok: false, message: "action=replace requires `tasks` array.", count: 0, completed: 0, inProgress: 0 };
8038
+ return f;
8039
+ }
8040
+ const newIds = new Set(input.tasks.map((t) => t.id));
8041
+ if (newIds.size !== input.tasks.length) {
8042
+ const seen = /* @__PURE__ */ new Set();
8043
+ const dupes = [...new Set(input.tasks.map((t) => t.id).filter((id) => seen.has(id) ? true : (seen.add(id), false)))];
8044
+ early = {
8045
+ ok: false,
8046
+ message: `action=replace has duplicate task IDs: ${dupes.join(", ")}. Each task id must be unique.`,
8047
+ count: 0,
8048
+ completed: 0,
8049
+ inProgress: 0
8050
+ };
8051
+ return f;
8052
+ }
8053
+ for (const t of input.tasks) {
8054
+ if (t.dependsOn && t.dependsOn.length > 0) {
8055
+ const missing = t.dependsOn.filter((d) => !newIds.has(d));
8056
+ if (missing.length > 0) {
8057
+ early = {
8058
+ ok: false,
8059
+ message: `dependsOn validation failed: task "${t.id}" references unknown IDs: ${missing.join(", ")}`,
8060
+ count: 0,
8061
+ completed: 0,
8062
+ inProgress: 0
8063
+ };
8064
+ return f;
8065
+ }
8066
+ }
8067
+ }
8068
+ const now = (/* @__PURE__ */ new Date()).toISOString();
8069
+ f.tasks = input.tasks.map((t) => ({
8070
+ ...t,
8071
+ createdAt: t.createdAt || now,
8072
+ updatedAt: now
8073
+ }));
8074
+ break;
7667
8075
  }
7668
- const newIds = new Set(input.tasks.map((t) => t.id));
7669
- for (const t of input.tasks) {
8076
+ case "add": {
8077
+ const t = input.task;
8078
+ if (!t || !t.title) {
8079
+ early = { ok: false, message: "action=add requires `task` with at least `title`.", count: 0, completed: 0, inProgress: 0 };
8080
+ return f;
8081
+ }
7670
8082
  if (t.dependsOn && t.dependsOn.length > 0) {
7671
- const missing = t.dependsOn.filter((d) => !newIds.has(d));
8083
+ const existingIds = new Set(f.tasks.map((e) => e.id));
8084
+ const missing = t.dependsOn.filter((d) => !existingIds.has(d));
7672
8085
  if (missing.length > 0) {
7673
8086
  early = {
7674
8087
  ok: false,
7675
- message: `dependsOn validation failed: task "${t.id}" references unknown IDs: ${missing.join(", ")}`,
8088
+ message: `dependsOn validation failed: unknown task IDs: ${missing.join(", ")}`,
7676
8089
  count: 0,
7677
8090
  completed: 0,
7678
8091
  inProgress: 0
@@ -7680,165 +8093,170 @@ var taskTool = {
7680
8093
  return f;
7681
8094
  }
7682
8095
  }
8096
+ const now = (/* @__PURE__ */ new Date()).toISOString();
8097
+ const newTask = {
8098
+ id: `task_${Date.now()}_${randomUUID().slice(0, 8)}`,
8099
+ title: t.title,
8100
+ description: t.description,
8101
+ type: t.type || "feature",
8102
+ priority: t.priority || "medium",
8103
+ status: t.status || "pending",
8104
+ dependsOn: t.dependsOn,
8105
+ assignee: t.assignee,
8106
+ estimateHours: t.estimateHours,
8107
+ tags: t.tags,
8108
+ createdAt: now,
8109
+ updatedAt: now
8110
+ };
8111
+ f.tasks.push(newTask);
8112
+ break;
7683
8113
  }
7684
- const now = (/* @__PURE__ */ new Date()).toISOString();
7685
- f.tasks = input.tasks.map((t) => ({
7686
- ...t,
7687
- createdAt: t.createdAt || now,
7688
- updatedAt: now
7689
- }));
7690
- break;
7691
- }
7692
- case "add": {
7693
- const t = input.task;
7694
- if (!t || !t.title) {
7695
- early = { ok: false, message: "action=add requires `task` with at least `title`.", count: 0, completed: 0, inProgress: 0 };
7696
- return f;
7697
- }
7698
- if (t.dependsOn && t.dependsOn.length > 0) {
7699
- const existingIds = new Set(f.tasks.map((e) => e.id));
7700
- const missing = t.dependsOn.filter((d) => !existingIds.has(d));
7701
- if (missing.length > 0) {
7702
- early = {
7703
- ok: false,
7704
- message: `dependsOn validation failed: unknown task IDs: ${missing.join(", ")}`,
7705
- count: 0,
7706
- completed: 0,
7707
- inProgress: 0
7708
- };
8114
+ case "status": {
8115
+ if (!input.id || !input.status) {
8116
+ early = { ok: false, message: "action=status requires `id` and `status`.", count: 0, completed: 0, inProgress: 0 };
7709
8117
  return f;
7710
8118
  }
8119
+ const task = f.tasks.find((t) => t.id === input.id);
8120
+ if (!task) {
8121
+ early = { ok: false, message: `Task "${input.id}" not found.`, count: 0, completed: 0, inProgress: 0 };
8122
+ return f;
8123
+ }
8124
+ task.status = input.status;
8125
+ task.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
8126
+ break;
7711
8127
  }
7712
- const now = (/* @__PURE__ */ new Date()).toISOString();
7713
- const newTask = {
7714
- id: `task_${Date.now()}_${randomUUID().slice(0, 8)}`,
7715
- title: t.title,
7716
- description: t.description,
7717
- type: t.type || "feature",
7718
- priority: t.priority || "medium",
7719
- status: t.status || "pending",
7720
- dependsOn: t.dependsOn,
7721
- assignee: t.assignee,
7722
- estimateHours: t.estimateHours,
7723
- tags: t.tags,
7724
- createdAt: now,
7725
- updatedAt: now
7726
- };
7727
- f.tasks.push(newTask);
7728
- break;
7729
- }
7730
- case "status": {
7731
- if (!input.id || !input.status) {
7732
- early = { ok: false, message: "action=status requires `id` and `status`.", count: 0, completed: 0, inProgress: 0 };
7733
- return f;
7734
- }
7735
- const task = f.tasks.find((t) => t.id === input.id);
7736
- if (!task) {
7737
- early = { ok: false, message: `Task "${input.id}" not found.`, count: 0, completed: 0, inProgress: 0 };
7738
- return f;
7739
- }
7740
- task.status = input.status;
7741
- task.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
7742
- break;
7743
- }
7744
- case "promote": {
7745
- const target = input.target?.trim();
7746
- if (!target) {
7747
- early = { ok: false, message: "action=promote requires `target` (task id, index, or title substring).", count: 0, completed: 0, inProgress: 0 };
7748
- return f;
7749
- }
7750
- const idx = findTaskIndex(f.tasks, target);
7751
- if (idx === -1) {
7752
- early = { ok: false, message: `No task matched "${target}".`, count: 0, completed: 0, inProgress: 0 };
7753
- return f;
7754
- }
7755
- const match = f.tasks[idx];
7756
- if (!match) {
7757
- early = { ok: false, message: `No task matched "${target}".`, count: 0, completed: 0, inProgress: 0 };
7758
- return f;
7759
- }
7760
- if (match.status !== "completed" && match.status !== "failed") {
7761
- match.status = "in_progress";
7762
- match.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
7763
- }
7764
- const todos = [];
7765
- const ts2 = Date.now();
7766
- todos.push({
7767
- id: `todo_${ts2}_task`,
7768
- content: match.title,
7769
- status: "in_progress",
7770
- activeForm: match.title,
7771
- promotedFromTask: match.id
7772
- });
7773
- if (match.description) {
8128
+ case "promote": {
8129
+ const target = input.target?.trim();
8130
+ if (!target) {
8131
+ early = { ok: false, message: "action=promote requires `target` (task id, index, or title substring).", count: 0, completed: 0, inProgress: 0 };
8132
+ return f;
8133
+ }
8134
+ const idx = findTaskIndex(f.tasks, target);
8135
+ if (idx === -1) {
8136
+ early = { ok: false, message: `No task matched "${target}".`, count: 0, completed: 0, inProgress: 0 };
8137
+ return f;
8138
+ }
8139
+ const match = f.tasks[idx];
8140
+ if (!match) {
8141
+ early = { ok: false, message: `No task matched "${target}".`, count: 0, completed: 0, inProgress: 0 };
8142
+ return f;
8143
+ }
8144
+ if (match.status !== "completed" && match.status !== "failed") {
8145
+ match.status = "in_progress";
8146
+ match.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
8147
+ }
8148
+ const todos = [];
8149
+ const ts2 = Date.now();
7774
8150
  todos.push({
7775
- id: `todo_${ts2}_${randomUUID().slice(0, 6)}`,
7776
- content: match.description.slice(0, 200),
7777
- status: "pending",
8151
+ id: `todo_${ts2}_task`,
8152
+ content: match.title,
8153
+ status: "in_progress",
8154
+ activeForm: match.title,
7778
8155
  promotedFromTask: match.id
7779
8156
  });
7780
- }
7781
- if (input.subtasks && input.subtasks.length > 0) {
7782
- for (const st of input.subtasks) {
8157
+ if (match.description) {
7783
8158
  todos.push({
7784
8159
  id: `todo_${ts2}_${randomUUID().slice(0, 6)}`,
7785
- content: st,
8160
+ content: match.description.slice(0, 200),
7786
8161
  status: "pending",
7787
8162
  promotedFromTask: match.id
7788
8163
  });
7789
8164
  }
8165
+ if (input.subtasks && input.subtasks.length > 0) {
8166
+ for (const st of input.subtasks) {
8167
+ todos.push({
8168
+ id: `todo_${ts2}_${randomUUID().slice(0, 6)}`,
8169
+ content: st,
8170
+ status: "pending",
8171
+ promotedFromTask: match.id
8172
+ });
8173
+ }
8174
+ }
8175
+ todosToReplace = todos;
8176
+ promoteMeta.count = todos.length;
8177
+ promoteMeta.title = match.title;
8178
+ break;
7790
8179
  }
7791
- todosToReplace = todos;
7792
- promoteMeta.count = todos.length;
7793
- promoteMeta.title = match.title;
7794
- break;
7795
- }
7796
- case "planify": {
7797
- const target = input.target?.trim();
7798
- if (!target) {
7799
- early = { ok: false, message: "action=planify requires `target` (task id, index, or title substring).", count: 0, completed: 0, inProgress: 0 };
7800
- return f;
7801
- }
7802
- const idx = findTaskIndex(f.tasks, target);
7803
- if (idx === -1) {
7804
- early = { ok: false, message: `No task matched "${target}".`, count: 0, completed: 0, inProgress: 0 };
7805
- return f;
8180
+ case "planify": {
8181
+ const target = input.target?.trim();
8182
+ if (!target) {
8183
+ early = { ok: false, message: "action=planify requires `target` (task id, index, or title substring).", count: 0, completed: 0, inProgress: 0 };
8184
+ return f;
8185
+ }
8186
+ const idx = findTaskIndex(f.tasks, target);
8187
+ if (idx === -1) {
8188
+ early = { ok: false, message: `No task matched "${target}".`, count: 0, completed: 0, inProgress: 0 };
8189
+ return f;
8190
+ }
8191
+ const match = f.tasks[idx];
8192
+ if (!match) {
8193
+ early = { ok: false, message: `No task matched "${target}".`, count: 0, completed: 0, inProgress: 0 };
8194
+ return f;
8195
+ }
8196
+ planifyMeta.title = match.title;
8197
+ planifyMeta.details = match.description ?? "";
8198
+ didPlanify = true;
8199
+ break;
7806
8200
  }
7807
- const match = f.tasks[idx];
7808
- if (!match) {
7809
- early = { ok: false, message: `No task matched "${target}".`, count: 0, completed: 0, inProgress: 0 };
8201
+ default:
8202
+ early = { ok: false, message: `Unknown action "${input.action}". Use replace | add | status | show | promote | planify.`, count: 0, completed: 0, inProgress: 0 };
7810
8203
  return f;
7811
- }
7812
- planifyMeta.title = match.title;
7813
- planifyMeta.details = match.description ?? "";
7814
- didPlanify = true;
7815
- break;
7816
8204
  }
7817
- default:
7818
- early = { ok: false, message: `Unknown action "${input.action}". Use replace | add | status | show | promote | planify.`, count: 0, completed: 0, inProgress: 0 };
7819
- return f;
7820
- }
7821
- return f;
7822
- });
8205
+ return f;
8206
+ });
8207
+ } catch (err) {
8208
+ return {
8209
+ ok: false,
8210
+ message: `Task change not saved \u2014 ${err instanceof Error ? err.message : String(err)}`,
8211
+ count: 0,
8212
+ completed: 0,
8213
+ inProgress: 0
8214
+ };
8215
+ }
7823
8216
  if (todosToReplace) ctx.state.replaceTodos(todosToReplace);
7824
8217
  if (early) return early;
7825
8218
  if (didPlanify) {
7826
8219
  const { title, details } = planifyMeta;
7827
- const planPath = ctx.meta["plan.path"];
7828
- if (typeof planPath === "string" && planPath) {
7829
- const planCfg = await loadPlan(planPath) ?? emptyPlan(sessionId);
7830
- const { plan: updated } = addPlanItem(planCfg, title, details || void 0);
7831
- await savePlan(planPath, updated);
8220
+ const planPathRaw = ctx.meta["plan.path"];
8221
+ const prog = computeTaskItemProgress(file.tasks);
8222
+ if (typeof planPathRaw === "string" && planPathRaw) {
8223
+ let planPath = planPathRaw;
8224
+ if (input.scope === "project") {
8225
+ const lastSep = Math.max(planPath.lastIndexOf("/"), planPath.lastIndexOf("\\"));
8226
+ planPath = lastSep >= 0 ? planPath.slice(0, lastSep + 1) + "backlog.plan.json" : "backlog.plan.json";
8227
+ }
8228
+ let formatted = "";
8229
+ try {
8230
+ await mutatePlan(planPath, sessionId, (pf) => {
8231
+ const { plan: updated } = addPlanItem(pf, title, details || void 0);
8232
+ formatted = formatPlan(updated);
8233
+ return updated;
8234
+ });
8235
+ } catch (err) {
8236
+ return {
8237
+ ok: false,
8238
+ message: `planify: plan not saved \u2014 ${err instanceof Error ? err.message : String(err)}`,
8239
+ count: file.tasks.length,
8240
+ completed: prog.completed,
8241
+ inProgress: prog.inProgress
8242
+ };
8243
+ }
7832
8244
  return {
7833
8245
  ok: true,
7834
8246
  message: `planify ok \u2014 added "${title}" to plan.
7835
- ${formatPlan(updated)}`,
8247
+ ${formatted}`,
7836
8248
  count: file.tasks.length,
7837
- completed: computeTaskItemProgress(file.tasks).completed,
7838
- inProgress: computeTaskItemProgress(file.tasks).inProgress
8249
+ completed: prog.completed,
8250
+ inProgress: prog.inProgress
7839
8251
  };
7840
8252
  }
7841
- return { ok: false, message: "Plan storage path not configured \u2014 cannot planify.", count: 0, completed: 0, inProgress: 0 };
8253
+ return {
8254
+ ok: false,
8255
+ message: "Plan storage path not configured \u2014 cannot planify.",
8256
+ count: file.tasks.length,
8257
+ completed: prog.completed,
8258
+ inProgress: prog.inProgress
8259
+ };
7842
8260
  }
7843
8261
  const p = computeTaskItemProgress(file.tasks);
7844
8262
  const summary = promoteMeta.count > 0 ? `promote ok \u2014 ${promoteMeta.count} todo(s) created from "${promoteMeta.title}".
@@ -7859,6 +8277,7 @@ var testTool = {
7859
8277
  usageHint: "ESSENTIAL BEFORE CONSIDERING WORK DONE:\n\n- Use `files` or `grep` to run only relevant tests during development.\n- `coverage: true` is useful when working on critical paths.\nRun tests frequently. A clean test run is usually required before the task can be considered complete.",
7860
8278
  permission: "confirm",
7861
8279
  mutating: false,
8280
+ icon: "test",
7862
8281
  timeoutMs: 12e4,
7863
8282
  capabilities: ["shell.restricted"],
7864
8283
  inputSchema: {
@@ -8018,6 +8437,7 @@ var todoTool = {
8018
8437
  // mutates only conversation state (ctx.todos), not external state — no confirmation needed
8019
8438
  timeoutMs: 1e3,
8020
8439
  capabilities: ["session.todo"],
8440
+ icon: "todo",
8021
8441
  inputSchema: {
8022
8442
  type: "object",
8023
8443
  properties: {
@@ -8126,6 +8546,7 @@ var toolHelpTool = {
8126
8546
  mutating: false,
8127
8547
  timeoutMs: 5e3,
8128
8548
  capabilities: ["tool.meta"],
8549
+ icon: "meta",
8129
8550
  inputSchema: {
8130
8551
  type: "object",
8131
8552
  properties: {
@@ -8249,6 +8670,7 @@ var toolSearchTool = {
8249
8670
  mutating: false,
8250
8671
  timeoutMs: 1e3,
8251
8672
  capabilities: ["tool.meta"],
8673
+ icon: "meta",
8252
8674
  inputSchema: {
8253
8675
  type: "object",
8254
8676
  properties: {
@@ -8328,6 +8750,7 @@ var toolUseTool = {
8328
8750
  mutating: true,
8329
8751
  timeoutMs: 6e4,
8330
8752
  capabilities: ["tool.mutate.any"],
8753
+ icon: "meta",
8331
8754
  inputSchema: {
8332
8755
  type: "object",
8333
8756
  properties: {
@@ -8408,6 +8831,7 @@ var treeTool = {
8408
8831
  permission: "auto",
8409
8832
  mutating: false,
8410
8833
  capabilities: ["fs.read"],
8834
+ icon: "tree",
8411
8835
  timeoutMs: 15e3,
8412
8836
  inputSchema: {
8413
8837
  type: "object",
@@ -8573,6 +8997,7 @@ var typecheckTool = {
8573
8997
  mutating: false,
8574
8998
  timeoutMs: 12e4,
8575
8999
  capabilities: ["shell.restricted"],
9000
+ icon: "code",
8576
9001
  inputSchema: {
8577
9002
  type: "object",
8578
9003
  properties: {
@@ -8661,6 +9086,7 @@ var writeTool = {
8661
9086
  mutating: true,
8662
9087
  timeoutMs: 5e3,
8663
9088
  capabilities: ["fs.write"],
9089
+ icon: "file",
8664
9090
  inputSchema: {
8665
9091
  type: "object",
8666
9092
  properties: {