@erclx/canon 5.78.0 → 5.80.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 +40 -0
- package/claude/.claude-plugin/plugin.json +1 -1
- package/claude/skills/app-drive/REQUIREMENT.md +12 -0
- package/claude/skills/app-drive/SKILL.md +44 -8
- package/claude/skills/app-drive/references/config.md +18 -8
- package/claude/skills/app-drive/references/drive-record.md +4 -0
- package/claude/skills/auto-ship/REQUIREMENT.md +1 -1
- package/claude/skills/auto-ship/SKILL.md +5 -4
- package/claude/skills/auto-ship/references/draft-mark.md +33 -15
- package/claude/skills/auto-ship/references/failure-recovery.md +4 -2
- package/claude/skills/canon-cli/SKILL.md +13 -10
- package/claude/skills/design-pick/SKILL.md +8 -0
- package/claude/skills/design-pick/references/design-config.md +9 -6
- package/claude/skills/design-references/REQUIREMENT.md +32 -0
- package/claude/skills/design-references/SKILL.md +120 -0
- package/claude/skills/design-references/references/films.md +90 -0
- package/claude/skills/design-references/references/sites.md +36 -0
- package/claude/skills/design-taste/SKILL.md +16 -4
- package/claude/skills/design-taste/references/adopted.md +5 -1
- package/claude/skills/design-taste/references/kinds.md +1 -0
- package/claude/skills/design-taste/references/preflight.md +27 -1
- package/claude/skills/design-taste/references/tells.md +2 -0
- package/claude/skills/git-followup/SKILL.md +1 -0
- package/claude/skills/git-pr/REQUIREMENT.md +2 -0
- package/claude/skills/git-pr/SKILL.md +2 -0
- package/claude/skills/git-pr/references/final-command.md +34 -2
- package/claude/skills/git-pr/references/pr.md +1 -1
- package/claude/skills/git-ship/REQUIREMENT.md +2 -2
- package/claude/skills/git-ship/SKILL.md +16 -11
- package/claude/skills/git-ship/references/ci-watch.md +28 -4
- package/claude/skills/markdown-craft/references/markdown.md +4 -4
- package/claude/skills/plan-feature/references/plan.md +2 -0
- package/claude/skills/review-address/SKILL.md +2 -1
- package/claude/skills/review-pr/references/key-changes.md +2 -1
- package/claude/skills/review-ui/references/driving.md +33 -1
- package/claude/skills/role-orchestrator/REQUIREMENT.md +1 -1
- package/claude/skills/role-orchestrator/SKILL.md +2 -2
- package/claude/skills/role-orchestrator/references/orchestrator-hand-launch.md +2 -0
- package/claude/skills/role-orchestrator/references/orchestrator-handback.md +1 -1
- package/claude/skills/role-orchestrator/references/orchestrator-limit-stops.md +47 -0
- package/claude/skills/role-orchestrator/references/orchestrator-sweep.md +3 -1
- package/claude/skills/role-worker/SKILL.md +1 -1
- package/claude/skills/search-craft/REQUIREMENT.md +3 -3
- package/claude/skills/search-craft/SKILL.md +12 -1
- package/claude/skills/target-scaffold/REQUIREMENT.md +31 -0
- package/claude/skills/target-scaffold/SKILL.md +44 -0
- package/claude/skills/target-setup/SKILL.md +5 -1
- package/claude/skills/task-board/SKILL.md +1 -0
- package/claude/skills/task-board/references/tasks.md +1 -0
- package/claude/skills/ui-checklist/SKILL.md +5 -1
- package/claude/skills/ui-craft/SKILL.md +9 -9
- package/claude/skills/ui-craft/references/adopted.md +4 -2
- package/claude/skills/ui-craft/references/capture.md +8 -0
- package/claude/skills/ui-craft/references/controls.md +6 -0
- package/claude/skills/ui-craft/references/motion.md +6 -0
- package/claude/skills/ui-film/references/take-craft.md +2 -0
- package/docs/agents/output-shape.md +2 -1
- package/docs/target/projects.md +2 -2
- package/docs/workflow/ai-workflow.md +1 -1
- package/docs/workflow/parallel-features.md +2 -1
- package/docs/workflow/skill-map.md +7 -5
- package/docs/workflow/visual-design-workflow.md +4 -0
- package/governance/ledger/skills/design-references/SKILL.md +38 -0
- package/governance/ledger/skills/design-taste/SKILL.md +36 -30
- package/governance/ledger/skills/design-taste/kinds.md +1 -0
- package/governance/ledger/skills/design-taste/tells.md +22 -20
- package/governance/ledger/skills/search-craft/SKILL.md +6 -0
- package/governance/ledger/skills/ui-craft/capture.md +10 -0
- package/governance/ledger/skills/ui-craft/controls.md +8 -0
- package/governance/ledger/skills/ui-craft/motion.md +9 -1
- package/package.json +3 -3
- package/src/capture/render.ts +154 -2
- package/src/capture/sections.ts +257 -0
- package/src/claude/cases/misc.ts +5 -0
- package/src/claude/cases/setup.ts +7 -0
- package/src/commands/capture.ts +149 -4
- package/src/commands/pr.ts +138 -15
- package/src/commands/sessions.ts +88 -6
- package/src/commands/tasks.ts +41 -2
- package/src/commands/tooling.ts +72 -0
- package/src/git/files.ts +5 -2
- package/src/indexes/frontmatter.ts +27 -6
- package/src/indexes/render.ts +21 -3
- package/src/markdown/colons.ts +14 -4
- package/src/markdown/heading-case.ts +9 -2
- package/src/pr/bijection.ts +8 -0
- package/src/pr/evidence.ts +38 -9
- package/src/pr/health.ts +63 -15
- package/src/pr/links.ts +160 -0
- package/src/pr/local.ts +17 -4
- package/src/pr/paths.ts +20 -2
- package/src/sessions/stop.ts +145 -0
- package/src/sessions/transfer/bundle.ts +1 -1
- package/src/take/encode.ts +16 -42
- package/src/take/frame-clock.ts +46 -0
- package/src/take/perform.ts +29 -4
- package/src/take/screencast.ts +114 -11
- package/src/tasks/holders.ts +198 -0
- package/src/tasks/validate.ts +148 -0
- package/src/tooling/list.ts +3 -0
- package/src/tooling/scaffold.ts +193 -0
- package/tooling/cloudflare/manifest.toml +1 -1
- package/tooling/cloudflare/reference.md +8 -0
- /package/src/sessions/{transfer/transcript.ts → transcript.ts} +0 -0
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import { execa } from 'execa'
|
|
2
|
+
import { gitEnv } from '@/git/env'
|
|
3
|
+
import {
|
|
4
|
+
repositoryOf,
|
|
5
|
+
resolveSessions,
|
|
6
|
+
type SessionReport,
|
|
7
|
+
} from '@/sessions/resolve'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Bounds the one network read `canon tasks validate --live` takes, shorter than
|
|
11
|
+
* the archive's own, since the sweep waits on it and a slow answer reads the
|
|
12
|
+
* same as no answer.
|
|
13
|
+
*/
|
|
14
|
+
const GH_TIMEOUT_MS = 5_000
|
|
15
|
+
|
|
16
|
+
/** `gh pr list` caps at 30 by default, and a truncated list would read as clean. */
|
|
17
|
+
const PULL_LIMIT = 1_000
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Who holds each branch, named for a person reading the report. `undefined`
|
|
21
|
+
* means the source could not be read, which has to stay apart from an empty map:
|
|
22
|
+
* an unreachable source that degraded to "nobody holds it" would report a held
|
|
23
|
+
* row as clean.
|
|
24
|
+
*/
|
|
25
|
+
export type HolderReader = () => Promise<
|
|
26
|
+
ReadonlyMap<string, string> | undefined
|
|
27
|
+
>
|
|
28
|
+
|
|
29
|
+
/** Whether a merged pull request names a branch, `undefined` when GitHub could not say. */
|
|
30
|
+
export type MergedReader = (branch: string) => Promise<boolean | undefined>
|
|
31
|
+
|
|
32
|
+
export interface LiveReaders {
|
|
33
|
+
readonly sessions: HolderReader
|
|
34
|
+
readonly pulls: HolderReader
|
|
35
|
+
readonly merged: MergedReader
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface SessionHolderOptions {
|
|
39
|
+
readonly resolve?: () => Promise<SessionReport>
|
|
40
|
+
readonly repository?: (root: string) => Promise<string | null>
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Reads the roster scoped to `root`'s repository, since a branch name says
|
|
45
|
+
* nothing across a machine and another project's worker on the same name holds
|
|
46
|
+
* nothing here. Two rows leave the liveness undecided, a foreign record whose
|
|
47
|
+
* socket is gone and a row resting only on a socket file a crash can leave, so
|
|
48
|
+
* either reads the whole roster as unread rather than as a hold or as clear.
|
|
49
|
+
*/
|
|
50
|
+
export function sessionHolders(
|
|
51
|
+
root: string,
|
|
52
|
+
opts: SessionHolderOptions = {},
|
|
53
|
+
): HolderReader {
|
|
54
|
+
const resolve = opts.resolve ?? resolveSessions
|
|
55
|
+
const repository = opts.repository ?? repositoryOf
|
|
56
|
+
|
|
57
|
+
return async () => {
|
|
58
|
+
try {
|
|
59
|
+
const [report, repo] = await Promise.all([resolve(), repository(root)])
|
|
60
|
+
if (
|
|
61
|
+
report.kind !== 'resolved' ||
|
|
62
|
+
report.foreignDead > 0 ||
|
|
63
|
+
report.sessions.some((session) => session.via === 'socket') ||
|
|
64
|
+
!repo
|
|
65
|
+
) {
|
|
66
|
+
return undefined
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return new Map(
|
|
70
|
+
report.sessions.flatMap((session) =>
|
|
71
|
+
session.repository === repo && session.branch
|
|
72
|
+
? [[session.branch, `session ${session.name}`] as const]
|
|
73
|
+
: [],
|
|
74
|
+
),
|
|
75
|
+
)
|
|
76
|
+
} catch {
|
|
77
|
+
return undefined
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export type GhRunner = (
|
|
83
|
+
cwd: string,
|
|
84
|
+
args: readonly string[],
|
|
85
|
+
) => Promise<string | undefined>
|
|
86
|
+
|
|
87
|
+
const runGh: GhRunner = async (cwd, args) => {
|
|
88
|
+
if (Bun.which('gh') === null) return undefined
|
|
89
|
+
|
|
90
|
+
try {
|
|
91
|
+
// `gh` resolves its repository through git, so a hook's resolution
|
|
92
|
+
// variables would point it at another repository than `cwd`.
|
|
93
|
+
const result = await execa('gh', [...args], {
|
|
94
|
+
cwd,
|
|
95
|
+
timeout: GH_TIMEOUT_MS,
|
|
96
|
+
env: gitEnv(),
|
|
97
|
+
extendEnv: false,
|
|
98
|
+
})
|
|
99
|
+
return result.stdout
|
|
100
|
+
} catch {
|
|
101
|
+
return undefined
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
interface ListedPull {
|
|
106
|
+
readonly number: number
|
|
107
|
+
readonly headRefName: string
|
|
108
|
+
readonly isCrossRepository: boolean
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function isListedPull(value: unknown): value is ListedPull {
|
|
112
|
+
if (typeof value !== 'object' || value === null) return false
|
|
113
|
+
const pull = value as Record<string, unknown>
|
|
114
|
+
|
|
115
|
+
return (
|
|
116
|
+
typeof pull.number === 'number' &&
|
|
117
|
+
typeof pull.headRefName === 'string' &&
|
|
118
|
+
typeof pull.isCrossRepository === 'boolean'
|
|
119
|
+
)
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function parsePulls(stdout: string): readonly ListedPull[] | undefined {
|
|
123
|
+
try {
|
|
124
|
+
const parsed: unknown = JSON.parse(stdout)
|
|
125
|
+
return Array.isArray(parsed) && parsed.every(isListedPull)
|
|
126
|
+
? parsed
|
|
127
|
+
: undefined
|
|
128
|
+
} catch {
|
|
129
|
+
return undefined
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Reads open pull requests off GitHub. A list reaching the limit may have cut
|
|
135
|
+
* off the one that holds a row, so it reads as unread. A fork's head names a
|
|
136
|
+
* branch in another repository, which holds nothing here however it is spelled.
|
|
137
|
+
*/
|
|
138
|
+
export function pullHolders(root: string, run: GhRunner = runGh): HolderReader {
|
|
139
|
+
return async () => {
|
|
140
|
+
const stdout = await run(root, [
|
|
141
|
+
'pr',
|
|
142
|
+
'list',
|
|
143
|
+
'--state',
|
|
144
|
+
'open',
|
|
145
|
+
'--limit',
|
|
146
|
+
String(PULL_LIMIT),
|
|
147
|
+
'--json',
|
|
148
|
+
'number,headRefName,isCrossRepository',
|
|
149
|
+
])
|
|
150
|
+
if (stdout === undefined) return undefined
|
|
151
|
+
|
|
152
|
+
const pulls = parsePulls(stdout)
|
|
153
|
+
if (!pulls || pulls.length >= PULL_LIMIT) return undefined
|
|
154
|
+
|
|
155
|
+
return new Map(
|
|
156
|
+
pulls
|
|
157
|
+
.filter((pull) => !pull.isCrossRepository)
|
|
158
|
+
.map((pull) => [pull.headRefName, `pull request #${pull.number}`]),
|
|
159
|
+
)
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Asks about one branch at a time rather than listing merged pull requests,
|
|
165
|
+
* since a list long enough to reach an old merge costs every run, and only a
|
|
166
|
+
* branch a session already holds against a row takes this read.
|
|
167
|
+
*/
|
|
168
|
+
export function mergedBranches(
|
|
169
|
+
root: string,
|
|
170
|
+
run: GhRunner = runGh,
|
|
171
|
+
): MergedReader {
|
|
172
|
+
return async (branch) => {
|
|
173
|
+
const stdout = await run(root, [
|
|
174
|
+
'pr',
|
|
175
|
+
'list',
|
|
176
|
+
'--state',
|
|
177
|
+
'merged',
|
|
178
|
+
'--head',
|
|
179
|
+
branch,
|
|
180
|
+
'--limit',
|
|
181
|
+
String(PULL_LIMIT),
|
|
182
|
+
'--json',
|
|
183
|
+
'number,headRefName,isCrossRepository',
|
|
184
|
+
])
|
|
185
|
+
if (stdout === undefined) return undefined
|
|
186
|
+
|
|
187
|
+
const pulls = parsePulls(stdout)
|
|
188
|
+
return pulls?.some((pull) => !pull.isCrossRepository)
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export function liveReaders(root: string): LiveReaders {
|
|
193
|
+
return {
|
|
194
|
+
sessions: sessionHolders(root),
|
|
195
|
+
pulls: pullHolders(root),
|
|
196
|
+
merged: mergedBranches(root),
|
|
197
|
+
}
|
|
198
|
+
}
|
package/src/tasks/validate.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { existsSync, statSync } from 'node:fs'
|
|
2
2
|
import { readdir, readFile } from 'node:fs/promises'
|
|
3
3
|
import { join, resolve } from 'node:path'
|
|
4
|
+
import { readFrontmatterSource } from '@/indexes/frontmatter'
|
|
4
5
|
import { codeSpans, unbrokenRun } from '@/markdown/tables'
|
|
5
6
|
import {
|
|
6
7
|
archiveDir,
|
|
@@ -21,6 +22,8 @@ import {
|
|
|
21
22
|
renderedLength,
|
|
22
23
|
WAITING_LIMIT,
|
|
23
24
|
} from '@/tasks/board'
|
|
25
|
+
import { branchOfPlan } from '@/tasks/branch'
|
|
26
|
+
import type { HolderReader, LiveReaders, MergedReader } from '@/tasks/holders'
|
|
24
27
|
import { gitTrunkReader, type TrunkReader } from '@/tasks/trunk'
|
|
25
28
|
|
|
26
29
|
const ORDERING_FILE = 'priority.md'
|
|
@@ -61,6 +64,7 @@ export const FINDING_KINDS = [
|
|
|
61
64
|
'row-untabled',
|
|
62
65
|
'row-misordered',
|
|
63
66
|
'row-unranked',
|
|
67
|
+
'row-held',
|
|
64
68
|
'touches-unstated',
|
|
65
69
|
'touches-stale',
|
|
66
70
|
'columns-mismatched',
|
|
@@ -69,6 +73,7 @@ export const FINDING_KINDS = [
|
|
|
69
73
|
'blocker-unresolved',
|
|
70
74
|
'blocker-declined',
|
|
71
75
|
'sessions-misshapen',
|
|
76
|
+
'frontmatter-invalid',
|
|
72
77
|
] as const
|
|
73
78
|
|
|
74
79
|
export type FindingKind = (typeof FINDING_KINDS)[number]
|
|
@@ -177,6 +182,18 @@ export interface BoardRow {
|
|
|
177
182
|
readonly cells: readonly string[]
|
|
178
183
|
}
|
|
179
184
|
|
|
185
|
+
/**
|
|
186
|
+
* What each source behind the held-row check answered. `unread` is a source the
|
|
187
|
+
* run asked and could not read, so a board carrying no `row-held` finding beside
|
|
188
|
+
* it is untested rather than clean. `not-asked` is a run without the live read.
|
|
189
|
+
*/
|
|
190
|
+
export type LiveState = 'read' | 'unread' | 'not-asked'
|
|
191
|
+
|
|
192
|
+
export interface LiveReading {
|
|
193
|
+
readonly sessions: LiveState
|
|
194
|
+
readonly pulls: LiveState
|
|
195
|
+
}
|
|
196
|
+
|
|
180
197
|
export interface ValidateReport {
|
|
181
198
|
readonly ok: true
|
|
182
199
|
readonly rows: number
|
|
@@ -189,6 +206,7 @@ export interface ValidateReport {
|
|
|
189
206
|
readonly shared: readonly SharedFiles[]
|
|
190
207
|
readonly wide: readonly WideToken[]
|
|
191
208
|
readonly unplaced: readonly Unplaced[]
|
|
209
|
+
readonly live: LiveReading
|
|
192
210
|
}
|
|
193
211
|
|
|
194
212
|
export interface ValidateRefused {
|
|
@@ -877,6 +895,34 @@ async function checkSessions(
|
|
|
877
895
|
return found.filter((finding): finding is Finding => finding !== undefined)
|
|
878
896
|
}
|
|
879
897
|
|
|
898
|
+
/**
|
|
899
|
+
* Reports a task file whose frontmatter block will not parse, which the index
|
|
900
|
+
* regeneration would otherwise surface only after the row was placed. A file
|
|
901
|
+
* with no block is left alone, since the index walker owns that report.
|
|
902
|
+
*/
|
|
903
|
+
async function checkFrontmatter(
|
|
904
|
+
stems: readonly string[],
|
|
905
|
+
dir: string,
|
|
906
|
+
): Promise<Finding[]> {
|
|
907
|
+
const found = await Promise.all(
|
|
908
|
+
stems.map(async (stem): Promise<Finding | undefined> => {
|
|
909
|
+
const reading = readFrontmatterSource(
|
|
910
|
+
await readFile(join(dir, `${stem}.md`), 'utf8'),
|
|
911
|
+
)
|
|
912
|
+
if (reading.kind !== 'invalid') return undefined
|
|
913
|
+
|
|
914
|
+
return {
|
|
915
|
+
kind: 'frontmatter-invalid',
|
|
916
|
+
group: undefined,
|
|
917
|
+
subject: stem,
|
|
918
|
+
message: `frontmatter is not valid YAML: ${reading.message}. Double any apostrophe inside the single-quoted title.`,
|
|
919
|
+
}
|
|
920
|
+
}),
|
|
921
|
+
)
|
|
922
|
+
|
|
923
|
+
return found.filter((finding): finding is Finding => finding !== undefined)
|
|
924
|
+
}
|
|
925
|
+
|
|
880
926
|
/**
|
|
881
927
|
* Tests the claim a group name makes about a plan. `## Needs a plan` says none
|
|
882
928
|
* is live and `## Up next` says one is written, and neither group has a `Plan`
|
|
@@ -1611,6 +1657,95 @@ export async function groupRuleFindings(
|
|
|
1611
1657
|
]
|
|
1612
1658
|
}
|
|
1613
1659
|
|
|
1660
|
+
/**
|
|
1661
|
+
* Reports a row outside `## Run now` whose task a live session or an open pull
|
|
1662
|
+
* request holds, tied through the branch each live plan on its `Plan:` line
|
|
1663
|
+
* derives. A row whose worker renamed its branch has nothing to match, and an
|
|
1664
|
+
* archived plan's branch is finished work, so neither is reported.
|
|
1665
|
+
*
|
|
1666
|
+
* A session outlives its merge, so a session still on a branch a merged pull
|
|
1667
|
+
* request names is finished work too, until the merge reaches this clone and the
|
|
1668
|
+
* hook archives the row. A merged read that fails leaves the hold standing, since
|
|
1669
|
+
* the session is still there, and reads `pulls` as unread, since that hold may be
|
|
1670
|
+
* finished work the open list could not rule out.
|
|
1671
|
+
*/
|
|
1672
|
+
async function checkHeld(
|
|
1673
|
+
rows: readonly BoardRow[],
|
|
1674
|
+
live: LiveRead,
|
|
1675
|
+
dir: string,
|
|
1676
|
+
root: string,
|
|
1677
|
+
): Promise<{ readonly findings: Finding[]; readonly mergedUnread: boolean }> {
|
|
1678
|
+
let mergedUnread = false
|
|
1679
|
+
|
|
1680
|
+
const holdersOf = async (branch: string): Promise<string[]> => {
|
|
1681
|
+
const session = live.sessions?.get(branch)
|
|
1682
|
+
const merged = session ? await live.merged?.(branch) : false
|
|
1683
|
+
if (merged === undefined) mergedUnread = true
|
|
1684
|
+
|
|
1685
|
+
return [session && !merged ? session : undefined, live.pulls?.get(branch)]
|
|
1686
|
+
.filter((name): name is string => name !== undefined)
|
|
1687
|
+
.map((name) => `${name} on ${branch}`)
|
|
1688
|
+
}
|
|
1689
|
+
|
|
1690
|
+
const found = await Promise.all(
|
|
1691
|
+
rows.map(async (row): Promise<Finding | undefined> => {
|
|
1692
|
+
if (row.group === 'Run now' || !row.stem) return undefined
|
|
1693
|
+
const file = join(dir, `${row.stem}.md`)
|
|
1694
|
+
if (!existsSync(file)) return undefined
|
|
1695
|
+
|
|
1696
|
+
const targets = readPlanTargets(await readFile(file, 'utf8'))
|
|
1697
|
+
const branches = targets.flatMap((target) => {
|
|
1698
|
+
const path = target.split('#')[0] || target
|
|
1699
|
+
return resolveLivePlan(path, dir, root) === undefined
|
|
1700
|
+
? []
|
|
1701
|
+
: [branchOfPlan(path)]
|
|
1702
|
+
})
|
|
1703
|
+
const held = (await Promise.all(branches.map(holdersOf))).flat()
|
|
1704
|
+
if (held.length === 0) return undefined
|
|
1705
|
+
|
|
1706
|
+
return {
|
|
1707
|
+
kind: 'row-held',
|
|
1708
|
+
group: row.group,
|
|
1709
|
+
subject: row.stem,
|
|
1710
|
+
message: `sits under ${row.group} and is held by ${held.join(' and ')}. A row with a worker belongs under Run now.`,
|
|
1711
|
+
}
|
|
1712
|
+
}),
|
|
1713
|
+
)
|
|
1714
|
+
|
|
1715
|
+
return {
|
|
1716
|
+
findings: found.filter(
|
|
1717
|
+
(finding): finding is Finding => finding !== undefined,
|
|
1718
|
+
),
|
|
1719
|
+
mergedUnread,
|
|
1720
|
+
}
|
|
1721
|
+
}
|
|
1722
|
+
|
|
1723
|
+
/** What each source answered, `undefined` where it was not asked or failed. */
|
|
1724
|
+
interface LiveRead {
|
|
1725
|
+
readonly reading: LiveReading
|
|
1726
|
+
readonly sessions?: Awaited<ReturnType<HolderReader>>
|
|
1727
|
+
readonly pulls?: Awaited<ReturnType<HolderReader>>
|
|
1728
|
+
readonly merged?: MergedReader
|
|
1729
|
+
}
|
|
1730
|
+
|
|
1731
|
+
/** Takes each source once, since a failed read answers undefined rather than throws. */
|
|
1732
|
+
async function readLive(live: LiveReaders | undefined): Promise<LiveRead> {
|
|
1733
|
+
if (!live) {
|
|
1734
|
+
return { reading: { sessions: 'not-asked', pulls: 'not-asked' } }
|
|
1735
|
+
}
|
|
1736
|
+
|
|
1737
|
+
const stateOf = (read: Awaited<ReturnType<HolderReader>>): LiveState =>
|
|
1738
|
+
read ? 'read' : 'unread'
|
|
1739
|
+
const [sessions, pulls] = await Promise.all([live.sessions(), live.pulls()])
|
|
1740
|
+
|
|
1741
|
+
return {
|
|
1742
|
+
reading: { sessions: stateOf(sessions), pulls: stateOf(pulls) },
|
|
1743
|
+
sessions,
|
|
1744
|
+
pulls,
|
|
1745
|
+
merged: live.merged,
|
|
1746
|
+
}
|
|
1747
|
+
}
|
|
1748
|
+
|
|
1614
1749
|
function refuse(reason: ValidateRefusal, message: string): ValidateRefused {
|
|
1615
1750
|
return { ok: false, reason, message }
|
|
1616
1751
|
}
|
|
@@ -1618,6 +1753,12 @@ function refuse(reason: ValidateRefusal, message: string): ValidateRefused {
|
|
|
1618
1753
|
export interface ValidateOptions {
|
|
1619
1754
|
/** Overridden by tests, which supply the trunk rather than reaching for git. */
|
|
1620
1755
|
readonly trunk?: TrunkReader
|
|
1756
|
+
/**
|
|
1757
|
+
* The roster and GitHub reads behind the held-row check. Absent by default,
|
|
1758
|
+
* since the board's write hook runs this on every edit and a network read
|
|
1759
|
+
* there costs every write.
|
|
1760
|
+
*/
|
|
1761
|
+
readonly live?: LiveReaders
|
|
1621
1762
|
}
|
|
1622
1763
|
|
|
1623
1764
|
/**
|
|
@@ -1668,6 +1809,8 @@ export async function validateBoard(
|
|
|
1668
1809
|
? await listTaskStems(declinedPath)
|
|
1669
1810
|
: []
|
|
1670
1811
|
const parked = await checkParked(rows, root, trunk)
|
|
1812
|
+
const live = await readLive(options.live)
|
|
1813
|
+
const held = await checkHeld(rows, live, dir, root)
|
|
1671
1814
|
|
|
1672
1815
|
const findings = [
|
|
1673
1816
|
...shapeFindings,
|
|
@@ -1675,10 +1818,12 @@ export async function validateBoard(
|
|
|
1675
1818
|
...(await groupRuleFindings(rows, root)),
|
|
1676
1819
|
...(await checkPlanAgreement(rows, dir, root)),
|
|
1677
1820
|
...(await checkSessions(rows, dir)),
|
|
1821
|
+
...(await checkFrontmatter(stems, dir)),
|
|
1678
1822
|
...(await checkStaleTouches(rows, root)),
|
|
1679
1823
|
...checkCellWidth(rows),
|
|
1680
1824
|
...checkOrdering(rows),
|
|
1681
1825
|
...parked.findings,
|
|
1826
|
+
...held.findings,
|
|
1682
1827
|
]
|
|
1683
1828
|
|
|
1684
1829
|
return {
|
|
@@ -1693,5 +1838,8 @@ export async function validateBoard(
|
|
|
1693
1838
|
shared: checkSharedFiles(rows),
|
|
1694
1839
|
wide: checkWideTokens(rows),
|
|
1695
1840
|
unplaced: checkUnplaced(rows, backlog, stems),
|
|
1841
|
+
live: held.mergedUnread
|
|
1842
|
+
? { ...live.reading, pulls: 'unread' }
|
|
1843
|
+
: live.reading,
|
|
1696
1844
|
}
|
|
1697
1845
|
}
|
package/src/tooling/list.ts
CHANGED
|
@@ -6,6 +6,8 @@ export interface StackSummary {
|
|
|
6
6
|
readonly devDeps: number
|
|
7
7
|
readonly scripts: number
|
|
8
8
|
readonly gitignoreGroups: number
|
|
9
|
+
/** Whether `canon tooling scaffold` can start a project on this stack. */
|
|
10
|
+
readonly scaffold: boolean
|
|
9
11
|
}
|
|
10
12
|
|
|
11
13
|
/**
|
|
@@ -26,6 +28,7 @@ export function buildStackSummaries(root: string): StackSummary[] {
|
|
|
26
28
|
devDeps: manifest.devPackages.length,
|
|
27
29
|
scripts: Object.keys(manifest.scripts).length,
|
|
28
30
|
gitignoreGroups: manifest.gitignore.length,
|
|
31
|
+
scaffold: manifest.scaffold !== undefined,
|
|
29
32
|
})
|
|
30
33
|
}
|
|
31
34
|
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import {
|
|
2
|
+
cpSync,
|
|
3
|
+
existsSync,
|
|
4
|
+
mkdirSync,
|
|
5
|
+
mkdtempSync,
|
|
6
|
+
readdirSync,
|
|
7
|
+
renameSync,
|
|
8
|
+
rmSync,
|
|
9
|
+
} from 'node:fs'
|
|
10
|
+
import { tmpdir } from 'node:os'
|
|
11
|
+
import { basename, join, resolve } from 'node:path'
|
|
12
|
+
import { execa } from 'execa'
|
|
13
|
+
import { isStackExcluded, loadManifest } from '@/tooling/manifest'
|
|
14
|
+
|
|
15
|
+
export interface ScaffoldOptions {
|
|
16
|
+
/** Root holding `tooling/<stack>/manifest.toml`. */
|
|
17
|
+
readonly root: string
|
|
18
|
+
readonly stack: string
|
|
19
|
+
readonly target: string
|
|
20
|
+
/** Parent of the scratch folder the command runs in. Defaults to the OS temp dir. */
|
|
21
|
+
readonly scratchBase?: string
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export type ScaffoldRefusal =
|
|
25
|
+
| 'excluded'
|
|
26
|
+
| 'no-manifest'
|
|
27
|
+
| 'no-scaffold'
|
|
28
|
+
| 'not-empty'
|
|
29
|
+
| 'bad-name'
|
|
30
|
+
| 'scaffold-failed'
|
|
31
|
+
| 'no-output'
|
|
32
|
+
|
|
33
|
+
export type ScaffoldResult =
|
|
34
|
+
| {
|
|
35
|
+
readonly ok: true
|
|
36
|
+
readonly stack: string
|
|
37
|
+
readonly target: string
|
|
38
|
+
readonly name: string
|
|
39
|
+
readonly files: readonly string[]
|
|
40
|
+
}
|
|
41
|
+
| {
|
|
42
|
+
readonly ok: false
|
|
43
|
+
readonly reason: ScaffoldRefusal
|
|
44
|
+
readonly message: string
|
|
45
|
+
readonly suggested?: string
|
|
46
|
+
readonly scratch?: string
|
|
47
|
+
readonly output?: string
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* npm, create-next-app, and composer all refuse a package name with capitals,
|
|
52
|
+
* spaces, or a leading dot, and the target's basename becomes that name.
|
|
53
|
+
*/
|
|
54
|
+
const PACKAGE_NAME = /^[a-z0-9][a-z0-9._-]*$/
|
|
55
|
+
const MAX_NAME = 214
|
|
56
|
+
|
|
57
|
+
/** A target holding only `.git` counts as empty, since `git init` first is common. */
|
|
58
|
+
const IGNORED_ENTRIES = new Set(['.git'])
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Runs the stack's `manifest.scaffold` in a scratch folder with `{{name}}` set
|
|
62
|
+
* to the target's basename, then moves what it produced into the target. The
|
|
63
|
+
* scratch step is what lets a command that opens on `mkdir {{name}} && cd`
|
|
64
|
+
* land in a folder that already exists.
|
|
65
|
+
*
|
|
66
|
+
* Nothing reaches the target unless the command exits 0 and produced its
|
|
67
|
+
* folder. A failed run leaves the scratch folder in place and names it.
|
|
68
|
+
*/
|
|
69
|
+
export async function scaffoldStack(
|
|
70
|
+
opts: ScaffoldOptions,
|
|
71
|
+
): Promise<ScaffoldResult> {
|
|
72
|
+
const { root, stack } = opts
|
|
73
|
+
const target = resolve(opts.target)
|
|
74
|
+
|
|
75
|
+
if (isStackExcluded(stack)) {
|
|
76
|
+
return refuse('excluded', `Stack '${stack}' is excluded from tooling.`)
|
|
77
|
+
}
|
|
78
|
+
const manifest = loadManifest(root, stack)
|
|
79
|
+
if (manifest === undefined) {
|
|
80
|
+
return refuse(
|
|
81
|
+
'no-manifest',
|
|
82
|
+
`No manifest at tooling/${stack}/manifest.toml`,
|
|
83
|
+
)
|
|
84
|
+
}
|
|
85
|
+
if (manifest.scaffold === undefined) {
|
|
86
|
+
return refuse(
|
|
87
|
+
'no-scaffold',
|
|
88
|
+
`Stack '${stack}' has no scaffold command in its manifest.`,
|
|
89
|
+
)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const held = existsSync(target) ? occupiedEntries(target) : []
|
|
93
|
+
if (held.length > 0) {
|
|
94
|
+
return refuse(
|
|
95
|
+
'not-empty',
|
|
96
|
+
`${target} is not empty (${held.slice(0, 3).join(', ')}${held.length > 3 ? ', ...' : ''}). Scaffold into an empty folder.`,
|
|
97
|
+
)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const name = basename(target)
|
|
101
|
+
if (!PACKAGE_NAME.test(name) || name.length > MAX_NAME) {
|
|
102
|
+
const suggested = suggestName(name)
|
|
103
|
+
return {
|
|
104
|
+
ok: false,
|
|
105
|
+
reason: 'bad-name',
|
|
106
|
+
message: `Folder name '${name}' is not a valid package name. Use lowercase letters, digits, '.', '_', and '-', such as '${suggested}'.`,
|
|
107
|
+
suggested,
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const scratch = mkdtempSync(
|
|
112
|
+
join(opts.scratchBase ?? tmpdir(), 'canon-scaffold-'),
|
|
113
|
+
)
|
|
114
|
+
// Manifests carry shell syntax (`&&`, `cd`, quoted globs), so the command
|
|
115
|
+
// runs under a shell rather than as a split argv, as `tooling verify` runs it.
|
|
116
|
+
const command = manifest.scaffold.replaceAll('{{name}}', name)
|
|
117
|
+
const run = await execa(command, {
|
|
118
|
+
cwd: scratch,
|
|
119
|
+
shell: 'bash',
|
|
120
|
+
all: true,
|
|
121
|
+
reject: false,
|
|
122
|
+
stdin: 'ignore',
|
|
123
|
+
env: { CI: '1' },
|
|
124
|
+
})
|
|
125
|
+
const output = typeof run.all === 'string' ? run.all : ''
|
|
126
|
+
|
|
127
|
+
if (run.failed) {
|
|
128
|
+
return {
|
|
129
|
+
ok: false,
|
|
130
|
+
reason: 'scaffold-failed',
|
|
131
|
+
message: `Scaffold command failed: ${command}`,
|
|
132
|
+
scratch,
|
|
133
|
+
output,
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
const produced = join(scratch, name)
|
|
137
|
+
if (!existsSync(produced)) {
|
|
138
|
+
return {
|
|
139
|
+
ok: false,
|
|
140
|
+
reason: 'no-output',
|
|
141
|
+
message: `Scaffold command exited 0 but produced no ${name}/ folder.`,
|
|
142
|
+
scratch,
|
|
143
|
+
output,
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
mkdirSync(target, { recursive: true })
|
|
148
|
+
const keepTargetGit = existsSync(join(target, '.git'))
|
|
149
|
+
const files: string[] = []
|
|
150
|
+
for (const entry of readdirSync(produced).sort()) {
|
|
151
|
+
if (entry === '.git' && keepTargetGit) continue
|
|
152
|
+
move(join(produced, entry), join(target, entry))
|
|
153
|
+
files.push(entry)
|
|
154
|
+
}
|
|
155
|
+
rmSync(scratch, { recursive: true, force: true })
|
|
156
|
+
|
|
157
|
+
return { ok: true, stack, target, name, files }
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function refuse(reason: ScaffoldRefusal, message: string): ScaffoldResult {
|
|
161
|
+
return { ok: false, reason, message }
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function occupiedEntries(dir: string): string[] {
|
|
165
|
+
return readdirSync(dir)
|
|
166
|
+
.filter((entry) => !IGNORED_ENTRIES.has(entry))
|
|
167
|
+
.sort()
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Lowercases the name and turns every run of other characters into one dash,
|
|
172
|
+
* falling back to `app` when nothing usable is left.
|
|
173
|
+
*/
|
|
174
|
+
export function suggestName(name: string): string {
|
|
175
|
+
const cleaned = name
|
|
176
|
+
.toLowerCase()
|
|
177
|
+
.replace(/[^a-z0-9._-]+/g, '-')
|
|
178
|
+
.replace(/^[^a-z0-9]+/, '')
|
|
179
|
+
.replace(/-+$/, '')
|
|
180
|
+
.slice(0, MAX_NAME)
|
|
181
|
+
return cleaned === '' ? 'app' : cleaned
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** The OS temp dir often sits on another filesystem, where a rename fails. */
|
|
185
|
+
function move(from: string, to: string): void {
|
|
186
|
+
try {
|
|
187
|
+
renameSync(from, to)
|
|
188
|
+
} catch (error) {
|
|
189
|
+
if ((error as NodeJS.ErrnoException).code !== 'EXDEV') throw error
|
|
190
|
+
cpSync(from, to, { recursive: true, preserveTimestamps: true })
|
|
191
|
+
rmSync(from, { recursive: true, force: true })
|
|
192
|
+
}
|
|
193
|
+
}
|
|
@@ -6,6 +6,14 @@ The cloudflare stack ships one parameterized GitHub Actions workflow that deploy
|
|
|
6
6
|
|
|
7
7
|
It ships one script, `scripts/delete-previews.sh`, which the cleanup job calls and which overwrites a project's own file of that name on every sync.
|
|
8
8
|
|
|
9
|
+
## Scaffold
|
|
10
|
+
|
|
11
|
+
The manifest's scaffold starts a new project as a TypeScript Worker from the `hello-world` template, through `npm create cloudflare@latest` with deploy and git both off. `canon tooling scaffold cloudflare` runs it in an empty folder.
|
|
12
|
+
|
|
13
|
+
- The starter installs its own dependencies, since `create-cloudflare` runs the install itself.
|
|
14
|
+
- A project already on `astro` or `vite-react` never takes this scaffold. It syncs the stack onto the framework it has.
|
|
15
|
+
- The stack extends nothing and ships no scripts, so `canon tooling verify cloudflare` passes Scaffold and Sync and stops at `lint:fix`. Run `canon init` on the starter to bring in the base scripts.
|
|
16
|
+
|
|
9
17
|
## Deploy workflow
|
|
10
18
|
|
|
11
19
|
### Job shape
|
|
File without changes
|