recursive-board 0.2.0 → 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
@@ -144,6 +144,47 @@ function removeKey(text, key) {
144
144
  function defaultEnd(text) {
145
145
  return text.includes("\r\n") ? "\r\n" : "\n";
146
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
+ }
147
188
 
148
189
  // src/shared/schema.ts
149
190
  var STATUSES = ["backlog", "options", "doing", "done"];
@@ -169,9 +210,8 @@ var OPTIONAL_FIELDS = [
169
210
  "archived",
170
211
  // docs/adr/0007-archive-is-a-frontmatter-flag.md: a flag, not a status.
171
212
  "area"
172
- // docs/adr/0028-area-work-items.md: an ongoing space with no status.
213
+ // docs/adr/0028-area-work-items.md: an ongoing space that keeps its status.
173
214
  ];
174
- var INHERITED_FIELDS = ["owner", "agent"];
175
215
  var WORK_ITEM_TYPE = "work-item";
176
216
  function isArea(value) {
177
217
  return value === true;
@@ -228,7 +268,10 @@ var WI_CONFIG_FILE = ".wi.json";
228
268
  var DEFAULT_VAULT_CONFIG = {
229
269
  workItemFolder: "Boards",
230
270
  defaultRoot: null,
231
- extraSections: []
271
+ extraSections: [],
272
+ maxAgents: null,
273
+ autoPromote: true,
274
+ areaTags: false
232
275
  };
233
276
  function parseVaultConfig(text) {
234
277
  if (text === null) return { ...DEFAULT_VAULT_CONFIG, extraSections: [] };
@@ -244,6 +287,9 @@ function parseVaultConfig(text) {
244
287
  const folderValue = "workItemFolder" in value ? value.workItemFolder : void 0;
245
288
  const rootValue = "defaultRoot" in value ? value.defaultRoot : void 0;
246
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;
247
293
  let workItemFolder = DEFAULT_VAULT_CONFIG.workItemFolder;
248
294
  if (folderValue !== void 0) {
249
295
  if (typeof folderValue !== "string") {
@@ -274,7 +320,22 @@ function parseVaultConfig(text) {
274
320
  extraSections.push(heading);
275
321
  }
276
322
  }
277
- 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 };
278
339
  }
279
340
 
280
341
  // src/shared/archive.ts
@@ -305,6 +366,15 @@ function activeDescendant(item, childrenOf, statusOf) {
305
366
  }
306
367
 
307
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
+ }
308
378
  var MARKDOWN = /\.md$/i;
309
379
  function wiConfigDir(env = process.env) {
310
380
  const xdg = env["XDG_CONFIG_HOME"];
@@ -550,18 +620,21 @@ function isMissingFile(error) {
550
620
  }
551
621
 
552
622
  // src/cli/commands/new.ts
553
- import { existsSync as existsSync2 } from "node:fs";
554
- import { join as join3 } from "node:path";
623
+ import { existsSync as existsSync3 } from "node:fs";
624
+ import { join as join4 } from "node:path";
555
625
 
556
626
  // src/cli/write.ts
557
- import { writeFile as writeFile2, rename as rename2 } 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";
558
630
  import { dirname as dirname2, join as join2 } from "node:path";
631
+ import { setTimeout as sleep } from "node:timers/promises";
559
632
 
560
633
  // src/shared/edits.ts
561
634
  function applyEdits(text, edits) {
562
635
  let out = text;
563
636
  for (const edit of edits) {
564
- 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);
565
638
  }
566
639
  return out;
567
640
  }
@@ -572,15 +645,46 @@ function withStamp(edits, stamp = today()) {
572
645
 
573
646
  // src/cli/write.ts
574
647
  async function writeAtomic(path, text) {
575
- const temp = join2(dirname2(path), `.wi-${process.pid}-${Date.now()}.tmp`);
648
+ const temp = join2(dirname2(path), `.wi-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}.tmp`);
576
649
  await writeFile2(temp, text, "utf8");
577
650
  await rename2(temp, path);
578
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
+ }
579
680
  async function editItem(item, edits, editBody = (text) => text) {
580
- if (editBody(applyEdits(item.text, edits)) === item.text) return item.text;
581
- const text = editBody(applyEdits(item.text, withStamp(edits)));
582
- await writeAtomic(item.path, text);
583
- return 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
+ });
584
688
  }
585
689
 
586
690
  // src/shared/templates.ts
@@ -599,13 +703,13 @@ var TEMPLATES = [
599
703
  name: "first-board-card",
600
704
  description: "A short guide to adding and moving cards on your first board.",
601
705
  sections: [
602
- { heading: "Getting started", starter: "Add cards from a board column. To move this card, open its card menu and choose \u201CMove to\u2026\u201D." },
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." },
603
707
  { heading: "Notes" }
604
708
  ]
605
709
  },
606
710
  {
607
711
  name: "area",
608
- description: "An ongoing area of work with no status.",
712
+ description: "An ongoing area of work.",
609
713
  area: true,
610
714
  sections: [
611
715
  { heading: "Objective" },
@@ -626,15 +730,45 @@ function requireTemplate(name = DEFAULT_TEMPLATE) {
626
730
  if (template) return template;
627
731
  throw new Error(`no template called "${name}". There is: ${templateNames().join(", ")}.`);
628
732
  }
629
- function renderBody(template, extraSections = []) {
630
- 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);
631
758
  const sections = [
632
759
  ...template.sections,
633
760
  ...extraSections.map((heading) => ({ heading, starter: "- " }))
634
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 = [];
635
768
  for (const section of sections) {
636
769
  parts.push(`## ${section.heading}`, "");
637
- 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, "");
638
772
  }
639
773
  return parts.join("\n");
640
774
  }
@@ -647,13 +781,13 @@ function renderVaultTemplate(template, defaultRoot = null, extraSections = []) {
647
781
  created: "",
648
782
  updated: ""
649
783
  };
784
+ placeholders["status"] = "backlog";
650
785
  if (template.area) placeholders["area"] = "true";
651
- else placeholders["status"] = "backlog";
652
- const fields = template.area ? CORE_FIELDS.filter((field) => field !== "status") : CORE_FIELDS;
786
+ const fields = CORE_FIELDS;
653
787
  const lines = fields.filter((field) => field in placeholders).map(
654
788
  (field) => `${field}: ${placeholders[field]}`.trimEnd()
655
789
  );
656
- if (template.area) lines.splice(3, 0, "area: true");
790
+ if (template.area) lines.splice(4, 0, "area: true");
657
791
  return `---
658
792
  ${lines.join("\n")}
659
793
  ---
@@ -662,6 +796,12 @@ ${renderBody(template, extraSections)}`;
662
796
  }
663
797
 
664
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
+ }
665
805
  function renderWorkItem(item, extraSections = []) {
666
806
  const template = requireTemplate(item.template);
667
807
  if (Boolean(template.area) !== (item.area === true)) {
@@ -672,62 +812,22 @@ function renderWorkItem(item, extraSections = []) {
672
812
  ["id", item.id],
673
813
  ["title", item.title]
674
814
  ];
815
+ fields.push(["status", item.status]);
675
816
  if (item.area) fields.push(["area", true]);
676
- else fields.push(["status", item.status]);
677
817
  fields.push(["parent", formatWikilink(item.parentStem)]);
678
818
  if (item.owner !== void 0 && item.owner !== "") fields.push(["owner", item.owner]);
679
819
  if (item.agent !== void 0 && item.agent !== "") fields.push(["agent", item.agent]);
680
820
  if (item.priority !== void 0) fields.push(["priority", item.priority]);
681
821
  fields.push(["created", item.created], ["updated", item.updated]);
682
- 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
+ }
683
826
  return `---
684
- ${frontmatter}
827
+ ${lines.join("\n")}
685
828
  ---
686
829
 
687
- ${renderBody(template, extraSections)}`;
688
- }
689
-
690
- // src/cli/commands/new.ts
691
- async function createItem(vault, options) {
692
- const title = options.title.trim();
693
- if (title === "") throw new Error("a work item needs a title");
694
- const parentRef = options.parent ?? vault.config.defaultRoot ?? "";
695
- if (parentRef.trim() === "") {
696
- throw new Error("a work item needs a --parent. Only the root has none.");
697
- }
698
- const template = requireTemplate(options.template);
699
- const status = options.status ?? "backlog";
700
- if (template.area && options.status !== void 0) {
701
- throw new Error("an area cannot have a status");
702
- }
703
- if (!template.area && !isStatus(status)) {
704
- throw new Error(`"${status}" is not a status. Use backlog, options, doing or done.`);
705
- }
706
- const parent = vault.resolve(parentRef);
707
- const id = newId(vault.takenIds);
708
- const stem = fileNameFor(title, id, vault.takenStems);
709
- const relPath = `${vault.config.workItemFolder}/${stem}.md`;
710
- const path = join3(vault.root, ...vault.config.workItemFolder.split("/"), `${stem}.md`);
711
- if (existsSync2(path)) {
712
- throw new Error(`${relPath} already exists. Refusing to overwrite it.`);
713
- }
714
- const stamp = today();
715
- const common = {
716
- id,
717
- title,
718
- parentStem: parent.stem,
719
- created: stamp,
720
- updated: stamp,
721
- template: options.template
722
- };
723
- const fields = template.area ? { ...common, area: true } : { ...common, status };
724
- for (const field of INHERITED_FIELDS) {
725
- const value = options[field] ?? parent.frontmatter.get(field);
726
- if (value !== void 0 && value !== "") fields[field] = String(value);
727
- }
728
- if (options.priority !== void 0) fields.priority = options.priority;
729
- await writeAtomic(path, renderWorkItem(fields, vault.config.extraSections));
730
- return { id, stem, relPath, path, parentStem: parent.stem };
830
+ ${renderBody(template, extraSections, item.brief)}`;
731
831
  }
732
832
 
733
833
  // src/shared/transitions.ts
@@ -741,13 +841,15 @@ function statusEdits(from, to, hasPrevStatus) {
741
841
  }
742
842
  return edits;
743
843
  }
744
- function claimEdits(from, currentAgent, agent, hasPrevStatus, hasDoingChild) {
844
+ function claimEdits(from, currentAgent, agent, hasPrevStatus, hasOtherDoingChild) {
745
845
  if (from === "done") throw new Error("a done card cannot be claimed.");
746
846
  if (currentAgent && currentAgent !== agent) {
747
847
  throw new Error(`already claimed by ${currentAgent}. Release that claim first.`);
748
848
  }
749
849
  if (currentAgent === agent && from === "doing") return null;
750
- if (hasDoingChild) throw new Error("this board has a child in doing. Release or finish the child first.");
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
+ }
751
853
  const status = statusEdits(from, "doing", hasPrevStatus);
752
854
  if (currentAgent === agent) return status;
753
855
  return [...status ?? [], { op: "set", key: "agent", value: agent }];
@@ -755,6 +857,13 @@ function claimEdits(from, currentAgent, agent, hasPrevStatus, hasDoingChild) {
755
857
  function releaseEdits(from, hasPrevStatus) {
756
858
  return [...statusEdits(from, "options", hasPrevStatus) ?? [], { op: "remove", key: "agent" }];
757
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
+ }
758
867
  function moveEdits(currentParentStem, targetStem) {
759
868
  if (currentParentStem !== null && currentParentStem.toLowerCase() === targetStem.toLowerCase()) {
760
869
  return null;
@@ -776,6 +885,219 @@ function moveRefusal(check) {
776
885
  return null;
777
886
  }
778
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
+
779
1101
  // src/cli/commands/status.ts
780
1102
  async function setStatus(vault, ref, status) {
781
1103
  if (!isStatus(status)) {
@@ -790,11 +1112,17 @@ async function setStatus(vault, ref, status) {
790
1112
  const from = item.status;
791
1113
  const edits = statusEdits(from, status, item.frontmatter.has("prev_status"));
792
1114
  if (edits === null) {
793
- return { item, from, to: status, recorded: void 0, changed: false };
1115
+ return { item, from, to: status, recorded: void 0, changed: false, parentReady: void 0 };
794
1116
  }
795
1117
  const recorded = status === "done" ? from : void 0;
796
1118
  await editItem(item, edits);
797
- 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;
798
1126
  }
799
1127
 
800
1128
  // src/shared/notes.ts
@@ -839,9 +1167,17 @@ function appendNote(text, line2) {
839
1167
  if (content === "") return `${text.slice(0, start)}${eol}${eol}${line2}${eol}${text.slice(end)}`;
840
1168
  const trailing = /(?:\r?\n[ \t]*)*$/.exec(content)?.[0] ?? "";
841
1169
  const prose = content.slice(0, content.length - trailing.length);
842
- const insertion = prose === "" ? `${content}${line2}${eol}${next ? eol : ""}` : `${prose}${eol}${line2}${trailing || eol}`;
1170
+ const insertion = prose === "" ? `${eol}${eol}${line2}${eol}${next ? eol : ""}` : `${prose}${eol}${line2}${trailing || eol}`;
843
1171
  return text.slice(0, start) + insertion + text.slice(end);
844
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
+ }
845
1181
 
846
1182
  // src/cli/commands/claim-release.ts
847
1183
  async function claimItem(vault, ref, agent) {
@@ -850,12 +1186,16 @@ async function claimItem(vault, ref, agent) {
850
1186
  if (item.parent === null) throw new Error(`${item.relPath} is a root, and a root cannot be claimed.`);
851
1187
  const current = item.frontmatter.get("agent");
852
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
+ }
853
1193
  const edits = claimEdits(
854
1194
  item.status,
855
1195
  currentAgent,
856
1196
  agent,
857
1197
  item.frontmatter.has("prev_status"),
858
- item.board && vault.childrenOf(item).some((child) => child.status === "doing")
1198
+ item.board && vault.childrenOf(item).some((child) => child.status === "doing" && child.frontmatter.get("agent") !== agent)
859
1199
  );
860
1200
  if (edits === null) return { item, agent, from: item.status, to: "doing", changed: false };
861
1201
  await editItem(item, edits);
@@ -877,6 +1217,16 @@ async function releaseItem(vault, ref, reason, where) {
877
1217
  return { item, agent: current, from: item.status, to: "options", reason, where, changed: true };
878
1218
  }
879
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 };
1228
+ }
1229
+
880
1230
  // src/cli/commands/children.ts
881
1231
  function listChildren(vault, ref, options = {}) {
882
1232
  const { status, recursive = false, archived = false } = options;
@@ -929,6 +1279,12 @@ async function validate(vault) {
929
1279
  checkDefaultRoot(vault, report);
930
1280
  checkRoots(vault, report);
931
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
+ }
932
1288
  problems.sort((a, b) => a.relPath.localeCompare(b.relPath) || a.rule.localeCompare(b.rule));
933
1289
  const errorCount = problems.filter((p) => p.severity === "error").length;
934
1290
  return {
@@ -1011,14 +1367,11 @@ function checkItem(item, vault, report) {
1011
1367
  if (item.title === void 0) say("title-missing", "error", "has no title.");
1012
1368
  const rawStatus = item.frontmatter.get("status");
1013
1369
  const isRoot = !item.frontmatter.has("parent");
1014
- const isArea2 = item.frontmatter.get("area") === true;
1015
1370
  const rawArea = item.frontmatter.get("area");
1016
1371
  if (rawArea !== void 0 && rawArea !== true) {
1017
1372
  say("area-invalid", "error", `carries area: ${rawArea}. The only valid marker is area: true.`);
1018
1373
  }
1019
- if (isArea2 && rawStatus !== void 0) {
1020
- say("status-on-area", "error", "is an area and carries a status. Areas have no status; remove the status field.");
1021
- } else if (isRoot) {
1374
+ if (isRoot) {
1022
1375
  if (rawStatus !== void 0) {
1023
1376
  say(
1024
1377
  "status-on-root",
@@ -1026,9 +1379,9 @@ function checkItem(item, vault, report) {
1026
1379
  `is a root and carries status "${rawStatus}". A root is not a card in anyone's column, so it takes no status.`
1027
1380
  );
