@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.
Files changed (57) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +323 -0
  3. package/docs/DECISIONS.md +170 -0
  4. package/package.json +38 -0
  5. package/payload/doc-templates/COMMANDS.md +419 -0
  6. package/payload/doc-templates/DECISIONS.md +168 -0
  7. package/payload/doc-templates/FACTS.md +43 -0
  8. package/payload/doc-templates/GROUNDWORK_METHODOLOGY.md +1300 -0
  9. package/payload/doc-templates/STACK_MAP.md +90 -0
  10. package/payload/doc-templates/WORKSTREAMS.md +79 -0
  11. package/payload/doc-templates/_INDEX.md +54 -0
  12. package/payload/doc-templates/phases/README.md +36 -0
  13. package/payload/doc-templates/phases/templates/README.md +63 -0
  14. package/payload/doc-templates/phases/templates/TASK_TEMPLATE.md +302 -0
  15. package/payload/doc-templates/phases/templates/task_template_prompt.md +229 -0
  16. package/payload/doc-templates/templates/ARCHITECTURE_GUIDE_TEMPLATE.md +250 -0
  17. package/payload/doc-templates/templates/DESIGN_SYSTEM_TEMPLATE.md +336 -0
  18. package/payload/doc-templates/templates/DONE_TEMPLATE.md +21 -0
  19. package/payload/doc-templates/templates/PHASES_README_TEMPLATE.md +144 -0
  20. package/payload/doc-templates/templates/PHASE_README_TEMPLATE.md +142 -0
  21. package/payload/doc-templates/templates/PRD_TEMPLATE.md +348 -0
  22. package/payload/doc-templates/templates/PRODUCTION_ROADMAP_TEMPLATE.md +168 -0
  23. package/payload/doc-templates/templates/QUEUE_TEMPLATE.md +17 -0
  24. package/payload/doc-templates/templates/TECH_STACK_TEMPLATE.md +199 -0
  25. package/payload/scripts/check-task.mjs +98 -0
  26. package/payload/scripts/check-versions.mjs +113 -0
  27. package/payload/scripts/phase-status.mjs +69 -0
  28. package/payload/scripts/set-fact.mjs +86 -0
  29. package/payload/skills/add-data-layer/SKILL.md +129 -0
  30. package/payload/skills/check-task/SKILL.md +35 -0
  31. package/payload/skills/check-versions/SKILL.md +47 -0
  32. package/payload/skills/create-prd/SKILL.md +90 -0
  33. package/payload/skills/domain-model/SKILL.md +90 -0
  34. package/payload/skills/kickstart/SKILL.md +157 -0
  35. package/payload/skills/log-decision/SKILL.md +65 -0
  36. package/payload/skills/next/SKILL.md +65 -0
  37. package/payload/skills/plan-phase/SKILL.md +108 -0
  38. package/payload/skills/remember/SKILL.md +77 -0
  39. package/payload/skills/start-session/SKILL.md +52 -0
  40. package/payload/skills/update-workstreams/SKILL.md +60 -0
  41. package/src/cli.mjs +115 -0
  42. package/src/commands/add.mjs +39 -0
  43. package/src/commands/artifacts.mjs +24 -0
  44. package/src/commands/doctor.mjs +292 -0
  45. package/src/commands/init.mjs +147 -0
  46. package/src/commands/knowledge.mjs +148 -0
  47. package/src/commands/list.mjs +61 -0
  48. package/src/commands/status.mjs +96 -0
  49. package/src/commands/update.mjs +128 -0
  50. package/src/lib/adr-tripwire.mjs +171 -0
  51. package/src/lib/artifacts.mjs +124 -0
  52. package/src/lib/config.mjs +43 -0
  53. package/src/lib/fs.mjs +46 -0
  54. package/src/lib/log.mjs +22 -0
  55. package/src/lib/paths.mjs +36 -0
  56. package/src/lib/progress.mjs +26 -0
  57. package/src/lib/skills.mjs +42 -0
