@erclx/canon 5.10.0 → 5.12.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 (45) hide show
  1. package/claude/.claude-plugin/plugin.json +1 -1
  2. package/claude/skills/auto-ship/REQUIREMENT.md +1 -0
  3. package/claude/skills/auto-ship/SKILL.md +5 -8
  4. package/claude/skills/auto-ship/references/failure-recovery.md +0 -1
  5. package/claude/skills/auto-ship/references/ui-checklist.md +21 -0
  6. package/claude/skills/git-pr/references/evidence.md +7 -7
  7. package/claude/skills/git-ship/REQUIREMENT.md +1 -1
  8. package/claude/skills/git-ship/SKILL.md +1 -1
  9. package/claude/skills/review-craft/references/ui.md +1 -2
  10. package/claude/skills/review-pr/SKILL.md +1 -1
  11. package/claude/skills/role-orchestrator/SKILL.md +2 -2
  12. package/claude/skills/ui-checklist/SKILL.md +1 -18
  13. package/docs/agents/gate.md +1 -1
  14. package/docs/agents/hooks.md +2 -0
  15. package/docs/agents/index.md +2 -2
  16. package/docs/agents/install-and-sync.md +2 -2
  17. package/docs/agents/pr-evidence.md +70 -22
  18. package/docs/agents/tasks-validate.md +3 -3
  19. package/docs/agents/worktrees.md +16 -1
  20. package/docs/target/migrations.md +1 -1
  21. package/docs/workflow/ai-workflow.md +2 -1
  22. package/docs/workflow/parallel-features.md +3 -3
  23. package/docs/workflow/skill-map.md +1 -1
  24. package/package.json +3 -4
  25. package/scripts/lib/sandbox-path.sh +1 -1
  26. package/src/commands/gate.ts +3 -2
  27. package/src/commands/pr.ts +93 -19
  28. package/src/commands/worktrees.ts +37 -0
  29. package/src/gate/sequencer.ts +0 -20
  30. package/src/gate/stages.ts +10 -3
  31. package/src/pr/evidence.ts +35 -0
  32. package/src/tasks/validate.ts +37 -27
  33. package/src/worktrees/bare-flag.ts +83 -0
  34. package/standards/tasks.md +1 -1
  35. package/tooling/base/configs/.husky/post-merge +30 -77
  36. package/tooling/base/configs/.husky/post-rewrite +3 -3
  37. package/tooling/base/reference.md +1 -1
  38. package/scripts/core/clean.sh +0 -36
  39. package/scripts/core/regen-agent-fixture.sh +0 -235
  40. package/scripts/core/regen-claude-copies.sh +0 -10
  41. package/scripts/core/regen-indexes.sh +0 -10
  42. package/scripts/core/repair-bare-flag.sh +0 -19
  43. package/scripts/core/snapshot.sh +0 -134
  44. package/scripts/core/update.sh +0 -35
  45. package/scripts/lib/worktree.sh +0 -20
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@erclx/canon",
3
3
  "type": "module",
4
- "version": "5.10.0",
4
+ "version": "5.12.0",
5
5
  "description": "Runs your agent's work and keeps its code good, on one setup for every repo.",
6
6
  "license": "MIT",
