@erclx/canon 5.27.0 → 5.29.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/draft-ready/SKILL.md +3 -1
- package/claude/skills/git-pr/references/evidence.md +1 -1
- package/claude/skills/review-address/REQUIREMENT.md +4 -2
- package/claude/skills/review-address/SKILL.md +16 -7
- package/claude/skills/review-address/references/rebase-conflicts.md +4 -2
- package/claude/skills/review-craft/references/ui.md +4 -4
- package/claude/skills/review-ui/REQUIREMENT.md +2 -2
- package/claude/skills/review-ui/SKILL.md +13 -11
- package/claude/skills/review-ui/references/driving.md +8 -8
- package/claude/skills/review-ui/references/post.md +19 -7
- package/claude/skills/review-ui/references/tick.md +22 -0
- package/claude/skills/role-orchestrator/REQUIREMENT.md +15 -15
- package/claude/skills/role-orchestrator/SKILL.md +24 -26
- package/claude/skills/role-orchestrator/references/orchestrator-dispatch.md +18 -16
- package/claude/skills/role-orchestrator/references/orchestrator-handback.md +12 -2
- package/claude/skills/role-orchestrator/references/orchestrator-inbound.md +1 -1
- package/claude/skills/role-orchestrator/references/orchestrator-launch.md +1 -1
- package/claude/skills/role-orchestrator/references/orchestrator-output.md +4 -4
- package/claude/skills/role-orchestrator/references/orchestrator-parked.md +5 -5
- package/claude/skills/role-orchestrator/references/orchestrator-poll.md +11 -2
- package/claude/skills/role-orchestrator/references/orchestrator-refill.md +6 -6
- package/claude/skills/role-orchestrator/references/orchestrator-sweep.md +2 -2
- package/claude/skills/role-orchestrator/scripts/poll.ts +304 -10
- package/claude/skills/role-planner/REQUIREMENT.md +1 -1
- package/claude/skills/role-planner/SKILL.md +2 -2
- package/claude/skills/role-reviewer/SKILL.md +1 -1
- package/claude/skills/role-worker/SKILL.md +3 -2
- package/claude/skills/ui-checklist/SKILL.md +1 -1
- package/docs/agents/canvas.md +12 -4
- package/docs/agents/gate.md +3 -1
- package/docs/agents/index.md +2 -1
- package/docs/agents/pr-evidence.md +15 -14
- package/docs/agents/pr-tick.md +72 -0
- package/docs/agents/sandbox.md +2 -2
- package/docs/agents/tasks-plans.md +1 -1
- package/docs/agents/tasks-validate.md +18 -18
- package/docs/agents/tasks.md +1 -1
- package/docs/workflow/operating-model.md +10 -15
- package/docs/workflow/parallel-features.md +7 -7
- package/package.json +1 -1
- package/src/audits/baseline.ts +1 -1
- package/src/audits/run.ts +1 -1
- package/src/canvas/capture.ts +1 -1
- package/src/canvas/client/app.tsx +105 -4
- package/src/canvas/client/inspector/field.tsx +61 -2
- package/src/canvas/client/inspector/fields.css +78 -0
- package/src/canvas/client/inspector/token-select.tsx +109 -0
- package/src/canvas/client/inspector/values.ts +74 -2
- package/src/canvas/client/inspector.tsx +134 -15
- package/src/canvas/client/keys.ts +71 -0
- package/src/canvas/client/pages-panel.tsx +5 -1
- package/src/canvas/client/panel-handle.tsx +90 -0
- package/src/canvas/client/selection.tsx +3 -1
- package/src/canvas/client/shell.css +194 -4
- package/src/canvas/client/state.ts +255 -11
- package/src/canvas/client/surface.tsx +231 -18
- package/src/canvas/client/tools.tsx +153 -0
- package/src/canvas/content.ts +66 -1
- package/src/canvas/edit.ts +207 -25
- package/src/canvas/history.ts +116 -0
- package/src/canvas/server.ts +119 -13
- package/src/canvas/tokens.ts +1 -1
- package/src/claude/seeds.ts +2 -2
- package/src/claude/skills-reach.ts +1 -1
- package/src/{exec.ts → cli/exec.ts} +2 -2
- package/src/{help.ts → cli/help.ts} +1 -1
- package/src/cli.ts +1 -1
- package/src/commands/audits.ts +9 -1
- package/src/commands/autoship.ts +1 -1
- package/src/commands/canvas.ts +2 -2
- package/src/commands/capture.ts +1 -1
- package/src/commands/census.ts +1 -1
- package/src/commands/claude.ts +5 -5
- package/src/commands/comments.ts +1 -1
- package/src/commands/context.ts +1 -1
- package/src/commands/demo.ts +9 -1
- package/src/commands/deps.ts +1 -1
- package/src/commands/design.ts +12 -4
- package/src/commands/docs.ts +2 -2
- package/src/commands/driver.ts +9 -1
- package/src/commands/feedback.ts +3 -3
- package/src/commands/gate.ts +10 -3
- package/src/commands/gov.ts +3 -3
- package/src/commands/indexes.ts +1 -1
- package/src/commands/init.ts +11 -3
- package/src/commands/intake.ts +1 -1
- package/src/commands/inventory.ts +9 -1
- package/src/commands/labels.ts +1 -1
- package/src/commands/markdown.ts +1 -1
- package/src/commands/migrate.ts +2 -2
- package/src/commands/pr.ts +324 -9
- package/src/commands/records.ts +1 -1
- package/src/commands/repo.ts +1 -1
- package/src/commands/sandbox.ts +11 -14
- package/src/commands/secrets.ts +9 -1
- package/src/commands/sessions.ts +1 -1
- package/src/commands/slides.ts +2 -2
- package/src/commands/standards.ts +1 -1
- package/src/commands/sync.ts +3 -3
- package/src/commands/targets.ts +1 -1
- package/src/commands/tasks.ts +20 -3
- package/src/commands/teach.ts +1 -1
- package/src/commands/tooling.ts +2 -2
- package/src/commands/transcripts.ts +2 -2
- package/src/commands/upgrade.ts +2 -2
- package/src/commands/wiki.ts +2 -2
- package/src/commands/worktrees.ts +1 -1
- package/src/context/architecture.ts +1 -1
- package/src/context/citations.ts +2 -2
- package/src/context/classify/extract.ts +1 -1
- package/src/context/folders.ts +2 -2
- package/src/context/requirements.ts +1 -1
- package/src/docs/read.ts +1 -1
- package/src/feedback/github.ts +1 -1
- package/src/gate/measures.ts +2 -2
- package/src/gate/sequencer.ts +55 -3
- package/src/gate/stages.ts +8 -0
- package/src/gate/suite-lock.ts +214 -0
- package/src/init/run.ts +1 -1
- package/src/intake/folder.ts +1 -1
- package/src/labels/map.ts +1 -1
- package/src/labels/phase.ts +3 -3
- package/src/migrate/record-layout.ts +1 -1
- package/src/migrate/records.ts +11 -11
- package/src/migrate/rename.ts +1 -1
- package/src/migrate/scratch-evidence.ts +1 -1
- package/src/migrate/surface-roots.ts +6 -6
- package/src/pr/evidence.ts +188 -16
- package/src/pr/tick.ts +56 -0
- package/src/process/harness.ts +1 -1
- package/src/records/backup.ts +1 -1
- package/src/records/ordinal.ts +1 -1
- package/src/records/prune.ts +1 -1
- package/src/records/size.ts +1 -1
- package/src/records/stale-canonical.ts +1 -1
- package/src/records/stale.ts +1 -1
- package/src/records/validate.ts +2 -2
- package/src/{project-root.ts → roots/project.ts} +2 -2
- package/src/{record-root.ts → roots/record.ts} +1 -1
- package/src/sandbox/coverage.ts +11 -12
- package/src/sandbox/equivalence.ts +57 -26
- package/src/sandbox/headless/run.ts +1 -1
- package/src/sandbox/hooks.ts +1 -1
- package/src/sandbox/provision.ts +79 -29
- package/src/sandbox/scenario.ts +588 -0
- package/src/serve/report.ts +1 -1
- package/src/sessions/live.ts +4 -1
- package/src/sessions/transfer/bundle.ts +1 -1
- package/src/slides/decks.ts +1 -1
- package/src/standards/read.ts +1 -1
- package/src/sync/engine.ts +2 -2
- package/src/sync/git.ts +1 -1
- package/src/sync/layout.ts +2 -2
- package/src/sync/stamp.ts +1 -1
- package/src/sync/workflow.ts +1 -1
- package/src/targets/validate.ts +2 -2
- package/src/tasks/answers.ts +1 -1
- package/src/tasks/archive.ts +1 -1
- package/src/tasks/label.ts +1 -1
- package/src/tasks/reach.ts +1 -1
- package/src/tasks/validate.ts +38 -14
- package/src/teach/nav.ts +1 -1
- package/src/teach/workspace.ts +1 -1
- package/src/tooling/inject.ts +2 -2
- package/src/tooling/read.ts +1 -1
- package/src/tooling/scan.ts +1 -1
- package/src/tooling/verify-stack.ts +1 -1
- package/src/version/installed.ts +1 -1
- package/src/version/skew.ts +1 -1
- package/standards/board.md +3 -3
- package/standards/plan.md +13 -7
- package/tsconfig.json +1 -1
- /package/src/{cli-run.ts → cli/run.ts} +0 -0
- /package/src/{ui.ts → cli/ui.ts} +0 -0
- /package/src/{legacy-path.ts → roots/legacy.ts} +0 -0
- /package/src/{surface-root.ts → roots/surface.ts} +0 -0
package/src/pr/evidence.ts
CHANGED
|
@@ -29,8 +29,17 @@ const BASE_PREFIX = '**Base:**'
|
|
|
29
29
|
const CHECKLIST_HEADING = '## What to look at'
|
|
30
30
|
|
|
31
31
|
/** Delimiters around the checklist, so a re-render can read back what a reviewer has already ticked. */
|
|
32
|
-
const CHECKLIST_START = '<!-- pr-checklist:start -->'
|
|
33
|
-
const CHECKLIST_END = '<!-- pr-checklist:end -->'
|
|
32
|
+
export const CHECKLIST_START = '<!-- pr-checklist:start -->'
|
|
33
|
+
export const CHECKLIST_END = '<!-- pr-checklist:end -->'
|
|
34
|
+
|
|
35
|
+
/** One checklist line, a list marker then a box that GitHub renders for `x` in either case. */
|
|
36
|
+
const BOX_LINE = /^(\s*[-*+] \[)([ xX])(\] )(.*)$/
|
|
37
|
+
|
|
38
|
+
/** The trailing segment a tick carries, naming the commit the box passed at. Matched only at the end of a line, so a box whose own text says `passed at` is left alone. */
|
|
39
|
+
const STAMP = / · passed at `([0-9a-f]{7,40})`$/
|
|
40
|
+
|
|
41
|
+
/** What ends the text of a box that a person has to judge by eye. */
|
|
42
|
+
const TASTE_SUFFIX = '(taste)'
|
|
34
43
|
|
|
35
44
|
/** A `#issuecomment-<id>` suffix, which is the REST comment id `gh pr view` never returns directly. */
|
|
36
45
|
const ISSUE_COMMENT_ID = /#issuecomment-(\d+)$/
|
|
@@ -38,8 +47,8 @@ const ISSUE_COMMENT_ID = /#issuecomment-(\d+)$/
|
|
|
38
47
|
/**
|
|
39
48
|
* The image extensions this module embeds. An `evidence/` folder holds
|
|
40
49
|
* whatever else a project keeps beside its captures, such as a README, a
|
|
41
|
-
* capture script, or a raw data file, and none of those render as
|
|
42
|
-
*
|
|
50
|
+
* capture script, or a raw data file, and none of those render as an
|
|
51
|
+
* `<img>` without producing a broken embed.
|
|
43
52
|
*/
|
|
44
53
|
const IMAGE_EXTENSION = /\.(png|jpe?g|gif|webp|avif|svg)$/i
|
|
45
54
|
|
|
@@ -48,6 +57,10 @@ export interface EvidenceItem {
|
|
|
48
57
|
readonly stem: string
|
|
49
58
|
/** True when `path` did not exist at the comparison's base commit. */
|
|
50
59
|
readonly added: boolean
|
|
60
|
+
/** Pixel width of the head image, read from its PNG header. Absent when unread. */
|
|
61
|
+
readonly width?: number
|
|
62
|
+
/** Pixel width of the base image, absent for an added case or an unread header. */
|
|
63
|
+
readonly baseWidth?: number
|
|
51
64
|
}
|
|
52
65
|
|
|
53
66
|
export interface EvidenceState {
|
|
@@ -130,6 +143,57 @@ function rawUrl(repo: string, sha: string, path: string): string {
|
|
|
130
143
|
return `https://github.com/${repo}/blob/${sha}/${path}?raw=true`
|
|
131
144
|
}
|
|
132
145
|
|
|
146
|
+
/**
|
|
147
|
+
* The most pixels a screenshot asks for. GitHub sizes a comment table to its
|
|
148
|
+
* content and clamps an image to its cell, so a width past any comment fills
|
|
149
|
+
* the cell, where `width="100%"` leaves a narrow screenshot narrow. An image
|
|
150
|
+
* asks for the smaller of its own width and this ceiling, so a small crop
|
|
151
|
+
* shows at its true size and is never scaled up. An image whose width was not
|
|
152
|
+
* read asks for the ceiling.
|
|
153
|
+
*/
|
|
154
|
+
const IMAGE_WIDTH = 1000
|
|
155
|
+
|
|
156
|
+
const PNG_SIGNATURE = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]
|
|
157
|
+
|
|
158
|
+
/** Bytes of a PNG that reach the end of IHDR's width field. */
|
|
159
|
+
export const PNG_HEADER_BYTES = 24
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* The pixel width in a PNG's IHDR chunk, or undefined for bytes that are not
|
|
163
|
+
* a PNG with a header or that carry a zero width.
|
|
164
|
+
*/
|
|
165
|
+
export function readPngWidth(bytes: Uint8Array): number | undefined {
|
|
166
|
+
if (bytes.length < PNG_HEADER_BYTES) return undefined
|
|
167
|
+
if (PNG_SIGNATURE.some((byte, index) => bytes[index] !== byte)) {
|
|
168
|
+
return undefined
|
|
169
|
+
}
|
|
170
|
+
const width = new DataView(bytes.buffer, bytes.byteOffset).getUint32(16)
|
|
171
|
+
return width > 0 ? width : undefined
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** A table cell and an attribute value share one escape, since a stem is a path segment placed in both. */
|
|
175
|
+
function escapeCell(text: string): string {
|
|
176
|
+
return text
|
|
177
|
+
.replaceAll('&', '&')
|
|
178
|
+
.replaceAll('<', '<')
|
|
179
|
+
.replaceAll('>', '>')
|
|
180
|
+
.replaceAll('"', '"')
|
|
181
|
+
.replaceAll('|', '|')
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function evidenceImage(url: string, alt: string, width?: number): string {
|
|
185
|
+
const shown = Math.min(width ?? IMAGE_WIDTH, IMAGE_WIDTH)
|
|
186
|
+
return `<img src="${url}" width="${shown}" alt="${alt}">`
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const EVIDENCE_IMAGE = String.raw`(?:!\[\]\([^)]*\)|<img [^>]*>)`
|
|
190
|
+
|
|
191
|
+
/** A case row in any shape a render has posted: a Base cell before the head image, or the head image alone. */
|
|
192
|
+
const EVIDENCE_ROW_PATTERN = new RegExp(
|
|
193
|
+
String.raw`^\| .+ \| (?:(?:\*\(new\)\*|${EVIDENCE_IMAGE}) \| )?${EVIDENCE_IMAGE} \|$`,
|
|
194
|
+
'gm',
|
|
195
|
+
)
|
|
196
|
+
|
|
133
197
|
export function evidenceMarker(head: string): string {
|
|
134
198
|
return `${MARKER_PREFIX} head=${head} -->`
|
|
135
199
|
}
|
|
@@ -189,20 +253,32 @@ export function renderEvidenceBody(
|
|
|
189
253
|
imageCount <= EVIDENCE_OPEN_LIMIT ? '<details open>' : '<details>'
|
|
190
254
|
|
|
191
255
|
const sections = states.map((entry) => {
|
|
256
|
+
const hasBase = entry.items.some((item) => !item.added)
|
|
192
257
|
const rows = entry.items.map((item) => {
|
|
258
|
+
const stem = escapeCell(item.stem)
|
|
193
259
|
const before = item.added
|
|
194
260
|
? '*(new)*'
|
|
195
|
-
:
|
|
196
|
-
|
|
197
|
-
|
|
261
|
+
: evidenceImage(
|
|
262
|
+
rawUrl(repo, base, item.path),
|
|
263
|
+
`${stem} base`,
|
|
264
|
+
item.baseWidth,
|
|
265
|
+
)
|
|
266
|
+
const after = evidenceImage(
|
|
267
|
+
rawUrl(repo, head, item.path),
|
|
268
|
+
`${stem} head`,
|
|
269
|
+
item.width,
|
|
270
|
+
)
|
|
271
|
+
return hasBase
|
|
272
|
+
? `| ${stem} | ${before} | ${after} |`
|
|
273
|
+
: `| ${stem} | ${after} |`
|
|
198
274
|
})
|
|
199
275
|
|
|
200
276
|
return [
|
|
201
277
|
opener,
|
|
202
278
|
`<summary>${entry.state === '' ? 'evidence' : entry.state} (${entry.items.length})</summary>`,
|
|
203
279
|
'',
|
|
204
|
-
'| Case | Base | Head |',
|
|
205
|
-
'| --- | --- | --- |',
|
|
280
|
+
hasBase ? '| Case | Base | Head |' : '| Case | Head |',
|
|
281
|
+
hasBase ? '| --- | --- | --- |' : '| --- | --- |',
|
|
206
282
|
...rows,
|
|
207
283
|
'',
|
|
208
284
|
'</details>',
|
|
@@ -221,16 +297,14 @@ export function renderEvidenceBody(
|
|
|
221
297
|
}
|
|
222
298
|
|
|
223
299
|
/**
|
|
224
|
-
* How many case rows a rendered body carries, read off the
|
|
300
|
+
* How many case rows a rendered body carries, read off the row shapes
|
|
225
301
|
* `renderEvidenceBody` writes. A row always ends in a head image, which a
|
|
226
|
-
* checklist line or a preview address never does.
|
|
302
|
+
* checklist line or a preview address never does. The pattern also reads the
|
|
303
|
+
* `` three-column shape an earlier render posted, so a comment posted
|
|
304
|
+
* before the change still counts its cases and the `would-empty` guard holds.
|
|
227
305
|
*/
|
|
228
306
|
export function countEvidenceCases(body: string): number {
|
|
229
|
-
return (
|
|
230
|
-
body.match(
|
|
231
|
-
/^\| .+ \| (?:\*\(new\)\*|!\[\]\([^)]*\)) \| !\[\]\([^)]*\) \|$/gm,
|
|
232
|
-
)?.length ?? 0
|
|
233
|
-
)
|
|
307
|
+
return body.match(EVIDENCE_ROW_PATTERN)?.length ?? 0
|
|
234
308
|
}
|
|
235
309
|
|
|
236
310
|
export interface EvidenceComment {
|
|
@@ -410,3 +484,101 @@ export function findEvidenceChecklist(
|
|
|
410
484
|
marked.body.slice(start + CHECKLIST_START.length, end).trim() || undefined
|
|
411
485
|
)
|
|
412
486
|
}
|
|
487
|
+
|
|
488
|
+
export interface ChecklistBox {
|
|
489
|
+
/** One-based position among the checklist's boxes, in the order they appear. */
|
|
490
|
+
readonly number: number
|
|
491
|
+
/** Index of the box's line within the checklist. */
|
|
492
|
+
readonly line: number
|
|
493
|
+
/** The box text with any stamp removed. */
|
|
494
|
+
readonly text: string
|
|
495
|
+
readonly isTicked: boolean
|
|
496
|
+
/** The short sha a tick names, absent on an empty box or a tick nothing stamped. */
|
|
497
|
+
readonly stamp?: string
|
|
498
|
+
/** Whether the box ends in `(taste)`, which no driver ticks. */
|
|
499
|
+
readonly isTaste: boolean
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
export interface BoxLine {
|
|
503
|
+
readonly before: string
|
|
504
|
+
readonly isTicked: boolean
|
|
505
|
+
readonly text: string
|
|
506
|
+
readonly stamp?: string
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
/** Splits one checklist line into its box parts, or undefined when the line is not a box. */
|
|
510
|
+
export function parseBoxLine(line: string): BoxLine | undefined {
|
|
511
|
+
const match = BOX_LINE.exec(line)
|
|
512
|
+
if (match === null) return undefined
|
|
513
|
+
const rest = (match[4] ?? '').trimEnd()
|
|
514
|
+
const stamped = STAMP.exec(rest)
|
|
515
|
+
return {
|
|
516
|
+
before: match[1] ?? '',
|
|
517
|
+
isTicked: (match[2] ?? ' ') !== ' ',
|
|
518
|
+
text: stamped === null ? rest : rest.slice(0, stamped.index),
|
|
519
|
+
...(stamped?.[1] !== undefined && { stamp: stamped[1] }),
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
/** The box line for the given parts, written back in the one shape the reader accepts. */
|
|
524
|
+
export function writeBoxLine(
|
|
525
|
+
box: Pick<BoxLine, 'before' | 'text'>,
|
|
526
|
+
stampedAt?: string,
|
|
527
|
+
): string {
|
|
528
|
+
return stampedAt === undefined
|
|
529
|
+
? `${box.before} ] ${box.text}`
|
|
530
|
+
: `${box.before}x] ${box.text} · passed at \`${stampedAt.slice(0, SHORT_SHA_LENGTH)}\``
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
/** Every box of a checklist in order, numbered the way `canon pr tick` takes them. */
|
|
534
|
+
export function readChecklistBoxes(checklist: string): ChecklistBox[] {
|
|
535
|
+
const boxes: ChecklistBox[] = []
|
|
536
|
+
checklist.split('\n').forEach((line, index) => {
|
|
537
|
+
const parsed = parseBoxLine(line)
|
|
538
|
+
if (parsed === undefined) return
|
|
539
|
+
boxes.push({
|
|
540
|
+
number: boxes.length + 1,
|
|
541
|
+
line: index,
|
|
542
|
+
text: parsed.text,
|
|
543
|
+
isTicked: parsed.isTicked,
|
|
544
|
+
...(parsed.stamp !== undefined && { stamp: parsed.stamp }),
|
|
545
|
+
isTaste: parsed.text.endsWith(TASTE_SUFFIX),
|
|
546
|
+
})
|
|
547
|
+
})
|
|
548
|
+
return boxes
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
/** The head the marked comment's trailing marker names. */
|
|
552
|
+
export function findEvidenceHead(
|
|
553
|
+
comments: readonly EvidenceComment[],
|
|
554
|
+
): string | undefined {
|
|
555
|
+
const marked = comments.find((comment) => hasEvidenceMarker(comment.body))
|
|
556
|
+
if (marked === undefined) return undefined
|
|
557
|
+
const lines = marked.body.trimEnd().split('\n')
|
|
558
|
+
const match = /head=(\S+)/.exec(lines[lines.length - 1] ?? '')
|
|
559
|
+
return match?.[1]
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
/**
|
|
563
|
+
* Clears every tick the render's head has not earned. A stamped tick stays
|
|
564
|
+
* while its sha is a prefix of the head. An unstamped one names no head, so
|
|
565
|
+
* it stays only while the comment it came from described this same head.
|
|
566
|
+
*/
|
|
567
|
+
export function settleChecklist(
|
|
568
|
+
checklist: string,
|
|
569
|
+
head: string,
|
|
570
|
+
carriedHead: string | undefined,
|
|
571
|
+
): string {
|
|
572
|
+
return checklist
|
|
573
|
+
.split('\n')
|
|
574
|
+
.map((line) => {
|
|
575
|
+
const parsed = parseBoxLine(line)
|
|
576
|
+
if (parsed === undefined || !parsed.isTicked) return line
|
|
577
|
+
const isEarned =
|
|
578
|
+
parsed.stamp === undefined
|
|
579
|
+
? carriedHead === head
|
|
580
|
+
: head.startsWith(parsed.stamp)
|
|
581
|
+
return isEarned ? line : writeBoxLine(parsed)
|
|
582
|
+
})
|
|
583
|
+
.join('\n')
|
|
584
|
+
}
|
package/src/pr/tick.ts
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import {
|
|
2
|
+
CHECKLIST_END,
|
|
3
|
+
CHECKLIST_START,
|
|
4
|
+
parseBoxLine,
|
|
5
|
+
readChecklistBoxes,
|
|
6
|
+
writeBoxLine,
|
|
7
|
+
} from '@/pr/evidence'
|
|
8
|
+
|
|
9
|
+
export type TickRefusal = 'no-checklist' | 'no-box' | 'taste-box'
|
|
10
|
+
|
|
11
|
+
export type TickResult =
|
|
12
|
+
| { readonly kind: 'ticked'; readonly body: string }
|
|
13
|
+
| { readonly kind: 'refused'; readonly reason: TickRefusal }
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Ticks the named boxes of a marked body's checklist and stamps each with the
|
|
17
|
+
* commit it passed at. A refusal writes nothing, so a taste box named among
|
|
18
|
+
* passed ones leaves every box as it was. A box already ticked is restamped,
|
|
19
|
+
* which makes a repeat at one head a no-op.
|
|
20
|
+
*/
|
|
21
|
+
export function tickBoxes(
|
|
22
|
+
body: string,
|
|
23
|
+
numbers: readonly number[],
|
|
24
|
+
head: string,
|
|
25
|
+
): TickResult {
|
|
26
|
+
const start = body.indexOf(CHECKLIST_START)
|
|
27
|
+
const end = body.indexOf(CHECKLIST_END, start)
|
|
28
|
+
if (start === -1 || end === -1) {
|
|
29
|
+
return { kind: 'refused', reason: 'no-checklist' }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const from = start + CHECKLIST_START.length
|
|
33
|
+
const region = body.slice(from, end)
|
|
34
|
+
const boxes = readChecklistBoxes(region)
|
|
35
|
+
|
|
36
|
+
const named = numbers.map((number) =>
|
|
37
|
+
boxes.find((box) => box.number === number),
|
|
38
|
+
)
|
|
39
|
+
if (named.some((box) => box === undefined)) {
|
|
40
|
+
return { kind: 'refused', reason: 'no-box' }
|
|
41
|
+
}
|
|
42
|
+
if (named.some((box) => box?.isTaste === true)) {
|
|
43
|
+
return { kind: 'refused', reason: 'taste-box' }
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const lines = region.split('\n')
|
|
47
|
+
for (const box of named) {
|
|
48
|
+
if (box === undefined) continue
|
|
49
|
+
const parsed = parseBoxLine(lines[box.line] ?? '')
|
|
50
|
+
if (parsed !== undefined) lines[box.line] = writeBoxLine(parsed, head)
|
|
51
|
+
}
|
|
52
|
+
return {
|
|
53
|
+
kind: 'ticked',
|
|
54
|
+
body: `${body.slice(0, from)}${lines.join('\n')}${body.slice(end)}`,
|
|
55
|
+
}
|
|
56
|
+
}
|
package/src/process/harness.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { spawnSync } from 'node:child_process'
|
|
2
2
|
import { readdirSync, statSync } from 'node:fs'
|
|
3
3
|
import { join } from 'node:path'
|
|
4
|
-
import { PROJECT_ROOT } from '@/project
|
|
4
|
+
import { PROJECT_ROOT } from '@/roots/project'
|
|
5
5
|
import { stateDir } from '@/targets/registry'
|
|
6
6
|
|
|
7
7
|
const CLI = join(PROJECT_ROOT, 'src/cli.ts')
|
package/src/records/backup.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { existsSync, lstatSync, readdirSync } from 'node:fs'
|
|
|
2
2
|
import { basename, join, relative, resolve } from 'node:path'
|
|
3
3
|
import { $ } from 'bun'
|
|
4
4
|
import { gitEnv } from '@/git/env'
|
|
5
|
-
import { RECORD_ROOTS, recordRoot, SCRATCH } from '@/record
|
|
5
|
+
import { RECORD_ROOTS, recordRoot, SCRATCH } from '@/roots/record'
|
|
6
6
|
import { type BlockedPath, guardPayload } from '@/records/push-guard'
|
|
7
7
|
|
|
8
8
|
/**
|
package/src/records/ordinal.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { mkdir, readdir, rm, stat } from 'node:fs/promises'
|
|
2
2
|
import { extractOrdinal } from '@/intake/folder'
|
|
3
|
-
import { recordDir } from '@/record
|
|
3
|
+
import { recordDir } from '@/roots/record'
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
6
|
* The two record folders that share one ordinal sequence, per
|
package/src/records/prune.ts
CHANGED
|
@@ -3,7 +3,7 @@ import { readdir, rm, stat } from 'node:fs/promises'
|
|
|
3
3
|
import { join, relative } from 'node:path'
|
|
4
4
|
import { RECORD_LAYOUT_MOVES } from '@/migrate/record-layout'
|
|
5
5
|
import { PROMOTED_FOLDERS } from '@/migrate/scratch-evidence'
|
|
6
|
-
import { RECORD_ROOTS, recordDir, SCRATCH } from '@/record
|
|
6
|
+
import { RECORD_ROOTS, recordDir, SCRATCH } from '@/roots/record'
|
|
7
7
|
import { day, newestMtime } from '@/records/size'
|
|
8
8
|
|
|
9
9
|
const DAY_MS = 24 * 60 * 60 * 1000
|
package/src/records/size.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { existsSync, type Stats } from 'node:fs'
|
|
2
2
|
import { readdir, stat } from 'node:fs/promises'
|
|
3
3
|
import { join } from 'node:path'
|
|
4
|
-
import { RECORD_ROOTS, recordDir, SCRATCH } from '@/record
|
|
4
|
+
import { RECORD_ROOTS, recordDir, SCRATCH } from '@/roots/record'
|
|
5
5
|
import { candidateFolders } from '@/records/backup'
|
|
6
6
|
|
|
7
7
|
/**
|
|
@@ -4,7 +4,7 @@ import { readFile } from 'node:fs/promises'
|
|
|
4
4
|
import { relative } from 'node:path'
|
|
5
5
|
import { gitEnv } from '@/git/env'
|
|
6
6
|
import { readReviewed } from '@/records/stale'
|
|
7
|
-
import { surfaceDir } from '@/surface
|
|
7
|
+
import { surfaceDir } from '@/roots/surface'
|
|
8
8
|
|
|
9
9
|
/** The two records whose drift is identity rather than a stale path. */
|
|
10
10
|
const CANONICAL_DOCS = ['REQUIREMENTS.md', 'ARCHITECTURE.md'] as const
|
package/src/records/stale.ts
CHANGED
package/src/records/validate.ts
CHANGED
|
@@ -6,7 +6,7 @@ import { linesOutsideFences } from '@/markdown/scan'
|
|
|
6
6
|
import {
|
|
7
7
|
recordDir as resolveRecordDir,
|
|
8
8
|
recordDirs as resolveRecordDirs,
|
|
9
|
-
} from '@/record
|
|
9
|
+
} from '@/roots/record'
|
|
10
10
|
import { ATTRIBUTE_MARKER, governedPaths, readScope } from '@/standards/scope'
|
|
11
11
|
import {
|
|
12
12
|
TEACH_GLOSSARY,
|
|
@@ -39,7 +39,7 @@ export type RecordKind = (typeof RECORD_KINDS)[number]
|
|
|
39
39
|
* the second, so one order serves both.
|
|
40
40
|
*
|
|
41
41
|
* Every other kind is a session record and takes the record roots instead,
|
|
42
|
-
* resolved by `@/record
|
|
42
|
+
* resolved by `@/roots/record`. Standards is the one kind that is tracked, so it
|
|
43
43
|
* does not move and spells its own two candidates here.
|
|
44
44
|
*/
|
|
45
45
|
const STANDARDS_FOLDERS: readonly string[] = [
|
|
@@ -16,7 +16,7 @@ import { fileURLToPath } from 'node:url'
|
|
|
16
16
|
*/
|
|
17
17
|
export const PROJECT_ROOT = resolve(
|
|
18
18
|
dirname(fileURLToPath(import.meta.url)),
|
|
19
|
-
'
|
|
19
|
+
'../..',
|
|
20
20
|
)
|
|
21
21
|
|
|
22
22
|
const readPackageName = (root: string): string | undefined => {
|
|
@@ -72,7 +72,7 @@ export function findCheckoutMismatch(startDir: string): string | undefined {
|
|
|
72
72
|
* The warning line every verb that answers from `PROJECT_ROOT` emits when the
|
|
73
73
|
* caller's cwd sits inside a second checkout, or `undefined` when it does not.
|
|
74
74
|
*
|
|
75
|
-
* It returns the string rather than logging it so this module keeps no `@/ui`
|
|
75
|
+
* It returns the string rather than logging it so this module keeps no `@/cli/ui`
|
|
76
76
|
* dependency and the wording is unit-testable on its own. The wording names
|
|
77
77
|
* both roots and no subcommand: the four chokepoints that carry it stand under
|
|
78
78
|
* eight-plus verbs each, so a per-verb suffix would cost a parameter at every
|
|
@@ -57,7 +57,7 @@ const CANON_SCRATCH = 'tmp'
|
|
|
57
57
|
* record root, which is the rule the move ran on. `rules`, `skills`, and
|
|
58
58
|
* `hooks` stay under `.claude/` because the vendor reads them there, while
|
|
59
59
|
* `context`, `wireframes`, and the loose documents are tracked surfaces that
|
|
60
|
-
* `surface
|
|
60
|
+
* `surface.ts` resolves under `canon/` instead. A seed and a
|
|
61
61
|
* superseded-layout report each ask this rather than assuming a root.
|
|
62
62
|
*/
|
|
63
63
|
export const RECORD_ENTRIES: readonly string[] = [
|
package/src/sandbox/coverage.ts
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
import { existsSync, readdirSync } from 'node:fs'
|
|
2
2
|
import { join } from 'node:path'
|
|
3
|
+
import {
|
|
4
|
+
FIXTURES_DIR,
|
|
5
|
+
listCategories,
|
|
6
|
+
listScenarioFiles,
|
|
7
|
+
} from '@/sandbox/scenario'
|
|
3
8
|
|
|
4
9
|
/**
|
|
5
10
|
* The arm a scenario declares when it carries no named arm. `expectFilePath`
|
|
@@ -8,9 +13,6 @@ import { join } from 'node:path'
|
|
|
8
13
|
*/
|
|
9
14
|
export const DEFAULT_ARM = '(default)'
|
|
10
15
|
|
|
11
|
-
/** Holds fixture content rather than scenarios. Twin of the filter in `sandbox.ts`. */
|
|
12
|
-
const FIXTURES_DIR = 'fixtures'
|
|
13
|
-
|
|
14
16
|
export interface ScenarioCoverage {
|
|
15
17
|
readonly category: string
|
|
16
18
|
readonly command: string
|
|
@@ -41,15 +43,12 @@ function directories(path: string): string[] {
|
|
|
41
43
|
function listScenarios(sandboxDir: string): ScenarioCoverage[] {
|
|
42
44
|
const scenarios: ScenarioCoverage[] = []
|
|
43
45
|
|
|
44
|
-
for (const category of
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
.filter((entry) => entry.isFile() && entry.name.endsWith('.sh'))
|
|
51
|
-
.map((entry) => entry.name.replace(/\.sh$/, ''))
|
|
52
|
-
.sort()
|
|
46
|
+
for (const category of listCategories(sandboxDir)) {
|
|
47
|
+
// A stem in both forms counts once, and provisioning refuses it by name.
|
|
48
|
+
const { scenarios: files, ambiguous } = listScenarioFiles(
|
|
49
|
+
join(sandboxDir, category),
|
|
50
|
+
)
|
|
51
|
+
const commands = [...files.map((f) => f.command), ...ambiguous].sort()
|
|
53
52
|
|
|
54
53
|
for (const command of commands) {
|
|
55
54
|
scenarios.push({ category, command, armed: [] })
|
|
@@ -17,11 +17,15 @@ import {
|
|
|
17
17
|
import { tmpdir } from 'node:os'
|
|
18
18
|
import { join } from 'node:path'
|
|
19
19
|
import { collectCoverage, DEFAULT_ARM } from '@/sandbox/coverage'
|
|
20
|
+
import {
|
|
21
|
+
armNames,
|
|
22
|
+
listCategories,
|
|
23
|
+
listScenarioFiles,
|
|
24
|
+
loadScenario,
|
|
25
|
+
type ScenarioKind,
|
|
26
|
+
} from '@/sandbox/scenario'
|
|
20
27
|
import { createStubRemote, readStubManifest } from '@/sandbox/stub-remote'
|
|
21
28
|
|
|
22
|
-
/** Holds fixture content rather than scenarios. Twin of the filter in `sandbox.ts`. */
|
|
23
|
-
const FIXTURES_DIR = 'fixtures'
|
|
24
|
-
|
|
25
29
|
/** One arm provisions inside this budget on each side, or reads as a failed run. */
|
|
26
30
|
const PROVISION_TIMEOUT_MS = 120_000
|
|
27
31
|
|
|
@@ -149,13 +153,6 @@ export function armKey(arm: Arm): string {
|
|
|
149
153
|
return arm.arm === undefined ? scenario : `${scenario}/${arm.arm}`
|
|
150
154
|
}
|
|
151
155
|
|
|
152
|
-
function directories(path: string): string[] {
|
|
153
|
-
return readdirSync(path, { withFileTypes: true })
|
|
154
|
-
.filter((entry) => entry.isDirectory())
|
|
155
|
-
.map((entry) => entry.name)
|
|
156
|
-
.sort()
|
|
157
|
-
}
|
|
158
|
-
|
|
159
156
|
/**
|
|
160
157
|
* Reads the quoted arguments of `select_or_route_scenario`, joining a call that
|
|
161
158
|
* continues across backslash-newline pairs. The first argument is the prompt and
|
|
@@ -201,6 +198,42 @@ function matchesArm(arm: Arm, targets: readonly string[]): boolean {
|
|
|
201
198
|
return scoped.includes(armKey(arm))
|
|
202
199
|
}
|
|
203
200
|
|
|
201
|
+
/**
|
|
202
|
+
* Reads a scenario's arm names and anchor flag: off a bash file's source, and
|
|
203
|
+
* off a TypeScript module's own keys, which is what its stage routes on.
|
|
204
|
+
*/
|
|
205
|
+
function readArms(
|
|
206
|
+
file: string,
|
|
207
|
+
kind: ScenarioKind,
|
|
208
|
+
):
|
|
209
|
+
| { ok: true; anchor: boolean; names: string[] }
|
|
210
|
+
| { ok: false; error: string } {
|
|
211
|
+
if (kind === 'sh') {
|
|
212
|
+
const source = readFileSync(file, 'utf8')
|
|
213
|
+
|
|
214
|
+
return {
|
|
215
|
+
ok: true,
|
|
216
|
+
anchor: /^use_anchor\s*\(\)/m.test(source),
|
|
217
|
+
names: parseArmNames(source),
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
try {
|
|
222
|
+
const definition = loadScenario(file)
|
|
223
|
+
|
|
224
|
+
return {
|
|
225
|
+
ok: true,
|
|
226
|
+
anchor: definition.anchor === true,
|
|
227
|
+
names: armNames(definition),
|
|
228
|
+
}
|
|
229
|
+
} catch (error) {
|
|
230
|
+
return {
|
|
231
|
+
ok: false,
|
|
232
|
+
error: error instanceof Error ? error.message : String(error),
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
204
237
|
/**
|
|
205
238
|
* Lists every provision a target names, from the scenario source. An armed arm
|
|
206
239
|
* that `canon sandbox coverage` reports and this list lacks is returned as an
|
|
@@ -216,25 +249,23 @@ export function enumerateArms(
|
|
|
216
249
|
const errors: string[] = []
|
|
217
250
|
const coverage = collectCoverage(root)
|
|
218
251
|
|
|
219
|
-
for (const category of
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
.map((f) => f.name.replace(/\.sh$/, ''))
|
|
227
|
-
.sort()
|
|
252
|
+
for (const category of listCategories(sandboxDir)) {
|
|
253
|
+
const { scenarios, ambiguous } = listScenarioFiles(
|
|
254
|
+
join(sandboxDir, category),
|
|
255
|
+
)
|
|
256
|
+
for (const command of ambiguous)
|
|
257
|
+
if (matchesTarget(category, command, targets))
|
|
258
|
+
errors.push(`${category}:${command} exists as both .sh and .ts`)
|
|
228
259
|
|
|
229
|
-
for (const command of
|
|
260
|
+
for (const { command, file, kind } of scenarios) {
|
|
230
261
|
if (!matchesTarget(category, command, targets)) continue
|
|
231
262
|
|
|
232
|
-
const
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
const names =
|
|
263
|
+
const declared = readArms(file, kind)
|
|
264
|
+
if (!declared.ok) {
|
|
265
|
+
errors.push(`${category}:${command} did not load: ${declared.error}`)
|
|
266
|
+
continue
|
|
267
|
+
}
|
|
268
|
+
const { anchor, names } = declared
|
|
238
269
|
|
|
239
270
|
const found: Arm[] =
|
|
240
271
|
names.length === 0
|
package/src/sandbox/hooks.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { spawnSync } from 'node:child_process'
|
|
|
2
2
|
import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'
|
|
3
3
|
import { tmpdir } from 'node:os'
|
|
4
4
|
import { join } from 'node:path'
|
|
5
|
-
import { PROJECT_ROOT } from '@/project
|
|
5
|
+
import { PROJECT_ROOT } from '@/roots/project'
|
|
6
6
|
|
|
7
7
|
/**
|
|
8
8
|
* The bash entry a scenario's hooks run through. Scenarios and the library they
|