@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.
- package/claude/.claude-plugin/plugin.json +1 -1
- package/claude/skills/api-design/REQUIREMENT.md +51 -0
- package/claude/skills/api-design/SKILL.md +102 -0
- package/claude/skills/api-design/references/adopted.md +40 -0
- package/claude/skills/codebase-layout/REQUIREMENT.md +1 -1
- package/claude/skills/codebase-layout/SKILL.md +2 -1
- package/claude/skills/review-ui/REQUIREMENT.md +3 -0
- package/claude/skills/review-ui/SKILL.md +15 -1
- package/claude/skills/review-ui/references/driving.md +3 -3
- package/claude/skills/review-ui/references/post.md +16 -3
- package/claude/skills/role-orchestrator/SKILL.md +2 -2
- package/claude/skills/systematic-debugging/REQUIREMENT.md +14 -0
- package/claude/skills/systematic-debugging/SKILL.md +45 -17
- package/claude/skills/systematic-debugging/references/adopted.md +44 -0
- package/docs/agents/capture.md +2 -2
- package/docs/agents/commands.md +1 -1
- package/docs/agents/index.md +2 -2
- package/docs/agents/pr-evidence.md +48 -1
- package/docs/agents/scripting.md +0 -3
- package/docs/agents/tasks-plans.md +8 -5
- package/docs/agents/worktrees.md +16 -1
- package/docs/workflow/skill-map.md +2 -1
- package/package.json +1 -1
- package/scripts/core/regen-agent-fixture.sh +3 -3
- package/scripts/core/regen-hero.ts +119 -0
- package/scripts/core/regen-web-favicon.ts +1 -1
- package/src/capture/frame-values.ts +359 -0
- package/src/claude/cases/misc.ts +5 -0
- package/src/claude/skills-audit.ts +2 -0
- package/src/commands/gate.ts +3 -2
- package/src/commands/pr.ts +78 -6
- package/src/commands/tasks.ts +12 -2
- package/src/commands/tooling.ts +2 -2
- package/src/commands/worktrees.ts +37 -0
- package/src/counts/catalogs.ts +3 -3
- package/src/design/tokens.ts +1 -1
- package/src/gate/sequencer.ts +0 -20
- package/src/gate/stages.ts +1 -2
- package/src/help.ts +0 -1
- package/src/pr/evidence.ts +35 -0
- package/src/tasks/archive.ts +27 -0
- package/src/tasks/record.ts +25 -2
- package/src/worktrees/bare-flag.ts +83 -0
- package/standards/tasks.md +2 -2
- package/scripts/core/regen-hero.sh +0 -419
- package/scripts/core/repair-bare-flag.sh +0 -19
- package/scripts/lib/worktree.sh +0 -20
- package/scripts/tooling/create.sh +0 -109
package/src/claude/cases/misc.ts
CHANGED
|
@@ -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. */
|
package/src/commands/gate.ts
CHANGED
|
@@ -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: [] }
|
package/src/commands/pr.ts
CHANGED
|
@@ -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
|
|
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
|
-
'.[] |
|
|
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
|
-
|
|
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 =
|
package/src/commands/tasks.ts
CHANGED
|
@@ -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
|
}
|
package/src/commands/tooling.ts
CHANGED
|
@@ -34,7 +34,7 @@ import {
|
|
|
34
34
|
select,
|
|
35
35
|
} from '@/ui'
|
|
36
36
|
|
|
37
|
-
const PASS_THROUGH_VERBS = ['
|
|
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
|
|
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
|
/**
|
package/src/counts/catalogs.ts
CHANGED
|
@@ -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.
|
|
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.
|
|
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
|
package/src/design/tokens.ts
CHANGED
|
@@ -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.
|
|
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'),
|
package/src/gate/sequencer.ts
CHANGED
|
@@ -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
|
-
}
|
package/src/gate/stages.ts
CHANGED
|
@@ -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: ['
|
|
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
package/src/pr/evidence.ts
CHANGED
|
@@ -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
|
package/src/tasks/archive.ts
CHANGED
|
@@ -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
|
|
package/src/tasks/record.ts
CHANGED
|
@@ -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(
|
|
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
|
+
}
|
package/standards/tasks.md
CHANGED
|
@@ -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
|
|
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
|
|