recursive-board 0.1.1 → 0.2.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.
package/README.md CHANGED
@@ -1,5 +1,7 @@
1
1
  # Recursive Board
2
2
 
3
+ > **Install with your agent.** Paste this prompt into your coding agent: "Check that Node.js 20.12 or later is installed, run `npm install --global recursive-board`, then run `wi setup`."
4
+
3
5
  Recursive Board turns a folder of Markdown files in an Obsidian vault into a hierarchical work board. Each work item is one Markdown file. Its parent link defines where it belongs.
4
6
 
5
7
  **Markdown is canonical. The plugin is a view, never the database.** The plugin renders and edits work items in Obsidian. The `wi` command-line tool supports scripts and agents. Both use the same schema and rules.
@@ -40,10 +42,18 @@ Place an optional `.wi.json` file at the vault root to choose the work-item fold
40
42
 
41
43
  `workItemFolder` is a vault-relative folder path. It defaults to `Boards`. `defaultRoot` is the filename stem of a root work item. It defaults to `null`, which means `wi new` needs an explicit `--parent`. `extraSections` is an array of non-empty, single-line headings. It defaults to `[]`. Each heading is added after the built-in template sections with an empty `- ` starter. The setting applies to `wi new`, `wi template write`, and items created in the plugin. Invalid values make `.wi.json` fail to load.
42
44
 
43
- `wi` finds the vault from `--vault <path>`, then `$WI_VAULT`, then the nearest folder with `.wi.json` or `Boards/`.
45
+ `wi setup` writes the selected vault to the user config at `$XDG_CONFIG_HOME/wi/config.json`, or `~/.config/wi/config.json` when `XDG_CONFIG_HOME` is unset. The format is `{"defaultVault":"/absolute/path/to/vault"}`. Vault detection uses Obsidian's registry on macOS, Linux, and Windows. `--vault <path>` selects a vault directly, and `--yes --vault <path>` runs without prompts.
46
+
47
+ `wi` finds the vault from `--vault <path>`, then `$WI_VAULT`, then the nearest folder with `.wi.json` or `Boards/`, then the current Git repo's pointer, then `defaultVault` in `~/.config/wi/config.json` (or `$XDG_CONFIG_HOME/wi/config.json`). Use `wi here --vault <path> --board <ref>` once in a repo to save its vault and board outside the repo. The pointer is keyed by Git's common directory, so linked worktrees share it. Run `wi here` to print the current repo's pointer.
44
48
 
45
49
  ## Start a board
46
50
 
51
+ ### Create one in Obsidian
52
+
53
+ Enable Recursive Board in an empty vault. Use the **Create your first board** button in the notice, or run **Create your first board** from the command palette. Enter a name (the default is `Main`). The plugin creates the board, adds a starter card that explains how to move it, and opens the board.
54
+
55
+ ### Manual fallback
56
+
47
57
  1. Create the file `Boards/Project.md` with this content. A root item has no `parent` and no `status`:
48
58
 
49
59
  ```markdown
@@ -81,13 +91,14 @@ Reload Obsidian after replacing plugin files. If your vault syncs its `.obsidian
81
91
  ```sh
82
92
  npm install --global recursive-board
83
93
  wi --help
94
+ wi setup
84
95
  ```
85
96
 
86
- Run `wi` inside a vault, pass `--vault <path>`, or set `WI_VAULT`. Commands accept a work-item id, filename, or title as a reference. An id takes precedence when references are ambiguous. Add `--json` for machine-readable output. The `--vault <path>` and `--json` flags apply to all commands.
97
+ Run `wi` inside a vault, pass `--vault <path>`, set `WI_VAULT`, or set a repo pointer with `wi here`. Commands accept a work-item id, filename, or title as a reference. An id takes precedence when references are ambiguous. Add `--json` for machine-readable output. The `--vault <path>` and `--json` flags apply to all commands. With a repo pointer, `wi new` uses its board when `--parent` is omitted, and `wi children` uses it when the reference is omitted.
87
98
 
88
99
  ## Use with coding agents
89
100
 
90
- `skills/recursive-board/SKILL.md` teaches an agent to read and change a vault through `wi`. Copy that folder into your agent's skills folder, for example `~/.claude/skills/` for Claude Code or `~/.agents/skills/`. From a source checkout, `npm run install:skill` links it there and links `wi` into `~/.local/bin`.
101
+ `skills/recursive-board/SKILL.md` teaches an agent to read and change a vault through `wi`. `wi setup` installs copies into `~/.claude/skills/recursive-board/` and `~/.agents/skills/recursive-board/`. It leaves symlinked development installs alone and refuses to replace an unmanaged folder unless you pass `--force`. From a source checkout, `npm run install:skill` links the skill and `wi` into `~/.local/bin`.
91
102
 
92
103
  ## Install the Git validation hook
93
104
 
@@ -105,14 +116,18 @@ The pre-commit hook runs `wi validate` and stops a commit when the vault has err
105
116
 
106
117
  | Command | What it does |
107
118
  | --- | --- |
108
- | `wi new <title> [--parent <ref>] [--status <status>] [--template <name>] [--owner <name>] [--agent <name>] [--priority <number>]` | Creates a work item under the given parent. If `--parent` is omitted, uses `defaultRoot` from `.wi.json`. |
119
+ | `wi setup [--yes] [--vault <path>] [--force]` | Installs the agent skill, selects and saves a default vault, and offers the Git validation hook for a Git vault. `--yes` requires `--vault` and asks no questions. |
120
+ | `wi new <title> [--parent <ref>] [--status <status>] [--template <name>] [--owner <name>] [--agent <name>] [--priority <number>]` | Creates a work item under the given parent. If `--parent` is omitted, uses the repo pointer's board, then `defaultRoot` from `.wi.json`. |
109
121
  | `wi status <ref> <status>` | Changes an item's status. Use `backlog`, `options`, `doing`, or `done`. Leaving `done` clears the recorded previous status. |
122
+ | `wi claim <ref> --agent <name>` | Claims a card for an agent and moves it to doing in one write. Refuses a different agent, a done card, or a board with a child in doing. Repeating an active claim by the same agent writes nothing. |
123
+ | `wi release <ref> --reason <text> [--where <branch-or-path>]` | Clears the agent, moves the card to options, and adds a dated line to Notes with the reason and optional work location. Refuses an unclaimed card. |
110
124
  | `wi move <ref> --to <ref>` | Changes the item's parent. Its status stays the same, and its children move with it. |
111
125
  | `wi archive <ref> [--undo]` | Archives an item. `--undo` unarchives it. Archived items are hidden from normal reads; descendants are hidden with an archived parent. |
112
126
  | `wi rm <ref> [--recursive] [--dry-run]` | Moves an item to the vault's `.trash` folder. Use `--dry-run` to preview. Items with children require `--recursive`. |
113
- | `wi children <ref> [--status <status>] [--tree] [--archived]` | Lists an item's children. `--status` filters by status, `--tree` shows descendants, and `--archived` includes archived items. |
127
+ | `wi children [<ref>] [--status <status>] [--tree] [--archived]` | Lists an item's children. If the ref is omitted, uses the repo pointer's board. `--status` filters by status, `--tree` shows descendants, and `--archived` includes archived items. |
114
128
  | `wi validate` | Checks work-item structure and reports errors and warnings. Exits with code 1 when it finds errors. |
115
129
  | `wi hook install\|uninstall\|status [--vault <path>]` | Installs, removes, or inspects the Git pre-commit validation hook. `install --force` replaces an unrelated hook. |
130
+ | `wi here [--board <ref>] [--vault <path>]` | Prints this Git repo's pointer, or sets it in user config. Linked worktrees share the pointer. |
116
131
  | `wi template [list\|write]` | Lists available templates, or writes the code's templates into `Templates/` with `wi template write`. Defaults to `list`. |
117
132
  | `wi --help` or `wi help` | Prints usage, options, and notes. |
118
133
  | `wi --version` | Prints the installed CLI version. |
@@ -123,6 +138,8 @@ The pre-commit hook runs `wi validate` and stops a commit when the vault has err
123
138
 
124
139
  Use `wi` for work-item changes. Do not edit work-item Markdown directly with scripts or bulk text tools. Use `wi validate` to check the vault after changes. `wi rm` moves items into `.trash`; removing a parent requires `--recursive`. Use `wi rm <ref> --dry-run` to review the affected items first.
125
140
 
141
+ A dispatcher assigns a card with `wi claim <ref> --agent <name>`. If that worker stops, the dispatcher runs `wi release <ref> --reason <text> [--where <branch-or-path>]` so the next worker can find the unfinished work. Keep a card in doing until its work is accepted.
142
+
126
143
  ## Development
127
144
 
