typeclaw 0.1.5 → 0.1.6

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 (128) hide show
  1. package/README.md +14 -12
  2. package/auth.schema.json +41 -0
  3. package/cron.schema.json +8 -0
  4. package/package.json +1 -1
  5. package/secrets.schema.json +41 -0
  6. package/src/agent/auth.ts +45 -22
  7. package/src/agent/index.ts +189 -19
  8. package/src/agent/multimodal/index.ts +12 -0
  9. package/src/agent/multimodal/look-at.ts +185 -0
  10. package/src/agent/multimodal/looker.ts +145 -0
  11. package/src/agent/plugin-tools.ts +30 -1
  12. package/src/agent/session-origin.ts +194 -46
  13. package/src/agent/subagents.ts +57 -1
  14. package/src/agent/system-prompt.ts +1 -1
  15. package/src/agent/tool-result-budget.ts +121 -0
  16. package/src/bundled-plugins/backup/index.ts +23 -8
  17. package/src/bundled-plugins/backup/runner.ts +22 -0
  18. package/src/bundled-plugins/memory/README.md +7 -4
  19. package/src/bundled-plugins/memory/append-tool.ts +87 -61
  20. package/src/bundled-plugins/memory/dreaming.ts +23 -9
  21. package/src/bundled-plugins/memory/find-entry-tool.ts +62 -0
  22. package/src/bundled-plugins/memory/fragment-parser.ts +19 -44
  23. package/src/bundled-plugins/memory/index.ts +91 -8
  24. package/src/bundled-plugins/memory/load-memory.ts +74 -34
  25. package/src/bundled-plugins/memory/memory-logger.ts +72 -29
  26. package/src/bundled-plugins/memory/migration.ts +276 -0
  27. package/src/bundled-plugins/memory/stream-events.ts +55 -0
  28. package/src/bundled-plugins/memory/stream-io.ts +63 -0
  29. package/src/bundled-plugins/memory/watermark.ts +48 -8
  30. package/src/bundled-plugins/security/index.ts +103 -10
  31. package/src/bundled-plugins/security/permissions.ts +12 -0
  32. package/src/bundled-plugins/security/policies/git-exfil.ts +51 -18
  33. package/src/bundled-plugins/tool-result-cap/README.md +9 -4
  34. package/src/bundled-plugins/tool-result-cap/cap-jsonl.ts +115 -0
  35. package/src/bundled-plugins/tool-result-cap/cap-result.ts +25 -13
  36. package/src/bundled-plugins/tool-result-cap/index.ts +16 -2
  37. package/src/channels/adapters/discord-bot-classify.ts +2 -6
  38. package/src/channels/adapters/discord-bot.ts +4 -45
  39. package/src/channels/adapters/kakaotalk-classify.ts +3 -7
  40. package/src/channels/adapters/kakaotalk.ts +28 -47
  41. package/src/channels/adapters/slack-bot-classify.ts +2 -6
  42. package/src/channels/adapters/slack-bot.ts +4 -50
  43. package/src/channels/adapters/telegram-bot-classify.ts +8 -10
  44. package/src/channels/adapters/telegram-bot.ts +3 -16
  45. package/src/channels/index.ts +3 -2
  46. package/src/channels/manager.ts +15 -1
  47. package/src/channels/persistence.ts +44 -10
  48. package/src/channels/router.ts +228 -19
  49. package/src/channels/schema.ts +6 -156
  50. package/src/cli/channel.ts +200 -4
  51. package/src/cli/compose-usage.ts +182 -0
  52. package/src/cli/compose.ts +33 -0
  53. package/src/cli/hostd.ts +49 -1
  54. package/src/cli/index.ts +4 -0
  55. package/src/cli/init.ts +799 -319
  56. package/src/cli/model.ts +244 -0
  57. package/src/cli/provider.ts +404 -0
  58. package/src/cli/reload.ts +6 -1
  59. package/src/cli/role.ts +156 -0
  60. package/src/cli/run.ts +3 -1
  61. package/src/cli/tui.ts +8 -1
  62. package/src/cli/usage-args.ts +47 -0
  63. package/src/cli/usage.ts +97 -0
  64. package/src/compose/index.ts +1 -0
  65. package/src/compose/usage.ts +65 -0
  66. package/src/config/config.ts +385 -12
  67. package/src/config/index.ts +7 -0
  68. package/src/config/models-mutation.ts +200 -0
  69. package/src/config/providers-mutation.ts +250 -0
  70. package/src/config/providers.ts +141 -2
  71. package/src/config/reloadable.ts +15 -4
  72. package/src/container/index.ts +5 -0
  73. package/src/container/require-running.ts +33 -0
  74. package/src/container/start.ts +39 -58
  75. package/src/cron/consumer.ts +22 -2
  76. package/src/cron/index.ts +45 -4
  77. package/src/cron/schema.ts +104 -0
  78. package/src/doctor/checks.ts +50 -33
  79. package/src/git/system-commit.ts +103 -0
  80. package/src/hostd/daemon.ts +16 -0
  81. package/src/hostd/kakao-renewal-manager.ts +223 -0
  82. package/src/hostd/paths.ts +7 -0
  83. package/src/init/dockerfile.ts +32 -6
  84. package/src/init/index.ts +183 -62
  85. package/src/init/kakaotalk-auth.ts +18 -1
  86. package/src/init/models-dev.ts +26 -1
  87. package/src/init/run-owner-claim.ts +77 -0
  88. package/src/permissions/builtins.ts +70 -0
  89. package/src/permissions/grant.ts +99 -0
  90. package/src/permissions/index.ts +29 -0
  91. package/src/permissions/match-rule.ts +305 -0
  92. package/src/permissions/permissions.ts +196 -0
  93. package/src/permissions/resolve.ts +80 -0
  94. package/src/permissions/schema.ts +79 -0
  95. package/src/plugin/context.ts +8 -4
  96. package/src/plugin/define.ts +2 -0
  97. package/src/plugin/index.ts +2 -0
  98. package/src/plugin/manager.ts +41 -0
  99. package/src/plugin/registry.ts +9 -0
  100. package/src/plugin/types.ts +35 -1
  101. package/src/role-claim/client.ts +182 -0
  102. package/src/role-claim/code.ts +53 -0
  103. package/src/role-claim/controller.ts +194 -0
  104. package/src/role-claim/index.ts +19 -0
  105. package/src/role-claim/match-rule.ts +43 -0
  106. package/src/role-claim/pending.ts +100 -0
  107. package/src/run/channel-session-factory.ts +76 -5
  108. package/src/run/index.ts +55 -6
  109. package/src/secrets/encryption.ts +116 -0
  110. package/src/secrets/kakao-renewal.ts +248 -0
  111. package/src/secrets/kakao-store.ts +66 -7
  112. package/src/secrets/keys.ts +173 -0
  113. package/src/secrets/schema.ts +23 -0
  114. package/src/secrets/storage.ts +68 -0
  115. package/src/server/index.ts +122 -11
  116. package/src/shared/index.ts +4 -0
  117. package/src/shared/protocol.ts +27 -0
  118. package/src/skills/typeclaw-channel-kakaotalk/SKILL.md +3 -3
  119. package/src/skills/typeclaw-config/SKILL.md +38 -64
  120. package/src/skills/typeclaw-memory/SKILL.md +1 -1
  121. package/src/skills/typeclaw-permissions/SKILL.md +166 -0
  122. package/src/stream/types.ts +7 -1
  123. package/src/usage/aggregate.ts +117 -0
  124. package/src/usage/format.ts +30 -0
  125. package/src/usage/index.ts +68 -0
  126. package/src/usage/report.ts +354 -0
  127. package/src/usage/scan.ts +186 -0
  128. package/typeclaw.schema.json +57 -45
