@erclx/canon 5.10.0 → 5.11.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.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "canon",
3
3
  "description": "Runs your agent's work and keeps its code good, on one setup for every repo.",
4
- "version": "5.10.0",
4
+ "version": "5.11.0",
5
5
  "author": {
6
6
  "name": "Eric Le",
7
7
  "url": "https://github.com/erclx"
@@ -63,7 +63,7 @@ Report the state of play in the invocation block, and open every later sweep rep
63
63
  4. Hand off. Read `${CLAUDE_SKILL_DIR}/references/orchestrator-dispatch.md` and follow it: check the branch is unclaimed, check the row's file set against every track in flight, then dispatch a background worker with `claude --bg`. Fall back to the human-launch line it replaces when the check refuses, the sets overlap, or a stated reason serializes the row behind something already out.
64
64
  5. Review the PR. When a worker opens a PR, its first pass runs through `review-pr` in a reviewer dispatched under `role-reviewer` when `### The review dispatch` below fires, and in this session otherwise. Either is the deep, independent pass. The worker's autoship self-review was only the green gate.
65
65
  - Learning that a PR moved is the mechanical half, so read `${CLAUDE_SKILL_DIR}/references/orchestrator-poll.md` and run the poll under the condition it states rather than checking the board by hand. That runbook holds the routing and the trigger, and a summary of it here is a second source that drifts from it.
66
- 6. Dispatch the handback. A pass posting anything owed, a finding at any severity or a testing question, tells the session holding that branch to run `review-address`, rather than waiting for a person to relay it. Re-review when the worker's own message says the address pass finished, per the channel `role-worker` states, rather than polling for an answer nothing else marks as landed, and send the re-review back to the dispatched reviewer when one took the first pass. Once a pass posts `## Review closed`, and once a UI close names the current head wherever the PR carries a checklist, lift the pull request's draft mark yourself, per Boundaries below. Then the human merges. Tell the trailing worker to rebase when its branch shares a seam with the merged one.
66
+ 6. Dispatch the handback. A pass posting anything owed, a finding at any severity or a testing question, tells the session holding that branch to run `review-address`, rather than waiting for a person to relay it. Re-review when the worker's own message says the address pass finished, per the channel `role-worker` states, rather than polling for an answer nothing else marks as landed, and send the re-review back to the dispatched reviewer when one took the first pass. Once a pass posts `## Review closed`, a UI close names the current head wherever the PR carries a checklist, and `--check` reports `settled`, lift the pull request's draft mark yourself, per Boundaries below. Then the human merges. Tell the trailing worker to rebase when its branch shares a seam with the merged one.
67
67
  - Read `${CLAUDE_SKILL_DIR}/references/orchestrator-handback.md` on reaching this step. It holds how the message is addressed and worded, what to do when no live session holds the branch, and where a worker's reply goes.
68
68
 
69
69
  A plan written here is written against a tree several branches are already changing, so it names the file set of every track in flight as a constraint, one set per track, read from the Touches column of that track's row. State for each set which of the two acts it forbids, per Constraints in `${CLAUDE_SKILL_DIR}/../../standards/plan.md`. A bare path list leaves the worker guessing, which is how a plan ends up forbidding the repair of a citation the change broke.
@@ -75,7 +75,7 @@ Stamp the block with the commit this session read the tree at, which the same se
75
75
  - Run one orchestrator at a time. The board is gitignored, so a second session sees none of this one's writes: two task files land minutes apart under different labels for the same work, one session archives a task mid-sweep in the other, and each archives a plan the other had retargeted. An Owner column does not fix this, since neither session can read the other's rows.
76
76
  - Do not implement features in this session. Hand the plan to a worker.
77
77
  - Do not merge. Recommend merge or changes. The human merges.
78
- - Lift a pull request's draft mark once its review closes, whether this session or a reviewer it dispatched closed it, acting directly on the pull request rather than dispatching a worker to do it. Where `canon pr evidence <number> --json` carries a `checklist`, hold it until a UI close names the current head. `role-worker` and `role-reviewer` state the mirroring refusal: neither can verify who is asking or whether review actually closed, so the act stays with this session.
78
+ - Lift a pull request's draft mark once its review closes, whether this session or a reviewer it dispatched closed it, acting directly on the pull request rather than dispatching a worker. Hold it on any `canon pr evidence <number> --check --json` reason but `settled`, reporting that reason, and where that record carries a `checklist`, until a UI close names the current head. `role-worker` and `role-reviewer` state the mirroring refusal: neither can verify who is asking or whether review actually closed, so the act stays with this session.
79
79
  - Do not spawn a worker with the Agent tool. An in-process subagent shares this session's context and cannot be steered or reached independently, which breaks the property this boundary protects rather than the mechanism it names. The launch in `orchestrator-launch.md` is a separate `claude --bg` process with its own worktree and its own PR, so it preserves that property instead.
80
80
  - Dispatch a background worker only once the collision check in `orchestrator-dispatch.md` clears and the row's file set is disjoint from every track in flight. Colliding with an existing worktree or session is what the check exists to catch rather than a judgment call this session makes case by case. No fixed count binds how many tracks run at once, and Parallelism below states what does.
81
81
  - Do not edit tracked files from this session, at any size. The boundary offers no proportionality exception and nothing enforces it.
@@ -35,7 +35,7 @@ CLI catalog and invocation rules for agents, split by command domain. Start with
35
35
  - [Migrate](migrate.md): What each canon migrate verb moves and rewrites, what it refuses, the markers that keep an old spelling, and the idempotence check a second run gives
36
36
  - [Output shape](output-shape.md): Two framed shapes every command renders into, how JSON and --names modes keep stdout clean, and the exit discipline that lets piped output drain
37
37
  - [Overview](overview.md): What this folder covers, the invocation rules every command inherits, and where domain behavior is documented instead
38
- - [The pull request evidence comparison](pr-evidence.md): What canon pr evidence compares, the marker that lets it edit its own comment rather than duplicate it, the refusal reasons it names, and why the comparison anchors at the merge base rather than the previous push
38
+ - [The pull request evidence comparison](pr-evidence.md): What canon pr evidence compares, the marker that lets it edit its own comment rather than duplicate it, the refusal reasons it names, what the --check read reports as owed, and why the comparison anchors at the merge base rather than the previous push
39
39
  - [The pull request local preview](pr-local.md): How canon pr local finds the server a worktree is running, why a server in another worktree is never reported, the refusal reasons it names, and how a close workflow removes the link with --remove
40
40
  - [The pull request preview deploy](pr-preview.md): How canon pr preview finds a fenced Cloudflare Pages deploy, dispatches it on a pull request's branch, reads the alias the run prints, the refusal reasons it names, and why an unfenced deploy is refused before anything runs
41
41
  - [Head-sensitive pull request reads](pr-reads.md): Resolving a branch tip from the remote rather than from the pull request object, reading what a review pass covered off its own marker rather than off GitHub's submission stamps, why an empty run list is not a pass, the refusal reasons each verb names, and what the remote read costs
@@ -64,4 +64,4 @@ CLI catalog and invocation rules for agents, split by command domain. Start with
64
64
  - [Teach authoring](teach-authoring.md): Resolving what the next lesson needs before it is written, rendering a lesson body's blocks to HTML, and rewriting the root listing, a contents page, and each lesson's chrome and quiz stepper from its marker regions
65
65
  - [Teach](teach.md): Listing learning workspaces with what their records schedule next, opening one with its required files, recording sources and glossary terms, viewing one with its chrome refreshed, where the lesson authoring verbs are described, the refusal reasons, and why every write here runs through a verb
66
66
  - [Test order](test-order.md): Reading where an implementation reached history ahead of its test, how a pair is decided, the three verdicts, the coverage the pairing cannot reach, and why the check reports rather than gates
67
- - [Worktrees](worktrees.md): Reporting which worktrees are reclaimable, removing the ones that are, the record a hook reads back, why the reading keys on the pull request rather than on git ancestry, the refusals it names, and the two removal shapes
67
+ - [Worktrees](worktrees.md): Reporting which worktrees are reclaimable, removing the ones that are, the hidden verb that clears a stranded bare flag, the record a hook reads back, why the reading keys on the pull request rather than on git ancestry, the refusals it names, and the two removal shapes
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  title: The pull request evidence comparison
3
- description: What canon pr evidence compares, the marker that lets it edit its own comment rather than duplicate it, the refusal reasons it names, and why the comparison anchors at the merge base rather than the previous push
3
+ description: What canon pr evidence compares, the marker that lets it edit its own comment rather than duplicate it, the refusal reasons it names, what the --check read reports as owed, and why the comparison anchors at the merge base rather than the previous push
4
4
  ---
5
5
 
6
6
  # The pull request evidence comparison
@@ -58,6 +58,7 @@ image or marks the case new.
58
58
  | `no-base` | GitHub reported no merge base for the pull request. |
59
59
  | `unreadable-changes` | GitHub could not list what the pull request changed. |
60
60
  | `would-empty` | The render holds no cases and the marked comment holds some. No body. |
61
+ | `check-writes` | `--check` was passed with `--preview`, `--local`, or `--checklist`. |
61
62
 
62
63
  `no-evidence` is not a refusal a caller reports. A project on a stack that
63
64
  carries no evidence path, such as `base` or `python`, hits this reason on
@@ -179,6 +180,52 @@ the checklist land in one comment rather than the checklist going out alone.
179
180
  `canon docs pr-local` covers where the address comes from and how the line is
180
181
  removed at close.
181
182
 
183
+ ## What a pull request is still owed
184
+
185
+ `--check` reads the same changed set and the same thread, renders no body, and
186
+ reports which steps of the ship chain that run after the pull request opens
187
+ never landed on it. It reads the pull request rather than any session's account
188
+ of what it ran, so a session that stopped after opening the pull request leaves
189
+ a branch the check can tell apart from one that needed neither step.
190
+
191
+ ```bash
192
+ canon pr evidence 1341 --check --json
193
+ ```
194
+
195
+ | Reason | What it means |
196
+ | --------- | ------------------------------------------------------------------ |
197
+ | `settled` | Nothing is owed. `owed` is empty. |
198
+ | `owed` | `owed` lists `evidence`, `preview`, or both, always in that order. |
199
+
200
+ - `evidence` is owed when the changed set carries an evidence image and no
201
+ comment carries the marker.
202
+ - `preview` is owed when the pull request has a marked comment or an evidence
203
+ change, a deploy workflow resolves in the checkout the verb runs from, and
204
+ the marked comment's first line is no `**Preview:**` line. A
205
+ `**Local preview:**` line does not satisfy it. A project deploying nothing is
206
+ never owed a preview.
207
+
208
+ The record carries `owed`, `commentId` when a marked comment exists, and the
209
+ same `preview`, `local`, and `checklist` fields the other reasons carry, so a
210
+ caller reads one shape. It never carries `body`, so a writer branching on `ok`
211
+ and posting `body` cannot act on a check record. Passing `--preview`, `--local`,
212
+ or `--checklist` with it refuses as `check-writes` before any `gh` call, since
213
+ those ask a read to write.
214
+
215
+ The thread cannot separate a skipped preview step from a failed one. A pull
216
+ request whose deploy run failed or timed out reads as owing a preview, and the
217
+ session that ran it holds the cause. A checklist posted on its own carries no
218
+ marker, so a branch with a checklist and no evidence image never reads as
219
+ owing a preview, though `git-pr` runs the preview step for it. The deploy
220
+ workflow is read from the checkout rather than the pull request's head, so a
221
+ pull request that adds or removes its own deploy workflow reads against trunk's.
222
+
223
+ An unreadable thread refuses as `gh-failed` rather than reporting `settled` off
224
+ a thread it never read. The check skips the merge base read, which only the
225
+ render needs, so it never refuses `no-base`. The orchestrator's draft lift is
226
+ the caller, and lifts only on `settled`, so a refusal holds it the same as
227
+ `owed` does.
228
+
182
229
  ## What a collapsed comment still leaves to GitHub
183
230
 
184
231
  GitHub already draws its own before-and-after comparison on the Files Changed
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  title: Worktrees
3
- description: Reporting which worktrees are reclaimable, removing the ones that are, the record a hook reads back, why the reading keys on the pull request rather than on git ancestry, the refusals it names, and the two removal shapes
3
+ description: Reporting which worktrees are reclaimable, removing the ones that are, the hidden verb that clears a stranded bare flag, the record a hook reads back, why the reading keys on the pull request rather than on git ancestry, the refusals it names, and the two removal shapes
4
4
  ---
5
5
 
6
6
  # Worktrees
@@ -24,6 +24,21 @@ Exit codes: `0` every worktree was read, `1` refused. The refusal carries a `rea
24
24
 
25
25
  An exit code says nothing about a call made from a session, since a shell profile may wrap the binary in a function taking its status from a later command. Read the record's `reason` rather than the exit when a skill consumes this.
26
26
 
27
+ ## Repair the bare flag
28
+
29
+ `canon worktrees repair-bare-flag` clears a `core.bare` flag that Claude Code's worktree entry left set in the shared config. It is hidden, so no help listing shows it, because its one caller is a hook rather than a person.
30
+
31
+ ```bash
32
+ canon worktrees repair-bare-flag --root <path> --json
33
+ ```
34
+
35
+ | Option | Behavior |
36
+ | --------------- | ------------------------------------- |
37
+ | `--root <path>` | Repository to repair, default the cwd |
38
+ | `--json` | Print the record on stdout |
39
+
40
+ `--json` writes `{repaired, reason, message}` on stdout, once, whether or not anything was written. `reason` is `flag-unset`, `common-dir-unreadable`, `genuinely-bare`, or `write-failed` when `repaired` is false. `message` carries text when the flag is repaired and when the write failed, and is null on the three quiet skips. A `write-failed` record means the flag is still set, which a lock file or a read-only config causes. A repository whose common dir is not named `.git` keeps its flag. The exit code is always `0`, so a hook never fails a tool call. Without `--json`, a record carrying a message prints it on stderr and any other prints nothing.
41
+
27
42
  ## Reclaim
28
43
 
29
44
  `canon worktrees reclaim` removes every worktree the reading above called reclaimable, and the branch behind each one.
@@ -8,7 +8,7 @@ category: Workflow
8
8
 
9
9
  Groups run in the order a project meets them, so a reader at a known point scans to that group and reads across. The set reconciles the scenarios in [AI workflow](ai-workflow.md) with the lifecycle [target projects](../target/projects.md) describes, rather than inventing a third vocabulary beside those two, so a group name matches neither source exactly and every moment either one names has a group. Each row says when to reach for the skill. What it does is the skill's own description.
10
10
 
11
- This page is the corpus the coverage claim is measured against: every name `canon claude skills list --names` reports takes exactly one row here. A skill serving two moments sits at the earlier one, and a mention on any other page is prose rather than routing.
11
+ This page is the corpus the coverage claim is measured against: every name `canon claude skills list --names` reports takes exactly one row here. A skill serving two moments sits at the earlier one, and a mention on any other page is prose rather than routing. The landing page's skills field renders from this page, so its build refuses a skill with no row and a row naming no skill.
12
12
 
13
13
  ## Set up a project
14
14
 
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.11.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": {
@@ -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
  [
@@ -478,8 +488,19 @@ export function register(program: Command): void {
478
488
  'record reports none of the three for it. An unread thread refuses as',
479
489
  'gh-failed on both reasons rather than reporting the fields absent.',
480
490
  '',
491
+ '--check reads the same changed set and thread, renders no body, and',
492
+ 'reports what the ship chain still owes the pull request:',
493
+ ' settled nothing is owed',
494
+ ' owed `owed` lists evidence, preview, or both, in that order',
495
+ 'evidence is owed when an evidence image changed and no comment carries',
496
+ 'the marker. preview is owed when either holds, a deploy workflow in',
497
+ "this checkout resolves, and the marked comment's first line is no",
498
+ '**Preview:** line. A failed deploy reads the same as a skipped one.',
499
+ 'The record carries `owed` and the same marked fields. --check refuses',
500
+ 'with --preview, --local, or --checklist as check-writes.',
501
+ '',
481
502
  'Exit codes:',
482
- ' 0 read, whether it produced a body or reported no-evidence',
503
+ ' 0 read, whether it produced a body, reported no-evidence, or checked',
483
504
  ' 1 refused, with the reason on stderr or in the JSON record',
484
505
  '',
485
506
  'Examples:',
@@ -488,6 +509,7 @@ export function register(program: Command): void {
488
509
  ' canon pr evidence 1341 --preview https://feat-x.site.pages.dev --json',
489
510
  ' canon pr evidence 1341 --checklist .canon/tmp/handoff/ui-checklist/x.md --json',
490
511
  ' canon pr evidence 1341 --local http://localhost:5173 --json',
512
+ ' canon pr evidence 1341 --check --json',
491
513
  '',
492
514
  ].join('\n'),
493
515
  )
@@ -1417,9 +1439,24 @@ async function runEvidence(
1417
1439
  ): Promise<number> {
1418
1440
  const root = resolve(opts.root ?? process.cwd())
1419
1441
  const emitJson = opts.json ?? false
1442
+ const isCheck = opts.check ?? false
1420
1443
 
1421
1444
  intro('canon pr evidence')
1422
1445
 
1446
+ if (
1447
+ isCheck &&
1448
+ (opts.preview !== undefined ||
1449
+ opts.local !== undefined ||
1450
+ opts.checklist !== undefined)
1451
+ ) {
1452
+ return refuseWith(
1453
+ 'check-writes',
1454
+ EVIDENCE_REFUSALS['check-writes'],
1455
+ emitJson,
1456
+ root,
1457
+ )
1458
+ }
1459
+
1423
1460
  // Read ahead of anything else, so a bad path refuses before a `gh` round
1424
1461
  // trip rather than after one.
1425
1462
  let suppliedChecklist: string | undefined
@@ -1461,9 +1498,13 @@ async function runEvidence(
1461
1498
  )
1462
1499
  }
1463
1500
 
1501
+ // The check never reads the merge base, so it skips the compare call and
1502
+ // a failed one cannot hold a draft lift for a reason the check never had.
1464
1503
  const [pullFiles, base] = await Promise.all([
1465
1504
  listPullFiles(root, identity.number),
1466
- readPullMergeBase(root, identity.number, identity.head),
1505
+ isCheck
1506
+ ? undefined
1507
+ : readPullMergeBase(root, identity.number, identity.head),
1467
1508
  ])
1468
1509
  if (pullFiles === undefined) {
1469
1510
  return refuseWith(
@@ -1473,9 +1514,6 @@ async function runEvidence(
1473
1514
  root,
1474
1515
  )
1475
1516
  }
1476
- if (base === undefined) {
1477
- return refuseWith('no-base', EVIDENCE_REFUSALS['no-base'], emitJson, root)
1478
- }
1479
1517
 
1480
1518
  const grouped = await groupEvidence(
1481
1519
  [...pullFiles.keys()],
@@ -1533,6 +1571,40 @@ async function runEvidence(
1533
1571
  ...(carriedChecklist !== undefined && { checklist: carriedChecklist }),
1534
1572
  }
1535
1573
 
1574
+ if (isCheck) {
1575
+ const owed = readOwed({
1576
+ hasEvidenceChange: grouped.kind === 'read',
1577
+ hasMarkedComment: hasMarkedEvidenceComment(comments),
1578
+ carriedPreview: carriedPreview !== undefined,
1579
+ deployWorkflowFound:
1580
+ findDeployWorkflow(await readWorkflows(root)).kind === 'found',
1581
+ })
1582
+ logStep(owed.length === 0 ? 'Settled' : 'Owed')
1583
+ logInfo(
1584
+ owed.length === 0
1585
+ ? 'Nothing the ship chain posts after the pull request is missing.'
1586
+ : `missing ${owed.join(', ')}`,
1587
+ )
1588
+ outro()
1589
+ if (emitJson) {
1590
+ process.stdout.write(
1591
+ `${JSON.stringify({
1592
+ root,
1593
+ number: identity.number,
1594
+ reason: owed.length === 0 ? 'settled' : 'owed',
1595
+ owed,
1596
+ ...(commentId !== undefined && { commentId }),
1597
+ ...carried,
1598
+ })}\n`,
1599
+ )
1600
+ }
1601
+ return 0
1602
+ }
1603
+
1604
+ if (base === undefined) {
1605
+ return refuseWith('no-base', EVIDENCE_REFUSALS['no-base'], emitJson, root)
1606
+ }
1607
+
1536
1608
  // A local address rides on a checklist or a hosted preview and never opens
1537
1609
  // a comment alone, so a docs-only branch with a server up posts nothing.
1538
1610
  const hasChecklistAndLocal =
@@ -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
 
@@ -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
@@ -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
+ }
@@ -1,19 +0,0 @@
1
- #!/usr/bin/env bash
2
- # Repairs `core.bare`, which Claude Code's worktree entry leaves set in the
3
- # shared config with nothing restoring it. The flag strands the main worktree
4
- # and breaks the git reads that scope a verification run, so this runs ahead of
5
- # every stage rather than as one of them.
6
- #
7
- # The rule itself lives in `scripts/lib/worktree.sh`, the one bash function in
8
- # this repository under test, so this file is the invocation and never a second
9
- # copy of the guard that spares a genuinely bare repository.
10
- set -e
11
- set -o pipefail
12
-
13
- SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
14
- PROJECT_ROOT="${PROJECT_ROOT:-$(cd "$SCRIPT_DIR/../.." && pwd)}"
15
-
16
- source "$PROJECT_ROOT/scripts/lib/ui.sh"
17
- source "$PROJECT_ROOT/scripts/lib/worktree.sh"
18
-
19
- repair_bare_flag "$PROJECT_ROOT"
@@ -1,20 +0,0 @@
1
- #!/usr/bin/env bash
2
-
3
- # Claude Code's worktree entry writes core.bare into the parent repository's
4
- # shared config and its exit never restores it, stranding every later command in
5
- # the main worktree. Sourced by verify.sh, which runs the repair before any stage
6
- # because the flag breaks the git reads that scope the run.
7
-
8
- repair_bare_flag() {
9
- local root="${1:-$PROJECT_ROOT}"
10
- local common_dir
11
- [ "$(git -C "$root" config --get core.bare 2>/dev/null || echo false)" = true ] || return 0
12
-
13
- common_dir=$(git -C "$root" rev-parse --path-format=absolute --git-common-dir 2>/dev/null) || return 0
14
- # A genuinely bare repository keeps its objects at the root and has no .git
15
- # directory, so it fails this test and keeps its flag.
16
- [ "$(basename "$common_dir")" = ".git" ] || return 0
17
-
18
- git -C "$root" config core.bare false
19
- log_warn "Repaired core.bare, which worktree entry left set. Recovery is 'git config core.bare false'."
20
- }