@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.
- package/claude/.claude-plugin/plugin.json +1 -1
- package/claude/skills/auto-ship/SKILL.md +1 -1
- package/claude/skills/draft-and-pick/REQUIREMENT.md +1 -1
- package/claude/skills/draft-and-pick/SKILL.md +1 -1
- package/claude/skills/{canon-screencast → draft-screencast}/REQUIREMENT.md +4 -4
- package/claude/skills/{canon-screencast → draft-screencast}/SKILL.md +4 -4
- package/claude/skills/{canon-slides-draft → draft-slides}/REQUIREMENT.md +3 -3
- package/claude/skills/{canon-slides-draft → draft-slides}/SKILL.md +2 -2
- package/claude/skills/{canon-frames-read → read-frames}/REQUIREMENT.md +2 -2
- package/claude/skills/{canon-frames-read → read-frames}/SKILL.md +3 -3
- package/claude/skills/{canon-record → record-screencast}/REQUIREMENT.md +5 -5
- package/claude/skills/{canon-record → record-screencast}/SKILL.md +4 -4
- package/claude/skills/review-pr/SKILL.md +55 -7
- package/claude/skills/role-orchestrator/SKILL.md +2 -1
- package/claude/skills/role-orchestrator/references/orchestrator-poll.md +7 -3
- package/claude/skills/role-orchestrator/scripts/poll.sh +79 -34
- package/claude/skills/role-worker/SKILL.md +2 -1
- package/docs/agents/commands.md +2 -0
- package/docs/agents/demo.md +3 -3
- package/docs/agents/index.md +1 -1
- package/docs/agents/pr-reads.md +47 -12
- package/docs/agents/tasks.md +46 -3
- package/docs/workflow/ai-workflow.md +19 -19
- package/package.json +3 -2
- package/scripts/core/regen-web-previews.ts +94 -0
- package/src/claude/cases/workflow.ts +4 -4
- package/src/commands/demo.ts +1 -1
- package/src/commands/pr.ts +130 -1
- package/src/commands/tasks.ts +178 -1
- package/src/demo/beats.ts +1 -1
- package/src/migrate/skill-names.ts +15 -1
- package/src/pr/review-scope.ts +177 -0
- package/src/tasks/archive.ts +206 -3
- package/src/tasks/label.ts +14 -6
- package/src/tasks/validate.ts +22 -0
- package/standards/plan.md +1 -1
- package/standards/tasks.md +15 -1
package/src/tasks/archive.ts
CHANGED
|
@@ -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
|
-
|
|
485
|
-
|
|
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
|
-
):
|
|
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
|
+
}
|
package/src/tasks/label.ts
CHANGED
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
import { existsSync } from 'node:fs'
|
|
2
2
|
import { relative } from 'node:path'
|
|
3
|
-
import {
|
|
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/`
|
|
72
|
-
* live board is blind to every label
|
|
73
|
-
* let two sessions hand out the same label within
|
|
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
|
|
94
|
-
|
|
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
|
package/src/tasks/validate.ts
CHANGED
|
@@ -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
|
package/standards/tasks.md
CHANGED
|
@@ -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
|
|
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.
|