128
145
  Requirements: Node.js 23.6 or later and npm. The tests run the TypeScript source directly, which needs type stripping. The built `wi` runs on Node.js 20.12 or later.
package/dist/wi/wi.js CHANGED
@@ -2,13 +2,15 @@
2
2
 
3
3
  // src/cli/wi.ts
4
4
  import { parseArgs } from "node:util";
5
- import { resolve as resolve2 } from "node:path";
6
- import { fileURLToPath } from "node:url";
5
+ import { resolve as resolve3 } from "node:path";
6
+ import { fileURLToPath as fileURLToPath2 } from "node:url";
7
7
 
8
8
  // src/cli/vault.ts
9
- import { readdir, readFile } from "node:fs/promises";
10
- import { existsSync } from "node:fs";
11
- import { join, resolve as resolvePath, dirname, basename, sep } from "node:path";
9
+ import { readdir, readFile, mkdir, writeFile, rename } from "node:fs/promises";
10
+ import { existsSync, realpathSync } from "node:fs";
11
+ import { execFileSync } from "node:child_process";
12
+ import { homedir } from "node:os";
13
+ import { join, resolve as resolvePath, dirname, basename, sep, isAbsolute } from "node:path";
12
14
 
13
15
  // src/shared/frontmatter.ts
14
16
  var KEY_LINE = /^([A-Za-z_][\w.-]*)\s*:(?:[ \t]+(.*))?$/;
@@ -99,6 +101,12 @@ function parseFrontmatter(text) {
99
101
  entry: (key) => byKey.get(key)
100
102
  };
101
103
  }
