@erclx/canon 4.70.0 → 4.72.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 (39) hide show
  1. package/README.md +1 -1
  2. package/claude/.claude-plugin/plugin.json +1 -1
  3. package/claude/skills/canon-cli/SKILL.md +4 -0
  4. package/claude/skills/deploy-cloudflare/REQUIREMENT.md +37 -0
  5. package/claude/skills/deploy-cloudflare/SKILL.md +61 -0
  6. package/claude/skills/draft-and-pick/REQUIREMENT.md +3 -1
  7. package/claude/skills/draft-and-pick/SKILL.md +10 -6
  8. package/claude/skills/{identity → draft-identity}/REQUIREMENT.md +2 -2
  9. package/claude/skills/{identity → draft-identity}/SKILL.md +2 -2
  10. package/claude/skills/git-ship/SKILL.md +17 -6
  11. package/claude/skills/role-orchestrator/references/orchestrator-dispatch.md +2 -0
  12. package/claude/skills/role-planner/SKILL.md +5 -0
  13. package/claude/skills/role-worker/SKILL.md +6 -0
  14. package/claude/skills/session-relay/REQUIREMENT.md +41 -0
  15. package/claude/skills/session-relay/SKILL.md +36 -0
  16. package/claude/skills/ux-audit/SKILL.md +3 -0
  17. package/docs/agents/key-changes.md +5 -1
  18. package/docs/agents/tasks.md +33 -0
  19. package/docs/workflow/ai-workflow.md +2 -1
  20. package/docs/workflow/visual-design-workflow.md +1 -1
  21. package/governance/rules/ui/440-surface-capture.md +1 -0
  22. package/package.json +1 -1
  23. package/src/claude/cases/misc.ts +5 -1
  24. package/src/claude/cases/workflow.ts +5 -0
  25. package/src/commands/pr.ts +128 -6
  26. package/src/commands/tasks.ts +154 -0
  27. package/src/commands/teach.ts +2 -0
  28. package/src/gate/measures.ts +66 -0
  29. package/src/gate/stages.ts +11 -0
  30. package/src/git-files.ts +69 -0
  31. package/src/migrate/skill-names.ts +10 -0
  32. package/src/pr/bijection.ts +59 -1
  33. package/src/tasks/reach.ts +383 -0
  34. package/src/teach/workspace.ts +60 -23
  35. package/standards/plan.md +2 -0
  36. package/standards/tasks.md +2 -0
  37. package/tooling/cloudflare/configs/.github/workflows/deploy.yml +103 -0
  38. package/tooling/cloudflare/manifest.toml +5 -0
  39. package/tooling/cloudflare/reference.md +24 -0
@@ -6,7 +6,11 @@ import { execa } from 'execa'
6
6
  import { gitEnv } from '@/git-env'
7
7
  import {
8
8
  listChangedFiles,
9
+ listIgnoreAdditions,
10
+ listRenames,
9
11
  listRepositoryFiles,
12
+ parseIgnoreAdditions,
13
+ type RenamePair,
10
14
  resolveBaseRef,
11
15
  } from '@/git-files'
12
16
  import {
@@ -292,6 +296,9 @@ interface PullRequestRead {
292
296
  readonly changed: readonly string[]
293
297
  readonly head: string | undefined
294
298
  readonly number: number | undefined
299
+ readonly renames: readonly RenamePair[]
300
+ readonly ignoreAdditions: readonly string[]
301
+ readonly evidenceUnread: boolean
295
302
  }
296
303
 
297
304
  type SourceRead =
@@ -349,13 +356,26 @@ async function readFromApi(
349
356
  return { kind: 'refused', reason: 'gh-truncated' }
350
357
  }
351
358
 
359
+ const body = row.body ?? ''
360
+ const sorted = [...changed].sort()
361
+ const evidence = await resolveApiEvidence(
362
+ cwd,
363
+ body,
364
+ sorted,
365
+ row.headRefOid,
366
+ row.number,
367
+ )
368
+
352
369
  return {
353
370
  kind: 'read',
354
371
  source: {
355
- body: row.body ?? '',
356
- changed: [...changed].sort(),
372
+ body,
373
+ changed: sorted,
357
374
  head: row.headRefOid,
358
375
  number: row.number,
376
+ renames: evidence.renames,
377
+ ignoreAdditions: evidence.ignoreAdditions,
378
+ evidenceUnread: evidence.unread,
359
379
  },
360
380
  }
361
381
  } catch {
@@ -363,6 +383,95 @@ async function readFromApi(
363
383
  }
364
384
  }
