@davidbalzan/groundwork 0.3.1
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/LICENSE +21 -0
- package/README.md +323 -0
- package/docs/DECISIONS.md +170 -0
- package/package.json +38 -0
- package/payload/doc-templates/COMMANDS.md +419 -0
- package/payload/doc-templates/DECISIONS.md +168 -0
- package/payload/doc-templates/FACTS.md +43 -0
- package/payload/doc-templates/GROUNDWORK_METHODOLOGY.md +1300 -0
- package/payload/doc-templates/STACK_MAP.md +90 -0
- package/payload/doc-templates/WORKSTREAMS.md +79 -0
- package/payload/doc-templates/_INDEX.md +54 -0
- package/payload/doc-templates/phases/README.md +36 -0
- package/payload/doc-templates/phases/templates/README.md +63 -0
- package/payload/doc-templates/phases/templates/TASK_TEMPLATE.md +302 -0
- package/payload/doc-templates/phases/templates/task_template_prompt.md +229 -0
- package/payload/doc-templates/templates/ARCHITECTURE_GUIDE_TEMPLATE.md +250 -0
- package/payload/doc-templates/templates/DESIGN_SYSTEM_TEMPLATE.md +336 -0
- package/payload/doc-templates/templates/DONE_TEMPLATE.md +21 -0
- package/payload/doc-templates/templates/PHASES_README_TEMPLATE.md +144 -0
- package/payload/doc-templates/templates/PHASE_README_TEMPLATE.md +142 -0
- package/payload/doc-templates/templates/PRD_TEMPLATE.md +348 -0
- package/payload/doc-templates/templates/PRODUCTION_ROADMAP_TEMPLATE.md +168 -0
- package/payload/doc-templates/templates/QUEUE_TEMPLATE.md +17 -0
- package/payload/doc-templates/templates/TECH_STACK_TEMPLATE.md +199 -0
- package/payload/scripts/check-task.mjs +98 -0
- package/payload/scripts/check-versions.mjs +113 -0
- package/payload/scripts/phase-status.mjs +69 -0
- package/payload/scripts/set-fact.mjs +86 -0
- package/payload/skills/add-data-layer/SKILL.md +129 -0
- package/payload/skills/check-task/SKILL.md +35 -0
- package/payload/skills/check-versions/SKILL.md +47 -0
- package/payload/skills/create-prd/SKILL.md +90 -0
- package/payload/skills/domain-model/SKILL.md +90 -0
- package/payload/skills/kickstart/SKILL.md +157 -0
- package/payload/skills/log-decision/SKILL.md +65 -0
- package/payload/skills/next/SKILL.md +65 -0
- package/payload/skills/plan-phase/SKILL.md +108 -0
- package/payload/skills/remember/SKILL.md +77 -0
- package/payload/skills/start-session/SKILL.md +52 -0
- package/payload/skills/update-workstreams/SKILL.md +60 -0
- package/src/cli.mjs +115 -0
- package/src/commands/add.mjs +39 -0
- package/src/commands/artifacts.mjs +24 -0
- package/src/commands/doctor.mjs +292 -0
- package/src/commands/init.mjs +147 -0
- package/src/commands/knowledge.mjs +148 -0
- package/src/commands/list.mjs +61 -0
- package/src/commands/status.mjs +96 -0
- package/src/commands/update.mjs +128 -0
- package/src/lib/adr-tripwire.mjs +171 -0
- package/src/lib/artifacts.mjs +124 -0
- package/src/lib/config.mjs +43 -0
- package/src/lib/fs.mjs +46 -0
- package/src/lib/log.mjs +22 -0
- package/src/lib/paths.mjs +36 -0
- package/src/lib/progress.mjs +26 -0
- package/src/lib/skills.mjs +42 -0
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { TARGET, PKG_ROOT } from "../lib/paths.mjs";
|
|
4
|
+
import { exists, readText, walk, listDirs } from "../lib/fs.mjs";
|
|
5
|
+
import { countCheckboxes, progressBar } from "../lib/progress.mjs";
|
|
6
|
+
import { ARTIFACTS } from "../lib/artifacts.mjs";
|
|
7
|
+
import { log, bold, green, yellow, dim, cyan } from "../lib/log.mjs";
|
|
8
|
+
import { adrTripwire } from "../lib/adr-tripwire.mjs";
|
|
9
|
+
import { parseFactsDoc, parseWorkDoc, workDocIssues, workDocLegacyWriteIssues } from "@davidbalzan/groundwork-seam";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* `groundwork doctor` — flag drift between the docs and reality. Offline + deterministic.
|
|
13
|
+
* `--json` emits { checks: [{check, level, items[]}], ok } for kit-console.
|
|
14
|
+
*/
|
|
15
|
+
export function collectDoctor(targetDir) {
|
|
16
|
+
const root = path.resolve(targetDir || ".");
|
|
17
|
+
const docs = path.join(root, TARGET.docs);
|
|
18
|
+
if (!exists(docs)) {
|
|
19
|
+
return {
|
|
20
|
+
ok: false,
|
|
21
|
+
exit: 1,
|
|
22
|
+
error: "No docs/ here. Run `groundwork init` first.",
|
|
23
|
+
checks: [],
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const checks = [];
|
|
28
|
+
const push = (check, level, items) => checks.push({ check, level, items });
|
|
29
|
+
const mdFiles = [...walk(docs)].filter((f) => f.endsWith(".md"));
|
|
30
|
+
|
|
31
|
+
const valid = validLinkTargets(docs, mdFiles);
|
|
32
|
+
const orphanItems = [];
|
|
33
|
+
const seen = new Set();
|
|
34
|
+
for (const rel of mdFiles) {
|
|
35
|
+
const text = readText(path.join(docs, rel));
|
|
36
|
+
for (const target of wikilinks(text)) {
|
|
37
|
+
if (resolves(target, valid)) continue;
|
|
38
|
+
const key = `${rel}::${target.toLowerCase()}`;
|
|
39
|
+
if (seen.has(key)) continue;
|
|
40
|
+
seen.add(key);
|
|
41
|
+
orphanItems.push(`${rel}: [[${target}]] → no such doc`);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
push("orphan-wikilinks", orphanItems.length ? "warn" : "ok", orphanItems.length ? orphanItems : ["all wikilinks resolve"]);
|
|
45
|
+
|
|
46
|
+
const phaseDir = path.join(docs, "phases");
|
|
47
|
+
const phaseItems = [];
|
|
48
|
+
let phaseWarn = false;
|
|
49
|
+
if (exists(phaseDir)) {
|
|
50
|
+
for (const rel of walk(phaseDir)) {
|
|
51
|
+
if (!/PHASE.*TASKS\.md$/i.test(rel)) continue;
|
|
52
|
+
const { done, total } = countCheckboxes(readText(path.join(phaseDir, rel)));
|
|
53
|
+
const pct = total ? Math.round((done / total) * 100) : 0;
|
|
54
|
+
const label = rel.split(path.sep)[0];
|
|
55
|
+
const line = `${label} ${progressBar(pct)} ${pct}% (${done}/${total})`;
|
|
56
|
+
if (total > 0 && done === total) {
|
|
57
|
+
phaseWarn = true;
|
|
58
|
+
phaseItems.push(`${line} — 100% done; mark it complete in the roadmap & record it in DONE.md`);
|
|
59
|
+
} else phaseItems.push(line);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
if (!phaseItems.length) push("phase-progress", "ok", ["no phase task files yet (run /plan-phase)"]);
|
|
63
|
+
else push("phase-progress", phaseWarn ? "warn" : "ok", phaseItems);
|
|
64
|
+
|
|
65
|
+
const coverageItems = [];
|
|
66
|
+
if (exists(phaseDir)) {
|
|
67
|
+
for (const dir of listDirs(phaseDir)) {
|
|
68
|
+
if (!/^phase\d+/i.test(dir)) continue;
|
|
69
|
+
const files = fs.readdirSync(path.join(phaseDir, dir));
|
|
70
|
+
const hasTasks = files.some((f) => /PHASE.*TASKS\.md$/i.test(f));
|
|
71
|
+
const hasReadme = files.some((f) => /^README.md$/i.test(f));
|
|
72
|
+
if (!hasTasks) coverageItems.push(`${dir}: no PHASE*_TASKS.md — run /plan-phase`);
|
|
73
|
+
else if (!hasReadme) coverageItems.push(`${dir}: tasks present but no README.md`);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
const prd = mdFiles.find((f) => /(^|\/)PRD(_.*)?\.md$/i.test(f));
|
|
77
|
+
if (prd) {
|
|
78
|
+
const t = readText(path.join(docs, prd)).toLowerCase();
|
|
79
|
+
const need = [
|
|
80
|
+
["problem", /problem/],
|
|
81
|
+
["goals", /goals?/],
|
|
82
|
+
["non-goals / out of scope", /non-goals|out of scope/],
|
|
83
|
+
["functional requirements", /functional requirements|requirements/],
|
|
84
|
+
["success metrics", /success metrics|kpi|success criteria/],
|
|
85
|
+
["risks", /risks?/],
|
|
86
|
+
];
|
|
87
|
+
const missing = need.filter(([, re]) => !re.test(t)).map(([n]) => n);
|
|
88
|
+
if (missing.length) coverageItems.push(`PRD missing section(s): ${missing.join(", ")}`);
|
|
89
|
+
} else coverageItems.push("no PRD yet (run /create-prd)");
|
|
90
|
+
const coverageInfoOnly = coverageItems.length === 1 && coverageItems[0].startsWith("no PRD");
|
|
91
|
+
if (!coverageItems.length) push("spec-coverage", "ok", ["phase structure + PRD sections look complete"]);
|
|
92
|
+
else push("spec-coverage", coverageInfoOnly ? "info" : "warn", coverageItems);
|
|
93
|
+
|
|
94
|
+
const marker = path.join(docs, ".groundwork", "VERSION");
|
|
95
|
+
const cliV = pkgVersion();
|
|
96
|
+
if (exists(marker)) {
|
|
97
|
+
const got = readText(marker).trim();
|
|
98
|
+
if (cmpVer(got, cliV) < 0)
|
|
99
|
+
push("version-marker", "warn", [`installed v${got}, CLI is v${cliV} — run \`groundwork update --all --docs\``]);
|
|
100
|
+
else push("version-marker", "ok", [`up to date (v${got})`]);
|
|
101
|
+
} else push("version-marker", "warn", ["no VERSION marker — re-run `groundwork init`/`update`"]);
|
|
102
|
+
|
|
103
|
+
const sm = path.join(docs, "STACK_MAP.md");
|
|
104
|
+
if (exists(sm)) {
|
|
105
|
+
const m = readText(sm).match(/Last audited[^\d]*(\d{4})-(\d{2})-(\d{2})/);
|
|
106
|
+
if (!m) push("stack-map-freshness", "warn", ["STACK_MAP has no `Last audited` date — run `/check-versions`"]);
|
|
107
|
+
else {
|
|
108
|
+
const days = daysSince(+m[1], +m[2], +m[3]);
|
|
109
|
+
if (days > 90) push("stack-map-freshness", "warn", [`STACK_MAP last audited ${days} days ago — run \`/check-versions\``]);
|
|
110
|
+
else push("stack-map-freshness", "ok", [`STACK_MAP audited ${days} days ago`]);
|
|
111
|
+
}
|
|
112
|
+
} else push("stack-map-freshness", "info", ["no STACK_MAP.md yet (run /kickstart)"]);
|
|
113
|
+
|
|
114
|
+
const wsFile = path.join(docs, "WORKSTREAMS.md");
|
|
115
|
+
if (exists(wsFile)) {
|
|
116
|
+
const wsDoc = parseWorkDoc(readText(wsFile));
|
|
117
|
+
const wsIssues = [...workDocIssues(wsDoc), ...workDocLegacyWriteIssues(wsDoc)];
|
|
118
|
+
if (wsIssues.length) push("workstreams", "warn", wsIssues);
|
|
119
|
+
else push("workstreams", "ok", ["write grammar is workstreams.v1"]);
|
|
120
|
+
} else push("workstreams", "info", ["no WORKSTREAMS.md yet (run `groundwork update --docs`)"]);
|
|
121
|
+
|
|
122
|
+
const factsFile = path.join(docs, "FACTS.md");
|
|
123
|
+
if (exists(factsFile)) {
|
|
124
|
+
const { entries, issues } = parseFactsDoc(readText(factsFile));
|
|
125
|
+
if (issues.length) push("facts-freshness", "warn", issues);
|
|
126
|
+
else
|
|
127
|
+
push(
|
|
128
|
+
"facts-freshness",
|
|
129
|
+
"ok",
|
|
130
|
+
[entries.length ? `${entries.length} fact(s), all verified and fresh` : "no facts recorded yet"],
|
|
131
|
+
);
|
|
132
|
+
} else push("facts-freshness", "info", ["no FACTS.md yet (run `groundwork update --docs`)"]);
|
|
133
|
+
|
|
134
|
+
const decisions = path.join(docs, "DECISIONS.md");
|
|
135
|
+
if (exists(decisions)) {
|
|
136
|
+
const acceptedIds = new Set();
|
|
137
|
+
if (exists(factsFile))
|
|
138
|
+
for (const m of readText(factsFile).matchAll(/^- `(adr-\d+-accepted-deviation)` — /gm)) acceptedIds.add(m[1]);
|
|
139
|
+
const hits = adrTripwire({ decisionsText: readText(decisions), root, acceptedFactIds: acceptedIds });
|
|
140
|
+
const byAdr = new Map();
|
|
141
|
+
for (const h of hits) (byAdr.get(h.adr) || byAdr.set(h.adr, []).get(h.adr)).push(h);
|
|
142
|
+
const adrItems = [];
|
|
143
|
+
for (const [adr, hs] of byAdr) {
|
|
144
|
+
const seenTok = new Set();
|
|
145
|
+
const uniq = hs.filter((h) => !seenTok.has(h.token) && seenTok.add(h.token));
|
|
146
|
+
const shown = uniq.slice(0, 3).map((h) => `\`${h.token}\` (${h.kind}: ${h.where}) ← "${h.alternative}"`);
|
|
147
|
+
const ev = shown.join("; ") + (uniq.length > 3 ? `; +${uniq.length - 3} more` : "");
|
|
148
|
+
adrItems.push(
|
|
149
|
+
`${adr} "${hs[0].title}" is Accepted but the tree contains its rejected alternative: ${ev} — supersede the ADR, or record fact \`${adr.toLowerCase()}-accepted-deviation\``,
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
if (adrItems.length) push("adr-tripwire", "warn", adrItems);
|
|
153
|
+
else push("adr-tripwire", "ok", ["no Accepted ADR contradicted by dependencies/paths"]);
|
|
154
|
+
} else push("adr-tripwire", "info", ["no DECISIONS.md yet (run /kickstart)"]);
|
|
155
|
+
|
|
156
|
+
const warnCount = checks.filter((c) => c.level === "warn").length;
|
|
157
|
+
return { ok: warnCount === 0, exit: warnCount === 0 ? 0 : 1, checks };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export function doctor(targetDir, opts = {}) {
|
|
161
|
+
const report = collectDoctor(targetDir);
|
|
162
|
+
if (opts.json) {
|
|
163
|
+
console.log(JSON.stringify(report, null, 2));
|
|
164
|
+
process.exitCode = report.exit;
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
if (report.error) {
|
|
168
|
+
log.warn(report.error);
|
|
169
|
+
process.exitCode = 1;
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
const titles = {
|
|
173
|
+
"orphan-wikilinks": "Wikilinks",
|
|
174
|
+
"phase-progress": "Phase progress",
|
|
175
|
+
"spec-coverage": "Spec coverage",
|
|
176
|
+
"version-marker": "Version",
|
|
177
|
+
"stack-map-freshness": "Versions audit",
|
|
178
|
+
workstreams: "Workstreams",
|
|
179
|
+
"facts-freshness": "Facts",
|
|
180
|
+
"adr-tripwire": "ADR tripwire",
|
|
181
|
+
};
|
|
182
|
+
for (const c of report.checks) {
|
|
183
|
+
log.heading(titles[c.check] ?? c.check);
|
|
184
|
+
for (const item of c.items) {
|
|
185
|
+
if (c.level === "warn") console.log(` ${yellow("⚠")} ${item}`);
|
|
186
|
+
else if (c.level === "info") log.info(dim(` ${item}`));
|
|
187
|
+
else if (c.check === "phase-progress" && item !== "no phase task files yet (run /plan-phase)")
|
|
188
|
+
console.log(` ${dim("·")} ${item}`);
|
|
189
|
+
else console.log(` ${green("✓")} ${item}`);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
log.heading("Summary");
|
|
193
|
+
if (report.ok) log.ok("No drift detected — docs match reality.");
|
|
194
|
+
else console.log(` ${yellow(String(report.checks.filter((c) => c.level === "warn").length))} issue(s) to look at.`);
|
|
195
|
+
process.exitCode = report.exit;
|
|
196
|
+
console.log(dim(" (offline checks; run `/check-versions` for dependency freshness)"));
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// ---------- helpers ----------
|
|
200
|
+
function wikilinks(text) {
|
|
201
|
+
const out = [];
|
|
202
|
+
for (const m of text.matchAll(/\[\[([^\]]+)\]\]/g)) {
|
|
203
|
+
let t = m[1].split("|")[0].split("#")[0].trim(); // strip alias + heading
|
|
204
|
+
if (!t) continue; // intra-doc heading link
|
|
205
|
+
out.push(t);
|
|
206
|
+
}
|
|
207
|
+
return out;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function validLinkTargets(docs, mdFiles) {
|
|
211
|
+
const set = new Set();
|
|
212
|
+
const add = (s) => set.add(s.toLowerCase());
|
|
213
|
+
// existing docs (basename + relative path, both without .md) + their frontmatter aliases
|
|
214
|
+
// (Obsidian resolves [[link]] by filename OR by a note's declared alias)
|
|
215
|
+
for (const rel of mdFiles) {
|
|
216
|
+
const noExt = rel.replace(/\.md$/, "").split(path.sep).join("/");
|
|
217
|
+
add(noExt);
|
|
218
|
+
add(noExt.split("/").pop());
|
|
219
|
+
for (const alias of frontmatterAliases(readText(path.join(docs, rel)))) add(alias);
|
|
220
|
+
}
|
|
221
|
+
// expected artifacts from the manifest (so links to not-yet-generated docs aren't flagged)
|
|
222
|
+
for (const a of ARTIFACTS) {
|
|
223
|
+
const base = a.path
|
|
224
|
+
.replace(/^docs\//, "")
|
|
225
|
+
.replace(/\s*\(.*\)\s*/g, "") // drop "(+ CONTEXT-MAP.md)"
|
|
226
|
+
.replace(/\.md$/, "")
|
|
227
|
+
.replace(/\/$/, "");
|
|
228
|
+
if (base) {
|
|
229
|
+
add(base);
|
|
230
|
+
add(base.split("/").pop());
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
// extra known names + phase placeholders handled in resolves()
|
|
234
|
+
["context-map", "phasen_tasks", "task_template", "prd"].forEach(add);
|
|
235
|
+
return set;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** Extract `aliases:` from a doc's YAML frontmatter (inline `[a, b]` or block `- a` form). */
|
|
239
|
+
function frontmatterAliases(text) {
|
|
240
|
+
const fm = text.match(/^---\n([\s\S]*?)\n---/);
|
|
241
|
+
if (!fm) return [];
|
|
242
|
+
const body = fm[1];
|
|
243
|
+
const out = [];
|
|
244
|
+
const inline = body.match(/^aliases:\s*\[([^\]]*)\]/m);
|
|
245
|
+
if (inline) {
|
|
246
|
+
for (const t of inline[1].split(",")) {
|
|
247
|
+
const v = t.trim().replace(/^["']|["']$/g, "");
|
|
248
|
+
if (v) out.push(v);
|
|
249
|
+
}
|
|
250
|
+
} else {
|
|
251
|
+
const lines = body.split("\n");
|
|
252
|
+
const i = lines.findIndex((l) => /^aliases:\s*$/.test(l));
|
|
253
|
+
if (i >= 0) {
|
|
254
|
+
for (let j = i + 1; j < lines.length; j++) {
|
|
255
|
+
const m = lines[j].match(/^\s*-\s*(.+)$/);
|
|
256
|
+
if (!m) break;
|
|
257
|
+
out.push(m[1].trim().replace(/^["']|["']$/g, ""));
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
return out;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function resolves(target, valid) {
|
|
265
|
+
let t = target.replace(/^\.\//, "").replace(/\.md$/, "").toLowerCase();
|
|
266
|
+
const base = t.split("/").pop();
|
|
267
|
+
if (valid.has(t) || valid.has(base)) return true;
|
|
268
|
+
if (/phase\d+/.test(base) || /^phases?\//.test(t)) return true; // phase refs
|
|
269
|
+
if (/_template$|template$/.test(base)) return true;
|
|
270
|
+
return false;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function pkgVersion() {
|
|
274
|
+
try {
|
|
275
|
+
return JSON.parse(readText(path.join(PKG_ROOT, "package.json"))).version;
|
|
276
|
+
} catch {
|
|
277
|
+
return "0.0.0";
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
function cmpVer(a, b) {
|
|
281
|
+
const pa = a.split(".").map(Number);
|
|
282
|
+
const pb = b.split(".").map(Number);
|
|
283
|
+
for (let i = 0; i < 3; i++) {
|
|
284
|
+
if ((pa[i] || 0) < (pb[i] || 0)) return -1;
|
|
285
|
+
if ((pa[i] || 0) > (pb[i] || 0)) return 1;
|
|
286
|
+
}
|
|
287
|
+
return 0;
|
|
288
|
+
}
|
|
289
|
+
function daysSince(y, mo, d) {
|
|
290
|
+
const then = new Date(y, mo - 1, d).getTime();
|
|
291
|
+
return Math.floor((Date.now() - then) / 86400000);
|
|
292
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import {
|
|
4
|
+
PAYLOAD_SKILLS,
|
|
5
|
+
PAYLOAD_DOCS,
|
|
6
|
+
PAYLOAD_SCRIPTS,
|
|
7
|
+
TARGET,
|
|
8
|
+
MINIMAL_SKILLS,
|
|
9
|
+
} from "../lib/paths.mjs";
|
|
10
|
+
import { copyFileSafe, walk, exists } from "../lib/fs.mjs";
|
|
11
|
+
import { loadSkills, mirrorFiles } from "../lib/skills.mjs";
|
|
12
|
+
import { writeArtifacts } from "./artifacts.mjs";
|
|
13
|
+
import { log, bold, cyan, dim } from "../lib/log.mjs";
|
|
14
|
+
|
|
15
|
+
/** Docs that are about Groundwork-the-product, not a per-project doc. */
|
|
16
|
+
const DOC_EXCLUDE = new Set(["README.md"]);
|
|
17
|
+
|
|
18
|
+
export function init(targetDir, flags) {
|
|
19
|
+
const root = path.resolve(targetDir || ".");
|
|
20
|
+
const minimal = flags.minimal;
|
|
21
|
+
const force = flags.force;
|
|
22
|
+
const only = minimal ? MINIMAL_SKILLS : null;
|
|
23
|
+
|
|
24
|
+
if (!exists(root)) {
|
|
25
|
+
log.err(`Target folder not found: ${root}`);
|
|
26
|
+
log.info(
|
|
27
|
+
dim(
|
|
28
|
+
" init installs into an existing repo — it won't create the target. " +
|
|
29
|
+
"Check the path, or `mkdir` it first (a missing folder usually means a typo or wrong directory)."
|
|
30
|
+
)
|
|
31
|
+
);
|
|
32
|
+
process.exitCode = 1;
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
log.heading(`Installing Groundwork → ${cyan(root)}`);
|
|
36
|
+
if (minimal) log.info(dim(` (minimal: ${MINIMAL_SKILLS.join(", ")})`));
|
|
37
|
+
|
|
38
|
+
const counts = { added: 0, skipped: 0 };
|
|
39
|
+
const bump = (r) => counts[r]++;
|
|
40
|
+
|
|
41
|
+
// 1. Skills → .claude/skills
|
|
42
|
+
log.step("Skills");
|
|
43
|
+
const skillNames = (only || allSkillNames()).filter(skillExists);
|
|
44
|
+
for (const name of skillNames) {
|
|
45
|
+
const src = path.join(PAYLOAD_SKILLS, name, "SKILL.md");
|
|
46
|
+
const dest = path.join(root, TARGET.skills, name, "SKILL.md");
|
|
47
|
+
const r = copyFileSafe(src, dest, { force });
|
|
48
|
+
bump(r);
|
|
49
|
+
r === "added" ? log.added(`${TARGET.skills}/${name}/SKILL.md`) : null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// 2. Generated IDE mirrors → .cursor/commands + .vscode/prompts
|
|
53
|
+
log.step("IDE mirrors (Cursor + VS Code, generated from skills)");
|
|
54
|
+
const skills = loadSkills(only);
|
|
55
|
+
for (const f of mirrorFiles(skills, {
|
|
56
|
+
cursorDir: path.join(root, TARGET.cursor),
|
|
57
|
+
vscodeDir: path.join(root, TARGET.vscode),
|
|
58
|
+
})) {
|
|
59
|
+
if (exists(f.rel) && !force) {
|
|
60
|
+
bump("skipped");
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
fs.mkdirSync(path.dirname(f.rel), { recursive: true });
|
|
64
|
+
fs.writeFileSync(f.rel, f.content);
|
|
65
|
+
bump("added");
|
|
66
|
+
}
|
|
67
|
+
log.info(dim(` ${skills.length} skills × 2 mirrors`));
|
|
68
|
+
|
|
69
|
+
// 3. Doc scaffolding → docs/
|
|
70
|
+
log.step("Docs (methodology, templates, phases, WORKSTREAMS)");
|
|
71
|
+
for (const rel of walk(PAYLOAD_DOCS)) {
|
|
72
|
+
if (DOC_EXCLUDE.has(rel)) continue;
|
|
73
|
+
const r = copyFileSafe(
|
|
74
|
+
path.join(PAYLOAD_DOCS, rel),
|
|
75
|
+
path.join(root, TARGET.docs, rel),
|
|
76
|
+
{ force }
|
|
77
|
+
);
|
|
78
|
+
bump(r);
|
|
79
|
+
}
|
|
80
|
+
// ARTIFACTS.md is generated from the manifest (single source), not copied.
|
|
81
|
+
if (!exists(path.join(root, TARGET.docs, "ARTIFACTS.md")) || force) {
|
|
82
|
+
writeArtifacts(root);
|
|
83
|
+
bump("added");
|
|
84
|
+
} else bump("skipped");
|
|
85
|
+
|
|
86
|
+
// 4. Helper scripts → docs/.groundwork/scripts
|
|
87
|
+
if (exists(PAYLOAD_SCRIPTS)) {
|
|
88
|
+
log.step("Helper scripts");
|
|
89
|
+
for (const rel of walk(PAYLOAD_SCRIPTS)) {
|
|
90
|
+
const r = copyFileSafe(
|
|
91
|
+
path.join(PAYLOAD_SCRIPTS, rel),
|
|
92
|
+
path.join(root, TARGET.scripts, rel),
|
|
93
|
+
{ force }
|
|
94
|
+
);
|
|
95
|
+
bump(r);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// 5. Stamp version marker
|
|
100
|
+
const marker = path.join(root, TARGET.docs, ".groundwork", "VERSION");
|
|
101
|
+
if (!exists(marker) || force) {
|
|
102
|
+
fs.mkdirSync(path.dirname(marker), { recursive: true });
|
|
103
|
+
fs.writeFileSync(marker, readPkgVersion() + "\n");
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
log.heading("Done");
|
|
107
|
+
log.ok(`${counts.added} files written, ${counts.skipped} skipped (already present)`);
|
|
108
|
+
console.log(
|
|
109
|
+
[
|
|
110
|
+
"",
|
|
111
|
+
bold("Next steps:"),
|
|
112
|
+
` 1. Open this repo in Claude Code / Cursor / VS Code`,
|
|
113
|
+
` 2. Run ${cyan('/create-prd "<your idea>"')} FIRST — define the product (problem, scope, goals)`,
|
|
114
|
+
` 3. Run ${cyan("/kickstart")} — scaffolds tech stack / architecture / roadmap / phases FROM the PRD`,
|
|
115
|
+
` 4. Then ${cyan("/check-versions")} → ${cyan("/plan-phase 1 <name>")} → ${cyan("/start-session")}`,
|
|
116
|
+
dim(` (unsure where you are at any point? run ${cyan("/next")})`),
|
|
117
|
+
counts.skipped
|
|
118
|
+
? dim(`\n Re-run with --force to overwrite the ${counts.skipped} skipped files.`)
|
|
119
|
+
: "",
|
|
120
|
+
]
|
|
121
|
+
.filter(Boolean)
|
|
122
|
+
.join("\n")
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function allSkillNames() {
|
|
127
|
+
return walkSkillNames();
|
|
128
|
+
}
|
|
129
|
+
function walkSkillNames() {
|
|
130
|
+
return fs
|
|
131
|
+
.readdirSync(PAYLOAD_SKILLS, { withFileTypes: true })
|
|
132
|
+
.filter((d) => d.isDirectory())
|
|
133
|
+
.map((d) => d.name);
|
|
134
|
+
}
|
|
135
|
+
function skillExists(name) {
|
|
136
|
+
return exists(path.join(PAYLOAD_SKILLS, name, "SKILL.md"));
|
|
137
|
+
}
|
|
138
|
+
function readPkgVersion() {
|
|
139
|
+
try {
|
|
140
|
+
const pkg = JSON.parse(
|
|
141
|
+
fs.readFileSync(new URL("../../package.json", import.meta.url), "utf8")
|
|
142
|
+
);
|
|
143
|
+
return pkg.version;
|
|
144
|
+
} catch {
|
|
145
|
+
return "0.0.0";
|
|
146
|
+
}
|
|
147
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { execFileSync } from "node:child_process";
|
|
5
|
+
import { writeConfig, resolveKnowledgePath, configFile } from "../lib/config.mjs";
|
|
6
|
+
import { log, bold, cyan, dim, green } from "../lib/log.mjs";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* `groundwork knowledge <init|link|path>` — configure THIS user's central
|
|
10
|
+
* ADR/lessons repo. The location is per-user (varies by user); the `remember`
|
|
11
|
+
* `remember` skill resolves it via $GROUNDWORK_KNOWLEDGE or the saved config.
|
|
12
|
+
*/
|
|
13
|
+
export function knowledge(sub, arg, flags = {}) {
|
|
14
|
+
switch (sub) {
|
|
15
|
+
case "path":
|
|
16
|
+
return knowledgePath();
|
|
17
|
+
case "init":
|
|
18
|
+
return knowledgeInit(arg);
|
|
19
|
+
case "link":
|
|
20
|
+
return knowledgeLink(arg);
|
|
21
|
+
case "sync":
|
|
22
|
+
return knowledgeSync(flags.push);
|
|
23
|
+
default:
|
|
24
|
+
log.info(
|
|
25
|
+
[
|
|
26
|
+
bold("groundwork knowledge") + dim(" — configure your central ADR/lessons repo"),
|
|
27
|
+
"",
|
|
28
|
+
` ${cyan("init")} [dir] create a new knowledge repo and remember its path (default: ~/groundwork-knowledge)`,
|
|
29
|
+
` ${cyan("link")} <dir> point at an existing clone and save it`,
|
|
30
|
+
` ${cyan("sync")} [--push] pull latest from origin (and optionally push)`,
|
|
31
|
+
` ${cyan("path")} print the resolved knowledge-repo path for this user`,
|
|
32
|
+
"",
|
|
33
|
+
dim("Resolution order: $GROUNDWORK_KNOWLEDGE → user config → (unset)"),
|
|
34
|
+
].join("\n")
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function knowledgePath() {
|
|
40
|
+
const { path: p, source } = resolveKnowledgePath();
|
|
41
|
+
if (!p) {
|
|
42
|
+
log.warn("No knowledge repo configured for this user.");
|
|
43
|
+
log.info(dim(" Set one with: groundwork knowledge init (or: link <dir>)"));
|
|
44
|
+
process.exitCode = 1;
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
console.log(p);
|
|
48
|
+
log.info(dim(` via ${source}${fs.existsSync(p) ? "" : " — ⚠ path does not exist"}`));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function knowledgeSync(push) {
|
|
52
|
+
const { path: p } = resolveKnowledgePath();
|
|
53
|
+
if (!p || !fs.existsSync(p)) {
|
|
54
|
+
log.err("No knowledge repo configured/found. Run `groundwork knowledge init` or `link <dir>`.");
|
|
55
|
+
process.exitCode = 1;
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
try {
|
|
59
|
+
log.step(`Pulling latest into ${cyan(p)}`);
|
|
60
|
+
execFileSync("git", ["pull", "--rebase", "--autostash"], { cwd: p, stdio: "inherit" });
|
|
61
|
+
if (push) {
|
|
62
|
+
execFileSync("git", ["push"], { cwd: p, stdio: "inherit" });
|
|
63
|
+
log.ok("Pulled and pushed.");
|
|
64
|
+
} else {
|
|
65
|
+
log.ok("Up to date.");
|
|
66
|
+
}
|
|
67
|
+
} catch (e) {
|
|
68
|
+
log.err(`git sync failed: ${e.message}`);
|
|
69
|
+
process.exitCode = 1;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function knowledgeLink(dir) {
|
|
74
|
+
if (!dir) {
|
|
75
|
+
log.err("Usage: groundwork knowledge link <dir>");
|
|
76
|
+
process.exitCode = 1;
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
const abs = path.resolve(dir);
|
|
80
|
+
if (!fs.existsSync(abs)) {
|
|
81
|
+
log.err(`Path does not exist: ${abs}`);
|
|
82
|
+
process.exitCode = 1;
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
writeConfig({ knowledgeRepo: abs });
|
|
86
|
+
log.ok(`Knowledge repo set to ${cyan(abs)}`);
|
|
87
|
+
log.info(dim(` saved in ${configFile()}`));
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function knowledgeInit(dir) {
|
|
91
|
+
const abs = path.resolve(dir || path.join(os.homedir(), "groundwork-knowledge"));
|
|
92
|
+
if (fs.existsSync(path.join(abs, "adr"))) {
|
|
93
|
+
log.warn(`Already looks initialised: ${abs}`);
|
|
94
|
+
} else {
|
|
95
|
+
fs.mkdirSync(path.join(abs, "adr"), { recursive: true });
|
|
96
|
+
fs.mkdirSync(path.join(abs, "notes"), { recursive: true });
|
|
97
|
+
fs.writeFileSync(path.join(abs, "notes", ".gitkeep"), "");
|
|
98
|
+
fs.writeFileSync(path.join(abs, "README.md"), README);
|
|
99
|
+
fs.writeFileSync(path.join(abs, "INDEX.md"), INDEX);
|
|
100
|
+
fs.writeFileSync(path.join(abs, "notes", "lessons.md"), "# Lessons\n\n");
|
|
101
|
+
try {
|
|
102
|
+
execFileSync("git", ["init", "-q"], { cwd: abs });
|
|
103
|
+
} catch {
|
|
104
|
+
/* git optional */
|
|
105
|
+
}
|
|
106
|
+
log.ok(`Created knowledge repo at ${cyan(abs)}`);
|
|
107
|
+
}
|
|
108
|
+
writeConfig({ knowledgeRepo: abs });
|
|
109
|
+
log.info(dim(` saved path in ${configFile()}`));
|
|
110
|
+
console.log(
|
|
111
|
+
[
|
|
112
|
+
"",
|
|
113
|
+
bold("Next:"),
|
|
114
|
+
` 1. (optional) create a remote and push:`,
|
|
115
|
+
dim(` cd ${abs} && git add -A && git commit -m init`),
|
|
116
|
+
dim(` gh repo create <you>/groundwork-knowledge --source=. --private --push`),
|
|
117
|
+
` 2. ${cyan("export GROUNDWORK_KNOWLEDGE=" + abs)} ${dim("(add to your shell profile)")}`,
|
|
118
|
+
` 3. Use ${green("/remember")} — it writes here.`,
|
|
119
|
+
].join("\n")
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const README = `# groundwork-knowledge
|
|
124
|
+
|
|
125
|
+
Central, git-level **ADR + lessons log** — cross-project, cross-machine, swarm-readable.
|
|
126
|
+
Created by \`groundwork knowledge init\`.
|
|
127
|
+
|
|
128
|
+
## Layout
|
|
129
|
+
|
|
130
|
+
\`\`\`
|
|
131
|
+
adr/ formal ADRs — NNNN-kebab-title.md
|
|
132
|
+
notes/ raw dated captures (lessons.md)
|
|
133
|
+
INDEX.md the ADR index
|
|
134
|
+
\`\`\`
|
|
135
|
+
|
|
136
|
+
Notes are written by \`/remember\`; ADRs by \`/remember --adr\`.
|
|
137
|
+
Resolved via the \`GROUNDWORK_KNOWLEDGE\` env var or your \`groundwork\` user config.
|
|
138
|
+
`;
|
|
139
|
+
|
|
140
|
+
const INDEX = `# ADR Index
|
|
141
|
+
|
|
142
|
+
Cross-project architectural decisions and lessons learned.
|
|
143
|
+
|
|
144
|
+
| ID | Title | Tags | Date |
|
|
145
|
+
| -- | ----- | ---- | ---- |
|
|
146
|
+
|
|
147
|
+
_New ADRs are added by \`/remember --adr\`. Keep IDs zero-padded and sequential._
|
|
148
|
+
`;
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { TARGET, MINIMAL_SKILLS } from "../lib/paths.mjs";
|
|
3
|
+
import { listDirs, exists, readText } from "../lib/fs.mjs";
|
|
4
|
+
import { loadSkills, isReadOnly } from "../lib/skills.mjs";
|
|
5
|
+
import { log, bold, green, dim, cyan } from "../lib/log.mjs";
|
|
6
|
+
|
|
7
|
+
export function collectList(targetDir) {
|
|
8
|
+
const root = path.resolve(targetDir || ".");
|
|
9
|
+
const installed = new Set(listDirs(path.join(root, TARGET.skills)));
|
|
10
|
+
const here = exists(path.join(root, TARGET.skills));
|
|
11
|
+
const marker = path.join(root, TARGET.docs, ".groundwork", "VERSION");
|
|
12
|
+
const version = exists(marker) ? readText(marker).trim() : null;
|
|
13
|
+
const skills = loadSkills().map((skill) => ({
|
|
14
|
+
name: skill.name,
|
|
15
|
+
description: skill.data.description || "",
|
|
16
|
+
set: MINIMAL_SKILLS.includes(skill.name) ? "min" : "opt",
|
|
17
|
+
mode: isReadOnly(skill) ? "ask" : "edit",
|
|
18
|
+
installed: here && installed.has(skill.name),
|
|
19
|
+
mirrors: {
|
|
20
|
+
cursor: exists(path.join(root, TARGET.cursor, `${skill.name}.md`)),
|
|
21
|
+
vscode: exists(path.join(root, TARGET.vscode, `${skill.name}.prompt.md`)),
|
|
22
|
+
},
|
|
23
|
+
}));
|
|
24
|
+
return { ok: true, version, project: here, skills };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** List every available skill, marking minimal-set, mode, and install state. */
|
|
28
|
+
export function list(targetDir, opts = {}) {
|
|
29
|
+
const report = collectList(targetDir);
|
|
30
|
+
if (opts.json) {
|
|
31
|
+
console.log(JSON.stringify(report, null, 2));
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
const here = report.project;
|
|
35
|
+
|
|
36
|
+
log.heading("Available skills");
|
|
37
|
+
for (const skill of report.skills) {
|
|
38
|
+
const min = skill.set === "min" ? green("min") : dim("opt");
|
|
39
|
+
const mode = skill.mode === "ask" ? dim("ask ") : dim("edit");
|
|
40
|
+
const mark = here
|
|
41
|
+
? skill.installed
|
|
42
|
+
? green("●")
|
|
43
|
+
: dim("○")
|
|
44
|
+
: " ";
|
|
45
|
+
console.log(
|
|
46
|
+
` ${mark} ${cyan(skill.name.padEnd(20))} ${min} ${mode} ${dim(
|
|
47
|
+
skill.description
|
|
48
|
+
)}`
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
if (here) {
|
|
52
|
+
console.log(
|
|
53
|
+
`\n ${green("●")} installed ${dim("○")} available ${green(
|
|
54
|
+
"min"
|
|
55
|
+
)}=minimal set ${dim("opt")}=optional`
|
|
56
|
+
);
|
|
57
|
+
} else {
|
|
58
|
+
console.log(`\n ${dim("Run inside a project (or pass a path) to see install state.")}`);
|
|
59
|
+
}
|
|
60
|
+
console.log(dim(` Add one with: ${bold("groundwork add <name>")}`));
|
|
61
|
+
}
|