@erclx/canon 4.69.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 (37) 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 +2 -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/tasks.md +46 -3
  23. package/docs/workflow/ai-workflow.md +19 -19
  24. package/package.json +3 -2
  25. package/scripts/core/regen-web-previews.ts +94 -0
  26. package/src/claude/cases/workflow.ts +4 -4
  27. package/src/commands/demo.ts +1 -1
  28. package/src/commands/pr.ts +130 -1
  29. package/src/commands/tasks.ts +178 -1
  30. package/src/demo/beats.ts +1 -1
  31. package/src/migrate/skill-names.ts +15 -1
  32. package/src/pr/review-scope.ts +177 -0
  33. package/src/tasks/archive.ts +206 -3
  34. package/src/tasks/label.ts +14 -6
  35. package/src/tasks/validate.ts +22 -0
  36. package/standards/plan.md +1 -1
  37. 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/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.