@freetesh/canon 0.16.0 → 0.18.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 (57) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/README.md +37 -31
  4. package/cli/dist/api.d.ts +1 -2
  5. package/cli/dist/api.js +1 -2
  6. package/cli/dist/check.d.ts +7 -3
  7. package/cli/dist/check.js +18 -7
  8. package/cli/dist/checks/commands.d.ts +1 -1
  9. package/cli/dist/checks/commands.js +1 -1
  10. package/cli/dist/checks/designs.d.ts +7 -0
  11. package/cli/dist/checks/designs.js +46 -0
  12. package/cli/dist/checks/index.js +3 -0
  13. package/cli/dist/checks/refs.js +8 -4
  14. package/cli/dist/commands/add.d.ts +1 -1
  15. package/cli/dist/commands/add.js +1 -4
  16. package/cli/dist/commands/check.js +4 -4
  17. package/cli/dist/commands/copies.js +1 -1
  18. package/cli/dist/commands/hooks.js +4 -4
  19. package/cli/dist/commands/learn.js +7 -5
  20. package/cli/dist/commands/pipeline.js +2 -2
  21. package/cli/dist/commands/scripts.d.ts +3 -0
  22. package/cli/dist/commands/scripts.js +32 -0
  23. package/cli/dist/commands/standards.js +1 -1
  24. package/cli/dist/commands/sync.js +1 -1
  25. package/cli/dist/commands/ui.js +3 -3
  26. package/cli/dist/config/load.d.ts +5 -11
  27. package/cli/dist/config/load.js +21 -25
  28. package/cli/dist/config/schema.d.ts +6 -2
  29. package/cli/dist/config/schema.js +9 -5
  30. package/cli/dist/doctor.js +2 -1
  31. package/cli/dist/index.js +4 -2
  32. package/cli/dist/learn.d.ts +2 -2
  33. package/cli/dist/learn.js +6 -6
  34. package/cli/dist/lib/env.d.ts +3 -3
  35. package/cli/dist/lib/env.js +4 -4
  36. package/cli/dist/packs.d.ts +1 -3
  37. package/cli/dist/packs.js +4 -7
  38. package/cli/dist/standards.d.ts +11 -6
  39. package/cli/dist/standards.js +37 -17
  40. package/cli/dist/status.js +1 -1
  41. package/cli/dist/sync.d.ts +0 -1
  42. package/cli/dist/sync.js +1 -1
  43. package/package.json +4 -1
  44. package/scripts/arch-set.py +140 -0
  45. package/scripts/check-designs.py +709 -0
  46. package/scripts/flow.py +313 -0
  47. package/skills/ingest/SKILL.md +1 -1
  48. package/standards/README.md +23 -6
  49. package/standards/agent-contract.md +21 -6
  50. package/standards/context-layout.md +42 -7
  51. package/standards/library/design-sets.md +857 -0
  52. package/standards/library/expo.md +54 -0
  53. package/standards/library/frontend.md +44 -0
  54. package/standards/library/performance.md +45 -0
  55. package/standards/library/pipeline.md +42 -0
  56. package/standards/library/secrets.md +37 -0
  57. package/standards/library/store-listing.md +122 -0
@@ -7,7 +7,7 @@
7
7
  {
8
8
  "name": "canon",
9
9
  "source": "./",
10
- "version": "0.16.0",
10
+ "version": "0.18.0",
11
11
  "description": "Keeps your domain knowledge and AI rules in your repos and enforces them, so Claude hallucinates less."
12
12
  }
13
13
  ],
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "name": "canon",
3
- "version": "0.16.0",
3
+ "version": "0.18.0",
4
4
  "description": "Keeps your domain knowledge and AI rules in your repos and enforces them, so Claude hallucinates less."
5
5
  }
package/README.md CHANGED
@@ -47,9 +47,11 @@ is a separate paid product built on this CLI.
47
47
  listing tree, dead `.context/` paths, prices with no canonical home, shared
48
48
  files, the import architecture, pipeline gates, and every checker your config
49
49
  declares. `canon hooks install` makes every push run it.
50
- - **Standards with one home.** canon ships defaults; an estate adds or replaces
51
- them from its own folder; a repo can override again. `canon standards` shows
52
- what applies.
50
+ - **Standards with one home.** canon ships defaults every repo follows, and a
51
+ library a repo imports by name (`standards: [design-sets, pipeline]`): design
52
+ sets, the build pipeline, Expo, frontend, performance, secrets, store listings.
53
+ A repo's own rules can sit in a folder it adds. `canon standards` shows what
54
+ applies.
53
55
  - **A repo names only what it uses.** Modules, packs and shared-file sets are
