@erclx/canon 5.48.0 → 5.49.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.
Files changed (47) hide show
  1. package/claude/.claude-plugin/plugin.json +1 -1
  2. package/claude/skills/auto-ship/SKILL.md +1 -1
  3. package/claude/skills/plan-feature/SKILL.md +18 -15
  4. package/claude/skills/plan-feature/references/batches.md +5 -5
  5. package/claude/skills/plan-feature/references/plan.md +82 -33
  6. package/claude/skills/plan-groundwork/SKILL.md +8 -0
  7. package/claude/skills/plan-groundwork/references/groundwork.md +2 -1
  8. package/claude/skills/plan-intake/SKILL.md +8 -0
  9. package/claude/skills/plan-intake/references/intake.md +9 -1
  10. package/claude/skills/plan-ready/REQUIREMENT.md +3 -3
  11. package/claude/skills/plan-ready/SKILL.md +3 -3
  12. package/claude/skills/plan-ready/references/assembly.md +7 -7
  13. package/claude/skills/plan-ready/references/ready.md +8 -6
  14. package/claude/skills/review-ui/REQUIREMENT.md +1 -1
  15. package/claude/skills/review-ui/SKILL.md +4 -11
  16. package/claude/skills/review-ui/references/close-out.md +1 -1
  17. package/claude/skills/review-ui/references/drive-copy.md +38 -0
  18. package/claude/skills/review-ui/references/post.md +24 -3
  19. package/claude/skills/review-ui/references/verb-absent.md +18 -0
  20. package/claude/skills/role-orchestrator/SKILL.md +3 -2
  21. package/claude/skills/role-orchestrator/references/orchestrator-dispatch.md +1 -1
  22. package/claude/skills/role-orchestrator/references/orchestrator-hand-launch.md +60 -0
  23. package/claude/skills/role-orchestrator/references/orchestrator-launch.md +5 -0
  24. package/claude/skills/role-orchestrator/references/orchestrator-parked.md +1 -1
  25. package/claude/skills/role-orchestrator/references/orchestrator-reviewer-launch.md +1 -1
  26. package/claude/skills/role-planner/SKILL.md +1 -1
  27. package/claude/skills/role-worker/SKILL.md +4 -2
  28. package/claude/skills/session-compact/references/handoff-note.md +3 -1
  29. package/claude/skills/session-map/references/session.md +1 -1
  30. package/claude/skills/task-board/references/board.md +2 -2
  31. package/claude/skills/task-board/references/tasks.md +1 -1
  32. package/docs/agents/records.md +1 -1
  33. package/docs/workflow/parallel-features.md +2 -2
  34. package/package.json +1 -1
  35. package/src/commands/pr.ts +411 -23
  36. package/src/commands/records.ts +45 -8
  37. package/src/commands/tasks.ts +12 -0
  38. package/src/hooks/post-merge.ts +11 -0
  39. package/src/pr/drive-copy.ts +218 -0
  40. package/src/pr/local.ts +26 -0
  41. package/src/pr/rest.ts +2 -0
  42. package/src/records/structure.ts +147 -0
  43. package/src/records/validate.ts +113 -12
  44. package/src/tasks/archive.ts +137 -4
  45. package/src/tasks/board.ts +68 -13
  46. package/src/tasks/validate.ts +39 -2
  47. package/tooling/base/configs/.github/workflows/pr-local-preview.yml +1 -1
@@ -1,5 +1,5 @@
1
1
  import { existsSync, statSync } from 'node:fs'
2
- import { readFile, realpath } from 'node:fs/promises'
2
+ import { mkdir, readFile, realpath, writeFile } from 'node:fs/promises'
3
3
  import { basename, join, resolve } from 'node:path'
4
4
  import { $ } from 'bun'
5
5
  import type { Command } from 'commander'
@@ -81,10 +81,20 @@ import {
81
81
  type MergeRefusal,
82
82
  type MergeRequest,
83
83
  } from '@/pr/merge'
84
+ import { listWorktrees } from '@/git/worktree'
85
+ import {
86
+ branchSlug,
87
+ buildDriveCopy,
88
+ changedSince,
89
+ loadPreviewConfig,
90
+ readWorktreeStatus,
91
+ type StatusEntry,
92
+ } from '@/pr/drive-copy'
84
93
  import { machineListenerReader } from '@/listeners/machine'
