@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.
- 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 +3 -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/sandbox.md +13 -10
- 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/scripts/lib/sandbox-dispatch.sh +8 -0
- package/src/claude/cases/workflow.ts +4 -4
- package/src/claude/plugin-update.ts +48 -0
- package/src/commands/claude.ts +281 -1
- package/src/commands/demo.ts +1 -1
- package/src/commands/feedback.ts +15 -5
- package/src/commands/gate.ts +3 -1
- package/src/commands/pr.ts +130 -1
- package/src/commands/sandbox.ts +13 -4
- package/src/commands/tasks.ts +178 -1
- package/src/demo/beats.ts +1 -1
- package/src/design/components.ts +12 -0
- package/src/gate/measures.ts +47 -1
- package/src/migrate/skill-names.ts +15 -1
- package/src/pr/review-scope.ts +177 -0
- package/src/sandbox/expect.ts +26 -1
- package/src/tasks/archive.ts +206 -3
- package/src/tasks/label.ts +14 -6
- package/src/tasks/validate.ts +22 -0
- package/src/teach/nav.ts +97 -3
- package/standards/glossary.md +8 -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/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
|
-
|
|
461
|
-
|
|
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')
|
package/standards/glossary.md
CHANGED
|
@@ -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
|
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.
|