@erclx/canon 5.65.0 → 5.67.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/CHANGELOG.md +26 -0
- package/claude/.claude-plugin/plugin.json +1 -1
- package/claude/skills/app-drive/REQUIREMENT.md +35 -0
- package/claude/skills/app-drive/SKILL.md +112 -0
- package/claude/skills/app-drive/references/config.md +38 -0
- package/claude/skills/app-drive/references/drive-record.md +70 -0
- package/claude/skills/app-drive/references/isolate.md +46 -0
- package/claude/skills/canon-cli/SKILL.md +2 -0
- package/claude/skills/create-skill/references/skill.md +1 -1
- package/claude/skills/git-pr/references/labels.md +6 -2
- package/claude/skills/git-pr-close/REQUIREMENT.md +29 -0
- package/claude/skills/git-pr-close/SKILL.md +55 -0
- package/claude/skills/git-pr-close/references/close.md +54 -0
- package/claude/skills/plan-feature/references/plan.md +1 -1
- package/claude/skills/plan-intake/references/intake.md +12 -2
- package/claude/skills/pr-evidence/SKILL.md +2 -2
- package/claude/skills/pr-evidence/references/evidence.md +3 -1
- package/claude/skills/review-ui/SKILL.md +5 -4
- package/claude/skills/review-ui/references/drive-copy.md +4 -0
- package/claude/skills/review-ui/references/driving.md +1 -1
- package/claude/skills/role-orchestrator/references/orchestrator-handoff.md +15 -2
- package/claude/skills/role-planner/SKILL.md +2 -2
- package/claude/skills/role-worker/SKILL.md +6 -8
- package/claude/skills/session-map/REQUIREMENT.md +1 -1
- package/claude/skills/session-map/SKILL.md +8 -1
- package/claude/skills/session-map/references/session.md +19 -5
- package/claude/skills/ui-record/REQUIREMENT.md +34 -0
- package/claude/skills/ui-record/SKILL.md +103 -0
- package/claude/skills/ui-record/references/config.md +40 -0
- package/claude/skills/ui-record/references/handoff.md +48 -0
- package/claude/skills/ui-record/references/take-craft.md +92 -0
- package/docs/agents/records.md +1 -0
- package/docs/workflow/parallel-features.md +1 -1
- package/docs/workflow/skill-map-on-request.md +1 -0
- package/docs/workflow/skill-map.md +2 -0
- package/docs/workflow/visual-design-workflow.md +2 -1
- package/package.json +1 -1
- package/src/audits/catalog.ts +5 -1
- package/src/browser/agent-cli.ts +68 -0
- package/src/claude/cases/git.ts +4 -0
- package/src/claude/cases/workflow.ts +20 -0
- package/src/cli/help.ts +3 -0
- package/src/cli.ts +4 -0
- package/src/commands/browser.ts +102 -0
- package/src/commands/intake.ts +181 -1
- package/src/commands/labels.ts +152 -4
- package/src/commands/pr.ts +278 -8
- package/src/commands/records.ts +18 -1
- package/src/commands/take.ts +266 -0
- package/src/context/requirements.ts +1 -11
- package/src/git/status.ts +73 -0
- package/src/intake/drive-check.ts +355 -0
- package/src/intake/folder.ts +1 -1
- package/src/labels/apply.ts +10 -5
- package/src/labels/sync.ts +17 -0
- package/src/markdown/scan.ts +10 -0
- package/src/pr/close.ts +152 -0
- package/src/pr/drive-copy.ts +97 -84
- package/src/pr/health.ts +56 -0
- package/src/pr/local.ts +49 -8
- package/src/pr/rest.ts +10 -2
- package/src/records/session-map.ts +75 -0
- package/src/records/validate.ts +64 -8
- package/src/sandbox/replay/run.ts +30 -5
- package/src/take/camera.ts +309 -0
- package/src/take/encode.ts +170 -0
- package/src/take/film.ts +206 -0
- package/src/take/fixtures/fade.html +49 -0
- package/src/take/fixtures/fetch.html +38 -0
- package/src/take/log.ts +109 -0
- package/src/take/perform.ts +200 -0
- package/src/take/plan.ts +193 -0
- package/src/take/screencast.ts +94 -0
- package/src/take/shell.ts +287 -0
- package/src/take/stepper.ts +252 -0
- package/src/take/zoom/NOTICE.md +45 -0
- package/src/take/zoom/math-utils.ts +69 -0
- package/src/take/zoom/region-utils.ts +55 -0
- package/src/take/zoom/spring.ts +133 -0
- package/src/tasks/archive.ts +1 -3
- package/src/tooling/verify-stack.ts +4 -5
- package/tooling/base/reference.md +1 -0
- package/tooling/base/seeds/canon/config/pr-labels.toml +41 -0
- package/claude/skills/review-ui/scripts/pw.sh +0 -60
|
@@ -43,8 +43,9 @@ Run `canon drive --help` for the step kinds and the run file shape.
|
|
|
43
43
|
Two reviews run beside each other and finish on their own clocks.
|
|
44
44
|
|
|
45
45
|
- `canon:review-pr` reads the code and the stills in the evidence comment, and posts under `## Review`.
|
|
46
|
-
- `canon:review-ui` never reads the diff. It drives each checklist box in the running app through the pinned `@playwright/cli
|
|
46
|
+
- `canon:review-ui` never reads the diff. It drives each checklist box in the running app through `canon browser`, which runs the pinned `@playwright/cli`, and posts under `## UI review`.
|
|
47
47
|
- Every box gets one verdict, being pass, fail, needs eyes, or not driven.
|
|
48
|
+
- The pass refuses rather than download a browser. Install it once per machine with `canon browser install-browser chromium`.
|
|
48
49
|
|
|
49
50
|
After the boxes, `review-ui` sweeps every route they reached:
|
|
50
51
|
|
package/package.json
CHANGED
package/src/audits/catalog.ts
CHANGED
|
@@ -451,7 +451,11 @@ function testOrderCounts(record: unknown): Record<string, number> | undefined {
|
|
|
451
451
|
})
|
|
452
452
|
}
|
|
453
453
|
|
|
454
|
-
/**
|
|
454
|
+
/**
|
|
455
|
+
* The record kinds whose findings the aggregate counts, and the corpus each
|
|
456
|
+
* reads. `sessions` is left out because it reports length alone and never a
|
|
457
|
+
* finding, so a row for it would always count zero.
|
|
458
|
+
*/
|
|
455
459
|
const RECORD_KINDS: readonly (readonly [string, Corpus])[] = [
|
|
456
460
|
['plans', 'per-machine'],
|
|
457
461
|
['groundwork', 'per-machine'],
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The pinned agent browser CLI every skill driving a page reaches through
|
|
3
|
+
* `canon browser`, so every call in a run and every run on a machine reach one
|
|
4
|
+
* version. `open` is forced onto the bundled Chromium, since the CLI's default
|
|
5
|
+
* channel is a branded Chrome most machines lack, and it refuses rather than
|
|
6
|
+
* downloading when that browser is absent, since a dispatched reviewer that
|
|
7
|
+
* installs mid-pass spends its budget on the download.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export const AGENT_CLI = '@playwright/cli@0.1.22'
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The Chromium revision that CLI version requires. It moves with the pin, and
|
|
14
|
+
* it is separate from the revision `INSTALL_BROWSER` fetches for the engine.
|
|
15
|
+
*/
|
|
16
|
+
export const CHROMIUM_REVISION = '1247'
|
|
17
|
+
|
|
18
|
+
export const INSTALL_AGENT_BROWSER = 'canon browser install-browser chromium'
|
|
19
|
+
|
|
20
|
+
export type AgentCall =
|
|
21
|
+
| { readonly kind: 'run'; readonly args: readonly string[] }
|
|
22
|
+
| { readonly kind: 'refused'; readonly message: string }
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Rewrites the caller's words into the child's, reading the revision only for
|
|
26
|
+
* `open`, the one verb that launches a browser. Global flags may lead the verb,
|
|
27
|
+
* as in `-s=<name> --raw open <url>`.
|
|
28
|
+
*/
|
|
29
|
+
export function planAgentCall(
|
|
30
|
+
args: readonly string[],
|
|
31
|
+
hasRevision: () => boolean,
|
|
32
|
+
): AgentCall {
|
|
33
|
+
const verbAt = args.findIndex((arg) => !arg.startsWith('-'))
|
|
34
|
+
if (verbAt === -1 || args[verbAt] !== 'open') return { kind: 'run', args }
|
|
35
|
+
|
|
36
|
+
if (!hasRevision()) {
|
|
37
|
+
return {
|
|
38
|
+
kind: 'refused',
|
|
39
|
+
message: `Chromium revision ${CHROMIUM_REVISION} is not installed. Run once, outside the pass: ${INSTALL_AGENT_BROWSER}`,
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const namesBrowser = args.some(
|
|
44
|
+
(arg) => arg === '--browser' || arg.startsWith('--browser='),
|
|
45
|
+
)
|
|
46
|
+
if (namesBrowser) return { kind: 'run', args }
|
|
47
|
+
|
|
48
|
+
return {
|
|
49
|
+
kind: 'run',
|
|
50
|
+
args: [
|
|
51
|
+
...args.slice(0, verbAt + 1),
|
|
52
|
+
'--browser=chromium',
|
|
53
|
+
...args.slice(verbAt + 1),
|
|
54
|
+
],
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Where Playwright keeps downloaded browsers, by its own lookup order. */
|
|
59
|
+
export function browsersRoot(
|
|
60
|
+
env: Readonly<Record<string, string | undefined>>,
|
|
61
|
+
platform: string,
|
|
62
|
+
home: string,
|
|
63
|
+
): string {
|
|
64
|
+
const override = env.PLAYWRIGHT_BROWSERS_PATH
|
|
65
|
+
if (override !== undefined && override !== '') return override
|
|
66
|
+
if (platform === 'darwin') return `${home}/Library/Caches/ms-playwright`
|
|
67
|
+
return `${home}/.cache/ms-playwright`
|
|
68
|
+
}
|
package/src/claude/cases/git.ts
CHANGED
|
@@ -22,6 +22,10 @@ export const GIT_CASES: readonly SkillCase[] = [
|
|
|
22
22
|
prompt: 'Turn this bug into a GitHub issue and file it.',
|
|
23
23
|
expect: 'git-issue',
|
|
24
24
|
},
|
|
25
|
+
{
|
|
26
|
+
prompt: 'Close #42 without merging, #57 supersedes it.',
|
|
27
|
+
expect: 'git-pr-close',
|
|
28
|
+
},
|
|
25
29
|
{
|
|
26
30
|
prompt: 'Write the title and body for this pull request.',
|
|
27
31
|
expect: 'git-pr',
|
|
@@ -149,6 +149,26 @@ export const WORKFLOW_CASES: readonly SkillCase[] = [
|
|
|
149
149
|
prompt: 'Tell me the paint and layout cost of this page right now.',
|
|
150
150
|
expect: 'ux-measure',
|
|
151
151
|
},
|
|
152
|
+
{
|
|
153
|
+
prompt:
|
|
154
|
+
'We just tagged the release. Spin up a copy of the build and use it with me as its users would, then file whatever breaks.',
|
|
155
|
+
expect: 'app-drive',
|
|
156
|
+
},
|
|
157
|
+
{
|
|
158
|
+
prompt:
|
|
159
|
+
'Before we plan the next wave, put the built app through every goal in the requirements with me and write up what we hit.',
|
|
160
|
+
expect: 'app-drive',
|
|
161
|
+
},
|
|
162
|
+
{
|
|
163
|
+
prompt:
|
|
164
|
+
'I need real footage of the board import flow for the launch video. Film it from our app so HyperFrames can zoom in on the clicks.',
|
|
165
|
+
expect: 'ui-record',
|
|
166
|
+
},
|
|
167
|
+
{
|
|
168
|
+
prompt:
|
|
169
|
+
'Shoot the takes for the storyboard we wrote, one clip per beat of the app doing its thing.',
|
|
170
|
+
expect: 'ui-record',
|
|
171
|
+
},
|
|
152
172
|
{
|
|
153
173
|
prompt:
|
|
154
174
|
'I am building this branch for another session. What am I on the hook for, and what is off limits?',
|
package/src/cli/help.ts
CHANGED
|
@@ -42,6 +42,8 @@ export const COMMAND_GROUPS: { title: string; rows: CommandRow[] }[] = [
|
|
|
42
42
|
['serve [dir]', 'Serve a directory on localhost, print the link'],
|
|
43
43
|
['inventory [subj]', 'Report one computed property across every route'],
|
|
44
44
|
['drive <url> <run>', 'Walk a page through interactions, measure each'],
|
|
45
|
+
['browser [args]', 'Drive the pinned agent browser CLI'],
|
|
46
|
+
['take <url> <plan>', 'Film a web app with a click log and camera track'],
|
|
45
47
|
['transcripts <url>', 'Fetch a YouTube transcript with frontmatter'],
|
|
46
48
|
],
|
|
47
49
|
},
|
|
@@ -100,6 +102,7 @@ const EXAMPLES = [
|
|
|
100
102
|
'canon capture assets/captures/install.html --selector .window --out assets/frames',
|
|
101
103
|
'canon inventory focus --json',
|
|
102
104
|
'canon drive http://localhost:4173 run.json --json',
|
|
105
|
+
'canon take http://localhost:4173 plan.json --out takes --json',
|
|
103
106
|
'pbpaste | canon feedback',
|
|
104
107
|
'canon transcripts https://youtu.be/VIDEO_ID',
|
|
105
108
|
'canon tasks --help --all',
|
package/src/cli.ts
CHANGED
|
@@ -14,6 +14,8 @@ import { register as capture } from '@/commands/capture'
|
|
|
14
14
|
import { register as serve } from '@/commands/serve'
|
|
15
15
|
import { register as inventory } from '@/commands/inventory'
|
|
16
16
|
import { register as driver } from '@/commands/driver'
|
|
17
|
+
import { register as browser } from '@/commands/browser'
|
|
18
|
+
import { register as take } from '@/commands/take'
|
|
17
19
|
import { register as feedback } from '@/commands/feedback'
|
|
18
20
|
import { register as transcripts } from '@/commands/transcripts'
|
|
19
21
|
import { register as tasks } from '@/commands/tasks'
|
|
@@ -78,6 +80,8 @@ capture(program)
|
|
|
78
80
|
serve(program)
|
|
79
81
|
inventory(program)
|
|
80
82
|
driver(program)
|
|
83
|
+
browser(program)
|
|
84
|
+
take(program)
|
|
81
85
|
feedback(program)
|
|
82
86
|
transcripts(program)
|
|
83
87
|
tasks(program)
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process'
|
|
2
|
+
import { existsSync } from 'node:fs'
|
|
3
|
+
import { constants, homedir } from 'node:os'
|
|
4
|
+
import type { Command } from 'commander'
|
|
5
|
+
import {
|
|
6
|
+
AGENT_CLI,
|
|
7
|
+
CHROMIUM_REVISION,
|
|
8
|
+
INSTALL_AGENT_BROWSER,
|
|
9
|
+
browsersRoot,
|
|
10
|
+
planAgentCall,
|
|
11
|
+
} from '@/browser/agent-cli'
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Every byte on stdout and stderr belongs to the child, which skills read
|
|
15
|
+
* snapshot paths and `--raw eval` values off, so this verb frames nothing and
|
|
16
|
+
* marks its own refusals with a prefix rather than a record.
|
|
17
|
+
*/
|
|
18
|
+
const FORWARDED_SIGNALS = ['SIGINT', 'SIGTERM', 'SIGHUP'] as const
|
|
19
|
+
|
|
20
|
+
export function register(program: Command): void {
|
|
21
|
+
program
|
|
22
|
+
.command('browser')
|
|
23
|
+
.description('Drive the pinned agent browser CLI')
|
|
24
|
+
.argument('[args...]', 'Words passed to the browser CLI untouched')
|
|
25
|
+
.helpOption('-h, --help', 'Show this help message')
|
|
26
|
+
.allowUnknownOption()
|
|
27
|
+
.allowExcessArguments(true)
|
|
28
|
+
.passThroughOptions()
|
|
29
|
+
.addHelpText(
|
|
30
|
+
'after',
|
|
31
|
+
[
|
|
32
|
+
'',
|
|
33
|
+
`Runs ${AGENT_CLI} through bunx in the current directory, so every`,
|
|
34
|
+
'skill driving a page reaches one version. Snapshots land under',
|
|
35
|
+
'.playwright-cli/ in the folder it runs in.',
|
|
36
|
+
'',
|
|
37
|
+
'open is forced onto the bundled Chromium unless the call names a',
|
|
38
|
+
`--browser, and refuses rather than downloading when revision ${CHROMIUM_REVISION}`,
|
|
39
|
+
'is absent. Install it once, outside any pass, with:',
|
|
40
|
+
` ${INSTALL_AGENT_BROWSER}`,
|
|
41
|
+
'',
|
|
42
|
+
"Stdout, stderr, and the exit code are the browser CLI's. A refusal of",
|
|
43
|
+
"this verb's own reads 'canon browser:' on stderr.",
|
|
44
|
+
'',
|
|
45
|
+
'Exit codes:',
|
|
46
|
+
" n the browser CLI's own exit code",
|
|
47
|
+
' 1 refused, with the reason on stderr',
|
|
48
|
+
'',
|
|
49
|
+
'Examples:',
|
|
50
|
+
' canon browser --version',
|
|
51
|
+
' canon browser -s=canon-12 open http://localhost:4321',
|
|
52
|
+
' canon browser -s=canon-12 --raw eval "document.title"',
|
|
53
|
+
'',
|
|
54
|
+
].join('\n'),
|
|
55
|
+
)
|
|
56
|
+
.action(async (args: string[]) => {
|
|
57
|
+
process.exitCode = await runBrowser(args)
|
|
58
|
+
})
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function runBrowser(args: readonly string[]): Promise<number> {
|
|
62
|
+
if (Bun.which('bunx') === null) {
|
|
63
|
+
return refuse('bunx is not on PATH, so the pinned CLI cannot run')
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const root = browsersRoot(process.env, process.platform, homedir())
|
|
67
|
+
const call = planAgentCall(args, () =>
|
|
68
|
+
existsSync(`${root}/chromium-${CHROMIUM_REVISION}`),
|
|
69
|
+
)
|
|
70
|
+
if (call.kind === 'refused') return refuse(call.message)
|
|
71
|
+
|
|
72
|
+
const child = spawn('bunx', ['-y', AGENT_CLI, ...call.args], {
|
|
73
|
+
stdio: 'inherit',
|
|
74
|
+
})
|
|
75
|
+
// A killed pass must not leave a browser running behind the parent, which a
|
|
76
|
+
// shell `exec` never had between the caller and the child.
|
|
77
|
+
const forward = (signal: NodeJS.Signals) => () => child.kill(signal)
|
|
78
|
+
const handlers = FORWARDED_SIGNALS.map((signal) => {
|
|
79
|
+
const handler = forward(signal)
|
|
80
|
+
process.on(signal, handler)
|
|
81
|
+
return [signal, handler] as const
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
try {
|
|
85
|
+
return await new Promise<number>((resolve) => {
|
|
86
|
+
child.on('error', (error) => {
|
|
87
|
+
process.stderr.write(`canon browser: ${error.message}\n`)
|
|
88
|
+
resolve(1)
|
|
89
|
+
})
|
|
90
|
+
child.on('exit', (code, signal) => {
|
|
91
|
+
resolve(code ?? 128 + (signal ? constants.signals[signal] : 0))
|
|
92
|
+
})
|
|
93
|
+
})
|
|
94
|
+
} finally {
|
|
95
|
+
for (const [signal, handler] of handlers) process.off(signal, handler)
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function refuse(message: string): number {
|
|
100
|
+
process.stderr.write(`canon browser: ${message}\n`)
|
|
101
|
+
return 1
|
|
102
|
+
}
|
package/src/commands/intake.ts
CHANGED
|
@@ -10,6 +10,14 @@ import {
|
|
|
10
10
|
readFolder,
|
|
11
11
|
type Selection,
|
|
12
12
|
} from '@/intake/folder'
|
|
13
|
+
import {
|
|
14
|
+
type BaselineOutcome,
|
|
15
|
+
type CheckOutcome,
|
|
16
|
+
checkDrive,
|
|
17
|
+
type DriveFindings,
|
|
18
|
+
type DriveRefused,
|
|
19
|
+
recordBaseline,
|
|
20
|
+
} from '@/intake/drive-check'
|
|
13
21
|
import { isUnread } from '@/intake/items'
|
|
14
22
|
import {
|
|
15
23
|
intro,
|
|
@@ -37,6 +45,12 @@ interface ListCommandOptions {
|
|
|
37
45
|
readonly root?: string
|
|
38
46
|
}
|
|
39
47
|
|
|
48
|
+
interface DriveCheckCommandOptions {
|
|
49
|
+
readonly baseline?: boolean
|
|
50
|
+
readonly json?: boolean
|
|
51
|
+
readonly root?: string
|
|
52
|
+
}
|
|
53
|
+
|
|
40
54
|
interface AnswerCommandOptions {
|
|
41
55
|
readonly cluster?: string
|
|
42
56
|
readonly json?: boolean
|
|
@@ -143,6 +157,67 @@ export function register(program: Command): void {
|
|
|
143
157
|
.action(async (slug: string, opts: AnswerCommandOptions) => {
|
|
144
158
|
process.exitCode = await runAnswer(slug, opts)
|
|
145
159
|
})
|
|
160
|
+
|
|
161
|
+
intake
|
|
162
|
+
.command('drive-check')
|
|
163
|
+
.description(
|
|
164
|
+
'Check an app drive against its goals, the checkout, and its evidence',
|
|
165
|
+
)
|
|
166
|
+
.argument('<slug>', 'Intake folder name, as in app-drive-1-0')
|
|
167
|
+
.helpOption('-h, --help', 'Show this help message')
|
|
168
|
+
.option(
|
|
169
|
+
'--baseline',
|
|
170
|
+
'Record the main checkout status before the drive, rather than check',
|
|
171
|
+
)
|
|
172
|
+
.option('--json', 'Emit a machine-readable record on stdout')
|
|
173
|
+
.option('--root <path>', 'Project root, defaulting to the main worktree')
|
|
174
|
+
.addHelpText(
|
|
175
|
+
'after',
|
|
176
|
+
[
|
|
177
|
+
'',
|
|
178
|
+
'With --baseline it stores the main checkout status at',
|
|
179
|
+
'.canon/tmp/app-drive/<slug>/baseline.json, any ordinal dropped from',
|
|
180
|
+
'the slug, so it runs before the intake folder exists. Without it, it',
|
|
181
|
+
'reads drive.json in the intake folder and fails the drive on:',
|
|
182
|
+
' unmapped a ## Goals bullet the record carries no entry for',
|
|
183
|
+
' unreasoned a goal marked not-driven with no reason',
|
|
184
|
+
' unknown an entry naming no goal the requirements hold',
|
|
185
|
+
' changed a checkout path that differs from the baseline',
|
|
186
|
+
' strayEvidence an evidence path outside the folder evidence/',
|
|
187
|
+
' missingEvidence an evidence path that holds no file',
|
|
188
|
+
' worktreeLeft the drive worktree the record names, still on disk',
|
|
189
|
+
'',
|
|
190
|
+
'A goal matches by its text with any bold label and trailing period',
|
|
191
|
+
'dropped. A changed path is named and never reverted.',
|
|
192
|
+
'',
|
|
193
|
+
'Read `reason` off the record: `recorded` for a baseline, `clean` or',
|
|
194
|
+
'`failed` for a check, and a refusal otherwise.',
|
|
195
|
+
'',
|
|
196
|
+
'Refusals, read off `reason`:',
|
|
197
|
+
' no-intake the project holds no intake folder',
|
|
198
|
+
' no-folder no folder matches the slug',
|
|
199
|
+
' ambiguous-slug a bare slug matches more than one folder',
|
|
200
|
+
' bad-input the slug could leave the scratch folder',
|
|
201
|
+
' no-record the folder carries no drive.json',
|
|
202
|
+
' bad-record drive.json does not parse or breaks its shape',
|
|
203
|
+
' no-requirements the project carries no requirements record',
|
|
204
|
+
' no-baseline no baseline was recorded for the slug',
|
|
205
|
+
' unreadable-tree git cannot read the main checkout status',
|
|
206
|
+
'',
|
|
207
|
+
'Exit codes:',
|
|
208
|
+
' 0 the baseline was recorded, or the drive is clean',
|
|
209
|
+
' 1 refused, with the reason on stderr or in the JSON record',
|
|
210
|
+
' 2 the drive failed, with every finding in the record',
|
|
211
|
+
'',
|
|
212
|
+
'Examples:',
|
|
213
|
+
' canon intake drive-check app-drive-1-0 --baseline --json',
|
|
214
|
+
' canon intake drive-check app-drive-1-0 --json',
|
|
215
|
+
'',
|
|
216
|
+
].join('\n'),
|
|
217
|
+
)
|
|
218
|
+
.action(async (slug: string, opts: DriveCheckCommandOptions) => {
|
|
219
|
+
process.exitCode = await runDriveCheck(slug, opts)
|
|
220
|
+
})
|
|
146
221
|
}
|
|
147
222
|
|
|
148
223
|
function collectSelection(value: string, previous: string[]): string[] {
|
|
@@ -262,9 +337,23 @@ async function runAnswer(
|
|
|
262
337
|
return reportAnswer(outcome, emitJson, root)
|
|
263
338
|
}
|
|
264
339
|
|
|
340
|
+
async function runDriveCheck(
|
|
341
|
+
slug: string,
|
|
342
|
+
opts: DriveCheckCommandOptions,
|
|
343
|
+
): Promise<number> {
|
|
344
|
+
const emitJson = opts.json ?? false
|
|
345
|
+
const root = opts.root ?? (await mainWorktreeRoot())
|
|
346
|
+
|
|
347
|
+
if (opts.baseline) {
|
|
348
|
+
return reportBaseline(await recordBaseline(root, slug), emitJson, root)
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
return reportDriveCheck(await checkDrive(root, slug), emitJson, root)
|
|
352
|
+
}
|
|
353
|
+
|
|
265
354
|
function reportRefusal(
|
|
266
355
|
title: string,
|
|
267
|
-
refused: IntakeRefused,
|
|
356
|
+
refused: IntakeRefused | DriveRefused,
|
|
268
357
|
emitJson: boolean,
|
|
269
358
|
root: string,
|
|
270
359
|
): number {
|
|
@@ -421,3 +510,94 @@ function reportAnswer(
|
|
|
421
510
|
|
|
422
511
|
return 0
|
|
423
512
|
}
|
|
513
|
+
|
|
514
|
+
function reportBaseline(
|
|
515
|
+
outcome: BaselineOutcome,
|
|
516
|
+
emitJson: boolean,
|
|
517
|
+
root: string,
|
|
518
|
+
): number {
|
|
519
|
+
if (!outcome.ok) {
|
|
520
|
+
return reportRefusal('canon intake drive-check', outcome, emitJson, root)
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
const path = relative(root, outcome.path)
|
|
524
|
+
|
|
525
|
+
if (emitJson) {
|
|
526
|
+
process.stdout.write(
|
|
527
|
+
`${JSON.stringify({
|
|
528
|
+
ok: true,
|
|
529
|
+
root,
|
|
530
|
+
slug: outcome.slug,
|
|
531
|
+
reason: 'recorded',
|
|
532
|
+
baseline: path,
|
|
533
|
+
entries: outcome.entries,
|
|
534
|
+
})}\n`,
|
|
535
|
+
)
|
|
536
|
+
return 0
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
intro('canon intake drive-check')
|
|
540
|
+
logStep('Baseline')
|
|
541
|
+
logInfo(`Recorded ${outcome.entries} status line(s).`)
|
|
542
|
+
logAdd(path)
|
|
543
|
+
outro()
|
|
544
|
+
|
|
545
|
+
return 0
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
const FINDING_LABELS: Record<keyof DriveFindings, string> = {
|
|
549
|
+
unmapped: 'Goals with no entry',
|
|
550
|
+
unreasoned: 'Goals not driven, with no reason',
|
|
551
|
+
unknown: 'Entries naming no goal',
|
|
552
|
+
changed: 'Changed in the checkout',
|
|
553
|
+
strayEvidence: 'Evidence outside evidence/',
|
|
554
|
+
missingEvidence: 'Evidence holding no file',
|
|
555
|
+
worktreeLeft: 'Drive worktree still on disk',
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
function reportDriveCheck(
|
|
559
|
+
outcome: CheckOutcome,
|
|
560
|
+
emitJson: boolean,
|
|
561
|
+
root: string,
|
|
562
|
+
): number {
|
|
563
|
+
if (!outcome.ok) {
|
|
564
|
+
return reportRefusal('canon intake drive-check', outcome, emitJson, root)
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
const code = outcome.reason === 'clean' ? 0 : 2
|
|
568
|
+
|
|
569
|
+
if (emitJson) {
|
|
570
|
+
process.stdout.write(
|
|
571
|
+
`${JSON.stringify({
|
|
572
|
+
ok: true,
|
|
573
|
+
root,
|
|
574
|
+
slug: outcome.slug,
|
|
575
|
+
reason: outcome.reason,
|
|
576
|
+
goals: outcome.goals,
|
|
577
|
+
findings: outcome.findings,
|
|
578
|
+
})}\n`,
|
|
579
|
+
)
|
|
580
|
+
return code
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
intro('canon intake drive-check')
|
|
584
|
+
|
|
585
|
+
if (outcome.reason === 'clean') {
|
|
586
|
+
logStep('Clean')
|
|
587
|
+
logInfo(
|
|
588
|
+
`${outcome.goals} goal(s) mapped, the checkout untouched, the evidence in place.`,
|
|
589
|
+
)
|
|
590
|
+
outro()
|
|
591
|
+
return code
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
for (const [key, label] of Object.entries(FINDING_LABELS)) {
|
|
595
|
+
const list = outcome.findings[key as keyof DriveFindings]
|
|
596
|
+
if (list.length === 0) continue
|
|
597
|
+
logStep(label)
|
|
598
|
+
for (const entry of list) logError(entry)
|
|
599
|
+
}
|
|
600
|
+
outro()
|
|
601
|
+
|
|
602
|
+
return code
|
|
603
|
+
}
|
package/src/commands/labels.ts
CHANGED
|
@@ -14,9 +14,10 @@ import {
|
|
|
14
14
|
} from '@/labels/density'
|
|
15
15
|
import { resolveScanInput } from '@/labels/event'
|
|
16
16
|
import { checkTitleFormat, type TitleFormatIssue } from '@/labels/format'
|
|
17
|
-
import { MAP_REL } from '@/labels/map'
|
|
17
|
+
import { MAP_REL, readLabelMap } from '@/labels/map'
|
|
18
18
|
import { isReleasePullRequest, scanPhaseLabels } from '@/labels/phase'
|
|
19
19
|
import { checkSections } from '@/labels/sections'
|
|
20
|
+
import { missingLabels } from '@/labels/sync'
|
|
20
21
|
import { BAN_SETS } from '@/markdown/bans'
|
|
21
22
|
import { bodyLines, scanBans } from '@/markdown/scan'
|
|
22
23
|
import { intro, logInfo, logStep, logWarn, outro, plural } from '@/cli/ui'
|
|
@@ -33,6 +34,12 @@ interface ApplyOptions extends AuditOptions {
|
|
|
33
34
|
|
|
34
35
|
type ApplyRefusal = 'bad-number' | 'no-branch' | 'gh-failed' | 'head-mismatch'
|
|
35
36
|
|
|
37
|
+
interface SyncOptions {
|
|
38
|
+
readonly root?: string
|
|
39
|
+
readonly check?: boolean
|
|
40
|
+
readonly json?: boolean
|
|
41
|
+
}
|
|
42
|
+
|
|
36
43
|
const GH_TIMEOUT_MS = 30_000
|
|
37
44
|
|
|
38
45
|
interface ScanOptions {
|
|
@@ -187,6 +194,48 @@ export function register(program: Command): void {
|
|
|
187
194
|
process.exitCode = await runApply(number, opts)
|
|
188
195
|
})
|
|
189
196
|
|
|
197
|
+
labels
|
|
198
|
+
.command('sync')
|
|
199
|
+
.description(
|
|
200
|
+
'Create on the remote each label the map names that it lacks, never editing or deleting one',
|
|
201
|
+
)
|
|
202
|
+
.helpOption('-h, --help', 'Show this help message')
|
|
203
|
+
.option('--root <path>', 'Repository to read, defaulting to the cwd')
|
|
204
|
+
.option('--check', 'Report the labels missing and write nothing')
|
|
205
|
+
.option('--json', 'Add a machine-readable record on stdout')
|
|
206
|
+
.addHelpText(
|
|
207
|
+
'after',
|
|
208
|
+
[
|
|
209
|
+
'',
|
|
210
|
+
`Reads the label names under [domains] in ${MAP_REL}, reads the remote's`,
|
|
211
|
+
'labels over REST, and creates each one the remote lacks. A label already',
|
|
212
|
+
'on the remote is left as it is, and one the map does not name is never',
|
|
213
|
+
'touched. A project declaring no map creates nothing and exits 0.',
|
|
214
|
+
'',
|
|
215
|
+
'Run it once after installing a map and again after adding a row, so',
|
|
216
|
+
'`canon labels apply` finds every label it earns.',
|
|
217
|
+
'',
|
|
218
|
+
'Record: labels, present, created, missing, failed',
|
|
219
|
+
'',
|
|
220
|
+
'Exit codes:',
|
|
221
|
+
' 0 the remote carries every label the map names after the run',
|
|
222
|
+
' 1 refused, with the reason on stderr or in the JSON record',
|
|
223
|
+
' 2 labels are still missing, being any under --check, or a failed',
|
|
224
|
+
' create otherwise',
|
|
225
|
+
'',
|
|
226
|
+
'Reasons:',
|
|
227
|
+
' gh-failed the remote labels could not be read, so nothing was written',
|
|
228
|
+
'',
|
|
229
|
+
'Examples:',
|
|
230
|
+
' canon labels sync',
|
|
231
|
+
' canon labels sync --check --json',
|
|
232
|
+
'',
|
|
233
|
+
].join('\n'),
|
|
234
|
+
)
|
|
235
|
+
.action(async (opts: SyncOptions) => {
|
|
236
|
+
process.exitCode = await runSync(opts)
|
|
237
|
+
})
|
|
238
|
+
|
|
190
239
|
labels
|
|
191
240
|
.command('scan')
|
|
192
241
|
.description(
|
|
@@ -544,9 +593,7 @@ async function runApply(number: string, opts: ApplyOptions): Promise<number> {
|
|
|
544
593
|
}
|
|
545
594
|
|
|
546
595
|
for (const label of diff.missingOnRemote) {
|
|
547
|
-
logWarn(
|
|
548
|
-
`${label} is not on the remote. Create it with: gh label create ${label}`,
|
|
549
|
-
)
|
|
596
|
+
logWarn(`${label} is not on the remote. Create it with: canon labels sync`)
|
|
550
597
|
}
|
|
551
598
|
for (const label of failed) logWarn(`Label apply failed: ${label}`)
|
|
552
599
|
|
|
@@ -577,6 +624,107 @@ async function runApply(number: string, opts: ApplyOptions): Promise<number> {
|
|
|
577
624
|
return owed.length === 0 ? 0 : 2
|
|
578
625
|
}
|
|
579
626
|
|
|
627
|
+
async function runSync(opts: SyncOptions): Promise<number> {
|
|
628
|
+
const root = resolve(opts.root ?? process.cwd())
|
|
629
|
+
const emitJson = opts.json ?? false
|
|
630
|
+
const isCheck = opts.check ?? false
|
|
631
|
+
|
|
632
|
+
intro('canon labels sync')
|
|
633
|
+
|
|
634
|
+
const map = readLabelMap(root)
|
|
635
|
+
|
|
636
|
+
if (map.kind === 'refused' && map.reason === 'no-map') {
|
|
637
|
+
logStep('Labels')
|
|
638
|
+
logInfo(REFUSALS['no-map'])
|
|
639
|
+
outro()
|
|
640
|
+
if (emitJson) {
|
|
641
|
+
process.stdout.write(
|
|
642
|
+
`${JSON.stringify({
|
|
643
|
+
reason: 'no-map',
|
|
644
|
+
labels: [],
|
|
645
|
+
present: [],
|
|
646
|
+
created: [],
|
|
647
|
+
missing: [],
|
|
648
|
+
failed: [],
|
|
649
|
+
})}\n`,
|
|
650
|
+
)
|
|
651
|
+
}
|
|
652
|
+
return 0
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
const refuse = (reason: LabelAuditRefusal | 'gh-failed'): number => {
|
|
656
|
+
const message =
|
|
657
|
+
reason === 'gh-failed'
|
|
658
|
+
? 'gh could not read the remote labels, so nothing was written.'
|
|
659
|
+
: REFUSALS[reason]
|
|
660
|
+
logStep('Refused')
|
|
661
|
+
logWarn(message)
|
|
662
|
+
outro()
|
|
663
|
+
if (emitJson) {
|
|
664
|
+
process.stdout.write(`${JSON.stringify({ root, reason, message })}\n`)
|
|
665
|
+
}
|
|
666
|
+
return 1
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
if (map.kind === 'refused') return refuse(map.reason)
|
|
670
|
+
|
|
671
|
+
const remoteRaw = await gh(root, [
|
|
672
|
+
'api',
|
|
673
|
+
'--paginate',
|
|
674
|
+
'repos/{owner}/{repo}/labels',
|
|
675
|
+
'--jq',
|
|
676
|
+
'.[].name',
|
|
677
|
+
])
|
|
678
|
+
if (remoteRaw === null) return refuse('gh-failed')
|
|
679
|
+
|
|
680
|
+
const labels = map.domains.map((row) => row.label)
|
|
681
|
+
const present = nonEmptyLines(remoteRaw)
|
|
682
|
+
const toCreate = missingLabels(labels, present)
|
|
683
|
+
|
|
684
|
+
const created: string[] = []
|
|
685
|
+
const failed: string[] = []
|
|
686
|
+
|
|
687
|
+
if (!isCheck) {
|
|
688
|
+
for (const label of toCreate) {
|
|
689
|
+
const wrote = await gh(root, [
|
|
690
|
+
'api',
|
|
691
|
+
'-X',
|
|
692
|
+
'POST',
|
|
693
|
+
'repos/{owner}/{repo}/labels',
|
|
694
|
+
'-f',
|
|
695
|
+
`name=${label}`,
|
|
696
|
+
'--silent',
|
|
697
|
+
])
|
|
698
|
+
if (wrote === null) failed.push(label)
|
|
699
|
+
else created.push(label)
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
const missing = toCreate.filter((label) => !created.includes(label))
|
|
704
|
+
|
|
705
|
+
logStep('Labels')
|
|
706
|
+
logInfo(labels.join(', '))
|
|
707
|
+
|
|
708
|
+
if (isCheck) {
|
|
709
|
+
logStep(missing.length === 0 ? 'Nothing missing' : 'Missing')
|
|
710
|
+
for (const label of missing) logWarn(label)
|
|
711
|
+
} else {
|
|
712
|
+
logStep('Created')
|
|
713
|
+
logInfo(created.length === 0 ? 'no label was created' : created.join(', '))
|
|
714
|
+
}
|
|
715
|
+
for (const label of failed) logWarn(`Label create failed: ${label}`)
|
|
716
|
+
|
|
717
|
+
outro()
|
|
718
|
+
|
|
719
|
+
if (emitJson) {
|
|
720
|
+
process.stdout.write(
|
|
721
|
+
`${JSON.stringify({ labels, present, created, missing, failed })}\n`,
|
|
722
|
+
)
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
return missing.length === 0 ? 0 : 2
|
|
726
|
+
}
|
|
727
|
+
|
|
580
728
|
/**
|
|
581
729
|
* Reads `--closes` as the issues the body must close. Undefined when the flag
|
|
582
730
|
* is absent, which skips the check, and a message when the value is neither
|