pi-kage 0.2.1 β†’ 0.3.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 (3) hide show
  1. package/README.md +50 -33
  2. package/bin/kage.mjs +305 -190
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -8,16 +8,16 @@
8
8
 
9
9
  <p align="center"><img src="./assets/demo.svg" alt="kage demo" width="100%"></p>
10
10
 
11
- `kage` copies your repo into an isolated sibling folder, drops you straight into
12
- [pi](https://github.com/earendil-works) to work in parallel, and when you're done merges the
13
- session memory back into the original and dispels the clone.
11
+ `kage` copies your repo into an isolated sibling folder, drops you straight into a **fresh**
12
+ [pi](https://github.com/earendil-works) session to work in parallel, and when you're done merges the
13
+ clone's new sessions back into the original and dispels the clone.
14
14
 
15
15
  ```bash
16
16
  npm install -g pi-kage
17
17
  cd my-app
18
- kage # πŸ₯· clone β†’ ../my-app--kage-<ts>, open pi with your recent context
18
+ kage # πŸ₯· clone β†’ ../my-app--kage-<ts>, open a fresh pi (origin history resumable)
19
19
  # ...work in the clone: commit, push, open a PR, quit pi...
20
- kage finish # πŸ’¨ merge the session memory back, delete the clone
20
+ kage finish # πŸ’¨ merge the clone's new sessions back, delete the clone
21
21
  ```
22
22
 
23
23
  ---
@@ -34,9 +34,10 @@ A shadow clone is a **full, independent copy** of the repo β€” like a second eng
34
34
  machine. Each parallel session gets its own working tree, its own branch, its own commits and PR.
35
35
  Code merges the normal way: on GitHub. No local collisions, ever.
36
36
 
37
- And like a real Naruto shadow clone, it **carries your memory out** (the clone's pi session is
38
- seeded with your recent conversation) and **returns it on dispel** (the clone's session is merged
39
- back into the original when you `finish`).
37
+ And like a real Naruto shadow clone, it **carries your memory out** (the origin's 5 most recent pi
38
+ sessions are copied into the clone, so you can `resume` them there) and **returns it on dispel** (the clone's
39
+ *new* sessions are merged back into the original when you `finish`). The clone always opens a **fresh**
40
+ session β€” kage never replays your old turns or fakes a "resumed" conversation.
40
41
 
41
42
  Why a full folder copy instead of `git worktree`? A worktree shares one `.git`, which means you
42
43
  can't check out the same branch twice, you share stash/refs, and you get a *fresh* checkout with no
@@ -71,14 +72,14 @@ Requires **git**, [**pi**](https://github.com/earendil-works), and **Node β‰₯ 18
71
72
  ```
72
73
  origin repo (you) shadow clone (independent copy)
73
74
  ───────────────── ──────────────────────────────
74
- $ kage --name fix-login ──copy + seed──► ../my-app--fix-login
75
- $ pi -c (your recent context, resumed)
75
+ $ kage --name fix-login ─copy + history─► ../my-app--fix-login
76
+ $ pi (fresh session; origin history resumable)
76
77
  Β· git switch -c fix-login
77
78
  Β· edit / commit / push / open PR
78
79
  Β· quit pi
79
- $ kage finish fix-login ◄──merge memory── (session .jsonl, deduped)
80
+ $ kage finish fix-login ◄─new sessions── (the clone's .jsonl, copied back)
80
81
  Β· safety check (committed? pushed?)
81
- Β· merge session back into ~/.pi
82
+ Β· merge the clone's new sessions into ~/.pi
82
83
  Β· delete the clone folder
83
84
  code arrives via the merged GitHub PR βœ“
84
85
  ```
@@ -88,17 +89,15 @@ Requires **git**, [**pi**](https://github.com/earendil-works), and **Node β‰₯ 18
88
89
  ```bash
89
90
  cd ~/code/my-app
90
91
 
91
- kage # clone . β†’ ../my-app--kage-<ts>, seed recent context, open `pi -c`
92
- kage --name fix-login # name the clone folder/branch suffix: ../my-app--fix-login
92
+ kage # clone . β†’ ../my-app--kage-<ts>, open a fresh pi (origin history resumable)
93
+ kage --name fix-login # name the clone folder: ../my-app--fix-login
93
94
  kage /path/to/other-repo # clone a different repo (path defaults to cwd)
94
- kage --blank # don't carry any context into the clone
95
- kage --recent 10 # seed the last 10 turns instead of the default 5
96
95
 
97
96
  # back in the origin after you quit the clone's pi:
98
97
  kage # no args inside a repo with clones -> interactive menu
99
- kage list # status dashboard: branch Β· dirty Β· ahead/behind Β· safe-to-clean
100
- kage list --pr # also show PR state (via gh)
101
- kage finish fix-login # check β†’ merge memory back β†’ delete the clone
98
+ kage status # status dashboard: branch Β· dirty Β· ahead/behind Β· safe-to-clean
99
+ kage status --pr # also show PR state (via gh)
100
+ kage finish fix-login # check β†’ merge the clone's new sessions back β†’ delete the clone
102
101
  kage finish fix-login --pr # push the branch + open a PR (via gh), then finish
103
102
  kage finish --force # skip the uncommitted/unpushed guard
104
103
  kage rm old-experiment # discard a clone without merging (refuses if it has local-only work)
@@ -125,9 +124,9 @@ for subcommands and clone names.
125
124
 
126
125
  | Command | Run from | What it does |
127
126
  |---|---|---|
128
- | `kage [path] [--name x] [--blank] [--recent N]` | origin repo | Copy the repo to `../<repo>--<name>` (default `kage-<ts>`), seed the clone's pi session with the last N turns (default 5; `--blank` for none), and launch `pi -c`. With no args (and existing clones) it opens an interactive picker. |
129
- | `kage list [--pr]` | origin repo | Status dashboard of clones: branch, dirty/clean, ahead/behind upstream, and a β€œsafe to clean” flag. `--pr` adds PR state via `gh`. |
130
- | `kage finish [name] [--force] [--push] [--pr]` | origin (or inside the clone) | Refuse if the clone has uncommitted or unpushed work (`--force` overrides), merge its session memory back (deduped), then delete the clone. `--push` pushes the branch first; `--pr` pushes and opens a PR via `gh`. Auto-selects / prompts when there are several. |
127
+ | `kage [path] [--name x]` | origin repo | Copy the repo to `../<repo>--<name>` (default `kage-<ts>`), copy the origin's 5 most recent pi sessions into the clone (resumable there, never replayed), and launch a **fresh** `pi` session. `--name` only names the folder β€” kage never creates a branch. With no args (and existing clones) it opens an interactive picker. |
128
+ | `kage status [--pr]` | origin repo | Status dashboard of clones: branch, dirty/clean, ahead/behind upstream, and a β€œsafe to clean” flag. `--pr` adds PR state via `gh`. (`kage list` is a kept alias.) |
129
+ | `kage finish [name] [--force] [--push] [--pr]` | origin (or inside the clone) | Refuse if the clone has uncommitted or unpushed work (`--force` overrides), merge the clone's **new** sessions back (copied-in origin history is skipped), then delete the clone. `--push` pushes the branch first; `--pr` pushes and opens a PR via `gh`. Auto-selects / prompts when there are several. |
131
130
  | `kage rm [name] [--force]` | origin (or inside the clone) | Discard a clone **without** merging memory. Refuses if it has local-only work unless `--force`. For abandoned experiments. |
132
131
  | `kage pull <path...>` | inside a clone | Copy specific files/dirs (even gitignored ones) back to the origin at the same relative path. |
133
132
  | `kage shell-init` | shell rc | Print a shell wrapper (cd-back after `finish`/`rm`) + tab completion. Use `eval "$(kage shell-init)"`. |
@@ -138,23 +137,41 @@ for subcommands and clone names.
138
137
  Four invariants keep parallel work safe and lossless:
139
138
 
140
139
  1. **Isolation** β€” a clone is a full independent copy with its own `.git`.
141
- 2. **Code flows back only via git/PR.** kage never copies the clone's working tree onto the origin β€”
142
- that would re-create the very collisions it avoids. `finish` makes you commit + push first.
143
- 3. **Memory flows through `~/.pi`.** Context is *seeded in* on create and *merged back* on finish.
144
- These are pi session `.jsonl` files (not the working tree), so there's zero collision risk. The
145
- seeded prefix is **deduped** on the way back β€” only the clone's new turns are kept, so you don't
146
- end up with two overlapping sessions.
140
+ 2. **Code flows back via git, never the working tree.** With a remote you push the branch and merge a
141
+ PR; with **no remote**, `finish` fetches the clone's branch into the origin's git as a local
142
+ `kage/<name>-<sha>` branch (the origin's working tree is left untouched β€” merge it when you like). Either
143
+ way kage never copies the clone's working tree onto the origin, which would re-create the collisions
144
+ it avoids. `finish` still refuses to delete **uncommitted** work (it can't be preserved by a fetch).
145
+ 3. **Memory flows through `~/.pi`.** On create, the origin's session `.jsonl` files are copied into the
146
+ clone (the 5 most recent, by mtime) β€” so `pi`'s resume picker inside the clone surfaces them if you
147
+ want it, but the clone itself opens a **fresh** session (kage never replays turns or fakes a resumed
148
+ conversation). On `finish`, sessions the clone created are copied back whole; an unchanged copied-in
149
+ session adds nothing; and a copied-in session you *resumed and added to* comes back as a **new,
150
+ self-contained session** β€” so the origin's original session (and the leaf pi would resume) is never
151
+ mutated, and your added turns aren't lost.
147
152
  4. **The origin is read-only to kage** β€” it only copies out and writes session memory; it never
148
153
  touches the origin's working tree, even while another session is live there.
149
154
 
150
155
  ## Notes & caveats
151
156
 
152
157
  - The copy is a snapshot of the origin's **current** state, **including uncommitted changes**.
153
- - kage **doesn't create a branch** β€” the clone stays on the origin's current branch. To keep the agent
154
- from committing to it, kage injects a short in-context reminder into the clone's session, so the
155
- agent itself is told to branch first (this reminder is deduped out when memory merges back).
156
- - Context seeding reads the origin's **most recent** session file. Pass `--blank` if that isn't the
157
- one you want carried over.
158
+ - kage **doesn't create a branch** β€” the clone stays on the origin's current branch, and kage stays out
159
+ of git flow entirely. Decide your own branching/PR workflow inside the clone (instruct the agent via
160
+ your `AGENTS.md` / project conventions).
161
+ - The clone opens a **fresh** pi session. The origin's 5 most recent sessions are copied in and are **resumable** via
162
+ pi's resume picker. Real work belongs in the clone's own fresh session, but if you do resume a copied
163
+ origin session and add turns, on `finish` that continuation is written back as a **separate** session
164
+ (the origin's original session is left untouched), so nothing is lost and no active conversation is hijacked.
165
+ - **Upgrading from an older kage:** clones created before the copy-in/fresh-session redesign carry a
166
+ fabricated *seed* session in their `.kage.json` (`seedFile`/`seedLeafId`). `finish` no longer special-
167
+ cases those, so finishing such a clone would copy the replayed seed context back into the origin. For
168
+ any clone created by an older kage, prefer `kage rm` (the code is already on its branch / PR) instead
169
+ of `kage finish`.
170
+ - **No remote?** `finish` still works losslessly: committed work that isn't on a remote is fetched into
171
+ the origin as a local `kage/<name>-<sha>` branch (the exact name is printed; `git merge` it to
172
+ integrate). The short sha keeps the ref unique, so reusing a clone name never collides. With a remote
173
+ configured, `finish` keeps nudging you to push first (so PR-flow mistakes surface) unless you
174
+ `--push`/`--pr` or `--force`.
158
175
  - **Submodules**: a submodule's `.git` pointer is an absolute path and breaks on copy β€” run
159
176
  `git submodule update --init` in the clone.
160
177
  - Non-APFS / non-reflink filesystems fall back to a full (heavier) copy.
package/bin/kage.mjs CHANGED
@@ -8,29 +8,32 @@
8
8
  *
9
9
  * Design invariants:
10
10
  * 1. Isolation β€” a clone is a full independent copy (its own .git).
11
- * 2. Code flows back only via git/PR β€” kage never copies the working tree back onto the origin.
12
- * 3. Memory flows via ~/.pi β€” recent context is seeded in on create and merged back on finish
13
- * (deduped). These are session .jsonl files, not the working tree, so there's no collision.
11
+ * 2. Code flows back via git only β€” a remote PR, or (no remote) a fetch of the clone's branch
12
+ * into the origin's git on finish; kage never copies the working tree back onto the origin.
13
+ * 3. Memory flows via ~/.pi β€” the origin's session history is copied into the clone on create
14
+ * (resumable, never replayed) and the clone's new sessions are merged back on finish. These
15
+ * are session .jsonl files, not the working tree, so there's no collision.
14
16
  * 4. The origin is read-only to kage β€” it only copies out and writes session memory.
15
17
  *
16
18
  * Commands:
17
- * kage [path] [--name x] [--blank] [--recent N] clone repo + launch pi (no args: interactive)
18
- * kage list [--pr] dashboard of clones (+ PR status via gh)
19
+ * kage [path] [--name x] clone repo + launch a fresh pi (no args: interactive)
20
+ * kage status [--pr] dashboard of clones (+ PR status via gh)
19
21
  * kage finish [name] [--force] check -> merge memory back -> delete clone
20
22
  * kage rm [name] [--force] discard a clone (no merge)
21
23
  * kage pull <path...> (inside a clone) copy files back to the origin
22
24
  */
23
25
 
24
- import { spawnSync } from "node:child_process";
26
+ import { spawn, spawnSync } from "node:child_process";
25
27
  import { randomUUID } from "node:crypto";
26
28
  import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
27
29
  import { homedir } from "node:os";
28
30
  import { basename, dirname, join, resolve, sep } from "node:path";
29
31
  import readline from "node:readline";
30
32
 
31
- const VERSION = "0.2.1"; // keep in sync with package.json (enforced by test)
33
+ const VERSION = "0.3.0"; // keep in sync with package.json (enforced by test)
32
34
  const MARKER = ".kage.json";
33
35
  const SESSIONS = process.env.KAGE_SESSIONS_DIR || join(homedir(), ".pi", "agent", "sessions");
36
+ const RECENT_SESSIONS = 5; // how many of the origin's most-recent sessions to copy into a clone
34
37
 
35
38
  // ── output helpers ───────────────────────────────────────────────────────────
36
39
  const TTY = process.stderr.isTTY;
@@ -77,14 +80,6 @@ function readMarker(dir) {
77
80
  }
78
81
  }
79
82
 
80
- function mtime(p) {
81
- try {
82
- return statSync(p).mtimeMs;
83
- } catch {
84
- return 0;
85
- }
86
- }
87
-
88
83
  /** Copy a whole directory: clonefile on macOS, reflink on Linux, plain copy as fallback. */
89
84
  function copyTree(src, dst) {
90
85
  const isMac = process.platform === "darwin";
@@ -93,12 +88,66 @@ function copyTree(src, dst) {
93
88
  return r;
94
89
  }
95
90
 
91
+ /** An indeterminate spinner on stderr (no-op when not a TTY). Returns { stop() }. */
92
+ function spinner(label) {
93
+ if (!process.stderr.isTTY) return { stop() {} };
94
+ const frames = ["β ‹", "β ™", "β Ή", "β Έ", "β Ό", "β ΄", "β ¦", "β §", "β ‡", "⠏"];
95
+ const t0 = Date.now();
96
+ let i = 0;
97
+ const tick = () => {
98
+ const s = ((Date.now() - t0) / 1000).toFixed(1);
99
+ process.stderr.write(`\r\x1b[2K${paint.cyan(frames[(i = (i + 1) % frames.length)])} ${label} ${paint.dim(`${s}s`)}`);
100
+ };
101
+ tick();
102
+ const id = setInterval(tick, 80);
103
+ return {
104
+ stop() {
105
+ clearInterval(id);
106
+ process.stderr.write("\r\x1b[2K");
107
+ },
108
+ };
109
+ }
110
+
111
+ /** Copy the repo with a spinner (the copy can be slow on non-reflink filesystems). */
112
+ async function copyRepo(src, dst) {
113
+ const isMac = process.platform === "darwin";
114
+ const primary = isMac ? ["-c", "-R", src, dst] : ["--reflink=auto", "-R", src, dst];
115
+ const tryCp = (args) =>
116
+ new Promise((res) => {
117
+ const p = spawn("cp", args, { stdio: ["ignore", "ignore", "pipe"] });
118
+ let err = "";
119
+ p.stderr.on("data", (d) => (err += d));
120
+ p.on("error", (e) => res({ ok: false, err: e.message }));
121
+ p.on("close", (code) => res({ ok: code === 0, err: err.trim() }));
122
+ });
123
+ const sp = spinner(`copying ${basename(dst)}`);
124
+ let r = await tryCp(primary);
125
+ if (!r.ok) r = await tryCp(["-R", src, dst]);
126
+ sp.stop();
127
+ return r;
128
+ }
129
+
96
130
  function tsName() {
97
131
  const d = new Date();
98
132
  const p = (n) => String(n).padStart(2, "0");
99
133
  return `kage-${p(d.getMonth() + 1)}${p(d.getDate())}-${p(d.getHours())}${p(d.getMinutes())}${p(d.getSeconds())}`;
100
134
  }
101
135
 
136
+ /**
137
+ * Sanitize a clone name into a slug that's safe as both a folder suffix and a git branch/ref:
138
+ * ref-illegal chars (spaces, /, ~^:?*[\\, etc.) -> '-', no '..', no leading/trailing '-'/'.',
139
+ * no trailing '.lock'. Falls back to a timestamp name if it sanitizes to empty.
140
+ */
141
+ function slug(name) {
142
+ const s = name
143
+ .replace(/[^A-Za-z0-9._-]+/g, "-")
144
+ .replace(/\.{2,}/g, ".")
145
+ .replace(/-{2,}/g, "-")
146
+ .replace(/^[-.]+|[-.]+$/g, "")
147
+ .replace(/\.lock$/i, "-lock");
148
+ return s || tsName();
149
+ }
150
+
102
151
  function parseArgs(argv) {
103
152
  const positional = [];
104
153
  const flags = {};
@@ -129,7 +178,8 @@ function listClones(originRepo) {
129
178
  function cloneStatus(dir) {
130
179
  const branch = git(dir, ["rev-parse", "--abbrev-ref", "HEAD"]).out || "?";
131
180
  const st = git(dir, ["status", "--porcelain"]).out;
132
- const dirty = st.split("\n").some((l) => l.trim() && l.slice(3).trim() !== MARKER);
181
+ const changed = st.split("\n").filter((l) => l.trim() && l.slice(3).trim() !== MARKER);
182
+ const dirty = changed.length > 0;
133
183
  const up = git(dir, ["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"]);
134
184
  let ahead = 0;
135
185
  let behind = 0;
@@ -141,7 +191,23 @@ function cloneStatus(dir) {
141
191
  ahead = a || 0;
142
192
  }
143
193
  }
144
- return { branch, dirty, ahead, behind, hasUpstream: up.ok };
194
+ // uncommitted line changes (tracked, vs HEAD) and the last commit on this branch
195
+ const ss = git(dir, ["diff", "HEAD", "--shortstat"]).out;
196
+ const added = Number(ss.match(/(\d+) insertion/)?.[1] || 0);
197
+ const removed = Number(ss.match(/(\d+) deletion/)?.[1] || 0);
198
+ const lc = git(dir, ["log", "-1", "--format=%h\x1f%s\x1f%cr"]).out;
199
+ const [sha, subject, when] = lc ? lc.split("\x1f") : [];
200
+ const lastCommit = sha ? { sha, subject, when } : undefined;
201
+ return { branch, dirty, dirtyCount: changed.length, added, removed, ahead, behind, hasUpstream: up.ok, lastCommit };
202
+ }
203
+
204
+ /** Compact relative age, e.g. "2h ago". */
205
+ function ago(date) {
206
+ const s = Math.max(0, (Date.now() - new Date(date).getTime()) / 1000);
207
+ if (s < 60) return `${Math.floor(s)}s ago`;
208
+ if (s < 3600) return `${Math.floor(s / 60)}m ago`;
209
+ if (s < 86400) return `${Math.floor(s / 3600)}h ago`;
210
+ return `${Math.floor(s / 86400)}d ago`;
145
211
  }
146
212
 
147
213
  /** Best-effort PR lookup via gh; returns { state, number, url } or undefined. */
@@ -158,6 +224,14 @@ function prInfo(dir, branch) {
158
224
  /** True when the clone has no local-only work (clean + pushed) -> safe to remove. */
159
225
  const isSafeToClean = (s) => !s.dirty && s.hasUpstream && s.ahead === 0;
160
226
 
227
+ /** True if the clone has committed work that lives only in the clone (not on a remote, not yet in the origin). */
228
+ function hasUnpreservedCommits(originRepo, cloneDir, s) {
229
+ if (s.hasUpstream && s.ahead === 0) return false; // already on a remote
230
+ const head = git(cloneDir, ["rev-parse", "HEAD"]).out;
231
+ if (!head) return false;
232
+ return !git(originRepo, ["cat-file", "-e", `${head}^{commit}`]).ok;
233
+ }
234
+
161
235
  // ── interactive picker (TUI-lite, arrow keys, no deps) ───────────────────────
162
236
  /** Returns the chosen index, or -1 when cancelled / non-interactive. */
163
237
  function select(title, labels) {
@@ -193,10 +267,13 @@ function select(title, labels) {
193
267
  });
194
268
  }
195
269
 
196
- async function ask(prompt) {
270
+ async function ask(prompt, prefill) {
197
271
  if (!process.stdin.isTTY) return "";
198
272
  const rl = readline.createInterface({ input: process.stdin, output: process.stderr });
199
- const a = await new Promise((r) => rl.question(prompt, (x) => (rl.close(), r(x))));
273
+ const a = await new Promise((r) => {
274
+ rl.question(prompt, (x) => (rl.close(), r(x)));
275
+ if (prefill) rl.write(prefill); // pre-fill an editable default: Enter accepts, or edit it
276
+ });
200
277
  return a.trim();
201
278
  }
202
279
 
@@ -228,87 +305,50 @@ async function pickClone(action, name) {
228
305
  return { originRepo, clone: clones[idx] };
229
306
  }
230
307
 
231
- // ── build the clone's pi session: seeded context + a kage operational reminder ──
308
+ // ── copy the origin's session history into the clone ─────────────────────────
232
309
  /**
233
- * Writes the clone's session: the origin's last N turns (unless blank) followed by an
234
- * in-context reminder telling the agent it's in a clone and to branch before committing.
235
- * The reminder is the leaf and `seedLeafId` points at it, so the whole seeded prefix
236
- * (context + reminder) is deduped out when memory merges back to the origin.
310
+ * Copies the origin's most recent session files (up to RECENT_SESSIONS, by mtime) into the
311
+ * clone's session dir, so `pi` resume inside the clone surfaces them (you decide whether to
312
+ * resume any of it). The clone itself opens a fresh session β€” kage never replays turns or
313
+ * fabricates a "resumed" conversation. On merge-back an unchanged copy adds nothing; if you
314
+ * resumed one and added turns, it comes back as a separate session (see mergeBack).
237
315
  */
238
- function buildCloneSession(originRepo, cloneDir, recentTurns, blank) {
239
- let kept = [];
240
- let turns = 0;
241
- let preview;
242
- let srcFile;
243
-
244
- if (!blank) {
245
- const srcDir = sessionDirFor(originRepo);
246
- if (existsSync(srcDir)) {
247
- const files = readdirSync(srcDir)
248
- .filter((f) => f.endsWith(".jsonl"))
249
- .map((f) => ({ f, m: mtime(join(srcDir, f)) }))
250
- .sort((a, b) => b.m - a.m);
251
- if (files.length) {
252
- srcFile = join(srcDir, files[0].f);
253
- const lines = readFileSync(srcFile, "utf8").split("\n").filter((l) => l.trim());
254
- if (lines.length >= 2) {
255
- const entries = lines.slice(1).map((l) => JSON.parse(l));
256
- const byId = new Map(entries.map((e) => [e.id, e]));
257
- let cur = entries[entries.length - 1];
258
- const branch = [];
259
- while (cur) {
260
- branch.unshift(cur);
261
- cur = cur.parentId ? byId.get(cur.parentId) : undefined;
262
- }
263
- const messages = branch.filter((e) => e.type === "message");
264
- const userIdx = [];
265
- messages.forEach((e, i) => {
266
- if (e.message?.role === "user") userIdx.push(i);
267
- });
268
- const start = userIdx.length > recentTurns ? userIdx[userIdx.length - recentTurns] : 0;
269
- kept = messages.slice(start);
270
- turns = userIdx.length > recentTurns ? recentTurns : userIdx.length;
271
- const fu = kept.find((e) => e.message?.role === "user");
272
- preview = fu ? snippet(fu.message.content) : undefined;
273
- }
274
- }
275
- }
276
- }
277
-
316
+ function copyOriginHistory(originRepo, cloneDir) {
317
+ const srcDir = sessionDirFor(originRepo);
318
+ if (!existsSync(srcDir)) return 0;
278
319
  const destDir = sessionDirFor(cloneDir);
279
320
  mkdirSync(destDir, { recursive: true });
280
- const id = randomUUID();
281
- const ts = new Date().toISOString();
282
- const fname = `${ts.replace(/[:.]/g, "-")}_${id}.jsonl`;
283
- const header = { type: "session", version: 3, id, timestamp: ts, cwd: cloneDir, ...(srcFile ? { parentSession: srcFile } : {}) };
284
- const outl = [JSON.stringify(header)];
285
- let prev = null;
286
- for (const e of kept) {
287
- outl.push(JSON.stringify({ ...e, parentId: prev }));
288
- prev = e.id;
321
+ const recent = readdirSync(srcDir)
322
+ .filter((f) => f.endsWith(".jsonl"))
323
+ .map((f) => ({ f, m: statSync(join(srcDir, f)).mtimeMs }))
324
+ .sort((a, b) => b.m - a.m)
325
+ .slice(0, RECENT_SESSIONS);
326
+ let n = 0;
327
+ for (const { f } of recent) {
328
+ const lines = readFileSync(join(srcDir, f), "utf8").split("\n");
329
+ try {
330
+ const header = JSON.parse(lines[0]);
331
+ header.cwd = cloneDir;
332
+ lines[0] = JSON.stringify(header);
333
+ } catch {
334
+ /* leave malformed header as-is */
335
+ }
336
+ writeFileSync(join(destDir, f), lines.join("\n"));
337
+ n++;
289
338
  }
290
-
291
- const curBranch = git(cloneDir, ["rev-parse", "--abbrev-ref", "HEAD"]).out || "the base branch";
292
- const hintId = randomUUID().replace(/-/g, "").slice(0, 8);
293
- const hint =
294
- `[kage] You are working in a shadow clone of ${originRepo} (folder: ${cloneDir}), ` +
295
- `currently on branch "${curBranch}". Before committing, create a dedicated feature branch ` +
296
- `(git switch -c <name>), then push it and open a PR β€” do not commit directly to "${curBranch}".`;
297
- outl.push(
298
- JSON.stringify({ type: "custom_message", id: hintId, parentId: prev, timestamp: ts, customType: "kage", content: hint, display: true }),
299
- );
300
- writeFileSync(join(destDir, fname), outl.join("\n") + "\n");
301
- return { seedFile: fname, seedLeafId: hintId, turns, preview, seeded: kept.length > 0 };
302
- }
303
-
304
- function snippet(content) {
305
- const text = typeof content === "string" ? content : (content || []).map((c) => c.text || "").join(" ");
306
- const one = text.replace(/\s+/g, " ").trim();
307
- return one.length > 60 ? one.slice(0, 57) + "…" : one;
339
+ return n;
308
340
  }
309
341
 
310
- // ── merge session memory back (deduped) ──────────────────────────────────────
311
- function mergeBack(cloneDir, originRepo, marker) {
342
+ // ── merge the clone's new sessions back into the origin ──────────────────────
343
+ /**
344
+ * Copies the clone's sessions into the origin's session dir:
345
+ * - a session the clone created (filename not in the origin) -> copied back whole.
346
+ * - a copied-in origin session left unchanged -> skipped (nothing new).
347
+ * - a copied-in origin session you resumed and added turns to -> written back as a NEW,
348
+ * self-contained session file, so the origin's original session (and the active leaf pi
349
+ * resumes) is never mutated. Costs a duplicated prefix; avoids hijacking the origin's leaf.
350
+ */
351
+ function mergeBack(cloneDir, originRepo) {
312
352
  const srcDir = sessionDirFor(cloneDir);
313
353
  if (!existsSync(srcDir)) return 0;
314
354
  const destDir = sessionDirFor(originRepo);
@@ -316,36 +356,52 @@ function mergeBack(cloneDir, originRepo, marker) {
316
356
  let n = 0;
317
357
  for (const f of readdirSync(srcDir)) {
318
358
  if (!f.endsWith(".jsonl")) continue;
359
+ const src = readFileSync(join(srcDir, f), "utf8").split("\n").filter((l) => l.trim());
360
+ if (src.length === 0) continue;
319
361
  const dest = join(destDir, f);
320
- if (existsSync(dest)) continue;
321
- const lines = readFileSync(join(srcDir, f), "utf8").split("\n").filter((l) => l.trim());
322
- if (lines.length === 0) continue;
362
+
363
+ if (!existsSync(dest)) {
364
+ let header;
365
+ try {
366
+ header = JSON.parse(src[0]);
367
+ } catch {
368
+ continue;
369
+ }
370
+ header.cwd = originRepo;
371
+ writeFileSync(dest, [JSON.stringify(header), ...src.slice(1)].join("\n") + "\n");
372
+ n++;
373
+ continue;
374
+ }
375
+
376
+ // A copied-in origin session. If the clone added records (e.g. you resumed it there),
377
+ // write the clone's full session back as a NEW, self-contained file β€” leaving the origin's
378
+ // original file (and the leaf pi resumes) untouched. Unchanged copies add nothing.
379
+ const have = new Set();
380
+ for (const l of readFileSync(dest, "utf8").split("\n")) {
381
+ if (!l.trim()) continue;
382
+ try {
383
+ have.add(JSON.parse(l).id);
384
+ } catch {
385
+ /* ignore */
386
+ }
387
+ }
388
+ const hasNew = src.slice(1).some((l) => {
389
+ try {
390
+ return !have.has(JSON.parse(l).id);
391
+ } catch {
392
+ return false;
393
+ }
394
+ });
395
+ if (!hasNew) continue;
323
396
  let header;
324
397
  try {
325
- header = JSON.parse(lines[0]);
398
+ header = JSON.parse(src[0]);
326
399
  } catch {
327
400
  continue;
328
401
  }
329
- header.cwd = originRepo;
330
- let body = lines.slice(1);
331
-
332
- if (marker?.seedFile === f && marker?.seedLeafId) {
333
- const idx = body.findIndex((l) => {
334
- try {
335
- return JSON.parse(l).id === marker.seedLeafId;
336
- } catch {
337
- return false;
338
- }
339
- });
340
- if (idx >= 0) {
341
- body = body.slice(idx + 1);
342
- if (body.length === 0) continue;
343
- const first = JSON.parse(body[0]);
344
- first.parentId = null;
345
- body[0] = JSON.stringify(first);
346
- }
347
- }
348
- writeFileSync(dest, [JSON.stringify(header), ...body].join("\n") + "\n");
402
+ const id = randomUUID();
403
+ const fname = `${new Date().toISOString().replace(/[:.]/g, "-")}_${id}.jsonl`;
404
+ writeFileSync(join(destDir, fname), [JSON.stringify({ ...header, id, cwd: originRepo }), ...src.slice(1)].join("\n") + "\n");
349
405
  n++;
350
406
  }
351
407
  try {
@@ -356,16 +412,25 @@ function mergeBack(cloneDir, originRepo, marker) {
356
412
  return n;
357
413
  }
358
414
 
359
- /** Ask the shell wrapper (kage shell-init) to cd somewhere after we exit. */
360
- function requestCd(path) {
415
+ /**
416
+ * We just deleted the clone we were running inside, so the parent shell is now in a
417
+ * deleted directory. A CLI can't cd its parent shell, so: if the shell wrapper is active
418
+ * (KAGE_CD_FILE set by `eval "$(kage shell-init)"`), hand it the origin path to cd into;
419
+ * otherwise print a copy-pasteable `cd` and how to enable the auto version.
420
+ */
421
+ function leaveClone(originRepo) {
361
422
  const f = process.env.KAGE_CD_FILE;
362
423
  if (f) {
363
424
  try {
364
- writeFileSync(f, path);
425
+ writeFileSync(f, originRepo);
426
+ info(paint.dim(` ↩ back to ${originRepo}`));
427
+ return;
365
428
  } catch {
366
- /* ignore */
429
+ /* fall through to the manual hint */
367
430
  }
368
431
  }
432
+ info(paint.yellow(` ↩ your shell is still in the deleted clone β€” run: ${paint.bold(`cd ${originRepo}`)}`));
433
+ info(paint.dim(` enable auto cd-back: add eval "$(kage shell-init)" to your ~/.zshrc`));
369
434
  }
370
435
 
371
436
  function launchPi(cwd, args) {
@@ -382,7 +447,7 @@ async function cmdNew(argv) {
382
447
  let path = positional[0];
383
448
 
384
449
  // Interactive launcher: `kage` with no args, inside a repo that already has clones.
385
- if (!path && !flags.name && !flags.blank && process.stdin.isTTY) {
450
+ if (!path && !flags.name && process.stdin.isTTY) {
386
451
  const repoRoot = repoTopLevel(process.cwd());
387
452
  const clones = repoRoot ? listClones(repoRoot) : [];
388
453
  if (clones.length > 0) {
@@ -409,35 +474,38 @@ async function cmdNew(argv) {
409
474
  if (act === 2) return cmdRm([clone.name]);
410
475
  return info("cancelled");
411
476
  }
412
- const nm = await ask("Name (blank = auto): ");
413
- if (nm) flags.name = nm;
477
+ // idx === 0: "create" β€” fall through to the name prompt below.
414
478
  }
415
479
  }
416
480
 
417
481
  const targetPath = path ? resolve(path) : process.cwd();
418
- const blank = !!flags.blank;
419
- const recent = Math.max(1, parseInt(flags.recent, 10) || 5);
420
482
 
421
483
  const repoRoot = repoTopLevel(targetPath);
422
484
  if (!repoRoot) die(`not a git repository: ${targetPath}`);
423
485
  if (existsSync(join(repoRoot, MARKER))) die("already inside a clone; run kage from the origin repo");
424
486
 
425
- const name = (typeof flags.name === "string" && flags.name) || tsName();
426
- const safe = name.replace(/\//g, "-");
487
+ // Resolve the clone name: explicit --name wins; otherwise show the full folder name with
488
+ // the fixed "<repo>--" prefix in the prompt and an editable default suffix β€” press Enter to
489
+ // accept, or edit the suffix (non-interactive falls back to the default).
490
+ let name = typeof flags.name === "string" && flags.name ? flags.name : "";
491
+ if (!name) {
492
+ const def = tsName();
493
+ const prompt = `Kage name: ${basename(repoRoot)}--`;
494
+ name = (process.stdin.isTTY ? await ask(prompt, def) : "") || def;
495
+ }
496
+ const safe = slug(name);
427
497
  const cloneDir = join(dirname(repoRoot), `${basename(repoRoot)}--${safe}`);
428
498
  if (existsSync(cloneDir)) die(`directory already exists: ${cloneDir}`);
429
499
 
430
- const cp = copyTree(repoRoot, cloneDir);
500
+ const cp = await copyRepo(repoRoot, cloneDir);
431
501
  if (!cp.ok) die(`copy failed: ${cp.err}`);
432
502
 
433
503
  // kage does NOT create a branch β€” the clone stays on the origin's current branch.
434
- const built = buildCloneSession(repoRoot, cloneDir, recent, blank);
504
+ const histN = copyOriginHistory(repoRoot, cloneDir);
435
505
  const marker = {
436
506
  originRepo: repoRoot,
437
507
  name: safe,
438
508
  createdAt: new Date().toISOString(),
439
- seedFile: built.seedFile,
440
- seedLeafId: built.seedLeafId,
441
509
  };
442
510
  writeFileSync(join(cloneDir, MARKER), JSON.stringify(marker, null, 2));
443
511
 
@@ -445,17 +513,11 @@ async function cmdNew(argv) {
445
513
  info("");
446
514
  info(`πŸ₯· ${paint.bold("Shadow clone ready")}: ${cloneDir}`);
447
515
  info(` origin: ${repoRoot} branch: ${paint.cyan(curBranch)}`);
448
- if (built.seeded) {
449
- info(` seeded with the last ${built.turns} turn(s) + a kage reminder (pi -c resumes them)`);
450
- if (built.preview) info(paint.dim(` ↳ "${built.preview}"`));
451
- } else {
452
- info(paint.dim(" blank clone (kage reminder only)"));
453
- }
454
- info(paint.yellow(` ⚠ create a feature branch before committing (clone is on ${curBranch})`));
516
+ if (histN > 0) info(paint.dim(` origin's ${histN} session(s) are available via resume (pi: pick from the list)`));
455
517
  info(paint.dim(` when done: kage finish ${safe}`));
456
518
  info("");
457
519
 
458
- launchPi(cloneDir, ["-c"]);
520
+ launchPi(cloneDir, []);
459
521
  info("");
460
522
  info(`β†©οΈŽ left the clone's pi. To finish: ${paint.bold(`kage finish ${safe}`)}`);
461
523
  }
@@ -495,16 +557,32 @@ async function cmdFinish(argv) {
495
557
  }
496
558
  }
497
559
 
560
+ // Decide how to preserve the clone's committed work before deleting it.
561
+ const s = cloneStatus(clone.dir);
562
+ const hasRemote = git(clone.dir, ["remote"]).out.trim().length > 0;
563
+
498
564
  if (!force) {
499
- const s = cloneStatus(clone.dir);
500
- const problems = [];
501
- if (s.dirty) problems.push("uncommitted changes");
502
- if (!s.hasUpstream) problems.push("branch not pushed (no upstream)");
503
- else if (s.ahead > 0) problems.push(`${s.ahead} unpushed commit(s)`);
504
- if (problems.length) die(`${clone.name}: ${problems.join(", ")} β€” push your work, or pass --force`);
565
+ if (s.dirty) die(`${clone.name}: uncommitted changes β€” commit them, or pass --force to discard them`);
566
+ // With a remote, keep the "push your work" guard so PR-flow mistakes surface.
567
+ if (hasRemote && (!s.hasUpstream || s.ahead > 0)) {
568
+ die(`${clone.name}: branch not pushed β€” push it (or use --push / --pr), or pass --force`);
569
+ }
570
+ }
571
+
572
+ // Preserve committed work that isn't on a remote: fetch the clone's branch into the origin
573
+ // as a local 'kage/<name>' branch (origin's working tree is left untouched). This is what
574
+ // makes finish lossless without GitHub β€” the commits land in the origin's git, ready to merge.
575
+ if (hasUnpreservedCommits(originRepo, clone.dir, s)) {
576
+ const head = git(clone.dir, ["rev-parse", "HEAD"]).out;
577
+ // Always a unique ref (name + short sha) so reusing a clone name never collides with an
578
+ // earlier preserved branch β€” which would either abort the fetch (non-ff) or clobber it.
579
+ const target = `kage/${slug(clone.name)}-${head.slice(0, 7)}`;
580
+ const r = git(originRepo, ["fetch", clone.dir, `${s.branch}:refs/heads/${target}`]);
581
+ if (!r.ok) die(`failed to preserve the clone's branch into the origin: ${r.err}`);
582
+ info(`🌿 preserved the clone's commits in the origin as ${paint.cyan(target)} (merge with: git merge ${target})`);
505
583
  }
506
584
 
507
- const n = mergeBack(clone.dir, originRepo, clone.marker);
585
+ const n = mergeBack(clone.dir, originRepo);
508
586
  try {
509
587
  process.chdir(originRepo);
510
588
  } catch {
@@ -513,10 +591,7 @@ async function cmdFinish(argv) {
513
591
  rmSync(clone.dir, { recursive: true, force: true });
514
592
 
515
593
  info(`πŸ’¨ Clone dispelled: merged ${n} session(s) back, removed ${clone.dir}`);
516
- if (insideClone) {
517
- requestCd(originRepo);
518
- info(paint.dim(` cd back to: ${originRepo} (auto with: eval "$(kage shell-init)")`));
519
- }
594
+ if (insideClone) leaveClone(originRepo);
520
595
  }
521
596
 
522
597
  async function cmdRm(argv) {
@@ -529,7 +604,7 @@ async function cmdRm(argv) {
529
604
 
530
605
  if (!force) {
531
606
  const s = cloneStatus(clone.dir);
532
- if (s.dirty || !s.hasUpstream || s.ahead > 0) {
607
+ if (s.dirty || hasUnpreservedCommits(originRepo, clone.dir, s)) {
533
608
  die(`${clone.name} has local-only work β€” use 'kage finish' to keep it, or 'kage rm --force' to discard`);
534
609
  }
535
610
  if (!(await confirm(`Discard clone ${clone.name} without merging its memory?`))) return info("aborted");
@@ -547,44 +622,56 @@ async function cmdRm(argv) {
547
622
  }
548
623
  rmSync(clone.dir, { recursive: true, force: true });
549
624
  info(`πŸ—‘ Removed clone ${clone.name} (${clone.dir})`);
550
- if (insideClone) {
551
- requestCd(originRepo);
552
- info(paint.dim(` cd back to: ${originRepo} (auto with: eval "$(kage shell-init)")`));
553
- }
625
+ if (insideClone) leaveClone(originRepo);
554
626
  }
555
627
 
556
628
  function cmdList(argv) {
557
629
  const { flags } = parseArgs(argv);
558
- const repoRoot = repoTopLevel(process.cwd());
559
- if (!repoRoot) die("not a git repository");
630
+ const here = repoTopLevel(process.cwd());
631
+ if (!here) die("not a git repository");
632
+ // Works from inside a clone too: resolve to the origin via the marker, then list its clones.
633
+ const repoRoot = readMarker(here)?.originRepo || here;
560
634
  const clones = listClones(repoRoot);
561
635
  if (clones.length === 0) return info("No shadow clones.");
562
636
 
563
- const rows = clones.map((c) => {
564
- const s = cloneStatus(c.dir);
565
- return { c, s, pr: flags.pr ? prInfo(c.dir, s.branch) : undefined };
566
- });
567
- const nameW = Math.max(...rows.map((r) => r.c.name.length), 4);
568
- const brW = Math.max(...rows.map((r) => r.s.branch.length), 6);
569
-
570
637
  info(paint.bold(`Shadow clones of ${basename(repoRoot)}:`));
571
638
  info("");
572
- for (const { c, s, pr } of rows) {
573
- const dirty = s.dirty ? paint.yellow("● dirty") : paint.green("clean ");
574
- let sync;
575
- if (!s.hasUpstream) sync = paint.dim("not pushed");
639
+ for (const c of clones) {
640
+ const s = cloneStatus(c.dir);
641
+ const pr = flags.pr ? prInfo(c.dir, s.branch) : undefined;
642
+
643
+ // header: status glyph Β· name Β· branch Β· age
644
+ const glyph = s.dirty ? paint.yellow("●") : isSafeToClean(s) ? paint.green("βœ“") : paint.cyan("Β·");
645
+ const age = c.marker?.createdAt ? paint.dim(`created ${ago(c.marker.createdAt)}`) : "";
646
+ info(` ${glyph} ${paint.bold(c.name)} ${paint.cyan(s.branch)} ${age}`);
647
+
648
+ // detail: working-tree state Β· sync Β· PR Β· safe-to-clean
649
+ const parts = [];
650
+ if (s.dirty) {
651
+ let d = `${s.dirtyCount} changed`;
652
+ if (s.added || s.removed) d += ` (${paint.green(`+${s.added}`)} ${paint.red(`-${s.removed}`)})`;
653
+ parts.push(paint.yellow(d));
654
+ } else {
655
+ parts.push(paint.green("clean"));
656
+ }
657
+ if (!s.hasUpstream) parts.push(paint.dim("not pushed"));
576
658
  else {
577
- const parts = [];
578
- if (s.ahead) parts.push(`↑${s.ahead}`);
579
- if (s.behind) parts.push(`↓${s.behind}`);
580
- sync = parts.length ? parts.join(" ") : paint.dim("in sync");
659
+ const sync = [];
660
+ if (s.ahead) sync.push(`↑${s.ahead}`);
661
+ if (s.behind) sync.push(`↓${s.behind}`);
662
+ parts.push(sync.length ? sync.join(" ") : paint.dim("in sync"));
581
663
  }
582
- const prStr = pr ? ` ${prState(pr)}` : "";
583
- const safe = isSafeToClean(s) ? paint.green(" βœ“ safe to clean") : "";
584
- info(` ${c.name.padEnd(nameW)} ${paint.cyan(s.branch.padEnd(brW))} ${dirty} ${sync}${prStr}${safe}`);
664
+ if (pr) parts.push(prState(pr));
665
+ if (isSafeToClean(s)) parts.push(paint.green("safe to clean"));
666
+ info(` ${parts.join(paint.dim(" Β· "))}`);
667
+
668
+ // last commit on the branch
669
+ if (s.lastCommit) {
670
+ info(paint.dim(` last: ${s.lastCommit.sha} "${s.lastCommit.subject}" (${s.lastCommit.when})`));
671
+ }
672
+ info("");
585
673
  }
586
- info("");
587
- info(paint.dim(" finish <name> to merge & remove Β· rm <name> to discard Β· list --pr for PR status"));
674
+ info(paint.dim(" finish <name> to merge & remove Β· rm <name> to discard Β· status --pr for PR status"));
588
675
  }
589
676
 
590
677
  function prState(pr) {
@@ -635,7 +722,7 @@ kage() {
635
722
  }
636
723
  if [ -n "$ZSH_VERSION" ]; then
637
724
  _kage() {
638
- if (( CURRENT == 2 )); then compadd new list finish rm pull; return; fi
725
+ if (( CURRENT == 2 )); then compadd new status finish rm pull; return; fi
639
726
  case "\${words[2]}" in
640
727
  finish|rm) compadd $(command kage __clones 2>/dev/null);;
641
728
  esac
@@ -644,7 +731,7 @@ if [ -n "$ZSH_VERSION" ]; then
644
731
  elif [ -n "$BASH_VERSION" ]; then
645
732
  _kage() {
646
733
  local cur="\${COMP_WORDS[COMP_CWORD]}"
647
- if [ "$COMP_CWORD" -eq 1 ]; then COMPREPLY=( $(compgen -W "new list finish rm pull" -- "$cur") ); return; fi
734
+ if [ "$COMP_CWORD" -eq 1 ]; then COMPREPLY=( $(compgen -W "new status finish rm pull" -- "$cur") ); return; fi
648
735
  case "\${COMP_WORDS[1]}" in
649
736
  finish|rm) COMPREPLY=( $(compgen -W "$(command kage __clones 2>/dev/null)" -- "$cur") );;
650
737
  esac
@@ -661,20 +748,42 @@ function cmdClones() {
661
748
  const HELP = `kage πŸ₯· β€” Shadow Clone Jutsu for your git repo
662
749
 
663
750
  Usage:
664
- kage [path] [--name <x>] [--blank] [--recent <N>] clone repo + launch pi
751
+ kage [path] [--name <x>] clone repo + launch a fresh pi
665
752
  (no args inside a repo with clones: interactive menu)
666
- kage list [--pr] dashboard of clones (--pr adds PR status via gh)
667
- kage finish [name] [--force] [--push] [--pr] check -> merge memory back -> delete clone
668
- (--push: push first Β· --pr: push + open a PR via gh)
753
+ kage status [--pr] dashboard of clones (--pr adds PR status via gh)
754
+ kage finish [name] [--force] [--push] [--pr] preserve work -> merge memory back -> delete clone
755
+ (--push/--pr use a remote; with no remote the branch is
756
+ kept in the origin as a local 'kage/<name>-<sha>' branch)
669
757
  kage rm [name] [--force] discard a clone without merging
670
758
  kage pull <path...> (inside a clone) copy files back to the origin
671
759
  kage shell-init shell wrapper (cd-back) + tab completion
760
+ kage --help | --version show this help / print the version
761
+
762
+ With no args inside a repo that already has clones, kage opens an interactive menu
763
+ (create a new clone, or enter / finish / remove an existing one).
672
764
 
673
765
  Options:
674
- --name <x> name the clone folder /<repo>--<x> (default: kage-<timestamp>)
675
- --blank don't seed the clone with the origin's recent context
676
- --recent <N> number of recent turns to seed (default: 5)
677
- --force skip the safety checks (finish/rm)
766
+ --name <x> name the clone folder /<repo>--<x> (default: kage-<timestamp>); skips the name prompt
767
+ (sanitized to a git-ref-safe slug, since the name is also used as a branch name)
768
+ --pr (finish) push the branch and open a GitHub PR via gh, then finish
769
+ --push (finish) push the branch before finishing (implied by --pr)
770
+ --force skip the safety checks: uncommitted/unpushed guard (finish) or local-only guard (rm)
771
+
772
+ Examples:
773
+ kage # clone the current repo, pick a name, open a fresh pi to work in
774
+ kage --name fix-login # same, but name the clone ../<repo>--fix-login (no prompt)
775
+ kage ~/code/other-repo # clone a different repo instead of the current dir
776
+
777
+ kage status # in the origin: list your clones + their git state
778
+ kage status --pr # ...also show each clone's PR state (needs gh)
779
+
780
+ # after you've worked in a clone (committed your changes), from the origin:
781
+ kage finish fix-login # you already pushed -> merge memory back, delete the clone
782
+ kage finish fix-login --pr # push the branch + open a PR via gh, then finish
783
+ kage finish fix-login --force # finish even with uncommitted/unpushed work
784
+ kage rm experiment # throw a clone away without merging its memory
785
+
786
+ kage pull .env # inside a clone: copy a gitignored file back to the origin
678
787
 
679
788
  Env:
680
789
  KAGE_SESSIONS_DIR pi session storage (default: ~/.pi/agent/sessions)`;
@@ -685,7 +794,8 @@ async function main() {
685
794
  case undefined:
686
795
  case "new":
687
796
  return cmdNew(sub === "new" ? rest : process.argv.slice(2));
688
- case "list":
797
+ case "status":
798
+ case "list": // alias
689
799
  return cmdList(rest);
690
800
  case "finish":
691
801
  return cmdFinish(rest);
@@ -705,7 +815,12 @@ async function main() {
705
815
  case "--version":
706
816
  return info(VERSION);
707
817
  default:
708
- return cmdNew(process.argv.slice(2)); // unknown subcommand -> treat as `kage <path>`
818
+ // `kage <path>` clones another repo, but a bare word that isn't an existing directory
819
+ // is a mistyped command (e.g. `kage statsu`) β€” fail clearly instead of "not a git repository".
820
+ if (!sub.startsWith("-") && !(existsSync(resolve(sub)) && statSync(resolve(sub)).isDirectory())) {
821
+ die(`unknown command or path: ${sub} (run 'kage --help')`);
822
+ }
823
+ return cmdNew(process.argv.slice(2)); // `kage <path>` or `kage <flags>`
709
824
  }
710
825
  }
711
826
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-kage",
3
- "version": "0.2.1",
3
+ "version": "0.3.0",
4
4
  "description": "πŸ₯· Shadow Clone Jutsu for your git repo: copy it into an isolated folder, work in parallel with pi, then merge the session memory back",
5
5
  "keywords": [
6
6
  "pi",