85
94
  import { loadPaintedPatterns } from '@/pr/painted'
86
95
  import {
87
96
  dropAddressLine,
97
+ maskLocalAddress,
88
98
  findLocalServer,
89
99
  type LocalRefusal,
90
100
  type LocalRunner,
@@ -177,6 +187,29 @@ interface TickOptions extends ReadOptions {
177
187
  readonly head?: string
178
188
  }
179
189
 
190
+ interface DriveCopyOptions extends ReadOptions {
191
+ readonly baseline?: boolean
192
+ readonly check?: boolean
193
+ }
194
+
195
+ type DriveCopyRefusal =
196
+ | 'no-worktree'
197
+ | 'no-branch'
198
+ | 'no-baseline'
199
+ | 'unreadable-tree'
200
+ | 'bad-flags'
201
+
202
+ const DRIVE_COPY_REFUSALS: Record<DriveCopyRefusal, string> = {
203
+ 'no-worktree':
204
+ 'No local worktree holds the pull request branch, so there is no tree to copy from or check.',
205
+ 'no-branch':
206
+ 'The checkout holds no branch, so no copy root could be named. Pass the pull request number.',
207
+ 'no-baseline':
208
+ 'No baseline was recorded for this pull request. Run --baseline before --check.',
209
+ 'unreadable-tree': 'git could not read the worktree status.',
210
+ 'bad-flags': '--baseline and --check cannot be combined.',
211
+ }
212
+
180
213
  type TickReason =
181
214
  | TickRefusal
182
215
  | 'count-mismatch'
@@ -1037,7 +1070,10 @@ export function register(program: Command): void {
1037
1070
  .helpOption('-h, --help', 'Show this help message')
1038
1071
  .option('--root <path>', 'Worktree to read, defaulting to the cwd')
1039
1072
  .option('--json', 'Add a machine-readable record on stdout')
1040
- .option('--remove', "Drop the evidence comment's preview address line")
1073
+ .option(
1074
+ '--remove',
1075
+ "Drop the evidence comment's preview address line and mask local addresses in UI reviews",
1076
+ )
1041
1077
  .addHelpText(
1042
1078
  'after',
1043
1079
  [
@@ -1060,6 +1096,11 @@ export function register(program: Command): void {
1060
1096
  'and drops the hosted link too because the same close deletes the',
1061
1097
  "branch's preview deployments.",
1062
1098
  '',
1099
+ 'It also rewrites the loopback address on the Driven at line of every',
1100
+ 'review carrying the review-ui marker to "a local preview", keeping',
1101
+ 'the head and the renderer. A hosted address and a code review are',
1102
+ 'left alone, and `reviews` on the JSON record counts the rewrites.',
1103
+ '',
1063
1104
  'Read `reason` on the JSON record:',
1064
1105
  ' ok a page answered, with its address in `url`',
1065
1106
  ' no-server nothing inside this worktree served a page',
@@ -1070,7 +1111,8 @@ export function register(program: Command): void {
1070
1111
  '',
1071
1112
  'Refusals, read off `reason`:',
1072
1113
  ' gh-missing `--remove` needs `gh` and it is not on the path',
1073
- ' gh-failed `--remove` could not read or edit the comment',
1114
+ ' gh-failed `--remove` could not read the thread, or edit the',
1115
+ ' comment or a review, after trying every edit',
1074
1116
  '',
1075
1117
  'Exit codes:',
1076
1118
  ' 0 a server was found, or --remove finished, including a no-op',
@@ -1153,6 +1195,83 @@ export function register(program: Command): void {
1153
1195
  process.exitCode = await runTick(number, opts)
1154
1196
  })
1155
1197
 
1198
+ pr.command('drive-copy')
1199
+ .description(
1200
+ "Copy the folders a running preview saves into, so a UI pass drives a throwaway copy, and check the worker's worktree gained no change",
1201
+ )
1202
+ .argument(
1203
+ '[number]',
1204
+ 'Pull request whose worktree to read, defaulting to the checkout at --root',
1205
+ )
1206
+ .helpOption('-h, --help', 'Show this help message')
1207
+ .option('--root <path>', 'Repository to read, defaulting to the cwd')
1208
+ .option('--json', 'Add a machine-readable record on stdout')
1209
+ .option(
1210
+ '--baseline',
1211
+ "Rebuild the copy, then record the worktree's git status",
1212
+ )
1213
+ .option(
1214
+ '--check',
1215
+ 'Report each path changed since the baseline, copying nothing',
1216
+ )
1217
+ .addHelpText(
1218
+ 'after',
1219
+ [
1220
+ '',
1221
+ 'A project names what its preview writes in',
1222
+ '`canon/config/preview.json` as `{ "writes": [path], "env": "<NAME>" }`.',
1223
+ 'Each `writes` path is relative to the worktree, and `env`, defaulting',
1224
+ 'to `CANON_PREVIEW_ROOT`, is the variable the server reads for the copy',
1225
+ 'root. The server resolves each declared path under that root.',
1226
+ '',
1227
+ 'The copy lives at `.canon/tmp/pr/review-ui/<slug>/board/` at the main',
1228
+ 'worktree root, where `<slug>` is the branch with a leading type',
1229
+ 'segment dropped and each `/` made `-`. With a number the branch is the',
1230
+ "pull request's head, and without one it is the checkout's, so the",
1231
+ 'worker starting its server before a pull request exists and the',
1232
+ 'reviewer naming the number land on one copy. Every run but --check',
1233
+ "rebuilds it from the worktree, dropping the last pass's drives.",
1234
+ '',
1235
+ '--baseline records `git status` for the worktree under',
1236
+ '`.canon/tmp/pr/review-ui/<number>/`, and --check reports each path',
1237
+ 'whose status or content changed since, an untracked file included.',
1238
+ 'Both run with no config, since a project that saves drives and never',
1239
+ 'declared it is the case the check exists for. An edit the worker makes',
1240
+ 'during the pass reads as changed too.',
1241
+ '',
1242
+ 'Read `reason` on the JSON record:',
1243
+ ' copied the copy was rebuilt, with `copyRoot`, `env`, `copied`, and',
1244
+ ' `missing` for a declared path the worktree does not hold',
1245
+ ' no-config the project declares no preview, so nothing was copied',
1246
+ ' clean --check found no change since the baseline',
1247
+ ' changed --check found changes, listed in `paths`, exit 1',
1248
+ '',
1249
+ 'Refusals, read off `reason`:',
1250
+ ' no-worktree no local worktree holds the branch, as for a cloud',
1251
+ " worker's, so there is no tree to check",
1252
+ ' no-branch the checkout holds no branch',
1253
+ ' no-baseline --check ran before any --baseline for this number',
1254
+ ' bad-config preview.json does not parse or lacks a writes list',
1255
+ ' unsafe-path a writes path is absolute or climbs with `..`',
1256
+ ' unreadable-tree git could not read the worktree status',
1257
+ ' bad-flags --baseline and --check were combined',
1258
+ ' gh-failed, ambiguous-pull, gh-missing',
1259
+ '',
1260
+ 'Exit codes:',
1261
+ ' 0 copied, no-config, or clean',
1262
+ ' 1 changed, or refused, with the reason in the JSON record',
1263
+ '',
1264
+ 'Examples:',
1265
+ ' canon pr drive-copy --json',
1266
+ ' canon pr drive-copy 1341 --baseline --json',
1267
+ ' canon pr drive-copy 1341 --check --json',
1268
+ '',
1269
+ ].join('\n'),
1270
+ )
1271
+ .action(async (number: string | undefined, opts: DriveCopyOptions) => {
1272
+ process.exitCode = await runDriveCopy(number, opts)
1273
+ })
1274
+
1156
1275
  pr.command('frames')
1157
1276
  .description(
1158
1277
  `Push a UI review frame to the never-merged ${FRAMES_BRANCH} branch and return its embed link, or drop frames from it`,
@@ -1733,7 +1852,7 @@ async function listReviews(cwd: string, number: string) {
1733
1852
  return listPages<RestReview>(
1734
1853
  cwd,
1735
1854
  `repos/{owner}/{repo}/pulls/${number}/reviews`,
1736
- 'body, commit_id, submitted_at',
1855
+ 'id, body, commit_id, submitted_at',
1737
1856
  )
1738
1857
  }
1739
1858
 
@@ -2922,9 +3041,9 @@ async function runLocal(opts: LocalOptions): Promise<number> {
2922
3041
  }
2923
3042
 
2924
3043
  /**
2925
- * Drops the address line on the marked comment. The verb writes here rather
2926
- * than handing a body back, because its caller is a close workflow with no
2927
- * session to post one.
3044
+ * Drops the address line on the marked comment and masks the loopback address
3045
+ * on every UI review. The verb writes here rather than handing a body back,
3046
+ * because its caller is a close workflow with no session to post one.
2928
3047
  */
2929
3048
  async function runLocalRemove(
2930
3049
  number: string | undefined,
@@ -2951,32 +3070,79 @@ async function runLocalRemove(
2951
3070
  root,
2952
3071
  )
2953
3072
  }
2954
- const comments = await listComments(root, resolved.number)
2955
- if (comments === undefined) {
3073
+ const [comments, reviews] = await Promise.all([
3074
+ listComments(root, resolved.number),
3075
+ listReviews(root, resolved.number),
3076
+ ])
3077
+ if (comments === undefined && reviews.kind === 'refused') {
2956
3078
  return refuseWith('gh-failed', LOCAL_REFUSALS['gh-failed'], emitJson, root)
2957
3079
  }
2958
3080
 
2959
- const finish = (reason: string, message: string): number => {
2960
- logStep(reason === 'removed' ? 'Removed' : 'Skipped')
2961
- logInfo(message)
2962
- outro()
2963
- if (emitJson) {
2964
- process.stdout.write(`${JSON.stringify({ root, reason })}\n`)
2965
- }
2966
- return 0
3081
+ // A refused read on one side still lets the other side's edits land, and
3082
+ // counts as a failure beside them, so one unread listing never strands both.
3083
+ const [evidence, masked] = await Promise.all([
3084
+ comments === undefined
3085
+ ? Promise.resolve<EvidenceRemoval>({ kind: 'failed' })
3086
+ : removeEvidenceAddress(root, comments),
3087
+ reviews.kind === 'refused'
3088
+ ? Promise.resolve({ rewritten: 0, failed: true })
3089
+ : maskUiReviews(root, resolved.number, reviews.rows),
3090
+ ])
3091
+ if (evidence.kind === 'failed' || masked.failed) {
3092
+ return refuseWith(
3093
+ 'gh-failed',
3094
+ LOCAL_REFUSALS['gh-failed'],
3095
+ emitJson,
3096
+ root,
3097
+ {
3098
+ reviews: masked.rewritten,
3099
+ },
3100
+ )
3101
+ }
3102
+
3103
+ logStep(evidence.reason === 'removed' ? 'Removed' : 'Skipped')
3104
+ logInfo(evidence.message)
3105
+ logInfo(`${masked.rewritten} UI review addresses masked`)
3106
+ outro()
3107
+ if (emitJson) {
3108
+ process.stdout.write(
3109
+ `${JSON.stringify({ root, reason: evidence.reason, reviews: masked.rewritten })}\n`,
3110
+ )
2967
3111
  }
3112
+ return 0
3113
+ }
2968
3114
 
3115
+ type EvidenceRemoval =
3116
+ | {
3117
+ readonly kind: 'done'
3118
+ readonly reason: 'removed' | 'no-comment' | 'no-line'
3119
+ readonly message: string
3120
+ }
3121
+ | { readonly kind: 'failed' }
3122
+
3123
+ async function removeEvidenceAddress(
3124
+ root: string,
3125
+ comments: readonly { url?: string; body: string }[],
3126
+ ): Promise<EvidenceRemoval> {
2969
3127
  const commentId = findEvidenceCommentId(comments)
2970
3128
  const marked = comments.find((comment) =>
2971
3129
  comment.url?.endsWith(`#issuecomment-${commentId}`),
2972
3130
  )
2973
3131
  if (commentId === undefined || marked === undefined) {
2974
- return finish('no-comment', 'No comment carries the evidence marker.')
3132
+ return {
3133
+ kind: 'done',
3134
+ reason: 'no-comment',
3135
+ message: 'No comment carries the evidence marker.',
3136
+ }
2975
3137
  }
2976
3138
 
2977
3139
  const dropped = dropAddressLine(marked.body)
2978
3140
  if (dropped === undefined) {
2979
- return finish('no-line', 'The evidence comment carries no address line.')
3141
+ return {
3142
+ kind: 'done',
3143
+ reason: 'no-line',
3144
+ message: 'The evidence comment carries no address line.',
3145
+ }
2980
3146
  }
2981
3147
 
2982
3148
  const patched = await gh(root, [
@@ -2987,11 +3153,43 @@ async function runLocalRemove(
2987
3153
  '-f',
2988
3154
  `body=${dropped}`,
2989
3155
  ])
2990
- if (patched === null) {
2991
- return refuseWith('gh-failed', LOCAL_REFUSALS['gh-failed'], emitJson, root)
2992
- }
3156
+ return patched === null
3157
+ ? { kind: 'failed' }
3158
+ : { kind: 'done', reason: 'removed', message: `comment ${commentId}` }
3159
+ }
2993
3160
 
2994
- return finish('removed', `comment ${commentId}`)
3161
+ const UI_REVIEW_MARKER = '<!-- review-ui: head='
3162
+
3163
+ /**
3164
+ * Masks the loopback address on each review `review-ui` posted, selected by
3165
+ * its marker so a code review quoting a local URL is never edited. Every
3166
+ * update is tried before a refusal is reported, so one refused review leaves
3167
+ * the rest masked.
3168
+ */
3169
+ async function maskUiReviews(
3170
+ root: string,
3171
+ number: string,
3172
+ reviews: readonly RestReview[],
3173
+ ): Promise<{ readonly rewritten: number; readonly failed: boolean }> {
3174
+ const updates = reviews.flatMap((review) => {
3175
+ const body = review.body ?? ''
3176
+ if (review.id === undefined || !body.includes(UI_REVIEW_MARKER)) return []
3177
+ const masked = maskLocalAddress(body)
3178
+ if (masked === undefined) return []
3179
+ return [
3180
+ gh(root, [
3181
+ 'api',
3182
+ '-X',
3183
+ 'PUT',
3184
+ `repos/{owner}/{repo}/pulls/${number}/reviews/${review.id}`,
3185
+ '-f',
3186
+ `body=${masked}`,
3187
+ ]),
3188
+ ]
3189
+ })
3190
+ const results = await Promise.all(updates)
3191
+ const rewritten = results.filter((stdout) => stdout !== null).length
3192
+ return { rewritten, failed: rewritten < results.length }
2995
3193
  }
2996
3194
 
2997
3195
  const POSITIVE_WHOLE = /^[1-9]\d*$/
@@ -3997,6 +4195,196 @@ function refuse(reason: Refusal, emitJson: boolean, root: string): number {
3997
4195
  return refuseWith(reason, REFUSALS[reason], emitJson, root)
3998
4196
  }
3999
4197
 
4198
+ /** The scratch folder `review-ui` writes under, at the main worktree root. */
4199
+ const REVIEW_UI_SCRATCH = join('.canon', 'tmp', 'pr', 'review-ui')
4200
+
4201
+ type DriveTarget =
4202
+ | {
4203
+ readonly kind: 'found'
4204
+ readonly main: string
4205
+ readonly worktree: string
4206
+ readonly branch: string
4207
+ readonly number: string | undefined
4208
+ }
4209
+ | {
4210
+ readonly kind: 'refused'
4211
+ readonly reason: DriveCopyRefusal | PullRefusal
4212
+ }
4213
+
4214
+ /**
4215
+ * The worker's worktree and its branch. A named pull request resolves through
4216
+ * its head ref, since a reviewer runs at the main root on the trunk. With no
4217
+ * number the checkout at `root` answers, and a number is looked up only when
4218
+ * the mode needs one, so a worker can build the copy before its pull request
4219
+ * exists.
4220
+ */
4221
+ async function resolveDriveTarget(
4222
+ root: string,
4223
+ number: string | undefined,
4224
+ needsNumber: boolean,
4225
+ ): Promise<DriveTarget> {
4226
+ const worktrees = await listWorktrees(root)
4227
+ const main = worktrees[0]?.path ?? root
4228
+
4229
+ if (number === undefined) {
4230
+ const toplevel = await resolveWorktreeRoot(root)
4231
+ const realPaths = await Promise.all(
4232
+ worktrees.map((candidate) =>
4233
+ realpath(candidate.path).catch(() => candidate.path),
4234
+ ),
4235
+ )
4236
+ const entry = worktrees[realPaths.indexOf(toplevel)]
4237
+ const branch = entry?.branch ?? null
4238
+ if (branch === null) return { kind: 'refused', reason: 'no-branch' }
4239
+ if (!needsNumber) {
4240
+ return { kind: 'found', main, worktree: toplevel, branch, number }
4241
+ }
4242
+ if (Bun.which('gh') === null) {
4243
+ return { kind: 'refused', reason: 'gh-missing' }
4244
+ }
4245
+ const resolved = await resolvePullNumber(root, undefined)
4246
+ if (resolved.kind === 'refused') return resolved
4247
+ return {
4248
+ kind: 'found',
4249
+ main,
4250
+ worktree: toplevel,
4251
+ branch,
4252
+ number: resolved.number,
4253
+ }
4254
+ }
4255
+
4256
+ if (Bun.which('gh') === null) return { kind: 'refused', reason: 'gh-missing' }
4257
+ const read = await readPull(root, number)
4258
+ if (read.kind === 'refused') return read
4259
+ const headRef = read.row.head?.ref
4260
+ if (headRef === undefined || headRef === '') {
4261
+ return { kind: 'refused', reason: 'no-worktree' }
4262
+ }
4263
+ const entry = worktrees.find((candidate) => candidate.branch === headRef)
4264
+ if (entry === undefined) return { kind: 'refused', reason: 'no-worktree' }
4265
+ return {
4266
+ kind: 'found',
4267
+ main,
4268
+ worktree: entry.path,
4269
+ branch: headRef,
4270
+ number,
4271
+ }
4272
+ }
4273
+
4274
+ async function runDriveCopy(
4275
+ number: string | undefined,
4276
+ opts: DriveCopyOptions,
4277
+ ): Promise<number> {
4278
+ const root = resolve(opts.root ?? process.cwd())
4279
+ const emitJson = opts.json ?? false
4280
+ const baseline = opts.baseline ?? false
4281
+ const check = opts.check ?? false
4282
+
4283
+ intro('canon pr drive-copy')
4284
+
4285
+ const refuse = (
4286
+ reason: DriveCopyRefusal | PullRefusal | string,
4287
+ message: string,
4288
+ ) => refuseWith(reason, message, emitJson, root)
4289
+
4290
+ if (baseline && check) {
4291
+ return refuse('bad-flags', DRIVE_COPY_REFUSALS['bad-flags'])
4292
+ }
4293
+
4294
+ const target = await resolveDriveTarget(root, number, baseline || check)
4295
+ if (target.kind === 'refused') {
4296
+ const reason = target.reason
4297
+ return refuse(
4298
+ reason,
4299
+ reason in DRIVE_COPY_REFUSALS
4300
+ ? DRIVE_COPY_REFUSALS[reason as DriveCopyRefusal]
4301
+ : PULL_REFUSALS[reason as PullRefusal],
4302
+ )
4303
+ }
4304
+
4305
+ const scratch = join(target.main, REVIEW_UI_SCRATCH)
4306
+ const baselinePath =
4307
+ target.number === undefined
4308
+ ? undefined
4309
+ : join(scratch, target.number, 'drive-baseline.json')
4310
+ const emit = (record: Record<string, unknown>) => {
4311
+ if (emitJson) {
4312
+ process.stdout.write(
4313
+ `${JSON.stringify({ root, worktree: target.worktree, branch: target.branch, ...record })}\n`,
4314
+ )
4315
+ }
4316
+ }
4317
+
4318
+ if (check && baselinePath !== undefined) {
4319
+ let prior: readonly StatusEntry[]
4320
+ try {
4321
+ prior = JSON.parse(await readFile(baselinePath, 'utf8'))
4322
+ } catch {
4323
+ return refuse('no-baseline', DRIVE_COPY_REFUSALS['no-baseline'])
4324
+ }
4325
+ const current = await readWorktreeStatus(target.worktree)
4326
+ if (current === undefined) {
4327
+ return refuse('unreadable-tree', DRIVE_COPY_REFUSALS['unreadable-tree'])
4328
+ }
4329
+ const paths = changedSince(prior, current)
4330
+ if (paths.length === 0) {
4331
+ logStep('Clean')
4332
+ logInfo(`No change in ${target.worktree} since the baseline.`)
4333
+ outro()
4334
+ emit({ reason: 'clean', paths })
4335
+ return 0
4336
+ }
4337
+ logStep('Changed')
4338
+ for (const path of paths) logWarn(path)
4339
+ outro()
4340
+ emit({ reason: 'changed', paths })
4341
+ return 1
4342
+ }
4343
+
4344
+ const config = loadPreviewConfig(target.worktree)
4345
+ if (config.kind === 'refused') return refuse(config.reason, config.detail)
4346
+
4347
+ let copy: Record<string, unknown>
4348
+ if (config.kind === 'absent') {
4349
+ logStep('No config')
4350
+ logInfo('The project declares no preview writes, so nothing was copied.')
4351
+ copy = { reason: 'no-config' }
4352
+ } else {
4353
+ const copyRoot = join(scratch, branchSlug(target.branch), 'board')
4354
+ const { copied, missing } = buildDriveCopy(
4355
+ target.worktree,
4356
+ copyRoot,
4357
+ config.config.writes,
4358
+ )
4359
+ logStep('Copied')
4360
+ logInfo(`${config.config.env}=${copyRoot}`)
4361
+ for (const path of missing) logWarn(`${path} is not in the worktree`)
4362
+ copy = {
4363
+ reason: 'copied',
4364
+ copyRoot,
4365
+ env: config.config.env,
4366
+ copied,
4367
+ missing,
4368
+ }
4369
+ }
4370
+
4371
+ if (baseline && baselinePath !== undefined) {
4372
+ const status = await readWorktreeStatus(target.worktree)
4373
+ if (status === undefined) {
4374
+ return refuse('unreadable-tree', DRIVE_COPY_REFUSALS['unreadable-tree'])
4375
+ }
4376
+ await mkdir(join(baselinePath, '..'), { recursive: true })
4377
+ await writeFile(baselinePath, `${JSON.stringify(status)}\n`)
4378
+ logStep('Baseline')
4379
+ logInfo(`Recorded ${plural(status.length, 'status line')}.`)
4380
+ copy = { ...copy, baseline: baselinePath }
4381
+ }
4382
+
4383
+ outro()
4384
+ emit(copy)
4385
+ return 0
4386
+ }
4387
+
4000
4388
  function refuseWith(
4001
4389
  reason: string,
4002
4390
  message: string,
@@ -31,6 +31,7 @@ import {
31
31
  RECORD_KINDS,
32
32
  type RecordKind,
33
33
  type ValidateOutcome,
34
+ type StructureFinding,
34
35
  validateRecords,
35
36
  } from '@/records/validate'
36
37
  import {
@@ -89,6 +90,10 @@ export function register(program: Command): void {
89
90
  .command('validate')
90
91
  .description('Report where a file and the standard governing it disagree')
91
92
  .argument('<kind>', `Record folder: ${RECORD_KINDS.join(', ')}`)
93
+ .argument(
94
+ '[record]',
95
+ 'One plan stem or folder slug, narrowing both findings and structure to it',
96
+ )
92
97
  .helpOption('-h, --help', 'Show this help message')
93
98
  .option('--json', 'Add a machine-readable record on stdout')
94
99
  .option('--root <path>', 'Project root, defaulting to the main worktree')
@@ -101,12 +106,18 @@ export function register(program: Command): void {
101
106
  ' groundwork README and current-state files, numbering, dating, and a half-closed track',
102
107
  ' intake overview file, numbering, dating, and the four bullets every item carries',
103
108
  '',
109
+ 'Structure, under `structure` and never in the exit:',
110
+ " S1 to S5 from the plan standard, read on plans, a track's README and",
111
+ ' numbered files, and intake clusters, plus intake-spill for content under',
112
+ ' a field. A record written before the criteria misses S1, so it reports alone.',
113
+ '',
104
114
  'Refusals, read off `reason`:',
105
115
  " no-folder none of the kind's directories exist at either record root",
116
+ ' no-record the folder holds no record by the name given',
106
117
  ' unknown-kind the argument names no published kind',
107
118
  '',
108
119
  'Exit codes:',
109
- ' 0 every check passed',
120
+ ' 0 no record carries a finding, whatever structure reports',
110
121
  ' 1 refused, with the reason on stderr or in the JSON record',
111
122
  ' 2 at least one record carries a finding',
112
123
  '',
@@ -115,14 +126,21 @@ export function register(program: Command): void {
115
126
  '',
116
127
  'Examples:',
117
128
  ' canon records validate plans',
129
+ ' canon records validate plans feature-records-by-heading',
118
130
  ' canon records validate teach',
119
131
  ' canon records validate intake --json',
120
132
  '',
121
133
  ].join('\n'),
122
134
  )
123
- .action(async (kind: string, opts: ValidateCommandOptions) => {
124
- process.exitCode = await runValidate(kind, opts)
125
- })
135
+ .action(
136
+ async (
137
+ kind: string,
138
+ record: string | undefined,
139
+ opts: ValidateCommandOptions,
140
+ ) => {
141
+ process.exitCode = await runValidate(kind, record, opts)
142
+ },
143
+ )
126
144
 
127
145
  records
128
146
  .command('ordinal')
@@ -721,6 +739,7 @@ function reportRefusal(
721
739
 
722
740
  async function runValidate(
723
741
  kind: string,
742
+ record: string | undefined,
724
743
  opts: ValidateCommandOptions,
725
744
  ): Promise<number> {
726
745
  const emitJson = opts.json ?? false
@@ -739,7 +758,11 @@ async function runValidate(
739
758
 
740
759
  const root = opts.root ?? (await mainWorktreeRoot())
741
760
 
742
- return report(await validateWithStacks(root, kind), emitJson, root)
761
+ // A plan named with its extension and without it is one record, so the walk
762
+ // and the cross-plan filter below both read the bare stem.
763
+ const stem = record?.replace(/\.md$/, '')
764
+
765
+ return report(await validateWithStacks(root, kind, stem), emitJson, root)
743
766
  }
744
767
 
745
768
  /**
@@ -749,18 +772,21 @@ async function runValidate(
749
772
  async function validateWithStacks(
750
773
  root: string,
751
774
  kind: RecordKind,
775
+ record: string | undefined,
752
776
  ): Promise<ValidateOutcome> {
753
- const outcome = await validateRecords(root, kind)
777
+ const outcome = await validateRecords(root, kind, record)
754
778
  if (!outcome.ok || kind !== 'plans') return outcome
755
779
 
756
780
  const live = await readLivePlans(root)
781
+ const across = [...stackFindings(live), ...fanInFindings(live)]
757
782
 
758
783
  return {
759
784
  ...outcome,
760
785
  findings: [
761
786
  ...outcome.findings,
762
- ...stackFindings(live),
763
- ...fanInFindings(live),
787
+ ...(record === undefined
788
+ ? across
789
+ : across.filter((found) => found.record === `${record}.md`)),
764
790
  ],
765
791
  }
766
792
  }
@@ -801,6 +827,7 @@ function report(
801
827
  kind: outcome.kind,
802
828
  records: outcome.records,
803
829
  findings: outcome.findings,
830
+ structure: outcome.structure,
804
831
  })}\n`,
805
832
  )
806
833
  } else {
@@ -814,6 +841,11 @@ function report(
814
841
  } else {
815
842
  for (const found of outcome.findings) logWarn(describe(found))
816
843
  }
844
+
845
+ if (outcome.structure.length > 0) {
846
+ logStep('Structure, reported and never in the exit')
847
+ for (const entry of outcome.structure) logInfo(describeStructure(entry))
848
+ }
817
849
  outro()
818
850
  }
819
851
 
@@ -825,6 +857,11 @@ function describe(found: Finding): string {
825
857
  return `${scope}${found.subject} ${found.message}`
826
858
  }
827
859
 
860
+ function describeStructure(entry: StructureFinding): string {
861
+ const scope = entry.record === entry.subject ? '' : `${entry.record}/`
862
+ return `${entry.criterion} ${scope}${entry.subject}:${entry.line} ${entry.message}`
863
+ }
864
+
828
865
  async function runOrdinal(
829
866
  kind: string,
830
867
  slug: string,