@freetesh/zeno 0.11.0 → 0.13.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.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://anthropic.com/claude-code/plugin.schema.json",
3
3
  "name": "zeno",
4
- "version": "0.11.0",
4
+ "version": "0.13.0",
5
5
  "description": "Keeps your domain knowledge and AI rules local and enforces them, so Claude hallucinates less.",
6
6
  "author": {
7
7
  "name": "Ritesh Firodiya",
package/README.md CHANGED
@@ -54,8 +54,10 @@ It has two halves that read the same config:
54
54
  `.context` layout, agent contract). A team adds or replaces them from its own
55
55
  folder; a repo can override again. `zeno standards` shows what applies.
56
56
  - **Hard rules are checks.** `zeno check` validates layout, wiki frontmatter,
57
- stale pages, placeholder text, page and `tasks.md` size caps, prices with no
58
- canonical home, copied standards, and runs every checker your config declares.
57
+ stale pages, placeholder text, page and `tasks.md` size caps, blocked tasks,
58
+ feature pages, the store listing tree, dead `.context/` paths, prices with no
59
+ canonical home, files shared across products, and runs every checker your
60
+ config declares. `zeno hooks install` makes every push run it.
59
61
  - **Context stays small.** A line belongs in `.context/` only if an agent would
60
62
  get something wrong without it. Decisions are one file per topic, overwritten
61
63
  when the choice changes; `tasks.md` holds open work only; history is git's.
@@ -123,6 +125,8 @@ zeno init -y --kind web --modules docs,code --packs nextjs # same, no question
123
125
  zeno add # list packs; `zeno add expo` adds one and indexes its docs (-g: whole estate)
124
126
  zeno check # this repo against the standards (exit 1 on any error)
125
127
  zeno check --all -q # every repo in the estate, only the ones with findings
128
+ zeno copies --sync # overwrite drifted shared files from copies.source (never adds one)
129
+ zeno hooks install # a pre-push hook that runs `zeno check` (--all: every product)
126
130
  zeno standards # which standards apply here, and where each comes from
127
131
  zeno sync # refresh pack docs + search index, move the pinned zeno version, then check
128
132
  zeno status # version, kind, modules, config layers, check summary
@@ -158,24 +162,33 @@ wiki:
158
162
  context:
159
163
  folders: # added to the ten .context folders
160
164
  - { folder: research, reason: interview notes, kept apart from raw/ }
165
+ copies: # files products copy from one source repo, in named sets
166
+ source: charades # a folder beside the estate config
167
+ sets:
168
+ play-upload: [scripts/play-upload.mjs] # the same path on both sides
169
+ design-shell-web:
170
+ - { from: .context/designs/mobile/index.html, to: .context/designs/web/index.html, named: true }
171
+ - { from: .context/designs/mobile/shared.css, to: .context/designs/web/shared.css, until: "PALETTE — " }
161
172
  ```
162
173
 
163
174
  ```yaml
164
175
  # <repo>/.claude/zeno.yaml — written by `zeno init`
165
- zeno: 0.11.0
176
+ zeno: 0.13.0
166
177
  kind: expo-app # expo-app | web | content
167
178
  modules: [docs, knowledge, code, listing] # all optional; design is added when .context/designs exists, metrics only when asked
168
179
  packs: [expo]
169
- checks:
170
- stale-copies: { enabled: false, reason: "Cursor is still used here" }
171
180
  weigh:
172
181
  weights: { C-05: 2 }
