@praxisflux/gates 0.6.2 → 0.6.4
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.
|
@@ -1,7 +1,16 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
// cli.mjs — command-line entry to the grounding-wiki gates.
|
|
3
3
|
// node cli.mjs freshness <repo-root> [corpus-dir] (corpus-dir defaults to docs/wiki)
|
|
4
|
-
|
|
4
|
+
// node cli.mjs plan <repo-root> [corpus-dir] computed reconciliation for stale
|
|
5
|
+
// notes: RE-PIN-ONLY entries print
|
|
6
|
+
// runnable repin.mjs commands, NEEDS-
|
|
7
|
+
// REVIEW entries print a work order.
|
|
8
|
+
// Fresh corpus prints nothing.
|
|
9
|
+
// Read-only — plan prints, the
|
|
10
|
+
// wiki-update skill executes.
|
|
11
|
+
import { fileURLToPath } from "node:url";
|
|
12
|
+
import { join, dirname } from "node:path";
|
|
13
|
+
import { validateFreshness, planFreshness } from "./freshness.mjs";
|
|
5
14
|
|
|
6
15
|
function report({ fails = [], warns = [], checked = 0 }, okMsg) {
|
|
7
16
|
for (const w of warns) console.log(`warn: ${w}`);
|
|
@@ -20,7 +29,25 @@ if (cmd === "freshness") {
|
|
|
20
29
|
if (!root) { console.error("usage: cli.mjs freshness <repo-root> [corpus-dir]"); process.exit(2); }
|
|
21
30
|
const r = validateFreshness(root, corpusDir || "docs/wiki");
|
|
22
31
|
report(r, "OK: %n note(s) fresh against their pinned sources.");
|
|
32
|
+
} else if (cmd === "plan") {
|
|
33
|
+
const [root, corpusDir] = rest;
|
|
34
|
+
if (!root) { console.error("usage: cli.mjs plan <repo-root> [corpus-dir]"); process.exit(2); }
|
|
35
|
+
const { head, entries, problems } = planFreshness(root, corpusDir || "docs/wiki");
|
|
36
|
+
if (problems.length) {
|
|
37
|
+
for (const p of problems) console.error(`# problem: ${p} — fix before planning`);
|
|
38
|
+
process.exit(1);
|
|
39
|
+
}
|
|
40
|
+
const repinScript = join(dirname(fileURLToPath(import.meta.url)), "..", "scripts", "repin.mjs");
|
|
41
|
+
const summary = (e) => e.files.map((f) => `${f.path} (+${f.plus}/-${f.minus})`).join(", ");
|
|
42
|
+
for (const e of entries) {
|
|
43
|
+
if (e.cls === "REPIN") {
|
|
44
|
+
console.log(`# RE-PIN-ONLY ${e.note} — ${e.reason} (${e.commits} commit(s): ${summary(e)})`);
|
|
45
|
+
console.log(`node ${repinScript} ${e.absPath} ${head}`);
|
|
46
|
+
} else {
|
|
47
|
+
console.log(`# NEEDS-REVIEW ${e.note} — pin ${e.pin.slice(0, 12)}, ${e.commits} commit(s); ${e.reason}; changed: ${summary(e)}`);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
23
50
|
} else {
|
|
24
|
-
console.error("usage: cli.mjs freshness <repo-root> [corpus-dir]");
|
|
51
|
+
console.error("usage: cli.mjs freshness <repo-root> [corpus-dir] | plan <repo-root> [corpus-dir]");
|
|
25
52
|
process.exit(2);
|
|
26
53
|
}
|
|
@@ -90,3 +90,69 @@ export function validateFreshness(repoRoot, corpusDir = "docs/wiki") {
|
|
|
90
90
|
|
|
91
91
|
return { fails, warns, checked };
|
|
92
92
|
}
|
|
93
|
+
|
|
94
|
+
/* ── plan: classify stale notes into computed re-pins vs review work ───── */
|
|
95
|
+
|
|
96
|
+
// The ONE provably-safe diff class. A changed line is a lockstep version stamp when it is a
|
|
97
|
+
// version key ("version": "1.2.3" / version: 1.2.3) or an npm-style pin (@scope/name@1.2.3).
|
|
98
|
+
const STAMP_LINE = /(["']?version["']?\s*[:=]\s*["']?\d+\.\d+\.\d+)|(@[\w.-]+\/[\w.-]+@\d+\.\d+\.\d+)/;
|
|
99
|
+
const SEMVER = /\d+\.\d+\.\d+/;
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Pure classifier. `changedLines` are the +/- content lines of the diff over a note's
|
|
103
|
+
* sources since its pin; `noteBody` is the note's full text. RE-PIN-ONLY iff every changed
|
|
104
|
+
* line is a version stamp AND the note quotes no semver literal anywhere (a note that names
|
|
105
|
+
* versions may claim the very number that just changed). Anything else — including an empty
|
|
106
|
+
* diff we couldn't read — defaults to NEEDS-REVIEW: a pin is a verification claim, and the
|
|
107
|
+
* planner must never make one it can't prove.
|
|
108
|
+
*/
|
|
109
|
+
export function classifyNote(changedLines, noteBody) {
|
|
110
|
+
if (!changedLines.length) return { cls: "REVIEW", reason: "diff could not be read — verify by hand" };
|
|
111
|
+
if (!changedLines.every((l) => STAMP_LINE.test(l)))
|
|
112
|
+
return { cls: "REVIEW", reason: "sources changed beyond version stamps — re-verify the prose against the diff" };
|
|
113
|
+
// Raw body, deliberately: notes quote versions in backticks (`0.6.3`), which stripCode
|
|
114
|
+
// would hide — and hiding them would flip the classification in the UNSAFE direction.
|
|
115
|
+
if (SEMVER.test(String(noteBody ?? "")))
|
|
116
|
+
return { cls: "REVIEW", reason: "stamp-only diff, but the note quotes version literals — update them, then re-pin" };
|
|
117
|
+
return { cls: "REPIN", reason: "version stamps only, and the note quotes no versions" };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Plan the reconciliation of a stale corpus: for each stale note, the diff summary since its
|
|
122
|
+
* pin and a classification. Read-only (gates/ contract) — the CLI prints, the wiki-update
|
|
123
|
+
* skill executes. Returns { head, entries, problems }; a fresh corpus yields entries: [].
|
|
124
|
+
* Notes that fail structurally (no pin, unknown pin) land in `problems` — plan doesn't
|
|
125
|
+
* paper over what the freshness gate would reject.
|
|
126
|
+
*/
|
|
127
|
+
export function planFreshness(repoRoot, corpusDir = "docs/wiki") {
|
|
128
|
+
const entries = [];
|
|
129
|
+
const problems = [];
|
|
130
|
+
const dir = isAbsolute(corpusDir) ? corpusDir : join(repoRoot, corpusDir);
|
|
131
|
+
if (!existsSync(join(dir, "INDEX.md"))) return { head: "", entries, problems: [`not a corpus: ${join(dir, "INDEX.md")} missing`] };
|
|
132
|
+
const head = git(repoRoot, ["rev-parse", "HEAD"]);
|
|
133
|
+
|
|
134
|
+
for (const file of readdirSync(dir).filter((f) => f.endsWith(".md") && f !== "INDEX.md").sort()) {
|
|
135
|
+
const rel = `${corpusDir}/${file}`;
|
|
136
|
+
const text = readFileSync(join(dir, file), "utf8");
|
|
137
|
+
const pin = parseFrontmatter(text)?.verified_against;
|
|
138
|
+
if (!pin) { problems.push(`${rel}: no verified_against pin`); continue; }
|
|
139
|
+
try { git(repoRoot, ["cat-file", "-e", `${pin}^{commit}`]); }
|
|
140
|
+
catch { problems.push(`${rel}: pin ${pin} is not a known commit`); continue; }
|
|
141
|
+
const sources = parseSourcesBlock(text);
|
|
142
|
+
if (!sources.length) continue; // unverifiable — the freshness gate already warns
|
|
143
|
+
|
|
144
|
+
const log = git(repoRoot, ["log", "--oneline", `${pin}..HEAD`, "--", ...sources]);
|
|
145
|
+
if (!log) continue; // fresh
|
|
146
|
+
const commits = log.split("\n").length;
|
|
147
|
+
const files = git(repoRoot, ["diff", "--numstat", `${pin}..HEAD`, "--", ...sources])
|
|
148
|
+
.split("\n").filter(Boolean)
|
|
149
|
+
.map((l) => { const [plus, minus, path] = l.split("\t"); return { path, plus: +plus || 0, minus: +minus || 0 }; });
|
|
150
|
+
const changedLines = git(repoRoot, ["diff", "-U0", `${pin}..HEAD`, "--", ...sources])
|
|
151
|
+
.split("\n")
|
|
152
|
+
.filter((l) => (/^[+-]/.test(l) && !/^(\+\+\+|---)/.test(l)))
|
|
153
|
+
.map((l) => l.slice(1))
|
|
154
|
+
.filter((l) => l.trim() !== "");
|
|
155
|
+
entries.push({ note: rel, absPath: join(dir, file), pin, head, commits, files, ...classifyNote(changedLines, text) });
|
|
156
|
+
}
|
|
157
|
+
return { head, entries, problems };
|
|
158
|
+
}
|
package/package.json
CHANGED
|
@@ -33,8 +33,10 @@ const RANK = { "to do": 0, "in progress": 1, done: 2 };
|
|
|
33
33
|
const DERIVED_RANK = { [STATUS.TODO]: 0, [STATUS.IN_PROGRESS]: 1, [STATUS.DONE_ELIGIBLE]: 2 };
|
|
34
34
|
|
|
35
35
|
/**
|
|
36
|
-
* Parse one Backlog task file. Returns { id, status, specDir } for a linked task,
|
|
37
|
-
* null for anything else (no marker, unreadable, or not a task file).
|
|
36
|
+
* Parse one Backlog task file. Returns { id, status, specDir, acs } for a linked task,
|
|
37
|
+
* null for anything else (no marker, unreadable, or not a task file). `acs` is the task's
|
|
38
|
+
* acceptance criteria as [{ index, checked, text }] read from the AC:BEGIN/END block —
|
|
39
|
+
* still read-only; the plan command needs them to compute reconciling edits.
|
|
38
40
|
*/
|
|
39
41
|
export function parseLinkedTask(raw) {
|
|
40
42
|
const text = String(raw ?? "");
|
|
@@ -46,7 +48,12 @@ export function parseLinkedTask(raw) {
|
|
|
46
48
|
const id = field("id");
|
|
47
49
|
const status = field("status");
|
|
48
50
|
if (!id) return null;
|
|
49
|
-
|
|
51
|
+
const acs = [];
|
|
52
|
+
const block = text.match(/<!-- AC:BEGIN -->([\s\S]*?)<!-- AC:END -->/);
|
|
53
|
+
if (block)
|
|
54
|
+
for (const m of block[1].matchAll(/^- \[( |x|X)\] #(\d+)\s+(.*\S)\s*$/gm))
|
|
55
|
+
acs.push({ index: +m[2], checked: m[1] !== " ", text: m[3] });
|
|
56
|
+
return { id, status, specDir: marker[1], acs };
|
|
50
57
|
}
|
|
51
58
|
|
|
52
59
|
/** Scan <root>/backlog/tasks/*.md for linked tasks. Unreadable files are skipped. */
|
|
@@ -117,6 +124,90 @@ export function checkBridge(root) {
|
|
|
117
124
|
return { links, problems, warnings };
|
|
118
125
|
}
|
|
119
126
|
|
|
127
|
+
/* ── plan: the exact backlog edits that reconcile the board ────────────── */
|
|
128
|
+
|
|
129
|
+
const PHASE_PREFIX = "Spec phase: ";
|
|
130
|
+
|
|
131
|
+
/** Single-quote a string for verbatim shell use. */
|
|
132
|
+
const sq = (s) => `'${String(s).replace(/'/g, "'\\''")}'`;
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Pure planner for ONE linked task: the ordered `backlog task edit` commands that reconcile
|
|
136
|
+
* it to its derived state. Command order matters and is: status move → stale phase-AC
|
|
137
|
+
* removals (highest index first, so earlier indexes stay valid) → phase-AC additions →
|
|
138
|
+
* check/uncheck at post-edit indexes → one progress note (only when something changed).
|
|
139
|
+
* ACs that don't start with "Spec phase: " are human-authored and are never touched.
|
|
140
|
+
*/
|
|
141
|
+
export function planLinkedTask(task, derived) {
|
|
142
|
+
const cmds = [];
|
|
143
|
+
const edit = (args) => cmds.push(`backlog task edit ${task.id} ${args}`);
|
|
144
|
+
|
|
145
|
+
// Status — Done-eligible is the only path to Done and carries the derived final summary.
|
|
146
|
+
const target = derived.status === STATUS.DONE_ELIGIBLE ? "Done" : derived.status;
|
|
147
|
+
const statusChanged = String(task.status).toLowerCase() !== target.toLowerCase();
|
|
148
|
+
if (statusChanged) {
|
|
149
|
+
if (target === "Done")
|
|
150
|
+
edit(`-s ${sq("Done")} --final-summary ${sq(`All spec tasks complete (${derived.progressNote}). Derived Done by spec-bridge sync.`)}`);
|
|
151
|
+
else edit(`-s ${sq(target)}`);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Phase-AC reconciliation. The bridge owns exactly the "Spec phase: " ACs.
|
|
155
|
+
const phaseByName = new Map((derived.phases || []).map((p) => [p.name, p]));
|
|
156
|
+
const seen = new Set();
|
|
157
|
+
const removed = new Set();
|
|
158
|
+
for (const a of task.acs || []) {
|
|
159
|
+
if (!a.text.startsWith(PHASE_PREFIX)) continue;
|
|
160
|
+
const name = a.text.slice(PHASE_PREFIX.length);
|
|
161
|
+
if (!phaseByName.has(name) || seen.has(name)) removed.add(a.index); // stale or duplicate
|
|
162
|
+
else seen.add(name);
|
|
163
|
+
}
|
|
164
|
+
for (const i of [...removed].sort((a, z) => z - a)) edit(`--remove-ac ${i}`);
|
|
165
|
+
|
|
166
|
+
const additions = (derived.phases || []).filter((p) => !seen.has(p.name));
|
|
167
|
+
for (const p of additions) edit(`--ac ${sq(PHASE_PREFIX + p.name)}`);
|
|
168
|
+
|
|
169
|
+
// Post-edit indexes: survivors keep their relative order and renumber from 1; additions
|
|
170
|
+
// append after them, unchecked. Check/uncheck against what each phase actually proves.
|
|
171
|
+
const survivors = (task.acs || []).filter((a) => !removed.has(a.index));
|
|
172
|
+
const finalList = [
|
|
173
|
+
...survivors.map((a) => ({ text: a.text, checked: a.checked })),
|
|
174
|
+
...additions.map((p) => ({ text: PHASE_PREFIX + p.name, checked: false })),
|
|
175
|
+
];
|
|
176
|
+
finalList.forEach((item, i) => {
|
|
177
|
+
if (!item.text.startsWith(PHASE_PREFIX)) return; // human-authored: never touched
|
|
178
|
+
const p = phaseByName.get(item.text.slice(PHASE_PREFIX.length));
|
|
179
|
+
if (!p) return;
|
|
180
|
+
const want = p.total > 0 && p.done === p.total;
|
|
181
|
+
if (want && !item.checked) edit(`--check-ac ${i + 1}`);
|
|
182
|
+
else if (!want && item.checked) edit(`--uncheck-ac ${i + 1}`);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
// One note, only when this sync changed something — no churn in the task history.
|
|
186
|
+
if (cmds.length) {
|
|
187
|
+
const suffix = statusChanged ? ` — status ${task.status} → ${target}` : "";
|
|
188
|
+
edit(`--append-notes ${sq(`spec-bridge sync: ${derived.progressNote}${suffix}`)}`);
|
|
189
|
+
}
|
|
190
|
+
return cmds;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Plan every linked task under <root>, in queue order. Returns:
|
|
195
|
+
* commands — ordered `backlog task edit` lines; empty on a reconciled board (no-op)
|
|
196
|
+
* skipped — [{ id, status }] for verdict-unknown tasks (custom status: don't guess)
|
|
197
|
+
* Read-only like everything in gates/: plan PRINTS edits, it never executes them.
|
|
198
|
+
*/
|
|
199
|
+
export function planBridge(root) {
|
|
200
|
+
const commands = [];
|
|
201
|
+
const skipped = [];
|
|
202
|
+
const requireAnalysis = loadBridgeConfig(root).strictDone === true;
|
|
203
|
+
for (const task of findLinkedTasks(root)) {
|
|
204
|
+
const derived = deriveSpecState(join(root, task.specDir), { requireAnalysis });
|
|
205
|
+
if (verdict(task.status, derived.status) === "unknown") { skipped.push({ id: task.id, status: task.status }); continue; }
|
|
206
|
+
commands.push(...planLinkedTask(task, derived));
|
|
207
|
+
}
|
|
208
|
+
return { commands, skipped };
|
|
209
|
+
}
|
|
210
|
+
|
|
120
211
|
/**
|
|
121
212
|
* The Stop-hook gate, in gate-runner shape. Roots are directories holding a backlog/ dir;
|
|
122
213
|
* a root with no linked tasks yields no problems, so the gate is a natural no-op outside
|
|
@@ -4,14 +4,17 @@
|
|
|
4
4
|
// node cli.mjs state <specDir> derived state for one spec dir, as JSON
|
|
5
5
|
// node cli.mjs links <root> every linked task under <root> with derived state + verdict, as JSON
|
|
6
6
|
// node cli.mjs check <root> human report; exit 1 if any task's status exceeds its artifacts
|
|
7
|
+
// node cli.mjs plan <root> the ordered `backlog task edit` commands that reconcile the
|
|
8
|
+
// board to the derived state (stdout; nothing on a reconciled
|
|
9
|
+
// board). Prints, NEVER executes — the sync skill runs them.
|
|
7
10
|
import { resolve } from "node:path";
|
|
8
11
|
import { deriveSpecState } from "../lib/spec-derive.mjs";
|
|
9
12
|
import { findRootUpwards, hasChild } from "../lib/project-root.mjs";
|
|
10
|
-
import { checkBridge, loadBridgeConfig } from "./bridge.mjs";
|
|
13
|
+
import { checkBridge, loadBridgeConfig, planBridge } from "./bridge.mjs";
|
|
11
14
|
|
|
12
15
|
const [cmd, target] = process.argv.slice(2);
|
|
13
16
|
if (!cmd || !target) {
|
|
14
|
-
console.error("usage: cli.mjs state <specDir> | links <root> | check <root>");
|
|
17
|
+
console.error("usage: cli.mjs state <specDir> | links <root> | check <root> | plan <root>");
|
|
15
18
|
process.exit(2);
|
|
16
19
|
}
|
|
17
20
|
|
|
@@ -31,6 +34,11 @@ if (cmd === "state") {
|
|
|
31
34
|
process.exit(1);
|
|
32
35
|
}
|
|
33
36
|
console.log(`spec-bridge ok: ${links.length} linked task(s), none exceed their artifacts`);
|
|
37
|
+
} else if (cmd === "plan") {
|
|
38
|
+
const { commands, skipped } = planBridge(target);
|
|
39
|
+
for (const s of skipped)
|
|
40
|
+
console.error(`# ${s.id}: status "${s.status}" is outside To Do/In Progress/Done — not planned; resolve by hand`);
|
|
41
|
+
for (const c of commands) console.log(c);
|
|
34
42
|
} else {
|
|
35
43
|
console.error(`unknown command: ${cmd}`);
|
|
36
44
|
process.exit(2);
|