pi-kage 0.2.0 β†’ 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 +58 -35
  2. package/bin/kage.mjs +306 -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
@@ -46,11 +47,17 @@ can't check out the same branch twice, you share stash/refs, and you get a *fres
46
47
  ## Install
47
48
 
48
49
  ```bash
50
+ # npm
49
51
  npm install -g pi-kage # then use `kage` anywhere
50
- # or, no install:
51
- npx pi-kage
52
+ npx pi-kage # or run without installing
53
+
54
+ # or install script (no npm needed β€” kage is a single, zero-dependency Node script)
55
+ curl -fsSL https://raw.githubusercontent.com/kid7st/kage/main/install.sh | sh
52
56
  ```
53
57
 
58
+ The install script drops the single `kage` file into `~/.local/bin` (override with `KAGE_BIN_DIR`,
59
+ pin a version with `KAGE_VERSION`). kage has **no dependencies** β€” it only needs Node, git, and pi.
60
+
54
61
  From source:
55
62
 
56
63
  ```bash
@@ -65,14 +72,14 @@ Requires **git**, [**pi**](https://github.com/earendil-works), and **Node β‰₯ 18
65
72
  ```
66
73
  origin repo (you) shadow clone (independent copy)
67
74
  ───────────────── ──────────────────────────────
68
- $ kage --name fix-login ──copy + seed──► ../my-app--fix-login
69
- $ pi -c (your recent context, resumed)
75
+ $ kage --name fix-login ─copy + history─► ../my-app--fix-login
76
+ $ pi (fresh session; origin history resumable)
70
77
  Β· git switch -c fix-login
71
78
  Β· edit / commit / push / open PR
72
79
  Β· quit pi
