@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.
- package/.claude-plugin/marketplace.json +14 -0
- package/.claude-plugin/plugin.json +10 -0
- package/LICENSE +21 -0
- package/README.md +191 -2
- package/cli/dist/checks/architecture.js +126 -0
- package/cli/dist/checks/canonical-values.js +46 -0
- package/cli/dist/checks/context-layout.js +42 -0
- package/cli/dist/checks/copies.js +73 -0
- package/cli/dist/checks/external.js +30 -0
- package/cli/dist/checks/index.js +17 -0
- package/cli/dist/checks/metrics.js +14 -0
- package/cli/dist/checks/types.js +1 -0
- package/cli/dist/checks/wiki-frontmatter.js +74 -0
- package/cli/dist/commands/add.js +24 -0
- package/cli/dist/commands/check.js +19 -0
- package/cli/dist/commands/doctor.js +16 -0
- package/cli/dist/commands/env.js +6 -0
- package/cli/dist/commands/init.js +78 -0
- package/cli/dist/commands/learn.js +35 -0
- package/cli/dist/commands/standards.js +26 -0
- package/cli/dist/commands/status.js +11 -0
- package/cli/dist/commands/sync.js +23 -0
- package/cli/dist/commands/ui.js +64 -0
- package/cli/dist/commands/weigh.js +16 -0
- package/cli/dist/config/load.js +96 -0
- package/cli/dist/config/schema.js +72 -0
- package/cli/dist/doctor.js +31 -0
- package/cli/dist/format.js +37 -0
- package/cli/dist/index.js +36 -0
- package/cli/dist/init.js +120 -0
- package/cli/dist/learn.js +69 -0
- package/cli/dist/lib/files.js +23 -0
- package/cli/dist/lib/frontmatter.js +16 -0
- package/cli/dist/lib/system.js +19 -0
- package/cli/dist/packs.js +85 -0
- package/cli/dist/run.js +45 -0
- package/cli/dist/standards.js +42 -0
- package/cli/dist/status.js +27 -0
- package/cli/dist/sync.js +30 -0
- package/cli/dist/ui.js +69 -0
- package/package.json +50 -4
- package/packs/expo/pack.yaml +5 -0
- package/packs/firebase/pack.yaml +5 -0
- package/packs/nextjs/pack.yaml +5 -0
- package/packs/react/pack.yaml +5 -0
- package/packs/revenuecat/pack.yaml +5 -0
- package/packs/uniwind/pack.yaml +5 -0
- package/skills/ask/SKILL.md +42 -0
- package/skills/decision/SKILL.md +33 -0
- package/skills/ingest/SKILL.md +45 -0
- package/skills/standards/SKILL.md +16 -0
- package/skills/weigh/SKILL.md +111 -0
- package/skills/wiki-lint/SKILL.md +69 -0
- package/standards/README.md +14 -0
- package/standards/agent-contract.md +40 -0
- package/standards/constitution.md +89 -0
- package/standards/context-layout.md +138 -0
- package/standards/dev-methods.md +29 -0
- package/standards/wiki-schema.md +287 -0
- package/templates/dependency-cruiser/app.cjs +23 -0
- package/templates/dependency-cruiser/base.cjs +52 -0
- package/templates/dependency-cruiser/package.cjs +31 -0
- 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
|
-
#
|
|
1
|
+
# zeno
|
|
2
2
|
|
|
3
|
-
|
|
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 {};
|