@sage-protocol/cli 0.2.9 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -208,6 +208,54 @@ function register(program) {
208
208
  .description('SubDAO management commands')
209
209
  .addHelpText('afterAll', '\nGuided launch walkthrough: sage help workflow subdao-launch\n')
210
210
  .addHelpText('after', `\nExamples:\n $ sage subdao save 0xAbc... --alias team-alpha\n $ sage subdao pick\n $ sage subdao use team-alpha\n $ sage subdao info team-alpha --json\n\nTemplates:\n 1. rapid-iteration - Fast community voting (testnet)\n 2. standard-secure - Balanced community governance\n 3. team - Safe multisig for small teams (was: operator)\n`)
211
+ .addCommand(
212
+ new Command('create')
213
+ .description('Create a new SubDAO using a governance playbook (Plan/Apply)')
214
+ .option('--playbook <id>', 'Playbook ID (creator, squad, community)')
215
+ .option('--name <name>', 'SubDAO name')
216
+ .option('--description <desc>', 'SubDAO description')
217
+ .option('--owners <addresses>', 'Safe owners (comma-separated)')
218
+ .option('--threshold <n>', 'Safe threshold')
219
+ .option('--min-stake <amount>', 'Minimum stake amount (SXXX)')
220
+ .option('--yes', 'Skip confirmation')
221
+ .option('--dry-run', 'Generate plan only (do not execute)')
222
+ .option('--apply <file>', 'Apply a previously generated plan file')
223
+ .action(async (opts) => {
224
+ try {
225
+ if (opts.apply) {
226
+ const { applyPlan } = require('../services/subdao/applier');
227
+ await applyPlan(opts.apply);
228
+ return;
229
+ }
230
+
231
+ const { planSubDAO } = require('../services/subdao/planner');
232
+ const plan = await planSubDAO(opts);
233
+
234
+ console.log(JSON.stringify(plan, null, 2));
235
+
236
+ if (opts.dryRun) {
237
+ const fs = require('fs');
238
+ const filename = `subdao-plan-${Date.now()}.json`;
239
+ fs.writeFileSync(filename, JSON.stringify(plan, null, 2));
240
+ console.log(`\n✅ Plan saved to ${filename}`);
241
+ return;
242
+ }
243
+
244
+ const inquirer = (require('inquirer').default || require('inquirer'));
245
+ if (!opts.yes) {
246
+ const { confirm } = await inquirer.prompt([{ type: 'confirm', name: 'confirm', message: 'Apply this plan?', default: true }]);
247
+ if (!confirm) return;
248
+ }
249
+
250
+ const { applyPlan } = require('../services/subdao/applier');
251
+ await applyPlan(plan);
252
+
253
+ } catch (e) {
254
+ console.error('❌ Create failed:', e.message);
255
+ process.exit(1);
256
+ }
257
+ })
258
+ )
211
259
  .addCommand(
212
260
  new Command('create-wizard')
213
261
  .description('Interactive SubDAO creation wizard (choose governance type and access model)')
@@ -556,140 +604,36 @@ function register(program) {
556
604
  .option('--json', 'Output JSON', false)
557
605
  .action(async (opts) => {
558
606
  try {
559
- const { ethers } = require('ethers');
560
- const { resolveGovContext } = require('../utils/gov-context');
607
+ const { diagnoseSubDAO } = require('../services/governance/doctor');
608
+ const { getSignerSession } = require('../utils/cli-session');
561
609
  const { signer, provider } = await getSignerSession();
562
- const ctx = await resolveGovContext({ govOpt: undefined, subdaoOpt: opts.subdao, provider });
563
- const subdao = ctx.subdao;
564
- if (!subdao) throw new Error('SubDAO not resolved. Pass --subdao or save one in profile via `sage context use --subdao <addr>`.');
565
- const governor = ctx.governor; const timelock = ctx.timelock;
566
- if (!governor || !timelock) throw new Error('Failed to resolve governor/timelock from --subdao');
567
- const out = { subdao, governor, timelock };
568
- try {
569
- const sd = new ethers.Contract(subdao, [
570
- 'function getGovernanceMode() view returns (uint8)',
571
- 'function promptRegistry() view returns (address)'
572
- ], provider);
573
- out.mode = Number(await sd.getGovernanceMode());
574
- out.registry = await sd.promptRegistry();
575
- } catch(_){}
576
- try { const tl = new ethers.Contract(timelock, ['function getMinDelay() view returns (uint256)','function PROPOSER_ROLE() view returns (bytes32)','function EXECUTOR_ROLE() view returns (bytes32)','function DEFAULT_ADMIN_ROLE() view returns (bytes32)','function hasRole(bytes32,address) view returns (bool)'], provider);
577
- out.minDelay = String(await tl.getMinDelay().catch(()=>0n));
578
- const PROPOSER = await tl.PROPOSER_ROLE();
579
- const EXECUTOR = await tl.EXECUTOR_ROLE();
580
- const ADMIN = await tl.DEFAULT_ADMIN_ROLE();
581
- out.govIsProposer = await tl.hasRole(PROPOSER, governor).catch(()=>false);
582
- out.anyoneExec = await tl.hasRole(EXECUTOR, ethers.ZeroAddress).catch(()=>false);
583
- out.signerIsAdmin = await tl.hasRole(ADMIN, await signer.getAddress()).catch(()=>false);
584
- } catch(_){}
585
- // Prompt registry governance handover / timelock wiring
586
- try {
587
- if (out.registry) {
588
- const reg = new ethers.Contract(out.registry, [
589
- 'function adminHandoverComplete() view returns (bool)',
590
- 'function governor() view returns (address)',
591
- 'function subDAO() view returns (address)'
592
- ], provider);
593
- out.registryAdminHandoverComplete = await reg.adminHandoverComplete().catch(()=>false);
594
- out.registryGovernor = await reg.governor().catch(()=>ethers.ZeroAddress);
595
- out.registrySubDAO = await reg.subDAO().catch(()=>ethers.ZeroAddress);
596
- }
597
- } catch(_) {}
598
-
599
- // LibraryRegistry scoped roles (if configured)
600
- try {
601
- const cfg = require('../config');
602
- const libRegAddr = cfg.resolveAddress ? cfg.resolveAddress('LIBRARY_REGISTRY_ADDRESS', null) : (process.env.LIBRARY_REGISTRY_ADDRESS || null);
603
- if (libRegAddr && out.timelock) {
604
- const lib = new ethers.Contract(libRegAddr, [
605
- 'function libraryAdminRole(address subdao) view returns (bytes32)',
606
- 'function hasRole(bytes32,address) view returns (bool)'
607
- ], provider);
608
- const scopedRole = await lib.libraryAdminRole(subdao);
609
- out.libraryRegistryAddress = libRegAddr;
610
- out.libraryAdminRole = scopedRole;
611
- out.libraryTimelockHasRole = await lib.hasRole(scopedRole, out.timelock).catch(()=>false);
612
- out.libraryGovernorHasRole = await lib.hasRole(scopedRole, out.governor).catch(()=>false);
613
- }
614
- } catch(_) {}
615
-
616
- const promptsAddr = opts.prompts || process.env.PREMIUM_PROMPTS_ADDRESS;
617
- if (promptsAddr) {
618
- try {
619
- const pr = new ethers.Contract(ethers.getAddress(promptsAddr), ['function factory() view returns (address)'], provider);
620
- const factory = await pr.factory(); out.prompts = promptsAddr; out.factory = factory;
621
- if (factory && /^0x/.test(factory)) {
622
- const f = new ethers.Contract(factory, ['function isSubDAO(address) view returns (bool)'], provider);
623
- out.factoryRecognizesSubDAO = await f.isSubDAO(subdao).catch(()=>false);
624
- }
625
- } catch(_){}
626
- }
627
- if (opts.json) { console.log(JSON.stringify(out, null, 2)); return; }
628
- console.log('┌─ SubDAO Doctor ────────────────────────────┐');
629
- console.log(`│ SubDAO : ${subdao}`);
630
- console.log(`│ Governor : ${governor}`);
631
- console.log(`│ Timelock : ${timelock}`);
632
- // Effective mode label (treat non-proposer as Team-only UX)
633
- if (out.mode !== undefined) {
634
- let label = (out.mode===0?'Personal':'Community');
635
- if (out.govIsProposer === false) label = 'Team-only';
636
- console.log(`│ Mode : ${out.mode} (${label})`);
637
- }
638
- if (out.minDelay !== undefined) console.log(`│ MinDelay : ${out.minDelay}s`);
639
- if (out.govIsProposer !== undefined) console.log(`│ Gov PROPOSER on TL: ${out.govIsProposer?'yes':'no'}`);
640
- if (out.registry) console.log(`│ Registry : ${out.registry}`);
641
- if (out.registryAdminHandoverComplete !== undefined) {
642
- console.log(`│ Registry handover : ${out.registryAdminHandoverComplete ? 'complete' : 'NOT COMPLETE'}`);
643
- }
644
- if (out.libraryRegistryAddress) {
645
- console.log(`│ LibraryRegistry : ${out.libraryRegistryAddress}`);
646
- console.log(`│ TL has LIB_ADMIN : ${out.libraryTimelockHasRole ? 'yes' : 'no'}`);
647
- console.log(`│ GOV has LIB_ADMIN: ${out.libraryGovernorHasRole ? 'yes' : 'no'}`);
648
- }
649
- if (out.prompts) console.log(`│ Premiums : ${out.prompts}`);
650
- if (out.factory) console.log(`│ Factory : ${out.factory}`);
651
- if (out.factoryRecognizesSubDAO !== undefined) console.log(`│ Factory isSubDAO(SubDAO): ${out.factoryRecognizesSubDAO?'yes':'no'}`);
652
- console.log('└────────────────────────────────────────────┘');
653
- console.log('\nRecommendations:');
654
- if (!out.govIsProposer) {
655
- console.log('- Grant PROPOSER_ROLE to the Governor on the SubDAO Timelock.');
656
- if (process.env.SAGE_SHOW_CAST === '1') {
657
- console.log(' Encode calldata:');
658
- console.log(` cast calldata "grantRole(bytes32,address)" $(cast call ${timelock} "PROPOSER_ROLE()") ${governor}`);
659
- }
660
- console.log(' Schedule on timelock:');
661
- console.log(` sage timelock schedule-call --subdao ${subdao} --to ${timelock} --sig 'grantRole(bytes32,address)' --args "$(cast call ${timelock} 'PROPOSER_ROLE()'),${governor}"`);
662
- console.log(' Or run one-shot helper:');
663
- console.log(` sage governance enable-proposals --subdao ${subdao}`);
664
- }
665
- if (out.registry && out.registryAdminHandoverComplete === false) {
666
- console.log('- Complete PromptRegistry admin handover to the Timelock to avoid deployer EOAs retaining control.');
667
- }
668
- if (out.libraryRegistryAddress && !out.libraryTimelockHasRole) {
669
- console.log('- Grant the scoped library admin role to the Timelock in LibraryRegistry for this SubDAO.');
670
- }
671
- console.log('- Prefer encoded helpers to avoid hex mistakes:');
672
- console.log(' - Build data: sage timelock build-data --sig "grantRole(bytes32,address)" --args "PROPOSER_ROLE,', governor, '"');
673
- console.log(' - Execute call: sage timelock execute-call --subdao', subdao, '--to', timelock, '--sig "grantRole(bytes32,address)" --args "PROPOSER_ROLE,', governor, '" --salt 0x...');
674
- if (out.factoryRecognizesSubDAO === false) {
675
- console.log('- PremiumPrompts factory does not recognize this SubDAO. Use a factory-created SubDAO for premium registration or extend the factory with an allowlist and redeploy.');
610
+
611
+ const report = await diagnoseSubDAO({ subdao: opts.subdao, provider, signer });
612
+
613
+ if (opts.json) {
614
+ console.log(JSON.stringify(report, null, 2));
615
+ return;
676
616
  }
677
- if (opts.fix) {
678
- if (!out.signerIsAdmin) {
679
- console.log('❌ Cannot fix: signer lacks DEFAULT_ADMIN_ROLE on Timelock');
680
- } else {
681
- const tlw = new ethers.Contract(timelock, ['function PROPOSER_ROLE() view returns (bytes32)','function EXECUTOR_ROLE() view returns (bytes32)','function grantRole(bytes32,address)','function hasRole(bytes32,address) view returns (bool)'], signer);
682
- const PROPOSER = await tlw.PROPOSER_ROLE();
683
- const EXECUTOR = await tlw.EXECUTOR_ROLE();
684
- if (!out.govIsProposer) {
685
- console.log('⚙️ Granting PROPOSER_ROLE to Governor...');
686
- const tx = await tlw.grantRole(PROPOSER, governor); console.log('Tx:', tx.hash); { const { waitForReceipt } = require('../utils/tx-wait'); const { ethers } = require('ethers'); const pr = new ethers.JsonRpcProvider(process.env.RPC_URL || process.env.BASE_SEPOLIA_RPC || process.env.BASE_RPC_URL || 'https://base-sepolia.publicnode.com'); await waitForReceipt(pr, tx, Number(process.env.SAGE_TX_WAIT_MS || 60000)); } console.log('✅ Granted');
687
- } else { console.log(' Governor already has PROPOSER_ROLE'); }
688
- if (!out.anyoneExec) {
689
- console.log('⚙️ Granting EXECUTOR_ROLE to address(0)...');
690
- const tx2 = await tlw.grantRole(EXECUTOR, ethers.ZeroAddress); console.log('Tx:', tx2.hash); { const { waitForReceipt } = require('../utils/tx-wait'); const { ethers } = require('ethers'); const pr = new ethers.JsonRpcProvider(process.env.RPC_URL || process.env.BASE_SEPOLIA_RPC || process.env.BASE_RPC_URL || 'https://base-sepolia.publicnode.com'); await waitForReceipt(pr, tx2, Number(process.env.SAGE_TX_WAIT_MS || 60000)); } console.log('✅ Granted');
691
- } else { console.log('✅ address(0) already EXECUTOR'); }
692
- }
617
+
618
+ console.log('🔍 Governance Doctor Report');
619
+ console.log(`SubDAO: ${report.subdao}`);
620
+ console.log(`Mode: ${report.mode === 1 ? 'Community' : 'Personal/Team'}`);
621
+ console.log(`Governor: ${report.governor}`);
622
+ console.log(`Timelock: ${report.timelock}`);
623
+
624
+ if (report.recommendations.length === 0) {
625
+ console.log(' All checks passed.');
626
+ } else {
627
+ console.log('⚠️ Issues Found:');
628
+ report.recommendations.forEach(rec => {
629
+ console.log(` - ${rec.msg}`);
630
+ if (rec.fixCmd) console.log(` Fix Cmd: ${rec.fixCmd}`);
631
+ });
632
+
633
+ if (opts.fix) {
634
+ console.log('\n⚠️ Automatic --fix is deprecated. Please use the commands above.');
635
+ console.log(' Or for Safe/Tally payloads, rely on "sage skills publish" or "sage timelock" tools.');
636
+ }
693
637
  }
694
638
  } catch (e) {
695
639
  console.error('❌ Doctor failed:', e.message);
@@ -0,0 +1,47 @@
1
+ [
2
+ {
3
+ "id": "creator",
4
+ "name": "Creator Playbook (Solo)",
5
+ "version": "1.0.0",
6
+ "description": "For publishing your own work. Direct control.",
7
+ "governance": "operator",
8
+ "params": {
9
+ "proposalThreshold": "0",
10
+ "votingPeriod": "0",
11
+ "quorumBps": 0
12
+ },
13
+ "roles": {
14
+ "description": "Owner has all roles. No voting."
15
+ }
16
+ },
17
+ {
18
+ "id": "squad",
19
+ "name": "Squad Playbook (Small Team)",
20
+ "version": "1.0.0",
21
+ "description": "Collaborate with a small team via a Safe multisig.",
22
+ "governance": "operator",
23
+ "params": {
24
+ "proposalThreshold": "0",
25
+ "votingPeriod": "0",
26
+ "quorumBps": 0
27
+ },
28
+ "roles": {
29
+ "description": "Safe multisig holds Admin/Proposer roles."
30
+ }
31
+ },
32
+ {
33
+ "id": "community",
34
+ "name": "Community Playbook (Decentralized)",
35
+ "version": "1.0.0",
36
+ "description": "Token-based voting governance.",
37
+ "governance": "token",
38
+ "params": {
39
+ "proposalThreshold": "10000",
40
+ "votingPeriod": "3 days",
41
+ "quorumBps": 400
42
+ },
43
+ "roles": {
44
+ "description": "Token holders propose/vote. Timelock executes."
45
+ }
46
+ }
47
+ ]
@@ -322,6 +322,10 @@ function createLocalConfig() {
322
322
  if (ipfs.warmGateway !== undefined && !process.env.SAGE_IPFS_WARM) {
323
323
  process.env.SAGE_IPFS_WARM = String(!!ipfs.warmGateway);
324
324
  }
325
+ const git = profile.git || {};
326
+ if (git.githubToken && !process.env.GITHUB_TOKEN && !process.env.GH_TOKEN) {
327
+ process.env.GITHUB_TOKEN = git.githubToken;
328
+ }
325
329
  }
326
330
  if (!isQuiet()) console.log(`🧭 Loaded config profile: ${active}`);
327
331
  } catch (e) {
@@ -410,6 +414,17 @@ function createLocalConfig() {
410
414
  this.writeProfileSettings({ ipfs: settings }, options);
411
415
  },
412
416
 
417
+ readGitConfig() {
418
+ const profiles = this.readProfiles();
419
+ const active = profiles.activeProfile || 'default';
420
+ const profile = profiles.profiles?.[active] || {};
421
+ return mergeObjects({}, profile.git || {});
422
+ },
423
+
424
+ writeGitConfig(settings = {}, options = {}) {
425
+ this.writeProfileSettings({ git: settings }, options);
426
+ },
427
+
413
428
  getActiveProfile(options = {}) {
414
429
  const profiles = this.readProfiles();
415
430
  const profileName = options.profile || profiles.activeProfile || 'default';
package/dist/cli/index.js CHANGED
@@ -86,7 +86,7 @@ program
86
86
 
87
87
  // Reordered to highlight consolidated namespaces ('prompts' and 'gov')
88
88
  const commandGroups = [
89
- { title: 'Content (new)', modules: ['prompts', 'prompt', 'library', 'premium', 'premium-pre', 'creator', 'contributor', 'bounty'] },
89
+ { title: 'Content (new)', modules: ['skills', 'prompts', 'prompt', 'library', 'premium', 'premium-pre', 'creator', 'contributor', 'bounty'] },
90
90
  { title: 'Governance (new)', modules: ['proposals', 'governance', 'subdao', 'timelock', 'roles', 'members', 'gov-config', 'stake-status'] },
91
91
  { title: 'Treasury', modules: ['treasury', 'safe', 'boost'] },
92
92
  { title: 'Ops & Utilities', modules: ['config', 'wallet', 'sxxx', 'ipfs', 'ipns', 'context', 'doctor', 'factory', 'upgrade', 'resolve', 'dry-run-queue', 'mcp', 'start', 'wizard', 'init', 'subdao-config', 'pin', 'council', 'sbt', 'completion', 'help', 'hook', 'ipns'] },
@@ -727,89 +727,23 @@ class SageMCPServer {
727
727
  * - Skill files live under promptsDir/skills/*.md
728
728
  */
729
729
  listWorkspaceSkills() {
730
- const skills = [];
731
730
  try {
732
731
  const { readWorkspace, DEFAULT_DIR } = require('./services/prompts/workspace');
732
+ const { findWorkspaceSkills } = require('./services/skills/discovery');
733
733
  const ws = readWorkspace() || {};
734
734
  const promptsDir = ws.promptsDir || DEFAULT_DIR || 'prompts';
735
- const baseDir = path.join(process.cwd(), promptsDir, 'skills');
736
- if (!fs.existsSync(baseDir)) {
737
- return {
738
- content: [
739
- {
740
- type: 'text',
741
- text: 'No workspace skills directory found (expected prompts/skills). Create prompts/skills/<name>.md to define skills for this repo.',
742
- },
743
- ],
744
- };
745
- }
746
- const walk = (dir) => {
747
- const entries = fs.readdirSync(dir, { withFileTypes: true });
748
- for (const entry of entries) {
749
- const full = path.join(dir, entry.name);
750
- if (entry.isDirectory()) {
751
- walk(full);
752
- } else if (entry.isFile() && full.toLowerCase().endsWith('.md')) {
753
- skills.push(full);
754
- }
755
- }
756
- };
757
- walk(baseDir);
758
- const results = skills.map((filePath) => {
759
- const relFromPrompts = path.relative(path.join(process.cwd(), promptsDir), filePath);
760
- const key = relFromPrompts.replace(/\\/g, '/').replace(/\.md$/i, '');
761
- let title = path.basename(filePath, '.md');
762
- let summary = '';
763
- let tags = [];
764
- try {
765
- const raw = fs.readFileSync(filePath, 'utf8');
766
- if (raw.startsWith('---')) {
767
- const end = raw.indexOf('\n---', 3);
768
- if (end !== -1) {
769
- const front = raw.slice(3, end).split(/\r?\n/);
770
- for (const line of front) {
771
- const idx = line.indexOf(':');
772
- if (idx === -1) continue;
773
- const k = line.slice(0, idx).trim().toLowerCase();
774
- let v = line.slice(idx + 1).trim();
775
- if (k === 'title' && v) title = v;
776
- if (k === 'summary' && v) summary = v;
777
- if (k === 'tags' && v) {
778
- try {
779
- tags = JSON.parse(v.replace(/'/g, '"'));
780
- } catch (_) {
781
- tags = String(v)
782
- .split(/[,|\s]+/)
783
- .filter(Boolean);
784
- }
785
- }
786
- }
787
- }
788
- }
789
- } catch (_) {
790
- // ignore parse errors; fall back to filename
791
- }
792
- return {
793
- key,
794
- name: title,
795
- summary,
796
- tags,
797
- path: filePath,
798
- };
799
- });
800
-
735
+ const results = findWorkspaceSkills({ promptsDir });
801
736
  if (!results.length) {
802
737
  return {
803
738
  content: [
804
739
  {
805
740
  type: 'text',
806
- text: 'No skills found under prompts/skills. Create prompts/skills/<name>.md to define repo-specific skills.',
741
+ text: 'No skills found. Create prompts/skills/<name>.md or prompts/skills/<name>/SKILL.md to define skills for this repo.',
807
742
  },
808
743
  { type: 'json', text: JSON.stringify({ skills: [] }, null, 2) },
809
744
  ],
810
745
  };
811
746
  }
812
-
813
747
  const textLines = results
814
748
  .map(
815
749
  (s, idx) =>
@@ -818,7 +752,6 @@ class SageMCPServer {
818
752
  }${s.tags && s.tags.length ? `\n 🔖 ${s.tags.join(', ')}` : ''}`,
819
753
  )
820
754
  .join('\n\n');
821
-
822
755
  return {
823
756
  content: [
824
757
  { type: 'text', text: `Workspace skills (${results.length})\n\n${textLines}` },
@@ -839,42 +772,38 @@ class SageMCPServer {
839
772
  getWorkspaceSkill({ key }) {
840
773
  try {
841
774
  if (!key || !String(key).trim()) {
842
- return {
843
- content: [{ type: 'text', text: 'get_workspace_skill: key is required' }],
844
- };
775
+ return { content: [{ type: 'text', text: 'get_workspace_skill: key is required' }] };
845
776
  }
846
777
  const { readWorkspace, DEFAULT_DIR } = require('./services/prompts/workspace');
778
+ const { resolveSkillFileByKey } = require('./services/skills/discovery');
847
779
  const ws = readWorkspace() || {};
848
780
  const promptsDir = ws.promptsDir || DEFAULT_DIR || 'prompts';
849
781
  const safeKey = String(key).trim().replace(/^\/+/, '').replace(/\.md$/i, '');
850
- const filePath = path.join(process.cwd(), promptsDir, `${safeKey}.md`);
851
- if (!fs.existsSync(filePath)) {
782
+ const resolved = resolveSkillFileByKey({ promptsDir, key: safeKey });
783
+ if (!resolved || !fs.existsSync(resolved.path)) {
784
+ const expectedFlat = path.join(process.cwd(), promptsDir, `${safeKey}.md`);
785
+ const expectedDir = path.join(process.cwd(), promptsDir, safeKey, 'SKILL.md');
852
786
  return {
853
787
  content: [
854
788
  {
855
789
  type: 'text',
856
- text: `Workspace skill not found for key '${safeKey}'. Expected file at ${path.relative(
857
- process.cwd(),
858
- filePath,
859
- )}`,
790
+ text: `Workspace skill not found for key '${safeKey}'. Expected at ${path.relative(process.cwd(), expectedFlat)} or ${path.relative(process.cwd(), expectedDir)}`,
860
791
  },
861
792
  ],
862
793
  };
863
794
  }
864
- const body = fs.readFileSync(filePath, 'utf8');
795
+ const body = fs.readFileSync(resolved.path, 'utf8');
865
796
  return {
866
797
  content: [
867
798
  {
868
799
  type: 'text',
869
- text: `Loaded workspace skill '${safeKey}' from ${path.relative(process.cwd(), filePath)}.\n\n${body}`,
800
+ text: `Loaded workspace skill '${safeKey}' from ${path.relative(process.cwd(), resolved.path)}.\n\n${body}`,
870
801
  },
871
- { type: 'json', text: JSON.stringify({ key: safeKey, path: filePath, body }, null, 2) },
802
+ { type: 'json', text: JSON.stringify({ key: safeKey, path: resolved.path, baseDir: resolved.baseDir, body }, null, 2) },
872
803
  ],
873
804
  };
874
805
  } catch (error) {
875
- return {
876
- content: [{ type: 'text', text: `Error loading workspace skill: ${error.message}` }],
877
- };
806
+ return { content: [{ type: 'text', text: `Error loading workspace skill: ${error.message}` }] };
878
807
  }
879
808
  }
880
809
 
@@ -0,0 +1,140 @@
1
+ const { ethers } = require('ethers');
2
+ const { resolveGovContext } = require('../../utils/gov-context');
3
+
4
+ /**
5
+ * Comprehensive Governance Diagnosis
6
+ * @param {object} opts - { subdao, provider, signer }
7
+ * @returns {object} Report with recommendations and structured fix payloads
8
+ */
9
+ async function diagnoseSubDAO({ subdao, provider, signer }) {
10
+ const out = {
11
+ subdao,
12
+ governor: null,
13
+ timelock: null,
14
+ mode: null,
15
+ checks: {},
16
+ recommendations: [], // { type, msg, fixCmd, fixPayload }
17
+ };
18
+
19
+ // 1. Resolve Context
20
+ try {
21
+ const ctx = await resolveGovContext({ subdaoOpt: subdao, govOpt: undefined, provider });
22
+ out.governor = ctx.governor;
23
+ out.timelock = ctx.timelock;
24
+ out.checks.context = 'ok';
25
+ } catch (e) {
26
+ out.checks.context = 'failed';
27
+ out.checks.contextError = e.message;
28
+ return out;
29
+ }
30
+
31
+ if (!out.governor || !out.timelock) {
32
+ out.recommendations.push({ msg: 'Could not resolve Governor or Timelock. Verify SubDAO address.' });
33
+ return out;
34
+ }
35
+
36
+ // 2. Detect Mode
37
+ try {
38
+ const SubABI = ['function getGovernanceMode() view returns (uint8)', 'function promptRegistry() view returns (address)'];
39
+ const sd = new ethers.Contract(subdao, SubABI, provider);
40
+ const mode = await sd.getGovernanceMode();
41
+ out.mode = Number(mode); // 0=Operator/Personal, 1=Community
42
+ out.registry = await sd.promptRegistry();
43
+ } catch (e) {
44
+ out.checks.mode = 'failed';
45
+ }
46
+
47
+ // 3. Roles Check (Timelock)
48
+ try {
49
+ const tl = new ethers.Contract(out.timelock, [
50
+ 'function PROPOSER_ROLE() view returns (bytes32)',
51
+ 'function EXECUTOR_ROLE() view returns (bytes32)',
52
+ 'function hasRole(bytes32,address) view returns (bool)',
53
+ 'function grantRole(bytes32,address)'
54
+ ], provider);
55
+
56
+ const PROPOSER = await tl.PROPOSER_ROLE();
57
+ const EXECUTOR = await tl.EXECUTOR_ROLE();
58
+
59
+ out.govIsProposer = await tl.hasRole(PROPOSER, out.governor);
60
+ out.anyoneExec = await tl.hasRole(EXECUTOR, ethers.ZeroAddress);
61
+
62
+ if (!out.govIsProposer) {
63
+ const iface = tl.interface;
64
+ const calldata = iface.encodeFunctionData('grantRole', [PROPOSER, out.governor]);
65
+ out.recommendations.push({
66
+ type: 'role_missing',
67
+ msg: 'Governor lacks PROPOSER_ROLE on Timelock.',
68
+ fixCmd: `sage timelock execute-call --subdao ${subdao} --to ${out.timelock} --sig "grantRole(bytes32,address)" --args "${PROPOSER},${out.governor}"`,
69
+ fixPayload: { to: out.timelock, data: calldata, description: 'Grant PROPOSER to Governor' }
70
+ });
71
+ }
72
+ if (!out.anyoneExec) {
73
+ const iface = tl.interface;
74
+ const calldata = iface.encodeFunctionData('grantRole', [EXECUTOR, ethers.ZeroAddress]);
75
+ out.recommendations.push({
76
+ type: 'role_missing',
77
+ msg: 'Timelock execution is restricted (executor != address(0)).',
78
+ fixCmd: `sage timelock execute-call --subdao ${subdao} --to ${out.timelock} --sig "grantRole(bytes32,address)" --args "${EXECUTOR},${ethers.ZeroAddress}"`,
79
+ fixPayload: { to: out.timelock, data: calldata, description: 'Open Execution (Grant EXECUTOR to 0x0)' }
80
+ });
81
+ }
82
+ } catch (e) {
83
+ out.checks.roles = 'failed';
84
+ }
85
+
86
+ // 4. PromptRegistry Governance
87
+ if (out.registry && out.registry !== ethers.ZeroAddress) {
88
+ try {
89
+ const reg = new ethers.Contract(out.registry, [
90
+ 'function GOVERNANCE_ROLE() view returns (bytes32)',
91
+ 'function hasRole(bytes32,address) view returns (bool)',
92
+ 'function grantRole(bytes32,address)'
93
+ ], provider);
94
+ const GOV_ROLE = await reg.GOVERNANCE_ROLE().catch(() => null);
95
+ if (GOV_ROLE) {
96
+ const has = await reg.hasRole(GOV_ROLE, subdao).catch(() => false);
97
+ if (!has) {
98
+ const calldata = reg.interface.encodeFunctionData('grantRole', [GOV_ROLE, subdao]);
99
+ out.recommendations.push({
100
+ type: 'registry_role',
101
+ msg: 'SubDAO missing GOVERNANCE_ROLE on PromptRegistry.',
102
+ fixCmd: `sage timelock execute-call --subdao ${subdao} --to ${out.registry} --sig "grantRole(bytes32,address)" --args "${GOV_ROLE},${subdao}"`,
103
+ fixPayload: { to: out.registry, data: calldata, description: 'Grant GOVERNANCE_ROLE to SubDAO' }
104
+ });
105
+ }
106
+ }
107
+ } catch (e) {
108
+ out.checks.registry = 'failed';
109
+ }
110
+ }
111
+
112
+ // 5. LibraryRegistry (Scoped)
113
+ const libRegAddr = process.env.LIBRARY_REGISTRY_ADDRESS;
114
+ if (libRegAddr && out.timelock) {
115
+ try {
116
+ const lib = new ethers.Contract(libRegAddr, [
117
+ 'function libraryAdminRole(address subdao) view returns (bytes32)',
118
+ 'function hasRole(bytes32,address) view returns (bool)',
119
+ 'function grantRole(bytes32,address)'
120
+ ], provider);
121
+ const scopedRole = await lib.libraryAdminRole(subdao);
122
+ const has = await lib.hasRole(scopedRole, out.timelock).catch(()=>false);
123
+ if (!has) {
124
+ const calldata = lib.interface.encodeFunctionData('grantRole', [scopedRole, out.timelock]);
125
+ out.recommendations.push({
126
+ type: 'library_role',
127
+ msg: 'Timelock missing LIBRARY_ADMIN_ROLE (scoped) on LibraryRegistry.',
128
+ fixCmd: `sage safe propose --safe <TREASURY> --to ${libRegAddr} --data ${calldata} --operation 0`,
129
+ fixPayload: { to: libRegAddr, data: calldata, description: 'Grant Scoped LIBRARY_ADMIN to Timelock' }
130
+ });
131
+ }
132
+ } catch (e) {
133
+ out.checks.library = 'failed';
134
+ }
135
+ }
136
+
137
+ return out;
138
+ }
139
+
140
+ module.exports = { diagnoseSubDAO };