@erclx/canon 4.8.0 → 4.9.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/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-teach/SKILL.md +11 -1
- package/claude/skills/claude-worker/SKILL.md +2 -2
- package/docs/agents/commands.md +8 -3
- package/docs/agents/install-and-sync.md +23 -1
- package/docs/agents/overview.md +3 -3
- package/docs/agents/tasks.md +10 -4
- package/docs/agents/teach.md +2 -0
- package/docs/ai-workflow.md +2 -4
- package/docs/visual-design-workflow.md +2 -0
- package/docs/zshrc-aliases.md +19 -7
- package/package.json +1 -1
- package/scripts/core/bootstrap.sh +4 -0
- package/src/commands/design.ts +147 -5
- package/src/commands/sync.ts +3 -5
- package/src/commands/tasks.ts +18 -1
- package/src/commands/teach.ts +72 -0
- package/src/design/adapter.ts +59 -0
- package/src/design/base.css +123 -0
- package/src/design/components.ts +118 -0
- package/src/design/contrast.ts +81 -0
- package/src/design/css.ts +142 -0
- package/src/design/document.ts +143 -0
- package/src/design/regen.ts +56 -0
- package/src/design/render.ts +43 -9
- package/src/design/tokens.ts +315 -0
- package/src/gate/stages.ts +28 -0
- package/src/slides/styles.ts +42 -15
- package/src/sync/check.ts +41 -0
- package/src/sync/engine.ts +28 -7
- package/src/sync/stamp.ts +5 -4
- package/src/sync/target.ts +4 -1
- package/src/tasks/archive.ts +91 -20
- package/src/tasks/validate.ts +76 -0
- package/src/teach/workspace.ts +57 -0
- package/standards/tasks.md +4 -4
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/src/teach/workspace.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { existsSync } from 'node:fs'
|
|
2
2
|
import { mkdir, readdir, readFile, writeFile } from 'node:fs/promises'
|
|
3
3
|
import { join, relative } from 'node:path'
|
|
4
|
+
import { buildDesignCss } from '@/design/css'
|
|
4
5
|
import { parseFrontmatter, readField } from '@/indexes/frontmatter'
|
|
5
6
|
import { type BodyLine, bodyLines } from '@/markdown/scan'
|
|
6
7
|
import { recordDir } from '@/record-root'
|
|
@@ -841,3 +842,59 @@ export async function defineTerms(
|
|
|
841
842
|
|
|
842
843
|
return { ok: true, slug: found.slug, path, defined: terms }
|
|
843
844
|
}
|
|
845
|
+
|
|
846
|
+
export interface StylesheetWritten {
|
|
847
|
+
readonly ok: true
|
|
848
|
+
readonly slug: string
|
|
849
|
+
/** Relative to the root, so a caller prints a path a reader can open. */
|
|
850
|
+
readonly path: string
|
|
851
|
+
/** False when the workspace already held one and this call left it alone. */
|
|
852
|
+
readonly written: boolean
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
export type StylesheetOutcome = StylesheetWritten | TeachRefused
|
|
856
|
+
|
|
857
|
+
const STYLESHEET_BANNER = [
|
|
858
|
+
'Seeded by `canon teach stylesheet` from the design source in',
|
|
859
|
+
'src/design/tokens.ts. The tokens and the two components below are the',
|
|
860
|
+
'system this workspace renders in. Add lesson rules under them and read a',
|
|
861
|
+
'value through its custom property rather than restating the hex, which is',
|
|
862
|
+
'what let one workspace fork the palette from every other.',
|
|
863
|
+
].join('\n ')
|
|
864
|
+
|
|
865
|
+
/**
|
|
866
|
+
* Writes a workspace's one stylesheet from the design source.
|
|
867
|
+
*
|
|
868
|
+
* Every workspace used to carry a hand-authored copy, which is how the course
|
|
869
|
+
* palette forked once per workspace. The name is fixed at `TEACH_STYLESHEET`
|
|
870
|
+
* and the folder at `TEACH_ASSETS` for the same reason a second lesson has to
|
|
871
|
+
* reach the file the first one wrote, and this is what puts the values in it.
|
|
872
|
+
*
|
|
873
|
+
* An existing stylesheet is left alone rather than replaced. A workspace adds
|
|
874
|
+
* lesson rules to this file as it goes, so overwriting would discard them, and
|
|
875
|
+
* `--force` is the caller saying it wants the seed back.
|
|
876
|
+
*/
|
|
877
|
+
export async function writeStylesheet(
|
|
878
|
+
root: string,
|
|
879
|
+
selector: string,
|
|
880
|
+
force = false,
|
|
881
|
+
): Promise<StylesheetOutcome> {
|
|
882
|
+
const found = await readWorkspace(root, selector)
|
|
883
|
+
if (!found.ok) return found
|
|
884
|
+
|
|
885
|
+
const workspace = found.workspace
|
|
886
|
+
const rel = join(workspace.path, TEACH_ASSETS, TEACH_STYLESHEET)
|
|
887
|
+
const path = join(root, rel)
|
|
888
|
+
|
|
889
|
+
if (existsSync(path) && !force) {
|
|
890
|
+
return { ok: true, slug: workspace.slug, path: rel, written: false }
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
await mkdir(join(root, workspace.path, TEACH_ASSETS), { recursive: true })
|
|
894
|
+
await writeFile(
|
|
895
|
+
path,
|
|
896
|
+
buildDesignCss(undefined, { banner: STYLESHEET_BANNER }),
|
|
897
|
+
)
|
|
898
|
+
|
|
899
|
+
return { ok: true, slug: workspace.slug, path: rel, written: true }
|
|
900
|
+
}
|
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.
|