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