typeclaw 0.1.1 → 0.1.2

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 (43) hide show
  1. package/README.md +12 -12
  2. package/package.json +1 -1
  3. package/src/agent/doctor.ts +173 -0
  4. package/src/agent/subagents.ts +24 -2
  5. package/src/bundled-plugins/backup/README.md +81 -0
  6. package/src/bundled-plugins/backup/index.ts +209 -0
  7. package/src/bundled-plugins/backup/runner.ts +231 -0
  8. package/src/bundled-plugins/backup/subagents.ts +200 -0
  9. package/src/bundled-plugins/memory/index.ts +42 -1
  10. package/src/channels/router.ts +29 -0
  11. package/src/cli/compose.ts +92 -1
  12. package/src/cli/doctor.ts +100 -0
  13. package/src/cli/index.ts +1 -0
  14. package/src/compose/doctor.ts +141 -0
  15. package/src/compose/index.ts +8 -0
  16. package/src/compose/logs.ts +32 -19
  17. package/src/config/config.ts +20 -0
  18. package/src/container/log-colors.ts +75 -0
  19. package/src/container/log-timestamps.ts +84 -0
  20. package/src/container/logs.ts +71 -5
  21. package/src/container/start.ts +23 -8
  22. package/src/cron/consumer.ts +29 -7
  23. package/src/doctor/checks.ts +426 -0
  24. package/src/doctor/commit.ts +71 -0
  25. package/src/doctor/index.ts +287 -0
  26. package/src/doctor/plugin-bridge.ts +147 -0
  27. package/src/doctor/report.ts +142 -0
  28. package/src/doctor/types.ts +87 -0
  29. package/src/init/cli-version.ts +81 -0
  30. package/src/init/dockerfile.ts +223 -25
  31. package/src/init/index.ts +18 -10
  32. package/src/plugin/hooks.ts +32 -0
  33. package/src/plugin/index.ts +7 -0
  34. package/src/plugin/manager.ts +2 -0
  35. package/src/plugin/registry.ts +32 -3
  36. package/src/plugin/types.ts +65 -0
  37. package/src/run/bundled-plugins.ts +8 -0
  38. package/src/run/index.ts +10 -5
  39. package/src/server/index.ts +103 -5
  40. package/src/shared/index.ts +3 -0
  41. package/src/shared/protocol.ts +22 -0
  42. package/src/skills/typeclaw-config/SKILL.md +1 -1
  43. package/typeclaw.schema.json +50 -0
@@ -7,6 +7,7 @@ import { configSchema, expandMountPath, type Config } from '@/config/config'
7
7
  import { send as sendToDaemon } from '@/hostd/client'
8
8
  import type { HttpInfoResult } from '@/hostd/protocol'
9
9
  import { ensureDaemon } from '@/hostd/spawn'
10
+ import { resolveBaseImageVersion } from '@/init/cli-version'
10
11
  import { buildDockerfile, DOCKERFILE } from '@/init/dockerfile'
11
12
  import { ensureDepsInstalled, type EnsureDepsResult } from '@/init/ensure-deps'
12
13
  import { buildGitignore, GITIGNORE_FILE } from '@/init/gitignore'