54
56
  opt-in; there is no skip list and no setting that turns a check off.
55
57
 
@@ -72,57 +74,61 @@ argument captures what a session taught.
72
74
  ```bash
73
75
  canon init # write .claude/canon.yaml and the missing .context files
74
76
  canon add # list packs; `canon add expo` adds one and indexes its docs
75
- canon check # this repo against the standards (exit 1 on any error); --all for the estate
77
+ canon check # this repo against the standards (exit 1 on any error); --all for every repo beside it
76
78
  canon copies --sync # repair drifted shared files from copies.source (never adds one)
77
79
  canon hooks install # a pre-push hook that runs `canon check` (--all: every product)
78
80
  canon standards # which standards apply here, and where each comes from
79
81
  canon sync # refresh pack docs and the search index, move the pinned version
80
82
  canon status # version, kind, modules, config layers, check summary
81
- canon doctor # node, git, qmd — plus any tools your config lists
83
+ canon doctor # node, git, python3, qmd — plus any tools your config lists
82
84
  canon learn # proposals from overridden verdicts
83
85
  canon approve <stage> # a person records a pipeline gate approval
84
86
  canon pipeline # the stages this repo resolves to, and where each stands
85
87
  canon weigh "<ask>" # the decision gate from the terminal
86
88
  canon ui # every wiki and design gallery on localhost
87
89
  canon search "<q>" # search the knowledge index
90
+ canon flow <product> # how far each local Claude conversation got against the pipeline (--write: metrics/flow.json)
91
+ canon arch-set <spec> # give a design set its architecture screens
88
92
  canon mcp # the search MCP server, for any MCP client
89
93
  ```
90
94
 
91
95
  ## Config
92
96
 
93
- `.claude/canon.yaml`, read in layers — **estate → repo → local**
94
- (`canon.local.yaml`, not committed). The estate is the nearest folder above a
95
- repo whose `canon.yaml` says `estate: true`, or `CANON_ESTATE`.
96
-
97
- ```yaml
98
- # <estate>/.claude/canon.yaml — shared by every repo below it
99
- estate: true
100
- standards: [.canon/standards] # estate rules; same name replaces a canon default
101
- commands: # your own checkers, run by `canon check`
102
- - { id: designs, run: ./scripts/check-designs.sh, if: .context/designs, module: design }
103
- - { id: designs-first, run: test -d .context/designs, kinds: [mobile, web] } # only repos that ship screens
104
- copies: # files products copy from one source repo, in named sets
105
- source: design-system
106
- sets:
107
- upload: [scripts/upload.mjs]
108
- shell-web:
109
- - { from: designs/mobile/index.html, to: designs/web/index.html, named: true }
110
- ```
97
+ Every repo carries its own rules in `.claude/canon.yaml`; nothing above the repo
98
+ is read. An uncommitted `canon.local.yaml` beside it overrides it on one machine.
99
+ Commands that span products (`--all`, `ui`, `learn`, `flow`) use the folder that
100
+ holds the repo you are in, or `CANON_WORKSPACE`.
111
101
 
112
102
  ```yaml
113
103
  # <repo>/.claude/canon.yaml — written by `canon init`
114
- canon: 0.16.0
104
+ canon: 0.18.0
115
105
  kind: mobile # mobile | web | mobile-and-web | package | content
106
+ standards: [design-sets, pipeline, expo] # canon's library standards this repo follows; ./rules adds a folder of its own
116
107
  modules: [docs, knowledge, code, listing, refs]
117
108
  packs: [expo]
118
109
  name: Acme # how the product is written; `named` copies swap it in
110
+ formerly: [acme-old] # older folder names, so `canon flow` counts their conversations
111
+ commands: # your own checkers, run by `canon check`
112
+ - { id: lint-copy, run: ./scripts/lint-copy.sh, if: .context/designs, module: design }
113
+ copies: # files copied from one source repo, kept equal to it
114
+ source: ../design-system
115
+ use: [upload, shell-web] # sets the source names under its own copies.sets
116
+ ```
117
+
118
+ ```yaml
119
+ # ../design-system/.claude/canon.yaml — the source names its sets
119
120
  copies:
120
- use: [upload, shell-web] # the sets this repo uses; only these are held to the source
121
+ sets:
122
+ upload: [scripts/upload.mjs]
123
+ shell-web:
124
+ - { from: designs/mobile/index.html, to: designs/web/index.html, named: true }
121
125
  ```
