@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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +37 -31
- package/cli/dist/api.d.ts +1 -2
- package/cli/dist/api.js +1 -2
- package/cli/dist/check.d.ts +7 -3
- package/cli/dist/check.js +18 -7
- package/cli/dist/checks/commands.d.ts +1 -1
- package/cli/dist/checks/commands.js +1 -1
- package/cli/dist/checks/designs.d.ts +7 -0
- package/cli/dist/checks/designs.js +46 -0
- package/cli/dist/checks/index.js +3 -0
- package/cli/dist/checks/refs.js +8 -4
- package/cli/dist/commands/add.d.ts +1 -1
- package/cli/dist/commands/add.js +1 -4
- package/cli/dist/commands/check.js +4 -4
- package/cli/dist/commands/copies.js +1 -1
- package/cli/dist/commands/hooks.js +4 -4
- package/cli/dist/commands/learn.js +7 -5
- package/cli/dist/commands/pipeline.js +2 -2
- package/cli/dist/commands/scripts.d.ts +3 -0
- package/cli/dist/commands/scripts.js +32 -0
- package/cli/dist/commands/standards.js +1 -1
- package/cli/dist/commands/sync.js +1 -1
- package/cli/dist/commands/ui.js +3 -3
- package/cli/dist/config/load.d.ts +5 -11
- package/cli/dist/config/load.js +21 -25
- package/cli/dist/config/schema.d.ts +6 -2
- package/cli/dist/config/schema.js +9 -5
- package/cli/dist/doctor.js +2 -1
- package/cli/dist/index.js +4 -2
- package/cli/dist/learn.d.ts +2 -2
- package/cli/dist/learn.js +6 -6
- package/cli/dist/lib/env.d.ts +3 -3
- package/cli/dist/lib/env.js +4 -4
- package/cli/dist/packs.d.ts +1 -3
- package/cli/dist/packs.js +4 -7
- package/cli/dist/standards.d.ts +11 -6
- package/cli/dist/standards.js +37 -17
- package/cli/dist/status.js +1 -1
- package/cli/dist/sync.d.ts +0 -1
- package/cli/dist/sync.js +1 -1
- package/package.json +4 -1
- package/scripts/arch-set.py +140 -0
- package/scripts/check-designs.py +709 -0
- package/scripts/flow.py +313 -0
- package/skills/ingest/SKILL.md +1 -1
- package/standards/README.md +23 -6
- package/standards/agent-contract.md +21 -6
- package/standards/context-layout.md +42 -7
- package/standards/library/design-sets.md +857 -0
- package/standards/library/expo.md +54 -0
- package/standards/library/frontend.md +44 -0
- package/standards/library/performance.md +45 -0
- package/standards/library/pipeline.md +42 -0
- package/standards/library/secrets.md +37 -0
- package/standards/library/store-listing.md +122 -0
package/scripts/flow.py
ADDED
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""How far each Claude conversation about a product got, measured against the
|
|
3
|
+
product's own stages (`canon pipeline`), and how long each step took. Counts
|
|
4
|
+
and times only; no prompt text leaves this machine.
|
|
5
|
+
|
|
6
|
+
canon flow askcal print the numbers
|
|
7
|
+
canon flow askcal --write also write <product>/.context/metrics/flow.json
|
|
8
|
+
|
|
9
|
+
`canon flow` runs this with CANON_WORKSPACE (the folder holding the products) and
|
|
10
|
+
CANON_FLOW_FORMERLY (each product's older folder names, from `formerly:` in its canon.yaml).
|
|
11
|
+
|
|
12
|
+
The transcripts live in ~/.claude/projects and CI cannot see them, so this
|
|
13
|
+
runs here and the product repo carries the result.
|
|
14
|
+
|
|
15
|
+
A conversation is a transcript with at least one typed request. Its commits
|
|
16
|
+
are the repo commits made within MATCH_SECONDS of a `git commit` it ran (rtk
|
|
17
|
+
prints "ok", not the hash, so time is the link). What each stage needs:
|
|
18
|
+
|
|
19
|
+
idea the conversation exists
|
|
20
|
+
research it searched or fetched the web, or a commit touched
|
|
21
|
+
.context/raw/ or .context/wiki/synthesis/
|
|
22
|
+
plan a commit touched .context/features/, tasks.md or wiki/decisions/
|
|
23
|
+
design a commit touched .context/designs/
|
|
24
|
+
code a commit touched anything outside .context/ and .claude/
|
|
25
|
+
review a commit is in a merged pull request
|
|
26
|
+
release a version tag holds a commit (an untagged version: the commit that set it)
|
|
27
|
+
store-review the version release.yml lists as in review holds a commit
|
|
28
|
+
deployed a GitHub deployment, or the web commit release.yml lists, holds a commit
|
|
29
|
+
live a version release.yml lists as live holds a commit
|
|
30
|
+
|
|
31
|
+
A conversation counts at every stage up to the furthest it reached.
|
|
32
|
+
"""
|
|
33
|
+
import glob
|
|
34
|
+
import json
|
|
35
|
+
import os
|
|
36
|
+
import re
|
|
37
|
+
import statistics
|
|
38
|
+
import subprocess
|
|
39
|
+
import sys
|
|
40
|
+
from datetime import datetime, timezone
|
|
41
|
+
|
|
42
|
+
MATCH_SECONDS = 120
|
|
43
|
+
PRODUCTS = os.environ["CANON_WORKSPACE"]
|
|
44
|
+
TRANSCRIPTS = os.path.expanduser("~/.claude/projects")
|
|
45
|
+
COMMIT_CALL = re.compile(r"\bgit\b[^|;&]*\bcommit\b")
|
|
46
|
+
# A release is a version tag; `stash-bak-0` and friends are not.
|
|
47
|
+
RELEASE_TAG = re.compile(r"^(?:[a-z]+-)?v(\d+\.\d+(?:\.\d+)?)")
|
|
48
|
+
WEB_TOOLS = {"WebSearch", "WebFetch"}
|
|
49
|
+
# A renamed product's conversations under its old folder name are still about it.
|
|
50
|
+
ALIASES = json.loads(os.environ.get("CANON_FLOW_FORMERLY") or "{}")
|
|
51
|
+
PRODUCT_PATH = re.compile(re.escape(PRODUCTS) + r"/([A-Za-z0-9._-]+)")
|
|
52
|
+
KNOWN = ["idea", "research", "plan", "design", "code", "review", "release", "store-review", "deployed", "live"]
|
|
53
|
+
TOUCH = {
|
|
54
|
+
"research": (".context/raw/", ".context/wiki/synthesis/"),
|
|
55
|
+
"plan": (".context/features/", ".context/tasks.md", ".context/wiki/decisions/"),
|
|
56
|
+
"design": (".context/designs/",),
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def ts(s):
|
|
61
|
+
return datetime.fromisoformat(str(s).replace("Z", "+00:00"))
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def day(v):
|
|
65
|
+
"""A release.yml date: '2026-10-27' or a full timestamp."""
|
|
66
|
+
return ts(v) if "T" in str(v) else ts(f"{v}T00:00:00+00:00")
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def run(*cmd, cwd=None):
|
|
70
|
+
return subprocess.run(cmd, capture_output=True, text=True, check=True, cwd=cwd).stdout
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def git(repo, *args):
|
|
74
|
+
return run("git", "-C", repo, *args)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def read_session(path):
|
|
78
|
+
"""Start, typed requests, `git commit` call times, first web-research time, and the
|
|
79
|
+
product folders it worked in (its working folders and the paths its tools named;
|
|
80
|
+
never tool output, which can list every product)."""
|
|
81
|
+
times, commits, asks, web, where = [], [], 0, None, set()
|
|
82
|
+
with open(path, errors="ignore") as f:
|
|
83
|
+
for line in f:
|
|
84
|
+
try:
|
|
85
|
+
e = json.loads(line)
|
|
86
|
+
except json.JSONDecodeError:
|
|
87
|
+
continue
|
|
88
|
+
if not e.get("timestamp"):
|
|
89
|
+
continue
|
|
90
|
+
t = ts(e["timestamp"])
|
|
91
|
+
times.append(t)
|
|
92
|
+
where.update(PRODUCT_PATH.findall(e.get("cwd") or ""))
|
|
93
|
+
content = e.get("message", {}).get("content")
|
|
94
|
+
if e.get("type") == "user" and not e.get("isMeta") and isinstance(content, str) and not content.lstrip().startswith("<"):
|
|
95
|
+
asks += 1
|
|
96
|
+
if e.get("type") == "assistant" and isinstance(content, list):
|
|
97
|
+
for c in content:
|
|
98
|
+
if c.get("type") != "tool_use":
|
|
99
|
+
continue
|
|
100
|
+
where.update(PRODUCT_PATH.findall(json.dumps(c.get("input", {}))))
|
|
101
|
+
if c.get("name") in WEB_TOOLS and web is None:
|
|
102
|
+
web = t
|
|
103
|
+
if COMMIT_CALL.search(c.get("input", {}).get("command", "")):
|
|
104
|
+
commits.append(t)
|
|
105
|
+
if not times or asks == 0:
|
|
106
|
+
return None
|
|
107
|
+
return {"start": min(times), "calls": commits, "web": web, "where": where}
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def all_sessions():
|
|
111
|
+
# Every folder: a conversation started in ~ or ~/git can still have worked in a product.
|
|
112
|
+
return {p: s for p in glob.glob(f"{TRANSCRIPTS}/*/*.jsonl") if (s := read_session(p))}
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def stages_of(repo):
|
|
116
|
+
"""The product's own stages, as canon resolves them."""
|
|
117
|
+
ids = [s["id"] for s in json.loads(run("canon", "pipeline", repo, "--json"))]
|
|
118
|
+
unknown = [i for i in ids if i not in KNOWN]
|
|
119
|
+
if unknown:
|
|
120
|
+
sys.exit(f"{repo}: no evidence rule for stage(s) {', '.join(unknown)} — known: {', '.join(KNOWN)}")
|
|
121
|
+
return ids
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def release_state(repo):
|
|
125
|
+
"""release.yml as data. Ruby's YAML is on every Mac; Python's is not."""
|
|
126
|
+
path = os.path.join(repo, ".context", "ops", "release.yml")
|
|
127
|
+
if not os.path.exists(path):
|
|
128
|
+
return {}
|
|
129
|
+
out = run("ruby", "-ryaml", "-rjson", "-rdate", "-e",
|
|
130
|
+
"puts YAML.load_file(ARGV[0], permitted_classes: [Date, Time, Symbol]).to_json", path)
|
|
131
|
+
return json.loads(out).get("surfaces") or {}
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def version_of(value):
|
|
135
|
+
"""'1.4.3', '2.0.1 (15)', {version: '1.4.3'} → '1.4.3'."""
|
|
136
|
+
if isinstance(value, dict):
|
|
137
|
+
value = value.get("version") or value.get("string")
|
|
138
|
+
m = re.match(r"\s*v?(\d+\.\d+(?:\.\d+)?)", str(value)) if value else None
|
|
139
|
+
return m.group(1) if m else None
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def reachable(repo, ref):
|
|
143
|
+
try:
|
|
144
|
+
return set(git(repo, "rev-list", ref).split())
|
|
145
|
+
except subprocess.CalledProcessError:
|
|
146
|
+
return set()
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def repo_facts(repo):
|
|
150
|
+
tags = []
|
|
151
|
+
for line in git(repo, "tag", "--format=%(refname:short) %(creatordate:iso-strict)").splitlines():
|
|
152
|
+
name, _, date = line.partition(" ")
|
|
153
|
+
m = RELEASE_TAG.match(name)
|
|
154
|
+
if m:
|
|
155
|
+
tags.append((ts(date), name, m.group(1)))
|
|
156
|
+
tags.sort()
|
|
157
|
+
released, by_version = {}, {}
|
|
158
|
+
for date, name, version in tags: # oldest first: a commit's release is the first tag that holds it
|
|
159
|
+
held = reachable(repo, name)
|
|
160
|
+
by_version.setdefault(version, set()).update(held)
|
|
161
|
+
for sha in held:
|
|
162
|
+
released.setdefault(sha, date)
|
|
163
|
+
|
|
164
|
+
slug = re.sub(r"^.*github\.com[:/]|\.git$", "", git(repo, "remote", "get-url", "origin").strip())
|
|
165
|
+
|
|
166
|
+
def version_held(v):
|
|
167
|
+
"""What a version holds: its tag, or — when it was never tagged — everything up to the
|
|
168
|
+
commit that first set that version (`chore(release): 1.4.3 (build 17)`)."""
|
|
169
|
+
if v in by_version:
|
|
170
|
+
return by_version[v]
|
|
171
|
+
bump = git(repo, "log", "--all", "--reverse", "--format=%H %cI", "-S", f'"version": "{v}"').split("\n")[0].split()
|
|
172
|
+
if not bump:
|
|
173
|
+
return set()
|
|
174
|
+
held = reachable(repo, bump[0])
|
|
175
|
+
for sha in held:
|
|
176
|
+
released.setdefault(sha, ts(bump[1]))
|
|
177
|
+
return held
|
|
178
|
+
|
|
179
|
+
surfaces = release_state(repo)
|
|
180
|
+
in_review, live, deployed = set(), set(), {}
|
|
181
|
+
review_at = None
|
|
182
|
+
for key, s in surfaces.items():
|
|
183
|
+
if not isinstance(s, dict) or s.get("verified") is False:
|
|
184
|
+
continue
|
|
185
|
+
v = version_of(s.get("in_review"))
|
|
186
|
+
if v:
|
|
187
|
+
in_review |= version_held(v)
|
|
188
|
+
sub = s["in_review"].get("submitted") if isinstance(s.get("in_review"), dict) else None
|
|
189
|
+
review_at = day(sub) if sub else review_at
|
|
190
|
+
lv = version_of(s.get("live"))
|
|
191
|
+
if lv:
|
|
192
|
+
live |= version_held(lv)
|
|
193
|
+
dep = s.get("deployed")
|
|
194
|
+
if key == "web" and isinstance(dep, dict) and dep.get("commit"):
|
|
195
|
+
when = day(dep["since"]) if dep.get("since") else None
|
|
196
|
+
for sha in reachable(repo, str(dep["commit"])):
|
|
197
|
+
deployed.setdefault(sha, when)
|
|
198
|
+
|
|
199
|
+
# Every deployment GitHub recorded (Vercel and Cloudflare post them), oldest first.
|
|
200
|
+
try:
|
|
201
|
+
pages = json.loads(run("gh", "api", "--paginate", "--slurp", f"repos/{slug}/deployments?per_page=100"))
|
|
202
|
+
for d in sorted((d for page in pages for d in page), key=lambda d: d["created_at"]):
|
|
203
|
+
for sha in reachable(repo, d["sha"]):
|
|
204
|
+
deployed.setdefault(sha, ts(d["created_at"]))
|
|
205
|
+
except (subprocess.CalledProcessError, FileNotFoundError, json.JSONDecodeError) as e:
|
|
206
|
+
print(f" {os.path.basename(repo)}: deployments not read ({getattr(e, 'stderr', '') or e}); Deployed counts only release.yml", file=sys.stderr)
|
|
207
|
+
|
|
208
|
+
merged = {}
|
|
209
|
+
try:
|
|
210
|
+
pulls = json.loads(run("gh", "api", "--paginate", "--slurp", f"repos/{slug}/pulls?state=closed&per_page=100"))
|
|
211
|
+
for pr in (p for page in pulls for p in page if p.get("merged_at")):
|
|
212
|
+
when = ts(pr["merged_at"])
|
|
213
|
+
merged[pr["merge_commit_sha"]] = when
|
|
214
|
+
# A squash merge makes a new commit; the conversation's own commits are the PR's.
|
|
215
|
+
for c in json.loads(run("gh", "api", "--paginate", "--slurp", f"repos/{slug}/pulls/{pr['number']}/commits?per_page=100")):
|
|
216
|
+
for commit in c:
|
|
217
|
+
merged.setdefault(commit["sha"], when)
|
|
218
|
+
except (subprocess.CalledProcessError, FileNotFoundError, json.JSONDecodeError) as e:
|
|
219
|
+
# Review then shows 0, never a guess — and says why.
|
|
220
|
+
print(f" {os.path.basename(repo)}: pull requests not read ({getattr(e, 'stderr', '') or e}); Review counts nothing", file=sys.stderr)
|
|
221
|
+
return {"released": released, "merged": merged, "in_review": in_review, "review_at": review_at,
|
|
222
|
+
"live": live, "deployed": deployed}
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def touched(repo, sha):
|
|
226
|
+
return git(repo, "show", "--name-only", "--format=", sha).split()
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def reached(session, commits, repo, facts):
|
|
230
|
+
"""{stage: when it was first reached} for one conversation."""
|
|
231
|
+
at = {"idea": session["start"]}
|
|
232
|
+
if session["web"]:
|
|
233
|
+
at["research"] = session["web"]
|
|
234
|
+
for sha, when in sorted(commits, key=lambda c: c[1]):
|
|
235
|
+
files = touched(repo, sha)
|
|
236
|
+
for stage, prefixes in TOUCH.items():
|
|
237
|
+
if any(f.startswith(prefixes) for f in files):
|
|
238
|
+
at.setdefault(stage, when)
|
|
239
|
+
if any(not f.startswith((".context/", ".claude/")) for f in files):
|
|
240
|
+
at.setdefault("code", when)
|
|
241
|
+
if sha in facts["merged"]:
|
|
242
|
+
at.setdefault("review", facts["merged"][sha])
|
|
243
|
+
if sha in facts["released"]:
|
|
244
|
+
at.setdefault("release", facts["released"][sha])
|
|
245
|
+
if sha in facts["in_review"]:
|
|
246
|
+
at.setdefault("store-review", facts["review_at"] or facts["released"].get(sha))
|
|
247
|
+
if sha in facts["deployed"]:
|
|
248
|
+
at.setdefault("deployed", facts["deployed"][sha])
|
|
249
|
+
if sha in facts["live"]:
|
|
250
|
+
at.setdefault("live", facts["released"].get(sha))
|
|
251
|
+
return at
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def flow(product, sessions):
|
|
255
|
+
repo = os.path.join(PRODUCTS, product)
|
|
256
|
+
stages = stages_of(repo)
|
|
257
|
+
log = [l.split() for l in git(repo, "log", "--all", "--format=%H %cI").splitlines() if l]
|
|
258
|
+
commits = [(h, ts(d)) for h, d in log]
|
|
259
|
+
facts = repo_facts(repo)
|
|
260
|
+
names = {product, *ALIASES.get(product, [])}
|
|
261
|
+
|
|
262
|
+
furthest, waits, first = [], {f"{a}→{b}": [] for a, b in zip(stages, stages[1:])}, None
|
|
263
|
+
for path, s in sessions.items():
|
|
264
|
+
mine = [c for c in commits if any(abs((c[1] - t).total_seconds()) <= MATCH_SECONDS for t in s["calls"])]
|
|
265
|
+
if not (s["where"] & names) and not mine:
|
|
266
|
+
continue # never worked in this product's folder, under any of its names, nor committed to it
|
|
267
|
+
first = min(first, s["start"]) if first else s["start"]
|
|
268
|
+
at = reached(s, mine, repo, facts)
|
|
269
|
+
hit = [i for i, st in enumerate(stages) if st in at]
|
|
270
|
+
furthest.append(max(hit))
|
|
271
|
+
for a, b in zip(stages, stages[1:]):
|
|
272
|
+
if a in at and b in at and at[a] and at[b] and at[b] >= at[a]:
|
|
273
|
+
waits[f"{a}→{b}"].append((at[b] - at[a]).total_seconds() / 3600)
|
|
274
|
+
|
|
275
|
+
return {
|
|
276
|
+
"$comment": "GENERATED by `canon flow` from local Claude transcripts, git, GitHub and release.yml. Counts only. Do not hand-edit.",
|
|
277
|
+
"updated": datetime.now(timezone.utc).date().isoformat(),
|
|
278
|
+
"since": first.date().isoformat() if first else None,
|
|
279
|
+
"stages": [{"id": st, "count": sum(1 for f in furthest if f >= i)} for i, st in enumerate(stages)],
|
|
280
|
+
"medianHours": {k: round(statistics.median(v), 1) if v else None for k, v in waits.items()},
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def self_check():
|
|
285
|
+
assert RELEASE_TAG.match("v1.4.1") and RELEASE_TAG.match("mobile-v0.1.1")
|
|
286
|
+
assert not RELEASE_TAG.match("stash-bak-0")
|
|
287
|
+
assert COMMIT_CALL.search('git -C askcal commit -m "x"') and COMMIT_CALL.search("git commit -q -F -")
|
|
288
|
+
assert not COMMIT_CALL.search("git status; echo commit")
|
|
289
|
+
assert day("2026-10-27").hour == 0 and day("2026-09-30T17:43:43+00:00").hour == 17
|
|
290
|
+
assert version_of("2.0.1 (15)") == "2.0.1" and version_of({"version": "1.4.3"}) == "1.4.3" and version_of(None) is None
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def main():
|
|
294
|
+
self_check()
|
|
295
|
+
args = [a for a in sys.argv[1:] if not a.startswith("--")]
|
|
296
|
+
if not args:
|
|
297
|
+
sys.exit("usage: canon flow <product>... [--write]")
|
|
298
|
+
sessions = all_sessions()
|
|
299
|
+
for product in args:
|
|
300
|
+
if not os.path.isdir(os.path.join(PRODUCTS, product, ".git")):
|
|
301
|
+
sys.exit(f"{product}: not a repo under {PRODUCTS}")
|
|
302
|
+
result = flow(product, sessions)
|
|
303
|
+
print(product, " → ".join(f"{s['id']} {s['count']}" for s in result["stages"]), "| since", result["since"])
|
|
304
|
+
if "--write" in sys.argv:
|
|
305
|
+
out = os.path.join(PRODUCTS, product, ".context", "metrics", "flow.json")
|
|
306
|
+
os.makedirs(os.path.dirname(out), exist_ok=True)
|
|
307
|
+
with open(out, "w") as f:
|
|
308
|
+
json.dump(result, f, indent=2, ensure_ascii=False)
|
|
309
|
+
f.write("\n")
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
if __name__ == "__main__":
|
|
313
|
+
main()
|
package/skills/ingest/SKILL.md
CHANGED
|
@@ -22,7 +22,7 @@ Source to ingest: `$ARGUMENTS`
|
|
|
22
22
|
- A user journey description → a concept, only if it holds a rule the designs don't show
|
|
23
23
|
- Write nothing an agent could read from the code, the designs or git.
|
|
24
24
|
- Don't force one source into one page — a meeting note can produce multiple pages.
|
|
25
|
-
- Folders are the four in the schema plus any listed under `wiki.types` in the repo's
|
|
25
|
+
- Folders are the four in the schema plus any listed under `wiki.types` in the repo's `.claude/canon.yaml`, each with its folder and its required `sections` (use its `sections` headings). Use an added type only when the source fits it.
|
|
26
26
|
|
|
27
27
|
4. **For each page you will create:**
|
|
28
28
|
- Filename: the topic, lowercase kebab-case, no date.
|
package/standards/README.md
CHANGED
|
@@ -1,14 +1,31 @@
|
|
|
1
1
|
# standards/
|
|
2
2
|
|
|
3
|
-
canon's
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
replaces it; a configured `constitution.md` adds principles instead.
|
|
3
|
+
canon's standards. `canon standards` lists the ones that apply to a repo.
|
|
4
|
+
|
|
5
|
+
## Defaults — apply to every repo
|
|
7
6
|
|
|
8
7
|
| File | What it holds |
|
|
9
8
|
|---|---|
|
|
10
|
-
| `constitution.md` | The principles a request is weighed against (C-01 to C-
|
|
9
|
+
| `constitution.md` | The principles a request is weighed against (C-01 to C-15; C-01 to C-19 are canon's) |
|
|
11
10
|
| `wiki-schema.md` | Wiki page types, frontmatter, required sections, lint rules |
|
|
12
|
-
| `context-layout.md` | The `.context/` ten-folder layout, where each kind of document lives, `features/` and `tasks.md` formats |
|
|
11
|
+
| `context-layout.md` | The `.context/` ten-folder layout, where each kind of document lives, the repo root, `features/` and `tasks.md` formats |
|
|
13
12
|
| `dev-methods.md` | The evidence spec-, test- and data-driven work leaves behind |
|
|
14
13
|
| `agent-contract.md` | What every agent does before, during and after work |
|
|
14
|
+
|
|
15
|
+
## Library — a repo opts in by name
|
|
16
|
+
|
|
17
|
+
A repo lists the ones it wants in `.claude/canon.yaml`:
|
|
18
|
+
`standards: [design-sets, pipeline, expo]`.
|
|
19
|
+
|
|
20
|
+
| Name | What it holds |
|
|
21
|
+
|---|---|
|
|
22
|
+
| `design-sets` | HTML design sets: layout, `routes.js`, the navbar, the flow chart, tokens, checks |
|
|
23
|
+
| `pipeline` | The five stages and their hard gates; principles C-16 and C-17 |
|
|
24
|
+
| `expo` | Scaffolding an Expo app, its layout, the versioned-docs rule, release checks |
|
|
25
|
+
| `frontend` | `.tsx` code rules: one component per file, thin routes, helpers in `lib/` and `hooks/`, UniWind |
|
|
26
|
+
| `performance` | No redraw after a local edit, counted round trips, no blank navigation, a number before and after |
|
|
27
|
+
| `secrets` | Every secret backed up the moment it exists; key names only in `INVENTORY.md` |
|
|
28
|
+
| `store-listing` | `.context/listing/` as fastlane's tree, screenshots, release notes from `features/` |
|
|
29
|
+
|
|
30
|
+
A library standard may add principles in the constitution's `### C-NN Title`
|
|
31
|
+
format; they apply only to repos that opt in.
|
|
@@ -8,7 +8,9 @@ file with its own `agent-contract.md` in a configured standards folder.
|
|
|
8
8
|
- Read the repo's `CLAUDE.md` / `AGENTS.md`: stack, layout, the rules that are
|
|
9
9
|
the repo's own.
|
|
10
10
|
- Read `.context/tasks.md`. It is the ordered queue — the top item of `## Now`
|
|
11
|
-
is the next action. After finishing, delete the task.
|
|
11
|
+
is the next action. After finishing, delete the task. The repo's `AGENTS.md`
|
|
12
|
+
(or `CLAUDE.md`) carries these two lines, because without them an agent that
|
|
13
|
+
was not told about `.context/` never finds it.
|
|
12
14
|
- For a non-trivial request, run the weigh gate first (`/canon:weigh`).
|
|
13
15
|
|
|
14
16
|
## Repository rules
|
|
@@ -17,10 +19,11 @@ file with its own `agent-contract.md` in a configured standards folder.
|
|
|
17
19
|
(`context-layout.md`). The wiki is the knowledge base; its schema is
|
|
18
20
|
`wiki-schema.md`.
|
|
19
21
|
2. Do not add top-level `docs/` or `wireframes/` folders, or root `TODO.md`,
|
|
20
|
-
`STATUS.md` or `SETUP.md`.
|
|
21
|
-
`
|
|
22
|
-
`.github/` — stay at the root.
|
|
22
|
+
`STATUS.md` or `SETUP.md`. What stays at the root, and where the rest goes:
|
|
23
|
+
`context-layout.md`, "Repo root".
|
|
23
24
|
3. Generated folders are never hand-edited; change their source and regenerate.
|
|
25
|
+
4. File names are lowercase kebab-case unless the language says otherwise.
|
|
26
|
+
5. Names follow the repo's current identity, not a legacy name.
|
|
24
27
|
|
|
25
28
|
## Replies
|
|
26
29
|
|
|
@@ -34,13 +37,25 @@ Write a comment only to explain **why**. The code already says how.
|
|
|
34
37
|
|
|
35
38
|
A comment earns its place when it records something the reader cannot recover
|
|
36
39
|
from the code: a constraint, a trade-off, a bug it prevents, or a non-obvious
|
|
37
|
-
consequence. If it restates what the next line does, delete it.
|
|
40
|
+
consequence. If it restates what the next line does, delete it.
|
|
41
|
+
|
|
42
|
+
```ts
|
|
43
|
+
// Good — the reason is invisible in the code:
|
|
44
|
+
// Prune only. Recomputing here would discard the user's per-pack choices
|
|
45
|
+
// on every launch, which is the bug this replaced.
|
|
46
|
+
state.selectedPackIds = prunePackIds(...);
|
|
47
|
+
|
|
48
|
+
// Bad — narrates the mechanism:
|
|
49
|
+
// Loop over the packs and filter out the locked ones
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
No
|
|
38
53
|
section-divider comments, no changelog comments, no comments restating a
|
|
39
54
|
function's own name.
|
|
40
55
|
|
|
41
56
|
## Before calling work done
|
|
42
57
|
|
|
43
|
-
1. Update any `.context/` page the change made wrong. Add a page only if an
|
|
58
|
+
1. Update `CLAUDE.md` and any `.context/` page the change made wrong. Add a page only if an
|
|
44
59
|
agent would get something wrong without it.
|
|
45
60
|
2. Run the repo's own lint, typecheck and tests.
|
|
46
61
|
3. Run `canon check`. Every checker the repo declares under `commands:` runs
|
|
@@ -24,8 +24,9 @@ folder.
|
|
|
24
24
|
## Rules
|
|
25
25
|
|
|
26
26
|
1. **Ten folders, one job each.** There is no `documents/`, `research/`,
|
|
27
|
-
`architecture/` or `
|
|
28
|
-
page, an architecture note is a wiki concept, an ADR is
|
|
27
|
+
`architecture/`, `decisions/` or `email-templates/` folder: a research brief
|
|
28
|
+
is a wiki synthesis page, an architecture note is a wiki concept, an ADR is
|
|
29
|
+
`wiki/decisions/`, a transactional email is HTML in `designs/`.
|
|
29
30
|
A product that truly needs another folder adds it in `.claude/canon.yaml`,
|
|
30
31
|
with the reason: `context: { folders: [{ folder: research, reason: … }] }`.
|
|
31
32
|
The ten cannot be removed.
|
|
@@ -38,7 +39,8 @@ folder.
|
|
|
38
39
|
4. **One home per artifact; everything else is an export.** Source art lives in
|
|
39
40
|
`designs/`; store images and press kits are generated from it.
|
|
40
41
|
5. **Authored vs generated is visible.** A generated file is committed but never
|
|
41
|
-
hand-edited.
|
|
42
|
+
hand-edited. Its source is a hand-written file such as `marketing/context.md`,
|
|
43
|
+
`metrics/definitions.yml` or `metrics/events.yml`.
|
|
42
44
|
|
|
43
45
|
## What a repo is expected to have
|
|
44
46
|
|
|
@@ -46,14 +48,15 @@ folder.
|
|
|
46
48
|
|---|---|
|
|
47
49
|
| any repo | `README.md` · `tasks.md` |
|
|
48
50
|
| uses the `docs` or `knowledge` module | + `raw/` · `wiki/` · `features/` |
|
|
49
|
-
| draws its UI | + `designs/` (
|
|
51
|
+
| draws its UI | + `designs/` (required for a mobile or web repo: designs come first) |
|
|
50
52
|
| has a store presence | + `listing/` |
|
|
51
53
|
| is live to users | + `marketing/` · `metrics/` · `support/` · `legal/` |
|
|
52
54
|
| once something runs | + `ops/` |
|
|
53
55
|
|
|
54
56
|
## Naming and frontmatter
|
|
55
57
|
|
|
56
|
-
- Dated sources (`raw/`) → ISO date: `YYYY-MM-DD-slug.md`.
|
|
58
|
+
- Dated sources (`raw/`) → ISO date: `YYYY-MM-DD-slug.md`. Month-grain data
|
|
59
|
+
keeps `YYYY-MM.csv`.
|
|
57
60
|
- Features → zero-padded number: `NNNN-slug.md`.
|
|
58
61
|
- Everything else is named by topic and edited in place.
|
|
59
62
|
- Frontmatter: `last-verified`, kebab-case keys. See the wiki schema.
|
|
@@ -74,10 +77,40 @@ separate folder per name.
|
|
|
74
77
|
| Know-how | `wiki/concepts/`; `ops/` when it is a runbook |
|
|
75
78
|
| Bug | the issue tracker; a wiki concept only when it teaches a rule |
|
|
76
79
|
| Data source | `wiki/entities/` — see the wiki schema, "Data sources" |
|
|
80
|
+
| Setup notes (account IDs, console links, where credentials live) | `ops/setup.md` |
|
|
81
|
+
| What is live right now, per surface | `ops/release.yml` |
|
|
82
|
+
| Runbook | `ops/runbooks/<slug>.md` |
|
|
83
|
+
| Incident | `ops/incidents/YYYY-MM-DD-<slug>.md` |
|
|
84
|
+
| Dated engineering narrative | `CHANGELOG.md` at the repo root |
|
|
77
85
|
|
|
78
86
|
If a document fits none of these for a real product, add a wiki type or a
|
|
79
87
|
folder in `.claude/canon.yaml` with its reason — not a new convention by hand.
|
|
80
88
|
|
|
89
|
+
## Inside the folders
|
|
90
|
+
|
|
91
|
+
A folder holds only what it uses; these are the usual names.
|
|
92
|
+
|
|
93
|
+
```
|
|
94
|
+
marketing/ context.md (identity, goals, markets) · brand/ (WORDS only — art is in designs/brand/)
|
|
95
|
+
posts/ · video/ · launch/ · press/ (press/media/ is an export of designs/brand/)
|
|
96
|
+
metrics/ definitions.yml (the KPI dictionary) · events.yml (what each event means)
|
|
97
|
+
history/YYYY-MM.csv (metric,period,value)
|
|
98
|
+
support/ SUPPORT.md · faq/ · replies/ · reviews/
|
|
99
|
+
legal/ privacy policy · terms · data deletion
|
|
100
|
+
ops/ release.yml · setup.md · runbooks/ · incidents/
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
## Repo root
|
|
104
|
+
|
|
105
|
+
These stay at the root, because a host renders them or a tool reads them by
|
|
106
|
+
exact path: `README.md` · `LICENSE` · `CHANGELOG.md` · `CONTRIBUTING.md` ·
|
|
107
|
+
`SECURITY.md` · `CLAUDE.md` · `AGENTS.md` · `.github/`.
|
|
108
|
+
|
|
109
|
+
`TODO.md`, `STATUS.md` and `SETUP.md` do not. `SETUP.md` becomes `ops/setup.md`.
|
|
110
|
+
The other two are not single documents, so split them: the backlog goes to
|
|
111
|
+
`tasks.md`, what is live to `ops/release.yml`, the dated narrative to
|
|
112
|
+
`CHANGELOG.md`, canonical values to `wiki/entities/`, a how-to to `ops/runbooks/`.
|
|
113
|
+
|
|
81
114
|
## `features/` — what is next
|
|
82
115
|
|
|
83
116
|
One page per feature, `NNNN-<slug>.md`:
|
|
@@ -88,7 +121,9 @@ id: FEAT-0031
|
|
|
88
121
|
type: feature # feature | experiment | chore | platform
|
|
89
122
|
status: building # planned | building | shipped | shelved | superseded
|
|
90
123
|
last-verified: 2026-04-02
|
|
91
|
-
release: 3.4.0
|
|
124
|
+
release: 3.4.0 # ties to CHANGELOG.md and the release notes
|
|
125
|
+
superseded-by: null
|
|
126
|
+
surfaces: ["[[settings-screen]]"]
|
|
92
127
|
---
|
|
93
128
|
## Summary · ## Motivation · ## Acceptance scenarios (Given/When/Then)
|
|
94
129
|
## Out of scope · ## Open questions
|
|
@@ -100,7 +135,7 @@ An experiment adds `## Hypothesis`, `## Metric` (a name from
|
|
|
100
135
|
`metrics/definitions.yml`) and `## Result` once it has run.
|
|
101
136
|
|
|
102
137
|
The wiki describes what *is*; `features/` describes what is *next*. A shipped
|
|
103
|
-
feature goes `status: shipped
|
|
138
|
+
feature goes `status: shipped` and is never deleted, so the reasoning survives.
|
|
104
139
|
|
|
105
140
|
## `tasks.md` — open work only
|
|
106
141
|
|