@freetesh/canon 0.0.0-stage → 0.15.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.
Files changed (134) hide show
  1. package/.claude-plugin/marketplace.json +15 -0
  2. package/.claude-plugin/plugin.json +5 -0
  3. package/LICENSE +21 -0
  4. package/README.md +146 -2
  5. package/cli/dist/api.d.ts +3 -0
  6. package/cli/dist/api.js +4 -0
  7. package/cli/dist/checks/architecture.d.ts +23 -0
  8. package/cli/dist/checks/architecture.js +135 -0
  9. package/cli/dist/checks/canonical-values.d.ts +2 -0
  10. package/cli/dist/checks/canonical-values.js +48 -0
  11. package/cli/dist/checks/context-layout.d.ts +5 -0
  12. package/cli/dist/checks/context-layout.js +83 -0
  13. package/cli/dist/checks/context-size.d.ts +2 -0
  14. package/cli/dist/checks/context-size.js +46 -0
  15. package/cli/dist/checks/copies.d.ts +3 -0
  16. package/cli/dist/checks/copies.js +73 -0
  17. package/cli/dist/checks/external.d.ts +7 -0
  18. package/cli/dist/checks/external.js +31 -0
  19. package/cli/dist/checks/features.d.ts +2 -0
  20. package/cli/dist/checks/features.js +38 -0
  21. package/cli/dist/checks/index.d.ts +2 -0
  22. package/cli/dist/checks/index.js +33 -0
  23. package/cli/dist/checks/listing.d.ts +2 -0
  24. package/cli/dist/checks/listing.js +51 -0
  25. package/cli/dist/checks/metrics.d.ts +2 -0
  26. package/cli/dist/checks/metrics.js +14 -0
  27. package/cli/dist/checks/pipeline.d.ts +8 -0
  28. package/cli/dist/checks/pipeline.js +34 -0
  29. package/cli/dist/checks/refs.d.ts +2 -0
  30. package/cli/dist/checks/refs.js +92 -0
  31. package/cli/dist/checks/shared-files.d.ts +22 -0
  32. package/cli/dist/checks/shared-files.js +84 -0
  33. package/cli/dist/checks/sources.d.ts +2 -0
  34. package/cli/dist/checks/sources.js +40 -0
  35. package/cli/dist/checks/tasks.d.ts +2 -0
  36. package/cli/dist/checks/tasks.js +66 -0
  37. package/cli/dist/checks/types.d.ts +23 -0
  38. package/cli/dist/checks/types.js +1 -0
  39. package/cli/dist/checks/wiki-frontmatter.d.ts +3 -0
  40. package/cli/dist/checks/wiki-frontmatter.js +84 -0
  41. package/cli/dist/commands/add.d.ts +3 -0
  42. package/cli/dist/commands/add.js +24 -0
  43. package/cli/dist/commands/approve.d.ts +2 -0
  44. package/cli/dist/commands/approve.js +20 -0
  45. package/cli/dist/commands/check.d.ts +3 -0
  46. package/cli/dist/commands/check.js +19 -0
  47. package/cli/dist/commands/copies.d.ts +3 -0
  48. package/cli/dist/commands/copies.js +23 -0
  49. package/cli/dist/commands/doctor.d.ts +2 -0
  50. package/cli/dist/commands/doctor.js +16 -0
  51. package/cli/dist/commands/env.d.ts +7 -0
  52. package/cli/dist/commands/env.js +6 -0
  53. package/cli/dist/commands/hooks.d.ts +6 -0
  54. package/cli/dist/commands/hooks.js +55 -0
  55. package/cli/dist/commands/init.d.ts +3 -0
  56. package/cli/dist/commands/init.js +78 -0
  57. package/cli/dist/commands/learn.d.ts +3 -0
  58. package/cli/dist/commands/learn.js +36 -0
  59. package/cli/dist/commands/pipeline.d.ts +3 -0
  60. package/cli/dist/commands/pipeline.js +26 -0
  61. package/cli/dist/commands/standards.d.ts +3 -0
  62. package/cli/dist/commands/standards.js +26 -0
  63. package/cli/dist/commands/status.d.ts +3 -0
  64. package/cli/dist/commands/status.js +11 -0
  65. package/cli/dist/commands/sync.d.ts +3 -0
  66. package/cli/dist/commands/sync.js +23 -0
  67. package/cli/dist/commands/ui.d.ts +3 -0
  68. package/cli/dist/commands/ui.js +64 -0
  69. package/cli/dist/commands/weigh.d.ts +2 -0
  70. package/cli/dist/commands/weigh.js +16 -0
  71. package/cli/dist/config/load.d.ts +23 -0
  72. package/cli/dist/config/load.js +104 -0
  73. package/cli/dist/config/schema.d.ts +181 -0
  74. package/cli/dist/config/schema.js +106 -0
  75. package/cli/dist/doctor.d.ts +12 -0
  76. package/cli/dist/doctor.js +31 -0
  77. package/cli/dist/format.d.ts +6 -0
  78. package/cli/dist/format.js +35 -0
  79. package/cli/dist/index.d.ts +2 -0
  80. package/cli/dist/index.js +48 -0
  81. package/cli/dist/init.d.ts +46 -0
  82. package/cli/dist/init.js +119 -0
  83. package/cli/dist/learn.d.ts +44 -0
  84. package/cli/dist/learn.js +80 -0
  85. package/cli/dist/lib/files.d.ts +3 -0
  86. package/cli/dist/lib/files.js +23 -0
  87. package/cli/dist/lib/frontmatter.d.ts +6 -0
  88. package/cli/dist/lib/frontmatter.js +16 -0
  89. package/cli/dist/lib/system.d.ts +4 -0
  90. package/cli/dist/lib/system.js +19 -0
  91. package/cli/dist/packs.d.ts +38 -0
  92. package/cli/dist/packs.js +85 -0
  93. package/cli/dist/pipeline.d.ts +20 -0
  94. package/cli/dist/pipeline.js +30 -0
  95. package/cli/dist/run.d.ts +19 -0
  96. package/cli/dist/run.js +40 -0
  97. package/cli/dist/search/mcp.d.ts +2 -0
  98. package/cli/dist/search/mcp.js +11 -0
  99. package/cli/dist/search/qmd.d.ts +9 -0
  100. package/cli/dist/search/qmd.js +13 -0
  101. package/cli/dist/search/search.d.ts +2 -0
  102. package/cli/dist/search/search.js +13 -0
  103. package/cli/dist/standards.d.ts +14 -0
  104. package/cli/dist/standards.js +42 -0
  105. package/cli/dist/status.d.ts +3 -0
  106. package/cli/dist/status.js +26 -0
  107. package/cli/dist/sync.d.ts +19 -0
  108. package/cli/dist/sync.js +30 -0
  109. package/cli/dist/ui.d.ts +15 -0
  110. package/cli/dist/ui.js +69 -0
  111. package/package.json +55 -4
  112. package/packs/expo/pack.yaml +5 -0
  113. package/packs/firebase/pack.yaml +5 -0
  114. package/packs/nextjs/pack.yaml +5 -0
  115. package/packs/react/pack.yaml +5 -0
  116. package/packs/revenuecat/pack.yaml +5 -0
  117. package/packs/uniwind/pack.yaml +5 -0
  118. package/scripts/log-verdict.mjs +56 -0
  119. package/skills/ask/SKILL.md +40 -0
  120. package/skills/decision/SKILL.md +36 -0
  121. package/skills/ingest/SKILL.md +45 -0
  122. package/skills/standards/SKILL.md +16 -0
  123. package/skills/weigh/SKILL.md +160 -0
  124. package/skills/wiki-lint/SKILL.md +67 -0
  125. package/standards/README.md +14 -0
  126. package/standards/agent-contract.md +47 -0
  127. package/standards/constitution.md +104 -0
  128. package/standards/context-layout.md +129 -0
  129. package/standards/dev-methods.md +29 -0
  130. package/standards/wiki-schema.md +71 -0
  131. package/templates/dependency-cruiser/app.cjs +23 -0
  132. package/templates/dependency-cruiser/base.cjs +52 -0
  133. package/templates/dependency-cruiser/package.cjs +31 -0
  134. package/templates/dependency-cruiser/root.cjs +15 -0