122
126
 
123
- Scalars: the most specific layer wins. `packs`, `standards` and `copies.use`
124
- add up across layers; `commands`, `doctor.tools`, `wiki.types` and
125
- `context.folders` merge by name.
127
+ Built in, for every repo: a mobile or web repo keeps `.context/designs/` (designs
128
+ first), and a repo that draws its screens always gets the design checks.
129
+
130
+ Lists of names (`packs`, `standards`, `copies.use`) add up across the two files;
131
+ `commands`, `doctor.tools`, `wiki.types` and `context.folders` merge by name.
126
132
 
127
133
  ## Layout
128
134
 
@@ -134,8 +140,8 @@ hooks/ session start: constitution titles, qmd refresh
134
140
  standards/ default standards
135
141
  packs/ vendor doc packs
136
142
  templates/ files init writes (dependency-cruiser base and starters)
137
- scripts/ log-verdict.mjs — the verdict log
138
- evals/ plugin evals and the fixture product
143
+ scripts/ log-verdict.mjs (the verdict log), check-designs.py, flow.py, arch-set.py
144
+ evals/ plugin evals: the fixture product, and cases over real product repos (_scaffold/product.sh)
139
145
  cli/ the canon CLI (TypeScript, vitest); `@freetesh/canon/api` for tools built on it
140
146
  ```
141
147
 
@@ -146,7 +152,7 @@ index.ts the program: registers every command
146
152
  api.ts what other tools import (`@freetesh/canon/api`)
147
153
  commands/ one file per command — options and output only
148
154
  checks/ one file per check, named by its id; index.ts lists them
149
- config/ .claude/canon.yaml: schema, layered loading, estate discovery
155
+ config/ .claude/canon.yaml: schema and loading
150
156
  lib/ shared helpers: files, frontmatter, env, qmd, system calls
151
157
  <name>.ts the logic the commands call: check (+ format for its output), init, packs (add), sync, …
152
158
  ```
package/cli/dist/api.d.ts CHANGED
@@ -1,3 +1,2 @@
1
- export { findEstate } from "./config/load.ts";
2
- export { findProducts } from "./check.ts";
1
+ export { findProducts, findWorkspace } from "./check.ts";
3
2
  export { qmdQuery, qmdRefresh } from "./lib/qmd.ts";
package/cli/dist/api.js CHANGED
@@ -1,4 +1,3 @@
1
1
  // What other tools build on: the canon knowledge site reads repos the way `canon check --all` finds them.
2
- export { findEstate } from "./config/load.js";
3
- export { findProducts } from "./check.js";
2
+ export { findProducts, findWorkspace } from "./check.js";
4
3
  export { qmdQuery, qmdRefresh } from "./lib/qmd.js";
