@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
@@ -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()
@@ -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 or the estate'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.
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.
@@ -48,7 +48,7 @@ folder.
48
48
  |---|---|
49
49
  | any repo | `README.md` · `tasks.md` |
50
50
  | uses the `docs` or `knowledge` module | + `raw/` · `wiki/` · `features/` |
51
- | draws its UI | + `designs/` (optional; an estate can require it with a command) |
51
+ | draws its UI | + `designs/` (required for a mobile or web repo: designs come first) |
52
52
  | has a store presence | + `listing/` |
53
53
  | is live to users | + `marketing/` · `metrics/` · `support/` · `legal/` |
54
54
  | once something runs | + `ops/` |
@@ -719,7 +719,7 @@ check a lie.
719
719
  **The canonical set lives in the repo named in `copies.source`** in
720
720
  `.claude/canon.yaml`. It should exercise nearly everything in this guide: the
721
721
  reserved flows, a few domain flows, modal screens, a state variant, and at least
722
- one edge of each of the four kinds. If it draws one theme only, the estate names
722
+ one edge of each of the four kinds. If it draws one theme only, the repo names
723
723
  a second reference for a two-theme palette. **Copy from those and from nothing
724
724
  else** — other sets may be on older versions of the shared files, and copying
725
725
  from them silently reverts the bar, the chart, or both. A migration that copied
@@ -6,8 +6,8 @@ Every secret is backed up the moment it exists. Opt in with
6
6
  Keep one offline backup folder for every credential, outside every repo. It is
7
7
  not a repo and never will be — that is what makes it safe to hold plaintext
8
8
  credentials. It mirrors the path layout of the repos, so restoring a file is a
9
- straight `cp` back. The estate decides where it lives and writes that path in
10
- its own `CLAUDE.md`.
9
+ straight `cp` back. The owner decides where it lives and writes that path in
10
+ the `CLAUDE.md` above the repos.
11
11
 
12
12
  **Whenever a secret is created, rotated, or first needed, copy it into the
13
13
  backup folder in the same run and add its line to the folder's `INVENTORY.md`.**
@@ -34,4 +34,4 @@ Rules that go with it:
34
34
  not. If one is already tracked, untrack it — do not just add it to
35
35
  `.gitignore` and move on.
36
36
  - The folder is a snapshot, not a sync. Re-copy after every rotation.
37
- - The estate's secret-leak scan must not start failing because of a new secret.
37
+ - A secret-leak scan must not start failing because of a new secret.
@@ -99,7 +99,7 @@ to the gitignored `out/`, and the command's last step is the mapper.
99
99
  "showcase"`, `layout: "classic"`, `screenOnly: true`, Merriweather, the
100
100
  `17-pro-silver` frame. Only the background is the app's own. It is written in
101
101
  `goldie.config.ts` and again in `goldie.design.json` — the file `goldie studio`
102
- saves and the CLI lays over the config. An estate check should fail when an app
102
+ saves and the CLI lays over the config. A repo check should fail when an app
103
103
  differs from the reference repo's style or when its two files disagree.
104
104
 
105
105
  ## Release notes come from `features/`