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 +22 -5
- package/dist/wi/wi.js +590 -52
- package/package.json +9 -4
- package/skills/recursive-board/SKILL.md +98 -0
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`
|
|
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 `
|
|
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`.
|
|
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
|
|
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
|
|
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 {
|
|
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
|
|
256
|
-
for (const heading of
|
|
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
|
|
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:
|
|
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
|
|
495
|
-
await
|
|
576
|
+
await writeFile2(temp, text, "utf8");
|
|
577
|
+
await rename2(temp, path);
|
|
496
578
|
}
|
|
497
|
-
async function editItem(item, edits) {
|
|
498
|
-
|
|
499
|
-
|
|
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
|
-
|
|
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(
|
|
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 (
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
1034
|
-
await
|
|
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
|
|
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
|
|
1136
|
-
await
|
|
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,
|
|
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
|
|
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.
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
1269
|
-
|
|
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.
|
|
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,
|
|
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.
|
|
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.
|