@erclx/canon 4.68.0 → 4.70.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/draft-and-pick/REQUIREMENT.md +1 -1
- package/claude/skills/draft-and-pick/SKILL.md +1 -1
- package/claude/skills/{canon-screencast → draft-screencast}/REQUIREMENT.md +4 -4
- package/claude/skills/{canon-screencast → draft-screencast}/SKILL.md +4 -4
- package/claude/skills/{canon-slides-draft → draft-slides}/REQUIREMENT.md +3 -3
- package/claude/skills/{canon-slides-draft → draft-slides}/SKILL.md +2 -2
- package/claude/skills/{canon-frames-read → read-frames}/REQUIREMENT.md +2 -2
- package/claude/skills/{canon-frames-read → read-frames}/SKILL.md +3 -3
- package/claude/skills/{canon-record → record-screencast}/REQUIREMENT.md +5 -5
- package/claude/skills/{canon-record → record-screencast}/SKILL.md +4 -4
- package/claude/skills/review-pr/SKILL.md +55 -7
- package/claude/skills/role-orchestrator/SKILL.md +2 -1
- package/claude/skills/role-orchestrator/references/orchestrator-poll.md +7 -3
- package/claude/skills/role-orchestrator/scripts/poll.sh +79 -34
- package/claude/skills/role-worker/SKILL.md +2 -1
- package/docs/agents/commands.md +3 -0
- package/docs/agents/demo.md +3 -3
- package/docs/agents/index.md +1 -1
- package/docs/agents/pr-reads.md +47 -12
- package/docs/agents/sandbox.md +13 -10
- package/docs/agents/tasks.md +46 -3
- package/docs/workflow/ai-workflow.md +19 -19
- package/package.json +3 -2
- package/scripts/core/regen-web-previews.ts +94 -0
- package/scripts/lib/sandbox-dispatch.sh +8 -0
- package/src/claude/cases/workflow.ts +4 -4
- package/src/claude/plugin-update.ts +48 -0
- package/src/commands/claude.ts +281 -1
- package/src/commands/demo.ts +1 -1
- package/src/commands/feedback.ts +15 -5
- package/src/commands/gate.ts +3 -1
- package/src/commands/pr.ts +130 -1
- package/src/commands/sandbox.ts +13 -4
- package/src/commands/tasks.ts +178 -1
- package/src/demo/beats.ts +1 -1
- package/src/design/components.ts +12 -0
- package/src/gate/measures.ts +47 -1
- package/src/migrate/skill-names.ts +15 -1
- package/src/pr/review-scope.ts +177 -0
- package/src/sandbox/expect.ts +26 -1
- package/src/tasks/archive.ts +206 -3
- package/src/tasks/label.ts +14 -6
- package/src/tasks/validate.ts +22 -0
- package/src/teach/nav.ts +97 -3
- package/standards/glossary.md +8 -0
- package/standards/plan.md +1 -1
- package/standards/tasks.md +15 -1
package/src/commands/claude.ts
CHANGED
|
@@ -3,7 +3,16 @@ import { chmod, readFile } from 'node:fs/promises'
|
|
|
3
3
|
import { homedir } from 'node:os'
|
|
4
4
|
import { join, resolve } from 'node:path'
|
|
5
5
|
import type { Command } from 'commander'
|
|
6
|
+
import { execa } from 'execa'
|
|
7
|
+
import { singleLine } from '@/commands/upgrade'
|
|
8
|
+
import { gitEnv } from '@/git-env'
|
|
6
9
|
import { claudeChain, pendingEntries, planGitignore } from '@/claude/gitignore'
|
|
10
|
+
import {
|
|
11
|
+
matchInstall,
|
|
12
|
+
type PluginInstall,
|
|
13
|
+
readPluginName,
|
|
14
|
+
updatedMessage,
|
|
15
|
+
} from '@/claude/plugin-update'
|
|
7
16
|
import {
|
|
8
17
|
applySeeds,
|
|
9
18
|
countByScope,
|
|
@@ -90,6 +99,37 @@ interface SkillsDriftOptions {
|
|
|
90
99
|
readonly json?: boolean
|
|
91
100
|
}
|
|
92
101
|
|
|
102
|
+
interface PluginUpdateOptions {
|
|
103
|
+
readonly json?: boolean
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* `no-claude` and `no-plugin` name permanent conditions on a machine that
|
|
108
|
+
* never carries the marketplace plugin at all, matching the `gh-missing` and
|
|
109
|
+
* `no-repository` reasons `.husky/post-merge`'s other two steps already stay
|
|
110
|
+
* quiet on forever rather than nagging a project that will never fix them.
|
|
111
|
+
* Every other reason is a real defect and prints.
|
|
112
|
+
*/
|
|
113
|
+
type PluginUpdateReason =
|
|
114
|
+
| 'no-claude'
|
|
115
|
+
| 'no-manifest'
|
|
116
|
+
| 'no-name'
|
|
117
|
+
| 'list-failed'
|
|
118
|
+
| 'no-plugin'
|
|
119
|
+
| 'ambiguous'
|
|
120
|
+
| 'update-failed'
|
|
121
|
+
| 'after-list-failed'
|
|
122
|
+
|
|
123
|
+
interface PluginUpdateRecord {
|
|
124
|
+
readonly root: string
|
|
125
|
+
readonly id?: string
|
|
126
|
+
readonly before?: string
|
|
127
|
+
readonly after?: string
|
|
128
|
+
readonly state: 'updated' | 'current' | 'refused'
|
|
129
|
+
readonly reason?: PluginUpdateReason
|
|
130
|
+
readonly message: string
|
|
131
|
+
}
|
|
132
|
+
|
|
93
133
|
interface SkillsReachOptions {
|
|
94
134
|
readonly json?: boolean
|
|
95
135
|
}
|
|
@@ -111,11 +151,24 @@ const SEEDED_FILES: readonly string[] = [
|
|
|
111
151
|
const SEEDED_DIRS: readonly string[] = ['memory', 'tasks', 'wireframes']
|
|
112
152
|
const USER_DIR = join('tooling', 'claude', 'user')
|
|
113
153
|
const STATUSLINE = 'statusline-command.sh'
|
|
154
|
+
const PLUGIN_MANIFEST = join('claude', '.claude-plugin', 'plugin.json')
|
|
155
|
+
|
|
156
|
+
/** A local cache read, not a network round trip, matching reclaim.ts's own bound for `claude agents --json`. */
|
|
157
|
+
const PLUGIN_LIST_TIMEOUT_MS = 10_000
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* `claude plugin update` fetches a marketplace archive over the network, so a
|
|
161
|
+
* stalled fetch should not hang the caller, which is a git hook on the
|
|
162
|
+
* ordinary path.
|
|
163
|
+
*/
|
|
164
|
+
const PLUGIN_UPDATE_TIMEOUT_MS = 60_000
|
|
114
165
|
|
|
115
166
|
export function register(program: Command): void {
|
|
116
167
|
const claude = program
|
|
117
168
|
.command('claude')
|
|
118
|
-
.description(
|
|
169
|
+
.description(
|
|
170
|
+
'Claude workflow (init, seeds, sync, setup, routing, plugin-update)',
|
|
171
|
+
)
|
|
119
172
|
.helpOption('-h, --help', 'Show this help message')
|
|
120
173
|
.addHelpText(
|
|
121
174
|
'after',
|
|
@@ -227,6 +280,39 @@ export function register(program: Command): void {
|
|
|
227
280
|
process.exitCode = runRouting(path, opts)
|
|
228
281
|
})
|
|
229
282
|
|
|
283
|
+
claude
|
|
284
|
+
.command('plugin-update')
|
|
285
|
+
.description(
|
|
286
|
+
'Update the installed marketplace plugin cache to match this CLI',
|
|
287
|
+
)
|
|
288
|
+
.helpOption('-h, --help', 'Show this help message')
|
|
289
|
+
.option('--json', 'Add a machine-readable record on stdout')
|
|
290
|
+
.addHelpText(
|
|
291
|
+
'after',
|
|
292
|
+
[
|
|
293
|
+
'',
|
|
294
|
+
'Mechanics:',
|
|
295
|
+
' Reads the plugin name out of claude/.claude-plugin/plugin.json,',
|
|
296
|
+
' matches it against an installed row from `claude plugin list',
|
|
297
|
+
' --json` by id prefix (<name>@), then runs `claude plugin update',
|
|
298
|
+
' <id> -y`. There is no --json on the update call itself, so the',
|
|
299
|
+
' version is read back off `claude plugin list --json` again and',
|
|
300
|
+
' compared to what it was before.',
|
|
301
|
+
'',
|
|
302
|
+
'Exit codes:',
|
|
303
|
+
' 0 current already, or the update ran',
|
|
304
|
+
' 1 refused, with the reason on stderr',
|
|
305
|
+
'',
|
|
306
|
+
'Examples:',
|
|
307
|
+
' canon claude plugin-update',
|
|
308
|
+
' canon claude plugin-update --json',
|
|
309
|
+
'',
|
|
310
|
+
].join('\n'),
|
|
311
|
+
)
|
|
312
|
+
.action(async (opts: PluginUpdateOptions) => {
|
|
313
|
+
process.exitCode = await runPluginUpdate(opts)
|
|
314
|
+
})
|
|
315
|
+
|
|
230
316
|
const skills = claude
|
|
231
317
|
.command('skills')
|
|
232
318
|
.description('Plugin skill catalog (list, audit, drift, reach, rank)')
|
|
@@ -830,6 +916,200 @@ function reportRouting(
|
|
|
830
916
|
)
|
|
831
917
|
}
|
|
832
918
|
|
|
919
|
+
/**
|
|
920
|
+
* Reads the plugin name off the manifest, matches it against an installed row,
|
|
921
|
+
* runs the update, and reads the version back off the same list rather than
|
|
922
|
+
* trusting the update call's own report, since `claude plugin update` carries
|
|
923
|
+
* no `--json` to answer with. `canon upgrade` makes the identical move for the
|
|
924
|
+
* package managers it drives.
|
|
925
|
+
*/
|
|
926
|
+
async function runPluginUpdate(opts: PluginUpdateOptions): Promise<number> {
|
|
927
|
+
const mismatch = checkoutMismatchWarning(process.cwd())
|
|
928
|
+
intro('canon claude plugin-update')
|
|
929
|
+
if (mismatch !== undefined) logWarn(mismatch)
|
|
930
|
+
|
|
931
|
+
const manifestPath = join(PROJECT_ROOT, PLUGIN_MANIFEST)
|
|
932
|
+
let manifestText: string
|
|
933
|
+
try {
|
|
934
|
+
manifestText = await readFile(manifestPath, 'utf8')
|
|
935
|
+
} catch {
|
|
936
|
+
return refusePluginUpdate(
|
|
937
|
+
opts,
|
|
938
|
+
'no-manifest',
|
|
939
|
+
`No manifest at ${manifestPath}, so there is no plugin name to update.`,
|
|
940
|
+
)
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
const name = readPluginName(manifestText)
|
|
944
|
+
if (name === undefined) {
|
|
945
|
+
return refusePluginUpdate(
|
|
946
|
+
opts,
|
|
947
|
+
'no-name',
|
|
948
|
+
`No name field in ${manifestPath}, so there is nothing to match against an installed plugin.`,
|
|
949
|
+
)
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
logStep('Manifest')
|
|
953
|
+
logInfo(`${name}, from ${manifestPath}`)
|
|
954
|
+
|
|
955
|
+
const before = await listPluginInstalls()
|
|
956
|
+
if (before.kind === 'missing') {
|
|
957
|
+
return refusePluginUpdate(
|
|
958
|
+
opts,
|
|
959
|
+
'no-claude',
|
|
960
|
+
'claude is not on PATH, so no installed plugin could be read.',
|
|
961
|
+
)
|
|
962
|
+
}
|
|
963
|
+
if (before.kind === 'failed') {
|
|
964
|
+
return refusePluginUpdate(
|
|
965
|
+
opts,
|
|
966
|
+
'list-failed',
|
|
967
|
+
`\`claude plugin list --json\` failed. ${before.detail}`,
|
|
968
|
+
)
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
const match = matchInstall(name, before.installs)
|
|
972
|
+
if (match.kind === 'none') {
|
|
973
|
+
return refusePluginUpdate(
|
|
974
|
+
opts,
|
|
975
|
+
'no-plugin',
|
|
976
|
+
`No installed plugin carries the id prefix "${name}@". Install it with \`claude plugin install\` first.`,
|
|
977
|
+
)
|
|
978
|
+
}
|
|
979
|
+
if (match.kind === 'many') {
|
|
980
|
+
return refusePluginUpdate(
|
|
981
|
+
opts,
|
|
982
|
+
'ambiguous',
|
|
983
|
+
`Multiple installed plugins share the name "${name}": ${match.installs
|
|
984
|
+
.map((install) => install.id)
|
|
985
|
+
.join(', ')}. Refusing rather than picking one.`,
|
|
986
|
+
)
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
const { install } = match
|
|
990
|
+
logStep('Installed')
|
|
991
|
+
logInfo(`${install.id} ${install.version}`)
|
|
992
|
+
|
|
993
|
+
logStep('Updating')
|
|
994
|
+
const result = await execa('claude', ['plugin', 'update', install.id, '-y'], {
|
|
995
|
+
reject: false,
|
|
996
|
+
timeout: PLUGIN_UPDATE_TIMEOUT_MS,
|
|
997
|
+
env: gitEnv(),
|
|
998
|
+
extendEnv: false,
|
|
999
|
+
})
|
|
1000
|
+
|
|
1001
|
+
if (result.exitCode !== 0) {
|
|
1002
|
+
return refusePluginUpdate(
|
|
1003
|
+
opts,
|
|
1004
|
+
'update-failed',
|
|
1005
|
+
`\`claude plugin update ${install.id} -y\` exited ${result.exitCode}. ${(result.stderr || result.stdout).trim()}`,
|
|
1006
|
+
install.id,
|
|
1007
|
+
install.version,
|
|
1008
|
+
)
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
const after = await listPluginInstalls()
|
|
1012
|
+
if (after.kind !== 'ok') {
|
|
1013
|
+
return refusePluginUpdate(
|
|
1014
|
+
opts,
|
|
1015
|
+
'after-list-failed',
|
|
1016
|
+
'`claude plugin update` ran, but `claude plugin list --json` failed to read the version back.',
|
|
1017
|
+
install.id,
|
|
1018
|
+
install.version,
|
|
1019
|
+
)
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
const afterVersion =
|
|
1023
|
+
after.installs.find((row) => row.id === install.id)?.version ??
|
|
1024
|
+
install.version
|
|
1025
|
+
const state = afterVersion === install.version ? 'current' : 'updated'
|
|
1026
|
+
|
|
1027
|
+
logStep('Installed')
|
|
1028
|
+
logInfo(
|
|
1029
|
+
afterVersion === install.version
|
|
1030
|
+
? `${afterVersion}, unchanged`
|
|
1031
|
+
: `${install.version} to ${afterVersion}`,
|
|
1032
|
+
)
|
|
1033
|
+
outro()
|
|
1034
|
+
|
|
1035
|
+
emitPluginUpdate(opts, {
|
|
1036
|
+
root: PROJECT_ROOT,
|
|
1037
|
+
id: install.id,
|
|
1038
|
+
before: install.version,
|
|
1039
|
+
after: afterVersion,
|
|
1040
|
+
state,
|
|
1041
|
+
message: updatedMessage(install.version, afterVersion),
|
|
1042
|
+
})
|
|
1043
|
+
return 0
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1046
|
+
type ListInstallsResult =
|
|
1047
|
+
| { readonly kind: 'ok'; readonly installs: readonly PluginInstall[] }
|
|
1048
|
+
| { readonly kind: 'missing' }
|
|
1049
|
+
| { readonly kind: 'failed'; readonly detail: string }
|
|
1050
|
+
|
|
1051
|
+
/**
|
|
1052
|
+
* `missing` separates a `claude` binary that is not on PATH from every other
|
|
1053
|
+
* failure, since only that condition is permanent enough for the hook to
|
|
1054
|
+
* silence forever. execa reports it as `ENOENT` on the result rather than by
|
|
1055
|
+
* throwing, because the call runs with `reject: false`.
|
|
1056
|
+
*/
|
|
1057
|
+
async function listPluginInstalls(): Promise<ListInstallsResult> {
|
|
1058
|
+
const result = await execa('claude', ['plugin', 'list', '--json'], {
|
|
1059
|
+
reject: false,
|
|
1060
|
+
timeout: PLUGIN_LIST_TIMEOUT_MS,
|
|
1061
|
+
env: gitEnv(),
|
|
1062
|
+
extendEnv: false,
|
|
1063
|
+
})
|
|
1064
|
+
|
|
1065
|
+
if (result.code === 'ENOENT') return { kind: 'missing' }
|
|
1066
|
+
if (result.exitCode !== 0) {
|
|
1067
|
+
return { kind: 'failed', detail: (result.stderr || result.stdout).trim() }
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
try {
|
|
1071
|
+
return {
|
|
1072
|
+
kind: 'ok',
|
|
1073
|
+
installs: JSON.parse(result.stdout) as readonly PluginInstall[],
|
|
1074
|
+
}
|
|
1075
|
+
} catch {
|
|
1076
|
+
return {
|
|
1077
|
+
kind: 'failed',
|
|
1078
|
+
detail: '`claude plugin list --json` did not print valid JSON.',
|
|
1079
|
+
}
|
|
1080
|
+
}
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
function refusePluginUpdate(
|
|
1084
|
+
opts: PluginUpdateOptions,
|
|
1085
|
+
reason: PluginUpdateReason,
|
|
1086
|
+
message: string,
|
|
1087
|
+
id?: string,
|
|
1088
|
+
before?: string,
|
|
1089
|
+
): number {
|
|
1090
|
+
outro()
|
|
1091
|
+
frameError(message)
|
|
1092
|
+
emitPluginUpdate(opts, {
|
|
1093
|
+
root: PROJECT_ROOT,
|
|
1094
|
+
...(id === undefined ? {} : { id }),
|
|
1095
|
+
...(before === undefined ? {} : { before }),
|
|
1096
|
+
state: 'refused',
|
|
1097
|
+
reason,
|
|
1098
|
+
message,
|
|
1099
|
+
})
|
|
1100
|
+
return 1
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1103
|
+
function emitPluginUpdate(
|
|
1104
|
+
opts: PluginUpdateOptions,
|
|
1105
|
+
record: PluginUpdateRecord,
|
|
1106
|
+
): void {
|
|
1107
|
+
if (opts.json !== true) return
|
|
1108
|
+
process.stdout.write(
|
|
1109
|
+
`${JSON.stringify({ ...record, message: singleLine(record.message) })}\n`,
|
|
1110
|
+
)
|
|
1111
|
+
}
|
|
1112
|
+
|
|
833
1113
|
/** What a reader does about the one way the corpus fails to build. */
|
|
834
1114
|
const REACH_REFUSALS: Record<ReachRefusal, string> = {
|
|
835
1115
|
'no-skills':
|
package/src/commands/demo.ts
CHANGED
|
@@ -54,7 +54,7 @@ export function register(program: Command): void {
|
|
|
54
54
|
demo
|
|
55
55
|
.command('compile')
|
|
56
56
|
.description('Turn a screencast draft into a plan a run can drive')
|
|
57
|
-
.argument('<draft>', 'Screencast draft written by
|
|
57
|
+
.argument('<draft>', 'Screencast draft written by draft-screencast')
|
|
58
58
|
.helpOption('-h, --help', 'Show this help message')
|
|
59
59
|
.option('-o, --out <dir>', 'Directory the plan is written to', DEFAULT_OUT)
|
|
60
60
|
.option('-s, --slug <slug>', 'Plan name, defaulting to the draft filename')
|
package/src/commands/feedback.ts
CHANGED
|
@@ -7,11 +7,11 @@ import {
|
|
|
7
7
|
missingField,
|
|
8
8
|
missingFieldMessage,
|
|
9
9
|
} from '@/commands/feedback-format'
|
|
10
|
-
import { PROJECT_ROOT } from '@/project-root'
|
|
10
|
+
import { checkoutMismatchWarning, PROJECT_ROOT } from '@/project-root'
|
|
11
11
|
import { creationRel } from '@/record-root'
|
|
12
12
|
import { createGithubIssue } from '@/github'
|
|
13
13
|
import { issueFailureMessage } from '@/github-format'
|
|
14
|
-
import { frameError, frameSuccess, palette } from '@/ui'
|
|
14
|
+
import { frameError, frameSuccess, logWarn, palette } from '@/ui'
|
|
15
15
|
|
|
16
16
|
function readStdin(): Promise<string> {
|
|
17
17
|
return new Promise((resolveStream, rejectStream) => {
|
|
@@ -45,7 +45,7 @@ function isToolkitSource(): boolean {
|
|
|
45
45
|
* hand, so each writes under its own name and the enclosing folder keeps the
|
|
46
46
|
* single ignore entry and the single backed-folder entry it already had.
|
|
47
47
|
*/
|
|
48
|
-
function writeLocal(body: string): string {
|
|
48
|
+
function writeLocal(body: string, mismatch: string | undefined): string {
|
|
49
49
|
// Resolved against the same root the write joins onto, so a checkout that has
|
|
50
50
|
// not migrated its records writes this beside the ones already there rather
|
|
51
51
|
// than opening a second root nothing reads.
|
|
@@ -55,7 +55,16 @@ function writeLocal(body: string): string {
|
|
|
55
55
|
const filename = `feedback-${deriveSlug(body)}-${timestamp()}.md`
|
|
56
56
|
const filePath = join(reviewDir, filename)
|
|
57
57
|
writeFileSync(filePath, `${body}\n`, 'utf8')
|
|
58
|
-
|
|
58
|
+
|
|
59
|
+
// Manual frame rather than `frameSuccess`, since the mismatch warning is a
|
|
60
|
+
// frame-interior line landing between the opener and the command title,
|
|
61
|
+
// matching the convention `#1587` set at its other six sites.
|
|
62
|
+
const { GREEN, GREY, NC, WHITE } = palette(process.stderr)
|
|
63
|
+
process.stderr.write(`${GREY}┌${NC}\n`)
|
|
64
|
+
if (mismatch !== undefined) logWarn(mismatch)
|
|
65
|
+
process.stderr.write(
|
|
66
|
+
`${GREY}│${NC} ${WHITE}canon feedback${NC}\n${GREY}│${NC}\n${GREY}│${NC} ${GREEN}✓${NC} ${join(relativeDir, filename)}\n${GREY}└${NC}\n`,
|
|
67
|
+
)
|
|
59
68
|
return filePath
|
|
60
69
|
}
|
|
61
70
|
|
|
@@ -124,7 +133,8 @@ export function register(program: Command): void {
|
|
|
124
133
|
return
|
|
125
134
|
}
|
|
126
135
|
|
|
127
|
-
const
|
|
136
|
+
const mismatch = checkoutMismatchWarning(process.cwd())
|
|
137
|
+
const filePath = writeLocal(body, mismatch)
|
|
128
138
|
process.stdout.write(`${filePath}\n`)
|
|
129
139
|
})
|
|
130
140
|
}
|
package/src/commands/gate.ts
CHANGED
|
@@ -12,7 +12,7 @@ import {
|
|
|
12
12
|
summarize,
|
|
13
13
|
} from '@/gate/sequencer'
|
|
14
14
|
import { STAGES } from '@/gate/stages'
|
|
15
|
-
import { PROJECT_ROOT } from '@/project-root'
|
|
15
|
+
import { checkoutMismatchWarning, PROJECT_ROOT } from '@/project-root'
|
|
16
16
|
import {
|
|
17
17
|
intro,
|
|
18
18
|
logError,
|
|
@@ -89,6 +89,7 @@ export function register(program: Command): void {
|
|
|
89
89
|
|
|
90
90
|
async function runGate(opts: RunCommandOptions): Promise<number> {
|
|
91
91
|
const root = PROJECT_ROOT
|
|
92
|
+
const mismatch = checkoutMismatchWarning(process.cwd())
|
|
92
93
|
const emitJson = opts.json ?? false
|
|
93
94
|
const nested = opts.nested ?? false
|
|
94
95
|
const write = opts.write ?? true
|
|
@@ -102,6 +103,7 @@ async function runGate(opts: RunCommandOptions): Promise<number> {
|
|
|
102
103
|
? { scoped: false, files: [] }
|
|
103
104
|
: await collectChangedFiles(run)
|
|
104
105
|
if (!emitJson && changed.notice !== undefined) logWarn(changed.notice)
|
|
106
|
+
if (mismatch !== undefined) logWarn(mismatch)
|
|
105
107
|
|
|
106
108
|
const ctx: GateContext = {
|
|
107
109
|
root,
|
package/src/commands/pr.ts
CHANGED
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
import { type CheckRunListing, collapseChecks } from '@/pr/checks'
|
|
19
19
|
import { type HeadRefusal, resolveHead, resolveTip } from '@/pr/head'
|
|
20
20
|
import { KEY_CHANGES } from '@/pr/paths'
|
|
21
|
+
import { type ReviewListing, resolveReviewScope } from '@/pr/review-scope'
|
|
21
22
|
import { intro, logInfo, logStep, logWarn, outro, plural } from '@/ui'
|
|
22
23
|
|
|
23
24
|
const GH_TIMEOUT_MS = 30_000
|
|
@@ -49,7 +50,12 @@ interface ReadOptions {
|
|
|
49
50
|
}
|
|
50
51
|
|
|
51
52
|
/** Why a head-sensitive read produced no answer about a commit. */
|
|
52
|
-
type PullRefusal =
|
|
53
|
+
type PullRefusal =
|
|
54
|
+
| 'gh-missing'
|
|
55
|
+
| 'gh-failed'
|
|
56
|
+
| 'no-branch'
|
|
57
|
+
| 'runs-unreadable'
|
|
58
|
+
| 'reviews-unreadable'
|
|
53
59
|
|
|
54
60
|
/** What a reader does about each way the two sha-keyed verbs produced nothing. */
|
|
55
61
|
const PULL_REFUSALS: Record<PullRefusal | HeadRefusal, string> = {
|
|
@@ -67,6 +73,8 @@ const PULL_REFUSALS: Record<PullRefusal | HeadRefusal, string> = {
|
|
|
67
73
|
'The pull request object reported no head commit, so there is nothing to compare the tip against.',
|
|
68
74
|
'runs-unreadable':
|
|
69
75
|
'The check runs for this commit could not be read. An empty answer here would report a commit as having no check rather than as unread, so nothing is reported.',
|
|
76
|
+
'reviews-unreadable':
|
|
77
|
+
'The reviews on this pull request could not be read. An empty answer here would report a reviewed pull request as never reviewed, which routes the next pass to the whole change, so nothing is reported.',
|
|
70
78
|
}
|
|
71
79
|
|
|
72
80
|
/** Why the read produced no comparison, ahead of the ones the compare owns. */
|
|
@@ -236,6 +244,47 @@ export function register(program: Command): void {
|
|
|
236
244
|
.action(async (number: string | undefined, opts: ReadOptions) => {
|
|
237
245
|
process.exitCode = await runChecks(number, opts)
|
|
238
246
|
})
|
|
247
|
+
|
|
248
|
+
pr.command('review-state')
|
|
249
|
+
.description('Report the commit and instant the last review pass covered')
|
|
250
|
+
.argument('[number]', 'Pull request to read, defaulting to this branch')
|
|
251
|
+
.helpOption('-h, --help', 'Show this help message')
|
|
252
|
+
.option('--root <path>', 'Repository to read, defaulting to the cwd')
|
|
253
|
+
.option('--json', 'Add a machine-readable record on stdout')
|
|
254
|
+
.addHelpText(
|
|
255
|
+
'after',
|
|
256
|
+
[
|
|
257
|
+
'',
|
|
258
|
+
'A review carries two stamps GitHub writes at submission: `commit.oid`',
|
|
259
|
+
'names whatever the head was at that instant and `submittedAt` names the',
|
|
260
|
+
'instant itself. Neither describes the commit the reviewing session read.',
|
|
261
|
+
'A push landing inside the compose window moves `commit.oid` onto a commit',
|
|
262
|
+
'nobody reviewed, and the next pass then scopes its delta past that work',
|
|
263
|
+
'and reports it covered.',
|
|
264
|
+
'',
|
|
265
|
+
'`review-pr` writes the commit it read and the instant it read it as a',
|
|
266
|
+
'marker on the last line of every body it posts. This is the one place',
|
|
267
|
+
'that marker is parsed, so `review-pr` and the orchestrator poll read one',
|
|
268
|
+
'answer rather than carrying a copy of the format each.',
|
|
269
|
+
'',
|
|
270
|
+
'Read `source` before trusting the rest:',
|
|
271
|
+
' marker the pass wrote its own read-time record, which is authority',
|
|
272
|
+
' fallback a pass posted before the marker shipped, off GitHub stamps',
|
|
273
|
+
' none the thread carries no pass, so the next one is a first pass',
|
|
274
|
+
'',
|
|
275
|
+
'Exit codes:',
|
|
276
|
+
' 0 the thread was read, whether it carries a pass or not',
|
|
277
|
+
' 1 refused, with the reason on stderr or in the JSON record',
|
|
278
|
+
'',
|
|
279
|
+
'Examples:',
|
|
280
|
+
' canon pr review-state',
|
|
281
|
+
' canon pr review-state 1341 --json',
|
|
282
|
+
'',
|
|
283
|
+
].join('\n'),
|
|
284
|
+
)
|
|
285
|
+
.action(async (number: string | undefined, opts: ReadOptions) => {
|
|
286
|
+
process.exitCode = await runReviewState(number, opts)
|
|
287
|
+
})
|
|
239
288
|
}
|
|
240
289
|
|
|
241
290
|
interface PullRequestRead {
|
|
@@ -768,6 +817,86 @@ async function runChecks(
|
|
|
768
817
|
return 0
|
|
769
818
|
}
|
|
770
819
|
|
|
820
|
+
async function runReviewState(
|
|
821
|
+
number: string | undefined,
|
|
822
|
+
opts: ReadOptions,
|
|
823
|
+
): Promise<number> {
|
|
824
|
+
const root = resolve(opts.root ?? process.cwd())
|
|
825
|
+
const emitJson = opts.json ?? false
|
|
826
|
+
|
|
827
|
+
intro('canon pr review-state')
|
|
828
|
+
|
|
829
|
+
if (Bun.which('gh') === null) {
|
|
830
|
+
return refuseWith('gh-missing', PULL_REFUSALS['gh-missing'], emitJson, root)
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
const args = ['pr', 'view']
|
|
834
|
+
if (number !== undefined) args.push(number)
|
|
835
|
+
// `number` rides along so the record names the pull request a caller that
|
|
836
|
+
// passed no argument was answered about. The comment families the poll reads
|
|
837
|
+
// stay out of the query, since nothing here parses one and a listing the
|
|
838
|
+
// caller discards is a payload paid for twice.
|
|
839
|
+
args.push('--json', 'number,reviews')
|
|
840
|
+
|
|
841
|
+
const stdout = await gh(root, args)
|
|
842
|
+
if (stdout === null) {
|
|
843
|
+
return refuseWith('gh-failed', PULL_REFUSALS['gh-failed'], emitJson, root)
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
let listing: ReviewListing & { number?: number }
|
|
847
|
+
try {
|
|
848
|
+
listing = JSON.parse(stdout)
|
|
849
|
+
} catch {
|
|
850
|
+
return refuseWith(
|
|
851
|
+
'reviews-unreadable',
|
|
852
|
+
PULL_REFUSALS['reviews-unreadable'],
|
|
853
|
+
emitJson,
|
|
854
|
+
root,
|
|
855
|
+
)
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
const scope = resolveReviewScope(listing)
|
|
859
|
+
|
|
860
|
+
logStep('Scope')
|
|
861
|
+
logInfo(
|
|
862
|
+
listing.number === undefined
|
|
863
|
+
? 'the pull request on this branch'
|
|
864
|
+
: `#${listing.number}`,
|
|
865
|
+
)
|
|
866
|
+
|
|
867
|
+
if (scope.source === 'none') {
|
|
868
|
+
logStep('First pass')
|
|
869
|
+
logInfo('the thread carries no review, so nothing has been covered yet')
|
|
870
|
+
} else if (scope.source === 'marker') {
|
|
871
|
+
logStep(scope.state === 'open' ? 'Open' : 'Closed')
|
|
872
|
+
logInfo(
|
|
873
|
+
`the last pass read ${scope.commit?.slice(0, 8)} at ${scope.readAt}, which is what it covered`,
|
|
874
|
+
)
|
|
875
|
+
} else {
|
|
876
|
+
logStep(scope.state === 'open' ? 'Open' : 'Closed')
|
|
877
|
+
// Named rather than folded into the line above, since the whole point of
|
|
878
|
+
// the marker is that these two fields describe the submission and not the
|
|
879
|
+
// read, and a caller cannot tell the two apart from the values alone.
|
|
880
|
+
logWarn(
|
|
881
|
+
`the last pass carries no read-time marker, so ${scope.commit === undefined ? 'no commit' : scope.commit.slice(0, 8)} and ${scope.submittedAt ?? 'no instant'} come off GitHub's submission stamps. A push inside that pass's compose window is invisible here.`,
|
|
882
|
+
)
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
outro()
|
|
886
|
+
|
|
887
|
+
if (emitJson) {
|
|
888
|
+
process.stdout.write(
|
|
889
|
+
`${JSON.stringify({
|
|
890
|
+
root,
|
|
891
|
+
...(listing.number !== undefined && { number: listing.number }),
|
|
892
|
+
...scope,
|
|
893
|
+
})}\n`,
|
|
894
|
+
)
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
return 0
|
|
898
|
+
}
|
|
899
|
+
|
|
771
900
|
/**
|
|
772
901
|
* Frames a refusal on stderr in both modes and puts the record on stdout alone,
|
|
773
902
|
* so an operator reading the terminal sees the reason rather than a command
|
package/src/commands/sandbox.ts
CHANGED
|
@@ -78,6 +78,7 @@ interface CheckOptions {
|
|
|
78
78
|
readonly writes?: string
|
|
79
79
|
readonly escapes?: string
|
|
80
80
|
readonly escapesWatched?: boolean
|
|
81
|
+
readonly concurrentSessions?: string
|
|
81
82
|
readonly json?: boolean
|
|
82
83
|
readonly strict?: boolean
|
|
83
84
|
}
|
|
@@ -349,7 +350,7 @@ function runCheck(
|
|
|
349
350
|
|
|
350
351
|
const parsed = parseTarget(target)
|
|
351
352
|
if (parsed === undefined) {
|
|
352
|
-
logError('Invalid target. Use <category>:<command>, e.g. claude:docs.')
|
|
353
|
+
logError('Invalid target. Use <category>:<command>, e.g. claude:docs-fold.')
|
|
353
354
|
outro()
|
|
354
355
|
process.exitCode = 1
|
|
355
356
|
return
|
|
@@ -376,6 +377,7 @@ function runCheck(
|
|
|
376
377
|
options.escapes === undefined
|
|
377
378
|
? undefined
|
|
378
379
|
: options.escapesWatched === true,
|
|
380
|
+
concurrentSessions: readPathList(options.concurrentSessions),
|
|
379
381
|
envelope: readEnvelope(options.envelope),
|
|
380
382
|
},
|
|
381
383
|
)
|
|
@@ -414,7 +416,10 @@ export function register(program: Command): void {
|
|
|
414
416
|
sandbox
|
|
415
417
|
.command('check')
|
|
416
418
|
.description('Check a provisioned sandbox against a scenario expectation')
|
|
417
|
-
.argument(
|
|
419
|
+
.argument(
|
|
420
|
+
'<target>',
|
|
421
|
+
'Scenario as <category>:<command>, e.g. claude:docs-fold',
|
|
422
|
+
)
|
|
418
423
|
.argument('[arm]', 'Named scenario arm, e.g. drift')
|
|
419
424
|
.helpOption('-h, --help', 'Show this help message')
|
|
420
425
|
.option('--envelope <file>', 'Run envelope JSON from claude -p')
|
|
@@ -427,6 +432,10 @@ export function register(program: Command): void {
|
|
|
427
432
|
'--escapes-watched',
|
|
428
433
|
'At least one watched root held a target this run, so a zero-escape file is a clean watch rather than one with nothing to watch',
|
|
429
434
|
)
|
|
435
|
+
.option(
|
|
436
|
+
'--concurrent-sessions <file>',
|
|
437
|
+
'Newline-delimited sessions live in the registry both before and after this run, a witness for an unbounded escape',
|
|
438
|
+
)
|
|
430
439
|
.option('--json', 'Emit the verdict as JSON on stdout')
|
|
431
440
|
.option('--strict', 'Exit non-zero when the arm declares no expectation')
|
|
432
441
|
.addHelpText(
|
|
@@ -434,8 +443,8 @@ export function register(program: Command): void {
|
|
|
434
443
|
[
|
|
435
444
|
'',
|
|
436
445
|
'Examples:',
|
|
437
|
-
' canon sandbox check claude:docs drift',
|
|
438
|
-
' canon sandbox check claude:docs drift --envelope run.json --json',
|
|
446
|
+
' canon sandbox check claude:docs-fold drift',
|
|
447
|
+
' canon sandbox check claude:docs-fold drift --envelope run.json --json',
|
|
439
448
|
'',
|
|
440
449
|
'Exit codes: 0 on pass or unchecked, 1 on failure.',
|
|
441
450
|
'With --strict, unchecked exits 1 as well.',
|