@@ -3,7 +3,8 @@ import { existsSync } from 'node:fs'
3
3
  import { readFile, writeFile } from 'node:fs/promises'
4
4
  import { isAbsolute, join, resolve } from 'node:path'
5
5
 
6
- import { configSchema, expandMountPath, migrateLegacyConfigShape, type Config } from '@/config'
6
+ import { expandMountPath, loadConfigSync, type Config } from '@/config'
7
+ import { commitSystemFile as commitSystemFileShared } from '@/git/system-commit'
7
8
  import { send as sendToDaemon } from '@/hostd/client'
8
9
  import type { HttpInfoResult } from '@/hostd/protocol'
9
10
  import { ensureDaemon } from '@/hostd/spawn'
@@ -30,7 +31,6 @@ import {
30
31
  defaultDockerExec,
31
32
  type DockerExec,
32
33
  type DockerExecResult,
33
- getBun,
34
34
  imageTagFromCwd,
35
35
  isContainerNameConflict,
36
36
  sanitizeDockerStderr,
@@ -41,7 +41,6 @@ import { buildCrashReason, createVerifyRunning, type VerifyRunningFn } from './v
41
41
  const PACKAGE_FILE = 'package.json'
42
42
  const BUN_LOCK_FILE = 'bun.lock'
43
43
  const DEPENDENCY_FILES = [PACKAGE_FILE, BUN_LOCK_FILE] as const
44
- const CONFIG_FILE = 'typeclaw.json'
45
44
  const ENV_FILE = '.env'
46
45
  const COMPOSE_PROJECT = 'typeclaw'
47
46
  const CONTAINER_HOSTD_HOST = 'host.docker.internal'
@@ -179,6 +178,14 @@ export async function start({
179
178
  // one-shot and idempotent — once `workspaces` is set, refreshPackageJson
180
179
  // is a no-op, so users who never edit their agent folder pay zero cost on
181
180
  // subsequent starts and users who customized `workspaces` are not clobbered.
181
+ //
182
+ // typeclaw.json migration is NOT triggered explicitly here — it follows
183
+ // the disk rewrite via persistMigratedConfig (src/config/config.ts), so
184
+ // every entry point that reads typeclaw.json (host CLI, hostd daemon,
185
+ // container runtime) also produces the commit. start() therefore only
186
+ // needs to orchestrate the .gitignore / package.json side; the
187
+ // refreshGitignore call below reads typeclaw.json and will incidentally
188
+ // trigger the migration commit if the file was legacy.
182
189
  await refreshGitignore(cwd)
183
190
  const pkgRefresh = await refreshPackageJson(cwd)
184
191
  await commitSystemFile(cwd, GITIGNORE_FILE, 'Update .gitignore')
@@ -237,7 +244,7 @@ export async function start({
237
244
  // line resolves against the agent's installed node_modules/typeclaw —
238
245
  // ensures the base image's CLI version matches the runtime the
239
246
  // container will actually load.
240
- await refreshDockerfile(cwd)
247
+ const dockerfileRefresh = await refreshDockerfile(cwd)
241
248
  await migrateKakaotalkCredentials(cwd)
242
249
 
243
250
  if (state.exists) {
@@ -308,7 +315,7 @@ export async function start({
308
315
  cwd,
309
316
  hostPort,
310
317
  imageExists: imageExisted,
311
- forceBuild,
318
+ forceBuild: forceBuild || dockerfileRefresh.changed,
312
319
  hostdControl,
313
320
  publishHost,
314
321
  tuiToken,
@@ -531,12 +538,20 @@ async function resolvePublishHost(exec: DockerExec): Promise<string> {
531
538
  return '127.0.0.1'
532
539
  }
533
540
 
534
- export async function refreshDockerfile(cwd: string): Promise<void> {
541
+ // The `changed` return drives auto-rebuild in start() so users don't need to
542
+ // pass `--build` after a CLI upgrade or after editing `typeclaw.json#docker.*`.
543
+ // Comparing rendered contents (rather than tracking a separate state file) is
544
+ // the cheapest correct signal: the build context for `docker build` is the
545
+ // Dockerfile itself, so equal contents definitionally produce an equivalent
546
+ // image.
547
+ export async function refreshDockerfile(cwd: string): Promise<{ changed: boolean }> {
535
548
  const cfg = await loadTypeclawConfig(cwd)
536
- await writeFile(
537
- join(cwd, DOCKERFILE),
538
- buildDockerfile(cfg.docker.file, { baseImageVersion: resolveBaseImageVersion(cwd) }),
539
- )
549
+ const next = buildDockerfile(cfg.docker.file, { baseImageVersion: resolveBaseImageVersion(cwd) })
550
+ const path = join(cwd, DOCKERFILE)
551
+ const prev = await readFile(path, 'utf8').catch(() => null)
552
+ if (prev === next) return { changed: false }
553
+ await writeFile(path, next)
554
+ return { changed: true }
540
555
  }
541
556
 
542
557
  export async function refreshGitignore(cwd: string): Promise<void> {
@@ -544,43 +559,13 @@ export async function refreshGitignore(cwd: string): Promise<void> {
544
559
  await writeFile(join(cwd, GITIGNORE_FILE), buildGitignore(cfg.git.ignore))
545
560
  }
546
561
 
547
- // Commits TypeClaw-owned system file(s) if any are dirty in git. Skips silently
548
- // when the agent folder is not a git repo, when bun is unavailable, or when
549
- // every named file is clean (no changes since last commit). Uses the user's
550
- // global git config for authorship TypeClaw does not impersonate the user
551
- // here. Accepts a single file or an array; the array form produces a single
552
- // atomic commit covering all listed paths, used for migrations that touch
553
- // multiple files together (e.g. enabling bun workspaces writes both
554
- // package.json and packages/.gitkeep in one commit).
555
- export async function commitSystemFile(cwd: string, file: string | readonly string[], message: string): Promise<void> {
556
- const files = typeof file === 'string' ? [file] : file
557
- if (files.length === 0) return
558
-
559
- const bun = getBun()
560
- if (!bun) return
561
- if (!existsSync(join(cwd, '.git'))) return
562
-
563
- const status = bun.spawn({
564
- cmd: ['git', 'status', '--porcelain', '--', ...files],
565
- cwd,
566
- stdout: 'pipe',
567
- stderr: 'pipe',
568
- })
569
- if ((await status.exited) !== 0) return
570
- const dirty = (await new Response(status.stdout).text()).trim().length > 0
571
- if (!dirty) return
572
-
573
- const add = bun.spawn({ cmd: ['git', 'add', '--', ...files], cwd, stdout: 'pipe', stderr: 'pipe' })
574
- if ((await add.exited) !== 0) return
575
-
576
- const commit = bun.spawn({
577
- cmd: ['git', 'commit', '-m', message, '--only', '--', ...files],
578
- cwd,
579
- stdout: 'pipe',
580
- stderr: 'pipe',
581
- })
582
- await commit.exited
583
- }
562
+ // Re-exported from src/git/system-commit.ts so existing call sites
563
+ // (refreshGitignore wiring, doctor/commit.ts comment references, test
564
+ // imports) keep working under the original name. New code should import
565
+ // directly from @/git/system-commit instead. The sync sibling lives
566
+ // alongside in that module and is used by persistMigratedConfig to pair
567
+ // the migration write with a commit on every read path, not only here.
568
+ export const commitSystemFile = commitSystemFileShared
584
569
 
585
570
  async function imageExists(exec: DockerExec, tag: string): Promise<boolean> {
586
571
  const result = await exec(['image', 'inspect', tag])
@@ -730,7 +715,13 @@ async function detectDevSource(cwd: string): Promise<string | null> {
730
715
  // invalid mount entry — must surface so the user sees they configured a mount
731
716
  // that won't be applied.
732
717
  async function loadTypeclawConfig(cwd: string): Promise<Config> {
733
- return configSchema.parse(await loadConfigJson(cwd))
718
+ // Goes through the shared loadConfigSync so the legacy-shape migration
719
+ // (and its paired git commit via persistMigratedConfig) follows every
720
+ // start() read path — refreshGitignore, the docker run argv builder, and
721
+ // the daemon-register payload all eventually land here. The function is
722
+ // declared async only to preserve the existing await sites; the work is
723
+ // synchronous under the hood.
724
+ return loadConfigSync(cwd)
734
725
  }
735
726
 
736
727
  async function registerWithDaemon({
@@ -777,16 +768,6 @@ async function useCurrentHostDaemon(): Promise<{ ok: true; httpPort: number } |
777
768
  return { ok: true, httpPort: result.port }
778
769
  }
779
770
 
780
- async function loadConfigJson(cwd: string): Promise<unknown> {
781
- let raw: string
782
- try {
783
- raw = await readFile(join(cwd, CONFIG_FILE), 'utf8')
784
- } catch {
785
- return {}
786
- }
787
- return migrateLegacyConfigShape(JSON.parse(raw)).json
788
- }
789
-
790
771
  type PreparedHostDaemonStatus =
791
772
  | { state: 'registered'; control: HostDaemonControl }
792
773
  | { state: 'unavailable'; reason: string }
@@ -100,8 +100,24 @@ async function runPrompt(
100
100
  stream: Stream,
101
101
  ): Promise<void> {
102
102
  if (job.subagent !== undefined) {
103
+ // Propagate the cron job's role and origin into the spawned subagent.
104
+ // Without this, every cron-triggered subagent (e.g. memory dreaming)
105
+ // resolves to `guest` because the new-session consumer reads provenance
106
+ // off the stream target rather than rebuilding it. Encode the parent
107
+ // origin as JSON since StreamTarget is a flat-string shape.
108
+ const parentOrigin: SessionOrigin = {
109
+ kind: 'cron',
110
+ jobId: job.id,
111
+ jobKind: 'prompt',
112
+ ...(job.scheduledByRole !== undefined ? { scheduledByRole: job.scheduledByRole } : {}),
113
+ }
103
114
  stream.publish({
104
- target: { kind: 'new-session', subagent: job.subagent },
115
+ target: {
116
+ kind: 'new-session',
117
+ subagent: job.subagent,
118
+ ...(job.scheduledByRole !== undefined ? { spawnedByRole: job.scheduledByRole } : {}),
119
+ spawnedByOriginJson: JSON.stringify(parentOrigin),
120
+ },
105
121
  payload: job.payload,
106
122
  })
107
123
  return
@@ -131,11 +147,15 @@ async function runPrompt(
131
147
  sessionId: session.sessionId,
132
148
  parentTranscriptPath: session.getTranscriptPath?.(),
133
149
  idleMs: 0,
150
+ ...(session.origin !== undefined ? { origin: session.origin } : {}),
134
151
  })
135
152
  }
136
153
  } finally {
137
154
  if (session.hooks && session.sessionId !== undefined) {
138
- await session.hooks.runSessionEnd({ sessionId: session.sessionId })
155
+ await session.hooks.runSessionEnd({
156
+ sessionId: session.sessionId,
157
+ ...(session.origin !== undefined ? { origin: session.origin } : {}),
158
+ })
139
159
  }
140
160
  session.dispose?.()
141
161
  }
package/src/cron/index.ts CHANGED
@@ -1,10 +1,17 @@
1
1
  import { existsSync } from 'node:fs'
2
- import { readFile } from 'node:fs/promises'
2
+ import { readFile, writeFile } from 'node:fs/promises'
3
3
  import { join } from 'node:path'
4
4
 
5
5
  import type { SubagentRegistry } from '@/agent/subagents'
6
+ import { commitSystemFile } from '@/git/system-commit'
6
7
 
7
- import { type CronFile, parseCronFile } from './schema'
8
+ import {
9
+ buildCronMigrationCommitMessage,
10
+ type CronFile,
11
+ type CronMigrationStep,
12
+ migrateLegacyCronShape,
13
+ parseCronFile,
14
+ } from './schema'
8
15
 
9
16
  export { createCronReloadable, type CreateCronReloadableOptions } from './reloadable'
10
17
  export {
@@ -15,7 +22,18 @@ export {
15
22
  type CronSession,
16
23
  } from './consumer'
17
24
  export { createScheduler, type JobDiff, type Scheduler, type SchedulerLogger } from './scheduler'
18
- export { cronFileSchema, cronJobSchema, type CronFile, type CronJob, type ExecJob, type PromptJob } from './schema'
25
+ export {
26
+ buildCronMigrationCommitMessage,
27
+ cronFileSchema,
28
+ cronJobSchema,
29
+ type CronFile,
30
+ type CronJob,
31
+ type CronMigrationResult,
32
+ type CronMigrationStep,
33
+ type ExecJob,
34
+ migrateLegacyCronShape,
35
+ type PromptJob,
36
+ } from './schema'
19
37
 
20
38
  const CRON_FILE = 'cron.json'
21
39
 
@@ -43,12 +61,35 @@ export async function loadCron(agentDir: string, options: LoadCronOptions = {}):
43
61
  return { ok: false, reason: `cron.json is not valid JSON: ${errorMessage(err)}` }
44
62
  }
45
63
 
46
- const result = parseCronFile(parsed, options.subagents !== undefined ? { subagents: options.subagents } : {})
64
+ const migrated = migrateLegacyCronShape(parsed)
65
+ if (migrated.changed) {
66
+ await persistMigratedCron(path, migrated.json, agentDir, migrated.applied)
67
+ }
68
+
69
+ const result = parseCronFile(migrated.json, options.subagents !== undefined ? { subagents: options.subagents } : {})
47
70
  if (!result.ok) return { ok: false, reason: result.reason }
48
71
 
49
72
  return { ok: true, file: result.file }
50
73
  }
51
74
 
75
+ async function persistMigratedCron(
76
+ path: string,
77
+ json: unknown,
78
+ agentDir: string,
79
+ applied: readonly CronMigrationStep[],
80
+ ): Promise<void> {
81
+ try {
82
+ await writeFile(path, `${JSON.stringify(json, null, 2)}\n`)
83
+ } catch {
84
+ return
85
+ }
86
+
87
+ const message = buildCronMigrationCommitMessage(applied)
88
+ if (message !== null) {
89
+ await commitSystemFile(agentDir, CRON_FILE, message)
90
+ }
91
+ }
92
+
52
93
  function errorMessage(err: unknown): string {
53
94
  return err instanceof Error ? err.message : String(err)
54
95
  }
@@ -11,6 +11,16 @@ const baseJob = z.object({
11
11
  schedule: z.string().min(1),
12
12
  enabled: z.boolean().default(true),
13
13
  timezone: z.string().optional(),
14
+ scheduledByRole: z.string().optional(),
15
+ // Audit snapshot of the SessionOrigin that scheduled this job. Persisted
16
+ // as opaque z.unknown() because SessionOrigin is recursive (a cron origin
17
+ // can contain a subagent origin can contain another cron origin, etc.)
18
+ // and we do not want to mirror that union in the cron schema. The cron
19
+ // consumer reads this back as-is and stamps it into the firing session's
20
+ // origin without further validation -- if it's malformed, role
21
+ // resolution falls back to `guest` via the same path that handles
22
+ // missing fields.
23
+ scheduledByOrigin: z.unknown().optional(),
14
24
  })
15
25
 
16
26
  const promptJob = baseJob.extend({
@@ -46,6 +56,93 @@ export type ParseCronOptions = {
46
56
  subagents?: SubagentRegistry
47
57
  }
48
58
 
59
+ // One-shot rewrite for cron.json files that predate PR #171, when
60
+ // `scheduledByRole` became mandatory on every job. The schema gate
61
+ // (`parseCronFile`) rejects legacy entries with a precise remediation
62
+ // message, but rejecting on every container boot is a stuck state for
63
+ // the user — the agent crashes in a tight restart loop with no path
64
+ // forward except hand-editing cron.json.
65
+ //
66
+ // The migration stamps `scheduledByRole: 'owner'` on every job that's
67
+ // missing it. `owner` is the right default for two reasons:
68
+ // 1. Before #171 there was no role concept; every cron job ran with
69
+ // the same (effectively-owner) privileges the agent had.
70
+ // 2. The schema gate's own error message tells users to add
71
+ // `"scheduledByRole": "owner"` for manually-authored entries —
72
+ // we just do it for them.
73
+ //
74
+ // Mirrors `migrateLegacyConfigShape` in src/config/config.ts: pure
75
+ // function, returns the rewritten JSON plus an `applied` array so
76
+ // callers can build a meaningful commit message. Returns `changed:
77
+ // false` on canonical input so the persist + commit path stays
78
+ // untouched on the happy path.
79
+ export type CronMigrationStep = { kind: 'stamp-scheduled-by-role-owner'; jobIds: string[] }
80
+
81
+ export type CronMigrationResult = { json: unknown; changed: boolean; applied: CronMigrationStep[] }
82
+
83
+ export function migrateLegacyCronShape(json: unknown): CronMigrationResult {
84
+ if (typeof json !== 'object' || json === null || Array.isArray(json)) {
85
+ return { json, changed: false, applied: [] }
86
+ }
87
+
88
+ const obj = json as Record<string, unknown>
89
+ const jobs = obj.jobs
90
+ if (!Array.isArray(jobs)) {
91
+ return { json, changed: false, applied: [] }
92
+ }
93
+
94
+ const stampedIds: string[] = []
95
+ const nextJobs = jobs.map((job) => {
96
+ if (typeof job !== 'object' || job === null || Array.isArray(job)) return job
97
+ const record = job as Record<string, unknown>
98
+ if ('scheduledByRole' in record) return job
99
+ const id = typeof record.id === 'string' ? record.id : '<unknown>'
100
+ stampedIds.push(id)
101
+ return { ...record, scheduledByRole: 'owner' }
102
+ })
103
+
104
+ if (stampedIds.length === 0) {
105
+ return { json, changed: false, applied: [] }
106
+ }
107
+
108
+ return {
109
+ json: { ...obj, jobs: nextJobs },
110
+ changed: true,
111
+ applied: [{ kind: 'stamp-scheduled-by-role-owner', jobIds: stampedIds }],
112
+ }
113
+ }
114
+
115
+ // Builds a one-line git commit subject (plus enumerating body) for a
116
+ // cron.json migration. Returns null when no steps were applied — callers
117
+ // should not commit in that case. Mirrors `buildConfigMigrationCommitMessage`
118
+ // in src/config/config.ts.
119
+ export function buildCronMigrationCommitMessage(applied: readonly CronMigrationStep[]): string | null {
120
+ const first = applied[0]
121
+ if (first === undefined) return null
122
+
123
+ const subject =
124
+ applied.length === 1
125
+ ? `cron.json: ${shortCronStepLabel(first)}`
126
+ : `cron.json: migrate legacy shape (${applied.length} steps)`
127
+
128
+ const bodyLines: string[] = applied.map((step) => `- ${describeCronStep(step)}`)
129
+ return `${subject}\n\n${bodyLines.join('\n')}\n`
130
+ }
131
+
132
+ function shortCronStepLabel(step: CronMigrationStep): string {
133
+ switch (step.kind) {
134
+ case 'stamp-scheduled-by-role-owner':
135
+ return `stamp scheduledByRole: "owner" on ${step.jobIds.length} legacy job${step.jobIds.length === 1 ? '' : 's'}`
136
+ }
137
+ }
138
+
139
+ function describeCronStep(step: CronMigrationStep): string {
140
+ switch (step.kind) {
141
+ case 'stamp-scheduled-by-role-owner':
142
+ return `stamp scheduledByRole: "owner" on jobs without provenance (PR #171 backfill): ${step.jobIds.join(', ')}`
143
+ }
144
+ }
145
+
49
146
  export function parseCronFile(raw: unknown, options: ParseCronOptions = {}): ParseCronResult {
50
147
  const parsed = cronFileSchema.safeParse(raw)
51
148
  if (!parsed.success) {
@@ -73,6 +170,13 @@ export function parseCronFile(raw: unknown, options: ParseCronOptions = {}): Par
73
170
  return { ok: false, reason: `job ${job.id}: invalid schedule "${job.schedule}": ${message}` }
74
171
  }
75
172
 
173
+ if (job.scheduledByRole === undefined) {
174
+ return {
175
+ ok: false,
176
+ reason: `job ${job.id}: missing 'scheduledByRole'. Add "scheduledByRole": "owner" if you authored this entry manually.`,
177
+ }
178
+ }
179
+
76
180
  if (job.kind === 'prompt' && job.subagent !== undefined && options.subagents !== undefined) {
77
181
  const subagent = options.subagents[job.subagent]
78
182
  if (!subagent) {
@@ -23,8 +23,6 @@ import { buildGitignore, GITIGNORE_FILE } from '@/init/gitignore'
23
23
 
24
24
  import type { DoctorCheck } from './types'
25
25
 
26
- const REQUIRED_DIRS = ['workspace', 'sessions', '.agents/skills', 'mounts', 'packages'] as const
27
-
28
26
  export function buildStaticChecks(opts: { dockerExec?: DockerExec } = {}): DoctorCheck[] {
29
27
  const dockerExec = opts.dockerExec ?? defaultDockerExec
30
28
 
@@ -32,12 +30,12 @@ export function buildStaticChecks(opts: { dockerExec?: DockerExec } = {}): Docto
32
30
  dockerDaemon(dockerExec),
33
31
  bunRuntime(),
34
32
  agentFolderInitialized(),
35
- agentFolderRequiredDirs(),
36
33
  agentFolderDockerfileTemplate(),
37
34
  agentFolderGitignoreTemplate(),
38
35
  agentFolderNodeModules(),
39
36
  agentFolderGitRepo(),
40
37
  configValid(),
38
+ configBundledProfiles(),
41
39
  hostdHomeWritable(),
42
40
  hostdReachable(),
43
41
  hostdRegistration(),
@@ -103,36 +101,6 @@ function agentFolderInitialized(): DoctorCheck {
103
101
  }
104
102
  }
105
103
 
106
- function agentFolderRequiredDirs(): DoctorCheck {
107
- return {
108
- name: 'agent-folder.required-dirs',
109
- category: 'agent-folder',
110
- description: 'required agent directories exist',
111
- applies: (ctx) => ctx.hasAgentFolder,
112
- async run(ctx) {
113
- const missing = REQUIRED_DIRS.filter((d) => !existsSync(join(ctx.cwd, d)))
114
- if (missing.length === 0) return { status: 'ok', message: 'all required directories present' }
115
- return {
116
- status: 'warning',
117
- message: `${missing.length} required ${missing.length === 1 ? 'directory' : 'directories'} missing`,
118
- details: missing.map((d) => `missing: ${d}/`),
119
- fix: {
120
- description: `Create the missing directories (${missing.map((d) => `${d}/`).join(', ')}).`,
121
- autoFix: async () => {
122
- for (const d of missing) {
123
- mkdirSync(join(ctx.cwd, d), { recursive: true })
124
- }
125
- return {
126
- summary: `created ${missing.map((d) => `${d}/`).join(', ')}`,
127
- changedPaths: missing.map((d) => `${d}/`),
128
- }
129
- },
130
- },
131
- }
132
- },
133
- }
134
- }
135
-
136
104
  function agentFolderDockerfileTemplate(): DoctorCheck {
137
105
  return {
138
106
  name: 'agent-folder.dockerfile-managed',
@@ -247,6 +215,55 @@ function configValid(): DoctorCheck {
247
215
  }
248
216
  }
249
217
 
218
+ // Warns (not errors) when a model profile that a bundled subagent prefers is
219
+ // absent from `models`. Bundled subagents fall back to `default` silently
220
+ // today, but the operator likely declared a `fast`/`deep`/`vision` model in
221
+ // the design discussion's tier scheme expecting the bundled subagents to
222
+ // pick them up. This check surfaces the gap once at `typeclaw doctor` time
223
+ // instead of leaving it buried in container logs (where the rate-limited
224
+ // fallback warning lives).
225
+ //
226
+ // We deliberately limit this to known bundled profiles (memory-logger=fast,
227
+ // dreaming=deep, multimodal-looker=vision). Plugin-contributed subagents
228
+ // would require loading the plugin registry — a heavyweight async path
229
+ // that doesn't belong in doctor's static check surface.
230
+ const BUNDLED_PROFILES: ReadonlyArray<{ profile: string; subagent: string }> = [
231
+ { profile: 'fast', subagent: 'memory-logger' },
232
+ { profile: 'deep', subagent: 'dreaming' },
233
+ { profile: 'vision', subagent: 'multimodal-looker (via look_at tool)' },
234
+ ]
235
+
236
+ function configBundledProfiles(): DoctorCheck {
237
+ return {
238
+ name: 'config.bundled-profiles',
239
+ category: 'config',
240
+ description: 'bundled subagent profiles (`fast`, `deep`, `vision`) declared in models',
241
+ applies: (ctx) => ctx.hasAgentFolder,
242
+ async run(ctx) {
243
+ const validation = validateConfig(ctx.cwd)
244
+ if (!validation.ok) {
245
+ return { status: 'ok', message: 'skipped (config.valid will report the underlying error)' }
246
+ }
247
+ const config = loadConfigSync(ctx.cwd)
248
+ const declared = new Set(Object.keys(config.models))
249
+ const missing = BUNDLED_PROFILES.filter((p) => !declared.has(p.profile))
250
+ if (missing.length === 0) {
251
+ return { status: 'ok', message: 'all bundled subagent profiles declared' }
252
+ }
253
+ return {
254
+ status: 'warning',
255
+ message: `${missing.length} bundled profile(s) missing; will fall back to \`default\``,
256
+ details: missing.map(
257
+ (m) => `${m.profile}: used by ${m.subagent}; declare \`models.${m.profile}\` in typeclaw.json to override`,
258
+ ),
259
+ fix: {
260
+ description: 'Add the missing profile(s) under `models` in typeclaw.json. See the typeclaw-config skill.',
261
+ },
262
+ }
263
+ },
264
+ }
265
+ }
266
+
250
267
  function hostdHomeWritable(): DoctorCheck {
251
268
  return {
252
269
  name: 'hostd.home-writable',
@@ -0,0 +1,103 @@
1
+ import { existsSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+
4
+ // Commits TypeClaw-owned tracked files (.gitignore, package.json,
5
+ // typeclaw.json) if any are dirty in git. Skips silently when the agent
6
+ // folder is not a git repo, when Bun is unavailable, or when every named
7
+ // file is clean. Uses the user's global git config for authorship —
8
+ // TypeClaw does not impersonate the user here.
9
+ //
10
+ // Accepts a single file or an array; the array form produces a single
11
+ // atomic commit covering all listed paths (used for migrations that touch
12
+ // multiple files together, e.g. enabling bun workspaces writes both
13
+ // package.json and packages/.gitkeep in one commit).
14
+ //
15
+ // Lives under src/git/ rather than src/container/ because both the
16
+ // host-stage launcher (typeclaw start) and src/config/config.ts (called
17
+ // from every entry point that reads typeclaw.json, host AND container)
18
+ // need to commit migration artifacts. Putting it in src/container/ would
19
+ // pull container-level imports into the config module and create a
20
+ // circular dependency at the package boundary.
21
+ export async function commitSystemFile(cwd: string, file: string | readonly string[], message: string): Promise<void> {
22
+ const files = typeof file === 'string' ? [file] : file
23
+ if (files.length === 0) return
24
+
25
+ const bun = getBunAsync()
26
+ if (!bun) return
27
+ if (!existsSync(join(cwd, '.git'))) return
28
+
29
+ const status = bun.spawn({
30
+ cmd: ['git', 'status', '--porcelain', '--', ...files],
31
+ cwd,
32
+ stdout: 'pipe',
33
+ stderr: 'pipe',
34
+ })
35
+ if ((await status.exited) !== 0) return
36
+ const dirty = (await new Response(status.stdout).text()).trim().length > 0
37
+ if (!dirty) return
38
+
39
+ const add = bun.spawn({ cmd: ['git', 'add', '--', ...files], cwd, stdout: 'pipe', stderr: 'pipe' })
40
+ if ((await add.exited) !== 0) return
41
+
42
+ const commit = bun.spawn({
43
+ cmd: ['git', 'commit', '-m', message, '--only', '--', ...files],
44
+ cwd,
45
+ stdout: 'pipe',
46
+ stderr: 'pipe',
47
+ })
48
+ await commit.exited
49
+ }
50
+
51
+ // Synchronous variant for callers that already hold a synchronous codepath
52
+ // — specifically `persistMigratedConfig` in src/config/config.ts. The
53
+ // migration write is itself synchronous (writeFileSync) and the call sites
54
+ // (loadConfigSync, validateConfig, loadPluginConfigsSync) are sync, so we
55
+ // cannot await an async commit without forcing them all to become async,
56
+ // which would ripple into hundreds of call sites across the codebase.
57
+ //
58
+ // The commit overhead (~10-50ms) is paid exactly once per agent folder
59
+ // per legacy form: after the first call rewrites the file to canonical
60
+ // shape, subsequent migrateLegacyConfigShape calls return changed=false
61
+ // and this codepath is unreachable. On canonical folders (the common
62
+ // case) this function is never called at all.
63
+ //
64
+ // Same skip semantics as the async variant — no-op when the folder is not
65
+ // a git repo, when Bun is unavailable, or when the file is clean.
66
+ export function commitSystemFileSync(cwd: string, file: string | readonly string[], message: string): void {
67
+ const files = typeof file === 'string' ? [file] : file
68
+ if (files.length === 0) return
69
+
70
+ const bun = getBunSync()
71
+ if (!bun) return
72
+ if (!existsSync(join(cwd, '.git'))) return
73
+
74
+ const status = bun.spawnSync({
75
+ cmd: ['git', 'status', '--porcelain', '--', ...files],
76
+ cwd,
77
+ stdout: 'pipe',
78
+ stderr: 'pipe',
79
+ })
80
+ if (status.exitCode !== 0) return
81
+ if (new TextDecoder().decode(status.stdout).trim().length === 0) return
82
+
83
+ const add = bun.spawnSync({ cmd: ['git', 'add', '--', ...files], cwd, stdout: 'pipe', stderr: 'pipe' })
84
+ if (add.exitCode !== 0) return
85
+
86
+ bun.spawnSync({
87
+ cmd: ['git', 'commit', '-m', message, '--only', '--', ...files],
88
+ cwd,
89
+ stdout: 'pipe',
90
+ stderr: 'pipe',
91
+ })
92
+ }
93
+
94
+ // Bun-availability shims kept tight to the two functions so the module
95
+ // has no module-level side effects (matters for the sync codepath, which
96
+ // is called during typeclaw.json reads on hot import).
97
+ function getBunAsync(): { spawn: typeof Bun.spawn } | undefined {
98
+ return (globalThis as { Bun?: { spawn: typeof Bun.spawn } }).Bun
99
+ }
100
+
101
+ function getBunSync(): { spawnSync: typeof Bun.spawnSync } | undefined {
102
+ return (globalThis as { Bun?: { spawnSync: typeof Bun.spawnSync } }).Bun
103
+ }