@praxisflux/gates 0.5.0
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 +74 -0
- package/codebase-to-course/gates/cli.mjs +20 -0
- package/codebase-to-course/gates/course.mjs +58 -0
- package/codebase-to-course/lib/README.md +8 -0
- package/codebase-to-course/lib/cli.mjs +23 -0
- package/codebase-to-course/lib/dates.mjs +11 -0
- package/codebase-to-course/lib/gate-runner.mjs +69 -0
- package/codebase-to-course/lib/handoff.mjs +75 -0
- package/codebase-to-course/lib/html/base.html +129 -0
- package/codebase-to-course/lib/installer.mjs +52 -0
- package/codebase-to-course/lib/lifecycle.mjs +67 -0
- package/codebase-to-course/lib/markdown.mjs +81 -0
- package/codebase-to-course/lib/project-root.mjs +52 -0
- package/codebase-to-course/lib/selfcontained.mjs +37 -0
- package/codebase-to-course/lib/spec-derive.mjs +114 -0
- package/codebase-to-course/lib/template.mjs +11 -0
- package/codebase-to-course/lib/toolkit/README.md +45 -0
- package/codebase-to-course/lib/toolkit/code-translation.md +50 -0
- package/codebase-to-course/lib/toolkit/diagrams.md +57 -0
- package/codebase-to-course/lib/toolkit/pedagogy.md +39 -0
- package/codebase-to-course/lib/toolkit/quiz-patterns.md +65 -0
- package/codebase-to-course/lib/toolkit/svg-diagrams.md +59 -0
- package/codebase-to-course/lib/toolkit/tooltip.md +83 -0
- package/codebase-to-course/skills/codebase-to-course/references/validate.mjs +301 -0
- package/grounding-wiki/gates/cli.mjs +26 -0
- package/grounding-wiki/gates/freshness.mjs +92 -0
- package/grounding-wiki/lib/README.md +8 -0
- package/grounding-wiki/lib/cli.mjs +23 -0
- package/grounding-wiki/lib/dates.mjs +11 -0
- package/grounding-wiki/lib/gate-runner.mjs +69 -0
- package/grounding-wiki/lib/handoff.mjs +75 -0
- package/grounding-wiki/lib/html/base.html +129 -0
- package/grounding-wiki/lib/installer.mjs +52 -0
- package/grounding-wiki/lib/lifecycle.mjs +67 -0
- package/grounding-wiki/lib/markdown.mjs +81 -0
- package/grounding-wiki/lib/project-root.mjs +52 -0
- package/grounding-wiki/lib/selfcontained.mjs +37 -0
- package/grounding-wiki/lib/spec-derive.mjs +114 -0
- package/grounding-wiki/lib/template.mjs +11 -0
- package/grounding-wiki/lib/toolkit/README.md +45 -0
- package/grounding-wiki/lib/toolkit/code-translation.md +50 -0
- package/grounding-wiki/lib/toolkit/diagrams.md +57 -0
- package/grounding-wiki/lib/toolkit/pedagogy.md +39 -0
- package/grounding-wiki/lib/toolkit/quiz-patterns.md +65 -0
- package/grounding-wiki/lib/toolkit/svg-diagrams.md +59 -0
- package/grounding-wiki/lib/toolkit/tooltip.md +83 -0
- package/lib/README.md +8 -0
- package/lib/cli.mjs +23 -0
- package/lib/dates.mjs +11 -0
- package/lib/gate-runner.mjs +69 -0
- package/lib/handoff.mjs +75 -0
- package/lib/html/base.html +129 -0
- package/lib/installer.mjs +52 -0
- package/lib/lifecycle.mjs +67 -0
- package/lib/markdown.mjs +81 -0
- package/lib/project-root.mjs +52 -0
- package/lib/selfcontained.mjs +37 -0
- package/lib/spec-derive.mjs +114 -0
- package/lib/template.mjs +11 -0
- package/lib/toolkit/README.md +45 -0
- package/lib/toolkit/code-translation.md +50 -0
- package/lib/toolkit/diagrams.md +57 -0
- package/lib/toolkit/pedagogy.md +39 -0
- package/lib/toolkit/quiz-patterns.md +65 -0
- package/lib/toolkit/svg-diagrams.md +59 -0
- package/lib/toolkit/tooltip.md +83 -0
- package/package.json +17 -0
- package/scripts/run-gates.mjs +91 -0
- package/spec-bridge/gates/bridge.mjs +130 -0
- package/spec-bridge/gates/cli.mjs +37 -0
- package/spec-bridge/lib/README.md +8 -0
- package/spec-bridge/lib/cli.mjs +23 -0
- package/spec-bridge/lib/dates.mjs +11 -0
- package/spec-bridge/lib/gate-runner.mjs +69 -0
- package/spec-bridge/lib/handoff.mjs +75 -0
- package/spec-bridge/lib/html/base.html +129 -0
- package/spec-bridge/lib/installer.mjs +52 -0
- package/spec-bridge/lib/lifecycle.mjs +67 -0
- package/spec-bridge/lib/markdown.mjs +81 -0
- package/spec-bridge/lib/project-root.mjs +52 -0
- package/spec-bridge/lib/selfcontained.mjs +37 -0
- package/spec-bridge/lib/spec-derive.mjs +114 -0
- package/spec-bridge/lib/template.mjs +11 -0
- package/spec-bridge/lib/toolkit/README.md +45 -0
- package/spec-bridge/lib/toolkit/code-translation.md +50 -0
- package/spec-bridge/lib/toolkit/diagrams.md +57 -0
- package/spec-bridge/lib/toolkit/pedagogy.md +39 -0
- package/spec-bridge/lib/toolkit/quiz-patterns.md +65 -0
- package/spec-bridge/lib/toolkit/svg-diagrams.md +59 -0
- package/spec-bridge/lib/toolkit/tooltip.md +83 -0
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
// handoff.mjs — the shared inter-plugin handoff TRANSPORT (envelope + lifecycle, not semantics).
|
|
2
|
+
//
|
|
3
|
+
// One plugin hands work to another through files, but those messages are transient plumbing —
|
|
4
|
+
// they must NOT clutter git. So payloads live in a gitignored `.handoff/` at the project root;
|
|
5
|
+
// the durable EVIDENCE that a handoff happened is the consumer's job to record in its own tracked
|
|
6
|
+
// state (e.g. progress.json). The chassis moves opaque payloads and never touches a plugin's ledger.
|
|
7
|
+
//
|
|
8
|
+
// The payload SCHEMA is per plugin pair (educate→build sends a SPEC; build→educate returns
|
|
9
|
+
// findings). This module only knows the envelope: id, kind (request|response), from, to, ref.
|
|
10
|
+
|
|
11
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync, readdirSync, renameSync } from "node:fs";
|
|
12
|
+
import { join } from "node:path";
|
|
13
|
+
import { ensureGitignore } from "./installer.mjs";
|
|
14
|
+
import { parseFrontmatter } from "./markdown.mjs";
|
|
15
|
+
|
|
16
|
+
export const HANDOFF_DIR = ".handoff";
|
|
17
|
+
|
|
18
|
+
export function handoffDir(root) { return join(root, HANDOFF_DIR); }
|
|
19
|
+
|
|
20
|
+
/** Ensure `.handoff/` exists and is gitignored (so it never shows in `git status`). */
|
|
21
|
+
export function ensureHandoffDir(root) {
|
|
22
|
+
const dir = handoffDir(root);
|
|
23
|
+
mkdirSync(dir, { recursive: true });
|
|
24
|
+
ensureGitignore(root, HANDOFF_DIR + "/");
|
|
25
|
+
return dir;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Write a handoff. Envelope fields are frontmatter; `body` is the opaque, plugin-defined payload.
|
|
30
|
+
* kind: "request" (e.g. a SPEC) | "response" (e.g. findings)
|
|
31
|
+
* ref: correlation id tying a response back to its request
|
|
32
|
+
* Returns the file path.
|
|
33
|
+
*/
|
|
34
|
+
export function writeHandoff(root, { id, kind, from, to, ref = "", title = "", body = "" }) {
|
|
35
|
+
if (!id || !kind || !from || !to) throw new Error("handoff requires id, kind, from, to");
|
|
36
|
+
const dir = ensureHandoffDir(root);
|
|
37
|
+
const fm = [`id: ${id}`, `kind: ${kind}`, `from: ${from}`, `to: ${to}`, `ref: ${ref}`, `title: ${title}`].join("\n");
|
|
38
|
+
const path = join(dir, `${id}.md`);
|
|
39
|
+
writeFileSync(path, `---\n${fm}\n---\n${body}\n`);
|
|
40
|
+
return path;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Read a handoff → { envelope, body, path } or null if absent. */
|
|
44
|
+
export function readHandoff(root, id) {
|
|
45
|
+
const path = join(handoffDir(root), `${id}.md`);
|
|
46
|
+
if (!existsSync(path)) return null;
|
|
47
|
+
const text = readFileSync(path, "utf8");
|
|
48
|
+
const envelope = parseFrontmatter(text) || {};
|
|
49
|
+
const body = text.replace(/^---[\s\S]*?\n---\s*\n?/, "");
|
|
50
|
+
return { envelope, body, path };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** List pending (unconsumed) handoffs, optionally filtered by envelope fields, e.g. {to, kind, ref}. */
|
|
54
|
+
export function listHandoffs(root, filter = {}) {
|
|
55
|
+
const dir = handoffDir(root);
|
|
56
|
+
if (!existsSync(dir)) return [];
|
|
57
|
+
return readdirSync(dir)
|
|
58
|
+
.filter((f) => f.endsWith(".md"))
|
|
59
|
+
.map((f) => readHandoff(root, f.replace(/\.md$/, "")))
|
|
60
|
+
.filter((h) => h && Object.entries(filter).every(([k, v]) => h.envelope[k] === v));
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Consume a handoff: move it into `.handoff/consumed/` so it stops appearing as pending. The
|
|
65
|
+
* durable record ("this handoff happened, here's what it changed") is the CONSUMER's job to write
|
|
66
|
+
* into its own tracked state — the chassis never edits a plugin's ledger. Returns false if absent.
|
|
67
|
+
*/
|
|
68
|
+
export function markConsumed(root, id) {
|
|
69
|
+
const src = join(handoffDir(root), `${id}.md`);
|
|
70
|
+
if (!existsSync(src)) return false;
|
|
71
|
+
const cdir = join(handoffDir(root), "consumed");
|
|
72
|
+
mkdirSync(cdir, { recursive: true });
|
|
73
|
+
renameSync(src, join(cdir, `${id}.md`));
|
|
74
|
+
return true;
|
|
75
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en" data-theme="auto">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<title>praxis — self-contained page base</title>
|
|
7
|
+
<!--
|
|
8
|
+
base.html — the SHARED, self-contained, theme-aware foundation for every praxis HTML artifact
|
|
9
|
+
(educate decks AND research briefings). Copy the token block, reset, theme-toggle, and table
|
|
10
|
+
component from here; never load anything external (the Artifact CSP blocks remote hosts —
|
|
11
|
+
inline all CSS/JS, embed images as data: URIs). Validate the result with lib/selfcontained.mjs.
|
|
12
|
+
|
|
13
|
+
Theme contract: style with the tokens below. Light is the default; dark is provided both by
|
|
14
|
+
@media (prefers-color-scheme: dark) AND by :root[data-theme="dark"], and an explicit
|
|
15
|
+
:root[data-theme="light"] block lets the on-page toggle override the OS in either direction.
|
|
16
|
+
-->
|
|
17
|
+
<style>
|
|
18
|
+
/* The canonical token schema. Hand-edit ONLY here (between the praxis:tokens markers);
|
|
19
|
+
consumers (educate's deck template) carry a stamped copy — scripts/sync-shared.mjs. */
|
|
20
|
+
/* praxis:tokens:start */
|
|
21
|
+
/* ---- design tokens (light) ---- */
|
|
22
|
+
:root{
|
|
23
|
+
--bg:#f4f6f9; --card:#ffffff; --ink:#1b2733; --muted:#5a6776;
|
|
24
|
+
--accent:#4f46e5; --accent2:#0e9488; --warn:#b4530a;
|
|
25
|
+
--line:#dde3ea; --chip:#f3f5f8;
|
|
26
|
+
--callout-bg:#f5f5ff; --warn-bg:#fff6ee; --ok-bg:#eefaf7;
|
|
27
|
+
--sans:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;
|
|
28
|
+
--mono:"SF Mono",ui-monospace,"JetBrains Mono",Menlo,Consolas,monospace;
|
|
29
|
+
}
|
|
30
|
+
/* ---- dark tokens: follow the OS by default ... ---- */
|
|
31
|
+
@media (prefers-color-scheme: dark){
|
|
32
|
+
:root{
|
|
33
|
+
--bg:#0e141b; --card:#161d26; --ink:#e7ecf2; --muted:#9aa7b5;
|
|
34
|
+
--accent:#8b83ff; --accent2:#3ac9b8; --warn:#e8903f;
|
|
35
|
+
--line:#28323e; --chip:#1d2630;
|
|
36
|
+
--callout-bg:#1b1d38; --warn-bg:#33220f; --ok-bg:#0f2a27;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
/* ... and the on-page toggle wins in either direction. */
|
|
40
|
+
:root[data-theme="dark"]{
|
|
41
|
+
--bg:#0e141b; --card:#161d26; --ink:#e7ecf2; --muted:#9aa7b5;
|
|
42
|
+
--accent:#8b83ff; --accent2:#3ac9b8; --warn:#e8903f;
|
|
43
|
+
--line:#28323e; --chip:#1d2630;
|
|
44
|
+
--callout-bg:#1b1d38; --warn-bg:#33220f; --ok-bg:#0f2a27;
|
|
45
|
+
}
|
|
46
|
+
:root[data-theme="light"]{
|
|
47
|
+
--bg:#f4f6f9; --card:#ffffff; --ink:#1b2733; --muted:#5a6776;
|
|
48
|
+
--accent:#4f46e5; --accent2:#0e9488; --warn:#b4530a;
|
|
49
|
+
--line:#dde3ea; --chip:#f3f5f8;
|
|
50
|
+
--callout-bg:#f5f5ff; --warn-bg:#fff6ee; --ok-bg:#eefaf7;
|
|
51
|
+
}
|
|
52
|
+
/* praxis:tokens:end */
|
|
53
|
+
|
|
54
|
+
/* ---- reset + base ---- */
|
|
55
|
+
*{box-sizing:border-box}
|
|
56
|
+
html,body{margin:0}
|
|
57
|
+
body{background:var(--bg);color:var(--ink);font-family:var(--sans);line-height:1.55;
|
|
58
|
+
-webkit-text-size-adjust:100%;padding:0}
|
|
59
|
+
.wrap{max-width:900px;margin:0 auto;padding:40px 24px 80px}
|
|
60
|
+
h1{font-size:2rem;line-height:1.15;letter-spacing:-.02em;margin:0 0 .4em}
|
|
61
|
+
h2{font-size:1.4rem;margin:1.8em 0 .5em;letter-spacing:-.01em}
|
|
62
|
+
p,li{font-size:1.05rem;color:var(--ink)}
|
|
63
|
+
.muted{color:var(--muted)}
|
|
64
|
+
a{color:var(--accent)}
|
|
65
|
+
code,.mono{font-family:var(--mono);background:var(--chip);border:1px solid var(--line);
|
|
66
|
+
border-radius:6px;padding:1px 6px;font-size:.9em}
|
|
67
|
+
.callout{border-left:4px solid var(--accent);background:var(--callout-bg);
|
|
68
|
+
border-radius:0 10px 10px 0;padding:14px 18px;margin:16px 0}
|
|
69
|
+
.callout.warn{border-left-color:var(--warn);background:var(--warn-bg)}
|
|
70
|
+
.callout.ok{border-left-color:var(--accent2);background:var(--ok-bg)}
|
|
71
|
+
|
|
72
|
+
/* ---- auditable data table (numbers must trace) ---- */
|
|
73
|
+
table.data{border-collapse:collapse;width:100%;margin:16px 0;font-size:.98rem}
|
|
74
|
+
table.data caption{text-align:left;color:var(--muted);font-size:.9rem;margin-bottom:6px}
|
|
75
|
+
table.data th,table.data td{border:1px solid var(--line);padding:8px 12px;text-align:left}
|
|
76
|
+
table.data thead th{background:var(--chip);font-weight:700}
|
|
77
|
+
table.data td.num{text-align:right;font-variant-numeric:tabular-nums;font-family:var(--mono)}
|
|
78
|
+
.table-scroll{overflow-x:auto}
|
|
79
|
+
|
|
80
|
+
/* ---- theme toggle ---- */
|
|
81
|
+
.theme-toggle{position:fixed;top:14px;right:14px;z-index:50;font:inherit;font-size:.9rem;
|
|
82
|
+
color:var(--ink);background:var(--card);border:1px solid var(--line);border-radius:8px;
|
|
83
|
+
padding:6px 12px;cursor:pointer}
|
|
84
|
+
.theme-toggle:hover{background:var(--chip)}
|
|
85
|
+
@media print{ .theme-toggle{display:none} body{background:#fff} }
|
|
86
|
+
</style>
|
|
87
|
+
</head>
|
|
88
|
+
<body>
|
|
89
|
+
<button class="theme-toggle" id="themeToggle" aria-label="Toggle light/dark theme">◐ theme</button>
|
|
90
|
+
<div class="wrap">
|
|
91
|
+
<h1>Self-contained page base</h1>
|
|
92
|
+
<p class="muted">The shared foundation for praxis artifacts — theme-aware, offline, CSP-safe.
|
|
93
|
+
Replace this content; keep the tokens, reset, toggle, and table.</p>
|
|
94
|
+
|
|
95
|
+
<div class="callout">Lead with the point. A briefing leads with the analysis's verdict; a deck
|
|
96
|
+
leads with the lesson's claim.</div>
|
|
97
|
+
|
|
98
|
+
<h2>Auditable figures</h2>
|
|
99
|
+
<p>Every number an artifact shows should trace to its source. Include a table so a reader can check.</p>
|
|
100
|
+
<div class="table-scroll">
|
|
101
|
+
<table class="data">
|
|
102
|
+
<caption>Example — figures trace to the grounding/analysis</caption>
|
|
103
|
+
<thead><tr><th>Option</th><th class="num">Cost</th><th>Note</th></tr></thead>
|
|
104
|
+
<tbody>
|
|
105
|
+
<tr><td>A</td><td class="num">1,200</td><td>baseline</td></tr>
|
|
106
|
+
<tr><td>B</td><td class="num">1,850</td><td>faster, pricier</td></tr>
|
|
107
|
+
</tbody>
|
|
108
|
+
</table>
|
|
109
|
+
</div>
|
|
110
|
+
</div>
|
|
111
|
+
<script>
|
|
112
|
+
// The shared theme-cycle core. Hand-edit ONLY here (between the praxis:theme markers);
|
|
113
|
+
// consumers carry a stamped copy — scripts/sync-shared.mjs.
|
|
114
|
+
// praxis:theme:start
|
|
115
|
+
// Cycles auto -> light -> dark, stamping data-theme on <html> so the CSS token overrides win
|
|
116
|
+
// over the OS preference. No storage, no external calls. State rides on the button
|
|
117
|
+
// (dataset.mode) so any page can bind its own button.
|
|
118
|
+
function praxisCycleTheme(btn){
|
|
119
|
+
const order=["auto","light","dark"], label={auto:"◐ auto",light:"☀ light",dark:"☾ dark"};
|
|
120
|
+
const next=order[(order.indexOf(btn.dataset.mode||"auto")+1)%order.length];
|
|
121
|
+
if(next==="auto") document.documentElement.removeAttribute("data-theme");
|
|
122
|
+
else document.documentElement.setAttribute("data-theme",next);
|
|
123
|
+
btn.dataset.mode=next; btn.textContent=label[next]; return next;
|
|
124
|
+
}
|
|
125
|
+
// praxis:theme:end
|
|
126
|
+
document.getElementById('themeToggle').addEventListener('click',(e)=>praxisCycleTheme(e.currentTarget));
|
|
127
|
+
</script>
|
|
128
|
+
</body>
|
|
129
|
+
</html>
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// installer.mjs — the shared "plant a project" helpers used by each plugin's start skill.
|
|
2
|
+
//
|
|
3
|
+
// A plugin has no always-on context slot, so it installs a project CLAUDE.md + scaffolding.
|
|
4
|
+
// These helpers make that dotfile-safe, idempotent, and honest (verify on disk, never clobber
|
|
5
|
+
// what the user has grown). Zero-dependency.
|
|
6
|
+
|
|
7
|
+
import { cpSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
8
|
+
import { join, dirname } from "node:path";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Recursively copy `src` → `dest`. Uses node:fs cp, which copies dotfiles (e.g. `.template/`)
|
|
12
|
+
* — the copy helpers that silently drop `.`-prefixed names are exactly what this avoids.
|
|
13
|
+
* `overwrite:false` leaves an existing dest untouched.
|
|
14
|
+
*/
|
|
15
|
+
export function copyDir(src, dest, { overwrite = true } = {}) {
|
|
16
|
+
cpSync(src, dest, { recursive: true, force: overwrite, errorOnExist: false });
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Copy a single file, creating parent dirs. Returns false if it existed and overwrite is off. */
|
|
20
|
+
export function copyFile(src, dest, { overwrite = true } = {}) {
|
|
21
|
+
if (!overwrite && existsSync(dest)) return false;
|
|
22
|
+
mkdirSync(dirname(dest), { recursive: true });
|
|
23
|
+
cpSync(src, dest, { force: true });
|
|
24
|
+
return true;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Ensure `.gitignore` in `root` contains `entry` (e.g. `.handoff/`), creating the file if
|
|
29
|
+
* absent and preserving existing lines. Idempotent — returns true only if it added the line.
|
|
30
|
+
*/
|
|
31
|
+
export function ensureGitignore(root, entry) {
|
|
32
|
+
const p = join(root, ".gitignore");
|
|
33
|
+
const want = entry.trim();
|
|
34
|
+
const existing = existsSync(p) ? readFileSync(p, "utf8") : "";
|
|
35
|
+
if (existing.split("\n").some((l) => l.trim() === want)) return false;
|
|
36
|
+
const prefix = existing === "" ? "" : existing.replace(/\n*$/, "\n");
|
|
37
|
+
writeFileSync(p, prefix + want + "\n");
|
|
38
|
+
return true;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Which of `relPaths` (relative to `root`) are missing on disk. Empty = all present. */
|
|
42
|
+
export function verifyPresent(root, relPaths) {
|
|
43
|
+
return relPaths.filter((r) => !existsSync(join(root, r)));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Decide install mode from the project root: `fresh` if none of the `markers` exist yet,
|
|
48
|
+
* else `update`. Lets a start skill branch between a clean install and an idempotent migrate.
|
|
49
|
+
*/
|
|
50
|
+
export function installMode(root, markers) {
|
|
51
|
+
return markers.some((m) => existsSync(join(root, m))) ? "update" : "fresh";
|
|
52
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// lifecycle.mjs — the "a status cannot exceed the artifacts that prove it" engine.
|
|
2
|
+
//
|
|
3
|
+
// Generalized from educate's progress.mjs. A plugin declares an ordered set of states, a map
|
|
4
|
+
// of artifact keys → filenames, and which artifacts each state requires. The engine derives
|
|
5
|
+
// the artifact map from disk and flags any item whose declared status outranks its evidence.
|
|
6
|
+
//
|
|
7
|
+
// Vocabulary stays per-plugin (educate: scaffolded…done; research: branch→analyzed→rendered);
|
|
8
|
+
// only the mechanism is shared.
|
|
9
|
+
|
|
10
|
+
import { existsSync } from "node:fs";
|
|
11
|
+
import { join } from "node:path";
|
|
12
|
+
|
|
13
|
+
/** Derive { key: bool } for each artifact by checking its filename on disk in `dir`. */
|
|
14
|
+
export function computeArtifacts(dir, artifactFiles) {
|
|
15
|
+
const out = {};
|
|
16
|
+
for (const [key, file] of Object.entries(artifactFiles)) out[key] = existsSync(join(dir, file));
|
|
17
|
+
return out;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Build a lifecycle checker.
|
|
22
|
+
* states: ordered array of status names (index = rank)
|
|
23
|
+
* artifacts: { key: filename }
|
|
24
|
+
* requires: { state: [artifactKey, …] } — artifacts that must exist on disk once an item
|
|
25
|
+
* is at or beyond that state.
|
|
26
|
+
*/
|
|
27
|
+
export function createLifecycle({ states, artifacts, requires = {} }) {
|
|
28
|
+
const rank = new Map(states.map((s, i) => [s, i]));
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Problems (strings) for an item at `status` living in `dir`. Empty array = consistent.
|
|
32
|
+
* `extraRequires` lets a caller add dynamically-required artifacts (e.g. a delegated build
|
|
33
|
+
* adds handoff artifacts) keyed the same way as `requires`.
|
|
34
|
+
*/
|
|
35
|
+
const check = (dir, status, extraRequires = {}) => {
|
|
36
|
+
const problems = [];
|
|
37
|
+
const statusRank = rank.get(status);
|
|
38
|
+
if (statusRank === undefined) {
|
|
39
|
+
problems.push(`unknown status ${JSON.stringify(status)} (expected one of ${states.join(", ")})`);
|
|
40
|
+
return problems;
|
|
41
|
+
}
|
|
42
|
+
const present = computeArtifacts(dir, artifacts);
|
|
43
|
+
const merged = { ...requires };
|
|
44
|
+
for (const [state, keys] of Object.entries(extraRequires)) {
|
|
45
|
+
merged[state] = [...(merged[state] || []), ...keys];
|
|
46
|
+
}
|
|
47
|
+
// Collapse all in-force requirements to the earliest state demanding each artifact key,
|
|
48
|
+
// so a key required by several states is reported at most once.
|
|
49
|
+
const requiredBy = new Map(); // key -> state name (lowest rank in force)
|
|
50
|
+
for (const [state, keys] of Object.entries(merged)) {
|
|
51
|
+
const r = rank.get(state);
|
|
52
|
+
if (r === undefined || r > statusRank) continue; // requirement not yet in force
|
|
53
|
+
for (const key of keys) {
|
|
54
|
+
const prev = requiredBy.get(key);
|
|
55
|
+
if (prev === undefined || r < rank.get(prev)) requiredBy.set(key, state);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
for (const [key, state] of requiredBy) {
|
|
59
|
+
if (!present[key]) {
|
|
60
|
+
problems.push(`status=${status} implies '${state}' but required artifact '${key}' (${artifacts[key] || key}) is missing on disk`);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return problems;
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
return { states, artifacts, rank, compute: (dir) => computeArtifacts(dir, artifacts), check };
|
|
67
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// markdown.mjs — the small slice of Markdown/YAML/wikilink parsing the gates need.
|
|
2
|
+
//
|
|
3
|
+
// Ported from the research gates' Python (verify_branch/verify_analysis). Deliberately
|
|
4
|
+
// minimal: inline `key: value` and inline `[key]` arrays in frontmatter, code-span
|
|
5
|
+
// stripping, and Obsidian `[[wikilink]]` extraction. Zero-dependency.
|
|
6
|
+
|
|
7
|
+
const FENCE_RE = /```[\s\S]*?```/g;
|
|
8
|
+
const INLINE_CODE_RE = /`[^`]*`/g;
|
|
9
|
+
const LINK_RE = /\[\[([^\]]+)\]\]/g;
|
|
10
|
+
|
|
11
|
+
const unquote = (s) => s.replace(/^["']|["']$/g, "");
|
|
12
|
+
|
|
13
|
+
/** Remove fenced and inline code so links inside code spans are ignored. */
|
|
14
|
+
export function stripCode(text) {
|
|
15
|
+
return (text || "").replace(FENCE_RE, "").replace(INLINE_CODE_RE, "");
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Parse a leading `---` YAML frontmatter block into a flat object, or null if the
|
|
20
|
+
* file has no block (i.e. it is not a vault note). Values that look like `[a, b]`
|
|
21
|
+
* become arrays; everything else is an unquoted string. `type` is lowercased.
|
|
22
|
+
*/
|
|
23
|
+
export function parseFrontmatter(text) {
|
|
24
|
+
if (!text) return null;
|
|
25
|
+
const t = text.replace(/^/, "").replace(/^\s+/, "");
|
|
26
|
+
if (!t.startsWith("---")) return null;
|
|
27
|
+
const m = /^---\s*\n([\s\S]*?)\n---\s*(?:\n|$)/.exec(t);
|
|
28
|
+
if (!m) return null;
|
|
29
|
+
const fm = {};
|
|
30
|
+
for (const line of m[1].split("\n")) {
|
|
31
|
+
const kv = /^([A-Za-z0-9_]+):\s*(.*)$/.exec(line);
|
|
32
|
+
if (!kv) continue;
|
|
33
|
+
const key = kv[1].toLowerCase();
|
|
34
|
+
const val = kv[2].trim();
|
|
35
|
+
if (val.startsWith("[") && val.endsWith("]")) {
|
|
36
|
+
fm[key] = val.slice(1, -1).split(",").map((s) => unquote(s.trim())).filter(Boolean);
|
|
37
|
+
} else {
|
|
38
|
+
fm[key] = unquote(val);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
if (typeof fm.type === "string") fm.type = fm.type.toLowerCase();
|
|
42
|
+
return fm;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Reduce a raw wikilink body to its target note name (drop `|alias`, `#head`, `^block`). */
|
|
46
|
+
export function linkTarget(raw) {
|
|
47
|
+
return raw.split("|")[0].split("#")[0].split("^")[0].trim();
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Every wikilink target in `text`, ignoring links inside code spans. */
|
|
51
|
+
export function extractWikilinks(text) {
|
|
52
|
+
const stripped = stripCode(text);
|
|
53
|
+
const out = [];
|
|
54
|
+
let m;
|
|
55
|
+
LINK_RE.lastIndex = 0;
|
|
56
|
+
while ((m = LINK_RE.exec(stripped)) !== null) out.push(linkTarget(m[1]));
|
|
57
|
+
return out;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** The set of names a note answers to: its filename stem, title, and aliases (lowercased). */
|
|
61
|
+
export function namesFor(basenameNoExt, fm) {
|
|
62
|
+
const names = new Set([basenameNoExt.toLowerCase()]);
|
|
63
|
+
if (fm) {
|
|
64
|
+
if (fm.title) names.add(String(fm.title).toLowerCase());
|
|
65
|
+
for (const a of fm.aliases || []) names.add(String(a).toLowerCase());
|
|
66
|
+
}
|
|
67
|
+
return names;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Partition wikilink targets into those that resolve against `knownNames` (a Set of
|
|
72
|
+
* lowercased names) and those that do not (cross-branch or broken).
|
|
73
|
+
*/
|
|
74
|
+
export function resolveLinks(targets, knownNames) {
|
|
75
|
+
const resolved = [], unresolved = [];
|
|
76
|
+
for (const t of targets) {
|
|
77
|
+
if (!t) continue;
|
|
78
|
+
(knownNames.has(t.toLowerCase()) ? resolved : unresolved).push(t);
|
|
79
|
+
}
|
|
80
|
+
return { resolved, unresolved };
|
|
81
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// project-root.mjs — locate a project root by walking the filesystem.
|
|
2
|
+
//
|
|
3
|
+
// Two shapes, because the suite has two placement models:
|
|
4
|
+
// - educate lives in a favored home folder, marked by a child (e.g. `topics/`). Walk UP.
|
|
5
|
+
// - research is drop-anywhere; a tree may contain several vaults. Find roots DOWN.
|
|
6
|
+
//
|
|
7
|
+
// Zero-dependency. Marker functions decide what "a root" means for each plugin.
|
|
8
|
+
|
|
9
|
+
import { existsSync, readdirSync } from "node:fs";
|
|
10
|
+
import { join, resolve, dirname, parse } from "node:path";
|
|
11
|
+
|
|
12
|
+
/** A marker function: presence of a child file/dir named `name`. */
|
|
13
|
+
export function hasChild(name) {
|
|
14
|
+
return (dir) => existsSync(join(dir, name));
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Walk up from `startDir` until `markerFn(dir)` is truthy. Returns the matching
|
|
19
|
+
* directory (absolute) or null if the filesystem root is reached without a match.
|
|
20
|
+
*/
|
|
21
|
+
export function findRootUpwards(startDir, markerFn) {
|
|
22
|
+
let dir = resolve(startDir);
|
|
23
|
+
const { root: fsRoot } = parse(dir);
|
|
24
|
+
for (;;) {
|
|
25
|
+
if (markerFn(dir)) return dir;
|
|
26
|
+
if (dir === fsRoot) return null;
|
|
27
|
+
dir = dirname(dir);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const defaultSkip = (name) => name.startsWith(".") || name === "node_modules";
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Find every directory at or under `startDir` for which `markerFn` is truthy.
|
|
35
|
+
* Does not descend into a matched root (a vault owns its subtree) or into skipped
|
|
36
|
+
* dirs (hidden / node_modules by default). Bounded by `maxDepth`.
|
|
37
|
+
*/
|
|
38
|
+
export function findRootsDownwards(startDir, markerFn, { skip = defaultSkip, maxDepth = 8 } = {}) {
|
|
39
|
+
const roots = [];
|
|
40
|
+
const walk = (dir, depth) => {
|
|
41
|
+
if (markerFn(dir)) { roots.push(dir); return; } // matched → don't descend
|
|
42
|
+
if (depth >= maxDepth) return;
|
|
43
|
+
let entries;
|
|
44
|
+
try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return; }
|
|
45
|
+
for (const e of entries) {
|
|
46
|
+
if (!e.isDirectory() || skip(e.name)) continue;
|
|
47
|
+
walk(join(dir, e.name), depth + 1);
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
walk(resolve(startDir), 0);
|
|
51
|
+
return roots;
|
|
52
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// selfcontained.mjs — verify a rendered HTML page is self-contained and CSP-safe.
|
|
2
|
+
//
|
|
3
|
+
// Ported from research's verify_artifact.py. The Artifact host enforces a strict CSP that
|
|
4
|
+
// blocks every external host, so a page that loads a remote script/font/style/image silently
|
|
5
|
+
// breaks. Both educate's deck.html and research's *-briefing.html are checked with this.
|
|
6
|
+
//
|
|
7
|
+
// Returns { ok, fails: string[], warns: string[] } — no process exit here; callers decide.
|
|
8
|
+
|
|
9
|
+
const EXT = "https?://";
|
|
10
|
+
const CHECKS = [
|
|
11
|
+
[new RegExp(`<script[^>]+\\bsrc\\s*=\\s*['"]?${EXT}`, "i"), "external <script src>"],
|
|
12
|
+
[new RegExp(`<link[^>]+\\bhref\\s*=\\s*['"]?${EXT}`, "i"), "external <link href> (stylesheet/font)"],
|
|
13
|
+
[new RegExp(`<(?:img|source|image)[^>]+\\bsrc\\s*=\\s*['"]?${EXT}`, "i"), "external image src"],
|
|
14
|
+
[new RegExp(`<[^>]+\\bsrcset\\s*=\\s*['"][^'"]*${EXT}`, "i"), "external srcset"],
|
|
15
|
+
[new RegExp(`url\\(\\s*['"]?${EXT}`, "i"), "external CSS url()"],
|
|
16
|
+
[new RegExp(`@import\\s+(?:url\\()?\\s*['"]?${EXT}`, "i"), "external @import"],
|
|
17
|
+
[new RegExp(`\\bfetch\\s*\\(\\s*['"]${EXT}`, "i"), "runtime fetch() to external host"],
|
|
18
|
+
[new RegExp(`\\.open\\s*\\(\\s*['"][A-Z]+['"]\\s*,\\s*['"]${EXT}`, "i"), "XHR to external host"],
|
|
19
|
+
];
|
|
20
|
+
|
|
21
|
+
/** Check an HTML string. `substantialBytes` is the size above which a data table is expected. */
|
|
22
|
+
export function checkHtml(html, { substantialBytes = 6000 } = {}) {
|
|
23
|
+
const fails = [], warns = [];
|
|
24
|
+
if (!/<title>\s*\S/i.test(html)) fails.push("no <title> — the artifact needs a name");
|
|
25
|
+
for (const [rx, label] of CHECKS) {
|
|
26
|
+
if (rx.test(html)) {
|
|
27
|
+
fails.push(`loads a resource from an external host: ${label} — the Artifact CSP blocks this; inline it or embed as a data: URI`);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
if (!/prefers-color-scheme/i.test(html)) {
|
|
31
|
+
warns.push("no @media (prefers-color-scheme) — page may not adapt to dark/light themes");
|
|
32
|
+
}
|
|
33
|
+
if (html.length > substantialBytes && !/<table/i.test(html)) {
|
|
34
|
+
warns.push("no <table> found — consider a data table so the figures are auditable");
|
|
35
|
+
}
|
|
36
|
+
return { ok: fails.length === 0, fails, warns };
|
|
37
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
// spec-derive.mjs — derive a kanban status from a Spec Kit spec directory.
|
|
2
|
+
//
|
|
3
|
+
// The spec-bridge contract: the spec dir (specs/NNN-feature/) is the source of truth and this
|
|
4
|
+
// module is the ONLY place its lifecycle is interpreted. It is a pure, stateless read — every
|
|
5
|
+
// call re-reads spec.md / plan.md / tasks.md and re-derives, so a regenerated tasks.md
|
|
6
|
+
// (renamed, added, or removed phases) is a non-event: nothing is cached, no residue survives.
|
|
7
|
+
//
|
|
8
|
+
// Status rules (gaps between the canonical Spec Kit stages resolve to the nearer honest state):
|
|
9
|
+
// - no spec.md -> "To Do"
|
|
10
|
+
// - spec.md present, not yet Done-eligible -> "In Progress"
|
|
11
|
+
// - plan.md present AND tasks.md has >=1 task, all
|
|
12
|
+
// checked -> "Done-eligible"
|
|
13
|
+
// "Done-eligible" deliberately isn't "Done": the sync skill may move the Backlog task to Done,
|
|
14
|
+
// and the bridge gate treats a Done status without this derivation as a blocking problem.
|
|
15
|
+
//
|
|
16
|
+
// Strict mode ({ requireAnalysis: true }): checked boxes are necessary but weak proof, so
|
|
17
|
+
// Done-eligible additionally requires an analysis report saved as analysis.md in the spec dir
|
|
18
|
+
// (the durable artifact of /speckit.analyze — chat output doesn't count) with no unresolved
|
|
19
|
+
// CRITICAL findings. The scan is line-based: a line containing the word CRITICAL counts as an
|
|
20
|
+
// unresolved finding unless the same line says "resolved" (or carries a checked box).
|
|
21
|
+
|
|
22
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
23
|
+
import { join } from "node:path";
|
|
24
|
+
|
|
25
|
+
export const STATUS = {
|
|
26
|
+
TODO: "To Do",
|
|
27
|
+
IN_PROGRESS: "In Progress",
|
|
28
|
+
DONE_ELIGIBLE: "Done-eligible",
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const PHASE_HEADING = /^##\s+(.+?)\s*$/;
|
|
32
|
+
const TASK_LINE = /^\s*[-*]\s+\[([ xX])\]\s+\S/;
|
|
33
|
+
|
|
34
|
+
/** Strip Spec Kit's "Phase 3.1:" style prefix so phase names read as AC labels ("Setup"). */
|
|
35
|
+
function phaseName(heading) {
|
|
36
|
+
return heading.replace(/^phase\s+[\d.]+\s*:\s*/i, "").trim() || heading;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Pure parser: tasks.md markdown -> [{ name, done, total }] in document order.
|
|
41
|
+
* A phase is a `##` heading; a task is any checkbox list line under it. Checkbox lines before
|
|
42
|
+
* the first heading are collected under a synthetic "Tasks" phase. Phases with no checkbox
|
|
43
|
+
* lines (e.g. a "Dependencies" notes section) are dropped.
|
|
44
|
+
*/
|
|
45
|
+
export function parseTasks(markdown) {
|
|
46
|
+
const phases = [];
|
|
47
|
+
let current = null;
|
|
48
|
+
for (const line of String(markdown ?? "").split("\n")) {
|
|
49
|
+
const heading = line.match(PHASE_HEADING);
|
|
50
|
+
if (heading) {
|
|
51
|
+
current = { name: phaseName(heading[1]), done: 0, total: 0 };
|
|
52
|
+
phases.push(current);
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
const task = line.match(TASK_LINE);
|
|
56
|
+
if (task) {
|
|
57
|
+
if (!current) {
|
|
58
|
+
current = { name: "Tasks", done: 0, total: 0 };
|
|
59
|
+
phases.push(current);
|
|
60
|
+
}
|
|
61
|
+
current.total += 1;
|
|
62
|
+
if (task[1] !== " ") current.done += 1;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return phases.filter((p) => p.total > 0);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** "Setup: 2/2 · Core: 4/7" — one segment per phase, empty string when there are no tasks. */
|
|
69
|
+
export function progressNote(phases) {
|
|
70
|
+
return phases.map((p) => `${p.name}: ${p.done}/${p.total}`).join(" · ");
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Unresolved CRITICAL findings in an analysis report, line-based: any line containing the
|
|
75
|
+
* word CRITICAL that isn't marked handled on that same line ("resolved" or a checked box).
|
|
76
|
+
*/
|
|
77
|
+
export function findCriticalFindings(markdown) {
|
|
78
|
+
return String(markdown ?? "")
|
|
79
|
+
.split("\n")
|
|
80
|
+
.filter((l) => /\bCRITICAL\b/.test(l) && !/resolved/i.test(l) && !/\[[xX]\]/.test(l))
|
|
81
|
+
.map((l) => l.trim());
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Derive the bridge state for one spec dir. Never throws: unreadable or malformed files
|
|
86
|
+
* degrade to "that artifact isn't there yet", so a broken dir derives as an early stage
|
|
87
|
+
* rather than crashing a sync or a Stop hook.
|
|
88
|
+
*/
|
|
89
|
+
export function deriveSpecState(specDir, { requireAnalysis = false } = {}) {
|
|
90
|
+
const has = (name) => existsSync(join(specDir, name));
|
|
91
|
+
const read = (name) => {
|
|
92
|
+
try { return has(name) ? readFileSync(join(specDir, name), "utf8") : ""; } catch { return ""; }
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
const phases = parseTasks(read("tasks.md"));
|
|
96
|
+
const tasksTotal = phases.reduce((n, p) => n + p.total, 0);
|
|
97
|
+
const tasksDone = phases.reduce((n, p) => n + p.done, 0);
|
|
98
|
+
|
|
99
|
+
const analysisPresent = has("analysis.md");
|
|
100
|
+
const criticals = requireAnalysis && analysisPresent ? findCriticalFindings(read("analysis.md")) : [];
|
|
101
|
+
const analysisOk = !requireAnalysis || (analysisPresent && criticals.length === 0);
|
|
102
|
+
|
|
103
|
+
let status = STATUS.TODO;
|
|
104
|
+
if (has("spec.md")) {
|
|
105
|
+
status = has("plan.md") && tasksTotal > 0 && tasksDone === tasksTotal && analysisOk
|
|
106
|
+
? STATUS.DONE_ELIGIBLE
|
|
107
|
+
: STATUS.IN_PROGRESS;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return {
|
|
111
|
+
status, phases, tasksDone, tasksTotal, progressNote: progressNote(phases),
|
|
112
|
+
analysis: { required: requireAnalysis, present: analysisPresent, criticals },
|
|
113
|
+
};
|
|
114
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// template.mjs — the tiny {{PLACEHOLDER}} substitution used when planting boilerplate.
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Replace `{{NAME}}` tokens in `text` with `vars[NAME]`. Unknown tokens are left
|
|
5
|
+
* verbatim so a template can be rendered in passes. Names are `[A-Z0-9_]+`.
|
|
6
|
+
*/
|
|
7
|
+
export function render(text, vars = {}) {
|
|
8
|
+
return (text || "").replace(/\{\{\s*([A-Z0-9_]+)\s*\}\}/g, (whole, key) =>
|
|
9
|
+
Object.prototype.hasOwnProperty.call(vars, key) ? String(vars[key]) : whole,
|
|
10
|
+
);
|
|
11
|
+
}
|