@erclx/canon 4.45.1 → 4.46.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-feature/SKILL.md +2 -0
- package/docs/agents/commands.md +1 -0
- package/docs/agents/tasks.md +22 -0
- package/package.json +1 -1
- package/src/commands/tasks.ts +85 -0
- package/src/tasks/archive.ts +19 -9
- package/src/tasks/record.ts +77 -0
- package/standards/tasks.md +2 -0
|
@@ -101,6 +101,8 @@ Run `canon records validate plans` after writing the file when the CLI is on PAT
|
|
|
101
101
|
|
|
102
102
|
Run `canon markdown audit .canon/plans/feature-<slug>.md` beside it, naming the file. `.canon/plans/` is gitignored and the audit's default path set is what git lists, so no other gate ever opens a plan, and six ban hits landed across four plans written without this call. Rewrite the sentence carrying a hit rather than swapping the token for a near-synonym.
|
|
103
103
|
|
|
104
|
+
When Step 1 resolved an existing task for this feature, run `canon tasks plan-link <task> .canon/plans/feature-<slug>.md` right after the file lands, naming the stem Step 1 read. Skip the call when Step 1 found no task, since there is nothing to link yet. Report a refusal rather than stopping on it. The plan file is already written, and `canon tasks validate` still catches an unlinked task through `plan-uncited` if this call cannot run.
|
|
105
|
+
|
|
104
106
|
Then output in chat:
|
|
105
107
|
|
|
106
108
|
```markdown
|
package/docs/agents/commands.md
CHANGED
|
@@ -32,6 +32,7 @@ Full help: `canon <command> --help`. Behavior notes for the install and sync ver
|
|
|
32
32
|
| `canon transcripts <url>` | Fetch a YouTube transcript with metadata frontmatter (needs `yt-dlp`) |
|
|
33
33
|
| `canon tasks archive` | Move a shipped task and its plan off the board, clear its ordering row, and regenerate the index |
|
|
34
34
|
| `canon tasks pull-request` | Record a pull request number on the task a branch closes, by stem or `--plan` (`--json`) |
|
|
35
|
+
| `canon tasks plan-link` | Write or correct a task's `Plan:` line to point at a plan, by stem and plan path or slug (`--json`) |
|
|
35
36
|
| `canon tasks outcome` | Mark outcomes `[x]` on a task by position, repeating `--close` (`--json`) |
|
|
36
37
|
| `canon tasks validate` | Report board rows whose shape, order, plan, task file, group, file set, or blocker does not hold (`--json`) |
|
|
37
38
|
| `canon intake list` | Report intake folder counts, or one folder's items, keeping what is unread with `--unread` (`--json`) |
|
package/docs/agents/tasks.md
CHANGED
|
@@ -104,6 +104,28 @@ The orchestrator dispatch runbook calls this before it checks the branch or the
|
|
|
104
104
|
canon tasks plan-answers dispatch-answer-gate --json | jq -r '.launchable'
|
|
105
105
|
```
|
|
106
106
|
|
|
107
|
+
## Plan link
|
|
108
|
+
|
|
109
|
+
`canon tasks plan-link <task> <plan>` writes or corrects a task's `Plan:` line, as `Plan: [<label>](<target>)` right after the H1. `claude-feature` calls it right after a plan file lands, when Step 1 resolved an existing task for the feature, so the line is a mechanical write rather than hand-edited markdown.
|
|
110
|
+
|
|
111
|
+
Name the task by its filename stem, and the plan by its path or its slug, the same two forms `canon tasks plan-answers` accepts:
|
|
112
|
+
|
|
113
|
+
```bash
|
|
114
|
+
canon tasks plan-link v28.1-trigger-escalation dispatch-answer-gate # canon-allow-reference: illustrates the stem-selection form, not a citation of a real task
|
|
115
|
+
canon tasks plan-link v28.1-trigger-escalation .canon/plans/feature-dispatch-answer-gate.md --json
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
| Option | Behavior |
|
|
119
|
+
| --------------- | ------------------------------------------- |
|
|
120
|
+
| `--json` | Emit a machine-readable record on stdout |
|
|
121
|
+
| `--root <path>` | Board root, defaulting to the main worktree |
|
|
122
|
+
|
|
123
|
+
The plan resolves the same way `canon tasks plan-answers` resolves one, against the project root first and `.canon/tasks/` second, so a bare slug and a board-relative path both work. A reference resolving to no file refuses as `no-plan`, naming every base it looked under.
|
|
124
|
+
|
|
125
|
+
The write mirrors `canon tasks pull-request`'s add/correct/unchanged shape, anchored on the H1 rather than on the last origin line, since `Plan:` is the first origin line a task carries rather than the last. The `action` field reports `added`, `corrected`, or `unchanged`, which makes a rerun against the same plan safe.
|
|
126
|
+
|
|
127
|
+
Exit codes: `0` recorded, `1` refused. The `reason` field carries `no-board`, `no-match`, `no-plan`, or `bad-input`.
|
|
128
|
+
|
|
107
129
|
## Pull request
|
|
108
130
|
|
|
109
131
|
`canon tasks pull-request` records the number a branch's pull request carries onto the task that branch closes. It adds `Pull request: #NNN` under the `Plan:`, `Groundwork:`, `Intake:`, or `Issue:` lines the task already holds, and corrects the number in place when the line exists.
|
package/package.json
CHANGED
package/src/commands/tasks.ts
CHANGED
|
@@ -11,7 +11,9 @@ import {
|
|
|
11
11
|
import {
|
|
12
12
|
type CloseOutcome,
|
|
13
13
|
closeOutcomes,
|
|
14
|
+
type PlanOutcome,
|
|
14
15
|
type PullRequestOutcome,
|
|
16
|
+
recordPlan,
|
|
15
17
|
type RecordRefused,
|
|
16
18
|
type RecordSelector,
|
|
17
19
|
recordPullRequest,
|
|
@@ -66,6 +68,11 @@ interface PullRequestCommandOptions {
|
|
|
66
68
|
readonly root?: string
|
|
67
69
|
}
|
|
68
70
|
|
|
71
|
+
interface PlanLinkCommandOptions {
|
|
72
|
+
readonly json?: boolean
|
|
73
|
+
readonly root?: string
|
|
74
|
+
}
|
|
75
|
+
|
|
69
76
|
interface OutcomeCommandOptions {
|
|
70
77
|
readonly close?: readonly string[]
|
|
71
78
|
readonly json?: boolean
|
|
@@ -258,6 +265,40 @@ export function register(program: Command): void {
|
|
|
258
265
|
},
|
|
259
266
|
)
|
|
260
267
|
|
|
268
|
+
tasks
|
|
269
|
+
.command('plan-link')
|
|
270
|
+
.description("Write or correct a task's Plan: line to point at a plan")
|
|
271
|
+
.argument('<task>', 'Task filename stem, as in v28.1-trigger-escalation')
|
|
272
|
+
.argument('<plan>', 'Plan path or its slug, as in dispatch-answer-gate')
|
|
273
|
+
.helpOption('-h, --help', 'Show this help message')
|
|
274
|
+
.option('--json', 'Emit a machine-readable record on stdout')
|
|
275
|
+
.option('--root <path>', 'Board root, defaulting to the main worktree')
|
|
276
|
+
.addHelpText(
|
|
277
|
+
'after',
|
|
278
|
+
[
|
|
279
|
+
'',
|
|
280
|
+
'Exit codes:',
|
|
281
|
+
' 0 the line was added, corrected, or already correct',
|
|
282
|
+
' 1 refused, with the reason on stderr or in the JSON record',
|
|
283
|
+
'',
|
|
284
|
+
'It writes Plan: [<label>](<target>) right after the H1, the same',
|
|
285
|
+
'add/correct/unchanged shape canon tasks pull-request writes with, and',
|
|
286
|
+
'resolves both a bare slug and a board-relative path the way',
|
|
287
|
+
'canon tasks plan-answers does. Safe from a linked worktree, since it',
|
|
288
|
+
'resolves the board root in-process.',
|
|
289
|
+
'',
|
|
290
|
+
'Examples:',
|
|
291
|
+
' canon tasks plan-link v28.1-trigger-escalation dispatch-answer-gate',
|
|
292
|
+
' canon tasks plan-link v28.1-trigger-escalation .canon/plans/feature-dispatch-answer-gate.md --json',
|
|
293
|
+
'',
|
|
294
|
+
].join('\n'),
|
|
295
|
+
)
|
|
296
|
+
.action(
|
|
297
|
+
async (task: string, plan: string, opts: PlanLinkCommandOptions) => {
|
|
298
|
+
process.exitCode = await runPlanLink(task, plan, opts)
|
|
299
|
+
},
|
|
300
|
+
)
|
|
301
|
+
|
|
261
302
|
tasks
|
|
262
303
|
.command('outcome')
|
|
263
304
|
.description('Mark outcomes closed on a task by their position')
|
|
@@ -366,6 +407,18 @@ async function runPullRequest(
|
|
|
366
407
|
return reportPullRequest(outcome, emitJson, root)
|
|
367
408
|
}
|
|
368
409
|
|
|
410
|
+
async function runPlanLink(
|
|
411
|
+
task: string,
|
|
412
|
+
plan: string,
|
|
413
|
+
opts: PlanLinkCommandOptions,
|
|
414
|
+
): Promise<number> {
|
|
415
|
+
const emitJson = opts.json ?? false
|
|
416
|
+
const root = opts.root ?? (await mainWorktreeRoot())
|
|
417
|
+
const outcome = await recordPlan(root, task, plan)
|
|
418
|
+
|
|
419
|
+
return reportPlanLink(outcome, emitJson, root)
|
|
420
|
+
}
|
|
421
|
+
|
|
369
422
|
async function runOutcome(
|
|
370
423
|
task: string | undefined,
|
|
371
424
|
opts: OutcomeCommandOptions,
|
|
@@ -484,6 +537,38 @@ function reportPullRequest(
|
|
|
484
537
|
return 0
|
|
485
538
|
}
|
|
486
539
|
|
|
540
|
+
function reportPlanLink(
|
|
541
|
+
outcome: PlanOutcome,
|
|
542
|
+
emitJson: boolean,
|
|
543
|
+
root: string,
|
|
544
|
+
): number {
|
|
545
|
+
if (!outcome.ok) {
|
|
546
|
+
return reportRecord('canon tasks plan-link', outcome, emitJson, root)
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
if (emitJson) {
|
|
550
|
+
process.stdout.write(
|
|
551
|
+
`${JSON.stringify({
|
|
552
|
+
ok: true,
|
|
553
|
+
root,
|
|
554
|
+
task: outcome.stem,
|
|
555
|
+
path: relative(root, outcome.path),
|
|
556
|
+
plan: outcome.plan,
|
|
557
|
+
action: outcome.action,
|
|
558
|
+
})}\n`,
|
|
559
|
+
)
|
|
560
|
+
return 0
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
intro('canon tasks plan-link')
|
|
564
|
+
logStep(outcome.action === 'unchanged' ? 'Already recorded' : 'Recorded')
|
|
565
|
+
logInfo(`${outcome.stem} names plan ${outcome.plan}`)
|
|
566
|
+
if (outcome.action !== 'unchanged') logAdd(relative(root, outcome.path))
|
|
567
|
+
outro()
|
|
568
|
+
|
|
569
|
+
return 0
|
|
570
|
+
}
|
|
571
|
+
|
|
487
572
|
function reportOutcome(
|
|
488
573
|
outcome: CloseOutcome,
|
|
489
574
|
emitJson: boolean,
|
package/src/tasks/archive.ts
CHANGED
|
@@ -211,10 +211,23 @@ export function readPlanTarget(text: string): string | undefined {
|
|
|
211
211
|
}
|
|
212
212
|
|
|
213
213
|
/**
|
|
214
|
-
*
|
|
215
|
-
*
|
|
216
|
-
*
|
|
217
|
-
*
|
|
214
|
+
* Builds a `Plan:` line as a markdown link whose text and target stay in step,
|
|
215
|
+
* the label taken from the target's filename with its extension dropped.
|
|
216
|
+
* `record.ts` reuses this so a plan-link write and an archive retarget produce
|
|
217
|
+
* one line shape rather than two.
|
|
218
|
+
*/
|
|
219
|
+
export function planLine(target: string): string {
|
|
220
|
+
const name = basename(target)
|
|
221
|
+
const label = name.endsWith('.md') ? name.slice(0, -'.md'.length) : name
|
|
222
|
+
|
|
223
|
+
return `Plan: [${label}](${target})`
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Points the task's `Plan:` line at the plan's new home. The line is matched
|
|
228
|
+
* with the pattern the read above uses, so the archive rewrites exactly the
|
|
229
|
+
* line it parsed and never a second `Plan:` a task displays inside a fenced
|
|
230
|
+
* sample.
|
|
218
231
|
*
|
|
219
232
|
* The replacement is built by a function rather than passed as a string,
|
|
220
233
|
* because `$&` and its siblings are substitution sequences inside a replacement
|
|
@@ -222,10 +235,7 @@ export function readPlanTarget(text: string): string | undefined {
|
|
|
222
235
|
* `.canon/plans/` is gitignored, so nothing recovers the pointer it replaced.
|
|
223
236
|
*/
|
|
224
237
|
export function retargetPlanLine(text: string, target: string): string {
|
|
225
|
-
|
|
226
|
-
const label = name.endsWith('.md') ? name.slice(0, -'.md'.length) : name
|
|
227
|
-
|
|
228
|
-
return text.replace(PLAN_PATTERN, () => `Plan: [${label}](${target})`)
|
|
238
|
+
return text.replace(PLAN_PATTERN, () => planLine(target))
|
|
229
239
|
}
|
|
230
240
|
|
|
231
241
|
/**
|
|
@@ -624,7 +634,7 @@ async function planToArchive(
|
|
|
624
634
|
* deeper than the live pair, so the link is measured between the two
|
|
625
635
|
* destinations rather than written as the `../plans/` the live task carried.
|
|
626
636
|
*/
|
|
627
|
-
function linkTo(taskDir: string, plan: string): string {
|
|
637
|
+
export function linkTo(taskDir: string, plan: string): string {
|
|
628
638
|
return relative(taskDir, plan).split(sep).join('/')
|
|
629
639
|
}
|
|
630
640
|
|
package/src/tasks/record.ts
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
import { existsSync } from 'node:fs'
|
|
2
2
|
import { readFile, writeFile } from 'node:fs/promises'
|
|
3
3
|
import { basename, join, relative } from 'node:path'
|
|
4
|
+
import { planCandidates } from '@/tasks/answers'
|
|
4
5
|
import {
|
|
5
6
|
fenceMask,
|
|
7
|
+
linkTo,
|
|
6
8
|
listTaskStems,
|
|
7
9
|
OUTCOME_PATTERN,
|
|
10
|
+
planLine,
|
|
8
11
|
readPlanTarget,
|
|
9
12
|
tasksDir,
|
|
10
13
|
} from '@/tasks/archive'
|
|
@@ -28,6 +31,7 @@ export const RECORD_REFUSALS = [
|
|
|
28
31
|
'ambiguous',
|
|
29
32
|
'no-outcomes',
|
|
30
33
|
'out-of-range',
|
|
34
|
+
'no-plan',
|
|
31
35
|
'bad-input',
|
|
32
36
|
] as const
|
|
33
37
|
|
|
@@ -56,6 +60,16 @@ export interface PullRequestRecorded {
|
|
|
56
60
|
|
|
57
61
|
export type PullRequestOutcome = PullRequestRecorded | RecordRefused
|
|
58
62
|
|
|
63
|
+
export interface PlanRecorded {
|
|
64
|
+
readonly ok: true
|
|
65
|
+
readonly stem: string
|
|
66
|
+
readonly path: string
|
|
67
|
+
readonly plan: string
|
|
68
|
+
readonly action: LineAction
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export type PlanOutcome = PlanRecorded | RecordRefused
|
|
72
|
+
|
|
59
73
|
export interface OutcomesClosed {
|
|
60
74
|
readonly ok: true
|
|
61
75
|
readonly stem: string
|
|
@@ -127,6 +141,33 @@ function lastOriginLine(lines: readonly string[]): number | undefined {
|
|
|
127
141
|
return found
|
|
128
142
|
}
|
|
129
143
|
|
|
144
|
+
/**
|
|
145
|
+
* Places the `Plan:` line right after the H1, mirroring
|
|
146
|
+
* `writePullRequestLine`'s add/correct/unchanged shape. `Plan:` is the first
|
|
147
|
+
* origin line a task carries, so it anchors on the heading itself rather than
|
|
148
|
+
* on the last origin line above it.
|
|
149
|
+
*/
|
|
150
|
+
export function writePlanLine(
|
|
151
|
+
text: string,
|
|
152
|
+
target: string,
|
|
153
|
+
): { readonly text: string; readonly action: LineAction } {
|
|
154
|
+
const line = planLine(target)
|
|
155
|
+
const lines = text.split('\n')
|
|
156
|
+
const existing = lines.findIndex((entry) => entry.startsWith('Plan:'))
|
|
157
|
+
|
|
158
|
+
if (existing !== -1) {
|
|
159
|
+
if (lines[existing] === line) return { text, action: 'unchanged' }
|
|
160
|
+
lines[existing] = line
|
|
161
|
+
return { text: lines.join('\n'), action: 'corrected' }
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const heading = lines.findIndex((entry) => entry.startsWith('# '))
|
|
165
|
+
if (heading === -1) return { text: `${line}\n${text}`, action: 'added' }
|
|
166
|
+
|
|
167
|
+
lines.splice(heading + 1, 0, '', line)
|
|
168
|
+
return { text: lines.join('\n'), action: 'added' }
|
|
169
|
+
}
|
|
170
|
+
|
|
130
171
|
/**
|
|
131
172
|
* Marks outcomes closed by their 1-based position in the task's outcome list,
|
|
132
173
|
* which is the order the board format writes them. A caller reads the file
|
|
@@ -267,6 +308,42 @@ export async function recordPullRequest(
|
|
|
267
308
|
return { ok: true, stem, path, number, action }
|
|
268
309
|
}
|
|
269
310
|
|
|
311
|
+
/**
|
|
312
|
+
* Records a plan's path on the task it belongs to, as the task's `Plan:` line.
|
|
313
|
+
* `claude-feature` runs this right after the plan file lands, resolving the
|
|
314
|
+
* reference the same two ways `canon tasks plan-answers` does, through
|
|
315
|
+
* `planCandidates`, so a bare slug and a board-relative path both resolve.
|
|
316
|
+
*
|
|
317
|
+
* The task is named directly rather than through `RecordSelector`'s `plan`
|
|
318
|
+
* kind: every caller already holds the stem, since resolving the task is what
|
|
319
|
+
* put it in a position to write the plan's path in the first place.
|
|
320
|
+
*/
|
|
321
|
+
export async function recordPlan(
|
|
322
|
+
root: string,
|
|
323
|
+
stem: string,
|
|
324
|
+
reference: string,
|
|
325
|
+
): Promise<PlanOutcome> {
|
|
326
|
+
const opened = await openTask(root, { kind: 'stem', stem })
|
|
327
|
+
if ('ok' in opened) return opened
|
|
328
|
+
|
|
329
|
+
const { path } = opened
|
|
330
|
+
const dir = tasksDir(root)
|
|
331
|
+
const candidates = planCandidates(root, reference)
|
|
332
|
+
const plan = candidates.find((candidate) => existsSync(candidate))
|
|
333
|
+
|
|
334
|
+
if (!plan) {
|
|
335
|
+
const looked = candidates.map((entry) => relative(root, entry)).join(' or ')
|
|
336
|
+
return refuse('no-plan', `No plan at ${looked}.`, [reference])
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
const target = linkTo(dir, plan)
|
|
340
|
+
const { text, action } = writePlanLine(await readFile(path, 'utf8'), target)
|
|
341
|
+
|
|
342
|
+
if (action !== 'unchanged') await writeFile(path, text)
|
|
343
|
+
|
|
344
|
+
return { ok: true, stem: opened.stem, path, plan: target, action }
|
|
345
|
+
}
|
|
346
|
+
|
|
270
347
|
/**
|
|
271
348
|
* Marks the named outcomes `[x]` in place. `claude-docs` runs this against a
|
|
272
349
|
* board it can read and cannot edit from a linked worktree, and the positions
|
package/standards/tasks.md
CHANGED
|
@@ -219,6 +219,8 @@ A project that archived plans before the folder nested under `.canon/plans/` hol
|
|
|
219
219
|
|
|
220
220
|
One plan per task. A plan cited by two tasks is a misfile rather than a shape to design for, which is why the sweep counts citations before archiving: the count is a guard against the misfile stranding a pointer, not support for the shape.
|
|
221
221
|
|
|
222
|
+
`canon tasks plan-link <task> <plan>` writes or corrects the `Plan:` line, mirroring how `canon tasks pull-request` writes its own. `claude-feature` calls it right after the plan file lands, when an existing task names the feature, so the line is a mechanical write rather than hand-edited markdown.
|
|
223
|
+
|
|
222
224
|
`Groundwork:` points at `../groundwork/<slug>/`, the folder `claude-groundwork` fills. It names the surface it points at the way `Plan:` does. Use this key alone. `Research record` and `Decision record` are earlier spellings of the same thing and both convert to it.
|
|
223
225
|
|
|
224
226
|
`Intake:` points at `../intake/<slug>/`, the folder an intake pass fills. Use it rather than `Groundwork:`, because a groundwork track measures one question in depth while an intake dispositions many across a tree, and one key covering both loses which kind of pass produced the task. The line names the folder rather than an item inside it. A task routinely promotes several items at once, so an anchored line would name one and drop the rest, and the item numbers belong in that task's `## Findings`.
|