@0dai-dev/cli 4.3.5 → 4.3.7
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/README.md +12 -11
- package/bin/0dai.js +214 -40
- package/lib/ai/manifest/mcp-exposure-contract.json +121 -0
- package/lib/ai/meta/manifest/mcp-tool-tiers.json +435 -0
- package/lib/ai/registry/mcp-catalog.json +98 -0
- package/lib/commands/auth.js +55 -1
- package/lib/commands/compliance.js +1 -1
- package/lib/commands/detect.js +10 -4
- package/lib/commands/doctor.js +545 -26
- package/lib/commands/experience.js +40 -5
- package/lib/commands/export.js +73 -0
- package/lib/commands/feedback.js +157 -15
- package/lib/commands/gh.js +26 -0
- package/lib/commands/graph.js +9 -4
- package/lib/commands/heatmap.js +1 -1
- package/lib/commands/init.js +222 -30
- package/lib/commands/mcp.js +129 -21
- package/lib/commands/models.js +138 -41
- package/lib/commands/provider.js +30 -59
- package/lib/commands/quota.js +1 -1
- package/lib/commands/receipt.js +1 -1
- package/lib/commands/run.js +18 -7
- package/lib/commands/runner.js +31 -1
- package/lib/commands/status.js +44 -11
- package/lib/commands/swarm.js +130 -12
- package/lib/commands/trust.js +286 -0
- package/lib/commands/update.js +184 -38
- package/lib/commands/usage.js +1 -1
- package/lib/commands/validate.js +32 -3
- package/lib/commands/vault.js +46 -9
- package/lib/python/__init__.py +0 -0
- package/lib/python/agent_quotas.py +525 -0
- package/lib/python/anomaly_alert.py +397 -0
- package/lib/python/anti_pattern_detector.py +799 -0
- package/lib/python/auth.py +443 -0
- package/lib/python/capi_profile_guard.py +477 -0
- package/lib/python/compliance_report.py +581 -0
- package/lib/python/drift_detector.py +388 -0
- package/lib/python/experience_pipeline.py +1130 -0
- package/lib/python/graph.py +19 -0
- package/lib/python/graph_core.py +293 -0
- package/lib/python/graph_io.py +179 -0
- package/lib/python/graph_legacy.py +2052 -0
- package/lib/python/graph_legacy_helpers.py +221 -0
- package/lib/python/graph_outcomes_core.py +85 -0
- package/lib/python/graph_queries.py +171 -0
- package/lib/python/graph_slice.py +198 -0
- package/lib/python/graph_slicer.py +576 -0
- package/lib/python/graph_slicer_cli.py +60 -0
- package/lib/python/graph_validation.py +64 -0
- package/lib/python/heatmap.py +934 -0
- package/lib/python/json_utils.py +193 -0
- package/lib/python/mcp_exposure_check.py +247 -0
- package/lib/python/model_router.py +1434 -0
- package/lib/python/project_manager.py +621 -0
- package/lib/python/provider_profiles.py +1618 -0
- package/lib/python/provider_registry.py +1211 -0
- package/lib/python/provider_registry_cli.py +125 -0
- package/lib/python/receipt_png.py +727 -0
- package/lib/python/structural_memory.py +325 -0
- package/lib/python/swarm_cost.py +177 -0
- package/lib/python/usage_ledger.py +569 -0
- package/lib/scripts/mcp_tier_config.py +240 -0
- package/lib/shared.js +97 -14
- package/lib/tui/index.mjs +35174 -0
- package/lib/utils/activation_telemetry.js +230 -11
- package/lib/utils/constants.js +7 -1
- package/lib/utils/export-bundler.js +285 -0
- package/lib/utils/identity.js +198 -1
- package/lib/utils/mcp-auth.js +81 -15
- package/lib/utils/plan.js +1 -1
- package/lib/vault/index.js +19 -3
- package/lib/vault/storage.js +21 -2
- package/lib/wizard.js +5 -2
- package/package.json +9 -3
- package/scripts/build-python-bundle.js +106 -0
- package/scripts/build-tui.js +14 -1
- package/scripts/harvest_experience.py +523 -0
- package/scripts/postinstall.js +15 -9
package/lib/commands/runner.js
CHANGED
|
@@ -376,6 +376,31 @@ function runRouteDryRunController(target, configPath, forwarded) {
|
|
|
376
376
|
}
|
|
377
377
|
}
|
|
378
378
|
|
|
379
|
+
function runHostPoolPlanController(target, forwarded) {
|
|
380
|
+
const script = findRepoScript(target, "runner_host_pool_autosize.py");
|
|
381
|
+
if (!script) {
|
|
382
|
+
log("runner host-pool plan controller not found");
|
|
383
|
+
console.log(` ${D}expected: scripts/runner_host_pool_autosize.py${R}`);
|
|
384
|
+
process.exitCode = 1;
|
|
385
|
+
return;
|
|
386
|
+
}
|
|
387
|
+
const result = spawnSync("python3", [script, "host-pool-plan", ...forwarded], {
|
|
388
|
+
encoding: "utf8",
|
|
389
|
+
timeout: 30000,
|
|
390
|
+
maxBuffer: 8 * 1024 * 1024,
|
|
391
|
+
});
|
|
392
|
+
if (result.error) {
|
|
393
|
+
log(`runner host-pool plan controller failed: ${result.error.message}`);
|
|
394
|
+
process.exitCode = 1;
|
|
395
|
+
return;
|
|
396
|
+
}
|
|
397
|
+
if (result.stdout) process.stdout.write(result.stdout);
|
|
398
|
+
if (result.stderr) process.stderr.write(result.stderr);
|
|
399
|
+
if (typeof result.status === "number" && result.status !== 0) {
|
|
400
|
+
process.exitCode = result.status;
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
|
|
379
404
|
function runLabelAuditController(target, configPath, forwarded) {
|
|
380
405
|
const script = findRepoScript(target, "runner_label_audit.py");
|
|
381
406
|
if (!script) {
|
|
@@ -468,6 +493,11 @@ function cmdRunner(target, sub, args) {
|
|
|
468
493
|
return;
|
|
469
494
|
}
|
|
470
495
|
|
|
496
|
+
if (command === "host-pool-plan" || command === "host-pool-autosize") {
|
|
497
|
+
runHostPoolPlanController(target, forwarded);
|
|
498
|
+
return;
|
|
499
|
+
}
|
|
500
|
+
|
|
471
501
|
if (command === "label-audit") {
|
|
472
502
|
runLabelAuditController(target, configPath, forwarded);
|
|
473
503
|
return;
|
|
@@ -485,7 +515,7 @@ function cmdRunner(target, sub, args) {
|
|
|
485
515
|
}
|
|
486
516
|
|
|
487
517
|
if (command !== "status" && command !== "plan") {
|
|
488
|
-
console.log("Usage: 0dai runner [status|plan|queue-status|label-audit|burst-plan|route-dry-run|burst-apply|burst-preflight|burst-reap|burst-reap-plan] [--json] [--offline]");
|
|
518
|
+
console.log("Usage: 0dai runner [status|plan|queue-status|label-audit|burst-plan|route-dry-run|host-pool-plan|burst-apply|burst-preflight|burst-reap|burst-reap-plan] [--json] [--offline]");
|
|
489
519
|
process.exitCode = 1;
|
|
490
520
|
return;
|
|
491
521
|
}
|
package/lib/commands/status.js
CHANGED
|
@@ -4,7 +4,15 @@ const {
|
|
|
4
4
|
log, T, R, D, fs, path, spawnSync, findRepoScript,
|
|
5
5
|
getSwarmQuotaLocal, _detectPlanLocal, PLAN_LEVELS, loadAuthState,
|
|
6
6
|
} = shared;
|
|
7
|
-
const {
|
|
7
|
+
const {
|
|
8
|
+
getActivationDurationStats,
|
|
9
|
+
getActivationMergedPrStats,
|
|
10
|
+
printActivationStats,
|
|
11
|
+
} = require("../utils/activation_telemetry");
|
|
12
|
+
const {
|
|
13
|
+
collectMcpExposurePayload,
|
|
14
|
+
formatMcpExposureLine,
|
|
15
|
+
} = require("./doctor");
|
|
8
16
|
|
|
9
17
|
function countJson(dir) {
|
|
10
18
|
try { return fs.readdirSync(dir).filter(f => f.endsWith(".json")).length; } catch { return 0; }
|
|
@@ -15,17 +23,32 @@ function loadJson(file) {
|
|
|
15
23
|
}
|
|
16
24
|
|
|
17
25
|
function loadProjectBinding(target) {
|
|
18
|
-
return
|
|
26
|
+
return shared.loadProjectBinding(target);
|
|
19
27
|
}
|
|
20
28
|
|
|
21
|
-
function collectStatusPayload(target) {
|
|
29
|
+
function collectStatusPayload(target, options = {}) {
|
|
22
30
|
const ai = path.join(target, "ai");
|
|
23
|
-
const
|
|
31
|
+
const baseIdentity = shared.buildProjectIdentity(target, shared.collectMetadata(target));
|
|
24
32
|
const binding = loadProjectBinding(target);
|
|
25
|
-
const
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
33
|
+
const identity = shared.applyBoundProjectIdentity(target, baseIdentity, binding);
|
|
34
|
+
// A binding whose stored target no longer matches this path (the project was
|
|
35
|
+
// moved/copied) must not report "bound" — the identity already falls back to
|
|
36
|
+
// the path-derived one, so the status must say "moved" and prompt a re-bind,
|
|
37
|
+
// not silently claim a stale project_id/name (#4363).
|
|
38
|
+
const bindingHasTarget =
|
|
39
|
+
!!binding &&
|
|
40
|
+
!!(binding.target || binding.current_target || (Array.isArray(binding.target_aliases) && binding.target_aliases.length));
|
|
41
|
+
const boundButMoved =
|
|
42
|
+
!!binding &&
|
|
43
|
+
binding.binding_status === "bound" &&
|
|
44
|
+
bindingHasTarget &&
|
|
45
|
+
!shared.bindingTargetMatches(target, binding);
|
|
46
|
+
const bindingStatus = boundButMoved ? "moved" : ((binding && binding.binding_status) || "local-only");
|
|
47
|
+
const bindingReason = boundButMoved
|
|
48
|
+
? "binding target does not match this path — project moved or copied; re-bind to refresh identity"
|
|
49
|
+
: binding
|
|
50
|
+
? (binding.binding_status ? "" : "project-binding.json has no binding_status")
|
|
51
|
+
: "no .0dai/project-binding.json";
|
|
29
52
|
const bindingNextAction = bindingStatus === "bound" ? "" : "run 0dai project bind";
|
|
30
53
|
let v = "?", stack = "?";
|
|
31
54
|
try { v = fs.readFileSync(path.join(ai, "VERSION"), "utf8").trim(); } catch {}
|
|
@@ -60,7 +83,7 @@ function collectStatusPayload(target) {
|
|
|
60
83
|
} catch {}
|
|
61
84
|
|
|
62
85
|
try {
|
|
63
|
-
const ds =
|
|
86
|
+
const ds = shared.resolvePythonScript(target, "drift_detector.py");
|
|
64
87
|
if (ds) {
|
|
65
88
|
const dr = spawnSync("python3", [ds, "report", "--target", target],
|
|
66
89
|
{ stdio: ["ignore", "pipe", "ignore"], encoding: "utf8", timeout: 5000 });
|
|
@@ -89,7 +112,7 @@ function collectStatusPayload(target) {
|
|
|
89
112
|
queued: q,
|
|
90
113
|
active: a,
|
|
91
114
|
done: d,
|
|
92
|
-
quota_state: quota.
|
|
115
|
+
quota_state: Number(quota.daily_limit || 0) === 0 ? "locked" : "available",
|
|
93
116
|
quota_plan: quota.plan,
|
|
94
117
|
used_today: Number(quota.used_today || 0),
|
|
95
118
|
daily_limit: Number(quota.daily_limit || 0),
|
|
@@ -116,13 +139,15 @@ function collectStatusPayload(target) {
|
|
|
116
139
|
drift: {
|
|
117
140
|
detected: driftDetected,
|
|
118
141
|
},
|
|
142
|
+
mcp_exposure: collectMcpExposurePayload(target, options.mcpExposureOptions || {}),
|
|
119
143
|
warnings: warningCount,
|
|
120
144
|
activation_ttfv: getActivationDurationStats(target),
|
|
145
|
+
activation_first_merged_pr: getActivationMergedPrStats(target),
|
|
121
146
|
};
|
|
122
147
|
}
|
|
123
148
|
|
|
124
149
|
function cmdStatus(target, options = {}) {
|
|
125
|
-
const payload = collectStatusPayload(target);
|
|
150
|
+
const payload = collectStatusPayload(target, options);
|
|
126
151
|
if (options.json) {
|
|
127
152
|
console.log(JSON.stringify(payload, null, 2));
|
|
128
153
|
return payload;
|
|
@@ -146,6 +171,14 @@ function cmdStatus(target, options = {}) {
|
|
|
146
171
|
console.log(` session roaming: ${T}available (${payload.session.roaming_plan})${R}`);
|
|
147
172
|
}
|
|
148
173
|
|
|
174
|
+
const mcpExposure = payload.mcp_exposure || {};
|
|
175
|
+
if (mcpExposure.status && mcpExposure.status !== "green") {
|
|
176
|
+
console.log(` mcp exposure: ${formatMcpExposureLine(mcpExposure)}`);
|
|
177
|
+
if (mcpExposure.next_step) console.log(` → ${mcpExposure.next_step}`);
|
|
178
|
+
} else if (mcpExposure.status === "green") {
|
|
179
|
+
console.log(` mcp exposure: ${formatMcpExposureLine(mcpExposure)}`);
|
|
180
|
+
}
|
|
181
|
+
|
|
149
182
|
if (payload.session.name) {
|
|
150
183
|
console.log(` session: ${payload.session.name} (agent: ${payload.session.agent || "?"})`);
|
|
151
184
|
}
|
package/lib/commands/swarm.js
CHANGED
|
@@ -2,32 +2,121 @@
|
|
|
2
2
|
const shared = require("../shared");
|
|
3
3
|
const { log, T, R, D, fs, path, https, requirePlan, spawnSync, findRepoScript } = shared;
|
|
4
4
|
|
|
5
|
+
// The npm package ships the lightweight swarm client; the Python orchestration
|
|
6
|
+
// behind pick/run/quality/sessions runs from a 0dai repo checkout or dev install
|
|
7
|
+
// (it needs the full toolchain + heavier deps). Emit an honest, actionable line
|
|
8
|
+
// instead of dev-internal "probed: <path>" noise that npm users can't act on.
|
|
9
|
+
function logSwarmOrchestrationUnavailable(capability, envVar) {
|
|
10
|
+
log(`${capability} is not available in the npm package`);
|
|
11
|
+
console.log(` ${D}It runs from a 0dai repo checkout with the full Python toolchain.${R}`);
|
|
12
|
+
console.log(` ${D}You need: the repo checkout + python3 + an installed agent CLI (claude/codex/...) + the gh CLI.${R}`);
|
|
13
|
+
console.log(` ${D}Run 0dai doctor to see which prerequisites are missing.${R}`);
|
|
14
|
+
const power = envVar ? ` · power users: point ${envVar} at a local helper` : "";
|
|
15
|
+
console.log(` ${D}Docs: https://0dai.dev/docs${power}${R}`);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function printSwarmHelp() {
|
|
19
|
+
console.log("Usage: 0dai swarm [status|pick|run|budget|estimate|quality|sessions|swarm-run]");
|
|
20
|
+
console.log(" 0dai swarm add --task '...' [--for agent]");
|
|
21
|
+
console.log(" 0dai swarm delegate --task '...' [--to agent]");
|
|
22
|
+
console.log(" pick Pick one queued task for this agent: 0dai swarm pick --agent codex");
|
|
23
|
+
console.log(" run One-shot: 0dai swarm run \"<goal>\" creates a task and dispatches it (queue-drains if no goal)");
|
|
24
|
+
console.log(" sessions Show live session registry table (idle/busy/stale per session) [--json]");
|
|
25
|
+
console.log(" swarm-run Repo-checkout helper: add, dispatch, and wait for one swarm task as JSON");
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function printSwarmRunHelp() {
|
|
29
|
+
console.log("Usage: 0dai swarm swarm-run --task '...' [--agent codex] [--files path ...] [--json]");
|
|
30
|
+
console.log("");
|
|
31
|
+
console.log("Adds, dispatches, and waits for one swarm task through scripts/swarm_run.py.");
|
|
32
|
+
console.log("Requires a full repo checkout helper or ODAI_SWARM_RUN_SCRIPT; otherwise use queue-only swarm commands.");
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function swarmRunAvailability(target) {
|
|
36
|
+
const override = process.env.ODAI_SWARM_RUN_SCRIPT || "";
|
|
37
|
+
if (override) {
|
|
38
|
+
if (fs.existsSync(override)) return { available: true, source: "ODAI_SWARM_RUN_SCRIPT", path: override };
|
|
39
|
+
return {
|
|
40
|
+
available: false,
|
|
41
|
+
source: "ODAI_SWARM_RUN_SCRIPT",
|
|
42
|
+
path: override,
|
|
43
|
+
reason: "override path does not exist",
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
const script = findRepoScript(target, "swarm_run.py");
|
|
47
|
+
if (script) return { available: true, source: "auto", path: script };
|
|
48
|
+
return {
|
|
49
|
+
available: false,
|
|
50
|
+
source: "auto",
|
|
51
|
+
reason: "swarm_run.py not found",
|
|
52
|
+
probed: [
|
|
53
|
+
path.join(target, "scripts", "swarm_run.py"),
|
|
54
|
+
path.join(process.cwd(), "scripts", "swarm_run.py"),
|
|
55
|
+
],
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function printSwarmRunnerHint(target) {
|
|
60
|
+
const runner = swarmRunAvailability(target);
|
|
61
|
+
if (runner.available) {
|
|
62
|
+
console.log(` ${D}runner: available (${runner.path})${R}`);
|
|
63
|
+
return runner;
|
|
64
|
+
}
|
|
65
|
+
console.log(` ${D}runner: queue-only (${runner.reason}; set ODAI_SWARM_RUN_SCRIPT or sync scripts/swarm_run.py)${R}`);
|
|
66
|
+
return runner;
|
|
67
|
+
}
|
|
68
|
+
|
|
5
69
|
function cmdSwarm(target, sub, args) {
|
|
6
70
|
const swarmDir = path.join(target, "ai", "swarm");
|
|
7
71
|
const queueDir = path.join(swarmDir, "queue");
|
|
8
72
|
|
|
73
|
+
if (!sub || sub === "help" || sub === "--help" || sub === "-h") {
|
|
74
|
+
printSwarmHelp();
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (sub === "pick") {
|
|
79
|
+
cmdPythonSwarm(target, "pick", args.slice(2));
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
9
82
|
if (sub === "swarm-run") {
|
|
10
83
|
cmdSwarmRun(target, args.slice(2));
|
|
11
84
|
return;
|
|
12
85
|
}
|
|
86
|
+
if (sub === "run") {
|
|
87
|
+
cmdRun(target, args.slice(2));
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
13
90
|
if (sub === "status") {
|
|
14
91
|
const count = (d) => { try { return fs.readdirSync(d).filter(f => f.endsWith(".json")).length; } catch { return 0; } };
|
|
15
92
|
const q = count(path.join(swarmDir, "queue"));
|
|
16
93
|
const a = count(path.join(swarmDir, "active"));
|
|
17
94
|
const d = count(path.join(swarmDir, "done"));
|
|
18
95
|
log(`swarm: ${q} queued, ${a} active, ${d} done`);
|
|
96
|
+
printSwarmRunnerHint(target);
|
|
19
97
|
return;
|
|
20
98
|
}
|
|
21
99
|
if (sub === "add" || sub === "delegate") {
|
|
100
|
+
if (args.includes("--help") || args.includes("-h")) {
|
|
101
|
+
printSwarmHelp();
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
const taskIndex = args.indexOf("--task");
|
|
105
|
+
const task = taskIndex >= 0 ? args[taskIndex + 1] : "";
|
|
106
|
+
if (!task || task.startsWith("--")) {
|
|
107
|
+
log(`Usage: 0dai swarm ${sub} --task '...' [--for agent]`);
|
|
108
|
+
process.exitCode = 1;
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
22
111
|
const gate = requirePlan("pro", "Swarm", target);
|
|
23
112
|
if (gate) { log(gate.error); log(gate.hint); return; }
|
|
24
113
|
fs.mkdirSync(queueDir, { recursive: true });
|
|
25
|
-
const task = args.find((_, i) => args[i - 1] === "--task") || "untitled";
|
|
26
114
|
const forAgent = args.find((_, i) => ["--for", "--to"].includes(args[i - 1])) || "any";
|
|
27
115
|
const id = `swarm-${Date.now()}`;
|
|
28
116
|
const t = { id, title: task, assigned_to: forAgent, status: "pending", created_at: new Date().toISOString(), created_by: "cli" };
|
|
29
117
|
fs.writeFileSync(path.join(queueDir, `${id}.json`), JSON.stringify(t, null, 2));
|
|
30
118
|
log(`task created: ${id} → ${forAgent}`);
|
|
119
|
+
printSwarmRunnerHint(target);
|
|
31
120
|
return;
|
|
32
121
|
}
|
|
33
122
|
if (sub === "webhook") {
|
|
@@ -185,7 +274,7 @@ function cmdSwarm(target, sub, args) {
|
|
|
185
274
|
}
|
|
186
275
|
if (sub === "quality") {
|
|
187
276
|
const scorerScript = findRepoScript(target, "quality_scorer.py");
|
|
188
|
-
if (!scorerScript) {
|
|
277
|
+
if (!scorerScript) { logSwarmOrchestrationUnavailable("swarm quality scoring"); return; }
|
|
189
278
|
const fwd = [scorerScript, "--target", target];
|
|
190
279
|
if (args.includes("--json")) fwd.push("--json");
|
|
191
280
|
for (let i = 2; i < args.length; i++) {
|
|
@@ -198,7 +287,7 @@ function cmdSwarm(target, sub, args) {
|
|
|
198
287
|
}
|
|
199
288
|
if (sub === "sessions") {
|
|
200
289
|
const script = findRepoScript(target, "swarm_session_registry.py");
|
|
201
|
-
if (!script) {
|
|
290
|
+
if (!script) { logSwarmOrchestrationUnavailable("the swarm session registry"); return; }
|
|
202
291
|
const fwd = [script, "rebuild", "--stale-after", "600"];
|
|
203
292
|
const asJson = args.includes("--json");
|
|
204
293
|
if (!asJson) fwd.push("--write", path.join(swarmDir, "session_registry.json"));
|
|
@@ -250,12 +339,44 @@ function cmdSwarm(target, sub, args) {
|
|
|
250
339
|
console.log(` ${G2}idle${reset}: ${idle} ${W2}busy${reset}: ${busy} ${M2}stale${reset}: ${stale} total: ${rows.length}\n`);
|
|
251
340
|
return;
|
|
252
341
|
}
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
342
|
+
printSwarmHelp();
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
function cmdPythonSwarm(target, subcommand, args) {
|
|
346
|
+
// Forward Python-backed swarm operations through scripts/swarm.py so quota,
|
|
347
|
+
// dispatch gates, pick semantics, and preflight messaging have one source of
|
|
348
|
+
// truth. `swarm-run` (hyphenated add+wait helper) stays on swarm_run.py.
|
|
349
|
+
let script = process.env.ODAI_SWARM_SCRIPT || "";
|
|
350
|
+
if (script && !fs.existsSync(script)) {
|
|
351
|
+
log(`swarm helper unavailable: ODAI_SWARM_SCRIPT=${script} does not exist`);
|
|
352
|
+
process.exit(1);
|
|
353
|
+
}
|
|
354
|
+
if (!script) script = findRepoScript(target, "swarm.py");
|
|
355
|
+
if (!script) {
|
|
356
|
+
logSwarmOrchestrationUnavailable("swarm orchestration", "ODAI_SWARM_SCRIPT");
|
|
357
|
+
process.exit(1);
|
|
358
|
+
}
|
|
359
|
+
const python = process.env.ODAI_SWARM_RUN_PYTHON || "python3";
|
|
360
|
+
const result = spawnSync(python, [script, "--target", target, subcommand, ...args], { stdio: "inherit" });
|
|
361
|
+
if (result.error) {
|
|
362
|
+
log(`swarm ${subcommand} failed: ${result.error.message}`);
|
|
363
|
+
process.exit(1);
|
|
364
|
+
}
|
|
365
|
+
if (typeof result.status === "number" && result.status !== 0) process.exit(result.status);
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
function cmdRun(target, args) {
|
|
369
|
+
// `0dai swarm run "<goal>"` — one-shot: forward to the python swarm.py `run`
|
|
370
|
+
// subcommand so the per-tier daily quota (check_swarm_quota / record_swarm_task)
|
|
371
|
+
// governs identically for the goal path and the queue-drain.
|
|
372
|
+
cmdPythonSwarm(target, "run", args);
|
|
256
373
|
}
|
|
257
374
|
|
|
258
375
|
function cmdSwarmRun(target, args) {
|
|
376
|
+
if (args.includes("--help") || args.includes("-h")) {
|
|
377
|
+
printSwarmRunHelp();
|
|
378
|
+
return;
|
|
379
|
+
}
|
|
259
380
|
// #2143 hardening:
|
|
260
381
|
// 1. `ODAI_SWARM_RUN_SCRIPT` env override lets tests inject a deterministic
|
|
261
382
|
// script path, bypassing the `findRepoScript` heuristic that walks up
|
|
@@ -269,15 +390,12 @@ function cmdSwarmRun(target, args) {
|
|
|
269
390
|
let script = process.env.ODAI_SWARM_RUN_SCRIPT || "";
|
|
270
391
|
if (script && !fs.existsSync(script)) {
|
|
271
392
|
log(`swarm-run helper unavailable: ODAI_SWARM_RUN_SCRIPT=${script} does not exist`);
|
|
393
|
+
log(` hint: set ODAI_SWARM_RUN_SCRIPT to a valid helper, unset it, or create the helper at that exact path`);
|
|
272
394
|
process.exit(1);
|
|
273
395
|
}
|
|
274
396
|
if (!script) script = findRepoScript(target, "swarm_run.py");
|
|
275
397
|
if (!script) {
|
|
276
|
-
|
|
277
|
-
log("swarm-run helper unavailable");
|
|
278
|
-
log(` probed: ${path.join(target, "scripts", "swarm_run.py")}`);
|
|
279
|
-
log(` probed: ${path.join(cwd, "scripts", "swarm_run.py")}`);
|
|
280
|
-
log(` hint: set ODAI_SWARM_RUN_SCRIPT to override the lookup`);
|
|
398
|
+
logSwarmOrchestrationUnavailable("the swarm-run helper", "ODAI_SWARM_RUN_SCRIPT");
|
|
281
399
|
process.exit(1);
|
|
282
400
|
}
|
|
283
401
|
const python = process.env.ODAI_SWARM_RUN_PYTHON || "python3";
|
|
@@ -289,4 +407,4 @@ function cmdSwarmRun(target, args) {
|
|
|
289
407
|
if (typeof result.status === "number" && result.status !== 0) process.exit(result.status);
|
|
290
408
|
}
|
|
291
409
|
|
|
292
|
-
module.exports = { cmdSwarm, cmdSwarmRun };
|
|
410
|
+
module.exports = { cmdSwarm, cmdSwarmRun, cmdRun, cmdPythonSwarm, swarmRunAvailability };
|
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// 0dai trust — pre-run blast-radius disclosure (F19 G3 / issue #3919).
|
|
3
|
+
//
|
|
4
|
+
// Renders, before any agent run, a human-readable summary of:
|
|
5
|
+
// (a) which paths agents may write vs which are protected
|
|
6
|
+
// (b) the authority matrix in force (AUTO / PICKER / FORBIDDEN per action x CLI)
|
|
7
|
+
// (c) what data leaves the repo
|
|
8
|
+
//
|
|
9
|
+
// WARM PATH (trust_scope.py found): delegates entirely to trust_scope.py, which
|
|
10
|
+
// imports the SAME config the guards enforce. No invented data.
|
|
11
|
+
//
|
|
12
|
+
// COLD PATH (trust_scope.py absent, i.e. pre-init repo): graceful degrade —
|
|
13
|
+
// prints a clearly-labeled static DEFAULT scope summary and exits 0 (issue #4007).
|
|
14
|
+
// This avoids the dead-end exit(1) that blocked maintainers evaluating blast-radius
|
|
15
|
+
// before running `0dai init`.
|
|
16
|
+
//
|
|
17
|
+
// Usage:
|
|
18
|
+
// 0dai trust [--json] [--target PATH]
|
|
19
|
+
|
|
20
|
+
const shared = require("../shared");
|
|
21
|
+
const { log, findRepoScript, spawnSync } = shared;
|
|
22
|
+
|
|
23
|
+
// ── COLD-PATH: static default-scope payload ───────────────────────────────────
|
|
24
|
+
//
|
|
25
|
+
// Emitted when trust_scope.py is unavailable (pre-init / fresh npm install).
|
|
26
|
+
//
|
|
27
|
+
// Honesty contract — mirrors trust_scope.py's section labels:
|
|
28
|
+
// [ENFORCED] = gated right now, regardless of init state
|
|
29
|
+
// [DECLARED] = policy exists but gate materialises only after `0dai init`
|
|
30
|
+
// [DEFAULT] = structural invariant described in docs, not a config file
|
|
31
|
+
//
|
|
32
|
+
// Why we are categorical rather than listing specific globs here:
|
|
33
|
+
// policy_enforcer.py (edit-time gate source) is NOT shipped in the npm bundle
|
|
34
|
+
// (package.json "files" includes only bin/, lib/, and scripts/ where scripts/
|
|
35
|
+
// carries only postinstall.js and build-tui.js — not policy_enforcer.py).
|
|
36
|
+
// Fabricating globs that might diverge from the enforced list would violate the
|
|
37
|
+
// honesty contract. Run `0dai trust` after `0dai init` for the exact enforced list.
|
|
38
|
+
//
|
|
39
|
+
// Egress facts are derived from shared.js code (checkVersion fires unconditionally)
|
|
40
|
+
// so they are the strongest honest cold signal we can disclose.
|
|
41
|
+
|
|
42
|
+
function _buildColdPayload(target) {
|
|
43
|
+
return {
|
|
44
|
+
trust_scope_available: false,
|
|
45
|
+
scope: "default-pre-init",
|
|
46
|
+
note:
|
|
47
|
+
"trust_scope.py not found — this repo has not run `0dai init` yet. " +
|
|
48
|
+
"Showing DEFAULT scope: what 0dai does BEFORE repo-specific config exists. " +
|
|
49
|
+
"This is NOT the enforced scope. Run `0dai init` then `0dai trust` again.",
|
|
50
|
+
docs: "https://0dai.dev/docs",
|
|
51
|
+
target,
|
|
52
|
+
generated_at: new Date().toISOString().replace(/\.\d+Z$/, "Z"),
|
|
53
|
+
|
|
54
|
+
// ── A. Protected paths ────────────────────────────────────────────────
|
|
55
|
+
// Edit-time patterns come from policy_enforcer.py (not in npm bundle);
|
|
56
|
+
// merge-time patterns from ai/policy/path-protect.yaml (absent pre-init).
|
|
57
|
+
// Neither can be listed verbatim. Describe the categories honestly.
|
|
58
|
+
//
|
|
59
|
+
// NOTE: ai/policy/** and ai/contracts/** are MERGE-TIME protected (by
|
|
60
|
+
// path-protect.yaml / 0dai-merge), NOT edit-time (not in policy_enforcer.py
|
|
61
|
+
// PROTECTED_WRITE_PATTERNS). Do not conflate the two gates.
|
|
62
|
+
protected_paths: {
|
|
63
|
+
edit_time: {
|
|
64
|
+
status: "DEFAULT",
|
|
65
|
+
note:
|
|
66
|
+
"Edit-time gate is enforced by scripts/policy_enforcer.py via the 0dai MCP server. " +
|
|
67
|
+
"That file is not bundled in the npm package, so exact globs cannot be disclosed " +
|
|
68
|
+
"without a repo checkout. The categories below are indicative examples from the " +
|
|
69
|
+
"published source; run `0dai trust` after `0dai init` for the authoritative list.",
|
|
70
|
+
categories_indicative: [
|
|
71
|
+
"credential / secret files (.env, .env.*, secrets/**, infra/secrets/**)",
|
|
72
|
+
"AI runtime state (ai/meta/**, ai/work/**, ai/sessions/**, ai/feedback/**)",
|
|
73
|
+
"AI memory file (ai/memory/memory.jsonl)",
|
|
74
|
+
"Applied lock (ai/manifest/applied-lock.json)",
|
|
75
|
+
"Terraform state (terraform.tfstate*)",
|
|
76
|
+
"Legacy memory bank (memory-bank/**)",
|
|
77
|
+
],
|
|
78
|
+
},
|
|
79
|
+
merge_time: {
|
|
80
|
+
status: "DECLARED",
|
|
81
|
+
note:
|
|
82
|
+
"Merge-time gate reads ai/policy/path-protect.yaml (enforced by scripts/0dai-merge). " +
|
|
83
|
+
"Covers governance files including ai/policy/**, ai/contracts/**, and repo-specific " +
|
|
84
|
+
"protected paths declared by the maintainer. " +
|
|
85
|
+
"This file is generated by `0dai init`. Not present in a pre-init repo.",
|
|
86
|
+
},
|
|
87
|
+
},
|
|
88
|
+
|
|
89
|
+
// ── B. Agent authority matrix ─────────────────────────────────────────
|
|
90
|
+
// The per-action grant table (AUTO / PICKER / FORBIDDEN per action-id x CLI)
|
|
91
|
+
// materialises from ai/contracts/agent-authority.yaml after `0dai init`.
|
|
92
|
+
// That file is NOT in the npm bundle and cannot be verified at cold runtime,
|
|
93
|
+
// so we describe the structure categorically — not a fabricated action table.
|
|
94
|
+
authority: {
|
|
95
|
+
status: "DEFAULT",
|
|
96
|
+
note:
|
|
97
|
+
"Per-action grant table (AUTO / PICKER / FORBIDDEN) materialises from " +
|
|
98
|
+
"ai/contracts/agent-authority.yaml after `0dai init`. " +
|
|
99
|
+
"Dispatch-gate enforcement (ODAI_AUTONOMY_MATRIX_GATE=1) is opt-in, not default-on. " +
|
|
100
|
+
"Before init, no repo-specific grants are enforced.",
|
|
101
|
+
structural_description: {
|
|
102
|
+
"AUTO": "agent may proceed unattended (e.g. issue create, read, CI runs)",
|
|
103
|
+
"PICKER": "human must confirm before action executes (e.g. admin merges, deploys, protected writes)",
|
|
104
|
+
"FORBIDDEN": "action is never permitted without an explicit override (e.g. force-push to main, editing constitution)",
|
|
105
|
+
},
|
|
106
|
+
note_on_defaults:
|
|
107
|
+
"After `0dai init`, high-risk actions (admin merge, deploy, editing governance files) " +
|
|
108
|
+
"default to PICKER or FORBIDDEN. Low-risk read and CI actions default to AUTO. " +
|
|
109
|
+
"Run `0dai trust` post-init to see the exact per-agent x per-action table.",
|
|
110
|
+
},
|
|
111
|
+
|
|
112
|
+
// ── C. Egress / telemetry ─────────────────────────────────────────────
|
|
113
|
+
// Derived from shared.js (checkVersion, apiCall). These apply
|
|
114
|
+
// unconditionally on every CLI run, regardless of init state.
|
|
115
|
+
egress: {
|
|
116
|
+
status: "DEFAULT",
|
|
117
|
+
note:
|
|
118
|
+
"Derived from cli source (shared.js checkVersion + apiCall). " +
|
|
119
|
+
"Unconditional items fire on every CLI run, pre-init included.",
|
|
120
|
+
unconditional: [
|
|
121
|
+
{
|
|
122
|
+
endpoint: "GET /v1/version (api.0dai.dev)",
|
|
123
|
+
trigger:
|
|
124
|
+
"Every CLI run, throttled to at most once per " +
|
|
125
|
+
"ODAI_UPDATE_CHECK_INTERVAL (default 3600 s). Checks for CLI updates.",
|
|
126
|
+
headers_sent: [
|
|
127
|
+
"X-Device-ID — sha256 of stable local machine identifiers (no PII)",
|
|
128
|
+
"X-CLI-Version — installed CLI version string",
|
|
129
|
+
],
|
|
130
|
+
request_body: null,
|
|
131
|
+
},
|
|
132
|
+
],
|
|
133
|
+
on_explicit_command_only: [
|
|
134
|
+
{
|
|
135
|
+
endpoint: "POST /v1/graph/push",
|
|
136
|
+
trigger: "0dai graph push",
|
|
137
|
+
data: "graph-eligible artifact classes (no credentials, no file contents)",
|
|
138
|
+
},
|
|
139
|
+
{
|
|
140
|
+
endpoint: "POST /v1/report",
|
|
141
|
+
trigger: "0dai report push",
|
|
142
|
+
data: "cloud-sync-allowed artifact classes",
|
|
143
|
+
},
|
|
144
|
+
{
|
|
145
|
+
endpoint: "POST /v1/feedback",
|
|
146
|
+
trigger: "0dai feedback push",
|
|
147
|
+
data: "user_feedback_payload",
|
|
148
|
+
},
|
|
149
|
+
{
|
|
150
|
+
endpoint: "POST /v1/licenses/activate",
|
|
151
|
+
trigger: "0dai activate",
|
|
152
|
+
data: "device_fingerprint + license_code",
|
|
153
|
+
},
|
|
154
|
+
],
|
|
155
|
+
},
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Return the static default-scope output for a cold (pre-init) repo.
|
|
161
|
+
*
|
|
162
|
+
* Exported for direct unit-testing — avoids fighting findRepoScript's
|
|
163
|
+
* __dirname-anchored candidate resolution in subprocess tests.
|
|
164
|
+
*
|
|
165
|
+
* @param {string} target - Repo path shown in output (informational; not read from disk).
|
|
166
|
+
* @param {boolean} asJson - Emit JSON string instead of human-readable text.
|
|
167
|
+
* @returns {string}
|
|
168
|
+
*/
|
|
169
|
+
function renderColdScope(target, asJson) {
|
|
170
|
+
const payload = _buildColdPayload(target);
|
|
171
|
+
|
|
172
|
+
if (asJson) {
|
|
173
|
+
return JSON.stringify(payload, null, 2);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const HR = "─".repeat(64);
|
|
177
|
+
const HR2 = "═".repeat(64);
|
|
178
|
+
const lines = [];
|
|
179
|
+
|
|
180
|
+
lines.push("");
|
|
181
|
+
lines.push(HR2);
|
|
182
|
+
lines.push(" 0dai trust — pre-run blast-radius disclosure [DEFAULT (pre-init) scope]");
|
|
183
|
+
lines.push(HR2);
|
|
184
|
+
lines.push(` target: ${payload.target}`);
|
|
185
|
+
lines.push(` generated: ${payload.generated_at}`);
|
|
186
|
+
lines.push("");
|
|
187
|
+
lines.push(" NOTE trust_scope.py not found — this repo has not run `0dai init` yet.");
|
|
188
|
+
lines.push(" This output shows DEFAULT scope: what 0dai does BEFORE any");
|
|
189
|
+
lines.push(" repo-specific config exists. It is NOT the enforced scope.");
|
|
190
|
+
lines.push(" Run `0dai init`, then re-run `0dai trust` for the real scope.");
|
|
191
|
+
lines.push("");
|
|
192
|
+
lines.push(" Key: [ENFORCED] = gated now [DECLARED] = post-init only");
|
|
193
|
+
lines.push(" [DEFAULT] = structural invariant, not a config file");
|
|
194
|
+
lines.push("");
|
|
195
|
+
|
|
196
|
+
// ── A. Protected paths ────────────────────────────────────────────────────
|
|
197
|
+
lines.push(" A. Protected paths");
|
|
198
|
+
lines.push(" " + HR.slice(0, 60));
|
|
199
|
+
|
|
200
|
+
const et = payload.protected_paths.edit_time;
|
|
201
|
+
lines.push(` [${et.status}] Edit-time gate`);
|
|
202
|
+
lines.push(` ${et.note}`);
|
|
203
|
+
lines.push(" Indicative categories (run `0dai trust` post-init for the authoritative list):");
|
|
204
|
+
for (const cat of et.categories_indicative) {
|
|
205
|
+
lines.push(` - ${cat}`);
|
|
206
|
+
}
|
|
207
|
+
lines.push("");
|
|
208
|
+
|
|
209
|
+
const mt = payload.protected_paths.merge_time;
|
|
210
|
+
lines.push(` [${mt.status}] Merge-time gate`);
|
|
211
|
+
lines.push(` ${mt.note}`);
|
|
212
|
+
lines.push("");
|
|
213
|
+
|
|
214
|
+
// ── B. Agent authority matrix ─────────────────────────────────────────────
|
|
215
|
+
lines.push(" B. Agent authority matrix");
|
|
216
|
+
lines.push(" " + HR.slice(0, 60));
|
|
217
|
+
|
|
218
|
+
const auth = payload.authority;
|
|
219
|
+
lines.push(` [${auth.status}] ${auth.note}`);
|
|
220
|
+
lines.push("");
|
|
221
|
+
lines.push(" Decision levels:");
|
|
222
|
+
for (const [level, desc] of Object.entries(auth.structural_description)) {
|
|
223
|
+
lines.push(` ${level.padEnd(12)} ${desc}`);
|
|
224
|
+
}
|
|
225
|
+
lines.push("");
|
|
226
|
+
lines.push(` ${auth.note_on_defaults}`);
|
|
227
|
+
lines.push("");
|
|
228
|
+
|
|
229
|
+
// ── C. Data that leaves the repo ──────────────────────────────────────────
|
|
230
|
+
lines.push(" C. Data that leaves the repo");
|
|
231
|
+
lines.push(" " + HR.slice(0, 60));
|
|
232
|
+
|
|
233
|
+
const eg = payload.egress;
|
|
234
|
+
lines.push(` [${eg.status}] ${eg.note}`);
|
|
235
|
+
lines.push("");
|
|
236
|
+
lines.push(" Unconditional (every CLI run, pre-init included):");
|
|
237
|
+
for (const ep of eg.unconditional) {
|
|
238
|
+
lines.push(` * ${ep.endpoint}`);
|
|
239
|
+
lines.push(` trigger: ${ep.trigger}`);
|
|
240
|
+
for (const h of ep.headers_sent) {
|
|
241
|
+
lines.push(` header: ${h}`);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
lines.push("");
|
|
245
|
+
lines.push(" On explicit command only (not triggered by `0dai trust` itself):");
|
|
246
|
+
for (const ep of eg.on_explicit_command_only) {
|
|
247
|
+
lines.push(` * ${ep.endpoint}`);
|
|
248
|
+
lines.push(` trigger: ${ep.trigger}`);
|
|
249
|
+
lines.push(` data: ${ep.data}`);
|
|
250
|
+
}
|
|
251
|
+
lines.push("");
|
|
252
|
+
|
|
253
|
+
lines.push(HR);
|
|
254
|
+
lines.push(" Next steps to get the full enforced scope:");
|
|
255
|
+
lines.push(" 1. Run `0dai init` to generate ai/ config.");
|
|
256
|
+
lines.push(" 2. Run `0dai trust` again to see the repo-specific enforced scope.");
|
|
257
|
+
lines.push(` Docs: ${payload.docs}`);
|
|
258
|
+
lines.push(HR);
|
|
259
|
+
lines.push("");
|
|
260
|
+
|
|
261
|
+
return lines.join("\n");
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function cmdTrust(target, args) {
|
|
265
|
+
const scriptPath = findRepoScript(target, "trust_scope.py");
|
|
266
|
+
|
|
267
|
+
// COLD PATH — trust_scope.py not found (pre-init / cold npm install).
|
|
268
|
+
// Graceful degrade: print static DEFAULT scope summary and exit 0.
|
|
269
|
+
// Clearly labeled as "DEFAULT (pre-init) scope" — cannot be mistaken for
|
|
270
|
+
// the repo-specific enforced scope rendered by trust_scope.py (WARM path).
|
|
271
|
+
if (!scriptPath) {
|
|
272
|
+
const asJson = !!(args && args.includes("--json"));
|
|
273
|
+
console.log(renderColdScope(target, asJson));
|
|
274
|
+
process.exit(0);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// WARM PATH — delegate to trust_scope.py (behaviour unchanged).
|
|
278
|
+
const forwarded = [scriptPath, "--target", target];
|
|
279
|
+
if (args && args.includes("--json")) forwarded.push("--json");
|
|
280
|
+
|
|
281
|
+
const result = spawnSync("python3", forwarded, { stdio: "inherit" });
|
|
282
|
+
if (typeof result.status === "number") process.exit(result.status);
|
|
283
|
+
process.exit(1);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
module.exports = { cmdTrust, renderColdScope };
|