typeclaw 0.1.5 → 0.2.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 (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 +209 -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 +190 -61
  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
@@ -6,6 +6,8 @@ import type { Model } from '@mariozechner/pi-ai'
6
6
  import { z } from 'zod'
7
7
 
8
8
  import { channelsSchema } from '@/channels/schema'
9
+ import { commitSystemFileSync } from '@/git/system-commit'
10
+ import { rolesConfigSchema } from '@/permissions/schema'
9
11
 
10
12
  import {
11
13
  DEFAULT_MODEL_REF,
@@ -203,11 +205,41 @@ export const networkSchema = z
203
205
 
204
206
  export type NetworkConfig = z.infer<typeof networkSchema>
205
207
 
208
+ // `models` is a map from profile name to a single curated model ref. The
209
+ // `default` profile is mandatory; every other profile is optional and falls
210
+ // back to `default` at resolution time (see `resolveProfile`).
211
+ //
212
+ // Profile names are open strings; the runtime recognizes a handful of
213
+ // well-known names by convention (`default`, `fast`, `deep`, `vision`) but
214
+ // any string is valid. Subagents may declare a static profile preference;
215
+ // callers may override per-spawn. Unknown profile names resolve to `default`
216
+ // with a one-time warning at session construction.
217
+ //
218
+ // The pre-multi-model schema had a single `model: KnownModelRef` at the top
219
+ // level. `migrateLegacyConfigShape` rewrites that to `models: { default: ... }`
220
+ // on first load (and writes the result back to disk + commits via
221
+ // `persistMigratedConfig`), so every downstream consumer sees the new shape.
222
+ export const modelsSchema = z
223
+ .record(z.string().min(1), z.enum(knownModelRefs))
224
+ .refine((m) => 'default' in m, { message: 'models.default is required' })
225
+
226
+ // Zod's `z.record(..., refine)` doesn't refine the inferred type — the inferred
227
+ // shape is `Record<string, KnownModelRef>` where every access is `T | undefined`.
228
+ // The runtime guarantee (the `refine` above) is that `default` is present, so
229
+ // we narrow the type here. Every consumer (auth.ts, agent/index.ts,
230
+ // resolveProfile) reads `models.default` on the hot path; without this
231
+ // narrowing they all have to assert or `?? throw`, which is noise around an
232
+ // invariant the schema already enforces.
233
+ export type Models = Record<string, KnownModelRef> & { default: KnownModelRef }
234
+
206
235
  export const configSchema = z
207
236
  .object({
208
237
  $schema: z.string().optional(),
209
238
  port: z.number().int().min(1).max(65535).default(DEFAULT_PORT),
210
- model: z.enum(knownModelRefs).default(DEFAULT_MODEL_REF),
239
+ // `default(() => ...)` ensures every parsed config has at least
240
+ // `models.default`. Direct `.default({ default: ... })` would short-circuit
241
+ // the refinement, so we lean on the lazy thunk form.
242
+ models: modelsSchema.default(() => ({ default: DEFAULT_MODEL_REF })) as unknown as z.ZodType<Models>,
211
243
  // Defaults to `[]` so the field can be omitted from `typeclaw.json` (no
212
244
  // host paths exposed) without failing the whole config load. `typeclaw
213
245
  // init` omits this field so users don't see noise for the empty case.
@@ -225,6 +257,7 @@ export const configSchema = z
225
257
  network: networkSchema,
226
258
  docker: dockerSchema,
227
259
  git: gitSchema,
260
+ roles: rolesConfigSchema.optional(),
228
261
  })
229
262
  .catchall(z.unknown())
230
263
 
@@ -238,6 +271,28 @@ export function resolveModel(ref: KnownModelRef): Model<'openai-completions'> |
238
271
  return KNOWN_PROVIDERS[providerId].models[modelId as never]
239
272
  }
240
273
 
274
+ // Resolves a profile name (e.g. `fast`, `deep`, `vision`) to a concrete model
275
+ // ref. Unknown profiles fall back to `default` so callers can pass through
276
+ // arbitrary subagent-declared or user-overridden strings without crashing.
277
+ // Returns the resolved ref plus whether it came from the requested profile or
278
+ // from the `default` fallback, so the caller can warn once per session
279
+ // instead of every prompt.
280
+ export type ResolvedProfile = {
281
+ ref: KnownModelRef
282
+ profile: string
283
+ fellBackToDefault: boolean
284
+ }
285
+
286
+ export function resolveProfile(models: Models, name: string | undefined): ResolvedProfile {
287
+ const requested = name ?? 'default'
288
+ const ref = models[requested]
289
+ if (ref !== undefined) {
290
+ return { ref, profile: requested, fellBackToDefault: false }
291
+ }
292
+ const fallback = models.default
293
+ return { ref: fallback, profile: 'default', fellBackToDefault: true }
294
+ }
295
+
241
296
  // Resolves a mount's `path` field to an absolute host path, mirroring shell
242
297
  // expansion rules: `~`/`~/...` → home dir, relative → resolved against `cwd`,
243
298
  // absolute → unchanged. Single source of truth so validation and Docker arg
@@ -306,7 +361,7 @@ export type FieldEffect = 'applied' | 'restart-required' | 'ignored'
306
361
 
307
362
  export const FIELD_EFFECTS: Record<string, FieldEffect> = {
308
363
  $schema: 'ignored',
309
- model: 'applied',
364
+ models: 'applied',
310
365
  port: 'restart-required',
311
366
  mounts: 'restart-required',
312
367
  plugins: 'restart-required',
@@ -316,6 +371,16 @@ export const FIELD_EFFECTS: Record<string, FieldEffect> = {
316
371
  network: 'restart-required',
317
372
  'docker.file': 'restart-required',
318
373
  'git.ignore': 'restart-required',
374
+ // Split: `match` lists are reload-safe (typeclaw role claim, hand-edits
375
+ // adding/removing match rules apply without a container restart);
376
+ // `permissions` lists are restart-required (changing what a role can DO
377
+ // is a bigger deal than changing WHO fills it, and several consumers —
378
+ // plugin contexts, the security plugin guards — capture the permissions
379
+ // contract at boot). The diff machinery in diffConfig() understands
380
+ // `roles.match` and `roles.permissions` as virtual paths and compares
381
+ // the corresponding projections of the whole `roles` block.
382
+ 'roles.match': 'applied',
383
+ 'roles.permissions': 'restart-required',
319
384
  }
320
385
 
321
386
  // Stable JSON for value comparison. Fields are small JSON-shaped objects, so
@@ -354,6 +419,8 @@ function diffConfig(before: Config, after: Config): ConfigReloadDiff {
354
419
  }
355
420
 
356
421
  function readPath(obj: unknown, path: string): unknown {
422
+ if (path === 'roles.match') return projectRoles(obj, 'match')
423
+ if (path === 'roles.permissions') return projectRoles(obj, 'permissions')
357
424
  let cur: unknown = obj
358
425
  for (const part of path.split('.')) {
359
426
  if (cur === null || cur === undefined) return undefined
@@ -362,6 +429,19 @@ function readPath(obj: unknown, path: string): unknown {
362
429
  return cur
363
430
  }
364
431
 
432
+ function projectRoles(obj: unknown, field: 'match' | 'permissions'): unknown {
433
+ if (typeof obj !== 'object' || obj === null) return undefined
434
+ const roles = (obj as Record<string, unknown>).roles
435
+ if (typeof roles !== 'object' || roles === null) return undefined
436
+ const projection: Record<string, unknown> = {}
437
+ for (const [roleName, roleVal] of Object.entries(roles as Record<string, unknown>)) {
438
+ if (typeof roleVal !== 'object' || roleVal === null) continue
439
+ const val = (roleVal as Record<string, unknown>)[field]
440
+ if (val !== undefined) projection[roleName] = val
441
+ }
442
+ return projection
443
+ }
444
+
365
445
  // Plugin configs live at the top level of typeclaw.json keyed by plugin name
366
446
  // (e.g. "standup-log": { ... }). They are preserved by configSchema.catchall(z.unknown())
367
447
  // because the schema does not predeclare these keys. This helper returns the
@@ -372,14 +452,17 @@ export function extractPluginConfigs(raw: unknown): Record<string, unknown> {
372
452
  const known = new Set([
373
453
  '$schema',
374
454
  'port',
375
- 'model',
455
+ 'models',
376
456
  'mounts',
377
457
  'plugins',
458
+ 'alias',
378
459
  'channels',
379
460
  'portForward',
380
461
  'network',
381
462
  'docker',
382
463
  'git',
464
+ 'roles',
465
+ 'permissions',
383
466
  ])
384
467
  const result: Record<string, unknown> = {}
385
468
  for (const [key, value] of Object.entries(raw as Record<string, unknown>)) {
@@ -402,6 +485,9 @@ export function loadPluginConfigsSync(cwd: string): Record<string, unknown> {
402
485
  return {}
403
486
  }
404
487
  const migrated = migrateLegacyConfigShape(json)
488
+ if (migrated.changed) {
489
+ persistMigratedConfig(cwd, migrated.json, migrated.applied)
490
+ }
405
491
  return extractPluginConfigs(migrated.json)
406
492
  }
407
493
 
@@ -423,7 +509,7 @@ export function loadConfigSync(cwd: string): Config {
423
509
 
424
510
  const migrated = migrateLegacyConfigShape(json)
425
511
  if (migrated.changed) {
426
- persistMigratedConfig(cwd, migrated.json)
512
+ persistMigratedConfig(cwd, migrated.json, migrated.applied)
427
513
  }
428
514
 
429
515
  const result = configSchema.safeParse(migrated.json)
@@ -447,18 +533,51 @@ export function loadConfigSync(cwd: string): Config {
447
533
  // Either way, the new shape is the source of truth — losing the legacy
448
534
  // duplicate is the right call because it would otherwise be shadowed at
449
535
  // parse time anyway (`configSchema` has no `dockerfile`/`gitignore` keys).
450
- export function migrateLegacyConfigShape(json: unknown): { json: unknown; changed: boolean } {
536
+ //
537
+ // The returned `applied` array names each migration step that fired, so
538
+ // callers in `typeclaw start` can build a meaningful git commit message
539
+ // instead of a generic "migrate legacy shape" subject. `changed` is the
540
+ // boolean equivalent of `applied.length > 0` and is preserved for back-compat
541
+ // with the many call sites that only care whether ANY rewrite happened.
542
+ export type MigrationStep =
543
+ | { kind: 'dockerfile-to-docker-file' }
544
+ | { kind: 'gitignore-to-git-ignore' }
545
+ | { kind: 'channels-allow-to-roles-member-match'; rules: string[]; dropped: string[] }
546
+ | { kind: 'strip-permissions-gate-channel-respond' }
547
+ | { kind: 'model-to-models'; ref: string }
548
+ | { kind: 'drop-stale-model'; ref: string }
549
+
550
+ export type MigrationResult = { json: unknown; changed: boolean; applied: MigrationStep[] }
551
+
552
+ export function migrateLegacyConfigShape(json: unknown): MigrationResult {
451
553
  if (typeof json !== 'object' || json === null || Array.isArray(json)) {
452
- return { json, changed: false }
554
+ return { json, changed: false, applied: [] }
453
555
  }
454
556
 
455
557
  const obj = json as Record<string, unknown>
456
558
  const hasLegacyDockerfile = 'dockerfile' in obj
457
559
  const hasLegacyGitignore = 'gitignore' in obj
458
- if (!hasLegacyDockerfile && !hasLegacyGitignore) {
459
- return { json, changed: false }
560
+ const channelsAllowMigration = collectChannelsAllowMigration(obj)
561
+ const hasLegacyGateChannelRespond = isPlainObject(obj.permissions) && 'gateChannelRespond' in obj.permissions
562
+ // The pre-multi-model schema had a top-level `model: KnownModelRef` and no
563
+ // `models` key. Detecting the legacy shape requires both: `model` present
564
+ // AND `models` absent. If both coexist (user hand-edited after auto-migrate
565
+ // but didn't delete the legacy key), `models` wins and `model` is dropped
566
+ // silently — same precedence rule as the dockerfile/gitignore migrations.
567
+ const hasLegacyModel = 'model' in obj && !('models' in obj) && typeof obj.model === 'string'
568
+ const hasStaleModelAlongsideModels = 'model' in obj && 'models' in obj
569
+ if (
570
+ !hasLegacyDockerfile &&
571
+ !hasLegacyGitignore &&
572
+ !channelsAllowMigration.found &&
573
+ !hasLegacyGateChannelRespond &&
574
+ !hasLegacyModel &&
575
+ !hasStaleModelAlongsideModels
576
+ ) {
577
+ return { json, changed: false, applied: [] }
460
578
  }
461
579
 
580
+ const applied: MigrationStep[] = []
462
581
  const next: Record<string, unknown> = { ...obj }
463
582
  if (hasLegacyDockerfile) {
464
583
  const legacy = next.dockerfile
@@ -468,6 +587,7 @@ export function migrateLegacyConfigShape(json: unknown): { json: unknown; change
468
587
  } else if (isPlainObject(next.docker) && !('file' in next.docker)) {
469
588
  next.docker = { ...next.docker, file: legacy }
470
589
  }
590
+ applied.push({ kind: 'dockerfile-to-docker-file' })
471
591
  }
472
592
  if (hasLegacyGitignore) {
473
593
  const legacy = next.gitignore
@@ -477,22 +597,275 @@ export function migrateLegacyConfigShape(json: unknown): { json: unknown; change
477
597
  } else if (isPlainObject(next.git) && !('ignore' in next.git)) {
478
598
  next.git = { ...next.git, ignore: legacy }
479
599
  }
600
+ applied.push({ kind: 'gitignore-to-git-ignore' })
601
+ }
602
+ if (channelsAllowMigration.found) {
603
+ applyChannelsAllowMigration(next, channelsAllowMigration)
604
+ applied.push({
605
+ kind: 'channels-allow-to-roles-member-match',
606
+ rules: channelsAllowMigration.rules,
607
+ dropped: channelsAllowMigration.warnings,
608
+ })
609
+ }
610
+ if (hasLegacyGateChannelRespond) {
611
+ const perms = { ...(next.permissions as Record<string, unknown>) }
612
+ delete perms.gateChannelRespond
613
+ if (Object.keys(perms).length === 0) {
614
+ delete next.permissions
615
+ } else {
616
+ next.permissions = perms
617
+ }
618
+ applied.push({ kind: 'strip-permissions-gate-channel-respond' })
619
+ }
620
+ if (hasLegacyModel) {
621
+ const ref = next.model as string
622
+ delete next.model
623
+ next.models = { default: ref }
624
+ applied.push({ kind: 'model-to-models', ref })
625
+ } else if (hasStaleModelAlongsideModels) {
626
+ // `models` wins (per the same precedence rule as dockerfile/gitignore), but
627
+ // the drop is still a tracked migration step so the disk rewrite gets a
628
+ // commit instead of silently dirtying the worktree. Without this, the
629
+ // file would be rewritten by persistMigratedConfig and no commit would
630
+ // fire (buildConfigMigrationCommitMessage returns null for empty applied
631
+ // lists), contradicting the invariant in persistMigratedConfig's comment.
632
+ const ref = typeof next.model === 'string' ? next.model : ''
633
+ delete next.model
634
+ applied.push({ kind: 'drop-stale-model', ref })
635
+ }
636
+ return { json: next, changed: true, applied }
637
+ }
638
+
639
+ // Builds a meaningful one-line git commit subject for a typeclaw.json
640
+ // migration. Single-step migrations get a specific subject; multi-step ones
641
+ // fall back to a stable summary subject with the count. The body (after the
642
+ // blank line) enumerates each step so `git log -p typeclaw.json` is an
643
+ // auditable trail of what legacy shapes the agent has graduated from.
644
+ //
645
+ // Returns null when no steps were applied — callers should not commit in
646
+ // that case. Keeping the null branch here (vs an empty string) makes the
647
+ // "nothing happened" case impossible to misuse at the call site.
648
+ export function buildConfigMigrationCommitMessage(applied: readonly MigrationStep[]): string | null {
649
+ const first = applied[0]
650
+ if (first === undefined) return null
651
+
652
+ const subject =
653
+ applied.length === 1
654
+ ? `typeclaw.json: ${shortStepLabel(first)}`
655
+ : `typeclaw.json: migrate legacy shape (${applied.length} steps)`
656
+
657
+ const bodyLines: string[] = applied.map((step) => `- ${describeStep(step)}`)
658
+
659
+ // Surface dropped rules in the commit body so a user inspecting `git log -p`
660
+ // sees exactly which legacy entries had to be hand-re-added (the lossy
661
+ // `channel:<id>` case). Without this, the silent-drop is invisible after
662
+ // the fact.
663
+ for (const step of applied) {
664
+ if (step.kind === 'channels-allow-to-roles-member-match' && step.dropped.length > 0) {
665
+ for (const warning of step.dropped) {
666
+ bodyLines.push(` warning: ${warning}`)
667
+ }
668
+ }
669
+ }
670
+
671
+ return `${subject}\n\n${bodyLines.join('\n')}\n`
672
+ }
673
+
674
+ function shortStepLabel(step: MigrationStep): string {
675
+ switch (step.kind) {
676
+ case 'dockerfile-to-docker-file':
677
+ return 'lift dockerfile → docker.file'
678
+ case 'gitignore-to-git-ignore':
679
+ return 'lift gitignore → git.ignore'
680
+ case 'channels-allow-to-roles-member-match':
681
+ return 'lift channels.<adapter>.allow[] → roles.member.match[]'
682
+ case 'strip-permissions-gate-channel-respond':
683
+ return 'drop permissions.gateChannelRespond'
684
+ case 'model-to-models':
685
+ return 'lift model → models.default'
686
+ case 'drop-stale-model':
687
+ return 'drop stale legacy model alongside models'
480
688
  }
481
- return { json: next, changed: true }
689
+ }
690
+
691
+ function describeStep(step: MigrationStep): string {
692
+ switch (step.kind) {
693
+ case 'dockerfile-to-docker-file':
694
+ return 'lift top-level dockerfile into docker.file'
695
+ case 'gitignore-to-git-ignore':
696
+ return 'lift top-level gitignore into git.ignore'
697
+ case 'channels-allow-to-roles-member-match': {
698
+ if (step.rules.length === 0) {
699
+ return 'strip channels.<adapter>.allow[] (no translatable rules)'
700
+ }
701
+ return `lift channels.<adapter>.allow[] → roles.member.match[]: ${step.rules.join(', ')}`
702
+ }
703
+ case 'strip-permissions-gate-channel-respond':
704
+ return 'drop permissions.gateChannelRespond (removed key)'
705
+ case 'model-to-models':
706
+ return `lift top-level model into models.default: ${step.ref}`
707
+ case 'drop-stale-model':
708
+ return step.ref !== ''
709
+ ? `drop stale top-level model (${step.ref}) — models block takes precedence`
710
+ : 'drop stale top-level model — models block takes precedence'
711
+ }
712
+ }
713
+
714
+ // Channels.<adapter>.allow[] → roles.member.match[] migration.
715
+ //
716
+ // Phase 3 removes the per-adapter allow-list and unifies wake-up gating
717
+ // through `roles.member.match[]` + the `channel.respond` permission. This
718
+ // helper translates legacy `allow` entries into canonical match-rule DSL
719
+ // strings and appends them (deduplicated, preserving declaration order)
720
+ // to `roles.member.match[]`. The `allow` field is then stripped from each
721
+ // adapter block; the block survives — only the field is gone.
722
+ //
723
+ // `channel:<id>` rules cannot round-trip (the DSL forbids
724
+ // wildcard-workspace + specific-chat) and are dropped with a warning. All
725
+ // other shapes translate losslessly per the table in match-rule.ts.
726
+ type ChannelsAllowMigration = {
727
+ found: boolean
728
+ rules: string[]
729
+ warnings: string[]
730
+ }
731
+
732
+ function collectChannelsAllowMigration(obj: Record<string, unknown>): ChannelsAllowMigration {
733
+ const out: ChannelsAllowMigration = { found: false, rules: [], warnings: [] }
734
+ const channels = obj.channels
735
+ if (!isPlainObject(channels)) return out
736
+ for (const [adapter, value] of Object.entries(channels)) {
737
+ if (!isPlainObject(value)) continue
738
+ if (!('allow' in value)) continue
739
+ out.found = true
740
+ const allow = value.allow
741
+ if (!Array.isArray(allow)) continue
742
+ for (const entry of allow) {
743
+ if (typeof entry !== 'string') continue
744
+ const translated = translateLegacyAllowRule(entry)
745
+ if (translated.kind === 'rule') {
746
+ out.rules.push(translated.value)
747
+ } else {
748
+ out.warnings.push(`channels.${adapter}.allow[]: dropped '${entry}' (${translated.reason})`)
749
+ }
750
+ }
751
+ }
752
+ return out
753
+ }
754
+
755
+ function applyChannelsAllowMigration(next: Record<string, unknown>, migration: ChannelsAllowMigration): void {
756
+ const channels = next.channels
757
+ if (isPlainObject(channels)) {
758
+ const updated: Record<string, unknown> = {}
759
+ for (const [adapter, value] of Object.entries(channels)) {
760
+ if (isPlainObject(value) && 'allow' in value) {
761
+ const { allow: _allow, ...rest } = value
762
+ updated[adapter] = rest
763
+ } else {
764
+ updated[adapter] = value
765
+ }
766
+ }
767
+ next.channels = updated
768
+ }
769
+
770
+ if (migration.rules.length === 0) {
771
+ for (const warning of migration.warnings) {
772
+ console.warn(`[config] ${warning}`)
773
+ }
774
+ return
775
+ }
776
+
777
+ const roles = isPlainObject(next.roles) ? { ...next.roles } : {}
778
+ const member = isPlainObject(roles.member) ? { ...roles.member } : {}
779
+ const existingMatch = Array.isArray(member.match)
780
+ ? (member.match as unknown[]).filter((m) => typeof m === 'string')
781
+ : []
782
+ const seen = new Set<string>(existingMatch as string[])
783
+ const merged = [...(existingMatch as string[])]
784
+ for (const rule of migration.rules) {
785
+ if (!seen.has(rule)) {
786
+ seen.add(rule)
787
+ merged.push(rule)
788
+ }
789
+ }
790
+ member.match = merged
791
+ roles.member = member
792
+ next.roles = roles
793
+
794
+ console.warn(`[config] migrated channels.<adapter>.allow[] -> roles.member.match[]: ${migration.rules.join(', ')}`)
795
+ for (const warning of migration.warnings) {
796
+ console.warn(`[config] ${warning}`)
797
+ }
798
+ }
799
+
800
+ type TranslatedRule = { kind: 'rule'; value: string } | { kind: 'drop'; reason: string }
801
+
802
+ function translateLegacyAllowRule(rule: string): TranslatedRule {
803
+ // Already canonical / cross-platform.
804
+ if (rule === '*') return { kind: 'rule', value: '*' }
805
+ if (rule.startsWith('kakao:')) return { kind: 'rule', value: rule }
806
+
807
+ // Discord: guild → discord, dm → discord:dm.
808
+ if (rule === 'guild:*') return { kind: 'rule', value: 'discord:*' }
809
+ if (rule.startsWith('guild:')) return { kind: 'rule', value: `discord:${rule.slice('guild:'.length)}` }
810
+ if (rule === 'dm:*') return { kind: 'rule', value: 'discord:dm/*' }
811
+ if (rule.startsWith('dm:')) return { kind: 'rule', value: `discord:dm/${rule.slice('dm:'.length)}` }
812
+
813
+ // Slack: team → slack, im → slack:dm.
814
+ if (rule === 'team:*') return { kind: 'rule', value: 'slack:*' }
815
+ if (rule.startsWith('team:')) return { kind: 'rule', value: `slack:${rule.slice('team:'.length)}` }
816
+ if (rule === 'im:*') return { kind: 'rule', value: 'slack:dm/*' }
817
+ if (rule.startsWith('im:')) return { kind: 'rule', value: `slack:dm/${rule.slice('im:'.length)}` }
818
+
819
+ // Telegram: tg → telegram.
820
+ if (rule === 'tg:*') return { kind: 'rule', value: 'telegram:*' }
821
+ if (rule.startsWith('tg:')) return { kind: 'rule', value: `telegram:${rule.slice('tg:'.length)}` }
822
+
823
+ // `channel:<id>` had no workspace; canonical DSL rejects wildcard
824
+ // workspace + specific chat. Drop with a warning so the operator knows
825
+ // to re-add the rule explicitly with a workspace coordinate.
826
+ if (rule.startsWith('channel:')) {
827
+ return {
828
+ kind: 'drop',
829
+ reason:
830
+ 'channel:<id> rules require an explicit workspace under the new DSL; re-add as discord:<guild>/<id> or slack:<team>/<id>',
831
+ }
832
+ }
833
+
834
+ return { kind: 'drop', reason: `unrecognized legacy allow shape '${rule}'` }
482
835
  }
483
836
 
484
837
  function isPlainObject(value: unknown): value is Record<string, unknown> {
485
838
  return typeof value === 'object' && value !== null && !Array.isArray(value)
486
839
  }
487
840
 
488
- function persistMigratedConfig(cwd: string, json: unknown): void {
841
+ function persistMigratedConfig(cwd: string, json: unknown, applied: readonly MigrationStep[]): void {
489
842
  try {
490
843
  writeFileSync(join(cwd, CONFIG_FILE), `${JSON.stringify(json, null, 2)}\n`)
491
844
  } catch {
492
845
  // Best-effort write-back: the migration is also applied in-memory on every
493
846
  // load, so a read-only filesystem (e.g. snapshotted CI checkout) just
494
847
  // means the rewrite retries next start. Surfacing the error would brick
495
- // load paths the user didn't ask to mutate.
848
+ // load paths the user didn't ask to mutate. Bail before the commit step
849
+ // too — without the write there's nothing to commit.
850
+ return
851
+ }
852
+
853
+ // Pair the disk rewrite with a git commit so the agent folder is never
854
+ // silently dirty after a legacy-shape migration. typeclaw.json is in
855
+ // git's "tracked" category (unlike Dockerfile, which is regenerated on
856
+ // every start and intentionally gitignored), so an uncommitted rewrite
857
+ // gets mixed into unrelated commits the moment any other tool touches
858
+ // the repo. commitSystemFileSync no-ops on non-git folders, missing
859
+ // Bun, and clean files, so canonical-shape reads pay zero cost.
860
+ //
861
+ // Called from every entry point that reads typeclaw.json (host CLI,
862
+ // hostd daemon, container runtime) so the commit follows the rewrite
863
+ // wherever it happens — not only from `typeclaw start`. The earlier
864
+ // design that committed only in start() missed the long-running hostd
865
+ // daemon, doctor, tui, reload, and compose paths.
866
+ const message = buildConfigMigrationCommitMessage(applied)
867
+ if (message !== null) {
868
+ commitSystemFileSync(cwd, CONFIG_FILE, message)
496
869
  }
497
870
  }
498
871
 
@@ -526,7 +899,7 @@ export function validateConfig(cwd: string): ValidateConfigResult {
526
899
 
527
900
  const migrated = migrateLegacyConfigShape(json)
528
901
  if (migrated.changed) {
529
- persistMigratedConfig(cwd, migrated.json)
902
+ persistMigratedConfig(cwd, migrated.json, migrated.applied)
530
903
  }
531
904
 
532
905
  const result = configSchema.safeParse(migrated.json)
@@ -1,4 +1,5 @@
1
1
  export {
2
+ buildConfigMigrationCommitMessage,
2
3
  config,
3
4
  configSchema,
4
5
  dockerSchema,
@@ -11,10 +12,12 @@ export {
11
12
  loadConfigSync,
12
13
  loadPluginConfigsSync,
13
14
  migrateLegacyConfigShape,
15
+ modelsSchema,
14
16
  mountSchema,
15
17
  portForwardSchema,
16
18
  reloadConfig,
17
19
  resolveModel,
20
+ resolveProfile,
18
21
  validateConfig,
19
22
  validateMount,
20
23
  type Config,
@@ -24,8 +27,12 @@ export {
24
27
  type DockerfileConfig,
25
28
  type GitConfig,
26
29
  type GitignoreConfig,
30
+ type MigrationResult,
31
+ type MigrationStep,
32
+ type Models,
27
33
  type Mount,
28
34
  type PortForward,
35
+ type ResolvedProfile,
29
36
  type ValidateConfigResult,
30
37
  } from './config'
31
38
  export { type KnownModelRef, type KnownProviderId } from './providers'