@freetesh/canon 0.0.0-stage → 0.16.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +15 -0
- package/.claude-plugin/plugin.json +5 -0
- package/LICENSE +21 -0
- package/README.md +161 -2
- package/cli/dist/api.d.ts +3 -0
- package/cli/dist/api.js +4 -0
- package/cli/dist/check.d.ts +19 -0
- package/cli/dist/check.js +40 -0
- package/cli/dist/checks/architecture.d.ts +23 -0
- package/cli/dist/checks/architecture.js +135 -0
- package/cli/dist/checks/canonical-values.d.ts +2 -0
- package/cli/dist/checks/canonical-values.js +48 -0
- package/cli/dist/checks/commands.d.ts +7 -0
- package/cli/dist/checks/commands.js +33 -0
- package/cli/dist/checks/context-layout.d.ts +5 -0
- package/cli/dist/checks/context-layout.js +83 -0
- package/cli/dist/checks/context-size.d.ts +2 -0
- package/cli/dist/checks/context-size.js +46 -0
- package/cli/dist/checks/features.d.ts +2 -0
- package/cli/dist/checks/features.js +38 -0
- package/cli/dist/checks/index.d.ts +2 -0
- package/cli/dist/checks/index.js +34 -0
- package/cli/dist/checks/listing.d.ts +2 -0
- package/cli/dist/checks/listing.js +51 -0
- package/cli/dist/checks/metrics-definitions.d.ts +2 -0
- package/cli/dist/checks/metrics-definitions.js +14 -0
- package/cli/dist/checks/pipeline.d.ts +8 -0
- package/cli/dist/checks/pipeline.js +34 -0
- package/cli/dist/checks/refs.d.ts +2 -0
- package/cli/dist/checks/refs.js +92 -0
- package/cli/dist/checks/shared-files.d.ts +22 -0
- package/cli/dist/checks/shared-files.js +84 -0
- package/cli/dist/checks/sources.d.ts +2 -0
- package/cli/dist/checks/sources.js +40 -0
- package/cli/dist/checks/stale-copies.d.ts +2 -0
- package/cli/dist/checks/stale-copies.js +18 -0
- package/cli/dist/checks/standards-copies.d.ts +2 -0
- package/cli/dist/checks/standards-copies.js +57 -0
- package/cli/dist/checks/tasks.d.ts +2 -0
- package/cli/dist/checks/tasks.js +66 -0
- package/cli/dist/checks/types.d.ts +23 -0
- package/cli/dist/checks/types.js +1 -0
- package/cli/dist/checks/wiki-frontmatter.d.ts +3 -0
- package/cli/dist/checks/wiki-frontmatter.js +84 -0
- package/cli/dist/commands/add.d.ts +3 -0
- package/cli/dist/commands/add.js +24 -0
- package/cli/dist/commands/approve.d.ts +2 -0
- package/cli/dist/commands/approve.js +20 -0
- package/cli/dist/commands/check.d.ts +3 -0
- package/cli/dist/commands/check.js +19 -0
- package/cli/dist/commands/copies.d.ts +3 -0
- package/cli/dist/commands/copies.js +23 -0
- package/cli/dist/commands/doctor.d.ts +2 -0
- package/cli/dist/commands/doctor.js +16 -0
- package/cli/dist/commands/hooks.d.ts +6 -0
- package/cli/dist/commands/hooks.js +55 -0
- package/cli/dist/commands/init.d.ts +3 -0
- package/cli/dist/commands/init.js +78 -0
- package/cli/dist/commands/learn.d.ts +3 -0
- package/cli/dist/commands/learn.js +36 -0
- package/cli/dist/commands/mcp.d.ts +2 -0
- package/cli/dist/commands/mcp.js +11 -0
- package/cli/dist/commands/pipeline.d.ts +3 -0
- package/cli/dist/commands/pipeline.js +26 -0
- package/cli/dist/commands/search.d.ts +2 -0
- package/cli/dist/commands/search.js +13 -0
- package/cli/dist/commands/standards.d.ts +3 -0
- package/cli/dist/commands/standards.js +26 -0
- package/cli/dist/commands/status.d.ts +3 -0
- package/cli/dist/commands/status.js +11 -0
- package/cli/dist/commands/sync.d.ts +3 -0
- package/cli/dist/commands/sync.js +23 -0
- package/cli/dist/commands/ui.d.ts +3 -0
- package/cli/dist/commands/ui.js +64 -0
- package/cli/dist/commands/weigh.d.ts +2 -0
- package/cli/dist/commands/weigh.js +16 -0
- package/cli/dist/config/load.d.ts +23 -0
- package/cli/dist/config/load.js +116 -0
- package/cli/dist/config/schema.d.ts +200 -0
- package/cli/dist/config/schema.js +116 -0
- package/cli/dist/doctor.d.ts +12 -0
- package/cli/dist/doctor.js +31 -0
- package/cli/dist/format.d.ts +6 -0
- package/cli/dist/format.js +35 -0
- package/cli/dist/index.d.ts +2 -0
- package/cli/dist/index.js +48 -0
- package/cli/dist/init.d.ts +46 -0
- package/cli/dist/init.js +119 -0
- package/cli/dist/learn.d.ts +44 -0
- package/cli/dist/learn.js +80 -0
- package/cli/dist/lib/env.d.ts +7 -0
- package/cli/dist/lib/env.js +6 -0
- package/cli/dist/lib/files.d.ts +3 -0
- package/cli/dist/lib/files.js +23 -0
- package/cli/dist/lib/frontmatter.d.ts +6 -0
- package/cli/dist/lib/frontmatter.js +16 -0
- package/cli/dist/lib/qmd.d.ts +9 -0
- package/cli/dist/lib/qmd.js +13 -0
- package/cli/dist/lib/system.d.ts +4 -0
- package/cli/dist/lib/system.js +19 -0
- package/cli/dist/packs.d.ts +40 -0
- package/cli/dist/packs.js +85 -0
- package/cli/dist/pipeline.d.ts +20 -0
- package/cli/dist/pipeline.js +30 -0
- package/cli/dist/standards.d.ts +14 -0
- package/cli/dist/standards.js +42 -0
- package/cli/dist/status.d.ts +3 -0
- package/cli/dist/status.js +26 -0
- package/cli/dist/sync.d.ts +19 -0
- package/cli/dist/sync.js +30 -0
- package/cli/dist/ui.d.ts +15 -0
- package/cli/dist/ui.js +69 -0
- package/package.json +54 -4
- package/packs/expo/pack.yaml +5 -0
- package/packs/firebase/pack.yaml +5 -0
- package/packs/nextjs/pack.yaml +5 -0
- package/packs/react/pack.yaml +5 -0
- package/packs/revenuecat/pack.yaml +5 -0
- package/packs/uniwind/pack.yaml +5 -0
- package/scripts/log-verdict.mjs +56 -0
- package/skills/ask/SKILL.md +40 -0
- package/skills/decision/SKILL.md +36 -0
- package/skills/ingest/SKILL.md +45 -0
- package/skills/standards/SKILL.md +16 -0
- package/skills/weigh/SKILL.md +160 -0
- package/skills/wiki-lint/SKILL.md +67 -0
- package/standards/README.md +14 -0
- package/standards/agent-contract.md +47 -0
- package/standards/constitution.md +104 -0
- package/standards/context-layout.md +129 -0
- package/standards/dev-methods.md +29 -0
- package/standards/wiki-schema.md +71 -0
- package/templates/dependency-cruiser/app.cjs +23 -0
- package/templates/dependency-cruiser/base.cjs +52 -0
- package/templates/dependency-cruiser/package.cjs +31 -0
- package/templates/dependency-cruiser/root.cjs +15 -0
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { resolve } from "node:path";
|
|
3
|
+
import { Command } from "commander";
|
|
4
|
+
import { registerAdd } from "./commands/add.js";
|
|
5
|
+
import { registerApprove } from "./commands/approve.js";
|
|
6
|
+
import { registerCheck } from "./commands/check.js";
|
|
7
|
+
import { registerCopies } from "./commands/copies.js";
|
|
8
|
+
import { registerDoctor } from "./commands/doctor.js";
|
|
9
|
+
import { registerHooks } from "./commands/hooks.js";
|
|
10
|
+
import { registerInit } from "./commands/init.js";
|
|
11
|
+
import { registerLearn } from "./commands/learn.js";
|
|
12
|
+
import { registerPipeline } from "./commands/pipeline.js";
|
|
13
|
+
import { registerStandards } from "./commands/standards.js";
|
|
14
|
+
import { registerStatus } from "./commands/status.js";
|
|
15
|
+
import { registerSync } from "./commands/sync.js";
|
|
16
|
+
import { registerUi } from "./commands/ui.js";
|
|
17
|
+
import { registerWeigh } from "./commands/weigh.js";
|
|
18
|
+
import { findEstate } from "./config/load.js";
|
|
19
|
+
import { registerMcp } from "./commands/mcp.js";
|
|
20
|
+
import { registerSearch } from "./commands/search.js";
|
|
21
|
+
import { canonVersion } from "./status.js";
|
|
22
|
+
const env = {
|
|
23
|
+
canonRoot: resolve(import.meta.dirname, "..", ".."),
|
|
24
|
+
estateRoot: process.env.CANON_ESTATE ?? findEstate(process.cwd()),
|
|
25
|
+
};
|
|
26
|
+
const program = new Command("canon")
|
|
27
|
+
.description("Shared standards, knowledge and decision checks for every product.")
|
|
28
|
+
.version(canonVersion(env.canonRoot));
|
|
29
|
+
registerInit(program, env);
|
|
30
|
+
registerAdd(program, env);
|
|
31
|
+
registerCheck(program, env);
|
|
32
|
+
registerCopies(program, env);
|
|
33
|
+
registerSync(program, env);
|
|
34
|
+
registerStatus(program, env);
|
|
35
|
+
registerStandards(program, env);
|
|
36
|
+
registerDoctor(program);
|
|
37
|
+
registerHooks(program, env);
|
|
38
|
+
registerUi(program, env);
|
|
39
|
+
registerLearn(program, env);
|
|
40
|
+
registerApprove(program);
|
|
41
|
+
registerPipeline(program, env);
|
|
42
|
+
registerWeigh(program);
|
|
43
|
+
registerSearch(program);
|
|
44
|
+
registerMcp(program);
|
|
45
|
+
program.parseAsync().catch((error) => {
|
|
46
|
+
console.error(`canon: ${error.message}`);
|
|
47
|
+
process.exitCode = 1;
|
|
48
|
+
});
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { type Kind, type Module } from "./config/schema.ts";
|
|
2
|
+
export type Runner = (command: string, args: string[]) => void;
|
|
3
|
+
export interface InitAnswers {
|
|
4
|
+
canonVersion: string;
|
|
5
|
+
kind?: Kind;
|
|
6
|
+
modules?: Module[];
|
|
7
|
+
packs?: string[];
|
|
8
|
+
force?: boolean;
|
|
9
|
+
}
|
|
10
|
+
export interface InitPlan {
|
|
11
|
+
config: {
|
|
12
|
+
canon: string;
|
|
13
|
+
kind: Kind;
|
|
14
|
+
modules: Module[];
|
|
15
|
+
expo?: {
|
|
16
|
+
sdk: number;
|
|
17
|
+
};
|
|
18
|
+
packs?: string[];
|
|
19
|
+
};
|
|
20
|
+
/** Files under the repo that will be created; existing files are never touched. */
|
|
21
|
+
create: string[];
|
|
22
|
+
}
|
|
23
|
+
export declare const ARCHITECTURE_BASE = ".dependency-cruiser.base.cjs";
|
|
24
|
+
export interface Starter {
|
|
25
|
+
file: string;
|
|
26
|
+
template: "base.cjs" | "root.cjs" | "app.cjs" | "package.cjs";
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* The rules files the architecture module needs: canon's managed base, then a starter
|
|
30
|
+
* per workspace (a folder under apps/ or packages/ with a package.json), or one at the
|
|
31
|
+
* root when the repo has no workspaces.
|
|
32
|
+
*/
|
|
33
|
+
export declare function architectureStarters(repo: string): Starter[];
|
|
34
|
+
/** Rewrites a repo's managed architecture base from canon's template; true when it changed. */
|
|
35
|
+
export declare function refreshArchitectureBase(repo: string, canonRoot: string): boolean;
|
|
36
|
+
/** Parses a `--modules`/`--packs` style comma list, rejecting anything not in `allowed`. */
|
|
37
|
+
export declare function parseChoices<T extends string>(value: string, allowed: readonly T[], label: string): T[];
|
|
38
|
+
export declare function detectExpoSdk(repo: string): number | undefined;
|
|
39
|
+
export declare function planInit(repo: string, answers: InitAnswers): InitPlan;
|
|
40
|
+
export declare function applyInit(repo: string, plan: InitPlan, options: {
|
|
41
|
+
run: Runner;
|
|
42
|
+
name: string;
|
|
43
|
+
canonRoot: string;
|
|
44
|
+
}): void;
|
|
45
|
+
/** Adds the repo's wiki and features to qmd. Safe to repeat: qmd refuses a name it already has. */
|
|
46
|
+
export declare function indexKnowledge(repo: string, name: string, run: Runner): void;
|
package/cli/dist/init.js
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
import { stringify } from "yaml";
|
|
4
|
+
import { RULES_FILES } from "./checks/architecture.js";
|
|
5
|
+
import { requiredContext } from "./checks/context-layout.js";
|
|
6
|
+
import { inferKind, inferModules } from "./config/load.js";
|
|
7
|
+
import { kindCovers, MODULES_BY_KIND } from "./config/schema.js";
|
|
8
|
+
export const ARCHITECTURE_BASE = ".dependency-cruiser.base.cjs";
|
|
9
|
+
const ARCHITECTURE_TEMPLATES = join("templates", "dependency-cruiser");
|
|
10
|
+
const WORKSPACE_PARENTS = ["apps", "packages"];
|
|
11
|
+
/**
|
|
12
|
+
* The rules files the architecture module needs: canon's managed base, then a starter
|
|
13
|
+
* per workspace (a folder under apps/ or packages/ with a package.json), or one at the
|
|
14
|
+
* root when the repo has no workspaces.
|
|
15
|
+
*/
|
|
16
|
+
export function architectureStarters(repo) {
|
|
17
|
+
const workspaces = WORKSPACE_PARENTS.flatMap((parent) => existsSync(join(repo, parent))
|
|
18
|
+
? readdirSync(join(repo, parent), { withFileTypes: true })
|
|
19
|
+
.filter((entry) => entry.isDirectory() && existsSync(join(repo, parent, entry.name, "package.json")))
|
|
20
|
+
.map((entry) => `${parent}/${entry.name}`)
|
|
21
|
+
.sort()
|
|
22
|
+
: []);
|
|
23
|
+
const starters = workspaces.length > 0
|
|
24
|
+
? workspaces.map((dir) => ({ file: `${dir}/${RULES_FILES[0]}`, template: dir.startsWith("apps/") ? "app.cjs" : "package.cjs" }))
|
|
25
|
+
: [{ file: RULES_FILES[0], template: "root.cjs" }];
|
|
26
|
+
return [{ file: ARCHITECTURE_BASE, template: "base.cjs" }, ...starters];
|
|
27
|
+
}
|
|
28
|
+
/** Rewrites a repo's managed architecture base from canon's template; true when it changed. */
|
|
29
|
+
export function refreshArchitectureBase(repo, canonRoot) {
|
|
30
|
+
const file = join(repo, ARCHITECTURE_BASE);
|
|
31
|
+
if (!existsSync(file))
|
|
32
|
+
return false;
|
|
33
|
+
const current = readFileSync(join(canonRoot, ARCHITECTURE_TEMPLATES, "base.cjs"), "utf8");
|
|
34
|
+
if (readFileSync(file, "utf8") === current)
|
|
35
|
+
return false;
|
|
36
|
+
writeFileSync(file, current);
|
|
37
|
+
return true;
|
|
38
|
+
}
|
|
39
|
+
/** Parses a `--modules`/`--packs` style comma list, rejecting anything not in `allowed`. */
|
|
40
|
+
export function parseChoices(value, allowed, label) {
|
|
41
|
+
const chosen = value.split(",").map((item) => item.trim()).filter(Boolean);
|
|
42
|
+
const unknown = chosen.find((item) => !allowed.includes(item));
|
|
43
|
+
if (unknown)
|
|
44
|
+
throw new Error(`unknown ${label} "${unknown}" — choose from: ${allowed.join(", ")}`);
|
|
45
|
+
return chosen;
|
|
46
|
+
}
|
|
47
|
+
const PACKAGE_FILES = ["package.json", "apps/mobile/package.json"];
|
|
48
|
+
export function detectExpoSdk(repo) {
|
|
49
|
+
for (const file of PACKAGE_FILES) {
|
|
50
|
+
const path = join(repo, file);
|
|
51
|
+
if (!existsSync(path))
|
|
52
|
+
continue;
|
|
53
|
+
const pkg = JSON.parse(readFileSync(path, "utf8"));
|
|
54
|
+
const range = pkg.dependencies?.expo ?? pkg.devDependencies?.expo;
|
|
55
|
+
const major = range?.match(/\d+/)?.[0];
|
|
56
|
+
if (major)
|
|
57
|
+
return Number(major);
|
|
58
|
+
}
|
|
59
|
+
return undefined;
|
|
60
|
+
}
|
|
61
|
+
// The first file each required .context entry gets, so no folder is created empty.
|
|
62
|
+
const FIRST_FILE = {
|
|
63
|
+
"README.md": ".context/README.md",
|
|
64
|
+
"tasks.md": ".context/tasks.md",
|
|
65
|
+
raw: ".context/raw/.gitkeep",
|
|
66
|
+
wiki: ".context/wiki/index.md",
|
|
67
|
+
features: ".context/features/index.md",
|
|
68
|
+
};
|
|
69
|
+
function template(file) {
|
|
70
|
+
switch (file) {
|
|
71
|
+
case ".context/README.md":
|
|
72
|
+
return "# .context\n\nProduct material, in the canon context layout. Start at [`tasks.md`](tasks.md).\n";
|
|
73
|
+
case ".context/tasks.md":
|
|
74
|
+
return "# Tasks\n\n## Now\n\n## Blocked\n\n## Waiting\n";
|
|
75
|
+
case ".context/wiki/index.md":
|
|
76
|
+
return "# Wiki index\n\nPages by type. Schema: the canon plugin's wiki-schema standard.\n";
|
|
77
|
+
case ".context/features/index.md":
|
|
78
|
+
return "# Features\n\n| ID | Title | Status | Release |\n|---|---|---|---|\n";
|
|
79
|
+
default:
|
|
80
|
+
return "";
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
export function planInit(repo, answers) {
|
|
84
|
+
if (existsSync(join(repo, ".claude", "canon.yaml")) && !answers.force) {
|
|
85
|
+
throw new Error(`${repo} is already set up (.claude/canon.yaml exists) — use --force to redo it`);
|
|
86
|
+
}
|
|
87
|
+
const kind = answers.kind ?? inferKind(repo);
|
|
88
|
+
const modules = answers.modules ?? (answers.kind ? MODULES_BY_KIND[kind] : inferModules(repo, kind));
|
|
89
|
+
const sdk = kindCovers(kind, "mobile") ? detectExpoSdk(repo) : undefined;
|
|
90
|
+
const packs = answers.packs?.length ? { packs: answers.packs } : {};
|
|
91
|
+
const config = { canon: answers.canonVersion, kind, modules, ...(sdk ? { expo: { sdk } } : {}), ...packs };
|
|
92
|
+
const create = requiredContext(modules).filter((name) => !existsSync(join(repo, ".context", name))).map((name) => FIRST_FILE[name]);
|
|
93
|
+
if (modules.includes("architecture")) {
|
|
94
|
+
// A workspace that already has rules of its own (any name dependency-cruiser reads) keeps them.
|
|
95
|
+
const taken = (file) => file === ARCHITECTURE_BASE ? existsSync(join(repo, file)) : RULES_FILES.some((name) => existsSync(join(repo, dirname(file), name)));
|
|
96
|
+
create.push(...architectureStarters(repo).map((s) => s.file).filter((file) => !taken(file)));
|
|
97
|
+
}
|
|
98
|
+
return { config, create };
|
|
99
|
+
}
|
|
100
|
+
export function applyInit(repo, plan, options) {
|
|
101
|
+
const write = (file, content) => {
|
|
102
|
+
mkdirSync(dirname(join(repo, file)), { recursive: true });
|
|
103
|
+
writeFileSync(join(repo, file), content);
|
|
104
|
+
};
|
|
105
|
+
write(".claude/canon.yaml", stringify(plan.config));
|
|
106
|
+
const starters = new Map(architectureStarters(repo).map((s) => [s.file, s.template]));
|
|
107
|
+
for (const file of plan.create) {
|
|
108
|
+
const starter = starters.get(file);
|
|
109
|
+
write(file, starter ? readFileSync(join(options.canonRoot, ARCHITECTURE_TEMPLATES, starter), "utf8") : template(file));
|
|
110
|
+
}
|
|
111
|
+
if (plan.config.modules.includes("knowledge"))
|
|
112
|
+
indexKnowledge(repo, options.name, options.run);
|
|
113
|
+
}
|
|
114
|
+
/** Adds the repo's wiki and features to qmd. Safe to repeat: qmd refuses a name it already has. */
|
|
115
|
+
export function indexKnowledge(repo, name, run) {
|
|
116
|
+
for (const kind of ["wiki", "features"]) {
|
|
117
|
+
run("qmd", ["collection", "add", join(repo, ".context", kind), "--name", `${name}-${kind}`]);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
export interface VerdictEntry {
|
|
2
|
+
product: string;
|
|
3
|
+
at: string;
|
|
4
|
+
verdict: string;
|
|
5
|
+
principles: string[];
|
|
6
|
+
request: string;
|
|
7
|
+
reason: string;
|
|
8
|
+
/** What happened next. Lines written before every verdict was logged have none, and were all overrides. */
|
|
9
|
+
outcome?: Outcome;
|
|
10
|
+
}
|
|
11
|
+
export declare const OUTCOMES: readonly ["proceeded", "stopped", "overridden", "dropped"];
|
|
12
|
+
export type Outcome = (typeof OUTCOMES)[number];
|
|
13
|
+
export type Summary = {
|
|
14
|
+
total: number;
|
|
15
|
+
} & Record<Outcome, number>;
|
|
16
|
+
/** How often the gate let work through, stopped it, or was overridden. */
|
|
17
|
+
export declare function summarise(entries: VerdictEntry[]): Summary;
|
|
18
|
+
export interface Decision {
|
|
19
|
+
id: string;
|
|
20
|
+
decision: "approved" | "rejected";
|
|
21
|
+
/** How many overrides the proposal had when it was decided. */
|
|
22
|
+
count: number;
|
|
23
|
+
at: string;
|
|
24
|
+
note: string;
|
|
25
|
+
}
|
|
26
|
+
export interface Proposal {
|
|
27
|
+
id: string;
|
|
28
|
+
summary: string;
|
|
29
|
+
count: number;
|
|
30
|
+
products: string[];
|
|
31
|
+
reasons: string[];
|
|
32
|
+
}
|
|
33
|
+
export declare function readVerdicts(repos: string[]): VerdictEntry[];
|
|
34
|
+
/**
|
|
35
|
+
* Turns override history into proposals: a principle overridden `threshold`+
|
|
36
|
+
* times, or one reason given in two or more products. A decided proposal comes
|
|
37
|
+
* back only when its count has grown past what it was when decided.
|
|
38
|
+
*/
|
|
39
|
+
export declare function proposals(all: VerdictEntry[], options: {
|
|
40
|
+
threshold: number;
|
|
41
|
+
decisions: Decision[];
|
|
42
|
+
}): Proposal[];
|
|
43
|
+
export declare function readDecisions(estateRoot: string): Decision[];
|
|
44
|
+
export declare function recordDecision(estateRoot: string, decision: Omit<Decision, "at">): void;
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { appendFileSync, existsSync, mkdirSync, readFileSync } from "node:fs";
|
|
2
|
+
import { basename, join } from "node:path";
|
|
3
|
+
export const OUTCOMES = ["proceeded", "stopped", "overridden", "dropped"];
|
|
4
|
+
const outcomeOf = (entry) => entry.outcome ?? "overridden";
|
|
5
|
+
/** How often the gate let work through, stopped it, or was overridden. */
|
|
6
|
+
export function summarise(entries) {
|
|
7
|
+
const counts = Object.fromEntries(OUTCOMES.map((o) => [o, 0]));
|
|
8
|
+
for (const entry of entries)
|
|
9
|
+
counts[outcomeOf(entry)] += 1;
|
|
10
|
+
return { total: entries.length, ...counts };
|
|
11
|
+
}
|
|
12
|
+
const LOG = join(".context", "raw", "canon", "verdicts.jsonl");
|
|
13
|
+
const MAX_REASONS = 5;
|
|
14
|
+
function readJsonLines(path) {
|
|
15
|
+
if (!existsSync(path))
|
|
16
|
+
return [];
|
|
17
|
+
return readFileSync(path, "utf8")
|
|
18
|
+
.split("\n")
|
|
19
|
+
.flatMap((line) => {
|
|
20
|
+
try {
|
|
21
|
+
return line.trim() ? [JSON.parse(line)] : [];
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
return [];
|
|
25
|
+
}
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
export function readVerdicts(repos) {
|
|
29
|
+
return repos.flatMap((repo) => readJsonLines(join(repo, LOG)).map((entry) => ({ ...entry, product: basename(repo) })));
|
|
30
|
+
}
|
|
31
|
+
function group(entries, keyOf) {
|
|
32
|
+
const groups = new Map();
|
|
33
|
+
for (const entry of entries) {
|
|
34
|
+
for (const key of keyOf(entry))
|
|
35
|
+
groups.set(key, [...(groups.get(key) ?? []), entry]);
|
|
36
|
+
}
|
|
37
|
+
return groups;
|
|
38
|
+
}
|
|
39
|
+
function toProposal(id, summary, entries) {
|
|
40
|
+
return {
|
|
41
|
+
id,
|
|
42
|
+
summary,
|
|
43
|
+
count: entries.length,
|
|
44
|
+
products: [...new Set(entries.map((e) => e.product))].sort(),
|
|
45
|
+
reasons: [...new Set(entries.map((e) => e.reason))].slice(0, MAX_REASONS),
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Turns override history into proposals: a principle overridden `threshold`+
|
|
50
|
+
* times, or one reason given in two or more products. A decided proposal comes
|
|
51
|
+
* back only when its count has grown past what it was when decided.
|
|
52
|
+
*/
|
|
53
|
+
export function proposals(all, options) {
|
|
54
|
+
// Only an override says a rule may be wrong; a verdict the user followed says nothing about it.
|
|
55
|
+
const entries = all.filter((entry) => outcomeOf(entry) === "overridden");
|
|
56
|
+
const found = [];
|
|
57
|
+
for (const [principle, group_] of group(entries, (e) => e.principles)) {
|
|
58
|
+
if (group_.length >= options.threshold) {
|
|
59
|
+
found.push(toProposal(`principle:${principle}`, `${principle} was overridden ${group_.length} times — revisit the rule or its wording`, group_));
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
for (const [reason, group_] of group(entries, (e) => [e.reason])) {
|
|
63
|
+
const products = new Set(group_.map((e) => e.product));
|
|
64
|
+
if (products.size >= 2) {
|
|
65
|
+
found.push(toProposal(`reason:${reason}`, `"${reason}" was given in ${products.size} products — a missing exception?`, group_));
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
const decidedAt = new Map(options.decisions.map((d) => [d.id, d.count]));
|
|
69
|
+
return found.filter((p) => !decidedAt.has(p.id) || p.count > (decidedAt.get(p.id) ?? 0));
|
|
70
|
+
}
|
|
71
|
+
// In the estate, not the canon install: a global npm install may be read-only and is replaced on upgrade.
|
|
72
|
+
const DECISIONS_DIR = join(".claude", "canon");
|
|
73
|
+
const DECISIONS = join(DECISIONS_DIR, "decisions.jsonl");
|
|
74
|
+
export function readDecisions(estateRoot) {
|
|
75
|
+
return readJsonLines(join(estateRoot, DECISIONS));
|
|
76
|
+
}
|
|
77
|
+
export function recordDecision(estateRoot, decision) {
|
|
78
|
+
mkdirSync(join(estateRoot, DECISIONS_DIR), { recursive: true });
|
|
79
|
+
appendFileSync(join(estateRoot, DECISIONS), `${JSON.stringify({ ...decision, at: new Date().toISOString() })}\n`);
|
|
80
|
+
}
|
|
@@ -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,23 @@
|
|
|
1
|
+
import { existsSync, readdirSync, statSync } from "node:fs";
|
|
2
|
+
import { join, relative } from "node:path";
|
|
3
|
+
/** Every file under `dir` whose name matches `test`, as paths relative to `root`. Missing dir → []. */
|
|
4
|
+
export function listFiles(root, dir, test) {
|
|
5
|
+
const start = join(root, dir);
|
|
6
|
+
if (!existsSync(start))
|
|
7
|
+
return [];
|
|
8
|
+
const found = [];
|
|
9
|
+
const walk = (current) => {
|
|
10
|
+
for (const entry of readdirSync(current, { withFileTypes: true })) {
|
|
11
|
+
const full = join(current, entry.name);
|
|
12
|
+
if (entry.isDirectory())
|
|
13
|
+
walk(full);
|
|
14
|
+
else if (test(entry.name))
|
|
15
|
+
found.push(relative(root, full));
|
|
16
|
+
}
|
|
17
|
+
};
|
|
18
|
+
walk(start);
|
|
19
|
+
return found.sort();
|
|
20
|
+
}
|
|
21
|
+
export function isDirectory(path) {
|
|
22
|
+
return existsSync(path) && statSync(path).isDirectory();
|
|
23
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { parse } from "yaml";
|
|
2
|
+
const FRONTMATTER = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/;
|
|
3
|
+
/** Splits YAML frontmatter from a Markdown body. Unparseable or absent frontmatter → data null. */
|
|
4
|
+
export function parseFrontmatter(text) {
|
|
5
|
+
const match = FRONTMATTER.exec(text);
|
|
6
|
+
if (!match)
|
|
7
|
+
return { data: null, body: text };
|
|
8
|
+
try {
|
|
9
|
+
const data = parse(match[1]);
|
|
10
|
+
const isObject = typeof data === "object" && data !== null && !Array.isArray(data);
|
|
11
|
+
return { data: isObject ? data : null, body: text.slice(match[0].length) };
|
|
12
|
+
}
|
|
13
|
+
catch {
|
|
14
|
+
return { data: null, body: text.slice(match[0].length) };
|
|
15
|
+
}
|
|
16
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { type SpawnSyncOptions } from "node:child_process";
|
|
2
|
+
export interface QmdResult {
|
|
3
|
+
ok: boolean;
|
|
4
|
+
out: string;
|
|
5
|
+
err: string;
|
|
6
|
+
}
|
|
7
|
+
export declare function qmd(args: string[], options?: SpawnSyncOptions): QmdResult;
|
|
8
|
+
export declare function qmdQuery(query: string, collections?: string[]): QmdResult;
|
|
9
|
+
export declare function qmdRefresh(): void;
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
// qmd subprocess wrapper — the retrieval engine behind search and the site index.
|
|
2
|
+
import { spawnSync } from "node:child_process";
|
|
3
|
+
export function qmd(args, options = {}) {
|
|
4
|
+
const result = spawnSync("qmd", args, { encoding: "utf8", ...options });
|
|
5
|
+
return { ok: result.status === 0, out: String(result.stdout ?? ""), err: String(result.stderr ?? "") };
|
|
6
|
+
}
|
|
7
|
+
export function qmdQuery(query, collections = []) {
|
|
8
|
+
return qmd(["query", query, ...collections.flatMap((collection) => ["-c", collection])]);
|
|
9
|
+
}
|
|
10
|
+
export function qmdRefresh() {
|
|
11
|
+
qmd(["update"]);
|
|
12
|
+
qmd(["embed"]);
|
|
13
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
export const CACHE_DIR = process.env.CANON_CACHE ?? join(homedir(), ".cache", "canon");
|
|
5
|
+
const FETCH_TIMEOUT_MS = 30_000;
|
|
6
|
+
export function runCommand(command, args) {
|
|
7
|
+
const result = spawnSync(command, args, { encoding: "utf8" });
|
|
8
|
+
if (result.error) {
|
|
9
|
+
const missing = result.error.code === "ENOENT";
|
|
10
|
+
return { status: 1, output: missing ? `${command} is not installed — run \`canon doctor\`` : result.error.message };
|
|
11
|
+
}
|
|
12
|
+
return { status: result.status ?? 1, output: `${result.stdout}${result.stderr}` };
|
|
13
|
+
}
|
|
14
|
+
export async function fetchText(url) {
|
|
15
|
+
const response = await fetch(url, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) });
|
|
16
|
+
if (!response.ok)
|
|
17
|
+
throw new Error(`HTTP ${response.status}`);
|
|
18
|
+
return response.text();
|
|
19
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
declare const packSchema: z.ZodObject<{
|
|
3
|
+
name: z.ZodString;
|
|
4
|
+
description: z.ZodString;
|
|
5
|
+
kinds: z.ZodArray<z.ZodEnum<{
|
|
6
|
+
content: "content";
|
|
7
|
+
mobile: "mobile";
|
|
8
|
+
"mobile-and-web": "mobile-and-web";
|
|
9
|
+
package: "package";
|
|
10
|
+
web: "web";
|
|
11
|
+
}>>;
|
|
12
|
+
skill: z.ZodOptional<z.ZodString>;
|
|
13
|
+
docs: z.ZodArray<z.ZodURL>;
|
|
14
|
+
}, z.core.$strict>;
|
|
15
|
+
export type Pack = z.infer<typeof packSchema>;
|
|
16
|
+
export interface RunResult {
|
|
17
|
+
status: number;
|
|
18
|
+
output: string;
|
|
19
|
+
}
|
|
20
|
+
export type CommandRunner = (command: string, args: string[]) => RunResult;
|
|
21
|
+
export declare function listPacks(canonRoot: string): Pack[];
|
|
22
|
+
export declare function findPack(canonRoot: string, name: string): Pack;
|
|
23
|
+
interface AddOptions {
|
|
24
|
+
canonRoot: string;
|
|
25
|
+
global?: boolean;
|
|
26
|
+
estateRoot?: string;
|
|
27
|
+
}
|
|
28
|
+
/** Records a pack in canon.yaml (product, or estate with --global), keeping the file's comments and other keys. */
|
|
29
|
+
export declare function addPack(repo: string, name: string, options: AddOptions): {
|
|
30
|
+
file: string;
|
|
31
|
+
added: boolean;
|
|
32
|
+
};
|
|
33
|
+
interface FetchOptions {
|
|
34
|
+
cacheDir: string;
|
|
35
|
+
fetchText: (url: string) => Promise<string>;
|
|
36
|
+
run: CommandRunner;
|
|
37
|
+
}
|
|
38
|
+
/** Downloads a pack's vendor docs into the cache as Markdown and indexes them in QMD as `pack-<name>`. */
|
|
39
|
+
export declare function fetchPackDocs(pack: Pack, options: FetchOptions): Promise<string>;
|
|
40
|
+
export {};
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
import { parse, parseDocument } from "yaml";
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
import { KINDS } from "./config/schema.js";
|
|
6
|
+
const packSchema = z
|
|
7
|
+
.object({
|
|
8
|
+
name: z.string().regex(/^[a-z0-9-]+$/),
|
|
9
|
+
description: z.string(),
|
|
10
|
+
kinds: z.array(z.enum(KINDS)),
|
|
11
|
+
skill: z.string().optional(),
|
|
12
|
+
docs: z.array(z.url()),
|
|
13
|
+
})
|
|
14
|
+
.strict();
|
|
15
|
+
export function listPacks(canonRoot) {
|
|
16
|
+
const root = join(canonRoot, "packs");
|
|
17
|
+
if (!existsSync(root))
|
|
18
|
+
return [];
|
|
19
|
+
return readdirSync(root, { withFileTypes: true })
|
|
20
|
+
.filter((entry) => entry.isDirectory() && existsSync(join(root, entry.name, "pack.yaml")))
|
|
21
|
+
.map((entry) => {
|
|
22
|
+
const pack = packSchema.parse(parse(readFileSync(join(root, entry.name, "pack.yaml"), "utf8")));
|
|
23
|
+
if (pack.name !== entry.name)
|
|
24
|
+
throw new Error(`packs/${entry.name}/pack.yaml is named "${pack.name}"`);
|
|
25
|
+
return pack;
|
|
26
|
+
})
|
|
27
|
+
.sort((a, b) => a.name.localeCompare(b.name));
|
|
28
|
+
}
|
|
29
|
+
export function findPack(canonRoot, name) {
|
|
30
|
+
const packs = listPacks(canonRoot);
|
|
31
|
+
const pack = packs.find((candidate) => candidate.name === name);
|
|
32
|
+
if (!pack)
|
|
33
|
+
throw new Error(`no pack "${name}" — available: ${packs.map((p) => p.name).join(", ")}`);
|
|
34
|
+
return pack;
|
|
35
|
+
}
|
|
36
|
+
/** Records a pack in canon.yaml (product, or estate with --global), keeping the file's comments and other keys. */
|
|
37
|
+
export function addPack(repo, name, options) {
|
|
38
|
+
findPack(options.canonRoot, name);
|
|
39
|
+
if (options.global && !options.estateRoot)
|
|
40
|
+
throw new Error("--global needs an estate (a folder whose canon.yaml says estate: true)");
|
|
41
|
+
const base = options.global ? options.estateRoot : repo;
|
|
42
|
+
const file = join(base, ".claude", "canon.yaml");
|
|
43
|
+
if (!existsSync(file) && !options.global)
|
|
44
|
+
throw new Error(`${repo} has no .claude/canon.yaml — run \`canon init\` first`);
|
|
45
|
+
const document = parseDocument(existsSync(file) ? readFileSync(file, "utf8") : "");
|
|
46
|
+
const current = document.get("packs")?.toJSON() ?? [];
|
|
47
|
+
if (current.includes(name))
|
|
48
|
+
return { file, added: false };
|
|
49
|
+
document.set("packs", [...current, name]);
|
|
50
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
51
|
+
writeFileSync(file, document.toString());
|
|
52
|
+
return { file, added: true };
|
|
53
|
+
}
|
|
54
|
+
function docFileName(url) {
|
|
55
|
+
const { hostname, pathname } = new URL(url);
|
|
56
|
+
const stem = pathname.split("/").filter(Boolean).pop()?.replace(/\.[a-z]+$/, "") ?? "index";
|
|
57
|
+
return `${hostname}-${stem}.md`;
|
|
58
|
+
}
|
|
59
|
+
/** Downloads a pack's vendor docs into the cache as Markdown and indexes them in QMD as `pack-<name>`. */
|
|
60
|
+
export async function fetchPackDocs(pack, options) {
|
|
61
|
+
const dir = join(options.cacheDir, "packs", pack.name);
|
|
62
|
+
mkdirSync(dir, { recursive: true });
|
|
63
|
+
for (const url of pack.docs) {
|
|
64
|
+
let text;
|
|
65
|
+
try {
|
|
66
|
+
text = await options.fetchText(url);
|
|
67
|
+
}
|
|
68
|
+
catch (error) {
|
|
69
|
+
throw new Error(`could not download ${url}: ${error.message}`);
|
|
70
|
+
}
|
|
71
|
+
writeFileSync(join(dir, docFileName(url)), text);
|
|
72
|
+
}
|
|
73
|
+
const collection = `pack-${pack.name}`;
|
|
74
|
+
const added = options.run("qmd", ["collection", "add", dir, "--name", collection]);
|
|
75
|
+
if (added.status === 0) {
|
|
76
|
+
options.run("qmd", ["context", "add", `qmd://${collection}`, `${pack.description.split(" — ")[0]} — vendor docs index (${pack.name} pack)`]);
|
|
77
|
+
}
|
|
78
|
+
else if (/already exists/i.test(added.output)) {
|
|
79
|
+
options.run("qmd", ["update"]);
|
|
80
|
+
}
|
|
81
|
+
else {
|
|
82
|
+
throw new Error(`qmd could not index ${dir}: ${added.output.trim()}`);
|
|
83
|
+
}
|
|
84
|
+
return dir;
|
|
85
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { StageSpec } from "./config/schema.ts";
|
|
2
|
+
export interface Approval {
|
|
3
|
+
stage: string;
|
|
4
|
+
/** When a person approved the gate. Approvals are never backdated. */
|
|
5
|
+
at: string;
|
|
6
|
+
note: string;
|
|
7
|
+
}
|
|
8
|
+
export declare const GATES_LOG: string;
|
|
9
|
+
export declare function readApprovals(repo: string): Approval[];
|
|
10
|
+
export declare function recordApproval(repo: string, stage: string, note: string): Approval;
|
|
11
|
+
export interface StageState {
|
|
12
|
+
id: string;
|
|
13
|
+
gate: boolean;
|
|
14
|
+
/** The first `done` path that exists, or null when the stage has not started. */
|
|
15
|
+
startedAt: string | null;
|
|
16
|
+
/** Whether a person approved this gate; null for a stage with no gate. */
|
|
17
|
+
approved: boolean | null;
|
|
18
|
+
}
|
|
19
|
+
/** The repo's resolved stages, in order, with where each stands. */
|
|
20
|
+
export declare function stagesOf(repo: string, stages: StageSpec[]): StageState[];
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { appendFileSync, existsSync, mkdirSync, readFileSync } from "node:fs";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
// In raw/, which is append-only: the dates are the record of how long each stage waited.
|
|
4
|
+
export const GATES_LOG = join(".context", "raw", "canon", "gates.jsonl");
|
|
5
|
+
export function readApprovals(repo) {
|
|
6
|
+
const path = join(repo, GATES_LOG);
|
|
7
|
+
if (!existsSync(path))
|
|
8
|
+
return [];
|
|
9
|
+
return readFileSync(path, "utf8")
|
|
10
|
+
.split("\n")
|
|
11
|
+
.filter((line) => line.trim())
|
|
12
|
+
.map((line) => JSON.parse(line));
|
|
13
|
+
}
|
|
14
|
+
export function recordApproval(repo, stage, note) {
|
|
15
|
+
const approval = { stage, at: new Date().toISOString(), note };
|
|
16
|
+
const path = join(repo, GATES_LOG);
|
|
17
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
18
|
+
appendFileSync(path, `${JSON.stringify(approval)}\n`);
|
|
19
|
+
return approval;
|
|
20
|
+
}
|
|
21
|
+
/** The repo's resolved stages, in order, with where each stands. */
|
|
22
|
+
export function stagesOf(repo, stages) {
|
|
23
|
+
const approved = new Set(readApprovals(repo).map((approval) => approval.stage));
|
|
24
|
+
return stages.map((stage) => ({
|
|
25
|
+
id: stage.id,
|
|
26
|
+
gate: stage.gate,
|
|
27
|
+
startedAt: stage.done.find((path) => existsSync(join(repo, path))) ?? null,
|
|
28
|
+
approved: stage.gate ? approved.has(stage.id) : null,
|
|
29
|
+
}));
|
|
30
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export interface Standard {
|
|
2
|
+
name: string;
|
|
3
|
+
path: string;
|
|
4
|
+
/** "canon" for a built-in default, else the configured folder it came from. */
|
|
5
|
+
from: string;
|
|
6
|
+
}
|
|
7
|
+
/** canon's default standards, then each configured folder in order; a later file of the same name replaces an earlier one. */
|
|
8
|
+
export declare function resolveStandards(canonRoot: string, folders: string[]): Standard[];
|
|
9
|
+
/**
|
|
10
|
+
* Principle headings from canon's constitution and every configured one. A
|
|
11
|
+
* constitution in a folder extends rather than replaces: it adds principles,
|
|
12
|
+
* and restating an ID rewords it.
|
|
13
|
+
*/
|
|
14
|
+
export declare function constitutionTitles(canonRoot: string, folders: string[]): string[];
|