@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.
Files changed (84) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/claude/.claude-plugin/plugin.json +1 -1
  3. package/claude/skills/app-drive/REQUIREMENT.md +35 -0
  4. package/claude/skills/app-drive/SKILL.md +112 -0
  5. package/claude/skills/app-drive/references/config.md +38 -0
  6. package/claude/skills/app-drive/references/drive-record.md +70 -0
  7. package/claude/skills/app-drive/references/isolate.md +46 -0
  8. package/claude/skills/canon-cli/SKILL.md +2 -0
  9. package/claude/skills/create-skill/references/skill.md +1 -1
  10. package/claude/skills/git-pr/references/labels.md +6 -2
  11. package/claude/skills/git-pr-close/REQUIREMENT.md +29 -0
  12. package/claude/skills/git-pr-close/SKILL.md +55 -0
  13. package/claude/skills/git-pr-close/references/close.md +54 -0
  14. package/claude/skills/plan-feature/references/plan.md +1 -1
  15. package/claude/skills/plan-intake/references/intake.md +12 -2
  16. package/claude/skills/pr-evidence/SKILL.md +2 -2
  17. package/claude/skills/pr-evidence/references/evidence.md +3 -1
  18. package/claude/skills/review-ui/SKILL.md +5 -4
  19. package/claude/skills/review-ui/references/drive-copy.md +4 -0
  20. package/claude/skills/review-ui/references/driving.md +1 -1
  21. package/claude/skills/role-orchestrator/references/orchestrator-handoff.md +15 -2
  22. package/claude/skills/role-planner/SKILL.md +2 -2
  23. package/claude/skills/role-worker/SKILL.md +6 -8
  24. package/claude/skills/session-map/REQUIREMENT.md +1 -1
  25. package/claude/skills/session-map/SKILL.md +8 -1
  26. package/claude/skills/session-map/references/session.md +19 -5
  27. package/claude/skills/ui-record/REQUIREMENT.md +34 -0
  28. package/claude/skills/ui-record/SKILL.md +103 -0
  29. package/claude/skills/ui-record/references/config.md +40 -0
  30. package/claude/skills/ui-record/references/handoff.md +48 -0
  31. package/claude/skills/ui-record/references/take-craft.md +92 -0
  32. package/docs/agents/records.md +1 -0
  33. package/docs/workflow/parallel-features.md +1 -1
  34. package/docs/workflow/skill-map-on-request.md +1 -0
  35. package/docs/workflow/skill-map.md +2 -0
  36. package/docs/workflow/visual-design-workflow.md +2 -1
  37. package/package.json +1 -1
  38. package/src/audits/catalog.ts +5 -1
  39. package/src/browser/agent-cli.ts +68 -0
  40. package/src/claude/cases/git.ts +4 -0
  41. package/src/claude/cases/workflow.ts +20 -0
  42. package/src/cli/help.ts +3 -0
  43. package/src/cli.ts +4 -0
  44. package/src/commands/browser.ts +102 -0
  45. package/src/commands/intake.ts +181 -1
  46. package/src/commands/labels.ts +152 -4
  47. package/src/commands/pr.ts +278 -8
  48. package/src/commands/records.ts +18 -1
  49. package/src/commands/take.ts +266 -0
  50. package/src/context/requirements.ts +1 -11
  51. package/src/git/status.ts +73 -0
  52. package/src/intake/drive-check.ts +355 -0
  53. package/src/intake/folder.ts +1 -1
  54. package/src/labels/apply.ts +10 -5
  55. package/src/labels/sync.ts +17 -0
  56. package/src/markdown/scan.ts +10 -0
  57. package/src/pr/close.ts +152 -0
  58. package/src/pr/drive-copy.ts +97 -84
  59. package/src/pr/health.ts +56 -0
  60. package/src/pr/local.ts +49 -8
  61. package/src/pr/rest.ts +10 -2
  62. package/src/records/session-map.ts +75 -0
  63. package/src/records/validate.ts +64 -8
  64. package/src/sandbox/replay/run.ts +30 -5
  65. package/src/take/camera.ts +309 -0
  66. package/src/take/encode.ts +170 -0
  67. package/src/take/film.ts +206 -0
  68. package/src/take/fixtures/fade.html +49 -0
  69. package/src/take/fixtures/fetch.html +38 -0
  70. package/src/take/log.ts +109 -0
  71. package/src/take/perform.ts +200 -0
  72. package/src/take/plan.ts +193 -0
  73. package/src/take/screencast.ts +94 -0
  74. package/src/take/shell.ts +287 -0
  75. package/src/take/stepper.ts +252 -0
  76. package/src/take/zoom/NOTICE.md +45 -0
  77. package/src/take/zoom/math-utils.ts +69 -0
  78. package/src/take/zoom/region-utils.ts +55 -0
  79. package/src/take/zoom/spring.ts +133 -0
  80. package/src/tasks/archive.ts +1 -3
  81. package/src/tooling/verify-stack.ts +4 -5
  82. package/tooling/base/reference.md +1 -0
  83. package/tooling/base/seeds/canon/config/pr-labels.toml +41 -0
  84. package/claude/skills/review-ui/scripts/pw.sh +0 -60
@@ -1,8 +1,12 @@
1
- import { createHash } from 'node:crypto'
2
- import { cpSync, existsSync, readFileSync, rmSync, statSync } from 'node:fs'
3
- import { isAbsolute, join } from 'node:path'
4
- import { $ } from 'bun'
5
- import { gitEnv } from '@/git/env'
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 `{ "writes": [...], "env": "<NAME>" }`.
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 `..` refuses, so the
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
- * Replaces the copy root with a fresh copy of each declared path, so the last
136
- * pass's drives never reach the next one. A path the worktree does not hold
137
- * is named rather than failing the copy.
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
- rmSync(copyRoot, { recursive: true, force: true })
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), { recursive: true })
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
- }
@@ -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 an HTML probe, injected so it can be driven without binding a socket. */
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
- | { readonly kind: 'refused'; readonly reason: LocalRefusal }
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 and answers an HTTP probe. A port rather
20
- * than a process is the unit, since one dev server often holds a second
21
- * socket for its reload channel, and the lower one is the page by convention.
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 { kind: 'refused', reason: 'no-server' }
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?: { readonly ref?: string; readonly sha?: string }
19
- readonly base?: { readonly ref?: string }
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
+ }
@@ -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 = ['plans', 'groundwork', 'intake'] as const
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
- cpSync(source.path, tree, {
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