@erclx/canon 5.77.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.
Files changed (125) hide show
  1. package/CHANGELOG.md +43 -0
  2. package/claude/.claude-plugin/plugin.json +1 -1
  3. package/claude/skills/app-drive/REQUIREMENT.md +12 -0
  4. package/claude/skills/app-drive/SKILL.md +44 -8
  5. package/claude/skills/app-drive/references/config.md +18 -8
  6. package/claude/skills/app-drive/references/drive-record.md +4 -0
  7. package/claude/skills/auto-ship/REQUIREMENT.md +1 -1
  8. package/claude/skills/auto-ship/SKILL.md +5 -4
  9. package/claude/skills/auto-ship/references/draft-mark.md +33 -15
  10. package/claude/skills/auto-ship/references/failure-recovery.md +4 -2
  11. package/claude/skills/auto-ship/references/ui-checklist.md +9 -1
  12. package/claude/skills/code-craft/REQUIREMENT.md +1 -1
  13. package/claude/skills/code-craft/SKILL.md +7 -0
  14. package/claude/skills/code-craft/references/adopted.md +8 -0
  15. package/claude/skills/git-followup/SKILL.md +1 -0
  16. package/claude/skills/git-pr/REQUIREMENT.md +2 -0
  17. package/claude/skills/git-pr/SKILL.md +2 -0
  18. package/claude/skills/git-pr/references/final-command.md +34 -2
  19. package/claude/skills/git-pr/references/pr.md +1 -1
  20. package/claude/skills/git-ship/REQUIREMENT.md +2 -2
  21. package/claude/skills/git-ship/SKILL.md +16 -11
  22. package/claude/skills/git-ship/references/ci-watch.md +28 -4
  23. package/claude/skills/markdown-craft/references/markdown.md +4 -4
  24. package/claude/skills/plan-feature/references/plan.md +2 -0
  25. package/claude/skills/pr-evidence/SKILL.md +6 -1
  26. package/claude/skills/pr-evidence/references/evidence.md +1 -0
  27. package/claude/skills/pr-evidence/references/reel.md +9 -1
  28. package/claude/skills/review-address/SKILL.md +2 -1
  29. package/claude/skills/review-craft/references/ui.md +13 -1
  30. package/claude/skills/review-pr/references/body-variants.md +2 -0
  31. package/claude/skills/review-pr/references/close-out.md +2 -0
  32. package/claude/skills/review-pr/references/key-changes.md +2 -1
  33. package/claude/skills/review-pr/references/post.md +1 -0
  34. package/claude/skills/review-ui/REQUIREMENT.md +1 -0
  35. package/claude/skills/review-ui/SKILL.md +2 -2
  36. package/claude/skills/review-ui/references/driving.md +32 -4
  37. package/claude/skills/review-ui/references/post.md +6 -2
  38. package/claude/skills/role-orchestrator/REQUIREMENT.md +1 -1
  39. package/claude/skills/role-orchestrator/SKILL.md +4 -4
  40. package/claude/skills/role-orchestrator/references/orchestrator-hand-launch.md +2 -0
  41. package/claude/skills/role-orchestrator/references/orchestrator-handback.md +1 -1
  42. package/claude/skills/role-orchestrator/references/orchestrator-limit-stops.md +47 -0
  43. package/claude/skills/role-orchestrator/references/orchestrator-output.md +24 -1
  44. package/claude/skills/role-orchestrator/references/orchestrator-poll.md +3 -2
  45. package/claude/skills/role-orchestrator/references/orchestrator-review-dispatch.md +35 -4
  46. package/claude/skills/role-orchestrator/references/orchestrator-reviewer-launch.md +1 -1
  47. package/claude/skills/role-orchestrator/references/orchestrator-sweep.md +3 -1
  48. package/claude/skills/role-worker/SKILL.md +2 -1
  49. package/claude/skills/task-board/SKILL.md +1 -0
  50. package/claude/skills/task-board/references/tasks.md +1 -0
  51. package/claude/skills/test-craft/SKILL.md +1 -0
  52. package/claude/skills/ui-checklist/REQUIREMENT.md +1 -1
  53. package/claude/skills/ui-checklist/SKILL.md +2 -1
  54. package/claude/skills/ui-craft/references/capture.md +16 -0
  55. package/claude/skills/ui-film/REQUIREMENT.md +2 -2
  56. package/claude/skills/ui-film/SKILL.md +6 -1
  57. package/claude/skills/ui-film/references/compose-craft.md +68 -11
  58. package/claude/skills/ui-film/references/config.md +2 -2
  59. package/claude/skills/ui-film/references/handoff.md +1 -1
  60. package/claude/skills/ui-film/references/harness.md +71 -0
  61. package/claude/skills/ui-film/references/take-craft.md +42 -17
  62. package/docs/workflow/ai-workflow.md +1 -1
  63. package/docs/workflow/operating-model.md +6 -5
  64. package/docs/workflow/parallel-features.md +2 -1
  65. package/docs/workflow/visual-design-workflow.md +2 -1
  66. package/governance/ledger/skills/code-craft/SKILL.md +9 -0
  67. package/governance/ledger/skills/test-craft/SKILL.md +1 -0
  68. package/governance/ledger/skills/ui-craft/capture.md +18 -5
  69. package/package.json +4 -3
  70. package/src/commands/pr.ts +255 -46
  71. package/src/commands/sessions.ts +88 -6
  72. package/src/commands/take.ts +12 -1
  73. package/src/commands/tasks.ts +41 -2
  74. package/src/design/components.ts +5 -107
  75. package/src/design/favicon.ts +21 -17
  76. package/src/design/render.ts +9 -27
  77. package/src/design/template.ts +42 -0
  78. package/src/design/templates/favicon.svg +17 -0
  79. package/src/design/templates/hand-drawn-figure.css +54 -0
  80. package/src/design/templates/preview.html +27 -0
  81. package/src/design/templates/scrollbar.css +29 -0
  82. package/src/design/templates/status.css +24 -0
  83. package/src/gate/embedded.ts +400 -0
  84. package/src/gate/stages.ts +8 -0
  85. package/src/git/files.ts +5 -2
  86. package/src/indexes/frontmatter.ts +27 -6
  87. package/src/indexes/render.ts +21 -3
  88. package/src/markdown/colons.ts +14 -4
  89. package/src/markdown/heading-case.ts +9 -2
  90. package/src/pr/bijection.ts +8 -0
  91. package/src/pr/evidence.ts +81 -13
  92. package/src/pr/frames.ts +1 -6
  93. package/src/pr/health.ts +63 -15
  94. package/src/pr/lift.ts +77 -9
  95. package/src/pr/links.ts +160 -0
  96. package/src/pr/local.ts +17 -4
  97. package/src/pr/paths.ts +20 -2
  98. package/src/pr/rest.ts +2 -0
  99. package/src/pr/review-scope.ts +28 -0
  100. package/src/pr/review-state.ts +5 -5
  101. package/src/pr/waiver.ts +45 -0
  102. package/src/sessions/stop.ts +145 -0
  103. package/src/sessions/transfer/bundle.ts +1 -1
  104. package/src/take/camera.ts +337 -208
  105. package/src/take/encode.ts +16 -42
  106. package/src/take/film.ts +7 -1
  107. package/src/take/frame-clock.ts +46 -0
  108. package/src/take/log.ts +43 -2
  109. package/src/take/perform.ts +85 -13
  110. package/src/take/plan.ts +26 -5
  111. package/src/take/reel.ts +30 -5
  112. package/src/take/screencast.ts +223 -13
  113. package/src/take/stepper.ts +24 -4
  114. package/src/tasks/holders.ts +198 -0
  115. package/src/tasks/validate.ts +148 -0
  116. package/src/commands/fixtures/gh.sh +0 -64
  117. package/src/take/fixtures/busy.html +0 -107
  118. package/src/take/fixtures/fade.html +0 -49
  119. package/src/take/fixtures/fetch.html +0 -38
  120. package/src/take/fixtures/input.html +0 -138
  121. package/src/take/zoom/NOTICE.md +0 -45
  122. package/src/take/zoom/math-utils.ts +0 -69
  123. package/src/take/zoom/region-utils.ts +0 -55
  124. package/src/take/zoom/spring.ts +0 -133
  125. /package/src/sessions/{transfer/transcript.ts → transcript.ts} +0 -0
