@freetesh/zeno 0.11.0 → 0.12.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.12.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,6 +162,12 @@ 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 every product copies from one source repo
166
+ source: charades # a folder beside the estate config
167
+ files:
168
+ - scripts/play-upload.mjs # same path; a product carries it or skips it
169
+ - { from: .context/designs/mobile/index.html, to: [.context/designs/mobile/index.html, .context/designs/web/index.html], named: true }
170
+ - { from: .context/designs/mobile/shared.css, to: [.context/designs/mobile/shared.css], until: "PALETTE — " }
161
171
  ```
162
172
 
163
173
  ```yaml
@@ -170,6 +180,10 @@ checks:
170
180
  stale-copies: { enabled: false, reason: "Cursor is still used here" }
171
181
  weigh:
172
182
  weights: { C-05: 2 }
183
+ name: Chitragupt # how the product is written; `named` copies swap it in
184
+ copies:
185
+ skip: # shared files this product does without, or keeps its own of
186
+ - { file: scripts/release-android.mjs, reason: built on EAS from a tag }
173
187
  ```
174
188
 
175
189
  Scalars: the most specific layer wins. `packs` and `standards` add up across
@@ -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
  };
@@ -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,87 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { existsSync, readFileSync } from "node:fs";
3
+ import { basename, dirname, join, normalize } from "node:path";
4
+ // A `.context/` path not preceded by something that makes it part of a longer one (another
5
+ // product's, an absolute path, a URL). A leading `./` means "beside this file".
6
+ const PATH = /(?<![\w/~.-])(\.\/)?(\.context\/[\w./@+-]*)/g;
7
+ const PLACEHOLDER = /[<{*[$]|YYYY|NNNN/;
8
+ const LINK = /\[\[([^\]|#]+)(?:[|#][^\]]*)?\]\]/g;
9
+ // A line reading a path out of git history names it as it was.
10
+ const FROM_HISTORY = /git (show|checkout|log|diff|restore)\b/;
11
+ // Dated records keep a path as it was on the day.
12
+ const DATED = /(^|\/)CHANGELOG\.md$|^\.context\/ops\/incidents\//;
13
+ 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;
14
+ const FEATURE_FILE = /^\.context\/features\/(\d{4})-[^/]+\.md$/;
15
+ function git(repo, args) {
16
+ const result = spawnSync("git", ["-C", repo, ...args], { encoding: "utf8", maxBuffer: 64 * 1024 * 1024 });
17
+ return { status: result.status ?? 1, stdout: result.stdout ?? "" };
18
+ }
19
+ /** The part of a path that names something real: cut back to the last whole folder before a placeholder. */
20
+ function literalPart(token, restOfLine) {
21
+ let path = token.replace(/[.,;:]+$/, "");
22
+ const cut = PLACEHOLDER.exec(path);
23
+ if (cut || PLACEHOLDER.test(restOfLine.slice(0, 1))) {
24
+ path = cut ? path.slice(0, cut.index) : path;
25
+ path = path.slice(0, path.lastIndexOf("/") + 1);
26
+ }
27
+ return path.replace(/^[./]+|[./]+$/g, "") === "context" ? null : path;
28
+ }
29
+ export const refs = {
30
+ id: "refs",
31
+ module: null,
32
+ 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)",
33
+ run({ repo, config }) {
34
+ const listed = git(repo, ["ls-files", "-z"]);
35
+ if (listed.status !== 0)
36
+ return [];
37
+ const files = listed.stdout.split("\0").filter((file) => file && !BINARY.test(file));
38
+ // A copy of the source repo's file carries the source's paths; the product cannot edit it.
39
+ const copied = new Set(config.copies.files.flatMap((file) => (typeof file === "string" ? [file] : (file.to ?? [file.from]))));
40
+ const pages = new Set(files.filter((f) => f.startsWith(".context/wiki/") && f.endsWith(".md")).map((f) => basename(f, ".md")));
41
+ const features = new Set(files.flatMap((f) => (FEATURE_FILE.exec(f) ? [`FEAT-${FEATURE_FILE.exec(f)[1]}`] : [])));
42
+ const resolves = (target) => pages.has(target) || features.has(target) || existsSync(join(repo, ".context", `${target}.md`));
43
+ const exists = new Map();
44
+ const present = (path) => {
45
+ if (!exists.has(path)) {
46
+ exists.set(path, existsSync(join(repo, path)) || git(repo, ["check-ignore", "-q", path]).status === 0);
47
+ }
48
+ return exists.get(path);
49
+ };
50
+ const findings = [];
51
+ for (const file of files) {
52
+ if (copied.has(file))
53
+ continue;
54
+ let text;
55
+ try {
56
+ text = readFileSync(join(repo, file), "utf8");
57
+ }
58
+ catch {
59
+ continue; // deleted in the working tree, or a submodule
60
+ }
61
+ const dated = DATED.test(file);
62
+ const links = file.startsWith(".context/") && file.endsWith(".md");
63
+ text.split("\n").forEach((line, index) => {
64
+ const at = `${file}:${index + 1}`;
65
+ if (!dated && line.includes(".context/") && !FROM_HISTORY.test(line)) {
66
+ for (const match of line.matchAll(PATH)) {
67
+ let path = literalPart(match[2], line.slice(match.index + match[0].length));
68
+ if (path === null)
69
+ continue;
70
+ if (match[1])
71
+ path = normalize(join(dirname(file), path)) + (path.endsWith("/") ? "/" : "");
72
+ if (!present(path))
73
+ findings.push({ check: "refs", level: "error", file: at, message: `${path} does not exist` });
74
+ }
75
+ }
76
+ if (links && line.includes("[[")) {
77
+ for (const match of line.matchAll(LINK)) {
78
+ const target = match[1].trim();
79
+ if (!resolves(target))
80
+ findings.push({ check: "refs", level: "warn", file: at, message: `[[${target}]] names no page yet` });
81
+ }
82
+ }
83
+ });
84
+ }
85
+ return findings;
86
+ },
87
+ };
@@ -0,0 +1,96 @@
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
+ function pairsOf(file) {
7
+ if (typeof file === "string")
8
+ return [{ from: file, to: file, required: true, named: false }];
9
+ return (file.to ?? [file.from]).map((to) => ({ from: file.from, to, required: false, named: file.named ?? false, until: file.until }));
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 above(text, marker) {
18
+ const lines = text.split("\n");
19
+ const at = lines.findIndex((line) => line.includes(marker));
20
+ return (at === -1 ? lines : lines.slice(0, at)).join("\n");
21
+ }
22
+ function below(text, marker) {
23
+ const lines = text.split("\n");
24
+ const at = lines.findIndex((line) => line.includes(marker));
25
+ return at === -1 ? "" : lines.slice(at).join("\n");
26
+ }
27
+ /** Compares every shared file of `repo` with the source repo. Skipped files and files absent from the source are left out. */
28
+ export function compareCopies(repo, config) {
29
+ const { source, files, skip } = config.copies;
30
+ if (!source || files.length === 0 || resolve(source) === resolve(repo))
31
+ return [];
32
+ const skipped = new Set(skip.map((entry) => entry.file));
33
+ const productName = nameOf(repo, config);
34
+ const sourceName = nameOf(source, loadConfig(source).config);
35
+ const states = [];
36
+ for (const pair of files.flatMap(pairsOf)) {
37
+ const from = join(source, pair.from);
38
+ const to = join(repo, pair.to);
39
+ if (skipped.has(pair.to) || !existsSync(from))
40
+ continue;
41
+ if (!existsSync(to)) {
42
+ if (pair.required)
43
+ states.push({ pair, state: "missing" });
44
+ continue;
45
+ }
46
+ if (pair.named && (!productName || !sourceName)) {
47
+ states.push({ pair, state: "unnamed" });
48
+ continue;
49
+ }
50
+ const original = readFileSync(from, "utf8");
51
+ const current = readFileSync(to, "utf8");
52
+ let want = pair.named ? original.replaceAll(sourceName, productName) : original;
53
+ let have = current;
54
+ if (pair.until) {
55
+ want = above(want, pair.until);
56
+ have = above(current, pair.until);
57
+ }
58
+ if (want === have)
59
+ states.push({ pair, state: "same" });
60
+ // Without the marker the product's own part cannot be told from the shared one, so nothing is written.
61
+ else if (pair.until && !current.includes(pair.until))
62
+ states.push({ pair, state: "drift" });
63
+ else
64
+ states.push({ pair, state: "drift", want: pair.until ? `${want}\n${below(current, pair.until)}` : want });
65
+ }
66
+ return states;
67
+ }
68
+ /** Overwrites each drifted copy with the source's. Never adds a missing file. Returns the paths written. */
69
+ export function syncCopies(repo, config) {
70
+ const written = [];
71
+ for (const { pair, state, want } of compareCopies(repo, config)) {
72
+ if (state !== "drift" || want === undefined)
73
+ continue;
74
+ writeFileSync(join(repo, pair.to), want);
75
+ written.push(pair.to);
76
+ }
77
+ return written;
78
+ }
79
+ export const sharedFiles = {
80
+ id: "shared-files",
81
+ module: null,
82
+ description: "files copied from the copies.source repo are identical to it; one a product does without is skipped with a reason",
83
+ run({ repo, config }) {
84
+ const source = config.copies.source ? basename(config.copies.source) : "";
85
+ return compareCopies(repo, config).flatMap(({ pair, state }) => {
86
+ const finding = (message) => [{ check: "shared-files", level: "error", file: pair.to, message }];
87
+ if (state === "missing")
88
+ return finding(`missing — copy it from ${source}, or add it under copies.skip with a reason`);
89
+ if (state === "unnamed")
90
+ return finding(`no product name to compare with — set name: in zeno.yaml`);
91
+ if (state === "drift")
92
+ return finding(`differs from ${source}${pair.until ? " above the shared block's end" : ""} — \`zeno copies --sync\` copies it`);
93
+ return [];
94
+ });
95
+ },
96
+ };
@@ -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
+ }
@@ -90,7 +90,15 @@ export function loadConfig(repo, options = {}) {
90
90
  wiki: { types: mergeBy(pick("wiki").map((wiki) => wiki?.types ?? []), (entry) => entry.type) },
91
91
  context: { folders: mergeBy(pick("context").map((context) => context?.folders ?? []), (entry) => entry.folder) },
92
92
  pipeline: lastDefined(pick("pipeline")) ?? [],
93
+ name: lastDefined(pick("name")),
94
+ copies: {
95
+ source: lastDefined(layers.map((layer) => (layer.data.copies?.source ? resolve(layer.base, layer.data.copies.source) : undefined))),
96
+ files: lastDefined(pick("copies").map((copies) => copies?.files)) ?? [],
97
+ skip: mergeBy(pick("copies").map((copies) => copies?.skip ?? []), (entry) => entry.file),
98
+ },
93
99
  };
