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/README.md +28 -6
- package/dist/wi/wi.js +756 -131
- package/package.json +1 -1
- package/skills/recursive-board/SKILL.md +71 -11
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
|
|
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
|
-
|
|
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
|
|
554
|
-
import { join as
|
|
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
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
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
|
|
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
|
-
|
|
630
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
|
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
|
-
${
|
|
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,
|
|
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 (
|
|
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 === "" ? `${
|
|
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 (
|
|
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 (
|
|
1382
|
+
} else if (rawStatus === void 0) {
|
|
1030
1383
|
say("status-missing", "error", `has no status. Use one of: ${STATUSES.join(", ")}.`);
|
|
1031
|
-
} else if (!
|
|
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
|
|
1170
|
-
import { existsSync as
|
|
1171
|
-
import { mkdir as
|
|
1172
|
-
import { join as
|
|
1522
|
+
import { readFile as readFile4 } from "node:fs/promises";
|
|
1523
|
+
import { existsSync as existsSync4 } from "node:fs";
|
|
1524
|
+
import { mkdir as mkdir3 } from "node:fs/promises";
|
|
1525
|
+
import { join as join5 } from "node:path";
|
|
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 =
|
|
1179
|
-
await
|
|
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 =
|
|
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 (!
|
|
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
|
|
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
|
|
1203
|
-
import { existsSync as
|
|
1204
|
-
import { join as
|
|
1555
|
+
import { mkdir as mkdir4, rename as rename3 } from "node:fs/promises";
|
|
1556
|
+
import { existsSync as existsSync5 } from "node:fs";
|
|
1557
|
+
import { join as join6 } from "node:path";
|
|
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 (!
|
|
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
|
|
1250
|
-
await rename3(target.path,
|
|
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
|
|
1302
|
-
import { chmod, mkdir as
|
|
1722
|
+
import { existsSync as existsSync6 } from "node:fs";
|
|
1723
|
+
import { chmod, mkdir as mkdir5, readFile as readFile5, rm as rm2, writeFile as writeFile3 } from "node:fs/promises";
|
|
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 =
|
|
1335
|
-
const ours = present && isOurHook(await
|
|
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 (
|
|
1346
|
-
const current = await
|
|
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
|
|
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 || !
|
|
1359
|
-
await
|
|
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
|
|
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
|
|
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
|
|
1795
|
+
return join7(location.home, "Library", "Application Support", "obsidian", "obsidian.json");
|
|
1375
1796
|
case "linux":
|
|
1376
|
-
return
|
|
1797
|
+
return join7(location.home, ".config", "obsidian", "obsidian.json");
|
|
1377
1798
|
case "win32":
|
|
1378
|
-
return
|
|
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
|
|
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
|
|
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
|
|
1432
|
-
const expected = await
|
|
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
|
|
1856
|
+
await rm3(destination, { recursive: true, force: true });
|
|
1436
1857
|
}
|
|
1437
|
-
await
|
|
1858
|
+
await mkdir6(dirname4(destination), { recursive: true });
|
|
1438
1859
|
await cp(source, destination, { recursive: true, errorOnExist: true, force: false });
|
|
1439
|
-
await writeFile4(
|
|
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 =
|
|
1865
|
+
const path = join7(root, "config.json");
|
|
1445
1866
|
let current = {};
|
|
1446
1867
|
try {
|
|
1447
|
-
const parsed = JSON.parse(await
|
|
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
|
|
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
|
|
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
|
-
|
|
1533
|
-
|
|
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
|
-
|
|
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.
|
|
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 (
|
|
1770
|
-
|
|
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
|
-
|
|
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)}
|