@freetesh/zeno 0.0.0-stage → 0.8.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 (63) 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 +191 -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 +42 -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 +17 -0
  11. package/cli/dist/checks/metrics.js +14 -0
  12. package/cli/dist/checks/types.js +1 -0
  13. package/cli/dist/checks/wiki-frontmatter.js +74 -0
  14. package/cli/dist/commands/add.js +24 -0
  15. package/cli/dist/commands/check.js +19 -0
  16. package/cli/dist/commands/doctor.js +16 -0
  17. package/cli/dist/commands/env.js +6 -0
  18. package/cli/dist/commands/init.js +78 -0
  19. package/cli/dist/commands/learn.js +35 -0
  20. package/cli/dist/commands/standards.js +26 -0
  21. package/cli/dist/commands/status.js +11 -0
  22. package/cli/dist/commands/sync.js +23 -0
  23. package/cli/dist/commands/ui.js +64 -0
  24. package/cli/dist/commands/weigh.js +16 -0
  25. package/cli/dist/config/load.js +96 -0
  26. package/cli/dist/config/schema.js +72 -0
  27. package/cli/dist/doctor.js +31 -0
  28. package/cli/dist/format.js +37 -0
  29. package/cli/dist/index.js +36 -0
  30. package/cli/dist/init.js +120 -0
  31. package/cli/dist/learn.js +69 -0
  32. package/cli/dist/lib/files.js +23 -0
  33. package/cli/dist/lib/frontmatter.js +16 -0
  34. package/cli/dist/lib/system.js +19 -0
  35. package/cli/dist/packs.js +85 -0
  36. package/cli/dist/run.js +45 -0
  37. package/cli/dist/standards.js +42 -0
  38. package/cli/dist/status.js +27 -0
  39. package/cli/dist/sync.js +30 -0
  40. package/cli/dist/ui.js +69 -0
  41. package/package.json +50 -4
  42. package/packs/expo/pack.yaml +5 -0
  43. package/packs/firebase/pack.yaml +5 -0
  44. package/packs/nextjs/pack.yaml +5 -0
  45. package/packs/react/pack.yaml +5 -0
  46. package/packs/revenuecat/pack.yaml +5 -0
  47. package/packs/uniwind/pack.yaml +5 -0
  48. package/skills/ask/SKILL.md +42 -0
  49. package/skills/decision/SKILL.md +33 -0
  50. package/skills/ingest/SKILL.md +45 -0
  51. package/skills/standards/SKILL.md +16 -0
  52. package/skills/weigh/SKILL.md +111 -0
  53. package/skills/wiki-lint/SKILL.md +69 -0
  54. package/standards/README.md +14 -0
  55. package/standards/agent-contract.md +40 -0
  56. package/standards/constitution.md +89 -0
  57. package/standards/context-layout.md +138 -0
  58. package/standards/dev-methods.md +29 -0
  59. package/standards/wiki-schema.md +287 -0
  60. package/templates/dependency-cruiser/app.cjs +23 -0
  61. package/templates/dependency-cruiser/base.cjs +52 -0
  62. package/templates/dependency-cruiser/package.cjs +31 -0
  63. 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": "Keeps your domain knowledge and AI rules local and enforces them, so Claude hallucinates less."
