@dharma-ai-labs/agent-fabric 0.1.13 → 0.1.16

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.
package/dist/index.js CHANGED
@@ -2,7 +2,7 @@
2
2
  import { execFile } from 'node:child_process';
3
3
  import { createHash, createPrivateKey, createPublicKey, randomUUID } from 'node:crypto';
4
4
  import { realpathSync } from 'node:fs';
5
- import { access, link, mkdir, open, readFile, realpath, rename, rm, unlink, writeFile } from 'node:fs/promises';
5
+ import { access, link, mkdir, open, readFile, readdir, realpath, rename, rm, unlink, writeFile } from 'node:fs/promises';
6
6
  import { homedir } from 'node:os';
7
7
  import { basename, dirname, isAbsolute, relative, resolve } from 'node:path';
8
8
  import { fileURLToPath } from 'node:url';
@@ -12,34 +12,40 @@ import { buildTrajectoryCapsule, redactValue, referencesExcludedPath } from '@dh
12
12
  import { assertPolicy, loadOrganizationPolicy, verifyServerAuthorizedPolicy } from '@dharma-ai-labs/agent-fabric-policy';
13
13
  import { agyAdapter, claudeAdapter, codexAdapter, providerAdapters } from '@dharma-ai-labs/agent-fabric-provider-adapters';
14
14
  import { AgentFabricClient, beginEnrollment, loadOrCreateDeviceIdentity, normalizeHqUrl, pollEnrollment, saveDeviceConfig, } from '@dharma-ai-labs/agent-fabric-relay-client';
15
+ import { AgentFabricClient as AgentFabricApiClient } from '@dharma-ai-labs/agent-fabric-sdk';
15
16
  import { getActiveSkillBundleId, installSkillBundle, verifySkillBundle } from '@dharma-ai-labs/agent-fabric-skill-manager';
16
17
  import { executeTask, FileTaskReceiptStore } from '@dharma-ai-labs/agent-fabric-task-runner';
17
- const VERSION = '0.1.13';
18
- const USAGE = 'Usage: dharma <onboard|login|status|providers list|workspace add|workspace sync|evidence preview|evidence capture|evidence capture-batch|evidence sync|evidence run-request|relay start|tasks run-once|skills sync|skills status|skills verify> [options]';
18
+ import { CLI_USAGE } from './usage.js';
19
+ const VERSION = '0.1.16';
20
+ const USAGE = CLI_USAGE;
19
21
  const execFileAsync = promisify(execFile);