73
- $ kage finish fix-login ◄──merge memory── (session .jsonl, deduped)
80
+ $ kage finish fix-login ◄─new sessions── (the clone's .jsonl, copied back)
74
81
  Β· safety check (committed? pushed?)
75
- Β· merge session back into ~/.pi
82
+ Β· merge the clone's new sessions into ~/.pi
76
83
  Β· delete the clone folder
77
84
  code arrives via the merged GitHub PR βœ“
78
85
  ```
@@ -82,17 +89,15 @@ Requires **git**, [**pi**](https://github.com/earendil-works), and **Node β‰₯ 18
82
89
  ```bash
83
90
  cd ~/code/my-app
84
91
 
85
- kage # clone . β†’ ../my-app--kage-<ts>, seed recent context, open `pi -c`
86
- 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
87
94
  kage /path/to/other-repo # clone a different repo (path defaults to cwd)
88
- kage --blank # don't carry any context into the clone
89
- kage --recent 10 # seed the last 10 turns instead of the default 5
90
95
 
91
96
  # back in the origin after you quit the clone's pi:
92
97
  kage # no args inside a repo with clones -> interactive menu
93
- kage list # status dashboard: branch Β· dirty Β· ahead/behind Β· safe-to-clean
94
- kage list --pr # also show PR state (via gh)
95
- 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
96
101
  kage finish fix-login --pr # push the branch + open a PR (via gh), then finish
97
102
  kage finish --force # skip the uncommitted/unpushed guard
98
103
  kage rm old-experiment # discard a clone without merging (refuses if it has local-only work)
@@ -119,9 +124,9 @@ for subcommands and clone names.
119
124
 
120
125
  | Command | Run from | What it does |
121
126
  |---|---|---|
122
- | `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. |
123
- | `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`. |
124
- | `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. |
125
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. |
126
131
  | `kage pull <path...>` | inside a clone | Copy specific files/dirs (even gitignored ones) back to the origin at the same relative path. |
127
132
  | `kage shell-init` | shell rc | Print a shell wrapper (cd-back after `finish`/`rm`) + tab completion. Use `eval "$(kage shell-init)"`. |
@@ -132,23 +137,41 @@ for subcommands and clone names.
132
137
  Four invariants keep parallel work safe and lossless:
133
138
 
134
139
  1. **Isolation** β€” a clone is a full independent copy with its own `.git`.
135
- 2. **Code flows back only via git/PR.** kage never copies the clone's working tree onto the origin β€”
136
- that would re-create the very collisions it avoids. `finish` makes you commit + push first.
137
- 3. **Memory flows through `~/.pi`.** Context is *seeded in* on create and *merged back* on finish.
138
- These are pi session `.jsonl` files (not the working tree), so there's zero collision risk. The
139
- seeded prefix is **deduped** on the way back β€” only the clone's new turns are kept, so you don't
140
- 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.
141
152
  4. **The origin is read-only to kage** β€” it only copies out and writes session memory; it never
142
153
  touches the origin's working tree, even while another session is live there.
143
154
 
144
155
  ## Notes & caveats
145
156
 
146
157
  - The copy is a snapshot of the origin's **current** state, **including uncommitted changes**.
147
- - kage **doesn't create a branch** β€” the clone stays on the origin's current branch. To keep the agent
148
- from committing to it, kage injects a short in-context reminder into the clone's session, so the
149
- agent itself is told to branch first (this reminder is deduped out when memory merges back).
150
- - Context seeding reads the origin's **most recent** session file. Pass `--blank` if that isn't the
151
- 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`.
152
175
  - **Submodules**: a submodule's `.git` pointer is an absolute path and breaks on copy β€” run
153
176
  `git submodule update --init` in the clone.
154
177
  - Non-APFS / non-reflink filesystems fall back to a full (heavier) copy.
package/bin/kage.mjs CHANGED
@@ -8,28 +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
 
33
+ const VERSION = "0.3.0"; // keep in sync with package.json (enforced by test)
31
34
  const MARKER = ".kage.json";
32
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
33
37
 
34
38
  // ── output helpers ───────────────────────────────────────────────────────────
35
39
  const TTY = process.stderr.isTTY;
@@ -76,14 +80,6 @@ function readMarker(dir) {
76
80
  }
77
81
  }
78
82
 
79
- function mtime(p) {
80
- try {
81
- return statSync(p).mtimeMs;
82
- } catch {
83
- return 0;
84
- }
85
- }
86
-
87
83
  /** Copy a whole directory: clonefile on macOS, reflink on Linux, plain copy as fallback. */
88
84
  function copyTree(src, dst) {
89
85
  const isMac = process.platform === "darwin";
@@ -92,12 +88,66 @@ function copyTree(src, dst) {
92
88
  return r;
93
89
  }
94
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
+
95
130
  function tsName() {
96
131
  const d = new Date();
97
132
  const p = (n) => String(n).padStart(2, "0");
98
133
  return `kage-${p(d.getMonth() + 1)}${p(d.getDate())}-${p(d.getHours())}${p(d.getMinutes())}${p(d.getSeconds())}`;
99
134
  }
100
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
+
101
151
  function parseArgs(argv) {
102
152
  const positional = [];
103
153
  const flags = {};
@@ -128,7 +178,8 @@ function listClones(originRepo) {
128
178
  function cloneStatus(dir) {
129
179
  const branch = git(dir, ["rev-parse", "--abbrev-ref", "HEAD"]).out || "?";
130
180
  const st = git(dir, ["status", "--porcelain"]).out;
131
- 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;
132
183
  const up = git(dir, ["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"]);
133
184
  let ahead = 0;
134
185
  let behind = 0;
@@ -140,7 +191,23 @@ function cloneStatus(dir) {
140
191
  ahead = a || 0;
141
192
  }
142
193
  }
143
- 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`;
144
211
  }
145
212
 
146
213
  /** Best-effort PR lookup via gh; returns { state, number, url } or undefined. */
@@ -157,6 +224,14 @@ function prInfo(dir, branch) {
157
224
  /** True when the clone has no local-only work (clean + pushed) -> safe to remove. */
158
225
  const isSafeToClean = (s) => !s.dirty && s.hasUpstream && s.ahead === 0;
159
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
+
160
235
  // ── interactive picker (TUI-lite, arrow keys, no deps) ───────────────────────
161
236
  /** Returns the chosen index, or -1 when cancelled / non-interactive. */
162
237
  function select(title, labels) {
@@ -192,10 +267,13 @@ function select(title, labels) {
192
267
  });
193
268
  }
194
269
 
195
- async function ask(prompt) {
270
+ async function ask(prompt, prefill) {
196
271
  if (!process.stdin.isTTY) return "";
197
272
  const rl = readline.createInterface({ input: process.stdin, output: process.stderr });
198
- 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
+ });
199
277
  return a.trim();
200
278
  }
201
279
 
