@erclx/canon 5.13.0 → 5.14.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.
Files changed (41) hide show
  1. package/claude/.claude-plugin/plugin.json +1 -1
  2. package/claude/skills/api-design/SKILL.md +3 -2
  3. package/claude/skills/deprecation-migration/REQUIREMENT.md +62 -0
  4. package/claude/skills/deprecation-migration/SKILL.md +115 -0
  5. package/claude/skills/deprecation-migration/references/adopted.md +50 -0
  6. package/claude/skills/git-followup/REQUIREMENT.md +1 -0
  7. package/claude/skills/git-followup/SKILL.md +3 -0
  8. package/claude/skills/git-pr/REQUIREMENT.md +1 -0
  9. package/claude/skills/git-pr/SKILL.md +1 -1
  10. package/claude/skills/git-pr/references/evidence.md +2 -2
  11. package/claude/skills/git-ship/REQUIREMENT.md +3 -0
  12. package/claude/skills/git-ship/SKILL.md +12 -1
  13. package/claude/skills/review-ui/REQUIREMENT.md +5 -2
  14. package/claude/skills/review-ui/SKILL.md +12 -4
  15. package/claude/skills/review-ui/references/driving.md +5 -3
  16. package/claude/skills/review-ui/references/post.md +3 -2
  17. package/docs/agents/commands.md +1 -1
  18. package/docs/agents/index.md +1 -0
  19. package/docs/agents/markdown-audit.md +1 -1
  20. package/docs/agents/pr-evidence.md +5 -0
  21. package/docs/agents/pr-frames.md +112 -0
  22. package/docs/workflow/skill-map.md +15 -14
  23. package/package.json +1 -1
  24. package/src/claude/cases/misc.ts +5 -0
  25. package/src/claude/skills-audit.ts +1 -0
  26. package/src/commands/pr.ts +452 -0
  27. package/src/context/architecture.ts +5 -2
  28. package/src/gate/boundaries.ts +509 -0
  29. package/src/gate/measures.ts +8 -19
  30. package/src/gate/stages.ts +12 -28
  31. package/src/pr/frames.ts +323 -0
  32. package/src/tooling/manifest.ts +2 -3
  33. package/src/tooling/seeds.ts +37 -0
  34. package/tooling/claude/manifest.toml +3 -3
  35. package/scripts/core/check-capability-seeding.sh +0 -134
  36. package/scripts/core/check-ignore-parity.sh +0 -141
  37. package/scripts/core/check-plugin-boundary.sh +0 -45
  38. package/scripts/core/check-seed-independence.sh +0 -62
  39. package/scripts/core/check-skill-paths.sh +0 -24
  40. package/scripts/core/list-seed-roots.sh +0 -18
  41. package/scripts/lib/tooling.sh +0 -37
