@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,120 @@
1
+ import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import { stringify } from "yaml";
4
+ import { RULES_FILES } from "./checks/architecture.js";
5
+ import { requiredContext } from "./checks/context-layout.js";
6
+ import { inferKind, inferModules } from "./config/load.js";
7
+ import { MODULES_BY_KIND } from "./config/schema.js";
8
+ export const ARCHITECTURE_BASE = ".dependency-cruiser.base.cjs";
9
+ const ARCHITECTURE_TEMPLATES = join("templates", "dependency-cruiser");
10
+ const WORKSPACE_PARENTS = ["apps", "packages"];
11
+ /**
12
+ * The rules files the architecture module needs: zeno's managed base, then a starter
13
+ * per workspace (a folder under apps/ or packages/ with a package.json), or one at the
14
+ * root when the repo has no workspaces.
15
+ */
16
+ export function architectureStarters(repo) {
17
+ const workspaces = WORKSPACE_PARENTS.flatMap((parent) => existsSync(join(repo, parent))
18
+ ? readdirSync(join(repo, parent), { withFileTypes: true })
19
+ .filter((entry) => entry.isDirectory() && existsSync(join(repo, parent, entry.name, "package.json")))
20
+ .map((entry) => `${parent}/${entry.name}`)
21
+ .sort()
22
+ : []);
23
+ const starters = workspaces.length > 0
24
+ ? workspaces.map((dir) => ({ file: `${dir}/${RULES_FILES[0]}`, template: dir.startsWith("apps/") ? "app.cjs" : "package.cjs" }))
25
+ : [{ file: RULES_FILES[0], template: "root.cjs" }];
26
+ return [{ file: ARCHITECTURE_BASE, template: "base.cjs" }, ...starters];
27
+ }
28
+ /** Rewrites a repo's managed architecture base from zeno's template; true when it changed. */
29
+ export function refreshArchitectureBase(repo, zenoRoot) {
30
+ const file = join(repo, ARCHITECTURE_BASE);
31
+ if (!existsSync(file))
32
+ return false;
33
+ const current = readFileSync(join(zenoRoot, ARCHITECTURE_TEMPLATES, "base.cjs"), "utf8");
34
+ if (readFileSync(file, "utf8") === current)
35
+ return false;
36
+ writeFileSync(file, current);
37
+ return true;
38
+ }
39
+ /** Parses a `--modules`/`--packs` style comma list, rejecting anything not in `allowed`. */
40
+ export function parseChoices(value, allowed, label) {
41
+ const chosen = value.split(",").map((item) => item.trim()).filter(Boolean);
42
+ const unknown = chosen.find((item) => !allowed.includes(item));
43
+ if (unknown)
44
+ throw new Error(`unknown ${label} "${unknown}" — choose from: ${allowed.join(", ")}`);
45
+ return chosen;
46
+ }
47
+ const PACKAGE_FILES = ["package.json", "apps/mobile/package.json"];
48
+ export function detectExpoSdk(repo) {
49
+ for (const file of PACKAGE_FILES) {
50
+ const path = join(repo, file);
51
+ if (!existsSync(path))
52
+ continue;
53
+ const pkg = JSON.parse(readFileSync(path, "utf8"));
54
+ const range = pkg.dependencies?.expo ?? pkg.devDependencies?.expo;
55
+ const major = range?.match(/\d+/)?.[0];
56
+ if (major)
57
+ return Number(major);
58
+ }
59
+ return undefined;
60
+ }
61
+ // The first file each required .context entry gets, so no folder is created empty.
62
+ const FIRST_FILE = {
63
+ "README.md": ".context/README.md",
64
+ "tasks.md": ".context/tasks.md",
65
+ raw: ".context/raw/.gitkeep",
66
+ wiki: ".context/wiki/index.md",
67
+ features: ".context/features/index.md",
68
+ };
69
+ function template(file, today) {
70
+ switch (file) {
71
+ case ".context/README.md":
72
+ return "# .context\n\nProduct material, in the ten folders of the zeno context layout.\n\n## Absent folders\n\n- listing/ — n/a — no store presence yet\n- marketing/, metrics/, support/, legal/ — n/a — not live yet\n- ops/ — n/a — nothing to run yet\n";
73
+ case ".context/tasks.md":
74
+ return `---\nupdated: ${today}\nhorizon: ${today}\n---\n\n# Tasks\n\n## Now\n\n## Parallel\n\n## Blocked\n\n## Waiting\n\n## Done\n\n## Session Notes\n`;
75
+ case ".context/wiki/index.md":
76
+ return "# Wiki index\n\nPages by type. Schema: the zeno plugin's wiki-schema standard.\n";
77
+ case ".context/features/index.md":
78
+ return "# Features\n\n| ID | Title | Status | Release |\n|---|---|---|---|\n";
79
+ default:
80
+ return "";
81
+ }
82
+ }
83
+ export function planInit(repo, answers) {
84
+ if (existsSync(join(repo, ".claude", "zeno.yaml")) && !answers.force) {
85
+ throw new Error(`${repo} is already set up (.claude/zeno.yaml exists) — use --force to redo it`);
86
+ }
87
+ const kind = answers.kind ?? inferKind(repo);
88
+ const modules = answers.modules ?? (answers.kind ? MODULES_BY_KIND[kind] : inferModules(repo, kind));
89
+ const sdk = kind === "expo-app" ? detectExpoSdk(repo) : undefined;
90
+ const packs = answers.packs?.length ? { packs: answers.packs } : {};
91
+ const config = { zeno: answers.zenoVersion, kind, modules, ...(sdk ? { expo: { sdk } } : {}), ...packs };
92
+ const create = requiredContext(modules).filter((name) => !existsSync(join(repo, ".context", name))).map((name) => FIRST_FILE[name]);
93
+ if (modules.includes("architecture")) {
94
+ // A workspace that already has rules of its own (any name dependency-cruiser reads) keeps them.
95
+ const taken = (file) => file === ARCHITECTURE_BASE ? existsSync(join(repo, file)) : RULES_FILES.some((name) => existsSync(join(repo, dirname(file), name)));
96
+ create.push(...architectureStarters(repo).map((s) => s.file).filter((file) => !taken(file)));
97
+ }
98
+ return { config, create };
99
+ }
100
+ export function applyInit(repo, plan, options) {
101
+ const today = options.today ?? new Date().toISOString().slice(0, 10);
102
+ const write = (file, content) => {
103
+ mkdirSync(dirname(join(repo, file)), { recursive: true });
104
+ writeFileSync(join(repo, file), content);
105
+ };
106
+ write(".claude/zeno.yaml", stringify(plan.config));
107
+ const starters = new Map(architectureStarters(repo).map((s) => [s.file, s.template]));
108
+ for (const file of plan.create) {
109
+ const starter = starters.get(file);
110
+ write(file, starter ? readFileSync(join(options.zenoRoot, ARCHITECTURE_TEMPLATES, starter), "utf8") : template(file, today));
111
+ }
112
+ if (plan.config.modules.includes("knowledge"))
113
+ indexKnowledge(repo, options.name, options.run);
114
+ }
115
+ /** Adds the repo's wiki and features to qmd. Safe to repeat: qmd refuses a name it already has. */
116
+ export function indexKnowledge(repo, name, run) {
117
+ for (const kind of ["wiki", "features"]) {
118
+ run("qmd", ["collection", "add", join(repo, ".context", kind), "--name", `${name}-${kind}`]);
119
+ }
120
+ }
@@ -0,0 +1,69 @@
1
+ import { appendFileSync, existsSync, mkdirSync, readFileSync } from "node:fs";
2
+ import { basename, join } from "node:path";
3
+ const LOG = join(".context", "raw", "zeno", "verdicts.jsonl");
4
+ const MAX_REASONS = 5;
5
+ function readJsonLines(path) {
6
+ if (!existsSync(path))
7
+ return [];
8
+ return readFileSync(path, "utf8")
9
+ .split("\n")
10
+ .flatMap((line) => {
11
+ try {
12
+ return line.trim() ? [JSON.parse(line)] : [];
13
+ }
14
+ catch {
15
+ return [];
16
+ }
17
+ });
18
+ }
19
+ export function readVerdicts(repos) {
20
+ return repos.flatMap((repo) => readJsonLines(join(repo, LOG)).map((entry) => ({ ...entry, product: basename(repo) })));
21
+ }
22
+ function group(entries, keyOf) {
23
+ const groups = new Map();
24
+ for (const entry of entries) {
25
+ for (const key of keyOf(entry))
26
+ groups.set(key, [...(groups.get(key) ?? []), entry]);
27
+ }
28
+ return groups;
29
+ }
30
+ function toProposal(id, summary, entries) {
31
+ return {
32
+ id,
33
+ summary,
34
+ count: entries.length,
35
+ products: [...new Set(entries.map((e) => e.product))].sort(),
36
+ reasons: [...new Set(entries.map((e) => e.reason))].slice(0, MAX_REASONS),
37
+ };
38
+ }
39
+ /**
40
+ * Turns override history into proposals: a principle overridden `threshold`+
41
+ * times, or one reason given in two or more products. A decided proposal comes
42
+ * back only when its count has grown past what it was when decided.
43
+ */
44
+ export function proposals(entries, options) {
45
+ const found = [];
46
+ for (const [principle, group_] of group(entries, (e) => e.principles)) {
47
+ if (group_.length >= options.threshold) {
48
+ found.push(toProposal(`principle:${principle}`, `${principle} was overridden ${group_.length} times — revisit the rule or its wording`, group_));
49
+ }
50
+ }
51
+ for (const [reason, group_] of group(entries, (e) => [e.reason])) {
52
+ const products = new Set(group_.map((e) => e.product));
53
+ if (products.size >= 2) {
54
+ found.push(toProposal(`reason:${reason}`, `"${reason}" was given in ${products.size} products — a missing exception?`, group_));
55
+ }
56
+ }
57
+ const decidedAt = new Map(options.decisions.map((d) => [d.id, d.count]));
58
+ return found.filter((p) => !decidedAt.has(p.id) || p.count > (decidedAt.get(p.id) ?? 0));
59
+ }
60
+ // In the estate, not the zeno install: a global npm install may be read-only and is replaced on upgrade.
61
+ const DECISIONS_DIR = join(".claude", "zeno");
62
+ const DECISIONS = join(DECISIONS_DIR, "decisions.jsonl");
63
+ export function readDecisions(estateRoot) {
64
+ return readJsonLines(join(estateRoot, DECISIONS));
65
+ }
66
+ export function recordDecision(estateRoot, decision) {
67
+ mkdirSync(join(estateRoot, DECISIONS_DIR), { recursive: true });
68
+ appendFileSync(join(estateRoot, DECISIONS), `${JSON.stringify({ ...decision, at: new Date().toISOString() })}\n`);
69
+ }
@@ -0,0 +1,23 @@
1
+ import { existsSync, readdirSync, statSync } from "node:fs";
2
+ import { join, relative } from "node:path";
3
+ /** Every file under `dir` whose name matches `test`, as paths relative to `root`. Missing dir → []. */
4
+ export function listFiles(root, dir, test) {
5
+ const start = join(root, dir);
6
+ if (!existsSync(start))
7
+ return [];
8
+ const found = [];
9
+ const walk = (current) => {
10
+ for (const entry of readdirSync(current, { withFileTypes: true })) {
11
+ const full = join(current, entry.name);
12
+ if (entry.isDirectory())
13
+ walk(full);
14
+ else if (test(entry.name))
15
+ found.push(relative(root, full));
16
+ }
17
+ };
18
+ walk(start);
19
+ return found.sort();
20
+ }
21
+ export function isDirectory(path) {
22
+ return existsSync(path) && statSync(path).isDirectory();
23
+ }
@@ -0,0 +1,16 @@
1
+ import { parse } from "yaml";
2
+ const FRONTMATTER = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/;
3
+ /** Splits YAML frontmatter from a Markdown body. Unparseable or absent frontmatter → data null. */
4
+ export function parseFrontmatter(text) {
5
+ const match = FRONTMATTER.exec(text);
6
+ if (!match)
7
+ return { data: null, body: text };
8
+ try {
9
+ const data = parse(match[1]);
10
+ const isObject = typeof data === "object" && data !== null && !Array.isArray(data);
11
+ return { data: isObject ? data : null, body: text.slice(match[0].length) };
12
+ }
13
+ catch {
14
+ return { data: null, body: text.slice(match[0].length) };
15
+ }
16
+ }
@@ -0,0 +1,19 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { homedir } from "node:os";
3
+ import { join } from "node:path";
4
+ export const CACHE_DIR = process.env.ZENO_CACHE ?? join(homedir(), ".cache", "zeno");
5
+ const FETCH_TIMEOUT_MS = 30_000;
6
+ export function runCommand(command, args) {
7
+ const result = spawnSync(command, args, { encoding: "utf8" });
8
+ if (result.error) {
9
+ const missing = result.error.code === "ENOENT";
10
+ return { status: 1, output: missing ? `${command} is not installed — run \`zeno doctor\`` : result.error.message };
11
+ }
12
+ return { status: result.status ?? 1, output: `${result.stdout}${result.stderr}` };
13
+ }
14
+ export async function fetchText(url) {
15
+ const response = await fetch(url, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) });
16
+ if (!response.ok)
17
+ throw new Error(`HTTP ${response.status}`);
18
+ return response.text();
19
+ }
@@ -0,0 +1,85 @@
1
+ import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import { parse, parseDocument } from "yaml";
4
+ import { z } from "zod";
5
+ import { KINDS } from "./config/schema.js";
6
+ const packSchema = z
7
+ .object({
8
+ name: z.string().regex(/^[a-z0-9-]+$/),
9
+ description: z.string(),
10
+ kinds: z.array(z.enum(KINDS)),
11
+ skill: z.string().optional(),
12
+ docs: z.array(z.url()),
13
+ })
14
+ .strict();
15
+ export function listPacks(zenoRoot) {
16
+ const root = join(zenoRoot, "packs");
17
+ if (!existsSync(root))
18
+ return [];
19
+ return readdirSync(root, { withFileTypes: true })
20
+ .filter((entry) => entry.isDirectory() && existsSync(join(root, entry.name, "pack.yaml")))
21
+ .map((entry) => {
22
+ const pack = packSchema.parse(parse(readFileSync(join(root, entry.name, "pack.yaml"), "utf8")));
23
+ if (pack.name !== entry.name)
24
+ throw new Error(`packs/${entry.name}/pack.yaml is named "${pack.name}"`);
25
+ return pack;
26
+ })
27
+ .sort((a, b) => a.name.localeCompare(b.name));
28
+ }
29
+ export function findPack(zenoRoot, name) {
30
+ const packs = listPacks(zenoRoot);
31
+ const pack = packs.find((candidate) => candidate.name === name);
32
+ if (!pack)
33
+ throw new Error(`no pack "${name}" — available: ${packs.map((p) => p.name).join(", ")}`);
34
+ return pack;
35
+ }
36
+ /** Records a pack in zeno.yaml (product, or estate with --global), keeping the file's comments and other keys. */
37
+ export function addPack(repo, name, options) {
38
+ findPack(options.zenoRoot, name);
39
+ if (options.global && !options.estateRoot)
40
+ throw new Error("--global needs an estate (a folder whose zeno.yaml says estate: true)");
41
+ const base = options.global ? options.estateRoot : repo;
42
+ const file = join(base, ".claude", "zeno.yaml");
43
+ if (!existsSync(file) && !options.global)
44
+ throw new Error(`${repo} has no .claude/zeno.yaml — run \`zeno init\` first`);
45
+ const document = parseDocument(existsSync(file) ? readFileSync(file, "utf8") : "");
46
+ const current = document.get("packs")?.toJSON() ?? [];
47
+ if (current.includes(name))
48
+ return { file, added: false };
49
+ document.set("packs", [...current, name]);
50
+ mkdirSync(dirname(file), { recursive: true });
51
+ writeFileSync(file, document.toString());
52
+ return { file, added: true };
53
+ }
54
+ function docFileName(url) {
55
+ const { hostname, pathname } = new URL(url);
56
+ const stem = pathname.split("/").filter(Boolean).pop()?.replace(/\.[a-z]+$/, "") ?? "index";
57
+ return `${hostname}-${stem}.md`;
58
+ }
59
+ /** Downloads a pack's vendor docs into the cache as Markdown and indexes them in QMD as `pack-<name>`. */
60
+ export async function fetchPackDocs(pack, options) {
61
+ const dir = join(options.cacheDir, "packs", pack.name);
62
+ mkdirSync(dir, { recursive: true });
63
+ for (const url of pack.docs) {
64
+ let text;
65
+ try {
66
+ text = await options.fetchText(url);
67
+ }
68
+ catch (error) {
69
+ throw new Error(`could not download ${url}: ${error.message}`);
70
+ }
71
+ writeFileSync(join(dir, docFileName(url)), text);
72
+ }
73
+ const collection = `pack-${pack.name}`;
74
+ const added = options.run("qmd", ["collection", "add", dir, "--name", collection]);
75
+ if (added.status === 0) {
76
+ options.run("qmd", ["context", "add", `qmd://${collection}`, `${pack.description.split(" — ")[0]} — vendor docs index (${pack.name} pack)`]);
77
+ }
78
+ else if (/already exists/i.test(added.output)) {
79
+ options.run("qmd", ["update"]);
80
+ }
81
+ else {
82
+ throw new Error(`qmd could not index ${dir}: ${added.output.trim()}`);
83
+ }
84
+ return dir;
85
+ }
@@ -0,0 +1,45 @@
1
+ import { existsSync, readdirSync } from "node:fs";
2
+ import { basename, join } from "node:path";
3
+ import { ALL_CHECKS } from "./checks/index.js";
4
+ import { loadConfig } from "./config/load.js";
5
+ import { isDirectory } from "./lib/files.js";
6
+ export function runChecks(repo, options) {
7
+ const report = { repo, name: basename(repo), findings: [], skipped: [], overrides: [] };
8
+ if (!isDirectory(repo)) {
9
+ report.findings.push({ check: "repo", level: "error", message: `no folder at ${repo}` });
10
+ return report;
11
+ }
12
+ let config;
13
+ try {
14
+ config = loadConfig(repo, { estateRoot: options.estateRoot }).config;
15
+ }
16
+ catch (error) {
17
+ report.findings.push({ check: "config", level: "error", message: error.message });
18
+ return report;
19
+ }
20
+ for (const check of options.checks ?? ALL_CHECKS) {
21
+ const override = config.checks[check.id];
22
+ if (override && !override.enabled) {
23
+ report.overrides.push({ check: check.id, reason: override.reason ?? "" });
24
+ continue;
25
+ }
26
+ if (check.module && !config.modules.includes(check.module)) {
27
+ report.skipped.push({ check: check.id, why: `module ${check.module} is off` });
28
+ continue;
29
+ }
30
+ try {
31
+ report.findings.push(...check.run({ repo, name: report.name, config, zenoRoot: options.zenoRoot, today: options.today ?? new Date() }));
32
+ }
33
+ catch (error) {
34
+ report.findings.push({ check: check.id, level: "error", message: `check crashed: ${error.message}` });
35
+ }
36
+ }
37
+ return report;
38
+ }
39
+ /** Product repos in the estate: every direct child folder holding a .context/. */
40
+ export function findProducts(estateRoot) {
41
+ return readdirSync(estateRoot, { withFileTypes: true })
42
+ .filter((entry) => entry.isDirectory() && existsSync(join(estateRoot, entry.name, ".context")))
43
+ .map((entry) => join(estateRoot, entry.name))
44
+ .sort();
45
+ }
@@ -0,0 +1,42 @@
1
+ import { existsSync, readdirSync, readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ const NOT_STANDARDS = new Set(["README.md"]);
4
+ const PRINCIPLE = /^### (C-\d{2}) (.+)$/;
5
+ function markdownIn(dir) {
6
+ return readdirSync(dir).filter((file) => file.endsWith(".md") && !NOT_STANDARDS.has(file));
7
+ }
8
+ /** zeno's default standards, then each configured folder in order; a later file of the same name replaces an earlier one. */
9
+ export function resolveStandards(zenoRoot, folders) {
10
+ const byName = new Map();
11
+ const sources = [{ dir: join(zenoRoot, "standards"), from: "zeno" }, ...folders.map((dir) => ({ dir, from: dir }))];
12
+ for (const { dir, from } of sources) {
13
+ if (!existsSync(dir))
14
+ throw new Error(`standards folder not found: ${dir}`);
15
+ for (const file of markdownIn(dir)) {
16
+ const name = file.replace(/\.md$/, "");
17
+ // Constitutions add up (see constitutionTitles), so each one keeps its own entry.
18
+ const key = name === "constitution" ? `${name}:${from}` : name;
19
+ byName.set(key, { name, path: join(dir, file), from });
20
+ }
21
+ }
22
+ return [...byName.values()].sort((a, b) => a.name.localeCompare(b.name) || Number(a.from !== "zeno") - Number(b.from !== "zeno"));
23
+ }
24
+ /**
25
+ * Principle headings from zeno's constitution and every configured one. A
26
+ * constitution in a folder extends rather than replaces: it adds principles,
27
+ * and restating an ID rewords it.
28
+ */
29
+ export function constitutionTitles(zenoRoot, folders) {
30
+ const titles = new Map();
31
+ for (const dir of [join(zenoRoot, "standards"), ...folders]) {
32
+ const file = join(dir, "constitution.md");
33
+ if (!existsSync(file))
34
+ continue;
35
+ for (const line of readFileSync(file, "utf8").split("\n")) {
36
+ const match = PRINCIPLE.exec(line);
37
+ if (match)
38
+ titles.set(match[1], `${match[1]} ${match[2]}`);
39
+ }
40
+ }
41
+ return [...titles.values()];
42
+ }
@@ -0,0 +1,27 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { loadConfig } from "./config/load.js";
4
+ import { runChecks } from "./run.js";
5
+ export function zenoVersion(zenoRoot) {
6
+ const manifest = join(zenoRoot, ".claude-plugin", "plugin.json");
7
+ if (!existsSync(manifest))
8
+ return "unknown";
9
+ return JSON.parse(readFileSync(manifest, "utf8")).version ?? "unknown";
10
+ }
11
+ export function buildStatus(repo, options) {
12
+ const { config, sources } = loadConfig(repo, { estateRoot: options.estateRoot });
13
+ const report = runChecks(repo, options);
14
+ const errors = report.findings.filter((f) => f.level === "error").length;
15
+ const warnings = report.findings.length - errors;
16
+ const configLine = sources.length === 0
17
+ ? "none — inferred; run `zeno init`"
18
+ : sources.map((source) => `${source.layer} ${source.path}`).join("\n ");
19
+ return [
20
+ `zeno ${zenoVersion(options.zenoRoot)} · ${report.name}`,
21
+ `kind ${config.kind}`,
22
+ `modules ${config.modules.join(", ")}`,
23
+ `config ${configLine}`,
24
+ `checks ${errors} error${errors === 1 ? "" : "s"}, ${warnings} warning${warnings === 1 ? "" : "s"}` +
25
+ (report.overrides.length > 0 ? `, ${report.overrides.length} override(s)` : ""),
26
+ ].join("\n");
27
+ }
@@ -0,0 +1,30 @@
1
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { basename, join } from "node:path";
3
+ import { parseDocument } from "yaml";
4
+ import { loadConfig } from "./config/load.js";
5
+ import { indexKnowledge, refreshArchitectureBase } from "./init.js";
6
+ import { fetchPackDocs, findPack } from "./packs.js";
7
+ import { zenoVersion } from "./status.js";
8
+ /** Refreshes a product's pack docs and search index, and moves its pinned zeno version to the current one. */
9
+ export async function syncRepo(repo, options) {
10
+ const file = join(repo, ".claude", "zeno.yaml");
11
+ if (!existsSync(file))
12
+ throw new Error(`${repo} has no .claude/zeno.yaml — run \`zeno init\` first`);
13
+ const { config } = loadConfig(repo, { estateRoot: options.estateRoot });
14
+ const packs = config.packs;
15
+ for (const name of packs) {
16
+ await fetchPackDocs(findPack(options.zenoRoot, name), options);
17
+ }
18
+ if (config.modules.includes("knowledge"))
19
+ indexKnowledge(repo, basename(repo), options.run);
20
+ options.run("qmd", ["update"]);
21
+ options.run("qmd", ["embed"]);
22
+ const base = refreshArchitectureBase(repo, options.zenoRoot);
23
+ const current = zenoVersion(options.zenoRoot);
24
+ if (config.zeno === current)
25
+ return { packs, base, version: null };
26
+ const document = parseDocument(readFileSync(file, "utf8"));
27
+ document.set("zeno", current);
28
+ writeFileSync(file, document.toString());
29
+ return { packs, base, version: { from: config.zeno, to: current } };
30
+ }
package/cli/dist/ui.js ADDED
@@ -0,0 +1,69 @@
1
+ import { existsSync, mkdirSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
2
+ import { basename, join } from "node:path";
3
+ import { parseDocument } from "yaml";
4
+ const SURFACES = ["mobile", "web"];
5
+ function relink(target, link) {
6
+ rmSync(link, { force: true, recursive: false });
7
+ symlinkSync(target, link);
8
+ }
9
+ // routes.js comes in two styles (`architecture: "…"` and `"architecture": "…"`);
10
+ // a regex reads both without executing the manifest.
11
+ const ARCHITECTURE_KEY = /["']?architecture["']?\s*:\s*["']([^"']+)["']/;
12
+ /** The views a drawn surface offers — flow chart, screenshots, architecture — as relative paths that exist. */
13
+ function surfaceViews(designs, surface) {
14
+ const dir = join(designs, surface);
15
+ if (!existsSync(join(dir, "index.html")))
16
+ return [];
17
+ const views = [["flow chart", "index.html"]];
18
+ if (existsSync(join(dir, "screenshots.html")))
19
+ views.push(["screenshots", "screenshots.html"]);
20
+ const routes = join(dir, "routes.js");
21
+ const architecture = existsSync(routes) ? ARCHITECTURE_KEY.exec(readFileSync(routes, "utf8"))?.[1] : undefined;
22
+ if (architecture && existsSync(join(dir, architecture)))
23
+ views.push(["architecture", architecture]);
24
+ return views.map(([label, path]) => [label, `${surface}/${path}`]);
25
+ }
26
+ /** Lays out the content Quartz renders and the folder the design server serves. */
27
+ export function buildSite(products, options) {
28
+ mkdirSync(options.siteDir, { recursive: true });
29
+ mkdirSync(options.designsDir, { recursive: true });
30
+ const rows = [];
31
+ for (const product of products) {
32
+ const name = basename(product);
33
+ const wiki = join(product, ".context", "wiki");
34
+ const designs = join(product, ".context", "designs");
35
+ const links = [];
36
+ if (existsSync(wiki)) {
37
+ relink(wiki, join(options.siteDir, name));
38
+ links.push(`[[${name}/index|${name}]]`);
39
+ }
40
+ const graph = options.graph?.(product);
41
+ if (graph) {
42
+ mkdirSync(join(options.siteDir, "architecture"), { recursive: true });
43
+ writeFileSync(join(options.siteDir, "architecture", `${name}.md`), `---\ntitle: ${name} architecture\n---\n\nImports between parts of ${name}, from its \`.dependency-cruiser.cjs\`. Broken rules fail \`zeno check\`.\n\n${graph}\n`);
44
+ links.push(`[[architecture/${name}|architecture graph]]`);
45
+ }
46
+ const surfaces = SURFACES.map((surface) => [surface, surfaceViews(designs, surface)]).filter(([, views]) => views.length > 0);
47
+ if (surfaces.length > 0)
48
+ relink(designs, join(options.designsDir, name));
49
+ if (links.length > 0 || surfaces.length > 0)
50
+ rows.push(`- ${links.join(" · ") || name}`);
51
+ for (const [surface, views] of surfaces) {
52
+ const linked = views.map(([label, path]) => `[${label}](${options.designsUrl}/${name}/${path})`);
53
+ rows.push(` - ${surface}: ${linked.join(" · ")}`);
54
+ }
55
+ }
56
+ writeFileSync(join(options.siteDir, "index.md"), `---\ntitle: zeno\n---\n\nEvery product's wiki, searchable and linked. Each surface opens its flow chart, screenshots and architecture overview; products with architecture rules also get an import graph.\n\n${rows.join("\n")}\n\n[[zeno-check|Standards check]]\n`);
57
+ writeFileSync(join(options.siteDir, "zeno-check.md"), `---\ntitle: Standards check\n---\n\nOutput of \`zeno check --all\` when this site was built.\n\n\`\`\`\n${options.report}\n\`\`\`\n`);
58
+ }
59
+ const IGNORED = ["**/CLAUDE.md", "_designs"];
60
+ /** Quartz's default config, adjusted for a private local site: no analytics, no schema files as pages. */
61
+ export function quartzConfig(defaultsYaml) {
62
+ const document = parseDocument(defaultsYaml);
63
+ document.setIn(["configuration", "pageTitle"], "zeno");
64
+ document.setIn(["configuration", "analytics"], null);
65
+ document.setIn(["configuration", "baseUrl"], "localhost");
66
+ const current = document.getIn(["configuration", "ignorePatterns"])?.toJSON() ?? [];
67
+ document.setIn(["configuration", "ignorePatterns"], [...current, ...IGNORED.filter((p) => !current.includes(p))]);
68
+ return document.toString();
69
+ }
package/package.json CHANGED
@@ -1,6 +1,52 @@
1
1
  {
2
2
  "name": "@freetesh/zeno",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.7.0",
4
+ "description": "zeno CLI: set up, check and sync product repos against the shared standards.",
5
+ "license": "MIT",
6
+ "author": "Ritesh Firodiya",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/ritesh-firodiya/zeno-ai.git"
10
+ },
11
+ "type": "module",
12
+ "bin": {
13
+ "zeno": "cli/dist/index.js"
14
+ },
15
+ "files": [
16
+ "cli/dist",
17
+ "standards",
18
+ "skills",
19
+ "packs",
20
+ "templates",
21
+ ".claude-plugin"
22
+ ],
23
+ "engines": {
24
+ "node": ">=22"
25
+ },
26
+ "packageManager": "pnpm@12.3.4",
27
+ "scripts": {
28
+ "zeno": "node cli/src/index.ts",
29
+ "build": "rm -rf cli/dist && tsc -p cli/tsconfig.build.json",
30
+ "test": "vitest run --root cli",
31
+ "coverage": "vitest run --root cli --coverage",
32
+ "typecheck": "tsc -p cli",
33
+ "prepack": "pnpm build",
34
+ "release": "bash scripts/publish.sh"
35
+ },
36
+ "dependencies": {
37
+ "@clack/prompts": "^1.8.1",
38
+ "commander": "^15.0.0",
39
+ "yaml": "^2.9.1",
40
+ "zod": "^4.6.7"
41
+ },
42
+ "devDependencies": {
43
+ "@types/node": "^26.6.5",
44
+ "@vitest/coverage-v8": "^5.0.5",
45
+ "dependency-cruiser": "18.7.0",
46
+ "typescript": "^7.0.2",
47
+ "vitest": "^5.0.5"
48
+ },
49
+ "publishConfig": {
50
+ "access": "public"
51
+ }
52
+ }
@@ -0,0 +1,5 @@
1
+ name: expo
2
+ description: Expo / React Native — versioned SDK docs
3
+ kinds: [expo-app]
4
+ docs:
5
+ - https://docs.expo.dev/llms.txt
@@ -0,0 +1,5 @@
1
+ name: firebase
2
+ description: Firebase — auth, Firestore, rules, functions
3
+ kinds: [expo-app, web]
4
+ docs:
5
+ - https://firebase.google.com/docs/llms.txt
@@ -0,0 +1,5 @@
1
+ name: nextjs
2
+ description: Next.js — app router, server components, routing
3
+ kinds: [web]
4
+ docs:
5
+ - https://nextjs.org/docs/llms.txt
@@ -0,0 +1,5 @@
1
+ name: react
2
+ description: React — components, hooks, rendering
3
+ kinds: [expo-app, web]
4
+ docs:
5
+ - https://react.dev/llms.txt
@@ -0,0 +1,5 @@
1
+ name: revenuecat
2
+ description: RevenueCat — subscriptions, paywalls, entitlements
3
+ kinds: [expo-app, web]
4
+ docs:
5
+ - https://www.revenuecat.com/docs/llms.txt
@@ -0,0 +1,5 @@
1
+ name: uniwind
2
+ description: UniWind — Tailwind classes for React Native
3
+ kinds: [expo-app]
4
+ docs:
5
+ - https://docs.uniwind.dev/llms.txt