@erclx/canon 4.68.0 → 4.70.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.
Files changed (49) hide show
  1. package/claude/.claude-plugin/plugin.json +1 -1
  2. package/claude/skills/auto-ship/SKILL.md +1 -1
  3. package/claude/skills/draft-and-pick/REQUIREMENT.md +1 -1
  4. package/claude/skills/draft-and-pick/SKILL.md +1 -1
  5. package/claude/skills/{canon-screencast → draft-screencast}/REQUIREMENT.md +4 -4
  6. package/claude/skills/{canon-screencast → draft-screencast}/SKILL.md +4 -4
  7. package/claude/skills/{canon-slides-draft → draft-slides}/REQUIREMENT.md +3 -3
  8. package/claude/skills/{canon-slides-draft → draft-slides}/SKILL.md +2 -2
  9. package/claude/skills/{canon-frames-read → read-frames}/REQUIREMENT.md +2 -2
  10. package/claude/skills/{canon-frames-read → read-frames}/SKILL.md +3 -3
  11. package/claude/skills/{canon-record → record-screencast}/REQUIREMENT.md +5 -5
  12. package/claude/skills/{canon-record → record-screencast}/SKILL.md +4 -4
  13. package/claude/skills/review-pr/SKILL.md +55 -7
  14. package/claude/skills/role-orchestrator/SKILL.md +2 -1
  15. package/claude/skills/role-orchestrator/references/orchestrator-poll.md +7 -3
  16. package/claude/skills/role-orchestrator/scripts/poll.sh +79 -34
  17. package/claude/skills/role-worker/SKILL.md +2 -1
  18. package/docs/agents/commands.md +3 -0
  19. package/docs/agents/demo.md +3 -3
  20. package/docs/agents/index.md +1 -1
  21. package/docs/agents/pr-reads.md +47 -12
  22. package/docs/agents/sandbox.md +13 -10
  23. package/docs/agents/tasks.md +46 -3
  24. package/docs/workflow/ai-workflow.md +19 -19
  25. package/package.json +3 -2
  26. package/scripts/core/regen-web-previews.ts +94 -0
  27. package/scripts/lib/sandbox-dispatch.sh +8 -0
  28. package/src/claude/cases/workflow.ts +4 -4
  29. package/src/claude/plugin-update.ts +48 -0
  30. package/src/commands/claude.ts +281 -1
  31. package/src/commands/demo.ts +1 -1
  32. package/src/commands/feedback.ts +15 -5
  33. package/src/commands/gate.ts +3 -1
  34. package/src/commands/pr.ts +130 -1
  35. package/src/commands/sandbox.ts +13 -4
  36. package/src/commands/tasks.ts +178 -1
  37. package/src/demo/beats.ts +1 -1
  38. package/src/design/components.ts +12 -0
  39. package/src/gate/measures.ts +47 -1
  40. package/src/migrate/skill-names.ts +15 -1
  41. package/src/pr/review-scope.ts +177 -0
  42. package/src/sandbox/expect.ts +26 -1
  43. package/src/tasks/archive.ts +206 -3
  44. package/src/tasks/label.ts +14 -6
  45. package/src/tasks/validate.ts +22 -0
  46. package/src/teach/nav.ts +97 -3
  47. package/standards/glossary.md +8 -0
  48. package/standards/plan.md +1 -1
  49. package/standards/tasks.md +15 -1
@@ -8,6 +8,8 @@ import { recordDir, recordDirs } from '@/record-root'
8
8
  const TASKS = 'tasks'
9
9
  const PLANS = 'plans'
10
10
  const ARCHIVE = 'archive'
11
+ const DECLINED = 'declined'
12
+ const BACKLOG = 'backlog.md'
11
13
 
