@xbghc/warden 0.5.0 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -14,7 +14,7 @@ with line comments you can copy back to the agent as a prompt.
14
14
  - Comments follow the code: they move with a hunk that gets staged, re-attach after the agent edits the file, and are cleaned up once the change is committed.
15
15
  - Auto-refresh — warden watches the repository and reloads itself when you edit, stage, commit or switch branches.
16
16
  - Local issues (title, Markdown body, open/closed) that link comments.
17
- - Branch-scoped todos, exportable as a numbered checklist.
17
+ - Branch-scoped todos, each copied to the clipboard on its own — one task at a time for the agent.
18
18
  - Commit history browser: grouped by day, branch / tag labels, search by message, sha, author or
19
19
  path (each a `git log` on the server, not a filter over the rows already loaded), and a
20
20
  first-parent view that folds merged branches into their merge commits.
@@ -57,19 +57,30 @@ Several instances on the same repository can run at the same time.
57
57
 
58
58
  Refs accept anything git can resolve (`main`, `v1.2`, `HEAD~3`, a sha). `@` is `HEAD`.
59
59
  Worktrees are discovered with `git worktree list` and share the review state of the main repository.
60
+ One whose directory is gone (git lists it as *prunable*) is not offered, and a remembered target
61
+ inside a removed worktree falls back to the working tree on the next load. The comments and viewed
62
+ flags kept under that worktree's key stay in the state file and come back if a worktree is created
63
+ at the same path again.
64
+
65
+ There is no target switcher in the UI: the sidebar's two blocks *are* the working tree, a commit is
66
+ picked from the *Commits* panel, and the *Branch vs* and *Range* fields at the top of that panel open
67
+ the other two. The sidebar names whatever is under review in its block header and puts a
68
+ *返回工作区* link above it.
60
69
 
61
70
  `working`, `staged` and `all` are the three **local views** of one worktree. While any of them is
62
71
  selected the sidebar shows two blocks — Unstaged (`working`) and Staged (`staged`) — instead of a
63
72
  single tree, and clicking a file switches to the view it belongs to. A file that is only partly
64
73
  staged appears in both. Switching between the three keeps your comments, the draft you are typing
65
- and the current selection; only the diff is reloaded. Commit, range and `base` targets keep the single tree.
74
+ and the current selection; only the diff is reloaded. `all` has no control of its own (a saved
75
+ `lastTarget` can still restore it; *Branch vs* `@` shows the same diff). Commit, range and `base`
76
+ targets keep the single tree.
66
77
 
67
78
  `base:<ref>` is for a branch a coding agent has been working on, committing as it goes: one tree with
68
79
  everything since the branch forked off `<ref>` — the commits plus whatever is still uncommitted or
69
80
  untracked. The diff runs against the merge base, so commits that landed on `<ref>` after the fork are
70
- not listed as reverted (which is what a plain `git diff <ref>` would do). The *Branch* button picks the
71
- base for you — the main worktree's branch when a sibling worktree is under review, otherwise `main` or
72
- `master` — and the field next to it takes any ref. Unlike the three local views, `base` keeps its own
81
+ not listed as reverted (which is what a plain `git diff <ref>` would do). The *Branch vs* field in the
82
+ Commits panel suggests the base for you — the main worktree's branch when a sibling worktree is under
83
+ review, otherwise `main` or `master` — and takes any ref. Unlike the three local views, `base` keeps its own
73
84
  pool of comments and a commit never deletes them: the round under review is not over when the agent
74
85
  commits, so a comment whose lines changed stays *orphaned*, snippet and all, until you have checked
75
86
  the fix and delete or re-attach it.
@@ -147,8 +158,8 @@ bound to a hash of the file's diff; when the diff changes the flag is dropped an
147
158
 
148
159
  ## Auto-refresh
149
160
 
