@erclx/canon 4.4.0 → 4.6.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/commands.md +3 -0
- package/docs/agents/context-audit.md +2 -2
- package/docs/agents/driver.md +103 -0
- package/docs/agents/index.md +2 -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/browser/engine.ts +50 -7
- package/src/cli.ts +4 -0
- package/src/commands/claude.ts +7 -1
- package/src/commands/context.ts +3 -3
- package/src/commands/design.ts +6 -1
- package/src/commands/driver.ts +266 -0
- 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/driver/drive.ts +184 -0
- package/src/driver/probes/details.ts +116 -0
- package/src/driver/probes/diagram.ts +260 -0
- package/src/driver/probes/focus.ts +121 -0
- package/src/driver/probes/viewport.ts +81 -0
- package/src/driver/steps.ts +266 -0
- package/src/gate/measures.ts +1 -1
- package/src/intake/folder.ts +2 -1
- package/src/inventory/walk.ts +6 -18
- 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
package/src/records/validate.ts
CHANGED
|
@@ -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
|
|
32
|
+
* The folders standards reads, in precedence order.
|
|
29
33
|
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
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
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
-
|
|
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
|
|
115
|
-
*
|
|
116
|
-
*
|
|
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
|
-
|
|
120
|
-
|
|
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 {
|
package/src/tasks/answers.ts
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
import { existsSync } from 'node:fs'
|
|
2
2
|
import { readFile } from 'node:fs/promises'
|
|
3
|
-
import { isAbsolute,
|
|
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
|
|
8
|
-
const
|
|
9
|
-
const
|
|
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 [
|
|
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 [
|
|
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
|
-
|
|
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.`,
|
package/src/tasks/archive.ts
CHANGED
|
@@ -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
|
|
8
|
-
const
|
|
9
|
-
const
|
|
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
|
|
80
|
+
return recordDir(root, TASKS)
|
|
81
81
|
}
|
|
82
82
|
|
|
83
83
|
export function archiveDir(root: string): string {
|
|
84
|
-
return
|
|
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
|
-
|
|
205
|
-
|
|
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 (
|
|
210
|
-
|
|
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(
|
|
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-
|
|
328
|
-
*
|
|
329
|
-
*
|
|
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
|
-
|
|
345
|
+
dirs: readonly string[],
|
|
336
346
|
): boolean {
|
|
337
|
-
const
|
|
347
|
+
const fromBoard = resolve(dir, target)
|
|
348
|
+
const fromRoot = resolve(root, target)
|
|
338
349
|
|
|
339
|
-
return (
|
|
340
|
-
isUnder(
|
|
341
|
-
isUnder(resolve(root, target), resolved)
|
|
350
|
+
return dirs.some(
|
|
351
|
+
(resolved) => isUnder(fromBoard, resolved) || isUnder(fromRoot, resolved),
|
|
342
352
|
)
|
|
343
353
|
}
|
|
344
354
|
|
package/src/teach/workspace.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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 .
|
|
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
|
-
|
|
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
|
|
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
|