182
+ name: Chitragupt # how the product is written; `named` copies swap it in
183
+ copies:
184
+ use: [play-upload, design-shell-web] # the sets this product uses; only these are held to the source
173
185
  ```
174
186
 
175
187
  Scalars: the most specific layer wins. `packs` and `standards` add up across
176
188
  layers; `commands`, `doctor.tools`, `wiki.types` and `context.folders` merge
177
- by name. Built-in types and folders cannot be removed or redefined. A check turned off must
178
- carry a `reason:`, and `zeno check` lists every override.
189
+ by name; `copies.use` adds up. Built-in types and folders cannot be removed or
190
+ redefined. A repo names only what it uses: a check applies through the modules
191
+ it turns on, and there is no setting to skip one.
179
192
 
180
193
  ## Standards
181
194
 
@@ -29,6 +29,13 @@ export function findRules(repo) {
29
29
  });
30
30
  }
31
31
  const fromRoot = (dir, path) => posix.normalize(posix.join(dir, path));
32
+ // The rules are about committed code; a gitignored file (a stale dist/ build) is never shipped.
33
+ function ignoredBy(repo, paths) {
34
+ if (paths.length === 0)
35
+ return new Set();
36
+ const result = spawnSync("git", ["-C", repo, "check-ignore", "--stdin"], { input: paths.join("\n"), encoding: "utf8" });
37
+ return new Set(result.stdout.split("\n").filter(Boolean));
38
+ }
32
39
  // The repo's own pinned copy, never a global one, so every machine runs the same version.
33
40
  function depcruiseBin(repo) {
34
41
  const bin = join(repo, "node_modules", ".bin", "depcruise");
@@ -81,7 +88,9 @@ export const architecture = {
81
88
  const result = depcruise(repo, bin, rules, ["--output-type", "json"]);
82
89
  try {
83
90
  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) })));
91
+ const violations = cruise.summary.violations.map((v) => ({ ...v, from: fromRoot(rules.dir, v.from), to: fromRoot(rules.dir, v.to) }));
92
+ const ignored = ignoredBy(repo, violations.map((v) => v.from));
93
+ const findings = summarize(violations.filter((v) => !ignored.has(v.from)));
85
94
  return unreadTypeScript(repo, rules, cruise.modules) ? [...findings, ...typeScriptWarning(rules)] : findings;
86
95
  }
87
96
  catch {
@@ -1,5 +1,6 @@
1
- import { existsSync, readdirSync } from "node:fs";
2
- import { join } from "node:path";
1
+ import { spawnSync } from "node:child_process";
2
+ import { existsSync, readdirSync, readFileSync } from "node:fs";
3
+ import { join, relative } from "node:path";
3
4
  import { CONTEXT_FOLDERS } from "../config/schema.js";
4
5
  const KNOWLEDGE_CONTEXT = ["raw", "wiki", "features"];
5
6
  /** What .context must hold: README.md and tasks.md always, the knowledge folders only when docs or knowledge is on. */
@@ -8,6 +9,17 @@ export function requiredContext(modules) {
8
9
  return ["README.md", "tasks.md", ...(knowledge ? KNOWLEDGE_CONTEXT : [])];
9
10
  }
10
11
  const ROOT_FILES = ["README.md", "tasks.md"];
12
+ const LIVE_FOLDERS = ["marketing", "metrics", "support", "legal"];
13
+ const ROOT_STRAYS = ["TODO.md", "STATUS.md", "SETUP.md"];
14
+ function emptyFolders(dir) {
15
+ const entries = readdirSync(dir, { withFileTypes: true });
16
+ if (entries.length === 0)
17
+ return [dir];
18
+ return entries.filter((entry) => entry.isDirectory()).flatMap((entry) => emptyFolders(join(dir, entry.name)));
19
+ }
20
+ function ignored(repo, path) {
21
+ return spawnSync("git", ["-C", repo, "check-ignore", "-q", path]).status === 0;
22
+ }
11
23
  export const contextLayout = {
12
24
  id: "context-layout",
13
25
  module: null,
@@ -37,6 +49,35 @@ export const contextLayout = {
37
49
  if (existsSync(join(context, "raw")) && !existsSync(join(context, "raw", ".gitkeep"))) {
38
50
  findings.push(finding("warn", ".context/raw/.gitkeep", "raw/ needs its .gitkeep so it survives a clean clone"));
39
51
  }
52
+ // git drops an empty folder on clone, so on disk it promises something that is not there.
53
+ // A gitignored one is tool output that recreates itself.
54
+ for (const empty of emptyFolders(context)) {
55
+ if (empty !== join(context, "raw") && !ignored(repo, empty)) {
56
+ findings.push(finding("error", relative(repo, empty), "empty folder — git will drop it"));
57
+ }
58
+ }
59
+ // Once something is live, the go-to-market folders are owed. release.yml is the stage signal.
60
+ const release = join(context, "ops", "release.yml");
61
+ if (existsSync(release) && /^\s+live:(?!\s*null)/m.test(readFileSync(release, "utf8"))) {
62
+ for (const owed of LIVE_FOLDERS) {
63
+ if (!existsSync(join(context, owed)))
64
+ findings.push(finding("error", `.context/${owed}`, `live (ops/release.yml) but no ${owed}/`));
65
+ }
66
+ }
67
+ for (const stray of ROOT_STRAYS) {
68
+ if (existsSync(join(repo, stray)))
69
+ findings.push(finding("error", stray, "nothing renders it at the root — it belongs in .context/ (tasks.md, ops/)"));
70
+ }
71
+ if (existsSync(join(repo, "docs")))
72
+ findings.push(finding("error", "docs/", "a root docs/ folder — that material belongs in .context/"));
73
+ if (existsSync(join(repo, "store.config.json")) && existsSync(join(context, "listing", "ios"))) {
74
+ findings.push(finding("error", "store.config.json", "two Apple listings — .context/listing/ios/ is the one source"));
75
+ }
76
+ // Without the pointer the whole .context/ tree is invisible to an agent.
77
+ const agents = join(repo, "AGENTS.md");
78
+ if (existsSync(agents) && !readFileSync(agents, "utf8").includes("tasks.md")) {
79
+ findings.push(finding("error", "AGENTS.md", "does not point at .context/tasks.md"));
80
+ }
40
81
  return findings;
41
82
  },
42
83
  };
@@ -17,6 +17,7 @@ export const commandChecks = {
17
17
  run({ repo, config }) {
18
18
  return config.commands
19
19
  .filter((command) => !command.if || existsSync(join(repo, command.if)))
20
+ .filter((command) => !command.module || config.modules.includes(command.module))
20
21
  .flatMap((command) => {
21
22
  const result = spawnSync("sh", ["-c", command.run], { cwd: repo, encoding: "utf8" });
22
23
  if (result.error) {
@@ -0,0 +1,38 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { basename, join } from "node:path";
3
+ import { listFiles } from "../lib/files.js";
4
+ import { parseFrontmatter } from "../lib/frontmatter.js";
5
+ const TYPES = ["feature", "experiment", "chore", "platform"];
6
+ const STATUSES = ["planned", "building", "shipped", "shelved", "superseded"];
7
+ // A chore or a platform change ships with nothing a user reads; a feature that never shipped keeps its target release.
8
+ const NEEDS_STORE_COPY = ["feature", "experiment"];
9
+ const NEVER_SHIPPED = ["shelved", "superseded"];
10
+ const STORE_COPY = /^## Store copy[ \t]*\n([\s\S]*?)(?=^## |(?![\s\S]))/m;
11
+ export const features = {
12
+ id: "features",
13
+ module: "docs",
14
+ description: "each features/ page has id, type and status from their sets; a released feature has its Store copy blockquote (the What's New text)",
15
+ run({ repo }) {
16
+ const pages = listFiles(repo, ".context/features", (name) => /^\d{4}-.+\.md$/.test(name));
17
+ return pages.flatMap((file) => {
18
+ const findings = [];
19
+ const add = (message) => findings.push({ check: "features", level: "error", file, message });
20
+ const { data, body } = parseFrontmatter(readFileSync(join(repo, file), "utf8"));
21
+ if (!data)
22
+ return [{ check: "features", level: "error", file, message: `no frontmatter (${basename(file)})` }];
23
+ for (const field of ["id", "type", "status"])
24
+ if (!data[field])
25
+ add(`missing ${field}`);
26
+ if (data.type && !TYPES.includes(String(data.type)))
27
+ add(`type ${data.type} is not one of ${TYPES.join(", ")}`);
28
+ if (data.status && !STATUSES.includes(String(data.status)))
29
+ add(`status ${data.status} is not one of ${STATUSES.join(", ")}`);
30
+ const released = /\d+\.\d+\.\d+/.test(String(data.release ?? "")) && !NEVER_SHIPPED.includes(String(data.status));
31
+ const copy = STORE_COPY.exec(body.replace(/<!--[\s\S]*?-->/g, ""));
32
+ if (released && NEEDS_STORE_COPY.includes(String(data.type)) && !(copy && /^>[ \t]*\S/m.test(copy[1]))) {
33
+ add(`ships in ${data.release} with no "> …" line under ## Store copy`);
34
+ }
35
+ return findings;
36
+ });
37
+ },
38
+ };
@@ -4,16 +4,26 @@ import { contextLayout } from "./context-layout.js";
4
4
  import { contextSize } from "./context-size.js";
