@erclx/canon 4.3.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/claude/skills/claude-orchestrate/references/orchestrator-dispatch.md +34 -9
- package/claude/skills/repo-metadata/REQUIREMENT.md +37 -0
- package/claude/skills/repo-metadata/SKILL.md +51 -0
- package/docs/agents/audits.md +6 -6
- package/docs/agents/commands.md +64 -62
- 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/sandbox.md +3 -1
- package/docs/agents/tasks.md +36 -1
- package/docs/operating-model.md +1 -1
- package/package.json +1 -1
- package/scripts/core/check-ignore-parity.sh +9 -3
- package/scripts/lib/sandbox-dispatch.sh +182 -0
- package/src/audits/baseline.ts +1 -1
- package/src/claude/cases/misc.ts +4 -0
- 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/feedback.ts +5 -1
- package/src/commands/gov.ts +2 -1
- package/src/commands/repo.ts +393 -0
- package/src/commands/slides.ts +6 -1
- package/src/commands/tasks.ts +100 -0
- 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/paths.ts +16 -0
- 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/repo/metadata.ts +206 -0
- package/src/tasks/answers.ts +202 -0
- package/src/tasks/archive.ts +33 -30
- 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
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs'
|
|
2
|
+
import { readFile } from 'node:fs/promises'
|
|
3
|
+
import { isAbsolute, relative, resolve } from 'node:path'
|
|
4
|
+
import { isUnder } from '@/paths'
|
|
5
|
+
import { recordDir, recordDirs } from '@/record-root'
|
|
6
|
+
import { readQuestions, splitPlanSections } from '@/records/validate'
|
|
7
|
+
|
|
8
|
+
const PLANS = 'plans'
|
|
9
|
+
const TASKS = 'tasks'
|
|
10
|
+
const ARCHIVE = 'archive'
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The suggestion the plan standard fixes for a question that turns on the
|
|
14
|
+
* operator's preference rather than on a technical default. Every other
|
|
15
|
+
* suggestion is accepted by a blank slot, so only this phrase over an empty
|
|
16
|
+
* `- Answer:` is a stop.
|
|
17
|
+
*/
|
|
18
|
+
const OPERATOR_CALL = 'needs your call'
|
|
19
|
+
|
|
20
|
+
const SUGGESTED_PREFIX = '- Suggested:'
|
|
21
|
+
const ANSWER_PREFIX = '- Answer:'
|
|
22
|
+
|
|
23
|
+
export const ANSWER_REFUSALS = ['no-plan', 'archived', 'bad-input'] as const
|
|
24
|
+
|
|
25
|
+
export type AnswerRefusal = (typeof ANSWER_REFUSALS)[number]
|
|
26
|
+
|
|
27
|
+
export interface AnswersRefused {
|
|
28
|
+
readonly ok: false
|
|
29
|
+
readonly reason: AnswerRefusal
|
|
30
|
+
readonly message: string
|
|
31
|
+
readonly detail: readonly string[]
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* One question the dispatch has to hand back. It carries the label rather than
|
|
36
|
+
* contributing to a count, since a dispatcher that refuses a row without naming
|
|
37
|
+
* the slot has nothing to give the operator.
|
|
38
|
+
*/
|
|
39
|
+
export interface OpenQuestion {
|
|
40
|
+
readonly label: string
|
|
41
|
+
readonly why: string
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface PlanAnswers {
|
|
45
|
+
readonly ok: true
|
|
46
|
+
readonly plan: string
|
|
47
|
+
readonly launchable: boolean
|
|
48
|
+
readonly open: readonly OpenQuestion[]
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export type AnswersOutcome = PlanAnswers | AnswersRefused
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The spellings a caller reaches a plan by, in the order they are tried. A bare
|
|
55
|
+
* slug names the live folder outright, and a path is resolved against the
|
|
56
|
+
* project root and against the board directory both.
|
|
57
|
+
*
|
|
58
|
+
* The second base is the one a dispatcher actually has to hand. A board row
|
|
59
|
+
* writes its `Plan:` link relative to `.claude/tasks/`, so the href reads
|
|
60
|
+
* `../plans/feature-<slug>.md`, and resolving that against the root alone lands
|
|
61
|
+
* a directory above the repository and refuses a plan that exists.
|
|
62
|
+
*
|
|
63
|
+
* `resolveLivePlan` reads a task's own line against the same two bases and is
|
|
64
|
+
* not this function. It tries the board first and tests containment under the
|
|
65
|
+
* live plans folder, where this tries the root first and tests nothing, so the
|
|
66
|
+
* two agree on the spellings a board writes and part company outside them.
|
|
67
|
+
* Sharing the bases is what makes a board link resolve for both, and the
|
|
68
|
+
* archive exclusion in `planAnswers` is stated separately for that reason.
|
|
69
|
+
*
|
|
70
|
+
* Root order is what keeps the documented forms unchanged. A reference that
|
|
71
|
+
* resolves from the root is taken there, and the board base is reached only by
|
|
72
|
+
* a path the root could not answer.
|
|
73
|
+
*/
|
|
74
|
+
export function planCandidates(root: string, reference: string): string[] {
|
|
75
|
+
if (reference.includes('/') || reference.endsWith('.md')) {
|
|
76
|
+
if (isAbsolute(reference)) return [reference]
|
|
77
|
+
|
|
78
|
+
return [
|
|
79
|
+
resolve(root, reference),
|
|
80
|
+
resolve(recordDir(root, TASKS), reference),
|
|
81
|
+
]
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const slug = reference.startsWith('feature-')
|
|
85
|
+
? reference.slice('feature-'.length)
|
|
86
|
+
: reference
|
|
87
|
+
|
|
88
|
+
return [recordDir(root, PLANS, `feature-${slug}.md`)]
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function suggestionOf(body: readonly string[]): string | undefined {
|
|
92
|
+
const line = body.find((entry) => entry.startsWith(SUGGESTED_PREFIX))
|
|
93
|
+
|
|
94
|
+
return line?.slice(SUGGESTED_PREFIX.length).trim()
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* An absent `- Answer:` line reads the same as a blank one here. The slot being
|
|
99
|
+
* missing is a conformance defect `canon tasks validate` already names, and a
|
|
100
|
+
* gate that answered it differently would refuse a row for a reason the
|
|
101
|
+
* validator has already reported.
|
|
102
|
+
*/
|
|
103
|
+
function isAnswered(body: readonly string[]): boolean {
|
|
104
|
+
const line = body.find((entry) => entry.startsWith(ANSWER_PREFIX))
|
|
105
|
+
if (line === undefined) return false
|
|
106
|
+
|
|
107
|
+
return line.slice(ANSWER_PREFIX.length).trim().length > 0
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* The standard writes the reason behind a comma and the corpus also writes it
|
|
112
|
+
* behind a full stop, so both separators come off. Reporting the phrase with
|
|
113
|
+
* whatever punctuation followed it hands the operator a stray mark where the
|
|
114
|
+
* reason should start.
|
|
115
|
+
*/
|
|
116
|
+
function reasonOf(suggested: string): string {
|
|
117
|
+
const rest = suggested.slice(OPERATOR_CALL.length).replace(/^[,.;:\s]+/, '')
|
|
118
|
+
|
|
119
|
+
return rest.length > 0 ? rest : 'no reason stated'
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* A question carrying no suggestion at all is also a stop at execution, and it
|
|
124
|
+
* is not read here. `checkQuestionContract` reports it as `suggestion-missing`
|
|
125
|
+
* and the runbook dispatches a row whose plan is already verified, so testing
|
|
126
|
+
* it again would put one rule in two places that ship on different cadences.
|
|
127
|
+
*/
|
|
128
|
+
function openQuestions(lines: readonly string[]): OpenQuestion[] {
|
|
129
|
+
const open: OpenQuestion[] = []
|
|
130
|
+
|
|
131
|
+
for (const question of readQuestions(lines)) {
|
|
132
|
+
const suggested = suggestionOf(question.body)
|
|
133
|
+
if (!suggested?.toLowerCase().startsWith(OPERATOR_CALL)) continue
|
|
134
|
+
if (isAnswered(question.body)) continue
|
|
135
|
+
|
|
136
|
+
open.push({ label: question.label, why: reasonOf(suggested) })
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return open
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Answers whether a plan is launchable, which is whether it still waits on the
|
|
144
|
+
* operator for a call only they can make. It reads the question block through
|
|
145
|
+
* the same `readQuestions` the plan validator runs, so the gate and the
|
|
146
|
+
* conformance check cannot drift into disagreeing about what a question is.
|
|
147
|
+
*
|
|
148
|
+
* It reports and never writes. Holding the row, naming the slot, and reaching
|
|
149
|
+
* the operator belong to the dispatcher, which is where the decision already
|
|
150
|
+
* sits.
|
|
151
|
+
*/
|
|
152
|
+
export async function planAnswers(
|
|
153
|
+
root: string,
|
|
154
|
+
reference: string,
|
|
155
|
+
): Promise<AnswersOutcome> {
|
|
156
|
+
if (reference.trim().length === 0) {
|
|
157
|
+
return refuse('bad-input', 'No plan named. Pass a plan path or its slug.')
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const candidates = planCandidates(root, reference)
|
|
161
|
+
const path = candidates.find((candidate) => existsSync(candidate))
|
|
162
|
+
|
|
163
|
+
if (!path) {
|
|
164
|
+
// Naming every base keeps a task-relative link from reporting the one place
|
|
165
|
+
// it does not resolve, since `relative` hands that spelling straight back.
|
|
166
|
+
const looked = candidates.map((entry) => relative(root, entry)).join(' or ')
|
|
167
|
+
|
|
168
|
+
return refuse('no-plan', `No plan at ${looked}.`, [reference])
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// An archived plan answers every question and would report as launchable, so
|
|
172
|
+
// the name would clear a dispatch that `claude-autoship` Step 1 then refuses
|
|
173
|
+
// as already-shipped work. Catching it here is a step earlier than the worker.
|
|
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))) {
|
|
178
|
+
return refuse(
|
|
179
|
+
'archived',
|
|
180
|
+
`${relative(root, path)} sits in the plans archive, so it describes work that already shipped.`,
|
|
181
|
+
[reference],
|
|
182
|
+
)
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const sections = splitPlanSections(await readFile(path, 'utf8'))
|
|
186
|
+
const open = openQuestions(sections.get('Questions') ?? [])
|
|
187
|
+
|
|
188
|
+
return {
|
|
189
|
+
ok: true,
|
|
190
|
+
plan: relative(root, path),
|
|
191
|
+
launchable: open.length === 0,
|
|
192
|
+
open,
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function refuse(
|
|
197
|
+
reason: AnswerRefusal,
|
|
198
|
+
message: string,
|
|
199
|
+
detail: readonly string[] = [],
|
|
200
|
+
): AnswersRefused {
|
|
201
|
+
return { ok: false, reason, message, detail }
|
|
202
|
+
}
|
package/src/tasks/archive.ts
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
import { existsSync } from 'node:fs'
|
|
2
2
|
import { mkdir, readdir, readFile, rename, writeFile } from 'node:fs/promises'
|
|
3
|
-
import { join, relative, resolve
|
|
3
|
+
import { join, relative, resolve } from 'node:path'
|
|
4
4
|
import { regenOne } from '@/indexes/regen'
|
|
5
|
+
import { isUnder } from '@/paths'
|
|
6
|
+
import { recordDir, recordDirs } from '@/record-root'
|
|
5
7
|
|
|
6
|
-
const
|
|
7
|
-
const
|
|
8
|
-
const
|
|
9
|
-
const PLANS_ARCHIVE_DIR = join(PLANS_DIR, 'archive')
|
|
8
|
+
const TASKS = 'tasks'
|
|
9
|
+
const PLANS = 'plans'
|
|
10
|
+
const ARCHIVE = 'archive'
|
|
10
11
|
|
|
11
12
|
/**
|
|
12
13
|
* Siblings that sit on the board without being tasks: the generated index, the
|
|
@@ -76,11 +77,11 @@ export interface TaskOutcomes {
|
|
|
76
77
|
}
|
|
77
78
|
|
|
78
79
|
export function tasksDir(root: string): string {
|
|
79
|
-
return
|
|
80
|
+
return recordDir(root, TASKS)
|
|
80
81
|
}
|
|
81
82
|
|
|
82
83
|
export function archiveDir(root: string): string {
|
|
83
|
-
return
|
|
84
|
+
return recordDir(root, TASKS, ARCHIVE)
|
|
84
85
|
}
|
|
85
86
|
|
|
86
87
|
export const OUTCOME_PATTERN = /^- \[([ xX])\] ?(.*)$/
|
|
@@ -180,14 +181,6 @@ function isRowFor(line: string, target: string): boolean {
|
|
|
180
181
|
return first !== undefined && first.includes(target)
|
|
181
182
|
}
|
|
182
183
|
|
|
183
|
-
/**
|
|
184
|
-
* Tests containment rather than a string prefix, so a sibling whose name merely
|
|
185
|
-
* extends the folder's is not read as being inside it.
|
|
186
|
-
*/
|
|
187
|
-
function isUnder(path: string, dir: string): boolean {
|
|
188
|
-
return path === dir || path.startsWith(`${dir}${sep}`)
|
|
189
|
-
}
|
|
190
|
-
|
|
191
184
|
/**
|
|
192
185
|
* Resolves the `Plan:` target against the board and against the project root
|
|
193
186
|
* both, which is how `claude-docs` reads the same line. It accepts `../plans/x.md`
|
|
@@ -208,15 +201,20 @@ export function resolveLivePlan(
|
|
|
208
201
|
dir: string,
|
|
209
202
|
root: string,
|
|
210
203
|
): string | undefined {
|
|
211
|
-
|
|
212
|
-
|
|
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))
|
|
213
213
|
const fromBoard = resolve(dir, target)
|
|
214
214
|
const fromRoot = resolve(root, target)
|
|
215
215
|
|
|
216
|
-
if (
|
|
217
|
-
|
|
218
|
-
}
|
|
219
|
-
if (isUnder(fromRoot, plans) && !isUnder(fromRoot, archive)) return fromRoot
|
|
216
|
+
if (live(fromBoard)) return fromBoard
|
|
217
|
+
if (live(fromRoot)) return fromRoot
|
|
220
218
|
return undefined
|
|
221
219
|
}
|
|
222
220
|
|
|
@@ -315,7 +313,12 @@ export async function planCitations(
|
|
|
315
313
|
|
|
316
314
|
const live = resolveLivePlan(target, dir, root)
|
|
317
315
|
if (!live) {
|
|
318
|
-
const location = resolvesUnder(
|
|
316
|
+
const location = resolvesUnder(
|
|
317
|
+
target,
|
|
318
|
+
dir,
|
|
319
|
+
root,
|
|
320
|
+
recordDirs(root, PLANS, ARCHIVE),
|
|
321
|
+
)
|
|
319
322
|
? 'archived'
|
|
320
323
|
: 'outside'
|
|
321
324
|
return { ok: true, stem, target, location, citedBy: [] }
|
|
@@ -331,21 +334,21 @@ export async function planCitations(
|
|
|
331
334
|
}
|
|
332
335
|
|
|
333
336
|
/**
|
|
334
|
-
* Runs the two-
|
|
335
|
-
*
|
|
336
|
-
*
|
|
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.
|
|
337
340
|
*/
|
|
338
341
|
function resolvesUnder(
|
|
339
342
|
target: string,
|
|
340
343
|
dir: string,
|
|
341
344
|
root: string,
|
|
342
|
-
|
|
345
|
+
dirs: readonly string[],
|
|
343
346
|
): boolean {
|
|
344
|
-
const
|
|
347
|
+
const fromBoard = resolve(dir, target)
|
|
348
|
+
const fromRoot = resolve(root, target)
|
|
345
349
|
|
|
346
|
-
return (
|
|
347
|
-
isUnder(
|
|
348
|
-
isUnder(resolve(root, target), resolved)
|
|
350
|
+
return dirs.some(
|
|
351
|
+
(resolved) => isUnder(fromBoard, resolved) || isUnder(fromRoot, resolved),
|
|
349
352
|
)
|
|
350
353
|
}
|
|
351
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
|