@freetesh/zeno 0.0.0-stage → 0.7.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 (61) hide show
  1. package/.claude-plugin/marketplace.json +14 -0
  2. package/.claude-plugin/plugin.json +10 -0
  3. package/LICENSE +21 -0
  4. package/README.md +175 -2
  5. package/cli/dist/checks/architecture.js +126 -0
  6. package/cli/dist/checks/canonical-values.js +46 -0
  7. package/cli/dist/checks/context-layout.js +41 -0
  8. package/cli/dist/checks/copies.js +73 -0
  9. package/cli/dist/checks/external.js +30 -0
  10. package/cli/dist/checks/index.js +15 -0
  11. package/cli/dist/checks/types.js +1 -0
  12. package/cli/dist/checks/wiki-frontmatter.js +71 -0
  13. package/cli/dist/commands/add.js +24 -0
  14. package/cli/dist/commands/check.js +19 -0
  15. package/cli/dist/commands/doctor.js +16 -0
  16. package/cli/dist/commands/env.js +6 -0
  17. package/cli/dist/commands/init.js +78 -0
  18. package/cli/dist/commands/learn.js +35 -0
  19. package/cli/dist/commands/standards.js +26 -0
  20. package/cli/dist/commands/status.js +11 -0
  21. package/cli/dist/commands/sync.js +23 -0
  22. package/cli/dist/commands/ui.js +64 -0
  23. package/cli/dist/commands/weigh.js +16 -0
  24. package/cli/dist/config/load.js +93 -0
  25. package/cli/dist/config/schema.js +43 -0
  26. package/cli/dist/doctor.js +31 -0
  27. package/cli/dist/format.js +37 -0
  28. package/cli/dist/index.js +36 -0
  29. package/cli/dist/init.js +120 -0
  30. package/cli/dist/learn.js +69 -0
  31. package/cli/dist/lib/files.js +23 -0
  32. package/cli/dist/lib/frontmatter.js +16 -0
  33. package/cli/dist/lib/system.js +19 -0
  34. package/cli/dist/packs.js +85 -0
  35. package/cli/dist/run.js +45 -0
  36. package/cli/dist/standards.js +42 -0
  37. package/cli/dist/status.js +27 -0
  38. package/cli/dist/sync.js +30 -0
  39. package/cli/dist/ui.js +69 -0
  40. package/package.json +50 -4
  41. package/packs/expo/pack.yaml +5 -0
  42. package/packs/firebase/pack.yaml +5 -0
  43. package/packs/nextjs/pack.yaml +5 -0
  44. package/packs/react/pack.yaml +5 -0
  45. package/packs/revenuecat/pack.yaml +5 -0
  46. package/packs/uniwind/pack.yaml +5 -0
  47. package/skills/ask/SKILL.md +42 -0
  48. package/skills/decision/SKILL.md +33 -0
  49. package/skills/ingest/SKILL.md +44 -0
  50. package/skills/standards/SKILL.md +16 -0
  51. package/skills/weigh/SKILL.md +102 -0
  52. package/skills/wiki-lint/SKILL.md +69 -0
  53. package/standards/README.md +13 -0
  54. package/standards/agent-contract.md +40 -0
  55. package/standards/constitution.md +89 -0
  56. package/standards/context-layout.md +112 -0
  57. package/standards/wiki-schema.md +249 -0
  58. package/templates/dependency-cruiser/app.cjs +23 -0
  59. package/templates/dependency-cruiser/base.cjs +52 -0
  60. package/templates/dependency-cruiser/package.cjs +31 -0
  61. package/templates/dependency-cruiser/root.cjs +15 -0