5
5
  import { staleCopies, standardsCopies } from "./copies.js";
6
6
  import { commandChecks } from "./external.js";
7
+ import { features } from "./features.js";
8
+ import { listing } from "./listing.js";
7
9
  import { metricsDefinitions } from "./metrics.js";
8
10
  import { pipeline } from "./pipeline.js";
11
+ import { refs } from "./refs.js";
12
+ import { sharedFiles } from "./shared-files.js";
9
13
  import { sources } from "./sources.js";
14
+ import { tasks } from "./tasks.js";
10
15
  import { wikiFrontmatter } from "./wiki-frontmatter.js";
11
16
  export const ALL_CHECKS = [
12
17
  contextLayout,
13
18
  wikiFrontmatter,
14
19
  contextSize,
20
+ tasks,
21
+ features,
22
+ listing,
15
23
  canonicalValues,
16
24
  standardsCopies,
25
+ sharedFiles,
26
+ refs,
17
27
  staleCopies,
18
28
  architecture,
19
29
  metricsDefinitions,
@@ -0,0 +1,51 @@
1
+ import { existsSync, readdirSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { listFiles } from "../lib/files.js";
4
+ const LISTING = ".context/listing";
5
+ // supply and deliver treat every folder in metadata/ as a locale, so a stray one aborts the upload.
6
+ const NOT_LOCALES = ["review_information", "trade_representative_contact_information", "app_clip_review_information"];
7
+ const LOCALE = /^[a-z]{2,3}(-([A-Za-z]{2}|[A-Za-z]{4}))?$/;
8
+ const PLAY_TEXT = ["title.txt", "short_description.txt", "full_description.txt"];
9
+ const PLAY_IMAGE_FOLDERS = ["phoneScreenshots", "sevenInchScreenshots", "tenInchScreenshots", "tvScreenshots", "wearScreenshots"];
10
+ const folders = (path) => (existsSync(path) ? readdirSync(path, { withFileTypes: true }).filter((entry) => entry.isDirectory()).map((entry) => entry.name) : []);
11
+ export const listing = {
12
+ id: "listing",
13
+ module: "listing",
14
+ description: "listing/ is fastlane's tree: metadata/ per store, locale folders only, Play's three text files, fastlane image folders, zero-padded screenshots",
15
+ run({ repo }) {
16
+ const root = join(repo, LISTING);
17
+ if (!existsSync(root))
18
+ return [];
19
+ const findings = [];
20
+ const add = (file, message) => findings.push({ check: "listing", level: "error", file, message });
21
+ for (const store of ["android", "ios"]) {
22
+ if (!existsSync(join(root, store)))
23
+ continue;
24
+ const metadata = join(root, store, "metadata");
25
+ if (!existsSync(metadata)) {
26
+ add(`${LISTING}/${store}/`, "no metadata/ folder");
27
+ continue;
28
+ }
29
+ for (const name of folders(metadata)) {
30
+ if (!NOT_LOCALES.includes(name) && !LOCALE.test(name))
31
+ add(`${LISTING}/${store}/metadata/${name}`, "not a locale — fastlane would upload it as one");
32
+ }
33
+ }
34
+ for (const locale of folders(join(root, "android", "metadata"))) {
35
+ const at = `${LISTING}/android/metadata/${locale}`;
36
+ for (const file of PLAY_TEXT)
37
+ if (!existsSync(join(repo, at, file)))
38
+ add(`${at}/${file}`, "missing");
39
+ for (const name of folders(join(repo, at, "images"))) {
40
+ if (!PLAY_IMAGE_FOLDERS.includes(name))
41
+ add(`${at}/images/${name}`, "not a fastlane image folder");
42
+ }
43
+ }
44
+ // Play ships screenshots in file-name order, so `2-` sorts after `10-`.
45
+ for (const file of listFiles(repo, LISTING, (name) => name.endsWith(".png"))) {
46
+ if (/[Ss]creenshots\//.test(file) && /\/\d-[^/]*$/.test(file))
47
+ add(file, "unpadded screenshot number — name it NN-<screen>.png");
48
+ }
49
+ return findings;
50
+ },
51
+ };
@@ -0,0 +1,89 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { existsSync, readFileSync } from "node:fs";
3
+ import { basename, dirname, join, normalize } from "node:path";
4
+ import { usedCopies } from "./shared-files.js";
5
+ // A `.context/` path not preceded by something that makes it part of a longer one (another
6
+ // product's, an absolute path, a URL). A leading `./` means "beside this file".
7
+ const PATH = /(?<![\w/~.-])(\.\/)?(\.context\/[\w./@+-]*)/g;
8
+ const PLACEHOLDER = /[<{*[$]|YYYY|NNNN/;
9
+ const LINK = /\[\[([^\]|#]+)(?:[|#][^\]]*)?\]\]/g;
10
+ // A line reading a path out of git history names it as it was.
11
+ const FROM_HISTORY = /git (show|checkout|log|diff|restore)\b/;
12
+ // Dated records keep a path as it was on the day.
13
+ const DATED = /(^|\/)CHANGELOG\.md$|^\.context\/ops\/incidents\//;
14
+ const BINARY = /\.(png|jpe?g|gif|webp|ico|icns|mp4|mov|pdf|zip|aab|apk|jks|keystore|p8|p12|ttf|otf|woff2?|lock|snap|sqlite|db|bin)$|(^|\/)pnpm-lock\.yaml$/i;
15
+ const FEATURE_FILE = /^\.context\/features\/(\d{4})-[^/]+\.md$/;
16
+ function git(repo, args) {
17
+ const result = spawnSync("git", ["-C", repo, ...args], { encoding: "utf8", maxBuffer: 64 * 1024 * 1024 });
18
+ return { status: result.status ?? 1, stdout: result.stdout ?? "" };
19
+ }
20
+ /** The part of a path that names something real: cut back to the last whole folder before a placeholder. */
21
+ function literalPart(token, restOfLine) {
22
+ let path = token.replace(/[.,;:]+$/, "");
23
+ const cut = PLACEHOLDER.exec(path);
24
+ if (cut || PLACEHOLDER.test(restOfLine.slice(0, 1))) {
25
+ path = cut ? path.slice(0, cut.index) : path;
26
+ path = path.slice(0, path.lastIndexOf("/") + 1);
27
+ }
28
+ return path.replace(/^[./]+|[./]+$/g, "") === "context" ? null : path;
29
+ }
30
+ export const refs = {
31
+ id: "refs",
32
+ // A module, not always on: a tool whose docs describe other repos' .context/ (zeno, canon) does not use it.
33
+ module: "refs",
34
+ description: "every .context/ path a tracked file names exists; a [[link]] names a page (a link to a page not yet written is a note)",
35
+ run({ repo, config }) {
36
+ const listed = git(repo, ["ls-files", "-z"]);
37
+ if (listed.status !== 0)
38
+ return [];
39
+ const files = listed.stdout.split("\0").filter((file) => file && !BINARY.test(file));
40
+ // A copy of the source repo's file carries the source's paths; the product cannot edit it.
41
+ const copied = new Set(usedCopies(config).map((pair) => pair.to));
42
+ const pages = new Set(files.filter((f) => f.startsWith(".context/wiki/") && f.endsWith(".md")).map((f) => basename(f, ".md")));
43
+ const features = new Set(files.flatMap((f) => (FEATURE_FILE.exec(f) ? [`FEAT-${FEATURE_FILE.exec(f)[1]}`] : [])));
44
+ const resolves = (target) => pages.has(target) || features.has(target) || existsSync(join(repo, ".context", `${target}.md`));
45
+ const exists = new Map();
46
+ const present = (path) => {
47
+ if (!exists.has(path)) {
48
+ exists.set(path, existsSync(join(repo, path)) || git(repo, ["check-ignore", "-q", path]).status === 0);
49
+ }
50
+ return exists.get(path);
51
+ };
52
+ const findings = [];
53
+ for (const file of files) {
54
+ if (copied.has(file))
55
+ continue;
56
+ let text;
57
+ try {
58
+ text = readFileSync(join(repo, file), "utf8");
59
+ }
60
+ catch {
61
+ continue; // deleted in the working tree, or a submodule
62
+ }
63
+ const dated = DATED.test(file);
64
+ const links = file.startsWith(".context/") && file.endsWith(".md");
65
+ text.split("\n").forEach((line, index) => {
66
+ const at = `${file}:${index + 1}`;
67
+ if (!dated && line.includes(".context/") && !FROM_HISTORY.test(line)) {
68
+ for (const match of line.matchAll(PATH)) {
69
+ let path = literalPart(match[2], line.slice(match.index + match[0].length));
70
+ if (path === null)
71
+ continue;
72
+ if (match[1])
73
+ path = normalize(join(dirname(file), path)) + (path.endsWith("/") ? "/" : "");
74
+ if (!present(path))
75
+ findings.push({ check: "refs", level: "error", file: at, message: `${path} does not exist` });
76
+ }
77
+ }
78
+ if (links && line.includes("[[")) {
79
+ for (const match of line.matchAll(LINK)) {
80
+ const target = match[1].trim();
81
+ if (!resolves(target))
82
+ findings.push({ check: "refs", level: "warn", file: at, message: `[[${target}]] names no page yet` });
83
+ }
84
+ }
85
+ });
86
+ }
87
+ return findings;
88
+ },
89
+ };
@@ -0,0 +1,84 @@
1
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { basename, join, resolve } from "node:path";
3
+ import { loadConfig } from "../config/load.js";
4
+ // zeno init writes the README heading `# .context — <Name>`; a product without a `name:` is read from it.
5
+ const README_NAME = /^# \.context — (.+)$/m;
6
+ const pairOf = (set, file) => typeof file === "string" ? { set, from: file, to: file, named: false } : { set, from: file.from, to: file.to ?? file.from, named: file.named ?? false, until: file.until };
7
+ /** Every file of every set the product uses, as source → product paths. */
8
+ export function usedCopies(config) {
9
+ return config.copies.use.flatMap((set) => (config.copies.sets[set] ?? []).map((file) => pairOf(set, file)));
10
+ }
11
+ function nameOf(repo, config) {
12
+ if (config.name)
13
+ return config.name;
14
+ const readme = join(repo, ".context", "README.md");
15
+ return existsSync(readme) ? README_NAME.exec(readFileSync(readme, "utf8"))?.[1]?.trim() : undefined;
16
+ }
17
+ function split(text, marker) {
18
+ const lines = text.split("\n");
19
+ const at = lines.findIndex((line) => line.includes(marker));
20
+ return at === -1 ? null : { shared: lines.slice(0, at).join("\n"), own: lines.slice(at).join("\n") };
21
+ }
22
+ /** Compares every file of every set the product uses with the source repo. */
23
+ export function compareCopies(repo, config) {
24
+ const { source, sets, use } = config.copies;
25
+ if (use.length === 0 || !source || resolve(source) === resolve(repo))
26
+ return [];
27
+ const unknown = use.filter((set) => !sets[set]).map((set) => ({ pair: { set, from: "", to: "", named: false }, state: "unknown-set" }));
28
+ const productName = nameOf(repo, config);
29
+ const sourceName = nameOf(source, loadConfig(source).config);
30
+ return [
31
+ ...unknown,
32
+ ...usedCopies(config).map((pair) => {
33
+ const from = join(source, pair.from);
34
+ const to = join(repo, pair.to);
35
+ if (!existsSync(from))
36
+ return { pair, state: "no-source" };
37
+ if (!existsSync(to))
38
+ return { pair, state: "missing" };
39
+ if (pair.named && (!productName || !sourceName))
40
+ return { pair, state: "unnamed" };
41
+ const original = pair.named ? readFileSync(from, "utf8").replaceAll(sourceName, productName) : readFileSync(from, "utf8");
42
+ const current = readFileSync(to, "utf8");
43
+ if (!pair.until)
44
+ return original === current ? { pair, state: "same" } : { pair, state: "drift", want: original };
45
+ const theirs = split(original, pair.until);
46
+ const ours = split(current, pair.until);
47
+ if (theirs && ours && theirs.shared === ours.shared)
48
+ return { pair, state: "same" };
49
+ // Without the marker the product's own part cannot be told from the shared one, so nothing is written.
50
+ return { pair, state: "drift", want: theirs && ours ? `${theirs.shared}\n${ours.own}` : undefined };
51
+ }),
52
+ ];
53
+ }
54
+ /** Overwrites each drifted copy with the source's. Never adds a missing file. Returns the paths written. */
55
+ export function syncCopies(repo, config) {
56
+ return compareCopies(repo, config).flatMap(({ pair, state, want }) => {
57
+ if (state !== "drift" || want === undefined)
58
+ return [];
59
+ writeFileSync(join(repo, pair.to), want);
60
+ return [pair.to];
61
+ });
62
+ }
63
+ export const sharedFiles = {
64
+ id: "shared-files",
65
+ module: null,
66
+ description: "every file of every copies set the product uses exists and matches copies.source",
67
+ run({ repo, config }) {
68
+ const source = config.copies.source ? basename(config.copies.source) : "copies.source";
69
+ return compareCopies(repo, config).flatMap(({ pair, state }) => {
70
+ const finding = (file, message) => [{ check: "shared-files", level: "error", file, message }];
71
+ if (state === "unknown-set")
72
+ return finding(".claude/zeno.yaml", `copies.use names ${pair.set}, which no copies.sets defines`);
73
+ if (state === "no-source")
74
+ return finding(pair.to, `${source} has no ${pair.from} (set ${pair.set})`);
75
+ if (state === "missing")
76
+ return finding(pair.to, `missing — set ${pair.set} is in use; copy it from ${source}`);
77
+ if (state === "unnamed")
78
+ return finding(pair.to, "no product name to compare with — set name: in zeno.yaml");
79
+ if (state === "drift")
80
+ return finding(pair.to, `differs from ${source}${pair.until ? " above the shared block's end" : ""} — \`zeno copies --sync\` copies it`);
81
+ return [];
82
+ });
83
+ },
84
+ };
@@ -14,13 +14,13 @@ function accessValue(body, id) {
14
14
  export const sources = {
15
15
  id: "sources",
16
16
  module: "docs",
17
- description: "each SOURCE.* page says how to read it (mcp, skill, manual, or a cli in doctor.tools) and has a How to query section",
17
+ description: "each SOURCE.* entity page says how to read it (mcp, skill, manual, or a cli in doctor.tools) and has a How to query section",
18
18
  run({ repo, config }) {
19
19
  const tools = new Set(config.doctor.tools.map((tool) => tool.tool));
20
20
  return wikiPages(repo).flatMap((file) => {
21
21
  const { data, body } = parseFrontmatter(readFileSync(join(repo, file), "utf8"));
22
22
  const id = String(data?.id ?? "");
23
- if (data?.type !== "entity" || !id.startsWith("SOURCE."))
23
+ if (!file.startsWith(".context/wiki/entities/") || !id.startsWith("SOURCE."))
24
24
  return [];
25
25
  const findings = [];
26
26
  const add = (message) => findings.push({ check: "sources", level: "error", file, message });
@@ -0,0 +1,66 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ const TASKS = ".context/tasks.md";
4
+ const COMMENT = /<!--[\s\S]*?-->/g;
5
+ const ITEM = /^(?:\d+\.|[-*]) \[[ xX]\] +(T\d+\b)?/gm;
6
+ const ISO_DATE = /\d{4}-\d{2}-\d{2}/;
7
+ // A blocker with no way out and no check date is the silent leak the format exists to stop.
8
+ const BLOCKED_FIELDS = ["blocked-by", "unblock", "next-check"];
9
+ function sectionOf(text, name) {
10
+ const clean = text.replace(COMMENT, "");
11
+ const headings = [...clean.matchAll(/^## +(\S[^\n]*?)[ \t]*$/gm)];
12
+ const at = headings.findIndex((heading) => heading[1].startsWith(name));
13
+ if (at === -1)
14
+ return "";
15
+ const start = headings[at].index + headings[at][0].length;
16
+ return clean.slice(start, headings[at + 1]?.index ?? clean.length);
17
+ }
18
+ function fieldOf(item, field) {
19
+ const found = new RegExp(`(?<![\\w-])${field}:[ \\t]*([^\\n]*)`).exec(item);
20
+ return found ? found[1].trim() : null;
21
+ }
22
+ function asDate(value) {
23
+ const found = ISO_DATE.exec(value);
24
+ if (!found)
25
+ return null;
26
+ const date = new Date(`${found[0]}T00:00:00Z`);
27
+ return Number.isNaN(date.getTime()) || date.toISOString().slice(0, 10) !== found[0] ? null : date;
28
+ }
29
+ export const tasks = {
30
+ id: "tasks",
31
+ module: null,
32
+ description: "every blocked task has an id, blocked-by, unblock and next-check; a passed date is a note",
33
+ run({ repo, today }) {
34
+ const path = join(repo, TASKS);
35
+ if (!existsSync(path))
36
+ return [];
37
+ const text = readFileSync(path, "utf8");
38
+ const findings = [];
39
+ const add = (level, message) => findings.push({ check: "tasks", level, file: TASKS, message });
40
+ const horizon = /^horizon:\s*(\S+)/m.exec(text);
41
+ const horizonDate = horizon ? asDate(horizon[1]) : null;
42
+ if (horizonDate && horizonDate < today)
43
+ add("warn", `past its horizon (${horizon[1]}) — regenerate it`);
44
+ const blocked = sectionOf(text, "Blocked");
45
+ const starts = [...blocked.matchAll(ITEM)];
46
+ starts.forEach((start, index) => {
47
+ const item = blocked.slice(start.index, starts[index + 1]?.index ?? blocked.length);
48
+ const id = start[1];
49
+ if (!id)
50
+ return add("error", `blocked item without a task id: ${item.split("\n")[0].trim().slice(0, 60)}`);
51
+ for (const field of BLOCKED_FIELDS)
52
+ if (!fieldOf(item, field))
53
+ add("error", `${id} is blocked without ${field}:`);
54
+ // next-check may be prose ("after T010"); only something shaped like a date can be a bad one.
55
+ const check = fieldOf(item, "next-check") ?? "";
56
+ if (ISO_DATE.test(check)) {
57
+ const on = asDate(check);
58
+ if (!on)
59
+ add("error", `${id} next-check: ${check} is not a date`);
60
+ else if (on < today)
61
+ add("warn", `${id} was to be checked on ${check}`);
62
+ }
63
+ });
64
+ return findings;
65
+ },
66
+ };
@@ -61,6 +61,8 @@ export const wikiFrontmatter = {
61
61
  const age = verified ? Math.floor((today.getTime() - verified.getTime()) / DAY_MS) : 0;
62
62
  if (verified && age > STALE_AFTER_DAYS)
63
63
  stale.push({ file, age });
64
+ if (verified && age < 0)
65
+ add("error", file, `last-verified ${data["last-verified"]} is in the future`);
64
66
  if (data.id)
65
67
  filesById.set(String(data.id), [...(filesById.get(String(data.id)) ?? []), file]);
66
68
  }
@@ -0,0 +1,23 @@
1
+ import { resolve } from "node:path";
2
+ import { compareCopies, syncCopies } from "../checks/shared-files.js";
3
+ import { loadConfig } from "../config/load.js";
4
+ export function registerCopies(program, env) {
5
+ program
6
+ .command("copies")
7
+ .description("compare a product's shared files with copies.source; --sync overwrites the drifted ones")
8
+ .argument("[repo]", "product repo", ".")
9
+ .option("--sync", "overwrite drifted copies from the source (never adds a missing file)")
10
+ .action((repo, options) => {
11
+ const path = resolve(repo);
12
+ const { config } = loadConfig(path, { estateRoot: env.estateRoot });
13
+ if (options.sync) {
14
+ const written = syncCopies(path, config);
15
+ console.log(written.length ? written.map((file) => ` synced: ${file}`).join("\n") : " nothing to sync");
16
+ return;
17
+ }
18
+ const off = compareCopies(path, config).filter((entry) => entry.state !== "same");
19
+ console.log(off.length ? off.map(({ pair, state }) => ` ${state}: ${pair.to}`).join("\n") : " ok");
20
+ if (off.length)
21
+ process.exitCode = 1;
22
+ });
23
+ }
@@ -0,0 +1,55 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { chmodSync, existsSync, lstatSync, readFileSync, renameSync, writeFileSync } from "node:fs";
3
+ import { join, resolve } from "node:path";
4
+ import { findProducts } from "../run.js";
5
+ import { requireEstate } from "./env.js";
6
+ const MARK = "installed by `zeno hooks install`";
7
+ // Hooks written by the estate script this command replaced; they are ours to overwrite.
8
+ const OLD_MARKS = [MARK, "installed by ~/git/bin/install-hooks"];
9
+ export const HOOK = `#!/bin/sh
10
+ # ${MARK} — run it again rather than editing this file.
11
+ # A machine without zeno pushes unchecked, as it would without any hook.
12
+ command -v zeno >/dev/null 2>&1 || exit 0
13
+ # The product is the folder that owns .git, not the folder being pushed from: a worktree shares these hooks.
14
+ root=$(cd "$(git rev-parse --git-common-dir)/.." && pwd) || exit 0
15
+ exec zeno check "$root"
16
+ `;
17
+ const ours = (path) => OLD_MARKS.some((mark) => readFileSync(path, "utf8").includes(mark));
18
+ /** Writes the pre-push hook into one repo. Returns a one-line outcome. */
19
+ export function installHook(repo) {
20
+ if (!existsSync(join(repo, ".git")))
21
+ return `skipped: ${repo} is not a git repo`;
22
+ if (!existsSync(join(repo, ".context", "tasks.md"))) {
23
+ const old = join(repo, ".git", "hooks", "pre-push");
24
+ if (existsSync(old) && ours(old)) {
25
+ renameSync(old, `${old}.disabled`);
26
+ return `disabled: ${repo} has no .context/tasks.md, so it would never pass`;
27
+ }
28
+ return `skipped: ${repo} has no .context/tasks.md`;
29
+ }
30
+ const hooksPath = spawnSync("git", ["-C", repo, "config", "core.hooksPath"], { encoding: "utf8" }).stdout.trim();
31
+ // husky points core.hooksPath at .husky/_, whose stubs run .husky/<name>.
32
+ const target = hooksPath === "" ? join(repo, ".git", "hooks", "pre-push") : hooksPath === ".husky/_" ? join(repo, ".husky", "pre-push") : null;
33
+ if (!target)
34
+ return `SKIPPED: ${repo} sets core.hooksPath=${hooksPath} — add the hook there by hand`;
35
+ if (existsSync(target) && (lstatSync(target).isSymbolicLink() || !ours(target)))
36
+ return `SKIPPED: ${target} exists and is not zeno's`;
37
+ writeFileSync(target, HOOK);
38
+ chmodSync(target, 0o755);
39
+ return target.includes("/.husky/") ? `installed: ${target} — a tracked file, commit it` : `installed: ${target}`;
40
+ }
41
+ export function registerHooks(program, env) {
42
+ program
43
+ .command("hooks")
44
+ .description("install a pre-push hook that runs `zeno check`")
45
+ .argument("<action>", "install")
46
+ .argument("[repo]", "product repo", ".")
47
+ .option("--all", "every product in the estate")
48
+ .action((action, repo, options) => {
49
+ if (action !== "install")
50
+ throw new Error(`unknown action ${action} — use: zeno hooks install`);
51
+ const repos = options.all ? findProducts(requireEstate(env)) : [resolve(repo)];
52
+ for (const path of repos)
53
+ console.log(` ${installHook(path)}`);
54
+ });
55
+ }
@@ -85,12 +85,19 @@ export function loadConfig(repo, options = {}) {
85
85
  standards: [...new Set(layers.flatMap((layer) => (layer.data.standards ?? []).map((dir) => resolve(layer.base, dir))))],
86
86
  commands: mergeBy(pick("commands").map((list) => list ?? []), (command) => command.id),
87
87
  doctor: { tools: mergeBy(pick("doctor").map((doctor) => doctor?.tools ?? []), (tool) => tool.tool) },
88
- checks: Object.assign({}, ...pick("checks")),
89
88
  weigh: { weights: Object.assign({}, ...pick("weigh").map((weigh) => weigh?.weights)) },
90
89
  wiki: { types: mergeBy(pick("wiki").map((wiki) => wiki?.types ?? []), (entry) => entry.type) },
91
90
  context: { folders: mergeBy(pick("context").map((context) => context?.folders ?? []), (entry) => entry.folder) },
92
91
  pipeline: lastDefined(pick("pipeline")) ?? [],
92
+ name: lastDefined(pick("name")),
93
+ copies: {
94
+ source: lastDefined(layers.map((layer) => (layer.data.copies?.source ? resolve(layer.base, layer.data.copies.source) : undefined))),
95
+ sets: Object.assign({}, ...pick("copies").map((copies) => copies?.sets)),
96
+ use: [...new Set(pick("copies").flatMap((copies) => copies?.use ?? []))],
97
+ },
93
98
  };
99
+ if (config.name === undefined)
100
+ delete config.name;
94
101
  if (Object.keys(config.expo ?? {}).length === 0)
95
102
  delete config.expo;
96
103
  return { config, sources };
@@ -1,6 +1,6 @@
1
1
  import { z } from "zod";
2
2
  export const KINDS = ["expo-app", "web", "content"];
3
- export const MODULES = ["docs", "design", "knowledge", "metrics", "code", "architecture", "listing"];
3
+ export const MODULES = ["docs", "design", "knowledge", "metrics", "code", "architecture", "listing", "refs"];
4
4
  /**
5
5
  * The wiki page types every repo has, and the folder each lives in. A repo adds more under `wiki.types`.
6
6
  * No flow type: a journey worth keeping is a concept. A surface page is a screen's notes, only where it has real ones.
@@ -18,11 +18,6 @@ export const REMOVED_WIKI_FOLDERS = {
18
18
  };
19
19
  /** The `.context/` folders every repo may have. A repo adds more under `context.folders`. */
20
20
  export const CONTEXT_FOLDERS = ["raw", "wiki", "features", "designs", "listing", "marketing", "metrics", "support", "legal", "ops"];
21
- const checkOverride = z
22
- .object({ enabled: z.boolean().default(true), reason: z.string().min(1).optional() })
23
- .refine((check) => check.enabled || check.reason !== undefined, {
24
- message: "a check turned off needs a `reason:` — an override nobody can explain is drift",
25
- });
26
21
  const principleId = z.string().regex(/^C-\d{2}$/, "principle IDs look like C-07");
27
22
  const command = z
28
23
  .object({
@@ -31,6 +26,8 @@ const command = z
31
26
  run: z.string().min(1),
32
27
  /** Only run when this path exists in the repo. */
33
28
  if: z.string().optional(),
29
+ /** Only run in a repo that uses this module. */
30
+ module: z.enum(MODULES).optional(),
34
31
  })
35
32
  .strict();
36
33
  const kebab = z.string().regex(/^[a-z0-9-]+$/, "use lowercase-kebab");
@@ -49,6 +46,20 @@ const wikiType = z
49
46
  message: "a built-in wiki type or its folder cannot be redefined — add a new one",
50
47
  });
51
48
  const contextFolder = z.object({ folder: kebab, reason }).strict();
49
+ /** A file copied from the source repo: the same path on both sides, or `from` one path `to` another. */
50
+ const copyFile = z.union([
51
+ z.string().min(1),
52
+ z
53
+ .object({
54
+ from: z.string().min(1),
55
+ to: z.string().min(1).optional(),
56
+ /** Identical except that the source product's name reads as this product's. */
57
+ named: z.boolean().optional(),
58
+ /** Compared only above the first line containing this text; below it is the product's own. */
59
+ until: z.string().min(1).optional(),
60
+ })
61
+ .strict(),
62
+ ]);
52
63
  const stage = z
53
64
  .object({
54
65
  id: kebab,
@@ -73,17 +84,23 @@ export const layerSchema = z
73
84
  standards: z.array(z.string()),
74
85
  commands: z.array(command),
75
86
  doctor: z.object({ tools: z.array(tool) }).partial(),
76
- checks: z.record(z.string(), checkOverride),
77
87
  weigh: z.object({ weights: z.record(principleId, z.number().nonnegative()) }).partial(),
78
88
  wiki: z.object({ types: z.array(wikiType) }).partial(),
79
89
  context: z.object({ folders: z.array(contextFolder) }).partial(),
80
90
  /** The stages work goes through, in order. A more specific layer replaces the whole list. */
81
91
  pipeline: z.array(stage),
92
+ /** How the product is written in prose, for files shared with only the name changed. */
93
+ name: z.string().min(1),
94
+ /**
95
+ * Files copied from one source repo. The estate names sets of them; a product lists the sets it
96
+ * uses, and only those are held equal to the source.
97
+ */
98
+ copies: z.object({ source: z.string().min(1), sets: z.record(kebab, z.array(copyFile).min(1)), use: z.array(kebab) }).partial().strict(),
82
99
  })
83
100
  .partial()
84
101
  .strict();
85
102
  export const MODULES_BY_KIND = {
86
- "expo-app": ["docs", "knowledge", "code", "listing"],
87
- web: ["docs", "knowledge", "code"],
88
- content: ["docs", "knowledge"],
103
+ "expo-app": ["docs", "knowledge", "code", "listing", "refs"],
104
+ web: ["docs", "knowledge", "code", "refs"],
105
+ content: ["docs", "knowledge", "refs"],
89
106
  };
@@ -18,7 +18,7 @@ export function formatReports(reports, options = {}) {
18
18
  errors += count.errors;
19
19
  warnings += count.warnings;
20
20
  const clean = report.findings.length === 0;
21
- if (clean && options.quiet && report.overrides.length === 0)
21
+ if (clean && options.quiet)
22
22
  continue;
23
23
  const summary = clean ? "ok" : `${plural(count.errors, "error")}, ${plural(count.warnings, "warning")}`;
24
24
  lines.push(`${report.name} ${summary}`);
@@ -26,8 +26,6 @@ export function formatReports(reports, options = {}) {
26
26
  const message = finding.message.replace(/\n/g, "\n ");
27
27
  lines.push(` ${MARK[finding.level]} ${finding.check} ${finding.file ?? ""} ${message}`.replace(/ +$/, ""));
28
28
  }
29
- for (const override of report.overrides)
30
- lines.push(` override ${override.check}: ${override.reason}`);
31
29
  }
32
30
  lines.push(`${plural(reports.length, "product")} · ${plural(errors, "error")} · ${plural(warnings, "warning")}`);
33
31
  return lines.join("\n");
package/cli/dist/index.js CHANGED
@@ -4,7 +4,9 @@ import { Command } from "commander";
4
4
  import { registerAdd } from "./commands/add.js";
5
5
  import { registerApprove } from "./commands/approve.js";
6
6
  import { registerCheck } from "./commands/check.js";
7
+ import { registerCopies } from "./commands/copies.js";
7
8
  import { registerDoctor } from "./commands/doctor.js";
9
+ import { registerHooks } from "./commands/hooks.js";
8
10
  import { registerInit } from "./commands/init.js";
9
11
  import { registerLearn } from "./commands/learn.js";
10
12
  import { registerStandards } from "./commands/standards.js";
@@ -24,10 +26,12 @@ const program = new Command("zeno")
24
26
  registerInit(program, env);
25
27
  registerAdd(program, env);
26
28
  registerCheck(program, env);
29
+ registerCopies(program, env);
27
30
  registerSync(program, env);
28
31
  registerStatus(program, env);
29
32
  registerStandards(program, env);
30
33
  registerDoctor(program);
34
+ registerHooks(program, env);
31
35
  registerUi(program, env);
32
36
  registerLearn(program, env);
33
37
  registerApprove(program);
package/cli/dist/init.js CHANGED
@@ -69,7 +69,7 @@ const FIRST_FILE = {
69
69
  function template(file) {
70
70
  switch (file) {
71
71
  case ".context/README.md":
72
- return "# .context\n\nProduct material, in the ten folders of the zeno context layout.\n\n## Absent folders\n\n- listing/ — n/a — no store presence yet\n- marketing/, metrics/, support/, legal/ — n/a — not live yet\n- ops/ — n/a — nothing to run yet\n";
72
+ return "# .context\n\nProduct material, in the zeno context layout. Start at [`tasks.md`](tasks.md).\n";
73
73
  case ".context/tasks.md":
74
74
  return "# Tasks\n\n## Now\n\n## Blocked\n\n## Waiting\n";
75
75
  case ".context/wiki/index.md":
package/cli/dist/run.js CHANGED
@@ -4,7 +4,7 @@ import { ALL_CHECKS } from "./checks/index.js";
4
4
  import { loadConfig } from "./config/load.js";
5
5
  import { isDirectory } from "./lib/files.js";
6
6
  export function runChecks(repo, options) {
7
- const report = { repo, name: basename(repo), findings: [], skipped: [], overrides: [] };
7
+ const report = { repo, name: basename(repo), findings: [], skipped: [] };
8
8
  if (!isDirectory(repo)) {
9
9
  report.findings.push({ check: "repo", level: "error", message: `no folder at ${repo}` });
10
10
  return report;
@@ -18,11 +18,6 @@ export function runChecks(repo, options) {
18
18
  return report;
19
19
  }
20
20
  for (const check of options.checks ?? ALL_CHECKS) {
21
- const override = config.checks[check.id];
22
- if (override && !override.enabled) {
23
- report.overrides.push({ check: check.id, reason: override.reason ?? "" });
24
- continue;
25
- }
26
21
  if (check.module && !config.modules.includes(check.module)) {
27
22
  report.skipped.push({ check: check.id, why: `module ${check.module} is off` });
28
23
  continue;
@@ -21,7 +21,6 @@ export function buildStatus(repo, options) {
21
21
  `kind ${config.kind}`,
22
22
  `modules ${config.modules.join(", ")}`,
23
23
  `config ${configLine}`,
24
- `checks ${errors} error${errors === 1 ? "" : "s"}, ${warnings} warning${warnings === 1 ? "" : "s"}` +
25
- (report.overrides.length > 0 ? `, ${report.overrides.length} override(s)` : ""),
24
+ `checks ${errors} error${errors === 1 ? "" : "s"}, ${warnings} warning${warnings === 1 ? "" : "s"}`,
26
25
  ].join("\n");
27
26
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@freetesh/zeno",
3
- "version": "0.11.0",
3
+ "version": "0.13.0",
4
4
  "description": "zeno CLI: keep your domain knowledge and AI rules in your repo, and enforce them.",
5
5
  "license": "MIT",
6
6
  "author": "Ritesh Firodiya",
@@ -7,7 +7,7 @@ folder.
7
7
 
8
8
  ```
9
9
  <repo>/.context/
10
- ├── README.md what this is, and which folders are absent and why
10
+ ├── README.md what this is, and where to start
11
11
  ├── tasks.md the ordered queue. The top item is the next action.
12
12
  ├── raw/ sources you didn't write. Immutable.
13
13
  ├── wiki/ everything you concluded. Schema: wiki-schema.md
@@ -30,7 +30,8 @@ folder.
30
30
  with the reason: `context: { folders: [{ folder: research, reason: … }] }`.
31
31
  The ten cannot be removed.
32
32
  2. **`README.md` and `tasks.md` are the only files at `.context/` root.**
33
- `README.md` lists every absent folder as `n/a — <why>`.
33
+ A repo names only what it uses. Its README, its `zeno.yaml` and its docs never
34
+ list a folder, file or tool it does without; absence is shown by absence.
34
35
  3. **Nothing is created empty.** A folder appears with its first file — git does
35
36
  not track empty folders, so a placeholder disappears on clone. `raw/` is the
36
37
  one exception and carries a `.gitkeep`.