365
385
 
386
+ /**
387
+ * A rename's source path and a `.gitignore` addition, fetched only when a
388
+ * first pass with neither already reports an unmet claim.
389
+ *
390
+ * The probe pays for a repository listing this read takes again a moment
391
+ * later in `runKeyChanges`, and the `gh api …/files` call besides it only on
392
+ * the pass that already has something to double-check, the same trade the
393
+ * `gh-truncated` pagination fallback makes.
394
+ *
395
+ * `unread` separates a read that failed from one that succeeded and found
396
+ * neither, which an empty `renames`/`ignoreAdditions` cannot do on its own.
397
+ * `listFilesByPage` above refuses the whole comparison on exactly this
398
+ * ground: a set known to be short would let a correct bullet accuse a file
399
+ * nobody changed. This carries the same refusal as a flag rather than a
400
+ * refused `Bijection`, since the base comparison can still run and most
401
+ * claims never touch a rename or a `.gitignore` line at all.
402
+ */
403
+ async function resolveApiEvidence(
404
+ cwd: string,
405
+ body: string,
406
+ changed: readonly string[],
407
+ head: string | undefined,
408
+ number: number | undefined,
409
+ ): Promise<{
410
+ readonly renames: readonly RenamePair[]
411
+ readonly ignoreAdditions: readonly string[]
412
+ readonly unread: boolean
413
+ }> {
414
+ const clean = { renames: [], ignoreAdditions: [], unread: false }
415
+ const unread = { renames: [], ignoreAdditions: [], unread: true }
416
+
417
+ const tracked = await listRepositoryFiles(cwd)
418
+ if (tracked === undefined) return unread
419
+
420
+ const probe = compareKeyChanges({
421
+ body,
422
+ changed,
423
+ roots: treeRoots(tracked, changed),
424
+ ...(head !== undefined && { head }),
425
+ })
426
+ // A refusal here means there is no claim to credit at all, and a clean
427
+ // pass means every claim already resolved without the extra evidence, so
428
+ // neither case leaves anything for unread evidence to have mattered to.
429
+ if (probe.kind !== 'measured' || probe.unmet.length === 0) return clean
430
+
431
+ // Only past this point does a missing pull request number become a real
432
+ // gap: there is a claim the probe could not credit, and no number to fetch
433
+ // the evidence that might explain it.
434
+ if (number === undefined) return unread
435
+
436
+ // No `--jq` filter here, unlike `listFilesByPage` above. Shaping each row to
437
+ // {filename, previous_filename, status, patch} would make `--paginate`
438
+ // concatenate one filtered value per page rather than one combined array,
439
+ // and gh's own pretty-printing of an object result (unlike the scalar
440
+ // strings `--jq '.[].filename'` yields) is not guaranteed to stay
441
+ // line-parseable. Parsing the raw paginated array is safe under both.
442
+ const stdout = await gh(cwd, [
443
+ 'api',
444
+ '--paginate',
445
+ `repos/{owner}/{repo}/pulls/${number}/files`,
446
+ ])
447
+ if (stdout === null) return unread
448
+
449
+ let rows: readonly {
450
+ readonly filename: string
451
+ readonly previous_filename?: string
452
+ readonly status: string
453
+ readonly patch?: string
454
+ }[]
455
+ try {
456
+ rows = JSON.parse(stdout)
457
+ } catch {
458
+ return unread
459
+ }
460
+
461
+ const renames = rows
462
+ .filter(
463
+ (row): row is typeof row & { previous_filename: string } =>
464
+ row.status === 'renamed' && row.previous_filename !== undefined,
465
+ )
466
+ .map((row) => ({ from: row.previous_filename, to: row.filename }))
467
+
468
+ const ignoreRow = rows.find((row) => row.filename === '.gitignore')
469
+ const ignoreAdditions =
470
+ ignoreRow?.patch !== undefined ? parseIgnoreAdditions(ignoreRow.patch) : []
471
+
472
+ return { renames, ignoreAdditions, unread: false }
473
+ }
474
+
366
475
  /**
367
476
  * Every file a pull request changed, read through the paginated endpoint.
368
477
  *
@@ -430,10 +539,11 @@ async function readFromFile(
430
539
  return { kind: 'refused', reason: 'unreadable-changes' }
431
540
  }
432
541
 
433
- const head = await $`git -C ${root} rev-parse HEAD`
434
- .env(gitEnv())
435
- .quiet()
436
- .nothrow()
542
+ const [head, renames, ignoreAdditions] = await Promise.all([
543
+ $`git -C ${root} rev-parse HEAD`.env(gitEnv()).quiet().nothrow(),
544
+ listRenames(root, resolved),
545
+ listIgnoreAdditions(root, resolved),
546
+ ])
437
547
 
438
548
  return {
439
549
  kind: 'read',
@@ -442,6 +552,9 @@ async function readFromFile(
442
552
  changed,
443
553
  head: head.exitCode === 0 ? head.text().trim() : undefined,
444
554
  number: undefined,
555
+ renames: renames ?? [],
556
+ ignoreAdditions: ignoreAdditions ?? [],
557
+ evidenceUnread: renames === undefined || ignoreAdditions === undefined,
445
558
  },
446
559
  }
447
560
  }
@@ -469,6 +582,9 @@ async function runKeyChanges(
469
582
  body: source.source.body,
470
583
  changed: source.source.changed,
471
584
  roots: treeRoots(tracked, source.source.changed),
585
+ renames: source.source.renames,
586
+ ignoreAdditions: source.source.ignoreAdditions,
587
+ evidenceUnread: source.source.evidenceUnread,
472
588
  ...(source.source.head !== undefined && { head: source.source.head }),
473
589
  })
474
590
 
@@ -480,6 +596,11 @@ async function runKeyChanges(
480
596
  report.head === undefined ? '' : ` at ${report.head.slice(0, 8)}`
481
597
  }`,
482
598
  )
599
+ if (report.evidenceUnread) {
600
+ logWarn(
601
+ 'Rename or .gitignore-addition evidence could not be read, so a claim it might have credited or accused landed in unresolved rather than unmet.',
602
+ )
603
+ }
483
604
 
484
605
  logStep(report.unmet.length === 0 ? 'Claimed' : 'Unmet')
485
606
  if (report.unmet.length === 0) {
@@ -545,6 +666,7 @@ async function runKeyChanges(
545
666
  unnamed: report.unnamed,
546
667
  incidental: report.incidental,
547
668
  unresolved: report.unresolved,
669
+ evidenceUnread: report.evidenceUnread,
548
670
  })}\n`,
549
671
  )
550
672
  }
@@ -14,6 +14,12 @@ import {
14
14
  planCitations,
15
15
  } from '@/tasks/archive'
16
16
  import { type LabelOutcome, nextLabel } from '@/tasks/label'
17
+ import {
18
+ type Claim,
19
+ type Holder,
20
+ planReach,
21
+ type ReachOutcome,
22
+ } from '@/tasks/reach'
17
23
  import {
18
24
  type CloseOutcome,
19
25
  closeOutcomes,
@@ -84,6 +90,12 @@ interface BranchCommandOptions {
84
90
  readonly root?: string
85
91
  }
86
92
 
93
+ interface ReachCommandOptions {
94
+ readonly base?: string
95
+ readonly json?: boolean
96
+ readonly root?: string
97
+ }
98
+
87
99
  interface PullRequestCommandOptions {
88
100
  readonly json?: boolean
89
101
  readonly plan?: string
@@ -326,6 +338,45 @@ export function register(program: Command): void {
326
338
  process.exitCode = await runBranch(plan, opts)
327
339
  })
328
340
 
341
+ tasks
342
+ .command('plan-reach')
343
+ .description('Read a branch back against what was declared about it')
344
+ .argument('<plan>', 'Plan path or its slug, as in dispatch-answer-gate')
345
+ .helpOption('-h, --help', 'Show this help message')
346
+ .option('--base <ref>', 'Far side of the range, defaulting to the trunk')
347
+ .option('--json', 'Emit a machine-readable record on stdout')
348
+ .option('--root <path>', 'Board root, defaulting to the main worktree')
349
+ .addHelpText(
350
+ 'after',
351
+ [
352
+ '',
353
+ 'Exit codes:',
354
+ ' 0 the reach was read and no other track claims a changed path',
355
+ ' 1 refused as no-plan, archived, bad-input, no-base, or no-diff',
356
+ ' 2 read, and another live plan or Run now row claims a path',
357
+ '',
358
+ 'It reports claimed first and undeclared second. A claimed path is one',
359
+ 'another track holds and is the half worth acting on; an undeclared one',
360
+ 'is a path this plan never named, which the ship chain writes on nearly',
361
+ 'every branch. It reports and never gates, so branch on the record',
362
+ 'rather than on the exit code, which a shell function wrapping canon',
363
+ 'can flatten to zero.',
364
+ '',
365
+ 'The range is read at the current directory and the plans and board at',
366
+ 'the board root, so a linked worktree reads its own branch against the',
367
+ 'shared records. It reads only what is written down, so the plan and',
368
+ 'row counts say how much was there to compare against.',
369
+ '',
370
+ 'Examples:',
371
+ ' canon tasks plan-reach dispatch-answer-gate',
372
+ ' canon tasks plan-reach dispatch-answer-gate --base origin/main --json',
373
+ '',
374
+ ].join('\n'),
375
+ )
376
+ .action(async (plan: string, opts: ReachCommandOptions) => {
377
+ process.exitCode = await runReach(plan, opts)
378
+ })
379
+
329
380
  tasks
330
381
  .command('pull-request')
331
382
  .description('Record a pull request number on the task a branch closes')
@@ -961,6 +1012,109 @@ function reportBranch(
961
1012
  return EXIT_FINDINGS
962
1013
  }
963
1014
 
1015
+ async function runReach(
1016
+ plan: string,
1017
+ opts: ReachCommandOptions,
1018
+ ): Promise<number> {
1019
+ const root = opts.root ?? (await mainWorktreeRoot())
1020
+ // The range belongs to the checkout the caller stands in, which is the linked
1021
+ // worktree holding the branch on every dispatched run. The board root is the
1022
+ // main worktree, where reading a range measures the trunk against itself.
1023
+ const outcome = await planReach(root, plan, {
1024
+ repo: process.cwd(),
1025
+ ref: opts.base,
1026
+ })
1027
+
1028
+ return reportReach(outcome, opts.json ?? false, root)
1029
+ }
1030
+
1031
+ /**
1032
+ * Names a holder, the declaration it matched on when that is wider than the
1033
+ * path, and whether a holding plan carries a dispatch row. A plan with no row
1034
+ * is the shape a plan nobody archived takes, which is what sends a reader to
1035
+ * check the holder rather than treating the claim as a live collision.
1036
+ */
1037
+ function describeHolder(claim: Claim, holder: Holder): string {
1038
+ const under =
1039
+ holder.declaration === claim.path ? '' : `, under ${holder.declaration}`
1040
+ const rowed = holder.source === 'plan' && !holder.rowed ? ', no row' : ''
1041
+
1042
+ return `${holder.name} (${holder.source}${under}${rowed})`
1043
+ }
1044
+
1045
+ function describeReachClaim(claim: Claim): string {
1046
+ const holders = claim.holders
1047
+ .map((holder) => describeHolder(claim, holder))
1048
+ .join(' and ')
1049
+
1050
+ return `${claim.path} is held by ${holders}`
1051
+ }
1052
+
1053
+ /**
1054
+ * Leads with the claimed list because it is the short one. Over the wave this
1055
+ * verb was filed against, undeclared paths ran 22 of 26 on one branch and 18 of
1056
+ * 25 on another, so a report opening on that list is noise a reader skips past
1057
+ * and the crossing underneath it goes with them.
1058
+ */
1059
+ function reportReach(
1060
+ outcome: ReachOutcome,
1061
+ emitJson: boolean,
1062
+ root: string,
1063
+ ): number {
1064
+ if (!outcome.ok) {
1065
+ if (emitJson) {
1066
+ process.stdout.write(
1067
+ `${JSON.stringify({ ok: false, reason: outcome.reason, message: outcome.message })}\n`,
1068
+ )
1069
+ return 1
1070
+ }
1071
+
1072
+ intro('canon tasks plan-reach')
1073
+ logStep('Refused')
1074
+ logError(outcome.message)
1075
+ outro()
1076
+ return 1
1077
+ }
1078
+
1079
+ if (emitJson) {
1080
+ process.stdout.write(`${JSON.stringify({ ...outcome, root })}\n`)
1081
+ return outcome.claimed.length === 0 ? 0 : EXIT_FINDINGS
1082
+ }
1083
+
1084
+ intro('canon tasks plan-reach')
1085
+
1086
+ logStep(outcome.claimed.length === 0 ? 'Uncontested' : 'Claimed')
1087
+ if (outcome.claimed.length === 0) {
1088
+ logInfo('no other live plan or Run now row holds a path this branch wrote')
1089
+ } else {
1090
+ for (const claim of outcome.claimed) logWarn(describeReachClaim(claim))
1091
+ }
1092
+
1093
+ logStep('Undeclared')
1094
+ if (outcome.undeclared.length === 0) {
1095
+ logInfo(
1096
+ `${outcome.plan} declared every one of the ${outcome.changed} paths`,
1097
+ )
1098
+ } else {
1099
+ for (const path of outcome.undeclared) logInfo(path)
1100
+ logInfo(
1101
+ `${outcome.undeclared.length} of ${outcome.changed} path(s) this plan did not name, which the ship chain's own steps account for on most branches`,
1102
+ )
1103
+ }
1104
+
1105
+ // The counts are the report's own bound. A verb reading what is written down
1106
+ // sees no hand-launched track and no track without a plan, so a clear reading
1107
+ // over nothing compared against would otherwise read as a proof.
1108
+ logStep('Compared against')
1109
+ logInfo(
1110
+ `${outcome.plans} other live plan(s) and ${outcome.rows} Run now row(s)${outcome.board ? '' : ', with no board on disk to read'}`,
1111
+ )
1112
+
1113
+ outro()
1114
+
1115
+ return outcome.claimed.length === 0 ? 0 : EXIT_FINDINGS
1116
+ }
1117
+
964
1118
  function reportValidation(
965
1119
  outcome: ValidateOutcome,
966
1120
  emitJson: boolean,
@@ -384,6 +384,7 @@ async function runStylesheet(
384
384
  root,
385
385
  slug: outcome.slug,
386
386
  path: outcome.path,
387
+ basePath: outcome.basePath,
387
388
  written: outcome.written,
388
389
  })}\n`,
389
390
  )
@@ -393,6 +394,7 @@ async function runStylesheet(
393
394
  intro('canon teach stylesheet')
394
395
  logStep(outcome.written ? 'Written' : 'Already present, left alone')
395
396
  logInfo(outcome.path)
397
+ logInfo(outcome.basePath)
396
398
  outro()
397
399
  return 0
398
400
  }
@@ -708,6 +708,72 @@ export const readmeCitations: Measure = async (ctx) => {
708
708
  }
709
709
  }
710
710
 
711
+ /**
712
+ * `deploy-site.yml` and `pr-visual-checks.yml` each carry their own literal
713
+ * copy of the eight path globs a landing-page change should trigger on, per
714
+ * Question 2 in `.canon/plans/archive/feature-render-visibility.md`. A YAML
715
+ * anchor cannot cross files here, so the two copies are read live and compared
716
+ * rather than one asserting a literal the other could drift behind unnoticed,
717
+ * mirroring how `web/e2e/home.spec.ts`'s rule-group test already reads live.
718
+ */
719
+ export const visualPathGlobs: Measure = async (ctx) => {
720
+ const deployPath = '.github/workflows/deploy-site.yml'
721
+ const visualPath = '.github/workflows/pr-visual-checks.yml'
722
+ const deployFile = join(ctx.root, deployPath)
723
+ const visualFile = join(ctx.root, visualPath)
724
+
725
+ if (!existsSync(deployFile) || !existsSync(visualFile)) {
726
+ return {
727
+ emissions: [],
728
+ unmeasured: `${deployPath} or ${visualPath} is absent, so no path list was compared.`,
729
+ }
730
+ }
731
+
732
+ const deploy = Bun.YAML.parse(readFileSync(deployFile, 'utf8')) as {
733
+ readonly on?: { readonly push?: { readonly paths?: readonly string[] } }
734
+ }
735
+ const visual = Bun.YAML.parse(readFileSync(visualFile, 'utf8')) as {
736
+ readonly on?: {
737
+ readonly pull_request?: { readonly paths?: readonly string[] }
738
+ }
739
+ }
740
+
741
+ const deployPaths = deploy.on?.push?.paths ?? []
742
+ const visualPaths = visual.on?.pull_request?.paths ?? []
743
+
744
+ if (deployPaths.length === 0 || visualPaths.length === 0) {
745
+ return {
746
+ emissions: [],
747
+ unmeasured: `${deployPath} or ${visualPath} carries no path list to compare.`,
748
+ }
749
+ }
750
+
751
+ const onlyDeploy = deployPaths.filter((path) => !visualPaths.includes(path))
752
+ const onlyVisual = visualPaths.filter((path) => !deployPaths.includes(path))
753
+
754
+ if (onlyDeploy.length === 0 && onlyVisual.length === 0) {
755
+ return {
756
+ emissions: [
757
+ info(
758
+ `${deployPaths.length} path glob(s) agree between ${deployPath} and ${visualPath}`,
759
+ ),
760
+ ],
761
+ }
762
+ }
763
+
764
+ return {
765
+ emissions: [
766
+ ...onlyDeploy.map((path) =>
767
+ warn(`${deployPath} carries ${path}, absent from ${visualPath}`),
768
+ ),
769
+ ...onlyVisual.map((path) =>
770
+ warn(`${visualPath} carries ${path}, absent from ${deployPath}`),
771
+ ),
772
+ ],
773
+ failure: `${deployPath} and ${visualPath} carry different path globs. Bring the two lists back into agreement.`,
774
+ }
775
+ }
776
+
711
777
  /**
712
778
  * `canon sandbox coverage` moves only when a person runs it, so a scenario added
713
779
  * with no expectation ships unnoticed.
@@ -12,6 +12,7 @@ import {
12
12
  shippedReferences,
13
13
  standardCriteria,
14
14
  unreferencedRules,
15
+ visualPathGlobs,
15
16
  } from '@/gate/measures'
16
17
  import { SHIPPED_CORPORA } from '@/shipped/references'
17
18
 
@@ -424,6 +425,16 @@ export const STAGES: readonly Stage[] = [
424
425
  skipped: 'Neither copy.ts nor README.md changed, so no citation was read',
425
426
  checks: [{ kind: 'measure', measure: readmeCitations }],
426
427
  },
428
+ {
429
+ // Scoped to the two files carrying the literal path-glob copy, so an edit
430
+ // to either one runs the check.
431
+ id: 'visual-path-globs',
432
+ label: 'Visual check path globs',
433
+ scope: /^\.github\/workflows\/(deploy-site|pr-visual-checks)\.yml$/,
434
+ skipped:
435
+ 'Neither deploy-site.yml nor pr-visual-checks.yml changed, so no path list was compared',
436
+ checks: [{ kind: 'measure', measure: visualPathGlobs }],
437
+ },
427
438
  {
428
439
  id: 'seed-standards',
429
440
  label: 'Seed standards',
package/src/git-files.ts CHANGED
@@ -68,6 +68,75 @@ export async function listChangedFiles(
68
68
  return [...new Set(paths.filter(Boolean))].sort()
69
69
  }
70
70
 
71
+ /** A file's old and new path across a git-detected rename. */
72
+ export interface RenamePair {
73
+ readonly from: string
74
+ readonly to: string
75
+ }
76
+
77
+ /**
78
+ * Every rename `base..working tree` carries, read through the same similarity
79
+ * detection `git diff -M` applies by default (50%).
80
+ *
81
+ * A move heavy enough on rewritten content to fall under that floor reports
82
+ * as a plain delete and add rather than a rename, which is not a defect this
83
+ * reads around: the diff genuinely does not carry the file a claim citing the
84
+ * old path describes.
85
+ *
86
+ * Returns undefined when git cannot answer, matching `listChangedFiles`.
87
+ */
88
+ export async function listRenames(
89
+ root: string,
90
+ base: string,
91
+ ): Promise<RenamePair[] | undefined> {
92
+ const output = await git(root, ['diff', '--name-status', '-M', base])
93
+ if (output === undefined) return undefined
94
+
95
+ const renames: RenamePair[] = []
96
+ for (const line of output.split('\n')) {
97
+ if (line === '') continue
98
+ const [status, from, to] = line.split('\t')
99
+ if (status === undefined || !status.startsWith('R')) continue
100
+ if (from === undefined || to === undefined) continue
101
+ renames.push({ from, to })
102
+ }
103
+ return renames
104
+ }
105
+
106
+ /**
107
+ * Added, non-comment lines from a unified diff hunk touching `.gitignore`.
108
+ *
109
+ * Shared between a local `git diff` and a patch string GitHub returns inline
110
+ * for the same file, so the two evidence sources read one pattern the same
111
+ * way.
112
+ */
113
+ export function parseIgnoreAdditions(diffText: string): string[] {
114
+ const patterns: string[] = []
115
+ for (const line of diffText.split('\n')) {
116
+ if (!line.startsWith('+') || line.startsWith('+++')) continue
117
+ const pattern = line.slice(1).trim()
118
+ if (pattern === '' || pattern.startsWith('#')) continue
119
+ patterns.push(pattern)
120
+ }
121
+ return patterns
122
+ }
123
+
124
+ /**
125
+ * Every pattern a branch added to `.gitignore` since `base`.
126
+ *
127
+ * `-U0` keeps the hunk to changed lines alone, which is all a claim needs.
128
+ *
129
+ * Returns undefined when git cannot answer, matching `listChangedFiles`.
130
+ */
131
+ export async function listIgnoreAdditions(
132
+ root: string,
133
+ base: string,
134
+ ): Promise<string[] | undefined> {
135
+ const output = await git(root, ['diff', '-U0', base, '--', '.gitignore'])
136
+ if (output === undefined) return undefined
137
+ return parseIgnoreAdditions(output)
138
+ }
139
+
71
140
  /**
72
141
  * Lists the files under `root`: tracked, plus untracked files git does not
73
142
  * ignore. The untracked half is what keeps a file added on this branch in scope
@@ -24,6 +24,16 @@ import { defineRenameRules, type RenameRules } from '@/migrate/rename'
24
24
  * Every name takes two words. Ten of these would have landed as a bare single
25
25
  * word under a plain strip, and a bare word such as `review` or `docs` is a
26
26
  * substring of ordinary prose with no token left for a later sweep to find.
27
+ *
28
+ * No bare single-word name may ever join this map, whatever prefix it would
29
+ * otherwise take. `wholeToken: true` below matches a standalone word rather
30
+ * than a namespaced compound, so a one-word key rewrites every unrelated use
31
+ * of that word too. `identity` proved it: a dry run against a fifth row
32
+ * reading `'identity': 'draft-identity'` reported 112 occurrences across 43
33
+ * files, most of them a variable, field, or type name spelling the bare word
34
+ * rather than the skill, including `readonly identity: SelfIdentity` at
35
+ * `src/sessions/resolve.ts:255`, which the rewrite would have turned into
36
+ * invalid TypeScript. The row was never added.
27
37
  */
28
38
  export const SKILL_NAME_MAP: Readonly<Record<string, string>> = {
29
39
  'claude-address-review': 'review-address',
@@ -1,3 +1,4 @@
1
+ import type { RenamePair } from '@/git-files'
1
2
  import { extractKeyChangePaths, KEY_CHANGES, type PathClaim } from '@/pr/paths'
2
3
 
3
4
  /**
@@ -53,6 +54,14 @@ export interface BijectionReport {
53
54
  * branch author.
54
55
  */
55
56
  readonly incidental: readonly string[]
57
+ /**
58
+ * True when the rename or `.gitignore`-addition evidence could not be read.
59
+ *
60
+ * A claim `unmet` would otherwise carry is downgraded to `unresolved`
61
+ * instead, since the unread evidence might have credited it and this
62
+ * comparison has no way to tell a stale claim from one it could not check.
63
+ */
64
+ readonly evidenceUnread: boolean
56
65
  }
57
66
 
58
67
  export type Bijection =
@@ -66,6 +75,20 @@ export interface BijectionInput {
66
75
  readonly roots: ReadonlySet<string>
67
76
  readonly head?: string
68
77
  readonly title?: string
78
+ /** A rename's source path credits a claim naming it, without ever counting as a changed file. */
79
+ readonly renames?: readonly RenamePair[]
80
+ /** A pattern newly added to `.gitignore`, crediting a claim naming it as the `.gitignore` change it is. */
81
+ readonly ignoreAdditions?: readonly string[]
82
+ /**
83
+ * True when the caller could not read the rename or ignore-addition
84
+ * evidence, rather than reading it and finding neither.
85
+ *
86
+ * The two states produce the same empty `renames`/`ignoreAdditions`, so
87
+ * this is the only way `compareKeyChanges` can tell a claim that is
88
+ * genuinely stale from one the evidence might have credited had the read
89
+ * succeeded.
90
+ */
91
+ readonly evidenceUnread?: boolean
69
92
  }
70
93
 
71
94
  /**
@@ -94,6 +117,28 @@ function owesNoBullet(path: string): boolean {
94
117
  return INCIDENTAL.some((pattern) => pattern.test(path))
95
118
  }
96
119
 
120
+ /** Drops a directory claim's trailing slash so it compares against a raw `.gitignore` pattern. */
121
+ function withoutTrailingSlash(path: string): string {
122
+ return path.endsWith('/') ? path.slice(0, -1) : path
123
+ }
124
+
125
+ /**
126
+ * Whether a claim names a pattern newly added to `.gitignore`.
127
+ *
128
+ * Compared with the trailing slash both sides may or may not carry stripped,
129
+ * since a bullet spells a folder pattern the way `.gitignore` itself does
130
+ * (`web/screenshots/`) and the diff line carries the identical text.
131
+ */
132
+ function coversIgnoreAddition(
133
+ claim: PathClaim,
134
+ ignoreAdditions: readonly string[],
135
+ ): boolean {
136
+ const named = withoutTrailingSlash(claim.path)
137
+ return ignoreAdditions.some(
138
+ (pattern) => withoutTrailingSlash(pattern) === named,
139
+ )
140
+ }
141
+
97
142
  /**
98
143
  * Whether one changed path is the file, or a file under the folder, a claim
99
144
  * names.
@@ -147,6 +192,10 @@ export function compareKeyChanges(input: BijectionInput): Bijection {
147
192
  return { kind: 'refused', reason: 'no-section' }
148
193
  if (read.claims.length === 0) return { kind: 'refused', reason: 'no-claims' }
149
194
 
195
+ const renames = input.renames ?? []
196
+ const ignoreAdditions = input.ignoreAdditions ?? []
197
+ const evidenceUnread = input.evidenceUnread ?? false
198
+
150
199
  const unmet: PathClaim[] = []
151
200
  const unresolved: PathClaim[] = []
152
201
  const named = new Set<string>()
@@ -155,7 +204,15 @@ export function compareKeyChanges(input: BijectionInput): Bijection {
155
204
  const hits = input.changed.filter((path) => covers(claim, path))
156
205
  for (const path of hits) named.add(path)
157
206
  if (hits.length > 0) continue
158
- if (claim.anchored && claim.leading) unmet.push(claim)
207
+
208
+ if (renames.some((rename) => covers(claim, rename.from))) continue
209
+
210
+ if (coversIgnoreAddition(claim, ignoreAdditions)) {
211
+ named.add('.gitignore')
212
+ continue
213
+ }
214
+
215
+ if (claim.anchored && claim.leading && !evidenceUnread) unmet.push(claim)
159
216
  else unresolved.push(claim)
160
217
  }
161
218
 
@@ -170,6 +227,7 @@ export function compareKeyChanges(input: BijectionInput): Bijection {
170
227
  unresolved,
171
228
  unnamed: reached.filter((path) => !owesNoBullet(path)),
172
229
  incidental: reached.filter(owesNoBullet),
230
+ evidenceUnread,
173
231
  }
174
232
  }
175
233