@@ -0,0 +1,19 @@
1
+ import { resolve } from "node:path";
2
+ import { exitCode, formatReports } from "../format.js";
3
+ import { findProducts, runChecks } from "../run.js";
4
+ import { requireEstate } from "./env.js";
5
+ export function registerCheck(program, env) {
6
+ program
7
+ .command("check")
8
+ .description("check that a product follows the standards (read-only)")
9
+ .argument("[repo]", "product repo", ".")
10
+ .option("--all", "check every product in the estate")
11
+ .option("--json", "print machine-readable JSON")
12
+ .option("-q, --quiet", "hide products with no findings")
13
+ .action((repo, options) => {
14
+ const repos = options.all ? findProducts(requireEstate(env)) : [resolve(repo)];
15
+ const reports = repos.map((path) => runChecks(path, { zenoRoot: env.zenoRoot, estateRoot: options.all ? env.estateRoot : undefined }));
16
+ console.log(options.json ? JSON.stringify(reports, null, 2) : formatReports(reports, { quiet: options.quiet }));
17
+ process.exitCode = exitCode(reports);
18
+ });
19
+ }
@@ -0,0 +1,16 @@
1
+ import { loadConfig } from "../config/load.js";
2
+ import { runDoctor, systemProbe } from "../doctor.js";
3
+ export function registerDoctor(program) {
4
+ program
5
+ .command("doctor")
6
+ .description("check that zeno's tools, and any this repo declares, are installed")
7
+ .action(() => {
8
+ const results = runDoctor(systemProbe, loadConfig(process.cwd()).config.doctor.tools);
9
+ for (const result of results) {
10
+ const mark = result.ok ? "✓" : "✗";
11
+ const detail = result.ok ? (result.version ?? "") : `${result.version ?? "missing"} — ${result.hint}`;
12
+ console.log(`${mark} ${result.tool.padEnd(8)} ${detail} (${result.why})`);
13
+ }
14
+ process.exitCode = results.every((result) => result.ok) ? 0 : 1;
15
+ });
16
+ }
@@ -0,0 +1,6 @@
1
+ /** For commands that only make sense across products. */
2
+ export function requireEstate(env) {
3
+ if (env.estateRoot)
4
+ return env.estateRoot;
5
+ throw new Error("no estate found — put `estate: true` in .claude/zeno.yaml of the folder that holds your products, or set ZENO_ESTATE");
6
+ }
@@ -0,0 +1,78 @@
1
+ import { basename, resolve } from "node:path";
2
+ import * as prompts from "@clack/prompts";
3
+ import { Option } from "commander";
4
+ import { KINDS, MODULES } from "../config/schema.js";
5
+ import { RULES_FILES } from "../checks/architecture.js";
6
+ import { applyInit, parseChoices, planInit } from "../init.js";
7
+ import { listPacks } from "../packs.js";
8
+ import { runCommand } from "../lib/system.js";
9
+ import { zenoVersion } from "../status.js";
10
+ function cancelled(value) {
11
+ if (!prompts.isCancel(value))
12
+ return false;
13
+ prompts.cancel("Nothing written.");
14
+ return true;
15
+ }
16
+ async function askAnswers(inferred, packs) {
17
+ const kind = await prompts.select({
18
+ message: "What kind of product is this?",
19
+ initialValue: inferred.config.kind,
20
+ options: KINDS.map((value) => ({ value, label: value })),
21
+ });
22
+ if (cancelled(kind))
23
+ return null;
24
+ const modules = await prompts.multiselect({
25
+ message: "Which modules? All optional. (docs = wiki, design = HTML designs, knowledge = search, specs, metrics, code = LSP, architecture, listing)",
26
+ initialValues: inferred.config.modules,
27
+ options: MODULES.map((value) => ({ value, label: value })),
28
+ required: false,
29
+ });
30
+ if (cancelled(modules))
31
+ return null;
32
+ const forKind = packs.filter((pack) => pack.kinds.includes(kind));
33
+ if (forKind.length === 0)
34
+ return { kind, modules, packs: [] };
35
+ const chosen = await prompts.multiselect({
36
+ message: "Which vendor doc packs? (indexed for search on `zeno sync`)",
37
+ initialValues: inferred.config.packs ?? [],
38
+ options: forKind.map((pack) => ({ value: pack.name, label: pack.name, hint: pack.description })),
39
+ required: false,
40
+ });
41
+ if (cancelled(chosen))
42
+ return null;
43
+ return { kind, modules, packs: chosen };
44
+ }
45
+ export function registerInit(program, env) {
46
+ program
47
+ .command("init")
48
+ .description("set zeno up for a product: write .claude/zeno.yaml and the missing .context files")
49
+ .argument("[repo]", "product repo", ".")
50
+ .option("-y, --yes", "accept what zeno infers (and any flags below), no questions")
51
+ .addOption(new Option("--kind <kind>", "product kind").choices(KINDS))
52
+ .option("--modules <list>", `comma list, empty for none: ${MODULES.join(", ")}`)
53
+ .option("--packs <list>", "comma list of vendor doc packs (see `zeno add`)")
54
+ .option("--force", "redo a repo that already has zeno.yaml")
55
+ .action(async (repo, options) => {
56
+ const path = resolve(repo);
57
+ const packs = listPacks(env.zenoRoot);
58
+ const flags = {
59
+ zenoVersion: zenoVersion(env.zenoRoot),
60
+ force: options.force,
61
+ kind: options.kind,
62
+ modules: options.modules === undefined ? undefined : parseChoices(options.modules, MODULES, "module"),
63
+ packs: options.packs === undefined ? undefined : parseChoices(options.packs, packs.map((p) => p.name), "pack"),
64
+ };
65
+ const inferred = planInit(path, flags);
66
+ const answers = options.yes ? null : await askAnswers(inferred, packs);
67
+ if (!options.yes && answers === null)
68
+ return;
69
+ const plan = answers ? planInit(path, { ...flags, ...answers }) : inferred;
70
+ applyInit(path, plan, { name: basename(path), zenoRoot: env.zenoRoot, run: (command, args) => void runCommand(command, args) });
71
+ console.log(`wrote .claude/zeno.yaml (${plan.config.kind}: ${plan.config.modules.join(", ")})`);
72
+ for (const file of plan.create)
73
+ console.log(`created ${file}`);
74
+ if (plan.create.some((file) => file.endsWith(RULES_FILES[0])))
75
+ console.log("architecture: add the checker with `pnpm add -D dependency-cruiser`, then edit the .dependency-cruiser.cjs files to this repo's layers");
76
+ console.log(plan.config.packs ? "next: zeno sync (fetches the packs), then zeno check" : "next: zeno check");
77
+ });
78
+ }
@@ -0,0 +1,35 @@
1
+ import { proposals, readDecisions, readVerdicts, recordDecision } from "../learn.js";
2
+ import { findProducts } from "../run.js";
3
+ import { requireEstate } from "./env.js";
4
+ const DEFAULT_THRESHOLD = 3;
5
+ export function registerLearn(program, env) {
6
+ program
7
+ .command("learn")
8
+ .description("turn overridden verdicts across products into proposals to change the rules")
9
+ .option("--threshold <n>", "overrides of one principle before it is proposed", String(DEFAULT_THRESHOLD))
10
+ .option("--approve <id>", "mark a proposal approved (then edit the standard)")
11
+ .option("--reject <id>", "mark a proposal rejected; it returns only with new evidence")
12
+ .option("--note <text>", "why, for --approve / --reject", "")
13
+ .action((options) => {
14
+ const estateRoot = requireEstate(env);
15
+ const entries = readVerdicts(findProducts(estateRoot));
16
+ const found = proposals(entries, { threshold: Number(options.threshold), decisions: readDecisions(estateRoot) });
17
+ const target = options.approve ?? options.reject;
18
+ if (target) {
19
+ const proposal = found.find((p) => p.id === target);
20
+ if (!proposal)
21
+ throw new Error(`no open proposal "${target}" — run \`zeno learn\` to list them`);
22
+ recordDecision(estateRoot, { id: target, decision: options.approve ? "approved" : "rejected", count: proposal.count, note: options.note });
23
+ console.log(`${options.approve ? "approved" : "rejected"} ${target}`);
24
+ return;
25
+ }
26
+ console.log(`${entries.length} overridden verdict(s) logged across the estate`);
27
+ if (found.length === 0)
28
+ console.log("no proposals");
29
+ for (const proposal of found) {
30
+ console.log(`\n${proposal.id}\n ${proposal.summary}\n products: ${proposal.products.join(", ")}`);
31
+ for (const reason of proposal.reasons)
32
+ console.log(` - "${reason}"`);
33
+ }
34
+ });
35
+ }
@@ -0,0 +1,26 @@
1
+ import { resolve } from "node:path";
2
+ import { loadConfig } from "../config/load.js";
3
+ import { constitutionTitles, resolveStandards } from "../standards.js";
4
+ export function registerStandards(program, env) {
5
+ program
6
+ .command("standards")
7
+ .description("list the standards that apply to a repo: zeno's defaults, then the estate's and the repo's")
8
+ .argument("[repo]", "product repo", ".")
9
+ .option("--json", "print machine-readable JSON")
10
+ .option("--constitution", "print only the principle titles, zeno's then any added ones")
11
+ .action((repo, options) => {
12
+ const { config } = loadConfig(resolve(repo));
13
+ if (options.constitution) {
14
+ const titles = constitutionTitles(env.zenoRoot, config.standards);
15
+ console.log(options.json ? JSON.stringify(titles) : titles.map((title) => `- ${title}`).join("\n"));
16
+ return;
17
+ }
18
+ const standards = resolveStandards(env.zenoRoot, config.standards);
19
+ if (options.json) {
20
+ console.log(JSON.stringify(standards, null, 2));
21
+ return;
22
+ }
23
+ for (const standard of standards)
24
+ console.log(`${standard.name.padEnd(18)} ${standard.path}`);
25
+ });
26
+ }
@@ -0,0 +1,11 @@
1
+ import { resolve } from "node:path";
2
+ import { buildStatus } from "../status.js";
3
+ export function registerStatus(program, env) {
4
+ program
5
+ .command("status")
6
+ .description("one screen: zeno version, config, check summary")
7
+ .argument("[repo]", "product repo", ".")
8
+ .action((repo) => {
9
+ console.log(buildStatus(resolve(repo), { zenoRoot: env.zenoRoot }));
10
+ });
11
+ }
@@ -0,0 +1,23 @@
1
+ import { resolve } from "node:path";
2
+ import { exitCode, formatReports } from "../format.js";
3
+ import { CACHE_DIR, fetchText, runCommand } from "../lib/system.js";
4
+ import { runChecks } from "../run.js";
5
+ import { syncRepo } from "../sync.js";
6
+ export function registerSync(program, env) {
7
+ program
8
+ .command("sync")
9
+ .description("refresh pack docs and the search index, move the pinned zeno version, then check")
10
+ .argument("[repo]", "product repo", ".")
11
+ .action(async (repo) => {
12
+ const path = resolve(repo);
13
+ const result = await syncRepo(path, { zenoRoot: env.zenoRoot, estateRoot: env.estateRoot ?? undefined, cacheDir: CACHE_DIR, fetchText, run: runCommand });
14
+ console.log(`packs refreshed: ${result.packs.join(", ") || "none"}`);
15
+ if (result.base)
16
+ console.log("architecture base updated from zeno's template");
17
+ if (result.version)
18
+ console.log(`zeno ${result.version.from ?? "unpinned"} → ${result.version.to}`);
19
+ const report = runChecks(path, { zenoRoot: env.zenoRoot });
20
+ console.log(formatReports([report]));
21
+ process.exitCode = exitCode([report]);
22
+ });
23
+ }
@@ -0,0 +1,64 @@
1
+ import { spawn, spawnSync } from "node:child_process";
2
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { architectureGraph } from "../checks/architecture.js";
5
+ import { formatReports } from "../format.js";
6
+ import { CACHE_DIR } from "../lib/system.js";
7
+ import { findProducts, runChecks } from "../run.js";
8
+ import { buildSite, quartzConfig } from "../ui.js";
9
+ import { requireEstate } from "./env.js";
10
+ // Quartz v5 has no tagged release yet, so pin the exact commit: an unpinned
11
+ // clone could change the site under us on the next fresh install.
12
+ const QUARTZ_REPO = "https://github.com/jackyzha0/quartz";
13
+ const QUARTZ_COMMIT = "97a2d05f80c4c50534959b1d0d41cc4b3895625e";
14
+ const QUARTZ_DIR = join(CACHE_DIR, "quartz");
15
+ function must(command, args, cwd) {
16
+ const result = spawnSync(command, args, { cwd, stdio: "inherit" });
17
+ if (result.status !== 0)
18
+ throw new Error(`${command} ${args.join(" ")} failed`);
19
+ }
20
+ function ensureQuartz() {
21
+ if (!existsSync(join(QUARTZ_DIR, "node_modules"))) {
22
+ if (!existsSync(QUARTZ_DIR))
23
+ must("git", ["clone", "--quiet", "--filter=blob:none", QUARTZ_REPO, QUARTZ_DIR]);
24
+ must("git", ["checkout", "--quiet", QUARTZ_COMMIT], QUARTZ_DIR);
25
+ must("npm", ["ci", "--silent"], QUARTZ_DIR);
26
+ }
27
+ const defaults = readFileSync(join(QUARTZ_DIR, "quartz.config.default.yaml"), "utf8");
28
+ writeFileSync(join(QUARTZ_DIR, "quartz.config.yaml"), quartzConfig(defaults));
29
+ }
30
+ export function registerUi(program, env) {
31
+ program
32
+ .command("ui")
33
+ .description("serve every product wiki (search, backlinks, graph) and the design galleries on localhost")
34
+ // Uncommon defaults: 8080 and 3001 are usually taken by Docker or a dev server.
35
+ .option("--port <port>", "wiki site port", "4610")
36
+ .option("--designs-port <port>", "design gallery port", "4611")
37
+ .option("--ws-port <port>", "live-reload socket port", "4612")
38
+ .action((options) => {
39
+ ensureQuartz();
40
+ const products = findProducts(requireEstate(env));
41
+ const siteDir = join(CACHE_DIR, "site");
42
+ const designsDir = join(CACHE_DIR, "designs");
43
+ const designsUrl = `http://127.0.0.1:${options.designsPort}`;
44
+ const report = formatReports(products.map((repo) => runChecks(repo, { zenoRoot: env.zenoRoot, estateRoot: env.estateRoot })));
45
+ buildSite(products, { siteDir, designsDir, designsUrl, report, graph: architectureGraph });
46
+ const designs = spawn("python3", ["-m", "http.server", options.designsPort, "--bind", "127.0.0.1", "--directory", designsDir], {
47
+ stdio: "ignore",
48
+ });
49
+ const site = spawn("npx", ["quartz", "build", "-d", siteDir, "-o", join(CACHE_DIR, "public"), "--serve", "--port", options.port, "--wsPort", options.wsPort], {
50
+ cwd: QUARTZ_DIR,
51
+ stdio: "inherit",
52
+ });
53
+ console.log(`wiki http://localhost:${options.port}\ndesigns ${designsUrl}`);
54
+ const stop = () => {
55
+ designs.kill();
56
+ site.kill();
57
+ };
58
+ process.on("SIGINT", stop);
59
+ site.on("exit", (code) => {
60
+ designs.kill();
61
+ process.exitCode = code ?? 0;
62
+ });
63
+ });
64
+ }
@@ -0,0 +1,16 @@
1
+ import { spawnSync } from "node:child_process";
2
+ export function registerWeigh(program) {
3
+ program
4
+ .command("weigh")
5
+ .description("run the decision gate on a request without doing it (calls Claude)")
6
+ .argument("<request...>", "what you want to do")
7
+ .action((words) => {
8
+ const request = words.join(" ");
9
+ const result = spawnSync("claude", ["-p", `/zeno:weigh ${request}\n\nWeigh only. Do not start the work.`], {
10
+ stdio: "inherit",
11
+ });
12
+ if (result.error)
13
+ throw new Error(`could not run claude: ${result.error.message}`);
14
+ process.exitCode = result.status ?? 1;
15
+ });
16
+ }
@@ -0,0 +1,93 @@
1
+ import { existsSync, readdirSync, readFileSync } from "node:fs";
2
+ import { dirname, join, resolve } from "node:path";
3
+ import { parse } from "yaml";
4
+ import { MODULES_BY_KIND, layerSchema } from "./schema.js";
5
+ const EXPO_MARKERS = ["app.config.ts", "app.config.js", "app.json"];
6
+ const WEB_MARKERS = ["next.config.ts", "next.config.js", "next.config.mjs", "vite.config.ts"];
7
+ function configPath(folder) {
8
+ return join(folder, ".claude", "zeno.yaml");
9
+ }
10
+ function readLayer(path) {
11
+ const parsed = layerSchema.safeParse(parse(readFileSync(path, "utf8")) ?? {});
12
+ if (!parsed.success) {
13
+ const problems = parsed.error.issues.map((issue) => `${issue.path.join(".") || "(root)"}: ${issue.message}`);
14
+ throw new Error(`${path} is invalid:\n ${problems.join("\n ")}`);
15
+ }
16
+ return parsed.data;
17
+ }
18
+ /** The nearest folder at or above `start` whose zeno.yaml says `estate: true`, or null. */
19
+ export function findEstate(start) {
20
+ let folder = resolve(start);
21
+ while (true) {
22
+ const path = configPath(folder);
23
+ if (existsSync(path) && readLayer(path).estate === true)
24
+ return folder;
25
+ const parent = dirname(folder);
26
+ if (parent === folder)
27
+ return null;
28
+ folder = parent;
29
+ }
30
+ }
31
+ /** The repo root plus each app in a monorepo's apps/ folder. */
32
+ function appRoots(repo) {
33
+ const apps = join(repo, "apps");
34
+ if (!existsSync(apps))
35
+ return [repo];
36
+ const children = readdirSync(apps, { withFileTypes: true }).filter((entry) => entry.isDirectory());
37
+ return [repo, ...children.map((entry) => join(apps, entry.name))];
38
+ }
39
+ export function inferKind(repo) {
40
+ const roots = appRoots(repo);
41
+ const anyHas = (markers) => roots.some((root) => markers.some((file) => existsSync(join(root, file))));
42
+ if (anyHas(EXPO_MARKERS))
43
+ return "expo-app";
44
+ if (anyHas(WEB_MARKERS))
45
+ return "web";
46
+ return "content";
47
+ }
48
+ export function inferModules(repo, kind) {
49
+ const modules = MODULES_BY_KIND[kind];
50
+ const hasDesigns = existsSync(join(repo, ".context", "designs"));
51
+ return hasDesigns && !modules.includes("design") ? [...modules, "design"] : modules;
52
+ }
53
+ function lastDefined(values) {
54
+ return values.filter((value) => value !== undefined).pop();
55
+ }
56
+ function mergeBy(lists, key) {
57
+ const merged = new Map();
58
+ for (const item of lists.flat())
59
+ merged.set(key(item), item);
60
+ return [...merged.values()];
61
+ }
62
+ /**
63
+ * Reads the estate, product and local zeno.yaml layers over defaults inferred
64
+ * from the repo. Scalars: the most specific layer wins. Lists of names add up
65
+ * (packs, standards); lists of records merge by their key (commands, tools).
66
+ */
67
+ export function loadConfig(repo, options = {}) {
68
+ const estateRoot = options.estateRoot === undefined ? findEstate(dirname(resolve(repo))) : options.estateRoot;
69
+ const candidates = [
70
+ ...(estateRoot && resolve(estateRoot) !== resolve(repo) ? [{ layer: "estate", path: configPath(estateRoot) }] : []),
71
+ { layer: "product", path: configPath(repo) },
72
+ { layer: "local", path: join(repo, ".claude", "zeno.local.yaml") },
73
+ ];
74
+ const sources = candidates.filter((source) => existsSync(source.path));
75
+ const layers = sources.map((source) => ({ data: readLayer(source.path), base: dirname(dirname(source.path)) }));
76
+ const pick = (key) => layers.map((layer) => layer.data[key]);
77
+ const kind = lastDefined(pick("kind")) ?? inferKind(repo);
78
+ const config = {
79
+ zeno: lastDefined(pick("zeno")),
80
+ kind,
81
+ modules: lastDefined(pick("modules")) ?? inferModules(repo, kind),
82
+ expo: Object.assign({}, ...pick("expo")),
83
+ packs: [...new Set(pick("packs").flatMap((packs) => packs ?? []))].sort(),
84
+ standards: [...new Set(layers.flatMap((layer) => (layer.data.standards ?? []).map((dir) => resolve(layer.base, dir))))],
85
+ commands: mergeBy(pick("commands").map((list) => list ?? []), (command) => command.id),
86
+ doctor: { tools: mergeBy(pick("doctor").map((doctor) => doctor?.tools ?? []), (tool) => tool.tool) },
87
+ checks: Object.assign({}, ...pick("checks")),
88
+ weigh: { weights: Object.assign({}, ...pick("weigh").map((weigh) => weigh?.weights)) },
89
+ };
90
+ if (Object.keys(config.expo ?? {}).length === 0)
91
+ delete config.expo;
92
+ return { config, sources };
93
+ }
@@ -0,0 +1,43 @@
1
+ import { z } from "zod";
2
+ export const KINDS = ["expo-app", "web", "content"];
3
+ export const MODULES = ["docs", "design", "knowledge", "specs", "metrics", "code", "architecture", "listing"];
4
+ const checkOverride = z
5
+ .object({ enabled: z.boolean().default(true), reason: z.string().min(1).optional() })
6
+ .refine((check) => check.enabled || check.reason !== undefined, {
7
+ message: "a check turned off needs a `reason:` — an override nobody can explain is drift",
8
+ });
9
+ const principleId = z.string().regex(/^C-\d{2}$/, "principle IDs look like C-07");
10
+ const command = z
11
+ .object({
12
+ id: z.string().regex(/^[a-z0-9-]+$/),
13
+ /** Shell command run from the repo root; a non-zero exit is an error finding. */
14
+ run: z.string().min(1),
15
+ /** Only run when this path exists in the repo. */
16
+ if: z.string().optional(),
17
+ })
18
+ .strict();
19
+ const tool = z.object({ tool: z.string(), why: z.string(), hint: z.string() }).strict();
20
+ /** One layer of `.claude/zeno.yaml` as written: every field optional. */
21
+ export const layerSchema = z
22
+ .object({
23
+ /** Marks the folder holding this file as an estate: the parent of several products. */
24
+ estate: z.boolean(),
25
+ zeno: z.string(),
26
+ kind: z.enum(KINDS),
27
+ modules: z.array(z.enum(MODULES)),
28
+ expo: z.object({ sdk: z.number().int().positive() }).partial(),
29
+ packs: z.array(z.string().regex(/^[a-z0-9-]+$/, "pack names are lowercase-kebab")),
30
+ /** Folders of extra standards, relative to the folder that holds `.claude/`. */
31
+ standards: z.array(z.string()),
32
+ commands: z.array(command),
33
+ doctor: z.object({ tools: z.array(tool) }).partial(),
34
+ checks: z.record(z.string(), checkOverride),
35
+ weigh: z.object({ weights: z.record(principleId, z.number().nonnegative()) }).partial(),
36
+ })
37
+ .partial()
38
+ .strict();
39
+ export const MODULES_BY_KIND = {
40
+ "expo-app": ["docs", "knowledge", "metrics", "code", "listing"],
41
+ web: ["docs", "knowledge", "metrics", "code"],
42
+ content: ["docs", "knowledge"],
43
+ };
@@ -0,0 +1,31 @@
1
+ import { spawnSync } from "node:child_process";
2
+ // What zeno itself needs. A repo or estate adds its own under `doctor.tools`.
3
+ const CORE_TOOLS = [
4
+ { tool: "node", why: "runs the zeno CLI and hooks", hint: "install Node 22+", minimum: [22, 0] },
5
+ { tool: "git", why: "zeno works on git repos", hint: "install git" },
6
+ { tool: "qmd", why: "knowledge search for Claude", hint: "npm i -g @tobilu/qmd (see the zeno README)" },
7
+ ];
8
+ const VERSION = /(\d+)\.(\d+)(?:\.(\d+))?/;
9
+ export const systemProbe = (command) => {
10
+ const result = spawnSync(command, ["--version"], { encoding: "utf8" });
11
+ if (result.error || result.status !== 0)
12
+ return null;
13
+ return `${result.stdout}${result.stderr}`.trim().split("\n")[0];
14
+ };
15
+ export function runDoctor(probe = systemProbe, extra = []) {
16
+ const tools = [...CORE_TOOLS, ...extra];
17
+ return tools.map(({ tool, why, hint, minimum }) => {
18
+ const output = probe(tool);
19
+ if (output === null)
20
+ return { tool, ok: false, why, hint };
21
+ const match = VERSION.exec(output);
22
+ const version = match?.[0];
23
+ if (minimum && match) {
24
+ const [major, minor] = [Number(match[1]), Number(match[2])];
25
+ const tooOld = major < minimum[0] || (major === minimum[0] && minor < minimum[1]);
26
+ if (tooOld)
27
+ return { tool, ok: false, version, why, hint };
28
+ }
29
+ return { tool, ok: true, version, why };
30
+ });
31
+ }
@@ -0,0 +1,37 @@
1
+ const MARK = { error: "✗", warn: "!" };
2
+ function plural(count, word) {
3
+ return `${count} ${word}${count === 1 ? "" : "s"}`;
4
+ }
5
+ function counts(report) {
6
+ return {
7
+ errors: report.findings.filter((f) => f.level === "error").length,
8
+ warnings: report.findings.filter((f) => f.level === "warn").length,
9
+ };
10
+ }
11
+ /** Compact, rtk-style text: one header per product, one line per finding, one total line. */
12
+ export function formatReports(reports, options = {}) {
13
+ const lines = [];
14
+ let errors = 0;
15
+ let warnings = 0;
16
+ for (const report of reports) {
17
+ const count = counts(report);
18
+ errors += count.errors;
19
+ warnings += count.warnings;
20
+ const clean = report.findings.length === 0;
21
+ if (clean && options.quiet && report.overrides.length === 0)
22
+ continue;
23
+ const summary = clean ? "ok" : `${plural(count.errors, "error")}, ${plural(count.warnings, "warning")}`;
24
+ lines.push(`${report.name} ${summary}`);
25
+ for (const finding of report.findings) {
26
+ const message = finding.message.replace(/\n/g, "\n ");
27
+ lines.push(` ${MARK[finding.level]} ${finding.check} ${finding.file ?? ""} ${message}`.replace(/ +$/, ""));
28
+ }
29
+ for (const override of report.overrides)
30
+ lines.push(` override ${override.check}: ${override.reason}`);
31
+ }
32
+ lines.push(`${plural(reports.length, "product")} · ${plural(errors, "error")} · ${plural(warnings, "warning")}`);
33
+ return lines.join("\n");
34
+ }
35
+ export function exitCode(reports) {
36
+ return reports.some((report) => report.findings.some((f) => f.level === "error")) ? 1 : 0;
37
+ }
@@ -0,0 +1,36 @@
1
+ #!/usr/bin/env node
2
+ import { resolve } from "node:path";
3
+ import { Command } from "commander";
4
+ import { registerAdd } from "./commands/add.js";
5
+ import { registerCheck } from "./commands/check.js";
6
+ import { registerDoctor } from "./commands/doctor.js";
7
+ import { registerInit } from "./commands/init.js";
8
+ import { registerLearn } from "./commands/learn.js";
9
+ import { registerStandards } from "./commands/standards.js";
10
+ import { registerStatus } from "./commands/status.js";
11
+ import { registerSync } from "./commands/sync.js";
12
+ import { registerUi } from "./commands/ui.js";
13
+ import { registerWeigh } from "./commands/weigh.js";
14
+ import { findEstate } from "./config/load.js";
15
+ import { zenoVersion } from "./status.js";
16
+ const env = {
17
+ zenoRoot: resolve(import.meta.dirname, "..", ".."),
18
+ estateRoot: process.env.ZENO_ESTATE ?? findEstate(process.cwd()),
19
+ };
20
+ const program = new Command("zeno")
21
+ .description("Shared standards, knowledge and decision checks for every product.")
22
+ .version(zenoVersion(env.zenoRoot));
23
+ registerInit(program, env);
24
+ registerAdd(program, env);
25
+ registerCheck(program, env);
26
+ registerSync(program, env);
27
+ registerStatus(program, env);
28
+ registerStandards(program, env);
29
+ registerDoctor(program);
30
+ registerUi(program, env);
31
+ registerLearn(program, env);
32
+ registerWeigh(program);
33
+ program.parseAsync().catch((error) => {
34
+ console.error(`zeno: ${error.message}`);
35
+ process.exitCode = 1;
36
+ });