@freetesh/canon 0.17.0 → 0.18.1

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 (51) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/README.md +31 -27
  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 +2 -0
  11. package/cli/dist/checks/designs.js +13 -0
  12. package/cli/dist/checks/index.js +2 -1
  13. package/cli/dist/checks/refs.js +8 -4
  14. package/cli/dist/checks/shared-files.d.ts +1 -1
  15. package/cli/dist/checks/shared-files.js +5 -0
  16. package/cli/dist/commands/add.d.ts +1 -1
  17. package/cli/dist/commands/add.js +1 -4
  18. package/cli/dist/commands/check.js +4 -4
  19. package/cli/dist/commands/copies.js +1 -1
  20. package/cli/dist/commands/hooks.js +4 -4
  21. package/cli/dist/commands/learn.js +7 -5
  22. package/cli/dist/commands/pipeline.js +2 -2
  23. package/cli/dist/commands/scripts.d.ts +3 -0
  24. package/cli/dist/commands/scripts.js +32 -0
  25. package/cli/dist/commands/standards.js +1 -1
  26. package/cli/dist/commands/sync.js +1 -1
  27. package/cli/dist/commands/ui.js +3 -3
  28. package/cli/dist/config/load.d.ts +5 -11
  29. package/cli/dist/config/load.js +19 -23
  30. package/cli/dist/config/schema.d.ts +4 -2
  31. package/cli/dist/config/schema.js +4 -4
  32. package/cli/dist/doctor.js +2 -1
  33. package/cli/dist/index.js +4 -2
  34. package/cli/dist/learn.d.ts +2 -2
  35. package/cli/dist/learn.js +6 -6
  36. package/cli/dist/lib/env.d.ts +3 -3
  37. package/cli/dist/lib/env.js +4 -4
  38. package/cli/dist/packs.d.ts +1 -3
  39. package/cli/dist/packs.js +4 -7
  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 +1 -1
  46. package/scripts/flow.py +313 -0
  47. package/skills/ingest/SKILL.md +1 -1
  48. package/standards/context-layout.md +1 -1
  49. package/standards/library/design-sets.md +1 -1
  50. package/standards/library/secrets.md +3 -3
  51. package/standards/library/store-listing.md +1 -1
@@ -7,7 +7,7 @@
7
7
  {
8
8
  "name": "canon",
9
9
  "source": "./",
10
- "version": "0.17.0",
10
+ "version": "0.18.1",
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.17.0",
3
+ "version": "0.18.1",
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
@@ -74,57 +74,61 @@ argument captures what a session taught.
74
74
  ```bash
75
75
  canon init # write .claude/canon.yaml and the missing .context files
76
76
  canon add # list packs; `canon add expo` adds one and indexes its docs
77
- 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
78
78
  canon copies --sync # repair drifted shared files from copies.source (never adds one)
79
79
  canon hooks install # a pre-push hook that runs `canon check` (--all: every product)
80
80
  canon standards # which standards apply here, and where each comes from
81
81
  canon sync # refresh pack docs and the search index, move the pinned version
82
82
  canon status # version, kind, modules, config layers, check summary
83
- canon doctor # node, git, qmd — plus any tools your config lists
83
+ canon doctor # node, git, python3, qmd — plus any tools your config lists
84
84
  canon learn # proposals from overridden verdicts
85
85
  canon approve <stage> # a person records a pipeline gate approval
86
86
  canon pipeline # the stages this repo resolves to, and where each stands
87
87
  canon weigh "<ask>" # the decision gate from the terminal
88
88
  canon ui # every wiki and design gallery on localhost
89
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
90
92
  canon mcp # the search MCP server, for any MCP client
91
93
  ```
92
94
 
93
95
  ## Config
94
96
 
95
- `.claude/canon.yaml`, read in layers — **estate → repo → local**
96
- (`canon.local.yaml`, not committed). The estate is the nearest folder above a
97
- repo whose `canon.yaml` says `estate: true`, or `CANON_ESTATE`.
98
-
99
- ```yaml
100
- # <estate>/.claude/canon.yaml — shared by every repo below it
101
- estate: true
102
- commands: # your own checkers, run by `canon check`
103
- - { id: lint-copy, run: ./scripts/lint-copy.sh, if: .context/designs, module: design }
104
- - { id: designs-first, run: test -d .context/designs, kinds: [mobile, web] } # only repos that ship screens
105
- copies: # files products copy from one source repo, in named sets
106
- source: design-system
107
- sets:
108
- upload: [scripts/upload.mjs]
109
- shell-web:
110
- - { from: designs/mobile/index.html, to: designs/web/index.html, named: true }
111
- ```
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`.
112
101
 
113
102
  ```yaml
114
103
  # <repo>/.claude/canon.yaml — written by `canon init`
115
- canon: 0.17.0
104
+ canon: 0.18.0
116
105
  kind: mobile # mobile | web | mobile-and-web | package | content
117
106
  standards: [design-sets, pipeline, expo] # canon's library standards this repo follows; ./rules adds a folder of its own
118
107
  modules: [docs, knowledge, code, listing, refs]
119
108
  packs: [expo]
120
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
121
120
  copies:
122
- 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 }
123
125
  ```
124
126
 
125
- Scalars: the most specific layer wins. `packs`, `standards` and `copies.use`
126
- add up across layers; `commands`, `doctor.tools`, `wiki.types` and
127
- `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.
128
132
 
