@yunhozz/arc 0.2.3 → 0.2.5

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 (95) hide show
  1. package/MANIFEST.sha256 +86 -88
  2. package/claude/agents/arc-implementer.md +32 -42
  3. package/claude/agents/arc-planner.md +33 -47
  4. package/claude/agents/arc-qa.md +28 -44
  5. package/claude/agents/arc-reviewer.md +28 -41
  6. package/claude/agents/arc-scouter.md +26 -38
  7. package/claude/skill/SKILL.md +41 -59
  8. package/claude/skill/models.toml +11 -11
  9. package/claude/skill/references/arc-visual-companion.md +19 -24
  10. package/claude/skill/references/brainstorming.md +111 -42
  11. package/claude/skill/references/commit.md +38 -70
  12. package/claude/skill/references/coordination.md +77 -126
  13. package/claude/skill/references/git-recovery.md +17 -35
  14. package/claude/skill/references/handoff-prep.md +91 -103
  15. package/claude/skill/references/handoff.md +110 -208
  16. package/claude/skill/references/limits.md +19 -31
  17. package/claude/skill/references/planning.md +93 -244
  18. package/claude/skill/references/superpowers-provenance.json +3 -3
  19. package/claude/skill/references/verification.md +105 -261
  20. package/claude/skill/runtime/adapters/claude.mjs +10 -9
  21. package/claude/skill/runtime/adapters/codex.mjs +7 -8
  22. package/claude/skill/runtime/bootstrap.mjs +3 -2
  23. package/claude/skill/runtime/capabilities.mjs +12 -0
  24. package/claude/skill/runtime/cooperative-schema.mjs +3 -2
  25. package/claude/skill/runtime/cooperative.mjs +52 -4
  26. package/claude/skill/runtime/evaluate.mjs +115 -2
  27. package/claude/skill/runtime/identity.mjs +22 -0
  28. package/claude/skill/runtime/role-policy.json +18 -18
  29. package/claude/skill/runtime/schema.mjs +19 -0
  30. package/claude/skill/scripts/model_config.py +20 -84
  31. package/codex/agents/arc-implementer.toml +34 -30
  32. package/codex/agents/arc-planner.toml +27 -23
  33. package/codex/agents/arc-qa.toml +24 -15
  34. package/codex/agents/arc-reviewer.toml +25 -16
  35. package/codex/agents/arc-scouter.toml +20 -26
  36. package/codex/skill/SKILL.md +42 -57
  37. package/codex/skill/models.toml +13 -13
  38. package/codex/skill/references/arc-visual-companion.md +21 -27
  39. package/codex/skill/references/brainstorming.md +111 -42
  40. package/codex/skill/references/commit.md +35 -81
  41. package/codex/skill/references/coordination.md +76 -104
  42. package/codex/skill/references/git-recovery.md +29 -51
  43. package/codex/skill/references/handoff-prep.md +76 -134
  44. package/codex/skill/references/handoff-worktree.md +32 -66
  45. package/codex/skill/references/handoff.md +111 -271
  46. package/codex/skill/references/limits.md +21 -34
  47. package/codex/skill/references/planning.md +125 -299
  48. package/codex/skill/references/superpowers-provenance.json +3 -3
  49. package/codex/skill/references/verification.md +90 -272
  50. package/codex/skill/runtime/adapters/claude.mjs +10 -9
  51. package/codex/skill/runtime/adapters/codex.mjs +7 -8
  52. package/codex/skill/runtime/bootstrap.mjs +3 -2
  53. package/codex/skill/runtime/capabilities.mjs +12 -0
  54. package/codex/skill/runtime/cooperative-schema.mjs +3 -2
  55. package/codex/skill/runtime/cooperative.mjs +52 -4
  56. package/codex/skill/runtime/evaluate.mjs +115 -2
  57. package/codex/skill/runtime/identity.mjs +22 -0
  58. package/codex/skill/runtime/role-policy.json +18 -18
  59. package/codex/skill/runtime/schema.mjs +19 -0
  60. package/codex/skill/scripts/model_config.py +25 -6
  61. package/lib/installer/global-config.mjs +2 -1
  62. package/lib/installer/manifest.mjs +1 -1
  63. package/lib/installer/plan.mjs +6 -8
  64. package/lib/installer/preset.mjs +7 -4
  65. package/lib/installer/uninstall.mjs +2 -2
  66. package/lib/management/operations.mjs +4 -2
  67. package/lib/management/update.mjs +3 -3
  68. package/lib/models/catalog-anthropic.mjs +46 -0
  69. package/lib/models/catalog.mjs +62 -0
  70. package/lib/models/codex-catalog.mjs +103 -0
  71. package/lib/models/config.mjs +19 -11
  72. package/lib/models/index.mjs +1 -1
  73. package/lib/models/profiles.mjs +1 -40
  74. package/lib/policy/render.mjs +1 -5
  75. package/lib/policy/role-policy.json +18 -18
  76. package/lib/runtime/adapters/claude.mjs +10 -9
  77. package/lib/runtime/adapters/codex.mjs +7 -8
  78. package/lib/runtime/bootstrap.mjs +3 -2
  79. package/lib/runtime/capabilities.mjs +12 -0
  80. package/lib/runtime/cooperative-schema.mjs +3 -2
  81. package/lib/runtime/cooperative.mjs +52 -4
  82. package/lib/runtime/evaluate.mjs +115 -2
  83. package/lib/runtime/identity.mjs +22 -0
  84. package/lib/runtime/schema.mjs +19 -0
  85. package/lib/tui/app.mjs +26 -16
  86. package/lib/tui/custom-preset.mjs +122 -0
  87. package/lib/tui/render.mjs +3 -2
  88. package/lib/tui/setup.mjs +65 -12
  89. package/package.json +1 -2
  90. package/claude/skill/hooks/git-policy.json +0 -208
  91. package/claude/skill/hooks/role-git-guard.mjs +0 -18
  92. package/claude/skill/hooks/runtime-gate.mjs +0 -12
  93. package/codex/skill/hooks/runtime-gate.mjs +0 -10
  94. package/lib/policy/claude-hook.mjs +0 -80
  95. package/scripts/probe-runtime-hooks.mjs +0 -55
@@ -2,6 +2,7 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import copy
5
6
  import json
6
7
  import re
7
8
  import tomllib
@@ -77,10 +78,14 @@ PRESETS = ("economy", "balanced", "smart")
77
78
 
78
79
 
79
80
  def validate_config(config: dict, path: Path) -> dict:
80
- if not isinstance(config, dict) or set(config) != {"version", "preset", "roles"} or type(config["version"]) is not int or config["version"] != 1:
81
- raise _error(path, "settings", "update to version 1 unified presets; reinstall ARC or edit models.toml to include preset and all role presets")
82
- if not isinstance(config["preset"], str) or config["preset"] not in PRESETS:
83
- raise _error(path, "preset", "must be economy, balanced or smart")
81
+ if not isinstance(config, dict) or type(config.get("version")) is not int or config["version"] not in (1, 2):
82
+ raise _error(path, "settings", "update to version 1 or 2 unified presets; reinstall ARC or edit models.toml to include preset and all role presets")
83
+ has_custom = "custom" in config
84
+ allowed = {"version", "preset", "roles"} | ({"custom"} if config["version"] == 2 and has_custom else set())
85
+ if set(config) != allowed:
86
+ raise _error(path, "settings", "update to version 1 or 2 unified presets; reinstall ARC or edit models.toml to include preset and all role presets")
87
+ if not isinstance(config["preset"], str) or config["preset"] not in (*PRESETS, "custom") or (config["preset"] == "custom" and not has_custom):
88
+ raise _error(path, "preset", "must select an available preset")
84
89
  roles = config["roles"]
85
90
  if not isinstance(roles, dict) or set(roles) != set(ROLE_FILES):
86
91
  raise _error(path, "roles", "must define exactly the five ARC roles")
@@ -89,24 +94,38 @@ def validate_config(config: dict, path: Path) -> dict:
89
94
  raise _error(path, f"roles.{role}", "must contain exactly economy, balanced and smart")
