@erclx/canon 4.4.0 → 4.5.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/.claude-plugin/plugin.json +1 -1
- package/claude/skills/claude-markdown-propose/REQUIREMENT.md +0 -1
- package/docs/agents/audits.md +6 -6
- package/docs/agents/context-audit.md +2 -2
- package/docs/agents/index.md +1 -1
- package/docs/agents/records.md +17 -7
- package/docs/agents/tasks.md +1 -1
- package/package.json +1 -1
- package/scripts/core/check-ignore-parity.sh +9 -3
- package/src/audits/baseline.ts +1 -1
- package/src/commands/claude.ts +7 -1
- package/src/commands/context.ts +3 -3
- package/src/commands/design.ts +6 -1
- package/src/commands/feedback.ts +5 -1
- package/src/commands/gov.ts +2 -1
- package/src/commands/slides.ts +6 -1
- package/src/context/citations.ts +16 -5
- package/src/context/folders.ts +18 -8
- package/src/gate/measures.ts +1 -1
- package/src/intake/folder.ts +2 -1
- package/src/record-root.ts +134 -0
- package/src/records/backup.ts +40 -22
- package/src/records/size.ts +12 -7
- package/src/records/validate.ts +35 -17
- package/src/tasks/answers.ts +14 -7
- package/src/tasks/archive.ts +31 -21
- package/src/teach/workspace.ts +2 -1
- package/tooling/claude/manifest.toml +1 -1
- package/tooling/claude/seeds/.claude/hooks/index-reminder.sh +8 -1
- package/tooling/claude/seeds/.claude/hooks/memory-index.sh +28 -11
- package/tooling/claude/seeds/.claude/hooks/scratch-guard.sh +12 -3
- package/tooling/claude/seeds/.claude/hooks/standards-audit.sh +4 -0
- package/tooling/claude/seeds/.claude/hooks/tasks-index.sh +28 -10
|
@@ -23,7 +23,6 @@ A second failure compounds the first. A claim copied across several files is cor
|
|
|
23
23
|
- Carry three labelled variants on a change whose replacement was invented, and one on a change that corrects text to a recorded fact
|
|
24
24
|
- Write one proposal file per source file under `.claude/proposals/<slug>/` and stop, leaving `You:` empty on every change
|
|
25
25
|
- Apply only a change carrying an answer, one file at a time, and re-grep its anchor before applying it
|
|
26
|
-
- Name a folder that does not collide with `.claude/audits/`, which holds generated data rather than decision state
|
|
27
26
|
- Report what each file leaves alone, so a proposal cannot be read as finding everything wanting
|
|
28
27
|
|
|
29
28
|
## Must not
|
package/docs/agents/audits.md
CHANGED
|
@@ -14,11 +14,11 @@ canon audits run --record
|
|
|
14
14
|
canon audits list --json
|
|
15
15
|
```
|
|
16
16
|
|
|
17
|
-
| Option | Behavior
|
|
18
|
-
| --------------- |
|
|
19
|
-
| `--json` | Add a machine-readable record on stdout, keeping the frame
|
|
20
|
-
| `--root <path>` | Measure this tree instead of the current worktree
|
|
21
|
-
| `--record` | Write this run's tracked counts to `.claude/
|
|
17
|
+
| Option | Behavior |
|
|
18
|
+
| --------------- | ---------------------------------------------------------------- |
|
|
19
|
+
| `--json` | Add a machine-readable record on stdout, keeping the frame |
|
|
20
|
+
| `--root <path>` | Measure this tree instead of the current worktree |
|
|
21
|
+
| `--record` | Write this run's tracked counts to `.claude/canon/baseline.json` |
|
|
22
22
|
|
|
23
23
|
## What it runs
|
|
24
24
|
|
|
@@ -73,7 +73,7 @@ Every run states how many corpora it measured against how many it skipped, inclu
|
|
|
73
73
|
|
|
74
74
|
## The retained baseline
|
|
75
75
|
|
|
76
|
-
`.claude/
|
|
76
|
+
`.claude/canon/baseline.json` holds the counts from the last run recorded with `--record`, alongside the day it was taken and the commit it was read at. Each later run reports which counts moved and by how much.
|
|
77
77
|
|
|
78
78
|
The file is committed. A per-machine record cannot answer the question this half exists for, which is whether a number grew since anyone last looked, and a fresh checkout has to inherit that answer rather than start over. The cost is that a branch moving a count either fixes it or re-records it and says why, which is the shape the consumed-copy assertion already carries.
|
|
79
79
|
|
|
@@ -31,7 +31,7 @@ canon context audit tooling/base/seeds --gate
|
|
|
31
31
|
|
|
32
32
|
Scope defaults to `context`, `diagrams`, and `wireframes`, and a folder the project does not carry is skipped rather than reported. A domain that outgrew one file and split into `<domain>/` is audited as its own folder, so a split entry measures at the same grain as a flat one.
|
|
33
33
|
|
|
34
|
-
A name passed to `--folder` resolves under `.
|
|
34
|
+
A name passed to `--folder` resolves under the record roots first, `.canon/` ahead of `.claude/`, and at the project root last, which is what puts `docs/` and any later corpus in reach of the same engine. The root base is reached only by a name the caller passes, so the default list still resolves under a record root alone and a project holding a root `wireframes/` is not audited against a standard it never adopted. `diagrams` is the one default name that is a session record and moves with them, which is why both record roots are in the list rather than one, and `docs/agents/records.md` states the read order every verb shares. The scope line prints the resolved path, so a project carrying both spellings reads which one was taken. The JSON record carries the base per folder as `folders[].base`.
|
|
35
35
|
|
|
36
36
|
A run where no requested name resolves refuses, whichever list it read. Naming the absent ones narrows to `--folder`, since a project carrying one of the three default folders is the ordinary case and a name it never asked for is not a typo. The JSON record carries those names as `unresolvedFolders`.
|
|
37
37
|
|
|
@@ -80,4 +80,4 @@ Append `<!-- audit-ignore-citations: <path> -->` to the source line in either ca
|
|
|
80
80
|
|
|
81
81
|
The marker itself stays out of anything that installs. A seed, a plugin skill body, and a stack reference all reach a target, so a marker there lands as toolkit bookkeeping in someone else's tree. Reword those lines to drop the path instead, and where a stop message has to spell it, move that message into a fenced block, which this check already skips.
|
|
82
82
|
|
|
83
|
-
The pattern spells
|
|
83
|
+
The pattern spells both record-root prefixes, so a citation into a folder that has moved still resolves and a folder resolved at the project root is measured by every other check while contributing nothing here. A pattern fixed at one root matches nothing after a move and reports nothing, which is a stale reference passing the check written to find it. Widening it to a bare `docs/x.md` would match prose that references nothing, which is a separate decision from where entries come from. A run whose folders all resolved at the root says the check is out of scope rather than reporting that zero paths resolved, and the same run under `--citations-only` refuses, because a gate exiting clean on a scope it could not build is the failure the gate exists to catch.
|
package/docs/agents/index.md
CHANGED
|
@@ -27,7 +27,7 @@ CLI catalog and invocation rules for agents, split by command domain. Start with
|
|
|
27
27
|
- [Markdown audit](markdown-audit.md): Running the audit over any markdown path, where its bans and checkpoints are read from, what each check reports, and why the ban half gates while the structural half reports
|
|
28
28
|
- [Output shape](output-shape.md): Two framed shapes every command renders into, how JSON and --names modes keep stdout clean, and the exit discipline that lets piped output drain
|
|
29
29
|
- [Overview](overview.md): What this folder covers, the invocation rules every command inherits, and where domain behavior is documented instead
|
|
30
|
-
- [Records](records.md):
|
|
30
|
+
- [Records](records.md): The two roots a record folder resolves at, validating the session records and the standards corpus, the per-kind checks, the refusal reasons, migrating a record a frontmatter change orphaned, reading each folder's size and growth, backing the folders to a private remote, and which root each kind defaults to
|
|
31
31
|
- [Restated instructions](restated.md): Counting the instructions the always-loaded file and every path-scoped rule share with the seed, the shipped skill bodies, and each other, how a match is decided, the three classes, which surface a later edit starts from, and why the sweep reports rather than gates
|
|
32
32
|
- [Review classification](review-classification.md): Deciding whether a changed set needs the review pass, the two tests it runs, why an empty set refuses rather than skipping, and the written fallback a target on an older binary falls back to
|
|
33
33
|
- [Routing report](routing.md): Reading per CLAUDE.md section how many bullets name a path, what counts as naming one, when a rule counts as covering it, the two refusals, and why the verb reports rather than gates
|
package/docs/agents/records.md
CHANGED
|
@@ -1,10 +1,20 @@
|
|
|
1
1
|
---
|
|
2
2
|
title: Records
|
|
3
|
-
description:
|
|
3
|
+
description: The two roots a record folder resolves at, validating the session records and the standards corpus, the per-kind checks, the refusal reasons, migrating a record a frontmatter change orphaned, reading each folder's size and growth, backing the folders to a private remote, and which root each kind defaults to
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# Records
|
|
7
7
|
|
|
8
|
+
## Record roots
|
|
9
|
+
|
|
10
|
+
Every verb here resolves a record folder at two roots rather than one. `.canon/<folder>` is read first, `.claude/<folder>` second, and a folder neither root carries resolves to the creation default, which is `.claude/`. The scratch folder is the one name that differs by root, spelled `.claude/.tmp` and `.canon/tmp`, since inside a dotted root the leading dot hides nothing already hidden.
|
|
11
|
+
|
|
12
|
+
The read order and the creation default disagree deliberately. The gitignored record folders are moving to a root of their own, and the CLI learns to read both roots in a release that ships ahead of the move, so the binary a session already holds knows where to look by the time a tree relocates. Creating under the new root before then would write records to a root whose ignore line may not have reached a project yet, and it would split one project's records across two roots with no verb able to reconcile them. The move flips the default and nothing else.
|
|
13
|
+
|
|
14
|
+
A caller never spells a record root by hand for the same reason. A path written as `.claude/plans/...` resolves against one root and reports nothing when it is wrong, which is the quiet failure this ordering exists to prevent: a stale binary meeting a moved layout, writing to the old path, and reporting success. Read a folder through the verb that owns it, and where a skill needs the path itself, take it from that verb's record rather than composing one.
|
|
15
|
+
|
|
16
|
+
A refusal names every root it looked at, so a message reading `no-folder` says where a write would land as well as where the read failed.
|
|
17
|
+
|
|
8
18
|
## Validate
|
|
9
19
|
|
|
10
20
|
`canon records validate <kind>` reports where a file and the standard governing it disagree. Five kinds are gitignored folders under `.claude/`: `plans`, `groundwork`, `intake`, `memory`, and `teach`. The sixth is `standards`, the authoring corpus, which is tracked and installed rather than scratch.
|
|
@@ -56,7 +66,7 @@ A section runs to the next marker-shaped line whatever it names, so a plan carry
|
|
|
56
66
|
|
|
57
67
|
### Exit codes and refusals
|
|
58
68
|
|
|
59
|
-
Exit codes: `0` every check passed, `1` refused, `2` at least one record carries a finding. A `reason` field carries which gate fired: `no-folder` when none of the kind's directories exist, and `unknown-kind` when the argument names no published kind. A `no-folder` message names every candidate, so the `standards` refusal names
|
|
69
|
+
Exit codes: `0` every check passed, `1` refused, `2` at least one record carries a finding. A `reason` field carries which gate fired: `no-folder` when none of the kind's directories exist, and `unknown-kind` when the argument names no published kind. A `no-folder` message names every candidate, so a record kind's refusal names both record roots and the `standards` refusal names the authoring root and the installed copy.
|
|
60
70
|
|
|
61
71
|
An exit code says nothing about a call made from a session, since a shell profile may wrap the binary in a function taking its status from a later command. Read the record's `findings` array and its `reason` rather than the exit when a skill consumes this.
|
|
62
72
|
|
|
@@ -96,7 +106,7 @@ Exit codes: `0` nothing carried a known transform, or `--write` repaired everyth
|
|
|
96
106
|
|
|
97
107
|
## Size
|
|
98
108
|
|
|
99
|
-
`canon records size` reports what each record folder holds and how much of it is recent. It reads the ten backed folders named under Push and pull, plus
|
|
109
|
+
`canon records size` reports what each record folder holds and how much of it is recent. It reads the ten backed folders named under Push and pull, plus the scratch folder, and it gates nothing.
|
|
100
110
|
|
|
101
111
|
```bash
|
|
102
112
|
canon records size
|
|
@@ -116,11 +126,11 @@ The reading carries two windows rather than one, at 7 and 30 days. A single wind
|
|
|
116
126
|
|
|
117
127
|
Nothing fails on a number here. A record folder has no correct size, so the reading is a number to notice rather than a threshold to gate, and the point of the verb is that the next reading is taken by a command instead of by someone remembering to count the folder. The memory pen went from 44 entries to 236 between two readings taken by hand two weeks apart, which is the measurement this replaces.
|
|
118
128
|
|
|
119
|
-
|
|
129
|
+
The scratch folder is read here and skipped by a backup, because deletable without loss is not the same as empty. The routing handoffs and the memory archive both sit there and both accumulate. `.claude/.records.git` stays out because it is the backup history rather than a record, and `.claude/worktrees/` stays out because each entry is a checkout of the project with its own removal verb, and one of them outweighs every record folder combined.
|
|
120
130
|
|
|
121
131
|
The window counts read `mtime`, so what they report is a file written inside the window rather than one created there. An entry edited long after it landed reads as recent, which overstates growth and never understates it, and these folders are append-mostly so the two readings agree on nearly every file. The one reading that is wrong rather than early is a machine restored by `canon records pull`, which resets the work tree hard and re-dates every file it writes, so a window taken there counts the restore. Nothing on the filesystem separates the two, since a restored file is new by every stamp it carries.
|
|
122
132
|
|
|
123
|
-
Exit codes: `0` the reading completed, `1` refused. The one refusal is `no-folder`, raised when the
|
|
133
|
+
Exit codes: `0` the reading completed, `1` refused. The one refusal is `no-folder`, raised when the project holds neither record root. A project holding a root and no records is empty rather than absent, and each folder's own `present` flag already says which of the ten it carries.
|
|
124
134
|
|
|
125
135
|
## Push and pull
|
|
126
136
|
|
|
@@ -132,9 +142,9 @@ canon records push --json
|
|
|
132
142
|
canon records pull
|
|
133
143
|
```
|
|
134
144
|
|
|
135
|
-
The backed folders are `diagrams`, `groundwork`, `intake`, `memory`, `plans`, `proposals`, `review`, `tasks`, and `teach`, all under
|
|
145
|
+
The backed folders are `diagrams`, `groundwork`, `intake`, `memory`, `plans`, `proposals`, `review`, `tasks`, and `teach`, all under whichever record root the project carries. Eight of them are the Claude ignore group the claude manifest ships, minus three entries: `.claude/.tmp`, which is deletable without loss, `.claude/worktrees/`, whose contents belong to the project repository already, and `.claude/.records.git/`, which is the history the rest are pushed into. `diagrams` is the one that group does not carry at all, since a target still tracks its own copies, which is why the list is spelled out rather than derived. Each name is a top-level record folder and every archive sits inside the one it archives, so the list stays at one entry per surface however many archives appear. It is a constant rather than configuration, and it deliberately does not match the six record kinds `validate` hardcodes.
|
|
136
146
|
|
|
137
|
-
Records are gitignored by design, so the history lives in a second git directory at `.
|
|
147
|
+
Records are gitignored by design, so the history lives in a second git directory at `.records.git` inside the record root, with that root as its work tree. Both resolve off the root together rather than folder by folder, since a history opened at one root beside a work tree at the other would stage the deletion of every folder a move relocated. Every path stays where it is, which is what a separate checkout could not do. The verbs stage the nine folders by explicit pathspec with `--force`, so nothing outside them can enter the index however the ignore rules read, and the project working tree and its index are never touched. Each pathspec is a bare folder name and git reads it against the current directory rather than against the work tree the same call names, so the invocation carries `-C` at the work tree beside the other two flags. That is what lets either verb run from a linked worktree under `.claude/worktrees/`, which sits inside the records work tree and would otherwise prefix every name with its own path.
|
|
138
148
|
|
|
139
149
|
### Setup
|
|
140
150
|
|
package/docs/agents/tasks.md
CHANGED
|
@@ -51,7 +51,7 @@ canon tasks archive --pull-request 673 --json | jq -r 'if .ok then .task else .r
|
|
|
51
51
|
|
|
52
52
|
The record carries `location`, one of `unstated`, `live`, `archived`, or `outside`, and `citedBy`, the other live tasks whose `Plan:` line lands on the same file. Exit codes: `0` read, `1` refused with `no-board` or `no-match`.
|
|
53
53
|
|
|
54
|
-
The target resolves against
|
|
54
|
+
The target resolves against the board folder and against the project root both, so `../plans/x.md` and `.claude/plans/x.md` land on the same file and one plan two tasks spelled differently counts once. Containment is tested at both record roots rather than at the one this tree resolves at, since a line somebody wrote against a root the tree has since left is still a path into the plans folder, and reading it as outside would report a shipped plan as still live. `docs/agents/records.md` states the read order.
|
|
55
55
|
|
|
56
56
|
`canon tasks archive` gates on this same answer, so a caller wanting the count reads it here rather than scanning the board. The `claude-docs` plans sweep is the exception and still states the rule in its own body, because a plugin skill reaches a target on merge while the CLI reaches one on release, so a sweep calling a verb the installed `canon` predates gets no record back and archives nothing.
|
|
57
57
|
|
package/package.json
CHANGED
|
@@ -117,11 +117,17 @@ for entry in "${shipped[@]}"; do
|
|
|
117
117
|
done
|
|
118
118
|
|
|
119
119
|
# Claude-scoped patterns this repository ignores that the manifest does not
|
|
120
|
-
# ship. Scoped to
|
|
121
|
-
# nothing about `node_modules/` or `.env`.
|
|
120
|
+
# ship. Scoped to the two record roots because the manifest is the claude stack
|
|
121
|
+
# and says nothing about `node_modules/` or `.env`.
|
|
122
|
+
#
|
|
123
|
+
# `.canon` is read as a bare root as well as a prefix, since it is one line
|
|
124
|
+
# covering a whole tree where `.claude/` is thirteen lines naming folders inside
|
|
125
|
+
# a root that also holds tracked content. Leaving it out is what would let a new
|
|
126
|
+
# entry sit outside the only stage comparing the two lists, which is the
|
|
127
|
+
# direction that goes silently blind.
|
|
122
128
|
for pattern in "${ignored[@]}"; do
|
|
123
129
|
case "$pattern" in
|
|
124
|
-
.claude/*) ;;
|
|
130
|
+
.claude/* | .canon | .canon/*) ;;
|
|
125
131
|
*) continue ;;
|
|
126
132
|
esac
|
|
127
133
|
contains "$pattern" "${shipped[@]}" && continue
|
package/src/audits/baseline.ts
CHANGED
|
@@ -15,7 +15,7 @@ import type { AuditResult } from '@/audits/catalog'
|
|
|
15
15
|
* that installs the CLI. A baseline in the package would hand a target this
|
|
16
16
|
* repository's counts to measure its own tree against.
|
|
17
17
|
*/
|
|
18
|
-
export const BASELINE_REL = join('.claude', '
|
|
18
|
+
export const BASELINE_REL = join('.claude', 'canon', 'baseline.json')
|
|
19
19
|
|
|
20
20
|
export interface Baseline {
|
|
21
21
|
/** The day the record was taken, as `YYYY-MM-DD`. */
|
package/src/commands/claude.ts
CHANGED
|
@@ -51,6 +51,7 @@ import {
|
|
|
51
51
|
import { copyPreservingMode } from '@/copy'
|
|
52
52
|
import { execScript } from '@/exec'
|
|
53
53
|
import { PROJECT_ROOT } from '@/project-root'
|
|
54
|
+
import { recordDir } from '@/record-root'
|
|
54
55
|
import { isDirectory, resolveTarget } from '@/target'
|
|
55
56
|
import { injectGitignore, pruneGitignore } from '@/tooling/inject'
|
|
56
57
|
import {
|
|
@@ -491,13 +492,18 @@ async function runSync(target: string): Promise<number> {
|
|
|
491
492
|
const resolved = resolveTarget(target, PROJECT_ROOT)
|
|
492
493
|
if (typeof resolved === 'number') return resolved
|
|
493
494
|
|
|
495
|
+
// A record folder resolves at either root, so a migrated target is reported as
|
|
496
|
+
// seeded rather than sent to `canon claude init` to re-create records it
|
|
497
|
+
// already holds. The three seeded files and `wireframes` are tracked and stay
|
|
498
|
+
// at `.claude/`, which the resolver answers for them anyway, since nothing
|
|
499
|
+
// ever creates a second root copy for a name that does not move.
|
|
494
500
|
logStep('Seeded')
|
|
495
501
|
for (const name of SEEDED_FILES) {
|
|
496
502
|
if (existsSync(join(resolved, '.claude', name))) logInfo(name)
|
|
497
503
|
else logWarn(`${name} missing. Run \`canon claude init\``)
|
|
498
504
|
}
|
|
499
505
|
for (const name of SEEDED_DIRS) {
|
|
500
|
-
if (isDirectory(
|
|
506
|
+
if (isDirectory(recordDir(resolved, name))) logInfo(`${name}/`)
|
|
501
507
|
else logWarn(`${name}/ missing. Run \`canon claude init\``)
|
|
502
508
|
}
|
|
503
509
|
|
package/src/commands/context.ts
CHANGED
|
@@ -180,7 +180,7 @@ async function runAudit(
|
|
|
180
180
|
if (folders.length === 0) {
|
|
181
181
|
return refuse(
|
|
182
182
|
'no-folders',
|
|
183
|
-
`No audited folder found ${named ? 'under
|
|
183
|
+
`No audited folder found ${named ? 'under a record root or the project root' : 'under a record root'}, since resolving one needs its own index.md file. Looked for: ${names.join(', ')}.`,
|
|
184
184
|
gateOnly,
|
|
185
185
|
root,
|
|
186
186
|
opts.json ?? false,
|
|
@@ -200,7 +200,7 @@ async function runAudit(
|
|
|
200
200
|
if (gateOnly && cited.length === 0) {
|
|
201
201
|
return refuse(
|
|
202
202
|
'no-citation-scope',
|
|
203
|
-
`The citation check spells the
|
|
203
|
+
`The citation check spells the record-root prefixes and no audited folder resolved under one. Looked for: ${names.join(', ')}.`,
|
|
204
204
|
gateOnly,
|
|
205
205
|
root,
|
|
206
206
|
opts.json ?? false,
|
|
@@ -414,7 +414,7 @@ function reportCitations(
|
|
|
414
414
|
|
|
415
415
|
if (cited.length === 0) {
|
|
416
416
|
logInfo(
|
|
417
|
-
'Out of scope. The pattern spells the
|
|
417
|
+
'Out of scope. The pattern spells the record-root prefixes, and no audited folder resolved under one.',
|
|
418
418
|
)
|
|
419
419
|
return
|
|
420
420
|
}
|
package/src/commands/design.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { existsSync } from 'node:fs'
|
|
2
2
|
import { resolve } from 'node:path'
|
|
3
3
|
import type { Command } from 'commander'
|
|
4
|
+
import { creationRel } from '@/record-root'
|
|
4
5
|
import { renderDesignDoc } from '@/design/render'
|
|
5
6
|
import { palette } from '@/ui'
|
|
6
7
|
|
|
@@ -13,7 +14,11 @@ export function register(program: Command): void {
|
|
|
13
14
|
.command('render')
|
|
14
15
|
.description('Render DESIGN.md tokens to HTML and CSS preview')
|
|
15
16
|
.option('-s, --source <path>', 'Source DESIGN.md path', '.claude/DESIGN.md')
|
|
16
|
-
.option(
|
|
17
|
+
.option(
|
|
18
|
+
'-o, --out <path>',
|
|
19
|
+
'Output directory',
|
|
20
|
+
creationRel('review', 'design'),
|
|
21
|
+
)
|
|
17
22
|
.action((opts: { source: string; out: string }) => {
|
|
18
23
|
const sourcePath = resolve(process.cwd(), opts.source)
|
|
19
24
|
const outDir = resolve(process.cwd(), opts.out)
|
package/src/commands/feedback.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { join } from 'node:path'
|
|
|
3
3
|
import type { Command } from 'commander'
|
|
4
4
|
import { deriveSlug, deriveTitle } from '@/commands/feedback-format'
|
|
5
5
|
import { PROJECT_ROOT } from '@/project-root'
|
|
6
|
+
import { creationRel } from '@/record-root'
|
|
6
7
|
import { createGithubIssue } from '@/github'
|
|
7
8
|
import { frameError, frameSuccess, palette } from '@/ui'
|
|
8
9
|
|
|
@@ -39,7 +40,10 @@ function isToolkitSource(): boolean {
|
|
|
39
40
|
* single ignore entry and the single backed-folder entry it already had.
|
|
40
41
|
*/
|
|
41
42
|
function writeLocal(body: string): string {
|
|
42
|
-
|
|
43
|
+
// Creation, so the destination is the creation default rather than the
|
|
44
|
+
// resolved read root. A toolkit checkout that has migrated its records already
|
|
45
|
+
// carries the folder and resolves the same path either way.
|
|
46
|
+
const relativeDir = creationRel('review', 'feedback')
|
|
43
47
|
const reviewDir = join(PROJECT_ROOT, relativeDir)
|
|
44
48
|
mkdirSync(reviewDir, { recursive: true })
|
|
45
49
|
const filename = `feedback-${deriveSlug(body)}-${timestamp()}.md`
|
package/src/commands/gov.ts
CHANGED
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
scanCounts,
|
|
10
10
|
} from '@/counts/scan'
|
|
11
11
|
import { PROJECT_ROOT } from '@/project-root'
|
|
12
|
+
import { creationRel, SCRATCH } from '@/record-root'
|
|
12
13
|
import { createGovAdapter } from '@/gov/adapter'
|
|
13
14
|
import { regenConsumedRules } from '@/gov/consumed'
|
|
14
15
|
import { installRules, lookupRules } from '@/gov/install'
|
|
@@ -65,7 +66,7 @@ import {
|
|
|
65
66
|
select,
|
|
66
67
|
} from '@/ui'
|
|
67
68
|
|
|
68
|
-
const PAYLOAD_REL =
|
|
69
|
+
const PAYLOAD_REL = creationRel(SCRATCH, 'gov', 'rules.md')
|
|
69
70
|
const RULES_REL = join('.claude', 'rules')
|
|
70
71
|
|
|
71
72
|
interface InstallOptions {
|
package/src/commands/slides.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { existsSync } from 'node:fs'
|
|
2
2
|
import { resolve } from 'node:path'
|
|
3
3
|
import type { Command } from 'commander'
|
|
4
|
+
import { creationRel } from '@/record-root'
|
|
4
5
|
import { LAYOUTS } from '@/slides/layouts'
|
|
5
6
|
import { openDeck } from '@/slides/open'
|
|
6
7
|
import { renderSlidesDoc } from '@/slides/render'
|
|
@@ -16,7 +17,11 @@ export function register(program: Command): void {
|
|
|
16
17
|
.command('render')
|
|
17
18
|
.description('Render a SLIDES.md source into a PowerPoint deck')
|
|
18
19
|
.option('-s, --source <path>', 'Source SLIDES.md path', '.claude/SLIDES.md')
|
|
19
|
-
.option(
|
|
20
|
+
.option(
|
|
21
|
+
'-o, --out <path>',
|
|
22
|
+
'Output directory',
|
|
23
|
+
creationRel('review', 'slides'),
|
|
24
|
+
)
|
|
20
25
|
.option('-v, --variant <variant>', 'Override variant (light or dark)')
|
|
21
26
|
.option(
|
|
22
27
|
'-m, --mirror <path>',
|
package/src/context/citations.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { existsSync } from 'node:fs'
|
|
|
2
2
|
import { readFile } from 'node:fs/promises'
|
|
3
3
|
import { resolve } from 'node:path'
|
|
4
4
|
import { listRepositoryFiles } from '@/git-files'
|
|
5
|
+
import { RECORD_ROOTS } from '@/record-root'
|
|
5
6
|
|
|
6
7
|
/**
|
|
7
8
|
* Suppresses citation checking for the source line carrying it.
|
|
@@ -76,21 +77,31 @@ export function isFixture(rel: string): boolean {
|
|
|
76
77
|
return rel.split('/').some((segment) => FIXTURE_SEGMENTS.includes(segment))
|
|
77
78
|
}
|
|
78
79
|
|
|
80
|
+
/**
|
|
81
|
+
* Both record roots are spelled, so a citation into a folder that has moved is
|
|
82
|
+
* still resolved. A pattern fixed at one root matches nothing after the move and
|
|
83
|
+
* reports nothing, which is a stale reference passing the check that exists to
|
|
84
|
+
* find it rather than a check that fails.
|
|
85
|
+
*/
|
|
79
86
|
export function citationPattern(folders: readonly string[]): RegExp {
|
|
80
|
-
const names = folders.map((name) =>
|
|
81
|
-
|
|
82
|
-
|
|
87
|
+
const names = folders.map((name) => escape(name))
|
|
88
|
+
const roots = RECORD_ROOTS.map((name) => escape(name))
|
|
89
|
+
|
|
83
90
|
return new RegExp(
|
|
84
|
-
|
|
91
|
+
`(?:${roots.join('|')})/(?:${names.join('|')})/[A-Za-z0-9._/-]+\\.md`,
|
|
85
92
|
'g',
|
|
86
93
|
)
|
|
87
94
|
}
|
|
88
95
|
|
|
96
|
+
function escape(value: string): string {
|
|
97
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
98
|
+
}
|
|
99
|
+
|
|
89
100
|
/**
|
|
90
101
|
* A backticked filename carrying no folder, the form a reference takes when it
|
|
91
102
|
* names a sibling rather than a path.
|
|
92
103
|
*
|
|
93
|
-
* `citationPattern` spells
|
|
104
|
+
* `citationPattern` spells a record-root prefix and cannot see this shape at
|
|
94
105
|
* all, which is the reason the form rule exists. Widening that expression to
|
|
95
106
|
* admit a bare name was the alternative and it puts one match in the position of
|
|
96
107
|
* answering two questions, since a spelled path is a reference by construction
|
package/src/context/folders.ts
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { existsSync } from 'node:fs'
|
|
2
2
|
import { dirname, relative, resolve } from 'node:path'
|
|
3
3
|
import { INDEX_FILE, listIndexes } from '@/indexes/walk'
|
|
4
|
+
import { RECORD_ROOTS } from '@/record-root'
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
|
-
* Folder names under
|
|
7
|
+
* Folder names under a record root audited by default.
|
|
7
8
|
*
|
|
8
9
|
* A named list rather than the index-plus-entry contract read off disk, so a
|
|
9
10
|
* generated tree satisfying that contract is never measured against a rule
|
|
@@ -12,8 +13,8 @@ import { INDEX_FILE, listIndexes } from '@/indexes/walk'
|
|
|
12
13
|
* here.
|
|
13
14
|
*
|
|
14
15
|
* It doubles as the citation check's scope, since `citationPattern` spells only
|
|
15
|
-
* these names. A
|
|
16
|
-
*
|
|
16
|
+
* these names. A folder left off the list is never resolved, so a path into one
|
|
17
|
+
* goes stale silently rather than failing a push.
|
|
17
18
|
*/
|
|
18
19
|
export const DEFAULT_FOLDERS: readonly string[] = [
|
|
19
20
|
'context',
|
|
@@ -21,8 +22,17 @@ export const DEFAULT_FOLDERS: readonly string[] = [
|
|
|
21
22
|
'wireframes',
|
|
22
23
|
]
|
|
23
24
|
|
|
24
|
-
/**
|
|
25
|
-
|
|
25
|
+
/**
|
|
26
|
+
* The bases every folder in the default list is looked for under, in the record
|
|
27
|
+
* roots' own precedence order.
|
|
28
|
+
*
|
|
29
|
+
* `diagrams` is the one name here that is a session record and moves with them,
|
|
30
|
+
* so the list has to carry the root it moves to. `context` and `wireframes` are
|
|
31
|
+
* tracked and stay, which leaves them resolvable at a root nothing will ever put
|
|
32
|
+
* them under. That costs one `existsSync` apiece and is cheaper than a per-name
|
|
33
|
+
* base map that would state the same split twice.
|
|
34
|
+
*/
|
|
35
|
+
const CLAUDE_BASES: readonly string[] = RECORD_ROOTS
|
|
26
36
|
|
|
27
37
|
/** The project root, reached only by a name the caller asked for. */
|
|
28
38
|
const ROOT_BASE = '.'
|
|
@@ -58,7 +68,7 @@ export interface AuditedFolder {
|
|
|
58
68
|
}
|
|
59
69
|
|
|
60
70
|
/**
|
|
61
|
-
* Names the requested
|
|
71
|
+
* Names the requested record-root folders that actually exist, which is the
|
|
62
72
|
* citation check's scope.
|
|
63
73
|
*
|
|
64
74
|
* A skill or seed pointing into `.claude/wireframes/` is a live instruction for
|
|
@@ -76,7 +86,7 @@ export function presentNames(folders: readonly AuditedFolder[]): string[] {
|
|
|
76
86
|
return [
|
|
77
87
|
...new Set(
|
|
78
88
|
folders
|
|
79
|
-
.filter((folder) => folder.base
|
|
89
|
+
.filter((folder) => CLAUDE_BASES.includes(folder.base))
|
|
80
90
|
.map((folder) => folder.name),
|
|
81
91
|
),
|
|
82
92
|
]
|
|
@@ -157,7 +167,7 @@ export async function resolveFolders(
|
|
|
157
167
|
names: readonly string[] = DEFAULT_FOLDERS,
|
|
158
168
|
{ canResolveAtRoot = false }: ResolveOptions = {},
|
|
159
169
|
): Promise<FolderResolution> {
|
|
160
|
-
const bases = canResolveAtRoot ? [
|
|
170
|
+
const bases = canResolveAtRoot ? [...CLAUDE_BASES, ROOT_BASE] : CLAUDE_BASES
|
|
161
171
|
const folders: AuditedFolder[] = []
|
|
162
172
|
const missing: string[] = []
|
|
163
173
|
|
package/src/gate/measures.ts
CHANGED
|
@@ -97,7 +97,7 @@ export const SANDBOX_UNDECLARED_CEILING = 47
|
|
|
97
97
|
* rather than derived, because this stage only ever names the file in a remedy
|
|
98
98
|
* a reader has to be able to open, and `canon audits run` owns writing it.
|
|
99
99
|
*/
|
|
100
|
-
export const AUDITS_BASELINE = '.claude/
|
|
100
|
+
export const AUDITS_BASELINE = '.claude/canon/baseline.json'
|
|
101
101
|
|
|
102
102
|
export const HERO_STAMP_FAILURE =
|
|
103
103
|
'The hero set disagrees with the stamp written when the image was captured. Run canon capture assets/hero.html and commit all three files together.'
|
package/src/intake/folder.ts
CHANGED
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
readItems,
|
|
10
10
|
writeAnswerLine,
|
|
11
11
|
} from '@/intake/items'
|
|
12
|
+
import { recordDir } from '@/record-root'
|
|
12
13
|
|
|
13
14
|
export const INTAKE_REFUSALS = [
|
|
14
15
|
'no-intake',
|
|
@@ -80,7 +81,7 @@ function refuse(
|
|
|
80
81
|
}
|
|
81
82
|
|
|
82
83
|
export function intakeDir(root: string): string {
|
|
83
|
-
return
|
|
84
|
+
return recordDir(root, 'intake')
|
|
84
85
|
}
|
|
85
86
|
|
|
86
87
|
/**
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The roots a session record folder is read at, in precedence order.
|
|
6
|
+
*
|
|
7
|
+
* `.canon/` wins because a tree that carries it has been migrated, and reading
|
|
8
|
+
* `.claude/` there would answer from the copy the move left behind. A tree that
|
|
9
|
+
* carries neither is every tree today, which is what keeps this branch a no-op
|
|
10
|
+
* until the move lands.
|
|
11
|
+
*
|
|
12
|
+
* The shape is `readStamp`'s: order the spellings, take the first that exists,
|
|
13
|
+
* and stand the creation default in when none does.
|
|
14
|
+
*/
|
|
15
|
+
export const RECORD_ROOTS = ['.canon', '.claude'] as const
|
|
16
|
+
|
|
17
|
+
export type RecordRoot = (typeof RECORD_ROOTS)[number]
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The root a record folder is created at.
|
|
21
|
+
*
|
|
22
|
+
* It disagrees with the read precedence above on purpose, and the disagreement
|
|
23
|
+
* is the whole of this branch. Creating under `.canon/` before the move would
|
|
24
|
+
* write records to a root whose ignore line may not have reached a target yet,
|
|
25
|
+
* and it would split one project's records across two roots with no verb able
|
|
26
|
+
* to reconcile them. The move flips this line and nothing else.
|
|
27
|
+
*/
|
|
28
|
+
export const CREATION_ROOT: RecordRoot = '.claude'
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The deletable scratch folder, named at the spelling `.claude/` gives it.
|
|
32
|
+
*
|
|
33
|
+
* It is the one folder whose name differs by root. Inside a dotted root the
|
|
34
|
+
* leading dot hides nothing already hidden and costs a bare `ls` that omits the
|
|
35
|
+
* folder, so the move drops it. Every other record folder keeps its name,
|
|
36
|
+
* `.records.git` included, where the dot marks the mechanism apart from a
|
|
37
|
+
* payload rather than hiding it.
|
|
38
|
+
*/
|
|
39
|
+
export const SCRATCH = '.tmp'
|
|
40
|
+
|
|
41
|
+
/** The scratch folder's name under `.canon/`. */
|
|
42
|
+
const CANON_SCRATCH = 'tmp'
|
|
43
|
+
|
|
44
|
+
/** How a root spells a folder name. Only the scratch folder differs. */
|
|
45
|
+
function spell(root: RecordRoot, folder: string): string {
|
|
46
|
+
return root === '.canon' && folder === SCRATCH ? CANON_SCRATCH : folder
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* The root a folder resolves at: the first that carries it, and the creation
|
|
51
|
+
* default when neither does.
|
|
52
|
+
*
|
|
53
|
+
* Presence is read on the record folder itself rather than on the full path, so
|
|
54
|
+
* an archive or a payload that does not exist yet still resolves beside the
|
|
55
|
+
* records it belongs to rather than at the creation default.
|
|
56
|
+
*/
|
|
57
|
+
function rootOf(root: string, folder: string): RecordRoot {
|
|
58
|
+
return (
|
|
59
|
+
RECORD_ROOTS.find((candidate) =>
|
|
60
|
+
existsSync(join(root, candidate, spell(candidate, folder))),
|
|
61
|
+
) ?? CREATION_ROOT
|
|
62
|
+
)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Where a record folder is read.
|
|
67
|
+
*
|
|
68
|
+
* `folder` is the record folder itself and `rest` is whatever sits inside it,
|
|
69
|
+
* so a caller spells no root and no folder-name variant of its own. A caller
|
|
70
|
+
* that spells `.claude` by hand is the one thing the move has to find, and one
|
|
71
|
+
* that calls this is one the move never has to open again.
|
|
72
|
+
*/
|
|
73
|
+
export function recordDir(
|
|
74
|
+
root: string,
|
|
75
|
+
folder: string,
|
|
76
|
+
...rest: string[]
|
|
77
|
+
): string {
|
|
78
|
+
const at = rootOf(root, folder)
|
|
79
|
+
|
|
80
|
+
return join(root, at, spell(at, folder), ...rest)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Every root a record folder would be read at, in precedence order, whether or
|
|
85
|
+
* not it is on disk.
|
|
86
|
+
*
|
|
87
|
+
* Containment tests take this rather than `recordDir`, since a path written
|
|
88
|
+
* against the root a tree no longer uses is still a path into that folder and
|
|
89
|
+
* reading it as outside would report a shipped plan as still live.
|
|
90
|
+
*/
|
|
91
|
+
export function recordDirs(
|
|
92
|
+
root: string,
|
|
93
|
+
folder: string,
|
|
94
|
+
...rest: string[]
|
|
95
|
+
): string[] {
|
|
96
|
+
return RECORD_ROOTS.map((candidate) =>
|
|
97
|
+
join(root, candidate, spell(candidate, folder), ...rest),
|
|
98
|
+
)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Where a record folder is created, which is the creation default always. */
|
|
102
|
+
export function creationDir(
|
|
103
|
+
root: string,
|
|
104
|
+
folder: string,
|
|
105
|
+
...rest: string[]
|
|
106
|
+
): string {
|
|
107
|
+
return join(root, CREATION_ROOT, spell(CREATION_ROOT, folder), ...rest)
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* The creation destination relative to the project root, which is the form a
|
|
112
|
+
* message displays and an option default carries.
|
|
113
|
+
*/
|
|
114
|
+
export function creationRel(folder: string, ...rest: string[]): string {
|
|
115
|
+
return join(CREATION_ROOT, spell(CREATION_ROOT, folder), ...rest)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* The record root itself, for a caller whose subject is the root rather than a
|
|
120
|
+
* folder inside it.
|
|
121
|
+
*
|
|
122
|
+
* A half-migrated tree resolves here on the root that exists rather than on the
|
|
123
|
+
* folders under it, which is what makes the backup history and its work tree
|
|
124
|
+
* one answer. Splitting them would let a push resolve a history at one root and
|
|
125
|
+
* stage a work tree at the other, which stages the deletion of every folder the
|
|
126
|
+
* move relocated.
|
|
127
|
+
*/
|
|
128
|
+
export function recordRoot(root: string): string {
|
|
129
|
+
return join(
|
|
130
|
+
root,
|
|
131
|
+
RECORD_ROOTS.find((candidate) => existsSync(join(root, candidate))) ??
|
|
132
|
+
CREATION_ROOT,
|
|
133
|
+
)
|
|
134
|
+
}
|