@erclx/canon 5.78.0 → 5.79.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 +23 -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/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/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/task-board/SKILL.md +1 -0
- package/claude/skills/task-board/references/tasks.md +1 -0
- package/docs/workflow/ai-workflow.md +1 -1
- package/docs/workflow/parallel-features.md +2 -1
- package/package.json +1 -1
- package/src/commands/pr.ts +138 -15
- package/src/commands/sessions.ts +88 -6
- package/src/commands/tasks.ts +41 -2
- 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/screencast.ts +114 -11
- package/src/tasks/holders.ts +198 -0
- package/src/tasks/validate.ts +148 -0
- /package/src/sessions/{transfer/transcript.ts → transcript.ts} +0 -0
package/src/take/screencast.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import { join } from 'node:path'
|
|
1
|
+
import { mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
|
2
|
+
import { dirname, join } from 'node:path'
|
|
4
3
|
import { chromium } from 'playwright-core'
|
|
5
|
-
import { lastFrame,
|
|
4
|
+
import { lastFrame, openFrameEncoder, probeFrames } from '@/take/encode'
|
|
5
|
+
import { frameStamp, slotFrames } from '@/take/frame-clock'
|
|
6
6
|
import { FPS, type LogEvent, START, VIEWPORT } from '@/take/log'
|
|
7
7
|
import {
|
|
8
8
|
type PointerInput,
|
|
@@ -51,8 +51,13 @@ export async function filmScreencast(request: {
|
|
|
51
51
|
readonly beat: Beat
|
|
52
52
|
readonly clip: string
|
|
53
53
|
}): Promise<ScreencastTake> {
|
|
54
|
-
|
|
55
|
-
|
|
54
|
+
// Ahead of the launch, so a folder that refuses leaves no browser holding the
|
|
55
|
+
// process open past its refusal.
|
|
56
|
+
const scratch = openSpool(request.clip)
|
|
57
|
+
const browser = await chromium.launch().catch((error: unknown) => {
|
|
58
|
+
scratch.remove()
|
|
59
|
+
throw error
|
|
60
|
+
})
|
|
56
61
|
let still: (() => Promise<Buffer | undefined>) | undefined
|
|
57
62
|
try {
|
|
58
63
|
const page = await browser.newPage({ viewport: VIEWPORT })
|
|
@@ -97,9 +102,33 @@ export async function filmScreencast(request: {
|
|
|
97
102
|
if (request.beat.setup.length > 0) await live.hold(1000)
|
|
98
103
|
const lag = await calibrate(page)
|
|
99
104
|
|
|
100
|
-
const webm = join(scratch, 'take.webm')
|
|
101
105
|
const events: LogEvent[] = []
|
|
102
|
-
|
|
106
|
+
const spool: SpooledFrame[] = []
|
|
107
|
+
let spoolFailure: unknown
|
|
108
|
+
await page.screencast.start({
|
|
109
|
+
size: VIEWPORT,
|
|
110
|
+
// The docs tie this to `path`, but 1.64.1 still reads it here, where it
|
|
111
|
+
// doubles the frames Chromium may hold unacknowledged.
|
|
112
|
+
fps: FPS,
|
|
113
|
+
// Spooled rather than held, since a busy page sends about 19 MB a second.
|
|
114
|
+
// The write stays synchronous, so the acknowledgement Chromium paces its
|
|
115
|
+
// frames on waits for no encoder. Playwright calls this from a listener
|
|
116
|
+
// nothing awaits, so a throw here would surface as an unhandled rejection.
|
|
117
|
+
onFrame: ({ data, timestamp }) => {
|
|
118
|
+
const stamp = frameStamp({
|
|
119
|
+
presentedWall: timestamp,
|
|
120
|
+
wallNow: Date.now(),
|
|
121
|
+
monoNow: performance.now(),
|
|
122
|
+
})
|
|
123
|
+
const path = join(scratch.folder, `${spool.length}.jpg`)
|
|
124
|
+
try {
|
|
125
|
+
writeFileSync(path, data)
|
|
126
|
+
spool.push({ path, stamp })
|
|
127
|
+
} catch (error) {
|
|
128
|
+
spoolFailure ??= error
|
|
129
|
+
}
|
|
130
|
+
},
|
|
131
|
+
})
|
|
103
132
|
const started = performance.now()
|
|
104
133
|
const recording = stageOn(() => performance.now() - started)
|
|
105
134
|
await recording.hold(HOLD_MS)
|
|
@@ -110,8 +139,9 @@ export async function filmScreencast(request: {
|
|
|
110
139
|
await recording.hold(HOLD_MS)
|
|
111
140
|
const filmedMs = recording.now()
|
|
112
141
|
await page.screencast.stop()
|
|
142
|
+
if (spoolFailure !== undefined) throw spoolFailure
|
|
113
143
|
|
|
114
|
-
await
|
|
144
|
+
await encodeSpool(spool, request.clip, started, filmedMs)
|
|
115
145
|
const frames = await probeFrames(request.clip)
|
|
116
146
|
const closing = await lastFrame(request.clip, frames)
|
|
117
147
|
if (lag === undefined) return { frames, events, still: closing }
|
|
@@ -126,13 +156,86 @@ export async function filmScreencast(request: {
|
|
|
126
156
|
throw error
|
|
127
157
|
} finally {
|
|
128
158
|
await browser.close()
|
|
129
|
-
|
|
159
|
+
scratch.remove()
|
|
130
160
|
}
|
|
131
161
|
}
|
|
132
162
|
|
|
133
163
|
type Page = import('playwright-core').Page
|
|
134
164
|
type Mouse = import('playwright-core').Mouse
|
|
135
165
|
|
|
166
|
+
interface SpooledFrame {
|
|
167
|
+
readonly path: string
|
|
168
|
+
/** When the frame was presented, on the monotonic clock. */
|
|
169
|
+
readonly stamp: number
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Opens the folder holding one take's frames, beside its clip rather than in a
|
|
174
|
+
* temp folder nothing sweeps, since a 34 s beat on a busy page spools about
|
|
175
|
+
* 650 MB. A take killed outright runs no handler, so it leaves the folder where
|
|
176
|
+
* its owner looks, and the next take of that beat clears it.
|
|
177
|
+
*/
|
|
178
|
+
function openSpool(clip: string): { folder: string; remove: () => void } {
|
|
179
|
+
const folder = join(dirname(clip), 'spool')
|
|
180
|
+
rmSync(folder, { recursive: true, force: true })
|
|
181
|
+
mkdirSync(folder, { recursive: true })
|
|
182
|
+
const remove = (): void => {
|
|
183
|
+
process.off('exit', remove)
|
|
184
|
+
process.off('SIGINT', interrupt)
|
|
185
|
+
process.off('SIGTERM', interrupt)
|
|
186
|
+
rmSync(folder, { recursive: true, force: true })
|
|
187
|
+
}
|
|
188
|
+
// A signal skips the exit event, so it removes the folder and then re-raises
|
|
189
|
+
// itself with this handler gone, ending the process as it would have.
|
|
190
|
+
const interrupt = (signal: NodeJS.Signals): void => {
|
|
191
|
+
remove()
|
|
192
|
+
process.kill(process.pid, signal)
|
|
193
|
+
}
|
|
194
|
+
process.on('exit', remove)
|
|
195
|
+
process.once('SIGINT', interrupt)
|
|
196
|
+
process.once('SIGTERM', interrupt)
|
|
197
|
+
return { folder, remove }
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Writes the clip from the spool, one frame per slot of the stage clock, so its
|
|
202
|
+
* length is the take's rather than whatever the frames span. Refuses when no
|
|
203
|
+
* frame arrived, since there is nothing true to show.
|
|
204
|
+
*/
|
|
205
|
+
async function encodeSpool(
|
|
206
|
+
spool: readonly SpooledFrame[],
|
|
207
|
+
clip: string,
|
|
208
|
+
startMs: number,
|
|
209
|
+
lengthMs: number,
|
|
210
|
+
): Promise<void> {
|
|
211
|
+
const slots = slotFrames(
|
|
212
|
+
spool.map((frame) => frame.stamp),
|
|
213
|
+
{ startMs, lengthMs, frameMs: FRAME_MS },
|
|
214
|
+
)
|
|
215
|
+
if (slots.length === 0) {
|
|
216
|
+
throw new Error(
|
|
217
|
+
`the screencast delivered no frame in ${(lengthMs / 1000).toFixed(2)} s of filming`,
|
|
218
|
+
)
|
|
219
|
+
}
|
|
220
|
+
const encoder = openFrameEncoder(clip, FPS)
|
|
221
|
+
try {
|
|
222
|
+
let shown = -1
|
|
223
|
+
let jpeg = Buffer.alloc(0)
|
|
224
|
+
for (const index of slots) {
|
|
225
|
+
const frame = spool[index]
|
|
226
|
+
if (frame && index !== shown) {
|
|
227
|
+
jpeg = readFileSync(frame.path)
|
|
228
|
+
shown = index
|
|
229
|
+
}
|
|
230
|
+
await encoder.write(jpeg)
|
|
231
|
+
}
|
|
232
|
+
await encoder.close()
|
|
233
|
+
} catch (error) {
|
|
234
|
+
encoder.abort()
|
|
235
|
+
throw error
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
136
239
|
/**
|
|
137
240
|
* The page time of its next animation frame, or undefined when none comes
|
|
138
241
|
* within `capMs`, as on a hidden page, or the page closed first.
|
|
@@ -155,7 +258,7 @@ const PAINTED = '__canonTakePainted'
|
|
|
155
258
|
* How long a move takes to show in the clip, measured by sending one aligned
|
|
156
259
|
* move to `START` and reading the animation frame after its handler against
|
|
157
260
|
* the frame it was aligned to. Both readings are page time, so no clock mapping
|
|
158
|
-
* is needed. The
|
|
261
|
+
* is needed. The screencast captures a frame once it is presented, one page
|
|
159
262
|
* frame after that frame begins, so the lag counts one page frame past the
|
|
160
263
|
* readings, read off two consecutive animation frames rather than assumed from
|
|
161
264
|
* the clip's rate. That held a dragged box within 7 px on every run where the
|
|
@@ -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
|
}
|
|
File without changes
|