1028
1381
  }
1029
- } else if (!isArea2 && rawStatus === void 0) {
1382
+ } else if (rawStatus === void 0) {
1030
1383
  say("status-missing", "error", `has no status. Use one of: ${STATUSES.join(", ")}.`);
1031
- } else if (!isArea2 && !isStatus(rawStatus)) {
1384
+ } else if (!isStatus(rawStatus)) {
1032
1385
  say(
1033
1386
  "status-invalid",
1034
1387
  "error",
@@ -1166,28 +1519,28 @@ function checkCycles(vault, report) {
1166
1519
  }
1167
1520
 
1168
1521
  // src/cli/commands/template.ts
1169
- import { readFile as readFile2 } from "node:fs/promises";
1170
- import { existsSync as existsSync3 } from "node:fs";
1171
- import { mkdir as mkdir2 } from "node:fs/promises";
1172
- 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";
1173
1526
  var TEMPLATES_FOLDER = "Templates";
1174
1527
  function listTemplates() {
1175
1528
  return TEMPLATES;
1176
1529
  }
1177
1530
  async function writeTemplates(vault) {
1178
- const folder = join4(vault.root, TEMPLATES_FOLDER);
1179
- await mkdir2(folder, { recursive: true });
1531
+ const folder = join5(vault.root, TEMPLATES_FOLDER);
1532
+ await mkdir3(folder, { recursive: true });
1180
1533
  const written = [];
1181
1534
  for (const template of TEMPLATES) {
1182
- const path = join4(folder, `${template.name}.md`);
1535
+ const path = join5(folder, `${template.name}.md`);
1183
1536
  const relPath = `${TEMPLATES_FOLDER}/${template.name}.md`;
1184
1537
  const next = renderVaultTemplate(template, vault.config.defaultRoot, vault.config.extraSections);
1185
- if (!existsSync3(path)) {
1538
+ if (!existsSync4(path)) {
1186
1539
  await writeAtomic(path, next);
1187
1540
  written.push({ name: template.name, relPath, outcome: "created" });
1188
1541
  continue;
1189
1542
  }
1190
- const current = await readFile2(path, "utf8");
1543
+ const current = await readFile4(path, "utf8");
1191
1544
  if (current === next) {
1192
1545
  written.push({ name: template.name, relPath, outcome: "unchanged" });
1193
1546
  continue;
@@ -1199,9 +1552,9 @@ async function writeTemplates(vault) {
1199
1552
  }
1200
1553
 
1201
1554
  // src/cli/commands/remove.ts
1202
- import { mkdir as mkdir3, rename as rename3 } from "node:fs/promises";
1203
- import { existsSync as existsSync4 } from "node:fs";
1204
- 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";
1205
1558
  var TRASH = ".trash";
1206
1559
  function subtree(vault, root) {
1207
1560
  const ordered = [];
@@ -1221,7 +1574,7 @@ function trashPath(vault, item) {
1221
1574
  const stem = item.stem;
1222
1575
  for (let n = 0; n < 1e3; n++) {
1223
1576
  const name = n === 0 ? `${stem}.md` : `${stem} ${n}.md`;
1224
- if (!existsSync4(join5(vault.root, TRASH, name))) return `${TRASH}/${name}`;
1577
+ if (!existsSync5(join6(vault.root, TRASH, name))) return `${TRASH}/${name}`;
1225
1578
  }
1226
1579
  return `${TRASH}/${stem} ${Date.now()}.md`;
1227
1580
  }
@@ -1246,8 +1599,8 @@ async function removeItem(vault, ref, options = {}) {
1246
1599
  for (const target of doomed) {
1247
1600
  const trashedTo = trashPath(vault, target);
1248
1601
  if (!dryRun) {
1249
- await mkdir3(join5(vault.root, TRASH), { recursive: true });
1250
- await rename3(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("/")));
1251
1604
  }
1252
1605
  removed.push({ item: target, trashedTo });
1253
1606
  }
@@ -1296,10 +1649,78 @@ async function archiveItem(vault, ref, undo) {
1296
1649
  return { item, archived, changed: true };
1297
1650
  }
1298
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
+
1299
1720
  // src/cli/commands/hook.ts
1300
1721
  import { execFile } from "node:child_process";
1301
- import { existsSync as existsSync5 } from "node:fs";
1302
- import { chmod, mkdir as mkdir4, readFile as readFile3, rm, writeFile as writeFile3 } 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";
1303
1724
  import { dirname as dirname3, resolve } from "node:path";
1304
1725
  import { promisify } from "node:util";
1305
1726
  var run = promisify(execFile);
@@ -1331,8 +1752,8 @@ exec ${shellQuote(process.execPath)} ${shellQuote(entry)} validate --vault ${she
1331
1752
  async function hookStatus(vault) {
1332
1753
  const git = await gitHook(vault);
1333
1754
  if (!git) return { vault, gitRepo: null, preCommit: "not installed" };
1334
- const present = existsSync5(git.path);
1335
- 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"));
1336
1757
  return {
1337
1758
  vault,
1338
1759
  gitRepo: git.top,
@@ -1342,28 +1763,28 @@ async function hookStatus(vault) {
1342
1763
  async function installHook(vault, entry, force) {
1343
1764
  const git = await gitHook(vault);
1344
1765
  if (!git) throw new Error(`${vault} is not inside a Git repository. Run: git -C ${shellQuote(vault)} init`);
1345
- if (existsSync5(git.path)) {
1346
- const current = await readFile3(git.path, "utf8");
1766
+ if (existsSync6(git.path)) {
1767
+ const current = await readFile5(git.path, "utf8");
1347
1768
  if (!isOurHook(current) && !force) {
1348
1769
  throw new Error(`${git.path} already exists and is not ours. Read it, then pass --force to replace it.`);
1349
1770
  }
1350
1771
  }
1351
- await mkdir4(dirname3(git.path), { recursive: true });
1772
+ await mkdir5(dirname3(git.path), { recursive: true });
1352
1773
  await writeFile3(git.path, hookBody(vault, entry), "utf8");
1353
1774
  await chmod(git.path, 493);
1354
1775
  return git.path;
1355
1776
  }
1356
1777
  async function uninstallHook(vault) {
1357
1778
  const git = await gitHook(vault);
1358
- if (!git || !existsSync5(git.path) || !isOurHook(await readFile3(git.path, "utf8"))) return null;
1359
- await rm(git.path);
1779
+ if (!git || !existsSync6(git.path) || !isOurHook(await readFile5(git.path, "utf8"))) return null;
1780
+ await rm2(git.path);
1360
1781
  return git.path;
1361
1782
  }
1362
1783
 
1363
1784
  // src/cli/commands/setup.ts
1364
- import { cp, lstat, mkdir as mkdir5, readFile as readFile4, rename as rename4, rm as rm2, writeFile as writeFile4 } from "node:fs/promises";
1785
+ import { cp, lstat, mkdir as mkdir6, readFile as readFile6, rename as rename4, rm as rm3, writeFile as writeFile4 } from "node:fs/promises";
1365
1786
  import { homedir as homedir2, platform } from "node:os";
1366
- import { dirname as dirname4, join as join6, resolve as resolve2 } from "node:path";
1787
+ import { dirname as dirname4, join as join7, resolve as resolve2 } from "node:path";
1367
1788
  import { fileURLToPath } from "node:url";
1368
1789
  import { createInterface } from "node:readline/promises";
1369
1790
  import { stdin, stdout } from "node:process";
@@ -1371,11 +1792,11 @@ function detectObsidianRegistryPath(location) {
1371
1792
  if (location.registryPath) return location.registryPath;
1372
1793
  switch (location.platform) {
1373
1794
  case "darwin":
1374
- return join6(location.home, "Library", "Application Support", "obsidian", "obsidian.json");
1795
+ return join7(location.home, "Library", "Application Support", "obsidian", "obsidian.json");
1375
1796
  case "linux":
1376
- return join6(location.home, ".config", "obsidian", "obsidian.json");
1797
+ return join7(location.home, ".config", "obsidian", "obsidian.json");
1377
1798
  case "win32":
1378
- return join6(location.appData ?? join6(location.home, "AppData", "Roaming"), "obsidian", "obsidian.json");
1799
+ return join7(location.appData ?? join7(location.home, "AppData", "Roaming"), "obsidian", "obsidian.json");
1379
1800
  default:
1380
1801
  throw new Error(`wi setup does not know the Obsidian registry location for ${location.platform}.`);
1381
1802
  }
@@ -1387,7 +1808,7 @@ async function readObsidianVaults(location) {
1387
1808
  const path = detectObsidianRegistryPath(location);
1388
1809
  let text;
1389
1810
  try {
1390
- text = await readFile4(path, "utf8");
1811
+ text = await readFile6(path, "utf8");
1391
1812
  } catch (error) {
1392
1813
  if (isMissingFile2(error)) return [];
1393
1814
  throw error;
@@ -1423,28 +1844,28 @@ async function installSkillCopy(source, destination, force) {
1423
1844
  }
1424
1845
  if (existing?.isSymbolicLink()) return "dev-link";
1425
1846
  if (existing) {
1426
- const managed = existing.isDirectory() && await readFile4(join6(destination, MANAGED_MARKER), "utf8").then((value) => value === MANAGED_TEXT).catch(() => false);
1847
+ const managed = existing.isDirectory() && await readFile6(join7(destination, MANAGED_MARKER), "utf8").then((value) => value === MANAGED_TEXT).catch(() => false);
1427
1848
  if (!managed && !force) {
1428
1849
  throw new Error(`${destination} already exists and was not installed by wi setup. Read it, then pass --force to replace it.`);
1429
1850
  }
1430
1851
  if (managed) {
1431
- const same = await readFile4(join6(destination, "SKILL.md"), "utf8").catch(() => null);
1432
- const expected = await readFile4(join6(source, "SKILL.md"), "utf8");
1852
+ const same = await readFile6(join7(destination, "SKILL.md"), "utf8").catch(() => null);
1853
+ const expected = await readFile6(join7(source, "SKILL.md"), "utf8");
1433
1854
  if (same === expected) return "already-installed";
1434
1855
  }
1435
- await rm2(destination, { recursive: true, force: true });
1856
+ await rm3(destination, { recursive: true, force: true });
1436
1857
  }
1437
- await mkdir5(dirname4(destination), { recursive: true });
1858
+ await mkdir6(dirname4(destination), { recursive: true });
1438
1859
  await cp(source, destination, { recursive: true, errorOnExist: true, force: false });
1439
- await writeFile4(join6(destination, MANAGED_MARKER), MANAGED_TEXT, "utf8");
1860
+ await writeFile4(join7(destination, MANAGED_MARKER), MANAGED_TEXT, "utf8");
1440
1861
  return "installed";
1441
1862
  }
1442
1863
  async function writeDefaultVault(vault) {
1443
1864
  const root = wiConfigDir();
1444
- const path = join6(root, "config.json");
1865
+ const path = join7(root, "config.json");
1445
1866
  let current = {};
1446
1867
  try {
1447
- const parsed = JSON.parse(await readFile4(path, "utf8"));
1868
+ const parsed = JSON.parse(await readFile6(path, "utf8"));
1448
1869
  if (!isRecord(parsed)) throw new Error(`cannot update ${path}: expected a JSON object.`);
1449
1870
  current = parsed;
1450
1871
  } catch (error) {
@@ -1456,7 +1877,7 @@ async function writeDefaultVault(vault) {
1456
1877
  throw error;
1457
1878
  }
1458
1879
  }
1459
- await mkdir5(root, { recursive: true });
1880
+ await mkdir6(root, { recursive: true });
1460
1881
  const temporary = `${path}.tmp-${process.pid}`;
1461
1882
  await writeFile4(temporary, `${JSON.stringify({ ...current, defaultVault: resolve2(vault) }, null, 2)}
1462
1883
  `, "utf8");
@@ -1520,7 +1941,7 @@ async function runSetup(options) {
1520
1941
  let packagedSkill;
1521
1942
  for (const candidate of skillCandidates) {
1522
1943
  try {
1523
- await readFile4(join6(candidate, "SKILL.md"), "utf8");
1944
+ await readFile6(join7(candidate, "SKILL.md"), "utf8");
1524
1945
  packagedSkill = candidate;
1525
1946
  break;
1526
1947
  } catch (error) {
@@ -1529,8 +1950,8 @@ async function runSetup(options) {
1529
1950
  }
1530
1951
  if (!packagedSkill) throw new Error("the recursive-board skill is missing from this installation.");
1531
1952
  const destinations = [
1532
- join6(home, ".claude", "skills", "recursive-board"),
1533
- join6(home, ".agents", "skills", "recursive-board")
1953
+ join7(home, ".claude", "skills", "recursive-board"),
1954
+ join7(home, ".agents", "skills", "recursive-board")
1534
1955
  ];
1535
1956
  const outcomes = await Promise.all(destinations.map((destination) => installSkillCopy(packagedSkill, destination, options.force)));
1536
1957
  const configPath = await writeDefaultVault(vault);
@@ -1557,15 +1978,23 @@ var HELP = `wi \u2014 the Recursive Board CLI
1557
1978
  Usage
1558
1979
  wi setup [--yes] [--vault <path>] [--force]
1559
1980
  wi new <title> [--parent <ref>] [--status <s>] [--template <t>] [--owner <o>] [--agent <a>]
1560
- [--priority <n>]
1981
+ [--priority <n>] [--objective <text>] [--context <text>]... [--criteria <text>]...
1982
+ [--strict]
1561
1983
  wi status <ref> <status>
1984
+ wi note <ref> <text> [--agent <name>]
1985
+ wi area <ref> [--off]
1562
1986
  wi claim <ref> --agent <name>
1987
+ wi agents
1563
1988
  wi release <ref> --reason <text> [--where <branch-or-path>]
1564
1989
  wi move <ref> --to <ref>
1565
1990
  wi archive <ref> [--undo]
1991
+ wi promote <ref>
1992
+ wi demote <ref>
1566
1993
  wi rm <ref> [--recursive] [--dry-run]
1567
1994
  wi children [<ref>] [--status <s>] [--tree] [--archived]
1568
1995
  wi validate
1996
+ wi retag [--dry-run]
1997
+ wi graph
1569
1998
  wi template [list|write]
1570
1999
  wi hook <install|uninstall|status> [--force]
1571
2000
  wi here [--board <ref>] [--vault <path>]
@@ -1584,6 +2013,19 @@ Options
1584
2013
  -V, --version Print the version.
1585
2014
 
1586
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.
1587
2029
  Unticking a done item is \`wi status <ref> <its prev_status>\`, which also clears the record.
1588
2030
  \`wi validate\` exits 1 when the vault has errors, so it works as a pre-commit hook.
1589
2031
  \`wi template write\` regenerates Templates/ from the code, which is authoritative.
@@ -1594,10 +2036,18 @@ Notes
1594
2036
  folder, because the index cannot read it and may be missing a work item. Let the sync
1595
2037
  client download the file, or delete the stray file, then retry. There is no --force.
1596
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.
1597
2047
  \`wi new\` warns when such a file exists because a new id or filename may clash with it.
1598
2048
  \`wi here\` reads or sets this repository's vault and board pointer in your user config.
1599
2049
  `;
1600
- var VERSION = "0.2.0";
2050
+ var VERSION = "0.3.0";
1601
2051
  var UsageError = class extends Error {
1602
2052
  };
1603
2053
  async function main(argv) {
@@ -1615,11 +2065,16 @@ async function main(argv) {
1615
2065
  where: { type: "string" },
1616
2066
  priority: { type: "string" },
1617
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 },
1618
2072
  vault: { type: "string" },
1619
2073
  board: { type: "string" },
1620
2074
  tree: { type: "boolean", default: false },
1621
2075
  archived: { type: "boolean", default: false },
1622
2076
  undo: { type: "boolean", default: false },
2077
+ off: { type: "boolean", default: false },
1623
2078
  recursive: { type: "boolean", short: "r", default: false },
1624
2079
  "dry-run": { type: "boolean", default: false },
1625
2080
  json: { type: "boolean", default: false },
@@ -1643,6 +2098,10 @@ async function main(argv) {
1643
2098
  throw new UsageError("--force applies only to wi hook install or wi setup.");
1644
2099
  }
1645
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.");
2104
+ }
1646
2105
  if (command === "setup") {
1647
2106
  if (rest.length > 0) throw new UsageError("wi setup takes options only. Run wi setup --help for usage.");
1648
2107
  await runSetup({
@@ -1660,14 +2119,24 @@ async function main(argv) {
1660
2119
  return runNew(vault, rest, values, json);
1661
2120
  case "status":
1662
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);
1663
2126
  case "claim":
1664
2127
  return runClaim(vault, rest, values, json);
2128
+ case "agents":
2129
+ return runAgents(vault, rest, json);
1665
2130
  case "release":
1666
2131
  return runRelease(vault, rest, values, json);
1667
2132
  case "move":
1668
- return runMove(vault, rest, values, json);
2133
+ return withRetagHint(vault, () => runMove(vault, rest, values, json));
1669
2134
  case "archive":
1670
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);
1671
2140
  case "rm":
1672
2141
  return runRemove(vault, rest, values, json);
1673
2142
  case "children":
@@ -1679,6 +2148,10 @@ async function main(argv) {
1679
2148
  return runChildren(vault, rest, values, json);
1680
2149
  case "validate":
1681
2150
  return runValidate(vault, json);
2151
+ case "retag":
2152
+ return runRetag(vault, rest, values, json);
2153
+ case "graph":
2154
+ return runGraph(vault, rest, json);
1682
2155
  case "template":
1683
2156
  return runTemplate(vault, rest, json);
1684
2157
  case "hook":
@@ -1758,7 +2231,13 @@ async function runNew(vault, rest, values, json) {
1758
2231
  ...typeof values["owner"] === "string" ? { owner: values["owner"] } : {},
1759
2232
  ...typeof values["agent"] === "string" ? { agent: values["agent"] } : {},
1760
2233
  ...typeof values["template"] === "string" ? { template: values["template"] } : {},
1761
- ...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
1762
2241
  });
1763
2242
  if (vault.unaccounted.length > 0) {
1764
2243
  process.stderr.write(
@@ -1766,9 +2245,28 @@ async function runNew(vault, rest, values, json) {
1766
2245
  `
1767
2246
  );
1768
2247
  }
1769
- if (json) print({ id: created.id, path: created.relPath, parent: created.parentStem });
1770
- 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)
1771
2268
  `);
2269
+ }
1772
2270
  return 0;
1773
2271
  }
1774
2272
  async function runStatus(vault, rest, json) {
@@ -1784,7 +2282,8 @@ async function runStatus(vault, rest, json) {
1784
2282
  from: change.from ?? null,
1785
2283
  to: change.to,
1786
2284
  prev_status: change.recorded ?? null,
1787
- changed: change.changed
2285
+ changed: change.changed,
2286
+ parent_ready: change.parentReady?.id ?? null
1788
2287
  });
1789
2288
  } else if (!change.changed) {
1790
2289
  process.stdout.write(`${label(change.item)} is already ${change.to}. Nothing written.
@@ -1792,6 +2291,35 @@ async function runStatus(vault, rest, json) {
1792
2291
  } else {
1793
2292
  const recorded = change.recorded ? ` (prev_status: ${change.recorded})` : "";
1794
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})` : ""}
1795
2323
  `);
1796
2324
  }
1797
2325
  return 0;
@@ -1808,6 +2336,8 @@ async function runClaim(vault, rest, values, json) {
1808
2336
  const ref = rest.join(" ").trim();
1809
2337
  if (ref === "") throw new UsageError("wi claim needs a <ref> and --agent <name>.");
1810
2338
  const agent = singleLineOption(values, "agent");
2339
+ const maxAgents = maxAgentsForRun(vault);
2340
+ const active = activeAgentNames(vault);
1811
2341
  const change = await claimItem(vault, ref, agent);
1812
2342
  if (json) print({
1813
2343
  id: change.item.id,
@@ -1819,6 +2349,53 @@ async function runClaim(vault, rest, values, json) {
1819
2349
  });
1820
2350
  else process.stdout.write(change.changed ? `${label(change.item)} ${change.from ?? "\u2014"} \u2192 doing (agent: ${agent})
1821
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.
2355
+ `);
2356
+ }
2357
+ return 0;
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)}
1822
2399
  `);
1823
2400
  return 0;
1824
2401
  }
@@ -1879,6 +2456,19 @@ async function runArchive(vault, rest, values, json) {
1879
2456
  }
1880
2457
  return 0;
1881
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
+ }
1882
2472
  async function runRemove(vault, rest, values, json) {
1883
2473
  const ref = rest.join(" ").trim();
1884
2474
  if (ref === "") throw new UsageError("wi rm needs a <ref>.");
@@ -1936,7 +2526,8 @@ function runChildren(vault, rest, values, json) {
1936
2526
  status: row.item.status ?? null,
1937
2527
  children: row.childCount,
1938
2528
  depth: row.depth,
1939
- archived: row.archived
2529
+ archived: row.archived,
2530
+ blocked: row.item.frontmatter.get("blocked") === true
1940
2531
  }))
1941
2532
  });
1942
2533
  return 0;
@@ -1963,6 +2554,39 @@ function runChildren(vault, rest, values, json) {
1963
2554
  `);
1964
2555
  return 0;
1965
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
+ }
1966
2590
  async function runValidate(vault, json) {
1967
2591
  const report = await validate(vault);
1968
2592
  if (json) {
@@ -2073,7 +2697,8 @@ function row3(row) {
2073
2697
  const kids = row.childCount > 0 ? ` (${row.childCount})` : "";
2074
2698
  const board = row.item.board ? " [board]" : "";
2075
2699
  const archived = row.archived ? " [archived]" : "";
2076
- 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}`;
2077
2702
  }
2078
2703
  function print(value) {
2079
2704
  process.stdout.write(`${JSON.stringify(value, null, 2)}