@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
@@ -0,0 +1,57 @@
1
+ # HTML/CSS diagram idioms
2
+
3
+ Diagrams that are plain HTML + CSS (boxes, arrows, trees — often with an emoji as the icon)
4
+ inherit the theme tokens, wrap responsively, and never hit SVG's text pitfalls. This is
5
+ codebase-to-course's native diagram idiom, portable to any praxis surface. For hand-drawn
6
+ SVG figures instead, see `svg-diagrams.md`.
7
+
8
+ **Graceful degradation:** if this module isn't available, a numbered list of steps or an
9
+ indented mono file listing does the job.
10
+
11
+ ## Flow of steps (boxes with arrow separators)
12
+
13
+ ```html
14
+ <style>
15
+ .flow{display:flex;align-items:stretch;gap:0;flex-wrap:wrap}
16
+ .flow .step{flex:1;min-width:9em;background:var(--card);border:1px solid var(--line);
17
+ border-radius:12px;padding:.8em 1em;text-align:center}
18
+ .flow .step b{display:block;margin-bottom:.25em}
19
+ .flow .arrow{align-self:center;padding:0 .5em;color:var(--muted);font-size:1.4em}
20
+ </style>
21
+
22
+ <div class="flow">
23
+ <div class="step">📨 <b>Input</b><span class="muted">the click arrives</span></div>
24
+ <div class="arrow">→</div>
25
+ <div class="step">⚙️ <b>Process</b><span class="muted">the server decides</span></div>
26
+ <div class="arrow">→</div>
27
+ <div class="step">📬 <b>Output</b><span class="muted">the page updates</span></div>
28
+ </div>
29
+ ```
30
+
31
+ ## Annotated file tree
32
+
33
+ Use instead of paragraphs saying "this folder does X, that folder does Y":
34
+
35
+ ```html
36
+ <style>
37
+ .tree{font-family:var(--mono);background:var(--chip);border:1px solid var(--line);
38
+ border-radius:12px;padding:1em 1.2em;line-height:1.8;white-space:pre}
39
+ .tree .why{font-family:var(--sans);color:var(--muted);font-style:italic}
40
+ </style>
41
+
42
+ <div class="tree">src/
43
+ ├─ api/ <span class="why">← every route the frontend can call</span>
44
+ ├─ models/ <span class="why">← what the data looks like</span>
45
+ └─ jobs/ <span class="why">← work that runs on a schedule</span></div>
46
+ ```
47
+
48
+ Rules: label every box/branch with WHY it matters, not just its name; keep a flow to 3–5
49
+ steps (split longer chains — shared pedagogy: one idea per screen); emoji are icons, pick
50
+ one each, don't decorate.
51
+
52
+ ## In codebase-to-course
53
+
54
+ Courses get richer, *animated* versions from the prebuilt assets (flow animations, group-chat
55
+ visualizations, interactive architecture diagrams) — author against the contracts in the
56
+ skill's `interactive-elements.md`. These static idioms are the portable variants for decks
57
+ and briefings.
@@ -0,0 +1,39 @@
1
+ # Visual-teaching principles
2
+
3
+ The shared pedagogy behind every praxis teaching surface — codebase-to-course's course screens,
4
+ educate's deck slides, research's briefing pages. Written once here; each skill keeps only its
5
+ medium-specific rules (and a one-line distillation as fallback) and points at this module.
6
+
7
+ **Graceful degradation:** if this module isn't available, the one-liner to keep: *one idea per
8
+ screen; show, don't tell; lead with the point; split, don't shrink.*
9
+
10
+ ## The principles
11
+
12
+ 1. **One idea per screen/slide.** Each unit of display teaches exactly one thing. If it feels
13
+ crammed, the fix is always MORE screens/slides, never smaller type or denser text. A
14
+ 20-slide deck that's effortless beats a 6-slide deck you squint at.
15
+
16
+ 2. **Show, don't tell.** Eyes glaze over text blocks. Prefer a diagram, card row, flow, code
17
+ translation, or animation to a paragraph — if you're writing a fourth sentence, stop and
18
+ convert it into a visual. Aim closer to an infographic than a textbook.
19
+
20
+ 3. **Lead with the point.** State the claim/verdict/takeaway first, then justify it. A briefing
21
+ leads with the analysis's verdict; a deck leads with the lesson's claim; a course screen
22
+ leads with the concept, not the buildup. Don't make the reader reverse-engineer the answer.
23
+
24
+ 4. **Split, don't shrink.** Never reduce type, tighten spacing, or trim whitespace to make
25
+ content fit. Cut words, or add another screen/slide. Readability is the budget everything
26
+ else pays from.
27
+
28
+ 5. **Let visuals breathe.** A figure that matters gets the room it needs — its own slide, a
29
+ full-width screen block, generous whitespace around it. Alternate big visuals with narrow
30
+ text to create rhythm; don't wedge a diagram into a corner.
31
+
32
+ ## How each medium applies them
33
+
34
+ - **Course screens** (codebase-to-course): every screen ≥50% visual, max 2–3 sentences per text
35
+ block, one "hero visual" per module — see the skill's `content-philosophy.md`.
36
+ - **Deck slides** (educate): large type is non-negotiable; verify nothing clips at 16:9 — see
37
+ the deck template's header rules.
38
+ - **Briefing pages** (research): verdict first, every figure traces to the analysis — see
39
+ `artifact-layer.md`.
@@ -0,0 +1,65 @@
1
+ # Quiz patterns — test application, not memory
2
+
3
+ A quiz exists to check whether the learner can *use* what they learned on a new problem, not
4
+ whether they can regurgitate a definition. Originally codebase-to-course's quiz doctrine;
5
+ the design rules are medium-independent.
6
+
7
+ **Graceful degradation:** if this module isn't available, end the lesson/module with 2–3
8
+ "what would you do?" questions in plain prose and discuss the answers.
9
+
10
+ ## The coverage rule (hard requirement)
11
+
12
+ A question may only use terms, concepts, components, and scenarios the material has already
13
+ introduced *before* the quiz. Applying knowledge to a brand-new situation is the whole point —
14
+ but every term the question leans on must be 100% guaranteed already taught. A learner should
15
+ never hit a quiz and think "we never talked about this." If a great question needs an
16
+ unintroduced concept, teach it first or cut the question.
17
+
18
+ ## What to quiz (in order of value)
19
+
20
+ 1. **"What would you do?" scenarios** — a new situation, apply what you learned. The gold standard.
21
+ 2. **Debugging scenarios** — "X is broken; where would you look first?"
22
+ 3. **Architecture decisions** — "would you put this logic here or there — and why?"
23
+ 4. **Tracing exercises** — "when a user does X, what path does the data take?"
24
+
25
+ **Never quiz:** definitions (that's what glossing is for), file-name recall, syntax details,
26
+ or anything answerable by scrolling up and copying.
27
+
28
+ ## Tone
29
+
30
+ Wrong answers get encouraging explanations that teach something new ("Not quite — here's
31
+ why…"); right answers get brief reinforcement of the principle ("Exactly — this works
32
+ because…"). Never punitive, never score-focused.
33
+
34
+ ## Portable snippet (decks, briefings — zero JS)
35
+
36
+ `<details>`/`<summary>` gives tap-to-reveal answers with no script, keyboard-accessible,
37
+ token-styled:
38
+
39
+ ```html
40
+ <style>
41
+ .quiz details{border:1px solid var(--line);border-radius:10px;margin:.4em 0;
42
+ background:var(--chip)}
43
+ .quiz summary{cursor:pointer;padding:.55em .9em;font-weight:600}
44
+ .quiz details p{margin:0;padding:.2em .9em .7em;color:var(--muted)}
45
+ .quiz details.right summary{border-left:4px solid var(--accent2)}
46
+ </style>
47
+
48
+ <div class="quiz">
49
+ <p><b>You want to add a "save to favorites" feature. Where does the logic live?</b></p>
50
+ <details><summary>A. In the browser extension</summary>
51
+ <p>Not quite — the extension only displays data; it never owns it. Look one hop back.</p></details>
52
+ <details class="right"><summary>B. In the backend API</summary>
53
+ <p>Exactly — the API owns the data, so a new feature that stores state starts there.</p></details>
54
+ </div>
55
+ ```
56
+
57
+ Deck usage: one question per slide, reveal by tapping — a natural end-of-lesson slide before
58
+ the takeaways.
59
+
60
+ ## In codebase-to-course
61
+
62
+ Courses do NOT use this snippet — the prebuilt assets ship graded quiz engines
63
+ (multiple-choice, drag-and-drop, spot-the-bug, scenario) with per-answer explanations; author
64
+ against the contracts in the skill's `interactive-elements.md`. The coverage rule above
65
+ applies everywhere.
@@ -0,0 +1,59 @@
1
+ # Inline-SVG diagram rules
2
+
3
+ Hand-drawn inline SVG is the praxis idiom for explanatory figures (educate decks draw
4
+ diagrams this way; research briefings draw charts as inline SVG). SVG has sharp edges that
5
+ HTML habits walk straight into — these are the hard-won rules, originally from educate's
6
+ deck template.
7
+
8
+ **Graceful degradation:** if this module isn't available, the two rules to keep: *no
9
+ `<b>`/`<i>` inside SVG `<text>` (use `<tspan>`), and CSS `var(--…)` does not resolve in SVG
10
+ presentation attributes — use literal values.*
11
+
12
+ ## The pitfalls (hard rules)
13
+
14
+ 1. **Never put `<b>` or `<i>` inside an SVG `<text>` element.** HTML formatting tags are
15
+ invisible-but-broken inside SVG — the text silently fails to render or renders unstyled.
16
+ Use `<tspan font-weight="700">` / `<tspan font-style="italic">` instead.
17
+
18
+ 2. **CSS `var(--token)` does NOT resolve in SVG presentation attributes** (`fill="var(--accent)"`
19
+ silently paints black). Use **literal color values** in SVG attributes — and since literals
20
+ can't follow the theme, pick colors that read on BOTH light and dark backgrounds, or give
21
+ the figure its own contrasting panel. (`var()` does work in a `style=""` attribute or CSS
22
+ rule targeting SVG elements — but only for properties CSS controls, and it's easy to get
23
+ this half-right; literals are the predictable path.)
24
+
25
+ 3. **Size for reflow:** `width:100%; height:auto` on the `<svg>` plus a `viewBox` — never fixed
26
+ pixel width/height attributes alone, or the figure won't scale with its container.
27
+
28
+ 4. **Accessibility:** `role="img"` and an `aria-label` describing what the diagram shows.
29
+
30
+ 5. **Give the figure room** (shared pedagogy — "let visuals breathe"): a diagram that carries
31
+ the point gets its own slide / a full-width block, filling most of the available width and
32
+ height. Don't wedge it beside a paragraph.
33
+
34
+ ## Skeleton — a boxes-and-arrows flow
35
+
36
+ ```html
37
+ <svg viewBox="0 0 800 300" role="img" aria-label="input flows through process to output">
38
+ <rect x="40" y="110" width="200" height="80" rx="12" fill="#eef0ff" stroke="#4f46e5" stroke-width="2"/>
39
+ <text x="140" y="158" text-anchor="middle" font-size="26" fill="#1b2733" font-family="sans-serif">Input</text>
40
+ <line x1="240" y1="150" x2="320" y2="150" stroke="#5a6776" stroke-width="3" marker-end="url(#a)"/>
41
+ <rect x="320" y="110" width="200" height="80" rx="12" fill="#eefaf7" stroke="#0e9488" stroke-width="2"/>
42
+ <text x="420" y="158" text-anchor="middle" font-size="26" fill="#1b2733" font-family="sans-serif">Process</text>
43
+ <defs><marker id="a" markerWidth="10" markerHeight="10" refX="8" refY="3" orient="auto">
44
+ <path d="M0,0 L8,3 L0,6 Z" fill="#5a6776"/></marker></defs>
45
+ </svg>
46
+ ```
47
+
48
+ Notes: pastel fills + saturated strokes read on both themes; the `<marker>` def gives every
49
+ arrow a clean head — define it once per SVG and reference with `marker-end="url(#a)"`; keep
50
+ marker `id`s unique per page if a page holds several SVGs.
51
+
52
+ ## Who uses this
53
+
54
+ - **educate decks** — the native idiom; the deck template's header carries the one-line
55
+ distillation and a sample diagram slide.
56
+ - **research briefings** — charts are inline SVG; these rules apply on top of the `dataviz`
57
+ skill's chart guidance.
58
+ - **codebase-to-course** — courses prefer the HTML/CSS + emoji diagram patterns in the skill's
59
+ `interactive-elements.md`; when a course page does hand-draw inline SVG, these rules apply.
@@ -0,0 +1,83 @@
1
+ # Jargon glossary tooltip
2
+
3
+ Gloss every technical term where it appears, so the reader never has to leave the page (or
4
+ ask) to decode jargon. The definition rides a `data-tip` attribute; a popover shows it on
5
+ hover (desktop) or tap (touch). This is the single most valuable accessibility feature for
6
+ non-technical audiences — codebase-to-course's content rules call it "No Term Left Behind."
7
+
8
+ **Graceful degradation:** if this module isn't available, gloss terms in parentheses on
9
+ first use — `the manifest (the file that declares what the extension may do)` — and move on.
10
+
11
+ ## The contract
12
+
13
+ Any element — HTML *or* SVG — may carry a `data-tip` attribute with a 1–2 sentence,
14
+ everyday-language definition:
15
+
16
+ ```html
17
+ <span data-tip="A service worker is a background script that runs independently of the
18
+ page — a behind-the-scenes assistant that's on even when you're not looking.">service
19
+ worker</span>
20
+ ```
21
+
22
+ - Give the reader a **visible affordance** so glossed terms are discoverable: a dashed
23
+ underline, or a small `?` badge (educate's decks use the badge — see the deck template's
24
+ sample slide).
25
+ - Keep definitions to 1–2 sentences, everyday language; a metaphor helps.
26
+ - Gloss on **first use per screen/slide**, not every repetition.
27
+
28
+ ## The snippet
29
+
30
+ Self-contained, and written against the **shared token names** (`--ink`, `--bg`) so it
31
+ inherits the consuming plugin's palette in both light and dark themes (the bubble is
32
+ inverted-contrast: dark-on-light pages get a dark bubble and vice versa). Popover text size
33
+ follows `--tip-size` (default `1rem`) — decks set it larger. The popover is appended to
34
+ `document.body` with `position: fixed`, so it is **never clipped** by an ancestor's
35
+ `overflow: hidden`, and it hides when printing.
36
+
37
+ Consumers embed the marked regions verbatim; `scripts/sync-shared.mjs` re-stamps them (drift
38
+ fails the test suite). Hand-edit only here.
39
+
40
+ ```css
41
+ /* praxis:tooltip-css:start */
42
+ [data-tip]{cursor:help}
43
+ .tip-pop{position:fixed;z-index:1000;max-width:min(420px,80vw);background:var(--ink);color:var(--bg);
44
+ font-size:var(--tip-size,1rem);line-height:1.4;padding:.65em .9em;border-radius:10px;
45
+ pointer-events:none;box-shadow:0 8px 24px rgba(0,0,0,.28);opacity:0;transform:translateY(4px);
46
+ transition:opacity .12s ease,transform .12s ease}
47
+ .tip-pop.show{opacity:1;transform:translateY(0)}
48
+ @media print{ .tip-pop{display:none} }
49
+ /* praxis:tooltip-css:end */
50
+ ```
51
+
52
+ ```js
53
+ // praxis:tooltip-js:start
54
+ // One shared popover: follows the cursor on hover, toggles on click/tap (touch), clamps to
55
+ // the viewport, and closes on tap-away. Works for HTML and SVG [data-tip] elements alike.
56
+ (function(){
57
+ const pop=document.createElement('div'); pop.className='tip-pop'; document.body.appendChild(pop);
58
+ let shownFor=null;
59
+ const place=(x,y)=>{ const pad=14, r=pop.getBoundingClientRect();
60
+ let px=x+pad, py=y+pad;
61
+ if(px+r.width>innerWidth-8) px=Math.max(8,x-r.width-pad);
62
+ if(py+r.height>innerHeight-8) py=Math.max(8,y-r.height-pad);
63
+ pop.style.left=px+'px'; pop.style.top=py+'px'; };
64
+ const show=(t,x,y)=>{ pop.textContent=t.getAttribute('data-tip'); pop.classList.add('show'); shownFor=t; place(x,y); };
65
+ const hide=()=>{ pop.classList.remove('show'); shownFor=null; };
66
+ document.addEventListener('mouseover',e=>{ const t=e.target.closest&&e.target.closest('[data-tip]'); if(t) show(t,e.clientX,e.clientY); });
67
+ document.addEventListener('mousemove',e=>{ if(shownFor) place(e.clientX,e.clientY); });
68
+ document.addEventListener('mouseout',e=>{ const t=e.target.closest&&e.target.closest('[data-tip]'); if(t && !t.contains(e.relatedTarget)) hide(); });
69
+ document.addEventListener('click',e=>{ const t=e.target.closest&&e.target.closest('[data-tip]');
70
+ if(t){ e.stopPropagation(); (shownFor===t)?hide():show(t,e.clientX,e.clientY); } else hide(); });
71
+ })();
72
+ // praxis:tooltip-js:end
73
+ ```
74
+
75
+ ## Where each plugin gets it
76
+
77
+ - **educate decks** — the deck template carries the stamped regions; nothing to author.
78
+ - **research briefings** — copy both regions into the page (alongside the base.html blocks).
79
+ - **codebase-to-course pages** — courses do NOT use this snippet: the prebuilt assets
80
+ (`references/styles.css` + `references/main.js`, copied verbatim into every course) ship
81
+ the same pattern as a course-native engine with the `.term` + `data-definition` contract
82
+ and a dashed-underline affordance. Author course HTML against that contract and never
83
+ inline tooltip CSS/JS there (see the skill's `interactive-elements.md`).
package/package.json ADDED
@@ -0,0 +1,17 @@
1
+ {
2
+ "name": "@praxisflux/gates",
3
+ "version": "0.5.0",
4
+ "description": "praxis gate checks as a zero-dependency CLI (spec-bridge, wiki-freshness, course) — status can't exceed proven artifacts",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/evanstern/praxis.git"
9
+ },
10
+ "type": "module",
11
+ "bin": {
12
+ "praxis-gates": "scripts/run-gates.mjs"
13
+ },
14
+ "engines": {
15
+ "node": ">=18"
16
+ }
17
+ }
@@ -0,0 +1,91 @@
1
+ #!/usr/bin/env node
2
+ // run-gates.mjs — the praxis CI consumption surface: run selected praxis gates against a
3
+ // consumer repository. This is what action.yml (the composite GitHub Action) invokes; it also
4
+ // runs by hand from any praxis checkout:
5
+ //
6
+ // node scripts/run-gates.mjs --gates spec-bridge,wiki-freshness [--path <root>]
7
+ // [--wiki-dir docs/wiki] [--course-dir docs/course]
8
+ //
9
+ // Gate names, options, and exit codes are praxis's versioned consumer contract
10
+ // (docs/consuming-gates.md): exit 0 when every gate passes, 1 when any gate fails, 2 on a
11
+ // usage error (unknown gate, missing --gates). Each failure line names its fix. This same
12
+ // file ships as the @praxisflux/gates npm bin (scripts/build-npm.mjs carves the package).
13
+ import { join, resolve } from "node:path";
14
+ import { spawnSync } from "node:child_process";
15
+ import { runAsCli } from "../lib/cli.mjs";
16
+ import { checkBridge } from "../spec-bridge/gates/bridge.mjs";
17
+ import { validateFreshness } from "../grounding-wiki/gates/freshness.mjs";
18
+ import { validateCourse } from "../codebase-to-course/gates/course.mjs";
19
+
20
+ /** wiki-freshness resolves verified_against pins through git history, which a shallow CI
21
+ * clone doesn't have — catch that up front with the exact fix instead of a wall of
22
+ * unknown-pin failures. */
23
+ function shallowCloneProblem(root) {
24
+ const r = spawnSync("git", ["rev-parse", "--is-shallow-repository"], { cwd: root, encoding: "utf8" });
25
+ if (r.status === 0 && r.stdout.trim() === "true")
26
+ return `${root} is a shallow clone — wiki-freshness needs full history to resolve verified_against pins; set "fetch-depth: 0" on actions/checkout`;
27
+ return null;
28
+ }
29
+
30
+ /** Each gate takes {root, wikiDir, courseDir} and returns {problems, warnings, ok} where
31
+ * ok is the one-line success summary. */
32
+ export const GATES = {
33
+ "spec-bridge": ({ root }) => {
34
+ const { links, problems, warnings } = checkBridge(root);
35
+ return { problems, warnings, ok: `spec-bridge ok: ${links.length} linked task(s), none exceed their artifacts` };
36
+ },
37
+ "wiki-freshness": ({ root, wikiDir }) => {
38
+ const shallow = shallowCloneProblem(root);
39
+ if (shallow) return { problems: [shallow], warnings: [], ok: "" };
40
+ const { fails, warns, checked } = validateFreshness(root, wikiDir);
41
+ return { problems: fails, warnings: warns, ok: `wiki-freshness ok: ${checked} note(s) fresh against their pinned sources` };
42
+ },
43
+ "course": ({ root, courseDir }) => {
44
+ const { fails, warns, modules } = validateCourse(join(root, courseDir));
45
+ return { problems: fails, warnings: warns, ok: `course ok: ${modules} module(s) pass the course gate` };
46
+ },
47
+ };
48
+
49
+ /** Run the named gates against opts.root. Returns [{gate, problems, warnings, ok}].
50
+ * Throws on an unknown or empty gate list — a misspelled gate must fail the build loudly,
51
+ * never skip silently. */
52
+ export function runGates(names, opts) {
53
+ if (!names.length) throw new Error(`no gates requested — pass --gates with any of: ${Object.keys(GATES).join(", ")}`);
54
+ for (const n of names)
55
+ if (!GATES[n]) throw new Error(`unknown gate "${n}" — valid gates: ${Object.keys(GATES).join(", ")}`);
56
+ return names.map((gate) => ({ gate, ...GATES[gate](opts) }));
57
+ }
58
+
59
+ if (runAsCli(import.meta.url)) {
60
+ const args = process.argv.slice(2);
61
+ const opt = (name, dflt) => {
62
+ const i = args.indexOf(`--${name}`);
63
+ return i !== -1 && args[i + 1] !== undefined ? args[i + 1] : dflt;
64
+ };
65
+ const opts = {
66
+ root: resolve(opt("path", ".")),
67
+ wikiDir: opt("wiki-dir", "docs/wiki"),
68
+ courseDir: opt("course-dir", "docs/course"),
69
+ };
70
+ const names = opt("gates", "").split(",").map((s) => s.trim()).filter(Boolean);
71
+
72
+ let results;
73
+ try {
74
+ results = runGates(names, opts);
75
+ } catch (e) {
76
+ console.error(`usage error: ${e.message}`);
77
+ process.exit(2);
78
+ }
79
+ let failed = 0;
80
+ for (const { gate, problems, warnings, ok } of results) {
81
+ for (const w of warnings) console.log(`[${gate}] warn: ${w}`);
82
+ if (problems.length) {
83
+ failed++;
84
+ console.log(`[${gate}] GATE FAILED (${problems.length} issue(s)):`);
85
+ for (const p of problems) console.log(` - ${p}`);
86
+ } else {
87
+ console.log(`[${gate}] ${ok}`);
88
+ }
89
+ }
90
+ process.exit(failed ? 1 : 0);
91
+ }
@@ -0,0 +1,130 @@
1
+ // bridge.mjs — read-only logic of the spec-bridge: find Backlog tasks linked to Spec Kit spec
2
+ // dirs and judge each task's kanban status against the state its spec artifacts prove.
3
+ //
4
+ // A task is "linked" by a marker line in its description: `Spec: <dir>` where <dir> is the spec
5
+ // directory relative to the project root (the dir holding backlog/). The marker is planted and
6
+ // maintained ONLY via the backlog CLI (link/sync skills); this module just reads the task files.
7
+ //
8
+ // Verdicts per linked task, comparing the task's frontmatter status to the derived status:
9
+ // exceeds — status claims more than the artifacts prove (e.g. Done over unchecked boxes).
10
+ // The Stop-hook gate blocks on these.
11
+ // lags — artifacts are ahead of the status (honest but stale). Warn, never block.
12
+ // ok — they agree.
13
+ // unknown — the task uses a status outside To Do / In Progress / Done (custom workflow);
14
+ // the bridge doesn't guess, so it neither blocks nor warns.
15
+
16
+ import { existsSync, readdirSync, readFileSync } from "node:fs";
17
+ import { join } from "node:path";
18
+ import { deriveSpecState, STATUS } from "../lib/spec-derive.mjs";
19
+ import { hasChild, findRootsDownwards } from "../lib/project-root.mjs";
20
+
21
+ /**
22
+ * Per-project bridge config: `.spec-bridge.json` at the project root (beside backlog/).
23
+ * `{ "strictDone": true }` turns on analyze-gated Done (see lib/spec-derive.mjs). Missing or
24
+ * malformed config means checkbox-only mode — strictness is opt-in.
25
+ */
26
+ export function loadBridgeConfig(root) {
27
+ try { return JSON.parse(readFileSync(join(root, ".spec-bridge.json"), "utf8")) ?? {}; }
28
+ catch { return {}; }
29
+ }
30
+
31
+ const MARKER = /^Spec:\s*(\S+?)\/?\s*$/m;
32
+ const RANK = { "to do": 0, "in progress": 1, done: 2 };
33
+ const DERIVED_RANK = { [STATUS.TODO]: 0, [STATUS.IN_PROGRESS]: 1, [STATUS.DONE_ELIGIBLE]: 2 };
34
+
35
+ /**
36
+ * Parse one Backlog task file. Returns { id, status, specDir } for a linked task,
37
+ * null for anything else (no marker, unreadable, or not a task file).
38
+ */
39
+ export function parseLinkedTask(raw) {
40
+ const text = String(raw ?? "");
41
+ const marker = text.match(MARKER);
42
+ if (!marker) return null;
43
+ const fm = text.match(/^---\n([\s\S]*?)\n---/);
44
+ if (!fm) return null;
45
+ const field = (name) => fm[1].match(new RegExp(`^${name}:\\s*(.+?)\\s*$`, "m"))?.[1]?.replace(/^['"]|['"]$/g, "") ?? "";
46
+ const id = field("id");
47
+ const status = field("status");
48
+ if (!id) return null;
49
+ return { id, status, specDir: marker[1] };
50
+ }
51
+
52
+ /** Scan <root>/backlog/tasks/*.md for linked tasks. Unreadable files are skipped. */
53
+ export function findLinkedTasks(root) {
54
+ const dir = join(root, "backlog", "tasks");
55
+ let entries = [];
56
+ try { entries = readdirSync(dir); } catch { return []; }
57
+ const linked = [];
58
+ for (const name of entries.filter((n) => n.endsWith(".md")).sort()) {
59
+ try {
60
+ const task = parseLinkedTask(readFileSync(join(dir, name), "utf8"));
61
+ if (task) linked.push({ ...task, file: join(dir, name) });
62
+ } catch { /* skip unreadable */ }
63
+ }
64
+ return linked;
65
+ }
66
+
67
+ /** Compare a task's Backlog status to its derived status: "exceeds" | "lags" | "ok" | "unknown". */
68
+ export function verdict(taskStatus, derivedStatus) {
69
+ const t = RANK[String(taskStatus).toLowerCase()];
70
+ const d = DERIVED_RANK[derivedStatus];
71
+ if (t === undefined || d === undefined) return "unknown";
72
+ return t > d ? "exceeds" : t < d ? "lags" : "ok";
73
+ }
74
+
75
+ /** One human sentence on why a spec dir doesn't prove more than its derived status. */
76
+ function shortfall(root, specDir, derived) {
77
+ const missing = ["spec.md", "plan.md"].filter((f) => !existsSync(join(root, specDir, f)));
78
+ const parts = missing.map((f) => `${f} missing`);
79
+ if (derived.tasksTotal === 0) parts.push("no tasks in tasks.md");
80
+ else if (derived.tasksDone < derived.tasksTotal)
81
+ parts.push(`${derived.tasksTotal - derived.tasksDone} of ${derived.tasksTotal} tasks unchecked (${derived.progressNote})`);
82
+ if (derived.analysis?.required) {
83
+ if (!derived.analysis.present)
84
+ parts.push("analysis.md missing (strict Done: save the /speckit.analyze report into the spec dir)");
85
+ else if (derived.analysis.criticals.length)
86
+ parts.push(`unresolved CRITICAL finding(s) in analysis.md: ${derived.analysis.criticals.join(" | ")}`);
87
+ }
88
+ return parts.join(", ") || "artifacts incomplete";
89
+ }
90
+
91
+ /**
92
+ * Judge every linked task under <root>. Returns:
93
+ * links — [{ id, status, specDir, derived, verdict }]
94
+ * problems — blocking messages, one per "exceeds"
95
+ * warnings — non-blocking messages, one per "lags"
96
+ */
97
+ export function checkBridge(root) {
98
+ const links = [];
99
+ const problems = [];
100
+ const warnings = [];
101
+ const requireAnalysis = loadBridgeConfig(root).strictDone === true;
102
+ for (const task of findLinkedTasks(root)) {
103
+ const derived = deriveSpecState(join(root, task.specDir), { requireAnalysis });
104
+ const v = verdict(task.status, derived.status);
105
+ links.push({ ...task, derived, verdict: v });
106
+ if (v === "exceeds") {
107
+ problems.push(
108
+ `[spec-bridge] ${task.id} is "${task.status}" but ${task.specDir} only proves "${derived.status}": ` +
109
+ `${shortfall(root, task.specDir, derived)}. Finish the spec work or set the task back (backlog task edit ${task.id} -s "...").`
110
+ );
111
+ } else if (v === "lags") {
112
+ warnings.push(
113
+ `[spec-bridge] ${task.id} is "${task.status}" but ${task.specDir} already derives "${derived.status}" — run the spec-bridge sync skill to catch the board up.`
114
+ );
115
+ }
116
+ }
117
+ return { links, problems, warnings };
118
+ }
119
+
120
+ /**
121
+ * The Stop-hook gate, in gate-runner shape. Roots are directories holding a backlog/ dir;
122
+ * a root with no linked tasks yields no problems, so the gate is a natural no-op outside
123
+ * bridged projects. "exceeds" blocks; "lags" only warns.
124
+ */
125
+ export const bridgeGate = {
126
+ name: "spec-bridge",
127
+ resolveRoots: (startDir) => findRootsDownwards(startDir, hasChild("backlog")),
128
+ check: (root) => checkBridge(root).problems,
129
+ warn: (root) => checkBridge(root).warnings,
130
+ };
@@ -0,0 +1,37 @@
1
+ #!/usr/bin/env node
2
+ // cli.mjs — command-line entry to the spec-bridge gates (read-only; all Backlog writes go
3
+ // through the backlog CLI in the skills). The skills call these instead of parsing markdown:
4
+ // node cli.mjs state <specDir> derived state for one spec dir, as JSON
5
+ // node cli.mjs links <root> every linked task under <root> with derived state + verdict, as JSON
6
+ // node cli.mjs check <root> human report; exit 1 if any task's status exceeds its artifacts
7
+ import { resolve } from "node:path";
8
+ import { deriveSpecState } from "../lib/spec-derive.mjs";
9
+ import { findRootUpwards, hasChild } from "../lib/project-root.mjs";
10
+ import { checkBridge, loadBridgeConfig } from "./bridge.mjs";
11
+
12
+ const [cmd, target] = process.argv.slice(2);
13
+ if (!cmd || !target) {
14
+ console.error("usage: cli.mjs state <specDir> | links <root> | check <root>");
15
+ process.exit(2);
16
+ }
17
+
18
+ if (cmd === "state") {
19
+ // Honor the project's .spec-bridge.json (strictDone) — same config checkBridge uses.
20
+ const root = findRootUpwards(resolve(target), hasChild("backlog"));
21
+ const requireAnalysis = root ? loadBridgeConfig(root).strictDone === true : false;
22
+ console.log(JSON.stringify(deriveSpecState(target, { requireAnalysis }), null, 2));
23
+ } else if (cmd === "links") {
24
+ console.log(JSON.stringify(checkBridge(target).links, null, 2));
25
+ } else if (cmd === "check") {
26
+ const { links, problems, warnings } = checkBridge(target);
27
+ for (const w of warnings) console.log(`warn: ${w}`);
28
+ if (problems.length) {
29
+ console.log(`\nGATE FAILED (${problems.length} issue(s)):`);
30
+ for (const p of problems) console.log(` - ${p}`);
31
+ process.exit(1);
32
+ }
33
+ console.log(`spec-bridge ok: ${links.length} linked task(s), none exceed their artifacts`);
34
+ } else {
35
+ console.error(`unknown command: ${cmd}`);
36
+ process.exit(2);
37
+ }
@@ -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
+ }