@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
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
|
+
}
|