@erclx/canon 4.71.0 → 4.72.1
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/draft-and-pick/REQUIREMENT.md +3 -1
- package/claude/skills/draft-and-pick/SKILL.md +10 -6
- package/claude/skills/git-ship/SKILL.md +17 -6
- package/claude/skills/markdown-propose/references/format.md +1 -1
- package/claude/skills/plan-groundwork/REQUIREMENT.md +1 -1
- package/claude/skills/plan-groundwork/SKILL.md +1 -1
- package/claude/skills/plan-intake/SKILL.md +2 -2
- package/claude/skills/role-orchestrator/references/orchestrator-dispatch.md +2 -0
- package/claude/skills/role-planner/SKILL.md +5 -0
- package/claude/skills/role-worker/SKILL.md +6 -0
- package/claude/skills/session-relay/REQUIREMENT.md +41 -0
- package/claude/skills/session-relay/SKILL.md +36 -0
- package/docs/agents/key-changes.md +5 -1
- package/docs/agents/tasks.md +33 -0
- package/docs/workflow/ai-workflow.md +1 -0
- package/governance/rules/ui/440-surface-capture.md +1 -0
- package/package.json +1 -1
- package/src/claude/cases/workflow.ts +5 -0
- package/src/commands/pr.ts +128 -6
- package/src/commands/tasks.ts +154 -0
- package/src/git-files.ts +69 -0
- package/src/pr/bijection.ts +59 -1
- package/src/tasks/reach.ts +383 -0
- package/standards/groundwork.md +1 -1
- package/standards/intake.md +1 -1
- package/standards/memory.md +2 -2
- package/standards/plan.md +3 -1
- package/standards/tasks.md +2 -0
|
@@ -0,0 +1,383 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs'
|
|
2
|
+
import { readdir, readFile } from 'node:fs/promises'
|
|
3
|
+
import { basename, join } from 'node:path'
|
|
4
|
+
import { listChangedFiles, resolveBaseRef } from '@/git-files'
|
|
5
|
+
import { recordDir } from '@/record-root'
|
|
6
|
+
import { splitPlanSections } from '@/records/validate'
|
|
7
|
+
import { type AnswersRefused, resolvePlanReference } from '@/tasks/answers'
|
|
8
|
+
import { orderingPath, readBoard } from '@/tasks/validate'
|
|
9
|
+
|
|
10
|
+
const PLANS = 'plans'
|
|
11
|
+
const MARKDOWN = '.md'
|
|
12
|
+
const NONE_IDENTIFIED = 'None identified.'
|
|
13
|
+
|
|
14
|
+
/** The one group a dispatch reads, per `standards/tasks.md`. */
|
|
15
|
+
const DISPATCH_GROUP = 'Run now'
|
|
16
|
+
|
|
17
|
+
export const REACH_REFUSALS = [
|
|
18
|
+
'no-plan',
|
|
19
|
+
'archived',
|
|
20
|
+
'bad-input',
|
|
21
|
+
'no-base',
|
|
22
|
+
'no-diff',
|
|
23
|
+
] as const
|
|
24
|
+
|
|
25
|
+
export type ReachRefusal = (typeof REACH_REFUSALS)[number]
|
|
26
|
+
|
|
27
|
+
export interface ReachRefused {
|
|
28
|
+
readonly ok: false
|
|
29
|
+
readonly reason: ReachRefusal
|
|
30
|
+
readonly message: string
|
|
31
|
+
readonly detail: readonly string[]
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* One surface holding a path: a live plan, named by its filename stem, or a
|
|
36
|
+
* `## Run now` row, named by its task label.
|
|
37
|
+
*/
|
|
38
|
+
export interface Holder {
|
|
39
|
+
readonly name: string
|
|
40
|
+
readonly source: 'plan' | 'row'
|
|
41
|
+
readonly declaration: string
|
|
42
|
+
/**
|
|
43
|
+
* Whether a holding plan also carries a row in `## Run now`, and undefined on
|
|
44
|
+
* a row holder, which is one by construction.
|
|
45
|
+
*
|
|
46
|
+
* A plan with no row is the shape a plan nobody archived takes, and it is
|
|
47
|
+
* also the shape of one whose task is merely not dispatched yet, so this
|
|
48
|
+
* narrows a reader's search rather than answering it. Testing whether the
|
|
49
|
+
* holder is genuinely in flight would put a second liveness reading here
|
|
50
|
+
* beside the dispatch gate's own, which the report declines.
|
|
51
|
+
*/
|
|
52
|
+
readonly rowed?: boolean
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* One changed path other tracks already hold, carrying every holder and the
|
|
57
|
+
* declaration each matched on. A count alone leaves the reader diffing two file
|
|
58
|
+
* lists by eye to find which pair collided.
|
|
59
|
+
*
|
|
60
|
+
* Grouped by path rather than by holder. The two sources are read separately
|
|
61
|
+
* and stay separate inside `holders`, since a board cell and a plan's own list
|
|
62
|
+
* answer different questions, but one track carrying both reported the same
|
|
63
|
+
* path twice under two names before this, and the report leads on this list.
|
|
64
|
+
*/
|
|
65
|
+
export interface Claim {
|
|
66
|
+
readonly path: string
|
|
67
|
+
readonly holders: readonly Holder[]
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface ReachReport {
|
|
71
|
+
readonly ok: true
|
|
72
|
+
readonly plan: string
|
|
73
|
+
readonly base: string
|
|
74
|
+
readonly changed: number
|
|
75
|
+
readonly declared: readonly string[]
|
|
76
|
+
/** Leads the report. Short on nearly every branch, and the half worth acting on. */
|
|
77
|
+
readonly claimed: readonly Claim[]
|
|
78
|
+
readonly undeclared: readonly string[]
|
|
79
|
+
/** Live plans compared against, this branch's own excluded. */
|
|
80
|
+
readonly plans: number
|
|
81
|
+
/** `## Run now` rows compared against, the row citing this plan excluded. */
|
|
82
|
+
readonly rows: number
|
|
83
|
+
/** Whether an ordering file was on disk to read rows from at all. */
|
|
84
|
+
readonly board: boolean
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export type ReachOutcome = ReachReport | ReachRefused
|
|
88
|
+
|
|
89
|
+
export interface ReachOptions {
|
|
90
|
+
/**
|
|
91
|
+
* Where the git range is read, defaulting to the records root.
|
|
92
|
+
*
|
|
93
|
+
* The two part company on every ordinary run, since the plans and the board
|
|
94
|
+
* are shared scratch at the main worktree root while the branch's own commits
|
|
95
|
+
* are in a linked worktree. Reading the range at the records root there
|
|
96
|
+
* measures a checkout sitting on the trunk, so the range closes on itself and
|
|
97
|
+
* every branch reports a reach of nothing. `canon gov test-order` carries the
|
|
98
|
+
* same split as an instruction in a skill body; this one is a parameter.
|
|
99
|
+
*/
|
|
100
|
+
readonly repo?: string
|
|
101
|
+
/** The far side of the range, defaulting to the trunk. */
|
|
102
|
+
readonly ref?: string
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Splits an entry at the colon that opens its reason, which is the first one
|
|
107
|
+
* standing outside a backticked span.
|
|
108
|
+
*
|
|
109
|
+
* The span test is what keeps `canon:docs-sync` and a `<slug>: <title>` label
|
|
110
|
+
* from ending the subject early, and it is the ordinary case rather than a
|
|
111
|
+
* corner: a reason routinely names a skill, and a skill is spelled with a
|
|
112
|
+
* colon inside backticks.
|
|
113
|
+
*/
|
|
114
|
+
function subjectOf(entry: string): string {
|
|
115
|
+
let fenced = false
|
|
116
|
+
|
|
117
|
+
for (let i = 0; i < entry.length; i += 1) {
|
|
118
|
+
const char = entry[i]
|
|
119
|
+
if (char === '`') fenced = !fenced
|
|
120
|
+
else if (char === ':' && !fenced) return entry.slice(0, i)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return entry
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Whether a span names a file rather than a skill, a command, or a version.
|
|
128
|
+
* It is `readPaths`' test, held here rather than shared, because that function
|
|
129
|
+
* reads a whole cell where this reads one already-split subject.
|
|
130
|
+
*/
|
|
131
|
+
function namesFile(span: string): boolean {
|
|
132
|
+
return span.includes('/') || /\.[A-Za-z][A-Za-z0-9]*$/.test(span)
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Reads the paths a plan declares out of its `**Files to touch:**` section.
|
|
137
|
+
*
|
|
138
|
+
* A declaration is a backticked span standing as an entry's subject, ahead of
|
|
139
|
+
* the colon opening its reason. Reading every span in the entry was the
|
|
140
|
+
* alternative and it reports a pair that was never going to collide, since the
|
|
141
|
+
* standard invites an entry to explain itself and an explanation names other
|
|
142
|
+
* files: one archived plan cites `src/gate/measures.ts` inside a reason whose
|
|
143
|
+
* subject is a sandbox arm, and never wrote it. Measured over the 2026-09-08
|
|
144
|
+
* wave, the subject rule reports 6 crossing pairs against 14 for every span.
|
|
145
|
+
*
|
|
146
|
+
* An entry carrying no colon at all is taken whole. The standard requires a
|
|
147
|
+
* reason rather than the punctuation introducing it, so the alternative is
|
|
148
|
+
* reading such an entry as declaring nothing, which reports every path it
|
|
149
|
+
* names as undeclared. `standards/plan.md` fixes the colon form, so the
|
|
150
|
+
* conforming entry never reaches this fallback.
|
|
151
|
+
*
|
|
152
|
+
* A rename declares both sides, since both are paths the branch writes and
|
|
153
|
+
* both sit ahead of the colon.
|
|
154
|
+
*/
|
|
155
|
+
export function readDeclarations(text: string): string[] {
|
|
156
|
+
const section = splitPlanSections(text).get('Files to touch') ?? []
|
|
157
|
+
const declared: string[] = []
|
|
158
|
+
|
|
159
|
+
for (const line of section) {
|
|
160
|
+
const trimmed = line.trim()
|
|
161
|
+
if (!trimmed.startsWith('- ') || trimmed === `- ${NONE_IDENTIFIED}`)
|
|
162
|
+
continue
|
|
163
|
+
|
|
164
|
+
const spans = subjectOf(trimmed.slice(2)).match(/`[^`]+`/g) ?? []
|
|
165
|
+
|
|
166
|
+
for (const span of spans) {
|
|
167
|
+
const path = span
|
|
168
|
+
.slice(1, -1)
|
|
169
|
+
.trim()
|
|
170
|
+
.replace(/^\.\//, '')
|
|
171
|
+
.replace(/\/+$/, '')
|
|
172
|
+
|
|
173
|
+
if (namesFile(path)) declared.push(path)
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
return [...new Set(declared)]
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Whether a declaration covers a changed path: the same file, or a folder the
|
|
182
|
+
* file sits under.
|
|
183
|
+
*
|
|
184
|
+
* Containment runs one way only. A changed path is always a file, so a
|
|
185
|
+
* declaration of `src/tasks/reach.ts` covering a change to `src/tasks/other.ts`
|
|
186
|
+
* would be the folder claim its author did not write, and the board's own
|
|
187
|
+
* `sharesPath` reads both directions because two rows can each name a folder.
|
|
188
|
+
*/
|
|
189
|
+
function covers(declaration: string, path: string): boolean {
|
|
190
|
+
return path === declaration || path.startsWith(`${declaration}/`)
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function stemOf(path: string): string {
|
|
194
|
+
return basename(path, MARKDOWN)
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Every live plan beside this one, as a stem and the paths it declares. The
|
|
199
|
+
* archive is a folder below this one and is never walked, so a shipped plan
|
|
200
|
+
* cannot claim a path against a branch building today.
|
|
201
|
+
*/
|
|
202
|
+
async function otherPlans(
|
|
203
|
+
root: string,
|
|
204
|
+
own: string,
|
|
205
|
+
): Promise<{ stem: string; declared: readonly string[] }[]> {
|
|
206
|
+
const dir = recordDir(root, PLANS)
|
|
207
|
+
if (!existsSync(dir)) return []
|
|
208
|
+
|
|
209
|
+
const names = (await readdir(dir)).filter(
|
|
210
|
+
(name) => name.endsWith(MARKDOWN) && name !== basename(own),
|
|
211
|
+
)
|
|
212
|
+
names.sort()
|
|
213
|
+
|
|
214
|
+
const plans = []
|
|
215
|
+
for (const name of names) {
|
|
216
|
+
const text = await readFile(join(dir, name), 'utf8')
|
|
217
|
+
plans.push({ stem: stemOf(name), declared: readDeclarations(text) })
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
return plans
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Every `## Run now` cell beside this branch's own row. A cell is copied from a
|
|
225
|
+
* plan's own list, so the board catches a row whose plan is absent and the
|
|
226
|
+
* plans folder catches a claim no cell carried.
|
|
227
|
+
*/
|
|
228
|
+
async function dispatchRows(
|
|
229
|
+
root: string,
|
|
230
|
+
own: string,
|
|
231
|
+
): Promise<{
|
|
232
|
+
read: boolean
|
|
233
|
+
rows: { label: string; plan: string | undefined; touches: string[] }[]
|
|
234
|
+
}> {
|
|
235
|
+
const ordering = orderingPath(root)
|
|
236
|
+
if (!existsSync(ordering)) return { read: false, rows: [] }
|
|
237
|
+
|
|
238
|
+
const { rows } = readBoard(await readFile(ordering, 'utf8'))
|
|
239
|
+
const ownStem = stemOf(own)
|
|
240
|
+
|
|
241
|
+
return {
|
|
242
|
+
read: true,
|
|
243
|
+
rows: rows
|
|
244
|
+
.filter((row) => row.group === DISPATCH_GROUP)
|
|
245
|
+
.filter((row) => row.plan === undefined || stemOf(row.plan) !== ownStem)
|
|
246
|
+
.map((row) => ({
|
|
247
|
+
label: row.label,
|
|
248
|
+
plan: row.plan === undefined ? undefined : stemOf(row.plan),
|
|
249
|
+
touches: [...(row.touches ?? [])],
|
|
250
|
+
})),
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Every holder of one path, plans first. Returning a list rather than a claim
|
|
256
|
+
* per source is what keeps a track carrying both a plan and a row from reading
|
|
257
|
+
* as two tracks in a report that leads on this list.
|
|
258
|
+
*/
|
|
259
|
+
function holdersOf(
|
|
260
|
+
path: string,
|
|
261
|
+
plans: readonly { stem: string; declared: readonly string[] }[],
|
|
262
|
+
rows: readonly {
|
|
263
|
+
label: string
|
|
264
|
+
plan: string | undefined
|
|
265
|
+
touches: readonly string[]
|
|
266
|
+
}[],
|
|
267
|
+
): Holder[] {
|
|
268
|
+
const holders: Holder[] = []
|
|
269
|
+
|
|
270
|
+
for (const plan of plans) {
|
|
271
|
+
const declaration = plan.declared.find((entry) => covers(entry, path))
|
|
272
|
+
if (declaration !== undefined) {
|
|
273
|
+
holders.push({
|
|
274
|
+
name: plan.stem,
|
|
275
|
+
source: 'plan',
|
|
276
|
+
declaration,
|
|
277
|
+
rowed: rows.some((row) => row.plan === plan.stem),
|
|
278
|
+
})
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
for (const row of rows) {
|
|
283
|
+
const declaration = row.touches.find((entry) => covers(entry, path))
|
|
284
|
+
if (declaration !== undefined) {
|
|
285
|
+
holders.push({ name: row.label, source: 'row', declaration })
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
return holders
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Reads a branch back against what was written down about it: the paths its own
|
|
294
|
+
* plan declared, and the paths every other live plan or dispatch row holds.
|
|
295
|
+
*
|
|
296
|
+
* It reports and never gates, the way `canon gov test-order` does, and it
|
|
297
|
+
* writes nothing. A `Touches` cell is the controller's to correct and a plan's
|
|
298
|
+
* list is a prediction the branch outgrows, so a verb that repaired either
|
|
299
|
+
* would be answering a question the reader has not been shown yet.
|
|
300
|
+
*
|
|
301
|
+
* What it reads is what is written down, so it inherits the dispatch runbook's
|
|
302
|
+
* own blindness. A hand-launched track carries no row and a track with no plan
|
|
303
|
+
* carries no declaration, which is why the report names how many plans and rows
|
|
304
|
+
* it compared against rather than reporting clear and letting that read as a
|
|
305
|
+
* proof.
|
|
306
|
+
*
|
|
307
|
+
* The live folder is trusted rather than tested, which cuts the other way: a
|
|
308
|
+
* plan whose work merged keeps claiming its files until something archives it,
|
|
309
|
+
* and `canon tasks archive` only runs on merge, so a stranded plan reports a
|
|
310
|
+
* collision against every branch after it. The first run of this verb reported
|
|
311
|
+
* five such paths and every one was a file nobody archived. Testing a holder for
|
|
312
|
+
* liveness would put a second reading of what is in flight here, beside the
|
|
313
|
+
* dispatch gate's own, so the report names the holder and the reader decides.
|
|
314
|
+
*/
|
|
315
|
+
export async function planReach(
|
|
316
|
+
root: string,
|
|
317
|
+
reference: string,
|
|
318
|
+
{ repo = root, ref }: ReachOptions = {},
|
|
319
|
+
): Promise<ReachOutcome> {
|
|
320
|
+
const resolved = resolvePlanReference(root, reference)
|
|
321
|
+
if (!resolved.ok) return widen(resolved)
|
|
322
|
+
|
|
323
|
+
const base = await resolveBaseRef(repo, ref)
|
|
324
|
+
if (base === undefined) {
|
|
325
|
+
return refuse(
|
|
326
|
+
'no-base',
|
|
327
|
+
`No merge base against ${ref ?? 'origin/main or main'}, so the branch has no range to read.`,
|
|
328
|
+
[reference],
|
|
329
|
+
)
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
const changed = await listChangedFiles(repo, base)
|
|
333
|
+
if (changed === undefined) {
|
|
334
|
+
return refuse(
|
|
335
|
+
'no-diff',
|
|
336
|
+
`git could not list the files changed since ${base}, so the reach is unread rather than clear.`,
|
|
337
|
+
[reference],
|
|
338
|
+
)
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
const declared = readDeclarations(await readFile(resolved.path, 'utf8'))
|
|
342
|
+
const plans = await otherPlans(root, resolved.path)
|
|
343
|
+
const board = await dispatchRows(root, resolved.path)
|
|
344
|
+
|
|
345
|
+
const claimed: Claim[] = []
|
|
346
|
+
const undeclared: string[] = []
|
|
347
|
+
|
|
348
|
+
for (const path of changed) {
|
|
349
|
+
const holders = holdersOf(path, plans, board.rows)
|
|
350
|
+
if (holders.length > 0) claimed.push({ path, holders })
|
|
351
|
+
if (!declared.some((entry) => covers(entry, path))) undeclared.push(path)
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
return {
|
|
355
|
+
ok: true,
|
|
356
|
+
plan: resolved.plan,
|
|
357
|
+
base,
|
|
358
|
+
changed: changed.length,
|
|
359
|
+
declared,
|
|
360
|
+
claimed,
|
|
361
|
+
undeclared,
|
|
362
|
+
plans: plans.length,
|
|
363
|
+
rows: board.rows.length,
|
|
364
|
+
board: board.read,
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* Carries a resolution refusal through unchanged. The reasons are a subset of
|
|
370
|
+
* this verb's own, so restating the message here would put one wording in two
|
|
371
|
+
* places that ship together.
|
|
372
|
+
*/
|
|
373
|
+
function widen(refused: AnswersRefused): ReachRefused {
|
|
374
|
+
return refused
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
function refuse(
|
|
378
|
+
reason: ReachRefusal,
|
|
379
|
+
message: string,
|
|
380
|
+
detail: readonly string[] = [],
|
|
381
|
+
): ReachRefused {
|
|
382
|
+
return { ok: false, reason, message, detail }
|
|
383
|
+
}
|
package/standards/groundwork.md
CHANGED
|
@@ -7,7 +7,7 @@ description: Folder layout, ordinal naming, reserved numbering, frontmatter and
|
|
|
7
7
|
|
|
8
8
|
Applies to a groundwork track at `.canon/groundwork/<nn>-<slug>/`. A track measures one question that has to be settled before anyone can plan against it. The numbering inside the folder is the table of contents, so a reader opens the folder and knows where to start and what follows without an index maintained inside each file.
|
|
9
9
|
|
|
10
|
-
The folder is gitignored and
|
|
10
|
+
The folder is gitignored, and backed wherever a records remote is configured: `canon records push` and `canon records pull` protect it against the machine being lost there, refusing with `no-remote` where no remote is set, and neither protects against a compaction dropping a session's reasoning before anyone has pushed. No check reaches its contents, so every rule here holds only while a session reads it, and the handoff file has to be self-contained.
|
|
11
11
|
|
|
12
12
|
## Scope
|
|
13
13
|
|
package/standards/intake.md
CHANGED
|
@@ -7,7 +7,7 @@ description: Folder layout, ordinal naming, reserved index number, frontmatter a
|
|
|
7
7
|
|
|
8
8
|
Applies to an intake folder at `.canon/intake/<nn>-<slug>/`. One folder holds one dump, filed by domain, and every finding in it is an item carrying a measured problem, one proposed fix, and a verdict.
|
|
9
9
|
|
|
10
|
-
The folder is gitignored and
|
|
10
|
+
The folder is gitignored, and backed wherever a records remote is configured: `canon records push` and `canon records pull` protect it against the machine being lost there, refuse with `no-remote` where it is not, and neither protects against a compaction dropping a session's reasoning before anyone has pushed. No check reaches its contents, so the shape below survives only by being read.
|
|
11
11
|
|
|
12
12
|
## Scope
|
|
13
13
|
|
package/standards/memory.md
CHANGED
|
@@ -7,7 +7,7 @@ description: Filename and type prefix, frontmatter, the body shape per type, lin
|
|
|
7
7
|
|
|
8
8
|
Applies to a memory entry at `.canon/memory/<type>-<slug>.md`. One file holds one rule or one fact, written at the end of the session that produced it and read by a session that holds none of it. Which surface owns a given fact is settled before an entry is written at all, and that routing is project policy rather than a shape rule.
|
|
9
9
|
|
|
10
|
-
The folder is gitignored and
|
|
10
|
+
The folder is gitignored, and backed wherever a records remote is configured: `canon records push` and `canon records pull` protect it against the machine being lost there, refuse with `no-remote` where it is not, and protect nothing against an entry deleted before anyone has pushed. That is why the retire step below is a move rather than a cleanup: a retired entry stays readable regardless of push timing, where a deleted one is gone the moment nothing has captured it yet.
|
|
11
11
|
|
|
12
12
|
## Scope
|
|
13
13
|
|
|
@@ -98,7 +98,7 @@ Link a related entry as `[[name]]`, where `name` is the target's filename stem w
|
|
|
98
98
|
|
|
99
99
|
- Check the folder for an entry on the same topic before writing a new one, and update that entry in place when one exists. Two entries on one rule disagree the moment either is edited.
|
|
100
100
|
- Rewrite an entry the tree has moved under rather than appending a second passage narrating the change. A reader cannot tell which of two claims is current.
|
|
101
|
-
- Never delete an entry. Retire one by moving it to an archive under its own name, because
|
|
101
|
+
- Never delete an entry. Retire one by moving it to an archive under its own name, because a bulk judgment made before the next push has no undo behind it, and moving keeps the record where deleting would not.
|
|
102
102
|
- Treat the folder as a holding pen rather than a destination. An entry whose rule belongs on a durable surface is promoted there and retired here, and the rest is what the pen is for.
|
|
103
103
|
|
|
104
104
|
The catalog is generated from sibling frontmatter rather than authored. Never hand-edit it, since the next regeneration discards whatever was added by hand.
|
package/standards/plan.md
CHANGED
|
@@ -7,7 +7,7 @@ description: Filename and slug, required sections, the suggested-and-answer cont
|
|
|
7
7
|
|
|
8
8
|
Applies to a feature plan at `.canon/plans/feature-<slug>.md`. One file holds one concern, written before implementation starts and read by whatever executes it, so it has to carry the scope without the conversation that produced it.
|
|
9
9
|
|
|
10
|
-
The folder is gitignored and
|
|
10
|
+
The folder is gitignored, and backed wherever a records remote is configured: `canon records push` and `canon records pull` protect it against the machine being lost there, refuse with `no-remote` where it is not, and protect nothing against a plan deleted before anyone has pushed. That is why the archive step below is a move rather than a cleanup.
|
|
11
11
|
|
|
12
12
|
## Scope
|
|
13
13
|
|
|
@@ -61,6 +61,8 @@ The document opens with `# Feature: <short title>` and one paragraph stating wha
|
|
|
61
61
|
- Write `None identified.` under a required section with nothing to report rather than dropping the marker. A dropped section and an unconsidered one read identically.
|
|
62
62
|
- Aim `## Summary` at a person scanning the plan, not at the session executing it. The other sections carry what execution needs.
|
|
63
63
|
- Give every `**Files to touch:**` entry a backticked path and something said about it. A bare path states scope and not intent, and the reason is what an executing session checks its edit against. Lead with the path or lead with a label carrying the path, whichever reads better for the entry.
|
|
64
|
+
- Separate the two halves with a colon, and keep every path the entry declares ahead of it. What sits before the colon is the entry's subject and reads as a target, so both sides of a rename belong there and a file merely cited by the reason does not. A reason is free to name another path, and one entry that did was read as declaring a file its branch never wrote.
|
|
65
|
+
- Read the list as what the branch sets out to write rather than as a bound on it. The ship chain writes past it on nearly every branch, since the sync skills refresh whichever context entry and public doc the change reaches, and no planner can name those before the change exists. `canon tasks plan-reach <plan>` reads the branch back against this list and every other live plan, ahead of the pull request.
|
|
64
66
|
- State every count and every claim about the tree as measured during the pass that wrote the plan. A figure carried in from a summary or an earlier session is the most common way a plan ships the wrong scope.
|
|
65
67
|
- Prefer a short plan over a padded one. A section filled to look thorough costs the reader the same attention as one that matters.
|
|
66
68
|
|
package/standards/tasks.md
CHANGED
|
@@ -67,6 +67,8 @@ Readiness is three groups under fixed headings, `## Run now`, `## Up next`, and
|
|
|
67
67
|
|
|
68
68
|
Each group fixes its own columns, which follow from the test above it rather than from preference. Neither half of the `## Run now` test is checkable without the file set and the plan sitting beside the task.
|
|
69
69
|
|
|
70
|
+
A `Touches` cell is copied from its plan's own list, so it states what the branch sets out to write rather than a bound on it, and a branch outgrows it while the row still reads as it did at dispatch. Correct the cell from the branch rather than from the plan once one is running, since the plan is the prediction that already went stale and only the diff says what was written. `canon tasks plan-reach <plan>` reads that diff against every live plan and every cell in this group, and the row's owner is the one who writes the correction: a worker never edits this board.
|
|
71
|
+
|
|
70
72
|
Row position inside `## Needs a plan` is the order those tasks get planned in, top first. The three tests answer whether a task can start, which is mechanical, and none of them answers which task is worth starting, which is a judgment no column holds. Position is where that judgment is recorded, so the top row is the answer to what to plan next and a reader needs no other surface to get it. The other two groups take the same reading, and it costs them little, since a group holding what is already planned is short by construction.
|
|
71
73
|
|
|
72
74
|
Position alone carries it, and no rank column exists. A number beside each row is a second thing to keep in step with the order it duplicates, and the file is edited by one session at a time, so the order the rows are written in is already unambiguous. State on each row why it sits where it does, in the same cell that carries what it is waiting on. A position with no stated reason is re-derived from memory by the next session, which is the failure the ordering replaces rather than moves.
|