@@ -227,87 +305,50 @@ async function pickClone(action, name) {
227
305
  return { originRepo, clone: clones[idx] };
228
306
  }
229
307
 
230
- // ── build the clone's pi session: seeded context + a kage operational reminder ──
308
+ // ── copy the origin's session history into the clone ─────────────────────────
231
309
  /**
232
- * Writes the clone's session: the origin's last N turns (unless blank) followed by an
233
- * in-context reminder telling the agent it's in a clone and to branch before committing.
234
- * The reminder is the leaf and `seedLeafId` points at it, so the whole seeded prefix
235
- * (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).
236
315
  */
237
- function buildCloneSession(originRepo, cloneDir, recentTurns, blank) {
238
- let kept = [];
239
- let turns = 0;
240
- let preview;
241
- let srcFile;
242
-
243
- if (!blank) {
244
- const srcDir = sessionDirFor(originRepo);
245
- if (existsSync(srcDir)) {
246
- const files = readdirSync(srcDir)
247
- .filter((f) => f.endsWith(".jsonl"))
248
- .map((f) => ({ f, m: mtime(join(srcDir, f)) }))
249
- .sort((a, b) => b.m - a.m);
250
- if (files.length) {
251
- srcFile = join(srcDir, files[0].f);
252
- const lines = readFileSync(srcFile, "utf8").split("\n").filter((l) => l.trim());
253
- if (lines.length >= 2) {
254
- const entries = lines.slice(1).map((l) => JSON.parse(l));
255
- const byId = new Map(entries.map((e) => [e.id, e]));
256
- let cur = entries[entries.length - 1];
257
- const branch = [];
258
- while (cur) {
259
- branch.unshift(cur);
260
- cur = cur.parentId ? byId.get(cur.parentId) : undefined;
261
- }
262
- const messages = branch.filter((e) => e.type === "message");
263
- const userIdx = [];
264
- messages.forEach((e, i) => {
265
- if (e.message?.role === "user") userIdx.push(i);
266
- });
267
- const start = userIdx.length > recentTurns ? userIdx[userIdx.length - recentTurns] : 0;
268
- kept = messages.slice(start);
269
- turns = userIdx.length > recentTurns ? recentTurns : userIdx.length;
270
- const fu = kept.find((e) => e.message?.role === "user");
271
- preview = fu ? snippet(fu.message.content) : undefined;
272
- }
273
- }
274
- }
275
- }
276
-
316
+ function copyOriginHistory(originRepo, cloneDir) {
317
+ const srcDir = sessionDirFor(originRepo);
318
+ if (!existsSync(srcDir)) return 0;
277
319
  const destDir = sessionDirFor(cloneDir);
278
320
  mkdirSync(destDir, { recursive: true });
279
- const id = randomUUID();
280
- const ts = new Date().toISOString();
281
- const fname = `${ts.replace(/[:.]/g, "-")}_${id}.jsonl`;
282
- const header = { type: "session", version: 3, id, timestamp: ts, cwd: cloneDir, ...(srcFile ? { parentSession: srcFile } : {}) };
283
- const outl = [JSON.stringify(header)];
284
- let prev = null;
285
- for (const e of kept) {
286
- outl.push(JSON.stringify({ ...e, parentId: prev }));
287
- 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++;
288
338
  }
289
-
290
- const curBranch = git(cloneDir, ["rev-parse", "--abbrev-ref", "HEAD"]).out || "the base branch";
291
- const hintId = randomUUID().replace(/-/g, "").slice(0, 8);
292
- const hint =
293
- `[kage] You are working in a shadow clone of ${originRepo} (folder: ${cloneDir}), ` +
294
- `currently on branch "${curBranch}". Before committing, create a dedicated feature branch ` +
295
- `(git switch -c <name>), then push it and open a PR β€” do not commit directly to "${curBranch}".`;
296
- outl.push(
297
- JSON.stringify({ type: "custom_message", id: hintId, parentId: prev, timestamp: ts, customType: "kage", content: hint, display: true }),
298
- );
299
- writeFileSync(join(destDir, fname), outl.join("\n") + "\n");
300
- return { seedFile: fname, seedLeafId: hintId, turns, preview, seeded: kept.length > 0 };
301
- }
302
-
303
- function snippet(content) {
304
- const text = typeof content === "string" ? content : (content || []).map((c) => c.text || "").join(" ");
305
- const one = text.replace(/\s+/g, " ").trim();
306
- return one.length > 60 ? one.slice(0, 57) + "…" : one;
339
+ return n;
307
340
  }
308
341
 
309
- // ── merge session memory back (deduped) ──────────────────────────────────────
310
- 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) {
311
352
  const srcDir = sessionDirFor(cloneDir);
312
353
  if (!existsSync(srcDir)) return 0;
313
354
  const destDir = sessionDirFor(originRepo);
@@ -315,36 +356,52 @@ function mergeBack(cloneDir, originRepo, marker) {
315
356
  let n = 0;
316
357
  for (const f of readdirSync(srcDir)) {
317
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;
318
361
  const dest = join(destDir, f);
319
- if (existsSync(dest)) continue;
320
- const lines = readFileSync(join(srcDir, f), "utf8").split("\n").filter((l) => l.trim());
321
- 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;
322
396
  let header;
323
397
  try {
324
- header = JSON.parse(lines[0]);
398
+ header = JSON.parse(src[0]);
325
399
  } catch {
326
400
  continue;
327
401
  }
328
- header.cwd = originRepo;
329
- let body = lines.slice(1);
330
-
331
- if (marker?.seedFile === f && marker?.seedLeafId) {
332
- const idx = body.findIndex((l) => {
333
- try {
334
- return JSON.parse(l).id === marker.seedLeafId;
335
- } catch {
336
- return false;
337
- }
338
- });
339
- if (idx >= 0) {
340
- body = body.slice(idx + 1);
341
- if (body.length === 0) continue;
342
- const first = JSON.parse(body[0]);
343
- first.parentId = null;
344
- body[0] = JSON.stringify(first);
345
- }
346
- }
347
- 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");
348
405
  n++;
349
406
  }
350
407
  try {
@@ -355,16 +412,25 @@ function mergeBack(cloneDir, originRepo, marker) {
355
412
  return n;
356
413
  }
357
414
 
358
- /** Ask the shell wrapper (kage shell-init) to cd somewhere after we exit. */
359
- 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) {
360
422
  const f = process.env.KAGE_CD_FILE;
361
423
  if (f) {
362
424
  try {
363
- writeFileSync(f, path);
425
+ writeFileSync(f, originRepo);
426
+ info(paint.dim(` ↩ back to ${originRepo}`));
427
+ return;
364
428
  } catch {
365
- /* ignore */
429
+ /* fall through to the manual hint */
366
430
  }
367
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`));
368
434
  }
369
435
 
370
436
  function launchPi(cwd, args) {
@@ -381,7 +447,7 @@ async function cmdNew(argv) {
381
447
  let path = positional[0];
382
448
 
383
449
  // Interactive launcher: `kage` with no args, inside a repo that already has clones.
384
- if (!path && !flags.name && !flags.blank && process.stdin.isTTY) {
450
+ if (!path && !flags.name && process.stdin.isTTY) {
385
451
  const repoRoot = repoTopLevel(process.cwd());
386
452
  const clones = repoRoot ? listClones(repoRoot) : [];
387
453
  if (clones.length > 0) {
@@ -408,35 +474,38 @@ async function cmdNew(argv) {
408
474
  if (act === 2) return cmdRm([clone.name]);
409
475
  return info("cancelled");
410
476
  }
411
- const nm = await ask("Name (blank = auto): ");
412
- if (nm) flags.name = nm;
477
+ // idx === 0: "create" β€” fall through to the name prompt below.
413
478
  }
414
479
  }
415
480
 
416
481
  const targetPath = path ? resolve(path) : process.cwd();
417
- const blank = !!flags.blank;
418
- const recent = Math.max(1, parseInt(flags.recent, 10) || 5);
419
482
 
420
483
  const repoRoot = repoTopLevel(targetPath);
421
484
  if (!repoRoot) die(`not a git repository: ${targetPath}`);
422
485
  if (existsSync(join(repoRoot, MARKER))) die("already inside a clone; run kage from the origin repo");
423
486
 
424
- const name = (typeof flags.name === "string" && flags.name) || tsName();
425
- 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);
426
497
  const cloneDir = join(dirname(repoRoot), `${basename(repoRoot)}--${safe}`);
427
498
  if (existsSync(cloneDir)) die(`directory already exists: ${cloneDir}`);
428
499
 
429
- const cp = copyTree(repoRoot, cloneDir);
500
+ const cp = await copyRepo(repoRoot, cloneDir);
430
501
  if (!cp.ok) die(`copy failed: ${cp.err}`);
431
502
 
432
503
  // kage does NOT create a branch β€” the clone stays on the origin's current branch.
433
- const built = buildCloneSession(repoRoot, cloneDir, recent, blank);
504
+ const histN = copyOriginHistory(repoRoot, cloneDir);
434
505
  const marker = {
435
506
  originRepo: repoRoot,
436
507
  name: safe,
437
508
  createdAt: new Date().toISOString(),
438
- seedFile: built.seedFile,
439
- seedLeafId: built.seedLeafId,
440
509
  };
441
510
  writeFileSync(join(cloneDir, MARKER), JSON.stringify(marker, null, 2));
442
511
 
@@ -444,17 +513,11 @@ async function cmdNew(argv) {
444
513
  info("");
445
514
  info(`πŸ₯· ${paint.bold("Shadow clone ready")}: ${cloneDir}`);
446
515
  info(` origin: ${repoRoot} branch: ${paint.cyan(curBranch)}`);
447
- if (built.seeded) {
448
- info(` seeded with the last ${built.turns} turn(s) + a kage reminder (pi -c resumes them)`);
449
- if (built.preview) info(paint.dim(` ↳ "${built.preview}"`));
450
- } else {
451
- info(paint.dim(" blank clone (kage reminder only)"));
452
- }
453
- 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)`));
454
517
  info(paint.dim(` when done: kage finish ${safe}`));
455
518
  info("");
456
519
 
457
- launchPi(cloneDir, ["-c"]);
520
+ launchPi(cloneDir, []);
458
521
  info("");
459
522
  info(`β†©οΈŽ left the clone's pi. To finish: ${paint.bold(`kage finish ${safe}`)}`);
460
523
  }
@@ -494,16 +557,32 @@ async function cmdFinish(argv) {
494
557
  }
495
558
  }
