@erclx/canon 5.65.0 → 5.67.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +26 -0
- package/claude/.claude-plugin/plugin.json +1 -1
- package/claude/skills/app-drive/REQUIREMENT.md +35 -0
- package/claude/skills/app-drive/SKILL.md +112 -0
- package/claude/skills/app-drive/references/config.md +38 -0
- package/claude/skills/app-drive/references/drive-record.md +70 -0
- package/claude/skills/app-drive/references/isolate.md +46 -0
- package/claude/skills/canon-cli/SKILL.md +2 -0
- package/claude/skills/create-skill/references/skill.md +1 -1
- package/claude/skills/git-pr/references/labels.md +6 -2
- package/claude/skills/git-pr-close/REQUIREMENT.md +29 -0
- package/claude/skills/git-pr-close/SKILL.md +55 -0
- package/claude/skills/git-pr-close/references/close.md +54 -0
- package/claude/skills/plan-feature/references/plan.md +1 -1
- package/claude/skills/plan-intake/references/intake.md +12 -2
- package/claude/skills/pr-evidence/SKILL.md +2 -2
- package/claude/skills/pr-evidence/references/evidence.md +3 -1
- package/claude/skills/review-ui/SKILL.md +5 -4
- package/claude/skills/review-ui/references/drive-copy.md +4 -0
- package/claude/skills/review-ui/references/driving.md +1 -1
- package/claude/skills/role-orchestrator/references/orchestrator-handoff.md +15 -2
- package/claude/skills/role-planner/SKILL.md +2 -2
- package/claude/skills/role-worker/SKILL.md +6 -8
- package/claude/skills/session-map/REQUIREMENT.md +1 -1
- package/claude/skills/session-map/SKILL.md +8 -1
- package/claude/skills/session-map/references/session.md +19 -5
- package/claude/skills/ui-record/REQUIREMENT.md +34 -0
- package/claude/skills/ui-record/SKILL.md +103 -0
- package/claude/skills/ui-record/references/config.md +40 -0
- package/claude/skills/ui-record/references/handoff.md +48 -0
- package/claude/skills/ui-record/references/take-craft.md +92 -0
- package/docs/agents/records.md +1 -0
- package/docs/workflow/parallel-features.md +1 -1
- package/docs/workflow/skill-map-on-request.md +1 -0
- package/docs/workflow/skill-map.md +2 -0
- package/docs/workflow/visual-design-workflow.md +2 -1
- package/package.json +1 -1
- package/src/audits/catalog.ts +5 -1
- package/src/browser/agent-cli.ts +68 -0
- package/src/claude/cases/git.ts +4 -0
- package/src/claude/cases/workflow.ts +20 -0
- package/src/cli/help.ts +3 -0
- package/src/cli.ts +4 -0
- package/src/commands/browser.ts +102 -0
- package/src/commands/intake.ts +181 -1
- package/src/commands/labels.ts +152 -4
- package/src/commands/pr.ts +278 -8
- package/src/commands/records.ts +18 -1
- package/src/commands/take.ts +266 -0
- package/src/context/requirements.ts +1 -11
- package/src/git/status.ts +73 -0
- package/src/intake/drive-check.ts +355 -0
- package/src/intake/folder.ts +1 -1
- package/src/labels/apply.ts +10 -5
- package/src/labels/sync.ts +17 -0
- package/src/markdown/scan.ts +10 -0
- package/src/pr/close.ts +152 -0
- package/src/pr/drive-copy.ts +97 -84
- package/src/pr/health.ts +56 -0
- package/src/pr/local.ts +49 -8
- package/src/pr/rest.ts +10 -2
- package/src/records/session-map.ts +75 -0
- package/src/records/validate.ts +64 -8
- package/src/sandbox/replay/run.ts +30 -5
- package/src/take/camera.ts +309 -0
- package/src/take/encode.ts +170 -0
- package/src/take/film.ts +206 -0
- package/src/take/fixtures/fade.html +49 -0
- package/src/take/fixtures/fetch.html +38 -0
- package/src/take/log.ts +109 -0
- package/src/take/perform.ts +200 -0
- package/src/take/plan.ts +193 -0
- package/src/take/screencast.ts +94 -0
- package/src/take/shell.ts +287 -0
- package/src/take/stepper.ts +252 -0
- package/src/take/zoom/NOTICE.md +45 -0
- package/src/take/zoom/math-utils.ts +69 -0
- package/src/take/zoom/region-utils.ts +55 -0
- package/src/take/zoom/spring.ts +133 -0
- package/src/tasks/archive.ts +1 -3
- package/src/tooling/verify-stack.ts +4 -5
- package/tooling/base/reference.md +1 -0
- package/tooling/base/seeds/canon/config/pr-labels.toml +41 -0
- package/claude/skills/review-ui/scripts/pw.sh +0 -60
package/src/pr/drive-copy.ts
CHANGED
|
@@ -1,8 +1,12 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
1
|
+
import {
|
|
2
|
+
cpSync,
|
|
3
|
+
existsSync,
|
|
4
|
+
lstatSync,
|
|
5
|
+
readdirSync,
|
|
6
|
+
readFileSync,
|
|
7
|
+
rmSync,
|
|
8
|
+
} from 'node:fs'
|
|
9
|
+
import { isAbsolute, join, relative, sep } from 'node:path'
|
|
6
10
|
|
|
7
11
|
/** Where a project names the folders its running preview saves into. */
|
|
8
12
|
export const PREVIEW_CONFIG_REL = join('canon', 'config', 'preview.json')
|
|
@@ -28,6 +32,8 @@ const BRANCH_TYPES = new Set([
|
|
|
28
32
|
export interface PreviewConfig {
|
|
29
33
|
readonly writes: readonly string[]
|
|
30
34
|
readonly env: string
|
|
35
|
+
/** Paths inside a declared path that a rebuild leaves as the server wrote them. */
|
|
36
|
+
readonly keep: readonly string[]
|
|
31
37
|
}
|
|
32
38
|
|
|
33
39
|
export type PreviewConfigRead =
|
|
@@ -39,13 +45,6 @@ export type PreviewConfigRead =
|
|
|
39
45
|
readonly detail: string
|
|
40
46
|
}
|
|
41
47
|
|
|
42
|
-
export interface StatusEntry {
|
|
43
|
-
readonly path: string
|
|
44
|
-
readonly status: string
|
|
45
|
-
/** The content digest, or null where no regular file stands at the path. */
|
|
46
|
-
readonly digest: string | null
|
|
47
|
-
}
|
|
48
|
-
|
|
49
48
|
/**
|
|
50
49
|
* The branch-to-slug transform every branch-derived name shares: a leading
|
|
51
50
|
* type segment comes off when a further segment follows it, and each
|
|
@@ -68,12 +67,25 @@ function isContained(path: string): boolean {
|
|
|
68
67
|
)
|
|
69
68
|
}
|
|
70
69
|
|
|
70
|
+
/** Whether `path` is `parent` or stands below it, both relative to one root. */
|
|
71
|
+
function isWithin(path: string, parent: string): boolean {
|
|
72
|
+
const rel = relative(join('/', parent), join('/', path))
|
|
73
|
+
return (
|
|
74
|
+
rel === '' ||
|
|
75
|
+
(rel !== '..' && !rel.startsWith(`..${sep}`) && !isAbsolute(rel))
|
|
76
|
+
)
|
|
77
|
+
}
|
|
78
|
+
|
|
71
79
|
/**
|
|
72
|
-
* Reads `canon/config/preview.json` as
|
|
80
|
+
* Reads `canon/config/preview.json` as
|
|
81
|
+
* `{ "writes": [...], "keep": [...], "env": "<NAME>" }`.
|
|
73
82
|
* A file that does not parse refuses rather than reading as absent, since a
|
|
74
83
|
* typo there would otherwise drive the real folders while the pass reports a
|
|
75
|
-
* copy. A `writes` path that is absolute or climbs with `..`
|
|
76
|
-
* copy never reads or writes outside its two roots
|
|
84
|
+
* copy. A `writes` or `keep` path that is absolute or climbs with `..`
|
|
85
|
+
* refuses, so the copy never reads or writes outside its two roots, and a
|
|
86
|
+
* `keep` path outside every `writes` path refuses, since the rebuild would
|
|
87
|
+
* never reach it, and so does one equal to a `writes` path, which would be
|
|
88
|
+
* declared both reset and kept.
|
|
77
89
|
*/
|
|
78
90
|
export function loadPreviewConfig(worktree: string): PreviewConfigRead {
|
|
79
91
|
const path = join(worktree, PREVIEW_CONFIG_REL)
|
|
@@ -97,7 +109,7 @@ export function loadPreviewConfig(worktree: string): PreviewConfigRead {
|
|
|
97
109
|
}
|
|
98
110
|
}
|
|
99
111
|
|
|
100
|
-
const { writes, env } = parsed as Record<string, unknown>
|
|
112
|
+
const { writes, env, keep = [] } = parsed as Record<string, unknown>
|
|
101
113
|
if (
|
|
102
114
|
!Array.isArray(writes) ||
|
|
103
115
|
!writes.every((item): item is string => typeof item === 'string')
|
|
@@ -108,6 +120,16 @@ export function loadPreviewConfig(worktree: string): PreviewConfigRead {
|
|
|
108
120
|
detail: `${PREVIEW_CONFIG_REL} carries no "writes" list of paths.`,
|
|
109
121
|
}
|
|
110
122
|
}
|
|
123
|
+
if (
|
|
124
|
+
!Array.isArray(keep) ||
|
|
125
|
+
!keep.every((item): item is string => typeof item === 'string')
|
|
126
|
+
) {
|
|
127
|
+
return {
|
|
128
|
+
kind: 'refused',
|
|
129
|
+
reason: 'bad-config',
|
|
130
|
+
detail: `${PREVIEW_CONFIG_REL} names a "keep" that is not a list of paths.`,
|
|
131
|
+
}
|
|
132
|
+
}
|
|
111
133
|
if (env !== undefined && (typeof env !== 'string' || env === '')) {
|
|
112
134
|
return {
|
|
113
135
|
kind: 'refused',
|
|
@@ -116,7 +138,7 @@ export function loadPreviewConfig(worktree: string): PreviewConfigRead {
|
|
|
116
138
|
}
|
|
117
139
|
}
|
|
118
140
|
|
|
119
|
-
const unsafe = writes.find((item) => !isContained(item))
|
|
141
|
+
const unsafe = [...writes, ...keep].find((item) => !isContained(item))
|
|
120
142
|
if (unsafe !== undefined) {
|
|
121
143
|
return {
|
|
122
144
|
kind: 'refused',
|
|
@@ -125,94 +147,85 @@ export function loadPreviewConfig(worktree: string): PreviewConfigRead {
|
|
|
125
147
|
}
|
|
126
148
|
}
|
|
127
149
|
|
|
150
|
+
const stray = keep.find(
|
|
151
|
+
(item) => !writes.some((path) => isWithin(item, path)),
|
|
152
|
+
)
|
|
153
|
+
if (stray !== undefined) {
|
|
154
|
+
return {
|
|
155
|
+
kind: 'refused',
|
|
156
|
+
reason: 'bad-config',
|
|
157
|
+
detail: `${PREVIEW_CONFIG_REL} keeps "${stray}", which no "writes" path holds.`,
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const clash = keep.find((item) =>
|
|
162
|
+
writes.some((path) => isWithin(item, path) && isWithin(path, item)),
|
|
163
|
+
)
|
|
164
|
+
if (clash !== undefined) {
|
|
165
|
+
return {
|
|
166
|
+
kind: 'refused',
|
|
167
|
+
reason: 'bad-config',
|
|
168
|
+
detail: `${PREVIEW_CONFIG_REL} keeps "${clash}", which is also a "writes" path, so the rebuild would never reset it.`,
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
128
172
|
return {
|
|
129
173
|
kind: 'read',
|
|
130
|
-
config: { writes, env: env ?? DEFAULT_PREVIEW_ENV },
|
|
174
|
+
config: { writes, env: env ?? DEFAULT_PREVIEW_ENV, keep },
|
|
131
175
|
}
|
|
132
176
|
}
|
|
133
177
|
|
|
134
178
|
/**
|
|
135
|
-
*
|
|
136
|
-
*
|
|
137
|
-
* is
|
|
179
|
+
* Removes `target` except the `keep` paths at or below it, where `rel` is
|
|
180
|
+
* `target` relative to the root `keep` is read against. A folder holding a
|
|
181
|
+
* kept path is emptied around it rather than removed.
|
|
182
|
+
*/
|
|
183
|
+
function clearAround(
|
|
184
|
+
target: string,
|
|
185
|
+
rel: string,
|
|
186
|
+
keep: readonly string[],
|
|
187
|
+
): void {
|
|
188
|
+
if (keep.includes(rel)) return
|
|
189
|
+
const stat = lstatSync(target, { throwIfNoEntry: false })
|
|
190
|
+
if (stat === undefined) return
|
|
191
|
+
if (!stat.isDirectory() || !keep.some((path) => isWithin(path, rel))) {
|
|
192
|
+
rmSync(target, { recursive: true, force: true })
|
|
193
|
+
return
|
|
194
|
+
}
|
|
195
|
+
for (const entry of readdirSync(target)) {
|
|
196
|
+
clearAround(join(target, entry), join(rel, entry), keep)
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Resets each declared path in the copy from the worktree, so the last pass's
|
|
202
|
+
* drives never reach the next one, and leaves everything else under the copy
|
|
203
|
+
* root as it is. A `keep` path inside a declared path is neither cleared nor
|
|
204
|
+
* copied, since the server running on the copy owns it. A path the worktree
|
|
205
|
+
* does not hold has its stale copy removed and is named rather than failing
|
|
206
|
+
* the copy.
|
|
138
207
|
*/
|
|
139
208
|
export function buildDriveCopy(
|
|
140
209
|
worktree: string,
|
|
141
210
|
copyRoot: string,
|
|
142
211
|
writes: readonly string[],
|
|
212
|
+
keep: readonly string[] = [],
|
|
143
213
|
): { readonly copied: readonly string[]; readonly missing: readonly string[] } {
|
|
144
|
-
|
|
214
|
+
const kept = keep.map((path) => relative(worktree, join(worktree, path)))
|
|
145
215
|
const copied: string[] = []
|
|
146
216
|
const missing: string[] = []
|
|
147
217
|
for (const path of writes) {
|
|
148
218
|
const source = join(worktree, path)
|
|
219
|
+
clearAround(join(copyRoot, path), relative(worktree, source), kept)
|
|
149
220
|
if (!existsSync(source)) {
|
|
150
221
|
missing.push(path)
|
|
151
222
|
continue
|
|
152
223
|
}
|
|
153
|
-
cpSync(source, join(copyRoot, path), {
|
|
224
|
+
cpSync(source, join(copyRoot, path), {
|
|
225
|
+
recursive: true,
|
|
226
|
+
filter: (item) => !kept.includes(relative(worktree, item)),
|
|
227
|
+
})
|
|
154
228
|
copied.push(path)
|
|
155
229
|
}
|
|
156
230
|
return { copied, missing }
|
|
157
231
|
}
|
|
158
|
-
|
|
159
|
-
function digestOf(path: string): string | null {
|
|
160
|
-
try {
|
|
161
|
-
if (!statSync(path).isFile()) return null
|
|
162
|
-
return createHash('sha1').update(readFileSync(path)).digest('hex')
|
|
163
|
-
} catch {
|
|
164
|
-
return null
|
|
165
|
-
}
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
/**
|
|
169
|
-
* Every entry `git status` reports, untracked files listed one by one so a
|
|
170
|
-
* file a drive adds inside a tracked folder names itself, each with a content
|
|
171
|
-
* digest so a file already dirty at the baseline still reads as changed when
|
|
172
|
-
* a drive writes it again. Undefined when git cannot read the tree.
|
|
173
|
-
*/
|
|
174
|
-
export async function readWorktreeStatus(
|
|
175
|
-
worktree: string,
|
|
176
|
-
): Promise<readonly StatusEntry[] | undefined> {
|
|
177
|
-
const result =
|
|
178
|
-
await $`git -C ${worktree} status --porcelain=v1 -z --untracked-files=all`
|
|
179
|
-
.env(gitEnv())
|
|
180
|
-
.quiet()
|
|
181
|
-
.nothrow()
|
|
182
|
-
if (result.exitCode !== 0) return undefined
|
|
183
|
-
|
|
184
|
-
const tokens = result.stdout.toString().split('\0')
|
|
185
|
-
const entries: StatusEntry[] = []
|
|
186
|
-
for (let index = 0; index < tokens.length; index++) {
|
|
187
|
-
const token = tokens[index] ?? ''
|
|
188
|
-
if (token.length < 4) continue
|
|
189
|
-
const status = token.slice(0, 2)
|
|
190
|
-
const path = token.slice(3)
|
|
191
|
-
// A rename or copy carries its source as the next token.
|
|
192
|
-
if (status.includes('R') || status.includes('C')) index++
|
|
193
|
-
entries.push({ path, status, digest: digestOf(join(worktree, path)) })
|
|
194
|
-
}
|
|
195
|
-
return entries
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
/**
|
|
199
|
-
* The paths whose status or content differs from the baseline, in the order
|
|
200
|
-
* git reports them. An entry gone since the baseline is not reported, since a
|
|
201
|
-
* worker committing its own edit mid-pass clears it the same way.
|
|
202
|
-
*/
|
|
203
|
-
export function changedSince(
|
|
204
|
-
baseline: readonly StatusEntry[],
|
|
205
|
-
current: readonly StatusEntry[],
|
|
206
|
-
): readonly string[] {
|
|
207
|
-
const before = new Map(baseline.map((entry) => [entry.path, entry]))
|
|
208
|
-
return current
|
|
209
|
-
.filter((entry) => {
|
|
210
|
-
const prior = before.get(entry.path)
|
|
211
|
-
return (
|
|
212
|
-
prior === undefined ||
|
|
213
|
-
prior.status !== entry.status ||
|
|
214
|
-
prior.digest !== entry.digest
|
|
215
|
-
)
|
|
216
|
-
})
|
|
217
|
-
.map((entry) => entry.path)
|
|
218
|
-
}
|
package/src/pr/health.ts
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
|
|
4
|
+
/** Where a project declares what `canon pr local` checks beyond the page. */
|
|
5
|
+
export const LOCAL_CONFIG_REL = join('canon', 'config', 'local.toml')
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The health paths a project declared. `none` is an absent file or an empty
|
|
9
|
+
* `health = []`. `invalid` is a declaration that exists and cannot be used,
|
|
10
|
+
* which refuses rather than falling back to the page-only probe, since that
|
|
11
|
+
* fallback is the failure the declaration exists to catch.
|
|
12
|
+
*/
|
|
13
|
+
export type HealthDeclaration =
|
|
14
|
+
| { readonly kind: 'paths'; readonly paths: readonly string[] }
|
|
15
|
+
| { readonly kind: 'none' }
|
|
16
|
+
| { readonly kind: 'invalid'; readonly reason: string }
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* A path on the page's own host. A second leading slash would read as a
|
|
20
|
+
* protocol-relative host, so it is refused to keep the probe on localhost.
|
|
21
|
+
*/
|
|
22
|
+
function isLocalPath(entry: unknown): entry is string {
|
|
23
|
+
return (
|
|
24
|
+
typeof entry === 'string' &&
|
|
25
|
+
entry.startsWith('/') &&
|
|
26
|
+
!entry.startsWith('//') &&
|
|
27
|
+
!entry.startsWith('/\\')
|
|
28
|
+
)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function readHealthPaths(root: string): HealthDeclaration {
|
|
32
|
+
const path = join(root, LOCAL_CONFIG_REL)
|
|
33
|
+
if (!existsSync(path)) return { kind: 'none' }
|
|
34
|
+
|
|
35
|
+
let parsed: Record<string, unknown>
|
|
36
|
+
try {
|
|
37
|
+
parsed = Bun.TOML.parse(readFileSync(path, 'utf8')) as Record<
|
|
38
|
+
string,
|
|
39
|
+
unknown
|
|
40
|
+
>
|
|
41
|
+
} catch {
|
|
42
|
+
return { kind: 'invalid', reason: `${LOCAL_CONFIG_REL} is not valid TOML` }
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const declared = parsed.health
|
|
46
|
+
if (!Array.isArray(declared) || !declared.every(isLocalPath)) {
|
|
47
|
+
return {
|
|
48
|
+
kind: 'invalid',
|
|
49
|
+
reason: `${LOCAL_CONFIG_REL} needs health as a list of paths, each starting with a single /`,
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return declared.length === 0
|
|
54
|
+
? { kind: 'none' }
|
|
55
|
+
: { kind: 'paths', paths: declared }
|
|
56
|
+
}
|
package/src/pr/local.ts
CHANGED
|
@@ -2,27 +2,62 @@ import { belongsTo } from '@/listeners/belongs'
|
|
|
2
2
|
import type { ListenerReader } from '@/listeners/machine'
|
|
3
3
|
import { readAddresses } from '@/pr/evidence'
|
|
4
4
|
|
|
5
|
-
export type LocalRefusal = 'no-server' | 'no-listener-reader'
|
|
5
|
+
export type LocalRefusal = 'no-server' | 'no-listener-reader' | 'unhealthy'
|
|
6
6
|
|
|
7
|
-
/** The listener reads a detection makes plus
|
|
7
|
+
/** The listener reads a detection makes plus two HTTP probes, injected so it can be driven without binding a socket. */
|
|
8
8
|
export interface LocalRunner extends ListenerReader {
|
|
9
9
|
/** Whether the port serves an HTML page at its root inside the runner's timeout. */
|
|
10
10
|
probe(port: number): Promise<boolean>
|
|
11
|
+
/** The final status a path on the port answers after redirects, or null when nothing answers inside the timeout. */
|
|
12
|
+
check(port: number, path: string): Promise<number | null>
|
|
11
13
|
}
|
|
12
14
|
|
|
13
15
|
export type LocalResult =
|
|
14
16
|
| { readonly kind: 'found'; readonly port: number; readonly url: string }
|
|
15
|
-
| {
|
|
17
|
+
| {
|
|
18
|
+
readonly kind: 'refused'
|
|
19
|
+
readonly reason: Exclude<LocalRefusal, 'unhealthy'>
|
|
20
|
+
}
|
|
21
|
+
| {
|
|
22
|
+
readonly kind: 'refused'
|
|
23
|
+
readonly reason: 'unhealthy'
|
|
24
|
+
readonly port: number
|
|
25
|
+
readonly path: string
|
|
26
|
+
readonly status: number | null
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
interface HealthFailure {
|
|
30
|
+
readonly path: string
|
|
31
|
+
readonly status: number | null
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async function firstFailingPath(
|
|
35
|
+
port: number,
|
|
36
|
+
healthPaths: readonly string[],
|
|
37
|
+
runner: LocalRunner,
|
|
38
|
+
): Promise<HealthFailure | undefined> {
|
|
39
|
+
for (const path of healthPaths) {
|
|
40
|
+
const status = await runner.check(port, path)
|
|
41
|
+
if (status === null || status < 200 || status >= 300) {
|
|
42
|
+
return { path, status }
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return undefined
|
|
46
|
+
}
|
|
16
47
|
|
|
17
48
|
/**
|
|
18
49
|
* Finds the server this worktree is running: the lowest port whose listening
|
|
19
|
-
* process runs inside the worktree
|
|
20
|
-
* than a process is the unit, since one
|
|
21
|
-
* socket for its reload channel, and the
|
|
50
|
+
* process runs inside the worktree, answers an HTTP probe, and passes every
|
|
51
|
+
* declared health path. A port rather than a process is the unit, since one
|
|
52
|
+
* dev server often holds a second socket for its reload channel, and the
|
|
53
|
+
* lower one is the page by convention. Health is read on the page's own port,
|
|
54
|
+
* since that is the address a reviewer drives, and a page whose backend fails
|
|
55
|
+
* refuses as `unhealthy` naming the lowest page port's first failing path.
|
|
22
56
|
*/
|
|
23
57
|
export async function findLocalServer(
|
|
24
58
|
worktreeRoot: string,
|
|
25
59
|
runner: LocalRunner,
|
|
60
|
+
healthPaths: readonly string[] = [],
|
|
26
61
|
): Promise<LocalResult> {
|
|
27
62
|
const listeners = await runner.listListeners()
|
|
28
63
|
if (listeners === undefined) {
|
|
@@ -37,12 +72,18 @@ export async function findLocalServer(
|
|
|
37
72
|
if (cwd !== undefined && belongsTo(cwd, worktreeRoot)) ports.add(port)
|
|
38
73
|
}
|
|
39
74
|
|
|
75
|
+
let unhealthy: (HealthFailure & { readonly port: number }) | undefined
|
|
40
76
|
for (const port of [...ports].sort((a, b) => a - b)) {
|
|
41
|
-
if (await runner.probe(port))
|
|
77
|
+
if (!(await runner.probe(port))) continue
|
|
78
|
+
const failure = await firstFailingPath(port, healthPaths, runner)
|
|
79
|
+
if (failure === undefined) {
|
|
42
80
|
return { kind: 'found', port, url: `http://localhost:${port}` }
|
|
43
81
|
}
|
|
82
|
+
unhealthy ??= { port, ...failure }
|
|
44
83
|
}
|
|
45
|
-
return
|
|
84
|
+
return unhealthy === undefined
|
|
85
|
+
? { kind: 'refused', reason: 'no-server' }
|
|
86
|
+
: { kind: 'refused', reason: 'unhealthy', ...unhealthy }
|
|
46
87
|
}
|
|
47
88
|
|
|
48
89
|
/**
|
package/src/pr/rest.ts
CHANGED
|
@@ -15,8 +15,16 @@ export interface RestPull {
|
|
|
15
15
|
readonly number?: number
|
|
16
16
|
readonly title?: string
|
|
17
17
|
readonly body?: string | null
|
|
18
|
-
readonly head?: {
|
|
19
|
-
|
|
18
|
+
readonly head?: {
|
|
19
|
+
readonly ref?: string
|
|
20
|
+
readonly sha?: string
|
|
21
|
+
/** Null when the fork the head lived in was deleted. */
|
|
22
|
+
readonly repo?: { readonly full_name?: string } | null
|
|
23
|
+
}
|
|
24
|
+
readonly base?: {
|
|
25
|
+
readonly ref?: string
|
|
26
|
+
readonly repo?: { readonly full_name?: string }
|
|
27
|
+
}
|
|
20
28
|
readonly mergeable_state?: string
|
|
21
29
|
readonly state?: string
|
|
22
30
|
readonly draft?: boolean
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { bodyLines, wordCount } from '@/markdown/scan'
|
|
2
|
+
|
|
3
|
+
/** The pre-compaction handoff takes one file per session, so its stems vary. */
|
|
4
|
+
export const SESSION_MAP_PREFIX = 'session-'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The most words a session map holds, which `session.md` states as
|
|
8
|
+
* `at most 350 words`. It lives here rather than being read off the standard
|
|
9
|
+
* because a consumer project loads skills from the plugin cache and carries no
|
|
10
|
+
* `session.md` to read, so a test pins the two to one number instead.
|
|
11
|
+
*/
|
|
12
|
+
export const SESSION_MAP_WORD_CAP = 350
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The closing block a role mandates, read by the resuming session rather than
|
|
16
|
+
* written from what this one learned, so it is left out of the count by name.
|
|
17
|
+
*/
|
|
18
|
+
const RESUME_HEADING = '## Resume'
|
|
19
|
+
|
|
20
|
+
const TITLE = /^#[ \t]+\S/
|
|
21
|
+
const H2 = /^##[ \t]+\S/
|
|
22
|
+
|
|
23
|
+
export interface SessionMapLength {
|
|
24
|
+
/** The map's filename inside the tasks folder. */
|
|
25
|
+
readonly record: string
|
|
26
|
+
readonly words: number
|
|
27
|
+
readonly cap: number
|
|
28
|
+
readonly over: boolean
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function isSessionMap(name: string): boolean {
|
|
32
|
+
return name.startsWith(SESSION_MAP_PREFIX) && name.endsWith('.md')
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Counts what a resuming reader has to read: the body below the title, with
|
|
37
|
+
* frontmatter and a `## Resume` section left out. A fenced block counts the way
|
|
38
|
+
* it does in the requirements record, since a reader reads it too, and a
|
|
39
|
+
* heading inside one neither opens nor closes a section.
|
|
40
|
+
*/
|
|
41
|
+
export function sessionMapWords(source: string): number {
|
|
42
|
+
const kept: string[] = []
|
|
43
|
+
let titled = false
|
|
44
|
+
let resuming = false
|
|
45
|
+
|
|
46
|
+
for (const line of bodyLines(source)) {
|
|
47
|
+
const text = line.text.trim()
|
|
48
|
+
|
|
49
|
+
if (!line.fenced && H2.test(text)) resuming = text === RESUME_HEADING
|
|
50
|
+
if (resuming) continue
|
|
51
|
+
|
|
52
|
+
if (!titled && !line.fenced && TITLE.test(text)) {
|
|
53
|
+
titled = true
|
|
54
|
+
continue
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
kept.push(line.text)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return wordCount(kept.join('\n'))
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function measureSessionMap(
|
|
64
|
+
record: string,
|
|
65
|
+
source: string,
|
|
66
|
+
): SessionMapLength {
|
|
67
|
+
const words = sessionMapWords(source)
|
|
68
|
+
|
|
69
|
+
return {
|
|
70
|
+
record,
|
|
71
|
+
words,
|
|
72
|
+
cap: SESSION_MAP_WORD_CAP,
|
|
73
|
+
over: words > SESSION_MAP_WORD_CAP,
|
|
74
|
+
}
|
|
75
|
+
}
|
package/src/records/validate.ts
CHANGED
|
@@ -3,6 +3,11 @@ import { readdir, readFile } from 'node:fs/promises'
|
|
|
3
3
|
import { join } from 'node:path'
|
|
4
4
|
import { parseFrontmatter, readField } from '@/indexes/frontmatter'
|
|
5
5
|
import { linesOutsideFences } from '@/markdown/scan'
|
|
6
|
+
import {
|
|
7
|
+
isSessionMap,
|
|
8
|
+
measureSessionMap,
|
|
9
|
+
type SessionMapLength,
|
|
10
|
+
} from '@/records/session-map'
|
|
6
11
|
import {
|
|
7
12
|
intakeStructure,
|
|
8
13
|
type StructureEntry,
|
|
@@ -13,7 +18,12 @@ import {
|
|
|
13
18
|
recordDirs as resolveRecordDirs,
|
|
14
19
|
} from '@/roots/record'
|
|
15
20
|
|
|
16
|
-
export const RECORD_KINDS = [
|
|
21
|
+
export const RECORD_KINDS = [
|
|
22
|
+
'plans',
|
|
23
|
+
'groundwork',
|
|
24
|
+
'intake',
|
|
25
|
+
'sessions',
|
|
26
|
+
] as const
|
|
17
27
|
|
|
18
28
|
export type RecordKind = (typeof RECORD_KINDS)[number]
|
|
19
29
|
|
|
@@ -79,6 +89,12 @@ export interface ValidateReport {
|
|
|
79
89
|
readonly records: number
|
|
80
90
|
readonly findings: readonly Finding[]
|
|
81
91
|
readonly structure: readonly StructureFinding[]
|
|
92
|
+
/**
|
|
93
|
+
* Each session map's words against its cap, carried by the sessions kind
|
|
94
|
+
* alone. It never sets the exit, since a map refused near a compaction loses
|
|
95
|
+
* the reasoning it was written to save.
|
|
96
|
+
*/
|
|
97
|
+
readonly length?: readonly SessionMapLength[]
|
|
82
98
|
}
|
|
83
99
|
|
|
84
100
|
export interface ValidateRefused {
|
|
@@ -89,9 +105,20 @@ export interface ValidateRefused {
|
|
|
89
105
|
|
|
90
106
|
export type ValidateOutcome = ValidateReport | ValidateRefused
|
|
91
107
|
|
|
108
|
+
/**
|
|
109
|
+
* The record folder each kind reads. Session maps sit on the task board beside
|
|
110
|
+
* the tasks rather than in a folder of their own.
|
|
111
|
+
*/
|
|
112
|
+
const KIND_FOLDER: Readonly<Record<RecordKind, string>> = {
|
|
113
|
+
plans: 'plans',
|
|
114
|
+
groundwork: 'groundwork',
|
|
115
|
+
intake: 'intake',
|
|
116
|
+
sessions: 'tasks',
|
|
117
|
+
}
|
|
118
|
+
|
|
92
119
|
/** Every folder a kind would accept, whether or not it is on disk. */
|
|
93
120
|
export function recordDirs(root: string, kind: RecordKind): string[] {
|
|
94
|
-
return resolveRecordDirs(root, kind)
|
|
121
|
+
return resolveRecordDirs(root, KIND_FOLDER[kind])
|
|
95
122
|
}
|
|
96
123
|
|
|
97
124
|
/**
|
|
@@ -100,7 +127,7 @@ export function recordDirs(root: string, kind: RecordKind): string[] {
|
|
|
100
127
|
* the location the kind would be written to.
|
|
101
128
|
*/
|
|
102
129
|
export function recordsDir(root: string, kind: RecordKind): string {
|
|
103
|
-
return resolveRecordDir(root, kind)
|
|
130
|
+
return resolveRecordDir(root, KIND_FOLDER[kind])
|
|
104
131
|
}
|
|
105
132
|
|
|
106
133
|
export function isRecordKind(value: string): value is RecordKind {
|
|
@@ -758,7 +785,7 @@ async function checkDump(dir: string, slug: string): Promise<Finding[]> {
|
|
|
758
785
|
*/
|
|
759
786
|
const FOLDER_CHECK: Readonly<
|
|
760
787
|
Record<
|
|
761
|
-
Exclude<RecordKind, 'plans'>,
|
|
788
|
+
Exclude<RecordKind, 'plans' | 'sessions'>,
|
|
762
789
|
(dir: string, slug: string) => Promise<Finding[]>
|
|
763
790
|
>
|
|
764
791
|
> = {
|
|
@@ -836,6 +863,37 @@ async function validateFiles(
|
|
|
836
863
|
}
|
|
837
864
|
}
|
|
838
865
|
|
|
866
|
+
/**
|
|
867
|
+
* The walk for session maps, which measures length alone. Their shape is not
|
|
868
|
+
* checked, so `findings` stays empty and the exit stays clean whatever the
|
|
869
|
+
* count reads.
|
|
870
|
+
*/
|
|
871
|
+
async function validateSessionMaps(
|
|
872
|
+
dir: string,
|
|
873
|
+
files: readonly string[],
|
|
874
|
+
): Promise<ValidateReport> {
|
|
875
|
+
const length = await Promise.all(
|
|
876
|
+
files.map(async (file) =>
|
|
877
|
+
measureSessionMap(file, await readFile(join(dir, file), 'utf8')),
|
|
878
|
+
),
|
|
879
|
+
)
|
|
880
|
+
|
|
881
|
+
return {
|
|
882
|
+
ok: true,
|
|
883
|
+
kind: 'sessions',
|
|
884
|
+
records: files.length,
|
|
885
|
+
findings: [],
|
|
886
|
+
structure: [],
|
|
887
|
+
length,
|
|
888
|
+
}
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
async function listRecords(dir: string, kind: RecordKind): Promise<string[]> {
|
|
892
|
+
if (kind === 'plans') return listMarkdown(dir)
|
|
893
|
+
if (kind === 'sessions') return (await listMarkdown(dir)).filter(isSessionMap)
|
|
894
|
+
return listFolders(dir)
|
|
895
|
+
}
|
|
896
|
+
|
|
839
897
|
/**
|
|
840
898
|
* Narrows a listing to the one record a caller named, by a plan's stem or a
|
|
841
899
|
* folder's slug, so a writing skill reads its own record and not every old one.
|
|
@@ -867,16 +925,14 @@ export async function validateRecords(
|
|
|
867
925
|
)
|
|
868
926
|
}
|
|
869
927
|
|
|
870
|
-
const names = narrow(
|
|
871
|
-
kind === 'plans' ? await listMarkdown(dir) : await listFolders(dir),
|
|
872
|
-
record,
|
|
873
|
-
)
|
|
928
|
+
const names = narrow(await listRecords(dir, kind), record)
|
|
874
929
|
|
|
875
930
|
if (record !== undefined && names.length === 0) {
|
|
876
931
|
return refuse('no-record', `No ${kind} record named ${record} in ${dir}.`)
|
|
877
932
|
}
|
|
878
933
|
|
|
879
934
|
if (kind === 'plans') return validateFiles(dir, kind, names, checkPlan)
|
|
935
|
+
if (kind === 'sessions') return validateSessionMaps(dir, names)
|
|
880
936
|
|
|
881
937
|
const check = FOLDER_CHECK[kind]
|
|
882
938
|
const perFolder = await Promise.all(
|
|
@@ -81,6 +81,35 @@ function isCopied(rel: string): boolean {
|
|
|
81
81
|
return rel.split('/').at(-1) !== 'node_modules' && rel !== '.claude/worktrees'
|
|
82
82
|
}
|
|
83
83
|
|
|
84
|
+
/**
|
|
85
|
+
* Git's background maintenance creates and removes files such as
|
|
86
|
+
* `.git/objects/maintenance.lock` while a copy walks the checkout, and a file
|
|
87
|
+
* listed then gone throws ENOENT. A short bound covers every transient name
|
|
88
|
+
* git uses, where a filter on `*.lock` would cover one.
|
|
89
|
+
*/
|
|
90
|
+
const COPY_ATTEMPTS = 3
|
|
91
|
+
|
|
92
|
+
export function copyCheckout(
|
|
93
|
+
source: ReplaySource,
|
|
94
|
+
tree: string,
|
|
95
|
+
copy: typeof cpSync = cpSync,
|
|
96
|
+
): void {
|
|
97
|
+
for (let attempt = 1; ; attempt++) {
|
|
98
|
+
try {
|
|
99
|
+
copy(source.path, tree, {
|
|
100
|
+
recursive: true,
|
|
101
|
+
verbatimSymlinks: true,
|
|
102
|
+
filter: (from) => isCopied(relative(source.path, from)),
|
|
103
|
+
})
|
|
104
|
+
return
|
|
105
|
+
} catch (error) {
|
|
106
|
+
const vanished = (error as NodeJS.ErrnoException).code === 'ENOENT'
|
|
107
|
+
if (!vanished || attempt === COPY_ATTEMPTS) throw error
|
|
108
|
+
rmSync(tree, { recursive: true, force: true })
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
84
113
|
/**
|
|
85
114
|
* Copies a source into `tree`. A snapshot's files lose their `.fixture`
|
|
86
115
|
* suffix and its recorded expectation stays behind, and a checkout copies whole rather than cloning, since a clone
|
|
@@ -88,11 +117,7 @@ function isCopied(rel: string): boolean {
|
|
|
88
117
|
*/
|
|
89
118
|
export function copySource(source: ReplaySource, tree: string): void {
|
|
90
119
|
if (source.kind === 'checkout') {
|
|
91
|
-
|
|
92
|
-
recursive: true,
|
|
93
|
-
verbatimSymlinks: true,
|
|
94
|
-
filter: (from) => isCopied(relative(source.path, from)),
|
|
95
|
-
})
|
|
120
|
+
copyCheckout(source, tree)
|
|
96
121
|
return
|
|
97
122
|
}
|
|
98
123
|
|