@tiens.nguyen/gu-cli 1.0.884 → 1.0.886

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/README.md CHANGED
@@ -126,6 +126,9 @@ Ctrl-C stops the running turn. Type `/` at the prompt to see every command; Tab
126
126
  | `/mcp [list\|tools\|add\|remove\|enable\|disable\|approve\|allow\|disallow\|reconnect]` | MCP servers, and which of their tools run without asking |
127
127
  | `/hooks [list\|approve\|unapprove]` | commands gu runs on `onStart`, `onAsk`, `onToolDone`, `onTurnEnd` |
128
128
  | `/switch-skill [claude\|gu]` | use Claude Code's (`.claude`) or gu's (`.gonext`) skills, MCP servers and hooks in this folder |
129
+ | `/skill-pack <name> [--out <file>] [--runtime <dir>]` | pack a skill with its MCP servers, hooks and code into one zip for another machine (no venvs, databases or secrets) |
130
+ | `/skill-install <zip\|folder> [--gu] [--claude] [--dry-run]` | install a packed skill for gu and Claude Code; without gu: `node <name>/install.mjs` |
131
+ | `/resume` | carry on the last unfinished task in this folder, Claude Code's or gu's: shows its plan, last command and next step, then asks (needs the memory skill) |
129
132
  | `/skills-copy-to-claude [--dry-run] [name…]` | copy gu's skills into Claude Code's folders |
130
133
  | `/proxy [show\|set\|on\|off\|clear\|test]` | route model calls through an HTTP proxy (local calls go direct) |
131
134
  | `/s3 [test]` | use your own S3 bucket for the RAG index and generated files |
package/gu-repl.mjs CHANGED
@@ -154,7 +154,7 @@ import { readSkillSource, sourceLabel, SKILL_SOURCE_KEY } from "./skill-source.m
154
154
  import { codingBannerLabel, isOllamaCloudUrl, SECRET_KEY_HINT } from "./coding-key-prompt.mjs";
155
155
  import { questionEchoText, thoughtPrintSeq, wrapDiffText } from "./terminal-layout.mjs";
156
156
  import { globalPrefixOf } from "./install-path.mjs";
157
- import { indentBlock, commandBlockRows } from "./terminal-layout.mjs"; // every line of an Allow-prompt detail indented (#189); wrapped + collapsible command (#204)
157
+ import { indentBlock, commandBlockRows, wrapIndented } from "./terminal-layout.mjs"; // every line of an Allow-prompt detail indented (#189); wrapped + collapsible command (#204)
158
158
  import { firstGuOnPath, pathGuMismatch, guOnPathIsCurrent, updateAttemptRecord, repeatUpdateNotice } from "./update-verify.mjs";
159
159
  import { embedNotice, embedState, setupNoticeRows } from "./embed-model-command.mjs";
160
160
  import { HINTS, pickHint, hintRow, hintFits, HINT_AFTER_SECONDS } from "./hints.mjs";