100
+ if (config.name === undefined)
101
+ delete config.name;
94
102
  if (Object.keys(config.expo ?? {}).length === 0)
95
103
  delete config.expo;
96
104
  return { config, sources };
@@ -49,6 +49,25 @@ const wikiType = z
49
49
  message: "a built-in wiki type or its folder cannot be redefined — add a new one",
50
50
  });
51
51
  const contextFolder = z.object({ folder: kebab, reason }).strict();
52
+ /**
53
+ * A file every product carries a copy of. A plain path is the same path on both sides and every
54
+ * product must carry it or skip it with a reason; an object maps one source file onto product
55
+ * paths that exist only where the product has that part (a design surface, say).
56
+ */
57
+ const copyFile = z.union([
58
+ z.string().min(1),
59
+ z
60
+ .object({
61
+ from: z.string().min(1),
62
+ to: z.array(z.string().min(1)).min(1).optional(),
63
+ /** Identical except that the source product's name reads as this product's. */
64
+ named: z.boolean().optional(),
65
+ /** Compared only above the first line containing this text; below it is the product's own. */
66
+ until: z.string().min(1).optional(),
67
+ })
68
+ .strict(),
69
+ ]);
70
+ const copySkip = z.object({ file: z.string().min(1), reason }).strict();
52
71
  const stage = z
53
72
  .object({
54
73
  id: kebab,
@@ -79,6 +98,10 @@ export const layerSchema = z
79
98
  context: z.object({ folders: z.array(contextFolder) }).partial(),
80
99
  /** The stages work goes through, in order. A more specific layer replaces the whole list. */
81
100
  pipeline: z.array(stage),
101
+ /** How the product is written in prose, for files shared with only the name changed. */
102
+ name: z.string().min(1),
103
+ /** Files copied from one source repo into every product, and the ones a product does without. */
104
+ copies: z.object({ source: z.string().min(1), files: z.array(copyFile), skip: z.array(copySkip) }).partial().strict(),
82
105
  })
83
106
  .partial()
84
107
  .strict();
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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@freetesh/zeno",
3
- "version": "0.11.0",
3
+ "version": "0.12.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",