@erclx/canon 5.9.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.
Files changed (48) hide show
  1. package/claude/.claude-plugin/plugin.json +1 -1
  2. package/claude/skills/api-design/REQUIREMENT.md +51 -0
  3. package/claude/skills/api-design/SKILL.md +102 -0
  4. package/claude/skills/api-design/references/adopted.md +40 -0
  5. package/claude/skills/codebase-layout/REQUIREMENT.md +1 -1
  6. package/claude/skills/codebase-layout/SKILL.md +2 -1
  7. package/claude/skills/review-ui/REQUIREMENT.md +3 -0
  8. package/claude/skills/review-ui/SKILL.md +15 -1
  9. package/claude/skills/review-ui/references/driving.md +3 -3
  10. package/claude/skills/review-ui/references/post.md +16 -3
  11. package/claude/skills/role-orchestrator/SKILL.md +2 -2
  12. package/claude/skills/systematic-debugging/REQUIREMENT.md +14 -0
  13. package/claude/skills/systematic-debugging/SKILL.md +45 -17
  14. package/claude/skills/systematic-debugging/references/adopted.md +44 -0
  15. package/docs/agents/capture.md +2 -2
  16. package/docs/agents/commands.md +1 -1
  17. package/docs/agents/index.md +2 -2
  18. package/docs/agents/pr-evidence.md +48 -1
  19. package/docs/agents/scripting.md +0 -3
  20. package/docs/agents/tasks-plans.md +8 -5
  21. package/docs/agents/worktrees.md +16 -1
  22. package/docs/workflow/skill-map.md +2 -1
  23. package/package.json +1 -1
  24. package/scripts/core/regen-agent-fixture.sh +3 -3
  25. package/scripts/core/regen-hero.ts +119 -0
  26. package/scripts/core/regen-web-favicon.ts +1 -1
  27. package/src/capture/frame-values.ts +359 -0
  28. package/src/claude/cases/misc.ts +5 -0
  29. package/src/claude/skills-audit.ts +2 -0
  30. package/src/commands/gate.ts +3 -2
  31. package/src/commands/pr.ts +78 -6
  32. package/src/commands/tasks.ts +12 -2
  33. package/src/commands/tooling.ts +2 -2
  34. package/src/commands/worktrees.ts +37 -0
  35. package/src/counts/catalogs.ts +3 -3
  36. package/src/design/tokens.ts +1 -1
  37. package/src/gate/sequencer.ts +0 -20
  38. package/src/gate/stages.ts +1 -2
  39. package/src/help.ts +0 -1
  40. package/src/pr/evidence.ts +35 -0
  41. package/src/tasks/archive.ts +27 -0
  42. package/src/tasks/record.ts +25 -2
  43. package/src/worktrees/bare-flag.ts +83 -0
  44. package/standards/tasks.md +2 -2
  45. package/scripts/core/regen-hero.sh +0 -419
  46. package/scripts/core/repair-bare-flag.sh +0 -19
  47. package/scripts/lib/worktree.sh +0 -20
  48. package/scripts/tooling/create.sh +0 -109
@@ -69,6 +69,11 @@ export const MISC_CASES: readonly SkillCase[] = [
69
69
  "The e2e folder has specs and helpers mixed together and I'm adding two more, where should they go?",
70
70
  expect: 'codebase-layout',
71
71
  },
72
+ {
73
+ prompt:
74
+ "I'm adding a field to the JSON this command prints and renaming one flag, anything to watch?",
75
+ expect: 'api-design',
76
+ },
72
77
  {
73
78
  prompt: 'Does our github about text still match what the readme says?',
74
79
  expect: 'repo-metadata',
@@ -35,6 +35,8 @@ export const PRACTICE_SKILLS: readonly string[] = [
35
35
  join(SHIPPED_CORPUS, 'codebase-layout'),
36
36
  join(SHIPPED_CORPUS, 'test-craft'),
37
37
  join(SHIPPED_CORPUS, 'review-craft'),
38
+ join(SHIPPED_CORPUS, 'systematic-debugging'),
39
+ join(SHIPPED_CORPUS, 'api-design'),
38
40
  ]
39
41
 
40
42
  /** The closing H2s a practice skill carries, matched exactly outside fences. */
@@ -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
  )