@@ -0,0 +1,84 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { listFiles } from "../lib/files.js";
4
+ import { parseFrontmatter } from "../lib/frontmatter.js";
5
+ 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;
10
+ const STALE_AFTER_DAYS = 90;
11
+ const DAY_MS = 86_400_000;
12
+ const NOT_PAGES = ["index.md", "CLAUDE.md"];
13
+ const OLDEST_SHOWN = 3;
14
+ export function wikiPages(repo) {
15
+ return listFiles(repo, ".context/wiki", (name) => name.endsWith(".md") && !NOT_PAGES.includes(name));
16
+ }
17
+ function asDate(value) {
18
+ const date = value instanceof Date ? value : new Date(String(value));
19
+ return Number.isNaN(date.getTime()) ? null : date;
20
+ }
21
+ export const wikiFrontmatter = {
22
+ id: "wiki-frontmatter",
23
+ module: "docs",
24
+ description: "every wiki page has last-verified and no placeholder text; a type, if set, matches its folder; ids are unique; nothing stale",
25
+ run({ repo, config, today }) {
26
+ const folderByType = { ...WIKI_FOLDER_BY_TYPE, ...Object.fromEntries(config.wiki.types.map((entry) => [entry.type, entry.folder])) };
27
+ // Built-in types' sections are linted by the wiki-lint skill; added types carry theirs in config.
28
+ const sectionsByType = new Map(config.wiki.types.map((entry) => [entry.type, entry.sections]));
29
+ const findings = [];
30
+ const add = (level, file, message) => findings.push({ check: "wiki-frontmatter", level, file, message });
31
+ const filesById = new Map();
32
+ const stale = [];
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
37
+ const { data, body } = parseFrontmatter(readFileSync(join(repo, file), "utf8"));
38
+ if (!data) {
39
+ add("error", file, "no frontmatter");
40
+ continue;
41
+ }
42
+ const missing = REQUIRED_FIELDS.filter((field) => data[field] === undefined || data[field] === null);
43
+ if (missing.length > 0)
44
+ add("error", file, `missing ${missing.join(", ")}`);
45
+ const expected = folderByType[String(data.type)];
46
+ if (data.type && expected !== folder)
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`);
50
+ const sections = sectionsByType.get(String(data.type));
51
+ if (sections) {
52
+ const headings = new Set(body.split("\n").flatMap((line) => (line.startsWith("## ") ? [line.slice(3).trim()] : [])));
53
+ const absent = sections.filter((section) => !headings.has(section));
54
+ if (absent.length > 0)
55
+ add("error", file, `missing sections: ${absent.join(", ")}`);
56
+ }
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`);
60
+ const verified = asDate(data["last-verified"]);
61
+ const age = verified ? Math.floor((today.getTime() - verified.getTime()) / DAY_MS) : 0;
62
+ if (verified && age > STALE_AFTER_DAYS)
63
+ stale.push({ file, age });
64
+ if (verified && age < 0)
65
+ add("error", file, `last-verified ${data["last-verified"]} is in the future`);
66
+ if (data.id)
67
+ filesById.set(String(data.id), [...(filesById.get(String(data.id)) ?? []), file]);
68
+ }
69
+ // One line per product: hundreds of "page X is stale" lines bury every other finding.
70
+ if (stale.length > 0) {
71
+ const oldest = stale
72
+ .sort((a, b) => b.age - a.age)
73
+ .slice(0, OLDEST_SHOWN)
74
+ .map(({ file, age }) => `${file.replace(".context/wiki/", "")} (${age}d)`);
75
+ const pages = stale.length === 1 ? "1 page" : `${stale.length} pages`;
76
+ add("warn", ".context/wiki", `${pages} last verified over ${STALE_AFTER_DAYS} days ago — oldest: ${oldest.join(", ")}`);
77
+ }
78
+ for (const [id, files] of filesById) {
79
+ if (files.length > 1)
80
+ add("error", files[1], `duplicate id ${id} (also ${files[0]})`);
81
+ }
82
+ return findings;
83
+ },
84
+ };
@@ -0,0 +1,3 @@
1
+ import type { Command } from "commander";
2
+ import { type Env } from "./env.ts";
3
+ export declare function registerAdd(program: Command, env: Env): void;
@@ -0,0 +1,24 @@
1
+ import { resolve } from "node:path";
2
+ import { CACHE_DIR, fetchText, runCommand } from "../lib/system.js";
3
+ import { addPack, fetchPackDocs, findPack, listPacks } from "../packs.js";
4
+ import { requireEstate } from "./env.js";
5
+ export function registerAdd(program, env) {
6
+ program
7
+ .command("add")
8
+ .description("add a knowledge pack (rules + vendor docs in search), e.g. canon add expo")
9
+ .argument("[pack]", "pack name; omit to list packs")
10
+ .option("-g, --global", "add for every product (estate canon.yaml)")
11
+ .option("--repo <repo>", "product repo", ".")
12
+ .action(async (name, options) => {
13
+ if (!name) {
14
+ for (const pack of listPacks(env.canonRoot))
15
+ console.log(`${pack.name.padEnd(11)} ${pack.description}`);
16
+ return;
17
+ }
18
+ const estateRoot = options.global ? requireEstate(env) : undefined;
19
+ const { file, added } = addPack(resolve(options.repo), name, { canonRoot: env.canonRoot, global: options.global, estateRoot });
20
+ console.log(added ? `added ${name} to ${file}` : `${name} already in ${file}`);
21
+ const dir = await fetchPackDocs(findPack(env.canonRoot, name), { cacheDir: CACHE_DIR, fetchText, run: runCommand });
22
+ console.log(`indexed ${dir} as qmd://pack-${name}`);
23
+ });
24
+ }
@@ -0,0 +1,2 @@
1
+ import type { Command } from "commander";
2
+ export declare function registerApprove(program: Command): void;
@@ -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 canon.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 canon.yaml)"}`);
16
+ }
17
+ const approval = recordApproval(root, stageId, options.note);
18
+ console.log(`approved ${stageId} at ${approval.at}`);
19
+ });
20
+ }
@@ -0,0 +1,3 @@
1
+ import type { Command } from "commander";
2
+ import { type Env } from "./env.ts";
3
+ export declare function registerCheck(program: Command, env: Env): void;
@@ -0,0 +1,19 @@
1
+ import { resolve } from "node:path";
2
+ import { exitCode, formatReports } from "../format.js";
3
+ import { findProducts, runChecks } from "../run.js";
4
+ import { requireEstate } from "./env.js";
5
+ export function registerCheck(program, env) {
6
+ program
7
+ .command("check")
8
+ .description("check that a product follows the standards (read-only)")
9
+ .argument("[repo]", "product repo", ".")
10
+ .option("--all", "check every product in the estate")
11
+ .option("--json", "print machine-readable JSON")
12
+ .option("-q, --quiet", "hide products with no findings")
13
+ .action((repo, options) => {
14
+ const repos = options.all ? findProducts(requireEstate(env)) : [resolve(repo)];
15
+ const reports = repos.map((path) => runChecks(path, { canonRoot: env.canonRoot, estateRoot: options.all ? env.estateRoot : undefined }));
16
+ console.log(options.json ? JSON.stringify(reports, null, 2) : formatReports(reports, { quiet: options.quiet }));
17
+ process.exitCode = exitCode(reports);
18
+ });
19
+ }
@@ -0,0 +1,3 @@
1
+ import type { Command } from "commander";
2
+ import type { Env } from "./env.ts";
3
+ export declare function registerCopies(program: Command, env: Env): void;
@@ -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,2 @@
1
+ import type { Command } from "commander";
2
+ export declare function registerDoctor(program: Command): void;
@@ -0,0 +1,16 @@
1
+ import { loadConfig } from "../config/load.js";
2
+ import { runDoctor, systemProbe } from "../doctor.js";
3
+ export function registerDoctor(program) {
4
+ program
5
+ .command("doctor")
6
+ .description("check that canon's tools, and any this repo declares, are installed")
7
+ .action(() => {
8
+ const results = runDoctor(systemProbe, loadConfig(process.cwd()).config.doctor.tools);
9
+ for (const result of results) {
10
+ const mark = result.ok ? "✓" : "✗";
11
+ const detail = result.ok ? (result.version ?? "") : `${result.version ?? "missing"} — ${result.hint}`;
12
+ console.log(`${mark} ${result.tool.padEnd(8)} ${detail} (${result.why})`);
13
+ }
14
+ process.exitCode = results.every((result) => result.ok) ? 0 : 1;
15
+ });
16
+ }
@@ -0,0 +1,7 @@
1
+ export interface Env {
2
+ canonRoot: string;
3
+ /** The estate folder (parent of several products), or null when there is none. */
4
+ estateRoot: string | null;
5
+ }
6
+ /** For commands that only make sense across products. */
7
+ export declare function requireEstate(env: Env): string;
@@ -0,0 +1,6 @@
1
+ /** For commands that only make sense across products. */
2
+ export function requireEstate(env) {
3
+ if (env.estateRoot)
4
+ return env.estateRoot;
5
+ throw new Error("no estate found — put `estate: true` in .claude/canon.yaml of the folder that holds your products, or set CANON_ESTATE");
6
+ }
@@ -0,0 +1,6 @@
1
+ import type { Command } from "commander";
2
+ import { type Env } from "./env.ts";
3
+ export declare const HOOK = "#!/bin/sh\n# installed by `canon hooks install` \u2014 run it again rather than editing this file.\n# A machine without canon pushes unchecked, as it would without any hook.\ncommand -v canon >/dev/null 2>&1 || exit 0\n# The product is the folder that owns .git, not the folder being pushed from: a worktree shares these hooks.\nroot=$(cd \"$(git rev-parse --git-common-dir)/..\" && pwd) || exit 0\nexec canon check \"$root\"\n";
4
+ /** Writes the pre-push hook into one repo. Returns a one-line outcome. */
5
+ export declare function installHook(repo: string): string;
6
+ export declare function registerHooks(program: Command, env: Env): void;
@@ -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 `canon hooks install`";
7
+ // Hooks written by the tools this command replaced (the estate script, then zeno); they are ours to overwrite.
8
+ const OLD_MARKS = [MARK, "installed by ~/git/bin/install-hooks", "installed by `zeno hooks install`"];
9
+ export const HOOK = `#!/bin/sh
10
+ # ${MARK} — run it again rather than editing this file.
11
+ # A machine without canon pushes unchecked, as it would without any hook.
12
+ command -v canon >/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 canon 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 canon'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 `canon 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: canon 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
+ }
@@ -0,0 +1,3 @@
1
+ import { type Command } from "commander";
2
+ import type { Env } from "./env.ts";
3
+ export declare function registerInit(program: Command, env: Env): void;
@@ -0,0 +1,78 @@
1
+ import { basename, resolve } from "node:path";
2
+ import * as prompts from "@clack/prompts";
3
+ import { Option } from "commander";
4
+ import { KINDS, MODULES } from "../config/schema.js";
5
+ import { RULES_FILES } from "../checks/architecture.js";
6
+ import { applyInit, parseChoices, planInit } from "../init.js";
7
+ import { listPacks } from "../packs.js";
8
+ import { runCommand } from "../lib/system.js";
9
+ import { canonVersion } from "../status.js";
10
+ function cancelled(value) {
11
+ if (!prompts.isCancel(value))
12
+ return false;
13
+ prompts.cancel("Nothing written.");
14
+ return true;
15
+ }
16
+ async function askAnswers(inferred, packs) {
17
+ const kind = await prompts.select({
18
+ message: "What kind of product is this?",
19
+ initialValue: inferred.config.kind,
20
+ options: KINDS.map((value) => ({ value, label: value })),
21
+ });
22
+ if (cancelled(kind))
23
+ return null;
24
+ const modules = await prompts.multiselect({
25
+ message: "Which modules? All optional. (docs = wiki, design = HTML designs, knowledge = search, metrics = definitions.yml, code = LSP, architecture, listing)",
26
+ initialValues: inferred.config.modules,
27
+ options: MODULES.map((value) => ({ value, label: value })),
28
+ required: false,
29
+ });
30
+ if (cancelled(modules))
31
+ return null;
32
+ const forKind = packs.filter((pack) => pack.kinds.includes(kind));
33
+ if (forKind.length === 0)
34
+ return { kind, modules, packs: [] };
35
+ const chosen = await prompts.multiselect({
36
+ message: "Which vendor doc packs? (indexed for search on `canon sync`)",
37
+ initialValues: inferred.config.packs ?? [],
38
+ options: forKind.map((pack) => ({ value: pack.name, label: pack.name, hint: pack.description })),
39
+ required: false,
40
+ });
41
+ if (cancelled(chosen))
42
+ return null;
43
+ return { kind, modules, packs: chosen };
44
+ }
45
+ export function registerInit(program, env) {
46
+ program
47
+ .command("init")
48
+ .description("set canon up for a product: write .claude/canon.yaml and the missing .context files")
49
+ .argument("[repo]", "product repo", ".")
50
+ .option("-y, --yes", "accept what canon infers (and any flags below), no questions")
51
+ .addOption(new Option("--kind <kind>", "product kind").choices(KINDS))
52
+ .option("--modules <list>", `comma list, empty for none: ${MODULES.join(", ")}`)
53
+ .option("--packs <list>", "comma list of vendor doc packs (see `canon add`)")
54
+ .option("--force", "redo a repo that already has canon.yaml")
55
+ .action(async (repo, options) => {
56
+ const path = resolve(repo);
57
+ const packs = listPacks(env.canonRoot);
58
+ const flags = {
59
+ canonVersion: canonVersion(env.canonRoot),
60
+ force: options.force,
61
+ kind: options.kind,
62
+ modules: options.modules === undefined ? undefined : parseChoices(options.modules, MODULES, "module"),
63
+ packs: options.packs === undefined ? undefined : parseChoices(options.packs, packs.map((p) => p.name), "pack"),
64
+ };
65
+ const inferred = planInit(path, flags);
66
+ const answers = options.yes ? null : await askAnswers(inferred, packs);
67
+ if (!options.yes && answers === null)
68
+ return;
69
+ const plan = answers ? planInit(path, { ...flags, ...answers }) : inferred;
70
+ applyInit(path, plan, { name: basename(path), canonRoot: env.canonRoot, run: (command, args) => void runCommand(command, args) });
71
+ console.log(`wrote .claude/canon.yaml (${plan.config.kind}: ${plan.config.modules.join(", ")})`);
72
+ for (const file of plan.create)
73
+ console.log(`created ${file}`);
74
+ if (plan.create.some((file) => file.endsWith(RULES_FILES[0])))
75
+ console.log("architecture: add the checker with `pnpm add -D dependency-cruiser`, then edit the .dependency-cruiser.cjs files to this repo's layers");
76
+ console.log(plan.config.packs ? "next: canon sync (fetches the packs), then canon check" : "next: canon check");
77
+ });
78
+ }
@@ -0,0 +1,3 @@
1
+ import type { Command } from "commander";
2
+ import { type Env } from "./env.ts";
3
+ export declare function registerLearn(program: Command, env: Env): void;
@@ -0,0 +1,36 @@
1
+ import { proposals, readDecisions, readVerdicts, recordDecision, summarise } from "../learn.js";
2
+ import { findProducts } from "../run.js";
3
+ import { requireEstate } from "./env.js";
4
+ const DEFAULT_THRESHOLD = 3;
5
+ export function registerLearn(program, env) {
6
+ program
7
+ .command("learn")
8
+ .description("show how often the gate stops work, and turn overrides across products into proposals to change the rules")
9
+ .option("--threshold <n>", "overrides of one principle before it is proposed", String(DEFAULT_THRESHOLD))
10
+ .option("--approve <id>", "mark a proposal approved (then edit the standard)")
11
+ .option("--reject <id>", "mark a proposal rejected; it returns only with new evidence")
12
+ .option("--note <text>", "why, for --approve / --reject", "")
13
+ .action((options) => {
14
+ const estateRoot = requireEstate(env);
15
+ const entries = readVerdicts(findProducts(estateRoot));
16
+ const found = proposals(entries, { threshold: Number(options.threshold), decisions: readDecisions(estateRoot) });
17
+ const target = options.approve ?? options.reject;
18
+ if (target) {
19
+ const proposal = found.find((p) => p.id === target);
20
+ if (!proposal)
21
+ throw new Error(`no open proposal "${target}" — run \`canon learn\` to list them`);
22
+ recordDecision(estateRoot, { id: target, decision: options.approve ? "approved" : "rejected", count: proposal.count, note: options.note });
23
+ console.log(`${options.approve ? "approved" : "rejected"} ${target}`);
24
+ return;
25
+ }
26
+ const s = summarise(entries);
27
+ console.log(`${s.total} verdict(s) logged: ${s.proceeded} went ahead, ${s.stopped} stopped, ${s.overridden} overridden, ${s.dropped} dropped`);
28
+ if (found.length === 0)
29
+ console.log("no proposals");
30
+ for (const proposal of found) {
31
+ console.log(`\n${proposal.id}\n ${proposal.summary}\n products: ${proposal.products.join(", ")}`);
32
+ for (const reason of proposal.reasons)
33
+ console.log(` - "${reason}"`);
34
+ }
35
+ });
36
+ }
@@ -0,0 +1,3 @@
1
+ import type { Command } from "commander";
2
+ import type { Env } from "./env.ts";
3
+ export declare function registerPipeline(program: Command, env: Env): void;
@@ -0,0 +1,26 @@
1
+ import { resolve } from "node:path";
2
+ import { loadConfig } from "../config/load.js";
3
+ import { stagesOf } from "../pipeline.js";
4
+ export function registerPipeline(program, env) {
5
+ program
6
+ .command("pipeline")
7
+ .description("the repo's stages in order — the estate's list unless the product declares its own — and where each stands")
8
+ .argument("[repo]", "product repo", ".")
9
+ .option("--json", "print JSON, for scripts")
10
+ .action((repo, options) => {
11
+ const root = resolve(repo);
12
+ const stages = stagesOf(root, loadConfig(root, { estateRoot: env.estateRoot }).config.pipeline);
13
+ if (options.json) {
14
+ console.log(JSON.stringify(stages, null, 2));
15
+ return;
16
+ }
17
+ if (stages.length === 0) {
18
+ console.log("no pipeline: in canon.yaml");
19
+ return;
20
+ }
21
+ for (const s of stages) {
22
+ const gate = s.gate ? (s.approved ? " gate approved" : " gate open") : "";
23
+ console.log(`${s.startedAt ? "●" : "○"} ${s.id.padEnd(14)}${s.startedAt ? ` started (${s.startedAt})` : " not started"}${gate}`);
24
+ }
25
+ });
26
+ }
@@ -0,0 +1,3 @@
1
+ import type { Command } from "commander";
2
+ import type { Env } from "./env.ts";
3
+ export declare function registerStandards(program: Command, env: Env): void;
@@ -0,0 +1,26 @@
1
+ import { resolve } from "node:path";
2
+ import { loadConfig } from "../config/load.js";
3
+ import { constitutionTitles, resolveStandards } from "../standards.js";
4
+ export function registerStandards(program, env) {
5
+ program
6
+ .command("standards")
7
+ .description("list the standards that apply to a repo: canon's defaults, then the estate's and the repo's")
8
+ .argument("[repo]", "product repo", ".")
9
+ .option("--json", "print machine-readable JSON")
10
+ .option("--constitution", "print only the principle titles, canon's then any added ones")
11
+ .action((repo, options) => {
12
+ const { config } = loadConfig(resolve(repo));
13
+ if (options.constitution) {
14
+ const titles = constitutionTitles(env.canonRoot, config.standards);
15
+ console.log(options.json ? JSON.stringify(titles) : titles.map((title) => `- ${title}`).join("\n"));
16
+ return;
17
+ }
18
+ const standards = resolveStandards(env.canonRoot, config.standards);
19
+ if (options.json) {
20
+ console.log(JSON.stringify(standards, null, 2));
21
+ return;
22
+ }
23
+ for (const standard of standards)
24
+ console.log(`${standard.name.padEnd(18)} ${standard.path}`);
25
+ });
26
+ }
@@ -0,0 +1,3 @@
1
+ import type { Command } from "commander";
2
+ import type { Env } from "./env.ts";
3
+ export declare function registerStatus(program: Command, env: Env): void;
@@ -0,0 +1,11 @@
1
+ import { resolve } from "node:path";
2
+ import { buildStatus } from "../status.js";
3
+ export function registerStatus(program, env) {
4
+ program
5
+ .command("status")
6
+ .description("one screen: canon version, config, check summary")
7
+ .argument("[repo]", "product repo", ".")
8
+ .action((repo) => {
9
+ console.log(buildStatus(resolve(repo), { canonRoot: env.canonRoot }));
10
+ });
11
+ }
@@ -0,0 +1,3 @@
1
+ import type { Command } from "commander";
2
+ import type { Env } from "./env.ts";
3
+ export declare function registerSync(program: Command, env: Env): void;
@@ -0,0 +1,23 @@
1
+ import { resolve } from "node:path";
2
+ import { exitCode, formatReports } from "../format.js";
3
+ import { CACHE_DIR, fetchText, runCommand } from "../lib/system.js";
4
+ import { runChecks } from "../run.js";
5
+ import { syncRepo } from "../sync.js";
6
+ export function registerSync(program, env) {
7
+ program
8
+ .command("sync")
9
+ .description("refresh pack docs and the search index, move the pinned canon version, then check")
10
+ .argument("[repo]", "product repo", ".")
11
+ .action(async (repo) => {
12
+ const path = resolve(repo);
13
+ const result = await syncRepo(path, { canonRoot: env.canonRoot, estateRoot: env.estateRoot ?? undefined, cacheDir: CACHE_DIR, fetchText, run: runCommand });
14
+ console.log(`packs refreshed: ${result.packs.join(", ") || "none"}`);
15
+ if (result.base)
16
+ console.log("architecture base updated from canon's template");
17
+ if (result.version)
18
+ console.log(`canon ${result.version.from ?? "unpinned"} → ${result.version.to}`);
19
+ const report = runChecks(path, { canonRoot: env.canonRoot });
20
+ console.log(formatReports([report]));
21
+ process.exitCode = exitCode([report]);
22
+ });
23
+ }
@@ -0,0 +1,3 @@
1
+ import type { Command } from "commander";
2
+ import { type Env } from "./env.ts";
3
+ export declare function registerUi(program: Command, env: Env): void;
@@ -0,0 +1,64 @@
1
+ import { spawn, spawnSync } from "node:child_process";
2
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { architectureGraph } from "../checks/architecture.js";
5
+ import { formatReports } from "../format.js";
6
+ import { CACHE_DIR } from "../lib/system.js";
7
+ import { findProducts, runChecks } from "../run.js";
8
+ import { buildSite, quartzConfig } from "../ui.js";
9
+ import { requireEstate } from "./env.js";
10
+ // Quartz v5 has no tagged release yet, so pin the exact commit: an unpinned
11
+ // clone could change the site under us on the next fresh install.
12
+ const QUARTZ_REPO = "https://github.com/jackyzha0/quartz";
13
+ const QUARTZ_COMMIT = "97a2d05f80c4c50534959b1d0d41cc4b3895625e";
14
+ const QUARTZ_DIR = join(CACHE_DIR, "quartz");
15
+ function must(command, args, cwd) {
16
+ const result = spawnSync(command, args, { cwd, stdio: "inherit" });
17
+ if (result.status !== 0)
18
+ throw new Error(`${command} ${args.join(" ")} failed`);
19
+ }
20
+ function ensureQuartz() {
21
+ if (!existsSync(join(QUARTZ_DIR, "node_modules"))) {
22
+ if (!existsSync(QUARTZ_DIR))
23
+ must("git", ["clone", "--quiet", "--filter=blob:none", QUARTZ_REPO, QUARTZ_DIR]);
24
+ must("git", ["checkout", "--quiet", QUARTZ_COMMIT], QUARTZ_DIR);
25
+ must("npm", ["ci", "--silent"], QUARTZ_DIR);
26
+ }
27
+ const defaults = readFileSync(join(QUARTZ_DIR, "quartz.config.default.yaml"), "utf8");
28
+ writeFileSync(join(QUARTZ_DIR, "quartz.config.yaml"), quartzConfig(defaults));
29
+ }
30
+ export function registerUi(program, env) {
31
+ program
32
+ .command("ui")
33
+ .description("serve every product wiki (search, backlinks, graph) and the design galleries on localhost")
34
+ // Uncommon defaults: 8080 and 3001 are usually taken by Docker or a dev server.
35
+ .option("--port <port>", "wiki site port", "4610")
36
+ .option("--designs-port <port>", "design gallery port", "4611")
37
+ .option("--ws-port <port>", "live-reload socket port", "4612")
38
+ .action((options) => {
39
+ ensureQuartz();
40
+ const products = findProducts(requireEstate(env));
41
+ const siteDir = join(CACHE_DIR, "site");
42
+ const designsDir = join(CACHE_DIR, "designs");
43
+ const designsUrl = `http://127.0.0.1:${options.designsPort}`;
44
+ const report = formatReports(products.map((repo) => runChecks(repo, { canonRoot: env.canonRoot, estateRoot: env.estateRoot })));
45
+ buildSite(products, { siteDir, designsDir, designsUrl, report, graph: architectureGraph });
46
+ const designs = spawn("python3", ["-m", "http.server", options.designsPort, "--bind", "127.0.0.1", "--directory", designsDir], {
47
+ stdio: "ignore",
48
+ });
49
+ const site = spawn("npx", ["quartz", "build", "-d", siteDir, "-o", join(CACHE_DIR, "public"), "--serve", "--port", options.port, "--wsPort", options.wsPort], {
50
+ cwd: QUARTZ_DIR,
51
+ stdio: "inherit",
52
+ });
53
+ console.log(`wiki http://localhost:${options.port}\ndesigns ${designsUrl}`);
54
+ const stop = () => {
55
+ designs.kill();
56
+ site.kill();
57
+ };
58
+ process.on("SIGINT", stop);
59
+ site.on("exit", (code) => {
60
+ designs.kill();
61
+ process.exitCode = code ?? 0;
62
+ });
63
+ });
64
+ }
@@ -0,0 +1,2 @@
1
+ import type { Command } from "commander";
2
+ export declare function registerWeigh(program: Command): void;
@@ -0,0 +1,16 @@
1
+ import { spawnSync } from "node:child_process";
2
+ export function registerWeigh(program) {
3
+ program
4
+ .command("weigh")
5
+ .description("run the decision gate on a request without doing it (calls Claude)")
6
+ .argument("<request...>", "what you want to do")
7
+ .action((words) => {
8
+ const request = words.join(" ");
9
+ const result = spawnSync("claude", ["-p", `/canon:weigh ${request}\n\nWeigh only. Do not start the work.`], {
10
+ stdio: "inherit",
11
+ });
12
+ if (result.error)
13
+ throw new Error(`could not run claude: ${result.error.message}`);
14
+ process.exitCode = result.status ?? 1;
15
+ });
16
+ }