@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,69 @@
|
|
|
1
|
+
// gate-runner.mjs — the shared Stop-hook harness.
|
|
2
|
+
//
|
|
3
|
+
// Each plugin ships a Stop hook whose command is a one-line Node entry that calls runStopHook
|
|
4
|
+
// with that plugin's gates. When several plugins are installed, each fires its own hook — so
|
|
5
|
+
// "run every applicable gate" happens naturally across plugins; within one plugin the runner
|
|
6
|
+
// runs that plugin's gates additively over every root they resolve.
|
|
7
|
+
//
|
|
8
|
+
// A gate is: { name, resolveRoots(startDir) -> string[], check(root) -> string[] (problems),
|
|
9
|
+
// warn?(root) -> string[] (non-blocking notices) }.
|
|
10
|
+
// A gate that resolves no roots is a no-op (this isn't its kind of project). `check` problems
|
|
11
|
+
// block the stop (exit 2); optional `warn` notices are surfaced on stderr but never block (exit 0)
|
|
12
|
+
// — for freshness reminders and the like that shouldn't refuse to let the model finish.
|
|
13
|
+
//
|
|
14
|
+
// Contract (Claude Code Stop hook): stdin is JSON with { stop_hook_active, cwd, … };
|
|
15
|
+
// exit 0 = allow the model to stop; exit 2 = block, and stderr becomes the message it sees.
|
|
16
|
+
|
|
17
|
+
/** Read all of stdin as a string (empty string on a TTY, so it's safe to run by hand). */
|
|
18
|
+
export function readStdin() {
|
|
19
|
+
return new Promise((resolve) => {
|
|
20
|
+
if (process.stdin.isTTY) return resolve("");
|
|
21
|
+
let data = "";
|
|
22
|
+
process.stdin.setEncoding("utf8");
|
|
23
|
+
process.stdin.on("data", (c) => (data += c));
|
|
24
|
+
process.stdin.on("end", () => resolve(data));
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Pure core: given the parsed hook input and the gates, return { block, message }.
|
|
30
|
+
* Separated from process I/O so it is unit-testable.
|
|
31
|
+
*/
|
|
32
|
+
export function evaluate(input, gates, { cwd = process.cwd() } = {}) {
|
|
33
|
+
if (input && input.stop_hook_active === true) return { block: false, message: "", warnings: "" };
|
|
34
|
+
const start = process.env.CLAUDE_PROJECT_DIR || (input && input.cwd) || cwd;
|
|
35
|
+
const problems = [];
|
|
36
|
+
const warnings = [];
|
|
37
|
+
for (const gate of gates) {
|
|
38
|
+
let roots = [];
|
|
39
|
+
try { roots = gate.resolveRoots(start) || []; } catch { roots = []; }
|
|
40
|
+
for (const root of roots) {
|
|
41
|
+
try { problems.push(...(gate.check(root) || [])); } catch (e) {
|
|
42
|
+
problems.push(`[${gate.name || "gate"}] crashed on ${root}: ${e.message}`);
|
|
43
|
+
}
|
|
44
|
+
if (typeof gate.warn === "function") {
|
|
45
|
+
try { warnings.push(...(gate.warn(root) || [])); } catch { /* warnings are best-effort */ }
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return { block: problems.length > 0, message: problems.join("\n"), warnings: warnings.join("\n") };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Full harness: read stdin, evaluate the gates, exit 0 (allow) or 2 (block, message on stderr).
|
|
54
|
+
* Blocking problems win; otherwise any non-blocking warnings are written to stderr and we still
|
|
55
|
+
* allow the stop (exit 0).
|
|
56
|
+
*/
|
|
57
|
+
export async function runStopHook({ gates, exit = process.exit } = {}) {
|
|
58
|
+
const raw = await readStdin();
|
|
59
|
+
let input = {};
|
|
60
|
+
try { input = JSON.parse(raw || "{}"); } catch { input = {}; }
|
|
61
|
+
const { block, message, warnings } = evaluate(input, gates);
|
|
62
|
+
if (block) {
|
|
63
|
+
process.stderr.write([message, warnings].filter(Boolean).join("\n") + "\n");
|
|
64
|
+
exit(2);
|
|
65
|
+
} else {
|
|
66
|
+
if (warnings) process.stderr.write(warnings + "\n");
|
|
67
|
+
exit(0);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
@@ -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
|
+
}
|