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/dist/wi/wi.js CHANGED
@@ -2,13 +2,15 @@
2
2
 
3
3
  // src/cli/wi.ts
4
4
  import { parseArgs } from "node:util";
5
- import { resolve as resolve2 } from "node:path";
6
- import { fileURLToPath } from "node:url";
5
+ import { resolve as resolve3 } from "node:path";
6
+ import { fileURLToPath as fileURLToPath2 } from "node:url";
7
7
 
8
8
  // src/cli/vault.ts
9
- import { readdir, readFile } from "node:fs/promises";
10
- import { existsSync } from "node:fs";
11
- import { join, resolve as resolvePath, dirname, basename, sep } from "node:path";
9
+ import { readdir, readFile, mkdir, writeFile, rename } from "node:fs/promises";
10
+ import { existsSync, realpathSync } from "node:fs";
11
+ import { execFileSync } from "node:child_process";
12
+ import { homedir } from "node:os";
13
+ import { join, resolve as resolvePath, dirname, basename, sep, isAbsolute } from "node:path";
12
14
 
13
15
  // src/shared/frontmatter.ts
14
16
  var KEY_LINE = /^([A-Za-z_][\w.-]*)\s*:(?:[ \t]+(.*))?$/;
@@ -99,6 +101,12 @@ function parseFrontmatter(text) {
99
101
  entry: (key) => byKey.get(key)
100
102
  };
101
103
  }
104
+ function frontmatterBody(text) {
105
+ const block = splitBlock(text);
106
+ if (!block) return text;
107
+ const fenceEnd = text.indexOf("\n", block.contentEnd);
108
+ return fenceEnd === -1 ? "" : text.slice(fenceEnd + 1);
109
+ }
102
110
  function rewrite(text, block, lines, ends) {
103
111
  const head = text.slice(0, block.contentStart);
104
112
  const tail = text.slice(block.contentEnd);
@@ -109,6 +117,7 @@ function setKey(text, key, value) {
109
117
  const block = splitBlock(text);
110
118
  if (!block) throw new Error(`cannot set "${key}": the file has no frontmatter`);
111
119
  const entry = readEntries(block.lines).find((e) => e.key === key);
120
+ if (entry?.value === value) return text;
112
121
  const line2 = `${key}: ${formatScalar(value)}`;
113
122
  const lines = [...block.lines];
114
123
  const ends = [...block.ends];
@@ -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 headings = sectionsValue;
256
- for (const heading of headings) {
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
- return { workItemFolder, defaultRoot, extraSections };
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 resolve3 = (ref) => {
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: resolve3,
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 existsSync2 } from "node:fs";
472
- import { join as join3 } from "node:path";
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 writeFile(temp, text, "utf8");
495
- await rename(temp, path);
496
- }
497
- async function editItem(item, edits) {
498
- const text = applyEdits(item.text, withStamp(edits));
499
- if (text !== item.text) await writeAtomic(item.path, text);
500
- return text;
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
- function renderBody(template, extraSections = []) {
529
- const parts = [];
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
- if (section.starter !== void 0) parts.push(section.starter, "");
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
- const lines = CORE_FIELDS.filter((field) => field in placeholders).map(
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 frontmatter = fields.map(([key, value]) => `${key}: ${formatScalar(value)}`).join("\n");
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
- ${frontmatter}
827
+ ${lines.join("\n")}
576
828
  ---
577
829
 
578
- ${renderBody(requireTemplate(item.template), extraSections)}`;
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
- rows.push({ item: child, childCount: vault.childrenOf(child).filter((kid) => !vault.isArchived(kid) || archived).length, depth, archived: effectiveArchived });
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 readFile2 } from "node:fs/promises";
954
- import { existsSync as existsSync3 } from "node:fs";
955
- import { mkdir } from "node:fs/promises";
956
- import { join as join4 } from "node:path";
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 = join4(vault.root, TEMPLATES_FOLDER);
963
- await mkdir(folder, { recursive: true });
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 = join4(folder, `${template.name}.md`);
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 (!existsSync3(path)) {
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 readFile2(path, "utf8");
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 mkdir2, rename as rename2 } from "node:fs/promises";
987
- import { existsSync as existsSync4 } from "node:fs";
988
- import { join as join5 } from "node:path";
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 (!existsSync4(join5(vault.root, TRASH, name))) return `${TRASH}/${name}`;
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 mkdir2(join5(vault.root, TRASH), { recursive: true });
1034
- await rename2(target.path, join5(vault.root, ...trashedTo.split("/")));
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 existsSync5 } from "node:fs";
1086
- import { chmod, mkdir as mkdir3, readFile as readFile3, rm, writeFile as writeFile2 } from "node:fs/promises";
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 = existsSync5(git.path);
1119
- const ours = present && isOurHook(await readFile3(git.path, "utf8"));
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 (existsSync5(git.path)) {
1130
- const current = await readFile3(git.path, "utf8");
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 mkdir3(dirname3(git.path), { recursive: true });
1136
- await writeFile2(git.path, hookBody(vault, entry), "utf8");
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 || !existsSync5(git.path) || !isOurHook(await readFile3(git.path, "utf8"))) return null;
1143
- await rm(git.path);
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
- [--priority <n>]
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, then the nearest configured vault or Boards/.
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 another pre-commit hook with wi hook install.
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.1.2";
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
- const [command, ...rest] = positionals;
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 !== "hook" || rest[0] !== "install")) {
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
- const root = hint ? resolve2(hint) : findVaultRoot(process.cwd());
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 set WI_VAULT."
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 parent = typeof values["parent"] === "string" ? values["parent"] : vault.config.defaultRoot;
1269
- if (!parent) throw new UsageError("wi new needs --parent <ref> or defaultRoot in .wi.json.");
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 (json) print({ id: created.id, path: created.relPath, parent: created.parentStem });
1290
- else process.stdout.write(`${created.id} ${created.relPath} (child of ${created.parentStem})
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.children.length === 0) {
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, fileURLToPath(import.meta.url), values["force"] === true);
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
- return `${row.item.id ?? "(no id)"} ${status.padEnd(7)} ${row.item.title ?? row.item.stem}${kids}${board}${archived}`;
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)}