@erclx/canon 4.98.0 → 4.100.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 +24 -6
- package/claude/.claude-plugin/plugin.json +1 -1
- package/claude/skills/auto-ship/SKILL.md +6 -4
- package/claude/skills/draft-and-pick/REQUIREMENT.md +1 -1
- package/claude/skills/draft-and-pick/SKILL.md +1 -1
- package/claude/skills/draft-ready/SKILL.md +3 -3
- package/claude/skills/git-pr/SKILL.md +41 -21
- package/claude/skills/test-craft/REQUIREMENT.md +1 -1
- package/claude/skills/test-first/REQUIREMENT.md +1 -1
- package/claude/skills/ui-checklist/REQUIREMENT.md +49 -0
- package/claude/skills/ui-checklist/SKILL.md +71 -0
- package/claude/skills/ux-audit/REQUIREMENT.md +1 -1
- package/claude/skills/ux-measure/REQUIREMENT.md +1 -1
- package/docs/agents/capture.md +6 -2
- package/docs/agents/demo.md +3 -1
- package/docs/agents/pr-evidence.md +34 -11
- package/docs/agents/pr-preview.md +4 -3
- package/docs/agents/tasks.md +3 -1
- package/docs/workflow/ai-workflow.md +4 -4
- package/docs/workflow/visual-design-workflow.md +2 -2
- package/package.json +1 -1
- package/scripts/core/regen-hero.sh +36 -3
- package/src/claude/cases/workflow.ts +3 -2
- package/src/commands/demo.ts +4 -3
- package/src/commands/pr.ts +53 -1
- package/src/commands/tasks.ts +16 -0
- package/src/demo/compile.ts +20 -0
- package/src/demo/drive.ts +1 -0
- package/src/migrate/skill-names.ts +14 -6
- package/src/pr/evidence.ts +36 -1
- package/src/sandbox/expect.ts +2 -1
- package/src/tasks/archive.ts +115 -3
- package/standards/ready.md +2 -2
- package/standards/tasks.md +4 -2
- package/tooling/web/configs/e2e/screenshot.ts +155 -11
- package/tooling/web/reference.md +2 -2
- package/claude/skills/ui-test/REQUIREMENT.md +0 -48
- package/claude/skills/ui-test/SKILL.md +0 -87
|
@@ -31,13 +31,13 @@ A cell no source anchors ends in `? verify`, and the preview shows that marker b
|
|
|
31
31
|
### Tools
|
|
32
32
|
|
|
33
33
|
- None beyond Claude Code itself
|
|
34
|
-
- Playwright CLI optional for verifying journeys across routes and browser-only behavior. See [`
|
|
34
|
+
- Playwright CLI optional for verifying journeys across routes and browser-only behavior. See [`test-craft`](../../claude/skills/test-craft/SKILL.md) for which layer a journey belongs at.
|
|
35
35
|
|
|
36
36
|
### Skills
|
|
37
37
|
|
|
38
38
|
- `canon:sketch-design` to trace a design direction from reference images or URLs, before `design-extract`'s greenfield path proposes from a personality paragraph alone
|
|
39
39
|
- `canon:design-extract` to draft `canon/DESIGN.md`, from existing project signals or from requirements alone on day one
|
|
40
|
-
- `canon:ui-
|
|
40
|
+
- `canon:ui-checklist` for what a reviewer has to look at after UI changes, and what ships without a test
|
|
41
41
|
- `canon:ux-audit` for UX gap detection on existing surfaces
|
|
42
42
|
- `canon:ux-measure` for what a running surface costs to paint, read against published thresholds
|
|
43
43
|
- `canon:draft-and-pick` for a call settled by looking, drafting several candidates onto one page and taking your pick
|
package/package.json
CHANGED
|
@@ -15,6 +15,10 @@
|
|
|
15
15
|
# component half is left out on purpose: it is a scrollbar and a status marker,
|
|
16
16
|
# and a static capture frame renders neither.
|
|
17
17
|
#
|
|
18
|
+
# A frame a README shows in both themes fills once more against the light roles
|
|
19
|
+
# and writes `<name>-light.html` beside the dark one, so each theme is its own
|
|
20
|
+
# capture set with its own stamp.
|
|
21
|
+
#
|
|
18
22
|
# Only the HTML regenerates here. The PNG beside it is a chromium render whose
|
|
19
23
|
# bytes move with the browser version, so asserting it in verify.sh would fail
|
|
20
24
|
# on a machine whose chromium differs rather than on a stale count. Rebuild the
|
|
@@ -393,9 +397,38 @@ if (templates.length === 0) {
|
|
|
393
397
|
process.exit(1)
|
|
394
398
|
}
|
|
395
399
|
|
|
396
|
-
|
|
400
|
+
// The frames a README serves in both themes. The emitter already carries a
|
|
401
|
+
// `--color-light-*` role beside each `--color-*` one, so the light frame is the
|
|
402
|
+
// same template with the plain roles pointed at the light ones rather than a
|
|
403
|
+
// second copy of any hex value. The hero is a landing-page frame no README
|
|
404
|
+
// shows, so it stays dark.
|
|
405
|
+
const LIGHT_TEMPLATES = new Set(["catalog.html.tmpl", "install.html.tmpl"])
|
|
406
|
+
|
|
407
|
+
const lightRoles = [...tokenCss.matchAll(/--color-light-([a-z-]+):/g)].map((match) => match[1])
|
|
408
|
+
if (lightRoles.length === 0) {
|
|
409
|
+
console.error("regen-hero: token css carries no --color-light-* roles, refusing to write a light frame")
|
|
410
|
+
process.exit(1)
|
|
411
|
+
}
|
|
412
|
+
const lightTokenCss = [
|
|
413
|
+
tokenCss,
|
|
414
|
+
"",
|
|
415
|
+
" :root {",
|
|
416
|
+
...lightRoles.map((role) => ` --color-${role}: var(--color-light-${role});`),
|
|
417
|
+
" --frame-shadow: rgb(0 0 0 / 16%);",
|
|
418
|
+
" }",
|
|
419
|
+
].join("\n")
|
|
420
|
+
|
|
421
|
+
const frames = templates.flatMap((template) => {
|
|
422
|
+
const base = template.replace(/\.html\.tmpl$/, "")
|
|
423
|
+
const dark = { template, out: `${base}.html`, tokens: tokenCss }
|
|
424
|
+
return LIGHT_TEMPLATES.has(template)
|
|
425
|
+
? [dark, { template, out: `${base}-light.html`, tokens: lightTokenCss }]
|
|
426
|
+
: [dark]
|
|
427
|
+
})
|
|
428
|
+
|
|
429
|
+
for (const { template, out, tokens } of frames) {
|
|
397
430
|
let html = await Bun.file(ASSET_DIR + "/" + template).text()
|
|
398
|
-
for (const [key, value] of Object.entries(values)) {
|
|
431
|
+
for (const [key, value] of Object.entries({ ...values, TOKENS: tokens })) {
|
|
399
432
|
html = html.replaceAll(`{{${key}}}`, value)
|
|
400
433
|
}
|
|
401
434
|
|
|
@@ -405,6 +438,6 @@ for (const template of templates) {
|
|
|
405
438
|
process.exit(1)
|
|
406
439
|
}
|
|
407
440
|
|
|
408
|
-
await Bun.write(ASSET_DIR + "/" +
|
|
441
|
+
await Bun.write(ASSET_DIR + "/" + out, html)
|
|
409
442
|
}
|
|
410
443
|
'
|
|
@@ -126,8 +126,9 @@ export const WORKFLOW_CASES: readonly SkillCase[] = [
|
|
|
126
126
|
expect: 'teach-workspace',
|
|
127
127
|
},
|
|
128
128
|
{
|
|
129
|
-
prompt:
|
|
130
|
-
|
|
129
|
+
prompt:
|
|
130
|
+
'The restyle is finished. Write down what a reviewer has to look at by eye, and which behaviors have no test.',
|
|
131
|
+
expect: 'ui-checklist',
|
|
131
132
|
},
|
|
132
133
|
{
|
|
133
134
|
prompt:
|
package/src/commands/demo.ts
CHANGED
|
@@ -187,9 +187,10 @@ function runCompile(draftPath: string, opts: CompileOptions): number {
|
|
|
187
187
|
if (existsSync(target) && !opts.force) {
|
|
188
188
|
logStep('Plan')
|
|
189
189
|
logError(`${display(target)} already exists`)
|
|
190
|
-
// Stated rather than implied, because the value at risk is
|
|
191
|
-
// operator tuned by watching a recording
|
|
192
|
-
|
|
190
|
+
// Stated rather than implied, because the value at risk is whatever the
|
|
191
|
+
// operator set by hand, such as timing tuned by watching a recording or a
|
|
192
|
+
// color scheme, and the draft can reproduce neither.
|
|
193
|
+
logWarn('Pass --force to overwrite it, losing any field set by hand.')
|
|
193
194
|
outro()
|
|
194
195
|
emit(opts.json, { plan: target, reason: 'plan-exists' })
|
|
195
196
|
return 1
|
package/src/commands/pr.ts
CHANGED
|
@@ -22,6 +22,7 @@ import {
|
|
|
22
22
|
} from '@/pr/bijection'
|
|
23
23
|
import { type CheckRunListing, collapseChecks } from '@/pr/checks'
|
|
24
24
|
import {
|
|
25
|
+
findEvidenceChecklist,
|
|
25
26
|
findEvidenceCommentId,
|
|
26
27
|
findEvidencePreview,
|
|
27
28
|
groupEvidence,
|
|
@@ -70,6 +71,7 @@ interface ReadOptions {
|
|
|
70
71
|
|
|
71
72
|
interface EvidenceOptions extends ReadOptions {
|
|
72
73
|
readonly preview?: string
|
|
74
|
+
readonly checklist?: string
|
|
73
75
|
}
|
|
74
76
|
|
|
75
77
|
interface PreviewOptions extends ReadOptions {
|
|
@@ -137,10 +139,13 @@ type EvidenceRefusal =
|
|
|
137
139
|
| 'no-base'
|
|
138
140
|
| 'unreadable-tree'
|
|
139
141
|
| 'unreadable-changes'
|
|
142
|
+
| 'unreadable-checklist'
|
|
140
143
|
|
|
141
144
|
const EVIDENCE_REFUSALS: Record<EvidenceRefusal, string> = {
|
|
142
145
|
'gh-failed':
|
|
143
146
|
'gh could not answer for this repository. Name the pull request number.',
|
|
147
|
+
'unreadable-checklist':
|
|
148
|
+
'The file named by --checklist could not be read, or holds nothing. Rendering without it would drop the only copy, since the caller deletes the handoff once a post reports success.',
|
|
144
149
|
'no-base': 'No base resolves against the trunk. Fetch origin and re-run.',
|
|
145
150
|
'unreadable-tree':
|
|
146
151
|
'git could not read the tree at the base commit, so no path could be judged added or changed.',
|
|
@@ -369,6 +374,10 @@ export function register(program: Command): void {
|
|
|
369
374
|
'--preview <url>',
|
|
370
375
|
'Open the body with this preview address, from canon pr preview',
|
|
371
376
|
)
|
|
377
|
+
.option(
|
|
378
|
+
'--checklist <path>',
|
|
379
|
+
'Close the body with this visual checklist file, from canon:ui-checklist',
|
|
380
|
+
)
|
|
372
381
|
.addHelpText(
|
|
373
382
|
'after',
|
|
374
383
|
[
|
|
@@ -393,6 +402,14 @@ export function register(program: Command): void {
|
|
|
393
402
|
'Without --preview, an address the marked comment already opens with',
|
|
394
403
|
'is carried into the new body, so a re-render after a push keeps it.',
|
|
395
404
|
'',
|
|
405
|
+
'--checklist closes the body with a visual checklist, below the',
|
|
406
|
+
'comparison it annotates. A checklist the marked comment already',
|
|
407
|
+
'carries is read back and carried forward the same way the preview',
|
|
408
|
+
'address is, which keeps a re-render from wiping ticked boxes. It does',
|
|
409
|
+
'not decide no-evidence: a branch with a checklist and no evidence',
|
|
410
|
+
'image still reports no-evidence, and the caller posts the checklist',
|
|
411
|
+
'on its own.',
|
|
412
|
+
'',
|
|
396
413
|
'Exit codes:',
|
|
397
414
|
' 0 read, whether it produced a body or reported no-evidence',
|
|
398
415
|
' 1 refused, with the reason on stderr or in the JSON record',
|
|
@@ -401,6 +418,7 @@ export function register(program: Command): void {
|
|
|
401
418
|
' canon pr evidence --json',
|
|
402
419
|
' canon pr evidence 1341 --json',
|
|
403
420
|
' canon pr evidence 1341 --preview https://feat-x.site.pages.dev --json',
|
|
421
|
+
' canon pr evidence 1341 --checklist .canon/tmp/handoff/ui-checklist/x.md --json',
|
|
404
422
|
'',
|
|
405
423
|
].join('\n'),
|
|
406
424
|
)
|
|
@@ -1214,6 +1232,23 @@ async function runEvidence(
|
|
|
1214
1232
|
|
|
1215
1233
|
intro('canon pr evidence')
|
|
1216
1234
|
|
|
1235
|
+
// Read ahead of anything else, so a bad path refuses before a `gh` round
|
|
1236
|
+
// trip rather than after one.
|
|
1237
|
+
let suppliedChecklist: string | undefined
|
|
1238
|
+
if (opts.checklist !== undefined) {
|
|
1239
|
+
suppliedChecklist = await readFile(resolve(root, opts.checklist), 'utf8')
|
|
1240
|
+
.then((text) => text.trim() || undefined)
|
|
1241
|
+
.catch(() => undefined)
|
|
1242
|
+
if (suppliedChecklist === undefined) {
|
|
1243
|
+
return refuseWith(
|
|
1244
|
+
'unreadable-checklist',
|
|
1245
|
+
EVIDENCE_REFUSALS['unreadable-checklist'],
|
|
1246
|
+
emitJson,
|
|
1247
|
+
root,
|
|
1248
|
+
)
|
|
1249
|
+
}
|
|
1250
|
+
}
|
|
1251
|
+
|
|
1217
1252
|
const read = await readIdentity(root, number)
|
|
1218
1253
|
if (read.kind === 'refused') {
|
|
1219
1254
|
return refuseWith(read.reason, PULL_REFUSALS[read.reason], emitJson, root)
|
|
@@ -1294,6 +1329,7 @@ async function runEvidence(
|
|
|
1294
1329
|
|
|
1295
1330
|
let commentId: number | undefined
|
|
1296
1331
|
let carriedPreview: string | undefined
|
|
1332
|
+
let carriedChecklist: string | undefined
|
|
1297
1333
|
if (identity.number !== undefined) {
|
|
1298
1334
|
const commentsRow = await gh(root, [
|
|
1299
1335
|
'pr',
|
|
@@ -1327,11 +1363,20 @@ async function runEvidence(
|
|
|
1327
1363
|
}
|
|
1328
1364
|
commentId = findEvidenceCommentId(comments)
|
|
1329
1365
|
carriedPreview = findEvidencePreview(comments)
|
|
1366
|
+
carriedChecklist = findEvidenceChecklist(comments)
|
|
1330
1367
|
}
|
|
1331
1368
|
|
|
1332
1369
|
const preview = opts.preview ?? carriedPreview
|
|
1370
|
+
const checklist = suppliedChecklist ?? carriedChecklist
|
|
1333
1371
|
const states = grouped.kind === 'read' ? grouped.states : []
|
|
1334
|
-
const body = renderEvidenceBody(
|
|
1372
|
+
const body = renderEvidenceBody(
|
|
1373
|
+
states,
|
|
1374
|
+
repo,
|
|
1375
|
+
base,
|
|
1376
|
+
identity.head,
|
|
1377
|
+
preview,
|
|
1378
|
+
checklist,
|
|
1379
|
+
)
|
|
1335
1380
|
|
|
1336
1381
|
const caseCount = states.reduce((n, s) => n + s.items.length, 0)
|
|
1337
1382
|
|
|
@@ -1340,6 +1385,13 @@ async function runEvidence(
|
|
|
1340
1385
|
`${plural(caseCount, 'case')} across ${plural(states.length, 'state')}`,
|
|
1341
1386
|
)
|
|
1342
1387
|
if (preview !== undefined) logInfo(`preview ${preview}`)
|
|
1388
|
+
if (checklist !== undefined) {
|
|
1389
|
+
logInfo(
|
|
1390
|
+
suppliedChecklist === undefined
|
|
1391
|
+
? 'checklist carried forward from the marked comment'
|
|
1392
|
+
: 'checklist folded in below the comparison',
|
|
1393
|
+
)
|
|
1394
|
+
}
|
|
1343
1395
|
|
|
1344
1396
|
outro()
|
|
1345
1397
|
|
package/src/commands/tasks.ts
CHANGED
|
@@ -146,6 +146,11 @@ export function register(program: Command): void {
|
|
|
146
146
|
'plan, and the archived task keeps a working Plan: pointer at the new',
|
|
147
147
|
'path. A plan several tasks share stays where it is.',
|
|
148
148
|
'',
|
|
149
|
+
'The ready folder the task names moves to .canon/ready/archive/ with its',
|
|
150
|
+
'plan, and the Ready: line and the plan path to it are retargeted. A',
|
|
151
|
+
'folder that does not resolve, or a destination already taken, moves',
|
|
152
|
+
'nothing and refuses nothing.',
|
|
153
|
+
'',
|
|
149
154
|
'Exit codes:',
|
|
150
155
|
' 0 the task was archived',
|
|
151
156
|
' 1 refused, with the reason on stderr or in the JSON record',
|
|
@@ -1310,6 +1315,11 @@ function report(
|
|
|
1310
1315
|
logAdd(relative(root, outcome.plan.to))
|
|
1311
1316
|
logInfo('retargeted the Plan: line')
|
|
1312
1317
|
}
|
|
1318
|
+
if (outcome.ready) {
|
|
1319
|
+
logRemove(relative(root, outcome.ready.from))
|
|
1320
|
+
logAdd(relative(root, outcome.ready.to))
|
|
1321
|
+
logInfo('retargeted the Ready: line')
|
|
1322
|
+
}
|
|
1313
1323
|
if (outcome.priorityRowRemoved) logInfo('cleared the ordering row')
|
|
1314
1324
|
if (outcome.indexRegenerated) logInfo('regenerated index.md')
|
|
1315
1325
|
if (outcome.cut > 0) logInfo(`${outcome.cut} outcome(s) cut`)
|
|
@@ -1344,6 +1354,12 @@ function recordFor(
|
|
|
1344
1354
|
to: relative(root, outcome.plan.to),
|
|
1345
1355
|
}
|
|
1346
1356
|
: null,
|
|
1357
|
+
ready: outcome.ready
|
|
1358
|
+
? {
|
|
1359
|
+
from: relative(root, outcome.ready.from),
|
|
1360
|
+
to: relative(root, outcome.ready.to),
|
|
1361
|
+
}
|
|
1362
|
+
: null,
|
|
1347
1363
|
closed: outcome.closed,
|
|
1348
1364
|
cut: outcome.cut,
|
|
1349
1365
|
}
|
package/src/demo/compile.ts
CHANGED
|
@@ -107,11 +107,14 @@ export interface DemoStep {
|
|
|
107
107
|
readonly note?: string
|
|
108
108
|
}
|
|
109
109
|
|
|
110
|
+
export type ColorScheme = 'light' | 'dark'
|
|
111
|
+
|
|
110
112
|
export interface DemoPlan {
|
|
111
113
|
readonly slug: string
|
|
112
114
|
readonly title: string
|
|
113
115
|
readonly url: string
|
|
114
116
|
readonly viewport: { readonly width: number; readonly height: number }
|
|
117
|
+
readonly colorScheme?: ColorScheme
|
|
115
118
|
readonly output: { readonly video: string; readonly still: string }
|
|
116
119
|
readonly pointer: { readonly travelMs: number; readonly typeDelayMs: number }
|
|
117
120
|
readonly annotations: typeof ANNOTATIONS
|
|
@@ -229,6 +232,9 @@ export function parsePlan(text: string): PlanParse {
|
|
|
229
232
|
steps.push(step.step)
|
|
230
233
|
}
|
|
231
234
|
|
|
235
|
+
const colorScheme = parseColorScheme(raw.colorScheme)
|
|
236
|
+
if (colorScheme.status === 'failed') return colorScheme
|
|
237
|
+
|
|
232
238
|
const slug = asText(raw.slug)
|
|
233
239
|
const output = isRecord(raw.output) ? raw.output : {}
|
|
234
240
|
const pointer = isRecord(raw.pointer) ? raw.pointer : {}
|
|
@@ -244,6 +250,7 @@ export function parsePlan(text: string): PlanParse {
|
|
|
244
250
|
width: asNumber(viewport.width, VIEWPORT.width),
|
|
245
251
|
height: asNumber(viewport.height, VIEWPORT.height),
|
|
246
252
|
},
|
|
253
|
+
...(colorScheme.value && { colorScheme: colorScheme.value }),
|
|
247
254
|
output: {
|
|
248
255
|
video: asText(output.video) || `demos/${slug || 'demo'}.webm`,
|
|
249
256
|
still: asText(output.still) || `demos/${slug || 'demo'}.png`,
|
|
@@ -258,6 +265,19 @@ export function parsePlan(text: string): PlanParse {
|
|
|
258
265
|
}
|
|
259
266
|
}
|
|
260
267
|
|
|
268
|
+
function parseColorScheme(
|
|
269
|
+
value: unknown,
|
|
270
|
+
):
|
|
271
|
+
| { status: 'ok'; value?: ColorScheme }
|
|
272
|
+
| { status: 'failed'; reason: string } {
|
|
273
|
+
if (value === undefined) return { status: 'ok' }
|
|
274
|
+
if (value === 'light' || value === 'dark') return { status: 'ok', value }
|
|
275
|
+
return {
|
|
276
|
+
status: 'failed',
|
|
277
|
+
reason: `colorScheme is ${JSON.stringify(value)}, which is not light or dark`,
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
261
281
|
function parseStep(
|
|
262
282
|
entry: unknown,
|
|
263
283
|
index: number,
|
package/src/demo/drive.ts
CHANGED
|
@@ -118,6 +118,7 @@ export async function drive(options: DriveOptions): Promise<DriveResult> {
|
|
|
118
118
|
|
|
119
119
|
context = await browser.value.newContext({
|
|
120
120
|
viewport: plan.viewport,
|
|
121
|
+
...(plan.colorScheme && { colorScheme: plan.colorScheme }),
|
|
121
122
|
// Pointed the opposite way from a test. A recording wants the motion the
|
|
122
123
|
// interface was designed with, where a test wants it suppressed.
|
|
123
124
|
reducedMotion: 'no-preference',
|
|
@@ -21,6 +21,13 @@ import { defineRenameRules, type RenameRules } from '@/migrate/rename'
|
|
|
21
21
|
* reading in sequence with `draft-screencast` as one three-step pipeline with
|
|
22
22
|
* no prefix forcing that reading.
|
|
23
23
|
*
|
|
24
|
+
* One row renames a skill rather than retiring a prefix. `ui-test` became
|
|
25
|
+
* `ui-checklist` when the skill stopped writing tests and shrank to the
|
|
26
|
+
* checklist a reviewer reads, so the old name described a job it no longer
|
|
27
|
+
* does. `claude-ui-test` is retargeted onto the same destination rather than
|
|
28
|
+
* left pointing at the retired name, which lands a target on the oldest
|
|
29
|
+
* spelling in one pass instead of two.
|
|
30
|
+
*
|
|
24
31
|
* Every name takes two words. Ten of these would have landed as a bare single
|
|
25
32
|
* word under a plain strip, and a bare word such as `review` or `docs` is a
|
|
26
33
|
* substring of ordinary prose with no token left for a later sweep to find.
|
|
@@ -56,7 +63,8 @@ export const SKILL_NAME_MAP: Readonly<Record<string, string>> = {
|
|
|
56
63
|
'claude-standards-audit': 'standards-audit',
|
|
57
64
|
'claude-tasks': 'task-board',
|
|
58
65
|
'claude-teach': 'teach-workspace',
|
|
59
|
-
'claude-ui-test': 'ui-
|
|
66
|
+
'claude-ui-test': 'ui-checklist',
|
|
67
|
+
'ui-test': 'ui-checklist',
|
|
60
68
|
'claude-ux-audit': 'ux-audit',
|
|
61
69
|
'claude-ux-measure': 'ux-measure',
|
|
62
70
|
'claude-worker': 'role-worker',
|
|
@@ -72,11 +80,11 @@ export const SKILL_NAME_MAP: Readonly<Record<string, string>> = {
|
|
|
72
80
|
*
|
|
73
81
|
* It protects nothing. The `aitk` sweep had to guard a sibling repository
|
|
74
82
|
* whose name contained the token, and no string here contains a skill name
|
|
75
|
-
* while meaning something else
|
|
76
|
-
* `claude-intake` inside `claude-
|
|
77
|
-
* ordering settles rather than a protected form. Reading
|
|
78
|
-
* would report a pass
|
|
79
|
-
*
|
|
83
|
+
* while meaning something else. Two rows overlap another row: `claude-intake`
|
|
84
|
+
* inside `claude-intake-answer`, and `ui-test` inside `claude-ui-test`. The
|
|
85
|
+
* longest-first ordering settles both rather than a protected form. Reading
|
|
86
|
+
* either off the output would report a pass anyway, since each pair lands on
|
|
87
|
+
* one destination, so the ordering is asserted directly.
|
|
80
88
|
*
|
|
81
89
|
* It matches whole tokens. A skill name is a complete name rather than a word
|
|
82
90
|
* stem, and the corpus holds one word that opens with a name and means
|
package/src/pr/evidence.ts
CHANGED
|
@@ -7,6 +7,13 @@ const MARKER_PREFIX = '<!-- pr-evidence:'
|
|
|
7
7
|
/** How the first line of a body names the branch's preview deployment. */
|
|
8
8
|
const PREVIEW_PREFIX = '**Preview:**'
|
|
9
9
|
|
|
10
|
+
/** The heading the checklist sits under, printed outside the delimiters so a carried checklist never doubles it. */
|
|
11
|
+
const CHECKLIST_HEADING = '## What to look at'
|
|
12
|
+
|
|
13
|
+
/** Delimiters around the checklist, so a re-render can read back what a reviewer has already ticked. */
|
|
14
|
+
const CHECKLIST_START = '<!-- pr-checklist:start -->'
|
|
15
|
+
const CHECKLIST_END = '<!-- pr-checklist:end -->'
|
|
16
|
+
|
|
10
17
|
/** A `#issuecomment-<id>` suffix, which is the REST comment id `gh pr view` never returns directly. */
|
|
11
18
|
const ISSUE_COMMENT_ID = /#issuecomment-(\d+)$/
|
|
12
19
|
|
|
@@ -116,6 +123,9 @@ export function evidenceMarker(head: string): string {
|
|
|
116
123
|
* A preview address opens the body, and with no states it is the whole body
|
|
117
124
|
* apart from the marker, so a pull request whose screenshots did not change
|
|
118
125
|
* still gets one comment a later call can find and edit in place.
|
|
126
|
+
*
|
|
127
|
+
* A checklist closes the body, below the comparison it annotates, so one
|
|
128
|
+
* comment carries the preview address, the screenshots, and what to look at.
|
|
119
129
|
*/
|
|
120
130
|
export function renderEvidenceBody(
|
|
121
131
|
states: readonly EvidenceState[],
|
|
@@ -123,11 +133,16 @@ export function renderEvidenceBody(
|
|
|
123
133
|
base: string,
|
|
124
134
|
head: string,
|
|
125
135
|
preview?: string,
|
|
136
|
+
checklist?: string,
|
|
126
137
|
): string {
|
|
127
138
|
const opening =
|
|
128
139
|
preview === undefined ? [] : [`${PREVIEW_PREFIX} ${preview}`, '']
|
|
140
|
+
const closing =
|
|
141
|
+
checklist === undefined
|
|
142
|
+
? []
|
|
143
|
+
: [CHECKLIST_HEADING, '', CHECKLIST_START, checklist, CHECKLIST_END, '']
|
|
129
144
|
if (states.length === 0) {
|
|
130
|
-
return [...opening, evidenceMarker(head)].join('\n')
|
|
145
|
+
return [...opening, ...closing, evidenceMarker(head)].join('\n')
|
|
131
146
|
}
|
|
132
147
|
|
|
133
148
|
const sections = states.map((entry) => {
|
|
@@ -157,6 +172,7 @@ export function renderEvidenceBody(
|
|
|
157
172
|
'',
|
|
158
173
|
...sections,
|
|
159
174
|
'',
|
|
175
|
+
...closing,
|
|
160
176
|
evidenceMarker(head),
|
|
161
177
|
].join('\n')
|
|
162
178
|
}
|
|
@@ -204,3 +220,22 @@ export function findEvidencePreview(
|
|
|
204
220
|
if (!first.startsWith(PREVIEW_PREFIX)) return undefined
|
|
205
221
|
return first.slice(PREVIEW_PREFIX.length).trim() || undefined
|
|
206
222
|
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* The checklist the marked comment already carries, read back out of its
|
|
226
|
+
* delimiters. The handoff file is deleted once posted, so every later render
|
|
227
|
+
* passes no checklist of its own, and carrying this one forward is what keeps
|
|
228
|
+
* a re-render after a push from wiping the boxes a reviewer has ticked.
|
|
229
|
+
*/
|
|
230
|
+
export function findEvidenceChecklist(
|
|
231
|
+
comments: readonly EvidenceComment[],
|
|
232
|
+
): string | undefined {
|
|
233
|
+
const marked = comments.find((comment) => hasEvidenceMarker(comment.body))
|
|
234
|
+
if (marked === undefined) return undefined
|
|
235
|
+
const start = marked.body.indexOf(CHECKLIST_START)
|
|
236
|
+
const end = marked.body.indexOf(CHECKLIST_END, start)
|
|
237
|
+
if (start === -1 || end === -1) return undefined
|
|
238
|
+
return (
|
|
239
|
+
marked.body.slice(start + CHECKLIST_START.length, end).trim() || undefined
|
|
240
|
+
)
|
|
241
|
+
}
|
package/src/sandbox/expect.ts
CHANGED
|
@@ -178,7 +178,8 @@ function contentArray(value: unknown): ContentAssertion[] {
|
|
|
178
178
|
// A bare key written below a `[[content]]` header belongs to that table in
|
|
179
179
|
// TOML, not to the document, so a declaration listing `manual` or
|
|
180
180
|
// `max_turns` after its content blocks parses clean and silently asserts
|
|
181
|
-
// neither. The `claude/ui-test
|
|
181
|
+
// neither. The arm then named `claude/ui-test`, now
|
|
182
|
+
// `claude/ui-checklist`, shipped that way: a turn ceiling that
|
|
182
183
|
// never ran and five manual entries that never reached the unchecked count,
|
|
183
184
|
// while `canon sandbox coverage` counted the arm as armed. Nothing at the
|
|
184
185
|
// top level can see the difference, so the check belongs here.
|
package/src/tasks/archive.ts
CHANGED
|
@@ -7,6 +7,7 @@ import { recordDir, recordDirs } from '@/record-root'
|
|
|
7
7
|
|
|
8
8
|
const TASKS = 'tasks'
|
|
9
9
|
const PLANS = 'plans'
|
|
10
|
+
const READY = 'ready'
|
|
10
11
|
const ARCHIVE = 'archive'
|
|
11
12
|
const DECLINED = 'declined'
|
|
12
13
|
const BACKLOG = 'backlog.md'
|
|
@@ -75,6 +76,12 @@ export interface PlanMove {
|
|
|
75
76
|
readonly to: string
|
|
76
77
|
}
|
|
77
78
|
|
|
79
|
+
/** A ready folder carried into the archive alongside the plan that is its verbatim source. */
|
|
80
|
+
export interface ReadyMove {
|
|
81
|
+
readonly from: string
|
|
82
|
+
readonly to: string
|
|
83
|
+
}
|
|
84
|
+
|
|
78
85
|
export interface ArchiveSuccess {
|
|
79
86
|
readonly ok: true
|
|
80
87
|
readonly stem: string
|
|
@@ -84,6 +91,8 @@ export interface ArchiveSuccess {
|
|
|
84
91
|
readonly indexRegenerated: boolean
|
|
85
92
|
/** Undefined when the task cited no live plan, or when another task still holds it. */
|
|
86
93
|
readonly plan: PlanMove | undefined
|
|
94
|
+
/** Undefined when the plan stays, or when no ready folder resolves and can move. */
|
|
95
|
+
readonly ready: ReadyMove | undefined
|
|
87
96
|
readonly closed: number
|
|
88
97
|
readonly cut: number
|
|
89
98
|
}
|
|
@@ -280,6 +289,32 @@ export function retargetPlanLine(text: string, target: string): string {
|
|
|
280
289
|
return text.replace(PLAN_PATTERN, () => planLine(target))
|
|
281
290
|
}
|
|
282
291
|
|
|
292
|
+
/** The `Ready:` line in either form, matched the way `PLAN_PATTERN` matches `Plan:`. */
|
|
293
|
+
const READY_PATTERN = /^Ready:[ \t]*(?:\[[^\]]*\]\(([^)]+)\)|(\S+))[ \t]*$/m
|
|
294
|
+
|
|
295
|
+
/** A ready folder path as a plan's constraints write it, the folder name captured. */
|
|
296
|
+
const READY_PATH_PATTERN = /(\.(?:canon|claude)\/ready\/)([^\s/`)]+)(?=\/)/g
|
|
297
|
+
|
|
298
|
+
export function readReadyTarget(text: string): string | undefined {
|
|
299
|
+
const match = READY_PATTERN.exec(text)
|
|
300
|
+
if (!match) return undefined
|
|
301
|
+
return match[1] ?? match[2]
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* Builds a `Ready:` line as a link whose target keeps the trailing slash a
|
|
306
|
+
* folder carries, the label taken from the folder's own name.
|
|
307
|
+
*/
|
|
308
|
+
export function readyLine(target: string): string {
|
|
309
|
+
const folder = target.replace(/\/+$/, '')
|
|
310
|
+
|
|
311
|
+
return `Ready: [${basename(folder)}](${folder}/)`
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
export function retargetReadyLine(text: string, target: string): string {
|
|
315
|
+
return text.replace(READY_PATTERN, () => readyLine(target))
|
|
316
|
+
}
|
|
317
|
+
|
|
283
318
|
/**
|
|
284
319
|
* Builds the `Declined:` line recording why a task was decided against and by
|
|
285
320
|
* whom. Free prose after the colon, since the line names no file to link,
|
|
@@ -731,22 +766,41 @@ export async function archiveTask(
|
|
|
731
766
|
}
|
|
732
767
|
|
|
733
768
|
const plan = await planToArchive(dir, root, stem, text)
|
|
769
|
+
const ready = plan && (await readyToArchive(dir, root, text, plan))
|
|
734
770
|
const destination = archiveDir(root)
|
|
735
771
|
const to = join(destination, `${stem}.md`)
|
|
736
772
|
|
|
737
773
|
// The plan moves first so the line written below describes a file already at
|
|
738
774
|
// its new path. Writing the retarget first and failing the move would leave a
|
|
739
775
|
// pointer at a folder holding nothing, and `.canon/plans/` is gitignored, so
|
|
740
|
-
// no history recovers the target it named.
|
|
776
|
+
// no history recovers the target it named. The folder follows the plan and
|
|
777
|
+
// both precede the task, so a failed rename never leaves the task archived
|
|
778
|
+
// with its folder live, which the unattended post-merge hook cannot repair.
|
|
741
779
|
if (plan) {
|
|
742
780
|
await mkdir(dirname(plan.to), { recursive: true })
|
|
743
781
|
await rename(plan.from, plan.to)
|
|
744
782
|
}
|
|
745
783
|
|
|
784
|
+
if (ready) {
|
|
785
|
+
await mkdir(dirname(ready.to), { recursive: true })
|
|
786
|
+
await rename(ready.from, ready.to)
|
|
787
|
+
await writeFile(
|
|
788
|
+
plan.to,
|
|
789
|
+
retargetReadyPaths(await readFile(plan.to, 'utf8'), basename(ready.from)),
|
|
790
|
+
)
|
|
791
|
+
}
|
|
792
|
+
|
|
746
793
|
await mkdir(destination, { recursive: true })
|
|
747
794
|
await rename(from, to)
|
|
748
|
-
if (plan)
|
|
749
|
-
|
|
795
|
+
if (plan) {
|
|
796
|
+
const retargeted = retargetPlanLine(text, linkTo(destination, plan.to))
|
|
797
|
+
await writeFile(
|
|
798
|
+
to,
|
|
799
|
+
ready
|
|
800
|
+
? retargetReadyLine(retargeted, `${linkTo(destination, ready.to)}/`)
|
|
801
|
+
: retargeted,
|
|
802
|
+
)
|
|
803
|
+
}
|
|
750
804
|
|
|
751
805
|
const priorityRowRemoved = await clearPriorityRow(dir, stem)
|
|
752
806
|
const regen = await regenOne(dir, { dryRun: false })
|
|
@@ -759,6 +813,7 @@ export async function archiveTask(
|
|
|
759
813
|
priorityRowRemoved,
|
|
760
814
|
indexRegenerated: regen.action === 'written',
|
|
761
815
|
plan,
|
|
816
|
+
ready: ready || undefined,
|
|
762
817
|
closed: closed.length,
|
|
763
818
|
cut: cut.length,
|
|
764
819
|
}
|
|
@@ -797,6 +852,63 @@ async function planToArchive(
|
|
|
797
852
|
}
|
|
798
853
|
}
|
|
799
854
|
|
|
855
|
+
/**
|
|
856
|
+
* The ready folder that travels with the plan, or nothing. It is read from the
|
|
857
|
+
* task's `Ready:` line, falling back to a folder path in the plan's own text for
|
|
858
|
+
* a task written before the line was defined. It moves only with its plan,
|
|
859
|
+
* since the folder is the plan's verbatim source and a plan another task still
|
|
860
|
+
* cites still needs it, so the caller passes the plan `planToArchive` settled.
|
|
861
|
+
*
|
|
862
|
+
* A target naming no folder, one already archived, or a destination taken
|
|
863
|
+
* yields nothing and refuses nothing, the way a mistyped `Plan:` does.
|
|
864
|
+
*/
|
|
865
|
+
async function readyToArchive(
|
|
866
|
+
dir: string,
|
|
867
|
+
root: string,
|
|
868
|
+
text: string,
|
|
869
|
+
plan: PlanMove,
|
|
870
|
+
): Promise<ReadyMove | undefined> {
|
|
871
|
+
const live =
|
|
872
|
+
readyFolder(dir, root, text) ??
|
|
873
|
+
readyFolder(dir, root, await readFile(plan.from, 'utf8'))
|
|
874
|
+
if (!live) return undefined
|
|
875
|
+
|
|
876
|
+
const to = join(recordDir(root, READY, ARCHIVE), basename(live))
|
|
877
|
+
if (existsSync(to)) return undefined
|
|
878
|
+
|
|
879
|
+
return { from: live, to }
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
function readyFolder(
|
|
883
|
+
dir: string,
|
|
884
|
+
root: string,
|
|
885
|
+
text: string,
|
|
886
|
+
): string | undefined {
|
|
887
|
+
const target = readReadyTarget(text)
|
|
888
|
+
const named = target ? [resolve(dir, target), resolve(root, target)] : []
|
|
889
|
+
const constrained = [...text.matchAll(READY_PATH_PATTERN)].map(([, , name]) =>
|
|
890
|
+
join(recordDir(root, READY), name),
|
|
891
|
+
)
|
|
892
|
+
const archives = recordDirs(root, READY, ARCHIVE)
|
|
893
|
+
|
|
894
|
+
return [...named, ...constrained].find(
|
|
895
|
+
(path) =>
|
|
896
|
+
recordDirs(root, READY).some((base) => dirname(path) === base) &&
|
|
897
|
+
basename(path) !== ARCHIVE &&
|
|
898
|
+
!archives.some((base) => isUnder(path, base)) &&
|
|
899
|
+
existsSync(path),
|
|
900
|
+
)
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
/** Points the plan's literal folder paths at `ready/archive/`, since the folder no longer sits where they say. */
|
|
904
|
+
function retargetReadyPaths(text: string, folder: string): string {
|
|
905
|
+
return text.replace(
|
|
906
|
+
READY_PATH_PATTERN,
|
|
907
|
+
(whole: string, prefix: string, name: string) =>
|
|
908
|
+
name === folder ? `${prefix}${ARCHIVE}/${name}` : whole,
|
|
909
|
+
)
|
|
910
|
+
}
|
|
911
|
+
|
|
800
912
|
/**
|
|
801
913
|
* The archived plan as the archived task cites it. Both halves land a folder
|
|
802
914
|
* deeper than the live pair, so the link is measured between the two
|
package/standards/ready.md
CHANGED
|
@@ -78,7 +78,7 @@ Below the frontmatter, state in prose what the worker still owns beyond copying
|
|
|
78
78
|
## Lifecycle
|
|
79
79
|
|
|
80
80
|
- Write the ready folder in the same session that writes the files it carries. A folder assembled later from memory is a plan with extra steps, not a handoff.
|
|
81
|
-
-
|
|
81
|
+
- Leave the move to `canon tasks archive`, which carries the folder to `.canon/ready/archive/<nn>-<slug>/` with its plan when the task that shipped it archives, and retargets the task's `Ready:` line. A plan another live task still cites keeps its folder.
|
|
82
82
|
- Never delete a ready folder. The archived copy sits beside the merged pull request as the exact text that shipped, the way an archived plan sits beside the reasoning that produced it.
|
|
83
83
|
|
|
84
84
|
## Anti-patterns
|
|
@@ -86,7 +86,7 @@ Below the frontmatter, state in prose what the worker still owns beyond copying
|
|
|
86
86
|
- **The folder with an undeclared destination.** A file the plan's `**Files to touch:**` does not list passes the collision check unseen, and a second track can write the same path without either side finding out.
|
|
87
87
|
- **The rewritten copy.** A worker that reads the folder's files as inspiration and writes its own version loses the exact text the handoff exists to carry.
|
|
88
88
|
- **The folder as scratch.** Notes, alternates, or draft passages left in the folder beside the real files leave the worker guessing which is the source.
|
|
89
|
-
- **The folder left live after shipping.** A ready folder
|
|
89
|
+
- **The folder left live after shipping.** A ready folder the archive verb did not move reads as unshipped work to the next session that lists the live folder. The verb skips one whose task carries no `Ready:` line and cites no folder path in its plan.
|
|
90
90
|
|
|
91
91
|
## Template
|
|
92
92
|
|