496
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
+
497
564
  if (!force) {
498
- const s = cloneStatus(clone.dir);
499
- const problems = [];
500
- if (s.dirty) problems.push("uncommitted changes");
501
- if (!s.hasUpstream) problems.push("branch not pushed (no upstream)");
502
- else if (s.ahead > 0) problems.push(`${s.ahead} unpushed commit(s)`);
503
- 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})`);
504
583
  }
505
584
 
506
- const n = mergeBack(clone.dir, originRepo, clone.marker);
585
+ const n = mergeBack(clone.dir, originRepo);
507
586
  try {
508
587
  process.chdir(originRepo);
509
588
  } catch {
@@ -512,10 +591,7 @@ async function cmdFinish(argv) {
512
591
  rmSync(clone.dir, { recursive: true, force: true });
513
592
 
514
593
  info(`πŸ’¨ Clone dispelled: merged ${n} session(s) back, removed ${clone.dir}`);
515
- if (insideClone) {
516
- requestCd(originRepo);
517
- info(paint.dim(` cd back to: ${originRepo} (auto with: eval "$(kage shell-init)")`));
518
- }
594
+ if (insideClone) leaveClone(originRepo);
519
595
  }
520
596
 
521
597
  async function cmdRm(argv) {
@@ -528,7 +604,7 @@ async function cmdRm(argv) {
528
604
 
529
605
  if (!force) {
530
606
  const s = cloneStatus(clone.dir);
531
- if (s.dirty || !s.hasUpstream || s.ahead > 0) {
607
+ if (s.dirty || hasUnpreservedCommits(originRepo, clone.dir, s)) {
532
608
  die(`${clone.name} has local-only work β€” use 'kage finish' to keep it, or 'kage rm --force' to discard`);
533
609
  }
534
610
  if (!(await confirm(`Discard clone ${clone.name} without merging its memory?`))) return info("aborted");
@@ -546,44 +622,56 @@ async function cmdRm(argv) {
546
622
  }
547
623
  rmSync(clone.dir, { recursive: true, force: true });
548
624
  info(`πŸ—‘ Removed clone ${clone.name} (${clone.dir})`);
549
- if (insideClone) {
550
- requestCd(originRepo);
551
- info(paint.dim(` cd back to: ${originRepo} (auto with: eval "$(kage shell-init)")`));
552
- }
625
+ if (insideClone) leaveClone(originRepo);
553
626
  }
554
627
 
555
628
  function cmdList(argv) {
556
629
  const { flags } = parseArgs(argv);
557
- const repoRoot = repoTopLevel(process.cwd());
558
- 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;
559
634
  const clones = listClones(repoRoot);
560
635
  if (clones.length === 0) return info("No shadow clones.");
561
636
 
562
- const rows = clones.map((c) => {
563
- const s = cloneStatus(c.dir);
564
- return { c, s, pr: flags.pr ? prInfo(c.dir, s.branch) : undefined };
565
- });
566
- const nameW = Math.max(...rows.map((r) => r.c.name.length), 4);
567
- const brW = Math.max(...rows.map((r) => r.s.branch.length), 6);
568
-
569
637
  info(paint.bold(`Shadow clones of ${basename(repoRoot)}:`));
570
638
  info("");
571
- for (const { c, s, pr } of rows) {
572
- const dirty = s.dirty ? paint.yellow("● dirty") : paint.green("clean ");
573
- let sync;
574
- 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"));
575
658
  else {
576
- const parts = [];
577
- if (s.ahead) parts.push(`↑${s.ahead}`);
578
- if (s.behind) parts.push(`↓${s.behind}`);
579
- 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"));
580
663
  }
581
- const prStr = pr ? ` ${prState(pr)}` : "";
582
- const safe = isSafeToClean(s) ? paint.green(" βœ“ safe to clean") : "";
583
- 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("");
584
673
  }
585
- info("");
586
- 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"));
587
675
  }
588
676
 
589
677
  function prState(pr) {
@@ -634,7 +722,7 @@ kage() {
634
722
  }
635
723
  if [ -n "$ZSH_VERSION" ]; then
636
724
  _kage() {
637
- 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
638
726
  case "\${words[2]}" in
639
727
  finish|rm) compadd $(command kage __clones 2>/dev/null);;
640
728
  esac
@@ -643,7 +731,7 @@ if [ -n "$ZSH_VERSION" ]; then
643
731
  elif [ -n "$BASH_VERSION" ]; then
644
732
  _kage() {
645
733
  local cur="\${COMP_WORDS[COMP_CWORD]}"
646
- 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
647
735
  case "\${COMP_WORDS[1]}" in
648
736
  finish|rm) COMPREPLY=( $(compgen -W "$(command kage __clones 2>/dev/null)" -- "$cur") );;
649
737
  esac
@@ -660,20 +748,42 @@ function cmdClones() {
660
748
  const HELP = `kage πŸ₯· β€” Shadow Clone Jutsu for your git repo