@@ -0,0 +1,96 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { TARGET } from "../lib/paths.mjs";
4
+ import { exists, readText, walk } from "../lib/fs.mjs";
5
+ import { log, bold, green, dim, yellow, cyan } from "../lib/log.mjs";
6
+ import { countCheckboxes, progressBar } from "../lib/progress.mjs";
7
+ import { boardRowsOf, parseWorkDoc, workstreamsV1RowsOf } from "@davidbalzan/groundwork-seam";
8
+
9
+ export function collectStatus(targetDir) {
10
+ const root = path.resolve(targetDir || ".");
11
+ const docs = path.join(root, TARGET.docs);
12
+ if (!exists(docs)) return { ok: false, error: "No docs/ directory here. Run `groundwork init` first." };
13
+
14
+ const streams = [];
15
+ let legacyLanes = false;
16
+ const ws = path.join(docs, "WORKSTREAMS.md");
17
+ if (exists(ws)) {
18
+ const wsDoc = parseWorkDoc(readText(ws));
19
+ for (const r of workstreamsV1RowsOf(wsDoc)) {
20
+ streams.push({
21
+ stream: r.stream,
22
+ owner: r.owner,
23
+ branchWorktree: r.branchWorktree,
24
+ status: r.status,
25
+ blocker: r.blocker,
26
+ lastNote: r.lastNote,
27
+ });
28
+ }
29
+ legacyLanes = !streams.length && boardRowsOf(wsDoc).length > 0;
30
+ }
31
+
32
+ const queue = [path.join(docs, "QUEUE.md"), path.join(docs, "BACKLOG.md")].find(exists);
33
+ const next = queue ? firstUnchecked(readText(queue)) : null;
34
+ const queueFound = !!queue;
35
+
36
+ const phases = [];
37
+ const phasesDir = path.join(docs, "phases");
38
+ if (exists(phasesDir)) {
39
+ for (const rel of walk(phasesDir)) {
40
+ if (!/PHASE.*TASKS\.md$/i.test(rel)) continue;
41
+ const { done, total } = countCheckboxes(readText(path.join(phasesDir, rel)));
42
+ phases.push({
43
+ label: rel.split(path.sep)[0],
44
+ done,
45
+ total,
46
+ pct: total ? Math.round((done / total) * 100) : 0,
47
+ });
48
+ }
49
+ }
50
+
51
+ return { ok: true, streams, legacyLanes, next, queueFound, phases };
52
+ }
53
+
54
+ /** A read-only project dashboard: live streams + next backlog item + phase progress. */
55
+ export function status(targetDir, opts = {}) {
56
+ const report = collectStatus(targetDir);
57
+ if (opts.json) {
58
+ console.log(JSON.stringify(report, null, 2));
59
+ if (!report.ok) process.exitCode = 1;
60
+ return;
61
+ }
62
+ if (!report.ok) {
63
+ log.warn(report.error);
64
+ return;
65
+ }
66
+
67
+ log.heading("Active streams");
68
+ if (report.streams.length)
69
+ report.streams.forEach((r) =>
70
+ console.log(` ${r.stream} ${dim(r.owner)} ${r.status}${r.lastNote ? ` ${r.lastNote}` : ""}`),
71
+ );
72
+ else if (report.legacyLanes)
73
+ log.info(dim(" legacy lanes table present; not the Active Streams view"));
74
+ else log.info(dim(" none"));
75
+
76
+ log.heading("Next in queue");
77
+ if (!report.queueFound) log.info(dim(" QUEUE.md not found (run /plan-phase)"));
78
+ else console.log(" " + (report.next ? cyan(report.next) : dim("queue empty")));
79
+
80
+ log.heading("Phase progress");
81
+ if (!report.phases.length) log.info(dim(" no PHASE*_TASKS.md files yet (run /plan-phase)"));
82
+ else
83
+ for (const p of report.phases) {
84
+ console.log(
85
+ ` ${bold(p.label.padEnd(10))} ${progressBar(p.pct)} ${p.pct
86
+ .toString()
87
+ .padStart(3)}% ${dim(`(${p.done}/${p.total})`)}`,
88
+ );
89
+ }
90
+ }
91
+
92
+ /** First unchecked `- [ ]` task line, trimmed. */
93
+ function firstUnchecked(text) {
94
+ const m = text.split("\n").find((l) => /^\s*-\s*\[ \]/.test(l));
95
+ return m ? m.replace(/^\s*-\s*\[ \]\s*/, "").trim() : null;
96
+ }
@@ -0,0 +1,128 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import {
4
+ PAYLOAD_SKILLS,
5
+ PAYLOAD_SCRIPTS,
6
+ PAYLOAD_DOCS,
7
+ PKG_ROOT,
8
+ TARGET,
9
+ } from "../lib/paths.mjs";
10
+ import { copyFileSafe, listDirs, exists, walk } from "../lib/fs.mjs";
11
+ import { loadSkills, mirrorFiles } from "../lib/skills.mjs";
12
+ import { writeArtifacts } from "./artifacts.mjs";
13
+ import { refreshableDocs } from "../lib/artifacts.mjs";
14
+ import { log, cyan, dim, green, bold } from "../lib/log.mjs";
15
+
16
+ /**
17
+ * Upgrade the workflow layer in an existing project, in place, WITHOUT touching
18
+ * project-authored docs. Refreshes installed skills + their mirrors + the helper
19
+ * scripts + the version marker. New skills introduced upstream are reported (and
20
+ * installed too when `--all` is passed) — never silently added by default.
21
+ */
22
+ export function update(targetDir, flags = {}) {
23
+ const root = path.resolve(targetDir || ".");
24
+ log.heading(`Updating Groundwork → ${cyan(root)}`);
25
+
26
+ const installed = listDirs(path.join(root, TARGET.skills));
27
+ if (installed.length === 0) {
28
+ log.warn("No skills found here. Run `groundwork init` first.");
29
+ return;
30
+ }
31
+
32
+ const payloadSkills = listDirs(PAYLOAD_SKILLS);
33
+ const newSkills = payloadSkills.filter((s) => !installed.includes(s));
34
+ // With --all, also install skills added upstream since this project's init.
35
+ const toRefresh = flags.all ? [...installed, ...newSkills] : installed;
36
+
37
+ // 1. Skills (force-refresh)
38
+ let refreshed = 0;
39
+ let added = 0;
40
+ for (const name of toRefresh) {
41
+ const src = path.join(PAYLOAD_SKILLS, name, "SKILL.md");
42
+ if (!exists(src)) {
43
+ log.warn(`${name}: not in payload (kept as-is)`);
44
+ continue;
45
+ }
46
+ copyFileSafe(src, path.join(root, TARGET.skills, name, "SKILL.md"), {
47
+ force: true,
48
+ });
49
+ installed.includes(name) ? refreshed++ : added++;
50
+ }
51
+
52
+ // 2. Regenerate mirrors for the resulting skill set
53
+ const skillSet = flags.all ? toRefresh : installed;
54
+ for (const f of mirrorFiles(loadSkills(skillSet), {
55
+ cursorDir: path.join(root, TARGET.cursor),
56
+ vscodeDir: path.join(root, TARGET.vscode),
57
+ })) {
58
+ fs.mkdirSync(path.dirname(f.rel), { recursive: true });
59
+ fs.writeFileSync(f.rel, f.content);
60
+ }
61
+
62
+ // 3. Refresh helper scripts (safe — tooling, not your docs)
63
+ let scripts = 0;
64
+ if (exists(PAYLOAD_SCRIPTS)) {
65
+ for (const rel of walk(PAYLOAD_SCRIPTS)) {
66
+ copyFileSafe(
67
+ path.join(PAYLOAD_SCRIPTS, rel),
68
+ path.join(root, TARGET.scripts, rel),
69
+ { force: true }
70
+ );
71
+ scripts++;
72
+ }
73
+ }
74
+
75
+ // 4. Regenerate the generated reference doc (ARTIFACTS.md) — not project-authored
76
+ writeArtifacts(root);
77
+
78
+ // 4b. With --docs, refresh the generic reference docs (methodology, COMMANDS, _INDEX).
79
+ // These are the same across projects; project-authored docs are NEVER touched.
80
+ let docs = 0;
81
+ if (flags.docs) {
82
+ for (const rel of refreshableDocs()) {
83
+ const src = path.join(PAYLOAD_DOCS, rel.replace(/^docs\//, ""));
84
+ if (!exists(src)) continue; // ARTIFACTS.md is generated, not in payload — skip
85
+ copyFileSafe(src, path.join(root, rel), { force: true });
86
+ docs++;
87
+ }
88
+ }
89
+
90
+ // 5. Stamp the version marker
91
+ const version = pkgVersion();
92
+ fs.mkdirSync(path.join(root, TARGET.docs, ".groundwork"), { recursive: true });
93
+ fs.writeFileSync(
94
+ path.join(root, TARGET.docs, ".groundwork", "VERSION"),
95
+ version + "\n"
96
+ );
97
+
98
+ log.ok(
99
+ `Refreshed ${refreshed} skills${added ? `, added ${added}` : ""}, ${scripts} scripts${docs ? `, ${docs} reference docs` : ""} → v${version}`
100
+ );
101
+ log.info(
102
+ dim(
103
+ flags.docs
104
+ ? " Generic reference docs refreshed; your project-authored docs were left untouched."
105
+ : " Project docs left untouched. Use --docs to also refresh the generic reference docs (methodology, COMMANDS, _INDEX)."
106
+ )
107
+ );
108
+
109
+ if (!flags.all && newSkills.length) {
110
+ log.heading("New skills available upstream (not installed)");
111
+ for (const s of newSkills) console.log(` ${green("+")} ${s}`);
112
+ console.log(
113
+ dim(
114
+ ` Add one: ${bold("groundwork add <name>")} · or all: ${bold("groundwork update --all")}`
115
+ )
116
+ );
117
+ }
118
+ }
119
+
120
+ function pkgVersion() {
121
+ try {
122
+ return JSON.parse(
123
+ fs.readFileSync(path.join(PKG_ROOT, "package.json"), "utf8")
124
+ ).version;
125
+ } catch {
126
+ return "0.0.0";
127
+ }
128
+ }
@@ -0,0 +1,171 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+
4
+ /**
5
+ * ADR tripwire — deterministic, offline. For every *Accepted* ADR in DECISIONS.md, take the
6
+ * rows of its "Alternatives Considered" table (the things the ADR REJECTED) and look for them
7
+ * in reality: dependency names across every package.json in the repo, and top-level /
8
+ * workspace directory names. A hit means the tree now contains what the ADR said it wouldn't —
9
+ * either the ADR was silently reversed (supersede it) or the deviation is accepted (record a
10
+ * FACT `adr-NNN-accepted-deviation`, which suppresses the warning). A status of "Superseded by"
11
+ * or "amended by ADR-NNN" also suppresses — the newer ADR carries the live claim.
12
+ *
13
+ * Field case: two ADRs (PWA-not-native, magic-link-not-password) reversed in the same week
14
+ * and stayed ✅ Accepted for five months; the docs agents read every session were wrong.
15
+ */
16
+
17
+ /** Alternative-name → package/dir tokens it usually shows up as. Extend freely; lowercase. */
18
+ export const ALIASES = {
19
+ "react native": ["react-native", "expo", "@react-navigation/native", "eas.json"],
20
+ "native ios + android": ["ios", "android", "expo", "react-native", "podfile"],
21
+ "native ios": ["ios", "podfile"],
22
+ "native android": ["android"],
23
+ "native app": ["expo", "react-native", "ios", "android"],
24
+ "capacitor/ionic": ["@capacitor/core", "@ionic/react", "@ionic/core"],
25
+ capacitor: ["@capacitor/core"],
26
+ ionic: ["@ionic/react", "@ionic/core"],
27
+ flutter: ["pubspec.yaml"],
28
+ express: ["express"],
29
+ fastify: ["fastify"],
30
+ koa: ["koa"],
31
+ nestjs: ["@nestjs/core"],
32
+ "next.js": ["next"],
33
+ nuxt: ["nuxt"],
34
+ "email + password": ["bcrypt", "bcryptjs", "argon2", "@node-rs/argon2", "@node-rs/bcrypt"],
35
+ password: ["bcrypt", "bcryptjs", "argon2", "@node-rs/argon2"],
36
+ "oauth (google/apple)": ["passport", "passport-google-oauth20", "arctic", "next-auth", "@auth/core", "google-auth-library"],
37
+ oauth: ["passport", "arctic", "next-auth", "@auth/core", "google-auth-library"],
38
+ "passkeys/webauthn": ["@simplewebauthn/server", "@simplewebauthn/browser"],
39
+ "openai only": ["openai"],
40
+ openai: ["openai"],
41
+ "self-hosted llm": ["ollama", "@lmstudio/sdk", "node-llama-cpp"],
42
+ mongodb: ["mongodb", "mongoose"],
43
+ mysql: ["mysql2", "mysql"],
44
+ sqlite: ["better-sqlite3", "sqlite3", "@libsql/client"],
45
+ prisma: ["prisma", "@prisma/client"],
46
+ typeorm: ["typeorm"],
47
+ sequelize: ["sequelize"],
48
+ knex: ["knex"],
49
+ "swagger ui": ["swagger-ui-express", "swagger-ui-dist"],
50
+ redoc: ["redoc"],
51
+ "pg-boss": ["pg-boss"],
52
+ agenda: ["agenda"],
53
+ "bull (v3)": ["bull"],
54
+ bull: ["bull"],
55
+ celery: ["celery"],
56
+ "npm workspaces": [],
57
+ "yarn workspaces": ["yarn.lock"],
58
+ turborepo: ["turbo"],
59
+ nx: ["nx"],
60
+ lerna: ["lerna"],
61
+ firebase: ["firebase", "firebase-admin"],
62
+ supabase: ["@supabase/supabase-js"],
63
+ graphql: ["graphql", "@apollo/server", "@apollo/client"],
64
+ trpc: ["@trpc/server"],
65
+ redux: ["@reduxjs/toolkit", "redux"],
66
+ mobx: ["mobx"],
67
+ jotai: ["jotai"],
68
+ "styled-components": ["styled-components"],
69
+ emotion: ["@emotion/react"],
70
+ "material ui": ["@mui/material"],
71
+ bootstrap: ["bootstrap"],
72
+ webpack: ["webpack"],
73
+ jest: ["jest"],
74
+ mocha: ["mocha"],
75
+ cypress: ["cypress"],
76
+ playwright: ["@playwright/test"],
77
+ };
78
+
79
+ const STOP = new Set(["the", "and", "with", "only", "over", "for", "app", "api", "via", "using", "based", "plain", "custom", "own", "direct", "integration", "hosted", "self", "native", "web", "server", "client", "service", "services", "library", "libraries", "framework", "solution", "approach", "option", "none", "n/a"]);
80
+
81
+ /** Parse DECISIONS.md into ADR blocks: {id, title, status, alternatives[]}. */
82
+ export function parseAdrs(text) {
83
+ const out = [];
84
+ const parts = text.split(/^(?=## ADR-\d+)/m);
85
+ for (const p of parts) {
86
+ const h = p.match(/^## (ADR-\d+):\s*(.+)$/m);
87
+ if (!h) continue;
88
+ const status = (p.match(/\*\*Status\*\*:\s*(.+)/) || [])[1]?.trim() || "";
89
+ const alternatives = [];
90
+ const alt = p.split(/###\s*Alternatives Considered/i)[1];
91
+ if (alt) {
92
+ for (const line of alt.split("\n")) {
93
+ const m = line.match(/^\|\s*([^|]+?)\s*\|/);
94
+ if (!m) continue;
95
+ const name = m[1].trim();
96
+ if (!name || /^-+$/.test(name) || /^alternative$/i.test(name) || /^option [a-z]$/i.test(name) || name === "...") continue;
97
+ alternatives.push(name);
98
+ }
99
+ }
100
+ out.push({ id: h[1], title: h[2].trim(), status, alternatives });
101
+ }
102
+ return out;
103
+ }
104
+
105
+ /** Tokens to look for, for one alternative name. */
106
+ export function tokensFor(alternative) {
107
+ const key = alternative.toLowerCase().replace(/\*+/g, "").replace(/\s+/g, " ").trim();
108
+ if (key in ALIASES) return ALIASES[key];
109
+ // generic: the full slug, plus meaningful single words (>=4 chars, not stopwords)
110
+ const words = key.replace(/[()]/g, " ").split(/[\s/+,]+/).filter(Boolean);
111
+ const set = new Set();
112
+ if (words.length > 1) set.add(words.join("-"));
113
+ for (const w of words) {
114
+ if (w.length >= 4 && !STOP.has(w) && !/^\d/.test(w)) set.add(w);
115
+ if (w in ALIASES) for (const t of ALIASES[w]) set.add(t);
116
+ }
117
+ return [...set];
118
+ }
119
+
120
+ /** Collect reality: dependency names (all package.json outside node_modules, depth<=4) + dir names (root + one level) + a few marker files. */
121
+ export function collectReality(root) {
122
+ const deps = new Map(); // name -> where
123
+ const dirs = new Map(); // lowercased dir/file name -> relative path
124
+ const skip = new Set(["node_modules", ".git", "dist", "build", ".next", "coverage", ".turbo", ".expo"]);
125
+ const walk = (dir, depth) => {
126
+ let ents = [];
127
+ try { ents = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
128
+ for (const e of ents) {
129
+ if (skip.has(e.name)) continue;
130
+ const full = path.join(dir, e.name);
131
+ const rel = path.relative(root, full) || ".";
132
+ if (e.isDirectory()) {
133
+ if (depth <= 1) dirs.set(e.name.toLowerCase(), rel);
134
+ if (depth < 4) walk(full, depth + 1);
135
+ } else if (e.name === "package.json") {
136
+ try {
137
+ const pkg = JSON.parse(fs.readFileSync(full, "utf8"));
138
+ for (const k of ["dependencies", "devDependencies", "peerDependencies", "optionalDependencies"])
139
+ for (const name of Object.keys(pkg[k] || {})) if (!deps.has(name)) deps.set(name, rel);
140
+ } catch { /* ignore */ }
141
+ } else if (depth <= 2 && /^(podfile|pubspec\.yaml|eas\.json|yarn\.lock|app\.json)$/i.test(e.name)) {
142
+ dirs.set(e.name.toLowerCase(), rel);
143
+ }
144
+ }
145
+ };
146
+ walk(root, 0);
147
+ return { deps, dirs };
148
+ }
149
+
150
+ /** Run the tripwire. Returns [{adr, alternative, token, kind, where}]. */
151
+ export function adrTripwire({ decisionsText, root, acceptedFactIds = new Set() }) {
152
+ const hits = [];
153
+ const { deps, dirs } = collectReality(root);
154
+ for (const adr of parseAdrs(decisionsText)) {
155
+ if (!/accepted/i.test(adr.status)) continue; // proposed / superseded / rejected are not live claims
156
+ if (/superseded|amended by/i.test(adr.status)) continue; // the newer ADR carries the live claim
157
+ // tokens that name the ADR's own choice are not evidence of the alternative (e.g. "BullMQ repeatable jobs only" vs "BullMQ …")
158
+ const own = new Set(adr.title.toLowerCase().replace(/[^a-z0-9@/.-]+/g, " ").split(" ").filter(Boolean));
159
+ const factId = `${adr.id.toLowerCase()}-accepted-deviation`;
160
+ if (acceptedFactIds.has(factId)) continue;
161
+ for (const alternative of adr.alternatives) {
162
+ for (const token of tokensFor(alternative)) {
163
+ const t = token.toLowerCase();
164
+ if (own.has(t)) continue;
165
+ if (deps.has(t)) hits.push({ adr: adr.id, title: adr.title, alternative, token: t, kind: "dependency", where: deps.get(t) });
166
+ else if (dirs.has(t)) hits.push({ adr: adr.id, title: adr.title, alternative, token: t, kind: "path", where: dirs.get(t) });
167
+ }
168
+ }
169
+ }
170
+ return hits;
171
+ }
@@ -0,0 +1,124 @@
1
+ /*
2
+ * Single source of truth for Groundwork's doc artifacts.
3
+ * Everything that lists "which docs exist + who writes/reads them" derives from here:
4
+ * - docs/ARTIFACTS.md is GENERATED from this (see renderArtifactsDoc)
5
+ * - `scope` classifies each doc, which `update --docs` uses to know what is safe to refresh
6
+ * ("reference" = generic, same across projects → refreshable; "project" = yours → never touched)
7
+ *
8
+ * Add a doc in ONE place: append an entry here, then `groundwork artifacts` regenerates the map.
9
+ */
10
+
11
+ /** @typedef {"project"|"reference"|"external"} Scope */
12
+
13
+ export const ARTIFACTS = [
14
+ // --- project artifacts (generated per project; vary; never auto-refreshed) ---
15
+ { path: "docs/PRD.md", scope: "project", purpose: "Product definition (problem, users, goals, scope, requirements)", writtenBy: "/create-prd", readBy: "/kickstart, /plan-phase", rules: "First artifact; the source for scaffolding" },
16
+ { path: "docs/CONTEXT.md (+ CONTEXT-MAP.md)", scope: "project", purpose: "Ubiquitous-language glossary / domain model", writtenBy: "/domain-model", readBy: "/start-session, /plan-phase", rules: "Glossary only — no implementation detail" },
17
+ { path: "docs/TECH_STACK.md", scope: "project", purpose: "Technology choices narrative", writtenBy: "/kickstart", readBy: "/plan-phase, /start-session", rules: "No version numbers here — link to STACK_MAP" },
18
+ { path: "docs/STACK_MAP.md", scope: "project", purpose: "Single source of truth for versions (pinned + latest)", writtenBy: "/kickstart, /check-versions", readBy: "anyone bumping deps", rules: "The only place a version appears" },
19
+ { path: "docs/ARCHITECTURE_GUIDE.md", scope: "project", purpose: "System design, patterns, the \"why\"", writtenBy: "/kickstart", readBy: "/plan-phase", rules: "—" },
20
+ { path: "docs/DECISIONS.md", scope: "project", purpose: "Project ADRs (decision log)", writtenBy: "/log-decision, /kickstart", readBy: "/start-session", rules: "ADRs are immutable; supersede, don't edit" },
21
+ { path: "docs/PRODUCTION_ROADMAP.md", scope: "project", purpose: "Phase roadmap + \"Current Status\" pointer", writtenBy: "/kickstart, /plan-phase", readBy: "/start-session, groundwork status", rules: "Current Status points at WORKSTREAMS" },
22
+ { path: "docs/phases/phaseN/README.md", scope: "project", purpose: "Phase overview", writtenBy: "/plan-phase", readBy: "/start-session", rules: "—" },
23
+ { path: "docs/phases/phaseN/PHASEN_TASKS.md", scope: "project", purpose: "Detailed checkbox tasks", writtenBy: "/plan-phase", readBy: "/check-task, /start-session, groundwork status", rules: "Progress recomputed by the helper script" },
24
+ { path: "docs/WORKSTREAMS.md", scope: "project", purpose: "Live state of parallel streams", writtenBy: "/update-workstreams + coordinator", readBy: "everyone, /start-session", rules: "One row per active stream" },
25
+ { path: "docs/QUEUE.md", scope: "project", purpose: "Inbound queue (phases + ad-hoc)", writtenBy: "/plan-phase + human", readBy: "/start-session, coordinator", rules: "Single writer per file: human/proxy only; executors never edit it" },
26
+ { path: "docs/DONE.md", scope: "project", purpose: "Completion log", writtenBy: "executor (solo you or coordinator)", readBy: "/start-session, humans", rules: "Append-only; sole executor write in the queue seam; pinned em-dash+middot line format" },
27
+ { path: "docs/DESIGN_SYSTEM.md", scope: "project", purpose: "Visual language (optional)", writtenBy: "/kickstart", readBy: "frontend work", rules: "Only when there's a UI" },
28
+ { path: "docs/FACTS.md", scope: "project", purpose: "Verified project facts (settled world-model)", writtenBy: "whoever verified (or set-fact.mjs)", readBy: "everyone, groundwork doctor", rules: "One writer per fact; entries carry verified/by/method; doctor flags stale (>14d)" },
29
+
30
+ // --- reference (shipped, generic, same across projects → refreshable by `update --docs`) ---
31
+ { path: "docs/GROUNDWORK_METHODOLOGY.md", scope: "reference", purpose: "The full methodology" },
32
+ { path: "docs/COMMANDS.md", scope: "reference", purpose: "Command/skill guide" },
33
+ { path: "docs/_INDEX.md", scope: "reference", purpose: "Obsidian Map of Content (human navigation)" },
34
+ { path: "docs/ARTIFACTS.md", scope: "reference", purpose: "This file (generated from the manifest)" },
35
+ { path: "docs/.groundwork/scripts/", scope: "reference", purpose: "Deterministic helpers: check-task.mjs (/check-task), phase-status.mjs (groundwork status), check-versions.mjs (/check-versions), set-fact.mjs (FACTS upsert)" },
36
+ { path: "docs/.groundwork/VERSION", scope: "reference", purpose: "Installed Groundwork version" },
37
+
38
+ // --- cross-project (external; lives in the central knowledge repo) ---
39
+ { path: "$GROUNDWORK_KNOWLEDGE/notes/lessons.md", scope: "external", purpose: "Raw cross-project lessons", writtenBy: "/remember" },
40
+ { path: "$GROUNDWORK_KNOWLEDGE/adr/NNNN-*.md", scope: "external", purpose: "Formal cross-project ADRs", writtenBy: "/remember --adr" },
41
+ ];
42
+
43
+ /** Doc paths that `update --docs` may safely refresh (generic, not project-authored). */
44
+ export function refreshableDocs() {
45
+ return ARTIFACTS.filter((a) => a.scope === "reference" && a.path.endsWith(".md")).map(
46
+ (a) => a.path
47
+ );
48
+ }
49
+
50
+ const esc = (s) => String(s ?? "—").replace(/\|/g, "\\|");
51
+
52
+ /** Render docs/ARTIFACTS.md from the manifest. */
53
+ export function renderArtifactsDoc() {
54
+ const project = ARTIFACTS.filter((a) => a.scope === "project");
55
+ const reference = ARTIFACTS.filter((a) => a.scope === "reference");
56
+ const external = ARTIFACTS.filter((a) => a.scope === "external");
57
+
58
+ const projRows = project
59
+ .map((a) => `| \`${esc(a.path)}\` | ${esc(a.purpose)} | ${esc(a.writtenBy)} | ${esc(a.readBy)} | ${esc(a.rules)} |`)
60
+ .join("\n");
61
+ const refRows = reference.map((a) => `| \`${esc(a.path)}\` | ${esc(a.purpose)} |`).join("\n");
62
+ const extRows = external
63
+ .map((a) => `| \`${esc(a.path)}\` | ${esc(a.purpose)} | ${esc(a.writtenBy)} |`)
64
+ .join("\n");
65
+
66
+ return `---
67
+ title: "Artifact Map"
68
+ tags: [groundwork/reference]
69
+ aliases: ["Artifacts", "Artifact Map", "Where things live"]
70
+ ---
71
+
72
+ <!-- GENERATED from the Groundwork artifact manifest. Do not edit by hand;
73
+ run \`groundwork artifacts\` after editing the manifest. -->
74
+
75
+ # Artifact Map
76
+
77
+ > **Agent reference.** Every Groundwork doc artifact: what it is, which skill **writes**
78
+ > it, who **reads** it, and the rules for touching it. Consult this to know *where things
79
+ > live* before reading or editing — especially when picking up a \`[[QUEUE]]\` item.
80
+
81
+ ## Project artifacts
82
+
83
+ | Artifact | Purpose | Written by | Read by | Rules |
84
+ | -------- | ------- | ---------- | ------- | ----- |
85
+ ${projRows}
86
+
87
+ ## Reference (shipped, generic — same across projects)
88
+
89
+ | Artifact | Purpose |
90
+ | -------- | ------- |
91
+ ${refRows}
92
+
93
+ ## Cross-project (external)
94
+
95
+ | Artifact | Purpose | Written by |
96
+ | -------- | ------- | ---------- |
97
+ ${extRows}
98
+
99
+ Resolve the central knowledge repo with \`groundwork knowledge path\`. Project-specific
100
+ decisions stay in \`docs/DECISIONS.md\`; cross-project lessons go to the central repo.
101
+
102
+ ## Terms (Groundwork's own vocabulary)
103
+
104
+ - **ADR / decision** — a *formal, structured* record of a choice with rationale and
105
+ trade-offs. Lives in \`docs/DECISIONS.md\` (project, via \`/log-decision\`) or
106
+ \`$GROUNDWORK_KNOWLEDGE/adr/\` (cross-project, via \`/remember --adr\`). Has frontmatter + sections.
107
+ - **Lesson / note** — a *raw, one-line, dated* capture via \`/remember\` to
108
+ \`$GROUNDWORK_KNOWLEDGE/notes/lessons.md\`. Unstructured; promote a keeper into an ADR
109
+ with \`/remember --adr\`.
110
+ - **Decision vs lesson:** a decision is "we chose X over Y because…"; a lesson is "X is
111
+ worth remembering."
112
+
113
+ When a project's *own* domain reuses these words, define how its model maps onto these
114
+ source meanings in its \`docs/CONTEXT.md\`.
115
+
116
+ ## The swarm seam (optional)
117
+
118
+ - **\`QUEUE.md\`** = what to do next (inbound). **\`WORKSTREAMS.md\`** = what's in flight (live). **\`DONE.md\`** = what shipped (completion log). **\`FACTS.md\`** = what's settled (verified world-model).
119
+ - Split by writer so single-writer-per-*file* is filesystem-enforced: humans write QUEUE, the executor appends to DONE and never touches QUEUE; whoever verified a fact writes it.
120
+ - An external coordinator reads QUEUE, opens a stream in WORKSTREAMS, and appends completions to DONE as work proceeds; agents cite FACTS ids instead of restating world-state.
121
+ - Optional — Groundwork runs solo without it. The coordinator depends on this seam, not the
122
+ reverse. Reference implementation: **\`coord-mcp\`** (MCP-based, harness-agnostic).
123
+ `;
124
+ }
@@ -0,0 +1,43 @@
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+
5
+ /** Per-user config dir (XDG-aware), e.g. ~/.config/groundwork/config.json */
6
+ export function configDir() {
7
+ const base =
8
+ process.env.XDG_CONFIG_HOME || path.join(os.homedir(), ".config");
9
+ return path.join(base, "groundwork");
10
+ }
11
+ export function configFile() {
12
+ return path.join(configDir(), "config.json");
13
+ }
14
+
15
+ export function readConfig() {
16
+ try {
17
+ return JSON.parse(fs.readFileSync(configFile(), "utf8"));
18
+ } catch {
19
+ return {};
20
+ }
21
+ }
22
+
23
+ export function writeConfig(patch) {
24
+ const dir = configDir();
25
+ fs.mkdirSync(dir, { recursive: true });
26
+ const merged = { ...readConfig(), ...patch };
27
+ fs.writeFileSync(configFile(), JSON.stringify(merged, null, 2) + "\n");
28
+ return merged;
29
+ }
30
+
31
+ /**
32
+ * Resolve the central knowledge-repo path for THIS user.
33
+ * Order: $GROUNDWORK_KNOWLEDGE env → user config `knowledgeRepo` → null.
34
+ * No personal/hardcoded defaults — varies per user by design.
35
+ */
36
+ export function resolveKnowledgePath() {
37
+ const fromEnv = process.env.GROUNDWORK_KNOWLEDGE;
38
+ if (fromEnv) return { path: fromEnv, source: "env (GROUNDWORK_KNOWLEDGE)" };
39
+ const cfg = readConfig();
40
+ if (cfg.knowledgeRepo)
41
+ return { path: cfg.knowledgeRepo, source: `config (${configFile()})` };
42
+ return { path: null, source: null };
43
+ }
package/src/lib/fs.mjs ADDED
@@ -0,0 +1,46 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+
4
+ export const exists = (p) => fs.existsSync(p);
5
+
6
+ export const readText = (p) => fs.readFileSync(p, "utf8");
7
+
8
+ export function writeText(p, content) {
9
+ fs.mkdirSync(path.dirname(p), { recursive: true });
10
+ fs.writeFileSync(p, content);
11
+ }
12
+
13
+ export function listDirs(p) {
14
+ if (!exists(p)) return [];
15
+ return fs
16
+ .readdirSync(p, { withFileTypes: true })
17
+ .filter((d) => d.isDirectory())
18
+ .map((d) => d.name)
19
+ .sort();
20
+ }
21
+
22
+ /**
23
+ * Recursively walk a directory, yielding paths relative to `root`.
24
+ */
25
+ export function* walk(root, rel = "") {
26
+ const dir = path.join(root, rel);
27
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
28
+ const childRel = path.join(rel, entry.name);
29
+ if (entry.isDirectory()) {
30
+ yield* walk(root, childRel);
31
+ } else {
32
+ yield childRel;
33
+ }
34
+ }
35
+ }
36
+
37
+ /**
38
+ * Copy a single file unless it exists (when `force` is false).
39
+ * Returns "added" | "skipped".
40
+ */
41
+ export function copyFileSafe(src, dest, { force = false } = {}) {
42
+ if (exists(dest) && !force) return "skipped";
43
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
44
+ fs.copyFileSync(src, dest);
45
+ return exists(dest) ? "added" : "skipped";
46
+ }
@@ -0,0 +1,22 @@
1
+ /* Tiny dependency-free logger with consistent, scannable output. */
2
+
3
+ const COLOR = process.stdout.isTTY && !process.env.NO_COLOR;
4
+ const c = (code, s) => (COLOR ? `\x1b[${code}m${s}\x1b[0m` : s);
5
+
6
+ export const dim = (s) => c("2", s);
7
+ export const bold = (s) => c("1", s);
8
+ export const green = (s) => c("32", s);
9
+ export const yellow = (s) => c("33", s);
10
+ export const red = (s) => c("31", s);
11
+ export const cyan = (s) => c("36", s);
12
+
13
+ export const log = {
14
+ info: (msg) => console.log(msg),
15
+ step: (msg) => console.log(`${cyan("›")} ${msg}`),
16
+ ok: (msg) => console.log(`${green("✓")} ${msg}`),
17
+ warn: (msg) => console.log(`${yellow("!")} ${msg}`),
18
+ err: (msg) => console.error(`${red("✗")} ${msg}`),
19
+ added: (p) => console.log(` ${green("+")} ${dim(p)}`),
20
+ skipped: (p) => console.log(` ${dim("·")} ${dim(p + " (exists, skipped)")}`),
21
+ heading: (msg) => console.log(`\n${bold(msg)}`),
22
+ };
@@ -0,0 +1,36 @@
1
+ import { fileURLToPath } from "node:url";
2
+ import path from "node:path";
3
+
4
+ const __filename = fileURLToPath(import.meta.url);
5
+ const __dirname = path.dirname(__filename);
6
+
7
+ /** Repo root of the groundwork package (one level above src/). */
8
+ export const PKG_ROOT = path.resolve(__dirname, "..", "..");
9
+
10
+ /** Source payload shipped with the package. */
11
+ export const PAYLOAD = path.join(PKG_ROOT, "payload");
12
+ export const PAYLOAD_SKILLS = path.join(PAYLOAD, "skills");
13
+ export const PAYLOAD_DOCS = path.join(PAYLOAD, "doc-templates");
14
+ export const PAYLOAD_SCRIPTS = path.join(PAYLOAD, "scripts");
15
+
16
+ /** Where each artifact lands inside a TARGET project. */
17
+ export const TARGET = {
18
+ skills: ".claude/skills",
19
+ cursor: ".cursor/commands",
20
+ vscode: ".vscode/prompts",
21
+ docs: "docs",
22
+ scripts: "docs/.groundwork/scripts",
23
+ };
24
+
25
+ /**
26
+ * The minimal skill set (mirrors the old "lite" repo). `init --minimal`
27
+ * installs only these; everything else is optional via `groundwork add`.
28
+ */
29
+ export const MINIMAL_SKILLS = [
30
+ "check-task",
31
+ "kickstart",
32
+ "next",
33
+ "plan-phase",
34
+ "start-session",
35
+ "update-workstreams",
36
+ ];