@@ -142,7 +143,6 @@ export async function start({
142
143
  // one-shot and idempotent — once `workspaces` is set, refreshPackageJson
143
144
  // is a no-op, so users who never edit their agent folder pay zero cost on
144
145
  // subsequent starts and users who customized `workspaces` are not clobbered.
145
- await refreshDockerfile(cwd)
146
146
  await refreshGitignore(cwd)
147
147
  const pkgRefresh = await refreshPackageJson(cwd)
148
148
  await commitSystemFile(cwd, GITIGNORE_FILE, 'Update .gitignore')
@@ -161,6 +161,11 @@ export async function start({
161
161
  return { ok: false, reason: `dependency install failed: ${deps.reason}` }
162
162
  }
163
163
  await commitSystemFile(cwd, DEPENDENCY_FILES, 'Update dependencies')
164
+ // Dockerfile refresh AFTER ensureDeps so the version pin in the FROM
165
+ // line resolves against the agent's installed node_modules/typeclaw —
166
+ // ensures the base image's CLI version matches the runtime the
167
+ // container will actually load.
168
+ await refreshDockerfile(cwd)
164
169
 
165
170
  if (state.exists) {
166
171
  // Container holds the name but is not running. Without `--rm`, this is
@@ -315,7 +320,8 @@ export async function planStart({
315
320
  const imageTag = imageTagFromCwd(cwd)
316
321
 
317
322
  const devSourcePath = await detectDevSource(cwd)
318
- const mounts = await loadMounts(cwd)
323
+ const cfg = await loadTypeclawConfig(cwd)
324
+ const mounts = cfg.mounts
319
325
 
320
326
  // No `--rm`: a crashed container's logs MUST survive past exit so users can
321
327
  // debug the failure. `typeclaw stop` removes the container explicitly, and
@@ -324,6 +330,17 @@ export async function planStart({
324
330
  // a running container or one the user has not started again yet.
325
331
  const runArgs = ['run', '-d', '--name', containerName, '-p', `127.0.0.1:${hostPort}:${CONTAINER_PORT}`]
326
332
 
333
+ // Network egress filter: when `typeclaw.json#network.blockInternal` is true,
334
+ // grant the container CAP_NET_ADMIN at boot so the entrypoint shim can
335
+ // install iptables OUTPUT rules. The shim drops the capability from the
336
+ // bounding set via setpriv before exec'ing the agent — see the shim source
337
+ // in src/init/dockerfile.ts for the full handoff. The `-e` flag is what
338
+ // tells the shim to take the on-path; absent or set to anything other than
339
+ // "1", the shim is a no-op.
340
+ if (cfg.network.blockInternal) {
341
+ runArgs.push('--cap-add=NET_ADMIN', '-e', 'TYPECLAW_NETWORK_BLOCK_INTERNAL=1')
342
+ }
343
+
327
344
  if (hostdControl) {
328
345
  runArgs.push('--add-host', HOST_GATEWAY_ALIAS)
329
346
  }
@@ -386,7 +403,10 @@ export async function planStart({
386
403
 
387
404
  export async function refreshDockerfile(cwd: string): Promise<void> {
388
405
  const cfg = await loadTypeclawConfig(cwd)
389
- await writeFile(join(cwd, DOCKERFILE), buildDockerfile(cfg.dockerfile))
406
+ await writeFile(
407
+ join(cwd, DOCKERFILE),
408
+ buildDockerfile(cfg.dockerfile, { baseImageVersion: resolveBaseImageVersion(cwd) }),
409
+ )
390
410
  }
391
411
 
392
412
  export async function refreshGitignore(cwd: string): Promise<void> {
@@ -576,11 +596,6 @@ async function detectDevSource(cwd: string): Promise<string | null> {
576
596
  // folder mid-init). Anything else — malformed JSON, schema-invalid config,
577
597
  // invalid mount entry — must surface so the user sees they configured a mount
578
598
  // that won't be applied.
579
- async function loadMounts(cwd: string): Promise<Config['mounts']> {
580
- const cfg = await loadTypeclawConfig(cwd)
581
- return cfg.mounts
582
- }
583
-
584
599
  async function loadTypeclawConfig(cwd: string): Promise<Config> {
585
600
  return configSchema.parse(await loadConfigJson(cwd))
586
601
  }
@@ -1,20 +1,25 @@
1
+ import type { SessionOrigin } from '@/agent/session-origin'
1
2
  import type { HookBus } from '@/plugin'
2
3
  import type { Stream, Unsubscribe } from '@/stream'
3
4
 
4
5
  import type { CronJob, ExecJob, PromptJob } from './schema'
5
6
 
6
- // `hooks`, `sessionId`, and `getTranscriptPath` are optional so test fakes can
7
- // stay one-liners. When present, the consumer fires `session.idle` after every
8
- // prompt completion and `session.end` on dispose, mirroring the lifecycle
9
- // signals the TUI server already emits in `src/server/index.ts`. Without this
10
- // the bundled memory plugin's debounced `memory-logger` never spawns for cron
11
- // prompt jobs because it only wakes on `session.idle`.
7
+ // `hooks`, `sessionId`, `agentDir`, and `getTranscriptPath` are optional so
8
+ // test fakes can stay one-liners. When present, the consumer fires
9
+ // `session.turn.start`/`session.turn.end` around `prompt()`, then
10
+ // `session.idle` after, then `session.end` on dispose mirroring the
11
+ // lifecycle signals the TUI server emits in `src/server/index.ts`. Without
12
+ // this the bundled memory plugin's debounced `memory-logger` never spawns for
13
+ // cron prompt jobs (it only wakes on `session.idle`), and the bundled backup
14
+ // plugin's turn counter would miss cron-driven activity.
12
15
  export type CronSession = {
13
16
  prompt: (text: string) => Promise<void>
14
17
  dispose?: () => void
15
18
  hooks?: HookBus
16
19
  sessionId?: string
20
+ agentDir?: string
17
21
  getTranscriptPath?: () => string | undefined
22
+ origin?: SessionOrigin
18
23
  }
19
24
 
20
25
  export type CronConsumerLogger = {
@@ -102,8 +107,25 @@ async function runPrompt(
102
107
  return
103
108
  }
104
109
  const session = await createSessionForCron(job)
110
+ const turnEvent =
111
+ session.hooks && session.sessionId !== undefined && session.agentDir !== undefined
112
+ ? {
113
+ sessionId: session.sessionId,
114
+ agentDir: session.agentDir,
115
+ ...(session.origin !== undefined ? { origin: session.origin } : {}),
116
+ }
117
+ : undefined
105
118
  try {
106
- await session.prompt(job.prompt)
119
+ if (session.hooks && turnEvent !== undefined) {
120
+ await session.hooks.runSessionTurnStart(turnEvent)
121
+ }
122
+ try {
123
+ await session.prompt(job.prompt)
124
+ } finally {
125
+ if (session.hooks && turnEvent !== undefined) {
126
+ await session.hooks.runSessionTurnEnd(turnEvent)
127
+ }
128
+ }
107
129
  if (session.hooks && session.sessionId !== undefined) {
108
130
  await session.hooks.runSessionIdle({
109
131
  sessionId: session.sessionId,
@@ -0,0 +1,426 @@
1
+ import { existsSync, mkdirSync } from 'node:fs'
2
+ import { readFile, writeFile } from 'node:fs/promises'
3
+ import { homedir } from 'node:os'
4
+ import { join, relative } from 'node:path'
5
+
6
+ import { loadConfigSync, validateConfig } from '@/config'
7
+ import {
8
+ checkDockerAvailable,
9
+ containerNameFromCwd,
10
+ defaultDockerExec,
11
+ imageTagFromCwd,
12
+ inspectContainer,
13
+ resolveHostPort,
14
+ type DockerExec,
15
+ } from '@/container'
16
+ import { isDaemonReachable, send } from '@/hostd'
17
+ import { buildDockerfile, DOCKERFILE } from '@/init/dockerfile'
18
+ import { detectMissingDeps } from '@/init/ensure-deps'
19
+ import { buildGitignore, GITIGNORE_FILE } from '@/init/gitignore'
20
+
21
+ import type { DoctorCheck } from './types'
22
+
23
+ const REQUIRED_DIRS = ['workspace', 'sessions', '.agents/skills', 'mounts', 'packages'] as const
24
+
25
+ export function buildStaticChecks(opts: { dockerExec?: DockerExec } = {}): DoctorCheck[] {
26
+ const dockerExec = opts.dockerExec ?? defaultDockerExec
27
+
28
+ return [
29
+ dockerDaemon(dockerExec),
30
+ bunRuntime(),
31
+ agentFolderInitialized(),
32
+ agentFolderRequiredDirs(),
33
+ agentFolderDockerfileTemplate(),
34
+ agentFolderGitignoreTemplate(),
35
+ agentFolderNodeModules(),
36
+ agentFolderEnvFile(),
37
+ agentFolderGitRepo(),
38
+ configValid(),
39
+ hostdHomeWritable(),
40
+ hostdReachable(),
41
+ hostdRegistration(),
42
+ containerState(dockerExec),
43
+ containerHostPort(),
44
+ ]
45
+ }
46
+
47
+ function dockerDaemon(exec: DockerExec): DoctorCheck {
48
+ return {
49
+ name: 'docker.daemon-reachable',
50
+ category: 'docker',
51
+ description: 'Docker daemon is reachable',
52
+ async run() {
53
+ const result = await checkDockerAvailable(exec)
54
+ if (result.ok) return { status: 'ok', message: 'docker info responded' }
55
+ return {
56
+ status: 'error',
57
+ message: result.reason === 'binary-missing' ? 'docker binary missing on $PATH' : 'docker daemon down',
58
+ details: [result.detail],
59
+ fix:
60
+ result.reason === 'binary-missing'
61
+ ? { description: 'Install Docker (Docker Desktop, OrbStack, or docker-ce).' }
62
+ : { description: 'Start the Docker daemon (Docker Desktop, OrbStack, or `systemctl start docker`).' },
63
+ }
64
+ },
65
+ }
66
+ }
67
+
68
+ function bunRuntime(): DoctorCheck {
69
+ return {
70
+ name: 'runtime.bun-available',
71
+ category: 'runtime',
72
+ description: 'Bun runtime is available',
73
+ async run() {
74
+ const bun = (globalThis as { Bun?: unknown }).Bun
75
+ if (bun === undefined) {
76
+ return {
77
+ status: 'error',
78
+ message: 'Bun runtime is not available',
79
+ fix: { description: 'Install Bun (https://bun.sh) and ensure the typeclaw CLI runs under it.' },
80
+ }
81
+ }
82
+ return { status: 'ok', message: `Bun ${process.versions.bun ?? 'present'}` }
83
+ },
84
+ }
85
+ }
86
+
87
+ function agentFolderInitialized(): DoctorCheck {
88
+ return {
89
+ name: 'agent-folder.is-initialized',
90
+ category: 'agent-folder',
91
+ description: 'agent folder contains typeclaw.json',
92
+ async run(ctx) {
93
+ if (ctx.hasAgentFolder) return { status: 'ok', message: 'typeclaw.json present' }
94
+ return {
95
+ status: 'info',
96
+ message: 'no typeclaw.json found in or above current directory',
97
+ details: ['Host-stage checks unrelated to the agent folder still ran.'],
98
+ fix: { description: 'Run `typeclaw init` in the directory you want to use as an agent folder.' },
99
+ }
100
+ },
101
+ }
102
+ }
103
+
104
+ function agentFolderRequiredDirs(): DoctorCheck {
105
+ return {
106
+ name: 'agent-folder.required-dirs',
107
+ category: 'agent-folder',
108
+ description: 'required agent directories exist',
109
+ applies: (ctx) => ctx.hasAgentFolder,
110
+ async run(ctx) {
111
+ const missing = REQUIRED_DIRS.filter((d) => !existsSync(join(ctx.cwd, d)))
112
+ if (missing.length === 0) return { status: 'ok', message: 'all required directories present' }
113
+ return {
114
+ status: 'warning',
115
+ message: `${missing.length} required ${missing.length === 1 ? 'directory' : 'directories'} missing`,
116
+ details: missing.map((d) => `missing: ${d}/`),
117
+ fix: {
118
+ description: `Create the missing directories (${missing.map((d) => `${d}/`).join(', ')}).`,
119
+ autoFix: async () => {
120
+ for (const d of missing) {
121
+ mkdirSync(join(ctx.cwd, d), { recursive: true })
122
+ }
123
+ return {
124
+ summary: `created ${missing.map((d) => `${d}/`).join(', ')}`,
125
+ changedPaths: missing.map((d) => `${d}/`),
126
+ }
127
+ },
128
+ },
129
+ }
130
+ },
131
+ }
132
+ }
133
+
134
+ function agentFolderDockerfileTemplate(): DoctorCheck {
135
+ return {
136
+ name: 'agent-folder.dockerfile-managed',
137
+ category: 'agent-folder',
138
+ description: 'Dockerfile matches the typeclaw template',
139
+ applies: (ctx) => ctx.hasAgentFolder,
140
+ async run(ctx) {
141
+ const dockerfilePath = join(ctx.cwd, DOCKERFILE)
142
+ const expected = buildExpectedDockerfile(ctx.cwd)
143
+ if (expected === null) {
144
+ return { status: 'info', message: 'config invalid; cannot compute expected Dockerfile' }
145
+ }
146
+ const actual = await safeRead(dockerfilePath)
147
+ if (actual === expected) return { status: 'ok', message: 'Dockerfile matches template' }
148
+ return {
149
+ status: 'warning',
150
+ message: actual === null ? 'Dockerfile missing' : 'Dockerfile diverges from template',
151
+ details: ['The Dockerfile is regenerated on every `typeclaw start`, so a divergent file will be overwritten.'],
152
+ fix: {
153
+ description: 'Regenerate the Dockerfile from the typeclaw template.',
154
+ autoFix: async () => {
155
+ await writeAtomic(dockerfilePath, expected)
156
+ return { summary: 'refreshed Dockerfile from template', changedPaths: [DOCKERFILE] }
157
+ },
158
+ },
159
+ }
160
+ },
161
+ }
162
+ }
163
+
164
+ function agentFolderGitignoreTemplate(): DoctorCheck {
165
+ return {
166
+ name: 'agent-folder.gitignore-managed',
167
+ category: 'agent-folder',
168
+ description: '.gitignore matches the typeclaw template',
169
+ applies: (ctx) => ctx.hasAgentFolder,
170
+ async run(ctx) {
171
+ const gitignorePath = join(ctx.cwd, GITIGNORE_FILE)
172
+ const expected = buildExpectedGitignore(ctx.cwd)
173
+ if (expected === null) {
174
+ return { status: 'info', message: 'config invalid; cannot compute expected .gitignore' }
175
+ }
176
+ const actual = await safeRead(gitignorePath)
177
+ if (actual === expected) return { status: 'ok', message: '.gitignore matches template' }
178
+ return {
179
+ status: 'warning',
180
+ message: actual === null ? '.gitignore missing' : '.gitignore diverges from template',
181
+ fix: {
182
+ description: 'Regenerate .gitignore from the typeclaw template.',
183
+ autoFix: async () => {
184
+ await writeAtomic(gitignorePath, expected)
185
+ return { summary: 'refreshed .gitignore from template', changedPaths: [GITIGNORE_FILE] }
186
+ },
187
+ },
188
+ }
189
+ },
190
+ }
191
+ }
192
+
193
+ function agentFolderNodeModules(): DoctorCheck {
194
+ return {
195
+ name: 'agent-folder.node-modules-complete',
196
+ category: 'agent-folder',
197
+ description: 'node_modules satisfies package.json dependencies',
198
+ applies: (ctx) => ctx.hasAgentFolder && existsSync(join(ctx.cwd, 'package.json')),
199
+ async run(ctx) {
200
+ const missing = await detectMissingDeps(ctx.cwd)
201
+ if (missing.length === 0) return { status: 'ok', message: 'node_modules complete' }
202
+ return {
203
+ status: 'error',
204
+ message: `${missing.length} package(s) missing from node_modules`,
205
+ details: missing.map((m) => `missing: ${m}`),
206
+ fix: { description: 'Run `bun install` inside the agent folder.' },
207
+ }
208
+ },
209
+ }
210
+ }
211
+
212
+ function agentFolderEnvFile(): DoctorCheck {
213
+ return {
214
+ name: 'agent-folder.env-file',
215
+ category: 'agent-folder',
216
+ description: '.env file is present',
217
+ applies: (ctx) => ctx.hasAgentFolder,
218
+ async run(ctx) {
219
+ if (existsSync(join(ctx.cwd, '.env'))) return { status: 'ok', message: '.env present' }
220
+ return {
221
+ status: 'warning',
222
+ message: '.env is missing',
223
+ details: ['Channels and external API integrations will not have their secrets injected.'],
224
+ fix: { description: 'Create a .env file with the credentials your agent needs.' },
225
+ }
226
+ },
227
+ }
228
+ }
229
+
230
+ function agentFolderGitRepo(): DoctorCheck {
231
+ return {
232
+ name: 'agent-folder.git-repo',
233
+ category: 'agent-folder',
234
+ description: 'agent folder is a git repo',
235
+ applies: (ctx) => ctx.hasAgentFolder,
236
+ async run(ctx) {
237
+ if (existsSync(join(ctx.cwd, '.git'))) return { status: 'ok', message: '.git present' }
238
+ return {
239
+ status: 'warning',
240
+ message: 'agent folder is not a git repo',
241
+ details: ['typeclaw doctor --fix cannot commit changes without a git repo.'],
242
+ fix: { description: 'Run `git init` in the agent folder.' },
243
+ }
244
+ },
245
+ }
246
+ }
247
+
248
+ function configValid(): DoctorCheck {
249
+ return {
250
+ name: 'config.valid',
251
+ category: 'config',
252
+ description: 'typeclaw.json is valid and mounts are accessible',
253
+ applies: (ctx) => ctx.hasAgentFolder,
254
+ async run(ctx) {
255
+ const result = validateConfig(ctx.cwd)
256
+ if (result.ok) return { status: 'ok', message: 'typeclaw.json valid; mounts accessible' }
257
+ return {
258
+ status: 'error',
259
+ message: result.reason,
260
+ fix: { description: 'Edit typeclaw.json to resolve the validation error above.' },
261
+ }
262
+ },
263
+ }
264
+ }
265
+
266
+ function hostdHomeWritable(): DoctorCheck {
267
+ return {
268
+ name: 'hostd.home-writable',
269
+ category: 'hostd',
270
+ description: 'hostd home (~/.typeclaw/) is writable',
271
+ async run() {
272
+ const home = process.env.TYPECLAW_HOME ?? join(homedir(), '.typeclaw')
273
+ try {
274
+ mkdirSync(home, { recursive: true })
275
+ return { status: 'ok', message: `${home} writable` }
276
+ } catch (err) {
277
+ return {
278
+ status: 'error',
279
+ message: `cannot create ${home}: ${err instanceof Error ? err.message : String(err)}`,
280
+ fix: { description: 'Ensure your home directory is writable, or set TYPECLAW_HOME to an alternate path.' },
281
+ }
282
+ }
283
+ },
284
+ }
285
+ }
286
+
287
+ function hostdReachable(): DoctorCheck {
288
+ return {
289
+ name: 'hostd.reachable',
290
+ category: 'hostd',
291
+ description: 'host daemon is reachable over the Unix socket',
292
+ async run() {
293
+ const reachable = await isDaemonReachable()
294
+ if (reachable) return { status: 'ok', message: 'daemon socket replied to list RPC' }
295
+ return {
296
+ status: 'info',
297
+ message: 'host daemon is not running',
298
+ details: [
299
+ 'This is normal when no agent has been started yet. `typeclaw start` will spawn the daemon on demand.',
300
+ ],
301
+ }
302
+ },
303
+ }
304
+ }
305
+
306
+ function hostdRegistration(): DoctorCheck {
307
+ return {
308
+ name: 'hostd.registration',
309
+ category: 'hostd',
310
+ description: 'agent container is registered with hostd',
311
+ applies: (ctx) => ctx.hasAgentFolder,
312
+ async run(ctx) {
313
+ if (!(await isDaemonReachable())) {
314
+ return { status: 'skipped', message: 'hostd unreachable (covered by hostd.reachable)' }
315
+ }
316
+ const containerName = containerNameFromCwd(ctx.cwd)
317
+ const reply = await send({ kind: 'status', containerName })
318
+ if (reply.ok) return { status: 'ok', message: 'registered with hostd' }
319
+ return {
320
+ status: 'info',
321
+ message: 'agent is not registered with hostd',
322
+ details: [reply.reason],
323
+ }
324
+ },
325
+ }
326
+ }
327
+
328
+ function containerState(exec: DockerExec): DoctorCheck {
329
+ return {
330
+ name: 'container.state',
331
+ category: 'container',
332
+ description: 'agent container Docker state',
333
+ applies: (ctx) => ctx.hasAgentFolder,
334
+ async run(ctx) {
335
+ const available = await checkDockerAvailable(exec)
336
+ if (!available.ok) {
337
+ return { status: 'skipped', message: 'docker unavailable (covered by docker.daemon-reachable)' }
338
+ }
339
+ const name = containerNameFromCwd(ctx.cwd)
340
+ const state = await inspectContainer(name)
341
+ if (!state.exists) {
342
+ return {
343
+ status: 'info',
344
+ message: `container ${name} does not exist`,
345
+ details: [`expected image tag: ${imageTagFromCwd(ctx.cwd)}`],
346
+ }
347
+ }
348
+ if (state.running) return { status: 'ok', message: `container ${name} is running` }
349
+ return {
350
+ status: 'warning',
351
+ message: `container ${name} is stopped`,
352
+ fix: { description: 'Run `typeclaw start` to bring the container back up.' },
353
+ }
354
+ },
355
+ }
356
+ }
357
+
358
+ function containerHostPort(): DoctorCheck {
359
+ return {
360
+ name: 'container.host-port-resolves',
361
+ category: 'container',
362
+ description: 'running container exposes its host port',
363
+ applies: (ctx) => ctx.hasAgentFolder,
364
+ async run(ctx) {
365
+ const name = containerNameFromCwd(ctx.cwd)
366
+ const state = await inspectContainer(name)
367
+ if (!state.exists || !state.running) {
368
+ return { status: 'skipped', message: 'container not running' }
369
+ }
370
+ try {
371
+ const port = await resolveHostPort({ cwd: ctx.cwd })
372
+ return { status: 'ok', message: `host port ${port} -> container` }
373
+ } catch (err) {
374
+ return {
375
+ status: 'warning',
376
+ message: `cannot resolve host port: ${err instanceof Error ? err.message : String(err)}`,
377
+ }
378
+ }
379
+ },
380
+ }
381
+ }
382
+
383
+ function buildExpectedDockerfile(cwd: string): string | null {
384
+ try {
385
+ const cfg = loadConfigStrictForTemplate(cwd)
386
+ if (cfg === null) return null
387
+ return buildDockerfile(cfg.dockerfile)
388
+ } catch {
389
+ return null
390
+ }
391
+ }
392
+
393
+ function buildExpectedGitignore(cwd: string): string | null {
394
+ try {
395
+ const cfg = loadConfigStrictForTemplate(cwd)
396
+ if (cfg === null) return null
397
+ return buildGitignore(cfg.gitignore)
398
+ } catch {
399
+ return null
400
+ }
401
+ }
402
+
403
+ function loadConfigStrictForTemplate(
404
+ cwd: string,
405
+ ): { dockerfile: Parameters<typeof buildDockerfile>[0]; gitignore: Parameters<typeof buildGitignore>[0] } | null {
406
+ const result = validateConfig(cwd)
407
+ if (!result.ok) return null
408
+ const cfg = loadConfigSync(cwd)
409
+ return { dockerfile: cfg.dockerfile, gitignore: cfg.gitignore }
410
+ }
411
+
412
+ async function safeRead(path: string): Promise<string | null> {
413
+ try {
414
+ return await readFile(path, 'utf8')
415
+ } catch {
416
+ return null
417
+ }
418
+ }
419
+
420
+ async function writeAtomic(path: string, content: string): Promise<void> {
421
+ await writeFile(path, content)
422
+ }
423
+
424
+ export function relativeToCwd(cwd: string, path: string): string {
425
+ return relative(cwd, path) || '.'
426
+ }
@@ -0,0 +1,71 @@
1
+ import { existsSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+
4
+ import type { CommitOutcome, FixAttempt } from './types'
5
+
6
+ export type CommitOptions = {
7
+ cwd: string
8
+ attempts: FixAttempt[]
9
+ spawnGit?: SpawnGit
10
+ }
11
+
12
+ export type GitResult = { exitCode: number; stdout: string; stderr: string }
13
+ export type SpawnGit = (args: string[], cwd: string) => Promise<GitResult>
14
+
15
+ export async function commitAutoFixes(opts: CommitOptions): Promise<CommitOutcome> {
16
+ const successes = opts.attempts.filter((a): a is Extract<FixAttempt, { ok: true }> => a.ok === true)
17
+ if (successes.length === 0) {
18
+ return { kind: 'skipped', reason: 'no successful auto-fixes' }
19
+ }
20
+
21
+ const pathsStaged = uniqueSorted(successes.flatMap((a) => a.changedPaths))
22
+ if (pathsStaged.length === 0) {
23
+ return { kind: 'skipped', reason: 'auto-fixes reported no changed paths' }
24
+ }
25
+
26
+ if (!existsSync(join(opts.cwd, '.git'))) {
27
+ return { kind: 'skipped', reason: 'agent folder is not a git repo (.git missing)' }
28
+ }
29
+
30
+ const spawnGit = opts.spawnGit ?? defaultSpawnGit
31
+
32
+ const add = await spawnGit(['add', '--', ...pathsStaged], opts.cwd)
33
+ if (add.exitCode !== 0) {
34
+ return { kind: 'failed', reason: `git add failed: ${add.stderr.trim() || `exit ${add.exitCode}`}` }
35
+ }
36
+
37
+ const message = buildCommitMessage(opts.attempts)
38
+ const commit = await spawnGit(['commit', '-m', message], opts.cwd)
39
+ if (commit.exitCode !== 0) {
40
+ return { kind: 'failed', reason: `git commit failed: ${commit.stderr.trim() || `exit ${commit.exitCode}`}` }
41
+ }
42
+
43
+ const sha = await spawnGit(['rev-parse', 'HEAD'], opts.cwd)
44
+ const commitSha = sha.exitCode === 0 ? sha.stdout.trim() : ''
45
+ return { kind: 'committed', commitSha, pathsStaged }
46
+ }
47
+
48
+ export function buildCommitMessage(attempts: FixAttempt[]): string {
49
+ const successes = attempts.filter((a): a is Extract<FixAttempt, { ok: true }> => a.ok === true)
50
+ const subject = `typeclaw doctor: auto-fix ${successes.length} issue${successes.length === 1 ? '' : 's'}`
51
+ const body = successes.map((a) => `- [${a.source}] ${a.name}: ${a.summary}`).join('\n')
52
+ return `${subject}\n\n${body}\n`
53
+ }
54
+
55
+ const defaultSpawnGit: SpawnGit = async (args, cwd) => {
56
+ const bun = (globalThis as { Bun?: { spawn: typeof Bun.spawn } }).Bun
57
+ if (!bun) return { exitCode: -1, stdout: '', stderr: 'bun runtime not available' }
58
+ try {
59
+ const proc = bun.spawn({ cmd: ['git', ...args], cwd, stdout: 'pipe', stderr: 'pipe' })
60
+ const exitCode = await proc.exited
61
+ const stdout = await new Response(proc.stdout).text()
62
+ const stderr = await new Response(proc.stderr).text()
63
+ return { exitCode, stdout, stderr }
64
+ } catch (err) {
65
+ return { exitCode: -1, stdout: '', stderr: err instanceof Error ? err.message : String(err) }
66
+ }
67
+ }
68
+
69
+ function uniqueSorted(values: string[]): string[] {
70
+ return [...new Set(values)].sort()
71
+ }