@erclx/canon 4.69.0 → 4.71.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/README.md +1 -1
- package/claude/.claude-plugin/plugin.json +1 -1
- package/claude/skills/auto-ship/SKILL.md +1 -1
- package/claude/skills/canon-cli/SKILL.md +4 -0
- package/claude/skills/deploy-cloudflare/REQUIREMENT.md +37 -0
- package/claude/skills/deploy-cloudflare/SKILL.md +61 -0
- package/claude/skills/draft-and-pick/REQUIREMENT.md +1 -1
- package/claude/skills/draft-and-pick/SKILL.md +1 -1
- package/claude/skills/{identity → draft-identity}/REQUIREMENT.md +2 -2
- package/claude/skills/{identity → draft-identity}/SKILL.md +2 -2
- 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/claude/skills/ux-audit/SKILL.md +3 -0
- package/docs/agents/commands.md +2 -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/tasks.md +46 -3
- package/docs/workflow/ai-workflow.md +19 -19
- package/docs/workflow/visual-design-workflow.md +1 -1
- package/package.json +3 -2
- package/scripts/core/regen-web-previews.ts +94 -0
- package/src/claude/cases/misc.ts +5 -1
- package/src/claude/cases/workflow.ts +4 -4
- package/src/commands/demo.ts +1 -1
- package/src/commands/pr.ts +130 -1
- package/src/commands/tasks.ts +178 -1
- package/src/commands/teach.ts +2 -0
- package/src/demo/beats.ts +1 -1
- package/src/gate/measures.ts +66 -0
- package/src/gate/stages.ts +11 -0
- package/src/migrate/skill-names.ts +25 -1
- package/src/pr/review-scope.ts +177 -0
- package/src/tasks/archive.ts +206 -3
- package/src/tasks/label.ts +14 -6
- package/src/tasks/validate.ts +22 -0
- package/src/teach/workspace.ts +60 -23
- package/standards/plan.md +1 -1
- package/standards/tasks.md +15 -1
- package/tooling/cloudflare/configs/.github/workflows/deploy.yml +103 -0
- package/tooling/cloudflare/manifest.toml +5 -0
- package/tooling/cloudflare/reference.md +24 -0
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Writes `web/public/previews/design-tokens/index.html` and `design.css` by
|
|
3
|
+
* calling `renderDesignDoc` directly, the in-process pattern
|
|
4
|
+
* `regen-web-favicon.ts` already takes rather than spawning a subprocess.
|
|
5
|
+
* Writes `web/public/previews/teach-workspace/` the same way, calling
|
|
6
|
+
* `generateNav` over an isolated copy of one workspace.
|
|
7
|
+
*
|
|
8
|
+
* Both are embedded live in a landing section as an `<iframe>` rather than a
|
|
9
|
+
* screenshot, since `web/e2e/home.spec.ts` asserts no `<img>` renders inside
|
|
10
|
+
* `<main>`.
|
|
11
|
+
*/
|
|
12
|
+
import { cpSync, existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'
|
|
13
|
+
import { tmpdir } from 'node:os'
|
|
14
|
+
import { join } from 'node:path'
|
|
15
|
+
import { DESIGN_DOCUMENT } from '@/design/regen'
|
|
16
|
+
import { renderDesignDoc } from '@/design/render'
|
|
17
|
+
import { generateNav } from '@/teach/nav'
|
|
18
|
+
|
|
19
|
+
const root = new URL('../..', import.meta.url).pathname
|
|
20
|
+
|
|
21
|
+
const { cssPath } = renderDesignDoc(
|
|
22
|
+
`${root}${DESIGN_DOCUMENT}`,
|
|
23
|
+
`${root}web/public/previews/design-tokens`,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
const css = readFileSync(cssPath, 'utf8')
|
|
27
|
+
if (!/--(color|space|type|radius)-/.test(css)) {
|
|
28
|
+
console.error(
|
|
29
|
+
'regen-web-previews: design-tokens render carries no custom properties, refusing an empty preview',
|
|
30
|
+
)
|
|
31
|
+
process.exit(1)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
console.log('regen-web-previews: wrote web/public/previews/design-tokens/')
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The workspace ships alone, on its own isolated nav tree, rather than
|
|
38
|
+
* copied straight out of `.canon/teach/`. That folder cross-links every
|
|
39
|
+
* sibling workspace by relative path, including the operator's own personal
|
|
40
|
+
* study notes, so a copy carrying its live chrome would ship either dead
|
|
41
|
+
* links or a path into content never meant to be public. Regenerating the
|
|
42
|
+
* chrome over a scratch copy holding this one workspace alone is what keeps
|
|
43
|
+
* the jump menus from naming anything else.
|
|
44
|
+
*
|
|
45
|
+
* `.canon/teach/` is gitignored session scratch, per `standards/teach.md`, so
|
|
46
|
+
* it exists on the machine that authored the lesson and nowhere else,
|
|
47
|
+
* including CI. `web/public/previews/teach-workspace/` is therefore the
|
|
48
|
+
* durable copy: committed once the workspace exists, regenerated here as a
|
|
49
|
+
* local convenience when authoring continues, and left untouched by a build
|
|
50
|
+
* that cannot see the source, the same way a target without Playwright still
|
|
51
|
+
* builds against a committed capture.
|
|
52
|
+
*/
|
|
53
|
+
const TEACH_SLUG = '04-governance-rule-loading'
|
|
54
|
+
const teachSource = `${root}.canon/teach/${TEACH_SLUG}`
|
|
55
|
+
|
|
56
|
+
if (!existsSync(teachSource)) {
|
|
57
|
+
console.log(
|
|
58
|
+
`regen-web-previews: no workspace at .canon/teach/${TEACH_SLUG} on this machine, leaving the committed web/public/previews/teach-workspace/ as is`,
|
|
59
|
+
)
|
|
60
|
+
} else {
|
|
61
|
+
const scratchRoot = mkdtempSync(join(tmpdir(), 'canon-teach-preview-'))
|
|
62
|
+
try {
|
|
63
|
+
cpSync(teachSource, join(scratchRoot, '.canon', 'teach', TEACH_SLUG), {
|
|
64
|
+
recursive: true,
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
const nav = await generateNav(scratchRoot)
|
|
68
|
+
if (!nav.ok) {
|
|
69
|
+
console.error(`regen-web-previews: ${nav.message}`)
|
|
70
|
+
process.exit(1)
|
|
71
|
+
}
|
|
72
|
+
if (nav.lessons === 0) {
|
|
73
|
+
console.error(
|
|
74
|
+
'regen-web-previews: teach-workspace render carries no lesson, refusing an empty preview',
|
|
75
|
+
)
|
|
76
|
+
process.exit(1)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const dest = `${root}web/public/previews/teach-workspace`
|
|
80
|
+
rmSync(dest, { recursive: true, force: true })
|
|
81
|
+
cpSync(join(scratchRoot, '.canon', 'teach'), dest, {
|
|
82
|
+
recursive: true,
|
|
83
|
+
// Only the rendered chrome ships. MISSION.md, RESOURCES.md, and
|
|
84
|
+
// GLOSSARY.md carry links relative to `.canon/teach/`, which resolve to
|
|
85
|
+
// nothing once copied under `web/public/`, and nothing links to them
|
|
86
|
+
// from the rendered pages anyway.
|
|
87
|
+
filter: (path) => !path.endsWith('.md'),
|
|
88
|
+
})
|
|
89
|
+
} finally {
|
|
90
|
+
rmSync(scratchRoot, { recursive: true, force: true })
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
console.log('regen-web-previews: wrote web/public/previews/teach-workspace/')
|
|
94
|
+
}
|
package/src/claude/cases/misc.ts
CHANGED
|
@@ -15,7 +15,7 @@ export const MISC_CASES: readonly SkillCase[] = [
|
|
|
15
15
|
{
|
|
16
16
|
prompt:
|
|
17
17
|
'This project has no logo yet. Draft one and give me a social card to go with it.',
|
|
18
|
-
expect: 'identity',
|
|
18
|
+
expect: 'draft-identity',
|
|
19
19
|
},
|
|
20
20
|
{
|
|
21
21
|
prompt: 'Fire up the dev server the way this project documents it.',
|
|
@@ -47,4 +47,8 @@ export const MISC_CASES: readonly SkillCase[] = [
|
|
|
47
47
|
prompt: 'Is there a page anywhere in this repo that covers retries?',
|
|
48
48
|
expect: 'index-lookup',
|
|
49
49
|
},
|
|
50
|
+
{
|
|
51
|
+
prompt: 'Connect this repo to Cloudflare Pages and set up the deploy.',
|
|
52
|
+
expect: 'deploy-cloudflare',
|
|
53
|
+
},
|
|
50
54
|
]
|
|
@@ -41,7 +41,7 @@ export const WORKFLOW_CASES: readonly SkillCase[] = [
|
|
|
41
41
|
{
|
|
42
42
|
prompt:
|
|
43
43
|
'The video just finished recording. Read it back and tell me what each part shows.',
|
|
44
|
-
expect: '
|
|
44
|
+
expect: 'read-frames',
|
|
45
45
|
},
|
|
46
46
|
{
|
|
47
47
|
prompt:
|
|
@@ -87,7 +87,7 @@ export const WORKFLOW_CASES: readonly SkillCase[] = [
|
|
|
87
87
|
{
|
|
88
88
|
prompt:
|
|
89
89
|
'The screencast draft is finished. Turn it into an actual video now.',
|
|
90
|
-
expect: '
|
|
90
|
+
expect: 'record-screencast',
|
|
91
91
|
},
|
|
92
92
|
{
|
|
93
93
|
prompt:
|
|
@@ -96,7 +96,7 @@ export const WORKFLOW_CASES: readonly SkillCase[] = [
|
|
|
96
96
|
},
|
|
97
97
|
{
|
|
98
98
|
prompt: 'Draft me a script with beats for a screencast recording.',
|
|
99
|
-
expect: '
|
|
99
|
+
expect: 'draft-screencast',
|
|
100
100
|
},
|
|
101
101
|
{
|
|
102
102
|
prompt:
|
|
@@ -105,7 +105,7 @@ export const WORKFLOW_CASES: readonly SkillCase[] = [
|
|
|
105
105
|
},
|
|
106
106
|
{
|
|
107
107
|
prompt: 'Turn this topic into a slide deck I can render.',
|
|
108
|
-
expect: '
|
|
108
|
+
expect: 'draft-slides',
|
|
109
109
|
},
|
|
110
110
|
{
|
|
111
111
|
prompt:
|
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/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/tasks.ts
CHANGED
|
@@ -1,11 +1,15 @@
|
|
|
1
1
|
import { relative } from 'node:path'
|
|
2
2
|
import type { Command } from 'commander'
|
|
3
|
+
import { execa } from 'execa'
|
|
4
|
+
import { gitEnv } from '@/git-env'
|
|
3
5
|
import { type AnswersOutcome, planAnswers } from '@/tasks/answers'
|
|
4
6
|
import { type BranchOutcome, planBranch } from '@/tasks/branch'
|
|
5
7
|
import {
|
|
6
8
|
type ArchiveOutcome,
|
|
7
9
|
archiveTask,
|
|
8
10
|
type CitationOutcome,
|
|
11
|
+
type DeclineOutcome,
|
|
12
|
+
declineTask,
|
|
9
13
|
type PlanCitations,
|
|
10
14
|
planCitations,
|
|
11
15
|
} from '@/tasks/archive'
|
|
@@ -44,12 +48,22 @@ import { mainWorktreeRoot } from '@/worktree'
|
|
|
44
48
|
/** Returned when the board carries a finding, which is the gating result. */
|
|
45
49
|
const EXIT_FINDINGS = 2
|
|
46
50
|
|
|
51
|
+
/** Matches `trunk.ts`'s bound on a git subprocess this verb also shells out to. */
|
|
52
|
+
const GIT_TIMEOUT_MS = 10_000
|
|
53
|
+
|
|
47
54
|
interface ArchiveCommandOptions {
|
|
48
55
|
readonly json?: boolean
|
|
49
56
|
readonly pullRequest?: string
|
|
50
57
|
readonly root?: string
|
|
51
58
|
}
|
|
52
59
|
|
|
60
|
+
interface DeclineCommandOptions {
|
|
61
|
+
readonly by?: string
|
|
62
|
+
readonly json?: boolean
|
|
63
|
+
readonly reason?: string
|
|
64
|
+
readonly root?: string
|
|
65
|
+
}
|
|
66
|
+
|
|
53
67
|
interface ValidateCommandOptions {
|
|
54
68
|
readonly json?: boolean
|
|
55
69
|
readonly root?: string
|
|
@@ -134,6 +148,39 @@ export function register(program: Command): void {
|
|
|
134
148
|
process.exitCode = await runArchive(task, opts)
|
|
135
149
|
})
|
|
136
150
|
|
|
151
|
+
tasks
|
|
152
|
+
.command('decline')
|
|
153
|
+
.description(
|
|
154
|
+
'Move a task decided against into .canon/tasks/declined/, recording why',
|
|
155
|
+
)
|
|
156
|
+
.argument('<task>', 'Task filename stem, as in v28.1-trigger-escalation')
|
|
157
|
+
.helpOption('-h, --help', 'Show this help message')
|
|
158
|
+
.option('--reason <text>', 'Why the task was decided against')
|
|
159
|
+
.option('--by <name>', 'Who decided, defaulting to git config user.name')
|
|
160
|
+
.option('--json', 'Emit a machine-readable record on stdout')
|
|
161
|
+
.option('--root <path>', 'Board root, defaulting to the main worktree')
|
|
162
|
+
.addHelpText(
|
|
163
|
+
'after',
|
|
164
|
+
[
|
|
165
|
+
'',
|
|
166
|
+
'Unlike archive, decline carries no outcome-state gate: a task can be',
|
|
167
|
+
'decided against at any outcome state, and the two never share a',
|
|
168
|
+
'refusal set since they answer different questions.',
|
|
169
|
+
'',
|
|
170
|
+
'Exit codes:',
|
|
171
|
+
' 0 the task was declined',
|
|
172
|
+
' 1 refused, with the reason on stderr or in the JSON record',
|
|
173
|
+
'',
|
|
174
|
+
'Examples:',
|
|
175
|
+
' canon tasks decline v28.1-trigger-escalation --reason "superseded by v30.2" # canon-allow-reference: illustrates the stem-selection form, not a citation of a real task',
|
|
176
|
+
' canon tasks decline v28.1-trigger-escalation --reason "no longer needed" --by Alex --json',
|
|
177
|
+
'',
|
|
178
|
+
].join('\n'),
|
|
179
|
+
)
|
|
180
|
+
.action(async (task: string, opts: DeclineCommandOptions) => {
|
|
181
|
+
process.exitCode = await runDecline(task, opts)
|
|
182
|
+
})
|
|
183
|
+
|
|
137
184
|
tasks
|
|
138
185
|
.command('validate')
|
|
139
186
|
.description(
|
|
@@ -938,7 +985,7 @@ function reportValidation(
|
|
|
938
985
|
intro('canon tasks validate')
|
|
939
986
|
logStep('Board')
|
|
940
987
|
logInfo(
|
|
941
|
-
`${outcome.rows} row(s) across the readiness groups, ${outcome.backlog} backlog line(s), ${outcome.tasks} task file(s)`,
|
|
988
|
+
`${outcome.rows} row(s) across the readiness groups, ${outcome.backlog} backlog line(s), ${outcome.tasks} task file(s), ${outcome.declined} declined`,
|
|
942
989
|
)
|
|
943
990
|
|
|
944
991
|
logStep(outcome.findings.length === 0 ? 'Clean' : 'Findings')
|
|
@@ -994,6 +1041,7 @@ function reportValidation(
|
|
|
994
1041
|
rows: outcome.rows,
|
|
995
1042
|
backlog: outcome.backlog,
|
|
996
1043
|
tasks: outcome.tasks,
|
|
1044
|
+
declined: outcome.declined,
|
|
997
1045
|
findings: outcome.findings,
|
|
998
1046
|
untested: outcome.untested,
|
|
999
1047
|
claims: outcome.claims,
|
|
@@ -1146,3 +1194,132 @@ function recordFor(
|
|
|
1146
1194
|
cut: outcome.cut,
|
|
1147
1195
|
}
|
|
1148
1196
|
}
|
|
1197
|
+
|
|
1198
|
+
/**
|
|
1199
|
+
* Reads the local `git config user.name` when `--by` names nobody. `--root`
|
|
1200
|
+
* scopes the read so it answers for the board's own repository rather than
|
|
1201
|
+
* whatever the ambient environment points at, mirroring `trunk.ts`'s guard.
|
|
1202
|
+
*/
|
|
1203
|
+
async function resolveBy(
|
|
1204
|
+
root: string,
|
|
1205
|
+
by: string | undefined,
|
|
1206
|
+
): Promise<string | undefined> {
|
|
1207
|
+
if (by) return by
|
|
1208
|
+
|
|
1209
|
+
const result = await execa('git', ['-C', root, 'config', 'user.name'], {
|
|
1210
|
+
reject: false,
|
|
1211
|
+
timeout: GIT_TIMEOUT_MS,
|
|
1212
|
+
env: gitEnv(),
|
|
1213
|
+
extendEnv: false,
|
|
1214
|
+
})
|
|
1215
|
+
|
|
1216
|
+
const name = result.exitCode === 0 ? result.stdout.trim() : ''
|
|
1217
|
+
return name.length > 0 ? name : undefined
|
|
1218
|
+
}
|
|
1219
|
+
|
|
1220
|
+
async function runDecline(
|
|
1221
|
+
task: string,
|
|
1222
|
+
opts: DeclineCommandOptions,
|
|
1223
|
+
): Promise<number> {
|
|
1224
|
+
const emitJson = opts.json ?? false
|
|
1225
|
+
|
|
1226
|
+
if (!opts.reason) {
|
|
1227
|
+
return reportDecline(
|
|
1228
|
+
{
|
|
1229
|
+
ok: false,
|
|
1230
|
+
reason: 'bad-input',
|
|
1231
|
+
message: 'No reason named. Pass --reason <text>.',
|
|
1232
|
+
detail: [],
|
|
1233
|
+
},
|
|
1234
|
+
emitJson,
|
|
1235
|
+
process.cwd(),
|
|
1236
|
+
)
|
|
1237
|
+
}
|
|
1238
|
+
|
|
1239
|
+
const root = opts.root ?? (await mainWorktreeRoot())
|
|
1240
|
+
const by = await resolveBy(root, opts.by)
|
|
1241
|
+
|
|
1242
|
+
if (!by) {
|
|
1243
|
+
return reportDecline(
|
|
1244
|
+
{
|
|
1245
|
+
ok: false,
|
|
1246
|
+
reason: 'bad-input',
|
|
1247
|
+
message:
|
|
1248
|
+
'No decider named. Pass --by <name> or set git config user.name.',
|
|
1249
|
+
detail: [],
|
|
1250
|
+
},
|
|
1251
|
+
emitJson,
|
|
1252
|
+
root,
|
|
1253
|
+
)
|
|
1254
|
+
}
|
|
1255
|
+
|
|
1256
|
+
const outcome = await declineTask(root, task, opts.reason, by)
|
|
1257
|
+
|
|
1258
|
+
return reportDecline(outcome, emitJson, root)
|
|
1259
|
+
}
|
|
1260
|
+
|
|
1261
|
+
function reportDecline(
|
|
1262
|
+
outcome: DeclineOutcome,
|
|
1263
|
+
emitJson: boolean,
|
|
1264
|
+
root: string,
|
|
1265
|
+
): number {
|
|
1266
|
+
if (emitJson) {
|
|
1267
|
+
process.stdout.write(`${JSON.stringify(declineRecordFor(outcome, root))}\n`)
|
|
1268
|
+
return outcome.ok ? 0 : 1
|
|
1269
|
+
}
|
|
1270
|
+
|
|
1271
|
+
intro('canon tasks decline')
|
|
1272
|
+
|
|
1273
|
+
if (!outcome.ok) {
|
|
1274
|
+
logStep('Refused')
|
|
1275
|
+
logError(outcome.message)
|
|
1276
|
+
if (outcome.detail.length > 0) pipeOutput(outcome.detail.join('\n'))
|
|
1277
|
+
outro()
|
|
1278
|
+
return 1
|
|
1279
|
+
}
|
|
1280
|
+
|
|
1281
|
+
logStep('Declined')
|
|
1282
|
+
logRemove(relative(root, outcome.from))
|
|
1283
|
+
logAdd(relative(root, outcome.to))
|
|
1284
|
+
if (outcome.plan) {
|
|
1285
|
+
logRemove(relative(root, outcome.plan.from))
|
|
1286
|
+
logAdd(relative(root, outcome.plan.to))
|
|
1287
|
+
logInfo('retargeted the Plan: line')
|
|
1288
|
+
}
|
|
1289
|
+
if (outcome.priorityRowRemoved) logInfo('cleared the ordering row')
|
|
1290
|
+
if (outcome.backlogRowRemoved) logInfo('cleared the backlog row')
|
|
1291
|
+
if (outcome.indexRegenerated) logInfo('regenerated index.md')
|
|
1292
|
+
outro()
|
|
1293
|
+
|
|
1294
|
+
return 0
|
|
1295
|
+
}
|
|
1296
|
+
|
|
1297
|
+
function declineRecordFor(
|
|
1298
|
+
outcome: DeclineOutcome,
|
|
1299
|
+
root: string,
|
|
1300
|
+
): Record<string, unknown> {
|
|
1301
|
+
if (!outcome.ok) {
|
|
1302
|
+
return {
|
|
1303
|
+
ok: false,
|
|
1304
|
+
reason: outcome.reason,
|
|
1305
|
+
message: outcome.message,
|
|
1306
|
+
detail: outcome.detail,
|
|
1307
|
+
}
|
|
1308
|
+
}
|
|
1309
|
+
|
|
1310
|
+
return {
|
|
1311
|
+
ok: true,
|
|
1312
|
+
task: outcome.stem,
|
|
1313
|
+
from: relative(root, outcome.from),
|
|
1314
|
+
to: relative(root, outcome.to),
|
|
1315
|
+
priorityRowRemoved: outcome.priorityRowRemoved,
|
|
1316
|
+
backlogRowRemoved: outcome.backlogRowRemoved,
|
|
1317
|
+
indexRegenerated: outcome.indexRegenerated,
|
|
1318
|
+
plan: outcome.plan
|
|
1319
|
+
? {
|
|
1320
|
+
from: relative(root, outcome.plan.from),
|
|
1321
|
+
to: relative(root, outcome.plan.to),
|
|
1322
|
+
}
|
|
1323
|
+
: null,
|
|
1324
|
+
}
|
|
1325
|
+
}
|
package/src/commands/teach.ts
CHANGED
|
@@ -384,6 +384,7 @@ async function runStylesheet(
|
|
|
384
384
|
root,
|
|
385
385
|
slug: outcome.slug,
|
|
386
386
|
path: outcome.path,
|
|
387
|
+
basePath: outcome.basePath,
|
|
387
388
|
written: outcome.written,
|
|
388
389
|
})}\n`,
|
|
389
390
|
)
|
|
@@ -393,6 +394,7 @@ async function runStylesheet(
|
|
|
393
394
|
intro('canon teach stylesheet')
|
|
394
395
|
logStep(outcome.written ? 'Written' : 'Already present, left alone')
|
|
395
396
|
logInfo(outcome.path)
|
|
397
|
+
logInfo(outcome.basePath)
|
|
396
398
|
outro()
|
|
397
399
|
return 0
|
|
398
400
|
}
|
package/src/demo/beats.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Reads the human-facing draft `
|
|
2
|
+
* Reads the human-facing draft `draft-screencast` writes. Nothing here knows
|
|
3
3
|
* about a browser: the draft is prose aimed at a person, and turning it into
|
|
4
4
|
* something executable is `@/demo/compile`'s job.
|
|
5
5
|
*/
|
package/src/gate/measures.ts
CHANGED
|
@@ -708,6 +708,72 @@ export const readmeCitations: Measure = async (ctx) => {
|
|
|
708
708
|
}
|
|
709
709
|
}
|
|
710
710
|
|
|
711
|
+
/**
|
|
712
|
+
* `deploy-site.yml` and `pr-visual-checks.yml` each carry their own literal
|
|
713
|
+
* copy of the eight path globs a landing-page change should trigger on, per
|
|
714
|
+
* Question 2 in `.canon/plans/archive/feature-render-visibility.md`. A YAML
|
|
715
|
+
* anchor cannot cross files here, so the two copies are read live and compared
|
|
716
|
+
* rather than one asserting a literal the other could drift behind unnoticed,
|
|
717
|
+
* mirroring how `web/e2e/home.spec.ts`'s rule-group test already reads live.
|
|
718
|
+
*/
|
|
719
|
+
export const visualPathGlobs: Measure = async (ctx) => {
|
|
720
|
+
const deployPath = '.github/workflows/deploy-site.yml'
|
|
721
|
+
const visualPath = '.github/workflows/pr-visual-checks.yml'
|
|
722
|
+
const deployFile = join(ctx.root, deployPath)
|
|
723
|
+
const visualFile = join(ctx.root, visualPath)
|
|
724
|
+
|
|
725
|
+
if (!existsSync(deployFile) || !existsSync(visualFile)) {
|
|
726
|
+
return {
|
|
727
|
+
emissions: [],
|
|
728
|
+
unmeasured: `${deployPath} or ${visualPath} is absent, so no path list was compared.`,
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
const deploy = Bun.YAML.parse(readFileSync(deployFile, 'utf8')) as {
|
|
733
|
+
readonly on?: { readonly push?: { readonly paths?: readonly string[] } }
|
|
734
|
+
}
|
|
735
|
+
const visual = Bun.YAML.parse(readFileSync(visualFile, 'utf8')) as {
|
|
736
|
+
readonly on?: {
|
|
737
|
+
readonly pull_request?: { readonly paths?: readonly string[] }
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
const deployPaths = deploy.on?.push?.paths ?? []
|
|
742
|
+
const visualPaths = visual.on?.pull_request?.paths ?? []
|
|
743
|
+
|
|
744
|
+
if (deployPaths.length === 0 || visualPaths.length === 0) {
|
|
745
|
+
return {
|
|
746
|
+
emissions: [],
|
|
747
|
+
unmeasured: `${deployPath} or ${visualPath} carries no path list to compare.`,
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
const onlyDeploy = deployPaths.filter((path) => !visualPaths.includes(path))
|
|
752
|
+
const onlyVisual = visualPaths.filter((path) => !deployPaths.includes(path))
|
|
753
|
+
|
|
754
|
+
if (onlyDeploy.length === 0 && onlyVisual.length === 0) {
|
|
755
|
+
return {
|
|
756
|
+
emissions: [
|
|
757
|
+
info(
|
|
758
|
+
`${deployPaths.length} path glob(s) agree between ${deployPath} and ${visualPath}`,
|
|
759
|
+
),
|
|
760
|
+
],
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
return {
|
|
765
|
+
emissions: [
|
|
766
|
+
...onlyDeploy.map((path) =>
|
|
767
|
+
warn(`${deployPath} carries ${path}, absent from ${visualPath}`),
|
|
768
|
+
),
|
|
769
|
+
...onlyVisual.map((path) =>
|
|
770
|
+
warn(`${visualPath} carries ${path}, absent from ${deployPath}`),
|
|
771
|
+
),
|
|
772
|
+
],
|
|
773
|
+
failure: `${deployPath} and ${visualPath} carry different path globs. Bring the two lists back into agreement.`,
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
|
|
711
777
|
/**
|
|
712
778
|
* `canon sandbox coverage` moves only when a person runs it, so a scenario added
|
|
713
779
|
* with no expectation ships unnoticed.
|
package/src/gate/stages.ts
CHANGED
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
shippedReferences,
|
|
13
13
|
standardCriteria,
|
|
14
14
|
unreferencedRules,
|
|
15
|
+
visualPathGlobs,
|
|
15
16
|
} from '@/gate/measures'
|
|
16
17
|
import { SHIPPED_CORPORA } from '@/shipped/references'
|
|
17
18
|
|
|
@@ -424,6 +425,16 @@ export const STAGES: readonly Stage[] = [
|
|
|
424
425
|
skipped: 'Neither copy.ts nor README.md changed, so no citation was read',
|
|
425
426
|
checks: [{ kind: 'measure', measure: readmeCitations }],
|
|
426
427
|
},
|
|
428
|
+
{
|
|
429
|
+
// Scoped to the two files carrying the literal path-glob copy, so an edit
|
|
430
|
+
// to either one runs the check.
|
|
431
|
+
id: 'visual-path-globs',
|
|
432
|
+
label: 'Visual check path globs',
|
|
433
|
+
scope: /^\.github\/workflows\/(deploy-site|pr-visual-checks)\.yml$/,
|
|
434
|
+
skipped:
|
|
435
|
+
'Neither deploy-site.yml nor pr-visual-checks.yml changed, so no path list was compared',
|
|
436
|
+
checks: [{ kind: 'measure', measure: visualPathGlobs }],
|
|
437
|
+
},
|
|
427
438
|
{
|
|
428
439
|
id: 'seed-standards',
|
|
429
440
|
label: 'Seed standards',
|