@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.
Files changed (91) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +74 -0
  3. package/codebase-to-course/gates/cli.mjs +20 -0
  4. package/codebase-to-course/gates/course.mjs +58 -0
  5. package/codebase-to-course/lib/README.md +8 -0
  6. package/codebase-to-course/lib/cli.mjs +23 -0
  7. package/codebase-to-course/lib/dates.mjs +11 -0
  8. package/codebase-to-course/lib/gate-runner.mjs +69 -0
  9. package/codebase-to-course/lib/handoff.mjs +75 -0
  10. package/codebase-to-course/lib/html/base.html +129 -0
  11. package/codebase-to-course/lib/installer.mjs +52 -0
  12. package/codebase-to-course/lib/lifecycle.mjs +67 -0
  13. package/codebase-to-course/lib/markdown.mjs +81 -0
  14. package/codebase-to-course/lib/project-root.mjs +52 -0
  15. package/codebase-to-course/lib/selfcontained.mjs +37 -0
  16. package/codebase-to-course/lib/spec-derive.mjs +114 -0
  17. package/codebase-to-course/lib/template.mjs +11 -0
  18. package/codebase-to-course/lib/toolkit/README.md +45 -0
  19. package/codebase-to-course/lib/toolkit/code-translation.md +50 -0
  20. package/codebase-to-course/lib/toolkit/diagrams.md +57 -0
  21. package/codebase-to-course/lib/toolkit/pedagogy.md +39 -0
  22. package/codebase-to-course/lib/toolkit/quiz-patterns.md +65 -0
  23. package/codebase-to-course/lib/toolkit/svg-diagrams.md +59 -0
  24. package/codebase-to-course/lib/toolkit/tooltip.md +83 -0
  25. package/codebase-to-course/skills/codebase-to-course/references/validate.mjs +301 -0
  26. package/grounding-wiki/gates/cli.mjs +26 -0
  27. package/grounding-wiki/gates/freshness.mjs +92 -0
  28. package/grounding-wiki/lib/README.md +8 -0
  29. package/grounding-wiki/lib/cli.mjs +23 -0
  30. package/grounding-wiki/lib/dates.mjs +11 -0
  31. package/grounding-wiki/lib/gate-runner.mjs +69 -0
  32. package/grounding-wiki/lib/handoff.mjs +75 -0
  33. package/grounding-wiki/lib/html/base.html +129 -0
  34. package/grounding-wiki/lib/installer.mjs +52 -0
  35. package/grounding-wiki/lib/lifecycle.mjs +67 -0
  36. package/grounding-wiki/lib/markdown.mjs +81 -0
  37. package/grounding-wiki/lib/project-root.mjs +52 -0
  38. package/grounding-wiki/lib/selfcontained.mjs +37 -0
  39. package/grounding-wiki/lib/spec-derive.mjs +114 -0
  40. package/grounding-wiki/lib/template.mjs +11 -0
  41. package/grounding-wiki/lib/toolkit/README.md +45 -0
  42. package/grounding-wiki/lib/toolkit/code-translation.md +50 -0
  43. package/grounding-wiki/lib/toolkit/diagrams.md +57 -0
  44. package/grounding-wiki/lib/toolkit/pedagogy.md +39 -0
  45. package/grounding-wiki/lib/toolkit/quiz-patterns.md +65 -0
  46. package/grounding-wiki/lib/toolkit/svg-diagrams.md +59 -0
  47. package/grounding-wiki/lib/toolkit/tooltip.md +83 -0
  48. package/lib/README.md +8 -0
  49. package/lib/cli.mjs +23 -0
  50. package/lib/dates.mjs +11 -0
  51. package/lib/gate-runner.mjs +69 -0
  52. package/lib/handoff.mjs +75 -0
  53. package/lib/html/base.html +129 -0
  54. package/lib/installer.mjs +52 -0
  55. package/lib/lifecycle.mjs +67 -0
  56. package/lib/markdown.mjs +81 -0
  57. package/lib/project-root.mjs +52 -0
  58. package/lib/selfcontained.mjs +37 -0
  59. package/lib/spec-derive.mjs +114 -0
  60. package/lib/template.mjs +11 -0
  61. package/lib/toolkit/README.md +45 -0
  62. package/lib/toolkit/code-translation.md +50 -0
  63. package/lib/toolkit/diagrams.md +57 -0
  64. package/lib/toolkit/pedagogy.md +39 -0
  65. package/lib/toolkit/quiz-patterns.md +65 -0
  66. package/lib/toolkit/svg-diagrams.md +59 -0
  67. package/lib/toolkit/tooltip.md +83 -0
  68. package/package.json +17 -0
  69. package/scripts/run-gates.mjs +91 -0
  70. package/spec-bridge/gates/bridge.mjs +130 -0
  71. package/spec-bridge/gates/cli.mjs +37 -0
  72. package/spec-bridge/lib/README.md +8 -0
  73. package/spec-bridge/lib/cli.mjs +23 -0
  74. package/spec-bridge/lib/dates.mjs +11 -0
  75. package/spec-bridge/lib/gate-runner.mjs +69 -0
  76. package/spec-bridge/lib/handoff.mjs +75 -0
  77. package/spec-bridge/lib/html/base.html +129 -0
  78. package/spec-bridge/lib/installer.mjs +52 -0
  79. package/spec-bridge/lib/lifecycle.mjs +67 -0
  80. package/spec-bridge/lib/markdown.mjs +81 -0
  81. package/spec-bridge/lib/project-root.mjs +52 -0
  82. package/spec-bridge/lib/selfcontained.mjs +37 -0
  83. package/spec-bridge/lib/spec-derive.mjs +114 -0
  84. package/spec-bridge/lib/template.mjs +11 -0
  85. package/spec-bridge/lib/toolkit/README.md +45 -0
  86. package/spec-bridge/lib/toolkit/code-translation.md +50 -0
  87. package/spec-bridge/lib/toolkit/diagrams.md +57 -0
  88. package/spec-bridge/lib/toolkit/pedagogy.md +39 -0
  89. package/spec-bridge/lib/toolkit/quiz-patterns.md +65 -0
  90. package/spec-bridge/lib/toolkit/svg-diagrams.md +59 -0
  91. package/spec-bridge/lib/toolkit/tooltip.md +83 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Evan Stern
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,74 @@
1
+ # Consuming praxis gates from another repo
2
+
3
+ praxis's gates — "status can't exceed proven artifacts" — are enforceable in repos that don't
4
+ carry praxis itself. There are two enforcement points, deliberately split the same way praxis
5
+ splits them for its own development:
6
+
7
+ - **Locally / interactively**: the installed plugins' Stop hooks run the gates as you work.
8
+ These float with plugin updates and are advisory.
9
+ - **In CI**: the composite GitHub Action runs the same gates at a **pinned release**. CI is
10
+ authoritative. Pin drift between local and CI is fine by design.
11
+
12
+ ## The action
13
+
14
+ ```yaml
15
+ jobs:
16
+ gates:
17
+ runs-on: ubuntu-latest
18
+ steps:
19
+ - uses: actions/checkout@v4
20
+ with:
21
+ fetch-depth: 0 # wiki-freshness resolves pins through git history
22
+ - uses: evanstern/praxis@v0.5.0
23
+ with:
24
+ gates: spec-bridge, wiki-freshness
25
+ ```
26
+
27
+ The action runs the gates via `npx @praxisflux/gates@<version>` — the npm package carved from
28
+ the same tree, pinned in lockstep with the tag you chose, and guaranteed live before the tag
29
+ exists (release order: npm publish, then tag). No install step, no dependencies (the gates
30
+ are zero-dependency Node, and every runner ships node + npm). Upgrade by bumping the tag;
31
+ Dependabot's `github-actions` ecosystem automates that.
32
+
33
+ ### Inputs
34
+
35
+ | input | default | meaning |
36
+ |--------------|---------------|---------|
37
+ | `gates` | *(required)* | Comma-separated: `spec-bridge`, `wiki-freshness`, `course`. An unknown name fails the build — gates never skip silently. |
38
+ | `path` | `.` | Repository root to check, relative to the workspace. |
39
+ | `wiki-dir` | `docs/wiki` | Grounded-corpus directory for `wiki-freshness`. |
40
+ | `course-dir` | `docs/course` | Built course directory for the `course` gate. |
41
+
42
+ ### The gates
43
+
44
+ - **`spec-bridge`** — every Backlog task linked to a Spec Kit spec dir carries a status its
45
+ spec artifacts prove (needs a `backlog/` dir; passes trivially with zero linked tasks).
46
+ - **`wiki-freshness`** — every `docs/wiki` note is fresh against its `verified_against` pin
47
+ (needs full git history: `fetch-depth: 0`; a shallow clone fails with exactly that fix).
48
+ - **`course`** — a built codebase-to-course course passes its output gate (self-contained,
49
+ quizzes/translations per module, current chrome stamp).
50
+
51
+ ## The npm package
52
+
53
+ The same surface ships on npm as **`@praxisflux/gates`** (published with provenance by the
54
+ release pipeline, version lockstep with the marketplace release), so non-GitHub CI and
55
+ one-off local runs need no praxis checkout:
56
+
57
+ ```sh
58
+ npx @praxisflux/gates --gates spec-bridge,wiki-freshness --path /path/to/repo
59
+ ```
60
+
61
+ The package's bin (`praxis-gates`) is `scripts/run-gates.mjs` itself, carved out by
62
+ `scripts/build-npm.mjs` with the chassis and gate modules alongside — same flags, same exit
63
+ codes, same failure lines.
64
+
65
+ ## The contract
66
+
67
+ Gate names, inputs, and exit codes (0 all pass · 1 any gate failed · 2 usage error) are
68
+ praxis's versioned consumer interface, released and semver-bumped like everything else
69
+ (`docs/releasing.md`); each failure line names its fix. You can also invoke the runner
70
+ directly from any praxis checkout:
71
+
72
+ ```sh
73
+ node scripts/run-gates.mjs --gates spec-bridge,wiki-freshness --path /path/to/repo
74
+ ```
@@ -0,0 +1,20 @@
1
+ #!/usr/bin/env node
2
+ // cli.mjs — command-line entry to the codebase-to-course gates.
3
+ // node cli.mjs course <course-dir>
4
+ import { validateCourse } from "./course.mjs";
5
+
6
+ const [cmd, courseDir] = process.argv.slice(2);
7
+ if (cmd !== "course" || !courseDir) {
8
+ console.error("usage: cli.mjs course <course-dir>");
9
+ process.exit(2);
10
+ }
11
+
12
+ const r = validateCourse(courseDir);
13
+ for (const w of r.warns) console.log(`warn: ${w}`);
14
+ if (r.fails.length) {
15
+ console.log(`\nGATE FAILED (${r.fails.length} issue(s)):`);
16
+ for (const f of r.fails) console.log(` - ${f}`);
17
+ process.exit(1);
18
+ }
19
+ console.log(`OK: course gate passed — ${r.modules} module(s), self-contained, all mandatory elements present.`);
20
+ process.exit(0);
@@ -0,0 +1,58 @@
1
+ // course.mjs — read-only output gate for a built codebase-to-course course (SKILL.md Phase 4).
2
+ //
3
+ // Checks the assembled index.html: it exists, is self-contained (lib/selfcontained.mjs;
4
+ // Google Fonts is the ONE allowed external host), nav dots match module count, every module
5
+ // carries >=1 quiz and >=1 code translation block, the course as a whole has >=1 group
6
+ // chat and >=1 flow animation, every translation block honors the pairing + bracket-balance
7
+ // contracts, and the vendored chrome carries the current version stamp.
8
+ // Never writes to disk (gates/ contract).
9
+ import { readFileSync, existsSync } from "node:fs";
10
+ import { join } from "node:path";
11
+ import { checkHtml } from "../lib/selfcontained.mjs";
12
+ import { checkTranslationBlocks, checkChrome } from "../skills/codebase-to-course/references/validate.mjs";
13
+
14
+ const GOOGLE_FONTS = /https?:\/\/(?:fonts\.googleapis\.com|fonts\.gstatic\.com)[^'")\s>]*/gi;
15
+ // Any of these container classes counts as a quiz (multiple-choice, drag-and-drop,
16
+ // spot-the-bug, scenario — per SKILL.md "any quiz type counts").
17
+ const QUIZ_CLASSES = ["quiz-container", "dnd-container", "bug-challenge", "scenario-block"];
18
+
19
+ const count = (html, marker) => html.split(marker).length - 1;
20
+ const hasAny = (html, classes) => classes.some((c) => html.includes(`class="${c}`));
21
+
22
+ /** Gate a built course directory. Returns { ok, fails, warns, modules }. */
23
+ export function validateCourse(courseDir) {
24
+ const fails = [], warns = [];
25
+ const indexPath = join(courseDir, "index.html");
26
+ if (!existsSync(indexPath)) {
27
+ return { ok: false, fails: [`no index.html in ${courseDir} — run build.sh to assemble the course`], warns, modules: 0 };
28
+ }
29
+ const html = readFileSync(indexPath, "utf8");
30
+
31
+ // Self-contained, with Google Fonts masked out first — it is the one allowed external.
32
+ const sc = checkHtml(html.replaceAll(GOOGLE_FONTS, "allowed-google-fonts"));
33
+ fails.push(...sc.fails); // sc.warns are artifact-page guidance (data table etc.) — not course concerns
34
+
35
+ const moduleChunks = html.split(/<section[^>]+class="module[\s"]/).slice(1);
36
+ const modules = moduleChunks.length;
37
+ const dots = count(html, 'class="nav-dot"');
38
+ if (modules === 0) fails.push('no <section class="module"> found — the course has no modules');
39
+ if (dots !== modules) fails.push(`nav dots (${dots}) != modules (${modules}) — fix NAV_DOTS in _base.html and rebuild`);
40
+
41
+ moduleChunks.forEach((chunk, i) => {
42
+ if (!hasAny(chunk, QUIZ_CLASSES)) fails.push(`module ${i + 1} has no quiz (need one of: ${QUIZ_CLASSES.join(", ")})`);
43
+ if (!chunk.includes('class="translation-block')) fails.push(`module ${i + 1} has no code translation block (.translation-block)`);
44
+ });
45
+
46
+ if (!html.includes('class="chat-window')) fails.push("course has no group chat animation (.chat-window) — at least one is mandatory");
47
+ if (!html.includes('class="flow-animation')) fails.push("course has no flow animation (.flow-animation) — at least one is mandatory");
48
+
49
+ // Translation-block contracts (same checks build.sh runs pre-assembly via the
50
+ // course's copied validate.mjs): 1:1 .tl/.code-line pairing + bracket balance.
51
+ fails.push(...checkTranslationBlocks(html, "index.html").fails);
52
+
53
+ // Vendored chrome must match the plugin's generation — unstamped (v1) or
54
+ // mixed-version chrome is the fossilization this gate exists to catch.
55
+ fails.push(...checkChrome(courseDir));
56
+
57
+ return { ok: fails.length === 0, fails, warns, modules };
58
+ }
@@ -0,0 +1,8 @@
1
+ # lib — the shared Node chassis
2
+
3
+ Zero-dependency Node modules shared by every plugin, **vendored into each plugin at build time**
4
+ (a shipped `.plugin` is self-contained; `${CLAUDE_PLUGIN_ROOT}/lib/…` resolves at runtime).
5
+
6
+ Planned modules (**TASK-1.2**): `project-root` · `gate-runner` (Stop-hook harness) · `markdown`
7
+ · `selfcontained` (HTML verifier) · `lifecycle` (status-cannot-exceed-proven-artifacts) ·
8
+ `installer` · `dates` · `template`.
@@ -0,0 +1,23 @@
1
+ // cli.mjs — the run-as-CLI guard for dual-use modules (importable library + executable
2
+ // script).
3
+ //
4
+ // The naive `import.meta.url === `file://${process.argv[1]}`` comparison breaks through
5
+ // symlinks: Node resolves import.meta.url to the module's physical path, while argv[1] stays
6
+ // as typed, so a script invoked via a symlinked checkout (~/projects -> Claude/Code) compares
7
+ // unequal and silently runs none of its CLI body — for a gate runner that means exit 0 having
8
+ // checked nothing, the exact silent skip the gates doctrine forbids. Realpath both sides so
9
+ // the comparison is invocation-path-independent.
10
+ import { realpathSync } from "node:fs";
11
+ import { fileURLToPath } from "node:url";
12
+
13
+ /** True when the module at `moduleUrl` (pass import.meta.url) is the script Node was asked
14
+ * to run, regardless of symlinks on either side. False when imported as a module, or when
15
+ * there is no entry script (`node -e`, REPL) or argv[1] is a runner's virtual entry. */
16
+ export function runAsCli(moduleUrl) {
17
+ if (!process.argv[1]) return false;
18
+ try {
19
+ return realpathSync(fileURLToPath(moduleUrl)) === realpathSync(process.argv[1]);
20
+ } catch {
21
+ return false;
22
+ }
23
+ }
@@ -0,0 +1,11 @@
1
+ // dates.mjs — ISO date helpers shared across the suite.
2
+
3
+ /** Today's date (or `date`'s) as an ISO `YYYY-MM-DD` string. */
4
+ export function today(date = new Date()) {
5
+ return date.toISOString().slice(0, 10);
6
+ }
7
+
8
+ /** Rewrite an `updated:` frontmatter line to today's date. No-op if none present. */
9
+ export function bumpUpdated(text, date = new Date()) {
10
+ return (text || "").replace(/^(updated:\s*).*$/m, `$1${today(date)}`);
11
+ }
@@ -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
+ }