@erclx/canon 4.7.0 → 4.8.1
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/README.md +55 -41
- package/claude/.claude-plugin/plugin.json +1 -1
- package/claude/skills/claude-docs/REQUIREMENT.md +2 -2
- package/claude/skills/claude-docs/SKILL.md +7 -36
- package/claude/skills/claude-orchestrate/references/orchestrator-dispatch.md +13 -8
- package/claude/skills/claude-tasks/SKILL.md +4 -5
- package/claude/skills/claude-worker/SKILL.md +2 -2
- package/docs/agents/capture.md +17 -11
- package/docs/agents/commands.md +4 -4
- package/docs/agents/demo.md +1 -1
- package/docs/agents/driver.md +2 -2
- package/docs/agents/index.md +1 -1
- package/docs/agents/overview.md +3 -3
- package/docs/agents/state-scoped-risk.md +1 -1
- package/docs/agents/tasks.md +10 -4
- package/docs/ai-workflow.md +2 -4
- package/docs/zshrc-aliases.md +19 -7
- package/package.json +1 -2
- package/scripts/core/bootstrap.sh +4 -0
- package/scripts/core/regen-hero.sh +6 -3
- package/src/capture/render.ts +166 -0
- package/src/capture/sources.ts +42 -0
- package/src/capture/stamp.ts +52 -0
- package/src/cli.ts +1 -1
- package/src/commands/capture.ts +52 -41
- package/src/commands/tasks.ts +18 -1
- package/src/demo/drive.ts +5 -8
- package/src/driver/drive.ts +7 -8
- package/src/gate/measures.ts +1 -1
- package/src/gate/stages.ts +1 -1
- package/src/inventory/walk.ts +3 -3
- package/src/tasks/archive.ts +91 -20
- package/src/tasks/validate.ts +76 -0
- package/standards/tasks.md +4 -4
package/src/commands/tasks.ts
CHANGED
|
@@ -81,7 +81,9 @@ export function register(program: Command): void {
|
|
|
81
81
|
|
|
82
82
|
tasks
|
|
83
83
|
.command('archive')
|
|
84
|
-
.description(
|
|
84
|
+
.description(
|
|
85
|
+
'Move a shipped task and its plan out of the board and clear its ordering',
|
|
86
|
+
)
|
|
85
87
|
.argument('[task]', 'Task filename stem, as in v28.1-trigger-escalation')
|
|
86
88
|
.helpOption('-h, --help', 'Show this help message')
|
|
87
89
|
.option(
|
|
@@ -93,6 +95,10 @@ export function register(program: Command): void {
|
|
|
93
95
|
.addHelpText(
|
|
94
96
|
'after',
|
|
95
97
|
[
|
|
98
|
+
'',
|
|
99
|
+
'The task carries its plan with it when no other live task cites that',
|
|
100
|
+
'plan, and the archived task keeps a working Plan: pointer at the new',
|
|
101
|
+
'path. A plan several tasks share stays where it is.',
|
|
96
102
|
'',
|
|
97
103
|
'Exit codes:',
|
|
98
104
|
' 0 the task was archived',
|
|
@@ -812,6 +818,11 @@ function report(
|
|
|
812
818
|
logStep('Archived')
|
|
813
819
|
logRemove(relative(root, outcome.from))
|
|
814
820
|
logAdd(relative(root, outcome.to))
|
|
821
|
+
if (outcome.plan) {
|
|
822
|
+
logRemove(relative(root, outcome.plan.from))
|
|
823
|
+
logAdd(relative(root, outcome.plan.to))
|
|
824
|
+
logInfo('retargeted the Plan: line')
|
|
825
|
+
}
|
|
815
826
|
if (outcome.priorityRowRemoved) logInfo('cleared the ordering row')
|
|
816
827
|
if (outcome.indexRegenerated) logInfo('regenerated index.md')
|
|
817
828
|
outro()
|
|
@@ -839,5 +850,11 @@ function recordFor(
|
|
|
839
850
|
to: relative(root, outcome.to),
|
|
840
851
|
priorityRowRemoved: outcome.priorityRowRemoved,
|
|
841
852
|
indexRegenerated: outcome.indexRegenerated,
|
|
853
|
+
plan: outcome.plan
|
|
854
|
+
? {
|
|
855
|
+
from: relative(root, outcome.plan.from),
|
|
856
|
+
to: relative(root, outcome.plan.to),
|
|
857
|
+
}
|
|
858
|
+
: null,
|
|
842
859
|
}
|
|
843
860
|
}
|
package/src/demo/drive.ts
CHANGED
|
@@ -20,15 +20,12 @@ declare global {
|
|
|
20
20
|
* the demo feature adds lives here, and `src/commands/demo.ts` reaches it
|
|
21
21
|
* through a dynamic import so no other command resolves the engine at startup.
|
|
22
22
|
*
|
|
23
|
-
* Unlike `@/capture/render`, this module ships. The capture command is excluded
|
|
24
|
-
* from the published package because it regenerates images committed to this
|
|
25
|
-
* repository, and that reason does not transfer to a command whose whole
|
|
26
|
-
* purpose is running in someone else's project.
|
|
27
|
-
*
|
|
28
23
|
* It imports `playwright-core` rather than `@playwright/test`, which stays a
|
|
29
|
-
* development dependency
|
|
30
|
-
* every target's dependency tree, and a target needs the driver
|
|
31
|
-
* test runner and an assertion library.
|
|
24
|
+
* development dependency the published tarball never carries. Shipping puts the
|
|
25
|
+
* import in every target's dependency tree, and a target needs the driver
|
|
26
|
+
* rather than a test runner and an assertion library. Every browser module here
|
|
27
|
+
* takes the same import for that reason, `@/capture/render` included since it
|
|
28
|
+
* started shipping too. Both are pinned to one version rather
|
|
32
29
|
* than a range, because `bunx playwright install chromium` fetches the browser
|
|
33
30
|
* revision the installed engine expects and a float would leave a target
|
|
34
31
|
* resolving a binary its engine cannot launch.
|
package/src/driver/drive.ts
CHANGED
|
@@ -23,19 +23,18 @@ import type {
|
|
|
23
23
|
* `src/commands/driver.ts` reaches it through a dynamic import so no other
|
|
24
24
|
* command resolves the engine at startup.
|
|
25
25
|
*
|
|
26
|
-
* Like `@/demo/drive
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
* two surfaces move independently.
|
|
26
|
+
* Like `@/demo/drive`, `@/inventory/walk`, and `@/capture/render`, this module
|
|
27
|
+
* ships. A command whose whole purpose is measuring someone else's page cannot
|
|
28
|
+
* stay toolkit-only, and nothing here reaches into `@/capture/`, so the two
|
|
29
|
+
* surfaces move independently.
|
|
31
30
|
*
|
|
32
31
|
* What separates it from `canon capture` is the axis it adds. A render answers
|
|
33
32
|
* about a page as it loads, and every defect that exists only after a menu
|
|
34
33
|
* opens, an answer is chosen, or the page scrolls is invisible to one. Probes
|
|
35
34
|
* therefore run after a step rather than on arrival, and a run reaches the load
|
|
36
|
-
* state by opening with a `wait` step of its own. That matters most where
|
|
37
|
-
* capture
|
|
38
|
-
*
|
|
35
|
+
* state by opening with a `wait` step of its own. That matters most where no
|
|
36
|
+
* capture runs against the page, since this command is then the only thing
|
|
37
|
+
* measuring it at all.
|
|
39
38
|
*/
|
|
40
39
|
|
|
41
40
|
/** Each probe keyed by the name a caller writes in the run. */
|
package/src/gate/measures.ts
CHANGED
|
@@ -100,7 +100,7 @@ export const SANDBOX_UNDECLARED_CEILING = 47
|
|
|
100
100
|
export const AUDITS_BASELINE = '.claude/canon/baseline.json'
|
|
101
101
|
|
|
102
102
|
export const HERO_STAMP_FAILURE =
|
|
103
|
-
'The hero set disagrees with the stamp written when the image was captured. Run canon capture assets/hero.html and commit all three files together.'
|
|
103
|
+
'The hero set disagrees with the stamp written when the image was captured. Run canon capture assets/hero.html --selector .window and commit all three files together.'
|
|
104
104
|
|
|
105
105
|
function parseJson(payload: string): unknown {
|
|
106
106
|
try {
|
package/src/gate/stages.ts
CHANGED
|
@@ -189,7 +189,7 @@ export const STAGES: readonly Stage[] = [
|
|
|
189
189
|
kind: 'drift',
|
|
190
190
|
pathspec: 'assets/hero.html',
|
|
191
191
|
failure:
|
|
192
|
-
'Hero counts drifted. Run bun run check, then canon capture assets/hero.html, and commit assets/hero.html with assets/hero.png and assets/hero.stamp.',
|
|
192
|
+
'Hero counts drifted. Run bun run check, then canon capture assets/hero.html --selector .window, and commit assets/hero.html with assets/hero.png and assets/hero.stamp.',
|
|
193
193
|
},
|
|
194
194
|
{ kind: 'measure', measure: heroStamp },
|
|
195
195
|
],
|
package/src/inventory/walk.ts
CHANGED
|
@@ -15,9 +15,9 @@ import type { Subject } from '@/inventory/subjects'
|
|
|
15
15
|
* and `src/commands/inventory.ts` reaches it through a dynamic import so no
|
|
16
16
|
* other command resolves the engine at startup.
|
|
17
17
|
*
|
|
18
|
-
* Like `@/demo/drive
|
|
19
|
-
* a command whose whole purpose is running inside someone else's
|
|
20
|
-
* stay toolkit-only.
|
|
18
|
+
* Like `@/demo/drive`, `@/driver/drive`, and `@/capture/render`, this module
|
|
19
|
+
* ships, because a command whose whole purpose is running inside someone else's
|
|
20
|
+
* project cannot stay toolkit-only.
|
|
21
21
|
*/
|
|
22
22
|
|
|
23
23
|
/**
|
package/src/tasks/archive.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { existsSync } from 'node:fs'
|
|
2
2
|
import { mkdir, readdir, readFile, rename, writeFile } from 'node:fs/promises'
|
|
3
|
-
import { join, relative, resolve } from 'node:path'
|
|
3
|
+
import { basename, dirname, join, relative, resolve, sep } from 'node:path'
|
|
4
4
|
import { regenOne } from '@/indexes/regen'
|
|
5
5
|
import { isUnder } from '@/paths'
|
|
6
6
|
import { recordDir, recordDirs } from '@/record-root'
|
|
@@ -43,7 +43,6 @@ export const ARCHIVE_REFUSALS = [
|
|
|
43
43
|
'ambiguous',
|
|
44
44
|
'no-outcomes',
|
|
45
45
|
'open-outcomes',
|
|
46
|
-
'plan-unswept',
|
|
47
46
|
'bad-input',
|
|
48
47
|
] as const
|
|
49
48
|
|
|
@@ -53,6 +52,12 @@ export type TaskSelector =
|
|
|
53
52
|
| { readonly kind: 'stem'; readonly stem: string }
|
|
54
53
|
| { readonly kind: 'pull-request'; readonly number: number }
|
|
55
54
|
|
|
55
|
+
/** A plan carried into the archive alongside the task that was its last citation. */
|
|
56
|
+
export interface PlanMove {
|
|
57
|
+
readonly from: string
|
|
58
|
+
readonly to: string
|
|
59
|
+
}
|
|
60
|
+
|
|
56
61
|
export interface ArchiveSuccess {
|
|
57
62
|
readonly ok: true
|
|
58
63
|
readonly stem: string
|
|
@@ -60,6 +65,8 @@ export interface ArchiveSuccess {
|
|
|
60
65
|
readonly to: string
|
|
61
66
|
readonly priorityRowRemoved: boolean
|
|
62
67
|
readonly indexRegenerated: boolean
|
|
68
|
+
/** Undefined when the task cited no live plan, or when another task still holds it. */
|
|
69
|
+
readonly plan: PlanMove | undefined
|
|
63
70
|
}
|
|
64
71
|
|
|
65
72
|
export interface ArchiveRefused {
|
|
@@ -142,16 +149,42 @@ export function readPullRequest(text: string): number | undefined {
|
|
|
142
149
|
return match ? Number(match[1]) : undefined
|
|
143
150
|
}
|
|
144
151
|
|
|
152
|
+
/**
|
|
153
|
+
* The `Plan:` line in either form, the link's target captured ahead of the bare
|
|
154
|
+
* path. Padding is spaces and tabs rather than `\s`, which spans a newline, so
|
|
155
|
+
* the match ends at the line and the retarget below cannot swallow the blank
|
|
156
|
+
* line that follows it.
|
|
157
|
+
*/
|
|
158
|
+
const PLAN_PATTERN = /^Plan:[ \t]*(?:\[[^\]]*\]\(([^)]+)\)|(\S+))[ \t]*$/m
|
|
159
|
+
|
|
145
160
|
/**
|
|
146
161
|
* Reads the `Plan:` target out of a markdown link, falling back to the older
|
|
147
162
|
* bare-path form. The path is returned as written, relative to the board.
|
|
148
163
|
*/
|
|
149
164
|
export function readPlanTarget(text: string): string | undefined {
|
|
150
|
-
const match =
|
|
165
|
+
const match = PLAN_PATTERN.exec(text)
|
|
151
166
|
if (!match) return undefined
|
|
152
167
|
return match[1] ?? match[2]
|
|
153
168
|
}
|
|
154
169
|
|
|
170
|
+
/**
|
|
171
|
+
* Points the task's `Plan:` line at the plan's new home, as a markdown link
|
|
172
|
+
* whose text and target stay in step. The line is matched with the pattern the
|
|
173
|
+
* read above uses, so the archive rewrites exactly the line it parsed and never
|
|
174
|
+
* a second `Plan:` a task displays inside a fenced sample.
|
|
175
|
+
*
|
|
176
|
+
* The replacement is built by a function rather than passed as a string,
|
|
177
|
+
* because `$&` and its siblings are substitution sequences inside a replacement
|
|
178
|
+
* string. A plan filename carrying one would write a path nobody typed, and
|
|
179
|
+
* `.canon/plans/` is gitignored, so nothing recovers the pointer it replaced.
|
|
180
|
+
*/
|
|
181
|
+
export function retargetPlanLine(text: string, target: string): string {
|
|
182
|
+
const name = basename(target)
|
|
183
|
+
const label = name.endsWith('.md') ? name.slice(0, -'.md'.length) : name
|
|
184
|
+
|
|
185
|
+
return text.replace(PLAN_PATTERN, () => `Plan: [${label}](${target})`)
|
|
186
|
+
}
|
|
187
|
+
|
|
155
188
|
/**
|
|
156
189
|
* Drops the archived task's row from the ordering table. Rows are matched by
|
|
157
190
|
* the link they carry rather than by a line pattern, because a row holds links
|
|
@@ -476,28 +509,23 @@ export async function archiveTask(
|
|
|
476
509
|
)
|
|
477
510
|
}
|
|
478
511
|
|
|
479
|
-
const
|
|
480
|
-
const
|
|
481
|
-
|
|
482
|
-
// A live plan is unswept only when nothing else on the board holds it. A plan
|
|
483
|
-
// several tasks share stays live by design, so refusing on the folder alone
|
|
484
|
-
// parked every one of those tasks behind a sweep that was right to decline.
|
|
485
|
-
if (livePlan) {
|
|
486
|
-
const shared = await otherTasksCitingPlan(dir, root, livePlan, stem)
|
|
512
|
+
const plan = await planToArchive(dir, root, stem, text)
|
|
513
|
+
const destination = archiveDir(root)
|
|
514
|
+
const to = join(destination, `${stem}.md`)
|
|
487
515
|
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
516
|
+
// The plan moves first so the line written below describes a file already at
|
|
517
|
+
// its new path. Writing the retarget first and failing the move would leave a
|
|
518
|
+
// pointer at a folder holding nothing, and `.canon/plans/` is gitignored, so
|
|
519
|
+
// no history recovers the target it named.
|
|
520
|
+
if (plan) {
|
|
521
|
+
await mkdir(dirname(plan.to), { recursive: true })
|
|
522
|
+
await rename(plan.from, plan.to)
|
|
495
523
|
}
|
|
496
524
|
|
|
497
|
-
const destination = archiveDir(root)
|
|
498
525
|
await mkdir(destination, { recursive: true })
|
|
499
|
-
const to = join(destination, `${stem}.md`)
|
|
500
526
|
await rename(from, to)
|
|
527
|
+
if (plan)
|
|
528
|
+
await writeFile(to, retargetPlanLine(text, linkTo(destination, plan.to)))
|
|
501
529
|
|
|
502
530
|
const priorityRowRemoved = await clearPriorityRow(dir, stem)
|
|
503
531
|
const regen = await regenOne(dir, { dryRun: false })
|
|
@@ -509,9 +537,52 @@ export async function archiveTask(
|
|
|
509
537
|
to,
|
|
510
538
|
priorityRowRemoved,
|
|
511
539
|
indexRegenerated: regen.action === 'written',
|
|
540
|
+
plan,
|
|
512
541
|
}
|
|
513
542
|
}
|
|
514
543
|
|
|
544
|
+
/**
|
|
545
|
+
* The plan this task carries into the archive with it, or nothing. The merge is
|
|
546
|
+
* what settles a plan, and the hook reaches this with nobody watching, so the
|
|
547
|
+
* move sits inside the archive rather than in a second call that could leave
|
|
548
|
+
* the task archived and the plan live.
|
|
549
|
+
*
|
|
550
|
+
* A plan another live task still cites stays where it is. Moving it on the
|
|
551
|
+
* first task to close strands every other pointer at a path that has gone, and
|
|
552
|
+
* the sibling has no history behind it to repair the line from.
|
|
553
|
+
*
|
|
554
|
+
* A target resolving to no file yields nothing too. A pointer somebody typed
|
|
555
|
+
* wrong is not a plan to move, and refusing the whole archive over it would
|
|
556
|
+
* park the board behind a repair the merge cannot make.
|
|
557
|
+
*/
|
|
558
|
+
async function planToArchive(
|
|
559
|
+
dir: string,
|
|
560
|
+
root: string,
|
|
561
|
+
stem: string,
|
|
562
|
+
text: string,
|
|
563
|
+
): Promise<PlanMove | undefined> {
|
|
564
|
+
const target = readPlanTarget(text)
|
|
565
|
+
const live = target && resolveLivePlan(target, dir, root)
|
|
566
|
+
if (!live || !existsSync(live)) return undefined
|
|
567
|
+
|
|
568
|
+
const shared = await otherTasksCitingPlan(dir, root, live, stem)
|
|
569
|
+
if (shared.length > 0) return undefined
|
|
570
|
+
|
|
571
|
+
return {
|
|
572
|
+
from: live,
|
|
573
|
+
to: join(recordDir(root, PLANS, ARCHIVE), basename(live)),
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
/**
|
|
578
|
+
* The archived plan as the archived task cites it. Both halves land a folder
|
|
579
|
+
* deeper than the live pair, so the link is measured between the two
|
|
580
|
+
* destinations rather than written as the `../plans/` the live task carried.
|
|
581
|
+
*/
|
|
582
|
+
function linkTo(taskDir: string, plan: string): string {
|
|
583
|
+
return relative(taskDir, plan).split(sep).join('/')
|
|
584
|
+
}
|
|
585
|
+
|
|
515
586
|
async function clearPriorityRow(dir: string, stem: string): Promise<boolean> {
|
|
516
587
|
const path = join(dir, 'priority.md')
|
|
517
588
|
if (!existsSync(path)) return false
|
package/src/tasks/validate.ts
CHANGED
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
archiveDir,
|
|
6
6
|
isReservedStem,
|
|
7
7
|
readOutcomes,
|
|
8
|
+
readPlanTarget,
|
|
8
9
|
readPullRequest,
|
|
9
10
|
tasksDir,
|
|
10
11
|
} from '@/tasks/archive'
|
|
@@ -34,6 +35,8 @@ export type ValidateRefusal = (typeof VALIDATE_REFUSALS)[number]
|
|
|
34
35
|
export const FINDING_KINDS = [
|
|
35
36
|
'plan-unstated',
|
|
36
37
|
'plan-unresolved',
|
|
38
|
+
'plan-uncited',
|
|
39
|
+
'plan-mismatched',
|
|
37
40
|
'task-unresolved',
|
|
38
41
|
'row-missing',
|
|
39
42
|
'row-duplicated',
|
|
@@ -612,6 +615,78 @@ function checkPlans(
|
|
|
612
615
|
return findings
|
|
613
616
|
}
|
|
614
617
|
|
|
618
|
+
/**
|
|
619
|
+
* Compares the two places one task's plan is written down. The `## Run now` row
|
|
620
|
+
* carries a `Plan` column and the task file carries its own `Plan:` line, and
|
|
621
|
+
* the archive reads the second while an operator reads the first, so a pair
|
|
622
|
+
* that disagrees settles the wrong plan on the merge.
|
|
623
|
+
*
|
|
624
|
+
* Both sides resolve before they compare. A row writing `../plans/x.md` and a
|
|
625
|
+
* task writing `.canon/plans/x.md` name one file, and comparing the strings
|
|
626
|
+
* would report every such pair as a mismatch.
|
|
627
|
+
*/
|
|
628
|
+
async function checkPlanAgreement(
|
|
629
|
+
rows: readonly BoardRow[],
|
|
630
|
+
dir: string,
|
|
631
|
+
root: string,
|
|
632
|
+
): Promise<Finding[]> {
|
|
633
|
+
const ready = rows.filter((row) => row.group === 'Run now' && row.plan)
|
|
634
|
+
|
|
635
|
+
const found = await Promise.all(
|
|
636
|
+
ready.map(async (row) => planDisagreement(row, dir, root)),
|
|
637
|
+
)
|
|
638
|
+
|
|
639
|
+
return found.filter((finding): finding is Finding => finding !== undefined)
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
async function planDisagreement(
|
|
643
|
+
row: BoardRow,
|
|
644
|
+
dir: string,
|
|
645
|
+
root: string,
|
|
646
|
+
): Promise<Finding | undefined> {
|
|
647
|
+
const subject = row.stem ?? row.label
|
|
648
|
+
if (!row.stem || !row.plan) return undefined
|
|
649
|
+
|
|
650
|
+
const file = join(dir, `${row.stem}.md`)
|
|
651
|
+
if (!existsSync(file)) return undefined
|
|
652
|
+
|
|
653
|
+
const target = readPlanTarget(await readFile(file, 'utf8'))
|
|
654
|
+
if (!target) {
|
|
655
|
+
return {
|
|
656
|
+
kind: 'plan-uncited',
|
|
657
|
+
group: 'Run now',
|
|
658
|
+
subject,
|
|
659
|
+
message: `is rowed against ${row.plan}, and the task file carries no Plan: line, so the archive settles no plan when it ships.`,
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
const rowed = planPath(row.plan, dir, root)
|
|
664
|
+
const cited = planPath(target, dir, root)
|
|
665
|
+
if (rowed === cited) return undefined
|
|
666
|
+
|
|
667
|
+
return {
|
|
668
|
+
kind: 'plan-mismatched',
|
|
669
|
+
group: 'Run now',
|
|
670
|
+
subject,
|
|
671
|
+
message: `is rowed against ${row.plan} and cites ${target} in its own Plan: line. One task names one plan.`,
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
/**
|
|
676
|
+
* Where a plan pointer lands, resolved against the board and against the
|
|
677
|
+
* project root the way the archive resolves the same line. Neither base
|
|
678
|
+
* existing leaves the board-relative reading, so two pointers at one absent
|
|
679
|
+
* file still compare equal and the mismatch check reports nothing.
|
|
680
|
+
*/
|
|
681
|
+
function planPath(target: string, dir: string, root: string): string {
|
|
682
|
+
const path = target.split('#')[0] || target
|
|
683
|
+
const fromBoard = resolve(dir, path)
|
|
684
|
+
if (existsSync(fromBoard)) return fromBoard
|
|
685
|
+
|
|
686
|
+
const fromRoot = resolve(root, path)
|
|
687
|
+
return existsSync(fromRoot) ? fromRoot : fromBoard
|
|
688
|
+
}
|
|
689
|
+
|
|
615
690
|
/**
|
|
616
691
|
* The half of the `## Run now` test a person cannot check by eye. Two rows a
|
|
617
692
|
* worker may be handed at once must touch disjoint files, and the `Touches`
|
|
@@ -984,6 +1059,7 @@ export async function validateBoard(
|
|
|
984
1059
|
...shapeFindings,
|
|
985
1060
|
...checkMapping(rows, backlog, stems, dir),
|
|
986
1061
|
...checkPlans(rows, dir, root),
|
|
1062
|
+
...(await checkPlanAgreement(rows, dir, root)),
|
|
987
1063
|
...checkCollisions(rows),
|
|
988
1064
|
...checkOrdinals(rows),
|
|
989
1065
|
...parked.findings,
|
package/standards/tasks.md
CHANGED
|
@@ -47,7 +47,7 @@ The handoff takes one file per session for the reason a task does. A single shar
|
|
|
47
47
|
|
|
48
48
|
The catalog is the one reader that filters nothing, so it carries a row per sibling alongside the tasks. That is what a folder catalog is for, and the handoffs are what make it worth stating: a board accumulates one row per session that ever wrote one, with nothing pruning them. Anything reading the catalog as the backlog therefore does its own filtering, and a reader that takes every row as a task reports the handoffs as queued work.
|
|
49
49
|
|
|
50
|
-
The `claude-tasks` skill creates and archives task files. `claude-docs` marks outcomes `[x]` in an existing file
|
|
50
|
+
The `claude-tasks` skill creates and archives task files, and the archive carries the task's plan with it. `claude-docs` marks outcomes `[x]` in an existing file. Neither does the other's job.
|
|
51
51
|
|
|
52
52
|
## Ordering
|
|
53
53
|
|
|
@@ -247,8 +247,8 @@ The archive nests inside `.canon/tasks/` rather than sitting beside it as a flat
|
|
|
247
247
|
|
|
248
248
|
One destination rather than a per-project choice is what lets the move happen without asking. It mirrors the plans archive at `.canon/plans/archive/`, sitting inside the folder it archives the same way, and it inherits the board's own ignore entry rather than needing one of its own. The cost is that an archived task does not appear in diffs, which is the cost the live board already carries.
|
|
249
249
|
|
|
250
|
-
Archiving a task
|
|
250
|
+
Archiving a task archives its plan alongside it, when the closing task is that plan's last live citation. The archived task's `Plan:` line is retargeted at `../../plans/archive/feature-<slug>.md`, a folder deeper than the live task wrote it, so a completed task still leads to the reasoning behind it. A plan several tasks share stays live and the task archives anyway, since moving it on the first task to close strands every sibling's pointer at a path that has gone.
|
|
251
251
|
|
|
252
|
-
|
|
252
|
+
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.
|
|
253
253
|
|
|
254
|
-
A task with an open outcome stays on the board. 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.
|
|
254
|
+
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.
|