@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.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +29 -6
- package/cli/dist/checks/architecture.js +10 -1
- package/cli/dist/checks/canonical-values.js +9 -7
- package/cli/dist/checks/context-layout.js +43 -2
- package/cli/dist/checks/context-size.js +46 -0
- package/cli/dist/checks/features.js +38 -0
- package/cli/dist/checks/index.js +14 -0
- package/cli/dist/checks/listing.js +51 -0
- package/cli/dist/checks/pipeline.js +34 -0
- package/cli/dist/checks/refs.js +87 -0
- package/cli/dist/checks/shared-files.js +96 -0
- package/cli/dist/checks/tasks.js +66 -0
- package/cli/dist/checks/wiki-frontmatter.js +16 -6
- package/cli/dist/commands/approve.js +20 -0
- package/cli/dist/commands/copies.js +23 -0
- package/cli/dist/commands/hooks.js +55 -0
- package/cli/dist/config/load.js +9 -0
- package/cli/dist/config/schema.js +42 -2
- package/cli/dist/index.js +6 -0
- package/cli/dist/init.js +3 -4
- package/cli/dist/pipeline.js +20 -0
- package/package.json +1 -1
- package/skills/ask/SKILL.md +3 -5
- package/skills/decision/SKILL.md +25 -22
- package/skills/ingest/SKILL.md +12 -13
- package/skills/wiki-lint/SKILL.md +10 -12
- package/standards/agent-contract.md +3 -2
- package/standards/context-layout.md +13 -23
- package/standards/wiki-schema.md +50 -278
|
@@ -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 = ["
|
|
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
|
|
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
|
-
|
|
51
|
-
|
|
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
|
+
}
|
package/cli/dist/config/load.js
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
|
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
|
|
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
|
|
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
package/skills/ask/SKILL.md
CHANGED
|
@@ -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
|
-
- `
|
|
19
|
-
-
|
|
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/`
|
|
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").
|
package/skills/decision/SKILL.md
CHANGED
|
@@ -1,33 +1,36 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: decision
|
|
3
|
-
description: Record a product or technical decision
|
|
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
|
|
6
|
+
You are recording a decision in `.context/wiki/decisions/<topic>.md`.
|
|
7
7
|
|
|
8
|
-
Decision
|
|
8
|
+
Decision: `$ARGUMENTS`
|
|
9
9
|
|
|
10
10
|
## Steps
|
|
11
11
|
|
|
12
|
-
1.
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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
|
|
32
|
-
-
|
|
33
|
-
-
|
|
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.
|
package/skills/ingest/SKILL.md
CHANGED
|
@@ -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
|
|
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 → `
|
|
18
|
-
- Decision note → `decision`
|
|
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 →
|
|
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
|
-
-
|
|
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
|
-
-
|
|
27
|
-
-
|
|
28
|
-
-
|
|
29
|
-
-
|
|
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`:**
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
| `
|
|
37
|
-
|
|
|
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
|
-
|
|
|
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
|
-
-
|
|
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.**
|
|
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).
|