recursive-board 0.1.2 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +47 -8
- package/dist/wi/wi.js +1289 -126
- package/package.json +4 -2
- package/skills/recursive-board/SKILL.md +158 -0
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];
|
|
@@ -135,6 +144,47 @@ function removeKey(text, key) {
|
|
|
135
144
|
function defaultEnd(text) {
|
|
136
145
|
return text.includes("\r\n") ? "\r\n" : "\n";
|
|
137
146
|
}
|
|
147
|
+
function getList(text, key) {
|
|
148
|
+
const block = splitBlock(text);
|
|
149
|
+
if (!block) return void 0;
|
|
150
|
+
const entry = readEntries(block.lines).find((e) => e.key === key);
|
|
151
|
+
if (!entry) return void 0;
|
|
152
|
+
const first = KEY_LINE.exec(block.lines[entry.start])?.[2]?.trim() ?? "";
|
|
153
|
+
let parts;
|
|
154
|
+
if (first.startsWith("[")) {
|
|
155
|
+
parts = first.replace(/^\[|\]$/g, "").split(",");
|
|
156
|
+
} else if (first !== "") {
|
|
157
|
+
parts = first.split(/[,\s]+/);
|
|
158
|
+
} else {
|
|
159
|
+
parts = block.lines.slice(entry.start + 1, entry.end).map((line2) => /^[ \t]*-[ \t]+(.*)$/.exec(line2)?.[1] ?? "");
|
|
160
|
+
}
|
|
161
|
+
return parts.map((part) => {
|
|
162
|
+
const value = parseScalar(part);
|
|
163
|
+
return typeof value === "string" ? value : value === void 0 ? "" : String(value);
|
|
164
|
+
}).map((part) => part.trim()).filter((part) => part !== "");
|
|
165
|
+
}
|
|
166
|
+
function setList(text, key, values) {
|
|
167
|
+
if (values.length === 0) return removeKey(text, key);
|
|
168
|
+
const block = splitBlock(text);
|
|
169
|
+
if (!block) throw new Error(`cannot set "${key}": the file has no frontmatter`);
|
|
170
|
+
const entry = readEntries(block.lines).find((e) => e.key === key);
|
|
171
|
+
const current = getList(text, key);
|
|
172
|
+
if (entry && current !== void 0 && current.length === values.length && current.every((value, i) => value === values[i]) && block.lines.slice(entry.start + 1, entry.end).every((line2) => /^[ \t]*-[ \t]/.test(line2))) {
|
|
173
|
+
return text;
|
|
174
|
+
}
|
|
175
|
+
const eol = defaultEnd(text);
|
|
176
|
+
const replacement = [`${key}:`, ...values.map((value) => ` - ${formatScalar(value)}`)];
|
|
177
|
+
const lines = [...block.lines];
|
|
178
|
+
const ends = [...block.ends];
|
|
179
|
+
if (entry) {
|
|
180
|
+
lines.splice(entry.start, entry.end - entry.start, ...replacement);
|
|
181
|
+
ends.splice(entry.start, entry.end - entry.start, ...replacement.map(() => eol));
|
|
182
|
+
} else {
|
|
183
|
+
lines.push(...replacement);
|
|
184
|
+
ends.push(...replacement.map(() => eol));
|
|
185
|
+
}
|
|
186
|
+
return rewrite(text, block, lines, ends);
|
|
187
|
+
}
|
|
138
188
|
|
|
139
189
|
// src/shared/schema.ts
|
|
140
190
|
var STATUSES = ["backlog", "options", "doing", "done"];
|
|
@@ -157,11 +207,15 @@ var OPTIONAL_FIELDS = [
|
|
|
157
207
|
"blocked",
|
|
158
208
|
"depends_on",
|
|
159
209
|
"tags",
|
|
160
|
-
"archived"
|
|
210
|
+
"archived",
|
|
161
211
|
// docs/adr/0007-archive-is-a-frontmatter-flag.md: a flag, not a status.
|
|
212
|
+
"area"
|
|
213
|
+
// docs/adr/0028-area-work-items.md: an ongoing space that keeps its status.
|
|
162
214
|
];
|
|
163
|
-
var INHERITED_FIELDS = ["owner", "agent"];
|
|
164
215
|
var WORK_ITEM_TYPE = "work-item";
|
|
216
|
+
function isArea(value) {
|
|
217
|
+
return value === true;
|
|
218
|
+
}
|
|
165
219
|
var FOLDERS = ["Boards", "Templates"];
|
|
166
220
|
var BOARDS = "Boards";
|
|
167
221
|
function isStatus(value) {
|
|
@@ -214,7 +268,10 @@ var WI_CONFIG_FILE = ".wi.json";
|
|
|
214
268
|
var DEFAULT_VAULT_CONFIG = {
|
|
215
269
|
workItemFolder: "Boards",
|
|
216
270
|
defaultRoot: null,
|
|
217
|
-
extraSections: []
|
|
271
|
+
extraSections: [],
|
|
272
|
+
maxAgents: null,
|
|
273
|
+
autoPromote: true,
|
|
274
|
+
areaTags: false
|
|
218
275
|
};
|
|
219
276
|
function parseVaultConfig(text) {
|
|
220
277
|
if (text === null) return { ...DEFAULT_VAULT_CONFIG, extraSections: [] };
|
|
@@ -230,6 +287,9 @@ function parseVaultConfig(text) {
|
|
|
230
287
|
const folderValue = "workItemFolder" in value ? value.workItemFolder : void 0;
|
|
231
288
|
const rootValue = "defaultRoot" in value ? value.defaultRoot : void 0;
|
|
232
289
|
const sectionsValue = "extraSections" in value ? value.extraSections : void 0;
|
|
290
|
+
const maxAgentsValue = "maxAgents" in value ? value.maxAgents : void 0;
|
|
291
|
+
const autoPromoteValue = "autoPromote" in value ? value.autoPromote : void 0;
|
|
292
|
+
const areaTagsValue = "areaTags" in value ? value.areaTags : void 0;
|
|
233
293
|
let workItemFolder = DEFAULT_VAULT_CONFIG.workItemFolder;
|
|
234
294
|
if (folderValue !== void 0) {
|
|
235
295
|
if (typeof folderValue !== "string") {
|
|
@@ -252,15 +312,30 @@ function parseVaultConfig(text) {
|
|
|
252
312
|
if (!Array.isArray(sectionsValue)) {
|
|
253
313
|
throw new Error(`${WI_CONFIG_FILE}: extraSections must be an array of non-empty, single-line headings.`);
|
|
254
314
|
}
|
|
255
|
-
const
|
|
256
|
-
for (const heading of
|
|
315
|
+
const headings2 = sectionsValue;
|
|
316
|
+
for (const heading of headings2) {
|
|
257
317
|
if (typeof heading !== "string" || heading.trim() === "" || /[\r\n]/.test(heading)) {
|
|
258
318
|
throw new Error(`${WI_CONFIG_FILE}: extraSections must be an array of non-empty, single-line headings.`);
|
|
259
319
|
}
|
|
260
320
|
extraSections.push(heading);
|
|
261
321
|
}
|
|
262
322
|
}
|
|
263
|
-
|
|
323
|
+
let maxAgents = null;
|
|
324
|
+
if (maxAgentsValue !== void 0 && maxAgentsValue !== null) {
|
|
325
|
+
if (typeof maxAgentsValue !== "number" || !Number.isSafeInteger(maxAgentsValue) || maxAgentsValue < 0) {
|
|
326
|
+
throw new Error(`${WI_CONFIG_FILE}: maxAgents must be a non-negative whole number or null.`);
|
|
327
|
+
}
|
|
328
|
+
maxAgents = maxAgentsValue;
|
|
329
|
+
}
|
|
330
|
+
if (autoPromoteValue !== void 0 && typeof autoPromoteValue !== "boolean") {
|
|
331
|
+
throw new Error(`${WI_CONFIG_FILE}: autoPromote must be true or false.`);
|
|
332
|
+
}
|
|
333
|
+
const autoPromote = autoPromoteValue ?? DEFAULT_VAULT_CONFIG.autoPromote;
|
|
334
|
+
if (areaTagsValue !== void 0 && typeof areaTagsValue !== "boolean") {
|
|
335
|
+
throw new Error(`${WI_CONFIG_FILE}: areaTags must be true or false.`);
|
|
336
|
+
}
|
|
337
|
+
const areaTags = areaTagsValue ?? DEFAULT_VAULT_CONFIG.areaTags;
|
|
338
|
+
return { workItemFolder, defaultRoot, extraSections, maxAgents, autoPromote, areaTags };
|
|
264
339
|
}
|
|
265
340
|
|
|
266
341
|
// src/shared/archive.ts
|
|
@@ -291,7 +366,83 @@ function activeDescendant(item, childrenOf, statusOf) {
|
|
|
291
366
|
}
|
|
292
367
|
|
|
293
368
|
// src/cli/vault.ts
|
|
369
|
+
function maxAgentsForRun(vault, env = process.env) {
|
|
370
|
+
const override = env["WI_MAX_AGENTS"];
|
|
371
|
+
if (override === void 0) return vault.config.maxAgents;
|
|
372
|
+
if (override.trim() === "") return null;
|
|
373
|
+
if (!/^\d+$/.test(override) || !Number.isSafeInteger(Number(override))) {
|
|
374
|
+
throw new Error("WI_MAX_AGENTS must be a non-negative whole number.");
|
|
375
|
+
}
|
|
376
|
+
return Number(override);
|
|
377
|
+
}
|
|
294
378
|
var MARKDOWN = /\.md$/i;
|
|
379
|
+
function wiConfigDir(env = process.env) {
|
|
380
|
+
const xdg = env["XDG_CONFIG_HOME"];
|
|
381
|
+
return xdg && isAbsolute(xdg) ? join(xdg, "wi") : join(homedir(), ".config", "wi");
|
|
382
|
+
}
|
|
383
|
+
function gitCommonDir(start) {
|
|
384
|
+
try {
|
|
385
|
+
const output = execFileSync("git", ["rev-parse", "--git-common-dir"], {
|
|
386
|
+
cwd: start,
|
|
387
|
+
encoding: "utf8",
|
|
388
|
+
stdio: ["ignore", "pipe", "ignore"]
|
|
389
|
+
}).trim();
|
|
390
|
+
return realpathSync(resolvePath(start, output));
|
|
391
|
+
} catch {
|
|
392
|
+
return null;
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
async function readJsonObject(path, description) {
|
|
396
|
+
let text;
|
|
397
|
+
try {
|
|
398
|
+
text = await readFile(path, "utf8");
|
|
399
|
+
} catch (error) {
|
|
400
|
+
if (error.code === "ENOENT") return null;
|
|
401
|
+
throw error;
|
|
402
|
+
}
|
|
403
|
+
let value;
|
|
404
|
+
try {
|
|
405
|
+
value = JSON.parse(text);
|
|
406
|
+
} catch {
|
|
407
|
+
throw new Error(`${description} must contain valid JSON.`);
|
|
408
|
+
}
|
|
409
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) {
|
|
410
|
+
throw new Error(`${description} must contain a JSON object.`);
|
|
411
|
+
}
|
|
412
|
+
return value;
|
|
413
|
+
}
|
|
414
|
+
async function getRepoPointer(start, env = process.env) {
|
|
415
|
+
const commonDir = gitCommonDir(start);
|
|
416
|
+
if (!commonDir) return null;
|
|
417
|
+
const map = await readJsonObject(join(wiConfigDir(env), "repos.json"), "wi repos.json");
|
|
418
|
+
const value = map?.[commonDir];
|
|
419
|
+
if (value === void 0) return null;
|
|
420
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) {
|
|
421
|
+
throw new Error(`wi repos.json: entry for ${commonDir} must contain vault and board strings.`);
|
|
422
|
+
}
|
|
423
|
+
const entry = value;
|
|
424
|
+
if (typeof entry["vault"] !== "string" || typeof entry["board"] !== "string") {
|
|
425
|
+
throw new Error(`wi repos.json: entry for ${commonDir} must contain vault and board strings.`);
|
|
426
|
+
}
|
|
427
|
+
return { vault: entry["vault"], board: entry["board"] };
|
|
428
|
+
}
|
|
429
|
+
async function setRepoPointer(start, pointer, env = process.env) {
|
|
430
|
+
const commonDir = gitCommonDir(start);
|
|
431
|
+
if (!commonDir) throw new Error("wi here must run inside a Git repository.");
|
|
432
|
+
const dir = wiConfigDir(env);
|
|
433
|
+
const path = join(dir, "repos.json");
|
|
434
|
+
const map = await readJsonObject(path, "wi repos.json") ?? {};
|
|
435
|
+
map[commonDir] = pointer;
|
|
436
|
+
await mkdir(dir, { recursive: true });
|
|
437
|
+
const temporary = `${path}.${process.pid}.tmp`;
|
|
438
|
+
await writeFile(temporary, `${JSON.stringify(map, null, 2)}
|
|
439
|
+
`, "utf8");
|
|
440
|
+
await rename(temporary, path);
|
|
441
|
+
}
|
|
442
|
+
async function getDefaultVault(env = process.env) {
|
|
443
|
+
const config = await readJsonObject(join(wiConfigDir(env), "config.json"), "wi config.json");
|
|
444
|
+
return typeof config?.["defaultVault"] === "string" ? config["defaultVault"] : null;
|
|
445
|
+
}
|
|
295
446
|
var IGNORED_HIDDEN = /* @__PURE__ */ new Set([".DS_Store", ".localized", ".gitkeep"]);
|
|
296
447
|
function isUnaccounted(name) {
|
|
297
448
|
if (!name.startsWith(".")) return false;
|
|
@@ -355,6 +506,7 @@ function toWorkItem(root, relPath, text) {
|
|
|
355
506
|
parent: parseWikilink(parentRaw),
|
|
356
507
|
parentRaw: typeof parentRaw === "string" ? parentRaw : void 0,
|
|
357
508
|
board: frontmatter.get("board") === true,
|
|
509
|
+
area: isArea(frontmatter.get("area")),
|
|
358
510
|
archived: frontmatter.get("archived") === true,
|
|
359
511
|
frontmatter,
|
|
360
512
|
text
|
|
@@ -432,7 +584,7 @@ async function loadVault(root) {
|
|
|
432
584
|
}
|
|
433
585
|
for (const siblings of children.values()) siblings.sort(compareSiblings);
|
|
434
586
|
const resolveLink = (target) => target === null ? void 0 : byStem.get(linkKey(target));
|
|
435
|
-
const
|
|
587
|
+
const resolve4 = (ref) => {
|
|
436
588
|
const trimmed = ref.trim();
|
|
437
589
|
const byIdHit = byId.get(trimmed);
|
|
438
590
|
if (byIdHit) return byIdHit;
|
|
@@ -458,7 +610,7 @@ async function loadVault(root) {
|
|
|
458
610
|
takenIds: new Set(byId.keys()),
|
|
459
611
|
takenStems: new Set(byStem.keys()),
|
|
460
612
|
resolveLink,
|
|
461
|
-
resolve:
|
|
613
|
+
resolve: resolve4,
|
|
462
614
|
childrenOf: (item) => children.get(item.stem.toLowerCase()) ?? [],
|
|
463
615
|
isArchived: (item) => archiveOwner(item, (current) => resolveLink(current.parent) ?? null, (current) => current.archived) !== null
|
|
464
616
|
};
|
|
@@ -468,18 +620,21 @@ function isMissingFile(error) {
|
|
|
468
620
|
}
|
|
469
621
|
|
|
470
622
|
// src/cli/commands/new.ts
|
|
471
|
-
import { existsSync as
|
|
472
|
-
import { join as
|
|
623
|
+
import { existsSync as existsSync3 } from "node:fs";
|
|
624
|
+
import { join as join4 } from "node:path";
|
|
473
625
|
|
|
474
626
|
// src/cli/write.ts
|
|
475
|
-
import { writeFile, rename } from "node:fs/promises";
|
|
627
|
+
import { writeFile as writeFile2, rename as rename2, readFile as readFile2, mkdir as mkdir2, rm, stat } from "node:fs/promises";
|
|
628
|
+
import { createHash } from "node:crypto";
|
|
629
|
+
import { tmpdir } from "node:os";
|
|
476
630
|
import { dirname as dirname2, join as join2 } from "node:path";
|
|
631
|
+
import { setTimeout as sleep } from "node:timers/promises";
|
|
477
632
|
|
|
478
633
|
// src/shared/edits.ts
|
|
479
634
|
function applyEdits(text, edits) {
|
|
480
635
|
let out = text;
|
|
481
636
|
for (const edit of edits) {
|
|
482
|
-
out = edit.op === "set" ? setKey(out, edit.key, edit.value) : removeKey(out, edit.key);
|
|
637
|
+
out = edit.op === "set" ? setKey(out, edit.key, edit.value) : edit.op === "list" ? setList(out, edit.key, edit.values) : removeKey(out, edit.key);
|
|
483
638
|
}
|
|
484
639
|
return out;
|
|
485
640
|
}
|
|
@@ -490,14 +645,46 @@ function withStamp(edits, stamp = today()) {
|
|
|
490
645
|
|
|
491
646
|
// src/cli/write.ts
|
|
492
647
|
async function writeAtomic(path, text) {
|
|
493
|
-
const temp = join2(dirname2(path), `.wi-${process.pid}-${Date.now()}.tmp`);
|
|
494
|
-
await
|
|
495
|
-
await
|
|
496
|
-
}
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
648
|
+
const temp = join2(dirname2(path), `.wi-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}.tmp`);
|
|
649
|
+
await writeFile2(temp, text, "utf8");
|
|
650
|
+
await rename2(temp, path);
|
|
651
|
+
}
|
|
652
|
+
var LOCK_WAIT_MS = 5e3;
|
|
653
|
+
var LOCK_STALE_MS = 3e4;
|
|
654
|
+
async function withFileLock(path, fn) {
|
|
655
|
+
const lock = join2(tmpdir(), `wi-lock-${createHash("sha1").update(path).digest("hex")}`);
|
|
656
|
+
const deadline = Date.now() + LOCK_WAIT_MS;
|
|
657
|
+
for (; ; ) {
|
|
658
|
+
try {
|
|
659
|
+
await mkdir2(lock);
|
|
660
|
+
break;
|
|
661
|
+
} catch (error) {
|
|
662
|
+
if (error.code !== "EEXIST") throw error;
|
|
663
|
+
const age = await stat(lock).then((s) => Date.now() - s.mtimeMs, () => 0);
|
|
664
|
+
if (age > LOCK_STALE_MS) {
|
|
665
|
+
await rm(lock, { recursive: true, force: true });
|
|
666
|
+
continue;
|
|
667
|
+
}
|
|
668
|
+
if (Date.now() > deadline) {
|
|
669
|
+
throw new Error(`another wi process is writing ${path}. Retry. If no wi runs, remove ${lock}.`);
|
|
670
|
+
}
|
|
671
|
+
await sleep(20);
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
try {
|
|
675
|
+
return await fn();
|
|
676
|
+
} finally {
|
|
677
|
+
await rm(lock, { recursive: true, force: true });
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
async function editItem(item, edits, editBody = (text) => text) {
|
|
681
|
+
return withFileLock(item.path, async () => {
|
|
682
|
+
const current = await readFile2(item.path, "utf8");
|
|
683
|
+
if (editBody(applyEdits(current, edits)) === current) return current;
|
|
684
|
+
const text = editBody(applyEdits(current, withStamp(edits)));
|
|
685
|
+
await writeAtomic(item.path, text);
|
|
686
|
+
return text;
|
|
687
|
+
});
|
|
501
688
|
}
|
|
502
689
|
|
|
503
690
|
// src/shared/templates.ts
|
|
@@ -511,6 +698,24 @@ var TEMPLATES = [
|
|
|
511
698
|
{ heading: "Acceptance Criteria", starter: "- " },
|
|
512
699
|
{ heading: "Notes" }
|
|
513
700
|
]
|
|
701
|
+
},
|
|
702
|
+
{
|
|
703
|
+
name: "first-board-card",
|
|
704
|
+
description: "A short guide to adding and moving cards on your first board.",
|
|
705
|
+
sections: [
|
|
706
|
+
{ heading: "Getting started", starter: "Add cards from a board column. Use Promote at the top of this card to give it its own board. To move this card, open its card menu and choose \u201CMove to\u2026\u201D." },
|
|
707
|
+
{ heading: "Notes" }
|
|
708
|
+
]
|
|
709
|
+
},
|
|
710
|
+
{
|
|
711
|
+
name: "area",
|
|
712
|
+
description: "An ongoing area of work.",
|
|
713
|
+
area: true,
|
|
714
|
+
sections: [
|
|
715
|
+
{ heading: "Objective" },
|
|
716
|
+
{ heading: "Context" },
|
|
717
|
+
{ heading: "Notes" }
|
|
718
|
+
]
|
|
514
719
|
}
|
|
515
720
|
];
|
|
516
721
|
var DEFAULT_TEMPLATE = "work-item";
|
|
@@ -525,15 +730,45 @@ function requireTemplate(name = DEFAULT_TEMPLATE) {
|
|
|
525
730
|
if (template) return template;
|
|
526
731
|
throw new Error(`no template called "${name}". There is: ${templateNames().join(", ")}.`);
|
|
527
732
|
}
|
|
528
|
-
|
|
529
|
-
|
|
733
|
+
var BRIEF_HEADINGS = { objective: "Objective", context: "Context", criteria: "Acceptance Criteria" };
|
|
734
|
+
function briefGaps(template, brief = {}) {
|
|
735
|
+
const headings2 = new Set(template.sections.map((s) => s.heading));
|
|
736
|
+
const gaps = [];
|
|
737
|
+
if (headings2.has(BRIEF_HEADINGS.objective) && !brief.objective?.trim()) gaps.push(BRIEF_HEADINGS.objective);
|
|
738
|
+
if (headings2.has(BRIEF_HEADINGS.criteria) && !brief.criteria?.some((c) => c.trim() !== "")) {
|
|
739
|
+
gaps.push(BRIEF_HEADINGS.criteria);
|
|
740
|
+
}
|
|
741
|
+
return gaps;
|
|
742
|
+
}
|
|
743
|
+
function briefContent(brief) {
|
|
744
|
+
const content = /* @__PURE__ */ new Map();
|
|
745
|
+
const objective = brief.objective?.trim();
|
|
746
|
+
if (objective) content.set(BRIEF_HEADINGS.objective, objective);
|
|
747
|
+
const context = (brief.context ?? []).map((c) => c.trim()).filter((c) => c !== "");
|
|
748
|
+
if (context.length > 0) content.set(BRIEF_HEADINGS.context, context.join("\n\n"));
|
|
749
|
+
const criteria = (brief.criteria ?? []).map((c) => c.trim()).filter((c) => c !== "");
|
|
750
|
+
for (const line2 of criteria) {
|
|
751
|
+
if (/[\r\n]/.test(line2)) throw new Error("each acceptance criterion must be one line.");
|
|
752
|
+
}
|
|
753
|
+
if (criteria.length > 0) content.set(BRIEF_HEADINGS.criteria, criteria.map((c) => `- ${c}`).join("\n"));
|
|
754
|
+
return content;
|
|
755
|
+
}
|
|
756
|
+
function renderBody(template, extraSections = [], brief = {}) {
|
|
757
|
+
const content = briefContent(brief);
|
|
530
758
|
const sections = [
|
|
531
759
|
...template.sections,
|
|
532
760
|
...extraSections.map((heading) => ({ heading, starter: "- " }))
|
|
533
761
|
];
|
|
762
|
+
for (const heading of content.keys()) {
|
|
763
|
+
if (!sections.some((s) => s.heading === heading)) {
|
|
764
|
+
throw new Error(`template "${template.name}" has no ${heading} section.`);
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
const parts = [];
|
|
534
768
|
for (const section of sections) {
|
|
535
769
|
parts.push(`## ${section.heading}`, "");
|
|
536
|
-
|
|
770
|
+
const filled = content.get(section.heading) ?? section.starter;
|
|
771
|
+
if (filled !== void 0) parts.push(filled, "");
|
|
537
772
|
}
|
|
538
773
|
return parts.join("\n");
|
|
539
774
|
}
|
|
@@ -542,14 +777,17 @@ function renderVaultTemplate(template, defaultRoot = null, extraSections = []) {
|
|
|
542
777
|
type: formatScalar(WORK_ITEM_TYPE),
|
|
543
778
|
id: "wi-XXXX",
|
|
544
779
|
title: "",
|
|
545
|
-
status: "backlog",
|
|
546
780
|
parent: defaultRoot === null ? "" : formatScalar(formatWikilink(defaultRoot)),
|
|
547
781
|
created: "",
|
|
548
782
|
updated: ""
|
|
549
783
|
};
|
|
550
|
-
|
|
784
|
+
placeholders["status"] = "backlog";
|
|
785
|
+
if (template.area) placeholders["area"] = "true";
|
|
786
|
+
const fields = CORE_FIELDS;
|
|
787
|
+
const lines = fields.filter((field) => field in placeholders).map(
|
|
551
788
|
(field) => `${field}: ${placeholders[field]}`.trimEnd()
|
|
552
789
|
);
|
|
790
|
+
if (template.area) lines.splice(4, 0, "area: true");
|
|
553
791
|
return `---
|
|
554
792
|
${lines.join("\n")}
|
|
555
793
|
---
|
|
@@ -558,64 +796,38 @@ ${renderBody(template, extraSections)}`;
|
|
|
558
796
|
}
|
|
559
797
|
|
|
560
798
|
// src/shared/work-item.ts
|
|
799
|
+
function inheritedChildFields(parent, status, overrides = {}) {
|
|
800
|
+
return {
|
|
801
|
+
owner: overrides.owner ?? parent.owner,
|
|
802
|
+
agent: overrides.agent ?? (status === "doing" ? parent.agent : void 0)
|
|
803
|
+
};
|
|
804
|
+
}
|
|
561
805
|
function renderWorkItem(item, extraSections = []) {
|
|
806
|
+
const template = requireTemplate(item.template);
|
|
807
|
+
if (Boolean(template.area) !== (item.area === true)) {
|
|
808
|
+
throw new Error(`template "${template.name}" does not match the work item kind`);
|
|
809
|
+
}
|
|
562
810
|
const fields = [
|
|
563
811
|
["type", WORK_ITEM_TYPE],
|
|
564
812
|
["id", item.id],
|
|
565
|
-
["title", item.title]
|
|
566
|
-
["status", item.status],
|
|
567
|
-
["parent", formatWikilink(item.parentStem)]
|
|
813
|
+
["title", item.title]
|
|
568
814
|
];
|
|
815
|
+
fields.push(["status", item.status]);
|
|
816
|
+
if (item.area) fields.push(["area", true]);
|
|
817
|
+
fields.push(["parent", formatWikilink(item.parentStem)]);
|
|
569
818
|
if (item.owner !== void 0 && item.owner !== "") fields.push(["owner", item.owner]);
|
|
570
819
|
if (item.agent !== void 0 && item.agent !== "") fields.push(["agent", item.agent]);
|
|
571
820
|
if (item.priority !== void 0) fields.push(["priority", item.priority]);
|
|
572
821
|
fields.push(["created", item.created], ["updated", item.updated]);
|
|
573
|
-
const
|
|
822
|
+
const lines = fields.map(([key, value]) => `${key}: ${formatScalar(value)}`);
|
|
823
|
+
if (item.tags !== void 0 && item.tags.length > 0) {
|
|
824
|
+
lines.push("tags:", ...item.tags.map((tag) => ` - ${formatScalar(tag)}`));
|
|
825
|
+
}
|
|
574
826
|
return `---
|
|
575
|
-
${
|
|
827
|
+
${lines.join("\n")}
|
|
576
828
|
---
|
|
577
829
|
|
|
578
|
-
${renderBody(
|
|
579
|
-
}
|
|
580
|
-
|
|
581
|
-
// src/cli/commands/new.ts
|
|
582
|
-
async function createItem(vault, options) {
|
|
583
|
-
const title = options.title.trim();
|
|
584
|
-
if (title === "") throw new Error("a work item needs a title");
|
|
585
|
-
const parentRef = options.parent ?? vault.config.defaultRoot ?? "";
|
|
586
|
-
if (parentRef.trim() === "") {
|
|
587
|
-
throw new Error("a work item needs a --parent. Only the root has none.");
|
|
588
|
-
}
|
|
589
|
-
requireTemplate(options.template);
|
|
590
|
-
const status = options.status ?? "backlog";
|
|
591
|
-
if (!isStatus(status)) {
|
|
592
|
-
throw new Error(`"${status}" is not a status. Use backlog, options, doing or done.`);
|
|
593
|
-
}
|
|
594
|
-
const parent = vault.resolve(parentRef);
|
|
595
|
-
const id = newId(vault.takenIds);
|
|
596
|
-
const stem = fileNameFor(title, id, vault.takenStems);
|
|
597
|
-
const relPath = `${vault.config.workItemFolder}/${stem}.md`;
|
|
598
|
-
const path = join3(vault.root, ...vault.config.workItemFolder.split("/"), `${stem}.md`);
|
|
599
|
-
if (existsSync2(path)) {
|
|
600
|
-
throw new Error(`${relPath} already exists. Refusing to overwrite it.`);
|
|
601
|
-
}
|
|
602
|
-
const stamp = today();
|
|
603
|
-
const fields = {
|
|
604
|
-
id,
|
|
605
|
-
title,
|
|
606
|
-
status,
|
|
607
|
-
parentStem: parent.stem,
|
|
608
|
-
created: stamp,
|
|
609
|
-
updated: stamp,
|
|
610
|
-
template: options.template
|
|
611
|
-
};
|
|
612
|
-
for (const field of INHERITED_FIELDS) {
|
|
613
|
-
const value = options[field] ?? parent.frontmatter.get(field);
|
|
614
|
-
if (value !== void 0 && value !== "") fields[field] = String(value);
|
|
615
|
-
}
|
|
616
|
-
if (options.priority !== void 0) fields.priority = options.priority;
|
|
617
|
-
await writeAtomic(path, renderWorkItem(fields, vault.config.extraSections));
|
|
618
|
-
return { id, stem, relPath, path, parentStem: parent.stem };
|
|
830
|
+
${renderBody(template, extraSections, item.brief)}`;
|
|
619
831
|
}
|
|
620
832
|
|
|
621
833
|
// src/shared/transitions.ts
|
|
@@ -629,6 +841,29 @@ function statusEdits(from, to, hasPrevStatus) {
|
|
|
629
841
|
}
|
|
630
842
|
return edits;
|
|
631
843
|
}
|
|
844
|
+
function claimEdits(from, currentAgent, agent, hasPrevStatus, hasOtherDoingChild) {
|
|
845
|
+
if (from === "done") throw new Error("a done card cannot be claimed.");
|
|
846
|
+
if (currentAgent && currentAgent !== agent) {
|
|
847
|
+
throw new Error(`already claimed by ${currentAgent}. Release that claim first.`);
|
|
848
|
+
}
|
|
849
|
+
if (currentAgent === agent && from === "doing") return null;
|
|
850
|
+
if (hasOtherDoingChild) {
|
|
851
|
+
throw new Error("this board has a child in doing that another agent or a person works. Release or finish that child first.");
|
|
852
|
+
}
|
|
853
|
+
const status = statusEdits(from, "doing", hasPrevStatus);
|
|
854
|
+
if (currentAgent === agent) return status;
|
|
855
|
+
return [...status ?? [], { op: "set", key: "agent", value: agent }];
|
|
856
|
+
}
|
|
857
|
+
function releaseEdits(from, hasPrevStatus) {
|
|
858
|
+
return [...statusEdits(from, "options", hasPrevStatus) ?? [], { op: "remove", key: "agent" }];
|
|
859
|
+
}
|
|
860
|
+
function boardEdits(promoted) {
|
|
861
|
+
return promoted ? [{ op: "set", key: "board", value: true }] : [{ op: "remove", key: "board" }];
|
|
862
|
+
}
|
|
863
|
+
function firstChildPromotion(parent, autoPromote) {
|
|
864
|
+
if (!autoPromote || parent.isRoot || parent.area || parent.hasBoardKey || parent.childCount > 0) return null;
|
|
865
|
+
return boardEdits(true);
|
|
866
|
+
}
|
|
632
867
|
function moveEdits(currentParentStem, targetStem) {
|
|
633
868
|
if (currentParentStem !== null && currentParentStem.toLowerCase() === targetStem.toLowerCase()) {
|
|
634
869
|
return null;
|
|
@@ -650,6 +885,219 @@ function moveRefusal(check) {
|
|
|
650
885
|
return null;
|
|
651
886
|
}
|
|
652
887
|
|
|
888
|
+
// src/shared/labels.ts
|
|
889
|
+
var LABEL_COLOURS = [
|
|
890
|
+
"red",
|
|
891
|
+
"orange",
|
|
892
|
+
"yellow",
|
|
893
|
+
"green",
|
|
894
|
+
"cyan",
|
|
895
|
+
"blue",
|
|
896
|
+
"purple",
|
|
897
|
+
"pink"
|
|
898
|
+
];
|
|
899
|
+
function labelColour(label2) {
|
|
900
|
+
const key = label2.trim().replace(/^#/, "").toLowerCase();
|
|
901
|
+
let hash = 0;
|
|
902
|
+
for (let i = 0; i < key.length; i++) {
|
|
903
|
+
hash = hash * 31 + key.charCodeAt(i) >>> 0;
|
|
904
|
+
}
|
|
905
|
+
return LABEL_COLOURS[hash % LABEL_COLOURS.length];
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
// src/shared/area-tags.ts
|
|
909
|
+
var AREA_TAG_PREFIX = "area/";
|
|
910
|
+
function isAreaTag(tag) {
|
|
911
|
+
return tag.replace(/^#/, "").toLowerCase().startsWith(AREA_TAG_PREFIX);
|
|
912
|
+
}
|
|
913
|
+
function areaSlug(title) {
|
|
914
|
+
const slug = title.toLowerCase().normalize("NFKD").replace(/[̀-ͯ]/g, "").replace(/[^a-z0-9_]+/g, "-").replace(/^-+|-+$/g, "");
|
|
915
|
+
return slug === "" ? "untitled" : slug;
|
|
916
|
+
}
|
|
917
|
+
function areaTagFor(chain) {
|
|
918
|
+
const areas = chain.filter((node2) => node2.area).reverse();
|
|
919
|
+
if (areas.length === 0) return null;
|
|
920
|
+
return `${AREA_TAG_PREFIX}${areas.map((node2) => areaSlug(node2.title)).join("/")}`;
|
|
921
|
+
}
|
|
922
|
+
function withAreaTag(tags, tag) {
|
|
923
|
+
const kept = tags.filter((existing) => !isAreaTag(existing));
|
|
924
|
+
return tag === null ? kept : [...kept, tag];
|
|
925
|
+
}
|
|
926
|
+
function hasAreaTag(tags, tag) {
|
|
927
|
+
const current = tags.filter(isAreaTag).map((t) => t.replace(/^#/, ""));
|
|
928
|
+
return tag === null ? current.length === 0 : current.length === 1 && current[0] === tag;
|
|
929
|
+
}
|
|
930
|
+
var RGB = {
|
|
931
|
+
red: 15282503,
|
|
932
|
+
orange: 15496448,
|
|
933
|
+
yellow: 14724096,
|
|
934
|
+
green: 571726,
|
|
935
|
+
cyan: 49084,
|
|
936
|
+
blue: 552413,
|
|
937
|
+
purple: 7885550,
|
|
938
|
+
pink: 13973892
|
|
939
|
+
};
|
|
940
|
+
function shade(rgb, amount) {
|
|
941
|
+
const target = amount >= 0 ? 255 : 0;
|
|
942
|
+
const mix = (channel) => Math.round(channel + (target - channel) * Math.abs(amount));
|
|
943
|
+
return mix(rgb >> 16 & 255) << 16 | mix(rgb >> 8 & 255) << 8 | mix(rgb & 255);
|
|
944
|
+
}
|
|
945
|
+
function areaColourGroups(tags) {
|
|
946
|
+
const unique = [...new Set([...tags].map((t) => t.replace(/^#/, "")).filter(isAreaTag))];
|
|
947
|
+
const depth = (tag) => tag.split("/").length - 1;
|
|
948
|
+
unique.sort((a, b) => depth(b) - depth(a) || a.localeCompare(b));
|
|
949
|
+
return unique.flatMap((tag) => {
|
|
950
|
+
const top = tag.split("/").slice(0, 2).join("/");
|
|
951
|
+
const base = shade(RGB[labelColour(top)], Math.min(0.6, (depth(tag) - 1) * 0.3));
|
|
952
|
+
return [
|
|
953
|
+
{ query: `tag:#${tag} ([board:true] OR [area:true])`, color: { a: 1, rgb: shade(base, -0.3) } },
|
|
954
|
+
{ query: `tag:#${tag}`, color: { a: 1, rgb: base } }
|
|
955
|
+
];
|
|
956
|
+
});
|
|
957
|
+
}
|
|
958
|
+
function isAreaColourGroup(group) {
|
|
959
|
+
if (typeof group !== "object" || group === null || !("query" in group)) return false;
|
|
960
|
+
return typeof group.query === "string" && group.query.startsWith(`tag:#${AREA_TAG_PREFIX}`);
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
// src/cli/commands/retag.ts
|
|
964
|
+
import { readFile as readFile3 } from "node:fs/promises";
|
|
965
|
+
import { existsSync as existsSync2 } from "node:fs";
|
|
966
|
+
import { join as join3 } from "node:path";
|
|
967
|
+
function node(item) {
|
|
968
|
+
return { title: item.title ?? item.stem, area: item.area };
|
|
969
|
+
}
|
|
970
|
+
function chainOf(vault, item) {
|
|
971
|
+
const chain = [];
|
|
972
|
+
const seen = /* @__PURE__ */ new Set();
|
|
973
|
+
for (let current = item; current; current = vault.resolveLink(current.parent)) {
|
|
974
|
+
if (seen.has(current.relPath)) break;
|
|
975
|
+
seen.add(current.relPath);
|
|
976
|
+
chain.push(node(current));
|
|
977
|
+
}
|
|
978
|
+
return chain;
|
|
979
|
+
}
|
|
980
|
+
function expectedAreaTag(vault, item) {
|
|
981
|
+
return areaTagFor(chainOf(vault, item));
|
|
982
|
+
}
|
|
983
|
+
function tagsOf(item) {
|
|
984
|
+
return getList(item.text, "tags") ?? [];
|
|
985
|
+
}
|
|
986
|
+
function staleAreaTags(vault) {
|
|
987
|
+
return vault.items.flatMap((item) => {
|
|
988
|
+
if (!item.frontmatter.has("parent")) return [];
|
|
989
|
+
const tags = tagsOf(item);
|
|
990
|
+
const tag = expectedAreaTag(vault, item);
|
|
991
|
+
return hasAreaTag(tags, tag) ? [] : [{ item, from: tags, to: withAreaTag(tags, tag) }];
|
|
992
|
+
});
|
|
993
|
+
}
|
|
994
|
+
function requireAreaTags(vault) {
|
|
995
|
+
if (!vault.config.areaTags) {
|
|
996
|
+
throw new Error('area tags are off in this vault. Set "areaTags": true in .wi.json first.');
|
|
997
|
+
}
|
|
998
|
+
}
|
|
999
|
+
async function retag(vault, dryRun) {
|
|
1000
|
+
requireAreaTags(vault);
|
|
1001
|
+
const stale = staleAreaTags(vault);
|
|
1002
|
+
if (!dryRun) {
|
|
1003
|
+
for (const entry of stale) await editItem(entry.item, [{ op: "list", key: "tags", values: entry.to }]);
|
|
1004
|
+
}
|
|
1005
|
+
return stale;
|
|
1006
|
+
}
|
|
1007
|
+
async function writeGraphColours(vault, configDir = ".obsidian") {
|
|
1008
|
+
requireAreaTags(vault);
|
|
1009
|
+
const path = join3(vault.root, configDir, "graph.json");
|
|
1010
|
+
if (!existsSync2(join3(vault.root, configDir))) {
|
|
1011
|
+
throw new Error(`${configDir}/ is missing, so this vault has never been opened in Obsidian. Open it once, then retry.`);
|
|
1012
|
+
}
|
|
1013
|
+
let config = {};
|
|
1014
|
+
if (existsSync2(path)) {
|
|
1015
|
+
const parsed = JSON.parse(await readFile3(path, "utf8"));
|
|
1016
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
1017
|
+
throw new Error(`${configDir}/graph.json is not a JSON object. Nothing was written.`);
|
|
1018
|
+
}
|
|
1019
|
+
config = { ...parsed };
|
|
1020
|
+
}
|
|
1021
|
+
const tags = vault.items.flatMap((item) => {
|
|
1022
|
+
const tag = expectedAreaTag(vault, item);
|
|
1023
|
+
return tag === null ? [] : [tag];
|
|
1024
|
+
});
|
|
1025
|
+
const groups = areaColourGroups(tags);
|
|
1026
|
+
const existing = Array.isArray(config["colorGroups"]) ? config["colorGroups"] : [];
|
|
1027
|
+
const kept = existing.filter((group) => !isAreaColourGroup(group));
|
|
1028
|
+
config["colorGroups"] = [...groups, ...kept];
|
|
1029
|
+
await writeAtomic(path, `${JSON.stringify(config, null, 2)}
|
|
1030
|
+
`);
|
|
1031
|
+
return { path: `${configDir}/graph.json`, groups: groups.length, kept: kept.length };
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
// src/cli/commands/new.ts
|
|
1035
|
+
async function createItem(vault, options) {
|
|
1036
|
+
const title = options.title.trim();
|
|
1037
|
+
if (title === "") throw new Error("a work item needs a title");
|
|
1038
|
+
const parentRef = options.parent ?? vault.config.defaultRoot ?? "";
|
|
1039
|
+
if (parentRef.trim() === "") {
|
|
1040
|
+
throw new Error("a work item needs a --parent. Only the root has none.");
|
|
1041
|
+
}
|
|
1042
|
+
const template = requireTemplate(options.template);
|
|
1043
|
+
const status = options.status ?? "backlog";
|
|
1044
|
+
if (!isStatus(status)) {
|
|
1045
|
+
throw new Error(`"${status}" is not a status. Use backlog, options, doing or done.`);
|
|
1046
|
+
}
|
|
1047
|
+
const parent = vault.resolve(parentRef);
|
|
1048
|
+
const gaps = briefGaps(template, options.brief);
|
|
1049
|
+
if (options.strict && gaps.length > 0) {
|
|
1050
|
+
throw new Error(`the card has no ${gaps.join(" or ")}. Pass --objective and --criteria, or drop --strict.`);
|
|
1051
|
+
}
|
|
1052
|
+
renderBody(template, vault.config.extraSections, options.brief);
|
|
1053
|
+
const id = newId(vault.takenIds);
|
|
1054
|
+
const stem = fileNameFor(title, id, vault.takenStems);
|
|
1055
|
+
const relPath = `${vault.config.workItemFolder}/${stem}.md`;
|
|
1056
|
+
const path = join4(vault.root, ...vault.config.workItemFolder.split("/"), `${stem}.md`);
|
|
1057
|
+
if (existsSync3(path)) {
|
|
1058
|
+
throw new Error(`${relPath} already exists. Refusing to overwrite it.`);
|
|
1059
|
+
}
|
|
1060
|
+
const stamp = today();
|
|
1061
|
+
const common = {
|
|
1062
|
+
id,
|
|
1063
|
+
title,
|
|
1064
|
+
parentStem: parent.stem,
|
|
1065
|
+
created: stamp,
|
|
1066
|
+
updated: stamp,
|
|
1067
|
+
template: options.template,
|
|
1068
|
+
brief: options.brief
|
|
1069
|
+
};
|
|
1070
|
+
const inherited = inheritedChildFields({
|
|
1071
|
+
owner: textField(parent.frontmatter.get("owner")),
|
|
1072
|
+
agent: textField(parent.frontmatter.get("agent"))
|
|
1073
|
+
}, status, options);
|
|
1074
|
+
const fields = template.area ? { ...common, ...inherited, area: true, status } : { ...common, ...inherited, status };
|
|
1075
|
+
if (options.priority !== void 0) fields.priority = options.priority;
|
|
1076
|
+
const areaTag = vault.config.areaTags ? areaTagFor([{ title, area: template.area === true }, ...chainOf(vault, parent)]) : null;
|
|
1077
|
+
if (areaTag !== null) fields.tags = [areaTag];
|
|
1078
|
+
await writeAtomic(path, renderWorkItem(fields, vault.config.extraSections));
|
|
1079
|
+
const promotion = firstChildPromotion({
|
|
1080
|
+
isRoot: parent.parent === null && !parent.frontmatter.has("parent"),
|
|
1081
|
+
area: parent.area,
|
|
1082
|
+
hasBoardKey: parent.frontmatter.has("board"),
|
|
1083
|
+
childCount: vault.childrenOf(parent).length
|
|
1084
|
+
}, vault.config.autoPromote);
|
|
1085
|
+
if (promotion) await editItem(parent, promotion);
|
|
1086
|
+
return {
|
|
1087
|
+
id,
|
|
1088
|
+
stem,
|
|
1089
|
+
relPath,
|
|
1090
|
+
path,
|
|
1091
|
+
parentStem: parent.stem,
|
|
1092
|
+
promotedParent: promotion !== null,
|
|
1093
|
+
gaps,
|
|
1094
|
+
renamed: stem !== fileNameStem(title)
|
|
1095
|
+
};
|
|
1096
|
+
}
|
|
1097
|
+
function textField(value) {
|
|
1098
|
+
return value === void 0 || value === null ? void 0 : String(value);
|
|
1099
|
+
}
|
|
1100
|
+
|
|
653
1101
|
// src/cli/commands/status.ts
|
|
654
1102
|
async function setStatus(vault, ref, status) {
|
|
655
1103
|
if (!isStatus(status)) {
|
|
@@ -664,11 +1112,119 @@ async function setStatus(vault, ref, status) {
|
|
|
664
1112
|
const from = item.status;
|
|
665
1113
|
const edits = statusEdits(from, status, item.frontmatter.has("prev_status"));
|
|
666
1114
|
if (edits === null) {
|
|
667
|
-
return { item, from, to: status, recorded: void 0, changed: false };
|
|
1115
|
+
return { item, from, to: status, recorded: void 0, changed: false, parentReady: void 0 };
|
|
668
1116
|
}
|
|
669
1117
|
const recorded = status === "done" ? from : void 0;
|
|
670
1118
|
await editItem(item, edits);
|
|
671
|
-
return { item, from, to: status, recorded, changed: true };
|
|
1119
|
+
return { item, from, to: status, recorded, changed: true, parentReady: status === "done" ? readyParent(vault, item) : void 0 };
|
|
1120
|
+
}
|
|
1121
|
+
function readyParent(vault, item) {
|
|
1122
|
+
const parent = vault.resolveLink(item.parent);
|
|
1123
|
+
if (parent === void 0 || !parent.frontmatter.has("parent") || parent.area || parent.status === "done") return void 0;
|
|
1124
|
+
const open = vault.childrenOf(parent).filter((child) => !child.archived && child !== item && child.status !== "done");
|
|
1125
|
+
return open.length === 0 ? parent : void 0;
|
|
1126
|
+
}
|
|
1127
|
+
|
|
1128
|
+
// src/shared/notes.ts
|
|
1129
|
+
function headings(text) {
|
|
1130
|
+
const body = frontmatterBody(text);
|
|
1131
|
+
const offset = text.length - body.length;
|
|
1132
|
+
const found = [];
|
|
1133
|
+
let fence;
|
|
1134
|
+
for (const match of body.matchAll(/([^\r\n]*)(\r?\n|$)/g)) {
|
|
1135
|
+
if (match[0] === "") continue;
|
|
1136
|
+
const line2 = match[1];
|
|
1137
|
+
const marker = /^[ \t]*(`{3,}|~{3,})/.exec(line2)?.[1];
|
|
1138
|
+
if (marker) {
|
|
1139
|
+
if (fence === void 0) fence = { marker: marker[0], length: marker.length };
|
|
1140
|
+
else if (marker[0] === fence.marker && marker.length >= fence.length) fence = void 0;
|
|
1141
|
+
continue;
|
|
1142
|
+
}
|
|
1143
|
+
if (fence !== void 0) continue;
|
|
1144
|
+
const heading = /^(#{1,6})[ \t]+(.+?)[ \t]*$/.exec(line2);
|
|
1145
|
+
if (heading) found.push({
|
|
1146
|
+
level: heading[1].length,
|
|
1147
|
+
title: heading[2].trim().toLowerCase(),
|
|
1148
|
+
start: offset + match.index,
|
|
1149
|
+
end: offset + match.index + line2.length
|
|
1150
|
+
});
|
|
1151
|
+
}
|
|
1152
|
+
return found;
|
|
1153
|
+
}
|
|
1154
|
+
function appendNote(text, line2) {
|
|
1155
|
+
const eol = text.includes("\r\n") ? "\r\n" : "\n";
|
|
1156
|
+
const all = headings(text);
|
|
1157
|
+
const noteIndex = all.findIndex((heading2) => heading2.level === 2 && heading2.title === "notes");
|
|
1158
|
+
const heading = all[noteIndex];
|
|
1159
|
+
if (!heading) {
|
|
1160
|
+
const gap = text.endsWith(eol + eol) ? "" : text.endsWith(eol) ? eol : eol + eol;
|
|
1161
|
+
return `${text}${gap}## Notes${eol}${eol}${line2}${eol}`;
|
|
1162
|
+
}
|
|
1163
|
+
const start = heading.end;
|
|
1164
|
+
const next = all.slice(noteIndex + 1).find((entry) => entry.level <= 2);
|
|
1165
|
+
const end = next?.start ?? text.length;
|
|
1166
|
+
const content = text.slice(start, end);
|
|
1167
|
+
if (content === "") return `${text.slice(0, start)}${eol}${eol}${line2}${eol}${text.slice(end)}`;
|
|
1168
|
+
const trailing = /(?:\r?\n[ \t]*)*$/.exec(content)?.[0] ?? "";
|
|
1169
|
+
const prose = content.slice(0, content.length - trailing.length);
|
|
1170
|
+
const insertion = prose === "" ? `${eol}${eol}${line2}${eol}${next ? eol : ""}` : `${prose}${eol}${line2}${trailing || eol}`;
|
|
1171
|
+
return text.slice(0, start) + insertion + text.slice(end);
|
|
1172
|
+
}
|
|
1173
|
+
function noteLine(text, agent, now = /* @__PURE__ */ new Date()) {
|
|
1174
|
+
const body = text.trim();
|
|
1175
|
+
if (body === "") throw new Error("a note needs text.");
|
|
1176
|
+
if (/[\r\n]/.test(body)) throw new Error("a note must be one line.");
|
|
1177
|
+
const pad = (n) => String(n).padStart(2, "0");
|
|
1178
|
+
const who = agent?.trim() ? `, ${agent.trim()}` : "";
|
|
1179
|
+
return `- ${today(now)} ${pad(now.getHours())}:${pad(now.getMinutes())}${who}: ${body}`;
|
|
1180
|
+
}
|
|
1181
|
+
|
|
1182
|
+
// src/cli/commands/claim-release.ts
|
|
1183
|
+
async function claimItem(vault, ref, agent) {
|
|
1184
|
+
const item = vault.resolve(ref);
|
|
1185
|
+
if (item.area) throw new Error(`${item.relPath} is an area, and an area cannot be claimed.`);
|
|
1186
|
+
if (item.parent === null) throw new Error(`${item.relPath} is a root, and a root cannot be claimed.`);
|
|
1187
|
+
const current = item.frontmatter.get("agent");
|
|
1188
|
+
const currentAgent = typeof current === "string" && current.trim() !== "" ? current : void 0;
|
|
1189
|
+
const holding = currentAgent === agent && item.status === "doing";
|
|
1190
|
+
if (!holding && item.frontmatter.get("blocked") === true) {
|
|
1191
|
+
throw new Error(`${item.relPath} is blocked. Clear its blocked flag when the block is gone, or claim another card.`);
|
|
1192
|
+
}
|
|
1193
|
+
const edits = claimEdits(
|
|
1194
|
+
item.status,
|
|
1195
|
+
currentAgent,
|
|
1196
|
+
agent,
|
|
1197
|
+
item.frontmatter.has("prev_status"),
|
|
1198
|
+
item.board && vault.childrenOf(item).some((child) => child.status === "doing" && child.frontmatter.get("agent") !== agent)
|
|
1199
|
+
);
|
|
1200
|
+
if (edits === null) return { item, agent, from: item.status, to: "doing", changed: false };
|
|
1201
|
+
await editItem(item, edits);
|
|
1202
|
+
return { item, agent, from: item.status, to: "doing", changed: true };
|
|
1203
|
+
}
|
|
1204
|
+
async function releaseItem(vault, ref, reason, where) {
|
|
1205
|
+
const item = vault.resolve(ref);
|
|
1206
|
+
if (item.parent === null) throw new Error(`${item.relPath} is a root, and a root cannot be released.`);
|
|
1207
|
+
const current = item.frontmatter.get("agent");
|
|
1208
|
+
if (typeof current !== "string" || current.trim() === "") {
|
|
1209
|
+
throw new Error(`${item.relPath} has no agent to release.`);
|
|
1210
|
+
}
|
|
1211
|
+
const note = `- ${today()} Released from ${current}: ${reason.replace(/\.$/, "")}.` + (where === void 0 ? "" : ` Work: ${where.replace(/\.$/, "")}.`);
|
|
1212
|
+
await editItem(
|
|
1213
|
+
item,
|
|
1214
|
+
releaseEdits(item.status, item.frontmatter.has("prev_status")),
|
|
1215
|
+
(text) => appendNote(text, note)
|
|
1216
|
+
);
|
|
1217
|
+
return { item, agent: current, from: item.status, to: "options", reason, where, changed: true };
|
|
1218
|
+
}
|
|
1219
|
+
|
|
1220
|
+
// src/cli/commands/note.ts
|
|
1221
|
+
async function addNote(vault, ref, text, agent, now = /* @__PURE__ */ new Date()) {
|
|
1222
|
+
const item = vault.resolve(ref);
|
|
1223
|
+
const held = item.frontmatter.get("agent");
|
|
1224
|
+
const who = agent ?? (typeof held === "string" && held.trim() !== "" ? held : void 0);
|
|
1225
|
+
const line2 = noteLine(text, who, now);
|
|
1226
|
+
await editItem(item, [], (body) => appendNote(body, line2));
|
|
1227
|
+
return { item, line: line2 };
|
|
672
1228
|
}
|
|
673
1229
|
|
|
674
1230
|
// src/cli/commands/children.ts
|
|
@@ -679,6 +1235,7 @@ function listChildren(vault, ref, options = {}) {
|
|
|
679
1235
|
}
|
|
680
1236
|
const parent = vault.resolve(ref);
|
|
681
1237
|
const rows = [];
|
|
1238
|
+
const areas = [];
|
|
682
1239
|
const seen = /* @__PURE__ */ new Set([parent.relPath]);
|
|
683
1240
|
let cycle = false;
|
|
684
1241
|
const walk = (item, depth) => {
|
|
@@ -690,7 +1247,9 @@ function listChildren(vault, ref, options = {}) {
|
|
|
690
1247
|
seen.add(child.relPath);
|
|
691
1248
|
const effectiveArchived = vault.isArchived(child);
|
|
692
1249
|
if (!effectiveArchived || archived) {
|
|
693
|
-
|
|
1250
|
+
const row = { item: child, childCount: vault.childrenOf(child).filter((kid) => !vault.isArchived(kid) || archived).length, depth, archived: effectiveArchived };
|
|
1251
|
+
if (child.area) areas.push(row);
|
|
1252
|
+
else rows.push(row);
|
|
694
1253
|
}
|
|
695
1254
|
if (recursive) walk(child, depth + 1);
|
|
696
1255
|
}
|
|
@@ -701,7 +1260,7 @@ function listChildren(vault, ref, options = {}) {
|
|
|
701
1260
|
for (const value of STATUSES) {
|
|
702
1261
|
byStatus.set(value, filtered.filter((r) => r.item.status === value));
|
|
703
1262
|
}
|
|
704
|
-
return { parent, children: filtered, byStatus, cycle };
|
|
1263
|
+
return { parent, children: filtered, areas: status === void 0 ? areas : [], byStatus, cycle };
|
|
705
1264
|
}
|
|
706
1265
|
|
|
707
1266
|
// src/cli/commands/validate.ts
|
|
@@ -720,6 +1279,12 @@ async function validate(vault) {
|
|
|
720
1279
|
checkDefaultRoot(vault, report);
|
|
721
1280
|
checkRoots(vault, report);
|
|
722
1281
|
checkCycles(vault, report);
|
|
1282
|
+
if (vault.config.areaTags) {
|
|
1283
|
+
for (const { item, to } of staleAreaTags(vault)) {
|
|
1284
|
+
const tag = to.find(isAreaTag);
|
|
1285
|
+
report("area-tag-stale", "warning", item.relPath, item.id, tag ? `should carry the area tag ${tag}. Run wi retag.` : "carries an area tag but sits in no area. Run wi retag.");
|
|
1286
|
+
}
|
|
1287
|
+
}
|
|
723
1288
|
problems.sort((a, b) => a.relPath.localeCompare(b.relPath) || a.rule.localeCompare(b.rule));
|
|
724
1289
|
const errorCount = problems.filter((p) => p.severity === "error").length;
|
|
725
1290
|
return {
|
|
@@ -802,6 +1367,10 @@ function checkItem(item, vault, report) {
|
|
|
802
1367
|
if (item.title === void 0) say("title-missing", "error", "has no title.");
|
|
803
1368
|
const rawStatus = item.frontmatter.get("status");
|
|
804
1369
|
const isRoot = !item.frontmatter.has("parent");
|
|
1370
|
+
const rawArea = item.frontmatter.get("area");
|
|
1371
|
+
if (rawArea !== void 0 && rawArea !== true) {
|
|
1372
|
+
say("area-invalid", "error", `carries area: ${rawArea}. The only valid marker is area: true.`);
|
|
1373
|
+
}
|
|
805
1374
|
if (isRoot) {
|
|
806
1375
|
if (rawStatus !== void 0) {
|
|
807
1376
|
say(
|
|
@@ -950,28 +1519,28 @@ function checkCycles(vault, report) {
|
|
|
950
1519
|
}
|
|
951
1520
|
|
|
952
1521
|
// src/cli/commands/template.ts
|
|
953
|
-
import { readFile as
|
|
954
|
-
import { existsSync as
|
|
955
|
-
import { mkdir } from "node:fs/promises";
|
|
956
|
-
import { join as
|
|
1522
|
+
import { readFile as readFile4 } from "node:fs/promises";
|
|
1523
|
+
import { existsSync as existsSync4 } from "node:fs";
|
|
1524
|
+
import { mkdir as mkdir3 } from "node:fs/promises";
|
|
1525
|
+
import { join as join5 } from "node:path";
|
|
957
1526
|
var TEMPLATES_FOLDER = "Templates";
|
|
958
1527
|
function listTemplates() {
|
|
959
1528
|
return TEMPLATES;
|
|
960
1529
|
}
|
|
961
1530
|
async function writeTemplates(vault) {
|
|
962
|
-
const folder =
|
|
963
|
-
await
|
|
1531
|
+
const folder = join5(vault.root, TEMPLATES_FOLDER);
|
|
1532
|
+
await mkdir3(folder, { recursive: true });
|
|
964
1533
|
const written = [];
|
|
965
1534
|
for (const template of TEMPLATES) {
|
|
966
|
-
const path =
|
|
1535
|
+
const path = join5(folder, `${template.name}.md`);
|
|
967
1536
|
const relPath = `${TEMPLATES_FOLDER}/${template.name}.md`;
|
|
968
1537
|
const next = renderVaultTemplate(template, vault.config.defaultRoot, vault.config.extraSections);
|
|
969
|
-
if (!
|
|
1538
|
+
if (!existsSync4(path)) {
|
|
970
1539
|
await writeAtomic(path, next);
|
|
971
1540
|
written.push({ name: template.name, relPath, outcome: "created" });
|
|
972
1541
|
continue;
|
|
973
1542
|
}
|
|
974
|
-
const current = await
|
|
1543
|
+
const current = await readFile4(path, "utf8");
|
|
975
1544
|
if (current === next) {
|
|
976
1545
|
written.push({ name: template.name, relPath, outcome: "unchanged" });
|
|
977
1546
|
continue;
|
|
@@ -983,9 +1552,9 @@ async function writeTemplates(vault) {
|
|
|
983
1552
|
}
|
|
984
1553
|
|
|
985
1554
|
// src/cli/commands/remove.ts
|
|
986
|
-
import { mkdir as
|
|
987
|
-
import { existsSync as
|
|
988
|
-
import { join as
|
|
1555
|
+
import { mkdir as mkdir4, rename as rename3 } from "node:fs/promises";
|
|
1556
|
+
import { existsSync as existsSync5 } from "node:fs";
|
|
1557
|
+
import { join as join6 } from "node:path";
|
|
989
1558
|
var TRASH = ".trash";
|
|
990
1559
|
function subtree(vault, root) {
|
|
991
1560
|
const ordered = [];
|
|
@@ -1005,7 +1574,7 @@ function trashPath(vault, item) {
|
|
|
1005
1574
|
const stem = item.stem;
|
|
1006
1575
|
for (let n = 0; n < 1e3; n++) {
|
|
1007
1576
|
const name = n === 0 ? `${stem}.md` : `${stem} ${n}.md`;
|
|
1008
|
-
if (!
|
|
1577
|
+
if (!existsSync5(join6(vault.root, TRASH, name))) return `${TRASH}/${name}`;
|
|
1009
1578
|
}
|
|
1010
1579
|
return `${TRASH}/${stem} ${Date.now()}.md`;
|
|
1011
1580
|
}
|
|
@@ -1030,8 +1599,8 @@ async function removeItem(vault, ref, options = {}) {
|
|
|
1030
1599
|
for (const target of doomed) {
|
|
1031
1600
|
const trashedTo = trashPath(vault, target);
|
|
1032
1601
|
if (!dryRun) {
|
|
1033
|
-
await
|
|
1034
|
-
await
|
|
1602
|
+
await mkdir4(join6(vault.root, TRASH), { recursive: true });
|
|
1603
|
+
await rename3(target.path, join6(vault.root, ...trashedTo.split("/")));
|
|
1035
1604
|
}
|
|
1036
1605
|
removed.push({ item: target, trashedTo });
|
|
1037
1606
|
}
|
|
@@ -1080,10 +1649,78 @@ async function archiveItem(vault, ref, undo) {
|
|
|
1080
1649
|
return { item, archived, changed: true };
|
|
1081
1650
|
}
|
|
1082
1651
|
|
|
1652
|
+
// src/cli/commands/promote.ts
|
|
1653
|
+
async function setPromoted(vault, ref, promoted) {
|
|
1654
|
+
const item = vault.resolve(ref);
|
|
1655
|
+
if (isArea(item.area)) {
|
|
1656
|
+
throw new Error(`${item.relPath} is an area and cannot be ${promoted ? "promoted" : "demoted"}.`);
|
|
1657
|
+
}
|
|
1658
|
+
if (item.board === promoted) return { item, promoted, changed: false };
|
|
1659
|
+
await editItem(item, boardEdits(promoted));
|
|
1660
|
+
return { item, promoted, changed: true };
|
|
1661
|
+
}
|
|
1662
|
+
|
|
1663
|
+
// src/shared/area.ts
|
|
1664
|
+
function areaRefusal(item, direction) {
|
|
1665
|
+
if (item.isRoot) return `${item.label} is a root, and a root is not a card or area child.`;
|
|
1666
|
+
if (direction === "card") {
|
|
1667
|
+
if (!item.isArea) return `${item.label} is not an area. Nothing to convert back to a card.`;
|
|
1668
|
+
return null;
|
|
1669
|
+
}
|
|
1670
|
+
if (item.isArea) return `${item.label} is already an area.`;
|
|
1671
|
+
if (item.agent !== void 0 && item.agent.trim() !== "") {
|
|
1672
|
+
return `${item.label} has agent "${item.agent}". Release it before converting it to an area.`;
|
|
1673
|
+
}
|
|
1674
|
+
return null;
|
|
1675
|
+
}
|
|
1676
|
+
function areaEdits(item, target) {
|
|
1677
|
+
const refusal = areaRefusal(item, target.kind);
|
|
1678
|
+
if (refusal !== null) throw new Error(refusal);
|
|
1679
|
+
if (target.kind === "area") {
|
|
1680
|
+
return [
|
|
1681
|
+
{ op: "set", key: "area", value: true },
|
|
1682
|
+
{ op: "remove", key: "prev_status" }
|
|
1683
|
+
];
|
|
1684
|
+
}
|
|
1685
|
+
return [
|
|
1686
|
+
{ op: "remove", key: "area" },
|
|
1687
|
+
{ op: "remove", key: "prev_status" }
|
|
1688
|
+
];
|
|
1689
|
+
}
|
|
1690
|
+
|
|
1691
|
+
// src/cli/commands/area.ts
|
|
1692
|
+
async function setArea(vault, ref, options) {
|
|
1693
|
+
const item = vault.resolve(ref);
|
|
1694
|
+
const agent = item.frontmatter.get("agent");
|
|
1695
|
+
const state = {
|
|
1696
|
+
label: item.relPath,
|
|
1697
|
+
isRoot: item.parent === null,
|
|
1698
|
+
isArea: item.area,
|
|
1699
|
+
status: item.status,
|
|
1700
|
+
agent: typeof agent === "string" ? agent : void 0
|
|
1701
|
+
};
|
|
1702
|
+
const direction = options.off ? "card" : "area";
|
|
1703
|
+
const refusal = areaRefusal(state, direction);
|
|
1704
|
+
if (refusal !== null) throw new Error(refusal);
|
|
1705
|
+
if (options.off) {
|
|
1706
|
+
const status = item.status;
|
|
1707
|
+
if (!isStatus(status)) throw new Error(`${item.relPath} has no valid status to preserve.`);
|
|
1708
|
+
const target = { kind: "card" };
|
|
1709
|
+
const edits2 = areaEdits(state, target);
|
|
1710
|
+
const before2 = item.text;
|
|
1711
|
+
const after2 = await editItem(item, edits2);
|
|
1712
|
+
return { item, from: "area", to: "card", status, changed: before2 !== after2 };
|
|
1713
|
+
}
|
|
1714
|
+
const edits = areaEdits(state, { kind: "area" });
|
|
1715
|
+
const before = item.text;
|
|
1716
|
+
const after = await editItem(item, edits);
|
|
1717
|
+
return { item, from: "card", to: "area", status: item.status, changed: before !== after };
|
|
1718
|
+
}
|
|
1719
|
+
|
|
1083
1720
|
// src/cli/commands/hook.ts
|
|
1084
1721
|
import { execFile } from "node:child_process";
|
|
1085
|
-
import { existsSync as
|
|
1086
|
-
import { chmod, mkdir as
|
|
1722
|
+
import { existsSync as existsSync6 } from "node:fs";
|
|
1723
|
+
import { chmod, mkdir as mkdir5, readFile as readFile5, rm as rm2, writeFile as writeFile3 } from "node:fs/promises";
|
|
1087
1724
|
import { dirname as dirname3, resolve } from "node:path";
|
|
1088
1725
|
import { promisify } from "node:util";
|
|
1089
1726
|
var run = promisify(execFile);
|
|
@@ -1115,8 +1752,8 @@ exec ${shellQuote(process.execPath)} ${shellQuote(entry)} validate --vault ${she
|
|
|
1115
1752
|
async function hookStatus(vault) {
|
|
1116
1753
|
const git = await gitHook(vault);
|
|
1117
1754
|
if (!git) return { vault, gitRepo: null, preCommit: "not installed" };
|
|
1118
|
-
const present =
|
|
1119
|
-
const ours = present && isOurHook(await
|
|
1755
|
+
const present = existsSync6(git.path);
|
|
1756
|
+
const ours = present && isOurHook(await readFile5(git.path, "utf8"));
|
|
1120
1757
|
return {
|
|
1121
1758
|
vault,
|
|
1122
1759
|
gitRepo: git.top,
|
|
@@ -1126,51 +1763,269 @@ async function hookStatus(vault) {
|
|
|
1126
1763
|
async function installHook(vault, entry, force) {
|
|
1127
1764
|
const git = await gitHook(vault);
|
|
1128
1765
|
if (!git) throw new Error(`${vault} is not inside a Git repository. Run: git -C ${shellQuote(vault)} init`);
|
|
1129
|
-
if (
|
|
1130
|
-
const current = await
|
|
1766
|
+
if (existsSync6(git.path)) {
|
|
1767
|
+
const current = await readFile5(git.path, "utf8");
|
|
1131
1768
|
if (!isOurHook(current) && !force) {
|
|
1132
1769
|
throw new Error(`${git.path} already exists and is not ours. Read it, then pass --force to replace it.`);
|
|
1133
1770
|
}
|
|
1134
1771
|
}
|
|
1135
|
-
await
|
|
1136
|
-
await
|
|
1772
|
+
await mkdir5(dirname3(git.path), { recursive: true });
|
|
1773
|
+
await writeFile3(git.path, hookBody(vault, entry), "utf8");
|
|
1137
1774
|
await chmod(git.path, 493);
|
|
1138
1775
|
return git.path;
|
|
1139
1776
|
}
|
|
1140
1777
|
async function uninstallHook(vault) {
|
|
1141
1778
|
const git = await gitHook(vault);
|
|
1142
|
-
if (!git || !
|
|
1143
|
-
await
|
|
1779
|
+
if (!git || !existsSync6(git.path) || !isOurHook(await readFile5(git.path, "utf8"))) return null;
|
|
1780
|
+
await rm2(git.path);
|
|
1144
1781
|
return git.path;
|
|
1145
1782
|
}
|
|
1146
1783
|
|
|
1784
|
+
// src/cli/commands/setup.ts
|
|
1785
|
+
import { cp, lstat, mkdir as mkdir6, readFile as readFile6, rename as rename4, rm as rm3, writeFile as writeFile4 } from "node:fs/promises";
|
|
1786
|
+
import { homedir as homedir2, platform } from "node:os";
|
|
1787
|
+
import { dirname as dirname4, join as join7, resolve as resolve2 } from "node:path";
|
|
1788
|
+
import { fileURLToPath } from "node:url";
|
|
1789
|
+
import { createInterface } from "node:readline/promises";
|
|
1790
|
+
import { stdin, stdout } from "node:process";
|
|
1791
|
+
function detectObsidianRegistryPath(location) {
|
|
1792
|
+
if (location.registryPath) return location.registryPath;
|
|
1793
|
+
switch (location.platform) {
|
|
1794
|
+
case "darwin":
|
|
1795
|
+
return join7(location.home, "Library", "Application Support", "obsidian", "obsidian.json");
|
|
1796
|
+
case "linux":
|
|
1797
|
+
return join7(location.home, ".config", "obsidian", "obsidian.json");
|
|
1798
|
+
case "win32":
|
|
1799
|
+
return join7(location.appData ?? join7(location.home, "AppData", "Roaming"), "obsidian", "obsidian.json");
|
|
1800
|
+
default:
|
|
1801
|
+
throw new Error(`wi setup does not know the Obsidian registry location for ${location.platform}.`);
|
|
1802
|
+
}
|
|
1803
|
+
}
|
|
1804
|
+
function isRecord(value) {
|
|
1805
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
1806
|
+
}
|
|
1807
|
+
async function readObsidianVaults(location) {
|
|
1808
|
+
const path = detectObsidianRegistryPath(location);
|
|
1809
|
+
let text;
|
|
1810
|
+
try {
|
|
1811
|
+
text = await readFile6(path, "utf8");
|
|
1812
|
+
} catch (error) {
|
|
1813
|
+
if (isMissingFile2(error)) return [];
|
|
1814
|
+
throw error;
|
|
1815
|
+
}
|
|
1816
|
+
let parsed;
|
|
1817
|
+
try {
|
|
1818
|
+
parsed = JSON.parse(text);
|
|
1819
|
+
} catch {
|
|
1820
|
+
throw new Error(`cannot read Obsidian vault registry ${path}: invalid JSON.`);
|
|
1821
|
+
}
|
|
1822
|
+
if (!isRecord(parsed) || !isRecord(parsed["vaults"])) {
|
|
1823
|
+
throw new Error(`cannot read Obsidian vault registry ${path}: expected a vaults object.`);
|
|
1824
|
+
}
|
|
1825
|
+
const paths = [];
|
|
1826
|
+
for (const entry of Object.values(parsed["vaults"])) {
|
|
1827
|
+
if (!isRecord(entry) || typeof entry["path"] !== "string" || entry["path"].trim() === "") continue;
|
|
1828
|
+
const vaultPath = resolve2(entry["path"]);
|
|
1829
|
+
if (!paths.includes(vaultPath)) paths.push(vaultPath);
|
|
1830
|
+
}
|
|
1831
|
+
return paths;
|
|
1832
|
+
}
|
|
1833
|
+
function isMissingFile2(error) {
|
|
1834
|
+
return isRecord(error) && error["code"] === "ENOENT";
|
|
1835
|
+
}
|
|
1836
|
+
var MANAGED_MARKER = ".recursive-board-managed";
|
|
1837
|
+
var MANAGED_TEXT = "Installed by wi setup.\n";
|
|
1838
|
+
async function installSkillCopy(source, destination, force) {
|
|
1839
|
+
let existing;
|
|
1840
|
+
try {
|
|
1841
|
+
existing = await lstat(destination);
|
|
1842
|
+
} catch (error) {
|
|
1843
|
+
if (!isMissingFile2(error)) throw error;
|
|
1844
|
+
}
|
|
1845
|
+
if (existing?.isSymbolicLink()) return "dev-link";
|
|
1846
|
+
if (existing) {
|
|
1847
|
+
const managed = existing.isDirectory() && await readFile6(join7(destination, MANAGED_MARKER), "utf8").then((value) => value === MANAGED_TEXT).catch(() => false);
|
|
1848
|
+
if (!managed && !force) {
|
|
1849
|
+
throw new Error(`${destination} already exists and was not installed by wi setup. Read it, then pass --force to replace it.`);
|
|
1850
|
+
}
|
|
1851
|
+
if (managed) {
|
|
1852
|
+
const same = await readFile6(join7(destination, "SKILL.md"), "utf8").catch(() => null);
|
|
1853
|
+
const expected = await readFile6(join7(source, "SKILL.md"), "utf8");
|
|
1854
|
+
if (same === expected) return "already-installed";
|
|
1855
|
+
}
|
|
1856
|
+
await rm3(destination, { recursive: true, force: true });
|
|
1857
|
+
}
|
|
1858
|
+
await mkdir6(dirname4(destination), { recursive: true });
|
|
1859
|
+
await cp(source, destination, { recursive: true, errorOnExist: true, force: false });
|
|
1860
|
+
await writeFile4(join7(destination, MANAGED_MARKER), MANAGED_TEXT, "utf8");
|
|
1861
|
+
return "installed";
|
|
1862
|
+
}
|
|
1863
|
+
async function writeDefaultVault(vault) {
|
|
1864
|
+
const root = wiConfigDir();
|
|
1865
|
+
const path = join7(root, "config.json");
|
|
1866
|
+
let current = {};
|
|
1867
|
+
try {
|
|
1868
|
+
const parsed = JSON.parse(await readFile6(path, "utf8"));
|
|
1869
|
+
if (!isRecord(parsed)) throw new Error(`cannot update ${path}: expected a JSON object.`);
|
|
1870
|
+
current = parsed;
|
|
1871
|
+
} catch (error) {
|
|
1872
|
+
if (isMissingFile2(error)) {
|
|
1873
|
+
current = {};
|
|
1874
|
+
} else if (error instanceof SyntaxError) {
|
|
1875
|
+
throw new Error(`cannot update ${path}: invalid JSON.`);
|
|
1876
|
+
} else {
|
|
1877
|
+
throw error;
|
|
1878
|
+
}
|
|
1879
|
+
}
|
|
1880
|
+
await mkdir6(root, { recursive: true });
|
|
1881
|
+
const temporary = `${path}.tmp-${process.pid}`;
|
|
1882
|
+
await writeFile4(temporary, `${JSON.stringify({ ...current, defaultVault: resolve2(vault) }, null, 2)}
|
|
1883
|
+
`, "utf8");
|
|
1884
|
+
await rename4(temporary, path);
|
|
1885
|
+
return path;
|
|
1886
|
+
}
|
|
1887
|
+
async function hasWorkItems(path) {
|
|
1888
|
+
try {
|
|
1889
|
+
return (await loadVault(path)).items.length > 0;
|
|
1890
|
+
} catch {
|
|
1891
|
+
return false;
|
|
1892
|
+
}
|
|
1893
|
+
}
|
|
1894
|
+
async function rankVaults(paths) {
|
|
1895
|
+
const ranked = await Promise.all(paths.map(async (path) => ({ path, hasItems: await hasWorkItems(path) })));
|
|
1896
|
+
ranked.sort((a, b) => Number(b.hasItems) - Number(a.hasItems) || a.path.localeCompare(b.path));
|
|
1897
|
+
return ranked;
|
|
1898
|
+
}
|
|
1899
|
+
async function selectVault(vaults) {
|
|
1900
|
+
const ranked = await rankVaults(vaults);
|
|
1901
|
+
if (ranked.length === 0) {
|
|
1902
|
+
throw new Error("no Obsidian vaults found. Open a vault in Obsidian, or run wi setup --vault <path>.");
|
|
1903
|
+
}
|
|
1904
|
+
stdout.write("Choose your default Obsidian vault:\n");
|
|
1905
|
+
ranked.forEach((entry, index) => stdout.write(` ${index + 1}. ${entry.path}${entry.hasItems ? " (work items found)" : ""}
|
|
1906
|
+
`));
|
|
1907
|
+
const io = createInterface({ input: stdin, output: stdout });
|
|
1908
|
+
try {
|
|
1909
|
+
for (; ; ) {
|
|
1910
|
+
const answer = (await io.question(`Vault [1-${ranked.length}]: `)).trim();
|
|
1911
|
+
const choice = Number(answer);
|
|
1912
|
+
if (Number.isInteger(choice) && choice >= 1 && choice <= ranked.length) return ranked[choice - 1].path;
|
|
1913
|
+
stdout.write(`Enter a number from 1 to ${ranked.length}.
|
|
1914
|
+
`);
|
|
1915
|
+
}
|
|
1916
|
+
} finally {
|
|
1917
|
+
io.close();
|
|
1918
|
+
}
|
|
1919
|
+
}
|
|
1920
|
+
async function confirmHook() {
|
|
1921
|
+
const io = createInterface({ input: stdin, output: stdout });
|
|
1922
|
+
try {
|
|
1923
|
+
return /^(y|yes)$/i.test((await io.question("This vault is a Git repository. Install the wi validation hook? [y/N] ")).trim());
|
|
1924
|
+
} finally {
|
|
1925
|
+
io.close();
|
|
1926
|
+
}
|
|
1927
|
+
}
|
|
1928
|
+
async function runSetup(options) {
|
|
1929
|
+
if (options.yes && !options.vault) throw new Error("wi setup --yes needs --vault <path> so it can run without questions.");
|
|
1930
|
+
const home = homedir2();
|
|
1931
|
+
const vault = resolve2(options.vault ?? await selectVault(await readObsidianVaults({
|
|
1932
|
+
platform: platform(),
|
|
1933
|
+
home,
|
|
1934
|
+
...process.env["APPDATA"] ? { appData: process.env["APPDATA"] } : {}
|
|
1935
|
+
})));
|
|
1936
|
+
const moduleDir = dirname4(fileURLToPath(import.meta.url));
|
|
1937
|
+
const skillCandidates = [
|
|
1938
|
+
resolve2(moduleDir, "..", "..", "..", "skills", "recursive-board"),
|
|
1939
|
+
resolve2(moduleDir, "..", "..", "skills", "recursive-board")
|
|
1940
|
+
];
|
|
1941
|
+
let packagedSkill;
|
|
1942
|
+
for (const candidate of skillCandidates) {
|
|
1943
|
+
try {
|
|
1944
|
+
await readFile6(join7(candidate, "SKILL.md"), "utf8");
|
|
1945
|
+
packagedSkill = candidate;
|
|
1946
|
+
break;
|
|
1947
|
+
} catch (error) {
|
|
1948
|
+
if (!isMissingFile2(error)) throw error;
|
|
1949
|
+
}
|
|
1950
|
+
}
|
|
1951
|
+
if (!packagedSkill) throw new Error("the recursive-board skill is missing from this installation.");
|
|
1952
|
+
const destinations = [
|
|
1953
|
+
join7(home, ".claude", "skills", "recursive-board"),
|
|
1954
|
+
join7(home, ".agents", "skills", "recursive-board")
|
|
1955
|
+
];
|
|
1956
|
+
const outcomes = await Promise.all(destinations.map((destination) => installSkillCopy(packagedSkill, destination, options.force)));
|
|
1957
|
+
const configPath = await writeDefaultVault(vault);
|
|
1958
|
+
stdout.write(`wi setup: default vault ${vault}
|
|
1959
|
+
`);
|
|
1960
|
+
stdout.write(`wi setup: config ${configPath}
|
|
1961
|
+
`);
|
|
1962
|
+
destinations.forEach((destination, index) => stdout.write(`wi setup: ${outcomes[index]} ${destination}
|
|
1963
|
+
`));
|
|
1964
|
+
if (!options.yes) {
|
|
1965
|
+
const status = await hookStatus(vault);
|
|
1966
|
+
if (status.gitRepo && await confirmHook()) {
|
|
1967
|
+
const entry = fileURLToPath(import.meta.url);
|
|
1968
|
+
const hook = await installHook(vault, entry, false);
|
|
1969
|
+
stdout.write(`wi setup: installed validation hook ${hook}
|
|
1970
|
+
`);
|
|
1971
|
+
}
|
|
1972
|
+
}
|
|
1973
|
+
}
|
|
1974
|
+
|
|
1147
1975
|
// src/cli/wi.ts
|
|
1148
1976
|
var HELP = `wi \u2014 the Recursive Board CLI
|
|
1149
1977
|
|
|
1150
1978
|
Usage
|
|
1979
|
+
wi setup [--yes] [--vault <path>] [--force]
|
|
1151
1980
|
wi new <title> [--parent <ref>] [--status <s>] [--template <t>] [--owner <o>] [--agent <a>]
|
|
1152
|
-
|
|
1981
|
+
[--priority <n>] [--objective <text>] [--context <text>]... [--criteria <text>]...
|
|
1982
|
+
[--strict]
|
|
1153
1983
|
wi status <ref> <status>
|
|
1984
|
+
wi note <ref> <text> [--agent <name>]
|
|
1985
|
+
wi area <ref> [--off]
|
|
1986
|
+
wi claim <ref> --agent <name>
|
|
1987
|
+
wi agents
|
|
1988
|
+
wi release <ref> --reason <text> [--where <branch-or-path>]
|
|
1154
1989
|
wi move <ref> --to <ref>
|
|
1155
1990
|
wi archive <ref> [--undo]
|
|
1991
|
+
wi promote <ref>
|
|
1992
|
+
wi demote <ref>
|
|
1156
1993
|
wi rm <ref> [--recursive] [--dry-run]
|
|
1157
|
-
wi children <ref> [--status <s>] [--tree] [--archived]
|
|
1994
|
+
wi children [<ref>] [--status <s>] [--tree] [--archived]
|
|
1158
1995
|
wi validate
|
|
1996
|
+
wi retag [--dry-run]
|
|
1997
|
+
wi graph
|
|
1159
1998
|
wi template [list|write]
|
|
1160
1999
|
wi hook <install|uninstall|status> [--force]
|
|
2000
|
+
wi here [--board <ref>] [--vault <path>]
|
|
1161
2001
|
|
|
1162
2002
|
A <ref> is a work item id, a filename or a title. An id always wins.
|
|
1163
2003
|
A <status> is one of: ${STATUSES.join(", ")}.
|
|
1164
2004
|
A <template> is one of: ${templateNames().join(", ")}.
|
|
1165
2005
|
|
|
1166
2006
|
Options
|
|
1167
|
-
--vault <path> The vault root. Defaults to $WI_VAULT,
|
|
2007
|
+
--vault <path> The vault root. Defaults to $WI_VAULT, the nearest vault, this repo's pointer, then defaultVault.
|
|
2008
|
+
--board <ref> Board work item used by wi here.
|
|
1168
2009
|
--json Machine-readable output.
|
|
1169
|
-
--force Replace
|
|
2010
|
+
--force Replace an unrelated hook, or an unmanaged skill during setup.
|
|
2011
|
+
--yes Run setup without prompts; requires --vault <path>.
|
|
1170
2012
|
-h, --help This text.
|
|
1171
2013
|
-V, --version Print the version.
|
|
1172
2014
|
|
|
1173
2015
|
Notes
|
|
2016
|
+
\`wi new\` writes the brief: --objective once, --context and --criteria once per paragraph or
|
|
2017
|
+
criterion. It warns when the card has no Objective or Acceptance Criteria; --strict refuses it.
|
|
2018
|
+
A title's unsafe filename characters become hyphens. When another item has the same filename,
|
|
2019
|
+
the new file gets the id's suffix; wi never writes over a file.
|
|
2020
|
+
\`wi new\` makes a parent a board when it gives the parent its first child. Set
|
|
2021
|
+
"autoPromote": false in .wi.json to turn this off. A root or an area is never changed.
|
|
2022
|
+
With "areaTags": true in .wi.json, each work item carries one tag naming its areas, such as
|
|
2023
|
+
area/work/website. \`wi new\` writes it. After \`wi move\` or \`wi area\`, run \`wi retag\` to fix
|
|
2024
|
+
the tags below. \`wi graph\` writes a colour group per area to .obsidian/graph.json and keeps
|
|
2025
|
+
your own groups. Close the graph view first: Obsidian may write over the file.
|
|
2026
|
+
\`wi note\` appends "- <date> <time>, <agent>: <text>" under Notes. The agent defaults to the
|
|
2027
|
+
card's agent. The write re-reads the card under a lock, so two notes at once both survive.
|
|
2028
|
+
\`wi status <ref> done\` says when that was the parent's last open child. It does not close the parent.
|
|
1174
2029
|
Unticking a done item is \`wi status <ref> <its prev_status>\`, which also clears the record.
|
|
1175
2030
|
\`wi validate\` exits 1 when the vault has errors, so it works as a pre-commit hook.
|
|
1176
2031
|
\`wi template write\` regenerates Templates/ from the code, which is authoritative.
|
|
@@ -1181,9 +2036,18 @@ Notes
|
|
|
1181
2036
|
folder, because the index cannot read it and may be missing a work item. Let the sync
|
|
1182
2037
|
client download the file, or delete the stray file, then retry. There is no --force.
|
|
1183
2038
|
\`wi archive\` changes one flag. Descendants disappear with their parent at read time.
|
|
2039
|
+
\`wi area <ref>\` marks a card as an area and keeps its status. It refuses a card with an agent.
|
|
2040
|
+
Use \`wi area <ref> --off\` to convert back without changing its status.
|
|
2041
|
+
\`wi claim\` refuses a card with blocked: true, and \`wi children\` marks one [blocked].
|
|
2042
|
+
\`wi claim\` lets an agent hold a card and its subtasks at once. It refuses a board with a child in
|
|
2043
|
+
doing that a different agent or a person works.
|
|
2044
|
+
\`wi agents\` reports the advisory limit, the number of distinct agents with a doing card, and each
|
|
2045
|
+
claimed doing card. WI_MAX_AGENTS overrides
|
|
2046
|
+
maxAgents from .wi.json for one run. Dispatchers decide whether to wait; wi claim does not enforce it.
|
|
1184
2047
|
\`wi new\` warns when such a file exists because a new id or filename may clash with it.
|
|
2048
|
+
\`wi here\` reads or sets this repository's vault and board pointer in your user config.
|
|
1185
2049
|
`;
|
|
1186
|
-
var VERSION = "0.
|
|
2050
|
+
var VERSION = "0.3.0";
|
|
1187
2051
|
var UsageError = class extends Error {
|
|
1188
2052
|
};
|
|
1189
2053
|
async function main(argv) {
|
|
@@ -1197,16 +2061,25 @@ async function main(argv) {
|
|
|
1197
2061
|
status: { type: "string" },
|
|
1198
2062
|
owner: { type: "string" },
|
|
1199
2063
|
agent: { type: "string" },
|
|
2064
|
+
reason: { type: "string" },
|
|
2065
|
+
where: { type: "string" },
|
|
1200
2066
|
priority: { type: "string" },
|
|
1201
2067
|
template: { type: "string" },
|
|
2068
|
+
objective: { type: "string" },
|
|
2069
|
+
context: { type: "string", multiple: true },
|
|
2070
|
+
criteria: { type: "string", multiple: true },
|
|
2071
|
+
strict: { type: "boolean", default: false },
|
|
1202
2072
|
vault: { type: "string" },
|
|
2073
|
+
board: { type: "string" },
|
|
1203
2074
|
tree: { type: "boolean", default: false },
|
|
1204
2075
|
archived: { type: "boolean", default: false },
|
|
1205
2076
|
undo: { type: "boolean", default: false },
|
|
2077
|
+
off: { type: "boolean", default: false },
|
|
1206
2078
|
recursive: { type: "boolean", short: "r", default: false },
|
|
1207
2079
|
"dry-run": { type: "boolean", default: false },
|
|
1208
2080
|
json: { type: "boolean", default: false },
|
|
1209
2081
|
force: { type: "boolean", default: false },
|
|
2082
|
+
yes: { type: "boolean", default: false },
|
|
1210
2083
|
help: { type: "boolean", short: "h", default: false },
|
|
1211
2084
|
version: { type: "boolean", short: "V", default: false }
|
|
1212
2085
|
}
|
|
@@ -1216,14 +2089,29 @@ async function main(argv) {
|
|
|
1216
2089
|
`);
|
|
1217
2090
|
return 0;
|
|
1218
2091
|
}
|
|
1219
|
-
|
|
2092
|
+
let [command, ...rest] = positionals;
|
|
1220
2093
|
if (values.help || command === void 0 || command === "help") {
|
|
1221
2094
|
process.stdout.write(HELP);
|
|
1222
2095
|
return command === void 0 && !values.help ? 2 : 0;
|
|
1223
2096
|
}
|
|
1224
|
-
if (values.force && (command
|
|
1225
|
-
throw new UsageError("--force applies only to wi hook install.");
|
|
2097
|
+
if (values.force && !(command === "hook" && rest[0] === "install" || command === "setup")) {
|
|
2098
|
+
throw new UsageError("--force applies only to wi hook install or wi setup.");
|
|
2099
|
+
}
|
|
2100
|
+
if (values.yes && command !== "setup") throw new UsageError("--yes applies only to wi setup.");
|
|
2101
|
+
if (values.off && command !== "area") throw new UsageError("--off applies only to wi area.");
|
|
2102
|
+
if ((values.objective !== void 0 || values.context !== void 0 || values.criteria !== void 0 || values.strict) && command !== "new") {
|
|
2103
|
+
throw new UsageError("--objective, --context, --criteria and --strict apply only to wi new.");
|
|
1226
2104
|
}
|
|
2105
|
+
if (command === "setup") {
|
|
2106
|
+
if (rest.length > 0) throw new UsageError("wi setup takes options only. Run wi setup --help for usage.");
|
|
2107
|
+
await runSetup({
|
|
2108
|
+
...typeof values["vault"] === "string" ? { vault: values["vault"] } : {},
|
|
2109
|
+
yes: values["yes"] === true,
|
|
2110
|
+
force: values["force"] === true
|
|
2111
|
+
});
|
|
2112
|
+
return 0;
|
|
2113
|
+
}
|
|
2114
|
+
if (command === "here") return runHere(values, values.json === true);
|
|
1227
2115
|
const vault = await openVault(values.vault);
|
|
1228
2116
|
const json = values.json;
|
|
1229
2117
|
switch (command) {
|
|
@@ -1231,16 +2119,39 @@ async function main(argv) {
|
|
|
1231
2119
|
return runNew(vault, rest, values, json);
|
|
1232
2120
|
case "status":
|
|
1233
2121
|
return runStatus(vault, rest, json);
|
|
2122
|
+
case "area":
|
|
2123
|
+
return withRetagHint(vault, () => runArea(vault, rest, values, json));
|
|
2124
|
+
case "note":
|
|
2125
|
+
return runNote(vault, rest, values, json);
|
|
2126
|
+
case "claim":
|
|
2127
|
+
return runClaim(vault, rest, values, json);
|
|
2128
|
+
case "agents":
|
|
2129
|
+
return runAgents(vault, rest, json);
|
|
2130
|
+
case "release":
|
|
2131
|
+
return runRelease(vault, rest, values, json);
|
|
1234
2132
|
case "move":
|
|
1235
|
-
return runMove(vault, rest, values, json);
|
|
2133
|
+
return withRetagHint(vault, () => runMove(vault, rest, values, json));
|
|
1236
2134
|
case "archive":
|
|
1237
2135
|
return runArchive(vault, rest, values, json);
|
|
2136
|
+
case "promote":
|
|
2137
|
+
return runPromote(vault, rest, true, json);
|
|
2138
|
+
case "demote":
|
|
2139
|
+
return runPromote(vault, rest, false, json);
|
|
1238
2140
|
case "rm":
|
|
1239
2141
|
return runRemove(vault, rest, values, json);
|
|
1240
2142
|
case "children":
|
|
2143
|
+
if (rest.length === 0) {
|
|
2144
|
+
const board = await repoBoardForVault(vault);
|
|
2145
|
+
if (!board) throw new UsageError("wi children needs a <ref> or a matching board pointer from wi here.");
|
|
2146
|
+
rest = [board];
|
|
2147
|
+
}
|
|
1241
2148
|
return runChildren(vault, rest, values, json);
|
|
1242
2149
|
case "validate":
|
|
1243
2150
|
return runValidate(vault, json);
|
|
2151
|
+
case "retag":
|
|
2152
|
+
return runRetag(vault, rest, values, json);
|
|
2153
|
+
case "graph":
|
|
2154
|
+
return runGraph(vault, rest, json);
|
|
1244
2155
|
case "template":
|
|
1245
2156
|
return runTemplate(vault, rest, json);
|
|
1246
2157
|
case "hook":
|
|
@@ -1251,10 +2162,18 @@ async function main(argv) {
|
|
|
1251
2162
|
}
|
|
1252
2163
|
async function openVault(flag) {
|
|
1253
2164
|
const hint = flag ?? process.env["WI_VAULT"];
|
|
1254
|
-
|
|
2165
|
+
let root = hint ? resolve3(hint) : findVaultRoot(process.cwd());
|
|
2166
|
+
if (root === null) {
|
|
2167
|
+
const pointer = await getRepoPointer(process.cwd());
|
|
2168
|
+
root = pointer ? resolve3(pointer.vault) : null;
|
|
2169
|
+
}
|
|
2170
|
+
if (root === null) {
|
|
2171
|
+
const configured = await getDefaultVault();
|
|
2172
|
+
root = configured ? resolve3(configured) : null;
|
|
2173
|
+
}
|
|
1255
2174
|
if (root === null) {
|
|
1256
2175
|
throw new UsageError(
|
|
1257
|
-
"no vault found. Run wi inside a vault, pass --vault <path>, or
|
|
2176
|
+
"no vault found. Run wi inside a vault, pass --vault <path>, set WI_VAULT, run wi here, or configure defaultVault with wi setup."
|
|
1258
2177
|
);
|
|
1259
2178
|
}
|
|
1260
2179
|
if (findVaultRoot(root) !== root) {
|
|
@@ -1262,11 +2181,45 @@ async function openVault(flag) {
|
|
|
1262
2181
|
}
|
|
1263
2182
|
return loadVault(root);
|
|
1264
2183
|
}
|
|
2184
|
+
async function runHere(values, json) {
|
|
2185
|
+
const start = process.cwd();
|
|
2186
|
+
const hasFlags = typeof values["board"] === "string" || typeof values["vault"] === "string";
|
|
2187
|
+
const needsExisting = !hasFlags || typeof values["board"] !== "string" || typeof values["vault"] !== "string";
|
|
2188
|
+
const existing = needsExisting ? await getRepoPointer(start) : null;
|
|
2189
|
+
if (!hasFlags) {
|
|
2190
|
+
if (!existing) throw new UsageError("no board pointer is set for this Git repository. Run wi here --board <ref> --vault <path>.");
|
|
2191
|
+
if (json) process.stdout.write(`${JSON.stringify(existing)}
|
|
2192
|
+
`);
|
|
2193
|
+
else process.stdout.write(`vault ${existing.vault}
|
|
2194
|
+
board ${existing.board}
|
|
2195
|
+
`);
|
|
2196
|
+
return 0;
|
|
2197
|
+
}
|
|
2198
|
+
const vault = await openVault(typeof values["vault"] === "string" ? values["vault"] : void 0);
|
|
2199
|
+
const board = typeof values["board"] === "string" ? values["board"] : existing?.board ?? vault.config.defaultRoot;
|
|
2200
|
+
if (!board) throw new UsageError("wi here needs --board <ref> or an existing repo board pointer.");
|
|
2201
|
+
const resolved = vault.resolve(board);
|
|
2202
|
+
const pointer = { vault: vault.root, board: resolved.id ?? resolved.stem };
|
|
2203
|
+
await setRepoPointer(start, pointer);
|
|
2204
|
+
if (json) process.stdout.write(`${JSON.stringify(pointer)}
|
|
2205
|
+
`);
|
|
2206
|
+
else process.stdout.write(`set repo pointer
|
|
2207
|
+
vault ${pointer.vault}
|
|
2208
|
+
board ${pointer.board}
|
|
2209
|
+
`);
|
|
2210
|
+
return 0;
|
|
2211
|
+
}
|
|
2212
|
+
async function repoBoardForVault(vault) {
|
|
2213
|
+
const pointer = await getRepoPointer(process.cwd());
|
|
2214
|
+
return pointer && resolve3(pointer.vault) === resolve3(vault.root) ? pointer.board : void 0;
|
|
2215
|
+
}
|
|
1265
2216
|
async function runNew(vault, rest, values, json) {
|
|
1266
2217
|
const title = rest.join(" ").trim();
|
|
1267
2218
|
if (title === "") throw new UsageError('wi new needs a title. Try: wi new "Build server" --parent Main');
|
|
1268
|
-
const
|
|
1269
|
-
|
|
2219
|
+
const explicitParent = typeof values["parent"] === "string" ? values["parent"] : void 0;
|
|
2220
|
+
const pointerBoard = explicitParent === void 0 ? await repoBoardForVault(vault) : void 0;
|
|
2221
|
+
const parent = explicitParent ?? pointerBoard ?? vault.config.defaultRoot;
|
|
2222
|
+
if (!parent) throw new UsageError("wi new needs --parent <ref>, a repo board pointer, or defaultRoot in .wi.json.");
|
|
1270
2223
|
const priority = typeof values["priority"] === "string" ? Number(values["priority"]) : void 0;
|
|
1271
2224
|
if (priority !== void 0 && !Number.isFinite(priority)) {
|
|
1272
2225
|
throw new UsageError(`--priority must be a number, not "${values["priority"]}".`);
|
|
@@ -1278,7 +2231,13 @@ async function runNew(vault, rest, values, json) {
|
|
|
1278
2231
|
...typeof values["owner"] === "string" ? { owner: values["owner"] } : {},
|
|
1279
2232
|
...typeof values["agent"] === "string" ? { agent: values["agent"] } : {},
|
|
1280
2233
|
...typeof values["template"] === "string" ? { template: values["template"] } : {},
|
|
1281
|
-
...priority !== void 0 ? { priority } : {}
|
|
2234
|
+
...priority !== void 0 ? { priority } : {},
|
|
2235
|
+
brief: {
|
|
2236
|
+
objective: typeof values["objective"] === "string" ? values["objective"] : void 0,
|
|
2237
|
+
context: Array.isArray(values["context"]) ? values["context"] : void 0,
|
|
2238
|
+
criteria: Array.isArray(values["criteria"]) ? values["criteria"] : void 0
|
|
2239
|
+
},
|
|
2240
|
+
strict: values["strict"] === true
|
|
1282
2241
|
});
|
|
1283
2242
|
if (vault.unaccounted.length > 0) {
|
|
1284
2243
|
process.stderr.write(
|
|
@@ -1286,9 +2245,28 @@ async function runNew(vault, rest, values, json) {
|
|
|
1286
2245
|
`
|
|
1287
2246
|
);
|
|
1288
2247
|
}
|
|
1289
|
-
if (
|
|
1290
|
-
|
|
2248
|
+
if (created.gaps.length > 0) {
|
|
2249
|
+
process.stderr.write(`wi: warning: ${created.id} has no ${created.gaps.join(" or ")}. Pass --objective and --criteria, or fill the card before work starts.
|
|
2250
|
+
`);
|
|
2251
|
+
}
|
|
2252
|
+
if (created.renamed) {
|
|
2253
|
+
process.stderr.write(`wi: note: another item has this filename, so this one is ${created.relPath}. If they are different work, give the card a more specific title.
|
|
2254
|
+
`);
|
|
2255
|
+
}
|
|
2256
|
+
if (json) {
|
|
2257
|
+
print({
|
|
2258
|
+
id: created.id,
|
|
2259
|
+
path: created.relPath,
|
|
2260
|
+
parent: created.parentStem,
|
|
2261
|
+
promoted_parent: created.promotedParent,
|
|
2262
|
+
gaps: created.gaps
|
|
2263
|
+
});
|
|
2264
|
+
} else {
|
|
2265
|
+
process.stdout.write(`${created.id} ${created.relPath} (child of ${created.parentStem})
|
|
2266
|
+
`);
|
|
2267
|
+
if (created.promotedParent) process.stdout.write(`${created.parentStem} promoted to a board (its first child)
|
|
1291
2268
|
`);
|
|
2269
|
+
}
|
|
1292
2270
|
return 0;
|
|
1293
2271
|
}
|
|
1294
2272
|
async function runStatus(vault, rest, json) {
|
|
@@ -1304,7 +2282,8 @@ async function runStatus(vault, rest, json) {
|
|
|
1304
2282
|
from: change.from ?? null,
|
|
1305
2283
|
to: change.to,
|
|
1306
2284
|
prev_status: change.recorded ?? null,
|
|
1307
|
-
changed: change.changed
|
|
2285
|
+
changed: change.changed,
|
|
2286
|
+
parent_ready: change.parentReady?.id ?? null
|
|
1308
2287
|
});
|
|
1309
2288
|
} else if (!change.changed) {
|
|
1310
2289
|
process.stdout.write(`${label(change.item)} is already ${change.to}. Nothing written.
|
|
@@ -1312,10 +2291,134 @@ async function runStatus(vault, rest, json) {
|
|
|
1312
2291
|
} else {
|
|
1313
2292
|
const recorded = change.recorded ? ` (prev_status: ${change.recorded})` : "";
|
|
1314
2293
|
process.stdout.write(`${label(change.item)} ${change.from ?? "\u2014"} \u2192 ${change.to}${recorded}
|
|
2294
|
+
`);
|
|
2295
|
+
}
|
|
2296
|
+
const ready = change.parentReady;
|
|
2297
|
+
if (ready && !json) {
|
|
2298
|
+
process.stdout.write(`${label(ready)} every child is done. If its own criteria are met, run: wi status ${ready.id ?? ready.stem} done
|
|
2299
|
+
`);
|
|
2300
|
+
}
|
|
2301
|
+
return 0;
|
|
2302
|
+
}
|
|
2303
|
+
async function runArea(vault, rest, values, json) {
|
|
2304
|
+
const ref = rest.join(" ").trim();
|
|
2305
|
+
if (ref === "") {
|
|
2306
|
+
throw new UsageError("wi area needs a <ref>. Use --off to convert an area back to a card.");
|
|
2307
|
+
}
|
|
2308
|
+
if (typeof values["status"] === "string") throw new UsageError("--status does not apply to wi area; conversion preserves the current status.");
|
|
2309
|
+
const change = await setArea(vault, ref, {
|
|
2310
|
+
off: values["off"] === true
|
|
2311
|
+
});
|
|
2312
|
+
if (json) {
|
|
2313
|
+
print({
|
|
2314
|
+
id: change.item.id,
|
|
2315
|
+
path: change.item.relPath,
|
|
2316
|
+
from: change.from,
|
|
2317
|
+
to: change.to,
|
|
2318
|
+
status: change.status ?? null,
|
|
2319
|
+
changed: change.changed
|
|
2320
|
+
});
|
|
2321
|
+
} else {
|
|
2322
|
+
process.stdout.write(`${label(change.item)} ${change.from} \u2192 ${change.to}${change.status ? ` (status: ${change.status})` : ""}
|
|
2323
|
+
`);
|
|
2324
|
+
}
|
|
2325
|
+
return 0;
|
|
2326
|
+
}
|
|
2327
|
+
function singleLineOption(values, key) {
|
|
2328
|
+
const value = values[key];
|
|
2329
|
+
if (typeof value !== "string" || value.trim() === "") {
|
|
2330
|
+
throw new UsageError(`--${key} needs non-empty text.`);
|
|
2331
|
+
}
|
|
2332
|
+
if (/[\r\n]/.test(value)) throw new UsageError(`--${key} must be one line.`);
|
|
2333
|
+
return value.trim();
|
|
2334
|
+
}
|
|
2335
|
+
async function runClaim(vault, rest, values, json) {
|
|
2336
|
+
const ref = rest.join(" ").trim();
|
|
2337
|
+
if (ref === "") throw new UsageError("wi claim needs a <ref> and --agent <name>.");
|
|
2338
|
+
const agent = singleLineOption(values, "agent");
|
|
2339
|
+
const maxAgents = maxAgentsForRun(vault);
|
|
2340
|
+
const active = activeAgentNames(vault);
|
|
2341
|
+
const change = await claimItem(vault, ref, agent);
|
|
2342
|
+
if (json) print({
|
|
2343
|
+
id: change.item.id,
|
|
2344
|
+
path: change.item.relPath,
|
|
2345
|
+
agent: change.agent,
|
|
2346
|
+
from: change.from ?? null,
|
|
2347
|
+
to: change.to,
|
|
2348
|
+
changed: change.changed
|
|
2349
|
+
});
|
|
2350
|
+
else process.stdout.write(change.changed ? `${label(change.item)} ${change.from ?? "\u2014"} \u2192 doing (agent: ${agent})
|
|
2351
|
+
` : `${label(change.item)} is already claimed by ${agent} in doing. Nothing written.
|
|
2352
|
+
`);
|
|
2353
|
+
if (change.changed && maxAgents !== null && !active.has(agent) && active.size + 1 > maxAgents) {
|
|
2354
|
+
process.stderr.write(`wi: warning: agent limit is ${maxAgents}; ${active.size + 1} agents now have a doing card.
|
|
1315
2355
|
`);
|
|
1316
2356
|
}
|
|
1317
2357
|
return 0;
|
|
1318
2358
|
}
|
|
2359
|
+
async function runNote(vault, rest, values, json) {
|
|
2360
|
+
const [ref, ...words] = rest;
|
|
2361
|
+
const text = words.join(" ").trim();
|
|
2362
|
+
if (ref === void 0 || text === "") {
|
|
2363
|
+
throw new UsageError('wi note needs a <ref> and the text. Try: wi note wi-a7f3 "Priced 12 lines."');
|
|
2364
|
+
}
|
|
2365
|
+
const agent = values["agent"] === void 0 ? void 0 : singleLineOption(values, "agent");
|
|
2366
|
+
const added = await addNote(vault, ref, text, agent);
|
|
2367
|
+
if (json) print({ id: added.item.id, path: added.item.relPath, line: added.line });
|
|
2368
|
+
else process.stdout.write(`${label(added.item)} ${added.line}
|
|
2369
|
+
`);
|
|
2370
|
+
return 0;
|
|
2371
|
+
}
|
|
2372
|
+
function claimedDoing(vault) {
|
|
2373
|
+
return vault.items.flatMap((item) => {
|
|
2374
|
+
const agent = item.frontmatter.get("agent");
|
|
2375
|
+
return item.status === "doing" && typeof agent === "string" && agent.trim() !== "" ? [{ agent, item }] : [];
|
|
2376
|
+
});
|
|
2377
|
+
}
|
|
2378
|
+
function activeAgentNames(vault) {
|
|
2379
|
+
return new Set(claimedDoing(vault).map((claim) => claim.agent));
|
|
2380
|
+
}
|
|
2381
|
+
function runAgents(vault, rest, json) {
|
|
2382
|
+
if (rest.length > 0) throw new UsageError("wi agents takes no arguments.");
|
|
2383
|
+
const maxAgents = maxAgentsForRun(vault);
|
|
2384
|
+
const claims = claimedDoing(vault).sort((a, b) => a.agent.localeCompare(b.agent));
|
|
2385
|
+
const activeAgents = new Set(claims.map((claim) => claim.agent)).size;
|
|
2386
|
+
if (json) {
|
|
2387
|
+
print({ maxAgents, activeAgents, claims: claims.map(({ agent, item }) => ({
|
|
2388
|
+
agent,
|
|
2389
|
+
id: item.id ?? null,
|
|
2390
|
+
title: item.title ?? item.stem,
|
|
2391
|
+
path: item.relPath
|
|
2392
|
+
})) });
|
|
2393
|
+
return 0;
|
|
2394
|
+
}
|
|
2395
|
+
process.stdout.write(`limit ${maxAgents === null ? "none" : maxAgents}
|
|
2396
|
+
agents with a doing card ${activeAgents}
|
|
2397
|
+
`);
|
|
2398
|
+
for (const { agent, item } of claims) process.stdout.write(` ${agent} ${label(item)}
|
|
2399
|
+
`);
|
|
2400
|
+
return 0;
|
|
2401
|
+
}
|
|
2402
|
+
async function runRelease(vault, rest, values, json) {
|
|
2403
|
+
const ref = rest.join(" ").trim();
|
|
2404
|
+
if (ref === "") throw new UsageError("wi release needs a <ref> and --reason <text>.");
|
|
2405
|
+
const reason = singleLineOption(values, "reason");
|
|
2406
|
+
const where = values["where"] === void 0 ? void 0 : singleLineOption(values, "where");
|
|
2407
|
+
const change = await releaseItem(vault, ref, reason, where);
|
|
2408
|
+
if (json) print({
|
|
2409
|
+
id: change.item.id,
|
|
2410
|
+
path: change.item.relPath,
|
|
2411
|
+
agent: change.agent,
|
|
2412
|
+
from: change.from ?? null,
|
|
2413
|
+
to: change.to,
|
|
2414
|
+
reason: change.reason,
|
|
2415
|
+
where: change.where ?? null,
|
|
2416
|
+
changed: change.changed
|
|
2417
|
+
});
|
|
2418
|
+
else process.stdout.write(`${label(change.item)} ${change.from ?? "\u2014"} \u2192 options (released ${change.agent})
|
|
2419
|
+
`);
|
|
2420
|
+
return 0;
|
|
2421
|
+
}
|
|
1319
2422
|
async function runMove(vault, rest, values, json) {
|
|
1320
2423
|
const ref = rest.join(" ").trim();
|
|
1321
2424
|
const to = typeof values["to"] === "string" ? values["to"] : "";
|
|
@@ -1353,6 +2456,19 @@ async function runArchive(vault, rest, values, json) {
|
|
|
1353
2456
|
}
|
|
1354
2457
|
return 0;
|
|
1355
2458
|
}
|
|
2459
|
+
async function runPromote(vault, rest, promoted, json) {
|
|
2460
|
+
const ref = rest.join(" ").trim();
|
|
2461
|
+
if (ref === "") throw new UsageError(`wi ${promoted ? "promote" : "demote"} needs a <ref>.`);
|
|
2462
|
+
const change = await setPromoted(vault, ref, promoted);
|
|
2463
|
+
if (json) {
|
|
2464
|
+
print({ id: change.item.id, path: change.item.relPath, promoted: change.promoted, changed: change.changed });
|
|
2465
|
+
} else {
|
|
2466
|
+
const verb = promoted ? "promoted" : "demoted";
|
|
2467
|
+
process.stdout.write(`${label(change.item)} ${change.changed ? verb : `already ${promoted ? "a board" : "a card"}; nothing written`}
|
|
2468
|
+
`);
|
|
2469
|
+
}
|
|
2470
|
+
return 0;
|
|
2471
|
+
}
|
|
1356
2472
|
async function runRemove(vault, rest, values, json) {
|
|
1357
2473
|
const ref = rest.join(" ").trim();
|
|
1358
2474
|
if (ref === "") throw new UsageError("wi rm needs a <ref>.");
|
|
@@ -1395,6 +2511,14 @@ function runChildren(vault, rest, values, json) {
|
|
|
1395
2511
|
print({
|
|
1396
2512
|
parent: { id: listing.parent.id, path: listing.parent.relPath, board: listing.parent.board },
|
|
1397
2513
|
cycle: listing.cycle,
|
|
2514
|
+
areas: listing.areas.map((row) => ({
|
|
2515
|
+
id: row.item.id,
|
|
2516
|
+
title: row.item.title,
|
|
2517
|
+
path: row.item.relPath,
|
|
2518
|
+
children: row.childCount,
|
|
2519
|
+
depth: row.depth,
|
|
2520
|
+
archived: row.archived
|
|
2521
|
+
})),
|
|
1398
2522
|
children: listing.children.map((row) => ({
|
|
1399
2523
|
id: row.item.id,
|
|
1400
2524
|
title: row.item.title,
|
|
@@ -1402,7 +2526,8 @@ function runChildren(vault, rest, values, json) {
|
|
|
1402
2526
|
status: row.item.status ?? null,
|
|
1403
2527
|
children: row.childCount,
|
|
1404
2528
|
depth: row.depth,
|
|
1405
|
-
archived: row.archived
|
|
2529
|
+
archived: row.archived,
|
|
2530
|
+
blocked: row.item.frontmatter.get("blocked") === true
|
|
1406
2531
|
}))
|
|
1407
2532
|
});
|
|
1408
2533
|
return 0;
|
|
@@ -1410,7 +2535,11 @@ function runChildren(vault, rest, values, json) {
|
|
|
1410
2535
|
const out = [
|
|
1411
2536
|
`${label(listing.parent)}${listing.parent.board ? " [board]" : ""}`
|
|
1412
2537
|
];
|
|
1413
|
-
if (listing.
|
|
2538
|
+
if (listing.areas.length > 0) {
|
|
2539
|
+
out.push(` Areas (${listing.areas.length})`);
|
|
2540
|
+
for (const row of listing.areas) out.push(` ${" ".repeat(row.depth)}${row3(row)}`);
|
|
2541
|
+
}
|
|
2542
|
+
if (listing.children.length === 0 && listing.areas.length === 0) {
|
|
1414
2543
|
out.push(" no children");
|
|
1415
2544
|
} else if (values["tree"] === true || typeof values["status"] === "string") {
|
|
1416
2545
|
for (const row of listing.children) out.push(` ${" ".repeat(row.depth)}${row3(row)}`);
|
|
@@ -1425,6 +2554,39 @@ function runChildren(vault, rest, values, json) {
|
|
|
1425
2554
|
`);
|
|
1426
2555
|
return 0;
|
|
1427
2556
|
}
|
|
2557
|
+
async function withRetagHint(vault, run2) {
|
|
2558
|
+
const code = await run2();
|
|
2559
|
+
if (vault.config.areaTags) {
|
|
2560
|
+
const stale = staleAreaTags(await loadVault(vault.root)).length;
|
|
2561
|
+
if (stale > 0) process.stderr.write(`wi: ${stale} work item${stale === 1 ? " has" : "s have"} a stale area tag. Run wi retag.
|
|
2562
|
+
`);
|
|
2563
|
+
}
|
|
2564
|
+
return code;
|
|
2565
|
+
}
|
|
2566
|
+
async function runRetag(vault, rest, values, json) {
|
|
2567
|
+
if (rest.length > 0) throw new UsageError("wi retag takes no arguments.");
|
|
2568
|
+
const dryRun = values["dry-run"] === true;
|
|
2569
|
+
const changed = await retag(vault, dryRun);
|
|
2570
|
+
if (json) {
|
|
2571
|
+
print({ dryRun, changed: changed.map(({ item, from, to }) => ({ id: item.id ?? null, path: item.relPath, from, to })) });
|
|
2572
|
+
return 0;
|
|
2573
|
+
}
|
|
2574
|
+
const verb = dryRun ? "would retag" : "retagged";
|
|
2575
|
+
for (const { item, to } of changed) process.stdout.write(`${verb} ${label(item)} [${to.join(", ")}]
|
|
2576
|
+
`);
|
|
2577
|
+
process.stdout.write(`${changed.length} work item${changed.length === 1 ? "" : "s"}${dryRun ? ", nothing written" : ""}
|
|
2578
|
+
`);
|
|
2579
|
+
return 0;
|
|
2580
|
+
}
|
|
2581
|
+
async function runGraph(vault, rest, json) {
|
|
2582
|
+
if (rest.length > 0) throw new UsageError("wi graph takes no arguments.");
|
|
2583
|
+
const written = await writeGraphColours(vault);
|
|
2584
|
+
if (json) print(written);
|
|
2585
|
+
else process.stdout.write(`wrote ${written.groups} area colour groups to ${written.path}, kept ${written.kept} of your own
|
|
2586
|
+
reopen the graph view to see them
|
|
2587
|
+
`);
|
|
2588
|
+
return 0;
|
|
2589
|
+
}
|
|
1428
2590
|
async function runValidate(vault, json) {
|
|
1429
2591
|
const report = await validate(vault);
|
|
1430
2592
|
if (json) {
|
|
@@ -1507,7 +2669,7 @@ async function runHook(vault, rest, values, json) {
|
|
|
1507
2669
|
` : "done\n");
|
|
1508
2670
|
return 0;
|
|
1509
2671
|
}
|
|
1510
|
-
const path = await installHook(vault.root,
|
|
2672
|
+
const path = await installHook(vault.root, fileURLToPath2(import.meta.url), values["force"] === true);
|
|
1511
2673
|
if (json) print({ installed: path });
|
|
1512
2674
|
else {
|
|
1513
2675
|
process.stdout.write(`installed ${path}
|
|
@@ -1535,7 +2697,8 @@ function row3(row) {
|
|
|
1535
2697
|
const kids = row.childCount > 0 ? ` (${row.childCount})` : "";
|
|
1536
2698
|
const board = row.item.board ? " [board]" : "";
|
|
1537
2699
|
const archived = row.archived ? " [archived]" : "";
|
|
1538
|
-
|
|
2700
|
+
const blocked = row.item.frontmatter.get("blocked") === true ? " [blocked]" : "";
|
|
2701
|
+
return `${row.item.id ?? "(no id)"} ${status.padEnd(7)} ${row.item.title ?? row.item.stem}${kids}${board}${blocked}${archived}`;
|
|
1539
2702
|
}
|
|
1540
2703
|
function print(value) {
|
|
1541
2704
|
process.stdout.write(`${JSON.stringify(value, null, 2)}
|