typeclaw 0.27.0 → 0.28.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 (42) hide show
  1. package/package.json +1 -1
  2. package/scripts/generate-schema.ts +4 -6
  3. package/src/agent/index.ts +26 -4
  4. package/src/agent/multimodal/look-at.ts +1 -2
  5. package/src/agent/tools/channel-fetch-attachment.ts +1 -2
  6. package/src/agent/tools/channel-react.ts +9 -3
  7. package/src/agent/tools/channel-reply.ts +30 -1
  8. package/src/agent/tools/channel-send.ts +94 -1
  9. package/src/bundled-plugins/github-cli-auth/gh-review-detect.ts +175 -0
  10. package/src/bundled-plugins/github-cli-auth/index.ts +4 -0
  11. package/src/bundled-plugins/github-cli-auth/review-recorder.ts +93 -0
  12. package/src/bundled-plugins/guard/policies/managed-config.ts +1 -1
  13. package/src/bundled-plugins/memory/README.md +3 -21
  14. package/src/bundled-plugins/memory/index.ts +1 -149
  15. package/src/bundled-plugins/security/policies/cron-promotion.ts +2 -2
  16. package/src/channels/adapters/github/inbound.ts +103 -0
  17. package/src/channels/adapters/github/review-thread-resolver.ts +65 -5
  18. package/src/channels/github-false-receipt.ts +87 -0
  19. package/src/channels/github-review-claim.ts +91 -0
  20. package/src/channels/github-review-turn-ledger.ts +71 -0
  21. package/src/channels/persistence.ts +4 -102
  22. package/src/channels/router.ts +2 -0
  23. package/src/channels/schema.ts +20 -5
  24. package/src/cli/channel.ts +2 -1
  25. package/src/cli/init.ts +2 -1
  26. package/src/config/config.ts +19 -288
  27. package/src/container/start.ts +0 -2
  28. package/src/cron/index.ts +3 -44
  29. package/src/cron/schema.ts +2 -96
  30. package/src/init/gitignore.ts +1 -2
  31. package/src/secrets/defaults.ts +1 -18
  32. package/src/secrets/index.ts +0 -2
  33. package/src/secrets/schema.ts +4 -90
  34. package/src/secrets/storage.ts +0 -2
  35. package/src/server/index.ts +0 -4
  36. package/src/skills/typeclaw-config/SKILL.md +9 -11
  37. package/src/skills/typeclaw-permissions/SKILL.md +1 -1
  38. package/typeclaw.schema.json +1 -0
  39. package/src/agent/tools/normalize-ref.ts +0 -11
  40. package/src/bundled-plugins/memory/migration.ts +0 -633
  41. package/src/secrets/migrate-kakaotalk.ts +0 -82
  42. package/src/secrets/migrate.ts +0 -96