90
95
  for preset, values in variants.items():
91
96
  _settings_for_profile(values, f"{role}.{preset}", path)
92
- return config
97
+ if has_custom:
98
+ custom = config["custom"]
99
+ if not isinstance(custom, dict) or set(custom) != set(ROLE_FILES):
100
+ raise _error(path, "custom", "must define exactly the five ARC roles")
101
+ for role, values in custom.items():
102
+ _settings_for_profile(values, f"custom.{role}", path)
103
+ result = copy.deepcopy(config)
104
+ result["version"] = 2
105
+ return result
93
106
 
94
107
 
95
108
  def load_settings(config_path: Path) -> dict[str, RoleSettings]:
96
109
  """Validate every preset and return the active role settings."""
97
110
  _, _, config = _parse_file(config_path, "settings")
98
111
  config = validate_config(config, config_path)
112
+ if config["preset"] == "custom":
113
+ return config["custom"]
99
114
  return {role: dict(variants[config["preset"]]) for role, variants in config["roles"].items()}
100
115
 
101
116
 
102
117
  def serialize_settings(config: dict) -> bytes:
103
118
  """Serialize a complete unified configuration without losing inactive presets."""
104
119
  config = validate_config(config, Path("models.toml"))
105
- lines = ["version = 1", f'preset = {json.dumps(config["preset"])}']
120
+ lines = ["version = 2", f'preset = {json.dumps(config["preset"])}']
106
121
  for role in ROLE_FILES:
107
122
  for preset in PRESETS:
108
123
  lines.extend(["", f"[roles.{role}.{preset}]"])
109
124
  lines.extend(f"{key} = {json.dumps(config['roles'][role][preset][key], ensure_ascii=False)}" for key in _MODEL_FIELDS)
125
+ if "custom" in config:
126
+ for role in ROLE_FILES:
127
+ lines.extend(["", f"[custom.{role}]"])
128
+ lines.extend(f"{key} = {json.dumps(config['custom'][role][key], ensure_ascii=False)}" for key in _MODEL_FIELDS)
110
129
  return ("\n".join(lines) + "\n").encode("utf-8")
111
130
 
112
131
 
@@ -38,5 +38,6 @@ export function renderWaitSettings(original){
38
38
  try{if(!isDeepStrictEqual(parseToml(updated),expected))return manual('automatic edit could not be verified');}catch{return manual('automatic edit could not be verified');}
39
39
  return{content:Buffer.from(updated),action:'set ARC wait settings'};
40
40
  }
