@davidbalzan/groundwork 0.3.1 → 0.3.2

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 CHANGED
@@ -9,12 +9,12 @@ It's a small CLI that installs the workflow into any repo — new or existing
9
9
  in place as it improves. (A bolt-on, not a template you fork.)
10
10
 
11
11
  ```bash
12
- npx github:davidbalzan/groundwork init # full workflow into the current repo
13
- npx github:davidbalzan/groundwork init . --minimal # just the core 6 skills
12
+ npm i -g @davidbalzan/groundwork
13
+ groundwork init # full workflow into the current repo
14
+ groundwork init . --minimal # just the core 6 skills
14
15
  ```
15
16
 
16
- > **Private repo:** `npx github:...` needs GitHub auth (the `gh` CLI login is enough).
17
- > Make it public with `gh repo edit davidbalzan/groundwork --visibility public`.
17
+ > Source lives in the [groundwork-kit](https://github.com/davidbalzan/groundwork-kit) monorepo (`packages/groundwork`); the old `davidbalzan/groundwork` remote is frozen.
18
18
 
19
19
  > **One-time per machine** (only if you use `/remember`): point Groundwork at your central
20
20
  > knowledge repo — `groundwork knowledge link <path>` (or `export GROUNDWORK_KNOWLEDGE=<path>`
@@ -196,7 +196,9 @@ Honest boundary: `doctor` catches *structural / consistency* drift, **not** whet
196
196
  matches the running code — that still needs you or the agent (or the optional test tripwire).
197
197
  The ADR tripwire is keyword-based (`src/lib/adr-tripwire.mjs` `ALIASES` maps alternative names
198
198
  to package/dir tokens — extend it); it catches the reversal that shows up as a dependency or a
199
- folder, not one that is pure code.
199
+ folder, not one that is pure code. Tokens that also appear in the ADR title / Decision / Context
200
+ (alias-expanded) are the accepted path and are not hits; generic project-wide paths (`docs/`,
201
+ `templates/`) are not hits.
200
202
 
201
203
  ---
202
204
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@davidbalzan/groundwork",
3
- "version": "0.3.1",
3
+ "version": "0.3.2",
4
4
  "description": "Groundwork — an installable AI development workflow (skills + doc methodology) you bolt onto any repo.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -28,7 +28,7 @@
28
28
  "author": "David Balzan",
29
29
  "license": "MIT",
30
30
  "dependencies": {
31
- "@davidbalzan/groundwork-seam": "0.1.0"
31
+ "@davidbalzan/groundwork-seam": "0.1.1"
32
32
  },
33
33
  "scripts": {
34
34
  "groundwork": "node src/cli.mjs",
package/src/cli.mjs CHANGED
@@ -66,7 +66,11 @@ function help() {
66
66
  const [cmd, ...rest] = argv;
67
67
  const { flags, pos } = parse(rest);
68
68
 
69
- try {
69
+ // `--*` are flags; `-h` is positional. Either form after a command must print
70
+ // usage and not run the command (`init --help` / `init -h` used to init cwd).
71
+ if (flags.help || flags.h || pos.includes("-h") || pos.includes("--help")) {
72
+ help();
73
+ } else try {
70
74
  switch (cmd) {
71
75
  case "init":
72
76
  init(pos[0], flags);
@@ -44,6 +44,10 @@ export function collectDoctor(targetDir) {
44
44
  push("orphan-wikilinks", orphanItems.length ? "warn" : "ok", orphanItems.length ? orphanItems : ["all wikilinks resolve"]);
45
45
 
46
46
  const phaseDir = path.join(docs, "phases");
47
+ const roadmapText = exists(path.join(docs, "PRODUCTION_ROADMAP.md"))
48
+ ? readText(path.join(docs, "PRODUCTION_ROADMAP.md"))
49
+ : "";
50
+ const doneText = exists(path.join(docs, "DONE.md")) ? readText(path.join(docs, "DONE.md")) : "";
47
51
  const phaseItems = [];
48
52
  let phaseWarn = false;
49
53
  if (exists(phaseDir)) {
@@ -54,8 +58,14 @@ export function collectDoctor(targetDir) {
54
58
  const label = rel.split(path.sep)[0];
55
59
  const line = `${label} ${progressBar(pct)} ${pct}% (${done}/${total})`;
56
60
  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`);
61
+ const n = phaseNumberFromDir(label);
62
+ const closed = n != null && (roadmapPhaseComplete(roadmapText, n) || doneLineNamesPhase(doneText, n));
63
+ if (closed) {
64
+ phaseItems.push(`${line} — 100% done; recorded complete`);
65
+ } else {
66
+ phaseWarn = true;
67
+ phaseItems.push(`${line} — 100% done; mark it complete in the roadmap & record it in DONE.md`);
68
+ }
59
69
  } else phaseItems.push(line);
60
70
  }
61
71
  }
@@ -119,6 +129,13 @@ export function collectDoctor(targetDir) {
119
129
  else push("workstreams", "ok", ["write grammar is workstreams.v1"]);
120
130
  } else push("workstreams", "info", ["no WORKSTREAMS.md yet (run `groundwork update --docs`)"]);
121
131
 
132
+ const qFile = path.join(docs, "QUEUE.md");
133
+ if (exists(qFile)) {
134
+ const qIssues = workDocIssues(parseWorkDoc(readText(qFile)));
135
+ if (qIssues.length) push("queue", "warn", qIssues);
136
+ else push("queue", "ok", ["queue.v1 — one line per item"]);
137
+ }
138
+
122
139
  const factsFile = path.join(docs, "FACTS.md");
123
140
  if (exists(factsFile)) {
124
141
  const { entries, issues } = parseFactsDoc(readText(factsFile));
@@ -197,6 +214,22 @@ export function doctor(targetDir, opts = {}) {
197
214
  }
198
215
 
199
216
  // ---------- helpers ----------
217
+ function phaseNumberFromDir(label) {
218
+ const m = String(label).match(/^phase(\d+)/i);
219
+ return m ? Number(m[1]) : null;
220
+ }
221
+
222
+ function roadmapPhaseComplete(roadmapText, n) {
223
+ const parts = String(roadmapText).split(/^(?=###\s*Phase\s+\d+)/im);
224
+ const block = parts.find((p) => new RegExp(`^###\\s*Phase\\s+${n}\\b`, "i").test(p)) ?? "";
225
+ return /\*\*Status\*\*:\s*🟢\s*Complete\b/i.test(block);
226
+ }
227
+
228
+ function doneLineNamesPhase(doneText, n) {
229
+ const re = new RegExp(`\\bPhase\\s+${n}\\b`, "i");
230
+ return String(doneText).split("\n").some((line) => re.test(line));
231
+ }
232
+
200
233
  function wikilinks(text) {
201
234
  const out = [];
202
235
  for (const m of text.matchAll(/\[\[([^\]]+)\]\]/g)) {
@@ -12,6 +12,11 @@ import path from "node:path";
12
12
  *
13
13
  * Field case: two ADRs (PWA-not-native, magic-link-not-password) reversed in the same week
14
14
  * and stayed ✅ Accepted for five months; the docs agents read every session were wrong.
15
+ *
16
+ * Shared-token suppression: tokens that also appear in the ADR title / Decision / Context
17
+ * (alias-expanded) are the accepted path, not evidence of the reject. Generic project-wide
18
+ * paths (`docs`, `templates`, `readme`, `wiki`) are not hits. Cass burned an audit cycle on
19
+ * `next` (accepted Next.js), top-level `docs/`, and `docs/templates`.
15
20
  */
16
21
 
17
22
  /** Alternative-name → package/dir tokens it usually shows up as. Extend freely; lowercase. */
@@ -78,7 +83,26 @@ export const ALIASES = {
78
83
 
79
84
  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
85
 
81
- /** Parse DECISIONS.md into ADR blocks: {id, title, status, alternatives[]}. */
86
+ /**
87
+ * Top-level / workspace path tokens that every repo tends to have. A hit on these is
88
+ * project-wide, not evidence of a rejected alternative (cass: `docs/`, `docs/templates`).
89
+ * Dependency names are not filtered here — `next` still fires unless it is in `own`.
90
+ */
91
+ export const GENERIC_PATHS = new Set(["docs", "templates", "readme", "wiki"]);
92
+
93
+ /** Pull a `### Heading` body from an ADR block. Stops at the next `###`. */
94
+ export function sectionBody(block, heading) {
95
+ const parts = block.split(/^###\s+/m);
96
+ const want = heading.toLowerCase();
97
+ for (const part of parts) {
98
+ const nl = part.indexOf("\n");
99
+ const h = (nl === -1 ? part : part.slice(0, nl)).trim();
100
+ if (h.toLowerCase() === want) return (nl === -1 ? "" : part.slice(nl + 1)).trim();
101
+ }
102
+ return "";
103
+ }
104
+
105
+ /** Parse DECISIONS.md into ADR blocks: {id, title, status, decision, context, alternatives[]}. */
82
106
  export function parseAdrs(text) {
83
107
  const out = [];
84
108
  const parts = text.split(/^(?=## ADR-\d+)/m);
@@ -97,11 +121,38 @@ export function parseAdrs(text) {
97
121
  alternatives.push(name);
98
122
  }
99
123
  }
100
- out.push({ id: h[1], title: h[2].trim(), status, alternatives });
124
+ out.push({
125
+ id: h[1],
126
+ title: h[2].trim(),
127
+ status,
128
+ decision: sectionBody(p, "Decision"),
129
+ context: sectionBody(p, "Context"),
130
+ alternatives,
131
+ });
101
132
  }
102
133
  return out;
103
134
  }
104
135
 
136
+ /**
137
+ * Tokens that name the ADR's accepted path. Title + Decision + Context (not Alternatives),
138
+ * plus ALIASES expansion (`next.js` → `next`) so a shared dep is not evidence of the reject.
139
+ */
140
+ export function ownTokens(adr) {
141
+ const own = new Set();
142
+ const absorb = (text) => {
143
+ if (!text) return;
144
+ for (const w of text.toLowerCase().replace(/[^a-z0-9@/.-]+/g, " ").split(" ").filter(Boolean)) {
145
+ own.add(w);
146
+ if (w in ALIASES) for (const t of ALIASES[w]) own.add(t.toLowerCase());
147
+ }
148
+ for (const t of tokensFor(text)) own.add(t.toLowerCase());
149
+ };
150
+ absorb(adr.title);
151
+ absorb(adr.decision);
152
+ absorb(adr.context);
153
+ return own;
154
+ }
155
+
105
156
  /** Tokens to look for, for one alternative name. */
106
157
  export function tokensFor(alternative) {
107
158
  const key = alternative.toLowerCase().replace(/\*+/g, "").replace(/\s+/g, " ").trim();
@@ -154,8 +205,9 @@ export function adrTripwire({ decisionsText, root, acceptedFactIds = new Set() }
154
205
  for (const adr of parseAdrs(decisionsText)) {
155
206
  if (!/accepted/i.test(adr.status)) continue; // proposed / superseded / rejected are not live claims
156
207
  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));
208
+ // tokens that name the ADR's own choice are not evidence of the alternative
209
+ // (title + Decision + Context; e.g. Decision names Next.js → do not flag `next`)
210
+ const own = ownTokens(adr);
159
211
  const factId = `${adr.id.toLowerCase()}-accepted-deviation`;
160
212
  if (acceptedFactIds.has(factId)) continue;
161
213
  for (const alternative of adr.alternatives) {
@@ -163,7 +215,7 @@ export function adrTripwire({ decisionsText, root, acceptedFactIds = new Set() }
163
215
  const t = token.toLowerCase();
164
216
  if (own.has(t)) continue;
165
217
  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) });
218
+ else if (dirs.has(t) && !GENERIC_PATHS.has(t)) hits.push({ adr: adr.id, title: adr.title, alternative, token: t, kind: "path", where: dirs.get(t) });
167
219
  }
168
220
  }
169
221
  }