20
- function options(args) {
22
+ export function parseCliOptions(args) {
21
23
  const positional = [];
22
24
  const flags = new Map();
25
+ const repeated = new Map();
23
26
  for (let index = 0; index < args.length; index += 1) {
24
27
  const value = args[index];
25
28
  if (!value.startsWith('--')) {
26
29
  positional.push(value);
27
30
  continue;
28
31
  }
29
- const [rawKey, inline] = value.slice(2).split('=', 2);
32
+ const rawOption = value.slice(2);
33
+ const separator = rawOption.indexOf('=');
34
+ const rawKey = separator < 0 ? rawOption : rawOption.slice(0, separator);
35
+ const inline = separator < 0 ? undefined : rawOption.slice(separator + 1);
30
36
  if (inline !== undefined) {
31
37
  flags.set(rawKey, inline);
38
+ repeated.set(rawKey, [...(repeated.get(rawKey) || []), inline]);
32
39
  continue;
33
40
  }
34
41
  const next = args[index + 1];
35
- if (next && !next.startsWith('--')) {
36
- flags.set(rawKey, next);
42
+ const parsed = next && !next.startsWith('--') ? next : true;
43
+ flags.set(rawKey, parsed);
44
+ repeated.set(rawKey, [...(repeated.get(rawKey) || []), parsed]);
45
+ if (parsed !== true)
37
46
  index += 1;
38
- }
39
- else
40
- flags.set(rawKey, true);
41
47
  }
42
- return { positional, flags };
48
+ return { positional, flags, repeated };
43
49
  }
44
50
  function required(flags, name) {
45
51
  const value = flags.get(name);
@@ -47,6 +53,9 @@ function required(flags, name) {
47
53
  throw new Error(`Missing required option --${name}.`);
48
54
  return value;
49
55
  }
56
+ export function portalUrl(flags, fallback = 'https://www.dharma-ai.io') {
57
+ return String(flags.get('portal-url') || flags.get('hq-url') || fallback);
58
+ }
50
59
  function print(value) {
51
60
  process.stdout.write(typeof value === 'string' ? `${value}\n` : `${JSON.stringify(value, null, 2)}\n`);
52
61
  }
@@ -808,6 +817,62 @@ function deterministicUuid(value) {
808
817
  const hex = bytes.toString('hex');
809
818
  return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
810
819
  }
820
+ export function normalizeGitRemoteIdentity(value) {
821
+ const raw = value.trim();
822
+ if (!raw || /[\u0000-\u001f\u007f]/.test(raw))
823
+ throw new Error('Git remote is empty or invalid.');
824
+ let host;
825
+ let repositoryPath;
826
+ const scp = raw.match(/^(?:[^@/\s]+@)?([^:/\s]+):(.+)$/);
827
+ if (!raw.includes('://') && scp && !/^[A-Za-z]:[\\/]/.test(raw)) {
828
+ host = scp[1].toLowerCase();
829
+ repositoryPath = scp[2];
830
+ }
831
+ else {
832
+ let remote;
833
+ try {
834
+ remote = new URL(raw);
835
+ }
836
+ catch {
837
+ throw new Error('Git remote must be a hosted URL or use --repository-key.');
838
+ }
839
+ if (!['https:', 'http:', 'ssh:', 'git:'].includes(remote.protocol) || !remote.hostname) {
840
+ throw new Error('Local and file Git remotes require an explicit stable --repository-key.');
841
+ }
842
+ host = remote.hostname.toLowerCase();
843
+ const port = remote.port && !((remote.protocol === 'https:' && remote.port === '443') || (remote.protocol === 'http:' && remote.port === '80'))
844
+ ? `:${remote.port}` : '';
845
+ host += port;
846
+ repositoryPath = remote.pathname;
847
+ }
848
+ repositoryPath = repositoryPath.replace(/^\/+|\/+$/g, '').replace(/\.git$/i, '');
849
+ if (!repositoryPath || repositoryPath.includes('..') || /\s/.test(repositoryPath)) {
850
+ throw new Error('Git remote repository path is invalid.');
851
+ }
852
+ if (host === 'github.com' || host === 'gitlab.com')
853
+ repositoryPath = repositoryPath.toLowerCase();
854
+ return `${host}/${repositoryPath}`;
855
+ }
856
+ export function sourceRepositoryFingerprint(remote, explicitKey) {
857
+ const key = explicitKey?.trim();
858
+ let identity;
859
+ let source;
860
+ if (key) {
861
+ if (!/^[A-Za-z0-9][A-Za-z0-9._:/-]{0,159}$/.test(key) || key.includes('..')) {
862
+ throw new Error('--repository-key must be a stable 1-160 character identifier without secrets or path traversal.');
863
+ }
864
+ identity = `key:${key}`;
865
+ source = 'explicit_key';
866
+ }
867
+ else if (remote) {
868
+ identity = `remote:${normalizeGitRemoteIdentity(remote)}`;
869
+ source = 'remote';
870
+ }
871
+ else {
872
+ throw new Error('Repository has no hosted Git remote. Supply a stable --repository-key for this repository.');
873
+ }
874
+ return { fingerprint: `sha256:${createHash('sha256').update(identity).digest('hex')}`, source };
875
+ }
811
876
  function responseTextFromEvent(value) {
812
877
  if (!value || typeof value !== 'object' || Array.isArray(value))
813
878
  return null;
@@ -901,6 +966,15 @@ async function registry() {
901
966
  return [];
902
967
  }
903
968
  }
969
+ async function saveRegistry(items) {
970
+ await mkdir(resolve(dharmaHome(), 'registry'), { recursive: true, mode: 0o700 });
971
+ await writeFile(workspaceRegistryPath(), `${JSON.stringify(items, null, 2)}\n`, { mode: 0o600 });
972
+ }
973
+ async function saveWorkspaceRecord(entry) {
974
+ const items = (await registry()).filter((item) => item.workspaceId !== entry.workspaceId);
975
+ items.push(entry);
976
+ await saveRegistry(items);
977
+ }
904
978
  async function gitValue(workspace, argv) {
905
979
  try {
906
980
  return (await execFileAsync('git', ['-C', workspace, ...argv], { timeout: 10_000 })).stdout.trim() || null;
@@ -914,6 +988,121 @@ async function client() {
914
988
  await instance.openSession(VERSION);
915
989
  return instance;
916
990
  }
991
+ async function organizationApi(flags) {
992
+ let enrolled = null;
993
+ try {
994
+ enrolled = JSON.parse(await readFile(configPath(), 'utf8'));
995
+ }
996
+ catch { }
997
+ const organizationId = String(flags.get('organization-id') || enrolled?.organizationId || '').trim();
998
+ if (!organizationId)
999
+ throw new Error('Organization command requires --organization-id or an enrolled device.');
1000
+ const token = String(process.env.DHARMA_ORG_API_TOKEN || '').trim();
1001
+ if (!token)
1002
+ throw new Error('Organization command requires DHARMA_ORG_API_TOKEN. Tokens are not accepted on the command line.');
1003
+ return new AgentFabricApiClient({
1004
+ organizationId,
1005
+ token,
1006
+ baseUrl: portalUrl(flags, enrolled?.hqUrl || 'https://www.dharma-ai.io'),
1007
+ });
1008
+ }
1009
+ async function commandJsonBody(flags) {
1010
+ const inline = flags.get('json-body');
1011
+ const path = flags.get('body-file');
1012
+ if (typeof inline === 'string' && typeof path === 'string') {
1013
+ throw new Error('Use either --json-body or --body-file, not both.');
1014
+ }
1015
+ const serialized = typeof inline === 'string'
1016
+ ? inline
1017
+ : typeof path === 'string'
1018
+ ? await readFile(resolve(path), 'utf8')
1019
+ : '{}';
1020
+ const parsed = JSON.parse(serialized);
1021
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
1022
+ throw new Error('Command body must be a JSON object.');
1023
+ }
1024
+ return parsed;
1025
+ }
1026
+ export function requireExplicitConfirmation(flags, action) {
1027
+ if (flags.get('confirm') !== true) {
1028
+ throw new Error(`${action} requires --confirm after reviewing organization scope, cost, and authority.`);
1029
+ }
1030
+ }
1031
+ async function runOrganizationCommand(command, subcommand, flags) {
1032
+ const api = await organizationApi(flags);
1033
+ if (command === 'organization' && subcommand === 'status')
1034
+ return api.instructions();
1035
+ if (command === 'agents' && subcommand === 'list')
1036
+ return api.listAgents();
1037
+ if (command === 'agents' && subcommand === 'bind-runtime') {
1038
+ requireExplicitConfirmation(flags, 'Binding a managed or cloud BYOK runtime endpoint');
1039
+ const agentId = required(flags, 'agent-id');
1040
+ const body = await commandJsonBody(flags);
1041
+ const endpointKind = String(body.endpointKind || '');
1042
+ if (!['managed_runtime', 'cloud_byok'].includes(endpointKind)) {
1043
+ throw new Error('Runtime endpoint kind must be managed_runtime or cloud_byok.');
1044
+ }
1045
+ return api.bindRuntimeEndpoint(agentId, {
1046
+ endpointKind: endpointKind,
1047
+ managedAgentId: String(body.managedAgentId || ''),
1048
+ runtimeBindingId: String(body.runtimeBindingId || ''),
1049
+ ...(body.priority === undefined ? {} : { priority: Number(body.priority) }),
1050
+ });
1051
+ }
1052
+ if (command === 'experiments' && subcommand === 'list')
1053
+ return api.listAnalysisWindows();
1054
+ if (command === 'experiments' && subcommand === 'run') {
1055
+ requireExplicitConfirmation(flags, 'Running an experiment');
1056
+ return api.requestAnalysis(await commandJsonBody(flags));
1057
+ }
1058
+ if (command === 'failures' && subcommand === 'list')
1059
+ return api.listFailures();
1060
+ if (command === 'remediations' && subcommand === 'list')
1061
+ return api.listRemediations();
1062
+ if (command === 'remediations' && subcommand === 'act') {
1063
+ requireExplicitConfirmation(flags, 'Changing a repository remediation release');
1064
+ const targetId = required(flags, 'target-id');
1065
+ const body = await commandJsonBody(flags);
1066
+ const action = String(flags.get('action') || body.action || '');
1067
+ if (!['run_backtest', 'link_backtest', 'approve', 'merge_pr', 'release', 'expand', 'rollback'].includes(action)) {
1068
+ throw new Error('Remediation action must be run_backtest, link_backtest, approve, merge_pr, release, expand, or rollback.');
1069
+ }
1070
+ return api.transitionRemediationTarget(targetId, {
1071
+ ...body,
1072
+ action: action,
1073
+ });
1074
+ }
1075
+ if (command === 'skills' && subcommand === 'list')
1076
+ return api.listSkills();
1077
+ if (command === 'skills' && subcommand === 'release') {
1078
+ requireExplicitConfirmation(flags, 'Releasing a skill');
1079
+ return api.releaseSkill(await commandJsonBody(flags));
1080
+ }
1081
+ if (command === 'skills' && (subcommand === 'rollout' || subcommand === 'rollback')) {
1082
+ requireExplicitConfirmation(flags, `${subcommand === 'rollback' ? 'Rolling back' : 'Rolling out'} a skill`);
1083
+ const bundleId = required(flags, 'bundle-id');
1084
+ const body = await commandJsonBody(flags);
1085
+ return api.transitionSkillRollout(bundleId, {
1086
+ action: subcommand === 'rollback' ? 'rollback' : String(body.action || 'start'),
1087
+ ...(body.canaryPercent === undefined ? {} : { canaryPercent: Number(body.canaryPercent) }),
1088
+ });
1089
+ }
1090
+ if (command === 'tasks' && subcommand === 'list')
1091
+ return api.listTasks();
1092
+ if (command === 'tasks' && subcommand === 'dispatch') {
1093
+ requireExplicitConfirmation(flags, 'Dispatching a task');
1094
+ return api.dispatchTask(await commandJsonBody(flags));
1095
+ }
1096
+ if (command === 'handoffs' && subcommand === 'list')
1097
+ return api.listHandoffs();
1098
+ if (command === 'handoffs' && subcommand === 'dispatch') {
1099
+ requireExplicitConfirmation(flags, 'Dispatching an A2A handoff');
1100
+ return api.dispatchHandoff(await commandJsonBody(flags));
1101
+ }
1102
+ if (command === 'usage' && subcommand === 'list')
1103
+ return api.usage();
1104
+ return null;
1105
+ }
917
1106
  async function openVerificationUri(url) {
918
1107
  const attempts = process.platform === 'darwin'
919
1108
  ? [['open', [url]]]
@@ -939,7 +1128,7 @@ async function login(flags) {
939
1128
  pending = JSON.parse(await readFile(pendingEnrollmentPath(), 'utf8'));
940
1129
  }
941
1130
  else {
942
- const hqUrl = normalizeHqUrl(String(flags.get('hq-url') || 'https://www.dharma-ai.io'));
1131
+ const hqUrl = normalizeHqUrl(portalUrl(flags));
943
1132
  const organizationId = required(flags, 'organization-id');
944
1133
  const name = String(flags.get('device-name') || `${process.env.USER || process.env.USERNAME || 'developer'} device`);
945
1134
  const devicePlatform = await platform();
@@ -1213,19 +1402,207 @@ async function workspaceAdd(flags, positional) {
1213
1402
  const organizationId = String(flags.get('organization-id') || device.organizationId);
1214
1403
  if (organizationId !== device.organizationId)
1215
1404
  throw new Error('Workspace organization must match the enrolled device.');
1216
- await mkdir(resolve(dharmaHome(), 'registry'), { recursive: true, mode: 0o700 });
1217
1405
  const workspaceId = deterministicUuid(`${organizationId}:${device.deviceId}:${path}`);
1218
1406
  const remote = await gitValue(path, ['config', '--get', 'remote.origin.url']);
1407
+ const identity = sourceRepositoryFingerprint(remote, typeof flags.get('repository-key') === 'string' ? String(flags.get('repository-key')) : null);
1219
1408
  const entry = {
1220
1409
  workspaceId, organizationId, name: String(flags.get('name') || basename(path)), path,
1221
1410
  routeHash: `sha256:${createHash('sha256').update(path).digest('hex')}`,
1222
- repositoryRemoteHash: remote ? `sha256:${createHash('sha256').update(remote).digest('hex')}` : null,
1411
+ repositoryRemoteHash: identity.fingerprint,
1412
+ repositoryIdentityVersion: 'normalized-v1',
1413
+ repositoryAgentId: null,
1414
+ repositoryBindingId: null,
1415
+ repositoryAgentKey: null,
1416
+ controlBranch: null,
1223
1417
  defaultBranch: await gitValue(path, ['branch', '--show-current']), status: 'active',
1224
1418
  };
1225
- const without = (await registry()).filter((item) => item.workspaceId !== workspaceId);
1226
- without.push(entry);
1227
- await writeFile(workspaceRegistryPath(), `${JSON.stringify(without, null, 2)}\n`, { mode: 0o600 });
1228
- return { ok: true, workspaceId, organizationId, pathStoredLocally: true, pathDisclosedToServer: false };
1419
+ await saveWorkspaceRecord(entry);
1420
+ return {
1421
+ ok: true,
1422
+ workspaceId,
1423
+ organizationId,
1424
+ sourceFingerprint: identity.fingerprint,
1425
+ repositoryIdentitySource: identity.source,
1426
+ pathStoredLocally: true,
1427
+ pathDisclosedToServer: false,
1428
+ };
1429
+ }
1430
+ async function discoverRepositoryAt(path) {
1431
+ const canonical = await realpath(path);
1432
+ const topLevel = await gitValue(canonical, ['rev-parse', '--show-toplevel']);
1433
+ if (!topLevel)
1434
+ return null;
1435
+ const workspace = await realpath(topLevel);
1436
+ const remote = await gitValue(workspace, ['config', '--get', 'remote.origin.url']);
1437
+ let normalizedRemote = null;
1438
+ let fingerprint = null;
1439
+ if (remote) {
1440
+ normalizedRemote = normalizeGitRemoteIdentity(remote);
1441
+ fingerprint = sourceRepositoryFingerprint(remote).fingerprint;
1442
+ }
1443
+ return {
1444
+ name: basename(workspace),
1445
+ path: workspace,
1446
+ remote: normalizedRemote,
1447
+ sourceFingerprint: fingerprint,
1448
+ defaultBranch: await gitValue(workspace, ['branch', '--show-current']),
1449
+ connectable: Boolean(fingerprint),
1450
+ requiredAction: fingerprint ? null : 'Supply --repository-key when connecting this repository.',
1451
+ };
1452
+ }
1453
+ async function repositoriesDiscover(flags, positional, roots) {
1454
+ const requested = [...positional, ...roots.filter((value) => typeof value === 'string')];
1455
+ if (requested.length === 0)
1456
+ requested.push(String(flags.get('root') || '.'));
1457
+ const discovered = new Map();
1458
+ for (const requestedRoot of requested) {
1459
+ const root = await realpath(requestedRoot);
1460
+ const direct = await discoverRepositoryAt(root);
1461
+ if (direct) {
1462
+ discovered.set(direct.path, direct);
1463
+ continue;
1464
+ }
1465
+ for (const entry of await readdir(root, { withFileTypes: true })) {
1466
+ if (!entry.isDirectory())
1467
+ continue;
1468
+ const candidate = await discoverRepositoryAt(resolve(root, entry.name));
1469
+ if (candidate)
1470
+ discovered.set(candidate.path, candidate);
1471
+ }
1472
+ }
1473
+ return {
1474
+ ok: true,
1475
+ scanBoundary: 'explicit_roots_and_immediate_children',
1476
+ repositories: [...discovered.values()].filter(Boolean),
1477
+ };
1478
+ }
1479
+ function repositoryKeyAssignments(paths, values) {
1480
+ const keys = values.filter((value) => typeof value === 'string');
1481
+ const assignments = new Map();
1482
+ if (keys.length === 0)
1483
+ return assignments;
1484
+ const named = keys.filter((value) => value.includes('='));
1485
+ if (named.length > 0 && named.length !== keys.length) {
1486
+ throw new Error('Use either ordered --repository-key values or path=key assignments, not both.');
1487
+ }
1488
+ if (named.length > 0) {
1489
+ for (const value of named) {
1490
+ const split = value.indexOf('=');
1491
+ const path = resolve(value.slice(0, split));
1492
+ const index = paths.findIndex((candidate) => resolve(candidate) === path);
1493
+ if (index < 0)
1494
+ throw new Error(`Repository key assignment does not match a selected repository: ${value.slice(0, split)}`);
1495
+ assignments.set(index, value.slice(split + 1));
1496
+ }
1497
+ return assignments;
1498
+ }
1499
+ if (keys.length !== paths.length) {
1500
+ if (paths.length === 1 && keys.length === 1)
1501
+ return new Map([[0, keys[0]]]);
1502
+ throw new Error('Provide one ordered --repository-key per selected repository without a hosted remote.');
1503
+ }
1504
+ keys.forEach((key, index) => assignments.set(index, key));
1505
+ return assignments;
1506
+ }
1507
+ async function repositoriesConnect(flags, positional, repeatedRepos, repeatedKeys, repeatedProviders) {
1508
+ const selected = [...positional, ...repeatedRepos.filter((value) => typeof value === 'string')];
1509
+ if (selected.length === 0)
1510
+ selected.push(String(flags.get('repo') || '.'));
1511
+ const canonical = await Promise.all(selected.map((path) => realpath(path)));
1512
+ const unique = [...new Set(canonical)];
1513
+ const keys = repositoryKeyAssignments(unique, repeatedKeys.length ? repeatedKeys : (typeof flags.get('repository-key') === 'string' ? [String(flags.get('repository-key'))] : []));
1514
+ const providerIds = parseSelectedProviderIds(repeatedProviders.length ? repeatedProviders : (typeof flags.get('provider') === 'string' ? [String(flags.get('provider'))] : []));
1515
+ const results = [];
1516
+ for (let index = 0; index < unique.length; index += 1) {
1517
+ const connectFlags = new Map(flags);
1518
+ connectFlags.set('workspace', unique[index]);
1519
+ const repositoryKey = keys.get(index);
1520
+ if (repositoryKey)
1521
+ connectFlags.set('repository-key', repositoryKey);
1522
+ else
1523
+ connectFlags.delete('repository-key');
1524
+ if (providerIds)
1525
+ connectFlags.set('providers', providerIds.join(','));
1526
+ const result = await onboard(connectFlags);
1527
+ results.push(result);
1528
+ if (result?.stage === 'approve_device')
1529
+ break;
1530
+ }
1531
+ return { ok: true, requested: unique.length, connected: results.length, repositories: results };
1532
+ }
1533
+ export function parseSelectedProviderIds(values) {
1534
+ const selected = [...new Set(values
1535
+ .filter((value) => typeof value === 'string')
1536
+ .flatMap((value) => value.split(','))
1537
+ .map((value) => value.trim())
1538
+ .filter(Boolean))];
1539
+ if (selected.length === 0)
1540
+ return null;
1541
+ if (selected.some((value) => !['codex', 'claude', 'agy'].includes(value))) {
1542
+ throw new Error('Repository providers must be codex, claude, or agy. Repeat --provider to select more than one.');
1543
+ }
1544
+ return selected;
1545
+ }
1546
+ function selectedProviderAdapters(providerIds) {
1547
+ return providerIds
1548
+ ? providerAdapters.filter((adapter) => providerIds.includes(adapter.providerId))
1549
+ : providerAdapters;
1550
+ }
1551
+ async function repositoriesList(flags) {
1552
+ const verbose = flags.has('verbose') || flags.has('diagnostic');
1553
+ return {
1554
+ ok: true,
1555
+ repositories: (await registry()).map((item) => ({
1556
+ workspaceId: item.workspaceId,
1557
+ name: item.name,
1558
+ repositoryAgentId: item.repositoryAgentId || null,
1559
+ repositoryAgentKey: item.repositoryAgentKey || null,
1560
+ controlBranch: item.controlBranch || null,
1561
+ connected: Boolean(item.repositoryAgentId && item.controlBranch),
1562
+ ...(verbose ? { localPath: item.path, sourceFingerprint: item.repositoryRemoteHash } : {}),
1563
+ })),
1564
+ };
1565
+ }
1566
+ async function bindRepositoryAgent(fabric, item) {
1567
+ if (item.repositoryAgentId && item.repositoryBindingId && item.repositoryAgentKey && item.controlBranch)
1568
+ return item;
1569
+ const remote = await gitValue(item.path, ['config', '--get', 'remote.origin.url']);
1570
+ const currentIdentity = item.repositoryIdentityVersion === 'normalized-v1'
1571
+ ? item.repositoryRemoteHash
1572
+ : remote ? sourceRepositoryFingerprint(remote).fingerprint : item.repositoryRemoteHash;
1573
+ if (!currentIdentity) {
1574
+ throw new Error('Repository identity is missing. Re-run dharma workspace add with --repository-key.');
1575
+ }
1576
+ const response = await fabric.connectRepositoryAgent({
1577
+ sourceFingerprint: currentIdentity,
1578
+ displayName: item.name,
1579
+ defaultSourceRef: item.defaultBranch,
1580
+ workspaceId: item.workspaceId,
1581
+ legacySourceFingerprint: item.repositoryIdentityVersion === 'normalized-v1' ? null : item.repositoryRemoteHash,
1582
+ });
1583
+ const repositoryAgent = response.repositoryAgent && typeof response.repositoryAgent === 'object'
1584
+ ? response.repositoryAgent
1585
+ : null;
1586
+ const branch = repositoryAgent?.branch && typeof repositoryAgent.branch === 'object'
1587
+ ? repositoryAgent.branch
1588
+ : null;
1589
+ const updated = {
1590
+ ...item,
1591
+ repositoryRemoteHash: currentIdentity,
1592
+ repositoryIdentityVersion: 'normalized-v1',
1593
+ repositoryAgentId: String(repositoryAgent?.organization_agent_id || ''),
1594
+ repositoryBindingId: String(repositoryAgent?.id || ''),
1595
+ repositoryAgentKey: String(repositoryAgent?.agent_key || ''),
1596
+ controlBranch: String(repositoryAgent?.control_branch || branch?.branch || ''),
1597
+ };
1598
+ if (!/^[0-9a-f-]{36}$/i.test(updated.repositoryAgentId || '')
1599
+ || !/^[0-9a-f-]{36}$/i.test(updated.repositoryBindingId || '')
1600
+ || !/^repo:[a-f0-9]{24}$/.test(updated.repositoryAgentKey || '')
1601
+ || !/^agents\/[a-z0-9][a-z0-9._-]*-[a-f0-9]{8}$/.test(updated.controlBranch || '')) {
1602
+ throw new Error('Dharma HQ returned an invalid repository-agent binding.');
1603
+ }
1604
+ await saveWorkspaceRecord(updated);
1605
+ return updated;
1229
1606
  }
1230
1607
  async function workspaceSync(flags, positional) {
1231
1608
  const workspaceId = positional[0] || required(flags, 'workspace-id');
@@ -1239,13 +1616,16 @@ async function workspaceSync(flags, positional) {
1239
1616
  next: `dharma workspace sync ${workspaceId} --policy-revision ${policyRevision} --apply`,
1240
1617
  };
1241
1618
  }
1242
- return syncWorkspacePolicy(await client(), item, policyRevision, true);
1619
+ const fabric = await client();
1620
+ const providerIds = parseSelectedProviderIds(typeof flags.get('provider') === 'string' ? [String(flags.get('provider'))] : []);
1621
+ return syncWorkspacePolicy(fabric, await bindRepositoryAgent(fabric, item), policyRevision, true, providerIds);
1243
1622
  }
1244
- async function syncWorkspacePolicy(fabric, item, policyRevision, apply = true) {
1245
- const providers = await Promise.all(providerAdapters.map((adapter) => adapter.capability()));
1623
+ async function syncWorkspacePolicy(fabric, item, policyRevision, apply = true, providerIds = null) {
1624
+ const providers = await Promise.all(selectedProviderAdapters(providerIds).map((adapter) => adapter.capability()));
1246
1625
  const response = await fabric.registerWorkspace({
1247
1626
  workspaceId: item.workspaceId, name: item.name, routeHash: item.routeHash,
1248
1627
  repositoryRemoteHash: item.repositoryRemoteHash, defaultBranch: item.defaultBranch,
1628
+ repositoryAgentId: item.repositoryAgentId,
1249
1629
  policyRevision, providers,
1250
1630
  });
1251
1631
  const generated = await materializeWorkspacePolicy({
@@ -1334,46 +1714,68 @@ Use the installed \`dharma\` CLI for organization-scoped agent work. Never print
1334
1714
  6. Use only signed tasks whose organization, device, workspace, authority, budget, and skill pin pass local validation.
1335
1715
  7. For cross-agent help, ask the control plane for a structured, task-bound handoff. Do not open arbitrary chat, shell, file, merge, deploy, or secret authority.
1336
1716
  8. Install only signed skill bundles. Preserve the active bundle receipt and automatic rollback result.
1717
+ 9. Use the organization MCP connection for role-scoped status, experiments, failures, remediations, rollouts, and profile administration. Reads may run directly. Paid evals, task dispatch, GitHub writes, approvals, rollout, rollback, and profile mutation require the granted scope and explicit confirmation.
1337
1718
 
1338
- The organization contract and API origin are recorded in \`.dharma/agent-fabric.json\`. API calls must use the published SDK and a scoped organization token supplied at runtime, never a credential committed to this repository.
1719
+ The organization contract and API origin are recorded in \`.dharma/agent-fabric.json\`; the logical repository-agent identity is recorded in \`.dharma/repository-agent.json\`. API calls must use the published SDK and a scoped organization token supplied at runtime, never a credential committed to this repository.
1339
1720
  `;
1340
1721
  const reference = `# Organization connection
1341
1722
 
1342
1723
  - HQ API: ${input.hqUrl}
1343
1724
  - Organization: ${input.organizationId}
1344
1725
  - Workspace: ${input.workspaceId}
1726
+ - Repository agent: ${input.repositoryAgentKey || 'pending'}
1727
+ - Permanent control branch: ${input.controlBranch || 'pending'}
1345
1728
  - Policy revision: ${input.policyRevision}
1346
1729
  - OpenAPI: ${input.hqUrl}/api/v1/agent-fabric/openapi.json
1730
+ - Organization instructions: ${input.hqUrl}/api/v1/orgs/${input.organizationId}/agent-fabric/instructions
1347
1731
 
1348
1732
  The CLI enrolls this device through browser-confirmed Clerk organization consent. Local provider credentials remain on this device. Managed and cloud BYOK execution are brokered by Dharma HQ and expose neither private runtime URLs nor cloud credentials.
1349
1733
  `;
1350
1734
  const connection = {
1351
- schema: 'dharma.repository-connection/v1',
1735
+ schema: 'dharma.repository-connection/v2',
1352
1736
  hqUrl: input.hqUrl,
1353
1737
  organizationId: input.organizationId,
1354
1738
  workspaceId: input.workspaceId,
1739
+ repositoryAgentId: input.repositoryAgentId || null,
1740
+ repositoryAgentKey: input.repositoryAgentKey || null,
1741
+ controlBranch: input.controlBranch || null,
1355
1742
  policyRevision: input.policyRevision,
1356
1743
  openapiUrl: `${input.hqUrl}/api/v1/agent-fabric/openapi.json`,
1744
+ instructionsUrl: `${input.hqUrl}/api/v1/orgs/${input.organizationId}/agent-fabric/instructions`,
1745
+ };
1746
+ const repositoryAgent = {
1747
+ schema: 'dharma.repository-agent/v1',
1748
+ organizationId: input.organizationId,
1749
+ organizationAgentId: input.repositoryAgentId || null,
1750
+ agentKey: input.repositoryAgentKey || null,
1751
+ controlBranch: input.controlBranch || null,
1752
+ workspaceId: input.workspaceId,
1357
1753
  };
1358
1754
  await writeFile(resolve(skillRoot, 'SKILL.md'), skill, { mode: 0o600 });
1359
1755
  await writeFile(resolve(skillRoot, 'references', 'organization.md'), reference, { mode: 0o600 });
1360
1756
  await writeFile(marker, `${JSON.stringify({ managedBy: 'dharma-agent-fabric', workspaceId: input.workspaceId }, null, 2)}\n`, { mode: 0o600 });
1361
1757
  await writeFile(resolve(input.workspace, '.dharma', 'agent-fabric.json'), `${JSON.stringify(connection, null, 2)}\n`, { mode: 0o600 });
1758
+ await writeFile(resolve(input.workspace, '.dharma', 'repository-agent.json'), `${JSON.stringify(repositoryAgent, null, 2)}\n`, { mode: 0o600 });
1362
1759
  return {
1363
1760
  skillPath: '.agents/skills/dharma-agent-fabric/SKILL.md',
1364
1761
  connectionPath: '.dharma/agent-fabric.json',
1762
+ repositoryAgentPath: '.dharma/repository-agent.json',
1365
1763
  };
1366
1764
  }
1367
1765
  async function onboard(flags) {
1368
1766
  const workspace = await realpath(String(flags.get('workspace') || flags.get('path') || '.'));
1369
1767
  const organizationId = required(flags, 'organization-id');
1370
1768
  const policyRevision = required(flags, 'policy-revision');
1371
- const requestedHqUrl = normalizeHqUrl(String(flags.get('hq-url') || 'https://www.dharma-ai.io'));
1769
+ const requestedHqUrl = normalizeHqUrl(portalUrl(flags));
1372
1770
  let config = await readDeviceConfig();
1373
1771
  if (!config) {
1374
1772
  const loginFlags = new Map(flags);
1375
1773
  loginFlags.set('hq-url', requestedHqUrl);
1376
1774
  loginFlags.set('organization-id', organizationId);
1775
+ if (flags.has('non-interactive')) {
1776
+ loginFlags.set('no-browser', true);
1777
+ loginFlags.set('no-wait', true);
1778
+ }
1377
1779
  const enrollment = await login(loginFlags);
1378
1780
  if (enrollment.status !== 'approved') {
1379
1781
  return {
@@ -1390,24 +1792,30 @@ async function onboard(flags) {
1390
1792
  if (config.organizationId !== organizationId) {
1391
1793
  throw new Error('This DHARMA_HOME is enrolled to a different organization. Use a separate DHARMA_HOME for each organization.');
1392
1794
  }
1393
- if (flags.has('hq-url') && config.hqUrl !== requestedHqUrl) {
1394
- throw new Error('This device is enrolled to a different Dharma HQ origin. Use a separate DHARMA_HOME for each HQ origin.');
1795
+ if ((flags.has('portal-url') || flags.has('hq-url')) && config.hqUrl !== requestedHqUrl) {
1796
+ throw new Error('This device is enrolled to a different Dharma portal origin. Use a separate DHARMA_HOME for each portal origin.');
1395
1797
  }
1396
1798
  const hqUrl = config.hqUrl;
1799
+ const providerIds = parseSelectedProviderIds(typeof flags.get('providers') === 'string'
1800
+ ? [String(flags.get('providers'))]
1801
+ : typeof flags.get('provider') === 'string' ? [String(flags.get('provider'))] : []);
1397
1802
  let registered = (await registry()).find((item) => item.path === workspace);
1398
- if (!registered) {
1399
- await workspaceAdd(new Map([
1803
+ if (!registered || (!registered.repositoryRemoteHash && typeof flags.get('repository-key') === 'string')) {
1804
+ const addFlags = new Map([
1400
1805
  ['organization-id', organizationId],
1401
1806
  ['path', workspace],
1402
1807
  ['name', String(flags.get('name') || basename(workspace))],
1403
- ]), [workspace]);
1808
+ ]);
1809
+ if (typeof flags.get('repository-key') === 'string')
1810
+ addFlags.set('repository-key', String(flags.get('repository-key')));
1811
+ await workspaceAdd(addFlags, [workspace]);
1404
1812
  registered = (await registry()).find((item) => item.path === workspace);
1405
1813
  }
1406
1814
  if (!registered)
1407
1815
  throw new Error('Workspace registration failed.');
1408
- const synced = await workspaceSync(new Map([
1409
- ['policy-revision', policyRevision], ['apply', true],
1410
- ]), [registered.workspaceId]);
1816
+ const fabric = await client();
1817
+ registered = await bindRepositoryAgent(fabric, registered);
1818
+ const synced = await syncWorkspacePolicy(fabric, registered, policyRevision, true, providerIds);
1411
1819
  const localPolicy = synced.localPolicy;
1412
1820
  const authoritativeRevision = String(localPolicy?.revision || policyRevision);
1413
1821
  const generatedPolicy = await loadVerifiedWorkspacePolicy(resolve(workspace, '.dharma', 'approved-policy.json'), registered.workspaceId);
@@ -1416,9 +1824,12 @@ async function onboard(flags) {
1416
1824
  hqUrl,
1417
1825
  organizationId,
1418
1826
  workspaceId: registered.workspaceId,
1827
+ repositoryAgentId: registered.repositoryAgentId,
1828
+ repositoryAgentKey: registered.repositoryAgentKey,
1829
+ controlBranch: registered.controlBranch,
1419
1830
  policyRevision: authoritativeRevision,
1420
1831
  });
1421
- const providers = await Promise.all(providerAdapters.map((adapter) => adapter.capability()));
1832
+ const providers = await Promise.all(selectedProviderAdapters(providerIds).map((adapter) => adapter.capability()));
1422
1833
  const nativeSkillResult = await installAvailableNativeAgentFabricBootstraps({
1423
1834
  providers,
1424
1835
  workspace,
@@ -1426,11 +1837,18 @@ async function onboard(flags) {
1426
1837
  organizationId,
1427
1838
  hqUrl,
1428
1839
  });
1840
+ const primaryProvider = providers[0]?.provider || 'codex';
1429
1841
  return {
1430
1842
  ok: true,
1431
1843
  stage: nativeSkillResult.failures.length ? 'ready_with_provider_actions' : 'ready',
1432
1844
  organizationId,
1433
1845
  workspaceId: registered.workspaceId,
1846
+ repositoryAgent: {
1847
+ id: registered.repositoryAgentId,
1848
+ key: registered.repositoryAgentKey,
1849
+ bindingId: registered.repositoryBindingId,
1850
+ controlBranch: registered.controlBranch,
1851
+ },
1434
1852
  deviceId: config.deviceId,
1435
1853
  providers,
1436
1854
  organizationPolicy: {
@@ -1445,10 +1863,10 @@ async function onboard(flags) {
1445
1863
  nativeSkillFailures: nativeSkillResult.failures,
1446
1864
  workspaceSync: synced,
1447
1865
  next: {
1448
- preview: 'dharma evidence preview --workspace . --provider codex',
1449
- sync: 'dharma evidence capture-batch --workspace . --provider codex --policy .dharma/approved-policy.json --maximum-sessions 20 --sync',
1866
+ preview: `dharma evidence preview --workspace . --provider ${primaryProvider}`,
1867
+ sync: `dharma evidence capture-batch --workspace . --provider ${primaryProvider} --policy .dharma/approved-policy.json --maximum-sessions 20 --sync`,
1450
1868
  relay: 'dharma relay start --policy .dharma/approved-policy.json',
1451
- verifySkill: 'dharma skills verify --provider codex --workspace .',
1869
+ verifySkill: `dharma skills verify --provider ${primaryProvider} --workspace .`,
1452
1870
  },
1453
1871
  };
1454
1872
  }
@@ -1683,7 +2101,7 @@ async function executeOneTask(fabric, policy, leaseSeconds) {
1683
2101
  if (task.target.deviceId !== config.deviceId)
1684
2102
  throw new Error('Task target does not match this enrolled device.');
1685
2103
  const serverPublicKey = createPublicKey({ key: { kty: 'OKP', crv: 'Ed25519', x: config.serverPublicKeyEd25519 }, format: 'jwk' });
1686
- const activeBundleId = await getActiveSkillBundleId(nativeSkillDirectory(task.target.provider));
2104
+ const activeBundleId = await getActiveSkillBundleId(nativeSkillDirectory(task.target.provider), task.workspaceId);
1687
2105
  try {
1688
2106
  assertTaskSkillPin(task.skillBundle, activeBundleId);
1689
2107
  }
@@ -1816,6 +2234,13 @@ export async function verifyAgentFabricSkillInstallation(input) {
1816
2234
  const nativeMarkerPath = resolve(nativeRoot, 'dharma-agent-fabric', '.dharma-agent-fabric-bootstrap.json');
1817
2235
  const repositoryInstalled = await pathExists(repositorySkillPath) && await pathExists(connectionPath);
1818
2236
  const nativeInstalled = await pathExists(nativeSkillPath) && await pathExists(nativeMarkerPath);
2237
+ let workspaceId;
2238
+ try {
2239
+ const connection = JSON.parse(await readFile(connectionPath, 'utf8'));
2240
+ if (typeof connection.workspaceId === 'string')
2241
+ workspaceId = connection.workspaceId;
2242
+ }
2243
+ catch { }
1819
2244
  return {
1820
2245
  provider: input.provider,
1821
2246
  ready: repositoryInstalled && nativeInstalled,
@@ -1824,7 +2249,8 @@ export async function verifyAgentFabricSkillInstallation(input) {
1824
2249
  repositorySkillPath,
1825
2250
  connectionPath,
1826
2251
  nativeSkillPath,
1827
- activeBundleId: await getActiveSkillBundleId(nativeRoot),
2252
+ workspaceId: workspaceId || null,
2253
+ activeBundleId: workspaceId ? await getActiveSkillBundleId(nativeRoot, workspaceId) : null,
1828
2254
  activation: 'next_session',
1829
2255
  nextAction: repositoryInstalled && nativeInstalled
1830
2256
  ? `Start a new ${input.provider} session from ${workspace} and invoke the dharma-agent-fabric skill.`
@@ -1908,7 +2334,11 @@ async function skillSync(flags) {
1908
2334
  const policy = await loadOrganizationPolicy(required(flags, 'policy'));
1909
2335
  const destination = nativeSkillDirectory(provider);
1910
2336
  const fabric = await client();
1911
- const response = await fabric.pollSkill({ workspaceId, provider, installedBundleId: await getActiveSkillBundleId(destination) });
2337
+ const response = await fabric.pollSkill({
2338
+ workspaceId,
2339
+ provider,
2340
+ installedBundleId: await getActiveSkillBundleId(destination, workspaceId),
2341
+ });
1912
2342
  const rollout = response.rollout;
1913
2343
  if (!rollout)
1914
2344
  return { ok: true, rollout: null, changed: false };
@@ -2023,7 +2453,7 @@ async function relayStart(flags) {
2023
2453
  return { ok: true, stopped: true, tasksCompleted, evidenceResponsesCompleted };
2024
2454
  }
2025
2455
  export async function run(argv) {
2026
- const { positional, flags } = options(argv);
2456
+ const { positional, flags, repeated } = parseCliOptions(argv);
2027
2457
  const [command, subcommand] = positional;
2028
2458
  if (flags.has('help') || command === 'help')
2029
2459
  return USAGE;
@@ -2035,6 +2465,22 @@ export async function run(argv) {
2035
2465
  return login(flags);
2036
2466
  if (command === 'providers' && subcommand === 'list')
2037
2467
  return { providers: await Promise.all(providerAdapters.map((adapter) => adapter.capability())) };
2468
+ if (command === 'repositories' && subcommand === 'discover') {
2469
+ return repositoriesDiscover(flags, positional.slice(2), repeated.get('root') || []);
2470
+ }
2471
+ if (command === 'repositories' && subcommand === 'connect') {
2472
+ return repositoriesConnect(flags, positional.slice(2), repeated.get('repo') || [], repeated.get('repository-key') || [], repeated.get('provider') || []);
2473
+ }
2474
+ if (command === 'repositories' && subcommand === 'list')
2475
+ return repositoriesList(flags);
2476
+ if (command === 'repositories' && subcommand === 'status') {
2477
+ const listed = await repositoriesList(flags);
2478
+ return {
2479
+ ...listed,
2480
+ relay: await relayProcessState(),
2481
+ providers: await Promise.all(providerAdapters.map((adapter) => adapter.capability())),
2482
+ };
2483
+ }
2038
2484
  if (command === 'workspace' && subcommand === 'add')
2039
2485
  return workspaceAdd(flags, positional.slice(2));
2040
2486
  if (command === 'workspace' && subcommand === 'sync')
@@ -2066,12 +2512,18 @@ export async function run(argv) {
2066
2512
  return status;
2067
2513
  }
2068
2514
  }
2515
+ if ([
2516
+ 'organization', 'agents', 'experiments', 'failures', 'remediations', 'handoffs', 'usage',
2517
+ ].includes(String(command)) || (['tasks', 'skills'].includes(String(command))
2518
+ && ['list', 'dispatch', 'release', 'rollout', 'rollback'].includes(String(subcommand)))) {
2519
+ const result = await runOrganizationCommand(command, subcommand, flags);
2520
+ if (result)
2521
+ return result;
2522
+ }
2069
2523
  if (command === 'tasks' && subcommand === 'run-once')
2070
2524
  return runOneTask(flags);
2071
2525
  if (command === 'relay' && subcommand === 'start')
2072
2526
  return relayStart(flags);
2073
- if (command === 'tasks' && subcommand === 'list')
2074
- return { tasks: [], coverage: 'server_poll_requires_relay' };
2075
2527
  if (command === 'skills' && subcommand === 'sync')
2076
2528
  return skillSync(flags);
2077
2529
  if (command === 'skills' && subcommand === 'status') {
@@ -2079,7 +2531,13 @@ export async function run(argv) {
2079
2531
  if (!['codex', 'claude', 'agy'].includes(providerValue))
2080
2532
  throw new Error('Skill provider must be codex, claude, or agy.');
2081
2533
  const root = nativeSkillDirectory(providerValue);
2082
- return { provider: providerValue, activeBundleId: await getActiveSkillBundleId(root), nativeSkillDirectory: root };
2534
+ const workspaceId = required(flags, 'workspace-id');
2535
+ return {
2536
+ provider: providerValue,
2537
+ workspaceId,
2538
+ activeBundleId: await getActiveSkillBundleId(root, workspaceId),
2539
+ nativeSkillDirectory: root,
2540
+ };
2083
2541
  }
2084
2542
  if (command === 'skills' && subcommand === 'verify') {
2085
2543
  const providerValue = required(flags, 'provider');