661
749
 
662
750
  Usage:
663
- kage [path] [--name <x>] [--blank] [--recent <N>] clone repo + launch pi
751
+ kage [path] [--name <x>] clone repo + launch a fresh pi
664
752
  (no args inside a repo with clones: interactive menu)
665
- kage list [--pr] dashboard of clones (--pr adds PR status via gh)
666
- kage finish [name] [--force] [--push] [--pr] check -> merge memory back -> delete clone
667
- (--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)
668
757
  kage rm [name] [--force] discard a clone without merging
669
758
  kage pull <path...> (inside a clone) copy files back to the origin
670
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).
671
764
 
672
765
  Options:
673
- --name <x> name the clone folder /<repo>--<x> (default: kage-<timestamp>)
674
- --blank don't seed the clone with the origin's recent context
675
- --recent <N> number of recent turns to seed (default: 5)
676
- --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
677
787
 
678
788
  Env:
679
789
  KAGE_SESSIONS_DIR pi session storage (default: ~/.pi/agent/sessions)`;
@@ -684,7 +794,8 @@ async function main() {
684
794
  case undefined:
685
795
  case "new":
686
796
  return cmdNew(sub === "new" ? rest : process.argv.slice(2));
687
- case "list":
797
+ case "status":
798
+ case "list": // alias
688
799
  return cmdList(rest);
689
800
  case "finish":
690
801
  return cmdFinish(rest);
@@ -702,9 +813,14 @@ async function main() {
702
813
  return info(HELP);
703
814
  case "-v":
704
815
  case "--version":
705
- return info(JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version);
816
+ return info(VERSION);
706
817
  default:
707
- 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>`
708
824
  }
709
825
  }
710
826
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-kage",
3
- "version": "0.2.0",
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",