11
+ }
12
+ ],
13
+ "description": "zeno: Keep your domain knowledge and AI rules in your repo, and enforce them. Less AI guessing, faster delivery."
14
+ }
@@ -0,0 +1,10 @@
1
+ {
2
+ "$schema": "https://anthropic.com/claude-code/plugin.schema.json",
3
+ "name": "zeno",
4
+ "version": "0.8.0",
5
+ "description": "Keeps your domain knowledge and AI rules local and enforces them, so Claude hallucinates less.",
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,192 @@
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
+ A productivity tool for the whole product development lifecycle (PDLC), from
4
+ idea to release. zeno keeps your domain knowledge, decisions and AI rules local,
5
+ in your own repos, and enforces them: Claude searches them before it answers,
6
+ weighs a request against them before it acts, and `zeno check` fails when code
7
+ or docs break them. The AI hallucinates less, and your team ships faster.
8
+
9
+ It has two halves that read the same config:
10
+
11
+ - **a Claude Code plugin** — skills, a decision gate, a session hook and a
12
+ search server, so every session in every repo starts with the same rules;
13
+ - **the `zeno` CLI** — set up, check, sync and browse your repos.
14
+
15
+ ## What it does
16
+
17
+ - **Weigh before acting.** For a non-trivial request Claude checks the
18
+ constitution, your wiki and decision records, existing features and tasks,
19
+ cost vs value, and the write-less ladder, then answers **DO IT**, **DO IT
20
+ DIFFERENTLY** or **PUSH BACK** with citations. Push back stops work until you
21
+ give a reason; overrides are logged and feed `zeno learn`.
22
+ - **Knowledge that is found, not remembered.** Every repo's `.context/wiki` and
23
+ feature board is indexed with [QMD](https://github.com/tobi/qmd) (local hybrid
24
+ search). Claude is told to search before answering.
25
+ - **Standards with one home.** zeno ships defaults (constitution, wiki schema,
26
+ `.context` layout, agent contract). A team adds or replaces them from its own
27
+ folder; a repo can override again. `zeno standards` shows what applies.
28
+ - **Hard rules are checks.** `zeno check` validates layout, wiki frontmatter,
29
+ stale pages, prices with no canonical home, copied standards, and runs every
30
+ checker your config declares.
31
+ - **Each repo's architecture is enforced.** With the `architecture` module, a
32
+ repo keeps its own import rules in `.dependency-cruiser.cjs` files: one at the
33
+ root, or one per workspace under `apps/` and `packages/` (each runs from its
34
+ folder, since apps map `@/` to their own `src`). `zeno init` writes starters and
35
+ `.dependency-cruiser.base.cjs`, the shared base they extend; that base is zeno's,
36
+ and `zeno sync` keeps it current. Add `dependency-cruiser` as a devDependency.
37
+ `zeno check` fails on a broken boundary or a cycle, the gate weighs requests
38
+ against the rules, and `zeno ui` draws each repo's import graph.
39
+ - **Products evolve together.** Packs index vendor docs (`llms.txt`) for a stack;
40
+ `zeno sync` refreshes them and moves each repo to the current zeno version.
41
+
42
+ ## Install
43
+
44
+ ```bash
45
+ # plugin (from GitHub)
46
+ claude plugin marketplace add ritesh-firodiya/zeno
47
+ claude plugin install zeno@zeno
48
+
49
+ # CLI (Node 22+)
50
+ npm install -g @freetesh/zeno
51
+
52
+ # search
53
+ 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
54
+
55
+ # optional: write-less discipline for the efficiency step of the gate
56
+ claude plugin marketplace add DietrichGebert/ponytail && claude plugin install ponytail@ponytail
57
+ ```
58
+
59
+ ## Quickstart (5 minutes)
60
+
61
+ In any git repo, after the install above:
62
+
63
+ ```bash
64
+ zeno doctor # node, git, qmd — fix anything marked ✗ first
65
+ zeno init # pick kind, modules and doc packs; writes .claude/zeno.yaml and .context/
66
+ zeno check # should print "<repo> ok"
67
+ zeno sync # fetches the packs you picked and indexes the wiki for search
68
+ ```
69
+
70
+ Then start Claude Code in the repo. The session hook loads the constitution,
71
+ and a non-trivial request is weighed before Claude acts on it — try
72
+ `/zeno:weigh add a dark mode toggle`. Write decisions to `.context/wiki/` as
73
+ you make them (`/zeno:decision`) and ask about them later with `/zeno:ask`.
74
+
75
+ Want import rules too? `zeno init --force --modules docs,knowledge,architecture`
76
+ (keep the modules you already had), `npm i -D dependency-cruiser`, then edit the
77
+ starter `.dependency-cruiser.cjs` to your layers. `evals/fixtures/streaks` is a
78
+ small product with a wiki and decision records, if you want to see the
79
+ `.context/` layout before touching your own repo.
80
+
81
+ For local development of zeno itself, link the repo as a skills-dir plugin so
82
+ edits apply on the next session: `ln -s "$PWD" ~/.claude/skills/zeno`.
83
+ Run the CLI from source (Node 23.6+ runs the TypeScript directly):
84
+ `pnpm install && ln -s "$PWD/cli/src/index.ts" ~/.local/bin/zeno`.
85
+ `pnpm test`, `pnpm typecheck` and `pnpm build` run from the repo root.
86
+
87
+ **Releasing.** Bump `version` in `package.json` and `.claude-plugin/plugin.json`,
88
+ then push or merge to `main`. The `release` workflow runs the tests, publishes
89
+ `@freetesh/zeno` to npm, tags `vX.Y.Z` and creates the GitHub release. A push
90
+ without a bump releases nothing. It needs the `NPM_TOKEN` Actions secret.
91
+
92
+ ## CLI
93
+
94
+ ```bash
95
+ zeno init # write .claude/zeno.yaml and the missing .context files (asks kind, modules, packs)
96
+ zeno init -y --kind web --modules docs,code --packs nextjs # same, no questions
97
+ zeno add # list packs; `zeno add expo` adds one and indexes its docs (-g: whole estate)
98
+ zeno check # this repo against the standards (exit 1 on any error)
99
+ zeno check --all -q # every repo in the estate, only the ones with findings
100
+ zeno standards # which standards apply here, and where each comes from
101
+ zeno sync # refresh pack docs + search index, move the pinned zeno version, then check
102
+ zeno status # version, kind, modules, config layers, check summary
103
+ zeno doctor # node, git, qmd — plus any tools your config lists
104
+ zeno learn # proposals from overridden verdicts; --approve/--reject <id>
105
+ zeno weigh "<ask>" # the decision gate from the terminal (runs Claude, weighs only)
106
+ zeno ui # every wiki (search, backlinks, graph) on :4610, design galleries on :4611
107
+ ```
108
+
109
+ ## Config
110
+
111
+ `.claude/zeno.yaml`, read in layers — **estate → repo → local**
112
+ (`zeno.local.yaml`, not committed). The estate is the nearest folder above a
113
+ repo whose `zeno.yaml` says `estate: true`, or `ZENO_ESTATE`.
114
+
115
+ ```yaml
116
+ # <estate>/.claude/zeno.yaml — shared by every repo below it
117
+ estate: true
118
+ standards: [.zeno/standards] # team rules; same name replaces a zeno default
119
+ commands: # your own checkers, run by `zeno check`
120
+ - id: designs
121
+ run: ./scripts/check-designs.sh
122
+ if: .context/designs # only where this path exists
123
+ doctor:
124
+ tools:
125
+ - { tool: pnpm, why: product scripts, hint: npm i -g pnpm }
126
+ wiki:
127
+ types: # added to the six built-in page types
128
+ - type: postmortem
129
+ folder: postmortems
130
+ reason: incidents need a timeline and a fix
131
+ sections: [Summary, Timeline, Root cause, Fix, Sources] # zeno check requires each `## ` heading
132
+ context:
133
+ folders: # added to the ten .context folders
134
+ - { folder: research, reason: interview notes, kept apart from raw/ }
135
+ ```
136
+
137
+ ```yaml
138
+ # <repo>/.claude/zeno.yaml — written by `zeno init`
139
+ zeno: 0.6.0
140
+ kind: expo-app # expo-app | web | content
141
+ modules: [docs, knowledge, code, listing] # all optional; design is added when .context/designs exists, metrics only when asked
142
+ packs: [expo]
143
+ checks:
144
+ stale-copies: { enabled: false, reason: "Cursor is still used here" }
145
+ weigh:
146
+ weights: { C-05: 2 }
147
+ ```
148
+
149
+ Scalars: the most specific layer wins. `packs` and `standards` add up across
150
+ layers; `commands`, `doctor.tools`, `wiki.types` and `context.folders` merge
151
+ by name. Built-in types and folders cannot be removed or redefined. A check turned off must
152
+ carry a `reason:`, and `zeno check` lists every override.
153
+
154
+ ## Standards
155
+
156
+ | zeno default | What it holds |
157
+ |---|---|
158
+ | `constitution.md` | Principles C-01–C-13 a request is weighed against. A team adds C-20+ in its own `constitution.md`. |
159
+ | `wiki-schema.md` | Wiki page types, frontmatter, required sections, lint rules |
160
+ | `context-layout.md` | The `.context/` ten-folder layout, `features/` and `tasks.md` |
161
+ | `agent-contract.md` | What every agent does before, during and after work |
162
+
163
+ ## Packs
164
+
165
+ `packs/<name>/pack.yaml` names the vendor docs to index. Shipped: expo,
166
+ uniwind, react, nextjs, firebase, revenuecat. A pack is three lines of YAML; add
167
+ your own.
168
+
169
+ ## Evals
170
+
171
+ `evals/` runs seven requests against a fixture product (`evals/fixtures/streaks`)
172
+ with and without the plugin, each in a sandbox seeded with the fixture and a
173
+ local QMD index:
174
+
175
+ ```bash
176
+ claude plugin eval . --scaffold --mocks off --allow-tools 'mcp__plugin_zeno_qmd__*' Write Edit --runs 2
177
+ ```
178
+
179
+ ## Layout
180
+
181
+ ```
182
+ .claude-plugin/ plugin manifest and marketplace
183
+ skills/ weigh, standards, ask, ingest, decision, wiki-lint
184
+ agents/ challenger — independent second opinion for too-large requests
185
+ hooks/ session start: constitution titles, qmd refresh
186
+ standards/ default standards
187
+ packs/ vendor doc packs
188
+ templates/ files init writes: dependency-cruiser base (synced) and starters
189
+ scripts/ log-verdict.mjs — append-only override log
190
+ cli/ the zeno CLI (TypeScript, vitest; built to cli/dist for npm)
191
+ evals/ plugin evals and the fixture product
192
+ ```
@@ -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,42 @@
1
+ import { existsSync, readdirSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { CONTEXT_FOLDERS } from "../config/schema.js";
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 allowed ones (the ten plus context.folders), 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
+ const allowed = [...CONTEXT_FOLDERS, ...config.context.folders.map((entry) => entry.folder)];
27
+ for (const entry of readdirSync(context, { withFileTypes: true })) {
28
+ if (entry.name === ".DS_Store")
29
+ continue;
30
+ if (entry.isDirectory() && !allowed.includes(entry.name)) {
31
+ findings.push(finding("error", `.context/${entry.name}`, `${entry.name}/ is not an allowed folder — move it, or add it under context.folders in zeno.yaml`));
32
+ }
33
+ if (entry.isFile() && !ROOT_FILES.includes(entry.name)) {
34
+ findings.push(finding("error", `.context/${entry.name}`, "only README.md and tasks.md may sit at .context/ root"));
35
+ }
36
+ }
37
+ if (existsSync(join(context, "raw")) && !existsSync(join(context, "raw", ".gitkeep"))) {
38
+ findings.push(finding("warn", ".context/raw/.gitkeep", "raw/ needs its .gitkeep so it survives a clean clone"));
39
+ }
40
+ return findings;
41
+ },
42
+ };
@@ -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,17 @@
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 { metricsDefinitions } from "./metrics.js";
7
+ import { wikiFrontmatter } from "./wiki-frontmatter.js";
8
+ export const ALL_CHECKS = [
9
+ contextLayout,
10
+ wikiFrontmatter,
11
+ canonicalValues,
12
+ standardsCopies,
13
+ staleCopies,
14
+ architecture,
15
+ metricsDefinitions,
16
+ commandChecks,
17
+ ];
@@ -0,0 +1,14 @@
1
+ import { existsSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ const DEFINITIONS = ".context/metrics/definitions.yml";
4
+ export const metricsDefinitions = {
5
+ id: "metrics-definitions",
6
+ module: "metrics",
7
+ description: "metrics/definitions.yml names the numbers the weigh gate measures value against",
8
+ run({ repo }) {
9
+ if (existsSync(join(repo, DEFINITIONS)))
10
+ return [];
11
+ // A warning: most products turn metrics on before they define a single number.
12
+ return [{ check: "metrics-definitions", level: "warn", file: DEFINITIONS, message: "no metric definitions — the weigh gate cannot name the metric a request moves" }];
13
+ },
14
+ };
@@ -0,0 +1 @@
1
+ export {};