7
7
  "bin": {
@@ -36,9 +36,8 @@
36
36
  "prepare": "husky",
37
37
  "check": "bun src/cli.ts gate run",
38
38
  "check:ci": "bun src/cli.ts gate run --all --no-write",
39
- "update": "./scripts/core/update.sh",
40
- "clean": "./scripts/core/clean.sh",
41
- "snapshot": "./scripts/core/snapshot.sh",
39
+ "update": "bun update --interactive && bun run check",
40
+ "clean": "rm -rf node_modules && bun pm cache rm && bun install",
42
41
  "bootstrap": "./scripts/core/bootstrap.sh",
43
42
  "canon:sandbox": "canon sandbox",
44
43
  "canon:sandbox:reset": "canon sandbox reset",
@@ -7,7 +7,7 @@
7
7
  # both carry the rule sending shared session scratch to the main worktree root,
8
8
  # and nothing decides which root wins. A session picking the toolkit writes its
9
9
  # output where no manifest reads it, so the run reports success while the verdict
10
- # reports no writes at all. `scripts/eval/run.sh` keeps its fixture outside the
10
+ # reports no writes at all. The retired eval runner kept its fixture outside the
11
11
  # repository for the same reason.
12
12
  #
13
13
  # Mints a short random per-run identifier the first time it is asked for, then
@@ -5,7 +5,6 @@ import {
5
5
  commandRunner,
6
6
  exitCodeFor,
7
7
  type GateContext,
8
- repairBareFlag,
9
8
  type StageResult,
10
9
  type Summary,
11
10
  runStages,
@@ -24,6 +23,7 @@ import {
24
23
  pipeOutput,
25
24
  plural,
26
25
  } from '@/ui'
26
+ import { repairBareFlag } from '@/worktrees/bare-flag'
27
27
 
28
28
  interface RunCommandOptions {
29
29
  readonly all?: boolean
@@ -97,7 +97,8 @@ async function runGate(opts: RunCommandOptions): Promise<number> {
97
97
 
98
98
  if (!emitJson && !nested) intro('canon gate run')
99
99
 
100
- await repairBareFlag(root)
100
+ const repair = await repairBareFlag(root)
101
+ if (repair.message !== null) logWarn(repair.message)
101
102
 
102
103
  const changed = opts.all
103
104
  ? { scoped: false, files: [] }
@@ -32,6 +32,8 @@ import {
32
32
  findEvidenceLocal,
33
33
  findEvidencePreview,
34
34
  groupEvidence,
35
+ hasMarkedEvidenceComment,
36
+ readOwed,
35
37
  renderEvidenceBody,
36
38
  } from '@/pr/evidence'
37
39
  import { type HeadRefusal, resolveHead, resolveTip } from '@/pr/head'
@@ -88,6 +90,7 @@ interface EvidenceOptions extends ReadOptions {
88
90
  readonly preview?: string
89
91
  readonly checklist?: string
90
92
  readonly local?: string
93
+ readonly check?: boolean
91
94
  }
92
95
 
93
96
  interface LocalOptions extends ReadOptions {
@@ -172,6 +175,7 @@ const PULL_REFUSALS: Record<PullRefusal | HeadRefusal, string> = {
172
175
  * one `identity.head` owns (`no-object-head`).
173
176
  */
174
177
  type EvidenceRefusal =
178
+ | 'check-writes'
175
179
  | 'gh-failed'
176
180
  | 'no-base'
177
181
  | 'unreadable-changes'
@@ -179,6 +183,8 @@ type EvidenceRefusal =
179
183
  | 'would-empty'
180
184
 
181
185
  const EVIDENCE_REFUSALS: Record<EvidenceRefusal, string> = {
186
+ 'check-writes':
187
+ '--check is a read and renders no body, so it takes none of --preview, --local, or --checklist. Run the check and the render as two calls.',
182
188
  'gh-failed':
183
189
  'gh could not answer for this repository. Name the pull request number.',
184
190
  'unreadable-checklist':
@@ -425,6 +431,10 @@ export function register(program: Command): void {
425
431
  '--local <url>',
426
432
  "Add this worktree's running server under any preview line, from canon pr local",
427
433
  )
434
+ .option(
435
+ '--check',
436
+ 'Report which post-pull-request steps the thread is still owed, rendering nothing',
437
+ )
428
438
  .addHelpText(
429
439
  'after',
430
440
  [
@@ -456,17 +466,17 @@ export function register(program: Command): void {
456
466
  '--checklist closes the body with a visual checklist, below the',
457
467
  'comparison it annotates. A checklist the marked comment already',
458
468
  'carries is read back and carried forward the same way the preview',
459
- 'address is, which keeps a re-render from wiping ticked boxes. It does',
460
- 'not decide no-evidence: a branch with a checklist and no evidence',
461
- 'image still reports no-evidence, and the caller posts the checklist',
462
- 'on its own.',
469
+ 'address is, which keeps a re-render from wiping ticked boxes. It',
470
+ 'turns no-evidence into ok: a branch with a checklist and no evidence',
471
+ 'image renders a marked body holding the checklist, so a later call',
472
+ 'finds and edits that comment.',
463
473
  '',
464
474
  '--local adds a **Local preview:** line under the hosted preview line,',
465
475
  'or opens the body with it when there is none, and is carried forward',
466
476
  'the same way. It does not by itself turn no-evidence into ok, so a',
467
477
  'branch with a server running and nothing to show posts no link-only',
468
- 'comment. Together with --checklist it does, so the checklist and the',
469
- 'link land in one comment.',
478
+ 'comment. Together with --checklist the link and the checklist land in',
479
+ 'one comment.',
470
480
  '',
471
481
  'Both reasons carry what the marked comment already posted, each field',
472
482
  'present only when that comment holds it:',
@@ -474,12 +484,24 @@ export function register(program: Command): void {
474
484
  ' local the **Local preview:** address under it',
475
485
  ' checklist the checklist between its delimiters, ticks included',
476
486
  'These come from the comment already posted, never from the flags this',
477
- 'call passed. A checklist posted on its own carries no marker, so the',
478
- 'record reports none of the three for it. An unread thread refuses as',
479
- 'gh-failed on both reasons rather than reporting the fields absent.',
487
+ 'call passed. A checklist the caller posted raw after a refused render',
488
+ 'carries no marker, so the record reports none of the three for it. An',
489
+ 'unread thread refuses as gh-failed on both reasons rather than',
490
+ 'reporting the fields absent.',
491
+ '',
492
+ '--check reads the same changed set and thread, renders no body, and',
493
+ 'reports what the ship chain still owes the pull request:',
494
+ ' settled nothing is owed',
495
+ ' owed `owed` lists evidence, preview, or both, in that order',
496
+ 'evidence is owed when an evidence image changed and no comment carries',
497
+ 'the marker. preview is owed when either holds, a deploy workflow in',
498
+ "this checkout resolves, and the marked comment's first line is no",
499
+ '**Preview:** line. A failed deploy reads the same as a skipped one.',
500
+ 'The record carries `owed` and the same marked fields. --check refuses',
501
+ 'with --preview, --local, or --checklist as check-writes.',
480
502
  '',
481
503
  'Exit codes:',
482
- ' 0 read, whether it produced a body or reported no-evidence',
504
+ ' 0 read, whether it produced a body, reported no-evidence, or checked',
483
505
  ' 1 refused, with the reason on stderr or in the JSON record',
484
506
  '',
485
507
  'Examples:',
@@ -488,6 +510,7 @@ export function register(program: Command): void {
488
510
  ' canon pr evidence 1341 --preview https://feat-x.site.pages.dev --json',
489
511
  ' canon pr evidence 1341 --checklist .canon/tmp/handoff/ui-checklist/x.md --json',
490
512
  ' canon pr evidence 1341 --local http://localhost:5173 --json',
513
+ ' canon pr evidence 1341 --check --json',
491
514
  '',
492
515
  ].join('\n'),
493
516
  )
@@ -1417,9 +1440,24 @@ async function runEvidence(
1417
1440
  ): Promise<number> {
1418
1441
  const root = resolve(opts.root ?? process.cwd())
1419
1442
  const emitJson = opts.json ?? false
1443
+ const isCheck = opts.check ?? false
1420
1444
 
1421
1445
  intro('canon pr evidence')
1422
1446
 
1447
+ if (
1448
+ isCheck &&
1449
+ (opts.preview !== undefined ||
1450
+ opts.local !== undefined ||
1451
+ opts.checklist !== undefined)
1452
+ ) {
1453
+ return refuseWith(
1454
+ 'check-writes',
1455
+ EVIDENCE_REFUSALS['check-writes'],
1456
+ emitJson,
1457
+ root,
1458
+ )
1459
+ }
1460
+
1423
1461
  // Read ahead of anything else, so a bad path refuses before a `gh` round
1424
1462
  // trip rather than after one.
1425
1463
  let suppliedChecklist: string | undefined
@@ -1461,9 +1499,13 @@ async function runEvidence(
1461
1499
  )
1462
1500
  }
1463
1501
 
1502
+ // The check never reads the merge base, so it skips the compare call and
1503
+ // a failed one cannot hold a draft lift for a reason the check never had.
1464
1504
  const [pullFiles, base] = await Promise.all([
1465
1505
  listPullFiles(root, identity.number),
1466
- readPullMergeBase(root, identity.number, identity.head),
1506
+ isCheck
1507
+ ? undefined
1508
+ : readPullMergeBase(root, identity.number, identity.head),
1467
1509
  ])
1468
1510
  if (pullFiles === undefined) {
1469
1511
  return refuseWith(
@@ -1473,9 +1515,6 @@ async function runEvidence(
1473
1515
  root,
1474
1516
  )
1475
1517
  }
1476
- if (base === undefined) {
1477
- return refuseWith('no-base', EVIDENCE_REFUSALS['no-base'], emitJson, root)
1478
- }
1479
1518
 
1480
1519
  const grouped = await groupEvidence(
1481
1520
  [...pullFiles.keys()],
@@ -1533,14 +1572,49 @@ async function runEvidence(
1533
1572
  ...(carriedChecklist !== undefined && { checklist: carriedChecklist }),
1534
1573
  }
1535
1574
 
1536
- // A local address rides on a checklist or a hosted preview and never opens
1537
- // a comment alone, so a docs-only branch with a server up posts nothing.
1538
- const hasChecklistAndLocal =
1539
- suppliedChecklist !== undefined && opts.local !== undefined
1575
+ if (isCheck) {
1576
+ const owed = readOwed({
1577
+ hasEvidenceChange: grouped.kind === 'read',
1578
+ hasMarkedComment: hasMarkedEvidenceComment(comments),
1579
+ carriedPreview: carriedPreview !== undefined,
1580
+ deployWorkflowFound:
1581
+ findDeployWorkflow(await readWorkflows(root)).kind === 'found',
1582
+ })
1583
+ logStep(owed.length === 0 ? 'Settled' : 'Owed')
1584
+ logInfo(
1585
+ owed.length === 0
1586
+ ? 'Nothing the ship chain posts after the pull request is missing.'
1587
+ : `missing ${owed.join(', ')}`,
1588
+ )
1589
+ outro()
1590
+ if (emitJson) {
1591
+ process.stdout.write(
1592
+ `${JSON.stringify({
1593
+ root,
1594
+ number: identity.number,
1595
+ reason: owed.length === 0 ? 'settled' : 'owed',
1596
+ owed,
1597
+ ...(commentId !== undefined && { commentId }),
1598
+ ...carried,
1599
+ })}\n`,
1600
+ )
1601
+ }
1602
+ return 0
1603
+ }
1604
+
1605
+ if (base === undefined) {
1606
+ return refuseWith('no-base', EVIDENCE_REFUSALS['no-base'], emitJson, root)
1607
+ }
1608
+
1609
+ // A checklist opens a comment on its own, so it always lands in the marked
1610
+ // comment a later call edits. A local address rides on a checklist or a
1611
+ // hosted preview and never opens one alone, so a docs-only branch with a
1612
+ // server up posts nothing.
1613
+ const hasChecklist = suppliedChecklist !== undefined
1540
1614
  if (
1541
1615
  grouped.kind === 'refused' &&
1542
1616
  opts.preview === undefined &&
1543
- !hasChecklistAndLocal
1617
+ !hasChecklist
1544
1618
  ) {
1545
1619
  logStep('Skipped')
1546
1620
  logInfo(
@@ -17,6 +17,7 @@ import {
17
17
  type WorktreeVerdict,
18
18
  } from '@/worktrees/reclaim'
19
19
  import { mainWorktreeRoot } from '@/worktree'
20
+ import { repairBareFlag } from '@/worktrees/bare-flag'
20
21
  import {
21
22
  removeReclaimable,
22
23
  type RemovalOutcome,
@@ -27,6 +28,11 @@ interface ListCommandOptions {
27
28
  readonly json?: boolean
28
29
  }
29
30
 
31
+ interface RepairBareFlagOptions {
32
+ readonly root?: string
33
+ readonly json?: boolean
34
+ }
35
+
30
36
  interface ReclaimCommandOptions {
31
37
  readonly dryRun?: boolean
32
38
  readonly json?: boolean
@@ -208,6 +214,37 @@ export function register(program: Command): void {
208
214
  .action(async (opts: ReclaimCommandOptions) => {
209
215
  process.exitCode = await runReclaim(opts)
210
216
  })
217
+
218
+ worktrees
219
+ .command('repair-bare-flag', { hidden: true })
220
+ .description('Clear a core.bare flag that worktree entry left set')
221
+ .helpOption('-h, --help', 'Show this help message')
222
+ .option('--root <path>', 'Repository to repair', '.')
223
+ .option('--json', 'Print the record on stdout')
224
+ .addHelpText(
225
+ 'after',
226
+ [
227
+ '',
228
+ 'Exit codes:',
229
+ ' 0 always, since the caller is a hook that must never fail a tool call',
230
+ '',
231
+ 'Called by .claude/hooks/bare-flag-repair.sh, the one caller that is not',
232
+ 'a person, so no help listing shows it. A repository whose common dir is',
233
+ 'not named .git keeps its flag, since that is a genuinely bare one.',
234
+ '',
235
+ 'Examples:',
236
+ ' canon worktrees repair-bare-flag --root . --json',
237
+ '',
238
+ ].join('\n'),
239
+ )
240
+ .action(async (opts: RepairBareFlagOptions) => {
241
+ const record = await repairBareFlag(opts.root ?? '.')
242
+ if (opts.json === true) {
243
+ process.stdout.write(`${JSON.stringify(record)}\n`)
244
+ } else if (record.message !== null) {
245
+ logWarn(record.message)
246
+ }
247
+ })
211
248
  }
212
249
 
213
250
  /**
@@ -377,23 +377,3 @@ async function spawn(
377
377
  spawnError: code === 'ENOENT' ? `${file} is not on PATH` : undefined,
378
378
  }
379
379
  }
380
-
381
- /**
382
- * Repairs `core.bare`, which Claude Code's worktree entry leaves set in the
383
- * shared config and nothing restores.
384
- *
385
- * It runs ahead of every stage rather than as one of them, because the flag
386
- * breaks the git reads that scope the run. The rule itself stays in
387
- * `scripts/lib/worktree.sh`, which is the one bash function under test, so this
388
- * calls it rather than restating the guard that spares a genuinely bare
389
- * repository.
390
- */
391
- export async function repairBareFlag(root: string): Promise<void> {
392
- await execa('bash', [join(root, 'scripts/core/repair-bare-flag.sh')], {
393
- cwd: root,
394
- reject: false,
395
- stdio: 'inherit',
396
- env: { ...gitEnv(), PROJECT_ROOT: root },
397
- extendEnv: false,
398
- })
399
- }
@@ -99,7 +99,6 @@ export const TEST_CORPORA_PATTERNS = [
99
99
  '^standards/markdown\\.md$',
100
100
  '^tooling/base/reference\\.md$',
101
101
  '^tooling/web/configs/scripts/worktree-port\\.sh$',
102
- '^scripts/lib/worktree\\.sh$',
103
102
  '^scripts/core/check-ignore-parity\\.sh$',
104
103
  ]
105
104
 
@@ -164,7 +163,11 @@ export const STAGES: readonly Stage[] = [
164
163
  id: 'indexes',
165
164
  label: 'Indexes',
166
165
  checks: [
167
- script('regen-indexes.sh', 'Index regen failed'),
166
+ {
167
+ kind: 'cli',
168
+ argv: ['indexes', 'regen'],
169
+ failure: 'Index regen failed',
170
+ },
168
171
  {
169
172
  kind: 'drift',
170
173
  pathspec: '*index.md',
@@ -178,7 +181,11 @@ export const STAGES: readonly Stage[] = [
178
181
  id: 'consumed-copies',
179
182
  label: 'Consumed copies',
180
183
  checks: [
181
- script('regen-claude-copies.sh', 'Consumed-copy regen failed'),
184
+ {
185
+ kind: 'cli',
186
+ argv: ['gov', 'regen'],
187
+ failure: 'Consumed-copy regen failed',
188
+ },
182
189
  {
183
190
  kind: 'drift',
184
191
  pathspec: '.claude/rules',
@@ -215,6 +215,41 @@ function hasEvidenceMarker(body: string): boolean {
215
215
  return (lines[index] ?? '').trim().startsWith(MARKER_PREFIX)
216
216
  }
217
217
 
218
+ /** Whether any comment carries the evidence marker, url or not. */
219
+ export function hasMarkedEvidenceComment(
220
+ comments: readonly EvidenceComment[],
221
+ ): boolean {
222
+ return comments.some((comment) => hasEvidenceMarker(comment.body))
223
+ }
224
+
225
+ /** A post-pull-request step of the ship chain that never landed on the thread. */
226
+ export type OwedStep = 'evidence' | 'preview'
227
+
228
+ export interface OwedInput {
229
+ readonly hasEvidenceChange: boolean
230
+ readonly hasMarkedComment: boolean
231
+ readonly carriedPreview: boolean
232
+ readonly deployWorkflowFound: boolean
233
+ }
234
+
235
+ /**
236
+ * The steps a pull request is still owed, read off the pull request rather
237
+ * than off any session's account of what it ran. The thread cannot tell a
238
+ * skipped preview from one whose deploy failed, so both read as owed.
239
+ */
240
+ export function readOwed(input: OwedInput): OwedStep[] {
241
+ const owed: OwedStep[] = []
242
+ if (input.hasEvidenceChange && !input.hasMarkedComment) owed.push('evidence')
243
+ if (
244
+ (input.hasMarkedComment || input.hasEvidenceChange) &&
245
+ input.deployWorkflowFound &&
246
+ !input.carriedPreview
247
+ ) {
248
+ owed.push('preview')
249
+ }
250
+ return owed
251
+ }
252
+
218
253
  /**
219
254
  * The REST id of the comment this module already posted, read off its `url`
220
255
  * field, since `gh pr view --json comments` reports only a GraphQL id there
@@ -6,7 +6,6 @@ import {
6
6
  declinedDir,
7
7
  isReservedStem,
8
8
  readOutcomes,
9
- readPlanTarget,
10
9
  readPlanTargets,
11
10
  readPullRequest,
12
11
  resolveLivePlan,
@@ -731,11 +730,8 @@ async function planDisagreement(
731
730
  if (!existsSync(file)) return undefined
732
731
 
733
732
  const text = await readFile(file, 'utf8')
734
- const target = readPlanTarget(text)
735
- if (!target) {
736
- const several = severalPlans(text, 'Run now', subject)
737
- if (several) return several
738
-
733
+ const targets = readPlanTargets(text)
734
+ if (targets.length === 0) {
739
735
  return {
740
736
  kind: 'plan-uncited',
741
737
  group: 'Run now',
@@ -744,15 +740,22 @@ async function planDisagreement(
744
740
  }
745
741
  }
746
742
 
743
+ const twice = duplicatePlans(targets, 'Run now', subject, dir, root)
744
+ if (twice) return twice
745
+
747
746
  const rowed = planPath(row.plan, dir, root)
748
- const cited = planPath(target, dir, root)
749
- if (rowed === cited) return undefined
747
+ if (targets.some((target) => planPath(target, dir, root) === rowed)) {
748
+ return undefined
749
+ }
750
750
 
751
751
  return {
752
752
  kind: 'plan-mismatched',
753
753
  group: 'Run now',
754
754
  subject,
755
- message: `is rowed against ${row.plan} and cites ${target} in its own Plan: line. One task names one plan.`,
755
+ message:
756
+ targets.length === 1
757
+ ? `is rowed against ${row.plan} and cites ${targets[0]} in its own Plan: line. One task names one plan.`
758
+ : `is rowed against ${row.plan}, which is none of the links on its own Plan: line (${targets.join(', ')}).`,
756
759
  }
757
760
  }
758
761
 
@@ -794,14 +797,15 @@ async function groupClaimFinding(
794
797
  if (!existsSync(file)) return undefined
795
798
 
796
799
  const text = await readFile(file, 'utf8')
797
- const target = readPlanTarget(text)
798
- if (!target) {
799
- const several = severalPlans(text, row.group, row.stem)
800
- if (several) return several
801
- }
800
+ const targets = readPlanTargets(text)
801
+ const twice = duplicatePlans(targets, row.group, row.stem, dir, root)
802
+ if (twice) return twice
802
803
 
803
- const live = target ? resolveLivePlan(target, dir, root) : undefined
804
- const hasLivePlan = live !== undefined && existsSync(live)
804
+ const target = targets.find((link) => {
805
+ const live = resolveLivePlan(link, dir, root)
806
+ return live !== undefined && existsSync(live)
807
+ })
808
+ const hasLivePlan = target !== undefined
805
809
 
806
810
  if (row.group === 'Needs a plan' && hasLivePlan) {
807
811
  return {
@@ -817,9 +821,12 @@ async function groupClaimFinding(
817
821
  kind: 'plan-absent',
818
822
  group: row.group,
819
823
  subject: row.stem,
820
- message: target
821
- ? `sits under ${row.group}, which claims a written plan, and its task cites ${target}, which is not a live plan.`
822
- : `sits under ${row.group}, which claims a written plan, and its task carries no Plan: line.`,
824
+ message:
825
+ targets.length > 1
826
+ ? `sits under ${row.group}, which claims a written plan, and its task cites ${targets.join(', ')}, none of which is a live plan.`
827
+ : targets.length === 1
828
+ ? `sits under ${row.group}, which claims a written plan, and its task cites ${targets[0]}, which is not a live plan.`
829
+ : `sits under ${row.group}, which claims a written plan, and its task carries no Plan: line.`,
823
830
  }
824
831
  }
825
832
 
@@ -827,23 +834,26 @@ async function groupClaimFinding(
827
834
  }
828
835
 
829
836
  /**
830
- * Reports a `Plan:` line linking more than one plan. `readPlanTarget` reads
831
- * such a line as naming nothing, so without this a caller would report the
832
- * line as absent when it is there and breaks the one-plan rule instead.
837
+ * Reports a `Plan:` line linking one plan more than once. Links to distinct
838
+ * plans are the shape a sliced task carries, so only a repeat is a finding.
839
+ * Links compare by the file they resolve to, so `../plans/x.md` beside
840
+ * `.canon/plans/x.md` is one plan listed twice.
833
841
  */
834
- function severalPlans(
835
- text: string,
842
+ function duplicatePlans(
843
+ targets: readonly string[],
836
844
  group: Finding['group'],
837
845
  subject: string,
846
+ dir: string,
847
+ root: string,
838
848
  ): Finding | undefined {
839
- const targets = readPlanTargets(text)
840
- if (targets.length < 2) return undefined
849
+ const paths = targets.map((target) => planPath(target, dir, root))
850
+ if (new Set(paths).size === paths.length) return undefined
841
851
 
842
852
  return {
843
853
  kind: 'plan-several',
844
854
  group,
845
855
  subject,
846
- message: `cites ${targets.length} plans on its Plan: line (${targets.join(', ')}). One task names one plan, so split the task or drop the extra links.`,
856
+ message: `lists the same plan twice on its Plan: line (${targets.join(', ')}). Drop the repeat, or link a distinct plan per slice.`,
847
857
  }
848
858
  }
849
859
 
@@ -0,0 +1,83 @@
1
+ import { basename } from 'node:path'
2
+ import { execa } from 'execa'
3
+ import { gitEnv } from '@/git-env'
4
+
5
+ /** Why a run wrote nothing. The three are different states a caller can tell apart. */
6
+ export type BareFlagSkip =
7
+ | 'flag-unset'
8
+ | 'common-dir-unreadable'
9
+ | 'genuinely-bare'
10
+ | 'write-failed'
11
+
12
+ /**
13
+ * What the repair did, or the reason it left the configuration alone.
14
+ *
15
+ * `message` is non-null whenever the flag is still set and a caller should say
16
+ * so, which is a repair and a failed write. The three quiet skips carry none.
17
+ */
18
+ export type BareFlagRecord =
19
+ | { readonly repaired: true; readonly reason: null; readonly message: string }
20
+ | {
21
+ readonly repaired: false
22
+ readonly reason: BareFlagSkip
23
+ readonly message: string | null
24
+ }
25
+
26
+ export const REPAIRED_MESSAGE =
27
+ "Repaired core.bare, which worktree entry left set. Recovery is 'git config core.bare false'."
28
+
29
+ export const WRITE_FAILED_MESSAGE =
30
+ "core.bare is set and the repair could not write the config, which a lock file or a read-only config causes. Recovery is 'git config core.bare false'."
31
+
32
+ const skipped = (reason: BareFlagSkip): BareFlagRecord => ({
33
+ repaired: false,
34
+ reason,
35
+ message: null,
36
+ })
37
+
38
+ /**
39
+ * Repairs `core.bare`, which Claude Code's worktree entry leaves set in the
40
+ * shared config and nothing restores.
41
+ *
42
+ * The flag strands the main worktree and breaks the git reads that scope a
43
+ * verification run, so the gate runs this ahead of every stage. Every git call
44
+ * drops the repository-resolution variables a git hook exports, so the root
45
+ * the caller names is the repository that gets read and written.
46
+ *
47
+ * A genuinely bare repository keeps its objects at the root and has no `.git`
48
+ * directory, so its common dir fails the basename test and keeps its flag. That
49
+ * test is the one case where an unguarded repair does damage.
50
+ */
51
+ export async function repairBareFlag(root: string): Promise<BareFlagRecord> {
52
+ const git = (...args: string[]) =>
53
+ execa('git', ['-C', root, ...args], {
54
+ env: gitEnv(),
55
+ extendEnv: false,
56
+ reject: false,
57
+ })
58
+
59
+ const flag = await git('config', '--get', 'core.bare')
60
+ if (flag.exitCode !== 0 || flag.stdout.trim() !== 'true') {
61
+ return skipped('flag-unset')
62
+ }
63
+
64
+ const common = await git(
65
+ 'rev-parse',
66
+ '--path-format=absolute',
67
+ '--git-common-dir',
68
+ )
69
+ if (common.exitCode !== 0) return skipped('common-dir-unreadable')
70
+ if (basename(common.stdout.trim()) !== '.git')
71
+ return skipped('genuinely-bare')
72
+
73
+ const write = await git('config', 'core.bare', 'false')
74
+ if (write.exitCode !== 0) {
75
+ return {
76
+ repaired: false,
77
+ reason: 'write-failed',
78
+ message: WRITE_FAILED_MESSAGE,
79
+ }
80
+ }
81
+
82
+ return { repaired: true, reason: null, message: REPAIRED_MESSAGE }
83
+ }
@@ -124,7 +124,7 @@ An intake folder answers that direction at folder scope rather than item scope.
124
124
 
125
125
  A project that archived plans before the folder nested under `.canon/plans/` holds closed tasks pointing at `../plans-archive/` or `../.tmp/plans-archive/`. Leave those pointers where they are, since each resolves against the files it names and a task retargeted without its plan moving leads nowhere.
126
126
 
127
- One plan per task, or one per slice on a sliced task. A plan cited by two tasks is a misfile rather than a shape to design for.
127
+ One plan per task, or one per slice, which the board check reads as distinct plans on one line. A plan cited by two tasks is a misfile.
128
128
 
129
129
  `canon tasks plan-link <task> <plan>` writes or corrects the `Plan:` line, and `--add` appends a slice's plan, so the line is never hand-edited.
130
130