@yunhozz/arc 0.0.0-stage → 0.1.1

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 (125) hide show
  1. package/MANIFEST.sha256 +96 -0
  2. package/README.md +61 -2
  3. package/bin/arc.mjs +3 -0
  4. package/claude/agents/arc-coder.md +67 -0
  5. package/claude/agents/arc-planner.md +74 -0
  6. package/claude/agents/arc-qa.md +68 -0
  7. package/claude/agents/arc-reviewer.md +65 -0
  8. package/claude/agents/arc-scouter.md +63 -0
  9. package/claude/skill/SKILL.md +370 -0
  10. package/claude/skill/hooks/git-policy.json +208 -0
  11. package/claude/skill/hooks/role-git-guard.mjs +98 -0
  12. package/claude/skill/models.toml +64 -0
  13. package/claude/skill/references/commit.md +77 -0
  14. package/claude/skill/references/git-recovery.md +46 -0
  15. package/claude/skill/references/handoff-prep.md +102 -0
  16. package/claude/skill/references/handoff.md +100 -0
  17. package/claude/skill/references/limits.md +42 -0
  18. package/claude/skill/references/planning.md +250 -0
  19. package/claude/skill/references/superpowers/LICENSE +21 -0
  20. package/claude/skill/references/superpowers/brainstorming/SKILL.md +225 -0
  21. package/claude/skill/references/superpowers/brainstorming/builder-check-prompt.md +23 -0
  22. package/claude/skill/references/superpowers/brainstorming/scripts/frame-template.html +213 -0
  23. package/claude/skill/references/superpowers/brainstorming/scripts/helper.js +167 -0
  24. package/claude/skill/references/superpowers/brainstorming/scripts/server.cjs +723 -0
  25. package/claude/skill/references/superpowers/brainstorming/scripts/start-server.sh +209 -0
  26. package/claude/skill/references/superpowers/brainstorming/scripts/stop-server.sh +120 -0
  27. package/claude/skill/references/superpowers/brainstorming/visual-companion.md +299 -0
  28. package/claude/skill/references/superpowers/provenance.json +26 -0
  29. package/claude/skill/references/superpowers/test-driven-development/SKILL.md +330 -0
  30. package/claude/skill/references/superpowers/test-driven-development/writing-good-tests.md +198 -0
  31. package/claude/skill/references/superpowers/writing-plans/SKILL.md +204 -0
  32. package/claude/skill/references/verification.md +125 -0
  33. package/claude/skill/references/visual-companion.md +16 -0
  34. package/claude/skill/scripts/apply-models.py +31 -0
  35. package/claude/skill/scripts/model_config.py +211 -0
  36. package/codex/agents/arc-coder.toml +37 -0
  37. package/codex/agents/arc-planner.toml +38 -0
  38. package/codex/agents/arc-qa.toml +24 -0
  39. package/codex/agents/arc-reviewer.toml +25 -0
  40. package/codex/agents/arc-scouter.toml +37 -0
  41. package/codex/skill/SKILL.md +356 -0
  42. package/codex/skill/models.toml +64 -0
  43. package/codex/skill/references/commit.md +100 -0
  44. package/codex/skill/references/git-recovery.md +64 -0
  45. package/codex/skill/references/handoff-prep.md +153 -0
  46. package/codex/skill/references/handoff-worktree.md +93 -0
  47. package/codex/skill/references/handoff.md +181 -0
  48. package/codex/skill/references/limits.md +43 -0
  49. package/codex/skill/references/planning.md +287 -0
  50. package/codex/skill/references/superpowers/LICENSE +21 -0
  51. package/codex/skill/references/superpowers/brainstorming/SKILL.md +225 -0
  52. package/codex/skill/references/superpowers/brainstorming/builder-check-prompt.md +23 -0
  53. package/codex/skill/references/superpowers/brainstorming/scripts/frame-template.html +213 -0
  54. package/codex/skill/references/superpowers/brainstorming/scripts/helper.js +167 -0
  55. package/codex/skill/references/superpowers/brainstorming/scripts/server.cjs +723 -0
  56. package/codex/skill/references/superpowers/brainstorming/scripts/start-server.sh +209 -0
  57. package/codex/skill/references/superpowers/brainstorming/scripts/stop-server.sh +120 -0
  58. package/codex/skill/references/superpowers/brainstorming/visual-companion.md +299 -0
  59. package/codex/skill/references/superpowers/provenance.json +26 -0
  60. package/codex/skill/references/superpowers/test-driven-development/SKILL.md +330 -0
  61. package/codex/skill/references/superpowers/test-driven-development/writing-good-tests.md +198 -0
  62. package/codex/skill/references/superpowers/writing-plans/SKILL.md +204 -0
  63. package/codex/skill/references/verification.md +163 -0
  64. package/codex/skill/references/visual-companion.md +23 -0
  65. package/codex/skill/scripts/apply-models.py +207 -0
  66. package/codex/skill/scripts/model_config.py +362 -0
  67. package/install.command +5 -0
  68. package/lib/cli/choices.mjs +47 -0
  69. package/lib/cli/completion.mjs +53 -0
  70. package/lib/cli/index.mjs +55 -0
  71. package/lib/cli/preset.mjs +29 -0
  72. package/lib/cli/progress.mjs +10 -0
  73. package/lib/cli/root.mjs +20 -0
  74. package/lib/cli/uninstall.mjs +17 -0
  75. package/lib/installer/acquisition.mjs +77 -0
  76. package/lib/installer/global-config.mjs +43 -0
  77. package/lib/installer/install.mjs +28 -0
  78. package/lib/installer/language.mjs +13 -0
  79. package/lib/installer/limits.mjs +21 -0
  80. package/lib/installer/manifest.mjs +37 -0
  81. package/lib/installer/package-references.mjs +32 -0
  82. package/lib/installer/plan.mjs +62 -0
  83. package/lib/installer/preset.mjs +26 -0
  84. package/lib/installer/runtime-config.mjs +85 -0
  85. package/lib/installer/shell-completion.mjs +29 -0
  86. package/lib/installer/stage-cleanup.mjs +40 -0
  87. package/lib/installer/superpowers.mjs +113 -0
  88. package/lib/installer/targets.mjs +55 -0
  89. package/lib/installer/transaction.mjs +91 -0
  90. package/lib/installer/uninstall.mjs +32 -0
  91. package/lib/limits.mjs +13 -0
  92. package/lib/models/config.mjs +58 -0
  93. package/lib/models/index.mjs +2 -0
  94. package/lib/models/profiles.mjs +111 -0
  95. package/lib/policy/claude-hook.mjs +40 -0
  96. package/lib/policy/render.mjs +74 -0
  97. package/lib/policy/role-policy.json +577 -0
  98. package/lib/safe-fs/index.mjs +67 -0
  99. package/lib/safe-fs/native.mjs +54 -0
  100. package/native/binding.c +157 -0
  101. package/native/binding.gyp +1 -0
  102. package/package.json +43 -4
  103. package/prebuilds/darwin-arm64/arc_safe_fs.node +0 -0
  104. package/prebuilds/darwin-x64/arc_safe_fs.node +0 -0
  105. package/prebuilds/index.json +64 -0
  106. package/prebuilds/linux-arm64-gnu/arc_safe_fs.node +0 -0
  107. package/prebuilds/linux-x64-gnu/arc_safe_fs.node +0 -0
  108. package/scripts/build-native.mjs +36 -0
  109. package/scripts/check-installer-parity.mjs +59 -0
  110. package/scripts/update-manifest.mjs +28 -0
  111. package/scripts/update-role-policy.mjs +15 -0
  112. package/scripts/update-superpowers.mjs +4 -0
  113. package/scripts/vendor-toml.mjs +40 -0
  114. package/vendor/toml/LICENSE +24 -0
  115. package/vendor/toml/dist/date.js +156 -0
  116. package/vendor/toml/dist/error.js +67 -0
  117. package/vendor/toml/dist/extract.js +293 -0
  118. package/vendor/toml/dist/index.js +34 -0
  119. package/vendor/toml/dist/parse.js +164 -0
  120. package/vendor/toml/dist/primitive.js +151 -0
  121. package/vendor/toml/dist/stringify.js +219 -0
  122. package/vendor/toml/dist/struct.js +156 -0
  123. package/vendor/toml/dist/util.js +57 -0
  124. package/vendor/toml/package.json +6 -0
  125. package/vendor/toml/provenance.json +35 -0
