@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.
@@ -1,16 +1,18 @@
1
1
  import { existsSync } from 'node:fs'
2
- import { join, resolve } from 'node:path'
2
+ import { join, relative, resolve } from 'node:path'
3
3
  import { $ } from 'bun'
4
4
  import { gitEnv } from '@/git-env'
5
+ import { recordRoot } from '@/record-root'
5
6
 
6
7
  /**
7
- * The folders a backup carries, relative to `.claude/`. Most of them are the
8
- * `# Claude` group the claude manifest ships, minus three: `.claude/.tmp`,
9
- * which is defined as deletable without loss, `.claude/worktrees/`, whose
10
- * contents belong to the enclosing repository already, and
11
- * `.claude/.records.git/`, which is the history the rest are pushed into. The
12
- * list is spelled out rather than read off that group so adding an ignore entry
13
- * cannot silently enlarge the payload.
8
+ * The folders a backup carries, relative to the record root `workTree` resolves
9
+ * rather than to `.claude/` specifically, since the same nine names sit under
10
+ * whichever root a tree holds. Most of them are the `# Claude` group the claude
11
+ * manifest ships, minus three: the scratch folder, which is defined as deletable
12
+ * without loss, `worktrees/`, whose contents belong to the enclosing repository
13
+ * already, and `.records.git/`, which is the history the rest are pushed into.
14
+ * The list is spelled out rather than read off that group so adding an ignore
15
+ * entry cannot silently enlarge the payload.
14
16
  *
15
17
  * `diagrams` is the one name the manifest group does not carry, so a target
16
18
  * tracks it where this repository ignores it. That is the second reason to
@@ -65,10 +67,25 @@ const RETIRED_FOLDERS = [
65
67
  'task-archive',
66
68
  ] as const
67
69
 
68
- /** Holds the records history beside the folders it tracks, ignored by the enclosing repository. */
69
- const RECORDS_GIT_DIR = join('.claude', '.records.git')
70
+ /** The history directory's own name, which keeps its dot at either record root. */
71
+ const RECORDS_GIT_NAME = '.records.git'
70
72
 
71
- const WORK_TREE = '.claude'
73
+ /**
74
+ * The tree a backup stages, which is the record root itself.
75
+ *
76
+ * It resolves the root rather than each folder under it, so the history and the
77
+ * work tree are one answer. Resolving them apart would let a half-migrated tree
78
+ * open a history at one root and stage a work tree at the other, which stages
79
+ * the deletion of every folder the move relocated and pushes it.
80
+ */
81
+ function workTree(root: string): string {
82
+ return recordRoot(root)
83
+ }
84
+
85
+ /** Holds the records history beside the folders it tracks, ignored by the enclosing repository. */
86
+ function recordsGitDir(root: string): string {
87
+ return join(workTree(root), RECORDS_GIT_NAME)
88
+ }
72
89
 
73
90
  /** Both directions name the branch, so a machine whose `init.defaultBranch` differs still lands on it. */
74
91
  const RECORDS_BRANCH = 'main'