@@ -1051,6 +1051,9 @@ const COMMANDS = [
1051
1051
  { name: "/hooks", usage: "/hooks [list|approve|unapprove]", desc: "hooks: commands gu runs on onStart, onAsk, onToolDone, onTurnEnd — list them, approve this project's" },
1052
1052
  { name: "/mcp", usage: "/mcp [list|tools|add|remove|enable|disable|approve|allow|disallow|reconnect]", desc: "MCP servers: see which connect, add one (asks step by step), choose which tools run without asking" },
1053
1053
  { name: "/switch-skill", usage: "/switch-skill [claude|gu]", desc: "whose skills, MCP servers and hooks this folder uses — Claude Code's (.claude) or gu's own (.gonext)" },
1054
+ { name: "/resume", desc: "carry on the last unfinished task in this folder — Claude Code's or gu's — from where it stopped (memory skill)" },
1055
+ { name: "/skill-pack", usage: "/skill-pack <name> [--out <file>] [--runtime <dir>]", desc: "pack a skill (gu's or Claude's) with its MCP servers, hooks and code into one zip for another machine" },
1056
+ { name: "/skill-install", usage: "/skill-install <zip|folder> [--gu] [--claude] [--dry-run]", desc: "install a packed skill for gu and Claude Code (whichever is set up here)" },
1054
1057
  { name: "/skills-copy-to-claude", usage: "/skills-copy-to-claude [--dry-run] [name…]", desc: "copy gu's skills (.gonext) into Claude Code's folders (.claude), renamed to Claude's names, with their MCP servers" },
1055
1058
  { name: "/help", desc: "list these commands" },
1056
1059
  { name: "/exit", aliases: ["/quit"], desc: "quit" },
@@ -7711,6 +7714,14 @@ async function afterStoppedTurn({ taskLine, submittedInput, turnStartMs, stopped
7711
7714
  if (barPinned()) paintBar();
7712
7715
  }
7713
7716
 
7717
+ /**
7718
+ * A message line under the gutter, cut to the terminal's width with a hanging indent (#293): a
7719
+ * long line used to be left to the terminal, which wraps it back to COLUMN 0 ("…as they do ther" /
7720
+ * "e."). `paint` colours each row after wrapping, so colour codes never count toward the width.
7721
+ */
7722
+ function sayWrapped(text, paint = (s) => s, hang = " ") {
7723
+ for (const row of wrapIndented(String(text ?? ""), { width: commandWidth(), indent: GUTTER, hang })) console.log(paint(row));
7724
+ }
7714
7725
  /**
7715
7726
  * /switch-skill — whose skills, MCP servers and hooks THIS FOLDER uses (task #325): Claude Code's,
7716
7727
  * read where Claude keeps them (~/.claude, ./.claude, ~/.claude.json), or gu's own (~/.gonext,
@@ -7755,9 +7766,10 @@ async function runSwitchSkillCommand(line) {
7755
7766
  return;
7756
7767
  }
7757
7768
  _skillScan = { at: 0, skills: [] }; // the "/" menu and Tab follow at once
7758
- const say = (l) => console.log(GUTTER + l);
7759
- say(accent(`✓ ${pick === current ? "this folder uses" : "this folder now uses"} ${sourceLabel(pick)}`));
7760
- say(dim(` skills, MCP servers and hooks are read from ${where(pick)}`));
7769
+ // #293: wrapped under the line's own indent (never back to column 0); one 2-space indent under "✓".
7770
+ const say = (text, paint) => sayWrapped(text, paint);
7771
+ say(`✓ ${pick === current ? "this folder uses" : "this folder now uses"} ${sourceLabel(pick)}`, accent);
7772
+ say(` skills, MCP servers and hooks are read from ${where(pick)}`, dim);
7761
7773
  // What was found there — and what was not, said rather than shown as an empty list.
7762
7774
  try {
7763
7775
  const sk = discoverSkills({ cwd: process.cwd(), reserved: COMMAND_NAMES });
@@ -7766,27 +7778,165 @@ async function runSwitchSkillCommand(line) {
7766
7778
  const nHooks = (h) => Object.values(h).reduce((a, groups) => a + groups.reduce((b, g) => b + g.hooks.length, 0), 0);
7767
7779
  const count = (n, word) => `${n} ${word}${n === 1 ? "" : "s"}`;
7768
7780
  const running = mcp.servers.filter((x) => x.runnable).length;
7769
- say(dim(` ${count(sk.skills.length, "skill")}${sk.skills.length ? ` (${sk.skills.map((x) => "/" + x.name).join(", ")})` : ""} · ${count(running, "MCP server")} · ${count(nHooks(hk.user) + (hk.projectApproved ? nHooks(hk.project) : 0), "hook")}`));
7781
+ say(` ${count(sk.skills.length, "skill")}${sk.skills.length ? ` (${sk.skills.map((x) => "/" + x.name).join(", ")})` : ""} · ${count(running, "MCP server")} · ${count(nHooks(hk.user) + (hk.projectApproved ? nHooks(hk.project) : 0), "hook")}`, dim);
7770
7782
  // Nothing there at all is a normal state, not a fault: say so, and that gu needs none of it.
7771
7783
  if (!sk.skills.length && !mcp.servers.length && !nHooks(hk.user) && !nHooks(hk.project) && !hk.notes.length) {
7772
- say(dim(" Nothing is set up there yet — gu works the same without any of them."));
7784
+ say(" Nothing is set up there yet — gu works the same without any of them.", dim);
7773
7785
  }
7774
- for (const w of [...sk.warnings, ...mcp.warnings, ...hk.warnings]) say(yellow(" " + w));
7786
+ for (const w of [...sk.warnings, ...mcp.warnings, ...hk.warnings]) say(" " + w, yellow);
7775
7787
  const unsupported = mcp.servers.filter((x) => x.error);
7776
- if (unsupported.length) say(yellow(` ${count(unsupported.length, "MCP server")} cannot run here: ${unsupported.map((x) => `${x.name} (${x.error})`).join("; ")}`));
7788
+ if (unsupported.length) say(` ${count(unsupported.length, "MCP server")} cannot run here: ${unsupported.map((x) => `${x.name} (${x.error})`).join("; ")}`, yellow);
7777
7789
  const waiting = mcp.servers.filter((x) => x.scope === "project" && !x.approved && !x.error);
7778
- if (waiting.length) say(yellow(` ${count(waiting.length, "project MCP server")} waiting for your approval: ${waiting.map((x) => x.name).join(", ")} — /mcp approve <name>`));
7779
- if (hk.projectPending) say(yellow(` this folder's ${hk.projectFile.replace(/^\.\//, "")} has hooks that will not run until you approve them — /hooks approve`));
7780
- if (hk.notes.length) say(yellow(` ${count(hk.notes.length, "hook")} in Claude's settings ${hk.notes.length === 1 ? "is" : "are"} NOT run by gu — /hooks says which and why`));
7781
- if (pick === "claude" && (nHooks(hk.user) || nHooks(hk.project))) say(dim(" Claude's hooks get Claude's event and tool names from gu, so they record here as they do there."));
7790
+ if (waiting.length) say(` ${count(waiting.length, "project MCP server")} waiting for your approval: ${waiting.map((x) => x.name).join(", ")} — /mcp approve <name>`, yellow);
7791
+ if (hk.projectPending) say(` this folder's ${hk.projectFile.replace(/^\.\//, "")} has hooks that will not run until you approve them — /hooks approve`, yellow);
7792
+ if (hk.notes.length) say(` ${count(hk.notes.length, "hook")} in Claude's settings ${hk.notes.length === 1 ? "is" : "are"} NOT run by gu — /hooks says which and why`, yellow);
7793
+ if (pick === "claude" && (nHooks(hk.user) || nHooks(hk.project))) say(" Claude's hooks get Claude's event and tool names from gu, so they record here as they do there.", dim);
7782
7794
  } catch (err) {
7783
- say(yellow(` (could not read it back: ${err.message})`));
7795
+ say(` (could not read it back: ${err.message})`, yellow);
7784
7796
  }
7785
- say(dim(` It holds from your next question. ${pick === "claude" ? "/switch-skill gu" : "/switch-skill claude"} switches back.`));
7797
+ say(` It holds from your next question. ${pick === "claude" ? "/switch-skill gu" : "/switch-skill claude"} switches back.`, dim);
7786
7798
  console.log("");
7787
7799
  if (barPinned()) paintBar();
7788
7800
  }
7789
7801
 
7802
+ /** "--out x" style option from a command line's words → value, or undefined. */
7803
+ function optionValue(words, flag) {
7804
+ const i = words.indexOf(flag);
7805
+ return i >= 0 ? words[i + 1] : undefined;
7806
+ }
7807
+
7808
+ /**
7809
+ * /skill-pack <name> [--out <file>] [--runtime <dir>] — one skill, with its MCP servers, hooks and
7810
+ * the code they run, into one zip another machine installs (#281, skill-package.mjs). Shows what
7811
+ * it found, then writes; a secret-looking line refuses the pack.
7812
+ */
7813
+ async function runResumeCommand() {
7814
+ const rm = await import("./resume-memory.mjs");
7815
+ const { hooksForTurn } = await import("./hooks-config.mjs");
7816
+ const say = (l) => console.log(GUTTER + l);
7817
+ console.log("");
7818
+ const r = rm.readResume({ cwd: process.cwd(), found: rm.findMemoryCli({ cwd: process.cwd(), hooksFor: hooksForTurn }) });
7819
+ if (r.error) {
7820
+ say(yellow(r.error) + "\n");
7821
+ return null;
7822
+ }
7823
+ if (!r.resume) {
7824
+ say(dim("Nothing to resume here: every recent session finished its plan.\n"));
7825
+ return null;
7826
+ }
7827
+ // #293: Goal and Plan are long; wrapped under their own indent, not back to column 0.
7828
+ for (const l of r.block.split("\n").filter((l) => !l.startsWith("If the user wants"))) {
7829
+ if (l.startsWith("## ")) sayWrapped(l.slice(3), bold);
7830
+ else sayWrapped(l, dim);
7831
+ }
7832
+ console.log("");
7833
+ if (!(await askYesNo(rm.resumeQuestion(r.resume), true))) {
7834
+ console.log("");
7835
+ return null;
7836
+ }
7837
+ return rm.resumePrompt(r.block, r.resume);
7838
+ }
7839
+
7840
+ async function runSkillPackCommand(line) {
7841
+ const sp = await import("./skill-package.mjs");
7842
+ const words = line.trim().split(/\s+/).slice(1);
7843
+ const out = optionValue(words, "--out");
7844
+ const runtime = optionValue(words, "--runtime");
7845
+ const name = words.find((w, i) => !w.startsWith("--") && !["--out", "--runtime"].includes(words[i - 1]));
7846
+ const home = homedir();
7847
+ const cwd = resolve(process.cwd());
7848
+ const tilde = (p) => (String(p).startsWith(home) ? "~" + String(p).slice(home.length) : p);
7849
+ const say = (l) => console.log(GUTTER + l);
7850
+ console.log("");
7851
+ if (!name) {
7852
+ say(dim("usage: /skill-pack <name> [--out <file>] [--runtime <dir>]\n"));
7853
+ return;
7854
+ }
7855
+ let plan;
7856
+ try {
7857
+ plan = sp.planSkillPack(name, { cwd, home, runtime });
7858
+ } catch (err) {
7859
+ say(red(`${err.message}\n`));
7860
+ return;
7861
+ }
7862
+ const f = plan.found;
7863
+ say(bold(`Pack ${plan.manifest.name}`) + dim(` (${f.source === "gu" ? "gu's" : "Claude Code's"} ${f.scope === "project" ? "project" : "user"} skill)`));
7864
+ say(dim(` skill ${tilde(f.skillDir)} ${plan.skill.size} file${plan.skill.size === 1 ? "" : "s"}`));
7865
+ if (f.runtimeDir) say(dim(` runtime ${tilde(f.runtimeDir)} ${plan.runtime.size} files${f.python ? " · venv rebuilt from requirements.txt" : ""}`));
7866
+ for (const s of f.servers) say(dim(` MCP ${s}`));
7867
+ if (f.hooks.length) say(dim(` hooks ${f.hooks.join(", ")}`));
7868
+ if (plan.manifest.env?.length) say(dim(` asked on install, not packed: ${plan.manifest.env.join(", ")}`));
7869
+ if (plan.skipped.length) say(dim(` left out ${plan.skipped.join(", ")}`));
7870
+ for (const w of plan.warnings) say(yellow(` ${w}`));
7871
+ if (plan.secrets.length) {
7872
+ for (const s of plan.secrets) say(red(` ✗ ${s.file}:${s.line} looks like ${s.kind}`));
7873
+ say(red(" Not packed — remove those and run /skill-pack again.\n"));
7874
+ return;
7875
+ }
7876
+ const file = resolve(out ?? `${plan.manifest.name}.zip`);
7877
+ try {
7878
+ const r = sp.writeSkillPack(plan, file);
7879
+ say(`${accent("✓")} ${tilde(r.file)} ${dim(`${Math.ceil(r.bytes / 1024)} KB, ${r.count} files`)}`);
7880
+ say(dim(` On the other machine: /skill-install ${file.split(/[\\/]/).pop()}`));
7881
+ // platform-ok: shown to the user — the folder inside the zip, which unzip makes with "/" names.
7882
+ say(dim(` Without gu: unzip it, then node ${plan.manifest.name}/install.mjs\n`));
7883
+ } catch (err) {
7884
+ say(red(`couldn't write the zip — ${err.message}\n`));
7885
+ }
7886
+ }
7887
+
7888
+ /**
7889
+ * /skill-install <zip|folder> [--gu] [--claude] [--dry-run] — a packed skill onto this machine
7890
+ * (skill-install.mjs, the same code as the package's own install.mjs). Shows the plan, then asks.
7891
+ */
7892
+ async function runSkillInstallCommand(line) {
7893
+ const si = await import("./skill-install.mjs");
7894
+ const words = line.trim().split(/\s+/).slice(1);
7895
+ const src = words.find((w) => !w.startsWith("--"));
7896
+ const targets = si.TARGETS.filter((t) => words.includes(`--${t}`));
7897
+ const home = homedir();
7898
+ const tilde = (p) => String(p).split(home).join("~");
7899
+ const say = (l) => console.log(GUTTER + l);
7900
+ console.log("");
7901
+ if (!src) {
7902
+ say(dim("usage: /skill-install <zip|folder> [--gu] [--claude] [--dry-run]\n"));
7903
+ return;
7904
+ }
7905
+ let plan;
7906
+ try {
7907
+ plan = si.planInstall(si.loadPackage(resolve(src.replace(/^~(?=\/|$)/, home))), { home, targets });
7908
+ } catch (err) {
7909
+ say(red(`Not installed — ${err.message}\n`));
7910
+ return;
7911
+ }
7912
+ const lines = si.describePlan(plan, tilde);
7913
+ say(bold(lines[0]));
7914
+ for (const l of lines.slice(1)) say(l.trimStart().startsWith("!") ? yellow(l) : dim(l));
7915
+ if (!si.planHasWork(plan)) {
7916
+ say(dim("\n Nothing to do — it is already installed.\n"));
7917
+ return;
7918
+ }
7919
+ if (words.includes("--dry-run")) {
7920
+ say(dim("\n --dry-run: nothing was written.\n"));
7921
+ return;
7922
+ }
7923
+ console.log(dim(`\n ${PICK_HINT}:`));
7924
+ const options = ["Install", "Cancel"];
7925
+ const idx = await pickFromList(options, 0);
7926
+ if (idx !== 0) {
7927
+ console.log(dim(" (cancelled — nothing installed)\n"));
7928
+ paintBar();
7929
+ return;
7930
+ }
7931
+ const r = si.applyInstall(plan, { say: (l) => say(dim(l)) });
7932
+ for (const d of r.done) say(`${accent("✓")} ${dim(tilde(d))}`);
7933
+ for (const e of r.errors) say(red(`✗ ${e}`));
7934
+ if (r.backups) say(dim(` Old copies: ${tilde(r.backups)}`));
7935
+ if (!r.errors.length) say(dim(` ${si.nextStep(plan)}`));
7936
+ console.log("");
7937
+ paintBar();
7938
+ }
7939
+
7790
7940
  /**
7791
7941
  * /skills-copy-to-claude [--dry-run] [name…] — gu's skills into Claude Code's folders (#241):
7792
7942
  * ~/.gonext/skills → ~/.claude/skills and ./.gonext/skills → ./.claude/skills, "gu-" names become
@@ -14808,6 +14958,13 @@ async function main() {
14808
14958
  console.log("\n" + GUTTER + dim(`⏺ background ${entries.length === 1 ? "shell" : "shells"} finished — ${entries.map((e) => `${e.rec.id} ${shellStatusText(e.st)}`).join(", ")} · gu asks the agent to recap`));
14809
14959
  lineRaw = recapQuestion(entries);
14810
14960
  }
14961
+ // /resume (#285): show where the last unfinished session stopped; on "yes" the line becomes the
14962
+ // prompt that carries it on, and the turn runs as if it had been typed.
14963
+ if (lineRaw.trim() === "/resume") {
14964
+ const prompt = await runResumeCommand();
14965
+ if (!prompt) continue;
14966
+ lineRaw = prompt;
14967
+ }
14811
14968
  const line = lineRaw.trim();
14812
14969
  // #302: what was typed for THIS line (before expansion), and whether it was sent from a
14813
14970
  // stopped turn's restored question.
@@ -14995,6 +15152,14 @@ async function main() {
14995
15152
  await runSwitchSkillCommand(line);
14996
15153
  continue;
14997
15154
  }
15155
+ if (line === "/skill-pack" || line.startsWith("/skill-pack ")) {
15156
+ await runSkillPackCommand(line);
15157
+ continue;
15158
+ }
15159
+ if (line === "/skill-install" || line.startsWith("/skill-install ")) {
15160
+ await runSkillInstallCommand(line);
15161
+ continue;
15162
+ }
14998
15163
  if (line === "/skills-copy-to-claude" || line.startsWith("/skills-copy-to-claude ")) {
14999
15164
  await runSkillsCopyCommand(line);
15000
15165
  continue;
package/hooks-config.mjs CHANGED
@@ -147,7 +147,9 @@ export function cleanHooks(raw, where, source = "gu") {
147
147
  ...(Number.isFinite(h.timeout) && h.timeout > 0 ? { timeout: h.timeout } : {}),
148
148
  ...(isObj(h.env) ? { env: Object.fromEntries(Object.entries(h.env).map(([k, v]) => [k, String(v)])) } : {}),
149
149
  }));
150
- if (hs.length) kept.push({ matcher: String(g.matcher ?? ""), hooks: hs });
150
+ // #281: a group marked dialect "claude" (a packed skill's hook, installed for both tools) is
151
+ // spoken to in Claude's vocabulary, as if it had come from Claude Code's settings.
152
+ if (hs.length) kept.push({ matcher: String(g.matcher ?? ""), hooks: hs, ...(g.dialect === "claude" ? { dialect: "claude" } : {}) });
151
153
  }
152
154
  if (kept.length) hooks[event] = kept;
153
155
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tiens.nguyen/gu-cli",
3
- "version": "1.0.884",
3
+ "version": "1.0.886",
4
4
  "description": "gu: the coding agent in your terminal. It reads your repo, makes the change and runs the tests, with your own models (Ollama, MLX or any OpenAI-compatible provider). macOS, Linux and Windows.",
5
5
  "keywords": [
6
6
  "ai",
@@ -146,6 +146,9 @@
146
146
  "skills.mjs",
147
147
  "skill-source.mjs",
148
148
  "skills-copy.mjs",
149
+ "skill-package.mjs",
150
+ "resume-memory.mjs",
151
+ "skill-install.mjs",
149
152
  "shell-details.mjs",
150
153
  "proc-identity.mjs",
151
154
  "win-path.mjs",
@@ -0,0 +1,85 @@
1
+ /**
2
+ * gu /resume (#285): where the last unfinished session in this project stopped — Claude Code's or
3
+ * gu's — read from the session-memory skill's database, so gu can carry on Claude's work.
4
+ *
5
+ * The memory skill is found through the hooks this folder uses (its memory_hook.py sits next to
6
+ * memory_cli.py), so whichever copy is installed — ~/.claude/memory, a /skill-install runtime —
7
+ * is the one asked. Nothing here writes the database.
8
+ */
9
+ import { spawnSync } from "node:child_process";
10
+ import { existsSync } from "node:fs";
11
+ import { homedir } from "node:os";
12
+ import { dirname, join } from "node:path";
13
+
14
+ const SKILL = "claude-session-memory-orchestration";
15
+
16
+ /** {python, cli} of the memory skill, or null when it is not installed. */
17
+ export function findMemoryCli({ cwd = process.cwd(), home = homedir(), hooksFor } = {}) {
18
+ for (const source of [undefined, "claude", "gu"]) {
19
+ let hooks = {};
20
+ try {
21
+ hooks = hooksFor ? hooksFor({ cwd, home, source }) : {};
22
+ } catch {
23
+ continue;
24
+ }
25
+ for (const groups of Object.values(hooks)) {
26
+ for (const g of groups ?? []) {
27
+ for (const h of g.hooks ?? []) {
28
+ const script = [h.command, ...(h.args ?? [])].find((a) => /memory_hook\.py$/.test(String(a)));
29
+ if (!script) {continue;}
30
+ const cli = join(dirname(dirname(String(script))), "memory_cli.py");
31
+ if (existsSync(cli)) {return { python: String(h.command).endsWith(".py") ? pythonFor(cli) : String(h.command), cli };}
32
+ }
33
+ }
34
+ }
35
+ }
36
+ for (const dir of [join(home, ".gonext", "skill-runtime", SKILL), join(home, ".claude", "skill-runtime", SKILL), join(home, ".claude", "memory")]) {
37
+ const cli = join(dir, "memory_cli.py");
38
+ if (existsSync(cli)) {return { python: pythonFor(cli), cli };}
39
+ }
40
+ return null;
41
+ }
42
+
43
+ function pythonFor(cli) {
44
+ const dir = dirname(cli);
45
+ for (const p of [join(dir, ".venv", "bin", "python"), join(dir, ".venv", "Scripts", "python.exe")]) {if (existsSync(p)) {return p;}}
46
+ return process.platform === "win32" ? "python" : "python3"; // platform-ok: the venv is preferred above
47
+ }
48
+
49
+ /** {block, resume} from `memory_cli.py resume --json`, or {error}. */
50
+ export function readResume({ cwd = process.cwd(), found, run = spawnSync } = {}) {
51
+ if (!found) {return { error: "the memory skill is not installed here — /skill-install claude-session-memory-orchestration.zip" };}
52
+ const r = run(found.python, [found.cli, "resume", "--json"], { cwd, encoding: "utf8", timeout: 20000 });
53
+ if (r.error || r.status !== 0) {return { error: `memory_cli resume failed: ${(r.error?.message || r.stderr || "").trim().split("\n").pop()}` };}
54
+ try {
55
+ const d = JSON.parse(r.stdout);
56
+ return { block: d.block || "", resume: d.resume ?? null };
57
+ } catch {
58
+ return { error: "memory_cli resume printed something that is not JSON" };
59
+ }
60
+ }
61
+
62
+ /** The step to offer: the saved next action, else the first plan step not done. */
63
+ export function nextStepOf(resume) {
64
+ if (!resume) {return "";}
65
+ const open = (resume.plan ?? []).find((p) => p.status !== "completed");
66
+ return String(resume.next_action || open?.text || resume.task?.next_action || "").trim();
67
+ }
68
+
69
+ /** The question asked before carrying on. */
70
+ export function resumeQuestion(resume) {
71
+ const step = nextStepOf(resume);
72
+ const what = resume?.task?.id ? `task #${resume.task.id}` : "this work";
73
+ return step ? `Continue ${what} from "${step.length > 60 ? step.slice(0, 59) + "…" : step}"?` : `Continue ${what}?`;
74
+ }
75
+
76
+ /** What the agent is sent on "yes": the block itself, and what to do with it. */
77
+ export function resumePrompt(block, resume) {
78
+ const step = nextStepOf(resume);
79
+ return [
80
+ `Continue the unfinished work below${resume?.task?.id ? ` (task #${resume.task.id})` : ""}. It was saved by the memory skill${resume?.tool === "claude" ? " from a Claude Code session" : ""}.`,
81
+ step ? `Start from: ${step}. Check the files it lists first; do not redo steps marked ✓.` : "Check the files it lists first; do not redo steps marked ✓.",
82
+ "",
83
+ block,
84
+ ].join("\n");
85
+ }