@erclx/canon 4.71.0 → 4.72.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.
@@ -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/plan.md CHANGED
@@ -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
 
@@ -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.