129
133
  ## Layout
130
134
 
@@ -136,8 +140,8 @@ hooks/ session start: constitution titles, qmd refresh
136
140
  standards/ default standards
137
141
  packs/ vendor doc packs
138
142
  templates/ files init writes (dependency-cruiser base and starters)
139
- scripts/ log-verdict.mjs — the verdict log
140
- 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)
141
145
  cli/ the canon CLI (TypeScript, vitest); `@freetesh/canon/api` for tools built on it
142
146
  ```
143
147
 
@@ -148,7 +152,7 @@ index.ts the program: registers every command
148
152
  api.ts what other tools import (`@freetesh/canon/api`)
149
153
  commands/ one file per command — options and output only
150
154
  checks/ one file per check, named by its id; index.ts lists them
151
- config/ .claude/canon.yaml: schema, layered loading, estate discovery
155
+ config/ .claude/canon.yaml: schema and loading
152
156
  lib/ shared helpers: files, frontmatter, env, qmd, system calls
153
157
  <name>.ts the logic the commands call: check (+ format for its output), init, packs (add), sync, …
154
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
  */
@@ -3,3 +3,5 @@ import type { Check } from "./types.ts";
3
3
  export declare function designSurfaces(repo: string): string[];
4
4
  /** Runs canon's design-set checker (scripts/check-designs.py) once per drawn surface. */
5
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;
@@ -1,6 +1,7 @@
1
1
  import { spawnSync } from "node:child_process";
2
2
  import { existsSync, readdirSync } from "node:fs";
3
3
  import { join } from "node:path";
4
+ import { kindCovers } from "../config/schema.js";
4
5
  const DESIGNS = ".context/designs";
5
6
  const MAX_OUTPUT_LINES = 15;
6
7
  /** Every drawn surface: a folder under .context/designs/ with a routes.js manifest. */
@@ -31,3 +32,15 @@ export const designs = {
31
32
  });
32
33
  },
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,7 +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 } from "./designs.js";
8
+ import { designs, designsFirst } from "./designs.js";
9
9
  import { features } from "./features.js";
10
10
  import { listing } from "./listing.js";
11
11
  import { metricsDefinitions } from "./metrics-definitions.js";
@@ -31,6 +31,7 @@ export const ALL_CHECKS = [
31
31
  metricsDefinitions,
32
32
  sources,
33
33
  pipeline,
34
+ designsFirst,
34
35
  designs,
35
36
  commandChecks,
36
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])
@@ -11,7 +11,7 @@ export interface Pair {
11
11
  export declare function usedCopies(config: Config): Pair[];
12
12
  export interface CopyState {
13
13
  pair: Pair;
14
- state: "same" | "drift" | "missing" | "unknown-set" | "no-source" | "unnamed";
14
+ state: "same" | "drift" | "missing" | "unknown-set" | "no-source" | "unnamed" | "no-checkout";
15
15
  /** What the product file should hold; set for a drift --sync can repair. */
16
16
  want?: string;
17
17
  }
@@ -24,6 +24,9 @@ export function compareCopies(repo, config) {
24
24
  const { source, sets, use } = config.copies;
25
25
  if (use.length === 0 || !source || resolve(source) === resolve(repo))
26
26
  return [];
27
+ // A clone of one repo (CI) has no source beside it: nothing can be compared, so say that once.
28
+ if (!existsSync(source))
29
+ return [{ pair: { set: use.join(", "), from: "", to: source, named: false }, state: "no-checkout" }];
27
30
  const unknown = use.filter((set) => !sets[set]).map((set) => ({ pair: { set, from: "", to: "", named: false }, state: "unknown-set" }));
28
31
  const productName = nameOf(repo, config);
29
32
  const sourceName = nameOf(source, loadConfig(source).config);
@@ -68,6 +71,8 @@ export const sharedFiles = {
68
71
  const source = config.copies.source ? basename(config.copies.source) : "copies.source";
69
72
  return compareCopies(repo, config).flatMap(({ pair, state }) => {
70
73
  const finding = (file, message) => [{ check: "shared-files", level: "error", file, message }];
74
+ if (state === "no-checkout")
75
+ return [{ check: "shared-files", level: "warn", file: ".claude/canon.yaml", message: `${source} is not checked out at ${pair.to}; sets ${pair.set} were not compared` }];
71
76
  if (state === "unknown-set")
72
77
  return finding(".claude/canon.yaml", `copies.use names ${pair.set}, which no copies.sets defines`);
73
78
  if (state === "no-source")
@@ -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
  };