@freetesh/zeno 0.10.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.
@@ -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
+ };
@@ -2,8 +2,11 @@ import { readFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { listFiles } from "../lib/files.js";
4
4
  import { parseFrontmatter } from "../lib/frontmatter.js";
5
- import { WIKI_FOLDER_BY_TYPE } from "../config/schema.js";
6
- const REQUIRED_FIELDS = ["id", "type", "status", "last-verified"];
5
+ import { REMOVED_WIKI_FOLDERS, WIKI_FOLDER_BY_TYPE } from "../config/schema.js";
6
+ const REQUIRED_FIELDS = ["last-verified"];
7
+ // Filler an agent would read as fact: a section with nothing to say is deleted, not stubbed.
8
+ const PLACEHOLDER = /\bTBD\b|\bnone yet\b|\bpopulate when\b/i;
9
+ const CODE_BLOCK = /```[\s\S]*?```/g;
7
10
  const STALE_AFTER_DAYS = 90;
8
11
  const DAY_MS = 86_400_000;
9
12
  const NOT_PAGES = ["index.md", "CLAUDE.md"];
@@ -18,7 +21,7 @@ function asDate(value) {
18
21
  export const wikiFrontmatter = {
19
22
  id: "wiki-frontmatter",
20
23
  module: "docs",
21
- description: "every wiki page has id, type, status and last-verified; type matches folder; ids are unique; nothing stale",
24
+ description: "every wiki page has last-verified and no placeholder text; a type, if set, matches its folder; ids are unique; nothing stale",
22
25
  run({ repo, config, today }) {
23
26
  const folderByType = { ...WIKI_FOLDER_BY_TYPE, ...Object.fromEntries(config.wiki.types.map((entry) => [entry.type, entry.folder])) };
24
27
  // Built-in types' sections are linted by the wiki-lint skill; added types carry theirs in config.
@@ -28,6 +31,9 @@ export const wikiFrontmatter = {
28
31
  const filesById = new Map();
29
32
  const stale = [];
30
33
  for (const file of wikiPages(repo)) {
34
+ const folder = file.split("/")[2];
35
+ if (folder in REMOVED_WIKI_FOLDERS)
36
+ continue; // context-size reports the folder once
31
37
  const { data, body } = parseFrontmatter(readFileSync(join(repo, file), "utf8"));
32
38
  if (!data) {
33
39
  add("error", file, "no frontmatter");
@@ -36,10 +42,11 @@ export const wikiFrontmatter = {
36
42
  const missing = REQUIRED_FIELDS.filter((field) => data[field] === undefined || data[field] === null);
37
43
  if (missing.length > 0)
38
44
  add("error", file, `missing ${missing.join(", ")}`);
39
- const folder = file.split("/")[2];
40
45
  const expected = folderByType[String(data.type)];
41
46
  if (data.type && expected !== folder)
42
47
  add("error", file, `type ${data.type} does not belong in ${folder}/`);
48
+ if (!data.type && !Object.values(folderByType).includes(folder))
49
+ add("error", file, `${folder}/ is not a wiki folder`);
43
50
  const sections = sectionsByType.get(String(data.type));
44
51
  if (sections) {
45
52
  const headings = new Set(body.split("\n").flatMap((line) => (line.startsWith("## ") ? [line.slice(3).trim()] : [])));
@@ -47,12 +54,15 @@ export const wikiFrontmatter = {
47
54
  if (absent.length > 0)
48
55
  add("error", file, `missing sections: ${absent.join(", ")}`);
49
56
  }
50
- if (data.status === "superseded" && !data["superseded-by"])
51
- add("error", file, "superseded without superseded-by");
57
+ const placeholder = body.replace(CODE_BLOCK, "").match(PLACEHOLDER);
58
+ if (placeholder)
59
+ add("warn", file, `placeholder "${placeholder[0]}" — write the fact or delete the section`);
52
60
  const verified = asDate(data["last-verified"]);
53
61
  const age = verified ? Math.floor((today.getTime() - verified.getTime()) / DAY_MS) : 0;
54
62
  if (verified && age > STALE_AFTER_DAYS)
55
63
  stale.push({ file, age });
64
+ if (verified && age < 0)
65
+ add("error", file, `last-verified ${data["last-verified"]} is in the future`);
56
66
  if (data.id)
57
67
  filesById.set(String(data.id), [...(filesById.get(String(data.id)) ?? []), file]);
58
68
  }
@@ -0,0 +1,20 @@
1
+ import { resolve } from "node:path";
2
+ import { loadConfig } from "../config/load.js";
3
+ import { recordApproval } from "../pipeline.js";
4
+ export function registerApprove(program) {
5
+ program
6
+ .command("approve")
7
+ .description("record that a person reviewed and approved a pipeline gate, dated now (a person runs this, not an agent)")
8
+ .argument("<stage>", "the gated stage's id in zeno.yaml `pipeline:`")
9
+ .argument("[repo]", "product repo", ".")
10
+ .option("--note <text>", "what was reviewed", "")
11
+ .action((stageId, repo, options) => {
12
+ const root = resolve(repo);
13
+ const gates = loadConfig(root).config.pipeline.filter((stage) => stage.gate).map((stage) => stage.id);
14
+ if (!gates.includes(stageId)) {
15
+ throw new Error(`"${stageId}" is not a gated stage here — gates: ${gates.join(", ") || "none (no pipeline: in zeno.yaml)"}`);
16
+ }
17
+ const approval = recordApproval(root, stageId, options.note);
18
+ console.log(`approved ${stageId} at ${approval.at}`);
19
+ });
20
+ }
@@ -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
+ }
@@ -89,7 +89,16 @@ export function loadConfig(repo, options = {}) {
89
89
  weigh: { weights: Object.assign({}, ...pick("weigh").map((weigh) => weigh?.weights)) },
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
+ 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
+ },
92
99
  };
100
+ if (config.name === undefined)
101
+ delete config.name;
93
102
  if (Object.keys(config.expo ?? {}).length === 0)
94
103
  delete config.expo;
95
104
  return { config, sources };
@@ -1,15 +1,21 @@
1
1
  import { z } from "zod";
2
2
  export const KINDS = ["expo-app", "web", "content"];
3
3
  export const MODULES = ["docs", "design", "knowledge", "metrics", "code", "architecture", "listing"];
4
- /** The wiki page types every repo has, and the folder each lives in. A repo adds more under `wiki.types`. */
4
+ /**
5
+ * The wiki page types every repo has, and the folder each lives in. A repo adds more under `wiki.types`.
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.
7
+ */
5
8
  export const WIKI_FOLDER_BY_TYPE = {
6
9
  concept: "concepts",
7
10
  entity: "entities",
8
11
  surface: "surfaces",
9
- flow: "flows",
10
12
  decision: "decisions",
11
13
  synthesis: "synthesis",
12
14
  };
15
+ /** Wiki folders that no longer exist, and what to do with what was in them. */
16
+ export const REMOVED_WIKI_FOLDERS = {
17
+ flows: "fold a journey worth keeping into a concept page",
18
+ };
13
19
  /** The `.context/` folders every repo may have. A repo adds more under `context.folders`. */
14
20
  export const CONTEXT_FOLDERS = ["raw", "wiki", "features", "designs", "listing", "marketing", "metrics", "support", "legal", "ops"];
15
21
  const checkOverride = z
@@ -43,6 +49,34 @@ const wikiType = z
43
49
  message: "a built-in wiki type or its folder cannot be redefined — add a new one",
44
50
  });
45
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();
71
+ const stage = z
72
+ .object({
73
+ id: kebab,
74
+ /** Paths that exist once the stage has started; any one is enough. */
75
+ done: z.array(z.string().min(1)).min(1),
76
+ /** A person must approve this stage (`zeno approve <id>`) before any later stage starts. */
77
+ gate: z.boolean().default(false),
78
+ })
79
+ .strict();
46
80
  const tool = z.object({ tool: z.string(), why: z.string(), hint: z.string() }).strict();
47
81
  /** One layer of `.claude/zeno.yaml` as written: every field optional. */
48
82
  export const layerSchema = z
@@ -62,6 +96,12 @@ export const layerSchema = z
62
96
  weigh: z.object({ weights: z.record(principleId, z.number().nonnegative()) }).partial(),
63
97
  wiki: z.object({ types: z.array(wikiType) }).partial(),
64
98
  context: z.object({ folders: z.array(contextFolder) }).partial(),
99
+ /** The stages work goes through, in order. A more specific layer replaces the whole list. */
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(),
65
105
  })
66
106
  .partial()
67
107
  .strict();
package/cli/dist/index.js CHANGED
@@ -2,8 +2,11 @@
2
2
  import { resolve } from "node:path";
3
3
  import { Command } from "commander";
4
4
  import { registerAdd } from "./commands/add.js";
5
+ import { registerApprove } from "./commands/approve.js";
5
6
  import { registerCheck } from "./commands/check.js";
7
+ import { registerCopies } from "./commands/copies.js";
6
8
  import { registerDoctor } from "./commands/doctor.js";
9
+ import { registerHooks } from "./commands/hooks.js";
7
10
  import { registerInit } from "./commands/init.js";
8
11
  import { registerLearn } from "./commands/learn.js";
9
12
  import { registerStandards } from "./commands/standards.js";
@@ -23,12 +26,15 @@ const program = new Command("zeno")
23
26
  registerInit(program, env);
24
27
  registerAdd(program, env);
25
28
  registerCheck(program, env);
29
+ registerCopies(program, env);
26
30
  registerSync(program, env);
27
31
  registerStatus(program, env);
28
32
  registerStandards(program, env);
29
33
  registerDoctor(program);
34
+ registerHooks(program, env);
30
35
  registerUi(program, env);
31
36
  registerLearn(program, env);
37
+ registerApprove(program);
32
38
  registerWeigh(program);
33
39
  program.parseAsync().catch((error) => {
34
40
  console.error(`zeno: ${error.message}`);
package/cli/dist/init.js CHANGED
@@ -66,12 +66,12 @@ const FIRST_FILE = {
66
66
  wiki: ".context/wiki/index.md",
67
67
  features: ".context/features/index.md",
68
68
  };
69
- function template(file, today) {
69
+ function template(file) {
70
70
  switch (file) {
71
71
  case ".context/README.md":
72
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";
73
73
  case ".context/tasks.md":
74
- return `---\nupdated: ${today}\nhorizon: ${today}\n---\n\n# Tasks\n\n## Now\n\n## Parallel\n\n## Blocked\n\n## Waiting\n\n## Done\n\n## Session Notes\n`;
74
+ return "# Tasks\n\n## Now\n\n## Blocked\n\n## Waiting\n";
75
75
  case ".context/wiki/index.md":
76
76
  return "# Wiki index\n\nPages by type. Schema: the zeno plugin's wiki-schema standard.\n";
77
77
  case ".context/features/index.md":
@@ -98,7 +98,6 @@ export function planInit(repo, answers) {
98
98
  return { config, create };
99
99
  }
100
100
  export function applyInit(repo, plan, options) {
101
- const today = options.today ?? new Date().toISOString().slice(0, 10);
102
101
  const write = (file, content) => {
103
102
  mkdirSync(dirname(join(repo, file)), { recursive: true });
104
103
  writeFileSync(join(repo, file), content);
@@ -107,7 +106,7 @@ export function applyInit(repo, plan, options) {
107
106
  const starters = new Map(architectureStarters(repo).map((s) => [s.file, s.template]));
108
107
  for (const file of plan.create) {
109
108
  const starter = starters.get(file);
110
- write(file, starter ? readFileSync(join(options.zenoRoot, ARCHITECTURE_TEMPLATES, starter), "utf8") : template(file, today));
109
+ write(file, starter ? readFileSync(join(options.zenoRoot, ARCHITECTURE_TEMPLATES, starter), "utf8") : template(file));
111
110
  }
112
111
  if (plan.config.modules.includes("knowledge"))
113
112
  indexKnowledge(repo, options.name, options.run);
@@ -0,0 +1,20 @@
1
+ import { appendFileSync, existsSync, mkdirSync, readFileSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ // In raw/, which is append-only: the dates are the record of how long each stage waited.
4
+ export const GATES_LOG = join(".context", "raw", "zeno", "gates.jsonl");
5
+ export function readApprovals(repo) {
6
+ const path = join(repo, GATES_LOG);
7
+ if (!existsSync(path))
8
+ return [];
9
+ return readFileSync(path, "utf8")
10
+ .split("\n")
11
+ .filter((line) => line.trim())
12
+ .map((line) => JSON.parse(line));
13
+ }
14
+ export function recordApproval(repo, stage, note) {
15
+ const approval = { stage, at: new Date().toISOString(), note };
16
+ const path = join(repo, GATES_LOG);
17
+ mkdirSync(dirname(path), { recursive: true });
18
+ appendFileSync(path, `${JSON.stringify(approval)}\n`);
19
+ return approval;
20
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@freetesh/zeno",
3
- "version": "0.10.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",
@@ -15,9 +15,8 @@ Question: `$ARGUMENTS`
15
15
  - `entity` pages for "what is the value / price / name of X"
16
16
  - `synthesis` pages for cross-cutting summaries (use these before chasing individual entities)
17
17
  - `concept` pages for "why" or "what does X mean" questions
18
- - `surface` pages for "what does this screen do"
19
- - `flow` pages for "how does the user get from A to B"
20
- - `decision` pages for "why did we choose X over Y"
18
+ - `decision` pages for "why did we choose X over Y", and its `Rejected:` line for "did we consider Y"
19
+ - for "what does this screen do", the HTML in `.context/designs/` is the spec; its `surfaces/` note, if any, says why
21
20
 
22
21
  3. **Read the candidate pages.** Follow `[[wikilinks]]` one hop if the answer is incomplete. Stop at two hops — if you need three, the wiki is missing a synthesis page; say so.
23
22
 
@@ -35,8 +34,7 @@ Question: `$ARGUMENTS`
35
34
 
36
35
  ## Rules
37
36
 
38
- - **Never read `.context/raw/` or `.context/designs/` from this skill.** If the answer requires raw inspection, refuse and recommend `/ingest`.
37
+ - **Never read `.context/raw/` from this skill.** If the answer requires raw inspection, recommend `/ingest`. Designs may be read for screen questions.
39
38
  - **Never invent facts.** If a canonical value isn't in an `entity` page, say "not in the wiki".
40
39
  - **Every cited page must actually exist.** Don't fabricate a `[[wikilink]]` to make the answer look authoritative.
41
- - A page with `status: superseded` is not canonical — follow `superseded-by` to the current page.
42
40
  - A page with `last-verified` > 90 days is stale — flag it in the answer ("last verified 2025-11-04 — may be out of date").
@@ -1,33 +1,36 @@
1
1
  ---
2
2
  name: decision
3
- description: Record a product or technical decision as a new ADR page in .context/wiki/decisions/. Usage `/decision <title> — <body>`.
3
+ description: Record or change a product or technical decision in .context/wiki/decisions/<topic>.md — one file per topic, overwritten when the choice changes. Usage `/decision <topic> — <choice and why>`.
4
4
  ---
5
5
 
6
- You are logging a decision as a new `.context/wiki/decisions/<YYYY-MM-DD>-<slug>.md` page.
6
+ You are recording a decision in `.context/wiki/decisions/<topic>.md`.
7
7
 
8
- Decision to record: `$ARGUMENTS`
8
+ Decision: `$ARGUMENTS`
9
9
 
10
10
  ## Steps
11
11
 
12
- 1. Get the shape. If `.context/wiki/decisions/` already holds pages, open the most recent one and match its frontmatter and sections — that is enough. Only when there is none, read two parts of the wiki schema (the `wiki-schema.md` that `zeno standards` lists — a team's own replaces zeno's; without the CLI, `../../standards/wiki-schema.md` relative to this skill): § "Frontmatter" and § "`decision`". Find their line numbers with Grep and Read just those ranges, not the whole file.
13
-
14
- 2. Parse `$ARGUMENTS`:
15
- - If it contains ` — ` (em-dash with spaces), split on the first occurrence: left = title, right = body.
16
- - Otherwise treat the whole string as the title; body = "(no detail provided)".
17
-
18
- 3. Derive the filename:
19
- - Date = today's `currentDate` from the session context (`YYYY-MM-DD`).
20
- - Slug = kebab-case of the title, lower-cased, alphanumerics + hyphens only, ≤ 60 chars.
21
- - Path = `.context/wiki/decisions/<date>-<slug>.md`. If a file at that path already exists, append `-2`, `-3`, etc.
22
-
23
- 4. Write the new ADR with the standard frontmatter and the body the user provided. Do not summarise or shorten the body.
24
-
25
- 5. Add the page to `.context/wiki/index.md` under Decisions.
26
-
27
- 6. Confirm: `decision logged: "<title>" → <path>`.
12
+ 1. Split `$ARGUMENTS` on the first ` — `: left = topic, right = the choice and why. With no ` — `, ask for the choice.
13
+ 2. Slug = the topic in kebab-case, no date (`pricing`, `auth-provider`). Look for an existing file on the same topic first, under any name. If there is one, it is the file to change.
14
+ 3. Write the file, at most 30 lines:
15
+
16
+ ```markdown
17
+ ---
18
+ last-verified: <today>
19
+ ---
20
+ # <Topic>
21
+ **Choice:** <one or two lines; link values to their entity page>
22
+ **Why:** <the trigger: data, incident, user feedback, cost>
23
+ **Rejected:** <option (why)> · <option (why)>
24
+ ```
25
+
26
+ When changing an existing decision, replace the choice and why, and move the
27
+ old choice into `Rejected:` with the reason it was dropped. Keep the rest of `Rejected:`.
28
+ 4. Put a price, SKU or limit on its entity page, not here (`canonical-values` checks this).
29
+ 5. Add or update the one line in `.context/wiki/index.md`.
30
+ 6. Confirm: `decision recorded: <topic> → <path>`.
28
31
 
29
32
  ## Rules
30
33
 
31
- - One decision per file. Never append to an existing ADR.
32
- - Do not edit any other wiki page in the same command — if cross-references are needed, surface them in the body as `[[wikilinks]]`, but leave the target pages alone.
33
- - The wiki is the only source of truth for decisions. There is no monolithic decision log.
34
+ - One file per topic, overwritten. Git keeps the old text; never add a dated or `-2` file.
35
+ - No fix lists, audit results or step-by-step history. Those go in the commit message or `CHANGELOG.md`.
36
+ - If the user's text is long, keep the choice, the reason and what was rejected, and drop the rest. Show what you dropped.
@@ -9,28 +9,27 @@ Source to ingest: `$ARGUMENTS`
9
9
 
10
10
  ## Steps
11
11
 
12
- 1. **Read the wiki schema (the `wiki-schema.md` that `zeno standards` lists — a team's own replaces zeno's; without the CLI, `../../standards/wiki-schema.md` relative to this skill) first.** It defines page types, frontmatter, required sections, ID conventions, and lint rules. Every page you write must conform.
12
+ 1. **Read the wiki schema (the `wiki-schema.md` that `zeno standards` lists — a team's own replaces zeno's; without the CLI, `../../standards/wiki-schema.md` relative to this skill) first.** It defines the folders, the one test for what belongs, and the caps. Every page you write must conform.
13
13
 
14
14
  2. **Read the raw source** at the path in `$ARGUMENTS`. Accepted: anything under `.context/raw/` or `.context/designs/`, and application source in this repository. Refuse paths outside the repo.
15
15
 
16
16
  3. **Classify** the source into one or more wiki page types:
17
- - HTML wireframe → `surface` page
18
- - Decision note → `decision` page
17
+ - HTML wireframe → nothing; the HTML is the spec. Only a constraint it can't show becomes that screen's `surfaces/<slug>.md` note.
18
+ - Decision note → the `decisions/<topic>.md` file, via the `decision` skill's format
19
19
  - Research doc / interview → typically `concept` pages (split if it covers multiple ideas), or one `synthesis` page for a brief that draws a conclusion
20
20
  - A new tier / product / canonical value → `entity` page
21
- - A user journey description → `flow` page
21
+ - A user journey description → a concept, only if it holds a rule the designs don't show
22
+ - Write nothing an agent could read from the code, the designs or git.
22
23
  - Don't force one source into one page — a meeting note can produce multiple pages.
23
- - Page types are the six in the schema plus any listed under `wiki.types` in the repo's or the estate's `.claude/zeno.yaml`, each with its folder and its required `sections` (use exactly those `## ` headings, in that order; its ID is `<TYPE>.<SLUG>`). Use an added type only when the source fits it.
24
+ - Folders are the four in the schema plus any listed under `wiki.types` in the repo's or the estate's `.claude/zeno.yaml`, each with its folder and its required `sections` (use its `sections` headings). Use an added type only when the source fits it.
24
25
 
25
26
  4. **For each page you will create:**
26
- - Pick the ID per the wiki schema § "ID conventions". IDs are uppercase, dot-separated, never change.
27
- - Pick the filename: the slug portion of the ID, lowercase, kebab-case, `.md` extension.
28
- - Check whether the page already exists. If yes, **update** rather than overwrite: preserve `id`, `supersedes`, `superseded-by`; refresh `last-verified` to today.
29
- - Use the required sections for that page type — verbatim from the wiki schema. Missing a required section is a lint failure.
30
- - Fill `Related` with at least two `[[wikilinks]]`. A link to a page that doesn't exist yet is OK — `/wiki-lint` will flag it as a TODO.
31
- - List the source path under `Sources`, so a future `/wiki-lint` can tell which raw files nothing has read.
27
+ - Filename: the topic, lowercase kebab-case, no date.
28
+ - Check whether a page on the topic already exists. If yes, edit it in place and refresh `last-verified` to today. Keep any `id`.
29
+ - Write only sections with something to say. No placeholders. Stay under the caps (page 60 lines).
30
+ - Link related pages with `[[slug]]` where it helps a reader. List the source path under `Sources`.
32
31
 
33
- 5. **Update `.context/wiki/index.md`:** add the new page under the right type heading, in alphabetical order by slug. Don't reformat existing entries.
32
+ 5. **Update `.context/wiki/index.md`:** one line for the new page, under its folder heading, alphabetical. Don't reformat existing entries.
34
33
 
35
34
  6. **Leave the source where it is.** `.context/raw/` is immutable: a source is "processed" when a wiki page lists it under `Sources`, not when it moves.
36
35
 
@@ -39,7 +38,7 @@ Source to ingest: `$ARGUMENTS`
39
38
  ## Rules
40
39
 
41
40
  - Never edit anything under `.context/raw/` or `.context/designs/`.
42
- - Never delete or rename an existing wiki page from this skill — use a `decision` page to log a rename, then run again.
41
+ - Never delete or rename an existing wiki page from this skill.
43
42
  - Never invent canonical values. If the source has a price/string/enum, copy it verbatim; if it's ambiguous, ask the user before writing.
44
43
  - Follow the copy rules from the root `CLAUDE.md` for any user-facing strings you transcribe.
45
44
  - Today's date is in the session context — use it for `last-verified`.
@@ -9,10 +9,11 @@ Mode: `$ARGUMENTS` (empty = audit only; `--fix` = audit + auto-fix safe issues).
9
9
 
10
10
  ## Steps
11
11
 
12
- 1. **Read the wiki schema** to refresh schema, ID conventions, required sections, and the lint-rules table.
12
+ 1. **Read the wiki schema** to refresh the folders, the one test and the caps.
13
13
 
14
14
  2. **Run `zeno check` first** if the CLI is installed. It checks frontmatter,
15
- duplicate IDs, staleness and prices held by no entity in under a second,
15
+ placeholders, caps, dated decision names, duplicate IDs, staleness and
16
+ prices held by no entity in under a second,
16
17
  plus any checker the repo declares. Start from its output rather than
17
18
  re-deriving it.
18
19
 
@@ -29,30 +30,27 @@ Mode: `$ARGUMENTS` (empty = audit only; `--fix` = audit + auto-fix safe issues).
29
30
 
30
31
  | Check | Rule |
31
32
  |---|---|
32
- | Missing required section | Per the wiki schema § "Page body — required sections" |
33
33
  | Broken `[[wikilink]]` | Target page does not exist |
34
34
  | Duplicate `id` | Two pages claim the same ID |
35
35
  | Contradictory canonical value | Two `entity` pages list the same key with different values |
36
- | `status: superseded` with no `superseded-by` | Set or remove the status |
37
- | Filename ↔ id slug mismatch | The filename must match the slug portion of the ID |
38
- | `type` does not match folder | `type: concept` must live in `concepts/`, etc. A type added under `wiki.types` in `.claude/zeno.yaml` lives in its configured folder and has every heading in its `sections`. |
36
+ | Placeholder text | `TBD`, `none yet`: write the fact or delete the section |
37
+ | `type` does not match folder | If a page sets `type`, it lives in that type's folder |
39
38
 
40
39
  ### Warnings
41
40
 
42
41
  | Check | Rule |
43
42
  |---|---|
44
- | Orphan page | Zero inbound `[[wikilinks]]`. Exception: `decision` and `synthesis` pages are commonly orphan-source. |
43
+ | Over a cap | Page 60 lines, decision 30, README 30, tasks 100 |
44
+ | Restates code, a design or git | Delete the lines; link the source instead |
45
+ | Two pages on one topic | Merge into one, edited in place |
45
46
  | Stale `last-verified` | > 90 days from today. |
46
47
  | Unprocessed raw file | A file under `.context/raw/` that no wiki page references in its `Sources`. |
47
- | Stale synthesis | A `synthesis` page where any `Built from` page was modified after the synthesis's `Last refresh`. |
48
- | Page missing two outbound links | Body has fewer than two `[[wikilinks]]`. |
49
48
  | User-facing string violates copy rules | Check quoted copy against the rules in the root `CLAUDE.md`. Best-effort. |
50
49
 
51
50
  6. **Auto-fix (only if `--fix` is in `$ARGUMENTS`):**
52
51
  - Regenerate `.context/wiki/index.md` from scratch: enumerate pages by type, sort alphabetically by slug, keep the structure the file already has.
53
52
  - Fix `last-verified` to today's date on any page whose body was edited in the working tree.
54
- - Fix filename ↔ id slug mismatches (rename the file; do NOT change the ID).
55
- - **Never** auto-fix: contradictory canonical values, missing required sections, broken `[[wikilinks]]`. These need a human decision.
53
+ - **Never** auto-fix: contradictory canonical values, broken `[[wikilinks]]`, merges or cuts. These need a human decision.
56
54
 
57
55
  7. **Report.** Print:
58
56
  - Counts: pages, blockers, warnings, auto-fixes applied.
@@ -63,7 +61,7 @@ Mode: `$ARGUMENTS` (empty = audit only; `--fix` = audit + auto-fix safe issues).
63
61
  ## Rules
64
62
 
65
63
  - **Never edit pages other than auto-fixes above.** Lint reports; the user (or `/ingest`) fixes.
66
- - **Never delete pages.** Superseded pages stay on disk with `status: superseded` and `superseded-by: <new-id>`.
64
+ - **Never delete pages from this skill.** Propose the cut; the user approves it. Git keeps what is deleted.
67
65
  - **Never modify `.context/raw/`** except to warn about unprocessed files.
68
66
  - If a blocker resists auto-fix, exit non-zero (in CI mode) but always print the full report first.
69
67
  - Today's date is in the session context — use it for staleness math (>90 days).