@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.
- package/.claude-plugin/marketplace.json +14 -0
- package/.claude-plugin/plugin.json +10 -0
- package/LICENSE +21 -0
- package/README.md +175 -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 +41 -0
- package/cli/dist/checks/copies.js +73 -0
- package/cli/dist/checks/external.js +30 -0
- package/cli/dist/checks/index.js +15 -0
- package/cli/dist/checks/types.js +1 -0
- package/cli/dist/checks/wiki-frontmatter.js +71 -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 +93 -0
- package/cli/dist/config/schema.js +43 -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 +44 -0
- package/skills/standards/SKILL.md +16 -0
- package/skills/weigh/SKILL.md +102 -0
- package/skills/wiki-lint/SKILL.md +69 -0
- package/standards/README.md +13 -0
- package/standards/agent-contract.md +40 -0
- package/standards/constitution.md +89 -0
- package/standards/context-layout.md +112 -0
- package/standards/wiki-schema.md +249 -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": "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
|
-
#
|
|
1
|
+
# zeno
|
|
2
2
|
|
|
3
|
-
|
|
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
|
+
}
|