@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,14 @@
1
+ {
2
+ "name": "zeno",
3
+ "owner": {
4
+ "name": "Ritesh Firodiya"
5
+ },
6
+ "plugins": [
7
+ {
8
+ "name": "zeno",
9
+ "source": "./",
10
+ "description": "Shared standards, searchable product knowledge and decision checks for every product repo."
11
+ }
12
+ ],
13
+ "description": "zeno: standards, searchable product knowledge and a decision gate for Claude Code."
14
+ }
@@ -0,0 +1,10 @@
1
+ {
2
+ "$schema": "https://anthropic.com/claude-code/plugin.schema.json",
3
+ "name": "zeno",
4
+ "version": "0.7.0",
5
+ "description": "Shared standards, searchable product knowledge and decision checks for every product repo.",
6
+ "author": {
7
+ "name": "Ritesh Firodiya",
8
+ "email": "40817224+ritesh-firodiya@users.noreply.github.com"
9
+ }
10
+ }
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Ritesh Firodiya
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 CHANGED
@@ -1,3 +1,176 @@
1
- # Temporary Holding Version
1
+ # zeno
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Standards, searchable product knowledge and a decision gate for teams that
4
+ build with Claude Code. zeno keeps several products consistent as they evolve,
5
+ keeps what you decided findable, and makes Claude weigh a request against your
6
+ rules and history before it acts on it.
7
+
8
+ It has two halves that read the same config:
9
+
10
+ - **a Claude Code plugin** — skills, a decision gate, a session hook and a
11
+ search server, so every session in every repo starts with the same rules;
12
+ - **the `zeno` CLI** — set up, check, sync and browse your repos.
13
+
14
+ ## What it does
15
+
16
+ - **Weigh before acting.** For a non-trivial request Claude checks the
17
+ constitution, your wiki and decision records, existing features and tasks,
18
+ cost vs value, and the write-less ladder, then answers **DO IT**, **DO IT
19
+ DIFFERENTLY** or **PUSH BACK** with citations. Push back stops work until you
20
+ give a reason; overrides are logged and feed `zeno learn`.
21
+ - **Knowledge that is found, not remembered.** Every repo's `.context/wiki` and
22
+ feature board is indexed with [QMD](https://github.com/tobi/qmd) (local hybrid
23
+ search). Claude is told to search before answering.
24
+ - **Standards with one home.** zeno ships defaults (constitution, wiki schema,
25
+ `.context` layout, agent contract). A team adds or replaces them from its own
26
+ folder; a repo can override again. `zeno standards` shows what applies.
27
+ - **Hard rules are checks.** `zeno check` validates layout, wiki frontmatter,
28
+ stale pages, prices with no canonical home, copied standards, and runs every
29
+ checker your config declares.
30
+ - **Each repo's architecture is enforced.** With the `architecture` module, a
31
+ repo keeps its own import rules in `.dependency-cruiser.cjs` files: one at the
32
+ root, or one per workspace under `apps/` and `packages/` (each runs from its
33
+ folder, since apps map `@/` to their own `src`). `zeno init` writes starters and
34
+ `.dependency-cruiser.base.cjs`, the shared base they extend; that base is zeno's,
35
+ and `zeno sync` keeps it current. Add `dependency-cruiser` as a devDependency.
36
+ `zeno check` fails on a broken boundary or a cycle, the gate weighs requests
37
+ against the rules, and `zeno ui` draws each repo's import graph.
38
+ - **Products evolve together.** Packs index vendor docs (`llms.txt`) for a stack;
39
+ `zeno sync` refreshes them and moves each repo to the current zeno version.
40
+
41
+ ## Install
42
+
43
+ ```bash
44
+ # plugin (from GitHub)
45
+ claude plugin marketplace add ritesh-firodiya/zeno-ai
46
+ claude plugin install zeno@zeno
47
+
48
+ # CLI (Node 22+)
49
+ npm install -g @freetesh/zeno
50
+
51
+ # search
52
+ npm install -g @tobilu/qmd --allow-scripts=node-llama-cpp,tree-sitter-go,tree-sitter-python,tree-sitter-rust,tree-sitter-typescript,tree-sitter-javascript
53
+
54
+ # optional: write-less discipline for the efficiency step of the gate
55
+ claude plugin marketplace add DietrichGebert/ponytail && claude plugin install ponytail@ponytail
56
+ ```
57
+
58
+ ## Quickstart (5 minutes)
59
+
60
+ In any git repo, after the install above:
61
+
62
+ ```bash
63
+ zeno doctor # node, git, qmd — fix anything marked ✗ first
64
+ zeno init # pick kind, modules and doc packs; writes .claude/zeno.yaml and .context/
65
+ zeno check # should print "<repo> ok"
66
+ zeno sync # fetches the packs you picked and indexes the wiki for search
67
+ ```
68
+
69
+ Then start Claude Code in the repo. The session hook loads the constitution,
70
+ and a non-trivial request is weighed before Claude acts on it — try
71
+ `/zeno:weigh add a dark mode toggle`. Write decisions to `.context/wiki/` as
72
+ you make them (`/zeno:decision`) and ask about them later with `/zeno:ask`.
73
+
74
+ Want import rules too? `zeno init --force --modules docs,knowledge,architecture`
75
+ (keep the modules you already had), `npm i -D dependency-cruiser`, then edit the
76
+ starter `.dependency-cruiser.cjs` to your layers. `evals/fixtures/streaks` is a
77
+ small product with a wiki and decision records, if you want to see the
78
+ `.context/` layout before touching your own repo.
79
+
80
+ For local development of zeno itself, link the repo as a skills-dir plugin so
81
+ edits apply on the next session: `ln -s "$PWD" ~/.claude/skills/zeno`.
82
+ Run the CLI from source (Node 23.6+ runs the TypeScript directly):
83
+ `pnpm install && ln -s "$PWD/cli/src/index.ts" ~/.local/bin/zeno`.
84
+ `pnpm test`, `pnpm typecheck` and `pnpm build` run from the repo root.
85
+
86
+ ## CLI
87
+
88
+ ```bash
89
+ zeno init # write .claude/zeno.yaml and the missing .context files (asks kind, modules, packs)
90
+ zeno init -y --kind web --modules docs,code --packs nextjs # same, no questions
91
+ zeno add # list packs; `zeno add expo` adds one and indexes its docs (-g: whole estate)
92
+ zeno check # this repo against the standards (exit 1 on any error)
93
+ zeno check --all -q # every repo in the estate, only the ones with findings
94
+ zeno standards # which standards apply here, and where each comes from
95
+ zeno sync # refresh pack docs + search index, move the pinned zeno version, then check
96
+ zeno status # version, kind, modules, config layers, check summary
97
+ zeno doctor # node, git, qmd — plus any tools your config lists
98
+ zeno learn # proposals from overridden verdicts; --approve/--reject <id>
99
+ zeno weigh "<ask>" # the decision gate from the terminal (runs Claude, weighs only)
100
+ zeno ui # every wiki (search, backlinks, graph) on :4610, design galleries on :4611
101
+ ```
102
+
103
+ ## Config
104
+
105
+ `.claude/zeno.yaml`, read in layers — **estate → repo → local**
106
+ (`zeno.local.yaml`, not committed). The estate is the nearest folder above a
107
+ repo whose `zeno.yaml` says `estate: true`, or `ZENO_ESTATE`.
108
+
109
+ ```yaml
110
+ # <estate>/.claude/zeno.yaml — shared by every repo below it
111
+ estate: true
112
+ standards: [.zeno/standards] # team rules; same name replaces a zeno default
113
+ commands: # your own checkers, run by `zeno check`
114
+ - id: designs
115
+ run: ./scripts/check-designs.sh
116
+ if: .context/designs # only where this path exists
117
+ doctor:
118
+ tools:
119
+ - { tool: pnpm, why: product scripts, hint: npm i -g pnpm }
120
+ ```
121
+
122
+ ```yaml
123
+ # <repo>/.claude/zeno.yaml — written by `zeno init`
124
+ zeno: 0.6.0
125
+ kind: expo-app # expo-app | web | content
126
+ modules: [docs, knowledge, metrics, code, listing] # all optional; design is added when .context/designs exists
127
+ packs: [expo]
128
+ checks:
129
+ stale-copies: { enabled: false, reason: "Cursor is still used here" }
130
+ weigh:
131
+ weights: { C-05: 2 }
132
+ ```
133
+
134
+ Scalars: the most specific layer wins. `packs` and `standards` add up across
135
+ layers; `commands` and `doctor.tools` merge by id. A check turned off must
136
+ carry a `reason:`, and `zeno check` lists every override.
137
+
138
+ ## Standards
139
+
140
+ | zeno default | What it holds |
141
+ |---|---|
142
+ | `constitution.md` | Principles C-01–C-13 a request is weighed against. A team adds C-20+ in its own `constitution.md`. |
143
+ | `wiki-schema.md` | Wiki page types, frontmatter, required sections, lint rules |
144
+ | `context-layout.md` | The `.context/` ten-folder layout, `features/` and `tasks.md` |
145
+ | `agent-contract.md` | What every agent does before, during and after work |
146
+
147
+ ## Packs
148
+
149
+ `packs/<name>/pack.yaml` names the vendor docs to index. Shipped: expo,
150
+ uniwind, react, nextjs, firebase, revenuecat. A pack is three lines of YAML; add
151
+ your own.
152
+
153
+ ## Evals
154
+
155
+ `evals/` runs five requests against a fixture product (`evals/fixtures/streaks`)
156
+ with and without the plugin, each in a sandbox seeded with the fixture and a
157
+ local QMD index:
158
+
159
+ ```bash
160
+ claude plugin eval . --scaffold --mocks off --allow-tools 'mcp__plugin_zeno_qmd__*' --runs 2
161
+ ```
162
+
163
+ ## Layout
164
+
165
+ ```
166
+ .claude-plugin/ plugin manifest and marketplace
167
+ skills/ weigh, standards, ask, ingest, decision, wiki-lint
168
+ agents/ challenger — independent second opinion for large requests
169
+ hooks/ session start: constitution titles, qmd refresh
170
+ standards/ default standards
171
+ packs/ vendor doc packs
172
+ templates/ files init writes: dependency-cruiser base (synced) and starters
173
+ scripts/ log-verdict.mjs — append-only override log
174
+ cli/ the zeno CLI (TypeScript, vitest; built to cli/dist for npm)
175
+ evals/ plugin evals and the fixture product
176
+ ```
@@ -0,0 +1,126 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { existsSync, readdirSync } from "node:fs";
3
+ import { createRequire } from "node:module";
4
+ import { join, posix } from "node:path";
5
+ // The names dependency-cruiser itself looks for; `zeno init` writes the first.
6
+ export const RULES_FILES = [".dependency-cruiser.cjs", ".dependency-cruiser.js", ".dependency-cruiser.mjs", ".dependency-cruiser.json"];
7
+ const DEFAULT_COLLAPSE = "^(apps|packages)/[^/]+|^[^/]+/[^/]+";
8
+ const SHOWN_PER_RULE = 3;
9
+ // Mermaid refuses diagrams over 50k characters.
10
+ const MERMAID_MAX_CHARS = 50_000;
11
+ const MAX_BUFFER = 64 * 1024 * 1024;
12
+ // Monorepo workspaces whose apps each map `@/` to their own src need one rules file each,
13
+ // because dependency-cruiser resolves aliases from a single tsconfig per run.
14
+ const WORKSPACE_PARENTS = ["apps", "packages"];
15
+ function rulesFile(dir) {
16
+ return RULES_FILES.find((file) => existsSync(join(dir, file)));
17
+ }
18
+ /** Every rules file in the repo: the root's, then one per workspace under apps/ and packages/. */
19
+ export function findRules(repo) {
20
+ const workspaces = WORKSPACE_PARENTS.flatMap((parent) => existsSync(join(repo, parent))
21
+ ? readdirSync(join(repo, parent), { withFileTypes: true })
22
+ .filter((entry) => entry.isDirectory())
23
+ .map((entry) => `${parent}/${entry.name}`)
24
+ .sort()
25
+ : []);
26
+ return [".", ...workspaces].flatMap((dir) => {
27
+ const file = rulesFile(join(repo, dir));
28
+ return file ? [{ dir, file }] : [];
29
+ });
30
+ }
31
+ const fromRoot = (dir, path) => posix.normalize(posix.join(dir, path));
32
+ // The repo's own pinned copy, never a global one, so every machine runs the same version.
33
+ function depcruiseBin(repo) {
34
+ const bin = join(repo, "node_modules", ".bin", "depcruise");
35
+ return existsSync(bin) ? bin : undefined;
36
+ }
37
+ function depcruise(repo, bin, rules, args) {
38
+ return spawnSync(bin, [".", "--config", rules.file, ...args], { cwd: join(repo, rules.dir), encoding: "utf8", maxBuffer: MAX_BUFFER });
39
+ }
40
+ // dependency-cruiser needs a TypeScript it supports (below 7) to read .ts files; without one it
41
+ // skips them and still reports success, so a TypeScript workspace with none read is not a pass.
42
+ function unreadTypeScript(repo, rules, modules) {
43
+ return existsSync(join(repo, rules.dir, "tsconfig.json")) && !modules.some((m) => /\.tsx?$/.test(m.source));
44
+ }
45
+ function typeScriptWarning(rules) {
46
+ const file = fromRoot(rules.dir, "tsconfig.json");
47
+ const message = "no TypeScript files were read, so their imports went unchecked — dependency-cruiser needs `typescript` below 7 installed here";
48
+ return [{ check: "architecture", level: "warn", file, message }];
49
+ }
50
+ /** One finding per broken rule: how often, and the first few places. */
51
+ export function summarize(violations) {
52
+ const byRule = new Map();
53
+ for (const violation of violations.filter((v) => v.rule.severity === "error" || v.rule.severity === "warn")) {
54
+ byRule.set(violation.rule.name, [...(byRule.get(violation.rule.name) ?? []), violation]);
55
+ }
56
+ return [...byRule].map(([name, list]) => {
57
+ const shown = list.slice(0, SHOWN_PER_RULE).map((v) => (v.from === v.to ? v.from : `${v.from} → ${v.to}`));
58
+ const more = list.length > SHOWN_PER_RULE ? ` and ${list.length - SHOWN_PER_RULE} more` : "";
59
+ return {
60
+ check: "architecture",
61
+ level: list[0].rule.severity === "error" ? "error" : "warn",
62
+ file: list[0].from,
63
+ message: `${name} (${list.length}): ${shown.join(", ")}${more}`,
64
+ };
65
+ });
66
+ }
67
+ export const architecture = {
68
+ id: "architecture",
69
+ module: "architecture",
70
+ description: "the repo keeps the import boundaries in its own .dependency-cruiser.cjs",
71
+ run({ repo }) {
72
+ const finding = (message, file) => [{ check: "architecture", level: "error", file, message }];
73
+ const all = findRules(repo);
74
+ if (all.length === 0) {
75
+ return finding("no architecture rules — run `zeno init --force --modules ...,architecture` for a starter, or write one", RULES_FILES[0]);
76
+ }
77
+ const bin = depcruiseBin(repo);
78
+ if (!bin)
79
+ return finding("dependency-cruiser is not installed here — add it as a devDependency (e.g. `pnpm add -D dependency-cruiser`)", "package.json");
80
+ return all.flatMap((rules) => {
81
+ const result = depcruise(repo, bin, rules, ["--output-type", "json"]);
82
+ try {
83
+ const cruise = JSON.parse(result.stdout);
84
+ const findings = summarize(cruise.summary.violations.map((v) => ({ ...v, from: fromRoot(rules.dir, v.from), to: fromRoot(rules.dir, v.to) })));
85
+ return unreadTypeScript(repo, rules, cruise.modules) ? [...findings, ...typeScriptWarning(rules)] : findings;
86
+ }
87
+ catch {
88
+ const why = (result.error?.message ?? result.stderr).trim().split("\n").slice(-5).join(" ");
89
+ return finding(`dependency-cruiser failed: ${why}`, fromRoot(rules.dir, rules.file));
90
+ }
91
+ });
92
+ },
93
+ };
94
+ // The graph level is the repo's choice, kept where dependency-cruiser keeps it for its own high-level view.
95
+ function collapsePattern(dir, file) {
96
+ try {
97
+ const config = createRequire(join(dir, "noop.js"))(join(dir, file));
98
+ return config.options?.reporterOptions?.archi?.collapsePattern ?? DEFAULT_COLLAPSE;
99
+ }
100
+ catch {
101
+ return DEFAULT_COLLAPSE;
102
+ }
103
+ }
104
+ function graphOf(repo, bin, rules) {
105
+ const collapse = collapsePattern(join(repo, rules.dir), rules.file);
106
+ // The graph shows the repo's own parts; npm packages would bury them (the rules still check npm imports).
107
+ const ownCode = "^(?!(\\.\\./)*node_modules/)";
108
+ const result = depcruise(repo, bin, rules, ["--output-type", "mermaid", "--collapse", collapse, "--include-only", ownCode]);
109
+ if (result.status !== 0 || !result.stdout.trim())
110
+ return undefined;
111
+ if (result.stdout.length > MERMAID_MAX_CHARS) {
112
+ return "flowchart LR\n too_big[\"Graph too large to draw — set a coarser reporterOptions.archi.collapsePattern\"]";
113
+ }
114
+ return result.stdout.trim();
115
+ }
116
+ /** The repo's architecture as markdown — one mermaid graph per rules file — or undefined when there is nothing to draw. */
117
+ export function architectureGraph(repo) {
118
+ const bin = depcruiseBin(repo);
119
+ if (!bin)
120
+ return undefined;
121
+ const sections = findRules(repo).flatMap((rules) => {
122
+ const graph = graphOf(repo, bin, rules);
123
+ return graph ? [`## ${rules.dir === "." ? "Whole repo" : rules.dir}\n\n\`\`\`mermaid\n${graph}\n\`\`\``] : [];
124
+ });
125
+ return sections.length > 0 ? sections.join("\n\n") : undefined;
126
+ }
@@ -0,0 +1,46 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { parseFrontmatter } from "../lib/frontmatter.js";
4
+ import { wikiPages } from "./wiki-frontmatter.js";
5
+ const PRICE = /(?:[$₹€£]\s?\d[\d,]*(?:\.\d+)?)/g;
6
+ const CODE_BLOCK = /```[\s\S]*?```/g;
7
+ // An amount counts as a price only in a paragraph that talks about selling something;
8
+ // "Ravi paid ₹6,200" in a bill-splitting app is an example, not a price.
9
+ const PRICE_WORDS = /pric|cost|paywall|plan|tier|subscri|bundle|pack|purchase|buy|unlock|\/mo|per month|per year|annual|yearly|monthly/i;
10
+ function amounts(text) {
11
+ return (text.match(PRICE) ?? []).map((price) => price.replace(/\s/g, ""));
12
+ }
13
+ function pricesIn(text) {
14
+ const paragraphs = text.replace(CODE_BLOCK, "").split(/\n\s*\n/);
15
+ return [...new Set(paragraphs.filter((paragraph) => PRICE_WORDS.test(paragraph)).flatMap(amounts))];
16
+ }
17
+ function heldAmounts(text) {
18
+ return amounts(text.replace(CODE_BLOCK, ""));
19
+ }
20
+ export const canonicalValues = {
21
+ id: "canonical-values",
22
+ module: "docs",
23
+ description: "a price shown on a surface page is held by an entity page — one home per fact (C-04)",
24
+ run({ repo }) {
25
+ const pages = wikiPages(repo).map((file) => ({ file, ...parseFrontmatter(readFileSync(join(repo, file), "utf8")) }));
26
+ const live = pages.filter((page) => page.data?.status !== "superseded");
27
+ const held = new Set(live.filter((page) => page.data?.type === "entity").flatMap((page) => heldAmounts(page.body)));
28
+ // Surfaces are where store prices get restated. Concepts legitimately carry
29
+ // example amounts (a tax product explains slabs with them).
30
+ return live
31
+ .filter((page) => page.data?.type === "surface")
32
+ .flatMap((page) => {
33
+ const orphans = pricesIn(page.body).filter((price) => !held.has(price));
34
+ if (orphans.length === 0)
35
+ return [];
36
+ return [
37
+ {
38
+ check: "canonical-values",
39
+ level: "warn",
40
+ file: page.file,
41
+ message: `${orphans.join(", ")} stated here but held by no entity page`,
42
+ },
43
+ ];
44
+ });
45
+ },
46
+ };
@@ -0,0 +1,41 @@
1
+ import { existsSync, readdirSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ const ALLOWED_FOLDERS = ["raw", "wiki", "features", "designs", "listing", "marketing", "metrics", "support", "legal", "ops"];
4
+ const KNOWLEDGE_CONTEXT = ["raw", "wiki", "features"];
5
+ /** What .context must hold: README.md and tasks.md always, the knowledge folders only when docs or knowledge is on. */
6
+ export function requiredContext(modules) {
7
+ const knowledge = modules.includes("docs") || modules.includes("knowledge");
8
+ return ["README.md", "tasks.md", ...(knowledge ? KNOWLEDGE_CONTEXT : [])];
9
+ }
10
+ const ROOT_FILES = ["README.md", "tasks.md"];
11
+ export const contextLayout = {
12
+ id: "context-layout",
13
+ module: null,
14
+ description: ".context/ has the required folders, only the ten allowed ones, and only README.md + tasks.md at its root",
15
+ run({ repo, config }) {
16
+ const context = join(repo, ".context");
17
+ const finding = (level, file, message) => ({
18
+ check: "context-layout",
19
+ level,
20
+ file,
21
+ message,
22
+ });
23
+ if (!existsSync(context))
24
+ return [finding("error", ".context", "no .context/ folder — run `zeno init`")];
25
+ const findings = requiredContext(config.modules).filter((name) => !existsSync(join(context, name))).map((name) => finding("error", `.context/${name}`, `required ${name} is missing`));
26
+ for (const entry of readdirSync(context, { withFileTypes: true })) {
27
+ if (entry.name === ".DS_Store")
28
+ continue;
29
+ if (entry.isDirectory() && !ALLOWED_FOLDERS.includes(entry.name)) {
30
+ findings.push(finding("error", `.context/${entry.name}`, `${entry.name}/ is not one of the ten folders`));
31
+ }
32
+ if (entry.isFile() && !ROOT_FILES.includes(entry.name)) {
33
+ findings.push(finding("error", `.context/${entry.name}`, "only README.md and tasks.md may sit at .context/ root"));
34
+ }
35
+ }
36
+ if (existsSync(join(context, "raw")) && !existsSync(join(context, "raw", ".gitkeep"))) {
37
+ findings.push(finding("warn", ".context/raw/.gitkeep", "raw/ needs its .gitkeep so it survives a clean clone"));
38
+ }
39
+ return findings;
40
+ },
41
+ };
@@ -0,0 +1,73 @@
1
+ import { existsSync, readdirSync, readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ // Instruction files for agents other than Claude Code. They are copies of
4
+ // AGENTS.md that nobody reads, so they only drift.
5
+ const OTHER_AGENT_FILES = [".github/copilot-instructions.md", ".cursorrules", ".windsurfrules"];
6
+ // Fraction of a shared standard's lines that must appear in a product file
7
+ // before it counts as a copy rather than a reference.
8
+ const COPY_THRESHOLD = 0.6;
9
+ const STANDARD_COPIES = [{ file: ".context/wiki/CLAUDE.md", standard: "standards/wiki-schema.md" }];
10
+ export const staleCopies = {
11
+ id: "stale-copies",
12
+ module: null,
13
+ description: "no instruction files for agents that are not used (Copilot, Cursor, Windsurf)",
14
+ run({ repo }) {
15
+ return OTHER_AGENT_FILES.filter((file) => existsSync(join(repo, file))).map((file) => ({
16
+ check: "stale-copies",
17
+ level: "warn",
18
+ file,
19
+ message: "instructions for an agent not in use — delete, Claude reads AGENTS.md",
20
+ }));
21
+ },
22
+ };
23
+ function meaningfulLines(text) {
24
+ return new Set(text
25
+ .split("\n")
26
+ .map((line) => line.trim())
27
+ .filter((line) => line.length > 0 && !line.startsWith("<!--")));
28
+ }
29
+ function sharedFraction(standard, candidate) {
30
+ const wanted = meaningfulLines(standard);
31
+ const present = meaningfulLines(candidate);
32
+ const shared = [...wanted].filter((line) => present.has(line)).length;
33
+ return wanted.size === 0 ? 0 : shared / wanted.size;
34
+ }
35
+ export const standardsCopies = {
36
+ id: "standards-copies",
37
+ module: null,
38
+ description: "shared rules and skills come from zeno, not from a copy in the product",
39
+ run({ repo, zenoRoot }) {
40
+ const findings = [];
41
+ for (const { file, standard } of STANDARD_COPIES) {
42
+ const productFile = join(repo, file);
43
+ const standardFile = join(zenoRoot, standard);
44
+ if (!existsSync(productFile) || !existsSync(standardFile))
45
+ continue;
46
+ const fraction = sharedFraction(readFileSync(standardFile, "utf8"), readFileSync(productFile, "utf8"));
47
+ if (fraction >= COPY_THRESHOLD) {
48
+ findings.push({
49
+ check: "standards-copies",
50
+ level: "warn",
51
+ file,
52
+ message: `${Math.round(fraction * 100)}% copy of zeno ${standard} — replace with a pointer`,
53
+ });
54
+ }
55
+ }
56
+ const zenoSkills = join(zenoRoot, "skills");
57
+ const productSkills = join(repo, ".claude", "skills");
58
+ if (existsSync(zenoSkills) && existsSync(productSkills)) {
59
+ const provided = new Set(readdirSync(zenoSkills));
60
+ for (const skill of readdirSync(productSkills)) {
61
+ if (!provided.has(skill))
62
+ continue;
63
+ findings.push({
64
+ check: "standards-copies",
65
+ level: "warn",
66
+ file: `.claude/skills/${skill}`,
67
+ message: `zeno provides this as zeno:${skill} — delete the product copy`,
68
+ });
69
+ }
70
+ }
71
+ return findings;
72
+ },
73
+ };
@@ -0,0 +1,30 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { existsSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ const MAX_OUTPUT_LINES = 15;
5
+ function tail(text) {
6
+ return text.trim().split("\n").slice(-MAX_OUTPUT_LINES).join("\n");
7
+ }
8
+ /**
9
+ * Runs each checker a repo or its estate declares under `commands:` in
10
+ * zeno.yaml. zeno does not know what they check; a non-zero exit is an error
11
+ * reported under the command's own id.
12
+ */
13
+ export const commandChecks = {
14
+ id: "commands",
15
+ module: null,
16
+ description: "every checker declared under `commands:` in zeno.yaml passes",
17
+ run({ repo, config }) {
18
+ return config.commands
19
+ .filter((command) => !command.if || existsSync(join(repo, command.if)))
20
+ .flatMap((command) => {
21
+ const result = spawnSync("sh", ["-c", command.run], { cwd: repo, encoding: "utf8" });
22
+ if (result.error) {
23
+ return [{ check: command.id, level: "error", file: command.if, message: `could not run: ${result.error.message}` }];
24
+ }
25
+ if (result.status === 0)
26
+ return [];
27
+ return [{ check: command.id, level: "error", file: command.if, message: tail(`${result.stdout}\n${result.stderr}`) }];
28
+ });
29
+ },
30
+ };
@@ -0,0 +1,15 @@
1
+ import { architecture } from "./architecture.js";
2
+ import { canonicalValues } from "./canonical-values.js";
3
+ import { contextLayout } from "./context-layout.js";
4
+ import { staleCopies, standardsCopies } from "./copies.js";
5
+ import { commandChecks } from "./external.js";
6
+ import { wikiFrontmatter } from "./wiki-frontmatter.js";
7
+ export const ALL_CHECKS = [
8
+ contextLayout,
9
+ wikiFrontmatter,
10
+ canonicalValues,
11
+ standardsCopies,
12
+ staleCopies,
13
+ architecture,
14
+ commandChecks,
15
+ ];
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,71 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { listFiles } from "../lib/files.js";
4
+ import { parseFrontmatter } from "../lib/frontmatter.js";
5
+ const REQUIRED_FIELDS = ["id", "type", "status", "last-verified"];
6
+ const FOLDER_BY_TYPE = {
7
+ concept: "concepts",
8
+ entity: "entities",
9
+ surface: "surfaces",
10
+ flow: "flows",
11
+ decision: "decisions",
12
+ synthesis: "synthesis",
13
+ };
14
+ const STALE_AFTER_DAYS = 90;
15
+ const DAY_MS = 86_400_000;
16
+ const NOT_PAGES = ["index.md", "CLAUDE.md"];
17
+ const OLDEST_SHOWN = 3;
18
+ export function wikiPages(repo) {
19
+ return listFiles(repo, ".context/wiki", (name) => name.endsWith(".md") && !NOT_PAGES.includes(name));
20
+ }
21
+ function asDate(value) {
22
+ const date = value instanceof Date ? value : new Date(String(value));
23
+ return Number.isNaN(date.getTime()) ? null : date;
24
+ }
25
+ export const wikiFrontmatter = {
26
+ id: "wiki-frontmatter",
27
+ module: "docs",
28
+ description: "every wiki page has id, type, status and last-verified; type matches folder; ids are unique; nothing stale",
29
+ run({ repo, today }) {
30
+ const findings = [];
31
+ const add = (level, file, message) => findings.push({ check: "wiki-frontmatter", level, file, message });
32
+ const filesById = new Map();
33
+ const stale = [];
34
+ for (const file of wikiPages(repo)) {
35
+ const { data } = parseFrontmatter(readFileSync(join(repo, file), "utf8"));
36
+ if (!data) {
37
+ add("error", file, "no frontmatter");
38
+ continue;
39
+ }
40
+ const missing = REQUIRED_FIELDS.filter((field) => data[field] === undefined || data[field] === null);
41
+ if (missing.length > 0)
42
+ add("error", file, `missing ${missing.join(", ")}`);
43
+ const folder = file.split("/")[2];
44
+ const expected = FOLDER_BY_TYPE[String(data.type)];
45
+ if (data.type && expected !== folder)
46
+ add("error", file, `type ${data.type} does not belong in ${folder}/`);
47
+ if (data.status === "superseded" && !data["superseded-by"])
48
+ add("error", file, "superseded without superseded-by");
49
+ const verified = asDate(data["last-verified"]);
50
+ const age = verified ? Math.floor((today.getTime() - verified.getTime()) / DAY_MS) : 0;
51
+ if (verified && age > STALE_AFTER_DAYS)
52
+ stale.push({ file, age });
53
+ if (data.id)
54
+ filesById.set(String(data.id), [...(filesById.get(String(data.id)) ?? []), file]);
55
+ }
56
+ // One line per product: hundreds of "page X is stale" lines bury every other finding.
57
+ if (stale.length > 0) {
58
+ const oldest = stale
59
+ .sort((a, b) => b.age - a.age)
60
+ .slice(0, OLDEST_SHOWN)
61
+ .map(({ file, age }) => `${file.replace(".context/wiki/", "")} (${age}d)`);
62
+ const pages = stale.length === 1 ? "1 page" : `${stale.length} pages`;
63
+ add("warn", ".context/wiki", `${pages} last verified over ${STALE_AFTER_DAYS} days ago — oldest: ${oldest.join(", ")}`);
64
+ }
65
+ for (const [id, files] of filesById) {
66
+ if (files.length > 1)
67
+ add("error", files[1], `duplicate id ${id} (also ${files[0]})`);
68
+ }
69
+ return findings;
70
+ },
71
+ };
@@ -0,0 +1,24 @@
1
+ import { resolve } from "node:path";
2
+ import { CACHE_DIR, fetchText, runCommand } from "../lib/system.js";
3
+ import { addPack, fetchPackDocs, findPack, listPacks } from "../packs.js";
4
+ import { requireEstate } from "./env.js";
5
+ export function registerAdd(program, env) {
6
+ program
7
+ .command("add")
8
+ .description("add a knowledge pack (rules + vendor docs in search), e.g. zeno add expo")
9
+ .argument("[pack]", "pack name; omit to list packs")
10
+ .option("-g, --global", "add for every product (estate zeno.yaml)")
11
+ .option("--repo <repo>", "product repo", ".")
12
+ .action(async (name, options) => {
13
+ if (!name) {
14
+ for (const pack of listPacks(env.zenoRoot))
15
+ console.log(`${pack.name.padEnd(11)} ${pack.description}`);
16
+ return;
17
+ }
18
+ const estateRoot = options.global ? requireEstate(env) : undefined;
19
+ const { file, added } = addPack(resolve(options.repo), name, { zenoRoot: env.zenoRoot, global: options.global, estateRoot });
20
+ console.log(added ? `added ${name} to ${file}` : `${name} already in ${file}`);
21
+ const dir = await fetchPackDocs(findPack(env.zenoRoot, name), { cacheDir: CACHE_DIR, fetchText, run: runCommand });
22
+ console.log(`indexed ${dir} as qmd://pack-${name}`);
23
+ });
24
+ }