12
14
  /**
13
15
  * Siblings that sit on the board without being tasks: the generated index, the
@@ -48,6 +50,21 @@ export const ARCHIVE_REFUSALS = [
48
50
 
49
51
  export type ArchiveRefusal = (typeof ARCHIVE_REFUSALS)[number]
50
52
 
53
+ /**
54
+ * Kept apart from `ARCHIVE_REFUSALS` on purpose. `archive` and `decline`
55
+ * answer different questions, shipped versus decided-against, and a shared
56
+ * refusal set would let one archive a task that cannot yet ship or decline
57
+ * one that already has.
58
+ */
59
+ export const DECLINE_REFUSALS = [
60
+ 'no-board',
61
+ 'no-match',
62
+ 'ambiguous',
63
+ 'bad-input',
64
+ ] as const
65
+
66
+ export type DeclineRefusal = (typeof DECLINE_REFUSALS)[number]
67
+
51
68
  export type TaskSelector =
52
69
  | { readonly kind: 'stem'; readonly stem: string }
53
70
  | { readonly kind: 'pull-request'; readonly number: number }
@@ -80,6 +97,27 @@ export interface ArchiveRefused {
80
97
 
81
98
  export type ArchiveOutcome = ArchiveSuccess | ArchiveRefused
82
99
 
100
+ export interface DeclineSuccess {
101
+ readonly ok: true
102
+ readonly stem: string
103
+ readonly from: string
104
+ readonly to: string
105
+ readonly priorityRowRemoved: boolean
106
+ readonly backlogRowRemoved: boolean
107
+ readonly indexRegenerated: boolean
108
+ /** Undefined when the task cited no live plan, or when another task still holds it. */
109
+ readonly plan: PlanMove | undefined
110
+ }
111
+
112
+ export interface DeclineRefused {
113
+ readonly ok: false
114
+ readonly reason: DeclineRefusal
115
+ readonly message: string
116
+ readonly detail: readonly string[]
117
+ }
118
+
119
+ export type DeclineOutcome = DeclineSuccess | DeclineRefused
120
+
83
121
  export interface TaskOutcomes {
84
122
  readonly open: readonly string[]
85
123
  readonly closed: readonly string[]
@@ -94,6 +132,10 @@ export function archiveDir(root: string): string {
94
132
  return recordDir(root, TASKS, ARCHIVE)
95
133
  }
96
134
 
135
+ export function declinedDir(root: string): string {
136
+ return recordDir(root, TASKS, DECLINED)
137
+ }
138
+
97
139
  export const OUTCOME_PATTERN = /^- \[([ xX])\] ?(.*)$/
98
140
 
99
141
  /**
@@ -238,6 +280,62 @@ export function retargetPlanLine(text: string, target: string): string {
238
280
  return text.replace(PLAN_PATTERN, () => planLine(target))
239
281
  }
240
282
 
283
+ /**
284
+ * Builds the `Declined:` line recording why a task was decided against and by
285
+ * whom. Free prose after the colon, since the line names no file to link,
286
+ * unlike `planLine`.
287
+ */
288
+ export function declineLine(reason: string, by: string, date: string): string {
289
+ return `Declined: ${reason}, ${by} on ${date}`
290
+ }
291
+
292
+ /**
293
+ * Lines a `Declined:` line anchors after, mirroring `record.ts`'s
294
+ * `ORIGIN_PREFIXES` with `Pull request:` folded in, since a decline can follow
295
+ * a pull request that never merged.
296
+ */
297
+ const DECLINE_ANCHOR_PREFIXES = [
298
+ 'Plan:',
299
+ 'Groundwork:',
300
+ 'Intake:',
301
+ 'Issue:',
302
+ 'Pull request:',
303
+ ] as const
304
+
305
+ function lastAnchorLine(lines: readonly string[]): number | undefined {
306
+ let found: number | undefined
307
+
308
+ for (const [index, line] of lines.entries()) {
309
+ if (DECLINE_ANCHOR_PREFIXES.some((prefix) => line.startsWith(prefix))) {
310
+ found = index
311
+ }
312
+ }
313
+
314
+ return found
315
+ }
316
+
317
+ /**
318
+ * Places the `Declined:` line after the origin lines a task carries, the same
319
+ * scan-and-anchor shape `writePullRequestLine` carries. A decline runs once
320
+ * per task, so there is no existing line to correct, unlike the
321
+ * add/correct/unchanged shape a write safe to run twice needs.
322
+ */
323
+ function insertDeclinedLine(text: string, line: string): string {
324
+ const lines = text.split('\n')
325
+ const anchor = lastAnchorLine(lines)
326
+
327
+ if (anchor !== undefined) {
328
+ lines.splice(anchor + 1, 0, line)
329
+ return lines.join('\n')
330
+ }
331
+
332
+ const heading = lines.findIndex((entry) => entry.startsWith('# '))
333
+ if (heading === -1) return `${line}\n${text}`
334
+
335
+ lines.splice(heading + 1, 0, '', line)
336
+ return lines.join('\n')
337
+ }
338
+
241
339
  /**
242
340
  * Drops the archived task's row from the ordering table. Rows are matched by
243
341
  * the link they carry rather than by a line pattern, because a row holds links
@@ -267,6 +365,27 @@ function isRowFor(line: string, target: string): boolean {
267
365
  return first !== undefined && first.includes(target)
268
366
  }
269
367
 
368
+ /**
369
+ * Drops the declined task's bullet from the backlog. A backlog line is a
370
+ * bullet carrying a link rather than a table row, so the match is a bullet
371
+ * prefix and the link target rather than `isRowFor`'s pipe-delimited cell.
372
+ */
373
+ export function removeBacklogRow(
374
+ text: string,
375
+ stem: string,
376
+ ): { readonly text: string; readonly removed: boolean } {
377
+ const target = `](${stem}.md)`
378
+ const lines = text.split('\n')
379
+ const kept = lines.filter((line) => !isBulletFor(line, target))
380
+
381
+ return { text: kept.join('\n'), removed: kept.length !== lines.length }
382
+ }
383
+
384
+ function isBulletFor(line: string, target: string): boolean {
385
+ const trimmed = line.trimStart()
386
+ return /^[-*]\s/.test(trimmed) && trimmed.includes(target)
387
+ }
388
+
270
389
  /**
271
390
  * Resolves the `Plan:` target against the board and against the project root
272
391
  * both, which is how `docs-fold` reads the same line. It accepts `../plans/x.md`
@@ -481,11 +600,20 @@ async function matchByPullRequest(
481
600
  return read.filter((entry) => entry.number === number).map(({ stem }) => stem)
482
601
  }
483
602
 
484
- function refuse(
485
- reason: ArchiveRefusal,
603
+ /**
604
+ * Generic over the refusal vocabulary so `archiveTask` and `declineTask` share
605
+ * one builder despite answering with two disjoint reason sets.
606
+ */
607
+ function refuse<Reason extends string>(
608
+ reason: Reason,
486
609
  message: string,
487
610
  detail: readonly string[] = [],
488
- ): ArchiveRefused {
611
+ ): {
612
+ readonly ok: false
613
+ readonly reason: Reason
614
+ readonly message: string
615
+ readonly detail: readonly string[]
616
+ } {
489
617
  return { ok: false, reason, message, detail }
490
618
  }
491
619
 
@@ -690,3 +818,78 @@ async function clearPriorityRow(dir: string, stem: string): Promise<boolean> {
690
818
 
691
819
  return removed
692
820
  }
821
+
822
+ async function clearBacklogRow(dir: string, stem: string): Promise<boolean> {
823
+ const path = join(dir, BACKLOG)
824
+ if (!existsSync(path)) return false
825
+
826
+ const { text, removed } = removeBacklogRow(await readFile(path, 'utf8'), stem)
827
+ if (removed) await writeFile(path, text)
828
+
829
+ return removed
830
+ }
831
+
832
+ /**
833
+ * Declines one task as a single unit: the move, the ordering-or-backlog row
834
+ * removal, and the index regen. Unlike `archiveTask`, it carries no
835
+ * outcome-state gate, since a task decided against can sit at any outcome
836
+ * state, and the two never share a refusal set for the reason
837
+ * `DECLINE_REFUSALS` states.
838
+ */
839
+ export async function declineTask(
840
+ root: string,
841
+ stem: string,
842
+ reason: string,
843
+ by: string,
844
+ ): Promise<DeclineOutcome> {
845
+ const dir = tasksDir(root)
846
+
847
+ if (!existsSync(dir)) {
848
+ return refuse('no-board', `No task board at ${relative(root, dir)}.`)
849
+ }
850
+
851
+ const stems = await listTaskStems(dir)
852
+ if (!stems.includes(stem)) {
853
+ const unmatched = describeUnmatchedStem(stems, stem)
854
+ return refuse(unmatched.reason, unmatched.message, unmatched.detail)
855
+ }
856
+
857
+ const from = join(dir, `${stem}.md`)
858
+ const text = await readFile(from, 'utf8')
859
+
860
+ const plan = await planToArchive(dir, root, stem, text)
861
+ const destination = declinedDir(root)
862
+ const to = join(destination, `${stem}.md`)
863
+
864
+ // The plan moves first, the same order archiveTask uses, so the retarget
865
+ // written below describes a file already at its new path.
866
+ if (plan) {
867
+ await mkdir(dirname(plan.to), { recursive: true })
868
+ await rename(plan.from, plan.to)
869
+ }
870
+
871
+ await mkdir(destination, { recursive: true })
872
+ await rename(from, to)
873
+
874
+ const date = new Date().toISOString().slice(0, 10)
875
+ const declined = insertDeclinedLine(text, declineLine(reason, by, date))
876
+ const final = plan
877
+ ? retargetPlanLine(declined, linkTo(destination, plan.to))
878
+ : declined
879
+ await writeFile(to, final)
880
+
881
+ const priorityRowRemoved = await clearPriorityRow(dir, stem)
882
+ const backlogRowRemoved = await clearBacklogRow(dir, stem)
883
+ const regen = await regenOne(dir, { dryRun: false })
884
+
885
+ return {
886
+ ok: true,
887
+ stem,
888
+ from,
889
+ to,
890
+ priorityRowRemoved,
891
+ backlogRowRemoved,
892
+ indexRegenerated: regen.action === 'written',
893
+ plan,
894
+ }
895
+ }
@@ -1,6 +1,11 @@
1
1
  import { existsSync } from 'node:fs'
2
2
  import { relative } from 'node:path'
3
- import { archiveDir, listTaskStems, tasksDir } from '@/tasks/archive'
3
+ import {
4
+ archiveDir,
5
+ declinedDir,
6
+ listTaskStems,
7
+ tasksDir,
8
+ } from '@/tasks/archive'
4
9
 
5
10
  /** Every label in the corpus today stops here before rolling to the next major. */
6
11
  const MINOR_ROLLOVER = 9
@@ -68,9 +73,10 @@ function next(label: Label): Label {
68
73
 
69
74
  /**
70
75
  * Reports the next unused phase label, read off the true maximum across
71
- * `.canon/tasks/` and its `archive/` sibling together. A scan confined to the
72
- * live board is blind to every label the archive already spent, which is what
73
- * let two sessions hand out the same label within minutes of each other.
76
+ * `.canon/tasks/` and its `archive/` and `declined/` siblings together. A scan
77
+ * confined to the live board is blind to every label a settled folder already
78
+ * spent, which is what let two sessions hand out the same label within
79
+ * minutes of each other.
74
80
  *
75
81
  * It reports and never writes. Two sessions calling it in the same second can
76
82
  * still take the same answer, since the board is gitignored files rather than
@@ -90,8 +96,10 @@ export async function nextLabel(root: string): Promise<LabelOutcome> {
90
96
  }
91
97
  }
92
98
 
93
- const archive = archiveDir(root)
94
- const dirs = existsSync(archive) ? [dir, archive] : [dir]
99
+ const settled = [archiveDir(root), declinedDir(root)].filter((candidate) =>
100
+ existsSync(candidate),
101
+ )
102
+ const dirs = [dir, ...settled]
95
103
  const stems = (await Promise.all(dirs.map((d) => listTaskStems(d)))).flat()
96
104
 
97
105
  const highest = stems
@@ -3,6 +3,7 @@ import { readdir, readFile } from 'node:fs/promises'
3
3
  import { join, resolve } from 'node:path'
4
4
  import {
5
5
  archiveDir,
6
+ declinedDir,
6
7
  isReservedStem,
7
8
  readOutcomes,
8
9
  readPlanTarget,
@@ -47,6 +48,7 @@ export const FINDING_KINDS = [
47
48
  'touches-collided',
48
49
  'blocker-settled',
49
50
  'blocker-unresolved',
51
+ 'blocker-declined',
50
52
  ] as const
51
53
 
52
54
  export type FindingKind = (typeof FINDING_KINDS)[number]
@@ -124,6 +126,7 @@ export interface ValidateReport {
124
126
  readonly rows: number
125
127
  readonly backlog: number
126
128
  readonly tasks: number
129
+ readonly declined: number
127
130
  readonly findings: readonly Finding[]
128
131
  readonly untested: readonly Untested[]
129
132
  readonly claims: readonly FolderClaim[]
@@ -970,6 +973,20 @@ async function checkCitedTask(
970
973
  return settled(group, subject, `waits on ${cited}, which is archived.`)
971
974
  }
972
975
 
976
+ if (existsSync(join(declinedDir(root), `${cited}.md`))) {
977
+ return {
978
+ findings: [
979
+ {
980
+ kind: 'blocker-declined',
981
+ group,
982
+ subject,
983
+ message: `waits on ${cited}, which was declined.`,
984
+ },
985
+ ],
986
+ untested: [],
987
+ }
988
+ }
989
+
973
990
  return {
974
991
  findings: [
975
992
  {
@@ -1158,6 +1175,10 @@ export async function validateBoard(
1158
1175
  : []
1159
1176
 
1160
1177
  const stems = await listTaskStems(dir)
1178
+ const declinedPath = declinedDir(root)
1179
+ const declined = existsSync(declinedPath)
1180
+ ? await listTaskStems(declinedPath)
1181
+ : []
1161
1182
  const parked = await checkParked(rows, root, trunk)
1162
1183
 
1163
1184
  const findings = [
@@ -1175,6 +1196,7 @@ export async function validateBoard(
1175
1196
  rows: rows.length,
1176
1197
  backlog: backlog.length,
1177
1198
  tasks: stems.length,
1199
+ declined: declined.length,
1178
1200
  findings,
1179
1201
  untested: parked.untested,
1180
1202
  claims: checkFolderClaims(rows, root),
package/src/teach/nav.ts CHANGED
@@ -109,6 +109,17 @@ const GLOSSARY_FILTER_SCRIPT = `<script>
109
109
  var input = document.getElementById("gfilter");
110
110
  var list = document.getElementById("gloss");
111
111
  if (!input || !list) return;
112
+ function updateGroups() {
113
+ list.querySelectorAll(".gloss-group").forEach(function (heading) {
114
+ var el = heading.nextElementSibling;
115
+ var any = false;
116
+ while (el && !el.classList.contains("gloss-group")) {
117
+ if (el.style.display !== "none") any = true;
118
+ el = el.nextElementSibling;
119
+ }
120
+ heading.style.display = any ? "" : "none";
121
+ });
122
+ }
112
123
  input.addEventListener("input", function () {
113
124
  var q = input.value.toLowerCase();
114
125
  var n = 0;
@@ -118,6 +129,7 @@ const GLOSSARY_FILTER_SCRIPT = `<script>
118
129
  if (match) n++;
119
130
  });
120
131
  list.classList.toggle("none", n === 0);
132
+ updateGroups();
121
133
  });
122
134
  var clear = list.querySelector(".clear");
123
135
  if (clear) {
@@ -457,8 +469,90 @@ function renderGlossaryEntry(entry: string): string {
457
469
  return `<div class="gterm"><b>${escapeHtml(term)}</b><span>${escapeHtml(definition)}</span></div>`
458
470
  }
459
471
 
460
- function renderGlossarySection(entries: readonly string[]): string {
461
- const rendered = entries.map(renderGlossaryEntry).join('')
472
+ const FIRST_SEEN_PATTERN = / First seen in (.+)\.$/
473
+
474
+ /**
475
+ * The lesson or reference page an entry's own "First seen in" sentence
476
+ * names, absent when the entry predates that citation convention.
477
+ */
478
+ function firstSeenFile(entry: string): string | undefined {
479
+ return FIRST_SEEN_PATTERN.exec(entry)?.[1]
480
+ }
481
+
482
+ /**
483
+ * `firstSeenFile` names a page free-form, per the `--first-seen` flag it
484
+ * comes from, so it may carry a directory prefix a lesson's own `file` does
485
+ * not. Comparing basenames is what keeps `lessons/0001-x.html` and
486
+ * `0001-x.html` resolving to the same lesson without a suffix match risking
487
+ * a false hit across two differently-prefixed filenames.
488
+ */
489
+ function matchingLesson(
490
+ file: string,
491
+ metas: readonly LessonMeta[],
492
+ ): LessonMeta | undefined {
493
+ const basename = file.split('/').pop()
494
+ return metas.find((meta) => meta.file === basename)
495
+ }
496
+
497
+ interface GlossaryGroup {
498
+ readonly heading: string
499
+ readonly entries: readonly string[]
500
+ }
501
+
502
+ const OTHER_TERMS_HEADING = 'Other terms'
503
+
504
+ /**
505
+ * Groups already-alphabetical glossary entries by the lesson their own
506
+ * "First seen in" sentence names, in lesson order. An entry naming a
507
+ * reference page instead, or carrying no citation at all, cannot be
508
+ * attributed to a lesson and trails in its own group, keeping the
509
+ * alphabetical order the source entries already carry.
510
+ */
511
+ function groupGlossaryEntries(
512
+ entries: readonly string[],
513
+ metas: readonly LessonMeta[],
514
+ ): readonly GlossaryGroup[] {
515
+ const byLesson = new Map<string, string[]>()
516
+ const other: string[] = []
517
+
518
+ for (const entry of entries) {
519
+ const file = firstSeenFile(entry)
520
+ const lesson = file ? matchingLesson(file, metas) : undefined
521
+
522
+ if (lesson) {
523
+ const list = byLesson.get(lesson.file) ?? []
524
+ list.push(entry)
525
+ byLesson.set(lesson.file, list)
526
+ } else {
527
+ other.push(entry)
528
+ }
529
+ }
530
+
531
+ const groups: GlossaryGroup[] = []
532
+ for (const meta of metas) {
533
+ const list = byLesson.get(meta.file)
534
+ if (list) groups.push({ heading: meta.title, entries: list })
535
+ }
536
+ if (other.length > 0) {
537
+ groups.push({ heading: OTHER_TERMS_HEADING, entries: other })
538
+ }
539
+
540
+ return groups
541
+ }
542
+
543
+ function renderGlossaryGroup(group: GlossaryGroup): string {
544
+ const entries = group.entries.map(renderGlossaryEntry).join('')
545
+
546
+ return `<h3 class="gloss-group">${escapeHtml(group.heading)}</h3>${entries}`
547
+ }
548
+
549
+ function renderGlossarySection(
550
+ entries: readonly string[],
551
+ metas: readonly LessonMeta[],
552
+ ): string {
553
+ const rendered = groupGlossaryEntries(entries, metas)
554
+ .map(renderGlossaryGroup)
555
+ .join('')
462
556
 
463
557
  return `<h2>Glossary <span class="count">${entries.length}</span></h2>
464
558
  <input class="filter" type="search" id="gfilter" aria-label="Filter glossary terms" aria-controls="gloss" placeholder="term">
@@ -610,7 +704,7 @@ async function renderContentsPage(
610
704
  referenceRows
611
705
  ? `<h2>Reference pages</h2>\n<ul class="toc">${referenceRows}</ul>`
612
706
  : '',
613
- renderGlossarySection(detail.glossary),
707
+ renderGlossarySection(detail.glossary, metas),
614
708
  ]
615
709
  .filter((section) => section !== '')
616
710
  .join('\n\n')
@@ -58,6 +58,14 @@ A glossary failing these is non-conforming even when it satisfies every shape ru
58
58
  - Name each category so a reader picks it from the term alone. A category a reader cannot predict makes the grouping a second thing to search.
59
59
  - State a departure from any rule above in the file itself, naming what it departs from and why. A glossary serving no single body of material is the case that produces one, since a term drawn from everywhere has no first appearance to name.
60
60
 
61
+ ## Rendered grouping
62
+
63
+ Applies to a rendered glossary page, never to the source file above, which stays the flat alphabetical list the `## Grouping` rules above govern.
64
+
65
+ - Group a rendered glossary by the lesson its own "First seen in" citation names, ordered by lesson order, under a sub-heading naming the lesson's title rather than its filename.
66
+ - Trail with an "Other terms" group holding any entry the citation cannot attribute to a lesson, whether it names a reference page instead or carries no citation at all. Keep it in the alphabetical order the source file already carries.
67
+ - Keep the workspace-wide term filter matching against every group, and drop a group's own heading once filtering leaves nothing under it.
68
+
61
69
  ## Template
62
70
 
63
71
  ```markdown
package/standards/plan.md CHANGED
@@ -124,7 +124,7 @@ This contract inverts the one an intake folder keeps, where an empty slot means
124
124
  - Write the plan before implementation starts, and treat it as the scope of the run that executes it.
125
125
  - Keep every plan at one root. A plan copied into each parallel working tree forks, and the copies answer the same question differently.
126
126
  - Amend the plan in place when a decision changes mid-flight. Do not append a second passage narrating the change, which leaves a reader to work out which of two answers is current. An execution-time deviation from a suggestion is one such amendment, and the contract above fixes which line takes it.
127
- - Move the plan to `.canon/plans/archive/` when the work it describes ships. Never delete it, because the plan is where the rejected alternative is written down and nothing else records it. The archive nests inside `.canon/plans/` rather than sitting beside it as `.claude/plans-archive/`, so an older layout is a flat sibling by that name and a current one is not.
127
+ - Move the plan to `.canon/plans/archive/` when the work it describes ships or is declined. Never delete it, because the plan is where the rejected alternative is written down and nothing else records it. The archive nests inside `.canon/plans/` rather than sitting beside it as `.claude/plans-archive/`, so an older layout is a flat sibling by that name and a current one is not.
128
128
  - Write the plan in the same session that opens the task it serves. The session executing it later inherits reasoning it would otherwise re-derive.
129
129
 
130
130
  ## Anti-patterns
@@ -31,6 +31,8 @@ Does not govern:
31
31
  ├── priority.md ← hand-maintained execution order
32
32
  ├── backlog.md ← unordered, what is not being scheduled
33
33
  ├── session-<slug>.md ← optional, what a compaction is about to destroy
34
+ ├── archive/ ← shipped tasks, moved by canon tasks archive
35
+ ├── declined/ ← decided-against tasks, moved by canon tasks decline
34
36
  ├── v09.0-sync-paths.md # canon-allow-reference: illustrates the vXX.Y-slug filename this section defines
35
37
  └── v13.0-toolkit-drift.md # canon-allow-reference: illustrates the vXX.Y-slug filename this section defines
36
38
  ```
@@ -207,7 +209,7 @@ Every task names where it came from, through a `Plan:`, `Groundwork:`, `Intake:`
207
209
 
208
210
  A task with no origin is either lost context or work nobody decided to do. The invariant runs both ways, and the second direction is the one that bites: a groundwork track, an intake folder, or an open issue that no task points at is work already decided and on its way to being forgotten.
209
211
 
210
- An intake folder answers that direction at folder scope rather than item scope, since one dump dispositions many items and most close without ever becoming a task. What names a folder is every item answered and no task citing it, on the board or in the archive. That is a dump nobody acted on. Counting the archive beside the board is what separates it from one already promoted and shipped, and a check reading the board alone calls every finished folder abandoned.
212
+ An intake folder answers that direction at folder scope rather than item scope, since one dump dispositions many items and most close without ever becoming a task. What names a folder is every item answered and no task citing it, on the board or in the archive. That is a dump nobody acted on. Counting the archive and declined folder beside the board is what separates it from one already promoted and settled, and a check reading the board alone calls every finished folder abandoned.
211
213
 
212
214
  `Plan:`, `Groundwork:`, and `Intake:` name their target as a markdown link whose text is the file or folder stem, so the line resolves on a ctrl-click the way `priority.md` rows already do. Write the path relative to `.canon/tasks/`, which makes it `../plans/`, `../groundwork/`, and `../intake/`. A path written from the project root renders as a link and resolves to nothing in an editor rooted at the project. `Issue:` stays a bare `#NNN`, since an issue number is not a path and a full URL would write the remote into a gitignored file.
213
215
 
@@ -258,3 +260,15 @@ Archiving a task archives its plan alongside it, when the closing task is that p
258
260
  One act rather than two is what makes the pair safe. The merge is the event that settles a plan, and a `post-merge` hook reaching the archive with nobody watching cannot act on a warning, so a second call after it would be a second failure point leaving the task archived and the plan live.
259
261
 
260
262
  A task with an open outcome stays on the board, and so does its plan. Close it, or cut it from the task when the work is being abandoned, so what was dropped is recorded rather than inferred from an archived file. Cutting means striking the outcome's body: `- ~~<outcome>~~ <why>`. `archiveTask` reads a struck body as cut whatever its checkbox holds, so a task carrying only cut outcomes still archives and a mixed task carries both counts on its success record.
263
+
264
+ ## Declining
265
+
266
+ A task decided against moves to `.canon/tasks/declined/` rather than `.canon/tasks/archive/`. The two folders answer different questions: archive means the work shipped, declined means somebody decided against doing it. Neither reading fits a task that is merely unscheduled, which stays on `backlog.md` rather than moving anywhere, since nobody has decided against it and it may still rise when the board has room.
267
+
268
+ `canon tasks decline` carries no outcome-state gate. A task can be decided against at any outcome state, open outcomes included, which is what separates its refusal set from archive's: the two never share one, since a shared gate would let one archive a task that cannot yet ship or decline one that already has.
269
+
270
+ The decision is recorded on the task itself with a `Declined:` line, in the `Plan:`/`Pull request:` family: `Declined: <reason>, <who> on <YYYY-MM-DD>`. It anchors the same way `Pull request:` does, after the last origin line the task carries. The line is free prose after the colon, since it names no file to link.
271
+
272
+ Declining a task moves its plan alongside it the same way archiving does, when the declining task is that plan's last live citation. A plan several tasks share stays where it is, and a declined task's plan lands in `.canon/plans/archive/` indistinguishable from a shipped one by folder alone. The task file under `.canon/tasks/declined/` is what records which it was.
273
+
274
+ The move clears whichever of `priority.md` or `backlog.md` holds the task's row, since a decided-against task most often comes off the backlog before anyone plans it, but a row already promoted to the ordering file is cleared the same way archive clears it.