@erclx/canon 5.48.0 → 5.50.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/SKILL.md +1 -1
- package/claude/skills/canvas-board/REQUIREMENT.md +4 -3
- package/claude/skills/canvas-board/SKILL.md +8 -9
- package/claude/skills/context-fold/references/context.md +4 -3
- package/claude/skills/design-record/REQUIREMENT.md +33 -0
- package/claude/skills/design-record/SKILL.md +84 -0
- package/claude/skills/design-record/references/folder.md +69 -0
- package/claude/skills/design-taste/SKILL.md +1 -1
- package/claude/skills/markdown-craft/REQUIREMENT.md +1 -0
- package/claude/skills/markdown-craft/SKILL.md +6 -1
- package/claude/skills/markdown-craft/references/markdown.md +3 -3
- package/claude/skills/markdown-craft/references/tables.md +30 -0
- package/claude/skills/pick-by-eye/SKILL.md +10 -8
- package/claude/skills/plan-feature/SKILL.md +31 -16
- package/claude/skills/plan-feature/references/batches.md +5 -5
- package/claude/skills/plan-feature/references/plan.md +82 -33
- package/claude/skills/plan-groundwork/SKILL.md +8 -0
- package/claude/skills/plan-groundwork/references/groundwork.md +2 -1
- package/claude/skills/plan-intake/SKILL.md +8 -0
- package/claude/skills/plan-intake/references/intake.md +9 -1
- package/claude/skills/plan-ready/REQUIREMENT.md +3 -3
- package/claude/skills/plan-ready/SKILL.md +3 -3
- package/claude/skills/plan-ready/references/assembly.md +7 -7
- package/claude/skills/plan-ready/references/ready.md +8 -6
- package/claude/skills/review-ui/REQUIREMENT.md +1 -1
- package/claude/skills/review-ui/SKILL.md +4 -11
- package/claude/skills/review-ui/references/close-out.md +1 -1
- package/claude/skills/review-ui/references/drive-copy.md +38 -0
- package/claude/skills/review-ui/references/post.md +24 -3
- package/claude/skills/review-ui/references/verb-absent.md +18 -0
- package/claude/skills/role-orchestrator/SKILL.md +3 -2
- package/claude/skills/role-orchestrator/references/orchestrator-dispatch.md +1 -1
- package/claude/skills/role-orchestrator/references/orchestrator-hand-launch.md +60 -0
- package/claude/skills/role-orchestrator/references/orchestrator-launch.md +5 -0
- package/claude/skills/role-orchestrator/references/orchestrator-parked.md +1 -1
- package/claude/skills/role-orchestrator/references/orchestrator-reviewer-launch.md +1 -1
- package/claude/skills/role-planner/SKILL.md +1 -1
- package/claude/skills/role-worker/SKILL.md +4 -2
- package/claude/skills/session-compact/references/handoff-note.md +3 -1
- package/claude/skills/session-map/references/session.md +1 -1
- package/claude/skills/session-worktree/SKILL.md +1 -1
- package/claude/skills/task-board/references/board.md +2 -2
- package/claude/skills/task-board/references/tasks.md +1 -1
- package/docs/agents/index.md +1 -1
- package/docs/agents/markdown-audit.md +5 -2
- package/docs/agents/records.md +1 -1
- package/docs/target/migrations.md +7 -0
- package/docs/target/projects.md +1 -1
- package/docs/workflow/parallel-features.md +2 -2
- package/docs/workflow/skill-map.md +2 -1
- package/docs/workflow/visual-design-workflow.md +2 -1
- package/governance/ledger/ui/design-reference.md +1 -0
- package/governance/rules/ui/design-reference.md +1 -0
- package/package.json +1 -1
- package/src/claude/cases/misc.ts +5 -0
- package/src/commands/claude.ts +26 -1
- package/src/commands/gate.ts +57 -16
- package/src/commands/markdown.ts +98 -19
- package/src/commands/pr.ts +411 -23
- package/src/commands/records.ts +45 -8
- package/src/commands/tasks.ts +12 -0
- package/src/context/audit.ts +6 -5
- package/src/design/budget.ts +1 -1
- package/src/design/components.ts +1 -1
- package/src/design/document.ts +1 -1
- package/src/design/regen.ts +1 -1
- package/src/design/render.ts +1 -1
- package/src/gate/hand-suite.ts +52 -0
- package/src/gate/measures.ts +9 -2
- package/src/gate/sequencer.ts +17 -8
- package/src/hooks/post-merge.ts +11 -0
- package/src/markdown/gated.ts +5 -7
- package/src/markdown/scope.ts +60 -10
- package/src/markdown/structure.ts +1 -1
- package/src/markdown/tables.ts +150 -0
- package/src/pr/drive-copy.ts +218 -0
- package/src/pr/local.ts +26 -0
- package/src/pr/rest.ts +2 -0
- package/src/records/structure.ts +147 -0
- package/src/records/validate.ts +113 -12
- package/src/tasks/archive.ts +137 -4
- package/src/tasks/board.ts +68 -13
- package/src/tasks/validate.ts +54 -14
- package/tooling/base/configs/.github/workflows/pr-local-preview.yml +1 -1
- package/tooling/claude/reference.md +1 -0
- package/tooling/claude/seeds/.claude/hooks/standards-audit.sh +11 -1
- package/tooling/claude/seeds/CLAUDE.md +9 -0
- /package/claude/skills/{canvas-board → design-record}/references/design.md +0 -0
package/src/commands/pr.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { existsSync, statSync } from 'node:fs'
|
|
2
|
-
import { readFile, realpath } from 'node:fs/promises'
|
|
2
|
+
import { mkdir, readFile, realpath, writeFile } from 'node:fs/promises'
|
|
3
3
|
import { basename, join, resolve } from 'node:path'
|
|
4
4
|
import { $ } from 'bun'
|
|
5
5
|
import type { Command } from 'commander'
|
|
@@ -81,10 +81,20 @@ import {
|
|
|
81
81
|
type MergeRefusal,
|
|
82
82
|
type MergeRequest,
|
|
83
83
|
} from '@/pr/merge'
|
|
84
|
+
import { listWorktrees } from '@/git/worktree'
|
|
85
|
+
import {
|
|
86
|
+
branchSlug,
|
|
87
|
+
buildDriveCopy,
|
|
88
|
+
changedSince,
|
|
89
|
+
loadPreviewConfig,
|
|
90
|
+
readWorktreeStatus,
|
|
91
|
+
type StatusEntry,
|
|
92
|
+
} from '@/pr/drive-copy'
|
|
84
93
|
import { machineListenerReader } from '@/listeners/machine'
|
|
85
94
|
import { loadPaintedPatterns } from '@/pr/painted'
|
|
86
95
|
import {
|
|
87
96
|
dropAddressLine,
|
|
97
|
+
maskLocalAddress,
|
|
88
98
|
findLocalServer,
|
|
89
99
|
type LocalRefusal,
|
|
90
100
|
type LocalRunner,
|
|
@@ -177,6 +187,29 @@ interface TickOptions extends ReadOptions {
|
|
|
177
187
|
readonly head?: string
|
|
178
188
|
}
|
|
179
189
|
|
|
190
|
+
interface DriveCopyOptions extends ReadOptions {
|
|
191
|
+
readonly baseline?: boolean
|
|
192
|
+
readonly check?: boolean
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
type DriveCopyRefusal =
|
|
196
|
+
| 'no-worktree'
|
|
197
|
+
| 'no-branch'
|
|
198
|
+
| 'no-baseline'
|
|
199
|
+
| 'unreadable-tree'
|
|
200
|
+
| 'bad-flags'
|
|
201
|
+
|
|
202
|
+
const DRIVE_COPY_REFUSALS: Record<DriveCopyRefusal, string> = {
|
|
203
|
+
'no-worktree':
|
|
204
|
+
'No local worktree holds the pull request branch, so there is no tree to copy from or check.',
|
|
205
|
+
'no-branch':
|
|
206
|
+
'The checkout holds no branch, so no copy root could be named. Pass the pull request number.',
|
|
207
|
+
'no-baseline':
|
|
208
|
+
'No baseline was recorded for this pull request. Run --baseline before --check.',
|
|
209
|
+
'unreadable-tree': 'git could not read the worktree status.',
|
|
210
|
+
'bad-flags': '--baseline and --check cannot be combined.',
|
|
211
|
+
}
|
|
212
|
+
|
|
180
213
|
type TickReason =
|
|
181
214
|
| TickRefusal
|
|
182
215
|
| 'count-mismatch'
|
|
@@ -1037,7 +1070,10 @@ export function register(program: Command): void {
|
|
|
1037
1070
|
.helpOption('-h, --help', 'Show this help message')
|
|
1038
1071
|
.option('--root <path>', 'Worktree to read, defaulting to the cwd')
|
|
1039
1072
|
.option('--json', 'Add a machine-readable record on stdout')
|
|
1040
|
-
.option(
|
|
1073
|
+
.option(
|
|
1074
|
+
'--remove',
|
|
1075
|
+
"Drop the evidence comment's preview address line and mask local addresses in UI reviews",
|
|
1076
|
+
)
|
|
1041
1077
|
.addHelpText(
|
|
1042
1078
|
'after',
|
|
1043
1079
|
[
|
|
@@ -1060,6 +1096,11 @@ export function register(program: Command): void {
|
|
|
1060
1096
|
'and drops the hosted link too because the same close deletes the',
|
|
1061
1097
|
"branch's preview deployments.",
|
|
1062
1098
|
'',
|
|
1099
|
+
'It also rewrites the loopback address on the Driven at line of every',
|
|
1100
|
+
'review carrying the review-ui marker to "a local preview", keeping',
|
|
1101
|
+
'the head and the renderer. A hosted address and a code review are',
|
|
1102
|
+
'left alone, and `reviews` on the JSON record counts the rewrites.',
|
|
1103
|
+
'',
|
|
1063
1104
|
'Read `reason` on the JSON record:',
|
|
1064
1105
|
' ok a page answered, with its address in `url`',
|
|
1065
1106
|
' no-server nothing inside this worktree served a page',
|
|
@@ -1070,7 +1111,8 @@ export function register(program: Command): void {
|
|
|
1070
1111
|
'',
|
|
1071
1112
|
'Refusals, read off `reason`:',
|
|
1072
1113
|
' gh-missing `--remove` needs `gh` and it is not on the path',
|
|
1073
|
-
' gh-failed `--remove` could not read or edit the
|
|
1114
|
+
' gh-failed `--remove` could not read the thread, or edit the',
|
|
1115
|
+
' comment or a review, after trying every edit',
|
|
1074
1116
|
'',
|
|
1075
1117
|
'Exit codes:',
|
|
1076
1118
|
' 0 a server was found, or --remove finished, including a no-op',
|
|
@@ -1153,6 +1195,83 @@ export function register(program: Command): void {
|
|
|
1153
1195
|
process.exitCode = await runTick(number, opts)
|
|
1154
1196
|
})
|
|
1155
1197
|
|
|
1198
|
+
pr.command('drive-copy')
|
|
1199
|
+
.description(
|
|
1200
|
+
"Copy the folders a running preview saves into, so a UI pass drives a throwaway copy, and check the worker's worktree gained no change",
|
|
1201
|
+
)
|
|
1202
|
+
.argument(
|
|
1203
|
+
'[number]',
|
|
1204
|
+
'Pull request whose worktree to read, defaulting to the checkout at --root',
|
|
1205
|
+
)
|
|
1206
|
+
.helpOption('-h, --help', 'Show this help message')
|
|
1207
|
+
.option('--root <path>', 'Repository to read, defaulting to the cwd')
|
|
1208
|
+
.option('--json', 'Add a machine-readable record on stdout')
|
|
1209
|
+
.option(
|
|
1210
|
+
'--baseline',
|
|
1211
|
+
"Rebuild the copy, then record the worktree's git status",
|
|
1212
|
+
)
|
|
1213
|
+
.option(
|
|
1214
|
+
'--check',
|
|
1215
|
+
'Report each path changed since the baseline, copying nothing',
|
|
1216
|
+
)
|
|
1217
|
+
.addHelpText(
|
|
1218
|
+
'after',
|
|
1219
|
+
[
|
|
1220
|
+
'',
|
|
1221
|
+
'A project names what its preview writes in',
|
|
1222
|
+
'`canon/config/preview.json` as `{ "writes": [path], "env": "<NAME>" }`.',
|
|
1223
|
+
'Each `writes` path is relative to the worktree, and `env`, defaulting',
|
|
1224
|
+
'to `CANON_PREVIEW_ROOT`, is the variable the server reads for the copy',
|
|
1225
|
+
'root. The server resolves each declared path under that root.',
|
|
1226
|
+
'',
|
|
1227
|
+
'The copy lives at `.canon/tmp/pr/review-ui/<slug>/board/` at the main',
|
|
1228
|
+
'worktree root, where `<slug>` is the branch with a leading type',
|
|
1229
|
+
'segment dropped and each `/` made `-`. With a number the branch is the',
|
|
1230
|
+
"pull request's head, and without one it is the checkout's, so the",
|
|
1231
|
+
'worker starting its server before a pull request exists and the',
|
|
1232
|
+
'reviewer naming the number land on one copy. Every run but --check',
|
|
1233
|
+
"rebuilds it from the worktree, dropping the last pass's drives.",
|
|
1234
|
+
'',
|
|
1235
|
+
'--baseline records `git status` for the worktree under',
|
|
1236
|
+
'`.canon/tmp/pr/review-ui/<number>/`, and --check reports each path',
|
|
1237
|
+
'whose status or content changed since, an untracked file included.',
|
|
1238
|
+
'Both run with no config, since a project that saves drives and never',
|
|
1239
|
+
'declared it is the case the check exists for. An edit the worker makes',
|
|
1240
|
+
'during the pass reads as changed too.',
|
|
1241
|
+
'',
|
|
1242
|
+
'Read `reason` on the JSON record:',
|
|
1243
|
+
' copied the copy was rebuilt, with `copyRoot`, `env`, `copied`, and',
|
|
1244
|
+
' `missing` for a declared path the worktree does not hold',
|
|
1245
|
+
' no-config the project declares no preview, so nothing was copied',
|
|
1246
|
+
' clean --check found no change since the baseline',
|
|
1247
|
+
' changed --check found changes, listed in `paths`, exit 1',
|
|
1248
|
+
'',
|
|
1249
|
+
'Refusals, read off `reason`:',
|
|
1250
|
+
' no-worktree no local worktree holds the branch, as for a cloud',
|
|
1251
|
+
" worker's, so there is no tree to check",
|
|
1252
|
+
' no-branch the checkout holds no branch',
|
|
1253
|
+
' no-baseline --check ran before any --baseline for this number',
|
|
1254
|
+
' bad-config preview.json does not parse or lacks a writes list',
|
|
1255
|
+
' unsafe-path a writes path is absolute or climbs with `..`',
|
|
1256
|
+
' unreadable-tree git could not read the worktree status',
|
|
1257
|
+
' bad-flags --baseline and --check were combined',
|
|
1258
|
+
' gh-failed, ambiguous-pull, gh-missing',
|
|
1259
|
+
'',
|
|
1260
|
+
'Exit codes:',
|
|
1261
|
+
' 0 copied, no-config, or clean',
|
|
1262
|
+
' 1 changed, or refused, with the reason in the JSON record',
|
|
1263
|
+
'',
|
|
1264
|
+
'Examples:',
|
|
1265
|
+
' canon pr drive-copy --json',
|
|
1266
|
+
' canon pr drive-copy 1341 --baseline --json',
|
|
1267
|
+
' canon pr drive-copy 1341 --check --json',
|
|
1268
|
+
'',
|
|
1269
|
+
].join('\n'),
|
|
1270
|
+
)
|
|
1271
|
+
.action(async (number: string | undefined, opts: DriveCopyOptions) => {
|
|
1272
|
+
process.exitCode = await runDriveCopy(number, opts)
|
|
1273
|
+
})
|
|
1274
|
+
|
|
1156
1275
|
pr.command('frames')
|
|
1157
1276
|
.description(
|
|
1158
1277
|
`Push a UI review frame to the never-merged ${FRAMES_BRANCH} branch and return its embed link, or drop frames from it`,
|
|
@@ -1733,7 +1852,7 @@ async function listReviews(cwd: string, number: string) {
|
|
|
1733
1852
|
return listPages<RestReview>(
|
|
1734
1853
|
cwd,
|
|
1735
1854
|
`repos/{owner}/{repo}/pulls/${number}/reviews`,
|
|
1736
|
-
'body, commit_id, submitted_at',
|
|
1855
|
+
'id, body, commit_id, submitted_at',
|
|
1737
1856
|
)
|
|
1738
1857
|
}
|
|
1739
1858
|
|
|
@@ -2922,9 +3041,9 @@ async function runLocal(opts: LocalOptions): Promise<number> {
|
|
|
2922
3041
|
}
|
|
2923
3042
|
|
|
2924
3043
|
/**
|
|
2925
|
-
* Drops the address line on the marked comment
|
|
2926
|
-
*
|
|
2927
|
-
* session to post one.
|
|
3044
|
+
* Drops the address line on the marked comment and masks the loopback address
|
|
3045
|
+
* on every UI review. The verb writes here rather than handing a body back,
|
|
3046
|
+
* because its caller is a close workflow with no session to post one.
|
|
2928
3047
|
*/
|
|
2929
3048
|
async function runLocalRemove(
|
|
2930
3049
|
number: string | undefined,
|
|
@@ -2951,32 +3070,79 @@ async function runLocalRemove(
|
|
|
2951
3070
|
root,
|
|
2952
3071
|
)
|
|
2953
3072
|
}
|
|
2954
|
-
const comments = await
|
|
2955
|
-
|
|
3073
|
+
const [comments, reviews] = await Promise.all([
|
|
3074
|
+
listComments(root, resolved.number),
|
|
3075
|
+
listReviews(root, resolved.number),
|
|
3076
|
+
])
|
|
3077
|
+
if (comments === undefined && reviews.kind === 'refused') {
|
|
2956
3078
|
return refuseWith('gh-failed', LOCAL_REFUSALS['gh-failed'], emitJson, root)
|
|
2957
3079
|
}
|
|
2958
3080
|
|
|
2959
|
-
|
|
2960
|
-
|
|
2961
|
-
|
|
2962
|
-
|
|
2963
|
-
|
|
2964
|
-
|
|
2965
|
-
|
|
2966
|
-
|
|
3081
|
+
// A refused read on one side still lets the other side's edits land, and
|
|
3082
|
+
// counts as a failure beside them, so one unread listing never strands both.
|
|
3083
|
+
const [evidence, masked] = await Promise.all([
|
|
3084
|
+
comments === undefined
|
|
3085
|
+
? Promise.resolve<EvidenceRemoval>({ kind: 'failed' })
|
|
3086
|
+
: removeEvidenceAddress(root, comments),
|
|
3087
|
+
reviews.kind === 'refused'
|
|
3088
|
+
? Promise.resolve({ rewritten: 0, failed: true })
|
|
3089
|
+
: maskUiReviews(root, resolved.number, reviews.rows),
|
|
3090
|
+
])
|
|
3091
|
+
if (evidence.kind === 'failed' || masked.failed) {
|
|
3092
|
+
return refuseWith(
|
|
3093
|
+
'gh-failed',
|
|
3094
|
+
LOCAL_REFUSALS['gh-failed'],
|
|
3095
|
+
emitJson,
|
|
3096
|
+
root,
|
|
3097
|
+
{
|
|
3098
|
+
reviews: masked.rewritten,
|
|
3099
|
+
},
|
|
3100
|
+
)
|
|
3101
|
+
}
|
|
3102
|
+
|
|
3103
|
+
logStep(evidence.reason === 'removed' ? 'Removed' : 'Skipped')
|
|
3104
|
+
logInfo(evidence.message)
|
|
3105
|
+
logInfo(`${masked.rewritten} UI review addresses masked`)
|
|
3106
|
+
outro()
|
|
3107
|
+
if (emitJson) {
|
|
3108
|
+
process.stdout.write(
|
|
3109
|
+
`${JSON.stringify({ root, reason: evidence.reason, reviews: masked.rewritten })}\n`,
|
|
3110
|
+
)
|
|
2967
3111
|
}
|
|
3112
|
+
return 0
|
|
3113
|
+
}
|
|
2968
3114
|
|
|
3115
|
+
type EvidenceRemoval =
|
|
3116
|
+
| {
|
|
3117
|
+
readonly kind: 'done'
|
|
3118
|
+
readonly reason: 'removed' | 'no-comment' | 'no-line'
|
|
3119
|
+
readonly message: string
|
|
3120
|
+
}
|
|
3121
|
+
| { readonly kind: 'failed' }
|
|
3122
|
+
|
|
3123
|
+
async function removeEvidenceAddress(
|
|
3124
|
+
root: string,
|
|
3125
|
+
comments: readonly { url?: string; body: string }[],
|
|
3126
|
+
): Promise<EvidenceRemoval> {
|
|
2969
3127
|
const commentId = findEvidenceCommentId(comments)
|
|
2970
3128
|
const marked = comments.find((comment) =>
|
|
2971
3129
|
comment.url?.endsWith(`#issuecomment-${commentId}`),
|
|
2972
3130
|
)
|
|
2973
3131
|
if (commentId === undefined || marked === undefined) {
|
|
2974
|
-
return
|
|
3132
|
+
return {
|
|
3133
|
+
kind: 'done',
|
|
3134
|
+
reason: 'no-comment',
|
|
3135
|
+
message: 'No comment carries the evidence marker.',
|
|
3136
|
+
}
|
|
2975
3137
|
}
|
|
2976
3138
|
|
|
2977
3139
|
const dropped = dropAddressLine(marked.body)
|
|
2978
3140
|
if (dropped === undefined) {
|
|
2979
|
-
return
|
|
3141
|
+
return {
|
|
3142
|
+
kind: 'done',
|
|
3143
|
+
reason: 'no-line',
|
|
3144
|
+
message: 'The evidence comment carries no address line.',
|
|
3145
|
+
}
|
|
2980
3146
|
}
|
|
2981
3147
|
|
|
2982
3148
|
const patched = await gh(root, [
|
|
@@ -2987,11 +3153,43 @@ async function runLocalRemove(
|
|
|
2987
3153
|
'-f',
|
|
2988
3154
|
`body=${dropped}`,
|
|
2989
3155
|
])
|
|
2990
|
-
|
|
2991
|
-
|
|
2992
|
-
|
|
3156
|
+
return patched === null
|
|
3157
|
+
? { kind: 'failed' }
|
|
3158
|
+
: { kind: 'done', reason: 'removed', message: `comment ${commentId}` }
|
|
3159
|
+
}
|
|
2993
3160
|
|
|
2994
|
-
|
|
3161
|
+
const UI_REVIEW_MARKER = '<!-- review-ui: head='
|
|
3162
|
+
|
|
3163
|
+
/**
|
|
3164
|
+
* Masks the loopback address on each review `review-ui` posted, selected by
|
|
3165
|
+
* its marker so a code review quoting a local URL is never edited. Every
|
|
3166
|
+
* update is tried before a refusal is reported, so one refused review leaves
|
|
3167
|
+
* the rest masked.
|
|
3168
|
+
*/
|
|
3169
|
+
async function maskUiReviews(
|
|
3170
|
+
root: string,
|
|
3171
|
+
number: string,
|
|
3172
|
+
reviews: readonly RestReview[],
|
|
3173
|
+
): Promise<{ readonly rewritten: number; readonly failed: boolean }> {
|
|
3174
|
+
const updates = reviews.flatMap((review) => {
|
|
3175
|
+
const body = review.body ?? ''
|
|
3176
|
+
if (review.id === undefined || !body.includes(UI_REVIEW_MARKER)) return []
|
|
3177
|
+
const masked = maskLocalAddress(body)
|
|
3178
|
+
if (masked === undefined) return []
|
|
3179
|
+
return [
|
|
3180
|
+
gh(root, [
|
|
3181
|
+
'api',
|
|
3182
|
+
'-X',
|
|
3183
|
+
'PUT',
|
|
3184
|
+
`repos/{owner}/{repo}/pulls/${number}/reviews/${review.id}`,
|
|
3185
|
+
'-f',
|
|
3186
|
+
`body=${masked}`,
|
|
3187
|
+
]),
|
|
3188
|
+
]
|
|
3189
|
+
})
|
|
3190
|
+
const results = await Promise.all(updates)
|
|
3191
|
+
const rewritten = results.filter((stdout) => stdout !== null).length
|
|
3192
|
+
return { rewritten, failed: rewritten < results.length }
|
|
2995
3193
|
}
|
|
2996
3194
|
|
|
2997
3195
|
const POSITIVE_WHOLE = /^[1-9]\d*$/
|
|
@@ -3997,6 +4195,196 @@ function refuse(reason: Refusal, emitJson: boolean, root: string): number {
|
|
|
3997
4195
|
return refuseWith(reason, REFUSALS[reason], emitJson, root)
|
|
3998
4196
|
}
|
|
3999
4197
|
|
|
4198
|
+
/** The scratch folder `review-ui` writes under, at the main worktree root. */
|
|
4199
|
+
const REVIEW_UI_SCRATCH = join('.canon', 'tmp', 'pr', 'review-ui')
|
|
4200
|
+
|
|
4201
|
+
type DriveTarget =
|
|
4202
|
+
| {
|
|
4203
|
+
readonly kind: 'found'
|
|
4204
|
+
readonly main: string
|
|
4205
|
+
readonly worktree: string
|
|
4206
|
+
readonly branch: string
|
|
4207
|
+
readonly number: string | undefined
|
|
4208
|
+
}
|
|
4209
|
+
| {
|
|
4210
|
+
readonly kind: 'refused'
|
|
4211
|
+
readonly reason: DriveCopyRefusal | PullRefusal
|
|
4212
|
+
}
|
|
4213
|
+
|
|
4214
|
+
/**
|
|
4215
|
+
* The worker's worktree and its branch. A named pull request resolves through
|
|
4216
|
+
* its head ref, since a reviewer runs at the main root on the trunk. With no
|
|
4217
|
+
* number the checkout at `root` answers, and a number is looked up only when
|
|
4218
|
+
* the mode needs one, so a worker can build the copy before its pull request
|
|
4219
|
+
* exists.
|
|
4220
|
+
*/
|
|
4221
|
+
async function resolveDriveTarget(
|
|
4222
|
+
root: string,
|
|
4223
|
+
number: string | undefined,
|
|
4224
|
+
needsNumber: boolean,
|
|
4225
|
+
): Promise<DriveTarget> {
|
|
4226
|
+
const worktrees = await listWorktrees(root)
|
|
4227
|
+
const main = worktrees[0]?.path ?? root
|
|
4228
|
+
|
|
4229
|
+
if (number === undefined) {
|
|
4230
|
+
const toplevel = await resolveWorktreeRoot(root)
|
|
4231
|
+
const realPaths = await Promise.all(
|
|
4232
|
+
worktrees.map((candidate) =>
|
|
4233
|
+
realpath(candidate.path).catch(() => candidate.path),
|
|
4234
|
+
),
|
|
4235
|
+
)
|
|
4236
|
+
const entry = worktrees[realPaths.indexOf(toplevel)]
|
|
4237
|
+
const branch = entry?.branch ?? null
|
|
4238
|
+
if (branch === null) return { kind: 'refused', reason: 'no-branch' }
|
|
4239
|
+
if (!needsNumber) {
|
|
4240
|
+
return { kind: 'found', main, worktree: toplevel, branch, number }
|
|
4241
|
+
}
|
|
4242
|
+
if (Bun.which('gh') === null) {
|
|
4243
|
+
return { kind: 'refused', reason: 'gh-missing' }
|
|
4244
|
+
}
|
|
4245
|
+
const resolved = await resolvePullNumber(root, undefined)
|
|
4246
|
+
if (resolved.kind === 'refused') return resolved
|
|
4247
|
+
return {
|
|
4248
|
+
kind: 'found',
|
|
4249
|
+
main,
|
|
4250
|
+
worktree: toplevel,
|
|
4251
|
+
branch,
|
|
4252
|
+
number: resolved.number,
|
|
4253
|
+
}
|
|
4254
|
+
}
|
|
4255
|
+
|
|
4256
|
+
if (Bun.which('gh') === null) return { kind: 'refused', reason: 'gh-missing' }
|
|
4257
|
+
const read = await readPull(root, number)
|
|
4258
|
+
if (read.kind === 'refused') return read
|
|
4259
|
+
const headRef = read.row.head?.ref
|
|
4260
|
+
if (headRef === undefined || headRef === '') {
|
|
4261
|
+
return { kind: 'refused', reason: 'no-worktree' }
|
|
4262
|
+
}
|
|
4263
|
+
const entry = worktrees.find((candidate) => candidate.branch === headRef)
|
|
4264
|
+
if (entry === undefined) return { kind: 'refused', reason: 'no-worktree' }
|
|
4265
|
+
return {
|
|
4266
|
+
kind: 'found',
|
|
4267
|
+
main,
|
|
4268
|
+
worktree: entry.path,
|
|
4269
|
+
branch: headRef,
|
|
4270
|
+
number,
|
|
4271
|
+
}
|
|
4272
|
+
}
|
|
4273
|
+
|
|
4274
|
+
async function runDriveCopy(
|
|
4275
|
+
number: string | undefined,
|
|
4276
|
+
opts: DriveCopyOptions,
|
|
4277
|
+
): Promise<number> {
|
|
4278
|
+
const root = resolve(opts.root ?? process.cwd())
|
|
4279
|
+
const emitJson = opts.json ?? false
|
|
4280
|
+
const baseline = opts.baseline ?? false
|
|
4281
|
+
const check = opts.check ?? false
|
|
4282
|
+
|
|
4283
|
+
intro('canon pr drive-copy')
|
|
4284
|
+
|
|
4285
|
+
const refuse = (
|
|
4286
|
+
reason: DriveCopyRefusal | PullRefusal | string,
|
|
4287
|
+
message: string,
|
|
4288
|
+
) => refuseWith(reason, message, emitJson, root)
|
|
4289
|
+
|
|
4290
|
+
if (baseline && check) {
|
|
4291
|
+
return refuse('bad-flags', DRIVE_COPY_REFUSALS['bad-flags'])
|
|
4292
|
+
}
|
|
4293
|
+
|
|
4294
|
+
const target = await resolveDriveTarget(root, number, baseline || check)
|
|
4295
|
+
if (target.kind === 'refused') {
|
|
4296
|
+
const reason = target.reason
|
|
4297
|
+
return refuse(
|
|
4298
|
+
reason,
|
|
4299
|
+
reason in DRIVE_COPY_REFUSALS
|
|
4300
|
+
? DRIVE_COPY_REFUSALS[reason as DriveCopyRefusal]
|
|
4301
|
+
: PULL_REFUSALS[reason as PullRefusal],
|
|
4302
|
+
)
|
|
4303
|
+
}
|
|
4304
|
+
|
|
4305
|
+
const scratch = join(target.main, REVIEW_UI_SCRATCH)
|
|
4306
|
+
const baselinePath =
|
|
4307
|
+
target.number === undefined
|
|
4308
|
+
? undefined
|
|
4309
|
+
: join(scratch, target.number, 'drive-baseline.json')
|
|
4310
|
+
const emit = (record: Record<string, unknown>) => {
|
|
4311
|
+
if (emitJson) {
|
|
4312
|
+
process.stdout.write(
|
|
4313
|
+
`${JSON.stringify({ root, worktree: target.worktree, branch: target.branch, ...record })}\n`,
|
|
4314
|
+
)
|
|
4315
|
+
}
|
|
4316
|
+
}
|
|
4317
|
+
|
|
4318
|
+
if (check && baselinePath !== undefined) {
|
|
4319
|
+
let prior: readonly StatusEntry[]
|
|
4320
|
+
try {
|
|
4321
|
+
prior = JSON.parse(await readFile(baselinePath, 'utf8'))
|
|
4322
|
+
} catch {
|
|
4323
|
+
return refuse('no-baseline', DRIVE_COPY_REFUSALS['no-baseline'])
|
|
4324
|
+
}
|
|
4325
|
+
const current = await readWorktreeStatus(target.worktree)
|
|
4326
|
+
if (current === undefined) {
|
|
4327
|
+
return refuse('unreadable-tree', DRIVE_COPY_REFUSALS['unreadable-tree'])
|
|
4328
|
+
}
|
|
4329
|
+
const paths = changedSince(prior, current)
|
|
4330
|
+
if (paths.length === 0) {
|
|
4331
|
+
logStep('Clean')
|
|
4332
|
+
logInfo(`No change in ${target.worktree} since the baseline.`)
|
|
4333
|
+
outro()
|
|
4334
|
+
emit({ reason: 'clean', paths })
|
|
4335
|
+
return 0
|
|
4336
|
+
}
|
|
4337
|
+
logStep('Changed')
|
|
4338
|
+
for (const path of paths) logWarn(path)
|
|
4339
|
+
outro()
|
|
4340
|
+
emit({ reason: 'changed', paths })
|
|
4341
|
+
return 1
|
|
4342
|
+
}
|
|
4343
|
+
|
|
4344
|
+
const config = loadPreviewConfig(target.worktree)
|
|
4345
|
+
if (config.kind === 'refused') return refuse(config.reason, config.detail)
|
|
4346
|
+
|
|
4347
|
+
let copy: Record<string, unknown>
|
|
4348
|
+
if (config.kind === 'absent') {
|
|
4349
|
+
logStep('No config')
|
|
4350
|
+
logInfo('The project declares no preview writes, so nothing was copied.')
|
|
4351
|
+
copy = { reason: 'no-config' }
|
|
4352
|
+
} else {
|
|
4353
|
+
const copyRoot = join(scratch, branchSlug(target.branch), 'board')
|
|
4354
|
+
const { copied, missing } = buildDriveCopy(
|
|
4355
|
+
target.worktree,
|
|
4356
|
+
copyRoot,
|
|
4357
|
+
config.config.writes,
|
|
4358
|
+
)
|
|
4359
|
+
logStep('Copied')
|
|
4360
|
+
logInfo(`${config.config.env}=${copyRoot}`)
|
|
4361
|
+
for (const path of missing) logWarn(`${path} is not in the worktree`)
|
|
4362
|
+
copy = {
|
|
4363
|
+
reason: 'copied',
|
|
4364
|
+
copyRoot,
|
|
4365
|
+
env: config.config.env,
|
|
4366
|
+
copied,
|
|
4367
|
+
missing,
|
|
4368
|
+
}
|
|
4369
|
+
}
|
|
4370
|
+
|
|
4371
|
+
if (baseline && baselinePath !== undefined) {
|
|
4372
|
+
const status = await readWorktreeStatus(target.worktree)
|
|
4373
|
+
if (status === undefined) {
|
|
4374
|
+
return refuse('unreadable-tree', DRIVE_COPY_REFUSALS['unreadable-tree'])
|
|
4375
|
+
}
|
|
4376
|
+
await mkdir(join(baselinePath, '..'), { recursive: true })
|
|
4377
|
+
await writeFile(baselinePath, `${JSON.stringify(status)}\n`)
|
|
4378
|
+
logStep('Baseline')
|
|
4379
|
+
logInfo(`Recorded ${plural(status.length, 'status line')}.`)
|
|
4380
|
+
copy = { ...copy, baseline: baselinePath }
|
|
4381
|
+
}
|
|
4382
|
+
|
|
4383
|
+
outro()
|
|
4384
|
+
emit(copy)
|
|
4385
|
+
return 0
|
|
4386
|
+
}
|
|
4387
|
+
|
|
4000
4388
|
function refuseWith(
|
|
4001
4389
|
reason: string,
|
|
4002
4390
|
message: string,
|
package/src/commands/records.ts
CHANGED
|
@@ -31,6 +31,7 @@ import {
|
|
|
31
31
|
RECORD_KINDS,
|
|
32
32
|
type RecordKind,
|
|
33
33
|
type ValidateOutcome,
|
|
34
|
+
type StructureFinding,
|
|
34
35
|
validateRecords,
|
|
35
36
|
} from '@/records/validate'
|
|
36
37
|
import {
|
|
@@ -89,6 +90,10 @@ export function register(program: Command): void {
|
|
|
89
90
|
.command('validate')
|
|
90
91
|
.description('Report where a file and the standard governing it disagree')
|
|
91
92
|
.argument('<kind>', `Record folder: ${RECORD_KINDS.join(', ')}`)
|
|
93
|
+
.argument(
|
|
94
|
+
'[record]',
|
|
95
|
+
'One plan stem or folder slug, narrowing both findings and structure to it',
|
|
96
|
+
)
|
|
92
97
|
.helpOption('-h, --help', 'Show this help message')
|
|
93
98
|
.option('--json', 'Add a machine-readable record on stdout')
|
|
94
99
|
.option('--root <path>', 'Project root, defaulting to the main worktree')
|
|
@@ -101,12 +106,18 @@ export function register(program: Command): void {
|
|
|
101
106
|
' groundwork README and current-state files, numbering, dating, and a half-closed track',
|
|
102
107
|
' intake overview file, numbering, dating, and the four bullets every item carries',
|
|
103
108
|
'',
|
|
109
|
+
'Structure, under `structure` and never in the exit:',
|
|
110
|
+
" S1 to S5 from the plan standard, read on plans, a track's README and",
|
|
111
|
+
' numbered files, and intake clusters, plus intake-spill for content under',
|
|
112
|
+
' a field. A record written before the criteria misses S1, so it reports alone.',
|
|
113
|
+
'',
|
|
104
114
|
'Refusals, read off `reason`:',
|
|
105
115
|
" no-folder none of the kind's directories exist at either record root",
|
|
116
|
+
' no-record the folder holds no record by the name given',
|
|
106
117
|
' unknown-kind the argument names no published kind',
|
|
107
118
|
'',
|
|
108
119
|
'Exit codes:',
|
|
109
|
-
' 0
|
|
120
|
+
' 0 no record carries a finding, whatever structure reports',
|
|
110
121
|
' 1 refused, with the reason on stderr or in the JSON record',
|
|
111
122
|
' 2 at least one record carries a finding',
|
|
112
123
|
'',
|
|
@@ -115,14 +126,21 @@ export function register(program: Command): void {
|
|
|
115
126
|
'',
|
|
116
127
|
'Examples:',
|
|
117
128
|
' canon records validate plans',
|
|
129
|
+
' canon records validate plans feature-records-by-heading',
|
|
118
130
|
' canon records validate teach',
|
|
119
131
|
' canon records validate intake --json',
|
|
120
132
|
'',
|
|
121
133
|
].join('\n'),
|
|
122
134
|
)
|
|
123
|
-
.action(
|
|
124
|
-
|
|
125
|
-
|
|
135
|
+
.action(
|
|
136
|
+
async (
|
|
137
|
+
kind: string,
|
|
138
|
+
record: string | undefined,
|
|
139
|
+
opts: ValidateCommandOptions,
|
|
140
|
+
) => {
|
|
141
|
+
process.exitCode = await runValidate(kind, record, opts)
|
|
142
|
+
},
|
|
143
|
+
)
|
|
126
144
|
|
|
127
145
|
records
|
|
128
146
|
.command('ordinal')
|
|
@@ -721,6 +739,7 @@ function reportRefusal(
|
|
|
721
739
|
|
|
722
740
|
async function runValidate(
|
|
723
741
|
kind: string,
|
|
742
|
+
record: string | undefined,
|
|
724
743
|
opts: ValidateCommandOptions,
|
|
725
744
|
): Promise<number> {
|
|
726
745
|
const emitJson = opts.json ?? false
|
|
@@ -739,7 +758,11 @@ async function runValidate(
|
|
|
739
758
|
|
|
740
759
|
const root = opts.root ?? (await mainWorktreeRoot())
|
|
741
760
|
|
|
742
|
-
|
|
761
|
+
// A plan named with its extension and without it is one record, so the walk
|
|
762
|
+
// and the cross-plan filter below both read the bare stem.
|
|
763
|
+
const stem = record?.replace(/\.md$/, '')
|
|
764
|
+
|
|
765
|
+
return report(await validateWithStacks(root, kind, stem), emitJson, root)
|
|
743
766
|
}
|
|
744
767
|
|
|
745
768
|
/**
|
|
@@ -749,18 +772,21 @@ async function runValidate(
|
|
|
749
772
|
async function validateWithStacks(
|
|
750
773
|
root: string,
|
|
751
774
|
kind: RecordKind,
|
|
775
|
+
record: string | undefined,
|
|
752
776
|
): Promise<ValidateOutcome> {
|
|
753
|
-
const outcome = await validateRecords(root, kind)
|
|
777
|
+
const outcome = await validateRecords(root, kind, record)
|
|
754
778
|
if (!outcome.ok || kind !== 'plans') return outcome
|
|
755
779
|
|
|
756
780
|
const live = await readLivePlans(root)
|
|
781
|
+
const across = [...stackFindings(live), ...fanInFindings(live)]
|
|
757
782
|
|
|
758
783
|
return {
|
|
759
784
|
...outcome,
|
|
760
785
|
findings: [
|
|
761
786
|
...outcome.findings,
|
|
762
|
-
...
|
|
763
|
-
|
|
787
|
+
...(record === undefined
|
|
788
|
+
? across
|
|
789
|
+
: across.filter((found) => found.record === `${record}.md`)),
|
|
764
790
|
],
|
|
765
791
|
}
|
|
766
792
|
}
|
|
@@ -801,6 +827,7 @@ function report(
|
|
|
801
827
|
kind: outcome.kind,
|
|
802
828
|
records: outcome.records,
|
|
803
829
|
findings: outcome.findings,
|
|
830
|
+
structure: outcome.structure,
|
|
804
831
|
})}\n`,
|
|
805
832
|
)
|
|
806
833
|
} else {
|
|
@@ -814,6 +841,11 @@ function report(
|
|
|
814
841
|
} else {
|
|
815
842
|
for (const found of outcome.findings) logWarn(describe(found))
|
|
816
843
|
}
|
|
844
|
+
|
|
845
|
+
if (outcome.structure.length > 0) {
|
|
846
|
+
logStep('Structure, reported and never in the exit')
|
|
847
|
+
for (const entry of outcome.structure) logInfo(describeStructure(entry))
|
|
848
|
+
}
|
|
817
849
|
outro()
|
|
818
850
|
}
|
|
819
851
|
|
|
@@ -825,6 +857,11 @@ function describe(found: Finding): string {
|
|
|
825
857
|
return `${scope}${found.subject} ${found.message}`
|
|
826
858
|
}
|
|
827
859
|
|
|
860
|
+
function describeStructure(entry: StructureFinding): string {
|
|
861
|
+
const scope = entry.record === entry.subject ? '' : `${entry.record}/`
|
|
862
|
+
return `${entry.criterion} ${scope}${entry.subject}:${entry.line} ${entry.message}`
|
|
863
|
+
}
|
|
864
|
+
|
|
828
865
|
async function runOrdinal(
|
|
829
866
|
kind: string,
|
|
830
867
|
slug: string,
|