@@ -1368,7 +1390,7 @@ async function listPullFiles(
1368
1390
  '--paginate',
1369
1391
  `repos/{owner}/{repo}/pulls/${number}/files`,
1370
1392
  '--jq',
1371
- '.[] | "\(.status)\t\(.filename)"',
1393
+ '.[] | [.status, .filename] | @tsv',
1372
1394
  ])
1373
1395
  if (stdout === null) return undefined
1374
1396
  const files = new Map<string, boolean>()
@@ -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 =
@@ -114,6 +114,7 @@ interface PullRequestCommandOptions {
114
114
  }
115
115
 
116
116
  interface PlanLinkCommandOptions {
117
+ readonly add?: boolean
117
118
  readonly json?: boolean
118
119
  readonly root?: string
119
120
  }
@@ -454,6 +455,10 @@ export function register(program: Command): void {
454
455
  .argument('<task>', 'Task filename stem, as in v28.1-trigger-escalation')
455
456
  .argument('<plan>', 'Plan path or its slug, as in dispatch-answer-gate')
456
457
  .helpOption('-h, --help', 'Show this help message')
458
+ .option(
459
+ '--add',
460
+ 'Append the plan to the line, for a task sliced over plans',
461
+ )
457
462
  .option('--json', 'Emit a machine-readable record on stdout')
458
463
  .option('--root <path>', 'Board root, defaulting to the main worktree')
459
464
  .addHelpText(
@@ -461,7 +466,7 @@ export function register(program: Command): void {
461
466
  [
462
467
  '',
463
468
  'Exit codes:',
464
- ' 0 the line was added, corrected, or already correct',
469
+ ' 0 the line was added, appended to, corrected, or already correct',
465
470
  ' 1 refused, including several-plans for a line linking more than one',
466
471
  ' plan, with the reason on stderr or in the JSON record',
467
472
  '',
@@ -471,8 +476,13 @@ export function register(program: Command): void {
471
476
  'tasks plan-answers does. Safe from a linked worktree, since it',
472
477
  'resolves the board root in-process.',
473
478
  '',
479
+ 'With --add it keeps every link in order and appends the plan, so a',
480
+ 'sliced task links one plan per slice. It reports unchanged when the',
481
+ 'plan is already listed and is allowed on a line holding several.',
482
+ '',
474
483
  'Examples:',
475
484
  ' canon tasks plan-link v28.1-trigger-escalation dispatch-answer-gate',
485
+ ' canon tasks plan-link v28.1-trigger-escalation second-slice --add',
476
486
  ' canon tasks plan-link v28.1-trigger-escalation .canon/plans/feature-dispatch-answer-gate.md --json',
477
487
  '',
478
488
  ].join('\n'),
@@ -725,7 +735,7 @@ async function runPlanLink(
725
735
  ): Promise<number> {
726
736
  const emitJson = opts.json ?? false
727
737
  const root = opts.root ?? (await mainWorktreeRoot())
728
- const outcome = await recordPlan(root, task, plan)
738
+ const outcome = await recordPlan(root, task, plan, opts.add ?? false)
729
739
 
730
740
  return reportPlanLink(outcome, emitJson, root)
731
741
  }
@@ -34,7 +34,7 @@ import {
34
34
  select,
35
35
  } from '@/ui'
36
36
 
37
- const PASS_THROUGH_VERBS = ['create', 'verify'] as const
37
+ const PASS_THROUGH_VERBS = ['verify'] as const
38
38
 
39
39
  interface SyncOptions {
40
40
  readonly skip?: string
@@ -71,7 +71,7 @@ type Prepared =
71
71
  export function register(program: Command): void {
72
72
  const tooling = program
73
73
  .command('tooling')
74
- .description('Manage tooling stacks (sync, reference, create)')
74
+ .description('Manage tooling stacks (sync, reference)')
75
75
  .helpOption('-h, --help', 'Show this help message')
76
76
 
77
77
  tooling
@@ -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
  /**
@@ -25,14 +25,14 @@ const REGISTER_IMPORT = /^import \{ register as /gm
25
25
  /**
26
26
  * Every top-level `canon` command, read off the CLI entry point rather than a
27
27
  * catalog verb, because a command carries no `--json` listing of its own
28
- * siblings. `regen-hero.sh` reads its own `COMMAND_COUNT` through `gov counts`
28
+ * siblings. `regen-hero.ts` imports this reader for its `COMMAND_COUNT`
29
29
  * rather than a second regex on `src/cli.ts`, so this is the one place that
30
30
  * pattern is written.
31
31
  *
32
32
  * This is the one catalog with no meaning outside this repository, the way
33
- * `regen-hero.sh` documents itself as clone-only for the same reason.
33
+ * `regen-hero.ts` documents itself as clone-only for the same reason.
34
34
  */
35
- function countCommands(root: string): number | undefined {
35
+ export function countCommands(root: string): number | undefined {
36
36
  const cli = join(root, 'src', 'cli.ts')
37
37
  if (!existsSync(cli)) return undefined
38
38
  return [...readFileSync(cli, 'utf8').matchAll(REGISTER_IMPORT)].length
@@ -99,7 +99,7 @@ export const TOKENS: DesignTokens = {
99
99
  '',
100
100
  'The values below are the system rather than a reading of one. Until 2026-09-01 this record transcribed two surfaces and agreed with nothing else, which is what made a change to it reach nobody. The slide theme, the token preview, and a teach workspace stylesheet now read the module this file is rendered from, so a value changed there changes what all three render.',
101
101
  '',
102
- 'The two rendered captures read it as well. `scripts/core/regen-hero.sh` fills `assets/captures/hero.html.tmpl` and `assets/captures/install.html.tmpl` with what `canon design css --no-components` emits, so both frames now carry the custom properties rather than their own copies of the hex, and a value moved here moves what the next capture renders.',
102
+ 'The two rendered captures read it as well. `scripts/core/regen-hero.ts` fills `assets/captures/hero.html.tmpl` and `assets/captures/install.html.tmpl` with what `canon design css --no-components` emits, so both frames now carry the custom properties rather than their own copies of the hex, and a value moved here moves what the next capture renders.',
103
103
  '',
104
104
  'The terminal framing is the one surface left holding its own values, and that is a decision rather than a gap. `scripts/lib/ui.sh` and `src/ui.ts` each spell six escape constants, and `canon/context/scripts/framing.md` records one color source per language with a check behind each, so generating a third spelling from here would break the rule those two checks enforce. What the record is still incomplete about is the other half of those six: `WHITE` and `GREY` name no role below, so the terminal palette is described here in part rather than in whole.',
105
105
  ].join('\n'),
@@ -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
 
@@ -203,7 +202,7 @@ export const STAGES: readonly Stage[] = [
203
202
  checks: [
204
203
  {
205
204
  kind: 'command',
206
- argv: ['bash', 'scripts/core/regen-hero.sh', '--check'],
205
+ argv: ['bun', 'scripts/core/regen-hero.ts', '--check'],
207
206
  failure: 'Hero regen health check failed',
208
207
  },
209
208
  { kind: 'measure', measure: captureStamps },
package/src/help.ts CHANGED
@@ -95,7 +95,6 @@ const EXAMPLES = [
95
95
  'canon standards markdown',
96
96
  'canon init ../my-app',
97
97
  'canon tooling sync base',
98
- 'canon tooling create',
99
98
  'canon claude init',
100
99
  'canon indexes regen',
101
100
  'canon indexes regen --dry-run --json',
@@ -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
@@ -417,6 +417,33 @@ export function retargetPlanLine(
417
417
  return lines.join('\n')
418
418
  }
419
419
 
420
+ /**
421
+ * Appends a link to a located `Plan:` line right after its last target, so
422
+ * trailing prose and a line ending stay where they were. A bare target is
423
+ * rewritten as a link on the way, since the reader takes bare paths only when
424
+ * no link sits on the line and the appended one would otherwise hide them.
425
+ */
426
+ export function appendPlanLink(line: string, target: string): string {
427
+ const tokens = planTokens(line)
428
+ if (tokens.length === 0) {
429
+ const trailing = /\s*$/.exec(line)?.[0] ?? ''
430
+ return `${line.slice(0, line.length - trailing.length)} ${planLink(target)}${trailing}`
431
+ }
432
+
433
+ let rewritten = ''
434
+ let at = 0
435
+ for (const token of tokens) {
436
+ const isLink = line[token.start] === '['
437
+ rewritten += line.slice(at, token.start)
438
+ rewritten += isLink
439
+ ? line.slice(token.start, token.end)
440
+ : planLink(token.target)
441
+ at = token.end
442
+ }
443
+
444
+ return `${rewritten}, ${planLink(target)}${line.slice(at)}`
445
+ }
446
+
420
447
  /** The `Ready:` line in either form, the link's target captured ahead of the bare path. */
421
448
  const READY_PATTERN = /^Ready:[ \t]*(?:\[[^\]]*\]\(([^)]+)\)|(\S+))[ \t]*$/m
422
449
 
@@ -3,6 +3,7 @@ import { readFile, writeFile } from 'node:fs/promises'
3
3
  import { basename, join, relative } from 'node:path'
4
4
  import { planCandidates } from '@/tasks/answers'
5
5
  import {
6
+ appendPlanLink,
6
7
  describeUnmatchedStem,
7
8
  fenceMask,
8
9
  linkTo,
@@ -240,12 +241,33 @@ export type PlanLineWrite =
240
241
  * A line already linking several plans is refused whole, even when one of them
241
242
  * is the target, since overwriting it drops links the caller never named and
242
243
  * reporting it unchanged hides a line that breaks the one-plan rule.
244
+ *
245
+ * `add` is the sliced task's writer, which links one plan per slice. It keeps
246
+ * every link in order and appends the target, reports `unchanged` when the
247
+ * target is already listed, and so never refuses a line holding several.
243
248
  */
244
- export function writePlanLine(text: string, target: string): PlanLineWrite {
249
+ export function writePlanLine(
250
+ text: string,
251
+ target: string,
252
+ add = false,
253
+ ): PlanLineWrite {
245
254
  const line = planLine(target)
246
255
  const lines = text.split('\n')
247
256
  const existing = planLineIndex(lines)
248
257
 
258
+ if (existing !== -1 && add) {
259
+ if (readPlanTargets(lines[existing]).includes(target)) {
260
+ return { ok: true, text, action: 'unchanged', replaced: undefined }
261
+ }
262
+ lines[existing] = appendPlanLink(lines[existing], target)
263
+ return {
264
+ ok: true,
265
+ text: lines.join('\n'),
266
+ action: 'appended',
267
+ replaced: undefined,
268
+ }
269
+ }
270
+
249
271
  if (existing !== -1) {
250
272
  const targets = readPlanTargets(lines[existing])
251
273
  if (targets.length > 1) return { ok: false, targets }
@@ -449,6 +471,7 @@ export async function recordPlan(
449
471
  root: string,
450
472
  stem: string,
451
473
  reference: string,
474
+ add = false,
452
475
  ): Promise<PlanOutcome> {
453
476
  const opened = await openTask(root, { kind: 'stem', stem })
454
477
  if ('ok' in opened) return opened
@@ -464,7 +487,7 @@ export async function recordPlan(
464
487
  }
465
488
 
466
489
  const target = linkTo(dir, plan)
467
- const written = writePlanLine(await readFile(path, 'utf8'), target)
490
+ const written = writePlanLine(await readFile(path, 'utf8'), target, add)
468
491
 
469
492
  if (!written.ok) {
470
493
  return refuse(
@@ -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,9 +124,9 @@ 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. 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 on a sliced task. A plan cited by two tasks is a misfile rather than a shape to design for.
128
128
 
129
- `canon tasks plan-link <task> <plan>` writes or corrects the `Plan:` line, so the line is a mechanical write rather than hand-edited markdown.
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
 
131
131
  `Groundwork:` points at `../groundwork/<slug>/`, the folder `plan-groundwork` fills. It names the surface it points at the way `Plan:` does. Use this key alone. `Research record` and `Decision record` are earlier spellings of the same thing and both convert to it.
132
132