41
- export function agentsBlockBody(home,userHome,language='en',platform='codex'){const skill=home+'/skills/arc/SKILL.md',within=skill.startsWith(userHome+'/'),shown=within?'~/'+skill.slice(userHome.length+1):skill;return '- '+languagePolicy(language)+'\n'+'- For requests requiring substantive multi-step research, analysis, or implementation, configuration, or test work, and for design, spec, plan, and brainstorming requests (these are NOT standalone document writing), load and follow `'+shown+'`; keep following it in relevant follow-up turns of the same chat without re-reading it unless it changed.'+(within?" Resolve `~` to the current user's home directory.":'')+' ARC subagents (depth 2) follow their role profile and brief instead and do not load `SKILL.md`.\n'+'- Design, spec, plan, and brainstorming drafts are authored ONLY by the Planner. Orchestrator MUST NEVER author them; route drafting skills to the Planner.\n'+
41
+
42
+ export function agentsBlockBody(home,userHome,language='en',platform='codex'){const skill=home+'/skills/arc/SKILL.md',within=skill.startsWith(userHome+'/'),shown=within?'~/'+skill.slice(userHome.length+1):skill;return '- '+languagePolicy(language)+'\n'+'- For requests requiring substantive multi-step research, analysis, or implementation, configuration, or test work, and for design, spec, plan, and brainstorming requests (these are NOT standalone document writing), load and follow `'+shown+'`; keep following it in relevant follow-up turns of the same chat without re-reading it unless it changed.'+(within?" Resolve `~` to the current user's home directory.":'')+' ARC subagents (depth 2) follow their role profile and brief instead and do not load `SKILL.md`.\n'+'- Design and brainstorming drafts and first complete spec and plan drafts are authored only by the Planner; route drafting skills to the Planner. Orchestrator reviews and directly revises the same spec or plan when needed; a missing or failed first draft does not transfer initial authorship. New product decisions outside approved scope require user input first.\n'+
42
43
  '- For repository implementation, configuration, or test work, Orchestrator owns coordination and acceptance; assign file edits and other work to the other roles according to their defined responsibilities. Orchestrator must not edit implementation, configuration, or test-code files directly, except under the ARC Orchestrator direct path for small, low-risk tasks.\n';}
@@ -40,7 +40,7 @@ export function verifyPayload({packageRoot,safeFs,payloadMode='auto'}){
40
40
  for(const platform of ['codex','claude'])referenceTrees[platform]=validateFlatReferenceFiles(files,platform);
41
41
  for(const{destination}of REFERENCE_PATH_MAP)if(!referenceTrees.codex.entries.get(destination).bytes.equals(referenceTrees.claude.entries.get(destination).bytes))throw Error('Imported platform parity mismatch: '+destination);
42
42
  for(const name of listed.keys())if(!files.has(name))throw Error(`Manifest entry is missing: ${name}`);
43
- const required=['claude/skill/hooks/role-git-guard.mjs','claude/skill/hooks/git-policy.json','lib/policy/claude-hook.mjs','lib/policy/role-policy.json','lib/policy/render.mjs','scripts/update-role-policy.mjs','lib/limits.mjs','lib/installer/limits.mjs','lib/installer/runtime-config.mjs','README.md','install.command','package.json','bin/arc.mjs','lib/cli/root.mjs','lib/tui/app.mjs','lib/tui/terminal.mjs','lib/tui/render.mjs','lib/tui/setup.mjs','lib/management/operations.mjs','lib/management/status.mjs','lib/management/update.mjs','lib/management/workflow.mjs','lib/cli/postinstall.mjs','scripts/postinstall.mjs','lib/cli/upgrade.mjs','bin/arc-upgrade.mjs','lib/installer/shell-completion.mjs','lib/installer/manifest.mjs','lib/installer/targets.mjs','lib/installer/global-config.mjs','lib/installer/plan.mjs','lib/safe-fs/index.mjs','lib/safe-fs/native.mjs','lib/models/index.mjs','lib/models/config.mjs','lib/models/profiles.mjs','vendor/toml/LICENSE','vendor/toml/package.json','vendor/toml/provenance.json','vendor/toml/dist/index.js','prebuilds/index.json'];
43
+ const required=['lib/policy/role-policy.json','lib/policy/render.mjs','scripts/update-role-policy.mjs','lib/limits.mjs','lib/installer/limits.mjs','lib/installer/runtime-config.mjs','README.md','install.command','package.json','bin/arc.mjs','lib/cli/root.mjs','lib/tui/app.mjs','lib/tui/terminal.mjs','lib/tui/render.mjs','lib/tui/setup.mjs','lib/management/operations.mjs','lib/management/status.mjs','lib/management/update.mjs','lib/management/workflow.mjs','lib/cli/postinstall.mjs','scripts/postinstall.mjs','lib/cli/upgrade.mjs','bin/arc-upgrade.mjs','lib/installer/shell-completion.mjs','lib/installer/manifest.mjs','lib/installer/targets.mjs','lib/installer/global-config.mjs','lib/installer/plan.mjs','lib/safe-fs/index.mjs','lib/safe-fs/native.mjs','lib/models/index.mjs','lib/models/config.mjs','lib/models/profiles.mjs','vendor/toml/LICENSE','vendor/toml/package.json','vendor/toml/provenance.json','vendor/toml/dist/index.js','prebuilds/index.json'];
44
44
  required.push('lib/installer/runtime-assets.mjs',...RUNTIME_MODULE_PATHS);
45
45
  for(const platform of ['codex','claude'])required.push(`${platform}/skill/runtime/role-policy.json`,...RUNTIME_MODULE_PATHS.map(name=>`${platform}/skill/runtime/${name.slice('lib/runtime/'.length)}`));
46
46
  for(const tuple of ['darwin-arm64','darwin-x64','linux-arm64-gnu','linux-x64-gnu'])required.push(`prebuilds/${tuple}/arc_safe_fs.node`);
@@ -1,9 +1,8 @@
1
- import {installClaudeHook} from '../policy/claude-hook.mjs';
2
- import {validateGitPolicy} from '../../claude/skill/hooks/role-git-guard.mjs';
3
1
  import{parseLanguage,serializeLanguage,renderLanguagePolicy,validateLanguage}from'./language.mjs';
4
2
  import{resolveLimits}from'../limits.mjs';import{parseLimits,serializeLimits}from'./limits.mjs';import{readRuntimeConcurrency,renderCodexConcurrency,renderClaudeConcurrency}from'./runtime-config.mjs';import{assertPlanSnapshot}from'./transaction.mjs';
5
3
  import{validateModelConfig}from'../models/config.mjs';
6
- import path from'node:path';import{loadModelConfig,applyPreset,serializeModelConfig,renderProfiles,resolveModelSettings}from'../models/index.mjs';import{inspectPath,TargetConfigurationError}from'./targets.mjs';import{renderManagedBlock,renderWaitSettings,agentsBlockBody}from'./global-config.mjs';
4
+ import{assertCatalogRoles}from'../models/catalog.mjs';
5
+ import path from'node:path';import{loadModelConfig,applyPreset,applyCustomRoles,serializeModelConfig,renderProfiles,resolveModelSettings}from'../models/index.mjs';import{inspectPath,TargetConfigurationError}from'./targets.mjs';import{renderManagedBlock,renderWaitSettings,agentsBlockBody}from'./global-config.mjs';
7
6
  function fail(message){throw new TargetConfigurationError(message);}
8
7
  export function previewLimits({target,safeFs}){
9
8
  const snapshot=new Map(),filename=path.join(target.home,'skills/arc/limits.toml');
@@ -17,10 +16,9 @@ export function previewLanguage({target,safeFs}){
17
16
  let existing=null;try{if(current.exists)existing=parseLanguage(current.bytes).language;}catch(cause){fail(`Invalid ARC language settings: ${cause.message}`);}
18
17
  return{existing,snapshot};
19
18
  }
20
- function renderModelAssets({payload,target,preset,modelConfig,language}){
19
+ function renderModelAssets({payload,target,preset,modelConfig,language,customRoles,preservedCustomRoles,verifiedCatalog}){
21
20
  const input=payload.modelInputs[target.platform];if(!input)fail('Selected payload platform is unavailable');
22
- const config=modelConfig===undefined?applyPreset(loadModelConfig(input.models,target.platform),preset):validateModelConfig(modelConfig);if(config.platform!==target.platform)fail('Model config platform does not match target');const profiles=renderProfiles(input.profiles,resolveModelSettings(config));for(const[name,bytes]of profiles)profiles.set(name,renderLanguagePolicy(bytes,language));const modelBytes=Buffer.from(serializeModelConfig(config));
23
- if(target.platform==='claude'){try{const script=payload.files.get('claude/skill/hooks/role-git-guard.mjs'),policy=payload.files.get('claude/skill/hooks/git-policy.json');if(!Buffer.isBuffer(script)||!script.length||!Buffer.isBuffer(policy))throw Error('Missing hook assets');validateGitPolicy(JSON.parse(policy.toString('utf8')));for(const[name,bytes]of profiles)profiles.set(name,installClaudeHook(bytes,path.join(target.home,'skills/arc')));}catch(cause){fail(`Invalid Claude Git guard: ${cause.message}`);}}
21
+ let config;if(modelConfig!==undefined)config=validateModelConfig(modelConfig);else{const base=loadModelConfig(input.models,target.platform);if(preset==='custom'){assertCatalogRoles({platform:target.platform,roles:customRoles,catalog:verifiedCatalog});config=applyCustomRoles(base,customRoles);}else {if(customRoles!==undefined)assertCatalogRoles({platform:target.platform,roles:customRoles,catalog:verifiedCatalog});config=applyPreset(customRoles!==undefined?applyCustomRoles(base,customRoles):preservedCustomRoles!==undefined?applyCustomRoles(base,preservedCustomRoles):base,preset);}}if(config.platform!==target.platform)fail('Model config platform does not match target');const profiles=renderProfiles(input.profiles,resolveModelSettings(config));for(const[name,bytes]of profiles)profiles.set(name,renderLanguagePolicy(bytes,language));const modelBytes=Buffer.from(serializeModelConfig(config));
24
22
  return{profiles,modelBytes};
25
23
  }
26
24
  function renderSkillPayload({payload,target,language,modelBytes,limits}){
@@ -48,14 +46,14 @@ function renderNativeChanges({target,safeFs,snapshot,skipGlobal,preview,concurre
48
46
  }
49
47
  return globalChanges;
50
48
  }
51
- export function buildInstallPlan({payload,target,preset,skipGlobal=false,safeFs,limits,concurrentExplicit=false,limitsPreview,language='en',languagePreview,modelConfig}){
49
+ export function buildInstallPlan({payload,target,preset,skipGlobal=false,safeFs,limits,concurrentExplicit=false,limitsPreview,language='en',languagePreview,modelConfig,customRoles,preservedCustomRoles,verifiedCatalog}){
52
50
  validateLanguage(language);
53
51
  if(languagePreview)assertPlanSnapshot({snapshot:languagePreview.snapshot},{safeFs});
54
52
  const languageState=languagePreview??previewLanguage({target,safeFs});
55
53
  if(limitsPreview)assertPlanSnapshot({snapshot:limitsPreview.snapshot},{safeFs});
56
54
  const preview=limitsPreview??previewLimits({target,safeFs});
57
55
  limits=resolveLimits({existing:preview.existing,overrides:limits??{}});
58
- const {profiles,modelBytes}=renderModelAssets({payload,target,preset,modelConfig,language});
56
+ const {profiles,modelBytes}=renderModelAssets({payload,target,preset,modelConfig,language,customRoles,preservedCustomRoles,verifiedCatalog});
59
57
  const skill=renderSkillPayload({payload,target,language,modelBytes,limits});
60
58
  const snapshot=new Map(),home=target.home;const skills=path.join(home,'skills'),agents=path.join(home,'agents'),arc=path.join(skills,'arc');
61
59
  for(const name of [home,skills,agents])inspectPath(safeFs,name,{kind:'directory',snapshot});
@@ -1,14 +1,17 @@
1
1
  import path from 'node:path';
2
2
  import {inspectPath,TargetConfigurationError} from './targets.mjs';
3
- import {loadModelConfig,applyPreset,serializeModelConfig,resolveModelSettings,renderProfiles} from '../models/index.mjs';
3
+ import {loadModelConfig,applyPreset,applyCustomRoles,serializeModelConfig,resolveModelSettings,renderProfiles} from '../models/index.mjs';
4
4
  import {assertPlanSnapshot,applyTransaction} from './transaction.mjs';
5
+ import {assertCatalogRoles} from '../models/catalog.mjs';
5
6
  import {validatePlanSet} from './plan.mjs';
6
- export function buildPresetPlan({target,preset,safeFs}){
7
+ export function buildPresetPlan({target,preset,safeFs,customRoles,verifiedCatalog}){
7
8
  const snapshot=new Map(),modelsPath=path.join(target.home,'skills/arc/models.toml'),managedPaths=[modelsPath,...target.profileNames.map(name=>path.join(target.home,'agents',name))];
8
9
  const read=filename=>{const found=inspectPath(safeFs,filename,{kind:'file',read:true,snapshot});if(!found.exists)throw new TargetConfigurationError(`Installed ARC file missing: ${filename}`);return found.bytes;};
9
- const originalModels=read(modelsPath),config=loadModelConfig(originalModels,target.platform),next=applyPreset(config,preset),originalProfiles=new Map(target.profileNames.map((name,i)=>[name,read(managedPaths[i+1])]));
10
+ const originalModels=read(modelsPath),config=loadModelConfig(originalModels,target.platform);
11
+ if(preset==='custom'||customRoles!==undefined)assertCatalogRoles({platform:target.platform,roles:customRoles,catalog:verifiedCatalog});
12
+ const next=customRoles!==undefined?applyPreset(applyCustomRoles(config,customRoles),preset):applyPreset(config,preset),originalProfiles=new Map(target.profileNames.map((name,i)=>[name,read(managedPaths[i+1])]));
10
13
  const profiles=renderProfiles(originalProfiles,resolveModelSettings(next)),specs=[];
11
- if(config.preset!==preset)specs.push({path:modelsPath,bytes:Buffer.from(serializeModelConfig(next))});
14
+ if(config.preset!==preset||(customRoles!==undefined&&(!config.custom||Object.keys(next.custom).some(role=>Object.keys(next.custom[role]).some(key=>config.custom[role]?.[key]!==next.custom[role][key])))))specs.push({path:modelsPath,bytes:Buffer.from(serializeModelConfig(next))});
12
15
  for(const[name,bytes]of profiles)if(!bytes.equals(originalProfiles.get(name)))specs.push({path:path.join(target.home,'agents',name),bytes});
13
16
  return{target,preset,snapshot,managedPaths,externalPaths:[],specs};
14
17
  }
@@ -4,7 +4,7 @@ import {SKILLS,snapshotSkill} from './superpowers.mjs';
4
4
  import {validatePlanSet} from './plan.mjs';
5
5
  import {removeManagedBlock} from './global-config.mjs';
6
6
  import {attachShellCompletionCleanup} from './shell-completion.mjs';
7
- export function buildUninstallPlan({target,safeFs,skipGlobal=false}){
7
+ export function buildUninstallPlan({target,safeFs,skipGlobal=false,removeCompletion=true}){
8
8
  const snapshot=new Map(),removals=[],managedPaths=[path.join(target.home,'skills/arc'),...target.profileNames.map(name=>path.join(target.home,'agents',name))],globalChanges=[];
9
9
  for(const filename of managedPaths){const directory=filename===managedPaths[0];const found=inspectPath(safeFs,filename,{kind:directory?'directory':'file',tree:directory,allowLinks:directory,snapshot});if(found.exists)removals.push(filename);}
10
10
  const linkedSkills=new Set(),arc=managedPaths[0];
@@ -16,7 +16,7 @@ export function buildUninstallPlan({target,safeFs,skipGlobal=false}){
16
16
  for(const skill of linkedSkills){const root=arc+'/references/superpowers/'+skill,directory=safeFs.openRoot(root);try{if(!sameIdentity(snapshot.get(root),directory.stat()))throw new TargetConfigurationError('Removal reference root changed');snapshotSkill(directory,root,[]);}finally{directory.close();}}
17
17
  if(!skipGlobal){const filename=target.instructionPath??target.globalPaths[0],found=inspectPath(safeFs,filename,{kind:'file',read:true,snapshot});managedPaths.push(filename);if(found.exists){const removed=removeManagedBlock(found.bytes);if(removed.changed)globalChanges.push({path:filename,content:removed.content});}}
18
18
  const plan={target,skipGlobal,snapshot,removals,managedPaths,externalPaths:[],globalChanges,summary:[target.platform==='codex'?'Codex config.toml wait settings and native concurrency key are preserved.':'Claude settings.json native concurrency key is preserved.']};
19
- attachShellCompletionCleanup([plan],{safeFs});return plan;
19
+ if(removeCompletion)attachShellCompletionCleanup([plan],{safeFs});return plan;
20
20
  }
21
21
  import {assertPlanSnapshot,applyUninstallTransaction} from './transaction.mjs';
22
22
  export async function uninstallTargets(plans,{safeFs,signal,hooks={}}){
@@ -19,12 +19,13 @@ export async function createManagementContext(options={}){
19
19
  export async function prepareOperation(context,options,{selections=[]}={}){
20
20
  if(options.kind==='update'){const{prepareUpdate}=await import('./update.mjs');return prepareUpdate(context);}
21
21
  if(!['install','preset','uninstall'].includes(options.kind))throw Object.assign(Error('Unknown management operation'),{code:'ARC_CLI_INPUT'});
22
+ if(options.kind==='uninstall')options={...options,removeCli:options.removeCli!==false};
22
23
  const platforms=options.target==='all'?['codex','claude']:[options.target],payload=options.kind==='install'?verifyPayload(context):null;
23
- const plans=platforms.map(platform=>{const target=resolveTarget(platform,context),selection=selections.find(item=>item.target.platform===platform&&item.target.home===target.home);if(options.kind==='preset')return buildPresetPlan({target,preset:options.preset,safeFs:context.safeFs});if(options.kind==='uninstall')return buildUninstallPlan({target,safeFs:context.safeFs,skipGlobal:options.skipGlobal});return buildInstallPlan({payload,target,limitsPreview:selection?.preview,languagePreview:selection?.languagePreview,preset:options.preset,language:options.language,skipGlobal:options.skipGlobal,safeFs:context.safeFs,limits:{...(options.maxTotalSubagents===undefined?{}:{max_total_subagents:options.maxTotalSubagents}),...(options.maxConcurrentSubagents===undefined?{}:{max_concurrent_subagents:options.maxConcurrentSubagents})},concurrentExplicit:options.maxConcurrentSubagents!==undefined||Boolean(options.concurrentSelected)});});
24
+ const plans=platforms.map(platform=>{const target=resolveTarget(platform,context),selection=selections.find(item=>item.target.platform===platform&&item.target.home===target.home);if(options.kind==='preset')return buildPresetPlan({target,preset:options.preset,safeFs:context.safeFs,customRoles:options.customRoles,verifiedCatalog:options.verifiedCatalog});if(options.kind==='uninstall')return buildUninstallPlan({target,safeFs:context.safeFs,skipGlobal:options.skipGlobal,removeCompletion:options.removeCli!==false});return buildInstallPlan({payload,target,limitsPreview:selection?.preview,languagePreview:selection?.languagePreview,preset:options.preset,customRoles:options.customRoles,preservedCustomRoles:options.preservedCustomRoles,verifiedCatalog:options.verifiedCatalog,language:options.language,skipGlobal:options.skipGlobal,safeFs:context.safeFs,limits:{...(options.maxTotalSubagents===undefined?{}:{max_total_subagents:options.maxTotalSubagents}),...(options.maxConcurrentSubagents===undefined?{}:{max_concurrent_subagents:options.maxConcurrentSubagents})},concurrentExplicit:options.maxConcurrentSubagents!==undefined||Boolean(options.concurrentSelected)});});
24
25
  if(options.kind==='install')attachShellCompletionCleanup(plans,context);
25
26
  // Uninstall plans initially share the completion snapshot; its executor assigns one owner.
26
27
  if(options.kind!=='uninstall')validatePlanSet(plans);
27
- const globalArc=options.kind==='uninstall'?await(context.inspectGlobalArc??inspectGlobalArc)({env:context.env,cwd:context.cwd,runNpm:context.runNpm}):undefined;
28
+ const globalArc=options.kind==='uninstall'&&options.removeCli!==false?await(context.inspectGlobalArc??inspectGlobalArc)({env:context.env,cwd:context.cwd,runNpm:context.runNpm}):undefined;
28
29
  return{kind:options.kind,plans,options:{...options},...(globalArc?{globalArc}:{})};
29
30
  }
30
31
  export function operationResult(result,successStatuses=['installed']){return{...result,code:result.interrupted?130:result.results.every(item=>successStatuses.includes(item.status)&&!item.error)?0:1,exitSession:false};}
@@ -34,6 +35,7 @@ export async function executeOperation(context,prepared){
34
35
  if(prepared.kind==='install')return operationResult(await installTargets(prepared.plans,options));
35
36
  if(prepared.kind==='preset')return operationResult(await switchPresetTargets(prepared.plans,options),['updated','unchanged']);
36
37
  if(prepared.kind==='uninstall'){
38
+ if(prepared.options?.removeCli===false)return operationResult(await uninstallTargets(prepared.plans,options),['uninstalled']);
37
39
  let result;
38
40
  try{
39
41
  const current=await(context.inspectGlobalArc??inspectGlobalArc)({context:prepared.globalArc,env:context.env,cwd:context.cwd,runNpm:context.runNpm});
@@ -12,7 +12,7 @@ export async function runtime(deps){
12
12
  return{...context,...state,...upgrade,...npm,...deps,safeFs};
13
13
  }
14
14
  export function report(result,output,error){
15
- for(const item of result.results){output.write(item.platform+': '+item.status+'; rollback: '+item.rollback+'\n');for(const filename of item.residualPaths??[])output.write('Residual path: '+filename+'\n');if(item.error)error.write('Error: '+item.error.message+'\n');}
15
+ for(const item of result.results){output.write(item.platform+': '+item.status+'\n');for(const filename of item.residualPaths??[])output.write('Residual path: '+filename+'\n');if(item.error)error.write('Error: '+item.error.message+'\n');}
16
16
  if(result.interrupted)return 130;
17
17
  if(result.results.every(item=>item.status==='installed'&&!item.error)){output.write('ARC version '+result.version+' harness upgraded! Previous harness version: not recorded.\n');return 0;}
18
18
  error.write('Harness update did not complete. Run arc again and choose Install / Update to retry the harness update.\n');return 1;
@@ -35,18 +35,18 @@ export async function runChild(request,io={},deps={}){
35
35
  export async function prepareUpdate(context){
36
36
  const root=context.packageRoot??packageRoot,pkg=identity(root),api=await runtime(context),cwd=fs.realpathSync(context.cwd??process.cwd()),env=context.env??process.env;
37
37
  const state=api.collectInstalledState({safeFs:api.safeFs,userHome:context.userHome??os.homedir(),env,cwd});
38
- if(!state.targets.length)throw configurationError('No installed ARC harness found. Run npx --yes @yunhozz/arc to install it first');
39
38
  const npmContext=await api.inspectNpmContext({packageRoot:root,entryPath:path.join(root,'bin/arc.mjs'),env,cwd});
40
39
  if(!['global','npx'].includes(npmContext.kind))throw configurationError('Run arc from a verified global ARC installation or npx');
41
40
  let latestVersion=null,latestError;
42
41
  try{const latest=await api.resolveLatest({context:npmContext,env,cwd,signal:context.signal});if(!/^\d+\.\d+\.\d+$/.test(latest.version))throw Error('Invalid latest ARC version');latestVersion=latest.version;}catch(error){if(['ARC_CANCELLED','ARC_NPM_CANCELLED'].includes(error.code))throw error;latestError=error;}
43
- return{kind:'update',root,pkg,cwd,env,state,npmContext,currentVersion:pkg.version,latestVersion,canUpgrade:latestVersion!==null&&newer(latestVersion,pkg.version),latestError};
42
+ return{kind:'update',root,pkg,cwd,env,state,npmContext,currentVersion:pkg.version,latestVersion,canUpgrade:state.targets.length>0&&latestVersion!==null&&newer(latestVersion,pkg.version),latestError};
44
43
  }
45
44
  function newer(left,right){const a=left.split('.').map(BigInt),b=right.split('.').map(BigInt);for(let i=0;i<3;i++){if(a[i]!==b[i])return a[i]>b[i];}return false;}
46
45
 
47
46
  export async function executeUpdate(context,prepared){
48
47
  let packageStarted=false;const messages=[];try{
49
48
  const api=await runtime(context),{root,pkg,cwd,env,state}=prepared;
49
+ if(!state?.targets?.length)throw configurationError('No installed ARC harness found');
50
50
  if(!prepared.canUpgrade||typeof prepared.latestVersion!=='string'||!/^\d+\.\d+\.\d+$/.test(prepared.latestVersion)||!newer(prepared.latestVersion,prepared.currentVersion))throw configurationError('No newer ARC version is available');
51
51
  if(identity(root).version!==pkg.version||pkg.version!==prepared.currentVersion)throw configurationError('ARC package changed after checking its version');
52
52
  const{assertPlanSnapshot}=await import('../installer/transaction.mjs');for(const entry of state.targets)assertPlanSnapshot(entry,{safeFs:api.safeFs});
@@ -0,0 +1,46 @@
1
+ const API = 'https://api.anthropic.com/v1/models';
2
+ const EFFORTS = ['low', 'medium', 'high', 'xhigh', 'max'];
3
+ const unavailable = (code, message) => ({ status: 'unavailable', code, message });
4
+
5
+ export async function discoverAnthropicCatalog({ apiKey, fetchImpl = globalThis.fetch, signal } = {}) {
6
+ if (typeof apiKey !== 'string' || !apiKey.trim() || /[\r\n]/.test(apiKey)) return unavailable('missing_api_key', 'Anthropic API key is unavailable.');
7
+ if (typeof fetchImpl !== 'function') return unavailable('unavailable', 'Anthropic catalog is unavailable.');
8
+ const pairs = [], unavailableModels = [], seenPairs = new Set(), seenModels = new Set(), supportedModels = new Set(), cursors = new Set();
9
+ let cursor;
10
+ try {
11
+ for (let pageNumber = 0; pageNumber < 1000; pageNumber++) {
12
+ if (signal?.aborted) return unavailable('aborted', 'Catalog lookup was cancelled.');
13
+ const url = new URL(API);
14
+ if (cursor) url.searchParams.set('after_id', cursor);
15
+ const response = await fetchImpl(url, {
16
+ method: 'GET', redirect: 'manual', signal,
17
+ headers: { 'anthropic-version': '2026-10-21', Authorization: `Bearer ${apiKey}` },
18
+ });
19
+ if (!response?.ok) return unavailable('http_error', 'Anthropic catalog is unavailable.');
20
+ if (response.url) {
21
+ const actual = new URL(response.url);
22
+ if (actual.origin !== url.origin || actual.pathname !== url.pathname) return unavailable('http_error', 'Anthropic catalog is unavailable.');
23
+ }
24
+ const page = await response.json();
25
+ if (!page || !Array.isArray(page.data) || !page.data.length || typeof page.has_more !== 'boolean') throw Error('invalid page');
26
+ for (const model of page.data) {
27
+ if (!model || typeof model.id !== 'string' || !/^[A-Za-z0-9][A-Za-z0-9._:/@+\-]*$/.test(model.id) || model.id.includes(apiKey)) throw Error('invalid model');
28
+ const capability = model.capabilities?.effort;
29
+ const supported = capability?.supported === true ? EFFORTS.filter(effort => capability[effort]?.supported === true) : [];
30
+ if (!supported.length && !seenModels.has(model.id)) unavailableModels.push({ model: model.id, reason: 'Effort capability is unavailable.' });
31
+ seenModels.add(model.id);
32
+ for (const effort of supported) {
33
+ supportedModels.add(model.id);
34
+ const key = JSON.stringify([model.id, effort]);
35
+ if (!seenPairs.has(key)) { seenPairs.add(key); pairs.push({ model: model.id, effort }); }
36
+ }
37
+ }
38
+ if (!page.has_more) return { status: 'ready', catalog: { platform: 'claude', pairs, unavailable: unavailableModels.filter(item => !supportedModels.has(item.model)) } };
39
+ if (typeof page.last_id !== 'string' || !page.last_id || page.last_id !== page.data.at(-1).id || cursors.has(page.last_id)) throw Error('invalid cursor');
40
+ cursor = page.last_id; cursors.add(cursor);
41
+ }
42
+ throw Error('too many pages');
43
+ } catch {
44
+ return signal?.aborted ? unavailable('aborted', 'Catalog lookup was cancelled.') : unavailable('invalid_response', 'Anthropic catalog is unavailable.');
45
+ }
46
+ }
@@ -0,0 +1,62 @@
1
+ import { effortField, validateRoles, ModelValidationError } from './config.mjs';
2
+ import { discoverAnthropicCatalog } from './catalog-anthropic.mjs';
3
+ export { discoverAnthropicCatalog } from './catalog-anthropic.mjs';
4
+ export { listCodexModelCapabilities } from './codex-catalog.mjs';
5
+
6
+ const unavailable = (code, message) => ({ status: 'unavailable', code, message });
7
+ const validText = value => typeof value === 'string' && value.length > 0 && value === value.trim() && !/[\p{Cc}\u2028\u2029]/u.test(value);
8
+
9
+ function codexCatalog(models, secrets) {
10
+ if (!Array.isArray(models) || !models.length) throw Error('invalid catalog');
11
+ const pairs = [], missing = [], seenPairs = new Set(), seenModels = new Set(), supportedModels = new Set();
12
+ for (const item of models) {
13
+ if (!item || !validText(item.model) || secrets.some(secret => item.model.includes(secret)) || !Array.isArray(item.efforts) || !item.efforts.every(value => validText(value) && !secrets.some(secret => value.includes(secret)))) throw Error('invalid model');
14
+ if (!item.efforts.length && !seenModels.has(item.model)) missing.push({ model: item.model, reason: 'Effort capability is unavailable.' });
15
+ seenModels.add(item.model);
16
+ for (const effort of item.efforts) {
17
+ supportedModels.add(item.model);
18
+ const key = JSON.stringify([item.model, effort]);
19
+ if (!seenPairs.has(key)) { seenPairs.add(key); pairs.push({ model: item.model, effort }); }
20
+ }
21
+ }
22
+ return { platform: 'codex', pairs, unavailable: missing.filter(item => !supportedModels.has(item.model)) };
23
+ }
24
+
25
+ export async function discoverModelCatalog({ platform, env = process.env, fetchImpl = globalThis.fetch, listCodexModels, signal, timeoutMs = 10000 } = {}) {
26
+ if (!['codex', 'claude'].includes(platform)) return unavailable('unsupported_platform', 'Catalog is unavailable.');
27
+ if (signal?.aborted) return unavailable('aborted', 'Catalog lookup was cancelled.');
28
+ if (platform === 'codex' && typeof listCodexModels !== 'function') return unavailable('unavailable', 'Codex catalog is unavailable.');
29
+ const controller = new AbortController(); let timedOut = false, resolveAbort;
30
+ const abortPromise = new Promise(resolve => { resolveAbort = resolve; });
31
+ const onAbort = () => { controller.abort(); resolveAbort(); };
32
+ signal?.addEventListener('abort', onAbort, { once: true });
33
+ const timer = setTimeout(() => { timedOut = true; onAbort(); }, Number.isFinite(timeoutMs) && timeoutMs > 0 ? timeoutMs : 10000);
34
+ const operation = Promise.resolve().then(async () => platform === 'codex'
35
+ ? { status: 'ready', catalog: codexCatalog(await listCodexModels({ env, signal: controller.signal }), [env?.OPENAI_API_KEY, env?.ANTHROPIC_API_KEY].filter(value => typeof value === 'string' && value.length)) }
36
+ : discoverAnthropicCatalog({ apiKey: env?.ANTHROPIC_API_KEY, fetchImpl, signal: controller.signal }));
37
+ const settled = operation.then(value => ({ value }), () => ({ failed: true }));
38
+ try {
39
+ const first = await Promise.race([settled, abortPromise.then(() => ({ aborted: true }))]);
40
+ if (first.aborted) {
41
+ if (platform === 'codex') {
42
+ let graceTimer;
43
+ await Promise.race([settled, new Promise(resolve => { graceTimer = setTimeout(resolve, 2100); })]);
44
+ clearTimeout(graceTimer);
45
+ }
46
+ return timedOut ? unavailable('timeout', 'Catalog lookup timed out.') : unavailable('aborted', 'Catalog lookup was cancelled.');
47
+ }
48
+ return first.failed ? unavailable('unavailable', 'Catalog is unavailable.') : first.value;
49
+ } finally { clearTimeout(timer); signal?.removeEventListener('abort', onAbort); }
50
+ }
51
+
52
+ export function assertCatalogRoles({ platform, roles, catalog } = {}) {
53
+ const validRoles = validateRoles(roles, platform);
54
+ if (!catalog || catalog.platform !== platform || !Array.isArray(catalog.pairs)) throw new ModelValidationError('Catalog is unavailable.');
55
+ const pairs = new Set();
56
+ for (const pair of catalog.pairs) {
57
+ if (!pair || !validText(pair.model) || !validText(pair.effort)) throw new ModelValidationError('Catalog is invalid.');
58
+ pairs.add(JSON.stringify([pair.model, pair.effort]));
59
+ }
60
+ const effort = effortField(platform);
61
+ if (Object.values(validRoles).some(value => !pairs.has(JSON.stringify([value.model, value[effort]])))) throw new ModelValidationError('Roles contain a pair absent from the catalog.');
62
+ }
@@ -0,0 +1,103 @@
1
+ import path from 'node:path';
2
+ import { spawn } from 'node:child_process';
3
+ import { StringDecoder } from 'node:string_decoder';
4
+
5
+ const ENV_KEYS = ['PATH', 'HOME', 'USERPROFILE', 'HOMEDRIVE', 'HOMEPATH', 'APPDATA', 'LOCALAPPDATA', 'SYSTEMROOT', 'WINDIR', 'TMPDIR', 'TEMP', 'TMP', 'CODEX_HOME', 'XDG_CONFIG_HOME', 'XDG_DATA_HOME'];
6
+ const validText = value => typeof value === 'string' && value.length > 0 && value === value.trim() && !/[\p{Cc}\u2028\u2029]/u.test(value);
7
+ const failure = () => new Error('Codex catalog is unavailable.');
8
+
9
+ export async function listCodexModelCapabilities({ env = process.env, cwd, spawnImpl = spawn, signal, timeoutMs = 10000 } = {}) {
10
+ if (typeof cwd !== 'string' || !path.isAbsolute(cwd) || path.normalize(cwd) !== cwd || typeof spawnImpl !== 'function' || signal?.aborted) throw failure();
11
+ const childEnv = Object.fromEntries(ENV_KEYS.filter(key => typeof env?.[key] === 'string').map(key => [key, env[key]]));
12
+ const secrets = [env?.OPENAI_API_KEY, env?.ANTHROPIC_API_KEY].filter(value => typeof value === 'string' && value.length);
13
+ const containsSecret = value => secrets.some(secret => value.includes(secret));
14
+ let child;
15
+ try { child = spawnImpl('codex', ['app-server'], { cwd, env: childEnv, stdio: ['pipe', 'pipe', 'pipe'], shell: false }); }
16
+ catch { throw failure(); }
17
+ if (!child?.stdin || !child?.stdout || !child?.stderr || typeof child.on !== 'function' || typeof child.kill !== 'function') throw failure();
18
+
19
+ let closed = false, interrupted = false, cancelled = false, nextId = 0, buffer = '';
20
+ const pending = new Map(), decoder = new StringDecoder('utf8');
21
+ let resolveClosed;
22
+ const closedPromise = new Promise(resolve => { resolveClosed = resolve; });
23
+ const rejectPending = () => { interrupted = true; for (const entry of pending.values()) entry.reject(failure()); pending.clear(); };
24
+ const onClose = () => { closed = true; resolveClosed(); rejectPending(); };
25
+ const onError = () => rejectPending();
26
+ const onData = chunk => {
27
+ buffer += decoder.write(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
28
+ if (buffer.length > 1048576) { rejectPending(); return; }
29
+ let newline;
30
+ while ((newline = buffer.indexOf('\n')) !== -1) {
31
+ const line = buffer.slice(0, newline); buffer = buffer.slice(newline + 1);
32
+ if (!line.trim()) continue;
33
+ let message; try { message = JSON.parse(line); } catch { rejectPending(); return; }
34
+ if (message?.id === undefined) continue;
35
+ const entry = pending.get(message.id);
36
+ if (!entry) { rejectPending(); return; }
37
+ pending.delete(message.id);
38
+ if (message.error || !Object.hasOwn(message, 'result')) entry.reject(failure()); else entry.resolve(message.result);
39
+ }
40
+ };
41
+ child.on('close', onClose); child.on('error', onError);
42
+ child.stdout.on('data', onData); child.stdout.on('error', onError); child.stdin.on('error', onError); child.stderr.on('error', onError); child.stderr.resume();
43
+ const onAbort = () => { cancelled = true; rejectPending(); };
44
+ signal?.addEventListener('abort', onAbort, { once: true });
45
+ if (signal?.aborted) onAbort();
46
+ const timer = setTimeout(onAbort, Number.isFinite(timeoutMs) && timeoutMs > 0 ? timeoutMs : 10000);
47
+ const send = value => { if (interrupted || closed) throw failure(); child.stdin.write(JSON.stringify(value) + '\n'); };
48
+ const request = (method, params) => new Promise((resolve, reject) => {
49
+ if (interrupted || closed) { reject(failure()); return; }
50
+ const id = ++nextId; pending.set(id, { resolve, reject });
51
+ try { send({ id, method, params }); } catch { pending.delete(id); reject(failure()); }
52
+ });
53
+ let result, error;
54
+ try {
55
+ const initialized = await request('initialize', { clientInfo: { name: 'arc', title: 'ARC', version: '1.0.0' } });
56
+ if (!initialized || typeof initialized !== 'object' || Array.isArray(initialized) || !validText(initialized.userAgent)) throw failure();
57
+ send({ method: 'initialized', params: {} });
58
+ const models = new Map(), cursors = new Set(); let cursor;
59
+ for (let pageNumber = 0; pageNumber < 1000; pageNumber++) {
60
+ const page = await request('model/list', { limit: 100, includeHidden: true, ...(cursor ? { cursor } : {}) });
61
+ if (interrupted) throw failure();
62
+ if (!page || !Array.isArray(page.data) || !page.data.length || !(page.nextCursor === null || validText(page.nextCursor))) throw failure();
63
+ for (const descriptor of page.data) {
64
+ if (descriptor?.hidden === true) continue;
65
+ if (!descriptor || !validText(descriptor.model) || containsSecret(descriptor.model) || !Array.isArray(descriptor.supportedReasoningEfforts)) throw failure();
66
+ let efforts = models.get(descriptor.model);
67
+ if (!efforts) { efforts = []; models.set(descriptor.model, efforts); }
68
+ for (const option of descriptor.supportedReasoningEfforts) {
69
+ if (!validText(option?.reasoningEffort) || containsSecret(option.reasoningEffort)) throw failure();
70
+ if (!efforts.includes(option.reasoningEffort)) efforts.push(option.reasoningEffort);
71
+ }
72
+ }
73
+ if (page.nextCursor === null) { result = [...models].map(([model, efforts]) => ({ model, efforts })); break; }
74
+ if (cursors.has(page.nextCursor)) throw failure();
75
+ cursors.add(page.nextCursor); cursor = page.nextCursor;
76
+ }
77
+ if (!result) throw failure();
78
+ } catch { error = failure(); }
79
+ finally {
80
+ rejectPending();
81
+ try { child.stdin.end(); } catch { error = failure(); }
82
+ if (!closed) { try { child.kill('SIGTERM'); } catch { error = failure(); } }
83
+ const wait = async ms => { if (closed) return true; let timerId; const timeout = new Promise(resolve => { timerId = setTimeout(() => resolve(false), ms); }); const value = await Promise.race([closedPromise.then(() => true), timeout]); clearTimeout(timerId); return value; };
84
+ if (!await wait(1000)) {
85
+ try { child.kill('SIGKILL'); } catch { error = failure(); }
86
+ if (!await wait(1000)) error = failure();
87
+ }
88
+ if (!closed) {
89
+ // A failed close may still emit late errors; keep only a guard until close.
90
+ const guard = () => {};
91
+ const streams = [child.stdin, child.stdout, child.stderr];
92
+ child.on('error', guard); for (const stream of streams) stream.on('error', guard);
93
+ child.once('close', () => { child.removeListener('error', guard); for (const stream of streams) stream.removeListener('error', guard); });
94
+ for (const stream of streams) stream.destroy();
95
+ }
96
+ clearTimeout(timer); signal?.removeEventListener('abort', onAbort);
97
+ if (cancelled) error = failure();
98
+ child.stdout.removeListener('data', onData); child.stdout.removeListener('error', onError); child.stdin.removeListener('error', onError); child.stderr.removeListener('error', onError);
99
+ child.removeListener('close', onClose); child.removeListener('error', onError);
100
+ }
101
+ if (error) throw error;
102
+ return result;
103
+ }
@@ -5,8 +5,7 @@ export class ModelValidationError extends Error {
5
5
  }
6
6
  export function fail(message) { throw new ModelValidationError(message); }
7
7
  export function platformOf(config) {
8
- if (config?.platform === 'codex' && config.version === 1) return 'codex';
9
- if (config?.platform === 'claude' && config.version === 1) return 'claude';
8
+ if (['codex', 'claude'].includes(config?.platform) && [1, 2].includes(config.version)) return config.platform;
10
9
  fail('Unsupported model schema version');
11
10
  }
12
11
  export function effortField(platform) {
@@ -30,29 +29,38 @@ export function validateRoles(roles, platform) {
30
29
  }
31
30
  export const PRESETS = Object.freeze(['economy', 'balanced', 'smart']);
32
31
  export function validateModelConfig(config) {
33
- exactKeys(config, ['platform', 'version', 'preset', 'roles'], 'model config'); const platform = platformOf(config);
34
- if (!PRESETS.includes(config.preset)) fail('Unsupported performance preset');
32
+ const platform = platformOf(config);
33
+ const hasCustom = Object.hasOwn(config, 'custom');
34
+ exactKeys(config, ['platform', 'version', 'preset', 'roles', ...(hasCustom && config.version === 2 ? ['custom'] : [])], 'model config');
35
+ if (![...PRESETS, 'custom'].includes(config.preset) || (config.preset === 'custom' && !hasCustom)) fail('Unsupported performance preset or missing custom roles');
35
36
  exactKeys(config.roles, ROLES, 'roles');
36
37
  for (const role of ROLES) exactKeys(config.roles[role], PRESETS, `roles.${role}`);
37
38
  for (const preset of PRESETS) validateRoles(Object.fromEntries(ROLES.map(role => [role, config.roles[role][preset]])), platform);
38
- return structuredClone(config);
39
+ if (hasCustom) validateRoles(config.custom, platform);
40
+ return { ...structuredClone(config), version: 2 };
39
41
  }
40
42
  export function resolveModelSettings(config) {
41
43
  const validated = validateModelConfig(config);
42
- return { platform: validated.platform, roles: Object.fromEntries(ROLES.map(role => [role, validated.roles[role][validated.preset]])) };
44
+ return { platform: validated.platform, roles: validated.preset === 'custom' ? validated.custom : Object.fromEntries(ROLES.map(role => [role, validated.roles[role][validated.preset]])) };
43
45
  }
44
46
  export function parseToml(text) {
45
47
  try { return parse(typeof text === 'string' ? text : new TextDecoder('utf-8', { fatal: true }).decode(text), { integersAsBigInt: true }); }
46
48
  catch (cause) { throw new ModelValidationError(`Invalid TOML: ${cause.message}`, { cause }); }
47
49
  }
48
50
  export function loadModelConfig(text, platform) {
49
- effortField(platform); const config = parseToml(text); exactKeys(config, ['version', 'preset', 'roles'], 'model file (reinstall or update legacy role-only settings)');
50
- if (config.version !== 1n) fail(`Expected ${platform} integer schema version 1; reinstall or update models.toml`);
51
- return validateModelConfig({ platform, version: 1, preset: config.preset, roles: config.roles });
51
+ effortField(platform); const config = parseToml(text);
52
+ if (config.version !== 1n && config.version !== 2n) fail(`Expected ${platform} integer schema version 1 or 2; reinstall or update models.toml`);
53
+ const hasCustom = Object.hasOwn(config, 'custom');
54
+ exactKeys(config, ['version', 'preset', 'roles', ...(hasCustom && config.version === 2n ? ['custom'] : [])], 'model file (reinstall or update legacy role-only settings)');
55
+ return validateModelConfig({ ...config, platform, version: Number(config.version) });
52
56
  }
53
- export function serializeModelConfig(config) { const { version, preset, roles } = validateModelConfig(config); return stringify({ version, preset, roles }); }
57
+ export function serializeModelConfig(config) { const { platform, ...file } = validateModelConfig(config); return stringify(file); }
54
58
  export function applyPreset(config, preset) {
55
59
  const validated = validateModelConfig(config);
56
- if (!PRESETS.includes(preset)) fail('Unsupported performance preset');
60
+ if (![...PRESETS, 'custom'].includes(preset) || (preset === 'custom' && !validated.custom)) fail('Unsupported performance preset or missing custom roles');
57
61
  return { ...validated, preset };
58
62
  }
63
+ export function applyCustomRoles(config, roles) {
64
+ const validated = validateModelConfig(config);
65
+ return { ...validated, preset: 'custom', custom: validateRoles(roles, validated.platform) };
66
+ }
@@ -1,2 +1,2 @@
1
- export { loadModelConfig, applyPreset, serializeModelConfig, resolveModelSettings, ModelValidationError } from './config.mjs';
1
+ export { loadModelConfig, applyPreset, applyCustomRoles, serializeModelConfig, resolveModelSettings, ModelValidationError } from './config.mjs';
2
2
  export { renderProfiles } from './profiles.mjs';
@@ -1,4 +1,3 @@
1
- import {validateClaudeHookCommand} from '../policy/claude-hook.mjs';
2
1
  import { parseToml, exactKeys, validateRoles, effortField, ROLES, fail } from './config.mjs';
3
2
  const tools = {
4
3
  arc_scouter: ['Read', 'Grep', 'Glob', 'WebFetch', 'WebSearch'],
@@ -74,43 +73,6 @@ function renderCodex(text, role, settings) {
74
73
  if (checked.name !== role || Object.entries(settings).some(([key, value]) => checked[key] !== value)) fail('Rendered Codex profile does not match settings');
75
74
  return Buffer.from(output);
76
75
  }
77
- // Validate the consumed hook subset without rewriting any hook/user bytes.
78
- // The two ARC entries are exact contracts; user command entries remain data.
79
- function validateClaudeHooks(lines){
80
- const managed=new Map();let event;const events=new Set();
81
- if(lines[0]!=='hooks:')fail('Unsupported managed Claude hooks');
82
- for(let i=1;i<lines.length;){
83
- const heading=/^ (PreToolUse|PostToolUse|SubagentStart):$/.exec(lines[i]);
84
- if(heading){event=heading[1];if(events.has(event))fail('Duplicate Claude hook event');events.add(event);i++;continue;}
85
- if(!event)fail('Unsupported Claude hook event');
86
- const matcher=/^ - matcher: (.+)$/.exec(lines[i]);
87
- let parsedMatcher,command;
88
- try{
89
- if(!matcher)throw Error('Missing matcher');
90
- parsedMatcher=matcher[1].startsWith('"')?JSON.parse(matcher[1]):matcher[1];
91
- if(typeof parsedMatcher!=='string'||!parsedMatcher||/[\r\n\x00]/.test(parsedMatcher)||(!matcher[1].startsWith('"')&&!/^[A-Za-z][A-Za-z0-9_|]*$/.test(parsedMatcher)))throw Error('Invalid matcher');
92
- if(lines[i+1]!==' hooks:'||lines[i+2]!==' - type: command'||!lines[i+3]?.startsWith(' command: '))throw Error('Unsupported command entry');
93
- command=JSON.parse(lines[i+3].slice(' command: '.length));
94
- if(typeof command!=='string'||!command||/[\r\n\x00]/.test(command))throw Error('Invalid command');
95
- }catch{fail('Unsupported managed Claude hooks');}
96
- let isManaged=false;
97
- try{validateClaudeHookCommand(command);isManaged=true;}catch{}
98
- const hasFailure=lines[i+4]?.startsWith(' onFailure:');
99
- if(isManaged){
100
- if(lines[i+4]!==' onFailure: block')fail('Unsupported managed Claude failure behavior');
101
- const encoded=/"([A-Za-z0-9+/]+={0,2})"$/.exec(command)[1];
102
- const url=new URL(Buffer.from(encoded,'base64').toString('utf8'));const file=url.pathname.split('/').at(-1);
103
- const expected=file==='runtime-gate.mjs'?'"*"':'Bash';
104
- if(event!=='PreToolUse'||matcher[1]!==expected||!hasFailure||managed.has(file))fail('Malformed or duplicate managed Claude hooks');
105
- managed.set(file,url.href.slice(0,url.href.lastIndexOf('/')+1));
106
- }else{
107
- if(hasFailure&&![' onFailure: continue',' onFailure: block'].includes(lines[i+4]))fail('Unsupported Claude failure behavior');
108
- if(/runtime-gate\.mjs|role-git-guard\.mjs/.test(command))fail('Unrecognized managed Claude hook command');
109
- }
110
- i+=hasFailure?5:4;
111
- }
112
- if(managed.size!==1||!managed.has('role-git-guard.mjs'))fail('Missing or mismatched managed Claude hooks');
113
- }
114
76
  function renderClaude(text, role, settings) {
115
77
  const lines = linesOf(text); if (lines[0]?.replace(/\r?\n$/, '') !== '---') fail('Missing opening Claude frontmatter');
116
78
  const fields = {}, indices = {}; let current, closed = false;
@@ -119,7 +81,6 @@ function renderClaude(text, role, settings) {
119
81
  if(line==='hooks:') {
120
82
  if(Object.hasOwn(fields,'hooks'))fail('Duplicate Claude hooks');
121
83
  let end=i+1;while(end<lines.length&&/^[ \t]/.test(lines[end]))end++;
122
- validateClaudeHooks(lines.slice(i,end).map(x=>x.replace(/\r?\n$/,'')));
123
84
  fields.hooks=true;indices.hooks=i;i=end-1;current='hooks';continue;
124
85
  }
125
86
  const item = /^ - ([A-Za-z][A-Za-z0-9]*)$/.exec(line);
@@ -130,7 +91,7 @@ function renderClaude(text, role, settings) {
130
91
  if (key === 'tools') { if (value !== undefined) fail('Claude tools must be an explicit list'); fields.tools = []; }
131
92
  else { if (!value || value !== value.trim() || /[\t\x00{}\[\]#&*!|>"']/.test(value) || value.includes(': ')) fail('Claude frontmatter requires plain scalar values'); fields[key] = value; }
132
93
  }
133
- if (!closed || !['name', 'description', 'model', 'effort', 'tools', 'hooks'].every(key => Object.hasOwn(fields, key)) || fields.name !== role.replace('_', '-') || JSON.stringify(fields.tools) !== JSON.stringify(tools[role])) fail('Invalid Claude profile identity or role tools');
94
+ if (!closed || !['name', 'description', 'model', 'effort', 'tools'].every(key => Object.hasOwn(fields, key)) || fields.name !== role.replace('_', '-') || JSON.stringify(fields.tools) !== JSON.stringify(tools[role])) fail('Invalid Claude profile identity or role tools');
134
95
  if (!/^[A-Za-z0-9][A-Za-z0-9._:/@+\-]*$/.test(fields.model) || !['low','medium','high','xhigh','max'].includes(fields.effort)) fail('Invalid Claude profile model or effort');
135
96
  for (const key of ['model', 'effort']) { const index = indices[key], eol = /\r?\n$/.exec(lines[index])?.[0] ?? ''; lines[index] = `${key}: ${settings[key]}${eol}`; }
136
97
  return Buffer.from(lines.join(''));