reasonix 0.4.16 → 0.4.19

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.
package/dist/index.js CHANGED
@@ -499,9 +499,25 @@ function setByPath(target, path, value) {
499
499
  var ToolRegistry = class {
500
500
  _tools = /* @__PURE__ */ new Map();
501
501
  _autoFlatten;
502
+ /**
503
+ * When true, `dispatch` refuses any tool whose `readOnly` flag isn't
504
+ * set (and whose `readOnlyCheck` doesn't pass on the specific args).
505
+ * Drives `reasonix code`'s Plan Mode — the model can still explore
506
+ * via read tools but its writes and non-allowlisted shell calls are
507
+ * bounced until the user approves a submitted plan.
508
+ */
509
+ _planMode = false;
502
510
  constructor(opts = {}) {
503
511
  this._autoFlatten = opts.autoFlatten !== false;
504
512
  }
513
+ /** Enable / disable plan-mode enforcement at dispatch. */
514
+ setPlanMode(on) {
515
+ this._planMode = Boolean(on);
516
+ }
517
+ /** True when the registry is currently refusing non-readonly calls. */
518
+ get planMode() {
519
+ return this._planMode;
520
+ }
505
521
  register(def) {
506
522
  if (!def.name) throw new Error("tool requires a name");
507
523
  const internal = { ...def };
@@ -553,16 +569,38 @@ var ToolRegistry = class {
553
569
  if (tool.flatSchema && args && typeof args === "object" && hasDotKey(args)) {
554
570
  args = nestArguments(args);
555
571
  }
572
+ if (this._planMode && !isReadOnlyCall(tool, args)) {
573
+ return JSON.stringify({
574
+ error: `${name}: unavailable in plan mode \u2014 this is a read-only exploration phase. Use read_file / list_directory / search_files / directory_tree / web_search / allowlisted shell commands to investigate. Call submit_plan with your proposed plan when you're ready for the user's review.`
575
+ });
576
+ }
556
577
  try {
557
578
  const result = await tool.fn(args, { signal: opts.signal });
558
579
  return typeof result === "string" ? result : JSON.stringify(result);
559
580
  } catch (err) {
581
+ const e = err;
582
+ if (typeof e.toToolResult === "function") {
583
+ try {
584
+ return JSON.stringify(e.toToolResult());
585
+ } catch {
586
+ }
587
+ }
560
588
  return JSON.stringify({
561
- error: `${err.name}: ${err.message}`
589
+ error: `${e.name}: ${e.message}`
562
590
  });
563
591
  }
564
592
  }
565
593
  };
594
+ function isReadOnlyCall(tool, args) {
595
+ if (tool.readOnlyCheck) {
596
+ try {
597
+ return Boolean(tool.readOnlyCheck(args));
598
+ } catch {
599
+ return false;
600
+ }
601
+ }
602
+ return tool.readOnly === true;
603
+ }
566
604
  function hasDotKey(obj) {
567
605
  for (const k of Object.keys(obj)) {
568
606
  if (k.includes(".")) return true;
@@ -949,6 +987,16 @@ var ToolCallRepair = class {
949
987
  this.opts = opts;
950
988
  this.storm = new StormBreaker(opts.stormWindow ?? 6, opts.stormThreshold ?? 3);
951
989
  }
990
+ /**
991
+ * Drop the StormBreaker's sliding window of recent (name, args)
992
+ * signatures. Called at the start of every user turn — a fresh user
993
+ * message is a new intent, so carrying old repetition state into it
994
+ * would turn a valid "try again with different input" flow into a
995
+ * false-positive block.
996
+ */
997
+ resetStorm() {
998
+ this.storm.reset();
999
+ }
952
1000
  process(declaredCalls, reasoningContent, content = null) {
953
1001
  const report = {
954
1002
  scavenged: 0,
@@ -1401,6 +1449,7 @@ var CacheFirstLoop = class {
1401
1449
  async *step(userInput) {
1402
1450
  this._turn++;
1403
1451
  this.scratch.reset();
1452
+ this.repair.resetStorm();
1404
1453
  this._turnAbort = new AbortController();
1405
1454
  const signal = this._turnAbort.signal;
1406
1455
  let pendingUser = userInput;
@@ -1624,6 +1673,16 @@ var CacheFirstLoop = class {
1624
1673
  repair: report,
1625
1674
  branch: branchSummary
1626
1675
  };
1676
+ if (report.stormsBroken > 0) {
1677
+ const noteTail = report.notes.length ? ` \u2014 ${report.notes[report.notes.length - 1]}` : "";
1678
+ const allSuppressed = repairedCalls.length === 0 && toolCalls.length > 0;
1679
+ const phrase = allSuppressed ? `stopped the model from calling the same tool with identical args repeatedly (all ${toolCalls.length} call(s) this turn were already in the recent-repeat window). Likely a stuck retry \u2014 reword your instruction, rule out the underlying blocker, or try /retry after fixing it` : `suppressed ${report.stormsBroken} repeat tool call(s) that had fired 3+ times with identical args in a sliding window`;
1680
+ yield {
1681
+ turn: this._turn,
1682
+ role: "warning",
1683
+ content: `${phrase}${noteTail}`
1684
+ };
1685
+ }
1627
1686
  if (repairedCalls.length === 0) {
1628
1687
  yield { turn: this._turn, role: "done", content: assistantContent };
1629
1688
  return;
@@ -1848,6 +1907,49 @@ function formatLoopError(err) {
1848
1907
  return msg;
1849
1908
  }
1850
1909
 
1910
+ // src/project-memory.ts
1911
+ import { existsSync as existsSync2, readFileSync as readFileSync2 } from "fs";
1912
+ import { join as join2 } from "path";
1913
+ var PROJECT_MEMORY_FILE = "REASONIX.md";
1914
+ var PROJECT_MEMORY_MAX_CHARS = 8e3;
1915
+ function readProjectMemory(rootDir) {
1916
+ const path = join2(rootDir, PROJECT_MEMORY_FILE);
1917
+ if (!existsSync2(path)) return null;
1918
+ let raw;
1919
+ try {
1920
+ raw = readFileSync2(path, "utf8");
1921
+ } catch {
1922
+ return null;
1923
+ }
1924
+ const trimmed = raw.trim();
1925
+ if (!trimmed) return null;
1926
+ const originalChars = trimmed.length;
1927
+ const truncated = originalChars > PROJECT_MEMORY_MAX_CHARS;
1928
+ const content = truncated ? `${trimmed.slice(0, PROJECT_MEMORY_MAX_CHARS)}
1929
+ \u2026 (truncated ${originalChars - PROJECT_MEMORY_MAX_CHARS} chars)` : trimmed;
1930
+ return { path, content, originalChars, truncated };
1931
+ }
1932
+ function memoryEnabled() {
1933
+ const env = process.env.REASONIX_MEMORY;
1934
+ if (env === "off" || env === "false" || env === "0") return false;
1935
+ return true;
1936
+ }
1937
+ function applyProjectMemory(basePrompt, rootDir) {
1938
+ if (!memoryEnabled()) return basePrompt;
1939
+ const mem = readProjectMemory(rootDir);
1940
+ if (!mem) return basePrompt;
1941
+ return `${basePrompt}
1942
+
1943
+ # Project memory (REASONIX.md)
1944
+
1945
+ The user pinned these notes about this project \u2014 treat them as authoritative context for every turn:
1946
+
1947
+ \`\`\`
1948
+ ${mem.content}
1949
+ \`\`\`
1950
+ `;
1951
+ }
1952
+
1851
1953
  // src/tools/filesystem.ts
1852
1954
  import { promises as fs } from "fs";
1853
1955
  import * as pathMod from "path";
@@ -1873,6 +1975,7 @@ function registerFilesystemTools(registry, opts) {
1873
1975
  registry.register({
1874
1976
  name: "read_file",
1875
1977
  description: "Read a file under the sandbox root. Returns the full contents (truncated with a notice if larger than the per-call cap). Paths may be relative to the root or absolute-under-root.",
1978
+ readOnly: true,
1876
1979
  parameters: {
1877
1980
  type: "object",
1878
1981
  properties: {
@@ -1910,6 +2013,7 @@ function registerFilesystemTools(registry, opts) {
1910
2013
  registry.register({
1911
2014
  name: "list_directory",
1912
2015
  description: "List entries in a directory under the sandbox root. Returns one line per entry, marking directories with a trailing slash. Not recursive \u2014 use directory_tree for that.",
2016
+ readOnly: true,
1913
2017
  parameters: {
1914
2018
  type: "object",
1915
2019
  properties: {
@@ -1929,6 +2033,7 @@ function registerFilesystemTools(registry, opts) {
1929
2033
  registry.register({
1930
2034
  name: "directory_tree",
1931
2035
  description: "Recursively list entries in a directory. Shows indented tree structure with directories marked '/'. Caps output so a huge tree doesn't drown the context.",
2036
+ readOnly: true,
1932
2037
  parameters: {
1933
2038
  type: "object",
1934
2039
  properties: {
@@ -1975,6 +2080,7 @@ function registerFilesystemTools(registry, opts) {
1975
2080
  registry.register({
1976
2081
  name: "search_files",
1977
2082
  description: "Find files whose NAME matches a substring or regex. Case-insensitive. Walks the directory recursively under the sandbox root. Returns one path per line.",
2083
+ readOnly: true,
1978
2084
  parameters: {
1979
2085
  type: "object",
1980
2086
  properties: {
@@ -2027,6 +2133,7 @@ function registerFilesystemTools(registry, opts) {
2027
2133
  registry.register({
2028
2134
  name: "get_file_info",
2029
2135
  description: "Stat a path under the sandbox root. Returns type (file|directory|symlink), size in bytes, mtime in ISO-8601.",
2136
+ readOnly: true,
2030
2137
  parameters: {
2031
2138
  type: "object",
2032
2139
  properties: {
@@ -2183,8 +2290,54 @@ function lineDiff(a, b) {
2183
2290
  return out;
2184
2291
  }
2185
2292
 
2293
+ // src/tools/plan.ts
2294
+ var PlanProposedError = class extends Error {
2295
+ plan;
2296
+ constructor(plan) {
2297
+ super(
2298
+ "PlanProposedError: plan submitted. STOP calling tools now \u2014 the TUI has shown the plan to the user. Wait for their next message; it will either approve (you'll then implement the plan), request a refinement (you should explore more and submit an updated plan), or cancel (drop the plan and ask what they want instead). Don't call any tools in the meantime."
2299
+ );
2300
+ this.name = "PlanProposedError";
2301
+ this.plan = plan;
2302
+ }
2303
+ /**
2304
+ * Structured tool-result shape. Consumed by the TUI to extract the
2305
+ * plan without regex-scraping the error message.
2306
+ */
2307
+ toToolResult() {
2308
+ return { error: `${this.name}: ${this.message}`, plan: this.plan };
2309
+ }
2310
+ };
2311
+ function registerPlanTool(registry, opts = {}) {
2312
+ registry.register({
2313
+ name: "submit_plan",
2314
+ description: "Submit a concrete plan to the user for review before executing. Use this for tasks that warrant a review gate \u2014 multi-file refactors, architecture changes, anything that would be expensive or confusing to undo. Skip it for small fixes (one-line typo, obvious bug with a clear fix) \u2014 just make the change. The user will either approve (you then implement it), ask for refinement, or cancel. If the user has already enabled /plan mode, writes are blocked at dispatch and you MUST use this. Write the plan as markdown with a one-line summary, a bulleted list of files to touch and what will change, and any risks or open questions.",
2315
+ readOnly: true,
2316
+ parameters: {
2317
+ type: "object",
2318
+ properties: {
2319
+ plan: {
2320
+ type: "string",
2321
+ description: "Markdown-formatted plan. Lead with a one-sentence summary. Then a file-by-file breakdown of what you'll change and why. Flag any risks or open questions at the end so the user can weigh in before you start."
2322
+ }
2323
+ },
2324
+ required: ["plan"]
2325
+ },
2326
+ fn: async (args) => {
2327
+ const plan = (args?.plan ?? "").trim();
2328
+ if (!plan) {
2329
+ throw new Error("submit_plan: empty plan \u2014 write a markdown plan and try again.");
2330
+ }
2331
+ opts.onPlanSubmitted?.(plan);
2332
+ throw new PlanProposedError(plan);
2333
+ }
2334
+ });
2335
+ return registry;
2336
+ }
2337
+
2186
2338
  // src/tools/shell.ts
2187
2339
  import { spawn } from "child_process";
2340
+ import { existsSync as existsSync3, statSync as statSync2 } from "fs";
2188
2341
  import * as pathMod2 from "path";
2189
2342
  var DEFAULT_TIMEOUT_SEC = 60;
2190
2343
  var DEFAULT_MAX_OUTPUT_CHARS = 32e3;
@@ -2302,10 +2455,12 @@ async function runCommand(cmd, opts) {
2302
2455
  windowsHide: true,
2303
2456
  env: process.env
2304
2457
  };
2458
+ const { bin, args, spawnOverrides } = prepareSpawn(argv);
2459
+ const effectiveSpawnOpts = { ...spawnOpts, ...spawnOverrides };
2305
2460
  return await new Promise((resolve5, reject) => {
2306
2461
  let child;
2307
2462
  try {
2308
- child = spawn(argv[0], argv.slice(1), spawnOpts);
2463
+ child = spawn(bin, args, effectiveSpawnOpts);
2309
2464
  } catch (err) {
2310
2465
  reject(err);
2311
2466
  return;
@@ -2339,6 +2494,59 @@ async function runCommand(cmd, opts) {
2339
2494
  });
2340
2495
  });
2341
2496
  }
2497
+ function resolveExecutable(cmd, opts = {}) {
2498
+ const platform = opts.platform ?? process.platform;
2499
+ if (platform !== "win32") return cmd;
2500
+ if (!cmd) return cmd;
2501
+ if (cmd.includes("/") || cmd.includes("\\") || pathMod2.isAbsolute(cmd)) return cmd;
2502
+ if (pathMod2.extname(cmd)) return cmd;
2503
+ const env = opts.env ?? process.env;
2504
+ const pathExt = (env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD").split(";").map((e) => e.trim()).filter(Boolean);
2505
+ const delimiter2 = opts.pathDelimiter ?? (platform === "win32" ? ";" : pathMod2.delimiter);
2506
+ const pathDirs = (env.PATH ?? "").split(delimiter2).filter(Boolean);
2507
+ const isFile = opts.isFile ?? defaultIsFile;
2508
+ for (const dir of pathDirs) {
2509
+ for (const ext of pathExt) {
2510
+ const full = pathMod2.join(dir, cmd + ext);
2511
+ if (isFile(full)) return full;
2512
+ }
2513
+ }
2514
+ return cmd;
2515
+ }
2516
+ function defaultIsFile(full) {
2517
+ try {
2518
+ return existsSync3(full) && statSync2(full).isFile();
2519
+ } catch {
2520
+ return false;
2521
+ }
2522
+ }
2523
+ function prepareSpawn(argv, opts = {}) {
2524
+ const head = argv[0] ?? "";
2525
+ const tail = argv.slice(1);
2526
+ const platform = opts.platform ?? process.platform;
2527
+ const resolved = resolveExecutable(head, opts);
2528
+ if (platform !== "win32") {
2529
+ return { bin: resolved, args: [...tail], spawnOverrides: {} };
2530
+ }
2531
+ if (/\.(cmd|bat)$/i.test(resolved)) {
2532
+ const cmdline = [resolved, ...tail].map(quoteForCmdExe).join(" ");
2533
+ return {
2534
+ bin: "cmd.exe",
2535
+ args: ["/d", "/s", "/c", cmdline],
2536
+ // windowsVerbatimArguments prevents Node from re-quoting the /c
2537
+ // payload — we've already composed an exact cmd.exe command
2538
+ // line. Without this Node wraps our already-quoted string in
2539
+ // another round of quotes and cmd.exe can't parse it.
2540
+ spawnOverrides: { windowsVerbatimArguments: true }
2541
+ };
2542
+ }
2543
+ return { bin: resolved, args: [...tail], spawnOverrides: {} };
2544
+ }
2545
+ function quoteForCmdExe(arg) {
2546
+ if (arg === "") return '""';
2547
+ if (!/[\s"&|<>^%(),;!]/.test(arg)) return arg;
2548
+ return `"${arg.replace(/"/g, '""')}"`;
2549
+ }
2342
2550
  var NeedsConfirmationError = class extends Error {
2343
2551
  command;
2344
2552
  constructor(command) {
@@ -2358,6 +2566,16 @@ function registerShellTools(registry, opts) {
2358
2566
  registry.register({
2359
2567
  name: "run_command",
2360
2568
  description: "Run a shell command in the project root and return its combined stdout+stderr. Read-only and test commands (git status, ls, npm test, pytest, cargo test, grep, etc.) run immediately. Anything that could mutate state (npm install, git commit, rm, chmod) is refused and the user has to confirm in the TUI. Prefer this over asking the user to run a command manually \u2014 after edits, run the project's tests to verify.",
2569
+ // Plan-mode gate: allow allowlisted commands through (git status,
2570
+ // cargo check, ls, grep …) so the model can actually investigate
2571
+ // during planning. Anything that would otherwise trigger a
2572
+ // confirmation prompt is treated as "not read-only" and bounced.
2573
+ readOnlyCheck: (args) => {
2574
+ if (allowAll) return true;
2575
+ const cmd = typeof args?.command === "string" ? args.command.trim() : "";
2576
+ if (!cmd) return false;
2577
+ return isAllowed(cmd, extraAllowed);
2578
+ },
2361
2579
  parameters: {
2362
2580
  type: "object",
2363
2581
  properties: {
@@ -2524,6 +2742,7 @@ function registerWebTools(registry, opts = {}) {
2524
2742
  registry.register({
2525
2743
  name: "web_search",
2526
2744
  description: "Search the public web. Returns ranked results with title, url, and snippet. Use this when the question needs information more current than your training data, when you're unsure of a factual detail, or when the user asks about a specific webpage/library/release you haven't seen.",
2745
+ readOnly: true,
2527
2746
  parameters: {
2528
2747
  type: "object",
2529
2748
  properties: {
@@ -2546,6 +2765,7 @@ function registerWebTools(registry, opts = {}) {
2546
2765
  registry.register({
2547
2766
  name: "web_fetch",
2548
2767
  description: "Download a URL and return its visible text content (HTML pages get scripts/styles/nav stripped). Truncated at the tool-result cap. Use after web_search when a snippet isn't enough.",
2768
+ readOnly: true,
2549
2769
  parameters: {
2550
2770
  type: "object",
2551
2771
  properties: {
@@ -2580,12 +2800,12 @@ ${i + 1}. ${r.title}`);
2580
2800
  }
2581
2801
 
2582
2802
  // src/env.ts
2583
- import { readFileSync as readFileSync2 } from "fs";
2803
+ import { readFileSync as readFileSync3 } from "fs";
2584
2804
  import { resolve as resolve3 } from "path";
2585
2805
  function loadDotenv(path = ".env") {
2586
2806
  let raw;
2587
2807
  try {
2588
- raw = readFileSync2(resolve3(process.cwd(), path), "utf8");
2808
+ raw = readFileSync3(resolve3(process.cwd(), path), "utf8");
2589
2809
  } catch {
2590
2810
  return;
2591
2811
  }
@@ -2604,7 +2824,7 @@ function loadDotenv(path = ".env") {
2604
2824
  }
2605
2825
 
2606
2826
  // src/transcript.ts
2607
- import { createWriteStream, readFileSync as readFileSync3 } from "fs";
2827
+ import { createWriteStream, readFileSync as readFileSync4 } from "fs";
2608
2828
  function recordFromLoopEvent(ev, extra) {
2609
2829
  const rec = {
2610
2830
  ts: (/* @__PURE__ */ new Date()).toISOString(),
@@ -2655,7 +2875,7 @@ function openTranscriptFile(path, meta) {
2655
2875
  return stream;
2656
2876
  }
2657
2877
  function readTranscript(path) {
2658
- const raw = readFileSync3(path, "utf8");
2878
+ const raw = readFileSync4(path, "utf8");
2659
2879
  return parseTranscript(raw);
2660
2880
  }
2661
2881
  function isPlanStateEmptyShape(s) {
@@ -3710,7 +3930,7 @@ async function trySection(load) {
3710
3930
  }
3711
3931
 
3712
3932
  // src/code/edit-blocks.ts
3713
- import { existsSync as existsSync2, mkdirSync as mkdirSync2, readFileSync as readFileSync4, unlinkSync as unlinkSync2, writeFileSync as writeFileSync2 } from "fs";
3933
+ import { existsSync as existsSync4, mkdirSync as mkdirSync2, readFileSync as readFileSync5, unlinkSync as unlinkSync2, writeFileSync as writeFileSync2 } from "fs";
3714
3934
  import { dirname as dirname3, resolve as resolve4 } from "path";
3715
3935
  var BLOCK_RE = /^(\S[^\n]*)\n<{7} SEARCH\n([\s\S]*?)\n?={7}\n([\s\S]*?)\n?>{7} REPLACE/gm;
3716
3936
  function parseEditBlocks(text) {
@@ -3739,7 +3959,7 @@ function applyEditBlock(block, rootDir) {
3739
3959
  };
3740
3960
  }
3741
3961
  const searchEmpty = block.search.length === 0;
3742
- const exists = existsSync2(absTarget);
3962
+ const exists = existsSync4(absTarget);
3743
3963
  try {
3744
3964
  if (!exists) {
3745
3965
  if (!searchEmpty) {
@@ -3753,7 +3973,7 @@ function applyEditBlock(block, rootDir) {
3753
3973
  writeFileSync2(absTarget, block.replace, "utf8");
3754
3974
  return { path: block.path, status: "created" };
3755
3975
  }
3756
- const content = readFileSync4(absTarget, "utf8");
3976
+ const content = readFileSync5(absTarget, "utf8");
3757
3977
  if (searchEmpty) {
3758
3978
  return {
3759
3979
  path: block.path,
@@ -3787,12 +4007,12 @@ function snapshotBeforeEdits(blocks, rootDir) {
3787
4007
  if (seen.has(b.path)) continue;
3788
4008
  seen.add(b.path);
3789
4009
  const abs = resolve4(absRoot, b.path);
3790
- if (!existsSync2(abs)) {
4010
+ if (!existsSync4(abs)) {
3791
4011
  snapshots.push({ path: b.path, prevContent: null });
3792
4012
  continue;
3793
4013
  }
3794
4014
  try {
3795
- snapshots.push({ path: b.path, prevContent: readFileSync4(abs, "utf8") });
4015
+ snapshots.push({ path: b.path, prevContent: readFileSync5(abs, "utf8") });
3796
4016
  } catch {
3797
4017
  snapshots.push({ path: b.path, prevContent: null });
3798
4018
  }
@@ -3812,7 +4032,7 @@ function restoreSnapshots(snapshots, rootDir) {
3812
4032
  }
3813
4033
  try {
3814
4034
  if (snap.prevContent === null) {
3815
- if (existsSync2(abs)) unlinkSync2(abs);
4035
+ if (existsSync4(abs)) unlinkSync2(abs);
3816
4036
  return {
3817
4037
  path: snap.path,
3818
4038
  status: "applied",
@@ -3835,10 +4055,31 @@ function sep() {
3835
4055
  }
3836
4056
 
3837
4057
  // src/code/prompt.ts
3838
- import { existsSync as existsSync3, readFileSync as readFileSync5 } from "fs";
3839
- import { join as join3 } from "path";
4058
+ import { existsSync as existsSync5, readFileSync as readFileSync6 } from "fs";
4059
+ import { join as join5 } from "path";
3840
4060
  var CODE_SYSTEM_PROMPT = `You are Reasonix Code, a coding assistant. You have filesystem tools (read_file, write_file, list_directory, search_files, etc.) rooted at the user's working directory.
3841
4061
 
4062
+ # When to propose a plan (submit_plan)
4063
+
4064
+ You have a \`submit_plan\` tool that shows the user a markdown plan and lets them Approve / Refine / Cancel before you execute. Use it proactively when the task is large enough to deserve a review gate:
4065
+
4066
+ - Multi-file refactors or renames.
4067
+ - Architecture changes (moving modules, splitting / merging files, new abstractions).
4068
+ - Anything where "undo" after the fact would be expensive \u2014 migrations, destructive cleanups, API shape changes.
4069
+ - When the user's request is ambiguous and multiple reasonable interpretations exist \u2014 propose your reading as a plan and let them confirm.
4070
+
4071
+ Skip submit_plan for small, obvious changes: one-line typo, clear bug with a clear fix, adding a missing import, renaming a local variable. Just do those.
4072
+
4073
+ Plan body: one-sentence summary, then a file-by-file breakdown of what you'll change and why, and any risks or open questions. If some decisions are genuinely up to the user (naming, tradeoffs, out-of-scope possibilities), list them in an "Open questions" or "\u5F85\u786E\u8BA4" section \u2014 the user sees the plan in a picker and has a text input to answer your questions before approving. Don't pretend certainty you don't have; flagged questions are how the user tells you what they care about. After calling submit_plan, STOP \u2014 don't call any more tools, wait for the user's verdict.
4074
+
4075
+ # Plan mode (/plan)
4076
+
4077
+ The user can ALSO enter "plan mode" via /plan, which is a stronger, explicit constraint:
4078
+ - Write tools (edit_file, write_file, create_directory, move_file) and non-allowlisted run_command calls are BOUNCED at dispatch \u2014 you'll get a tool result like "unavailable in plan mode". Don't retry them.
4079
+ - Read tools (read_file, list_directory, search_files, directory_tree, get_file_info) and allowlisted shell (git status/log/diff, ls, cat, grep, cargo check, npm test) still work \u2014 use them to investigate.
4080
+ - You MUST call submit_plan before anything will execute. Approve exits plan mode; Refine stays in; Cancel exits without implementing.
4081
+
4082
+
3842
4083
  # When to edit vs. when to explore
3843
4084
 
3844
4085
  Only propose edits when the user explicitly asks you to change, fix, add, remove, refactor, or write something. Do NOT propose edits when the user asks you to:
@@ -3885,18 +4126,19 @@ Rules:
3885
4126
  - If you need to explore first (list / grep / read), do it with tool calls before writing any prose \u2014 silence while exploring is fine.
3886
4127
  `;
3887
4128
  function codeSystemPrompt(rootDir) {
3888
- const gitignorePath = join3(rootDir, ".gitignore");
3889
- if (!existsSync3(gitignorePath)) return CODE_SYSTEM_PROMPT;
4129
+ const withMemory = applyProjectMemory(CODE_SYSTEM_PROMPT, rootDir);
4130
+ const gitignorePath = join5(rootDir, ".gitignore");
4131
+ if (!existsSync5(gitignorePath)) return withMemory;
3890
4132
  let content;
3891
4133
  try {
3892
- content = readFileSync5(gitignorePath, "utf8");
4134
+ content = readFileSync6(gitignorePath, "utf8");
3893
4135
  } catch {
3894
- return CODE_SYSTEM_PROMPT;
4136
+ return withMemory;
3895
4137
  }
3896
4138
  const MAX = 2e3;
3897
4139
  const truncated = content.length > MAX ? `${content.slice(0, MAX)}
3898
4140
  \u2026 (truncated ${content.length - MAX} chars)` : content;
3899
- return `${CODE_SYSTEM_PROMPT}
4141
+ return `${withMemory}
3900
4142
 
3901
4143
  # Project .gitignore
3902
4144
 
@@ -3909,15 +4151,15 @@ ${truncated}
3909
4151
  }
3910
4152
 
3911
4153
  // src/config.ts
3912
- import { chmodSync as chmodSync2, mkdirSync as mkdirSync3, readFileSync as readFileSync6, writeFileSync as writeFileSync3 } from "fs";
4154
+ import { chmodSync as chmodSync2, mkdirSync as mkdirSync3, readFileSync as readFileSync7, writeFileSync as writeFileSync3 } from "fs";
3913
4155
  import { homedir as homedir2 } from "os";
3914
- import { dirname as dirname4, join as join4 } from "path";
4156
+ import { dirname as dirname4, join as join6 } from "path";
3915
4157
  function defaultConfigPath() {
3916
- return join4(homedir2(), ".reasonix", "config.json");
4158
+ return join6(homedir2(), ".reasonix", "config.json");
3917
4159
  }
3918
4160
  function readConfig(path = defaultConfigPath()) {
3919
4161
  try {
3920
- const raw = readFileSync6(path, "utf8");
4162
+ const raw = readFileSync7(path, "utf8");
3921
4163
  const parsed = JSON.parse(raw);
3922
4164
  if (parsed && typeof parsed === "object") return parsed;
3923
4165
  } catch {
@@ -3952,7 +4194,7 @@ function redactKey(key) {
3952
4194
  }
3953
4195
 
3954
4196
  // src/index.ts
3955
- var VERSION = "0.4.16";
4197
+ var VERSION = "0.4.19";
3956
4198
  export {
3957
4199
  AppendOnlyLog,
3958
4200
  CODE_SYSTEM_PROMPT,
@@ -3963,6 +4205,9 @@ export {
3963
4205
  MCP_PROTOCOL_VERSION,
3964
4206
  McpClient,
3965
4207
  NeedsConfirmationError,
4208
+ PROJECT_MEMORY_FILE,
4209
+ PROJECT_MEMORY_MAX_CHARS,
4210
+ PlanProposedError,
3966
4211
  SessionStats,
3967
4212
  SseTransport,
3968
4213
  StdioTransport,
@@ -3977,6 +4222,7 @@ export {
3977
4222
  appendSessionMessage,
3978
4223
  applyEditBlock,
3979
4224
  applyEditBlocks,
4225
+ applyProjectMemory,
3980
4226
  bridgeMcpTools,
3981
4227
  claudeEquivalentCost,
3982
4228
  codeSystemPrompt,
@@ -4006,6 +4252,7 @@ export {
4006
4252
  loadApiKey,
4007
4253
  loadDotenv,
4008
4254
  loadSessionMessages,
4255
+ memoryEnabled,
4009
4256
  nestArguments,
4010
4257
  openTranscriptFile,
4011
4258
  outputCostUsd,
@@ -4013,17 +4260,22 @@ export {
4013
4260
  parseMcpSpec,
4014
4261
  parseMojeekResults,
4015
4262
  parseTranscript,
4263
+ prepareSpawn,
4264
+ quoteForCmdExe,
4016
4265
  readConfig,
4266
+ readProjectMemory,
4017
4267
  readTranscript,
4018
4268
  recordFromLoopEvent,
4019
4269
  redactKey,
4020
4270
  registerFilesystemTools,
4271
+ registerPlanTool,
4021
4272
  registerShellTools,
4022
4273
  registerWebTools,
4023
4274
  renderMarkdown as renderDiffMarkdown,
4024
4275
  renderSummaryTable as renderDiffSummary,
4025
4276
  repairTruncatedJson,
4026
4277
  replayFromFile,
4278
+ resolveExecutable,
4027
4279
  restoreSnapshots,
4028
4280
  runBranches,
4029
4281
  runCommand,