150
- warden polls the worktree every 1.5 s while the page is open (`git status --porcelain`, HEAD, the
151
- branch name, plus mtime and size of the paths status reports — enough to notice a second edit to an
161
+ warden polls the worktree every 1.5 s while the page is open (`git status --porcelain
162
+ --untracked-files=all`, HEAD, the branch name, plus mtime and size of the paths status reports — enough to notice a second edit to an
152
163
  already-modified file) and pushes a change event over `GET /api/events` (SSE). The page then re-runs
153
164
  the normal refresh: re-anchor, reload both file lists, reload the open diff. Your draft comment,
154
165
  selection, current view and current file are left alone, and the diff is scrolled back to where you
@@ -161,19 +172,16 @@ Notes attached to a branch rather than to a line of code, for the things you not
161
172
  that do not belong in a comment. They are not deleted when you commit. They sit in the right-hand
162
173
  rail next to the comments, under the *Todo* tab: type a title and press Enter to add one, expand a
163
174
  card to read or edit its Markdown body, filter by status, and switch between the current branch
164
- and all branches; *复制 Todo* copies the open ones
165
- (optionally including the done ones, marked ` (done)`) as:
175
+ and all branches.
176
+
177
+ Each card has its own *复制* button, and there is deliberately no "copy all": a todo is one task to
178
+ hand to an agent, and the agent it goes to is already working in that branch. So the clipboard gets
179
+ the title and the body — no branch, repo, count or numbering:
166
180
 
167
181
  ```markdown
168
- # TODO
169
- Branch: feature/x
170
- Repo: /home/user/project
171
- Count: 2
182
+ 补充 OrderList 的空状态
172
183
 
173
- ## 1. 补充 OrderList 的空状态
174
184
  描述正文(Markdown)…
175
-
176
- ## 2. useOrder 的依赖数组缺少 id
177
185
  ```
178
186
 
179
187
  ## nvim integration
package/dist/cli.js CHANGED
@@ -3,7 +3,7 @@
3
3
  // bin/cli.ts
4
4
  import path8 from "path";
5
5
  import { spawn } from "child_process";
6
- import { existsSync } from "fs";
6
+ import { existsSync as existsSync2 } from "fs";
7
7
  import { fileURLToPath } from "url";
8
8
 
9
9
  // packages/server/src/index.ts
@@ -3025,6 +3025,7 @@ function parseCommitLog(stdout) {
3025
3025
 
3026
3026
  // packages/server/src/git.ts
3027
3027
  import { execFile } from "child_process";
3028
+ import { existsSync } from "fs";
3028
3029
  var ALLOWED_SUBCOMMANDS = /* @__PURE__ */ new Set(["rev-parse", "diff", "show", "log", "worktree", "ls-files", "status", "merge-base"]);
3029
3030
  var FORBIDDEN_OPTION_PREFIXES = ["--output", "--ext-diff", "--textconv", "-c", "--config-env", "--exec-path", "--git-dir", "--work-tree"];
3030
3031
  var DEFAULT_GIT_TIMEOUT_MS = 3e4;
@@ -3087,6 +3088,10 @@ async function runGit(args, opts) {
3087
3088
  return;
3088
3089
  }
3089
3090
  if (err.code === "ENOENT") {
3091
+ if (opts.cwd && !existsSync(opts.cwd)) {
3092
+ reject(new GitError(`working directory no longer exists: ${opts.cwd}`, null, "", 400, "unknown_worktree"));
3093
+ return;
3094
+ }
3090
3095
  reject(new GitError("git executable not found in PATH", null, "", 500, "git_missing"));
3091
3096
  return;
3092
3097
  }
@@ -3159,7 +3164,7 @@ async function listWorktrees(ctx) {
3159
3164
  const out = [];
3160
3165
  let cur = null;
3161
3166
  const flush = () => {
3162
- if (cur && cur.path) {
3167
+ if (cur && cur.path && !cur.prunable) {
3163
3168
  out.push({
3164
3169
  path: cur.path,
3165
3170
  head: cur.head ?? "",
@@ -3185,6 +3190,7 @@ async function listWorktrees(ctx) {
3185
3190
  else if (line.startsWith("branch ")) cur.branch = line.slice(7).replace(/^refs\/heads\//, "");
3186
3191
  else if (line === "detached") cur.detached = true;
3187
3192
  else if (line === "bare") cur.bare = true;
3193
+ else if (line.startsWith("prunable")) cur.prunable = true;
3188
3194
  }
3189
3195
  flush();
3190
3196
  for (const wt of out) {
@@ -3387,11 +3393,11 @@ function parseUnifiedDiff(text) {
3387
3393
  continue;
3388
3394
  }
3389
3395
  if (line.startsWith("--- ")) {
3390
- c.fromMinus = stripPrefix(line.slice(4), "a/");
3396
+ c.fromMinus = stripPrefix(line.slice(4).replace(/\t$/, ""), "a/");
3391
3397
  continue;
3392
3398
  }
3393
3399
  if (line.startsWith("+++ ")) {
3394
- c.fromPlus = stripPrefix(line.slice(4), "b/");
3400
+ c.fromPlus = stripPrefix(line.slice(4).replace(/\t$/, ""), "b/");
3395
3401
  continue;
3396
3402
  }
3397
3403
  if (line.startsWith("new file mode ")) {
@@ -3464,6 +3470,10 @@ function resolveTargetContext(repo, worktrees, key) {
3464
3470
  }
3465
3471
  return { key, target, cwd };
3466
3472
  }
3473
+ function assertRepoPath(p) {
3474
+ if (!p || p.startsWith("/") || p.split("/").includes("..")) throw badRequest(`invalid path: ${p}`, "invalid_path");
3475
+ }
3476
+ var literal = (p) => `:(literal)${p}`;
3467
3477
  async function hasHead(cwd) {
3468
3478
  return refExists(cwd, "HEAD");
3469
3479
  }
@@ -3491,6 +3501,7 @@ async function diffArgs(ctx) {
3491
3501
  case "range": {
3492
3502
  if (!await refExists(ctx.cwd, t.base)) throw badRequest(`unknown ref: ${t.base}`, "unknown_ref");
3493
3503
  if (!await refExists(ctx.cwd, t.head)) throw badRequest(`unknown ref: ${t.head}`, "unknown_ref");
3504
+ if (!await mergeBase(ctx.cwd, t.base, t.head)) throw badRequest(`${t.base} and ${t.head} share no history`, "no_merge_base");
3494
3505
  return [...DIFF_BASE_ARGS, `${t.base}...${t.head}`];
3495
3506
  }
3496
3507
  case "base":
@@ -3505,7 +3516,7 @@ async function listUntracked(cwd) {
3505
3516
  return r.stdout.split("\0").filter(Boolean);
3506
3517
  }
3507
3518
  async function isUntracked(cwd, file) {
3508
- const r = await runGit(["ls-files", "--others", "--exclude-standard", "-z", "--", file], { cwd });
3519
+ const r = await runGit(["ls-files", "--others", "--exclude-standard", "-z", "--", literal(file)], { cwd });
3509
3520
  return r.stdout.split("\0").filter(Boolean).includes(file);
3510
3521
  }
3511
3522
  async function untrackedDiff(cwd, file) {
@@ -3551,15 +3562,14 @@ function toSummary(f) {
3551
3562
  return rest;
3552
3563
  }
3553
3564
  async function getFileDiff(ctx, filePath, hints = {}) {
3554
- if (!filePath || filePath.startsWith("/") || filePath.split("/").includes("..")) {
3555
- throw badRequest(`invalid path: ${filePath}`, "invalid_path");
3556
- }
3565
+ assertRepoPath(filePath);
3566
+ if (hints.oldPath) assertRepoPath(hints.oldPath);
3557
3567
  if (includesUntracked(ctx.target) && (hints.untracked || await isUntracked(ctx.cwd, filePath))) {
3558
3568
  return untrackedDiff(ctx.cwd, filePath);
3559
3569
  }
3560
3570
  const args = await diffArgs(ctx);
3561
- const pathspec = [filePath];
3562
- if (hints.oldPath && hints.oldPath !== filePath) pathspec.push(hints.oldPath);
3571
+ const pathspec = [literal(filePath)];
3572
+ if (hints.oldPath && hints.oldPath !== filePath) pathspec.push(literal(hints.oldPath));
3563
3573
  const r = await runGit([...args, "--", ...pathspec], { cwd: ctx.cwd });
3564
3574
  const files = parseUnifiedDiff(r.stdout);
3565
3575
  return files.find((f) => f.path === filePath) ?? files[0];
@@ -3583,9 +3593,7 @@ async function refForSide(ctx, side) {
3583
3593
  }
3584
3594
  }
3585
3595
  async function getFullFile(ctx, filePath, side) {
3586
- if (!filePath || filePath.startsWith("/") || filePath.split("/").includes("..")) {
3587
- throw badRequest(`invalid path: ${filePath}`, "invalid_path");
3588
- }
3596
+ assertRepoPath(filePath);
3589
3597
  const ref = await refForSide(ctx, side);
3590
3598
  if (ref === void 0) {
3591
3599
  try {
@@ -3751,6 +3759,8 @@ function ensureTarget(state, key) {
3751
3759
  }
3752
3760
  return t;
3753
3761
  }
3762
+ var usable = (v) => !!v && typeof v === "object" && typeof v.id === "string";
3763
+ var usableComment = (v) => usable(v) && typeof v.anchor === "object" && !!v.anchor;
3754
3764
  function normalise(raw2, repoRoot) {
3755
3765
  const base = defaultState(repoRoot);
3756
3766
  if (!raw2 || typeof raw2 !== "object") return base;
@@ -3761,7 +3771,7 @@ function normalise(raw2, repoRoot) {
3761
3771
  if (!v || typeof v !== "object") continue;
3762
3772
  targets[k] = {
3763
3773
  viewed: typeof v.viewed === "object" && v.viewed ? v.viewed : {},
3764
- comments: Array.isArray(v.comments) ? v.comments : [],
3774
+ comments: Array.isArray(v.comments) ? v.comments.filter(usableComment) : [],
3765
3775
  ...typeof v.head === "string" ? { head: v.head } : {}
3766
3776
  };
3767
3777
  }
@@ -3770,8 +3780,8 @@ function normalise(raw2, repoRoot) {
3770
3780
  schemaVersion: 1,
3771
3781
  repoRoot: r.repoRoot ?? repoRoot,
3772
3782
  targets,
3773
- issues: Array.isArray(r.issues) ? r.issues : [],
3774
- todos: Array.isArray(r.todos) ? r.todos : [],
3783
+ issues: Array.isArray(r.issues) ? r.issues.filter(usable) : [],
3784
+ todos: Array.isArray(r.todos) ? r.todos.filter(usable) : [],
3775
3785
  prefs: {
3776
3786
  ...defaultPrefs(),
3777
3787
  ...r.prefs ?? {},
@@ -3960,16 +3970,6 @@ function formatIssueExport({ repoRoot, issue, comments }) {
3960
3970
  parts.push(...comments.map(formatCommentSection));
3961
3971
  return parts.join("\n\n") + "\n";
3962
3972
  }
3963
- function formatTodosExport({ repoRoot, branch, todos }) {
3964
- const head = ["# TODO", `Branch: ${branch}`, `Repo: ${repoRoot}`, `Count: ${todos.length}`];
3965
- const sections = todos.map((t, i) => {
3966
- const title = `## ${i + 1}. ${t.title}${t.status === "done" ? " (done)" : ""}`;
3967
- const body = t.body.replace(/\r\n/g, "\n").trim();
3968
- return body ? `${title}
3969
- ${body}` : title;
3970
- });
3971
- return [head.join("\n"), ...sections].join("\n\n") + "\n";
3972
- }
3973
3973
 
3974
3974
  // packages/server/src/nvim.ts
3975
3975
  import os2 from "os";
@@ -4176,7 +4176,9 @@ var RepoWatcher = class {
4176
4176
  }
4177
4177
  async snapshot() {
4178
4178
  const [statusRes, headRes, branch] = await Promise.all([
4179
- runGit(["status", "--porcelain", "-z"], { cwd: this.root }),
4179
+ // Untracked files one by one: folded into their directory, an edit inside a directory the
4180
+ // agent just created would leave the directory's own mtime and size untouched.
4181
+ runGit(["status", "--porcelain", "-z", "--untracked-files=all"], { cwd: this.root }),
4180
4182
  runGit(["rev-parse", "HEAD"], { cwd: this.root }).catch(() => ({ stdout: "" })),
4181
4183
  // The same label getRepoInfo reports, so the branch identity the client filters todos by
4182
4184
  // never flips between `HEAD` and a short sha depending on which of the two spoke last.
@@ -4285,13 +4287,6 @@ function createApp(opts) {
4285
4287
  throw e;
4286
4288
  }
4287
4289
  };
4288
- const decodeKey = (raw2) => {
4289
- try {
4290
- return decodeURIComponent(raw2);
4291
- } catch {
4292
- throw badRequest("malformed target key", "invalid_target");
4293
- }
4294
- };
4295
4290
  const fileDiffWithHints = async (ctx, filePath, explicit) => {
4296
4291
  const cached = listings.get(ctx.key);
4297
4292
  const hint = cached && Date.now() - cached.at < LISTING_HINT_TTL_MS ? cached.files.find((f) => f.path === filePath) : void 0;
@@ -4318,8 +4313,13 @@ function createApp(opts) {
4318
4313
  api.get("/ping", (c) => c.text(opts.instanceToken ?? ""));
4319
4314
  api.get("/repo", async (c) => {
4320
4315
  const state = await store.load();
4321
- const info = await getRepoInfo(repo, state.prefs.lastTarget ?? "working");
4316
+ const info = await getRepoInfo(repo, "working");
4322
4317
  worktreeCache = { at: Date.now(), list: info.worktrees };
4318
+ const last = state.prefs.lastTarget;
4319
+ const target = last ? tryParseTargetKey(last) : void 0;
4320
+ const usable2 = !!target && (!target.worktree || info.worktrees.some((w) => w.path === target.worktree));
4321
+ if (last && usable2) info.defaultTarget = last;
4322
+ else if (last) await store.update((s) => void (s.prefs.lastTarget = "working"));
4323
4323
  return c.json(info);
4324
4324
  });
4325
4325
  api.get("/state", async (c) => c.json(await store.load()));
@@ -4337,7 +4337,7 @@ function createApp(opts) {
4337
4337
  return c.json(prefs);
4338
4338
  });
4339
4339
  api.get("/targets/:key/files", async (c) => {
4340
- const key = decodeKey(c.req.param("key"));
4340
+ const key = c.req.param("key");
4341
4341
  const ctx = await targetCtx(key);
4342
4342
  const diffs = await listTargetDiffs(ctx);
4343
4343
  listings.set(key, { at: Date.now(), files: diffs });
@@ -4373,7 +4373,7 @@ function createApp(opts) {
4373
4373
  return c.json(res);
4374
4374
  });
4375
4375
  api.get("/targets/:key/file", async (c) => {
4376
- const key = decodeKey(c.req.param("key"));
4376
+ const key = c.req.param("key");
4377
4377
  const filePath = c.req.query("path");
4378
4378
  if (!filePath) throw badRequest("missing path");
4379
4379
  const ctx = await targetCtx(key);
@@ -4384,7 +4384,7 @@ function createApp(opts) {
4384
4384
  return c.json(diff);
4385
4385
  });
4386
4386
  api.get("/targets/:key/file/full", async (c) => {
4387
- const key = decodeKey(c.req.param("key"));
4387
+ const key = c.req.param("key");
4388
4388
  const filePath = c.req.query("path");
4389
4389
  if (!filePath) throw badRequest("missing path");
4390
4390
  const side = c.req.query("side") === "old" ? "old" : "new";
@@ -4394,7 +4394,7 @@ function createApp(opts) {
4394
4394
  return c.json(res);
4395
4395
  });
4396
4396
  api.put("/targets/:key/viewed", async (c) => {
4397
- const key = decodeKey(c.req.param("key"));
4397
+ const key = c.req.param("key");
4398
4398
  await targetCtx(key);
4399
4399
  const body = await c.req.json();
4400
4400
  if (!body.path) throw badRequest("missing path");
@@ -4408,12 +4408,12 @@ function createApp(opts) {
4408
4408
  return c.json({ viewed });
4409
4409
  });
4410
4410
  api.get("/targets/:key/comments", async (c) => {
4411
- const key = decodeKey(c.req.param("key"));
4411
+ const key = c.req.param("key");
4412
4412
  const state = await store.load();
4413
4413
  return c.json({ comments: state.targets[commentScopeKey(key)]?.comments ?? [] });
4414
4414
  });
4415
4415
  api.post("/targets/:key/comments", async (c) => {
4416
- const key = decodeKey(c.req.param("key"));
4416
+ const key = c.req.param("key");
4417
4417
  const ctx = await targetCtx(key);
4418
4418
  const body = await c.req.json();
4419
4419
  if (!body.filePath || typeof body.body !== "string" || !body.body.trim()) throw badRequest("filePath and body are required");
@@ -4446,7 +4446,7 @@ function createApp(opts) {
4446
4446
  return c.json(comment, 201);
4447
4447
  });
4448
4448
  api.patch("/targets/:key/comments/:id", async (c) => {
4449
- const key = decodeKey(c.req.param("key"));
4449
+ const key = c.req.param("key");
4450
4450
  const id = c.req.param("id");
4451
4451
  const ctx = await targetCtx(key);
4452
4452
  const body = await c.req.json();
@@ -4491,7 +4491,7 @@ function createApp(opts) {
4491
4491
  return c.json(updated);
4492
4492
  });
4493
4493
  api.delete("/targets/:key/comments/:id", async (c) => {
4494
- const key = decodeKey(c.req.param("key"));
4494
+ const key = c.req.param("key");
4495
4495
  const id = c.req.param("id");
4496
4496
  const removed = await store.update((s) => {
4497
4497
  const t = s.targets[commentScopeKey(key)];
@@ -4505,7 +4505,7 @@ function createApp(opts) {
4505
4505
  return c.json({ ok: true });
4506
4506
  });
4507
4507
  api.post("/targets/:key/comments/reanchor", async (c) => {
4508
- const key = decodeKey(c.req.param("key"));
4508
+ const key = c.req.param("key");
4509
4509
  const ctx = await targetCtx(key);
4510
4510
  const scope = commentScopeKey(key);
4511
4511
  const local = isLocalTarget(ctx.target);
@@ -4721,15 +4721,6 @@ function createApp(opts) {
4721
4721
  });
4722
4722
  return c.json(todo, 201);
4723
4723
  });
4724
- api.post("/todos/export", async (c) => {
4725
- const body = await c.req.json();
4726
- const branch = typeof body.branch === "string" ? body.branch.trim() : "";
4727
- if (!branch) throw badRequest("branch is required");
4728
- const s = await store.load();
4729
- const todos = s.todos.filter((t) => t.branch === branch && (body.includeDone === true || t.status === "open"));
4730
- const res = { text: formatTodosExport({ repoRoot: repo.root, branch, todos }), count: todos.length };
4731
- return c.json(res);
4732
- });
4733
4724
  api.patch("/todos/:id", async (c) => {
4734
4725
  const id = c.req.param("id");
4735
4726
  const body = await c.req.json();
@@ -4978,7 +4969,7 @@ async function startServer(opts) {
4978
4969
  }
4979
4970
 
4980
4971
  // bin/cli.ts
4981
- var VERSION = true ? "0.5.0" : "dev";
4972
+ var VERSION = true ? "0.6.1" : "dev";
4982
4973
  function parseArgs(argv) {
4983
4974
  const args = { repoPath: process.cwd(), open: true, help: false, version: false };
4984
4975
  for (let i = 0; i < argv.length; i++) {
@@ -5048,7 +5039,7 @@ function resolveWebDir() {
5048
5039
  // bin/cli.ts (tsx dev) -> dist/web
5049
5040
  ].filter((p) => !!p);
5050
5041
  for (const dir of candidates) {
5051
- if (existsSync(path8.join(dir, "index.html"))) return dir;
5042
+ if (existsSync2(path8.join(dir, "index.html"))) return dir;
5052
5043
  }
5053
5044
  return void 0;
5054
5045
  }