104
+ function frontmatterBody(text) {
105
+ const block = splitBlock(text);
106
+ if (!block) return text;
107
+ const fenceEnd = text.indexOf("\n", block.contentEnd);
108
+ return fenceEnd === -1 ? "" : text.slice(fenceEnd + 1);
109
+ }
102
110
  function rewrite(text, block, lines, ends) {
103
111
  const head = text.slice(0, block.contentStart);
104
112
  const tail = text.slice(block.contentEnd);
@@ -109,6 +117,7 @@ function setKey(text, key, value) {
109
117
  const block = splitBlock(text);
110
118
  if (!block) throw new Error(`cannot set "${key}": the file has no frontmatter`);
111
119
  const entry = readEntries(block.lines).find((e) => e.key === key);
120
+ if (entry?.value === value) return text;
112
121
  const line2 = `${key}: ${formatScalar(value)}`;
113
122
  const lines = [...block.lines];
114
123
  const ends = [...block.ends];
@@ -157,11 +166,16 @@ var OPTIONAL_FIELDS = [
157
166
  "blocked",
158
167
  "depends_on",
159
168
  "tags",
160
- "archived"
169
+ "archived",
161
170
  // docs/adr/0007-archive-is-a-frontmatter-flag.md: a flag, not a status.
171
+ "area"
172
+ // docs/adr/0028-area-work-items.md: an ongoing space with no status.
162
173
  ];
163
174
  var INHERITED_FIELDS = ["owner", "agent"];
164
175
  var WORK_ITEM_TYPE = "work-item";
176
+ function isArea(value) {
177
+ return value === true;
178
+ }
165
179
  var FOLDERS = ["Boards", "Templates"];
166
180
  var BOARDS = "Boards";
167
181
  function isStatus(value) {
@@ -252,8 +266,8 @@ function parseVaultConfig(text) {
252
266
  if (!Array.isArray(sectionsValue)) {
253
267
  throw new Error(`${WI_CONFIG_FILE}: extraSections must be an array of non-empty, single-line headings.`);
254
268
  }
255
- const headings = sectionsValue;
256
- for (const heading of headings) {
269
+ const headings2 = sectionsValue;
270
+ for (const heading of headings2) {
257
271
  if (typeof heading !== "string" || heading.trim() === "" || /[\r\n]/.test(heading)) {
258
272
  throw new Error(`${WI_CONFIG_FILE}: extraSections must be an array of non-empty, single-line headings.`);
259
273
  }
@@ -292,6 +306,73 @@ function activeDescendant(item, childrenOf, statusOf) {
292
306
 
293
307
  // src/cli/vault.ts
294
308
  var MARKDOWN = /\.md$/i;
309
+ function wiConfigDir(env = process.env) {
310
+ const xdg = env["XDG_CONFIG_HOME"];
311
+ return xdg && isAbsolute(xdg) ? join(xdg, "wi") : join(homedir(), ".config", "wi");
312
+ }
313
+ function gitCommonDir(start) {
314
+ try {
315
+ const output = execFileSync("git", ["rev-parse", "--git-common-dir"], {
316
+ cwd: start,
317
+ encoding: "utf8",
318
+ stdio: ["ignore", "pipe", "ignore"]
319
+ }).trim();
320
+ return realpathSync(resolvePath(start, output));
321
+ } catch {
322
+ return null;
323
+ }
324
+ }
325
+ async function readJsonObject(path, description) {
326
+ let text;
327
+ try {
328
+ text = await readFile(path, "utf8");
329
+ } catch (error) {
330
+ if (error.code === "ENOENT") return null;
331
+ throw error;
332
+ }
333
+ let value;
334
+ try {
335
+ value = JSON.parse(text);
336
+ } catch {
337
+ throw new Error(`${description} must contain valid JSON.`);
338
+ }
339
+ if (value === null || typeof value !== "object" || Array.isArray(value)) {
340
+ throw new Error(`${description} must contain a JSON object.`);
341
+ }
342
+ return value;
343
+ }
344
+ async function getRepoPointer(start, env = process.env) {
345
+ const commonDir = gitCommonDir(start);
346
+ if (!commonDir) return null;
347
+ const map = await readJsonObject(join(wiConfigDir(env), "repos.json"), "wi repos.json");
348
+ const value = map?.[commonDir];
349
+ if (value === void 0) return null;
350
+ if (value === null || typeof value !== "object" || Array.isArray(value)) {
351
+ throw new Error(`wi repos.json: entry for ${commonDir} must contain vault and board strings.`);
352
+ }
353
+ const entry = value;
354
+ if (typeof entry["vault"] !== "string" || typeof entry["board"] !== "string") {
355
+ throw new Error(`wi repos.json: entry for ${commonDir} must contain vault and board strings.`);
356
+ }
357
+ return { vault: entry["vault"], board: entry["board"] };
358
+ }
359
+ async function setRepoPointer(start, pointer, env = process.env) {
360
+ const commonDir = gitCommonDir(start);
361
+ if (!commonDir) throw new Error("wi here must run inside a Git repository.");
362
+ const dir = wiConfigDir(env);
363
+ const path = join(dir, "repos.json");
364
+ const map = await readJsonObject(path, "wi repos.json") ?? {};
365
+ map[commonDir] = pointer;
366
+ await mkdir(dir, { recursive: true });
367
+ const temporary = `${path}.${process.pid}.tmp`;
368
+ await writeFile(temporary, `${JSON.stringify(map, null, 2)}
369
+ `, "utf8");
370
+ await rename(temporary, path);
371
+ }
372
+ async function getDefaultVault(env = process.env) {
373
+ const config = await readJsonObject(join(wiConfigDir(env), "config.json"), "wi config.json");
374
+ return typeof config?.["defaultVault"] === "string" ? config["defaultVault"] : null;
375
+ }
295
376
  var IGNORED_HIDDEN = /* @__PURE__ */ new Set([".DS_Store", ".localized", ".gitkeep"]);
296
377
  function isUnaccounted(name) {
297
378
  if (!name.startsWith(".")) return false;
@@ -355,6 +436,7 @@ function toWorkItem(root, relPath, text) {
355
436
  parent: parseWikilink(parentRaw),
356
437
  parentRaw: typeof parentRaw === "string" ? parentRaw : void 0,
357
438
  board: frontmatter.get("board") === true,
439
+ area: isArea(frontmatter.get("area")),
358
440
  archived: frontmatter.get("archived") === true,
359
441
  frontmatter,
360
442
  text
@@ -432,7 +514,7 @@ async function loadVault(root) {
432
514
  }
433
515
  for (const siblings of children.values()) siblings.sort(compareSiblings);
434
516
  const resolveLink = (target) => target === null ? void 0 : byStem.get(linkKey(target));
435
- const resolve3 = (ref) => {
517
+ const resolve4 = (ref) => {
436
518
  const trimmed = ref.trim();
437
519
  const byIdHit = byId.get(trimmed);
438
520
  if (byIdHit) return byIdHit;
@@ -458,7 +540,7 @@ async function loadVault(root) {
458
540
  takenIds: new Set(byId.keys()),
459
541
  takenStems: new Set(byStem.keys()),
460
542
  resolveLink,
461
- resolve: resolve3,
543
+ resolve: resolve4,
462
544
  childrenOf: (item) => children.get(item.stem.toLowerCase()) ?? [],
463
545
  isArchived: (item) => archiveOwner(item, (current) => resolveLink(current.parent) ?? null, (current) => current.archived) !== null
464
546
  };
@@ -472,7 +554,7 @@ import { existsSync as existsSync2 } from "node:fs";
472
554
  import { join as join3 } from "node:path";
473
555
 
474
556
  // src/cli/write.ts
475
- import { writeFile, rename } from "node:fs/promises";
557
+ import { writeFile as writeFile2, rename as rename2 } from "node:fs/promises";
476
558
  import { dirname as dirname2, join as join2 } from "node:path";
477
559
 
478
560
  // src/shared/edits.ts
@@ -491,12 +573,13 @@ function withStamp(edits, stamp = today()) {
491
573
  // src/cli/write.ts
492
574
  async function writeAtomic(path, text) {
493
575
  const temp = join2(dirname2(path), `.wi-${process.pid}-${Date.now()}.tmp`);
494
- await writeFile(temp, text, "utf8");
495
- await rename(temp, path);
576
+ await writeFile2(temp, text, "utf8");
577
+ await rename2(temp, path);
496
578
  }
497
- async function editItem(item, edits) {
498
- const text = applyEdits(item.text, withStamp(edits));
499
- if (text !== item.text) await writeAtomic(item.path, text);
579
+ async function editItem(item, edits, editBody = (text) => text) {
580
+ if (editBody(applyEdits(item.text, edits)) === item.text) return item.text;
581
+ const text = editBody(applyEdits(item.text, withStamp(edits)));
582
+ await writeAtomic(item.path, text);
500
583
  return text;
501
584
  }
502
585
 
@@ -511,6 +594,24 @@ var TEMPLATES = [
511
594
  { heading: "Acceptance Criteria", starter: "- " },
512
595
  { heading: "Notes" }
513
596
  ]
597
+ },
598
+ {
599
+ name: "first-board-card",
600
+ description: "A short guide to adding and moving cards on your first board.",
601
+ sections: [
602
+ { heading: "Getting started", starter: "Add cards from a board column. To move this card, open its card menu and choose \u201CMove to\u2026\u201D." },
603
+ { heading: "Notes" }
604
+ ]
605
+ },
606
+ {
607
+ name: "area",
608
+ description: "An ongoing area of work with no status.",
609
+ area: true,
610
+ sections: [
611
+ { heading: "Objective" },
612
+ { heading: "Context" },
613
+ { heading: "Notes" }
614
+ ]
514
615
  }
515
616
  ];
516
617
  var DEFAULT_TEMPLATE = "work-item";
@@ -542,14 +643,17 @@ function renderVaultTemplate(template, defaultRoot = null, extraSections = []) {
542
643
  type: formatScalar(WORK_ITEM_TYPE),
543
644
  id: "wi-XXXX",
544
645
  title: "",
545
- status: "backlog",
546
646
  parent: defaultRoot === null ? "" : formatScalar(formatWikilink(defaultRoot)),
547
647
  created: "",
548
648
  updated: ""
549
649
  };
550
- const lines = CORE_FIELDS.filter((field) => field in placeholders).map(
650
+ if (template.area) placeholders["area"] = "true";
651
+ else placeholders["status"] = "backlog";
652
+ const fields = template.area ? CORE_FIELDS.filter((field) => field !== "status") : CORE_FIELDS;
653
+ const lines = fields.filter((field) => field in placeholders).map(
551
654
  (field) => `${field}: ${placeholders[field]}`.trimEnd()
552
655
  );
656
+ if (template.area) lines.splice(3, 0, "area: true");
553
657
  return `---
554
658
  ${lines.join("\n")}
555
659
  ---
@@ -559,13 +663,18 @@ ${renderBody(template, extraSections)}`;
559
663
 
560
664
  // src/shared/work-item.ts
561
665
  function renderWorkItem(item, extraSections = []) {
666
+ const template = requireTemplate(item.template);
667
+ if (Boolean(template.area) !== (item.area === true)) {
668
+ throw new Error(`template "${template.name}" does not match the work item kind`);
669
+ }
562
670
  const fields = [
563
671
  ["type", WORK_ITEM_TYPE],
564
672
  ["id", item.id],
565
- ["title", item.title],
566
- ["status", item.status],
567
- ["parent", formatWikilink(item.parentStem)]
673
+ ["title", item.title]
568
674
  ];
675
+ if (item.area) fields.push(["area", true]);
676
+ else fields.push(["status", item.status]);
677
+ fields.push(["parent", formatWikilink(item.parentStem)]);
569
678
  if (item.owner !== void 0 && item.owner !== "") fields.push(["owner", item.owner]);
570
679
  if (item.agent !== void 0 && item.agent !== "") fields.push(["agent", item.agent]);
571
680
  if (item.priority !== void 0) fields.push(["priority", item.priority]);
@@ -575,7 +684,7 @@ function renderWorkItem(item, extraSections = []) {
575
684
  ${frontmatter}
576
685
  ---
577
686
 
578
- ${renderBody(requireTemplate(item.template), extraSections)}`;
687
+ ${renderBody(template, extraSections)}`;
579
688
  }
580
689
 
581
690
  // src/cli/commands/new.ts
@@ -586,9 +695,12 @@ async function createItem(vault, options) {
586
695
  if (parentRef.trim() === "") {
587
696
  throw new Error("a work item needs a --parent. Only the root has none.");
588
697
  }
589
- requireTemplate(options.template);
698
+ const template = requireTemplate(options.template);
590
699
  const status = options.status ?? "backlog";
591
- if (!isStatus(status)) {
700
+ if (template.area && options.status !== void 0) {
701
+ throw new Error("an area cannot have a status");
702
+ }
703
+ if (!template.area && !isStatus(status)) {
592
704
  throw new Error(`"${status}" is not a status. Use backlog, options, doing or done.`);
593
705
  }
594
706
  const parent = vault.resolve(parentRef);
@@ -600,15 +712,15 @@ async function createItem(vault, options) {
600
712
  throw new Error(`${relPath} already exists. Refusing to overwrite it.`);
601
713
  }
602
714
  const stamp = today();
603
- const fields = {
715
+ const common = {
604
716
  id,
605
717
  title,
606
- status,
607
718
  parentStem: parent.stem,
608
719
  created: stamp,
609
720
  updated: stamp,
610
721
  template: options.template
611
722
  };
723
+ const fields = template.area ? { ...common, area: true } : { ...common, status };
612
724
  for (const field of INHERITED_FIELDS) {
613
725
  const value = options[field] ?? parent.frontmatter.get(field);
614
726
  if (value !== void 0 && value !== "") fields[field] = String(value);
@@ -629,6 +741,20 @@ function statusEdits(from, to, hasPrevStatus) {
629
741
  }
630
742
  return edits;
631
743
  }
744
+ function claimEdits(from, currentAgent, agent, hasPrevStatus, hasDoingChild) {
745
+ if (from === "done") throw new Error("a done card cannot be claimed.");
746
+ if (currentAgent && currentAgent !== agent) {
747
+ throw new Error(`already claimed by ${currentAgent}. Release that claim first.`);
748
+ }
749
+ if (currentAgent === agent && from === "doing") return null;
750
+ if (hasDoingChild) throw new Error("this board has a child in doing. Release or finish the child first.");
751
+ const status = statusEdits(from, "doing", hasPrevStatus);
752
+ if (currentAgent === agent) return status;
753
+ return [...status ?? [], { op: "set", key: "agent", value: agent }];
754
+ }
755
+ function releaseEdits(from, hasPrevStatus) {
756
+ return [...statusEdits(from, "options", hasPrevStatus) ?? [], { op: "remove", key: "agent" }];
757
+ }
632
758
  function moveEdits(currentParentStem, targetStem) {
633
759
  if (currentParentStem !== null && currentParentStem.toLowerCase() === targetStem.toLowerCase()) {
634
760
  return null;
@@ -671,6 +797,86 @@ async function setStatus(vault, ref, status) {
671
797
  return { item, from, to: status, recorded, changed: true };
672
798
  }
673
799
 
800
+ // src/shared/notes.ts
801
+ function headings(text) {
802
+ const body = frontmatterBody(text);
803
+ const offset = text.length - body.length;
804
+ const found = [];
805
+ let fence;
806
+ for (const match of body.matchAll(/([^\r\n]*)(\r?\n|$)/g)) {
807
+ if (match[0] === "") continue;
808
+ const line2 = match[1];
809
+ const marker = /^[ \t]*(`{3,}|~{3,})/.exec(line2)?.[1];
810
+ if (marker) {
811
+ if (fence === void 0) fence = { marker: marker[0], length: marker.length };
812
+ else if (marker[0] === fence.marker && marker.length >= fence.length) fence = void 0;
813
+ continue;
814
+ }
815
+ if (fence !== void 0) continue;
816
+ const heading = /^(#{1,6})[ \t]+(.+?)[ \t]*$/.exec(line2);
817
+ if (heading) found.push({
818
+ level: heading[1].length,
819
+ title: heading[2].trim().toLowerCase(),
820
+ start: offset + match.index,
821
+ end: offset + match.index + line2.length
822
+ });
823
+ }
824
+ return found;
825
+ }
826
+ function appendNote(text, line2) {
827
+ const eol = text.includes("\r\n") ? "\r\n" : "\n";
828
+ const all = headings(text);
829
+ const noteIndex = all.findIndex((heading2) => heading2.level === 2 && heading2.title === "notes");
830
+ const heading = all[noteIndex];
831
+ if (!heading) {
832
+ const gap = text.endsWith(eol + eol) ? "" : text.endsWith(eol) ? eol : eol + eol;
833
+ return `${text}${gap}## Notes${eol}${eol}${line2}${eol}`;
834
+ }
835
+ const start = heading.end;
836
+ const next = all.slice(noteIndex + 1).find((entry) => entry.level <= 2);
837
+ const end = next?.start ?? text.length;
838
+ const content = text.slice(start, end);
839
+ if (content === "") return `${text.slice(0, start)}${eol}${eol}${line2}${eol}${text.slice(end)}`;
840
+ const trailing = /(?:\r?\n[ \t]*)*$/.exec(content)?.[0] ?? "";
841
+ const prose = content.slice(0, content.length - trailing.length);
842
+ const insertion = prose === "" ? `${content}${line2}${eol}${next ? eol : ""}` : `${prose}${eol}${line2}${trailing || eol}`;
843
+ return text.slice(0, start) + insertion + text.slice(end);
844
+ }
845
+
846
+ // src/cli/commands/claim-release.ts
847
+ async function claimItem(vault, ref, agent) {
848
+ const item = vault.resolve(ref);
849
+ if (item.area) throw new Error(`${item.relPath} is an area, and an area cannot be claimed.`);
850
+ if (item.parent === null) throw new Error(`${item.relPath} is a root, and a root cannot be claimed.`);
851
+ const current = item.frontmatter.get("agent");
852
+ const currentAgent = typeof current === "string" && current.trim() !== "" ? current : void 0;
853
+ const edits = claimEdits(
854
+ item.status,
855
+ currentAgent,
856
+ agent,
857
+ item.frontmatter.has("prev_status"),
858
+ item.board && vault.childrenOf(item).some((child) => child.status === "doing")
859
+ );
860
+ if (edits === null) return { item, agent, from: item.status, to: "doing", changed: false };
861
+ await editItem(item, edits);
862
+ return { item, agent, from: item.status, to: "doing", changed: true };
863
+ }
864
+ async function releaseItem(vault, ref, reason, where) {
865
+ const item = vault.resolve(ref);
866
+ if (item.parent === null) throw new Error(`${item.relPath} is a root, and a root cannot be released.`);
867
+ const current = item.frontmatter.get("agent");
868
+ if (typeof current !== "string" || current.trim() === "") {
869
+ throw new Error(`${item.relPath} has no agent to release.`);
870
+ }
871
+ const note = `- ${today()} Released from ${current}: ${reason.replace(/\.$/, "")}.` + (where === void 0 ? "" : ` Work: ${where.replace(/\.$/, "")}.`);
872
+ await editItem(
873
+ item,
874
+ releaseEdits(item.status, item.frontmatter.has("prev_status")),
875
+ (text) => appendNote(text, note)
876
+ );
877
+ return { item, agent: current, from: item.status, to: "options", reason, where, changed: true };
878
+ }
879
+
674
880
  // src/cli/commands/children.ts
675
881
  function listChildren(vault, ref, options = {}) {
676
882
  const { status, recursive = false, archived = false } = options;
@@ -679,6 +885,7 @@ function listChildren(vault, ref, options = {}) {
679
885
  }
680
886
  const parent = vault.resolve(ref);
681
887
  const rows = [];
888
+ const areas = [];
682
889
  const seen = /* @__PURE__ */ new Set([parent.relPath]);
683
890
  let cycle = false;
684
891
  const walk = (item, depth) => {
@@ -690,7 +897,9 @@ function listChildren(vault, ref, options = {}) {
690
897
  seen.add(child.relPath);
691
898
  const effectiveArchived = vault.isArchived(child);
692
899
  if (!effectiveArchived || archived) {
693
- rows.push({ item: child, childCount: vault.childrenOf(child).filter((kid) => !vault.isArchived(kid) || archived).length, depth, archived: effectiveArchived });
900
+ const row = { item: child, childCount: vault.childrenOf(child).filter((kid) => !vault.isArchived(kid) || archived).length, depth, archived: effectiveArchived };
901
+ if (child.area) areas.push(row);
902
+ else rows.push(row);
694
903
  }
695
904
  if (recursive) walk(child, depth + 1);
696
905
  }
@@ -701,7 +910,7 @@ function listChildren(vault, ref, options = {}) {
701
910
  for (const value of STATUSES) {
702
911
  byStatus.set(value, filtered.filter((r) => r.item.status === value));
703
912
  }
704
- return { parent, children: filtered, byStatus, cycle };
913
+ return { parent, children: filtered, areas: status === void 0 ? areas : [], byStatus, cycle };
705
914
  }
706
915
 
707
916
  // src/cli/commands/validate.ts
@@ -802,7 +1011,14 @@ function checkItem(item, vault, report) {
802
1011
  if (item.title === void 0) say("title-missing", "error", "has no title.");
803
1012
  const rawStatus = item.frontmatter.get("status");
804
1013
  const isRoot = !item.frontmatter.has("parent");
805
- if (isRoot) {
1014
+ const isArea2 = item.frontmatter.get("area") === true;
1015
+ const rawArea = item.frontmatter.get("area");
1016
+ if (rawArea !== void 0 && rawArea !== true) {
1017
+ say("area-invalid", "error", `carries area: ${rawArea}. The only valid marker is area: true.`);
1018
+ }
1019
+ if (isArea2 && rawStatus !== void 0) {
1020
+ say("status-on-area", "error", "is an area and carries a status. Areas have no status; remove the status field.");
1021
+ } else if (isRoot) {
806
1022
  if (rawStatus !== void 0) {
807
1023
  say(
808
1024
  "status-on-root",
@@ -810,9 +1026,9 @@ function checkItem(item, vault, report) {
810
1026
  `is a root and carries status "${rawStatus}". A root is not a card in anyone's column, so it takes no status.`
811
1027
  );
812
1028
  }
813
- } else if (rawStatus === void 0) {
1029
+ } else if (!isArea2 && rawStatus === void 0) {
814
1030
  say("status-missing", "error", `has no status. Use one of: ${STATUSES.join(", ")}.`);
815
- } else if (!isStatus(rawStatus)) {
1031
+ } else if (!isArea2 && !isStatus(rawStatus)) {
816
1032
  say(
817
1033
  "status-invalid",
818
1034
  "error",
@@ -952,7 +1168,7 @@ function checkCycles(vault, report) {
952
1168
  // src/cli/commands/template.ts
953
1169
  import { readFile as readFile2 } from "node:fs/promises";
954
1170
  import { existsSync as existsSync3 } from "node:fs";
955
- import { mkdir } from "node:fs/promises";
1171
+ import { mkdir as mkdir2 } from "node:fs/promises";
956
1172
  import { join as join4 } from "node:path";
957
1173
  var TEMPLATES_FOLDER = "Templates";
958
1174
  function listTemplates() {
@@ -960,7 +1176,7 @@ function listTemplates() {
960
1176
  }
961
1177
  async function writeTemplates(vault) {
962
1178
  const folder = join4(vault.root, TEMPLATES_FOLDER);
963
- await mkdir(folder, { recursive: true });
1179
+ await mkdir2(folder, { recursive: true });
964
1180
  const written = [];
965
1181
  for (const template of TEMPLATES) {
966
1182
  const path = join4(folder, `${template.name}.md`);
@@ -983,7 +1199,7 @@ async function writeTemplates(vault) {
983
1199
  }
984
1200
 
985
1201
  // src/cli/commands/remove.ts
986
- import { mkdir as mkdir2, rename as rename2 } from "node:fs/promises";
1202
+ import { mkdir as mkdir3, rename as rename3 } from "node:fs/promises";
987
1203
  import { existsSync as existsSync4 } from "node:fs";
988
1204
  import { join as join5 } from "node:path";
989
1205
  var TRASH = ".trash";
@@ -1030,8 +1246,8 @@ async function removeItem(vault, ref, options = {}) {
1030
1246
  for (const target of doomed) {
1031
1247
  const trashedTo = trashPath(vault, target);
1032
1248
  if (!dryRun) {
1033
- await mkdir2(join5(vault.root, TRASH), { recursive: true });
1034
- await rename2(target.path, join5(vault.root, ...trashedTo.split("/")));
1249
+ await mkdir3(join5(vault.root, TRASH), { recursive: true });
1250
+ await rename3(target.path, join5(vault.root, ...trashedTo.split("/")));
1035
1251
  }
1036
1252
  removed.push({ item: target, trashedTo });
1037
1253
  }
@@ -1083,7 +1299,7 @@ async function archiveItem(vault, ref, undo) {
1083
1299
  // src/cli/commands/hook.ts
1084
1300
  import { execFile } from "node:child_process";
1085
1301
  import { existsSync as existsSync5 } from "node:fs";
1086
- import { chmod, mkdir as mkdir3, readFile as readFile3, rm, writeFile as writeFile2 } from "node:fs/promises";
1302
+ import { chmod, mkdir as mkdir4, readFile as readFile3, rm, writeFile as writeFile3 } from "node:fs/promises";
1087
1303
  import { dirname as dirname3, resolve } from "node:path";
1088
1304
  import { promisify } from "node:util";
1089
1305
  var run = promisify(execFile);
@@ -1132,8 +1348,8 @@ async function installHook(vault, entry, force) {
1132
1348
  throw new Error(`${git.path} already exists and is not ours. Read it, then pass --force to replace it.`);
1133
1349
  }
1134
1350
  }
1135
- await mkdir3(dirname3(git.path), { recursive: true });
1136
- await writeFile2(git.path, hookBody(vault, entry), "utf8");
1351
+ await mkdir4(dirname3(git.path), { recursive: true });
1352
+ await writeFile3(git.path, hookBody(vault, entry), "utf8");
1137
1353
  await chmod(git.path, 493);
1138
1354
  return git.path;
1139
1355
  }
@@ -1144,29 +1360,226 @@ async function uninstallHook(vault) {
1144
1360
  return git.path;
1145
1361
  }
1146
1362
 
1363
+ // src/cli/commands/setup.ts
1364
+ import { cp, lstat, mkdir as mkdir5, readFile as readFile4, rename as rename4, rm as rm2, writeFile as writeFile4 } from "node:fs/promises";
1365
+ import { homedir as homedir2, platform } from "node:os";
1366
+ import { dirname as dirname4, join as join6, resolve as resolve2 } from "node:path";
1367
+ import { fileURLToPath } from "node:url";
1368
+ import { createInterface } from "node:readline/promises";
1369
+ import { stdin, stdout } from "node:process";
1370
+ function detectObsidianRegistryPath(location) {
1371
+ if (location.registryPath) return location.registryPath;
1372
+ switch (location.platform) {
1373
+ case "darwin":
1374
+ return join6(location.home, "Library", "Application Support", "obsidian", "obsidian.json");
1375
+ case "linux":
1376
+ return join6(location.home, ".config", "obsidian", "obsidian.json");
1377
+ case "win32":
1378
+ return join6(location.appData ?? join6(location.home, "AppData", "Roaming"), "obsidian", "obsidian.json");
1379
+ default:
1380
+ throw new Error(`wi setup does not know the Obsidian registry location for ${location.platform}.`);
1381
+ }
1382
+ }
1383
+ function isRecord(value) {
1384
+ return typeof value === "object" && value !== null && !Array.isArray(value);
1385
+ }
1386
+ async function readObsidianVaults(location) {
1387
+ const path = detectObsidianRegistryPath(location);
1388
+ let text;
1389
+ try {
1390
+ text = await readFile4(path, "utf8");
1391
+ } catch (error) {
1392
+ if (isMissingFile2(error)) return [];
1393
+ throw error;
1394
+ }
1395
+ let parsed;
1396
+ try {
1397
+ parsed = JSON.parse(text);
1398
+ } catch {
1399
+ throw new Error(`cannot read Obsidian vault registry ${path}: invalid JSON.`);
1400
+ }
1401
+ if (!isRecord(parsed) || !isRecord(parsed["vaults"])) {
1402
+ throw new Error(`cannot read Obsidian vault registry ${path}: expected a vaults object.`);
1403
+ }
1404
+ const paths = [];
1405
+ for (const entry of Object.values(parsed["vaults"])) {
1406
+ if (!isRecord(entry) || typeof entry["path"] !== "string" || entry["path"].trim() === "") continue;
1407
+ const vaultPath = resolve2(entry["path"]);
1408
+ if (!paths.includes(vaultPath)) paths.push(vaultPath);
1409
+ }
1410
+ return paths;
1411
+ }
1412
+ function isMissingFile2(error) {
1413
+ return isRecord(error) && error["code"] === "ENOENT";
1414
+ }
1415
+ var MANAGED_MARKER = ".recursive-board-managed";
1416
+ var MANAGED_TEXT = "Installed by wi setup.\n";
1417
+ async function installSkillCopy(source, destination, force) {
1418
+ let existing;
1419
+ try {
1420
+ existing = await lstat(destination);
1421
+ } catch (error) {
1422
+ if (!isMissingFile2(error)) throw error;
1423
+ }
1424
+ if (existing?.isSymbolicLink()) return "dev-link";
1425
+ if (existing) {
1426
+ const managed = existing.isDirectory() && await readFile4(join6(destination, MANAGED_MARKER), "utf8").then((value) => value === MANAGED_TEXT).catch(() => false);
1427
+ if (!managed && !force) {
1428
+ throw new Error(`${destination} already exists and was not installed by wi setup. Read it, then pass --force to replace it.`);
1429
+ }
1430
+ if (managed) {
1431
+ const same = await readFile4(join6(destination, "SKILL.md"), "utf8").catch(() => null);
1432
+ const expected = await readFile4(join6(source, "SKILL.md"), "utf8");
1433
+ if (same === expected) return "already-installed";
1434
+ }
1435
+ await rm2(destination, { recursive: true, force: true });
1436
+ }
1437
+ await mkdir5(dirname4(destination), { recursive: true });
1438
+ await cp(source, destination, { recursive: true, errorOnExist: true, force: false });
1439
+ await writeFile4(join6(destination, MANAGED_MARKER), MANAGED_TEXT, "utf8");
1440
+ return "installed";
1441
+ }
1442
+ async function writeDefaultVault(vault) {
1443
+ const root = wiConfigDir();
1444
+ const path = join6(root, "config.json");
1445
+ let current = {};
1446
+ try {
1447
+ const parsed = JSON.parse(await readFile4(path, "utf8"));
1448
+ if (!isRecord(parsed)) throw new Error(`cannot update ${path}: expected a JSON object.`);
1449
+ current = parsed;
1450
+ } catch (error) {
1451
+ if (isMissingFile2(error)) {
1452
+ current = {};
1453
+ } else if (error instanceof SyntaxError) {
1454
+ throw new Error(`cannot update ${path}: invalid JSON.`);
1455
+ } else {
1456
+ throw error;
1457
+ }
1458
+ }
1459
+ await mkdir5(root, { recursive: true });
1460
+ const temporary = `${path}.tmp-${process.pid}`;
1461
+ await writeFile4(temporary, `${JSON.stringify({ ...current, defaultVault: resolve2(vault) }, null, 2)}
1462
+ `, "utf8");
1463
+ await rename4(temporary, path);
1464
+ return path;
1465
+ }
1466
+ async function hasWorkItems(path) {
1467
+ try {
1468
+ return (await loadVault(path)).items.length > 0;
1469
+ } catch {
1470
+ return false;
1471
+ }
1472
+ }
1473
+ async function rankVaults(paths) {
1474
+ const ranked = await Promise.all(paths.map(async (path) => ({ path, hasItems: await hasWorkItems(path) })));
1475
+ ranked.sort((a, b) => Number(b.hasItems) - Number(a.hasItems) || a.path.localeCompare(b.path));
1476
+ return ranked;
1477
+ }
1478
+ async function selectVault(vaults) {
1479
+ const ranked = await rankVaults(vaults);
1480
+ if (ranked.length === 0) {
1481
+ throw new Error("no Obsidian vaults found. Open a vault in Obsidian, or run wi setup --vault <path>.");
1482
+ }
1483
+ stdout.write("Choose your default Obsidian vault:\n");
1484
+ ranked.forEach((entry, index) => stdout.write(` ${index + 1}. ${entry.path}${entry.hasItems ? " (work items found)" : ""}
1485
+ `));
1486
+ const io = createInterface({ input: stdin, output: stdout });
1487
+ try {
1488
+ for (; ; ) {
1489
+ const answer = (await io.question(`Vault [1-${ranked.length}]: `)).trim();
1490
+ const choice = Number(answer);
1491
+ if (Number.isInteger(choice) && choice >= 1 && choice <= ranked.length) return ranked[choice - 1].path;
1492
+ stdout.write(`Enter a number from 1 to ${ranked.length}.
1493
+ `);
1494
+ }
1495
+ } finally {
1496
+ io.close();
1497
+ }
1498
+ }
1499
+ async function confirmHook() {
1500
+ const io = createInterface({ input: stdin, output: stdout });
1501
+ try {
1502
+ return /^(y|yes)$/i.test((await io.question("This vault is a Git repository. Install the wi validation hook? [y/N] ")).trim());
1503
+ } finally {
1504
+ io.close();
1505
+ }
1506
+ }
1507
+ async function runSetup(options) {
1508
+ if (options.yes && !options.vault) throw new Error("wi setup --yes needs --vault <path> so it can run without questions.");
1509
+ const home = homedir2();
1510
+ const vault = resolve2(options.vault ?? await selectVault(await readObsidianVaults({
1511
+ platform: platform(),
1512
+ home,
1513
+ ...process.env["APPDATA"] ? { appData: process.env["APPDATA"] } : {}
1514
+ })));
1515
+ const moduleDir = dirname4(fileURLToPath(import.meta.url));
1516
+ const skillCandidates = [
1517
+ resolve2(moduleDir, "..", "..", "..", "skills", "recursive-board"),
1518
+ resolve2(moduleDir, "..", "..", "skills", "recursive-board")
1519
+ ];
1520
+ let packagedSkill;
1521
+ for (const candidate of skillCandidates) {
1522
+ try {
1523
+ await readFile4(join6(candidate, "SKILL.md"), "utf8");
1524
+ packagedSkill = candidate;
1525
+ break;
1526
+ } catch (error) {
1527
+ if (!isMissingFile2(error)) throw error;
1528
+ }
1529
+ }
1530
+ if (!packagedSkill) throw new Error("the recursive-board skill is missing from this installation.");
1531
+ const destinations = [
1532
+ join6(home, ".claude", "skills", "recursive-board"),
1533
+ join6(home, ".agents", "skills", "recursive-board")
1534
+ ];
1535
+ const outcomes = await Promise.all(destinations.map((destination) => installSkillCopy(packagedSkill, destination, options.force)));
1536
+ const configPath = await writeDefaultVault(vault);
1537
+ stdout.write(`wi setup: default vault ${vault}
1538
+ `);
1539
+ stdout.write(`wi setup: config ${configPath}
1540
+ `);
1541
+ destinations.forEach((destination, index) => stdout.write(`wi setup: ${outcomes[index]} ${destination}
1542
+ `));
1543
+ if (!options.yes) {
1544
+ const status = await hookStatus(vault);
1545
+ if (status.gitRepo && await confirmHook()) {
1546
+ const entry = fileURLToPath(import.meta.url);
1547
+ const hook = await installHook(vault, entry, false);
1548
+ stdout.write(`wi setup: installed validation hook ${hook}
1549
+ `);
1550
+ }
1551
+ }
1552
+ }
1553
+
1147
1554
  // src/cli/wi.ts
1148
1555
  var HELP = `wi \u2014 the Recursive Board CLI
1149
1556
 
1150
1557
  Usage
1558
+ wi setup [--yes] [--vault <path>] [--force]
1151
1559
  wi new <title> [--parent <ref>] [--status <s>] [--template <t>] [--owner <o>] [--agent <a>]
1152
1560
  [--priority <n>]
1153
1561
  wi status <ref> <status>
1562
+ wi claim <ref> --agent <name>
1563
+ wi release <ref> --reason <text> [--where <branch-or-path>]
1154
1564
  wi move <ref> --to <ref>
1155
1565
  wi archive <ref> [--undo]
1156
1566
  wi rm <ref> [--recursive] [--dry-run]
1157
- wi children <ref> [--status <s>] [--tree] [--archived]
1567
+ wi children [<ref>] [--status <s>] [--tree] [--archived]
1158
1568
  wi validate
1159
1569
  wi template [list|write]
1160
1570
  wi hook <install|uninstall|status> [--force]
1571
+ wi here [--board <ref>] [--vault <path>]
1161
1572
 
1162
1573
  A <ref> is a work item id, a filename or a title. An id always wins.
1163
1574
  A <status> is one of: ${STATUSES.join(", ")}.
1164
1575
  A <template> is one of: ${templateNames().join(", ")}.
1165
1576
 
1166
1577
  Options
1167
- --vault <path> The vault root. Defaults to $WI_VAULT, then the nearest configured vault or Boards/.
1578
+ --vault <path> The vault root. Defaults to $WI_VAULT, the nearest vault, this repo's pointer, then defaultVault.
1579
+ --board <ref> Board work item used by wi here.
1168
1580
  --json Machine-readable output.
1169
- --force Replace another pre-commit hook with wi hook install.
1581
+ --force Replace an unrelated hook, or an unmanaged skill during setup.
1582
+ --yes Run setup without prompts; requires --vault <path>.
1170
1583
  -h, --help This text.
1171
1584
  -V, --version Print the version.
1172
1585
 
@@ -1182,8 +1595,9 @@ Notes
1182
1595
  client download the file, or delete the stray file, then retry. There is no --force.
1183
1596
  \`wi archive\` changes one flag. Descendants disappear with their parent at read time.
1184
1597
  \`wi new\` warns when such a file exists because a new id or filename may clash with it.
1598
+ \`wi here\` reads or sets this repository's vault and board pointer in your user config.
1185
1599
  `;
1186
- var VERSION = "0.1.1";
1600
+ var VERSION = "0.2.0";
1187
1601
  var UsageError = class extends Error {
1188
1602
  };
1189
1603
  async function main(argv) {
@@ -1197,9 +1611,12 @@ async function main(argv) {
1197
1611
  status: { type: "string" },
1198
1612
  owner: { type: "string" },
1199
1613
  agent: { type: "string" },
1614
+ reason: { type: "string" },
1615
+ where: { type: "string" },
1200
1616
  priority: { type: "string" },
1201
1617
  template: { type: "string" },
1202
1618
  vault: { type: "string" },
1619
+ board: { type: "string" },
1203
1620
  tree: { type: "boolean", default: false },
1204
1621
  archived: { type: "boolean", default: false },
1205
1622
  undo: { type: "boolean", default: false },
@@ -1207,6 +1624,7 @@ async function main(argv) {
1207
1624
  "dry-run": { type: "boolean", default: false },
1208
1625
  json: { type: "boolean", default: false },
1209
1626
  force: { type: "boolean", default: false },
1627
+ yes: { type: "boolean", default: false },
1210
1628
  help: { type: "boolean", short: "h", default: false },
1211
1629
  version: { type: "boolean", short: "V", default: false }
1212
1630
  }
@@ -1216,14 +1634,25 @@ async function main(argv) {
1216
1634
  `);
1217
1635
  return 0;
1218
1636
  }
1219
- const [command, ...rest] = positionals;
1637
+ let [command, ...rest] = positionals;
1220
1638
  if (values.help || command === void 0 || command === "help") {
1221
1639
  process.stdout.write(HELP);
1222
1640
  return command === void 0 && !values.help ? 2 : 0;
1223
1641
  }
1224
- if (values.force && (command !== "hook" || rest[0] !== "install")) {
1225
- throw new UsageError("--force applies only to wi hook install.");
1642
+ if (values.force && !(command === "hook" && rest[0] === "install" || command === "setup")) {
1643
+ throw new UsageError("--force applies only to wi hook install or wi setup.");
1644
+ }
1645
+ if (values.yes && command !== "setup") throw new UsageError("--yes applies only to wi setup.");
1646
+ if (command === "setup") {
1647
+ if (rest.length > 0) throw new UsageError("wi setup takes options only. Run wi setup --help for usage.");
1648
+ await runSetup({
1649
+ ...typeof values["vault"] === "string" ? { vault: values["vault"] } : {},
1650
+ yes: values["yes"] === true,
1651
+ force: values["force"] === true
1652
+ });
1653
+ return 0;
1226
1654
  }
1655
+ if (command === "here") return runHere(values, values.json === true);
1227
1656
  const vault = await openVault(values.vault);
1228
1657
  const json = values.json;
1229
1658
  switch (command) {
@@ -1231,6 +1660,10 @@ async function main(argv) {
1231
1660
  return runNew(vault, rest, values, json);
1232
1661
  case "status":
1233
1662
  return runStatus(vault, rest, json);
1663
+ case "claim":
1664
+ return runClaim(vault, rest, values, json);
1665
+ case "release":
1666
+ return runRelease(vault, rest, values, json);
1234
1667
  case "move":
1235
1668
  return runMove(vault, rest, values, json);
1236
1669
  case "archive":
@@ -1238,6 +1671,11 @@ async function main(argv) {
1238
1671
  case "rm":
1239
1672
  return runRemove(vault, rest, values, json);
1240
1673
  case "children":
1674
+ if (rest.length === 0) {
1675
+ const board = await repoBoardForVault(vault);
1676
+ if (!board) throw new UsageError("wi children needs a <ref> or a matching board pointer from wi here.");
1677
+ rest = [board];
1678
+ }
1241
1679
  return runChildren(vault, rest, values, json);
1242
1680
  case "validate":
1243
1681
  return runValidate(vault, json);
@@ -1251,10 +1689,18 @@ async function main(argv) {
1251
1689
  }
1252
1690
  async function openVault(flag) {
1253
1691
  const hint = flag ?? process.env["WI_VAULT"];
1254
- const root = hint ? resolve2(hint) : findVaultRoot(process.cwd());
1692
+ let root = hint ? resolve3(hint) : findVaultRoot(process.cwd());
1693
+ if (root === null) {
1694
+ const pointer = await getRepoPointer(process.cwd());
1695
+ root = pointer ? resolve3(pointer.vault) : null;
1696
+ }
1697
+ if (root === null) {
1698
+ const configured = await getDefaultVault();
1699
+ root = configured ? resolve3(configured) : null;
1700
+ }
1255
1701
  if (root === null) {
1256
1702
  throw new UsageError(
1257
- "no vault found. Run wi inside a vault, pass --vault <path>, or set WI_VAULT."
1703
+ "no vault found. Run wi inside a vault, pass --vault <path>, set WI_VAULT, run wi here, or configure defaultVault with wi setup."
1258
1704
  );
1259
1705
  }
1260
1706
  if (findVaultRoot(root) !== root) {
@@ -1262,11 +1708,45 @@ async function openVault(flag) {
1262
1708
  }
1263
1709
  return loadVault(root);
1264
1710
  }
1711
+ async function runHere(values, json) {
1712
+ const start = process.cwd();
1713
+ const hasFlags = typeof values["board"] === "string" || typeof values["vault"] === "string";
1714
+ const needsExisting = !hasFlags || typeof values["board"] !== "string" || typeof values["vault"] !== "string";
1715
+ const existing = needsExisting ? await getRepoPointer(start) : null;
1716
+ if (!hasFlags) {
1717
+ if (!existing) throw new UsageError("no board pointer is set for this Git repository. Run wi here --board <ref> --vault <path>.");
1718
+ if (json) process.stdout.write(`${JSON.stringify(existing)}
1719
+ `);
1720
+ else process.stdout.write(`vault ${existing.vault}
1721
+ board ${existing.board}
1722
+ `);
1723
+ return 0;
1724
+ }
1725
+ const vault = await openVault(typeof values["vault"] === "string" ? values["vault"] : void 0);
1726
+ const board = typeof values["board"] === "string" ? values["board"] : existing?.board ?? vault.config.defaultRoot;
1727
+ if (!board) throw new UsageError("wi here needs --board <ref> or an existing repo board pointer.");
1728
+ const resolved = vault.resolve(board);
1729
+ const pointer = { vault: vault.root, board: resolved.id ?? resolved.stem };
1730
+ await setRepoPointer(start, pointer);
1731
+ if (json) process.stdout.write(`${JSON.stringify(pointer)}
1732
+ `);
1733
+ else process.stdout.write(`set repo pointer
1734
+ vault ${pointer.vault}
1735
+ board ${pointer.board}
1736
+ `);
1737
+ return 0;
1738
+ }
1739
+ async function repoBoardForVault(vault) {
1740
+ const pointer = await getRepoPointer(process.cwd());
1741
+ return pointer && resolve3(pointer.vault) === resolve3(vault.root) ? pointer.board : void 0;
1742
+ }
1265
1743
  async function runNew(vault, rest, values, json) {
1266
1744
  const title = rest.join(" ").trim();
1267
1745
  if (title === "") throw new UsageError('wi new needs a title. Try: wi new "Build server" --parent Main');
1268
- const parent = typeof values["parent"] === "string" ? values["parent"] : vault.config.defaultRoot;
1269
- if (!parent) throw new UsageError("wi new needs --parent <ref> or defaultRoot in .wi.json.");
1746
+ const explicitParent = typeof values["parent"] === "string" ? values["parent"] : void 0;
1747
+ const pointerBoard = explicitParent === void 0 ? await repoBoardForVault(vault) : void 0;
1748
+ const parent = explicitParent ?? pointerBoard ?? vault.config.defaultRoot;
1749
+ if (!parent) throw new UsageError("wi new needs --parent <ref>, a repo board pointer, or defaultRoot in .wi.json.");
1270
1750
  const priority = typeof values["priority"] === "string" ? Number(values["priority"]) : void 0;
1271
1751
  if (priority !== void 0 && !Number.isFinite(priority)) {
1272
1752
  throw new UsageError(`--priority must be a number, not "${values["priority"]}".`);
@@ -1316,6 +1796,52 @@ async function runStatus(vault, rest, json) {
1316
1796
  }
1317
1797
  return 0;
1318
1798
  }
1799
+ function singleLineOption(values, key) {
1800
+ const value = values[key];
1801
+ if (typeof value !== "string" || value.trim() === "") {
1802
+ throw new UsageError(`--${key} needs non-empty text.`);
1803
+ }
1804
+ if (/[\r\n]/.test(value)) throw new UsageError(`--${key} must be one line.`);
1805
+ return value.trim();
1806
+ }
1807
+ async function runClaim(vault, rest, values, json) {
1808
+ const ref = rest.join(" ").trim();
1809
+ if (ref === "") throw new UsageError("wi claim needs a <ref> and --agent <name>.");
1810
+ const agent = singleLineOption(values, "agent");
1811
+ const change = await claimItem(vault, ref, agent);
1812
+ if (json) print({
1813
+ id: change.item.id,
1814
+ path: change.item.relPath,
1815
+ agent: change.agent,
1816
+ from: change.from ?? null,
1817
+ to: change.to,
1818
+ changed: change.changed
1819
+ });
1820
+ else process.stdout.write(change.changed ? `${label(change.item)} ${change.from ?? "\u2014"} \u2192 doing (agent: ${agent})
1821
+ ` : `${label(change.item)} is already claimed by ${agent} in doing. Nothing written.
1822
+ `);
1823
+ return 0;
1824
+ }
1825
+ async function runRelease(vault, rest, values, json) {
1826
+ const ref = rest.join(" ").trim();
1827
+ if (ref === "") throw new UsageError("wi release needs a <ref> and --reason <text>.");
1828
+ const reason = singleLineOption(values, "reason");
1829
+ const where = values["where"] === void 0 ? void 0 : singleLineOption(values, "where");
1830
+ const change = await releaseItem(vault, ref, reason, where);
1831
+ if (json) print({
1832
+ id: change.item.id,
1833
+ path: change.item.relPath,
1834
+ agent: change.agent,
1835
+ from: change.from ?? null,
1836
+ to: change.to,
1837
+ reason: change.reason,
1838
+ where: change.where ?? null,
1839
+ changed: change.changed
1840
+ });
1841
+ else process.stdout.write(`${label(change.item)} ${change.from ?? "\u2014"} \u2192 options (released ${change.agent})
1842
+ `);
1843
+ return 0;
1844
+ }
1319
1845
  async function runMove(vault, rest, values, json) {
1320
1846
  const ref = rest.join(" ").trim();
1321
1847
  const to = typeof values["to"] === "string" ? values["to"] : "";
@@ -1395,6 +1921,14 @@ function runChildren(vault, rest, values, json) {
1395
1921
  print({
1396
1922
  parent: { id: listing.parent.id, path: listing.parent.relPath, board: listing.parent.board },
1397
1923
  cycle: listing.cycle,
1924
+ areas: listing.areas.map((row) => ({
1925
+ id: row.item.id,
1926
+ title: row.item.title,
1927
+ path: row.item.relPath,
1928
+ children: row.childCount,
1929
+ depth: row.depth,
1930
+ archived: row.archived
1931
+ })),
1398
1932
  children: listing.children.map((row) => ({
1399
1933
  id: row.item.id,
1400
1934
  title: row.item.title,
@@ -1410,7 +1944,11 @@ function runChildren(vault, rest, values, json) {
1410
1944
  const out = [
1411
1945
  `${label(listing.parent)}${listing.parent.board ? " [board]" : ""}`
1412
1946
  ];
1413
- if (listing.children.length === 0) {
1947
+ if (listing.areas.length > 0) {
1948
+ out.push(` Areas (${listing.areas.length})`);
1949
+ for (const row of listing.areas) out.push(` ${" ".repeat(row.depth)}${row3(row)}`);
1950
+ }
1951
+ if (listing.children.length === 0 && listing.areas.length === 0) {
1414
1952
  out.push(" no children");
1415
1953
  } else if (values["tree"] === true || typeof values["status"] === "string") {
1416
1954
  for (const row of listing.children) out.push(` ${" ".repeat(row.depth)}${row3(row)}`);
@@ -1507,7 +2045,7 @@ async function runHook(vault, rest, values, json) {
1507
2045
  ` : "done\n");
1508
2046
  return 0;
1509
2047
  }
1510
- const path = await installHook(vault.root, fileURLToPath(import.meta.url), values["force"] === true);
2048
+ const path = await installHook(vault.root, fileURLToPath2(import.meta.url), values["force"] === true);
1511
2049
  if (json) print({ installed: path });
1512
2050
  else {
1513
2051
  process.stdout.write(`installed ${path}
package/package.json CHANGED
@@ -1,10 +1,14 @@
1
1
  {
2
2
  "name": "recursive-board",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
4
4
  "type": "module",
5
5
  "description": "wi: the CLI that owns every write into a Recursive Board vault",
6
6
  "author": "vic-cio",
7
7
  "license": "MIT",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/vic-cio/recursive-board.git"
11
+ },
8
12
  "engines": {
9
13
  "node": ">=20.12"
10
14
  },
@@ -12,12 +16,14 @@
12
16
  "wi": "./dist/wi/wi.js"
13
17
  },
14
18
  "files": [
15
- "dist/wi/"
19
+ "dist/wi/",
20
+ "skills/"
16
21
  ],
17
22
  "scripts": {
18
23
  "test": "npm run typecheck && node --test 'src/**/*.test.ts' 'scripts/**/*.test.ts'",
19
24
  "test:unit": "node --test 'src/**/*.test.ts' 'scripts/**/*.test.ts'",
20
25
  "fixture": "node scripts/fixture.ts",
26
+ "bench": "node scripts/bench.ts",
21
27
  "typecheck": "tsc -p tsconfig.json && tsc -p tsconfig.plugin.json",
22
28
  "wi": "node ./src/cli/wi.ts",
23
29
  "build": "node build/plugin.mjs && node build/wi.mjs",
@@ -33,6 +39,5 @@
33
39
  "esbuild": "^0.28.2",
34
40
  "obsidian": "^1.13.1",
35
41
  "typescript": "^5.9.3"
36
- },
37
- "repository": "https://github.com/vic-cio/recursive-board.git"
42
+ }
38
43
  }
@@ -0,0 +1,98 @@
1
+ ---
2
+ name: recursive-board
3
+ description: Read and change work items in a Recursive Board vault through the `wi` CLI. Use when asked what is on a board, backlog or agenda; to add, tick, move, archive or remove a card; to break work into child items; or to take on and work a card. Also use whenever the working directory holds a `Boards/` folder or a `.wi.json` file.
4
+ ---
5
+
6
+ # recursive-board
7
+
8
+ A Recursive Board vault stores each work item as one Markdown file. `wi` owns work-item metadata
9
+ and applies the same rules as the Obsidian plugin. Edit card body text directly when the workflow
10
+ below calls for it.
11
+
12
+ ## Setup
13
+
14
+ After installing the `recursive-board` npm package, run `wi setup` once. It copies this skill to
15
+ both `~/.claude/skills/recursive-board/` and `~/.agents/skills/recursive-board/`, detects vaults
16
+ from Obsidian's registry, and saves the selected default vault. Use `wi setup --vault <path>` to
17
+ select a vault directly, or `wi setup --yes --vault <path>` for unattended setup. Setup keeps
18
+ symlinked development installs and refuses to replace an unmanaged skill folder unless passed
19
+ `--force`.
20
+
21
+ ## Before the first write
22
+
23
+ If the vault root has an `AGENTS.md`, read it. The vault owner's rules there (what you may
24
+ delete, how to name cards) take priority over this page.
25
+
26
+ ## Reaching the vault
27
+
28
+ `wi` finds the vault in this order: `--vault <path>`, `$WI_VAULT`, the nearest folder above the
29
+ working directory that holds `.wi.json` or `Boards/`, this Git repo's pointer, then
30
+ `defaultVault` in `~/.config/wi/config.json` (or `$XDG_CONFIG_HOME/wi/config.json`). To set a
31
+ repo pointer, run `wi here --vault <path> --board <ref>` once from that repo. It is stored in
32
+ user config outside the repo, keyed by Git's common directory, so linked worktrees share it.
33
+ Run `wi here` to print the current repo's pointer. With a pointer, `wi new` defaults to its board
34
+ and `wi children` can omit the reference. Explicit `--parent` and `wi children <ref>` still work.
35
+
36
+ `wi --help` is the full command reference. Read it for any flag this page does not name.
37
+
38
+ ## Reading
39
+
40
+ A `<ref>` is an id (`wi-3k9p`), a filename, or a title. Use the id once you have it: it is exact
41
+ and survives a rename.
42
+
43
+ ```bash
44
+ wi children <root> --tree # the whole tree under a root item
45
+ wi children <ref> # one board, grouped by status
46
+ wi children <ref> --status doing # one column
47
+ wi children # use the repo pointer's board
48
+ ```
49
+
50
+ Add `--json` when you parse the result. A card's own text (objective, criteria, notes) is in
51
+ its file in the work-item folder (`Boards/` by default). Read the file.
52
+
53
+ ## Writing
54
+
55
+ ```bash
56
+ wi new "<title>" --parent <ref> [--status backlog] [--priority <n>] # 1 is the highest
57
+ wi status <ref> <backlog|options|doing|done>
58
+ wi claim <ref> --agent <name> # assign and move to doing in one write
59
+ wi release <ref> --reason <text> [--where <branch-or-path>]
60
+ wi move <ref> --to <new parent ref>
61
+ wi archive <ref> # --undo reverses it
62
+ wi rm <ref> --recursive --dry-run # read what it lists, then run it without --dry-run
63
+ ```
64
+
65
+ - Write a new title as an imperative phrase. It becomes the filename, so make it unique.
66
+ - `wi new` writes the frontmatter and the template sections. Fill the body sections with a file
67
+ edit afterwards. Leave the frontmatter to `wi`.
68
+ - To untick a done item, set it back to its `prev_status`.
69
+ - A dispatcher claims cards from options for its workers. `wi claim` refuses a card claimed by a
70
+ different agent, a done card, or a board with a child in doing. A repeat by the same agent in
71
+ doing writes nothing.
72
+ - When a worker stops, its dispatcher runs `wi release` with a reason and, when available, the
73
+ branch or worktree path. Release clears the agent, returns the card to options, and records the
74
+ continuation location in Notes.
75
+ - Run `wi validate` after a batch of writes. Exit 0 is clean. Exit 1 lists what broke.
76
+
77
+ ## Working a card
78
+
79
+ For `/recursive-board <card> <instruction>`, use the instruction to clarify the request and the
80
+ card's Objective, Context and Acceptance Criteria as the brief:
81
+
82
+ 1. Resolve the vault and board from the repo map (`wi here`). If the repo has no entry, ask once
83
+ which board to use, then record it with `wi here --board <board> --vault <vault>`.
84
+ 2. Read the card body. If Objective or Acceptance Criteria is missing, write a proposed brief in
85
+ the card and ask for approval; wait before doing the work.
86
+ 3. Claim it with `wi claim <card> --agent <agent name>`. Work in the current repo on a new
87
+ `card/<slug>` branch. Run the repo's tests and commit the result.
88
+ 4. If the work is too large or cannot finish, stop with a split proposal or continuation note in
89
+ your report, then run `wi release <card> --reason <reason> --where <branch-or-path>`. Let the
90
+ dispatcher decide whether to create child cards.
91
+ 5. When finished, add one dated line under the card's Notes with the branch and result. Leave the
92
+ card in `doing` and stop for review. Do not merge or push; those wait for the owner's verdict.
93
+
94
+ ## Outcomes
95
+
96
+ `wi` prints the change it made, for example `wi-3k9p Write the release notes doing → done`.
97
+ Report that line to the user. A refusal (exit 2) names its reason. It is a rule doing its job:
98
+ read it and change the approach. Do not work around it with a file tool.