typeclaw 0.19.0 → 0.21.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 (38) hide show
  1. package/package.json +1 -1
  2. package/src/agent/index.ts +7 -0
  3. package/src/agent/live-subagents.ts +4 -0
  4. package/src/agent/restart/index.ts +101 -0
  5. package/src/agent/session-origin.ts +32 -10
  6. package/src/agent/tools/channel-react.ts +79 -0
  7. package/src/agent/tools/restart.ts +23 -52
  8. package/src/agent/tools/spawn-subagent.ts +1 -0
  9. package/src/agent/tools/subagent-access.ts +67 -0
  10. package/src/agent/tools/subagent-cancel.ts +11 -6
  11. package/src/agent/tools/subagent-output.ts +10 -2
  12. package/src/channels/adapters/discord-bot-classify.ts +8 -2
  13. package/src/channels/adapters/discord-bot.ts +265 -22
  14. package/src/channels/adapters/github/decoy-reviewer.ts +43 -0
  15. package/src/channels/adapters/github/inbound.ts +79 -0
  16. package/src/channels/adapters/github/index.ts +19 -0
  17. package/src/channels/adapters/github/permission-guidance.ts +20 -1
  18. package/src/channels/adapters/github/reactions.ts +276 -0
  19. package/src/channels/adapters/slack-bot-classify.ts +2 -2
  20. package/src/channels/adapters/slack-bot.ts +25 -2
  21. package/src/channels/engagement.ts +81 -44
  22. package/src/channels/router.ts +255 -18
  23. package/src/channels/types.ts +57 -0
  24. package/src/cli/builtins.ts +1 -0
  25. package/src/cli/dreams.ts +147 -0
  26. package/src/cli/index.ts +1 -0
  27. package/src/cli/inspect.ts +3 -0
  28. package/src/dreams/git.ts +85 -0
  29. package/src/dreams/index.ts +134 -0
  30. package/src/dreams/parse.ts +224 -0
  31. package/src/dreams/render.ts +155 -0
  32. package/src/dreams/types.ts +50 -0
  33. package/src/inspect/loop.ts +12 -1
  34. package/src/permissions/permissions.ts +24 -0
  35. package/src/server/index.ts +49 -0
  36. package/src/shared/protocol.ts +2 -0
  37. package/src/skills/typeclaw-channel-github/SKILL.md +6 -2
  38. package/src/tui/index.ts +70 -18