@@ -249,8 +249,7 @@ export const gitignoreSchema = gitignoreObjectSchema.default(() => gitignoreObje
249
249
  export type GitignoreConfig = z.infer<typeof gitignoreSchema>
250
250
 
251
251
  // Same rationale as `dockerSchema`: a `git` namespace today carries `git.ignore`
252
- // and leaves room for future siblings (e.g. `git.attributes`). The one-time
253
- // migration also handles the rename of legacy top-level `gitignore`.
252
+ // and leaves room for future siblings (e.g. `git.attributes`).
254
253
  export const gitSchema = z
255
254
  .object({
256
255
  ignore: gitignoreSchema,
@@ -783,34 +782,17 @@ export function loadConfigSync(cwd: string): Config {
783
782
  return result.data
784
783
  }
785
784
 
786
- // One-shot rename of legacy top-level `dockerfile` / `gitignore` keys into the
787
- // nested `docker.file` / `git.ignore` shape introduced for namespace
788
- // extensibility (`docker.compose`, `git.attributes`, etc. land here later
789
- // without a second migration). Called from every entry point that reads
790
- // `typeclaw.json` so the rest of the pipeline only ever sees the new shape.
791
- //
792
- // Precedence when both legacy and new keys coexist: the new shape wins and
793
- // the legacy key is dropped silently. Two ways this happens in practice:
794
- // 1. User hand-edited the new shape after auto-migration but forgot to
795
- // delete the legacy key.
796
- // 2. Two `typeclaw start` invocations raced on a stale checkout.
797
- // Either way, the new shape is the source of truth — losing the legacy
798
- // duplicate is the right call because it would otherwise be shadowed at
799
- // parse time anyway (`configSchema` has no `dockerfile`/`gitignore` keys).
785
+ // Strips a `channels.github.eventAllowlist` that deep-equals a value `channel
786
+ // add` / `init` previously seeded verbatim, so the config re-tracks the shipped
787
+ // default. Called from every entry point that reads `typeclaw.json` so the rest
788
+ // of the pipeline only ever sees the canonical shape.
800
789
  //
801
790
  // The returned `applied` array names each migration step that fired, so
802
- // callers in `typeclaw start` can build a meaningful git commit message
803
- // instead of a generic "migrate legacy shape" subject. `changed` is the
804
- // boolean equivalent of `applied.length > 0` and is preserved for back-compat
805
- // with the many call sites that only care whether ANY rewrite happened.
806
- export type MigrationStep =
807
- | { kind: 'dockerfile-to-docker-file' }
808
- | { kind: 'gitignore-to-git-ignore' }
809
- | { kind: 'channels-allow-to-roles-member-match'; rules: string[]; dropped: string[] }
810
- | { kind: 'strip-permissions-gate-channel-respond' }
811
- | { kind: 'model-to-models'; ref: string }
812
- | { kind: 'drop-stale-model'; ref: string }
813
- | { kind: 'drop-github-seeded-event-allowlist' }
791
+ // callers in `typeclaw start` can build a meaningful git commit message.
792
+ // `changed` is the boolean equivalent of `applied.length > 0` and is preserved
793
+ // for back-compat with the many call sites that only care whether ANY rewrite
794
+ // happened.
795
+ export type MigrationStep = { kind: 'drop-github-seeded-event-allowlist' }
814
796
 
815
797
  export type MigrationResult = { json: unknown; changed: boolean; applied: MigrationStep[] }
816
798
 
@@ -820,86 +802,13 @@ export function migrateLegacyConfigShape(json: unknown): MigrationResult {
820
802
  }
821
803
 
822
804
  const obj = json as Record<string, unknown>
823
- const hasLegacyDockerfile = 'dockerfile' in obj
824
- const hasLegacyGitignore = 'gitignore' in obj
825
- const channelsAllowMigration = collectChannelsAllowMigration(obj)
826
- const hasLegacyGateChannelRespond = isPlainObject(obj.permissions) && 'gateChannelRespond' in obj.permissions
827
- // The pre-multi-model schema had a top-level `model: KnownModelRef` and no
828
- // `models` key. Detecting the legacy shape requires both: `model` present
829
- // AND `models` absent. If both coexist (user hand-edited after auto-migrate
830
- // but didn't delete the legacy key), `models` wins and `model` is dropped
831
- // silently — same precedence rule as the dockerfile/gitignore migrations.
832
- const hasLegacyModel = 'model' in obj && !('models' in obj) && typeof obj.model === 'string'
833
- const hasStaleModelAlongsideModels = 'model' in obj && 'models' in obj
834
805
  const hasSeededGithubEventAllowlist = isSeededGithubEventAllowlist(obj)
835
- if (
836
- !hasLegacyDockerfile &&
837
- !hasLegacyGitignore &&
838
- !channelsAllowMigration.found &&
839
- !hasLegacyGateChannelRespond &&
840
- !hasLegacyModel &&
841
- !hasStaleModelAlongsideModels &&
842
- !hasSeededGithubEventAllowlist
843
- ) {
806
+ if (!hasSeededGithubEventAllowlist) {
844
807
  return { json, changed: false, applied: [] }
845
808
  }
846
809
 
847
810
  const applied: MigrationStep[] = []
848
811
  const next: Record<string, unknown> = { ...obj }
849
- if (hasLegacyDockerfile) {
850
- const legacy = next.dockerfile
851
- delete next.dockerfile
852
- if (!('docker' in next)) {
853
- next.docker = { file: legacy }
854
- } else if (isPlainObject(next.docker) && !('file' in next.docker)) {
855
- next.docker = { ...next.docker, file: legacy }
856
- }
857
- applied.push({ kind: 'dockerfile-to-docker-file' })
858
- }
859
- if (hasLegacyGitignore) {
860
- const legacy = next.gitignore
861
- delete next.gitignore
862
- if (!('git' in next)) {
863
- next.git = { ignore: legacy }
864
- } else if (isPlainObject(next.git) && !('ignore' in next.git)) {
865
- next.git = { ...next.git, ignore: legacy }
866
- }
867
- applied.push({ kind: 'gitignore-to-git-ignore' })
868
- }
869
- if (channelsAllowMigration.found) {
870
- applyChannelsAllowMigration(next, channelsAllowMigration)
871
- applied.push({
872
- kind: 'channels-allow-to-roles-member-match',
873
- rules: channelsAllowMigration.rules,
874
- dropped: channelsAllowMigration.warnings,
875
- })
876
- }
877
- if (hasLegacyGateChannelRespond) {
878
- const perms = { ...(next.permissions as Record<string, unknown>) }
879
- delete perms.gateChannelRespond
880
- if (Object.keys(perms).length === 0) {
881
- delete next.permissions
882
- } else {
883
- next.permissions = perms
884
- }
885
- applied.push({ kind: 'strip-permissions-gate-channel-respond' })
886
- }
887
- if (hasLegacyModel) {
888
- const ref = next.model as string
889
- delete next.model
890
- next.models = { default: ref }
891
- applied.push({ kind: 'model-to-models', ref })
892
- } else if (hasStaleModelAlongsideModels) {
893
- // `models` wins (per the same precedence rule as dockerfile/gitignore), but
894
- // the drop is still a tracked migration step so the disk rewrite gets a
895
- // commit instead of silently dirtying the worktree. Without this, the
896
- // file would be rewritten by persistMigratedConfig and no commit would
897
- // fire (buildConfigMigrationCommitMessage returns null for empty applied
898
- // lists), contradicting the invariant in persistMigratedConfig's comment.
899
- const ref = typeof next.model === 'string' ? next.model : ''
900
- delete next.model
901
- applied.push({ kind: 'drop-stale-model', ref })
902
- }
903
812
  if (hasSeededGithubEventAllowlist) {
904
813
  dropSeededGithubEventAllowlist(next)
905
814
  applied.push({ kind: 'drop-github-seeded-event-allowlist' })
@@ -937,12 +846,6 @@ function arraysEqual(a: readonly unknown[], b: readonly unknown[]): boolean {
937
846
  return true
938
847
  }
939
848
 
940
- // Builds a meaningful one-line git commit subject for a typeclaw.json
941
- // migration. Single-step migrations get a specific subject; multi-step ones
942
- // fall back to a stable summary subject with the count. The body (after the
943
- // blank line) enumerates each step so `git log -p typeclaw.json` is an
944
- // auditable trail of what legacy shapes the agent has graduated from.
945
- //
946
849
  // Returns null when no steps were applied — callers should not commit in
947
850
  // that case. Keeping the null branch here (vs an empty string) makes the
948
851
  // "nothing happened" case impossible to misuse at the call site.
@@ -950,42 +853,13 @@ export function buildConfigMigrationCommitMessage(applied: readonly MigrationSte
950
853
  const first = applied[0]
951
854
  if (first === undefined) return null
952
855
 
953
- const subject =
954
- applied.length === 1
955
- ? `typeclaw.json: ${shortStepLabel(first)}`
956
- : `typeclaw.json: migrate legacy shape (${applied.length} steps)`
957
-
856
+ const subject = `typeclaw.json: ${shortStepLabel(first)}`
958
857
  const bodyLines: string[] = applied.map((step) => `- ${describeStep(step)}`)
959
-
960
- // Surface dropped rules in the commit body so a user inspecting `git log -p`
961
- // sees exactly which legacy entries had to be hand-re-added (the lossy
962
- // `channel:<id>` case). Without this, the silent-drop is invisible after
963
- // the fact.
964
- for (const step of applied) {
965
- if (step.kind === 'channels-allow-to-roles-member-match' && step.dropped.length > 0) {
966
- for (const warning of step.dropped) {
967
- bodyLines.push(` warning: ${warning}`)
968
- }
969
- }
970
- }
971
-
972
858
  return `${subject}\n\n${bodyLines.join('\n')}\n`
973
859
  }
974
860
 
975
861
  function shortStepLabel(step: MigrationStep): string {
976
862
  switch (step.kind) {
977
- case 'dockerfile-to-docker-file':
978
- return 'lift dockerfile → docker.file'
979
- case 'gitignore-to-git-ignore':
980
- return 'lift gitignore → git.ignore'
981
- case 'channels-allow-to-roles-member-match':
982
- return 'lift channels.<adapter>.allow[] → roles.member.match[]'
983
- case 'strip-permissions-gate-channel-respond':
984
- return 'drop permissions.gateChannelRespond'
985
- case 'model-to-models':
986
- return 'lift model → models.default'
987
- case 'drop-stale-model':
988
- return 'drop stale legacy model alongside models'
989
863
  case 'drop-github-seeded-event-allowlist':
990
864
  return 'drop seeded channels.github.eventAllowlist'
991
865
  }
@@ -993,152 +867,11 @@ function shortStepLabel(step: MigrationStep): string {
993
867
 
994
868
  function describeStep(step: MigrationStep): string {
995
869
  switch (step.kind) {
996
- case 'dockerfile-to-docker-file':
997
- return 'lift top-level dockerfile into docker.file'
998
- case 'gitignore-to-git-ignore':
999
- return 'lift top-level gitignore into git.ignore'
1000
- case 'channels-allow-to-roles-member-match': {
1001
- if (step.rules.length === 0) {
1002
- return 'strip channels.<adapter>.allow[] (no translatable rules)'
1003
- }
1004
- return `lift channels.<adapter>.allow[] → roles.member.match[]: ${step.rules.join(', ')}`
1005
- }
1006
- case 'strip-permissions-gate-channel-respond':
1007
- return 'drop permissions.gateChannelRespond (removed key)'
1008
- case 'model-to-models':
1009
- return `lift top-level model into models.default: ${step.ref}`
1010
- case 'drop-stale-model':
1011
- return step.ref !== ''
1012
- ? `drop stale top-level model (${step.ref}) — models block takes precedence`
1013
- : 'drop stale top-level model — models block takes precedence'
1014
870
  case 'drop-github-seeded-event-allowlist':
1015
871
  return 'drop seeded channels.github.eventAllowlist so it re-tracks the shipped default'
1016
872
  }
1017
873
  }
1018
874
 
1019
- // Channels.<adapter>.allow[] → roles.member.match[] migration.
1020
- //
1021
- // Phase 3 removes the per-adapter allow-list and unifies wake-up gating
1022
- // through `roles.member.match[]` + the `channel.respond` permission. This
1023
- // helper translates legacy `allow` entries into canonical match-rule DSL
1024
- // strings and appends them (deduplicated, preserving declaration order)
1025
- // to `roles.member.match[]`. The `allow` field is then stripped from each
1026
- // adapter block; the block survives — only the field is gone.
1027
- //
1028
- // `channel:<id>` rules cannot round-trip (the DSL forbids
1029
- // wildcard-workspace + specific-chat) and are dropped with a warning. All
1030
- // other shapes translate losslessly per the table in match-rule.ts.
1031
- type ChannelsAllowMigration = {
1032
- found: boolean
1033
- rules: string[]
1034
- warnings: string[]
1035
- }
1036
-
1037
- function collectChannelsAllowMigration(obj: Record<string, unknown>): ChannelsAllowMigration {
1038
- const out: ChannelsAllowMigration = { found: false, rules: [], warnings: [] }
1039
- const channels = obj.channels
1040
- if (!isPlainObject(channels)) return out
1041
- for (const [adapter, value] of Object.entries(channels)) {
1042
- if (!isPlainObject(value)) continue
1043
- if (!('allow' in value)) continue
1044
- out.found = true
1045
- const allow = value.allow
1046
- if (!Array.isArray(allow)) continue
1047
- for (const entry of allow) {
1048
- if (typeof entry !== 'string') continue
1049
- const translated = translateLegacyAllowRule(entry)
1050
- if (translated.kind === 'rule') {
1051
- out.rules.push(translated.value)
1052
- } else {
1053
- out.warnings.push(`channels.${adapter}.allow[]: dropped '${entry}' (${translated.reason})`)
1054
- }
1055
- }
1056
- }
1057
- return out
1058
- }
1059
-
1060
- function applyChannelsAllowMigration(next: Record<string, unknown>, migration: ChannelsAllowMigration): void {
1061
- const channels = next.channels
1062
- if (isPlainObject(channels)) {
1063
- const updated: Record<string, unknown> = {}
1064
- for (const [adapter, value] of Object.entries(channels)) {
1065
- if (isPlainObject(value) && 'allow' in value) {
1066
- const { allow: _allow, ...rest } = value
1067
- updated[adapter] = rest
1068
- } else {
1069
- updated[adapter] = value
1070
- }
1071
- }
1072
- next.channels = updated
1073
- }
1074
-
1075
- if (migration.rules.length === 0) {
1076
- for (const warning of migration.warnings) {
1077
- console.warn(`[config] ${warning}`)
1078
- }
1079
- return
1080
- }
1081
-
1082
- const roles = isPlainObject(next.roles) ? { ...next.roles } : {}
1083
- const member = isPlainObject(roles.member) ? { ...roles.member } : {}
1084
- const existingMatch = Array.isArray(member.match)
1085
- ? (member.match as unknown[]).filter((m) => typeof m === 'string')
1086
- : []
1087
- const seen = new Set<string>(existingMatch as string[])
1088
- const merged = [...(existingMatch as string[])]
1089
- for (const rule of migration.rules) {
1090
- if (!seen.has(rule)) {
1091
- seen.add(rule)
1092
- merged.push(rule)
1093
- }
1094
- }
1095
- member.match = merged
1096
- roles.member = member
1097
- next.roles = roles
1098
-
1099
- console.warn(`[config] migrated channels.<adapter>.allow[] -> roles.member.match[]: ${migration.rules.join(', ')}`)
1100
- for (const warning of migration.warnings) {
1101
- console.warn(`[config] ${warning}`)
1102
- }
1103
- }
1104
-
1105
- type TranslatedRule = { kind: 'rule'; value: string } | { kind: 'drop'; reason: string }
1106
-
1107
- function translateLegacyAllowRule(rule: string): TranslatedRule {
1108
- // Already canonical / cross-platform.
1109
- if (rule === '*') return { kind: 'rule', value: '*' }
1110
- if (rule.startsWith('kakao:')) return { kind: 'rule', value: rule }
1111
-
1112
- // Discord: guild → discord, dm → discord:dm.
1113
- if (rule === 'guild:*') return { kind: 'rule', value: 'discord:*' }
1114
- if (rule.startsWith('guild:')) return { kind: 'rule', value: `discord:${rule.slice('guild:'.length)}` }
1115
- if (rule === 'dm:*') return { kind: 'rule', value: 'discord:dm/*' }
1116
- if (rule.startsWith('dm:')) return { kind: 'rule', value: `discord:dm/${rule.slice('dm:'.length)}` }
1117
-
1118
- // Slack: team → slack, im → slack:dm.
1119
- if (rule === 'team:*') return { kind: 'rule', value: 'slack:*' }
1120
- if (rule.startsWith('team:')) return { kind: 'rule', value: `slack:${rule.slice('team:'.length)}` }
1121
- if (rule === 'im:*') return { kind: 'rule', value: 'slack:dm/*' }
1122
- if (rule.startsWith('im:')) return { kind: 'rule', value: `slack:dm/${rule.slice('im:'.length)}` }
1123
-
1124
- // Telegram: tg → telegram.
1125
- if (rule === 'tg:*') return { kind: 'rule', value: 'telegram:*' }
1126
- if (rule.startsWith('tg:')) return { kind: 'rule', value: `telegram:${rule.slice('tg:'.length)}` }
1127
-
1128
- // `channel:<id>` had no workspace; canonical DSL rejects wildcard
1129
- // workspace + specific chat. Drop with a warning so the operator knows
1130
- // to re-add the rule explicitly with a workspace coordinate.
1131
- if (rule.startsWith('channel:')) {
1132
- return {
1133
- kind: 'drop',
1134
- reason:
1135
- 'channel:<id> rules require an explicit workspace under the new DSL; re-add as discord:<guild>/<id> or slack:<team>/<id>',
1136
- }
1137
- }
1138
-
1139
- return { kind: 'drop', reason: `unrecognized legacy allow shape '${rule}'` }
1140
- }
1141
-
1142
875
  function isPlainObject(value: unknown): value is Record<string, unknown> {
1143
876
  return typeof value === 'object' && value !== null && !Array.isArray(value)
1144
877
  }
@@ -1156,18 +889,16 @@ function persistMigratedConfig(cwd: string, json: unknown, applied: readonly Mig
1156
889
  }
1157
890
 
1158
891
  // Pair the disk rewrite with a git commit so the agent folder is never
1159
- // silently dirty after a legacy-shape migration. typeclaw.json is in
1160
- // git's "tracked" category (unlike Dockerfile, which is regenerated on
1161
- // every start and intentionally gitignored), so an uncommitted rewrite
1162
- // gets mixed into unrelated commits the moment any other tool touches
1163
- // the repo. commitSystemFileSync no-ops on non-git folders, missing
1164
- // Bun, and clean files, so canonical-shape reads pay zero cost.
892
+ // silently dirty after a migration. typeclaw.json is in git's "tracked"
893
+ // category (unlike Dockerfile, which is regenerated on every start and
894
+ // intentionally gitignored), so an uncommitted rewrite gets mixed into
895
+ // unrelated commits the moment any other tool touches the repo.
896
+ // commitSystemFileSync no-ops on non-git folders, missing Bun, and clean
897
+ // files, so canonical-shape reads pay zero cost.
1165
898
  //
1166
899
  // Called from every entry point that reads typeclaw.json (host CLI,
1167
900
  // hostd daemon, container runtime) so the commit follows the rewrite
1168
- // wherever it happens — not only from `typeclaw start`. The earlier
1169
- // design that committed only in start() missed the long-running hostd
1170
- // daemon, doctor, tui, reload, and compose paths.
901
+ // wherever it happens — not only from `typeclaw start`.
1171
902
  const message = buildConfigMigrationCommitMessage(applied)
1172
903
  if (message !== null) {
1173
904
  commitSystemFileSync(cwd, CONFIG_FILE, message)
@@ -22,7 +22,6 @@ import { ensureDepsInstalled, type EnsureDepsResult } from '@/init/ensure-deps'
22
22
  import { buildGitignore, GITIGNORE_FILE } from '@/init/gitignore'
23
23
  import { refreshPackageJson } from '@/init/packagejson'
24
24
  import { runBunUpdate, type UpdateRunner } from '@/init/run-bun-install'
25
- import { migrateKakaotalkCredentials } from '@/secrets'
26
25
 
27
26
  import { CONTAINER_PORT, TUI_TOKEN_LABEL, findFreePort, isPortAllocatedError, resolveTuiToken } from './port'
28
27
  import {
@@ -258,7 +257,6 @@ export async function start({
258
257
  // ensures the base image's CLI version matches the runtime the
259
258
  // container will actually load.
260
259
  const dockerfileRefresh = await refreshDockerfile(cwd)
261
- await migrateKakaotalkCredentials(cwd)
262
260
 
263
261
  if (state.exists) {
264
262
  // Container holds the name but is not running. Without `--rm`, this is
package/src/cron/index.ts CHANGED
@@ -1,17 +1,10 @@
1
1
  import { existsSync } from 'node:fs'
2
- import { readFile, writeFile } from 'node:fs/promises'
2
+ import { readFile } 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'
7
6
 
8
- import {
9
- buildCronMigrationCommitMessage,
10
- type CronFile,
11
- type CronMigrationStep,
12
- migrateLegacyCronShape,
13
- parseCronFile,
14
- } from './schema'
7
+ import { type CronFile, parseCronFile } from './schema'
15
8
 
16
9
  export { createCronReloadable, type CreateCronReloadableOptions } from './reloadable'
17
10
  export {
@@ -31,16 +24,12 @@ export {
31
24
  } from './scheduler'
32
25
  export { aggregateCronList, type CronListEntry, type CronListSource } from './list'
33
26
  export {
34
- buildCronMigrationCommitMessage,
35
27
  cronFileSchema,
36
28
  cronJobSchema,
37
29
  type CronFile,
38
30
  type CronJob,
39
- type CronMigrationResult,
40
- type CronMigrationStep,
41
31
  type ExecJob,
42
32
  type HandlerJob,
43
- migrateLegacyCronShape,
44
33
  parseCronJson,
45
34
  type ParseCronJsonOptions,
46
35
  type ParseCronResult,
@@ -54,12 +43,6 @@ export type LoadCronResult = { ok: true; file: CronFile | null } | { ok: false;
54
43
 
55
44
  export type LoadCronOptions = {
56
45
  subagents?: SubagentRegistry
57
- // When true (the default), legacy-shape migrations are written back
58
- // to cron.json on disk and committed by the system-commit helper.
59
- // Read-only inspection callers must pass `false` so an unaware
60
- // `typeclaw cron list` against a legacy file does not produce a
61
- // commit on whatever branch the user happens to be on.
62
- persistMigrations?: boolean
63
46
  }
64
47
 
65
48
  export async function loadCron(agentDir: string, options: LoadCronOptions = {}): Promise<LoadCronResult> {
@@ -80,36 +63,12 @@ export async function loadCron(agentDir: string, options: LoadCronOptions = {}):
80
63
  return { ok: false, reason: `cron.json is not valid JSON: ${errorMessage(err)}` }
81
64
  }
82
65
 
83
- const migrated = migrateLegacyCronShape(parsed)
84
- const persistMigrations = options.persistMigrations ?? true
85
- if (migrated.changed && persistMigrations) {
86
- await persistMigratedCron(path, migrated.json, agentDir, migrated.applied)
87
- }
88
-
89
- const result = parseCronFile(migrated.json, options.subagents !== undefined ? { subagents: options.subagents } : {})
66
+ const result = parseCronFile(parsed, options.subagents !== undefined ? { subagents: options.subagents } : {})
90
67
  if (!result.ok) return { ok: false, reason: result.reason }
91
68
 
92
69
  return { ok: true, file: result.file }
93
70
  }
94
71
 
95
- async function persistMigratedCron(
96
- path: string,
97
- json: unknown,
98
- agentDir: string,
99
- applied: readonly CronMigrationStep[],
100
- ): Promise<void> {
101
- try {
102
- await writeFile(path, `${JSON.stringify(json, null, 2)}\n`)
103
- } catch {
104
- return
105
- }
106
-
107
- const message = buildCronMigrationCommitMessage(applied)
108
- if (message !== null) {
109
- await commitSystemFile(agentDir, CRON_FILE, message)
110
- }
111
- }
112
-
113
72
  function errorMessage(err: unknown): string {
114
73
  return err instanceof Error ? err.message : String(err)
115
74
  }
@@ -64,99 +64,7 @@ export type ParseCronOptions = {
64
64
  subagents?: SubagentRegistry
65
65
  }
66
66
 
67
- // One-shot rewrite for cron.json files that predate PR #171, when
68
- // `scheduledByRole` became mandatory on every job. The schema gate
69
- // (`parseCronFile`) rejects legacy entries with a precise remediation
70
- // message, but rejecting on every container boot is a stuck state for
71
- // the user — the agent crashes in a tight restart loop with no path
72
- // forward except hand-editing cron.json.
73
- //
74
- // The migration stamps `scheduledByRole: 'owner'` on every job that's
75
- // missing it. `owner` is the right default for two reasons:
76
- // 1. Before #171 there was no role concept; every cron job ran with
77
- // the same (effectively-owner) privileges the agent had.
78
- // 2. The schema gate's own error message tells users to add
79
- // `"scheduledByRole": "owner"` for manually-authored entries —
80
- // we just do it for them.
81
- //
82
- // Mirrors `migrateLegacyConfigShape` in src/config/config.ts: pure
83
- // function, returns the rewritten JSON plus an `applied` array so
84
- // callers can build a meaningful commit message. Returns `changed:
85
- // false` on canonical input so the persist + commit path stays
86
- // untouched on the happy path.
87
- export type CronMigrationStep = { kind: 'stamp-scheduled-by-role-owner'; jobIds: string[] }
88
-
89
- export type CronMigrationResult = { json: unknown; changed: boolean; applied: CronMigrationStep[] }
90
-
91
- export function migrateLegacyCronShape(json: unknown): CronMigrationResult {
92
- if (typeof json !== 'object' || json === null || Array.isArray(json)) {
93
- return { json, changed: false, applied: [] }
94
- }
95
-
96
- const obj = json as Record<string, unknown>
97
- const jobs = obj.jobs
98
- if (!Array.isArray(jobs)) {
99
- return { json, changed: false, applied: [] }
100
- }
101
-
102
- const stampedIds: string[] = []
103
- const nextJobs = jobs.map((job) => {
104
- if (typeof job !== 'object' || job === null || Array.isArray(job)) return job
105
- const record = job as Record<string, unknown>
106
- if ('scheduledByRole' in record) return job
107
- const id = typeof record.id === 'string' ? record.id : '<unknown>'
108
- stampedIds.push(id)
109
- return { ...record, scheduledByRole: 'owner' }
110
- })
111
-
112
- if (stampedIds.length === 0) {
113
- return { json, changed: false, applied: [] }
114
- }
115
-
116
- return {
117
- json: { ...obj, jobs: nextJobs },
118
- changed: true,
119
- applied: [{ kind: 'stamp-scheduled-by-role-owner', jobIds: stampedIds }],
120
- }
121
- }
122
-
123
- // Builds a one-line git commit subject (plus enumerating body) for a
124
- // cron.json migration. Returns null when no steps were applied — callers
125
- // should not commit in that case. Mirrors `buildConfigMigrationCommitMessage`
126
- // in src/config/config.ts.
127
- export function buildCronMigrationCommitMessage(applied: readonly CronMigrationStep[]): string | null {
128
- const first = applied[0]
129
- if (first === undefined) return null
130
-
131
- const subject =
132
- applied.length === 1
133
- ? `cron.json: ${shortCronStepLabel(first)}`
134
- : `cron.json: migrate legacy shape (${applied.length} steps)`
135
-
136
- const bodyLines: string[] = applied.map((step) => `- ${describeCronStep(step)}`)
137
- return `${subject}\n\n${bodyLines.join('\n')}\n`
138
- }
139
-
140
- function shortCronStepLabel(step: CronMigrationStep): string {
141
- switch (step.kind) {
142
- case 'stamp-scheduled-by-role-owner':
143
- return `stamp scheduledByRole: "owner" on ${step.jobIds.length} legacy job${step.jobIds.length === 1 ? '' : 's'}`
144
- }
145
- }
146
-
147
- function describeCronStep(step: CronMigrationStep): string {
148
- switch (step.kind) {
149
- case 'stamp-scheduled-by-role-owner':
150
- return `stamp scheduledByRole: "owner" on jobs without provenance (PR #171 backfill): ${step.jobIds.join(', ')}`
151
- }
152
- }
153
-
154
- export type ParseCronJsonOptions = ParseCronOptions & {
155
- // Apply `migrateLegacyCronShape` before schema validation. Defaults to true
156
- // so the guard accepts the same legacy shapes `loadCron` would auto-migrate
157
- // on disk; pass false to validate the exact bytes (used in tests).
158
- migrate?: boolean
159
- }
67
+ export type ParseCronJsonOptions = ParseCronOptions
160
68
 
161
69
  export function parseCronJson(raw: string, options: ParseCronJsonOptions = {}): ParseCronResult {
162
70
  let json: unknown
@@ -166,9 +74,7 @@ export function parseCronJson(raw: string, options: ParseCronJsonOptions = {}):
166
74
  return { ok: false, reason: `cron.json is not valid JSON: ${err instanceof Error ? err.message : String(err)}` }
167
75
  }
168
76
 
169
- const shouldMigrate = options.migrate ?? true
170
- const migrated = shouldMigrate ? migrateLegacyCronShape(json) : { json, changed: false, applied: [] }
171
- return parseCronFile(migrated.json, options.subagents !== undefined ? { subagents: options.subagents } : {})
77
+ return parseCronFile(json, options.subagents !== undefined ? { subagents: options.subagents } : {})
172
78
  }
173
79
 
174
80
  export function parseCronFile(raw: unknown, options: ParseCronOptions = {}): ParseCronResult {
@@ -38,8 +38,7 @@ export function buildGitignore(config: GitignoreConfig = { append: [] }): string
38
38
  #
39
39
  # auth.json is the pre-rename name for secrets.json; kept here permanently
40
40
  # as a safety net so an agent folder cloned from a pre-rename machine never
41
- # stages credentials by accident, even if its agent boot hasn't yet run the
42
- # auth.json -> secrets.json migration.
41
+ # stages credentials by accident.
43
42
  #
44
43
  # .typeclaw/home/ is the persistent-$HOME overlay populated by the
45
44
  # entrypoint shim's \`link_persistent_home_files\` (see
@@ -2,7 +2,7 @@ import { KNOWN_PROVIDERS, type KnownProviderId } from '@/config/providers'
2
2
 
3
3
  // DEFAULT_ENV_NAMES is the single source of truth for the env-var name each
4
4
  // secret-bearing field uses when the user does not override it via the `env`
5
- // field of a `Secret` object. Three layers depend on it:
5
+ // field of a `Secret` object. Two layers depend on it:
6
6
  //
7
7
  // 1. resolveSecret (src/secrets/resolve.ts) — when the on-disk Secret has
8
8
  // no explicit `env`, it falls back to this table to know which env var
@@ -11,10 +11,6 @@ import { KNOWN_PROVIDERS, type KnownProviderId } from '@/config/providers'
11
11
  // resolved channel field values into `process.env`, it uses these names
12
12
  // so that `src/channels/manager.ts` (which reads `env.DISCORD_BOT_TOKEN`
13
13
  // etc. directly) keeps working without per-adapter refactoring.
14
- // 3. parseSecretsFile legacy upgrade — when reading a v1 file with the old
15
- // `{ ENV_NAME: value }` channel shape, it inverts this table to rename
16
- // the keys to the new per-adapter field names.
17
- //
18
14
  // Providers come from `KNOWN_PROVIDERS[id].apiKeyEnv` — derived, not duplicated.
19
15
  // OAuth-only providers are intentionally absent: OAuth credentials are not
20
16
  // env-injectable (refresh tokens are stateful).
@@ -31,19 +27,6 @@ export function isKnownAdapterId(id: string): id is KnownAdapterId {
31
27
  return id in CHANNEL_FIELD_ENV
32
28
  }
33
29
 
34
- // Reverse map: env-var name -> { adapterId, fieldName }. Built from
35
- // CHANNEL_FIELD_ENV so adding a new adapter field updates both directions
36
- // automatically. Used exclusively by the legacy v1 channels-shape upgrade.
37
- export const CHANNEL_ENV_TO_FIELD: Record<string, { adapterId: KnownAdapterId; fieldName: string }> = (() => {
38
- const out: Record<string, { adapterId: KnownAdapterId; fieldName: string }> = {}
39
- for (const [adapterId, fields] of Object.entries(CHANNEL_FIELD_ENV)) {
40
- for (const [fieldName, envName] of Object.entries(fields)) {
41
- out[envName] = { adapterId: adapterId as KnownAdapterId, fieldName }
42
- }
43
- }
44
- return out
45
- })()
46
-
47
30
  // Returns the default env-var name for a known channel field, or undefined
48
31
  // when the adapter or field is not in CHANNEL_FIELD_ENV (forward-compat: a
49
32
  // future adapter contributed via plugin would not appear in this table).
@@ -6,8 +6,6 @@ export { type Secret } from './resolve'
6
6
 
7
7
  export { hydrateChannelEnvFromSecrets } from './hydrate'
8
8
 
9
- export { migrateKakaotalkCredentials } from './migrate-kakaotalk'
10
-
11
9
  export {
12
10
  type ExportCodexAuthFileResult,
13
11
  exportCodexAuthFileForAgent,