@@ -0,0 +1,323 @@
1
+ import { readFile } from 'node:fs/promises'
2
+
3
+ /** The branch holding review frames. It never merges into a branch anyone checks out. */
4
+ export const FRAMES_BRANCH = 'canon-frames'
5
+
6
+ /** The eight bytes every PNG opens with. A frame that lacks them embeds as a broken image. */
7
+ const PNG_SIGNATURE = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]
8
+
9
+ const SHORT_HEAD_LENGTH = 7
10
+
11
+ const HEAD_PATTERN = /^[0-9a-f]{7,40}$/
12
+
13
+ /** A compact UTC instant such as `20261002T154450Z`, the form `date -u +%Y%m%dT%H%M%SZ` prints. */
14
+ const PASS_PATTERN = /^\d{8}T\d{6}Z$/
15
+
16
+ /** How many times an add rebuilds on a tip another writer moved. */
17
+ const ADD_ATTEMPTS = 3
18
+
19
+ const FOLDER_PATTERN = /^pr-(\d+)$/
20
+
21
+ const DAY_MS = 86_400_000
22
+
23
+ export interface TreeEntry {
24
+ readonly path: string
25
+ readonly mode: string
26
+ readonly type: 'blob' | 'tree'
27
+ readonly sha: string
28
+ }
29
+
30
+ export type Tip =
31
+ | { readonly kind: 'found'; readonly commit: string; readonly tree: string }
32
+ | { readonly kind: 'missing' }
33
+ | { readonly kind: 'unreadable' }
34
+
35
+ /** How a ref write landed. `conflict` means another writer holds the ref the call expected to own. */
36
+ export type RefWrite = 'ok' | 'conflict' | 'failed'
37
+
38
+ export interface PullClosure {
39
+ readonly state: 'open' | 'closed'
40
+ readonly closedAt?: string
41
+ }
42
+
43
+ /**
44
+ * The git data API calls a frame write needs, injected so the logic runs
45
+ * without a network. Every read that fails returns undefined rather than
46
+ * throwing, and each caller decides what an absent answer means.
47
+ */
48
+ export interface FramesApi {
49
+ readTip(): Promise<Tip>
50
+ listTree(tree: string): Promise<readonly TreeEntry[] | undefined>
51
+ createBlob(base64: string): Promise<string | undefined>
52
+ createTree(
53
+ entries: readonly TreeEntry[],
54
+ base?: string,
55
+ ): Promise<string | undefined>
56
+ createCommit(
57
+ message: string,
58
+ tree: string,
59
+ parents: readonly string[],
60
+ ): Promise<string | undefined>
61
+ createRef(commit: string): Promise<RefWrite>
62
+ moveRef(commit: string, force: boolean): Promise<RefWrite>
63
+ deleteRef(): Promise<boolean>
64
+ readPull(number: number): Promise<PullClosure | undefined>
65
+ }
66
+
67
+ export type FramesRefusal = 'unreadable-tip' | 'push-failed' | 'ref-conflict'
68
+
69
+ export interface FrameInput {
70
+ readonly number: number
71
+ readonly head: string
72
+ readonly pass: string
73
+ readonly box: number
74
+ readonly bytes: Uint8Array
75
+ }
76
+
77
+ export type PushResult =
78
+ | {
79
+ readonly kind: 'pushed'
80
+ readonly commit: string
81
+ readonly path: string
82
+ readonly created: boolean
83
+ }
84
+ | { readonly kind: 'refused'; readonly reason: FramesRefusal }
85
+
86
+ export type DropResult =
87
+ | {
88
+ readonly kind: 'dropped'
89
+ readonly removed: readonly number[]
90
+ readonly commit?: string
91
+ readonly deleted?: boolean
92
+ }
93
+ | { readonly kind: 'refused'; readonly reason: FramesRefusal }
94
+
95
+ export type PruneResult =
96
+ | {
97
+ readonly kind: 'dropped'
98
+ readonly removed: readonly number[]
99
+ readonly kept: readonly number[]
100
+ readonly unread: readonly number[]
101
+ readonly commit?: string
102
+ readonly deleted?: boolean
103
+ }
104
+ | { readonly kind: 'refused'; readonly reason: FramesRefusal }
105
+
106
+ /** Whether a value names a commit, so a branch name or a path never becomes a folder. */
107
+ export function isFrameHead(head: string): boolean {
108
+ return HEAD_PATTERN.test(head)
109
+ }
110
+
111
+ /** Whether a value is a compact UTC stamp, the only pass segment the path takes. */
112
+ export function isFramePass(pass: string): boolean {
113
+ return PASS_PATTERN.test(pass)
114
+ }
115
+
116
+ /**
117
+ * Where a frame lands. The pass segment keeps a second pass at the same head
118
+ * and box from replacing the image an earlier review comment shows, since a
119
+ * branch-path link reads whatever the path holds now.
120
+ */
121
+ export function framePath(
122
+ number: number,
123
+ head: string,
124
+ pass: string,
125
+ box: number,
126
+ ): string {
127
+ return `pr-${number}/${head.slice(0, SHORT_HEAD_LENGTH)}/${pass}/box-${box}.png`
128
+ }
129
+
130
+ /**
131
+ * The embed link, by branch path rather than by commit. Every drop rewrites
132
+ * the branch, so a commit-pinned link to another pull request's frame would
133
+ * stop resolving once GitHub collects the commit, while a path link lives
134
+ * exactly as long as its file. The blob form is the one `canon pr evidence`
135
+ * embeds, which a private repository's browser can open where the raw host
136
+ * serves nothing.
137
+ */
138
+ export function frameLink(repo: string, path: string): string {
139
+ return `https://github.com/${repo}/blob/${FRAMES_BRANCH}/${path}?raw=true`
140
+ }
141
+
142
+ /** The frame's bytes, or undefined when the file is absent, empty, or not a PNG. */
143
+ export async function readFrame(path: string): Promise<Uint8Array | undefined> {
144
+ const bytes = await readFile(path).catch(() => undefined)
145
+ if (bytes === undefined || bytes.length <= PNG_SIGNATURE.length) {
146
+ return undefined
147
+ }
148
+ return PNG_SIGNATURE.every((byte, index) => bytes[index] === byte)
149
+ ? new Uint8Array(bytes)
150
+ : undefined
151
+ }
152
+
153
+ /**
154
+ * Commits one frame onto the branch, creating the branch with a root
155
+ * commit when it is missing. The ref only fast-forwards here, so a drop or a
156
+ * second add landing between the read and the move fails the move, and the
157
+ * add rebuilds on the tip that writer left.
158
+ */
159
+ export async function pushFrame(
160
+ api: FramesApi,
161
+ input: FrameInput,
162
+ ): Promise<PushResult> {
163
+ const path = framePath(input.number, input.head, input.pass, input.box)
164
+ const blob = await api.createBlob(Buffer.from(input.bytes).toString('base64'))
165
+ if (blob === undefined) return { kind: 'refused', reason: 'push-failed' }
166
+ const entry: TreeEntry = { path, mode: '100644', type: 'blob', sha: blob }
167
+ const message = `chore(frames): add box ${input.box} of #${input.number}`
168
+
169
+ for (let attempt = 0; attempt < ADD_ATTEMPTS; attempt += 1) {
170
+ const tip = await api.readTip()
171
+ if (tip.kind === 'unreadable') {
172
+ return { kind: 'refused', reason: 'unreadable-tip' }
173
+ }
174
+
175
+ const base = tip.kind === 'found' ? tip.tree : undefined
176
+ const tree = await api.createTree([entry], base)
177
+ if (tree === undefined) return { kind: 'refused', reason: 'push-failed' }
178
+ const parents = tip.kind === 'found' ? [tip.commit] : []
179
+ const commit = await api.createCommit(message, tree, parents)
180
+ if (commit === undefined) return { kind: 'refused', reason: 'push-failed' }
181
+
182
+ const write =
183
+ tip.kind === 'found'
184
+ ? await api.moveRef(commit, false)
185
+ : await api.createRef(commit)
186
+ if (write === 'failed') return { kind: 'refused', reason: 'push-failed' }
187
+ if (write === 'ok') {
188
+ return { kind: 'pushed', commit, path, created: tip.kind === 'missing' }
189
+ }
190
+ }
191
+ return { kind: 'refused', reason: 'ref-conflict' }
192
+ }
193
+
194
+ function folderNumber(entry: TreeEntry): number | undefined {
195
+ if (entry.type !== 'tree') return undefined
196
+ const match = FOLDER_PATTERN.exec(entry.path)
197
+ return match?.[1] === undefined ? undefined : Number(match[1])
198
+ }
199
+
200
+ async function readFolders(
201
+ api: FramesApi,
202
+ ): Promise<
203
+ | { readonly kind: 'read'; readonly entries: readonly TreeEntry[] }
204
+ | { readonly kind: 'missing' }
205
+ | { readonly kind: 'refused'; readonly reason: FramesRefusal }
206
+ > {
207
+ const tip = await api.readTip()
208
+ if (tip.kind === 'missing') return { kind: 'missing' }
209
+ if (tip.kind === 'unreadable') {
210
+ return { kind: 'refused', reason: 'unreadable-tip' }
211
+ }
212
+ const entries = await api.listTree(tip.tree)
213
+ if (entries === undefined) {
214
+ return { kind: 'refused', reason: 'unreadable-tip' }
215
+ }
216
+ return { kind: 'read', entries }
217
+ }
218
+
219
+ /**
220
+ * Rewrites the branch without the named pull requests' folders, as one
221
+ * root commit the ref is force-moved to. A plain delete commit would
222
+ * keep every dropped image reachable through history. The rewrite builds from
223
+ * the tip it reads, and a branch left empty is deleted rather than holding an
224
+ * empty tree.
225
+ */
226
+ async function rewriteWithout(
227
+ api: FramesApi,
228
+ entries: readonly TreeEntry[],
229
+ drop: ReadonlySet<number>,
230
+ ): Promise<DropResult> {
231
+ const removed = entries
232
+ .map(folderNumber)
233
+ .filter((n): n is number => n !== undefined && drop.has(n))
234
+ .sort((a, b) => a - b)
235
+ if (removed.length === 0) return { kind: 'dropped', removed: [] }
236
+
237
+ const kept = entries.filter((entry) => {
238
+ const n = folderNumber(entry)
239
+ return n === undefined || !drop.has(n)
240
+ })
241
+ if (kept.length === 0) {
242
+ return (await api.deleteRef())
243
+ ? { kind: 'dropped', removed, deleted: true }
244
+ : { kind: 'refused', reason: 'push-failed' }
245
+ }
246
+
247
+ const tree = await api.createTree(kept)
248
+ if (tree === undefined) return { kind: 'refused', reason: 'push-failed' }
249
+ const message = `chore(frames): drop ${removed.map((n) => `#${n}`).join(', ')}`
250
+ const commit = await api.createCommit(message, tree, [])
251
+ if (commit === undefined) return { kind: 'refused', reason: 'push-failed' }
252
+ const write = await api.moveRef(commit, true)
253
+ return write === 'ok'
254
+ ? { kind: 'dropped', removed, commit }
255
+ : { kind: 'refused', reason: 'push-failed' }
256
+ }
257
+
258
+ export async function dropFrames(
259
+ api: FramesApi,
260
+ numbers: readonly number[],
261
+ ): Promise<DropResult> {
262
+ const folders = await readFolders(api)
263
+ if (folders.kind === 'missing') return { kind: 'dropped', removed: [] }
264
+ if (folders.kind === 'refused') return folders
265
+ return rewriteWithout(api, folders.entries, new Set(numbers))
266
+ }
267
+
268
+ /**
269
+ * Whether a pull request's frames are past keeping. Only a pull request
270
+ * reading closed, with a close date strictly older than the window, expires,
271
+ * so a reopened one, an unread one, and one at the boundary all stay.
272
+ */
273
+ export function isExpired(
274
+ closure: PullClosure | undefined,
275
+ days: number,
276
+ now: Date,
277
+ ): boolean {
278
+ if (closure?.state !== 'closed' || closure.closedAt === undefined) {
279
+ return false
280
+ }
281
+ const closed = Date.parse(closure.closedAt)
282
+ if (Number.isNaN(closed)) return false
283
+ return now.getTime() - closed > days * DAY_MS
284
+ }
285
+
286
+ /**
287
+ * Drops every pull request on the branch that closed more than `days` ago, in
288
+ * one rewrite. A pull request whose state could not be read is kept and
289
+ * listed under `unread`, so a failed read never deletes frames.
290
+ */
291
+ export async function pruneFrames(
292
+ api: FramesApi,
293
+ days: number,
294
+ now: Date,
295
+ ): Promise<PruneResult> {
296
+ const folders = await readFolders(api)
297
+ if (folders.kind === 'missing') {
298
+ return { kind: 'dropped', removed: [], kept: [], unread: [] }
299
+ }
300
+ if (folders.kind === 'refused') return folders
301
+
302
+ const numbers = folders.entries
303
+ .map(folderNumber)
304
+ .filter((n): n is number => n !== undefined)
305
+ const closures = await Promise.all(numbers.map((n) => api.readPull(n)))
306
+ const expired = new Set<number>()
307
+ const kept: number[] = []
308
+ const unread: number[] = []
309
+ numbers.forEach((n, index) => {
310
+ const closure = closures[index]
311
+ if (closure === undefined) unread.push(n)
312
+ if (isExpired(closure, days, now)) expired.add(n)
313
+ else kept.push(n)
314
+ })
315
+
316
+ const result = await rewriteWithout(api, folders.entries, expired)
317
+ if (result.kind === 'refused') return result
318
+ return {
319
+ ...result,
320
+ kept: kept.sort((a, b) => a - b),
321
+ unread: unread.sort((a, b) => a - b),
322
+ }
323
+ }
@@ -111,9 +111,8 @@ export function isStackExcluded(stack: string): boolean {
111
111
  }