@@ -150,11 +167,11 @@ interface GitResult {
150
167
  * work tree.
151
168
  */
152
169
  async function records(root: string, args: string[]): Promise<GitResult> {
153
- const gitDir = resolve(root, RECORDS_GIT_DIR)
154
- const workTree = resolve(root, WORK_TREE)
170
+ const gitDir = resolve(recordsGitDir(root))
171
+ const tree = resolve(workTree(root))
155
172
 
156
173
  const result =
157
- await $`git -C ${workTree} --git-dir=${gitDir} --work-tree=${workTree} ${args}`
174
+ await $`git -C ${tree} --git-dir=${gitDir} --work-tree=${tree} ${args}`
158
175
  .env(gitEnv())
159
176
  .quiet()
160
177
  .nothrow()
@@ -237,13 +254,15 @@ async function enclosingRemoteUrls(
237
254
  * cannot be read is what keeps a failed comparison from reading as a pass.
238
255
  */
239
256
  async function resolveRemote(root: string): Promise<string | BackupRefused> {
240
- if (!existsSync(join(root, RECORDS_GIT_DIR))) {
257
+ const gitDir = recordsGitDir(root)
258
+
259
+ if (!existsSync(gitDir)) {
241
260
  return refuse(
242
261
  'no-repository',
243
262
  [
244
- `No records history at ${RECORDS_GIT_DIR}. Create it once, against a private repository:`,
245
- ` git --git-dir=${join(root, RECORDS_GIT_DIR)} init`,
246
- ` git --git-dir=${join(root, RECORDS_GIT_DIR)} remote add origin <private-repo-url>`,
263
+ `No records history at ${relative(root, gitDir)}. Create it once, against a private repository:`,
264
+ ` git --git-dir=${gitDir} init`,
265
+ ` git --git-dir=${gitDir} remote add origin <private-repo-url>`,
247
266
  ].join('\n'),
248
267
  )
249
268
  }
@@ -254,7 +273,7 @@ async function resolveRemote(root: string): Promise<string | BackupRefused> {
254
273
  'no-remote',
255
274
  [
256
275
  'The records history has no origin. Point it at a private repository:',
257
- ` git --git-dir=${join(root, RECORDS_GIT_DIR)} remote add origin <private-repo-url>`,
276
+ ` git --git-dir=${gitDir} remote add origin <private-repo-url>`,
258
277
  ].join('\n'),
259
278
  )
260
279
  }
@@ -299,8 +318,7 @@ async function scopedFolders(root: string): Promise<string[]> {
299
318
  )
300
319
 
301
320
  return [...BACKED_FOLDERS, ...RETIRED_FOLDERS].filter(
302
- (folder) =>
303
- existsSync(join(root, WORK_TREE, folder)) || indexed.has(folder),
321
+ (folder) => existsSync(join(workTree(root), folder)) || indexed.has(folder),
304
322
  )
305
323
  }
306
324
 
@@ -311,7 +329,7 @@ function topSegment(path: string): string {
311
329
  /** What a report names, which is the folders a reader can go and open. */
312
330
  function presentFolders(root: string): string[] {
313
331
  return BACKED_FOLDERS.filter((folder) =>
314
- existsSync(join(root, WORK_TREE, folder)),
332
+ existsSync(join(workTree(root), folder)),
315
333
  )
316
334
  }
317
335
 
@@ -1,12 +1,13 @@
1
1
  import { existsSync, type Stats } from 'node:fs'
2
2
  import { readdir, stat } from 'node:fs/promises'
3
3
  import { join } from 'node:path'
4
+ import { RECORD_ROOTS, recordDir, SCRATCH } from '@/record-root'
4
5
  import { BACKED_FOLDERS } from '@/records/backup'
5
6
 
6
7
  /**
7
- * The folders a size reading covers, relative to `.claude/`.
8
+ * The folders a size reading covers, named at the record root they sit under.
8
9
  *
9
- * It is the backed set plus `.tmp`, which a backup skips because it is
10
+ * It is the backed set plus the scratch folder, which a backup skips because it is
10
11
  * deletable without loss and a reading covers because deletable is not the same
11
12
  * as empty: the routing handoffs and the memory archive both sit there and both
12
13
  * accumulate. `.records.git` stays out because it is the backup history rather
@@ -14,7 +15,7 @@ import { BACKED_FOLDERS } from '@/records/backup'
14
15
  * checkout of the enclosing repository with its own removal verb, and one of
15
16
  * them outweighs every record folder combined.
16
17
  */
17
- export const SIZED_FOLDERS = [...BACKED_FOLDERS, '.tmp'] as const
18
+ export const SIZED_FOLDERS = [...BACKED_FOLDERS, SCRATCH] as const
18
19
 
19
20
  /**
20
21
  * The windows a reading reports, in days.
@@ -34,7 +35,7 @@ export interface WindowCount {
34
35
  }
35
36
 
36
37
  export interface FolderSize {
37
- /** Relative to `.claude/`, which is the name a reader opens. */
38
+ /** Relative to the record root, which is the name a reader opens. */
38
39
  readonly folder: string
39
40
  readonly present: boolean
40
41
  readonly files: number
@@ -170,7 +171,7 @@ async function measure(
170
171
  folder: string,
171
172
  now: number,
172
173
  ): Promise<FolderSize> {
173
- const path = join(root, '.claude', folder)
174
+ const path = recordDir(root, folder)
174
175
  const empty = GROWTH_WINDOWS.map((days) => ({ days, files: 0 }))
175
176
 
176
177
  if (!existsSync(path)) {
@@ -212,11 +213,15 @@ export async function sizeRecords(
212
213
  root: string,
213
214
  now: number = Date.now(),
214
215
  ): Promise<SizeOutcome> {
215
- if (!existsSync(join(root, '.claude'))) {
216
+ // Either root answers, so a migrated tree is read rather than refused. The
217
+ // roots are tested rather than the folders under them, since a project that
218
+ // holds the root and no records yet is empty rather than absent and the
219
+ // per-folder `present` flags already say which of the ten it carries.
220
+ if (!RECORD_ROOTS.some((name) => existsSync(join(root, name)))) {
216
221
  return {
217
222
  ok: false,
218
223
  reason: 'no-folder',
219
- message: `No .claude directory at ${root}, so there are no record folders to read.`,
224
+ message: `No ${RECORD_ROOTS.join(' or ')} directory at ${root}, so there are no record folders to read.`,
220
225
  }
221
226
  }
222
227
 
@@ -3,6 +3,10 @@ import { readdir, readFile } from 'node:fs/promises'
3
3
  import { join } from 'node:path'
4
4
  import { parseFrontmatter, readField } from '@/indexes/frontmatter'
5
5
  import { linesOutsideFences } from '@/markdown/scan'
6
+ import {
7
+ recordDir as resolveRecordDir,
8
+ recordDirs as resolveRecordDirs,
9
+ } from '@/record-root'
6
10
  import {
7
11
  TEACH_GLOSSARY,
8
12
  TEACH_MISSION,
@@ -25,22 +29,22 @@ export const RECORD_KINDS = [
25
29
  export type RecordKind = (typeof RECORD_KINDS)[number]
26
30
 
27
31
  /**
28
- * The folders each kind reads, in precedence order.
32
+ * The folders standards reads, in precedence order.
29
33
  *
30
- * Standards carry two because the corpus authors at the project root and
31
- * installs under `.claude/`. The authoring root wins where both exist, since the
34
+ * It carries two because the corpus authors at the project root and installs
35
+ * under `.claude/`. The authoring root wins where both exist, since the
32
36
  * installed tree is a generated copy here and a finding fixed there is
33
37
  * overwritten by the next regen. A project that consumed the corpus holds only
34
38
  * the second, so one order serves both.
39
+ *
40
+ * Every other kind is a session record and takes the record roots instead,
41
+ * resolved by `@/record-root`. Standards is the one kind that is tracked, so it
42
+ * does not move and spells its own two candidates here.
35
43
  */
36
- const FOLDERS_BY_KIND: Readonly<Record<RecordKind, readonly string[]>> = {
37
- plans: [join('.claude', 'plans')],
38
- groundwork: [join('.claude', 'groundwork')],
39
- intake: [join('.claude', 'intake')],
40
- memory: [join('.claude', 'memory')],
41
- standards: ['standards', join('.claude', 'standards')],
42
- teach: [join('.claude', 'teach')],
43
- }
44
+ const STANDARDS_FOLDERS: readonly string[] = [
45
+ 'standards',
46
+ join('.claude', 'standards'),
47
+ ]
44
48
 
45
49
  /**
46
50
  * `unknown-kind` is raised at the argument boundary rather than by the walk, and
@@ -107,17 +111,31 @@ export type ValidateOutcome = ValidateReport | ValidateRefused
107
111
 
108
112
  /** Every folder a kind would accept, whether or not it is on disk. */
109
113
  export function recordDirs(root: string, kind: RecordKind): string[] {
110
- return FOLDERS_BY_KIND[kind].map((folder) => join(root, folder))
114
+ if (kind === 'standards') {
115
+ return STANDARDS_FOLDERS.map((folder) => join(root, folder))
116
+ }
117
+
118
+ return resolveRecordDirs(root, kind)
111
119
  }
112
120
 
113
121
  /**
114
- * The folder a kind reads. The first candidate on disk wins, and the first
115
- * candidate stands in when none exists, so a refusal and a test fixture both
116
- * name the location the kind prefers.
122
+ * The folder a kind reads. The first candidate on disk wins, and the creation
123
+ * default stands in when none exists, so a refusal and a test fixture both name
124
+ * the location the kind would be written to.
125
+ *
126
+ * The default is where this parts company with `dirs[0]`, which the record
127
+ * kinds can no longer take: their first candidate is the root the move lands at
128
+ * and nothing creates there yet, so a fallback reading it would name a folder no
129
+ * verb would ever write. Standards has no such split, since it is tracked and
130
+ * its first candidate is where it is authored.
117
131
  */
118
132
  export function recordsDir(root: string, kind: RecordKind): string {
119
- const dirs = recordDirs(root, kind)
120
- return dirs.find((dir) => existsSync(dir)) ?? dirs[0]
133
+ if (kind === 'standards') {
134
+ const dirs = recordDirs(root, kind)
135
+ return dirs.find((dir) => existsSync(dir)) ?? dirs[0]
136
+ }
137
+
138
+ return resolveRecordDir(root, kind)
121
139
  }
122
140
 
123
141
  export function isRecordKind(value: string): value is RecordKind {
@@ -1,12 +1,13 @@
1
1
  import { existsSync } from 'node:fs'
2
2
  import { readFile } from 'node:fs/promises'
3
- import { isAbsolute, join, relative, resolve } from 'node:path'
3
+ import { isAbsolute, relative, resolve } from 'node:path'
4
4
  import { isUnder } from '@/paths'
5
+ import { recordDir, recordDirs } from '@/record-root'
5
6
  import { readQuestions, splitPlanSections } from '@/records/validate'
6
7
 
7
- const PLANS_DIR = join('.claude', 'plans')
8
- const PLANS_ARCHIVE_DIR = join(PLANS_DIR, 'archive')
9
- const TASKS_DIR = join('.claude', 'tasks')
8
+ const PLANS = 'plans'
9
+ const TASKS = 'tasks'
10
+ const ARCHIVE = 'archive'
10
11
 
11
12
  /**
12
13
  * The suggestion the plan standard fixes for a question that turns on the
@@ -74,14 +75,17 @@ export function planCandidates(root: string, reference: string): string[] {
74
75
  if (reference.includes('/') || reference.endsWith('.md')) {
75
76
  if (isAbsolute(reference)) return [reference]
76
77
 
77
- return [resolve(root, reference), resolve(join(root, TASKS_DIR), reference)]
78
+ return [
79
+ resolve(root, reference),
80
+ resolve(recordDir(root, TASKS), reference),
81
+ ]
78
82
  }
79
83
 
80
84
  const slug = reference.startsWith('feature-')
81
85
  ? reference.slice('feature-'.length)
82
86
  : reference
83
87
 
84
- return [join(root, PLANS_DIR, `feature-${slug}.md`)]
88
+ return [recordDir(root, PLANS, `feature-${slug}.md`)]
85
89
  }
86
90
 
87
91
  function suggestionOf(body: readonly string[]): string | undefined {
@@ -167,7 +171,10 @@ export async function planAnswers(
167
171
  // An archived plan answers every question and would report as launchable, so
168
172
  // the name would clear a dispatch that `claude-autoship` Step 1 then refuses
169
173
  // as already-shipped work. Catching it here is a step earlier than the worker.
170
- if (isUnder(path, join(root, PLANS_ARCHIVE_DIR))) {
174
+ // Both roots, for the reason `resolveLivePlan` carries: the reference is a
175
+ // string a caller wrote, and one spelling the root this tree has since left is
176
+ // still a path into the archive.
177
+ if (recordDirs(root, PLANS, ARCHIVE).some((dir) => isUnder(path, dir))) {
171
178
  return refuse(
172
179
  'archived',
173
180
  `${relative(root, path)} sits in the plans archive, so it describes work that already shipped.`,
@@ -3,11 +3,11 @@ import { mkdir, readdir, readFile, rename, writeFile } from 'node:fs/promises'
3
3
  import { join, relative, resolve } from 'node:path'
4
4
  import { regenOne } from '@/indexes/regen'
5
5
  import { isUnder } from '@/paths'
6
+ import { recordDir, recordDirs } from '@/record-root'
6
7
 
7
- const TASKS_DIR = join('.claude', 'tasks')
8
- const ARCHIVE_DIR = join(TASKS_DIR, 'archive')
9
- const PLANS_DIR = join('.claude', 'plans')
10
- const PLANS_ARCHIVE_DIR = join(PLANS_DIR, 'archive')
8
+ const TASKS = 'tasks'
9
+ const PLANS = 'plans'
10
+ const ARCHIVE = 'archive'
11
11
 
12
12
  /**
13
13
  * Siblings that sit on the board without being tasks: the generated index, the
@@ -77,11 +77,11 @@ export interface TaskOutcomes {
77
77
  }
78
78
 
79
79
  export function tasksDir(root: string): string {
80
- return join(root, TASKS_DIR)
80
+ return recordDir(root, TASKS)
81
81
  }
82
82
 
83
83
  export function archiveDir(root: string): string {
84
- return join(root, ARCHIVE_DIR)
84
+ return recordDir(root, TASKS, ARCHIVE)
85
85
  }
86
86
 
87
87
  export const OUTCOME_PATTERN = /^- \[([ xX])\] ?(.*)$/
@@ -201,15 +201,20 @@ export function resolveLivePlan(
201
201
  dir: string,
202
202
  root: string,
203
203
  ): string | undefined {
204
- const plans = join(root, PLANS_DIR)
205
- const archive = join(root, PLANS_ARCHIVE_DIR)
204
+ // Both roots are tested rather than the one this tree resolves at, since a
205
+ // task's line is a string somebody wrote and a path spelling the root the tree
206
+ // has since left is still a path into the plans folder. Reading it as outside
207
+ // would report a shipped plan as still live.
208
+ const plans = recordDirs(root, PLANS)
209
+ const archives = recordDirs(root, PLANS, ARCHIVE)
210
+ const live = (path: string): boolean =>
211
+ plans.some((dir) => isUnder(path, dir)) &&
212
+ !archives.some((dir) => isUnder(path, dir))
206
213
  const fromBoard = resolve(dir, target)
207
214
  const fromRoot = resolve(root, target)
208
215
 
209
- if (isUnder(fromBoard, plans) && !isUnder(fromBoard, archive)) {
210
- return fromBoard
211
- }
212
- if (isUnder(fromRoot, plans) && !isUnder(fromRoot, archive)) return fromRoot
216
+ if (live(fromBoard)) return fromBoard
217
+ if (live(fromRoot)) return fromRoot
213
218
  return undefined
214
219
  }
215
220
 
@@ -308,7 +313,12 @@ export async function planCitations(
308
313
 
309
314
  const live = resolveLivePlan(target, dir, root)
310
315
  if (!live) {
311
- const location = resolvesUnder(target, dir, root, PLANS_ARCHIVE_DIR)
316
+ const location = resolvesUnder(
317
+ target,
318
+ dir,
319
+ root,
320
+ recordDirs(root, PLANS, ARCHIVE),
321
+ )
312
322
  ? 'archived'
313
323
  : 'outside'
314
324
  return { ok: true, stem, target, location, citedBy: [] }
@@ -324,21 +334,21 @@ export async function planCitations(
324
334
  }
325
335
 
326
336
  /**
327
- * Runs the two-spelling resolution `resolveLivePlan` applies against a folder
328
- * other than the live one, so an archived plan is read as archived whichever
329
- * root the task wrote its path against.
337
+ * Runs the two-base resolution `resolveLivePlan` applies against folders other
338
+ * than the live ones, so an archived plan is read as archived whichever base the
339
+ * task wrote its path against and whichever record root it spelled.
330
340
  */
331
341
  function resolvesUnder(
332
342
  target: string,
333
343
  dir: string,
334
344
  root: string,
335
- folder: string,
345
+ dirs: readonly string[],
336
346
  ): boolean {
337
- const resolved = join(root, folder)
347
+ const fromBoard = resolve(dir, target)
348
+ const fromRoot = resolve(root, target)
338
349
 
339
- return (
340
- isUnder(resolve(dir, target), resolved) ||
341
- isUnder(resolve(root, target), resolved)
350
+ return dirs.some(
351
+ (resolved) => isUnder(fromBoard, resolved) || isUnder(fromRoot, resolved),
342
352
  )
343
353
  }
344
354
 
@@ -3,6 +3,7 @@ import { mkdir, readdir, readFile, writeFile } from 'node:fs/promises'
3
3
  import { join, relative } from 'node:path'
4
4
  import { parseFrontmatter, readField } from '@/indexes/frontmatter'
5
5
  import { type BodyLine, bodyLines } from '@/markdown/scan'
6
+ import { recordDir } from '@/record-root'
6
7
 
7
8
  export const TEACH_REFUSALS = [
8
9
  'no-teach',
@@ -173,7 +174,7 @@ export function refuse(
173
174
  * this takes one and never reads the working directory.
174
175
  */
175
176
  export function teachDir(root: string): string {
176
- return join(root, '.claude', 'teach')
177
+ return recordDir(root, 'teach')
177
178
  }
178
179
 
179
180
  async function listSlugs(dir: string): Promise<string[]> {
@@ -11,4 +11,4 @@ scaffold = ""
11
11
  # scripts/core/check-ignore-parity.sh, which also holds the two `.claude/` paths
12
12
  # this array deliberately omits and the reason each stays out.
13
13
  [gitignore]
14
- "# Claude" = [".claude/.records.git/", ".claude/.tmp/", ".claude/groundwork/", ".claude/intake/", ".claude/memory/", ".claude/plans/", ".claude/proposals/", ".claude/review/", ".claude/worktrees/", ".claude/tasks/", ".claude/teach/"]
14
+ "# Claude" = [".canon/", ".claude/.records.git/", ".claude/.tmp/", ".claude/groundwork/", ".claude/intake/", ".claude/memory/", ".claude/plans/", ".claude/proposals/", ".claude/review/", ".claude/worktrees/", ".claude/tasks/", ".claude/teach/"]
@@ -40,7 +40,14 @@ done
40
40
 
41
41
  session=$(printf '%s' "$input" | jq -r '.session_id // "none"')
42
42
  key=$(printf '%s__%s' "$session" "$index" | tr -c 'A-Za-z0-9' '_')
43
- marker_dir="${CLAUDE_PROJECT_DIR:-.}/.claude/.tmp/index-reminder"
43
+ # The marker is scratch, so it follows the scratch folder to whichever record
44
+ # root the project carries rather than creating a second one beside it.
45
+ project="${CLAUDE_PROJECT_DIR:-.}"
46
+ if [ -d "$project/.canon" ]; then
47
+ marker_dir="$project/.canon/tmp/index-reminder"
48
+ else
49
+ marker_dir="$project/.claude/.tmp/index-reminder"
50
+ fi
44
51
  marker="$marker_dir/$key"
45
52
  [ -f "$marker" ] && exit 0
46
53
  mkdir -p "$marker_dir"
@@ -25,31 +25,48 @@ esac
25
25
  file_path=$(printf '%s' "$input" | jq -r '.tool_input.file_path // empty')
26
26
  [ -n "$file_path" ] || exit 0
27
27
 
28
+ # Both record roots, since a project the move has reached keeps its memory pen
29
+ # under `.canon/` and a guard fixed at the old spelling stops matching with
30
+ # nothing said. The index then goes stale while every save reports success.
28
31
  case "$file_path" in
29
- */.claude/memory/*.md) ;;
32
+ */.claude/memory/*.md | */.canon/memory/*.md) ;;
30
33
  *) exit 0 ;;
31
34
  esac
32
35
 
33
36
  case "$file_path" in
34
- */.claude/memory/index.md) exit 0 ;;
37
+ */.claude/memory/index.md | */.canon/memory/index.md) exit 0 ;;
35
38
  esac
36
39
 
40
+ # The walk-up boundary has to come from the path, not from the session. Shared
41
+ # scratch resolves at the main worktree root, so a session inside a linked
42
+ # worktree passes a path that sits outside its own project directory and the
43
+ # default boundary would reject it.
44
+ #
45
+ # The index this would have rebuilt is read out of the same branch, so both
46
+ # messages below name the file that actually went stale rather than one root's
47
+ # spelling of it.
48
+ case "$file_path" in
49
+ */.canon/memory/*)
50
+ root="${file_path%/.canon/memory/*}"
51
+ index=".canon/memory/index.md"
52
+ ;;
53
+ *)
54
+ root="${file_path%/.claude/memory/*}"
55
+ index=".claude/memory/index.md"
56
+ ;;
57
+ esac
58
+ [ -n "$root" ] || exit 0
59
+
37
60
  # Report a missing CLI rather than exiting quietly. The path guard above already
38
61
  # scopes this to a memory-file edit, so the message only fires where the stale
39
62
  # index it warns about is the actual outcome.
40
63
  if ! command -v canon >/dev/null 2>&1; then
41
- jq -nc --arg msg 'canon is not on PATH, so .claude/memory/index.md was not regenerated and is now stale. Install the toolkit CLI or run canon indexes regen by hand.' \
64
+ msg="canon is not on PATH, so $index was not regenerated and is now stale. Install the toolkit CLI or run canon indexes regen by hand."
65
+ jq -nc --arg msg "$msg" \
42
66
  '{hookSpecificOutput:{hookEventName:"PostToolUse",additionalContext:$msg}}'
43
67
  exit 0
44
68
  fi
45
69
 
46
- # The walk-up boundary has to come from the path, not from the session. Shared
47
- # scratch resolves at the main worktree root, so a session inside a linked
48
- # worktree passes a path that sits outside its own project directory and the
49
- # default boundary would reject it.
50
- root="${file_path%/.claude/memory/*}"
51
- [ -n "$root" ] || exit 0
52
-
53
70
  # `--no-stage` because a hook has no business touching the index. On a project
54
71
  # whose memory folder is not gitignored, the default auto-stage would silently
55
72
  # add memory files to whatever commit is being assembled.
@@ -62,7 +79,7 @@ output=$(canon indexes regen --no-stage --root "$root" "$file_path" 2>&1) && exi
62
79
  errors=$(printf '%s\n' "$output" | grep '^ERROR: ' | head -5)
63
80
  [ -n "$errors" ] || errors="$output"
64
81
 
65
- msg="Memory index regen failed, so .claude/memory/index.md is now stale. Fix the frontmatter and save again. $errors"
82
+ msg="Memory index regen failed, so $index is now stale. Fix the frontmatter and save again. $errors"
66
83
  jq -nc --arg msg "$msg" \
67
84
  '{hookSpecificOutput:{hookEventName:"PostToolUse",additionalContext:$msg}}'
68
85
  exit 0
@@ -18,8 +18,12 @@ esac
18
18
  file_path=$(printf '%s' "$input" | jq -r '.tool_input.file_path // empty')
19
19
  [ -n "$file_path" ] || exit 0
20
20
 
21
+ # Both record roots, and the scratch folder loses its leading dot under the new
22
+ # one, since inside a dotted root the dot hides nothing already hidden. A guard
23
+ # fixed at the old spelling warns on every correct scratch write in a project
24
+ # the move has reached.
21
25
  case "$file_path" in
22
- */.claude/.tmp/*) exit 0 ;;
26
+ */.claude/.tmp/* | */.canon/tmp/*) exit 0 ;;
23
27
  esac
24
28
 
25
29
  # A project whose own root sits under a path carrying a tmp segment is not
@@ -42,11 +46,16 @@ esac
42
46
 
43
47
  session=$(printf '%s' "$input" | jq -r '.session_id // "none"')
44
48
  key=$(printf '%s' "$session" | tr -c 'A-Za-z0-9' '_')
45
- marker_dir="${CLAUDE_PROJECT_DIR:-.}/.claude/.tmp/scratch-guard"
49
+ project="${CLAUDE_PROJECT_DIR:-.}"
50
+ if [ -d "$project/.canon" ]; then
51
+ marker_dir="$project/.canon/tmp/scratch-guard"
52
+ else
53
+ marker_dir="$project/.claude/.tmp/scratch-guard"
54
+ fi
46
55
  marker="$marker_dir/$key"
47
56
  [ -f "$marker" ] && exit 0
48
57
  mkdir -p "$marker_dir"
49
58
  : >"$marker"
50
59
 
51
- msg='Temporary file write outside .claude/.tmp/. Write temp files to .claude/.tmp/<slug>/ in the project root, not system temp. See the Scratch rule in CLAUDE.md.'
60
+ msg='Temporary file write outside the project scratch folder. Write temp files to .claude/.tmp/<slug>/ in the project root, or .canon/tmp/<slug>/ where the project carries that root, not system temp. See the Scratch rule in CLAUDE.md.'
52
61
  jq -nc --arg msg "$msg" '{hookSpecificOutput:{hookEventName:"PreToolUse",additionalContext:$msg}}'
@@ -18,8 +18,12 @@ case "$file" in
18
18
  *) exit 0 ;;
19
19
  esac
20
20
 
21
+ # Four record folders, at either root. A skip list fixed at the old spelling
22
+ # audits a project's own session records the moment the move reaches it, which
23
+ # reports findings against prose nobody publishes.
21
24
  case "$file" in
22
25
  *.claude/.tmp/* | *.claude/memory/* | *.claude/review/* | *.claude/plans/*) exit 0 ;;
26
+ *.canon/tmp/* | *.canon/memory/* | *.canon/review/* | *.canon/plans/*) exit 0 ;;
23
27
  esac
24
28
 
25
29
  [ -f "$file" ] || exit 0
@@ -25,8 +25,11 @@ esac
25
25
  file_path=$(printf '%s' "$input" | jq -r '.tool_input.file_path // empty')
26
26
  [ -n "$file_path" ] || exit 0
27
27
 
28
+ # Both record roots, since a project the move has reached keeps its board under
29
+ # `.canon/` and a guard fixed at the old spelling stops matching with nothing
30
+ # said. The index then goes stale while every save reports success.
28
31
  case "$file_path" in
29
- */.claude/tasks/*.md) ;;
32
+ */.claude/tasks/*.md | */.canon/tasks/*.md) ;;
30
33
  *) exit 0 ;;
31
34
  esac
32
35
 
@@ -35,24 +38,39 @@ esac
35
38
  # a regen fired on one would rebuild the index the archive was taken out of.
36
39
  case "$file_path" in
37
40
  */.claude/tasks/index.md | */.claude/tasks/archive/*) exit 0 ;;
41
+ */.canon/tasks/index.md | */.canon/tasks/archive/*) exit 0 ;;
38
42
  esac
39
43
 
44
+ # The walk-up boundary has to come from the path, not from the session. Shared
45
+ # scratch resolves at the main worktree root, so a session inside a linked
46
+ # worktree passes a path that sits outside its own project directory and the
47
+ # default boundary would reject it.
48
+ #
49
+ # The index this would have rebuilt is read out of the same branch, so both
50
+ # messages below name the file that actually went stale rather than one root's
51
+ # spelling of it.
52
+ case "$file_path" in
53
+ */.canon/tasks/*)
54
+ root="${file_path%/.canon/tasks/*}"
55
+ index=".canon/tasks/index.md"
56
+ ;;
57
+ *)
58
+ root="${file_path%/.claude/tasks/*}"
59
+ index=".claude/tasks/index.md"
60
+ ;;
61
+ esac
62
+ [ -n "$root" ] || exit 0
63
+
40
64
  # Report a missing CLI rather than exiting quietly. The path guard above already
41
65
  # scopes this to a task-file edit, so the message only fires where the stale
42
66
  # index it warns about is the actual outcome.
43
67
  if ! command -v canon >/dev/null 2>&1; then
44
- jq -nc --arg msg 'canon is not on PATH, so .claude/tasks/index.md was not regenerated and is now stale. Install the toolkit CLI or run canon indexes regen by hand.' \
68
+ msg="canon is not on PATH, so $index was not regenerated and is now stale. Install the toolkit CLI or run canon indexes regen by hand."
69
+ jq -nc --arg msg "$msg" \
45
70
  '{hookSpecificOutput:{hookEventName:"PostToolUse",additionalContext:$msg}}'
46
71
  exit 0
47
72
  fi
48
73
 
49
- # The walk-up boundary has to come from the path, not from the session. Shared
50
- # scratch resolves at the main worktree root, so a session inside a linked
51
- # worktree passes a path that sits outside its own project directory and the
52
- # default boundary would reject it.
53
- root="${file_path%/.claude/tasks/*}"
54
- [ -n "$root" ] || exit 0
55
-
56
74
  # `--no-stage` because a hook has no business touching the index. On a project
57
75
  # whose board is not gitignored, the default auto-stage would silently add task
58
76
  # files to whatever commit is being assembled.
@@ -65,7 +83,7 @@ output=$(canon indexes regen --no-stage --root "$root" "$file_path" 2>&1) && exi
65
83
  errors=$(printf '%s\n' "$output" | grep '^ERROR: ' | head -5)
66
84
  [ -n "$errors" ] || errors="$output"
67
85
 
68
- msg="Task index regen failed, so .claude/tasks/index.md is now stale. Fix the frontmatter and save again. $errors"
86
+ msg="Task index regen failed, so $index is now stale. Fix the frontmatter and save again. $errors"
69
87
  jq -nc --arg msg "$msg" \
70
88
  '{hookSpecificOutput:{hookEventName:"PostToolUse",additionalContext:$msg}}'
71
89
  exit 0