@@ -0,0 +1,113 @@
1
+ import path from 'node:path';
2
+ import {createHash} from 'node:crypto';
3
+ import {readCheckedFile,sameIdentity,typeOf,metadataOf} from './targets.mjs';
4
+ export const REQUIRED=Object.freeze({
5
+ 'brainstorming':['SKILL.md','visual-companion.md','scripts/start-server.sh','scripts/stop-server.sh','scripts/server.cjs','scripts/helper.js','scripts/frame-template.html'],
6
+ 'writing-plans':['SKILL.md'],
7
+ 'test-driven-development':['SKILL.md','writing-good-tests.md'],
8
+ });
9
+ export const SKILLS=Object.freeze(Object.keys(REQUIRED));
10
+ export const REPOSITORY='https://github.com/obra/superpowers';
11
+ export const DYNAMIC_ROOTS=Object.freeze(['codex','claude'].map(platform=>`${platform}/skill/references/superpowers`));
12
+ export const isDynamicReferencePath=name=>DYNAMIC_ROOTS.some(root=>name===root||name.startsWith(root+'/'));
13
+ const hash=bytes=>createHash('sha256').update(bytes).digest('hex');
14
+ function failure(message){throw new Error(message);}
15
+ export function snapshotSkill(root,absolute,required){
16
+ const rootBefore=root.stat(),entries=new Map(),snapshot=new Map();
17
+ function walk(directory,prefix=''){
18
+ const before=directory.stat(),names=directory.readDir();
19
+ for(const name of names){const relative=prefix?prefix+'/'+name:name,stat=directory.lstat(name),type=typeOf(stat);
20
+ if(type===0o040000){const child=directory.openDir(name);try{if(!sameIdentity(stat,child.stat()))failure('Acquired directory changed during validation');entries.set(relative,{type:'directory',mode:stat.mode});snapshot.set(relative,{...metadataOf(stat),type:'directory'});walk(child,relative);}finally{child.close();}}
21
+ else if(type===0o100000){const file=readCheckedFile(directory,name,stat);entries.set(relative,{type:'file',bytes:file.bytes,mode:stat.mode});snapshot.set(relative,file.metadata);}
22
+ else if(type===0o120000){const raw=directory.readlink(name);const after=directory.lstat(name);if(!sameIdentity(stat,after))failure('Acquired symlink changed during validation');entries.set(relative,{type:'symlink',raw,mode:stat.mode});snapshot.set(relative,{...metadataOf(stat),type:'symlink',link:raw});}
23
+ else failure('Acquired tree contains a special file');
24
+ }if(!sameIdentity(before,directory.stat()))failure('Acquired directory changed during validation');
25
+ }
26
+ walk(root);
27
+ const resolvedLinks=new Map();
28
+ function targetOf(name,trail=new Set()){
29
+ if(trail.has(name))failure('Acquired symlink cycle');
30
+ if(resolvedLinks.has(name))return resolvedLinks.get(name);
31
+ const entry=entries.get(name);if(!entry||entry.type!=='symlink')failure('Invalid acquired link');
32
+ const active=new Set(trail);active.add(name);
33
+ const parent=path.posix.dirname(name);const target=resolvePath(entry.raw,parent==='.'?[]:parent.split('/'),active);
34
+ resolvedLinks.set(name,target);return target;
35
+ }
36
+ function resolvePath(raw,base=[],trail=new Set()){
37
+ let current=[...base],parts;
38
+ if(raw.startsWith('/')){if(!raw.startsWith(absolute+'/'))failure('Acquired link escapes its root');current=[];parts=raw.slice(absolute.length+1).split('/');}
39
+ else parts=raw.split('/');
40
+ for(let i=0;i<parts.length;i++){
41
+ const part=parts[i];if(!part||part==='.')continue;
42
+ if(part==='..'){if(!current.length)failure('Acquired link escapes its root');current.pop();continue;}
43
+ const name=[...current,part].join('/'),entry=entries.get(name);if(!entry)failure('Acquired link is dangling');
44
+ const canonical=entry.type==='symlink'?targetOf(name,trail):name;
45
+ if(i<parts.length-1&&entries.get(canonical)?.type!=='directory')failure('Acquired link parent is not a directory');
46
+ current=canonical.split('/');
47
+ }
48
+ const result=current.join('/');if(!result||!entries.has(result))failure('Acquired link resolves to its root or a missing entry');return result;
49
+ }
50
+ for(const[name,entry]of entries)if(entry.type==='symlink')targetOf(name);
51
+ for(const[name,target]of resolvedLinks){const entry=entries.get(name);entry.target=target;entry.link=path.posix.relative(path.posix.dirname(name),target);delete entry.raw;}
52
+ // Build directory edges, including directory symlinks, to reject cycles that
53
+ // resolve individually but would create recursive staging traversals.
54
+ const graph=new Map([['',new Set()]]);for(const[relative,entry]of entries){const parent=path.posix.dirname(relative)==='.'?'':path.posix.dirname(relative);if(!graph.has(parent))graph.set(parent,new Set());if(entry.type==='directory'){graph.get(parent).add(relative);if(!graph.has(relative))graph.set(relative,new Set());}else if(entry.type==='symlink'&&entries.get(entry.target)?.type==='directory')graph.get(parent).add(entry.target);}
55
+ const active=new Set(),done=new Set();function visit(node){if(active.has(node))failure('Acquired directory links create a cycle');if(done.has(node))return;active.add(node);for(const next of graph.get(node)??[])visit(next);active.delete(node);done.add(node);}visit('');
56
+ for(const name of required){const actual=resolvePath(name);if(entries.get(actual)?.type!=='file')failure('Required acquired skill file is missing or not regular');}
57
+ if(!sameIdentity(rootBefore,root.stat()))failure('Acquired root changed during validation');
58
+ return{root:absolute,rootMetadata:{...metadataOf(rootBefore),type:'directory',entries:root.readDir()},entries,snapshot};
59
+ }
60
+
61
+ export function createReferenceBundle({skills,license,revision,fetchedAt=new Date().toISOString()}){
62
+ const entries=new Map([['LICENSE',{type:'file',bytes:Buffer.from(license),mode:0o644n}]]);
63
+ for(const name of SKILLS){const tree=skills.get(name);if(!tree)failure('Required skill is missing');entries.set(name,{type:'directory',mode:tree.rootMetadata.mode});for(const[key,entry]of tree.entries)entries.set(name+'/'+key,{...entry});}
64
+ const provenance={schemaVersion:1,repository:REPOSITORY,revision,fetchedAt,skills:[...SKILLS],files:{},links:{}};
65
+ for(const[name,entry]of entries){if(entry.type==='file')provenance.files[name]=hash(entry.bytes);else if(entry.type==='symlink')provenance.links[name]=entry.link;}
66
+ entries.set('provenance.json',{type:'file',bytes:Buffer.from(JSON.stringify(provenance,null,2)+'\n'),mode:0o644n});
67
+ const bundle={entries,provenance};validateReferenceBundle(bundle);return bundle;
68
+ }
69
+ export function validateReferenceBundle(bundle){
70
+ const entries=bundle?.entries,p=bundle?.provenance;
71
+ if(!(entries instanceof Map)||!p||p.schemaVersion!==1||p.repository!==REPOSITORY||!/^[a-f0-9]{40,64}$/.test(p.revision)||typeof p.fetchedAt!=='string'||!Number.isFinite(Date.parse(p.fetchedAt))||new Date(p.fetchedAt).toISOString()!==p.fetchedAt||JSON.stringify(p.skills)!==JSON.stringify(SKILLS)||!p.files||typeof p.files!=='object'||Array.isArray(p.files)||!p.links||typeof p.links!=='object'||Array.isArray(p.links))failure('Invalid reference provenance');
72
+ const files={},links={};
73
+ for(const[name,entry]of entries){
74
+ if(typeof name!=='string'||name.includes('\\')||name.includes('\0')||name.split('/').some(part=>!part||part==='.'||part==='..')||!['LICENSE','provenance.json',...SKILLS].includes(name.split('/')[0]))failure('Unexpected reference path');
75
+ const parent=path.posix.dirname(name);if(parent!=='.'&&entries.get(parent)?.type!=='directory')failure('Reference parent is missing');
76
+ if(entry.type==='file'){if(!Buffer.isBuffer(entry.bytes))failure('Invalid reference bytes');if(name!=='provenance.json')files[name]=hash(entry.bytes);}
77
+ else if(entry.type==='symlink'){if(!SKILLS.includes(name.split('/')[0])||typeof entry.link!=='string'||path.posix.isAbsolute(entry.link))failure('Unsafe reference link');const resolved=path.posix.normalize(path.posix.join(parent,entry.link));if(!resolved.startsWith(name.split('/')[0]+'/')||!entries.has(resolved))failure('Reference link escapes or is missing');links[name]=entry.link;}
78
+ else if(entry.type!=='directory')failure('Unsafe reference entry');
79
+ }
80
+ for(const name of ['LICENSE','provenance.json'])if(entries.get(name)?.type!=='file')failure('Reference metadata file missing');
81
+ if(!entries.get('LICENSE').bytes.length)failure('Reference license is empty');
82
+ let recorded;try{recorded=JSON.parse(entries.get('provenance.json').bytes);}catch{failure('Malformed reference provenance');}
83
+ const equal=(a,b)=>Object.keys(a).sort().join('\0')===Object.keys(b).sort().join('\0')&&Object.keys(a).every(key=>a[key]===b[key]);
84
+ if(JSON.stringify(recorded)!==JSON.stringify(p)||!equal(files,p.files)||!equal(links,p.links))failure('Reference inventory or hash mismatch');
85
+ function resolve(name,trail=new Set()){
86
+ if(trail.has(name))failure('Reference link cycle');const entry=entries.get(name);if(!entry)failure('Required reference is missing');
87
+ if(entry.type==='symlink'){const next=new Set(trail);next.add(name);return resolve(path.posix.normalize(path.posix.join(path.posix.dirname(name),entry.link)),next);}
88
+ return entry;
89
+ }
90
+ for(const[name,entry]of entries)if(entry.type==='symlink')resolve(name);
91
+ const graph=new Map([['',new Set()]]);
92
+ for(const[name,entry]of entries){const parent=path.posix.dirname(name)==='.'?'':path.posix.dirname(name);if(!graph.has(parent))graph.set(parent,new Set());if(entry.type==='directory'){graph.get(parent).add(name);if(!graph.has(name))graph.set(name,new Set());}else if(entry.type==='symlink'&&resolve(name).type==='directory')graph.get(parent).add(path.posix.normalize(path.posix.join(path.posix.dirname(name),entry.link)));}
93
+ const active=new Set(),done=new Set();function visit(name){if(active.has(name))failure('Reference directory link cycle');if(done.has(name))return;active.add(name);for(const next of graph.get(name)??[])visit(next);active.delete(name);done.add(name);}visit('');
94
+
95
+ for(const[skill,names]of Object.entries(REQUIRED)){if(entries.get(skill)?.type!=='directory')failure('Required skill tree missing');for(const name of names){let parts=(skill+'/'+name).split('/'),current='';for(let i=0;i<parts.length;i++){current=current?current+'/'+parts[i]:parts[i];const e=entries.get(current);if(e?.type==='symlink'){current=path.posix.normalize(path.posix.join(path.posix.dirname(current),e.link));resolve(current);}if(i===parts.length-1&&resolve(current).type!=='file')failure('Required reference is not regular');}}}
96
+ return bundle;
97
+ }
98
+ export function readReferenceBundle({safeFs,root,directory:held}){
99
+ const directory=held??safeFs.openRoot(root);try{
100
+ const allowed=new Set(['LICENSE','provenance.json',...SKILLS]);if(directory.readDir().some(name=>!allowed.has(name)))failure('Unknown reference tree');
101
+ const skills=new Map();for(const name of SKILLS){const skill=directory.openDir(name);try{skills.set(name,snapshotSkill(skill,path.join(root,name),REQUIRED[name]));}finally{skill.close();}}
102
+ const license=readCheckedFile(directory,'LICENSE'),provenance=readCheckedFile(directory,'provenance.json');let p;try{p=JSON.parse(provenance.bytes);}catch{failure('Malformed reference provenance');}
103
+ const entries=new Map([['LICENSE',{type:'file',bytes:license.bytes,mode:license.metadata.mode}],['provenance.json',{type:'file',bytes:provenance.bytes,mode:provenance.metadata.mode}]]);
104
+ for(const[name,tree]of skills){entries.set(name,{type:'directory',mode:tree.rootMetadata.mode});for(const[key,value]of tree.entries)entries.set(name+'/'+key,value);}
105
+ return validateReferenceBundle({entries,provenance:p});
106
+ }finally{if(!held)directory.close();}
107
+ }
108
+ export function composeArcSkillTree(skillFiles,references){
109
+ validateReferenceBundle(references);const entries=new Map();
110
+ for(const[name,bytes]of skillFiles){if(name==='references/superpowers'||name.startsWith('references/superpowers/'))continue;const parts=name.split('/');if(parts.some(part=>!part||part==='.'||part==='..'))failure('Unsafe ARC path');for(let i=1;i<parts.length;i++)entries.set(parts.slice(0,i).join('/'),{type:'directory',mode:0o700n});entries.set(name,{type:'file',bytes:Buffer.from(bytes),mode:0o600n});}
111
+ entries.set('references',{type:'directory',mode:0o700n});entries.set('references/superpowers',{type:'directory',mode:0o700n});
112
+ for(const[name,entry]of references.entries)entries.set('references/superpowers/'+name,{...entry});return entries;
113
+ }
@@ -0,0 +1,55 @@
1
+ import path from 'node:path';import crypto from 'node:crypto';
2
+ export class TargetConfigurationError extends Error{constructor(message,options){super(message,options);this.name='TargetConfigurationError';this.code='ARC_TARGET_CONFIGURATION';}}
3
+ const fail=message=>{throw new TargetConfigurationError(message);};
4
+ export function resolveTarget(platform,{userHome,env={},cwd}={}){
5
+ if(!['codex','claude'].includes(platform))fail('Target must be codex or claude');
6
+ if(typeof userHome!=='string'||!path.isAbsolute(userHome)||!userHome.trim())fail('userHome must be an absolute user directory');
7
+ userHome=path.resolve(userHome);if(!env||typeof env!=='object')fail('env must be an environment object');
8
+ const expand=value=>value==='~'?userHome:value.startsWith('~/')?path.join(userHome,value.slice(2)):value;
9
+ let home;if(platform==='codex'){const raw=env.CODEX_HOME;if(raw!==undefined&&(typeof raw!=='string'||!raw.trim()))fail('CODEX_HOME is empty or invalid');const expanded=expand(raw??path.join(userHome,'.codex'));if(path.isAbsolute(expanded))home=path.resolve(expanded);else{const base=cwd??process.cwd();if(typeof base!=='string'||!path.isAbsolute(base))fail('cwd must be absolute for relative CODEX_HOME');home=path.resolve(base,expanded);}}
10
+ else{home=path.join(userHome,'.claude');const raw=env.CLAUDE_CONFIG_DIR;if(raw!==undefined&&(typeof raw!=='string'||!raw.trim()||!path.isAbsolute(expand(raw))||path.resolve(expand(raw))!==home))fail('CLAUDE_CONFIG_DIR must select the default ~/.claude directory');}
11
+ if(home==='/'||home===path.resolve(userHome))fail('Target home must not be the filesystem or user-home root');
12
+ const profileNames=['scouter','planner','coder','qa','reviewer'].map(role=>`arc-${role}.${platform==='codex'?'toml':'md'}`);
13
+ return Object.freeze({platform,home,userHome:path.resolve(userHome),payloadPrefix:platform,profileNames:Object.freeze(profileNames),instructionPath:path.join(home,platform==='codex'?'AGENTS.md':'CLAUDE.md'),runtimeConfigPath:path.join(home,platform==='codex'?'config.toml':'settings.json'),globalPaths:Object.freeze(platform==='codex'?[path.join(home,'AGENTS.md'),path.join(home,'config.toml')]:[path.join(home,'CLAUDE.md'),path.join(home,'settings.json')])});
14
+ }
15
+ export function typeOf(metadata){return Number(metadata.mode&0o170000n);}
16
+ export function sameObjectIdentity(a,b){return ['dev','ino','mode'].every(key=>a[key]===b[key]);}
17
+ export function sameIdentity(a,b){return ['dev','ino','mode','size','mtimeNs','ctimeNs'].every(key=>a[key]===b[key]);}
18
+ export function metadataOf(stat){return Object.fromEntries(['dev','ino','mode','size','atimeNs','mtimeNs','ctimeNs'].map(key=>[key,stat[key]]));}
19
+ export function readCheckedFile(directory,name,before=directory.lstat(name)){
20
+ const file=directory.openFile(name,{read:true});try{const opened=file.stat();if(!sameIdentity(before,opened))fail('File identity changed before reading');const bytes=file.readAll();const after=file.stat();if(!sameIdentity(opened,after))fail('File changed during read');return{bytes,metadata:{...metadataOf(after),type:'file',sha256:crypto.createHash('sha256').update(bytes).digest('hex')}};}finally{file.close();}
21
+ }
22
+ // Traverse every existing component through held directory descriptors. Missing
23
+ // targets are recorded without creating their parents or opening a fallback path.
24
+ export function inspectPath(safeFs,absolute,{kind,tree=false,allowLinks=false,snapshot=new Map(),read=false,allowLeafLink=false}={}){
25
+ if(!path.isAbsolute(absolute))fail('Inspection requires an absolute path');
26
+ const parts=absolute.split('/').filter(Boolean);if(parts.some(p=>p==='.'||p==='..'))fail('Unsafe destination component');
27
+ let directory=safeFs.openRoot('/');let current='';
28
+ try{
29
+ for(let index=0;index<parts.length;index++){
30
+ const name=parts[index];current+='/'+name;let stat;
31
+ try{stat=directory.lstat(name);}catch(error){if(error.code==='ENOENT'){snapshot.set(absolute,null);return{exists:false};}throw error;}
32
+ const type=typeOf(stat),leaf=index===parts.length-1;
33
+ if(type===0o120000){if(leaf&&allowLeafLink){const link=directory.readlink(name);snapshot.set(current,{...metadataOf(stat),type:'symlink',link});return{exists:true,type:'symlink',link};}fail(`Symlink destination is unsafe: ${current}`);}
34
+ if(!leaf||kind==='directory'){
35
+ if(type!==0o040000)fail(`Expected destination directory: ${current}`);
36
+ const next=directory.openDir(name);if(!sameObjectIdentity(stat,next.stat())){next.close();fail('Directory identity changed during traversal');}directory.close();directory=next;
37
+ if(leaf){snapshot.set(current,{...metadataOf(stat),type:'directory'});if(tree)inspectTree(directory,current,{allowLinks,snapshot});return{exists:true,type:'directory'};}
38
+ }else{
39
+ if(type!==0o100000)fail(`Expected regular destination file: ${current}`);
40
+ const result=readCheckedFile(directory,name,stat);snapshot.set(current,result.metadata);return{exists:true,type:'file',...(read?{bytes:result.bytes}:{})};
41
+ }
42
+ }
43
+ fail('Filesystem root cannot be a managed destination');
44
+ }catch(error){if(error instanceof TargetConfigurationError)throw error;throw new TargetConfigurationError(`Cannot inspect destination ${absolute}: ${error.message}`,{cause:error});}finally{directory.close();}
45
+ }
46
+ function inspectTree(directory,absolute,{allowLinks,snapshot}){
47
+ const before=directory.stat(),names=directory.readDir();snapshot.set(absolute,{...metadataOf(before),type:'directory',entries:names});
48
+ for(const name of names){const child=absolute+'/'+name,stat=directory.lstat(name),type=typeOf(stat);
49
+ if(type===0o040000){const next=directory.openDir(name);try{if(!sameIdentity(stat,next.stat()))fail('Directory changed before tree traversal');inspectTree(next,child,{allowLinks,snapshot});}finally{next.close();}}
50
+ else if(type===0o100000)snapshot.set(child,readCheckedFile(directory,name,stat).metadata);
51
+ else if(type===0o120000&&allowLinks)snapshot.set(child,{...metadataOf(stat),type:'symlink',link:directory.readlink(name)});
52
+ else fail(`Unsafe entry in existing destination tree: ${child}`);
53
+ }
54
+ if(!sameIdentity(before,directory.stat()))fail('Directory changed during tree inspection');
55
+ }
@@ -0,0 +1,91 @@
1
+ import {composeArcSkillTree} from './superpowers.mjs';
2
+ import path from 'node:path';
3
+ import {cleanupStage,captureCleanupEntry} from './stage-cleanup.mjs';
4
+ import {randomUUID,createHash} from 'node:crypto';
5
+ import {inspectPath,typeOf,metadataOf,sameObjectIdentity,readCheckedFile} from './targets.mjs';
6
+
7
+ export class InstallError extends Error {
8
+ constructor(message,code='ARC_INSTALL_FAILED',details={}) {super(message,{cause:details.cause});this.name='InstallError';this.code=code;Object.assign(this,details);}
9
+ }
10
+ function fail(message,code='ARC_INSTALL_FAILED',details){throw new InstallError(message,code,details);}
11
+ function exists(parent,name){try{return parent.lstat(name);}catch(error){if(error.code==='ENOENT')return null;throw error;}}
12
+ function metadataEqual(a,b){if(a===null||b===null)return a===b;return ['dev','ino','mode','size','mtimeNs','ctimeNs','type','sha256','link'].every(key=>a[key]===b[key])&&JSON.stringify(a.entries)===JSON.stringify(b.entries);}
13
+ function inspectOne(safeFs,filename,expected,snapshot,tree=expected?.type==='directory'){
14
+ inspectPath(safeFs,filename,{kind:expected?.type==='file'?'file':'directory',tree,allowLinks:true,allowLeafLink:expected?.type==='symlink',snapshot});
15
+ }
16
+ export function capturePlanSnapshot(plan,{safeFs}){
17
+ const snapshot=new Map();for(const filename of [...plan.managedPaths,...plan.externalPaths]){let parent;let stat=null;try{parent=safeFs.openRoot(path.dirname(filename));stat=exists(parent,path.basename(filename));}catch(error){if(error.code!=='ENOENT')throw error;}finally{parent?.close();}
18
+ inspectOne(safeFs,filename,stat?{type:typeOf(stat)===0o100000?'file':typeOf(stat)===0o120000?'symlink':'directory'}:null,snapshot);}
19
+ plan.snapshot=snapshot;return snapshot;
20
+ }
21
+ export function assertPlanSnapshot(plan,{safeFs}){
22
+ const fresh=new Map();try{
23
+ for(const[filename,expected]of plan.snapshot)inspectOne(safeFs,filename,expected,fresh,expected?.type==='directory'&&Object.hasOwn(expected,'entries'));
24
+ for(const[filename,expected]of plan.snapshot){const actual=fresh.get(filename)??null;if(!metadataEqual(expected,actual))fail(`Install target changed: ${filename}`,'ARC_INSTALL_DRIFT');}
25
+ }catch(error){if(error instanceof InstallError)throw error;throw new InstallError('Install target snapshot changed','ARC_INSTALL_DRIFT',{cause:error});}
26
+ }
27
+ function assertParentNamespace(safeFs,action){
28
+ let named;try{named=safeFs.openRoot(path.dirname(action.path));if(!sameObjectIdentity(named.stat(),action.parent.stat()))fail('Destination parent namespace changed','ARC_INSTALL_DRIFT');}catch(cause){if(cause instanceof InstallError)throw cause;throw new InstallError('Destination parent cannot be revalidated','ARC_INSTALL_DRIFT',{cause});}finally{named?.close();}
29
+ }
30
+ function openParent(safeFs,absolute,created){
31
+ const components=path.dirname(absolute).split('/').filter(Boolean);let current=safeFs.openRoot('/'),prefix='';
32
+ try{for(const name of components){prefix+='/'+name;let stat=exists(current,name);if(!stat){current.mkdir(name,0o700);stat=current.lstat(name);created.push(prefix);}if(typeOf(stat)!==0o040000)fail('Unsafe install parent');const next=current.openDir(name);if(!sameObjectIdentity(stat,next.stat())){next.close();fail('Install parent changed');}current.close();current=next;}return current;}catch(error){current.close();throw error;}
33
+ }
34
+ function treeFromDirectory(directory){
35
+ const entries=new Map();function walk(handle,prefix=''){for(const name of handle.readDir()){const key=prefix?prefix+'/'+name:name,stat=handle.lstat(name),type=typeOf(stat);
36
+ if(type===0o040000){const child=handle.openDir(name);try{if(!sameObjectIdentity(stat,child.stat()))fail('Backup directory changed');entries.set(key,{type:'directory',mode:stat.mode,times:metadataOf(stat)});walk(child,key);}finally{child.close();}}
37
+ else if(type===0o100000){const item=readCheckedFile(handle,name,stat);entries.set(key,{type:'file',mode:stat.mode,bytes:item.bytes,times:item.metadata});}
38
+ else if(type===0o120000)entries.set(key,{type:'symlink',mode:stat.mode,link:handle.readlink(name),times:metadataOf(stat)});
39
+ else fail('Special file in backup source');}}
40
+ walk(directory);return entries;
41
+ }
42
+ function finishDirectory(directory,mode,times){
43
+ const permissions=Number(mode&0o7777n);directory.chmod(permissions);if(times)directory.setTimes(times);directory.sync();
44
+ const actual=directory.stat();if(Number(actual.mode&0o7777n)!==permissions||times&&(actual.atimeNs!==times.atimeNs||actual.mtimeNs!==times.mtimeNs))fail('Copied directory metadata verification failed');
45
+ }
46
+ function writeTree(directory,entries){
47
+ const handles=new Map([['',directory]]);try{for(const[key,entry]of [...entries].sort((a,b)=>a[0].split('/').length-b[0].split('/').length||a[0].localeCompare(b[0]))){const parentKey=path.posix.dirname(key)==='.'?'':path.posix.dirname(key),parent=handles.get(parentKey);if(!parent)fail('Staged entry parent is missing');const name=path.posix.basename(key);
48
+ if(entry.type==='directory'){parent.mkdir(name,0o700);handles.set(key,parent.openDir(name));}
49
+ else if(entry.type==='file'){const file=parent.openFile(name,{createExclusive:true,mode:Number(entry.mode&0o7777n)});try{file.writeAll(entry.bytes);file.chmod(Number(entry.mode&0o7777n));if(entry.times)file.setTimes(entry.times);file.sync();}finally{file.close();}}
50
+ else if(entry.type==='symlink')parent.symlink(entry.link,name);else fail('Unknown staged entry type');}
51
+ for(const[key,handle]of [...handles].reverse()){if(key){const entry=entries.get(key);finishDirectory(handle,entry.mode,entry.times);}}
52
+ }finally{for(const[key,handle]of handles)if(key)handle.close();}
53
+ }
54
+ function snapshotEntry(parent,name){const stat=exists(parent,name);if(!stat)return null;const type=typeOf(stat);if(type===0o100000)return readCheckedFile(parent,name,stat).metadata;if(type===0o120000)return{...metadataOf(stat),type:'symlink',link:parent.readlink(name)};if(type===0o040000){const dir=parent.openDir(name);try{if(!sameObjectIdentity(stat,dir.stat()))fail('Entry directory changed');const entries=treeFromDirectory(dir);const hash=createHash('sha256');for(const[k,v]of entries){hash.update(JSON.stringify([k,v.type,...['dev','ino','mode','size','mtimeNs','ctimeNs'].map(field=>String(v.times[field])),v.bytes?.toString('hex')??v.link??null]));}return{...metadataOf(stat),type:'directory',sha256:hash.digest('hex'),children:captureCleanupEntry(parent,name).children};}finally{dir.close();}}fail('Special target entry');}
55
+ function equalMoved(a,b){return a&&b&&['dev','ino','mode','size','mtimeNs','type','sha256','link'].every(k=>a[k]===b[k]);}
56
+ export function applyTransaction(plan,{safeFs,acquired,signal,hooks={},specs:explicitSpecs}){
57
+ assertPlanSnapshot(plan,{safeFs});const actions=[],areas=[],parents=[],created=[],actionLog=[],byParent=new Map();
58
+ const cleanAreas=()=>{const paths=[],errors=[];for(const area of areas){const result=cleanupStage(area,{safeFs,hooks:{fault:event=>hooks.fault?.({...event,plan})}});paths.push(...result.residualPaths);if(result.cleanupError)errors.push(result.cleanupError);}return{paths,error:errors.length?Object.assign(new AggregateError(errors,errors.map(error=>error.message).join('; ')),{code:'ARC_INSTALL_CLEANUP',residualPaths:paths}):null};};
59
+ const event=(phase,details={})=>{actionLog.push({phase,...details});if(signal?.aborted)throw new InstallError('Install interrupted','ARC_INSTALL_CANCELLED');hooks.fault?.({phase,...details,plan});};
60
+ try{
61
+ event('before-prepare');
62
+ const specs=explicitSpecs??plan.uninstallSpecs??[...plan.externalPaths.map(p=>({path:p,tree:acquired.skills.get(path.basename(p))?.entries,rootMetadata:acquired.skills.get(path.basename(p))?.rootMetadata})),{path:plan.target.home+'/skills/arc',tree:composeArcSkillTree(plan.generated.skill,acquired.references)},...[...plan.generated.profiles].map(([name,bytes])=>({path:plan.target.home+'/agents/'+name,bytes})),...plan.globalChanges.map(change=>({path:change.path,bytes:change.content}))];
63
+ for(const spec of specs){if(!spec.remove&&!spec.tree&&!Buffer.isBuffer(spec.bytes))fail('Missing verified staged data');const parent=openParent(safeFs,spec.path,created);parents.push(parent);const name=path.basename(spec.path);event('before-stage',{path:spec.path});let area=byParent.get(path.dirname(spec.path));if(area&&!sameObjectIdentity(parent.stat(),area.parent.stat()))fail('Reused staging parent identity changed','ARC_INSTALL_DRIFT');if(!area){const stageName='.arc-stage-'+randomUUID();parent.mkdir(stageName,0o700);const stage=parent.openDir(stageName);area={parent,stage,name:stageName,path:path.dirname(spec.path)+'/'+stageName,identity:stage.stat(),expected:new Map()};areas.push(area);byParent.set(path.dirname(spec.path),area);}const stage=area.stage,preparedName='prepared-'+actions.length,previousName='previous-'+actions.length,rollbackName='rollback-installed-'+actions.length;
64
+ if(spec.remove){}
65
+ else if(spec.tree){stage.mkdir(preparedName,0o700);const dir=stage.openDir(preparedName);try{writeTree(dir,spec.tree);if(spec.rootMetadata)finishDirectory(dir,spec.rootMetadata.mode,spec.rootMetadata);else dir.sync();}finally{dir.close();}}
66
+ else{const prior=exists(parent,name),file=stage.openFile(preparedName,{createExclusive:true,mode:prior?Number(prior.mode&0o7777n):0o600});try{file.writeAll(spec.bytes);if(prior){file.chmod(Number(prior.mode&0o7777n));file.setTimes(metadataOf(prior));}file.sync();}finally{file.close();}}
67
+ if(!spec.remove)area.expected.set(preparedName,snapshotEntry(stage,preparedName));
68
+ const previous=snapshotEntry(parent,name);
69
+ actions.push({remove:spec.remove,path:spec.path,parent,name,stage,area,preparedName,previousName,rollbackName,previous,prepared:snapshotEntry(stage,preparedName),moved:false,published:false});event('after-stage',{path:spec.path});
70
+ }
71
+ // Directory metadata changed due to our private staging, so recheck actual
72
+ // managed leaves rather than treating installer-created siblings as drift.
73
+ const leafSnapshot=new Map([...plan.snapshot].filter(([filename])=>[...plan.managedPaths,...plan.externalPaths].some(root=>filename===root||filename.startsWith(root+'/'))));
74
+ assertPlanSnapshot({...plan,snapshot:leafSnapshot},{safeFs});
75
+ for(const action of actions){if(!equalNullable(action.previous,snapshotEntry(action.parent,action.name)))fail('Target changed during staging','ARC_INSTALL_DRIFT');}
76
+ for(const action of actions){event('before-preserve',{path:action.path});assertParentNamespace(safeFs,action);if(!equalNullable(action.previous,snapshotEntry(action.parent,action.name)))fail('Target changed immediately before replacement','ARC_INSTALL_DRIFT');if(action.previous){action.parent.renameNoReplaceTo(action.name,action.stage,action.previousName);action.moved=true;action.area.expected.set(action.previousName,action.previous);if(!equalMoved(action.previous,snapshotEntry(action.stage,action.previousName)))fail('Moved target identity is uncertain','ARC_INSTALL_RECOVERY');}event('after-preserve',{path:action.path});event('before-publish',{path:action.path});assertParentNamespace(safeFs,action);if(!action.remove){action.stage.renameNoReplaceTo(action.preparedName,action.parent,action.name);action.published=true;action.area.expected.delete(action.preparedName);if(!equalMoved(action.prepared,snapshotEntry(action.parent,action.name)))fail('Published target identity is uncertain','ARC_INSTALL_RECOVERY');action.installed=snapshotEntry(action.parent,action.name);}event('after-publish',{path:action.path});assertParentNamespace(safeFs,action);action.parent.sync();}
77
+ for(const action of actions)assertParentNamespace(safeFs,action);
78
+ const cleaned=cleanAreas();return{actionLog,installedSnapshot:new Map(actions.map(a=>[a.path,a.installed])),residualPaths:cleaned.paths,cleanupError:cleaned.error};
79
+ }catch(cause){const recovery=[],detachedParents=new Set();for(const action of [...actions].reverse()){try{hooks.fault?.({phase:'before-rollback',path:action.path,plan});if(action.published){if(!equalMoved(action.installed??action.prepared,snapshotEntry(action.parent,action.name)))fail('Installed target changed before rollback');action.parent.renameNoReplaceTo(action.name,action.stage,action.rollbackName);action.area.expected.set(action.rollbackName,action.installed??action.prepared);if(!equalMoved(action.installed??action.prepared,snapshotEntry(action.stage,action.rollbackName)))fail('Rollback source changed');}if(action.moved||action.published)try{assertParentNamespace(safeFs,action);}catch(error){detachedParents.add(path.dirname(action.path));throw error;}if(action.moved){if(!equalMoved(action.previous,snapshotEntry(action.stage,action.previousName)))fail('Previous entry changed');action.stage.renameNoReplaceTo(action.previousName,action.parent,action.name);action.area.expected.delete(action.previousName);try{assertParentNamespace(safeFs,action);}catch(error){detachedParents.add(path.dirname(action.path));if(!equalMoved(action.previous,snapshotEntry(action.parent,action.name)))throw error;action.parent.renameNoReplaceTo(action.name,action.stage,action.previousName);action.area.expected.set(action.previousName,action.previous);throw error;}}hooks.fault?.({phase:'after-rollback',path:action.path,plan});}catch{recovery.push(action.path,action.area.path);}}
80
+ const cleaned=recovery.length?{paths:areas.map(area=>area.path),error:null}:cleanAreas();
81
+ const details={cause,actionLog,detachedParents:[...detachedParents],residualPaths:[...new Set([...recovery,...cleaned.paths])],cleanupError:cleaned.error,rollback:recovery.length?'failed':'restored'};
82
+ if(recovery.length)throw new InstallError(detachedParents.size?'Rollback namespace unavailable; reported stage paths are last-known locations':'Rollback conflict; preserve reported residual paths','ARC_INSTALL_RECOVERY',details);
83
+ if(cleaned.error)throw new InstallError('Rollback restored targets; '+cleaned.error.message,'ARC_INSTALL_CLEANUP',details);
84
+ throw new InstallError(cause.message,cause.code==='ARC_INSTALL_CANCELLED'?'ARC_INSTALL_CANCELLED':cause.code==='ARC_INSTALL_DRIFT'?'ARC_INSTALL_DRIFT':'ARC_INSTALL_FAILED',details);
85
+ }finally{for(const area of areas)area.stage.close();for(const parent of parents)parent.close();}
86
+ }
87
+ function equalNullable(a,b){return a===null||b===null?a===b:equalMoved(a,b)&&a.ctimeNs===b.ctimeNs;}
88
+ export function applyUninstallTransaction(plan,options){
89
+ if(!plan.removals.length&&!plan.globalChanges.length)return{residualPaths:[],cleanupError:null,installedSnapshot:new Map(),actionLog:[]};
90
+ return applyTransaction({...plan,uninstallSpecs:[...plan.removals.map(path=>({path,remove:true})),...plan.globalChanges.map(change=>({path:change.path,bytes:change.content}))]},options);
91
+ }
@@ -0,0 +1,32 @@
1
+ import path from 'node:path';
2
+ import {inspectPath,sameIdentity,TargetConfigurationError} from './targets.mjs';
3
+ import {SKILLS,snapshotSkill} from './superpowers.mjs';
4
+ import {validatePlanSet} from './plan.mjs';
5
+ import {removeManagedBlock} from './global-config.mjs';
6
+ export function buildUninstallPlan({target,safeFs,skipGlobal=false}){
7
+ const snapshot=new Map(),removals=[],managedPaths=[path.join(target.home,'skills/arc'),...target.profileNames.map(name=>path.join(target.home,'agents',name))],globalChanges=[];
8
+ 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);}
9
+ const linkedSkills=new Set(),arc=managedPaths[0];
10
+ for(const[filename,entry]of snapshot){if(entry?.type!=='symlink')continue;
11
+ const relative=path.relative(arc,filename),parts=relative.split('/'),skill=parts[2];
12
+ if(parts[0]!=='references'||parts[1]!=='superpowers'||!SKILLS.includes(skill)||parts.length<4||!entry.link||path.isAbsolute(entry.link)||entry.link.includes('\\')||entry.link.includes('\0'))throw new TargetConfigurationError('Unsafe ARC removal link');
13
+ linkedSkills.add(skill);
14
+ }
15
+ 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();}}
16
+ 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});}}
17
+ return{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.']};
18
+ }
19
+ import {assertPlanSnapshot,applyUninstallTransaction} from './transaction.mjs';
20
+ export async function uninstallTargets(plans,{safeFs,signal,hooks={}}){
21
+ const results=plans.map(plan=>({platform:plan.target.platform,status:'not-started',rollback:'not-needed',residualPaths:[],error:null}));let invalid=false;
22
+ try{validatePlanSet(plans);}catch(error){return{results:results.map(item=>({...item,status:'failed',error})),interrupted:false};}
23
+ for(let i=0;i<plans.length;i++)try{assertPlanSnapshot(plans[i],{safeFs});}catch(error){results[i]={...results[i],status:'failed',error};invalid=true;}
24
+ if(invalid)return{results,interrupted:false};
25
+
26
+ for(let i=0;i<plans.length;i++){
27
+ if(signal?.aborted)return{results,interrupted:true};const plan=plans[i];
28
+ try{assertPlanSnapshot(plan,{safeFs});const applied=applyUninstallTransaction(plan,{safeFs,signal,hooks});results[i]={...results[i],status:'uninstalled',residualPaths:[...results[i].residualPaths,...applied.residualPaths],error:applied.cleanupError??null};if(applied.cleanupError)break;}
29
+ catch(error){results[i]={...results[i],status:'failed',rollback:error.rollback??'not-needed',residualPaths:[...results[i].residualPaths,...(error.residualPaths??[])],error};return{results,interrupted:Boolean(signal?.aborted)};}
30
+ }
31
+ return{results,interrupted:false};
32
+ }
package/lib/limits.mjs ADDED
@@ -0,0 +1,13 @@
1
+ export function parsePositiveLimit(raw,label='limit') {
2
+ if(typeof raw!=='string'||! /^[0-9]+$/.test(raw.trim())||!Number.isSafeInteger(Number(raw.trim()))||Number(raw.trim())<1)throw new Error(`${label} must be a positive safe ASCII decimal integer`);
3
+ return Number(raw.trim());
4
+ }
5
+ export function resolveLimits({existing=null,overrides={}}={}) {
6
+ const result={version:1};
7
+ for(const [key,fallback] of [['max_total_subagents',100],['max_concurrent_subagents',5]]) {
8
+ const value=overrides[key]??existing?.[key]??fallback;
9
+ if(typeof value!=='number'||!Number.isSafeInteger(value)||value<1)throw new Error(`Invalid ${key}`);
10
+ result[key]=value;
11
+ }
12
+ return result;
13
+ }
@@ -0,0 +1,58 @@
1
+ import { parse, stringify } from '../../vendor/toml/dist/index.js';
2
+ export const ROLES = Object.freeze(['arc_scouter', 'arc_planner', 'arc_coder', 'arc_qa', 'arc_reviewer']);
3
+ export class ModelValidationError extends Error {
4
+ constructor(message, options) { super(message, options); this.name = 'ModelValidationError'; this.code = 'ARC_MODEL_VALIDATION'; }
5
+ }
6
+ export function fail(message) { throw new ModelValidationError(message); }
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';
10
+ fail('Unsupported model schema version');
11
+ }
12
+ export function effortField(platform) {
13
+ if (platform === 'codex') return 'model_reasoning_effort';
14
+ if (platform === 'claude') return 'effort';
15
+ fail('Unknown model platform');
16
+ }
17
+ export function exactKeys(value, keys, label) {
18
+ if (!value || typeof value !== 'object' || Array.isArray(value) || Object.keys(value).length !== keys.length || keys.some(key => !Object.hasOwn(value, key))) fail(`${label} must contain exactly ${keys.join(', ')}`);
19
+ }
20
+ function string(value, label) {
21
+ if (typeof value !== 'string' || !value || value !== value.trim() || /[\p{Cc}\u2028\u2029]/u.test(value)) fail(`${label} must be a nonempty string without whitespace or control characters`);
22
+ }
23
+ export function validateRoles(roles, platform) {
24
+ const effort = effortField(platform); exactKeys(roles, ROLES, 'roles');
25
+ for (const [role, fields] of Object.entries(roles)) {
26
+ exactKeys(fields, ['model', effort], role); string(fields.model, `${role}.model`); string(fields[effort], `${role}.${effort}`);
27
+ if (platform === 'claude' && (!/^[A-Za-z0-9][A-Za-z0-9._:/@+\-]*$/.test(fields.model) || !['low', 'medium', 'high', 'xhigh', 'max'].includes(fields.effort))) fail(`${role}: unsupported Claude model identifier or effort`);
28
+ }
29
+ return structuredClone(roles);
30
+ }
31
+ export const PRESETS = Object.freeze(['economy', 'balanced', 'smart']);
32
+ 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');
35
+ exactKeys(config.roles, ROLES, 'roles');
36
+ for (const role of ROLES) exactKeys(config.roles[role], PRESETS, `roles.${role}`);
37
+ for (const preset of PRESETS) validateRoles(Object.fromEntries(ROLES.map(role => [role, config.roles[role][preset]])), platform);
38
+ return structuredClone(config);
39
+ }
40
+ export function resolveModelSettings(config) {
41
+ const validated = validateModelConfig(config);
42
+ return { platform: validated.platform, roles: Object.fromEntries(ROLES.map(role => [role, validated.roles[role][validated.preset]])) };
43
+ }
44
+ export function parseToml(text) {
45
+ try { return parse(typeof text === 'string' ? text : new TextDecoder('utf-8', { fatal: true }).decode(text), { integersAsBigInt: true }); }
46
+ catch (cause) { throw new ModelValidationError(`Invalid TOML: ${cause.message}`, { cause }); }
47
+ }
48
+ 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 });
52
+ }
53
+ export function serializeModelConfig(config) { const { version, preset, roles } = validateModelConfig(config); return stringify({ version, preset, roles }); }
54
+ export function applyPreset(config, preset) {
55
+ const validated = validateModelConfig(config);
56
+ if (!PRESETS.includes(preset)) fail('Unsupported performance preset');
57
+ return { ...validated, preset };
58
+ }
@@ -0,0 +1,2 @@
1
+ export { loadModelConfig, applyPreset, serializeModelConfig, resolveModelSettings, ModelValidationError } from './config.mjs';
2
+ export { renderProfiles } from './profiles.mjs';
@@ -0,0 +1,111 @@
1
+ import {hookFrontmatter,validateClaudeHookCommand} from '../policy/claude-hook.mjs';
2
+ import { parseToml, exactKeys, validateRoles, effortField, ROLES, fail } from './config.mjs';
3
+ const tools = {
4
+ arc_scouter: ['Read', 'Grep', 'Glob', 'WebFetch', 'WebSearch'],
5
+ arc_planner: ['Read', 'Grep', 'Glob'],
6
+ arc_coder: ['Read', 'Grep', 'Glob', 'Bash', 'Write', 'Edit'],
7
+ arc_qa: ['Read', 'Grep', 'Glob', 'Bash'],
8
+ arc_reviewer: ['Read', 'Grep', 'Glob'],
9
+ };
10
+ function decode(buffer) {
11
+ if (!Buffer.isBuffer(buffer)) fail('Profile input must be a verified Buffer');
12
+ if (buffer.subarray(0, 3).equals(Buffer.from([0xef, 0xbb, 0xbf]))) fail('Profile must not start with a UTF-8 BOM');
13
+ try { return new TextDecoder('utf-8', { fatal: true }).decode(buffer); }
14
+ catch { fail('Profile must contain valid UTF-8'); }
15
+ }
16
+ function linesOf(text) { return text.match(/[^\n]*\n|[^\n]+$/g) ?? []; }
17
+ // Track TOML strings and value nesting so instructions, inline tables and array
18
+ // continuations cannot be mistaken for root assignments. The parser validates
19
+ // syntax independently; the scanner only locates narrowly editable lines.
20
+ function locateRootFields(lines) {
21
+ let quote = null, depth = 0, root = true, firstTable = null;
22
+ const fields = new Map();
23
+ for (let lineNumber = 0; lineNumber < lines.length; lineNumber++) {
24
+ const line = lines[lineNumber];
25
+ if (quote === null && depth === 0 && root) {
26
+ if (/^[ \t]*\[/.test(line)) { root = false; firstTable = lineNumber; }
27
+ else {
28
+ const assignment = /^[ \t]*((?:[A-Za-z0-9_-]+)|(?:"(?:\\.|[^"\\])*"|'[^']*'))[ \t]*=/.exec(line);
29
+ if (assignment) {
30
+ const rawKey = assignment[1]; const parsed = parseToml(`${rawKey} = ""`); const key = Object.keys(parsed)[0];
31
+ if (['model', 'model_reasoning_effort'].includes(key)) {
32
+ if (![key, `"${key}"`, `'${key}'`].includes(rawKey) || fields.has(key)) fail('Unsupported root model assignment');
33
+ fields.set(key, lineNumber);
34
+ }
35
+ }
36
+ }
37
+ }
38
+ for (let i = 0; i < line.length;) {
39
+ if (quote !== null) {
40
+ if (quote.startsWith('"') && line[i] === '\\') { i += 2; continue; }
41
+ if (line.startsWith(quote, i)) {
42
+ if (quote.length === 3) { const char = quote[0]; let end = i + 3; while (line[end] === char) end++; i = end; }
43
+ else i++;
44
+ quote = null;
45
+ } else i++;
46
+ } else if (line[i] === '#') break;
47
+ else if (line[i] === '"' || line[i] === "'") { quote = line.startsWith(line[i].repeat(3), i) ? line[i].repeat(3) : line[i]; i += quote.length; }
48
+ else { if ('[{'.includes(line[i])) depth++; else if (']}'.includes(line[i])) depth--; i++; }
49
+ }
50
+ }
51
+ return { fields, firstTable };
52
+ }
53
+ function renderCodex(text, role, settings) {
54
+ const parsed = parseToml(text); if (parsed.name !== role) fail('Codex profile name does not match role filename');
55
+ const lines = linesOf(text), { fields, firstTable } = locateRootFields(lines);
56
+ const missing = [];
57
+ for (const key of ['model', 'model_reasoning_effort']) {
58
+ if (!Object.hasOwn(parsed, key)) { if (fields.has(key)) fail('Ambiguous root model field'); missing.push(key); continue; }
59
+ if (typeof parsed[key] !== 'string' || !fields.has(key)) fail('Root model field must be an editable one-line quoted string');
60
+ const index = fields.get(key);
61
+ const match = /^([ \t]*(?:model|model_reasoning_effort|"model"|"model_reasoning_effort"|'model'|'model_reasoning_effort')[ \t]*=[ \t]*)("(?:\\.|[^"\\\r\n])*"|'[^'\r\n]*')([ \t]*(?:#[^\r\n]*)?)(\r?\n)?$/.exec(lines[index]);
62
+ if (!match) fail('Root model field must be an editable one-line quoted string');
63
+ if (parsed[key] !== settings[key]) lines[index] = match[1] + JSON.stringify(settings[key]) + match[3] + (match[4] ?? '');
64
+ }
65
+ let output = lines.join('');
66
+ if (missing.length) {
67
+ const eol = /\r?\n/.exec(text)?.[0] ?? '\n';
68
+ let addition = missing.map(key => `${key} = ${JSON.stringify(settings[key])}${eol}`).join('');
69
+ const offset = firstTable === null ? output.length : lines.slice(0, firstTable).join('').length;
70
+ if (offset && !/[\r\n]$/.test(output.slice(0, offset))) addition = eol + addition;
71
+ output = output.slice(0, offset) + addition + output.slice(offset);
72
+ }
73
+ const checked = parseToml(output);
74
+ if (checked.name !== role || Object.entries(settings).some(([key, value]) => checked[key] !== value)) fail('Rendered Codex profile does not match settings');
75
+ return Buffer.from(output);
76
+ }
77
+ function renderClaude(text, role, settings) {
78
+ const lines = linesOf(text); if (lines[0]?.replace(/\r?\n$/, '') !== '---') fail('Missing opening Claude frontmatter');
79
+ const fields = {}, indices = {}; let current, closed = false;
80
+ for (let i = 1; i < lines.length; i++) {
81
+ const line = lines[i].replace(/\r?\n$/, ''); if (line === '---') { closed = true; break; }
82
+ if(line==='hooks:') {
83
+ if(Object.hasOwn(fields,'hooks'))fail('Duplicate Claude hooks');
84
+ const raw=lines.slice(i,i+6).map(x=>x.replace(/\r?\n$/,''));
85
+ try {const command=JSON.parse(raw[5]?.replace(/^ command: /,''));validateClaudeHookCommand(command);if(raw.join('\n')!==hookFrontmatter(command))throw Error('Unsupported hooks');fields.hooks=command;}catch {fail('Unsupported managed Claude hooks');}
86
+ indices.hooks=i;i+=5;current='hooks';continue;
87
+ }
88
+ const item = /^ - ([A-Za-z][A-Za-z0-9]*)$/.exec(line);
89
+ if (item && current === 'tools') { fields.tools.push(item[1]); continue; }
90
+ const match = /^([a-z][a-zA-Z0-9_-]*):(?: (.+))?$/.exec(line);
91
+ if (!match || Object.hasOwn(fields, match[1])) fail('Malformed or duplicate Claude frontmatter');
92
+ const [, key, value] = match; current = key; indices[key] = i;
93
+ if (key === 'tools') { if (value !== undefined) fail('Claude tools must be an explicit list'); fields.tools = []; }
94
+ else { if (!value || value !== value.trim() || /[\t\x00{}\[\]#&*!|>"']/.test(value) || value.includes(': ')) fail('Claude frontmatter requires plain scalar values'); fields[key] = value; }
95
+ }
96
+ 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');
97
+ 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');
98
+ for (const key of ['model', 'effort']) { const index = indices[key], eol = /\r?\n$/.exec(lines[index])?.[0] ?? ''; lines[index] = `${key}: ${settings[key]}${eol}`; }
99
+ return Buffer.from(lines.join(''));
100
+ }
101
+ export function renderProfiles(profileBuffers, config) {
102
+ exactKeys(config, ['platform', 'roles'], 'effective model settings');
103
+ const platform = config.platform, validated = {roles: validateRoles(config.roles, platform)}, extension = platform === 'codex' ? 'toml' : 'md';
104
+ if (profileBuffers instanceof Map) profileBuffers = Object.fromEntries(profileBuffers);
105
+ const names = ROLES.map(role => `${role.replace('_', '-')}.${extension}`);
106
+ if (!profileBuffers || Object.keys(profileBuffers).length !== names.length || names.some(name => !Object.hasOwn(profileBuffers, name))) fail('Profile set must contain exactly the five platform role files');
107
+ return new Map(names.map((name, index) => {
108
+ const role = ROLES[index], text = decode(profileBuffers[name]);
109
+ return [name, platform === 'codex' ? renderCodex(text, role, validated.roles[role]) : renderClaude(text, role, validated.roles[role])];
110
+ }));
111
+ }
@@ -0,0 +1,40 @@
1
+ import path from 'node:path';
2
+ import {pathToFileURL} from 'node:url';
3
+ // Only this fixed bootstrap is executed. The separately encoded path is data.
4
+ const bootstrap="import(Buffer.from(process.argv[1],'base64').toString('utf8')).then(module=>module.runHook()).catch(()=>{process.stderr.write('ARC Git guard failed to load\\n');process.exitCode=2;});";
5
+ const prefix=`node -e "eval(Buffer.from('${Buffer.from(bootstrap).toString('base64')}','base64').toString('utf8'))" `;
6
+ export function buildClaudeHookCommand(scriptPath) {
7
+ if(typeof scriptPath!=='string'||/[\x00-\x1f]/.test(scriptPath)||!path.isAbsolute(scriptPath)&&!path.win32.isAbsolute(scriptPath))throw Error('Claude hook requires an absolute script path');
8
+ let url;
9
+ if(path.win32.isAbsolute(scriptPath)&&/^[A-Za-z]:[\\/]/.test(scriptPath)) {
10
+ const normalized=path.win32.normalize(scriptPath).replaceAll('\\','/');url='file:///'+normalized.split('/').map((part,i)=>i===0?part:encodeURIComponent(part)).join('/');
11
+ } else {if(!path.isAbsolute(scriptPath)||path.normalize(scriptPath)!==scriptPath)throw Error('Invalid absolute hook path');url=pathToFileURL(scriptPath).href;}
12
+ return prefix+`"${Buffer.from(url).toString('base64')}"`;
13
+ }
14
+ export const templateHookCommand=buildClaudeHookCommand('/__ARC_INSTALLED_SKILL__/hooks/role-git-guard.mjs');
15
+ export function validateClaudeHookCommand(command) {
16
+ if(typeof command!=='string'||!command.startsWith(prefix))throw Error('Unmanaged Claude hook command');
17
+ const encoded=/^"([A-Za-z0-9+/]+={0,2})"$/.exec(command.slice(prefix.length));if(!encoded)throw Error('Invalid Claude hook command');
18
+ const decoded=Buffer.from(encoded[1],'base64');if(decoded.toString('base64')!==encoded[1])throw Error('Invalid hook path encoding');
19
+ const url=new URL(decoded.toString('utf8'));if(url.protocol!=='file:'||url.host||!url.pathname.endsWith('/hooks/role-git-guard.mjs')||url.search||url.hash)throw Error('Invalid hook file URL');
20
+ return command;
21
+ }
22
+ export function hookFrontmatter(command=templateHookCommand,eol='\n') {
23
+ validateClaudeHookCommand(command);
24
+ return ['hooks:',' PreToolUse:',' - matcher: Bash',' hooks:',' - type: command',` command: ${JSON.stringify(command)}`].join(eol);
25
+ }
26
+ export function installClaudeHook(bytes,skillPath) {
27
+ const text=bytes.toString('utf8'),eol=/\r?\n/.exec(text)?.[0]??'\n';
28
+ const pattern=/^hooks:\r?\n PreToolUse:\r?\n - matcher: Bash\r?\n hooks:\r?\n - type: command\r?\n command: ("[^\r\n]+")(?=\r?$)/gm;
29
+ const matches=[...text.matchAll(pattern)];if(matches.length!==1)throw Error('Missing or duplicate managed Claude hook');
30
+ validateClaudeHookCommand(JSON.parse(matches[0][1]));
31
+ return Buffer.from(text.replace(pattern,()=>hookFrontmatter(buildClaudeHookCommand(path.join(skillPath,'hooks/role-git-guard.mjs')),eol)));
32
+ }
33
+ export function renderSourceClaudeHook(text) {
34
+ const eol=/\r?\n/.exec(text)?.[0]??'\n';
35
+ const frontmatter=/^---\r?\n([\s\S]*?)\r?\n---(?=\r?\n|$)/.exec(text);
36
+ if(!frontmatter)throw Error('Missing Claude frontmatter for hook generation');
37
+ if(/^hooks:/m.test(frontmatter[1]))return installClaudeHook(Buffer.from(text),'/__ARC_INSTALLED_SKILL__').toString('utf8');
38
+ const closing=frontmatter[0].lastIndexOf(eol+'---');
39
+ return text.slice(0,closing)+eol+hookFrontmatter(templateHookCommand,eol)+text.slice(closing);
40
+ }