112
112
 
113
113
  /**
114
- * Lists installable stack names, filtering the `claude` stack the way
115
- * `is_tooling_stack_excluded` in `scripts/lib/tooling.sh` did. Claude is
116
- * managed by `canon claude`, not `canon tooling`.
114
+ * Lists installable stack names, filtering the stacks in `EXCLUDED_STACKS`.
115
+ * Claude is managed by `canon claude`, not `canon tooling`.
117
116
  */
118
117
  export function listStacks(root: string): string[] {
119
118
  const dir = toolingDir(root)
@@ -0,0 +1,37 @@
1
+ import { existsSync, readdirSync, statSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+
4
+ const TOOLING = 'tooling'
5
+ const SURFACE_ROOTS = ['.claude', 'canon']
6
+
7
+ /**
8
+ * Every `tooling/<stack>/seeds` holding a `.claude/` or a `canon/`, sorted and
9
+ * relative to the root.
10
+ *
11
+ * Both seed stages discover through this rather than naming a stack, so a stack
12
+ * seeding either root later reaches both and the two cannot disagree about which
13
+ * roots exist. A stack seeding only tracked surfaces carries `canon/` alone,
14
+ * which a `.claude/` test would drop from both stages. An absent `tooling/` is
15
+ * an empty answer, and each caller decides what an empty answer means.
16
+ */
17
+ export function seedRoots(root: string): string[] {
18
+ const tooling = join(root, TOOLING)
19
+ if (!existsSync(tooling)) return []
20
+
21
+ return readdirSync(tooling)
22
+ .filter((name) => !name.startsWith('.'))
23
+ .sort()
24
+ .map((name) => `${TOOLING}/${name}/seeds`)
25
+ .filter((seeds) => isDirectory(join(root, seeds)))
26
+ .filter((seeds) =>
27
+ SURFACE_ROOTS.some((surface) => isDirectory(join(root, seeds, surface))),
28
+ )
29
+ }
30
+
31
+ function isDirectory(path: string): boolean {
32
+ try {
33
+ return statSync(path).isDirectory()
34
+ } catch {
35
+ return false
36
+ }
37
+ }
@@ -1,5 +1,5 @@
1
1
  # Consumed only by `canon claude init`. Claude is not a discoverable tooling stack
2
- # and is excluded from `canon tooling` discovery via scripts/lib/tooling.sh.
2
+ # and is excluded from `canon tooling` discovery via `EXCLUDED_STACKS` in src/tooling/manifest.ts.
3
3
 
4
4
  [stack]
5
5
  name = "claude"
@@ -8,8 +8,8 @@ runtime = ""
8
8
  scaffold = ""
9
9
 
10
10
  # Checked against this repository's own `.gitignore` by
11
- # scripts/core/check-ignore-parity.sh, which carries no sanctioned divergence
12
- # now that both sides ship the same two entries.
11
+ # the Ignore parity stage of `bun run check`, which carries no sanctioned
12
+ # divergence now that both sides ship the same two entries.
13
13
  #
14
14
  # One root entry rather than a folder apiece, so a target that gains a record
15
15
  # folder later receives no manifest edit and no sync. `.claude/worktrees/` is
@@ -1,134 +0,0 @@
1
- #!/usr/bin/env bash
2
- set -e
3
- set -o pipefail
4
- shopt -s nullglob
5
-
6
- SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
7
- PROJECT_ROOT="${PROJECT_ROOT:-$(cd "$SCRIPT_DIR/../.." && pwd)}"
8
-
9
- TOOLING_ROOT="$PROJECT_ROOT/tooling"
10
- MARKER="canon-no-seed:"
11
-
12
- # `find` writes to stderr and returns non-zero for a missing root, inside a
13
- # process substitution whose status nothing reads. Without this the walk covers
14
- # nothing and the check reports every capability seeded having never read one.
15
- if [ ! -d "$TOOLING_ROOT" ]; then
16
- echo "No tooling root at ${TOOLING_ROOT#"$PROJECT_ROOT/"}, capability seeding unverifiable." >&2
17
- exit 1
18
- fi
19
-
20
- failures=""
21
-
22
- # A comment naming the marker anywhere in the source file is the recorded
23
- # reason a capability withholds itself from every destination it was compared
24
- # against. The reason's own prose is free-form, so only the marker is tested.
25
- has_reason() {
26
- grep -q "$MARKER" "$1"
27
- }
28
-
29
- # Compares one source directory's files against the basenames already
30
- # expanded from a set of destination paths. Presence on either side clears a
31
- # name; a source file reaching neither the destination nor a marked reason is
32
- # a capability answered on one side of the seed or config boundary and not
33
- # the other.
34
- check_capability() {
35
- local label="$1" src_dir="$2"
36
- shift 2
37
-
38
- [ -d "$src_dir" ] || return 0
39
-
40
- local dest_names=" "
41
- local dest
42
- for dest in "$@"; do
43
- [ -f "$dest" ] || continue
44
- dest_names="$dest_names$(basename "$dest") "
45
- done
46
-
47
- local src_file name
48
- for src_file in "$src_dir"/*; do
49
- [ -f "$src_file" ] || continue
50
- name=$(basename "$src_file")
51
- case "$dest_names" in
52
- *" $name "*) continue ;;
53
- esac
54
- has_reason "$src_file" && continue
55
- failures="$failures $label: ${src_file#"$PROJECT_ROOT/"} reaches no seed or config and carries no $MARKER reason"$'\n'
56
- done
57
- }
58
-
59
- check_capability "Hooks" "$PROJECT_ROOT/.claude/hooks" \
60
- "$PROJECT_ROOT"/tooling/claude/seeds/.claude/hooks/*
61
-
62
- check_capability "Workflows" "$PROJECT_ROOT/.github/workflows" \
63
- "$PROJECT_ROOT"/tooling/*/configs/.github/workflows/* \
64
- "$PROJECT_ROOT"/tooling/*/seeds/.github/workflows/*
65
-
66
- check_capability "Husky" "$PROJECT_ROOT/.husky" \
67
- "$PROJECT_ROOT"/tooling/base/configs/.husky/*
68
-
69
- # The reverse direction: a destination file whose source here is gone reaches
70
- # neither check_capability above nor the wiring pass below, both of which read
71
- # forward from the source, and it ships a target a hook, workflow, or husky
72
- # script this repository has already deleted. A destination carrying the same
73
- # marker clears just as a source does, which is the escape hatch a target-only
74
- # capability needs, such as a stack-specific workflow with no root counterpart
75
- # by design.
76
- check_orphans() {
77
- local label="$1" src_dir="$2" dest_dir="$3"
78
-
79
- [ -d "$dest_dir" ] || return 0
80
-
81
- local dest_file name
82
- for dest_file in "$dest_dir"/*; do
83
- [ -f "$dest_file" ] || continue
84
- name=$(basename "$dest_file")
85
- [ -f "$src_dir/$name" ] && continue
86
- has_reason "$dest_file" && continue
87
- failures="$failures $label: ${dest_file#"$PROJECT_ROOT/"} is seeded or configured with no source at ${src_dir#"$PROJECT_ROOT/"}/$name"$'\n'
88
- done
89
- }
90
-
91
- check_orphans "Hooks" "$PROJECT_ROOT/.claude/hooks" \
92
- "$PROJECT_ROOT/tooling/claude/seeds/.claude/hooks"
93
-
94
- for dir in "$PROJECT_ROOT"/tooling/*/configs/.github/workflows \
95
- "$PROJECT_ROOT"/tooling/*/seeds/.github/workflows; do
96
- check_orphans "Workflows" "$PROJECT_ROOT/.github/workflows" "$dir"
97
- done
98
-
99
- check_orphans "Husky" "$PROJECT_ROOT/.husky" \
100
- "$PROJECT_ROOT/tooling/base/configs/.husky"
101
-
102
- # A hook that reached the seed tree with no wiring in the seeded settings.json
103
- # is installed dead, which fails the same way an unseeded hook does.
104
- SEED_HOOKS_DIR="$PROJECT_ROOT/tooling/claude/seeds/.claude/hooks"
105
- SEED_SETTINGS="$PROJECT_ROOT/tooling/claude/seeds/.claude/settings.json"
106
-
107
- if [ -d "$SEED_HOOKS_DIR" ]; then
108
- if [ ! -f "$SEED_SETTINGS" ]; then
109
- failures="$failures Seed settings: no settings.json at ${SEED_SETTINGS#"$PROJECT_ROOT/"} to confirm a seeded hook is wired"$'\n'
110
- elif ! command -v jq >/dev/null 2>&1; then
111
- failures="$failures Seed settings: jq is not installed, seeded-hook wiring unverifiable"$'\n'
112
- else
113
- # Every wired command as its own path segment set, rather than a substring
114
- # search over the raw file, so a hook name that is a substring of another
115
- # hook's filename (`log.sh` inside `pr-create-log.sh`) cannot pass on a
116
- # neighbor's wiring.
117
- wired=" $(jq -r '.. | .command? // empty' "$SEED_SETTINGS" | tr '/\n' ' ') "
118
- for hook_file in "$SEED_HOOKS_DIR"/*.sh; do
119
- [ -f "$hook_file" ] || continue
120
- name=$(basename "$hook_file")
121
- case "$wired" in
122
- *" $name "*) continue ;;
123
- esac
124
- failures="$failures Seed settings: $name is seeded and wired into no command in ${SEED_SETTINGS#"$PROJECT_ROOT/"}"$'\n'
125
- done
126
- fi
127
- fi
128
-
129
- if [ -n "$failures" ]; then
130
- echo "A capability reaches one side of the seed or config boundary and not the other:" >&2
131
- printf '%s' "$failures" >&2
132
- echo "Seed or configure the capability, or mark the source line with # $MARKER <reason>." >&2
133
- exit 1
134
- fi
@@ -1,141 +0,0 @@
1
- #!/usr/bin/env bash
2
- set -e
3
- set -o pipefail
4
-
5
- SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
6
- PROJECT_ROOT="${PROJECT_ROOT:-$(cd "$SCRIPT_DIR/../.." && pwd)}"
7
-
8
- GITIGNORE="$PROJECT_ROOT/.gitignore"
9
- MANIFEST="$PROJECT_ROOT/tooling/claude/manifest.toml"
10
- SECTION="# Claude"
11
-
12
- # Parity is exact, with no exception list behind it, and the move to `.canon/`
13
- # is what removed the one there was. Its two members each named a path inside
14
- # `.claude/` that this repository ignored and the manifest withheld, so a target
15
- # kept tracking it. A single root entry can withhold nothing inside itself: git
16
- # does not descend into an excluded directory, so a re-inclusion like
17
- # `!.canon/diagrams/` matches nothing. Retiring both is what buys the one line.
18
- #
19
- # The diagrams divergence is the one that cost something. A target used to track
20
- # its diagrams and keep the context audit's default coverage of them, and it now
21
- # ignores them with the rest of its records, so that coverage is gone unless the
22
- # project names the folder itself. `canon/context/tooling/manifests.md` carries the
23
- # narrative.
24
- #
25
- # An exception mechanism is not kept against a future divergence, because an
26
- # empty one cannot be exercised and a check nobody can test is a check nobody
27
- # should trust. It comes back with its first real member or not at all.
28
-
29
- if [ ! -f "$GITIGNORE" ]; then
30
- echo "No .gitignore at ${GITIGNORE#"$PROJECT_ROOT/"}, ignore parity unverifiable." >&2
31
- exit 1
32
- fi
33
-
34
- if [ ! -f "$MANIFEST" ]; then
35
- echo "No claude manifest at ${MANIFEST#"$PROJECT_ROOT/"}, ignore parity unverifiable." >&2
36
- exit 1
37
- fi
38
-
39
- # A gitignore pattern and a manifest entry describe the same folder whether or
40
- # not either spells the trailing slash, so presence is compared with it dropped.
41
- # No live pair differs that way now that both lists carry the same two entries,
42
- # and the normalization stays because either side may be written without one.
43
- normalize() {
44
- printf '%s\n' "${1%/}"
45
- }
46
-
47
- # Every pattern the file carries, comments and blank lines dropped. The whole
48
- # file rather than one header, since a claude-scoped entry filed under a header
49
- # of its own would otherwise read as missing from a list that carries it.
50
- gitignore_patterns() {
51
- awk '{ sub(/[[:space:]]+$/, "") } $0 ~ /^[[:space:]]*#/ || $0 == "" { next } { print }' "$GITIGNORE"
52
- }
53
-
54
- # Every entry of the named array in the manifest's `[gitignore]` table. The
55
- # array is accumulated to its closing bracket before the quoted strings are read
56
- # out, so a formatter wrapping it across lines does not silently empty this.
57
- manifest_entries() {
58
- awk -v key="\"$SECTION\"" '
59
- /^\[/ { table = $0 }
60
- table != "[gitignore]" { next }
61
- index($0, key) == 1 { collecting = 1; buf = "" }
62
- collecting { buf = buf $0 }
63
- collecting && index($0, "]") > 0 {
64
- collecting = 0
65
- buf = substr(buf, index(buf, "["))
66
- count = split(buf, parts, "\"")
67
- for (i = 2; i <= count; i += 2) print parts[i]
68
- }
69
- ' "$MANIFEST"
70
- }
71
-
72
- contains() {
73
- local needle=$1
74
- shift
75
- local candidate
76
- for candidate in "$@"; do
77
- [ "$candidate" = "$needle" ] || continue
78
- return 0
79
- done
80
- return 1
81
- }
82
-
83
- ignored=()
84
- while IFS= read -r pattern; do
85
- [ -n "$pattern" ] || continue
86
- ignored+=("$(normalize "$pattern")")
87
- done < <(gitignore_patterns)
88
-
89
- shipped=()
90
- while IFS= read -r entry; do
91
- [ -n "$entry" ] || continue
92
- shipped+=("$(normalize "$entry")")
93
- done < <(manifest_entries)
94
-
95
- # An empty read on either side is a parse that failed rather than a repository
96
- # ignoring nothing, and reporting parity off it would say the two lists agree
97
- # having compared none of their entries.
98
- if [ ${#ignored[@]} -eq 0 ]; then
99
- echo "No patterns read from .gitignore, ignore parity unverifiable." >&2
100
- exit 1
101
- fi
102
-
103
- if [ ${#shipped[@]} -eq 0 ]; then
104
- echo "No entries read from the \"$SECTION\" array in ${MANIFEST#"$PROJECT_ROOT/"}, ignore parity unverifiable." >&2
105
- exit 1
106
- fi
107
-
108
- failures=""
109
-
110
- # Entries the manifest ships that this repository does not ignore. A target is
111
- # told to ignore a folder the toolkit itself tracks, which no decision sanctions,
112
- # so this direction carries no exception list.
113
- for entry in "${shipped[@]}"; do
114
- contains "$entry" "${ignored[@]}" && continue
115
- failures="$failures $entry is shipped by the manifest and absent from .gitignore"$'\n'
116
- done
117
-
118
- # Claude-scoped patterns this repository ignores that the manifest does not
119
- # ship. Scoped to the two record roots because the manifest is the claude stack
120
- # and says nothing about `node_modules/` or `.env`.
121
- #
122
- # `.canon` is read as a bare root as well as a prefix, since it is one line
123
- # covering a whole tree where `.claude/` names a folder inside a root that also
124
- # holds tracked content. Leaving it out is what would let a new entry sit outside
125
- # the only stage comparing the two lists, which is the direction that goes
126
- # silently blind.
127
- for pattern in "${ignored[@]}"; do
128
- case "$pattern" in
129
- .claude/* | .canon | .canon/*) ;;
130
- *) continue ;;
131
- esac
132
- contains "$pattern" "${shipped[@]}" && continue
133
- failures="$failures $pattern is ignored here and absent from the manifest"$'\n'
134
- done
135
-
136
- if [ -n "$failures" ]; then
137
- echo "The ignore set a target receives disagrees with this repository's own:" >&2
138
- printf '%s' "$failures" >&2
139
- echo "Add the entry to the \"$SECTION\" array in ${MANIFEST#"$PROJECT_ROOT/"} and to .gitignore. The two lists are compared exactly, and nothing here records an exception." >&2
140
- exit 1
141
- fi