@@ -0,0 +1,147 @@
1
+ import { defineCommand } from 'citty'
2
+
3
+ import { type DreamEntry, renderListRow, runDreams, type ViewAction } from '@/dreams'
4
+ import { findAgentDir } from '@/init'
5
+
6
+ import { createEscController } from './inspect-controller'
7
+ import { c, cancel, errorLine, isCancel } from './ui'
8
+
9
+ const ESC_DEBOUNCE_MS = 50
10
+ const QUIT_KEY = 0x71
11
+
12
+ export const dreamsCommand = defineCommand({
13
+ meta: {
14
+ name: 'dreams',
15
+ description: "browse the dreaming subagent's memory-consolidation journal from git history (host stage)",
16
+ },
17
+ args: {
18
+ limit: {
19
+ type: 'string',
20
+ description: 'show at most N most-recent dreams',
21
+ },
22
+ json: {
23
+ type: 'boolean',
24
+ description: 'emit one JSON object per dream (subject-level)',
25
+ default: false,
26
+ },
27
+ details: {
28
+ type: 'boolean',
29
+ description: 'with --json, hydrate each dream with its consolidated fragments/shards/skills',
30
+ default: false,
31
+ },
32
+ },
33
+ async run({ args }) {
34
+ const cwd = findAgentDir(process.cwd()) ?? process.cwd()
35
+ const color = useColor()
36
+ const limit = parseLimit(args.limit)
37
+ const interactive = isInteractive() && args.json !== true
38
+
39
+ const result = await runDreams({
40
+ agentDir: cwd,
41
+ json: args.json === true,
42
+ details: args.details === true,
43
+ color,
44
+ ...(limit !== undefined ? { limit } : {}),
45
+ selectDream: (entries, selectOpts) => clackSelect(entries, color, selectOpts?.initialSha),
46
+ ...(interactive ? { viewDream: () => waitForViewerKey(color) } : {}),
47
+ stdout: (line) => process.stdout.write(`${line}\n`),
48
+ })
49
+
50
+ if (!result.ok) {
51
+ process.stderr.write(`${errorLine(result.reason)}\n`)
52
+ process.exit(result.exitCode)
53
+ }
54
+ process.exit(result.exitCode)
55
+ },
56
+ })
57
+
58
+ function isInteractive(): boolean {
59
+ return Boolean(process.stdout.isTTY) && Boolean(process.stdin.isTTY)
60
+ }
61
+
62
+ type RawInput = Pick<NodeJS.ReadStream, 'isTTY' | 'setRawMode' | 'resume' | 'pause' | 'on' | 'off'>
63
+
64
+ // Esc routes through createEscController so a standalone Esc returns 'back'
65
+ // while a multi-byte CSI sequence (↑/↓ arrows) does not. Teardown restores
66
+ // raw mode but deliberately does NOT pause stdin: clack cannot re-flow a
67
+ // paused process.stdin under Bun, so the next picker would freeze — the same
68
+ // reason cli/inspect.ts leaves the stream flowing on its return path.
69
+ export async function waitForViewerKey(color: boolean, input: RawInput = process.stdin): Promise<ViewAction> {
70
+ const stdin = input
71
+ if (!stdin.isTTY || typeof stdin.setRawMode !== 'function') return 'exit'
72
+
73
+ process.stdout.write(`${viewerHintLine(color)}\n`)
74
+
75
+ const ctrl = createEscController({ debounceMs: ESC_DEBOUNCE_MS })
76
+ const escSignal = ctrl.armForStream()
77
+
78
+ return new Promise<ViewAction>((resolve) => {
79
+ let settled = false
80
+ const finish = (action: ViewAction): void => {
81
+ if (settled) return
82
+ settled = true
83
+ escSignal.removeEventListener('abort', onEscAbort)
84
+ stdin.off('data', onData)
85
+ ctrl.dispose()
86
+ try {
87
+ stdin.setRawMode(false)
88
+ } catch {
89
+ /* terminal already torn down */
90
+ }
91
+ resolve(action)
92
+ }
93
+ const onEscAbort = (): void => finish('back')
94
+ const onData = (chunk: Buffer): void => {
95
+ if (chunk[0] === QUIT_KEY) {
96
+ finish('exit')
97
+ return
98
+ }
99
+ const { sigint } = ctrl.onChunk(chunk)
100
+ if (sigint) finish('exit')
101
+ }
102
+ escSignal.addEventListener('abort', onEscAbort, { once: true })
103
+ stdin.setRawMode(true)
104
+ stdin.resume()
105
+ stdin.on('data', onData)
106
+ })
107
+ }
108
+
109
+ function viewerHintLine(color: boolean): string {
110
+ const text = '(esc to go back to the list · q to quit)'
111
+ return color ? c.dim(text) : text
112
+ }
113
+
114
+ function parseLimit(raw: unknown): number | undefined {
115
+ if (typeof raw !== 'string') return undefined
116
+ const n = Number.parseInt(raw, 10)
117
+ return Number.isFinite(n) && n > 0 ? n : undefined
118
+ }
119
+
120
+ async function clackSelect(
121
+ entries: DreamEntry[],
122
+ color: boolean,
123
+ initialSha: string | undefined,
124
+ ): Promise<DreamEntry | null> {
125
+ const { select } = await import('@clack/prompts')
126
+ const preferred = initialSha !== undefined && entries.some((e) => e.sha === initialSha) ? initialSha : entries[0]?.sha
127
+ const picked = await select<string>({
128
+ message: `Pick a dream to open (${entries.length} total)`,
129
+ options: entries.map((entry) => ({
130
+ value: entry.sha,
131
+ label: renderListRow(entry, { color }),
132
+ })),
133
+ initialValue: preferred,
134
+ })
135
+ if (isCancel(picked)) {
136
+ cancel('Cancelled.')
137
+ return null
138
+ }
139
+ return entries.find((entry) => entry.sha === picked) ?? null
140
+ }
141
+
142
+ function useColor(): boolean {
143
+ if (process.env.NO_COLOR !== undefined && process.env.NO_COLOR !== '') return false
144
+ if (process.env.FORCE_COLOR === '0') return false
145
+ if (process.env.FORCE_COLOR) return true
146
+ return Boolean(process.stdout.isTTY)
147
+ }
package/src/cli/index.ts CHANGED
@@ -23,6 +23,7 @@ const main = defineCommand({
23
23
  reload: () => import('./reload').then((m) => m.reload),
24
24
  logs: () => import('./logs').then((m) => m.logsCommand),
25
25
  inspect: () => import('./inspect').then((m) => m.inspectCommand),
26
+ dreams: () => import('./dreams').then((m) => m.dreamsCommand),
26
27
  shell: () => import('./shell').then((m) => m.shellCommand),
27
28
  compose: () => import('./compose').then((m) => m.composeCommand),
28
29
  channel: () => import('./channel').then((m) => m.channelCommand),
@@ -75,6 +75,9 @@ export const inspectCommand = defineCommand({
75
75
  if (escListener === null) return new AbortController().signal
76
76
  return escListener.armForStream()
77
77
  },
78
+ afterEscStream: () => {
79
+ escListener?.pause()
80
+ },
78
81
  ...(liveHint !== undefined ? { liveHint } : {}),
79
82
  stdout: (line) => process.stdout.write(`${line}\n`),
80
83
  stderr: (line) => process.stderr.write(`${line}\n`),
@@ -0,0 +1,85 @@
1
+ export type GitResult = { exitCode: number; stdout: string; stderr: string }
2
+ export type SpawnGit = (args: string[], cwd: string) => Promise<GitResult>
3
+
4
+ export type RawCommit = {
5
+ sha: string
6
+ shortSha: string
7
+ committedAt: string
8
+ subject: string
9
+ }
10
+
11
+ export type ResolveRepoResult = { ok: true; root: string } | { ok: false; reason: 'not-a-repo' | 'git-failed' }
12
+
13
+ const FIELD_SEP = '\x1f'
14
+ const RECORD_SEP = '\x1e'
15
+
16
+ export async function resolveGitRepo(cwd: string, spawnGit: SpawnGit = defaultSpawnGit): Promise<ResolveRepoResult> {
17
+ const res = await spawnGit(['rev-parse', '--show-toplevel'], cwd)
18
+ if (res.exitCode === 0) {
19
+ const root = res.stdout.trim()
20
+ if (root.length > 0) return { ok: true, root }
21
+ return { ok: false, reason: 'git-failed' }
22
+ }
23
+ if (/not a git repository/i.test(res.stderr)) return { ok: false, reason: 'not-a-repo' }
24
+ return { ok: false, reason: 'git-failed' }
25
+ }
26
+
27
+ const DREAM_SUBJECT_PREFIX = 'dream: '
28
+
29
+ export async function readDreamCommitLog(
30
+ root: string,
31
+ opts: { limit?: number } = {},
32
+ spawnGit: SpawnGit = defaultSpawnGit,
33
+ ): Promise<RawCommit[]> {
34
+ // --grep is only a cheap pre-filter: it matches ANY line of the commit
35
+ // message, so a non-dream commit with a `dream: ...` body line slips
36
+ // through. The subject is the authoritative contract, so the prefix filter
37
+ // below is what actually decides membership — and the limit is applied
38
+ // AFTER it so body-matching impostors can't consume a slot and shrink the
39
+ // result below the requested count.
40
+ const args = ['log', '--grep=^dream: ', `--format=%H${FIELD_SEP}%h${FIELD_SEP}%cI${FIELD_SEP}%s${RECORD_SEP}`]
41
+
42
+ const res = await spawnGit(args, root)
43
+ if (res.exitCode !== 0) return []
44
+ const dreams = parseLogOutput(res.stdout).filter((c) => c.subject.startsWith(DREAM_SUBJECT_PREFIX))
45
+ if (opts.limit !== undefined && opts.limit > 0) return dreams.slice(0, opts.limit)
46
+ return dreams
47
+ }
48
+
49
+ export function parseLogOutput(stdout: string): RawCommit[] {
50
+ const commits: RawCommit[] = []
51
+ for (const record of stdout.split(RECORD_SEP)) {
52
+ const trimmed = record.replace(/^\n+/, '')
53
+ if (trimmed.length === 0) continue
54
+ const [sha, shortSha, committedAt, subject] = trimmed.split(FIELD_SEP)
55
+ if (sha === undefined || shortSha === undefined || committedAt === undefined || subject === undefined) continue
56
+ commits.push({ sha, shortSha, committedAt, subject })
57
+ }
58
+ return commits
59
+ }
60
+
61
+ export async function readDreamCommitShow(
62
+ root: string,
63
+ sha: string,
64
+ spawnGit: SpawnGit = defaultSpawnGit,
65
+ ): Promise<{ nameStatus: string; patch: string } | null> {
66
+ const nameStatus = await spawnGit(['show', '--no-color', '--find-renames', '--format=', '--name-status', sha], root)
67
+ if (nameStatus.exitCode !== 0) return null
68
+ const patch = await spawnGit(['show', '--no-color', '--format=', '--unified=0', sha], root)
69
+ if (patch.exitCode !== 0) return null
70
+ return { nameStatus: nameStatus.stdout, patch: patch.stdout }
71
+ }
72
+
73
+ const defaultSpawnGit: SpawnGit = async (args, cwd) => {
74
+ const bun = (globalThis as { Bun?: { spawn: typeof Bun.spawn } }).Bun
75
+ if (!bun) return { exitCode: -1, stdout: '', stderr: 'bun runtime not available' }
76
+ try {
77
+ const proc = bun.spawn({ cmd: ['git', ...args], cwd, stdout: 'pipe', stderr: 'pipe' })
78
+ const exitCode = await proc.exited
79
+ const stdout = await new Response(proc.stdout).text()
80
+ const stderr = await new Response(proc.stderr).text()
81
+ return { exitCode, stdout, stderr }
82
+ } catch (err) {
83
+ return { exitCode: -1, stdout: '', stderr: err instanceof Error ? err.message : String(err) }
84
+ }
85
+ }
@@ -0,0 +1,134 @@
1
+ import { readDreamCommitLog, readDreamCommitShow, resolveGitRepo, type SpawnGit } from './git'
2
+ import { parseDreamDetail, parseDreamSubject } from './parse'
3
+ import { renderDetail, renderListRow, type RenderOptions, toJsonShape } from './render'
4
+ import type { DreamEntry } from './types'
5
+
6
+ export type { SpawnGit } from './git'
7
+ export type { DreamEntry } from './types'
8
+ export { renderDetail, renderListRow, toJsonShape } from './render'
9
+ export { parseDreamDetail, parseDreamSubject } from './parse'
10
+
11
+ export type SelectDreamOptions = {
12
+ initialSha?: string
13
+ }
14
+
15
+ export type SelectDream = (entries: DreamEntry[], opts?: SelectDreamOptions) => Promise<DreamEntry | null>
16
+
17
+ // 'back' re-opens the picker with the just-viewed dream pre-selected.
18
+ export type ViewAction = 'back' | 'exit'
19
+ export type ViewDream = () => Promise<ViewAction>
20
+
21
+ export type RunDreamsOptions = {
22
+ agentDir: string
23
+ json: boolean
24
+ details: boolean
25
+ color: boolean
26
+ limit?: number
27
+ selectDream: SelectDream
28
+ viewDream?: ViewDream
29
+ stdout: (line: string) => void
30
+ spawnGit?: SpawnGit
31
+ }
32
+
33
+ export type RunDreamsResult = { ok: true; exitCode: 0 } | { ok: false; exitCode: number; reason: string }
34
+
35
+ export async function listDreams(
36
+ agentDir: string,
37
+ opts: { limit?: number } = {},
38
+ spawnGit?: SpawnGit,
39
+ ): Promise<DreamEntry[]> {
40
+ const repo = await resolveGitRepo(agentDir, spawnGit)
41
+ if (!repo.ok) return []
42
+ const commits = await readDreamCommitLog(repo.root, opts.limit !== undefined ? { limit: opts.limit } : {}, spawnGit)
43
+ return commits.map((commit) => {
44
+ const subject = parseDreamSubject(commit.subject)
45
+ return {
46
+ sha: commit.sha,
47
+ shortSha: commit.shortSha,
48
+ subject: commit.subject,
49
+ committedAt: commit.committedAt,
50
+ isDreamCommit: subject.isDreamCommit,
51
+ summary: subject.summary,
52
+ emoji: subject.emoji,
53
+ categories: subject.categories,
54
+ }
55
+ })
56
+ }
57
+
58
+ export async function hydrateDream(agentDir: string, entry: DreamEntry, spawnGit?: SpawnGit): Promise<DreamEntry> {
59
+ const repo = await resolveGitRepo(agentDir, spawnGit)
60
+ if (!repo.ok) return entry
61
+ const show = await readDreamCommitShow(repo.root, entry.sha, spawnGit)
62
+ if (show === null) return entry
63
+ return { ...entry, detail: parseDreamDetail(show.nameStatus, show.patch) }
64
+ }
65
+
66
+ export async function runDreams(opts: RunDreamsOptions): Promise<RunDreamsResult> {
67
+ const repo = await resolveGitRepo(opts.agentDir, opts.spawnGit)
68
+ if (!repo.ok) {
69
+ if (repo.reason === 'not-a-repo') {
70
+ return {
71
+ ok: false,
72
+ exitCode: 1,
73
+ reason:
74
+ "Not a git repository. Dreams live in the agent folder's git history — run this from your agent folder.",
75
+ }
76
+ }
77
+ return { ok: false, exitCode: 1, reason: 'git failed while resolving the repository root.' }
78
+ }
79
+
80
+ const entries = await listDreams(opts.agentDir, opts.limit !== undefined ? { limit: opts.limit } : {}, opts.spawnGit)
81
+ const renderOpts: RenderOptions = { color: opts.color }
82
+
83
+ if (opts.json) return runJson(opts, repo.root, entries)
84
+
85
+ if (entries.length === 0) {
86
+ opts.stdout('No dreams yet. The dreaming subagent commits here after it consolidates memory.')
87
+ return { ok: true, exitCode: 0 }
88
+ }
89
+
90
+ if (!isInteractive()) {
91
+ for (const entry of entries) opts.stdout(renderListRow(entry, renderOpts))
92
+ return { ok: true, exitCode: 0 }
93
+ }
94
+
95
+ return runInteractiveLoop(opts, entries, renderOpts)
96
+ }
97
+
98
+ async function runInteractiveLoop(
99
+ opts: RunDreamsOptions,
100
+ entries: DreamEntry[],
101
+ renderOpts: RenderOptions,
102
+ ): Promise<RunDreamsResult> {
103
+ let initialSha: string | undefined
104
+ while (true) {
105
+ const picked = await opts.selectDream(entries, initialSha !== undefined ? { initialSha } : {})
106
+ if (picked === null) return { ok: true, exitCode: 0 }
107
+ initialSha = picked.sha
108
+
109
+ const hydrated = await hydrateDream(opts.agentDir, picked, opts.spawnGit)
110
+ opts.stdout(renderDetail(hydrated, renderOpts))
111
+
112
+ if (opts.viewDream === undefined) return { ok: true, exitCode: 0 }
113
+ const action = await opts.viewDream()
114
+ if (action === 'exit') return { ok: true, exitCode: 0 }
115
+ }
116
+ }
117
+
118
+ async function runJson(opts: RunDreamsOptions, root: string, entries: DreamEntry[]): Promise<RunDreamsResult> {
119
+ for (const entry of entries) {
120
+ const final = opts.details ? await hydrateEntryFromRoot(root, entry, opts.spawnGit) : entry
121
+ opts.stdout(JSON.stringify(toJsonShape(final)))
122
+ }
123
+ return { ok: true, exitCode: 0 }
124
+ }
125
+
126
+ async function hydrateEntryFromRoot(root: string, entry: DreamEntry, spawnGit?: SpawnGit): Promise<DreamEntry> {
127
+ const show = await readDreamCommitShow(root, entry.sha, spawnGit)
128
+ if (show === null) return entry
129
+ return { ...entry, detail: parseDreamDetail(show.nameStatus, show.patch) }
130
+ }
131
+
132
+ function isInteractive(): boolean {
133
+ return Boolean(process.stdout.isTTY) && Boolean(process.stdin.isTTY)
134
+ }
@@ -0,0 +1,224 @@
1
+ import {
2
+ type DreamCategory,
3
+ type DreamEmoji,
4
+ DREAM_EMOJI_POOL,
5
+ type DreamEntryDetail,
6
+ type FragmentEventSummary,
7
+ type ShardChangeStatus,
8
+ type SkillCreation,
9
+ type TopicShardChange,
10
+ } from './types'
11
+
12
+ const BODY_PREVIEW_MAX = 80
13
+ const EMOJI_SET = new Set<string>(DREAM_EMOJI_POOL)
14
+ const STREAM_PATH = /^memory\/(?:streams\/)?(\d{4}-\d{2}-\d{2})\.jsonl$/
15
+ const TOPIC_PATH = /^memory\/topics\/(.+)\.md$/
16
+ const SKILL_PATH = /^memory\/skills\/([^/]+)\/SKILL\.md$/
17
+ const STATE_PATH = 'memory/.dreaming-state.json'
18
+
19
+ export type DreamSubject = {
20
+ isDreamCommit: boolean
21
+ summary: string | null
22
+ emoji: DreamEmoji | null
23
+ categories: DreamCategory[]
24
+ }
25
+
26
+ export function parseDreamSubject(subject: string): DreamSubject {
27
+ const match = /^dream:\s+(.*)$/.exec(subject)
28
+ if (match === null) {
29
+ return { isDreamCommit: false, summary: null, emoji: null, categories: [] }
30
+ }
31
+
32
+ const rest = (match[1] ?? '').trim()
33
+ const { summary, emoji } = splitTrailingEmoji(rest)
34
+ return {
35
+ isDreamCommit: true,
36
+ summary: summary.length > 0 ? summary : null,
37
+ emoji,
38
+ categories: classifySummary(summary),
39
+ }
40
+ }
41
+
42
+ function splitTrailingEmoji(rest: string): { summary: string; emoji: DreamEmoji | null } {
43
+ const chars = [...rest]
44
+ const last = chars.at(-1)
45
+ if (last !== undefined && EMOJI_SET.has(last)) {
46
+ return { summary: chars.slice(0, -1).join('').trim(), emoji: last as DreamEmoji }
47
+ }
48
+ return { summary: rest, emoji: null }
49
+ }
50
+
51
+ function classifySummary(summary: string): DreamCategory[] {
52
+ const categories: DreamCategory[] = []
53
+ if (/\bfragments?\b/.test(summary)) categories.push('fragments')
54
+ if (/\bskills?\b/.test(summary)) categories.push('skills')
55
+ if (/watermarks only/.test(summary)) categories.push('watermarks-only')
56
+ if (/^snapshot$/.test(summary)) categories.push('snapshot')
57
+ if (categories.length === 0) categories.push('other')
58
+ return categories
59
+ }
60
+
61
+ export function parseDreamDetail(nameStatus: string, patch: string): DreamEntryDetail {
62
+ const warnings: string[] = []
63
+ const changedTopics: TopicShardChange[] = []
64
+ const createdSkills: SkillCreation[] = []
65
+ let stateChanged = false
66
+
67
+ for (const { status, path, oldPath } of parseNameStatus(nameStatus)) {
68
+ if (path === STATE_PATH || oldPath === STATE_PATH) {
69
+ stateChanged = true
70
+ continue
71
+ }
72
+ const topic = TOPIC_PATH.exec(path)
73
+ if (topic !== null) {
74
+ changedTopics.push({ path, slug: topic[1] ?? path, status, additions: null, deletions: null })
75
+ continue
76
+ }
77
+ if (status === 'added') {
78
+ const skill = SKILL_PATH.exec(path)
79
+ if (skill !== null) createdSkills.push({ name: skill[1] ?? '', path })
80
+ }
81
+ }
82
+
83
+ applyTopicLineCounts(changedTopics, patch, warnings)
84
+ const addedFragments = extractAddedFragments(patch, warnings)
85
+
86
+ return { addedFragments, changedTopics, createdSkills, stateChanged, parseWarnings: warnings }
87
+ }
88
+
89
+ type NameStatusRow = { status: ShardChangeStatus; path: string; oldPath: string | null }
90
+
91
+ function parseNameStatus(nameStatus: string): NameStatusRow[] {
92
+ const rows: NameStatusRow[] = []
93
+ for (const line of nameStatus.split('\n')) {
94
+ if (line.trim().length === 0) continue
95
+ const cols = line.split('\t')
96
+ const code = cols[0] ?? ''
97
+ if (code.startsWith('R')) {
98
+ const oldPath = cols[1] ?? ''
99
+ const newPath = cols[2] ?? ''
100
+ if (newPath.length > 0) rows.push({ status: 'renamed', path: newPath, oldPath })
101
+ continue
102
+ }
103
+ const path = cols[1] ?? ''
104
+ if (path.length === 0) continue
105
+ rows.push({ status: mapStatusCode(code), path, oldPath: null })
106
+ }
107
+ return rows
108
+ }
109
+
110
+ function mapStatusCode(code: string): ShardChangeStatus {
111
+ const c = code.charAt(0)
112
+ if (c === 'A') return 'added'
113
+ if (c === 'M') return 'modified'
114
+ if (c === 'D') return 'deleted'
115
+ if (c === 'R') return 'renamed'
116
+ return 'unknown'
117
+ }
118
+
119
+ type ParsedHunk = { path: string; addedLines: string[] }
120
+
121
+ function* iterateHunks(patch: string): Generator<ParsedHunk> {
122
+ let currentPath: string | null = null
123
+ let added: string[] = []
124
+ const flush = function* (): Generator<ParsedHunk> {
125
+ if (currentPath !== null) yield { path: currentPath, addedLines: added }
126
+ }
127
+
128
+ for (const line of patch.split('\n')) {
129
+ if (line.startsWith('diff --git ')) {
130
+ yield* flush()
131
+ currentPath = null
132
+ added = []
133
+ continue
134
+ }
135
+ if (line.startsWith('+++ ')) {
136
+ currentPath = stripDiffPathPrefix(line.slice(4))
137
+ continue
138
+ }
139
+ if (line.startsWith('+') && !line.startsWith('+++')) {
140
+ added.push(line.slice(1))
141
+ }
142
+ }
143
+ yield* flush()
144
+ }
145
+
146
+ function stripDiffPathPrefix(raw: string): string {
147
+ const trimmed = raw.trim()
148
+ if (trimmed === '/dev/null') return trimmed
149
+ return trimmed.replace(/^b\//, '')
150
+ }
151
+
152
+ function extractAddedFragments(patch: string, warnings: string[]): FragmentEventSummary[] {
153
+ const out: FragmentEventSummary[] = []
154
+ for (const hunk of iterateHunks(patch)) {
155
+ const streamMatch = STREAM_PATH.exec(hunk.path)
156
+ if (streamMatch === null) continue
157
+ const streamDate = streamMatch[1] ?? null
158
+ for (const line of hunk.addedLines) {
159
+ if (line.trim().length === 0) continue
160
+ const fragment = parseFragmentLine(line, streamDate)
161
+ if (fragment === null) {
162
+ warnings.push(`unparseable stream line in ${hunk.path}`)
163
+ continue
164
+ }
165
+ if (fragment !== 'skip') out.push(fragment)
166
+ }
167
+ }
168
+ return out
169
+ }
170
+
171
+ function parseFragmentLine(line: string, streamDate: string | null): FragmentEventSummary | 'skip' | null {
172
+ let raw: unknown
173
+ try {
174
+ raw = JSON.parse(line)
175
+ } catch {
176
+ return null
177
+ }
178
+ if (typeof raw !== 'object' || raw === null) return null
179
+ const obj = raw as Record<string, unknown>
180
+ if (obj.type !== 'fragment') return 'skip'
181
+ if (typeof obj.id !== 'string' || obj.id.length === 0) return null
182
+ return {
183
+ id: obj.id,
184
+ streamDate,
185
+ topic: typeof obj.topic === 'string' ? obj.topic : null,
186
+ bodyPreview: typeof obj.body === 'string' ? preview(obj.body) : null,
187
+ }
188
+ }
189
+
190
+ function applyTopicLineCounts(topics: TopicShardChange[], patch: string, warnings: string[]): void {
191
+ if (topics.length === 0) return
192
+ const counts = new Map<string, { additions: number; deletions: number }>()
193
+ let currentPath: string | null = null
194
+
195
+ for (const line of patch.split('\n')) {
196
+ if (line.startsWith('+++ ')) {
197
+ currentPath = stripDiffPathPrefix(line.slice(4))
198
+ if (currentPath !== null && !counts.has(currentPath)) counts.set(currentPath, { additions: 0, deletions: 0 })
199
+ continue
200
+ }
201
+ if (currentPath === null) continue
202
+ const bucket = counts.get(currentPath)
203
+ if (bucket === undefined) continue
204
+ if (line.startsWith('+') && !line.startsWith('+++')) bucket.additions++
205
+ else if (line.startsWith('-') && !line.startsWith('---')) bucket.deletions++
206
+ }
207
+
208
+ for (const topic of topics) {
209
+ if (topic.status === 'deleted') continue
210
+ const bucket = counts.get(topic.path)
211
+ if (bucket === undefined) {
212
+ if (topic.status === 'modified') warnings.push(`no diff hunk for modified topic ${topic.path}`)
213
+ continue
214
+ }
215
+ topic.additions = bucket.additions
216
+ topic.deletions = bucket.deletions
217
+ }
218
+ }
219
+
220
+ function preview(body: string): string {
221
+ const oneline = body.replace(/\s+/g, ' ').trim()
222
+ if (oneline.length <= BODY_PREVIEW_MAX) return oneline
223
+ return `${oneline.slice(0, BODY_PREVIEW_MAX)}…`
224
+ }