package/src/pr/local.ts CHANGED
@@ -45,6 +45,11 @@ async function firstFailingPath(
45
45
  return undefined
46
46
  }
47
47
 
48
+ /** Whether `cwd` is `folder` or inside it, compared on whole path segments. */
49
+ function isUnder(cwd: string, folder: string): boolean {
50
+ return cwd === folder || cwd.startsWith(`${folder}/`)
51
+ }
52
+
48
53
  /**
49
54
  * Finds the server this worktree is running: the lowest port whose listening
50
55
  * process runs inside the worktree, answers an HTTP probe, and passes every
@@ -53,11 +58,14 @@ async function firstFailingPath(
53
58
  * lower one is the page by convention. Health is read on the page's own port,
54
59
  * since that is the address a reviewer drives, and a page whose backend fails
55
60
  * refuses as `unhealthy` naming the lowest page port's first failing path.
61
+ * A port whose listener runs under a `pageOnly` folder, matched on whole
62
+ * segments, is found on the page probe alone.
56
63
  */
57
64
  export async function findLocalServer(
58
65
  worktreeRoot: string,
59
66
  runner: LocalRunner,
60
67
  healthPaths: readonly string[] = [],
68
+ pageOnly: readonly string[] = [],
61
69
  ): Promise<LocalResult> {
62
70
  const listeners = await runner.listListeners()
63
71
  if (listeners === undefined) {
@@ -65,17 +73,22 @@ export async function findLocalServer(
65
73
  }
66
74
 
67
75
  const cwdByPid = new Map<number, string | undefined>()
68
- const ports = new Set<number>()
76
+ const ports = new Map<number, boolean>()
77
+ const folders = pageOnly.map((folder) => `${worktreeRoot}/${folder}`)
69
78
  for (const { pid, port } of listeners) {
70
79
  if (!cwdByPid.has(pid)) cwdByPid.set(pid, await runner.cwdOf(pid))
71
80
  const cwd = cwdByPid.get(pid)
72
- if (cwd !== undefined && belongsTo(cwd, worktreeRoot)) ports.add(port)
81
+ if (cwd === undefined || !belongsTo(cwd, worktreeRoot)) continue
82
+ const skipsHealth = folders.some((folder) => isUnder(cwd, folder))
83
+ ports.set(port, (ports.get(port) ?? false) || skipsHealth)
73
84
  }
74
85
 
75
86
  let unhealthy: (HealthFailure & { readonly port: number }) | undefined
76
- for (const port of [...ports].sort((a, b) => a - b)) {
87
+ for (const [port, skipsHealth] of [...ports].sort(([a], [b]) => a - b)) {
77
88
  if (!(await runner.probe(port))) continue
78
- const failure = await firstFailingPath(port, healthPaths, runner)
89
+ const failure = skipsHealth
90
+ ? undefined
91
+ : await firstFailingPath(port, healthPaths, runner)
79
92
  if (failure === undefined) {
80
93
  return { kind: 'found', port, url: `http://localhost:${port}` }
81
94
  }
package/src/pr/paths.ts CHANGED
@@ -77,7 +77,7 @@ const HEADING = /^(#{1,6})\s+(.+?)\s*$/
77
77
  * A `file.ts:42` or `file.ts:42-58` suffix, which is a reader's click target
78
78
  * rather than part of the name.
79
79
  */
80
- const LINE_SUFFIX = /:\d+(?:-\d+)?$/
80
+ export const LINE_SUFFIX = /:\d+(?:-\d+)?$/
81
81
 
82
82
  /**
83
83
  * A span that carries a line suffix and nothing else, such as `:642` beside
@@ -214,6 +214,21 @@ function disclaimsChange(bullet: string): boolean {
214
214
  * reports rather than reading as an empty section.
215
215
  */
216
216
  export function readSection(body: string, title: string): string | undefined {
217
+ return readSectionAt(body, title)?.text
218
+ }
219
+
220
+ /**
221
+ * The section's text and the character offset it starts at, counted in the body
222
+ * with its line endings normalized to `\n`.
223
+ *
224
+ * The offset is what lets a caller rewrite the section in place. Searching the
225
+ * body for the text instead finds the first copy, so an identical block above
226
+ * the heading would take the edit.
227
+ */
228
+ export function readSectionAt(
229
+ body: string,
230
+ title: string,
231
+ ): { readonly text: string; readonly offset: number } | undefined {
217
232
  const lines = body.replace(/\r\n/g, '\n').split('\n')
218
233
  const wanted = title.toLowerCase()
219
234
 
@@ -246,7 +261,10 @@ export function readSection(body: string, title: string): string | undefined {
246
261
  out.push(line)
247
262
  }
248
263
 
249
- return out.join('\n')
264
+ const offset = lines
265
+ .slice(0, start)
266
+ .reduce((sum, line) => sum + line.length + 1, 0)
267
+ return { text: out.join('\n'), offset }
250
268
  }
251
269
 
252
270
  /**
package/src/pr/rest.ts CHANGED
@@ -39,6 +39,7 @@ export interface RestReview {
39
39
  readonly body?: string | null
40
40
  readonly commit_id?: string | null
41
41
  readonly submitted_at?: string | null
42
+ readonly state?: string | null
42
43
  }
43
44
 
44
45
  /** One row of `repos/{owner}/{repo}/issues/<n>/comments`. */
@@ -117,6 +118,7 @@ export function reviewRowOf(review: RestReview): ReviewRow {
117
118
  ? null
118
119
  : { oid: review.commit_id },
119
120
  submittedAt: review.submitted_at ?? null,
121
+ ...(typeof review.state === 'string' && { state: review.state }),
120
122
  }
121
123
  }
122
124
 
@@ -47,6 +47,8 @@ export interface ReviewRow {
47
47
  readonly body?: string
48
48
  readonly commit?: { readonly oid?: string } | null
49
49
  readonly submittedAt?: string | null
50
+ /** GitHub's review state, `DISMISSED` once the operator withdrew the pass. */
51
+ readonly state?: string | null
50
52
  }
51
53
 
52
54
  /**
@@ -238,6 +240,32 @@ export function resolveUiScope(listing: ReviewListing): UiScope {
238
240
  }
239
241
  }
240
242
 
243
+ /**
244
+ * The newest code pass and the newest UI pass, each only when its own marker
245
+ * closes it, skipping any pass the operator dismissed. A body with no pass
246
+ * heading never stands, and neither does an older pass a newer one replaced,
247
+ * so a pass speaks only for the head it read.
248
+ */
249
+ export function standingPasses(reviews: readonly ReviewRow[]): ReviewRow[] {
250
+ let code: ReviewRow | undefined
251
+ let ui: ReviewRow | undefined
252
+ for (const row of reviews) {
253
+ if (row.state === 'DISMISSED') continue
254
+ const body = row.body ?? ''
255
+ if (headingOf(body) !== undefined) code = row
256
+ else if (resolveUiScope({ reviews: [row] }).state !== 'none') ui = row
257
+ }
258
+
259
+ const passes: ReviewRow[] = []
260
+ if (code !== undefined && markerOf(code.body ?? '') !== undefined) {
261
+ passes.push(code)
262
+ }
263
+ if (ui !== undefined && UI_MARKER.test(lastLine(ui.body ?? '') ?? '')) {
264
+ passes.push(ui)
265
+ }
266
+ return passes
267
+ }
268
+
241
269
  /**
242
270
  * Whether a covered commit names the head. A marker may carry a short sha, so
243
271
  * coverage is a prefix match on the head.
@@ -94,7 +94,7 @@ function familyOf(gate: GateResult, commit: string | undefined, tip: string) {
94
94
  */
95
95
  async function readAt(
96
96
  api: ReviewStateApi,
97
- hasChecklist: boolean,
97
+ hasDrivableBox: boolean,
98
98
  painted: PaintedPatterns,
99
99
  ): Promise<ReviewStateRead> {
100
100
  const [tip, reviews] = await Promise.all([api.readTip(), api.readReviews()])
@@ -105,7 +105,7 @@ async function readAt(
105
105
  const listing: { reviews: readonly ReviewRow[] } = { reviews }
106
106
  const scope = resolveReviewScope(listing)
107
107
  const uiScope = resolveUiScope(listing)
108
- const ui = await uiGate(api, painted, reviews, tip, hasChecklist)
108
+ const ui = await uiGate(api, painted, reviews, tip, hasDrivableBox)
109
109
  if (ui.reason === 'gh-failed') return { kind: 'refused', reason: 'gh-failed' }
110
110
 
111
111
  return {
@@ -117,7 +117,7 @@ async function readAt(
117
117
  ui: {
118
118
  ...uiScope,
119
119
  ...familyOf(ui, uiScope.commit, tip),
120
- isBound: hasChecklist,
120
+ isBound: hasDrivableBox || uiScope.state === 'open',
121
121
  ...(ui.carriedFrom !== undefined && { carriedFrom: ui.carriedFrom }),
122
122
  ...(ui.paintedPaths !== undefined && { paintedPaths: ui.paintedPaths }),
123
123
  ...(ui.drivenPaths !== undefined && { drivenPaths: ui.drivenPaths }),
@@ -132,7 +132,7 @@ export async function readReviewState(
132
132
  ): Promise<ReviewStateRead> {
133
133
  const evidence = await api.readEvidence()
134
134
  if (evidence === undefined) return { kind: 'refused', reason: 'gh-failed' }
135
- return readAt(api, evidence.hasChecklist, painted)
135
+ return readAt(api, evidence.hasDrivableBox, painted)
136
136
  }
137
137
 
138
138
  /**
@@ -158,7 +158,7 @@ export async function waitForReviews(
158
158
  }
159
159
 
160
160
  for (;;) {
161
- const read = await readAt(api, evidence.hasChecklist, painted)
161
+ const read = await readAt(api, evidence.hasDrivableBox, painted)
162
162
  if (read.kind === 'refused') {
163
163
  return { ...read, waitedSeconds: waited() }
164
164
  }
@@ -0,0 +1,45 @@
1
+ import type { ReviewRow } from '@/pr/review-scope'
2
+
3
+ /** A painted path a review accepted as owing no capture, and why. */
4
+ export interface Waiver {
5
+ readonly path: string
6
+ readonly reason: string
7
+ }
8
+
9
+ /**
10
+ * The line `review-pr` writes in a pass body to waive one path. The path is
11
+ * kept exactly as spelled, so a waiver matches an uncaptured path only when
12
+ * both spell it alike.
13
+ */
14
+ const WAIVER_LINE = /^- \*\*Waived capture:\*\* `([^`]+)`:\s*(\S.*)$/
15
+
16
+ const FENCE = /^\s*(```|~~~)/
17
+
18
+ /**
19
+ * Every waiver in the review bodies, in order. A line inside a fenced block is
20
+ * a quoted sample rather than a waiver, so it never counts.
21
+ *
22
+ * Review bodies alone are read, never the pull request body or an issue
23
+ * comment, since every session posts as one account and a review is the one
24
+ * surface a worker never writes.
25
+ */
26
+ export function readWaivers(reviews: readonly ReviewRow[]): Waiver[] {
27
+ const waivers: Waiver[] = []
28
+ for (const review of reviews) {
29
+ let fence: string | undefined
30
+ for (const line of (review.body ?? '').split('\n')) {
31
+ const opener = FENCE.exec(line)?.[1]
32
+ if (opener !== undefined) {
33
+ if (fence === undefined) fence = opener
34
+ else if (opener === fence) fence = undefined
35
+ continue
36
+ }
37
+ if (fence !== undefined) continue
38
+ const match = WAIVER_LINE.exec(line.trimEnd())
39
+ if (match?.[1] !== undefined && match[2] !== undefined) {
40
+ waivers.push({ path: match[1], reason: match[2] })
41
+ }
42
+ }
43
+ }
44
+ return waivers
45
+ }
@@ -0,0 +1,145 @@
1
+ import { closeSync, fstatSync, openSync, readSync } from 'node:fs'
2
+ import type { ResolvedSession } from '@/sessions/resolve'
3
+ import { locateTranscript, projectsDir } from '@/sessions/transcript'
4
+
5
+ export interface StopReading {
6
+ /**
7
+ * `usage-limit` means the last assistant entry is a limit stop, passing over
8
+ * a `<synthetic>` entry with no error, such as the one a client restart
9
+ * writes. `unknown` means the read failed or found a limit-worded entry of
10
+ * another shape, so the row may be stopped and nothing here can say.
11
+ */
12
+ readonly stopped: 'usage-limit' | 'unknown' | null
13
+ readonly stopResets: string | null
14
+ }
15
+
16
+ const NOT_STOPPED: StopReading = { stopped: null, stopResets: null }
17
+ const UNREADABLE: StopReading = { stopped: 'unknown', stopResets: null }
18
+
19
+ /**
20
+ * The largest measured distance from a final stop to the end of its file is
21
+ * under 4 KiB, while a live transcript reaches hundreds of megabytes, so the
22
+ * read stays positional and never loads the file.
23
+ */
24
+ const WINDOW_BYTES = 1024 * 1024
25
+
26
+ function readTail(path: string): {
27
+ readonly text: string
28
+ readonly cut: boolean
29
+ } {
30
+ const fd = openSync(path, 'r')
31
+ try {
32
+ const { size } = fstatSync(fd)
33
+ const length = Math.min(size, WINDOW_BYTES)
34
+ const start = size - length
35
+ const buffer = Buffer.alloc(length)
36
+ let filled = 0
37
+ while (filled < length) {
38
+ const read = readSync(fd, buffer, filled, length - filled, start + filled)
39
+ if (read === 0) break
40
+ filled += read
41
+ }
42
+ return {
43
+ text: buffer.subarray(0, filled).toString('utf8'),
44
+ cut: start > 0,
45
+ }
46
+ } finally {
47
+ closeSync(fd)
48
+ }
49
+ }
50
+
51
+ function parseEntry(line: string): Record<string, unknown> | null {
52
+ try {
53
+ const parsed: unknown = JSON.parse(line)
54
+ return typeof parsed === 'object' && parsed !== null
55
+ ? (parsed as Record<string, unknown>)
56
+ : null
57
+ } catch {
58
+ return null
59
+ }
60
+ }
61
+
62
+ function entryText(message: Record<string, unknown>): string {
63
+ if (!Array.isArray(message.content)) return ''
64
+ return message.content
65
+ .map((block: { text?: unknown } | null) =>
66
+ typeof block?.text === 'string' ? block.text : '',
67
+ )
68
+ .join('\n')
69
+ }
70
+
71
+ /**
72
+ * Walks back to the entry that decides the row. No published contract describes
73
+ * the stop entry, so this rests on a measured shape: `error: "rate_limit"`, a
74
+ * `<synthetic>` model, `isApiErrorMessage`, and text naming the limit and its
75
+ * reset.
76
+ *
77
+ * A generated entry with limit wording and any other shape reads `unknown`,
78
+ * since a release renaming the field or the type still parses cleanly and would
79
+ * otherwise read as a session that never stopped. A `<synthetic>` entry with no
80
+ * error and no limit wording is passed over, since a client restart writes one
81
+ * reading `No response requested.` with no model turn behind it.
82
+ */
83
+ function readEntries(text: string, cut: boolean): StopReading {
84
+ const lines = text.split('\n')
85
+ // The first line of a cut window usually starts mid-entry, and one that
86
+ // happens to parse cannot be told apart from it.
87
+ const first = cut ? 1 : 0
88
+
89
+ for (let index = lines.length - 1; index >= first; index -= 1) {
90
+ const entry = parseEntry(lines[index] ?? '')
91
+ if (entry === null || entry.isSidechain === true) continue
92
+
93
+ const message =
94
+ typeof entry.message === 'object' && entry.message !== null
95
+ ? (entry.message as Record<string, unknown>)
96
+ : {}
97
+ const synthetic = message.model === '<synthetic>'
98
+ const generated = synthetic || entry.isApiErrorMessage === true
99
+ const entryWords = generated ? entryText(message) : ''
100
+ const worded = /hit your .* limit/.test(entryWords)
101
+
102
+ if (entry.type !== 'assistant') {
103
+ if (worded) return UNREADABLE
104
+ continue
105
+ }
106
+
107
+ if (entry.error === 'rate_limit') {
108
+ return {
109
+ stopped: 'usage-limit',
110
+ stopResets: /resets (.+)/.exec(entryText(message))?.[1]?.trim() ?? null,
111
+ }
112
+ }
113
+ if (worded) return UNREADABLE
114
+ if (synthetic && entry.error === undefined) continue
115
+
116
+ return NOT_STOPPED
117
+ }
118
+
119
+ return cut ? UNREADABLE : NOT_STOPPED
120
+ }
121
+
122
+ /**
123
+ * Reads whether a usage limit stopped one roster row, from the tail of its
124
+ * transcript. A `busy` row reads nothing, since a turn is running and a resume
125
+ * may be in flight.
126
+ */
127
+ export function readStop(
128
+ row: Pick<ResolvedSession, 'status' | 'sessionId'>,
129
+ dir: string = projectsDir(),
130
+ ): StopReading {
131
+ if (row.status === 'busy') return NOT_STOPPED
132
+ if (row.sessionId === null) return UNREADABLE
133
+
134
+ const located = locateTranscript(row.sessionId, dir)
135
+ if (located === null) return UNREADABLE
136
+
137
+ let tail: ReturnType<typeof readTail>
138
+ try {
139
+ tail = readTail(located.transcript)
140
+ } catch {
141
+ return UNREADABLE
142
+ }
143
+
144
+ return readEntries(tail.text, tail.cut)
145
+ }
@@ -21,7 +21,7 @@ import {
21
21
  readSideFolder,
22
22
  readTranscript,
23
23
  transcriptFacts,
24
- } from '@/sessions/transfer/transcript'
24
+ } from '@/sessions/transcript'
25
25
 
26
26
  /** Bumped when a field changes meaning, so an older import refuses a newer bundle. */
27
27
  export const BUNDLE_FORMAT = 1