@@ -11,9 +11,13 @@ export interface Report {
11
11
  export interface RunOptions {
12
12
  checks?: Check[];
13
13
  canonRoot: string;
14
- estateRoot?: string | null;
15
14
  today?: Date;
16
15
  }
17
16
  export declare function runChecks(repo: string, options: RunOptions): Report;
18
- /** Product repos in the estate: every direct child folder holding a .context/. */
19
- export declare function findProducts(estateRoot: string): string[];
17
+ /** Product repos in a workspace: every direct child folder holding a .context/. */
18
+ export declare function findProducts(workspace: string): string[];
19
+ /**
20
+ * The folder holding the product repos: the parent of the git repo `start` sits in, or `start`
21
+ * itself outside any repo. Null when that folder holds no product.
22
+ */
23
+ export declare function findWorkspace(start: string): string | null;
package/cli/dist/check.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { existsSync, readdirSync } from "node:fs";
2
- import { basename, join } from "node:path";
2
+ import { basename, dirname, join, resolve } from "node:path";
3
3
  import { ALL_CHECKS } from "./checks/index.js";
4
4
  import { loadConfig } from "./config/load.js";
5
5
  import { isDirectory } from "./lib/files.js";
@@ -11,7 +11,7 @@ export function runChecks(repo, options) {
11
11
  }
12
12
  let config;
13
13
  try {
14
- config = loadConfig(repo, { estateRoot: options.estateRoot }).config;
14
+ config = loadConfig(repo).config;
15
15
  }
16
16
  catch (error) {
17
17
  report.findings.push({ check: "config", level: "error", message: error.message });
@@ -31,10 +31,21 @@ export function runChecks(repo, options) {
31
31
  }
32
32
  return report;
33
33
  }
34
- /** Product repos in the estate: every direct child folder holding a .context/. */
35
- export function findProducts(estateRoot) {
36
- return readdirSync(estateRoot, { withFileTypes: true })
37
- .filter((entry) => entry.isDirectory() && existsSync(join(estateRoot, entry.name, ".context")))
38
- .map((entry) => join(estateRoot, entry.name))
34
+ /** Product repos in a workspace: every direct child folder holding a .context/. */
35
+ export function findProducts(workspace) {
36
+ return readdirSync(workspace, { withFileTypes: true })
37
+ .filter((entry) => entry.isDirectory() && existsSync(join(workspace, entry.name, ".context")))
38
+ .map((entry) => join(workspace, entry.name))
39
39
  .sort();
40
40
  }
41
+ /**
42
+ * The folder holding the product repos: the parent of the git repo `start` sits in, or `start`
43
+ * itself outside any repo. Null when that folder holds no product.
44
+ */
45
+ export function findWorkspace(start) {
46
+ let folder = resolve(start);
47
+ while (!existsSync(join(folder, ".git")) && dirname(folder) !== folder)
48
+ folder = dirname(folder);
49
+ const workspace = existsSync(join(folder, ".git")) ? dirname(folder) : resolve(start);
50
+ return findProducts(workspace).length > 0 ? workspace : null;
51
+ }
@@ -1,6 +1,6 @@
1
1
  import type { Check } from "./types.ts";
2
2
  /**
3
- * Runs each checker a repo or its estate declares under `commands:` in
3
+ * Runs each checker a repo declares under `commands:` in
4
4
  * canon.yaml. canon does not know what they check; a non-zero exit is an error
5
5
  * reported under the command's own id.
6
6
  */
@@ -7,7 +7,7 @@ function tail(text) {
7
7
  return text.trim().split("\n").slice(-MAX_OUTPUT_LINES).join("\n");
8
8
  }
9
9
  /**
10
- * Runs each checker a repo or its estate declares under `commands:` in
10
+ * Runs each checker a repo declares under `commands:` in
11
11
  * canon.yaml. canon does not know what they check; a non-zero exit is an error
12
12
  * reported under the command's own id.
13
13
  */
@@ -0,0 +1,7 @@
1
+ import type { Check } from "./types.ts";
2
+ /** Every drawn surface: a folder under .context/designs/ with a routes.js manifest. */
3
+ export declare function designSurfaces(repo: string): string[];
4
+ /** Runs canon's design-set checker (scripts/check-designs.py) once per drawn surface. */
5
+ export declare const designs: Check;
6
+ /** A repo with screens draws them first (pipeline stage 1): a mobile or web repo keeps .context/designs/. */
7
+ export declare const designsFirst: Check;
@@ -0,0 +1,46 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { existsSync, readdirSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { kindCovers } from "../config/schema.js";
5
+ const DESIGNS = ".context/designs";
6
+ const MAX_OUTPUT_LINES = 15;
7
+ /** Every drawn surface: a folder under .context/designs/ with a routes.js manifest. */
8
+ export function designSurfaces(repo) {
9
+ const root = join(repo, DESIGNS);
10
+ if (!existsSync(root))
11
+ return [];
12
+ return readdirSync(root, { withFileTypes: true })
13
+ .filter((entry) => entry.isDirectory() && existsSync(join(root, entry.name, "routes.js")))
14
+ .map((entry) => `${DESIGNS}/${entry.name}`)
15
+ .sort();
16
+ }
17
+ /** Runs canon's design-set checker (scripts/check-designs.py) once per drawn surface. */
18
+ export const designs = {
19
+ id: "designs",
20
+ module: "design",
21
+ description: "every drawn surface follows the design-sets standard (scripts/check-designs.py)",
22
+ run({ repo, canonRoot }) {
23
+ const script = join(canonRoot, "scripts", "check-designs.py");
24
+ return designSurfaces(repo).flatMap((surface) => {
25
+ const result = spawnSync("python3", [script, surface], { cwd: repo, encoding: "utf8" });
26
+ if (result.error)
27
+ return [{ check: "designs", level: "error", file: surface, message: `could not run python3: ${result.error.message}` }];
28
+ if (result.status === 0)
29
+ return [];
30
+ const output = `${result.stdout}\n${result.stderr}`.trim().split("\n").slice(-MAX_OUTPUT_LINES).join("\n");
31
+ return [{ check: "designs", level: "error", file: surface, message: output }];
32
+ });
33
+ },
34
+ };
35
+ /** A repo with screens draws them first (pipeline stage 1): a mobile or web repo keeps .context/designs/. */
36
+ export const designsFirst = {
37
+ id: "designs-first",
38
+ module: null,
39
+ description: "a mobile or web repo draws its screens in .context/designs/ before any code",
40
+ run({ repo, config }) {
41
+ const hasScreens = kindCovers(config.kind, "mobile") || kindCovers(config.kind, "web");
42
+ if (!hasScreens || existsSync(join(repo, DESIGNS)))
43
+ return [];
44
+ return [{ check: "designs-first", level: "error", file: DESIGNS, message: "no .context/designs yet — stage 1 (HTML designs) comes before any code" }];
45
+ },
46
+ };
@@ -5,6 +5,7 @@ import { contextSize } from "./context-size.js";
5
5
  import { staleCopies } from "./stale-copies.js";
6
6
  import { standardsCopies } from "./standards-copies.js";
7
7
  import { commandChecks } from "./commands.js";
8
+ import { designs, designsFirst } from "./designs.js";
8
9
  import { features } from "./features.js";
9
10
  import { listing } from "./listing.js";
10
11
  import { metricsDefinitions } from "./metrics-definitions.js";
@@ -30,5 +31,7 @@ export const ALL_CHECKS = [
30
31
  metricsDefinitions,
31
32
  sources,
32
33
  pipeline,
34
+ designsFirst,
35
+ designs,
33
36
  commandChecks,
34
37
  ];
@@ -15,6 +15,8 @@ const BINARY = /\.(png|jpe?g|gif|webp|ico|icns|mp4|mov|pdf|zip|aab|apk|jks|keyst
15
15
  const FEATURE_FILE = /^\.context\/features\/(\d{4})-[^/]+\.md$/;
16
16
  // A pipeline stage's `done:` paths name what will exist once that stage starts.
17
17
  const STAGE_DONE = /^\s*-\s*\{[^}]*\bdone:\s*\[/;
18
+ // A copy set's `to:` path is where the file lands in the products that copy it, not in this repo.
19
+ const COPY_TO = /,\s*to:.*$/;
18
20
  function git(repo, args) {
19
21
  const result = spawnSync("git", ["-C", repo, ...args], { encoding: "utf8", maxBuffer: 64 * 1024 * 1024 });
20
22
  return { status: result.status ?? 1, stdout: result.stdout ?? "" };
@@ -66,10 +68,12 @@ export const refs = {
66
68
  const links = file.startsWith(".context/") && file.endsWith(".md");
67
69
  text.split("\n").forEach((line, index) => {
68
70
  const at = `${file}:${index + 1}`;
69
- const future = file === ".claude/canon.yaml" && STAGE_DONE.test(line);
70
- if (!dated && !future && line.includes(".context/") && !FROM_HISTORY.test(line)) {
71
- for (const match of line.matchAll(PATH)) {
72
- let path = literalPart(match[2], line.slice(match.index + match[0].length));
71
+ const inConfig = file === ".claude/canon.yaml";
72
+ const future = inConfig && STAGE_DONE.test(line);
73
+ const own = inConfig ? line.replace(COPY_TO, "") : line;
74
+ if (!dated && !future && own.includes(".context/") && !FROM_HISTORY.test(own)) {
75
+ for (const match of own.matchAll(PATH)) {
76
+ let path = literalPart(match[2], own.slice(match.index + match[0].length));
73
77
  if (path === null)
74
78
  continue;
75
79
  if (match[1])
@@ -1,3 +1,3 @@
1
1
  import type { Command } from "commander";
2
- import { type Env } from "../lib/env.ts";
2
+ import type { Env } from "../lib/env.ts";
3
3
  export declare function registerAdd(program: Command, env: Env): void;
@@ -1,13 +1,11 @@
1
1
  import { resolve } from "node:path";
2
2
  import { CACHE_DIR, fetchText, runCommand } from "../lib/system.js";
3
3
  import { addPack, fetchPackDocs, findPack, listPacks } from "../packs.js";
4
- import { requireEstate } from "../lib/env.js";
5
4
  export function registerAdd(program, env) {
6
5
  program
7
6
  .command("add")
8
7
  .description("add a knowledge pack (rules + vendor docs in search), e.g. canon add expo")
9
8
  .argument("[pack]", "pack name; omit to list packs")
10
- .option("-g, --global", "add for every product (estate canon.yaml)")
11
9
  .option("--repo <repo>", "product repo", ".")
12
10
  .action(async (name, options) => {
13
11
  if (!name) {
@@ -15,8 +13,7 @@ export function registerAdd(program, env) {
15
13
  console.log(`${pack.name.padEnd(11)} ${pack.description}`);
16
14
  return;
17
15
  }
18
- const estateRoot = options.global ? requireEstate(env) : undefined;
19
- const { file, added } = addPack(resolve(options.repo), name, { canonRoot: env.canonRoot, global: options.global, estateRoot });
16
+ const { file, added } = addPack(resolve(options.repo), name, { canonRoot: env.canonRoot });
20
17
  console.log(added ? `added ${name} to ${file}` : `${name} already in ${file}`);
21
18
  const dir = await fetchPackDocs(findPack(env.canonRoot, name), { cacheDir: CACHE_DIR, fetchText, run: runCommand });
22
19
  console.log(`indexed ${dir} as qmd://pack-${name}`);
@@ -1,18 +1,18 @@
1
1
  import { resolve } from "node:path";
2
2
  import { exitCode, formatReports } from "../format.js";
3
3
  import { findProducts, runChecks } from "../check.js";
4
- import { requireEstate } from "../lib/env.js";
4
+ import { requireWorkspace } from "../lib/env.js";
5
5
  export function registerCheck(program, env) {
6
6
  program
7
7
  .command("check")
8
8
  .description("check that a product follows the standards (read-only)")
9
9
  .argument("[repo]", "product repo", ".")
10
- .option("--all", "check every product in the estate")
10
+ .option("--all", "check every product in the workspace")
11
11
  .option("--json", "print machine-readable JSON")
12
12
  .option("-q, --quiet", "hide products with no findings")
13
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 }));
14
+ const repos = options.all ? findProducts(requireWorkspace(env)) : [resolve(repo)];
15
+ const reports = repos.map((path) => runChecks(path, { canonRoot: env.canonRoot }));
16
16
  console.log(options.json ? JSON.stringify(reports, null, 2) : formatReports(reports, { quiet: options.quiet }));
17
17
  process.exitCode = exitCode(reports);
18
18
  });
@@ -9,7 +9,7 @@ export function registerCopies(program, env) {
9
9
  .option("--sync", "overwrite drifted copies from the source (never adds a missing file)")
10
10
  .action((repo, options) => {
11
11
  const path = resolve(repo);
12
- const { config } = loadConfig(path, { estateRoot: env.estateRoot });
12
+ const { config } = loadConfig(path);
13
13
  if (options.sync) {
14
14
  const written = syncCopies(path, config);
15
15
  console.log(written.length ? written.map((file) => ` synced: ${file}`).join("\n") : " nothing to sync");
@@ -2,9 +2,9 @@ import { spawnSync } from "node:child_process";
2
2
  import { chmodSync, existsSync, lstatSync, readFileSync, renameSync, writeFileSync } from "node:fs";
3
3
  import { join, resolve } from "node:path";
4
4
  import { findProducts } from "../check.js";
5
- import { requireEstate } from "../lib/env.js";
5
+ import { requireWorkspace } from "../lib/env.js";
6
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.
7
+ // Hooks written by the tools this command replaced (the old ~/git/bin script, then zeno); they are ours to overwrite.
8
8
  const OLD_MARKS = [MARK, "installed by ~/git/bin/install-hooks", "installed by `zeno hooks install`"];
9
9
  export const HOOK = `#!/bin/sh
10
10
  # ${MARK} — run it again rather than editing this file.
@@ -44,11 +44,11 @@ export function registerHooks(program, env) {
44
44
  .description("install a pre-push hook that runs `canon check`")
45
45
  .argument("<action>", "install")
46
46
  .argument("[repo]", "product repo", ".")
47
- .option("--all", "every product in the estate")
47
+ .option("--all", "every product in the workspace")
48
48
  .action((action, repo, options) => {
49
49
  if (action !== "install")
50
50
  throw new Error(`unknown action ${action} — use: canon hooks install`);
51
- const repos = options.all ? findProducts(requireEstate(env)) : [resolve(repo)];
51
+ const repos = options.all ? findProducts(requireWorkspace(env)) : [resolve(repo)];
52
52
  for (const path of repos)
53
53
  console.log(` ${installHook(path)}`);
54
54
  });
@@ -1,7 +1,10 @@
1
+ import { homedir } from "node:os";
1
2
  import { proposals, readDecisions, readVerdicts, recordDecision, summarise } from "../learn.js";
2
3
  import { findProducts } from "../check.js";
3
- import { requireEstate } from "../lib/env.js";
4
+ import { requireWorkspace } from "../lib/env.js";
4
5
  const DEFAULT_THRESHOLD = 3;
6
+ // The decisions log lives in the user's home: it spans every product and must survive canon upgrades.
7
+ const DECISIONS_HOME = homedir();
5
8
  export function registerLearn(program, env) {
6
9
  program
7
10
  .command("learn")
@@ -11,15 +14,14 @@ export function registerLearn(program, env) {
11
14
  .option("--reject <id>", "mark a proposal rejected; it returns only with new evidence")
12
15
  .option("--note <text>", "why, for --approve / --reject", "")
13
16
  .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 entries = readVerdicts(findProducts(requireWorkspace(env)));
18
+ const found = proposals(entries, { threshold: Number(options.threshold), decisions: readDecisions(DECISIONS_HOME) });
17
19
  const target = options.approve ?? options.reject;
18
20
  if (target) {
19
21
  const proposal = found.find((p) => p.id === target);
20
22
  if (!proposal)
21
23
  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 });
24
+ recordDecision(DECISIONS_HOME, { id: target, decision: options.approve ? "approved" : "rejected", count: proposal.count, note: options.note });
23
25
  console.log(`${options.approve ? "approved" : "rejected"} ${target}`);
24
26
  return;
25
27
  }
@@ -4,12 +4,12 @@ import { stagesOf } from "../pipeline.js";
4
4
  export function registerPipeline(program, env) {
5
5
  program
6
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")
7
+ .description("the repo's stages in order — and where each stands")
8
8
  .argument("[repo]", "product repo", ".")
9
9
  .option("--json", "print JSON, for scripts")
10
10
  .action((repo, options) => {
11
11
  const root = resolve(repo);
12
- const stages = stagesOf(root, loadConfig(root, { estateRoot: env.estateRoot }).config.pipeline);
12
+ const stages = stagesOf(root, loadConfig(root).config.pipeline);
13
13
  if (options.json) {
14
14
  console.log(JSON.stringify(stages, null, 2));
15
15
  return;
@@ -0,0 +1,3 @@
1
+ import type { Command } from "commander";
2
+ import { type Env } from "../lib/env.ts";
3
+ export declare function registerScripts(program: Command, env: Env): void;
@@ -0,0 +1,32 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { join, resolve } from "node:path";
3
+ import { findProducts } from "../check.js";
4
+ import { loadConfig } from "../config/load.js";
5
+ import { requireWorkspace } from "../lib/env.js";
6
+ /** Runs one of canon's Python scripts with the terminal attached, and passes its exit code on. */
7
+ function runScript(env, script, args, extraEnv = {}) {
8
+ const result = spawnSync("python3", [join(env.canonRoot, "scripts", script), ...args], { stdio: "inherit", env: { ...process.env, ...extraEnv } });
9
+ if (result.error)
10
+ throw new Error(`could not run python3: ${result.error.message}`);
11
+ process.exitCode = result.status ?? 1;
12
+ }
13
+ export function registerScripts(program, env) {
14
+ program
15
+ .command("flow")
16
+ .description("how far each local Claude conversation about a product got against its pipeline, and how long each step took")
17
+ .argument("<product...>", "product folder names in the workspace")
18
+ .option("--write", "also write <product>/.context/metrics/flow.json")
19
+ .action((products, options) => {
20
+ const workspace = requireWorkspace(env);
21
+ const formerly = Object.fromEntries(findProducts(workspace).map((repo) => [resolve(repo).split("/").pop(), loadConfig(repo).config.formerly]));
22
+ runScript(env, "flow.py", [...products, ...(options.write ? ["--write"] : [])], {
23
+ CANON_WORKSPACE: workspace,
24
+ CANON_FLOW_FORMERLY: JSON.stringify(formerly),
25
+ });
26
+ });
27
+ program
28
+ .command("arch-set")
29
+ .description("give a design set its architecture screens from a spec (see scripts/arch-set.py)")
30
+ .argument("<spec>", "spec.json naming the set and what to draw")
31
+ .action((spec) => runScript(env, "arch-set.py", [resolve(spec)]));
32
+ }
@@ -4,7 +4,7 @@ import { constitutionTitles, resolveStandards } from "../standards.js";
4
4
  export function registerStandards(program, env) {
5
5
  program
6
6
  .command("standards")
7
- .description("list the standards that apply to a repo: canon's defaults, then the estate's and the repo's")
7
+ .description("list the standards that apply to a repo: canon's defaults, then the ones the repo imports")
8
8
  .argument("[repo]", "product repo", ".")
9
9
  .option("--json", "print machine-readable JSON")
10
10
  .option("--constitution", "print only the principle titles, canon's then any added ones")
@@ -10,7 +10,7 @@ export function registerSync(program, env) {
10
10
  .argument("[repo]", "product repo", ".")
11
11
  .action(async (repo) => {
12
12
  const path = resolve(repo);
13
- const result = await syncRepo(path, { canonRoot: env.canonRoot, estateRoot: env.estateRoot ?? undefined, cacheDir: CACHE_DIR, fetchText, run: runCommand });
13
+ const result = await syncRepo(path, { canonRoot: env.canonRoot, cacheDir: CACHE_DIR, fetchText, run: runCommand });
14
14
  console.log(`packs refreshed: ${result.packs.join(", ") || "none"}`);
15
15
  if (result.base)
16
16
  console.log("architecture base updated from canon's template");
@@ -6,7 +6,7 @@ import { formatReports } from "../format.js";
6
6
  import { CACHE_DIR } from "../lib/system.js";
7
7
  import { findProducts, runChecks } from "../check.js";
8
8
  import { buildSite, quartzConfig } from "../ui.js";
9
- import { requireEstate } from "../lib/env.js";
9
+ import { requireWorkspace } from "../lib/env.js";
10
10
  // Quartz v5 has no tagged release yet, so pin the exact commit: an unpinned
11
11
  // clone could change the site under us on the next fresh install.
12
12
  const QUARTZ_REPO = "https://github.com/jackyzha0/quartz";
@@ -37,11 +37,11 @@ export function registerUi(program, env) {
37
37
  .option("--ws-port <port>", "live-reload socket port", "4612")
38
38
  .action((options) => {
39
39
  ensureQuartz();
40
- const products = findProducts(requireEstate(env));
40
+ const products = findProducts(requireWorkspace(env));
41
41
  const siteDir = join(CACHE_DIR, "site");
42
42
  const designsDir = join(CACHE_DIR, "designs");
43
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 })));
44
+ const report = formatReports(products.map((repo) => runChecks(repo, { canonRoot: env.canonRoot })));
45
45
  buildSite(products, { siteDir, designsDir, designsUrl, report, graph: architectureGraph });
46
46
  const designs = spawn("python3", ["-m", "http.server", options.designsPort, "--bind", "127.0.0.1", "--directory", designsDir], {
47
47
  stdio: "ignore",
@@ -1,23 +1,17 @@
1
1
  import { type Config, type Kind, type Module } from "./schema.ts";
2
2
  export interface ConfigSource {
3
- layer: "estate" | "product" | "local";
3
+ layer: "product" | "local";
4
4
  path: string;
5
5
  }
6
- export interface LoadOptions {
7
- /** The estate folder; found by walking up from the repo when not given. */
8
- estateRoot?: string | null;
9
- }
10
- /** The nearest folder at or above `start` whose canon.yaml says `estate: true`, or null. */
11
- export declare function findEstate(start: string): string | null;
12
6
  export declare function inferKind(repo: string): Kind;
13
7
  export declare function inferModules(repo: string, kind: Kind): Module[];
14
8
  /**
15
- * Reads the estate, product and local canon.yaml layers over defaults inferred
16
- * from the repo. Scalars: the most specific layer wins. Lists of names add up
9
+ * Reads the repo's canon.yaml and its uncommitted canon.local.yaml over defaults
10
+ * inferred from the repo. Scalars: the local layer wins. Lists of names add up
17
11
  * (packs, standards); lists of records merge by their key (commands, tools,
18
- * wiki types, context folders).
12
+ * wiki types, context folders). Copy sets come from the source repo's canon.yaml.
19
13
  */
20
- export declare function loadConfig(repo: string, options?: LoadOptions): {
14
+ export declare function loadConfig(repo: string): {
21
15
  config: Config;
22
16
  sources: ConfigSource[];
23
17
  };