@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 +7 -5
- package/package.json +2 -2
- package/src/cli.mjs +5 -1
- package/src/commands/doctor.mjs +35 -2
- package/src/lib/adr-tripwire.mjs +57 -5
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
|
-
|
|
13
|
-
|
|
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
|
-
>
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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);
|
package/src/commands/doctor.mjs
CHANGED
|
@@ -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
|
-
|
|
58
|
-
|
|
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)) {
|
package/src/lib/adr-tripwire.mjs
CHANGED
|
@@ -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
|
-
/**
|
|
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({
|
|
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
|
|
158
|
-
|
|
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
|
}
|